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:
Flo
2026-05-07 16:27:45 +02:00
co-authored by Claude
parent 0f10f8507f
commit 9a958c0051
46 changed files with 10056 additions and 1 deletions
+61
View File
@@ -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)}`);
}