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