// Vrai-Prix — atelier « mon évaluation » d'une propriété à vendre. /** * Quatre temps, comme un rapport d'évaluation par comparaison directe : * 01 bassin — ventes réelles (et annonces actives) autour du sujet, l'utilisateur * coche celles qu'il juge comparables (les 12 du moteur sont signalées) ; * 02 grille — ajustements en dollars (marché, superficie, âge, + libres), * pré-remplis avec les formules du moteur mais entièrement modifiables ; * 03 réconciliation — médiane, moyenne, moyennes pondérées ; valeur conclue ; * 04 comparaison — face à la mesure Vrai-Prix déjà calculée (hybride, hédonique, * comparables, coût, rôle indexé, ensemble), au prix demandé et au rôle. * Tout est conservé sur l'appareil (localStorage) — aucun envoi au serveur. */ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useLang } from "@/components/LangContext"; import RadarMap, { type RadarComp } from "@/components/RadarMap"; import { TYPE_MATCH } from "@/lib/engine"; import type { CompCandidate, CompsPayload } from "@/lib/comps"; import type { TypeGroupKey } from "@/lib/immoka"; import { groupLabel } from "./ListingCardView"; import { dateFr, mean, median, money, num, pct, round100, signedMoney, sqftToM2, weightedMean, weightedMedian } from "./fmt"; /* ---------------------------------- types --------------------------------- */ export interface WorkbenchHead { uid: string; address: string | null; city: string | null; propertyType: string | null; group: TypeGroupKey; price: number; image: string | null; bedrooms: number | null; bathrooms: number | null; areaSqft: number | null; lotSqft: number | null; yearBuilt: number | null; geolocated: boolean; eval: { est: number | null; low: number | null; high: number | null; confidence: string | null; modelEst: number | null; compsEst: number | null; costEst: number | null; roleEst: number | null; ensEst: number | null; valeurRole: number | null; nComps: number | null; unitId: string | null; evaluatedAt: string | null; } | null; } interface Extra { id: string; label: string; amount: number; } interface CompState { adjTime: number; adjArea: number; adjAge: number; extras: Extra[]; weight: number; // 1..5 rev?: number; // incrémenté à chaque « ↺ moteur » → remonte les champs (key) } type Method = "median" | "mean" | "wmean" | "wmedian"; interface Params { months: number; radiusKm: number; includeListings: boolean; sameType: boolean; } interface Saved { v: 1; params: Params; selected: string[]; states: Record; snap: Record; method: Method; rangePct: number; finalValue: number | null; } const DEFAULT_PARAMS: Params = { months: 24, radiusKm: 3, includeListings: true, sameType: true }; const key = (uid: string) => `vrai-prix-avendre:${uid}`; const adjustedOf = (c: CompCandidate, s: CompState) => c.amount + s.adjTime + s.adjArea + s.adjAge + s.extras.reduce((a, e) => a + (Number.isFinite(e.amount) ? e.amount : 0), 0); const grossAdjPct = (c: CompCandidate, s: CompState) => ((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; const fmtDist = (m: number) => (m < 1000 ? `${m} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`); /* ------------------------------- sous-blocs -------------------------------- */ function Sect({ num: n, kicker, title, right }: { num: string; kicker: string; title: string; right?: React.ReactNode }) { return (
{n} — {kicker} {right}

