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,114 @@
|
||||
// CSV parsing for the four source files. Handles:
|
||||
// • UTF-8 BOM
|
||||
// • `;` separator
|
||||
// • German number format (`.` thousands, `,` decimal)
|
||||
// • Sign convention: source revenues are negative, source expenses are
|
||||
// positive. We flip revenues to positive so downstream code can treat both
|
||||
// flows symmetrically.
|
||||
// • Header drift: `Leistunngsentgelte` (double-n) and `algemeine` (single-l)
|
||||
// map to the same canonical CategoryKey.
|
||||
// • The 2025 plan's extra `Globaler Minderaufwand` column.
|
||||
// • Empty rows (the 2023 plan and Jahresabschluss have a stray blank line).
|
||||
|
||||
import { dsvFormat } from "d3-dsv";
|
||||
import type {
|
||||
BudgetRow,
|
||||
CategoryKey,
|
||||
Categories,
|
||||
RowKind,
|
||||
SourceFile,
|
||||
} from "./types.js";
|
||||
import { ERTRAG_KEYS } from "./types.js";
|
||||
|
||||
const semicolon = dsvFormat(";");
|
||||
|
||||
// Maps a verbatim source-CSV header (after BOM strip and trim) to a canonical
|
||||
// CategoryKey. Includes the known typos and spelling variants observed across
|
||||
// files. If a header does not appear here it is silently ignored — the first
|
||||
// three columns (Produktbereich, Produktgruppe, Geschäftsjahr) are handled
|
||||
// separately.
|
||||
const HEADER_TO_KEY: ReadonlyMap<string, CategoryKey> = new Map([
|
||||
["Steuern und ähnliche Abgaben", "steuern"],
|
||||
["Zuwendungen und allgemeine Umlagen", "zuwendungenAllgemeineUmlagen"],
|
||||
["Zuwendungen und algemeine Umlagen", "zuwendungenAllgemeineUmlagen"],
|
||||
["Sonstige Transfererträge", "sonstigeTransfererträge"],
|
||||
["Öffentlich-rechtliche Leistungsentgelte", "öffentlichRechtlicheLeistungsentgelte"],
|
||||
["Öffentlich-rechtliche Leistunngsentgelte", "öffentlichRechtlicheLeistungsentgelte"],
|
||||
["Privatrechtliche Leistungsentgelte", "privatrechtlicheLeistungsentgelte"],
|
||||
["Kostenerstattungen und Kostenumlagen", "kostenerstattungenKostenumlagen"],
|
||||
["Sonstige ordentliche Erträge", "sonstigeOrdentlicheErträge"],
|
||||
["Aktivierte Eigenleistungen", "aktivierteEigenleistungen"],
|
||||
["Bestandsveränderungen", "bestandsveränderungen"],
|
||||
["Personalaufwendungen", "personalaufwendungen"],
|
||||
["Versorgungsaufwendungen", "versorgungsaufwendungen"],
|
||||
["Aufwendungen für Sach- und Dienstleistungen", "sachUndDienstleistungen"],
|
||||
["Bilanzielle Abschreibungen", "bilanzielleAbschreibungen"],
|
||||
["Transferaufwendungen", "transferaufwendungen"],
|
||||
["Sonstige ordentliche Aufwendungen", "sonstigeOrdentlicheAufwendungen"],
|
||||
["Finanzerträge", "finanzerträge"],
|
||||
["Zinsen und sonstige Finanzaufwendungen", "zinsenFinanzaufwendungen"],
|
||||
["Außerordentliche Erträge", "außerordentlicheErträge"],
|
||||
["Außerordentliche Aufwendungen", "außerordentlicheAufwendungen"],
|
||||
["Globaler Minderaufwand", "globalerMinderaufwand"],
|
||||
]);
|
||||
|
||||
const ERTRAG_SET: ReadonlySet<CategoryKey> = new Set(ERTRAG_KEYS);
|
||||
|
||||
/** Parse a German-formatted decimal. Returns undefined for empty/missing. */
|
||||
function parseGermanNumber(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
// `-733.670.000,00` → `-733670000.00`
|
||||
const normalized = trimmed.replace(/\./g, "").replace(",", ".");
|
||||
const n = Number(normalized);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function classifyRow(produktbereich: string, produktgruppe: string): RowKind {
|
||||
if (produktbereich === "Gesamt" && produktgruppe === "Gesamt") return "grandTotal";
|
||||
if (produktgruppe === "Gesamt") return "produktbereichTotal";
|
||||
return "leaf";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one CSV file's contents into BudgetRows.
|
||||
*
|
||||
* `text` is the file's raw UTF-8 content (BOM tolerated). `source` describes
|
||||
* provenance for downstream conflict resolution.
|
||||
*/
|
||||
export function parseCsv(text: string, source: SourceFile): BudgetRow[] {
|
||||
// Strip BOM if present; d3-dsv doesn't.
|
||||
const stripped = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
const rows = semicolon.parse(stripped);
|
||||
|
||||
const out: BudgetRow[] = [];
|
||||
for (const row of rows) {
|
||||
const produktbereich = (row["Produktbereich"] ?? "").trim();
|
||||
const produktgruppe = (row["Produktgruppe"] ?? "").trim();
|
||||
const yearRaw = (row["Geschäftsjahr"] ?? "").trim();
|
||||
if (!produktbereich || !produktgruppe || !yearRaw) continue;
|
||||
const year = Number.parseInt(yearRaw, 10);
|
||||
if (!Number.isInteger(year)) continue;
|
||||
|
||||
const categories: Categories = {};
|
||||
for (const [header, value] of Object.entries(row)) {
|
||||
const key = HEADER_TO_KEY.get(header.trim());
|
||||
if (!key) continue;
|
||||
const n = parseGermanNumber(value);
|
||||
if (n === undefined) continue;
|
||||
// Source convention: revenues negative, expenses positive. Flip revenues.
|
||||
categories[key] = ERTRAG_SET.has(key) ? -n : n;
|
||||
}
|
||||
|
||||
out.push({
|
||||
produktbereich,
|
||||
produktgruppe,
|
||||
year,
|
||||
kind: classifyRow(produktbereich, produktgruppe),
|
||||
categories,
|
||||
source,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user