@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.
Files changed (77) hide show
  1. package/dist/GridFooter.svelte +14 -5
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/GridMenus.svelte +18 -10
  4. package/dist/GridMenus.svelte.d.ts +2 -1
  5. package/dist/SvGrid.controller.svelte.d.ts +17 -0
  6. package/dist/SvGrid.controller.svelte.js +128 -14
  7. package/dist/SvGrid.css +79 -10
  8. package/dist/SvGrid.svelte +191 -186
  9. package/dist/SvGrid.types.d.ts +42 -1
  10. package/dist/SvGridChart.svelte +571 -41
  11. package/dist/SvGridChart.svelte.d.ts +63 -1
  12. package/dist/SvGridChartPanel.svelte +169 -21
  13. package/dist/SvGridChartView.svelte +9 -0
  14. package/dist/SvGridChartView.svelte.d.ts +7 -0
  15. package/dist/SvGroupCell.svelte +28 -3
  16. package/dist/SvGroupCell.svelte.d.ts +8 -0
  17. package/dist/SvRowGroupPanel.svelte +20 -4
  18. package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
  19. package/dist/ai.d.ts +11 -2
  20. package/dist/ai.js +51 -7
  21. package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
  22. package/dist/cdn/{GridMenus-DFB6iwF8.js → GridMenus-M2XP9t0_.js} +152 -150
  23. package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
  24. package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
  25. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
  26. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
  27. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-CxfakxR0.js} +5 -3
  28. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-Dm_YEx9L.js} +5 -3
  29. package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
  30. package/dist/cdn/chart-CKJRCPOk.js +1652 -0
  31. package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
  32. package/dist/cdn/{src-C8QjSaq-.js → src-BJ2opd0j.js} +3608 -3414
  33. package/dist/cdn/{src-D0hO2eYN.js → src-DNbz94Ct.js} +8617 -8423
  34. package/dist/cdn/svgrid.js +16 -15
  35. package/dist/cdn/svgrid.svelte-external.js +16 -15
  36. package/dist/cell-formatting.d.ts +7 -0
  37. package/dist/cell-formatting.js +7 -1
  38. package/dist/chart-export.d.ts +23 -2
  39. package/dist/chart-export.js +80 -6
  40. package/dist/chart.d.ts +410 -18
  41. package/dist/chart.js +824 -24
  42. package/dist/grid-icons.d.ts +49 -0
  43. package/dist/grid-icons.js +86 -0
  44. package/dist/index.d.ts +3 -2
  45. package/dist/index.js +3 -2
  46. package/package.json +11 -11
  47. package/src/GridFooter.svelte +14 -5
  48. package/src/GridMenus.svelte +18 -10
  49. package/src/SvGrid.controller.svelte.ts +146 -13
  50. package/src/SvGrid.css +79 -10
  51. package/src/SvGrid.svelte +191 -186
  52. package/src/SvGrid.types.ts +42 -1
  53. package/src/SvGridChart.svelte +571 -41
  54. package/src/SvGridChart.test.ts +243 -1
  55. package/src/SvGridChartPanel.svelte +169 -21
  56. package/src/SvGridChartView.svelte +9 -0
  57. package/src/SvGroupCell.svelte +28 -3
  58. package/src/SvRowGroupPanel.svelte +20 -4
  59. package/src/ai.test.ts +60 -1
  60. package/src/ai.ts +59 -6
  61. package/src/cell-formatting.ts +7 -1
  62. package/src/chart-export.test.ts +112 -0
  63. package/src/chart-export.ts +76 -8
  64. package/src/chart.test.ts +830 -1
  65. package/src/chart.ts +1053 -32
  66. package/src/grid-icons.test.ts +46 -0
  67. package/src/grid-icons.ts +184 -0
  68. package/src/icon-overrides.grid.test.ts +188 -0
  69. package/src/icon-seam.test.ts +100 -0
  70. package/src/index.ts +19 -0
  71. package/src/svgrid.charting.test.ts +120 -0
  72. package/dist/cdn/GridMenus-CYzOvmNQ.js +0 -600
  73. package/dist/cdn/SvGridChart-BEJmNNx9.js +0 -1203
  74. package/dist/cdn/SvGridChart-BwVos976.js +0 -1202
  75. package/dist/cdn/SvGridChartPanel-BOknOkrP.js +0 -561
  76. package/dist/cdn/SvGridChartPanel-D2PjII4O.js +0 -585
  77. 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
- /** Treat `categories` as dates -> time-scaled x positions + date ticks. */
138
- xType?: 'category' | 'time'
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
- * Builds on the compact `1.2k` / `1.2M` base: currency prefixes `$` (sign
772
- * outside), percent multiplies by 100 and suffixes `%`. Unset / `'number'` /
773
- * `'compact'` = the plain compact form.
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(n: number, format?: ChartValueFormat): string {
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.type ?? (spec.type === 'pie' || spec.type === 'scatter' ? 'bar' : spec.type)) as
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 - 9),
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 - 16, tailR = 18, baseR = 6
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
- for (const band of spec.gaugeRanges ?? []) {
1269
- if (value >= band.from && value <= band.to) valueColor = band.color
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: 7 }, valueColor,
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
- const timeVals =
1847
- spec.xType === 'time' ? spec.categories.map((c) => Date.parse(c)) : null
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
- timeOk && Number.isFinite(timeVals![i])
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[] = timeOk
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
- : spec.categories.map((label, i) => ({ label, x: xCenter(i) }))
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
- if (s.kind === 'bar') continue
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) => ({ value, y: yOf(dom, value, log), label: formatChartValue(value, spec.valueFormat) }))
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
+ }