Files
ms-haushalt/src/data/parse.ts
T
Flo 9a958c0051 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>
2026-05-07 16:27:45 +02:00

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;
}