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%
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