@svgrid/grid 3.0.0 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -88
- package/README.md +199 -199
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +190 -181
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/GridMenus.svelte +18 -10
- package/dist/GridMenus.svelte.d.ts +2 -1
- package/dist/SvAutoComplete.svelte +169 -169
- package/dist/SvAvatar.svelte +75 -75
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +102 -102
- package/dist/SvCircularProgress.svelte +109 -109
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +279 -279
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +163 -163
- package/dist/SvDrawer.svelte +254 -254
- package/dist/SvDropDownList.svelte +378 -378
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.controller.svelte.d.ts +17 -0
- package/dist/SvGrid.controller.svelte.js +128 -14
- package/dist/SvGrid.css +79 -10
- package/dist/SvGrid.svelte +191 -186
- package/dist/SvGrid.types.d.ts +42 -1
- package/dist/SvGridCellEditor.svelte +748 -744
- package/dist/SvGridChart.svelte +2246 -1716
- package/dist/SvGridChart.svelte.d.ts +63 -1
- package/dist/SvGridChartPanel.svelte +633 -485
- package/dist/SvGridChartView.svelte +79 -70
- package/dist/SvGridChartView.svelte.d.ts +7 -0
- package/dist/SvGridDropdown.svelte +728 -728
- package/dist/SvGridSelect.svelte +270 -270
- package/dist/SvGroupCell.svelte +141 -116
- package/dist/SvGroupCell.svelte.d.ts +8 -0
- package/dist/SvListBox.svelte +334 -334
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +293 -293
- package/dist/SvNumberInput.svelte +172 -172
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +133 -133
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvProgress.svelte +116 -116
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRating.svelte +112 -112
- package/dist/SvResult.svelte +73 -73
- package/dist/SvRichText.svelte +211 -211
- package/dist/SvRowGroupPanel.svelte +186 -170
- package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +203 -203
- package/dist/SvSwitchButton.svelte +108 -108
- package/dist/SvTagsInput.svelte +115 -115
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +208 -208
- package/dist/SvTree.svelte +444 -444
- package/dist/SvTreeSelect.svelte +239 -239
- package/dist/ai.d.ts +11 -2
- package/dist/ai.js +51 -7
- package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
- package/dist/cdn/GridMenus-M2XP9t0_.js +603 -0
- package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
- package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
- package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
- package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
- package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-CxfakxR0.js} +5 -3
- package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-Dm_YEx9L.js} +5 -3
- package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
- package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
- package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
- package/dist/cdn/chart-CKJRCPOk.js +1652 -0
- package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
- package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
- package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
- package/dist/cdn/{src-B_YS5AOc.js → src-BJ2opd0j.js} +3642 -3450
- package/dist/cdn/{src-DYXXpuSk.js → src-DNbz94Ct.js} +8649 -8457
- package/dist/cdn/svgrid.js +16 -15
- package/dist/cdn/svgrid.svelte-external.js +16 -15
- package/dist/cell-formatting.d.ts +7 -0
- package/dist/cell-formatting.js +7 -1
- package/dist/chart-export.d.ts +23 -2
- package/dist/chart-export.js +88 -14
- package/dist/chart.d.ts +410 -18
- package/dist/chart.js +824 -24
- package/dist/editing.js +22 -13
- package/dist/grid-icons.d.ts +49 -0
- package/dist/grid-icons.js +86 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/package.json +1 -1
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +190 -181
- package/src/GridMenus.svelte +18 -10
- package/src/SvAutoComplete.svelte +169 -169
- package/src/SvAvatar.svelte +75 -75
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +102 -102
- package/src/SvCircularProgress.svelte +109 -109
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +279 -279
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +163 -163
- package/src/SvDrawer.svelte +254 -254
- package/src/SvDropDownList.svelte +378 -378
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.controller.svelte.ts +146 -13
- package/src/SvGrid.css +79 -10
- package/src/SvGrid.svelte +191 -186
- package/src/SvGrid.types.ts +2133 -2092
- package/src/SvGridCellEditor.svelte +748 -744
- package/src/SvGridChart.svelte +2246 -1716
- package/src/SvGridChart.test.ts +243 -1
- package/src/SvGridChartPanel.svelte +633 -485
- package/src/SvGridChartView.svelte +79 -70
- package/src/SvGridDropdown.svelte +728 -728
- package/src/SvGridSelect.svelte +270 -270
- package/src/SvGroupCell.svelte +141 -116
- package/src/SvListBox.svelte +334 -334
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +293 -293
- package/src/SvNumberInput.svelte +172 -172
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +133 -133
- package/src/SvPopover.svelte +197 -197
- package/src/SvProgress.svelte +116 -116
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRating.svelte +112 -112
- package/src/SvResult.svelte +73 -73
- package/src/SvRichText.svelte +211 -211
- package/src/SvRowGroupPanel.svelte +186 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +203 -203
- package/src/SvSwitchButton.svelte +108 -108
- package/src/SvTagsInput.svelte +115 -115
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +208 -208
- package/src/SvTree.svelte +444 -444
- package/src/SvTreeSelect.svelte +239 -239
- package/src/a11y/dismissable.test.ts +119 -119
- package/src/a11y/dismissable.ts +114 -114
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +561 -502
- package/src/ai.ts +1472 -1419
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +846 -846
- package/src/builtin-editors.grid.test.ts +83 -83
- package/src/cell-formatting.ts +177 -171
- package/src/cell-render.test.ts +513 -513
- package/src/cell-render.ts +496 -496
- package/src/cell-values.ts +148 -148
- package/src/chart-export.test.ts +112 -0
- package/src/chart-export.ts +270 -202
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.test.ts +830 -1
- package/src/chart.ts +3342 -2321
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/column-groups.ts +78 -78
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1865 -1865
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createListbox.svelte.ts +250 -250
- package/src/createMenu.svelte.ts +224 -224
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +322 -322
- package/src/datetime/date-core.ts +206 -206
- package/src/datetime/date-restrict.ts +61 -61
- package/src/datetime/timezone.ts +135 -135
- package/src/dock-manager-model.ts +596 -596
- package/src/dock-model.ts +374 -374
- package/src/editing.test.ts +974 -974
- package/src/editing.ts +618 -609
- package/src/editor-contract.ts +171 -171
- package/src/editor-registry.grid.test.ts +144 -144
- package/src/editor-registry.ts +122 -122
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +601 -601
- package/src/filter-operators.ts +160 -160
- package/src/filtering/excel-filters.ts +325 -325
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/grid-icons.test.ts +46 -0
- package/src/grid-icons.ts +184 -0
- package/src/group-display.test.ts +167 -167
- package/src/group-display.ts +200 -200
- package/src/headless.ts +87 -87
- package/src/icon-overrides.grid.test.ts +188 -0
- package/src/icon-seam.test.ts +100 -0
- package/src/index.ts +19 -0
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard-handlers.ts +270 -270
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +179 -179
- package/src/menus.ts +597 -597
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/positioning.ts +268 -268
- package/src/render-component.ts +28 -28
- package/src/row-drag.test.ts +401 -401
- package/src/row-drag.ts +419 -419
- package/src/row-resize.test.ts +524 -524
- package/src/row-resize.ts +228 -228
- package/src/scheduler-ical.ts +181 -181
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +873 -873
- package/src/selection.test.ts +885 -885
- package/src/server-data-source.test.ts +383 -383
- package/src/server-data-source.ts +469 -469
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +488 -488
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/summaries.ts +113 -113
- package/src/svgrid-wrapper.types.ts +563 -563
- package/src/svgrid.async-editor-options.test.ts +273 -273
- package/src/svgrid.auto-row-height.test.ts +204 -204
- package/src/svgrid.behavior.test.ts +910 -910
- package/src/svgrid.charting.test.ts +654 -534
- package/src/svgrid.comments-autocomplete.test.ts +127 -127
- package/src/svgrid.context-menu.test.ts +147 -147
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-depth.test.ts +163 -163
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
- package/src/svgrid.filter-menu-scroll.test.ts +112 -112
- package/src/svgrid.grand-total.test.ts +188 -188
- package/src/svgrid.group-display-mode.test.ts +171 -171
- package/src/svgrid.group-footers.test.ts +121 -121
- package/src/svgrid.group-pagination.test.ts +153 -153
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.tree-data.test.ts +186 -186
- package/src/svgrid.wrapper.test.ts +63 -63
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +288 -288
- package/src/toast-store.svelte.ts +250 -250
- package/src/toast-store.test.ts +147 -147
- package/src/tree-row-model.test.ts +168 -168
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-localization.test.ts +113 -113
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +322 -322
- package/dist/cdn/GridMenus-DMihUtma.js +0 -600
- package/dist/cdn/GridMenus-nmNDj1a3.js +0 -601
- package/dist/cdn/SvGridChart-Bs2GIR2Q.js +0 -1202
- package/dist/cdn/SvGridChart-CwhFz7GV.js +0 -1203
- package/dist/cdn/SvGridChartPanel-1WXuStzM.js +0 -561
- package/dist/cdn/SvGridChartPanel-e5_nqNP9.js +0 -585
- package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
package/dist/chart.js
CHANGED
|
@@ -1,3 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integrated chart geometry. Pure functions that turn categories + numeric
|
|
3
|
+
* series into SVG primitives - the "chart from a grid range" enterprise
|
|
4
|
+
* feature without bundling a charting library. The `<SvGridChart>` component
|
|
5
|
+
* paints the result; this module has no DOM so it is unit-testable.
|
|
6
|
+
*
|
|
7
|
+
* Supports: grouped + stacked bars, line, area, pie/donut, combo charts
|
|
8
|
+
* (per-series type), a secondary (right) Y axis, signed Y domains (negative
|
|
9
|
+
* values drop below a zero baseline), and nice auto-scaled ticks.
|
|
10
|
+
*/
|
|
11
|
+
// The one import this module has. `Intl.NumberFormat` is expensive enough to
|
|
12
|
+
// construct that a chart with 8 axis ticks and 200 data labels would rebuild it
|
|
13
|
+
// 200 times a frame, and the grid already solved that for cells - so reuse that
|
|
14
|
+
// cache rather than starting a second one. Still DOM-free: it is plain TS.
|
|
15
|
+
import { getNumberFormatter } from './cell-formatting.js';
|
|
16
|
+
/**
|
|
17
|
+
* Five-number summary of a raw sample, with the 1.5 IQR whisker rule.
|
|
18
|
+
*
|
|
19
|
+
* Whiskers stop at the last observation INSIDE the fence rather than at the
|
|
20
|
+
* fence itself, which is what makes them read as real data; anything past them
|
|
21
|
+
* comes back in `outliers`. Quartiles use linear interpolation between the two
|
|
22
|
+
* neighbouring order statistics.
|
|
23
|
+
*
|
|
24
|
+
* Returns `null` for an empty sample, so a category with no observations is a
|
|
25
|
+
* gap rather than a box drawn at zero.
|
|
26
|
+
*/
|
|
27
|
+
export function boxStats(sample, whisker = 1.5) {
|
|
28
|
+
const v = sample.filter((n) => Number.isFinite(n)).slice().sort((a, b) => a - b);
|
|
29
|
+
if (!v.length)
|
|
30
|
+
return null;
|
|
31
|
+
const q = (p) => {
|
|
32
|
+
const pos = (v.length - 1) * p;
|
|
33
|
+
const lo = Math.floor(pos);
|
|
34
|
+
const hi = Math.ceil(pos);
|
|
35
|
+
return lo === hi ? v[lo] : v[lo] + (v[hi] - v[lo]) * (pos - lo);
|
|
36
|
+
};
|
|
37
|
+
const q1 = q(0.25);
|
|
38
|
+
const median = q(0.5);
|
|
39
|
+
const q3 = q(0.75);
|
|
40
|
+
const fenceLo = q1 - whisker * (q3 - q1);
|
|
41
|
+
const fenceHi = q3 + whisker * (q3 - q1);
|
|
42
|
+
const inside = v.filter((n) => n >= fenceLo && n <= fenceHi);
|
|
43
|
+
const outliers = v.filter((n) => n < fenceLo || n > fenceHi);
|
|
44
|
+
return {
|
|
45
|
+
// `inside` can only be empty if every point is an outlier, which the fence
|
|
46
|
+
// rule makes impossible (q1 and q3 are always within it) - but a degenerate
|
|
47
|
+
// sample should still produce a box rather than `undefined` coordinates.
|
|
48
|
+
min: inside.length ? inside[0] : v[0],
|
|
49
|
+
q1,
|
|
50
|
+
median,
|
|
51
|
+
q3,
|
|
52
|
+
max: inside.length ? inside[inside.length - 1] : v[v.length - 1],
|
|
53
|
+
...(outliers.length ? { outliers } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Category-axis labels, thinned so they do not overlap.
|
|
58
|
+
*
|
|
59
|
+
* A category axis used to emit one tick per category however many there were.
|
|
60
|
+
* At 5000 categories that is 5000 `<text>` nodes stacked into an unreadable
|
|
61
|
+
* grey band - the labels were the single biggest thing the renderer had to put
|
|
62
|
+
* in the DOM, and none of them could be read. The time and ordinal-time axes
|
|
63
|
+
* already thinned themselves (`dateTicks` / `ordinalDateTicks`); this brings
|
|
64
|
+
* the plain category axis in line.
|
|
65
|
+
*
|
|
66
|
+
* The first category always gets a label, so a thinned axis still starts where
|
|
67
|
+
* the data does.
|
|
68
|
+
*/
|
|
69
|
+
function thinCategoryTicks(categories, xCenter, slot, rotated) {
|
|
70
|
+
// Rotated labels run diagonally and pack far tighter than upright ones. 18px
|
|
71
|
+
// is deliberately just under the spacing a 40-category chart at 800px already
|
|
72
|
+
// had, so charts that read fine before are untouched and only genuinely
|
|
73
|
+
// overlapping axes get thinned.
|
|
74
|
+
const minPx = rotated ? 18 : 60;
|
|
75
|
+
const step = Math.max(1, Math.ceil(minPx / Math.max(slot, 0.001)));
|
|
76
|
+
const out = [];
|
|
77
|
+
for (let i = 0; i < categories.length; i += step)
|
|
78
|
+
out.push({ label: categories[i], x: xCenter(i) });
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** Normalize one `errors` entry to an absolute low/high pair around `value`. */
|
|
82
|
+
function errorSpan(e, value) {
|
|
83
|
+
if (e == null)
|
|
84
|
+
return null;
|
|
85
|
+
if (typeof e === 'number') {
|
|
86
|
+
if (!Number.isFinite(e))
|
|
87
|
+
return null;
|
|
88
|
+
const m = Math.abs(e);
|
|
89
|
+
return { lo: value - m, hi: value + m };
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isFinite(e.lo) || !Number.isFinite(e.hi))
|
|
92
|
+
return null;
|
|
93
|
+
return { lo: Math.min(e.lo, e.hi), hi: Math.max(e.lo, e.hi) };
|
|
94
|
+
}
|
|
1
95
|
/** Series colours used when a {@link ChartSeries} sets none, in order. */
|
|
2
96
|
export const DEFAULT_PALETTE = [
|
|
3
97
|
'#2563eb',
|
|
@@ -112,6 +206,48 @@ function project(value, min, max, isLog) {
|
|
|
112
206
|
}
|
|
113
207
|
return (value - min) / (max - min);
|
|
114
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Turn a laid-out chart's axes into functions, so a caller can draw its own
|
|
211
|
+
* marks in the same coordinates the built-in ones use.
|
|
212
|
+
*
|
|
213
|
+
* This is the custom-series seam. Rather than a registry of mark types, the
|
|
214
|
+
* chart hands over its geometry and its scales and lets the caller render
|
|
215
|
+
* whatever SVG it likes into the plot - which is the Svelte-shaped answer, and
|
|
216
|
+
* means a custom mark is ordinary markup rather than a plugin.
|
|
217
|
+
*
|
|
218
|
+
* Deriving the scale from `geo.axes` matters: the domain a chart drew against
|
|
219
|
+
* is the NICE-ROUNDED one, stretched to include zero for bar charts and any
|
|
220
|
+
* reference lines. Recomputing it from the data outside would land custom marks
|
|
221
|
+
* a few pixels off the built-in ones, in a way that looks like a rendering bug.
|
|
222
|
+
*
|
|
223
|
+
* Returns `null` for a chart with no cartesian axes (pie, gauge, treemap,
|
|
224
|
+
* sankey, calendar, radar, funnel), where plot coordinates mean nothing.
|
|
225
|
+
*/
|
|
226
|
+
export function chartScales(geo) {
|
|
227
|
+
const a = geo.axes;
|
|
228
|
+
if (!a)
|
|
229
|
+
return null;
|
|
230
|
+
const { x: px, y: py, w: pw, h: ph } = geo.plot;
|
|
231
|
+
const yFor = (dom) => (value) => {
|
|
232
|
+
const t = project(value, dom.min, dom.max, dom.log);
|
|
233
|
+
return t === null ? Number.NaN : py + ph - t * ph;
|
|
234
|
+
};
|
|
235
|
+
const left = yFor(a.y);
|
|
236
|
+
const right = a.y2 ? yFor(a.y2) : left;
|
|
237
|
+
const invFor = (dom) => (y) => {
|
|
238
|
+
const t = ph === 0 ? 0 : (py + ph - y) / ph;
|
|
239
|
+
if (!dom.log)
|
|
240
|
+
return dom.min + t * (dom.max - dom.min);
|
|
241
|
+
const lo = Math.log10(dom.min);
|
|
242
|
+
return 10 ** (lo + t * (Math.log10(dom.max) - lo));
|
|
243
|
+
};
|
|
244
|
+
return {
|
|
245
|
+
xOf: (i) => px + a.slot * i + a.slot / 2,
|
|
246
|
+
yOf: (value, axis) => (axis === 'right' ? right(value) : left(value)),
|
|
247
|
+
xInvert: (x) => Math.max(0, Math.min(a.count - 1, Math.floor((x - px) / (a.slot || 1)))),
|
|
248
|
+
yInvert: (y, axis) => (axis === 'right' && a.y2 ? invFor(a.y2)(y) : invFor(a.y)(y)),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
115
251
|
// ---- Overlay math: trendline + moving averages -----------------------
|
|
116
252
|
/** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
|
|
117
253
|
* monotone cubic interpolation (preserves local extrema - no overshoots).
|
|
@@ -314,13 +450,38 @@ function fmtTick(n) {
|
|
|
314
450
|
}
|
|
315
451
|
/**
|
|
316
452
|
* Format a numeric value for display, honouring an optional `valueFormat`.
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
453
|
+
*
|
|
454
|
+
* Two modes, on purpose. With no `locale` and no `currency` this is the original
|
|
455
|
+
* locale-free output: the compact `1.2k` / `1.2M` base, currency prefixed with
|
|
456
|
+
* `$` (sign outside), percent multiplied by 100 and suffixed `%`. Set either one
|
|
457
|
+
* and it switches to `Intl.NumberFormat`, so separators, the decimal mark and the
|
|
458
|
+
* compact suffixes all follow the locale.
|
|
459
|
+
*
|
|
460
|
+
* Keeping the old path as the default is deliberate rather than lazy. `Intl`'s
|
|
461
|
+
* compact notation is not the same string even for `en-US` (`1.2K`, capital),
|
|
462
|
+
* so formatting everything through it would silently restyle every axis in every
|
|
463
|
+
* existing chart. Opting in is the only version of this that is not a surprise.
|
|
320
464
|
*/
|
|
321
|
-
export function formatChartValue(n, format) {
|
|
465
|
+
export function formatChartValue(n, format, opts) {
|
|
322
466
|
if (!Number.isFinite(n))
|
|
323
467
|
return '';
|
|
468
|
+
const localized = opts && (opts.locale || opts.currency);
|
|
469
|
+
if (localized) {
|
|
470
|
+
// Compact notation because these are axis ticks and data labels, where a
|
|
471
|
+
// full-precision number is what makes an axis unreadable.
|
|
472
|
+
const style = format === 'currency' ? 'currency' : format === 'percent' ? 'percent' : 'decimal';
|
|
473
|
+
const o = { notation: 'compact', maximumFractionDigits: 1 };
|
|
474
|
+
if (style === 'currency') {
|
|
475
|
+
o.style = 'currency';
|
|
476
|
+
// `style: 'currency'` throws without a code, so fall back to the symbol
|
|
477
|
+
// this used to hard-code rather than refusing to draw the chart.
|
|
478
|
+
o.currency = opts.currency || 'USD';
|
|
479
|
+
}
|
|
480
|
+
else if (style === 'percent') {
|
|
481
|
+
o.style = 'percent';
|
|
482
|
+
}
|
|
483
|
+
return getNumberFormatter(opts.locale, o).format(n);
|
|
484
|
+
}
|
|
324
485
|
if (format === 'currency')
|
|
325
486
|
return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`;
|
|
326
487
|
if (format === 'percent') {
|
|
@@ -341,6 +502,56 @@ function dateTicks(tMin, tMax) {
|
|
|
341
502
|
ticks.push(tMin, tMax);
|
|
342
503
|
return ticks;
|
|
343
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* Tick positions for an ordinal (evenly spaced) date axis, as INDICES into
|
|
507
|
+
* `times`.
|
|
508
|
+
*
|
|
509
|
+
* A time axis can put a tick anywhere, because x is a function of the
|
|
510
|
+
* timestamp. An ordinal axis cannot: x is a function of the index, so a tick
|
|
511
|
+
* has to land on a point that exists. This picks the first point of each
|
|
512
|
+
* calendar unit - day, week, month, year, whichever gets closest to `target`
|
|
513
|
+
* ticks without going over - so labels sit on real sessions and a weekend or a
|
|
514
|
+
* holiday never stretches the spacing.
|
|
515
|
+
*/
|
|
516
|
+
export function ordinalDateTicks(times, target = 6) {
|
|
517
|
+
if (times.length <= 1)
|
|
518
|
+
return times.length ? [0] : [];
|
|
519
|
+
const keyOf = {
|
|
520
|
+
day: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`,
|
|
521
|
+
week: (d) => Math.floor((Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / DAY + 4) / 7),
|
|
522
|
+
month: (d) => `${d.getUTCFullYear()}-${d.getUTCMonth()}`,
|
|
523
|
+
year: (d) => d.getUTCFullYear(),
|
|
524
|
+
};
|
|
525
|
+
let chosen = null;
|
|
526
|
+
for (const unit of ['day', 'week', 'month', 'year']) {
|
|
527
|
+
const at = [];
|
|
528
|
+
let prev = null;
|
|
529
|
+
for (let i = 0; i < times.length; i += 1) {
|
|
530
|
+
const t = times[i];
|
|
531
|
+
if (!Number.isFinite(t))
|
|
532
|
+
continue;
|
|
533
|
+
const k = keyOf[unit](new Date(t));
|
|
534
|
+
if (k !== prev)
|
|
535
|
+
at.push(i);
|
|
536
|
+
prev = k;
|
|
537
|
+
}
|
|
538
|
+
chosen = at;
|
|
539
|
+
if (at.length <= target * 2)
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
let out = chosen ?? [];
|
|
543
|
+
// Even the coarsest unit can be too dense (a decade of yearly points), and a
|
|
544
|
+
// single trading day yields one boundary. Thin, or fall back to plain strides.
|
|
545
|
+
if (out.length > target) {
|
|
546
|
+
const stride = Math.ceil(out.length / target);
|
|
547
|
+
out = out.filter((_, i) => i % stride === 0);
|
|
548
|
+
}
|
|
549
|
+
if (out.length < 2) {
|
|
550
|
+
const stride = Math.max(1, Math.ceil(times.length / target));
|
|
551
|
+
out = times.map((_, i) => i).filter((i) => i % stride === 0);
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
344
555
|
function fmtDate(t, span) {
|
|
345
556
|
const d = new Date(t);
|
|
346
557
|
if (span <= 70 * DAY)
|
|
@@ -349,6 +560,24 @@ function fmtDate(t, span) {
|
|
|
349
560
|
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' });
|
|
350
561
|
return String(d.getFullYear());
|
|
351
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* Which mark a series draws.
|
|
565
|
+
*
|
|
566
|
+
* A series counts as a candle when it says so OR when it carries `ohlc` data,
|
|
567
|
+
* so a spec typed `'candlestick'` can still hold a plain volume bar series
|
|
568
|
+
* beside the prices. Boxes work the same way. Anything unrecognised falls back
|
|
569
|
+
* to `'bar'`, which is what pie and scatter have always relied on.
|
|
570
|
+
*/
|
|
571
|
+
function kindOf(s, specType) {
|
|
572
|
+
const t = s.type ?? specType;
|
|
573
|
+
if (t === 'candlestick' || t === 'ohlc' || s.ohlc)
|
|
574
|
+
return 'candle';
|
|
575
|
+
if (t === 'boxplot' || s.boxes)
|
|
576
|
+
return 'box';
|
|
577
|
+
if (t === 'line' || t === 'area')
|
|
578
|
+
return t;
|
|
579
|
+
return 'bar';
|
|
580
|
+
}
|
|
352
581
|
/** Data domain for one axis, honoring stacking of its bar/area series.
|
|
353
582
|
* When `isLog` is true, non-positive values are discarded (log undefined)
|
|
354
583
|
* and the domain is rounded to decade boundaries instead of nice steps. */
|
|
@@ -392,6 +621,50 @@ function axisDomain(list, categories, stacked, extra = [], isLog = false) {
|
|
|
392
621
|
for (const s of lines)
|
|
393
622
|
for (const v of s.values)
|
|
394
623
|
note(v);
|
|
624
|
+
// Candles: note the HIGH and the LOW, not `values` (the closes), or every
|
|
625
|
+
// wick clips at the body. Note also that candles are deliberately absent
|
|
626
|
+
// from `stackable`, so the zero-baseline rule below does not fire for them:
|
|
627
|
+
// a price series running 180 to 195 keeps a readable domain instead of being
|
|
628
|
+
// flattened against zero.
|
|
629
|
+
for (const s of list) {
|
|
630
|
+
if (s.kind !== 'candle')
|
|
631
|
+
continue;
|
|
632
|
+
for (const k of s.ohlc ?? []) {
|
|
633
|
+
if (!k)
|
|
634
|
+
continue;
|
|
635
|
+
note(k.h);
|
|
636
|
+
note(k.l);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// Boxes: the whisker ends and any outlier, for the same reason - `values`
|
|
640
|
+
// holds the medians, so a domain built from those alone would clip half of
|
|
641
|
+
// every box. Boxes are also absent from `stackable`, so a sample that never
|
|
642
|
+
// goes near zero keeps a readable domain.
|
|
643
|
+
for (const s of list) {
|
|
644
|
+
if (s.kind !== 'box')
|
|
645
|
+
continue;
|
|
646
|
+
for (const b of s.boxes ?? []) {
|
|
647
|
+
if (!b)
|
|
648
|
+
continue;
|
|
649
|
+
note(b.min);
|
|
650
|
+
note(b.max);
|
|
651
|
+
for (const o of b.outliers ?? [])
|
|
652
|
+
note(o);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Error bars extend past their own mark, so a whisker that leaves the plot is
|
|
656
|
+
// the same defect as a clipped candle wick.
|
|
657
|
+
for (const s of list) {
|
|
658
|
+
if (!s.errors)
|
|
659
|
+
continue;
|
|
660
|
+
s.errors.forEach((e, i) => {
|
|
661
|
+
const span = errorSpan(e, s.values[i] ?? 0);
|
|
662
|
+
if (!span)
|
|
663
|
+
return;
|
|
664
|
+
note(span.lo);
|
|
665
|
+
note(span.hi);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
395
668
|
if (dMin === Infinity) {
|
|
396
669
|
dMin = isLog ? 1 : 0;
|
|
397
670
|
dMax = isLog ? 10 : 1;
|
|
@@ -417,7 +690,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
417
690
|
const series = spec.series.map((s, i) => ({
|
|
418
691
|
...s,
|
|
419
692
|
color: s.color ?? palette[i % palette.length],
|
|
420
|
-
kind: (s
|
|
693
|
+
kind: kindOf(s, spec.type),
|
|
421
694
|
axis: s.axis ?? 'left',
|
|
422
695
|
}));
|
|
423
696
|
const legend = series.map((s) => ({ label: s.label, color: s.color }));
|
|
@@ -426,7 +699,11 @@ export function buildChart(spec, theme = 'light') {
|
|
|
426
699
|
width,
|
|
427
700
|
height,
|
|
428
701
|
plot: { x: 0, y: 0, w: width, h: height },
|
|
702
|
+
axes: null,
|
|
429
703
|
bars: [],
|
|
704
|
+
candles: [],
|
|
705
|
+
boxes: [],
|
|
706
|
+
errorBars: [],
|
|
430
707
|
lines: [],
|
|
431
708
|
slices: [],
|
|
432
709
|
yTicks: [],
|
|
@@ -548,7 +825,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
548
825
|
x: round(padL + slotW * i + slotW / 2),
|
|
549
826
|
}));
|
|
550
827
|
const yTicks = dom.ticks.map((value) => ({
|
|
551
|
-
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat),
|
|
828
|
+
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat, spec),
|
|
552
829
|
}));
|
|
553
830
|
return {
|
|
554
831
|
...empty,
|
|
@@ -744,7 +1021,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
744
1021
|
const legend = Array.from({ length: 5 }, (_, i) => {
|
|
745
1022
|
const t = i / 4;
|
|
746
1023
|
const value = vMin + (vMax - vMin) * t;
|
|
747
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) };
|
|
1024
|
+
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) };
|
|
748
1025
|
});
|
|
749
1026
|
return {
|
|
750
1027
|
...empty,
|
|
@@ -779,8 +1056,13 @@ export function buildChart(spec, theme = 'light') {
|
|
|
779
1056
|
};
|
|
780
1057
|
const trackPath = arc(A0, A1, r);
|
|
781
1058
|
const valuePath = arc(A0, angleAt(value), r);
|
|
1059
|
+
// Bands sit on their own inner ring, well clear of the value arc's 16px
|
|
1060
|
+
// stroke at `r`. They are context, not the reading: a band covering most
|
|
1061
|
+
// of the scale (an error-rate dial where anything above 0.45 is red) used
|
|
1062
|
+
// to out-shout the value arc completely, so the dial looked pegged at
|
|
1063
|
+
// maximum when the actual value was 9 percent.
|
|
782
1064
|
const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
|
|
783
|
-
path: arc(angleAt(band.from), angleAt(band.to), r -
|
|
1065
|
+
path: arc(angleAt(band.from), angleAt(band.to), r - 16),
|
|
784
1066
|
color: band.color, from: band.from, to: band.to,
|
|
785
1067
|
}));
|
|
786
1068
|
let targetPx = null;
|
|
@@ -810,23 +1092,46 @@ export function buildChart(spec, theme = 'light') {
|
|
|
810
1092
|
}
|
|
811
1093
|
// Pointer needle: a kite (long tip toward the value, short counterweight
|
|
812
1094
|
// tail) pivoting on a center hub.
|
|
1095
|
+
//
|
|
1096
|
+
// The proportions matter more than they look, and the counterweight was the
|
|
1097
|
+
// whole problem. A tail sticking out past the hub is a short, solid triangle;
|
|
1098
|
+
// the pointer is a 130px taper that thins to nothing. The compact shape wins
|
|
1099
|
+
// the eye, so on a near-full dial the needle read as an arrow pointing at the
|
|
1100
|
+
// MINIMUM. Keeping the tail inside the hub radius removes the competing
|
|
1101
|
+
// point without giving up the pivot, and the shoulders sit outside the hub so
|
|
1102
|
+
// the needle actually has a visible base to taper from.
|
|
813
1103
|
const aV = angleAt(value);
|
|
814
|
-
const tipR = r -
|
|
1104
|
+
const tipR = r - 14, tailR = 5, baseR = 9;
|
|
815
1105
|
const aPerp = aV + Math.PI / 2;
|
|
816
1106
|
const pt = (rad, ang) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`;
|
|
817
1107
|
const needlePath = `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`;
|
|
818
1108
|
// Color the value arc by the band the value currently sits in.
|
|
1109
|
+
// First match wins, and bands are half-open [from, to). Bands normally
|
|
1110
|
+
// share endpoints - green 0..0.3, amber 0.3..0.45, red 0.45..5 - and with
|
|
1111
|
+
// an inclusive `to` plus last-match-wins, a value sitting exactly ON a
|
|
1112
|
+
// boundary took the colour of the band ABOVE it. An error rate of 0.45
|
|
1113
|
+
// against a 0.45 amber ceiling read as red.
|
|
819
1114
|
let valueColor = null;
|
|
820
|
-
|
|
821
|
-
|
|
1115
|
+
const bands = spec.gaugeRanges ?? [];
|
|
1116
|
+
for (const band of bands) {
|
|
1117
|
+
if (value >= band.from && value < band.to) {
|
|
822
1118
|
valueColor = band.color;
|
|
1119
|
+
break;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
// The very top of the scale belongs to the last band that reaches it,
|
|
1123
|
+
// which the half-open test above would otherwise exclude.
|
|
1124
|
+
if (valueColor == null) {
|
|
1125
|
+
for (const band of bands)
|
|
1126
|
+
if (value >= band.from && value <= band.to)
|
|
1127
|
+
valueColor = band.color;
|
|
823
1128
|
}
|
|
824
1129
|
return {
|
|
825
1130
|
...empty,
|
|
826
1131
|
plot: { x: 0, y: 0, w: width, h: height },
|
|
827
1132
|
gauge: {
|
|
828
1133
|
cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
|
|
829
|
-
ticks, needle: { path: needlePath, hubR:
|
|
1134
|
+
ticks, needle: { path: needlePath, hubR: 6 }, valueColor,
|
|
830
1135
|
minLabel: { x: cx - r, y: cy + 20 },
|
|
831
1136
|
maxLabel: { x: cx + r, y: cy + 20 },
|
|
832
1137
|
value, min, max, unit: spec.gaugeUnit ?? '',
|
|
@@ -1131,7 +1436,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1131
1436
|
const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
|
|
1132
1437
|
const t = i / 4;
|
|
1133
1438
|
const value = vMin + (vMax - vMin) * t;
|
|
1134
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) };
|
|
1439
|
+
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat, spec) };
|
|
1135
1440
|
});
|
|
1136
1441
|
return {
|
|
1137
1442
|
...empty,
|
|
@@ -1261,7 +1566,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1261
1566
|
}
|
|
1262
1567
|
const referenceLines = (spec.referenceLines ?? []).map((ref) => ({
|
|
1263
1568
|
y: yOf(ref.value),
|
|
1264
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
1569
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
1265
1570
|
color: ref.color ?? '#ef4444',
|
|
1266
1571
|
dashed: ref.dashed !== false,
|
|
1267
1572
|
}));
|
|
@@ -1270,7 +1575,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1270
1575
|
plot,
|
|
1271
1576
|
scatterPoints,
|
|
1272
1577
|
referenceLines,
|
|
1273
|
-
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat) })),
|
|
1578
|
+
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat, spec) })),
|
|
1274
1579
|
xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
|
|
1275
1580
|
};
|
|
1276
1581
|
}
|
|
@@ -1353,7 +1658,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1353
1658
|
});
|
|
1354
1659
|
}
|
|
1355
1660
|
const valueTicks = dom.ticks.map((value) => ({
|
|
1356
|
-
label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat),
|
|
1661
|
+
label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat, spec),
|
|
1357
1662
|
x: xOf(value),
|
|
1358
1663
|
}));
|
|
1359
1664
|
const catTicks = spec.categories.map((label, i) => ({
|
|
@@ -1363,7 +1668,7 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1363
1668
|
}));
|
|
1364
1669
|
const referenceLinesV = (spec.referenceLines ?? []).map((ref) => ({
|
|
1365
1670
|
x: xOf(ref.value),
|
|
1366
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
1671
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
1367
1672
|
color: ref.color ?? '#ef4444',
|
|
1368
1673
|
dashed: ref.dashed !== false,
|
|
1369
1674
|
}));
|
|
@@ -1428,19 +1733,30 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1428
1733
|
const slot = plotW / Math.max(1, n);
|
|
1429
1734
|
// X positions. A time axis spaces points by actual time (irregular gaps);
|
|
1430
1735
|
// a category axis is uniform. (Bars stay uniform either way.)
|
|
1431
|
-
|
|
1736
|
+
// Two date modes, and the difference is only where the marks go: `'time'`
|
|
1737
|
+
// positions by the timestamp, `'ordinal-time'` positions by the index and
|
|
1738
|
+
// uses the dates for labels alone. Both parse; only one scales.
|
|
1739
|
+
const isDateAxis = spec.xType === 'time' || spec.xType === 'ordinal-time';
|
|
1740
|
+
const timeVals = isDateAxis ? spec.categories.map((c) => Date.parse(c)) : null;
|
|
1432
1741
|
const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t));
|
|
1742
|
+
const timeScaled = timeOk && spec.xType === 'time';
|
|
1433
1743
|
const tMin = timeOk ? Math.min(...timeVals.filter(Number.isFinite)) : 0;
|
|
1434
1744
|
const tSpan = timeOk ? Math.max(...timeVals.filter(Number.isFinite)) - tMin || 1 : 1;
|
|
1435
|
-
const xCenter = (i) =>
|
|
1745
|
+
const xCenter = (i) => timeScaled && Number.isFinite(timeVals[i])
|
|
1436
1746
|
? round(padL + ((timeVals[i] - tMin) / tSpan) * plotW)
|
|
1437
1747
|
: round(padL + slot * i + slot / 2);
|
|
1438
|
-
const xTicks =
|
|
1748
|
+
const xTicks = timeScaled
|
|
1439
1749
|
? dateTicks(tMin, tMin + tSpan).map((t) => ({
|
|
1440
1750
|
label: fmtDate(t, tSpan),
|
|
1441
1751
|
x: round(padL + ((t - tMin) / tSpan) * plotW),
|
|
1442
1752
|
}))
|
|
1443
|
-
:
|
|
1753
|
+
: timeOk
|
|
1754
|
+
? // Ordinal: ticks land on points that exist, labelled from their dates.
|
|
1755
|
+
ordinalDateTicks(timeVals).map((i) => ({
|
|
1756
|
+
label: fmtDate(timeVals[i], tSpan),
|
|
1757
|
+
x: xCenter(i),
|
|
1758
|
+
}))
|
|
1759
|
+
: thinCategoryTicks(spec.categories, xCenter, slot, xLabelRotated);
|
|
1444
1760
|
// Parent-tier ticks for a grouped category axis: each spans its leaves.
|
|
1445
1761
|
const categoryGroupTicks = [];
|
|
1446
1762
|
if (validGroups && !timeOk) {
|
|
@@ -1532,6 +1848,135 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1532
1848
|
});
|
|
1533
1849
|
}
|
|
1534
1850
|
}
|
|
1851
|
+
// Candlesticks / OHLC bars. Laid out once; the two marks differ only in how
|
|
1852
|
+
// the renderer paints them, so there is no second geometry pass.
|
|
1853
|
+
const candleSeries = series.filter((s) => s.kind === 'candle');
|
|
1854
|
+
const candles = [];
|
|
1855
|
+
if (candleSeries.length) {
|
|
1856
|
+
const upColor = spec.candleColors?.up ?? '#16a34a';
|
|
1857
|
+
const downColor = spec.candleColors?.down ?? '#ef4444';
|
|
1858
|
+
// Share the slot when two instruments are charted together, the same way
|
|
1859
|
+
// grouped bars do.
|
|
1860
|
+
const bodyW = Math.max(1, (slot * 0.7) / candleSeries.length);
|
|
1861
|
+
candleSeries.forEach((s, si) => {
|
|
1862
|
+
const dom = domOf(s);
|
|
1863
|
+
const log = isLogOf(s);
|
|
1864
|
+
(s.ohlc ?? []).forEach((k, i) => {
|
|
1865
|
+
if (!k)
|
|
1866
|
+
return;
|
|
1867
|
+
if (![k.o, k.h, k.l, k.c].every(Number.isFinite))
|
|
1868
|
+
return;
|
|
1869
|
+
// A log price axis is genuinely used for long histories, and a
|
|
1870
|
+
// non-positive price has no place on one.
|
|
1871
|
+
if (log && (k.o <= 0 || k.h <= 0 || k.l <= 0 || k.c <= 0))
|
|
1872
|
+
return;
|
|
1873
|
+
const centre = padL + slot * i + slot / 2;
|
|
1874
|
+
const x = centre - (bodyW * candleSeries.length) / 2 + bodyW * si;
|
|
1875
|
+
const yOpen = yOf(dom, k.o, log);
|
|
1876
|
+
const yClose = yOf(dom, k.c, log);
|
|
1877
|
+
const up = k.c >= k.o;
|
|
1878
|
+
candles.push({
|
|
1879
|
+
x: round(x),
|
|
1880
|
+
w: round(bodyW),
|
|
1881
|
+
xCenter: round(x + bodyW / 2),
|
|
1882
|
+
yOpen,
|
|
1883
|
+
yClose,
|
|
1884
|
+
yHigh: yOf(dom, k.h, log),
|
|
1885
|
+
yLow: yOf(dom, k.l, log),
|
|
1886
|
+
bodyY: Math.min(yOpen, yClose),
|
|
1887
|
+
// A doji closes where it opened; keep it visible as a 1px line
|
|
1888
|
+
// rather than a zero-height rect that paints nothing.
|
|
1889
|
+
bodyH: Math.max(1, Math.abs(yClose - yOpen)),
|
|
1890
|
+
up,
|
|
1891
|
+
color: up ? upColor : downColor,
|
|
1892
|
+
label: spec.categories[i] ?? String(i),
|
|
1893
|
+
series: s.label,
|
|
1894
|
+
o: k.o,
|
|
1895
|
+
h: k.h,
|
|
1896
|
+
l: k.l,
|
|
1897
|
+
c: k.c,
|
|
1898
|
+
});
|
|
1899
|
+
});
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
// Box plots. Same slot-sharing as grouped bars and candles, so several
|
|
1903
|
+
// samples can sit side by side under one category.
|
|
1904
|
+
const boxSeries = series.filter((s) => s.kind === 'box');
|
|
1905
|
+
const boxes = [];
|
|
1906
|
+
if (boxSeries.length) {
|
|
1907
|
+
const boxW = Math.max(1, (slot * 0.6) / boxSeries.length);
|
|
1908
|
+
boxSeries.forEach((s, si) => {
|
|
1909
|
+
const dom = domOf(s);
|
|
1910
|
+
const log = isLogOf(s);
|
|
1911
|
+
(s.boxes ?? []).forEach((b, i) => {
|
|
1912
|
+
if (!b)
|
|
1913
|
+
return;
|
|
1914
|
+
if (![b.min, b.q1, b.median, b.q3, b.max].every(Number.isFinite))
|
|
1915
|
+
return;
|
|
1916
|
+
if (log && b.min <= 0)
|
|
1917
|
+
return;
|
|
1918
|
+
const centre = padL + slot * i + slot / 2;
|
|
1919
|
+
const x = centre - (boxW * boxSeries.length) / 2 + boxW * si;
|
|
1920
|
+
const yQ1 = yOf(dom, b.q1, log);
|
|
1921
|
+
const yQ3 = yOf(dom, b.q3, log);
|
|
1922
|
+
boxes.push({
|
|
1923
|
+
x: round(x),
|
|
1924
|
+
w: round(boxW),
|
|
1925
|
+
xCenter: round(x + boxW / 2),
|
|
1926
|
+
yMin: yOf(dom, b.min, log),
|
|
1927
|
+
yQ1,
|
|
1928
|
+
yMedian: yOf(dom, b.median, log),
|
|
1929
|
+
yQ3,
|
|
1930
|
+
yMax: yOf(dom, b.max, log),
|
|
1931
|
+
boxY: Math.min(yQ1, yQ3),
|
|
1932
|
+
// A sample with no spread would otherwise paint nothing at all.
|
|
1933
|
+
boxH: Math.max(1, Math.abs(yQ1 - yQ3)),
|
|
1934
|
+
outliers: (b.outliers ?? [])
|
|
1935
|
+
.filter((o) => Number.isFinite(o) && (!log || o > 0))
|
|
1936
|
+
.map((o) => ({ y: yOf(dom, o, log), value: o })),
|
|
1937
|
+
color: s.color,
|
|
1938
|
+
label: spec.categories[i] ?? String(i),
|
|
1939
|
+
series: s.label,
|
|
1940
|
+
min: b.min,
|
|
1941
|
+
q1: b.q1,
|
|
1942
|
+
median: b.median,
|
|
1943
|
+
q3: b.q3,
|
|
1944
|
+
max: b.max,
|
|
1945
|
+
});
|
|
1946
|
+
});
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
// Error bars. Not a mark of their own: they annotate whatever the series
|
|
1950
|
+
// already draws, so this runs over every series carrying `errors` regardless
|
|
1951
|
+
// of kind, and the geometry sits in its own array so no existing loop changes.
|
|
1952
|
+
const errorBars = [];
|
|
1953
|
+
for (const s of series) {
|
|
1954
|
+
if (!s.errors)
|
|
1955
|
+
continue;
|
|
1956
|
+
const dom = domOf(s);
|
|
1957
|
+
const log = isLogOf(s);
|
|
1958
|
+
s.errors.forEach((e, i) => {
|
|
1959
|
+
const v = s.values[i];
|
|
1960
|
+
if (!Number.isFinite(v))
|
|
1961
|
+
return;
|
|
1962
|
+
const span = errorSpan(e, v);
|
|
1963
|
+
if (!span)
|
|
1964
|
+
return;
|
|
1965
|
+
if (log && span.lo <= 0)
|
|
1966
|
+
return;
|
|
1967
|
+
errorBars.push({
|
|
1968
|
+
xCenter: round(padL + slot * i + slot / 2),
|
|
1969
|
+
yLo: yOf(dom, span.lo, log),
|
|
1970
|
+
yHi: yOf(dom, span.hi, log),
|
|
1971
|
+
cap: round(Math.min(6, slot * 0.15)),
|
|
1972
|
+
color: s.color,
|
|
1973
|
+
label: spec.categories[i] ?? String(i),
|
|
1974
|
+
series: s.label,
|
|
1975
|
+
lo: span.lo,
|
|
1976
|
+
hi: span.hi,
|
|
1977
|
+
});
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1535
1980
|
// Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
|
|
1536
1981
|
const lines = [];
|
|
1537
1982
|
const areaCum = {
|
|
@@ -1549,7 +1994,12 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1549
1994
|
}
|
|
1550
1995
|
}
|
|
1551
1996
|
for (const s of series) {
|
|
1552
|
-
|
|
1997
|
+
// Bars, candles and boxes draw their own marks. Candles and boxes
|
|
1998
|
+
// especially: `values` holds their closes / medians so that tooltips, CSV
|
|
1999
|
+
// and overlays work, and without this guard that same array was ALSO drawn
|
|
2000
|
+
// as a line, laying a dotted close-line straight over every candle. Boxes
|
|
2001
|
+
// would do exactly the same thing through the median.
|
|
2002
|
+
if (s.kind === 'bar' || s.kind === 'candle' || s.kind === 'box')
|
|
1553
2003
|
continue;
|
|
1554
2004
|
const dom = domOf(s);
|
|
1555
2005
|
const log = isLogOf(s);
|
|
@@ -1649,14 +2099,23 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1649
2099
|
}
|
|
1650
2100
|
lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath });
|
|
1651
2101
|
}
|
|
1652
|
-
|
|
2102
|
+
// Under `stacked100` the axis is a share of the total, not the measure, so
|
|
2103
|
+
// it is labelled as a percentage whatever `valueFormat` says. Formatting it
|
|
2104
|
+
// as currency gives an axis reading "$0 .. $100" for what are percentages -
|
|
2105
|
+
// which is what it did, unnoticed, while `stacked100` was reachable only
|
|
2106
|
+
// from config.
|
|
2107
|
+
const tickFor = (dom, log) => dom.ticks.map((value) => ({
|
|
2108
|
+
value,
|
|
2109
|
+
y: yOf(dom, value, log),
|
|
2110
|
+
label: spec.stacked100 ? `${round(value)}%` : formatChartValue(value, spec.valueFormat, spec),
|
|
2111
|
+
}));
|
|
1653
2112
|
const referenceLines = (spec.referenceLines ?? []).map((ref) => {
|
|
1654
2113
|
const onRight = ref.axis === 'right';
|
|
1655
2114
|
const dom = onRight ? (rightDom ?? leftDom) : leftDom;
|
|
1656
2115
|
const log = onRight ? rightLog : leftLog;
|
|
1657
2116
|
return {
|
|
1658
2117
|
y: yOf(dom, ref.value, log),
|
|
1659
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
2118
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat, spec),
|
|
1660
2119
|
color: ref.color ?? '#ef4444',
|
|
1661
2120
|
dashed: ref.dashed !== false,
|
|
1662
2121
|
};
|
|
@@ -1735,7 +2194,20 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1735
2194
|
return {
|
|
1736
2195
|
...empty,
|
|
1737
2196
|
plot,
|
|
2197
|
+
// What a caller needs to put its own marks in this chart's coordinates.
|
|
2198
|
+
// Reported rather than recomputed, so a custom mark lands on exactly the
|
|
2199
|
+
// scale the built-in ones did - including the nice-scale rounding and the
|
|
2200
|
+
// "always include zero" rule, which are impossible to guess from outside.
|
|
2201
|
+
axes: {
|
|
2202
|
+
y: { min: leftDom.min, max: leftDom.max, log: leftLog },
|
|
2203
|
+
y2: rightDom ? { min: rightDom.min, max: rightDom.max, log: rightLog } : null,
|
|
2204
|
+
slot,
|
|
2205
|
+
count: spec.categories.length,
|
|
2206
|
+
},
|
|
1738
2207
|
bars,
|
|
2208
|
+
candles,
|
|
2209
|
+
boxes,
|
|
2210
|
+
errorBars,
|
|
1739
2211
|
lines,
|
|
1740
2212
|
yTicks: tickFor(leftDom, leftLog),
|
|
1741
2213
|
y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
|
|
@@ -1748,6 +2220,51 @@ export function buildChart(spec, theme = 'light') {
|
|
|
1748
2220
|
annotations,
|
|
1749
2221
|
};
|
|
1750
2222
|
}
|
|
2223
|
+
/**
|
|
2224
|
+
* Narrow a spec to the category window `[lo, hi]`, keeping every
|
|
2225
|
+
* category-parallel array in step.
|
|
2226
|
+
*
|
|
2227
|
+
* This is the zoom / brush slice. It lives here rather than in the renderer
|
|
2228
|
+
* because getting it wrong is a MODEL bug, not a paint bug, and it was wrong:
|
|
2229
|
+
* the component used to slice `categories`, `values` and `rowIds` by hand and
|
|
2230
|
+
* spread the rest of the series through untouched. `upperValues` and
|
|
2231
|
+
* `lowerValues` therefore kept their full length, the equality guard on the
|
|
2232
|
+
* confidence band (see `buildChart`) stopped matching, and the band silently
|
|
2233
|
+
* disappeared the moment anyone zoomed.
|
|
2234
|
+
*
|
|
2235
|
+
* The lesson generalises: every array here is indexed by category, so each one
|
|
2236
|
+
* added in future has to be sliced too. Keeping them in one function is what
|
|
2237
|
+
* makes that a single place to remember rather than a scattered convention.
|
|
2238
|
+
*/
|
|
2239
|
+
export function sliceChartWindow(spec, lo, hi) {
|
|
2240
|
+
const from = Math.max(0, lo);
|
|
2241
|
+
const to = Math.min(spec.categories.length - 1, hi);
|
|
2242
|
+
const cut = (arr) => arr ? arr.slice(from, to + 1) : undefined;
|
|
2243
|
+
return {
|
|
2244
|
+
...spec,
|
|
2245
|
+
categories: spec.categories.slice(from, to + 1),
|
|
2246
|
+
// EVERY per-category array on a series has to be cut here, not just the
|
|
2247
|
+
// ones that existed when this function was written. A missed one does not
|
|
2248
|
+
// throw: the geometry keeps indexing the full-length array against the
|
|
2249
|
+
// sliced categories, so marks land at the wrong x or off the plot entirely.
|
|
2250
|
+
// `upperValues` / `lowerValues` were missed once and silently dropped the
|
|
2251
|
+
// confidence band on zoom; `ohlc` was missed the same way and drew a
|
|
2252
|
+
// zoomed candlestick chart against the wrong categories.
|
|
2253
|
+
series: spec.series.map((s) => ({
|
|
2254
|
+
...s,
|
|
2255
|
+
values: s.values.slice(from, to + 1),
|
|
2256
|
+
rowIds: cut(s.rowIds),
|
|
2257
|
+
upperValues: cut(s.upperValues),
|
|
2258
|
+
lowerValues: cut(s.lowerValues),
|
|
2259
|
+
ohlc: cut(s.ohlc),
|
|
2260
|
+
boxes: cut(s.boxes),
|
|
2261
|
+
errors: cut(s.errors),
|
|
2262
|
+
})),
|
|
2263
|
+
// Per-category, so it has to travel with the window or the waterfall's
|
|
2264
|
+
// running total resets on the wrong bars.
|
|
2265
|
+
waterfallTotals: cut(spec.waterfallTotals),
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
1751
2268
|
/**
|
|
1752
2269
|
* Aggregate flat rows into a chart spec. Group by a category field, reduce a
|
|
1753
2270
|
* value field per group. Three multi-series shapes:
|
|
@@ -1866,3 +2383,286 @@ export function rowsToChartSpec(rows, opts) {
|
|
|
1866
2383
|
palette: opts.palette,
|
|
1867
2384
|
};
|
|
1868
2385
|
}
|
|
2386
|
+
// ---------------------------------------------------------------------------
|
|
2387
|
+
// Shape adapters.
|
|
2388
|
+
//
|
|
2389
|
+
// Nine of the thirteen chart types were unreachable from the chart panel
|
|
2390
|
+
// because they do not read a `categories x series` grid: a treemap wants a
|
|
2391
|
+
// hierarchy, a sankey wants an edge list, a gauge wants one number. Rather
|
|
2392
|
+
// than give each its own aggregation path, these take the spec
|
|
2393
|
+
// `rowsToChartSpec` already produced and reshape it, so grouping, `reduce`,
|
|
2394
|
+
// `sort`, `topN` and the "Other" bucket keep working for all of them.
|
|
2395
|
+
// ---------------------------------------------------------------------------
|
|
2396
|
+
/**
|
|
2397
|
+
* Reshape an aggregated spec into a tree-map hierarchy.
|
|
2398
|
+
*
|
|
2399
|
+
* One series gives a flat set of leaves. Several (a split-by) give two levels,
|
|
2400
|
+
* category above series, which is the shape people expect from "sales by
|
|
2401
|
+
* region, split by channel".
|
|
2402
|
+
*/
|
|
2403
|
+
export function specToTreemap(spec, rootName = 'Total') {
|
|
2404
|
+
const positive = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
2405
|
+
if (spec.series.length <= 1) {
|
|
2406
|
+
const s = spec.series[0];
|
|
2407
|
+
return {
|
|
2408
|
+
name: rootName,
|
|
2409
|
+
children: spec.categories
|
|
2410
|
+
.map((name, i) => ({ name, value: positive(s?.values[i]) }))
|
|
2411
|
+
.filter((n) => n.value > 0),
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
return {
|
|
2415
|
+
name: rootName,
|
|
2416
|
+
children: spec.categories
|
|
2417
|
+
.map((name, i) => ({
|
|
2418
|
+
name,
|
|
2419
|
+
children: spec.series
|
|
2420
|
+
.map((s) => ({ name: s.label, value: positive(s.values[i]) }))
|
|
2421
|
+
.filter((n) => n.value > 0),
|
|
2422
|
+
}))
|
|
2423
|
+
.filter((n) => n.children.length > 0),
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Reshape an aggregated spec into calendar samples.
|
|
2428
|
+
*
|
|
2429
|
+
* Categories that do not parse as a date are dropped rather than rendered at
|
|
2430
|
+
* epoch zero, which would put a stray cell in 1970 and rescale the whole year.
|
|
2431
|
+
*/
|
|
2432
|
+
export function specToCalendar(spec) {
|
|
2433
|
+
const s = spec.series[0];
|
|
2434
|
+
const out = [];
|
|
2435
|
+
spec.categories.forEach((c, i) => {
|
|
2436
|
+
const t = Date.parse(c);
|
|
2437
|
+
if (!Number.isFinite(t))
|
|
2438
|
+
return;
|
|
2439
|
+
const v = s?.values[i];
|
|
2440
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
2441
|
+
return;
|
|
2442
|
+
out.push({ date: new Date(t).toISOString().slice(0, 10), value: v });
|
|
2443
|
+
});
|
|
2444
|
+
return out;
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Reshape a pivoted spec into sankey nodes and links.
|
|
2448
|
+
*
|
|
2449
|
+
* The pivot `rowsToChartSpec` already performs is exactly an edge list read
|
|
2450
|
+
* sideways: categories are sources, series are targets, and each cell is the
|
|
2451
|
+
* flow between them. Zero cells and self-edges are dropped, the first because
|
|
2452
|
+
* a zero-width ribbon is not a flow and the second because the layout has no
|
|
2453
|
+
* meaningful place to put one.
|
|
2454
|
+
*/
|
|
2455
|
+
export function specToSankey(spec) {
|
|
2456
|
+
const links = [];
|
|
2457
|
+
const ids = new Set();
|
|
2458
|
+
spec.categories.forEach((from, i) => {
|
|
2459
|
+
for (const s of spec.series) {
|
|
2460
|
+
const v = s.values[i];
|
|
2461
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0)
|
|
2462
|
+
continue;
|
|
2463
|
+
// Sources and targets share a namespace, so a value appearing on both
|
|
2464
|
+
// sides would otherwise become one node with a cycle through it.
|
|
2465
|
+
const source = `from:${from}`;
|
|
2466
|
+
const target = `to:${s.label}`;
|
|
2467
|
+
if (source === target)
|
|
2468
|
+
continue;
|
|
2469
|
+
ids.add(source);
|
|
2470
|
+
ids.add(target);
|
|
2471
|
+
links.push({ source, target, value: v });
|
|
2472
|
+
}
|
|
2473
|
+
});
|
|
2474
|
+
return {
|
|
2475
|
+
nodes: [...ids].map((id) => ({ id, label: id.slice(id.indexOf(':') + 1) })),
|
|
2476
|
+
links,
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* The chart types that read ROWS directly instead of a grouped grid, behind one
|
|
2481
|
+
* call. Returns `null` for every other type, which then goes through
|
|
2482
|
+
* `rowsToChartSpec` and its reduce / sort / topN / "Other" pipeline.
|
|
2483
|
+
*
|
|
2484
|
+
* One entry point rather than a branch per type in the caller, because the
|
|
2485
|
+
* caller is the grid controller and the controller is in the BASE bundle: every
|
|
2486
|
+
* type named there is bytes paid by grids that never chart. Here it is in the
|
|
2487
|
+
* lazy chart chunk, next to the builders it dispatches to, and adding a fourth
|
|
2488
|
+
* direct type costs a grid nothing.
|
|
2489
|
+
*/
|
|
2490
|
+
export function rowsToDirectSpec(type, rows, opts) {
|
|
2491
|
+
const cat = opts.category;
|
|
2492
|
+
const val = opts.value;
|
|
2493
|
+
const ser = opts.series;
|
|
2494
|
+
if (type === 'scatter') {
|
|
2495
|
+
const y = opts.value2;
|
|
2496
|
+
if (!val || !y)
|
|
2497
|
+
return null;
|
|
2498
|
+
return rowsToScatterSpec(rows, {
|
|
2499
|
+
x: val,
|
|
2500
|
+
y,
|
|
2501
|
+
...(ser ? { series: ser } : {}),
|
|
2502
|
+
...(opts.palette ? { palette: opts.palette } : {}),
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
if (type === 'gauge') {
|
|
2506
|
+
if (!val)
|
|
2507
|
+
return null;
|
|
2508
|
+
return rowsToGaugeSpec(rows, { value: val, ...(opts.reduce ? { reduce: opts.reduce } : {}) });
|
|
2509
|
+
}
|
|
2510
|
+
if (type === 'boxplot') {
|
|
2511
|
+
if (!cat || !val)
|
|
2512
|
+
return null;
|
|
2513
|
+
const spec = rowsToBoxSpec(rows, {
|
|
2514
|
+
category: cat,
|
|
2515
|
+
value: val,
|
|
2516
|
+
...(ser ? { series: ser } : {}),
|
|
2517
|
+
});
|
|
2518
|
+
if (opts.palette)
|
|
2519
|
+
spec.palette = opts.palette;
|
|
2520
|
+
return spec;
|
|
2521
|
+
}
|
|
2522
|
+
return null;
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Build a box plot spec straight from rows: group by a category, then reduce
|
|
2526
|
+
* each group to a five-number summary.
|
|
2527
|
+
*
|
|
2528
|
+
* This is the one aggregate the panel's `sum | avg | count` cannot express, and
|
|
2529
|
+
* that is the point of it. Every other chart answers "how much"; a box plot
|
|
2530
|
+
* answers "how spread out", which needs the whole sample per group rather than
|
|
2531
|
+
* one number, so it cannot go through `rowsToChartSpec`.
|
|
2532
|
+
*
|
|
2533
|
+
* `values` comes out as the medians, so tooltips, CSV and overlays work with no
|
|
2534
|
+
* box-specific code - the same contract `ohlc` follows.
|
|
2535
|
+
*/
|
|
2536
|
+
export function rowsToBoxSpec(rows, opts) {
|
|
2537
|
+
const cats = [];
|
|
2538
|
+
const seen = new Set();
|
|
2539
|
+
for (const r of rows) {
|
|
2540
|
+
const c = String(r[opts.category] ?? '');
|
|
2541
|
+
if (!seen.has(c)) {
|
|
2542
|
+
seen.add(c);
|
|
2543
|
+
cats.push(c);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
const groupNames = [];
|
|
2547
|
+
const groupSeen = new Set();
|
|
2548
|
+
if (opts.series) {
|
|
2549
|
+
for (const r of rows) {
|
|
2550
|
+
const g = String(r[opts.series] ?? '');
|
|
2551
|
+
if (!groupSeen.has(g)) {
|
|
2552
|
+
groupSeen.add(g);
|
|
2553
|
+
groupNames.push(g);
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
else {
|
|
2558
|
+
groupNames.push(opts.seriesLabel ?? String(opts.value));
|
|
2559
|
+
}
|
|
2560
|
+
const series = groupNames.map((g) => {
|
|
2561
|
+
const boxes = cats.map((c) => {
|
|
2562
|
+
const sample = [];
|
|
2563
|
+
for (const r of rows) {
|
|
2564
|
+
if (String(r[opts.category] ?? '') !== c)
|
|
2565
|
+
continue;
|
|
2566
|
+
if (opts.series && String(r[opts.series] ?? '') !== g)
|
|
2567
|
+
continue;
|
|
2568
|
+
// `Number(null)` and `Number('')` are both 0, so coercing first would
|
|
2569
|
+
// fold every empty cell into the sample as a zero and drag the whole
|
|
2570
|
+
// box down. An absent observation is absent, not zero.
|
|
2571
|
+
const raw = r[opts.value];
|
|
2572
|
+
if (raw == null || raw === '')
|
|
2573
|
+
continue;
|
|
2574
|
+
const n = Number(raw);
|
|
2575
|
+
if (Number.isFinite(n))
|
|
2576
|
+
sample.push(n);
|
|
2577
|
+
}
|
|
2578
|
+
return boxStats(sample, opts.whisker);
|
|
2579
|
+
});
|
|
2580
|
+
return {
|
|
2581
|
+
label: g,
|
|
2582
|
+
// Medians, so a gap stays a gap rather than plotting as zero.
|
|
2583
|
+
values: boxes.map((b) => (b ? b.median : Number.NaN)),
|
|
2584
|
+
boxes,
|
|
2585
|
+
};
|
|
2586
|
+
});
|
|
2587
|
+
return {
|
|
2588
|
+
type: 'boxplot',
|
|
2589
|
+
categories: cats,
|
|
2590
|
+
series,
|
|
2591
|
+
...(opts.width ? { width: opts.width } : {}),
|
|
2592
|
+
...(opts.height ? { height: opts.height } : {}),
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Build a scatter / bubble spec straight from rows.
|
|
2597
|
+
*
|
|
2598
|
+
* Unlike the adapters above this cannot reuse `rowsToChartSpec`: a scatter
|
|
2599
|
+
* point is one row, not one group, so there is nothing to reduce. `series`
|
|
2600
|
+
* colours the points by a categorical field.
|
|
2601
|
+
*/
|
|
2602
|
+
export function rowsToScatterSpec(rows, opts) {
|
|
2603
|
+
const bySeries = new Map();
|
|
2604
|
+
for (const row of rows) {
|
|
2605
|
+
const x = Number(row[opts.x]);
|
|
2606
|
+
const y = Number(row[opts.y]);
|
|
2607
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
2608
|
+
continue;
|
|
2609
|
+
const key = opts.series ? String(row[opts.series] ?? '') : opts.y;
|
|
2610
|
+
const pt = { x, y };
|
|
2611
|
+
if (opts.r) {
|
|
2612
|
+
const r = Number(row[opts.r]);
|
|
2613
|
+
if (Number.isFinite(r))
|
|
2614
|
+
pt.r = r;
|
|
2615
|
+
}
|
|
2616
|
+
if (opts.label)
|
|
2617
|
+
pt.label = String(row[opts.label] ?? '');
|
|
2618
|
+
const list = bySeries.get(key);
|
|
2619
|
+
if (list)
|
|
2620
|
+
list.push(pt);
|
|
2621
|
+
else
|
|
2622
|
+
bySeries.set(key, [pt]);
|
|
2623
|
+
}
|
|
2624
|
+
return {
|
|
2625
|
+
type: 'scatter',
|
|
2626
|
+
categories: [],
|
|
2627
|
+
series: [...bySeries].map(([label, points]) => ({ label, values: [], points })),
|
|
2628
|
+
palette: opts.palette,
|
|
2629
|
+
width: opts.width,
|
|
2630
|
+
height: opts.height,
|
|
2631
|
+
xAxisTitle: opts.x,
|
|
2632
|
+
yAxisTitle: opts.y,
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Reduce rows to the single number a gauge shows.
|
|
2637
|
+
*
|
|
2638
|
+
* There is no category axis here, which is why this cannot go through
|
|
2639
|
+
* `rowsToChartSpec`. The dial ends on a nice round number rather than exactly
|
|
2640
|
+
* the value, so the needle never sits pinned at the far end of the arc.
|
|
2641
|
+
*/
|
|
2642
|
+
export function rowsToGaugeSpec(rows, opts) {
|
|
2643
|
+
const reduce = opts.reduce ?? 'sum';
|
|
2644
|
+
let sum = 0;
|
|
2645
|
+
let count = 0;
|
|
2646
|
+
for (const row of rows) {
|
|
2647
|
+
const v = Number(row[opts.value]);
|
|
2648
|
+
if (!Number.isFinite(v))
|
|
2649
|
+
continue;
|
|
2650
|
+
sum += v;
|
|
2651
|
+
count += 1;
|
|
2652
|
+
}
|
|
2653
|
+
const value = reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum;
|
|
2654
|
+
const min = opts.min ?? Math.min(0, value);
|
|
2655
|
+
const max = opts.max ?? (value > min ? niceScale(min, value).max : min + 1);
|
|
2656
|
+
return {
|
|
2657
|
+
type: 'gauge',
|
|
2658
|
+
categories: [],
|
|
2659
|
+
series: [],
|
|
2660
|
+
gaugeValue: value,
|
|
2661
|
+
gaugeMin: min,
|
|
2662
|
+
gaugeMax: max,
|
|
2663
|
+
gaugeTarget: opts.target,
|
|
2664
|
+
gaugeUnit: opts.unit,
|
|
2665
|
+
width: opts.width,
|
|
2666
|
+
height: opts.height,
|
|
2667
|
+
};
|
|
2668
|
+
}
|