@svgrid/grid 2.2.27 → 2.2.29
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 +199 -199
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +179 -179
- package/dist/GridMenus.svelte +782 -734
- package/dist/SvAutoComplete.svelte +165 -165
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +101 -101
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +275 -275
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +160 -160
- package/dist/SvDrawer.svelte +254 -250
- package/dist/SvDropDownList.svelte +374 -374
- 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 +14 -1
- package/dist/SvGrid.controller.svelte.js +112 -28
- package/dist/SvGrid.css +2704 -2723
- package/dist/SvGrid.svelte +3361 -3333
- package/dist/SvGrid.types.d.ts +20 -5
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +696 -696
- package/dist/SvGridSelect.svelte +259 -259
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvListBox.svelte +326 -287
- package/dist/SvListBox.svelte.d.ts +10 -1
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +281 -281
- package/dist/SvNumberInput.svelte +168 -168
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +133 -131
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +200 -200
- package/dist/SvSwitchButton.svelte +107 -107
- package/dist/SvTagsInput.svelte +112 -112
- 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 +204 -204
- package/dist/SvTree.svelte +437 -437
- package/dist/SvTreeSelect.svelte +233 -233
- package/dist/cdn/GridMenus-Da_p7SKS.js +486 -0
- package/dist/cdn/GridMenus-Ds3OpzZT.js +490 -0
- package/dist/cdn/{SvDateTimePicker-B1sDc9Pz.js → SvDateTimePicker-B8GopjhC.js} +2 -2
- package/dist/cdn/{SvDateTimePicker-Bh0FTs1E.js → SvDateTimePicker-DB0aIEk8.js} +2 -2
- package/dist/cdn/{SvGridChart-Bgk4hDs8.js → SvGridChart-BcLCYvw9.js} +1 -1
- package/dist/cdn/{SvGridChart-BKQsO5fZ.js → SvGridChart-BhnFdLd0.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DkCc2Z7b.js → SvGridChartPanel-Cmx9DHAS.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DSkHIQDd.js → SvGridChartPanel-DvjwxLvK.js} +1 -1
- package/dist/cdn/{SvGridChartView-BrbVaELm.js → SvGridChartView-B40DTJSX.js} +1 -1
- package/dist/cdn/{SvGridChartView-i9rWvSKg.js → SvGridChartView-PwqI4s02.js} +1 -1
- package/dist/cdn/{SvGridDropdown-D08sVmAy.js → SvGridDropdown-DjALc_4f.js} +1 -1
- package/dist/cdn/{SvGridDropdown-CGxxopQ4.js → SvGridDropdown-hpnHvOGt.js} +1 -1
- package/dist/cdn/{src-cgs1wcUS.js → src-BLJfdyL0.js} +4034 -3889
- package/dist/cdn/{src-Cw8d9U8j.js → src-DYDCiC0D.js} +7053 -6908
- package/dist/cdn/svgrid.js +9 -9
- package/dist/cdn/svgrid.svelte-external.js +9 -9
- package/dist/chart-export.js +8 -8
- package/dist/column-groups.js +8 -10
- package/dist/column-id.d.ts +9 -0
- package/dist/column-id.js +25 -0
- package/dist/core.js +2 -1
- package/dist/createListbox.svelte.d.ts +15 -2
- package/dist/createListbox.svelte.js +27 -6
- package/dist/editing.d.ts +1 -0
- package/dist/editing.js +45 -10
- package/dist/filtering/excel-filters.d.ts +30 -10
- package/dist/filtering/excel-filters.js +96 -15
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/menus.d.ts +2 -0
- package/dist/menus.js +25 -0
- package/dist/row-resize.js +40 -0
- package/dist/selection.js +1 -1
- package/package.json +10 -10
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +179 -179
- package/src/GridMenus.svelte +782 -734
- package/src/SvAutoComplete.svelte +165 -165
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +101 -101
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +275 -275
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +160 -160
- package/src/SvDrawer.svelte +254 -250
- package/src/SvDrawer.test.ts +18 -1
- package/src/SvDropDownList.svelte +374 -374
- 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 +3335 -3239
- package/src/SvGrid.css +2704 -2723
- package/src/SvGrid.svelte +3361 -3333
- package/src/SvGrid.types.ts +1628 -1613
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +696 -696
- package/src/SvGridSelect.svelte +259 -259
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvListBox.svelte +326 -287
- 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 +281 -281
- package/src/SvNavPane.test.ts +15 -4
- package/src/SvNumberInput.svelte +168 -168
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +133 -131
- package/src/SvPopover.svelte +197 -197
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +200 -200
- package/src/SvSwitchButton.svelte +107 -107
- package/src/SvTagsInput.svelte +112 -112
- 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 +204 -204
- package/src/SvTree.svelte +437 -437
- package/src/SvTreeSelect.svelte +233 -233
- 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 +502 -502
- package/src/ai.ts +1391 -1391
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +895 -895
- package/src/builtin-editors.grid.test.ts +85 -85
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2296 -2296
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/column-groups.test.ts +48 -0
- package/src/column-groups.ts +8 -10
- package/src/column-id.ts +30 -0
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1112 -1111
- 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 +39 -7
- package/src/createMenu.svelte.ts +225 -225
- 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 +319 -319
- package/src/editing.test.ts +949 -859
- package/src/editing.ts +709 -675
- package/src/editor-contract.ts +157 -157
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +598 -598
- package/src/filter-operators.ts +157 -157
- package/src/filtering/excel-filters.test.ts +44 -1
- package/src/filtering/excel-filters.ts +91 -14
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/index.ts +715 -713
- package/src/js-scroller.svelte.ts +173 -173
- 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 +172 -172
- package/src/menus.test.ts +736 -700
- package/src/menus.ts +679 -652
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/row-resize.test.ts +100 -0
- package/src/row-resize.ts +36 -0
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +872 -872
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +600 -600
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +489 -489
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +524 -524
- package/src/svgrid.api-extensions.test.ts +8 -3
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.cell-dom-ids.test.ts +96 -0
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.conditional-stat-scope.test.ts +111 -0
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +308 -0
- package/src/svgrid.filter-menu-scroll.test.ts +3 -1
- package/src/svgrid.group-header-ids.test.ts +121 -0
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +215 -215
- package/src/toast-store.svelte.ts +246 -246
- package/src/toast-store.test.ts +147 -147
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- 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 +296 -296
- package/themes/ember.css +71 -71
- package/dist/cdn/GridMenus-BzxEYNtW.js +0 -421
- package/dist/cdn/GridMenus-Dr-nGEXn.js +0 -417
package/src/chart.ts
CHANGED
|
@@ -1,2296 +1,2296 @@
|
|
|
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
|
-
export type ChartType =
|
|
12
|
-
| 'bar' | 'line' | 'area' | 'pie' | 'scatter'
|
|
13
|
-
| 'heatmap' | 'waterfall' | 'funnel' | 'radar'
|
|
14
|
-
| 'calendar' | 'gauge' | 'treemap' | 'sankey'
|
|
15
|
-
|
|
16
|
-
/** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`.
|
|
17
|
-
* `rowIds` is populated when the spec was built from grid rows (via
|
|
18
|
-
* `rowsToChartSpec`) and lets a drill handler filter the grid back to the
|
|
19
|
-
* source rows for the clicked category / series cell. */
|
|
20
|
-
export type ChartSelection = {
|
|
21
|
-
category: string
|
|
22
|
-
series: string
|
|
23
|
-
value: number
|
|
24
|
-
rowIds?: Array<string | number>
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** A single scatter / bubble point. */
|
|
28
|
-
export type ScatterPoint = { x: number; y: number; r?: number; label?: string }
|
|
29
|
-
|
|
30
|
-
/** A statistical / smoothing line drawn on top of a source series.
|
|
31
|
-
* - `'linear'`: ordinary least-squares regression line
|
|
32
|
-
* - `'sma:N'`: simple moving average over a window of N points
|
|
33
|
-
* - `'ema:N'`: exponential moving average with smoothing factor 2/(N+1) */
|
|
34
|
-
export type SeriesOverlay = 'linear' | `sma:${number}` | `ema:${number}`
|
|
35
|
-
|
|
36
|
-
/** A texture fill applied in addition to (and on top of) the series color.
|
|
37
|
-
* Helps colorblind readers distinguish series at a glance. */
|
|
38
|
-
export type SeriesPattern = 'solid' | 'stripe' | 'crosshatch' | 'dots' | 'diagonal'
|
|
39
|
-
/** Cycle used when `ChartSpec.patternFallback` is true and a series has no
|
|
40
|
-
* explicit `pattern` set. Skips `'solid'` so every series gets a texture. */
|
|
41
|
-
const PATTERN_CYCLE: SeriesPattern[] = ['stripe', 'crosshatch', 'dots', 'diagonal']
|
|
42
|
-
|
|
43
|
-
export type ChartSeries = {
|
|
44
|
-
label: string
|
|
45
|
-
values: number[]
|
|
46
|
-
color?: string
|
|
47
|
-
/** Per-series chart type, for combo charts. Defaults to the spec `type`. */
|
|
48
|
-
type?: 'bar' | 'line' | 'area'
|
|
49
|
-
/** Plot against the left (default) or right Y axis. */
|
|
50
|
-
axis?: 'left' | 'right'
|
|
51
|
-
/** Scatter / bubble points (used when `type === 'scatter'`). */
|
|
52
|
-
points?: ScatterPoint[]
|
|
53
|
-
/** Row IDs contributing to each data point - parallel to `values`. When
|
|
54
|
-
* present, click handlers receive these in `ChartSelection.rowIds` so
|
|
55
|
-
* callers can drill the grid back to the source rows. */
|
|
56
|
-
rowIds?: Array<Array<string | number>>
|
|
57
|
-
/** Draw a smoothing / trend overlay on top of this series. */
|
|
58
|
-
overlay?: SeriesOverlay
|
|
59
|
-
/** Color for the overlay line. Defaults to the series color. */
|
|
60
|
-
overlayColor?: string
|
|
61
|
-
/** Texture fill (e.g. diagonal stripes) layered over the series color.
|
|
62
|
-
* Lets colorblind viewers tell two series apart even at the same hue. */
|
|
63
|
-
pattern?: SeriesPattern
|
|
64
|
-
/** Interpolate the line as a curve instead of polylines. `'monotone'`
|
|
65
|
-
* cubic prevents overshoots between points (best default for data);
|
|
66
|
-
* `true` is an alias for `'monotone'`. Only meaningful for line/area. */
|
|
67
|
-
smooth?: boolean | 'monotone'
|
|
68
|
-
/** Upper envelope (e.g. forecast 95th percentile) parallel to `values`.
|
|
69
|
-
* When set alongside `lowerValues`, the chart shades the band between
|
|
70
|
-
* the two as a translucent fill in the series color. */
|
|
71
|
-
upperValues?: number[]
|
|
72
|
-
/** Lower envelope; pair with `upperValues` for a confidence band. */
|
|
73
|
-
lowerValues?: number[]
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** A pinned label drawn over the plot, anchored to a data point or to an
|
|
77
|
-
* arbitrary (x, y) in data space. Useful for "Release v1", "Outage", etc. */
|
|
78
|
-
export type ChartAnnotation = {
|
|
79
|
-
/** Anchor in data space. Provide either `category` + `axis` for a point on
|
|
80
|
-
* an existing series, OR raw `x` / `y` numeric coordinates in data space. */
|
|
81
|
-
at:
|
|
82
|
-
| { category: string; series?: string }
|
|
83
|
-
| { x: number; y?: number }
|
|
84
|
-
label: string
|
|
85
|
-
color?: string
|
|
86
|
-
/** Where the label sits relative to the marker. Defaults to 'top'. */
|
|
87
|
-
placement?: 'top' | 'bottom' | 'left' | 'right'
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** A horizontal reference / target line drawn across the plot. */
|
|
91
|
-
export type ChartReferenceLine = {
|
|
92
|
-
value: number
|
|
93
|
-
label?: string
|
|
94
|
-
axis?: 'left' | 'right'
|
|
95
|
-
color?: string
|
|
96
|
-
dashed?: boolean
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export type ChartSpec = {
|
|
100
|
-
/** Default type for series that don't set their own `type`. */
|
|
101
|
-
type: ChartType
|
|
102
|
-
/** X-axis labels (one per data point). */
|
|
103
|
-
categories: string[]
|
|
104
|
-
series: ChartSeries[]
|
|
105
|
-
width?: number
|
|
106
|
-
height?: number
|
|
107
|
-
/** Palette used when a series has no explicit `color`. */
|
|
108
|
-
palette?: string[]
|
|
109
|
-
/** Per-category color overrides (by category label) - for pie / donut slice
|
|
110
|
-
* recolouring, where colour follows the category, not a series. */
|
|
111
|
-
categoryColors?: Record<string, string>
|
|
112
|
-
/** Number format for the value axis, tooltips, data labels and reference
|
|
113
|
-
* lines. Unset = the default compact `1.2k` / `1.2M` style. */
|
|
114
|
-
valueFormat?: ChartValueFormat
|
|
115
|
-
/** Grouped (nested) category axis: a parent tier spanning consecutive leaf
|
|
116
|
-
* categories (spans must sum to `categories.length`). Vertical category
|
|
117
|
-
* charts only (ignored for time / horizontal / pie). */
|
|
118
|
-
categoryGroups?: Array<{ label: string; span: number }>
|
|
119
|
-
/** Stack bar / area series (per axis) instead of grouping them. */
|
|
120
|
-
stacked?: boolean
|
|
121
|
-
/** Stack to 100% (each category normalized to its total). Implies stacked. */
|
|
122
|
-
stacked100?: boolean
|
|
123
|
-
/**
|
|
124
|
-
* Bar orientation. `'horizontal'` swaps the axes - categories run down the
|
|
125
|
-
* left, bars grow rightward - which suits long category labels. Only applies
|
|
126
|
-
* when every series is a bar (combo / line / area fall back to vertical).
|
|
127
|
-
*/
|
|
128
|
-
orientation?: 'vertical' | 'horizontal'
|
|
129
|
-
/** Pie only: inner radius as a fraction of the outer radius (0..1) -> donut. */
|
|
130
|
-
innerRadius?: number
|
|
131
|
-
/** Horizontal target / goal / average lines. */
|
|
132
|
-
referenceLines?: ChartReferenceLine[]
|
|
133
|
-
/** Treat `categories` as dates -> time-scaled x positions + date ticks. */
|
|
134
|
-
xType?: 'category' | 'time'
|
|
135
|
-
/** Axis titles (reserve gutter space + render). */
|
|
136
|
-
yAxisTitle?: string
|
|
137
|
-
y2AxisTitle?: string
|
|
138
|
-
xAxisTitle?: string
|
|
139
|
-
/** Y-axis scale. `'log'` plots base-10 logarithmic - values <= 0 are
|
|
140
|
-
* treated as missing. Necessary for wide-range data (money, audience
|
|
141
|
-
* size, scientific). Default `'linear'`. */
|
|
142
|
-
yScale?: 'linear' | 'log'
|
|
143
|
-
/** Right (secondary) Y-axis scale. Default `'linear'`. */
|
|
144
|
-
y2Scale?: 'linear' | 'log'
|
|
145
|
-
/** Pinned text labels at fixed data-space positions (callouts). */
|
|
146
|
-
annotations?: ChartAnnotation[]
|
|
147
|
-
/** When true, automatically cycle through pattern fills for every series
|
|
148
|
-
* that doesn't set `pattern` explicitly. Useful as a one-flag colorblind
|
|
149
|
-
* fallback. Default false. */
|
|
150
|
-
patternFallback?: boolean
|
|
151
|
-
/** Calendar heatmap: array of date+value samples (one per day). Date
|
|
152
|
-
* strings are 'YYYY-MM-DD'. Missing days render as blank cells. */
|
|
153
|
-
calendarValues?: Array<{ date: string; value: number }>
|
|
154
|
-
/** Calendar heatmap: year window. Default: span the data. */
|
|
155
|
-
calendarStart?: string
|
|
156
|
-
calendarEnd?: string
|
|
157
|
-
|
|
158
|
-
/** Gauge: the value to display. */
|
|
159
|
-
gaugeValue?: number
|
|
160
|
-
/** Gauge: min/max of the dial scale. Defaults [0, 100]. */
|
|
161
|
-
gaugeMin?: number
|
|
162
|
-
gaugeMax?: number
|
|
163
|
-
/** Gauge: target marker (the line/notch on the arc). */
|
|
164
|
-
gaugeTarget?: number
|
|
165
|
-
/** Gauge: color bands along the arc (e.g. red/amber/green). */
|
|
166
|
-
gaugeRanges?: Array<{ from: number; to: number; color: string }>
|
|
167
|
-
/** Gauge: unit / suffix shown next to the value (e.g. '%', 'ms'). */
|
|
168
|
-
gaugeUnit?: string
|
|
169
|
-
|
|
170
|
-
/** Tree-map: hierarchical root. Leaves have `value`; parents are the
|
|
171
|
-
* sum of their children's totals. */
|
|
172
|
-
treemap?: TreeNode
|
|
173
|
-
|
|
174
|
-
/** Sankey: nodes + flow links between them. Link `source` / `target`
|
|
175
|
-
* reference node ids. */
|
|
176
|
-
sankeyNodes?: Array<{ id: string; label?: string; color?: string }>
|
|
177
|
-
sankeyLinks?: Array<{ source: string; target: string; value: number; color?: string }>
|
|
178
|
-
|
|
179
|
-
/** Waterfall: per-category flag marking bars as totals/subtotals that
|
|
180
|
-
* reset the running sum and span from 0. Same length as `categories`. */
|
|
181
|
-
waterfallTotals?: boolean[]
|
|
182
|
-
/** Waterfall: explicit colors for positive/negative/total bars. The
|
|
183
|
-
* series color is ignored when this is set. */
|
|
184
|
-
waterfallColors?: { positive?: string; negative?: string; total?: string }
|
|
185
|
-
/** Heatmap color scale. `'sequential'` maps min->max through one hue,
|
|
186
|
-
* `'diverging'` runs cold->neutral->warm around 0. A custom array
|
|
187
|
-
* (>=2 hex colors) defines an arbitrary gradient. Default `'sequential'`. */
|
|
188
|
-
colorScale?: 'sequential' | 'diverging' | string[]
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** A tree-map / sankey / treemap node spec. Used recursively as a tree. */
|
|
192
|
-
export type TreeNode = {
|
|
193
|
-
name: string
|
|
194
|
-
value?: number
|
|
195
|
-
color?: string
|
|
196
|
-
children?: TreeNode[]
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** A laid-out tree-map rectangle. */
|
|
200
|
-
export type ChartTreemapCell = {
|
|
201
|
-
x: number
|
|
202
|
-
y: number
|
|
203
|
-
w: number
|
|
204
|
-
h: number
|
|
205
|
-
color: string
|
|
206
|
-
textColor: string
|
|
207
|
-
name: string
|
|
208
|
-
value: number
|
|
209
|
-
/** Depth from the root - useful for color cycling per level. */
|
|
210
|
-
depth: number
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** A calendar-heatmap cell (one day). */
|
|
214
|
-
export type ChartCalendarCell = {
|
|
215
|
-
x: number
|
|
216
|
-
y: number
|
|
217
|
-
size: number
|
|
218
|
-
date: string
|
|
219
|
-
value: number
|
|
220
|
-
/** Defined when a value was supplied for this day; blank otherwise. */
|
|
221
|
-
defined: boolean
|
|
222
|
-
color: string
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** A gauge dial layout. */
|
|
226
|
-
export type ChartGaugeLayout = {
|
|
227
|
-
cx: number
|
|
228
|
-
cy: number
|
|
229
|
-
r: number
|
|
230
|
-
/** Track arc path (background grey). */
|
|
231
|
-
trackPath: string
|
|
232
|
-
/** Value arc path (filled to the current value). */
|
|
233
|
-
valuePath: string
|
|
234
|
-
/** Optional colored range arcs. */
|
|
235
|
-
rangePaths: Array<{ path: string; color: string; from: number; to: number }>
|
|
236
|
-
/** Pixel position of the target marker (when set). */
|
|
237
|
-
target: { x1: number; y1: number; x2: number; y2: number } | null
|
|
238
|
-
/** Tick marks around the dial (major ticks are longer). */
|
|
239
|
-
ticks: Array<{ x1: number; y1: number; x2: number; y2: number; major: boolean }>
|
|
240
|
-
/** Pointer needle (a kite shape) + its center hub radius. */
|
|
241
|
-
needle: { path: string; hubR: number }
|
|
242
|
-
/** Status color of the value arc (the band the value falls in), or null to
|
|
243
|
-
* fall back to the theme accent. */
|
|
244
|
-
valueColor: string | null
|
|
245
|
-
/** Scale end labels positioned under the two arc ends. */
|
|
246
|
-
minLabel: { x: number; y: number }
|
|
247
|
-
maxLabel: { x: number; y: number }
|
|
248
|
-
value: number
|
|
249
|
-
min: number
|
|
250
|
-
max: number
|
|
251
|
-
unit: string
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** A sankey node + its laid-out rect + total flow. */
|
|
255
|
-
export type ChartSankeyNode = {
|
|
256
|
-
id: string
|
|
257
|
-
label: string
|
|
258
|
-
color: string
|
|
259
|
-
x: number
|
|
260
|
-
y: number
|
|
261
|
-
w: number
|
|
262
|
-
h: number
|
|
263
|
-
/** Column (depth) the node was assigned to. */
|
|
264
|
-
column: number
|
|
265
|
-
totalIn: number
|
|
266
|
-
totalOut: number
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** A sankey link rendered as a curved ribbon. */
|
|
270
|
-
export type ChartSankeyLink = {
|
|
271
|
-
path: string
|
|
272
|
-
color: string
|
|
273
|
-
/** Stroke width = link value scaled to pixels. */
|
|
274
|
-
width: number
|
|
275
|
-
source: string
|
|
276
|
-
target: string
|
|
277
|
-
value: number
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** A single funnel segment (trapezoid) in pixel space. */
|
|
281
|
-
export type ChartFunnelSegment = {
|
|
282
|
-
/** Pre-built SVG path for the trapezoid. */
|
|
283
|
-
path: string
|
|
284
|
-
color: string
|
|
285
|
-
label: string
|
|
286
|
-
/** Original value (before any percentile normalisation). */
|
|
287
|
-
value: number
|
|
288
|
-
/** Conversion vs. first segment, 0..1. */
|
|
289
|
-
conversion: number
|
|
290
|
-
/** Drop-off from the previous segment, 0..1. */
|
|
291
|
-
dropoff: number
|
|
292
|
-
/** Centre point (label anchor). */
|
|
293
|
-
cx: number
|
|
294
|
-
cy: number
|
|
295
|
-
/** Auto-picked black/white contrast color for in-segment labels. */
|
|
296
|
-
textColor: string
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** A radar series' polygon: axis values + the closed polygon path. */
|
|
300
|
-
export type ChartRadarSeries = {
|
|
301
|
-
label: string
|
|
302
|
-
color: string
|
|
303
|
-
path: string
|
|
304
|
-
/** Per-axis (x, y) endpoints so callers can draw dots / hit targets. */
|
|
305
|
-
points: Array<{ x: number; y: number; value: number; axis: string }>
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Radar axis spoke + tick info. */
|
|
309
|
-
export type ChartRadarAxis = {
|
|
310
|
-
label: string
|
|
311
|
-
/** Outermost endpoint of the spoke. */
|
|
312
|
-
x: number
|
|
313
|
-
y: number
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** A single heatmap rectangle in pixel space. */
|
|
317
|
-
export type ChartHeatmapCell = {
|
|
318
|
-
x: number
|
|
319
|
-
y: number
|
|
320
|
-
w: number
|
|
321
|
-
h: number
|
|
322
|
-
color: string
|
|
323
|
-
/** Text color picked for contrast against `color`. */
|
|
324
|
-
textColor: string
|
|
325
|
-
value: number
|
|
326
|
-
rowLabel: string
|
|
327
|
-
colLabel: string
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export type ChartBar = {
|
|
331
|
-
x: number
|
|
332
|
-
y: number
|
|
333
|
-
w: number
|
|
334
|
-
h: number
|
|
335
|
-
color: string
|
|
336
|
-
/** Category (x label) this bar belongs to - for tooltips + labels. */
|
|
337
|
-
label: string
|
|
338
|
-
/** Series label this bar belongs to. */
|
|
339
|
-
series: string
|
|
340
|
-
value: number
|
|
341
|
-
}
|
|
342
|
-
export type ChartLinePoint = {
|
|
343
|
-
x: number
|
|
344
|
-
y: number
|
|
345
|
-
label: string
|
|
346
|
-
value: number
|
|
347
|
-
/** False for null / NaN values - the line breaks (gap), no dot is drawn. */
|
|
348
|
-
defined: boolean
|
|
349
|
-
}
|
|
350
|
-
export type ChartLine = {
|
|
351
|
-
path: string
|
|
352
|
-
areaPath: string
|
|
353
|
-
color: string
|
|
354
|
-
label: string
|
|
355
|
-
points: ChartLinePoint[]
|
|
356
|
-
/** Confidence-band path (between upperValues + lowerValues) for this
|
|
357
|
-
* series, when both arrays are supplied. Empty otherwise. */
|
|
358
|
-
bandPath?: string
|
|
359
|
-
}
|
|
360
|
-
export type ChartPieSlice = {
|
|
361
|
-
path: string
|
|
362
|
-
color: string
|
|
363
|
-
label: string
|
|
364
|
-
value: number
|
|
365
|
-
percent: number
|
|
366
|
-
/** Centroid - anchor point for a data label. */
|
|
367
|
-
cx: number
|
|
368
|
-
cy: number
|
|
369
|
-
}
|
|
370
|
-
export type ChartAxisTick = { value: number; y: number; label: string }
|
|
371
|
-
export type ChartCategoryTick = { label: string; x: number }
|
|
372
|
-
export type ChartLegendItem = { label: string; color: string }
|
|
373
|
-
export type ChartRefLineGeo = { y: number; label: string; color: string; dashed: boolean }
|
|
374
|
-
/** A vertical reference line (horizontal bar charts) positioned by `x`. */
|
|
375
|
-
export type ChartRefLineGeoV = { x: number; label: string; color: string; dashed: boolean }
|
|
376
|
-
export type ChartScatterDot = {
|
|
377
|
-
cx: number
|
|
378
|
-
cy: number
|
|
379
|
-
r: number
|
|
380
|
-
color: string
|
|
381
|
-
label: string
|
|
382
|
-
series: string
|
|
383
|
-
x: number
|
|
384
|
-
y: number
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
export type ChartGeometry = {
|
|
388
|
-
type: ChartType
|
|
389
|
-
width: number
|
|
390
|
-
height: number
|
|
391
|
-
plot: { x: number; y: number; w: number; h: number }
|
|
392
|
-
bars: ChartBar[]
|
|
393
|
-
lines: ChartLine[]
|
|
394
|
-
slices: ChartPieSlice[]
|
|
395
|
-
yTicks: ChartAxisTick[]
|
|
396
|
-
/** Right-axis ticks (combo / dual-axis); empty when there's no right axis. */
|
|
397
|
-
y2Ticks: ChartAxisTick[]
|
|
398
|
-
hasRightAxis: boolean
|
|
399
|
-
xTicks: ChartCategoryTick[]
|
|
400
|
-
/** Grouped category axis parent tier: label + span extent (pixels). Empty
|
|
401
|
-
* unless `spec.categoryGroups` is set on a vertical category chart. */
|
|
402
|
-
categoryGroupTicks: Array<{ label: string; xCenter: number; x0: number; x1: number }>
|
|
403
|
-
/** True when x labels are long/many and should be rotated. */
|
|
404
|
-
xLabelRotated: boolean
|
|
405
|
-
legend: ChartLegendItem[]
|
|
406
|
-
/** Donut centre (pie + innerRadius), for a centre total label. */
|
|
407
|
-
donut: { cx: number; cy: number; r: number; total: number } | null
|
|
408
|
-
/** Horizontal reference / target lines. */
|
|
409
|
-
referenceLines: ChartRefLineGeo[]
|
|
410
|
-
/** Scatter / bubble points (type === 'scatter'). */
|
|
411
|
-
scatterPoints: ChartScatterDot[]
|
|
412
|
-
/** Bar orientation. `'horizontal'` uses `valueTicks` / `catTicks` below. */
|
|
413
|
-
orientation: 'vertical' | 'horizontal'
|
|
414
|
-
/** Horizontal bars: value-axis ticks along the bottom (label + x). */
|
|
415
|
-
valueTicks: ChartCategoryTick[]
|
|
416
|
-
/** Horizontal bars: category labels down the left (label + y; value = index). */
|
|
417
|
-
catTicks: ChartAxisTick[]
|
|
418
|
-
/** Horizontal bars: vertical reference / target lines (positioned by x). */
|
|
419
|
-
referenceLinesV: ChartRefLineGeoV[]
|
|
420
|
-
/** Trend / moving-average overlay lines (parallel to `lines`). Drawn
|
|
421
|
-
* dashed on top of their source series. */
|
|
422
|
-
overlays: ChartLine[]
|
|
423
|
-
/** Pinned annotation labels with pre-resolved screen coordinates. */
|
|
424
|
-
annotations: Array<{ x: number; y: number; label: string; color: string; placement: 'top' | 'bottom' | 'left' | 'right' }>
|
|
425
|
-
/** Heatmap cells (type === 'heatmap'). */
|
|
426
|
-
heatmapCells: ChartHeatmapCell[]
|
|
427
|
-
/** Heatmap row labels with pre-resolved y positions (left gutter). */
|
|
428
|
-
heatmapRowTicks: ChartAxisTick[]
|
|
429
|
-
/** Heatmap column labels (bottom of plot). */
|
|
430
|
-
heatmapColTicks: ChartCategoryTick[]
|
|
431
|
-
/** Heatmap color-scale legend: ordered stops with value + color. */
|
|
432
|
-
heatmapLegend: Array<{ value: number; color: string; label: string }>
|
|
433
|
-
/** Funnel segments (type === 'funnel'). */
|
|
434
|
-
funnelSegments: ChartFunnelSegment[]
|
|
435
|
-
/** Radar concentric grid rings (centred at `radarCenter`). */
|
|
436
|
-
radarRings: number[]
|
|
437
|
-
/** Radar axis labels + spoke endpoints. */
|
|
438
|
-
radarAxes: ChartRadarAxis[]
|
|
439
|
-
/** Radar series polygons. */
|
|
440
|
-
radarSeries: ChartRadarSeries[]
|
|
441
|
-
/** Centre of the radar / pie. Pre-computed so callers don't re-derive. */
|
|
442
|
-
radarCenter: { cx: number; cy: number; r: number } | null
|
|
443
|
-
/** Tree-map cells (type === 'treemap'). */
|
|
444
|
-
treemapCells: ChartTreemapCell[]
|
|
445
|
-
/** Calendar heatmap (type === 'calendar'). */
|
|
446
|
-
calendarCells: ChartCalendarCell[]
|
|
447
|
-
/** Calendar month labels along the top. */
|
|
448
|
-
calendarMonthTicks: ChartCategoryTick[]
|
|
449
|
-
/** Calendar legend stops (sequential ramp). */
|
|
450
|
-
calendarLegend: Array<{ value: number; color: string; label: string }>
|
|
451
|
-
/** Gauge layout (type === 'gauge'). Null when not a gauge. */
|
|
452
|
-
gauge: ChartGaugeLayout | null
|
|
453
|
-
/** Sankey nodes (type === 'sankey'). */
|
|
454
|
-
sankeyNodes: ChartSankeyNode[]
|
|
455
|
-
/** Sankey links (type === 'sankey'). */
|
|
456
|
-
sankeyLinks: ChartSankeyLink[]
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
export const DEFAULT_PALETTE = [
|
|
460
|
-
'#2563eb',
|
|
461
|
-
'#16a34a',
|
|
462
|
-
'#f59e0b',
|
|
463
|
-
'#ef4444',
|
|
464
|
-
'#8b5cf6',
|
|
465
|
-
'#0ea5e9',
|
|
466
|
-
'#ec4899',
|
|
467
|
-
'#14b8a6',
|
|
468
|
-
]
|
|
469
|
-
|
|
470
|
-
function round(n: number): number {
|
|
471
|
-
return Math.round(n * 100) / 100
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
function niceNum(range: number, roundIt: boolean): number {
|
|
475
|
-
if (range <= 0) return 1
|
|
476
|
-
const exp = Math.floor(Math.log10(range))
|
|
477
|
-
const f = range / Math.pow(10, exp)
|
|
478
|
-
let nf: number
|
|
479
|
-
if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
|
|
480
|
-
else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
|
|
481
|
-
return nf * Math.pow(10, exp)
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
|
|
485
|
-
|
|
486
|
-
// ---- Color helpers for heatmap / pattern fills ----------------------
|
|
487
|
-
|
|
488
|
-
/** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
|
|
489
|
-
const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
|
|
490
|
-
/** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
|
|
491
|
-
const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
|
|
492
|
-
/** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
|
|
493
|
-
* above the dark grid surface instead of near-white, so empty / low cells read
|
|
494
|
-
* as "cold" rather than as glaring white rectangles. */
|
|
495
|
-
const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
|
|
496
|
-
const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
|
|
497
|
-
|
|
498
|
-
function resolveColorScale(
|
|
499
|
-
scale: 'sequential' | 'diverging' | string[] | undefined,
|
|
500
|
-
vMin: number,
|
|
501
|
-
vMax: number,
|
|
502
|
-
theme: 'light' | 'dark' = 'light',
|
|
503
|
-
): string[] {
|
|
504
|
-
if (Array.isArray(scale) && scale.length >= 2) return scale
|
|
505
|
-
const dark = theme === 'dark'
|
|
506
|
-
if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
|
|
507
|
-
return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
|
|
508
|
-
}
|
|
509
|
-
return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/** Sample a hex color from an array of hex stops at fractional position t.
|
|
513
|
-
* Linearly interpolates between the two nearest stops in RGB space. */
|
|
514
|
-
export function sampleGradient(stops: string[], t: number): string {
|
|
515
|
-
if (!stops.length) return '#888'
|
|
516
|
-
const clamped = Math.max(0, Math.min(1, t))
|
|
517
|
-
if (stops.length === 1) return stops[0]!
|
|
518
|
-
const pos = clamped * (stops.length - 1)
|
|
519
|
-
const i = Math.floor(pos)
|
|
520
|
-
const frac = pos - i
|
|
521
|
-
const a = hexToRgb(stops[i]!)
|
|
522
|
-
const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
|
|
523
|
-
if (!a || !b) return stops[i] ?? '#888'
|
|
524
|
-
const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
|
|
525
|
-
const toHex = (n: number) => n.toString(16).padStart(2, '0')
|
|
526
|
-
return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
530
|
-
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
|
|
531
|
-
if (!m) return null
|
|
532
|
-
const n = parseInt(m[1]!, 16)
|
|
533
|
-
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** Pick a black or white text color that has the better contrast against
|
|
537
|
-
* the given background. Uses the WCAG relative-luminance heuristic. */
|
|
538
|
-
export function pickContrastText(bgHex: string): string {
|
|
539
|
-
const rgb = hexToRgb(bgHex)
|
|
540
|
-
if (!rgb) return '#0f172a'
|
|
541
|
-
const lin = (c: number) => {
|
|
542
|
-
const s = c / 255
|
|
543
|
-
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
544
|
-
}
|
|
545
|
-
const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
|
|
546
|
-
return L > 0.5 ? '#0f172a' : '#ffffff'
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/** Pick the largest power of 10 that fits at the bottom of [min,max], and
|
|
550
|
-
* the smallest that covers the top, then enumerate decade boundaries. Used
|
|
551
|
-
* by log-scale axes (yScale: 'log'). */
|
|
552
|
-
export function niceLogScale(min: number, max: number): NiceScale {
|
|
553
|
-
// Only positive values are plottable on a log scale; callers should
|
|
554
|
-
// strip non-positive values before passing them in.
|
|
555
|
-
if (!Number.isFinite(min) || min <= 0) min = 1
|
|
556
|
-
if (!Number.isFinite(max) || max <= min) max = min * 10
|
|
557
|
-
const lo = Math.floor(Math.log10(min))
|
|
558
|
-
const hi = Math.ceil(Math.log10(max))
|
|
559
|
-
const ticks: number[] = []
|
|
560
|
-
for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
|
|
561
|
-
return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/** Map a value to a fractional position [0..1] across the axis domain.
|
|
565
|
-
* Pass the appropriate fn into projection code so linear / log share the
|
|
566
|
-
* same plumbing. Returns null for non-positive values on log. */
|
|
567
|
-
function project(value: number, min: number, max: number, isLog: boolean): number | null {
|
|
568
|
-
if (!Number.isFinite(value)) return null
|
|
569
|
-
if (isLog) {
|
|
570
|
-
if (value <= 0 || min <= 0) return null
|
|
571
|
-
return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
|
|
572
|
-
}
|
|
573
|
-
return (value - min) / (max - min)
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// ---- Overlay math: trendline + moving averages -----------------------
|
|
577
|
-
|
|
578
|
-
/** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
|
|
579
|
-
* Returns the fitted value at each x index, or NaN where the source value
|
|
580
|
-
* was non-finite. */
|
|
581
|
-
/** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
|
|
582
|
-
* monotone cubic interpolation (preserves local extrema - no overshoots).
|
|
583
|
-
* Breaks the path at `defined === false` gaps. */
|
|
584
|
-
export function buildLinePath(
|
|
585
|
-
pts: Array<{ x: number; y: number; defined: boolean }>,
|
|
586
|
-
smooth: boolean,
|
|
587
|
-
): string {
|
|
588
|
-
if (!smooth) {
|
|
589
|
-
let path = ''
|
|
590
|
-
let pen = false
|
|
591
|
-
for (const p of pts) {
|
|
592
|
-
if (!p.defined) { pen = false; continue }
|
|
593
|
-
path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
|
|
594
|
-
pen = true
|
|
595
|
-
}
|
|
596
|
-
return path.trim()
|
|
597
|
-
}
|
|
598
|
-
// Group defined-only runs; each run is smoothed independently.
|
|
599
|
-
const runs: Array<Array<{ x: number; y: number }>> = []
|
|
600
|
-
let cur: Array<{ x: number; y: number }> = []
|
|
601
|
-
for (const p of pts) {
|
|
602
|
-
if (p.defined) cur.push({ x: p.x, y: p.y })
|
|
603
|
-
else if (cur.length) { runs.push(cur); cur = [] }
|
|
604
|
-
}
|
|
605
|
-
if (cur.length) runs.push(cur)
|
|
606
|
-
return runs.map(monotoneCubicPath).filter(Boolean).join(' ')
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
|
|
610
|
-
* Slope at each point chosen so the curve passes through every (xi, yi)
|
|
611
|
-
* AND stays monotonic between them; control points sit 1/3 of the way
|
|
612
|
-
* to the neighbours along that tangent. */
|
|
613
|
-
function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
|
|
614
|
-
const n = pts.length
|
|
615
|
-
if (n === 0) return ''
|
|
616
|
-
if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
|
|
617
|
-
if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
|
|
618
|
-
// Secant slopes between adjacent points.
|
|
619
|
-
const dx: number[] = new Array(n - 1)
|
|
620
|
-
const m: number[] = new Array(n - 1)
|
|
621
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
622
|
-
const d = pts[i + 1]!.x - pts[i]!.x
|
|
623
|
-
dx[i] = d
|
|
624
|
-
m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
|
|
625
|
-
}
|
|
626
|
-
// Tangent at each point: average of neighbouring slopes, with sign
|
|
627
|
-
// checks that flatten the tangent when slopes change sign.
|
|
628
|
-
const tan: number[] = new Array(n)
|
|
629
|
-
tan[0] = m[0]!
|
|
630
|
-
tan[n - 1] = m[n - 2]!
|
|
631
|
-
for (let i = 1; i < n - 1; i += 1) {
|
|
632
|
-
if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
|
|
633
|
-
else tan[i] = (m[i - 1]! + m[i]!) / 2
|
|
634
|
-
}
|
|
635
|
-
// Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
|
|
636
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
637
|
-
if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
|
|
638
|
-
const a = tan[i]! / m[i]!
|
|
639
|
-
const b = tan[i + 1]! / m[i]!
|
|
640
|
-
const h = Math.hypot(a, b)
|
|
641
|
-
if (h > 3) {
|
|
642
|
-
tan[i] = (3 / h) * a * m[i]!
|
|
643
|
-
tan[i + 1] = (3 / h) * b * m[i]!
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
// Build the Bezier path. Each segment: control points at 1/3 of dx.
|
|
647
|
-
let path = `M${pts[0]!.x},${pts[0]!.y}`
|
|
648
|
-
for (let i = 0; i < n - 1; i += 1) {
|
|
649
|
-
const h = dx[i]!
|
|
650
|
-
const c1x = pts[i]!.x + h / 3
|
|
651
|
-
const c1y = pts[i]!.y + (tan[i]! * h) / 3
|
|
652
|
-
const c2x = pts[i + 1]!.x - h / 3
|
|
653
|
-
const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
|
|
654
|
-
path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
|
|
655
|
-
}
|
|
656
|
-
return path
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
export function linearTrend(values: number[]): number[] {
|
|
660
|
-
let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
|
|
661
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
662
|
-
const y = values[i]!
|
|
663
|
-
if (!Number.isFinite(y)) continue
|
|
664
|
-
n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
|
|
665
|
-
}
|
|
666
|
-
if (n < 2) return values.map(() => NaN)
|
|
667
|
-
const denom = n * sumXX - sumX * sumX
|
|
668
|
-
if (denom === 0) return values.map(() => sumY / n)
|
|
669
|
-
const slope = (n * sumXY - sumX * sumY) / denom
|
|
670
|
-
const intercept = (sumY - slope * sumX) / n
|
|
671
|
-
return values.map((_, i) => slope * i + intercept)
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/** Simple moving average over a window of `period` values. Window centres
|
|
675
|
-
* trail to the right (typical for time-series). NaN for points before the
|
|
676
|
-
* window is full. */
|
|
677
|
-
export function simpleMovingAverage(values: number[], period: number): number[] {
|
|
678
|
-
if (period < 1) return values.slice()
|
|
679
|
-
const out: number[] = new Array(values.length).fill(NaN)
|
|
680
|
-
let sum = 0, count = 0
|
|
681
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
682
|
-
const v = values[i]!
|
|
683
|
-
if (Number.isFinite(v)) { sum += v; count += 1 }
|
|
684
|
-
if (i >= period) {
|
|
685
|
-
const drop = values[i - period]!
|
|
686
|
-
if (Number.isFinite(drop)) { sum -= drop; count -= 1 }
|
|
687
|
-
}
|
|
688
|
-
if (i >= period - 1 && count > 0) out[i] = sum / count
|
|
689
|
-
}
|
|
690
|
-
return out
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
|
|
694
|
-
export function exponentialMovingAverage(values: number[], period: number): number[] {
|
|
695
|
-
const alpha = 2 / (Math.max(1, period) + 1)
|
|
696
|
-
const out: number[] = new Array(values.length).fill(NaN)
|
|
697
|
-
let prev: number | null = null
|
|
698
|
-
for (let i = 0; i < values.length; i += 1) {
|
|
699
|
-
const v = values[i]!
|
|
700
|
-
if (!Number.isFinite(v)) { out[i] = prev ?? NaN; continue }
|
|
701
|
-
prev = prev == null ? v : alpha * v + (1 - alpha) * prev
|
|
702
|
-
out[i] = prev
|
|
703
|
-
}
|
|
704
|
-
return out
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
|
|
708
|
-
export function computeOverlay(values: number[], spec: SeriesOverlay): number[] {
|
|
709
|
-
if (spec === 'linear') return linearTrend(values)
|
|
710
|
-
const m = /^(sma|ema):(\d+)$/.exec(spec)
|
|
711
|
-
if (!m) return values.map(() => NaN)
|
|
712
|
-
const period = Number(m[2])
|
|
713
|
-
return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period)
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/** Round a [min,max] domain out to nice tick boundaries. */
|
|
717
|
-
export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
|
|
718
|
-
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
719
|
-
min = 0
|
|
720
|
-
max = 1
|
|
721
|
-
}
|
|
722
|
-
if (min === max) {
|
|
723
|
-
if (min === 0) max = 1
|
|
724
|
-
else {
|
|
725
|
-
min = Math.min(0, min)
|
|
726
|
-
max = Math.max(0, max)
|
|
727
|
-
}
|
|
728
|
-
if (min === max) max = min + 1
|
|
729
|
-
}
|
|
730
|
-
const range = niceNum(max - min, false)
|
|
731
|
-
const step = niceNum(range / Math.max(1, tickCount), true)
|
|
732
|
-
const nMin = Math.floor(min / step) * step
|
|
733
|
-
const nMax = Math.ceil(max / step) * step
|
|
734
|
-
const ticks: number[] = []
|
|
735
|
-
for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
|
|
736
|
-
return { min: nMin, max: nMax, step, ticks }
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
function fmtTick(n: number): string {
|
|
740
|
-
const abs = Math.abs(n)
|
|
741
|
-
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
|
|
742
|
-
if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
|
|
743
|
-
return String(Math.round(n * 100) / 100)
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/** Value-axis / tooltip / label number format. */
|
|
747
|
-
export type ChartValueFormat = 'number' | 'currency' | 'percent' | 'compact'
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* Format a numeric value for display, honouring an optional `valueFormat`.
|
|
751
|
-
* Builds on the compact `1.2k` / `1.2M` base: currency prefixes `$` (sign
|
|
752
|
-
* outside), percent multiplies by 100 and suffixes `%`. Unset / `'number'` /
|
|
753
|
-
* `'compact'` = the plain compact form.
|
|
754
|
-
*/
|
|
755
|
-
export function formatChartValue(n: number, format?: ChartValueFormat): string {
|
|
756
|
-
if (!Number.isFinite(n)) return ''
|
|
757
|
-
if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
|
|
758
|
-
if (format === 'percent') {
|
|
759
|
-
const p = n * 100
|
|
760
|
-
return `${Math.round(p * 10) / 10}%`
|
|
761
|
-
}
|
|
762
|
-
return fmtTick(n)
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
const DAY = 86_400_000
|
|
766
|
-
/** Nice date-tick timestamps across [min, max]. */
|
|
767
|
-
function dateTicks(tMin: number, tMax: number): number[] {
|
|
768
|
-
const span = tMax - tMin
|
|
769
|
-
const step =
|
|
770
|
-
span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY
|
|
771
|
-
const ticks: number[] = []
|
|
772
|
-
for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step) ticks.push(t)
|
|
773
|
-
if (!ticks.length) ticks.push(tMin, tMax)
|
|
774
|
-
return ticks
|
|
775
|
-
}
|
|
776
|
-
function fmtDate(t: number, span: number): string {
|
|
777
|
-
const d = new Date(t)
|
|
778
|
-
if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
779
|
-
if (span <= 800 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
|
|
780
|
-
return String(d.getFullYear())
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
type ResolvedSeries = ChartSeries & {
|
|
784
|
-
color: string
|
|
785
|
-
kind: 'bar' | 'line' | 'area'
|
|
786
|
-
axis: 'left' | 'right'
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
/** Data domain for one axis, honoring stacking of its bar/area series.
|
|
790
|
-
* When `isLog` is true, non-positive values are discarded (log undefined)
|
|
791
|
-
* and the domain is rounded to decade boundaries instead of nice steps. */
|
|
792
|
-
function axisDomain(
|
|
793
|
-
list: ResolvedSeries[],
|
|
794
|
-
categories: string[],
|
|
795
|
-
stacked: boolean,
|
|
796
|
-
extra: number[] = [],
|
|
797
|
-
isLog = false,
|
|
798
|
-
): NiceScale {
|
|
799
|
-
let dMin = Infinity
|
|
800
|
-
let dMax = -Infinity
|
|
801
|
-
const note = (v: number) => {
|
|
802
|
-
if (!Number.isFinite(v)) return
|
|
803
|
-
if (isLog && v <= 0) return
|
|
804
|
-
if (v < dMin) dMin = v
|
|
805
|
-
if (v > dMax) dMax = v
|
|
806
|
-
}
|
|
807
|
-
for (const v of extra) note(v)
|
|
808
|
-
const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area')
|
|
809
|
-
const lines = list.filter((s) => s.kind === 'line')
|
|
810
|
-
if (stacked && stackable.length) {
|
|
811
|
-
for (let i = 0; i < categories.length; i += 1) {
|
|
812
|
-
let pos = 0
|
|
813
|
-
let neg = 0
|
|
814
|
-
for (const s of stackable) {
|
|
815
|
-
const v = s.values[i] ?? 0
|
|
816
|
-
if (v >= 0) pos += v
|
|
817
|
-
else neg += v
|
|
818
|
-
}
|
|
819
|
-
note(pos)
|
|
820
|
-
note(neg)
|
|
821
|
-
}
|
|
822
|
-
} else {
|
|
823
|
-
for (const s of stackable) for (const v of s.values) note(v)
|
|
824
|
-
}
|
|
825
|
-
for (const s of lines) for (const v of s.values) note(v)
|
|
826
|
-
if (dMin === Infinity) {
|
|
827
|
-
dMin = isLog ? 1 : 0
|
|
828
|
-
dMax = isLog ? 10 : 1
|
|
829
|
-
}
|
|
830
|
-
// Bar / area charts read against a zero baseline, so always include 0
|
|
831
|
-
// - but only on linear axes (0 is invalid in log).
|
|
832
|
-
if (stackable.length && !isLog) {
|
|
833
|
-
dMin = Math.min(dMin, 0)
|
|
834
|
-
dMax = Math.max(dMax, 0)
|
|
835
|
-
}
|
|
836
|
-
return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax)
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'): ChartGeometry {
|
|
840
|
-
const width = spec.width ?? 520
|
|
841
|
-
const height = spec.height ?? 300
|
|
842
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
843
|
-
|
|
844
|
-
const stacked = !!(spec.stacked || spec.stacked100)
|
|
845
|
-
const series: ResolvedSeries[] = spec.series.map((s, i) => ({
|
|
846
|
-
...s,
|
|
847
|
-
color: s.color ?? palette[i % palette.length]!,
|
|
848
|
-
kind: (s.type ?? (spec.type === 'pie' || spec.type === 'scatter' ? 'bar' : spec.type)) as
|
|
849
|
-
| 'bar'
|
|
850
|
-
| 'line'
|
|
851
|
-
| 'area',
|
|
852
|
-
axis: s.axis ?? 'left',
|
|
853
|
-
}))
|
|
854
|
-
const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
|
|
855
|
-
|
|
856
|
-
const empty: ChartGeometry = {
|
|
857
|
-
type: spec.type,
|
|
858
|
-
width,
|
|
859
|
-
height,
|
|
860
|
-
plot: { x: 0, y: 0, w: width, h: height },
|
|
861
|
-
bars: [],
|
|
862
|
-
lines: [],
|
|
863
|
-
slices: [],
|
|
864
|
-
yTicks: [],
|
|
865
|
-
y2Ticks: [],
|
|
866
|
-
hasRightAxis: false,
|
|
867
|
-
xTicks: [],
|
|
868
|
-
categoryGroupTicks: [],
|
|
869
|
-
xLabelRotated: false,
|
|
870
|
-
legend,
|
|
871
|
-
donut: null,
|
|
872
|
-
referenceLines: [],
|
|
873
|
-
scatterPoints: [],
|
|
874
|
-
orientation: 'vertical',
|
|
875
|
-
valueTicks: [],
|
|
876
|
-
catTicks: [],
|
|
877
|
-
referenceLinesV: [],
|
|
878
|
-
overlays: [],
|
|
879
|
-
annotations: [],
|
|
880
|
-
heatmapCells: [],
|
|
881
|
-
heatmapRowTicks: [],
|
|
882
|
-
heatmapColTicks: [],
|
|
883
|
-
heatmapLegend: [],
|
|
884
|
-
funnelSegments: [],
|
|
885
|
-
radarRings: [],
|
|
886
|
-
radarAxes: [],
|
|
887
|
-
radarSeries: [],
|
|
888
|
-
radarCenter: null,
|
|
889
|
-
treemapCells: [],
|
|
890
|
-
calendarCells: [],
|
|
891
|
-
calendarMonthTicks: [],
|
|
892
|
-
calendarLegend: [],
|
|
893
|
-
gauge: null,
|
|
894
|
-
sankeyNodes: [],
|
|
895
|
-
sankeyLinks: [],
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// ---- Waterfall ----------------------------------------------------
|
|
899
|
-
// First series provides the values. Each non-total bar starts at the
|
|
900
|
-
// running cumulative sum; total bars (waterfallTotals[i]) reset and
|
|
901
|
-
// span from 0 to that sum. Color is derived from sign + total flag, with
|
|
902
|
-
// optional palette overrides via spec.waterfallColors.
|
|
903
|
-
if (spec.type === 'waterfall') {
|
|
904
|
-
const src = series[0]
|
|
905
|
-
if (!src) return { ...empty }
|
|
906
|
-
const colors = spec.waterfallColors ?? {}
|
|
907
|
-
const positive = colors.positive ?? '#16a34a'
|
|
908
|
-
const negative = colors.negative ?? '#ef4444'
|
|
909
|
-
const total = colors.total ?? '#475569'
|
|
910
|
-
|
|
911
|
-
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
912
|
-
const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
|
|
913
|
-
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
914
|
-
const padR = 12
|
|
915
|
-
const padT = 10
|
|
916
|
-
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
|
|
917
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
918
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
919
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
920
|
-
|
|
921
|
-
// Compute the running cumulative + per-bar (from, to) pairs.
|
|
922
|
-
const totals = spec.waterfallTotals ?? []
|
|
923
|
-
const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
|
|
924
|
-
let cum = 0
|
|
925
|
-
spec.categories.forEach((_, i) => {
|
|
926
|
-
const v = src.values[i] ?? 0
|
|
927
|
-
const isTotal = !!totals[i]
|
|
928
|
-
if (isTotal) {
|
|
929
|
-
pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
|
|
930
|
-
} else {
|
|
931
|
-
pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
|
|
932
|
-
cum += v
|
|
933
|
-
}
|
|
934
|
-
})
|
|
935
|
-
// Y-axis domain spans every visited level (including 0).
|
|
936
|
-
let dMin = 0, dMax = 0
|
|
937
|
-
for (const p of pairs) {
|
|
938
|
-
if (p.from < dMin) dMin = p.from
|
|
939
|
-
if (p.to < dMin) dMin = p.to
|
|
940
|
-
if (p.from > dMax) dMax = p.from
|
|
941
|
-
if (p.to > dMax) dMax = p.to
|
|
942
|
-
}
|
|
943
|
-
const dom = niceScale(dMin, dMax)
|
|
944
|
-
const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
|
|
945
|
-
|
|
946
|
-
const slotW = plotW / Math.max(1, spec.categories.length)
|
|
947
|
-
const barPad = slotW * 0.2
|
|
948
|
-
const barW = Math.max(1, slotW - barPad)
|
|
949
|
-
const bars: ChartBar[] = pairs.map((p, i) => {
|
|
950
|
-
const yTop = yOfW(Math.max(p.from, p.to))
|
|
951
|
-
const yBot = yOfW(Math.min(p.from, p.to))
|
|
952
|
-
const x = padL + slotW * i + barPad / 2
|
|
953
|
-
const color = p.isTotal ? total : p.value >= 0 ? positive : negative
|
|
954
|
-
return {
|
|
955
|
-
x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
|
|
956
|
-
color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
|
|
957
|
-
}
|
|
958
|
-
})
|
|
959
|
-
// Thin connector lines between bar tops -> running total reads cleanly.
|
|
960
|
-
const connectors: ChartLine[] = [{
|
|
961
|
-
path: pairs
|
|
962
|
-
.map((p, i) => {
|
|
963
|
-
const x0 = padL + slotW * i + barPad / 2 + barW
|
|
964
|
-
const y = yOfW(p.to)
|
|
965
|
-
const x1 = padL + slotW * (i + 1) + barPad / 2
|
|
966
|
-
// Skip the final connector beyond the last bar.
|
|
967
|
-
return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
|
|
968
|
-
})
|
|
969
|
-
.filter(Boolean)
|
|
970
|
-
.join(' '),
|
|
971
|
-
areaPath: '',
|
|
972
|
-
color: 'var(--sg-muted, #94a3b8)',
|
|
973
|
-
label: '',
|
|
974
|
-
points: [],
|
|
975
|
-
}]
|
|
976
|
-
const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
|
|
977
|
-
label,
|
|
978
|
-
x: round(padL + slotW * i + slotW / 2),
|
|
979
|
-
}))
|
|
980
|
-
const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
|
|
981
|
-
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat),
|
|
982
|
-
}))
|
|
983
|
-
return {
|
|
984
|
-
...empty,
|
|
985
|
-
plot,
|
|
986
|
-
bars,
|
|
987
|
-
lines: connectors,
|
|
988
|
-
yTicks,
|
|
989
|
-
xTicks,
|
|
990
|
-
xLabelRotated,
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
// ---- Funnel -------------------------------------------------------
|
|
995
|
-
// One series of strictly-decreasing values gets rendered as a stack
|
|
996
|
-
// of horizontal trapezoids: each level's width is proportional to its
|
|
997
|
-
// value relative to the largest, slope automatically links level N+1
|
|
998
|
-
// narrower than level N. Labels show value, conversion vs. top, and
|
|
999
|
-
// step drop-off.
|
|
1000
|
-
if (spec.type === 'funnel') {
|
|
1001
|
-
const src = series[0]
|
|
1002
|
-
if (!src || !src.values.length) return { ...empty }
|
|
1003
|
-
const padL = 20, padR = 20, padT = 16, padB = 16
|
|
1004
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1005
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1006
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1007
|
-
const n = src.values.length
|
|
1008
|
-
const stepH = plotH / n
|
|
1009
|
-
const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
|
|
1010
|
-
const top = src.values[0] ?? 0
|
|
1011
|
-
const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
|
|
1012
|
-
const cx = padL + plotW / 2
|
|
1013
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1014
|
-
const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
|
|
1015
|
-
const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
|
|
1016
|
-
const w0 = widthAt(v)
|
|
1017
|
-
const w1 = widthAt(next)
|
|
1018
|
-
const y0 = padT + stepH * i
|
|
1019
|
-
const y1 = y0 + stepH
|
|
1020
|
-
const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
|
|
1021
|
-
const color = src.color ?? palette[i % palette.length]!
|
|
1022
|
-
return {
|
|
1023
|
-
path, color,
|
|
1024
|
-
label: spec.categories[i] ?? src.label,
|
|
1025
|
-
value: v,
|
|
1026
|
-
conversion: top > 0 ? v / top : 0,
|
|
1027
|
-
dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
|
|
1028
|
-
cx,
|
|
1029
|
-
cy: (y0 + y1) / 2,
|
|
1030
|
-
textColor: pickContrastText(color),
|
|
1031
|
-
}
|
|
1032
|
-
})
|
|
1033
|
-
return {
|
|
1034
|
-
...empty,
|
|
1035
|
-
plot,
|
|
1036
|
-
funnelSegments: segments,
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// ---- Radar --------------------------------------------------------
|
|
1041
|
-
// Polar coordinates: each `category` is a spoke (axis); each `series`
|
|
1042
|
-
// contributes a polygon connecting its values across the spokes. All
|
|
1043
|
-
// series share the same scale (max across every value). Concentric
|
|
1044
|
-
// ring count derived from data, capped at 5 for legibility.
|
|
1045
|
-
if (spec.type === 'radar') {
|
|
1046
|
-
if (!series.length || !spec.categories.length) return { ...empty }
|
|
1047
|
-
const padL = 30, padR = 30, padT = 24, padB = 24
|
|
1048
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1049
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1050
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1051
|
-
const cx = padL + plotW / 2
|
|
1052
|
-
const cy = padT + plotH / 2
|
|
1053
|
-
const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20)
|
|
1054
|
-
const axes = spec.categories
|
|
1055
|
-
const k = axes.length
|
|
1056
|
-
let vMax = 0
|
|
1057
|
-
for (const s of series) for (const v of s.values) {
|
|
1058
|
-
if (Number.isFinite(v) && v > vMax) vMax = v
|
|
1059
|
-
}
|
|
1060
|
-
if (vMax === 0) vMax = 1
|
|
1061
|
-
const ringCount = 5
|
|
1062
|
-
const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax)
|
|
1063
|
-
/** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
|
|
1064
|
-
* proceed clockwise so axes lay out left-to-right when k <= 4. */
|
|
1065
|
-
const angleAt = (i: number) => -Math.PI / 2 + (i / k) * Math.PI * 2
|
|
1066
|
-
const pointAt = (i: number, v: number) => {
|
|
1067
|
-
const t = v / vMax
|
|
1068
|
-
const a = angleAt(i)
|
|
1069
|
-
return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) }
|
|
1070
|
-
}
|
|
1071
|
-
const radarAxes: ChartRadarAxis[] = axes.map((label, i) => {
|
|
1072
|
-
const p = pointAt(i, vMax)
|
|
1073
|
-
return { label, x: p.x, y: p.y }
|
|
1074
|
-
})
|
|
1075
|
-
const radarSeriesGeo: ChartRadarSeries[] = series.map((s, si) => {
|
|
1076
|
-
const pts = s.values.map((v, i) => {
|
|
1077
|
-
const safe = Number.isFinite(v) ? v : 0
|
|
1078
|
-
const p = pointAt(i, safe)
|
|
1079
|
-
return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' }
|
|
1080
|
-
})
|
|
1081
|
-
const path = pts.length
|
|
1082
|
-
? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
|
|
1083
|
-
: ''
|
|
1084
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1085
|
-
return { label: s.label, color: s.color ?? palette[si % palette.length]!, path, points: pts }
|
|
1086
|
-
})
|
|
1087
|
-
return {
|
|
1088
|
-
...empty,
|
|
1089
|
-
plot,
|
|
1090
|
-
radarRings: ringValues,
|
|
1091
|
-
radarAxes,
|
|
1092
|
-
radarSeries: radarSeriesGeo,
|
|
1093
|
-
radarCenter: { cx, cy, r },
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
// ---- Calendar heatmap --------------------------------------------
|
|
1098
|
-
// GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
|
|
1099
|
-
// cell is a small square shaded by `calendarValues[i].value` via the
|
|
1100
|
-
// sequential color scale. Days with no value render blank (border only)
|
|
1101
|
-
// so missing data is visually obvious.
|
|
1102
|
-
if (spec.type === 'calendar') {
|
|
1103
|
-
const values = spec.calendarValues ?? []
|
|
1104
|
-
if (!values.length && !spec.calendarStart) return { ...empty }
|
|
1105
|
-
// Build a value lookup + figure out the date range.
|
|
1106
|
-
const valueByDate = new Map<string, number>()
|
|
1107
|
-
let vMin = Infinity, vMax = -Infinity
|
|
1108
|
-
for (const v of values) {
|
|
1109
|
-
valueByDate.set(v.date, v.value)
|
|
1110
|
-
if (Number.isFinite(v.value)) {
|
|
1111
|
-
if (v.value < vMin) vMin = v.value
|
|
1112
|
-
if (v.value > vMax) vMax = v.value
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
if (vMin === Infinity) { vMin = 0; vMax = 1 }
|
|
1116
|
-
if (vMin === vMax) vMax = vMin + 1
|
|
1117
|
-
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
|
|
1118
|
-
const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
|
|
1119
|
-
// Determine date range. If calendarStart/End set, use them, otherwise
|
|
1120
|
-
// span the data + round to whole weeks (Sun..Sat).
|
|
1121
|
-
const sorted = values.map((v) => v.date).sort()
|
|
1122
|
-
const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
|
|
1123
|
-
const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
|
|
1124
|
-
const start = new Date(startStr + 'T00:00:00Z')
|
|
1125
|
-
const end = new Date(endStr + 'T00:00:00Z')
|
|
1126
|
-
// Roll start back to its Sunday, end forward to its Saturday.
|
|
1127
|
-
start.setUTCDate(start.getUTCDate() - start.getUTCDay())
|
|
1128
|
-
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
|
|
1129
|
-
const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
|
|
1130
|
-
const weeks = Math.ceil(totalDays / 7)
|
|
1131
|
-
const padL = 36, padR = 80, padT = 26, padB = 16
|
|
1132
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1133
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1134
|
-
// Cell size: fit weeks across width, 7 rows down height.
|
|
1135
|
-
const cellW = Math.floor(plotW / weeks)
|
|
1136
|
-
const cellH = Math.floor(plotH / 7)
|
|
1137
|
-
const cellSize = Math.max(6, Math.min(cellW, cellH))
|
|
1138
|
-
const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
|
|
1139
|
-
const cells: ChartCalendarCell[] = []
|
|
1140
|
-
let lastMonth = -1
|
|
1141
|
-
const monthTicks: ChartCategoryTick[] = []
|
|
1142
|
-
for (let i = 0; i < totalDays; i += 1) {
|
|
1143
|
-
const day = new Date(start.getTime() + i * 86_400_000)
|
|
1144
|
-
const col = Math.floor(i / 7)
|
|
1145
|
-
const row = i % 7
|
|
1146
|
-
const date = day.toISOString().slice(0, 10)
|
|
1147
|
-
const has = valueByDate.has(date)
|
|
1148
|
-
const v = valueByDate.get(date) ?? 0
|
|
1149
|
-
cells.push({
|
|
1150
|
-
x: padL + col * cellSize,
|
|
1151
|
-
y: padT + row * cellSize,
|
|
1152
|
-
size: cellSize,
|
|
1153
|
-
date, value: v,
|
|
1154
|
-
defined: has,
|
|
1155
|
-
color: has ? colorAt(v) : 'transparent',
|
|
1156
|
-
})
|
|
1157
|
-
if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
|
|
1158
|
-
lastMonth = day.getUTCMonth()
|
|
1159
|
-
monthTicks.push({
|
|
1160
|
-
label: day.toLocaleDateString(undefined, { month: 'short' }),
|
|
1161
|
-
x: padL + col * cellSize,
|
|
1162
|
-
})
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
const legend = Array.from({ length: 5 }, (_, i) => {
|
|
1166
|
-
const t = i / 4
|
|
1167
|
-
const value = vMin + (vMax - vMin) * t
|
|
1168
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
|
|
1169
|
-
})
|
|
1170
|
-
return {
|
|
1171
|
-
...empty,
|
|
1172
|
-
plot,
|
|
1173
|
-
calendarCells: cells,
|
|
1174
|
-
calendarMonthTicks: monthTicks,
|
|
1175
|
-
calendarLegend: legend,
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
// ---- Gauge --------------------------------------------------------
|
|
1180
|
-
// Semicircle dial: track arc + value arc + optional colored range bands
|
|
1181
|
-
// + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
|
|
1182
|
-
if (spec.type === 'gauge') {
|
|
1183
|
-
const min = spec.gaugeMin ?? 0
|
|
1184
|
-
const max = spec.gaugeMax ?? 100
|
|
1185
|
-
const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0))
|
|
1186
|
-
const target = spec.gaugeTarget
|
|
1187
|
-
const cx = width / 2
|
|
1188
|
-
const cy = height * 0.78
|
|
1189
|
-
const r = Math.min(width * 0.42, height * 0.65)
|
|
1190
|
-
// Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
|
|
1191
|
-
const A0 = Math.PI
|
|
1192
|
-
const A1 = 2 * Math.PI
|
|
1193
|
-
const angleAt = (v: number) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0)
|
|
1194
|
-
const arc = (a0: number, a1: number, radius: number): string => {
|
|
1195
|
-
const x1 = cx + radius * Math.cos(a0)
|
|
1196
|
-
const y1 = cy + radius * Math.sin(a0)
|
|
1197
|
-
const x2 = cx + radius * Math.cos(a1)
|
|
1198
|
-
const y2 = cy + radius * Math.sin(a1)
|
|
1199
|
-
const large = a1 - a0 > Math.PI ? 1 : 0
|
|
1200
|
-
return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`
|
|
1201
|
-
}
|
|
1202
|
-
const trackPath = arc(A0, A1, r)
|
|
1203
|
-
const valuePath = arc(A0, angleAt(value), r)
|
|
1204
|
-
const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
|
|
1205
|
-
path: arc(angleAt(band.from), angleAt(band.to), r - 9),
|
|
1206
|
-
color: band.color, from: band.from, to: band.to,
|
|
1207
|
-
}))
|
|
1208
|
-
let targetPx: ChartGaugeLayout['target'] = null
|
|
1209
|
-
if (target != null && Number.isFinite(target)) {
|
|
1210
|
-
const a = angleAt(Math.max(min, Math.min(max, target)))
|
|
1211
|
-
const inner = r - 12
|
|
1212
|
-
const outer = r + 4
|
|
1213
|
-
targetPx = {
|
|
1214
|
-
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
1215
|
-
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
// Tick marks just outside the track: a major tick every 1/4 of the scale,
|
|
1219
|
-
// with 4 minor ticks between each. Gives the dial a measured, instrument feel.
|
|
1220
|
-
const ticks: ChartGaugeLayout['ticks'] = []
|
|
1221
|
-
const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR
|
|
1222
|
-
for (let i = 0; i <= TICK_TOTAL; i++) {
|
|
1223
|
-
const a = A0 + (i / TICK_TOTAL) * (A1 - A0)
|
|
1224
|
-
const major = i % TICK_MINOR === 0
|
|
1225
|
-
const inner = r + 9
|
|
1226
|
-
const outer = r + (major ? 17 : 13)
|
|
1227
|
-
ticks.push({
|
|
1228
|
-
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
1229
|
-
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
1230
|
-
major,
|
|
1231
|
-
})
|
|
1232
|
-
}
|
|
1233
|
-
// Pointer needle: a kite (long tip toward the value, short counterweight
|
|
1234
|
-
// tail) pivoting on a center hub.
|
|
1235
|
-
const aV = angleAt(value)
|
|
1236
|
-
const tipR = r - 16, tailR = 18, baseR = 6
|
|
1237
|
-
const aPerp = aV + Math.PI / 2
|
|
1238
|
-
const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
|
|
1239
|
-
const needlePath =
|
|
1240
|
-
`M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
|
|
1241
|
-
// Color the value arc by the band the value currently sits in.
|
|
1242
|
-
let valueColor: string | null = null
|
|
1243
|
-
for (const band of spec.gaugeRanges ?? []) {
|
|
1244
|
-
if (value >= band.from && value <= band.to) valueColor = band.color
|
|
1245
|
-
}
|
|
1246
|
-
return {
|
|
1247
|
-
...empty,
|
|
1248
|
-
plot: { x: 0, y: 0, w: width, h: height },
|
|
1249
|
-
gauge: {
|
|
1250
|
-
cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
|
|
1251
|
-
ticks, needle: { path: needlePath, hubR: 7 }, valueColor,
|
|
1252
|
-
minLabel: { x: cx - r, y: cy + 20 },
|
|
1253
|
-
maxLabel: { x: cx + r, y: cy + 20 },
|
|
1254
|
-
value, min, max, unit: spec.gaugeUnit ?? '',
|
|
1255
|
-
},
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// ---- Tree-map -----------------------------------------------------
|
|
1260
|
-
// Squarified tree-map (Bruls et al. 2000): each level recursively
|
|
1261
|
-
// partitions its rectangle in proportion to its children, picking the
|
|
1262
|
-
// split orientation that keeps aspect ratios closest to 1.
|
|
1263
|
-
if (spec.type === 'treemap') {
|
|
1264
|
-
const root = spec.treemap
|
|
1265
|
-
if (!root) return { ...empty }
|
|
1266
|
-
const padL = 4, padR = 4, padT = 4, padB = 4
|
|
1267
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1268
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1269
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1270
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1271
|
-
const cells: ChartTreemapCell[] = []
|
|
1272
|
-
function totalOf(n: TreeNode): number {
|
|
1273
|
-
if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
|
|
1274
|
-
return Math.max(0, n.value ?? 0)
|
|
1275
|
-
}
|
|
1276
|
-
function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
|
|
1277
|
-
if (!items.length || w <= 0 || h <= 0) return
|
|
1278
|
-
const totals = items.map(totalOf)
|
|
1279
|
-
const sum = totals.reduce((a, b) => a + b, 0)
|
|
1280
|
-
if (sum <= 0) return
|
|
1281
|
-
// Process largest-first so big items dominate the first row.
|
|
1282
|
-
const ordered = items
|
|
1283
|
-
.map((n, i) => ({ node: n, value: totals[i]! }))
|
|
1284
|
-
.sort((a, b) => b.value - a.value)
|
|
1285
|
-
let cx = x, cy = y, cw = w, ch = h, remaining = sum
|
|
1286
|
-
let row: typeof ordered = []
|
|
1287
|
-
const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
|
|
1288
|
-
if (rowSum <= 0) return Infinity
|
|
1289
|
-
const rowArea = rowSum * scale
|
|
1290
|
-
const rowSide = rowArea / shortSide
|
|
1291
|
-
let worst = 0
|
|
1292
|
-
for (const v of vals) {
|
|
1293
|
-
const cell = v * scale
|
|
1294
|
-
const long = cell / rowSide
|
|
1295
|
-
const r = Math.max(shortSide / long, long / shortSide)
|
|
1296
|
-
if (r > worst) worst = r
|
|
1297
|
-
}
|
|
1298
|
-
return worst
|
|
1299
|
-
}
|
|
1300
|
-
function flushRow() {
|
|
1301
|
-
if (!row.length) return
|
|
1302
|
-
const rowSum = row.reduce((a, b) => a + b.value, 0)
|
|
1303
|
-
const scale = (cw * ch) / remaining
|
|
1304
|
-
const horizontal = cw >= ch
|
|
1305
|
-
const shortSide = horizontal ? ch : cw
|
|
1306
|
-
const rowSide = (rowSum * scale) / shortSide
|
|
1307
|
-
let offset = 0
|
|
1308
|
-
for (const it of row) {
|
|
1309
|
-
const cellSize = (it.value * scale) / rowSide
|
|
1310
|
-
const cx2 = horizontal ? cx : cx + offset
|
|
1311
|
-
const cy2 = horizontal ? cy + offset : cy
|
|
1312
|
-
const ww = horizontal ? rowSide : cellSize
|
|
1313
|
-
const hh = horizontal ? cellSize : rowSide
|
|
1314
|
-
const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
|
|
1315
|
-
// Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
|
|
1316
|
-
if (it.node.children?.length) {
|
|
1317
|
-
cells.push({
|
|
1318
|
-
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
1319
|
-
color, textColor: pickContrastText(color),
|
|
1320
|
-
name: it.node.name, value: it.value, depth,
|
|
1321
|
-
})
|
|
1322
|
-
const labelH = Math.min(18, hh * 0.25)
|
|
1323
|
-
squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
|
|
1324
|
-
} else {
|
|
1325
|
-
cells.push({
|
|
1326
|
-
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
1327
|
-
color, textColor: pickContrastText(color),
|
|
1328
|
-
name: it.node.name, value: it.value, depth,
|
|
1329
|
-
})
|
|
1330
|
-
}
|
|
1331
|
-
offset += cellSize
|
|
1332
|
-
}
|
|
1333
|
-
// Shrink the remaining strip.
|
|
1334
|
-
if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
|
|
1335
|
-
remaining -= rowSum
|
|
1336
|
-
row = []
|
|
1337
|
-
}
|
|
1338
|
-
for (const it of ordered) {
|
|
1339
|
-
const scale = (cw * ch) / remaining
|
|
1340
|
-
const shortSide = Math.min(cw, ch)
|
|
1341
|
-
const rowSum = row.reduce((a, b) => a + b.value, 0)
|
|
1342
|
-
const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
|
|
1343
|
-
const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
|
|
1344
|
-
if (row.length && nextWorst > currWorst) {
|
|
1345
|
-
flushRow()
|
|
1346
|
-
}
|
|
1347
|
-
row.push(it)
|
|
1348
|
-
}
|
|
1349
|
-
flushRow()
|
|
1350
|
-
}
|
|
1351
|
-
const seedItems = root.children ?? [root]
|
|
1352
|
-
squarify(seedItems, padL, padT, plotW, plotH, 0)
|
|
1353
|
-
return { ...empty, plot, treemapCells: cells }
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
// ---- Sankey -------------------------------------------------------
|
|
1357
|
-
// Multi-column flow layout. Each node assigned to a column by longest
|
|
1358
|
-
// path from any source. Within a column, nodes are stacked vertically;
|
|
1359
|
-
// height proportional to max(totalIn, totalOut). Links render as
|
|
1360
|
-
// bezier ribbons whose width is the link value (in pixels).
|
|
1361
|
-
if (spec.type === 'sankey') {
|
|
1362
|
-
const nodes = spec.sankeyNodes ?? []
|
|
1363
|
-
const links = spec.sankeyLinks ?? []
|
|
1364
|
-
if (!nodes.length || !links.length) return { ...empty }
|
|
1365
|
-
const padL = 10, padR = 10, padT = 14, padB = 14
|
|
1366
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1367
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1368
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1369
|
-
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1370
|
-
const nodeById = new Map(nodes.map((n) => [n.id, n]))
|
|
1371
|
-
// Column = longest path from any node with no incoming edges.
|
|
1372
|
-
const targets = new Set(links.map((l) => l.target))
|
|
1373
|
-
const sources = nodes.filter((n) => !targets.has(n.id))
|
|
1374
|
-
const column = new Map<string, number>()
|
|
1375
|
-
function visit(id: string, depth: number, seen: Set<string>) {
|
|
1376
|
-
if (seen.has(id)) return
|
|
1377
|
-
seen.add(id)
|
|
1378
|
-
const cur = column.get(id) ?? 0
|
|
1379
|
-
if (depth > cur || !column.has(id)) column.set(id, depth)
|
|
1380
|
-
for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
|
|
1381
|
-
seen.delete(id)
|
|
1382
|
-
}
|
|
1383
|
-
for (const s of sources) visit(s.id, 0, new Set())
|
|
1384
|
-
// Cover any nodes with no path from a source (orphan rings).
|
|
1385
|
-
for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
|
|
1386
|
-
const maxCol = Math.max(...column.values())
|
|
1387
|
-
const cols = maxCol + 1
|
|
1388
|
-
const nodeW = 14
|
|
1389
|
-
const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
|
|
1390
|
-
// Totals per node.
|
|
1391
|
-
const totalIn = new Map<string, number>()
|
|
1392
|
-
const totalOut = new Map<string, number>()
|
|
1393
|
-
for (const l of links) {
|
|
1394
|
-
totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
|
|
1395
|
-
totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
|
|
1396
|
-
}
|
|
1397
|
-
// Per-column groups + max total in that column.
|
|
1398
|
-
const byCol: Map<number, string[]> = new Map()
|
|
1399
|
-
for (const n of nodes) {
|
|
1400
|
-
const c = column.get(n.id) ?? 0
|
|
1401
|
-
const arr = byCol.get(c) ?? []
|
|
1402
|
-
arr.push(n.id); byCol.set(c, arr)
|
|
1403
|
-
}
|
|
1404
|
-
// Per-column total height + node height scale.
|
|
1405
|
-
let maxColTotal = 0
|
|
1406
|
-
for (const ids of byCol.values()) {
|
|
1407
|
-
const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
|
|
1408
|
-
if (t > maxColTotal) maxColTotal = t
|
|
1409
|
-
}
|
|
1410
|
-
if (maxColTotal === 0) return { ...empty, plot }
|
|
1411
|
-
const nodeGapPx = 8
|
|
1412
|
-
const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
|
|
1413
|
-
const placed: ChartSankeyNode[] = []
|
|
1414
|
-
for (const [c, ids] of byCol) {
|
|
1415
|
-
const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
|
|
1416
|
-
const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
|
|
1417
|
-
let yCursor = padT + (plotH - totalH) / 2
|
|
1418
|
-
const xCol = padL + c * (nodeW + gapBetweenColumns)
|
|
1419
|
-
ids.forEach((id, idx) => {
|
|
1420
|
-
const node = nodeById.get(id)!
|
|
1421
|
-
const h = heights[idx]!
|
|
1422
|
-
placed.push({
|
|
1423
|
-
id,
|
|
1424
|
-
label: node.label ?? id,
|
|
1425
|
-
color: node.color ?? palette[(placed.length) % palette.length]!,
|
|
1426
|
-
x: xCol, y: yCursor, w: nodeW, h,
|
|
1427
|
-
column: c,
|
|
1428
|
-
totalIn: totalIn.get(id) ?? 0,
|
|
1429
|
-
totalOut: totalOut.get(id) ?? 0,
|
|
1430
|
-
})
|
|
1431
|
-
yCursor += h + nodeGapPx
|
|
1432
|
-
})
|
|
1433
|
-
}
|
|
1434
|
-
const placedById = new Map(placed.map((n) => [n.id, n]))
|
|
1435
|
-
// Per-node sub-cursor so multiple links from one node stack vertically.
|
|
1436
|
-
const inCursor = new Map<string, number>()
|
|
1437
|
-
const outCursor = new Map<string, number>()
|
|
1438
|
-
const builtLinks: ChartSankeyLink[] = []
|
|
1439
|
-
// Sort links so wider ribbons render first (so thin ribbons stack on top).
|
|
1440
|
-
const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
|
|
1441
|
-
for (const link of sortedLinks) {
|
|
1442
|
-
const a = placedById.get(link.source)
|
|
1443
|
-
const b = placedById.get(link.target)
|
|
1444
|
-
if (!a || !b) continue
|
|
1445
|
-
const linkH = Math.max(1, link.value * heightScale)
|
|
1446
|
-
const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
|
|
1447
|
-
const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
|
|
1448
|
-
outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
|
|
1449
|
-
inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
|
|
1450
|
-
const x0 = a.x + a.w
|
|
1451
|
-
const x1 = b.x
|
|
1452
|
-
const mid = (x0 + x1) / 2
|
|
1453
|
-
const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
|
|
1454
|
-
builtLinks.push({
|
|
1455
|
-
path, color: link.color ?? a.color, width: linkH,
|
|
1456
|
-
source: link.source, target: link.target, value: link.value,
|
|
1457
|
-
})
|
|
1458
|
-
}
|
|
1459
|
-
return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
// ---- Heatmap ------------------------------------------------------
|
|
1463
|
-
// Each series is one row, series.values are the cells across categories.
|
|
1464
|
-
// Color comes from a sequential/diverging/custom palette mapped to the
|
|
1465
|
-
// global value range. Cell text contrasts black/white against the cell.
|
|
1466
|
-
if (spec.type === 'heatmap') {
|
|
1467
|
-
if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
|
|
1468
|
-
// Layout: left gutter for row labels, bottom for column labels.
|
|
1469
|
-
const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
|
|
1470
|
-
const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
|
|
1471
|
-
const padR = 64 // room for the right-side legend bar
|
|
1472
|
-
const padT = 12
|
|
1473
|
-
const padB = 32
|
|
1474
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1475
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1476
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1477
|
-
const cellW = plotW / spec.categories.length
|
|
1478
|
-
const cellH = plotH / series.length
|
|
1479
|
-
// Resolve value range across the whole matrix.
|
|
1480
|
-
let vMin = Infinity, vMax = -Infinity
|
|
1481
|
-
for (const s of series) for (const v of s.values) {
|
|
1482
|
-
if (!Number.isFinite(v)) continue
|
|
1483
|
-
if (v < vMin) vMin = v
|
|
1484
|
-
if (v > vMax) vMax = v
|
|
1485
|
-
}
|
|
1486
|
-
if (vMin === Infinity) { vMin = 0; vMax = 1 }
|
|
1487
|
-
if (vMin === vMax) vMax = vMin + 1
|
|
1488
|
-
// Pick the palette stops.
|
|
1489
|
-
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
|
|
1490
|
-
const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
|
|
1491
|
-
const heatmapCells: ChartHeatmapCell[] = []
|
|
1492
|
-
series.forEach((s, ri) => {
|
|
1493
|
-
s.values.forEach((v, ci) => {
|
|
1494
|
-
if (!Number.isFinite(v)) return
|
|
1495
|
-
const color = colorAt(v)
|
|
1496
|
-
heatmapCells.push({
|
|
1497
|
-
x: round(padL + cellW * ci),
|
|
1498
|
-
y: round(padT + cellH * ri),
|
|
1499
|
-
w: round(cellW),
|
|
1500
|
-
h: round(cellH),
|
|
1501
|
-
color,
|
|
1502
|
-
textColor: pickContrastText(color),
|
|
1503
|
-
value: v,
|
|
1504
|
-
rowLabel: s.label,
|
|
1505
|
-
colLabel: spec.categories[ci] ?? '',
|
|
1506
|
-
})
|
|
1507
|
-
})
|
|
1508
|
-
})
|
|
1509
|
-
const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
|
|
1510
|
-
value: i,
|
|
1511
|
-
y: round(padT + cellH * i + cellH / 2),
|
|
1512
|
-
label: s.label,
|
|
1513
|
-
}))
|
|
1514
|
-
const heatmapColTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
|
|
1515
|
-
label,
|
|
1516
|
-
x: round(padL + cellW * i + cellW / 2),
|
|
1517
|
-
}))
|
|
1518
|
-
// Legend: sample 5 stops across the range.
|
|
1519
|
-
const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
|
|
1520
|
-
const t = i / 4
|
|
1521
|
-
const value = vMin + (vMax - vMin) * t
|
|
1522
|
-
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
|
|
1523
|
-
})
|
|
1524
|
-
return {
|
|
1525
|
-
...empty,
|
|
1526
|
-
plot,
|
|
1527
|
-
heatmapCells,
|
|
1528
|
-
heatmapRowTicks,
|
|
1529
|
-
heatmapColTicks,
|
|
1530
|
-
heatmapLegend,
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
if (spec.type === 'pie') {
|
|
1535
|
-
const s = series[0]
|
|
1536
|
-
if (!s) return empty
|
|
1537
|
-
const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
|
|
1538
|
-
const cx = width / 2
|
|
1539
|
-
const cy = height / 2
|
|
1540
|
-
const r = Math.min(width, height) / 2 - 10
|
|
1541
|
-
const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
|
|
1542
|
-
const ir = r * innerFrac
|
|
1543
|
-
let angle = -Math.PI / 2
|
|
1544
|
-
const slices: ChartPieSlice[] = s.values.map((v, i) => {
|
|
1545
|
-
const frac = Math.max(0, v) / total
|
|
1546
|
-
const a0 = angle
|
|
1547
|
-
const a1 = angle + frac * Math.PI * 2
|
|
1548
|
-
angle = a1
|
|
1549
|
-
const large = a1 - a0 > Math.PI ? 1 : 0
|
|
1550
|
-
const mid = (a0 + a1) / 2
|
|
1551
|
-
const labelR = (r + ir) / 2 || r * 0.6
|
|
1552
|
-
const ox0 = cx + r * Math.cos(a0)
|
|
1553
|
-
const oy0 = cy + r * Math.sin(a0)
|
|
1554
|
-
const ox1 = cx + r * Math.cos(a1)
|
|
1555
|
-
const oy1 = cy + r * Math.sin(a1)
|
|
1556
|
-
let path: string
|
|
1557
|
-
if (frac >= 0.999) {
|
|
1558
|
-
path = ir
|
|
1559
|
-
? `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` +
|
|
1560
|
-
`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`
|
|
1561
|
-
: `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`
|
|
1562
|
-
} else if (ir > 0) {
|
|
1563
|
-
const ix0 = cx + ir * Math.cos(a0)
|
|
1564
|
-
const iy0 = cy + ir * Math.sin(a0)
|
|
1565
|
-
const ix1 = cx + ir * Math.cos(a1)
|
|
1566
|
-
const iy1 = cy + ir * Math.sin(a1)
|
|
1567
|
-
path =
|
|
1568
|
-
`M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
|
|
1569
|
-
`L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
|
|
1570
|
-
} else {
|
|
1571
|
-
path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
|
|
1572
|
-
}
|
|
1573
|
-
const catLabel = spec.categories[i] ?? String(i)
|
|
1574
|
-
return {
|
|
1575
|
-
path,
|
|
1576
|
-
color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length]!,
|
|
1577
|
-
label: catLabel,
|
|
1578
|
-
value: v,
|
|
1579
|
-
percent: frac * 100,
|
|
1580
|
-
cx: round(cx + labelR * Math.cos(mid)),
|
|
1581
|
-
cy: round(cy + labelR * Math.sin(mid)),
|
|
1582
|
-
}
|
|
1583
|
-
})
|
|
1584
|
-
return {
|
|
1585
|
-
...empty,
|
|
1586
|
-
slices,
|
|
1587
|
-
legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! })),
|
|
1588
|
-
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,
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
if (spec.type === 'scatter') {
|
|
1593
|
-
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
1594
|
-
const padR = 12
|
|
1595
|
-
const padT = 10
|
|
1596
|
-
const padB = 28 + (spec.xAxisTitle ? 16 : 0)
|
|
1597
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1598
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1599
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1600
|
-
|
|
1601
|
-
let xMin = Infinity
|
|
1602
|
-
let xMax = -Infinity
|
|
1603
|
-
let yMin = Infinity
|
|
1604
|
-
let yMax = -Infinity
|
|
1605
|
-
let rMin = Infinity
|
|
1606
|
-
let rMax = -Infinity
|
|
1607
|
-
for (const s of series) {
|
|
1608
|
-
for (const pt of s.points ?? []) {
|
|
1609
|
-
if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
|
|
1610
|
-
if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
|
|
1611
|
-
if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
1614
|
-
if (xMin === Infinity) return { ...empty, plot }
|
|
1615
|
-
const xDom = niceScale(xMin, xMax)
|
|
1616
|
-
const yDom = niceScale(yMin, yMax)
|
|
1617
|
-
const hasR = rMax > rMin
|
|
1618
|
-
const xOf = (v: number) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW)
|
|
1619
|
-
const yOf = (v: number) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH)
|
|
1620
|
-
const rOf = (r?: number) =>
|
|
1621
|
-
hasR && r != null && Number.isFinite(r)
|
|
1622
|
-
? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
|
|
1623
|
-
: 5
|
|
1624
|
-
|
|
1625
|
-
const scatterPoints: ChartScatterDot[] = []
|
|
1626
|
-
for (const s of series) {
|
|
1627
|
-
for (const pt of s.points ?? []) {
|
|
1628
|
-
if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue
|
|
1629
|
-
scatterPoints.push({
|
|
1630
|
-
cx: xOf(pt.x),
|
|
1631
|
-
cy: yOf(pt.y),
|
|
1632
|
-
r: rOf(pt.r),
|
|
1633
|
-
color: s.color,
|
|
1634
|
-
label: pt.label ?? '',
|
|
1635
|
-
series: s.label,
|
|
1636
|
-
x: pt.x,
|
|
1637
|
-
y: pt.y,
|
|
1638
|
-
})
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
1642
|
-
y: yOf(ref.value),
|
|
1643
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
1644
|
-
color: ref.color ?? '#ef4444',
|
|
1645
|
-
dashed: ref.dashed !== false,
|
|
1646
|
-
}))
|
|
1647
|
-
return {
|
|
1648
|
-
...empty,
|
|
1649
|
-
plot,
|
|
1650
|
-
scatterPoints,
|
|
1651
|
-
referenceLines,
|
|
1652
|
-
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat) })),
|
|
1653
|
-
xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
|
|
1654
|
-
}
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
// ---- Horizontal bars ----------------------------------------------------
|
|
1658
|
-
// Categories run down the left, bars grow rightward. Bars-only (no combo).
|
|
1659
|
-
const horizontal =
|
|
1660
|
-
spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar')
|
|
1661
|
-
if (horizontal) {
|
|
1662
|
-
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
1663
|
-
const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0)
|
|
1664
|
-
const padR = 16
|
|
1665
|
-
const padT = 8
|
|
1666
|
-
const padB = 26 + (spec.xAxisTitle ? 16 : 0)
|
|
1667
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1668
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1669
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1670
|
-
|
|
1671
|
-
const refs = (spec.referenceLines ?? []).map((r) => r.value)
|
|
1672
|
-
const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs)
|
|
1673
|
-
const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
|
|
1674
|
-
|
|
1675
|
-
const n = spec.categories.length
|
|
1676
|
-
const slot = plotH / Math.max(1, n)
|
|
1677
|
-
const groupPad = slot * 0.2
|
|
1678
|
-
const inner = slot - groupPad
|
|
1679
|
-
const bandTop = (i: number) => padT + slot * i + groupPad / 2
|
|
1680
|
-
const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max))
|
|
1681
|
-
|
|
1682
|
-
const bars: ChartBar[] = []
|
|
1683
|
-
if (stacked) {
|
|
1684
|
-
const totals = spec.stacked100
|
|
1685
|
-
? spec.categories.map(
|
|
1686
|
-
(_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
|
|
1687
|
-
)
|
|
1688
|
-
: null
|
|
1689
|
-
const pos = new Array(n).fill(0)
|
|
1690
|
-
const neg = new Array(n).fill(0)
|
|
1691
|
-
for (const s of series) {
|
|
1692
|
-
s.values.forEach((v, i) => {
|
|
1693
|
-
if (!Number.isFinite(v)) return
|
|
1694
|
-
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
1695
|
-
let xL: number
|
|
1696
|
-
let xR: number
|
|
1697
|
-
if (vp >= 0) {
|
|
1698
|
-
xL = xOf(pos[i])
|
|
1699
|
-
xR = xOf(pos[i] + vp)
|
|
1700
|
-
pos[i] += vp
|
|
1701
|
-
} else {
|
|
1702
|
-
xL = xOf(neg[i] + vp)
|
|
1703
|
-
xR = xOf(neg[i])
|
|
1704
|
-
neg[i] += vp
|
|
1705
|
-
}
|
|
1706
|
-
bars.push({
|
|
1707
|
-
x: Math.min(xL, xR),
|
|
1708
|
-
y: round(bandTop(i)),
|
|
1709
|
-
w: round(Math.abs(xR - xL)),
|
|
1710
|
-
h: round(Math.max(1, inner)),
|
|
1711
|
-
color: s.color,
|
|
1712
|
-
label: spec.categories[i] ?? String(i),
|
|
1713
|
-
series: s.label,
|
|
1714
|
-
value: v,
|
|
1715
|
-
})
|
|
1716
|
-
})
|
|
1717
|
-
}
|
|
1718
|
-
} else {
|
|
1719
|
-
const barH = inner / series.length
|
|
1720
|
-
series.forEach((s, bi) => {
|
|
1721
|
-
s.values.forEach((v, i) => {
|
|
1722
|
-
if (!Number.isFinite(v)) return
|
|
1723
|
-
const xV = xOf(v)
|
|
1724
|
-
bars.push({
|
|
1725
|
-
x: Math.min(xV, xBase),
|
|
1726
|
-
y: round(bandTop(i) + barH * bi),
|
|
1727
|
-
w: round(Math.max(1, Math.abs(xV - xBase))),
|
|
1728
|
-
h: round(Math.max(1, barH - 1)),
|
|
1729
|
-
color: s.color,
|
|
1730
|
-
label: spec.categories[i] ?? String(i),
|
|
1731
|
-
series: s.label,
|
|
1732
|
-
value: v,
|
|
1733
|
-
})
|
|
1734
|
-
})
|
|
1735
|
-
})
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
|
|
1739
|
-
label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat),
|
|
1740
|
-
x: xOf(value),
|
|
1741
|
-
}))
|
|
1742
|
-
const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
|
|
1743
|
-
value: i,
|
|
1744
|
-
y: round(bandTop(i) + inner / 2),
|
|
1745
|
-
label,
|
|
1746
|
-
}))
|
|
1747
|
-
const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
1748
|
-
x: xOf(ref.value),
|
|
1749
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
1750
|
-
color: ref.color ?? '#ef4444',
|
|
1751
|
-
dashed: ref.dashed !== false,
|
|
1752
|
-
}))
|
|
1753
|
-
|
|
1754
|
-
return {
|
|
1755
|
-
...empty,
|
|
1756
|
-
plot,
|
|
1757
|
-
bars,
|
|
1758
|
-
orientation: 'horizontal',
|
|
1759
|
-
valueTicks,
|
|
1760
|
-
catTicks,
|
|
1761
|
-
referenceLinesV,
|
|
1762
|
-
xLabelRotated: false,
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
// ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
|
|
1767
|
-
const leftSeries = series.filter((s) => s.axis === 'left')
|
|
1768
|
-
const rightSeries = series.filter((s) => s.axis === 'right')
|
|
1769
|
-
const hasRightAxis = rightSeries.length > 0
|
|
1770
|
-
|
|
1771
|
-
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
1772
|
-
const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
|
|
1773
|
-
// Grouped (nested) category axis: valid only when the spans cover every leaf.
|
|
1774
|
-
const validGroups =
|
|
1775
|
-
spec.categoryGroups &&
|
|
1776
|
-
spec.categoryGroups.length > 0 &&
|
|
1777
|
-
spec.xType !== 'time' &&
|
|
1778
|
-
spec.orientation !== 'horizontal' &&
|
|
1779
|
-
spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
|
|
1780
|
-
? spec.categoryGroups
|
|
1781
|
-
: null
|
|
1782
|
-
const groupTierH = validGroups ? 18 : 0
|
|
1783
|
-
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
1784
|
-
const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
|
|
1785
|
-
const padT = 10
|
|
1786
|
-
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH
|
|
1787
|
-
const plotW = Math.max(1, width - padL - padR)
|
|
1788
|
-
const plotH = Math.max(1, height - padT - padB)
|
|
1789
|
-
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1790
|
-
|
|
1791
|
-
const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
|
|
1792
|
-
const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
|
|
1793
|
-
const leftLog = spec.yScale === 'log'
|
|
1794
|
-
const rightLog = spec.y2Scale === 'log'
|
|
1795
|
-
const leftDom = spec.stacked100
|
|
1796
|
-
? niceScale(0, 100)
|
|
1797
|
-
: axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog)
|
|
1798
|
-
const rightDom = hasRightAxis
|
|
1799
|
-
? spec.stacked100
|
|
1800
|
-
? niceScale(0, 100)
|
|
1801
|
-
: axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
|
|
1802
|
-
: null
|
|
1803
|
-
|
|
1804
|
-
/** Map a data value to a y pixel. Returns NaN for non-positive values on
|
|
1805
|
-
* a log axis so callers can drop the point (line gap / missing bar). */
|
|
1806
|
-
const yOf = (dom: NiceScale, v: number, isLog = false) => {
|
|
1807
|
-
const t = project(v, dom.min, dom.max, isLog)
|
|
1808
|
-
if (t === null) return NaN
|
|
1809
|
-
return round(padT + plotH - t * plotH)
|
|
1810
|
-
}
|
|
1811
|
-
const yLeft = (v: number) => yOf(leftDom, v, leftLog)
|
|
1812
|
-
const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog)
|
|
1813
|
-
const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
|
|
1814
|
-
const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
|
|
1815
|
-
|
|
1816
|
-
const n = spec.categories.length
|
|
1817
|
-
const slot = plotW / Math.max(1, n)
|
|
1818
|
-
|
|
1819
|
-
// X positions. A time axis spaces points by actual time (irregular gaps);
|
|
1820
|
-
// a category axis is uniform. (Bars stay uniform either way.)
|
|
1821
|
-
const timeVals =
|
|
1822
|
-
spec.xType === 'time' ? spec.categories.map((c) => Date.parse(c)) : null
|
|
1823
|
-
const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
|
|
1824
|
-
const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
|
|
1825
|
-
const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
|
|
1826
|
-
const xCenter = (i: number) =>
|
|
1827
|
-
timeOk && Number.isFinite(timeVals![i])
|
|
1828
|
-
? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
|
|
1829
|
-
: round(padL + slot * i + slot / 2)
|
|
1830
|
-
const xTicks: ChartCategoryTick[] = timeOk
|
|
1831
|
-
? dateTicks(tMin, tMin + tSpan).map((t) => ({
|
|
1832
|
-
label: fmtDate(t, tSpan),
|
|
1833
|
-
x: round(padL + ((t - tMin) / tSpan) * plotW),
|
|
1834
|
-
}))
|
|
1835
|
-
: spec.categories.map((label, i) => ({ label, x: xCenter(i) }))
|
|
1836
|
-
|
|
1837
|
-
// Parent-tier ticks for a grouped category axis: each spans its leaves.
|
|
1838
|
-
const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
|
|
1839
|
-
if (validGroups && !timeOk) {
|
|
1840
|
-
let start = 0
|
|
1841
|
-
for (const g of validGroups) {
|
|
1842
|
-
const x0 = round(padL + slot * start)
|
|
1843
|
-
const x1 = round(padL + slot * (start + g.span))
|
|
1844
|
-
categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
|
|
1845
|
-
start += g.span
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
const barSeries = series.filter((s) => s.kind === 'bar')
|
|
1850
|
-
const bars: ChartBar[] = []
|
|
1851
|
-
if (barSeries.length) {
|
|
1852
|
-
const groupPad = slot * 0.2
|
|
1853
|
-
if (stacked) {
|
|
1854
|
-
const inner = slot - groupPad
|
|
1855
|
-
const x0 = (i: number) => padL + slot * i + groupPad / 2
|
|
1856
|
-
// Stack independently per axis so dual-axis stacks line up to their own scale.
|
|
1857
|
-
for (const axis of ['left', 'right'] as const) {
|
|
1858
|
-
const axisBars = barSeries.filter((s) => s.axis === axis)
|
|
1859
|
-
if (!axisBars.length) continue
|
|
1860
|
-
const yA = axis === 'right' ? yRight : yLeft
|
|
1861
|
-
// 100% mode normalizes each category to its absolute total.
|
|
1862
|
-
const totals = spec.stacked100
|
|
1863
|
-
? spec.categories.map(
|
|
1864
|
-
(_, i) =>
|
|
1865
|
-
axisBars.reduce(
|
|
1866
|
-
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
1867
|
-
0,
|
|
1868
|
-
) || 1,
|
|
1869
|
-
)
|
|
1870
|
-
: null
|
|
1871
|
-
const pos = new Array(n).fill(0)
|
|
1872
|
-
const neg = new Array(n).fill(0)
|
|
1873
|
-
for (const s of axisBars) {
|
|
1874
|
-
s.values.forEach((v, i) => {
|
|
1875
|
-
if (!Number.isFinite(v)) return
|
|
1876
|
-
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
1877
|
-
let yTop: number
|
|
1878
|
-
let yBot: number
|
|
1879
|
-
if (vp >= 0) {
|
|
1880
|
-
yTop = yA(pos[i] + vp)
|
|
1881
|
-
yBot = yA(pos[i])
|
|
1882
|
-
pos[i] += vp
|
|
1883
|
-
} else {
|
|
1884
|
-
yTop = yA(neg[i])
|
|
1885
|
-
yBot = yA(neg[i] + vp)
|
|
1886
|
-
neg[i] += vp
|
|
1887
|
-
}
|
|
1888
|
-
bars.push({
|
|
1889
|
-
x: round(x0(i)),
|
|
1890
|
-
y: Math.min(yTop, yBot),
|
|
1891
|
-
w: round(Math.max(1, inner)),
|
|
1892
|
-
h: round(Math.abs(yBot - yTop)),
|
|
1893
|
-
color: s.color,
|
|
1894
|
-
label: spec.categories[i] ?? String(i),
|
|
1895
|
-
series: s.label,
|
|
1896
|
-
value: v,
|
|
1897
|
-
})
|
|
1898
|
-
})
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
} else {
|
|
1902
|
-
const inner = slot - groupPad
|
|
1903
|
-
const barW = inner / barSeries.length
|
|
1904
|
-
barSeries.forEach((s, bi) => {
|
|
1905
|
-
const dom = domOf(s)
|
|
1906
|
-
const log = isLogOf(s)
|
|
1907
|
-
// Log axis: bars grow from the axis floor (dom.min) up to v rather
|
|
1908
|
-
// than from 0, since 0 is invalid in log space.
|
|
1909
|
-
const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log)
|
|
1910
|
-
s.values.forEach((v, i) => {
|
|
1911
|
-
if (!Number.isFinite(v)) return
|
|
1912
|
-
if (log && v <= 0) return
|
|
1913
|
-
const x = padL + slot * i + groupPad / 2 + barW * bi
|
|
1914
|
-
const yV = yOf(dom, v, log)
|
|
1915
|
-
bars.push({
|
|
1916
|
-
x: round(x),
|
|
1917
|
-
y: Math.min(yV, base),
|
|
1918
|
-
w: round(Math.max(1, barW - 1)),
|
|
1919
|
-
h: round(Math.max(1, Math.abs(yV - base))),
|
|
1920
|
-
color: s.color,
|
|
1921
|
-
label: spec.categories[i] ?? String(i),
|
|
1922
|
-
series: s.label,
|
|
1923
|
-
value: v,
|
|
1924
|
-
})
|
|
1925
|
-
})
|
|
1926
|
-
})
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
// Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
|
|
1931
|
-
const lines: ChartLine[] = []
|
|
1932
|
-
const areaCum: Record<'left' | 'right', number[]> = {
|
|
1933
|
-
left: new Array(n).fill(0),
|
|
1934
|
-
right: new Array(n).fill(0),
|
|
1935
|
-
}
|
|
1936
|
-
// 100% mode: per-axis per-category totals to normalize stacked areas to 100.
|
|
1937
|
-
const areaTotals: Record<'left' | 'right', number[] | null> = { left: null, right: null }
|
|
1938
|
-
if (spec.stacked100) {
|
|
1939
|
-
for (const axis of ['left', 'right'] as const) {
|
|
1940
|
-
const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis)
|
|
1941
|
-
if (areaSeries.length) {
|
|
1942
|
-
areaTotals[axis] = spec.categories.map(
|
|
1943
|
-
(_, i) =>
|
|
1944
|
-
areaSeries.reduce(
|
|
1945
|
-
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
1946
|
-
0,
|
|
1947
|
-
) || 1,
|
|
1948
|
-
)
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
for (const s of series) {
|
|
1953
|
-
if (s.kind === 'bar') continue
|
|
1954
|
-
const dom = domOf(s)
|
|
1955
|
-
const log = isLogOf(s)
|
|
1956
|
-
const yA = (v: number) => yOf(dom, v, log)
|
|
1957
|
-
const isStackedArea = stacked && s.kind === 'area'
|
|
1958
|
-
const px = (i: number) => xCenter(i)
|
|
1959
|
-
let pts: ChartLinePoint[]
|
|
1960
|
-
let baselinePts: Array<{ x: number; y: number }> | null = null
|
|
1961
|
-
if (isStackedArea) {
|
|
1962
|
-
// Stacked areas treat a gap as 0 so the stack stays continuous.
|
|
1963
|
-
const cum = areaCum[s.axis]
|
|
1964
|
-
const prev = cum.slice()
|
|
1965
|
-
const totals = areaTotals[s.axis]
|
|
1966
|
-
pts = s.values.map((v, i) => {
|
|
1967
|
-
const vv = Number.isFinite(v) ? v : 0
|
|
1968
|
-
// 100% mode positions by share of the category total; value stays original.
|
|
1969
|
-
const norm = totals ? (vv / totals[i]!) * 100 : vv
|
|
1970
|
-
const c = (cum[i] ?? 0) + norm
|
|
1971
|
-
cum[i] = c
|
|
1972
|
-
return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) }
|
|
1973
|
-
})
|
|
1974
|
-
baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
|
|
1975
|
-
} else {
|
|
1976
|
-
pts = s.values.map((v, i) => {
|
|
1977
|
-
const ok = Number.isFinite(v)
|
|
1978
|
-
return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
|
|
1979
|
-
})
|
|
1980
|
-
}
|
|
1981
|
-
// Build the line - smoothed via monotone cubic when requested, else
|
|
1982
|
-
// straight polylines. Either way, gaps break the path cleanly.
|
|
1983
|
-
const smooth = !!s.smooth
|
|
1984
|
-
const path = buildLinePath(pts, smooth)
|
|
1985
|
-
|
|
1986
|
-
let areaPath = ''
|
|
1987
|
-
if (s.kind === 'area' && pts.length) {
|
|
1988
|
-
if (baselinePts) {
|
|
1989
|
-
const top = smooth
|
|
1990
|
-
? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
|
|
1991
|
-
: pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
1992
|
-
const back = smooth
|
|
1993
|
-
? `L${baselinePts[baselinePts.length - 1]!.x},${baselinePts[baselinePts.length - 1]!.y} ` +
|
|
1994
|
-
monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
1995
|
-
: baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
|
|
1996
|
-
areaPath = `${top} ${back} Z`
|
|
1997
|
-
} else {
|
|
1998
|
-
// One filled polygon per contiguous run of defined points.
|
|
1999
|
-
const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)))
|
|
2000
|
-
const runs: ChartLinePoint[][] = []
|
|
2001
|
-
let cur: ChartLinePoint[] = []
|
|
2002
|
-
for (const p of pts) {
|
|
2003
|
-
if (p.defined) cur.push(p)
|
|
2004
|
-
else if (cur.length) {
|
|
2005
|
-
runs.push(cur)
|
|
2006
|
-
cur = []
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
if (cur.length) runs.push(cur)
|
|
2010
|
-
areaPath = runs
|
|
2011
|
-
.map((run) => {
|
|
2012
|
-
const top = smooth
|
|
2013
|
-
? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
|
|
2014
|
-
: run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
2015
|
-
return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
|
|
2016
|
-
})
|
|
2017
|
-
.join(' ')
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
// Confidence band: shaded envelope between upperValues / lowerValues.
|
|
2022
|
-
// Both arrays must be present and aligned to the value array.
|
|
2023
|
-
let bandPath = ''
|
|
2024
|
-
if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
|
|
2025
|
-
const upperPts: Array<{ x: number; y: number }> = []
|
|
2026
|
-
const lowerPts: Array<{ x: number; y: number }> = []
|
|
2027
|
-
for (let i = 0; i < s.values.length; i += 1) {
|
|
2028
|
-
const u = s.upperValues[i]!
|
|
2029
|
-
const lo = s.lowerValues[i]!
|
|
2030
|
-
if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
|
|
2031
|
-
if (log && (u <= 0 || lo <= 0)) continue
|
|
2032
|
-
upperPts.push({ x: px(i), y: yA(u) })
|
|
2033
|
-
lowerPts.push({ x: px(i), y: yA(lo) })
|
|
2034
|
-
}
|
|
2035
|
-
if (upperPts.length >= 2) {
|
|
2036
|
-
const top = smooth
|
|
2037
|
-
? monotoneCubicPath(upperPts)
|
|
2038
|
-
: upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
2039
|
-
const back = smooth
|
|
2040
|
-
? `L${lowerPts[lowerPts.length - 1]!.x},${lowerPts[lowerPts.length - 1]!.y} ` +
|
|
2041
|
-
monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
2042
|
-
: lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
|
|
2043
|
-
bandPath = `${top} ${back} Z`
|
|
2044
|
-
}
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath })
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
|
|
2051
|
-
dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: formatChartValue(value, spec.valueFormat) }))
|
|
2052
|
-
|
|
2053
|
-
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
|
|
2054
|
-
const onRight = ref.axis === 'right'
|
|
2055
|
-
const dom = onRight ? (rightDom ?? leftDom) : leftDom
|
|
2056
|
-
const log = onRight ? rightLog : leftLog
|
|
2057
|
-
return {
|
|
2058
|
-
y: yOf(dom, ref.value, log),
|
|
2059
|
-
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
2060
|
-
color: ref.color ?? '#ef4444',
|
|
2061
|
-
dashed: ref.dashed !== false,
|
|
2062
|
-
}
|
|
2063
|
-
})
|
|
2064
|
-
|
|
2065
|
-
// ---- Overlays: trendline / moving average ------------------------
|
|
2066
|
-
// For every series with an `overlay`, compute the smoothed values and
|
|
2067
|
-
// render as a dashed line in the source series' color (or overlayColor).
|
|
2068
|
-
const overlays: ChartLine[] = []
|
|
2069
|
-
for (const s of series) {
|
|
2070
|
-
if (!s.overlay) continue
|
|
2071
|
-
const dom = domOf(s)
|
|
2072
|
-
const log = isLogOf(s)
|
|
2073
|
-
const overlayVals = computeOverlay(s.values, s.overlay)
|
|
2074
|
-
const color = s.overlayColor ?? s.color
|
|
2075
|
-
const pts: ChartLinePoint[] = overlayVals.map((v, i) => {
|
|
2076
|
-
const ok = Number.isFinite(v) && (!log || v > 0)
|
|
2077
|
-
return {
|
|
2078
|
-
x: xCenter(i),
|
|
2079
|
-
y: ok ? yOf(dom, v, log) : NaN,
|
|
2080
|
-
label: spec.categories[i] ?? String(i),
|
|
2081
|
-
value: v,
|
|
2082
|
-
defined: ok,
|
|
2083
|
-
}
|
|
2084
|
-
})
|
|
2085
|
-
const path = buildLinePath(pts, !!s.smooth)
|
|
2086
|
-
overlays.push({
|
|
2087
|
-
path,
|
|
2088
|
-
areaPath: '',
|
|
2089
|
-
color,
|
|
2090
|
-
label: `${s.label} (${s.overlay})`,
|
|
2091
|
-
points: pts,
|
|
2092
|
-
})
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
// ---- Annotations: resolve data-space anchors to pixel coords ------
|
|
2096
|
-
const annotations: ChartGeometry['annotations'] = []
|
|
2097
|
-
for (const a of (spec.annotations ?? [])) {
|
|
2098
|
-
let ax: number | null = null
|
|
2099
|
-
let ay: number | null = null
|
|
2100
|
-
if ('category' in a.at) {
|
|
2101
|
-
const ci = spec.categories.indexOf(a.at.category)
|
|
2102
|
-
if (ci < 0) continue
|
|
2103
|
-
ax = xCenter(ci)
|
|
2104
|
-
// Anchor to the named series' value at that category, else just
|
|
2105
|
-
// mid-plot. Picks the first matching series if `series` is set.
|
|
2106
|
-
const seriesName = a.at.series
|
|
2107
|
-
const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
|
|
2108
|
-
if (s) {
|
|
2109
|
-
const v = s.values[ci]
|
|
2110
|
-
if (Number.isFinite(v)) ay = yOf(domOf(s), v as number, isLogOf(s))
|
|
2111
|
-
}
|
|
2112
|
-
if (ay == null) ay = padT + plotH / 2
|
|
2113
|
-
} else {
|
|
2114
|
-
// Raw x/y in data space (x ignored for category x-axis; takes the
|
|
2115
|
-
// mid-plot in that case). y projects through the left axis.
|
|
2116
|
-
ax = padL + plotW / 2
|
|
2117
|
-
if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog)
|
|
2118
|
-
else ay = padT + plotH / 2
|
|
2119
|
-
}
|
|
2120
|
-
if (ax != null && ay != null && Number.isFinite(ay)) {
|
|
2121
|
-
annotations.push({
|
|
2122
|
-
x: ax,
|
|
2123
|
-
y: ay,
|
|
2124
|
-
label: a.label,
|
|
2125
|
-
color: a.color ?? '#0f172a',
|
|
2126
|
-
placement: a.placement ?? 'top',
|
|
2127
|
-
})
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
return {
|
|
2132
|
-
...empty,
|
|
2133
|
-
plot,
|
|
2134
|
-
bars,
|
|
2135
|
-
lines,
|
|
2136
|
-
yTicks: tickFor(leftDom, leftLog),
|
|
2137
|
-
y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
|
|
2138
|
-
hasRightAxis,
|
|
2139
|
-
xTicks,
|
|
2140
|
-
categoryGroupTicks,
|
|
2141
|
-
xLabelRotated: timeOk ? false : xLabelRotated,
|
|
2142
|
-
referenceLines,
|
|
2143
|
-
overlays,
|
|
2144
|
-
annotations,
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
/**
|
|
2149
|
-
* Aggregate flat rows into a chart spec. Group by a category field, reduce a
|
|
2150
|
-
* value field per group. Three multi-series shapes:
|
|
2151
|
-
* - `value: 'revenue'` -> one series
|
|
2152
|
-
* - `value: ['revenue','cost']` -> one series per value field
|
|
2153
|
-
* - `value: 'sales', series: 'region'` -> pivot: one series per distinct
|
|
2154
|
-
* value of the `series` field
|
|
2155
|
-
*/
|
|
2156
|
-
export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
2157
|
-
rows: ReadonlyArray<T>,
|
|
2158
|
-
opts: {
|
|
2159
|
-
type: ChartType
|
|
2160
|
-
category: keyof T & string
|
|
2161
|
-
value: (keyof T & string) | Array<keyof T & string>
|
|
2162
|
-
/** Pivot dimension: one series per distinct value of this field. */
|
|
2163
|
-
series?: keyof T & string
|
|
2164
|
-
reduce?: 'sum' | 'avg' | 'count'
|
|
2165
|
-
seriesLabel?: string
|
|
2166
|
-
width?: number
|
|
2167
|
-
height?: number
|
|
2168
|
-
stacked?: boolean
|
|
2169
|
-
stacked100?: boolean
|
|
2170
|
-
palette?: string[]
|
|
2171
|
-
/** Order categories. Defaults to insertion order (or value-desc when topN). */
|
|
2172
|
-
sort?: 'value-desc' | 'value-asc' | 'category' | 'none'
|
|
2173
|
-
/** Keep only the top N categories; bucket the rest into "Other". */
|
|
2174
|
-
topN?: number
|
|
2175
|
-
/** Label for the bucketed remainder. Default "Other". */
|
|
2176
|
-
otherLabel?: string
|
|
2177
|
-
/** Field carrying each row's stable id. When set, the resulting spec's
|
|
2178
|
-
* series carry `rowIds` arrays so click handlers can drill back to
|
|
2179
|
-
* the source rows. */
|
|
2180
|
-
idField?: keyof T & string
|
|
2181
|
-
},
|
|
2182
|
-
): ChartSpec {
|
|
2183
|
-
const reduce = opts.reduce ?? 'sum'
|
|
2184
|
-
const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value]
|
|
2185
|
-
const reduceCell = (sum: number, count: number) =>
|
|
2186
|
-
reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
|
|
2187
|
-
|
|
2188
|
-
const categories: string[] = []
|
|
2189
|
-
const catIndex = new Map<string, number>()
|
|
2190
|
-
const ensureCat = (key: string) => {
|
|
2191
|
-
let idx = catIndex.get(key)
|
|
2192
|
-
if (idx === undefined) {
|
|
2193
|
-
idx = categories.length
|
|
2194
|
-
catIndex.set(key, idx)
|
|
2195
|
-
categories.push(key)
|
|
2196
|
-
}
|
|
2197
|
-
return idx
|
|
2198
|
-
}
|
|
2199
|
-
|
|
2200
|
-
// Series keyed by name -> per-category {sum,count,rowIds}.
|
|
2201
|
-
type Cell = { sum: number; count: number; rowIds: Array<string | number> }
|
|
2202
|
-
const seriesMap = new Map<string, Cell[]>()
|
|
2203
|
-
const ensureSeries = (name: string) => {
|
|
2204
|
-
let arr = seriesMap.get(name)
|
|
2205
|
-
if (!arr) {
|
|
2206
|
-
arr = []
|
|
2207
|
-
seriesMap.set(name, arr)
|
|
2208
|
-
}
|
|
2209
|
-
return arr
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
const trackIds = opts.idField !== undefined
|
|
2213
|
-
for (const row of rows) {
|
|
2214
|
-
const cat = String(row[opts.category] ?? '')
|
|
2215
|
-
const ci = ensureCat(cat)
|
|
2216
|
-
const rowId = trackIds ? (row[opts.idField as keyof T] as string | number) : undefined
|
|
2217
|
-
if (opts.series) {
|
|
2218
|
-
const sName = String(row[opts.series] ?? '')
|
|
2219
|
-
const arr = ensureSeries(sName)
|
|
2220
|
-
const num = Number(row[valueFields[0]!])
|
|
2221
|
-
const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
|
|
2222
|
-
if (Number.isFinite(num)) {
|
|
2223
|
-
cell.sum += num
|
|
2224
|
-
cell.count += 1
|
|
2225
|
-
if (rowId !== undefined) cell.rowIds.push(rowId)
|
|
2226
|
-
}
|
|
2227
|
-
} else {
|
|
2228
|
-
for (const vf of valueFields) {
|
|
2229
|
-
const arr = ensureSeries(vf)
|
|
2230
|
-
const num = Number(row[vf])
|
|
2231
|
-
const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
|
|
2232
|
-
if (Number.isFinite(num)) {
|
|
2233
|
-
cell.sum += num
|
|
2234
|
-
cell.count += 1
|
|
2235
|
-
if (rowId !== undefined) cell.rowIds.push(rowId)
|
|
2236
|
-
}
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
|
|
2241
|
-
const entries = [...seriesMap.entries()].map(([name, arr]) => ({
|
|
2242
|
-
label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
|
|
2243
|
-
values: categories.map((_, i) => {
|
|
2244
|
-
const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] as Array<string | number> }
|
|
2245
|
-
return reduceCell(cell.sum, cell.count)
|
|
2246
|
-
}),
|
|
2247
|
-
rowIds: trackIds
|
|
2248
|
-
? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
|
|
2249
|
-
: undefined,
|
|
2250
|
-
}))
|
|
2251
|
-
|
|
2252
|
-
// ---- Sort + top-N -----------------------------------------------------
|
|
2253
|
-
const totals = categories.map((_, i) =>
|
|
2254
|
-
entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0),
|
|
2255
|
-
)
|
|
2256
|
-
const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none')
|
|
2257
|
-
let order = categories.map((_, i) => i)
|
|
2258
|
-
if (sort === 'value-desc') order.sort((a, b) => totals[b]! - totals[a]!)
|
|
2259
|
-
else if (sort === 'value-asc') order.sort((a, b) => totals[a]! - totals[b]!)
|
|
2260
|
-
else if (sort === 'category') order.sort((a, b) => categories[a]!.localeCompare(categories[b]!))
|
|
2261
|
-
|
|
2262
|
-
let finalCategories: string[]
|
|
2263
|
-
let finalSeries: ChartSeries[]
|
|
2264
|
-
if (opts.topN && order.length > opts.topN) {
|
|
2265
|
-
const keep = order.slice(0, opts.topN)
|
|
2266
|
-
const rest = order.slice(opts.topN)
|
|
2267
|
-
finalCategories = keep.map((i) => categories[i]!).concat(opts.otherLabel ?? 'Other')
|
|
2268
|
-
finalSeries = entries.map((e) => ({
|
|
2269
|
-
label: e.label,
|
|
2270
|
-
values: keep
|
|
2271
|
-
.map((i) => e.values[i]!)
|
|
2272
|
-
.concat(rest.reduce((sum, i) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0)),
|
|
2273
|
-
rowIds: e.rowIds
|
|
2274
|
-
? keep.map((i) => e.rowIds![i]!).concat([rest.flatMap((i) => e.rowIds![i] ?? [])])
|
|
2275
|
-
: undefined,
|
|
2276
|
-
}))
|
|
2277
|
-
} else {
|
|
2278
|
-
finalCategories = order.map((i) => categories[i]!)
|
|
2279
|
-
finalSeries = entries.map((e) => ({
|
|
2280
|
-
label: e.label,
|
|
2281
|
-
values: order.map((i) => e.values[i]!),
|
|
2282
|
-
rowIds: e.rowIds ? order.map((i) => e.rowIds![i]!) : undefined,
|
|
2283
|
-
}))
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
return {
|
|
2287
|
-
type: opts.type,
|
|
2288
|
-
categories: finalCategories,
|
|
2289
|
-
series: finalSeries,
|
|
2290
|
-
width: opts.width,
|
|
2291
|
-
height: opts.height,
|
|
2292
|
-
stacked: opts.stacked,
|
|
2293
|
-
stacked100: opts.stacked100,
|
|
2294
|
-
palette: opts.palette,
|
|
2295
|
-
}
|
|
2296
|
-
}
|
|
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
|
+
export type ChartType =
|
|
12
|
+
| 'bar' | 'line' | 'area' | 'pie' | 'scatter'
|
|
13
|
+
| 'heatmap' | 'waterfall' | 'funnel' | 'radar'
|
|
14
|
+
| 'calendar' | 'gauge' | 'treemap' | 'sankey'
|
|
15
|
+
|
|
16
|
+
/** A clicked bar / point / slice - the payload of `SvGridChart`'s `onSelect`.
|
|
17
|
+
* `rowIds` is populated when the spec was built from grid rows (via
|
|
18
|
+
* `rowsToChartSpec`) and lets a drill handler filter the grid back to the
|
|
19
|
+
* source rows for the clicked category / series cell. */
|
|
20
|
+
export type ChartSelection = {
|
|
21
|
+
category: string
|
|
22
|
+
series: string
|
|
23
|
+
value: number
|
|
24
|
+
rowIds?: Array<string | number>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A single scatter / bubble point. */
|
|
28
|
+
export type ScatterPoint = { x: number; y: number; r?: number; label?: string }
|
|
29
|
+
|
|
30
|
+
/** A statistical / smoothing line drawn on top of a source series.
|
|
31
|
+
* - `'linear'`: ordinary least-squares regression line
|
|
32
|
+
* - `'sma:N'`: simple moving average over a window of N points
|
|
33
|
+
* - `'ema:N'`: exponential moving average with smoothing factor 2/(N+1) */
|
|
34
|
+
export type SeriesOverlay = 'linear' | `sma:${number}` | `ema:${number}`
|
|
35
|
+
|
|
36
|
+
/** A texture fill applied in addition to (and on top of) the series color.
|
|
37
|
+
* Helps colorblind readers distinguish series at a glance. */
|
|
38
|
+
export type SeriesPattern = 'solid' | 'stripe' | 'crosshatch' | 'dots' | 'diagonal'
|
|
39
|
+
/** Cycle used when `ChartSpec.patternFallback` is true and a series has no
|
|
40
|
+
* explicit `pattern` set. Skips `'solid'` so every series gets a texture. */
|
|
41
|
+
const PATTERN_CYCLE: SeriesPattern[] = ['stripe', 'crosshatch', 'dots', 'diagonal']
|
|
42
|
+
|
|
43
|
+
export type ChartSeries = {
|
|
44
|
+
label: string
|
|
45
|
+
values: number[]
|
|
46
|
+
color?: string
|
|
47
|
+
/** Per-series chart type, for combo charts. Defaults to the spec `type`. */
|
|
48
|
+
type?: 'bar' | 'line' | 'area'
|
|
49
|
+
/** Plot against the left (default) or right Y axis. */
|
|
50
|
+
axis?: 'left' | 'right'
|
|
51
|
+
/** Scatter / bubble points (used when `type === 'scatter'`). */
|
|
52
|
+
points?: ScatterPoint[]
|
|
53
|
+
/** Row IDs contributing to each data point - parallel to `values`. When
|
|
54
|
+
* present, click handlers receive these in `ChartSelection.rowIds` so
|
|
55
|
+
* callers can drill the grid back to the source rows. */
|
|
56
|
+
rowIds?: Array<Array<string | number>>
|
|
57
|
+
/** Draw a smoothing / trend overlay on top of this series. */
|
|
58
|
+
overlay?: SeriesOverlay
|
|
59
|
+
/** Color for the overlay line. Defaults to the series color. */
|
|
60
|
+
overlayColor?: string
|
|
61
|
+
/** Texture fill (e.g. diagonal stripes) layered over the series color.
|
|
62
|
+
* Lets colorblind viewers tell two series apart even at the same hue. */
|
|
63
|
+
pattern?: SeriesPattern
|
|
64
|
+
/** Interpolate the line as a curve instead of polylines. `'monotone'`
|
|
65
|
+
* cubic prevents overshoots between points (best default for data);
|
|
66
|
+
* `true` is an alias for `'monotone'`. Only meaningful for line/area. */
|
|
67
|
+
smooth?: boolean | 'monotone'
|
|
68
|
+
/** Upper envelope (e.g. forecast 95th percentile) parallel to `values`.
|
|
69
|
+
* When set alongside `lowerValues`, the chart shades the band between
|
|
70
|
+
* the two as a translucent fill in the series color. */
|
|
71
|
+
upperValues?: number[]
|
|
72
|
+
/** Lower envelope; pair with `upperValues` for a confidence band. */
|
|
73
|
+
lowerValues?: number[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A pinned label drawn over the plot, anchored to a data point or to an
|
|
77
|
+
* arbitrary (x, y) in data space. Useful for "Release v1", "Outage", etc. */
|
|
78
|
+
export type ChartAnnotation = {
|
|
79
|
+
/** Anchor in data space. Provide either `category` + `axis` for a point on
|
|
80
|
+
* an existing series, OR raw `x` / `y` numeric coordinates in data space. */
|
|
81
|
+
at:
|
|
82
|
+
| { category: string; series?: string }
|
|
83
|
+
| { x: number; y?: number }
|
|
84
|
+
label: string
|
|
85
|
+
color?: string
|
|
86
|
+
/** Where the label sits relative to the marker. Defaults to 'top'. */
|
|
87
|
+
placement?: 'top' | 'bottom' | 'left' | 'right'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A horizontal reference / target line drawn across the plot. */
|
|
91
|
+
export type ChartReferenceLine = {
|
|
92
|
+
value: number
|
|
93
|
+
label?: string
|
|
94
|
+
axis?: 'left' | 'right'
|
|
95
|
+
color?: string
|
|
96
|
+
dashed?: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ChartSpec = {
|
|
100
|
+
/** Default type for series that don't set their own `type`. */
|
|
101
|
+
type: ChartType
|
|
102
|
+
/** X-axis labels (one per data point). */
|
|
103
|
+
categories: string[]
|
|
104
|
+
series: ChartSeries[]
|
|
105
|
+
width?: number
|
|
106
|
+
height?: number
|
|
107
|
+
/** Palette used when a series has no explicit `color`. */
|
|
108
|
+
palette?: string[]
|
|
109
|
+
/** Per-category color overrides (by category label) - for pie / donut slice
|
|
110
|
+
* recolouring, where colour follows the category, not a series. */
|
|
111
|
+
categoryColors?: Record<string, string>
|
|
112
|
+
/** Number format for the value axis, tooltips, data labels and reference
|
|
113
|
+
* lines. Unset = the default compact `1.2k` / `1.2M` style. */
|
|
114
|
+
valueFormat?: ChartValueFormat
|
|
115
|
+
/** Grouped (nested) category axis: a parent tier spanning consecutive leaf
|
|
116
|
+
* categories (spans must sum to `categories.length`). Vertical category
|
|
117
|
+
* charts only (ignored for time / horizontal / pie). */
|
|
118
|
+
categoryGroups?: Array<{ label: string; span: number }>
|
|
119
|
+
/** Stack bar / area series (per axis) instead of grouping them. */
|
|
120
|
+
stacked?: boolean
|
|
121
|
+
/** Stack to 100% (each category normalized to its total). Implies stacked. */
|
|
122
|
+
stacked100?: boolean
|
|
123
|
+
/**
|
|
124
|
+
* Bar orientation. `'horizontal'` swaps the axes - categories run down the
|
|
125
|
+
* left, bars grow rightward - which suits long category labels. Only applies
|
|
126
|
+
* when every series is a bar (combo / line / area fall back to vertical).
|
|
127
|
+
*/
|
|
128
|
+
orientation?: 'vertical' | 'horizontal'
|
|
129
|
+
/** Pie only: inner radius as a fraction of the outer radius (0..1) -> donut. */
|
|
130
|
+
innerRadius?: number
|
|
131
|
+
/** Horizontal target / goal / average lines. */
|
|
132
|
+
referenceLines?: ChartReferenceLine[]
|
|
133
|
+
/** Treat `categories` as dates -> time-scaled x positions + date ticks. */
|
|
134
|
+
xType?: 'category' | 'time'
|
|
135
|
+
/** Axis titles (reserve gutter space + render). */
|
|
136
|
+
yAxisTitle?: string
|
|
137
|
+
y2AxisTitle?: string
|
|
138
|
+
xAxisTitle?: string
|
|
139
|
+
/** Y-axis scale. `'log'` plots base-10 logarithmic - values <= 0 are
|
|
140
|
+
* treated as missing. Necessary for wide-range data (money, audience
|
|
141
|
+
* size, scientific). Default `'linear'`. */
|
|
142
|
+
yScale?: 'linear' | 'log'
|
|
143
|
+
/** Right (secondary) Y-axis scale. Default `'linear'`. */
|
|
144
|
+
y2Scale?: 'linear' | 'log'
|
|
145
|
+
/** Pinned text labels at fixed data-space positions (callouts). */
|
|
146
|
+
annotations?: ChartAnnotation[]
|
|
147
|
+
/** When true, automatically cycle through pattern fills for every series
|
|
148
|
+
* that doesn't set `pattern` explicitly. Useful as a one-flag colorblind
|
|
149
|
+
* fallback. Default false. */
|
|
150
|
+
patternFallback?: boolean
|
|
151
|
+
/** Calendar heatmap: array of date+value samples (one per day). Date
|
|
152
|
+
* strings are 'YYYY-MM-DD'. Missing days render as blank cells. */
|
|
153
|
+
calendarValues?: Array<{ date: string; value: number }>
|
|
154
|
+
/** Calendar heatmap: year window. Default: span the data. */
|
|
155
|
+
calendarStart?: string
|
|
156
|
+
calendarEnd?: string
|
|
157
|
+
|
|
158
|
+
/** Gauge: the value to display. */
|
|
159
|
+
gaugeValue?: number
|
|
160
|
+
/** Gauge: min/max of the dial scale. Defaults [0, 100]. */
|
|
161
|
+
gaugeMin?: number
|
|
162
|
+
gaugeMax?: number
|
|
163
|
+
/** Gauge: target marker (the line/notch on the arc). */
|
|
164
|
+
gaugeTarget?: number
|
|
165
|
+
/** Gauge: color bands along the arc (e.g. red/amber/green). */
|
|
166
|
+
gaugeRanges?: Array<{ from: number; to: number; color: string }>
|
|
167
|
+
/** Gauge: unit / suffix shown next to the value (e.g. '%', 'ms'). */
|
|
168
|
+
gaugeUnit?: string
|
|
169
|
+
|
|
170
|
+
/** Tree-map: hierarchical root. Leaves have `value`; parents are the
|
|
171
|
+
* sum of their children's totals. */
|
|
172
|
+
treemap?: TreeNode
|
|
173
|
+
|
|
174
|
+
/** Sankey: nodes + flow links between them. Link `source` / `target`
|
|
175
|
+
* reference node ids. */
|
|
176
|
+
sankeyNodes?: Array<{ id: string; label?: string; color?: string }>
|
|
177
|
+
sankeyLinks?: Array<{ source: string; target: string; value: number; color?: string }>
|
|
178
|
+
|
|
179
|
+
/** Waterfall: per-category flag marking bars as totals/subtotals that
|
|
180
|
+
* reset the running sum and span from 0. Same length as `categories`. */
|
|
181
|
+
waterfallTotals?: boolean[]
|
|
182
|
+
/** Waterfall: explicit colors for positive/negative/total bars. The
|
|
183
|
+
* series color is ignored when this is set. */
|
|
184
|
+
waterfallColors?: { positive?: string; negative?: string; total?: string }
|
|
185
|
+
/** Heatmap color scale. `'sequential'` maps min->max through one hue,
|
|
186
|
+
* `'diverging'` runs cold->neutral->warm around 0. A custom array
|
|
187
|
+
* (>=2 hex colors) defines an arbitrary gradient. Default `'sequential'`. */
|
|
188
|
+
colorScale?: 'sequential' | 'diverging' | string[]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A tree-map / sankey / treemap node spec. Used recursively as a tree. */
|
|
192
|
+
export type TreeNode = {
|
|
193
|
+
name: string
|
|
194
|
+
value?: number
|
|
195
|
+
color?: string
|
|
196
|
+
children?: TreeNode[]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A laid-out tree-map rectangle. */
|
|
200
|
+
export type ChartTreemapCell = {
|
|
201
|
+
x: number
|
|
202
|
+
y: number
|
|
203
|
+
w: number
|
|
204
|
+
h: number
|
|
205
|
+
color: string
|
|
206
|
+
textColor: string
|
|
207
|
+
name: string
|
|
208
|
+
value: number
|
|
209
|
+
/** Depth from the root - useful for color cycling per level. */
|
|
210
|
+
depth: number
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A calendar-heatmap cell (one day). */
|
|
214
|
+
export type ChartCalendarCell = {
|
|
215
|
+
x: number
|
|
216
|
+
y: number
|
|
217
|
+
size: number
|
|
218
|
+
date: string
|
|
219
|
+
value: number
|
|
220
|
+
/** Defined when a value was supplied for this day; blank otherwise. */
|
|
221
|
+
defined: boolean
|
|
222
|
+
color: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A gauge dial layout. */
|
|
226
|
+
export type ChartGaugeLayout = {
|
|
227
|
+
cx: number
|
|
228
|
+
cy: number
|
|
229
|
+
r: number
|
|
230
|
+
/** Track arc path (background grey). */
|
|
231
|
+
trackPath: string
|
|
232
|
+
/** Value arc path (filled to the current value). */
|
|
233
|
+
valuePath: string
|
|
234
|
+
/** Optional colored range arcs. */
|
|
235
|
+
rangePaths: Array<{ path: string; color: string; from: number; to: number }>
|
|
236
|
+
/** Pixel position of the target marker (when set). */
|
|
237
|
+
target: { x1: number; y1: number; x2: number; y2: number } | null
|
|
238
|
+
/** Tick marks around the dial (major ticks are longer). */
|
|
239
|
+
ticks: Array<{ x1: number; y1: number; x2: number; y2: number; major: boolean }>
|
|
240
|
+
/** Pointer needle (a kite shape) + its center hub radius. */
|
|
241
|
+
needle: { path: string; hubR: number }
|
|
242
|
+
/** Status color of the value arc (the band the value falls in), or null to
|
|
243
|
+
* fall back to the theme accent. */
|
|
244
|
+
valueColor: string | null
|
|
245
|
+
/** Scale end labels positioned under the two arc ends. */
|
|
246
|
+
minLabel: { x: number; y: number }
|
|
247
|
+
maxLabel: { x: number; y: number }
|
|
248
|
+
value: number
|
|
249
|
+
min: number
|
|
250
|
+
max: number
|
|
251
|
+
unit: string
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A sankey node + its laid-out rect + total flow. */
|
|
255
|
+
export type ChartSankeyNode = {
|
|
256
|
+
id: string
|
|
257
|
+
label: string
|
|
258
|
+
color: string
|
|
259
|
+
x: number
|
|
260
|
+
y: number
|
|
261
|
+
w: number
|
|
262
|
+
h: number
|
|
263
|
+
/** Column (depth) the node was assigned to. */
|
|
264
|
+
column: number
|
|
265
|
+
totalIn: number
|
|
266
|
+
totalOut: number
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A sankey link rendered as a curved ribbon. */
|
|
270
|
+
export type ChartSankeyLink = {
|
|
271
|
+
path: string
|
|
272
|
+
color: string
|
|
273
|
+
/** Stroke width = link value scaled to pixels. */
|
|
274
|
+
width: number
|
|
275
|
+
source: string
|
|
276
|
+
target: string
|
|
277
|
+
value: number
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A single funnel segment (trapezoid) in pixel space. */
|
|
281
|
+
export type ChartFunnelSegment = {
|
|
282
|
+
/** Pre-built SVG path for the trapezoid. */
|
|
283
|
+
path: string
|
|
284
|
+
color: string
|
|
285
|
+
label: string
|
|
286
|
+
/** Original value (before any percentile normalisation). */
|
|
287
|
+
value: number
|
|
288
|
+
/** Conversion vs. first segment, 0..1. */
|
|
289
|
+
conversion: number
|
|
290
|
+
/** Drop-off from the previous segment, 0..1. */
|
|
291
|
+
dropoff: number
|
|
292
|
+
/** Centre point (label anchor). */
|
|
293
|
+
cx: number
|
|
294
|
+
cy: number
|
|
295
|
+
/** Auto-picked black/white contrast color for in-segment labels. */
|
|
296
|
+
textColor: string
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A radar series' polygon: axis values + the closed polygon path. */
|
|
300
|
+
export type ChartRadarSeries = {
|
|
301
|
+
label: string
|
|
302
|
+
color: string
|
|
303
|
+
path: string
|
|
304
|
+
/** Per-axis (x, y) endpoints so callers can draw dots / hit targets. */
|
|
305
|
+
points: Array<{ x: number; y: number; value: number; axis: string }>
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Radar axis spoke + tick info. */
|
|
309
|
+
export type ChartRadarAxis = {
|
|
310
|
+
label: string
|
|
311
|
+
/** Outermost endpoint of the spoke. */
|
|
312
|
+
x: number
|
|
313
|
+
y: number
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A single heatmap rectangle in pixel space. */
|
|
317
|
+
export type ChartHeatmapCell = {
|
|
318
|
+
x: number
|
|
319
|
+
y: number
|
|
320
|
+
w: number
|
|
321
|
+
h: number
|
|
322
|
+
color: string
|
|
323
|
+
/** Text color picked for contrast against `color`. */
|
|
324
|
+
textColor: string
|
|
325
|
+
value: number
|
|
326
|
+
rowLabel: string
|
|
327
|
+
colLabel: string
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export type ChartBar = {
|
|
331
|
+
x: number
|
|
332
|
+
y: number
|
|
333
|
+
w: number
|
|
334
|
+
h: number
|
|
335
|
+
color: string
|
|
336
|
+
/** Category (x label) this bar belongs to - for tooltips + labels. */
|
|
337
|
+
label: string
|
|
338
|
+
/** Series label this bar belongs to. */
|
|
339
|
+
series: string
|
|
340
|
+
value: number
|
|
341
|
+
}
|
|
342
|
+
export type ChartLinePoint = {
|
|
343
|
+
x: number
|
|
344
|
+
y: number
|
|
345
|
+
label: string
|
|
346
|
+
value: number
|
|
347
|
+
/** False for null / NaN values - the line breaks (gap), no dot is drawn. */
|
|
348
|
+
defined: boolean
|
|
349
|
+
}
|
|
350
|
+
export type ChartLine = {
|
|
351
|
+
path: string
|
|
352
|
+
areaPath: string
|
|
353
|
+
color: string
|
|
354
|
+
label: string
|
|
355
|
+
points: ChartLinePoint[]
|
|
356
|
+
/** Confidence-band path (between upperValues + lowerValues) for this
|
|
357
|
+
* series, when both arrays are supplied. Empty otherwise. */
|
|
358
|
+
bandPath?: string
|
|
359
|
+
}
|
|
360
|
+
export type ChartPieSlice = {
|
|
361
|
+
path: string
|
|
362
|
+
color: string
|
|
363
|
+
label: string
|
|
364
|
+
value: number
|
|
365
|
+
percent: number
|
|
366
|
+
/** Centroid - anchor point for a data label. */
|
|
367
|
+
cx: number
|
|
368
|
+
cy: number
|
|
369
|
+
}
|
|
370
|
+
export type ChartAxisTick = { value: number; y: number; label: string }
|
|
371
|
+
export type ChartCategoryTick = { label: string; x: number }
|
|
372
|
+
export type ChartLegendItem = { label: string; color: string }
|
|
373
|
+
export type ChartRefLineGeo = { y: number; label: string; color: string; dashed: boolean }
|
|
374
|
+
/** A vertical reference line (horizontal bar charts) positioned by `x`. */
|
|
375
|
+
export type ChartRefLineGeoV = { x: number; label: string; color: string; dashed: boolean }
|
|
376
|
+
export type ChartScatterDot = {
|
|
377
|
+
cx: number
|
|
378
|
+
cy: number
|
|
379
|
+
r: number
|
|
380
|
+
color: string
|
|
381
|
+
label: string
|
|
382
|
+
series: string
|
|
383
|
+
x: number
|
|
384
|
+
y: number
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export type ChartGeometry = {
|
|
388
|
+
type: ChartType
|
|
389
|
+
width: number
|
|
390
|
+
height: number
|
|
391
|
+
plot: { x: number; y: number; w: number; h: number }
|
|
392
|
+
bars: ChartBar[]
|
|
393
|
+
lines: ChartLine[]
|
|
394
|
+
slices: ChartPieSlice[]
|
|
395
|
+
yTicks: ChartAxisTick[]
|
|
396
|
+
/** Right-axis ticks (combo / dual-axis); empty when there's no right axis. */
|
|
397
|
+
y2Ticks: ChartAxisTick[]
|
|
398
|
+
hasRightAxis: boolean
|
|
399
|
+
xTicks: ChartCategoryTick[]
|
|
400
|
+
/** Grouped category axis parent tier: label + span extent (pixels). Empty
|
|
401
|
+
* unless `spec.categoryGroups` is set on a vertical category chart. */
|
|
402
|
+
categoryGroupTicks: Array<{ label: string; xCenter: number; x0: number; x1: number }>
|
|
403
|
+
/** True when x labels are long/many and should be rotated. */
|
|
404
|
+
xLabelRotated: boolean
|
|
405
|
+
legend: ChartLegendItem[]
|
|
406
|
+
/** Donut centre (pie + innerRadius), for a centre total label. */
|
|
407
|
+
donut: { cx: number; cy: number; r: number; total: number } | null
|
|
408
|
+
/** Horizontal reference / target lines. */
|
|
409
|
+
referenceLines: ChartRefLineGeo[]
|
|
410
|
+
/** Scatter / bubble points (type === 'scatter'). */
|
|
411
|
+
scatterPoints: ChartScatterDot[]
|
|
412
|
+
/** Bar orientation. `'horizontal'` uses `valueTicks` / `catTicks` below. */
|
|
413
|
+
orientation: 'vertical' | 'horizontal'
|
|
414
|
+
/** Horizontal bars: value-axis ticks along the bottom (label + x). */
|
|
415
|
+
valueTicks: ChartCategoryTick[]
|
|
416
|
+
/** Horizontal bars: category labels down the left (label + y; value = index). */
|
|
417
|
+
catTicks: ChartAxisTick[]
|
|
418
|
+
/** Horizontal bars: vertical reference / target lines (positioned by x). */
|
|
419
|
+
referenceLinesV: ChartRefLineGeoV[]
|
|
420
|
+
/** Trend / moving-average overlay lines (parallel to `lines`). Drawn
|
|
421
|
+
* dashed on top of their source series. */
|
|
422
|
+
overlays: ChartLine[]
|
|
423
|
+
/** Pinned annotation labels with pre-resolved screen coordinates. */
|
|
424
|
+
annotations: Array<{ x: number; y: number; label: string; color: string; placement: 'top' | 'bottom' | 'left' | 'right' }>
|
|
425
|
+
/** Heatmap cells (type === 'heatmap'). */
|
|
426
|
+
heatmapCells: ChartHeatmapCell[]
|
|
427
|
+
/** Heatmap row labels with pre-resolved y positions (left gutter). */
|
|
428
|
+
heatmapRowTicks: ChartAxisTick[]
|
|
429
|
+
/** Heatmap column labels (bottom of plot). */
|
|
430
|
+
heatmapColTicks: ChartCategoryTick[]
|
|
431
|
+
/** Heatmap color-scale legend: ordered stops with value + color. */
|
|
432
|
+
heatmapLegend: Array<{ value: number; color: string; label: string }>
|
|
433
|
+
/** Funnel segments (type === 'funnel'). */
|
|
434
|
+
funnelSegments: ChartFunnelSegment[]
|
|
435
|
+
/** Radar concentric grid rings (centred at `radarCenter`). */
|
|
436
|
+
radarRings: number[]
|
|
437
|
+
/** Radar axis labels + spoke endpoints. */
|
|
438
|
+
radarAxes: ChartRadarAxis[]
|
|
439
|
+
/** Radar series polygons. */
|
|
440
|
+
radarSeries: ChartRadarSeries[]
|
|
441
|
+
/** Centre of the radar / pie. Pre-computed so callers don't re-derive. */
|
|
442
|
+
radarCenter: { cx: number; cy: number; r: number } | null
|
|
443
|
+
/** Tree-map cells (type === 'treemap'). */
|
|
444
|
+
treemapCells: ChartTreemapCell[]
|
|
445
|
+
/** Calendar heatmap (type === 'calendar'). */
|
|
446
|
+
calendarCells: ChartCalendarCell[]
|
|
447
|
+
/** Calendar month labels along the top. */
|
|
448
|
+
calendarMonthTicks: ChartCategoryTick[]
|
|
449
|
+
/** Calendar legend stops (sequential ramp). */
|
|
450
|
+
calendarLegend: Array<{ value: number; color: string; label: string }>
|
|
451
|
+
/** Gauge layout (type === 'gauge'). Null when not a gauge. */
|
|
452
|
+
gauge: ChartGaugeLayout | null
|
|
453
|
+
/** Sankey nodes (type === 'sankey'). */
|
|
454
|
+
sankeyNodes: ChartSankeyNode[]
|
|
455
|
+
/** Sankey links (type === 'sankey'). */
|
|
456
|
+
sankeyLinks: ChartSankeyLink[]
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export const DEFAULT_PALETTE = [
|
|
460
|
+
'#2563eb',
|
|
461
|
+
'#16a34a',
|
|
462
|
+
'#f59e0b',
|
|
463
|
+
'#ef4444',
|
|
464
|
+
'#8b5cf6',
|
|
465
|
+
'#0ea5e9',
|
|
466
|
+
'#ec4899',
|
|
467
|
+
'#14b8a6',
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
function round(n: number): number {
|
|
471
|
+
return Math.round(n * 100) / 100
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function niceNum(range: number, roundIt: boolean): number {
|
|
475
|
+
if (range <= 0) return 1
|
|
476
|
+
const exp = Math.floor(Math.log10(range))
|
|
477
|
+
const f = range / Math.pow(10, exp)
|
|
478
|
+
let nf: number
|
|
479
|
+
if (roundIt) nf = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10
|
|
480
|
+
else nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
|
|
481
|
+
return nf * Math.pow(10, exp)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export type NiceScale = { min: number; max: number; step: number; ticks: number[] }
|
|
485
|
+
|
|
486
|
+
// ---- Color helpers for heatmap / pattern fills ----------------------
|
|
487
|
+
|
|
488
|
+
/** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
|
|
489
|
+
const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a']
|
|
490
|
+
/** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
|
|
491
|
+
const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8']
|
|
492
|
+
/** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
|
|
493
|
+
* above the dark grid surface instead of near-white, so empty / low cells read
|
|
494
|
+
* as "cold" rather than as glaring white rectangles. */
|
|
495
|
+
const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd']
|
|
496
|
+
const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa']
|
|
497
|
+
|
|
498
|
+
function resolveColorScale(
|
|
499
|
+
scale: 'sequential' | 'diverging' | string[] | undefined,
|
|
500
|
+
vMin: number,
|
|
501
|
+
vMax: number,
|
|
502
|
+
theme: 'light' | 'dark' = 'light',
|
|
503
|
+
): string[] {
|
|
504
|
+
if (Array.isArray(scale) && scale.length >= 2) return scale
|
|
505
|
+
const dark = theme === 'dark'
|
|
506
|
+
if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
|
|
507
|
+
return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS
|
|
508
|
+
}
|
|
509
|
+
return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Sample a hex color from an array of hex stops at fractional position t.
|
|
513
|
+
* Linearly interpolates between the two nearest stops in RGB space. */
|
|
514
|
+
export function sampleGradient(stops: string[], t: number): string {
|
|
515
|
+
if (!stops.length) return '#888'
|
|
516
|
+
const clamped = Math.max(0, Math.min(1, t))
|
|
517
|
+
if (stops.length === 1) return stops[0]!
|
|
518
|
+
const pos = clamped * (stops.length - 1)
|
|
519
|
+
const i = Math.floor(pos)
|
|
520
|
+
const frac = pos - i
|
|
521
|
+
const a = hexToRgb(stops[i]!)
|
|
522
|
+
const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]!)
|
|
523
|
+
if (!a || !b) return stops[i] ?? '#888'
|
|
524
|
+
const lerp = (x: number, y: number) => Math.round(x + (y - x) * frac)
|
|
525
|
+
const toHex = (n: number) => n.toString(16).padStart(2, '0')
|
|
526
|
+
return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
530
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
|
|
531
|
+
if (!m) return null
|
|
532
|
+
const n = parseInt(m[1]!, 16)
|
|
533
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Pick a black or white text color that has the better contrast against
|
|
537
|
+
* the given background. Uses the WCAG relative-luminance heuristic. */
|
|
538
|
+
export function pickContrastText(bgHex: string): string {
|
|
539
|
+
const rgb = hexToRgb(bgHex)
|
|
540
|
+
if (!rgb) return '#0f172a'
|
|
541
|
+
const lin = (c: number) => {
|
|
542
|
+
const s = c / 255
|
|
543
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
544
|
+
}
|
|
545
|
+
const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
|
|
546
|
+
return L > 0.5 ? '#0f172a' : '#ffffff'
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Pick the largest power of 10 that fits at the bottom of [min,max], and
|
|
550
|
+
* the smallest that covers the top, then enumerate decade boundaries. Used
|
|
551
|
+
* by log-scale axes (yScale: 'log'). */
|
|
552
|
+
export function niceLogScale(min: number, max: number): NiceScale {
|
|
553
|
+
// Only positive values are plottable on a log scale; callers should
|
|
554
|
+
// strip non-positive values before passing them in.
|
|
555
|
+
if (!Number.isFinite(min) || min <= 0) min = 1
|
|
556
|
+
if (!Number.isFinite(max) || max <= min) max = min * 10
|
|
557
|
+
const lo = Math.floor(Math.log10(min))
|
|
558
|
+
const hi = Math.ceil(Math.log10(max))
|
|
559
|
+
const ticks: number[] = []
|
|
560
|
+
for (let p = lo; p <= hi; p += 1) ticks.push(Math.pow(10, p))
|
|
561
|
+
return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Map a value to a fractional position [0..1] across the axis domain.
|
|
565
|
+
* Pass the appropriate fn into projection code so linear / log share the
|
|
566
|
+
* same plumbing. Returns null for non-positive values on log. */
|
|
567
|
+
function project(value: number, min: number, max: number, isLog: boolean): number | null {
|
|
568
|
+
if (!Number.isFinite(value)) return null
|
|
569
|
+
if (isLog) {
|
|
570
|
+
if (value <= 0 || min <= 0) return null
|
|
571
|
+
return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min))
|
|
572
|
+
}
|
|
573
|
+
return (value - min) / (max - min)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---- Overlay math: trendline + moving averages -----------------------
|
|
577
|
+
|
|
578
|
+
/** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
|
|
579
|
+
* Returns the fitted value at each x index, or NaN where the source value
|
|
580
|
+
* was non-finite. */
|
|
581
|
+
/** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
|
|
582
|
+
* monotone cubic interpolation (preserves local extrema - no overshoots).
|
|
583
|
+
* Breaks the path at `defined === false` gaps. */
|
|
584
|
+
export function buildLinePath(
|
|
585
|
+
pts: Array<{ x: number; y: number; defined: boolean }>,
|
|
586
|
+
smooth: boolean,
|
|
587
|
+
): string {
|
|
588
|
+
if (!smooth) {
|
|
589
|
+
let path = ''
|
|
590
|
+
let pen = false
|
|
591
|
+
for (const p of pts) {
|
|
592
|
+
if (!p.defined) { pen = false; continue }
|
|
593
|
+
path += `${pen ? 'L' : 'M'}${p.x},${p.y} `
|
|
594
|
+
pen = true
|
|
595
|
+
}
|
|
596
|
+
return path.trim()
|
|
597
|
+
}
|
|
598
|
+
// Group defined-only runs; each run is smoothed independently.
|
|
599
|
+
const runs: Array<Array<{ x: number; y: number }>> = []
|
|
600
|
+
let cur: Array<{ x: number; y: number }> = []
|
|
601
|
+
for (const p of pts) {
|
|
602
|
+
if (p.defined) cur.push({ x: p.x, y: p.y })
|
|
603
|
+
else if (cur.length) { runs.push(cur); cur = [] }
|
|
604
|
+
}
|
|
605
|
+
if (cur.length) runs.push(cur)
|
|
606
|
+
return runs.map(monotoneCubicPath).filter(Boolean).join(' ')
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
|
|
610
|
+
* Slope at each point chosen so the curve passes through every (xi, yi)
|
|
611
|
+
* AND stays monotonic between them; control points sit 1/3 of the way
|
|
612
|
+
* to the neighbours along that tangent. */
|
|
613
|
+
function monotoneCubicPath(pts: Array<{ x: number; y: number }>): string {
|
|
614
|
+
const n = pts.length
|
|
615
|
+
if (n === 0) return ''
|
|
616
|
+
if (n === 1) return `M${pts[0]!.x},${pts[0]!.y}`
|
|
617
|
+
if (n === 2) return `M${pts[0]!.x},${pts[0]!.y} L${pts[1]!.x},${pts[1]!.y}`
|
|
618
|
+
// Secant slopes between adjacent points.
|
|
619
|
+
const dx: number[] = new Array(n - 1)
|
|
620
|
+
const m: number[] = new Array(n - 1)
|
|
621
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
622
|
+
const d = pts[i + 1]!.x - pts[i]!.x
|
|
623
|
+
dx[i] = d
|
|
624
|
+
m[i] = d === 0 ? 0 : (pts[i + 1]!.y - pts[i]!.y) / d
|
|
625
|
+
}
|
|
626
|
+
// Tangent at each point: average of neighbouring slopes, with sign
|
|
627
|
+
// checks that flatten the tangent when slopes change sign.
|
|
628
|
+
const tan: number[] = new Array(n)
|
|
629
|
+
tan[0] = m[0]!
|
|
630
|
+
tan[n - 1] = m[n - 2]!
|
|
631
|
+
for (let i = 1; i < n - 1; i += 1) {
|
|
632
|
+
if (m[i - 1]! * m[i]! <= 0) tan[i] = 0
|
|
633
|
+
else tan[i] = (m[i - 1]! + m[i]!) / 2
|
|
634
|
+
}
|
|
635
|
+
// Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
|
|
636
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
637
|
+
if (m[i] === 0) { tan[i] = 0; tan[i + 1] = 0; continue }
|
|
638
|
+
const a = tan[i]! / m[i]!
|
|
639
|
+
const b = tan[i + 1]! / m[i]!
|
|
640
|
+
const h = Math.hypot(a, b)
|
|
641
|
+
if (h > 3) {
|
|
642
|
+
tan[i] = (3 / h) * a * m[i]!
|
|
643
|
+
tan[i + 1] = (3 / h) * b * m[i]!
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// Build the Bezier path. Each segment: control points at 1/3 of dx.
|
|
647
|
+
let path = `M${pts[0]!.x},${pts[0]!.y}`
|
|
648
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
649
|
+
const h = dx[i]!
|
|
650
|
+
const c1x = pts[i]!.x + h / 3
|
|
651
|
+
const c1y = pts[i]!.y + (tan[i]! * h) / 3
|
|
652
|
+
const c2x = pts[i + 1]!.x - h / 3
|
|
653
|
+
const c2y = pts[i + 1]!.y - (tan[i + 1]! * h) / 3
|
|
654
|
+
path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1]!.x},${pts[i + 1]!.y}`
|
|
655
|
+
}
|
|
656
|
+
return path
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function linearTrend(values: number[]): number[] {
|
|
660
|
+
let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0
|
|
661
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
662
|
+
const y = values[i]!
|
|
663
|
+
if (!Number.isFinite(y)) continue
|
|
664
|
+
n += 1; sumX += i; sumY += y; sumXX += i * i; sumXY += i * y
|
|
665
|
+
}
|
|
666
|
+
if (n < 2) return values.map(() => NaN)
|
|
667
|
+
const denom = n * sumXX - sumX * sumX
|
|
668
|
+
if (denom === 0) return values.map(() => sumY / n)
|
|
669
|
+
const slope = (n * sumXY - sumX * sumY) / denom
|
|
670
|
+
const intercept = (sumY - slope * sumX) / n
|
|
671
|
+
return values.map((_, i) => slope * i + intercept)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Simple moving average over a window of `period` values. Window centres
|
|
675
|
+
* trail to the right (typical for time-series). NaN for points before the
|
|
676
|
+
* window is full. */
|
|
677
|
+
export function simpleMovingAverage(values: number[], period: number): number[] {
|
|
678
|
+
if (period < 1) return values.slice()
|
|
679
|
+
const out: number[] = new Array(values.length).fill(NaN)
|
|
680
|
+
let sum = 0, count = 0
|
|
681
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
682
|
+
const v = values[i]!
|
|
683
|
+
if (Number.isFinite(v)) { sum += v; count += 1 }
|
|
684
|
+
if (i >= period) {
|
|
685
|
+
const drop = values[i - period]!
|
|
686
|
+
if (Number.isFinite(drop)) { sum -= drop; count -= 1 }
|
|
687
|
+
}
|
|
688
|
+
if (i >= period - 1 && count > 0) out[i] = sum / count
|
|
689
|
+
}
|
|
690
|
+
return out
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
|
|
694
|
+
export function exponentialMovingAverage(values: number[], period: number): number[] {
|
|
695
|
+
const alpha = 2 / (Math.max(1, period) + 1)
|
|
696
|
+
const out: number[] = new Array(values.length).fill(NaN)
|
|
697
|
+
let prev: number | null = null
|
|
698
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
699
|
+
const v = values[i]!
|
|
700
|
+
if (!Number.isFinite(v)) { out[i] = prev ?? NaN; continue }
|
|
701
|
+
prev = prev == null ? v : alpha * v + (1 - alpha) * prev
|
|
702
|
+
out[i] = prev
|
|
703
|
+
}
|
|
704
|
+
return out
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
|
|
708
|
+
export function computeOverlay(values: number[], spec: SeriesOverlay): number[] {
|
|
709
|
+
if (spec === 'linear') return linearTrend(values)
|
|
710
|
+
const m = /^(sma|ema):(\d+)$/.exec(spec)
|
|
711
|
+
if (!m) return values.map(() => NaN)
|
|
712
|
+
const period = Number(m[2])
|
|
713
|
+
return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Round a [min,max] domain out to nice tick boundaries. */
|
|
717
|
+
export function niceScale(min: number, max: number, tickCount = 4): NiceScale {
|
|
718
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
719
|
+
min = 0
|
|
720
|
+
max = 1
|
|
721
|
+
}
|
|
722
|
+
if (min === max) {
|
|
723
|
+
if (min === 0) max = 1
|
|
724
|
+
else {
|
|
725
|
+
min = Math.min(0, min)
|
|
726
|
+
max = Math.max(0, max)
|
|
727
|
+
}
|
|
728
|
+
if (min === max) max = min + 1
|
|
729
|
+
}
|
|
730
|
+
const range = niceNum(max - min, false)
|
|
731
|
+
const step = niceNum(range / Math.max(1, tickCount), true)
|
|
732
|
+
const nMin = Math.floor(min / step) * step
|
|
733
|
+
const nMax = Math.ceil(max / step) * step
|
|
734
|
+
const ticks: number[] = []
|
|
735
|
+
for (let v = nMin; v <= nMax + step * 0.5; v += step) ticks.push(round(v))
|
|
736
|
+
return { min: nMin, max: nMax, step, ticks }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function fmtTick(n: number): string {
|
|
740
|
+
const abs = Math.abs(n)
|
|
741
|
+
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(abs % 1_000_000 ? 1 : 0)}M`
|
|
742
|
+
if (abs >= 1_000) return `${(n / 1_000).toFixed(abs % 1_000 ? 1 : 0)}k`
|
|
743
|
+
return String(Math.round(n * 100) / 100)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Value-axis / tooltip / label number format. */
|
|
747
|
+
export type ChartValueFormat = 'number' | 'currency' | 'percent' | 'compact'
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Format a numeric value for display, honouring an optional `valueFormat`.
|
|
751
|
+
* Builds on the compact `1.2k` / `1.2M` base: currency prefixes `$` (sign
|
|
752
|
+
* outside), percent multiplies by 100 and suffixes `%`. Unset / `'number'` /
|
|
753
|
+
* `'compact'` = the plain compact form.
|
|
754
|
+
*/
|
|
755
|
+
export function formatChartValue(n: number, format?: ChartValueFormat): string {
|
|
756
|
+
if (!Number.isFinite(n)) return ''
|
|
757
|
+
if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
|
|
758
|
+
if (format === 'percent') {
|
|
759
|
+
const p = n * 100
|
|
760
|
+
return `${Math.round(p * 10) / 10}%`
|
|
761
|
+
}
|
|
762
|
+
return fmtTick(n)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const DAY = 86_400_000
|
|
766
|
+
/** Nice date-tick timestamps across [min, max]. */
|
|
767
|
+
function dateTicks(tMin: number, tMax: number): number[] {
|
|
768
|
+
const span = tMax - tMin
|
|
769
|
+
const step =
|
|
770
|
+
span <= 7 * DAY ? DAY : span <= 70 * DAY ? 7 * DAY : span <= 800 * DAY ? 30 * DAY : 365 * DAY
|
|
771
|
+
const ticks: number[] = []
|
|
772
|
+
for (let t = Math.ceil(tMin / step) * step; t <= tMax + 1; t += step) ticks.push(t)
|
|
773
|
+
if (!ticks.length) ticks.push(tMin, tMax)
|
|
774
|
+
return ticks
|
|
775
|
+
}
|
|
776
|
+
function fmtDate(t: number, span: number): string {
|
|
777
|
+
const d = new Date(t)
|
|
778
|
+
if (span <= 70 * DAY) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
779
|
+
if (span <= 800 * DAY) return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
|
|
780
|
+
return String(d.getFullYear())
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
type ResolvedSeries = ChartSeries & {
|
|
784
|
+
color: string
|
|
785
|
+
kind: 'bar' | 'line' | 'area'
|
|
786
|
+
axis: 'left' | 'right'
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Data domain for one axis, honoring stacking of its bar/area series.
|
|
790
|
+
* When `isLog` is true, non-positive values are discarded (log undefined)
|
|
791
|
+
* and the domain is rounded to decade boundaries instead of nice steps. */
|
|
792
|
+
function axisDomain(
|
|
793
|
+
list: ResolvedSeries[],
|
|
794
|
+
categories: string[],
|
|
795
|
+
stacked: boolean,
|
|
796
|
+
extra: number[] = [],
|
|
797
|
+
isLog = false,
|
|
798
|
+
): NiceScale {
|
|
799
|
+
let dMin = Infinity
|
|
800
|
+
let dMax = -Infinity
|
|
801
|
+
const note = (v: number) => {
|
|
802
|
+
if (!Number.isFinite(v)) return
|
|
803
|
+
if (isLog && v <= 0) return
|
|
804
|
+
if (v < dMin) dMin = v
|
|
805
|
+
if (v > dMax) dMax = v
|
|
806
|
+
}
|
|
807
|
+
for (const v of extra) note(v)
|
|
808
|
+
const stackable = list.filter((s) => s.kind === 'bar' || s.kind === 'area')
|
|
809
|
+
const lines = list.filter((s) => s.kind === 'line')
|
|
810
|
+
if (stacked && stackable.length) {
|
|
811
|
+
for (let i = 0; i < categories.length; i += 1) {
|
|
812
|
+
let pos = 0
|
|
813
|
+
let neg = 0
|
|
814
|
+
for (const s of stackable) {
|
|
815
|
+
const v = s.values[i] ?? 0
|
|
816
|
+
if (v >= 0) pos += v
|
|
817
|
+
else neg += v
|
|
818
|
+
}
|
|
819
|
+
note(pos)
|
|
820
|
+
note(neg)
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
for (const s of stackable) for (const v of s.values) note(v)
|
|
824
|
+
}
|
|
825
|
+
for (const s of lines) for (const v of s.values) note(v)
|
|
826
|
+
if (dMin === Infinity) {
|
|
827
|
+
dMin = isLog ? 1 : 0
|
|
828
|
+
dMax = isLog ? 10 : 1
|
|
829
|
+
}
|
|
830
|
+
// Bar / area charts read against a zero baseline, so always include 0
|
|
831
|
+
// - but only on linear axes (0 is invalid in log).
|
|
832
|
+
if (stackable.length && !isLog) {
|
|
833
|
+
dMin = Math.min(dMin, 0)
|
|
834
|
+
dMax = Math.max(dMax, 0)
|
|
835
|
+
}
|
|
836
|
+
return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax)
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'): ChartGeometry {
|
|
840
|
+
const width = spec.width ?? 520
|
|
841
|
+
const height = spec.height ?? 300
|
|
842
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
843
|
+
|
|
844
|
+
const stacked = !!(spec.stacked || spec.stacked100)
|
|
845
|
+
const series: ResolvedSeries[] = spec.series.map((s, i) => ({
|
|
846
|
+
...s,
|
|
847
|
+
color: s.color ?? palette[i % palette.length]!,
|
|
848
|
+
kind: (s.type ?? (spec.type === 'pie' || spec.type === 'scatter' ? 'bar' : spec.type)) as
|
|
849
|
+
| 'bar'
|
|
850
|
+
| 'line'
|
|
851
|
+
| 'area',
|
|
852
|
+
axis: s.axis ?? 'left',
|
|
853
|
+
}))
|
|
854
|
+
const legend: ChartLegendItem[] = series.map((s) => ({ label: s.label, color: s.color }))
|
|
855
|
+
|
|
856
|
+
const empty: ChartGeometry = {
|
|
857
|
+
type: spec.type,
|
|
858
|
+
width,
|
|
859
|
+
height,
|
|
860
|
+
plot: { x: 0, y: 0, w: width, h: height },
|
|
861
|
+
bars: [],
|
|
862
|
+
lines: [],
|
|
863
|
+
slices: [],
|
|
864
|
+
yTicks: [],
|
|
865
|
+
y2Ticks: [],
|
|
866
|
+
hasRightAxis: false,
|
|
867
|
+
xTicks: [],
|
|
868
|
+
categoryGroupTicks: [],
|
|
869
|
+
xLabelRotated: false,
|
|
870
|
+
legend,
|
|
871
|
+
donut: null,
|
|
872
|
+
referenceLines: [],
|
|
873
|
+
scatterPoints: [],
|
|
874
|
+
orientation: 'vertical',
|
|
875
|
+
valueTicks: [],
|
|
876
|
+
catTicks: [],
|
|
877
|
+
referenceLinesV: [],
|
|
878
|
+
overlays: [],
|
|
879
|
+
annotations: [],
|
|
880
|
+
heatmapCells: [],
|
|
881
|
+
heatmapRowTicks: [],
|
|
882
|
+
heatmapColTicks: [],
|
|
883
|
+
heatmapLegend: [],
|
|
884
|
+
funnelSegments: [],
|
|
885
|
+
radarRings: [],
|
|
886
|
+
radarAxes: [],
|
|
887
|
+
radarSeries: [],
|
|
888
|
+
radarCenter: null,
|
|
889
|
+
treemapCells: [],
|
|
890
|
+
calendarCells: [],
|
|
891
|
+
calendarMonthTicks: [],
|
|
892
|
+
calendarLegend: [],
|
|
893
|
+
gauge: null,
|
|
894
|
+
sankeyNodes: [],
|
|
895
|
+
sankeyLinks: [],
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ---- Waterfall ----------------------------------------------------
|
|
899
|
+
// First series provides the values. Each non-total bar starts at the
|
|
900
|
+
// running cumulative sum; total bars (waterfallTotals[i]) reset and
|
|
901
|
+
// span from 0 to that sum. Color is derived from sign + total flag, with
|
|
902
|
+
// optional palette overrides via spec.waterfallColors.
|
|
903
|
+
if (spec.type === 'waterfall') {
|
|
904
|
+
const src = series[0]
|
|
905
|
+
if (!src) return { ...empty }
|
|
906
|
+
const colors = spec.waterfallColors ?? {}
|
|
907
|
+
const positive = colors.positive ?? '#16a34a'
|
|
908
|
+
const negative = colors.negative ?? '#ef4444'
|
|
909
|
+
const total = colors.total ?? '#475569'
|
|
910
|
+
|
|
911
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
912
|
+
const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
|
|
913
|
+
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
914
|
+
const padR = 12
|
|
915
|
+
const padT = 10
|
|
916
|
+
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
|
|
917
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
918
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
919
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
920
|
+
|
|
921
|
+
// Compute the running cumulative + per-bar (from, to) pairs.
|
|
922
|
+
const totals = spec.waterfallTotals ?? []
|
|
923
|
+
const pairs: Array<{ from: number; to: number; value: number; isTotal: boolean }> = []
|
|
924
|
+
let cum = 0
|
|
925
|
+
spec.categories.forEach((_, i) => {
|
|
926
|
+
const v = src.values[i] ?? 0
|
|
927
|
+
const isTotal = !!totals[i]
|
|
928
|
+
if (isTotal) {
|
|
929
|
+
pairs.push({ from: 0, to: cum, value: cum, isTotal: true })
|
|
930
|
+
} else {
|
|
931
|
+
pairs.push({ from: cum, to: cum + v, value: v, isTotal: false })
|
|
932
|
+
cum += v
|
|
933
|
+
}
|
|
934
|
+
})
|
|
935
|
+
// Y-axis domain spans every visited level (including 0).
|
|
936
|
+
let dMin = 0, dMax = 0
|
|
937
|
+
for (const p of pairs) {
|
|
938
|
+
if (p.from < dMin) dMin = p.from
|
|
939
|
+
if (p.to < dMin) dMin = p.to
|
|
940
|
+
if (p.from > dMax) dMax = p.from
|
|
941
|
+
if (p.to > dMax) dMax = p.to
|
|
942
|
+
}
|
|
943
|
+
const dom = niceScale(dMin, dMax)
|
|
944
|
+
const yOfW = (v: number) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH)
|
|
945
|
+
|
|
946
|
+
const slotW = plotW / Math.max(1, spec.categories.length)
|
|
947
|
+
const barPad = slotW * 0.2
|
|
948
|
+
const barW = Math.max(1, slotW - barPad)
|
|
949
|
+
const bars: ChartBar[] = pairs.map((p, i) => {
|
|
950
|
+
const yTop = yOfW(Math.max(p.from, p.to))
|
|
951
|
+
const yBot = yOfW(Math.min(p.from, p.to))
|
|
952
|
+
const x = padL + slotW * i + barPad / 2
|
|
953
|
+
const color = p.isTotal ? total : p.value >= 0 ? positive : negative
|
|
954
|
+
return {
|
|
955
|
+
x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
|
|
956
|
+
color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
|
|
957
|
+
}
|
|
958
|
+
})
|
|
959
|
+
// Thin connector lines between bar tops -> running total reads cleanly.
|
|
960
|
+
const connectors: ChartLine[] = [{
|
|
961
|
+
path: pairs
|
|
962
|
+
.map((p, i) => {
|
|
963
|
+
const x0 = padL + slotW * i + barPad / 2 + barW
|
|
964
|
+
const y = yOfW(p.to)
|
|
965
|
+
const x1 = padL + slotW * (i + 1) + barPad / 2
|
|
966
|
+
// Skip the final connector beyond the last bar.
|
|
967
|
+
return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : ''
|
|
968
|
+
})
|
|
969
|
+
.filter(Boolean)
|
|
970
|
+
.join(' '),
|
|
971
|
+
areaPath: '',
|
|
972
|
+
color: 'var(--sg-muted, #94a3b8)',
|
|
973
|
+
label: '',
|
|
974
|
+
points: [],
|
|
975
|
+
}]
|
|
976
|
+
const xTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
|
|
977
|
+
label,
|
|
978
|
+
x: round(padL + slotW * i + slotW / 2),
|
|
979
|
+
}))
|
|
980
|
+
const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
|
|
981
|
+
value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat),
|
|
982
|
+
}))
|
|
983
|
+
return {
|
|
984
|
+
...empty,
|
|
985
|
+
plot,
|
|
986
|
+
bars,
|
|
987
|
+
lines: connectors,
|
|
988
|
+
yTicks,
|
|
989
|
+
xTicks,
|
|
990
|
+
xLabelRotated,
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ---- Funnel -------------------------------------------------------
|
|
995
|
+
// One series of strictly-decreasing values gets rendered as a stack
|
|
996
|
+
// of horizontal trapezoids: each level's width is proportional to its
|
|
997
|
+
// value relative to the largest, slope automatically links level N+1
|
|
998
|
+
// narrower than level N. Labels show value, conversion vs. top, and
|
|
999
|
+
// step drop-off.
|
|
1000
|
+
if (spec.type === 'funnel') {
|
|
1001
|
+
const src = series[0]
|
|
1002
|
+
if (!src || !src.values.length) return { ...empty }
|
|
1003
|
+
const padL = 20, padR = 20, padT = 16, padB = 16
|
|
1004
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1005
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1006
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1007
|
+
const n = src.values.length
|
|
1008
|
+
const stepH = plotH / n
|
|
1009
|
+
const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)))
|
|
1010
|
+
const top = src.values[0] ?? 0
|
|
1011
|
+
const widthAt = (v: number) => (valMax > 0 ? (v / valMax) * plotW : 0)
|
|
1012
|
+
const cx = padL + plotW / 2
|
|
1013
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1014
|
+
const segments: ChartFunnelSegment[] = src.values.map((v, i) => {
|
|
1015
|
+
const next = src.values[i + 1] ?? v * 0.8 // taper to a point on the last level
|
|
1016
|
+
const w0 = widthAt(v)
|
|
1017
|
+
const w1 = widthAt(next)
|
|
1018
|
+
const y0 = padT + stepH * i
|
|
1019
|
+
const y1 = y0 + stepH
|
|
1020
|
+
const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`
|
|
1021
|
+
const color = src.color ?? palette[i % palette.length]!
|
|
1022
|
+
return {
|
|
1023
|
+
path, color,
|
|
1024
|
+
label: spec.categories[i] ?? src.label,
|
|
1025
|
+
value: v,
|
|
1026
|
+
conversion: top > 0 ? v / top : 0,
|
|
1027
|
+
dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
|
|
1028
|
+
cx,
|
|
1029
|
+
cy: (y0 + y1) / 2,
|
|
1030
|
+
textColor: pickContrastText(color),
|
|
1031
|
+
}
|
|
1032
|
+
})
|
|
1033
|
+
return {
|
|
1034
|
+
...empty,
|
|
1035
|
+
plot,
|
|
1036
|
+
funnelSegments: segments,
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// ---- Radar --------------------------------------------------------
|
|
1041
|
+
// Polar coordinates: each `category` is a spoke (axis); each `series`
|
|
1042
|
+
// contributes a polygon connecting its values across the spokes. All
|
|
1043
|
+
// series share the same scale (max across every value). Concentric
|
|
1044
|
+
// ring count derived from data, capped at 5 for legibility.
|
|
1045
|
+
if (spec.type === 'radar') {
|
|
1046
|
+
if (!series.length || !spec.categories.length) return { ...empty }
|
|
1047
|
+
const padL = 30, padR = 30, padT = 24, padB = 24
|
|
1048
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1049
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1050
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1051
|
+
const cx = padL + plotW / 2
|
|
1052
|
+
const cy = padT + plotH / 2
|
|
1053
|
+
const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20)
|
|
1054
|
+
const axes = spec.categories
|
|
1055
|
+
const k = axes.length
|
|
1056
|
+
let vMax = 0
|
|
1057
|
+
for (const s of series) for (const v of s.values) {
|
|
1058
|
+
if (Number.isFinite(v) && v > vMax) vMax = v
|
|
1059
|
+
}
|
|
1060
|
+
if (vMax === 0) vMax = 1
|
|
1061
|
+
const ringCount = 5
|
|
1062
|
+
const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax)
|
|
1063
|
+
/** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
|
|
1064
|
+
* proceed clockwise so axes lay out left-to-right when k <= 4. */
|
|
1065
|
+
const angleAt = (i: number) => -Math.PI / 2 + (i / k) * Math.PI * 2
|
|
1066
|
+
const pointAt = (i: number, v: number) => {
|
|
1067
|
+
const t = v / vMax
|
|
1068
|
+
const a = angleAt(i)
|
|
1069
|
+
return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) }
|
|
1070
|
+
}
|
|
1071
|
+
const radarAxes: ChartRadarAxis[] = axes.map((label, i) => {
|
|
1072
|
+
const p = pointAt(i, vMax)
|
|
1073
|
+
return { label, x: p.x, y: p.y }
|
|
1074
|
+
})
|
|
1075
|
+
const radarSeriesGeo: ChartRadarSeries[] = series.map((s, si) => {
|
|
1076
|
+
const pts = s.values.map((v, i) => {
|
|
1077
|
+
const safe = Number.isFinite(v) ? v : 0
|
|
1078
|
+
const p = pointAt(i, safe)
|
|
1079
|
+
return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' }
|
|
1080
|
+
})
|
|
1081
|
+
const path = pts.length
|
|
1082
|
+
? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
|
|
1083
|
+
: ''
|
|
1084
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1085
|
+
return { label: s.label, color: s.color ?? palette[si % palette.length]!, path, points: pts }
|
|
1086
|
+
})
|
|
1087
|
+
return {
|
|
1088
|
+
...empty,
|
|
1089
|
+
plot,
|
|
1090
|
+
radarRings: ringValues,
|
|
1091
|
+
radarAxes,
|
|
1092
|
+
radarSeries: radarSeriesGeo,
|
|
1093
|
+
radarCenter: { cx, cy, r },
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ---- Calendar heatmap --------------------------------------------
|
|
1098
|
+
// GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
|
|
1099
|
+
// cell is a small square shaded by `calendarValues[i].value` via the
|
|
1100
|
+
// sequential color scale. Days with no value render blank (border only)
|
|
1101
|
+
// so missing data is visually obvious.
|
|
1102
|
+
if (spec.type === 'calendar') {
|
|
1103
|
+
const values = spec.calendarValues ?? []
|
|
1104
|
+
if (!values.length && !spec.calendarStart) return { ...empty }
|
|
1105
|
+
// Build a value lookup + figure out the date range.
|
|
1106
|
+
const valueByDate = new Map<string, number>()
|
|
1107
|
+
let vMin = Infinity, vMax = -Infinity
|
|
1108
|
+
for (const v of values) {
|
|
1109
|
+
valueByDate.set(v.date, v.value)
|
|
1110
|
+
if (Number.isFinite(v.value)) {
|
|
1111
|
+
if (v.value < vMin) vMin = v.value
|
|
1112
|
+
if (v.value > vMax) vMax = v.value
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (vMin === Infinity) { vMin = 0; vMax = 1 }
|
|
1116
|
+
if (vMin === vMax) vMax = vMin + 1
|
|
1117
|
+
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
|
|
1118
|
+
const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
|
|
1119
|
+
// Determine date range. If calendarStart/End set, use them, otherwise
|
|
1120
|
+
// span the data + round to whole weeks (Sun..Sat).
|
|
1121
|
+
const sorted = values.map((v) => v.date).sort()
|
|
1122
|
+
const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01'
|
|
1123
|
+
const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr
|
|
1124
|
+
const start = new Date(startStr + 'T00:00:00Z')
|
|
1125
|
+
const end = new Date(endStr + 'T00:00:00Z')
|
|
1126
|
+
// Roll start back to its Sunday, end forward to its Saturday.
|
|
1127
|
+
start.setUTCDate(start.getUTCDate() - start.getUTCDay())
|
|
1128
|
+
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()))
|
|
1129
|
+
const totalDays = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1
|
|
1130
|
+
const weeks = Math.ceil(totalDays / 7)
|
|
1131
|
+
const padL = 36, padR = 80, padT = 26, padB = 16
|
|
1132
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1133
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1134
|
+
// Cell size: fit weeks across width, 7 rows down height.
|
|
1135
|
+
const cellW = Math.floor(plotW / weeks)
|
|
1136
|
+
const cellH = Math.floor(plotH / 7)
|
|
1137
|
+
const cellSize = Math.max(6, Math.min(cellW, cellH))
|
|
1138
|
+
const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 }
|
|
1139
|
+
const cells: ChartCalendarCell[] = []
|
|
1140
|
+
let lastMonth = -1
|
|
1141
|
+
const monthTicks: ChartCategoryTick[] = []
|
|
1142
|
+
for (let i = 0; i < totalDays; i += 1) {
|
|
1143
|
+
const day = new Date(start.getTime() + i * 86_400_000)
|
|
1144
|
+
const col = Math.floor(i / 7)
|
|
1145
|
+
const row = i % 7
|
|
1146
|
+
const date = day.toISOString().slice(0, 10)
|
|
1147
|
+
const has = valueByDate.has(date)
|
|
1148
|
+
const v = valueByDate.get(date) ?? 0
|
|
1149
|
+
cells.push({
|
|
1150
|
+
x: padL + col * cellSize,
|
|
1151
|
+
y: padT + row * cellSize,
|
|
1152
|
+
size: cellSize,
|
|
1153
|
+
date, value: v,
|
|
1154
|
+
defined: has,
|
|
1155
|
+
color: has ? colorAt(v) : 'transparent',
|
|
1156
|
+
})
|
|
1157
|
+
if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
|
|
1158
|
+
lastMonth = day.getUTCMonth()
|
|
1159
|
+
monthTicks.push({
|
|
1160
|
+
label: day.toLocaleDateString(undefined, { month: 'short' }),
|
|
1161
|
+
x: padL + col * cellSize,
|
|
1162
|
+
})
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const legend = Array.from({ length: 5 }, (_, i) => {
|
|
1166
|
+
const t = i / 4
|
|
1167
|
+
const value = vMin + (vMax - vMin) * t
|
|
1168
|
+
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
|
|
1169
|
+
})
|
|
1170
|
+
return {
|
|
1171
|
+
...empty,
|
|
1172
|
+
plot,
|
|
1173
|
+
calendarCells: cells,
|
|
1174
|
+
calendarMonthTicks: monthTicks,
|
|
1175
|
+
calendarLegend: legend,
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// ---- Gauge --------------------------------------------------------
|
|
1180
|
+
// Semicircle dial: track arc + value arc + optional colored range bands
|
|
1181
|
+
// + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
|
|
1182
|
+
if (spec.type === 'gauge') {
|
|
1183
|
+
const min = spec.gaugeMin ?? 0
|
|
1184
|
+
const max = spec.gaugeMax ?? 100
|
|
1185
|
+
const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0))
|
|
1186
|
+
const target = spec.gaugeTarget
|
|
1187
|
+
const cx = width / 2
|
|
1188
|
+
const cy = height * 0.78
|
|
1189
|
+
const r = Math.min(width * 0.42, height * 0.65)
|
|
1190
|
+
// Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
|
|
1191
|
+
const A0 = Math.PI
|
|
1192
|
+
const A1 = 2 * Math.PI
|
|
1193
|
+
const angleAt = (v: number) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0)
|
|
1194
|
+
const arc = (a0: number, a1: number, radius: number): string => {
|
|
1195
|
+
const x1 = cx + radius * Math.cos(a0)
|
|
1196
|
+
const y1 = cy + radius * Math.sin(a0)
|
|
1197
|
+
const x2 = cx + radius * Math.cos(a1)
|
|
1198
|
+
const y2 = cy + radius * Math.sin(a1)
|
|
1199
|
+
const large = a1 - a0 > Math.PI ? 1 : 0
|
|
1200
|
+
return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`
|
|
1201
|
+
}
|
|
1202
|
+
const trackPath = arc(A0, A1, r)
|
|
1203
|
+
const valuePath = arc(A0, angleAt(value), r)
|
|
1204
|
+
const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
|
|
1205
|
+
path: arc(angleAt(band.from), angleAt(band.to), r - 9),
|
|
1206
|
+
color: band.color, from: band.from, to: band.to,
|
|
1207
|
+
}))
|
|
1208
|
+
let targetPx: ChartGaugeLayout['target'] = null
|
|
1209
|
+
if (target != null && Number.isFinite(target)) {
|
|
1210
|
+
const a = angleAt(Math.max(min, Math.min(max, target)))
|
|
1211
|
+
const inner = r - 12
|
|
1212
|
+
const outer = r + 4
|
|
1213
|
+
targetPx = {
|
|
1214
|
+
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
1215
|
+
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// Tick marks just outside the track: a major tick every 1/4 of the scale,
|
|
1219
|
+
// with 4 minor ticks between each. Gives the dial a measured, instrument feel.
|
|
1220
|
+
const ticks: ChartGaugeLayout['ticks'] = []
|
|
1221
|
+
const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR
|
|
1222
|
+
for (let i = 0; i <= TICK_TOTAL; i++) {
|
|
1223
|
+
const a = A0 + (i / TICK_TOTAL) * (A1 - A0)
|
|
1224
|
+
const major = i % TICK_MINOR === 0
|
|
1225
|
+
const inner = r + 9
|
|
1226
|
+
const outer = r + (major ? 17 : 13)
|
|
1227
|
+
ticks.push({
|
|
1228
|
+
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
1229
|
+
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
1230
|
+
major,
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
1233
|
+
// Pointer needle: a kite (long tip toward the value, short counterweight
|
|
1234
|
+
// tail) pivoting on a center hub.
|
|
1235
|
+
const aV = angleAt(value)
|
|
1236
|
+
const tipR = r - 16, tailR = 18, baseR = 6
|
|
1237
|
+
const aPerp = aV + Math.PI / 2
|
|
1238
|
+
const pt = (rad: number, ang: number) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`
|
|
1239
|
+
const needlePath =
|
|
1240
|
+
`M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`
|
|
1241
|
+
// Color the value arc by the band the value currently sits in.
|
|
1242
|
+
let valueColor: string | null = null
|
|
1243
|
+
for (const band of spec.gaugeRanges ?? []) {
|
|
1244
|
+
if (value >= band.from && value <= band.to) valueColor = band.color
|
|
1245
|
+
}
|
|
1246
|
+
return {
|
|
1247
|
+
...empty,
|
|
1248
|
+
plot: { x: 0, y: 0, w: width, h: height },
|
|
1249
|
+
gauge: {
|
|
1250
|
+
cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
|
|
1251
|
+
ticks, needle: { path: needlePath, hubR: 7 }, valueColor,
|
|
1252
|
+
minLabel: { x: cx - r, y: cy + 20 },
|
|
1253
|
+
maxLabel: { x: cx + r, y: cy + 20 },
|
|
1254
|
+
value, min, max, unit: spec.gaugeUnit ?? '',
|
|
1255
|
+
},
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ---- Tree-map -----------------------------------------------------
|
|
1260
|
+
// Squarified tree-map (Bruls et al. 2000): each level recursively
|
|
1261
|
+
// partitions its rectangle in proportion to its children, picking the
|
|
1262
|
+
// split orientation that keeps aspect ratios closest to 1.
|
|
1263
|
+
if (spec.type === 'treemap') {
|
|
1264
|
+
const root = spec.treemap
|
|
1265
|
+
if (!root) return { ...empty }
|
|
1266
|
+
const padL = 4, padR = 4, padT = 4, padB = 4
|
|
1267
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1268
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1269
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1270
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1271
|
+
const cells: ChartTreemapCell[] = []
|
|
1272
|
+
function totalOf(n: TreeNode): number {
|
|
1273
|
+
if (n.children?.length) return n.children.reduce((s, c) => s + totalOf(c), 0)
|
|
1274
|
+
return Math.max(0, n.value ?? 0)
|
|
1275
|
+
}
|
|
1276
|
+
function squarify(items: TreeNode[], x: number, y: number, w: number, h: number, depth: number) {
|
|
1277
|
+
if (!items.length || w <= 0 || h <= 0) return
|
|
1278
|
+
const totals = items.map(totalOf)
|
|
1279
|
+
const sum = totals.reduce((a, b) => a + b, 0)
|
|
1280
|
+
if (sum <= 0) return
|
|
1281
|
+
// Process largest-first so big items dominate the first row.
|
|
1282
|
+
const ordered = items
|
|
1283
|
+
.map((n, i) => ({ node: n, value: totals[i]! }))
|
|
1284
|
+
.sort((a, b) => b.value - a.value)
|
|
1285
|
+
let cx = x, cy = y, cw = w, ch = h, remaining = sum
|
|
1286
|
+
let row: typeof ordered = []
|
|
1287
|
+
const worstRatio = (vals: number[], shortSide: number, rowSum: number, scale: number): number => {
|
|
1288
|
+
if (rowSum <= 0) return Infinity
|
|
1289
|
+
const rowArea = rowSum * scale
|
|
1290
|
+
const rowSide = rowArea / shortSide
|
|
1291
|
+
let worst = 0
|
|
1292
|
+
for (const v of vals) {
|
|
1293
|
+
const cell = v * scale
|
|
1294
|
+
const long = cell / rowSide
|
|
1295
|
+
const r = Math.max(shortSide / long, long / shortSide)
|
|
1296
|
+
if (r > worst) worst = r
|
|
1297
|
+
}
|
|
1298
|
+
return worst
|
|
1299
|
+
}
|
|
1300
|
+
function flushRow() {
|
|
1301
|
+
if (!row.length) return
|
|
1302
|
+
const rowSum = row.reduce((a, b) => a + b.value, 0)
|
|
1303
|
+
const scale = (cw * ch) / remaining
|
|
1304
|
+
const horizontal = cw >= ch
|
|
1305
|
+
const shortSide = horizontal ? ch : cw
|
|
1306
|
+
const rowSide = (rowSum * scale) / shortSide
|
|
1307
|
+
let offset = 0
|
|
1308
|
+
for (const it of row) {
|
|
1309
|
+
const cellSize = (it.value * scale) / rowSide
|
|
1310
|
+
const cx2 = horizontal ? cx : cx + offset
|
|
1311
|
+
const cy2 = horizontal ? cy + offset : cy
|
|
1312
|
+
const ww = horizontal ? rowSide : cellSize
|
|
1313
|
+
const hh = horizontal ? cellSize : rowSide
|
|
1314
|
+
const color = it.node.color ?? palette[(depth + cells.length) % palette.length]!
|
|
1315
|
+
// Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
|
|
1316
|
+
if (it.node.children?.length) {
|
|
1317
|
+
cells.push({
|
|
1318
|
+
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
1319
|
+
color, textColor: pickContrastText(color),
|
|
1320
|
+
name: it.node.name, value: it.value, depth,
|
|
1321
|
+
})
|
|
1322
|
+
const labelH = Math.min(18, hh * 0.25)
|
|
1323
|
+
squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1)
|
|
1324
|
+
} else {
|
|
1325
|
+
cells.push({
|
|
1326
|
+
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
1327
|
+
color, textColor: pickContrastText(color),
|
|
1328
|
+
name: it.node.name, value: it.value, depth,
|
|
1329
|
+
})
|
|
1330
|
+
}
|
|
1331
|
+
offset += cellSize
|
|
1332
|
+
}
|
|
1333
|
+
// Shrink the remaining strip.
|
|
1334
|
+
if (horizontal) { cx += rowSide; cw -= rowSide } else { cy += rowSide; ch -= rowSide }
|
|
1335
|
+
remaining -= rowSum
|
|
1336
|
+
row = []
|
|
1337
|
+
}
|
|
1338
|
+
for (const it of ordered) {
|
|
1339
|
+
const scale = (cw * ch) / remaining
|
|
1340
|
+
const shortSide = Math.min(cw, ch)
|
|
1341
|
+
const rowSum = row.reduce((a, b) => a + b.value, 0)
|
|
1342
|
+
const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale)
|
|
1343
|
+
const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale)
|
|
1344
|
+
if (row.length && nextWorst > currWorst) {
|
|
1345
|
+
flushRow()
|
|
1346
|
+
}
|
|
1347
|
+
row.push(it)
|
|
1348
|
+
}
|
|
1349
|
+
flushRow()
|
|
1350
|
+
}
|
|
1351
|
+
const seedItems = root.children ?? [root]
|
|
1352
|
+
squarify(seedItems, padL, padT, plotW, plotH, 0)
|
|
1353
|
+
return { ...empty, plot, treemapCells: cells }
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ---- Sankey -------------------------------------------------------
|
|
1357
|
+
// Multi-column flow layout. Each node assigned to a column by longest
|
|
1358
|
+
// path from any source. Within a column, nodes are stacked vertically;
|
|
1359
|
+
// height proportional to max(totalIn, totalOut). Links render as
|
|
1360
|
+
// bezier ribbons whose width is the link value (in pixels).
|
|
1361
|
+
if (spec.type === 'sankey') {
|
|
1362
|
+
const nodes = spec.sankeyNodes ?? []
|
|
1363
|
+
const links = spec.sankeyLinks ?? []
|
|
1364
|
+
if (!nodes.length || !links.length) return { ...empty }
|
|
1365
|
+
const padL = 10, padR = 10, padT = 14, padB = 14
|
|
1366
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1367
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1368
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1369
|
+
const palette = spec.palette ?? DEFAULT_PALETTE
|
|
1370
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]))
|
|
1371
|
+
// Column = longest path from any node with no incoming edges.
|
|
1372
|
+
const targets = new Set(links.map((l) => l.target))
|
|
1373
|
+
const sources = nodes.filter((n) => !targets.has(n.id))
|
|
1374
|
+
const column = new Map<string, number>()
|
|
1375
|
+
function visit(id: string, depth: number, seen: Set<string>) {
|
|
1376
|
+
if (seen.has(id)) return
|
|
1377
|
+
seen.add(id)
|
|
1378
|
+
const cur = column.get(id) ?? 0
|
|
1379
|
+
if (depth > cur || !column.has(id)) column.set(id, depth)
|
|
1380
|
+
for (const l of links) if (l.source === id) visit(l.target, depth + 1, seen)
|
|
1381
|
+
seen.delete(id)
|
|
1382
|
+
}
|
|
1383
|
+
for (const s of sources) visit(s.id, 0, new Set())
|
|
1384
|
+
// Cover any nodes with no path from a source (orphan rings).
|
|
1385
|
+
for (const n of nodes) if (!column.has(n.id)) column.set(n.id, 0)
|
|
1386
|
+
const maxCol = Math.max(...column.values())
|
|
1387
|
+
const cols = maxCol + 1
|
|
1388
|
+
const nodeW = 14
|
|
1389
|
+
const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0
|
|
1390
|
+
// Totals per node.
|
|
1391
|
+
const totalIn = new Map<string, number>()
|
|
1392
|
+
const totalOut = new Map<string, number>()
|
|
1393
|
+
for (const l of links) {
|
|
1394
|
+
totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value)
|
|
1395
|
+
totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value)
|
|
1396
|
+
}
|
|
1397
|
+
// Per-column groups + max total in that column.
|
|
1398
|
+
const byCol: Map<number, string[]> = new Map()
|
|
1399
|
+
for (const n of nodes) {
|
|
1400
|
+
const c = column.get(n.id) ?? 0
|
|
1401
|
+
const arr = byCol.get(c) ?? []
|
|
1402
|
+
arr.push(n.id); byCol.set(c, arr)
|
|
1403
|
+
}
|
|
1404
|
+
// Per-column total height + node height scale.
|
|
1405
|
+
let maxColTotal = 0
|
|
1406
|
+
for (const ids of byCol.values()) {
|
|
1407
|
+
const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0)
|
|
1408
|
+
if (t > maxColTotal) maxColTotal = t
|
|
1409
|
+
}
|
|
1410
|
+
if (maxColTotal === 0) return { ...empty, plot }
|
|
1411
|
+
const nodeGapPx = 8
|
|
1412
|
+
const heightScale = (plotH - nodeGapPx * 8) / maxColTotal // leave gap room
|
|
1413
|
+
const placed: ChartSankeyNode[] = []
|
|
1414
|
+
for (const [c, ids] of byCol) {
|
|
1415
|
+
const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale))
|
|
1416
|
+
const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1)
|
|
1417
|
+
let yCursor = padT + (plotH - totalH) / 2
|
|
1418
|
+
const xCol = padL + c * (nodeW + gapBetweenColumns)
|
|
1419
|
+
ids.forEach((id, idx) => {
|
|
1420
|
+
const node = nodeById.get(id)!
|
|
1421
|
+
const h = heights[idx]!
|
|
1422
|
+
placed.push({
|
|
1423
|
+
id,
|
|
1424
|
+
label: node.label ?? id,
|
|
1425
|
+
color: node.color ?? palette[(placed.length) % palette.length]!,
|
|
1426
|
+
x: xCol, y: yCursor, w: nodeW, h,
|
|
1427
|
+
column: c,
|
|
1428
|
+
totalIn: totalIn.get(id) ?? 0,
|
|
1429
|
+
totalOut: totalOut.get(id) ?? 0,
|
|
1430
|
+
})
|
|
1431
|
+
yCursor += h + nodeGapPx
|
|
1432
|
+
})
|
|
1433
|
+
}
|
|
1434
|
+
const placedById = new Map(placed.map((n) => [n.id, n]))
|
|
1435
|
+
// Per-node sub-cursor so multiple links from one node stack vertically.
|
|
1436
|
+
const inCursor = new Map<string, number>()
|
|
1437
|
+
const outCursor = new Map<string, number>()
|
|
1438
|
+
const builtLinks: ChartSankeyLink[] = []
|
|
1439
|
+
// Sort links so wider ribbons render first (so thin ribbons stack on top).
|
|
1440
|
+
const sortedLinks = links.slice().sort((a, b) => b.value - a.value)
|
|
1441
|
+
for (const link of sortedLinks) {
|
|
1442
|
+
const a = placedById.get(link.source)
|
|
1443
|
+
const b = placedById.get(link.target)
|
|
1444
|
+
if (!a || !b) continue
|
|
1445
|
+
const linkH = Math.max(1, link.value * heightScale)
|
|
1446
|
+
const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2
|
|
1447
|
+
const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2
|
|
1448
|
+
outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH)
|
|
1449
|
+
inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH)
|
|
1450
|
+
const x0 = a.x + a.w
|
|
1451
|
+
const x1 = b.x
|
|
1452
|
+
const mid = (x0 + x1) / 2
|
|
1453
|
+
const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`
|
|
1454
|
+
builtLinks.push({
|
|
1455
|
+
path, color: link.color ?? a.color, width: linkH,
|
|
1456
|
+
source: link.source, target: link.target, value: link.value,
|
|
1457
|
+
})
|
|
1458
|
+
}
|
|
1459
|
+
return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks }
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// ---- Heatmap ------------------------------------------------------
|
|
1463
|
+
// Each series is one row, series.values are the cells across categories.
|
|
1464
|
+
// Color comes from a sequential/diverging/custom palette mapped to the
|
|
1465
|
+
// global value range. Cell text contrasts black/white against the cell.
|
|
1466
|
+
if (spec.type === 'heatmap') {
|
|
1467
|
+
if (!series.length || !spec.categories.length) return { ...empty, plot: { x: 0, y: 0, w: width, h: height } }
|
|
1468
|
+
// Layout: left gutter for row labels, bottom for column labels.
|
|
1469
|
+
const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0)
|
|
1470
|
+
const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7))
|
|
1471
|
+
const padR = 64 // room for the right-side legend bar
|
|
1472
|
+
const padT = 12
|
|
1473
|
+
const padB = 32
|
|
1474
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1475
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1476
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1477
|
+
const cellW = plotW / spec.categories.length
|
|
1478
|
+
const cellH = plotH / series.length
|
|
1479
|
+
// Resolve value range across the whole matrix.
|
|
1480
|
+
let vMin = Infinity, vMax = -Infinity
|
|
1481
|
+
for (const s of series) for (const v of s.values) {
|
|
1482
|
+
if (!Number.isFinite(v)) continue
|
|
1483
|
+
if (v < vMin) vMin = v
|
|
1484
|
+
if (v > vMax) vMax = v
|
|
1485
|
+
}
|
|
1486
|
+
if (vMin === Infinity) { vMin = 0; vMax = 1 }
|
|
1487
|
+
if (vMin === vMax) vMax = vMin + 1
|
|
1488
|
+
// Pick the palette stops.
|
|
1489
|
+
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme)
|
|
1490
|
+
const colorAt = (v: number) => sampleGradient(stops, (v - vMin) / (vMax - vMin))
|
|
1491
|
+
const heatmapCells: ChartHeatmapCell[] = []
|
|
1492
|
+
series.forEach((s, ri) => {
|
|
1493
|
+
s.values.forEach((v, ci) => {
|
|
1494
|
+
if (!Number.isFinite(v)) return
|
|
1495
|
+
const color = colorAt(v)
|
|
1496
|
+
heatmapCells.push({
|
|
1497
|
+
x: round(padL + cellW * ci),
|
|
1498
|
+
y: round(padT + cellH * ri),
|
|
1499
|
+
w: round(cellW),
|
|
1500
|
+
h: round(cellH),
|
|
1501
|
+
color,
|
|
1502
|
+
textColor: pickContrastText(color),
|
|
1503
|
+
value: v,
|
|
1504
|
+
rowLabel: s.label,
|
|
1505
|
+
colLabel: spec.categories[ci] ?? '',
|
|
1506
|
+
})
|
|
1507
|
+
})
|
|
1508
|
+
})
|
|
1509
|
+
const heatmapRowTicks: ChartAxisTick[] = series.map((s, i) => ({
|
|
1510
|
+
value: i,
|
|
1511
|
+
y: round(padT + cellH * i + cellH / 2),
|
|
1512
|
+
label: s.label,
|
|
1513
|
+
}))
|
|
1514
|
+
const heatmapColTicks: ChartCategoryTick[] = spec.categories.map((label, i) => ({
|
|
1515
|
+
label,
|
|
1516
|
+
x: round(padL + cellW * i + cellW / 2),
|
|
1517
|
+
}))
|
|
1518
|
+
// Legend: sample 5 stops across the range.
|
|
1519
|
+
const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
|
|
1520
|
+
const t = i / 4
|
|
1521
|
+
const value = vMin + (vMax - vMin) * t
|
|
1522
|
+
return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
|
|
1523
|
+
})
|
|
1524
|
+
return {
|
|
1525
|
+
...empty,
|
|
1526
|
+
plot,
|
|
1527
|
+
heatmapCells,
|
|
1528
|
+
heatmapRowTicks,
|
|
1529
|
+
heatmapColTicks,
|
|
1530
|
+
heatmapLegend,
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (spec.type === 'pie') {
|
|
1535
|
+
const s = series[0]
|
|
1536
|
+
if (!s) return empty
|
|
1537
|
+
const total = s.values.reduce((a, b) => a + Math.max(0, b), 0) || 1
|
|
1538
|
+
const cx = width / 2
|
|
1539
|
+
const cy = height / 2
|
|
1540
|
+
const r = Math.min(width, height) / 2 - 10
|
|
1541
|
+
const innerFrac = Math.min(0.9, Math.max(0, spec.innerRadius ?? 0))
|
|
1542
|
+
const ir = r * innerFrac
|
|
1543
|
+
let angle = -Math.PI / 2
|
|
1544
|
+
const slices: ChartPieSlice[] = s.values.map((v, i) => {
|
|
1545
|
+
const frac = Math.max(0, v) / total
|
|
1546
|
+
const a0 = angle
|
|
1547
|
+
const a1 = angle + frac * Math.PI * 2
|
|
1548
|
+
angle = a1
|
|
1549
|
+
const large = a1 - a0 > Math.PI ? 1 : 0
|
|
1550
|
+
const mid = (a0 + a1) / 2
|
|
1551
|
+
const labelR = (r + ir) / 2 || r * 0.6
|
|
1552
|
+
const ox0 = cx + r * Math.cos(a0)
|
|
1553
|
+
const oy0 = cy + r * Math.sin(a0)
|
|
1554
|
+
const ox1 = cx + r * Math.cos(a1)
|
|
1555
|
+
const oy1 = cy + r * Math.sin(a1)
|
|
1556
|
+
let path: string
|
|
1557
|
+
if (frac >= 0.999) {
|
|
1558
|
+
path = ir
|
|
1559
|
+
? `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` +
|
|
1560
|
+
`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`
|
|
1561
|
+
: `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`
|
|
1562
|
+
} else if (ir > 0) {
|
|
1563
|
+
const ix0 = cx + ir * Math.cos(a0)
|
|
1564
|
+
const iy0 = cy + ir * Math.sin(a0)
|
|
1565
|
+
const ix1 = cx + ir * Math.cos(a1)
|
|
1566
|
+
const iy1 = cy + ir * Math.sin(a1)
|
|
1567
|
+
path =
|
|
1568
|
+
`M${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} ` +
|
|
1569
|
+
`L${round(ix1)},${round(iy1)} A${ir},${ir} 0 ${large} 0 ${round(ix0)},${round(iy0)} Z`
|
|
1570
|
+
} else {
|
|
1571
|
+
path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
|
|
1572
|
+
}
|
|
1573
|
+
const catLabel = spec.categories[i] ?? String(i)
|
|
1574
|
+
return {
|
|
1575
|
+
path,
|
|
1576
|
+
color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length]!,
|
|
1577
|
+
label: catLabel,
|
|
1578
|
+
value: v,
|
|
1579
|
+
percent: frac * 100,
|
|
1580
|
+
cx: round(cx + labelR * Math.cos(mid)),
|
|
1581
|
+
cy: round(cy + labelR * Math.sin(mid)),
|
|
1582
|
+
}
|
|
1583
|
+
})
|
|
1584
|
+
return {
|
|
1585
|
+
...empty,
|
|
1586
|
+
slices,
|
|
1587
|
+
legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! })),
|
|
1588
|
+
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,
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (spec.type === 'scatter') {
|
|
1593
|
+
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
1594
|
+
const padR = 12
|
|
1595
|
+
const padT = 10
|
|
1596
|
+
const padB = 28 + (spec.xAxisTitle ? 16 : 0)
|
|
1597
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1598
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1599
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1600
|
+
|
|
1601
|
+
let xMin = Infinity
|
|
1602
|
+
let xMax = -Infinity
|
|
1603
|
+
let yMin = Infinity
|
|
1604
|
+
let yMax = -Infinity
|
|
1605
|
+
let rMin = Infinity
|
|
1606
|
+
let rMax = -Infinity
|
|
1607
|
+
for (const s of series) {
|
|
1608
|
+
for (const pt of s.points ?? []) {
|
|
1609
|
+
if (Number.isFinite(pt.x)) { xMin = Math.min(xMin, pt.x); xMax = Math.max(xMax, pt.x) }
|
|
1610
|
+
if (Number.isFinite(pt.y)) { yMin = Math.min(yMin, pt.y); yMax = Math.max(yMax, pt.y) }
|
|
1611
|
+
if (pt.r != null && Number.isFinite(pt.r)) { rMin = Math.min(rMin, pt.r); rMax = Math.max(rMax, pt.r) }
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
if (xMin === Infinity) return { ...empty, plot }
|
|
1615
|
+
const xDom = niceScale(xMin, xMax)
|
|
1616
|
+
const yDom = niceScale(yMin, yMax)
|
|
1617
|
+
const hasR = rMax > rMin
|
|
1618
|
+
const xOf = (v: number) => round(padL + ((v - xDom.min) / (xDom.max - xDom.min || 1)) * plotW)
|
|
1619
|
+
const yOf = (v: number) => round(padT + plotH - ((v - yDom.min) / (yDom.max - yDom.min || 1)) * plotH)
|
|
1620
|
+
const rOf = (r?: number) =>
|
|
1621
|
+
hasR && r != null && Number.isFinite(r)
|
|
1622
|
+
? round(4 + ((r - rMin) / (rMax - rMin || 1)) * 14)
|
|
1623
|
+
: 5
|
|
1624
|
+
|
|
1625
|
+
const scatterPoints: ChartScatterDot[] = []
|
|
1626
|
+
for (const s of series) {
|
|
1627
|
+
for (const pt of s.points ?? []) {
|
|
1628
|
+
if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) continue
|
|
1629
|
+
scatterPoints.push({
|
|
1630
|
+
cx: xOf(pt.x),
|
|
1631
|
+
cy: yOf(pt.y),
|
|
1632
|
+
r: rOf(pt.r),
|
|
1633
|
+
color: s.color,
|
|
1634
|
+
label: pt.label ?? '',
|
|
1635
|
+
series: s.label,
|
|
1636
|
+
x: pt.x,
|
|
1637
|
+
y: pt.y,
|
|
1638
|
+
})
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
1642
|
+
y: yOf(ref.value),
|
|
1643
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
1644
|
+
color: ref.color ?? '#ef4444',
|
|
1645
|
+
dashed: ref.dashed !== false,
|
|
1646
|
+
}))
|
|
1647
|
+
return {
|
|
1648
|
+
...empty,
|
|
1649
|
+
plot,
|
|
1650
|
+
scatterPoints,
|
|
1651
|
+
referenceLines,
|
|
1652
|
+
yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat) })),
|
|
1653
|
+
xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// ---- Horizontal bars ----------------------------------------------------
|
|
1658
|
+
// Categories run down the left, bars grow rightward. Bars-only (no combo).
|
|
1659
|
+
const horizontal =
|
|
1660
|
+
spec.orientation === 'horizontal' && series.length > 0 && series.every((s) => s.kind === 'bar')
|
|
1661
|
+
if (horizontal) {
|
|
1662
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
1663
|
+
const padL = Math.min(150, 18 + maxLabel * 6.4) + (spec.yAxisTitle ? 16 : 0)
|
|
1664
|
+
const padR = 16
|
|
1665
|
+
const padT = 8
|
|
1666
|
+
const padB = 26 + (spec.xAxisTitle ? 16 : 0)
|
|
1667
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1668
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1669
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1670
|
+
|
|
1671
|
+
const refs = (spec.referenceLines ?? []).map((r) => r.value)
|
|
1672
|
+
const dom = spec.stacked100 ? niceScale(0, 100) : axisDomain(series, spec.categories, stacked, refs)
|
|
1673
|
+
const xOf = (v: number) => round(padL + ((v - dom.min) / (dom.max - dom.min || 1)) * plotW)
|
|
1674
|
+
|
|
1675
|
+
const n = spec.categories.length
|
|
1676
|
+
const slot = plotH / Math.max(1, n)
|
|
1677
|
+
const groupPad = slot * 0.2
|
|
1678
|
+
const inner = slot - groupPad
|
|
1679
|
+
const bandTop = (i: number) => padT + slot * i + groupPad / 2
|
|
1680
|
+
const xBase = xOf(Math.min(Math.max(0, dom.min), dom.max))
|
|
1681
|
+
|
|
1682
|
+
const bars: ChartBar[] = []
|
|
1683
|
+
if (stacked) {
|
|
1684
|
+
const totals = spec.stacked100
|
|
1685
|
+
? spec.categories.map(
|
|
1686
|
+
(_, i) => series.reduce((sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0), 0) || 1,
|
|
1687
|
+
)
|
|
1688
|
+
: null
|
|
1689
|
+
const pos = new Array(n).fill(0)
|
|
1690
|
+
const neg = new Array(n).fill(0)
|
|
1691
|
+
for (const s of series) {
|
|
1692
|
+
s.values.forEach((v, i) => {
|
|
1693
|
+
if (!Number.isFinite(v)) return
|
|
1694
|
+
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
1695
|
+
let xL: number
|
|
1696
|
+
let xR: number
|
|
1697
|
+
if (vp >= 0) {
|
|
1698
|
+
xL = xOf(pos[i])
|
|
1699
|
+
xR = xOf(pos[i] + vp)
|
|
1700
|
+
pos[i] += vp
|
|
1701
|
+
} else {
|
|
1702
|
+
xL = xOf(neg[i] + vp)
|
|
1703
|
+
xR = xOf(neg[i])
|
|
1704
|
+
neg[i] += vp
|
|
1705
|
+
}
|
|
1706
|
+
bars.push({
|
|
1707
|
+
x: Math.min(xL, xR),
|
|
1708
|
+
y: round(bandTop(i)),
|
|
1709
|
+
w: round(Math.abs(xR - xL)),
|
|
1710
|
+
h: round(Math.max(1, inner)),
|
|
1711
|
+
color: s.color,
|
|
1712
|
+
label: spec.categories[i] ?? String(i),
|
|
1713
|
+
series: s.label,
|
|
1714
|
+
value: v,
|
|
1715
|
+
})
|
|
1716
|
+
})
|
|
1717
|
+
}
|
|
1718
|
+
} else {
|
|
1719
|
+
const barH = inner / series.length
|
|
1720
|
+
series.forEach((s, bi) => {
|
|
1721
|
+
s.values.forEach((v, i) => {
|
|
1722
|
+
if (!Number.isFinite(v)) return
|
|
1723
|
+
const xV = xOf(v)
|
|
1724
|
+
bars.push({
|
|
1725
|
+
x: Math.min(xV, xBase),
|
|
1726
|
+
y: round(bandTop(i) + barH * bi),
|
|
1727
|
+
w: round(Math.max(1, Math.abs(xV - xBase))),
|
|
1728
|
+
h: round(Math.max(1, barH - 1)),
|
|
1729
|
+
color: s.color,
|
|
1730
|
+
label: spec.categories[i] ?? String(i),
|
|
1731
|
+
series: s.label,
|
|
1732
|
+
value: v,
|
|
1733
|
+
})
|
|
1734
|
+
})
|
|
1735
|
+
})
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
|
|
1739
|
+
label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat),
|
|
1740
|
+
x: xOf(value),
|
|
1741
|
+
}))
|
|
1742
|
+
const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
|
|
1743
|
+
value: i,
|
|
1744
|
+
y: round(bandTop(i) + inner / 2),
|
|
1745
|
+
label,
|
|
1746
|
+
}))
|
|
1747
|
+
const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
|
|
1748
|
+
x: xOf(ref.value),
|
|
1749
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
1750
|
+
color: ref.color ?? '#ef4444',
|
|
1751
|
+
dashed: ref.dashed !== false,
|
|
1752
|
+
}))
|
|
1753
|
+
|
|
1754
|
+
return {
|
|
1755
|
+
...empty,
|
|
1756
|
+
plot,
|
|
1757
|
+
bars,
|
|
1758
|
+
orientation: 'horizontal',
|
|
1759
|
+
valueTicks,
|
|
1760
|
+
catTicks,
|
|
1761
|
+
referenceLinesV,
|
|
1762
|
+
xLabelRotated: false,
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// ---- Cartesian (bar / line / area, possibly combo + dual axis) ----------
|
|
1767
|
+
const leftSeries = series.filter((s) => s.axis === 'left')
|
|
1768
|
+
const rightSeries = series.filter((s) => s.axis === 'right')
|
|
1769
|
+
const hasRightAxis = rightSeries.length > 0
|
|
1770
|
+
|
|
1771
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
|
|
1772
|
+
const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
|
|
1773
|
+
// Grouped (nested) category axis: valid only when the spans cover every leaf.
|
|
1774
|
+
const validGroups =
|
|
1775
|
+
spec.categoryGroups &&
|
|
1776
|
+
spec.categoryGroups.length > 0 &&
|
|
1777
|
+
spec.xType !== 'time' &&
|
|
1778
|
+
spec.orientation !== 'horizontal' &&
|
|
1779
|
+
spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
|
|
1780
|
+
? spec.categoryGroups
|
|
1781
|
+
: null
|
|
1782
|
+
const groupTierH = validGroups ? 18 : 0
|
|
1783
|
+
const padL = 48 + (spec.yAxisTitle ? 16 : 0)
|
|
1784
|
+
const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
|
|
1785
|
+
const padT = 10
|
|
1786
|
+
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH
|
|
1787
|
+
const plotW = Math.max(1, width - padL - padR)
|
|
1788
|
+
const plotH = Math.max(1, height - padT - padB)
|
|
1789
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH }
|
|
1790
|
+
|
|
1791
|
+
const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value)
|
|
1792
|
+
const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value)
|
|
1793
|
+
const leftLog = spec.yScale === 'log'
|
|
1794
|
+
const rightLog = spec.y2Scale === 'log'
|
|
1795
|
+
const leftDom = spec.stacked100
|
|
1796
|
+
? niceScale(0, 100)
|
|
1797
|
+
: axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog)
|
|
1798
|
+
const rightDom = hasRightAxis
|
|
1799
|
+
? spec.stacked100
|
|
1800
|
+
? niceScale(0, 100)
|
|
1801
|
+
: axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
|
|
1802
|
+
: null
|
|
1803
|
+
|
|
1804
|
+
/** Map a data value to a y pixel. Returns NaN for non-positive values on
|
|
1805
|
+
* a log axis so callers can drop the point (line gap / missing bar). */
|
|
1806
|
+
const yOf = (dom: NiceScale, v: number, isLog = false) => {
|
|
1807
|
+
const t = project(v, dom.min, dom.max, isLog)
|
|
1808
|
+
if (t === null) return NaN
|
|
1809
|
+
return round(padT + plotH - t * plotH)
|
|
1810
|
+
}
|
|
1811
|
+
const yLeft = (v: number) => yOf(leftDom, v, leftLog)
|
|
1812
|
+
const yRight = (v: number) => yOf(rightDom ?? leftDom, v, rightLog)
|
|
1813
|
+
const domOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom)
|
|
1814
|
+
const isLogOf = (s: ResolvedSeries) => (s.axis === 'right' ? rightLog : leftLog)
|
|
1815
|
+
|
|
1816
|
+
const n = spec.categories.length
|
|
1817
|
+
const slot = plotW / Math.max(1, n)
|
|
1818
|
+
|
|
1819
|
+
// X positions. A time axis spaces points by actual time (irregular gaps);
|
|
1820
|
+
// a category axis is uniform. (Bars stay uniform either way.)
|
|
1821
|
+
const timeVals =
|
|
1822
|
+
spec.xType === 'time' ? spec.categories.map((c) => Date.parse(c)) : null
|
|
1823
|
+
const timeOk = !!timeVals && timeVals.some((t) => Number.isFinite(t))
|
|
1824
|
+
const tMin = timeOk ? Math.min(...timeVals!.filter(Number.isFinite)) : 0
|
|
1825
|
+
const tSpan = timeOk ? Math.max(...timeVals!.filter(Number.isFinite)) - tMin || 1 : 1
|
|
1826
|
+
const xCenter = (i: number) =>
|
|
1827
|
+
timeOk && Number.isFinite(timeVals![i])
|
|
1828
|
+
? round(padL + ((timeVals![i]! - tMin) / tSpan) * plotW)
|
|
1829
|
+
: round(padL + slot * i + slot / 2)
|
|
1830
|
+
const xTicks: ChartCategoryTick[] = timeOk
|
|
1831
|
+
? dateTicks(tMin, tMin + tSpan).map((t) => ({
|
|
1832
|
+
label: fmtDate(t, tSpan),
|
|
1833
|
+
x: round(padL + ((t - tMin) / tSpan) * plotW),
|
|
1834
|
+
}))
|
|
1835
|
+
: spec.categories.map((label, i) => ({ label, x: xCenter(i) }))
|
|
1836
|
+
|
|
1837
|
+
// Parent-tier ticks for a grouped category axis: each spans its leaves.
|
|
1838
|
+
const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
|
|
1839
|
+
if (validGroups && !timeOk) {
|
|
1840
|
+
let start = 0
|
|
1841
|
+
for (const g of validGroups) {
|
|
1842
|
+
const x0 = round(padL + slot * start)
|
|
1843
|
+
const x1 = round(padL + slot * (start + g.span))
|
|
1844
|
+
categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
|
|
1845
|
+
start += g.span
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
const barSeries = series.filter((s) => s.kind === 'bar')
|
|
1850
|
+
const bars: ChartBar[] = []
|
|
1851
|
+
if (barSeries.length) {
|
|
1852
|
+
const groupPad = slot * 0.2
|
|
1853
|
+
if (stacked) {
|
|
1854
|
+
const inner = slot - groupPad
|
|
1855
|
+
const x0 = (i: number) => padL + slot * i + groupPad / 2
|
|
1856
|
+
// Stack independently per axis so dual-axis stacks line up to their own scale.
|
|
1857
|
+
for (const axis of ['left', 'right'] as const) {
|
|
1858
|
+
const axisBars = barSeries.filter((s) => s.axis === axis)
|
|
1859
|
+
if (!axisBars.length) continue
|
|
1860
|
+
const yA = axis === 'right' ? yRight : yLeft
|
|
1861
|
+
// 100% mode normalizes each category to its absolute total.
|
|
1862
|
+
const totals = spec.stacked100
|
|
1863
|
+
? spec.categories.map(
|
|
1864
|
+
(_, i) =>
|
|
1865
|
+
axisBars.reduce(
|
|
1866
|
+
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
1867
|
+
0,
|
|
1868
|
+
) || 1,
|
|
1869
|
+
)
|
|
1870
|
+
: null
|
|
1871
|
+
const pos = new Array(n).fill(0)
|
|
1872
|
+
const neg = new Array(n).fill(0)
|
|
1873
|
+
for (const s of axisBars) {
|
|
1874
|
+
s.values.forEach((v, i) => {
|
|
1875
|
+
if (!Number.isFinite(v)) return
|
|
1876
|
+
const vp = totals ? (v / totals[i]!) * 100 : v
|
|
1877
|
+
let yTop: number
|
|
1878
|
+
let yBot: number
|
|
1879
|
+
if (vp >= 0) {
|
|
1880
|
+
yTop = yA(pos[i] + vp)
|
|
1881
|
+
yBot = yA(pos[i])
|
|
1882
|
+
pos[i] += vp
|
|
1883
|
+
} else {
|
|
1884
|
+
yTop = yA(neg[i])
|
|
1885
|
+
yBot = yA(neg[i] + vp)
|
|
1886
|
+
neg[i] += vp
|
|
1887
|
+
}
|
|
1888
|
+
bars.push({
|
|
1889
|
+
x: round(x0(i)),
|
|
1890
|
+
y: Math.min(yTop, yBot),
|
|
1891
|
+
w: round(Math.max(1, inner)),
|
|
1892
|
+
h: round(Math.abs(yBot - yTop)),
|
|
1893
|
+
color: s.color,
|
|
1894
|
+
label: spec.categories[i] ?? String(i),
|
|
1895
|
+
series: s.label,
|
|
1896
|
+
value: v,
|
|
1897
|
+
})
|
|
1898
|
+
})
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
} else {
|
|
1902
|
+
const inner = slot - groupPad
|
|
1903
|
+
const barW = inner / barSeries.length
|
|
1904
|
+
barSeries.forEach((s, bi) => {
|
|
1905
|
+
const dom = domOf(s)
|
|
1906
|
+
const log = isLogOf(s)
|
|
1907
|
+
// Log axis: bars grow from the axis floor (dom.min) up to v rather
|
|
1908
|
+
// than from 0, since 0 is invalid in log space.
|
|
1909
|
+
const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log)
|
|
1910
|
+
s.values.forEach((v, i) => {
|
|
1911
|
+
if (!Number.isFinite(v)) return
|
|
1912
|
+
if (log && v <= 0) return
|
|
1913
|
+
const x = padL + slot * i + groupPad / 2 + barW * bi
|
|
1914
|
+
const yV = yOf(dom, v, log)
|
|
1915
|
+
bars.push({
|
|
1916
|
+
x: round(x),
|
|
1917
|
+
y: Math.min(yV, base),
|
|
1918
|
+
w: round(Math.max(1, barW - 1)),
|
|
1919
|
+
h: round(Math.max(1, Math.abs(yV - base))),
|
|
1920
|
+
color: s.color,
|
|
1921
|
+
label: spec.categories[i] ?? String(i),
|
|
1922
|
+
series: s.label,
|
|
1923
|
+
value: v,
|
|
1924
|
+
})
|
|
1925
|
+
})
|
|
1926
|
+
})
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// Lines / areas. Stacked areas accumulate per axis; others fill to baseline.
|
|
1931
|
+
const lines: ChartLine[] = []
|
|
1932
|
+
const areaCum: Record<'left' | 'right', number[]> = {
|
|
1933
|
+
left: new Array(n).fill(0),
|
|
1934
|
+
right: new Array(n).fill(0),
|
|
1935
|
+
}
|
|
1936
|
+
// 100% mode: per-axis per-category totals to normalize stacked areas to 100.
|
|
1937
|
+
const areaTotals: Record<'left' | 'right', number[] | null> = { left: null, right: null }
|
|
1938
|
+
if (spec.stacked100) {
|
|
1939
|
+
for (const axis of ['left', 'right'] as const) {
|
|
1940
|
+
const areaSeries = series.filter((s) => s.kind === 'area' && s.axis === axis)
|
|
1941
|
+
if (areaSeries.length) {
|
|
1942
|
+
areaTotals[axis] = spec.categories.map(
|
|
1943
|
+
(_, i) =>
|
|
1944
|
+
areaSeries.reduce(
|
|
1945
|
+
(sum, s) => sum + Math.abs(Number.isFinite(s.values[i]!) ? s.values[i]! : 0),
|
|
1946
|
+
0,
|
|
1947
|
+
) || 1,
|
|
1948
|
+
)
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
for (const s of series) {
|
|
1953
|
+
if (s.kind === 'bar') continue
|
|
1954
|
+
const dom = domOf(s)
|
|
1955
|
+
const log = isLogOf(s)
|
|
1956
|
+
const yA = (v: number) => yOf(dom, v, log)
|
|
1957
|
+
const isStackedArea = stacked && s.kind === 'area'
|
|
1958
|
+
const px = (i: number) => xCenter(i)
|
|
1959
|
+
let pts: ChartLinePoint[]
|
|
1960
|
+
let baselinePts: Array<{ x: number; y: number }> | null = null
|
|
1961
|
+
if (isStackedArea) {
|
|
1962
|
+
// Stacked areas treat a gap as 0 so the stack stays continuous.
|
|
1963
|
+
const cum = areaCum[s.axis]
|
|
1964
|
+
const prev = cum.slice()
|
|
1965
|
+
const totals = areaTotals[s.axis]
|
|
1966
|
+
pts = s.values.map((v, i) => {
|
|
1967
|
+
const vv = Number.isFinite(v) ? v : 0
|
|
1968
|
+
// 100% mode positions by share of the category total; value stays original.
|
|
1969
|
+
const norm = totals ? (vv / totals[i]!) * 100 : vv
|
|
1970
|
+
const c = (cum[i] ?? 0) + norm
|
|
1971
|
+
cum[i] = c
|
|
1972
|
+
return { x: px(i), y: yA(c), label: spec.categories[i] ?? String(i), value: v, defined: Number.isFinite(v) }
|
|
1973
|
+
})
|
|
1974
|
+
baselinePts = prev.map((c, i) => ({ x: px(i), y: yA(c) }))
|
|
1975
|
+
} else {
|
|
1976
|
+
pts = s.values.map((v, i) => {
|
|
1977
|
+
const ok = Number.isFinite(v)
|
|
1978
|
+
return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok }
|
|
1979
|
+
})
|
|
1980
|
+
}
|
|
1981
|
+
// Build the line - smoothed via monotone cubic when requested, else
|
|
1982
|
+
// straight polylines. Either way, gaps break the path cleanly.
|
|
1983
|
+
const smooth = !!s.smooth
|
|
1984
|
+
const path = buildLinePath(pts, smooth)
|
|
1985
|
+
|
|
1986
|
+
let areaPath = ''
|
|
1987
|
+
if (s.kind === 'area' && pts.length) {
|
|
1988
|
+
if (baselinePts) {
|
|
1989
|
+
const top = smooth
|
|
1990
|
+
? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
|
|
1991
|
+
: pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
1992
|
+
const back = smooth
|
|
1993
|
+
? `L${baselinePts[baselinePts.length - 1]!.x},${baselinePts[baselinePts.length - 1]!.y} ` +
|
|
1994
|
+
monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
1995
|
+
: baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
|
|
1996
|
+
areaPath = `${top} ${back} Z`
|
|
1997
|
+
} else {
|
|
1998
|
+
// One filled polygon per contiguous run of defined points.
|
|
1999
|
+
const baseY = round(yA(Math.min(Math.max(0, dom.min), dom.max)))
|
|
2000
|
+
const runs: ChartLinePoint[][] = []
|
|
2001
|
+
let cur: ChartLinePoint[] = []
|
|
2002
|
+
for (const p of pts) {
|
|
2003
|
+
if (p.defined) cur.push(p)
|
|
2004
|
+
else if (cur.length) {
|
|
2005
|
+
runs.push(cur)
|
|
2006
|
+
cur = []
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
if (cur.length) runs.push(cur)
|
|
2010
|
+
areaPath = runs
|
|
2011
|
+
.map((run) => {
|
|
2012
|
+
const top = smooth
|
|
2013
|
+
? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
|
|
2014
|
+
: run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
2015
|
+
return `${top} L${run[run.length - 1]!.x},${baseY} L${run[0]!.x},${baseY} Z`
|
|
2016
|
+
})
|
|
2017
|
+
.join(' ')
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Confidence band: shaded envelope between upperValues / lowerValues.
|
|
2022
|
+
// Both arrays must be present and aligned to the value array.
|
|
2023
|
+
let bandPath = ''
|
|
2024
|
+
if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
|
|
2025
|
+
const upperPts: Array<{ x: number; y: number }> = []
|
|
2026
|
+
const lowerPts: Array<{ x: number; y: number }> = []
|
|
2027
|
+
for (let i = 0; i < s.values.length; i += 1) {
|
|
2028
|
+
const u = s.upperValues[i]!
|
|
2029
|
+
const lo = s.lowerValues[i]!
|
|
2030
|
+
if (!Number.isFinite(u) || !Number.isFinite(lo)) continue
|
|
2031
|
+
if (log && (u <= 0 || lo <= 0)) continue
|
|
2032
|
+
upperPts.push({ x: px(i), y: yA(u) })
|
|
2033
|
+
lowerPts.push({ x: px(i), y: yA(lo) })
|
|
2034
|
+
}
|
|
2035
|
+
if (upperPts.length >= 2) {
|
|
2036
|
+
const top = smooth
|
|
2037
|
+
? monotoneCubicPath(upperPts)
|
|
2038
|
+
: upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
|
|
2039
|
+
const back = smooth
|
|
2040
|
+
? `L${lowerPts[lowerPts.length - 1]!.x},${lowerPts[lowerPts.length - 1]!.y} ` +
|
|
2041
|
+
monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
2042
|
+
: lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ')
|
|
2043
|
+
bandPath = `${top} ${back} Z`
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath })
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
|
|
2051
|
+
dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: formatChartValue(value, spec.valueFormat) }))
|
|
2052
|
+
|
|
2053
|
+
const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
|
|
2054
|
+
const onRight = ref.axis === 'right'
|
|
2055
|
+
const dom = onRight ? (rightDom ?? leftDom) : leftDom
|
|
2056
|
+
const log = onRight ? rightLog : leftLog
|
|
2057
|
+
return {
|
|
2058
|
+
y: yOf(dom, ref.value, log),
|
|
2059
|
+
label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
|
|
2060
|
+
color: ref.color ?? '#ef4444',
|
|
2061
|
+
dashed: ref.dashed !== false,
|
|
2062
|
+
}
|
|
2063
|
+
})
|
|
2064
|
+
|
|
2065
|
+
// ---- Overlays: trendline / moving average ------------------------
|
|
2066
|
+
// For every series with an `overlay`, compute the smoothed values and
|
|
2067
|
+
// render as a dashed line in the source series' color (or overlayColor).
|
|
2068
|
+
const overlays: ChartLine[] = []
|
|
2069
|
+
for (const s of series) {
|
|
2070
|
+
if (!s.overlay) continue
|
|
2071
|
+
const dom = domOf(s)
|
|
2072
|
+
const log = isLogOf(s)
|
|
2073
|
+
const overlayVals = computeOverlay(s.values, s.overlay)
|
|
2074
|
+
const color = s.overlayColor ?? s.color
|
|
2075
|
+
const pts: ChartLinePoint[] = overlayVals.map((v, i) => {
|
|
2076
|
+
const ok = Number.isFinite(v) && (!log || v > 0)
|
|
2077
|
+
return {
|
|
2078
|
+
x: xCenter(i),
|
|
2079
|
+
y: ok ? yOf(dom, v, log) : NaN,
|
|
2080
|
+
label: spec.categories[i] ?? String(i),
|
|
2081
|
+
value: v,
|
|
2082
|
+
defined: ok,
|
|
2083
|
+
}
|
|
2084
|
+
})
|
|
2085
|
+
const path = buildLinePath(pts, !!s.smooth)
|
|
2086
|
+
overlays.push({
|
|
2087
|
+
path,
|
|
2088
|
+
areaPath: '',
|
|
2089
|
+
color,
|
|
2090
|
+
label: `${s.label} (${s.overlay})`,
|
|
2091
|
+
points: pts,
|
|
2092
|
+
})
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// ---- Annotations: resolve data-space anchors to pixel coords ------
|
|
2096
|
+
const annotations: ChartGeometry['annotations'] = []
|
|
2097
|
+
for (const a of (spec.annotations ?? [])) {
|
|
2098
|
+
let ax: number | null = null
|
|
2099
|
+
let ay: number | null = null
|
|
2100
|
+
if ('category' in a.at) {
|
|
2101
|
+
const ci = spec.categories.indexOf(a.at.category)
|
|
2102
|
+
if (ci < 0) continue
|
|
2103
|
+
ax = xCenter(ci)
|
|
2104
|
+
// Anchor to the named series' value at that category, else just
|
|
2105
|
+
// mid-plot. Picks the first matching series if `series` is set.
|
|
2106
|
+
const seriesName = a.at.series
|
|
2107
|
+
const s = seriesName ? series.find((x) => x.label === seriesName) : series[0]
|
|
2108
|
+
if (s) {
|
|
2109
|
+
const v = s.values[ci]
|
|
2110
|
+
if (Number.isFinite(v)) ay = yOf(domOf(s), v as number, isLogOf(s))
|
|
2111
|
+
}
|
|
2112
|
+
if (ay == null) ay = padT + plotH / 2
|
|
2113
|
+
} else {
|
|
2114
|
+
// Raw x/y in data space (x ignored for category x-axis; takes the
|
|
2115
|
+
// mid-plot in that case). y projects through the left axis.
|
|
2116
|
+
ax = padL + plotW / 2
|
|
2117
|
+
if (Number.isFinite(a.at.y as number)) ay = yOf(leftDom, a.at.y as number, leftLog)
|
|
2118
|
+
else ay = padT + plotH / 2
|
|
2119
|
+
}
|
|
2120
|
+
if (ax != null && ay != null && Number.isFinite(ay)) {
|
|
2121
|
+
annotations.push({
|
|
2122
|
+
x: ax,
|
|
2123
|
+
y: ay,
|
|
2124
|
+
label: a.label,
|
|
2125
|
+
color: a.color ?? '#0f172a',
|
|
2126
|
+
placement: a.placement ?? 'top',
|
|
2127
|
+
})
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
return {
|
|
2132
|
+
...empty,
|
|
2133
|
+
plot,
|
|
2134
|
+
bars,
|
|
2135
|
+
lines,
|
|
2136
|
+
yTicks: tickFor(leftDom, leftLog),
|
|
2137
|
+
y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
|
|
2138
|
+
hasRightAxis,
|
|
2139
|
+
xTicks,
|
|
2140
|
+
categoryGroupTicks,
|
|
2141
|
+
xLabelRotated: timeOk ? false : xLabelRotated,
|
|
2142
|
+
referenceLines,
|
|
2143
|
+
overlays,
|
|
2144
|
+
annotations,
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* Aggregate flat rows into a chart spec. Group by a category field, reduce a
|
|
2150
|
+
* value field per group. Three multi-series shapes:
|
|
2151
|
+
* - `value: 'revenue'` -> one series
|
|
2152
|
+
* - `value: ['revenue','cost']` -> one series per value field
|
|
2153
|
+
* - `value: 'sales', series: 'region'` -> pivot: one series per distinct
|
|
2154
|
+
* value of the `series` field
|
|
2155
|
+
*/
|
|
2156
|
+
export function rowsToChartSpec<T extends Record<string, unknown>>(
|
|
2157
|
+
rows: ReadonlyArray<T>,
|
|
2158
|
+
opts: {
|
|
2159
|
+
type: ChartType
|
|
2160
|
+
category: keyof T & string
|
|
2161
|
+
value: (keyof T & string) | Array<keyof T & string>
|
|
2162
|
+
/** Pivot dimension: one series per distinct value of this field. */
|
|
2163
|
+
series?: keyof T & string
|
|
2164
|
+
reduce?: 'sum' | 'avg' | 'count'
|
|
2165
|
+
seriesLabel?: string
|
|
2166
|
+
width?: number
|
|
2167
|
+
height?: number
|
|
2168
|
+
stacked?: boolean
|
|
2169
|
+
stacked100?: boolean
|
|
2170
|
+
palette?: string[]
|
|
2171
|
+
/** Order categories. Defaults to insertion order (or value-desc when topN). */
|
|
2172
|
+
sort?: 'value-desc' | 'value-asc' | 'category' | 'none'
|
|
2173
|
+
/** Keep only the top N categories; bucket the rest into "Other". */
|
|
2174
|
+
topN?: number
|
|
2175
|
+
/** Label for the bucketed remainder. Default "Other". */
|
|
2176
|
+
otherLabel?: string
|
|
2177
|
+
/** Field carrying each row's stable id. When set, the resulting spec's
|
|
2178
|
+
* series carry `rowIds` arrays so click handlers can drill back to
|
|
2179
|
+
* the source rows. */
|
|
2180
|
+
idField?: keyof T & string
|
|
2181
|
+
},
|
|
2182
|
+
): ChartSpec {
|
|
2183
|
+
const reduce = opts.reduce ?? 'sum'
|
|
2184
|
+
const valueFields = Array.isArray(opts.value) ? opts.value : [opts.value]
|
|
2185
|
+
const reduceCell = (sum: number, count: number) =>
|
|
2186
|
+
reduce === 'count' ? count : reduce === 'avg' ? (count ? sum / count : 0) : sum
|
|
2187
|
+
|
|
2188
|
+
const categories: string[] = []
|
|
2189
|
+
const catIndex = new Map<string, number>()
|
|
2190
|
+
const ensureCat = (key: string) => {
|
|
2191
|
+
let idx = catIndex.get(key)
|
|
2192
|
+
if (idx === undefined) {
|
|
2193
|
+
idx = categories.length
|
|
2194
|
+
catIndex.set(key, idx)
|
|
2195
|
+
categories.push(key)
|
|
2196
|
+
}
|
|
2197
|
+
return idx
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Series keyed by name -> per-category {sum,count,rowIds}.
|
|
2201
|
+
type Cell = { sum: number; count: number; rowIds: Array<string | number> }
|
|
2202
|
+
const seriesMap = new Map<string, Cell[]>()
|
|
2203
|
+
const ensureSeries = (name: string) => {
|
|
2204
|
+
let arr = seriesMap.get(name)
|
|
2205
|
+
if (!arr) {
|
|
2206
|
+
arr = []
|
|
2207
|
+
seriesMap.set(name, arr)
|
|
2208
|
+
}
|
|
2209
|
+
return arr
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
const trackIds = opts.idField !== undefined
|
|
2213
|
+
for (const row of rows) {
|
|
2214
|
+
const cat = String(row[opts.category] ?? '')
|
|
2215
|
+
const ci = ensureCat(cat)
|
|
2216
|
+
const rowId = trackIds ? (row[opts.idField as keyof T] as string | number) : undefined
|
|
2217
|
+
if (opts.series) {
|
|
2218
|
+
const sName = String(row[opts.series] ?? '')
|
|
2219
|
+
const arr = ensureSeries(sName)
|
|
2220
|
+
const num = Number(row[valueFields[0]!])
|
|
2221
|
+
const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
|
|
2222
|
+
if (Number.isFinite(num)) {
|
|
2223
|
+
cell.sum += num
|
|
2224
|
+
cell.count += 1
|
|
2225
|
+
if (rowId !== undefined) cell.rowIds.push(rowId)
|
|
2226
|
+
}
|
|
2227
|
+
} else {
|
|
2228
|
+
for (const vf of valueFields) {
|
|
2229
|
+
const arr = ensureSeries(vf)
|
|
2230
|
+
const num = Number(row[vf])
|
|
2231
|
+
const cell = (arr[ci] ??= { sum: 0, count: 0, rowIds: [] })
|
|
2232
|
+
if (Number.isFinite(num)) {
|
|
2233
|
+
cell.sum += num
|
|
2234
|
+
cell.count += 1
|
|
2235
|
+
if (rowId !== undefined) cell.rowIds.push(rowId)
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
const entries = [...seriesMap.entries()].map(([name, arr]) => ({
|
|
2242
|
+
label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
|
|
2243
|
+
values: categories.map((_, i) => {
|
|
2244
|
+
const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] as Array<string | number> }
|
|
2245
|
+
return reduceCell(cell.sum, cell.count)
|
|
2246
|
+
}),
|
|
2247
|
+
rowIds: trackIds
|
|
2248
|
+
? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
|
|
2249
|
+
: undefined,
|
|
2250
|
+
}))
|
|
2251
|
+
|
|
2252
|
+
// ---- Sort + top-N -----------------------------------------------------
|
|
2253
|
+
const totals = categories.map((_, i) =>
|
|
2254
|
+
entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0),
|
|
2255
|
+
)
|
|
2256
|
+
const sort = opts.sort ?? (opts.topN ? 'value-desc' : 'none')
|
|
2257
|
+
let order = categories.map((_, i) => i)
|
|
2258
|
+
if (sort === 'value-desc') order.sort((a, b) => totals[b]! - totals[a]!)
|
|
2259
|
+
else if (sort === 'value-asc') order.sort((a, b) => totals[a]! - totals[b]!)
|
|
2260
|
+
else if (sort === 'category') order.sort((a, b) => categories[a]!.localeCompare(categories[b]!))
|
|
2261
|
+
|
|
2262
|
+
let finalCategories: string[]
|
|
2263
|
+
let finalSeries: ChartSeries[]
|
|
2264
|
+
if (opts.topN && order.length > opts.topN) {
|
|
2265
|
+
const keep = order.slice(0, opts.topN)
|
|
2266
|
+
const rest = order.slice(opts.topN)
|
|
2267
|
+
finalCategories = keep.map((i) => categories[i]!).concat(opts.otherLabel ?? 'Other')
|
|
2268
|
+
finalSeries = entries.map((e) => ({
|
|
2269
|
+
label: e.label,
|
|
2270
|
+
values: keep
|
|
2271
|
+
.map((i) => e.values[i]!)
|
|
2272
|
+
.concat(rest.reduce((sum, i) => sum + (Number.isFinite(e.values[i]!) ? e.values[i]! : 0), 0)),
|
|
2273
|
+
rowIds: e.rowIds
|
|
2274
|
+
? keep.map((i) => e.rowIds![i]!).concat([rest.flatMap((i) => e.rowIds![i] ?? [])])
|
|
2275
|
+
: undefined,
|
|
2276
|
+
}))
|
|
2277
|
+
} else {
|
|
2278
|
+
finalCategories = order.map((i) => categories[i]!)
|
|
2279
|
+
finalSeries = entries.map((e) => ({
|
|
2280
|
+
label: e.label,
|
|
2281
|
+
values: order.map((i) => e.values[i]!),
|
|
2282
|
+
rowIds: e.rowIds ? order.map((i) => e.rowIds![i]!) : undefined,
|
|
2283
|
+
}))
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
return {
|
|
2287
|
+
type: opts.type,
|
|
2288
|
+
categories: finalCategories,
|
|
2289
|
+
series: finalSeries,
|
|
2290
|
+
width: opts.width,
|
|
2291
|
+
height: opts.height,
|
|
2292
|
+
stacked: opts.stacked,
|
|
2293
|
+
stacked100: opts.stacked100,
|
|
2294
|
+
palette: opts.palette,
|
|
2295
|
+
}
|
|
2296
|
+
}
|