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 <noreply@anthropic.com> --- 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<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)})`} - {node.name} + style={`font-size: ${nameSize}px;`} + > + {nameLines.map((line, i) => ( + + {line} + + ))} +