// UQO Éval — mode étudiant : l'analyse pédagogique complète d'une estimation. /** * Décompose le résultat du moteur (engine.ts) SANS inventer de calcul : * chaque chiffre affiché est recomposé à partir des mêmes règles que le * moteur (sélection des comparables, gaussiennes de pondération, ajustements, * combinaison 65/35, score de confiance) appliquées aux données du résultat. */ "use client"; import { useLang } from "./LangContext"; import type { EstimateResult } from "@/lib/engine"; const money = (v: number, lang: string) => new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }).format(Math.round(v)); /** Reconstruit la décomposition du score de confiance (mêmes règles que engine.ts). */ function confidenceParts(r: EstimateResult) { const base = 35; const comps = Math.min(25, r.comps.length * 2.5); const disp = r.compsDispersionPct != null ? Math.max(0, 20 - r.compsDispersionPct) : 0; const model = r.modelEstimate != null ? 15 : 0; const widthPct = r.estimate > 0 ? ((r.high - r.low) / r.estimate) * 100 : 100; const width = -Math.max(0, (widthPct - 30) / 3); return { base, comps, disp, model, width, widthPct }; } function Formula({ children }: { children: React.ReactNode }) { return (

{children}

); } function Block({ title, children, }: { title: string; children: React.ReactNode; }) { return (

{title}

{children}
); } export default function StudentPanel({ r }: { r: EstimateResult }) { const { lang, t } = useLang(); const fr = lang === "fr"; const parts = confidenceParts(r); const total = Math.max( 5, Math.min(97, parts.base + parts.comps + parts.disp + parts.model + parts.width) ); const modelPct = Math.round(r.modelWeight * 100); const compsPct = 100 - modelPct; const confRows: [string, number][] = [ [t("pedConfBase"), parts.base], [t("pedConfComps"), parts.comps], [t("pedConfDisp"), parts.disp], [t("pedConfModel"), parts.model], [t("pedConfWidth"), parts.width], ]; return (

{fr ? "Analyse pédagogique" : "Pedagogical analysis"}

{t("studentMode")} · IMM1003 / IMM1033

{t("pedIntro")}

{/* 1 — sélection des comparables */}

{t("pedWhyComps")}

{fr ? `Ici : ${r.nCompsUsed} comparables retenus${r.compsDispersionPct != null ? ` · dispersion médiane ±${r.compsDispersionPct} %` : ""}` : `Here: ${r.nCompsUsed} comparables kept${r.compsDispersionPct != null ? ` · median dispersion ±${r.compsDispersionPct}%` : ""}`}

{/* 2 — pondération */}

{t("pedWeight")}

poids = exp(−(distance∕1500 m)²) × exp(−(mois∕24)²) × exp(−(Δaire∕25 %)²)
{/* 3 — ajustements */}
  • {t("adjTime")} — {t("pedAdjTime")}
  • {t("adjArea")} — {t("pedAdjArea")}
  • {t("adjAge")} — {t("pedAdjAge")}
prix ajusté = prix de vente + ajust. marché + ajust. superficie + ajust. âge
{/* 4 — combinaison finale */}

{t("pedBlend")}

{/* barre de combinaison réelle */}
{r.modelEstimate != null && modelPct > 0 && ( {modelPct} % )} {r.compsEstimate != null && compsPct > 0 && ( {compsPct} % )}

{t("modelPart")} :{" "} {r.modelEstimate != null ? money(r.modelEstimate, lang) : "—"} {t("compsPart")} :{" "} {r.compsEstimate != null ? money(r.compsEstimate, lang) : "—"}

{r.modelEstimate != null && r.compsEstimate != null && ( {(r.modelWeight).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })}  × {money(r.modelEstimate, lang)} +{" "} {(1 - r.modelWeight).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })}  × {money(r.compsEstimate, lang)} ≈ {money(r.estimate, lang)} )}
{/* 5 — indice de confiance décomposé */}
{confRows.map(([label, v]) => (
{label} {v >= 0 ? "+" : "−"} {Math.abs(v).toFixed(1).replace(/\.0$/, "")}
))}
{t("pedConfTotal")} {Math.round(total)} / 100 → {r.confidenceLevel}

{fr ? "Niveaux : A ≥ 75 · B ≥ 60 · C ≥ 45 · D < 45 (borné à 5-97)" : "Levels: A ≥ 75 · B ≥ 60 · C ≥ 45 · D < 45 (clamped 5-97)"}

{/* 6 — concepts clés */}
{( [ ["pedConceptMarket", "pedConceptMarketDef"], ["pedConceptAsking", "pedConceptAskingDef"], ["pedConceptSale", "pedConceptSaleDef"], ] as const ).map(([k, d]) => (

{t(k)}

{t(d)}

))}

{t("pedRoleTitle")} {t("pedRole")}

); }