// -----------------------------------------------------------------------------
// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
// Charts.tsx : graphiques SVG maison — histogramme, barres horizontales, donut.
// Marques fines, bouts arrondis, étiquettes directes sélectives, infobulle
// au survol, vue tableau repliable (accessibilité). Palette catégorielle
// validée (contraste + daltonisme) : #d94f1e #1f6fb5 #1c7a4d #b58500
// #7d4fc9 #a05a2c.
// -----------------------------------------------------------------------------
import { ReactNode, useState } from "react";
export const CAT_COLORS = ["#d94f1e", "#1f6fb5", "#1c7a4d", "#b58500", "#7d4fc9", "#a05a2c"];
export interface Datum {
label: string;
n: number;
extra?: string;
}
const fmt = (n: number) => Math.round(n).toLocaleString("fr-CA");
function DataTable({ rows, valueLabel }: { rows: Datum[]; valueLabel: string }) {
return (
Voir les données
| Catégorie | {valueLabel} |
{rows.map((r) => (
| {r.label} |
{fmt(r.n)}{r.extra ? ` · ${r.extra}` : ""} |
))}
);
}
function Tip({ x, y, children }: { x: number; y: number; children: ReactNode }) {
return (
{children}
);
}
/** Histogramme vertical — magnitude d'une distribution, une seule teinte. */
export function VBars({ data, color = "#d94f1e", valueLabel = "véhicules", height = 190 }:
{ data: Datum[]; color?: string; valueLabel?: string; height?: number }) {
const [tip, setTip] = useState<{ x: number; y: number; d: Datum } | null>(null);
if (!data.length) return null;
const max = Math.max(...data.map((d) => d.n), 1);
const W = 720, PAD = 6, LBL = 28;
const bw = (W - PAD * (data.length - 1)) / data.length;
const plotH = height - LBL - 18;
return (
setTip(null)}>
{tip && {tip.d.label} — {fmt(tip.d.n)} {valueLabel}{tip.d.extra ? ` · ${tip.d.extra}` : ""}}
);
}
/** Barres horizontales — classement d'une même mesure, une seule teinte. */
export function HBars({ data, color = "#d94f1e", valueLabel = "véhicules" }:
{ data: Datum[]; color?: string; valueLabel?: string }) {
const [tip, setTip] = useState<{ x: number; y: number; d: Datum } | null>(null);
if (!data.length) return null;
const max = Math.max(...data.map((d) => d.n), 1);
const W = 720, ROW = 26, LBL = 150;
const H = data.length * ROW;
return (
setTip(null)}>
{tip && {tip.d.label} — {fmt(tip.d.n)} {valueLabel}{tip.d.extra ? ` · ${tip.d.extra}` : ""}}
);
}
/** Donut — parts d'un tout (≤ 6 catégories + « Autres »), palette validée. */
export function Donut({ data, valueLabel = "véhicules" }:
{ data: Datum[]; valueLabel?: string }) {
const [tip, setTip] = useState<{ x: number; y: number; d: Datum; pct: number } | null>(null);
if (!data.length) return null;
const top = data.slice(0, 5);
const rest = data.slice(5).reduce((s, d) => s + d.n, 0);
const parts: Datum[] = rest > 0 ? [...top, { label: "Autres", n: rest }] : top;
const total = parts.reduce((s, d) => s + d.n, 0) || 1;
const R = 80, r = 46, CX = 110, CY = 100;
let angle = -Math.PI / 2;
const arcs = parts.map((d, i) => {
const span = (d.n / total) * Math.PI * 2;
const a0 = angle, a1 = angle + span;
angle = a1;
const large = span > Math.PI ? 1 : 0;
const p = (a: number, rad: number) =>
`${CX + rad * Math.cos(a)},${CY + rad * Math.sin(a)}`;
return {
d,
pct: (d.n / total) * 100,
color: CAT_COLORS[i % CAT_COLORS.length],
path: `M ${p(a0, R)} A ${R} ${R} 0 ${large} 1 ${p(a1, R)} L ${p(a1, r)} A ${r} ${r} 0 ${large} 0 ${p(a0, r)} Z`,
};
});
return (
setTip(null)}>
{arcs.map((a) => (
{a.d.label}
{a.pct.toFixed(1)} %
))}
{tip &&
{tip.d.label} — {fmt(tip.d.n)} {valueLabel} ({tip.pct.toFixed(1)} %)}
);
}