@jarenjs/charts 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +293 -0
  2. package/dist/types/component/index.d.ts +95 -0
  3. package/dist/types/core/axis.d.ts +77 -0
  4. package/dist/types/core/cartesian.d.ts +127 -0
  5. package/dist/types/core/chart.d.ts +58 -0
  6. package/dist/types/core/domain.d.ts +96 -0
  7. package/dist/types/core/marks.d.ts +86 -0
  8. package/dist/types/core/palette.d.ts +72 -0
  9. package/dist/types/core/scale.d.ts +59 -0
  10. package/dist/types/core/session.d.ts +85 -0
  11. package/dist/types/core/stream-adapter.d.ts +187 -0
  12. package/dist/types/index.d.ts +35 -0
  13. package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
  14. package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
  15. package/dist/types/types/bar.d.ts +213 -0
  16. package/dist/types/types/boxplot.d.ts +116 -0
  17. package/dist/types/types/candlestick.d.ts +218 -0
  18. package/dist/types/types/gauge.d.ts +68 -0
  19. package/dist/types/types/heatmap.d.ts +104 -0
  20. package/dist/types/types/line.d.ts +272 -0
  21. package/dist/types/types/map.d.ts +137 -0
  22. package/dist/types/types/pie.d.ts +146 -0
  23. package/dist/types/types/radar.d.ts +89 -0
  24. package/dist/types/types/sankey.d.ts +100 -0
  25. package/dist/types/types/scatter.d.ts +80 -0
  26. package/dist/types/types/streamgraph.d.ts +75 -0
  27. package/dist/types/types/treemap.d.ts +118 -0
  28. package/package.json +76 -0
  29. package/schemas/chart-definition.schema.json +448 -0
  30. package/src/component/index.js +125 -0
  31. package/src/core/axis.js +221 -0
  32. package/src/core/cartesian.js +192 -0
  33. package/src/core/chart.js +101 -0
  34. package/src/core/domain.js +123 -0
  35. package/src/core/marks.js +110 -0
  36. package/src/core/palette.js +126 -0
  37. package/src/core/scale.js +106 -0
  38. package/src/core/session.js +0 -0
  39. package/src/core/stream-adapter.js +613 -0
  40. package/src/index.js +40 -0
  41. package/src/transforms/benchmark-adapter.js +298 -0
  42. package/src/transforms/mermaid-adapter.js +19 -0
  43. package/src/types/bar.js +276 -0
  44. package/src/types/boxplot.js +216 -0
  45. package/src/types/candlestick.js +274 -0
  46. package/src/types/gauge.js +140 -0
  47. package/src/types/heatmap.js +176 -0
  48. package/src/types/line.js +349 -0
  49. package/src/types/map.js +378 -0
  50. package/src/types/pie.js +163 -0
  51. package/src/types/radar.js +224 -0
  52. package/src/types/sankey.js +391 -0
  53. package/src/types/scatter.js +148 -0
  54. package/src/types/streamgraph.js +158 -0
  55. package/src/types/treemap.js +322 -0
  56. package/styles/charts.css +83 -0
