fix(team): Kreisgrafik fuellt die Flaeche und deckt keine Namen mehr zu
Die Darstellung reservierte Platz fuer Ringe, die gar nicht gezeichnet wurden: das Kernteam sass auf dem innersten Ring, zwei Drittel des Quadrats blieben leer. Neu rueckt der aeusserste sichtbare Ring an den Rand, Portraits und Schrift wachsen gedaempft mit. Zweitens verdeckte die Nabe die Beschriftungen. Der Transform am Ring eroeffnet einen eigenen Stacking-Context, in dem das zIndex der Portraits gefangen blieb; die spaeter im DOM stehende Nabe gewann. Der Ring traegt nun selbst ein zIndex. Die Nabe ergibt sich ausserdem aus dem tatsaechlich freien Raum bis zum Kernring statt aus starren 19 Prozent, und Beschriftungen sind auf den Bogenabstand zum Nachbarn begrenzt, damit sie sich nicht ueberschreiben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react'
|
|||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import type { AgentDirectoryEntry } from '../../domain/agentDirectory'
|
import type { AgentDirectoryEntry } from '../../domain/agentDirectory'
|
||||||
import type { ArcOptions, RingSpec } from './agentRingConfig'
|
import type { ArcOptions, RingSpec } from './agentRingConfig'
|
||||||
import { layoutArc } from './agentRingConfig'
|
import { RING_REFERENCE_PX, labelWidthFor, layoutArc } from './agentRingConfig'
|
||||||
import { AgentRingNode } from './AgentRingNode'
|
import { AgentRingNode } from './AgentRingNode'
|
||||||
import { DS_BORDER } from '../../lib/ds'
|
import { DS_BORDER } from '../../lib/ds'
|
||||||
|
|
||||||
@@ -40,12 +40,22 @@ export const AgentRing = memo(function AgentRing({
|
|||||||
[agents.length, spec.radius, arc],
|
[agents.length, spec.radius, arc],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const labelWidth = useMemo(
|
||||||
|
() => labelWidthFor(agents.length, spec, scale * RING_REFERENCE_PX, arc),
|
||||||
|
[agents.length, spec, scale, arc],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
aria-hidden={!visible}
|
aria-hidden={!visible}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
// Der Transform unten eröffnet einen eigenen Stacking-Context, in dem das
|
||||||
|
// zIndex der Portraits gefangen bleibt. Ohne dieses zIndex am Ring selbst
|
||||||
|
// gewinnt die später im DOM stehende Nabe und schneidet die
|
||||||
|
// Beschriftungen ab.
|
||||||
|
zIndex: 1,
|
||||||
opacity: visible ? 1 : 0,
|
opacity: visible ? 1 : 0,
|
||||||
transform: visible ? 'scale(1)' : 'scale(0.94)',
|
transform: visible ? 'scale(1)' : 'scale(0.94)',
|
||||||
pointerEvents: visible ? 'auto' : 'none',
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
@@ -68,6 +78,8 @@ export const AgentRing = memo(function AgentRing({
|
|||||||
? `1.5px dashed ${DS_BORDER.strong}`
|
? `1.5px dashed ${DS_BORDER.strong}`
|
||||||
: `1px solid ${DS_BORDER.muted}`,
|
: `1px solid ${DS_BORDER.muted}`,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
transition: 'width 0.35s ease, height 0.35s ease',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -81,6 +93,7 @@ export const AgentRing = memo(function AgentRing({
|
|||||||
agent={agent}
|
agent={agent}
|
||||||
spec={spec}
|
spec={spec}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
|
labelWidth={labelWidth}
|
||||||
leftPct={position.leftPct}
|
leftPct={position.leftPct}
|
||||||
topPct={position.topPct}
|
topPct={position.topPct}
|
||||||
selected={agent.id === selectedId}
|
selected={agent.id === selectedId}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { memo, useState } from 'react'
|
|||||||
import { Box, Tooltip, Typography } from '@mui/material'
|
import { Box, Tooltip, Typography } from '@mui/material'
|
||||||
import type { AgentDirectoryEntry } from '../../domain/agentDirectory'
|
import type { AgentDirectoryEntry } from '../../domain/agentDirectory'
|
||||||
import type { RingSpec } from './agentRingConfig'
|
import type { RingSpec } from './agentRingConfig'
|
||||||
|
import { RING_REFERENCE_PX, avatarPxFor } from './agentRingConfig'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import { BADGE_COLORS, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { BADGE_COLORS, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
@@ -10,13 +11,24 @@ interface Props {
|
|||||||
spec: RingSpec
|
spec: RingSpec
|
||||||
/** Skalierungsfaktor gegenüber der Referenzbreite. */
|
/** Skalierungsfaktor gegenüber der Referenzbreite. */
|
||||||
scale: number
|
scale: number
|
||||||
|
/** Vom Ring vorgegeben, begrenzt durch den Bogenabstand zum Nachbarn. */
|
||||||
|
labelWidth: number
|
||||||
leftPct: number
|
leftPct: number
|
||||||
topPct: number
|
topPct: number
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
onSelect: (agent: AgentDirectoryEntry) => void
|
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.
|
* Ein Portrait auf dem Ring.
|
||||||
@@ -33,15 +45,17 @@ export const AgentRingNode = memo(function AgentRingNode({
|
|||||||
agent,
|
agent,
|
||||||
spec,
|
spec,
|
||||||
scale,
|
scale,
|
||||||
|
labelWidth,
|
||||||
leftPct,
|
leftPct,
|
||||||
topPct,
|
topPct,
|
||||||
selected = false,
|
selected = false,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [active, setActive] = useState(false)
|
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 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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -70,7 +84,9 @@ export const AgentRingNode = memo(function AgentRingNode({
|
|||||||
font: 'inherit',
|
font: 'inherit',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
zIndex: active || selected ? 3 : 1,
|
zIndex: active || selected ? 3 : 1,
|
||||||
transition: 'transform 0.2s ease',
|
// left/top wandern beim Stufenwechsel mit, weil sich der Ringradius
|
||||||
|
// ändert — ohne Übergang springen die Portraits an ihren neuen Platz.
|
||||||
|
transition: 'transform 0.2s ease, left 0.35s ease, top 0.35s ease',
|
||||||
'&:hover, &:focus-visible': { transform: 'translate(-50%, -50%) scale(1.06)' },
|
'&:hover, &:focus-visible': { transform: 'translate(-50%, -50%) scale(1.06)' },
|
||||||
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 4, borderRadius: 2 },
|
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 4, borderRadius: 2 },
|
||||||
'@media (prefers-reduced-motion: reduce)': {
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
@@ -100,7 +116,7 @@ export const AgentRingNode = memo(function AgentRingNode({
|
|||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: `${Math.max(9.5, 11 * scale)}px`,
|
fontSize: `${nameFontPx}px`,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
color: DS_TEXT.primary,
|
color: DS_TEXT.primary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@@ -112,7 +128,7 @@ export const AgentRingNode = memo(function AgentRingNode({
|
|||||||
{/* Höhe wird reserviert, damit die Ringe beim Hover nicht springen */}
|
{/* Höhe wird reserviert, damit die Ringe beim Hover nicht springen */}
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: `${Math.max(8.5, 9.5 * scale)}px`,
|
fontSize: `${roleFontPx}px`,
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
color: DS_TEXT.secondary,
|
color: DS_TEXT.secondary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { AGENT_LEVEL_ORDER } from '../../domain/agentDirectory'
|
|||||||
import { agentDirectory } from '../../mock-data/agentDirectory'
|
import { agentDirectory } from '../../mock-data/agentDirectory'
|
||||||
import { AgentRing } from './AgentRing'
|
import { AgentRing } from './AgentRing'
|
||||||
import { AgentPreviewPopover } from './AgentPreviewPopover'
|
import { AgentPreviewPopover } from './AgentPreviewPopover'
|
||||||
import { RING_SPECS, RING_REFERENCE_PX } from './agentRingConfig'
|
import {
|
||||||
|
RING_SPECS,
|
||||||
|
RING_REFERENCE_PX,
|
||||||
|
HUB_CAPTION_MIN_PX,
|
||||||
|
HUB_FONT_RATIO,
|
||||||
|
hubDiameterPx,
|
||||||
|
zoomedRingSpecs,
|
||||||
|
} from './agentRingConfig'
|
||||||
import { ROUTES } from '../../lib/constants'
|
import { ROUTES } from '../../lib/constants'
|
||||||
import { BADGE_COLORS, DS_TEXT } from '../../lib/ds'
|
import { BADGE_COLORS, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
@@ -15,9 +22,6 @@ interface Props {
|
|||||||
level: AgentLevel
|
level: AgentLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verkleinerung nach Anzahl sichtbarer Ringe. */
|
|
||||||
const RING_COUNT_SCALE: Record<number, number> = { 1: 0.72, 2: 0.84, 3: 0.92, 4: 1 }
|
|
||||||
|
|
||||||
/** Platz für Seitentitel, Stufenfilter und Aussenabstände. */
|
/** Platz für Seitentitel, Stufenfilter und Aussenabstände. */
|
||||||
const VIEWPORT_RESERVE_PX = 250
|
const VIEWPORT_RESERVE_PX = 250
|
||||||
|
|
||||||
@@ -68,13 +72,18 @@ export function TeamOverviewRing({ level }: Props) {
|
|||||||
const visibleUpTo = AGENT_LEVEL_ORDER.indexOf(level)
|
const visibleUpTo = AGENT_LEVEL_ORDER.indexOf(level)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Je mehr Ringe sichtbar sind, desto kleiner die Grafik: sonst wächst sie mit
|
* Die Fläche bleibt auf jeder Stufe gleich gross; vergrössert wird stattdessen
|
||||||
* jeder Stufe aus dem Bild heraus und die Seite wird länger. Zusätzlich
|
* die Zeichnung, bis der äusserste sichtbare Ring an den Rand kommt. Die
|
||||||
* begrenzt die Viewport-Höhe, damit das Kernteam ohne Scrollen vollständig
|
* Viewport-Höhe begrenzt zusätzlich, damit die Grafik ohne Scrollen
|
||||||
* sichtbar bleibt.
|
* vollständig sichtbar bleibt.
|
||||||
*/
|
*/
|
||||||
const ringScale = RING_COUNT_SCALE[visibleUpTo + 1] ?? RING_COUNT_SCALE[4]
|
const boxSize = `min(100%, ${RING_REFERENCE_PX}px, calc(100vh - ${VIEWPORT_RESERVE_PX}px))`
|
||||||
const boxSize = `min(100%, ${Math.round(RING_REFERENCE_PX * ringScale)}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 agentsByLevel = useMemo(() => {
|
||||||
const map = new Map<AgentLevel, AgentDirectoryEntry[]>()
|
const map = new Map<AgentLevel, AgentDirectoryEntry[]>()
|
||||||
@@ -122,11 +131,9 @@ export function TeamOverviewRing({ level }: Props) {
|
|||||||
width: boxSize,
|
width: boxSize,
|
||||||
aspectRatio: '1 / 1',
|
aspectRatio: '1 / 1',
|
||||||
mx: 'auto',
|
mx: 'auto',
|
||||||
transition: 'width 0.35s ease',
|
|
||||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{RING_SPECS.map((spec, index) => (
|
{specs.map((spec, index) => (
|
||||||
<AgentRing
|
<AgentRing
|
||||||
key={spec.level}
|
key={spec.level}
|
||||||
agents={agentsByLevel.get(spec.level) ?? []}
|
agents={agentsByLevel.get(spec.level) ?? []}
|
||||||
@@ -137,15 +144,16 @@ export function TeamOverviewRing({ level }: Props) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Nabe */}
|
{/* Nabe — bewusst unter den Ringen, damit sie keine Beschriftung deckt. */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
top: '50%',
|
top: '50%',
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
width: '19%',
|
zIndex: 0,
|
||||||
height: '19%',
|
width: hubPx,
|
||||||
|
height: hubPx,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: DS_TEXT.brand,
|
bgcolor: DS_TEXT.brand,
|
||||||
color: DS_TEXT.inverted,
|
color: DS_TEXT.inverted,
|
||||||
@@ -155,13 +163,15 @@ export function TeamOverviewRing({ level }: Props) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
px: 1,
|
px: 1,
|
||||||
|
transition: 'width 0.35s ease, height 0.35s ease',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
fontSize: `${Math.max(9, 13 * scale)}px`,
|
fontSize: `${Math.max(9, hubPx * HUB_FONT_RATIO.brand)}px`,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -171,16 +181,20 @@ export function TeamOverviewRing({ level }: Props) {
|
|||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: `${Math.max(20, 40 * scale)}px`,
|
fontSize: `${Math.max(20, hubPx * HUB_FONT_RATIO.count)}px`,
|
||||||
lineHeight: 1.05,
|
lineHeight: 1.05,
|
||||||
mt: 0.25,
|
mt: 0.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{agentDirectory.length}
|
{agentDirectory.length}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: `${Math.max(7.5, 10 * scale)}px`, lineHeight: 1.25, opacity: 0.85 }}>
|
{showHubCaption && (
|
||||||
digitale Mitarbeitende
|
<Typography
|
||||||
</Typography>
|
sx={{ fontSize: `${hubPx * HUB_FONT_RATIO.caption}px`, lineHeight: 1.25, opacity: 0.85 }}
|
||||||
|
>
|
||||||
|
digitale Mitarbeitende
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export interface RingSpec {
|
|||||||
export const RING_REFERENCE_PX = 940
|
export const RING_REFERENCE_PX = 940
|
||||||
|
|
||||||
export const RING_SPECS: RingSpec[] = [
|
export const RING_SPECS: RingSpec[] = [
|
||||||
{ level: AgentLevel.CORE, radius: 0.30, avatarPx: 76, showRole: true, dashed: false },
|
{ level: AgentLevel.CORE, radius: 0.38, avatarPx: 76, showRole: true, dashed: false },
|
||||||
{ level: AgentLevel.CENTRAL_SERVICES, radius: 0.52, avatarPx: 54, showRole: false, dashed: false },
|
{ level: AgentLevel.CENTRAL_SERVICES, radius: 0.60, avatarPx: 54, showRole: false, dashed: false },
|
||||||
{ level: AgentLevel.COMMERCIAL, radius: 0.72, 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 },
|
{ 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]
|
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 {
|
export interface RingNodePosition {
|
||||||
/** Position in Prozent der Containerkante, bezogen auf die Mitte. */
|
/** Position in Prozent der Containerkante, bezogen auf die Mitte. */
|
||||||
leftPct: number
|
leftPct: number
|
||||||
|
|||||||
Reference in New Issue
Block a user