SPB Git

spb/vrai-prix Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

TypeScript 96.7% CSS 3.1%
9.9 KB · 207 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Rapport statistique provincial — PDF vectoriel : valeur totale du parc4 * immobilier québécois, évolution 2021-2026, répartition par type et5 * palmarès complet des 200 plus grandes municipalités.6 */7import PDFDocument from "pdfkit";8import path from "path";9import type { ProvStats } from "@/components/StatsView";1011const INK = "#141814";12const INK3 = "#8b928c";13const PAPER = "#f5f3ee";14const SURFACE2 = "#faf9f5";15const GREEN = "#9e2a25";16const GREEN_DEEP = "#771f1b";17const LIME = "#ff5148";1819const W = 612;20const H = 792;21const M = 44;22const CW = W - 2 * M;2324const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);25const money = (v: number) =>26  new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);27const compact = (v: number) => {28  if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`;29  if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`;30  if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`;31  return money(v);32};33const num = (v: number) => v.toLocaleString("fr-CA");34const TYPE_FR: Record<string, string> = {35  unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi",36  chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre",37};38const POP_QC = 9_111_000;39const BUDGET_QC = 165_800_000_000;40const PIB_QC = 640_000_000_000;4142type Doc = InstanceType<typeof PDFDocument>;4344function logo(doc: Doc, x: number, y: number) {45  doc.font("SG-Bold").fontSize(24);46  doc.fillColor(PAPER).text("Vrai", x, y, { lineBreak: false });47  const w = doc.widthOfString("Vrai");48  const bx = x + w + 4;49  doc.save();50  doc.rotate(-3, { origin: [bx, y + 12] });51  doc.roundedRect(bx, y - 3, doc.widthOfString("Prix") + 12, 30, 5).fill(LIME);52  doc.fillColor(INK).text("Prix", bx + 6, y, { lineBreak: false });53  doc.restore();54}5556function kicker(doc: Doc, txt: string, x: number, y: number) {57  doc.rect(x, y + 3.5, 20, 2).fill(GREEN);58  doc.font("JB-Bold").fontSize(8).fillColor(GREEN)59    .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false });60}6162function frame(doc: Doc, page: number, total: number, generated: string) {63  doc.rect(0, 0, W, H).fill(PAPER);64  doc.rect(M, H - 46, CW, 1.2).fill(INK);65  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(66    "VRAI-PRIX.COM — RAPPORT STATISTIQUE PROVINCIAL · ESTIMATIONS STATISTIQUES (MODÈLE HÉDONIQUE) · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI",67    M, H - 38, { width: CW - 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 hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac: number, txt: string, sub: string | null, dark = false) {80  doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(label, x, y + 3, { width: 82, height: 9, ellipsis: true, lineBreak: false });81  const tx = x + 88;82  const tw = w - 88 - 118;83  doc.rect(tx, y, tw, 13).fill(SURFACE2);84  doc.rect(tx, y, Math.max(2, frac * tw), 13).fill(dark ? INK : GREEN);85  doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(txt, x + w - 112, y + (sub ? 0 : 3), { width: 112, align: "right" });86  if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" });87}8889export async function buildStatsReport(s: ProvStats): Promise<Buffer> {90  const generated = new Date().toISOString().slice(0, 16).replace("T", " ");91  const PER_PAGE = 42;92  const munPages = Math.ceil(s.par_ville.length / PER_PAGE);93  const TOTAL = 1 + munPages;9495  const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: "Vrai-Prix — Rapport statistique provincial" } });96  doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));97  doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));98  doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));99  doc.registerFont("Inter", F("Inter-Regular.ttf"));100101  const chunks: Buffer[] = [];102  doc.on("data", (c: Buffer) => chunks.push(c));103  const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));104105  /* ------------------------------ PAGE 1 : LE CHIFFRE ------------------------------ */106  frame(doc, 1, TOTAL, generated);107  doc.rect(0, 0, W, 96).fill(INK);108  logo(doc, M, 30);109  doc.font("JB-Reg").fontSize(7).fillColor(LIME)110    .text("RAPPORT STATISTIQUE PROVINCIAL · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false });111  doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")112    .text(`MILLÉSIME 2026 · GÉNÉRÉ LE ${generated}`, W - M - 220, 40, { width: 220, align: "right" });113  doc.rect(0, 96, W, 3).fill(LIME);114115  let y = 122;116  kicker(doc, "Valeur totale du parc immobilier québécois", M, y);117  y += 18;118  card(doc, M, y, CW, 96, INK);119  doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, y + 18);120  doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)")121    .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`,122      M + 20, y + 62, { characterSpacing: 0.8 });123  y += 112;124125  // comparaisons126  const comps: [string, string][] = [127    ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)],128    ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`],129    ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`],130    ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`],131    ["Évaluation municipale totale", compact(s.valeur_role_totale)],132    ["Valeur médiane", money(s.valeur_mediane_2026)],133    ["Logements", num(s.logements)],134    ["Plancher bâti", `${num(s.aire_etages_km2)} km²`],135  ];136  const tw2 = (CW - 3 * 10) / 4;137  comps.forEach(([k, v], i) => {138    const tx = M + (i % 4) * (tw2 + 10);139    const ty = y + Math.floor(i / 4) * 56;140    card(doc, tx, ty, tw2, 48);141    doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, ty + 10, { width: tw2 - 20, height: 16, ellipsis: true, lineBreak: false });142    doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, ty + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 });143  });144  y += 2 * 56 + 8;145  doc.font("JB-Reg").fontSize(6).fillColor(INK3)146    .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, y, { characterSpacing: 0.5 });147  y += 18;148149  // par année150  kicker(doc, "Valeur provinciale par millésime", M, y);151  y += 16;152  const maxYear = Math.max(...s.totaux_annee.map((x) => x.total));153  card(doc, M, y, CW, s.totaux_annee.length * 19 + 22);154  s.totaux_annee.forEach((h, i) => {155    const prev = i > 0 ? s.totaux_annee[i - 1].total : null;156    const yoy = prev ? ` (+${(((h.total / prev) - 1) * 100).toFixed(1)} %)` : "";157    hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, String(h.year), h.total / maxYear,158      compact(h.total), `${num(h.n)} unités${yoy}`, h.year === 2026);159  });160  y += s.totaux_annee.length * 19 + 36;161162  // par type163  kicker(doc, "Répartition par type de propriété", M, y);164  y += 16;165  const maxType = Math.max(...s.par_type.map((x) => x.total));166  card(doc, M, y, CW, s.par_type.length * 19 + 22);167  s.par_type.forEach((tp, i) => {168    hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, TYPE_FR[tp.type] ?? tp.type,169      tp.total / maxType, compact(tp.total),170      `${num(tp.n)} · méd. ${tp.mediane ? compact(tp.mediane) : "—"}`);171  });172173  /* --------------------------- PAGES 2+ : MUNICIPALITÉS --------------------------- */174  for (let p = 0; p < munPages; p++) {175    doc.addPage({ size: "LETTER", margin: 0 });176    frame(doc, 2 + p, TOTAL, generated);177    doc.rect(0, 0, W, 8).fill(INK);178    doc.rect(0, 8, W, 2.5).fill(LIME);179    let yy = 32;180    kicker(doc, `Palmarès des municipalités (${p * PER_PAGE + 1} à ${Math.min((p + 1) * PER_PAGE, s.par_ville.length)} de ${s.par_ville.length})`, M, yy);181    yy += 18;182    // en-tête183    doc.rect(M, yy, CW, 16).fill(INK);184    doc.font("JB-Bold").fontSize(6).fillColor("#ffffff");185    doc.text("#", M + 8, yy + 5, { lineBreak: false });186    doc.text("MUNICIPALITÉ", M + 34, yy + 5, { lineBreak: false });187    doc.text("PROPRIÉTÉS", M + 268, yy + 5, { width: 70, align: "right", lineBreak: false });188    doc.text("VALEUR TOTALE", M + 348, yy + 5, { width: 90, align: "right", lineBreak: false });189    doc.text("VALEUR MÉDIANE", M + 438, yy + 5, { width: CW - 446, align: "right", lineBreak: false });190    yy += 16;191    s.par_ville.slice(p * PER_PAGE, (p + 1) * PER_PAGE).forEach((v, i) => {192      const rank = p * PER_PAGE + i + 1;193      if (i % 2 === 0) doc.rect(M, yy, CW, 15).fill(SURFACE2);194      doc.font("JB-Bold").fontSize(6.8).fillColor(rank <= 3 ? GREEN_DEEP : INK3).text(String(rank), M + 8, yy + 4.5, { lineBreak: false });195      doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(v.ville, M + 34, yy + 3.5, { width: 226, height: 10, ellipsis: true, lineBreak: false });196      doc.font("JB-Reg").fontSize(7).fillColor(INK).text(num(v.n), M + 268, yy + 4.5, { width: 70, align: "right", lineBreak: false });197      doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(compact(v.total), M + 348, yy + 3.5, { width: 90, align: "right", lineBreak: false });198      doc.font("JB-Reg").fontSize(7).fillColor(INK).text(v.mediane ? money(v.mediane) : "—", M + 438, yy + 4.5, { width: CW - 446, align: "right", lineBreak: false });199      yy += 15;200    });201    doc.rect(M, yy, CW, 0.9).fill(INK);202  }203204  doc.end();205  return done;206}207