@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,123 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Domain-stability policies for the streaming chart types. A
4
+ * unit-space AST stores every position as a fraction of its domain, so
5
+ * a tick that moves the domain legitimately changes every mark — a
6
+ * wholesale re-render is then correct. These policies exist so that
7
+ * most streaming ticks do NOT move the domain, which is what makes
8
+ * incremental re-render (the chart session) possible:
9
+ *
10
+ * - `x: { window, slide? }` — sliding x window of fixed span `window`,
11
+ * whose END is quantized to multiples of `slide` (default a quarter
12
+ * window). The domain therefore moves once per `slide` of x
13
+ * progress, not once per sample; samples older than the window are
14
+ * dropped from the plot.
15
+ * - `y: { min?, max? }` — pinned value bounds; samples beyond them
16
+ * clamp to the plot edge (the render's clamp01 already does this).
17
+ * - `y: 'step'` — hysteresis by quantization: the value domain snaps
18
+ * outward to nice-number multiples (decades under a log axis), so it
19
+ * changes only when a sample crosses a step boundary.
20
+ *
21
+ * All resolution is pure — policies are declared in the config and
22
+ * resolved from the data extremes on every build; stability comes from
23
+ * quantization, not hidden state.
24
+ */
25
+
26
+ import { niceStep } from './axis.js';
27
+
28
+ /**
29
+ * @typedef {object} DomainPolicy
30
+ * @property {number|null} window sliding x span (null = none)
31
+ * @property {number|null} slide window end quantum (null = window/4)
32
+ * @property {{min: number|null, max: number|null}|null} pin pinned y bounds
33
+ * @property {boolean} step quantized y domain
34
+ */
35
+
36
+ /**
37
+ * Normalize a config `domain` member into a policy object; hostile or
38
+ * absent input yields the all-null policy (today's behavior).
39
+ * @param {any} domain the config `domain` member
40
+ * @returns {DomainPolicy}
41
+ */
42
+ export function normalizeDomainPolicy(domain) {
43
+ const none = { window: null, slide: null, pin: null, step: false };
44
+ if (domain === null || typeof domain !== 'object') return none;
45
+ const policy = { ...none };
46
+ const x = domain.x;
47
+ if (x !== null && typeof x === 'object'
48
+ && typeof x.window === 'number' && Number.isFinite(x.window) && x.window > 0) {
49
+ policy.window = x.window;
50
+ if (typeof x.slide === 'number' && Number.isFinite(x.slide) && x.slide > 0)
51
+ policy.slide = x.slide;
52
+ }
53
+ const y = domain.y;
54
+ if (y === 'step') {
55
+ policy.step = true;
56
+ }
57
+ else if (y !== null && typeof y === 'object') {
58
+ const min = typeof y.min === 'number' && Number.isFinite(y.min) ? y.min : null;
59
+ const max = typeof y.max === 'number' && Number.isFinite(y.max) ? y.max : null;
60
+ if (min !== null || max !== null) policy.pin = { min, max };
61
+ }
62
+ return policy;
63
+ }
64
+
65
+ /**
66
+ * Resolve the windowed x domain `[end - window, end]`: the end is the
67
+ * smallest multiple of the slide quantum at or above the newest sample,
68
+ * so it moves once per quantum, not once per sample.
69
+ * @param {number} xMax newest x in the data (non-finite = empty data)
70
+ * @param {number} window the window span (> 0)
71
+ * @param {number|null} slide the end quantum (null = window / 4)
72
+ * @returns {[number, number]}
73
+ */
74
+ export function resolveWindowX(xMax, window, slide) {
75
+ const quantum = slide ?? window / 4;
76
+ const end = Number.isFinite(xMax) ? quantum * Math.ceil(xMax / quantum) : window;
77
+ return [end - window, end];
78
+ }
79
+
80
+ /**
81
+ * Resolve the quantized (`'step'`) y domain: extremes snapped outward
82
+ * to multiples of a nice step of the span, so small new extremes
83
+ * usually land inside the current domain.
84
+ * @param {number} yMin @param {number} yMax data extremes (finite)
85
+ * @returns {[number, number]}
86
+ */
87
+ export function resolveStepY(yMin, yMax) {
88
+ const span = yMax - yMin;
89
+ const step = niceStep(span > 0 ? span : Math.abs(yMax) || 1, 4);
90
+ let lo = step * Math.floor(yMin / step);
91
+ let hi = step * Math.ceil(yMax / step);
92
+ if (hi === lo) hi = lo + step;
93
+ return [lo, hi];
94
+ }
95
+
96
+ /**
97
+ * Resolve the quantized y domain under a log axis: decade bounds
98
+ * (`10^floor` / `10^ceil`), the log counterpart of {@link resolveStepY}.
99
+ * @param {number} yMin @param {number} yMax data extremes (> 0)
100
+ * @returns {[number, number]}
101
+ */
102
+ export function resolveStepYLog(yMin, yMax) {
103
+ const lo = Math.pow(10, Math.floor(Math.log10(yMin)));
104
+ let hi = Math.pow(10, Math.ceil(Math.log10(yMax)));
105
+ if (hi === lo) hi = lo * 10;
106
+ return [lo, hi];
107
+ }
108
+
109
+ /**
110
+ * Resolve pinned y bounds over the data extremes. Under a log axis a
111
+ * non-positive pin is ignored (a hostile pin never breaks the scale);
112
+ * a pin pair that closes the domain falls back to the data extremes.
113
+ * @param {number} yMin @param {number} yMax data extremes
114
+ * @param {{min: number|null, max: number|null}} pin
115
+ * @param {boolean} log
116
+ * @returns {[number, number]}
117
+ */
118
+ export function resolvePinnedY(yMin, yMax, pin, log) {
119
+ let lo = pin.min !== null && (!log || pin.min > 0) ? pin.min : yMin;
120
+ let hi = pin.max !== null && (!log || pin.max > 0) ? pin.max : yMax;
121
+ if (hi <= lo) { lo = yMin; hi = yMax; }
122
+ return [lo, hi];
123
+ }
@@ -0,0 +1,110 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Value marks — the two things every value-carrying mark carries.
4
+ *
5
+ * 1. A `<title>` child holding the mark's hover text. Native SSR-safe
6
+ * hover: it works in a static `toSvgString()` document with no
7
+ * script, no CSS and no app, which is why it is unconditional.
8
+ * 2. Opt-in pointer **bindings** (`on`, VIEW-FORMAT §4) for a host that
9
+ * wants a positioned floating tooltip instead. Bindings are plain
10
+ * JSON built at render time, so the engine stays pure — it names an
11
+ * action, it never calls one — and `renderToString` drops `on`
12
+ * entirely, so the SSR bytes are the same either way.
13
+ *
14
+ * The binding's `with` payload is the mark descriptor: its `text` (the
15
+ * same string the `<title>` carries, so a host needs nothing else to
16
+ * draw a box) plus whatever identifies the mark for a richer host.
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} ChartTooltipSpec
21
+ * @property {string} action action dispatched when the pointer enters a mark
22
+ * @property {string} [leaveAction] action dispatched when it leaves
23
+ * @property {string} [enter] enter event name (default `'pointerenter'`)
24
+ * @property {string} [leave] leave event name (default `'pointerleave'`)
25
+ * @property {string[]} [event] `$event` fields to request (default
26
+ * `['clientX', 'clientY']` — what a floating box needs to position itself)
27
+ */
28
+ /**
29
+ * @typedef {object} ChartTooltip resolved spec
30
+ * @property {string} action
31
+ * @property {string|null} leaveAction
32
+ * @property {string} enter @property {string} leave
33
+ * @property {string[]} event
34
+ */
35
+
36
+ /** The pointer coordinates a floating tooltip positions itself from. */
37
+ const POINTER_FIELDS = ['clientX', 'clientY'];
38
+
39
+ /**
40
+ * Resolve a tooltip spec. Anything that does not name an action
41
+ * resolves to `null` — bindings off, `<title>` hover only — so a
42
+ * hostile or half-written spec degrades to the static rendering rather
43
+ * than emitting a binding no action answers.
44
+ * @param {ChartTooltipSpec|null|undefined|any} spec
45
+ * @returns {ChartTooltip|null}
46
+ */
47
+ export function normalizeTooltip(spec) {
48
+ if (spec === null || typeof spec !== 'object') return null;
49
+ if (typeof spec.action !== 'string' || spec.action === '') return null;
50
+ const name = (v, fallback) => typeof v === 'string' && v !== '' ? v : fallback;
51
+ return {
52
+ action: spec.action,
53
+ leaveAction: name(spec.leaveAction, null),
54
+ enter: name(spec.enter, 'pointerenter'),
55
+ leave: name(spec.leave, 'pointerleave'),
56
+ event: Array.isArray(spec.event)
57
+ ? spec.event.filter((f) => typeof f === 'string')
58
+ : POINTER_FIELDS,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * The `on` binding object for one mark, or `undefined` when tooltips
64
+ * are off (so callers can keep passing their props object through
65
+ * untouched — an unbound chart allocates nothing extra).
66
+ * @param {ChartTooltip|null} tooltip
67
+ * @param {string} text the mark's hover text
68
+ * @param {Record<string, any>} [descriptor] extra `with` members
69
+ * @returns {Record<string, any>|undefined}
70
+ */
71
+ export function markBinding(tooltip, text, descriptor) {
72
+ if (tooltip === null) return undefined;
73
+ const on = {
74
+ [tooltip.enter]: {
75
+ action: tooltip.action,
76
+ with: descriptor === undefined ? { text } : { text, ...descriptor },
77
+ event: tooltip.event,
78
+ },
79
+ };
80
+ if (tooltip.leaveAction !== null) on[tooltip.leave] = { action: tooltip.leaveAction };
81
+ return on;
82
+ }
83
+
84
+ /**
85
+ * Mark props with the tooltip bindings folded in — for a mark whose
86
+ * `<title>` sits among other children (a series or candle `<g>`).
87
+ * @param {Record<string, any>} props
88
+ * @param {ChartTooltip|null} tooltip
89
+ * @param {string} text
90
+ * @param {Record<string, any>} [descriptor]
91
+ * @returns {Record<string, any>}
92
+ */
93
+ export function markProps(props, tooltip, text, descriptor) {
94
+ const on = markBinding(tooltip, text, descriptor);
95
+ return on === undefined ? props : { ...props, on };
96
+ }
97
+
98
+ /**
99
+ * A leaf value mark: its element, its hover `<title>`, and the tooltip
100
+ * bindings when a host asked for them.
101
+ * @param {string} tag
102
+ * @param {Record<string, any>} props
103
+ * @param {ChartTooltip|null} tooltip
104
+ * @param {string} text hover text
105
+ * @param {Record<string, any>} [descriptor] extra `with` members
106
+ * @returns {any} the mark vnode
107
+ */
108
+ export function valueMark(tag, props, tooltip, text, descriptor) {
109
+ return [tag, markProps(props, tooltip, text, descriptor), ['title', {}, text]];
110
+ }
@@ -0,0 +1,126 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Colors: the categorical palette and the theme token tables.
4
+ *
5
+ * The categorical palette is a concrete constant, not a set of theme
6
+ * tokens (docs/DESIGN.md §8), in the suite's anchor order — blue first,
7
+ * amber second, teal third, then green/red/navy/olive/slate — with no
8
+ * pink and no purple anywhere. It is the same palette the mermaid pie
9
+ * used before it delegated here, so a pie renders byte-identically
10
+ * through either package.
11
+ *
12
+ * The semantic tokens (text/grid/axis, the win/loss pair) resolve
13
+ * through the shared `resolveTheme` kernel with the `chart` prefix; the
14
+ * `'host'` theme links them to the site token vocabulary (docs/DESIGN.md §2,
15
+ * §7) so charts follow the host's light/dark flip live with no
16
+ * re-render. Sync invariant: the `default`/`dark` values below must
17
+ * match the `--chart-*` fallbacks in `styles/charts.css`.
18
+ */
19
+
20
+ import { resolveTheme } from '@jarenjs/view/helpers';
21
+ import { inkFor, lerpColor } from '@jarenjs/core/color';
22
+
23
+ // Re-exported: chart modules have always imported it from here.
24
+ export { inkFor };
25
+ import { clamp01 } from '@jarenjs/core/math';
26
+
27
+ /**
28
+ * The categorical series palette (docs/DESIGN.md §8 anchor order).
29
+ * @type {readonly string[]}
30
+ */
31
+ export const CATEGORICAL = ['#2563eb', '#f59e0b', '#0d9488', '#dc2626', '#16a34a', '#0369a1', '#ca8a04', '#64748b', '#93c5fd', '#78350f'];
32
+
33
+ /**
34
+ * Ordinal color assignment: series `i` gets the `i`-th palette entry,
35
+ * wrapping.
36
+ * @param {number} i - Series index
37
+ * @param {readonly string[]} [palette] - Palette to draw from
38
+ * @returns {string}
39
+ */
40
+ export function seriesColor(i, palette = CATEGORICAL) {
41
+ return palette[i % palette.length];
42
+ }
43
+
44
+ /**
45
+ * The sequential magnitude ramp: one hue (the brand blue family),
46
+ * light→dark, for value-carrying fills (heatmap cells). A concrete
47
+ * constant like {@link CATEGORICAL}, not a theme token. The stops keep
48
+ * monotone perceptual lightness with visible step gaps, and both ends
49
+ * stay legible against the light and the dark site surface (the ramp
50
+ * does not flip with the theme).
51
+ * @type {readonly string[]}
52
+ */
53
+ export const SEQUENTIAL = ['#60a5fa', '#3b82f6', '#2563eb', '#1e40af'];
54
+
55
+ /**
56
+ * Continuous color for a normalized magnitude: `t` in [0,1] maps onto
57
+ * the ramp by piecewise-linear interpolation between its stops
58
+ * (clamped; non-finite `t` reads as 0).
59
+ * @param {number} t - Normalized magnitude (0 = low, 1 = high)
60
+ * @param {readonly string[]} [ramp] - Ramp stops, light→dark
61
+ * @returns {string} a `#rrggbb` color
62
+ */
63
+ export function sequentialColor(t, ramp = SEQUENTIAL) {
64
+ // The finite guard is this ramp's own policy: a NaN magnitude reads as the
65
+ // low end rather than propagating, which `clamp01` deliberately does not do.
66
+ t = Number.isFinite(t) ? clamp01(t) : 0;
67
+ const spans = ramp.length - 1;
68
+ if (spans <= 0) return ramp[0];
69
+ const at = t * spans;
70
+ const i = Math.min(spans - 1, Math.floor(at));
71
+ return lerpColor(ramp[i], ramp[i + 1], at - i);
72
+ }
73
+
74
+ /** @type {Record<string, Record<string, string>>} */
75
+ const THEMES = {
76
+ default: {
77
+ background: 'transparent',
78
+ text: '#1f2020',
79
+ muted: '#64748b',
80
+ grid: '#e2e8f0',
81
+ axis: '#94a3b8',
82
+ win: '#16a34a',
83
+ loss: '#dc2626',
84
+ sliceStroke: '#ffffff',
85
+ fontFamily: 'Inter, "Helvetica Neue", Arial, sans-serif',
86
+ },
87
+ dark: {
88
+ background: 'transparent',
89
+ text: '#f4f4f4',
90
+ muted: '#94a3b8',
91
+ grid: '#334155',
92
+ axis: '#64748b',
93
+ win: '#4ade80',
94
+ loss: '#f87171',
95
+ sliceStroke: '#11141c',
96
+ fontFamily: 'Inter, "Helvetica Neue", Arial, sans-serif',
97
+ },
98
+ };
99
+
100
+ /**
101
+ * Host custom-property links for the `'host'` theme: token key → the
102
+ * site token it follows (docs/DESIGN.md §2). Concrete defaults remain as
103
+ * `var()` fallbacks, so the same SVG stays standalone-valid.
104
+ * @type {Record<string, string>}
105
+ */
106
+ export const HOST_VARS = {
107
+ text: '--fg',
108
+ muted: '--muted',
109
+ grid: '--border',
110
+ axis: '--muted',
111
+ win: '--ok',
112
+ loss: '--fail',
113
+ sliceStroke: '--bg',
114
+ };
115
+
116
+ /**
117
+ * Resolve a chart theme. The name `'host'` resolves the default tokens
118
+ * linked to the host token vocabulary via {@link HOST_VARS}.
119
+ * @param {string | Record<string, any>} [nameOrOverrides]
120
+ * @returns {{ name: string, tokens: Record<string, string>, cssVars: Record<string, string> }}
121
+ */
122
+ export function createTheme(nameOrOverrides = 'default') {
123
+ return resolveTheme(THEMES, 'chart', nameOrOverrides, HOST_VARS);
124
+ }
125
+
126
+ export { THEMES };
@@ -0,0 +1,106 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Scales: pure `domain -> (value) => [0,1]` closures. All pixel
4
+ * mapping happens in the render pass; the AST and these scales stay in
5
+ * abstract unit space (the geometry-free contract). Construction may
6
+ * allocate (lookup maps); the returned functions never do.
7
+ *
8
+ * Out-of-domain inputs map outside [0,1] (linear/log/time) or to `NaN`
9
+ * (unknown ordinal/band categories); `polylinePath` and `num` in
10
+ * `@jarenjs/view/helpers` are the render-side guards that keep a NaN
11
+ * from ever reaching an emitted string.
12
+ */
13
+
14
+ /**
15
+ * @typedef {(value: number) => number} UnitScale
16
+ */
17
+
18
+ /**
19
+ * Linear scale. A zero-span domain maps every value to 0.5.
20
+ * @param {number} min - Domain minimum
21
+ * @param {number} max - Domain maximum
22
+ * @returns {UnitScale}
23
+ */
24
+ export function scaleLinear(min, max) {
25
+ const span = max - min;
26
+ if (span === 0)
27
+ return () => 0.5;
28
+ return (v) => (v - min) / span;
29
+ }
30
+
31
+ /**
32
+ * Logarithmic scale (base 10). The domain must be strictly positive —
33
+ * that is checked once here, not per call; a non-positive *value* maps
34
+ * to NaN (log of a non-positive number), which the render guards drop.
35
+ * @param {number} min - Domain minimum (> 0)
36
+ * @param {number} max - Domain maximum (> 0)
37
+ * @returns {UnitScale}
38
+ */
39
+ export function scaleLog(min, max) {
40
+ if (!(min > 0) || !(max > 0))
41
+ throw new RangeError(`scaleLog domain must be positive, got [${min}, ${max}]`);
42
+ const logMin = Math.log10(min);
43
+ const span = Math.log10(max) - logMin;
44
+ if (span === 0)
45
+ return () => 0.5;
46
+ return (v) => (Math.log10(v) - logMin) / span;
47
+ }
48
+
49
+ /**
50
+ * Ordinal scale: each category maps to the center of its equal slot,
51
+ * `(i + 0.5) / n`. Unknown categories map to NaN.
52
+ * @param {readonly string[]} categories - Domain, in display order
53
+ * @returns {(category: string) => number}
54
+ */
55
+ export function scaleOrdinal(categories) {
56
+ const n = categories.length;
57
+ const index = new Map();
58
+ for (let i = 0; i < n; ++i)
59
+ index.set(categories[i], i);
60
+ return (category) => {
61
+ const i = index.get(category);
62
+ return i === undefined ? NaN : (i + 0.5) / n;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Band scale: each category owns an equal band with inner padding. The
68
+ * returned function gives the band's start; `bandwidth` and `step` are
69
+ * exposed as properties. Unknown categories map to NaN.
70
+ * @param {readonly string[]} categories - Domain, in display order
71
+ * @param {number} [padding] - Fraction of each step left empty (0..1)
72
+ * @returns {((category: string) => number) & {bandwidth: number, step: number}}
73
+ */
74
+ export function scaleBand(categories, padding = 0.2) {
75
+ const n = categories.length;
76
+ const step = n === 0 ? 1 : 1 / n;
77
+ const bandwidth = step * (1 - padding);
78
+ const inset = (step - bandwidth) / 2;
79
+ const index = new Map();
80
+ for (let i = 0; i < n; ++i)
81
+ index.set(categories[i], i);
82
+ const scale = (category) => {
83
+ const i = index.get(category);
84
+ return i === undefined ? NaN : i * step + inset;
85
+ };
86
+ scale.bandwidth = bandwidth;
87
+ scale.step = step;
88
+ return scale;
89
+ }
90
+
91
+ /**
92
+ * Time scale: a linear scale over epoch milliseconds that also accepts
93
+ * `Date` instances (converted once per call via `getTime`, no
94
+ * allocation).
95
+ * @param {number|Date} min - Domain minimum
96
+ * @param {number|Date} max - Domain maximum
97
+ * @returns {(value: number|Date) => number}
98
+ */
99
+ export function scaleTime(min, max) {
100
+ const t0 = typeof min === 'number' ? min : min.getTime();
101
+ const t1 = typeof max === 'number' ? max : max.getTime();
102
+ const span = t1 - t0;
103
+ if (span === 0)
104
+ return () => 0.5;
105
+ return (v) => ((typeof v === 'number' ? v : v.getTime()) - t0) / span;
106
+ }
Binary file