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