@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,216 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The boxplot chart type: five-number summaries per category with
4
+ * Tukey whiskers and outlier dots. Data shape (two forms per box):
5
+ *
6
+ * data = { boxes: [{ label, values: number[] } // raw samples
7
+ * | { label, min, q1, med, q3, max, outliers? }] } // precomputed
8
+ * config = { type:'boxplot', title?, catLabel?, valLabel? }
9
+ *
10
+ * Raw samples get the standard treatment: quartiles by linear
11
+ * interpolation over the sorted values, whiskers at the most extreme
12
+ * samples inside the 1.5·IQR fences, everything outside them an
13
+ * outlier. Precomputed summaries are trusted as given (their whiskers
14
+ * are the stated min/max). The AST is unit-space: `u` along the
15
+ * category axis, `v` along the value axis.
16
+ */
17
+
18
+ import { svgRoot, line as svgLine, coord } from '@jarenjs/view/helpers';
19
+ import { clamp01 } from '@jarenjs/core/math';
20
+ import { scaleLinear, scaleBand } from '../core/scale.js';
21
+ import { axisTicksLinear, formatTickValue } from '../core/axis.js';
22
+ import { cartesianFrame, annotateChart } from '../core/cartesian.js';
23
+ import { CATEGORICAL, seriesColor } from '../core/palette.js';
24
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
25
+
26
+ /**
27
+ * @typedef {object} BoxAST
28
+ * @property {string} label
29
+ * @property {number} u0 @property {number} u1
30
+ * @property {number} loV whisker low @property {number} q1V
31
+ * @property {number} medV @property {number} q3V
32
+ * @property {number} hiV whisker high
33
+ * @property {number[]} outliersV
34
+ * @property {{min:number, q1:number, med:number, q3:number, max:number}} stats
35
+ */
36
+ /**
37
+ * @typedef {object} BoxplotAST
38
+ * @property {'boxplot'} type
39
+ * @property {string|null} title
40
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} cat
41
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} val
42
+ * @property {BoxAST[]} boxes
43
+ * @property {number} count number of categories
44
+ */
45
+
46
+ /**
47
+ * Quantile of an ascending-sorted sample by linear interpolation.
48
+ * @param {number[]} sorted - Ascending finite samples (non-empty)
49
+ * @param {number} p - Quantile in [0, 1]
50
+ * @returns {number}
51
+ */
52
+ export function quantileSorted(sorted, p) {
53
+ const at = (sorted.length - 1) * p;
54
+ const lo = Math.floor(at);
55
+ const hi = Math.ceil(at);
56
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo);
57
+ }
58
+
59
+ /**
60
+ * Five-number summary + Tukey fences for one box.
61
+ * @param {any} box
62
+ * @returns {{min:number, q1:number, med:number, q3:number, max:number, lo:number, hi:number, outliers:number[]}|null}
63
+ */
64
+ function summarize(box) {
65
+ if (Array.isArray(box?.values)) {
66
+ const sorted = box.values
67
+ .filter((v) => typeof v === 'number' && Number.isFinite(v))
68
+ .sort((a, b) => a - b);
69
+ if (sorted.length === 0) return null;
70
+ const q1 = quantileSorted(sorted, 0.25);
71
+ const med = quantileSorted(sorted, 0.5);
72
+ const q3 = quantileSorted(sorted, 0.75);
73
+ const iqr = q3 - q1;
74
+ const loFence = q1 - 1.5 * iqr;
75
+ const hiFence = q3 + 1.5 * iqr;
76
+ const inside = sorted.filter((v) => v >= loFence && v <= hiFence);
77
+ return {
78
+ min: sorted[0],
79
+ q1, med, q3,
80
+ max: sorted[sorted.length - 1],
81
+ lo: inside.length !== 0 ? inside[0] : q1,
82
+ hi: inside.length !== 0 ? inside[inside.length - 1] : q3,
83
+ outliers: sorted.filter((v) => v < loFence || v > hiFence),
84
+ };
85
+ }
86
+ const fields = [box?.min, box?.q1, box?.med, box?.q3, box?.max];
87
+ if (!fields.every((v) => typeof v === 'number' && Number.isFinite(v))) return null;
88
+ const outliers = (box.outliers ?? []).filter((v) => typeof v === 'number' && Number.isFinite(v));
89
+ return {
90
+ min: box.min, q1: box.q1, med: box.med, q3: box.q3, max: box.max,
91
+ lo: box.min, hi: box.max, outliers,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Build the geometry-free boxplot AST.
97
+ * @param {any} data
98
+ * @param {any} [config]
99
+ * @returns {BoxplotAST}
100
+ */
101
+ export function buildBoxplotAST(data, config = {}) {
102
+ const input = (data?.boxes ?? [])
103
+ .map((box) => ({ label: String(box?.label ?? ''), stats: summarize(box) }))
104
+ .filter((box) => box.stats !== null);
105
+
106
+ let lo = Infinity;
107
+ let hi = -Infinity;
108
+ for (const { stats } of input) {
109
+ const min = Math.min(stats.lo, ...stats.outliers);
110
+ const max = Math.max(stats.hi, ...stats.outliers);
111
+ if (min < lo) lo = min;
112
+ if (max > hi) hi = max;
113
+ }
114
+ if (!Number.isFinite(lo)) { lo = 0; hi = 1; }
115
+ const ticks = axisTicksLinear(lo, hi, 5);
116
+ const sLo = Math.min(lo, ticks[0] ?? lo);
117
+ const sHi = Math.max(hi, ticks[ticks.length - 1] ?? hi);
118
+ const scale = scaleLinear(sLo, sHi === sLo ? sLo + 1 : sHi);
119
+
120
+ const labels = input.map((box) => box.label);
121
+ const band = scaleBand(labels, 0.35);
122
+ const boxes = input.map((box, i) => {
123
+ const start = i * band.step + (band.step - band.bandwidth) / 2;
124
+ const s = box.stats;
125
+ return {
126
+ label: box.label,
127
+ u0: start,
128
+ u1: start + band.bandwidth,
129
+ loV: clamp01(scale(s.lo)),
130
+ q1V: clamp01(scale(s.q1)),
131
+ medV: clamp01(scale(s.med)),
132
+ q3V: clamp01(scale(s.q3)),
133
+ hiV: clamp01(scale(s.hi)),
134
+ outliersV: s.outliers.map((v) => clamp01(scale(v))),
135
+ stats: { min: s.min, q1: s.q1, med: s.med, q3: s.q3, max: s.max },
136
+ };
137
+ });
138
+
139
+ return {
140
+ type: 'boxplot',
141
+ title: config.title ?? null,
142
+ cat: {
143
+ ticks: labels.map((label, i) => ({ pos: (i + 0.5) * band.step, label })),
144
+ label: config.catLabel ?? null,
145
+ },
146
+ val: {
147
+ ticks: ticks.map((v) => ({ pos: clamp01(scale(v)), label: formatTickValue(v) })),
148
+ label: config.valLabel ?? null,
149
+ },
150
+ boxes,
151
+ count: boxes.length,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Render a boxplot AST to a pure-vnode SVG: capped whiskers, a
157
+ * translucent box with a full-strength median line, outlier dots, and
158
+ * a summary `<title>` per box.
159
+ * @param {BoxplotAST} ast
160
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
161
+ * @param {string} hash
162
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number,
163
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
164
+ * @returns {any}
165
+ */
166
+ export function renderBoxplotAST(ast, theme, hash, options = {}) {
167
+ const t = theme.tokens;
168
+ const palette = options.palette ?? CATEGORICAL;
169
+ const tooltip = normalizeTooltip(options.tooltip);
170
+ const color = seriesColor(0, palette);
171
+ const frame = cartesianFrame({
172
+ title: ast.title,
173
+ legend: null,
174
+ xAxis: ast.cat,
175
+ yAxis: ast.val,
176
+ grid: 'y',
177
+ width: options.width,
178
+ palette,
179
+ theme,
180
+ });
181
+ const { plot } = frame;
182
+ const children = frame.children;
183
+ for (const box of ast.boxes) {
184
+ const x0 = plot.x + box.u0 * plot.w;
185
+ const x1 = plot.x + box.u1 * plot.w;
186
+ const cx = (x0 + x1) / 2;
187
+ const capW = (x1 - x0) * 0.5;
188
+ const y = (v) => plot.y + (1 - v) * plot.h;
189
+ const whisker = { stroke: t.axis, 'stroke-width': 1, class: 'chart-box-whisker' };
190
+ children.push(svgLine(coord(cx), coord(y(box.loV)), coord(cx), coord(y(box.q1V)), whisker));
191
+ children.push(svgLine(coord(cx), coord(y(box.q3V)), coord(cx), coord(y(box.hiV)), whisker));
192
+ children.push(svgLine(coord(cx - capW / 2), coord(y(box.loV)), coord(cx + capW / 2), coord(y(box.loV)), whisker));
193
+ children.push(svgLine(coord(cx - capW / 2), coord(y(box.hiV)), coord(cx + capW / 2), coord(y(box.hiV)), whisker));
194
+ const s = box.stats;
195
+ children.push(valueMark('rect', {
196
+ x: coord(x0), y: coord(y(box.q3V)),
197
+ width: coord(x1 - x0), height: coord(Math.max(1, y(box.q1V) - y(box.q3V))),
198
+ fill: color, 'fill-opacity': 0.35, stroke: color, 'stroke-width': 1.5,
199
+ class: 'chart-box',
200
+ }, tooltip,
201
+ `${box.label} — min ${formatTickValue(s.min)}, q1 ${formatTickValue(s.q1)}, `
202
+ + `median ${formatTickValue(s.med)}, q3 ${formatTickValue(s.q3)}, max ${formatTickValue(s.max)}`,
203
+ { type: 'boxplot', label: box.label, ...s }));
204
+ children.push(svgLine(coord(x0), coord(y(box.medV)), coord(x1), coord(y(box.medV)),
205
+ { stroke: color, 'stroke-width': 2, class: 'chart-box-median' }));
206
+ for (const v of box.outliersV) {
207
+ children.push(['circle', {
208
+ cx: coord(cx), cy: coord(y(v)), r: 2.5,
209
+ fill: color, 'fill-opacity': 0.75, class: 'chart-dot',
210
+ }]);
211
+ }
212
+ }
213
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-boxplot-chart',
214
+ frame.width, frame.height, theme, children, (options.keyPrefix ?? 'box-') + hash);
215
+ return annotateChart(svg, ast.title);
216
+ }
@@ -0,0 +1,274 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The candlestick chart type: OHLC records on a time x-scale and
4
+ * a linear y-scale. Up/down candles use the win/loss semantic pair —
5
+ * gain and loss are exactly what the tokens mean, and the pair is
6
+ * host-linked (`--ok`/`--fail`) like every semantic color. Data shape:
7
+ *
8
+ * data = { candles: [{t, open, high, low, close}] }
9
+ * config = { type:'candlestick', title?, xLabel?, yLabel?, domain? }
10
+ *
11
+ * Candle width comes from the band-width math: an equal share of the
12
+ * axis per candle (klines arrive at a fixed interval, so equal bands
13
+ * and true time positions coincide).
14
+ *
15
+ * `config.domain` declares a domain-stability policy (`core/domain.js`):
16
+ * a quantized sliding `x` window (candles older than it are dropped —
17
+ * a clamped candle would misstate its prices), pinned or
18
+ * step-quantized `y` bounds. The AST records the resolved domain so a
19
+ * later build — or the incremental session — can detect "unchanged".
20
+ *
21
+ * The extremes scan, domain resolution and per-candle mapping are
22
+ * exported (`scanCandleExtremes` / `resolveCandleDomains` /
23
+ * `candleUnit`) because the session must make the same decisions from
24
+ * the same numbers. Each candle renders as one keyed
25
+ * `<g class="chart-candle">` (wick line, then body rect) — the candle
26
+ * is the replaceable unit a kline upsert patches; `buildCandlestickRender`
27
+ * is the render variant that also returns that geometry.
28
+ */
29
+
30
+ import { svgRoot, line as svgLine, coord } from '@jarenjs/view/helpers';
31
+ import { clamp01 } from '@jarenjs/core/math';
32
+ import { scaleLinear, scaleTime } from '../core/scale.js';
33
+ import {
34
+ axisTicksLinear, axisTicksTime, niceTimeStep, formatTickValue, formatTimeTick,
35
+ } from '../core/axis.js';
36
+ import { cartesianFrame, annotateChart } from '../core/cartesian.js';
37
+ import { normalizeTooltip, markProps } from '../core/marks.js';
38
+ import { numOf } from '../core/stream-adapter.js';
39
+ import {
40
+ normalizeDomainPolicy, resolveWindowX, resolveStepY, resolvePinnedY,
41
+ } from '../core/domain.js';
42
+
43
+ /**
44
+ * @typedef {object} CandleAST
45
+ * @property {number} t open time (epoch ms — the candle's identity)
46
+ * @property {number} u center position (0..1)
47
+ * @property {number} w body width (0..1)
48
+ * @property {number} openV @property {number} closeV
49
+ * @property {number} highV @property {number} lowV
50
+ * @property {number} open @property {number} high
51
+ * @property {number} low @property {number} close raw prices, which the
52
+ * hover text reports (a clamped unit value cannot be read back to one)
53
+ * @property {boolean} up close >= open
54
+ */
55
+ /**
56
+ * @typedef {object} CandlestickAST
57
+ * @property {'candlestick'} type
58
+ * @property {string|null} title
59
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} x
60
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} y
61
+ * @property {{x: [number, number], y: [number, number]}} domain resolved scale bounds
62
+ * @property {CandleAST[]} candles
63
+ */
64
+
65
+ /**
66
+ * Scan the candle extremes the domain resolution needs: t bounds over
67
+ * every well-formed candle, price bounds over the candles a window
68
+ * keeps — and the kept list itself (windowed-out candles are dropped
69
+ * entirely; a candle clamped to the edge would misstate its prices).
70
+ * @param {any[]} rawCandles
71
+ * @param {import('../core/domain.js').DomainPolicy} policy
72
+ * @returns {{t0:number, t1:number, lo:number, hi:number, kept: any[]}}
73
+ */
74
+ export function scanCandleExtremes(rawCandles, policy) {
75
+ const all = (rawCandles ?? []).filter((c) =>
76
+ Number.isFinite(numOf(c?.t)) && [c?.open, c?.high, c?.low, c?.close].every(Number.isFinite));
77
+ let t0 = Infinity;
78
+ let t1 = -Infinity;
79
+ for (const c of all) {
80
+ const t = numOf(c.t);
81
+ if (t < t0) t0 = t;
82
+ if (t > t1) t1 = t;
83
+ }
84
+ let kept = all;
85
+ if (policy.window !== null) {
86
+ const [wLo] = resolveWindowX(t1, policy.window, policy.slide);
87
+ kept = all.filter((c) => numOf(c.t) >= wLo);
88
+ }
89
+ let lo = Infinity;
90
+ let hi = -Infinity;
91
+ for (const c of kept) {
92
+ if (c.low < lo) lo = c.low;
93
+ if (c.high > hi) hi = c.high;
94
+ }
95
+ return { t0, t1, lo, hi, kept };
96
+ }
97
+
98
+ /**
99
+ * Resolve the scale domains (and their tick values) from the scanned
100
+ * extremes under the domain policy — the single place the candlestick
101
+ * type decides its bounds.
102
+ * @param {{t0:number, t1:number, lo:number, hi:number}} ext
103
+ * @param {import('../core/domain.js').DomainPolicy} policy
104
+ * @returns {{x: [number,number], y: [number,number],
105
+ * xTickValues: number[], yTickValues: number[]}}
106
+ */
107
+ export function resolveCandleDomains(ext, policy) {
108
+ let { t0, t1, lo, hi } = ext;
109
+ if (policy.window !== null)
110
+ [t0, t1] = resolveWindowX(ext.t1, policy.window, policy.slide);
111
+ if (!Number.isFinite(t0)) { t0 = 0; t1 = 1; }
112
+ if (!Number.isFinite(lo)) { lo = 0; hi = 1; }
113
+
114
+ const xHi = t1 === t0 ? t0 + 1 : t1;
115
+ let yTickValues;
116
+ let yLo;
117
+ let yHi;
118
+ if (policy.step || policy.pin !== null) {
119
+ [yLo, yHi] = policy.step ? resolveStepY(lo, hi) : resolvePinnedY(lo, hi, policy.pin, false);
120
+ if (yHi === yLo) yHi = yLo + 1;
121
+ yTickValues = axisTicksLinear(yLo, yHi, 5);
122
+ }
123
+ else {
124
+ yTickValues = axisTicksLinear(lo, hi, 5);
125
+ yLo = Math.min(lo, yTickValues[0] ?? lo);
126
+ const extended = Math.max(hi, yTickValues[yTickValues.length - 1] ?? hi);
127
+ yHi = extended === yLo ? yLo + 1 : extended;
128
+ }
129
+ return {
130
+ x: [t0, xHi],
131
+ y: [yLo, yHi],
132
+ xTickValues: axisTicksTime(t0, xHi, 4),
133
+ xTickStep: niceTimeStep(xHi - t0, 4),
134
+ yTickValues,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Map one OHLC record onto its unit-space candle — the per-candle half
140
+ * of the build, shared with the session.
141
+ * @param {any} c a well-formed `{t, open, high, low, close}` record
142
+ * @param {(v:number)=>number} xScale @param {(v:number)=>number} yScale
143
+ * @param {number} w candle width (0..1)
144
+ * @returns {CandleAST}
145
+ */
146
+ export function candleUnit(c, xScale, yScale, w) {
147
+ const t = numOf(c.t);
148
+ return {
149
+ t,
150
+ u: clamp01(xScale(t)),
151
+ w,
152
+ openV: clamp01(yScale(c.open)),
153
+ closeV: clamp01(yScale(c.close)),
154
+ highV: clamp01(yScale(c.high)),
155
+ lowV: clamp01(yScale(c.low)),
156
+ open: c.open,
157
+ high: c.high,
158
+ low: c.low,
159
+ close: c.close,
160
+ up: c.close >= c.open,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Build the geometry-free candlestick AST.
166
+ * @param {any} data
167
+ * @param {any} [config]
168
+ * @returns {CandlestickAST}
169
+ */
170
+ export function buildCandlestickAST(data, config = {}) {
171
+ const policy = normalizeDomainPolicy(config.domain);
172
+ const ext = scanCandleExtremes(data?.candles, policy);
173
+ const domains = resolveCandleDomains(ext, policy);
174
+ const xScale = scaleTime(domains.x[0], domains.x[1]);
175
+ const yScale = scaleLinear(domains.y[0], domains.y[1]);
176
+
177
+ const w = ext.kept.length === 0 ? 0.1 : (1 / ext.kept.length) * 0.7;
178
+ const candles = ext.kept.map((c) => candleUnit(c, xScale, yScale, w));
179
+
180
+ return {
181
+ type: 'candlestick',
182
+ title: config.title ?? null,
183
+ x: {
184
+ ticks: domains.xTickValues.map((v) => ({
185
+ pos: clamp01(xScale(v)), label: formatTimeTick(v, domains.xTickStep),
186
+ })),
187
+ label: config.xLabel ?? null,
188
+ },
189
+ y: {
190
+ ticks: domains.yTickValues.map((v) => ({ pos: clamp01(yScale(v)), label: formatTickValue(v) })),
191
+ label: config.yLabel ?? null,
192
+ },
193
+ domain: { x: domains.x, y: domains.y },
194
+ candles,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Render one candle as its keyed `<g>` group: an OHLC hover `<title>`,
200
+ * then the wick line under the body rect, up/down tones from the
201
+ * theme's win/loss pair.
202
+ * @param {CandleAST} c
203
+ * @param {{x:number,y:number,w:number,h:number}} plot
204
+ * @param {{tokens: Record<string,string>}} theme
205
+ * @param {import('../core/marks.js').ChartTooltip|null} [tooltip]
206
+ * @returns {any}
207
+ */
208
+ export function candleRender(c, plot, theme, tooltip = null) {
209
+ const color = c.up ? theme.tokens.win : theme.tokens.loss;
210
+ const x = plot.x + c.u * plot.w;
211
+ const halfW = Math.max(1, (c.w * plot.w) / 2);
212
+ const yHigh = plot.y + (1 - c.highV) * plot.h;
213
+ const yLow = plot.y + (1 - c.lowV) * plot.h;
214
+ const yOpen = plot.y + (1 - c.openV) * plot.h;
215
+ const yClose = plot.y + (1 - c.closeV) * plot.h;
216
+ const bodyTop = Math.min(yOpen, yClose);
217
+ const bodyH = Math.max(1, Math.abs(yOpen - yClose));
218
+ const text = `${formatTimeTick(c.t)} O ${c.open} H ${c.high} L ${c.low} C ${c.close}`;
219
+ return ['g', markProps({ key: `c${c.t}`, class: 'chart-candle' }, tooltip, text,
220
+ { type: 'candlestick', t: c.t, open: c.open, high: c.high, low: c.low, close: c.close }),
221
+ ['title', {}, text],
222
+ svgLine(coord(x), coord(yHigh), coord(x), coord(yLow),
223
+ { stroke: color, 'stroke-width': 1, class: c.up ? 'chart-candle-up' : 'chart-candle-down' }),
224
+ ['rect', {
225
+ x: coord(x - halfW), y: coord(bodyTop),
226
+ width: coord(halfW * 2), height: coord(bodyH),
227
+ fill: color, class: c.up ? 'chart-candle-up' : 'chart-candle-down',
228
+ }],
229
+ ];
230
+ }
231
+
232
+ /**
233
+ * Render a candlestick AST and return the svg WITH the geometry a
234
+ * session needs to patch it per candle.
235
+ * @param {CandlestickAST} ast
236
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
237
+ * @param {string} hash
238
+ * @param {{rootClass?: string, keyPrefix?: string, width?: number,
239
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
240
+ * @returns {{svg: any, plot: {x:number,y:number,w:number,h:number}, chromeLen: number}}
241
+ */
242
+ export function buildCandlestickRender(ast, theme, hash, options = {}) {
243
+ const tooltip = normalizeTooltip(options.tooltip);
244
+ const frame = cartesianFrame({
245
+ title: ast.title,
246
+ legend: null,
247
+ xAxis: ast.x,
248
+ yAxis: ast.y,
249
+ grid: 'y',
250
+ width: options.width,
251
+ theme,
252
+ });
253
+ const { plot } = frame;
254
+ const chromeLen = frame.children.length;
255
+ const children = frame.children;
256
+ for (const c of ast.candles)
257
+ children.push(candleRender(c, plot, theme, tooltip));
258
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-candlestick-chart',
259
+ frame.width, frame.height, theme, children, (options.keyPrefix ?? 'candle-') + hash);
260
+ annotateChart(svg, ast.title);
261
+ return { svg, plot, chromeLen };
262
+ }
263
+
264
+ /**
265
+ * Render a candlestick AST to a pure-vnode SVG.
266
+ * @param {CandlestickAST} ast
267
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
268
+ * @param {string} hash
269
+ * @param {{rootClass?: string, keyPrefix?: string, width?: number}} [options]
270
+ * @returns {any}
271
+ */
272
+ export function renderCandlestickAST(ast, theme, hash, options = {}) {
273
+ return buildCandlestickRender(ast, theme, hash, options).svg;
274
+ }
@@ -0,0 +1,140 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The gauge chart type: a single value on a semicircular dial —
4
+ * the "how full is it" headline mark. Data shape:
5
+ *
6
+ * data = { value: number }
7
+ * config = { type:'gauge', title?, min?, max?, unit?, tone? }
8
+ *
9
+ * The domain is `[min, max]` (0..100 when unset); the AST carries the
10
+ * clamped fill fraction and tick fractions only — the dial radius and
11
+ * stroke widths are render decisions. `tone` colors the fill through
12
+ * the semantic win/loss pair; the default is the first categorical
13
+ * anchor (the brand blue).
14
+ */
15
+
16
+ import {
17
+ svgRoot, path as svgPath, line as svgLine, textAt, coord, polarPoint, anchorForAngle,
18
+ } from '@jarenjs/view/helpers';
19
+ import { clamp01 } from '@jarenjs/core/math';
20
+ import { axisTicksLinear, formatTickValue } from '../core/axis.js';
21
+ import { FS_TICK, toneColor, annotateChart, chartTitle } from '../core/cartesian.js';
22
+ import { CATEGORICAL } from '../core/palette.js';
23
+ import { normalizeTooltip, markProps } from '../core/marks.js';
24
+
25
+ /**
26
+ * @typedef {object} GaugeAST
27
+ * @property {'gauge'} type
28
+ * @property {string|null} title
29
+ * @property {number} value
30
+ * @property {string|null} unit
31
+ * @property {number} min @property {number} max
32
+ * @property {number} frac clamped fill fraction (0..1)
33
+ * @property {{frac: number, label: string}[]} ticks
34
+ * @property {'win'|'loss'|null} tone
35
+ */
36
+
37
+ /**
38
+ * Build the geometry-free gauge AST.
39
+ * @param {any} data
40
+ * @param {any} [config]
41
+ * @returns {GaugeAST}
42
+ */
43
+ export function buildGaugeAST(data, config = {}) {
44
+ const raw = data?.value;
45
+ const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
46
+ let min = typeof config.min === 'number' && Number.isFinite(config.min) ? config.min : 0;
47
+ let max = typeof config.max === 'number' && Number.isFinite(config.max) ? config.max : 100;
48
+ if (max <= min) max = min + 1;
49
+ const frac = clamp01((value - min) / (max - min));
50
+ const ticks = axisTicksLinear(min, max, 4).map((v) => ({
51
+ frac: clamp01((v - min) / (max - min)),
52
+ label: formatTickValue(v),
53
+ }));
54
+ return {
55
+ type: 'gauge',
56
+ title: config.title ?? null,
57
+ value,
58
+ unit: config.unit ?? null,
59
+ min,
60
+ max,
61
+ frac,
62
+ ticks,
63
+ tone: config.tone === 'win' || config.tone === 'loss' ? config.tone : null,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Render a gauge AST to a pure-vnode SVG: a semicircular track, the
69
+ * value arc over it, outward tick marks with labels, and the value as
70
+ * the headline figure in the dial's mouth.
71
+ * @param {GaugeAST} ast
72
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
73
+ * @param {string} hash
74
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[],
75
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
76
+ * @returns {any}
77
+ */
78
+ export function renderGaugeAST(ast, theme, hash, options = {}) {
79
+ const t = theme.tokens;
80
+ const palette = options.palette ?? CATEGORICAL;
81
+ const tooltip = normalizeTooltip(options.tooltip);
82
+ const valueText = formatTickValue(ast.value) + (ast.unit ? ` ${ast.unit}` : '');
83
+ const R = 110;
84
+ const stroke = 18;
85
+ const pad = 46;
86
+ const cx = pad + R;
87
+ const top = ast.title ? 40 : 16;
88
+ const cy = top + R + stroke / 2;
89
+ const children = [];
90
+
91
+ if (ast.title) {
92
+ children.push(chartTitle(cx, 24, ast.title, 15, t));
93
+ }
94
+
95
+ // Dial angles run π (left) → 2π (right); the fill sweeps clockwise.
96
+ const angleAt = (frac) => Math.PI + frac * Math.PI;
97
+ const pointAt = (frac, r) => {
98
+ const p = polarPoint(cx, cy, r, angleAt(frac));
99
+ return [coord(p.x), coord(p.y)];
100
+ };
101
+ const arcPath = (f0, f1) => {
102
+ const [x0, y0] = pointAt(f0, R);
103
+ const [x1, y1] = pointAt(f1, R);
104
+ return `M${x0},${y0} A${R},${R} 0 0 1 ${x1},${y1}`;
105
+ };
106
+
107
+ children.push(svgPath(arcPath(0, 1),
108
+ { fill: 'none', stroke: t.grid, 'stroke-width': stroke, class: 'chart-gauge-track' }));
109
+ if (ast.frac > 0) {
110
+ // The fill arc takes the tooltip bindings but no `<title>`: this
111
+ // chart already spells its value out in 30px type below, and with
112
+ // no chart title the root's aria-label is that same value.
113
+ children.push(['path', markProps({
114
+ d: arcPath(0, ast.frac),
115
+ fill: 'none', stroke: toneColor(theme, ast.tone, 0, palette),
116
+ 'stroke-width': stroke, class: 'chart-gauge-fill',
117
+ }, tooltip, valueText,
118
+ { type: 'gauge', value: ast.value, min: ast.min, max: ast.max, unit: ast.unit })]);
119
+ }
120
+
121
+ for (const tick of ast.ticks) {
122
+ const [x0, y0] = pointAt(tick.frac, R + stroke / 2 + 2);
123
+ const [x1, y1] = pointAt(tick.frac, R + stroke / 2 + 8);
124
+ children.push(svgLine(x0, y0, x1, y1, { stroke: t.axis, 'stroke-width': 1, class: 'chart-axis' }));
125
+ const [lx, ly] = pointAt(tick.frac, R + stroke / 2 + 12);
126
+ const anchor = anchorForAngle(angleAt(tick.frac));
127
+ children.push(textAt(lx, ly + (anchor === 'middle' ? -2 : 4), tick.label, FS_TICK,
128
+ { 'text-anchor': anchor, fill: t.muted, class: 'chart-tick' }));
129
+ }
130
+
131
+ // The headline figure inside the dial's mouth.
132
+ children.push(textAt(cx, cy - 8, valueText, 30,
133
+ { 'font-weight': 'bold', 'text-anchor': 'middle', fill: t.text, class: 'chart-gauge-value' }));
134
+
135
+ const width = 2 * (pad + R);
136
+ const height = cy + 24;
137
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-gauge-chart',
138
+ width, height, theme, children, (options.keyPrefix ?? 'gauge-') + hash);
139
+ return annotateChart(svg, ast.title ?? valueText);
140
+ }