@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,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
|
+
}
|
package/src/core/axis.js
ADDED
|
@@ -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
|
+
}
|