@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.
- package/README.md +293 -0
- package/dist/types/component/index.d.ts +95 -0
- package/dist/types/core/axis.d.ts +77 -0
- package/dist/types/core/cartesian.d.ts +127 -0
- package/dist/types/core/chart.d.ts +58 -0
- package/dist/types/core/domain.d.ts +96 -0
- package/dist/types/core/marks.d.ts +86 -0
- package/dist/types/core/palette.d.ts +72 -0
- package/dist/types/core/scale.d.ts +59 -0
- package/dist/types/core/session.d.ts +85 -0
- package/dist/types/core/stream-adapter.d.ts +187 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
- package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
- package/dist/types/types/bar.d.ts +213 -0
- package/dist/types/types/boxplot.d.ts +116 -0
- package/dist/types/types/candlestick.d.ts +218 -0
- package/dist/types/types/gauge.d.ts +68 -0
- package/dist/types/types/heatmap.d.ts +104 -0
- package/dist/types/types/line.d.ts +272 -0
- package/dist/types/types/map.d.ts +137 -0
- package/dist/types/types/pie.d.ts +146 -0
- package/dist/types/types/radar.d.ts +89 -0
- package/dist/types/types/sankey.d.ts +100 -0
- package/dist/types/types/scatter.d.ts +80 -0
- package/dist/types/types/streamgraph.d.ts +75 -0
- package/dist/types/types/treemap.d.ts +118 -0
- package/package.json +76 -0
- package/schemas/chart-definition.schema.json +448 -0
- package/src/component/index.js +125 -0
- package/src/core/axis.js +221 -0
- package/src/core/cartesian.js +192 -0
- package/src/core/chart.js +101 -0
- package/src/core/domain.js +123 -0
- package/src/core/marks.js +110 -0
- package/src/core/palette.js +126 -0
- package/src/core/scale.js +106 -0
- package/src/core/session.js +0 -0
- package/src/core/stream-adapter.js +613 -0
- package/src/index.js +40 -0
- package/src/transforms/benchmark-adapter.js +298 -0
- package/src/transforms/mermaid-adapter.js +19 -0
- package/src/types/bar.js +276 -0
- package/src/types/boxplot.js +216 -0
- package/src/types/candlestick.js +274 -0
- package/src/types/gauge.js +140 -0
- package/src/types/heatmap.js +176 -0
- package/src/types/line.js +349 -0
- package/src/types/map.js +378 -0
- package/src/types/pie.js +163 -0
- package/src/types/radar.js +224 -0
- package/src/types/sankey.js +391 -0
- package/src/types/scatter.js +148 -0
- package/src/types/streamgraph.js +158 -0
- package/src/types/treemap.js +322 -0
- 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
|
+
}
|