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%
15.2 KB · 308 lines typescript
Raw Blame History
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2/**3 * Rapport PDF de parc immobilier — couverture agrégée (tuiles, historique du parc,4 * répartitions, table des propriétés) puis une page condensée par propriété.5 * 100 % vectoriel, identité UQO Éval (logo officiel UQO).6 * Généré dans le cadre des cours IMM1003 et IMM1033 (UQO).7 */8import PDFDocument from "pdfkit";9import path from "path";10import type { PortfolioResult, UnitEstimate } from "./estimator";1112const INK = "#16181b";13const INK3 = "#8b928c";14const PAPER = "#f4f5f3";15const SURFACE2 = "#f9fafa";16const GREEN = "#005F85"; // bleu UQO (PMS 7469)17const LIME = "#2BCCAE"; // turquoise UQO18const LIME_SOFT = "#ffe3e0";19const AMBER_SOFT = "#fdf3e2";20const DANGER_SOFT = "#fbe9e7";21const LINE = "#dedcd4";2223const W = 612;24const H = 792;25const M = 44;2627const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);28const fmt = (v: number | null | undefined) =>29  v == null30    ? "—"31    : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);32const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT };33const TYPE_FR: Record<string, string> = {34  unifamilial: "Unifamiliale", plex: "Plex", condo_ou_multi: "Condo/multi",35  chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre",36};3738type Doc = InstanceType<typeof PDFDocument>;3940const UQO_LOGO_BLANC_PATH = pathJoinUqo("UQO_blanc.png");41function pathJoinUqo(f: string) {42  return path.join(process.cwd(), "assets", "uqo", f);43}44/** Logo officiel UQO (blanc, bandeau encre) + wordmark « Éval » — proportions natives. */45function logo(doc: Doc, x: number, y: number, scale = 1) {46  const h = 26 * scale;47  const w = h * (2123 / 853);48  doc.image(UQO_LOGO_BLANC_PATH, x, y, { height: h });49  doc.font("SG-Bold").fontSize(22 * scale).fillColor(LIME)50    .text("Éval", x + w + 8 * scale, y + h - 22 * scale, { lineBreak: false });51}5253function kicker(doc: Doc, txt: string, x: number, y: number) {54  doc.rect(x, y + 3.5, 20, 2).fill(GREEN);55  doc.font("JB-Bold").fontSize(8).fillColor(GREEN)56    .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false });57}5859function pageFrame(doc: Doc, page: number, total: number, generated: string) {60  doc.rect(0, 0, W, H).fill(PAPER);61  doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);62  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(63    "UQO-EVAL.APP · RAPPORT DE PARC — PLATEFORME PÉDAGOGIQUE · ESTIMATION À TITRE INDICATIF",64    M, H - 39, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false });65  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(66    "COURS IMM1003 · IMM1033 — SIMON-PIERRE BOUCHER, UQO",67    M, H - 30, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false });68  doc.font("JB-Bold").fontSize(7.5).fillColor(INK)69    .text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });70  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)71    .text(generated, W - M - 160, H - 30, { width: 160, align: "right" });72}7374function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") {75  doc.roundedRect(x + 3, y + 3, w, h, 8).fill("#e3e1d9");76  doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(fill, INK);77}7879function tile(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string, hero = false) {80  card(doc, x, y, w, h, hero ? INK : "#ffffff");81  doc.font("SG-Bold").fontSize(16).fillColor(hero ? LIME : INK)82    .text(v, x + 12, y + 12, { width: w - 24, height: 22, ellipsis: true, lineBreak: false });83  doc.font("JB-Bold").fontSize(5.6).fillColor(hero ? "rgba(255,81,72,0.7)" : INK3)84    .text(k.toUpperCase(), x + 12, y + h - 16, { width: w - 24, characterSpacing: 0.7, lineBreak: false });85}8687function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) {88  doc.lineWidth(0.8);89  doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE);90  doc.font("JB-Bold").fontSize(5.6).fillColor(INK3)91    .text(k.toUpperCase(), x + 6, y + 5.5, { width: w - 12, characterSpacing: 0.6, height: 8, ellipsis: true });92  doc.font("SG-Bold").fontSize(8.8).fillColor(INK)93    .text(v, x + 6, y + 15.5, { width: w - 12, height: h - 19, ellipsis: true });94  doc.lineWidth(1.3);95}9697function hbars(doc: Doc, x: number, y: number, w: number, rows: [string, number, string][], accentLast = true) {98  const max = Math.max(...rows.map((r) => r[1]), 1);99  rows.forEach(([label, value, txt], i) => {100    const ly = y + i * 19;101    doc.font("JB-Bold").fontSize(7).fillColor(INK).text(label, x, ly + 2, { lineBreak: false });102    const tx = x + 50;103    const tw = w - 50 - 86;104    doc.rect(tx, ly, tw, 11).fill(SURFACE2);105    doc.rect(tx, ly, Math.max(2, (value / max) * tw), 11)106      .fill(accentLast && i === rows.length - 1 ? INK : GREEN);107    doc.font("JB-Bold").fontSize(7).fillColor(INK)108      .text(txt, x + w - 82, ly + 2, { width: 82, align: "right" });109  });110}111112/** Page condensée d'une propriété du parc. */113function propertyPage(doc: Doc, item: UnitEstimate, idx: number, total: number, pageNo: number, pageTotal: number, generated: string) {114  const u = item.unit!;115  const s = u.specs;116  const r = item.result;117  doc.addPage({ size: "LETTER", margin: 0 });118  pageFrame(doc, pageNo, pageTotal, generated);119  doc.rect(0, 0, W, 8).fill(INK);120  doc.rect(0, 8, W, 2.5).fill(LIME);121122  let y = 34;123  // index de propriété124  doc.circle(M + 10, y + 12, 12).lineWidth(1.4).fillAndStroke(GREEN, INK);125  doc.font("JB-Bold").fontSize(10).fillColor(PAPER)126    .text(String(idx + 1), M - 2, y + 7.5, { width: 24, align: "center", lineBreak: false });127  doc.font("JB-Reg").fontSize(7).fillColor(INK3)128    .text(`PROPRIÉTÉ ${idx + 1} / ${total}`, M + 30, y + 2, { characterSpacing: 1 });129  doc.font("SG-Bold").fontSize(14).fillColor(INK)130    .text(`${(u.adresse ?? "").toUpperCase()}${s.apt ? " APP. " + s.apt : ""} · ${(u.municipalite ?? "").toUpperCase()}`,131      M + 30, y + 12, { width: W - 2 * M - 30 });132  y = Math.max(doc.y + 10, y + 44);133134  doc.font("SG-Bold").fontSize(34).fillColor(INK).text(fmt(r.estimate), M, y);135  y = doc.y + 6;136  doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FOURCHETTE", M, y, { lineBreak: false });137  doc.font("SG-Bold").fontSize(11).fillColor(INK).text(`${fmt(r.low)} → ${fmt(r.high)}`, M, y + 10, { lineBreak: false });138  const confTxt = `CONFIANCE ${r.confidenceLevel} · ${r.confidencePct} %`;139  doc.font("JB-Bold").fontSize(7);140  const cw = doc.widthOfString(confTxt) + 16;141  doc.roundedRect(M + 220, y + 4, cw, 16, 4).lineWidth(1).fillAndStroke(CONF_BG[r.confidenceLevel], INK);142  doc.fillColor(INK).text(confTxt, M + 228, y + 8.5, { lineBreak: false });143  doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("RÔLE 2026", M + 380, y, { lineBreak: false });144  doc.font("SG-Bold").fontSize(11).fillColor(INK).text(fmt(u.valeurRole), M + 380, y + 10, { lineBreak: false });145  y += 34;146147  // fiche compacte148  const rows: [string, string][] = [149    ["Usage", s.cubfLibelle ?? "—"],150    ["Année constr.", u.anneeConstruction ? String(u.anneeConstruction) : "—"],151    ["Aire étages", u.aireEtagesM2 ? `${u.aireEtagesM2} m²` : "—"],152    ["Terrain", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m²` : "—"],153    ["Frontage", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"],154    ["Étages", s.nbEtages != null ? String(s.nbEtages) : "—"],155    ["Logements", u.nbLogements != null ? String(u.nbLogements) : "—"],156    ["Genre", s.genreConstruction ?? "—"],157    ["Lien phys.", s.lienPhysique ?? "—"],158    ["Val. terrain", fmt(s.valeurTerrain)],159    ["Val. bâtiment", fmt(s.valeurBatiment)],160    ["Matricule", s.matricule ?? "—"],161  ];162  kicker(doc, "Fiche du registre", M, y);163  y += 14;164  const cw2 = (W - 2 * M - 3 * 8) / 4;165  rows.forEach(([k, v], i) => {166    kvCell(doc, M + (i % 4) * (cw2 + 8), y + Math.floor(i / 4) * 36, cw2, 30, k, v);167  });168  y += 3 * 36 + 14;169170  // historique171  kicker(doc, "Valeur estimée 2021 → 2026", M, y);172  y += 16;173  card(doc, M, y, W - 2 * M, u.history.length * 19 + 20);174  hbars(doc, M + 14, y + 12, W - 2 * M - 28,175    u.history.map((h) => [String(h.year), h.value ?? 0, fmt(h.value)]));176  y += u.history.length * 19 + 34;177178  // top comparables179  kicker(doc, `Meilleurs comparables (${Math.min(5, r.comps.length)})`, M, y);180  y += 14;181  r.comps.slice(0, 5).forEach((c, i) => {182    const ly = y + i * 17;183    if (i % 2 === 0) doc.rect(M, ly - 2, W - 2 * M, 16).fill(SURFACE2);184    doc.font("SG-Bold").fontSize(7.5).fillColor(INK)185      .text(`${i + 1}. ${c.street ?? ""}, ${c.city ?? ""}`, M + 6, ly, { width: 300, height: 9, ellipsis: true, lineBreak: false });186    doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)187      .text(`${c.date} · ${c.distanceM < 1000 ? Math.round(c.distanceM) + " m" : (c.distanceM / 1000).toFixed(1) + " km"}`,188        M + 316, ly + 1, { lineBreak: false });189    doc.font("SG-Bold").fontSize(8).fillColor(INK)190      .text(fmt(Math.round(c.adjustedPrice)), W - M - 90, ly, { width: 84, align: "right", lineBreak: false });191  });192}193194export async function buildPortfolioReport(pf: PortfolioResult): Promise<Buffer> {195  const { items, aggregates: a } = pf;196  const generated = new Date().toISOString().slice(0, 16).replace("T", " ");197  const pageTotal = 1 + items.length;198199  const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: "UQO Éval — Rapport de parc immobilier", Author: "UQO Éval — cours IMM1003 · IMM1033 (Simon-Pierre Boucher, UQO)" } });200  doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));201  doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));202  doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));203  doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));204  doc.registerFont("Inter", F("Inter-Regular.ttf"));205206  const chunks: Buffer[] = [];207  doc.on("data", (c: Buffer) => chunks.push(c));208  const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));209210  /* ------------------------------ COUVERTURE ------------------------------ */211  pageFrame(doc, 1, pageTotal, generated);212  doc.rect(0, 0, W, 110).fill(INK);213  logo(doc, M, 32);214  doc.font("JB-Reg").fontSize(7).fillColor(LIME)215    .text("RAPPORT DE PARC IMMOBILIER · LABORATOIRE PÉDAGOGIQUE UQO ÉVAL", M, 70, { characterSpacing: 1.2, lineBreak: false });216  doc.font("JB-Reg").fontSize(6).fillColor("#8fb8c9")217    .text("COURS IMM1003 · IMM1033 — SIMON-PIERRE BOUCHER, UQO", M, 82, { characterSpacing: 1, lineBreak: false });218  doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")219    .text(`${a.count} PROPRIÉTÉS`, W - M - 200, 38, { width: 200, align: "right" })220    .text(`GÉNÉRÉ LE ${generated}`, W - M - 200, 50, { width: 200, align: "right" });221  doc.rect(0, 110, W, 3).fill(LIME);222223  let y = 134;224  kicker(doc, "Valeur totale estimée du parc", M, y);225  y += 16;226  doc.font("SG-Bold").fontSize(44).fillColor(INK).text(fmt(a.totalEstimate), M, y);227  y = doc.y + 8;228  doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("FOURCHETTE CUMULÉE", M, y, { lineBreak: false });229  doc.font("SG-Bold").fontSize(12).fillColor(INK).text(`${fmt(a.totalLow)} → ${fmt(a.totalHigh)}`, M, y + 10, { lineBreak: false });230  const confTxt = `CONFIANCE PONDÉRÉE ${a.confidenceLevel} · ${a.confidencePct} %`;231  doc.font("JB-Bold").fontSize(7.5);232  const cw = doc.widthOfString(confTxt) + 20;233  doc.roundedRect(M + 260, y + 2, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[a.confidenceLevel], INK);234  doc.fillColor(INK).text(confTxt, M + 270, y + 7.5, { lineBreak: false });235  y += 38;236237  // tuiles238  const tiles: [string, string, boolean][] = [239    ["Propriétés", String(a.count), true],240    ["Évaluation municipale", fmt(a.totalRole), false],241    ["Écart vs rôle", a.ecartRolePct != null ? `${a.ecartRolePct >= 0 ? "+" : ""}${a.ecartRolePct.toFixed(0)} %` : "—", false],242    ["Croissance 2021→2026", a.growthPct != null ? `${a.growthPct >= 0 ? "+" : ""}${a.growthPct.toFixed(0)} %` : "—", true],243    ["Aire habitable totale", `${a.totalFloorArea.toLocaleString("fr-CA")} m²`, false],244    ["Terrain total", `${a.totalLandArea.toLocaleString("fr-CA")} m²`, false],245    ["Logements", String(a.totalDwellings), false],246    ["Année moyenne", a.avgYearBuilt ? String(a.avgYearBuilt) : "—", false],247  ];248  const tw = (W - 2 * M - 3 * 10) / 4;249  tiles.forEach(([k, v, hero], i) => {250    tile(doc, M + (i % 4) * (tw + 10), y + Math.floor(i / 4) * 58, tw, 50, k, v, hero);251  });252  y += 2 * 58 + 12;253254  // historique du parc255  kicker(doc, "Valeur du parc par année (unités couvertes)", M, y);256  y += 14;257  card(doc, M, y, (W - 2 * M - 12) * 0.58, 6 * 19 + 20);258  hbars(doc, M + 12, y + 12, (W - 2 * M - 12) * 0.58 - 24,259    a.history.map((h) => [String(h.year), h.total, fmt(h.total)]));260261  // répartitions262  const rx = M + (W - 2 * M - 12) * 0.58 + 12;263  const rw = W - M - rx;264  card(doc, rx, y, rw, 6 * 19 + 20);265  doc.font("JB-Bold").fontSize(6.2).fillColor(INK3).text("PAR MUNICIPALITÉ", rx + 12, y + 10, { characterSpacing: 0.8 });266  a.municipalities.slice(0, 3).forEach((m, i) => {267    const ly = y + 22 + i * 15;268    doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(m.name, rx + 12, ly, { width: rw - 100, height: 9, ellipsis: true, lineBreak: false });269    doc.font("JB-Reg").fontSize(6.8).fillColor(INK3).text(`${m.count} · ${fmt(m.total)}`, rx + rw - 96, ly, { width: 86, align: "right", lineBreak: false });270  });271  doc.font("JB-Bold").fontSize(6.2).fillColor(INK3).text("PAR TYPE", rx + 12, y + 72, { characterSpacing: 0.8 });272  a.types.slice(0, 3).forEach((tp, i) => {273    const ly = y + 84 + i * 15;274    doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(TYPE_FR[tp.type] ?? tp.type, rx + 12, ly, { width: rw - 100, height: 9, ellipsis: true, lineBreak: false });275    doc.font("JB-Reg").fontSize(6.8).fillColor(INK3).text(`${tp.count} · ${fmt(tp.total)}`, rx + rw - 96, ly, { width: 86, align: "right", lineBreak: false });276  });277  y += 6 * 19 + 34;278279  // table des propriétés280  kicker(doc, "Sommaire des propriétés", M, y);281  y += 14;282  items.slice(0, 14).forEach((it, i) => {283    const u = it.unit!;284    const ly = y + i * 17;285    if (i % 2 === 0) doc.rect(M, ly - 2, W - 2 * M, 16).fill(SURFACE2);286    doc.circle(M + 8, ly + 5, 6).lineWidth(0.9).fillAndStroke(GREEN, INK);287    doc.font("JB-Bold").fontSize(6).fillColor(PAPER).text(String(i + 1), M + 2, ly + 2.5, { width: 12, align: "center", lineBreak: false });288    doc.font("SG-Bold").fontSize(7.5).fillColor(INK)289      .text(`${u.adresse ?? ""} · ${u.municipalite ?? ""}`, M + 20, ly, { width: 280, height: 9, ellipsis: true, lineBreak: false });290    doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)291      .text(`CONF. ${it.result.confidenceLevel}`, M + 310, ly + 1, { lineBreak: false });292    doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)293      .text(`RÔLE ${fmt(u.valeurRole)}`, M + 360, ly + 1, { width: 90, lineBreak: false });294    doc.font("SG-Bold").fontSize(8).fillColor(INK)295      .text(fmt(it.result.estimate), W - M - 80, ly, { width: 76, align: "right", lineBreak: false });296  });297  if (items.length > 14) {298    doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)299      .text(`+ ${items.length - 14} autres propriétés — voir pages suivantes`, M, y + 14 * 17 + 2);300  }301302  /* --------------------------- PAGES PROPRIÉTÉS --------------------------- */303  items.forEach((it, i) => propertyPage(doc, it, i, items.length, i + 2, pageTotal, generated));304305  doc.end();306  return done;307}308