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;