Commit
Stats v2 (module commun Groupe KA) : /stats ultra complet + 5 rapports PDF
- Dashboard v2 (agrégats réels du rôle + corpus de ventes, stats-v2.json via scripts/build-stats-v2.mjs) : 10 KPI (deltas honnêtes millésime/12 mois + sparklines), 4 jauges de couverture, 5 séries (millésimes, ventes/mois, volume, prix médian), 2 multi-courbes (indice marché, $/m² par type), ventes empilées par type, 3 répartitions, 2 distributions, géo, calendrier des ventes, 6 tableaux, 10 records. - /api/stats/report : 5 modes (complet, synthese, tendances, repartitions, donnees ; inconnu → complet), filename(mode), moteur pdfkit v2 100 % vectoriel (sommaire paginé, courbes/aires/barres, multi-courbes à motifs, empilées, anneau, jauges, heatmap, tableaux zébrés paginés, records). - /stats : hero conservé + StatsDashboard (kit kacharts v2, ordre SPEC §1), PdfButton menu 5 rapports, fraîcheur + rafraîchir. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
modified
src/app/api/report/stats/route.ts
+10 −9
@@ -1,21 +1,22 @@ | |||
| 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | /** | 2 | /** |
| 3 | − * Alias historique de /api/stats/report (gabarit commun Groupe KA). | 3 | + * Alias historique de /api/stats/report (gabarit commun Groupe KA v2). |
| 4 | − * Sert le même rapport PDF, mêmes paramètres (`mode=complet|synthese`). | 4 | + * Sert le même rapport PDF, mêmes paramètres |
| 5 | + * (`mode=complet|synthese|tendances|repartitions|donnees`). | ||
| 5 | */ | 6 | */ |
| 6 | import { type NextRequest, NextResponse } from "next/server"; | 7 | import { type NextRequest, NextResponse } from "next/server"; |
| 7 | −import { buildStatsReport, reportFilename, type ReportMode } from "@/lib/report-stats"; | 8 | +import { buildStatsReport, reportFilename, REPORT_MODES, type ReportMode } from "@/lib/report-stats"; |
| 8 | −import stats from "@/data/stats.json"; | ||
| 9 | −import type { ProvStats } from "@/components/StatsView"; | ||
| 10 | 9 | ||
| 11 | export async function GET(req: NextRequest) { | 10 | export async function GET(req: NextRequest) { |
| 12 | − const mode: ReportMode = | 11 | + const raw = req.nextUrl.searchParams.get("mode") ?? "complet"; |
| 13 | − req.nextUrl.searchParams.get("mode") === "synthese" ? "synthese" : "complet"; | 12 | + const mode: ReportMode = (REPORT_MODES as readonly string[]).includes(raw) |
| 14 | − const pdf = await buildStatsReport(stats as ProvStats, mode); | 13 | + ? (raw as ReportMode) |
| 14 | + : "complet"; | ||
| 15 | + const pdf = await buildStatsReport(mode); | ||
| 15 | return new NextResponse(new Uint8Array(pdf), { | 16 | return new NextResponse(new Uint8Array(pdf), { |
| 16 | headers: { | 17 | headers: { |
| 17 | "Content-Type": "application/pdf", | 18 | "Content-Type": "application/pdf", |
| 18 | − "Content-Disposition": `attachment; filename="${reportFilename()}"`, | 19 | + "Content-Disposition": `attachment; filename="${reportFilename(mode)}"`, |
| 19 | "Cache-Control": "public, max-age=3600", | 20 | "Cache-Control": "public, max-age=3600", |
| 20 | }, | 21 | }, |
| 21 | }); | 22 | }); |
modified
src/app/api/stats/report/route.ts
+13 −11
@@ -1,23 +1,25 @@ | |||
| 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | /** | 2 | /** |
| 3 | − * GET /api/stats/report?mode=complet|synthese — rapport statistique PDF au | 3 | + * GET /api/stats/report?mode=complet|synthese|tendances|repartitions|donnees |
| 4 | − * gabarit commun Groupe KA (src/ka/stats/SPEC.md §3). `period`/`from`/`to` | 4 | + * — rapport statistique PDF au gabarit commun Groupe KA v2 (src/ka/stats/ |
| 5 | − * sont acceptés (contrat commun) mais non applicables : les données sont un | 5 | + * SPEC.md §3), moteur pdfkit vectoriel. Un mode inconnu retombe sur |
| 6 | − * instantané du rôle 2026. Alias historique : /api/report/stats. | 6 | + * « complet ». `period`/`from`/`to` sont acceptés (contrat commun) mais non |
| 7 | + * applicables : les données sont un instantané du rôle 2026. | ||
| 8 | + * Alias historique : /api/report/stats. | ||
| 7 | */ | 9 | */ |
| 8 | import { type NextRequest, NextResponse } from "next/server"; | 10 | import { type NextRequest, NextResponse } from "next/server"; |
| 9 | −import { buildStatsReport, reportFilename, type ReportMode } from "@/lib/report-stats"; | 11 | +import { buildStatsReport, reportFilename, REPORT_MODES, type ReportMode } from "@/lib/report-stats"; |
| 10 | −import stats from "@/data/stats.json"; | ||
| 11 | −import type { ProvStats } from "@/components/StatsView"; | ||
| 12 | 12 | ||
| 13 | export async function GET(req: NextRequest) { | 13 | export async function GET(req: NextRequest) { |
| 14 | − const mode: ReportMode = | 14 | + const raw = req.nextUrl.searchParams.get("mode") ?? "complet"; |
| 15 | − req.nextUrl.searchParams.get("mode") === "synthese" ? "synthese" : "complet"; | 15 | + const mode: ReportMode = (REPORT_MODES as readonly string[]).includes(raw) |
| 16 | − const pdf = await buildStatsReport(stats as ProvStats, mode); | 16 | + ? (raw as ReportMode) |
| 17 | + : "complet"; | ||
| 18 | + const pdf = await buildStatsReport(mode); | ||
| 17 | return new NextResponse(new Uint8Array(pdf), { | 19 | return new NextResponse(new Uint8Array(pdf), { |
| 18 | headers: { | 20 | headers: { |
| 19 | "Content-Type": "application/pdf", | 21 | "Content-Type": "application/pdf", |
| 20 | − "Content-Disposition": `attachment; filename="${reportFilename()}"`, | 22 | + "Content-Disposition": `attachment; filename="${reportFilename(mode)}"`, |
| 21 | "Cache-Control": "public, max-age=3600", | 23 | "Cache-Control": "public, max-age=3600", |
| 22 | }, | 24 | }, |
| 23 | }); | 25 | }); |
modified
src/app/stats/page.tsx
+10 −2
@@ -1,5 +1,7 @@ | |||
| 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | import StatsView, { type ProvStats } from "@/components/StatsView"; | 2 | import StatsView, { type ProvStats } from "@/components/StatsView"; |
| 3 | +import StatsDashboard, { type DashboardPayload } from "@/components/StatsDashboard"; | ||
| 4 | +import { getDashboard } from "@/lib/stats-dashboard"; | ||
| 3 | import stats from "@/data/stats.json"; | 5 | import stats from "@/data/stats.json"; |
| 4 | 6 | ||
| 5 | import type { Metadata } from "next"; | 7 | import type { Metadata } from "next"; |
@@ -7,10 +9,16 @@ import type { Metadata } from "next"; | |||
| 7 | export const metadata: Metadata = { | 9 | export const metadata: Metadata = { |
| 8 | title: "Statistiques provinciales — la valeur de l'immobilier québécois", | 10 | title: "Statistiques provinciales — la valeur de l'immobilier québécois", |
| 9 | description: | 11 | description: |
| 10 | − "La valeur totale de l'immobilier québécois : 2 000 milliards $ en 2026, répartition par millésime, par type et palmarès des 200 plus grandes municipalités.", | 12 | + "La valeur totale de l'immobilier québécois : 2 000 milliards $ en 2026, indicateurs, indices de marché, distributions, palmarès des 200 plus grandes municipalités et 5 rapports PDF.", |
| 11 | alternates: { canonical: "/stats" }, | 13 | alternates: { canonical: "/stats" }, |
| 12 | }; | 14 | }; |
| 13 | 15 | ||
| 14 | export default function StatsPage() { | 16 | export default function StatsPage() { |
| 15 | − return <StatsView s={stats as ProvStats} />; | 17 | + const dash = getDashboard() as unknown as DashboardPayload; |
| 18 | + return ( | ||
| 19 | + <div className="pb-6"> | ||
| 20 | + <StatsView s={stats as ProvStats} /> | ||
| 21 | + <StatsDashboard initial={dash} /> | ||
| 22 | + </div> | ||
| 23 | + ); | ||
| 16 | } | 24 | } |
added
src/components/StatsDashboard.tsx
+180 −0
@@ -0,0 +1,180 @@ | |||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||
| 2 | +/** | ||
| 3 | + * Tableau de bord /stats v2 — module Stats commun Groupe KA (src/ka/stats/ | ||
| 4 | + * SPEC.md §1), monté sur le kit kacharts : bandeau KPI (sparklines), jauges, | ||
| 5 | + * courbes + stats de séries, multi-courbes, barres empilées, répartitions, | ||
| 6 | + * distributions, géographie, calendrier d'activité, tableaux, records, | ||
| 7 | + * fraîcheur + PdfButton (5 rapports). Données : /api/stats/dashboard | ||
| 8 | + * (instantané du rôle 2026 + corpus de ventes réelles — period non applicable). | ||
| 9 | + */ | ||
| 10 | +"use client"; | ||
| 11 | +import { useCallback, useState } from "react"; | ||
| 12 | +import { | ||
| 13 | + KpiCard, GaugeCard, LineChart, MultiLineChart, StackedBarChart, BarChart, | ||
| 14 | + Donut, Histogram, CalendarHeatmap, StatSummary, DataTable, RecordCard, | ||
| 15 | + PdfButton, Fraicheur, | ||
| 16 | + type Kpi, type Serie, type MultiSerie, type StackedSerie, type BreakItem, | ||
| 17 | + type Distribution, type Gauge, type TableSpec, type RecordFact, | ||
| 18 | +} from "@/ka/stats/kacharts"; | ||
| 19 | +import { useLang } from "./LangContext"; | ||
| 20 | + | ||
| 21 | +export interface DashboardPayload { | ||
| 22 | + updated: string; | ||
| 23 | + period: { label: string; applicable?: boolean }; | ||
| 24 | + kpis: Kpi[]; | ||
| 25 | + gauges?: Gauge[]; | ||
| 26 | + series?: Serie[]; | ||
| 27 | + multiseries?: MultiSerie[]; | ||
| 28 | + stacked?: StackedSerie[]; | ||
| 29 | + breakdowns?: { id: string; title: string; kind: "donut" | "bars"; items: BreakItem[] }[]; | ||
| 30 | + distributions?: Distribution[]; | ||
| 31 | + geo?: { title: string; items: BreakItem[] }; | ||
| 32 | + heatmap?: { title: string; cells: { date: string; value: number }[] }; | ||
| 33 | + tables?: TableSpec[]; | ||
| 34 | + records?: RecordFact[]; | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +function SectionTitle({ kicker, title }: { kicker: string; title: string }) { | ||
| 38 | + return ( | ||
| 39 | + <div className="mb-4 mt-12"> | ||
| 40 | + <span className="kicker">{kicker}</span> | ||
| 41 | + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{title}</h2> | ||
| 42 | + </div> | ||
| 43 | + ); | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +export default function StatsDashboard({ initial }: { initial: DashboardPayload }) { | ||
| 47 | + const { lang } = useLang(); | ||
| 48 | + const fr = lang === "fr"; | ||
| 49 | + const [data, setData] = useState<DashboardPayload>(initial); | ||
| 50 | + const refresh = useCallback(async () => { | ||
| 51 | + try { | ||
| 52 | + const r = await fetch("/api/stats/dashboard?period=tout", { cache: "no-store" }); | ||
| 53 | + if (r.ok) setData(await r.json()); | ||
| 54 | + } catch { | ||
| 55 | + /* on garde les données courantes */ | ||
| 56 | + } | ||
| 57 | + }, []); | ||
| 58 | + | ||
| 59 | + const keySeries = new Set(["valeur_millesime", "ventes_mois", "prix_median_mois"]); | ||
| 60 | + | ||
| 61 | + return ( | ||
| 62 | + <div> | ||
| 63 | + {/* ---- fraîcheur + rapports PDF (5 modes) ---- */} | ||
| 64 | + <div className="mb-2 mt-10 flex flex-wrap items-center justify-between gap-3 rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3"> | ||
| 65 | + <div> | ||
| 66 | + <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.08em] text-ink-3"> | ||
| 67 | + {fr | ||
| 68 | + ? `${data.period.label} · corpus de ventes 2021-2026 · périodes non applicables (instantané)` | ||
| 69 | + : `${data.period.label} · 2021-2026 sales corpus · periods not applicable (snapshot)`} | ||
| 70 | + </p> | ||
| 71 | + <div className="mt-1.5"> | ||
| 72 | + <Fraicheur updated={data.updated} onRefresh={refresh} /> | ||
| 73 | + </div> | ||
| 74 | + </div> | ||
| 75 | + <PdfButton period="tout" /> | ||
| 76 | + </div> | ||
| 77 | + | ||
| 78 | + {/* ---- 1. bandeau KPI ---- */} | ||
| 79 | + <SectionTitle kicker={fr ? "Indicateurs" : "Indicators"} title={fr ? "Indicateurs clés" : "Key indicators"} /> | ||
| 80 | + <div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> | ||
| 81 | + {data.kpis.map((k) => <KpiCard key={k.id} k={k} />)} | ||
| 82 | + </div> | ||
| 83 | + | ||
| 84 | + {/* ---- 3. jauges ---- */} | ||
| 85 | + {!!data.gauges?.length && ( | ||
| 86 | + <> | ||
| 87 | + <SectionTitle kicker={fr ? "Couverture" : "Coverage"} title={fr ? "Couverture & complétude du rôle" : "Roll coverage & completeness"} /> | ||
| 88 | + <div className="grid grid-cols-2 gap-3.5 lg:grid-cols-4"> | ||
| 89 | + {data.gauges.map((g) => <GaugeCard key={g.id} g={g} />)} | ||
| 90 | + </div> | ||
| 91 | + </> | ||
| 92 | + )} | ||
| 93 | + | ||
| 94 | + {/* ---- 4. évolutions ---- */} | ||
| 95 | + {!!data.series?.length && ( | ||
| 96 | + <> | ||
| 97 | + <SectionTitle kicker="2021 → 2026" title={fr ? "Évolution — millésimes & ventes réelles" : "Trends — vintages & real sales"} /> | ||
| 98 | + <div className="grid gap-4"> | ||
| 99 | + {data.series.map((s) => ( | ||
| 100 | + <div key={s.id}> | ||
| 101 | + <LineChart serie={s} /> | ||
| 102 | + {keySeries.has(s.id) && <StatSummary serie={s} />} | ||
| 103 | + </div> | ||
| 104 | + ))} | ||
| 105 | + </div> | ||
| 106 | + </> | ||
| 107 | + )} | ||
| 108 | + {!!data.multiseries?.length && ( | ||
| 109 | + <> | ||
| 110 | + <SectionTitle kicker={fr ? "Marché" : "Market"} title={fr ? "Indices de marché & prix au m²" : "Market indices & price per m²"} /> | ||
| 111 | + <div className="grid gap-4"> | ||
| 112 | + {data.multiseries.map((ms) => <MultiLineChart key={ms.id} ms={ms} />)} | ||
| 113 | + </div> | ||
| 114 | + </> | ||
| 115 | + )} | ||
| 116 | + {!!data.stacked?.length && ( | ||
| 117 | + <div className="mt-4 grid gap-4"> | ||
| 118 | + {data.stacked.map((st) => <StackedBarChart key={st.id} st={st} />)} | ||
| 119 | + </div> | ||
| 120 | + )} | ||
| 121 | + | ||
| 122 | + {/* ---- 5. répartitions & distributions ---- */} | ||
| 123 | + {(!!data.breakdowns?.length || !!data.distributions?.length) && ( | ||
| 124 | + <> | ||
| 125 | + <SectionTitle kicker={fr ? "Répartitions" : "Breakdowns"} title={fr ? "Répartitions & distributions" : "Breakdowns & distributions"} /> | ||
| 126 | + <div className="grid gap-4 lg:grid-cols-2"> | ||
| 127 | + {data.breakdowns?.map((b) => | ||
| 128 | + b.kind === "donut" | ||
| 129 | + ? <Donut key={b.id} title={b.title} items={b.items} /> | ||
| 130 | + : <BarChart key={b.id} title={b.title} items={b.items} /> | ||
| 131 | + )} | ||
| 132 | + {data.distributions?.map((d) => <Histogram key={d.id} dist={d} />)} | ||
| 133 | + </div> | ||
| 134 | + </> | ||
| 135 | + )} | ||
| 136 | + | ||
| 137 | + {/* ---- 6. géographie ---- */} | ||
| 138 | + {!!data.geo?.items?.length && ( | ||
| 139 | + <> | ||
| 140 | + <SectionTitle kicker={fr ? "Géographie" : "Geography"} title={data.geo.title} /> | ||
| 141 | + <BarChart title={data.geo.title} items={data.geo.items} /> | ||
| 142 | + </> | ||
| 143 | + )} | ||
| 144 | + | ||
| 145 | + {/* ---- 7. calendrier d'activité ---- */} | ||
| 146 | + {!!data.heatmap?.cells?.length && ( | ||
| 147 | + <> | ||
| 148 | + <SectionTitle kicker={fr ? "Activité" : "Activity"} title={fr ? "Ventes réelles au calendrier" : "Real sales calendar"} /> | ||
| 149 | + <CalendarHeatmap title={data.heatmap.title} cells={data.heatmap.cells} /> | ||
| 150 | + </> | ||
| 151 | + )} | ||
| 152 | + | ||
| 153 | + {/* ---- 8. tableaux détaillés ---- */} | ||
| 154 | + {!!data.tables?.length && ( | ||
| 155 | + <> | ||
| 156 | + <SectionTitle kicker={fr ? "Détails" : "Details"} title={fr ? "Tableaux détaillés" : "Detailed tables"} /> | ||
| 157 | + <div className="grid gap-5"> | ||
| 158 | + {data.tables.map((t) => <DataTable key={t.id} spec={t as TableSpec} />)} | ||
| 159 | + </div> | ||
| 160 | + </> | ||
| 161 | + )} | ||
| 162 | + | ||
| 163 | + {/* ---- 9. records ---- */} | ||
| 164 | + {!!data.records?.length && ( | ||
| 165 | + <> | ||
| 166 | + <SectionTitle kicker={fr ? "Faits marquants" : "Highlights"} title={fr ? "Records & faits marquants" : "Records & highlights"} /> | ||
| 167 | + <div className="grid gap-3 sm:grid-cols-2"> | ||
| 168 | + {data.records.map((r) => <RecordCard key={r.label} r={r} />)} | ||
| 169 | + </div> | ||
| 170 | + </> | ||
| 171 | + )} | ||
| 172 | + | ||
| 173 | + {/* ---- 10. fraîcheur (rappel de bas de page) ---- */} | ||
| 174 | + <div className="mt-10 flex justify-between gap-3"> | ||
| 175 | + <Fraicheur updated={data.updated} onRefresh={refresh} /> | ||
| 176 | + <PdfButton period="tout" /> | ||
| 177 | + </div> | ||
| 178 | + </div> | ||
| 179 | + ); | ||
| 180 | +} | ||
modified
src/components/StatsView.tsx
+9 −198
@@ -1,9 +1,13 @@ | |||
| 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | +/** | ||
| 3 | + * /stats — bandeau « LE CHIFFRE » (valeur totale du parc immobilier | ||
| 4 | + * québécois, comparaisons vulgarisées). Les sections analytiques | ||
| 5 | + * (KPI, jauges, séries, répartitions, tableaux, records, rapports PDF) | ||
| 6 | + * sont servies par <StatsDashboard/> — module Stats commun Groupe KA v2. | ||
| 7 | + */ | ||
| 2 | "use client"; | 8 | "use client"; |
| 3 | −import { useState } from "react"; | ||
| 4 | import { CountUp } from "./MetricViz"; | 9 | import { CountUp } from "./MetricViz"; |
| 5 | import { useLang } from "./LangContext"; | 10 | import { useLang } from "./LangContext"; |
| 6 | −import type { TKey } from "@/lib/i18n"; | ||
| 7 | 11 | ||
| 8 | export interface ProvStats { | 12 | export interface ProvStats { |
| 9 | generated: string; | 13 | generated: string; |
@@ -44,44 +48,18 @@ const num = (v: number, lang: string) => | |||
| 44 | v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA"); | 48 | v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA"); |
| 45 | 49 | ||
| 46 | export default function StatsView({ s }: { s: ProvStats }) { | 50 | export default function StatsView({ s }: { s: ProvStats }) { |
| 47 | − const { lang, t } = useLang(); | 51 | + const { lang } = useLang(); |
| 48 | const fr = lang === "fr"; | 52 | const fr = lang === "fr"; |
| 49 | − const [q, setQ] = useState(""); | ||
| 50 | − | ||
| 51 | − const maxYear = Math.max(...s.totaux_annee.map((x) => x.total)); | ||
| 52 | − const maxType = Math.max(...s.par_type.map((x) => x.total)); | ||
| 53 | − const top10 = s.par_ville.slice(0, 10); | ||
| 54 | − const maxVille = Math.max(...top10.map((x) => x.total)); | ||
| 55 | − const villes = s.par_ville.filter((v) => | ||
| 56 | − v.ville.toLowerCase().includes(q.trim().toLowerCase()) | ||
| 57 | − ); | ||
| 58 | 53 | ||
| 59 | return ( | 54 | return ( |
| 60 | − <div className="pb-6 pt-10 sm:pt-14"> | 55 | + <div className="pt-10 sm:pt-14"> |
| 61 | − {/* ---- rapport PDF Groupe-KA + fraîcheur ---- */} | ||
| 62 | − <div className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-[10px] border-[1.5px] border-ink bg-surface px-4 py-3"> | ||
| 63 | − <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.08em] text-ink-3"> | ||
| 64 | − {fr | ||
| 65 | − ? `Instantané du rôle d'évaluation 2026 · statistiques générées le ${s.generated}` | ||
| 66 | − : `2026 assessment roll snapshot · stats generated ${s.generated}`} | ||
| 67 | − </p> | ||
| 68 | − <div className="flex flex-wrap gap-2.5"> | ||
| 69 | − <a href="/api/stats/report?mode=complet" className="btn btn-primary" download> | ||
| 70 | − {fr ? "Télécharger le rapport PDF" : "Download the PDF report"} ↓ | ||
| 71 | − </a> | ||
| 72 | − <a href="/api/stats/report?mode=synthese" className="btn btn-ghost" download> | ||
| 73 | − {fr ? "Synthèse (PDF)" : "Summary (PDF)"} | ||
| 74 | − </a> | ||
| 75 | − </div> | ||
| 76 | − </div> | ||
| 77 | − | ||
| 78 | {/* ---- LE CHIFFRE ---- */} | 56 | {/* ---- LE CHIFFRE ---- */} |
| 79 | <section> | 57 | <section> |
| 80 | <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span> | 58 | <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span> |
| 81 | <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]"> | 59 | <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]"> |
| 82 | {fr ? ( | 60 | {fr ? ( |
| 83 | <> | 61 | <> |
| 84 | − Tout l'immobilier du Québec vaut, | 62 | + Tout l'immobilier du Québec vaut, |
| 85 | <br /> | 63 | <br /> |
| 86 | <span className="hl">au dollar près</span>… | 64 | <span className="hl">au dollar près</span>… |
| 87 | </> | 65 | </> |
@@ -127,173 +105,6 @@ export default function StatsView({ s }: { s: ProvStats }) { | |||
| 127 | : "Sum of 3,747,008 Vrai-Prix estimates (hedonic model, 2026 roll). Corresponding total municipal assessment: "} | 105 | : "Sum of 3,747,008 Vrai-Prix estimates (hedonic model, 2026 roll). Corresponding total municipal assessment: "} |
| 128 | {compact(s.valeur_role_totale, lang)}. | 106 | {compact(s.valeur_role_totale, lang)}. |
| 129 | </p> | 107 | </p> |
| 130 | − <a href="/api/stats/report?mode=complet" className="btn btn-primary mt-5" download> | ||
| 131 | − {fr | ||
| 132 | − ? "Télécharger le rapport statistique provincial (PDF)" | ||
| 133 | − : "Download the provincial statistics report (PDF)"}{" "} | ||
| 134 | − ↓ | ||
| 135 | − </a> | ||
| 136 | − </section> | ||
| 137 | − | ||
| 138 | − {/* ---- tuiles physiques ---- */} | ||
| 139 | − <section className="mt-10"> | ||
| 140 | − <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> | ||
| 141 | − {( | ||
| 142 | − [ | ||
| 143 | − [fr ? "Valeur médiane" : "Median value", money(s.valeur_mediane_2026, lang), true], | ||
| 144 | − [fr ? "Logements" : "Dwellings", num(s.logements, lang), false], | ||
| 145 | − [fr ? "Plancher bâti" : "Built floor area", `${num(s.aire_etages_km2, lang)} km²`, false], | ||
| 146 | − [fr ? "Terrains privés" : "Private land", `${num(s.terrain_km2, lang)} km²`, false], | ||
| 147 | − ] as [string, string, boolean][] | ||
| 148 | − ).map(([k, v, hero], i) => ( | ||
| 149 | − <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}> | ||
| 150 | − <p className={`vp-display text-[clamp(19px,2.4vw,27px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p> | ||
| 151 | − <p className={`vp-mono mt-1.5 text-[10px] font-bold uppercase tracking-[0.1em] ${hero ? "text-[rgba(255,81,72,0.7)]" : "text-ink-3"}`}>{k}</p> | ||
| 152 | − </div> | ||
| 153 | − ))} | ||
| 154 | − </div> | ||
| 155 | − </section> | ||
| 156 | − | ||
| 157 | − {/* ---- valeur provinciale par année ---- */} | ||
| 158 | − <section className="mt-12"> | ||
| 159 | − <span className="kicker">2021 → 2026</span> | ||
| 160 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | ||
| 161 | − {fr ? "La province, année par année" : "The province, year by year"} | ||
| 162 | − </h2> | ||
| 163 | − <div className="vp-card rise mt-4 p-5 sm:p-6"> | ||
| 164 | − <div className="flex flex-col gap-2"> | ||
| 165 | − {s.totaux_annee.map((h, i) => { | ||
| 166 | − const prev = i > 0 ? s.totaux_annee[i - 1].total : null; | ||
| 167 | − const yoy = prev ? ((h.total / prev - 1) * 100) : null; | ||
| 168 | − return ( | ||
| 169 | − <div key={h.year} className="hbar-row grid grid-cols-[44px_1fr_auto] items-center gap-2 sm:grid-cols-[52px_1fr_auto] sm:gap-3"> | ||
| 170 | − <span className="vp-mono text-[12px] font-bold">{h.year}</span> | ||
| 171 | − <span className="hbar-track !h-[24px]"> | ||
| 172 | − <span | ||
| 173 | − className="hbar-fill grow-x !rounded-r-[6px]" | ||
| 174 | − style={{ | ||
| 175 | − width: `${(h.total / maxYear) * 100}%`, | ||
| 176 | − animationDelay: `${i * 90}ms`, | ||
| 177 | − background: h.year === 2026 ? "var(--ink)" : undefined, | ||
| 178 | − }} | ||
| 179 | − /> | ||
| 180 | − </span> | ||
| 181 | − <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold"> | ||
| 182 | − {compact(h.total, lang)} | ||
| 183 | − {yoy != null && ( | ||
| 184 | − <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}> | ||
| 185 | − {yoy >= 0 ? "+" : ""}{yoy.toFixed(1)} % | ||
| 186 | − </span> | ||
| 187 | − )} | ||
| 188 | − </span> | ||
| 189 | − </div> | ||
| 190 | − ); | ||
| 191 | − })} | ||
| 192 | − </div> | ||
| 193 | − <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3"> | ||
| 194 | − {fr | ||
| 195 | − ? "Sommes des unités estimées chaque millésime — le parc s'agrandit d'année en année (nouvelles constructions incluses)." | ||
| 196 | − : "Sums of units estimated each vintage — the stock grows every year (new construction included)."} | ||
| 197 | − </p> | ||
| 198 | − </div> | ||
| 199 | − </section> | ||
| 200 | − | ||
| 201 | − {/* ---- par type ---- */} | ||
| 202 | − <section className="mt-12"> | ||
| 203 | − <span className="kicker">{fr ? "Répartition" : "Breakdown"}</span> | ||
| 204 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | ||
| 205 | − {fr ? "Valeur par type de propriété" : "Value by property type"} | ||
| 206 | − </h2> | ||
| 207 | − <div className="vp-card rise mt-4 p-5 sm:p-6"> | ||
| 208 | − <div className="flex flex-col gap-2.5"> | ||
| 209 | − {s.par_type.map((tp, i) => ( | ||
| 210 | − <div key={tp.type} className="hbar-row grid grid-cols-[110px_1fr_auto] items-center gap-2 sm:grid-cols-[150px_1fr_auto] sm:gap-3"> | ||
| 211 | − <span className="vp-display truncate text-[13px] font-bold">{t(tp.type as TKey)}</span> | ||
| 212 | − <span className="hbar-track !h-[22px]"> | ||
| 213 | − <span | ||
| 214 | − className="hbar-fill grow-x" | ||
| 215 | − style={{ width: `${(tp.total / maxType) * 100}%`, animationDelay: `${i * 80}ms` }} | ||
| 216 | − /> | ||
| 217 | − </span> | ||
| 218 | − <span className="vp-mono text-right text-[11.5px] font-bold"> | ||
| 219 | − {compact(tp.total, lang)} | ||
| 220 | − <span className="block text-[9.5px] font-normal text-ink-3"> | ||
| 221 | − {num(tp.n, lang)} · {fr ? "méd." : "med."} {tp.mediane ? compact(tp.mediane, lang) : "—"} | ||
| 222 | − </span> | ||
| 223 | − </span> | ||
| 224 | − </div> | ||
| 225 | − ))} | ||
| 226 | − </div> | ||
| 227 | − </div> | ||
| 228 | − </section> | ||
| 229 | − | ||
| 230 | − {/* ---- par ville ---- */} | ||
| 231 | − <section className="mt-12"> | ||
| 232 | − <span className="kicker">{fr ? "Palmarès" : "Ranking"}</span> | ||
| 233 | − <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]"> | ||
| 234 | − {fr ? "Valeur par municipalité" : "Value by municipality"} | ||
| 235 | − </h2> | ||
| 236 | − {/* top 10 en barres */} | ||
| 237 | − <div className="vp-card rise mt-4 p-5 sm:p-6"> | ||
| 238 | − <div className="flex flex-col gap-2"> | ||
| 239 | − {top10.map((v, i) => ( | ||
| 240 | − <div key={v.ville} className="hbar-row grid grid-cols-[24px_96px_1fr_auto] items-center gap-2 sm:grid-cols-[28px_150px_1fr_auto] sm:gap-3"> | ||
| 241 | − <span className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[10.5px] font-bold ${i < 3 ? "bg-ink text-lime" : "bg-surface text-ink"}`}> | ||
| 242 | − {i + 1} | ||
| 243 | − </span> | ||
| 244 | − <span className="vp-display truncate text-[13px] font-bold">{v.ville}</span> | ||
| 245 | − <span className="hbar-track !h-[22px]"> | ||
| 246 | − <span | ||
| 247 | − className="hbar-fill grow-x" | ||
| 248 | − style={{ width: `${(v.total / maxVille) * 100}%`, animationDelay: `${i * 70}ms`, background: i === 0 ? "var(--ink)" : undefined }} | ||
| 249 | − /> | ||
| 250 | − </span> | ||
| 251 | − <span className="vp-mono text-right text-[11.5px] font-bold">{compact(v.total, lang)}</span> | ||
| 252 | − </div> | ||
| 253 | − ))} | ||
| 254 | − </div> | ||
| 255 | − </div> | ||
| 256 | − {/* table complète filtrable */} | ||
| 257 | − <div className="mt-4"> | ||
| 258 | − <input | ||
| 259 | − value={q} | ||
| 260 | − onChange={(e) => setQ(e.target.value)} | ||
| 261 | − placeholder={fr ? "Filtrer parmi les 200 plus grandes municipalités…" : "Filter the 200 largest municipalities…"} | ||
| 262 | − className="vp-input max-w-md" | ||
| 263 | − /> | ||
| 264 | − <div className="src-wrap mt-3 max-h-[460px] overflow-y-auto"> | ||
| 265 | − <table className="src-table"> | ||
| 266 | − <thead className="sticky top-0 z-10"> | ||
| 267 | − <tr> | ||
| 268 | − <th>#</th> | ||
| 269 | − <th>{fr ? "Municipalité" : "Municipality"}</th> | ||
| 270 | − <th className="text-right">{fr ? "Propriétés" : "Properties"}</th> | ||
| 271 | − <th className="text-right">{fr ? "Valeur totale" : "Total value"}</th> | ||
| 272 | − <th className="text-right">{fr ? "Valeur médiane" : "Median value"}</th> | ||
| 273 | − </tr> | ||
| 274 | − </thead> | ||
| 275 | − <tbody> | ||
| 276 | − {villes.map((v) => { | ||
| 277 | − const rank = s.par_ville.indexOf(v) + 1; | ||
| 278 | − return ( | ||
| 279 | − <tr key={v.ville}> | ||
| 280 | − <td className="vp-mono text-[11px] font-bold text-ink-3">{rank}</td> | ||
| 281 | − <td className="font-semibold">{v.ville}</td> | ||
| 282 | − <td className="vp-mono text-right text-[12px]">{num(v.n, lang)}</td> | ||
| 283 | − <td className="vp-display text-right font-bold">{compact(v.total, lang)}</td> | ||
| 284 | − <td className="vp-mono text-right text-[12px]">{v.mediane ? money(v.mediane, lang) : "—"}</td> | ||
| 285 | − </tr> | ||
| 286 | − ); | ||
| 287 | − })} | ||
| 288 | − </tbody> | ||
| 289 | − </table> | ||
| 290 | − </div> | ||
| 291 | − <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3"> | ||
| 292 | − {fr | ||
| 293 | − ? `Top 200 des ${num(s.municipalites, lang)} municipalités, par valeur totale estimée (millésime 2026). Généré le ${s.generated}.` | ||
| 294 | − : `Top 200 of ${num(s.municipalites, lang)} municipalities by total estimated value (2026 vintage). Generated ${s.generated}.`} | ||
| 295 | − </p> | ||
| 296 | − </div> | ||
| 297 | </section> | 108 | </section> |
| 298 | </div> | 109 | </div> |
| 299 | ); | 110 | ); |
modified
src/lib/report-stats.ts
+566 −177
@@ -1,18 +1,22 @@ | |||
| 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | /** | 2 | /** |
| 3 | * Rapport statistique provincial — PDF vectoriel au GABARIT COMMUN GROUPE KA | 3 | * Rapport statistique provincial — PDF vectoriel au GABARIT COMMUN GROUPE KA |
| 4 | − * (src/ka/stats/SPEC.md §3) : couverture estampillée « GROUPE KA · RAPPORT | 4 | + * v2 (src/ka/stats/SPEC.md §3), moteur pdfkit (équivalent TS de kapdf.py) : |
| 5 | − * STATISTIQUE », en-tête/pied normalisés sur chaque page, page de fin avec | 5 | + * couverture estampillée « GROUPE KA · RAPPORT STATISTIQUE », en-tête/pied |
| 6 | − * coordonnées + avertissement d'agrégateur (src/ka/ecosystem.json). | 6 | + * normalisés, sommaire paginé, graphiques 100 % VECTORIELS (courbes, aires, |
| 7 | − * Entre ces pages, tout le contenu riche d'origine : valeur totale du parc | 7 | + * barres, multi-courbes à motifs distincts, empilées, anneaux, jauges, |
| 8 | − * immobilier québécois, évolution 2021-2026, répartition par type, records et | 8 | + * calendrier), statistiques de séries (min/max/moy/méd/σ), tableaux zébrés |
| 9 | − * palmarès complet des 200 plus grandes municipalités. | 9 | + * paginés, records, page de fin avec coordonnées (src/ka/ecosystem.json). |
| 10 | − * Modes : "complet" (défaut) · "synthese" (couverture + sommaire exécutif). | 10 | + * |
| 11 | + * 5 MODES (SPEC §3) — un mode inconnu retombe sur « complet » : | ||
| 12 | + * complet · synthese · tendances · repartitions · donnees | ||
| 13 | + * Toutes les données viennent de getDashboard() (agrégats réels du rôle). | ||
| 11 | */ | 14 | */ |
| 12 | import PDFDocument from "pdfkit"; | 15 | import PDFDocument from "pdfkit"; |
| 13 | import path from "path"; | 16 | import path from "path"; |
| 14 | import type { ProvStats } from "@/components/StatsView"; | 17 | import type { ProvStats } from "@/components/StatsView"; |
| 15 | −import { buildRecords, PERIOD_LABEL } from "@/lib/stats-dashboard"; | 18 | +import { getDashboard, buildRecords, PERIOD_LABEL, type KaSerie, type KaRecord } from "@/lib/stats-dashboard"; |
| 19 | +import stats from "@/data/stats.json"; | ||
| 16 | import eco from "@/ka/ecosystem.json"; | 20 | import eco from "@/ka/ecosystem.json"; |
| 17 | 21 | ||
| 18 | const INK = "#141814"; | 22 | const INK = "#141814"; |
@@ -30,6 +34,8 @@ const W = 595.28; | |||
| 30 | const H = 841.89; | 34 | const H = 841.89; |
| 31 | const M = 44; | 35 | const M = 44; |
| 32 | const CW = W - 2 * M; | 36 | const CW = W - 2 * M; |
| 37 | +const TOP = 56; // début du contenu sous l'en-tête | ||
| 38 | +const BOT = H - 58; // fin du contenu au-dessus du pied | ||
| 33 | 39 | ||
| 34 | const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); | 40 | const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); |
| 35 | const money = (v: number) => | 41 | const money = (v: number) => |
@@ -41,17 +47,30 @@ const compact = (v: number) => { | |||
| 41 | return money(v); | 47 | return money(v); |
| 42 | }; | 48 | }; |
| 43 | const num = (v: number) => v.toLocaleString("fr-CA"); | 49 | const num = (v: number) => v.toLocaleString("fr-CA"); |
| 44 | −const TYPE_FR: Record<string, string> = { | 50 | +/** Nombre court pour axes/valeurs de graphiques. */ |
| 45 | − unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi", | 51 | +const short = (v: number) => { |
| 46 | − chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre", | 52 | + if (Math.abs(v) >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} B`; |
| 53 | + if (Math.abs(v) >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G`; | ||
| 54 | + if (Math.abs(v) >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`; | ||
| 55 | + if (Math.abs(v) >= 1e4) return `${(v / 1e3).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} k`; | ||
| 56 | + return v.toLocaleString("fr-CA", { maximumFractionDigits: 1 }); | ||
| 47 | }; | 57 | }; |
| 48 | const POP_QC = 9_111_000; | 58 | const POP_QC = 9_111_000; |
| 49 | const BUDGET_QC = 165_800_000_000; | 59 | const BUDGET_QC = 165_800_000_000; |
| 50 | const PIB_QC = 640_000_000_000; | 60 | const PIB_QC = 640_000_000_000; |
| 51 | 61 | ||
| 52 | −export type ReportMode = "complet" | "synthese"; | 62 | +export const REPORT_MODES = ["complet", "synthese", "tendances", "repartitions", "donnees"] as const; |
| 63 | +export type ReportMode = (typeof REPORT_MODES)[number]; | ||
| 64 | +const MODE_FR: Record<ReportMode, string> = { | ||
| 65 | + complet: "Rapport complet", | ||
| 66 | + synthese: "Synthèse exécutive", | ||
| 67 | + tendances: "Tendances & évolution", | ||
| 68 | + repartitions: "Répartitions & géographie", | ||
| 69 | + donnees: "Données détaillées", | ||
| 70 | +}; | ||
| 53 | 71 | ||
| 54 | type Doc = InstanceType<typeof PDFDocument>; | 72 | type Doc = InstanceType<typeof PDFDocument>; |
| 73 | +type Dash = ReturnType<typeof getDashboard>; | ||
| 55 | 74 | ||
| 56 | /** Wordmark Vrai-Prix : « Vrai- » encre + boîte encre / accent rouge #ff5148. */ | 75 | /** Wordmark Vrai-Prix : « Vrai- » encre + boîte encre / accent rouge #ff5148. */ |
| 57 | function wordmark(doc: Doc, x: number, y: number, size = 24) { | 76 | function wordmark(doc: Doc, x: number, y: number, size = 24) { |
@@ -67,25 +86,21 @@ function wordmark(doc: Doc, x: number, y: number, size = 24) { | |||
| 67 | function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) { | 86 | function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) { |
| 68 | doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN); | 87 | doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN); |
| 69 | doc.font("JB-Bold").fontSize(size).fillColor(GREEN) | 88 | doc.font("JB-Bold").fontSize(size).fillColor(GREEN) |
| 70 | − .text(txt.toUpperCase(), x + 2.5 * size + 6, y, { characterSpacing: 0.175 * size, lineBreak: false }); | 89 | + .text(txt.toUpperCase(), x + 2.5 * size + 6, y, { characterSpacing: 0.175 * size, lineBreak: false, width: CW - 2.5 * size - 6, height: size * 1.6, ellipsis: true }); |
| 71 | } | 90 | } |
| 72 | 91 | ||
| 73 | −/** En-tête + pied normalisés Groupe-KA (toutes les pages sauf la couverture). */ | 92 | +/** En-tête + pied normalisés Groupe-KA (numéros de page ajoutés en post-passe). */ |
| 74 | −function chrome(doc: Doc, page: number, total: number) { | 93 | +function chrome(doc: Doc) { |
| 75 | const year = new Date().getFullYear(); | 94 | const year = new Date().getFullYear(); |
| 76 | doc.rect(0, 0, W, H).fill(PAPER); | 95 | doc.rect(0, 0, W, H).fill(PAPER); |
| 77 | − // en-tête discret | ||
| 78 | doc.font("SG-Bold").fontSize(9.5).fillColor(INK) | 96 | doc.font("SG-Bold").fontSize(9.5).fillColor(INK) |
| 79 | .text("Groupe KA · Vrai-Prix", M, 22, { lineBreak: false }); | 97 | .text("Groupe KA · Vrai-Prix", M, 22, { lineBreak: false }); |
| 80 | doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) | 98 | doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) |
| 81 | .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 }); | 99 | .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 }); |
| 82 | doc.rect(M, 37, CW, 1.1).fill(INK); | 100 | doc.rect(M, 37, CW, 1.1).fill(INK); |
| 83 | − // pied | ||
| 84 | doc.rect(M, H - 42, CW, 0.7).fill(INK3); | 101 | doc.rect(M, H - 42, CW, 0.7).fill(INK3); |
| 85 | doc.font("JB-Reg").fontSize(6.8).fillColor(INK3) | 102 | doc.font("JB-Reg").fontSize(6.8).fillColor(INK3) |
| 86 | .text(`© Groupe-KA — ${year} — groupe-ka.com · ${PERIOD_LABEL}`, M, H - 34, { lineBreak: false }); | 103 | .text(`© Groupe-KA — ${year} — groupe-ka.com · ${PERIOD_LABEL}`, M, H - 34, { lineBreak: false }); |
| 87 | − doc.font("JB-Bold").fontSize(7.5).fillColor(INK) | ||
| 88 | − .text(`p. ${page}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right" }); | ||
| 89 | } | 104 | } |
| 90 | 105 | ||
| 91 | function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") { | 106 | function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") { |
@@ -103,25 +118,431 @@ function hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac: | |||
| 103 | if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" }); | 118 | if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" }); |
| 104 | } | 119 | } |
| 105 | 120 | ||
| 121 | +/* ============================ curseur de page ============================ */ | ||
| 122 | +class Cursor { | ||
| 123 | + y = TOP; | ||
| 124 | + sections: { label: string; page: number }[] = []; | ||
| 125 | + constructor(public doc: Doc) {} | ||
| 126 | + page() { | ||
| 127 | + // bufferedPageRange().count = pages déjà créées (1-based pour l'humain) | ||
| 128 | + return this.doc.bufferedPageRange().count; | ||
| 129 | + } | ||
| 130 | + ensure(h: number) { | ||
| 131 | + if (this.y + h > BOT) { | ||
| 132 | + this.doc.addPage({ size: "A4", margin: 0 }); | ||
| 133 | + chrome(this.doc); | ||
| 134 | + this.y = TOP; | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + section(label: string, remember = true) { | ||
| 138 | + this.ensure(30); | ||
| 139 | + if (remember) this.sections.push({ label, page: this.page() }); | ||
| 140 | + kicker(this.doc, label, M, this.y); | ||
| 141 | + this.y += 20; | ||
| 142 | + } | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +/* ============================ primitives graphiques ============================ */ | ||
| 146 | + | ||
| 147 | +/** Cadre de graphique : carte + titre, renvoie la zone de tracé. */ | ||
| 148 | +function chartFrame(c: Cursor, title: string, h: number) { | ||
| 149 | + c.ensure(h + 34); | ||
| 150 | + const { doc } = c; | ||
| 151 | + card(doc, M, c.y, CW, h + 26); | ||
| 152 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK) | ||
| 153 | + .text(title, M + 14, c.y + 10, { width: CW - 28, height: 12, ellipsis: true, lineBreak: false }); | ||
| 154 | + const area = { x: M + 52, y: c.y + 30, w: CW - 52 - 22, h: h - 14 }; | ||
| 155 | + c.y += h + 26 + 12; | ||
| 156 | + return area; | ||
| 157 | +} | ||
| 158 | + | ||
| 159 | +function axes(doc: Doc, a: { x: number; y: number; w: number; h: number }, vmin: number, vmax: number, labels: string[]) { | ||
| 160 | + for (let g = 0; g <= 4; g++) { | ||
| 161 | + const y = a.y + (a.h * g) / 4; | ||
| 162 | + const v = vmax - ((vmax - vmin) * g) / 4; | ||
| 163 | + doc.rect(a.x, y, a.w, 0.5).fill("#e3e1d9"); | ||
| 164 | + doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) | ||
| 165 | + .text(short(v), a.x - 40, y - 2.5, { width: 36, align: "right", lineBreak: false }); | ||
| 166 | + } | ||
| 167 | + const idx = [0, Math.floor(labels.length / 2), labels.length - 1].filter((v, i, arr) => arr.indexOf(v) === i); | ||
| 168 | + idx.forEach((i) => { | ||
| 169 | + const x = a.x + (labels.length > 1 ? (a.w * i) / (labels.length - 1) : 0); | ||
| 170 | + doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) | ||
| 171 | + .text(labels[i] ?? "", x - 24, a.y + a.h + 4, { width: 48, align: "center", lineBreak: false }); | ||
| 172 | + }); | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +function linePdf(c: Cursor, s: KaSerie, h = 120) { | ||
| 176 | + const pts = s.points ?? []; | ||
| 177 | + if (pts.length < 2) return; | ||
| 178 | + const a = chartFrame(c, s.title, h); | ||
| 179 | + const { doc } = c; | ||
| 180 | + const vmax = Math.max(...pts.map((p) => p.v), 1); | ||
| 181 | + const vmin = Math.min(0, ...pts.map((p) => p.v)); | ||
| 182 | + axes(doc, a, vmin, vmax, pts.map((p) => p.t)); | ||
| 183 | + const X = (i: number) => a.x + (a.w * i) / (pts.length - 1); | ||
| 184 | + const Y = (v: number) => a.y + a.h * (1 - (v - vmin) / (vmax - vmin || 1)); | ||
| 185 | + if (s.kind === "bar") { | ||
| 186 | + const bw = Math.max(1.2, a.w / pts.length - 1.2); | ||
| 187 | + pts.forEach((p, i) => { | ||
| 188 | + const bh = a.h * ((p.v - Math.max(0, vmin)) / (vmax - vmin || 1)); | ||
| 189 | + doc.rect(a.x + (a.w * i) / pts.length, a.y + a.h - bh, bw, Math.max(bh, 0.6)).fill(GREEN); | ||
| 190 | + }); | ||
| 191 | + return; | ||
| 192 | + } | ||
| 193 | + if (s.kind === "area") { | ||
| 194 | + doc.moveTo(X(0), Y(pts[0].v)); | ||
| 195 | + pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v))); | ||
| 196 | + doc.lineTo(X(pts.length - 1), a.y + a.h).lineTo(a.x, a.y + a.h).closePath(); | ||
| 197 | + doc.fillOpacity(0.16).fill(GREEN).fillOpacity(1); | ||
| 198 | + } | ||
| 199 | + doc.moveTo(X(0), Y(pts[0].v)); | ||
| 200 | + pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v))); | ||
| 201 | + doc.lineWidth(1.6).strokeColor(GREEN).stroke(); | ||
| 202 | + // point final + valeur | ||
| 203 | + const lastX = X(pts.length - 1), lastY = Y(pts[pts.length - 1].v); | ||
| 204 | + doc.circle(lastX, lastY, 2).fill(INK); | ||
| 205 | + doc.font("JB-Bold").fontSize(6).fillColor(INK) | ||
| 206 | + .text(short(pts[pts.length - 1].v), lastX - 60, lastY - 10, { width: 58, align: "right", lineBreak: false }); | ||
| 207 | +} | ||
| 208 | + | ||
| 209 | +/** Min/max/moyenne/médiane/écart-type sous une courbe (SPEC §1.4). */ | ||
| 210 | +function statLine(c: Cursor, s: KaSerie) { | ||
| 211 | + const vs = (s.points ?? []).map((p) => p.v); | ||
| 212 | + if (vs.length < 2) return; | ||
| 213 | + const sorted = [...vs].sort((x, y) => x - y); | ||
| 214 | + const mean = vs.reduce((x, y) => x + y, 0) / vs.length; | ||
| 215 | + const sd = Math.sqrt(vs.reduce((x, v) => x + (v - mean) ** 2, 0) / vs.length); | ||
| 216 | + const items: [string, number][] = [ | ||
| 217 | + ["MIN", sorted[0]], ["MAX", sorted[sorted.length - 1]], ["MOYENNE", mean], | ||
| 218 | + ["MÉDIANE", sorted[Math.floor(sorted.length / 2)]], ["ÉCART-TYPE", sd], | ||
| 219 | + ]; | ||
| 220 | + c.ensure(16); | ||
| 221 | + c.y -= 6; | ||
| 222 | + const w = CW / items.length; | ||
| 223 | + items.forEach(([l, v], i) => { | ||
| 224 | + c.doc.font("JB-Reg").fontSize(5.5).fillColor(INK3).text(l, M + i * w, c.y, { width: w - 6, lineBreak: false }); | ||
| 225 | + c.doc.font("JB-Bold").fontSize(7).fillColor(INK).text(short(v), M + i * w, c.y + 7.5, { width: w - 6, lineBreak: false }); | ||
| 226 | + }); | ||
| 227 | + c.y += 24; | ||
| 228 | +} | ||
| 229 | + | ||
| 230 | +const MULTI_PDF = [ | ||
| 231 | + { color: GREEN, dash: null as number[] | null, width: 1.8 }, | ||
| 232 | + { color: INK, dash: null, width: 1.2 }, | ||
| 233 | + { color: GREEN_DEEP, dash: [5, 2.5], width: 1.5 }, | ||
| 234 | + { color: INK3, dash: [1.5, 2.5], width: 1.5 }, | ||
| 235 | +]; | ||
| 236 | + | ||
| 237 | +function multiLinePdf(c: Cursor, ms: { title: string; unit?: string; series: { label: string; points: { t: string; v: number }[] }[] }, h = 130) { | ||
| 238 | + const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4); | ||
| 239 | + if (!series.length) return; | ||
| 240 | + const a = chartFrame(c, ms.title + (ms.unit ? ` (${ms.unit})` : ""), h + 14); | ||
| 241 | + const { doc } = c; | ||
| 242 | + // légende (motif + libellé — jamais la couleur seule) | ||
| 243 | + let lx = a.x; | ||
| 244 | + series.forEach((s, i) => { | ||
| 245 | + const st = MULTI_PDF[i]; | ||
| 246 | + doc.lineWidth(2).strokeColor(st.color); | ||
| 247 | + if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash(); | ||
| 248 | + doc.moveTo(lx, a.y + 2).lineTo(lx + 16, a.y + 2).stroke(); | ||
| 249 | + doc.undash(); | ||
| 250 | + doc.font("JB-Bold").fontSize(6).fillColor(INK).text(s.label.toUpperCase(), lx + 20, a.y - 1, { lineBreak: false }); | ||
| 251 | + lx += 26 + doc.widthOfString(s.label.toUpperCase()) + 14; | ||
| 252 | + }); | ||
| 253 | + const area = { ...a, y: a.y + 12, h: a.h - 12 }; | ||
| 254 | + const all = series.flatMap((s) => s.points.map((p) => p.v)); | ||
| 255 | + const vmax = Math.max(...all, 1); | ||
| 256 | + const vmin = Math.min(0, ...all); | ||
| 257 | + axes(doc, area, vmin, vmax, series[0].points.map((p) => p.t)); | ||
| 258 | + series.forEach((s, i) => { | ||
| 259 | + const st = MULTI_PDF[i]; | ||
| 260 | + const X = (j: number) => area.x + (area.w * j) / (s.points.length - 1); | ||
| 261 | + const Y = (v: number) => area.y + area.h * (1 - (v - vmin) / (vmax - vmin || 1)); | ||
| 262 | + doc.moveTo(X(0), Y(s.points[0].v)); | ||
| 263 | + s.points.forEach((p, j) => doc.lineTo(X(j), Y(p.v))); | ||
| 264 | + doc.lineWidth(st.width).strokeColor(st.color); | ||
| 265 | + if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash(); | ||
| 266 | + doc.stroke(); | ||
| 267 | + doc.undash(); | ||
| 268 | + }); | ||
| 269 | +} | ||
| 270 | + | ||
| 271 | +const SHADES = [1, 0.72, 0.5, 0.34, 0.22, 0.13]; | ||
| 272 | + | ||
| 273 | +function stackedPdf(c: Cursor, st: { title: string; unit?: string; keys: string[]; points: { t: string; values: number[] }[] }, h = 130) { | ||
| 274 | + const keys = (st.keys ?? []).slice(0, 6); | ||
| 275 | + const pts = st.points ?? []; | ||
| 276 | + if (!keys.length || !pts.length) return; | ||
| 277 | + const a = chartFrame(c, st.title, h + 14); | ||
| 278 | + const { doc } = c; | ||
| 279 | + let lx = a.x; | ||
| 280 | + keys.forEach((k, i) => { | ||
| 281 | + doc.fillOpacity(SHADES[i]).rect(lx, a.y - 1, 8, 8).fill(GREEN).fillOpacity(1); | ||
| 282 | + doc.rect(lx, a.y - 1, 8, 8).lineWidth(0.5).stroke(INK); | ||
| 283 | + doc.font("JB-Bold").fontSize(6).fillColor(INK).text(k.toUpperCase(), lx + 12, a.y, { lineBreak: false }); | ||
| 284 | + lx += 18 + doc.widthOfString(k.toUpperCase()) + 12; | ||
| 285 | + }); | ||
| 286 | + const area = { ...a, y: a.y + 12, h: a.h - 12 }; | ||
| 287 | + const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0)); | ||
| 288 | + const vmax = Math.max(...totals, 1); | ||
| 289 | + axes(doc, area, 0, vmax, pts.map((p) => p.t)); | ||
| 290 | + const bw = Math.max(1, area.w / pts.length - 1); | ||
| 291 | + pts.forEach((p, i) => { | ||
| 292 | + const x = area.x + (area.w * i) / pts.length; | ||
| 293 | + let yAcc = area.y + area.h; | ||
| 294 | + keys.forEach((k, j) => { | ||
| 295 | + const v = p.values[j] || 0; | ||
| 296 | + const bh = area.h * (v / vmax); | ||
| 297 | + yAcc -= bh; | ||
| 298 | + if (bh > 0.3) { | ||
| 299 | + doc.fillOpacity(SHADES[j]).rect(x, yAcc, bw, bh).fill(GREEN).fillOpacity(1); | ||
| 300 | + } | ||
| 301 | + }); | ||
| 302 | + }); | ||
| 303 | +} | ||
| 304 | + | ||
| 305 | +/** Anneau vectoriel (arcs tracés en petits segments) + légende. */ | ||
| 306 | +function donutPdf(c: Cursor, title: string, items: { label: string; value: number }[]) { | ||
| 307 | + const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8); | ||
| 308 | + const total = rows.reduce((s, r) => s + r.value, 0); | ||
| 309 | + if (!total) return; | ||
| 310 | + const h = 150; | ||
| 311 | + c.ensure(h + 40); | ||
| 312 | + const { doc } = c; | ||
| 313 | + card(doc, M, c.y, CW, h + 26); | ||
| 314 | + doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text(title, M + 14, c.y + 10, { lineBreak: false }); | ||
| 315 | + const cx = M + 90, cy = c.y + 30 + (h - 20) / 2, R = 52; | ||
| 316 | + let angle = -Math.PI / 2; | ||
| 317 | + rows.forEach((r, i) => { | ||
| 318 | + const sweep = (2 * Math.PI * r.value) / total; | ||
| 319 | + const steps = Math.max(2, Math.ceil(sweep / 0.06)); | ||
| 320 | + doc.lineWidth(24).strokeColor(GREEN).strokeOpacity(SHADES[i % SHADES.length] * 0.9 + 0.1); | ||
| 321 | + doc.moveTo(cx + R * Math.cos(angle), cy + R * Math.sin(angle)); | ||
| 322 | + for (let s = 1; s <= steps; s++) { | ||
| 323 | + const t = angle + (sweep * s) / steps; | ||
| 324 | + doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t)); | ||
| 325 | + } | ||
| 326 | + doc.stroke().strokeOpacity(1); | ||
| 327 | + angle += sweep; | ||
| 328 | + }); | ||
| 329 | + doc.circle(cx, cy, R + 12).lineWidth(0.7).strokeOpacity(0.5).stroke(INK).strokeOpacity(1); | ||
| 330 | + // légende | ||
| 331 | + const lx = M + 190; | ||
| 332 | + let ly = c.y + 34; | ||
| 333 | + rows.forEach((r, i) => { | ||
| 334 | + doc.fillOpacity(SHADES[i % SHADES.length]).rect(lx, ly, 8, 8).fill(GREEN).fillOpacity(1); | ||
| 335 | + doc.rect(lx, ly, 8, 8).lineWidth(0.5).stroke(INK); | ||
| 336 | + doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(r.label, lx + 14, ly, { width: 200, height: 9, ellipsis: true, lineBreak: false }); | ||
| 337 | + doc.font("JB-Bold").fontSize(7).fillColor(INK2) | ||
| 338 | + .text(`${((100 * r.value) / total).toFixed(1).replace(".", ",")} % · ${short(r.value)}$`, lx + 220, ly + 0.5, { width: CW - 220 - (lx - M) - 14, align: "right", lineBreak: false }); | ||
| 339 | + ly += Math.min(17, (h - 10) / rows.length); | ||
| 340 | + }); | ||
| 341 | + c.y += h + 26 + 12; | ||
| 342 | +} | ||
| 343 | + | ||
| 344 | +/** Barres horizontales (répartitions / géo). */ | ||
| 345 | +function hbarsPdf(c: Cursor, title: string, items: { label: string; value: number }[], fmt: (v: number) => string = compact) { | ||
| 346 | + const rows = (items ?? []).slice(0, 15); | ||
| 347 | + if (!rows.length) return; | ||
| 348 | + const boxH = rows.length * 17 + 24; | ||
| 349 | + c.ensure(boxH + 30); | ||
| 350 | + const { doc } = c; | ||
| 351 | + c.section(title, false); | ||
| 352 | + card(doc, M, c.y, CW, boxH); | ||
| 353 | + const max = Math.max(...rows.map((r) => r.value), 1); | ||
| 354 | + rows.forEach((r, i) => { | ||
| 355 | + hbarRow(doc, M + 14, c.y + 12 + i * 17, CW - 28, r.label, r.value / max, fmt(r.value), null, i === 0); | ||
| 356 | + }); | ||
| 357 | + c.y += boxH + 14; | ||
| 358 | +} | ||
| 359 | + | ||
| 360 | +/** Jauges demi-arc (accent), 4 par rangée. */ | ||
| 361 | +function gaugesPdf(c: Cursor, gauges: Dash["gauges"]) { | ||
| 362 | + if (!gauges?.length) return; | ||
| 363 | + const gw = (CW - 3 * 10) / 4; | ||
| 364 | + const rowsN = Math.ceil(gauges.length / 4); | ||
| 365 | + c.ensure(rowsN * 86 + 10); | ||
| 366 | + const { doc } = c; | ||
| 367 | + gauges.forEach((g, i) => { | ||
| 368 | + const x = M + (i % 4) * (gw + 10); | ||
| 369 | + const y = c.y + Math.floor(i / 4) * 86; | ||
| 370 | + card(doc, x, y, gw, 78); | ||
| 371 | + const cx = x + gw / 2, cy = y + 44, R = 26; | ||
| 372 | + const arc = (from: number, to: number, color: string, lw: number) => { | ||
| 373 | + const steps = Math.max(2, Math.ceil(((to - from) / Math.PI) * 24)); | ||
| 374 | + doc.lineWidth(lw).strokeColor(color); | ||
| 375 | + doc.moveTo(cx + R * Math.cos(from), cy + R * Math.sin(from)); | ||
| 376 | + for (let s = 1; s <= steps; s++) { | ||
| 377 | + const t = from + ((to - from) * s) / steps; | ||
| 378 | + doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t)); | ||
| 379 | + } | ||
| 380 | + doc.stroke(); | ||
| 381 | + }; | ||
| 382 | + arc(Math.PI, 2 * Math.PI, "#e3e1d9", 8); | ||
| 383 | + const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0)); | ||
| 384 | + if (frac > 0.01) arc(Math.PI, Math.PI + Math.PI * frac, GREEN, 8); | ||
| 385 | + doc.font("SG-Bold").fontSize(13).fillColor(INK) | ||
| 386 | + .text(`${g.value.toLocaleString("fr-CA", { maximumFractionDigits: 1 })}${g.unit ?? ""}`, x, cy - 12, { width: gw, align: "center", lineBreak: false }); | ||
| 387 | + doc.font("JB-Bold").fontSize(5.2).fillColor(INK3) | ||
| 388 | + .text(g.label.toUpperCase(), x + 8, y + 58, { width: gw - 16, align: "center", characterSpacing: 0.3, height: 16 }); | ||
| 389 | + }); | ||
| 390 | + c.y += rowsN * 86 + 8; | ||
| 391 | +} | ||
| 392 | + | ||
| 393 | +/** Grille de cartes KPI (valeur, libellé, delta coloré à 1 décimale). */ | ||
| 394 | +function kpiGridPdf(c: Cursor, kpis: Dash["kpis"]) { | ||
| 395 | + const kw = (CW - 3 * 10) / 4; | ||
| 396 | + const rowsN = Math.ceil(kpis.length / 4); | ||
| 397 | + c.ensure(rowsN * 64 + 6); | ||
| 398 | + const { doc } = c; | ||
| 399 | + kpis.forEach((k, i) => { | ||
| 400 | + const x = M + (i % 4) * (kw + 10); | ||
| 401 | + const y = c.y + Math.floor(i / 4) * 64; | ||
| 402 | + card(doc, x, y, kw, 56); | ||
| 403 | + const val = typeof k.value === "number" ? num(k.value) : k.value; | ||
| 404 | + doc.font("SG-Bold").fontSize(12).fillColor(INK) | ||
| 405 | + .text(val + (k.unit && typeof k.value === "number" ? ` ${k.unit}` : ""), x + 9, y + 9, { width: kw - 18, height: 15, ellipsis: true, lineBreak: false }); | ||
| 406 | + if (k.delta_pct !== undefined && k.delta_pct !== null) { | ||
| 407 | + const up = (k.direction ?? (k.delta_pct >= 0 ? "up" : "down")) === "up"; | ||
| 408 | + doc.font("JB-Bold").fontSize(6.5).fillColor(up ? "#1c5c41" : "#b3423a") | ||
| 409 | + .text(`${up ? "▲" : "▼"} ${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`, x + 9, y + 26, { lineBreak: false }); | ||
| 410 | + } | ||
| 411 | + doc.font("JB-Bold").fontSize(5).fillColor(INK3) | ||
| 412 | + .text(k.label.toUpperCase(), x + 9, y + 37, { width: kw - 18, characterSpacing: 0.3, height: 15 }); | ||
| 413 | + }); | ||
| 414 | + c.y += rowsN * 64 + 10; | ||
| 415 | +} | ||
| 416 | + | ||
| 417 | +/** Calendrier d'activité (grille par semaine, opacité ∝ valeur). */ | ||
| 418 | +function heatmapPdf(c: Cursor, hm: { title: string; cells: { date: string; value: number }[] }) { | ||
| 419 | + if (!hm?.cells?.length) return; | ||
| 420 | + const byDate = new Map(hm.cells.map((x) => [x.date, x.value])); | ||
| 421 | + const dates = hm.cells.map((x) => x.date).sort(); | ||
| 422 | + const end = new Date(dates[dates.length - 1] + "T12:00:00"); | ||
| 423 | + const max = Math.max(...hm.cells.map((x) => x.value), 1); | ||
| 424 | + const weeks = 26; | ||
| 425 | + const cell = (CW - 28) / weeks; | ||
| 426 | + const gridH = 7 * cell; | ||
| 427 | + c.ensure(gridH + 60); | ||
| 428 | + const { doc } = c; | ||
| 429 | + c.section(hm.title, false); | ||
| 430 | + card(doc, M, c.y, CW, gridH + 24); | ||
| 431 | + const cur = new Date(end); | ||
| 432 | + cur.setDate(cur.getDate() - (weeks * 7 - 1)); | ||
| 433 | + for (let w = 0; w < weeks; w++) { | ||
| 434 | + for (let d = 0; d < 7; d++) { | ||
| 435 | + const iso = cur.toISOString().slice(0, 10); | ||
| 436 | + const v = byDate.get(iso) ?? 0; | ||
| 437 | + const x = M + 14 + w * cell; | ||
| 438 | + const y = c.y + 12 + d * cell; | ||
| 439 | + if (v > 0) { | ||
| 440 | + doc.fillOpacity(0.2 + 0.8 * (v / max)).rect(x, y, cell - 1.6, cell - 1.6).fill(GREEN).fillOpacity(1); | ||
| 441 | + } else { | ||
| 442 | + doc.rect(x, y, cell - 1.6, cell - 1.6).fill("#eceae2"); | ||
| 443 | + } | ||
| 444 | + cur.setDate(cur.getDate() + 1); | ||
| 445 | + } | ||
| 446 | + } | ||
| 447 | + c.y += gridH + 24 + 14; | ||
| 448 | +} | ||
| 449 | + | ||
| 450 | +/** Tableau zébré paginé proprement (jamais de ligne coupée). */ | ||
| 451 | +function tablePdf(c: Cursor, t: { title: string; columns: string[]; rows: (string | number | null)[][] }, maxRows: number, remember = true) { | ||
| 452 | + const rows = t.rows.slice(0, maxRows); | ||
| 453 | + if (!rows.length) return; | ||
| 454 | + const { doc } = c; | ||
| 455 | + c.ensure(90); | ||
| 456 | + if (remember) c.sections.push({ label: t.title, page: c.page() }); | ||
| 457 | + kicker(doc, t.title, M, c.y); | ||
| 458 | + c.y += 18; | ||
| 459 | + // largeur des colonnes : numériques 88 pt à droite, texte se partage le reste | ||
| 460 | + const isNum = t.columns.map((_, ci) => rows.every((r) => r[ci] === null || typeof r[ci] === "number")); | ||
| 461 | + const numW = 88; | ||
| 462 | + const rankW = 30; | ||
| 463 | + const widths: number[] = t.columns.map((col, ci) => { | ||
| 464 | + if (ci === 0 && (col === "Rang" || col === "#")) return rankW; | ||
| 465 | + return isNum[ci] ? numW : 0; | ||
| 466 | + }); | ||
| 467 | + const fixed = widths.reduce((a, b) => a + b, 0); | ||
| 468 | + const flexN = widths.filter((w) => w === 0).length || 1; | ||
| 469 | + const flexW = (CW - fixed) / flexN; | ||
| 470 | + const xs: number[] = []; | ||
| 471 | + let xAcc = M; | ||
| 472 | + widths.forEach((w) => { xs.push(xAcc); xAcc += w || flexW; }); | ||
| 473 | + const header = () => { | ||
| 474 | + doc.rect(M, c.y, CW, 15).fill(INK); | ||
| 475 | + doc.font("JB-Bold").fontSize(5.6).fillColor(WHITE); | ||
| 476 | + t.columns.forEach((col, ci) => { | ||
| 477 | + const w = (widths[ci] || flexW) - 10; | ||
| 478 | + doc.text(col.toUpperCase(), xs[ci] + 5, c.y + 5, { width: w, align: isNum[ci] && ci > 0 ? "right" : "left", lineBreak: false }); | ||
| 479 | + }); | ||
| 480 | + c.y += 15; | ||
| 481 | + }; | ||
| 482 | + header(); | ||
| 483 | + const fmtCell = (v: string | number | null, ci: number) => { | ||
| 484 | + if (v === null || v === undefined) return "—"; | ||
| 485 | + if (typeof v !== "number") return String(v); | ||
| 486 | + if (/\$/.test(t.columns[ci]) && Math.abs(v) >= 1e6) return compact(v); | ||
| 487 | + return num(v); | ||
| 488 | + }; | ||
| 489 | + rows.forEach((r, ri) => { | ||
| 490 | + if (c.y + 13 > BOT) { | ||
| 491 | + doc.rect(M, c.y, CW, 0.9).fill(INK); | ||
| 492 | + doc.addPage({ size: "A4", margin: 0 }); | ||
| 493 | + chrome(doc); | ||
| 494 | + c.y = TOP; | ||
| 495 | + kicker(doc, `${t.title} (suite)`, M, c.y); | ||
| 496 | + c.y += 18; | ||
| 497 | + header(); | ||
| 498 | + } | ||
| 499 | + if (ri % 2 === 0) doc.rect(M, c.y, CW, 13).fill(SURFACE2); | ||
| 500 | + t.columns.forEach((col, ci) => { | ||
| 501 | + const w = (widths[ci] || flexW) - 10; | ||
| 502 | + const right = isNum[ci] && ci > 0; | ||
| 503 | + doc.font(ci === 1 && !isNum[1] ? "SG-Bold" : "JB-Reg").fontSize(6.6).fillColor(INK) | ||
| 504 | + .text(fmtCell(r[ci], ci), xs[ci] + 5, c.y + 3.6, { width: w, align: right ? "right" : "left", height: 9, ellipsis: true, lineBreak: false }); | ||
| 505 | + }); | ||
| 506 | + c.y += 13; | ||
| 507 | + }); | ||
| 508 | + doc.rect(M, c.y, CW, 0.9).fill(INK); | ||
| 509 | + c.y += 14; | ||
| 510 | +} | ||
| 511 | + | ||
| 512 | +/** Records & faits marquants — cartes compactes 2 par rangée. */ | ||
| 513 | +function recordsPdf(c: Cursor, records: KaRecord[], remember = true) { | ||
| 514 | + if (!records?.length) return; | ||
| 515 | + c.section("Records & faits marquants", remember); | ||
| 516 | + const { doc } = c; | ||
| 517 | + const rw = (CW - 10) / 2; | ||
| 518 | + records.forEach((r, i) => { | ||
| 519 | + if (i % 2 === 0) c.ensure(52); | ||
| 520 | + const x = M + (i % 2) * (rw + 10); | ||
| 521 | + const y = c.y; | ||
| 522 | + card(doc, x, y, rw, 44, SURFACE2); | ||
| 523 | + doc.font("SG-Bold").fontSize(9).fillColor(INK) | ||
| 524 | + .text(r.value, x + 10, y + 8, { width: rw - 20, height: 11, ellipsis: true, lineBreak: false }); | ||
| 525 | + doc.font("JB-Bold").fontSize(5.2).fillColor(INK3) | ||
| 526 | + .text(r.label.toUpperCase() + (r.date ? ` · ${r.date}` : ""), x + 10, y + 24, { width: rw - 20, characterSpacing: 0.3, height: 14 }); | ||
| 527 | + if (i % 2 === 1 || i === records.length - 1) c.y += 52; | ||
| 528 | + }); | ||
| 529 | + c.y += 6; | ||
| 530 | +} | ||
| 531 | + | ||
| 106 | /* ------------------------------- COUVERTURE ------------------------------- */ | 532 | /* ------------------------------- COUVERTURE ------------------------------- */ |
| 107 | function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) { | 533 | function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) { |
| 108 | doc.rect(0, 0, W, H).fill(PAPER); | 534 | doc.rect(0, 0, W, H).fill(PAPER); |
| 109 | − // cadre encre | ||
| 110 | doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK); | 535 | doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK); |
| 111 | − // kicker | ||
| 112 | kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5); | 536 | kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5); |
| 113 | − // wordmark plateforme (boîte encre / accent rouge) | ||
| 114 | wordmark(doc, 64, 172, 46); | 537 | wordmark(doc, 64, 172, 46); |
| 115 | − // sous-titre | ||
| 116 | doc.font("Inter").fontSize(13).fillColor(INK2).text( | 538 | doc.font("Inter").fontSize(13).fillColor(INK2).text( |
| 117 | "La valeur réelle de chaque propriété — rapport statistique provincial du parc immobilier québécois.", | 539 | "La valeur réelle de chaque propriété — rapport statistique provincial du parc immobilier québécois.", |
| 118 | 64, 256, { width: W - 168, lineGap: 3 }); | 540 | 64, 256, { width: W - 168, lineGap: 3 }); |
| 119 | − // fiche : période / génération / plateforme / mode / données | ||
| 120 | const rows: [string, string][] = [ | 541 | const rows: [string, string][] = [ |
| 542 | + ["Type de rapport", MODE_FR[mode]], | ||
| 121 | ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"], | 543 | ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"], |
| 122 | ["Généré le", `${generated} (heure de l'Est)`], | 544 | ["Généré le", `${generated} (heure de l'Est)`], |
| 123 | ["Plateforme", "www.vrai-prix.com"], | 545 | ["Plateforme", "www.vrai-prix.com"], |
| 124 | − ["Mode", mode === "complet" ? "Rapport complet" : "Synthèse"], | ||
| 125 | ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`], | 546 | ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`], |
| 126 | ]; | 547 | ]; |
| 127 | doc.rect(64, 318, 34, 3).fill(LIME); | 548 | doc.rect(64, 318, 34, 3).fill(LIME); |
@@ -133,7 +554,6 @@ function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) { | |||
| 133 | .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false }); | 554 | .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false }); |
| 134 | y += 24; | 555 | y += 24; |
| 135 | }); | 556 | }); |
| 136 | − // bande encre au pied : « par Groupe KA — groupe-ka.com » | ||
| 137 | doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK); | 557 | doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK); |
| 138 | const by = H - 28 - 64 + 24; | 558 | const by = H - 28 - 64 + 24; |
| 139 | doc.font("SG-Bold").fontSize(15).fillColor(WHITE).text("par Groupe ", 64, by, { lineBreak: false }); | 559 | doc.font("SG-Bold").fontSize(15).fillColor(WHITE).text("par Groupe ", 64, by, { lineBreak: false }); |
@@ -142,92 +562,33 @@ function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) { | |||
| 142 | .text("groupe-ka.com", W - 64 - 180, by + 4, { width: 180, align: "right", characterSpacing: 0.8 }); | 562 | .text("groupe-ka.com", W - 64 - 180, by + 4, { width: 180, align: "right", characterSpacing: 0.8 }); |
| 143 | } | 563 | } |
| 144 | 564 | ||
| 145 | −/* --------------------- VUE D'ENSEMBLE (contenu riche) --------------------- */ | 565 | +/* --------------------- VUE D'ENSEMBLE (le chiffre + comparaisons) --------------------- */ |
| 146 | −function overview(doc: Doc, s: ProvStats, withRecords: boolean, execTitle: string | null) { | 566 | +function overview(c: Cursor, s: ProvStats) { |
| 147 | − let y = 56; | 567 | + const { doc } = c; |
| 148 | − if (execTitle) { | 568 | + c.section("Valeur totale du parc immobilier québécois"); |
| 149 | − kicker(doc, execTitle, M, y); | 569 | + card(doc, M, c.y, CW, 96, INK); |
| 150 | − y += 18; | 570 | + doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, c.y + 18); |
| 151 | − } | ||
| 152 | − kicker(doc, "Valeur totale du parc immobilier québécois", M, y); | ||
| 153 | − y += 18; | ||
| 154 | − card(doc, M, y, CW, 96, INK); | ||
| 155 | − doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, y + 18); | ||
| 156 | doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)") | 571 | doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)") |
| 157 | .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`, | 572 | .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`, |
| 158 | − M + 20, y + 62, { characterSpacing: 0.8 }); | 573 | + M + 20, c.y + 62, { characterSpacing: 0.8 }); |
| 159 | − y += 112; | 574 | + c.y += 112; |
| 160 | − | ||
| 161 | − // comparaisons (KPI) | ||
| 162 | const comps: [string, string][] = [ | 575 | const comps: [string, string][] = [ |
| 163 | ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)], | 576 | ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)], |
| 164 | − ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toFixed(1)}`], | 577 | + ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`], |
| 165 | − ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`], | 578 | + ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`], |
| 166 | ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`], | 579 | ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`], |
| 167 | − ["Évaluation municipale totale", compact(s.valeur_role_totale)], | ||
| 168 | − ["Valeur médiane", money(s.valeur_mediane_2026)], | ||
| 169 | − ["Logements", num(s.logements)], | ||
| 170 | − ["Plancher bâti", `${num(s.aire_etages_km2)} km²`], | ||
| 171 | ]; | 580 | ]; |
| 172 | const tw2 = (CW - 3 * 10) / 4; | 581 | const tw2 = (CW - 3 * 10) / 4; |
| 173 | comps.forEach(([k, v], i) => { | 582 | comps.forEach(([k, v], i) => { |
| 174 | const tx = M + (i % 4) * (tw2 + 10); | 583 | const tx = M + (i % 4) * (tw2 + 10); |
| 175 | − const ty = y + Math.floor(i / 4) * 56; | 584 | + card(doc, tx, c.y, tw2, 48); |
| 176 | − card(doc, tx, ty, tw2, 48); | 585 | + doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, c.y + 10, { width: tw2 - 20, height: 16, ellipsis: true, lineBreak: false }); |
| 177 | − doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, ty + 10, { width: tw2 - 20, height: 16, ellipsis: true, lineBreak: false }); | 586 | + doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, c.y + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 }); |
| 178 | − doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, ty + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 }); | ||
| 179 | }); | 587 | }); |
| 180 | − y += 2 * 56 + 8; | 588 | + c.y += 56 + 6; |
| 181 | doc.font("JB-Reg").fontSize(6).fillColor(INK3) | 589 | doc.font("JB-Reg").fontSize(6).fillColor(INK3) |
| 182 | − .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, y, { characterSpacing: 0.5 }); | 590 | + .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, c.y, { characterSpacing: 0.5 }); |
| 183 | − y += 18; | 591 | + c.y += 18; |
| 184 | − | ||
| 185 | − // par année | ||
| 186 | − kicker(doc, "Valeur provinciale par millésime", M, y); | ||
| 187 | − y += 16; | ||
| 188 | − const maxYear = Math.max(...s.totaux_annee.map((x) => x.total)); | ||
| 189 | − card(doc, M, y, CW, s.totaux_annee.length * 19 + 22); | ||
| 190 | − s.totaux_annee.forEach((h, i) => { | ||
| 191 | − const prev = i > 0 ? s.totaux_annee[i - 1].total : null; | ||
| 192 | − const yoy = prev ? ` (+${(((h.total / prev) - 1) * 100).toFixed(1)} %)` : ""; | ||
| 193 | − hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, String(h.year), h.total / maxYear, | ||
| 194 | − compact(h.total), `${num(h.n)} unités${yoy}`, h.year === 2026); | ||
| 195 | − }); | ||
| 196 | − y += s.totaux_annee.length * 19 + 36; | ||
| 197 | − | ||
| 198 | − if (!execTitle) { | ||
| 199 | − // par type (rapport complet) | ||
| 200 | − kicker(doc, "Répartition par type de propriété", M, y); | ||
| 201 | − y += 16; | ||
| 202 | − const maxType = Math.max(...s.par_type.map((x) => x.total)); | ||
| 203 | − card(doc, M, y, CW, s.par_type.length * 19 + 22); | ||
| 204 | − s.par_type.forEach((tp, i) => { | ||
| 205 | − hbarRow(doc, M + 14, y + 12 + i * 19, CW - 28, TYPE_FR[tp.type] ?? tp.type, | ||
| 206 | − tp.total / maxType, compact(tp.total), | ||
| 207 | − `${num(tp.n)} · méd. ${tp.mediane ? compact(tp.mediane) : "—"}`); | ||
| 208 | − }); | ||
| 209 | − y += s.par_type.length * 19 + 36; | ||
| 210 | − } | ||
| 211 | − | ||
| 212 | − if (withRecords) { | ||
| 213 | − // records & faits marquants (calculés, jamais inventés) | ||
| 214 | − kicker(doc, "Records & faits marquants", M, y); | ||
| 215 | − y += 16; | ||
| 216 | − const recs = buildRecords(s).slice(0, 4); | ||
| 217 | − recs.forEach((r, i) => { | ||
| 218 | − const tx = M + (i % 4) * (tw2 + 10); | ||
| 219 | − const [main, sub] = r.value.split(" — "); | ||
| 220 | − card(doc, tx, y, tw2, 56, SURFACE2); | ||
| 221 | − doc.font("SG-Bold").fontSize(11).fillColor(INK) | ||
| 222 | − .text(main, tx + 10, y + 9, { width: tw2 - 20, height: 14, ellipsis: true, lineBreak: false }); | ||
| 223 | − doc.font("JB-Reg").fontSize(6.2).fillColor(GREEN_DEEP) | ||
| 224 | − .text(sub ? sub : (r.date ?? " "), tx + 10, y + 24, { width: tw2 - 20, height: 8, ellipsis: true, lineBreak: false }); | ||
| 225 | − doc.font("JB-Bold").fontSize(5.3).fillColor(INK3) | ||
| 226 | − .text(r.label.toUpperCase(), tx + 10, y + 36, { width: tw2 - 20, characterSpacing: 0.4, height: 16 }); | ||
| 227 | − }); | ||
| 228 | − y += 56 + 16; | ||
| 229 | − } | ||
| 230 | − return y; | ||
| 231 | } | 592 | } |
| 232 | 593 | ||
| 233 | /* ------------------------------ PAGE DE FIN ------------------------------ */ | 594 | /* ------------------------------ PAGE DE FIN ------------------------------ */ |
@@ -262,20 +623,17 @@ function finalPage(doc: Doc) { | |||
| 262 | } | 623 | } |
| 263 | 624 | ||
| 264 | /* --------------------------------- RAPPORT -------------------------------- */ | 625 | /* --------------------------------- RAPPORT -------------------------------- */ |
| 265 | −export async function buildStatsReport(s: ProvStats, mode: ReportMode = "complet"): Promise<Buffer> { | 626 | +export async function buildStatsReport(mode: ReportMode = "complet"): Promise<Buffer> { |
| 627 | + const s = stats as ProvStats; | ||
| 628 | + const dash = getDashboard(); | ||
| 266 | const generated = new Date().toLocaleString("fr-CA", { | 629 | const generated = new Date().toLocaleString("fr-CA", { |
| 267 | timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit", | 630 | timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit", |
| 268 | hour: "2-digit", minute: "2-digit", hour12: false, | 631 | hour: "2-digit", minute: "2-digit", hour12: false, |
| 269 | }).replace(",", " ·"); | 632 | }).replace(",", " ·"); |
| 270 | − const PER_PAGE = 42; | ||
| 271 | − const munPages = Math.ceil(s.par_ville.length / PER_PAGE); | ||
| 272 | − // complet : couverture + sommaire + vue d'ensemble + municipalités + fin | ||
| 273 | − // synthese : couverture + sommaire exécutif + fin | ||
| 274 | − const TOTAL = mode === "complet" ? 3 + munPages + 1 : 3; | ||
| 275 | 633 | ||
| 276 | const doc = new PDFDocument({ | 634 | const doc = new PDFDocument({ |
| 277 | − size: "A4", margin: 0, | 635 | + size: "A4", margin: 0, bufferPages: true, |
| 278 | − info: { Title: "Groupe KA · Vrai-Prix — Rapport statistique provincial", Author: "Groupe KA — groupe-ka.com" }, | 636 | + info: { Title: `Groupe KA · Vrai-Prix — Rapport statistique (${MODE_FR[mode]})`, Author: "Groupe KA — groupe-ka.com" }, |
| 279 | }); | 637 | }); |
| 280 | doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); | 638 | doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); |
| 281 | doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); | 639 | doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); |
@@ -283,91 +641,122 @@ export async function buildStatsReport(s: ProvStats, mode: ReportMode = "complet | |||
| 283 | doc.registerFont("Inter", F("Inter-Regular.ttf")); | 641 | doc.registerFont("Inter", F("Inter-Regular.ttf")); |
| 284 | 642 | ||
| 285 | const chunks: Buffer[] = []; | 643 | const chunks: Buffer[] = []; |
| 286 | − doc.on("data", (c: Buffer) => chunks.push(c)); | 644 | + doc.on("data", (b: Buffer) => chunks.push(b)); |
| 287 | const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks)))); | 645 | const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks)))); |
| 288 | 646 | ||
| 289 | − /* ------ PAGE 1 : COUVERTURE (gabarit Groupe-KA) ------ */ | 647 | + /* ------ PAGE 1 : COUVERTURE ------ */ |
| 290 | cover(doc, s, generated, mode); | 648 | cover(doc, s, generated, mode); |
| 291 | 649 | ||
| 292 | − if (mode === "synthese") { | 650 | + const withToc = mode === "complet" || mode === "donnees"; |
| 293 | − /* ------ PAGE 2 : SOMMAIRE EXÉCUTIF ------ */ | 651 | + if (withToc) { |
| 294 | − doc.addPage({ size: "A4", margin: 0 }); | 652 | + doc.addPage({ size: "A4", margin: 0 }); // page 2 réservée au sommaire (remplie en post-passe) |
| 295 | − chrome(doc, 2, TOTAL); | 653 | + } |
| 296 | − overview(doc, s, true, "Synthèse · sommaire exécutif"); | 654 | + |
| 297 | − doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text( | 655 | + doc.addPage({ size: "A4", margin: 0 }); |
| 298 | − `INSTANTANÉ DU RÔLE 2026 · STATISTIQUES GÉNÉRÉES LE ${s.generated} · RAPPORT COMPLET : WWW.VRAI-PRIX.COM/STATS`, | 656 | + chrome(doc); |
| 299 | − M, H - 58, { characterSpacing: 0.5 }); | 657 | + const c = new Cursor(doc); |
| 658 | + | ||
| 659 | + if (mode === "complet") { | ||
| 660 | + c.sections.push({ label: "Vue d'ensemble provinciale", page: c.page() }); | ||
| 661 | + overview(c, s); | ||
| 662 | + c.section("Indicateurs clés"); | ||
| 663 | + kpiGridPdf(c, dash.kpis); | ||
| 664 | + c.section("Couverture & complétude du rôle"); | ||
| 665 | + gaugesPdf(c, dash.gauges); | ||
| 666 | + c.section("Évolution — millésimes & corpus de ventes"); | ||
| 667 | + dash.series.forEach((se) => { linePdf(c, se as KaSerie); statLine(c, se as KaSerie); }); | ||
| 668 | + c.section("Marché — indices & prix au m²"); | ||
| 669 | + dash.multiseries.forEach((ms) => multiLinePdf(c, ms)); | ||
| 670 | + dash.stacked.forEach((st) => stackedPdf(c, st)); | ||
| 671 | + c.section("Répartitions"); | ||
| 672 | + dash.breakdowns.forEach((b) => { | ||
| 673 | + if (b.kind === "donut") donutPdf(c, b.title, b.items); | ||
| 674 | + else hbarsPdf(c, b.title, b.items, b.id === "types_n" ? num : compact); | ||
| 675 | + }); | ||
| 676 | + dash.distributions.forEach((d) => linePdf(c, { id: d.id, title: d.title, unit: d.unit, kind: "bar", points: d.bins.map((b) => ({ t: b.label, v: b.value })) })); | ||
| 677 | + hbarsPdf(c, dash.geo.title, dash.geo.items); | ||
| 678 | + heatmapPdf(c, dash.heatmap); | ||
| 679 | + dash.tables.forEach((t) => tablePdf(c, t, 200)); | ||
| 680 | + recordsPdf(c, dash.records); | ||
| 681 | + } else if (mode === "synthese") { | ||
| 682 | + overview(c, s); | ||
| 683 | + c.section("Indicateurs clés"); | ||
| 684 | + kpiGridPdf(c, dash.kpis); | ||
| 685 | + c.section("Couverture & complétude du rôle"); | ||
| 686 | + gaugesPdf(c, dash.gauges); | ||
| 687 | + recordsPdf(c, dash.records); | ||
| 688 | + } else if (mode === "tendances") { | ||
| 689 | + c.section("Indicateurs clés"); | ||
| 690 | + kpiGridPdf(c, dash.kpis); | ||
| 691 | + c.section("Évolution — millésimes & corpus de ventes"); | ||
| 692 | + dash.series.forEach((se) => { linePdf(c, se as KaSerie); statLine(c, se as KaSerie); }); | ||
| 693 | + c.section("Marché — indices & prix au m²"); | ||
| 694 | + dash.multiseries.forEach((ms) => multiLinePdf(c, ms)); | ||
| 695 | + dash.stacked.forEach((st) => stackedPdf(c, st)); | ||
| 696 | + recordsPdf(c, dash.records); | ||
| 697 | + } else if (mode === "repartitions") { | ||
| 698 | + c.section("Répartitions"); | ||
| 699 | + dash.breakdowns.forEach((b) => { | ||
| 700 | + if (b.kind === "donut") donutPdf(c, b.title, b.items); | ||
| 701 | + else hbarsPdf(c, b.title, b.items, b.id === "types_n" ? num : compact); | ||
| 702 | + }); | ||
| 703 | + c.section("Distributions"); | ||
| 704 | + dash.distributions.forEach((d) => linePdf(c, { id: d.id, title: d.title, unit: d.unit, kind: "bar", points: d.bins.map((b) => ({ t: b.label, v: b.value })) })); | ||
| 705 | + c.section("Répartition géographique"); | ||
| 706 | + hbarsPdf(c, dash.geo.title, dash.geo.items); | ||
| 707 | + heatmapPdf(c, dash.heatmap); | ||
| 300 | } else { | 708 | } else { |
| 301 | − /* ------ PAGE 2 : SOMMAIRE ------ */ | 709 | + // donnees : tous les tableaux en version longue (≤ 400 lignes) |
| 302 | − doc.addPage({ size: "A4", margin: 0 }); | 710 | + dash.tables.forEach((t) => tablePdf(c, t, 400)); |
| 303 | − chrome(doc, 2, TOTAL); | 711 | + } |
| 304 | − let y = 60; | 712 | + |
| 305 | − kicker(doc, "Rapport complet", M, y); | 713 | + /* ------ DERNIÈRE PAGE : COORDONNÉES & MENTIONS ------ */ |
| 714 | + doc.addPage({ size: "A4", margin: 0 }); | ||
| 715 | + chrome(doc); | ||
| 716 | + finalPage(doc); | ||
| 717 | + | ||
| 718 | + /* ------ POST-PASSE : sommaire (page 2) + numéros de page ------ */ | ||
| 719 | + const range = doc.bufferedPageRange(); | ||
| 720 | + const total = range.count; | ||
| 721 | + if (withToc) { | ||
| 722 | + doc.switchToPage(1); | ||
| 723 | + chrome(doc); | ||
| 724 | + let y = TOP + 4; | ||
| 725 | + kicker(doc, MODE_FR[mode], M, y); | ||
| 306 | y += 20; | 726 | y += 20; |
| 307 | doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y); | 727 | doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y); |
| 308 | − y += 44; | 728 | + y += 40; |
| 309 | const entries: [string, string][] = [ | 729 | const entries: [string, string][] = [ |
| 310 | − ["Vue d'ensemble provinciale — le chiffre, millésimes, types, records", "3"], | 730 | + ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]), |
| 311 | − [`Palmarès des ${s.par_ville.length} plus grandes municipalités`, munPages > 1 ? `4–${3 + munPages}` : "4"], | 731 | + ["Coordonnées du Groupe KA & mentions", String(total)], |
| 312 | − ["Coordonnées du Groupe KA & mentions", String(4 + munPages)], | ||
| 313 | ]; | 732 | ]; |
| 314 | − entries.forEach(([label, pg]) => { | 733 | + entries.slice(0, 16).forEach(([label, pg]) => { |
| 315 | − doc.rect(M, y + 26, CW, 0.7).fill("#e3e1d9"); | 734 | + doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9"); |
| 316 | − doc.font("SG-Bold").fontSize(12).fillColor(INK).text(label, M, y, { width: CW - 70, height: 30 }); | 735 | + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(label, M, y, { width: CW - 70, height: 26, ellipsis: true }); |
| 317 | − doc.font("JB-Bold").fontSize(11).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" }); | 736 | + doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" }); |
| 318 | − y += 40; | 737 | + y += 34; |
| 319 | }); | 738 | }); |
| 320 | − y += 10; | 739 | + y += 8; |
| 321 | doc.font("JB-Reg").fontSize(7).fillColor(INK3).text( | 740 | doc.font("JB-Reg").fontSize(7).fillColor(INK3).text( |
| 322 | − `PÉRIODE : ${PERIOD_LABEL.toUpperCase()} · STATISTIQUES GÉNÉRÉES LE ${s.generated} · SOURCE : RÔLE D'ÉVALUATION FONCIÈRE + MODÈLE HÉDONIQUE VRAI-PRIX`, | 741 | + `PÉRIODE : ${PERIOD_LABEL.toUpperCase()} · STATISTIQUES GÉNÉRÉES LE ${s.generated} · SOURCE : RÔLE D'ÉVALUATION FONCIÈRE + CORPUS DE VENTES + MODÈLE HÉDONIQUE VRAI-PRIX`, |
| 323 | M, y, { characterSpacing: 0.4, width: CW }); | 742 | M, y, { characterSpacing: 0.4, width: CW }); |
| 324 | − | ||
| 325 | − /* ------ PAGE 3 : VUE D'ENSEMBLE (contenu riche) ------ */ | ||
| 326 | − doc.addPage({ size: "A4", margin: 0 }); | ||
| 327 | − chrome(doc, 3, TOTAL); | ||
| 328 | − overview(doc, s, true, null); | ||
| 329 | − | ||
| 330 | − /* ------ PAGES 4+ : MUNICIPALITÉS ------ */ | ||
| 331 | − for (let p = 0; p < munPages; p++) { | ||
| 332 | − doc.addPage({ size: "A4", margin: 0 }); | ||
| 333 | − chrome(doc, 4 + p, TOTAL); | ||
| 334 | − let yy = 54; | ||
| 335 | − 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); | ||
| 336 | − yy += 18; | ||
| 337 | − // en-tête | ||
| 338 | − doc.rect(M, yy, CW, 16).fill(INK); | ||
| 339 | − doc.font("JB-Bold").fontSize(6).fillColor("#ffffff"); | ||
| 340 | − doc.text("#", M + 8, yy + 5, { lineBreak: false }); | ||
| 341 | − doc.text("MUNICIPALITÉ", M + 34, yy + 5, { lineBreak: false }); | ||
| 342 | − doc.text("PROPRIÉTÉS", M + 252, yy + 5, { width: 70, align: "right", lineBreak: false }); | ||
| 343 | − doc.text("VALEUR TOTALE", M + 332, yy + 5, { width: 90, align: "right", lineBreak: false }); | ||
| 344 | − doc.text("VALEUR MÉDIANE", M + 422, yy + 5, { width: CW - 430, align: "right", lineBreak: false }); | ||
| 345 | − yy += 16; | ||
| 346 | − s.par_ville.slice(p * PER_PAGE, (p + 1) * PER_PAGE).forEach((v, i) => { | ||
| 347 | − const rank = p * PER_PAGE + i + 1; | ||
| 348 | − if (i % 2 === 0) doc.rect(M, yy, CW, 15).fill(SURFACE2); | ||
| 349 | − doc.font("JB-Bold").fontSize(6.8).fillColor(rank <= 3 ? GREEN_DEEP : INK3).text(String(rank), M + 8, yy + 4.5, { lineBreak: false }); | ||
| 350 | − doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(v.ville, M + 34, yy + 3.5, { width: 210, height: 10, ellipsis: true, lineBreak: false }); | ||
| 351 | − doc.font("JB-Reg").fontSize(7).fillColor(INK).text(num(v.n), M + 252, yy + 4.5, { width: 70, align: "right", lineBreak: false }); | ||
| 352 | − doc.font("SG-Bold").fontSize(7.8).fillColor(INK).text(compact(v.total), M + 332, yy + 3.5, { width: 90, align: "right", lineBreak: false }); | ||
| 353 | − doc.font("JB-Reg").fontSize(7).fillColor(INK).text(v.mediane ? money(v.mediane) : "—", M + 422, yy + 4.5, { width: CW - 430, align: "right", lineBreak: false }); | ||
| 354 | − yy += 15; | ||
| 355 | − }); | ||
| 356 | − doc.rect(M, yy, CW, 0.9).fill(INK); | ||
| 357 | − } | ||
| 358 | } | 743 | } |
| 359 | − | 744 | + for (let i = 1; i < total; i++) { |
| 360 | − /* ------ DERNIÈRE PAGE : COORDONNÉES & MENTIONS (gabarit Groupe-KA) ------ */ | 745 | + doc.switchToPage(i); |
| 361 | − doc.addPage({ size: "A4", margin: 0 }); | 746 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK) |
| 362 | − chrome(doc, TOTAL, TOTAL); | 747 | + .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false }); |
| 363 | − finalPage(doc); | 748 | + } |
| 364 | 749 | ||
| 365 | doc.end(); | 750 | doc.end(); |
| 366 | return done; | 751 | return done; |
| 367 | } | 752 | } |
| 368 | 753 | ||
| 369 | −/** Nom de fichier normalisé Groupe-KA (SPEC §2). */ | 754 | +/** Nom de fichier normalisé Groupe-KA (SPEC §3) — pas de suffixe pour `complet`. */ |
| 370 | −export function reportFilename(): string { | 755 | +export function reportFilename(mode: ReportMode = "complet"): string { |
| 371 | const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" }); | 756 | const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" }); |
| 372 | − return `groupe-ka_vrai-prix_stats_instantane_${today}.pdf`; | 757 | + const suffix = mode === "complet" ? "" : `_${mode}`; |
| 758 | + return `groupe-ka_vrai-prix_stats_instantane${suffix}_${today}.pdf`; | ||
| 373 | } | 759 | } |
| 760 | + | ||
| 761 | +/** Compat : buildRecords ré-exporté pour les consommateurs historiques. */ | ||
| 762 | +export { buildRecords }; | ||
modified
src/lib/stats-dashboard.ts
+317 −60
@@ -1,13 +1,20 @@ | |||
| 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | /** | 2 | /** |
| 3 | − * Contrat commun /api/stats/dashboard du Groupe KA (voir src/ka/stats/SPEC.md), | 3 | + * Contrat commun /api/stats/dashboard v2 du Groupe KA (voir src/ka/stats/SPEC.md), |
| 4 | − * construit depuis src/data/stats.json (agrégats SQLite précalculés du rôle). | 4 | + * construit depuis src/data/stats.json (agrégats historiques du rôle) et |
| 5 | − * IMPORTANT : les données de Vrai-Prix sont un INSTANTANÉ du rôle d'évaluation | 5 | + * src/data/stats-v2.json (agrégats enrichis : distributions, transactions |
| 6 | − * foncière (millésime 2026) — aucune série temporelle quotidienne n'existe ; | 6 | + * mensuelles/quotidiennes, indice de marché — générés par |
| 7 | − * le paramètre `period` est donc accepté mais non applicable. La seule vraie | 7 | + * scripts/build-stats-v2.mjs depuis data/vraiprix.db, données 100 % réelles). |
| 8 | − * série est la valeur provinciale par millésime (2021-2026, 6 points). | 8 | + * |
| 9 | + * Particularité Vrai-Prix : le rôle d'évaluation est un INSTANTANÉ (millésime | ||
| 10 | + * 2026) — `period` est accepté (contrat commun) mais non applicable ; les | ||
| 11 | + * vraies séries temporelles sont les millésimes 2021-2026, le corpus de | ||
| 12 | + * ventes réelles (mensuel, 2021-01 → 2026-07) et l'indice de marché mensuel. | ||
| 13 | + * Les delta_pct sont calculés (millésime vs millésime, 12 mois vs 12 mois | ||
| 14 | + * précédents) et arrondis à 1 décimale — jamais inventés. | ||
| 9 | */ | 15 | */ |
| 10 | import stats from "@/data/stats.json"; | 16 | import stats from "@/data/stats.json"; |
| 17 | +import v2 from "@/data/stats-v2.json"; | ||
| 11 | import type { ProvStats } from "@/components/StatsView"; | 18 | import type { ProvStats } from "@/components/StatsView"; |
| 12 | 19 | ||
| 13 | /** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */ | 20 | /** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */ |
@@ -34,12 +41,53 @@ const compact = (v: number) => { | |||
| 34 | return money(v); | 41 | return money(v); |
| 35 | }; | 42 | }; |
| 36 | const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`; | 43 | const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`; |
| 44 | +const r1 = (v: number) => Math.round(v * 10) / 10; | ||
| 45 | +const delta = (cur: number, prev: number): number | null => | ||
| 46 | + prev > 0 ? r1((cur / prev - 1) * 100) : null; | ||
| 47 | +const MOIS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juill.", "août", "sept.", "oct.", "nov.", "déc."]; | ||
| 48 | +const moisLabel = (m: string) => `${MOIS_FR[parseInt(m.slice(5, 7), 10) - 1]} ${m.slice(0, 4)}`; | ||
| 37 | 49 | ||
| 38 | −export interface KaRecord { | 50 | +/* ---------- types du contrat (SPEC §2) ---------- */ |
| 39 | − label: string; | 51 | +export type Point = { t: string; v: number }; |
| 40 | − value: string; | 52 | +export interface KaKpi { |
| 41 | − date?: string; | 53 | + id: string; label: string; value: number | string; unit?: string; |
| 54 | + delta_pct?: number | null; direction?: "up" | "down"; spark?: Point[]; help?: string; | ||
| 42 | } | 55 | } |
| 56 | +export interface KaSerie { | ||
| 57 | + id: string; title: string; unit?: string; kind: "line" | "bar" | "area"; | ||
| 58 | + points: Point[]; compare?: Point[]; | ||
| 59 | +} | ||
| 60 | +export interface KaRecord { label: string; value: string; date?: string } | ||
| 61 | + | ||
| 62 | +interface V2Data { | ||
| 63 | + generated: string; | ||
| 64 | + units: { | ||
| 65 | + total: number; geoloc: number; with_role: number; with_est2026: number; | ||
| 66 | + with_year: number; with_area: number; avg_est2026: number; avg_role: number; | ||
| 67 | + sum_role: number; logements: number; | ||
| 68 | + }; | ||
| 69 | + valeur_bins: { label: string; value: number }[]; | ||
| 70 | + construction: { label: string; n: number; total: number; moyenne: number | null }[]; | ||
| 71 | + rec: { | ||
| 72 | + max_role: { municipalite: string; v: number; cubf_libelle: string | null }; | ||
| 73 | + max_est: { municipalite: string; v: number }; | ||
| 74 | + }; | ||
| 75 | + tx: { | ||
| 76 | + monthly: { m: string; n: number; total: number; median: number }[]; | ||
| 77 | + monthly_by_type: { keys: string[]; points: { t: string; values: number[] }[] }; | ||
| 78 | + amount_bins: { label: string; value: number }[]; | ||
| 79 | + daily: { date: string; n: number }[]; | ||
| 80 | + max_date: string; | ||
| 81 | + max: { amount: number; city: string | null; date: string }; | ||
| 82 | + best_month: { m: string; n: number }; | ||
| 83 | + top_villes: { city: string; n: number; moyenne: number }[]; | ||
| 84 | + }; | ||
| 85 | + market: { | ||
| 86 | + idx_by_type: { label: string; points: Point[] }[]; | ||
| 87 | + ppm2_by_type: { label: string; points: Point[] }[]; | ||
| 88 | + }; | ||
| 89 | +} | ||
| 90 | +const V = v2 as unknown as V2Data; | ||
| 43 | 91 | ||
| 44 | /** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */ | 92 | /** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */ |
| 45 | export function buildRecords(s: ProvStats): KaRecord[] { | 93 | export function buildRecords(s: ProvStats): KaRecord[] { |
@@ -82,74 +130,283 @@ export function buildRecords(s: ProvStats): KaRecord[] { | |||
| 82 | label: "Croissance 2021→2026 (périmètre constant)", | 130 | label: "Croissance 2021→2026 (périmètre constant)", |
| 83 | value: pct(s.croissance_2021_2026_pct), | 131 | value: pct(s.croissance_2021_2026_pct), |
| 84 | }); | 132 | }); |
| 133 | + // records v2 — rôle & corpus de ventes (stats-v2.json, données réelles) | ||
| 134 | + if (V.rec?.max_role) { | ||
| 135 | + out.push({ | ||
| 136 | + label: "Plus grosse évaluation au rôle", | ||
| 137 | + value: `${V.rec.max_role.cubf_libelle ?? "Immeuble"} (${V.rec.max_role.municipalite}) — ${compact(V.rec.max_role.v)}`, | ||
| 138 | + }); | ||
| 139 | + } | ||
| 140 | + if (V.rec?.max_est) { | ||
| 141 | + out.push({ | ||
| 142 | + label: "Estimation Vrai-Prix la plus élevée", | ||
| 143 | + value: `${V.rec.max_est.municipalite} — ${compact(V.rec.max_est.v)}`, | ||
| 144 | + }); | ||
| 145 | + } | ||
| 146 | + if (V.tx?.max) { | ||
| 147 | + out.push({ | ||
| 148 | + label: "Plus grosse vente du corpus", | ||
| 149 | + value: `${compact(V.tx.max.amount)}${V.tx.max.city ? ` — ${V.tx.max.city}` : ""}`, | ||
| 150 | + date: V.tx.max.date, | ||
| 151 | + }); | ||
| 152 | + } | ||
| 153 | + if (V.tx?.best_month) { | ||
| 154 | + out.push({ | ||
| 155 | + label: "Mois record de ventes", | ||
| 156 | + value: `${V.tx.best_month.n.toLocaleString("fr-CA")} ventes`, | ||
| 157 | + date: moisLabel(V.tx.best_month.m), | ||
| 158 | + }); | ||
| 159 | + } | ||
| 160 | + const topTx = V.tx?.top_villes?.[0]; | ||
| 161 | + if (topTx) { | ||
| 162 | + out.push({ | ||
| 163 | + label: "Ville la plus active en ventes (2021-2026)", | ||
| 164 | + value: `${topTx.city} — ${topTx.n.toLocaleString("fr-CA")} ventes`, | ||
| 165 | + }); | ||
| 166 | + } | ||
| 85 | return out; | 167 | return out; |
| 86 | } | 168 | } |
| 87 | 169 | ||
| 88 | function buildDashboard() { | 170 | function buildDashboard() { |
| 89 | const s = stats as ProvStats; | 171 | const s = stats as ProvStats; |
| 172 | + const u = V.units; | ||
| 173 | + const gpct = (a: number, b: number) => r1((100 * a) / (b || 1)); | ||
| 174 | + | ||
| 175 | + /* --- millésimes (2021-2026) : deltas millésime vs millésime précédent --- */ | ||
| 176 | + const ta = s.totaux_annee; | ||
| 177 | + const last = ta[ta.length - 1]; | ||
| 178 | + const prev = ta[ta.length - 2]; | ||
| 179 | + const avgPrev = prev ? prev.total / prev.n : 0; | ||
| 180 | + | ||
| 181 | + /* --- corpus de ventes : 12 derniers mois vs 12 mois précédents (réel) --- */ | ||
| 182 | + const m = V.tx.monthly; | ||
| 183 | + const last12 = m.slice(-12); | ||
| 184 | + const prev12 = m.slice(-24, -12); | ||
| 185 | + const sum = (a: { n: number; total: number }[], k: "n" | "total") => a.reduce((x, y) => x + y[k], 0); | ||
| 186 | + const lastMonth = m[m.length - 1]; | ||
| 187 | + const sameMonthN1 = m[m.length - 13]; | ||
| 188 | + | ||
| 189 | + const kpis: KaKpi[] = [ | ||
| 190 | + { | ||
| 191 | + id: "unites", label: "Propriétés couvertes (rôle 2026)", value: s.unites, | ||
| 192 | + delta_pct: prev ? delta(last.n, prev.n) : null, direction: "up", | ||
| 193 | + spark: ta.map((a) => ({ t: String(a.year), v: a.n })), | ||
| 194 | + help: "Unités d'évaluation estimées au millésime 2026 — variation vs millésime 2025.", | ||
| 195 | + }, | ||
| 196 | + { | ||
| 197 | + id: "valeur_totale", label: "Valeur totale du parc (2026)", value: compact(s.valeur_totale_2026), | ||
| 198 | + delta_pct: prev ? delta(last.total, prev.total) : null, direction: "up", | ||
| 199 | + spark: ta.map((a) => ({ t: String(a.year), v: a.total })), | ||
| 200 | + help: "Somme des estimations Vrai-Prix — variation vs millésime 2025.", | ||
| 201 | + }, | ||
| 202 | + { | ||
| 203 | + id: "valeur_moyenne", label: "Valeur moyenne (2026)", value: money(u.avg_est2026), | ||
| 204 | + delta_pct: avgPrev ? delta(u.avg_est2026, avgPrev) : null, direction: "up", | ||
| 205 | + spark: ta.map((a) => ({ t: String(a.year), v: Math.round(a.total / a.n) })), | ||
| 206 | + }, | ||
| 207 | + { id: "valeur_mediane", label: "Valeur médiane (2026)", value: money(s.valeur_mediane_2026) }, | ||
| 208 | + { id: "municipalites", label: "Municipalités couvertes", value: s.municipalites }, | ||
| 209 | + { id: "logements", label: "Logements au rôle", value: u.logements }, | ||
| 210 | + { | ||
| 211 | + id: "ventes_12m", label: "Ventes réelles — 12 derniers mois", value: sum(last12, "n"), | ||
| 212 | + delta_pct: delta(sum(last12, "n"), sum(prev12, "n")), | ||
| 213 | + direction: sum(last12, "n") >= sum(prev12, "n") ? "up" : "down", | ||
| 214 | + spark: m.slice(-24).map((x) => ({ t: x.m, v: x.n })), | ||
| 215 | + help: "Corpus de ventes publiées — 12 derniers mois vs les 12 mois précédents.", | ||
| 216 | + }, | ||
| 217 | + { | ||
| 218 | + id: "volume_12m", label: "Volume des ventes — 12 derniers mois", value: compact(sum(last12, "total")), | ||
| 219 | + delta_pct: delta(sum(last12, "total"), sum(prev12, "total")), | ||
| 220 | + direction: sum(last12, "total") >= sum(prev12, "total") ? "up" : "down", | ||
| 221 | + spark: m.slice(-24).map((x) => ({ t: x.m, v: x.total })), | ||
| 222 | + }, | ||
| 223 | + { | ||
| 224 | + id: "prix_vente_median", label: `Prix de vente médian (${moisLabel(lastMonth.m)})`, value: money(lastMonth.median), | ||
| 225 | + delta_pct: sameMonthN1 ? delta(lastMonth.median, sameMonthN1.median) : null, | ||
| 226 | + direction: sameMonthN1 && lastMonth.median >= sameMonthN1.median ? "up" : "down", | ||
| 227 | + spark: m.slice(-24).map((x) => ({ t: x.m, v: x.median })), | ||
| 228 | + help: "Médiane des ventes du dernier mois complet du corpus — variation vs le même mois un an plus tôt.", | ||
| 229 | + }, | ||
| 230 | + { | ||
| 231 | + id: "transactions", label: "Ventes réelles du corpus (2021-01 → 2026-07)", value: TRANSACTIONS, | ||
| 232 | + help: "Toutes les ventes publiées intégrées au modèle de comparables.", | ||
| 233 | + }, | ||
| 234 | + ]; | ||
| 235 | + | ||
| 236 | + const gauges = [ | ||
| 237 | + { | ||
| 238 | + id: "geoloc", label: "Unités géolocalisées", value: gpct(u.geoloc, u.total), max: 100, unit: "%", | ||
| 239 | + help: "Unités du rôle avec coordonnées lat/lng valides.", | ||
| 240 | + }, | ||
| 241 | + { | ||
| 242 | + id: "role_connu", label: "Valeur au rôle connue", value: gpct(u.with_role, u.total), max: 100, unit: "%", | ||
| 243 | + help: "Unités avec évaluation municipale renseignée.", | ||
| 244 | + }, | ||
| 245 | + { | ||
| 246 | + id: "annee_connue", label: "Année de construction connue", value: gpct(u.with_year, u.total), max: 100, unit: "%", | ||
| 247 | + }, | ||
| 248 | + { | ||
| 249 | + id: "aire_connue", label: "Superficie de plancher connue", value: gpct(u.with_area, u.total), max: 100, unit: "%", | ||
| 250 | + }, | ||
| 251 | + ]; | ||
| 252 | + | ||
| 253 | + const series: KaSerie[] = [ | ||
| 254 | + { | ||
| 255 | + id: "valeur_millesime", title: "Valeur provinciale par millésime (2021-2026)", unit: "$", | ||
| 256 | + kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.total })), | ||
| 257 | + }, | ||
| 258 | + { | ||
| 259 | + id: "unites_millesime", title: "Unités estimées par millésime", unit: "unités", | ||
| 260 | + kind: "line", points: ta.map((a) => ({ t: String(a.year), v: a.n })), | ||
| 261 | + }, | ||
| 262 | + { | ||
| 263 | + id: "ventes_mois", title: "Ventes réelles par mois (corpus 2021-2026)", unit: "ventes", | ||
| 264 | + kind: "bar", points: m.map((x) => ({ t: x.m, v: x.n })), | ||
| 265 | + }, | ||
| 266 | + { | ||
| 267 | + id: "volume_mois", title: "Volume mensuel des ventes ($)", unit: "$", | ||
| 268 | + kind: "area", points: m.map((x) => ({ t: x.m, v: x.total })), | ||
| 269 | + }, | ||
| 270 | + { | ||
| 271 | + id: "prix_median_mois", title: "Prix de vente médian par mois", unit: "$", | ||
| 272 | + kind: "line", points: m.map((x) => ({ t: x.m, v: x.median })), | ||
| 273 | + }, | ||
| 274 | + ]; | ||
| 275 | + | ||
| 276 | + const multiseries = [ | ||
| 277 | + { | ||
| 278 | + id: "indice_marche", title: "Indice de marché par type (base 1,0)", unit: "", | ||
| 279 | + series: V.market.idx_by_type, | ||
| 280 | + }, | ||
| 281 | + { | ||
| 282 | + id: "ppm2", title: "Prix au m² des ventes, par type", unit: "$/m²", | ||
| 283 | + series: V.market.ppm2_by_type, | ||
| 284 | + }, | ||
| 285 | + ]; | ||
| 286 | + | ||
| 287 | + const stacked = [ | ||
| 288 | + { | ||
| 289 | + id: "ventes_type", title: "Ventes mensuelles par type de propriété", unit: "ventes", | ||
| 290 | + keys: V.tx.monthly_by_type.keys, points: V.tx.monthly_by_type.points, | ||
| 291 | + }, | ||
| 292 | + ]; | ||
| 293 | + | ||
| 294 | + const breakdowns = [ | ||
| 295 | + { | ||
| 296 | + id: "types", title: "Répartition de la valeur par type de bien", kind: "donut" as const, | ||
| 297 | + items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })), | ||
| 298 | + }, | ||
| 299 | + { | ||
| 300 | + id: "types_n", title: "Unités par type de bien", kind: "bars" as const, | ||
| 301 | + items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.n })), | ||
| 302 | + }, | ||
| 303 | + { | ||
| 304 | + id: "construction", title: "Valeur par tranche d'année de construction", kind: "bars" as const, | ||
| 305 | + items: V.construction.map((c) => ({ label: c.label, value: c.total })), | ||
| 306 | + }, | ||
| 307 | + ]; | ||
| 308 | + | ||
| 309 | + const distributions = [ | ||
| 310 | + { | ||
| 311 | + id: "valeurs", title: "Distribution des valeurs estimées (2026)", unit: "unités", | ||
| 312 | + bins: V.valeur_bins, | ||
| 313 | + }, | ||
| 314 | + { | ||
| 315 | + id: "montants_vente", title: "Distribution des montants de vente (2021-2026)", unit: "ventes", | ||
| 316 | + bins: V.tx.amount_bins, | ||
| 317 | + }, | ||
| 318 | + ]; | ||
| 319 | + | ||
| 320 | + const geo = { | ||
| 321 | + title: "Top municipalités par valeur totale estimée", | ||
| 322 | + items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })), | ||
| 323 | + }; | ||
| 324 | + | ||
| 325 | + const heatmap = { | ||
| 326 | + title: `Ventes réelles par jour (corpus, jusqu'au ${V.tx.max_date})`, | ||
| 327 | + cells: V.tx.daily.map((d) => ({ date: d.date, value: d.n })), | ||
| 328 | + }; | ||
| 329 | + | ||
| 330 | + const topMediane = [...s.par_ville] | ||
| 331 | + .filter((v) => v.mediane != null) | ||
| 332 | + .sort((a, b) => (b.mediane ?? 0) - (a.mediane ?? 0)) | ||
| 333 | + .slice(0, 20); | ||
| 334 | + | ||
| 335 | + const idxEnsemble = V.market.idx_by_type.find((x) => x.label === "Ensemble"); | ||
| 336 | + const ppm2 = (label: string, t: string) => | ||
| 337 | + V.market.ppm2_by_type.find((x) => x.label === label)?.points.find((p) => p.t === t)?.v ?? null; | ||
| 338 | + const last12Months = (idxEnsemble?.points ?? []).slice(-12); | ||
| 339 | + | ||
| 340 | + const tables = [ | ||
| 341 | + { | ||
| 342 | + id: "top_municipalites", | ||
| 343 | + title: "Palmarès des 200 plus grandes municipalités (millésime 2026)", | ||
| 344 | + columns: ["Rang", "Municipalité", "Propriétés", "Valeur totale ($)", "Valeur médiane ($)"], | ||
| 345 | + rows: s.par_ville.map((v, i) => [i + 1, v.ville, v.n, v.total, v.mediane] as (string | number | null)[]), | ||
| 346 | + }, | ||
| 347 | + { | ||
| 348 | + id: "top_medianes", | ||
| 349 | + title: "Top 20 municipalités par valeur médiane (parmi le top 200)", | ||
| 350 | + columns: ["Rang", "Municipalité", "Valeur médiane ($)", "Propriétés", "Valeur totale ($)"], | ||
| 351 | + rows: topMediane.map((v, i) => [i + 1, v.ville, v.mediane, v.n, v.total] as (string | number | null)[]), | ||
| 352 | + }, | ||
| 353 | + { | ||
| 354 | + id: "par_type", | ||
| 355 | + title: "Répartition par type de propriété", | ||
| 356 | + columns: ["Type", "Unités", "Valeur totale ($)", "Valeur médiane ($)"], | ||
| 357 | + rows: s.par_type.map((t) => [TYPE_FR[t.type] ?? t.type, t.n, t.total, t.mediane] as (string | number | null)[]), | ||
| 358 | + }, | ||
| 359 | + { | ||
| 360 | + id: "construction", | ||
| 361 | + title: "Parc par tranche d'année de construction", | ||
| 362 | + columns: ["Tranche", "Unités", "Valeur totale ($)", "Valeur moyenne ($)"], | ||
| 363 | + rows: V.construction.map((c) => [c.label, c.n, c.total, c.moyenne] as (string | number | null)[]), | ||
| 364 | + }, | ||
| 365 | + { | ||
| 366 | + id: "indice_marche", | ||
| 367 | + title: "Indice de marché — 12 derniers mois", | ||
| 368 | + columns: ["Mois", "Indice (ensemble)", "$/m² unifamiliale", "$/m² condo", "$/m² plex"], | ||
| 369 | + rows: last12Months.map((p) => [ | ||
| 370 | + p.t, p.v, ppm2("Unifamiliale", p.t), ppm2("Condo", p.t), ppm2("Plex", p.t), | ||
| 371 | + ] as (string | number | null)[]), | ||
| 372 | + }, | ||
| 373 | + { | ||
| 374 | + id: "top_villes_ventes", | ||
| 375 | + title: "Top 20 villes par nombre de ventes (corpus 2021-2026)", | ||
| 376 | + columns: ["Rang", "Ville", "Ventes", "Montant moyen ($)"], | ||
| 377 | + rows: V.tx.top_villes.map((v, i) => [i + 1, v.city, v.n, v.moyenne] as (string | number | null)[]), | ||
| 378 | + }, | ||
| 379 | + ]; | ||
| 380 | + | ||
| 90 | return { | 381 | return { |
| 91 | − updated: s.generated, | 382 | + updated: V.generated, |
| 92 | period: { | 383 | period: { |
| 93 | from: null as string | null, | 384 | from: null as string | null, |
| 94 | to: null as string | null, | 385 | to: null as string | null, |
| 95 | label: PERIOD_LABEL, | 386 | label: PERIOD_LABEL, |
| 96 | applicable: false, | 387 | applicable: false, |
| 97 | note: | 388 | note: |
| 98 | − "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026), " + | 389 | + "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026) : " + |
| 99 | − "sans séries temporelles quotidiennes : le paramètre `period` est accepté mais sans effet — " + | 390 | + "le paramètre `period` est accepté mais sans effet. Les séries temporelles réelles servies sont " + |
| 100 | − "la réponse est toujours l'instantané.", | 391 | + "les millésimes 2021-2026, le corpus de ventes mensuel (2021-01 → 2026-07) et l'indice de marché.", |
| 101 | − }, | 392 | + }, |
| 102 | − kpis: [ | 393 | + kpis, |
| 103 | − { id: "unites", label: "Propriétés estimées", value: s.unites, unit: "" }, | 394 | + gauges, |
| 104 | − { id: "valeur_totale", label: "Valeur totale du parc (2026)", value: s.valeur_totale_2026, unit: "$" }, | 395 | + series, |
| 105 | − { id: "valeur_mediane", label: "Valeur médiane (2026)", value: s.valeur_mediane_2026, unit: "$" }, | 396 | + multiseries, |
| 106 | − { | 397 | + stacked, |
| 107 | − id: "croissance", | 398 | + breakdowns, |
| 108 | − label: "Croissance 2021→2026 (périmètre constant)", | 399 | + distributions, |
| 109 | − value: s.croissance_2021_2026_pct, | 400 | + geo, |
| 110 | − unit: "%", | 401 | + heatmap, |
| 111 | − delta_pct: s.croissance_2021_2026_pct, | 402 | + tables, |
| 112 | − direction: "up" as const, | ||
| 113 | − }, | ||
| 114 | − { id: "municipalites", label: "Municipalités couvertes", value: s.municipalites, unit: "" }, | ||
| 115 | − { id: "transactions", label: "Transactions réelles du corpus (2021-01 → 2026-07)", value: TRANSACTIONS, unit: "" }, | ||
| 116 | − ], | ||
| 117 | − series: [ | ||
| 118 | − { | ||
| 119 | − id: "valeur_millesime", | ||
| 120 | − title: "Valeur provinciale par millésime (2021-2026)", | ||
| 121 | − unit: "$", | ||
| 122 | − kind: "line" as const, | ||
| 123 | − points: s.totaux_annee.map((a) => ({ t: String(a.year), v: a.total })), | ||
| 124 | − }, | ||
| 125 | − ], | ||
| 126 | − breakdowns: [ | ||
| 127 | − { | ||
| 128 | − id: "types", | ||
| 129 | − title: "Répartition de la valeur par type de bien", | ||
| 130 | − kind: "donut" as const, | ||
| 131 | − items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })), | ||
| 132 | − }, | ||
| 133 | − ], | ||
| 134 | − geo: { | ||
| 135 | − title: "Top municipalités par valeur totale estimée", | ||
| 136 | − items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })), | ||
| 137 | − }, | ||
| 138 | − tables: [ | ||
| 139 | − { | ||
| 140 | − id: "top_municipalites", | ||
| 141 | − title: "Top 20 municipalités (valeur totale estimée, millésime 2026)", | ||
| 142 | − columns: ["Rang", "Municipalité", "Propriétés", "Valeur totale ($)", "Valeur médiane ($)"], | ||
| 143 | − rows: s.par_ville.slice(0, 20).map((v, i) => [i + 1, v.ville, v.n, v.total, v.mediane] as (string | number | null)[]), | ||
| 144 | − }, | ||
| 145 | − ], | ||
| 146 | records: buildRecords(s), | 403 | records: buildRecords(s), |
| 147 | }; | 404 | }; |
| 148 | } | 405 | } |
| 149 | 406 | ||
| 150 | export type StatsDashboard = ReturnType<typeof buildDashboard>; | 407 | export type StatsDashboard = ReturnType<typeof buildDashboard>; |
| 151 | 408 | ||
| 152 | −/** Cache mémoire : les données sont un import statique, on ne construit qu'une fois. */ | 409 | +/** Cache mémoire : les données sont des imports statiques, on ne construit qu'une fois. */ |
| 153 | let cache: StatsDashboard | null = null; | 410 | let cache: StatsDashboard | null = null; |
| 154 | export function getDashboard(): StatsDashboard { | 411 | export function getDashboard(): StatsDashboard { |
| 155 | if (!cache) cache = buildDashboard(); | 412 | if (!cache) cache = buildDashboard(); |
| 156 | 413 | ||