SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
yesterdaylast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
17.6 KB · 373 lines typescript
Raw Blame History
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2/**3 * Rapport PDF UQO Éval — vectoriel (pdfkit) + logo officiel UQO :4 * fiche complète du rôle, historique en barres, plan radar des comparables5 * dessiné dans le PDF, tableau des ajustements. Format Lettre.6 * Généré dans le cadre des cours IMM1003 et IMM1033 (UQO).7 */8import PDFDocument from "pdfkit";9import path from "path";10import type { UnitEstimate } from "./estimator";1112const INK = "#16181b";13const INK3 = "#8b928c";14const PAPER = "#f4f5f3";15const SURFACE2 = "#f9fafa";16const GREEN = "#005F85"; // bleu UQO (PMS 7469)17const GREEN_DEEP = "#00465F";18const LIME = "#2BCCAE"; // turquoise UQO19const LIME_SOFT = "#d9f4ee";20const AMBER_SOFT = "#fdf3e2";21const DANGER_SOFT = "#fbe9e7";22const LINE = "#dedcd4";2324const W = 612;25const H = 792;26const M = 44; // marge2728const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);29const UQO_LOGO_BLANC = path.join(process.cwd(), "assets", "uqo", "UQO_blanc.png");3031/** Mention pédagogique obligatoire sur tous les rapports UQO Éval. */32const COURS =33  "Rapport généré dans le cadre des cours IMM1003 — Éléments d'évaluation immobilière et " +34  "IMM1033 — Méthodes du coût en évaluation immobilière, enseignés par Simon-Pierre Boucher (UQO).";3536const fmt = (v: number | null | undefined) =>37  v == null38    ? "—"39    : new Intl.NumberFormat("fr-CA", {40        style: "currency",41        currency: "CAD",42        maximumFractionDigits: 0,43      }).format(v);4445const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`;46const fmtDist = (m: number) => (m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`);4748const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT };49const CONF_TXT: Record<string, string> = {50  A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince",51};5253type Doc = InstanceType<typeof PDFDocument>;5455/** Logo officiel UQO (blanc, pour bandeau encre) + wordmark « Éval ».56 * Proportions du logo respectées (jamais déformé — hauteur seule imposée). */57function logo(doc: Doc, x: number, y: number, scale = 1) {58  const h = 26 * scale; // hauteur du logo — la largeur suit le ratio natif (2123×853)59  const w = h * (2123 / 853);60  doc.image(UQO_LOGO_BLANC, x, y, { height: h });61  doc.font("SG-Bold").fontSize(22 * scale).fillColor(LIME)62    .text("Éval", x + w + 8 * scale, y + h - 22 * scale, { lineBreak: false });63}6465function kicker(doc: Doc, txt: string, x: number, y: number, color = GREEN) {66  doc.rect(x, y + 3.5, 20, 2).fill(color);67  doc.font("JB-Bold").fontSize(8).fillColor(color)68    .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false });69}7071function pageFrame(doc: Doc, page: number, total: number, generated: string) {72  doc.rect(0, 0, W, H).fill(PAPER);73  // filet de bas de page74  doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);75  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(76    "UQO-EVAL.APP · PLATEFORME PÉDAGOGIQUE — ESTIMATION STATISTIQUE À TITRE INDICATIF (OEAQ)",77    M, H - 39, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false }78  );79  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(80    "COURS IMM1003 · IMM1033 — SIMON-PIERRE BOUCHER, UQO",81    M, H - 30, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false }82  );83  doc.font("JB-Bold").fontSize(7.5).fillColor(INK)84    .text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });85  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)86    .text(generated, W - M - 160, H - 30, { width: 160, align: "right" });87}8889function card(doc: Doc, x: number, y: number, w: number, h: number, opts?: { shadow?: boolean; fill?: string }) {90  if (opts?.shadow !== false) doc.roundedRect(x + 3.5, y + 3.5, w, h, 8).fill("#e3e1d9");91  doc.roundedRect(x, y, w, h, 8).fillAndStroke(opts?.fill ?? "#ffffff", INK);92  doc.lineWidth(1.3);93}9495function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) {96  doc.lineWidth(0.8);97  doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE);98  doc.font("JB-Bold").fontSize(5.8).fillColor(INK3)99    .text(k.toUpperCase(), x + 7, y + 6, { width: w - 14, characterSpacing: 0.7, height: 8, ellipsis: true });100  doc.font("SG-Bold").fontSize(9.2).fillColor(INK)101    .text(v, x + 7, y + 16.5, { width: w - 14, height: h - 20, ellipsis: true });102  doc.lineWidth(1.3);103}104105/** Plan radar vectoriel : sujet au centre, comparables par azimut/distance. */106function radar(doc: Doc, cx: number, cy: number, R: number, data: UnitEstimate) {107  const u = data.unit!;108  const comps = data.result.comps;109  const cos = Math.cos((u.lat * Math.PI) / 180);110  const pts = comps.map((c) => ({111    ...c,112    x: (c.lng - u.lng) * 111320 * cos,113    y: (c.lat - u.lat) * 110574,114  }));115  const maxM = Math.max(120, ...pts.map((p) => Math.hypot(p.x, p.y)));116  const ringSets = [117    [100, 250, 500], [250, 500, 1000], [500, 1000, 2000],118    [1000, 2500, 5000], [2500, 5000, 10000], [5000, 10000, 20000], [10000, 25000, 50000],119  ];120  const rings = ringSets.find((s) => maxM <= s[2]) ?? ringSets[ringSets.length - 1];121  const k = R / rings[2];122123  // cadre + grille124  doc.save();125  doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).clip();126  doc.rect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16)).fill("#ffffff");127  doc.lineWidth(0.5).strokeColor("#eceae3");128  for (let g = -R - 16; g <= R + 16; g += 24) {129    doc.moveTo(cx + g, cy - R - 16).lineTo(cx + g, cy + R + 16).stroke();130    doc.moveTo(cx - R - 16, cy + g).lineTo(cx + R + 16, cy + g).stroke();131  }132  // anneaux133  rings.forEach((m, i) => {134    const rr = m * k;135    doc.lineWidth(i === 2 ? 1.3 : 0.8).strokeColor(i === 2 ? INK : "#b9b7ae");136    if (i !== 2) doc.dash(3, { space: 3 });137    doc.circle(cx, cy, rr).stroke();138    doc.undash();139    doc.font("JB-Reg").fontSize(5.5).fillColor(INK3)140      .text(fmtDist(m), cx + rr * 0.7071 + 2, cy - rr * 0.7071 - 7, { lineBreak: false });141  });142  // croix143  doc.lineWidth(0.6).strokeColor("#d5d3ca");144  doc.moveTo(cx, cy - R).lineTo(cx, cy + R).stroke();145  doc.moveTo(cx - R, cy).lineTo(cx + R, cy).stroke();146  // comparables numérotés147  pts.forEach((p, i) => {148    const px = cx + p.x * k;149    const py = cy - p.y * k;150    const rr = 6.5 + p.weight * 3.5;151    doc.circle(px, py, rr).lineWidth(1.1).fillAndStroke(GREEN, INK);152    doc.font("JB-Bold").fontSize(rr > 8.5 ? 7 : 6).fillColor(PAPER)153      .text(String(i + 1), px - rr, py - 3, { width: 2 * rr, align: "center", lineBreak: false });154  });155  // sujet : losange lime156  doc.save().translate(cx, cy).rotate(45);157  doc.rect(-8, -8, 16, 16).lineWidth(1.6).fillAndStroke(LIME, INK);158  doc.rect(-2.5, -2.5, 5, 5).fill(INK);159  doc.restore();160  // nord161  doc.save().translate(cx + R + 2, cy - R - 2);162  doc.path("M0,-9 L4,5 L0,2 L-4,5 Z").fill(INK);163  doc.font("JB-Bold").fontSize(6).fillColor(INK).text("N", -2.2, 8, { lineBreak: false });164  doc.restore();165  doc.restore();166  // bordure du cadre167  doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).lineWidth(1.3).stroke(INK);168}169170export async function buildReport(data: UnitEstimate): Promise<Buffer> {171  const u = data.unit!;172  const s = u.specs;173  const r = data.result;174  const generated = new Date().toISOString().slice(0, 16).replace("T", " ");175176  const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `UQO Éval — ${u.adresse ?? ""}`, Author: "UQO Éval — cours IMM1003 · IMM1033 (Simon-Pierre Boucher, UQO)" } });177  doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));178  doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));179  doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));180  doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));181  doc.registerFont("Inter", F("Inter-Regular.ttf"));182183  const chunks: Buffer[] = [];184  doc.on("data", (c: Buffer) => chunks.push(c));185  const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));186187  /* =============================== PAGE 1 =============================== */188  pageFrame(doc, 1, 2, generated);189190  // bandeau encre191  doc.rect(0, 0, W, 96).fill(INK);192  logo(doc, M, 30);193  doc.font("JB-Reg").fontSize(7).fillColor(LIME)194    .text("LABORATOIRE PÉDAGOGIQUE D'ÉVALUATION IMMOBILIÈRE · RAPPORT D'ÉVALUATION", M, 66, { characterSpacing: 1.2, lineBreak: false });195  doc.font("JB-Reg").fontSize(6).fillColor("#8fb8c9")196    .text("COURS IMM1003 · IMM1033 — SIMON-PIERRE BOUCHER, UQO", M, 78, { characterSpacing: 1, lineBreak: false });197  doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")198    .text(`ID ${u.id}`, W - M - 200, 34, { width: 200, align: "right" })199    .text(`GÉNÉRÉ LE ${generated}`, W - M - 200, 46, { width: 200, align: "right" })200    .text(`MATRICULE ${s.matricule ?? "—"}`, W - M - 200, 58, { width: 200, align: "right" });201  doc.rect(0, 96, W, 3).fill(LIME);202203  // adresse + estimation204  let y = 122;205  kicker(doc, "Valeur estimée au marché", M, y);206  y += 16;207  doc.font("SG-Bold").fontSize(15).fillColor(INK)208    .text(`${(u.adresse ?? "").toUpperCase()}${s.apt ? " APP. " + s.apt : ""} · ${(u.municipalite ?? "").toUpperCase()}`,209      M, y, { width: W - 2 * M });210  y = doc.y + 6;211  doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(r.estimate), M, y);212  y = doc.y + 10;213214  // rangée fourchette / confiance / rôle / écart215  const rowY = y;216  const cols = [217    ["FOURCHETTE HONNÊTE", `${fmt(r.low)} → ${fmt(r.high)}`],218    ["ÉVALUATION MUNICIPALE 2026", fmt(u.valeurRole)],219    ["ÉCART VS RÔLE", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %` : "—"],220  ] as const;221  let cx0 = M;222  for (const [k, v] of cols) {223    doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text(k, cx0, rowY, { characterSpacing: 0.8, lineBreak: false });224    const lw = doc.widthOfString(k) + cols.length * 0.8;225    doc.font("SG-Bold").fontSize(13).fillColor(INK).text(v, cx0, rowY + 11, { lineBreak: false });226    cx0 += Math.max(doc.widthOfString(v), lw) + 34;227  }228  // pastille confiance229  const confTxt = `CONFIANCE ${r.confidenceLevel} · ${r.confidencePct} % — ${CONF_TXT[r.confidenceLevel].toUpperCase()}`;230  doc.font("JB-Bold").fontSize(7.5);231  const cw = doc.widthOfString(confTxt) + 20;232  doc.roundedRect(M, rowY + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[r.confidenceLevel], INK);233  doc.fillColor(r.confidenceLevel === "D" ? "#b3423a" : INK).text(confTxt, M + 10, rowY + 39.5, { lineBreak: false });234235  // fiche de la propriété236  y = rowY + 72;237  kicker(doc, "Fiche de la propriété — registre officiel (rôle 2026)", M, y);238  y += 16;239  const groups: [string, [string, string][]][] = [240    ["BÂTIMENT", [241      ["Année constr.", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (est.)" : ""}` : "—"],242      ["Aire étages", u.aireEtagesM2 ? `${u.aireEtagesM2} m²` : "—"],243      ["Étages", u.specs.nbEtages != null ? String(u.specs.nbEtages) : "—"],244      ["Genre", s.genreConstruction ?? "—"],245      ["Lien phys.", s.lienPhysique ?? "—"],246      ["Logements", u.nbLogements != null ? String(u.nbLogements) : "—"],247      ["Locaux n-rés.", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"],248      ["Chambres loc.", s.nbChambresLocatives != null ? String(s.nbChambresLocatives) : "—"],249    ]],250    ["TERRAIN", [251      ["Superficie", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m²` : "—"],252      ["Mes. frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"],253      ["CUBF", s.cubf ? String(s.cubf) : "—"],254      ["Usage", s.cubfLibelle ?? "—"],255      ["Unité voisin.", s.uniteVoisinage || "—"],256      ["Arrond.", s.arrond || "—"],257    ]],258    ["RÔLE D'ÉVALUATION", [259      ["Val. terrain", fmt(s.valeurTerrain)],260      ["Val. bâtiment", fmt(s.valeurBatiment)],261      ["Val. totale", fmt(u.valeurRole)],262      ["Rôle antér.", fmt(s.valeurAnterieure)],263      ["Cond. marché", s.datCondMarche ?? "—"],264      ["Adresses", s.nAdresses != null ? String(s.nAdresses) : "—"],265    ]],266  ];267  const gW = (W - 2 * M - 2 * 14) / 3;268  const cellH = 30;269  const gH = 4 * (cellH + 6) + 34;270  groups.forEach(([title, rows], gi) => {271    const gx = M + gi * (gW + 14);272    card(doc, gx, y, gW, gH);273    doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP)274      .text(title, gx + 12, y + 11, { characterSpacing: 1.2, lineBreak: false });275    doc.moveTo(gx + 12, y + 24).lineTo(gx + gW - 12, y + 24).dash(2, { space: 3 }).lineWidth(0.8).stroke(LINE).undash();276    const cw2 = (gW - 24 - 6) / 2;277    rows.forEach(([k, v], i) => {278      const col = i % 2, row = Math.floor(i / 2);279      kvCell(doc, gx + 12 + col * (cw2 + 6), y + 32 + row * (cellH + 6), cw2, cellH, k, v);280    });281  });282  y += gH + 24;283284  // historique285  kicker(doc, "Valeur estimée par année — 2021 → 2026", M, y);286  y += 16;287  const hist = u.history;288  const hMax = Math.max(...hist.map((h) => h.value ?? 0), 1);289  const histH = hist.length * 21 + 24;290  card(doc, M, y, W - 2 * M, histH);291  hist.forEach((h, i) => {292    const ly = y + 14 + i * 21;293    doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(String(h.year), M + 14, ly + 2, { lineBreak: false });294    const trackX = M + 52, trackW = W - 2 * M - 52 - 100;295    doc.rect(trackX, ly, trackW, 12).fill(SURFACE2);296    doc.rect(trackX, ly, Math.max(2, ((h.value ?? 0) / hMax) * trackW), 12).fill(h.year === 2026 ? INK : GREEN);297    doc.font("JB-Bold").fontSize(7.5).fillColor(INK)298      .text(fmt(h.value), W - M - 92, ly + 2, { width: 80, align: "right" });299  });300301  /* =============================== PAGE 2 =============================== */302  doc.addPage({ size: "LETTER", margin: 0 });303  pageFrame(doc, 2, 2, generated);304  doc.rect(0, 0, W, 8).fill(INK);305  doc.rect(0, 8, W, 2.5).fill(LIME);306307  y = 34;308  kicker(doc, "Pourquoi ce prix — le calcul, sans boîte noire", M, y);309  y += 16;310  const tileW = (W - 2 * M - 14) / 2;311  card(doc, M, y, tileW, 64);312  doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("MODÈLE HÉDONIQUE (LIGHTGBM · 690 000 VENTES QC)", M + 14, y + 11, { characterSpacing: 0.6 });313  doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.modelEstimate), M + 14, y + 24);314  doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text(`POIDS : ${(r.modelWeight * 100).toFixed(0)} %`, M + 14, y + 49);315  card(doc, M + tileW + 14, y, tileW, 64);316  doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("COMPARABLES AJUSTÉS (MÉDIANE PONDÉRÉE)", M + tileW + 28, y + 11, { characterSpacing: 0.6 });317  doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.compsEstimate), M + tileW + 28, y + 24);318  doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text(319    `POIDS : ${((1 - r.modelWeight) * 100).toFixed(0)} % · ${r.nCompsUsed} COMPS${r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}`,320    M + tileW + 28, y + 49);321  y += 88;322323  // radar + tableau324  kicker(doc, `Plan des comparables — ${r.nCompsUsed} ventes réelles · azimut + distance`, M, y);325  y += 18;326  const R = 118;327  radar(doc, M + R + 16, y + R + 16, R, data);328329  const tX = M + 2 * (R + 16) + 16;330  const tW = W - M - tX;331  doc.font("JB-Bold").fontSize(6.2).fillColor(INK3);332  let ty = y + 2;333  r.comps.slice(0, 12).forEach((c, i) => {334    const rowH = 22.5;335    if (i % 2 === 0) doc.rect(tX, ty - 2, tW, rowH).fill(SURFACE2);336    doc.circle(tX + 8, ty + 7, 6).lineWidth(0.9).fillAndStroke(GREEN, INK);337    doc.font("JB-Bold").fontSize(6).fillColor(PAPER).text(String(i + 1), tX + 2, ty + 4.5, { width: 12, align: "center", lineBreak: false });338    doc.font("SG-Bold").fontSize(7.3).fillColor(INK)339      .text(`${c.street ?? ""}`, tX + 19, ty, { width: tW - 90, height: 9, ellipsis: true, lineBreak: false });340    doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)341      .text(`${c.date} · ${fmtDist(c.distanceM)} · VENDU ${fmt(c.amount)} · T ${signed(c.adjTime)} · S ${signed(c.adjArea)} · Â ${signed(c.adjAge)}`,342        tX + 19, ty + 10, { width: tW - 78, height: 8, ellipsis: true, lineBreak: false });343    doc.font("SG-Bold").fontSize(8).fillColor(INK)344      .text(fmt(Math.round(c.adjustedPrice)), tX + tW - 68, ty + 3, { width: 68, align: "right", lineBreak: false });345    ty += rowH;346  });347  // légende radar348  const legY = y + 2 * (R + 16) + 8;349  doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text(350    "◆ = PROPRIÉTÉ ÉVALUÉE (CENTRE) · ● = VENTE COMPARABLE (TAILLE = POIDS) · T/S/Â = AJUSTEMENTS TEMPS / SUPERFICIE / ÂGE",351    M, legY, { width: W - 2 * M, characterSpacing: 0.4 });352353  // méthodo354  const my = Math.max(legY + 20, ty + 14);355  card(doc, M, my, W - 2 * M, 104, { fill: INK, shadow: false });356  doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("MÉTHODOLOGIE", M + 16, my + 12, { characterSpacing: 1.4 });357  doc.font("Inter").fontSize(7.8).fillColor("#e8e6df").text(358    "L'estimation combine (1) un modèle hédonique de gradient boosting entraîné sur ~690 000 transactions québécoises " +359    "(2021-2026) appariées au rôle d'évaluation foncière géoréférencé du MAMH — erreur médiane de 11 % — et (2) une médiane " +360    "pondérée des ventes comparables voisines, chacune ajustée pour l'évolution du marché (indice mensuel), l'écart de " +361    "superficie (50 % du $/m²) et l'écart d'âge (0,5 %/an, plafonné à ±10 %). La fourchette correspond aux 10e et 90e " +362    "centiles du modèle recentrés sur l'estimation finale. L'indice de confiance (A-D) reflète le nombre de comparables, " +363    "leur dispersion et la largeur de la fourchette. Validation selon la norme IAAO sur études de ratios.",364    M + 16, my + 26, { width: W - 2 * M - 32, lineGap: 1.5 });365  doc.font("JB-Reg").fontSize(6.8).fillColor(LIME)366    .text(367      "GÉNÉRÉ DANS LE CADRE DES COURS IMM1003 · IMM1033 (ÉVALUATION IMMOBILIÈRE) — SIMON-PIERRE BOUCHER, UQO",368      M + 16, my + 88, { width: W - 2 * M - 32, characterSpacing: 0.2, height: 14, ellipsis: true, lineBreak: false });369370  doc.end();371  return done;372}373