/** * Annulus geometry for the two breakdown donuts. Pure, so the arithmetic that * decides whether a slice closes correctly is testable without a DOM. */ export interface DonutSlice { /** Semantic identity: the React key, the colour key and the legend's identity. */ key: string; label: string; /** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */ secondary?: string; value: number; color: string; } export interface DonutArc { slice: DonutSlice; /** Of the whole, 0 to 1. */ share: number; d: string; } export interface DonutLayout { size: number; total: number; arcs: DonutArc[]; } const START_ANGLE = -Math.PI / 2; function point(center: number, radius: number, angle: number): string { return `${(center + radius * Math.cos(angle)).toFixed(3)} ${(center + radius * Math.sin(angle)).toFixed(3)}`; } /** * A whole-circle slice cannot be drawn as one arc — start and end coincide, and * the renderer draws nothing at all — so the ring is two half arcs. */ function fullRing(center: number, outer: number, inner: number): string { const top = `${center} ${center - outer}`; const bottom = `${center} ${center + outer}`; const innerTop = `${center} ${center - inner}`; const innerBottom = `${center} ${center + inner}`; return [ `M ${top}`, `A ${outer} ${outer} 0 0 1 ${bottom}`, `A ${outer} ${outer} 0 0 1 ${top}`, `M ${innerTop}`, `A ${inner} ${inner} 0 0 0 ${innerBottom}`, `A ${inner} ${inner} 0 0 0 ${innerTop}`, "Z", ].join(" "); } /** * Slices in the order given — the caller has already ranked them — starting at * twelve o'clock and running clockwise. Zero-valued slices are dropped: they * have no arc to draw, and a legend entry reading 0 is noise. */ export function layoutDonut(slices: DonutSlice[], size: number, thickness: number): DonutLayout { const drawn = slices.filter((slice) => slice.value > 0); const total = drawn.reduce((sum, slice) => sum + slice.value, 0); if (total <= 0) return { size, total: 0, arcs: [] }; const center = size / 2; const outer = center; const inner = Math.max(0, center - thickness); if (drawn.length === 1) { return { size, total, arcs: [{ slice: drawn[0], share: 1, d: fullRing(center, outer, inner) }], }; } let angle = START_ANGLE; const arcs = drawn.map((slice) => { const share = slice.value / total; const sweep = share * Math.PI * 2; const end = angle + sweep; const large = sweep > Math.PI ? 1 : 0; const d = [ `M ${point(center, outer, angle)}`, `A ${outer} ${outer} 0 ${large} 1 ${point(center, outer, end)}`, `L ${point(center, inner, end)}`, `A ${inner} ${inner} 0 ${large} 0 ${point(center, inner, angle)}`, "Z", ].join(" "); angle = end; return { slice, share, d }; }); return { size, total, arcs }; }