SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
2 h agolast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
8.9 KB · 233 lines tsx
Raw Blame History
1// UQO Éval — mode étudiant : l'analyse pédagogique complète d'une estimation.2/**3 * Décompose le résultat du moteur (engine.ts) SANS inventer de calcul :4 * chaque chiffre affiché est recomposé à partir des mêmes règles que le5 * moteur (sélection des comparables, gaussiennes de pondération, ajustements,6 * combinaison 65/35, score de confiance) appliquées aux données du résultat.7 */8"use client";9import { useLang } from "./LangContext";10import type { EstimateResult } from "@/lib/engine";1112const money = (v: number, lang: string) =>13  new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {14    style: "currency",15    currency: "CAD",16    maximumFractionDigits: 0,17  }).format(Math.round(v));1819/** Reconstruit la décomposition du score de confiance (mêmes règles que engine.ts). */20function confidenceParts(r: EstimateResult) {21  const base = 35;22  const comps = Math.min(25, r.comps.length * 2.5);23  const disp =24    r.compsDispersionPct != null ? Math.max(0, 20 - r.compsDispersionPct) : 0;25  const model = r.modelEstimate != null ? 15 : 0;26  const widthPct = r.estimate > 0 ? ((r.high - r.low) / r.estimate) * 100 : 100;27  const width = -Math.max(0, (widthPct - 30) / 3);28  return { base, comps, disp, model, width, widthPct };29}3031function Formula({ children }: { children: React.ReactNode }) {32  return (33    <p className="vp-mono mt-2 overflow-x-auto whitespace-nowrap border-l-2 border-accent bg-surface px-3 py-2 text-[11.5px] text-ink">34      {children}35    </p>36  );37}3839function Block({40  title,41  children,42}: {43  title: string;44  children: React.ReactNode;45}) {46  return (47    <div className="border-t border-[var(--line)] py-5 first:border-t-0 first:pt-0">48      <h3 className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">49        {title}50      </h3>51      <div className="mt-2 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">52        {children}53      </div>54    </div>55  );56}5758export default function StudentPanel({ r }: { r: EstimateResult }) {59  const { lang, t } = useLang();60  const fr = lang === "fr";61  const parts = confidenceParts(r);62  const total = Math.max(63    5,64    Math.min(97, parts.base + parts.comps + parts.disp + parts.model + parts.width)65  );66  const modelPct = Math.round(r.modelWeight * 100);67  const compsPct = 100 - modelPct;6869  const confRows: [string, number][] = [70    [t("pedConfBase"), parts.base],71    [t("pedConfComps"), parts.comps],72    [t("pedConfDisp"), parts.disp],73    [t("pedConfModel"), parts.model],74    [t("pedConfWidth"), parts.width],75  ];7677  return (78    <section className="rise border border-[var(--line-strong)] bg-surface-2 p-5 sm:p-8">79      <header className="flex flex-wrap items-baseline justify-between gap-2 border-b-2 border-ink pb-3">80        <h2 className="vp-display text-[clamp(18px,2.6vw,24px)] font-bold uppercase tracking-[-0.02em]">81          {fr ? "Analyse pédagogique" : "Pedagogical analysis"}82        </h2>83        <span className="vp-mono text-[10px] uppercase tracking-[0.1em] text-accent-deep">84          {t("studentMode")} · IMM1003 / IMM103385        </span>86      </header>87      <p className="mt-4 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">88        {t("pedIntro")}89      </p>9091      <div className="mt-6">92        {/* 1 — sélection des comparables */}93        <Block title={t("pedWhyCompsTitle")}>94          <p>{t("pedWhyComps")}</p>95          <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.05em] text-ink-3">96            {fr97              ? `Ici : ${r.nCompsUsed} comparables retenus${r.compsDispersionPct != null ? ` · dispersion médiane ±${r.compsDispersionPct} %` : ""}`98              : `Here: ${r.nCompsUsed} comparables kept${r.compsDispersionPct != null ? ` · median dispersion ±${r.compsDispersionPct}%` : ""}`}99          </p>100        </Block>101102        {/* 2 — pondération */}103        <Block title={t("pedWeightTitle")}>104          <p>{t("pedWeight")}</p>105          <Formula>106            poids = exp(−(distance∕1500&nbsp;m)²) × exp(−(mois∕24)²) ×107            exp(−(Δaire∕25&nbsp;%)²)108          </Formula>109        </Block>110111        {/* 3 — ajustements */}112        <Block title={t("pedAdjTitle")}>113          <ul className="list-none space-y-2 p-0">114            <li>115              <b className="text-ink">{t("adjTime")}</b> — {t("pedAdjTime")}116            </li>117            <li>118              <b className="text-ink">{t("adjArea")}</b> — {t("pedAdjArea")}119            </li>120            <li>121              <b className="text-ink">{t("adjAge")}</b> — {t("pedAdjAge")}122            </li>123          </ul>124          <Formula>125            prix ajusté = prix de vente + ajust. marché + ajust. superficie +126            ajust. âge127          </Formula>128        </Block>129130        {/* 4 — combinaison finale */}131        <Block title={t("pedBlendTitle")}>132          <p>{t("pedBlend")}</p>133          {/* barre de combinaison réelle */}134          <div className="mt-3 max-w-xl">135            <div className="flex h-8 overflow-hidden border border-[var(--line-strong)]">136              {r.modelEstimate != null && modelPct > 0 && (137                <span138                  className="flex items-center justify-center bg-ink text-[10.5px] font-bold text-paper"139                  style={{ width: `${modelPct}%` }}140                >141                  {modelPct} %142                </span>143              )}144              {r.compsEstimate != null && compsPct > 0 && (145                <span146                  className="flex items-center justify-center bg-accent text-[10.5px] font-bold text-white"147                  style={{ width: `${compsPct}%` }}148                >149                  {compsPct} %150                </span>151              )}152            </div>153            <p className="vp-mono mt-1.5 flex flex-wrap justify-between gap-2 text-[10px] uppercase tracking-[0.06em] text-ink-3">154              <span>155                {t("modelPart")} :{" "}156                {r.modelEstimate != null ? money(r.modelEstimate, lang) : "—"}157              </span>158              <span>159                {t("compsPart")} :{" "}160                {r.compsEstimate != null ? money(r.compsEstimate, lang) : "—"}161              </span>162            </p>163          </div>164          {r.modelEstimate != null && r.compsEstimate != null && (165            <Formula>166              {(r.modelWeight).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })}167              &nbsp;× {money(r.modelEstimate, lang)} +{" "}168              {(1 - r.modelWeight).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })}169              &nbsp;× {money(r.compsEstimate, lang)} ≈ {money(r.estimate, lang)}170            </Formula>171          )}172        </Block>173174        {/* 5 — indice de confiance décomposé */}175        <Block title={t("pedConfTitle")}>176          <div className="max-w-xl">177            {confRows.map(([label, v]) => (178              <div179                key={label}180                className="flex items-baseline justify-between gap-4 border-b border-[var(--line-soft)] py-1.5"181              >182                <span className="text-[13px]">{label}</span>183                <span184                  className={`vp-mono text-[12.5px] font-bold ${v < 0 ? "text-[var(--danger)]" : "text-ink"}`}185                >186                  {v >= 0 ? "+" : "−"}187                  {Math.abs(v).toFixed(1).replace(/\.0$/, "")}188                </span>189              </div>190            ))}191            <div className="flex items-baseline justify-between gap-4 border-t-2 border-ink py-2">192              <span className="vp-display text-[14px] font-bold uppercase">193                {t("pedConfTotal")}194              </span>195              <span className="vp-mono text-[14px] font-bold text-ink">196                {Math.round(total)} / 100 → {r.confidenceLevel}197              </span>198            </div>199            <p className="vp-mono mt-1 text-[10px] uppercase tracking-[0.05em] text-ink-3">200              {fr201                ? "Niveaux : A ≥ 75 · B ≥ 60 · C ≥ 45 · D < 45 (borné à 5-97)"202                : "Levels: A ≥ 75 · B ≥ 60 · C ≥ 45 · D < 45 (clamped 5-97)"}203            </p>204          </div>205        </Block>206207        {/* 6 — concepts clés */}208        <Block title={t("pedConceptsTitle")}>209          <div className="grid gap-x-8 gap-y-4 sm:grid-cols-3">210            {(211              [212                ["pedConceptMarket", "pedConceptMarketDef"],213                ["pedConceptAsking", "pedConceptAskingDef"],214                ["pedConceptSale", "pedConceptSaleDef"],215              ] as const216            ).map(([k, d]) => (217              <div key={k}>218                <p className="vp-mono text-[11px] font-bold uppercase tracking-[0.08em] text-accent-deep">219                  {t(k)}220                </p>221                <p className="mt-1 text-[13px] leading-snug">{t(d)}</p>222              </div>223            ))}224          </div>225          <p className="mt-4">226            <b className="text-ink">{t("pedRoleTitle")}</b> {t("pedRole")}227          </p>228        </Block>229      </div>230    </section>231  );232}233