SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
13 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
7.2 KB · 118 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// fiche/MarketCard.tsx : « Prix et marché » — KPI (prix demandé, médiane des5//   comparables, fourchette 25–75 %), jauge de position (percentile), nuage6//   prix / kilométrage des comparables (ce véhicule en orange), PDSF d'origine7//   si connu, historique du prix demandé, méthodologie.8// -----------------------------------------------------------------------------9import { MarketAnalysis, VehicleDetail, fmtDate, fmtPrice } from "../api";10import { Ico } from "../components/Icons";11import { ComparaisonPrix } from "./synthese";12import { PriceCapsule } from "./VehicleHero";13import { Accordion, SectionCard, StatTile, NBSP } from "./ui";1415function Scatter({ m, price, km }: { m: MarketAnalysis; price: number; km: number | null }) {16  const pts = m.points;17  if (pts.length < 5) return null;18  const W = 520, H = 220, PAD = { t: 16, r: 14, b: 28, l: 52 };19  const kms = pts.map((d) => d.km).concat(km != null ? [km] : []);20  const ps = pts.map((d) => d.p).concat([price]);21  const kMin = Math.min(...kms), kMax = Math.max(...kms), pMin = Math.min(...ps), pMax = Math.max(...ps);22  const x = (v: number) => PAD.l + (kMax > kMin ? (v - kMin) / (kMax - kMin) : 0.5) * (W - PAD.l - PAD.r);23  const y = (v: number) => H - PAD.b - (pMax > pMin ? (v - pMin) / (pMax - pMin) : 0.5) * (H - PAD.t - PAD.b);24  const fmtK = (v: number) => `${Math.round(v / 1000)} k`;25  return (26    <svg viewBox={`0 0 ${W} ${H}`} className="ak-scatter" role="img" aria-label="Nuage prix / kilométrage des véhicules comparables">27      <line x1={PAD.l} y1={H - PAD.b} x2={W - PAD.r} y2={H - PAD.b} className="axis" />28      <line x1={PAD.l} y1={PAD.t} x2={PAD.l} y2={H - PAD.b} className="axis" />29      <line x1={PAD.l} y1={y(m.median)} x2={W - PAD.r} y2={y(m.median)} className="median" />30      <text x={W - PAD.r} y={y(m.median) - 5} textAnchor="end" className="lbl">médiane {fmtPrice(m.median)}</text>31      {pts.map((d, i) => <circle key={i} cx={x(d.km)} cy={y(d.p)} r={3.5} className="dot" />)}32      {km != null && (33        <g>34          <circle cx={x(km)} cy={y(price)} r={7} className="me" />35          <text x={x(km)} y={y(price) - 12} textAnchor="middle" className="lbl me-lbl">ce véhicule</text>36        </g>37      )}38      <text x={PAD.l} y={H - 8} className="lbl">{fmtK(kMin)} km</text>39      <text x={W - PAD.r} y={H - 8} textAnchor="end" className="lbl">{fmtK(kMax)} km</text>40      <text x={PAD.l - 6} y={y(pMax) + 4} textAnchor="end" className="lbl">{fmtK(pMax)} $</text>41      <text x={PAD.l - 6} y={y(pMin) + 4} textAnchor="end" className="lbl">{fmtK(pMin)} $</text>42    </svg>43  );44}4546export default function MarketCard({ v, cmp }: { v: VehicleDetail; cmp: ComparaisonPrix | null }) {47  if (v.price == null) return null;48  const m = v.market;49  const hist = (v.price_history ?? []).filter((h) => h.price != null);50  const msrp = typeof v.details?.msrp === "number" ? (v.details.msrp as number) : null;51  const jours = v.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - v.first_seen) / 86400)) : null;52  return (53    <SectionCard id="prix" title="Prix et marché" icon={<Ico name="scale" size={18} />} aside={cmp && <PriceCapsule cmp={cmp} short />}>54      {m && m.n >= 3 ? (55        <>56          <div className="ak-kpis cols-3">57            <StatTile accent value={fmtPrice(v.price)} label="Prix demandé" />58            <StatTile value={fmtPrice(m.median)} label={`Médiane de ${m.n} comparables`} />59            <StatTile value={`${fmtPrice(m.p25)} – ${fmtPrice(m.p75)}`} label="Fourchette 25–75 %" />60          </div>61          <div className="ak-gauge">62            <svg viewBox="0 0 320 44" role="img" aria-label={`${m.percentile} % des comparables sont moins chers`}>63              <rect className="ak-gauge-track" x="8" y="18" width="304" height="8" rx="4" />64              <rect className="ak-gauge-box" x={8 + 304 * 0.25} y="16" width={304 * 0.5} height="12" rx="4" />65              <circle className="ak-gauge-me" cx={8 + 304 * Math.max(0, Math.min(100, m.percentile)) / 100} cy="22" r="7" />66              <text className="ak-gauge-txt me" x={8 + 304 * Math.max(0, Math.min(100, m.percentile)) / 100} y="42"67                    textAnchor={m.percentile < 18 ? "start" : m.percentile > 82 ? "end" : "middle"}>{m.percentile}{NBSP}% des comparables sont moins chers</text>68            </svg>69            <div className="ak-gauge-lbls"><span className="good">moins cher · {fmtPrice(m.min)}</span><span className="bad">plus cher · {fmtPrice(m.max)}</span></div>70          </div>71          <Scatter m={m} price={v.price} km={v.mileage_km} />72          <div className="ak-meta">73            <span><b>{m.n}</b> {v.make} {v.model.split(" ")[0]}{v.year ? ` ${v.year - 1}–${v.year + 1}` : ""} en vente au Québec</span>74            {msrp != null && msrp > 0 && <span>PDSF d'origine <b>{fmtPrice(msrp)}</b> ({Math.round((1 - v.price / msrp) * 100)}{NBSP}% de moins)</span>}75            {jours != null && jours > 0 && <span><b>{jours}</b> jour{jours > 1 ? "s" : ""} en vente (observé)</span>}76          </div>77        </>78      ) : (79        <>80          <div className="ak-kpis cols-3">81            <StatTile accent value={fmtPrice(v.price)} label="Prix demandé" />82            {msrp != null && msrp > 0 && <StatTile value={fmtPrice(msrp)} label="PDSF d'origine (neuf)" />}83            {jours != null && jours > 0 && <StatTile value={jours} label="Jours en vente (observé)" />}84          </div>85          <p className="ak-fine meth">Pas assez de {v.make} {v.model.split(" ")[0]} comparables en vente pour situer ce prix.</p>86        </>87      )}88      {hist.length >= 2 && (89        <>90          <h3 className="ak-card-sub ak-subtitle">Historique du prix demandé</h3>91          <ul className="ak-tl">92            {hist.slice(0, 8).map((h, i) => {93              const prev = hist[i + 1];94              const cls = prev && prev.price != null ? (h.price! < prev.price ? "baisse" : h.price! > prev.price ? "hausse" : "") : "";95              return (96                <li key={h.ts} className={cls}>97                  <span className="ak-tl-date">{fmtDate(h.ts)}</span>98                  {prev && prev.price != null && prev.price !== h.price99                    ? <>{h.price! < prev.price ? "Baissé" : "Monté"} de {fmtPrice(prev.price)} à <b>{fmtPrice(h.price!)}</b></>100                    : prev ? <>Prix inchangé : <b>{fmtPrice(h.price!)}</b></> : <>Premier prix observé : <b>{fmtPrice(h.price!)}</b></>}101                </li>102              );103            })}104          </ul>105        </>106      )}107      <Accordion title="Méthodologie" small>108        <p>109          Comparables = véhicules de même marque et modèle, année ±1, en vente au Québec dans les sources Auto-Ka, doublons110          (même NIV) exclus. La médiane et la fourchette 25–75 % sont recalculées à chaque affichage ; le percentile indique111          la part des comparables affichés moins cher. L'état du véhicule, les options et l'historique d'entretien ne sont pas112          pris en compte : c'est un repère, pas une évaluation. Le PDSF provient de la fiche constructeur quand il est connu.113        </p>114      </Accordion>115    </SectionCard>116  );117}118