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>
1551 lines
64 KiB
Plaintext
1551 lines
64 KiB
Plaintext
---
|
||
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 2008–2028 · 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>
|