From 04a46caf19b2757b46c75e61d6faa294ccfd9729 Mon Sep 17 00:00:00 2001
From: Flo
Date: Thu, 7 May 2026 16:23:23 +0200
Subject: [PATCH] feat(icicle): refine layout, labels, and zoom UX
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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 lines so labels
no longer overrun their box.
- Drop browser-native 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
---
src/components/Icicle.astro | 437 +++++++++++++++++++++++++++++++-----
src/lib/colors.ts | 64 ++++--
src/lib/icicle.ts | 37 ++-
src/pages/index.astro | 161 +++++++++----
src/styles/global.css | 20 +-
5 files changed, 589 insertions(+), 130 deletions(-)
diff --git a/src/components/Icicle.astro b/src/components/Icicle.astro
index b1da80c..6581a9d 100644
--- a/src/components/Icicle.astro
+++ b/src/components/Icicle.astro
@@ -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 = {
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 = {
+ 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;
+}
---
@@ -126,7 +164,7 @@ const NAME_META_GAP = 8;
>
@@ -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. */}
{bothFlows.years.map((year, i) => {
// Initial outline heights use the DEFAULT-flow's per-year
@@ -160,6 +204,28 @@ const NAME_META_GAP = 8;
})}
+ {/* 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. */}
+
+ {[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 (
+
+ );
+ })}
+
+
{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)}
>
-
- {`${node.name} Β· ${fmtEuroCompact(node.value)} (${fmtPercentage(node.shareOfParent)})`}
-
{node.name}
+ style={`font-size: ${nameSize}px;`}
+ >
+ {nameLines.map((line, i) => (
+
+ {line}
+
+ ))}
+
, 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(".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(".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(
+ ".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);
diff --git a/src/lib/colors.ts b/src/lib/colors.ts
index e4b4755..3257766 100644
--- a/src/lib/colors.ts
+++ b/src/lib/colors.ts
@@ -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 = {
- 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,
+ };
}
diff --git a/src/lib/icicle.ts b/src/lib/icicle.ts
index 284d529..81a4713 100644
--- a/src/lib/icicle.ts
+++ b/src/lib/icicle.ts
@@ -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()
.size([VB_H, VB_W])
.padding(0)(root) as HierarchyRectangularNode;
@@ -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,
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 8bc6f54..ac89717 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -193,7 +193,6 @@ const yearEnd = tree.years.at(-1)!;
style={`height: ${(aufwandRatio * 100).toFixed(2)}%;`}
/>
- {y}
);
})
@@ -201,6 +200,34 @@ const yearEnd = tree.years.at(-1)!;
+ {/* 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. */}
+
+ {
+ multiYear.years.map((y) => {
+ const isCurrent = y === defaultYear;
+ return (
+
+
+ {y}
+
+
+ );
+ })
+ }
+
+
+
+ Klicken Sie auf einen
Block rechts, um in den Bereich hineinzuzoomen.
@@ -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(".timeline-bars");
+ const axisTicks = Array.from(
+ document.querySelectorAll(
+ ".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", {
diff --git a/src/styles/global.css b/src/styles/global.css
index 518b614..b92de3b 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -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;