Files
ms-haushalt/src/lib/breakdowns.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

137 lines
5.2 KiB
TypeScript

// Build a path-keyed lookup of per-Produktgruppe per-category
// breakdowns extracted from the source PDF Erläuterungen.
//
// Currently only PG 1601 (Allgemeine Finanzwirtschaft) has parseable
// tabular breakdowns — that's where the major taxes (Gewerbesteuer,
// Grundsteuer, Einkommensteuer, etc.) get itemised. Other PGs have
// either prose-only Erläuterungen or no breakdowns; for those the
// lookup simply has no entries.
import type { BudgetTree, CategoryKey, Flow } from "../data/types.js";
// Teilergebnisplan line numbers map to our CategoryKey + flow side
// per the standard NRW-NKF schema. (Lines 27/28 are internal
// Leistungsbeziehungen and don't map to our category list.)
const LINE_TO_CATEGORY: Record<number, { key: CategoryKey; flow: Flow }> = {
1: { key: "steuern", flow: "ertraege" },
2: { key: "zuwendungenAllgemeineUmlagen", flow: "ertraege" },
3: { key: "sonstigeTransfererträge", flow: "ertraege" },
4: { key: "öffentlichRechtlicheLeistungsentgelte", flow: "ertraege" },
5: { key: "privatrechtlicheLeistungsentgelte", flow: "ertraege" },
6: { key: "kostenerstattungenKostenumlagen", flow: "ertraege" },
7: { key: "sonstigeOrdentlicheErträge", flow: "ertraege" },
8: { key: "aktivierteEigenleistungen", flow: "ertraege" },
9: { key: "bestandsveränderungen", flow: "ertraege" },
11: { key: "personalaufwendungen", flow: "aufwendungen" },
12: { key: "versorgungsaufwendungen", flow: "aufwendungen" },
13: { key: "sachUndDienstleistungen", flow: "aufwendungen" },
14: { key: "bilanzielleAbschreibungen", flow: "aufwendungen" },
15: { key: "transferaufwendungen", flow: "aufwendungen" },
16: { key: "sonstigeOrdentlicheAufwendungen", flow: "aufwendungen" },
19: { key: "finanzerträge", flow: "ertraege" },
20: { key: "zinsenFinanzaufwendungen", flow: "aufwendungen" },
23: { key: "außerordentlicheErträge", flow: "ertraege" },
24: { key: "außerordentlicheAufwendungen", flow: "aufwendungen" },
};
export interface BreakdownItem {
name: string;
slug: string;
/** € values keyed by year. Sparse — only years the source covers. */
values: Record<number, number>;
}
/** Lookup keyed by `${flow}/${bereichSlug}/${gruppeSlug}/${categoryKey}` */
export type BreakdownsByPath = Record<string, BreakdownItem[]>;
interface PgSectionWithBreakdowns {
pgNumber: string;
name: string;
beschreibung: string | null;
erlaeuterungen: string | null;
breakdowns?: Record<string, Array<{ name: string; values: Record<string, number> }>>;
}
const SLUG_REPLACEMENTS: Array<readonly [RegExp, string]> = [
[/ä/g, "ae"],
[/ö/g, "oe"],
[/ü/g, "ue"],
[/ß/g, "ss"],
];
function slugify(input: string): string {
let s = input.toLowerCase();
for (const [pat, rep] of SLUG_REPLACEMENTS) s = s.replace(pat, rep);
s = s.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return s || "n-a";
}
/** Match a PG by name against the budget tree and return its
* (bereich, gruppe) pair. Tolerates the same abbreviations as the
* pg-notes matcher. */
function matchPgInTree(
pgName: string,
tree: BudgetTree
): { bereichSlug: string; gruppeSlug: string } | null {
const target = pgName.toLowerCase().replace(/[^a-z0-9]/g, "");
let best: { dist: number; bereich: string; gruppe: string } | null = null;
for (const bereich of tree.produktbereiche) {
for (const gruppe of bereich.produktgruppen) {
const candidate = gruppe.name.toLowerCase().replace(/[^a-z0-9]/g, "");
if (candidate === target) {
return { bereichSlug: bereich.slug, gruppeSlug: gruppe.slug };
}
// Fuzzy fallback (cheap edit-distance via length symmetric diff).
let dist = Math.abs(candidate.length - target.length);
const min = Math.min(candidate.length, target.length);
for (let i = 0; i < min; i++) {
if (candidate[i] !== target[i]) dist++;
}
if (best === null || dist < best.dist) {
best = { dist, bereich: bereich.slug, gruppe: gruppe.slug };
}
}
}
if (best && best.dist / Math.max(target.length, 1) < 0.4) {
return { bereichSlug: best.bereich, gruppeSlug: best.gruppe };
}
return null;
}
export function buildBreakdownsByPath(
tree: BudgetTree,
pgSections: Record<string, PgSectionWithBreakdowns>
): BreakdownsByPath {
const out: BreakdownsByPath = {};
for (const pg of Object.values(pgSections)) {
if (!pg.breakdowns || Object.keys(pg.breakdowns).length === 0) continue;
const place = matchPgInTree(pg.name, tree);
if (!place) continue;
for (const [lineNumStr, items] of Object.entries(pg.breakdowns)) {
const lineNum = parseInt(lineNumStr, 10);
const map = LINE_TO_CATEGORY[lineNum];
if (!map) continue;
// Convert string year keys back to numbers; assemble the lookup
// key in the same shape consumers will use it.
const breakdownItems: BreakdownItem[] = items.map((it) => {
const values: Record<number, number> = {};
for (const [yStr, v] of Object.entries(it.values)) {
values[Number(yStr)] = v;
}
return {
name: it.name,
slug: slugify(it.name),
values,
};
});
const key = `${map.flow}/${place.bereichSlug}/${place.gruppeSlug}/${map.key}`;
out[key] = breakdownItems;
}
}
return out;
}