@svgrid/grid 3.0.1 → 3.0.2
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/dist/GridFooter.svelte +14 -5
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/GridMenus.svelte +18 -10
- package/dist/GridMenus.svelte.d.ts +2 -1
- package/dist/SvGrid.controller.svelte.d.ts +17 -0
- package/dist/SvGrid.controller.svelte.js +128 -14
- package/dist/SvGrid.css +79 -10
- package/dist/SvGrid.svelte +191 -186
- package/dist/SvGrid.types.d.ts +42 -1
- package/dist/SvGridChart.svelte +571 -41
- package/dist/SvGridChart.svelte.d.ts +63 -1
- package/dist/SvGridChartPanel.svelte +169 -21
- package/dist/SvGridChartView.svelte +9 -0
- package/dist/SvGridChartView.svelte.d.ts +7 -0
- package/dist/SvGroupCell.svelte +28 -3
- package/dist/SvGroupCell.svelte.d.ts +8 -0
- package/dist/SvRowGroupPanel.svelte +20 -4
- package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
- package/dist/ai.d.ts +11 -2
- package/dist/ai.js +51 -7
- package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
- package/dist/cdn/{GridMenus-DFB6iwF8.js → GridMenus-M2XP9t0_.js} +152 -150
- package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
- package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-CxfakxR0.js} +5 -3
- package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-Dm_YEx9L.js} +5 -3
- package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
- package/dist/cdn/chart-CKJRCPOk.js +1652 -0
- package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
- package/dist/cdn/{src-C8QjSaq-.js → src-BJ2opd0j.js} +3608 -3414
- package/dist/cdn/{src-D0hO2eYN.js → src-DNbz94Ct.js} +8617 -8423
- package/dist/cdn/svgrid.js +16 -15
- package/dist/cdn/svgrid.svelte-external.js +16 -15
- package/dist/cell-formatting.d.ts +7 -0
- package/dist/cell-formatting.js +7 -1
- package/dist/chart-export.d.ts +23 -2
- package/dist/chart-export.js +80 -6
- package/dist/chart.d.ts +410 -18
- package/dist/chart.js +824 -24
- package/dist/grid-icons.d.ts +49 -0
- package/dist/grid-icons.js +86 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/package.json +11 -11
- package/src/GridFooter.svelte +14 -5
- package/src/GridMenus.svelte +18 -10
- package/src/SvGrid.controller.svelte.ts +146 -13
- package/src/SvGrid.css +79 -10
- package/src/SvGrid.svelte +191 -186
- package/src/SvGrid.types.ts +42 -1
- package/src/SvGridChart.svelte +571 -41
- package/src/SvGridChart.test.ts +243 -1
- package/src/SvGridChartPanel.svelte +169 -21
- package/src/SvGridChartView.svelte +9 -0
- package/src/SvGroupCell.svelte +28 -3
- package/src/SvRowGroupPanel.svelte +20 -4
- package/src/ai.test.ts +60 -1
- package/src/ai.ts +59 -6
- package/src/cell-formatting.ts +7 -1
- package/src/chart-export.test.ts +112 -0
- package/src/chart-export.ts +76 -8
- package/src/chart.test.ts +830 -1
- package/src/chart.ts +1053 -32
- package/src/grid-icons.test.ts +46 -0
- package/src/grid-icons.ts +184 -0
- package/src/icon-overrides.grid.test.ts +188 -0
- package/src/icon-seam.test.ts +100 -0
- package/src/index.ts +19 -0
- package/src/svgrid.charting.test.ts +120 -0
- package/dist/cdn/GridMenus-CYzOvmNQ.js +0 -600
- package/dist/cdn/SvGridChart-BEJmNNx9.js +0 -1203
- package/dist/cdn/SvGridChart-BwVos976.js +0 -1202
- package/dist/cdn/SvGridChartPanel-BOknOkrP.js +0 -561
- package/dist/cdn/SvGridChartPanel-D2PjII4O.js +0 -585
- package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
package/src/chart.ts
CHANGED
|
@@ -8,10 +8,37 @@
|
|
|
8
8
|
* (per-series type), a secondary (right) Y axis, signed Y domains (negative
|
|
9
9
|
* values drop below a zero baseline), and nice auto-scaled ticks.
|
|
10
10
|
*/
|
|
11
|
+
// The one import this module has. `Intl.NumberFormat` is expensive enough to
|
|
12
|
+
// construct that a chart with 8 axis ticks and 200 data labels would rebuild it
|
|
13
|
+
// 200 times a frame, and the grid already solved that for cells - so reuse that
|
|
14
|
+
// cache rather than starting a second one. Still DOM-free: it is plain TS.
|
|
15
|
+
import { getNumberFormatter } from './cell-formatting'
|
|
16
|
+
|
|
17
|
+
/** Every mark this engine can draw. `bar`, `line` and `area` compose (a series
|
|
18
|
+
* can override the spec's type for a combo chart); the rest are whole-chart
|
|
19
|
+
* types that ignore per-series overrides. */
|
|
11
20
|
export type ChartType =
|
|
12
21
|
| 'bar' | 'line' | 'area' | 'pie' | 'scatter'
|
|
13
22
|
| 'heatmap' | 'waterfall' | 'funnel' | 'radar'
|
|
14
23
|
| 'calendar' | 'gauge' | 'treemap' | 'sankey'
|
|
24
|
+
| 'candlestick' | 'ohlc' | 'boxplot'
|
|
25
|
+
|
|
26
|
+
/** One open / high / low / close bar. */
|
|
27
|
+
export type OhlcBar = { o: number; h: number; l: number; c: number }
|
|
28
|
+
|
|
29
|
+
/** A five-number summary: one box, its whiskers, and anything past them.
|
|
30
|
+
* `min` / `max` are the WHISKER ENDS, not the extremes of the sample - with
|
|
31
|
+
* the usual 1.5 IQR rule those differ, and the points beyond go in
|
|
32
|
+
* `outliers` so they can be drawn individually. */
|
|
33
|
+
export type BoxStats = {
|
|
34
|
+
min: number
|
|
35
|
+
q1: number
|
|
36
|
+
median: number
|
|
37
|
+
q3: number
|
|
38
|
+
max: number
|
|
39
|
+
/** Values outside the whiskers, drawn as individual points. */
|
|
40
|
+
outliers?: number[]
|
|
41
|
+
}
|
|
15
42
|
|
|
16
43
|
/** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`.
|
|
17
44
|
* `rowIds` is populated when the spec was built from grid rows (via
|
|
@@ -43,7 +70,40 @@ export type ChartSeries = {
|
|
|
43
70
|
values: number[]
|
|
44
71
|
color?: string
|
|
45
72
|
/** Per-series chart type, for combo charts. Defaults to the spec `type`. */
|
|
46
|
-
type?: 'bar' | 'line' | 'area'
|
|
73
|
+
type?: 'bar' | 'line' | 'area' | 'candlestick' | 'ohlc' | 'boxplot'
|
|
74
|
+
/**
|
|
75
|
+
* Open / high / low / close per category, parallel to `categories`. `null`
|
|
76
|
+
* is a gap (a day with no session) and draws nothing.
|
|
77
|
+
*
|
|
78
|
+
* Set `values` to the CLOSING prices alongside this. Everything that reads a
|
|
79
|
+
* series generically reads `values` - the tooltip rows, the CSV export, the
|
|
80
|
+
* screen-reader table, and `overlay` - so filling it in is what lets a
|
|
81
|
+
* candlestick series carry a moving average (`overlay: 'sma:20'`) or export
|
|
82
|
+
* to CSV without a single line of candle-specific code.
|
|
83
|
+
*/
|
|
84
|
+
ohlc?: Array<OhlcBar | null>
|
|
85
|
+
/**
|
|
86
|
+
* Five-number summaries per category, parallel to `categories`. `null` is a
|
|
87
|
+
* gap and draws nothing.
|
|
88
|
+
*
|
|
89
|
+
* Set `values` to the MEDIANS alongside this, for exactly the reason `ohlc`
|
|
90
|
+
* sets them to the closes: everything that reads a series generically reads
|
|
91
|
+
* `values`, so the tooltip rows, the CSV export, the screen-reader table and
|
|
92
|
+
* `overlay` all keep working with no box-specific code.
|
|
93
|
+
*
|
|
94
|
+
* `boxStats()` turns a raw sample into one of these.
|
|
95
|
+
*/
|
|
96
|
+
boxes?: Array<BoxStats | null>
|
|
97
|
+
/**
|
|
98
|
+
* Symmetric or asymmetric error bars, parallel to `values`. A number is a
|
|
99
|
+
* symmetric +/- margin; a pair is an explicit low/high; `null` draws nothing.
|
|
100
|
+
*
|
|
101
|
+
* These are an ANNOTATION on an existing mark rather than a mark of their
|
|
102
|
+
* own, so they compose: a bar, line, area or scatter series can carry them
|
|
103
|
+
* without changing its type. That is the whole reason they are not a
|
|
104
|
+
* `ChartType` - "bar chart with error bars" should not be a different chart.
|
|
105
|
+
*/
|
|
106
|
+
errors?: Array<number | { lo: number; hi: number } | null>
|
|
47
107
|
/** Plot against the left (default) or right Y axis. */
|
|
48
108
|
axis?: 'left' | 'right'
|
|
49
109
|
/** Scatter / bubble points (used when `type === 'scatter'`). */
|
|
@@ -116,6 +176,16 @@ export type ChartSpec = {
|
|
|
116
176
|
/** Number format for the value axis, tooltips, data labels and reference
|
|
117
177
|
* lines. Unset = the default compact `1.2k` / `1.2M` style. */
|
|
118
178
|
valueFormat?: ChartValueFormat
|
|
179
|
+
/** BCP-47 locale for value formatting. Setting this (or `currency`) switches
|
|
180
|
+
* formatting to `Intl.NumberFormat`, so thousands separators, the decimal
|
|
181
|
+
* mark and the compact suffixes follow the locale rather than the built-in
|
|
182
|
+
* English `1.2k` / `1.2M`. Unset = the locale-free default, which is why the
|
|
183
|
+
* default output has never changed under anyone's feet. */
|
|
184
|
+
locale?: string | ReadonlyArray<string>;
|
|
185
|
+
/** ISO 4217 code for `valueFormat: 'currency'` (`'EUR'`, `'JPY'`, ...). Unset
|
|
186
|
+
* means the axis reads `$`, which is wrong everywhere outside the dollar
|
|
187
|
+
* zone and was the only currency this chart could draw for a long time. */
|
|
188
|
+
currency?: string
|
|
119
189
|
/** Grouped (nested) category axis: a parent tier spanning consecutive leaf
|
|
120
190
|
* categories (spans must sum to `categories.length`). Vertical category
|
|
121
191
|
* charts only (ignored for time / horizontal / pie). */
|
|
@@ -134,8 +204,18 @@ export type ChartSpec = {
|
|
|
134
204
|
innerRadius?: number
|
|
135
205
|
/** Horizontal target / goal / average lines. */
|
|
136
206
|
referenceLines?: ChartReferenceLine[]
|
|
137
|
-
/**
|
|
138
|
-
|
|
207
|
+
/**
|
|
208
|
+
* How to read `categories` along the x axis.
|
|
209
|
+
*
|
|
210
|
+
* - `'category'` (default): evenly spaced labels, taken literally.
|
|
211
|
+
* - `'time'`: parsed as dates and positioned by ACTUAL elapsed time, so an
|
|
212
|
+
* irregular gap renders as a proportional gap.
|
|
213
|
+
* - `'ordinal-time'`: parsed as dates but spaced EVENLY, with date-derived
|
|
214
|
+
* ticks. This is what a series of trading sessions or business days needs:
|
|
215
|
+
* on a true time axis every weekend opens a hole a third as wide as the
|
|
216
|
+
* working week, which is noise rather than information.
|
|
217
|
+
*/
|
|
218
|
+
xType?: 'category' | 'time' | 'ordinal-time'
|
|
139
219
|
/** Axis titles (reserve gutter space + render). */
|
|
140
220
|
yAxisTitle?: string
|
|
141
221
|
y2AxisTitle?: string
|
|
@@ -186,6 +266,10 @@ export type ChartSpec = {
|
|
|
186
266
|
/** Waterfall: explicit colors for positive/negative/total bars. The
|
|
187
267
|
* series color is ignored when this is set. */
|
|
188
268
|
waterfallColors?: { positive?: string; negative?: string; total?: string }
|
|
269
|
+
/** Candlestick / OHLC colors. Direction beats series identity here, the same
|
|
270
|
+
* way `waterfallColors` overrides the series color. Defaults to the green /
|
|
271
|
+
* red pair from the palette's own vocabulary. */
|
|
272
|
+
candleColors?: { up?: string; down?: string }
|
|
189
273
|
/** Heatmap color scale. `'sequential'` maps min->max through one hue,
|
|
190
274
|
* `'diverging'` runs cold->neutral->warm around 0. A custom array
|
|
191
275
|
* (>=2 hex colors) defines an arbitrary gradient. Default `'sequential'`. */
|
|
@@ -331,6 +415,168 @@ export type ChartHeatmapCell = {
|
|
|
331
415
|
colLabel: string
|
|
332
416
|
}
|
|
333
417
|
|
|
418
|
+
/**
|
|
419
|
+
* One candlestick / OHLC bar in SVG coordinates.
|
|
420
|
+
*
|
|
421
|
+
* Kept apart from {@link ChartBar} rather than folded into it: bars pick up
|
|
422
|
+
* series pattern fills, data labels and the brush mini-map, and all three are
|
|
423
|
+
* wrong for a candle. A separate array means every existing loop over `bars`
|
|
424
|
+
* keeps working untouched, which is the point of this flat geometry.
|
|
425
|
+
*/
|
|
426
|
+
export type ChartCandle = {
|
|
427
|
+
/** Body rect left edge and width. In OHLC mode, the span of the two ticks. */
|
|
428
|
+
x: number
|
|
429
|
+
w: number
|
|
430
|
+
/** Wick line, and the OHLC bar's vertical. */
|
|
431
|
+
xCenter: number
|
|
432
|
+
yOpen: number
|
|
433
|
+
yClose: number
|
|
434
|
+
yHigh: number
|
|
435
|
+
yLow: number
|
|
436
|
+
/** Body rect, pre-ordered so the renderer does no min/max of its own. */
|
|
437
|
+
bodyY: number
|
|
438
|
+
bodyH: number
|
|
439
|
+
/** Close at or above open. Drives colour and hollow-vs-filled. */
|
|
440
|
+
up: boolean
|
|
441
|
+
color: string
|
|
442
|
+
label: string
|
|
443
|
+
series: string
|
|
444
|
+
o: number
|
|
445
|
+
h: number
|
|
446
|
+
l: number
|
|
447
|
+
c: number
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* A laid-out box plot, in SVG coordinates. Its own array for the same reason
|
|
452
|
+
* candles have one: `bars` carries pattern fills, data labels and the brush
|
|
453
|
+
* mini-map, none of which mean anything for a box.
|
|
454
|
+
*/
|
|
455
|
+
export type ChartBox = {
|
|
456
|
+
/** Box rect left edge and width. */
|
|
457
|
+
x: number
|
|
458
|
+
w: number
|
|
459
|
+
/** Whisker line and the caps, centred on the slot. */
|
|
460
|
+
xCenter: number
|
|
461
|
+
yMin: number
|
|
462
|
+
yQ1: number
|
|
463
|
+
yMedian: number
|
|
464
|
+
yQ3: number
|
|
465
|
+
yMax: number
|
|
466
|
+
/** Box rect, pre-ordered so the renderer does no min/max of its own. */
|
|
467
|
+
boxY: number
|
|
468
|
+
boxH: number
|
|
469
|
+
/** Points beyond the whiskers, already positioned. */
|
|
470
|
+
outliers: Array<{ y: number; value: number }>
|
|
471
|
+
color: string
|
|
472
|
+
label: string
|
|
473
|
+
series: string
|
|
474
|
+
min: number
|
|
475
|
+
q1: number
|
|
476
|
+
median: number
|
|
477
|
+
q3: number
|
|
478
|
+
max: number
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** One positioned error bar: a vertical span with caps, centred on its mark. */
|
|
482
|
+
export type ChartErrorBar = {
|
|
483
|
+
xCenter: number
|
|
484
|
+
yLo: number
|
|
485
|
+
yHi: number
|
|
486
|
+
/** Cap half-width, so the renderer draws the same T at both ends. */
|
|
487
|
+
cap: number
|
|
488
|
+
color: string
|
|
489
|
+
label: string
|
|
490
|
+
series: string
|
|
491
|
+
lo: number
|
|
492
|
+
hi: number
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Five-number summary of a raw sample, with the 1.5 IQR whisker rule.
|
|
497
|
+
*
|
|
498
|
+
* Whiskers stop at the last observation INSIDE the fence rather than at the
|
|
499
|
+
* fence itself, which is what makes them read as real data; anything past them
|
|
500
|
+
* comes back in `outliers`. Quartiles use linear interpolation between the two
|
|
501
|
+
* neighbouring order statistics.
|
|
502
|
+
*
|
|
503
|
+
* Returns `null` for an empty sample, so a category with no observations is a
|
|
504
|
+
* gap rather than a box drawn at zero.
|
|
505
|
+
*/
|
|
506
|
+
export function boxStats(sample: ReadonlyArray<number>, whisker = 1.5): BoxStats | null {
|
|
507
|
+
const v = sample.filter((n) => Number.isFinite(n)).slice().sort((a, b) => a - b)
|
|
508
|
+
if (!v.length) return null
|
|
509
|
+
const q = (p: number) => {
|
|
510
|
+
const pos = (v.length - 1) * p
|
|
511
|
+
const lo = Math.floor(pos)
|
|
512
|
+
const hi = Math.ceil(pos)
|
|
513
|
+
return lo === hi ? v[lo]! : v[lo]! + (v[hi]! - v[lo]!) * (pos - lo)
|
|
514
|
+
}
|
|
515
|
+
const q1 = q(0.25)
|
|
516
|
+
const median = q(0.5)
|
|
517
|
+
const q3 = q(0.75)
|
|
518
|
+
const fenceLo = q1 - whisker * (q3 - q1)
|
|
519
|
+
const fenceHi = q3 + whisker * (q3 - q1)
|
|
520
|
+
const inside = v.filter((n) => n >= fenceLo && n <= fenceHi)
|
|
521
|
+
const outliers = v.filter((n) => n < fenceLo || n > fenceHi)
|
|
522
|
+
return {
|
|
523
|
+
// `inside` can only be empty if every point is an outlier, which the fence
|
|
524
|
+
// rule makes impossible (q1 and q3 are always within it) - but a degenerate
|
|
525
|
+
// sample should still produce a box rather than `undefined` coordinates.
|
|
526
|
+
min: inside.length ? inside[0]! : v[0]!,
|
|
527
|
+
q1,
|
|
528
|
+
median,
|
|
529
|
+
q3,
|
|
530
|
+
max: inside.length ? inside[inside.length - 1]! : v[v.length - 1]!,
|
|
531
|
+
...(outliers.length ? { outliers } : {}),
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Category-axis labels, thinned so they do not overlap.
|
|
537
|
+
*
|
|
538
|
+
* A category axis used to emit one tick per category however many there were.
|
|
539
|
+
* At 5000 categories that is 5000 `<text>` nodes stacked into an unreadable
|
|
540
|
+
* grey band - the labels were the single biggest thing the renderer had to put
|
|
541
|
+
* in the DOM, and none of them could be read. The time and ordinal-time axes
|
|
542
|
+
* already thinned themselves (`dateTicks` / `ordinalDateTicks`); this brings
|
|
543
|
+
* the plain category axis in line.
|
|
544
|
+
*
|
|
545
|
+
* The first category always gets a label, so a thinned axis still starts where
|
|
546
|
+
* the data does.
|
|
547
|
+
*/
|
|
548
|
+
function thinCategoryTicks(
|
|
549
|
+
categories: string[],
|
|
550
|
+
xCenter: (i: number) => number,
|
|
551
|
+
slot: number,
|
|
552
|
+
rotated: boolean,
|
|
553
|
+
): ChartCategoryTick[] {
|
|
554
|
+
// Rotated labels run diagonally and pack far tighter than upright ones. 18px
|
|
555
|
+
// is deliberately just under the spacing a 40-category chart at 800px already
|
|
556
|
+
// had, so charts that read fine before are untouched and only genuinely
|
|
557
|
+
// overlapping axes get thinned.
|
|
558
|
+
const minPx = rotated ? 18 : 60
|
|
559
|
+
const step = Math.max(1, Math.ceil(minPx / Math.max(slot, 0.001)))
|
|
560
|
+
const out: ChartCategoryTick[] = []
|
|
561
|
+
for (let i = 0; i < categories.length; i += step) out.push({ label: categories[i]!, x: xCenter(i) })
|
|
562
|
+
return out
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Normalize one `errors` entry to an absolute low/high pair around `value`. */
|
|
566
|
+
function errorSpan(
|
|
567
|
+
e: number | { lo: number; hi: number } | null | undefined,
|
|
568
|
+
value: number,
|
|
569
|
+
): { lo: number; hi: number } | null {
|
|
570
|
+
if (e == null) return null
|
|
571
|
+
if (typeof e === 'number') {
|
|
572
|
+
if (!Number.isFinite(e)) return null
|
|
573
|
+
const m = Math.abs(e)
|
|
574
|
+
return { lo: value - m, hi: value + m }
|
|
575
|
+
}
|
|
576
|
+
if (!Number.isFinite(e.lo) || !Number.isFinite(e.hi)) return null
|
|
577
|
+
return { lo: Math.min(e.lo, e.hi), hi: Math.max(e.lo, e.hi) }
|
|
578
|
+
}
|
|
579
|
+
|
|
334
580
|
/** A computed bar rectangle in SVG coordinates. Output of {@link buildChart}, not an input. */
|
|
335
581
|
export type ChartBar = {
|
|
336
582
|
x: number
|
|
@@ -407,7 +653,33 @@ export type ChartGeometry = {
|
|
|
407
653
|
width: number
|
|
408
654
|
height: number
|
|
409
655
|
plot: { x: number; y: number; w: number; h: number }
|
|
656
|
+
/**
|
|
657
|
+
* The value domain each axis was actually drawn against, and the slot width
|
|
658
|
+
* of the category axis. Plain data, deliberately: the geometry stays a
|
|
659
|
+
* serialisable value object, and `chartScales(geo)` turns this into the
|
|
660
|
+
* `xOf` / `yOf` functions a custom mark needs.
|
|
661
|
+
*
|
|
662
|
+
* Null on the types with no cartesian axes (pie, gauge, treemap, sankey,
|
|
663
|
+
* calendar, radar, funnel), which is also how a caller can tell whether
|
|
664
|
+
* drawing into plot coordinates means anything.
|
|
665
|
+
*/
|
|
666
|
+
axes: {
|
|
667
|
+
y: { min: number; max: number; log: boolean }
|
|
668
|
+
/** The right axis, when a series is plotted against one. */
|
|
669
|
+
y2: { min: number; max: number; log: boolean } | null
|
|
670
|
+
/** Horizontal room per category, in px. */
|
|
671
|
+
slot: number
|
|
672
|
+
/** Number of categories the axis was laid out for. */
|
|
673
|
+
count: number
|
|
674
|
+
} | null
|
|
410
675
|
bars: ChartBar[]
|
|
676
|
+
/** Candlestick / OHLC bars. Empty for every other chart type. */
|
|
677
|
+
candles: ChartCandle[]
|
|
678
|
+
/** Box plots. Empty for every other chart type. */
|
|
679
|
+
boxes: ChartBox[]
|
|
680
|
+
/** Error bars, from any series carrying `errors`. Empty when none do - they
|
|
681
|
+
* annotate whatever mark the series already draws. */
|
|
682
|
+
errorBars: ChartErrorBar[]
|
|
411
683
|
lines: ChartLine[]
|
|
412
684
|
slices: ChartPieSlice[]
|
|
413
685
|
yTicks: ChartAxisTick[]
|
|
@@ -593,6 +865,62 @@ function project(value: number, min: number, max: number, isLog: boolean): numbe
|
|
|
593
865
|
return (value - min) / (max - min)
|
|
594
866
|
}
|
|
595
867
|
|
|
868
|
+
/** The scale functions for a laid-out cartesian chart. See {@link chartScales}. */
|
|
869
|
+
export type ChartScales = {
|
|
870
|
+
/** Pixel x at the centre of category `i`. Fractional indices interpolate,
|
|
871
|
+
* so `xOf(2.5)` is the midpoint between the third and fourth categories. */
|
|
872
|
+
xOf: (index: number) => number
|
|
873
|
+
/** Pixel y for a value on the left axis (or the right, when asked). Returns
|
|
874
|
+
* NaN for a value the axis cannot express, e.g. zero on a log scale. */
|
|
875
|
+
yOf: (value: number, axis?: 'left' | 'right') => number
|
|
876
|
+
/** The inverse of `xOf`: which category a pixel x falls on. Clamped. */
|
|
877
|
+
xInvert: (px: number) => number
|
|
878
|
+
/** The inverse of `yOf`. */
|
|
879
|
+
yInvert: (px: number, axis?: 'left' | 'right') => number
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Turn a laid-out chart's axes into functions, so a caller can draw its own
|
|
884
|
+
* marks in the same coordinates the built-in ones use.
|
|
885
|
+
*
|
|
886
|
+
* This is the custom-series seam. Rather than a registry of mark types, the
|
|
887
|
+
* chart hands over its geometry and its scales and lets the caller render
|
|
888
|
+
* whatever SVG it likes into the plot - which is the Svelte-shaped answer, and
|
|
889
|
+
* means a custom mark is ordinary markup rather than a plugin.
|
|
890
|
+
*
|
|
891
|
+
* Deriving the scale from `geo.axes` matters: the domain a chart drew against
|
|
892
|
+
* is the NICE-ROUNDED one, stretched to include zero for bar charts and any
|
|
893
|
+
* reference lines. Recomputing it from the data outside would land custom marks
|
|
894
|
+
* a few pixels off the built-in ones, in a way that looks like a rendering bug.
|
|
895
|
+
*
|
|
896
|
+
* Returns `null` for a chart with no cartesian axes (pie, gauge, treemap,
|
|
897
|
+
* sankey, calendar, radar, funnel), where plot coordinates mean nothing.
|
|
898
|
+
*/
|
|
899
|
+
export function chartScales(geo: ChartGeometry): ChartScales | null {
|
|
900
|
+
const a = geo.axes
|
|
901
|
+
if (!a) return null
|
|
902
|
+
const { x: px, y: py, w: pw, h: ph } = geo.plot
|
|
903
|
+
const yFor = (dom: { min: number; max: number; log: boolean }) => (value: number) => {
|
|
904
|
+
const t = project(value, dom.min, dom.max, dom.log)
|
|
905
|
+
return t === null ? Number.NaN : py + ph - t * ph
|
|
906
|
+
}
|
|
907
|
+
const left = yFor(a.y)
|
|
908
|
+
const right = a.y2 ? yFor(a.y2) : left
|
|
909
|
+
const invFor = (dom: { min: number; max: number; log: boolean }) => (y: number) => {
|
|
910
|
+
const t = ph === 0 ? 0 : (py + ph - y) / ph
|
|
911
|
+
if (!dom.log) return dom.min + t * (dom.max - dom.min)
|
|
912
|
+
const lo = Math.log10(dom.min)
|
|
913
|
+
return 10 ** (lo + t * (Math.log10(dom.max) - lo))
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
xOf: (i) => px + a.slot * i + a.slot / 2,
|
|
917
|
+
yOf: (value, axis) => (axis === 'right' ? right(value) : left(value)),
|
|
918
|
+
xInvert: (x) =>
|
|
919
|
+
Math.max(0, Math.min(a.count - 1, Math.floor((x - px) / (a.slot || 1)))),
|
|
920
|
+
yInvert: (y, axis) => (axis === 'right' && a.y2 ? invFor(a.y2)(y) : invFor(a.y)(y)),
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
596
924
|
// ---- Overlay math: trendline + moving averages -----------------------
|
|
597
925
|
|
|
598
926
|
/** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
|
|
@@ -766,14 +1094,46 @@ function fmtTick(n: number): string {
|
|
|
766
1094
|
/** Value-axis / tooltip / label number format. */
|
|
767
1095
|
export type ChartValueFormat = 'number' | 'currency' | 'percent' | 'compact'
|
|
768
1096
|
|
|
1097
|
+
/** Locale-aware formatting options, a structural subset of `ChartSpec` so a
|
|
1098
|
+
* caller inside the engine can pass the spec straight through. */
|
|
1099
|
+
export type ChartFormatLocale = { locale?: string | ReadonlyArray<string>; currency?: string }
|
|
1100
|
+
|
|
769
1101
|
/**
|
|
770
1102
|
* Format a numeric value for display, honouring an optional `valueFormat`.
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
1103
|
+
*
|
|
1104
|
+
* Two modes, on purpose. With no `locale` and no `currency` this is the original
|
|
1105
|
+
* locale-free output: the compact `1.2k` / `1.2M` base, currency prefixed with
|
|
1106
|
+
* `$` (sign outside), percent multiplied by 100 and suffixed `%`. Set either one
|
|
1107
|
+
* and it switches to `Intl.NumberFormat`, so separators, the decimal mark and the
|
|
1108
|
+
* compact suffixes all follow the locale.
|
|
1109
|
+
*
|
|
1110
|
+
* Keeping the old path as the default is deliberate rather than lazy. `Intl`'s
|
|
1111
|
+
* compact notation is not the same string even for `en-US` (`1.2K`, capital),
|
|
1112
|
+
* so formatting everything through it would silently restyle every axis in every
|
|
1113
|
+
* existing chart. Opting in is the only version of this that is not a surprise.
|
|
774
1114
|
*/
|
|
775
|
-
export function formatChartValue(
|
|
1115
|
+
export function formatChartValue(
|
|
1116
|
+
n: number,
|
|
1117
|
+
format?: ChartValueFormat,
|
|
1118
|
+
opts?: ChartFormatLocale,
|
|
1119
|
+
): string {
|
|
776
1120
|
if (!Number.isFinite(n)) return ''
|
|
1121
|
+
const localized = opts && (opts.locale || opts.currency)
|
|
1122
|
+
if (localized) {
|
|
1123
|
+
// Compact notation because these are axis ticks and data labels, where a
|
|
1124
|
+
// full-precision number is what makes an axis unreadable.
|
|
1125
|
+
const style = format === 'currency' ? 'currency' : format === 'percent' ? 'percent' : 'decimal'
|
|
1126
|
+
const o: Intl.NumberFormatOptions = { notation: 'compact', maximumFractionDigits: 1 }
|
|
1127
|
+
if (style === 'currency') {
|
|
1128
|
+
o.style = 'currency'
|
|
1129
|
+
// `style: 'currency'` throws without a code, so fall back to the symbol
|
|
1130
|
+
// this used to hard-code rather than refusing to draw the chart.
|
|
1131
|
+
o.currency = opts!.currency || 'USD'
|
|
1132
|
+
} else if (style === 'percent') {
|
|
1133
|
+
o.style = 'percent'
|
|
1134
|
+
}
|
|
1135
|
+
return getNumberFormatter(opts!.locale, o).format(n)
|
|
1136
|
+
}
|
|
777
1137
|
if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
|
|
778
1138
|
if (format === 'percent') {
|
|
779
1139
|
const p = n * 100
|
|
@@ -793,6 +1153,53 @@ function dateTicks(tMin: number, tMax: number): number[] {
|
|
|
793
1153
|
if (!ticks.length) ticks.push(tMin, tMax)
|
|
794
1154
|
return ticks
|
|
795
1155
|
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Tick positions for an ordinal (evenly spaced) date axis, as INDICES into
|
|
1158
|
+
* `times`.
|
|
1159
|
+
*
|
|
1160
|
+
* A time axis can put a tick anywhere, because x is a function of the
|
|
1161
|
+
* timestamp. An ordinal axis cannot: x is a function of the index, so a tick
|
|
1162
|
+
* has to land on a point that exists. This picks the first point of each
|
|
1163
|
+
* calendar unit - day, week, month, year, whichever gets closest to `target`
|
|
1164
|
+
* ticks without going over - so labels sit on real sessions and a weekend or a
|
|
1165
|
+
* holiday never stretches the spacing.
|
|
1166
|
+
*/
|
|
1167
|
+
export function ordinalDateTicks(times: number[], target = 6): number[] {
|
|
1168
|
+
if (times.length <= 1) return times.length ? [0] : []
|
|
1169
|
+
const keyOf: Record<string, (d: Date) => number | string> = {
|
|
1170
|
+
day: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`,
|
|
1171
|
+
week: (d) => Math.floor((Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / DAY + 4) / 7),
|
|
1172
|
+
month: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}`,
|
|
1173
|
+
year: (d) => d.getUTCFullYear(),
|
|
1174
|
+
}
|
|
1175
|
+
let chosen: number[] | null = null
|
|
1176
|
+
for (const unit of ['day', 'week', 'month', 'year'] as const) {
|
|
1177
|
+
const at: number[] = []
|
|
1178
|
+
let prev: number | string | null = null
|
|
1179
|
+
for (let i = 0; i < times.length; i += 1) {
|
|
1180
|
+
const t = times[i]!
|
|
1181
|
+
if (!Number.isFinite(t)) continue
|
|
1182
|
+
const k = keyOf[unit]!(new Date(t))
|
|
1183
|
+
if (k !== prev) at.push(i)
|
|
1184
|
+
prev = k
|
|
1185
|
+
}
|
|
1186
|
+
chosen = at
|
|
1187
|
+
if (at.length <= target * 2) break
|
|
1188
|
+
}
|
|
1189
|
+
let out = chosen ?? []
|
|
1190
|
+
// Even the coarsest unit can be too dense (a decade of yearly points), and a
|
|
1191
|
+
// single trading day yields one boundary. Thin, or fall back to plain strides.
|
|
1192
|
+
if (out.length > target) {
|
|
1193
|
+
const stride = Math.ceil(out.length / target)
|
|
1194
|
+
out = out.filter((_, i) => i % stride === 0)
|
|
1195
|
+
}
|
|
1196
|
+
if (out.length < 2) {
|
|
1197
|
+
const stride = Math.max(1, Math.ceil(times.length / target))
|
|
1198
|
+
out = times.map((_, i) => i).filter((i) => i % stride === 0)
|
|
1199
|
+
}
|
|
1200
|
+
return out
|
|
1201
|
+
}
|
|
1202
|
+
|
|
796
1203
|
function fmtDate(t: number, span: number): string {
|
|
797
1204
|
const d = new Date(t)
|
|
798
1205
|
if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
@@ -802,10 +1209,26 @@ function fmtDate(t: number, span: number): string {
|
|
|
802
1209
|
|
|
803
1210
|
type ResolvedSeries = ChartSeries & {
|
|
804
1211
|
color: string
|
|
805
|
-
kind: 'bar' | 'line' | 'area'
|
|
1212
|
+
kind: 'bar' | 'line' | 'area' | 'candle' | 'box'
|
|
806
1213
|
axis: 'left' | 'right'
|
|
807
1214
|
}
|
|
808
1215
|
|
|
1216
|
+
/**
|
|
1217
|
+
* Which mark a series draws.
|
|
1218
|
+
*
|
|
1219
|
+
* A series counts as a candle when it says so OR when it carries `ohlc` data,
|
|
1220
|
+
* so a spec typed `'candlestick'` can still hold a plain volume bar series
|
|
1221
|
+
* beside the prices. Boxes work the same way. Anything unrecognised falls back
|
|
1222
|
+
* to `'bar'`, which is what pie and scatter have always relied on.
|
|
1223
|
+
*/
|
|
1224
|
+
function kindOf(s: ChartSeries, specType: ChartType): ResolvedSeries['kind'] {
|
|
1225
|
+
const t = s.type ?? specType
|
|
1226
|
+
if (t === 'candlestick' || t === 'ohlc' || s.ohlc) return 'candle'
|
|
1227
|
+
if (t === 'boxplot' || s.boxes) return 'box'
|
|
1228
|
+
if (t === 'line' || t === 'area') return t
|
|
1229
|
+
return 'bar'
|
|
1230
|
+
}
|
|
1231
|
+
|
|
809
1232
|
/** Data domain for one axis, honoring stacking of its bar/area series.
|
|
810
1233
|
* When `isLog` is true, non-positive values are discarded (log undefined)
|
|
811
1234
|
* and the domain is rounded to decade boundaries instead of nice steps. */
|
|
@@ -843,6 +1266,43 @@ function axisDomain(
|
|
|
843
1266
|
for (const s of stackable) for (const v of s.values) note(v)
|
|
844
1267
|
}
|
|
845
1268
|
for (const s of lines) for (const v of s.values) note(v)
|
|
1269
|
+
// Candles: note the HIGH and the LOW, not `values` (the closes), or every
|
|
1270
|
+
// wick clips at the body. Note also that candles are deliberately absent
|
|
1271
|
+
// from `stackable`, so the zero-baseline rule below does not fire for them:
|
|
1272
|
+
// a price series running 180 to 195 keeps a readable domain instead of being
|
|
1273
|
+
// flattened against zero.
|
|
1274
|
+
for (const s of list) {
|
|
1275
|
+
if (s.kind !== 'candle') continue
|
|
1276
|
+
for (const k of s.ohlc ?? []) {
|
|
1277
|
+
if (!k) continue
|
|
1278
|
+
note(k.h)
|
|
1279
|
+
note(k.l)
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
// Boxes: the whisker ends and any outlier, for the same reason - `values`
|
|
1283
|
+
// holds the medians, so a domain built from those alone would clip half of
|
|
1284
|
+
// every box. Boxes are also absent from `stackable`, so a sample that never
|
|
1285
|
+
// goes near zero keeps a readable domain.
|
|
1286
|
+
for (const s of list) {
|
|
1287
|
+
if (s.kind !== 'box') continue
|
|
1288
|
+
for (const b of s.boxes ?? []) {
|
|
1289
|
+
if (!b) continue
|
|
1290
|
+
note(b.min)
|
|
1291
|
+
note(b.max)
|
|
1292
|
+
for (const o of b.outliers ?? []) note(o)
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// Error bars extend past their own mark, so a whisker that leaves the plot is
|
|
1296
|
+
// the same defect as a clipped candle wick.
|
|
1297
|
+
for (const s of list) {
|
|
1298
|
+
if (!s.errors) continue
|
|
1299
|
+
s.errors.forEach((e, i) => {
|
|
1300
|
+
const span = errorSpan(e, s.values[i] ?? 0)
|
|
1301
|
+
if (!span) return
|
|
1302
|
+
note(span.lo)
|
|
1303
|
+
note(span.hi)
|
|
1304
|
+
})
|
|
1305
|
+
}
|
|
846
1306
|
if (dMin === Infinity) {
|
|
847
1307
|
dMin = isLog ? 1 : 0
|
|
848
1308
|
dMax = isLog ? 10 : 1
|
|
@@ -870,10 +1330,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
870
1330
|
const series: ResolvedSeries[] = spec.series.map((s, i) => ({
|
|
871
1331
|
...s,
|
|
872
1332
|
color: s.color ?? palette[i % palette.length]!,
|
|
873
|
-
kind: (s
|
|
874
|
-
| 'bar'
|
|
875
|
-
| 'line'
|
|
876
|
-
| 'area',
|
|
1333
|
+
kind: kindOf(s, spec.type),
|
|
877
1334
|
axis: s.axis ?? 'left',
|
|
878
1335
|
}))
|
|
879
1336
|
const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
|
|
@@ -883,7 +1340,11 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
883
1340
|
width,
|
|
884
1341
|
height,
|
|
885
1342
|
plot: { x: 0, y: 0, w: width, h: height },
|
|
1343
|
+
axes: null,
|
|
886
1344
|
bars: [],
|
|
1345
|
+
candles: [],
|
|
1346
|
+
boxes: [],
|
|
1347
|
+
errorBars: [],
|
|
887
1348
|
lines: [],
|
|
888
1349
|
slices: [],
|
|
889
1350
|
yTicks: [],
|
|
@@ -1003,7 +1464,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1003
1464
|
x: round(padL + slotW * i + slotW / 2),
|
|
1004
1465
|
}))
|
|
1005
1466
|
const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
|
|
1006
|
-
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat),
|
|
1467
|
+
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
|
|
1007
1468
|
}))
|
|
1008
1469
|
return {
|
|
1009
1470
|
...empty,
|
|
@@ -1190,7 +1651,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1190
1651
|
const legend = Array.from({ length: 5 }, (_, i) => {
|
|
1191
1652
|
const t = i / 4
|
|
1192
1653
|
const value = vMin + (vMax - vMin) * t
|
|
1193
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
|
|
1654
|
+
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
|
|
1194
1655
|
})
|
|
1195
1656
|
return {
|
|
1196
1657
|
...empty,
|
|
@@ -1226,8 +1687,13 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1226
1687
|
}
|
|
1227
1688
|
const trackPath = arc(A0, A1, r)
|
|
1228
1689
|
const valuePath = arc(A0, angleAt(value), r)
|
|
1690
|
+
// Bands sit on their own inner ring, well clear of the value arc's 16px
|
|
1691
|
+
// stroke at `r`. They are context, not the reading: a band covering most
|
|
1692
|
+
// of the scale (an error-rate dial where anything above 0.45 is red) used
|
|
1693
|
+
// to out-shout the value arc completely, so the dial looked pegged at
|
|
1694
|
+
// maximum when the actual value was 9 percent.
|
|
1229
1695
|
const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
|
|
1230
|
-
path: arc(angleAt(band.from), angleAt(band.to), r -
|
|
1696
|
+
path: arc(angleAt(band.from), angleAt(band.to), r - 16),
|
|
1231
1697
|
color: band.color, from: band.from, to: band.to,
|
|
1232
1698
|
}))
|
|
1233
1699
|
let targetPx: ChartGaugeLayout['target'] = null
|
|
@@ -1257,23 +1723,42 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1257
1723
|
}
|
|
1258
1724
|
// Pointer needle: a kite (long tip toward the value, short counterweight
|
|
1259
1725
|
// tail) pivoting on a center hub.
|
|
1726
|
+
//
|
|
1727
|
+
// The proportions matter more than they look, and the counterweight was the
|
|
1728
|
+
// whole problem. A tail sticking out past the hub is a short, solid triangle;
|
|
1729
|
+
// the pointer is a 130px taper that thins to nothing. The compact shape wins
|
|
1730
|
+
// the eye, so on a near-full dial the needle read as an arrow pointing at the
|
|
1731
|
+
// MINIMUM. Keeping the tail inside the hub radius removes the competing
|
|
1732
|
+
// point without giving up the pivot, and the shoulders sit outside the hub so
|
|
1733
|
+
// the needle actually has a visible base to taper from.
|
|
1260
1734
|
const aV = angleAt(value)
|
|
1261
|
-
const tipR = r -
|
|
1735
|
+
const tipR = r - 14, tailR = 5, baseR = 9
|
|
1262
1736
|
const aPerp = aV + Math.PI / 2
|
|
1263
1737
|
const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
|
|
1264
1738
|
const needlePath =
|
|
1265
1739
|
`M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
|
|
1266
1740
|
// Color the value arc by the band the value currently sits in.
|
|
1741
|
+
// First match wins, and bands are half-open [from, to). Bands normally
|
|
1742
|
+
// share endpoints - green 0..0.3, amber 0.3..0.45, red 0.45..5 - and with
|
|
1743
|
+
// an inclusive `to` plus last-match-wins, a value sitting exactly ON a
|
|
1744
|
+
// boundary took the colour of the band ABOVE it. An error rate of 0.45
|
|
1745
|
+
// against a 0.45 amber ceiling read as red.
|
|
1267
1746
|
let valueColor: string | null = null
|
|
1268
|
-
|
|
1269
|
-
|
|
1747
|
+
const bands = spec.gaugeRanges ?? []
|
|
1748
|
+
for (const band of bands) {
|
|
1749
|
+
if (value >= band.from && value < band.to) { valueColor = band.color; break }
|
|
1750
|
+
}
|
|
1751
|
+
// The very top of the scale belongs to the last band that reaches it,
|
|
1752
|
+
// which the half-open test above would otherwise exclude.
|
|
1753
|
+
if (valueColor == null) {
|
|
1754
|
+
for (const band of bands) if (value >= band.from && value <= band.to) valueColor = band.color
|
|
1270
1755
|
}
|
|
1271
1756
|
return {
|
|
1272
1757
|
...empty,
|
|
1273
1758
|
plot: { x: 0, y: 0, w: width, h: height },
|
|
1274
1759
|
gauge: {
|
|
1275
1760
|
cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
|
|
1276
|
-
ticks, needle: { path: needlePath, hubR:
|
|
1761
|
+
ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
|
|
1277
1762
|
minLabel: { x: cx - r, y: cy + 20 },
|
|
1278
1763
|
maxLabel: { x: cx + r, y: cy + 20 },
|
|
1279
1764
|
value, min, max, unit: spec.gaugeUnit ?? '',
|
|
@@ -1544,7 +2029,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1544
2029
|
const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
|
|
1545
2030
|
const t = i / 4
|
|
1546
2031
|
const value = vMin + (vMax - vMin) * t
|
|
1547
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
|
|
2032
|
+
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
|
|
1548
2033
|
})
|
|
1549
2034
|
return {
|
|
1550
2035
|
...empty,
|
|
@@ -1665,7 +2150,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1665
2150
|
}
|
|
1666
2151
|
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
1667
2152
|
y: yOf(ref.value),
|
|
1668
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
2153
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
1669
2154
|
color: ref.color ?? '#ef4444',
|
|
1670
2155
|
dashed: ref.dashed !== false,
|
|
1671
2156
|
}))
|
|
@@ -1674,7 +2159,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1674
2159
|
plot,
|
|
1675
2160
|
scatterPoints,
|
|
1676
2161
|
referenceLines,
|
|
1677
|
-
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat) })),
|
|
2162
|
+
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat, spec) })),
|
|
1678
2163
|
xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
|
|
1679
2164
|
}
|
|
1680
2165
|
}
|
|
@@ -1761,7 +2246,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1761
2246
|
}
|
|
1762
2247
|
|
|
1763
2248
|
const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
|
|
1764
|
-
label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat),
|
|
2249
|
+
label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat, spec),
|
|
1765
2250
|
x: xOf(value),
|
|
1766
2251
|
}))
|
|
1767
2252
|
const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
|
|
@@ -1771,7 +2256,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1771
2256
|
}))
|
|
1772
2257
|
const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
1773
2258
|
x: xOf(ref.value),
|
|
1774
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
2259
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
1775
2260
|
color: ref.color ?? '#ef4444',
|
|
1776
2261
|
dashed: ref.dashed !== false,
|
|
1777
2262
|
}))
|
|
@@ -1843,21 +2328,31 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1843
2328
|
|
|
1844
2329
|
// X positions. A time axis spaces points by actual time (irregular gaps);
|
|
1845
2330
|
// a category axis is uniform. (Bars stay uniform either way.)
|
|
1846
|
-
|
|
1847
|
-
|
|
2331
|
+
// Two date modes, and the difference is only where the marks go: `'time'`
|
|
2332
|
+
// positions by the timestamp, `'ordinal-time'` positions by the index and
|
|
2333
|
+
// uses the dates for labels alone. Both parse; only one scales.
|
|
2334
|
+
const isDateAxis = spec.xType === 'time' || spec.xType === 'ordinal-time'
|
|
2335
|
+
const timeVals = isDateAxis ? spec.categories.map((c) => Date.parse(c)) : null
|
|
1848
2336
|
const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
|
|
2337
|
+
const timeScaled = timeOk && spec.xType === 'time'
|
|
1849
2338
|
const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
|
|
1850
2339
|
const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
|
|
1851
2340
|
const xCenter = (i: number) =>
|
|
1852
|
-
|
|
2341
|
+
timeScaled && Number.isFinite(timeVals![i])
|
|
1853
2342
|
? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
|
|
1854
2343
|
: round(padL + slot * i + slot / 2)
|
|
1855
|
-
const xTicks: ChartCategoryTick[] =
|
|
2344
|
+
const xTicks: ChartCategoryTick[] = timeScaled
|
|
1856
2345
|
? dateTicks(tMin, tMin + tSpan).map((t) => ({
|
|
1857
2346
|
label: fmtDate(t, tSpan),
|
|
1858
2347
|
x: round(padL + ((t - tMin) / tSpan) * plotW),
|
|
1859
2348
|
}))
|
|
1860
|
-
:
|
|
2349
|
+
: timeOk
|
|
2350
|
+
? // Ordinal: ticks land on points that exist, labelled from their dates.
|
|
2351
|
+
ordinalDateTicks(timeVals!).map((i) => ({
|
|
2352
|
+
label: fmtDate(timeVals![i]!, tSpan),
|
|
2353
|
+
x: xCenter(i),
|
|
2354
|
+
}))
|
|
2355
|
+
: thinCategoryTicks(spec.categories, xCenter, slot, xLabelRotated)
|
|
1861
2356
|
|
|
1862
2357
|
// Parent-tier ticks for a grouped category axis: each spans its leaves.
|
|
1863
2358
|
const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
|
|
@@ -1952,6 +2447,128 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1952
2447
|
}
|
|
1953
2448
|
}
|
|
1954
2449
|
|
|
2450
|
+
// Candlesticks / OHLC bars. Laid out once; the two marks differ only in how
|
|
2451
|
+
// the renderer paints them, so there is no second geometry pass.
|
|
2452
|
+
const candleSeries = series.filter((s) => s.kind === 'candle')
|
|
2453
|
+
const candles: ChartCandle[] = []
|
|
2454
|
+
if (candleSeries.length) {
|
|
2455
|
+
const upColor = spec.candleColors?.up ?? '#16a34a'
|
|
2456
|
+
const downColor = spec.candleColors?.down ?? '#ef4444'
|
|
2457
|
+
// Share the slot when two instruments are charted together, the same way
|
|
2458
|
+
// grouped bars do.
|
|
2459
|
+
const bodyW = Math.max(1, (slot * 0.7) / candleSeries.length)
|
|
2460
|
+
candleSeries.forEach((s, si) => {
|
|
2461
|
+
const dom = domOf(s)
|
|
2462
|
+
const log = isLogOf(s)
|
|
2463
|
+
;(s.ohlc ?? []).forEach((k, i) => {
|
|
2464
|
+
if (!k) return
|
|
2465
|
+
if (![k.o, k.h, k.l, k.c].every(Number.isFinite)) return
|
|
2466
|
+
// A log price axis is genuinely used for long histories, and a
|
|
2467
|
+
// non-positive price has no place on one.
|
|
2468
|
+
if (log && (k.o <= 0 || k.h <= 0 || k.l <= 0 || k.c <= 0)) return
|
|
2469
|
+
const centre = padL + slot * i + slot / 2
|
|
2470
|
+
const x = centre - (bodyW * candleSeries.length) / 2 + bodyW * si
|
|
2471
|
+
const yOpen = yOf(dom, k.o, log)
|
|
2472
|
+
const yClose = yOf(dom, k.c, log)
|
|
2473
|
+
const up = k.c >= k.o
|
|
2474
|
+
candles.push({
|
|
2475
|
+
x: round(x),
|
|
2476
|
+
w: round(bodyW),
|
|
2477
|
+
xCenter: round(x + bodyW / 2),
|
|
2478
|
+
yOpen,
|
|
2479
|
+
yClose,
|
|
2480
|
+
yHigh: yOf(dom, k.h, log),
|
|
2481
|
+
yLow: yOf(dom, k.l, log),
|
|
2482
|
+
bodyY: Math.min(yOpen, yClose),
|
|
2483
|
+
// A doji closes where it opened; keep it visible as a 1px line
|
|
2484
|
+
// rather than a zero-height rect that paints nothing.
|
|
2485
|
+
bodyH: Math.max(1, Math.abs(yClose - yOpen)),
|
|
2486
|
+
up,
|
|
2487
|
+
color: up ? upColor : downColor,
|
|
2488
|
+
label: spec.categories[i] ?? String(i),
|
|
2489
|
+
series: s.label,
|
|
2490
|
+
o: k.o,
|
|
2491
|
+
h: k.h,
|
|
2492
|
+
l: k.l,
|
|
2493
|
+
c: k.c,
|
|
2494
|
+
})
|
|
2495
|
+
})
|
|
2496
|
+
})
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// Box plots. Same slot-sharing as grouped bars and candles, so several
|
|
2500
|
+
// samples can sit side by side under one category.
|
|
2501
|
+
const boxSeries = series.filter((s) => s.kind === 'box')
|
|
2502
|
+
const boxes: ChartBox[] = []
|
|
2503
|
+
if (boxSeries.length) {
|
|
2504
|
+
const boxW = Math.max(1, (slot * 0.6) / boxSeries.length)
|
|
2505
|
+
boxSeries.forEach((s, si) => {
|
|
2506
|
+
const dom = domOf(s)
|
|
2507
|
+
const log = isLogOf(s)
|
|
2508
|
+
;(s.boxes ?? []).forEach((b, i) => {
|
|
2509
|
+
if (!b) return
|
|
2510
|
+
if (![b.min, b.q1, b.median, b.q3, b.max].every(Number.isFinite)) return
|
|
2511
|
+
if (log && b.min <= 0) return
|
|
2512
|
+
const centre = padL + slot * i + slot / 2
|
|
2513
|
+
const x = centre - (boxW * boxSeries.length) / 2 + boxW * si
|
|
2514
|
+
const yQ1 = yOf(dom, b.q1, log)
|
|
2515
|
+
const yQ3 = yOf(dom, b.q3, log)
|
|
2516
|
+
boxes.push({
|
|
2517
|
+
x: round(x),
|
|
2518
|
+
w: round(boxW),
|
|
2519
|
+
xCenter: round(x + boxW / 2),
|
|
2520
|
+
yMin: yOf(dom, b.min, log),
|
|
2521
|
+
yQ1,
|
|
2522
|
+
yMedian: yOf(dom, b.median, log),
|
|
2523
|
+
yQ3,
|
|
2524
|
+
yMax: yOf(dom, b.max, log),
|
|
2525
|
+
boxY: Math.min(yQ1, yQ3),
|
|
2526
|
+
// A sample with no spread would otherwise paint nothing at all.
|
|
2527
|
+
boxH: Math.max(1, Math.abs(yQ1 - yQ3)),
|
|
2528
|
+
outliers: (b.outliers ?? [])
|
|
2529
|
+
.filter((o) => Number.isFinite(o) && (!log || o > 0))
|
|
2530
|
+
.map((o) => ({ y: yOf(dom, o, log), value: o })),
|
|
2531
|
+
color: s.color,
|
|
2532
|
+
label: spec.categories[i] ?? String(i),
|
|
2533
|
+
series: s.label,
|
|
2534
|
+
min: b.min,
|
|
2535
|
+
q1: b.q1,
|
|
2536
|
+
median: b.median,
|
|
2537
|
+
q3: b.q3,
|
|
2538
|
+
max: b.max,
|
|
2539
|
+
})
|
|
2540
|
+
})
|
|
2541
|
+
})
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Error bars. Not a mark of their own: they annotate whatever the series
|
|
2545
|
+
// already draws, so this runs over every series carrying `errors` regardless
|
|
2546
|
+
// of kind, and the geometry sits in its own array so no existing loop changes.
|
|
2547
|
+
const errorBars: ChartErrorBar[] = []
|
|
2548
|
+
for (const s of series) {
|
|
2549
|
+
if (!s.errors) continue
|
|
2550
|
+
const dom = domOf(s)
|
|
2551
|
+
const log = isLogOf(s)
|
|
2552
|
+
s.errors.forEach((e, i) => {
|
|
2553
|
+
const v = s.values[i]
|
|
2554
|
+
if (!Number.isFinite(v)) return
|
|
2555
|
+
const span = errorSpan(e, v!)
|
|
2556
|
+
if (!span) return
|
|
2557
|
+
if (log && span.lo <= 0) return
|
|
2558
|
+
errorBars.push({
|
|
2559
|
+
xCenter: round(padL + slot * i + slot / 2),
|
|
2560
|
+
yLo: yOf(dom, span.lo, log),
|
|
2561
|
+
yHi: yOf(dom, span.hi, log),
|
|
2562
|
+
cap: round(Math.min(6, slot * 0.15)),
|
|
2563
|
+
color: s.color,
|
|
2564
|
+
label: spec.categories[i] ?? String(i),
|
|
2565
|
+
series: s.label,
|
|
2566
|
+
lo: span.lo,
|
|
2567
|
+
hi: span.hi,
|
|
2568
|
+
})
|
|
2569
|
+
})
|
|
2570
|
+
}
|
|
2571
|
+
|
|
1955
2572
|
// Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
|
|
1956
2573
|
const lines: ChartLine[] = []
|
|
1957
2574
|
const areaCum: Record<'left' | 'right', number[]> = {
|
|
@@ -1975,7 +2592,12 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1975
2592
|
}
|
|
1976
2593
|
}
|
|
1977
2594
|
for (const s of series) {
|
|
1978
|
-
|
|
2595
|
+
// Bars, candles and boxes draw their own marks. Candles and boxes
|
|
2596
|
+
// especially: `values` holds their closes / medians so that tooltips, CSV
|
|
2597
|
+
// and overlays work, and without this guard that same array was ALSO drawn
|
|
2598
|
+
// as a line, laying a dotted close-line straight over every candle. Boxes
|
|
2599
|
+
// would do exactly the same thing through the median.
|
|
2600
|
+
if (s.kind === 'bar' || s.kind === 'candle' || s.kind === 'box') continue
|
|
1979
2601
|
const dom = domOf(s)
|
|
1980
2602
|
const log = isLogOf(s)
|
|
1981
2603
|
const yA = (v: number) => yOf(dom, v, log)
|
|
@@ -2072,8 +2694,17 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
2072
2694
|
lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath })
|
|
2073
2695
|
}
|
|
2074
2696
|
|
|
2697
|
+
// Under `stacked100` the axis is a share of the total, not the measure, so
|
|
2698
|
+
// it is labelled as a percentage whatever `valueFormat` says. Formatting it
|
|
2699
|
+
// as currency gives an axis reading "$0 .. $100" for what are percentages -
|
|
2700
|
+
// which is what it did, unnoticed, while `stacked100` was reachable only
|
|
2701
|
+
// from config.
|
|
2075
2702
|
const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
|
|
2076
|
-
dom.ticks.map((value) => ({
|
|
2703
|
+
dom.ticks.map((value) => ({
|
|
2704
|
+
value,
|
|
2705
|
+
y: yOf(dom, value, log),
|
|
2706
|
+
label: spec.stacked100 ? `${round(value)}%` : formatChartValue(value, spec.valueFormat, spec),
|
|
2707
|
+
}))
|
|
2077
2708
|
|
|
2078
2709
|
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
|
|
2079
2710
|
const onRight = ref.axis === 'right'
|
|
@@ -2081,7 +2712,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
2081
2712
|
const log = onRight ? rightLog : leftLog
|
|
2082
2713
|
return {
|
|
2083
2714
|
y: yOf(dom, ref.value, log),
|
|
2084
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
2715
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
2085
2716
|
color: ref.color ?? '#ef4444',
|
|
2086
2717
|
dashed: ref.dashed !== false,
|
|
2087
2718
|
}
|
|
@@ -2156,7 +2787,20 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
2156
2787
|
return {
|
|
2157
2788
|
...empty,
|
|
2158
2789
|
plot,
|
|
2790
|
+
// What a caller needs to put its own marks in this chart's coordinates.
|
|
2791
|
+
// Reported rather than recomputed, so a custom mark lands on exactly the
|
|
2792
|
+
// scale the built-in ones did - including the nice-scale rounding and the
|
|
2793
|
+
// "always include zero" rule, which are impossible to guess from outside.
|
|
2794
|
+
axes: {
|
|
2795
|
+
y: { min: leftDom.min, max: leftDom.max, log: leftLog },
|
|
2796
|
+
y2: rightDom ? { min: rightDom.min, max: rightDom.max, log: rightLog } : null,
|
|
2797
|
+
slot,
|
|
2798
|
+
count: spec.categories.length,
|
|
2799
|
+
},
|
|
2159
2800
|
bars,
|
|
2801
|
+
candles,
|
|
2802
|
+
boxes,
|
|
2803
|
+
errorBars,
|
|
2160
2804
|
lines,
|
|
2161
2805
|
yTicks: tickFor(leftDom, leftLog),
|
|
2162
2806
|
y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
|
|
@@ -2170,6 +2814,53 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
2170
2814
|
}
|
|
2171
2815
|
}
|
|
2172
2816
|
|
|
2817
|
+
/**
|
|
2818
|
+
* Narrow a spec to the category window `[lo, hi]`, keeping every
|
|
2819
|
+
* category-parallel array in step.
|
|
2820
|
+
*
|
|
2821
|
+
* This is the zoom / brush slice. It lives here rather than in the renderer
|
|
2822
|
+
* because getting it wrong is a MODEL bug, not a paint bug, and it was wrong:
|
|
2823
|
+
* the component used to slice `categories`, `values` and `rowIds` by hand and
|
|
2824
|
+
* spread the rest of the series through untouched. `upperValues` and
|
|
2825
|
+
* `lowerValues` therefore kept their full length, the equality guard on the
|
|
2826
|
+
* confidence band (see `buildChart`) stopped matching, and the band silently
|
|
2827
|
+
* disappeared the moment anyone zoomed.
|
|
2828
|
+
*
|
|
2829
|
+
* The lesson generalises: every array here is indexed by category, so each one
|
|
2830
|
+
* added in future has to be sliced too. Keeping them in one function is what
|
|
2831
|
+
* makes that a single place to remember rather than a scattered convention.
|
|
2832
|
+
*/
|
|
2833
|
+
export function sliceChartWindow(spec: ChartSpec, lo: number, hi: number): ChartSpec {
|
|
2834
|
+
const from = Math.max(0, lo)
|
|
2835
|
+
const to = Math.min(spec.categories.length - 1, hi)
|
|
2836
|
+
const cut = <T,>(arr: T[] | undefined): T[] | undefined =>
|
|
2837
|
+
arr ? arr.slice(from, to + 1) : undefined
|
|
2838
|
+
return {
|
|
2839
|
+
...spec,
|
|
2840
|
+
categories: spec.categories.slice(from, to + 1),
|
|
2841
|
+
// EVERY per-category array on a series has to be cut here, not just the
|
|
2842
|
+
// ones that existed when this function was written. A missed one does not
|
|
2843
|
+
// throw: the geometry keeps indexing the full-length array against the
|
|
2844
|
+
// sliced categories, so marks land at the wrong x or off the plot entirely.
|
|
2845
|
+
// `upperValues` / `lowerValues` were missed once and silently dropped the
|
|
2846
|
+
// confidence band on zoom; `ohlc` was missed the same way and drew a
|
|
2847
|
+
// zoomed candlestick chart against the wrong categories.
|
|
2848
|
+
series: spec.series.map((s) => ({
|
|
2849
|
+
...s,
|
|
2850
|
+
values: s.values.slice(from, to + 1),
|
|
2851
|
+
rowIds: cut(s.rowIds),
|
|
2852
|
+
upperValues: cut(s.upperValues),
|
|
2853
|
+
lowerValues: cut(s.lowerValues),
|
|
2854
|
+
ohlc: cut(s.ohlc),
|
|
2855
|
+
boxes: cut(s.boxes),
|
|
2856
|
+
errors: cut(s.errors),
|
|
2857
|
+
})),
|
|
2858
|
+
// Per-category, so it has to travel with the window or the waterfall's
|
|
2859
|
+
// running total resets on the wrong bars.
|
|
2860
|
+
waterfallTotals: cut(spec.waterfallTotals),
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2173
2864
|
/**
|
|
2174
2865
|
* Aggregate flat rows into a chart spec. Group by a category field, reduce a
|
|
2175
2866
|
* value field per group. Three multi-series shapes:
|
|
@@ -2319,3 +3010,333 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
|
2319
3010
|
palette: opts.palette,
|
|
2320
3011
|
}
|
|
2321
3012
|
}
|
|
3013
|
+
|
|
3014
|
+
// ---------------------------------------------------------------------------
|
|
3015
|
+
// Shape adapters.
|
|
3016
|
+
//
|
|
3017
|
+
// Nine of the thirteen chart types were unreachable from the chart panel
|
|
3018
|
+
// because they do not read a `categories x series` grid: a treemap wants a
|
|
3019
|
+
// hierarchy, a sankey wants an edge list, a gauge wants one number. Rather
|
|
3020
|
+
// than give each its own aggregation path, these take the spec
|
|
3021
|
+
// `rowsToChartSpec` already produced and reshape it, so grouping, `reduce`,
|
|
3022
|
+
// `sort`, `topN` and the "Other" bucket keep working for all of them.
|
|
3023
|
+
// ---------------------------------------------------------------------------
|
|
3024
|
+
|
|
3025
|
+
/**
|
|
3026
|
+
* Reshape an aggregated spec into a tree-map hierarchy.
|
|
3027
|
+
*
|
|
3028
|
+
* One series gives a flat set of leaves. Several (a split-by) give two levels,
|
|
3029
|
+
* category above series, which is the shape people expect from "sales by
|
|
3030
|
+
* region, split by channel".
|
|
3031
|
+
*/
|
|
3032
|
+
export function specToTreemap(spec: ChartSpec, rootName = 'Total'): TreeNode {
|
|
3033
|
+
const positive = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
|
|
3034
|
+
if (spec.series.length <= 1) {
|
|
3035
|
+
const s = spec.series[0]
|
|
3036
|
+
return {
|
|
3037
|
+
name: rootName,
|
|
3038
|
+
children: spec.categories
|
|
3039
|
+
.map((name, i) => ({ name, value: positive(s?.values[i]) }))
|
|
3040
|
+
.filter((n) => n.value > 0),
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
return {
|
|
3044
|
+
name: rootName,
|
|
3045
|
+
children: spec.categories
|
|
3046
|
+
.map((name, i) => ({
|
|
3047
|
+
name,
|
|
3048
|
+
children: spec.series
|
|
3049
|
+
.map((s) => ({ name: s.label, value: positive(s.values[i]) }))
|
|
3050
|
+
.filter((n) => n.value > 0),
|
|
3051
|
+
}))
|
|
3052
|
+
.filter((n) => n.children.length > 0),
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
/**
|
|
3057
|
+
* Reshape an aggregated spec into calendar samples.
|
|
3058
|
+
*
|
|
3059
|
+
* Categories that do not parse as a date are dropped rather than rendered at
|
|
3060
|
+
* epoch zero, which would put a stray cell in 1970 and rescale the whole year.
|
|
3061
|
+
*/
|
|
3062
|
+
export function specToCalendar(spec: ChartSpec): Array<{ date: string; value: number }> {
|
|
3063
|
+
const s = spec.series[0]
|
|
3064
|
+
const out: Array<{ date: string; value: number }> = []
|
|
3065
|
+
spec.categories.forEach((c, i) => {
|
|
3066
|
+
const t = Date.parse(c)
|
|
3067
|
+
if (!Number.isFinite(t)) return
|
|
3068
|
+
const v = s?.values[i]
|
|
3069
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return
|
|
3070
|
+
out.push({ date: new Date(t).toISOString().slice(0, 10), value: v })
|
|
3071
|
+
})
|
|
3072
|
+
return out
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
/**
|
|
3076
|
+
* Reshape a pivoted spec into sankey nodes and links.
|
|
3077
|
+
*
|
|
3078
|
+
* The pivot `rowsToChartSpec` already performs is exactly an edge list read
|
|
3079
|
+
* sideways: categories are sources, series are targets, and each cell is the
|
|
3080
|
+
* flow between them. Zero cells and self-edges are dropped, the first because
|
|
3081
|
+
* a zero-width ribbon is not a flow and the second because the layout has no
|
|
3082
|
+
* meaningful place to put one.
|
|
3083
|
+
*/
|
|
3084
|
+
export function specToSankey(spec: ChartSpec): {
|
|
3085
|
+
nodes: Array<{ id: string; label?: string }>
|
|
3086
|
+
links: Array<{ source: string; target: string; value: number }>
|
|
3087
|
+
} {
|
|
3088
|
+
const links: Array<{ source: string; target: string; value: number }> = []
|
|
3089
|
+
const ids = new Set<string>()
|
|
3090
|
+
spec.categories.forEach((from, i) => {
|
|
3091
|
+
for (const s of spec.series) {
|
|
3092
|
+
const v = s.values[i]
|
|
3093
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) continue
|
|
3094
|
+
// Sources and targets share a namespace, so a value appearing on both
|
|
3095
|
+
// sides would otherwise become one node with a cycle through it.
|
|
3096
|
+
const source = `from:${from}`
|
|
3097
|
+
const target = `to:${s.label}`
|
|
3098
|
+
if (source === target) continue
|
|
3099
|
+
ids.add(source)
|
|
3100
|
+
ids.add(target)
|
|
3101
|
+
links.push({ source, target, value: v })
|
|
3102
|
+
}
|
|
3103
|
+
})
|
|
3104
|
+
return {
|
|
3105
|
+
nodes: [...ids].map((id) => ({ id, label: id.slice(id.indexOf(':') + 1) })),
|
|
3106
|
+
links,
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
/**
|
|
3111
|
+
* The chart types that read ROWS directly instead of a grouped grid, behind one
|
|
3112
|
+
* call. Returns `null` for every other type, which then goes through
|
|
3113
|
+
* `rowsToChartSpec` and its reduce / sort / topN / "Other" pipeline.
|
|
3114
|
+
*
|
|
3115
|
+
* One entry point rather than a branch per type in the caller, because the
|
|
3116
|
+
* caller is the grid controller and the controller is in the BASE bundle: every
|
|
3117
|
+
* type named there is bytes paid by grids that never chart. Here it is in the
|
|
3118
|
+
* lazy chart chunk, next to the builders it dispatches to, and adding a fourth
|
|
3119
|
+
* direct type costs a grid nothing.
|
|
3120
|
+
*/
|
|
3121
|
+
export function rowsToDirectSpec<T extends Record<string, unknown>>(
|
|
3122
|
+
type: ChartType,
|
|
3123
|
+
rows: ReadonlyArray<T>,
|
|
3124
|
+
opts: {
|
|
3125
|
+
category?: string
|
|
3126
|
+
/** The measure. For scatter this is X. */
|
|
3127
|
+
value?: string
|
|
3128
|
+
/** Scatter's Y measure. */
|
|
3129
|
+
value2?: string
|
|
3130
|
+
series?: string
|
|
3131
|
+
reduce?: 'sum' | 'avg' | 'count'
|
|
3132
|
+
palette?: string[]
|
|
3133
|
+
},
|
|
3134
|
+
): ChartSpec | null {
|
|
3135
|
+
const cat = opts.category as (keyof T & string) | undefined
|
|
3136
|
+
const val = opts.value as (keyof T & string) | undefined
|
|
3137
|
+
const ser = opts.series as (keyof T & string) | undefined
|
|
3138
|
+
if (type === 'scatter') {
|
|
3139
|
+
const y = opts.value2 as (keyof T & string) | undefined
|
|
3140
|
+
if (!val || !y) return null
|
|
3141
|
+
return rowsToScatterSpec(rows, {
|
|
3142
|
+
x: val,
|
|
3143
|
+
y,
|
|
3144
|
+
...(ser ? { series: ser } : {}),
|
|
3145
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
3146
|
+
})
|
|
3147
|
+
}
|
|
3148
|
+
if (type === 'gauge') {
|
|
3149
|
+
if (!val) return null
|
|
3150
|
+
return rowsToGaugeSpec(rows, { value: val, ...(opts.reduce ? { reduce: opts.reduce } : {}) })
|
|
3151
|
+
}
|
|
3152
|
+
if (type === 'boxplot') {
|
|
3153
|
+
if (!cat || !val) return null
|
|
3154
|
+
const spec = rowsToBoxSpec(rows, {
|
|
3155
|
+
category: cat,
|
|
3156
|
+
value: val,
|
|
3157
|
+
...(ser ? { series: ser } : {}),
|
|
3158
|
+
})
|
|
3159
|
+
if (opts.palette) spec.palette = opts.palette
|
|
3160
|
+
return spec
|
|
3161
|
+
}
|
|
3162
|
+
return null
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
/**
|
|
3166
|
+
* Build a box plot spec straight from rows: group by a category, then reduce
|
|
3167
|
+
* each group to a five-number summary.
|
|
3168
|
+
*
|
|
3169
|
+
* This is the one aggregate the panel's `sum | avg | count` cannot express, and
|
|
3170
|
+
* that is the point of it. Every other chart answers "how much"; a box plot
|
|
3171
|
+
* answers "how spread out", which needs the whole sample per group rather than
|
|
3172
|
+
* one number, so it cannot go through `rowsToChartSpec`.
|
|
3173
|
+
*
|
|
3174
|
+
* `values` comes out as the medians, so tooltips, CSV and overlays work with no
|
|
3175
|
+
* box-specific code - the same contract `ohlc` follows.
|
|
3176
|
+
*/
|
|
3177
|
+
export function rowsToBoxSpec<T extends Record<string, unknown>>(
|
|
3178
|
+
rows: ReadonlyArray<T>,
|
|
3179
|
+
opts: {
|
|
3180
|
+
category: keyof T & string
|
|
3181
|
+
value: keyof T & string
|
|
3182
|
+
/** One box series per distinct value of this field, side by side. */
|
|
3183
|
+
series?: keyof T & string
|
|
3184
|
+
seriesLabel?: string
|
|
3185
|
+
/** Whisker length in IQRs. Default 1.5. */
|
|
3186
|
+
whisker?: number
|
|
3187
|
+
width?: number
|
|
3188
|
+
height?: number
|
|
3189
|
+
},
|
|
3190
|
+
): ChartSpec {
|
|
3191
|
+
const cats: string[] = []
|
|
3192
|
+
const seen = new Set<string>()
|
|
3193
|
+
for (const r of rows) {
|
|
3194
|
+
const c = String(r[opts.category] ?? '')
|
|
3195
|
+
if (!seen.has(c)) {
|
|
3196
|
+
seen.add(c)
|
|
3197
|
+
cats.push(c)
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
const groupNames: string[] = []
|
|
3201
|
+
const groupSeen = new Set<string>()
|
|
3202
|
+
if (opts.series) {
|
|
3203
|
+
for (const r of rows) {
|
|
3204
|
+
const g = String(r[opts.series] ?? '')
|
|
3205
|
+
if (!groupSeen.has(g)) {
|
|
3206
|
+
groupSeen.add(g)
|
|
3207
|
+
groupNames.push(g)
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
} else {
|
|
3211
|
+
groupNames.push(opts.seriesLabel ?? String(opts.value))
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
const series: ChartSeries[] = groupNames.map((g) => {
|
|
3215
|
+
const boxes: Array<BoxStats | null> = cats.map((c) => {
|
|
3216
|
+
const sample: number[] = []
|
|
3217
|
+
for (const r of rows) {
|
|
3218
|
+
if (String(r[opts.category] ?? '') !== c) continue
|
|
3219
|
+
if (opts.series && String(r[opts.series] ?? '') !== g) continue
|
|
3220
|
+
// `Number(null)` and `Number('')` are both 0, so coercing first would
|
|
3221
|
+
// fold every empty cell into the sample as a zero and drag the whole
|
|
3222
|
+
// box down. An absent observation is absent, not zero.
|
|
3223
|
+
const raw = r[opts.value]
|
|
3224
|
+
if (raw == null || raw === '') continue
|
|
3225
|
+
const n = Number(raw)
|
|
3226
|
+
if (Number.isFinite(n)) sample.push(n)
|
|
3227
|
+
}
|
|
3228
|
+
return boxStats(sample, opts.whisker)
|
|
3229
|
+
})
|
|
3230
|
+
return {
|
|
3231
|
+
label: g,
|
|
3232
|
+
// Medians, so a gap stays a gap rather than plotting as zero.
|
|
3233
|
+
values: boxes.map((b) => (b ? b.median : Number.NaN)),
|
|
3234
|
+
boxes,
|
|
3235
|
+
}
|
|
3236
|
+
})
|
|
3237
|
+
|
|
3238
|
+
return {
|
|
3239
|
+
type: 'boxplot',
|
|
3240
|
+
categories: cats,
|
|
3241
|
+
series,
|
|
3242
|
+
...(opts.width ? { width: opts.width } : {}),
|
|
3243
|
+
...(opts.height ? { height: opts.height } : {}),
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
/**
|
|
3248
|
+
* Build a scatter / bubble spec straight from rows.
|
|
3249
|
+
*
|
|
3250
|
+
* Unlike the adapters above this cannot reuse `rowsToChartSpec`: a scatter
|
|
3251
|
+
* point is one row, not one group, so there is nothing to reduce. `series`
|
|
3252
|
+
* colours the points by a categorical field.
|
|
3253
|
+
*/
|
|
3254
|
+
export function rowsToScatterSpec<T extends Record<string, unknown>>(
|
|
3255
|
+
rows: ReadonlyArray<T>,
|
|
3256
|
+
opts: {
|
|
3257
|
+
x: keyof T & string
|
|
3258
|
+
y: keyof T & string
|
|
3259
|
+
/** Bubble radius field. Omit for a plain scatter. */
|
|
3260
|
+
r?: keyof T & string
|
|
3261
|
+
/** Group points into one series per distinct value. */
|
|
3262
|
+
series?: keyof T & string
|
|
3263
|
+
/** Per-point label, shown in the tooltip. */
|
|
3264
|
+
label?: keyof T & string
|
|
3265
|
+
palette?: string[]
|
|
3266
|
+
width?: number
|
|
3267
|
+
height?: number
|
|
3268
|
+
},
|
|
3269
|
+
): ChartSpec {
|
|
3270
|
+
const bySeries = new Map<string, ScatterPoint[]>()
|
|
3271
|
+
for (const row of rows) {
|
|
3272
|
+
const x = Number(row[opts.x])
|
|
3273
|
+
const y = Number(row[opts.y])
|
|
3274
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
|
|
3275
|
+
const key = opts.series ? String(row[opts.series] ?? '') : (opts.y as string)
|
|
3276
|
+
const pt: ScatterPoint = { x, y }
|
|
3277
|
+
if (opts.r) {
|
|
3278
|
+
const r = Number(row[opts.r])
|
|
3279
|
+
if (Number.isFinite(r)) pt.r = r
|
|
3280
|
+
}
|
|
3281
|
+
if (opts.label) pt.label = String(row[opts.label] ?? '')
|
|
3282
|
+
const list = bySeries.get(key)
|
|
3283
|
+
if (list) list.push(pt)
|
|
3284
|
+
else bySeries.set(key, [pt])
|
|
3285
|
+
}
|
|
3286
|
+
return {
|
|
3287
|
+
type: 'scatter',
|
|
3288
|
+
categories: [],
|
|
3289
|
+
series: [...bySeries].map(([label, points]) => ({ label, values: [], points })),
|
|
3290
|
+
palette: opts.palette,
|
|
3291
|
+
width: opts.width,
|
|
3292
|
+
height: opts.height,
|
|
3293
|
+
xAxisTitle: opts.x,
|
|
3294
|
+
yAxisTitle: opts.y,
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
/**
|
|
3299
|
+
* Reduce rows to the single number a gauge shows.
|
|
3300
|
+
*
|
|
3301
|
+
* There is no category axis here, which is why this cannot go through
|
|
3302
|
+
* `rowsToChartSpec`. The dial ends on a nice round number rather than exactly
|
|
3303
|
+
* the value, so the needle never sits pinned at the far end of the arc.
|
|
3304
|
+
*/
|
|
3305
|
+
export function rowsToGaugeSpec<T extends Record<string, unknown>>(
|
|
3306
|
+
rows: ReadonlyArray<T>,
|
|
3307
|
+
opts: {
|
|
3308
|
+
value: keyof T & string
|
|
3309
|
+
reduce?: 'sum' | 'avg' | 'count'
|
|
3310
|
+
min?: number
|
|
3311
|
+
max?: number
|
|
3312
|
+
unit?: string
|
|
3313
|
+
target?: number
|
|
3314
|
+
width?: number
|
|
3315
|
+
height?: number
|
|
3316
|
+
},
|
|
3317
|
+
): ChartSpec {
|
|
3318
|
+
const reduce = opts.reduce ?? 'sum'
|
|
3319
|
+
let sum = 0
|
|
3320
|
+
let count = 0
|
|
3321
|
+
for (const row of rows) {
|
|
3322
|
+
const v = Number(row[opts.value])
|
|
3323
|
+
if (!Number.isFinite(v)) continue
|
|
3324
|
+
sum += v
|
|
3325
|
+
count += 1
|
|
3326
|
+
}
|
|
3327
|
+
const value = reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
|
|
3328
|
+
const min = opts.min ?? Math.min(0, value)
|
|
3329
|
+
const max = opts.max ?? (value > min ? niceScale(min, value).max : min + 1)
|
|
3330
|
+
return {
|
|
3331
|
+
type: 'gauge',
|
|
3332
|
+
categories: [],
|
|
3333
|
+
series: [],
|
|
3334
|
+
gaugeValue: value,
|
|
3335
|
+
gaugeMin: min,
|
|
3336
|
+
gaugeMax: max,
|
|
3337
|
+
gaugeTarget: opts.target,
|
|
3338
|
+
gaugeUnit: opts.unit,
|
|
3339
|
+
width: opts.width,
|
|
3340
|
+
height: opts.height,
|
|
3341
|
+
}
|
|
3342
|
+
}
|