@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,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
|
+
}
|