{title}

); } /** Champ monétaire : texte libre pendant la saisie ; remonté par `key` quand le parent réinitialise. */ function MoneyInput({ value, onChange, hint, label }: { value: number; onChange: (v: number) => void; hint?: string; label: string }) { const [txt, setTxt] = useState(() => String(Math.round(value))); return ( ); } /** Graphique à points : chaque repère sur sa ligne, échelle commune, ma valeur en trait vertical. */ function DotPlot({ rows, me, lang, }: { rows: { key: string; label: string; value: number | null; band?: [number, number] | null; tone: "me" | "main" | "method" | "ask" | "role" }[]; me: number | null; lang: string; }) { const vals = rows.flatMap((r) => [r.value, r.band?.[0], r.band?.[1]]).filter((v): v is number => v != null && Number.isFinite(v)); if (!vals.length) return null; const lo = Math.min(...vals) * 0.96; const hi = Math.max(...vals) * 1.04; const W = 760; const L = 250; const R = 24; const RH = 30; const H = rows.length * RH + 28; const x = (v: number) => L + ((v - lo) / (hi - lo)) * (W - L - R); const ticks = 4; return ( {Array.from({ length: ticks + 1 }, (_, i) => lo + ((hi - lo) * i) / ticks).map((v, i) => ( {new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { notation: "compact", maximumFractionDigits: 0 }).format(v)} $ ))} {me != null && Number.isFinite(me) && } {rows.map((r, i) => { const y = i * RH + RH / 2 + 4; const color = r.tone === "me" ? "var(--accent)" : r.tone === "ask" ? "var(--danger)" : r.tone === "role" ? "var(--ink-3)" : "var(--ink)"; return ( {r.label.length > 40 ? r.label.slice(0, 39) + "…" : r.label} {r.band && r.band[1] > r.band[0] && } {r.value != null && Number.isFinite(r.value) && ( <> {r.tone === "me" ? ( ) : ( )} 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}> {money(r.value, lang)} )} ); })} ); } /* --------------------------------- atelier --------------------------------- */ export default function EvalWorkbench({ head }: { head: WorkbenchHead }) { const { lang } = useLang(); const fr = lang === "fr"; const [params, setParams] = useState(DEFAULT_PARAMS); const [data, setData] = useState(null); // la réponse (ou l'erreur) porte la clé de la requête qui l'a produite : // « en chargement » = la requête courante n'est pas encore servie const reqKey = `${head.uid}|${params.months}|${params.radiusKm}|${params.includeListings ? 1 : 0}`; const [dataKey, setDataKey] = useState(null); const [errState, setErrState] = useState<{ key: string; msg: string } | null>(null); const error = errState?.key === reqKey ? errState.msg : null; const [selected, setSelected] = useState([]); const [states, setStates] = useState>({}); const [snap, setSnap] = useState>({}); const [method, setMethod] = useState("wmedian"); const [rangePct, setRangePct] = useState(8); const [finalValue, setFinalValue] = useState(null); const [finalTxt, setFinalTxt] = useState(""); const [hover, setHover] = useState(null); const [sortBy, setSortBy] = useState<"weight" | "distance" | "date" | "price">("weight"); const [showAll, setShowAll] = useState(false); const [ready, setReady] = useState(false); const abortRef = useRef(null); /* --- restauration locale --- */ useEffect(() => { const id = setTimeout(() => { try { const raw = window.localStorage.getItem(key(head.uid)); if (raw) { const s = JSON.parse(raw) as Saved; if (s.v === 1) { setParams({ ...DEFAULT_PARAMS, ...s.params }); setSelected(s.selected ?? []); setStates(s.states ?? {}); setSnap(s.snap ?? {}); setMethod(s.method ?? "wmedian"); setRangePct(s.rangePct ?? 8); setFinalValue(s.finalValue ?? null); if (s.finalValue != null) setFinalTxt(String(Math.round(s.finalValue))); } } } catch {} setReady(true); }, 0); return () => clearTimeout(id); }, [head.uid]); /* --- sauvegarde locale (débouncée) --- */ useEffect(() => { if (!ready) return; const id = setTimeout(() => { try { const s: Saved = { v: 1, params, selected, states, snap, method, rangePct, finalValue }; window.localStorage.setItem(key(head.uid), JSON.stringify(s)); } catch {} }, 300); return () => clearTimeout(id); }, [ready, params, selected, states, snap, method, rangePct, finalValue, head.uid]); /* --- bassin de comparables --- */ useEffect(() => { if (!ready || !head.geolocated) return; abortRef.current?.abort(); const ctl = new AbortController(); abortRef.current = ctl; const key = `${head.uid}|${params.months}|${params.radiusKm}|${params.includeListings ? 1 : 0}`; const qs = new URLSearchParams({ uid: head.uid, months: String(params.months), radius: String(params.radiusKm), listings: params.includeListings ? "1" : "0", limit: "80", }); fetch(`/api/avendre/comps?${qs}`, { signal: ctl.signal }) .then(async (r) => { if (!r.ok) throw new Error((await r.json()).error ?? r.statusText); return r.json() as Promise; }) .then((d) => { if (ctl.signal.aborted) return; setData(d); setDataKey(key); }) .catch((e) => { if (ctl.signal.aborted) return; setErrState({ key, msg: String(e.message ?? e) }); }); return () => ctl.abort(); }, [ready, head.uid, head.geolocated, params.months, params.radiusKm, params.includeListings]); const loading = head.geolocated && ready && dataKey !== reqKey && error == null; const subject = data?.subject ?? null; const subjArea = subject?.floorArea ?? sqftToM2(head.areaSqft); const pool = useMemo(() => { if (!data) return [] as CompCandidate[]; let list = data.comps; if (params.sameType && subject) { const types = TYPE_MATCH[subject.typeProp] ?? TYPE_MATCH.autre; list = list.filter((c) => c.kind === "listing" || (c.propertyType != null && types.includes(c.propertyType))); } const sorted = [...list]; sorted.sort((a, b) => { if (sortBy === "distance") return a.distanceM - b.distanceM; if (sortBy === "date") return b.date.localeCompare(a.date); if (sortBy === "price") return a.amount - b.amount; return Number(b.engineUsed) - Number(a.engineUsed) || b.weight - a.weight; }); return sorted; }, [data, params.sameType, sortBy, subject]); const visible = showAll ? pool : pool.slice(0, 30); const maxW = useMemo(() => Math.max(1e-6, ...pool.map((c) => c.weight)), [pool]); const compOf = useCallback((id: string): CompCandidate | undefined => data?.comps.find((c) => c.id === id) ?? snap[id], [data, snap]); const toggle = (c: CompCandidate) => { setSelected((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id])); setSnap((prev) => (prev[c.id] ? prev : { ...prev, [c.id]: c })); setStates((prev) => prev[c.id] ? prev : { ...prev, [c.id]: { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], weight: c.engineUsed ? 4 : 3 } } ); }; const takeEngine = () => { if (!data) return; const eng = data.comps.filter((c) => c.engineUsed); setSelected(eng.map((c) => c.id)); setSnap((prev) => ({ ...prev, ...Object.fromEntries(eng.map((c) => [c.id, c])) })); setStates((prev) => ({ ...prev, ...Object.fromEntries(eng.map((c) => [c.id, prev[c.id] ?? { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], weight: 4 }])), })); }; const clearAll = () => setSelected([]); const resetAll = () => { setSelected([]); setStates({}); setSnap({}); setMethod("wmedian"); setRangePct(8); setFinalValue(null); setFinalTxt(""); setParams(DEFAULT_PARAMS); try { window.localStorage.removeItem(key(head.uid)); } catch {} }; const patchState = (id: string, patch: Partial) => setStates((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } })); /* --- réconciliation --- */ const chosen = selected.map((id) => ({ c: compOf(id), s: states[id] })).filter((x): x is { c: CompCandidate; s: CompState } => !!x.c && !!x.s); const adjusted = chosen.map(({ c, s }) => adjustedOf(c, s)); const weights = chosen.map(({ s }) => s.weight); const indications: Record = { median: median(adjusted), mean: mean(adjusted), wmean: weightedMean(adjusted, weights), wmedian: weightedMedian(adjusted, weights), }; const indication = indications[method]; const myValue = finalValue ?? (indication != null ? round100(indication) : null); const dispersion = (() => { if (adjusted.length < 2 || indication == null || indication <= 0) return null; const dev = adjusted.map((v) => Math.abs(v - indication) / indication).sort((a, b) => a - b); return dev[Math.floor(dev.length / 2)] * 100; })(); const avgGross = chosen.length ? chosen.reduce((a, { c, s }) => a + grossAdjPct(c, s), 0) / chosen.length : null; const engineOverlap = chosen.filter(({ c }) => c.engineUsed).length; /* --- repères de comparaison --- */ const ev = head.eval; const vpMain = ev?.est ?? data?.engine.estimate ?? null; const vpLow = ev?.low ?? data?.engine.low ?? null; const vpHigh = ev?.high ?? data?.engine.high ?? null; const rows = [ { 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 }, { 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 }, { key: "model", label: fr ? "Modèle hédonique" : "Hedonic model", value: ev?.modelEst ?? data?.engine.modelEstimate ?? null, tone: "method" as const }, { key: "comps", label: fr ? "Comparables du moteur" : "Engine comparables", value: ev?.compsEst ?? data?.engine.compsEstimate ?? null, tone: "method" as const }, { key: "cost", label: fr ? "Méthode du coût" : "Cost approach", value: ev?.costEst ?? null, tone: "method" as const }, { key: "role_idx", label: fr ? "Rôle indexé (IAAO)" : "Indexed roll (IAAO)", value: ev?.roleEst ?? null, tone: "method" as const }, { key: "ens", label: fr ? "Ensemble des méthodes" : "Ensemble of methods", value: ev?.ensEst ?? null, tone: "method" as const }, { key: "ask", label: fr ? "Prix demandé" : "Asking price", value: head.price, tone: "ask" as const }, { key: "role", label: fr ? "Valeur au rôle" : "Assessed value", value: ev?.valeurRole ?? null, tone: "role" as const }, ].filter((r) => r.value != null || r.key === "me"); const diffPct = (v: number | null) => (v != null && myValue != null && v > 0 ? ((myValue - v) / v) * 100 : null); const inBand = myValue != null && vpLow != null && vpHigh != null ? myValue >= vpLow && myValue <= vpHigh : null; const radarComps: RadarComp[] = visible.map((c) => ({ id: c.id, lat: c.lat, lng: c.lng, label: `${c.street ?? "—"}${c.city ? `, ${c.city}` : ""}${c.kind === "listing" ? (fr ? " (en vente)" : " (for sale)") : ""}`, price: money(c.amount, lang), adjusted: money(states[c.id] ? adjustedOf(c, states[c.id]) : c.adjustedPrice, lang), date: dateFr(c.date, lang), distanceM: c.distanceM, weight: selected.includes(c.id) ? 1 : (c.weight / maxW) * 0.5, })); const step = (n: number, done: boolean, label: string) => ( {n} {label} ); return (
{/* ================= masthead ================= */}
{step(1, selected.length > 0, fr ? `Comparables (${selected.length})` : `Comparables (${selected.length})`)} {step(2, selected.length > 0, fr ? "Ajustements" : "Adjustments")} {step(3, myValue != null, fr ? "Réconciliation" : "Reconciliation")} {step(4, myValue != null, fr ? "Comparaison" : "Comparison")}
{fr ? "Atelier — mode exercice" : "Workshop — exercise mode"}

{fr ? "Mon évaluation" : "My valuation"} · {head.address || head.propertyType || "—"} {head.city ? · {head.city} : null}

{[ groupLabel(head.group, fr), `${fr ? "prix demandé" : "asking"} ${money(head.price, lang)}`, subjArea ? `${num(subjArea, lang)} m²` : null, (subject?.yearBuilt ?? head.yearBuilt) ? `${fr ? "constr." : "built"} ${subject?.yearBuilt ?? head.yearBuilt}` : null, subject?.landArea ? `${fr ? "terrain" : "lot"} ${num(subject.landArea, lang)} m²` : null, head.bedrooms != null ? `${head.bedrooms} ${fr ? "ch." : "bd"}` : null, ] .filter(Boolean) .join(" · ")}

{subject && (

{subject.fromUnit ? fr ? `Caractéristiques du sujet lues au rôle d'évaluation (unité ${subject.unitId}) : type ${subject.typeProp}, aire d'étages, terrain, année.` : `Subject characteristics read from the assessment roll (unit ${subject.unitId}): type ${subject.typeProp}, floor area, lot, year.` : fr ? "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)." : "No roll unit matched: subject characteristics come from the listing (area and year declared by the seller)."}

)}
← {fr ? "Fiche de la propriété" : "Property listing"}
{head.image && ( // eslint-disable-next-line @next/next/no-img-element )}
{!head.geolocated && (

{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."}

)} {/* ================= 01 — bassin ================= */}
{data ? `${pool.length} ${fr ? "candidats" : "candidates"} · ${data.comps.filter((c) => c.engineUsed).length} ${fr ? "retenus par le moteur" : "kept by the engine"}` : ""} } />

{fr ? "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." : "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."}

{error &&

{error}

}
{subject && (

{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."}

)}
{visible.map((c, i) => { const on = selected.includes(c.id); return ( setHover(c.id)} onMouseLeave={() => setHover(null)} onClick={() => toggle(c)} className={`cursor-pointer ${on ? "bg-[var(--accent-soft)]" : ""} ${hover === c.id ? "outline outline-1 outline-[var(--accent)]" : ""}`} > ); })} {!loading && data && visible.length === 0 && ( )}
# {fr ? "Propriété" : "Property"} {fr ? "Date" : "Date"} {fr ? "Prix" : "Price"} {fr ? "Dist." : "Dist."} m² {fr ? "Année" : "Year"} {fr ? "Type" : "Type"}
toggle(c)} onClick={(e) => e.stopPropagation()} className="h-4 w-4 accent-[var(--accent)]" aria-label={fr ? "Retenir ce comparable" : "Keep this comparable"} /> {i + 1}

{c.street ?? "—"}

{c.city ?? ""} {c.engineUsed ? {fr ? "moteur" : "engine"} : null} {c.kind === "listing" ? {fr ? "en vente" : "for sale"} : null}

{c.kind === "listing" ? (fr ? "actuel" : "current") : dateFr(c.date, lang)} {money(c.amount, lang)} {fmtDist(c.distanceM)} {c.floorArea ? num(c.floorArea, lang) : "—"} {c.yearBuilt ?? "—"} {c.propertyType ?? "—"}
{fr ? "Aucun candidat avec ces critères — élargissez le rayon ou la fenêtre." : "No candidate with these criteria — widen the radius or window."}
{pool.length > visible.length && ( )}
{/* ================= 02 — ajustements ================= */}

{fr ? "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." : "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."}

{chosen.length === 0 ? (

{fr ? "Cochez au moins un comparable ci-dessus pour ouvrir la grille." : "Tick at least one comparable above to open the grid."}

) : (
{chosen.map(({ c, s }, i) => { const adj = adjustedOf(c, s); const gross = grossAdjPct(c, s); return (

C{i + 1} {c.street ?? "—"} {c.city ? · {c.city} : null}

{c.kind === "listing" ? (fr ? "annonce active — prix demandé" : "active listing — asking price") : `${fr ? "vendu le" : "sold"} ${dateFr(c.date, lang)}`} · {fmtDist(c.distanceM)} {c.floorArea ? ` · ${num(c.floorArea, lang)} m²` : ""} {c.yearBuilt ? ` · ${c.yearBuilt}` : ""} {c.propertyType ? ` · ${c.propertyType}` : ""} {c.engineUsed ? ` · ${fr ? "retenu par le moteur" : "kept by the engine"}` : ""} {c.idProvinc ? ( <> {" · "} {fr ? "fiche" : "record"} ) : null} {c.uid ? ( <> {" · "} {fr ? "annonce" : "listing"} ) : null}

{c.kind === "listing" ? (fr ? "Prix demandé" : "Asking price") : fr ? "Prix de vente" : "Sale price"}

{money(c.amount, lang)}

{c.valeurRole ?

{fr ? "rôle" : "roll"} {money(c.valeurRole, lang)}

: null}
patchState(c.id, { adjTime: v })} hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjTime, lang)} · ${c.kind === "listing" ? (fr ? "actuel" : "current") : `${num(c.monthsAgo, lang)} ${fr ? "mois" : "mo"}`}`} /> patchState(c.id, { adjArea: v })} hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjArea, lang)}${subjArea && c.floorArea ? ` · Δ ${subjArea - c.floorArea >= 0 ? "+" : "−"}${num(Math.abs(subjArea - c.floorArea), lang)} m²` : ""}`} /> patchState(c.id, { adjAge: v })} hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjAge, lang)}${(subject?.yearBuilt ?? head.yearBuilt) && c.yearBuilt ? ` · Δ ${(subject?.yearBuilt ?? head.yearBuilt)! - c.yearBuilt} ${fr ? "ans" : "yrs"}` : ""}`} />

{fr ? "Prix ajusté" : "Adjusted price"}

{money(adj, lang)}

25 ? "text-[var(--danger)]" : "text-ink-3"}`}> {fr ? "ajust. brut" : "gross adj."} {pct(gross, lang, 1, false)} {gross > 25 ? (fr ? " — comparable faible" : " — weak comparable") : ""}

