Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
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