Files
benevoles/frontend/src/components/charts.tsx
T
AdamThomasandClaude Opus 5.5 ee1f6ed72e
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Écran Statistiques : inscriptions, réponses, cotisations et besoins
Nouvel onglet « Statistiques » (/statistiques, COORDINATION), alimenté
par GET /editions/{id}/stats (app/api/stats.py, recalculé à chaque appel,
aucune nouvelle entité) :
- inscriptions : total, 7 derniers jours, évolution cumulée par jour
- réponses : part des bénévoles ayant renseigné chaque information,
  artistes les plus demandés
- cotisations : en ligne / sur place / aucune, montants par type,
  mécénat, formulaires remplis sans paiement abouti
- besoins face aux bénévoles, par journée de festival : heures × personnes
  demandées vs disponibilités déclarées vs affectées ; par poste, places
  pourvues (plafonnées à l'effectif idéal de chaque créneau) et rapport
  vœux / place

Graphiques SVG maison (components/charts.tsx) avec infobulles au survol
et au clavier, vue tableau pour chacun, palette validée (daltonisme,
contraste), mise en page vérifiée sur ordinateur et mobile. Le mock
calcule les mêmes chiffres et étale les inscriptions du jeu de démo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:40:46 +02:00

528 lines
20 KiB
TypeScript

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<T extends HTMLElement>(fallback = 640) {
const ref = useRef<T>(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<TipState | null>(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<HTMLDivElement>(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 (
<div
ref={ref}
role="tooltip"
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 50 }}
className="pointer-events-none border-2 border-ink bg-paper px-[9px] py-[6px] font-mono text-[11px] shadow-brutal-sm"
>
{tip.content}
</div>
);
}
/** 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 (
<div className="flex items-center gap-[6px] whitespace-nowrap">
{color ? <span className="inline-block h-[2px] w-[10px]" style={{ background: color }} /> : null}
<b>{value}</b>
<span className="opacity-70">{label}</span>
</div>
);
}
// --------------------------------------------------------------------------- //
// Légende, vue tableau //
// --------------------------------------------------------------------------- //
export function Legend({ items }: { items: Array<{ label: string; color: string; value?: string }> }) {
return (
<div className="flex flex-wrap gap-x-[14px] gap-y-[4px] font-mono text-[10.5px]">
{items.map((item) => (
<span key={item.label} className="flex items-center gap-[6px]">
<span className="inline-block h-[10px] w-[10px] rounded-[2px]" style={{ background: item.color }} />
{item.label}
{item.value ? <b>{item.value}</b> : null}
</span>
))}
</div>
);
}
/** Équivalent tableau d'un graphique — toujours disponible, replié. */
export function TableToggle({ label, children }: { label: string; children: ReactNode }) {
return (
<details className="mt-2">
<summary className="cursor-pointer font-display text-[9.5px] font-extrabold uppercase tracking-wide opacity-70 hover:opacity-100">
{label}
</summary>
<div className="mt-2 overflow-x-auto">{children}</div>
</details>
);
}
export function DataTable({
head,
rows,
}: {
head: string[];
rows: Array<Array<ReactNode>>;
}) {
return (
<table className="w-full border-collapse font-mono text-[11px]">
<thead>
<tr>
{head.map((h, i) => (
<th
key={h}
className={cx(
'border-b-2 border-ink px-[6px] py-[4px] font-display text-[9.5px] font-extrabold uppercase tracking-wide',
i === 0 ? 'text-left' : 'text-right',
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={r} className="border-b border-sand-line">
{row.map((cell, c) => (
<td
key={c}
className={cx('px-[6px] py-[4px]', c === 0 ? 'text-left' : 'text-right tabular-nums')}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// --------------------------------------------------------------------------- //
// 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<HTMLDivElement>();
const { tip, show, hide } = useTip();
const [hover, setHover] = useState<number | null>(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) => (
<>
<div className="mb-[2px] font-display text-[10px] font-extrabold">{dateLabel(points[i].date)}</div>
<TipRow color={SERIES.blue} label={valueLabel} value={fr(points[i].cumulative)} />
<TipRow label={dailyLabel} value={`+${fr(points[i].count)}`} />
</>
);
return (
<div ref={ref} className="w-full min-w-0 overflow-hidden">
<svg
width={width}
height={height}
role="img"
tabIndex={0}
aria-label={`${valueLabel} : ${last ? fr(last.cumulative) : 0}`}
onPointerMove={(e) => {
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) => (
<g key={tk}>
<line x1={pad.left} x2={width - pad.right} y1={y(tk)} y2={y(tk)} stroke={GRID} strokeWidth={1} />
<text x={pad.left - 6} y={y(tk) + 3} textAnchor="end" className="fill-ink font-mono text-[10px] opacity-60">
{fr(tk)}
</text>
</g>
))}
{points.map((p, i) =>
labelled.has(i) ? (
<text
key={p.date}
x={x(i)}
y={height - 8}
textAnchor={i === 0 ? 'start' : i === points.length - 1 ? 'end' : 'middle'}
className="fill-ink font-mono text-[10px] opacity-60"
>
{dateLabel(p.date)}
</text>
) : null,
)}
<path d={area} fill={SERIES.blue} fillOpacity={0.1} />
<path d={line} fill="none" stroke={SERIES.blue} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{hover !== null ? (
<>
<line x1={x(hover)} x2={x(hover)} y1={pad.top} y2={pad.top + innerH} stroke="#14110E" strokeOpacity={0.35} strokeWidth={1} />
<circle cx={x(hover)} cy={y(points[hover].cumulative)} r={4} fill={SERIES.blue} stroke={SURFACE} strokeWidth={2} />
</>
) : null}
{last ? (
<>
<circle cx={x(points.length - 1)} cy={y(last.cumulative)} r={4} fill={SERIES.blue} stroke={SURFACE} strokeWidth={2} />
<text x={x(points.length - 1) + 8} y={y(last.cumulative) + 4} className="fill-ink font-mono text-[11px] font-bold">
{fr(last.cumulative)}
</text>
</>
) : null}
</svg>
<Tooltip tip={tip} />
</div>
);
}
// --------------------------------------------------------------------------- //
// 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 (
<div className="flex flex-col gap-[6px]">
{rows.map((r) => (
<div
key={r.key}
tabIndex={0}
onPointerMove={(e) => show(e, <TipRow color={color} label={r.label} value={r.tip ?? r.valueLabel} />)}
onPointerLeave={hide}
onFocus={(e) => showAt(e.currentTarget, <TipRow color={color} label={r.label} value={r.tip ?? r.valueLabel} />)}
onBlur={hide}
className="grid items-center gap-[8px] outline-none hover:bg-cream focus-visible:bg-cream"
style={{ gridTemplateColumns: 'minmax(90px, 34%) 1fr' }}
>
<span className="truncate font-mono text-[11px]" title={r.label}>
{r.label}
</span>
<span className="flex items-center gap-[6px]">
<span
className="block h-[14px] rounded-r-[4px]"
style={{
width: `${Math.max(r.value > 0 ? 1.5 : 0, (r.value / scale) * 82)}%`,
background: color,
}}
/>
<span className="whitespace-nowrap font-mono text-[10.5px] font-bold">{r.valueLabel}</span>
</span>
</div>
))}
<Tooltip tip={tip} />
</div>
);
}
// --------------------------------------------------------------------------- //
// 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<HTMLDivElement>();
const { tip, show, hide, showAt } = useTip();
const [hover, setHover] = useState<number | null>(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]) => (
<>
<div className="mb-[2px] font-display text-[10px] font-extrabold">{g.label}</div>
{series.map((s, i) => (
<TipRow key={s.label} color={s.color} label={s.label} value={`${fr(g.values[i], 1)} ${unit}`} />
))}
</>
);
// 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 (
<div ref={ref} className="w-full min-w-0 overflow-hidden">
<svg width={width} height={height} role="img" aria-label={series.map((s) => s.label).join(', ')} className="block">
{ticks.map((tk) => (
<g key={tk}>
<line x1={pad.left} x2={width - pad.right} y1={y(tk)} y2={y(tk)} stroke={GRID} strokeWidth={1} />
<text x={pad.left - 6} y={y(tk) + 3} textAnchor="end" className="fill-ink font-mono text-[10px] opacity-60">
{fr(tk)}
</text>
</g>
))}
{groups.map((g, gi) => {
const x0 = pad.left + gi * band + (band - groupW) / 2;
return (
<g
key={g.key}
tabIndex={0}
onPointerMove={(e) => {
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. */}
<rect
x={pad.left + gi * band}
y={pad.top}
width={band}
height={innerH}
fill={hover === gi ? '#14110E' : 'transparent'}
fillOpacity={hover === gi ? 0.04 : 0}
/>
{series.map((s, si) => (
<path key={s.label} d={bar(x0 + si * (barW + 2), g.values[si])} fill={s.color} />
))}
<text x={pad.left + gi * band + band / 2} y={height - 10} textAnchor="middle" className="fill-ink font-mono text-[10.5px]">
{g.label}
</text>
</g>
);
})}
<line x1={pad.left} x2={width - pad.right} y1={y(0)} y2={y(0)} stroke="#14110E" strokeOpacity={0.4} strokeWidth={1} />
</svg>
<Tooltip tip={tip} />
</div>
);
}
// --------------------------------------------------------------------------- //
// 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 (
<div>
<div className="flex h-[22px] w-full gap-[2px]">
{visible.map((s, i) => (
<div
key={s.label}
tabIndex={0}
onPointerMove={(e) => show(e, <TipRow color={s.color} label={s.label} value={`${fr(s.value)} (${pct(s.value)} %)`} />)}
onPointerLeave={hide}
onFocus={(e) => showAt(e.currentTarget, <TipRow color={s.color} label={s.label} value={`${fr(s.value)} (${pct(s.value)} %)`} />)}
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 }}
/>
))}
</div>
<div className="mt-2">
<Legend items={segments.map((s) => ({ label: s.label, color: s.color, value: `${fr(s.value)} · ${pct(s.value)} %` }))} />
</div>
<Tooltip tip={tip} />
</div>
);
}
// --------------------------------------------------------------------------- //
// 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 (
<span className="flex items-center gap-[6px]" title={title ?? label}>
<span className="relative block h-[8px] w-[72px] rounded-[4px]" style={{ background: '#cde2fb' }}>
<span
className="absolute inset-y-0 left-0 rounded-[4px]"
style={{ width: `${ratio * 100}%`, background: SERIES.blue }}
/>
</span>
<span className="w-[64px] text-left font-mono text-[10.5px] font-bold">{label}</span>
<span className="w-[26px] text-left font-mono text-[10px] opacity-60">{extra ?? ''}</span>
</span>
);
}