spb/auto-ka Public
Python 82.8%
TypeScript 11.9%
CSS 5.1%
1// -----------------------------------------------------------------------------2// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// Charts.tsx : graphiques SVG maison — histogramme, barres horizontales, donut.5// Marques fines, bouts arrondis, étiquettes directes sélectives, infobulle6// au survol, vue tableau repliable (accessibilité). Palette catégorielle7// validée (contraste + daltonisme) : #d94f1e #1f6fb5 #1c7a4d #b585008// #7d4fc9 #a05a2c.9// -----------------------------------------------------------------------------10import { ReactNode, useState } from "react";1112export const CAT_COLORS = ["#d94f1e", "#1f6fb5", "#1c7a4d", "#b58500", "#7d4fc9", "#a05a2c"];1314export interface Datum {15 label: string;16 n: number;17 extra?: string;18}1920const fmt = (n: number) => Math.round(n).toLocaleString("fr-CA");2122function DataTable({ rows, valueLabel }: { rows: Datum[]; valueLabel: string }) {23 return (24 <details className="chart-data">25 <summary>Voir les données</summary>26 <table>27 <thead>28 <tr><th>Catégorie</th><th>{valueLabel}</th></tr>29 </thead>30 <tbody>31 {rows.map((r) => (32 <tr key={r.label}>33 <td>{r.label}</td>34 <td>{fmt(r.n)}{r.extra ? ` · ${r.extra}` : ""}</td>35 </tr>36 ))}37 </tbody>38 </table>39 </details>40 );41}4243function Tip({ x, y, children }: { x: number; y: number; children: ReactNode }) {44 return (45 <div className="chart-tip" style={{ left: x, top: y }}>46 {children}47 </div>48 );49}5051/** Histogramme vertical — magnitude d'une distribution, une seule teinte. */52export function VBars({ data, color = "#d94f1e", valueLabel = "véhicules", height = 190 }:53 { data: Datum[]; color?: string; valueLabel?: string; height?: number }) {54 const [tip, setTip] = useState<{ x: number; y: number; d: Datum } | null>(null);55 if (!data.length) return null;56 const max = Math.max(...data.map((d) => d.n), 1);57 const W = 720, PAD = 6, LBL = 28;58 const bw = (W - PAD * (data.length - 1)) / data.length;59 const plotH = height - LBL - 18;60 return (61 <div className="chart-wrap" onMouseLeave={() => setTip(null)}>62 <svg viewBox={`0 0 ${W} ${height}`} className="chart vbars" role="img"63 aria-label={`Histogramme : ${valueLabel} par classe`}>64 {data.map((d, i) => {65 const h = Math.max(3, (d.n / max) * plotH);66 const x = i * (bw + PAD);67 const y = 18 + (plotH - h);68 const show = d.n === max || i === 0 || i === data.length - 1;69 return (70 <g key={d.label}>71 <rect72 x={x} y={y} width={bw} height={h} rx={4} fill={color}73 className="mark"74 onMouseEnter={(e) => {75 const r = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect();76 setTip({ x: e.clientX - r.left, y: e.clientY - r.top - 10, d });77 }}78 />79 {show && (80 <text x={x + bw / 2} y={y - 5} textAnchor="middle" className="c-val">81 {fmt(d.n)}82 </text>83 )}84 <text x={x + bw / 2} y={height - 4} textAnchor="middle" className="c-lbl">85 {d.label}86 </text>87 </g>88 );89 })}90 </svg>91 {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel}{tip.d.extra ? ` · ${tip.d.extra}` : ""}</Tip>}92 <DataTable rows={data} valueLabel={valueLabel} />93 </div>94 );95}9697/** Barres horizontales — classement d'une même mesure, une seule teinte. */98export function HBars({ data, color = "#d94f1e", valueLabel = "véhicules" }:99 { data: Datum[]; color?: string; valueLabel?: string }) {100 const [tip, setTip] = useState<{ x: number; y: number; d: Datum } | null>(null);101 if (!data.length) return null;102 const max = Math.max(...data.map((d) => d.n), 1);103 const W = 720, ROW = 26, LBL = 150;104 const H = data.length * ROW;105 return (106 <div className="chart-wrap" onMouseLeave={() => setTip(null)}>107 <svg viewBox={`0 0 ${W} ${H}`} className="chart hbars" role="img"108 aria-label={`Barres : ${valueLabel} par catégorie`}>109 {data.map((d, i) => {110 const w = Math.max(4, (d.n / max) * (W - LBL - 120));111 const y = i * ROW + 4;112 return (113 <g key={d.label}>114 <text x={LBL - 8} y={y + 13} textAnchor="end" className="c-cat">115 {d.label.length > 22 ? d.label.slice(0, 21) + "…" : d.label}116 </text>117 <rect118 x={LBL} y={y} width={w} height={ROW - 10} rx={4} fill={color}119 className="mark"120 onMouseEnter={(e) => {121 const r = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect();122 setTip({ x: e.clientX - r.left, y: e.clientY - r.top - 10, d });123 }}124 />125 <text x={LBL + w + 7} y={y + 12} className="c-val">126 {fmt(d.n)}{d.extra ? ` · ${d.extra}` : ""}127 </text>128 </g>129 );130 })}131 </svg>132 {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel}{tip.d.extra ? ` · ${tip.d.extra}` : ""}</Tip>}133 <DataTable rows={data} valueLabel={valueLabel} />134 </div>135 );136}137138/** Donut — parts d'un tout (≤ 6 catégories + « Autres »), palette validée. */139export function Donut({ data, valueLabel = "véhicules" }:140 { data: Datum[]; valueLabel?: string }) {141 const [tip, setTip] = useState<{ x: number; y: number; d: Datum; pct: number } | null>(null);142 if (!data.length) return null;143 const top = data.slice(0, 5);144 const rest = data.slice(5).reduce((s, d) => s + d.n, 0);145 const parts: Datum[] = rest > 0 ? [...top, { label: "Autres", n: rest }] : top;146 const total = parts.reduce((s, d) => s + d.n, 0) || 1;147 const R = 80, r = 46, CX = 110, CY = 100;148 let angle = -Math.PI / 2;149 const arcs = parts.map((d, i) => {150 const span = (d.n / total) * Math.PI * 2;151 const a0 = angle, a1 = angle + span;152 angle = a1;153 const large = span > Math.PI ? 1 : 0;154 const p = (a: number, rad: number) =>155 `${CX + rad * Math.cos(a)},${CY + rad * Math.sin(a)}`;156 return {157 d,158 pct: (d.n / total) * 100,159 color: CAT_COLORS[i % CAT_COLORS.length],160 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`,161 };162 });163 return (164 <div className="chart-wrap donut-wrap" onMouseLeave={() => setTip(null)}>165 <svg viewBox="0 0 220 200" className="chart donut" role="img"166 aria-label={`Répartition : ${valueLabel} par catégorie`}>167 {arcs.map((a) => (168 <path169 key={a.d.label} d={a.path} fill={a.color} stroke="var(--surface)"170 strokeWidth={2} className="mark"171 onMouseEnter={(e) => {172 const rr = (e.currentTarget.closest(".chart-wrap") as HTMLElement).getBoundingClientRect();173 setTip({ x: e.clientX - rr.left, y: e.clientY - rr.top - 10, d: a.d, pct: a.pct });174 }}175 />176 ))}177 <text x={CX} y={CY - 2} textAnchor="middle" className="donut-total">{fmt(total)}</text>178 <text x={CX} y={CY + 14} textAnchor="middle" className="c-lbl">{valueLabel}</text>179 </svg>180 <div className="donut-legend">181 {arcs.map((a) => (182 <div key={a.d.label} className="dl-row">183 <span className="dl-swatch" style={{ background: a.color }} />184 <span className="dl-name">{a.d.label}</span>185 <span className="dl-val">{a.pct.toFixed(1)} %</span>186 </div>187 ))}188 </div>189 {tip && <Tip x={tip.x} y={tip.y}><b>{tip.d.label}</b> — {fmt(tip.d.n)} {valueLabel} ({tip.pct.toFixed(1)} %)</Tip>}190 <DataTable rows={parts} valueLabel={valueLabel} />191 </div>192 );193}194