@@ -0,0 +1,148 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The scatter chart type: points over numeric or log axes,
4
+ * per-point semantic tone (win/loss), and an optional horizontal
5
+ * reference line (e.g. ratio = 1). Data shape:
6
+ *
7
+ * data = { points: [{x, y, tone?: 'win'|'loss'}] }
8
+ * config = { type:'scatter', title?, xLog?, yLog?, refY?, refLabel?,
9
+ * xLabel?, yLabel? }
10
+ */
11
+
12
+ import { svgRoot, line as svgLine, textAt, coord } from '@jarenjs/view/helpers';
13
+ import { clamp01 } from '@jarenjs/core/math';
14
+ import { scaleLinear, scaleLog } from '../core/scale.js';
15
+ import { axisTicksLinear, axisTicksLog, formatTickValue } from '../core/axis.js';
16
+ import { cartesianFrame, toneColor, annotateChart, FS_TICK } from '../core/cartesian.js';
17
+ import { CATEGORICAL } from '../core/palette.js';
18
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
19
+
20
+ /**
21
+ * @typedef {object} ScatterAST
22
+ * @property {'scatter'} type
23
+ * @property {string|null} title
24
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} x
25
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} y
26
+ * @property {{u:number,v:number,tone:'win'|'loss'|null,x:number,y:number}[]} points
27
+ * unit position plus the raw sample the hover text reports
28
+ * @property {{v: number, label: string|null}|null} ref
29
+ */
30
+
31
+ /**
32
+ * Build the geometry-free scatter AST.
33
+ * @param {any} data
34
+ * @param {any} [config]
35
+ * @returns {ScatterAST}
36
+ */
37
+ export function buildScatterAST(data, config = {}) {
38
+ const input = data?.points ?? [];
39
+ const xLog = config.xLog === true;
40
+ const yLog = config.yLog === true;
41
+
42
+ const xs = [];
43
+ const ys = [];
44
+ for (const p of input) {
45
+ if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue;
46
+ if (xLog && p.x <= 0) continue;
47
+ if (yLog && p.y <= 0) continue;
48
+ xs.push(p.x);
49
+ ys.push(p.y);
50
+ }
51
+ if (typeof config.refY === 'number' && Number.isFinite(config.refY)
52
+ && (!yLog || config.refY > 0)) {
53
+ ys.push(config.refY);
54
+ }
55
+ const [xScale, xTicks] = axisFor(xs, xLog);
56
+ const [yScale, yTicks] = axisFor(ys, yLog);
57
+
58
+ const points = [];
59
+ for (const p of input) {
60
+ if (!Number.isFinite(p?.x) || !Number.isFinite(p?.y)) continue;
61
+ const u = xScale(p.x);
62
+ const v = yScale(p.y);
63
+ if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
64
+ points.push({ u: clamp01(u), v: clamp01(v), tone: p.tone ?? null, x: p.x, y: p.y });
65
+ }
66
+
67
+ const ref = typeof config.refY === 'number' && Number.isFinite(yScale(config.refY))
68
+ ? { v: clamp01(yScale(config.refY)), label: config.refLabel ?? null }
69
+ : null;
70
+
71
+ return {
72
+ type: 'scatter',
73
+ title: config.title ?? null,
74
+ x: { ticks: xTicks.map((v) => ({ pos: clamp01(xScale(v)), label: formatTickValue(v) })), label: config.xLabel ?? null },
75
+ y: { ticks: yTicks.map((v) => ({ pos: clamp01(yScale(v)), label: formatTickValue(v) })), label: config.yLabel ?? null },
76
+ points,
77
+ ref,
78
+ };
79
+ }
80
+
81
+ function axisFor(values, log) {
82
+ let lo = Infinity;
83
+ let hi = -Infinity;
84
+ for (const v of values) {
85
+ if (v < lo) lo = v;
86
+ if (v > hi) hi = v;
87
+ }
88
+ if (!Number.isFinite(lo)) { lo = log ? 0.1 : 0; hi = 1; }
89
+ if (log) {
90
+ const d0 = Math.pow(10, Math.floor(Math.log10(lo)));
91
+ const d1 = Math.pow(10, Math.ceil(Math.log10(hi)));
92
+ const top = d1 === d0 ? d0 * 10 : d1;
93
+ return [scaleLog(d0, top), axisTicksLog(d0, top)];
94
+ }
95
+ const ticks = axisTicksLinear(lo, hi, 5);
96
+ const min = Math.min(lo, ticks[0] ?? lo);
97
+ const max = Math.max(hi, ticks[ticks.length - 1] ?? hi);
98
+ return [scaleLinear(min, max === min ? min + 1 : max), ticks];
99
+ }
100
+
101
+ /**
102
+ * Render a scatter AST to a pure-vnode SVG. Each dot carries its raw
103
+ * `(x, y)` as a `<title>`: on a log axis a pixel position is not
104
+ * readable back to a value, so the hover text is the only honest way to
105
+ * name the sample.
106
+ * @param {ScatterAST} ast
107
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
108
+ * @param {string} hash
109
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number,
110
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
111
+ * @returns {any}
112
+ */
113
+ export function renderScatterAST(ast, theme, hash, options = {}) {
114
+ const palette = options.palette ?? CATEGORICAL;
115
+ const tooltip = normalizeTooltip(options.tooltip);
116
+ const frame = cartesianFrame({
117
+ title: ast.title,
118
+ legend: null,
119
+ xAxis: ast.x,
120
+ yAxis: ast.y,
121
+ grid: 'y',
122
+ width: options.width,
123
+ palette,
124
+ theme,
125
+ });
126
+ const { plot } = frame;
127
+ const children = frame.children;
128
+ if (ast.ref !== null) {
129
+ const y = coord(plot.y + (1 - ast.ref.v) * plot.h);
130
+ children.push(svgLine(plot.x, y, plot.x + plot.w, y,
131
+ { stroke: theme.tokens.muted, 'stroke-width': 1, 'stroke-dasharray': '4 3', class: 'chart-ref' }));
132
+ if (ast.ref.label) {
133
+ children.push(textAt(plot.x + plot.w, y - 5, ast.ref.label, FS_TICK,
134
+ { 'text-anchor': 'end', fill: theme.tokens.muted, class: 'chart-tick' }));
135
+ }
136
+ }
137
+ for (const p of ast.points) {
138
+ children.push(valueMark('circle', {
139
+ cx: coord(plot.x + p.u * plot.w),
140
+ cy: coord(plot.y + (1 - p.v) * plot.h),
141
+ r: 3,
142
+ fill: toneColor(theme, p.tone, 0, palette), 'fill-opacity': 0.75, class: 'chart-dot',
143
+ }, tooltip, `(${p.x}, ${p.y})`, { type: 'scatter', x: p.x, y: p.y, tone: p.tone }));
144
+ }
145
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-scatter-chart',
146
+ frame.width, frame.height, theme, children, (options.keyPrefix ?? 'scatter-') + hash);
147
+ return annotateChart(svg, ast.title);
148
+ }
@@ -0,0 +1,158 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The streamgraph chart type: stacked series as flowing bands
4
+ * around a silhouette baseline (the stack centered on its running
5
+ * total, so the outline stays symmetric). Data shape:
6
+ *
7
+ * data = { series: [{ name, values: number[] }], xs?: number[] }
8
+ * config = { type:'streamgraph', title?, xLabel? }
9
+ *
10
+ * `values` align by index across series; `xs` optionally places the
11
+ * samples on a numeric x axis (index positions otherwise). Negative
12
+ * and non-finite samples read as 0 — a streamgraph stacks
13
+ * non-negative magnitudes. The y axis is deliberately unlabeled:
14
+ * silhouette offsets make absolute y positions meaningless; band
15
+ * thickness is the encoding, and each band carries its name as hover
16
+ * text.
17
+ */
18
+
19
+ import { svgRoot, coord } from '@jarenjs/view/helpers';
20
+ import { clamp01 } from '@jarenjs/core/math';
21
+ import { scaleLinear } from '../core/scale.js';
22
+ import { axisTicksLinear, formatTickValue } from '../core/axis.js';
23
+ import { cartesianFrame, annotateChart } from '../core/cartesian.js';
24
+ import { CATEGORICAL, seriesColor } from '../core/palette.js';
25
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
26
+
27
+ /**
28
+ * @typedef {object} StreamgraphAST
29
+ * @property {'streamgraph'} type
30
+ * @property {string|null} title
31
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} x
32
+ * @property {{name: string, swatch: number}[]|null} legend
33
+ * @property {{name: string, points: {u:number, lo:number, hi:number}[]}[]} layers
34
+ */
35
+
36
+ /**
37
+ * Build the geometry-free streamgraph AST.
38
+ * @param {any} data
39
+ * @param {any} [config]
40
+ * @returns {StreamgraphAST}
41
+ */
42
+ export function buildStreamgraphAST(data, config = {}) {
43
+ const input = (data?.series ?? []).filter((s) => Array.isArray(s.values));
44
+ const count = Math.max(0, ...input.map((s) => s.values.length));
45
+ const xs = Array.isArray(data?.xs) && data.xs.every((v) => typeof v === 'number' && Number.isFinite(v))
46
+ && data.xs.length >= count && count > 0
47
+ ? data.xs.slice(0, count)
48
+ : null;
49
+
50
+ const at = (s, k) => {
51
+ const v = s.values[k];
52
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0;
53
+ };
54
+
55
+ // Silhouette baseline: the stack at sample k is centered on 0.
56
+ let yMin = Infinity;
57
+ let yMax = -Infinity;
58
+ const stacked = [];
59
+ for (let k = 0; k < count; k++) {
60
+ let total = 0;
61
+ for (const s of input) total += at(s, k);
62
+ let running = -total / 2;
63
+ const column = [];
64
+ for (const s of input) {
65
+ const v = at(s, k);
66
+ column.push([running, running + v]);
67
+ running += v;
68
+ }
69
+ stacked.push(column);
70
+ if (-total / 2 < yMin) yMin = -total / 2;
71
+ if (total / 2 > yMax) yMax = total / 2;
72
+ }
73
+ if (!Number.isFinite(yMin)) { yMin = 0; yMax = 1; }
74
+ const yScale = scaleLinear(yMin, yMax === yMin ? yMin + 1 : yMax);
75
+
76
+ const uOf = xs !== null
77
+ ? scaleLinear(Math.min(...xs), Math.max(...xs) === Math.min(...xs) ? Math.min(...xs) + 1 : Math.max(...xs))
78
+ : null;
79
+ const uAt = (k) => xs !== null ? clamp01(uOf(xs[k]))
80
+ : count <= 1 ? 0.5 : k / (count - 1);
81
+
82
+ const layers = input.map((s, si) => ({
83
+ name: String(s.name ?? ''),
84
+ points: stacked.map((column, k) => ({
85
+ u: uAt(k),
86
+ lo: clamp01(yScale(column[si][0])),
87
+ hi: clamp01(yScale(column[si][1])),
88
+ })),
89
+ }));
90
+
91
+ const tickValues = xs !== null
92
+ ? axisTicksLinear(Math.min(...xs), Math.max(...xs), 5)
93
+ : axisTicksLinear(0, Math.max(0, count - 1), Math.min(5, Math.max(1, count - 1)));
94
+ const ticks = tickValues.map((v) => ({
95
+ pos: xs !== null ? clamp01(uOf(v)) : count <= 1 ? 0.5 : clamp01(v / (count - 1)),
96
+ label: formatTickValue(v),
97
+ }));
98
+
99
+ return {
100
+ type: 'streamgraph',
101
+ title: config.title ?? null,
102
+ x: { ticks, label: config.xLabel ?? null },
103
+ legend: layers.length > 1 ? layers.map((l, i) => ({ name: l.name, swatch: i })) : null,
104
+ layers,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Render a streamgraph AST to a pure-vnode SVG: one closed band path
110
+ * per layer (top edge forward, bottom edge back), separated by the
111
+ * slice-stroke hairline the pie uses between touching fills.
112
+ * @param {StreamgraphAST} ast
113
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
114
+ * @param {string} hash
115
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number,
116
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
117
+ * @returns {any}
118
+ */
119
+ export function renderStreamgraphAST(ast, theme, hash, options = {}) {
120
+ const palette = options.palette ?? CATEGORICAL;
121
+ const tooltip = normalizeTooltip(options.tooltip);
122
+ const frame = cartesianFrame({
123
+ title: ast.title,
124
+ legend: ast.legend,
125
+ xAxis: ast.x,
126
+ yAxis: { ticks: [] },
127
+ grid: 'none',
128
+ width: options.width,
129
+ palette,
130
+ theme,
131
+ });
132
+ const { plot } = frame;
133
+ const children = frame.children;
134
+ for (let si = 0; si < ast.layers.length; si++) {
135
+ const layer = ast.layers[si];
136
+ if (layer.points.length === 0) continue;
137
+ const px = (p) => coord(plot.x + p.u * plot.w);
138
+ const py = (v) => coord(plot.y + (1 - v) * plot.h);
139
+ let d = '';
140
+ for (let k = 0; k < layer.points.length; k++) {
141
+ const p = layer.points[k];
142
+ d += `${k === 0 ? 'M' : 'L'}${px(p)},${py(p.hi)} `;
143
+ }
144
+ for (let k = layer.points.length - 1; k >= 0; k--) {
145
+ const p = layer.points[k];
146
+ d += `L${px(p)},${py(p.lo)} `;
147
+ }
148
+ children.push(valueMark('path', {
149
+ d: d.trimEnd() + ' Z',
150
+ fill: seriesColor(si, palette),
151
+ stroke: theme.tokens.sliceStroke, 'stroke-width': 1,
152
+ class: 'chart-stream-band',
153
+ }, tooltip, layer.name, { type: 'streamgraph', series: layer.name }));
154
+ }
155
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-streamgraph-chart',
156
+ frame.width, frame.height, theme, children, (options.keyPrefix ?? 'stream-') + hash);
157
+ return annotateChart(svg, ast.title);
158
+ }
@@ -0,0 +1,322 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The treemap chart type: part-of-whole tiles by area, laid out
4
+ * with the squarified algorithm (rows chosen to keep tile aspect ratios
5
+ * near 1). Data shape:
6
+ *
7
+ * data = { items: [{ label, value }] } // flat
8
+ * data = { items: [{ label, children: [{label, value}] }] } // grouped
9
+ * config = { type:'treemap', title?, aspect? }
10
+ *
11
+ * The AST is a flat list of unit-square tiles (`x0..x1`/`y0..y1` in
12
+ * [0,1], `y` growing downward — a treemap has no axes, so reading order
13
+ * wins). Squarification optimizes *rendered* aspect ratios, so the
14
+ * build needs the drawing's width:height ratio — that is `aspect`
15
+ * (default 1.6), carried in the AST so the render maps height from
16
+ * width with the same value.
17
+ *
18
+ * One level of grouping is supported: an item with `children` is a
19
+ * group whose value is its children's sum. Groups squarify against each
20
+ * other, then each group's children squarify inside its rect below a
21
+ * header band that names it — the package→module reading. Groups take a
22
+ * palette color each, so a group is one hue and its children are told
23
+ * apart by the hairline between them. A childless item in a grouped
24
+ * chart becomes a group of one, so no data is dropped either way; a
25
+ * chart with no `children` anywhere lays out exactly as it always has.
26
+ */
27
+
28
+ import { svgRoot, textAt, textWidth, coord } from '@jarenjs/view/helpers';
29
+ import { clamp01 } from '@jarenjs/core/math';
30
+ import { FS_LABEL, annotateChart, chartTitle, fitLabel } from '../core/cartesian.js';
31
+ import { CATEGORICAL, seriesColor, inkFor } from '../core/palette.js';
32
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
33
+
34
+ /**
35
+ * @typedef {object} TreemapTileAST
36
+ * @property {string} label
37
+ * @property {number} value
38
+ * @property {number} frac fraction of the total (0..1)
39
+ * @property {number} x0 @property {number} y0
40
+ * @property {number} x1 @property {number} y1
41
+ * @property {string|null} group the group this tile belongs to (null when flat)
42
+ * @property {number} swatch palette index for the fill
43
+ */
44
+ /**
45
+ * @typedef {object} TreemapGroupAST
46
+ * @property {string} label
47
+ * @property {number} value the children's sum
48
+ * @property {number} frac fraction of the total (0..1)
49
+ * @property {number} x0 @property {number} y0
50
+ * @property {number} x1 @property {number} y1
51
+ * @property {number} header height of the naming band at the group's top
52
+ * (a fraction of the map height; the children fill what is left)
53
+ * @property {number} swatch palette index
54
+ */
55
+ /**
56
+ * @typedef {object} TreemapAST
57
+ * @property {'treemap'} type
58
+ * @property {string|null} title
59
+ * @property {number} aspect layout width:height ratio
60
+ * @property {number} total
61
+ * @property {TreemapTileAST[]} tiles value-descending, grouped when
62
+ * `groups` is non-empty (descending inside each group)
63
+ * @property {TreemapGroupAST[]} groups empty for a flat treemap
64
+ */
65
+
66
+ /**
67
+ * The naming band at a group's top, as a fraction of the map height —
68
+ * at the default 560×340 drawing, ~20px, room for a 12px label. It is
69
+ * a fraction because the build has no pixels: a short group's band is
70
+ * capped at a third of its own height, and the render leaves a band
71
+ * that ends up too small for the text empty rather than overprinting
72
+ * it (the group's name is still in every child's hover text).
73
+ */
74
+ const HEADER_FRAC = 0.06;
75
+
76
+ /**
77
+ * Build the geometry-free treemap AST.
78
+ * @param {any} data
79
+ * @param {any} [config]
80
+ * @returns {TreemapAST}
81
+ */
82
+ export function buildTreemapAST(data, config = {}) {
83
+ const aspect = typeof config.aspect === 'number' && Number.isFinite(config.aspect) && config.aspect > 0
84
+ ? config.aspect : 1.6;
85
+ const items = data?.items ?? [];
86
+ const grouped = items.some((item) => Array.isArray(item?.children));
87
+ return grouped
88
+ ? buildGrouped(items, aspect, config)
89
+ : buildFlat(leavesOf(items), aspect, config);
90
+ }
91
+
92
+ /** The positive-valued `{label, value}` leaves of a list, descending. */
93
+ function leavesOf(items) {
94
+ return items
95
+ .filter((item) => typeof item?.value === 'number' && Number.isFinite(item.value) && item.value > 0)
96
+ .map((item) => ({ label: String(item.label ?? ''), value: item.value }))
97
+ .sort((a, b) => b.value - a.value);
98
+ }
99
+
100
+ /** A flat treemap: every leaf squarified against every other. */
101
+ function buildFlat(input, aspect, config) {
102
+ const total = input.reduce((s, item) => s + item.value, 0);
103
+ const tiles = [];
104
+ if (total > 0) {
105
+ // Squarify in aspect-scaled space (width = aspect, height = 1, area
106
+ // = aspect) so the optimized ratios are the ratios the reader sees.
107
+ const rects = squarify(input.map((item) => (item.value / total) * aspect), 0, 0, aspect, 1);
108
+ for (let i = 0; i < input.length; i++) {
109
+ tiles.push({
110
+ label: input[i].label,
111
+ value: input[i].value,
112
+ frac: input[i].value / total,
113
+ ...unitRect(rects[i], aspect),
114
+ group: null,
115
+ swatch: i,
116
+ });
117
+ }
118
+ }
119
+ return { type: 'treemap', title: config.title ?? null, aspect, total, tiles, groups: [] };
120
+ }
121
+
122
+ /**
123
+ * A grouped treemap: groups squarified against each other, each
124
+ * group's children squarified below its header band.
125
+ */
126
+ function buildGrouped(items, aspect, config) {
127
+ const input = [];
128
+ for (const item of items) {
129
+ // a childless item is a group of one, so nothing is dropped
130
+ const children = Array.isArray(item?.children) ? leavesOf(item.children) : leavesOf([item]);
131
+ if (children.length === 0) continue;
132
+ input.push({
133
+ label: String(item?.label ?? ''),
134
+ children,
135
+ value: children.reduce((s, c) => s + c.value, 0),
136
+ });
137
+ }
138
+ input.sort((a, b) => b.value - a.value);
139
+ const total = input.reduce((s, g) => s + g.value, 0);
140
+
141
+ const tiles = [];
142
+ const groups = [];
143
+ if (total > 0) {
144
+ const rects = squarify(input.map((g) => (g.value / total) * aspect), 0, 0, aspect, 1);
145
+ for (let gi = 0; gi < input.length; gi++) {
146
+ const group = input[gi];
147
+ const rect = rects[gi];
148
+ const height = rect.y1 - rect.y0;
149
+ // never eat more than a third of a short group's own height
150
+ const header = Math.min(HEADER_FRAC, height / 3);
151
+ const inner = { x0: rect.x0, y0: rect.y0 + header, x1: rect.x1, y1: rect.y1 };
152
+ groups.push({
153
+ label: group.label,
154
+ value: group.value,
155
+ frac: group.value / total,
156
+ ...unitRect(rect, aspect),
157
+ header,
158
+ swatch: gi,
159
+ });
160
+ const area = (inner.x1 - inner.x0) * (inner.y1 - inner.y0);
161
+ const childRects = squarify(
162
+ group.children.map((c) => (c.value / group.value) * area),
163
+ inner.x0, inner.y0, inner.x1 - inner.x0, inner.y1 - inner.y0);
164
+ for (let ci = 0; ci < group.children.length; ci++) {
165
+ tiles.push({
166
+ label: group.children[ci].label,
167
+ value: group.children[ci].value,
168
+ frac: group.children[ci].value / total,
169
+ ...unitRect(childRects[ci], aspect),
170
+ group: group.label,
171
+ swatch: gi,
172
+ });
173
+ }
174
+ }
175
+ }
176
+ return { type: 'treemap', title: config.title ?? null, aspect, total, tiles, groups };
177
+ }
178
+
179
+ /**
180
+ * Squarify `areas` (summing to `w * h`) into the rect at `x, y` in
181
+ * aspect-scaled space, returning one rect per area in input order.
182
+ * @param {number[]} areas @param {number} x @param {number} y
183
+ * @param {number} w @param {number} h
184
+ * @returns {{x0:number,y0:number,x1:number,y1:number}[]}
185
+ */
186
+ function squarify(areas, x, y, w, h) {
187
+ const rects = [];
188
+ let start = 0;
189
+ while (start < areas.length) {
190
+ // Grow the row while the worst tile aspect ratio keeps improving.
191
+ const side = Math.min(w, h);
192
+ let sum = areas[start];
193
+ let best = worst(areas, start, start + 1, sum, side);
194
+ let end = start + 1;
195
+ while (end < areas.length) {
196
+ const nextSum = sum + areas[end];
197
+ const nextWorst = worst(areas, start, end + 1, nextSum, side);
198
+ if (nextWorst > best) break;
199
+ sum = nextSum;
200
+ best = nextWorst;
201
+ end++;
202
+ }
203
+ // Lay the row along the shorter side.
204
+ const thickness = sum / side;
205
+ let offset = 0;
206
+ for (let i = start; i < end; i++) {
207
+ const length = areas[i] / thickness;
208
+ rects.push(w <= h
209
+ ? { x0: x + offset, y0: y, x1: x + offset + length, y1: y + thickness }
210
+ : { x0: x, y0: y + offset, x1: x + thickness, y1: y + offset + length });
211
+ offset += length;
212
+ }
213
+ if (w <= h) { y += thickness; h -= thickness; }
214
+ else { x += thickness; w -= thickness; }
215
+ start = end;
216
+ }
217
+ return rects;
218
+ }
219
+
220
+ /**
221
+ * An aspect-scaled rect as unit-square coordinates. Clamped: row
222
+ * arithmetic can land an epsilon outside [0,1].
223
+ */
224
+ function unitRect(rect, aspect) {
225
+ return {
226
+ x0: clamp01(rect.x0 / aspect), y0: clamp01(rect.y0),
227
+ x1: clamp01(rect.x1 / aspect), y1: clamp01(rect.y1),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Worst (max) tile aspect ratio of the row `areas[start..end)` with
233
+ * total area `sum` laid along a side of length `side`.
234
+ */
235
+ function worst(areas, start, end, sum, side) {
236
+ const thickness = sum / side;
237
+ let max = 0;
238
+ for (let i = start; i < end; i++) {
239
+ const length = areas[i] / thickness;
240
+ const ratio = Math.max(length / thickness, thickness / length);
241
+ if (ratio > max) max = ratio;
242
+ }
243
+ return max;
244
+ }
245
+
246
+ /**
247
+ * Render a treemap AST to a pure-vnode SVG: inset tile rects on the
248
+ * categorical palette, labels inside the tiles that fit them (ink
249
+ * picked by fill luminance), and a label+share `<title>` on every tile.
250
+ * A grouped treemap adds the group's name in its header band and a
251
+ * hairline between same-hue siblings — both only when there are groups,
252
+ * so a flat treemap renders exactly as it did before grouping existed.
253
+ * @param {TreemapAST} ast
254
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
255
+ * @param {string} hash
256
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number,
257
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
258
+ * @returns {any}
259
+ */
260
+ export function renderTreemapAST(ast, theme, hash, options = {}) {
261
+ const t = theme.tokens;
262
+ const palette = options.palette ?? CATEGORICAL;
263
+ const tooltip = normalizeTooltip(options.tooltip);
264
+ const width = options.width ?? 560;
265
+ const top = ast.title ? 34 : 8;
266
+ const plotW = width - 16;
267
+ const plotH = plotW / ast.aspect;
268
+ const children = [];
269
+
270
+ if (ast.title) {
271
+ children.push(chartTitle(width / 2, 22, ast.title, 15, t));
272
+ }
273
+
274
+ const inset = 1;
275
+ const nested = ast.groups.length !== 0;
276
+
277
+ for (const group of ast.groups) {
278
+ const y = top + group.y0 * plotH;
279
+ const headerH = group.header * plotH;
280
+ if (headerH < FS_LABEL + 2) continue;
281
+ const label = `${group.label} (${(group.frac * 100).toFixed(1)}%)`;
282
+ const x = 8 + group.x0 * plotW;
283
+ const w = (group.x1 - group.x0) * plotW;
284
+ children.push(textAt(coord(x + 3), coord(y + FS_LABEL), fitLabel(label, w - 6, FS_LABEL), FS_LABEL,
285
+ { fill: t.text, 'font-weight': 'bold', class: 'chart-treemap-group' }));
286
+ }
287
+
288
+ for (let i = 0; i < ast.tiles.length; i++) {
289
+ const tile = ast.tiles[i];
290
+ const x = 8 + tile.x0 * plotW + inset;
291
+ const y = top + tile.y0 * plotH + inset;
292
+ const w = (tile.x1 - tile.x0) * plotW - 2 * inset;
293
+ const h = (tile.y1 - tile.y0) * plotH - 2 * inset;
294
+ const fill = seriesColor(tile.swatch, palette);
295
+ const share = `${(tile.frac * 100).toFixed(1)}%`;
296
+ children.push(valueMark('rect', nested
297
+ ? {
298
+ x: coord(x), y: coord(y),
299
+ width: coord(Math.max(0.5, w)), height: coord(Math.max(0.5, h)),
300
+ // siblings share the group's hue, so they need a seam
301
+ fill, stroke: t.sliceStroke, 'stroke-width': 1, class: 'chart-treemap-tile',
302
+ }
303
+ : {
304
+ x: coord(x), y: coord(y),
305
+ width: coord(Math.max(0.5, w)), height: coord(Math.max(0.5, h)),
306
+ fill, class: 'chart-treemap-tile',
307
+ },
308
+ tooltip, tile.group === null
309
+ ? `${tile.label}: ${tile.value} (${share})`
310
+ : `${tile.group} / ${tile.label}: ${tile.value} (${share})`,
311
+ { type: 'treemap', label: tile.label, group: tile.group, value: tile.value }));
312
+ if (h >= FS_LABEL + 8 && textWidth(tile.label, FS_LABEL) <= w - 8) {
313
+ children.push(textAt(coord(x + 4), coord(y + FS_LABEL + 2), tile.label, FS_LABEL,
314
+ { fill: inkFor(fill), class: 'chart-treemap-label' }));
315
+ }
316
+ }
317
+
318
+ const height = top + plotH + 8;
319
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-treemap-chart',
320
+ width, height, theme, children, (options.keyPrefix ?? 'tree-') + hash);
321
+ return annotateChart(svg, ast.title);
322
+ }