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:
Flo
2026-05-07 16:27:45 +02:00
co-authored by Claude
parent 0f10f8507f
commit 9a958c0051
46 changed files with 10056 additions and 1 deletions
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
// Ad-hoc sanity check, not a real test runner.
// pnpm exec tsx src/data/__check.ts
import { loadBudget } from "./load.js";
function fmtEUR(n: number): string {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(n);
}
const tree = await loadBudget();
console.log(`Years: ${tree.years.length} (${tree.years[0]}${tree.years.at(-1)})`);
console.log(`Produktbereiche: ${tree.produktbereiche.length}`);
console.log(
`Produktgruppen total: ${tree.produktbereiche.reduce(
(n, b) => n + b.produktgruppen.length,
0
)}`
);
console.log(`Overlap notes (>5% disagreement): ${tree.overlaps.length}`);
// Bucket overlaps by year to confirm they're concentrated in the expected
// triple-counted years (2025/26/27 appear in two plans each).
const byYear: Record<number, number> = {};
for (const o of tree.overlaps) byYear[o.year] = (byYear[o.year] ?? 0) + 1;
console.log("Overlaps by year:", byYear);
// Sample 3 overlaps to confirm they're plausible plan revisions, not bugs.
console.log("\nSample overlaps:");
for (const o of tree.overlaps.slice(0, 3)) {
console.log(
` ${o.produktbereich} > ${o.produktgruppe} · ${o.year} · ${o.category}`
);
for (const v of o.values) {
console.log(` ${v.source.padEnd(50)} ${fmtEUR(v.value)}`);
}
console.log(` Δ = ${(o.divergencePct * 100).toFixed(1)}%`);
}
for (const year of [2022, 2025, 2026, 2028]) {
const t = tree.totals[year];
if (!t) continue;
console.log(
`\n${year}: Aufw ${fmtEUR(t.aufwendungen.total)} · Ertr ${fmtEUR(
t.ertraege.total
)} · Saldo ${fmtEUR(t.ertraege.total - t.aufwendungen.total)}`
);
}
console.log("\nTop 5 Produktbereiche by Aufwand 2026:");
const ranked = [...tree.produktbereiche]
.map((b) => ({ name: b.name, aufwand: b.totals[2026]?.aufwendungen.total ?? 0 }))
.sort((a, b) => b.aufwand - a.aufwand)
.slice(0, 5);
for (const r of ranked) {
console.log(` ${r.name.padEnd(42)} ${fmtEUR(r.aufwand)}`);
}
+305
View File
@@ -0,0 +1,305 @@
// Build-time data loader. Reads the four source CSVs from data/, merges them,
// resolves overlaps between plan files, and produces a BudgetTree consumed by
// the page and treemap components.
//
// Conflict resolution policy (per design brief §9, open question 2):
// • When the same {produktbereich, produktgruppe, year, category} cell
// appears in two plan files, the NEWER plan wins (later planYear).
// • Disagreements >5 % are recorded in `overlaps` so we can footnote them.
// • Jahresabschluss values (actuals) always win against plan values for the
// same year — actuals supersede projections.
import { promises as fs } from "node:fs";
import path from "node:path";
import type {
BudgetRow,
BudgetTree,
CategoryKey,
Flow,
FlowTotals,
OverlapNote,
ProduktbereichNode,
ProduktgruppeNode,
SourceFile,
} from "./types.js";
import { AUFWAND_KEYS, ERTRAG_KEYS } from "./types.js";
import { parseCsv } from "./parse.js";
// Astro runs both `dev` and `build` from the project root, so cwd is stable.
// Avoiding `import.meta.url` here because the bundled output ends up under
// dist/.prerender/chunks/ and the path arithmetic doesn't survive that move.
const DATA_DIR = path.join(process.cwd(), "data");
const SOURCES: SourceFile[] = [
{
path: "jahresabschluesse/Jahresabschluss-Muenster-2008-2022.csv",
planYear: 2022,
kind: "actual",
},
{ path: "2023/Haushaltsplan-Muenster-2023.csv", planYear: 2023, kind: "plan" },
{ path: "2024/Haushaltsplan-Muenster-2024-2027.csv", planYear: 2024, kind: "plan" },
{ path: "2025/HH_Plan_Muenster_25-28.csv", planYear: 2025, kind: "plan" },
];
const ERTRAG_SET: ReadonlySet<CategoryKey> = new Set(ERTRAG_KEYS);
const AUFWAND_SET: ReadonlySet<CategoryKey> = new Set(AUFWAND_KEYS);
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";
}
interface CellKey {
bereich: string;
gruppe: string;
year: number;
category: CategoryKey;
}
function cellKeyString(k: CellKey): string {
return `${k.bereich}${k.gruppe}${k.year}${k.category}`;
}
interface CellEntry {
value: number;
source: SourceFile;
}
/**
* Pick the canonical value among multiple sources for the same cell. Returns
* the chosen entry plus a possible OverlapNote when sources disagreed.
*/
function resolveCell(key: CellKey, entries: CellEntry[]): {
chosen: CellEntry;
overlap?: OverlapNote;
} {
// Prefer actuals over plans; among plans prefer the newer planYear.
const sorted = [...entries].sort((a, b) => {
if (a.source.kind !== b.source.kind) {
return a.source.kind === "actual" ? -1 : 1;
}
return b.source.planYear - a.source.planYear;
});
const chosen = sorted[0]!;
if (entries.length < 2) return { chosen };
const values = entries.map((e) => e.value);
const max = Math.max(...values.map(Math.abs));
const min = Math.min(...values.map(Math.abs));
// Divergence as fraction of the larger magnitude. 0 if both are 0.
const divergencePct = max === 0 ? 0 : (max - min) / max;
if (divergencePct > 0.05) {
return {
chosen,
overlap: {
produktbereich: key.bereich,
produktgruppe: key.gruppe,
year: key.year,
category: key.category,
values: entries.map((e) => ({
source: e.source.path,
value: e.value,
})),
divergencePct,
},
};
}
return { chosen };
}
function emptyFlowTotals(): FlowTotals {
return { total: 0, byCategory: {} };
}
function emptyYearTotals(): Record<Flow, FlowTotals> {
return { aufwendungen: emptyFlowTotals(), ertraege: emptyFlowTotals() };
}
function addToFlow(target: FlowTotals, key: CategoryKey, value: number): void {
target.total += value;
target.byCategory[key] = (target.byCategory[key] ?? 0) + value;
}
/** Read all source CSVs and return the merged, normalized BudgetTree. */
export async function loadBudget(): Promise<BudgetTree> {
// Read & parse every file.
const allRows: BudgetRow[] = [];
for (const source of SOURCES) {
const full = path.join(DATA_DIR, source.path);
const text = await fs.readFile(full, "utf8");
const rows = parseCsv(text, source);
allRows.push(...rows);
}
// For overlap resolution we operate on LEAF rows only. Subtotal rows (kind
// !== "leaf") are discarded — we recompute subtotals ourselves to guarantee
// consistency between the levels of the treemap.
const leafRows = allRows.filter((r) => r.kind === "leaf");
// Bucket each cell value by its CellKey, then resolve.
const buckets = new Map<string, { key: CellKey; entries: CellEntry[] }>();
for (const row of leafRows) {
for (const [catKey, value] of Object.entries(row.categories) as Array<
[CategoryKey, number]
>) {
const key: CellKey = {
bereich: row.produktbereich,
gruppe: row.produktgruppe,
year: row.year,
category: catKey,
};
const ks = cellKeyString(key);
const existing = buckets.get(ks);
if (existing) {
existing.entries.push({ value, source: row.source });
} else {
buckets.set(ks, { key, entries: [{ value, source: row.source }] });
}
}
}
// Resolve buckets and re-fold into a per-(bereich, gruppe, year) structure.
type LeafYearMap = Map<number, Record<Flow, FlowTotals>>;
type LeafMap = Map<string, LeafYearMap>; // key = bereich|gruppe
const leafIndex: LeafMap = new Map();
// Track display names (first observed wins; consistent across files).
const displayNames = new Map<string, { bereich: string; gruppe: string }>();
const overlaps: OverlapNote[] = [];
for (const { key, entries } of buckets.values()) {
const { chosen, overlap } = resolveCell(key, entries);
if (overlap) overlaps.push(overlap);
const leafKey = `${key.bereich}${key.gruppe}`;
if (!displayNames.has(leafKey)) {
displayNames.set(leafKey, { bereich: key.bereich, gruppe: key.gruppe });
}
let yearMap = leafIndex.get(leafKey);
if (!yearMap) {
yearMap = new Map();
leafIndex.set(leafKey, yearMap);
}
let totals = yearMap.get(key.year);
if (!totals) {
totals = emptyYearTotals();
yearMap.set(key.year, totals);
}
if (ERTRAG_SET.has(key.category)) {
addToFlow(totals.ertraege, key.category, chosen.value);
} else if (AUFWAND_SET.has(key.category)) {
addToFlow(totals.aufwendungen, key.category, chosen.value);
}
}
// Group leaves by Produktbereich and aggregate up.
const bereichMap = new Map<
string,
{
name: string;
slug: string;
gruppen: ProduktgruppeNode[];
totals: Record<number, Record<Flow, FlowTotals>>;
}
>();
// Track all years seen (across all leaves) so the slider has a stable axis.
const yearSet = new Set<number>();
for (const [leafKey, yearMap] of leafIndex.entries()) {
const names = displayNames.get(leafKey)!;
const gruppe: ProduktgruppeNode = {
name: names.gruppe,
slug: slugify(names.gruppe),
totals: {},
};
for (const [year, perFlow] of yearMap.entries()) {
gruppe.totals[year] = perFlow;
yearSet.add(year);
}
let bereich = bereichMap.get(names.bereich);
if (!bereich) {
bereich = {
name: names.bereich,
slug: slugify(names.bereich),
gruppen: [],
totals: {},
};
bereichMap.set(names.bereich, bereich);
}
bereich.gruppen.push(gruppe);
// Roll up into the bereich totals.
for (const [year, perFlow] of yearMap.entries()) {
let bTotals = bereich.totals[year];
if (!bTotals) {
bTotals = emptyYearTotals();
bereich.totals[year] = bTotals;
}
for (const flow of ["aufwendungen", "ertraege"] as const) {
bTotals[flow].total += perFlow[flow].total;
for (const [cat, v] of Object.entries(perFlow[flow].byCategory) as Array<
[CategoryKey, number]
>) {
bTotals[flow].byCategory[cat] =
(bTotals[flow].byCategory[cat] ?? 0) + v;
}
}
}
}
// Grand totals across all bereiche.
const grandTotals: Record<number, Record<Flow, FlowTotals>> = {};
for (const bereich of bereichMap.values()) {
for (const [yearStr, perFlow] of Object.entries(bereich.totals)) {
const year = Number(yearStr);
let g = grandTotals[year];
if (!g) {
g = emptyYearTotals();
grandTotals[year] = g;
}
for (const flow of ["aufwendungen", "ertraege"] as const) {
g[flow].total += perFlow[flow].total;
for (const [cat, v] of Object.entries(perFlow[flow].byCategory) as Array<
[CategoryKey, number]
>) {
g[flow].byCategory[cat] = (g[flow].byCategory[cat] ?? 0) + v;
}
}
}
}
const years = [...yearSet].sort((a, b) => a - b);
// Default slider position = latest year present (planning years included).
const defaultYear = years[years.length - 1] ?? new Date().getFullYear();
// Stable sort: Produktbereiche alphabetically by display name.
const produktbereiche: ProduktbereichNode[] = [...bereichMap.values()]
.map((b) => ({
name: b.name,
slug: b.slug,
produktgruppen: b.gruppen.sort((a, b) =>
a.name.localeCompare(b.name, "de")
),
totals: b.totals,
}))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
return {
years,
defaultYear,
produktbereiche,
totals: grandTotals,
overlaps,
};
}
+114
View File
@@ -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;
}
+162
View File
@@ -0,0 +1,162 @@
// The 19 financial line-item columns appearing in the source CSVs.
// `globalerMinderaufwand` exists only in the 2025 plan (23 columns); other
// files are 22 columns. All other categories are present in every file.
//
// Source-CSV column names are preserved verbatim where helpful, including the
// `Leistunngsentgelte` typo and `algemeine` (single-l) drift across files.
export type Flow = "aufwendungen" | "ertraege";
export type ErtragKey =
| "steuern"
| "zuwendungenAllgemeineUmlagen"
| "sonstigeTransfererträge"
| "öffentlichRechtlicheLeistungsentgelte"
| "privatrechtlicheLeistungsentgelte"
| "kostenerstattungenKostenumlagen"
| "sonstigeOrdentlicheErträge"
| "aktivierteEigenleistungen"
| "bestandsveränderungen"
| "finanzerträge"
| "außerordentlicheErträge";
export type AufwandKey =
| "personalaufwendungen"
| "versorgungsaufwendungen"
| "sachUndDienstleistungen"
| "bilanzielleAbschreibungen"
| "transferaufwendungen"
| "sonstigeOrdentlicheAufwendungen"
| "zinsenFinanzaufwendungen"
| "außerordentlicheAufwendungen"
| "globalerMinderaufwand";
export type CategoryKey = ErtragKey | AufwandKey;
export const ERTRAG_KEYS: ReadonlyArray<ErtragKey> = [
"steuern",
"zuwendungenAllgemeineUmlagen",
"sonstigeTransfererträge",
"öffentlichRechtlicheLeistungsentgelte",
"privatrechtlicheLeistungsentgelte",
"kostenerstattungenKostenumlagen",
"sonstigeOrdentlicheErträge",
"aktivierteEigenleistungen",
"bestandsveränderungen",
"finanzerträge",
"außerordentlicheErträge",
];
export const AUFWAND_KEYS: ReadonlyArray<AufwandKey> = [
"personalaufwendungen",
"versorgungsaufwendungen",
"sachUndDienstleistungen",
"bilanzielleAbschreibungen",
"transferaufwendungen",
"sonstigeOrdentlicheAufwendungen",
"zinsenFinanzaufwendungen",
"außerordentlicheAufwendungen",
"globalerMinderaufwand",
];
// Display labels in German (for tooltips and detail panels). Keys match
// CategoryKey; the canonical (non-typo) German names are used here.
export const CATEGORY_LABELS: Record<CategoryKey, string> = {
steuern: "Steuern und ähnliche Abgaben",
zuwendungenAllgemeineUmlagen: "Zuwendungen und allgemeine Umlagen",
sonstigeTransfererträge: "Sonstige Transfererträge",
öffentlichRechtlicheLeistungsentgelte: "Öffentlich-rechtliche Leistungsentgelte",
privatrechtlicheLeistungsentgelte: "Privatrechtliche Leistungsentgelte",
kostenerstattungenKostenumlagen: "Kostenerstattungen und Kostenumlagen",
sonstigeOrdentlicheErträge: "Sonstige ordentliche Erträge",
aktivierteEigenleistungen: "Aktivierte Eigenleistungen",
bestandsveränderungen: "Bestandsveränderungen",
finanzerträge: "Finanzerträge",
außerordentlicheErträge: "Außerordentliche Erträge",
personalaufwendungen: "Personalaufwendungen",
versorgungsaufwendungen: "Versorgungsaufwendungen",
sachUndDienstleistungen: "Aufwendungen für Sach- und Dienstleistungen",
bilanzielleAbschreibungen: "Bilanzielle Abschreibungen",
transferaufwendungen: "Transferaufwendungen",
sonstigeOrdentlicheAufwendungen: "Sonstige ordentliche Aufwendungen",
zinsenFinanzaufwendungen: "Zinsen und sonstige Finanzaufwendungen",
außerordentlicheAufwendungen: "Außerordentliche Aufwendungen",
globalerMinderaufwand: "Globaler Minderaufwand",
};
export type Categories = Partial<Record<CategoryKey, number>>;
// `Gesamt` rows (where Produktgruppe is "Gesamt") are subtotals at the
// Produktbereich level. The single Produktbereich="Gesamt" row is the grand
// total. We retain both but mark them so they can be filtered out of leaf-level
// aggregations.
export type RowKind = "leaf" | "produktbereichTotal" | "grandTotal";
export interface BudgetRow {
produktbereich: string;
produktgruppe: string;
year: number;
kind: RowKind;
// Numeric values, normalized: revenues are now POSITIVE (sign flipped from
// source). Empty source cells become `undefined`, not 0.
categories: Categories;
// Provenance: which CSV file contributed this row. Used to resolve overlaps
// between plan years.
source: SourceFile;
}
export interface SourceFile {
/** Filename relative to data/ (e.g., "2025/HH_Plan_Muenster_25-28.csv"). */
path: string;
/** Plan year of the file. For Jahresabschluss this is the file's max year. */
planYear: number;
/** Whether the file contains actuals (Jahresabschluss) or planning values. */
kind: "actual" | "plan";
}
// Aggregated per-year, per-flow totals across the leaves of one node.
export interface FlowTotals {
/** Sum across all categories belonging to the flow, in absolute euros. */
total: number;
/** Per-category breakdown. Missing categories omitted. */
byCategory: Categories;
}
// The shape consumed by the page and treemap components.
export interface BudgetTree {
/** Sorted list of years for which we have data. */
years: number[];
/** Year of the latest plan (default slider position). */
defaultYear: number;
/** Top-level: Produktbereich → Produktgruppe → year → flow totals. */
produktbereiche: ProduktbereichNode[];
/** Across all Produktbereiche, per-year, per-flow grand totals. */
totals: Record<number, Record<Flow, FlowTotals>>;
/** Conflicts where two source files disagreed by >5% on the same cell. */
overlaps: OverlapNote[];
}
export interface ProduktbereichNode {
name: string;
/** Stable URL slug derived from name. */
slug: string;
produktgruppen: ProduktgruppeNode[];
/** Per-year, per-flow totals aggregated from leaf produktgruppen. */
totals: Record<number, Record<Flow, FlowTotals>>;
}
export interface ProduktgruppeNode {
name: string;
slug: string;
/** Per-year, per-flow totals from the source row(s) for this leaf. */
totals: Record<number, Record<Flow, FlowTotals>>;
}
export interface OverlapNote {
produktbereich: string;
produktgruppe: string;
year: number;
category: CategoryKey;
values: { source: string; value: number }[];
divergencePct: number;
}
+136
View File
@@ -0,0 +1,136 @@
// 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;
}
+65
View File
@@ -0,0 +1,65 @@
// OKLCH-based tile color computation for the treemap.
//
// Each flow has its own hue family (set via CSS custom properties in
// global.css). Within a flow, tile lightness/chroma encodes the share-of-
// parent so larger tiles read as more saturated and smaller tiles fade
// toward the paper background. This matches the brief's two-hue decision:
// tile lightness within a flow encodes value; the hue itself signals which
// flow we're in.
import type { Flow } from "../data/types.js";
interface FlowPalette {
/** OKLCH hue, degrees. */
h: number;
/** OKLCH chroma at the darkest end of the scale. */
cMax: number;
}
// Must mirror the CSS custom properties in src/styles/global.css. Keep
// the two definitions in sync — the JS values drive the SVG fills, the
// CSS values drive page chrome (toggle underline, Saldo color, etc.).
const PALETTE: Record<Flow, FlowPalette> = {
aufwendungen: { h: 295, cMax: 0.17 }, // deep plum-purple
ertraege: { h: 55, cMax: 0.16 }, // warm orange / amber
};
interface TileColorOptions {
/** Share of the parent total for this tile, in [0, 1]. */
share: number;
/** Which flow we're rendering. */
flow: Flow;
}
/**
* Compute fill + label color for a treemap tile.
*
* Lightness scale is non-linear: most tiles fall in a middle band so the
* legibility of labels stays manageable, only the very largest tiles go
* really dark. We use a cube-root mapping which compresses the long tail
* of small Produktgruppen.
*/
export function tileColor({ share, flow }: TileColorOptions): {
fill: string;
label: string;
} {
const { h, cMax } = PALETTE[flow];
// Cube-root maps a long-tail distribution into a more even one.
const t = Math.cbrt(Math.max(0, Math.min(1, share)));
// Lightness from 78% (smallest) down to 38% (largest). Above 78% the
// tile vanishes against the paper; below 38% we lose label contrast.
const L = 78 - t * 40;
// Chroma scales with t too, but shallower — small tiles still feel
// tinted, not gray.
const C = cMax * (0.45 + 0.55 * t);
const fill = `oklch(${L.toFixed(1)}% ${C.toFixed(3)} ${h})`;
// Switch label color when the tile is dark enough to need light text.
const label =
L > 56
? `oklch(20% 0.02 ${h})`
: `oklch(96% 0.01 ${h})`;
return { fill, label };
}
+77
View File
@@ -0,0 +1,77 @@
// German-locale number formatting helpers. Used everywhere a euro figure
// appears so the spelling and rounding stay consistent.
const fmtFull = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
});
const fmtTwoSig = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
const fmtPlainInt = new Intl.NumberFormat("de-DE", {
maximumFractionDigits: 0,
});
const fmtPercent = new Intl.NumberFormat("de-DE", {
style: "percent",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
const fmtPercentSigned = new Intl.NumberFormat("de-DE", {
style: "percent",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
signDisplay: "exceptZero",
});
/** Full-euro formatting: "1.234.567 €". For the detail panel. */
export function fmtEuroFull(n: number): string {
return fmtFull.format(n);
}
/**
* Compact, headline-grade euro formatting:
* < 1 Mio. → "754.300 €" (full form)
* ≥ 1 Mio. → "12,3 Mio. €"
* ≥ 1 Mrd. → "1,7 Mrd. €"
*
* The dropoff to full-form below 1 Mio. avoids "0,8 Mio. €" which reads
* worse than the precise number.
*/
export function fmtEuroCompact(n: number): string {
const abs = Math.abs(n);
const sign = n < 0 ? "" : "";
if (abs >= 1_000_000_000) {
return `${sign}${fmtTwoSig.format(abs / 1_000_000_000)} Mrd. €`;
}
if (abs >= 1_000_000) {
return `${sign}${fmtTwoSig.format(abs / 1_000_000)} Mio. €`;
}
return `${sign}${fmtPlainInt.format(abs)} €`;
}
/**
* Same as fmtEuroCompact but ALWAYS shows the sign — "+" for positive,
* "" for negative, "±" for zero. Use for signed deltas like Saldo
* where the sign carries semantic meaning.
*/
export function fmtEuroCompactSigned(n: number): string {
if (n === 0) return `±${fmtEuroCompact(0)}`;
const prefix = n > 0 ? "+" : "";
return `${prefix}${fmtEuroCompact(n)}`;
}
/** Share-of-something formatting: "24 %". */
export function fmtPercentage(fraction: number): string {
return fmtPercent.format(fraction);
}
/** Signed share for delta displays: "+1,3 %", "4,2 %". */
export function fmtDelta(fraction: number): string {
return fmtPercentSigned.format(fraction);
}
+406
View File
@@ -0,0 +1,406 @@
// Build-time icicle (partition) layout.
//
// Three levels of hierarchy:
// Produktbereich → Produktgruppe → Category (financial line item)
// Column widths are non-uniform: the third column is intentionally
// narrower (a thin "fine print" stripe) and rendered without labels.
//
// d3.partition lays this out as nested rectangles where each level
// occupies its own column (horizontal orientation: column = depth, row =
// share within parent). At any given depth, sibling rectangles stack
// along the share axis with sizes proportional to value. Coordinates
// are returned in viewBox units so the Astro component renders without
// further math.
import { hierarchy, partition as d3partition } from "d3-hierarchy";
import type { HierarchyRectangularNode } from "d3-hierarchy";
import type { BudgetTree, CategoryKey, Flow } from "../data/types.js";
import {
AUFWAND_KEYS,
CATEGORY_LABELS,
ERTRAG_KEYS,
} from "../data/types.js";
import type { BreakdownsByPath } from "./breakdowns.js";
// viewBox dimensions; the page's CSS aspect-ratio matches them so
// preserveAspectRatio="none" doesn't actually distort. The 4:5 ratio
// gives bars enough vertical room with 17 Bereiche.
export const VB_W = 1600;
export const VB_H = 2000;
/** Depth values used by Icicle.astro to switch styling per level. */
export const DEPTH_BEREICH = 1;
export const DEPTH_GRUPPE = 2;
export const DEPTH_CATEGORY = 3;
export const DEPTH_BREAKDOWN = 4;
/** Column boundaries on the depth axis (viewBox x). depth d ∈ {1..4}
* occupies x in [COL_X[d-1], COL_X[d]]. At the OVERVIEW state the
* depth-4 (Breakdown) column collapses to zero width so the
* fine-print stays hidden — depth-4 only becomes visible once the
* user zooms in (see ZOOM_COL_BOUNDS in Icicle.astro for the
* per-zoom-state widths). */
export const COL_X = [0, 770, 1540, VB_W, VB_W];
export interface IcicleNode {
/** 1 = Bereich, 2 = Gruppe, 3 = Category, 4 = Breakdown item. */
depth: number;
/** Display name. */
name: string;
/** Stable URL slug. */
slug: string;
/** Path of slugs from root, joined by "/", suitable for URLs and keys. */
path: string;
value: number;
/** Share of the parent total, in [0, 1]. */
shareOfParent: number;
/** Share of the grand total, in [0, 1]. */
shareOfGrand: number;
/** Rectangle in viewBox coordinates (horizontal orientation:
* x is the depth axis, y is the share axis). */
x0: number;
y0: number;
x1: number;
y1: number;
/** Parent slug path; empty for top-level nodes. */
parentPath: string;
/** True when this node has no children in the rendered tree. Used
* by the component to decide click-to-zoom eligibility — a depth-3
* Category is a leaf only when it has no extracted breakdowns. */
isLeaf: boolean;
}
export interface IcicleLayout {
flow: Flow;
year: number;
total: number;
nodes: IcicleNode[];
/** How many depth levels the layout actually contains (max 3). */
maxDepth: number;
}
interface InputDatum {
name: string;
slug: string;
value?: number;
children?: InputDatum[];
}
function categoriesFor(flow: Flow): readonly CategoryKey[] {
return flow === "aufwendungen" ? AUFWAND_KEYS : ERTRAG_KEYS;
}
/**
* Build the input hierarchy and run d3.partition.
*
* Three or four levels:
* Bereich → Gruppe → Category (default)
* Bereich → Gruppe → Category → Breakdown (when extracted breakdown
* data exists for that flow/bereich/gruppe/category cell AND the
* selected year has at least one non-zero breakdown value)
*
* Empty branches are pruned. Only leaves carry numeric values; sums
* propagate upward via .sum().
*/
export function icicleLayout(
tree: BudgetTree,
flow: Flow,
year: number,
breakdowns: BreakdownsByPath = {}
): IcicleLayout {
const flowCategories = categoriesFor(flow);
const inputBereiche: InputDatum[] = [];
for (const bereich of tree.produktbereiche) {
const gruppen: InputDatum[] = [];
for (const g of bereich.produktgruppen) {
const flowTotals = g.totals[year]?.[flow];
if (!flowTotals || flowTotals.total <= 0) continue;
const categoryChildren: InputDatum[] = [];
for (const catKey of flowCategories) {
const v = flowTotals.byCategory[catKey] ?? 0;
if (v <= 0) continue;
// If breakdown data exists for this cell AND covers the year
// with non-zero values, attach the breakdown items as the
// category's children. Otherwise the category stays a leaf
// with its CSV-derived value.
const breakdownKey = `${flow}/${bereich.slug}/${g.slug}/${catKey}`;
const items = breakdowns[breakdownKey];
const breakdownChildren: InputDatum[] = [];
if (items && items.length > 0) {
for (const item of items) {
const iv = item.values[year] ?? 0;
if (iv > 0) {
breakdownChildren.push({
name: item.name,
slug: item.slug,
value: iv,
});
}
}
}
if (breakdownChildren.length > 0) {
categoryChildren.push({
name: CATEGORY_LABELS[catKey],
slug: catKey,
children: breakdownChildren,
});
} else {
categoryChildren.push({
name: CATEGORY_LABELS[catKey],
slug: catKey,
value: v,
});
}
}
if (categoryChildren.length === 0) continue;
gruppen.push({
name: g.name,
slug: g.slug,
children: categoryChildren,
});
}
if (gruppen.length === 0) continue;
inputBereiche.push({
name: bereich.name,
slug: bereich.slug,
children: gruppen,
});
}
const root = hierarchy<InputDatum>({
name: "Gesamt",
slug: "",
children: inputBereiche,
})
.sum((d) => d.value ?? 0)
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
const grandTotal = root.value ?? 0;
// partition.size([width, height]) — d3.partition computes x in [0, dx]
// for the SHARE axis and divides y in [0, dy] equally across depth+1
// levels. We pass dx = VB_H so the share axis fits our viewBox height,
// then in the output we override the depth-axis coordinates ourselves
// (otherwise the synthetic root steals one column of width).
const layoutRoot = d3partition<InputDatum>()
.size([VB_H, VB_W])
.padding(0)(root) as HierarchyRectangularNode<InputDatum>;
const nodes: IcicleNode[] = [];
let maxDepth = 0;
for (const node of layoutRoot.descendants()) {
if (node.depth === 0) continue; // synthetic root
if (node.depth > maxDepth) maxDepth = node.depth;
const value = node.value ?? 0;
const parentValue = node.parent?.value ?? grandTotal;
const ancestorSlugs = node
.ancestors()
.reverse()
.slice(1) // drop root
.map((a) => a.data.slug);
const path = ancestorSlugs.join("/");
const parentPath = ancestorSlugs.slice(0, -1).join("/");
// x (depth axis) — explicit per-depth column boundaries (Bereich and
// Gruppe wide, Category narrow).
// y (share axis) — partition's x0/x1 output (we passed size with
// dx = VB_H, so share already runs [0, VB_H]).
const xCol0 = COL_X[node.depth - 1] ?? 0;
const xCol1 = COL_X[node.depth] ?? VB_W;
nodes.push({
depth: node.depth,
name: node.data.name,
slug: node.data.slug,
path,
value,
shareOfParent: parentValue > 0 ? value / parentValue : 0,
shareOfGrand: grandTotal > 0 ? value / grandTotal : 0,
x0: xCol0,
y0: node.x0,
x1: xCol1,
y1: node.x1,
parentPath,
isLeaf: !node.children || node.children.length === 0,
});
}
return {
flow,
year,
total: grandTotal,
nodes,
maxDepth,
};
}
// ── Multi-year ──────────────────────────────────────────────────────
/** Per-path per-year arrays. Indexed positionally by years[i]. */
export interface PathSeries {
/** Numeric value (positive €), 0 if path didn't exist that year. */
v: number[];
/** Layout y0 in viewBox units, 0 if missing. */
y0: number[];
/** Layout y1 in viewBox units, 0 if missing (collapsed bar). */
y1: number[];
}
export interface MultiYearLayout {
flow: Flow;
years: number[];
defaultYear: number;
/** Year with the largest total budget (defines the canvas height). */
maxYear: number;
/** Largest year's total (positive €). */
maxTotal: number;
/** Per-year totals, indexed positionally by years[i]. */
totals: number[];
/** Per-path metadata + per-year series (only paths that exist in
* some year). */
paths: Record<
string,
{
name: string;
depth: number;
parentPath: string;
x0: number;
x1: number;
series: PathSeries;
}
>;
/** The default-year layout, used for the initial server-rendered
* SVG (so the page is fully visible without JS). */
initial: IcicleLayout;
}
/**
* Compute icicle layouts for every year in the tree, plus a per-path
* time series. Used by the year-slider to morph bars between years.
*/
export function multiYearLayout(
tree: BudgetTree,
flow: Flow,
defaultYear: number,
breakdowns: BreakdownsByPath = {}
): MultiYearLayout {
const years = [...tree.years].sort((a, b) => a - b);
// Build per-year layouts and index them by path for fast lookup.
const layoutsByYear = new Map<number, IcicleLayout>();
type IcicleNodeLite = IcicleLayout["nodes"][number];
const idxByYear = new Map<number, Map<string, IcicleNodeLite>>();
for (const year of years) {
const layout = icicleLayout(tree, flow, year, breakdowns);
layoutsByYear.set(year, layout);
const idx = new Map<string, IcicleNodeLite>();
for (const node of layout.nodes) idx.set(node.path, node);
idxByYear.set(year, idx);
}
const totals = years.map((y) => layoutsByYear.get(y)?.total ?? 0);
let maxTotal = 0;
let maxYear = years[0] ?? defaultYear;
for (let i = 0; i < years.length; i++) {
const t = totals[i] ?? 0;
if (t > maxTotal) {
maxTotal = t;
maxYear = years[i]!;
}
}
// Union of all paths across all years.
const allPaths = new Set<string>();
for (const layout of layoutsByYear.values()) {
for (const node of layout.nodes) allPaths.add(node.path);
}
// Build per-path series. For years where a path didn't exist, fall
// back to {v: 0, y0: 0, y1: 0} so the bar renders collapsed (zero
// height) and animates back when the path reappears.
const paths: MultiYearLayout["paths"] = {};
for (const path of allPaths) {
let meta: {
name: string;
depth: number;
parentPath: string;
x0: number;
x1: number;
} | null = null;
const v: number[] = [];
const y0: number[] = [];
const y1: number[] = [];
for (const year of years) {
const node = idxByYear.get(year)?.get(path);
if (node) {
if (!meta) {
meta = {
name: node.name,
depth: node.depth,
parentPath: node.parentPath,
x0: node.x0,
x1: node.x1,
};
}
v.push(node.value);
y0.push(node.y0);
y1.push(node.y1);
} else {
v.push(0);
y0.push(0);
y1.push(0);
}
}
if (!meta) continue;
paths[path] = { ...meta, series: { v, y0, y1 } };
}
return {
flow,
years,
defaultYear,
maxYear,
maxTotal,
totals,
paths,
initial: layoutsByYear.get(defaultYear) ?? layoutsByYear.get(years[0]!)!,
};
}
/** Both flows' multi-year layouts, combined for the icicle component
* to render side-by-side and toggle between at runtime. */
export interface BothFlowsLayout {
years: number[];
defaultYear: number;
defaultFlow: Flow;
byFlow: Record<Flow, MultiYearLayout>;
/** Max total across BOTH flows × all years; the canvas reserves
* this height as the global max-budget reference. */
maxTotalAcrossBothFlows: number;
}
export function multiYearLayoutBothFlows(
tree: BudgetTree,
defaultYear: number,
defaultFlow: Flow = "aufwendungen",
breakdowns: BreakdownsByPath = {}
): BothFlowsLayout {
const aufw = multiYearLayout(tree, "aufwendungen", defaultYear, breakdowns);
const ertr = multiYearLayout(tree, "ertraege", defaultYear, breakdowns);
return {
years: aufw.years,
defaultYear,
defaultFlow,
byFlow: { aufwendungen: aufw, ertraege: ertr },
maxTotalAcrossBothFlows: Math.max(aufw.maxTotal, ertr.maxTotal),
};
}
+124
View File
@@ -0,0 +1,124 @@
// Match Produktgruppe names from the open-data CSV (which uses
// abbreviations like "Erzieh.u.wirtschaftl.Hilfen für Familien")
// against the names from the extracted PDF sections (which use the
// full forms like "Erzieherische und wirtschaftliche Hilfen für
// Familien"), and produce a path-keyed map of {beschreibung,
// erlaeuterungen} for the sidebar to consume.
import type { BudgetTree } from "../data/types.js";
export interface PgSection {
pgNumber: string;
name: string;
beschreibung: string | null;
erlaeuterungen: string | null;
}
export type PgSections = Record<string, PgSection>;
export interface NotesByPath {
[path: string]: {
beschreibung: string | null;
erlaeuterungen: string | null;
};
}
/** Lowercase + strip German diacritics + drop non-alphanumeric chars. */
function normalize(s: string): string {
const lower = s.toLowerCase();
const transliterated = lower
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss");
return transliterated.replace(/[^a-z0-9]/g, "");
}
/** Plain Levenshtein distance, iterative DP. Small inputs; we don't
* bother optimizing. */
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
let prev = new Array(b.length + 1);
let curr = new Array(b.length + 1);
for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i = 1; i <= a.length; i++) {
curr[0] = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(
curr[j - 1] + 1,
prev[j] + 1,
prev[j - 1] + cost
);
}
[prev, curr] = [curr, prev];
}
return prev[b.length] ?? 0;
}
/**
* Given the budget tree and the extracted PG sections JSON, build a
* path-keyed lookup that the icicle component can embed in the page.
*
* Matching strategy:
* 1. Exact normalized name match (most CSV/PDF pairs match this way).
* 2. Fallback to Levenshtein with a length-normalized threshold,
* picking the closest unique candidate per name.
*
* Anything still unmatched is left out — the sidebar simply won't show
* a Beschreibung/Erläuterungen for those paths.
*/
export function buildNotesByPath(
tree: BudgetTree,
pgSections: PgSections
): NotesByPath {
const candidates = Object.values(pgSections).map((e) => ({
pg: e.pgNumber,
name: e.name,
norm: normalize(e.name),
section: e,
}));
const out: NotesByPath = {};
for (const bereich of tree.produktbereiche) {
for (const gruppe of bereich.produktgruppen) {
const target = normalize(gruppe.name);
let chosen: (typeof candidates)[number] | null = null;
// 1. Exact normalized match.
for (const c of candidates) {
if (c.norm === target) {
chosen = c;
break;
}
}
// 2. Fuzzy fallback: pick the closest by Levenshtein, accept if
// the relative edit distance is under 0.4 (i.e., >60% similar).
if (!chosen) {
let best: { c: (typeof candidates)[number]; ratio: number } | null =
null;
for (const c of candidates) {
const dist = levenshtein(target, c.norm);
const ratio = dist / Math.max(target.length, c.norm.length, 1);
if (best === null || ratio < best.ratio) {
best = { c, ratio };
}
}
if (best && best.ratio < 0.4) chosen = best.c;
}
if (!chosen) continue;
const path = `${bereich.slug}/${gruppe.slug}`;
out[path] = {
beschreibung: chosen.section.beschreibung,
erlaeuterungen: chosen.section.erlaeuterungen,
};
}
}
return out;
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
/* ──────────────────────────────────────────────────────────────────────────
ms-haushalt · global tokens, reset, and base typography.
See .impeccable.md for the design context this expresses.
────────────────────────────────────────────────────────────────────────── */
@import "@fontsource-variable/geist";
@import "@fontsource-variable/geist-mono";
/* Figtree Variable — display + body face. SIL OFL, served from
public/fonts. Roman + italic axes, weight 300900. */
@font-face {
font-family: "Figtree Variable";
src: url("/fonts/Figtree-VariableFont_wght.woff2") format("woff2-variations");
font-weight: 300 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Figtree Variable";
src: url("/fonts/Figtree-Italic-VariableFont_wght.woff2") format("woff2-variations");
font-weight: 300 900;
font-style: italic;
font-display: swap;
}
:root {
/* ── Color (OKLCH, paper-feeling, two-hue flow palette) ───────────── */
/* Background: warm uncoated stock, never pure white. Tinted toward the
Aufwendungen hue family by 0.005-0.01 chroma per the impeccable
"tint neutrals toward brand hue" principle. */
--paper: oklch(98.4% 0.006 60);
--paper-deep: oklch(96.0% 0.008 60);
/* Ink: warmed near-black. Same principle inverted. */
--ink: oklch(20% 0.02 50);
--ink-soft: oklch(38% 0.02 55);
--ink-mute: oklch(58% 0.015 60);
/* Hairlines & faint surfaces. */
--rule: oklch(86% 0.012 60);
--rule-soft: oklch(92% 0.008 60);
/* Two-hue flow palette per design brief §9.5: deep purple for
Aufwendungen (money leaving), orange for Erträge (money coming in).
Far enough apart on the OKLCH wheel to read as a clear pivot; both
hold their own against the warm-paper background. NOT red/green. */
--flow-aufwand-h: 295; /* deep plum-purple */
--flow-aufwand-c: 0.17;
--flow-ertrag-h: 55; /* warm orange / amber */
--flow-ertrag-c: 0.16;
/* Solid ink-on-paper renders of each flow accent (used for active toggle
state, breadcrumb separators, slider handle, etc.). */
--flow-aufwand: oklch(40% var(--flow-aufwand-c) var(--flow-aufwand-h));
--flow-ertrag: oklch(58% var(--flow-ertrag-c) var(--flow-ertrag-h));
/* ── Spacing (4pt scale, semantic names) ──────────────────────────── */
--space-2xs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--space-3xl: 64px;
--space-4xl: 96px;
/* ── Type scale ───────────────────────────────────────────────────── */
/* Display sizes are fluid (editorial, not app-chrome), per the
typography rule. Body and UI sizes are fixed rem. The display
range is intentionally tighter than the previous version — the
hero needs to stay above the fold on a typical 13" laptop. */
--size-display-1: clamp(2.625rem, 5.5vw + 1rem, 5.25rem); /* hero stack */
--size-display-2: clamp(1.75rem, 2.5vw + 0.5rem, 2.5rem); /* sidebar heading */
--size-display-3: 1.375rem; /* secondary */
--size-body: 1rem; /* 16px */
--size-body-lg: 1.0625rem;/* 17px lede */
--size-caption: 0.8125rem;/* 13px */
--size-eyebrow: 0.6875rem;/* 11px small caps */
--size-micro: 0.75rem; /* 12px sidebar dt labels */
/* ── Faces ────────────────────────────────────────────────────────── */
/* Display + body: Figtree (variable, SIL OFL, served from
public/fonts). Numbers: Geist Mono. The `--face-serif` /
`--face-sans` token names are legacy aliases — both now point to
Figtree since it carries display and body alike. */
--face-display: "Figtree Variable", -apple-system, BlinkMacSystemFont,
"Segoe UI", system-ui, sans-serif;
--face-serif: var(--face-display);
--face-sans: var(--face-display);
--face-mono: "Geist Mono Variable", "Geist Variable", ui-monospace,
"SF Mono", "Menlo", monospace;
/* ── Layout rhythm ────────────────────────────────────────────────── */
--measure: 65ch;
--page-max: 1280px;
--page-pad-x: clamp(1rem, 4vw, 3rem);
}
/* ── Reset ──────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
font-feature-settings:
"kern" 1,
"liga" 1,
"calt" 1,
"ss01" 1;
/* German-specific OpenType: lining figures for tabular data, oldstyle
elsewhere. Enabled per-element where useful. */
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--face-serif);
font-size: var(--size-body);
line-height: 1.55;
font-feature-settings:
"kern" 1,
"liga" 1,
"calt" 1;
/* Slight optical correction for warmly-tinted paper background. */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
p {
margin: 0 0 var(--space-md);
max-width: var(--measure);
}
h1, h2, h3, h4 {
margin: 0;
font-family: var(--face-serif);
font-weight: 800;
line-height: 0.95;
letter-spacing: -0.01em;
font-variant-ligatures: discretionary-ligatures;
}
a {
color: inherit;
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
/* ── Utility patterns we use repeatedly ─────────────────────────────── */
.eyebrow {
font-family: var(--face-sans);
font-size: var(--size-eyebrow);
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-mute);
}
.caption {
font-family: var(--face-sans);
font-size: var(--size-caption);
color: var(--ink-soft);
line-height: 1.4;
}
.tabular {
font-family: var(--face-mono);
font-variant-numeric: tabular-nums lining-nums;
font-feature-settings: "tnum" 1, "lnum" 1;
letter-spacing: -0.01em;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}