9a958c0051
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>
115 lines
4.8 KiB
TypeScript
115 lines
4.8 KiB
TypeScript
// 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;
|
|
}
|