import { useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react'; import clsx from 'clsx'; import { useLaikit } from '../../provider.js'; import TitleCard from '../TitleCard/index.js'; import Skeleton from '../Skeleton/index.js'; import DataState from '../DataState/index.js'; import { formatCompact } from '../../utils/format.js'; import styles from './styles.module.css'; export interface DonutRow { x: string; y: number; } export interface DonutProps { title: string; icon: string; items: DonutRow[]; loading?: boolean; emptyText: string; // Set when the source fetch failed, so an unreachable endpoint reads as an // error rather than as a legitimately empty dataset. error?: string; errorAction?: ReactNode; notice?: ReactNode; // Collapse everything past the top (maxSlices - 1) into a neutral "Other" // slice, so a dominant category never shreds the ring into slivers. Also fixes // the row count so the legend area — and the skeleton — reserve equal height. maxSlices?: number; otherLabel?: string; renderLabel?: (x: string) => ReactNode; formatValue?: (y: number) => string; } // Ring geometry in a 100×100 viewBox; pathLength normalises the circumference to // 100 so a slice's arc length equals its percentage. const R = 42; const SW = 10; const SW_ACTIVE = 12; // Blank between slices, in pathLength units — one gap per boundary. The whole // gap budget is removed from the circumference up front (see `available`), so no // single arc is shortened out of proportion; the gap just reveals the card // background as a hairline separator. const GAP = 1; // Single-hue ramp for the real slices: the largest is the vivid brand primary, // the rest tint toward the card surface. One hue keeps it unified with the site; // the aggregated "Other" slice is a neutral grey, set apart from the ramp. function sliceColor(i: number, n: number): string { const strength = n <= 1 ? 100 : 100 - (i / (n - 1)) * 45; return `color-mix(in srgb, var(--lk-color-primary) ${strength}%, var(--lk-card-background-color))`; } interface Slice { key: string; y: number; label: ReactNode; color: string; } export default function Donut({ title, icon, items, loading, emptyText, error, errorAction, notice, maxSlices, otherLabel, renderLabel, formatValue, }: DonutProps) { const { locale, messages } = useLaikit(); const [active, setActive] = useState(null); const plotRef = useRef(null); const format = formatValue ?? ((n: number) => formatCompact(n, locale)); const label = (x: string) => (renderLabel ? renderLabel(x) : x); const sorted = [...items].sort((a, b) => b.y - a.y); const total = sorted.reduce((s, d) => s + d.y, 0); // Fold the long tail into one "Other" slice (only when it merges ≥2 items). let reals = sorted; let otherY = 0; if (maxSlices && sorted.length > maxSlices) { const tail = sorted.slice(maxSlices - 1); if (tail.length >= 2) { reals = sorted.slice(0, maxSlices - 1); otherY = tail.reduce((s, d) => s + d.y, 0); } } const slices: Slice[] = reals.map((d, i) => ({ key: d.x, y: d.y, label: label(d.x), color: sliceColor(i, reals.length), })); if (otherY > 0) { slices.push({ key: '__other__', y: otherY, label: otherLabel ?? messages.other, color: 'var(--lk-color-emphasis-400)', }); } let acc = 0; const segments = slices.map((s) => { const frac = total > 0 ? s.y / total : 0; const seg = { ...s, frac, start: acc }; acc += frac; return seg; }); const gap = segments.length > 1 ? GAP : 0; // Circumference left for the arcs after reserving one gap per boundary; sharing // it by fraction keeps every arc exactly proportional (D3 padAngle style). const available = 100 - segments.length * gap; const activeSeg = active != null ? segments[active] : null; // Hover is hit-tested by angle over the ring band: each slice owns its arc plus // half the gap on both sides, so circling the ring never drops into a dead gap; // the hole and anything outside the band clear the selection. const onPlotMove = (e: ReactPointerEvent) => { const el = plotRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const px = ((e.clientX - rect.left) / rect.width) * 100 - 50; const py = ((e.clientY - rect.top) / rect.height) * 100 - 50; if (Math.hypot(px, py) < R - SW || Math.hypot(px, py) > R + SW) { setActive(null); return; } // Position 0–100 clockwise from 12 o'clock, matching the arc layout. const pos = ((((Math.atan2(px, -py) / (2 * Math.PI)) % 1) + 1) % 1) * 100; const i = segments.findIndex((seg, idx) => { const lo = seg.start * available + idx * gap; return pos >= lo && pos < lo + seg.frac * available + gap; }); setActive(i >= 0 ? i : null); }; // Reserve legend height for the full slice count so short cards (e.g. Devices) // stay the same height as the others and nothing shifts on data load. const rows = maxSlices ?? segments.length; return ( {notice} {loading ? (
    {Array.from({ length: rows }).map((_, i) => (
  • ))}
) : error ? ( ) : total === 0 ? ( ) : (
setActive(null)} >
{activeSeg ? format(activeSeg.y) : format(total)}
    {segments.map((seg, i) => (
  • setActive(i)} onPointerLeave={() => setActive(null)} > {seg.label} {(seg.frac * 100).toFixed(1)}%
  • ))}
)}
); }