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>
7 changed files +1,105 −457
modified
src/app/api/report/stats/route.ts
+10 −9
@@ -1,21 +1,22 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | /** |
| 3 | − * Alias historique de /api/stats/report (gabarit commun Groupe KA). | |
| 4 | − * Sert le même rapport PDF, mêmes paramètres (`mode=complet|synthese`). | |
| 3 | + * Alias historique de /api/stats/report (gabarit commun Groupe KA v2). | |
| 4 | + * Sert le même rapport PDF, mêmes paramètres | |
| 5 | + * (`mode=complet|synthese|tendances|repartitions|donnees`). | |
| 5 | 6 | */ |
| 6 | 7 | import { type NextRequest, NextResponse } from "next/server"; |
| 7 | −import { buildStatsReport, reportFilename, type ReportMode } from "@/lib/report-stats"; | |
| 8 | −import stats from "@/data/stats.json"; | |
| 9 | −import type { ProvStats } from "@/components/StatsView"; | |
| 8 | +import { buildStatsReport, reportFilename, REPORT_MODES, type ReportMode } from "@/lib/report-stats"; | |
| 10 | 9 | |
| 11 | 10 | export async function GET(req: NextRequest) { |
| 12 | − const mode: ReportMode = | |
| 13 | − req.nextUrl.searchParams.get("mode") === "synthese" ? "synthese" : "complet"; | |
| 14 | − const pdf = await buildStatsReport(stats as ProvStats, mode); | |
| 11 | + const raw = req.nextUrl.searchParams.get("mode") ?? "complet"; | |
| 12 | + const mode: ReportMode = (REPORT_MODES as readonly string[]).includes(raw) | |
| 13 | + ? (raw as ReportMode) | |
| 14 | + : "complet"; | |
| 15 | + const pdf = await buildStatsReport(mode); | |
| 15 | 16 | return new NextResponse(new Uint8Array(pdf), { |
| 16 | 17 | headers: { |
| 17 | 18 | "Content-Type": "application/pdf", |
| 18 | − "Content-Disposition": `attachment; filename="${reportFilename()}"`, | |
| 19 | + "Content-Disposition": `attachment; filename="${reportFilename(mode)}"`, | |
| 19 | 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 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | /** |
| 3 | − * GET /api/stats/report?mode=complet|synthese — rapport statistique PDF au | |
| 4 | − * gabarit commun Groupe KA (src/ka/stats/SPEC.md §3). `period`/`from`/`to` | |
| 5 | − * sont acceptés (contrat commun) mais non applicables : les données sont un | |
| 6 | − * instantané du rôle 2026. Alias historique : /api/report/stats. | |
| 3 | + * GET /api/stats/report?mode=complet|synthese|tendances|repartitions|donnees | |
| 4 | + * — rapport statistique PDF au gabarit commun Groupe KA v2 (src/ka/stats/ | |
| 5 | + * SPEC.md §3), moteur pdfkit vectoriel. Un mode inconnu retombe sur | |
| 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 | 10 | import { type NextRequest, NextResponse } from "next/server"; |
| 9 | −import { buildStatsReport, reportFilename, type ReportMode } from "@/lib/report-stats"; | |
| 10 | −import stats from "@/data/stats.json"; | |
| 11 | −import type { ProvStats } from "@/components/StatsView"; | |
| 11 | +import { buildStatsReport, reportFilename, REPORT_MODES, type ReportMode } from "@/lib/report-stats"; | |
| 12 | 12 | |
| 13 | 13 | export async function GET(req: NextRequest) { |
| 14 | − const mode: ReportMode = | |
| 15 | − req.nextUrl.searchParams.get("mode") === "synthese" ? "synthese" : "complet"; | |
| 16 | − const pdf = await buildStatsReport(stats as ProvStats, mode); | |
| 14 | + const raw = req.nextUrl.searchParams.get("mode") ?? "complet"; | |
| 15 | + const mode: ReportMode = (REPORT_MODES as readonly string[]).includes(raw) | |
| 16 | + ? (raw as ReportMode) | |
| 17 | + : "complet"; | |
| 18 | + const pdf = await buildStatsReport(mode); | |
| 17 | 19 | return new NextResponse(new Uint8Array(pdf), { |
| 18 | 20 | headers: { |
| 19 | 21 | "Content-Type": "application/pdf", |
| 20 | − "Content-Disposition": `attachment; filename="${reportFilename()}"`, | |
| 22 | + "Content-Disposition": `attachment; filename="${reportFilename(mode)}"`, | |
| 21 | 23 | "Cache-Control": "public, max-age=3600", |
| 22 | 24 | }, |
| 23 | 25 | }); |
modified
src/app/stats/page.tsx
+10 −2
@@ -1,5 +1,7 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | import StatsView, { type ProvStats } from "@/components/StatsView"; |
| 3 | +import StatsDashboard, { type DashboardPayload } from "@/components/StatsDashboard"; | |
| 4 | +import { getDashboard } from "@/lib/stats-dashboard"; | |
| 3 | 5 | import stats from "@/data/stats.json"; |
| 4 | 6 | |
| 5 | 7 | import type { Metadata } from "next"; |
@@ -7,10 +9,16 @@ import type { Metadata } from "next"; | ||
| 7 | 9 | export const metadata: Metadata = { |
| 8 | 10 | title: "Statistiques provinciales — la valeur de l'immobilier québécois", |
| 9 | 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 | 13 | alternates: { canonical: "/stats" }, |
| 12 | 14 | }; |
| 13 | 15 | |
| 14 | 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 | 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 | 8 | "use client"; |
| 3 | −import { useState } from "react"; | |
| 4 | 9 | import { CountUp } from "./MetricViz"; |
| 5 | 10 | import { useLang } from "./LangContext"; |
| 6 | −import type { TKey } from "@/lib/i18n"; | |
| 7 | 11 | |
| 8 | 12 | export interface ProvStats { |
| 9 | 13 | generated: string; |
@@ -44,44 +48,18 @@ const num = (v: number, lang: string) => | ||
| 44 | 48 | v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA"); |
| 45 | 49 | |
| 46 | 50 | export default function StatsView({ s }: { s: ProvStats }) { |
| 47 | − const { lang, t } = useLang(); | |
| 51 | + const { lang } = useLang(); | |
| 48 | 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 | 54 | return ( |
| 60 | − <div className="pb-6 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 | − | |
| 55 | + <div className="pt-10 sm:pt-14"> | |
| 78 | 56 | {/* ---- LE CHIFFRE ---- */} |
| 79 | 57 | <section> |
| 80 | 58 | <span className="kicker">{fr ? "Juste pour le show" : "Just for the show"}</span> |
| 81 | 59 | <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]"> |
| 82 | 60 | {fr ? ( |
| 83 | 61 | <> |
| 84 | − Tout l'immobilier du Québec vaut, | |
| 62 | + Tout l'immobilier du Québec vaut, | |
| 85 | 63 | <br /> |
| 86 | 64 | <span className="hl">au dollar près</span>… |
| 87 | 65 | </> |
@@ -127,173 +105,6 @@ export default function StatsView({ s }: { s: ProvStats }) { | ||
| 127 | 105 | : "Sum of 3,747,008 Vrai-Prix estimates (hedonic model, 2026 roll). Corresponding total municipal assessment: "} |
| 128 | 106 | {compact(s.valeur_role_totale, lang)}. |
| 129 | 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 | 108 | </section> |
| 298 | 109 | </div> |
| 299 | 110 | ); |
modified
src/lib/report-stats.ts
+566 −177
@@ -1,18 +1,22 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | 2 | /** |
| 3 | 3 | * Rapport statistique provincial — PDF vectoriel au GABARIT COMMUN GROUPE KA |
| 4 | − * (src/ka/stats/SPEC.md §3) : couverture estampillée « GROUPE KA · RAPPORT | |
| 5 | − * STATISTIQUE », en-tête/pied normalisés sur chaque page, page de fin avec | |
| 6 | − * coordonnées + avertissement d'agrégateur (src/ka/ecosystem.json). | |
| 7 | − * Entre ces pages, tout le contenu riche d'origine : valeur totale du parc | |
| 8 | − * immobilier québécois, évolution 2021-2026, répartition par type, records et | |
| 9 | − * palmarès complet des 200 plus grandes municipalités. | |
| 10 | − * Modes : "complet" (défaut) · "synthese" (couverture + sommaire exécutif). | |
| 4 | + * v2 (src/ka/stats/SPEC.md §3), moteur pdfkit (équivalent TS de kapdf.py) : | |
| 5 | + * couverture estampillée « GROUPE KA · RAPPORT STATISTIQUE », en-tête/pied | |
| 6 | + * normalisés, sommaire paginé, graphiques 100 % VECTORIELS (courbes, aires, | |
| 7 | + * barres, multi-courbes à motifs distincts, empilées, anneaux, jauges, | |
| 8 | + * calendrier), statistiques de séries (min/max/moy/méd/σ), tableaux zébrés | |
| 9 | + * paginés, records, page de fin avec coordonnées (src/ka/ecosystem.json). | |
| 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 | 15 | import PDFDocument from "pdfkit"; |
| 13 | 16 | import path from "path"; |
| 14 | 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 | 20 | import eco from "@/ka/ecosystem.json"; |
| 17 | 21 | |
| 18 | 22 | const INK = "#141814"; |
@@ -30,6 +34,8 @@ const W = 595.28; | ||
| 30 | 34 | const H = 841.89; |
| 31 | 35 | const M = 44; |
| 32 | 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 | 40 | const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); |
| 35 | 41 | const money = (v: number) => |
@@ -41,17 +47,30 @@ const compact = (v: number) => { | ||
| 41 | 47 | return money(v); |
| 42 | 48 | }; |
| 43 | 49 | const num = (v: number) => v.toLocaleString("fr-CA"); |
| 44 | −const TYPE_FR: Record<string, string> = { | |
| 45 | − unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi", | |
| 46 | − chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre", | |
| 50 | +/** Nombre court pour axes/valeurs de graphiques. */ | |
| 51 | +const short = (v: number) => { | |
| 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 | 58 | const POP_QC = 9_111_000; |
| 49 | 59 | const BUDGET_QC = 165_800_000_000; |
| 50 | 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 | 72 | type Doc = InstanceType<typeof PDFDocument>; |
| 73 | +type Dash = ReturnType<typeof getDashboard>; | |
| 55 | 74 | |
| 56 | 75 | /** Wordmark Vrai-Prix : « Vrai- » encre + boîte encre / accent rouge #ff5148. */ |
| 57 | 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 | 86 | function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) { |
| 68 | 87 | doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN); |
| 69 | 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). */ | |
| 74 | −function chrome(doc: Doc, page: number, total: number) { | |
| 92 | +/** En-tête + pied normalisés Groupe-KA (numéros de page ajoutés en post-passe). */ | |
| 93 | +function chrome(doc: Doc) { | |
| 75 | 94 | const year = new Date().getFullYear(); |
| 76 | 95 | doc.rect(0, 0, W, H).fill(PAPER); |
| 77 | − // en-tête discret | |
| 78 | 96 | doc.font("SG-Bold").fontSize(9.5).fillColor(INK) |
| 79 | 97 | .text("Groupe KA · Vrai-Prix", M, 22, { lineBreak: false }); |
| 80 | 98 | doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) |
| 81 | 99 | .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 }); |
| 82 | 100 | doc.rect(M, 37, CW, 1.1).fill(INK); |
| 83 | − // pied | |
| 84 | 101 | doc.rect(M, H - 42, CW, 0.7).fill(INK3); |
| 85 | 102 | doc.font("JB-Reg").fontSize(6.8).fillColor(INK3) |
| 86 | 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 | 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 | 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 | 532 | /* ------------------------------- COUVERTURE ------------------------------- */ |
| 107 | 533 | function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) { |
| 108 | 534 | doc.rect(0, 0, W, H).fill(PAPER); |
| 109 | − // cadre encre | |
| 110 | 535 | doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK); |
| 111 | − // kicker | |
| 112 | 536 | kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5); |
| 113 | − // wordmark plateforme (boîte encre / accent rouge) | |
| 114 | 537 | wordmark(doc, 64, 172, 46); |
| 115 | − // sous-titre | |
| 116 | 538 | doc.font("Inter").fontSize(13).fillColor(INK2).text( |
| 117 | 539 | "La valeur réelle de chaque propriété — rapport statistique provincial du parc immobilier québécois.", |
| 118 | 540 | 64, 256, { width: W - 168, lineGap: 3 }); |
| 119 | − // fiche : période / génération / plateforme / mode / données | |
| 120 | 541 | const rows: [string, string][] = [ |
| 542 | + ["Type de rapport", MODE_FR[mode]], | |
| 121 | 543 | ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"], |
| 122 | 544 | ["Généré le", `${generated} (heure de l'Est)`], |
| 123 | 545 | ["Plateforme", "www.vrai-prix.com"], |
| 124 | − ["Mode", mode === "complet" ? "Rapport complet" : "Synthèse"], | |
| 125 | 546 | ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`], |
| 126 | 547 | ]; |
| 127 | 548 | doc.rect(64, 318, 34, 3).fill(LIME); |
@@ -133,7 +554,6 @@ function cover(doc: Doc, s: ProvStats, generated: string, mode: ReportMode) { | ||
| 133 | 554 | .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false }); |
| 134 | 555 | y += 24; |
| 135 | 556 | }); |
| 136 | − // bande encre au pied : « par Groupe KA — groupe-ka.com » | |
| 137 | 557 | doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK); |
| 138 | 558 | const by = H - 28 - 64 + 24; |
| 139 | 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 | 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) --------------------- */ | |
| 146 | −function overview(doc: Doc, s: ProvStats, withRecords: boolean, execTitle: string | null) { | |
| 147 | − let y = 56; | |
| 148 | − if (execTitle) { | |
| 149 | − kicker(doc, execTitle, M, y); | |
| 150 | − 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); | |
| 565 | +/* --------------------- VUE D'ENSEMBLE (le chiffre + comparaisons) --------------------- */ | |
| 566 | +function overview(c: Cursor, s: ProvStats) { | |
| 567 | + const { doc } = c; | |
| 568 | + c.section("Valeur totale du parc immobilier québécois"); | |
| 569 | + card(doc, M, c.y, CW, 96, INK); | |
| 570 | + doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, c.y + 18); | |
| 156 | 571 | doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)") |
| 157 | 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 }); | |
| 159 | − y += 112; | |
| 160 | − | |
| 161 | − // comparaisons (KPI) | |
| 573 | + M + 20, c.y + 62, { characterSpacing: 0.8 }); | |
| 574 | + c.y += 112; | |
| 162 | 575 | const comps: [string, string][] = [ |
| 163 | 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)}`], | |
| 165 | − ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toFixed(1)}`], | |
| 577 | + ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`], | |
| 578 | + ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`], | |
| 166 | 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 | 581 | const tw2 = (CW - 3 * 10) / 4; |
| 173 | 582 | comps.forEach(([k, v], i) => { |
| 174 | 583 | const tx = M + (i % 4) * (tw2 + 10); |
| 175 | − const ty = y + Math.floor(i / 4) * 56; | |
| 176 | − card(doc, tx, ty, tw2, 48); | |
| 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 }); | |
| 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 }); | |
| 584 | + card(doc, tx, c.y, 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 }); | |
| 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 }); | |
| 179 | 587 | }); |
| 180 | − y += 2 * 56 + 8; | |
| 588 | + c.y += 56 + 6; | |
| 181 | 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 }); | |
| 183 | − 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; | |
| 590 | + .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, c.y, { characterSpacing: 0.5 }); | |
| 591 | + c.y += 18; | |
| 231 | 592 | } |
| 232 | 593 | |
| 233 | 594 | /* ------------------------------ PAGE DE FIN ------------------------------ */ |
@@ -262,20 +623,17 @@ function finalPage(doc: Doc) { | ||
| 262 | 623 | } |
| 263 | 624 | |
| 264 | 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 | 629 | const generated = new Date().toLocaleString("fr-CA", { |
| 267 | 630 | timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit", |
| 268 | 631 | hour: "2-digit", minute: "2-digit", hour12: false, |
| 269 | 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 | 634 | const doc = new PDFDocument({ |
| 277 | − size: "A4", margin: 0, | |
| 278 | − info: { Title: "Groupe KA · Vrai-Prix — Rapport statistique provincial", Author: "Groupe KA — groupe-ka.com" }, | |
| 635 | + size: "A4", margin: 0, bufferPages: true, | |
| 636 | + info: { Title: `Groupe KA · Vrai-Prix — Rapport statistique (${MODE_FR[mode]})`, Author: "Groupe KA — groupe-ka.com" }, | |
| 279 | 637 | }); |
| 280 | 638 | doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); |
| 281 | 639 | doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); |
@@ -283,91 +641,122 @@ export async function buildStatsReport(s: ProvStats, mode: ReportMode = "complet | ||
| 283 | 641 | doc.registerFont("Inter", F("Inter-Regular.ttf")); |
| 284 | 642 | |
| 285 | 643 | const chunks: Buffer[] = []; |
| 286 | − doc.on("data", (c: Buffer) => chunks.push(c)); | |
| 644 | + doc.on("data", (b: Buffer) => chunks.push(b)); | |
| 287 | 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 | 648 | cover(doc, s, generated, mode); |
| 291 | 649 | |
| 292 | − if (mode === "synthese") { | |
| 293 | − /* ------ PAGE 2 : SOMMAIRE EXÉCUTIF ------ */ | |
| 294 | − doc.addPage({ size: "A4", margin: 0 }); | |
| 295 | − chrome(doc, 2, TOTAL); | |
| 296 | − overview(doc, s, true, "Synthèse · sommaire exécutif"); | |
| 297 | − doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text( | |
| 298 | − `INSTANTANÉ DU RÔLE 2026 · STATISTIQUES GÉNÉRÉES LE ${s.generated} · RAPPORT COMPLET : WWW.VRAI-PRIX.COM/STATS`, | |
| 299 | − M, H - 58, { characterSpacing: 0.5 }); | |
| 650 | + const withToc = mode === "complet" || mode === "donnees"; | |
| 651 | + if (withToc) { | |
| 652 | + doc.addPage({ size: "A4", margin: 0 }); // page 2 réservée au sommaire (remplie en post-passe) | |
| 653 | + } | |
| 654 | + | |
| 655 | + doc.addPage({ size: "A4", margin: 0 }); | |
| 656 | + chrome(doc); | |
| 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 | 708 | } else { |
| 301 | − /* ------ PAGE 2 : SOMMAIRE ------ */ | |
| 302 | − doc.addPage({ size: "A4", margin: 0 }); | |
| 303 | − chrome(doc, 2, TOTAL); | |
| 304 | − let y = 60; | |
| 305 | − kicker(doc, "Rapport complet", M, y); | |
| 709 | + // donnees : tous les tableaux en version longue (≤ 400 lignes) | |
| 710 | + dash.tables.forEach((t) => tablePdf(c, t, 400)); | |
| 711 | + } | |
| 712 | + | |
| 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 | 726 | y += 20; |
| 307 | 727 | doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y); |
| 308 | − y += 44; | |
| 728 | + y += 40; | |
| 309 | 729 | const entries: [string, string][] = [ |
| 310 | − ["Vue d'ensemble provinciale — le chiffre, millésimes, types, records", "3"], | |
| 311 | − [`Palmarès des ${s.par_ville.length} plus grandes municipalités`, munPages > 1 ? `4–${3 + munPages}` : "4"], | |
| 312 | − ["Coordonnées du Groupe KA & mentions", String(4 + munPages)], | |
| 730 | + ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]), | |
| 731 | + ["Coordonnées du Groupe KA & mentions", String(total)], | |
| 313 | 732 | ]; |
| 314 | − entries.forEach(([label, pg]) => { | |
| 315 | − doc.rect(M, y + 26, CW, 0.7).fill("#e3e1d9"); | |
| 316 | − doc.font("SG-Bold").fontSize(12).fillColor(INK).text(label, M, y, { width: CW - 70, height: 30 }); | |
| 317 | − doc.font("JB-Bold").fontSize(11).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" }); | |
| 318 | − y += 40; | |
| 733 | + entries.slice(0, 16).forEach(([label, pg]) => { | |
| 734 | + doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9"); | |
| 735 | + doc.font("SG-Bold").fontSize(11).fillColor(INK).text(label, M, y, { width: CW - 70, height: 26, ellipsis: true }); | |
| 736 | + doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" }); | |
| 737 | + y += 34; | |
| 319 | 738 | }); |
| 320 | − y += 10; | |
| 739 | + y += 8; | |
| 321 | 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 | 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 | − | |
| 360 | − /* ------ DERNIÈRE PAGE : COORDONNÉES & MENTIONS (gabarit Groupe-KA) ------ */ | |
| 361 | − doc.addPage({ size: "A4", margin: 0 }); | |
| 362 | − chrome(doc, TOTAL, TOTAL); | |
| 363 | − finalPage(doc); | |
| 744 | + for (let i = 1; i < total; i++) { | |
| 745 | + doc.switchToPage(i); | |
| 746 | + doc.font("JB-Bold").fontSize(7.5).fillColor(INK) | |
| 747 | + .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false }); | |
| 748 | + } | |
| 364 | 749 | |
| 365 | 750 | doc.end(); |
| 366 | 751 | return done; |
| 367 | 752 | } |
| 368 | 753 | |
| 369 | −/** Nom de fichier normalisé Groupe-KA (SPEC §2). */ | |
| 370 | −export function reportFilename(): string { | |
| 754 | +/** Nom de fichier normalisé Groupe-KA (SPEC §3) — pas de suffixe pour `complet`. */ | |
| 755 | +export function reportFilename(mode: ReportMode = "complet"): string { | |
| 371 | 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 | 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), | |
| 4 | − * construit depuis src/data/stats.json (agrégats SQLite précalculés du rôle). | |
| 5 | − * IMPORTANT : les données de Vrai-Prix sont un INSTANTANÉ du rôle d'évaluation | |
| 6 | − * foncière (millésime 2026) — aucune série temporelle quotidienne n'existe ; | |
| 7 | − * le paramètre `period` est donc accepté mais non applicable. La seule vraie | |
| 8 | − * série est la valeur provinciale par millésime (2021-2026, 6 points). | |
| 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 historiques du rôle) et | |
| 5 | + * src/data/stats-v2.json (agrégats enrichis : distributions, transactions | |
| 6 | + * mensuelles/quotidiennes, indice de marché — générés par | |
| 7 | + * scripts/build-stats-v2.mjs depuis data/vraiprix.db, données 100 % réelles). | |
| 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 | 16 | import stats from "@/data/stats.json"; |
| 17 | +import v2 from "@/data/stats-v2.json"; | |
| 11 | 18 | import type { ProvStats } from "@/components/StatsView"; |
| 12 | 19 | |
| 13 | 20 | /** Ventes réelles publiées 2021-01 → 2026-07 (constante du corpus, voir /methodologie). */ |
@@ -34,12 +41,53 @@ const compact = (v: number) => { | ||
| 34 | 41 | return money(v); |
| 35 | 42 | }; |
| 36 | 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 { | |
| 39 | − label: string; | |
| 40 | − value: string; | |
| 41 | − date?: string; | |
| 50 | +/* ---------- types du contrat (SPEC §2) ---------- */ | |
| 51 | +export type Point = { t: string; v: number }; | |
| 52 | +export interface KaKpi { | |
| 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 | 92 | /** Records & faits marquants, calculés depuis les données réelles (jamais inventés). */ |
| 45 | 93 | export function buildRecords(s: ProvStats): KaRecord[] { |
@@ -82,74 +130,283 @@ export function buildRecords(s: ProvStats): KaRecord[] { | ||
| 82 | 130 | label: "Croissance 2021→2026 (périmètre constant)", |
| 83 | 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 | 167 | return out; |
| 86 | 168 | } |
| 87 | 169 | |
| 88 | 170 | function buildDashboard() { |
| 89 | 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 | 381 | return { |
| 91 | − updated: s.generated, | |
| 382 | + updated: V.generated, | |
| 92 | 383 | period: { |
| 93 | 384 | from: null as string | null, |
| 94 | 385 | to: null as string | null, |
| 95 | 386 | label: PERIOD_LABEL, |
| 96 | 387 | applicable: false, |
| 97 | 388 | note: |
| 98 | − "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 — " + | |
| 100 | − "la réponse est toujours l'instantané.", | |
| 101 | − }, | |
| 102 | − kpis: [ | |
| 103 | − { id: "unites", label: "Propriétés estimées", value: s.unites, unit: "" }, | |
| 104 | − { id: "valeur_totale", label: "Valeur totale du parc (2026)", value: s.valeur_totale_2026, unit: "$" }, | |
| 105 | − { id: "valeur_mediane", label: "Valeur médiane (2026)", value: s.valeur_mediane_2026, unit: "$" }, | |
| 106 | − { | |
| 107 | − id: "croissance", | |
| 108 | − label: "Croissance 2021→2026 (périmètre constant)", | |
| 109 | − value: s.croissance_2021_2026_pct, | |
| 110 | − unit: "%", | |
| 111 | − delta_pct: s.croissance_2021_2026_pct, | |
| 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 | − ], | |
| 389 | + "Les données de Vrai-Prix sont un instantané du rôle d'évaluation foncière (millésime 2026) : " + | |
| 390 | + "le paramètre `period` est accepté mais sans effet. Les séries temporelles réelles servies sont " + | |
| 391 | + "les millésimes 2021-2026, le corpus de ventes mensuel (2021-01 → 2026-07) et l'indice de marché.", | |
| 392 | + }, | |
| 393 | + kpis, | |
| 394 | + gauges, | |
| 395 | + series, | |
| 396 | + multiseries, | |
| 397 | + stacked, | |
| 398 | + breakdowns, | |
| 399 | + distributions, | |
| 400 | + geo, | |
| 401 | + heatmap, | |
| 402 | + tables, | |
| 146 | 403 | records: buildRecords(s), |
| 147 | 404 | }; |
| 148 | 405 | } |
| 149 | 406 | |
| 150 | 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 | 410 | let cache: StatsDashboard | null = null; |
| 154 | 411 | export function getDashboard(): StatsDashboard { |
| 155 | 412 | if (!cache) cache = buildDashboard(); |
| 156 | 413 | |