@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,125 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The charts VISUAL COMPONENT — the only app-aware file in the
4
+ * package (the two-layer rule the mermaid component established). The
5
+ * engine neither knows nor needs any of it.
6
+ *
7
+ * `createChartComponent()` returns a memoized `view()` projection for
8
+ * `@jarenjs/app` viewModels: the same `(config, data)` pair yields a
9
+ * reference-equal vnode, so an unchanged chart patches in O(1). The
10
+ * memo is a WeakMap keyed on the DATA object's identity (streaming
11
+ * snapshots are fresh objects, so every tick re-renders; static data is
12
+ * stable, so navigation is free), with an inner cache keyed on the
13
+ * config's structural identity. That identity is the whole serialized
14
+ * config, not a fingerprint of it: the memo hands back the vnode, so a
15
+ * hash collision would draw one chart's config under another's.
16
+ */
17
+
18
+ import { createSemanticCache } from '@jarenjs/core/cache';
19
+ import { compileChart } from '../core/chart.js';
20
+ import { createChartSession } from '../core/session.js';
21
+
22
+ /** Distinct configs memoized per data object before the oldest is
23
+ * dropped — one chart is redrawn from a handful of configs at most, so
24
+ * the bound only stops an unbounded churn of generated configs. */
25
+ const CONFIG_MEMO_LIMIT = 64;
26
+
27
+ /**
28
+ * @typedef {object} ChartComponentOptions
29
+ * @property {any} [theme] theme name or override object
30
+ * @property {import('../core/marks.js').ChartTooltipSpec} [tooltip] pointer
31
+ * bindings emitted from every value mark, for a floating-tooltip host
32
+ * (see {@link tooltipView}); absent leaves charts on their native
33
+ * `<title>` hover only
34
+ */
35
+ /**
36
+ * @typedef {object} ChartComponent
37
+ * @property {(config: any, data?: any) => import('../core/chart.js').CompiledChart} compile
38
+ * @property {(config: any, data?: any) => any} view memoized vnode projection
39
+ * @property {(config: any, source: import('../core/session.js').ChartSessionSource)
40
+ * => import('../core/session.js').ChartSession} createSession incremental
41
+ * session bound to this component's theme
42
+ * @property {Record<string, (props: any, dispatch: any) => any>} effects
43
+ */
44
+
45
+ /**
46
+ * Create the charts component.
47
+ *
48
+ * @example
49
+ * const charts = createChartComponent({ theme: 'host' });
50
+ * createApp(appDoc, {
51
+ * viewModel: (state) => ({ ...state, chart: charts.view(state.config, state.data) }),
52
+ * });
53
+ *
54
+ * @param {ChartComponentOptions} [options]
55
+ * @returns {ChartComponent}
56
+ */
57
+ export function createChartComponent(options = {}) {
58
+ const compileOptions = { theme: options.theme, tooltip: options.tooltip };
59
+
60
+ /** Data-identity memo; inner caches key on the config's identity. */
61
+ /** @type {WeakMap<object, import('@jarenjs/core/cache').SemanticCache<any>>} */
62
+ const byData = new WeakMap();
63
+
64
+ const compile = (config, data = config) => compileChart(config, data, compileOptions);
65
+
66
+ return {
67
+ compile,
68
+
69
+ createSession(config, source) {
70
+ return createChartSession(config, source, compileOptions);
71
+ },
72
+
73
+ view(config, data = config) {
74
+ if (config === null || config === undefined) return null;
75
+ if (data === null || typeof data !== 'object') {
76
+ return compile(config, data).toVnode();
77
+ }
78
+ let byConfig = byData.get(data);
79
+ if (byConfig === undefined) {
80
+ byConfig = createSemanticCache(CONFIG_MEMO_LIMIT);
81
+ byData.set(data, byConfig);
82
+ }
83
+ // structural identity, not a fingerprint: this memo RETURNS the
84
+ // vnode, so a collision would draw one chart's config as another
85
+ return byConfig.getOrCreate(config, () => compile(config, data).toVnode());
86
+ },
87
+
88
+ // No app effects yet: rendering is synchronous and pure. Streaming
89
+ // wiring (timers, sockets) belongs to the host boundary, not here.
90
+ effects: {},
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The floating-tooltip host: the vnode for the box a `tooltip` binding
96
+ * asks for. The mark dispatches `{ text, … }` plus the pointer's
97
+ * `clientX`/`clientY`; an action stores that slice in the state and the
98
+ * viewModel projects it through here.
99
+ *
100
+ * Positioned `fixed` at the viewport coordinates the event carried, so
101
+ * it needs no measurement and no layout read — the stylesheet's
102
+ * `translate` lifts it clear of the pointer. `null` (the leave action's
103
+ * state) renders nothing.
104
+ *
105
+ * @example
106
+ * // viewModel: (state) => ({ ...state, tip: tooltipView(state.tip) })
107
+ * // action: { "tip": { "text": "$payload.text",
108
+ * // "x": "$event.clientX", "y": "$event.clientY" } }
109
+ *
110
+ * @param {{text?: string, x?: number, y?: number}|null|undefined} tip
111
+ * @returns {any} a `<div class="chart-tooltip">` vnode, or null
112
+ */
113
+ export function tooltipView(tip) {
114
+ if (tip === null || typeof tip !== 'object') return null;
115
+ const text = tip.text;
116
+ if (typeof text !== 'string' || text === '') return null;
117
+ return ['div', {
118
+ class: 'chart-tooltip',
119
+ role: 'status',
120
+ style: {
121
+ left: `${Number.isFinite(tip.x) ? tip.x : 0}px`,
122
+ top: `${Number.isFinite(tip.y) ? tip.y : 0}px`,
123
+ },
124
+ }, text];
125
+ }
@@ -0,0 +1,221 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Tick generators. Linear ticks snap to a nice-number step
4
+ * (1/2/5 × 10^k) so an axis never shows values like `12.75`; log ticks
5
+ * are decade powers (the shape benchmark-ratio charts need, where one
6
+ * axis spans two orders of magnitude); ordinal ticks center on their
7
+ * band. Tick *positions* are the caller's job via the matching scale —
8
+ * these functions return domain values only.
9
+ */
10
+
11
+ import { Float64, niceStep } from '@jarenjs/core/math';
12
+ import {
13
+ partsFromEpoch,
14
+ epochOfRFC3339Parts,
15
+ startOfParts,
16
+ addToParts,
17
+ compileDateFormat,
18
+ } from '@jarenjs/core/dates';
19
+
20
+ // The 1/2/5 ladder is generic numeric math, so it lives in the kernel; it is
21
+ // re-exported here because it is part of this module's tick vocabulary.
22
+ export { niceStep };
23
+
24
+ /**
25
+ * Linear ticks: multiples of a nice step inside `[min, max]`.
26
+ * Degenerate domains yield a single tick.
27
+ * @param {number} min - Domain minimum
28
+ * @param {number} max - Domain maximum
29
+ * @param {number} [count] - Desired tick count (approximate)
30
+ * @returns {number[]}
31
+ */
32
+ export function axisTicksLinear(min, max, count = 5) {
33
+ if (!Number.isFinite(min) || !Number.isFinite(max))
34
+ return [];
35
+ if (min === max)
36
+ return [min];
37
+ if (min > max)
38
+ return axisTicksLinear(max, min, count);
39
+ const step = niceStep(max - min, count);
40
+ const decimals = Math.max(0, -Math.floor(Math.log10(step)));
41
+ const ticks = [];
42
+ const first = Math.ceil(min / step);
43
+ const last = Math.floor(max / step);
44
+ for (let i = first; i <= last; ++i)
45
+ ticks.push(Number((i * step).toFixed(decimals)));
46
+ return ticks;
47
+ }
48
+
49
+ /**
50
+ * Log ticks: the decade powers (… 0.1, 1, 10, 100 …) inside
51
+ * `[min, max]`. When the domain sits within a single decade the
52
+ * endpoints are returned so the axis is never empty.
53
+ * @param {number} min - Domain minimum (> 0)
54
+ * @param {number} max - Domain maximum (> 0)
55
+ * @returns {number[]}
56
+ */
57
+ export function axisTicksLog(min, max) {
58
+ if (!(min > 0) || !(max > 0))
59
+ throw new RangeError(`axisTicksLog domain must be positive, got [${min}, ${max}]`);
60
+ if (min > max)
61
+ return axisTicksLog(max, min);
62
+ const ticks = [];
63
+ const first = Math.ceil(Math.log10(min) - 1e-12);
64
+ const last = Math.floor(Math.log10(max) + 1e-12);
65
+ for (let k = first; k <= last; ++k)
66
+ ticks.push(Math.pow(10, k));
67
+ return ticks.length !== 0 ? ticks : [min, max];
68
+ }
69
+
70
+ /**
71
+ * Ordinal ticks: every category, centered on its band.
72
+ * @param {readonly string[]} categories - Domain, in display order
73
+ * @returns {{label: string, pos: number}[]} positions in [0,1]
74
+ */
75
+ export function axisTicksOrdinal(categories) {
76
+ const n = categories.length;
77
+ return categories.map((label, i) => ({ label, pos: (i + 0.5) / n }));
78
+ }
79
+
80
+ /**
81
+ * A compact tick label: `1.5k`, `2M`, `1G` for large magnitudes,
82
+ * 3-significant-digit decimals otherwise.
83
+ * @param {number} v - Tick value
84
+ * @returns {string}
85
+ */
86
+ export function formatTickValue(v) {
87
+ if (v === 0) return '0';
88
+ const abs = Math.abs(v);
89
+ if (abs >= 1e9) return trim(v / 1e9) + 'G';
90
+ if (abs >= 1e6) return trim(v / 1e6) + 'M';
91
+ if (abs >= 1e3) return trim(v / 1e3) + 'k';
92
+ return trim(v);
93
+ }
94
+
95
+ // The steps a clock and a calendar actually have. The 1/2/5 ladder is
96
+ // right for quantities and wrong for time: it puts ticks 50 seconds or
97
+ // 8.64 days apart, which no reader converts back into a date. Each entry
98
+ // is [unit, amount]; the unit names are the kernel's.
99
+ const TIME_STEPS = Object.freeze([
100
+ ['second', 1], ['second', 5], ['second', 15], ['second', 30],
101
+ ['minute', 1], ['minute', 5], ['minute', 15], ['minute', 30],
102
+ ['hour', 1], ['hour', 3], ['hour', 6], ['hour', 12],
103
+ ['day', 1], ['day', 2], ['week', 1], ['week', 2],
104
+ ['month', 1], ['month', 3], ['month', 6],
105
+ ['year', 1],
106
+ ]);
107
+
108
+ // approximate widths, used only to pick a step near the target count
109
+ const STEP_MS = Object.freeze({
110
+ second: 1000, minute: 60000, hour: 3600000, day: 86400000,
111
+ week: 604800000, month: 2629800000, year: 31557600000,
112
+ });
113
+
114
+ /**
115
+ * Choose the calendar step closest to covering `span` in `count` ticks.
116
+ * @param {number} span - Domain width in milliseconds
117
+ * @param {number} count - Desired tick count
118
+ * @returns {[string, number]} a [unit, amount] pair
119
+ */
120
+ export function niceTimeStep(span, count) {
121
+ const target = span / Math.max(1, count);
122
+ let best = TIME_STEPS[0];
123
+ let bestErr = Infinity;
124
+ for (let i = 0; i < TIME_STEPS.length; i++) {
125
+ const [unit, amount] = TIME_STEPS[i];
126
+ const err = Math.abs(Math.log(STEP_MS[unit] * amount / target));
127
+ if (err < bestErr) {
128
+ bestErr = err;
129
+ best = TIME_STEPS[i];
130
+ }
131
+ }
132
+ return best;
133
+ }
134
+
135
+ /**
136
+ * Time ticks on calendar boundaries: the first tick is the start of a
137
+ * `[unit, amount]` step at or after `min`, and each following one is a
138
+ * whole step later. Month and year steps land on the first of the
139
+ * month, so a multi-year axis reads `2024 2025 2026` rather than
140
+ * arbitrary instants.
141
+ * @param {number} min - Domain minimum, epoch milliseconds
142
+ * @param {number} max - Domain maximum, epoch milliseconds
143
+ * @param {number} [count] - Desired tick count (approximate)
144
+ * @returns {number[]} tick values in epoch milliseconds
145
+ */
146
+ export function axisTicksTime(min, max, count = 4) {
147
+ if (!Number.isFinite(min) || !Number.isFinite(max))
148
+ return [];
149
+ if (min === max)
150
+ return [min];
151
+ if (min > max)
152
+ return axisTicksTime(max, min, count);
153
+ const span = max - min;
154
+ // below a second the calendar has nothing to say; the numeric ladder does
155
+ if (span < 1000 * count)
156
+ return axisTicksLinear(min, max, count);
157
+ const [unit, amount] = niceTimeStep(span, count);
158
+ // snap to the step's own boundary, then advance whole steps
159
+ let parts = startOfParts(partsFromEpoch(min), unit === 'week' ? 'week' : unit);
160
+ if (unit === 'month' && amount > 1) {
161
+ // quarters and half-years start on month 1, 4, 7, 10 (or 1, 7)
162
+ const aligned = Math.floor((parts.month - 1) / amount) * amount + 1;
163
+ parts = { ...parts, month: aligned };
164
+ }
165
+ const ticks = [];
166
+ let ms = epochOfRFC3339Parts(parts);
167
+ if (ms < min) {
168
+ parts = addToParts(parts, amount, unit);
169
+ ms = epochOfRFC3339Parts(parts);
170
+ }
171
+ // the step is never zero, so this terminates; the cap is a guard
172
+ // against a pathological domain rather than an expected path
173
+ for (let i = 0; ms <= max && i < 1000; i++) {
174
+ ticks.push(ms);
175
+ parts = addToParts(parts, amount, unit);
176
+ ms = epochOfRFC3339Parts(parts);
177
+ }
178
+ return ticks;
179
+ }
180
+
181
+ // label patterns by how coarse the step is, compiled once
182
+ const LABEL_SECOND = compileDateFormat('HH:mm:ss');
183
+ const LABEL_MINUTE = compileDateFormat('HH:mm');
184
+ const LABEL_DAY = compileDateFormat('yyyy-MM-dd');
185
+ const LABEL_MONTH = compileDateFormat('yyyy-MM');
186
+ const LABEL_YEAR = compileDateFormat('yyyy');
187
+
188
+ /**
189
+ * A time-axis tick label in UTC (deterministic across machines).
190
+ *
191
+ * With a `step` the label matches the step's granularity — clock time
192
+ * for intraday ticks, a date for daily ones, a month or year above that
193
+ * — so an axis never repeats the same string on every tick. Without one
194
+ * it keeps the historical behaviour: `HH:MM:SS`, or the date when the
195
+ * value sits exactly on a day boundary.
196
+ *
197
+ * @param {number|Date} v - Epoch milliseconds or a Date
198
+ * @param {[string, number]} [step] - The [unit, amount] the axis stepped by
199
+ * @returns {string}
200
+ */
201
+ export function formatTimeTick(v, step = undefined) {
202
+ const ms = typeof v === 'number' ? v : v.getTime();
203
+ const parts = partsFromEpoch(ms);
204
+ if (step === undefined) {
205
+ return parts.hours === 0 && parts.minutes === 0 && parts.seconds === 0
206
+ ? LABEL_DAY(parts)
207
+ : LABEL_SECOND(parts);
208
+ }
209
+ const unit = step[0];
210
+ if (unit === 'second')
211
+ return LABEL_SECOND(parts);
212
+ if (unit === 'minute' || unit === 'hour')
213
+ return LABEL_MINUTE(parts);
214
+ if (unit === 'day' || unit === 'week')
215
+ return LABEL_DAY(parts);
216
+ return unit === 'month' ? LABEL_MONTH(parts) : LABEL_YEAR(parts);
217
+ }
218
+
219
+ function trim(x) {
220
+ return String(Float64.roundToPrecision(x, 3));
221
+ }
@@ -0,0 +1,192 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The shared cartesian chrome: plot frame, axes, grid, legend and
4
+ * title, rendered from an AST's tick lists. Chart types append their
5
+ * marks inside the returned plot rect. All positions arrive in unit
6
+ * space (`pos`/`u`/`v` in [0,1], `v = 0` at the domain minimum, drawn
7
+ * at the plot bottom); pixel mapping happens only here and in the
8
+ * callers' mark loops.
9
+ */
10
+
11
+ import { line as svgLine, textAt, textWidth, num } from '@jarenjs/view/helpers';
12
+ import { seriesColor } from './palette.js';
13
+
14
+ export const FS_TICK = 11;
15
+ export const FS_LABEL = 12;
16
+ export const FS_TITLE = 15;
17
+
18
+ /**
19
+ * Resolve a mark color: semantic tone first, series palette otherwise.
20
+ * @param {{tokens: Record<string,string>}} theme
21
+ * @param {'win'|'loss'|null|undefined} tone
22
+ * @param {number} i series index
23
+ * @param {readonly string[]} [palette]
24
+ * @returns {string}
25
+ */
26
+ export function toneColor(theme, tone, i, palette) {
27
+ if (tone === 'win') return theme.tokens.win;
28
+ if (tone === 'loss') return theme.tokens.loss;
29
+ return seriesColor(i, palette);
30
+ }
31
+
32
+ /**
33
+ * Truncate a label so it fits `maxWidth` at `fontSize`, appending an
34
+ * ellipsis when anything was cut.
35
+ * @param {string} label
36
+ * @param {number} maxWidth
37
+ * @param {number} fontSize
38
+ * @returns {string}
39
+ */
40
+ export function fitLabel(label, maxWidth, fontSize) {
41
+ if (textWidth(label, fontSize) <= maxWidth) return label;
42
+ let end = label.length;
43
+ while (end > 1 && textWidth(label.slice(0, end) + '…', fontSize) > maxWidth)
44
+ end--;
45
+ return label.slice(0, end) + '…';
46
+ }
47
+
48
+ /**
49
+ * The chart title `<text>`: bold, centred on `x`, carrying the
50
+ * `chart-title` class the chart stylesheet hooks. Every chart type places
51
+ * its own title — the anchor point and size are part of each type's
52
+ * layout — so those stay the caller's, and only the styling is shared.
53
+ * @param {number} x anchor (the title centres on it)
54
+ * @param {number} y baseline
55
+ * @param {string} text
56
+ * @param {number} fontSize
57
+ * @param {Record<string,string>} tokens the resolved theme tokens
58
+ * @returns {any}
59
+ */
60
+ export function chartTitle(x, y, text, fontSize, tokens) {
61
+ return textAt(x, y, text, fontSize,
62
+ { 'font-weight': 'bold', 'text-anchor': 'middle', fill: tokens.text, class: 'chart-title' });
63
+ }
64
+
65
+ /**
66
+ * @typedef {{name: string, swatch?: number, tone?: 'win'|'loss'|null}} LegendEntry
67
+ */
68
+
69
+ /**
70
+ * The legend swatch row, laid out right-to-left from `right` so the last
71
+ * entry ends at the plot's right edge. Returns the vnodes rather than
72
+ * drawing, so a chart with no cartesian frame at all (the map) can place
73
+ * the same row itself.
74
+ * @param {LegendEntry[]} legend
75
+ * @param {number} right the x the row ends at
76
+ * @param {number} y the swatches' top edge
77
+ * @param {{tokens: Record<string,string>}} theme
78
+ * @param {readonly string[]} [palette]
79
+ * @returns {any[]}
80
+ */
81
+ export function legendRow(legend, right, y, theme, palette) {
82
+ let x = right;
83
+ const entries = [];
84
+ for (let i = legend.length - 1; i >= 0; i--) {
85
+ const entry = legend[i];
86
+ const label = entry.name;
87
+ const w = Math.ceil(textWidth(label, FS_TICK));
88
+ x -= w + 4;
89
+ entries.push(textAt(x + 14, y + 9, label, FS_TICK, { fill: theme.tokens.muted, class: 'chart-tick' }));
90
+ x -= 14;
91
+ entries.push(['rect', {
92
+ x: num(x), y: num(y), width: 10, height: 10,
93
+ fill: toneColor(theme, entry.tone, entry.swatch ?? i, palette),
94
+ class: 'chart-swatch',
95
+ }]);
96
+ x -= 14;
97
+ }
98
+ return entries;
99
+ }
100
+
101
+ /**
102
+ * @typedef {{ticks: {pos: number, label: string}[], label?: string|null}} AxisAST
103
+ */
104
+
105
+ /**
106
+ * Compute the plot frame and render the chart chrome.
107
+ * @param {object} params
108
+ * @param {string|null} [params.title]
109
+ * @param {{name: string, swatch?: number, tone?: 'win'|'loss'|null}[]|null} [params.legend]
110
+ * @param {AxisAST} [params.xAxis] ticks along the bottom edge
111
+ * @param {AxisAST} [params.yAxis] ticks along the left edge
112
+ * @param {'x'|'y'|'xy'|'none'} [params.grid] which tick sets draw grid lines
113
+ * @param {number} [params.width]
114
+ * @param {number} [params.plotHeight]
115
+ * @param {readonly string[]} [params.palette]
116
+ * @param {{tokens: Record<string,string>}} params.theme
117
+ * @returns {{width: number, height: number, plot: {x:number,y:number,w:number,h:number}, children: any[]}}
118
+ */
119
+ export function cartesianFrame(params) {
120
+ const t = params.theme.tokens;
121
+ const width = params.width ?? 560;
122
+ const xAxis = params.xAxis ?? { ticks: [] };
123
+ const yAxis = params.yAxis ?? { ticks: [] };
124
+ const grid = params.grid ?? 'y';
125
+ const legend = params.legend ?? null;
126
+ const yTickW = Math.max(0, ...yAxis.ticks.map((tk) => textWidth(tk.label, FS_TICK)));
127
+ const left = Math.ceil(Math.max(30, Math.min(230, yTickW) + 14)) + (yAxis.label ? 18 : 0);
128
+ const top = (params.title ? 34 : 14) + (legend !== null && legend.length !== 0 ? 22 : 0);
129
+ const right = 16;
130
+ const bottom = 32 + (xAxis.label ? 18 : 0);
131
+ const plotH = params.plotHeight ?? 220;
132
+ const height = top + plotH + bottom;
133
+ const plot = { x: left, y: top, w: width - left - right, h: plotH };
134
+ const children = [];
135
+
136
+ if (params.title) {
137
+ children.push(chartTitle(width / 2, 22, params.title, FS_TITLE, t));
138
+ }
139
+
140
+ if (legend !== null && legend.length !== 0) {
141
+ children.push(...legendRow(legend, plot.x + plot.w, plot.y - 10, params.theme, params.palette));
142
+ }
143
+
144
+ for (const tick of yAxis.ticks) {
145
+ const y = plot.y + (1 - tick.pos) * plot.h;
146
+ if (grid === 'y' || grid === 'xy')
147
+ children.push(svgLine(plot.x, y, plot.x + plot.w, y, { stroke: t.grid, 'stroke-width': 1, class: 'chart-grid' }));
148
+ children.push(textAt(plot.x - 6, y + 4, fitLabel(tick.label, 230, FS_TICK), FS_TICK,
149
+ { 'text-anchor': 'end', fill: t.muted, class: 'chart-tick' }));
150
+ }
151
+ for (const tick of xAxis.ticks) {
152
+ const x = plot.x + tick.pos * plot.w;
153
+ if (grid === 'x' || grid === 'xy')
154
+ children.push(svgLine(x, plot.y, x, plot.y + plot.h, { stroke: t.grid, 'stroke-width': 1, class: 'chart-grid' }));
155
+ children.push(textAt(x, plot.y + plot.h + 16,
156
+ fitLabel(tick.label, Math.max(40, plot.w / Math.max(1, xAxis.ticks.length) - 8), FS_TICK), FS_TICK,
157
+ { 'text-anchor': 'middle', fill: t.muted, class: 'chart-tick' }));
158
+ }
159
+
160
+ children.push(svgLine(plot.x, plot.y, plot.x, plot.y + plot.h, { stroke: t.axis, 'stroke-width': 1, class: 'chart-axis' }));
161
+ children.push(svgLine(plot.x, plot.y + plot.h, plot.x + plot.w, plot.y + plot.h, { stroke: t.axis, 'stroke-width': 1, class: 'chart-axis' }));
162
+
163
+ if (xAxis.label) {
164
+ children.push(textAt(plot.x + plot.w / 2, height - 8, xAxis.label, FS_LABEL,
165
+ { 'text-anchor': 'middle', fill: t.muted, class: 'chart-axis-label' }));
166
+ }
167
+ if (yAxis.label) {
168
+ children.push(['text', {
169
+ x: 0, y: 0, 'font-size': FS_LABEL, fill: t.muted, 'text-anchor': 'middle',
170
+ transform: `rotate(-90) translate(${num(-(plot.y + plot.h / 2))} 12)`,
171
+ class: 'chart-axis-label',
172
+ }, String(yAxis.label)]);
173
+ }
174
+
175
+ return { width, height, plot, children };
176
+ }
177
+
178
+ /**
179
+ * Stamp accessibility onto a finished chart svg vnode: an `aria-label`
180
+ * (the title) and a leading `<title>` child for hover text. `svgRoot`
181
+ * already sets `role="img"`.
182
+ * @param {any} svg the `['svg', props, …]` vnode (mutated in place)
183
+ * @param {string|null|undefined} label
184
+ * @returns {any} the same vnode
185
+ */
186
+ export function annotateChart(svg, label) {
187
+ if (label) {
188
+ svg[1]['aria-label'] = String(label);
189
+ svg.splice(2, 0, ['title', {}, String(label)]);
190
+ }
191
+ return svg;
192
+ }
@@ -0,0 +1,101 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `compileChart` — the type dispatcher. Compiles a chart
4
+ * definition plus its data into a bundle of cached projections
5
+ * (the `compileMermaid` shape): the geometry-free AST, a pure-vnode
6
+ * SVG, and a standalone SVG string.
7
+ *
8
+ * Memoization strategy (deliberate, see the component layer): identity
9
+ * of the *data object* is the cache key, held in a WeakMap by
10
+ * `createChartComponent` — never a hash of the object itself, which
11
+ * would stringify every object to `"[object Object]"` and collide
12
+ * universally. `contentKey` (the memo-grade stable-stringify hash from
13
+ * `@jarenjs/core/object`) derives the root vnode key from the config.
14
+ */
15
+
16
+ import { renderToString } from '@jarenjs/view';
17
+ import { contentKey } from '@jarenjs/core/object';
18
+
19
+ import { createTheme } from './palette.js';
20
+ import { buildPieAST, renderPieAST } from '../types/pie.js';
21
+ import { buildBarAST, renderBarAST } from '../types/bar.js';
22
+ import { buildLineAST, renderLineAST } from '../types/line.js';
23
+ import { buildScatterAST, renderScatterAST } from '../types/scatter.js';
24
+ import { buildCandlestickAST, renderCandlestickAST } from '../types/candlestick.js';
25
+ import { buildRadarAST, renderRadarAST } from '../types/radar.js';
26
+ import { buildGaugeAST, renderGaugeAST } from '../types/gauge.js';
27
+ import { buildBoxplotAST, renderBoxplotAST } from '../types/boxplot.js';
28
+ import { buildHeatmapAST, renderHeatmapAST } from '../types/heatmap.js';
29
+ import { buildTreemapAST, renderTreemapAST } from '../types/treemap.js';
30
+ import { buildStreamgraphAST, renderStreamgraphAST } from '../types/streamgraph.js';
31
+ import { buildSankeyAST, renderSankeyAST } from '../types/sankey.js';
32
+ import { buildMapAST, renderMapAST } from '../types/map.js';
33
+
34
+ /** @type {Record<string, {build: (data: any, config: any) => any, render: (ast: any, theme: any, hash: string, options?: any) => any}>} */
35
+ const TYPES = {
36
+ pie: { build: buildPieAST, render: renderPieAST },
37
+ bar: { build: buildBarAST, render: renderBarAST },
38
+ line: { build: buildLineAST, render: renderLineAST },
39
+ scatter: { build: buildScatterAST, render: renderScatterAST },
40
+ candlestick: { build: buildCandlestickAST, render: renderCandlestickAST },
41
+ radar: { build: buildRadarAST, render: renderRadarAST },
42
+ gauge: { build: buildGaugeAST, render: renderGaugeAST },
43
+ boxplot: { build: buildBoxplotAST, render: renderBoxplotAST },
44
+ heatmap: { build: buildHeatmapAST, render: renderHeatmapAST },
45
+ treemap: { build: buildTreemapAST, render: renderTreemapAST },
46
+ streamgraph: { build: buildStreamgraphAST, render: renderStreamgraphAST },
47
+ sankey: { build: buildSankeyAST, render: renderSankeyAST },
48
+ map: { build: buildMapAST, render: renderMapAST },
49
+ };
50
+
51
+ /**
52
+ * The chart types the dispatcher knows.
53
+ * @returns {string[]}
54
+ */
55
+ export function chartTypes() {
56
+ return Object.keys(TYPES);
57
+ }
58
+
59
+ /**
60
+ * @typedef {object} CompiledChart
61
+ * @property {any} ast the geometry-free AST
62
+ * @property {() => any} toVnode cached pure-vnode SVG
63
+ * @property {() => string} toSvgString cached standalone SVG string (SSR)
64
+ */
65
+
66
+ /**
67
+ * Compile a chart definition. `config` carries the type and
68
+ * presentation fields; `data` carries the series/slices and defaults to
69
+ * the config object itself, so a single self-contained definition
70
+ * document works while streaming callers pass live data separately.
71
+ *
72
+ * @param {{type: string, title?: string, [k: string]: any}} config
73
+ * @param {any} [data]
74
+ * @param {{theme?: any, tooltip?: import('./marks.js').ChartTooltipSpec}} [options]
75
+ * @returns {CompiledChart}
76
+ * @throws {TypeError} On an unknown chart type
77
+ */
78
+ export function compileChart(config, data = config, options = {}) {
79
+ const def = TYPES[config?.type];
80
+ if (def === undefined)
81
+ throw new TypeError(`unknown chart type '${config?.type}'`);
82
+ const ast = def.build(data, config);
83
+ const theme = createTheme(options.theme);
84
+ const hash = contentKey(config);
85
+ // Only the render half takes options; passing the object through
86
+ // whole would let a `theme` member reach a type's palette options.
87
+ const renderOptions = options.tooltip === undefined ? undefined : { tooltip: options.tooltip };
88
+ let vnode;
89
+ let svg;
90
+ return {
91
+ ast,
92
+ toVnode() {
93
+ if (vnode === undefined) vnode = def.render(ast, theme, hash, renderOptions);
94
+ return vnode;
95
+ },
96
+ toSvgString() {
97
+ if (svg === undefined) svg = renderToString(this.toVnode());
98
+ return svg;
99
+ },
100
+ };
101
+ }