diff --git a/src/components/team/AgentRing.tsx b/src/components/team/AgentRing.tsx
index 52d1a3d..8fb74c8 100644
--- a/src/components/team/AgentRing.tsx
+++ b/src/components/team/AgentRing.tsx
@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react'
import { Box } from '@mui/material'
import type { AgentDirectoryEntry } from '../../domain/agentDirectory'
import type { ArcOptions, RingSpec } from './agentRingConfig'
-import { layoutArc } from './agentRingConfig'
+import { RING_REFERENCE_PX, labelWidthFor, layoutArc } from './agentRingConfig'
import { AgentRingNode } from './AgentRingNode'
import { DS_BORDER } from '../../lib/ds'
@@ -40,12 +40,22 @@ export const AgentRing = memo(function AgentRing({
[agents.length, spec.radius, arc],
)
+ const labelWidth = useMemo(
+ () => labelWidthFor(agents.length, spec, scale * RING_REFERENCE_PX, arc),
+ [agents.length, spec, scale, arc],
+ )
+
return (
)}
@@ -81,6 +93,7 @@ export const AgentRing = memo(function AgentRing({
agent={agent}
spec={spec}
scale={scale}
+ labelWidth={labelWidth}
leftPct={position.leftPct}
topPct={position.topPct}
selected={agent.id === selectedId}
diff --git a/src/components/team/AgentRingNode.tsx b/src/components/team/AgentRingNode.tsx
index d3e7685..c90a6c6 100644
--- a/src/components/team/AgentRingNode.tsx
+++ b/src/components/team/AgentRingNode.tsx
@@ -2,6 +2,7 @@ import { memo, useState } from 'react'
import { Box, Tooltip, Typography } from '@mui/material'
import type { AgentDirectoryEntry } from '../../domain/agentDirectory'
import type { RingSpec } from './agentRingConfig'
+import { RING_REFERENCE_PX, avatarPxFor } from './agentRingConfig'
import { AgentAvatar } from './AgentAvatar'
import { BADGE_COLORS, DS_BORDER, DS_TEXT } from '../../lib/ds'
@@ -10,13 +11,24 @@ interface Props {
spec: RingSpec
/** Skalierungsfaktor gegenüber der Referenzbreite. */
scale: number
+ /** Vom Ring vorgegeben, begrenzt durch den Bogenabstand zum Nachbarn. */
+ labelWidth: number
leftPct: number
topPct: number
selected?: boolean
onSelect: (agent: AgentDirectoryEntry) => void
}
-const MIN_AVATAR_PX = 30
+/**
+ * Schriftgrössen als Anteil des Portraits statt der Containerbreite: nur so
+ * wachsen Bild und Beschriftung gemeinsam, wenn die Grafik bei wenigen Ringen
+ * vergrössert wird. Die Werte entsprechen dem bisherigen Verhältnis (11/76 und
+ * 9.5/76) und lassen die volle Darstellung unverändert.
+ */
+const NAME_FONT_RATIO = 11 / 76
+const ROLE_FONT_RATIO = 9.5 / 76
+const MIN_NAME_FONT_PX = 9.5
+const MIN_ROLE_FONT_PX = 8.5
/**
* Ein Portrait auf dem Ring.
@@ -33,15 +45,17 @@ export const AgentRingNode = memo(function AgentRingNode({
agent,
spec,
scale,
+ labelWidth,
leftPct,
topPct,
selected = false,
onSelect,
}: Props) {
const [active, setActive] = useState(false)
- const px = Math.max(MIN_AVATAR_PX, Math.round(spec.avatarPx * scale))
+ const px = avatarPxFor(spec, scale * RING_REFERENCE_PX)
const showRole = spec.showRole || active || selected
- const labelWidth = Math.round(px * 2.1)
+ const nameFontPx = Math.max(MIN_NAME_FONT_PX, px * NAME_FONT_RATIO)
+ const roleFontPx = Math.max(MIN_ROLE_FONT_PX, px * ROLE_FONT_RATIO)
return (
= { 1: 0.72, 2: 0.84, 3: 0.92, 4: 1 }
-
/** Platz für Seitentitel, Stufenfilter und Aussenabstände. */
const VIEWPORT_RESERVE_PX = 250
@@ -68,13 +72,18 @@ export function TeamOverviewRing({ level }: Props) {
const visibleUpTo = AGENT_LEVEL_ORDER.indexOf(level)
/**
- * Je mehr Ringe sichtbar sind, desto kleiner die Grafik: sonst wächst sie mit
- * jeder Stufe aus dem Bild heraus und die Seite wird länger. Zusätzlich
- * begrenzt die Viewport-Höhe, damit das Kernteam ohne Scrollen vollständig
- * sichtbar bleibt.
+ * Die Fläche bleibt auf jeder Stufe gleich gross; vergrössert wird stattdessen
+ * die Zeichnung, bis der äusserste sichtbare Ring an den Rand kommt. Die
+ * Viewport-Höhe begrenzt zusätzlich, damit die Grafik ohne Scrollen
+ * vollständig sichtbar bleibt.
*/
- const ringScale = RING_COUNT_SCALE[visibleUpTo + 1] ?? RING_COUNT_SCALE[4]
- const boxSize = `min(100%, ${Math.round(RING_REFERENCE_PX * ringScale)}px, calc(100vh - ${VIEWPORT_RESERVE_PX}px))`
+ const boxSize = `min(100%, ${RING_REFERENCE_PX}px, calc(100vh - ${VIEWPORT_RESERVE_PX}px))`
+
+ const specs = useMemo(() => zoomedRingSpecs(visibleUpTo), [visibleUpTo])
+
+ // Die Nabe folgt dem Kernring, statt eine feste Prozentzahl der Fläche zu sein.
+ const hubPx = hubDiameterPx(specs, width)
+ const showHubCaption = hubPx >= HUB_CAPTION_MIN_PX
const agentsByLevel = useMemo(() => {
const map = new Map()
@@ -122,11 +131,9 @@ export function TeamOverviewRing({ level }: Props) {
width: boxSize,
aspectRatio: '1 / 1',
mx: 'auto',
- transition: 'width 0.35s ease',
- '@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
>
- {RING_SPECS.map((spec, index) => (
+ {specs.map((spec, index) => (
))}
- {/* Nabe */}
+ {/* Nabe — bewusst unter den Ringen, damit sie keine Beschriftung deckt. */}
@@ -171,16 +181,20 @@ export function TeamOverviewRing({ level }: Props) {
{agentDirectory.length}
-
- digitale Mitarbeitende
-
+ {showHubCaption && (
+
+ digitale Mitarbeitende
+
+ )}
diff --git a/src/components/team/agentRingConfig.ts b/src/components/team/agentRingConfig.ts
index 8b87237..1634281 100644
--- a/src/components/team/agentRingConfig.ts
+++ b/src/components/team/agentRingConfig.ts
@@ -28,9 +28,9 @@ export interface RingSpec {
export const RING_REFERENCE_PX = 940
export const RING_SPECS: RingSpec[] = [
- { level: AgentLevel.CORE, radius: 0.30, avatarPx: 76, showRole: true, dashed: false },
- { level: AgentLevel.CENTRAL_SERVICES, radius: 0.52, avatarPx: 54, showRole: false, dashed: false },
- { level: AgentLevel.COMMERCIAL, radius: 0.72, avatarPx: 54, showRole: false, dashed: false },
+ { level: AgentLevel.CORE, radius: 0.38, avatarPx: 76, showRole: true, dashed: false },
+ { level: AgentLevel.CENTRAL_SERVICES, radius: 0.60, avatarPx: 54, showRole: false, dashed: false },
+ { level: AgentLevel.COMMERCIAL, radius: 0.76, avatarPx: 54, showRole: false, dashed: false },
{ level: AgentLevel.RESIDENTIAL, radius: 0.90, avatarPx: 50, showRole: false, dashed: true },
]
@@ -38,6 +38,109 @@ export function ringSpecFor(level: AgentLevel): RingSpec {
return RING_SPECS.find(s => s.level === level) ?? RING_SPECS[RING_SPECS.length - 1]
}
+/** Kleinstes Portrait; darunter ist ein Gesicht nicht mehr zu erkennen. */
+export const MIN_AVATAR_PX = 30
+
+/**
+ * Anteil der halben Kante, auf den der äusserste *sichtbare* Ring rückt.
+ *
+ * Nach Anzahl sichtbarer Ringe gestaffelt: sind wenige Ringe zu sehen, wachsen
+ * die Portraits und damit die Beschriftungen — dann braucht der Rand mehr Platz,
+ * sonst schreiben die äusseren Namen über die Kante hinaus.
+ */
+const OUTER_RING_TARGETS = [0.72, 0.84, 0.86, 0.88]
+
+/**
+ * Dämpfung, mit der Portraits der Vergrösserung folgen.
+ *
+ * Ungedämpft läge der Faktor bei einem einzigen sichtbaren Ring bei rund 1,9 —
+ * sieben Portraits dieser Grösse klebten den Kreis zu.
+ */
+const AVATAR_ZOOM_DAMPING = 0.45
+
+/** Anteil des freien Raums zwischen Mittelpunkt und Kernring, den die Nabe füllt. */
+const HUB_CLEARANCE = 0.52
+
+/** Kleinste Nabe; darunter ist die Zahl in der Mitte nicht mehr lesbar. */
+export const HUB_MIN_PX = 96
+
+/** Ab dieser Nabengrösse trägt sie zusätzlich die erläuternde Zeile. */
+export const HUB_CAPTION_MIN_PX = 130
+
+/** Schriftgrössen der Nabe als Anteil ihres Durchmessers. */
+export const HUB_FONT_RATIO = { brand: 0.073, count: 0.224, caption: 0.056 } as const
+
+/** Wunschbreite der Beschriftung, gemessen am Portrait. */
+const LABEL_WIDTH_RATIO = 2.1
+
+/** Anteil des Bogenabstands, den eine Beschriftung höchstens einnehmen darf. */
+const LABEL_ARC_FILL = 0.96
+
+/** Darunter bricht jeder Name unbrauchbar um. */
+const MIN_LABEL_WIDTH_PX = 46
+
+/**
+ * Vergrössert die sichtbaren Ringe, bis der äusserste von ihnen die Fläche füllt.
+ *
+ * Ohne das zeichnet die Stufe «Kernteam» ihre sieben Portraits auf den innersten
+ * Ring und lässt zwei Drittel des Quadrats leer — Platz, der für Ringe reserviert
+ * ist, die gar nicht dargestellt werden. Verborgene Ringe bleiben unberührt: sie
+ * sind unsichtbar, und unvergrössert bleiben sie innerhalb der Fläche.
+ */
+export function zoomedRingSpecs(visibleUpTo: number): RingSpec[] {
+ const index = Math.min(Math.max(visibleUpTo, 0), RING_SPECS.length - 1)
+ const zoom = OUTER_RING_TARGETS[index] / RING_SPECS[index].radius
+ const avatarZoom = 1 + (zoom - 1) * AVATAR_ZOOM_DAMPING
+
+ return RING_SPECS.map((spec, i) =>
+ i <= index
+ ? { ...spec, radius: spec.radius * zoom, avatarPx: spec.avatarPx * avatarZoom }
+ : spec,
+ )
+}
+
+/** Portraitdurchmesser eines Rings bei gegebener Containerbreite. */
+export function avatarPxFor(spec: RingSpec, containerPx: number): number {
+ return Math.max(MIN_AVATAR_PX, Math.round((spec.avatarPx * containerPx) / RING_REFERENCE_PX))
+}
+
+/**
+ * Nabendurchmesser aus der Geometrie statt als feste Prozentzahl.
+ *
+ * Die Nabe füllt einen festen Anteil des Raums, der zwischen Mittelpunkt und
+ * innerem Portraitrand tatsächlich frei ist. Damit kann sie die Beschriftungen
+ * des Kernrings nicht mehr überdecken — was mit den früheren starren 19 % der
+ * Fall war, sobald nur das Kernteam sichtbar war.
+ */
+export function hubDiameterPx(specs: RingSpec[], containerPx: number): number {
+ const inner = specs[0]
+ const ringPx = (inner.radius * containerPx) / 2
+ const avatarRadiusPx = avatarPxFor(inner, containerPx) / 2
+ return Math.max(HUB_MIN_PX, (ringPx - avatarRadiusPx) * 2 * HUB_CLEARANCE)
+}
+
+/**
+ * Breite einer Beschriftung, begrenzt durch den Bogenabstand zum Nachbarn.
+ *
+ * Ohne diese Grenze überlappen sich auf engen Ringen die Namen gegenseitig und
+ * reichen bis in die Nabe hinein.
+ */
+export function labelWidthFor(
+ count: number,
+ spec: RingSpec,
+ containerPx: number,
+ { sweepDeg = 360, closed = true }: ArcOptions = {},
+): number {
+ const wish = avatarPxFor(spec, containerPx) * LABEL_WIDTH_RATIO
+ if (count <= 1) return wish
+
+ const radiusPx = (spec.radius * containerPx) / 2
+ const divisor = closed ? count : count - 1
+ const spacingPx = (2 * Math.PI * radiusPx * (sweepDeg / 360)) / divisor
+
+ return Math.max(MIN_LABEL_WIDTH_PX, Math.min(wish, spacingPx * LABEL_ARC_FILL))
+}
+
export interface RingNodePosition {
/** Position in Prozent der Containerkante, bezogen auf die Mitte. */
leftPct: number