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>
137 lines
5.2 KiB
TypeScript
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;
|
|
}
|