feat(icicle): refine layout, labels, and zoom UX

- Sticky labels: each bar's title slides down inside its rect under
  the sticky timeline so it stays visible while any part of the rect
  is in view, then slides off when the rect's bottom passes.
- Auto-wrap long bar names into multiple <tspan> lines so labels
  no longer overrun their box.
- Drop browser-native <title> tooltips; the custom tooltip remains.
- Demote depth-1 nameSize to depth-2 size when a bar shows its name
  but not its euro amount, so it doesn't outshout column-2 titles.
- Collapse the depth-4 column when the zoomed-in scope has no
  Breakdown items; surviving columns expand to fill the canvas.
- Tighter year-budget centering, runtime gap (GAP_Y) instead of
  d3 partition padding, gutter masks for year-frame strokes.
- Two-hue OKLCH palette tuned so all labels can stay a single colour
  per flow (light on Aufwendungen, dark on Erträge).
- Sidebar: clickable Aufw/Ertr/Saldo to swap flow; Beschreibung +
  collapsed Erläuterungen extracted from PG sections.
- Sticky timeline axis with active-year tick in the active flow's
  colour; URL state sync for path/year/flow.

🤖 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:23:23 +02:00
parent 9a958c0051
commit 04a46caf19
5 changed files with 589 additions and 130 deletions
+386 -51
View File
@@ -14,9 +14,9 @@ import type {
BothFlowsLayout,
IcicleNode,
} from "../lib/icicle.ts";
import { VB_H, VB_W } from "../lib/icicle.ts";
import { COL_RECTS as COL_RECTS_SSR, VB_H, VB_W } from "../lib/icicle.ts";
import { tileColor } from "../lib/colors.ts";
import { fmtEuroCompact, fmtPercentage } from "../lib/format.ts";
import { fmtEuroCompact } from "../lib/format.ts";
import type { Flow } from "../data/types.ts";
interface Props {
@@ -104,6 +104,44 @@ const DEPTH_STYLE: Record<number, DepthStyle> = {
const PAD_X = 18; // viewBox units
const PAD_TOP = 12; // viewBox units, breathing room above the name
const NAME_META_GAP = 8;
// Absolute sibling gap (viewBox units). Applied as an inset on top
// and bottom of each bar AFTER the zoom transform, so the visible
// gap between adjacent siblings stays the same size at every zoom
// state instead of ballooning when bars magnify.
const GAP_Y = 2;
// Per-depth max characters per name line. Computed from the
// narrowest column width (across all zoom states where labels
// render for that depth) divided by the nameSize × an avg-char
// fraction for Figtree (~0.55em). Slightly conservative so wraps
// still fit at the tightest state. Used by wrapText below.
const MAX_CHARS_PER_LINE: Record<number, number> = {
1: 30,
2: 30,
3: 22,
4: 30,
};
/** Greedy word-wrap to a max character count. Keeps single
* oversized words on their own line rather than splitting them. */
function wrapText(text: string, maxChars: number): string[] {
if (text.length <= maxChars) return [text];
const words = text.split(/\s+/);
const lines: string[] = [];
let current = "";
for (const word of words) {
const next = current ? `${current} ${word}` : word;
if (next.length <= maxChars) {
current = next;
} else {
if (current) lines.push(current);
current = word;
}
}
if (current) lines.push(current);
return lines;
}
---
<div class="icicle-tooltip" role="tooltip" aria-hidden="true">
@@ -126,7 +164,7 @@ const NAME_META_GAP = 8;
>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
preserveAspectRatio="none"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-hidden="false"
>
@@ -136,6 +174,12 @@ const NAME_META_GAP = 8;
current year's icicle (on top) reads against the comparative
backdrop of every other year. The current year's outline gets
a `is-current-year` class JS-toggles on year change. */}
{/* DOM paint order:
1. year-outlines (faintest, behind everything)
2. column-gutters (paper-fill masks at depth boundaries)
3. bars (foreground)
The gutter masks hide the year-outline horizontals where
they would otherwise cross the column boundary. */}
<g class="year-outlines" aria-hidden="true">
{bothFlows.years.map((year, i) => {
// Initial outline heights use the DEFAULT-flow's per-year
@@ -160,6 +204,28 @@ const NAME_META_GAP = 8;
})}
</g>
{/* Column gutter masks — paper-fill rects sitting in the small
gaps between adjacent depth columns, on top of year-outlines
but below bars. They hide the year-outline horizontals where
the column boundary would otherwise show through. */}
<g class="column-gutters" aria-hidden="true">
{[1, 2, 3].map((d) => {
const left = COL_RECTS_SSR[d - 1]?.r ?? VB_W;
const right = COL_RECTS_SSR[d]?.l ?? VB_W;
const w = Math.max(0, right - left);
return (
<rect
class="column-gutter"
data-after-depth={d}
x={left}
y={0}
width={w}
height={VB_H}
/>
);
})}
</g>
{flowOrder.flatMap((flow) => {
const flowLayout = bothFlows.byFlow[flow].initial;
const flowRatio = dataByFlow[flow]!.ratio;
@@ -171,10 +237,14 @@ const NAME_META_GAP = 8;
.filter((n) => n.y1 - n.y0 > 0)
.map((rawNode: IcicleNode) => {
const ssrYOffset = (VB_H * (1 - flowRatio)) / 2;
// Inset top + bottom by GAP_Y/2 so adjacent siblings have
// an absolute GAP_Y gap between them — same size at every
// zoom state since the inset is applied AFTER the zoom
// transform (here, just the SSR ratio scaling).
const node: IcicleNode = {
...rawNode,
y0: rawNode.y0 * flowRatio + ssrYOffset,
y1: rawNode.y1 * flowRatio + ssrYOffset,
y0: rawNode.y0 * flowRatio + ssrYOffset + GAP_Y / 2,
y1: rawNode.y1 * flowRatio + ssrYOffset - GAP_Y / 2,
};
const w = node.x1 - node.x0;
const h = node.y1 - node.y0;
@@ -197,8 +267,22 @@ const NAME_META_GAP = 8;
// effective depth) take over on zoom.
const showsName = node.depth < 3 && h >= style.minNameH;
const showsMeta = node.depth < 3 && h >= style.minMetaH;
// When a depth-1 bar shows its name but not the euro amount,
// it has nothing to anchor visual weight against — drop it
// to the depth-2 size so it doesn't bellow at the column-2
// titles next door.
const nameSize =
showsName && !showsMeta && node.depth === 1
? DEPTH_STYLE[2]!.nameSize
: style.nameSize;
const nameLines = wrapText(
node.name,
MAX_CHARS_PER_LINE[node.depth] ?? 30
);
const lineCount = nameLines.length;
const lineHeight = nameSize * 1.05;
const nameY = node.y0 + PAD_TOP;
const metaY = nameY + style.nameSize + NAME_META_GAP;
const metaY = nameY + lineCount * lineHeight + NAME_META_GAP;
const cx = node.x0 + PAD_X;
const classes = [
"bar",
@@ -217,14 +301,12 @@ const NAME_META_GAP = 8;
data-parent-path={node.parentPath}
data-depth={node.depth}
data-name={node.name}
data-name-lines={lineCount}
data-value={String(node.value)}
data-value-compact={fmtEuroCompact(node.value)}
data-share-grand={String(node.shareOfGrand)}
data-share-parent={String(node.shareOfParent)}
>
<title>
{`${node.name} · ${fmtEuroCompact(node.value)} (${fmtPercentage(node.shareOfParent)})`}
</title>
<rect
x={node.x0}
y={node.y0}
@@ -238,8 +320,14 @@ const NAME_META_GAP = 8;
y={nameY}
fill={label}
dominant-baseline="hanging"
style={`font-size: ${style.nameSize}px;`}
>{node.name}</text>
style={`font-size: ${nameSize}px;`}
>
{nameLines.map((line, i) => (
<tspan x={cx} dy={i === 0 ? 0 : lineHeight}>
{line}
</tspan>
))}
</text>
<text
class="bar-meta"
x={cx}
@@ -267,6 +355,12 @@ const NAME_META_GAP = 8;
margin: 0;
width: 100%;
aspect-ratio: 4 / 5;
/* Aspect-ratio is authoritative — the figure always fills the
canvas column's full width. On tall viewports the chart can
exceed one viewport height; the user scrolls within the page.
(Returning to a max-height cap would force the SVG into a
wider-than-viewBox container, leaving L+R whitespace on
wide viewports.) */
position: relative;
}
.icicle svg {
@@ -275,11 +369,11 @@ const NAME_META_GAP = 8;
display: block;
}
/* Active flow toggle — only one set of bars is rendered at a time.
Both flows are SSR'd; CSS hides whichever isn't selected by the
figure's data-active-flow attribute. JS filters by active flow
when applying layout/zoom updates so inactive bars aren't
re-laid-out unnecessarily. */
/* Active flow toggle — only one set of bars + bands is rendered
at a time. Both flows are SSR'd; CSS hides whichever isn't
selected by the figure's data-active-flow attribute. JS filters
by active flow when applying layout updates so inactive bars
aren't re-laid-out unnecessarily. */
.icicle[data-active-flow="aufwendungen"] .bar[data-flow="ertraege"],
.icicle[data-active-flow="ertraege"] .bar[data-flow="aufwendungen"] {
display: none;
@@ -303,6 +397,22 @@ const NAME_META_GAP = 8;
opacity: 0.7;
}
/* Column gutters — paper-fill masks. Width transitions with the
bars during zoom so the gutter follows whichever column edges
are currently active. */
.column-gutter {
fill: var(--paper);
pointer-events: none;
transition:
x 520ms cubic-bezier(0.22, 1, 0.36, 1),
width 520ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
.column-gutter {
transition: none;
}
}
/* Animation strategy: CSS transitions on the SVG geometry attributes
themselves. Modern browsers animate `x`, `y`, `width`, `height`
directly when they change via setAttribute(), which is dramatically
@@ -458,23 +568,74 @@ const NAME_META_GAP = 8;
const VB_W = 1600;
const VB_H = 2000;
// Column boundaries by zoom depth. 5-element arrays index into the
// 4-level hierarchy (Bereich/Gruppe/Category/Breakdown) with bound
// i giving the LEFT edge of depth i+1. Levels above the zoom depth
// sit OFFSCREEN so the visibility check naturally hides them.
//
// At the overview, depth-4 (Breakdowns) is collapsed to zero width
// so the fine-print stays out of sight until the user zooms — the
// top-level view should read as the three structural levels of the
// budget, not include the deepest detail.
//
// Be Gr Ca Bd
// Column rectangles per zoom depth. Each entry has 4 {l, r}
// pairs (one per absolute depth). A small 2-vb gutter sits
// between adjacent visible columns — paper-coloured so it masks
// year-outline horizontals at the boundary. Offscreen depths get
// a far-negative l/r so the visibility check naturally hides
// them. The depth-4 (Breakdown) column collapses to zero width
// at overview so the fine-print stays hidden until the user
// zooms in.
const OFFSCREEN = -1e6;
const ZOOM_COL_BOUNDS: number[][] = [
[0, 770, 1540, VB_W, VB_W], // zoom 0 (overview)
[OFFSCREEN, 0, 800, 1280, VB_W], // zoom 1 (Bereich)
[OFFSCREEN, OFFSCREEN, 0, 960, VB_W], // zoom 2 (Gruppe)
[OFFSCREEN, OFFSCREEN, OFFSCREEN, 0, VB_W], // zoom 3 (Category)
const GUTTER = 2;
const HALF = GUTTER / 2;
type ColRect = { l: number; r: number };
const ZOOM_COL_RECTS: ColRect[][] = [
// zoom 0 (overview): Bereich + Gruppe + thin Category.
[
{ l: 0, r: 770 - HALF },
{ l: 770 + HALF, r: 1540 - HALF },
{ l: 1540 + HALF, r: VB_W },
{ l: VB_W, r: VB_W },
],
// zoom 1 (Bereich): Gruppe + Category + Breakdown share canvas.
[
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: 0, r: 800 - HALF },
{ l: 800 + HALF, r: 1280 - HALF },
{ l: 1280 + HALF, r: VB_W },
],
// zoom 2 (Gruppe): Category + Breakdown.
[
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: 0, r: 960 - HALF },
{ l: 960 + HALF, r: VB_W },
],
// zoom 3 (Category): Breakdown fills the canvas.
[
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: 0, r: VB_W },
],
];
// Alternate column allocations used when the zoomed-in scope has
// no depth-4 (Breakdown) data: the breakdown column collapses and
// its width is given back to the surviving columns. Same shape as
// ZOOM_COL_RECTS so applyLayout can swap one for the other.
const ZOOM_COL_RECTS_NO_L4: ColRect[][] = [
// zoom 0: unchanged (depth-4 is hidden at overview anyway).
ZOOM_COL_RECTS[0]!,
// zoom 1 without breakdown: Gruppe + Category split the canvas
// in the same 5:3 ratio as their original widths (800 : 480).
[
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: 0, r: 1000 - HALF },
{ l: 1000 + HALF, r: VB_W },
{ l: OFFSCREEN, r: OFFSCREEN },
],
// zoom 2 without breakdown: Category fills the canvas.
[
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: OFFSCREEN, r: OFFSCREEN },
{ l: 0, r: VB_W },
{ l: OFFSCREEN, r: OFFSCREEN },
],
// zoom 3: unchanged (Breakdown is the only column; if it's
// empty the user can't have reached this zoom anyway).
ZOOM_COL_RECTS[3]!,
];
// Per-EFFECTIVE-depth label thresholds and sizes. "Effective depth"
@@ -507,11 +668,28 @@ const NAME_META_GAP = 8;
nameY0: number;
/** Original baseline y of the meta <text>, viewBox units. */
metaY0: number;
/**
* Latest authoritative geometry written by applyLayout, used by
* the sticky-label scroll handler to recompute label y without
* re-running the full layout pass.
*/
geom?: {
y0: number;
y1: number;
lineCount: number;
lineHeight: number;
nameSize: number;
metaSize: number;
showsName: boolean;
showsMeta: boolean;
visible: boolean;
};
};
function init(figure: HTMLElement) {
const svg = figure.querySelector("svg");
if (!svg) return;
const svgEl = figure.querySelector("svg");
if (!svgEl) return;
const svg: SVGSVGElement = svgEl;
const groups = Array.from(
svg.querySelectorAll<SVGGElement>(".bar")
@@ -554,6 +732,12 @@ const NAME_META_GAP = 8;
ertraege: new Map(barsByFlow.ertraege.map((b) => [b.path, b])),
};
// Column-gutter masks, indexed by `data-after-depth` so we can
// reposition them on every zoom state.
const columnGutters = Array.from(
svg.querySelectorAll<SVGRectElement>(".column-gutter")
);
// Read the embedded multi-year + multi-flow payload.
type FlowData = {
totals: number[];
@@ -617,7 +801,19 @@ const NAME_META_GAP = 8;
// 1 = Bereich-zoom, 2 = Gruppe-zoom). Drives column bounds and
// effective-depth label sizing.
const zoomDepth = path === "" ? 0 : path.split("/").length;
const cols = ZOOM_COL_BOUNDS[zoomDepth] ?? ZOOM_COL_BOUNDS[0]!;
// Does the zoomed-in scope contain any depth-4 (Breakdown)
// bars? If not, fall back to the no-L4 column allocation so
// the surviving columns expand to fill the canvas.
const scopePrefix = path === "" ? "" : path + "/";
const hasBreakdown = activeBars.some(
(b) =>
b.depth === 4 &&
(path === "" || b.path.startsWith(scopePrefix))
);
const colTable = hasBreakdown
? ZOOM_COL_RECTS
: ZOOM_COL_RECTS_NO_L4;
const colRects = colTable[zoomDepth] ?? colTable[0]!;
const targetLive = target ? liveOrig(target) : null;
const pY0 = targetLive?.y0 ?? 0;
@@ -640,16 +836,23 @@ const NAME_META_GAP = 8;
: 1;
const yOffset = (VB_H * (1 - ratio)) / 2;
// Absolute sibling gap inset (viewBox units). Applied AFTER
// the zoom transform so the visible gap between adjacent
// siblings stays the same size at every zoom state.
const GAP_Y = 2;
for (const b of activeBars) {
const live = liveOrig(b);
const newY0 = (live.y0 - pY0) * yScale * ratio + yOffset;
const newY1 = (live.y1 - pY0) * yScale * ratio + yOffset;
// X positions come from the zoom-aware column bounds, NOT from
const newY0 =
(live.y0 - pY0) * yScale * ratio + yOffset + GAP_Y / 2;
const newY1 =
(live.y1 - pY0) * yScale * ratio + yOffset - GAP_Y / 2;
// X positions come from the zoom-aware column rects, NOT from
// the bar's original x — this is what lets us re-allocate
// 66/33 after Bereich zoom and 100% after Gruppe zoom rather
// than just stretching the original 770/770/60 split.
const newX0 = cols[b.depth - 1] ?? OFFSCREEN;
const newX1 = cols[b.depth] ?? OFFSCREEN;
// column widths per zoom state (e.g. 66/33 after Bereich
// zoom) rather than just stretching the original split.
const colRect = colRects[b.depth - 1] ?? { l: OFFSCREEN, r: OFFSCREEN };
const newX0 = colRect.l;
const newX1 = colRect.r;
const newW = Math.max(0, newX1 - newX0);
const newH = Math.max(0, newY1 - newY0);
@@ -686,10 +889,19 @@ const NAME_META_GAP = 8;
1,
Math.min(4, b.depth - zoomDepth)
);
const nameSize = DEPTH_NAME_SIZE[effectiveDepth] ?? 22;
const metaSize = DEPTH_META_SIZE[effectiveDepth] ?? 0;
const minName = MIN_NAME_H[effectiveDepth] ?? 32;
const minMeta = MIN_META_H[effectiveDepth] ?? 70;
// When a depth-1 (active-level) bar is tall enough to show
// its name but too short for the euro amount, drop the
// typeface to depth-2's size — it would otherwise dominate
// its neighbours with nothing to anchor against.
const showsNameNow = newH >= minName;
const showsMetaNow = newH >= minMeta;
const nameSize =
effectiveDepth === 1 && showsNameNow && !showsMetaNow
? DEPTH_NAME_SIZE[2]!
: (DEPTH_NAME_SIZE[effectiveDepth] ?? 22);
// Labels appear only at effective depths 1 and 2 — the active
// level and one step below it. Anything deeper (categories at
@@ -702,21 +914,46 @@ const NAME_META_GAP = 8;
b.g.classList.toggle("shows-name", labelEligible && newH >= minName);
b.g.classList.toggle("shows-meta", labelEligible && newH >= minMeta);
// Top-anchor labels with constant PAD_TOP_RT padding. Font
// sizes update with effective depth so the active level has
// the largest type after every zoom.
const nameY = newY0 + PAD_TOP_RT;
const metaY = nameY + nameSize + NAME_META_GAP_RT;
// Cache geometry the sticky-label scroll handler will need
// so it can recompute label y without re-running the layout.
const lineCount = Number(b.g.dataset.nameLines ?? "1");
const lineHeight = nameSize * 1.05;
const showsName = labelEligible && newH >= minName;
const showsMeta = labelEligible && newH >= minMeta;
b.geom = {
y0: newY0,
y1: newY1,
lineCount,
lineHeight,
nameSize,
metaSize,
showsName,
showsMeta,
visible,
};
if (b.name) {
b.name.setAttribute("x", String(newX0 + PAD_X_RT));
b.name.setAttribute("y", String(nameY));
const tx = String(newX0 + PAD_X_RT);
b.name.setAttribute("x", tx);
b.name.style.fontSize = `${nameSize}px`;
// Tspans carry explicit x (so each wrapped line starts at
// the bar's left edge) and dy (line advance from prior
// line). Both depend on the bar's current x and the
// current effective-depth nameSize.
const tspans = b.name.querySelectorAll("tspan");
tspans.forEach((t, i) => {
t.setAttribute("x", tx);
if (i > 0) t.setAttribute("dy", String(lineHeight));
});
}
if (b.meta) {
b.meta.setAttribute("x", String(newX0 + PAD_X_RT));
b.meta.setAttribute("y", String(metaY));
b.meta.style.fontSize = `${metaSize}px`;
}
// Initial y placement (no scroll offset). The sticky pass
// below will adjust for any bars whose top has scrolled past
// the sticky timeline ceiling.
positionLabels(b, newY0 + PAD_TOP_RT);
// Update value + share displayed text and the data-* attrs the
// tooltip and sidebar consume — for the current year, in the
// bar's own flow.
@@ -740,10 +977,29 @@ const NAME_META_GAP = 8;
}
}
// Reposition the column-gutter masks. Each mask sits between
// depth d and depth d+1; opacity 0 if either side is offscreen
// so the mask doesn't blanket the whole canvas in paper.
for (const g of columnGutters) {
const d = Number(g.dataset.afterDepth ?? "0");
const leftRect = colRects[d - 1];
const rightRect = colRects[d];
const x = leftRect?.r ?? VB_W;
const w = (rightRect?.l ?? VB_W) - x;
const visible =
w > 0 && (leftRect?.r ?? -1) > 0 && (rightRect?.l ?? -1) > 0;
g.style.opacity = visible ? "1" : "0";
g.setAttribute("x", String(x));
g.setAttribute("width", String(Math.max(0, w)));
}
currentPath = path;
figure.classList.toggle("is-zoomed", path !== "");
figure.dataset.zoomPath = path;
// Re-evaluate sticky-label offsets now that geometry changed.
updateStickyLabels();
// Notify the page so the sidebar can update its content.
figure.dispatchEvent(
new CustomEvent("icicle:zoom", {
@@ -753,6 +1009,85 @@ const NAME_META_GAP = 8;
);
}
// Place a bar's name + meta texts at a given viewBox y, sharing
// the same vertical-rhythm offsets the SSR uses. Both the layout
// pass and the scroll pass go through here so they cannot drift.
function positionLabels(b: Bar, nameY: number): void {
if (!b.geom) return;
const metaY =
nameY + b.geom.lineCount * b.geom.lineHeight + NAME_META_GAP_RT;
if (b.name) b.name.setAttribute("y", String(nameY));
if (b.meta) b.meta.setAttribute("y", String(metaY));
}
// Cached references for the scroll handler. The sticky timeline
// axis is rendered by index.astro and scrolls to top:0 once the
// hero leaves view; its height is the ceiling labels stick to.
const stickyAxis = document.querySelector<HTMLElement>(
".timeline-axis"
);
/**
* Sticky labels: as a bar's top scrolls past the sticky timeline,
* its name + value slide down inside the rect so the title stays
* on screen until the rect's bottom edge passes too. Implemented
* by mapping the screen-space ceiling back into viewBox units and
* clamping each bar's nameY into [rectTop+pad, rectBottom-labelH].
*/
function updateStickyLabels(): void {
const svgRect = svg.getBoundingClientRect();
if (svgRect.height <= 0) return;
const vbPerPx = VB_H / svgRect.height;
// Ceiling: viewport top, plus the sticky axis if it's currently
// pinned above the icicle.
// Breathing room between the sticky axis (or viewport top) and
// a sticky label, so labels don't visually touch the year row.
const STICKY_GAP_PX = 12;
let ceilingPx = STICKY_GAP_PX;
if (stickyAxis) {
const axisRect = stickyAxis.getBoundingClientRect();
// Only count the axis as a ceiling when it's actually above
// (or overlapping) the icicle — otherwise viewport-top wins.
if (axisRect.bottom > 0 && axisRect.top < svgRect.bottom) {
ceilingPx = Math.max(ceilingPx, axisRect.bottom + STICKY_GAP_PX);
}
}
const vbCeiling = (ceilingPx - svgRect.top) * vbPerPx;
for (const b of bars) {
if (!b.geom || !b.geom.visible || !b.geom.showsName) continue;
const { y0, y1, lineCount, lineHeight, metaSize, showsMeta } =
b.geom;
const labelH =
lineCount * lineHeight +
(showsMeta ? NAME_META_GAP_RT + metaSize : 0);
const minY = y0 + PAD_TOP_RT;
const maxY = y1 - PAD_TOP_RT - labelH;
// If the rect is too short to host the label below the
// ceiling without overflowing, just pin to the rect top.
const target =
maxY <= minY ? minY : Math.min(Math.max(vbCeiling, minY), maxY);
positionLabels(b, target);
}
}
// Throttle scroll/resize handling to one update per frame. The
// listener is passive so it never blocks scroll.
let stickyRafPending = false;
function scheduleStickyUpdate(): void {
if (stickyRafPending) return;
stickyRafPending = true;
requestAnimationFrame(() => {
stickyRafPending = false;
updateStickyLabels();
});
}
window.addEventListener("scroll", scheduleStickyUpdate, {
passive: true,
});
window.addEventListener("resize", scheduleStickyUpdate);
// Convenience for parts of the code that historically called zoomTo.
function zoomTo(path: string) {
applyLayout(path);
+46 -18
View File
@@ -12,16 +12,46 @@ import type { Flow } from "../data/types.js";
interface FlowPalette {
/** OKLCH hue, degrees. */
h: number;
/** OKLCH chroma at the darkest end of the scale. */
/** OKLCH chroma at the largest tile (most saturated end). */
cMax: number;
/** Multiplier for the chroma floor (smallest tile = cMax × cFloor).
* Higher = small tiles still feel saturated; lower = pastel. */
cFloor: number;
/** Lightness for the smallest tile (one end of the scale). */
Llight: number;
/** Lightness for the largest tile (the other end). */
Ldark: number;
/** Foreground colour used for labels on tiles of this flow. */
label: string;
}
// Must mirror the CSS custom properties in src/styles/global.css. Keep
// the two definitions in sync — the JS values drive the SVG fills, the
// CSS values drive page chrome (toggle underline, Saldo color, etc.).
// CSS values drive page chrome (toggle underline, Saldo colour, etc.).
//
// Lightness ranges sit cleanly on opposite sides of the label-contrast
// threshold so labels can be a single fixed colour per flow:
// • Aufwendungen — purple, darker bars (Llight 50 → Ldark 22).
// Always uses LIGHT labels.
// • Erträge — orange, lighter bars (Llight 84 → Ldark 60).
// Always uses DARK labels.
const PALETTE: Record<Flow, FlowPalette> = {
aufwendungen: { h: 295, cMax: 0.17 }, // deep plum-purple
ertraege: { h: 55, cMax: 0.16 }, // warm orange / amber
aufwendungen: {
h: 295,
cMax: 0.19,
cFloor: 0.55,
Llight: 50,
Ldark: 22,
label: "oklch(96% 0.01 295)",
},
ertraege: {
h: 55,
cMax: 0.15,
cFloor: 0.6,
Llight: 90,
Ldark: 66,
label: "oklch(22% 0.04 55)",
},
};
interface TileColorOptions {
@@ -43,23 +73,21 @@ export function tileColor({ share, flow }: TileColorOptions): {
fill: string;
label: string;
} {
const { h, cMax } = PALETTE[flow];
const p = PALETTE[flow];
// Cube-root maps a long-tail distribution into a more even one.
const t = Math.cbrt(Math.max(0, Math.min(1, share)));
// Lightness from 78% (smallest) down to 38% (largest). Above 78% the
// tile vanishes against the paper; below 38% we lose label contrast.
const L = 78 - t * 40;
// Chroma scales with t too, but shallower — small tiles still feel
// tinted, not gray.
const C = cMax * (0.45 + 0.55 * t);
// Lightness walks from Llight (smallest tile) to Ldark (largest)
// along the t-axis. Each flow's range stays on one side of the
// label-contrast threshold so labels are a single colour per flow.
const L = p.Llight - t * (p.Llight - p.Ldark);
// Chroma also scales with t, with a flow-tunable floor so small
// tiles still feel saturated rather than pastel.
const C = p.cMax * (p.cFloor + (1 - p.cFloor) * t);
const fill = `oklch(${L.toFixed(1)}% ${C.toFixed(3)} ${h})`;
// Switch label color when the tile is dark enough to need light text.
const label =
L > 56
? `oklch(20% 0.02 ${h})`
: `oklch(96% 0.01 ${h})`;
return { fill, label };
return {
fill: `oklch(${L.toFixed(1)}% ${C.toFixed(3)} ${p.h})`,
label: p.label,
};
}
+26 -11
View File
@@ -35,13 +35,21 @@ export const DEPTH_GRUPPE = 2;
export const DEPTH_CATEGORY = 3;
export const DEPTH_BREAKDOWN = 4;
/** Column boundaries on the depth axis (viewBox x). depth d ∈ {1..4}
* occupies x in [COL_X[d-1], COL_X[d]]. At the OVERVIEW state the
* depth-4 (Breakdown) column collapses to zero width so the
* fine-print stays hidden — depth-4 only becomes visible once the
* user zooms in (see ZOOM_COL_BOUNDS in Icicle.astro for the
* per-zoom-state widths). */
export const COL_X = [0, 770, 1540, VB_W, VB_W];
/** Width of the inter-column gutter, in viewBox units. ≈ 1px on
* screen at typical scale; rendered as a paper-coloured strip in
* the SVG that masks the year-outline horizontals at the boundary. */
export const GUTTER = 2;
/** Column rectangles on the depth axis at the OVERVIEW state. The
* gutter sits between adjacent rects (e.g. [d].r → [d+1].l). Depth
* 4 (Breakdown) collapses to zero width so the fine-print stays
* hidden until the user zooms in. */
export const COL_RECTS: ReadonlyArray<{ l: number; r: number }> = [
{ l: 0, r: 770 - GUTTER / 2 },
{ l: 770 + GUTTER / 2, r: 1540 - GUTTER / 2 },
{ l: 1540 + GUTTER / 2, r: VB_W },
{ l: VB_W, r: VB_W },
];
export interface IcicleNode {
/** 1 = Bereich, 2 = Gruppe, 3 = Category, 4 = Breakdown item. */
@@ -184,6 +192,11 @@ export function icicleLayout(
// levels. We pass dx = VB_H so the share axis fits our viewBox height,
// then in the output we override the depth-axis coordinates ourselves
// (otherwise the synthetic root steals one column of width).
// No partition padding here — sibling gaps are inset on top of the
// zoom-transformed coords in the component so they stay an
// absolute size regardless of how zoomed-in we are. (With
// partition.padding > 0, the gap multiplies by yScale during zoom
// and balloons.)
const layoutRoot = d3partition<InputDatum>()
.size([VB_H, VB_W])
.padding(0)(root) as HierarchyRectangularNode<InputDatum>;
@@ -205,12 +218,14 @@ export function icicleLayout(
const path = ancestorSlugs.join("/");
const parentPath = ancestorSlugs.slice(0, -1).join("/");
// x (depth axis) — explicit per-depth column boundaries (Bereich and
// Gruppe wide, Category narrow).
// x (depth axis) — explicit per-depth column rectangles with
// 2rem gutters between adjacent columns. (Runtime applies a
// different rect set per zoom state.)
// y (share axis) — partition's x0/x1 output (we passed size with
// dx = VB_H, so share already runs [0, VB_H]).
const xCol0 = COL_X[node.depth - 1] ?? 0;
const xCol1 = COL_X[node.depth] ?? VB_W;
const colRect = COL_RECTS[node.depth - 1] ?? { l: 0, r: VB_W };
const xCol0 = colRect.l;
const xCol1 = colRect.r;
nodes.push({
depth: node.depth,
+119 -42
View File
@@ -193,7 +193,6 @@ const yearEnd = tree.years.at(-1)!;
style={`height: ${(aufwandRatio * 100).toFixed(2)}%;`}
/>
</span>
<span class="timeline-bar-label">{y}</span>
</button>
);
})
@@ -201,6 +200,34 @@ const yearEnd = tree.years.at(-1)!;
</div>
</section>
{/* Sticky year axis — a thin row of year labels aligned
under the timeline bars. The CURRENT year's tick also
shows the same two-flow mini-histogram as the bars
above, so when the user scrolls past the full
histogram the active year's reading stays visible as
a compact indicator pinned to the top of the
viewport. */}
<div class="timeline-axis" aria-hidden="true">
{
multiYear.years.map((y) => {
const isCurrent = y === defaultYear;
return (
<span
class:list={[
"timeline-axis-tick",
{ "is-current": isCurrent },
]}
data-year={y}
>
<span class="timeline-axis-label">
{y}
</span>
</span>
);
})
}
</div>
<section class="spread">
<aside
class="sidebar"
@@ -262,16 +289,10 @@ const yearEnd = tree.years.at(-1)!;
<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
unter Oberbürgermeister Tilman Fuchs.
</p>
<p>
Klicken Sie auf einen
Block rechts, um in den Bereich hineinzuzoomen.
</p>
</div>
@@ -810,10 +831,12 @@ const yearEnd = tree.years.at(-1)!;
flow color; the others stay as a grey outline. Click any bar to
scrub; drag horizontally for continuous scrubbing. */
/* Year slider section — title + histogram. Scrolls away
normally; only the bottom year-axis (a separate sibling)
sticks. */
.timeline {
margin-bottom: var(--space-xl);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--rule);
margin-bottom: 0;
padding-bottom: var(--space-2xs);
}
.timeline-eyebrow {
margin: 0 0 var(--space-sm);
@@ -909,29 +932,6 @@ const yearEnd = tree.years.at(-1)!;
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;
@@ -940,6 +940,73 @@ const yearEnd = tree.years.at(-1)!;
outline: none;
}
/* Sticky year axis — sits as a sibling after the
timeline section. Once the histogram bars scroll out of
view, this axis pins to the top of the viewport so the
year reading stays anchored as the icicle scrolls
below. The axis is a flex row matching the bar layout
(21 evenly-spaced cells). The active year's tick gets
a 2px flow-color tick line above its label as the only
selection indicator. */
.timeline-axis {
position: sticky;
top: 0;
z-index: 5;
display: flex;
gap: 4px;
background: var(--paper);
padding: var(--space-2xs) 0 var(--space-2xs);
margin-bottom: var(--space-md);
border-bottom: 1px solid var(--rule);
}
.timeline-axis-tick {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.timeline-axis-tick::before {
content: "";
display: block;
height: 2px;
width: 100%;
background: transparent;
transition: background 220ms ease-out;
}
.timeline-axis-tick.is-current::before {
background: var(--flow-aufwand);
}
[data-flow-state="ertraege"]
.timeline-axis-tick.is-current::before {
background: var(--flow-ertrag);
}
.timeline-axis-label {
font-family: var(--face-mono);
font-size: 0.6rem;
color: var(--ink-mute);
text-align: center;
opacity: 0;
white-space: nowrap;
transition:
opacity 180ms ease-out,
color 180ms ease-out;
}
.timeline-axis-tick:nth-child(5n + 1) .timeline-axis-label,
.timeline-axis-tick.is-current .timeline-axis-label {
opacity: 1;
}
.timeline-axis-tick.is-current .timeline-axis-label {
color: var(--flow-aufwand);
font-weight: 600;
}
[data-flow-state="ertraege"]
.timeline-axis-tick.is-current
.timeline-axis-label {
color: var(--flow-ertrag);
}
/* ── Page footer ─────────────────────────────────────────────── */
.page-footer {
@@ -1085,18 +1152,28 @@ const yearEnd = tree.years.at(-1)!;
const sliderTrack =
slider.querySelector<HTMLDivElement>(".timeline-bars");
const axisTicks = Array.from(
document.querySelectorAll<HTMLElement>(
".timeline-axis-tick",
),
);
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 ?? "";
const targetYear = bar.dataset.year ?? "";
for (const tick of axisTicks) {
tick.classList.toggle(
"is-current",
tick.dataset.year === targetYear,
);
}
slider.dataset.year = targetYear;
slider.dataset.yearIndex = String(i);
slider.setAttribute(
"aria-valuenow",
bar.dataset.year ?? "0",
);
slider.setAttribute("aria-valuenow", targetYear || "0");
if (figureEl) {
figureEl.dispatchEvent(
new CustomEvent("icicle:setyear", {
+12 -8
View File
@@ -42,17 +42,21 @@
/* 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. */
The two ranges sit on opposite sides of the label-contrast
threshold so all labels on each flow's icicle can be a single
colour: light text on the dark purples, dark text on the light
oranges. Mirrors the JS PALETTE in src/lib/colors.ts. */
--flow-aufwand-h: 295; /* deep plum-purple */
--flow-aufwand-c: 0.17;
--flow-aufwand-c: 0.19;
--flow-ertrag-h: 55; /* warm orange / amber */
--flow-ertrag-c: 0.16;
--flow-ertrag-c: 0.15;
/* 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));
/* Solid ink-on-paper renders of each flow accent (used for active
toggle state, breadcrumb separators, slider handle, etc.). The
accent uses Ldark / Llight values that match the bar palette's
darkest-purple and lightest-readable-orange respectively. */
--flow-aufwand: oklch(34% var(--flow-aufwand-c) var(--flow-aufwand-h));
--flow-ertrag: oklch(68% var(--flow-ertrag-c) var(--flow-ertrag-h));
/* ── Spacing (4pt scale, semantic names) ──────────────────────────── */
--space-2xs: 4px;