// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Rapport statistique provincial — PDF vectoriel : valeur totale du parc * immobilier québécois, évolution 2021-2026, répartition par type et * palmarès complet des 200 plus grandes municipalités. */ import PDFDocument from "pdfkit"; import path from "path"; import type { ProvStats } from "@/components/StatsView"; const INK = "#141814"; const INK3 = "#8b928c"; const PAPER = "#f5f3ee"; const SURFACE2 = "#faf9f5"; const GREEN = "#9e2a25"; const GREEN_DEEP = "#771f1b"; const LIME = "#ff5148"; const W = 612; const H = 792; const M = 44; const CW = W - 2 * M; const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); const money = (v: number) => new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v); const compact = (v: number) => { if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`; if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`; if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`; return money(v); }; const num = (v: number) => v.toLocaleString("fr-CA"); const TYPE_FR: Record = { unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi", chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre", }; const POP_QC = 9_111_000; const BUDGET_QC = 165_800_000_000; const PIB_QC = 640_000_000_000; type Doc = InstanceType; function logo(doc: Doc, x: number, y: number) { doc.font("SG-Bold").fontSize(24); doc.fillColor(PAPER).text("Vrai", x, y, { lineBreak: false }); const w = doc.widthOfString("Vrai"); const bx = x + w + 4; doc.save(); doc.rotate(-3, { origin: [bx, y + 12] }); doc.roundedRect(bx, y - 3, doc.widthOfString("Prix") + 12, 30, 5).fill(LIME); doc.fillColor(INK).text("Prix", bx + 6, y, { lineBreak: false }); doc.restore(); } function kicker(doc: Doc, txt: string, x: number, y: number) { doc.rect(x, y + 3.5, 20, 2).fill(GREEN); doc.font("JB-Bold").fontSize(8).fillColor(GREEN) .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false }); } function frame(doc: Doc, page: number, total: number, generated: string) { doc.rect(0, 0, W, H).fill(PAPER); doc.rect(M, H - 46, CW, 1.2).fill(INK); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text( "VRAI-PRIX.COM — RAPPORT STATISTIQUE PROVINCIAL · ESTIMATIONS STATISTIQUES (MODÈLE HÉDONIQUE) · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI", M, H - 38, { width: CW - 60, characterSpacing: 0.4, lineBreak: false }); doc.font("JB-Bold").fontSize(7.5).fillColor(INK) .text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" }); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) .text(generated, W - M - 160, H - 30, { width: 160, align: "right" }); } function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") { doc.roundedRect(x + 3, y + 3, w, h, 8).fill("#e3e1d9"); doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(fill, INK); } function hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac: number, txt: string, sub: string | null, dark = false) { doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(label, x, y + 3, { width: 82, height: 9, ellipsis: true, lineBreak: false }); const tx = x + 88; const tw = w - 88 - 118; doc.rect(tx, y, tw, 13).fill(SURFACE2); doc.rect(tx, y, Math.max(2, frac * tw), 13).fill(dark ? INK : GREEN); doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(txt, x + w - 112, y + (sub ? 0 : 3), { width: 112, align: "right" }); if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" }); } export async function buildStatsReport(s: ProvStats): Promise { const generated = new Date().toISOString().slice(0, 16).replace("T", " "); const PER_PAGE = 42; const munPages = Math.ceil(s.par_ville.length / PER_PAGE); const TOTAL = 1 + munPages; const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: "Vrai-Prix — Rapport statistique provincial" } }); doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); doc.registerFont("Inter", F("Inter-Regular.ttf")); const chunks: Buffer[] = []; doc.on("data", (c: Buffer) => chunks.push(c)); const done = new Promise((res) => doc.on("end", () => res(Buffer.concat(chunks)))); /* ------------------------------ PAGE 1 : LE CHIFFRE ------------------------------ */ frame(doc, 1, TOTAL, generated); doc.rect(0, 0, W, 96).fill(INK); logo(doc, M, 30); doc.font("JB-Reg").fontSize(7).fillColor(LIME) .text("RAPPORT STATISTIQUE PROVINCIAL · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false }); doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c") .text(`MILLÉSIME 2026 · GÉNÉRÉ LE ${generated}`, W - M - 220, 40, { width: 220, align: "right" }); doc.rect(0, 96, W, 3).fill(LIME); let y = 122; kicker(doc, "Valeur totale du parc immobilier québécois", M, y); y += 18; card(doc, M, y, CW, 96, INK); doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, y + 18); doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)") .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`, M + 20, y + 62, { characterSpacing: 0.8 }); y += 112; // comparaisons const comps: [string, string][] = [ ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)], ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`], ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`], ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`], ["Évaluation municipale totale", compact(s.valeur_role_totale)], ["Valeur médiane", money(s.valeur_mediane_2026)], ["Logements", num(s.logements)], ["Plancher bâti", `${num(s.aire_etages_km2)} km²`], ]; const tw2 = (CW - 3 * 10) / 4; comps.forEach(([k, v], i) => { const tx = M + (i % 4) * (tw2 + 10); const ty = y + Math.floor(i / 4) * 56; card(doc, tx, ty, tw2, 48); doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, ty + 10, { width: tw2 - 20, height: 16, ellipsis: true, lineBreak: false }); doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, ty + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 }); }); y += 2 * 56 + 8; doc.font("JB-Reg").fontSize(6).fillColor(INK3) .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, y, { characterSpacing: 0.5 }); y += 18; // par année kicker(doc, "Valeur provinciale par millésime", M, y); y += 16; const maxYear = Math.max(...s.totaux_annee.map((x) => x.total)); card(doc, M, y, CW, s.totaux_annee.length * 19 + 22); s.totaux_annee.forEach((h, i) => { const prev = i > 0 ? s.totaux_annee[i - 1].total : null; const yoy = prev ? ` (+${(((h.total / prev) - 1) * 100).toFixed(1)} %)` : ""; hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, String(h.year), h.total / maxYear, compact(h.total), `${num(h.n)} unités${yoy}`, h.year === 2026); }); y += s.totaux_annee.length * 19 + 36; // par type kicker(doc, "Répartition par type de propriété", M, y); y += 16; const maxType = Math.max(...s.par_type.map((x) => x.total)); card(doc, M, y, CW, s.par_type.length * 19 + 22); s.par_type.forEach((tp, i) => { hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, TYPE_FR[tp.type] ?? tp.type, tp.total / maxType, compact(tp.total), `${num(tp.n)} · méd. ${tp.mediane ? compact(tp.mediane) : "—"}`); }); /* --------------------------- PAGES 2+ : MUNICIPALITÉS --------------------------- */ for (let p = 0; p < munPages; p++) { doc.addPage({ size: "LETTER", margin: 0 }); frame(doc, 2 + p, TOTAL, generated); doc.rect(0, 0, W, 8).fill(INK); doc.rect(0, 8, W, 2.5).fill(LIME); let yy = 32; 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); yy += 18; // en-tête doc.rect(M, yy, CW, 16).fill(INK); doc.font("JB-Bold").fontSize(6).fillColor("#ffffff"); doc.text("#", M + 8, yy + 5, { lineBreak: false }); doc.text("MUNICIPALITÉ", M + 34, yy + 5, { lineBreak: false }); doc.text("PROPRIÉTÉS", M + 268, yy + 5, { width: 70, align: "right", lineBreak: false }); doc.text("VALEUR TOTALE", M + 348, yy + 5, { width: 90, align: "right", lineBreak: false }); doc.text("VALEUR MÉDIANE", M + 438, yy + 5, { width: CW - 446, align: "right", lineBreak: false }); yy += 16; s.par_ville.slice(p * PER_PAGE, (p + 1) * PER_PAGE).forEach((v, i) => { const rank = p * PER_PAGE + i + 1; if (i % 2 === 0) doc.rect(M, yy, CW, 15).fill(SURFACE2); doc.font("JB-Bold").fontSize(6.8).fillColor(rank <= 3 ? GREEN_DEEP : INK3).text(String(rank), M + 8, yy + 4.5, { lineBreak: false }); 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 }); doc.font("JB-Reg").fontSize(7).fillColor(INK).text(num(v.n), M + 268, yy + 4.5, { width: 70, align: "right", lineBreak: false }); doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(compact(v.total), M + 348, yy + 3.5, { width: 90, align: "right", lineBreak: false }); 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 }); yy += 15; }); doc.rect(M, yy, CW, 0.9).fill(INK); } doc.end(); return done; }