Files
ms-haushalt/src/pages/index.astro
T
FloandClaude 9a958c0051 feat: initial Münster Haushalt icicle viewer
Editorial single-page viewer for the City of Münster's 2026/2027
budget draft, built as an Astro v6 SPA with a 4-level zoomable
icicle (Produktbereich → Produktgruppe → Category → Breakdown).

Highlights:
- Multi-flow data layer over the official open-data CSVs
  (Aufwendungen + Erträge, 2008–2028) with overlap reconciliation
  across plan years.
- Year slider as a 21-year mini-histogram of both flows;
  drag-to-scrub and click-to-jump, with bars morphing via CSS
  transitions on SVG geometry attributes.
- Vertically centred icicle with year-outline rectangles framing
  each year's relative budget size, à la Bostock's animated treemap.
- Headline "ausgibt / einnimmt" toggle; sidebar Aufwendungen/Erträge
  rows double as flow toggles. Active flow in Aufwendungen-purple /
  Erträge-orange (OKLCH).
- Click-to-zoom via path-keyed lookup with ZOOM_COL_BOUNDS that
  reallocate the depth axis per zoom state. Zoomed item moves to the
  sidebar; canvas shows its descendants only (no adjacent-block leaks).
- Sidebar shows path-specific Aufwendungen/Erträge/Saldo plus the
  source-PDF Beschreibung; Erläuterungen behind a collapsed details.
- Build-time PDF extraction (scripts/extract-pg-sections.mjs) parses
  68 Produktgruppen' Beschreibung + Erläuterungen sections from
  Band 1, including 10 cells of structured Mio.-€ breakdowns
  (Steuern, Transferaufwendungen, etc.) that drive the level-4 view.
- URL state sync for path, year, and flow via history.replaceState
  so any zoom is shareable.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 16:27:45 +02:00

