import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { cx } from '@/lib/cx'; // Graphiques de l'écran Statistiques — SVG à la main (aucune bibliothèque de // graphiques dans le projet). Règles suivies : marques fines (barres ≤ 24 px, // bout arrondi de 4 px côté valeur, carré côté ligne de base ; courbes de // 2 px), séparation par un vide de 2 px couleur de fond (jamais de bordure), // quadrillage en filet plein discret, un seul axe par graphique, texte // toujours à l'encre (jamais dans la couleur de la série), infobulle au // survol et au focus — qui ne fait que compléter : chaque graphique a sa vue // en tableau (`TableToggle`). Palette catégorielle vérifiée (daltonisme, // séparation, contraste) sur le fond `paper` : l'aqua est sous 3:1, d'où // libellés directs + tableau. export const SERIES = { blue: '#2a78d6', orange: '#eb6834', aqua: '#1baf7a', /** « Rien / aucun » : gris de mise en retrait, jamais une série à part entière. */ muted: '#c9c3b3', } as const; const GRID = '#E0D8C4'; // sand-line : un cran au-dessus du fond paper const SURFACE = '#FFFDF7'; // paper const BAR_MAX_PX = 24; const fr = (n: number, digits = 0) => n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 }); /** Graduations « propres » (0, 5, 10… / 0, 20, 40…) couvrant `max`. */ function niceTicks(max: number, target = 4): number[] { if (max <= 0) return [0, 1]; const raw = max / target; const pow = 10 ** Math.floor(Math.log10(raw)); const step = [1, 2, 2.5, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? raw; const ticks: number[] = []; for (let v = 0; v <= max + step * 0.999; v += step) ticks.push(Math.round(v * 100) / 100); return ticks; } /** Largeur réelle du conteneur (le SVG se dessine en pixels, pas en %). */ function useWidth(fallback = 640) { const ref = useRef(null); const [width, setWidth] = useState(fallback); useLayoutEffect(() => { const el = ref.current; if (!el || typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver(([entry]) => setWidth(Math.max(240, entry.contentRect.width))); ro.observe(el); return () => ro.disconnect(); }, []); return { ref, width }; } // --------------------------------------------------------------------------- // // Infobulle (position: fixed, bornée à l'écran — même raison que // // lib/useTooltipPosition.ts : jamais coincée par un conteneur défilant) // // --------------------------------------------------------------------------- // interface TipState { x: number; y: number; content: ReactNode; } function useTip() { const [tip, setTip] = useState(null); const show = (e: { clientX: number; clientY: number }, content: ReactNode) => setTip({ x: e.clientX, y: e.clientY, content }); const showAt = (el: Element, content: ReactNode) => { const r = el.getBoundingClientRect(); setTip({ x: r.left + r.width / 2, y: r.top, content }); }; return { tip, show, showAt, hide: () => setTip(null) }; } function Tooltip({ tip }: { tip: TipState | null }) { const ref = useRef(null); const [pos, setPos] = useState({ left: -9999, top: -9999 }); useLayoutEffect(() => { if (!tip || !ref.current) return; const { width, height } = ref.current.getBoundingClientRect(); const left = Math.min(Math.max(tip.x + 12, 8), window.innerWidth - width - 8); const top = tip.y - height - 12 < 8 ? tip.y + 16 : tip.y - height - 12; setPos({ left, top }); }, [tip]); if (!tip) return null; return (
{tip.content}
); } /** Ligne d'infobulle : la valeur d'abord (forte), puis la série (avec un * court trait de sa couleur, pas une pastille pleine). */ function TipRow({ color, label, value }: { color?: string; label: string; value: string }) { return (
{color ? : null} {value} {label}
); } // --------------------------------------------------------------------------- // // Légende, vue tableau // // --------------------------------------------------------------------------- // export function Legend({ items }: { items: Array<{ label: string; color: string; value?: string }> }) { return (
{items.map((item) => ( {item.label} {item.value ? {item.value} : null} ))}
); } /** Équivalent tableau d'un graphique — toujours disponible, replié. */ export function TableToggle({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
); } export function DataTable({ head, rows, }: { head: string[]; rows: Array>; }) { return ( {head.map((h, i) => ( ))} {rows.map((row, r) => ( {row.map((cell, c) => ( ))} ))}
{h}
{cell}
); } // --------------------------------------------------------------------------- // // Courbe cumulée (une série : pas de légende, le titre la nomme) // // --------------------------------------------------------------------------- // export function CumulativeLineChart({ points, dateLabel, valueLabel, dailyLabel, }: { points: Array<{ date: string; count: number; cumulative: number }>; dateLabel: (date: string) => string; valueLabel: string; dailyLabel: string; }) { const { ref, width } = useWidth(); const { tip, show, hide } = useTip(); const [hover, setHover] = useState(null); const height = 220; const pad = { top: 16, right: 48, bottom: 28, left: 40 }; const innerW = width - pad.left - pad.right; const innerH = height - pad.top - pad.bottom; const ticks = niceTicks(Math.max(...points.map((p) => p.cumulative), 1)); const yMax = ticks[ticks.length - 1]; const x = (i: number) => pad.left + (points.length <= 1 ? innerW / 2 : (i / (points.length - 1)) * innerW); const y = (v: number) => pad.top + innerH - (v / yMax) * innerH; const line = points.map((p, i) => `${i ? 'L' : 'M'}${x(i)},${y(p.cumulative)}`).join(' '); const area = `${line} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`; const last = points[points.length - 1]; // Quelques dates seulement sous l'axe, réparties du début à la fin — leur // nombre suit la largeur (≈ une tous les 80 px), sinon elles se // chevauchent sur un téléphone. const slots = Math.max(2, Math.min(points.length, Math.floor(innerW / 80) + 1)); const labelled = new Set( Array.from({ length: slots }, (_, k) => Math.round((k * (points.length - 1)) / Math.max(1, slots - 1)), ), ); const pick = (clientX: number, rect: DOMRect) => { const rel = clientX - rect.left - pad.left; return Math.min(points.length - 1, Math.max(0, Math.round((rel / innerW) * (points.length - 1)))); }; const tipContent = (i: number) => ( <>
{dateLabel(points[i].date)}
); return (
{ const i = pick(e.clientX, e.currentTarget.getBoundingClientRect()); setHover(i); show(e, tipContent(i)); }} onPointerLeave={() => { setHover(null); hide(); }} onBlur={() => { setHover(null); hide(); }} onKeyDown={(e) => { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; const i = Math.min(points.length - 1, Math.max(0, (hover ?? points.length - 1) + (e.key === 'ArrowLeft' ? -1 : 1))); setHover(i); const rect = e.currentTarget.getBoundingClientRect(); show({ clientX: rect.left + x(i), clientY: rect.top + y(points[i].cumulative) }, tipContent(i)); }} className="block outline-none focus-visible:outline-2 focus-visible:outline-ink" > {ticks.map((tk) => ( {fr(tk)} ))} {points.map((p, i) => labelled.has(i) ? ( {dateLabel(p.date)} ) : null, )} {hover !== null ? ( <> ) : null} {last ? ( <> {fr(last.cumulative)} ) : null}
); } // --------------------------------------------------------------------------- // // Barres horizontales (une série, une couleur pour toutes les barres) // // --------------------------------------------------------------------------- // export function BarList({ rows, max, color = SERIES.blue, }: { rows: Array<{ key: string; label: string; value: number; valueLabel: string; tip?: string }>; /** Échelle commune (ex. 100 pour des pourcentages) — défaut : la plus grande valeur. */ max?: number; color?: string; }) { const { tip, show, showAt, hide } = useTip(); const scale = max ?? Math.max(...rows.map((r) => r.value), 1); return (
{rows.map((r) => (
show(e, )} onPointerLeave={hide} onFocus={(e) => showAt(e.currentTarget, )} onBlur={hide} className="grid items-center gap-[8px] outline-none hover:bg-cream focus-visible:bg-cream" style={{ gridTemplateColumns: 'minmax(90px, 34%) 1fr' }} > {r.label} 0 ? 1.5 : 0, (r.value / scale) * 82)}%`, background: color, }} /> {r.valueLabel}
))}
); } // --------------------------------------------------------------------------- // // Colonnes groupées (plusieurs séries, même unité, un seul axe) // // --------------------------------------------------------------------------- // export function GroupedColumns({ groups, series, unit, }: { groups: Array<{ key: string; label: string; values: number[] }>; series: Array<{ label: string; color: string }>; unit: string; }) { const { ref, width } = useWidth(); const { tip, show, hide, showAt } = useTip(); const [hover, setHover] = useState(null); const height = 240; const pad = { top: 18, right: 8, bottom: 30, left: 44 }; const innerW = width - pad.left - pad.right; const innerH = height - pad.top - pad.bottom; const ticks = niceTicks(Math.max(...groups.flatMap((g) => g.values), 1)); const yMax = ticks[ticks.length - 1]; const band = innerW / Math.max(groups.length, 1); const barW = Math.min(BAR_MAX_PX, (band * 0.7 - 2 * (series.length - 1)) / series.length); const groupW = barW * series.length + 2 * (series.length - 1); const y = (v: number) => pad.top + innerH - (v / yMax) * innerH; const content = (g: (typeof groups)[number]) => ( <>
{g.label}
{series.map((s, i) => ( ))} ); // Barre au bout arrondi côté valeur (haut), carré côté ligne de base. const bar = (x0: number, v: number) => { const top = y(v); const h = y(0) - top; if (h <= 0) return ''; const r = Math.min(4, h, barW / 2); return `M${x0},${y(0)} V${top + r} Q${x0},${top} ${x0 + r},${top} H${x0 + barW - r} Q${x0 + barW},${top} ${x0 + barW},${top + r} V${y(0)} Z`; }; return (
s.label).join(', ')} className="block"> {ticks.map((tk) => ( {fr(tk)} ))} {groups.map((g, gi) => { const x0 = pad.left + gi * band + (band - groupW) / 2; return ( { setHover(gi); show(e, content(g)); }} onPointerLeave={() => { setHover(null); hide(); }} onFocus={(e) => { setHover(gi); showAt(e.currentTarget, content(g)); }} onBlur={() => { setHover(null); hide(); }} className="outline-none" > {/* Zone de survol : toute la bande du jour, pas seulement les barres. */} {series.map((s, si) => ( ))} {g.label} ); })}
); } // --------------------------------------------------------------------------- // // Répartition (partie d'un tout) — une barre, segments séparés par 2 px // // --------------------------------------------------------------------------- // export function ShareBar({ segments, }: { segments: Array<{ label: string; value: number; color: string }>; }) { const { tip, show, showAt, hide } = useTip(); const total = segments.reduce((t, s) => t + s.value, 0); const visible = segments.filter((s) => s.value > 0); const pct = (v: number) => (total ? Math.round((v / total) * 100) : 0); return (
{visible.map((s, i) => (
show(e, )} onPointerLeave={hide} onFocus={(e) => showAt(e.currentTarget, )} onBlur={hide} className={cx( 'h-full outline-none hover:brightness-110 focus-visible:brightness-110', i === 0 && 'rounded-l-[4px]', i === visible.length - 1 && 'rounded-r-[4px]', )} style={{ flexGrow: s.value, flexBasis: 0, background: s.color }} /> ))}
({ label: s.label, color: s.color, value: `${fr(s.value)} · ${pct(s.value)} %` }))} />
); } // --------------------------------------------------------------------------- // // Jauge : une valeur rapportée à un maximum (piste = même teinte, plus claire) // // --------------------------------------------------------------------------- // export function Meter({ value, max, label, extra, title, }: { value: number; max: number; label: string; /** Complément court après le libellé (ex. « +2 »), dans une colonne fixe * pour que les jauges restent alignées d'une ligne à l'autre. */ extra?: string; title?: string; }) { const ratio = max > 0 ? Math.min(1, value / max) : 0; return ( {label} {extra ?? ''} ); }