@svgrid/grid 3.0.3 → 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/GridMenus.svelte +44 -2
- package/dist/SvChartMenu.svelte +69 -0
- package/dist/SvChartMenu.svelte.d.ts +11 -0
- package/dist/SvChartPanes.svelte +113 -0
- package/dist/SvChartPanes.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.d.ts +110 -39
- package/dist/SvGrid.controller.svelte.js +655 -200
- package/dist/SvGrid.css +105 -2
- package/dist/SvGrid.svelte +386 -514
- package/dist/SvGrid.types.d.ts +210 -9
- package/dist/SvGridCellEditor.svelte +17 -0
- package/dist/SvGridChart.svelte +2257 -329
- package/dist/SvGridChart.svelte.d.ts +6 -106
- package/dist/SvGridChart.types.d.ts +338 -0
- package/dist/SvGridChart.types.js +1 -0
- package/dist/SvGridChartBuilder.svelte +323 -0
- package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
- package/dist/SvGridChartPanel.svelte +221 -275
- package/dist/SvGridChartPickers.svelte +417 -0
- package/dist/SvGridChartPickers.svelte.d.ts +70 -0
- package/dist/SvGridChartView.svelte +13 -14
- package/dist/SvModal.svelte +7 -1
- package/dist/SvModal.svelte.d.ts +5 -0
- package/dist/ai.d.ts +40 -8
- package/dist/ai.js +135 -24
- package/dist/aria.d.ts +11 -0
- package/dist/build-api.js +108 -22
- package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
- package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
- package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
- package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
- package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
- package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
- package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
- package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
- package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
- package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
- package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
- package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
- package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
- package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
- package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
- package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
- package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
- package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
- package/dist/cdn/SvModal-CIZWCcad.js +397 -0
- package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
- package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
- package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
- package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
- package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
- package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
- package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
- package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
- package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
- package/dist/cdn/dismissable-DAHetSNk.js +44 -0
- package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
- package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
- package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
- package/dist/cdn/{src-D6XvA1Ij.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-Bugf5XlI.js → src-V1uu8iE9.js} +6568 -6128
- package/dist/cdn/svgrid.js +26 -15
- package/dist/cdn/svgrid.svelte-external.js +26 -15
- package/dist/chart-axes.d.ts +53 -0
- package/dist/chart-axes.js +351 -0
- package/dist/chart-cartesian.d.ts +88 -0
- package/dist/chart-cartesian.js +1862 -0
- package/dist/chart-decimate.d.ts +51 -0
- package/dist/chart-decimate.js +199 -0
- package/dist/chart-export-pdf.d.ts +47 -0
- package/dist/chart-export-pdf.js +187 -0
- package/dist/chart-export.d.ts +2 -1
- package/dist/chart-export.js +18 -3
- package/dist/chart-financial.d.ts +91 -0
- package/dist/chart-financial.js +175 -0
- package/dist/chart-flow.d.ts +10 -0
- package/dist/chart-flow.js +298 -0
- package/dist/chart-format.d.ts +18 -0
- package/dist/chart-format.js +65 -0
- package/dist/chart-grid.d.ts +8 -0
- package/dist/chart-grid.js +190 -0
- package/dist/chart-hierarchy.d.ts +17 -0
- package/dist/chart-hierarchy.js +146 -0
- package/dist/chart-indicators.d.ts +115 -0
- package/dist/chart-indicators.js +389 -0
- package/dist/chart-messages.d.ts +95 -0
- package/dist/chart-messages.js +92 -0
- package/dist/chart-motion.d.ts +16 -0
- package/dist/chart-motion.js +141 -0
- package/dist/chart-panel-messages.d.ts +265 -0
- package/dist/chart-panel-messages.js +285 -0
- package/dist/chart-pivot.d.ts +87 -0
- package/dist/chart-pivot.js +156 -0
- package/dist/chart-polar.d.ts +25 -0
- package/dist/chart-polar.js +700 -0
- package/dist/chart-samples.d.ts +16 -0
- package/dist/chart-samples.js +153 -0
- package/dist/chart-scale.d.ts +113 -0
- package/dist/chart-scale.js +405 -0
- package/dist/chart-stats.d.ts +111 -0
- package/dist/chart-stats.js +419 -0
- package/dist/chart-stream.d.ts +45 -0
- package/dist/chart-stream.js +91 -0
- package/dist/chart-summary.d.ts +27 -0
- package/dist/chart-summary.js +205 -0
- package/dist/chart-sync.svelte.d.ts +32 -0
- package/dist/chart-sync.svelte.js +23 -0
- package/dist/chart-table.d.ts +35 -0
- package/dist/chart-table.js +165 -0
- package/dist/chart-trend.d.ts +19 -0
- package/dist/chart-trend.js +61 -0
- package/dist/chart-types.d.ts +1456 -0
- package/dist/chart-types.js +6 -0
- package/dist/chart-validate.d.ts +43 -0
- package/dist/chart-validate.js +272 -0
- package/dist/chart-zoom.d.ts +40 -0
- package/dist/chart-zoom.js +145 -0
- package/dist/chart.d.ts +186 -865
- package/dist/chart.js +460 -2204
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +137 -18
- package/dist/column-resize.d.ts +3 -0
- package/dist/column-resize.js +4 -1
- package/dist/columns.js +3 -0
- package/dist/command-context.d.ts +19 -0
- package/dist/command-context.js +116 -0
- package/dist/core.d.ts +7 -0
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +262 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/grid-messages.d.ts +10 -3
- package/dist/grid-messages.js +5 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.js +20 -2
- package/dist/keyboard-handlers.js +88 -31
- package/dist/keyboard.d.ts +55 -0
- package/dist/keyboard.js +132 -0
- package/dist/menus.d.ts +3 -1
- package/dist/menus.js +7 -2
- package/dist/merges.d.ts +97 -0
- package/dist/merges.js +147 -0
- package/dist/row-resize.d.ts +3 -0
- package/dist/row-resize.js +14 -0
- package/dist/scroll-sync.d.ts +1 -0
- package/dist/scroll-sync.js +24 -0
- package/dist/selection.d.ts +16 -0
- package/dist/selection.js +108 -16
- package/dist/shortcut-registry.d.ts +145 -0
- package/dist/shortcut-registry.js +44 -0
- package/dist/svgrid-wrapper.types.d.ts +213 -22
- package/dist/virtualization/virtualizer.js +38 -17
- package/package.json +6 -1
- package/src/GridMenus.svelte +44 -2
- package/src/SvChartMenu.svelte +69 -0
- package/src/SvChartPanes.svelte +113 -0
- package/src/SvChartPanes.test.ts +66 -0
- package/src/SvGrid.controller.svelte.ts +629 -172
- package/src/SvGrid.css +105 -2
- package/src/SvGrid.svelte +386 -514
- package/src/SvGrid.types.ts +188 -8
- package/src/SvGridCellEditor.svelte +17 -0
- package/src/SvGridChart.svelte +2257 -329
- package/src/SvGridChart.test.ts +1419 -1
- package/src/SvGridChart.types.ts +336 -0
- package/src/SvGridChartBuilder.svelte +323 -0
- package/src/SvGridChartPanel.svelte +221 -275
- package/src/SvGridChartPickers.svelte +417 -0
- package/src/SvGridChartView.svelte +13 -14
- package/src/SvModal.svelte +7 -1
- package/src/SvModal.test.ts +9 -0
- package/src/ai.test.ts +57 -2
- package/src/ai.ts +166 -22
- package/src/aria.d.ts +11 -0
- package/src/build-api.coverage.test.ts +184 -0
- package/src/build-api.ts +107 -18
- package/src/chart-axes.test.ts +573 -0
- package/src/chart-axes.ts +342 -0
- package/src/chart-cartesian.ts +1863 -0
- package/src/chart-decimate.test.ts +249 -0
- package/src/chart-decimate.ts +186 -0
- package/src/chart-export-pdf.test.ts +91 -0
- package/src/chart-export-pdf.ts +207 -0
- package/src/chart-export.test.ts +14 -0
- package/src/chart-export.ts +13 -3
- package/src/chart-financial.ts +208 -0
- package/src/chart-flow.ts +286 -0
- package/src/chart-format.ts +67 -0
- package/src/chart-grid.ts +179 -0
- package/src/chart-hierarchy.ts +135 -0
- package/src/chart-indicators.test.ts +443 -0
- package/src/chart-indicators.ts +384 -0
- package/src/chart-messages.test.ts +21 -0
- package/src/chart-messages.ts +184 -0
- package/src/chart-motion.test.ts +71 -0
- package/src/chart-motion.ts +148 -0
- package/src/chart-panel-messages.test.ts +29 -0
- package/src/chart-panel-messages.ts +550 -0
- package/src/chart-pivot.test.ts +54 -0
- package/src/chart-pivot.ts +225 -0
- package/src/chart-polar.ts +674 -0
- package/src/chart-samples.ts +106 -0
- package/src/chart-scale.ts +394 -0
- package/src/chart-series-types.test.ts +714 -0
- package/src/chart-stats.ts +409 -0
- package/src/chart-stream.test.ts +75 -0
- package/src/chart-stream.ts +114 -0
- package/src/chart-summary.test.ts +90 -0
- package/src/chart-summary.ts +197 -0
- package/src/chart-sync.svelte.ts +41 -0
- package/src/chart-table.test.ts +76 -0
- package/src/chart-table.ts +178 -0
- package/src/chart-trend.ts +47 -0
- package/src/chart-types.ts +1309 -0
- package/src/chart-validate.test.ts +100 -0
- package/src/chart-validate.ts +247 -0
- package/src/chart-zoom.test.ts +137 -0
- package/src/chart-zoom.ts +124 -0
- package/src/chart.coverage.test.ts +81 -0
- package/src/chart.test.ts +56 -3
- package/src/chart.ts +557 -2784
- package/src/clipboard.test.ts +151 -0
- package/src/clipboard.ts +145 -16
- package/src/column-resize.ts +7 -1
- package/src/columns.ts +2 -0
- package/src/command-context.test.ts +200 -0
- package/src/command-context.ts +137 -0
- package/src/core.ts +7 -0
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +256 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/grid-messages.ts +16 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +3 -2
- package/src/index.ts +150 -2
- package/src/keyboard-handlers.coverage.test.ts +126 -6
- package/src/keyboard-handlers.ts +91 -30
- package/src/keyboard-shortcuts.seam.test.ts +200 -0
- package/src/keyboard.test.ts +124 -1
- package/src/keyboard.ts +142 -0
- package/src/menus.test.ts +26 -0
- package/src/menus.ts +10 -3
- package/src/merges.test.ts +112 -0
- package/src/merges.ts +168 -0
- package/src/pivot.grid.test.ts +40 -5
- package/src/row-resize.test.ts +44 -0
- package/src/row-resize.ts +17 -0
- package/src/scroll-sync.test.ts +42 -1
- package/src/scroll-sync.ts +25 -0
- package/src/selection.multi-range.test.ts +34 -0
- package/src/selection.test.ts +30 -2
- package/src/selection.ts +111 -19
- package/src/shortcut-registry.test.ts +97 -0
- package/src/shortcut-registry.ts +181 -0
- package/src/svgrid-wrapper.types.ts +184 -13
- package/src/svgrid.behavior.test.ts +135 -0
- package/src/svgrid.charting.test.ts +513 -3
- package/src/svgrid.context-menu.test.ts +31 -0
- package/src/svgrid.new-features.wrapper.test.ts +10 -3
- package/src/virtualization/virtualizer.ts +39 -17
- package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
- package/dist/cdn/GridMenus-DhdPF700.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
- package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
- package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
- package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
- package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
- package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
- package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
package/src/chart.ts
CHANGED
|
@@ -8,1331 +8,178 @@
|
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
export
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
export type
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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>
|
|
107
|
-
/** Plot against the left (default) or right Y axis. */
|
|
108
|
-
axis?: 'left' | 'right'
|
|
109
|
-
/** Scatter / bubble points (used when `type === 'scatter'`). */
|
|
110
|
-
points?: ScatterPoint[]
|
|
111
|
-
/** Row IDs contributing to each data point - parallel to `values`. When
|
|
112
|
-
* present, click handlers receive these in `ChartSelection.rowIds` so
|
|
113
|
-
* callers can drill the grid back to the source rows. */
|
|
114
|
-
rowIds?: Array<Array<string | number>>
|
|
115
|
-
/** Draw a smoothing / trend overlay on top of this series. */
|
|
116
|
-
overlay?: SeriesOverlay
|
|
117
|
-
/** Color for the overlay line. Defaults to the series color. */
|
|
118
|
-
overlayColor?: string
|
|
119
|
-
/** Texture fill (e.g. diagonal stripes) layered over the series color.
|
|
120
|
-
* Lets colorblind viewers tell two series apart even at the same hue. */
|
|
121
|
-
pattern?: SeriesPattern
|
|
122
|
-
/** Interpolate the line as a curve instead of polylines. `'monotone'`
|
|
123
|
-
* cubic prevents overshoots between points (best default for data);
|
|
124
|
-
* `true` is an alias for `'monotone'`. Only meaningful for line/area. */
|
|
125
|
-
smooth?: boolean | 'monotone'
|
|
126
|
-
/** Upper envelope (e.g. forecast 95th percentile) parallel to `values`.
|
|
127
|
-
* When set alongside `lowerValues`, the chart shades the band between
|
|
128
|
-
* the two as a translucent fill in the series color. */
|
|
129
|
-
upperValues?: number[]
|
|
130
|
-
/** Lower envelope; pair with `upperValues` for a confidence band. */
|
|
131
|
-
lowerValues?: number[]
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** A pinned label drawn over the plot, anchored to a data point or to an
|
|
135
|
-
* arbitrary (x, y) in data space. Useful for "Release v1", "Outage", etc. */
|
|
136
|
-
export type ChartAnnotation = {
|
|
137
|
-
/** Anchor in data space. Provide either `category` + `axis` for a point on
|
|
138
|
-
* an existing series, OR raw `x` / `y` numeric coordinates in data space. */
|
|
139
|
-
at:
|
|
140
|
-
| { category: string; series?: string }
|
|
141
|
-
| { x: number; y?: number }
|
|
142
|
-
label: string
|
|
143
|
-
color?: string
|
|
144
|
-
/** Where the label sits relative to the marker. Defaults to 'top'. */
|
|
145
|
-
placement?: 'top' | 'bottom' | 'left' | 'right'
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** A horizontal reference / target line drawn across the plot. */
|
|
149
|
-
export type ChartReferenceLine = {
|
|
150
|
-
value: number
|
|
151
|
-
label?: string
|
|
152
|
-
axis?: 'left' | 'right'
|
|
153
|
-
color?: string
|
|
154
|
-
dashed?: boolean
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* What to plot - the input you build and hand to a chart. Categories are the
|
|
159
|
-
* x-axis labels and every series supplies one value per category.
|
|
160
|
-
*
|
|
161
|
-
* {@link buildChart} turns this into a {@link ChartGeometry} for rendering.
|
|
162
|
-
*/
|
|
163
|
-
export type ChartSpec = {
|
|
164
|
-
/** Default type for series that don't set their own `type`. */
|
|
165
|
-
type: ChartType
|
|
166
|
-
/** X-axis labels (one per data point). */
|
|
167
|
-
categories: string[]
|
|
168
|
-
series: ChartSeries[]
|
|
169
|
-
width?: number
|
|
170
|
-
height?: number
|
|
171
|
-
/** Palette used when a series has no explicit `color`. */
|
|
172
|
-
palette?: string[]
|
|
173
|
-
/** Per-category color overrides (by category label) - for pie / donut slice
|
|
174
|
-
* recolouring, where colour follows the category, not a series. */
|
|
175
|
-
categoryColors?: Record<string, string>
|
|
176
|
-
/** Number format for the value axis, tooltips, data labels and reference
|
|
177
|
-
* lines. Unset = the default compact `1.2k` / `1.2M` style. */
|
|
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
|
|
189
|
-
/** Grouped (nested) category axis: a parent tier spanning consecutive leaf
|
|
190
|
-
* categories (spans must sum to `categories.length`). Vertical category
|
|
191
|
-
* charts only (ignored for time / horizontal / pie). */
|
|
192
|
-
categoryGroups?: Array<{ label: string; span: number }>
|
|
193
|
-
/** Stack bar / area series (per axis) instead of grouping them. */
|
|
194
|
-
stacked?: boolean
|
|
195
|
-
/** Stack to 100% (each category normalized to its total). Implies stacked. */
|
|
196
|
-
stacked100?: boolean
|
|
197
|
-
/**
|
|
198
|
-
* Bar orientation. `'horizontal'` swaps the axes - categories run down the
|
|
199
|
-
* left, bars grow rightward - which suits long category labels. Only applies
|
|
200
|
-
* when every series is a bar (combo / line / area fall back to vertical).
|
|
201
|
-
*/
|
|
202
|
-
orientation?: 'vertical' | 'horizontal'
|
|
203
|
-
/** Pie only: inner radius as a fraction of the outer radius (0..1) -> donut. */
|
|
204
|
-
innerRadius?: number
|
|
205
|
-
/** Horizontal target / goal / average lines. */
|
|
206
|
-
referenceLines?: ChartReferenceLine[]
|
|
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'
|
|
219
|
-
/** Axis titles (reserve gutter space + render). */
|
|
220
|
-
yAxisTitle?: string
|
|
221
|
-
y2AxisTitle?: string
|
|
222
|
-
xAxisTitle?: string
|
|
223
|
-
/** Y-axis scale. `'log'` plots base-10 logarithmic - values <= 0 are
|
|
224
|
-
* treated as missing. Necessary for wide-range data (money, audience
|
|
225
|
-
* size, scientific). Default `'linear'`. */
|
|
226
|
-
yScale?: 'linear' | 'log'
|
|
227
|
-
/** Right (secondary) Y-axis scale. Default `'linear'`. */
|
|
228
|
-
y2Scale?: 'linear' | 'log'
|
|
229
|
-
/** Pinned text labels at fixed data-space positions (callouts). */
|
|
230
|
-
annotations?: ChartAnnotation[]
|
|
231
|
-
/** When true, automatically cycle through pattern fills for every series
|
|
232
|
-
* that doesn't set `pattern` explicitly. Useful as a one-flag colorblind
|
|
233
|
-
* fallback. Default false. */
|
|
234
|
-
patternFallback?: boolean
|
|
235
|
-
/** Calendar heatmap: array of date+value samples (one per day). Date
|
|
236
|
-
* strings are 'YYYY-MM-DD'. Missing days render as blank cells. */
|
|
237
|
-
calendarValues?: Array<{ date: string; value: number }>
|
|
238
|
-
/** Calendar heatmap: year window. Default: span the data. */
|
|
239
|
-
calendarStart?: string
|
|
240
|
-
calendarEnd?: string
|
|
241
|
-
|
|
242
|
-
/** Gauge: the value to display. */
|
|
243
|
-
gaugeValue?: number
|
|
244
|
-
/** Gauge: min/max of the dial scale. Defaults [0, 100]. */
|
|
245
|
-
gaugeMin?: number
|
|
246
|
-
gaugeMax?: number
|
|
247
|
-
/** Gauge: target marker (the line/notch on the arc). */
|
|
248
|
-
gaugeTarget?: number
|
|
249
|
-
/** Gauge: color bands along the arc (e.g. red/amber/green). */
|
|
250
|
-
gaugeRanges?: Array<{ from: number; to: number; color: string }>
|
|
251
|
-
/** Gauge: unit / suffix shown next to the value (e.g. '%', 'ms'). */
|
|
252
|
-
gaugeUnit?: string
|
|
253
|
-
|
|
254
|
-
/** Tree-map: hierarchical root. Leaves have `value`; parents are the
|
|
255
|
-
* sum of their children's totals. */
|
|
256
|
-
treemap?: TreeNode
|
|
257
|
-
|
|
258
|
-
/** Sankey: nodes + flow links between them. Link `source` / `target`
|
|
259
|
-
* reference node ids. */
|
|
260
|
-
sankeyNodes?: Array<{ id: string; label?: string; color?: string }>
|
|
261
|
-
sankeyLinks?: Array<{ source: string; target: string; value: number; color?: string }>
|
|
262
|
-
|
|
263
|
-
/** Waterfall: per-category flag marking bars as totals/subtotals that
|
|
264
|
-
* reset the running sum and span from 0. Same length as `categories`. */
|
|
265
|
-
waterfallTotals?: boolean[]
|
|
266
|
-
/** Waterfall: explicit colors for positive/negative/total bars. The
|
|
267
|
-
* series color is ignored when this is set. */
|
|
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 }
|
|
273
|
-
/** Heatmap color scale. `'sequential'` maps min->max through one hue,
|
|
274
|
-
* `'diverging'` runs cold->neutral->warm around 0. A custom array
|
|
275
|
-
* (>=2 hex colors) defines an arbitrary gradient. Default `'sequential'`. */
|
|
276
|
-
colorScale?: 'sequential' | 'diverging' | string[]
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** A tree-map / sankey / treemap node spec. Used recursively as a tree. */
|
|
280
|
-
export type TreeNode = {
|
|
281
|
-
name: string
|
|
282
|
-
value?: number
|
|
283
|
-
color?: string
|
|
284
|
-
children?: TreeNode[]
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** A laid-out tree-map rectangle. */
|
|
288
|
-
export type ChartTreemapCell = {
|
|
289
|
-
x: number
|
|
290
|
-
y: number
|
|
291
|
-
w: number
|
|
292
|
-
h: number
|
|
293
|
-
color: string
|
|
294
|
-
textColor: string
|
|
295
|
-
name: string
|
|
296
|
-
value: number
|
|
297
|
-
/** Depth from the root - useful for color cycling per level. */
|
|
298
|
-
depth: number
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** A calendar-heatmap cell (one day). */
|
|
302
|
-
export type ChartCalendarCell = {
|
|
303
|
-
x: number
|
|
304
|
-
y: number
|
|
305
|
-
size: number
|
|
306
|
-
date: string
|
|
307
|
-
value: number
|
|
308
|
-
/** Defined when a value was supplied for this day; blank otherwise. */
|
|
309
|
-
defined: boolean
|
|
310
|
-
color: string
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** A gauge dial layout. */
|
|
314
|
-
export type ChartGaugeLayout = {
|
|
315
|
-
cx: number
|
|
316
|
-
cy: number
|
|
317
|
-
r: number
|
|
318
|
-
/** Track arc path (background grey). */
|
|
319
|
-
trackPath: string
|
|
320
|
-
/** Value arc path (filled to the current value). */
|
|
321
|
-
valuePath: string
|
|
322
|
-
/** Optional colored range arcs. */
|
|
323
|
-
rangePaths: Array<{ path: string; color: string; from: number; to: number }>
|
|
324
|
-
/** Pixel position of the target marker (when set). */
|
|
325
|
-
target: { x1: number; y1: number; x2: number; y2: number } | null
|
|
326
|
-
/** Tick marks around the dial (major ticks are longer). */
|
|
327
|
-
ticks: Array<{ x1: number; y1: number; x2: number; y2: number; major: boolean }>
|
|
328
|
-
/** Pointer needle (a kite shape) + its center hub radius. */
|
|
329
|
-
needle: { path: string; hubR: number }
|
|
330
|
-
/** Status color of the value arc (the band the value falls in), or null to
|
|
331
|
-
* fall back to the theme accent. */
|
|
332
|
-
valueColor: string | null
|
|
333
|
-
/** Scale end labels positioned under the two arc ends. */
|
|
334
|
-
minLabel: { x: number; y: number }
|
|
335
|
-
maxLabel: { x: number; y: number }
|
|
336
|
-
value: number
|
|
337
|
-
min: number
|
|
338
|
-
max: number
|
|
339
|
-
unit: string
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/** A sankey node + its laid-out rect + total flow. */
|
|
343
|
-
export type ChartSankeyNode = {
|
|
344
|
-
id: string
|
|
345
|
-
label: string
|
|
346
|
-
color: string
|
|
347
|
-
x: number
|
|
348
|
-
y: number
|
|
349
|
-
w: number
|
|
350
|
-
h: number
|
|
351
|
-
/** Column (depth) the node was assigned to. */
|
|
352
|
-
column: number
|
|
353
|
-
totalIn: number
|
|
354
|
-
totalOut: number
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** A sankey link rendered as a curved ribbon. */
|
|
358
|
-
export type ChartSankeyLink = {
|
|
359
|
-
path: string
|
|
360
|
-
color: string
|
|
361
|
-
/** Stroke width = link value scaled to pixels. */
|
|
362
|
-
width: number
|
|
363
|
-
source: string
|
|
364
|
-
target: string
|
|
365
|
-
value: number
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** A single funnel segment (trapezoid) in pixel space. */
|
|
369
|
-
export type ChartFunnelSegment = {
|
|
370
|
-
/** Pre-built SVG path for the trapezoid. */
|
|
371
|
-
path: string
|
|
372
|
-
color: string
|
|
373
|
-
label: string
|
|
374
|
-
/** Original value (before any percentile normalisation). */
|
|
375
|
-
value: number
|
|
376
|
-
/** Conversion vs. first segment, 0..1. */
|
|
377
|
-
conversion: number
|
|
378
|
-
/** Drop-off from the previous segment, 0..1. */
|
|
379
|
-
dropoff: number
|
|
380
|
-
/** Centre point (label anchor). */
|
|
381
|
-
cx: number
|
|
382
|
-
cy: number
|
|
383
|
-
/** Auto-picked black/white contrast color for in-segment labels. */
|
|
384
|
-
textColor: string
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** A radar series' polygon: axis values + the closed polygon path. */
|
|
388
|
-
export type ChartRadarSeries = {
|
|
389
|
-
label: string
|
|
390
|
-
color: string
|
|
391
|
-
path: string
|
|
392
|
-
/** Per-axis (x, y) endpoints so callers can draw dots / hit targets. */
|
|
393
|
-
points: Array<{ x: number; y: number; value: number; axis: string }>
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/** Radar axis spoke + tick info. */
|
|
397
|
-
export type ChartRadarAxis = {
|
|
398
|
-
label: string
|
|
399
|
-
/** Outermost endpoint of the spoke. */
|
|
400
|
-
x: number
|
|
401
|
-
y: number
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** A single heatmap rectangle in pixel space. */
|
|
405
|
-
export type ChartHeatmapCell = {
|
|
406
|
-
x: number
|
|
407
|
-
y: number
|
|
408
|
-
w: number
|
|
409
|
-
h: number
|
|
410
|
-
color: string
|
|
411
|
-
/** Text color picked for contrast against `color`. */
|
|
412
|
-
textColor: string
|
|
413
|
-
value: number
|
|
414
|
-
rowLabel: string
|
|
415
|
-
colLabel: string
|
|
416
|
-
}
|
|
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
|
-
|
|
580
|
-
/** A computed bar rectangle in SVG coordinates. Output of {@link buildChart}, not an input. */
|
|
581
|
-
export type ChartBar = {
|
|
582
|
-
x: number
|
|
583
|
-
y: number
|
|
584
|
-
w: number
|
|
585
|
-
h: number
|
|
586
|
-
color: string
|
|
587
|
-
/** Category (x label) this bar belongs to - for tooltips + labels. */
|
|
588
|
-
label: string
|
|
589
|
-
/** Series label this bar belongs to. */
|
|
590
|
-
series: string
|
|
591
|
-
value: number
|
|
592
|
-
}
|
|
593
|
-
/** One computed point on a line, with whether the series has a value there. */
|
|
594
|
-
export type ChartLinePoint = {
|
|
595
|
-
x: number
|
|
596
|
-
y: number
|
|
597
|
-
label: string
|
|
598
|
-
value: number
|
|
599
|
-
/** False for null / NaN values - the line breaks (gap), no dot is drawn. */
|
|
600
|
-
defined: boolean
|
|
601
|
-
}
|
|
602
|
-
/** A computed line series: its points and the path drawn through them. */
|
|
603
|
-
export type ChartLine = {
|
|
604
|
-
path: string
|
|
605
|
-
areaPath: string
|
|
606
|
-
color: string
|
|
607
|
-
label: string
|
|
608
|
-
points: ChartLinePoint[]
|
|
609
|
-
/** Confidence-band path (between upperValues + lowerValues) for this
|
|
610
|
-
* series, when both arrays are supplied. Empty otherwise. */
|
|
611
|
-
bandPath?: string
|
|
612
|
-
}
|
|
613
|
-
/** A computed pie slice, as an SVG arc plus its label placement. */
|
|
614
|
-
export type ChartPieSlice = {
|
|
615
|
-
path: string
|
|
616
|
-
color: string
|
|
617
|
-
label: string
|
|
618
|
-
value: number
|
|
619
|
-
percent: number
|
|
620
|
-
/** Centroid - anchor point for a data label. */
|
|
621
|
-
cx: number
|
|
622
|
-
cy: number
|
|
623
|
-
}
|
|
624
|
-
/** A value-axis tick: the number, where it sits vertically, and its label. */
|
|
625
|
-
export type ChartAxisTick = { value: number; y: number; label: string }
|
|
626
|
-
/** A category-axis tick: the label and its horizontal position. */
|
|
627
|
-
export type ChartCategoryTick = { label: string; x: number }
|
|
628
|
-
/** One legend entry, paired with the series colour it stands for. */
|
|
629
|
-
export type ChartLegendItem = { label: string; color: string }
|
|
630
|
-
/** A computed reference line (target, average, threshold) at its plotted height. */
|
|
631
|
-
export type ChartRefLineGeo = { y: number; label: string; color: string; dashed: boolean }
|
|
632
|
-
/** A vertical reference line (horizontal bar charts) positioned by `x`. */
|
|
633
|
-
export type ChartRefLineGeoV = { x: number; label: string; color: string; dashed: boolean }
|
|
634
|
-
/** A computed scatter point in SVG coordinates. */
|
|
635
|
-
export type ChartScatterDot = {
|
|
636
|
-
cx: number
|
|
637
|
-
cy: number
|
|
638
|
-
r: number
|
|
639
|
-
color: string
|
|
640
|
-
label: string
|
|
641
|
-
series: string
|
|
642
|
-
x: number
|
|
643
|
-
y: number
|
|
644
|
-
}
|
|
11
|
+
// This file is the public facade over the chart engine. The types live in
|
|
12
|
+
// chart-types.ts, scales and formatting in chart-scale.ts, statistics in
|
|
13
|
+
// chart-stats.ts, axis resolution in chart-axes.ts and each family's layout in
|
|
14
|
+
// chart-cartesian / chart-polar / chart-flow / chart-grid / chart-hierarchy.
|
|
15
|
+
// buildChart below is the single entry: it resolves the series once and
|
|
16
|
+
// dispatches to the family. Everything a consumer imports from './chart' is
|
|
17
|
+
// re-exported here so the split is invisible from outside.
|
|
18
|
+
import type {
|
|
19
|
+
ChartResponsiveRule,
|
|
20
|
+
ChartStyle,
|
|
21
|
+
BoxStats,
|
|
22
|
+
ChartAxisConfig,
|
|
23
|
+
ChartGeometry,
|
|
24
|
+
ChartLegendItem,
|
|
25
|
+
ChartMarkerShape,
|
|
26
|
+
ChartReducer,
|
|
27
|
+
ChartSeries,
|
|
28
|
+
ChartSpec,
|
|
29
|
+
ChartType,
|
|
30
|
+
ChartValueFormat,
|
|
31
|
+
LayoutCtx,
|
|
32
|
+
ResolvedSeries,
|
|
33
|
+
ScatterPoint,
|
|
34
|
+
TreeNode,
|
|
35
|
+
} from './chart-types'
|
|
36
|
+
import { DEFAULT_PALETTE, niceScale } from './chart-scale'
|
|
37
|
+
import { binValues, boxStats, bucketStart, reducerNeedsSamples, reduceValues, type ChartTimeBucket } from './chart-stats'
|
|
38
|
+
import { chartFrame, kindOf, resolveAxes } from './chart-axes'
|
|
39
|
+
import { pickCategories } from './chart-decimate'
|
|
40
|
+
import { layoutBullet, layoutCartesian, layoutHorizontalBars, layoutScatter } from './chart-cartesian'
|
|
41
|
+
import { layoutChord, layoutGauge, layoutPie, layoutRadar, layoutRadialBar, layoutRadialColumn, layoutSunburst } from './chart-polar'
|
|
42
|
+
import { layoutFunnel, layoutSankey, layoutWaterfall } from './chart-flow'
|
|
43
|
+
import { layoutCalendar, layoutHeatmap } from './chart-grid'
|
|
44
|
+
import { layoutTreemap } from './chart-hierarchy'
|
|
45
|
+
|
|
46
|
+
export type * from './chart-types'
|
|
47
|
+
export {
|
|
48
|
+
DEFAULT_PALETTE,
|
|
49
|
+
formatChartValue,
|
|
50
|
+
niceLogScale,
|
|
51
|
+
niceScale,
|
|
52
|
+
ordinalDateTicks,
|
|
53
|
+
pickContrastText,
|
|
54
|
+
sampleGradient,
|
|
55
|
+
} from './chart-scale'
|
|
56
|
+
export { chartScales } from './chart-axes'
|
|
57
|
+
export { decimateSpec, lttb, minMaxIndices, pickCategories, PER_CATEGORY_SERIES_KEYS, PER_CATEGORY_SPEC_KEYS } from './chart-decimate'
|
|
58
|
+
export { buildLinePath, layoutDataLabels, markerPath, streamBaseline, type ChartDataLabel } from './chart-cartesian'
|
|
59
|
+
export { arcPath } from './chart-polar'
|
|
60
|
+
export { pivotResultToChartSpec, pivotChartType, pivotFilterColumn, bucketsToChartSpec, type PivotResultChartOptions, type PivotResultLike } from './chart-pivot'
|
|
61
|
+
export { heikinAshi, resampleOhlc, rowsToOhlcSpec, guessOhlcColumns, splitPanelIndicators, ohlcDirectOptions, type OhlcColumns, type ChartPanelIndicator } from './chart-financial'
|
|
62
|
+
import { rowsToOhlcSpec } from './chart-financial'
|
|
63
|
+
export {
|
|
64
|
+
binValues,
|
|
65
|
+
boxStats,
|
|
66
|
+
bucketStart,
|
|
67
|
+
percentile,
|
|
68
|
+
reduceValues,
|
|
69
|
+
type ChartBins,
|
|
70
|
+
type ChartTimeBucket,
|
|
71
|
+
exponentialMovingAverage,
|
|
72
|
+
linearTrend,
|
|
73
|
+
linearFit,
|
|
74
|
+
polynomialFit,
|
|
75
|
+
exponentialFit,
|
|
76
|
+
logarithmicFit,
|
|
77
|
+
powerFit,
|
|
78
|
+
rSquared,
|
|
79
|
+
pearson,
|
|
80
|
+
type RegressionFit,
|
|
81
|
+
simpleMovingAverage,
|
|
82
|
+
} from './chart-stats'
|
|
83
|
+
export {
|
|
84
|
+
computeOverlay,
|
|
85
|
+
computeOverlayFit,
|
|
86
|
+
regressionFit,
|
|
87
|
+
overlayName,
|
|
88
|
+
bollingerBands,
|
|
89
|
+
rsi,
|
|
90
|
+
macd,
|
|
91
|
+
vwap,
|
|
92
|
+
atr,
|
|
93
|
+
stochastic,
|
|
94
|
+
wma,
|
|
95
|
+
obv,
|
|
96
|
+
indicatorPane,
|
|
97
|
+
type ChartIndicatorSpec,
|
|
98
|
+
} from './chart-indicators'
|
|
645
99
|
|
|
646
100
|
/**
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
101
|
+
* The CSS custom properties `spec.style` sets on the chart's host, as one
|
|
102
|
+
* inline style string (empty when nothing is set). `background` becomes the
|
|
103
|
+
* chart's `--sg-bg` (exports read it as the page colour), `textColor` the
|
|
104
|
+
* text tokens, `gridColor` the border token, `fontSize` a scale every label
|
|
105
|
+
* multiplies (12px is 1), and `fontFamily` the host's font.
|
|
650
106
|
*/
|
|
651
|
-
export
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
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[]
|
|
683
|
-
lines: ChartLine[]
|
|
684
|
-
slices: ChartPieSlice[]
|
|
685
|
-
yTicks: ChartAxisTick[]
|
|
686
|
-
/** Right-axis ticks (combo / dual-axis); empty when there's no right axis. */
|
|
687
|
-
y2Ticks: ChartAxisTick[]
|
|
688
|
-
hasRightAxis: boolean
|
|
689
|
-
xTicks: ChartCategoryTick[]
|
|
690
|
-
/** Grouped category axis parent tier: label + span extent (pixels). Empty
|
|
691
|
-
* unless `spec.categoryGroups` is set on a vertical category chart. */
|
|
692
|
-
categoryGroupTicks: Array<{ label: string; xCenter: number; x0: number; x1: number }>
|
|
693
|
-
/** True when x labels are long/many and should be rotated. */
|
|
694
|
-
xLabelRotated: boolean
|
|
695
|
-
legend: ChartLegendItem[]
|
|
696
|
-
/** Donut centre (pie + innerRadius), for a centre total label. */
|
|
697
|
-
donut: { cx: number; cy: number; r: number; total: number } | null
|
|
698
|
-
/** Horizontal reference / target lines. */
|
|
699
|
-
referenceLines: ChartRefLineGeo[]
|
|
700
|
-
/** Scatter / bubble points (type === 'scatter'). */
|
|
701
|
-
scatterPoints: ChartScatterDot[]
|
|
702
|
-
/** Bar orientation. `'horizontal'` uses `valueTicks` / `catTicks` below. */
|
|
703
|
-
orientation: 'vertical' | 'horizontal'
|
|
704
|
-
/** Horizontal bars: value-axis ticks along the bottom (label + x). */
|
|
705
|
-
valueTicks: ChartCategoryTick[]
|
|
706
|
-
/** Horizontal bars: category labels down the left (label + y; value = index). */
|
|
707
|
-
catTicks: ChartAxisTick[]
|
|
708
|
-
/** Horizontal bars: vertical reference / target lines (positioned by x). */
|
|
709
|
-
referenceLinesV: ChartRefLineGeoV[]
|
|
710
|
-
/** Trend / moving-average overlay lines (parallel to `lines`). Drawn
|
|
711
|
-
* dashed on top of their source series. */
|
|
712
|
-
overlays: ChartLine[]
|
|
713
|
-
/** Pinned annotation labels with pre-resolved screen coordinates. */
|
|
714
|
-
annotations: Array<{ x: number; y: number; label: string; color: string; placement: 'top' | 'bottom' | 'left' | 'right' }>
|
|
715
|
-
/** Heatmap cells (type === 'heatmap'). */
|
|
716
|
-
heatmapCells: ChartHeatmapCell[]
|
|
717
|
-
/** Heatmap row labels with pre-resolved y positions (left gutter). */
|
|
718
|
-
heatmapRowTicks: ChartAxisTick[]
|
|
719
|
-
/** Heatmap column labels (bottom of plot). */
|
|
720
|
-
heatmapColTicks: ChartCategoryTick[]
|
|
721
|
-
/** Heatmap color-scale legend: ordered stops with value + color. */
|
|
722
|
-
heatmapLegend: Array<{ value: number; color: string; label: string }>
|
|
723
|
-
/** Funnel segments (type === 'funnel'). */
|
|
724
|
-
funnelSegments: ChartFunnelSegment[]
|
|
725
|
-
/** Radar concentric grid rings (centred at `radarCenter`). */
|
|
726
|
-
radarRings: number[]
|
|
727
|
-
/** Radar axis labels + spoke endpoints. */
|
|
728
|
-
radarAxes: ChartRadarAxis[]
|
|
729
|
-
/** Radar series polygons. */
|
|
730
|
-
radarSeries: ChartRadarSeries[]
|
|
731
|
-
/** Centre of the radar / pie. Pre-computed so callers don't re-derive. */
|
|
732
|
-
radarCenter: { cx: number; cy: number; r: number } | null
|
|
733
|
-
/** Tree-map cells (type === 'treemap'). */
|
|
734
|
-
treemapCells: ChartTreemapCell[]
|
|
735
|
-
/** Calendar heatmap (type === 'calendar'). */
|
|
736
|
-
calendarCells: ChartCalendarCell[]
|
|
737
|
-
/** Calendar month labels along the top. */
|
|
738
|
-
calendarMonthTicks: ChartCategoryTick[]
|
|
739
|
-
/** Calendar legend stops (sequential ramp). */
|
|
740
|
-
calendarLegend: Array<{ value: number; color: string; label: string }>
|
|
741
|
-
/** Gauge layout (type === 'gauge'). Null when not a gauge. */
|
|
742
|
-
gauge: ChartGaugeLayout | null
|
|
743
|
-
/** Sankey nodes (type === 'sankey'). */
|
|
744
|
-
sankeyNodes: ChartSankeyNode[]
|
|
745
|
-
/** Sankey links (type === 'sankey'). */
|
|
746
|
-
sankeyLinks: ChartSankeyLink[]
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/** Series colours used when a {@link ChartSeries} sets none, in order. */
|
|
750
|
-
export const DEFAULT_PALETTE = [
|
|
751
|
-
'#2563eb',
|
|
752
|
-
'#16a34a',
|
|
753
|
-
'#f59e0b',
|
|
754
|
-
'#ef4444',
|
|
755
|
-
'#8b5cf6',
|
|
756
|
-
'#0ea5e9',
|
|
757
|
-
'#ec4899',
|
|
758
|
-
'#14b8a6',
|
|
759
|
-
]
|
|
760
|
-
|
|
761
|
-
function round(n: number): number {
|
|
762
|
-
return Math.round(n * 100) / 100
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
function niceNum(range: number, roundIt: boolean): number {
|
|
766
|
-
if (range <= 0) return 1
|
|
767
|
-
const exp = Math.floor(Math.log10(range))
|
|
768
|
-
const f = range / Math.pow(10, exp)
|
|
769
|
-
let nf: number
|
|
770
|
-
if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
|
|
771
|
-
else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
|
|
772
|
-
return nf * Math.pow(10, exp)
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
/** An axis range rounded to human-friendly bounds and tick spacing. */
|
|
776
|
-
export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
|
|
777
|
-
|
|
778
|
-
// ---- Color helpers for heatmap / pattern fills ----------------------
|
|
779
|
-
|
|
780
|
-
/** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
|
|
781
|
-
const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
|
|
782
|
-
/** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
|
|
783
|
-
const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
|
|
784
|
-
/** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
|
|
785
|
-
* above the dark grid surface instead of near-white, so empty / low cells read
|
|
786
|
-
* as "cold" rather than as glaring white rectangles. */
|
|
787
|
-
const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
|
|
788
|
-
const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
|
|
789
|
-
|
|
790
|
-
function resolveColorScale(
|
|
791
|
-
scale: 'sequential' | 'diverging' | string[] | undefined,
|
|
792
|
-
vMin: number,
|
|
793
|
-
vMax: number,
|
|
794
|
-
theme: 'light' | 'dark' = 'light',
|
|
795
|
-
): string[] {
|
|
796
|
-
if (Array.isArray(scale) && scale.length >= 2) return scale
|
|
797
|
-
const dark = theme === 'dark'
|
|
798
|
-
if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
|
|
799
|
-
return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
|
|
800
|
-
}
|
|
801
|
-
return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
/** Sample a hex color from an array of hex stops at fractional position t.
|
|
805
|
-
* Linearly interpolates between the two nearest stops in RGB space. */
|
|
806
|
-
export function sampleGradient(stops: string[], t: number): string {
|
|
807
|
-
if (!stops.length) return '#888'
|
|
808
|
-
const clamped = Math.max(0, Math.min(1, t))
|
|
809
|
-
if (stops.length === 1) return stops[0]!
|
|
810
|
-
const pos = clamped * (stops.length - 1)
|
|
811
|
-
const i = Math.floor(pos)
|
|
812
|
-
const frac = pos - i
|
|
813
|
-
const a = hexToRgb(stops[i]!)
|
|
814
|
-
const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
|
|
815
|
-
if (!a || !b) return stops[i] ?? '#888'
|
|
816
|
-
const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
|
|
817
|
-
const toHex = (n: number) => n.toString(16).padStart(2, '0')
|
|
818
|
-
return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
822
|
-
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
|
|
823
|
-
if (!m) return null
|
|
824
|
-
const n = parseInt(m[1]!, 16)
|
|
825
|
-
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/** Pick a black or white text color that has the better contrast against
|
|
829
|
-
* the given background. Uses the WCAG relative-luminance heuristic. */
|
|
830
|
-
export function pickContrastText(bgHex: string): string {
|
|
831
|
-
const rgb = hexToRgb(bgHex)
|
|
832
|
-
if (!rgb) return '#0f172a'
|
|
833
|
-
const lin = (c: number) => {
|
|
834
|
-
const s = c / 255
|
|
835
|
-
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
836
|
-
}
|
|
837
|
-
const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
|
|
838
|
-
return L > 0.5 ? '#0f172a' : '#ffffff'
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/** Pick the largest power of 10 that fits at the bottom of [min,max], and
|
|
842
|
-
* the smallest that covers the top, then enumerate decade boundaries. Used
|
|
843
|
-
* by log-scale axes (yScale: 'log'). */
|
|
844
|
-
export function niceLogScale(min: number, max: number): NiceScale {
|
|
845
|
-
// Only positive values are plottable on a log scale; callers should
|
|
846
|
-
// strip non-positive values before passing them in.
|
|
847
|
-
if (!Number.isFinite(min) || min <= 0) min = 1
|
|
848
|
-
if (!Number.isFinite(max) || max <= min) max = min * 10
|
|
849
|
-
const lo = Math.floor(Math.log10(min))
|
|
850
|
-
const hi = Math.ceil(Math.log10(max))
|
|
851
|
-
const ticks: number[] = []
|
|
852
|
-
for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
|
|
853
|
-
return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/** Map a value to a fractional position [0..1] across the axis domain.
|
|
857
|
-
* Pass the appropriate fn into projection code so linear / log share the
|
|
858
|
-
* same plumbing. Returns null for non-positive values on log. */
|
|
859
|
-
function project(value: number, min: number, max: number, isLog: boolean): number | null {
|
|
860
|
-
if (!Number.isFinite(value)) return null
|
|
861
|
-
if (isLog) {
|
|
862
|
-
if (value <= 0 || min <= 0) return null
|
|
863
|
-
return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
|
|
864
|
-
}
|
|
865
|
-
return (value - min) / (max - min)
|
|
866
|
-
}
|
|
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
|
|
107
|
+
export function chartStyleVars(style: ChartStyle | undefined): string {
|
|
108
|
+
if (!style) return ''
|
|
109
|
+
const out: string[] = []
|
|
110
|
+
if (style.background) out.push(`--sg-chart-bg:${style.background}`, `--sg-bg:${style.background}`)
|
|
111
|
+
if (style.textColor) out.push(`--sg-fg:${style.textColor}`, `--sg-muted:${style.textColor}`)
|
|
112
|
+
if (style.gridColor) out.push(`--sg-border:${style.gridColor}`)
|
|
113
|
+
if (style.fontSize && Number.isFinite(style.fontSize) && style.fontSize > 0) out.push(`--sg-chart-font-scale:${Math.round((style.fontSize / 12) * 1000) / 1000}`)
|
|
114
|
+
if (style.fontFamily) out.push(`font-family:${style.fontFamily}`)
|
|
115
|
+
return out.join(';')
|
|
880
116
|
}
|
|
881
117
|
|
|
882
118
|
/**
|
|
883
|
-
*
|
|
884
|
-
*
|
|
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.
|
|
119
|
+
* The responsive rules that apply at a rendered size, in spec order. Pure:
|
|
120
|
+
* `SvChart` uses it for the legend field, {@link resolveResponsive} for the
|
|
121
|
+
* spec patches.
|
|
898
122
|
*/
|
|
899
|
-
export function
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
924
|
-
// ---- Overlay math: trendline + moving averages -----------------------
|
|
925
|
-
|
|
926
|
-
/** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
|
|
927
|
-
* monotone cubic interpolation (preserves local extrema - no overshoots).
|
|
928
|
-
* Breaks the path at `defined === false` gaps. */
|
|
929
|
-
export function buildLinePath(
|
|
930
|
-
pts: Array<{ x: number; y: number; defined: boolean }>,
|
|
931
|
-
smooth: boolean,
|
|
932
|
-
): string {
|
|
933
|
-
if (!smooth) {
|
|
934
|
-
let path = ''
|
|
935
|
-
let pen = false
|
|
936
|
-
for (const p of pts) {
|
|
937
|
-
if (!p.defined) { pen = false; continue }
|
|
938
|
-
path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
|
|
939
|
-
pen = true
|
|
940
|
-
}
|
|
941
|
-
return path.trim()
|
|
942
|
-
}
|
|
943
|
-
// Group defined-only runs; each run is smoothed independently.
|
|
944
|
-
const runs: Array<Array<{ x: number; y: number }>> = []
|
|
945
|
-
let cur: Array<{ x: number; y: number }> = []
|
|
946
|
-
for (const p of pts) {
|
|
947
|
-
if (p.defined) cur.push({ x: p.x, y: p.y })
|
|
948
|
-
else if (cur.length) { runs.push(cur); cur = [] }
|
|
949
|
-
}
|
|
950
|
-
if (cur.length) runs.push(cur)
|
|
951
|
-
return runs.map(monotoneCubicPath).filter(Boolean).join(' ')
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
/** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
|
|
955
|
-
* Slope at each point chosen so the curve passes through every (xi, yi)
|
|
956
|
-
* AND stays monotonic between them; control points sit 1/3 of the way
|
|
957
|
-
* to the neighbours along that tangent. */
|
|
958
|
-
function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
|
|
959
|
-
const n = pts.length
|
|
960
|
-
if (n === 0) return ''
|
|
961
|
-
if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
|
|
962
|
-
if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
|
|
963
|
-
// Secant slopes between adjacent points.
|
|
964
|
-
const dx: number[] = new Array(n - 1)
|
|
965
|
-
const m: number[] = new Array(n - 1)
|
|
966
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
967
|
-
const d = pts[i + 1]!.x - pts[i]!.x
|
|
968
|
-
dx[i] = d
|
|
969
|
-
m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
|
|
970
|
-
}
|
|
971
|
-
// Tangent at each point: average of neighbouring slopes, with sign
|
|
972
|
-
// checks that flatten the tangent when slopes change sign.
|
|
973
|
-
const tan: number[] = new Array(n)
|
|
974
|
-
tan[0] = m[0]!
|
|
975
|
-
tan[n - 1] = m[n - 2]!
|
|
976
|
-
for (let i = 1; i < n - 1; i += 1) {
|
|
977
|
-
if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
|
|
978
|
-
else tan[i] = (m[i - 1]! + m[i]!) / 2
|
|
979
|
-
}
|
|
980
|
-
// Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
|
|
981
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
982
|
-
if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
|
|
983
|
-
const a = tan[i]! / m[i]!
|
|
984
|
-
const b = tan[i + 1]! / m[i]!
|
|
985
|
-
const h = Math.hypot(a, b)
|
|
986
|
-
if (h > 3) {
|
|
987
|
-
tan[i] = (3 / h) * a * m[i]!
|
|
988
|
-
tan[i + 1] = (3 / h) * b * m[i]!
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
// Build the Bezier path. Each segment: control points at 1/3 of dx.
|
|
992
|
-
let path = `M${pts[0]!.x},${pts[0]!.y}`
|
|
993
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
994
|
-
const h = dx[i]!
|
|
995
|
-
const c1x = pts[i]!.x + h / 3
|
|
996
|
-
const c1y = pts[i]!.y + (tan[i]! * h) / 3
|
|
997
|
-
const c2x = pts[i + 1]!.x - h / 3
|
|
998
|
-
const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
|
|
999
|
-
path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
|
|
1000
|
-
}
|
|
1001
|
-
return path
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
/** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
|
|
1005
|
-
* Returns the fitted value at each x index, or NaN where the source value
|
|
1006
|
-
* was non-finite. */
|
|
1007
|
-
export function linearTrend(values: number[]): number[] {
|
|
1008
|
-
let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
|
|
1009
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
1010
|
-
const y = values[i]!
|
|
1011
|
-
if (!Number.isFinite(y)) continue
|
|
1012
|
-
n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
|
|
1013
|
-
}
|
|
1014
|
-
if (n < 2) return values.map(() => NaN)
|
|
1015
|
-
const denom = n * sumXX - sumX * sumX
|
|
1016
|
-
if (denom === 0) return values.map(() => sumY / n)
|
|
1017
|
-
const slope = (n * sumXY - sumX * sumY) / denom
|
|
1018
|
-
const intercept = (sumY - slope * sumX) / n
|
|
1019
|
-
return values.map((_, i) => slope * i + intercept)
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
/** Simple moving average over a window of `period` values. Window centres
|
|
1023
|
-
* trail to the right (typical for time-series). NaN for points before the
|
|
1024
|
-
* window is full. */
|
|
1025
|
-
export function simpleMovingAverage(values: number[], period: number): number[] {
|
|
1026
|
-
if (period < 1) return values.slice()
|
|
1027
|
-
const out: number[] = new Array(values.length).fill(NaN)
|
|
1028
|
-
let sum = 0, count = 0
|
|
1029
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
1030
|
-
const v = values[i]!
|
|
1031
|
-
if (Number.isFinite(v)) { sum += v; count += 1 }
|
|
1032
|
-
if (i >= period) {
|
|
1033
|
-
const drop = values[i - period]!
|
|
1034
|
-
if (Number.isFinite(drop)) { sum -= drop; count -= 1 }
|
|
1035
|
-
}
|
|
1036
|
-
if (i >= period - 1 && count > 0) out[i] = sum / count
|
|
1037
|
-
}
|
|
1038
|
-
return out
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
|
|
1042
|
-
export function exponentialMovingAverage(values: number[], period: number): number[] {
|
|
1043
|
-
const alpha = 2 / (Math.max(1, period) + 1)
|
|
1044
|
-
const out: number[] = new Array(values.length).fill(NaN)
|
|
1045
|
-
let prev: number | null = null
|
|
1046
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
1047
|
-
const v = values[i]!
|
|
1048
|
-
if (!Number.isFinite(v)) { out[i] = prev ?? NaN; continue }
|
|
1049
|
-
prev = prev == null ? v : alpha * v + (1 - alpha) * prev
|
|
1050
|
-
out[i] = prev
|
|
1051
|
-
}
|
|
1052
|
-
return out
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
/** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
|
|
1056
|
-
export function computeOverlay(values: number[], spec: SeriesOverlay): number[] {
|
|
1057
|
-
if (spec === 'linear') return linearTrend(values)
|
|
1058
|
-
const m = /^(sma|ema):(\d+)$/.exec(spec)
|
|
1059
|
-
if (!m) return values.map(() => NaN)
|
|
1060
|
-
const period = Number(m[2])
|
|
1061
|
-
return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period)
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
/** Round a [min,max] domain out to nice tick boundaries. */
|
|
1065
|
-
export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
|
|
1066
|
-
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
1067
|
-
min = 0
|
|
1068
|
-
max = 1
|
|
1069
|
-
}
|
|
1070
|
-
if (min === max) {
|
|
1071
|
-
if (min === 0) max = 1
|
|
1072
|
-
else {
|
|
1073
|
-
min = Math.min(0, min)
|
|
1074
|
-
max = Math.max(0, max)
|
|
1075
|
-
}
|
|
1076
|
-
if (min === max) max = min + 1
|
|
1077
|
-
}
|
|
1078
|
-
const range = niceNum(max - min, false)
|
|
1079
|
-
const step = niceNum(range / Math.max(1, tickCount), true)
|
|
1080
|
-
const nMin = Math.floor(min / step) * step
|
|
1081
|
-
const nMax = Math.ceil(max / step) * step
|
|
1082
|
-
const ticks: number[] = []
|
|
1083
|
-
for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
|
|
1084
|
-
return { min: nMin, max: nMax, step, ticks }
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
function fmtTick(n: number): string {
|
|
1088
|
-
const abs = Math.abs(n)
|
|
1089
|
-
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
|
|
1090
|
-
if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
|
|
1091
|
-
return String(Math.round(n * 100) / 100)
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
/** Value-axis / tooltip / label number format. */
|
|
1095
|
-
export type ChartValueFormat = 'number' | 'currency' | 'percent' | 'compact'
|
|
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
|
-
|
|
1101
|
-
/**
|
|
1102
|
-
* Format a numeric value for display, honouring an optional `valueFormat`.
|
|
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.
|
|
1114
|
-
*/
|
|
1115
|
-
export function formatChartValue(
|
|
1116
|
-
n: number,
|
|
1117
|
-
format?: ChartValueFormat,
|
|
1118
|
-
opts?: ChartFormatLocale,
|
|
1119
|
-
): string {
|
|
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
|
-
}
|
|
1137
|
-
if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
|
|
1138
|
-
if (format === 'percent') {
|
|
1139
|
-
const p = n * 100
|
|
1140
|
-
return `${Math.round(p * 10) / 10}%`
|
|
1141
|
-
}
|
|
1142
|
-
return fmtTick(n)
|
|
123
|
+
export function matchResponsiveRules(rules: ChartResponsiveRule[] | undefined, width: number, height: number): ChartResponsiveRule[] {
|
|
124
|
+
if (!rules?.length) return []
|
|
125
|
+
return rules.filter(
|
|
126
|
+
(r) =>
|
|
127
|
+
(r.maxWidth == null || width <= r.maxWidth) &&
|
|
128
|
+
(r.minWidth == null || width >= r.minWidth) &&
|
|
129
|
+
(r.maxHeight == null || height <= r.maxHeight),
|
|
130
|
+
)
|
|
1143
131
|
}
|
|
1144
132
|
|
|
1145
|
-
const DAY = 86_400_000
|
|
1146
|
-
/** Nice date-tick timestamps across [min, max]. */
|
|
1147
|
-
function dateTicks(tMin: number, tMax: number): number[] {
|
|
1148
|
-
const span = tMax - tMin
|
|
1149
|
-
const step =
|
|
1150
|
-
span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY
|
|
1151
|
-
const ticks: number[] = []
|
|
1152
|
-
for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step) ticks.push(t)
|
|
1153
|
-
if (!ticks.length) ticks.push(tMin, tMax)
|
|
1154
|
-
return ticks
|
|
1155
|
-
}
|
|
1156
133
|
/**
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
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.
|
|
134
|
+
* Apply `spec.responsive` for a rendered size: every matching rule's `spec`
|
|
135
|
+
* is merged over the input in order (later wins), the three axis objects one
|
|
136
|
+
* level deep. Returns the input itself when nothing matches, so callers can
|
|
137
|
+
* compare by identity. `width`, `height` and `responsive` in a rule are
|
|
138
|
+
* ignored; the size is the caller's.
|
|
1166
139
|
*/
|
|
1167
|
-
export function
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
|
140
|
+
export function resolveResponsive(spec: ChartSpec, width: number, height: number): ChartSpec {
|
|
141
|
+
const hits = matchResponsiveRules(spec.responsive, width, height)
|
|
142
|
+
if (!hits.length) return spec
|
|
143
|
+
let out: ChartSpec = spec
|
|
144
|
+
for (const rule of hits) {
|
|
145
|
+
if (!rule.spec) continue
|
|
146
|
+
const { width: _w, height: _h, responsive: _r, ...patch } = rule.spec
|
|
147
|
+
const merged: ChartSpec = { ...out, ...patch }
|
|
148
|
+
for (const k of ['xAxis', 'yAxis', 'y2Axis'] as const) {
|
|
149
|
+
if (patch[k]) merged[k] = { ...(out[k] ?? {}), ...patch[k] }
|
|
1185
150
|
}
|
|
1186
|
-
|
|
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)
|
|
151
|
+
out = merged
|
|
1199
152
|
}
|
|
1200
153
|
return out
|
|
1201
154
|
}
|
|
1202
155
|
|
|
1203
|
-
function fmtDate(t: number, span: number): string {
|
|
1204
|
-
const d = new Date(t)
|
|
1205
|
-
if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
1206
|
-
if (span <= 800 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
|
|
1207
|
-
return String(d.getFullYear())
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
type ResolvedSeries = ChartSeries & {
|
|
1211
|
-
color: string
|
|
1212
|
-
kind: 'bar' | 'line' | 'area' | 'candle' | 'box'
|
|
1213
|
-
axis: 'left' | 'right'
|
|
1214
|
-
}
|
|
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
|
-
|
|
1232
|
-
/** Data domain for one axis, honoring stacking of its bar/area series.
|
|
1233
|
-
* When `isLog` is true, non-positive values are discarded (log undefined)
|
|
1234
|
-
* and the domain is rounded to decade boundaries instead of nice steps. */
|
|
1235
|
-
function axisDomain(
|
|
1236
|
-
list: ResolvedSeries[],
|
|
1237
|
-
categories: string[],
|
|
1238
|
-
stacked: boolean,
|
|
1239
|
-
extra: number[] = [],
|
|
1240
|
-
isLog = false,
|
|
1241
|
-
): NiceScale {
|
|
1242
|
-
let dMin = Infinity
|
|
1243
|
-
let dMax = -Infinity
|
|
1244
|
-
const note = (v: number) => {
|
|
1245
|
-
if (!Number.isFinite(v)) return
|
|
1246
|
-
if (isLog && v <= 0) return
|
|
1247
|
-
if (v < dMin) dMin = v
|
|
1248
|
-
if (v > dMax) dMax = v
|
|
1249
|
-
}
|
|
1250
|
-
for (const v of extra) note(v)
|
|
1251
|
-
const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area')
|
|
1252
|
-
const lines = list.filter((s) => s.kind === 'line')
|
|
1253
|
-
if (stacked && stackable.length) {
|
|
1254
|
-
for (let i = 0; i < categories.length; i += 1) {
|
|
1255
|
-
let pos = 0
|
|
1256
|
-
let neg = 0
|
|
1257
|
-
for (const s of stackable) {
|
|
1258
|
-
const v = s.values[i] ?? 0
|
|
1259
|
-
if (v >= 0) pos += v
|
|
1260
|
-
else neg += v
|
|
1261
|
-
}
|
|
1262
|
-
note(pos)
|
|
1263
|
-
note(neg)
|
|
1264
|
-
}
|
|
1265
|
-
} else {
|
|
1266
|
-
for (const s of stackable) for (const v of s.values) note(v)
|
|
1267
|
-
}
|
|
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
|
-
}
|
|
1306
|
-
if (dMin === Infinity) {
|
|
1307
|
-
dMin = isLog ? 1 : 0
|
|
1308
|
-
dMax = isLog ? 10 : 1
|
|
1309
|
-
}
|
|
1310
|
-
// Bar / area charts read against a zero baseline, so always include 0
|
|
1311
|
-
// - but only on linear axes (0 is invalid in log).
|
|
1312
|
-
if (stackable.length && !isLog) {
|
|
1313
|
-
dMin = Math.min(dMin, 0)
|
|
1314
|
-
dMax = Math.max(dMax, 0)
|
|
1315
|
-
}
|
|
1316
|
-
return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax)
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
156
|
/**
|
|
1320
157
|
* Lay out a {@link ChartSpec} into renderable {@link ChartGeometry} - scales,
|
|
1321
158
|
* ticks, and the position of every bar, line, slice and dot. Pure: no DOM, so
|
|
1322
159
|
* it runs during SSR and can be unit-tested directly.
|
|
1323
160
|
*/
|
|
1324
|
-
export function buildChart(
|
|
1325
|
-
const width =
|
|
1326
|
-
const height =
|
|
161
|
+
export function buildChart(input: ChartSpec, theme: 'light' | 'dark' = 'light'): ChartGeometry {
|
|
162
|
+
const width = input.width ?? 520
|
|
163
|
+
const height = input.height ?? 300
|
|
164
|
+
// Size rules first, so every family sees the patched spec.
|
|
165
|
+
const spec = resolveResponsive(input, width, height)
|
|
166
|
+
// A pareto is a bar chart with a cumulative-share line on the right axis,
|
|
167
|
+
// sorted by value. Build that spec and lay it out as the combo it is.
|
|
168
|
+
if (spec.type === 'pareto') return buildChart(paretoSpec(spec), theme)
|
|
1327
169
|
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1328
170
|
|
|
1329
171
|
const stacked = !!(spec.stacked || spec.stacked100)
|
|
1330
172
|
const series: ResolvedSeries[] = spec.series.map((s, i) => ({
|
|
1331
173
|
...s,
|
|
174
|
+
// A null is a gap unless the series (or the spec) says it is a zero. Done
|
|
175
|
+
// here, once, so every family and every overlay sees the same numbers.
|
|
176
|
+
values: (s.nullAs ?? spec.nullAs) === 'zero' ? s.values.map((v) => (Number.isFinite(v) ? v : 0)) : s.values,
|
|
1332
177
|
color: s.color ?? palette[i % palette.length]!,
|
|
1333
178
|
kind: kindOf(s, spec.type),
|
|
1334
179
|
axis: s.axis ?? 'left',
|
|
1335
180
|
}))
|
|
181
|
+
const frame = chartFrame(spec, width, height)
|
|
182
|
+
const axes = resolveAxes(spec)
|
|
1336
183
|
const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
|
|
1337
184
|
|
|
1338
185
|
const empty: ChartGeometry = {
|
|
@@ -1341,10 +188,21 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1341
188
|
height,
|
|
1342
189
|
plot: { x: 0, y: 0, w: width, h: height },
|
|
1343
190
|
axes: null,
|
|
191
|
+
frame,
|
|
192
|
+
grid: { x: false, y: false },
|
|
193
|
+
referenceBands: [],
|
|
1344
194
|
bars: [],
|
|
1345
195
|
candles: [],
|
|
1346
196
|
boxes: [],
|
|
1347
197
|
errorBars: [],
|
|
198
|
+
stems: [],
|
|
199
|
+
arcs: [],
|
|
200
|
+
chordRibbons: [],
|
|
201
|
+
bullets: [],
|
|
202
|
+
drawings: [],
|
|
203
|
+
seriesLabels: [],
|
|
204
|
+
polarAxes: [],
|
|
205
|
+
polarRings: [],
|
|
1348
206
|
lines: [],
|
|
1349
207
|
slices: [],
|
|
1350
208
|
yTicks: [],
|
|
@@ -1353,6 +211,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1353
211
|
xTicks: [],
|
|
1354
212
|
categoryGroupTicks: [],
|
|
1355
213
|
xLabelRotated: false,
|
|
214
|
+
xLabelAngle: 0,
|
|
1356
215
|
legend,
|
|
1357
216
|
donut: null,
|
|
1358
217
|
referenceLines: [],
|
|
@@ -1381,1437 +240,29 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
1381
240
|
sankeyLinks: [],
|
|
1382
241
|
}
|
|
1383
242
|
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
if (spec.type === '
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
const padR = 12
|
|
1401
|
-
const padT = 10
|
|
1402
|
-
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
|
|
1403
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1404
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1405
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1406
|
-
|
|
1407
|
-
// Compute the running cumulative + per-bar (from, to) pairs.
|
|
1408
|
-
const totals = spec.waterfallTotals ?? []
|
|
1409
|
-
const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
|
|
1410
|
-
let cum = 0
|
|
1411
|
-
spec.categories.forEach((_, i) => {
|
|
1412
|
-
const v = src.values[i] ?? 0
|
|
1413
|
-
const isTotal = !!totals[i]
|
|
1414
|
-
if (isTotal) {
|
|
1415
|
-
pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
|
|
1416
|
-
} else {
|
|
1417
|
-
pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
|
|
1418
|
-
cum += v
|
|
1419
|
-
}
|
|
1420
|
-
})
|
|
1421
|
-
// Y-axis domain spans every visited level (including 0).
|
|
1422
|
-
let dMin = 0, dMax = 0
|
|
1423
|
-
for (const p of pairs) {
|
|
1424
|
-
if (p.from < dMin) dMin = p.from
|
|
1425
|
-
if (p.to < dMin) dMin = p.to
|
|
1426
|
-
if (p.from > dMax) dMax = p.from
|
|
1427
|
-
if (p.to > dMax) dMax = p.to
|
|
1428
|
-
}
|
|
1429
|
-
const dom = niceScale(dMin, dMax)
|
|
1430
|
-
const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
|
|
1431
|
-
|
|
1432
|
-
const slotW = plotW / Math.max(1, spec.categories.length)
|
|
1433
|
-
const barPad = slotW * 0.2
|
|
1434
|
-
const barW = Math.max(1, slotW - barPad)
|
|
1435
|
-
const bars: ChartBar[] = pairs.map((p, i) => {
|
|
1436
|
-
const yTop = yOfW(Math.max(p.from, p.to))
|
|
1437
|
-
const yBot = yOfW(Math.min(p.from, p.to))
|
|
1438
|
-
const x = padL + slotW * i + barPad / 2
|
|
1439
|
-
const color = p.isTotal ? total : p.value >= 0 ? positive : negative
|
|
1440
|
-
return {
|
|
1441
|
-
x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
|
|
1442
|
-
color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
|
|
1443
|
-
}
|
|
1444
|
-
})
|
|
1445
|
-
// Thin connector lines between bar tops -> running total reads cleanly.
|
|
1446
|
-
const connectors: ChartLine[] = [{
|
|
1447
|
-
path: pairs
|
|
1448
|
-
.map((p, i) => {
|
|
1449
|
-
const x0 = padL + slotW * i + barPad / 2 + barW
|
|
1450
|
-
const y = yOfW(p.to)
|
|
1451
|
-
const x1 = padL + slotW * (i + 1) + barPad / 2
|
|
1452
|
-
// Skip the final connector beyond the last bar.
|
|
1453
|
-
return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
|
|
1454
|
-
})
|
|
1455
|
-
.filter(Boolean)
|
|
1456
|
-
.join(' '),
|
|
1457
|
-
areaPath: '',
|
|
1458
|
-
color: 'var(--sg-muted, #94a3b8)',
|
|
1459
|
-
label: '',
|
|
1460
|
-
points: [],
|
|
1461
|
-
}]
|
|
1462
|
-
const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
|
|
1463
|
-
label,
|
|
1464
|
-
x: round(padL + slotW * i + slotW / 2),
|
|
1465
|
-
}))
|
|
1466
|
-
const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
|
|
1467
|
-
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
|
|
1468
|
-
}))
|
|
1469
|
-
return {
|
|
1470
|
-
...empty,
|
|
1471
|
-
plot,
|
|
1472
|
-
bars,
|
|
1473
|
-
lines: connectors,
|
|
1474
|
-
yTicks,
|
|
1475
|
-
xTicks,
|
|
1476
|
-
xLabelRotated,
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
// ---- Funnel -------------------------------------------------------
|
|
1481
|
-
// One series of strictly-decreasing values gets rendered as a stack
|
|
1482
|
-
// of horizontal trapezoids: each level's width is proportional to its
|
|
1483
|
-
// value relative to the largest, slope automatically links level N+1
|
|
1484
|
-
// narrower than level N. Labels show value, conversion vs. top, and
|
|
1485
|
-
// step drop-off.
|
|
1486
|
-
if (spec.type === 'funnel') {
|
|
1487
|
-
const src = series[0]
|
|
1488
|
-
if (!src || !src.values.length) return { ...empty }
|
|
1489
|
-
const padL = 20, padR = 20, padT = 16, padB = 16
|
|
1490
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1491
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1492
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1493
|
-
const n = src.values.length
|
|
1494
|
-
const stepH = plotH / n
|
|
1495
|
-
const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
|
|
1496
|
-
const top = src.values[0] ?? 0
|
|
1497
|
-
const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
|
|
1498
|
-
const cx = padL + plotW / 2
|
|
1499
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1500
|
-
const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
|
|
1501
|
-
const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
|
|
1502
|
-
const w0 = widthAt(v)
|
|
1503
|
-
const w1 = widthAt(next)
|
|
1504
|
-
const y0 = padT + stepH * i
|
|
1505
|
-
const y1 = y0 + stepH
|
|
1506
|
-
const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
|
|
1507
|
-
const color = src.color ?? palette[i % palette.length]!
|
|
1508
|
-
return {
|
|
1509
|
-
path, color,
|
|
1510
|
-
label: spec.categories[i] ?? src.label,
|
|
1511
|
-
value: v,
|
|
1512
|
-
conversion: top > 0 ? v / top : 0,
|
|
1513
|
-
dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
|
|
1514
|
-
cx,
|
|
1515
|
-
cy: (y0 + y1) / 2,
|
|
1516
|
-
textColor: pickContrastText(color),
|
|
1517
|
-
}
|
|
1518
|
-
})
|
|
1519
|
-
return {
|
|
1520
|
-
...empty,
|
|
1521
|
-
plot,
|
|
1522
|
-
funnelSegments: segments,
|
|
1523
|
-
}
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
// ---- Radar --------------------------------------------------------
|
|
1527
|
-
// Polar coordinates: each `category` is a spoke (axis); each `series`
|
|
1528
|
-
// contributes a polygon connecting its values across the spokes. All
|
|
1529
|
-
// series share the same scale (max across every value). Concentric
|
|
1530
|
-
// ring count derived from data, capped at 5 for legibility.
|
|
1531
|
-
if (spec.type === 'radar') {
|
|
1532
|
-
if (!series.length || !spec.categories.length) return { ...empty }
|
|
1533
|
-
const padL = 30, padR = 30, padT = 24, padB = 24
|
|
1534
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1535
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1536
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1537
|
-
const cx = padL + plotW / 2
|
|
1538
|
-
const cy = padT + plotH / 2
|
|
1539
|
-
const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20)
|
|
1540
|
-
const axes = spec.categories
|
|
1541
|
-
const k = axes.length
|
|
1542
|
-
let vMax = 0
|
|
1543
|
-
for (const s of series) for (const v of s.values) {
|
|
1544
|
-
if (Number.isFinite(v) && v > vMax) vMax = v
|
|
1545
|
-
}
|
|
1546
|
-
if (vMax === 0) vMax = 1
|
|
1547
|
-
const ringCount = 5
|
|
1548
|
-
const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax)
|
|
1549
|
-
/** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
|
|
1550
|
-
* proceed clockwise so axes lay out left-to-right when k <= 4. */
|
|
1551
|
-
const angleAt = (i: number) => -Math.PI / 2 + (i / k) * Math.PI * 2
|
|
1552
|
-
const pointAt = (i: number, v: number) => {
|
|
1553
|
-
const t = v / vMax
|
|
1554
|
-
const a = angleAt(i)
|
|
1555
|
-
return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) }
|
|
1556
|
-
}
|
|
1557
|
-
const radarAxes: ChartRadarAxis[] = axes.map((label, i) => {
|
|
1558
|
-
const p = pointAt(i, vMax)
|
|
1559
|
-
return { label, x: p.x, y: p.y }
|
|
1560
|
-
})
|
|
1561
|
-
const radarSeriesGeo: ChartRadarSeries[] = series.map((s, si) => {
|
|
1562
|
-
const pts = s.values.map((v, i) => {
|
|
1563
|
-
const safe = Number.isFinite(v) ? v : 0
|
|
1564
|
-
const p = pointAt(i, safe)
|
|
1565
|
-
return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' }
|
|
1566
|
-
})
|
|
1567
|
-
const path = pts.length
|
|
1568
|
-
? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
|
|
1569
|
-
: ''
|
|
1570
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1571
|
-
return { label: s.label, color: s.color ?? palette[si % palette.length]!, path, points: pts }
|
|
1572
|
-
})
|
|
1573
|
-
return {
|
|
1574
|
-
...empty,
|
|
1575
|
-
plot,
|
|
1576
|
-
radarRings: ringValues,
|
|
1577
|
-
radarAxes,
|
|
1578
|
-
radarSeries: radarSeriesGeo,
|
|
1579
|
-
radarCenter: { cx, cy, r },
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
// ---- Calendar heatmap --------------------------------------------
|
|
1584
|
-
// GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
|
|
1585
|
-
// cell is a small square shaded by `calendarValues[i].value` via the
|
|
1586
|
-
// sequential color scale. Days with no value render blank (border only)
|
|
1587
|
-
// so missing data is visually obvious.
|
|
1588
|
-
if (spec.type === 'calendar') {
|
|
1589
|
-
const values = spec.calendarValues ?? []
|
|
1590
|
-
if (!values.length && !spec.calendarStart) return { ...empty }
|
|
1591
|
-
// Build a value lookup + figure out the date range.
|
|
1592
|
-
const valueByDate = new Map<string, number>()
|
|
1593
|
-
let vMin = Infinity, vMax = -Infinity
|
|
1594
|
-
for (const v of values) {
|
|
1595
|
-
valueByDate.set(v.date, v.value)
|
|
1596
|
-
if (Number.isFinite(v.value)) {
|
|
1597
|
-
if (v.value < vMin) vMin = v.value
|
|
1598
|
-
if (v.value > vMax) vMax = v.value
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
if (vMin === Infinity) { vMin = 0; vMax = 1 }
|
|
1602
|
-
if (vMin === vMax) vMax = vMin + 1
|
|
1603
|
-
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
|
|
1604
|
-
const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
|
|
1605
|
-
// Determine date range. If calendarStart/End set, use them, otherwise
|
|
1606
|
-
// span the data + round to whole weeks (Sun..Sat).
|
|
1607
|
-
const sorted = values.map((v) => v.date).sort()
|
|
1608
|
-
const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
|
|
1609
|
-
const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
|
|
1610
|
-
const start = new Date(startStr + 'T00:00:00Z')
|
|
1611
|
-
const end = new Date(endStr + 'T00:00:00Z')
|
|
1612
|
-
// Roll start back to its Sunday, end forward to its Saturday.
|
|
1613
|
-
start.setUTCDate(start.getUTCDate() - start.getUTCDay())
|
|
1614
|
-
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
|
|
1615
|
-
const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
|
|
1616
|
-
const weeks = Math.ceil(totalDays / 7)
|
|
1617
|
-
const padL = 36, padR = 80, padT = 26, padB = 16
|
|
1618
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1619
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1620
|
-
// Cell size: fit weeks across width, 7 rows down height.
|
|
1621
|
-
const cellW = Math.floor(plotW / weeks)
|
|
1622
|
-
const cellH = Math.floor(plotH / 7)
|
|
1623
|
-
const cellSize = Math.max(6, Math.min(cellW, cellH))
|
|
1624
|
-
const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
|
|
1625
|
-
const cells: ChartCalendarCell[] = []
|
|
1626
|
-
let lastMonth = -1
|
|
1627
|
-
const monthTicks: ChartCategoryTick[] = []
|
|
1628
|
-
for (let i = 0; i < totalDays; i += 1) {
|
|
1629
|
-
const day = new Date(start.getTime() + i * 86_400_000)
|
|
1630
|
-
const col = Math.floor(i / 7)
|
|
1631
|
-
const row = i % 7
|
|
1632
|
-
const date = day.toISOString().slice(0, 10)
|
|
1633
|
-
const has = valueByDate.has(date)
|
|
1634
|
-
const v = valueByDate.get(date) ?? 0
|
|
1635
|
-
cells.push({
|
|
1636
|
-
x: padL + col * cellSize,
|
|
1637
|
-
y: padT + row * cellSize,
|
|
1638
|
-
size: cellSize,
|
|
1639
|
-
date, value: v,
|
|
1640
|
-
defined: has,
|
|
1641
|
-
color: has ? colorAt(v) : 'transparent',
|
|
1642
|
-
})
|
|
1643
|
-
if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
|
|
1644
|
-
lastMonth = day.getUTCMonth()
|
|
1645
|
-
monthTicks.push({
|
|
1646
|
-
label: day.toLocaleDateString(undefined, { month: 'short' }),
|
|
1647
|
-
x: padL + col * cellSize,
|
|
1648
|
-
})
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
const legend = Array.from({ length: 5 }, (_, i) => {
|
|
1652
|
-
const t = i / 4
|
|
1653
|
-
const value = vMin + (vMax - vMin) * t
|
|
1654
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
|
|
1655
|
-
})
|
|
1656
|
-
return {
|
|
1657
|
-
...empty,
|
|
1658
|
-
plot,
|
|
1659
|
-
calendarCells: cells,
|
|
1660
|
-
calendarMonthTicks: monthTicks,
|
|
1661
|
-
calendarLegend: legend,
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
// ---- Gauge --------------------------------------------------------
|
|
1666
|
-
// Semicircle dial: track arc + value arc + optional colored range bands
|
|
1667
|
-
// + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
|
|
1668
|
-
if (spec.type === 'gauge') {
|
|
1669
|
-
const min = spec.gaugeMin ?? 0
|
|
1670
|
-
const max = spec.gaugeMax ?? 100
|
|
1671
|
-
const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0))
|
|
1672
|
-
const target = spec.gaugeTarget
|
|
1673
|
-
const cx = width / 2
|
|
1674
|
-
const cy = height * 0.78
|
|
1675
|
-
const r = Math.min(width * 0.42, height * 0.65)
|
|
1676
|
-
// Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
|
|
1677
|
-
const A0 = Math.PI
|
|
1678
|
-
const A1 = 2 * Math.PI
|
|
1679
|
-
const angleAt = (v: number) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0)
|
|
1680
|
-
const arc = (a0: number, a1: number, radius: number): string => {
|
|
1681
|
-
const x1 = cx + radius * Math.cos(a0)
|
|
1682
|
-
const y1 = cy + radius * Math.sin(a0)
|
|
1683
|
-
const x2 = cx + radius * Math.cos(a1)
|
|
1684
|
-
const y2 = cy + radius * Math.sin(a1)
|
|
1685
|
-
const large = a1 - a0 > Math.PI ? 1 : 0
|
|
1686
|
-
return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`
|
|
1687
|
-
}
|
|
1688
|
-
const trackPath = arc(A0, A1, r)
|
|
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.
|
|
1695
|
-
const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
|
|
1696
|
-
path: arc(angleAt(band.from), angleAt(band.to), r - 16),
|
|
1697
|
-
color: band.color, from: band.from, to: band.to,
|
|
1698
|
-
}))
|
|
1699
|
-
let targetPx: ChartGaugeLayout['target'] = null
|
|
1700
|
-
if (target != null && Number.isFinite(target)) {
|
|
1701
|
-
const a = angleAt(Math.max(min, Math.min(max, target)))
|
|
1702
|
-
const inner = r - 12
|
|
1703
|
-
const outer = r + 4
|
|
1704
|
-
targetPx = {
|
|
1705
|
-
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
1706
|
-
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
// Tick marks just outside the track: a major tick every 1/4 of the scale,
|
|
1710
|
-
// with 4 minor ticks between each. Gives the dial a measured, instrument feel.
|
|
1711
|
-
const ticks: ChartGaugeLayout['ticks'] = []
|
|
1712
|
-
const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR
|
|
1713
|
-
for (let i = 0; i <= TICK_TOTAL; i++) {
|
|
1714
|
-
const a = A0 + (i / TICK_TOTAL) * (A1 - A0)
|
|
1715
|
-
const major = i % TICK_MINOR === 0
|
|
1716
|
-
const inner = r + 9
|
|
1717
|
-
const outer = r + (major ? 17 : 13)
|
|
1718
|
-
ticks.push({
|
|
1719
|
-
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
1720
|
-
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
1721
|
-
major,
|
|
1722
|
-
})
|
|
1723
|
-
}
|
|
1724
|
-
// Pointer needle: a kite (long tip toward the value, short counterweight
|
|
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.
|
|
1734
|
-
const aV = angleAt(value)
|
|
1735
|
-
const tipR = r - 14, tailR = 5, baseR = 9
|
|
1736
|
-
const aPerp = aV + Math.PI / 2
|
|
1737
|
-
const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
|
|
1738
|
-
const needlePath =
|
|
1739
|
-
`M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
|
|
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.
|
|
1746
|
-
let valueColor: string | null = null
|
|
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
|
|
1755
|
-
}
|
|
1756
|
-
return {
|
|
1757
|
-
...empty,
|
|
1758
|
-
plot: { x: 0, y: 0, w: width, h: height },
|
|
1759
|
-
gauge: {
|
|
1760
|
-
cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
|
|
1761
|
-
ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
|
|
1762
|
-
minLabel: { x: cx - r, y: cy + 20 },
|
|
1763
|
-
maxLabel: { x: cx + r, y: cy + 20 },
|
|
1764
|
-
value, min, max, unit: spec.gaugeUnit ?? '',
|
|
1765
|
-
},
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
// ---- Tree-map -----------------------------------------------------
|
|
1770
|
-
// Squarified tree-map (Bruls et al. 2000): each level recursively
|
|
1771
|
-
// partitions its rectangle in proportion to its children, picking the
|
|
1772
|
-
// split orientation that keeps aspect ratios closest to 1.
|
|
1773
|
-
if (spec.type === 'treemap') {
|
|
1774
|
-
const root = spec.treemap
|
|
1775
|
-
if (!root) return { ...empty }
|
|
1776
|
-
const padL = 4, padR = 4, padT = 4, padB = 4
|
|
1777
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1778
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1779
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1780
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1781
|
-
const cells: ChartTreemapCell[] = []
|
|
1782
|
-
function totalOf(n: TreeNode): number {
|
|
1783
|
-
if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
|
|
1784
|
-
return Math.max(0, n.value ?? 0)
|
|
1785
|
-
}
|
|
1786
|
-
function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
|
|
1787
|
-
if (!items.length || w <= 0 || h <= 0) return
|
|
1788
|
-
const totals = items.map(totalOf)
|
|
1789
|
-
const sum = totals.reduce((a, b) => a + b, 0)
|
|
1790
|
-
if (sum <= 0) return
|
|
1791
|
-
// Process largest-first so big items dominate the first row.
|
|
1792
|
-
const ordered = items
|
|
1793
|
-
.map((n, i) => ({ node: n, value: totals[i]! }))
|
|
1794
|
-
.sort((a, b) => b.value - a.value)
|
|
1795
|
-
let cx = x, cy = y, cw = w, ch = h, remaining = sum
|
|
1796
|
-
let row: typeof ordered = []
|
|
1797
|
-
const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
|
|
1798
|
-
if (rowSum <= 0) return Infinity
|
|
1799
|
-
const rowArea = rowSum * scale
|
|
1800
|
-
const rowSide = rowArea / shortSide
|
|
1801
|
-
let worst = 0
|
|
1802
|
-
for (const v of vals) {
|
|
1803
|
-
const cell = v * scale
|
|
1804
|
-
const long = cell / rowSide
|
|
1805
|
-
const r = Math.max(shortSide / long, long / shortSide)
|
|
1806
|
-
if (r > worst) worst = r
|
|
1807
|
-
}
|
|
1808
|
-
return worst
|
|
1809
|
-
}
|
|
1810
|
-
function flushRow() {
|
|
1811
|
-
if (!row.length) return
|
|
1812
|
-
const rowSum = row.reduce((a, b) => a + b.value, 0)
|
|
1813
|
-
const scale = (cw * ch) / remaining
|
|
1814
|
-
const horizontal = cw >= ch
|
|
1815
|
-
const shortSide = horizontal ? ch : cw
|
|
1816
|
-
const rowSide = (rowSum * scale) / shortSide
|
|
1817
|
-
let offset = 0
|
|
1818
|
-
for (const it of row) {
|
|
1819
|
-
const cellSize = (it.value * scale) / rowSide
|
|
1820
|
-
const cx2 = horizontal ? cx : cx + offset
|
|
1821
|
-
const cy2 = horizontal ? cy + offset : cy
|
|
1822
|
-
const ww = horizontal ? rowSide : cellSize
|
|
1823
|
-
const hh = horizontal ? cellSize : rowSide
|
|
1824
|
-
const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
|
|
1825
|
-
// Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
|
|
1826
|
-
if (it.node.children?.length) {
|
|
1827
|
-
cells.push({
|
|
1828
|
-
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
1829
|
-
color, textColor: pickContrastText(color),
|
|
1830
|
-
name: it.node.name, value: it.value, depth,
|
|
1831
|
-
})
|
|
1832
|
-
const labelH = Math.min(18, hh * 0.25)
|
|
1833
|
-
squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
|
|
1834
|
-
} else {
|
|
1835
|
-
cells.push({
|
|
1836
|
-
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
1837
|
-
color, textColor: pickContrastText(color),
|
|
1838
|
-
name: it.node.name, value: it.value, depth,
|
|
1839
|
-
})
|
|
1840
|
-
}
|
|
1841
|
-
offset += cellSize
|
|
1842
|
-
}
|
|
1843
|
-
// Shrink the remaining strip.
|
|
1844
|
-
if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
|
|
1845
|
-
remaining -= rowSum
|
|
1846
|
-
row = []
|
|
1847
|
-
}
|
|
1848
|
-
for (const it of ordered) {
|
|
1849
|
-
const scale = (cw * ch) / remaining
|
|
1850
|
-
const shortSide = Math.min(cw, ch)
|
|
1851
|
-
const rowSum = row.reduce((a, b) => a + b.value, 0)
|
|
1852
|
-
const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
|
|
1853
|
-
const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
|
|
1854
|
-
if (row.length && nextWorst > currWorst) {
|
|
1855
|
-
flushRow()
|
|
1856
|
-
}
|
|
1857
|
-
row.push(it)
|
|
1858
|
-
}
|
|
1859
|
-
flushRow()
|
|
1860
|
-
}
|
|
1861
|
-
const seedItems = root.children ?? [root]
|
|
1862
|
-
squarify(seedItems, padL, padT, plotW, plotH, 0)
|
|
1863
|
-
return { ...empty, plot, treemapCells: cells }
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
// ---- Sankey -------------------------------------------------------
|
|
1867
|
-
// Multi-column flow layout. Each node assigned to a column by longest
|
|
1868
|
-
// path from any source. Within a column, nodes are stacked vertically;
|
|
1869
|
-
// height proportional to max(totalIn, totalOut). Links render as
|
|
1870
|
-
// bezier ribbons whose width is the link value (in pixels).
|
|
1871
|
-
if (spec.type === 'sankey') {
|
|
1872
|
-
const nodes = spec.sankeyNodes ?? []
|
|
1873
|
-
const links = spec.sankeyLinks ?? []
|
|
1874
|
-
if (!nodes.length || !links.length) return { ...empty }
|
|
1875
|
-
const padL = 10, padR = 10, padT = 14, padB = 14
|
|
1876
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1877
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1878
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1879
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1880
|
-
const nodeById = new Map(nodes.map((n) => [n.id, n]))
|
|
1881
|
-
// Column = longest path from any node with no incoming edges.
|
|
1882
|
-
const targets = new Set(links.map((l) => l.target))
|
|
1883
|
-
const sources = nodes.filter((n) => !targets.has(n.id))
|
|
1884
|
-
const column = new Map<string, number>()
|
|
1885
|
-
function visit(id: string, depth: number, seen: Set<string>) {
|
|
1886
|
-
if (seen.has(id)) return
|
|
1887
|
-
seen.add(id)
|
|
1888
|
-
const cur = column.get(id) ?? 0
|
|
1889
|
-
if (depth > cur || !column.has(id)) column.set(id, depth)
|
|
1890
|
-
for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
|
|
1891
|
-
seen.delete(id)
|
|
1892
|
-
}
|
|
1893
|
-
for (const s of sources) visit(s.id, 0, new Set())
|
|
1894
|
-
// Cover any nodes with no path from a source (orphan rings).
|
|
1895
|
-
for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
|
|
1896
|
-
const maxCol = Math.max(...column.values())
|
|
1897
|
-
const cols = maxCol + 1
|
|
1898
|
-
const nodeW = 14
|
|
1899
|
-
const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
|
|
1900
|
-
// Totals per node.
|
|
1901
|
-
const totalIn = new Map<string, number>()
|
|
1902
|
-
const totalOut = new Map<string, number>()
|
|
1903
|
-
for (const l of links) {
|
|
1904
|
-
totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
|
|
1905
|
-
totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
|
|
1906
|
-
}
|
|
1907
|
-
// Per-column groups + max total in that column.
|
|
1908
|
-
const byCol: Map<number, string[]> = new Map()
|
|
1909
|
-
for (const n of nodes) {
|
|
1910
|
-
const c = column.get(n.id) ?? 0
|
|
1911
|
-
const arr = byCol.get(c) ?? []
|
|
1912
|
-
arr.push(n.id); byCol.set(c, arr)
|
|
1913
|
-
}
|
|
1914
|
-
// Per-column total height + node height scale.
|
|
1915
|
-
let maxColTotal = 0
|
|
1916
|
-
for (const ids of byCol.values()) {
|
|
1917
|
-
const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
|
|
1918
|
-
if (t > maxColTotal) maxColTotal = t
|
|
1919
|
-
}
|
|
1920
|
-
if (maxColTotal === 0) return { ...empty, plot }
|
|
1921
|
-
const nodeGapPx = 8
|
|
1922
|
-
const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
|
|
1923
|
-
const placed: ChartSankeyNode[] = []
|
|
1924
|
-
for (const [c, ids] of byCol) {
|
|
1925
|
-
const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
|
|
1926
|
-
const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
|
|
1927
|
-
let yCursor = padT + (plotH - totalH) / 2
|
|
1928
|
-
const xCol = padL + c * (nodeW + gapBetweenColumns)
|
|
1929
|
-
ids.forEach((id, idx) => {
|
|
1930
|
-
const node = nodeById.get(id)!
|
|
1931
|
-
const h = heights[idx]!
|
|
1932
|
-
placed.push({
|
|
1933
|
-
id,
|
|
1934
|
-
label: node.label ?? id,
|
|
1935
|
-
color: node.color ?? palette[(placed.length) % palette.length]!,
|
|
1936
|
-
x: xCol, y: yCursor, w: nodeW, h,
|
|
1937
|
-
column: c,
|
|
1938
|
-
totalIn: totalIn.get(id) ?? 0,
|
|
1939
|
-
totalOut: totalOut.get(id) ?? 0,
|
|
1940
|
-
})
|
|
1941
|
-
yCursor += h + nodeGapPx
|
|
1942
|
-
})
|
|
1943
|
-
}
|
|
1944
|
-
const placedById = new Map(placed.map((n) => [n.id, n]))
|
|
1945
|
-
// Per-node sub-cursor so multiple links from one node stack vertically.
|
|
1946
|
-
const inCursor = new Map<string, number>()
|
|
1947
|
-
const outCursor = new Map<string, number>()
|
|
1948
|
-
const builtLinks: ChartSankeyLink[] = []
|
|
1949
|
-
// Sort links so wider ribbons render first (so thin ribbons stack on top).
|
|
1950
|
-
const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
|
|
1951
|
-
for (const link of sortedLinks) {
|
|
1952
|
-
const a = placedById.get(link.source)
|
|
1953
|
-
const b = placedById.get(link.target)
|
|
1954
|
-
if (!a || !b) continue
|
|
1955
|
-
const linkH = Math.max(1, link.value * heightScale)
|
|
1956
|
-
const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
|
|
1957
|
-
const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
|
|
1958
|
-
outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
|
|
1959
|
-
inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
|
|
1960
|
-
const x0 = a.x + a.w
|
|
1961
|
-
const x1 = b.x
|
|
1962
|
-
const mid = (x0 + x1) / 2
|
|
1963
|
-
const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
|
|
1964
|
-
builtLinks.push({
|
|
1965
|
-
path, color: link.color ?? a.color, width: linkH,
|
|
1966
|
-
source: link.source, target: link.target, value: link.value,
|
|
1967
|
-
})
|
|
1968
|
-
}
|
|
1969
|
-
return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
// ---- Heatmap ------------------------------------------------------
|
|
1973
|
-
// Each series is one row, series.values are the cells across categories.
|
|
1974
|
-
// Color comes from a sequential/diverging/custom palette mapped to the
|
|
1975
|
-
// global value range. Cell text contrasts black/white against the cell.
|
|
1976
|
-
if (spec.type === 'heatmap') {
|
|
1977
|
-
if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
|
|
1978
|
-
// Layout: left gutter for row labels, bottom for column labels.
|
|
1979
|
-
const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
|
|
1980
|
-
const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
|
|
1981
|
-
const padR = 64 // room for the right-side legend bar
|
|
1982
|
-
const padT = 12
|
|
1983
|
-
const padB = 32
|
|
1984
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1985
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1986
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1987
|
-
const cellW = plotW / spec.categories.length
|
|
1988
|
-
const cellH = plotH / series.length
|
|
1989
|
-
// Resolve value range across the whole matrix.
|
|
1990
|
-
let vMin = Infinity, vMax = -Infinity
|
|
1991
|
-
for (const s of series) for (const v of s.values) {
|
|
1992
|
-
if (!Number.isFinite(v)) continue
|
|
1993
|
-
if (v < vMin) vMin = v
|
|
1994
|
-
if (v > vMax) vMax = v
|
|
1995
|
-
}
|
|
1996
|
-
if (vMin === Infinity) { vMin = 0; vMax = 1 }
|
|
1997
|
-
if (vMin === vMax) vMax = vMin + 1
|
|
1998
|
-
// Pick the palette stops.
|
|
1999
|
-
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
|
|
2000
|
-
const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
|
|
2001
|
-
const heatmapCells: ChartHeatmapCell[] = []
|
|
2002
|
-
series.forEach((s, ri) => {
|
|
2003
|
-
s.values.forEach((v, ci) => {
|
|
2004
|
-
if (!Number.isFinite(v)) return
|
|
2005
|
-
const color = colorAt(v)
|
|
2006
|
-
heatmapCells.push({
|
|
2007
|
-
x: round(padL + cellW * ci),
|
|
2008
|
-
y: round(padT + cellH * ri),
|
|
2009
|
-
w: round(cellW),
|
|
2010
|
-
h: round(cellH),
|
|
2011
|
-
color,
|
|
2012
|
-
textColor: pickContrastText(color),
|
|
2013
|
-
value: v,
|
|
2014
|
-
rowLabel: s.label,
|
|
2015
|
-
colLabel: spec.categories[ci] ?? '',
|
|
2016
|
-
})
|
|
2017
|
-
})
|
|
2018
|
-
})
|
|
2019
|
-
const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
|
|
2020
|
-
value: i,
|
|
2021
|
-
y: round(padT + cellH * i + cellH / 2),
|
|
2022
|
-
label: s.label,
|
|
2023
|
-
}))
|
|
2024
|
-
const heatmapColTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
|
|
2025
|
-
label,
|
|
2026
|
-
x: round(padL + cellW * i + cellW / 2),
|
|
2027
|
-
}))
|
|
2028
|
-
// Legend: sample 5 stops across the range.
|
|
2029
|
-
const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
|
|
2030
|
-
const t = i / 4
|
|
2031
|
-
const value = vMin + (vMax - vMin) * t
|
|
2032
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) }
|
|
2033
|
-
})
|
|
2034
|
-
return {
|
|
2035
|
-
...empty,
|
|
2036
|
-
plot,
|
|
2037
|
-
heatmapCells,
|
|
2038
|
-
heatmapRowTicks,
|
|
2039
|
-
heatmapColTicks,
|
|
2040
|
-
heatmapLegend,
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
if (spec.type === 'pie') {
|
|
2045
|
-
const s = series[0]
|
|
2046
|
-
if (!s) return empty
|
|
2047
|
-
const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
|
|
2048
|
-
const cx = width / 2
|
|
2049
|
-
const cy = height / 2
|
|
2050
|
-
const r = Math.min(width, height) / 2 - 10
|
|
2051
|
-
const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
|
|
2052
|
-
const ir = r * innerFrac
|
|
2053
|
-
let angle = -Math.PI / 2
|
|
2054
|
-
const slices: ChartPieSlice[] = s.values.map((v, i) => {
|
|
2055
|
-
const frac = Math.max(0, v) / total
|
|
2056
|
-
const a0 = angle
|
|
2057
|
-
const a1 = angle + frac * Math.PI * 2
|
|
2058
|
-
angle = a1
|
|
2059
|
-
const large = a1 - a0 > Math.PI ? 1 : 0
|
|
2060
|
-
const mid = (a0 + a1) / 2
|
|
2061
|
-
const labelR = (r + ir) / 2 || r * 0.6
|
|
2062
|
-
const ox0 = cx + r * Math.cos(a0)
|
|
2063
|
-
const oy0 = cy + r * Math.sin(a0)
|
|
2064
|
-
const ox1 = cx + r * Math.cos(a1)
|
|
2065
|
-
const oy1 = cy + r * Math.sin(a1)
|
|
2066
|
-
let path: string
|
|
2067
|
-
if (frac >= 0.999) {
|
|
2068
|
-
path = ir
|
|
2069
|
-
? `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z` +
|
|
2070
|
-
`M${round(cx - ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx + ir)},${round(cy)} A${ir},${ir} 0 1 0 ${round(cx - ir)},${round(cy)} Z`
|
|
2071
|
-
: `M${round(cx - r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx + r)},${round(cy)} A${r},${r} 0 1 1 ${round(cx - r)},${round(cy)} Z`
|
|
2072
|
-
} else if (ir > 0) {
|
|
2073
|
-
const ix0 = cx + ir * Math.cos(a0)
|
|
2074
|
-
const iy0 = cy + ir * Math.sin(a0)
|
|
2075
|
-
const ix1 = cx + ir * Math.cos(a1)
|
|
2076
|
-
const iy1 = cy + ir * Math.sin(a1)
|
|
2077
|
-
path =
|
|
2078
|
-
`M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
|
|
2079
|
-
`L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
|
|
2080
|
-
} else {
|
|
2081
|
-
path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
|
|
2082
|
-
}
|
|
2083
|
-
const catLabel = spec.categories[i] ?? String(i)
|
|
2084
|
-
return {
|
|
2085
|
-
path,
|
|
2086
|
-
color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length]!,
|
|
2087
|
-
label: catLabel,
|
|
2088
|
-
value: v,
|
|
2089
|
-
percent: frac * 100,
|
|
2090
|
-
cx: round(cx + labelR * Math.cos(mid)),
|
|
2091
|
-
cy: round(cy + labelR * Math.sin(mid)),
|
|
2092
|
-
}
|
|
2093
|
-
})
|
|
2094
|
-
return {
|
|
2095
|
-
...empty,
|
|
2096
|
-
slices,
|
|
2097
|
-
legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! })),
|
|
2098
|
-
donut: ir > 0 ? { cx: round(cx), cy: round(cy), r: round(ir), total: s.values.reduce((a, b) => a + Math.max(0, b), 0) } : null,
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
if (spec.type === 'scatter') {
|
|
2103
|
-
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
2104
|
-
const padR = 12
|
|
2105
|
-
const padT = 10
|
|
2106
|
-
const padB = 28 + (spec.xAxisTitle ? 16 : 0)
|
|
2107
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
2108
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
2109
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
2110
|
-
|
|
2111
|
-
let xMin = Infinity
|
|
2112
|
-
let xMax = -Infinity
|
|
2113
|
-
let yMin = Infinity
|
|
2114
|
-
let yMax = -Infinity
|
|
2115
|
-
let rMin = Infinity
|
|
2116
|
-
let rMax = -Infinity
|
|
2117
|
-
for (const s of series) {
|
|
2118
|
-
for (const pt of s.points ?? []) {
|
|
2119
|
-
if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
|
|
2120
|
-
if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
|
|
2121
|
-
if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
if (xMin === Infinity) return { ...empty, plot }
|
|
2125
|
-
const xDom = niceScale(xMin, xMax)
|
|
2126
|
-
const yDom = niceScale(yMin, yMax)
|
|
2127
|
-
const hasR = rMax > rMin
|
|
2128
|
-
const xOf = (v: number) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW)
|
|
2129
|
-
const yOf = (v: number) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH)
|
|
2130
|
-
const rOf = (r?: number) =>
|
|
2131
|
-
hasR && r != null && Number.isFinite(r)
|
|
2132
|
-
? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
|
|
2133
|
-
: 5
|
|
2134
|
-
|
|
2135
|
-
const scatterPoints: ChartScatterDot[] = []
|
|
2136
|
-
for (const s of series) {
|
|
2137
|
-
for (const pt of s.points ?? []) {
|
|
2138
|
-
if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue
|
|
2139
|
-
scatterPoints.push({
|
|
2140
|
-
cx: xOf(pt.x),
|
|
2141
|
-
cy: yOf(pt.y),
|
|
2142
|
-
r: rOf(pt.r),
|
|
2143
|
-
color: s.color,
|
|
2144
|
-
label: pt.label ?? '',
|
|
2145
|
-
series: s.label,
|
|
2146
|
-
x: pt.x,
|
|
2147
|
-
y: pt.y,
|
|
2148
|
-
})
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
2152
|
-
y: yOf(ref.value),
|
|
2153
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
2154
|
-
color: ref.color ?? '#ef4444',
|
|
2155
|
-
dashed: ref.dashed !== false,
|
|
2156
|
-
}))
|
|
2157
|
-
return {
|
|
2158
|
-
...empty,
|
|
2159
|
-
plot,
|
|
2160
|
-
scatterPoints,
|
|
2161
|
-
referenceLines,
|
|
2162
|
-
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat, spec) })),
|
|
2163
|
-
xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
243
|
+
const ctx: LayoutCtx = { spec, theme, width, height, palette, stacked, series, legend, empty, frame, axes }
|
|
244
|
+
if (spec.type === 'waterfall') return layoutWaterfall(ctx)
|
|
245
|
+
if (spec.type === 'funnel') return layoutFunnel(ctx)
|
|
246
|
+
if (spec.type === 'radar') return layoutRadar(ctx)
|
|
247
|
+
if (spec.type === 'calendar') return layoutCalendar(ctx)
|
|
248
|
+
if (spec.type === 'gauge') return layoutGauge(ctx)
|
|
249
|
+
if (spec.type === 'treemap') return layoutTreemap(ctx)
|
|
250
|
+
if (spec.type === 'sankey') return layoutSankey(ctx)
|
|
251
|
+
if (spec.type === 'heatmap') return layoutHeatmap(ctx)
|
|
252
|
+
if (spec.type === 'pie') return layoutPie(ctx)
|
|
253
|
+
if (spec.type === 'scatter') return layoutScatter(ctx)
|
|
254
|
+
if (spec.type === 'sunburst') return layoutSunburst(ctx)
|
|
255
|
+
if (spec.type === 'radial-bar') return layoutRadialBar(ctx)
|
|
256
|
+
if (spec.type === 'radial-column' || spec.type === 'nightingale') return layoutRadialColumn(ctx)
|
|
257
|
+
if (spec.type === 'chord') return layoutChord(ctx)
|
|
258
|
+
if (spec.type === 'bullet') return layoutBullet(ctx)
|
|
2166
259
|
|
|
2167
260
|
// ---- Horizontal bars ----------------------------------------------------
|
|
2168
261
|
// Categories run down the left, bars grow rightward. Bars-only (no combo).
|
|
2169
262
|
const horizontal =
|
|
2170
|
-
spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar')
|
|
2171
|
-
if (horizontal)
|
|
2172
|
-
|
|
2173
|
-
const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0)
|
|
2174
|
-
const padR = 16
|
|
2175
|
-
const padT = 8
|
|
2176
|
-
const padB = 26 + (spec.xAxisTitle ? 16 : 0)
|
|
2177
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
2178
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
2179
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
2180
|
-
|
|
2181
|
-
const refs = (spec.referenceLines ?? []).map((r) => r.value)
|
|
2182
|
-
const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs)
|
|
2183
|
-
const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
|
|
2184
|
-
|
|
2185
|
-
const n = spec.categories.length
|
|
2186
|
-
const slot = plotH / Math.max(1, n)
|
|
2187
|
-
const groupPad = slot * 0.2
|
|
2188
|
-
const inner = slot - groupPad
|
|
2189
|
-
const bandTop = (i: number) => padT + slot * i + groupPad / 2
|
|
2190
|
-
const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max))
|
|
2191
|
-
|
|
2192
|
-
const bars: ChartBar[] = []
|
|
2193
|
-
if (stacked) {
|
|
2194
|
-
const totals = spec.stacked100
|
|
2195
|
-
? spec.categories.map(
|
|
2196
|
-
(_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
|
|
2197
|
-
)
|
|
2198
|
-
: null
|
|
2199
|
-
const pos = new Array(n).fill(0)
|
|
2200
|
-
const neg = new Array(n).fill(0)
|
|
2201
|
-
for (const s of series) {
|
|
2202
|
-
s.values.forEach((v, i) => {
|
|
2203
|
-
if (!Number.isFinite(v)) return
|
|
2204
|
-
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
2205
|
-
let xL: number
|
|
2206
|
-
let xR: number
|
|
2207
|
-
if (vp >= 0) {
|
|
2208
|
-
xL = xOf(pos[i])
|
|
2209
|
-
xR = xOf(pos[i] + vp)
|
|
2210
|
-
pos[i] += vp
|
|
2211
|
-
} else {
|
|
2212
|
-
xL = xOf(neg[i] + vp)
|
|
2213
|
-
xR = xOf(neg[i])
|
|
2214
|
-
neg[i] += vp
|
|
2215
|
-
}
|
|
2216
|
-
bars.push({
|
|
2217
|
-
x: Math.min(xL, xR),
|
|
2218
|
-
y: round(bandTop(i)),
|
|
2219
|
-
w: round(Math.abs(xR - xL)),
|
|
2220
|
-
h: round(Math.max(1, inner)),
|
|
2221
|
-
color: s.color,
|
|
2222
|
-
label: spec.categories[i] ?? String(i),
|
|
2223
|
-
series: s.label,
|
|
2224
|
-
value: v,
|
|
2225
|
-
})
|
|
2226
|
-
})
|
|
2227
|
-
}
|
|
2228
|
-
} else {
|
|
2229
|
-
const barH = inner / series.length
|
|
2230
|
-
series.forEach((s, bi) => {
|
|
2231
|
-
s.values.forEach((v, i) => {
|
|
2232
|
-
if (!Number.isFinite(v)) return
|
|
2233
|
-
const xV = xOf(v)
|
|
2234
|
-
bars.push({
|
|
2235
|
-
x: Math.min(xV, xBase),
|
|
2236
|
-
y: round(bandTop(i) + barH * bi),
|
|
2237
|
-
w: round(Math.max(1, Math.abs(xV - xBase))),
|
|
2238
|
-
h: round(Math.max(1, barH - 1)),
|
|
2239
|
-
color: s.color,
|
|
2240
|
-
label: spec.categories[i] ?? String(i),
|
|
2241
|
-
series: s.label,
|
|
2242
|
-
value: v,
|
|
2243
|
-
})
|
|
2244
|
-
})
|
|
2245
|
-
})
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
|
|
2249
|
-
label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat, spec),
|
|
2250
|
-
x: xOf(value),
|
|
2251
|
-
}))
|
|
2252
|
-
const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
|
|
2253
|
-
value: i,
|
|
2254
|
-
y: round(bandTop(i) + inner / 2),
|
|
2255
|
-
label,
|
|
2256
|
-
}))
|
|
2257
|
-
const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
2258
|
-
x: xOf(ref.value),
|
|
2259
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
2260
|
-
color: ref.color ?? '#ef4444',
|
|
2261
|
-
dashed: ref.dashed !== false,
|
|
2262
|
-
}))
|
|
2263
|
-
|
|
2264
|
-
return {
|
|
2265
|
-
...empty,
|
|
2266
|
-
plot,
|
|
2267
|
-
bars,
|
|
2268
|
-
orientation: 'horizontal',
|
|
2269
|
-
valueTicks,
|
|
2270
|
-
catTicks,
|
|
2271
|
-
referenceLinesV,
|
|
2272
|
-
xLabelRotated: false,
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
// ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
|
|
2277
|
-
const leftSeries = series.filter((s) => s.axis === 'left')
|
|
2278
|
-
const rightSeries = series.filter((s) => s.axis === 'right')
|
|
2279
|
-
const hasRightAxis = rightSeries.length > 0
|
|
2280
|
-
|
|
2281
|
-
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
2282
|
-
const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
|
|
2283
|
-
// Grouped (nested) category axis: valid only when the spans cover every leaf.
|
|
2284
|
-
const validGroups =
|
|
2285
|
-
spec.categoryGroups &&
|
|
2286
|
-
spec.categoryGroups.length > 0 &&
|
|
2287
|
-
spec.xType !== 'time' &&
|
|
2288
|
-
spec.orientation !== 'horizontal' &&
|
|
2289
|
-
spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
|
|
2290
|
-
? spec.categoryGroups
|
|
2291
|
-
: null
|
|
2292
|
-
const groupTierH = validGroups ? 18 : 0
|
|
2293
|
-
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
2294
|
-
const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
|
|
2295
|
-
const padT = 10
|
|
2296
|
-
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH
|
|
2297
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
2298
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
2299
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
2300
|
-
|
|
2301
|
-
const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
|
|
2302
|
-
const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
|
|
2303
|
-
const leftLog = spec.yScale === 'log'
|
|
2304
|
-
const rightLog = spec.y2Scale === 'log'
|
|
2305
|
-
const leftDom = spec.stacked100
|
|
2306
|
-
? niceScale(0, 100)
|
|
2307
|
-
: axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog)
|
|
2308
|
-
const rightDom = hasRightAxis
|
|
2309
|
-
? spec.stacked100
|
|
2310
|
-
? niceScale(0, 100)
|
|
2311
|
-
: axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
|
|
2312
|
-
: null
|
|
2313
|
-
|
|
2314
|
-
/** Map a data value to a y pixel. Returns NaN for non-positive values on
|
|
2315
|
-
* a log axis so callers can drop the point (line gap / missing bar). */
|
|
2316
|
-
const yOf = (dom: NiceScale, v: number, isLog = false) => {
|
|
2317
|
-
const t = project(v, dom.min, dom.max, isLog)
|
|
2318
|
-
if (t === null) return NaN
|
|
2319
|
-
return round(padT + plotH - t * plotH)
|
|
2320
|
-
}
|
|
2321
|
-
const yLeft = (v: number) => yOf(leftDom, v, leftLog)
|
|
2322
|
-
const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog)
|
|
2323
|
-
const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
|
|
2324
|
-
const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
|
|
2325
|
-
|
|
2326
|
-
const n = spec.categories.length
|
|
2327
|
-
const slot = plotW / Math.max(1, n)
|
|
2328
|
-
|
|
2329
|
-
// X positions. A time axis spaces points by actual time (irregular gaps);
|
|
2330
|
-
// a category axis is uniform. (Bars stay uniform either way.)
|
|
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
|
|
2336
|
-
const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
|
|
2337
|
-
const timeScaled = timeOk && spec.xType === 'time'
|
|
2338
|
-
const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
|
|
2339
|
-
const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
|
|
2340
|
-
const xCenter = (i: number) =>
|
|
2341
|
-
timeScaled && Number.isFinite(timeVals![i])
|
|
2342
|
-
? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
|
|
2343
|
-
: round(padL + slot * i + slot / 2)
|
|
2344
|
-
const xTicks: ChartCategoryTick[] = timeScaled
|
|
2345
|
-
? dateTicks(tMin, tMin + tSpan).map((t) => ({
|
|
2346
|
-
label: fmtDate(t, tSpan),
|
|
2347
|
-
x: round(padL + ((t - tMin) / tSpan) * plotW),
|
|
2348
|
-
}))
|
|
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)
|
|
2356
|
-
|
|
2357
|
-
// Parent-tier ticks for a grouped category axis: each spans its leaves.
|
|
2358
|
-
const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
|
|
2359
|
-
if (validGroups && !timeOk) {
|
|
2360
|
-
let start = 0
|
|
2361
|
-
for (const g of validGroups) {
|
|
2362
|
-
const x0 = round(padL + slot * start)
|
|
2363
|
-
const x1 = round(padL + slot * (start + g.span))
|
|
2364
|
-
categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
|
|
2365
|
-
start += g.span
|
|
2366
|
-
}
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
const barSeries = series.filter((s) => s.kind === 'bar')
|
|
2370
|
-
const bars: ChartBar[] = []
|
|
2371
|
-
if (barSeries.length) {
|
|
2372
|
-
const groupPad = slot * 0.2
|
|
2373
|
-
if (stacked) {
|
|
2374
|
-
const inner = slot - groupPad
|
|
2375
|
-
const x0 = (i: number) => padL + slot * i + groupPad / 2
|
|
2376
|
-
// Stack independently per axis so dual-axis stacks line up to their own scale.
|
|
2377
|
-
for (const axis of ['left', 'right'] as const) {
|
|
2378
|
-
const axisBars = barSeries.filter((s) => s.axis === axis)
|
|
2379
|
-
if (!axisBars.length) continue
|
|
2380
|
-
const yA = axis === 'right' ? yRight : yLeft
|
|
2381
|
-
// 100% mode normalizes each category to its absolute total.
|
|
2382
|
-
const totals = spec.stacked100
|
|
2383
|
-
? spec.categories.map(
|
|
2384
|
-
(_, i) =>
|
|
2385
|
-
axisBars.reduce(
|
|
2386
|
-
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
2387
|
-
0,
|
|
2388
|
-
) || 1,
|
|
2389
|
-
)
|
|
2390
|
-
: null
|
|
2391
|
-
const pos = new Array(n).fill(0)
|
|
2392
|
-
const neg = new Array(n).fill(0)
|
|
2393
|
-
for (const s of axisBars) {
|
|
2394
|
-
s.values.forEach((v, i) => {
|
|
2395
|
-
if (!Number.isFinite(v)) return
|
|
2396
|
-
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
2397
|
-
let yTop: number
|
|
2398
|
-
let yBot: number
|
|
2399
|
-
if (vp >= 0) {
|
|
2400
|
-
yTop = yA(pos[i] + vp)
|
|
2401
|
-
yBot = yA(pos[i])
|
|
2402
|
-
pos[i] += vp
|
|
2403
|
-
} else {
|
|
2404
|
-
yTop = yA(neg[i])
|
|
2405
|
-
yBot = yA(neg[i] + vp)
|
|
2406
|
-
neg[i] += vp
|
|
2407
|
-
}
|
|
2408
|
-
bars.push({
|
|
2409
|
-
x: round(x0(i)),
|
|
2410
|
-
y: Math.min(yTop, yBot),
|
|
2411
|
-
w: round(Math.max(1, inner)),
|
|
2412
|
-
h: round(Math.abs(yBot - yTop)),
|
|
2413
|
-
color: s.color,
|
|
2414
|
-
label: spec.categories[i] ?? String(i),
|
|
2415
|
-
series: s.label,
|
|
2416
|
-
value: v,
|
|
2417
|
-
})
|
|
2418
|
-
})
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
} else {
|
|
2422
|
-
const inner = slot - groupPad
|
|
2423
|
-
const barW = inner / barSeries.length
|
|
2424
|
-
barSeries.forEach((s, bi) => {
|
|
2425
|
-
const dom = domOf(s)
|
|
2426
|
-
const log = isLogOf(s)
|
|
2427
|
-
// Log axis: bars grow from the axis floor (dom.min) up to v rather
|
|
2428
|
-
// than from 0, since 0 is invalid in log space.
|
|
2429
|
-
const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log)
|
|
2430
|
-
s.values.forEach((v, i) => {
|
|
2431
|
-
if (!Number.isFinite(v)) return
|
|
2432
|
-
if (log && v <= 0) return
|
|
2433
|
-
const x = padL + slot * i + groupPad / 2 + barW * bi
|
|
2434
|
-
const yV = yOf(dom, v, log)
|
|
2435
|
-
bars.push({
|
|
2436
|
-
x: round(x),
|
|
2437
|
-
y: Math.min(yV, base),
|
|
2438
|
-
w: round(Math.max(1, barW - 1)),
|
|
2439
|
-
h: round(Math.max(1, Math.abs(yV - base))),
|
|
2440
|
-
color: s.color,
|
|
2441
|
-
label: spec.categories[i] ?? String(i),
|
|
2442
|
-
series: s.label,
|
|
2443
|
-
value: v,
|
|
2444
|
-
})
|
|
2445
|
-
})
|
|
2446
|
-
})
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
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
|
-
|
|
2572
|
-
// Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
|
|
2573
|
-
const lines: ChartLine[] = []
|
|
2574
|
-
const areaCum: Record<'left' | 'right', number[]> = {
|
|
2575
|
-
left: new Array(n).fill(0),
|
|
2576
|
-
right: new Array(n).fill(0),
|
|
2577
|
-
}
|
|
2578
|
-
// 100% mode: per-axis per-category totals to normalize stacked areas to 100.
|
|
2579
|
-
const areaTotals: Record<'left' | 'right', number[] | null> = { left: null, right: null }
|
|
2580
|
-
if (spec.stacked100) {
|
|
2581
|
-
for (const axis of ['left', 'right'] as const) {
|
|
2582
|
-
const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis)
|
|
2583
|
-
if (areaSeries.length) {
|
|
2584
|
-
areaTotals[axis] = spec.categories.map(
|
|
2585
|
-
(_, i) =>
|
|
2586
|
-
areaSeries.reduce(
|
|
2587
|
-
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
2588
|
-
0,
|
|
2589
|
-
) || 1,
|
|
2590
|
-
)
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
}
|
|
2594
|
-
for (const s of series) {
|
|
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
|
|
2601
|
-
const dom = domOf(s)
|
|
2602
|
-
const log = isLogOf(s)
|
|
2603
|
-
const yA = (v: number) => yOf(dom, v, log)
|
|
2604
|
-
const isStackedArea = stacked && s.kind === 'area'
|
|
2605
|
-
const px = (i: number) => xCenter(i)
|
|
2606
|
-
let pts: ChartLinePoint[]
|
|
2607
|
-
let baselinePts: Array<{ x: number; y: number }> | null = null
|
|
2608
|
-
if (isStackedArea) {
|
|
2609
|
-
// Stacked areas treat a gap as 0 so the stack stays continuous.
|
|
2610
|
-
const cum = areaCum[s.axis]
|
|
2611
|
-
const prev = cum.slice()
|
|
2612
|
-
const totals = areaTotals[s.axis]
|
|
2613
|
-
pts = s.values.map((v, i) => {
|
|
2614
|
-
const vv = Number.isFinite(v) ? v : 0
|
|
2615
|
-
// 100% mode positions by share of the category total; value stays original.
|
|
2616
|
-
const norm = totals ? (vv / totals[i]!) * 100 : vv
|
|
2617
|
-
const c = (cum[i] ?? 0) + norm
|
|
2618
|
-
cum[i] = c
|
|
2619
|
-
return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) }
|
|
2620
|
-
})
|
|
2621
|
-
baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
|
|
2622
|
-
} else {
|
|
2623
|
-
pts = s.values.map((v, i) => {
|
|
2624
|
-
const ok = Number.isFinite(v)
|
|
2625
|
-
return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
|
|
2626
|
-
})
|
|
2627
|
-
}
|
|
2628
|
-
// Build the line - smoothed via monotone cubic when requested, else
|
|
2629
|
-
// straight polylines. Either way, gaps break the path cleanly.
|
|
2630
|
-
const smooth = !!s.smooth
|
|
2631
|
-
const path = buildLinePath(pts, smooth)
|
|
2632
|
-
|
|
2633
|
-
let areaPath = ''
|
|
2634
|
-
if (s.kind === 'area' && pts.length) {
|
|
2635
|
-
if (baselinePts) {
|
|
2636
|
-
const top = smooth
|
|
2637
|
-
? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
|
|
2638
|
-
: pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
2639
|
-
const back = smooth
|
|
2640
|
-
? `L${baselinePts[baselinePts.length - 1]!.x},${baselinePts[baselinePts.length - 1]!.y} ` +
|
|
2641
|
-
monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
2642
|
-
: baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
|
|
2643
|
-
areaPath = `${top} ${back} Z`
|
|
2644
|
-
} else {
|
|
2645
|
-
// One filled polygon per contiguous run of defined points.
|
|
2646
|
-
const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)))
|
|
2647
|
-
const runs: ChartLinePoint[][] = []
|
|
2648
|
-
let cur: ChartLinePoint[] = []
|
|
2649
|
-
for (const p of pts) {
|
|
2650
|
-
if (p.defined) cur.push(p)
|
|
2651
|
-
else if (cur.length) {
|
|
2652
|
-
runs.push(cur)
|
|
2653
|
-
cur = []
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
if (cur.length) runs.push(cur)
|
|
2657
|
-
areaPath = runs
|
|
2658
|
-
.map((run) => {
|
|
2659
|
-
const top = smooth
|
|
2660
|
-
? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
|
|
2661
|
-
: run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
2662
|
-
return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
|
|
2663
|
-
})
|
|
2664
|
-
.join(' ')
|
|
2665
|
-
}
|
|
2666
|
-
}
|
|
2667
|
-
|
|
2668
|
-
// Confidence band: shaded envelope between upperValues / lowerValues.
|
|
2669
|
-
// Both arrays must be present and aligned to the value array.
|
|
2670
|
-
let bandPath = ''
|
|
2671
|
-
if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
|
|
2672
|
-
const upperPts: Array<{ x: number; y: number }> = []
|
|
2673
|
-
const lowerPts: Array<{ x: number; y: number }> = []
|
|
2674
|
-
for (let i = 0; i < s.values.length; i += 1) {
|
|
2675
|
-
const u = s.upperValues[i]!
|
|
2676
|
-
const lo = s.lowerValues[i]!
|
|
2677
|
-
if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
|
|
2678
|
-
if (log && (u <= 0 || lo <= 0)) continue
|
|
2679
|
-
upperPts.push({ x: px(i), y: yA(u) })
|
|
2680
|
-
lowerPts.push({ x: px(i), y: yA(lo) })
|
|
2681
|
-
}
|
|
2682
|
-
if (upperPts.length >= 2) {
|
|
2683
|
-
const top = smooth
|
|
2684
|
-
? monotoneCubicPath(upperPts)
|
|
2685
|
-
: upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
2686
|
-
const back = smooth
|
|
2687
|
-
? `L${lowerPts[lowerPts.length - 1]!.x},${lowerPts[lowerPts.length - 1]!.y} ` +
|
|
2688
|
-
monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
2689
|
-
: lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
|
|
2690
|
-
bandPath = `${top} ${back} Z`
|
|
2691
|
-
}
|
|
2692
|
-
}
|
|
2693
|
-
|
|
2694
|
-
lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath })
|
|
2695
|
-
}
|
|
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.
|
|
2702
|
-
const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
|
|
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
|
-
}))
|
|
2708
|
-
|
|
2709
|
-
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
|
|
2710
|
-
const onRight = ref.axis === 'right'
|
|
2711
|
-
const dom = onRight ? (rightDom ?? leftDom) : leftDom
|
|
2712
|
-
const log = onRight ? rightLog : leftLog
|
|
2713
|
-
return {
|
|
2714
|
-
y: yOf(dom, ref.value, log),
|
|
2715
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
2716
|
-
color: ref.color ?? '#ef4444',
|
|
2717
|
-
dashed: ref.dashed !== false,
|
|
2718
|
-
}
|
|
2719
|
-
})
|
|
2720
|
-
|
|
2721
|
-
// ---- Overlays: trendline / moving average ------------------------
|
|
2722
|
-
// For every series with an `overlay`, compute the smoothed values and
|
|
2723
|
-
// render as a dashed line in the source series' color (or overlayColor).
|
|
2724
|
-
const overlays: ChartLine[] = []
|
|
2725
|
-
for (const s of series) {
|
|
2726
|
-
if (!s.overlay) continue
|
|
2727
|
-
const dom = domOf(s)
|
|
2728
|
-
const log = isLogOf(s)
|
|
2729
|
-
const overlayVals = computeOverlay(s.values, s.overlay)
|
|
2730
|
-
const color = s.overlayColor ?? s.color
|
|
2731
|
-
const pts: ChartLinePoint[] = overlayVals.map((v, i) => {
|
|
2732
|
-
const ok = Number.isFinite(v) && (!log || v > 0)
|
|
2733
|
-
return {
|
|
2734
|
-
x: xCenter(i),
|
|
2735
|
-
y: ok ? yOf(dom, v, log) : NaN,
|
|
2736
|
-
label: spec.categories[i] ?? String(i),
|
|
2737
|
-
value: v,
|
|
2738
|
-
defined: ok,
|
|
2739
|
-
}
|
|
2740
|
-
})
|
|
2741
|
-
const path = buildLinePath(pts, !!s.smooth)
|
|
2742
|
-
overlays.push({
|
|
2743
|
-
path,
|
|
2744
|
-
areaPath: '',
|
|
2745
|
-
color,
|
|
2746
|
-
label: `${s.label} (${s.overlay})`,
|
|
2747
|
-
points: pts,
|
|
2748
|
-
})
|
|
2749
|
-
}
|
|
2750
|
-
|
|
2751
|
-
// ---- Annotations: resolve data-space anchors to pixel coords ------
|
|
2752
|
-
const annotations: ChartGeometry['annotations'] = []
|
|
2753
|
-
for (const a of (spec.annotations ?? [])) {
|
|
2754
|
-
let ax: number | null = null
|
|
2755
|
-
let ay: number | null = null
|
|
2756
|
-
if ('category' in a.at) {
|
|
2757
|
-
const ci = spec.categories.indexOf(a.at.category)
|
|
2758
|
-
if (ci < 0) continue
|
|
2759
|
-
ax = xCenter(ci)
|
|
2760
|
-
// Anchor to the named series' value at that category, else just
|
|
2761
|
-
// mid-plot. Picks the first matching series if `series` is set.
|
|
2762
|
-
const seriesName = a.at.series
|
|
2763
|
-
const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
|
|
2764
|
-
if (s) {
|
|
2765
|
-
const v = s.values[ci]
|
|
2766
|
-
if (Number.isFinite(v)) ay = yOf(domOf(s), v as number, isLogOf(s))
|
|
2767
|
-
}
|
|
2768
|
-
if (ay == null) ay = padT + plotH / 2
|
|
2769
|
-
} else {
|
|
2770
|
-
// Raw x/y in data space (x ignored for category x-axis; takes the
|
|
2771
|
-
// mid-plot in that case). y projects through the left axis.
|
|
2772
|
-
ax = padL + plotW / 2
|
|
2773
|
-
if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog)
|
|
2774
|
-
else ay = padT + plotH / 2
|
|
2775
|
-
}
|
|
2776
|
-
if (ax != null && ay != null && Number.isFinite(ay)) {
|
|
2777
|
-
annotations.push({
|
|
2778
|
-
x: ax,
|
|
2779
|
-
y: ay,
|
|
2780
|
-
label: a.label,
|
|
2781
|
-
color: a.color ?? '#0f172a',
|
|
2782
|
-
placement: a.placement ?? 'top',
|
|
2783
|
-
})
|
|
2784
|
-
}
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
return {
|
|
2788
|
-
...empty,
|
|
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
|
-
},
|
|
2800
|
-
bars,
|
|
2801
|
-
candles,
|
|
2802
|
-
boxes,
|
|
2803
|
-
errorBars,
|
|
2804
|
-
lines,
|
|
2805
|
-
yTicks: tickFor(leftDom, leftLog),
|
|
2806
|
-
y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
|
|
2807
|
-
hasRightAxis,
|
|
2808
|
-
xTicks,
|
|
2809
|
-
categoryGroupTicks,
|
|
2810
|
-
xLabelRotated: timeOk ? false : xLabelRotated,
|
|
2811
|
-
referenceLines,
|
|
2812
|
-
overlays,
|
|
2813
|
-
annotations,
|
|
2814
|
-
}
|
|
263
|
+
spec.orientation === 'horizontal' && spec.type !== 'histogram' && series.length > 0 && series.every((s) => s.kind === 'bar')
|
|
264
|
+
if (horizontal) return layoutHorizontalBars(ctx)
|
|
265
|
+
return layoutCartesian(ctx)
|
|
2815
266
|
}
|
|
2816
267
|
|
|
2817
268
|
/**
|
|
@@ -2833,32 +284,18 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
|
|
|
2833
284
|
export function sliceChartWindow(spec: ChartSpec, lo: number, hi: number): ChartSpec {
|
|
2834
285
|
const from = Math.max(0, lo)
|
|
2835
286
|
const to = Math.min(spec.categories.length - 1, hi)
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
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
|
-
}
|
|
287
|
+
if (to < from) return { ...spec, categories: [], series: spec.series.map((s) => ({ ...s, values: [] })) }
|
|
288
|
+
// EVERY per-category array on a series has to be cut here, not just the
|
|
289
|
+
// ones that existed when this function was written. A missed one does not
|
|
290
|
+
// throw: the geometry keeps indexing the full-length array against the
|
|
291
|
+
// sliced categories, so marks land at the wrong x or off the plot entirely.
|
|
292
|
+
// `upperValues` / `lowerValues` were missed once and silently dropped the
|
|
293
|
+
// confidence band on zoom; `ohlc` was missed the same way. The list of those
|
|
294
|
+
// arrays now lives in one constant (PER_CATEGORY_SERIES_KEYS) shared with
|
|
295
|
+
// decimation, and `pickCategories` is the one function that applies it.
|
|
296
|
+
const indices: number[] = new Array(to - from + 1)
|
|
297
|
+
for (let i = from; i <= to; i += 1) indices[i - from] = i
|
|
298
|
+
return pickCategories(spec, indices)
|
|
2862
299
|
}
|
|
2863
300
|
|
|
2864
301
|
/**
|
|
@@ -2877,14 +314,25 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
|
2877
314
|
value: (keyof T & string) | Array<keyof T & string>
|
|
2878
315
|
/** Pivot dimension: one series per distinct value of this field. */
|
|
2879
316
|
series?: keyof T & string
|
|
2880
|
-
|
|
317
|
+
/** How each group collapses to one number. Default `'sum'`. See
|
|
318
|
+
* {@link ChartReducer} for the full list (min, max, median, p90, ...). */
|
|
319
|
+
reduce?: ChartReducer
|
|
320
|
+
/**
|
|
321
|
+
* Group a date category by calendar unit instead of by exact value:
|
|
322
|
+
* `'month'` files every row of March under `2026-03-01`. Categories come
|
|
323
|
+
* out as ISO dates in chronological order and the spec's x axis is set to
|
|
324
|
+
* `'ordinal-time'`, so the ticks read as dates. Rows whose category does
|
|
325
|
+
* not parse as a date are dropped.
|
|
326
|
+
*/
|
|
327
|
+
bucket?: ChartTimeBucket
|
|
2881
328
|
seriesLabel?: string
|
|
2882
329
|
width?: number
|
|
2883
330
|
height?: number
|
|
2884
331
|
stacked?: boolean
|
|
2885
332
|
stacked100?: boolean
|
|
2886
333
|
palette?: string[]
|
|
2887
|
-
/** Order categories. Defaults to insertion order (or value-desc when topN
|
|
334
|
+
/** Order categories. Defaults to insertion order (or value-desc when topN,
|
|
335
|
+
* or chronological when bucketed). */
|
|
2888
336
|
sort?: 'value-desc' | 'value-asc' | 'category' | 'none'
|
|
2889
337
|
/** Keep only the top N categories; bucket the rest into "Other". */
|
|
2890
338
|
topN?: number
|
|
@@ -2896,10 +344,15 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
|
2896
344
|
idField?: keyof T & string
|
|
2897
345
|
},
|
|
2898
346
|
): ChartSpec {
|
|
2899
|
-
const reduce = opts.reduce ?? 'sum'
|
|
347
|
+
const reduce: ChartReducer = opts.reduce ?? 'sum'
|
|
348
|
+
const wantSamples = reducerNeedsSamples(reduce)
|
|
2900
349
|
const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value]
|
|
2901
|
-
|
|
2902
|
-
|
|
350
|
+
// The three original reducers run on a sum and a count; the rest keep every
|
|
351
|
+
// observation, which is what a median or a percentile needs.
|
|
352
|
+
const reduceCell = (cell: Cell) =>
|
|
353
|
+
wantSamples
|
|
354
|
+
? reduceValues(cell.samples, reduce)
|
|
355
|
+
: reduce === 'count' ? cell.count : reduce === 'avg' ? (cell.count ? cell.sum / cell.count : 0) : cell.sum
|
|
2903
356
|
|
|
2904
357
|
const categories: string[] = []
|
|
2905
358
|
const catIndex = new Map<string, number>()
|
|
@@ -2914,7 +367,7 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
|
2914
367
|
}
|
|
2915
368
|
|
|
2916
369
|
// Series keyed by name -> per-category {sum,count,rowIds}.
|
|
2917
|
-
type Cell = { sum: number; count: number; rowIds: Array<string | number
|
|
370
|
+
type Cell = { sum: number; count: number; rowIds: Array<string | number>; samples: number[] }
|
|
2918
371
|
const seriesMap = new Map<string, Cell[]>()
|
|
2919
372
|
const ensureSeries = (name: string) => {
|
|
2920
373
|
let arr = seriesMap.get(name)
|
|
@@ -2924,42 +377,40 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
|
2924
377
|
}
|
|
2925
378
|
return arr
|
|
2926
379
|
}
|
|
380
|
+
const note = (cell: Cell, num: number, rowId: string | number | undefined) => {
|
|
381
|
+
cell.sum += num
|
|
382
|
+
cell.count += 1
|
|
383
|
+
if (wantSamples) cell.samples.push(num)
|
|
384
|
+
if (rowId !== undefined) cell.rowIds.push(rowId)
|
|
385
|
+
}
|
|
386
|
+
const fresh = (): Cell => ({ sum: 0, count: 0, rowIds: [], samples: [] })
|
|
2927
387
|
|
|
2928
388
|
const trackIds = opts.idField !== undefined
|
|
2929
389
|
for (const row of rows) {
|
|
2930
|
-
const
|
|
390
|
+
const raw = row[opts.category]
|
|
391
|
+
const cat = opts.bucket ? bucketStart(raw as string | number | Date, opts.bucket) : String(raw ?? '')
|
|
392
|
+
if (opts.bucket && !cat) continue
|
|
2931
393
|
const ci = ensureCat(cat)
|
|
2932
394
|
const rowId = trackIds ? (row[opts.idField as keyof T] as string | number) : undefined
|
|
2933
395
|
if (opts.series) {
|
|
2934
396
|
const sName = String(row[opts.series] ?? '')
|
|
2935
397
|
const arr = ensureSeries(sName)
|
|
2936
398
|
const num = Number(row[valueFields[0]!])
|
|
2937
|
-
const cell = (arr[ci] ??=
|
|
2938
|
-
if (Number.isFinite(num))
|
|
2939
|
-
cell.sum += num
|
|
2940
|
-
cell.count += 1
|
|
2941
|
-
if (rowId !== undefined) cell.rowIds.push(rowId)
|
|
2942
|
-
}
|
|
399
|
+
const cell = (arr[ci] ??= fresh())
|
|
400
|
+
if (Number.isFinite(num)) note(cell, num, rowId)
|
|
2943
401
|
} else {
|
|
2944
402
|
for (const vf of valueFields) {
|
|
2945
403
|
const arr = ensureSeries(vf)
|
|
2946
404
|
const num = Number(row[vf])
|
|
2947
|
-
const cell = (arr[ci] ??=
|
|
2948
|
-
if (Number.isFinite(num))
|
|
2949
|
-
cell.sum += num
|
|
2950
|
-
cell.count += 1
|
|
2951
|
-
if (rowId !== undefined) cell.rowIds.push(rowId)
|
|
2952
|
-
}
|
|
405
|
+
const cell = (arr[ci] ??= fresh())
|
|
406
|
+
if (Number.isFinite(num)) note(cell, num, rowId)
|
|
2953
407
|
}
|
|
2954
408
|
}
|
|
2955
409
|
}
|
|
2956
410
|
|
|
2957
411
|
const entries = [...seriesMap.entries()].map(([name, arr]) => ({
|
|
2958
412
|
label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
|
|
2959
|
-
values: categories.map((_, i) =>
|
|
2960
|
-
const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] as Array<string | number> }
|
|
2961
|
-
return reduceCell(cell.sum, cell.count)
|
|
2962
|
-
}),
|
|
413
|
+
values: categories.map((_, i) => reduceCell(arr[i] ?? fresh())),
|
|
2963
414
|
rowIds: trackIds
|
|
2964
415
|
? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
|
|
2965
416
|
: undefined,
|
|
@@ -2969,7 +420,7 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
|
2969
420
|
const totals = categories.map((_, i) =>
|
|
2970
421
|
entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0),
|
|
2971
422
|
)
|
|
2972
|
-
const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none')
|
|
423
|
+
const sort = opts.sort ?? (opts.topN ? 'value-desc' : opts.bucket ? 'category' : 'none')
|
|
2973
424
|
const order = categories.map((_, i) => i)
|
|
2974
425
|
if (sort === 'value-desc') order.sort((a, b) => totals[b]! - totals[a]!)
|
|
2975
426
|
else if (sort === 'value-asc') order.sort((a, b) => totals[a]! - totals[b]!)
|
|
@@ -2978,9 +429,16 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
|
2978
429
|
let finalCategories: string[]
|
|
2979
430
|
let finalSeries: ChartSeries[]
|
|
2980
431
|
if (opts.topN && order.length > opts.topN) {
|
|
2981
|
-
const
|
|
2982
|
-
|
|
2983
|
-
|
|
432
|
+
const other = opts.otherLabel ?? 'Other'
|
|
433
|
+
// A real category may already be called "Other" (a survey's own bucket,
|
|
434
|
+
// a browser share's long tail). Two categories with one name is a
|
|
435
|
+
// duplicate key the renderer cannot draw, so the tail folds INTO that
|
|
436
|
+
// category rather than beside it: it is kept whether or not it made the
|
|
437
|
+
// top N, and the rest is added to it.
|
|
438
|
+
const own = order.find((i) => categories[i] === other)
|
|
439
|
+
const keep = order.slice(0, opts.topN).filter((i) => i !== own)
|
|
440
|
+
const rest = order.filter((i) => !keep.includes(i))
|
|
441
|
+
finalCategories = keep.map((i) => categories[i]!).concat(other)
|
|
2984
442
|
finalSeries = entries.map((e) => ({
|
|
2985
443
|
label: e.label,
|
|
2986
444
|
values: keep
|
|
@@ -3008,6 +466,146 @@ export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
|
3008
466
|
stacked: opts.stacked,
|
|
3009
467
|
stacked100: opts.stacked100,
|
|
3010
468
|
palette: opts.palette,
|
|
469
|
+
...(opts.bucket ? { xType: 'ordinal-time' as const } : {}),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The bar-plus-line spec a pareto chart draws: categories sorted by value,
|
|
475
|
+
* bars for the values, and a cumulative-share line on the right axis pinned to
|
|
476
|
+
* 0..100 with a reference line at `threshold` (default 80, the "vital few").
|
|
477
|
+
* `buildChart` calls this for `type: 'pareto'`; call it yourself to get at the
|
|
478
|
+
* spec, or pass a ready spec whose first series is the values.
|
|
479
|
+
*/
|
|
480
|
+
export function paretoSpec(spec: ChartSpec, threshold = 80): ChartSpec {
|
|
481
|
+
const src = spec.series[0]
|
|
482
|
+
if (!src) return { ...spec, type: 'bar' }
|
|
483
|
+
const order = spec.categories.map((_, i) => i).sort((a, b) => (src.values[b] ?? 0) - (src.values[a] ?? 0))
|
|
484
|
+
const sorted = pickCategories(spec, order)
|
|
485
|
+
const values = sorted.series[0]!.values.map((v) => (Number.isFinite(v) ? Math.max(0, v) : 0))
|
|
486
|
+
const total = values.reduce((a, b) => a + b, 0) || 1
|
|
487
|
+
let cum = 0
|
|
488
|
+
const share = values.map((v) => {
|
|
489
|
+
cum += v
|
|
490
|
+
return Math.round((cum / total) * 1000) / 10
|
|
491
|
+
})
|
|
492
|
+
return {
|
|
493
|
+
...sorted,
|
|
494
|
+
type: 'bar',
|
|
495
|
+
series: [
|
|
496
|
+
{ ...sorted.series[0]!, type: 'bar' },
|
|
497
|
+
{ label: 'Cumulative %', values: share, type: 'line', axis: 'right', marker: 'circle', color: sorted.series[0]!.overlayColor ?? '#ef4444' },
|
|
498
|
+
],
|
|
499
|
+
y2Axis: { min: 0, max: 100, tickInterval: 25, formatter: (v) => `${v}%`, ...(spec.y2Axis ?? {}) },
|
|
500
|
+
referenceLines: [
|
|
501
|
+
...(spec.referenceLines ?? []),
|
|
502
|
+
...(threshold > 0 ? [{ value: threshold, axis: 'right' as const, label: `${threshold}%`, dashed: true }] : []),
|
|
503
|
+
],
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Bin one numeric field of the rows into a histogram spec. The categories are
|
|
509
|
+
* the bin midpoints on a numeric axis and `binEdges` labels the edges, so the
|
|
510
|
+
* bars touch and the axis reads `0, 10, 20` rather than `5, 15, 25`.
|
|
511
|
+
* `series` splits the sample into one histogram per distinct value.
|
|
512
|
+
*/
|
|
513
|
+
export function rowsToHistogramSpec<T extends Record<string, unknown>>(
|
|
514
|
+
rows: ReadonlyArray<T>,
|
|
515
|
+
opts: {
|
|
516
|
+
value: keyof T & string
|
|
517
|
+
series?: keyof T & string
|
|
518
|
+
seriesLabel?: string
|
|
519
|
+
bins?: number
|
|
520
|
+
binWidth?: number
|
|
521
|
+
method?: 'sturges' | 'fd' | 'sqrt'
|
|
522
|
+
min?: number
|
|
523
|
+
max?: number
|
|
524
|
+
palette?: string[]
|
|
525
|
+
width?: number
|
|
526
|
+
height?: number
|
|
527
|
+
},
|
|
528
|
+
): ChartSpec {
|
|
529
|
+
const all = rows.map((r) => Number(r[opts.value])).filter(Number.isFinite)
|
|
530
|
+
// Bin the whole sample once so every split shares the same edges.
|
|
531
|
+
const bins = binValues(all, { bins: opts.bins, binWidth: opts.binWidth, method: opts.method, min: opts.min, max: opts.max })
|
|
532
|
+
const groups: Array<{ label: string; rows: ReadonlyArray<T> }> = opts.series
|
|
533
|
+
? [...new Set(rows.map((r) => String(r[opts.series!] ?? '')))].map((g) => ({ label: g, rows: rows.filter((r) => String(r[opts.series!] ?? '') === g) }))
|
|
534
|
+
: [{ label: opts.seriesLabel ?? String(opts.value), rows }]
|
|
535
|
+
const series: ChartSeries[] = groups.map((g) => {
|
|
536
|
+
const counts = new Array<number>(bins.counts.length).fill(0)
|
|
537
|
+
const lo = bins.edges[0] ?? 0
|
|
538
|
+
for (const r of g.rows) {
|
|
539
|
+
const n = Number(r[opts.value])
|
|
540
|
+
if (!Number.isFinite(n) || !bins.width) continue
|
|
541
|
+
let i = Math.floor((n - lo) / bins.width)
|
|
542
|
+
if (i >= counts.length) i = counts.length - 1
|
|
543
|
+
if (i < 0) i = 0
|
|
544
|
+
counts[i] = (counts[i] ?? 0) + 1
|
|
545
|
+
}
|
|
546
|
+
return { label: g.label, values: counts }
|
|
547
|
+
})
|
|
548
|
+
return {
|
|
549
|
+
type: 'histogram',
|
|
550
|
+
categories: bins.categories,
|
|
551
|
+
series,
|
|
552
|
+
binEdges: bins.edges,
|
|
553
|
+
xType: 'number',
|
|
554
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
555
|
+
...(opts.width ? { width: opts.width } : {}),
|
|
556
|
+
...(opts.height ? { height: opts.height } : {}),
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* A range (floating) bar or area spec from rows: one row per category with a
|
|
562
|
+
* low field and a high field, e.g. a day's low and high temperature, or a
|
|
563
|
+
* salary band. `series` splits into one range per distinct value.
|
|
564
|
+
*/
|
|
565
|
+
export function rowsToRangeSpec<T extends Record<string, unknown>>(
|
|
566
|
+
rows: ReadonlyArray<T>,
|
|
567
|
+
opts: {
|
|
568
|
+
type?: 'range-bar' | 'range-area'
|
|
569
|
+
category: keyof T & string
|
|
570
|
+
low: keyof T & string
|
|
571
|
+
high: keyof T & string
|
|
572
|
+
series?: keyof T & string
|
|
573
|
+
seriesLabel?: string
|
|
574
|
+
palette?: string[]
|
|
575
|
+
width?: number
|
|
576
|
+
height?: number
|
|
577
|
+
},
|
|
578
|
+
): ChartSpec {
|
|
579
|
+
const cats: string[] = []
|
|
580
|
+
const seen = new Set<string>()
|
|
581
|
+
for (const r of rows) {
|
|
582
|
+
const c = String(r[opts.category] ?? '')
|
|
583
|
+
if (!seen.has(c)) { seen.add(c); cats.push(c) }
|
|
584
|
+
}
|
|
585
|
+
const names = opts.series
|
|
586
|
+
? [...new Set(rows.map((r) => String(r[opts.series!] ?? '')))]
|
|
587
|
+
: [opts.seriesLabel ?? `${String(opts.low)} to ${String(opts.high)}`]
|
|
588
|
+
const series: ChartSeries[] = names.map((name) => {
|
|
589
|
+
const lows = new Array<number>(cats.length).fill(Number.NaN)
|
|
590
|
+
const highs = new Array<number>(cats.length).fill(Number.NaN)
|
|
591
|
+
for (const r of rows) {
|
|
592
|
+
if (opts.series && String(r[opts.series] ?? '') !== name) continue
|
|
593
|
+
const i = cats.indexOf(String(r[opts.category] ?? ''))
|
|
594
|
+
const lo = Number(r[opts.low])
|
|
595
|
+
const hi = Number(r[opts.high])
|
|
596
|
+
if (i < 0 || !Number.isFinite(lo) || !Number.isFinite(hi)) continue
|
|
597
|
+
lows[i] = Math.min(lo, hi)
|
|
598
|
+
highs[i] = Math.max(lo, hi)
|
|
599
|
+
}
|
|
600
|
+
return { label: name, values: highs, lowValues: lows }
|
|
601
|
+
})
|
|
602
|
+
return {
|
|
603
|
+
type: opts.type ?? 'range-bar',
|
|
604
|
+
categories: cats,
|
|
605
|
+
series,
|
|
606
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
607
|
+
...(opts.width ? { width: opts.width } : {}),
|
|
608
|
+
...(opts.height ? { height: opts.height } : {}),
|
|
3011
609
|
}
|
|
3012
610
|
}
|
|
3013
611
|
|
|
@@ -3107,6 +705,125 @@ export function specToSankey(spec: ChartSpec): {
|
|
|
3107
705
|
}
|
|
3108
706
|
}
|
|
3109
707
|
|
|
708
|
+
|
|
709
|
+
// ---- Panel format state -----------------------------------------------------
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* What the grid panel's chart builder lets a reader change about the LOOK of
|
|
713
|
+
* a chart, as plain data so it can be saved with the view and applied to the
|
|
714
|
+
* spec the panel derives on every data change. Every field is optional and
|
|
715
|
+
* additive; an absent field leaves the derived spec alone. Series are keyed
|
|
716
|
+
* by label, so a per-series colour survives a re-sort of the categories.
|
|
717
|
+
*/
|
|
718
|
+
export type ChartFormatState = {
|
|
719
|
+
title?: string
|
|
720
|
+
subtitle?: string
|
|
721
|
+
caption?: string
|
|
722
|
+
/** Where the legend goes; `false` hides it. The panel reads this for the
|
|
723
|
+
* component prop, it is not a spec field. */
|
|
724
|
+
legend?: 'top' | 'bottom' | 'left' | 'right' | false
|
|
725
|
+
xAxis?: { title?: string; gridLines?: boolean; labelRotation?: number | 'auto' }
|
|
726
|
+
yAxis?: { min?: number | null; max?: number | null; title?: string; gridLines?: boolean; format?: ChartValueFormat }
|
|
727
|
+
y2Axis?: { min?: number | null; max?: number | null; title?: string; gridLines?: boolean; format?: ChartValueFormat }
|
|
728
|
+
/** Per-series overrides, keyed by series label. */
|
|
729
|
+
series?: Record<string, {
|
|
730
|
+
color?: string
|
|
731
|
+
type?: 'bar' | 'line' | 'area'
|
|
732
|
+
axis?: 'left' | 'right'
|
|
733
|
+
marker?: ChartMarkerShape
|
|
734
|
+
strokeWidth?: number
|
|
735
|
+
dash?: string
|
|
736
|
+
smooth?: boolean
|
|
737
|
+
/** The stack group a bar or area joins; an empty string leaves the
|
|
738
|
+
* group. See {@link ChartSeries.stack}. */
|
|
739
|
+
stack?: string
|
|
740
|
+
}>
|
|
741
|
+
/** Where data labels sit; `null` hides them. */
|
|
742
|
+
dataLabels?: 'inside' | 'outside' | 'top' | 'center' | null
|
|
743
|
+
palette?: string[] | null
|
|
744
|
+
/** Series names at the end of each line (`spec.seriesLabels`). */
|
|
745
|
+
seriesLabels?: boolean
|
|
746
|
+
/** The axis value pills on the crosshair. A component prop, like
|
|
747
|
+
* `legend`: the panel reads it, the engine does not. */
|
|
748
|
+
crosshairLabels?: boolean
|
|
749
|
+
/** Below this rendered width in px the chart goes compact: no series or
|
|
750
|
+
* data labels, vertical category labels, no legend. Appended to the
|
|
751
|
+
* spec's own `responsive` rules as {@link CHART_RESPONSIVE_PRESETS}.compact. */
|
|
752
|
+
compactBelow?: number | null
|
|
753
|
+
/** Font, size and colours for the whole chart (`spec.style`), merged
|
|
754
|
+
* over what the spec carries. */
|
|
755
|
+
style?: ChartStyle
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Responsive rules the builder offers by name. `compact(maxWidth)` is the
|
|
760
|
+
* one the Format tab writes through `compactBelow`: the labels that crowd a
|
|
761
|
+
* narrow chart go, the category labels turn vertical, and the legend hides.
|
|
762
|
+
*/
|
|
763
|
+
export const CHART_RESPONSIVE_PRESETS = {
|
|
764
|
+
compact: (maxWidth: number): ChartResponsiveRule => ({
|
|
765
|
+
maxWidth,
|
|
766
|
+
spec: { seriesLabels: false, dataLabels: { show: false }, xAxis: { labelRotation: 90 } },
|
|
767
|
+
legend: false,
|
|
768
|
+
}),
|
|
769
|
+
} as const
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Apply a {@link ChartFormatState} to a spec: a new spec with the titles,
|
|
773
|
+
* axis settings, palette and per-series styling merged in. Pure, so the
|
|
774
|
+
* panel can run it on every derived spec and a saved format keeps applying
|
|
775
|
+
* to fresh data. Unknown series labels are ignored.
|
|
776
|
+
*/
|
|
777
|
+
export function applyChartFormat(spec: ChartSpec, format: ChartFormatState | null | undefined): ChartSpec {
|
|
778
|
+
if (!format) return spec
|
|
779
|
+
const out: ChartSpec = { ...spec }
|
|
780
|
+
if (format.title !== undefined) out.title = format.title || undefined
|
|
781
|
+
if (format.subtitle !== undefined) out.subtitle = format.subtitle || undefined
|
|
782
|
+
if (format.caption !== undefined) out.caption = format.caption || undefined
|
|
783
|
+
if (format.palette) out.palette = format.palette
|
|
784
|
+
if (format.seriesLabels !== undefined) out.seriesLabels = format.seriesLabels
|
|
785
|
+
if (format.style) out.style = { ...spec.style, ...format.style }
|
|
786
|
+
if (format.compactBelow != null && format.compactBelow > 0) {
|
|
787
|
+
out.responsive = [...(spec.responsive ?? []), CHART_RESPONSIVE_PRESETS.compact(format.compactBelow)]
|
|
788
|
+
}
|
|
789
|
+
const axis = (base: ChartAxisConfig | undefined, f: NonNullable<ChartFormatState['yAxis']> | undefined): ChartAxisConfig | undefined => {
|
|
790
|
+
if (!f) return base
|
|
791
|
+
const next: ChartAxisConfig = { ...base }
|
|
792
|
+
if (f.min !== undefined) { if (f.min === null) delete next.min; else next.min = f.min }
|
|
793
|
+
if (f.max !== undefined) { if (f.max === null) delete next.max; else next.max = f.max }
|
|
794
|
+
if (f.title !== undefined) next.title = f.title || undefined
|
|
795
|
+
if (f.gridLines !== undefined) next.gridLines = f.gridLines
|
|
796
|
+
if (f.format !== undefined) next.format = f.format
|
|
797
|
+
return next
|
|
798
|
+
}
|
|
799
|
+
if (format.xAxis) {
|
|
800
|
+
const next: ChartAxisConfig = { ...out.xAxis }
|
|
801
|
+
if (format.xAxis.title !== undefined) next.title = format.xAxis.title || undefined
|
|
802
|
+
if (format.xAxis.gridLines !== undefined) next.gridLines = format.xAxis.gridLines
|
|
803
|
+
if (format.xAxis.labelRotation !== undefined) next.labelRotation = format.xAxis.labelRotation
|
|
804
|
+
out.xAxis = next
|
|
805
|
+
}
|
|
806
|
+
if (format.yAxis) out.yAxis = axis(out.yAxis, format.yAxis)
|
|
807
|
+
if (format.y2Axis) out.y2Axis = axis(out.y2Axis, format.y2Axis)
|
|
808
|
+
if (format.series) {
|
|
809
|
+
out.series = spec.series.map((s) => {
|
|
810
|
+
const f = format.series![s.label]
|
|
811
|
+
if (!f) return s
|
|
812
|
+
const next: ChartSeries = { ...s }
|
|
813
|
+
if (f.color) next.color = f.color
|
|
814
|
+
if (f.type) next.type = f.type
|
|
815
|
+
if (f.axis) next.axis = f.axis
|
|
816
|
+
if (f.marker) next.marker = f.marker
|
|
817
|
+
if (f.strokeWidth !== undefined) next.strokeWidth = f.strokeWidth
|
|
818
|
+
if (f.dash !== undefined) next.dash = f.dash || undefined
|
|
819
|
+
if (f.smooth !== undefined) next.smooth = f.smooth
|
|
820
|
+
if (f.stack !== undefined) next.stack = f.stack || undefined
|
|
821
|
+
return next
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
return out
|
|
825
|
+
}
|
|
826
|
+
|
|
3110
827
|
/**
|
|
3111
828
|
* The chart types that read ROWS directly instead of a grouped grid, behind one
|
|
3112
829
|
* call. Returns `null` for every other type, which then goes through
|
|
@@ -3128,13 +845,32 @@ export function rowsToDirectSpec<T extends Record<string, unknown>>(
|
|
|
3128
845
|
/** Scatter's Y measure. */
|
|
3129
846
|
value2?: string
|
|
3130
847
|
series?: string
|
|
3131
|
-
reduce?:
|
|
848
|
+
reduce?: ChartReducer
|
|
3132
849
|
palette?: string[]
|
|
850
|
+
/** Histogram: how many bins. Default: Sturges' rule. */
|
|
851
|
+
bins?: number
|
|
852
|
+
/** Candlestick / OHLC: the price columns, an optional volume, a calendar
|
|
853
|
+
* bucket to roll the bars up to, and the candle style. `category` is
|
|
854
|
+
* the date column. */
|
|
855
|
+
ohlc?: { open: string; high: string; low: string; close: string; volume?: string; bucket?: ChartTimeBucket; candleStyle?: ChartSpec['candleStyle']; lastPriceLine?: boolean }
|
|
3133
856
|
},
|
|
3134
857
|
): ChartSpec | null {
|
|
3135
858
|
const cat = opts.category as (keyof T & string) | undefined
|
|
3136
859
|
const val = opts.value as (keyof T & string) | undefined
|
|
3137
860
|
const ser = opts.series as (keyof T & string) | undefined
|
|
861
|
+
if (type === 'candlestick' || type === 'ohlc') {
|
|
862
|
+
const o = opts.ohlc
|
|
863
|
+
if (!cat || !o) return null
|
|
864
|
+
const k = (f: string) => f as keyof T & string
|
|
865
|
+
return rowsToOhlcSpec(rows, {
|
|
866
|
+
date: cat, open: k(o.open), high: k(o.high), low: k(o.low), close: k(o.close),
|
|
867
|
+
...(o.volume ? { volume: k(o.volume) } : {}),
|
|
868
|
+
...(o.bucket ? { bucket: o.bucket } : {}),
|
|
869
|
+
...(o.candleStyle ? { candleStyle: o.candleStyle } : {}),
|
|
870
|
+
...(o.lastPriceLine ? { lastPriceLine: true } : {}),
|
|
871
|
+
type,
|
|
872
|
+
})
|
|
873
|
+
}
|
|
3138
874
|
if (type === 'scatter') {
|
|
3139
875
|
const y = opts.value2 as (keyof T & string) | undefined
|
|
3140
876
|
if (!val || !y) return null
|
|
@@ -3159,6 +895,46 @@ export function rowsToDirectSpec<T extends Record<string, unknown>>(
|
|
|
3159
895
|
if (opts.palette) spec.palette = opts.palette
|
|
3160
896
|
return spec
|
|
3161
897
|
}
|
|
898
|
+
if (type === 'histogram') {
|
|
899
|
+
if (!val) return null
|
|
900
|
+
return rowsToHistogramSpec(rows, {
|
|
901
|
+
value: val,
|
|
902
|
+
...(ser ? { series: ser } : {}),
|
|
903
|
+
...(opts.bins ? { bins: opts.bins } : {}),
|
|
904
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
905
|
+
})
|
|
906
|
+
}
|
|
907
|
+
if (type === 'range-bar' || type === 'range-area' || type === 'dumbbell') {
|
|
908
|
+
// Two measures: the first is the low end, the second the high end, each
|
|
909
|
+
// reduced per category the way a bar would be.
|
|
910
|
+
const hi = opts.value2 as (keyof T & string) | undefined
|
|
911
|
+
if (!cat || !val || !hi) return null
|
|
912
|
+
const lows = rowsToChartSpec(rows, { type: 'bar', category: cat, value: val, ...(ser ? { series: ser } : {}), reduce: opts.reduce ?? 'sum' })
|
|
913
|
+
const highs = rowsToChartSpec(rows, { type: 'bar', category: cat, value: hi, ...(ser ? { series: ser } : {}), reduce: opts.reduce ?? 'sum' })
|
|
914
|
+
return {
|
|
915
|
+
type,
|
|
916
|
+
categories: highs.categories,
|
|
917
|
+
series: highs.series.map((s, i) => ({
|
|
918
|
+
...s,
|
|
919
|
+
label: ser ? s.label : `${String(val)} to ${String(hi)}`,
|
|
920
|
+
lowValues: lows.series[i]?.values.map((v, k) => Math.min(v, s.values[k] ?? v)) ?? [],
|
|
921
|
+
values: s.values.map((v, k) => Math.max(v, lows.series[i]?.values[k] ?? v)),
|
|
922
|
+
})),
|
|
923
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (type === 'bullet') {
|
|
927
|
+
if (!cat || !val) return null
|
|
928
|
+
const measure = rowsToChartSpec(rows, { type: 'bar', category: cat, value: val, reduce: opts.reduce ?? 'sum' })
|
|
929
|
+
const t = opts.value2 as (keyof T & string) | undefined
|
|
930
|
+
const target = t ? rowsToChartSpec(rows, { type: 'bar', category: cat, value: t, reduce: opts.reduce ?? 'sum' }) : null
|
|
931
|
+
return {
|
|
932
|
+
type: 'bullet',
|
|
933
|
+
categories: measure.categories,
|
|
934
|
+
series: [{ ...measure.series[0]!, ...(target ? { targets: target.series[0]!.values } : {}) }],
|
|
935
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
936
|
+
}
|
|
937
|
+
}
|
|
3162
938
|
return null
|
|
3163
939
|
}
|
|
3164
940
|
|
|
@@ -3306,7 +1082,7 @@ export function rowsToGaugeSpec<T extends Record<string, unknown>>(
|
|
|
3306
1082
|
rows: ReadonlyArray<T>,
|
|
3307
1083
|
opts: {
|
|
3308
1084
|
value: keyof T & string
|
|
3309
|
-
reduce?:
|
|
1085
|
+
reduce?: ChartReducer
|
|
3310
1086
|
min?: number
|
|
3311
1087
|
max?: number
|
|
3312
1088
|
unit?: string
|
|
@@ -3315,16 +1091,13 @@ export function rowsToGaugeSpec<T extends Record<string, unknown>>(
|
|
|
3315
1091
|
height?: number
|
|
3316
1092
|
},
|
|
3317
1093
|
): ChartSpec {
|
|
3318
|
-
const
|
|
3319
|
-
let sum = 0
|
|
3320
|
-
let count = 0
|
|
1094
|
+
const samples: number[] = []
|
|
3321
1095
|
for (const row of rows) {
|
|
3322
1096
|
const v = Number(row[opts.value])
|
|
3323
|
-
if (
|
|
3324
|
-
sum += v
|
|
3325
|
-
count += 1
|
|
1097
|
+
if (Number.isFinite(v)) samples.push(v)
|
|
3326
1098
|
}
|
|
3327
|
-
const
|
|
1099
|
+
const reduced = reduceValues(samples, opts.reduce ?? 'sum')
|
|
1100
|
+
const value = Number.isFinite(reduced) ? reduced : 0
|
|
3328
1101
|
const min = opts.min ?? Math.min(0, value)
|
|
3329
1102
|
const max = opts.max ?? (value > min ? niceScale(min, value).max : min + 1)
|
|
3330
1103
|
return {
|