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,
|
BothFlowsLayout,
|
||||||
IcicleNode,
|
IcicleNode,
|
||||||
} from "../lib/icicle.ts";
|
} 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 { 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";
|
import type { Flow } from "../data/types.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -104,6 +104,44 @@ const DEPTH_STYLE: Record<number, DepthStyle> = {
|
|||||||
const PAD_X = 18; // viewBox units
|
const PAD_X = 18; // viewBox units
|
||||||
const PAD_TOP = 12; // viewBox units, breathing room above the name
|
const PAD_TOP = 12; // viewBox units, breathing room above the name
|
||||||
const NAME_META_GAP = 8;
|
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">
|
<div class="icicle-tooltip" role="tooltip" aria-hidden="true">
|
||||||
@@ -126,7 +164,7 @@ const NAME_META_GAP = 8;
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${VB_W} ${VB_H}`}
|
viewBox={`0 0 ${VB_W} ${VB_H}`}
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
role="img"
|
role="img"
|
||||||
aria-hidden="false"
|
aria-hidden="false"
|
||||||
>
|
>
|
||||||
@@ -136,6 +174,12 @@ const NAME_META_GAP = 8;
|
|||||||
current year's icicle (on top) reads against the comparative
|
current year's icicle (on top) reads against the comparative
|
||||||
backdrop of every other year. The current year's outline gets
|
backdrop of every other year. The current year's outline gets
|
||||||
a `is-current-year` class JS-toggles on year change. */}
|
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">
|
<g class="year-outlines" aria-hidden="true">
|
||||||
{bothFlows.years.map((year, i) => {
|
{bothFlows.years.map((year, i) => {
|
||||||
// Initial outline heights use the DEFAULT-flow's per-year
|
// Initial outline heights use the DEFAULT-flow's per-year
|
||||||
@@ -160,6 +204,28 @@ const NAME_META_GAP = 8;
|
|||||||
})}
|
})}
|
||||||
</g>
|
</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) => {
|
{flowOrder.flatMap((flow) => {
|
||||||
const flowLayout = bothFlows.byFlow[flow].initial;
|
const flowLayout = bothFlows.byFlow[flow].initial;
|
||||||
const flowRatio = dataByFlow[flow]!.ratio;
|
const flowRatio = dataByFlow[flow]!.ratio;
|
||||||
@@ -171,10 +237,14 @@ const NAME_META_GAP = 8;
|
|||||||
.filter((n) => n.y1 - n.y0 > 0)
|
.filter((n) => n.y1 - n.y0 > 0)
|
||||||
.map((rawNode: IcicleNode) => {
|
.map((rawNode: IcicleNode) => {
|
||||||
const ssrYOffset = (VB_H * (1 - flowRatio)) / 2;
|
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 = {
|
const node: IcicleNode = {
|
||||||
...rawNode,
|
...rawNode,
|
||||||
y0: rawNode.y0 * flowRatio + ssrYOffset,
|
y0: rawNode.y0 * flowRatio + ssrYOffset + GAP_Y / 2,
|
||||||
y1: rawNode.y1 * flowRatio + ssrYOffset,
|
y1: rawNode.y1 * flowRatio + ssrYOffset - GAP_Y / 2,
|
||||||
};
|
};
|
||||||
const w = node.x1 - node.x0;
|
const w = node.x1 - node.x0;
|
||||||
const h = node.y1 - node.y0;
|
const h = node.y1 - node.y0;
|
||||||
@@ -197,8 +267,22 @@ const NAME_META_GAP = 8;
|
|||||||
// effective depth) take over on zoom.
|
// effective depth) take over on zoom.
|
||||||
const showsName = node.depth < 3 && h >= style.minNameH;
|
const showsName = node.depth < 3 && h >= style.minNameH;
|
||||||
const showsMeta = node.depth < 3 && h >= style.minMetaH;
|
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 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 cx = node.x0 + PAD_X;
|
||||||
const classes = [
|
const classes = [
|
||||||
"bar",
|
"bar",
|
||||||
@@ -217,14 +301,12 @@ const NAME_META_GAP = 8;
|
|||||||
data-parent-path={node.parentPath}
|
data-parent-path={node.parentPath}
|
||||||
data-depth={node.depth}
|
data-depth={node.depth}
|
||||||
data-name={node.name}
|
data-name={node.name}
|
||||||
|
data-name-lines={lineCount}
|
||||||
data-value={String(node.value)}
|
data-value={String(node.value)}
|
||||||
data-value-compact={fmtEuroCompact(node.value)}
|
data-value-compact={fmtEuroCompact(node.value)}
|
||||||
data-share-grand={String(node.shareOfGrand)}
|
data-share-grand={String(node.shareOfGrand)}
|
||||||
data-share-parent={String(node.shareOfParent)}
|
data-share-parent={String(node.shareOfParent)}
|
||||||
>
|
>
|
||||||
<title>
|
|
||||||
{`${node.name} · ${fmtEuroCompact(node.value)} (${fmtPercentage(node.shareOfParent)})`}
|
|
||||||
</title>
|
|
||||||
<rect
|
<rect
|
||||||
x={node.x0}
|
x={node.x0}
|
||||||
y={node.y0}
|
y={node.y0}
|
||||||
@@ -238,8 +320,14 @@ const NAME_META_GAP = 8;
|
|||||||
y={nameY}
|
y={nameY}
|
||||||
fill={label}
|
fill={label}
|
||||||
dominant-baseline="hanging"
|
dominant-baseline="hanging"
|
||||||
style={`font-size: ${style.nameSize}px;`}
|
style={`font-size: ${nameSize}px;`}
|
||||||
>{node.name}</text>
|
>
|
||||||
|
{nameLines.map((line, i) => (
|
||||||
|
<tspan x={cx} dy={i === 0 ? 0 : lineHeight}>
|
||||||
|
{line}
|
||||||
|
</tspan>
|
||||||
|
))}
|
||||||
|
</text>
|
||||||
<text
|
<text
|
||||||
class="bar-meta"
|
class="bar-meta"
|
||||||
x={cx}
|
x={cx}
|
||||||
@@ -267,6 +355,12 @@ const NAME_META_GAP = 8;
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 5;
|
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;
|
position: relative;
|
||||||
}
|
}
|
||||||
.icicle svg {
|
.icicle svg {
|
||||||
@@ -275,11 +369,11 @@ const NAME_META_GAP = 8;
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active flow toggle — only one set of bars is rendered at a time.
|
/* Active flow toggle — only one set of bars + bands is rendered
|
||||||
Both flows are SSR'd; CSS hides whichever isn't selected by the
|
at a time. Both flows are SSR'd; CSS hides whichever isn't
|
||||||
figure's data-active-flow attribute. JS filters by active flow
|
selected by the figure's data-active-flow attribute. JS filters
|
||||||
when applying layout/zoom updates so inactive bars aren't
|
by active flow when applying layout updates so inactive bars
|
||||||
re-laid-out unnecessarily. */
|
aren't re-laid-out unnecessarily. */
|
||||||
.icicle[data-active-flow="aufwendungen"] .bar[data-flow="ertraege"],
|
.icicle[data-active-flow="aufwendungen"] .bar[data-flow="ertraege"],
|
||||||
.icicle[data-active-flow="ertraege"] .bar[data-flow="aufwendungen"] {
|
.icicle[data-active-flow="ertraege"] .bar[data-flow="aufwendungen"] {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -303,6 +397,22 @@ const NAME_META_GAP = 8;
|
|||||||
opacity: 0.7;
|
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
|
/* Animation strategy: CSS transitions on the SVG geometry attributes
|
||||||
themselves. Modern browsers animate `x`, `y`, `width`, `height`
|
themselves. Modern browsers animate `x`, `y`, `width`, `height`
|
||||||
directly when they change via setAttribute(), which is dramatically
|
directly when they change via setAttribute(), which is dramatically
|
||||||
@@ -458,23 +568,74 @@ const NAME_META_GAP = 8;
|
|||||||
const VB_W = 1600;
|
const VB_W = 1600;
|
||||||
const VB_H = 2000;
|
const VB_H = 2000;
|
||||||
|
|
||||||
// Column boundaries by zoom depth. 5-element arrays index into the
|
// Column rectangles per zoom depth. Each entry has 4 {l, r}
|
||||||
// 4-level hierarchy (Bereich/Gruppe/Category/Breakdown) with bound
|
// pairs (one per absolute depth). A small 2-vb gutter sits
|
||||||
// i giving the LEFT edge of depth i+1. Levels above the zoom depth
|
// between adjacent visible columns — paper-coloured so it masks
|
||||||
// sit OFFSCREEN so the visibility check naturally hides them.
|
// year-outline horizontals at the boundary. Offscreen depths get
|
||||||
//
|
// a far-negative l/r so the visibility check naturally hides
|
||||||
// At the overview, depth-4 (Breakdowns) is collapsed to zero width
|
// them. The depth-4 (Breakdown) column collapses to zero width
|
||||||
// so the fine-print stays out of sight until the user zooms — the
|
// at overview so the fine-print stays hidden until the user
|
||||||
// top-level view should read as the three structural levels of the
|
// zooms in.
|
||||||
// budget, not include the deepest detail.
|
|
||||||
//
|
|
||||||
// Be Gr Ca Bd
|
|
||||||
const OFFSCREEN = -1e6;
|
const OFFSCREEN = -1e6;
|
||||||
const ZOOM_COL_BOUNDS: number[][] = [
|
const GUTTER = 2;
|
||||||
[0, 770, 1540, VB_W, VB_W], // zoom 0 (overview)
|
const HALF = GUTTER / 2;
|
||||||
[OFFSCREEN, 0, 800, 1280, VB_W], // zoom 1 (Bereich)
|
type ColRect = { l: number; r: number };
|
||||||
[OFFSCREEN, OFFSCREEN, 0, 960, VB_W], // zoom 2 (Gruppe)
|
const ZOOM_COL_RECTS: ColRect[][] = [
|
||||||
[OFFSCREEN, OFFSCREEN, OFFSCREEN, 0, VB_W], // zoom 3 (Category)
|
// 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"
|
// Per-EFFECTIVE-depth label thresholds and sizes. "Effective depth"
|
||||||
@@ -507,11 +668,28 @@ const NAME_META_GAP = 8;
|
|||||||
nameY0: number;
|
nameY0: number;
|
||||||
/** Original baseline y of the meta <text>, viewBox units. */
|
/** Original baseline y of the meta <text>, viewBox units. */
|
||||||
metaY0: number;
|
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) {
|
function init(figure: HTMLElement) {
|
||||||
const svg = figure.querySelector("svg");
|
const svgEl = figure.querySelector("svg");
|
||||||
if (!svg) return;
|
if (!svgEl) return;
|
||||||
|
const svg: SVGSVGElement = svgEl;
|
||||||
|
|
||||||
const groups = Array.from(
|
const groups = Array.from(
|
||||||
svg.querySelectorAll<SVGGElement>(".bar")
|
svg.querySelectorAll<SVGGElement>(".bar")
|
||||||
@@ -554,6 +732,12 @@ const NAME_META_GAP = 8;
|
|||||||
ertraege: new Map(barsByFlow.ertraege.map((b) => [b.path, b])),
|
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.
|
// Read the embedded multi-year + multi-flow payload.
|
||||||
type FlowData = {
|
type FlowData = {
|
||||||
totals: number[];
|
totals: number[];
|
||||||
@@ -617,7 +801,19 @@ const NAME_META_GAP = 8;
|
|||||||
// 1 = Bereich-zoom, 2 = Gruppe-zoom). Drives column bounds and
|
// 1 = Bereich-zoom, 2 = Gruppe-zoom). Drives column bounds and
|
||||||
// effective-depth label sizing.
|
// effective-depth label sizing.
|
||||||
const zoomDepth = path === "" ? 0 : path.split("/").length;
|
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 targetLive = target ? liveOrig(target) : null;
|
||||||
const pY0 = targetLive?.y0 ?? 0;
|
const pY0 = targetLive?.y0 ?? 0;
|
||||||
@@ -640,16 +836,23 @@ const NAME_META_GAP = 8;
|
|||||||
: 1;
|
: 1;
|
||||||
const yOffset = (VB_H * (1 - ratio)) / 2;
|
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) {
|
for (const b of activeBars) {
|
||||||
const live = liveOrig(b);
|
const live = liveOrig(b);
|
||||||
const newY0 = (live.y0 - pY0) * yScale * ratio + yOffset;
|
const newY0 =
|
||||||
const newY1 = (live.y1 - pY0) * yScale * ratio + yOffset;
|
(live.y0 - pY0) * yScale * ratio + yOffset + GAP_Y / 2;
|
||||||
// X positions come from the zoom-aware column bounds, NOT from
|
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
|
// the bar's original x — this is what lets us re-allocate
|
||||||
// 66/33 after Bereich zoom and 100% after Gruppe zoom rather
|
// column widths per zoom state (e.g. 66/33 after Bereich
|
||||||
// than just stretching the original 770/770/60 split.
|
// zoom) rather than just stretching the original split.
|
||||||
const newX0 = cols[b.depth - 1] ?? OFFSCREEN;
|
const colRect = colRects[b.depth - 1] ?? { l: OFFSCREEN, r: OFFSCREEN };
|
||||||
const newX1 = cols[b.depth] ?? OFFSCREEN;
|
const newX0 = colRect.l;
|
||||||
|
const newX1 = colRect.r;
|
||||||
const newW = Math.max(0, newX1 - newX0);
|
const newW = Math.max(0, newX1 - newX0);
|
||||||
const newH = Math.max(0, newY1 - newY0);
|
const newH = Math.max(0, newY1 - newY0);
|
||||||
|
|
||||||
@@ -686,10 +889,19 @@ const NAME_META_GAP = 8;
|
|||||||
1,
|
1,
|
||||||
Math.min(4, b.depth - zoomDepth)
|
Math.min(4, b.depth - zoomDepth)
|
||||||
);
|
);
|
||||||
const nameSize = DEPTH_NAME_SIZE[effectiveDepth] ?? 22;
|
|
||||||
const metaSize = DEPTH_META_SIZE[effectiveDepth] ?? 0;
|
const metaSize = DEPTH_META_SIZE[effectiveDepth] ?? 0;
|
||||||
const minName = MIN_NAME_H[effectiveDepth] ?? 32;
|
const minName = MIN_NAME_H[effectiveDepth] ?? 32;
|
||||||
const minMeta = MIN_META_H[effectiveDepth] ?? 70;
|
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
|
// Labels appear only at effective depths 1 and 2 — the active
|
||||||
// level and one step below it. Anything deeper (categories at
|
// 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-name", labelEligible && newH >= minName);
|
||||||
b.g.classList.toggle("shows-meta", labelEligible && newH >= minMeta);
|
b.g.classList.toggle("shows-meta", labelEligible && newH >= minMeta);
|
||||||
|
|
||||||
// Top-anchor labels with constant PAD_TOP_RT padding. Font
|
// Cache geometry the sticky-label scroll handler will need
|
||||||
// sizes update with effective depth so the active level has
|
// so it can recompute label y without re-running the layout.
|
||||||
// the largest type after every zoom.
|
const lineCount = Number(b.g.dataset.nameLines ?? "1");
|
||||||
const nameY = newY0 + PAD_TOP_RT;
|
const lineHeight = nameSize * 1.05;
|
||||||
const metaY = nameY + nameSize + NAME_META_GAP_RT;
|
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) {
|
if (b.name) {
|
||||||
b.name.setAttribute("x", String(newX0 + PAD_X_RT));
|
const tx = String(newX0 + PAD_X_RT);
|
||||||
b.name.setAttribute("y", String(nameY));
|
b.name.setAttribute("x", tx);
|
||||||
b.name.style.fontSize = `${nameSize}px`;
|
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) {
|
if (b.meta) {
|
||||||
b.meta.setAttribute("x", String(newX0 + PAD_X_RT));
|
b.meta.setAttribute("x", String(newX0 + PAD_X_RT));
|
||||||
b.meta.setAttribute("y", String(metaY));
|
|
||||||
b.meta.style.fontSize = `${metaSize}px`;
|
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
|
// Update value + share displayed text and the data-* attrs the
|
||||||
// tooltip and sidebar consume — for the current year, in the
|
// tooltip and sidebar consume — for the current year, in the
|
||||||
// bar's own flow.
|
// 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;
|
currentPath = path;
|
||||||
figure.classList.toggle("is-zoomed", path !== "");
|
figure.classList.toggle("is-zoomed", path !== "");
|
||||||
figure.dataset.zoomPath = 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.
|
// Notify the page so the sidebar can update its content.
|
||||||
figure.dispatchEvent(
|
figure.dispatchEvent(
|
||||||
new CustomEvent("icicle:zoom", {
|
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.
|
// Convenience for parts of the code that historically called zoomTo.
|
||||||
function zoomTo(path: string) {
|
function zoomTo(path: string) {
|
||||||
applyLayout(path);
|
applyLayout(path);
|
||||||
|
|||||||
+46
-18
@@ -12,16 +12,46 @@ import type { Flow } from "../data/types.js";
|
|||||||
interface FlowPalette {
|
interface FlowPalette {
|
||||||
/** OKLCH hue, degrees. */
|
/** OKLCH hue, degrees. */
|
||||||
h: number;
|
h: number;
|
||||||
/** OKLCH chroma at the darkest end of the scale. */
|
/** OKLCH chroma at the largest tile (most saturated end). */
|
||||||
cMax: number;
|
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
|
// 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
|
// 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> = {
|
const PALETTE: Record<Flow, FlowPalette> = {
|
||||||
aufwendungen: { h: 295, cMax: 0.17 }, // deep plum-purple
|
aufwendungen: {
|
||||||
ertraege: { h: 55, cMax: 0.16 }, // warm orange / amber
|
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 {
|
interface TileColorOptions {
|
||||||
@@ -43,23 +73,21 @@ export function tileColor({ share, flow }: TileColorOptions): {
|
|||||||
fill: string;
|
fill: string;
|
||||||
label: string;
|
label: string;
|
||||||
} {
|
} {
|
||||||
const { h, cMax } = PALETTE[flow];
|
const p = PALETTE[flow];
|
||||||
|
|
||||||
// Cube-root maps a long-tail distribution into a more even one.
|
// Cube-root maps a long-tail distribution into a more even one.
|
||||||
const t = Math.cbrt(Math.max(0, Math.min(1, share)));
|
const t = Math.cbrt(Math.max(0, Math.min(1, share)));
|
||||||
|
|
||||||
// Lightness from 78% (smallest) down to 38% (largest). Above 78% the
|
// Lightness walks from Llight (smallest tile) to Ldark (largest)
|
||||||
// tile vanishes against the paper; below 38% we lose label contrast.
|
// along the t-axis. Each flow's range stays on one side of the
|
||||||
const L = 78 - t * 40;
|
// label-contrast threshold so labels are a single colour per flow.
|
||||||
// Chroma scales with t too, but shallower — small tiles still feel
|
const L = p.Llight - t * (p.Llight - p.Ldark);
|
||||||
// tinted, not gray.
|
// Chroma also scales with t, with a flow-tunable floor so small
|
||||||
const C = cMax * (0.45 + 0.55 * t);
|
// 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})`;
|
return {
|
||||||
// Switch label color when the tile is dark enough to need light text.
|
fill: `oklch(${L.toFixed(1)}% ${C.toFixed(3)} ${p.h})`,
|
||||||
const label =
|
label: p.label,
|
||||||
L > 56
|
};
|
||||||
? `oklch(20% 0.02 ${h})`
|
|
||||||
: `oklch(96% 0.01 ${h})`;
|
|
||||||
return { fill, label };
|
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-11
@@ -35,13 +35,21 @@ export const DEPTH_GRUPPE = 2;
|
|||||||
export const DEPTH_CATEGORY = 3;
|
export const DEPTH_CATEGORY = 3;
|
||||||
export const DEPTH_BREAKDOWN = 4;
|
export const DEPTH_BREAKDOWN = 4;
|
||||||
|
|
||||||
/** Column boundaries on the depth axis (viewBox x). depth d ∈ {1..4}
|
/** Width of the inter-column gutter, in viewBox units. ≈ 1px on
|
||||||
* occupies x in [COL_X[d-1], COL_X[d]]. At the OVERVIEW state the
|
* screen at typical scale; rendered as a paper-coloured strip in
|
||||||
* depth-4 (Breakdown) column collapses to zero width so the
|
* the SVG that masks the year-outline horizontals at the boundary. */
|
||||||
* fine-print stays hidden — depth-4 only becomes visible once the
|
export const GUTTER = 2;
|
||||||
* user zooms in (see ZOOM_COL_BOUNDS in Icicle.astro for the
|
|
||||||
* per-zoom-state widths). */
|
/** Column rectangles on the depth axis at the OVERVIEW state. The
|
||||||
export const COL_X = [0, 770, 1540, VB_W, VB_W];
|
* 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 {
|
export interface IcicleNode {
|
||||||
/** 1 = Bereich, 2 = Gruppe, 3 = Category, 4 = Breakdown item. */
|
/** 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,
|
// 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
|
// then in the output we override the depth-axis coordinates ourselves
|
||||||
// (otherwise the synthetic root steals one column of width).
|
// (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>()
|
const layoutRoot = d3partition<InputDatum>()
|
||||||
.size([VB_H, VB_W])
|
.size([VB_H, VB_W])
|
||||||
.padding(0)(root) as HierarchyRectangularNode<InputDatum>;
|
.padding(0)(root) as HierarchyRectangularNode<InputDatum>;
|
||||||
@@ -205,12 +218,14 @@ export function icicleLayout(
|
|||||||
const path = ancestorSlugs.join("/");
|
const path = ancestorSlugs.join("/");
|
||||||
const parentPath = ancestorSlugs.slice(0, -1).join("/");
|
const parentPath = ancestorSlugs.slice(0, -1).join("/");
|
||||||
|
|
||||||
// x (depth axis) — explicit per-depth column boundaries (Bereich and
|
// x (depth axis) — explicit per-depth column rectangles with
|
||||||
// Gruppe wide, Category narrow).
|
// 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
|
// y (share axis) — partition's x0/x1 output (we passed size with
|
||||||
// dx = VB_H, so share already runs [0, VB_H]).
|
// dx = VB_H, so share already runs [0, VB_H]).
|
||||||
const xCol0 = COL_X[node.depth - 1] ?? 0;
|
const colRect = COL_RECTS[node.depth - 1] ?? { l: 0, r: VB_W };
|
||||||
const xCol1 = COL_X[node.depth] ?? VB_W;
|
const xCol0 = colRect.l;
|
||||||
|
const xCol1 = colRect.r;
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
depth: node.depth,
|
depth: node.depth,
|
||||||
|
|||||||
+119
-42
@@ -193,7 +193,6 @@ const yearEnd = tree.years.at(-1)!;
|
|||||||
style={`height: ${(aufwandRatio * 100).toFixed(2)}%;`}
|
style={`height: ${(aufwandRatio * 100).toFixed(2)}%;`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="timeline-bar-label">{y}</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -201,6 +200,34 @@ const yearEnd = tree.years.at(-1)!;
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="spread">
|
||||||
<aside
|
<aside
|
||||||
class="sidebar"
|
class="sidebar"
|
||||||
@@ -262,16 +289,10 @@ const yearEnd = tree.years.at(-1)!;
|
|||||||
|
|
||||||
<p class="lede" id="ov-lede">
|
<p class="lede" id="ov-lede">
|
||||||
Der Entwurf für 2026/2027 ist der erste Haushalt
|
Der Entwurf für 2026/2027 ist der erste Haushalt
|
||||||
unter Oberbürgermeister Tilman Fuchs. Er sieht für
|
unter Oberbürgermeister Tilman Fuchs.
|
||||||
2026 Aufwendungen von
|
</p>
|
||||||
<strong class="tabular" id="ov-lede-aufwand"
|
<p>
|
||||||
>{fmtEuroCompact(aufwandTotal)}</strong
|
Klicken Sie auf einen
|
||||||
>
|
|
||||||
vor — etwa
|
|
||||||
<strong class="tabular" id="ov-lede-saldo"
|
|
||||||
>{fmtEuroCompact(saldo)}</strong
|
|
||||||
>
|
|
||||||
mehr als die erwarteten Erträge. Klicken Sie auf einen
|
|
||||||
Block rechts, um in den Bereich hineinzuzoomen.
|
Block rechts, um in den Bereich hineinzuzoomen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -810,10 +831,12 @@ const yearEnd = tree.years.at(-1)!;
|
|||||||
flow color; the others stay as a grey outline. Click any bar to
|
flow color; the others stay as a grey outline. Click any bar to
|
||||||
scrub; drag horizontally for continuous scrubbing. */
|
scrub; drag horizontally for continuous scrubbing. */
|
||||||
|
|
||||||
|
/* Year slider section — title + histogram. Scrolls away
|
||||||
|
normally; only the bottom year-axis (a separate sibling)
|
||||||
|
sticks. */
|
||||||
.timeline {
|
.timeline {
|
||||||
margin-bottom: var(--space-xl);
|
margin-bottom: 0;
|
||||||
padding-bottom: var(--space-lg);
|
padding-bottom: var(--space-2xs);
|
||||||
border-bottom: 1px solid var(--rule);
|
|
||||||
}
|
}
|
||||||
.timeline-eyebrow {
|
.timeline-eyebrow {
|
||||||
margin: 0 0 var(--space-sm);
|
margin: 0 0 var(--space-sm);
|
||||||
@@ -909,29 +932,6 @@ const yearEnd = tree.years.at(-1)!;
|
|||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--ink);
|
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 {
|
.timeline-bar:focus-visible .timeline-bar-fill {
|
||||||
outline: 2px solid var(--ink);
|
outline: 2px solid var(--ink);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -940,6 +940,73 @@ const yearEnd = tree.years.at(-1)!;
|
|||||||
outline: none;
|
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 ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.page-footer {
|
.page-footer {
|
||||||
@@ -1085,18 +1152,28 @@ const yearEnd = tree.years.at(-1)!;
|
|||||||
const sliderTrack =
|
const sliderTrack =
|
||||||
slider.querySelector<HTMLDivElement>(".timeline-bars");
|
slider.querySelector<HTMLDivElement>(".timeline-bars");
|
||||||
|
|
||||||
|
const axisTicks = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
".timeline-axis-tick",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
function selectYearByIndex(idx: number) {
|
function selectYearByIndex(idx: number) {
|
||||||
const i = Math.max(0, Math.min(allBars.length - 1, idx));
|
const i = Math.max(0, Math.min(allBars.length - 1, idx));
|
||||||
for (let k = 0; k < allBars.length; k++) {
|
for (let k = 0; k < allBars.length; k++) {
|
||||||
allBars[k]!.classList.toggle("is-current", k === i);
|
allBars[k]!.classList.toggle("is-current", k === i);
|
||||||
}
|
}
|
||||||
const bar = allBars[i]!;
|
const bar = allBars[i]!;
|
||||||
slider.dataset.year = bar.dataset.year ?? "";
|
const targetYear = bar.dataset.year ?? "";
|
||||||
slider.dataset.yearIndex = String(i);
|
for (const tick of axisTicks) {
|
||||||
slider.setAttribute(
|
tick.classList.toggle(
|
||||||
"aria-valuenow",
|
"is-current",
|
||||||
bar.dataset.year ?? "0",
|
tick.dataset.year === targetYear,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
slider.dataset.year = targetYear;
|
||||||
|
slider.dataset.yearIndex = String(i);
|
||||||
|
slider.setAttribute("aria-valuenow", targetYear || "0");
|
||||||
if (figureEl) {
|
if (figureEl) {
|
||||||
figureEl.dispatchEvent(
|
figureEl.dispatchEvent(
|
||||||
new CustomEvent("icicle:setyear", {
|
new CustomEvent("icicle:setyear", {
|
||||||
|
|||||||
+12
-8
@@ -42,17 +42,21 @@
|
|||||||
|
|
||||||
/* Two-hue flow palette per design brief §9.5: deep purple for
|
/* Two-hue flow palette per design brief §9.5: deep purple for
|
||||||
Aufwendungen (money leaving), orange for Erträge (money coming in).
|
Aufwendungen (money leaving), orange for Erträge (money coming in).
|
||||||
Far enough apart on the OKLCH wheel to read as a clear pivot; both
|
The two ranges sit on opposite sides of the label-contrast
|
||||||
hold their own against the warm-paper background. NOT red/green. */
|
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-h: 295; /* deep plum-purple */
|
||||||
--flow-aufwand-c: 0.17;
|
--flow-aufwand-c: 0.19;
|
||||||
--flow-ertrag-h: 55; /* warm orange / amber */
|
--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
|
/* Solid ink-on-paper renders of each flow accent (used for active
|
||||||
state, breadcrumb separators, slider handle, etc.). */
|
toggle state, breadcrumb separators, slider handle, etc.). The
|
||||||
--flow-aufwand: oklch(40% var(--flow-aufwand-c) var(--flow-aufwand-h));
|
accent uses Ldark / Llight values that match the bar palette's
|
||||||
--flow-ertrag: oklch(58% var(--flow-ertrag-c) var(--flow-ertrag-h));
|
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) ──────────────────────────── */
|
/* ── Spacing (4pt scale, semantic names) ──────────────────────────── */
|
||||||
--space-2xs: 4px;
|
--space-2xs: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user