feat: initial Münster Haushalt icicle viewer
Editorial single-page viewer for the City of Münster's 2026/2027 budget draft, built as an Astro v6 SPA with a 4-level zoomable icicle (Produktbereich → Produktgruppe → Category → Breakdown). Highlights: - Multi-flow data layer over the official open-data CSVs (Aufwendungen + Erträge, 2008–2028) with overlap reconciliation across plan years. - Year slider as a 21-year mini-histogram of both flows; drag-to-scrub and click-to-jump, with bars morphing via CSS transitions on SVG geometry attributes. - Vertically centred icicle with year-outline rectangles framing each year's relative budget size, à la Bostock's animated treemap. - Headline "ausgibt / einnimmt" toggle; sidebar Aufwendungen/Erträge rows double as flow toggles. Active flow in Aufwendungen-purple / Erträge-orange (OKLCH). - Click-to-zoom via path-keyed lookup with ZOOM_COL_BOUNDS that reallocate the depth axis per zoom state. Zoomed item moves to the sidebar; canvas shows its descendants only (no adjacent-block leaks). - Sidebar shows path-specific Aufwendungen/Erträge/Saldo plus the source-PDF Beschreibung; Erläuterungen behind a collapsed details. - Build-time PDF extraction (scripts/extract-pg-sections.mjs) parses 68 Produktgruppen' Beschreibung + Erläuterungen sections from Band 1, including 10 cells of structured Mio.-€ breakdowns (Steuern, Transferaufwendungen, etc.) that drive the level-4 view. - URL state sync for path, year, and flow via history.replaceState so any zoom is shareable. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// Ad-hoc sanity check, not a real test runner.
|
||||
// pnpm exec tsx src/data/__check.ts
|
||||
|
||||
import { loadBudget } from "./load.js";
|
||||
|
||||
function fmtEUR(n: number): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
const tree = await loadBudget();
|
||||
|
||||
console.log(`Years: ${tree.years.length} (${tree.years[0]}–${tree.years.at(-1)})`);
|
||||
console.log(`Produktbereiche: ${tree.produktbereiche.length}`);
|
||||
console.log(
|
||||
`Produktgruppen total: ${tree.produktbereiche.reduce(
|
||||
(n, b) => n + b.produktgruppen.length,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
console.log(`Overlap notes (>5% disagreement): ${tree.overlaps.length}`);
|
||||
|
||||
// Bucket overlaps by year to confirm they're concentrated in the expected
|
||||
// triple-counted years (2025/26/27 appear in two plans each).
|
||||
const byYear: Record<number, number> = {};
|
||||
for (const o of tree.overlaps) byYear[o.year] = (byYear[o.year] ?? 0) + 1;
|
||||
console.log("Overlaps by year:", byYear);
|
||||
|
||||
// Sample 3 overlaps to confirm they're plausible plan revisions, not bugs.
|
||||
console.log("\nSample overlaps:");
|
||||
for (const o of tree.overlaps.slice(0, 3)) {
|
||||
console.log(
|
||||
` ${o.produktbereich} > ${o.produktgruppe} · ${o.year} · ${o.category}`
|
||||
);
|
||||
for (const v of o.values) {
|
||||
console.log(` ${v.source.padEnd(50)} ${fmtEUR(v.value)}`);
|
||||
}
|
||||
console.log(` Δ = ${(o.divergencePct * 100).toFixed(1)}%`);
|
||||
}
|
||||
|
||||
for (const year of [2022, 2025, 2026, 2028]) {
|
||||
const t = tree.totals[year];
|
||||
if (!t) continue;
|
||||
console.log(
|
||||
`\n${year}: Aufw ${fmtEUR(t.aufwendungen.total)} · Ertr ${fmtEUR(
|
||||
t.ertraege.total
|
||||
)} · Saldo ${fmtEUR(t.ertraege.total - t.aufwendungen.total)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nTop 5 Produktbereiche by Aufwand 2026:");
|
||||
const ranked = [...tree.produktbereiche]
|
||||
.map((b) => ({ name: b.name, aufwand: b.totals[2026]?.aufwendungen.total ?? 0 }))
|
||||
.sort((a, b) => b.aufwand - a.aufwand)
|
||||
.slice(0, 5);
|
||||
for (const r of ranked) {
|
||||
console.log(` ${r.name.padEnd(42)} ${fmtEUR(r.aufwand)}`);
|
||||
}
|
||||
Reference in New Issue
Block a user