SPB Git

spb/auto-ka Public

Python 82.8% TypeScript 11.9% CSS 5.1%
7.9 KB · 194 lines tsx
Raw Blame History
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