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:
+386
-51
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user