1551 lines
64 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import "../styles/global.css";
import { loadBudget } from "../data/load.ts";
import { multiYearLayoutBothFlows } from "../lib/icicle.ts";
import { fmtEuroCompact, fmtEuroCompactSigned } from "../lib/format.ts";
import Icicle from "../components/Icicle.astro";
import type { Flow } from "../data/types.ts";
import { buildNotesByPath, type PgSections } from "../lib/pg-notes.ts";
import { buildBreakdownsByPath } from "../lib/breakdowns.ts";
import pgSectionsRaw from "../../data/extracted/pg-sections-2026.json";
const tree = await loadBudget();
const pgNotesByPath = buildNotesByPath(tree, pgSectionsRaw as PgSections);
const breakdownsByPath = buildBreakdownsByPath(
tree,
pgSectionsRaw as Parameters<typeof buildBreakdownsByPath>[1],
);
// Default scene per the brief §5: Aufwendungen selected, year = the new
// draft (2026). Latest data point is 2028 but the *editorial* default is
// the current draft year, which is the news.
const defaultFlow: Flow = "aufwendungen";
const defaultYear = 2026;
const bothFlows = multiYearLayoutBothFlows(
tree,
defaultYear,
defaultFlow,
breakdownsByPath,
);
// Aufwendungen layout is the active one at SSR (default flow); both
// flows render via the Icicle component, but the year slider and the
// initial overview totals key off this one.
const multiYear = bothFlows.byFlow.aufwendungen;
// Year-slider data: each year's two-bar histogram (Aufwendungen +
// Erträge), normalized to the larger of the two flows' max year totals
// so the heights are directly comparable across flows.
const maxErtrag = multiYear.years.reduce(
(m, y) => Math.max(m, tree.totals[y]?.ertraege.total ?? 0),
0,
);
const sliderMax = Math.max(multiYear.maxTotal, maxErtrag);
const defaultYearIdx = multiYear.years.indexOf(defaultYear);
// Per-year overview data — drives the sidebar's overview card content
// when the user scrubs through years. Includes both flows' totals plus
// the largest Bereich for each year (for the "Größter Posten" line).
type OverviewEntry = {
year: number;
aufwand: number;
ertrag: number;
saldo: number;
largestName: string;
largestValue: number;
largestShare: number;
};
const yearOverviews: OverviewEntry[] = multiYear.years.map((year) => {
const aufwand = tree.totals[year]?.aufwendungen.total ?? 0;
const ertrag = tree.totals[year]?.ertraege.total ?? 0;
const saldo = ertrag - aufwand;
let largestName = "";
let largestValue = 0;
for (const b of tree.produktbereiche) {
const v = b.totals[year]?.aufwendungen.total ?? 0;
if (v > largestValue) {
largestValue = v;
largestName = b.name;
}
}
return {
year,
aufwand,
ertrag,
saldo,
largestName,
largestValue,
largestShare: aufwand > 0 ? largestValue / aufwand : 0,
};
});
const totals = tree.totals[defaultYear];
const ertragTotal = totals?.ertraege.total ?? 0;
const aufwandTotal = totals?.aufwendungen.total ?? 0;
const saldo = ertragTotal - aufwandTotal;
const yearStart = tree.years[0]!;
const yearEnd = tree.years.at(-1)!;
---
<html lang="de" data-flow-state="aufwendungen">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>
Wo die Stadt Münster ihr Geld ausgibt — Haushaltsentwurf 2026/2027
</title>
<meta
name="description"
content="Der Haushaltsentwurf 2026/2027 der Stadt Münster, interaktiv erkundet als Treemap der Aufwendungen und Erträge."
/>
</head>
<body>
<main class="page">
<header class="hero">
<p class="eyebrow">
Stadt Münster · Haushaltsentwurf 2026/2027
</p>
<h1 class="hero-title">
<span>Wo die Stadt Münster</span>
<span>ihr Geld</span>
<span
class="hero-toggle-line"
role="group"
aria-label="Geldfluss wählen"
>
<button
class="hero-toggle-option"
type="button"
data-flow="ertraege"
aria-pressed="false"
id="flow-toggle-ertrag"
>
einnimmt
</button>
<span class="hero-toggle-sep" aria-hidden="true">/</span
>
<button
class="hero-toggle-option"
type="button"
data-flow="aufwendungen"
aria-pressed="true"
id="flow-toggle-aufwand"
>
ausgibt
</button>
<span class="hero-period">.</span>
</span>
</h1>
</header>
<section
class="timeline"
aria-label="Zeitachse"
id="year-slider"
data-year={defaultYear}
data-year-index={defaultYearIdx}
>
<p class="eyebrow timeline-eyebrow">
Haushalt 20082028 · Gesamtvolumen pro Jahr
</p>
<div
class="timeline-bars"
role="slider"
tabindex="0"
aria-valuemin={yearStart}
aria-valuemax={yearEnd}
aria-valuenow={defaultYear}
>
{
multiYear.years.map((y, i) => {
const aufwand = multiYear.totals[i] ?? 0;
const ertrag = tree.totals[y]?.ertraege.total ?? 0;
const aufwandRatio =
sliderMax > 0 ? aufwand / sliderMax : 0;
const ertragRatio =
sliderMax > 0 ? ertrag / sliderMax : 0;
const isCurrent = y === defaultYear;
const isPast = !(y > 2024);
return (
<button
type="button"
class:list={[
"timeline-bar",
{
"is-current": isCurrent,
"is-actual": isPast,
},
]}
data-year={y}
data-year-index={i}
aria-label={`Jahr ${y} · Aufwendungen ${fmtEuroCompact(aufwand)} · Erträge ${fmtEuroCompact(ertrag)}`}
>
<span class="timeline-bar-flows">
<span
class="timeline-bar-flow timeline-bar-ertrag"
style={`height: ${(ertragRatio * 100).toFixed(2)}%;`}
/>
<span
class="timeline-bar-flow timeline-bar-aufwand"
style={`height: ${(aufwandRatio * 100).toFixed(2)}%;`}
/>
</span>
<span class="timeline-bar-label">{y}</span>
</button>
);
})
}
</div>
</section>
<section class="spread">
<aside
class="sidebar"
id="sidebar"
data-state="overview"
aria-label="Detailansicht"
>
{
/*
Sidebar content is hot-swapped client-side based on which
node is currently zoomed. Two markup blocks live here: the
"overview" block (visible by default) and the "node" block
(visible when zoomed). The script toggles which one shows
via the data-state attribute on this aside.
*/
}
<div class="sb-block sb-overview">
<h2 class="sb-heading">Gesamthaushalt</h2>
<dl class="totals">
<div
class="total flow-target"
data-flow-target="aufwendungen"
role="button"
tabindex="0"
aria-label="Aufwendungen anzeigen"
>
<dt>Aufwendungen</dt>
<dd class="tabular" id="ov-aufwand">
{fmtEuroCompact(aufwandTotal)}
</dd>
</div>
<div
class="total flow-target"
data-flow-target="ertraege"
role="button"
tabindex="0"
aria-label="Erträge anzeigen"
>
<dt>Erträge</dt>
<dd class="tabular" id="ov-ertrag">
{fmtEuroCompact(ertragTotal)}
</dd>
</div>
<div class="total total-saldo">
<dt>Saldo</dt>
<dd
class:list={[
"tabular",
{ "is-negative": saldo < 0 },
]}
id="ov-saldo"
>
{fmtEuroCompactSigned(saldo)}
</dd>
</div>
</dl>
<p class="lede" id="ov-lede">
Der Entwurf für 2026/2027 ist der erste Haushalt
unter Oberbürgermeister Tilman Fuchs. Er sieht für
2026 Aufwendungen von
<strong class="tabular" id="ov-lede-aufwand"
>{fmtEuroCompact(aufwandTotal)}</strong
>
vor — etwa
<strong class="tabular" id="ov-lede-saldo"
>{fmtEuroCompact(saldo)}</strong
>
mehr als die erwarteten Erträge. Klicken Sie auf einen
Block rechts, um in den Bereich hineinzuzoomen.
</p>
</div>
<script
is:inline
type="application/json"
id="overview-data"
set:html={JSON.stringify({
defaultYear,
years: yearOverviews,
})}
/>
<script
is:inline
type="application/json"
id="pg-notes"
set:html={JSON.stringify(pgNotesByPath)}
/>
<div class="sb-block sb-node">
<nav
class="sb-breadcrumb"
id="sb-breadcrumb"
aria-label="Hierarchie"
>
</nav>
<h2 class="sb-heading sb-name" id="sb-name"></h2>
{
/*
Same totals shape as the overview block — Aufwendungen,
Erträge, Saldo — but for the currently selected node.
Bereich and Gruppe paths exist in both flows, so we can
show both sides; if a node is present on only one flow
the missing rows show "—".
*/
}
<dl class="totals">
<div
class="total flow-target"
data-flow-target="aufwendungen"
role="button"
tabindex="0"
aria-label="Aufwendungen anzeigen"
>
<dt>Aufwendungen</dt>
<dd class="tabular" id="sb-aufwand"></dd>
</div>
<div
class="total flow-target"
data-flow-target="ertraege"
role="button"
tabindex="0"
aria-label="Erträge anzeigen"
>
<dt>Erträge</dt>
<dd class="tabular" id="sb-ertrag"></dd>
</div>
<div class="total total-saldo">
<dt>Saldo</dt>
<dd class="tabular" id="sb-saldo"></dd>
</div>
</dl>
{
/* Editorial copy from the source PDF: Beschreibung +
collapsed Erläuterungen, populated client-side from
the embedded pg-notes payload. The templated note
stays as a fallback for nodes without extracted
content (e.g. Bereich-level zoom). */
}
<p class="sb-beschreibung" id="sb-beschreibung"></p>
<details
class="sb-erlaeuterungen"
id="sb-erlaeuterungen"
>
<summary>Erläuterungen</summary>
<div
class="sb-erlaeuterungen-body"
id="sb-erlaeuterungen-body"
>
</div>
</details>
<p class="caption sb-note" id="sb-note"></p>
</div>
</aside>
<div class="canvas">
<Icicle bothFlows={bothFlows} />
</div>
</section>
<footer class="page-footer">
<p class="caption">
Datengrundlage: <a
href="https://opendata.stadt-muenster.de/"
rel="external">opendata.stadt-muenster.de</a
> · <a
href="https://www.stadt-muenster.de/finanzen/muensters-haushalt/der-haushaltsplan"
rel="external">stadt-muenster.de/finanzen</a
> · Lizenz: Datenlizenz Deutschland Namensnennung 2.0 ·
<span
title={`Erkannte Plan-Diskrepanzen >5%: ${tree.overlaps.length}`}
>
{tree.produktbereiche.length} Produktbereiche, {
tree.years.length
} Jahre erfasst
</span> · <a
href="https://faz.ms/#imprint"
rel="external">Impressum</a
>
</p>
</footer>
</main>
<style>
.page {
max-width: var(--page-max);
margin: 0 auto;
padding: var(--space-2xl) var(--page-pad-x) var(--space-2xl);
}
/* ── Hero ────────────────────────────────────────────────────── */
.hero {
padding-bottom: var(--space-xl);
border-bottom: 1px solid var(--rule);
margin-bottom: var(--space-xl);
}
.hero .eyebrow {
margin: 0 0 var(--space-sm);
}
.hero-title {
font-size: var(--size-display-1);
font-weight: 800;
line-height: 0.98;
letter-spacing: -0.025em;
font-feature-settings:
"kern" 1,
"liga" 1,
"ss01" 1;
text-wrap: balance;
display: flex;
flex-direction: column;
gap: 0.05em;
}
.hero-title > span {
display: block;
}
/* Toggle line — the third headline line is "ausgibt / einnimmt."
with both verbs visible. The active verb has a flow-color
underline; the inactive verb is faded. Clicking either word
swaps which is active; the underline appears to slide between
them via synchronized color/border transitions. */
.hero-toggle-line {
display: inline-flex;
align-items: baseline;
gap: 0;
line-height: 0.98;
}
.hero-toggle-option {
font: inherit;
color: var(--ink);
background: none;
border: 0;
padding: 0 0.04em 0.06em;
margin: 0 0.06em;
cursor: pointer;
line-height: inherit;
position: relative;
opacity: 0.32;
border-bottom: 0.05em solid transparent;
transition:
opacity 380ms cubic-bezier(0.22, 1, 0.36, 1),
color 380ms cubic-bezier(0.22, 1, 0.36, 1),
border-bottom-color 380ms cubic-bezier(0.22, 1, 0.36, 1);
}
.hero-toggle-option:first-of-type {
margin-left: 0;
}
.hero-toggle-option[aria-pressed="true"] {
opacity: 1;
}
.hero-toggle-option[aria-pressed="true"][data-flow="aufwendungen"] {
border-bottom-color: var(--flow-aufwand);
}
.hero-toggle-option[aria-pressed="true"][data-flow="ertraege"] {
border-bottom-color: var(--flow-ertrag);
}
.hero-toggle-option:hover {
opacity: 0.72;
}
.hero-toggle-option[aria-pressed="true"]:hover {
opacity: 1;
}
.hero-toggle-option:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
border-radius: 2px;
}
.hero-toggle-option:focus {
outline: none;
}
.hero-toggle-sep {
color: var(--rule);
font-weight: 300;
font-size: 0.72em;
line-height: 1;
align-self: center;
padding-bottom: 0.18em;
}
.hero-period {
color: var(--flow-aufwand);
margin-left: 0.06em;
transition: color 380ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-flow-state="ertraege"] .hero-period {
color: var(--flow-ertrag);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ── Spread (canvas + sidebar) ───────────────────────────────── */
.spread {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(0, 2.4fr);
gap: var(--space-xl);
align-items: start;
}
.canvas {
min-width: 0;
}
.sidebar {
padding-top: var(--space-2xs);
border-right: 1px solid var(--rule);
padding-right: var(--space-lg);
}
.sidebar .eyebrow {
margin-bottom: var(--space-md);
}
/* Sidebar block toggling — only one block is visible at a time. */
.sidebar .sb-block {
display: none;
}
.sidebar[data-state="overview"] .sb-overview {
display: block;
}
.sidebar[data-state="node"] .sb-node {
display: block;
}
.sb-heading {
font-family: var(--face-serif);
font-size: var(--size-display-2);
font-weight: 700;
line-height: 1.04;
letter-spacing: -0.022em;
margin: 0 0 var(--space-lg);
text-wrap: balance;
}
/* Breadcrumb as a vertical stack of "back" links. Each item is
a button row with a ← arrow + label; closest ancestor on top,
root at the bottom. Hover/focus reveals the flow accent. */
.sb-breadcrumb {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2xs);
margin: 0 0 var(--space-md);
font-family: var(--face-sans);
}
.sb-breadcrumb:empty {
display: none;
}
.sb-breadcrumb-item {
font: inherit;
font-size: var(--size-body);
font-weight: 500;
line-height: 1.3;
color: var(--ink-soft);
background: none;
border: 0;
padding: var(--space-2xs) 0;
cursor: pointer;
display: inline-flex;
align-items: baseline;
gap: 0.4em;
transition:
color 160ms ease-out,
transform 160ms ease-out;
}
.sb-breadcrumb-arrow {
font-family: var(--face-mono);
color: var(--ink-mute);
font-weight: 400;
transition:
color 160ms ease-out,
transform 200ms ease-out;
}
.sb-breadcrumb-label {
text-decoration: underline;
text-decoration-color: var(--rule);
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
transition: text-decoration-color 160ms ease-out;
}
.sb-breadcrumb-item:hover {
color: var(--ink);
}
.sb-breadcrumb-item:hover .sb-breadcrumb-arrow {
color: var(--flow-aufwand);
transform: translateX(-3px);
}
[data-flow-state="ertraege"]
.sb-breadcrumb-item:hover
.sb-breadcrumb-arrow {
color: var(--flow-ertrag);
}
.sb-breadcrumb-item:hover .sb-breadcrumb-label {
text-decoration-color: var(--ink);
}
.sb-breadcrumb-item:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
border-radius: 2px;
}
.sb-breadcrumb-item:focus {
outline: none;
}
.sb-note {
margin-top: var(--space-md);
}
/* Editorial copy from the source PDF. Beschreibung is the
"what is this thing" paragraph — body-text-sized so it reads
as the editorial voice of the sidebar, not a caption. */
.sb-beschreibung {
font-family: var(--face-serif);
font-size: var(--size-body);
line-height: 1.55;
color: var(--ink);
margin: var(--space-md) 0 var(--space-md);
max-width: 38ch;
}
.sb-beschreibung:empty {
display: none;
}
/* Erläuterungen: collapsed by default, opens to a quieter,
smaller block of pre-formatted text since the source content
is line-broken at fixed widths in the PDF. */
.sb-erlaeuterungen {
margin: var(--space-md) 0;
font-family: var(--face-sans);
}
.sb-erlaeuterungen[hidden],
.sb-erlaeuterungen.is-empty {
display: none;
}
.sb-erlaeuterungen > summary {
font-size: var(--size-caption);
font-weight: 500;
color: var(--ink-soft);
cursor: pointer;
padding: var(--space-2xs) 0;
list-style: none;
display: inline-flex;
align-items: baseline;
gap: 0.4em;
transition: color 160ms ease-out;
}
.sb-erlaeuterungen > summary::-webkit-details-marker {
display: none;
}
.sb-erlaeuterungen > summary::before {
content: "+";
font-family: var(--face-mono);
color: var(--ink-mute);
transition: transform 200ms ease-out;
display: inline-block;
width: 0.7em;
}
.sb-erlaeuterungen[open] > summary::before {
content: "";
}
.sb-erlaeuterungen > summary:hover {
color: var(--ink);
}
.sb-erlaeuterungen-body {
margin-top: var(--space-sm);
font-size: var(--size-caption);
line-height: 1.5;
color: var(--ink-soft);
max-width: 40ch;
white-space: pre-wrap;
}
.totals {
display: grid;
gap: var(--space-xs);
margin: 0 0 var(--space-lg);
}
.total {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: var(--space-md);
padding: var(--space-2xs) 0 var(--space-xs);
border-bottom: 1px solid var(--rule-soft);
}
.total dt {
font-family: var(--face-sans);
font-size: var(--size-micro);
color: var(--ink-soft);
margin: 0;
letter-spacing: 0;
}
.total dd {
margin: 0;
font-family: var(--face-mono);
font-size: 1.25rem;
font-weight: 500;
font-feature-settings:
"tnum" 1,
"lnum" 1;
letter-spacing: -0.01em;
}
/* Saldo color: negative (deficit) reads in the Aufwendungen
purple — money is leaving faster than coming in. Positive
(surplus) stays at default ink so a rare positive value
doesn't get mistaken for the active-flow accent. */
.total-saldo dd {
color: var(--ink);
transition: color 240ms ease-out;
}
.total-saldo dd.is-negative {
color: var(--flow-aufwand);
}
/* Clickable flow rows in the sidebar — Aufwendungen and Erträge
double as a flow toggle. The active flow's row carries a
left-edge tick in its flow color; the inactive row is muted.
Hover lifts the muted row toward full presence so the
affordance reads on first contact. */
.total.flow-target {
cursor: pointer;
position: relative;
padding-left: var(--space-md);
margin-left: calc(var(--space-md) * -1);
transition: opacity 220ms ease-out;
}
.total.flow-target::before {
content: "";
position: absolute;
left: 0;
top: 30%;
bottom: 30%;
width: 2px;
background: transparent;
transition: background 220ms ease-out;
}
[data-flow-state="aufwendungen"]
.total.flow-target[data-flow-target="aufwendungen"]::before {
background: var(--flow-aufwand);
}
[data-flow-state="ertraege"]
.total.flow-target[data-flow-target="ertraege"]::before {
background: var(--flow-ertrag);
}
[data-flow-state="aufwendungen"]
.total.flow-target[data-flow-target="ertraege"],
[data-flow-state="ertraege"]
.total.flow-target[data-flow-target="aufwendungen"] {
opacity: 0.45;
}
.total.flow-target:hover {
opacity: 1;
}
.total.flow-target:hover::before {
background: var(--ink-mute);
}
[data-flow-state="aufwendungen"]
.total.flow-target[data-flow-target="aufwendungen"]:hover::before {
background: var(--flow-aufwand);
}
[data-flow-state="ertraege"]
.total.flow-target[data-flow-target="ertraege"]:hover::before {
background: var(--flow-ertrag);
}
.total.flow-target:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
border-radius: 2px;
}
.total.flow-target:focus {
outline: none;
}
.lede {
font-size: var(--size-body-lg);
line-height: 1.55;
color: var(--ink);
margin-bottom: var(--space-md);
max-width: 38ch;
}
.lede strong {
font-weight: 600;
}
.lede strong.tabular {
font-family: var(--face-mono);
font-feature-settings:
"tnum" 1,
"lnum" 1;
font-weight: 500;
}
/* ── Timeline / year slider ──────────────────────────────────── */
/* Each year is a vertical bar; height encodes total budget that
year relative to the max year. The current year's bar fills in
flow color; the others stay as a grey outline. Click any bar to
scrub; drag horizontally for continuous scrubbing. */
.timeline {
margin-bottom: var(--space-xl);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--rule);
}
.timeline-eyebrow {
margin: 0 0 var(--space-sm);
}
.timeline-bars {
display: flex;
gap: 4px;
align-items: flex-end;
height: 100px;
position: relative;
user-select: none;
touch-action: pan-x;
}
.timeline-bar {
flex: 1 1 0;
min-width: 0;
height: 100%;
position: relative;
background: none;
border: 0;
padding: 0;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
}
/* Two flow-bars per year (Aufwendungen + Erträge), end-aligned so
both grow upward from the bottom. Heights are normalized to the
larger of the two flows' max-year totals, so cross-flow
comparisons read correctly. */
.timeline-bar-flows {
display: flex;
gap: 1px;
width: 100%;
height: 100%;
align-items: flex-end;
}
.timeline-bar-flow {
flex: 1 1 0;
min-width: 0;
opacity: 0.42;
transition: opacity 240ms ease-out;
}
.timeline-bar-aufwand {
background: var(--flow-aufwand);
}
.timeline-bar-ertrag {
background: var(--flow-ertrag);
}
.timeline-bar:hover .timeline-bar-flow {
opacity: 0.7;
}
.timeline-bar.is-current .timeline-bar-flow {
opacity: 1;
}
/* Inactive-flow dimming: whichever flow is NOT currently selected
by the headline toggle gets pushed back so the active flow's
year-by-year shape reads as the foreground story. The override
applies across idle / hover / current states. */
[data-flow-state="aufwendungen"] .timeline-bar-ertrag,
[data-flow-state="ertraege"] .timeline-bar-aufwand {
opacity: 0.12;
}
[data-flow-state="aufwendungen"]
.timeline-bar:hover
.timeline-bar-ertrag,
[data-flow-state="ertraege"]
.timeline-bar:hover
.timeline-bar-aufwand {
opacity: 0.28;
}
[data-flow-state="aufwendungen"]
.timeline-bar.is-current
.timeline-bar-ertrag,
[data-flow-state="ertraege"]
.timeline-bar.is-current
.timeline-bar-aufwand {
opacity: 0.45;
}
/* Subtle bottom marker for the active year, sitting under the
labels so the slider has a clear "you are here" signal that
doesn't compete with the flow-color bars themselves. */
.timeline-bar.is-current::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -6px;
height: 2px;
background: var(--ink);
}
.timeline-bar-label {
position: absolute;
bottom: -22px;
left: 50%;
transform: translateX(-50%);
font-family: var(--face-mono);
font-size: 0.6rem;
color: var(--ink-mute);
opacity: 0;
transition: opacity 180ms ease-out;
pointer-events: none;
white-space: nowrap;
}
/* Show labels for every 5th year by default, plus the current. */
.timeline-bar:nth-child(5n + 1) .timeline-bar-label,
.timeline-bar.is-current .timeline-bar-label,
.timeline-bar:hover .timeline-bar-label {
opacity: 1;
}
.timeline-bar.is-current .timeline-bar-label {
color: var(--flow-aufwand);
font-weight: 600;
}
.timeline-bar:focus-visible .timeline-bar-fill {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.timeline-bar:focus {
outline: none;
}
/* ── Page footer ─────────────────────────────────────────────── */
.page-footer {
margin-top: var(--space-3xl);
padding-top: var(--space-md);
border-top: 1px solid var(--rule-soft);
}
/* ── Mobile reflow (basic; full reflow comes in next slice) ──── */
@media (max-width: 880px) {
.hero {
grid-template-columns: 1fr;
}
.flow-toggle {
grid-column: 1;
justify-self: start;
}
.spread {
grid-template-columns: 1fr;
gap: var(--space-xl);
}
.sidebar {
border-left: 0;
border-top: 1px solid var(--rule);
padding-left: 0;
padding-top: var(--space-xl);
}
}
</style>
<script>
// Wire the sidebar to the icicle's zoom events.
//
// The icicle component dispatches an "icicle:zoom" CustomEvent
// whose detail.path is "" for overview, or a slash-joined slug
// path like "soziale-hilfen/hilfen-fuer-asylbewerber" for a
// zoomed node. We look up the corresponding bar element by path,
// read its data-* attributes, and update the sidebar fields.
const sidebar = document.getElementById("sidebar");
const sbName = document.getElementById("sb-name");
const sbAufwand = document.getElementById("sb-aufwand");
const sbErtrag = document.getElementById("sb-ertrag");
const sbSaldo = document.getElementById("sb-saldo");
const sbBreadcrumb = document.getElementById("sb-breadcrumb");
const sbNote = document.getElementById("sb-note");
const sbBeschreibung = document.getElementById("sb-beschreibung");
const sbErlaeuterungen = document.getElementById(
"sb-erlaeuterungen",
) as HTMLDetailsElement | null;
const sbErlaeuterungenBody = document.getElementById(
"sb-erlaeuterungen-body",
);
// Per-Produktgruppe extracted content from the source PDF.
type PgNotes = Record<
string,
{ beschreibung: string | null; erlaeuterungen: string | null }
>;
let pgNotes: PgNotes = {};
const pgNotesEl = document.getElementById("pg-notes");
if (pgNotesEl?.textContent) {
try {
pgNotes = JSON.parse(pgNotesEl.textContent) as PgNotes;
} catch {
pgNotes = {};
}
}
const figure = document.querySelector(".icicle");
// Flow toggle in the headline. Swaps the visible flow throughout
// the page: theme color, headline-period accent, timeline
// dimming, sidebar saldo, AND the icicle's bar set + outlines.
const flowToggleOptions = Array.from(
document.querySelectorAll<HTMLButtonElement>(
".hero-toggle-option",
),
);
const icicleFigure = document.querySelector(".icicle");
function setFlowActive(flow: string) {
for (const opt of flowToggleOptions) {
opt.setAttribute(
"aria-pressed",
opt.dataset.flow === flow ? "true" : "false",
);
}
document.documentElement.dataset.flowState = flow;
if (icicleFigure) {
icicleFigure.dispatchEvent(
new CustomEvent("icicle:setflow", { detail: { flow } }),
);
}
}
for (const opt of flowToggleOptions) {
opt.addEventListener("click", () => {
const flow = opt.dataset.flow;
if (!flow) return;
if (opt.getAttribute("aria-pressed") === "true") return;
setFlowActive(flow);
});
}
// Sidebar flow rows: clicking the Aufwendungen or Erträge row
// (in either the overview or node block) toggles the flow,
// exactly like the headline ausgibt/einnimmt switch.
const flowTargetRows = Array.from(
document.querySelectorAll<HTMLElement>(".total.flow-target"),
);
for (const row of flowTargetRows) {
const trigger = () => {
const flow = row.dataset.flowTarget;
if (!flow) return;
if (document.documentElement.dataset.flowState === flow)
return;
setFlowActive(flow);
};
row.addEventListener("click", trigger);
row.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
trigger();
}
});
}
// Year slider — click and drag to scrub through years. Dispatches
// an `icicle:setyear` event the icicle listens for.
const yearSlider = document.getElementById("year-slider");
const yearBars = yearSlider
? Array.from(
yearSlider.querySelectorAll<HTMLButtonElement>(
".timeline-bar",
),
)
: [];
if (yearSlider && yearBars.length > 0) {
const slider = yearSlider;
const allBars = yearBars;
const figureEl = document.querySelector(".icicle");
const sliderTrack =
slider.querySelector<HTMLDivElement>(".timeline-bars");
function selectYearByIndex(idx: number) {
const i = Math.max(0, Math.min(allBars.length - 1, idx));
for (let k = 0; k < allBars.length; k++) {
allBars[k]!.classList.toggle("is-current", k === i);
}
const bar = allBars[i]!;
slider.dataset.year = bar.dataset.year ?? "";
slider.dataset.yearIndex = String(i);
slider.setAttribute(
"aria-valuenow",
bar.dataset.year ?? "0",
);
if (figureEl) {
figureEl.dispatchEvent(
new CustomEvent("icicle:setyear", {
detail: { yearIndex: i },
}),
);
}
}
// Click each bar.
allBars.forEach((bar, idx) => {
bar.addEventListener("click", () => selectYearByIndex(idx));
});
// Drag-to-scrub: pointerdown on the track, pointermove until up.
// Snaps to the nearest bar by horizontal position.
if (sliderTrack) {
let dragging = false;
function indexFromX(clientX: number): number {
const rect = sliderTrack!.getBoundingClientRect();
const ratio = (clientX - rect.left) / rect.width;
const idx = Math.round(ratio * (allBars.length - 1));
return Math.max(0, Math.min(allBars.length - 1, idx));
}
sliderTrack.addEventListener("pointerdown", (ev) => {
dragging = true;
sliderTrack!.setPointerCapture(ev.pointerId);
selectYearByIndex(indexFromX(ev.clientX));
});
sliderTrack.addEventListener("pointermove", (ev) => {
if (!dragging) return;
selectYearByIndex(indexFromX(ev.clientX));
});
const stop = (ev: PointerEvent) => {
dragging = false;
try {
sliderTrack!.releasePointerCapture(ev.pointerId);
} catch {
// ignore
}
};
sliderTrack.addEventListener("pointerup", stop);
sliderTrack.addEventListener("pointercancel", stop);
}
// Keyboard navigation: arrow keys when slider is focused.
slider.addEventListener("keydown", (ev) => {
const idx = Number(slider.dataset.yearIndex ?? "0");
if (ev.key === "ArrowLeft") {
ev.preventDefault();
selectYearByIndex(idx - 1);
} else if (ev.key === "ArrowRight") {
ev.preventDefault();
selectYearByIndex(idx + 1);
} else if (ev.key === "Home") {
ev.preventDefault();
selectYearByIndex(0);
} else if (ev.key === "End") {
ev.preventDefault();
selectYearByIndex(allBars.length - 1);
}
});
}
if (sidebar && figure) {
const sb = sidebar; // narrowed locals so closures keep the type
const fig = figure;
const fmtPercent = new Intl.NumberFormat("de-DE", {
style: "percent",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
function updateSidebar(path: string) {
if (!path) {
sb.dataset.state = "overview";
return;
}
// Look up the path's bars in both flows so we can show
// Aufwendungen, Erträge, and Saldo for the same node — same
// shape as the overview block. We use the active-flow bar
// as the source of name/depth metadata so the heading and
// breadcrumb reflect the user's current view.
const activeFlow =
document.documentElement.dataset.flowState ??
"aufwendungen";
const aufwBar = fig.querySelector<SVGGElement>(
`.bar[data-flow="aufwendungen"][data-path="${CSS.escape(path)}"]`,
);
const ertrBar = fig.querySelector<SVGGElement>(
`.bar[data-flow="ertraege"][data-path="${CSS.escape(path)}"]`,
);
const primary =
(activeFlow === "aufwendungen" ? aufwBar : ertrBar) ??
aufwBar ??
ertrBar;
if (!primary) {
sb.dataset.state = "overview";
return;
}
const name = primary.dataset.name ?? "";
const depth = Number(primary.dataset.depth ?? "1");
if (sbName) sbName.textContent = name;
if (sbAufwand) {
sbAufwand.textContent =
aufwBar?.dataset.valueCompact ?? "—";
}
if (sbErtrag) {
sbErtrag.textContent =
ertrBar?.dataset.valueCompact ?? "—";
}
if (sbSaldo) {
if (aufwBar && ertrBar) {
const aufw = parseFloat(
aufwBar.dataset.value ?? "0",
);
const ertr = parseFloat(
ertrBar.dataset.value ?? "0",
);
const saldo = ertr - aufw;
sbSaldo.textContent = compactEuroSigned(saldo);
sbSaldo.classList.toggle("is-negative", saldo < 0);
} else {
sbSaldo.textContent = "—";
sbSaldo.classList.remove("is-negative");
}
}
// Build breadcrumb as a vertical stack of "back" links. Each
// ancestor (incl. an "Übersicht" pseudo-root) is a button
// prefixed with a ← arrow to make clear it's navigation
// upward in the hierarchy. The current node's name lives in
// the sb-heading below, not in the breadcrumb itself.
if (sbBreadcrumb) {
sbBreadcrumb.replaceChildren();
const segs = path.split("/").filter(Boolean);
const items: Array<{
label: string;
targetPath: string;
}> = [{ label: "Übersicht", targetPath: "" }];
for (let i = 0; i < segs.length - 1; i++) {
const ancestorPath = segs.slice(0, i + 1).join("/");
const el = fig.querySelector<SVGGElement>(
`.bar[data-path="${CSS.escape(ancestorPath)}"]`,
);
if (el?.dataset.name) {
items.push({
label: el.dataset.name,
targetPath: ancestorPath,
});
}
}
// Render root first, closest-ancestor last — reads top to
// bottom as a hierarchy from broadest to most specific.
// For path "soziale-hilfen/hilfen-asyl":
// ← Übersicht
// ← Soziale Hilfen
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "sb-breadcrumb-item";
btn.dataset.targetPath = item.targetPath;
const arrow = document.createElement("span");
arrow.className = "sb-breadcrumb-arrow";
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = "←";
const label = document.createElement("span");
label.className = "sb-breadcrumb-label";
label.textContent = item.label;
btn.append(arrow, label);
btn.addEventListener("click", () => {
fig.dispatchEvent(
new CustomEvent("icicle:zoomto", {
detail: { path: item.targetPath },
}),
);
});
sbBreadcrumb.appendChild(btn);
}
}
// Editorial copy from the source PDF, when available for
// this Produktgruppe. Beschreibung shows verbatim; the
// Erläuterungen block stays collapsed by default behind a
// <details> summary.
const notes = pgNotes[path] ?? null;
if (sbBeschreibung) {
sbBeschreibung.textContent = notes?.beschreibung ?? "";
}
if (sbErlaeuterungen && sbErlaeuterungenBody) {
if (notes?.erlaeuterungen) {
sbErlaeuterungenBody.textContent =
notes.erlaeuterungen;
sbErlaeuterungen.classList.remove("is-empty");
sbErlaeuterungen.open = false;
} else {
sbErlaeuterungenBody.textContent = "";
sbErlaeuterungen.classList.add("is-empty");
}
}
// Templated note — used as a fallback when no extracted
// Beschreibung is available (typically for Bereich-level
// zoom, since extraction targets Produktgruppen only).
if (sbNote) {
if (notes?.beschreibung) {
// Beschreibung says it; the templated sentence would
// just be redundant. Hide the note slot.
sbNote.textContent = "";
} else {
const flowSrc =
activeFlow === "ertraege" ? ertrBar : aufwBar;
const shareGrand = flowSrc
? parseFloat(flowSrc.dataset.shareGrand ?? "0")
: 0;
const sharePct = fmtPercent.format(shareGrand);
const flowLabel =
activeFlow === "ertraege"
? "der Erträge"
: "der Aufwendungen";
if (depth === 1) {
sbNote.textContent = `${name} macht ${sharePct} ${flowLabel} aus. Klicken Sie eine Produktgruppe rechts an, um tiefer einzuzoomen.`;
} else if (depth === 2) {
sbNote.textContent = `Diese Produktgruppe entspricht ${sharePct} des Gesamthaushalts.`;
} else {
sbNote.textContent = "";
}
}
}
sb.dataset.state = "node";
}
fig.addEventListener("icicle:zoom", (ev: Event) => {
const detail = (ev as CustomEvent<{ path: string }>).detail;
updateSidebar(detail.path ?? "");
});
// Per-year overview card data, embedded at build time.
type OverviewEntry = {
year: number;
aufwand: number;
ertrag: number;
saldo: number;
largestName: string;
largestValue: number;
largestShare: number;
};
type OverviewPayload = {
defaultYear: number;
years: OverviewEntry[];
};
let overviewData: OverviewPayload | null = null;
const overviewEl = document.getElementById("overview-data");
if (overviewEl?.textContent) {
try {
overviewData = JSON.parse(
overviewEl.textContent,
) as OverviewPayload;
} catch {
overviewData = null;
}
}
const fmtEUR = new Intl.NumberFormat("de-DE", {
maximumFractionDigits: 0,
});
const fmtEURone = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
function compactEuro(n: number): string {
const abs = Math.abs(n);
const sign = n < 0 ? "" : "";
if (abs >= 1_000_000_000)
return `${sign}${fmtEURone.format(abs / 1_000_000_000)} Mrd. €`;
if (abs >= 1_000_000)
return `${sign}${fmtEURone.format(abs / 1_000_000)} Mio. €`;
return `${sign}${fmtEUR.format(abs)} €`;
}
function compactEuroSigned(n: number): string {
if (n === 0) return `±${compactEuro(0)}`;
const prefix = n > 0 ? "+" : "";
return `${prefix}${compactEuro(n)}`;
}
function updateOverviewForYear(year: number) {
if (!overviewData) return;
const entry = overviewData.years.find(
(e) => e.year === year,
);
if (!entry) return;
const setText = (id: string, text: string) => {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
setText("ov-aufwand", compactEuro(entry.aufwand));
setText("ov-ertrag", compactEuro(entry.ertrag));
setText("ov-saldo", compactEuroSigned(entry.saldo));
const saldoEl = document.getElementById("ov-saldo");
if (saldoEl) {
saldoEl.classList.toggle(
"is-negative",
entry.saldo < 0,
);
}
// Lede paragraph: editorial copy for the default-year story
// (2026/2027 draft); data-derived for other years.
const lede = document.getElementById("ov-lede");
if (lede) {
if (year === overviewData.defaultYear) {
lede.innerHTML =
"Der Entwurf für 2026/2027 ist der erste Haushalt unter " +
"Oberbürgermeister Tilman Fuchs. Er sieht für 2026 " +
'Aufwendungen von <strong class="tabular">' +
compactEuro(entry.aufwand) +
'</strong> vor — etwa <strong class="tabular">' +
compactEuro(entry.saldo) +
"</strong> mehr als die erwarteten Erträge. Klicken Sie " +
"auf einen Block rechts, um in den Bereich hineinzuzoomen.";
} else {
const isActual = year <= 2022;
const verb = isActual ? "verzeichnete" : "plante";
const saldoVerb =
entry.saldo >= 0
? "ein Plus von"
: "ein Defizit von";
lede.innerHTML =
`Im Haushaltsjahr ${year} ${verb} die Stadt Münster ` +
'Aufwendungen von <strong class="tabular">' +
compactEuro(entry.aufwand) +
'</strong> und Erträge von <strong class="tabular">' +
compactEuro(entry.ertrag) +
`</strong> — ${saldoVerb} <strong class="tabular">` +
compactEuro(Math.abs(entry.saldo)) +
"</strong>.";
}
}
}
// On year change, refresh whichever sidebar block is showing.
fig.addEventListener("icicle:yearchange", (ev: Event) => {
const detail = (ev as CustomEvent<{ year: number }>).detail;
const year = detail?.year;
if (typeof year === "number") {
updateOverviewForYear(year);
}
if (sb.dataset.state === "node") {
// Re-read the currently zoomed bar's data attributes (the
// icicle's year-change handler updated them).
const path =
fig.querySelector<HTMLElement>(".icicle")?.dataset
.zoomPath;
if (path) updateSidebar(path);
}
});
}
// URL state sync — keeps the address bar in lockstep with
// the current zoom path, year, and flow so a copy-pasted
// link reproduces the exact view. Uses history.replaceState
// so we don't pollute the back/forward stack with every
// year-slider tick.
const figForUrl = document.querySelector(".icicle");
if (figForUrl) {
const URL_DEFAULTS = {
path: "",
year: String(figForUrl.getAttribute("data-default-year") ?? ""),
flow: String(figForUrl.getAttribute("data-active-flow") ?? "aufwendungen"),
};
const urlState = { ...URL_DEFAULTS };
let urlSyncReady = false;
function writeUrl() {
if (!urlSyncReady) return;
const params = new URLSearchParams();
if (urlState.flow && urlState.flow !== URL_DEFAULTS.flow)
params.set("flow", urlState.flow);
if (urlState.year && urlState.year !== URL_DEFAULTS.year)
params.set("year", urlState.year);
if (urlState.path) params.set("path", urlState.path);
const qs = params.toString();
const next =
window.location.pathname + (qs ? "?" + qs : "");
if (next !== window.location.pathname + window.location.search) {
history.replaceState(null, "", next);
}
}
figForUrl.addEventListener("icicle:zoom", (ev: Event) => {
urlState.path =
(ev as CustomEvent<{ path: string }>).detail?.path ?? "";
writeUrl();
});
figForUrl.addEventListener("icicle:yearchange", (ev: Event) => {
const y = (ev as CustomEvent<{ year: number }>).detail?.year;
if (typeof y === "number") {
urlState.year = String(y);
writeUrl();
}
});
figForUrl.addEventListener("icicle:flowchange", (ev: Event) => {
const f = (ev as CustomEvent<{ flow: string }>).detail?.flow;
if (f) {
urlState.flow = f;
writeUrl();
}
});
// Restore state from URL on initial load. Order is
// important: flow first (some paths are flow-specific),
// then year, then path.
const initial = new URLSearchParams(window.location.search);
const initFlow = initial.get("flow");
if (
(initFlow === "aufwendungen" || initFlow === "ertraege") &&
initFlow !== URL_DEFAULTS.flow
) {
setFlowActive(initFlow);
}
const initYearStr = initial.get("year");
if (initYearStr) {
// Reuse the year bar's own click handler — it
// already updates the slider class state, the
// section data attributes, and dispatches
// icicle:setyear.
const yearBarEl = document.querySelector<HTMLElement>(
`.timeline-bar[data-year="${initYearStr}"]`,
);
if (yearBarEl) yearBarEl.click();
}
const initPath = initial.get("path");
if (initPath) {
figForUrl.dispatchEvent(
new CustomEvent("icicle:zoomto", {
detail: { path: initPath },
}),
);
}
urlSyncReady = true;
writeUrl();
}
</script>
</body>
</html>