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:
File diff suppressed because it is too large
Load Diff
@@ -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)}`);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 300–900. */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user