SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
55.3 KB · 973 lines tsx
Raw Blame History
1// Vrai-Prix — atelier « mon évaluation » d'une propriété à vendre.2/**3 * Quatre temps, comme un rapport d'évaluation par comparaison directe :4 *  01 bassin — ventes réelles (et annonces actives) autour du sujet, l'utilisateur5 *     coche celles qu'il juge comparables (les 12 du moteur sont signalées) ;6 *  02 grille — ajustements en dollars (marché, superficie, âge, + libres),7 *     pré-remplis avec les formules du moteur mais entièrement modifiables ;8 *  03 réconciliation — médiane, moyenne, moyennes pondérées ; valeur conclue ;9 *  04 comparaison — face à la mesure Vrai-Prix déjà calculée (hybride, hédonique,10 *     comparables, coût, rôle indexé, ensemble), au prix demandé et au rôle.11 * Tout est conservé sur l'appareil (localStorage) — aucun envoi au serveur.12 */13"use client";14import { useCallback, useEffect, useMemo, useRef, useState } from "react";15import Link from "next/link";16import { useLang } from "@/components/LangContext";17import RadarMap, { type RadarComp } from "@/components/RadarMap";18import { TYPE_MATCH } from "@/lib/engine";19import type { CompCandidate, CompsPayload } from "@/lib/comps";20import type { TypeGroupKey } from "@/lib/immoka";21import { groupLabel } from "./ListingCardView";22import { dateFr, mean, median, money, num, pct, round100, signedMoney, sqftToM2, weightedMean, weightedMedian } from "./fmt";2324/* ---------------------------------- types --------------------------------- */25export interface WorkbenchHead {26  uid: string;27  address: string | null;28  city: string | null;29  propertyType: string | null;30  group: TypeGroupKey;31  price: number;32  image: string | null;33  bedrooms: number | null;34  bathrooms: number | null;35  areaSqft: number | null;36  lotSqft: number | null;37  yearBuilt: number | null;38  geolocated: boolean;39  eval: {40    est: number | null;41    low: number | null;42    high: number | null;43    confidence: string | null;44    modelEst: number | null;45    compsEst: number | null;46    costEst: number | null;47    roleEst: number | null;48    ensEst: number | null;49    valeurRole: number | null;50    nComps: number | null;51    unitId: string | null;52    evaluatedAt: string | null;53  } | null;54}5556interface Extra {57  id: string;58  label: string;59  amount: number;60}61interface CompState {62  adjTime: number;63  adjArea: number;64  adjAge: number;65  extras: Extra[];66  weight: number; // 1..567  rev?: number; // incrémenté à chaque « ↺ moteur » → remonte les champs (key)68}69type Method = "median" | "mean" | "wmean" | "wmedian";70interface Params {71  months: number;72  radiusKm: number;73  includeListings: boolean;74  sameType: boolean;75}76interface Saved {77  v: 1;78  params: Params;79  selected: string[];80  states: Record<string, CompState>;81  snap: Record<string, CompCandidate>;82  method: Method;83  rangePct: number;84  finalValue: number | null;85}8687const DEFAULT_PARAMS: Params = { months: 24, radiusKm: 3, includeListings: true, sameType: true };88const key = (uid: string) => `vrai-prix-avendre:${uid}`;8990const adjustedOf = (c: CompCandidate, s: CompState) =>91  c.amount + s.adjTime + s.adjArea + s.adjAge + s.extras.reduce((a, e) => a + (Number.isFinite(e.amount) ? e.amount : 0), 0);92const grossAdjPct = (c: CompCandidate, s: CompState) =>93  ((Math.abs(s.adjTime) + Math.abs(s.adjArea) + Math.abs(s.adjAge) + s.extras.reduce((a, e) => a + Math.abs(e.amount || 0), 0)) / c.amount) * 100;9495const fmtDist = (m: number) => (m < 1000 ? `${m} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`);9697/* ------------------------------- sous-blocs -------------------------------- */98function Sect({ num: n, kicker, title, right }: { num: string; kicker: string; title: string; right?: React.ReactNode }) {99  return (100    <header className="sec">101      <div className="flex flex-wrap items-baseline justify-between gap-2">102        <span className="sec-num">103          {n} — {kicker}104        </span>105        {right}106      </div>107      <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{title}</h2>108    </header>109  );110}111112/** Champ monétaire : texte libre pendant la saisie ; remonté par `key` quand le parent réinitialise. */113function MoneyInput({ value, onChange, hint, label }: { value: number; onChange: (v: number) => void; hint?: string; label: string }) {114  const [txt, setTxt] = useState(() => String(Math.round(value)));115  return (116    <label className="block">117      <span className="klabel">{label}</span>118      <input119        value={txt}120        inputMode="numeric"121        onChange={(e) => {122          const t = e.target.value.replace(/[^\d-]/g, "");123          setTxt(t);124          const n = Number(t);125          if (Number.isFinite(n) && t !== "" && t !== "-") onChange(n);126          if (t === "") onChange(0);127        }}128        className="vp-input vp-mono mt-0.5 text-[13px]"129      />130      {hint && <span className="vp-mono mt-0.5 block text-[9.5px] uppercase tracking-[0.05em] text-ink-3">{hint}</span>}131    </label>132  );133}134135/** Graphique à points : chaque repère sur sa ligne, échelle commune, ma valeur en trait vertical. */136function DotPlot({137  rows,138  me,139  lang,140}: {141  rows: { key: string; label: string; value: number | null; band?: [number, number] | null; tone: "me" | "main" | "method" | "ask" | "role" }[];142  me: number | null;143  lang: string;144}) {145  const vals = rows.flatMap((r) => [r.value, r.band?.[0], r.band?.[1]]).filter((v): v is number => v != null && Number.isFinite(v));146  if (!vals.length) return null;147  const lo = Math.min(...vals) * 0.96;148  const hi = Math.max(...vals) * 1.04;149  const W = 760;150  const L = 250;151  const R = 24;152  const RH = 30;153  const H = rows.length * RH + 28;154  const x = (v: number) => L + ((v - lo) / (hi - lo)) * (W - L - R);155  const ticks = 4;156  return (157    <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="Comparaison des valeurs">158      {Array.from({ length: ticks + 1 }, (_, i) => lo + ((hi - lo) * i) / ticks).map((v, i) => (159        <g key={i}>160          <line x1={x(v)} y1={4} x2={x(v)} y2={H - 22} stroke="var(--line)" strokeWidth="1" />161          <text x={x(v)} y={H - 8} textAnchor="middle" fontSize="9.5" fontFamily="var(--font-jetbrains), monospace" fill="var(--ink-3)">162            {new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { notation: "compact", maximumFractionDigits: 0 }).format(v)} $163          </text>164        </g>165      ))}166      {me != null && Number.isFinite(me) && <line x1={x(me)} y1={2} x2={x(me)} y2={H - 22} stroke="var(--accent)" strokeWidth="1.5" strokeDasharray="4 4" />}167      {rows.map((r, i) => {168        const y = i * RH + RH / 2 + 4;169        const color = r.tone === "me" ? "var(--accent)" : r.tone === "ask" ? "var(--danger)" : r.tone === "role" ? "var(--ink-3)" : "var(--ink)";170        return (171          <g key={r.key}>172            <text x={0} y={y + 4} fontSize={r.tone === "me" || r.tone === "main" ? 11.5 : 10.5} fontWeight={r.tone === "me" || r.tone === "main" ? 700 : 500} fontFamily="var(--font-space-grotesk), sans-serif" fill={color}>173              {r.label.length > 40 ? r.label.slice(0, 39) + "…" : r.label}174            </text>175            <line x1={L} y1={y} x2={W - R} y2={y} stroke="var(--line-soft)" strokeWidth="1" />176            {r.band && r.band[1] > r.band[0] && <rect x={x(r.band[0])} y={y - 6} width={Math.max(2, x(r.band[1]) - x(r.band[0]))} height={12} fill={color} opacity={0.14} />}177            {r.value != null && Number.isFinite(r.value) && (178              <>179                {r.tone === "me" ? (180                  <rect x={x(r.value) - 7} y={y - 7} width={14} height={14} transform={`rotate(45 ${x(r.value)} ${y})`} fill={color} stroke="var(--paper)" strokeWidth="1.5" />181                ) : (182                  <circle cx={x(r.value)} cy={y} r={r.tone === "main" ? 6.5 : 5} fill={r.tone === "ask" ? "var(--paper)" : color} stroke={color} strokeWidth={2} />183                )}184                <text x={x(r.value) + (x(r.value) > W - 120 ? -12 : 12)} y={y + 4} textAnchor={x(r.value) > W - 120 ? "end" : "start"} fontSize="10.5" fontWeight="700" fontFamily="var(--font-jetbrains), monospace" fill={color}>185                  {money(r.value, lang)}186                </text>187              </>188            )}189          </g>190        );191      })}192    </svg>193  );194}195196/* --------------------------------- atelier --------------------------------- */197export default function EvalWorkbench({ head }: { head: WorkbenchHead }) {198  const { lang } = useLang();199  const fr = lang === "fr";200  const [params, setParams] = useState<Params>(DEFAULT_PARAMS);201  const [data, setData] = useState<CompsPayload | null>(null);202  // la réponse (ou l'erreur) porte la clé de la requête qui l'a produite :203  // « en chargement » = la requête courante n'est pas encore servie204  const reqKey = `${head.uid}|${params.months}|${params.radiusKm}|${params.includeListings ? 1 : 0}`;205  const [dataKey, setDataKey] = useState<string | null>(null);206  const [errState, setErrState] = useState<{ key: string; msg: string } | null>(null);207  const error = errState?.key === reqKey ? errState.msg : null;208  const [selected, setSelected] = useState<string[]>([]);209  const [states, setStates] = useState<Record<string, CompState>>({});210  const [snap, setSnap] = useState<Record<string, CompCandidate>>({});211  const [method, setMethod] = useState<Method>("wmedian");212  const [rangePct, setRangePct] = useState(8);213  const [finalValue, setFinalValue] = useState<number | null>(null);214  const [finalTxt, setFinalTxt] = useState("");215  const [hover, setHover] = useState<string | null>(null);216  const [sortBy, setSortBy] = useState<"weight" | "distance" | "date" | "price">("weight");217  const [showAll, setShowAll] = useState(false);218  const [ready, setReady] = useState(false);219  const abortRef = useRef<AbortController | null>(null);220221  /* --- restauration locale --- */222  useEffect(() => {223    const id = setTimeout(() => {224      try {225        const raw = window.localStorage.getItem(key(head.uid));226        if (raw) {227          const s = JSON.parse(raw) as Saved;228          if (s.v === 1) {229            setParams({ ...DEFAULT_PARAMS, ...s.params });230            setSelected(s.selected ?? []);231            setStates(s.states ?? {});232            setSnap(s.snap ?? {});233            setMethod(s.method ?? "wmedian");234            setRangePct(s.rangePct ?? 8);235            setFinalValue(s.finalValue ?? null);236            if (s.finalValue != null) setFinalTxt(String(Math.round(s.finalValue)));237          }238        }239      } catch {}240      setReady(true);241    }, 0);242    return () => clearTimeout(id);243  }, [head.uid]);244245  /* --- sauvegarde locale (débouncée) --- */246  useEffect(() => {247    if (!ready) return;248    const id = setTimeout(() => {249      try {250        const s: Saved = { v: 1, params, selected, states, snap, method, rangePct, finalValue };251        window.localStorage.setItem(key(head.uid), JSON.stringify(s));252      } catch {}253    }, 300);254    return () => clearTimeout(id);255  }, [ready, params, selected, states, snap, method, rangePct, finalValue, head.uid]);256257  /* --- bassin de comparables --- */258  useEffect(() => {259    if (!ready || !head.geolocated) return;260    abortRef.current?.abort();261    const ctl = new AbortController();262    abortRef.current = ctl;263    const key = `${head.uid}|${params.months}|${params.radiusKm}|${params.includeListings ? 1 : 0}`;264    const qs = new URLSearchParams({265      uid: head.uid,266      months: String(params.months),267      radius: String(params.radiusKm),268      listings: params.includeListings ? "1" : "0",269      limit: "80",270    });271    fetch(`/api/avendre/comps?${qs}`, { signal: ctl.signal })272      .then(async (r) => {273        if (!r.ok) throw new Error((await r.json()).error ?? r.statusText);274        return r.json() as Promise<CompsPayload>;275      })276      .then((d) => {277        if (ctl.signal.aborted) return;278        setData(d);279        setDataKey(key);280      })281      .catch((e) => {282        if (ctl.signal.aborted) return;283        setErrState({ key, msg: String(e.message ?? e) });284      });285    return () => ctl.abort();286  }, [ready, head.uid, head.geolocated, params.months, params.radiusKm, params.includeListings]);287288  const loading = head.geolocated && ready && dataKey !== reqKey && error == null;289  const subject = data?.subject ?? null;290  const subjArea = subject?.floorArea ?? sqftToM2(head.areaSqft);291292  const pool = useMemo(() => {293    if (!data) return [] as CompCandidate[];294    let list = data.comps;295    if (params.sameType && subject) {296      const types = TYPE_MATCH[subject.typeProp] ?? TYPE_MATCH.autre;297      list = list.filter((c) => c.kind === "listing" || (c.propertyType != null && types.includes(c.propertyType)));298    }299    const sorted = [...list];300    sorted.sort((a, b) => {301      if (sortBy === "distance") return a.distanceM - b.distanceM;302      if (sortBy === "date") return b.date.localeCompare(a.date);303      if (sortBy === "price") return a.amount - b.amount;304      return Number(b.engineUsed) - Number(a.engineUsed) || b.weight - a.weight;305    });306    return sorted;307  }, [data, params.sameType, sortBy, subject]);308309  const visible = showAll ? pool : pool.slice(0, 30);310  const maxW = useMemo(() => Math.max(1e-6, ...pool.map((c) => c.weight)), [pool]);311312  const compOf = useCallback((id: string): CompCandidate | undefined => data?.comps.find((c) => c.id === id) ?? snap[id], [data, snap]);313314  const toggle = (c: CompCandidate) => {315    setSelected((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]));316    setSnap((prev) => (prev[c.id] ? prev : { ...prev, [c.id]: c }));317    setStates((prev) =>318      prev[c.id] ? prev : { ...prev, [c.id]: { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], weight: c.engineUsed ? 4 : 3 } }319    );320  };321  const takeEngine = () => {322    if (!data) return;323    const eng = data.comps.filter((c) => c.engineUsed);324    setSelected(eng.map((c) => c.id));325    setSnap((prev) => ({ ...prev, ...Object.fromEntries(eng.map((c) => [c.id, c])) }));326    setStates((prev) => ({327      ...prev,328      ...Object.fromEntries(eng.map((c) => [c.id, prev[c.id] ?? { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], weight: 4 }])),329    }));330  };331  const clearAll = () => setSelected([]);332  const resetAll = () => {333    setSelected([]);334    setStates({});335    setSnap({});336    setMethod("wmedian");337    setRangePct(8);338    setFinalValue(null);339    setFinalTxt("");340    setParams(DEFAULT_PARAMS);341    try {342      window.localStorage.removeItem(key(head.uid));343    } catch {}344  };345  const patchState = (id: string, patch: Partial<CompState>) => setStates((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));346347  /* --- réconciliation --- */348  const chosen = selected.map((id) => ({ c: compOf(id), s: states[id] })).filter((x): x is { c: CompCandidate; s: CompState } => !!x.c && !!x.s);349  const adjusted = chosen.map(({ c, s }) => adjustedOf(c, s));350  const weights = chosen.map(({ s }) => s.weight);351  const indications: Record<Method, number | null> = {352    median: median(adjusted),353    mean: mean(adjusted),354    wmean: weightedMean(adjusted, weights),355    wmedian: weightedMedian(adjusted, weights),356  };357  const indication = indications[method];358  const myValue = finalValue ?? (indication != null ? round100(indication) : null);359  const dispersion = (() => {360    if (adjusted.length < 2 || indication == null || indication <= 0) return null;361    const dev = adjusted.map((v) => Math.abs(v - indication) / indication).sort((a, b) => a - b);362    return dev[Math.floor(dev.length / 2)] * 100;363  })();364  const avgGross = chosen.length ? chosen.reduce((a, { c, s }) => a + grossAdjPct(c, s), 0) / chosen.length : null;365  const engineOverlap = chosen.filter(({ c }) => c.engineUsed).length;366367  /* --- repères de comparaison --- */368  const ev = head.eval;369  const vpMain = ev?.est ?? data?.engine.estimate ?? null;370  const vpLow = ev?.low ?? data?.engine.low ?? null;371  const vpHigh = ev?.high ?? data?.engine.high ?? null;372  const rows = [373    { key: "me", label: fr ? "Ma valeur conclue" : "My concluded value", value: myValue, band: myValue != null ? ([myValue * (1 - rangePct / 100), myValue * (1 + rangePct / 100)] as [number, number]) : null, tone: "me" as const },374    { key: "vp", label: fr ? "Vrai-Prix — hybride (mesure principale)" : "Vrai-Prix — hybrid (main measure)", value: vpMain, band: vpLow != null && vpHigh != null ? ([vpLow, vpHigh] as [number, number]) : null, tone: "main" as const },375    { key: "model", label: fr ? "Modèle hédonique" : "Hedonic model", value: ev?.modelEst ?? data?.engine.modelEstimate ?? null, tone: "method" as const },376    { key: "comps", label: fr ? "Comparables du moteur" : "Engine comparables", value: ev?.compsEst ?? data?.engine.compsEstimate ?? null, tone: "method" as const },377    { key: "cost", label: fr ? "Méthode du coût" : "Cost approach", value: ev?.costEst ?? null, tone: "method" as const },378    { key: "role_idx", label: fr ? "Rôle indexé (IAAO)" : "Indexed roll (IAAO)", value: ev?.roleEst ?? null, tone: "method" as const },379    { key: "ens", label: fr ? "Ensemble des méthodes" : "Ensemble of methods", value: ev?.ensEst ?? null, tone: "method" as const },380    { key: "ask", label: fr ? "Prix demandé" : "Asking price", value: head.price, tone: "ask" as const },381    { key: "role", label: fr ? "Valeur au rôle" : "Assessed value", value: ev?.valeurRole ?? null, tone: "role" as const },382  ].filter((r) => r.value != null || r.key === "me");383384  const diffPct = (v: number | null) => (v != null && myValue != null && v > 0 ? ((myValue - v) / v) * 100 : null);385  const inBand = myValue != null && vpLow != null && vpHigh != null ? myValue >= vpLow && myValue <= vpHigh : null;386387  const radarComps: RadarComp[] = visible.map((c) => ({388    id: c.id,389    lat: c.lat,390    lng: c.lng,391    label: `${c.street ?? "—"}${c.city ? `, ${c.city}` : ""}${c.kind === "listing" ? (fr ? " (en vente)" : " (for sale)") : ""}`,392    price: money(c.amount, lang),393    adjusted: money(states[c.id] ? adjustedOf(c, states[c.id]) : c.adjustedPrice, lang),394    date: dateFr(c.date, lang),395    distanceM: c.distanceM,396    weight: selected.includes(c.id) ? 1 : (c.weight / maxW) * 0.5,397  }));398399  const step = (n: number, done: boolean, label: string) => (400    <span className={`vp-mono flex items-center gap-1.5 text-[10px] uppercase tracking-[0.08em] ${done ? "text-ink" : "text-ink-3"}`}>401      <span className={`inline-flex h-5 w-5 items-center justify-center border text-[10px] font-bold ${done ? "border-ink bg-ink text-paper" : "border-[var(--line-strong)]"}`}>{n}</span>402      {label}403    </span>404  );405406  return (407    <div className="space-y-12 py-8">408      {/* ================= masthead ================= */}409      <section className="rise">410        <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">411          <nav className="vp-mono flex min-w-0 flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">412            <Link href="/" className="text-ink-2 hover:text-accent-deep">413              Vrai&nbsp;Prix414            </Link>415            <span aria-hidden="true">/</span>416            <Link href="/a-vendre" className="text-ink-2 hover:text-accent-deep">417              {fr ? "À vendre" : "For sale"}418            </Link>419            <span aria-hidden="true">/</span>420            <Link href={`/a-vendre/${encodeURIComponent(head.uid)}`} className="truncate text-ink-2 hover:text-accent-deep">421              {fr ? "Fiche" : "Listing"}422            </Link>423            <span aria-hidden="true">/</span>424            <span>{fr ? "Mon évaluation" : "My valuation"}</span>425          </nav>426          <div className="flex flex-wrap gap-4 print:hidden">427            {step(1, selected.length > 0, fr ? `Comparables (${selected.length})` : `Comparables (${selected.length})`)}428            {step(2, selected.length > 0, fr ? "Ajustements" : "Adjustments")}429            {step(3, myValue != null, fr ? "Réconciliation" : "Reconciliation")}430            {step(4, myValue != null, fr ? "Comparaison" : "Comparison")}431          </div>432        </div>433        <div className="mt-6 grid gap-x-10 gap-y-6 lg:grid-cols-[1fr_260px]">434          <div className="min-w-0">435            <span className="kicker">{fr ? "Atelier — mode exercice" : "Workshop — exercise mode"}</span>436            <h1 className="vp-display mt-2 text-[clamp(22px,3.2vw,34px)] font-bold uppercase leading-tight tracking-[-0.025em]">437              {fr ? "Mon évaluation" : "My valuation"} <span className="text-ink-3">·</span> {head.address || head.propertyType || "—"}438              {head.city ? <span className="text-ink-3"> · {head.city}</span> : null}439            </h1>440            <p className="vp-mono mt-3 text-[11px] uppercase tracking-[0.06em] text-ink-2">441              {[442                groupLabel(head.group, fr),443                `${fr ? "prix demandé" : "asking"} ${money(head.price, lang)}`,444                subjArea ? `${num(subjArea, lang)} m²` : null,445                (subject?.yearBuilt ?? head.yearBuilt) ? `${fr ? "constr." : "built"} ${subject?.yearBuilt ?? head.yearBuilt}` : null,446                subject?.landArea ? `${fr ? "terrain" : "lot"} ${num(subject.landArea, lang)} m²` : null,447                head.bedrooms != null ? `${head.bedrooms} ${fr ? "ch." : "bd"}` : null,448              ]449                .filter(Boolean)450                .join("  ·  ")}451            </p>452            {subject && (453              <p className="mt-2 text-[12.5px] text-ink-3">454                {subject.fromUnit455                  ? fr456                    ? `Caractéristiques du sujet lues au rôle d'évaluation (unité ${subject.unitId}) : type ${subject.typeProp}, aire d'étages, terrain, année.`457                    : `Subject characteristics read from the assessment roll (unit ${subject.unitId}): type ${subject.typeProp}, floor area, lot, year.`458                  : fr459                    ? "Aucune unité du rôle jumelée : les caractéristiques du sujet viennent de l'annonce (superficie et année déclarées par le vendeur)."460                    : "No roll unit matched: subject characteristics come from the listing (area and year declared by the seller)."}461              </p>462            )}463            <div className="mt-5 flex flex-wrap gap-2.5 print:hidden">464              <Link href={`/a-vendre/${encodeURIComponent(head.uid)}`} className="btn btn-ghost">465                ← {fr ? "Fiche de la propriété" : "Property listing"}466              </Link>467              <button type="button" className="btn btn-ghost" onClick={() => window.print()}>468                {fr ? "Imprimer / PDF" : "Print / PDF"}469              </button>470              <button type="button" className="btn btn-ghost" onClick={resetAll}>471                ↺ {fr ? "Réinitialiser l'atelier" : "Reset workshop"}472              </button>473            </div>474          </div>475          {head.image && (476            // eslint-disable-next-line @next/next/no-img-element477            <img src={head.image} alt="" referrerPolicy="no-referrer" className="aspect-[4/3] w-full border border-[var(--line)] object-cover" />478          )}479        </div>480      </section>481482      {!head.geolocated && (483        <p className="border-l-2 border-[var(--danger)] pl-4 text-[14px] text-ink-2">484          {fr ? "Cette annonce n'est pas géolocalisée : impossible de chercher des comparables autour d'elle." : "This listing is not geolocated: comparables cannot be searched around it."}485        </p>486      )}487488      {/* ================= 01 — bassin ================= */}489      <section>490        <Sect491          num="01"492          kicker={fr ? "Sélection" : "Selection"}493          title={fr ? "Choisir mes comparables" : "Choose my comparables"}494          right={495            <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">496              {data ? `${pool.length} ${fr ? "candidats" : "candidates"} · ${data.comps.filter((c) => c.engineUsed).length} ${fr ? "retenus par le moteur" : "kept by the engine"}` : ""}497            </span>498          }499        />500        <p className="mt-3 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">501          {fr502            ? "Ventes réelles publiées autour du sujet (et, si vous le souhaitez, annonces actives voisines — des prix demandés, pas des ventes). Cochez les propriétés que VOUS jugez comparables : même marché, même type, superficie et âge voisins, vente récente. Le moteur signale ses 12 choix, mais c'est votre jugement qui compte ici."503            : "Published real sales around the subject (and, optionally, nearby active listings — asking prices, not sales). Tick the properties YOU judge comparable: same market, same type, similar size and age, recent sale. The engine flags its 12 picks, but your judgment is what matters here."}504        </p>505506        <div className="mt-5 grid gap-4 print:hidden sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_1fr_auto]">507          <label className="block">508            <span className="klabel">{fr ? "Rayon de recherche" : "Search radius"}</span>509            <select value={params.radiusKm} onChange={(e) => setParams({ ...params, radiusKm: Number(e.target.value) })} className="vp-input mt-1 cursor-pointer">510              {[1, 2, 3, 5, 10, 20].map((k) => (511                <option key={k} value={k}>512                  {k} km513                </option>514              ))}515            </select>516          </label>517          <label className="block">518            <span className="klabel">{fr ? "Ventes depuis" : "Sales since"}</span>519            <select value={params.months} onChange={(e) => setParams({ ...params, months: Number(e.target.value) })} className="vp-input mt-1 cursor-pointer">520              {[12, 24, 36, 60].map((m) => (521                <option key={m} value={m}>522                  {m} {fr ? "mois" : "months"}523                </option>524              ))}525            </select>526          </label>527          <label className="block">528            <span className="klabel">{fr ? "Trier par" : "Sort by"}</span>529            <select value={sortBy} onChange={(e) => setSortBy(e.target.value as typeof sortBy)} className="vp-input mt-1 cursor-pointer">530              <option value="weight">{fr ? "Pertinence (poids moteur)" : "Relevance (engine weight)"}</option>531              <option value="distance">{fr ? "Distance" : "Distance"}</option>532              <option value="date">{fr ? "Date (récentes d'abord)" : "Date (newest first)"}</option>533              <option value="price">{fr ? "Prix" : "Price"}</option>534            </select>535          </label>536          <div className="flex flex-col justify-end gap-1.5 text-[13px]">537            <label className="flex items-center gap-2">538              <input type="checkbox" checked={params.sameType} onChange={(e) => setParams({ ...params, sameType: e.target.checked })} className="h-4 w-4 accent-[var(--accent)]" />539              {fr ? "Même famille de type" : "Same type family"}540            </label>541            <label className="flex items-center gap-2">542              <input type="checkbox" checked={params.includeListings} onChange={(e) => setParams({ ...params, includeListings: e.target.checked })} className="h-4 w-4 accent-[var(--accent)]" />543              {fr ? "Inclure les annonces actives" : "Include active listings"}544            </label>545          </div>546          <div className="flex flex-col justify-end gap-2">547            <button type="button" className="btn btn-primary whitespace-nowrap" onClick={takeEngine} disabled={!data}>548              {fr ? "Reprendre les 12 du moteur" : "Take the engine's 12"}549            </button>550            <button type="button" className="btn btn-ghost whitespace-nowrap" onClick={clearAll} disabled={!selected.length}>551              {fr ? "Tout retirer" : "Clear all"}552            </button>553          </div>554        </div>555556        {error && <p className="mt-4 text-[13px] text-[var(--danger)]">{error}</p>}557558        <div className="mt-6 grid gap-8 lg:grid-cols-[minmax(0,420px)_1fr]">559          {subject && (560            <div className="border border-[var(--line)]">561              <RadarMap subject={{ lat: subject.lat, lng: subject.lng, label: head.address ?? head.uid }} comps={radarComps} highlight={hover} onHover={setHover} />562              <p className="vp-mono border-t border-[var(--line)] px-3 py-2 text-[9.5px] uppercase tracking-[0.06em] text-ink-3">563                {fr ? "Gros cercles = comparables cochés. Survolez une ligne du tableau pour la repérer." : "Large circles = ticked comparables. Hover a table row to locate it."}564              </p>565            </div>566          )}567          <div className={`src-wrap ${loading ? "opacity-60" : ""}`}>568            <table className="src-table">569              <thead>570                <tr>571                  <th></th>572                  <th>#</th>573                  <th>{fr ? "Propriété" : "Property"}</th>574                  <th>{fr ? "Date" : "Date"}</th>575                  <th className="text-right">{fr ? "Prix" : "Price"}</th>576                  <th className="text-right">{fr ? "Dist." : "Dist."}</th>577                  <th className="text-right">m²</th>578                  <th className="text-right">{fr ? "Année" : "Year"}</th>579                  <th>{fr ? "Type" : "Type"}</th>580                </tr>581              </thead>582              <tbody>583                {visible.map((c, i) => {584                  const on = selected.includes(c.id);585                  return (586                    <tr587                      key={c.id}588                      onMouseEnter={() => setHover(c.id)}589                      onMouseLeave={() => setHover(null)}590                      onClick={() => toggle(c)}591                      className={`cursor-pointer ${on ? "bg-[var(--accent-soft)]" : ""} ${hover === c.id ? "outline outline-1 outline-[var(--accent)]" : ""}`}592                    >593                      <td>594                        <input type="checkbox" checked={on} onChange={() => toggle(c)} onClick={(e) => e.stopPropagation()} className="h-4 w-4 accent-[var(--accent)]" aria-label={fr ? "Retenir ce comparable" : "Keep this comparable"} />595                      </td>596                      <td className="vp-mono text-[11px] text-ink-3">{i + 1}</td>597                      <td>598                        <p className="font-semibold leading-snug">{c.street ?? "—"}</p>599                        <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">600                          {c.city ?? ""}601                          {c.engineUsed ? <span className="ml-2 border border-ink px-1 text-ink">{fr ? "moteur" : "engine"}</span> : null}602                          {c.kind === "listing" ? <span className="ml-2 border border-[var(--danger)] px-1 text-[var(--danger)]">{fr ? "en vente" : "for sale"}</span> : null}603                        </p>604                      </td>605                      <td className="vp-mono whitespace-nowrap text-[12px]">{c.kind === "listing" ? (fr ? "actuel" : "current") : dateFr(c.date, lang)}</td>606                      <td className="vp-mono whitespace-nowrap text-right font-bold">{money(c.amount, lang)}</td>607                      <td className="vp-mono whitespace-nowrap text-right text-[12px]">{fmtDist(c.distanceM)}</td>608                      <td className="vp-mono text-right text-[12px]">{c.floorArea ? num(c.floorArea, lang) : "—"}</td>609                      <td className="vp-mono text-right text-[12px]">{c.yearBuilt ?? "—"}</td>610                      <td className="text-[12px] text-ink-2">{c.propertyType ?? "—"}</td>611                    </tr>612                  );613                })}614                {!loading && data && visible.length === 0 && (615                  <tr>616                    <td colSpan={9} className="text-center text-ink-3">617                      {fr ? "Aucun candidat avec ces critères — élargissez le rayon ou la fenêtre." : "No candidate with these criteria — widen the radius or window."}618                    </td>619                  </tr>620                )}621              </tbody>622            </table>623            {pool.length > visible.length && (624              <button type="button" className="btn btn-ghost mt-3 w-full print:hidden" onClick={() => setShowAll(true)}>625                {fr ? `Voir les ${pool.length - visible.length} autres candidats` : `Show the ${pool.length - visible.length} other candidates`}626              </button>627            )}628          </div>629        </div>630      </section>631632      {/* ================= 02 — ajustements ================= */}633      <section>634        <Sect num="02" kicker={fr ? "Grille" : "Grid"} title={fr ? "Ajuster chaque comparable au sujet" : "Adjust each comparable to the subject"} />635        <p className="mt-3 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">636          {fr637            ? "On ajuste le comparable vers le sujet : si le comparable est plus petit, on ajoute ; s'il est meilleur, on retire. Les trois ajustements du moteur sont pré-remplis (marché = indice mensuel du type ; superficie = 50 % du $/m² marginal, borné à ±25 % ; âge = 0,5 %/an, borné à ±10 %). Modifiez-les, ajoutez les vôtres (garage, terrain, état, vue…) et pondérez de 1 à 5."638            : "Comparables are adjusted toward the subject: smaller comparable → add; better comparable → subtract. The engine's three adjustments are prefilled (market = monthly type index; area = 50% of marginal $/m², capped ±25%; age = 0.5%/yr, capped ±10%). Edit them, add your own (garage, lot, condition, view…) and weight 1 to 5."}639        </p>640        {chosen.length === 0 ? (641          <p className="mt-6 border border-dashed border-[var(--line-strong)] p-6 text-center text-[13.5px] text-ink-3">642            {fr ? "Cochez au moins un comparable ci-dessus pour ouvrir la grille." : "Tick at least one comparable above to open the grid."}643          </p>644        ) : (645          <div className="mt-6 space-y-5">646            {chosen.map(({ c, s }, i) => {647              const adj = adjustedOf(c, s);648              const gross = grossAdjPct(c, s);649              return (650                <article key={c.id} className="border border-[var(--line)] bg-surface p-4 sm:p-5">651                  <header className="flex flex-wrap items-baseline justify-between gap-2 border-b border-[var(--line)] pb-3">652                    <div className="min-w-0">653                      <p className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">654                        <span className="vp-mono mr-2 text-[11px] text-accent">C{i + 1}</span>655                        {c.street ?? "—"}656                        {c.city ? <span className="text-ink-3"> · {c.city}</span> : null}657                      </p>658                      <p className="vp-mono mt-0.5 text-[10px] uppercase tracking-[0.05em] text-ink-3">659                        {c.kind === "listing" ? (fr ? "annonce active — prix demandé" : "active listing — asking price") : `${fr ? "vendu le" : "sold"} ${dateFr(c.date, lang)}`} · {fmtDist(c.distanceM)}660                        {c.floorArea ? ` · ${num(c.floorArea, lang)} m²` : ""}661                        {c.yearBuilt ? ` · ${c.yearBuilt}` : ""}662                        {c.propertyType ? ` · ${c.propertyType}` : ""}663                        {c.engineUsed ? ` · ${fr ? "retenu par le moteur" : "kept by the engine"}` : ""}664                        {c.idProvinc ? (665                          <>666                            {" · "}667                            <Link href={`/estimation/${encodeURIComponent(c.idProvinc)}`} className="text-accent underline-offset-2 hover:underline">668                              {fr ? "fiche" : "record"}669                            </Link>670                          </>671                        ) : null}672                        {c.uid ? (673                          <>674                            {" · "}675                            <Link href={`/a-vendre/${encodeURIComponent(c.uid)}`} className="text-accent underline-offset-2 hover:underline">676                              {fr ? "annonce" : "listing"}677                            </Link>678                          </>679                        ) : null}680                      </p>681                    </div>682                    <div className="flex items-center gap-2 print:hidden">683                      <button type="button" className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-2 hover:text-accent-deep" onClick={() => patchState(c.id, { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], rev: (s.rev ?? 0) + 1 })}>684                        ↺ {fr ? "moteur" : "engine"}685                      </button>686                      <button type="button" className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-[var(--danger)]" onClick={() => setSelected((p) => p.filter((x) => x !== c.id))}>687                        ✕ {fr ? "retirer" : "remove"}688                      </button>689                    </div>690                  </header>691                  <div className="mt-4 grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_1fr_1.2fr]">692                    <div>693                      <p className="klabel">{c.kind === "listing" ? (fr ? "Prix demandé" : "Asking price") : fr ? "Prix de vente" : "Sale price"}</p>694                      <p className="vp-display mt-1 text-[20px] font-bold tracking-[-0.02em]">{money(c.amount, lang)}</p>695                      {c.valeurRole ? <p className="vp-mono text-[9.5px] uppercase tracking-[0.05em] text-ink-3">{fr ? "rôle" : "roll"} {money(c.valeurRole, lang)}</p> : null}696                    </div>697                    <MoneyInput698                      key={`t${s.rev ?? 0}`}699                      label={fr ? "Ajust. marché ($)" : "Market adj. ($)"}700                      value={s.adjTime}701                      onChange={(v) => patchState(c.id, { adjTime: v })}702                      hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjTime, lang)} · ${c.kind === "listing" ? (fr ? "actuel" : "current") : `${num(c.monthsAgo, lang)} ${fr ? "mois" : "mo"}`}`}703                    />704                    <MoneyInput705                      key={`a${s.rev ?? 0}`}706                      label={fr ? "Ajust. superficie ($)" : "Area adj. ($)"}707                      value={s.adjArea}708                      onChange={(v) => patchState(c.id, { adjArea: v })}709                      hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjArea, lang)}${subjArea && c.floorArea ? ` · Δ ${subjArea - c.floorArea >= 0 ? "+" : "−"}${num(Math.abs(subjArea - c.floorArea), lang)} m²` : ""}`}710                    />711                    <MoneyInput712                      key={`g${s.rev ?? 0}`}713                      label={fr ? "Ajust. âge ($)" : "Age adj. ($)"}714                      value={s.adjAge}715                      onChange={(v) => patchState(c.id, { adjAge: v })}716                      hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjAge, lang)}${(subject?.yearBuilt ?? head.yearBuilt) && c.yearBuilt ? ` · Δ ${(subject?.yearBuilt ?? head.yearBuilt)! - c.yearBuilt} ${fr ? "ans" : "yrs"}` : ""}`}717                    />718                    <div className="border-l-2 border-accent pl-4">719                      <p className="klabel">{fr ? "Prix ajusté" : "Adjusted price"}</p>720                      <p className="vp-display mt-1 text-[24px] font-bold tracking-[-0.02em]">{money(adj, lang)}</p>721                      <p className={`vp-mono text-[9.5px] uppercase tracking-[0.05em] ${gross > 25 ? "text-[var(--danger)]" : "text-ink-3"}`}>722                        {fr ? "ajust. brut" : "gross adj."} {pct(gross, lang, 1, false)}723                        {gross > 25 ? (fr ? " — comparable faible" : " — weak comparable") : ""}724                      </p>725                    </div>726                  </div>727                  {/* ajustements libres */}728                  <div className="mt-4 grid gap-x-6 gap-y-3 sm:grid-cols-[1fr_auto]">729                    <div className="space-y-2">730                      {s.extras.map((x) => (731                        <div key={x.id} className="grid grid-cols-[1fr_140px_auto] items-end gap-3">732                          <label className="block">733                            <span className="klabel">{fr ? "Ajustement libre" : "Custom adjustment"}</span>734                            <input value={x.label} onChange={(e) => patchState(c.id, { extras: s.extras.map((y) => (y.id === x.id ? { ...y, label: e.target.value } : y)) })} placeholder={fr ? "ex. garage, piscine, état, vue…" : "e.g. garage, pool, condition, view…"} className="vp-input mt-0.5 text-[13px]" />735                          </label>736                          <MoneyInput label="$" value={x.amount} onChange={(v) => patchState(c.id, { extras: s.extras.map((y) => (y.id === x.id ? { ...y, amount: v } : y)) })} />737                          <button type="button" className="vp-mono pb-2 text-[11px] text-[var(--danger)] print:hidden" onClick={() => patchState(c.id, { extras: s.extras.filter((y) => y.id !== x.id) })} aria-label={fr ? "Supprimer" : "Delete"}>738                            ✕739                          </button>740                        </div>741                      ))}742                      <button type="button" className="vp-mono text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent print:hidden" onClick={() => patchState(c.id, { extras: [...s.extras, { id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, label: "", amount: 0 }] })}>743                        + {fr ? "ajouter un ajustement" : "add an adjustment"}744                      </button>745                    </div>746                    <div>747                      <p className="klabel">{fr ? "Poids (1-5)" : "Weight (1-5)"}</p>748                      <div className="mt-1 flex gap-1">749                        {[1, 2, 3, 4, 5].map((w) => (750                          <button key={w} type="button" onClick={() => patchState(c.id, { weight: w })} className={`vp-mono h-8 w-8 border text-[12px] font-bold ${s.weight === w ? "border-ink bg-ink text-paper" : "border-[var(--line-strong)] text-ink-2"}`} aria-pressed={s.weight === w}>751                            {w}752                          </button>753                        ))}754                      </div>755                    </div>756                  </div>757                </article>758              );759            })}760            <p className="vp-mono overflow-x-auto whitespace-nowrap border-l-2 border-accent bg-surface px-3 py-2 text-[11.5px] text-ink">761              {fr ? "prix ajusté = prix + ajust. marché + ajust. superficie + ajust. âge + Σ ajustements libres" : "adjusted price = price + market adj. + area adj. + age adj. + Σ custom adjustments"}762            </p>763          </div>764        )}765      </section>766767      {/* ================= 03 — réconciliation ================= */}768      <section>769        <Sect num="03" kicker={fr ? "Conclusion" : "Conclusion"} title={fr ? "Réconcilier ma valeur" : "Reconcile my value"} />770        {chosen.length === 0 ? (771          <p className="mt-6 text-[13.5px] text-ink-3">{fr ? "La réconciliation s'ouvre dès qu'un comparable est retenu." : "Reconciliation opens as soon as one comparable is kept."}</p>772        ) : (773          <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.3fr_1fr]">774            <div>775              <p className="klabel">{fr ? "Indications de valeur selon la règle de réconciliation" : "Value indications by reconciliation rule"}</p>776              <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">777                {(778                  [779                    ["median", fr ? "Médiane" : "Median"],780                    ["mean", fr ? "Moyenne" : "Mean"],781                    ["wmean", fr ? "Moyenne pondérée" : "Weighted mean"],782                    ["wmedian", fr ? "Médiane pondérée" : "Weighted median"],783                  ] as [Method, string][]784                ).map(([k, label]) => (785                  <button key={k} type="button" onClick={() => setMethod(k)} aria-pressed={method === k} className={`border p-3 text-left ${method === k ? "border-ink bg-ink text-paper" : "border-[var(--line)] bg-surface hover:border-[var(--line-strong)]"}`}>786                    <p className={`vp-mono text-[9.5px] uppercase tracking-[0.08em] ${method === k ? "text-paper/70" : "text-ink-3"}`}>{label}</p>787                    <p className="vp-display mt-1 text-[17px] font-bold tracking-[-0.02em]">{indications[k] != null ? money(round100(indications[k]!), lang) : "—"}</p>788                  </button>789                ))}790              </div>791              <div className="mt-6 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">792                <div className="kv-cell">793                  <p className="k">{fr ? "Comparables" : "Comparables"}</p>794                  <p className="v">795                    {chosen.length} <span className="text-ink-3">({engineOverlap} {fr ? "du moteur" : "engine"})</span>796                  </p>797                </div>798                <div className="kv-cell">799                  <p className="k">{fr ? "Étendue ajustée" : "Adjusted range"}</p>800                  <p className="v text-[12.5px]">801                    {money(Math.min(...adjusted), lang)} – {money(Math.max(...adjusted), lang)}802                  </p>803                </div>804                <div className="kv-cell">805                  <p className="k">{fr ? "Dispersion médiane" : "Median dispersion"}</p>806                  <p className="v">{dispersion != null ? `±${pct(dispersion, lang, 1, false)}` : "—"}</p>807                </div>808                <div className="kv-cell">809                  <p className="k">{fr ? "Ajust. brut moyen" : "Avg gross adj."}</p>810                  <p className={`v ${avgGross != null && avgGross > 25 ? "text-[var(--danger)]" : ""}`}>{avgGross != null ? pct(avgGross, lang, 1, false) : "—"}</p>811                </div>812              </div>813              <p className="mt-4 max-w-2xl text-[13px] leading-relaxed text-ink-2">814                {fr815                  ? "La médiane résiste aux valeurs extrêmes ; la moyenne pondérée laisse parler votre confiance dans chaque comparable. Le rapport final ne retient qu'UNE valeur conclue, arrondie, avec une fourchette qui reflète la dispersion observée."816                  : "The median resists outliers; the weighted mean lets your confidence in each comparable speak. The final report keeps ONE concluded value, rounded, with a range reflecting the observed dispersion."}817              </p>818            </div>819            <div className="lg:border-l lg:border-[var(--line)] lg:pl-10">820              <label className="block">821                <span className="klabel">{fr ? "Ma valeur conclue ($)" : "My concluded value ($)"}</span>822                <input823                  value={finalTxt !== "" ? finalTxt : myValue != null ? String(Math.round(myValue)) : ""}824                  inputMode="numeric"825                  onChange={(e) => {826                    const t = e.target.value.replace(/\D/g, "");827                    setFinalTxt(t);828                    setFinalValue(t ? Number(t) : null);829                  }}830                  className="vp-input vp-display mt-1 text-[32px] font-bold tracking-[-0.03em]"831                />832              </label>833              <button834                type="button"835                className="vp-mono mt-2 text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent print:hidden"836                onClick={() => {837                  setFinalValue(null);838                  setFinalTxt("");839                }}840              >841                ↺ {fr ? "reprendre l'indication" : "take the indication"} ({indication != null ? money(round100(indication), lang) : "—"})842              </button>843              <label className="mt-6 block">844                <span className="klabel">845                  {fr ? "Fourchette" : "Range"} ±{rangePct} %846                </span>847                <input type="range" min={1} max={30} value={rangePct} onChange={(e) => setRangePct(Number(e.target.value))} className="mt-2 w-full accent-[var(--accent)]" />848              </label>849              {myValue != null && (850                <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.05em] text-ink-2">851                  {money(round100(myValue * (1 - rangePct / 100)), lang)} – {money(round100(myValue * (1 + rangePct / 100)), lang)}852                  {subjArea ? ` · ${num(Math.round(myValue / subjArea), lang)} $/m²` : ""}853                </p>854              )}855              {dispersion != null && (856                <button type="button" className="vp-mono mt-2 text-[10px] uppercase tracking-[0.08em] text-ink-3 print:hidden" onClick={() => setRangePct(Math.max(1, Math.min(30, Math.round(dispersion))))}>857                  {fr ? "→ aligner sur la dispersion" : "→ align on dispersion"} (±{Math.round(dispersion)} %)858                </button>859              )}860            </div>861          </div>862        )}863      </section>864865      {/* ================= 04 — comparaison ================= */}866      <section>867        <Sect num="04" kicker={fr ? "Confrontation" : "Confrontation"} title={fr ? "Ma valeur face à la mesure Vrai-Prix" : "My value vs. the Vrai-Prix measure"} />868        {myValue == null ? (869          <p className="mt-6 text-[13.5px] text-ink-3">{fr ? "Concluez une valeur pour la comparer." : "Conclude a value to compare it."}</p>870        ) : (871          <div className="mt-6 space-y-8">872            <div className="border border-[var(--line)] bg-surface p-3 sm:p-5">873              <DotPlot rows={rows} me={myValue} lang={lang} />874              <p className="vp-mono mt-1 text-[9.5px] uppercase tracking-[0.06em] text-ink-3">875                {fr ? "Bandes : ma fourchette (bleu) et l'intervalle P10-P90 de Vrai-Prix (encre). Trait pointillé : ma valeur." : "Bands: my range (blue) and the Vrai-Prix P10-P90 interval (ink). Dashed line: my value."}876              </p>877            </div>878            <div className="grid gap-x-12 gap-y-8 lg:grid-cols-[1.2fr_1fr]">879              <div className="src-wrap">880                <table className="src-table">881                  <thead>882                    <tr>883                      <th>{fr ? "Repère" : "Benchmark"}</th>884                      <th className="text-right">{fr ? "Valeur" : "Value"}</th>885                      <th className="text-right">{fr ? "Ma valeur − repère" : "My value − benchmark"}</th>886                      <th className="text-right">%</th>887                    </tr>888                  </thead>889                  <tbody>890                    {rows891                      .filter((r) => r.key !== "me")892                      .map((r) => {893                        const d = r.value != null ? myValue - r.value : null;894                        const p = diffPct(r.value);895                        return (896                          <tr key={r.key} className={r.tone === "main" ? "font-bold" : ""}>897                            <td>{r.label}</td>898                            <td className="vp-mono text-right">{money(r.value, lang)}</td>899                            <td className={`vp-mono text-right ${d != null && d < 0 ? "text-[var(--danger)]" : ""}`}>{d != null ? signedMoney(d, lang) : "—"}</td>900                            <td className={`vp-mono text-right ${p != null && p < 0 ? "text-[var(--danger)]" : ""}`}>{pct(p, lang, 1)}</td>901                          </tr>902                        );903                      })}904                  </tbody>905                </table>906              </div>907              <div className="space-y-4 text-[14px] leading-relaxed text-ink-2 lg:border-l lg:border-[var(--line)] lg:pl-10">908                <p className="klabel">{fr ? "Lecture" : "Reading"}</p>909                {vpMain != null && (910                  <p>911                    {fr ? (912                      <>913                        Votre valeur conclue (<b className="text-ink">{money(myValue, lang)}</b>) est{" "}914                        <b className="text-ink">{pct(diffPct(vpMain), lang, 1)}</b> par rapport à la mesure principale Vrai-Prix ({money(vpMain, lang)}915                        {ev?.confidence ? `, confiance ${ev.confidence}` : ""}).{" "}916                        {inBand === true ? "Elle se situe DANS l'intervalle P10-P90 de Vrai-Prix : les deux lectures sont compatibles." : inBand === false ? "Elle se situe HORS de l'intervalle P10-P90 de Vrai-Prix : l'écart mérite d'être expliqué (comparables différents, ajustements, superficie retenue)." : ""}917                      </>918                    ) : (919                      <>920                        Your concluded value (<b className="text-ink">{money(myValue, lang)}</b>) is <b className="text-ink">{pct(diffPct(vpMain), lang, 1)}</b> relative to the main Vrai-Prix measure ({money(vpMain, lang)}921                        {ev?.confidence ? `, confidence ${ev.confidence}` : ""}).{" "}922                        {inBand === true ? "It lies WITHIN the Vrai-Prix P10-P90 interval: both readings are compatible." : inBand === false ? "It lies OUTSIDE the Vrai-Prix P10-P90 interval: the gap deserves an explanation (different comparables, adjustments, retained area)." : ""}923                      </>924                    )}925                  </p>926                )}927                <p>928                  {fr ? (929                    <>930                      Face au prix demandé ({money(head.price, lang)}), votre évaluation suggère un affichage{" "}931                      <b className="text-ink">{myValue < head.price ? `au-dessus de la valeur de ${pct(((head.price - myValue) / myValue) * 100, lang, 1, false)}` : `en dessous de la valeur de ${pct(((myValue - head.price) / myValue) * 100, lang, 1, false)}`}</b>. Rappel : un prix demandé n&apos;est pas une valeur — le marché tranche à la vente.932                    </>933                  ) : (934                    <>935                      Against the asking price ({money(head.price, lang)}), your valuation suggests a listing{" "}936                      <b className="text-ink">{myValue < head.price ? `priced ${pct(((head.price - myValue) / myValue) * 100, lang, 1, false)} above value` : `priced ${pct(((myValue - head.price) / myValue) * 100, lang, 1, false)} below value`}</b>. Reminder: an asking price is not a value — the market decides at the sale.937                    </>938                  )}939                </p>940                <p>941                  {fr942                    ? `${engineOverlap} de vos ${chosen.length} comparables font partie des ${ev?.nComps ?? data?.engine.nComps ?? 12} retenus par le moteur${chosen.some(({ c }) => c.kind === "listing") ? " ; vous avez aussi retenu des annonces actives (prix demandés, non des ventes — à pondérer avec prudence)" : ""}.`943                    : `${engineOverlap} of your ${chosen.length} comparables are among the ${ev?.nComps ?? data?.engine.nComps ?? 12} kept by the engine${chosen.some(({ c }) => c.kind === "listing") ? "; you also kept active listings (asking prices, not sales — weight with caution)" : ""}.`}944                </p>945                {ev?.evaluatedAt && (946                  <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">947                    {fr ? "Mesure Vrai-Prix calculée le" : "Vrai-Prix measure computed on"} {ev.evaluatedAt}948                    {ev.unitId ? ` · ${fr ? "unité" : "unit"} ${ev.unitId}` : ""}949                  </p>950                )}951                {!ev && (952                  <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">953                    {fr ? "Aucune mesure stockée pour cette annonce : repère Vrai-Prix calculé à la volée (comparables du moteur seulement)." : "No stored measure for this listing: Vrai-Prix benchmark computed on the fly (engine comparables only)."}954                  </p>955                )}956              </div>957            </div>958          </div>959        )}960      </section>961962      <p className="vp-mono border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">963        {fr964          ? "Atelier d'évaluation — vos choix restent sur cet appareil. Ni la mesure Vrai-Prix ni votre évaluation ne constituent une évaluation professionnelle certifiée (OEAQ)."965          : "Valuation workshop — your choices stay on this device. Neither the Vrai-Prix measure nor your valuation is a certified professional appraisal (OEAQ)."}966      </p>967    </div>968  );969}970971// utilitaires exposés pour les tests972export { adjustedOf, grossAdjPct };973