{/* ajustements libres */}
{s.extras.map((x) => (
patchState(c.id, { extras: s.extras.map((y) => (y.id === x.id ? { ...y, amount: v } : y)) })} />
))}

{fr ? "Poids (1-5)" : "Weight (1-5)"}

{[1, 2, 3, 4, 5].map((w) => ( ))}
); })}

{fr ? "prix ajusté = prix + ajust. marché + ajust. superficie + ajust. âge + Σ ajustements libres" : "adjusted price = price + market adj. + area adj. + age adj. + Σ custom adjustments"}

)}
{/* ================= 03 — réconciliation ================= */}
{chosen.length === 0 ? (

{fr ? "La réconciliation s'ouvre dès qu'un comparable est retenu." : "Reconciliation opens as soon as one comparable is kept."}

) : (

{fr ? "Indications de valeur selon la règle de réconciliation" : "Value indications by reconciliation rule"}

{( [ ["median", fr ? "Médiane" : "Median"], ["mean", fr ? "Moyenne" : "Mean"], ["wmean", fr ? "Moyenne pondérée" : "Weighted mean"], ["wmedian", fr ? "Médiane pondérée" : "Weighted median"], ] as [Method, string][] ).map(([k, label]) => ( ))}

{fr ? "Comparables" : "Comparables"}

{chosen.length} ({engineOverlap} {fr ? "du moteur" : "engine"})

{fr ? "Étendue ajustée" : "Adjusted range"}

{money(Math.min(...adjusted), lang)} – {money(Math.max(...adjusted), lang)}

{fr ? "Dispersion médiane" : "Median dispersion"}

{dispersion != null ? `±${pct(dispersion, lang, 1, false)}` : "—"}

{fr ? "Ajust. brut moyen" : "Avg gross adj."}

25 ? "text-[var(--danger)]" : ""}`}>{avgGross != null ? pct(avgGross, lang, 1, false) : "—"}

{fr ? "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." : "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."}

{myValue != null && (

{money(round100(myValue * (1 - rangePct / 100)), lang)} – {money(round100(myValue * (1 + rangePct / 100)), lang)} {subjArea ? ` · ${num(Math.round(myValue / subjArea), lang)} $/m²` : ""}

)} {dispersion != null && ( )}
)}
{/* ================= 04 — comparaison ================= */}
{myValue == null ? (

{fr ? "Concluez une valeur pour la comparer." : "Conclude a value to compare it."}

) : (

{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."}

{rows .filter((r) => r.key !== "me") .map((r) => { const d = r.value != null ? myValue - r.value : null; const p = diffPct(r.value); return ( ); })}
{fr ? "Repère" : "Benchmark"} {fr ? "Valeur" : "Value"} {fr ? "Ma valeur − repère" : "My value − benchmark"} %
{r.label} {money(r.value, lang)} {d != null ? signedMoney(d, lang) : "—"} {pct(p, lang, 1)}

{fr ? "Lecture" : "Reading"}

{vpMain != null && (

{fr ? ( <> Votre valeur conclue ({money(myValue, lang)}) est{" "} {pct(diffPct(vpMain), lang, 1)} par rapport à la mesure principale Vrai-Prix ({money(vpMain, lang)} {ev?.confidence ? `, confiance ${ev.confidence}` : ""}).{" "} {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)." : ""} ) : ( <> Your concluded value ({money(myValue, lang)}) is {pct(diffPct(vpMain), lang, 1)} relative to the main Vrai-Prix measure ({money(vpMain, lang)} {ev?.confidence ? `, confidence ${ev.confidence}` : ""}).{" "} {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)." : ""} )}

)}

{fr ? ( <> Face au prix demandé ({money(head.price, lang)}), votre évaluation suggère un affichage{" "} {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)}`}. Rappel : un prix demandé n'est pas une valeur — le marché tranche à la vente. ) : ( <> Against the asking price ({money(head.price, lang)}), your valuation suggests a listing{" "} {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`}. Reminder: an asking price is not a value — the market decides at the sale. )}

{fr ? `${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)" : ""}.` : `${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)" : ""}.`}

{ev?.evaluatedAt && (

{fr ? "Mesure Vrai-Prix calculée le" : "Vrai-Prix measure computed on"} {ev.evaluatedAt} {ev.unitId ? ` · ${fr ? "unité" : "unit"} ${ev.unitId}` : ""}

)} {!ev && (

{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)."}

)}
)}

{fr ? "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)." : "Valuation workshop — your choices stay on this device. Neither the Vrai-Prix measure nor your valuation is a certified professional appraisal (OEAQ)."}

); } // utilitaires exposés pour les tests export { adjustedOf, grossAdjPct };