@svgrid/grid 3.0.0 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -88
- package/README.md +199 -199
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +190 -181
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/GridMenus.svelte +18 -10
- package/dist/GridMenus.svelte.d.ts +2 -1
- package/dist/SvAutoComplete.svelte +169 -169
- package/dist/SvAvatar.svelte +75 -75
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +102 -102
- package/dist/SvCircularProgress.svelte +109 -109
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +279 -279
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +163 -163
- package/dist/SvDrawer.svelte +254 -254
- package/dist/SvDropDownList.svelte +378 -378
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.controller.svelte.d.ts +17 -0
- package/dist/SvGrid.controller.svelte.js +128 -14
- package/dist/SvGrid.css +79 -10
- package/dist/SvGrid.svelte +191 -186
- package/dist/SvGrid.types.d.ts +42 -1
- package/dist/SvGridCellEditor.svelte +748 -744
- package/dist/SvGridChart.svelte +2246 -1716
- package/dist/SvGridChart.svelte.d.ts +63 -1
- package/dist/SvGridChartPanel.svelte +633 -485
- package/dist/SvGridChartView.svelte +79 -70
- package/dist/SvGridChartView.svelte.d.ts +7 -0
- package/dist/SvGridDropdown.svelte +728 -728
- package/dist/SvGridSelect.svelte +270 -270
- package/dist/SvGroupCell.svelte +141 -116
- package/dist/SvGroupCell.svelte.d.ts +8 -0
- package/dist/SvListBox.svelte +334 -334
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +293 -293
- package/dist/SvNumberInput.svelte +172 -172
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +133 -133
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvProgress.svelte +116 -116
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRating.svelte +112 -112
- package/dist/SvResult.svelte +73 -73
- package/dist/SvRichText.svelte +211 -211
- package/dist/SvRowGroupPanel.svelte +186 -170
- package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +203 -203
- package/dist/SvSwitchButton.svelte +108 -108
- package/dist/SvTagsInput.svelte +115 -115
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +208 -208
- package/dist/SvTree.svelte +444 -444
- package/dist/SvTreeSelect.svelte +239 -239
- package/dist/ai.d.ts +11 -2
- package/dist/ai.js +51 -7
- package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
- package/dist/cdn/GridMenus-M2XP9t0_.js +603 -0
- package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
- package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
- package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
- package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
- package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-CxfakxR0.js} +5 -3
- package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-Dm_YEx9L.js} +5 -3
- package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
- package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
- package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
- package/dist/cdn/chart-CKJRCPOk.js +1652 -0
- package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
- package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
- package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
- package/dist/cdn/{src-B_YS5AOc.js → src-BJ2opd0j.js} +3642 -3450
- package/dist/cdn/{src-DYXXpuSk.js → src-DNbz94Ct.js} +8649 -8457
- package/dist/cdn/svgrid.js +16 -15
- package/dist/cdn/svgrid.svelte-external.js +16 -15
- package/dist/cell-formatting.d.ts +7 -0
- package/dist/cell-formatting.js +7 -1
- package/dist/chart-export.d.ts +23 -2
- package/dist/chart-export.js +88 -14
- package/dist/chart.d.ts +410 -18
- package/dist/chart.js +824 -24
- package/dist/editing.js +22 -13
- package/dist/grid-icons.d.ts +49 -0
- package/dist/grid-icons.js +86 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/package.json +1 -1
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +190 -181
- package/src/GridMenus.svelte +18 -10
- package/src/SvAutoComplete.svelte +169 -169
- package/src/SvAvatar.svelte +75 -75
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +102 -102
- package/src/SvCircularProgress.svelte +109 -109
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +279 -279
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +163 -163
- package/src/SvDrawer.svelte +254 -254
- package/src/SvDropDownList.svelte +378 -378
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.controller.svelte.ts +146 -13
- package/src/SvGrid.css +79 -10
- package/src/SvGrid.svelte +191 -186
- package/src/SvGrid.types.ts +2133 -2092
- package/src/SvGridCellEditor.svelte +748 -744
- package/src/SvGridChart.svelte +2246 -1716
- package/src/SvGridChart.test.ts +243 -1
- package/src/SvGridChartPanel.svelte +633 -485
- package/src/SvGridChartView.svelte +79 -70
- package/src/SvGridDropdown.svelte +728 -728
- package/src/SvGridSelect.svelte +270 -270
- package/src/SvGroupCell.svelte +141 -116
- package/src/SvListBox.svelte +334 -334
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +293 -293
- package/src/SvNumberInput.svelte +172 -172
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +133 -133
- package/src/SvPopover.svelte +197 -197
- package/src/SvProgress.svelte +116 -116
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRating.svelte +112 -112
- package/src/SvResult.svelte +73 -73
- package/src/SvRichText.svelte +211 -211
- package/src/SvRowGroupPanel.svelte +186 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +203 -203
- package/src/SvSwitchButton.svelte +108 -108
- package/src/SvTagsInput.svelte +115 -115
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +208 -208
- package/src/SvTree.svelte +444 -444
- package/src/SvTreeSelect.svelte +239 -239
- package/src/a11y/dismissable.test.ts +119 -119
- package/src/a11y/dismissable.ts +114 -114
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +561 -502
- package/src/ai.ts +1472 -1419
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +846 -846
- package/src/builtin-editors.grid.test.ts +83 -83
- package/src/cell-formatting.ts +177 -171
- package/src/cell-render.test.ts +513 -513
- package/src/cell-render.ts +496 -496
- package/src/cell-values.ts +148 -148
- package/src/chart-export.test.ts +112 -0
- package/src/chart-export.ts +270 -202
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.test.ts +830 -1
- package/src/chart.ts +3342 -2321
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/column-groups.ts +78 -78
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1865 -1865
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createListbox.svelte.ts +250 -250
- package/src/createMenu.svelte.ts +224 -224
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +322 -322
- package/src/datetime/date-core.ts +206 -206
- package/src/datetime/date-restrict.ts +61 -61
- package/src/datetime/timezone.ts +135 -135
- package/src/dock-manager-model.ts +596 -596
- package/src/dock-model.ts +374 -374
- package/src/editing.test.ts +974 -974
- package/src/editing.ts +618 -609
- package/src/editor-contract.ts +171 -171
- package/src/editor-registry.grid.test.ts +144 -144
- package/src/editor-registry.ts +122 -122
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +601 -601
- package/src/filter-operators.ts +160 -160
- package/src/filtering/excel-filters.ts +325 -325
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/grid-icons.test.ts +46 -0
- package/src/grid-icons.ts +184 -0
- package/src/group-display.test.ts +167 -167
- package/src/group-display.ts +200 -200
- package/src/headless.ts +87 -87
- package/src/icon-overrides.grid.test.ts +188 -0
- package/src/icon-seam.test.ts +100 -0
- package/src/index.ts +19 -0
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard-handlers.ts +270 -270
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +179 -179
- package/src/menus.ts +597 -597
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/positioning.ts +268 -268
- package/src/render-component.ts +28 -28
- package/src/row-drag.test.ts +401 -401
- package/src/row-drag.ts +419 -419
- package/src/row-resize.test.ts +524 -524
- package/src/row-resize.ts +228 -228
- package/src/scheduler-ical.ts +181 -181
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +873 -873
- package/src/selection.test.ts +885 -885
- package/src/server-data-source.test.ts +383 -383
- package/src/server-data-source.ts +469 -469
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +488 -488
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/summaries.ts +113 -113
- package/src/svgrid-wrapper.types.ts +563 -563
- package/src/svgrid.async-editor-options.test.ts +273 -273
- package/src/svgrid.auto-row-height.test.ts +204 -204
- package/src/svgrid.behavior.test.ts +910 -910
- package/src/svgrid.charting.test.ts +654 -534
- package/src/svgrid.comments-autocomplete.test.ts +127 -127
- package/src/svgrid.context-menu.test.ts +147 -147
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-depth.test.ts +163 -163
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
- package/src/svgrid.filter-menu-scroll.test.ts +112 -112
- package/src/svgrid.grand-total.test.ts +188 -188
- package/src/svgrid.group-display-mode.test.ts +171 -171
- package/src/svgrid.group-footers.test.ts +121 -121
- package/src/svgrid.group-pagination.test.ts +153 -153
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.tree-data.test.ts +186 -186
- package/src/svgrid.wrapper.test.ts +63 -63
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +288 -288
- package/src/toast-store.svelte.ts +250 -250
- package/src/toast-store.test.ts +147 -147
- package/src/tree-row-model.test.ts +168 -168
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-localization.test.ts +113 -113
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +322 -322
- package/dist/cdn/GridMenus-DMihUtma.js +0 -600
- package/dist/cdn/GridMenus-nmNDj1a3.js +0 -601
- package/dist/cdn/SvGridChart-Bs2GIR2Q.js +0 -1202
- package/dist/cdn/SvGridChart-CwhFz7GV.js +0 -1203
- package/dist/cdn/SvGridChartPanel-1WXuStzM.js +0 -561
- package/dist/cdn/SvGridChartPanel-e5_nqNP9.js +0 -585
- package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
package/src/SvGridChart.svelte
CHANGED
|
@@ -1,1716 +1,2246 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* SvGridChart - renders a `ChartSpec` as inline SVG. Supports grouped +
|
|
4
|
-
* stacked bars, line, area, pie / donut, combo charts (per-series type),
|
|
5
|
-
* a secondary Y axis, signed Y domains, and axis titles. Interactive: a
|
|
6
|
-
* unified crosshair tooltip (hover a category -> all series at once),
|
|
7
|
-
* focus tooltips, a clickable legend that toggles series, optional data
|
|
8
|
-
* labels, and an `onSelect` drill hook. No external charting dependency.
|
|
9
|
-
*/
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
legend
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
let
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
function
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
else
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
})
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
$
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
function
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
|
|
496
|
-
function
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
if (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (!
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
{
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
{
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
{
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
{
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
{
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
{
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
<
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
{/if}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
{#each geo.
|
|
1013
|
-
|
|
1014
|
-
<
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
{
|
|
1029
|
-
{
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
class="sv-grid-chart-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
{#
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
{
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
{
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
{
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
{#
|
|
1241
|
-
<
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
{
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
.sv-grid-chart
|
|
1698
|
-
position:
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvGridChart - renders a `ChartSpec` as inline SVG. Supports grouped +
|
|
4
|
+
* stacked bars, line, area, pie / donut, combo charts (per-series type),
|
|
5
|
+
* a secondary Y axis, signed Y domains, and axis titles. Interactive: a
|
|
6
|
+
* unified crosshair tooltip (hover a category -> all series at once),
|
|
7
|
+
* focus tooltips, a clickable legend that toggles series, optional data
|
|
8
|
+
* labels, and an `onSelect` drill hook. No external charting dependency.
|
|
9
|
+
*/
|
|
10
|
+
import type { Snippet } from 'svelte'
|
|
11
|
+
import { buildChart, chartScales, DEFAULT_PALETTE, formatChartValue, sliceChartWindow, type ChartSpec, type ChartSelection, type ChartGeometry, type ChartScales } from './chart'
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
spec: ChartSpec
|
|
15
|
+
/** Show the (clickable) legend. Default true. */
|
|
16
|
+
legend?: boolean
|
|
17
|
+
/** Enable tooltips + crosshair + legend toggling. Default true. */
|
|
18
|
+
interactive?: boolean
|
|
19
|
+
/** Draw the value on each bar / point / slice. Default false. */
|
|
20
|
+
dataLabels?: boolean
|
|
21
|
+
/** Format a value for tooltips, data labels, AND Y-axis ticks. */
|
|
22
|
+
formatValue?: (value: number) => string
|
|
23
|
+
/** Fired when a category / slice is clicked (drill into the grid). */
|
|
24
|
+
onSelect?: (selection: ChartSelection) => void
|
|
25
|
+
/** Like `onSelect`, but only fires when the spec carries `rowIds` and
|
|
26
|
+
* includes them in the payload. Use this to filter / highlight the
|
|
27
|
+
* source grid when the user clicks a chart element. */
|
|
28
|
+
onDrill?: (selection: ChartSelection) => void
|
|
29
|
+
/** Allow drag-to-zoom on the plot area. Double-click resets.
|
|
30
|
+
* Only meaningful for cartesian charts (not pie). Default false. */
|
|
31
|
+
zoomable?: boolean
|
|
32
|
+
/** Show a compact brush / mini-map under the main chart. A draggable
|
|
33
|
+
* window selects what the main chart shows. Pairs naturally with
|
|
34
|
+
* `zoomable`. Default false. */
|
|
35
|
+
brush?: boolean
|
|
36
|
+
/** Height of the brush strip in pixels. Default 88. */
|
|
37
|
+
brushHeight?: number
|
|
38
|
+
/** Show a small toolbar above the chart: reset-zoom (when zoomed) and
|
|
39
|
+
* export (PNG / SVG / copy). Default true when zoomable or onDrill is
|
|
40
|
+
* set, otherwise false. Set to `false` to hide explicitly. */
|
|
41
|
+
toolbar?: boolean
|
|
42
|
+
/** Explicit chart pixel size (viewBox). When set, the chart lays out to
|
|
43
|
+
* fit exactly - used by the docked panel to size the chart to its body. */
|
|
44
|
+
width?: number
|
|
45
|
+
height?: number
|
|
46
|
+
/**
|
|
47
|
+
* Draw your own marks in the chart's own coordinates - the custom-series
|
|
48
|
+
* seam. `underlay` paints beneath the built-in marks (bands, shaded
|
|
49
|
+
* regions, a background you want the bars to sit on); `overlay` paints
|
|
50
|
+
* above them but below the crosshair and the hit layer, so tooltips and
|
|
51
|
+
* clicks keep working over whatever you draw.
|
|
52
|
+
*
|
|
53
|
+
* Both receive the laid-out geometry and its scales, so a custom mark lands
|
|
54
|
+
* on exactly the axis the built-in ones did:
|
|
55
|
+
*
|
|
56
|
+
* ```svelte
|
|
57
|
+
* <SvChart {spec}>
|
|
58
|
+
* {#snippet overlay({ geo, xOf, yOf })}
|
|
59
|
+
* <circle cx={xOf(3)} cy={yOf(120)} r="5" fill="tomato" />
|
|
60
|
+
* {/snippet}
|
|
61
|
+
* </SvChart>
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* `xOf` / `yOf` are null for the types with no cartesian axes (pie, gauge,
|
|
65
|
+
* treemap, sankey, calendar, radar, funnel); `geo` is always there.
|
|
66
|
+
*/
|
|
67
|
+
underlay?: Snippet<[ChartRenderContext]>
|
|
68
|
+
overlay?: Snippet<[ChartRenderContext]>
|
|
69
|
+
/**
|
|
70
|
+
* Let a reader pin annotations on the plot. Adds an "Annotate" toggle to
|
|
71
|
+
* the toolbar; while it is on, clicking the plot fires `onAnnotate` with
|
|
72
|
+
* the point that was clicked, and clicking an existing marker fires
|
|
73
|
+
* `onAnnotationRemove` with its index.
|
|
74
|
+
*
|
|
75
|
+
* The chart does NOT store them. It owns the gesture - which you cannot
|
|
76
|
+
* build from outside, because drag is already zoom and click is already
|
|
77
|
+
* drill - and hands back the data point; where the annotations live, and
|
|
78
|
+
* whether they survive a reload, is the host's business. Put what comes
|
|
79
|
+
* back into `spec.annotations` and they render like any other.
|
|
80
|
+
*/
|
|
81
|
+
annotatable?: boolean
|
|
82
|
+
/**
|
|
83
|
+
* A point the reader picked while annotating. It is a DATA point, not a
|
|
84
|
+
* pixel: annotate mode takes over the category gesture, so a note anchors
|
|
85
|
+
* to the category it was pinned on and stays there when the chart is
|
|
86
|
+
* re-laid-out, re-sorted or zoomed. That also means it works from the
|
|
87
|
+
* keyboard, since the category hit zones already navigate with arrows.
|
|
88
|
+
*/
|
|
89
|
+
onAnnotate?: (at: {
|
|
90
|
+
/** The category picked, and its index. */
|
|
91
|
+
category: string
|
|
92
|
+
index: number
|
|
93
|
+
/** The value at that category on the first series. */
|
|
94
|
+
value: number
|
|
95
|
+
}) => void
|
|
96
|
+
/** An existing `spec.annotations` entry was clicked, by index. */
|
|
97
|
+
onAnnotationRemove?: (index: number) => void
|
|
98
|
+
}
|
|
99
|
+
/** What a custom mark is handed. `scales` is null on non-cartesian types. */
|
|
100
|
+
type ChartRenderContext = {
|
|
101
|
+
geo: ChartGeometry
|
|
102
|
+
/** Pixel x at the centre of category `i`. Null when there is no x axis. */
|
|
103
|
+
xOf: ChartScales['xOf'] | null
|
|
104
|
+
/** Pixel y for a value. Null when there is no value axis. */
|
|
105
|
+
yOf: ChartScales['yOf'] | null
|
|
106
|
+
scales: ChartScales | null
|
|
107
|
+
}
|
|
108
|
+
let {
|
|
109
|
+
spec,
|
|
110
|
+
legend = true,
|
|
111
|
+
interactive = true,
|
|
112
|
+
dataLabels = false,
|
|
113
|
+
formatValue,
|
|
114
|
+
onSelect,
|
|
115
|
+
onDrill,
|
|
116
|
+
zoomable = false,
|
|
117
|
+
brush = false,
|
|
118
|
+
brushHeight = 88,
|
|
119
|
+
toolbar,
|
|
120
|
+
width,
|
|
121
|
+
height,
|
|
122
|
+
underlay,
|
|
123
|
+
overlay,
|
|
124
|
+
annotatable = false,
|
|
125
|
+
onAnnotate,
|
|
126
|
+
onAnnotationRemove,
|
|
127
|
+
}: Props = $props()
|
|
128
|
+
const showToolbar = $derived(toolbar ?? (zoomable || !!onDrill || annotatable))
|
|
129
|
+
/** Annotate mode is off until the reader asks for it: it takes over the plot
|
|
130
|
+
* click, which is the drill gesture the rest of the time. */
|
|
131
|
+
let annotating = $state(false)
|
|
132
|
+
|
|
133
|
+
const fmt = (v: number) =>
|
|
134
|
+
formatValue
|
|
135
|
+
? formatValue(v)
|
|
136
|
+
: spec.valueFormat
|
|
137
|
+
? formatChartValue(v, spec.valueFormat, spec)
|
|
138
|
+
: Number.isFinite(v)
|
|
139
|
+
? v.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
140
|
+
: String(v)
|
|
141
|
+
|
|
142
|
+
let hidden = $state(new Set<string>())
|
|
143
|
+
// Isolate: double-clicking a legend chip shows ONLY that series/slice.
|
|
144
|
+
let isolated = $state<string | null>(null)
|
|
145
|
+
// Series colours read from --sg-chart-1..8. Declared here because the
|
|
146
|
+
// palette derivation below reads it, and a rune used above its declaration
|
|
147
|
+
// is an error rather than a hoist. Filled by the theme effect further down.
|
|
148
|
+
let tokenPalette = $state<string[] | null>(null)
|
|
149
|
+
|
|
150
|
+
// Hover-dim: hovering a legend chip dims the other series (visual only).
|
|
151
|
+
let dimmed = $state<string | null>(null)
|
|
152
|
+
// An explicit `spec.palette` is the author speaking about THIS chart, so it
|
|
153
|
+
// outranks the theme's tokens, which in turn outrank the built-in colours.
|
|
154
|
+
const palette = $derived(spec.palette ?? tokenPalette ?? DEFAULT_PALETTE)
|
|
155
|
+
const uid = `svgc-${Math.random().toString(36).slice(2, 8)}`
|
|
156
|
+
|
|
157
|
+
const coloredSeries = $derived(
|
|
158
|
+
spec.series.map((s, i) => ({ ...s, color: s.color ?? palette[i % palette.length]! })),
|
|
159
|
+
)
|
|
160
|
+
/** Resolve effective pattern per series: explicit > cycle (when
|
|
161
|
+
* patternFallback is on) > 'solid'. Cycle skips 'solid' so every series
|
|
162
|
+
* ends up with a distinct texture. */
|
|
163
|
+
const seriesPatterns = $derived(
|
|
164
|
+
spec.series.map((s, i): string => {
|
|
165
|
+
if (s.pattern) return s.pattern
|
|
166
|
+
if (spec.patternFallback) return ['stripe', 'crosshatch', 'dots', 'diagonal'][i % 4]!
|
|
167
|
+
return 'solid'
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
/** Map from series label to fill string used in SVG (either the plain
|
|
171
|
+
* color or `url(#pattern-id)`). */
|
|
172
|
+
const seriesFill = $derived.by<Record<string, string>>(() => {
|
|
173
|
+
const out: Record<string, string> = {}
|
|
174
|
+
coloredSeries.forEach((s, i) => {
|
|
175
|
+
const p = seriesPatterns[i]
|
|
176
|
+
out[s.label] = p === 'solid' || !p ? s.color : `url(#${uid}-pat-${i})`
|
|
177
|
+
})
|
|
178
|
+
return out
|
|
179
|
+
})
|
|
180
|
+
/** Pattern <defs> to emit. Each entry pairs a unique id with the series
|
|
181
|
+
* color so the pattern carries the right hue baked in. */
|
|
182
|
+
const patternDefs = $derived(
|
|
183
|
+
coloredSeries
|
|
184
|
+
.map((s, i) => ({ id: `${uid}-pat-${i}`, color: s.color, kind: seriesPatterns[i] ?? 'solid' }))
|
|
185
|
+
.filter((d) => d.kind !== 'solid'),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// Effective hidden set = manual toggles, unless a series is isolated (then
|
|
189
|
+
// everything else is hidden).
|
|
190
|
+
const effectiveHidden = $derived.by(() => {
|
|
191
|
+
if (isolated == null) return hidden
|
|
192
|
+
const labels = spec.type === 'pie' ? spec.categories : spec.series.map((s) => s.label)
|
|
193
|
+
return new Set(labels.filter((l) => l !== isolated))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ---- Zoom state ------------------------------------------------------
|
|
197
|
+
// `zoom` holds inclusive integer category indices [i0, i1] of the visible
|
|
198
|
+
// window. null = no zoom (show everything). Pie charts don't support
|
|
199
|
+
// zoom; their pseudo-categories are slices, not an axis.
|
|
200
|
+
let zoom = $state<{ i0: number; i1: number } | null>(null)
|
|
201
|
+
const isZoomed = $derived(zoom !== null)
|
|
202
|
+
function resetZoom() { zoom = null }
|
|
203
|
+
|
|
204
|
+
// Drag-to-zoom: track pointer in SVG-local coordinates so the overlay
|
|
205
|
+
// rect aligns with what the user is actually selecting on screen.
|
|
206
|
+
let svgEl: SVGSVGElement | null = $state(null)
|
|
207
|
+
let dragStart = $state<{ x: number; y: number } | null>(null)
|
|
208
|
+
let dragEnd = $state<{ x: number; y: number } | null>(null)
|
|
209
|
+
const dragRect = $derived.by(() => {
|
|
210
|
+
if (!dragStart || !dragEnd) return null
|
|
211
|
+
const x = Math.min(dragStart.x, dragEnd.x)
|
|
212
|
+
const y = Math.min(dragStart.y, dragEnd.y)
|
|
213
|
+
const w = Math.abs(dragEnd.x - dragStart.x)
|
|
214
|
+
const h = Math.abs(dragEnd.y - dragStart.y)
|
|
215
|
+
return w > 4 && h > 4 ? { x, y, w, h } : null
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
function svgPoint(e: PointerEvent): { x: number; y: number } | null {
|
|
219
|
+
if (!svgEl) return null
|
|
220
|
+
const r = svgEl.getBoundingClientRect()
|
|
221
|
+
return {
|
|
222
|
+
x: ((e.clientX - r.left) / r.width) * geo.width,
|
|
223
|
+
y: ((e.clientY - r.top) / r.height) * geo.height,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Clamp a point's main-axis coordinate to an integer category index. */
|
|
227
|
+
function pointToCatIndex(p: { x: number; y: number }): number {
|
|
228
|
+
const coord = isHorizontal ? (p.y - geo.plot.y) : (p.x - geo.plot.x)
|
|
229
|
+
const idx = Math.floor(coord / slot)
|
|
230
|
+
return Math.max(0, Math.min(visibleSpec.categories.length - 1, idx))
|
|
231
|
+
}
|
|
232
|
+
function onZoomDown(e: PointerEvent) {
|
|
233
|
+
// Annotate mode takes the plot pointer over. Without this a click to pin an
|
|
234
|
+
// annotation also starts a zoom drag, and the reader gets both.
|
|
235
|
+
if (annotating) return
|
|
236
|
+
if (!zoomable || !isCartesian) return
|
|
237
|
+
const p = svgPoint(e)
|
|
238
|
+
if (!p) return
|
|
239
|
+
// Only start a drag inside the plot rect.
|
|
240
|
+
if (p.x < geo.plot.x || p.x > geo.plot.x + geo.plot.w ||
|
|
241
|
+
p.y < geo.plot.y || p.y > geo.plot.y + geo.plot.h) return
|
|
242
|
+
dragStart = p
|
|
243
|
+
dragEnd = p
|
|
244
|
+
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
|
245
|
+
}
|
|
246
|
+
function onZoomMove(e: PointerEvent) {
|
|
247
|
+
if (!dragStart) return
|
|
248
|
+
dragEnd = svgPoint(e)
|
|
249
|
+
}
|
|
250
|
+
function onZoomUp() {
|
|
251
|
+
if (!dragStart || !dragEnd) { dragStart = null; dragEnd = null; return }
|
|
252
|
+
const r = dragRect
|
|
253
|
+
if (r) {
|
|
254
|
+
// Map the drag's main-axis bounds to category indices. When already
|
|
255
|
+
// zoomed, these indices are relative to the visible slice, so
|
|
256
|
+
// translate back to the original `spec.categories` indexing.
|
|
257
|
+
const i0v = pointToCatIndex({ x: r.x, y: r.y })
|
|
258
|
+
const i1v = pointToCatIndex({ x: r.x + r.w, y: r.y + r.h })
|
|
259
|
+
const offset = zoom ? zoom.i0 : 0
|
|
260
|
+
const lo = offset + Math.min(i0v, i1v)
|
|
261
|
+
const hi = offset + Math.max(i0v, i1v)
|
|
262
|
+
if (hi > lo) zoom = { i0: lo, i1: hi }
|
|
263
|
+
}
|
|
264
|
+
dragStart = null
|
|
265
|
+
dragEnd = null
|
|
266
|
+
}
|
|
267
|
+
function onPlotDblClick() {
|
|
268
|
+
if (zoomable && isZoomed) resetZoom()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Candles are cartesian, and the `isCartesian` negative list below already
|
|
272
|
+
// covers them, so they inherit gridlines, the crosshair, category hit bands,
|
|
273
|
+
// keyboard nav, drag-zoom and double-click reset with no new wiring.
|
|
274
|
+
// Declared up here because the brush spec reads it, and a `$derived` used
|
|
275
|
+
// above its declaration is an error rather than a hoist.
|
|
276
|
+
const isCandle = $derived(spec.type === 'candlestick' || spec.type === 'ohlc')
|
|
277
|
+
/** OHLC draws ticks off a vertical; candlestick draws a body and a wick. */
|
|
278
|
+
const isOhlcBars = $derived(spec.type === 'ohlc')
|
|
279
|
+
|
|
280
|
+
// ---- Brush state -----------------------------------------------------
|
|
281
|
+
// The brush is a compact second chart showing the FULL data range with
|
|
282
|
+
// a translucent window over the visible slice. Dragging the body pans
|
|
283
|
+
// the window; dragging either edge resizes one side. Eligible for
|
|
284
|
+
// cartesian charts only (heatmap / pie don't have a single x-axis).
|
|
285
|
+
const brushEligible = $derived(brush && spec.type !== 'pie' && spec.type !== 'heatmap')
|
|
286
|
+
// Build a separate geometry from the un-zoomed spec at brushHeight.
|
|
287
|
+
const brushSpec = $derived<ChartSpec>({
|
|
288
|
+
...spec,
|
|
289
|
+
height: brushHeight,
|
|
290
|
+
// Mute axes in the brush by clearing titles + reference lines.
|
|
291
|
+
yAxisTitle: undefined, y2AxisTitle: undefined, xAxisTitle: undefined,
|
|
292
|
+
referenceLines: undefined,
|
|
293
|
+
annotations: undefined,
|
|
294
|
+
// The mini-map draws candles as a close-price line: at brush scale a
|
|
295
|
+
// candle is a couple of pixels wide and reads as noise, while a close line
|
|
296
|
+
// is the shape a reader actually navigates by.
|
|
297
|
+
// A box gets the same treatment for the same reason: at brush scale it is
|
|
298
|
+
// a smear, while the median line is a shape you can navigate by.
|
|
299
|
+
type: isCandle ? 'line' : spec.type === 'boxplot' ? 'line' : spec.type,
|
|
300
|
+
series: coloredSeries
|
|
301
|
+
.filter((s) => !effectiveHidden.has(s.label))
|
|
302
|
+
.map((s) =>
|
|
303
|
+
s.ohlc || s.boxes
|
|
304
|
+
? { ...s, ohlc: undefined, boxes: undefined, errors: undefined, type: 'line' as const }
|
|
305
|
+
: s.errors
|
|
306
|
+
? { ...s, errors: undefined }
|
|
307
|
+
: s,
|
|
308
|
+
),
|
|
309
|
+
})
|
|
310
|
+
const brushGeo = $derived(brushEligible ? buildChart(brushSpec) : null)
|
|
311
|
+
/** The brush window in fractional [0..1] of the visible data range,
|
|
312
|
+
* derived from `zoom`. When not zoomed, the window covers everything. */
|
|
313
|
+
const brushWindow = $derived.by(() => {
|
|
314
|
+
const n = spec.categories.length
|
|
315
|
+
if (!zoom || n === 0) return { t0: 0, t1: 1 }
|
|
316
|
+
return { t0: zoom.i0 / n, t1: (zoom.i1 + 1) / n }
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
type BrushDrag ={ mode: 'move' | 'left' | 'right'; startX: number; startT0: number; startT1: number }
|
|
320
|
+
let brushDrag = $state<BrushDrag | null>(null)
|
|
321
|
+
function brushSvgX(e: PointerEvent, svg: SVGSVGElement): number {
|
|
322
|
+
const r = svg.getBoundingClientRect()
|
|
323
|
+
if (!brushGeo) return 0
|
|
324
|
+
return ((e.clientX - r.left) / r.width) * brushGeo.width
|
|
325
|
+
}
|
|
326
|
+
function onBrushDown(e: PointerEvent, mode: BrushDrag['mode']) {
|
|
327
|
+
if (!brushGeo) return
|
|
328
|
+
const svg = e.currentTarget as SVGSVGElement
|
|
329
|
+
const x = brushSvgX(e, svg.ownerSVGElement ?? svg)
|
|
330
|
+
brushDrag = { mode, startX: x, startT0: brushWindow.t0, startT1: brushWindow.t1 }
|
|
331
|
+
;(e.currentTarget as Element).setPointerCapture?.(e.pointerId)
|
|
332
|
+
e.stopPropagation()
|
|
333
|
+
}
|
|
334
|
+
function onBrushMove(e: PointerEvent) {
|
|
335
|
+
if (!brushDrag || !brushGeo) return
|
|
336
|
+
const svg = (e.currentTarget as SVGSVGElement).ownerSVGElement ?? (e.currentTarget as SVGSVGElement)
|
|
337
|
+
const x = brushSvgX(e, svg)
|
|
338
|
+
const dx = x - brushDrag.startX
|
|
339
|
+
const tw = brushGeo.plot.w
|
|
340
|
+
const dt = dx / tw
|
|
341
|
+
const n = spec.categories.length
|
|
342
|
+
if (n === 0) return
|
|
343
|
+
let t0 = brushDrag.startT0
|
|
344
|
+
let t1 = brushDrag.startT1
|
|
345
|
+
if (brushDrag.mode === 'move') {
|
|
346
|
+
const w = t1 - t0
|
|
347
|
+
t0 = Math.max(0, Math.min(1 - w, t0 + dt))
|
|
348
|
+
t1 = t0 + w
|
|
349
|
+
} else if (brushDrag.mode === 'left') {
|
|
350
|
+
// Keep at least 1 category visible.
|
|
351
|
+
t0 = Math.max(0, Math.min(t1 - 1 / n, t0 + dt))
|
|
352
|
+
} else {
|
|
353
|
+
t1 = Math.min(1, Math.max(t0 + 1 / n, t1 + dt))
|
|
354
|
+
}
|
|
355
|
+
// Translate back to integer category indices.
|
|
356
|
+
const i0 = Math.max(0, Math.floor(t0 * n))
|
|
357
|
+
const i1 = Math.min(n - 1, Math.ceil(t1 * n) - 1)
|
|
358
|
+
if (i0 === 0 && i1 === n - 1) zoom = null
|
|
359
|
+
else zoom = { i0, i1: Math.max(i0, i1) }
|
|
360
|
+
}
|
|
361
|
+
function onBrushUp(e: PointerEvent) {
|
|
362
|
+
brushDrag = null
|
|
363
|
+
;(e.currentTarget as Element).releasePointerCapture?.(e.pointerId)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const visibleSpec = $derived.by<ChartSpec>(() => {
|
|
367
|
+
if (spec.type === 'pie') {
|
|
368
|
+
const s = coloredSeries[0]
|
|
369
|
+
if (!s) return spec
|
|
370
|
+
const values = s.values.map((v, i) => (effectiveHidden.has(spec.categories[i] ?? String(i)) ? 0 : v))
|
|
371
|
+
return { ...spec, series: [{ ...s, values }] }
|
|
372
|
+
}
|
|
373
|
+
const visibleSeries = coloredSeries.filter((s) => !effectiveHidden.has(s.label))
|
|
374
|
+
if (!zoom) return { ...spec, series: visibleSeries }
|
|
375
|
+
// Narrow to the zoom window so buildChart re-spreads the visible slice
|
|
376
|
+
// across the full plot. The slicing itself lives in chart.ts: this used to
|
|
377
|
+
// be done by hand here and only covered categories / values / rowIds, so
|
|
378
|
+
// the confidence-band envelopes kept their full length and the band
|
|
379
|
+
// silently vanished as soon as anyone zoomed.
|
|
380
|
+
return sliceChartWindow({ ...spec, series: visibleSeries }, zoom.i0, zoom.i1)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// Heatmap / calendar cell fills are computed colors (not CSS), so they can't
|
|
384
|
+
// inherit the theme. We detect the surface luminance (see the $effect near
|
|
385
|
+
// chartEl) and hand buildChart a light/dark hint so low-value cells render as
|
|
386
|
+
// "cold" rather than glaring white rectangles on a dark grid.
|
|
387
|
+
let isDark = $state(false)
|
|
388
|
+
// Hand the RESOLVED palette down here rather than relying on per-series
|
|
389
|
+
// colours alone: buildChart also picks from `spec.palette` for the marks
|
|
390
|
+
// that have no series of their own (pie slices, treemap cells), and those
|
|
391
|
+
// should follow the theme tokens too.
|
|
392
|
+
const geo = $derived(
|
|
393
|
+
buildChart(
|
|
394
|
+
{ ...visibleSpec, palette, ...(width || height ? { width, height } : {}) },
|
|
395
|
+
isDark ? 'dark' : 'light',
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
/** What the `underlay` / `overlay` snippets are handed. Built from the
|
|
399
|
+
* laid-out geometry, so a custom mark shares the built-in scale exactly. */
|
|
400
|
+
const renderScales = $derived(chartScales(geo))
|
|
401
|
+
const renderCtx = $derived<ChartRenderContext>({
|
|
402
|
+
geo,
|
|
403
|
+
scales: renderScales,
|
|
404
|
+
xOf: renderScales?.xOf ?? null,
|
|
405
|
+
yOf: renderScales?.yOf ?? null,
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
const isCartesian = $derived(
|
|
409
|
+
spec.type !== 'pie' && spec.type !== 'heatmap' &&
|
|
410
|
+
spec.type !== 'funnel' && spec.type !== 'radar' &&
|
|
411
|
+
spec.type !== 'calendar' && spec.type !== 'gauge' &&
|
|
412
|
+
spec.type !== 'treemap' && spec.type !== 'sankey',
|
|
413
|
+
)
|
|
414
|
+
const isScatter = $derived(spec.type === 'scatter')
|
|
415
|
+
const isHeatmap = $derived(spec.type === 'heatmap')
|
|
416
|
+
const isFunnel = $derived(spec.type === 'funnel')
|
|
417
|
+
const isRadar = $derived(spec.type === 'radar')
|
|
418
|
+
const isCalendar = $derived(spec.type === 'calendar')
|
|
419
|
+
const isGauge = $derived(spec.type === 'gauge')
|
|
420
|
+
const isTreemap = $derived(spec.type === 'treemap')
|
|
421
|
+
const isSankey = $derived(spec.type === 'sankey')
|
|
422
|
+
const isHorizontal = $derived(geo.orientation === 'horizontal')
|
|
423
|
+
// Per-category band size along the main (category) axis: width when vertical,
|
|
424
|
+
// height when horizontal.
|
|
425
|
+
const slot = $derived(
|
|
426
|
+
(isHorizontal ? geo.plot.h : geo.plot.w) / Math.max(1, visibleSpec.categories.length),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* A chart with more categories than the plot has room for.
|
|
431
|
+
*
|
|
432
|
+
* `buildChart` itself scales fine - 100k line points is about 140ms of pure
|
|
433
|
+
* maths. What does not scale is what the DOM is asked to hold: one `<circle>`
|
|
434
|
+
* per point, one hit `<rect>` per category whose `aria-label` costs a
|
|
435
|
+
* `catRows()` call AT RENDER TIME, and one screen-reader table row per
|
|
436
|
+
* category. That is roughly 300k nodes for a 100k-point series, and the chart
|
|
437
|
+
* stops responding long before the engine breaks a sweat.
|
|
438
|
+
*
|
|
439
|
+
* Below ~4px per category none of that machinery is buying anything anyway:
|
|
440
|
+
* the dots overlap into a smear that hides the line they are meant to mark,
|
|
441
|
+
* and a hit rect thinner than the pointer cannot be aimed at. So under the
|
|
442
|
+
* threshold the marks collapse to the line path, the hit rects collapse to one
|
|
443
|
+
* surface that computes the index from the cursor, and the table is capped.
|
|
444
|
+
*/
|
|
445
|
+
const DENSE_SLOT_PX = 4
|
|
446
|
+
const dense = $derived(
|
|
447
|
+
isCartesian && !isScatter && !isHorizontal && slot < DENSE_SLOT_PX && visibleSpec.categories.length > 1,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
/** Rows the screen-reader table will actually render before it is capped. */
|
|
451
|
+
const SR_ROW_CAP = 1000
|
|
452
|
+
|
|
453
|
+
/** Which category the cursor is over, in dense mode. Binary search over the
|
|
454
|
+
* first series' point x's, so a non-uniform (time) axis is handled too;
|
|
455
|
+
* uniform slot maths when there are no line points to search. */
|
|
456
|
+
/** Client px -> viewBox px. The svg scales to its container, so the two
|
|
457
|
+
* differ whenever the chart is not rendered at its natural size. */
|
|
458
|
+
function toViewBox(clientX: number, clientY: number, target: Element): { x: number; y: number } | null {
|
|
459
|
+
const svg = (target as SVGGraphicsElement).ownerSVGElement ?? (target as SVGSVGElement)
|
|
460
|
+
if (!svg || typeof svg.getBoundingClientRect !== 'function') return null
|
|
461
|
+
const box = svg.getBoundingClientRect()
|
|
462
|
+
if (!box.width || !box.height) return null
|
|
463
|
+
const vb = (svg as SVGSVGElement).viewBox?.baseVal
|
|
464
|
+
const sx = vb && vb.width ? vb.width / box.width : 1
|
|
465
|
+
const sy = vb && vb.height ? vb.height / box.height : 1
|
|
466
|
+
return {
|
|
467
|
+
x: (clientX - box.left) * sx + (vb ? vb.x : 0),
|
|
468
|
+
y: (clientY - box.top) * sy + (vb ? vb.y : 0),
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function catAtClientX(clientX: number, target: Element): number | null {
|
|
473
|
+
const vp = toViewBox(clientX, 0, target)
|
|
474
|
+
if (!vp) return null
|
|
475
|
+
const x = vp.x
|
|
476
|
+
const pts = geo.lines[0]?.points
|
|
477
|
+
const n = visibleSpec.categories.length
|
|
478
|
+
if (!pts || pts.length !== n) {
|
|
479
|
+
const i = Math.floor((x - geo.plot.x) / slot)
|
|
480
|
+
return i >= 0 && i < n ? i : null
|
|
481
|
+
}
|
|
482
|
+
let lo = 0
|
|
483
|
+
let hi = pts.length - 1
|
|
484
|
+
while (lo < hi) {
|
|
485
|
+
const mid = (lo + hi) >> 1
|
|
486
|
+
if (pts[mid]!.x < x) lo = mid + 1
|
|
487
|
+
else hi = mid
|
|
488
|
+
}
|
|
489
|
+
// Land on the nearer of the two neighbours rather than always rounding up.
|
|
490
|
+
if (lo > 0 && Math.abs(pts[lo - 1]!.x - x) <= Math.abs(pts[lo]!.x - x)) lo -= 1
|
|
491
|
+
return lo
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Focused category in dense mode, where there is one rect rather than n. */
|
|
495
|
+
let denseIndex = $state(0)
|
|
496
|
+
function onDenseKey(e: KeyboardEvent) {
|
|
497
|
+
const max = visibleSpec.categories.length - 1
|
|
498
|
+
const step = Math.max(1, Math.floor(max / 10))
|
|
499
|
+
let next = denseIndex
|
|
500
|
+
switch (e.key) {
|
|
501
|
+
case 'ArrowRight': case 'ArrowDown': next = Math.min(max, denseIndex + 1); break
|
|
502
|
+
case 'ArrowLeft': case 'ArrowUp': next = Math.max(0, denseIndex - 1); break
|
|
503
|
+
case 'Home': next = 0; break
|
|
504
|
+
case 'End': next = max; break
|
|
505
|
+
case 'PageDown': next = Math.min(max, denseIndex + step); break
|
|
506
|
+
case 'PageUp': next = Math.max(0, denseIndex - step); break
|
|
507
|
+
case 'Enter': case ' ':
|
|
508
|
+
e.preventDefault()
|
|
509
|
+
select(visibleSpec.categories[denseIndex] ?? '', '', 0, denseIndex)
|
|
510
|
+
return
|
|
511
|
+
default: return
|
|
512
|
+
}
|
|
513
|
+
e.preventDefault()
|
|
514
|
+
denseIndex = next
|
|
515
|
+
focusCat(e.currentTarget as Element, next)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Opacity for a series when another legend chip is being hovered.
|
|
519
|
+
const dimOf = (seriesLabel: string) => (dimmed && dimmed !== seriesLabel ? 0.18 : 1)
|
|
520
|
+
|
|
521
|
+
const legendItems = $derived(
|
|
522
|
+
spec.type === 'pie'
|
|
523
|
+
? spec.categories.map((label, i) => ({ label, color: palette[i % palette.length]!, off: hidden.has(label) }))
|
|
524
|
+
: coloredSeries.map((s) => ({ label: s.label, color: s.color, off: hidden.has(s.label) })),
|
|
525
|
+
)
|
|
526
|
+
// Legend overflow: collapse to the first N chips with a "+N more" toggle so a
|
|
527
|
+
// wide pivot (many series) doesn't flood the chart with legend rows.
|
|
528
|
+
const LEGEND_MAX = 10
|
|
529
|
+
let legendExpanded = $state(false)
|
|
530
|
+
const legendOverflow = $derived(legendItems.length > LEGEND_MAX)
|
|
531
|
+
const shownLegend = $derived(
|
|
532
|
+
legendOverflow && !legendExpanded ? legendItems.slice(0, LEGEND_MAX) : legendItems,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
const isEmpty = $derived.by(() => {
|
|
536
|
+
if (spec.type === 'pie') return geo.slices.every((s) => s.value <= 0)
|
|
537
|
+
if (spec.type === 'scatter') return geo.scatterPoints.length === 0
|
|
538
|
+
if (spec.type === 'heatmap') return geo.heatmapCells.length === 0
|
|
539
|
+
if (spec.type === 'funnel') return geo.funnelSegments.length === 0
|
|
540
|
+
if (spec.type === 'radar') return geo.radarSeries.length === 0
|
|
541
|
+
if (spec.type === 'calendar') return geo.calendarCells.length === 0
|
|
542
|
+
if (spec.type === 'gauge') return !geo.gauge
|
|
543
|
+
if (spec.type === 'treemap') return geo.treemapCells.length === 0
|
|
544
|
+
if (spec.type === 'sankey') return geo.sankeyNodes.length === 0
|
|
545
|
+
if (isCandle) return geo.candles.length === 0
|
|
546
|
+
if (spec.type === 'boxplot') return geo.boxes.length === 0
|
|
547
|
+
return geo.bars.length === 0 && geo.lines.every((l) => l.points.every((p) => !p.defined))
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
function toggle(label: string) {
|
|
551
|
+
if (!interactive) return
|
|
552
|
+
if (isolated != null) { isolated = null; return }
|
|
553
|
+
const next = new Set(hidden)
|
|
554
|
+
if (next.has(label)) next.delete(label)
|
|
555
|
+
else next.add(label)
|
|
556
|
+
hidden = next
|
|
557
|
+
}
|
|
558
|
+
// Double-click a legend chip to isolate it (show only that one); double-click
|
|
559
|
+
// again (or the same chip) to clear the isolation.
|
|
560
|
+
function isolate(label: string) {
|
|
561
|
+
if (!interactive) return
|
|
562
|
+
isolated = isolated === label ? null : label
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---- Screen-reader data table -----------------------------------------
|
|
566
|
+
// A visually-hidden table that conveys the same data to assistive tech.
|
|
567
|
+
const srTable = $derived.by(() => {
|
|
568
|
+
if (spec.type === 'pie') {
|
|
569
|
+
const s = coloredSeries[0]
|
|
570
|
+
return {
|
|
571
|
+
cols: ['Category', 'Value'],
|
|
572
|
+
rows: spec.categories.map((c, i) => [c, fmt(s?.values[i] ?? 0)]),
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (isScatter) {
|
|
576
|
+
const rows: string[][] = []
|
|
577
|
+
for (const s of coloredSeries) for (const p of s.points ?? []) rows.push([s.label, fmt(p.x), fmt(p.y), p.r != null ? fmt(p.r) : ''])
|
|
578
|
+
return { cols: ['Series', 'X', 'Y', 'Size'], rows }
|
|
579
|
+
}
|
|
580
|
+
if (isCandle) {
|
|
581
|
+
// This IS the accessible version of the chart, so it carries the four
|
|
582
|
+
// real numbers rather than the closes `values` happens to hold.
|
|
583
|
+
const rows: string[][] = []
|
|
584
|
+
for (const s of coloredSeries) {
|
|
585
|
+
;(s.ohlc ?? []).forEach((k, i) => {
|
|
586
|
+
if (!k) return
|
|
587
|
+
rows.push([spec.categories[i] ?? String(i), s.label, fmt(k.o), fmt(k.h), fmt(k.l), fmt(k.c)])
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
return { cols: ['Date', 'Series', 'Open', 'High', 'Low', 'Close'], rows }
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
cols: ['Category', ...coloredSeries.map((s) => s.label)],
|
|
594
|
+
rows: spec.categories.map((c, i) => [c, ...coloredSeries.map((s) => fmt(s.values[i] ?? 0))]),
|
|
595
|
+
}
|
|
596
|
+
})
|
|
597
|
+
/** Declared here rather than in the markup: `{@const}` is only legal as the
|
|
598
|
+
* immediate child of a block, and the table sits at the template top level.
|
|
599
|
+
* Below `srTable` because a `$derived` used above its declaration is an
|
|
600
|
+
* error, not a hoist. */
|
|
601
|
+
const srCapped = $derived(srTable.rows.length > SR_ROW_CAP)
|
|
602
|
+
const srRows = $derived(srCapped ? srTable.rows.slice(0, SR_ROW_CAP) : srTable.rows)
|
|
603
|
+
|
|
604
|
+
function truncate(s: string, n = 12): string {
|
|
605
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s
|
|
606
|
+
}
|
|
607
|
+
const yTickLabel = (value: number, fallback: string) => (formatValue ? formatValue(value) : fallback)
|
|
608
|
+
|
|
609
|
+
// ---- Tooltip + crosshair ----------------------------------------------
|
|
610
|
+
let chartEl: HTMLElement | null = $state(null)
|
|
611
|
+
|
|
612
|
+
// Resolve whether the chart sits on a dark surface by reading the --sg-bg
|
|
613
|
+
// token's luminance. Re-checks on theme toggles (data-theme / class / style
|
|
614
|
+
// changes on the document root) so the heatmap ramp follows light<->dark.
|
|
615
|
+
function surfaceIsDark(color: string): boolean {
|
|
616
|
+
if (!color) return false
|
|
617
|
+
let r = 0, g = 0, b = 0
|
|
618
|
+
const hex = color.trim().startsWith('#') ? color.trim().slice(1) : ''
|
|
619
|
+
if (hex.length === 6) {
|
|
620
|
+
r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16)
|
|
621
|
+
} else {
|
|
622
|
+
const m = /rgba?\(([^)]+)\)/.exec(color)
|
|
623
|
+
if (!m) return false
|
|
624
|
+
const p = m[1]!.split(',').map((s) => parseFloat(s))
|
|
625
|
+
r = p[0] ?? 0; g = p[1] ?? 0; b = p[2] ?? 0
|
|
626
|
+
}
|
|
627
|
+
// Perceived luminance (0..255); below the midpoint reads as a dark surface.
|
|
628
|
+
return 0.299 * r + 0.587 * g + 0.114 * b < 128
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Series colours declared as `--sg-chart-1` .. `--sg-chart-8`.
|
|
632
|
+
*
|
|
633
|
+
* The palette used to be a hard-coded array in `chart.ts`, which meant a
|
|
634
|
+
* theme could restyle every part of a chart except the data - the one part
|
|
635
|
+
* a brand actually cares about. It stays out of `chart.ts` because that
|
|
636
|
+
* module is deliberately DOM-free and SSR-safe, so the tokens are read here
|
|
637
|
+
* and handed down as an ordinary `palette`.
|
|
638
|
+
*
|
|
639
|
+
* Partial sets are honoured: declare two tokens and the rest fall back, so a
|
|
640
|
+
* theme can set a brand primary and secondary without inventing six more.
|
|
641
|
+
*/
|
|
642
|
+
$effect(() => {
|
|
643
|
+
if (!chartEl || typeof window === 'undefined') return
|
|
644
|
+
const el = chartEl
|
|
645
|
+
const check = () => {
|
|
646
|
+
const cs = getComputedStyle(el)
|
|
647
|
+
isDark = surfaceIsDark(cs.getPropertyValue('--sg-bg'))
|
|
648
|
+
const next: string[] = []
|
|
649
|
+
for (let i = 1; i <= 8; i += 1) {
|
|
650
|
+
const v = cs.getPropertyValue(`--sg-chart-${i}`).trim()
|
|
651
|
+
next.push(v || DEFAULT_PALETTE[i - 1]!)
|
|
652
|
+
}
|
|
653
|
+
const changed = !tokenPalette || next.some((c, i) => c !== tokenPalette![i])
|
|
654
|
+
if (changed) tokenPalette = next.some((c, i) => c !== DEFAULT_PALETTE[i]) ? next : null
|
|
655
|
+
}
|
|
656
|
+
check()
|
|
657
|
+
const obs = new MutationObserver(check)
|
|
658
|
+
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] })
|
|
659
|
+
return () => obs.disconnect()
|
|
660
|
+
})
|
|
661
|
+
type TipRow = { label?: string; color?: string; value: string }
|
|
662
|
+
let tip = $state<{ left: number; top: number; below: boolean; title: string; rows: TipRow[] } | null>(null)
|
|
663
|
+
let activeCat = $state<number | null>(null)
|
|
664
|
+
|
|
665
|
+
function showTip(clientX: number, clientY: number, title: string, rows: TipRow[]) {
|
|
666
|
+
if (!interactive || !chartEl || rows.length === 0) return
|
|
667
|
+
const r = chartEl.getBoundingClientRect()
|
|
668
|
+
const left = Math.max(70, Math.min(r.width - 70, clientX - r.left))
|
|
669
|
+
const top = clientY - r.top
|
|
670
|
+
tip = { left, top, below: top < 44, title, rows }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Unified tooltip: every visible series' value at one category.
|
|
674
|
+
function catRows(i: number): TipRow[] {
|
|
675
|
+
const rows: TipRow[] = []
|
|
676
|
+
for (const s of visibleSpec.series) {
|
|
677
|
+
const color = (s as { color?: string }).color
|
|
678
|
+
// A candle carries four numbers, so it gets four rows. Doing it here
|
|
679
|
+
// rather than in a parallel tooltip path means the crosshair, the
|
|
680
|
+
// keyboard focus tooltip and the hit rect's aria-label all follow, and a
|
|
681
|
+
// volume series on the right axis still lists itself underneath.
|
|
682
|
+
const k = s.ohlc?.[i]
|
|
683
|
+
if (k) {
|
|
684
|
+
rows.push({ label: `${s.label} O`, color, value: fmt(k.o) })
|
|
685
|
+
rows.push({ label: `${s.label} H`, value: fmt(k.h) })
|
|
686
|
+
rows.push({ label: `${s.label} L`, value: fmt(k.l) })
|
|
687
|
+
rows.push({ label: `${s.label} C`, value: fmt(k.c) })
|
|
688
|
+
continue
|
|
689
|
+
}
|
|
690
|
+
// A box is a five-number summary, and reading it off the picture is
|
|
691
|
+
// exactly what a tooltip is for. Same reasoning as the candle above:
|
|
692
|
+
// one place, so crosshair / keyboard / aria-label all agree.
|
|
693
|
+
const b = s.boxes?.[i]
|
|
694
|
+
if (b) {
|
|
695
|
+
rows.push({ label: `${s.label} max`, color, value: fmt(b.max) })
|
|
696
|
+
rows.push({ label: `${s.label} Q3`, value: fmt(b.q3) })
|
|
697
|
+
rows.push({ label: `${s.label} median`, value: fmt(b.median) })
|
|
698
|
+
rows.push({ label: `${s.label} Q1`, value: fmt(b.q1) })
|
|
699
|
+
rows.push({ label: `${s.label} min`, value: fmt(b.min) })
|
|
700
|
+
if (b.outliers?.length) {
|
|
701
|
+
rows.push({ label: `${s.label} outliers`, value: String(b.outliers.length) })
|
|
702
|
+
}
|
|
703
|
+
continue
|
|
704
|
+
}
|
|
705
|
+
const v = s.values[i]
|
|
706
|
+
if (Number.isFinite(v)) {
|
|
707
|
+
rows.push({ label: s.label, color, value: fmt(v as number) })
|
|
708
|
+
// An error bar is only worth drawing if you can read what it means.
|
|
709
|
+
const e = s.errors?.[i]
|
|
710
|
+
if (e != null) {
|
|
711
|
+
const lo = typeof e === 'number' ? (v as number) - Math.abs(e) : Math.min(e.lo, e.hi)
|
|
712
|
+
const hi = typeof e === 'number' ? (v as number) + Math.abs(e) : Math.max(e.lo, e.hi)
|
|
713
|
+
if (Number.isFinite(lo) && Number.isFinite(hi)) {
|
|
714
|
+
rows.push({ label: `${s.label} range`, value: `${fmt(lo)} - ${fmt(hi)}` })
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return rows
|
|
720
|
+
}
|
|
721
|
+
function hoverCat(clientX: number, clientY: number, i: number) {
|
|
722
|
+
activeCat = i
|
|
723
|
+
showTip(clientX, clientY, visibleSpec.categories[i] ?? '', catRows(i))
|
|
724
|
+
}
|
|
725
|
+
function focusCat(el: Element, i: number) {
|
|
726
|
+
const b = el.getBoundingClientRect()
|
|
727
|
+
hoverCat(b.left + b.width / 2, b.top + 12, i)
|
|
728
|
+
}
|
|
729
|
+
function clearActive() {
|
|
730
|
+
activeCat = null
|
|
731
|
+
tip = null
|
|
732
|
+
}
|
|
733
|
+
function hoverSlice(clientX: number, clientY: number, label: string, value: number, pct: number) {
|
|
734
|
+
showTip(clientX, clientY, label, [{ value: `${fmt(value)} · ${pct.toFixed(1)}%` }])
|
|
735
|
+
}
|
|
736
|
+
function hoverDot(clientX: number, clientY: number, d: (typeof geo.scatterPoints)[number]) {
|
|
737
|
+
const rows: TipRow[] = [
|
|
738
|
+
{ label: 'x', value: fmt(d.x) },
|
|
739
|
+
{ label: 'y', value: fmt(d.y) },
|
|
740
|
+
]
|
|
741
|
+
showTip(clientX, clientY, d.label || d.series, rows)
|
|
742
|
+
}
|
|
743
|
+
function hoverCell(clientX: number, clientY: number, cell: (typeof geo.heatmapCells)[number]) {
|
|
744
|
+
showTip(clientX, clientY, `${cell.rowLabel} · ${cell.colLabel}`, [
|
|
745
|
+
{ color: cell.color, value: fmt(cell.value) },
|
|
746
|
+
])
|
|
747
|
+
}
|
|
748
|
+
function hoverFunnel(clientX: number, clientY: number, seg: (typeof geo.funnelSegments)[number]) {
|
|
749
|
+
const rows: TipRow[] = [
|
|
750
|
+
{ value: fmt(seg.value) },
|
|
751
|
+
{ label: 'conversion', value: `${(seg.conversion * 100).toFixed(1)}%` },
|
|
752
|
+
]
|
|
753
|
+
if (seg.dropoff > 0) rows.push({ label: 'drop-off', value: `${(seg.dropoff * 100).toFixed(1)}%` })
|
|
754
|
+
showTip(clientX, clientY, seg.label, rows)
|
|
755
|
+
}
|
|
756
|
+
function hoverRadarPoint(clientX: number, clientY: number, label: string, axis: string, value: number, color: string) {
|
|
757
|
+
showTip(clientX, clientY, `${label} · ${axis}`, [{ color, value: fmt(value) }])
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function select(category: string, series: string, value: number, catIndex?: number) {
|
|
761
|
+
// Annotate mode takes over the category gesture rather than adding one of
|
|
762
|
+
// its own. That is what makes it work from the keyboard: the hit rects
|
|
763
|
+
// already have a roving tabindex and fire this on Enter, so pinning a note
|
|
764
|
+
// is Tab, arrow, Enter - with no extra handler and no click-only feature.
|
|
765
|
+
if (annotating && onAnnotate && catIndex !== undefined) {
|
|
766
|
+
// A CATEGORY click carries no value - `select(cat, '', 0, i)` passes zero
|
|
767
|
+
// because a drill only needs the category - so read the real one off the
|
|
768
|
+
// first series. A click on a specific bar or slice already has one.
|
|
769
|
+
const picked = series ? value : visibleSpec.series[0]?.values[catIndex]
|
|
770
|
+
onAnnotate({ category, index: catIndex, value: Number.isFinite(picked) ? (picked as number) : 0 })
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
// Collect contributing row ids when the spec carries them. For a
|
|
774
|
+
// category click (series === ''), flatten across every series; for a
|
|
775
|
+
// single-series click, return just that series' bucket.
|
|
776
|
+
let rowIds: Array<string | number> | undefined
|
|
777
|
+
if (catIndex !== undefined) {
|
|
778
|
+
const buckets: Array<Array<string | number>> = []
|
|
779
|
+
for (const s of visibleSpec.series) {
|
|
780
|
+
if (!s.rowIds) continue
|
|
781
|
+
if (series && s.label !== series) continue
|
|
782
|
+
const ids = s.rowIds[catIndex]
|
|
783
|
+
if (ids?.length) buckets.push(ids)
|
|
784
|
+
}
|
|
785
|
+
if (buckets.length) rowIds = buckets.flat()
|
|
786
|
+
}
|
|
787
|
+
const sel: ChartSelection = { category, series, value, rowIds }
|
|
788
|
+
onSelect?.(sel)
|
|
789
|
+
if (rowIds && rowIds.length) onDrill?.(sel)
|
|
790
|
+
}
|
|
791
|
+
/** Move keyboard focus to the cat-hit rect at index `next`, snapping
|
|
792
|
+
* back to the source element via the same `data-cat-index` attribute. */
|
|
793
|
+
function focusCatIndex(source: Element, next: number) {
|
|
794
|
+
const root = (source as SVGElement).ownerSVGElement ?? source.closest('svg')
|
|
795
|
+
if (!root) return
|
|
796
|
+
const target = root.querySelector<SVGElement>(`[data-cat-index="${next}"]`)
|
|
797
|
+
if (!target) return
|
|
798
|
+
target.focus?.()
|
|
799
|
+
// Mirror the tooltip / crosshair to the new focused category.
|
|
800
|
+
focusCat(target, next)
|
|
801
|
+
}
|
|
802
|
+
function onCatKey(e: KeyboardEvent, i: number) {
|
|
803
|
+
const max = visibleSpec.categories.length - 1
|
|
804
|
+
const src = e.currentTarget as Element
|
|
805
|
+
// Arrow keys + Home / End navigate; Enter / Space select. PageUp/Down
|
|
806
|
+
// jump a tenth of the range at a time for long datasets.
|
|
807
|
+
switch (e.key) {
|
|
808
|
+
case 'ArrowRight':
|
|
809
|
+
case 'ArrowDown':
|
|
810
|
+
e.preventDefault(); focusCatIndex(src, Math.min(max, i + 1)); return
|
|
811
|
+
case 'ArrowLeft':
|
|
812
|
+
case 'ArrowUp':
|
|
813
|
+
e.preventDefault(); focusCatIndex(src, Math.max(0, i - 1)); return
|
|
814
|
+
case 'Home':
|
|
815
|
+
e.preventDefault(); focusCatIndex(src, 0); return
|
|
816
|
+
case 'End':
|
|
817
|
+
e.preventDefault(); focusCatIndex(src, max); return
|
|
818
|
+
case 'PageDown':
|
|
819
|
+
e.preventDefault(); focusCatIndex(src, Math.min(max, i + Math.max(1, Math.floor(max / 10)))); return
|
|
820
|
+
case 'PageUp':
|
|
821
|
+
e.preventDefault(); focusCatIndex(src, Math.max(0, i - Math.max(1, Math.floor(max / 10)))); return
|
|
822
|
+
case 'Enter':
|
|
823
|
+
case ' ':
|
|
824
|
+
if (!onSelect) return
|
|
825
|
+
e.preventDefault()
|
|
826
|
+
select(visibleSpec.categories[i] ?? '', '', 0, i)
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function onSliceKey(e: KeyboardEvent, label: string, value: number) {
|
|
831
|
+
if (!onSelect) return
|
|
832
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
833
|
+
e.preventDefault()
|
|
834
|
+
select(label, label, value)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---- Export ----------------------------------------------------------
|
|
839
|
+
// PNG / SVG download + copy-as-image. Serialize the live SVG element so
|
|
840
|
+
// the exported file matches the user's current zoom / visibility state.
|
|
841
|
+
let copyStatus = $state<'idle' | 'ok' | 'err'>('idle')
|
|
842
|
+
function serializeSvg(): string | null {
|
|
843
|
+
if (!svgEl) return null
|
|
844
|
+
const clone = svgEl.cloneNode(true) as SVGSVGElement
|
|
845
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
846
|
+
// Drop our interaction-only classes from the export.
|
|
847
|
+
clone.removeAttribute('class')
|
|
848
|
+
// Inline the computed sizes so the standalone SVG renders at the
|
|
849
|
+
// same pixel dimensions when opened directly.
|
|
850
|
+
const rect = svgEl.getBoundingClientRect()
|
|
851
|
+
if (rect.width) clone.setAttribute('width', String(Math.round(rect.width)))
|
|
852
|
+
if (rect.height) clone.setAttribute('height', String(Math.round(rect.height)))
|
|
853
|
+
// Pull the chart's stylesheet so the standalone SVG is self-contained.
|
|
854
|
+
const styleTag = document.createElement('style')
|
|
855
|
+
const sheets = Array.from(document.styleSheets)
|
|
856
|
+
const rules: string[] = []
|
|
857
|
+
for (const sheet of sheets) {
|
|
858
|
+
try {
|
|
859
|
+
for (const rule of Array.from(sheet.cssRules ?? [])) {
|
|
860
|
+
const t = rule.cssText
|
|
861
|
+
if (t.includes('.sv-grid-chart')) rules.push(t)
|
|
862
|
+
}
|
|
863
|
+
} catch { /* cross-origin sheets - skip */ }
|
|
864
|
+
}
|
|
865
|
+
styleTag.textContent = rules.join('\n')
|
|
866
|
+
clone.insertBefore(styleTag, clone.firstChild)
|
|
867
|
+
return new XMLSerializer().serializeToString(clone)
|
|
868
|
+
}
|
|
869
|
+
function triggerDownload(blob: Blob, filename: string) {
|
|
870
|
+
const url = URL.createObjectURL(blob)
|
|
871
|
+
const a = document.createElement('a')
|
|
872
|
+
a.href = url
|
|
873
|
+
a.download = filename
|
|
874
|
+
document.body.appendChild(a)
|
|
875
|
+
a.click()
|
|
876
|
+
a.remove()
|
|
877
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
878
|
+
}
|
|
879
|
+
async function rasterizeSvg(svgText: string): Promise<Blob | null> {
|
|
880
|
+
return new Promise((resolve) => {
|
|
881
|
+
const rect = svgEl?.getBoundingClientRect()
|
|
882
|
+
const w = Math.max(1, Math.round(rect?.width ?? geo.width))
|
|
883
|
+
const h = Math.max(1, Math.round(rect?.height ?? geo.height))
|
|
884
|
+
const dpr = window.devicePixelRatio || 1
|
|
885
|
+
const canvas = document.createElement('canvas')
|
|
886
|
+
canvas.width = w * dpr
|
|
887
|
+
canvas.height = h * dpr
|
|
888
|
+
const ctx = canvas.getContext('2d')
|
|
889
|
+
if (!ctx) { resolve(null); return }
|
|
890
|
+
const img = new Image()
|
|
891
|
+
img.onload = () => {
|
|
892
|
+
ctx.scale(dpr, dpr)
|
|
893
|
+
ctx.drawImage(img, 0, 0, w, h)
|
|
894
|
+
canvas.toBlob((b) => resolve(b), 'image/png')
|
|
895
|
+
}
|
|
896
|
+
img.onerror = () => resolve(null)
|
|
897
|
+
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText)
|
|
898
|
+
})
|
|
899
|
+
}
|
|
900
|
+
async function downloadImage(format: 'png' | 'svg') {
|
|
901
|
+
const text = serializeSvg()
|
|
902
|
+
if (!text) return
|
|
903
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
904
|
+
if (format === 'svg') {
|
|
905
|
+
triggerDownload(new Blob([text], { type: 'image/svg+xml' }), `chart-${stamp}.svg`)
|
|
906
|
+
return
|
|
907
|
+
}
|
|
908
|
+
const png = await rasterizeSvg(text)
|
|
909
|
+
if (png) triggerDownload(png, `chart-${stamp}.png`)
|
|
910
|
+
}
|
|
911
|
+
async function copyAsImage() {
|
|
912
|
+
const text = serializeSvg()
|
|
913
|
+
if (!text) { copyStatus = 'err'; return }
|
|
914
|
+
try {
|
|
915
|
+
const png = await rasterizeSvg(text)
|
|
916
|
+
if (!png) throw new Error('rasterize failed')
|
|
917
|
+
const Clip = (window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
|
|
918
|
+
if (Clip && navigator.clipboard?.write) {
|
|
919
|
+
await navigator.clipboard.write([new Clip({ 'image/png': png })])
|
|
920
|
+
} else {
|
|
921
|
+
// Fallback: copy SVG markup as text so the user can paste somewhere.
|
|
922
|
+
await navigator.clipboard.writeText(text)
|
|
923
|
+
}
|
|
924
|
+
copyStatus = 'ok'
|
|
925
|
+
} catch {
|
|
926
|
+
copyStatus = 'err'
|
|
927
|
+
}
|
|
928
|
+
setTimeout(() => (copyStatus = 'idle'), 1500)
|
|
929
|
+
}
|
|
930
|
+
</script>
|
|
931
|
+
|
|
932
|
+
<div class="sv-grid-chart" bind:this={chartEl}>
|
|
933
|
+
{#if showToolbar}
|
|
934
|
+
<div class="sv-grid-chart-toolbar">
|
|
935
|
+
{#if zoomable && isZoomed}
|
|
936
|
+
<button type="button" class="sv-grid-chart-tool" onclick={resetZoom} title="Reset zoom (or double-click the plot)">
|
|
937
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
|
|
938
|
+
Reset zoom
|
|
939
|
+
</button>
|
|
940
|
+
{/if}
|
|
941
|
+
{#if annotatable}
|
|
942
|
+
<button
|
|
943
|
+
type="button"
|
|
944
|
+
class="sv-grid-chart-tool"
|
|
945
|
+
class:is-on={annotating}
|
|
946
|
+
aria-pressed={annotating}
|
|
947
|
+
onclick={() => (annotating = !annotating)}
|
|
948
|
+
title={annotating ? 'Click the plot to pin a note, or a marker to remove it' : 'Pin notes on the chart'}
|
|
949
|
+
>
|
|
950
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="11" r="3"/><path d="M12 14v8"/></svg>
|
|
951
|
+
Annotate
|
|
952
|
+
</button>
|
|
953
|
+
{/if}
|
|
954
|
+
<button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('png')} title="Download PNG">PNG</button>
|
|
955
|
+
<button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('svg')} title="Download SVG">SVG</button>
|
|
956
|
+
<button type="button" class="sv-grid-chart-tool" onclick={copyAsImage} title="Copy chart as image">
|
|
957
|
+
{copyStatus === 'ok' ? '✓ Copied' : 'Copy'}
|
|
958
|
+
</button>
|
|
959
|
+
</div>
|
|
960
|
+
{/if}
|
|
961
|
+
<svg
|
|
962
|
+
bind:this={svgEl}
|
|
963
|
+
class="sv-grid-chart-svg"
|
|
964
|
+
class:is-interactive={interactive}
|
|
965
|
+
class:is-clickable={!!onSelect || !!onDrill}
|
|
966
|
+
class:is-zoomable={zoomable && isCartesian && !annotating}
|
|
967
|
+
class:is-annotating={annotating}
|
|
968
|
+
class:is-dragging={!!dragStart}
|
|
969
|
+
viewBox={`0 0 ${geo.width} ${geo.height}`}
|
|
970
|
+
width="100%"
|
|
971
|
+
role="img"
|
|
972
|
+
aria-label={`${spec.type} chart`}
|
|
973
|
+
aria-describedby={`${uid}-table`}
|
|
974
|
+
onpointerdown={onZoomDown}
|
|
975
|
+
onpointermove={onZoomMove}
|
|
976
|
+
onpointerup={onZoomUp}
|
|
977
|
+
onpointercancel={onZoomUp}
|
|
978
|
+
ondblclick={onPlotDblClick}
|
|
979
|
+
>
|
|
980
|
+
<!-- Pattern defs: emitted once per series when patternFallback is on
|
|
981
|
+
or a series sets `pattern`. Color is baked into the pattern so
|
|
982
|
+
each series gets a hue-tinted texture without dynamic CSS. -->
|
|
983
|
+
{#if patternDefs.length}
|
|
984
|
+
<defs>
|
|
985
|
+
{#each patternDefs as p (p.id)}
|
|
986
|
+
{#if p.kind === 'stripe'}
|
|
987
|
+
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
988
|
+
<rect width="6" height="6" fill={p.color} />
|
|
989
|
+
<rect width="3" height="6" fill="rgba(255,255,255,0.35)" />
|
|
990
|
+
</pattern>
|
|
991
|
+
{:else if p.kind === 'crosshatch'}
|
|
992
|
+
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse">
|
|
993
|
+
<rect width="6" height="6" fill={p.color} />
|
|
994
|
+
<path d="M0 0L6 6 M6 0L0 6" stroke="rgba(255,255,255,0.45)" stroke-width="1" />
|
|
995
|
+
</pattern>
|
|
996
|
+
{:else if p.kind === 'dots'}
|
|
997
|
+
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse">
|
|
998
|
+
<rect width="6" height="6" fill={p.color} />
|
|
999
|
+
<circle cx="3" cy="3" r="1.2" fill="rgba(255,255,255,0.55)" />
|
|
1000
|
+
</pattern>
|
|
1001
|
+
{:else if p.kind === 'diagonal'}
|
|
1002
|
+
<pattern id={p.id} width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)">
|
|
1003
|
+
<rect width="8" height="8" fill={p.color} />
|
|
1004
|
+
<line x1="0" y1="0" x2="0" y2="8" stroke="rgba(0,0,0,0.18)" stroke-width="2" />
|
|
1005
|
+
</pattern>
|
|
1006
|
+
{/if}
|
|
1007
|
+
{/each}
|
|
1008
|
+
</defs>
|
|
1009
|
+
{/if}
|
|
1010
|
+
{#if isHorizontal}
|
|
1011
|
+
<!-- Value axis (vertical gridlines + bottom labels) -->
|
|
1012
|
+
{#each geo.valueTicks as t (t.label + t.x)}
|
|
1013
|
+
<line class="sv-grid-chart-gridline" x1={t.x} y1={geo.plot.y} x2={t.x} y2={geo.plot.y + geo.plot.h} />
|
|
1014
|
+
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 14} text-anchor="middle">{t.label}</text>
|
|
1015
|
+
{/each}
|
|
1016
|
+
<!-- Category labels down the left -->
|
|
1017
|
+
{#each geo.catTicks as t (t.value)}
|
|
1018
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{truncate(t.label, 18)}</text>
|
|
1019
|
+
{/each}
|
|
1020
|
+
{#if spec.yAxisTitle}
|
|
1021
|
+
<text class="sv-grid-chart-axis-title" x={13} y={geo.plot.y + geo.plot.h / 2} text-anchor="middle" transform={`rotate(-90 13 ${geo.plot.y + geo.plot.h / 2})`}>{spec.yAxisTitle}</text>
|
|
1022
|
+
{/if}
|
|
1023
|
+
{#if spec.xAxisTitle}
|
|
1024
|
+
<text class="sv-grid-chart-axis-title" x={geo.plot.x + geo.plot.w / 2} y={geo.height - 3} text-anchor="middle">{spec.xAxisTitle}</text>
|
|
1025
|
+
{/if}
|
|
1026
|
+
{:else if isCartesian}
|
|
1027
|
+
{#each geo.yTicks as t (t.value)}
|
|
1028
|
+
<line class="sv-grid-chart-gridline" class:is-zero={t.value === 0} x1={geo.plot.x} y1={t.y} x2={geo.plot.x + geo.plot.w} y2={t.y} />
|
|
1029
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{yTickLabel(t.value, t.label)}</text>
|
|
1030
|
+
{/each}
|
|
1031
|
+
{#if geo.hasRightAxis}
|
|
1032
|
+
{#each geo.y2Ticks as t (t.value)}
|
|
1033
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x + geo.plot.w + 6} y={t.y + 3} text-anchor="start">{yTickLabel(t.value, t.label)}</text>
|
|
1034
|
+
{/each}
|
|
1035
|
+
{/if}
|
|
1036
|
+
{#each geo.xTicks as t (t.label + t.x)}
|
|
1037
|
+
{#if geo.xLabelRotated}
|
|
1038
|
+
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 12} text-anchor="end" transform={`rotate(-40 ${t.x} ${geo.plot.y + geo.plot.h + 12})`}>{truncate(t.label)}</text>
|
|
1039
|
+
{:else}
|
|
1040
|
+
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 16} text-anchor="middle">{truncate(t.label, 16)}</text>
|
|
1041
|
+
{/if}
|
|
1042
|
+
{/each}
|
|
1043
|
+
<!-- Grouped (nested) category axis: parent tier under the leaf labels. -->
|
|
1044
|
+
{#each geo.categoryGroupTicks as g (g.label + g.x0)}
|
|
1045
|
+
{@const gy = geo.plot.y + geo.plot.h + (geo.xLabelRotated ? 46 : 32)}
|
|
1046
|
+
<line class="sv-grid-chart-gridline" x1={g.x0 + 3} y1={gy - 9} x2={g.x1 - 3} y2={gy - 9} />
|
|
1047
|
+
{#if g.x0 > geo.plot.x + 1}
|
|
1048
|
+
<line class="sv-grid-chart-gridline" x1={g.x0} y1={geo.plot.y + geo.plot.h} x2={g.x0} y2={gy - 9} opacity="0.5" />
|
|
1049
|
+
{/if}
|
|
1050
|
+
<text class="sv-grid-chart-axis sv-grid-chart-group-label" x={g.xCenter} y={gy} text-anchor="middle">{truncate(g.label, Math.max(4, Math.floor((g.x1 - g.x0) / 7)))}</text>
|
|
1051
|
+
{/each}
|
|
1052
|
+
{#if spec.yAxisTitle}
|
|
1053
|
+
<text class="sv-grid-chart-axis-title" x={13} y={geo.plot.y + geo.plot.h / 2} text-anchor="middle" transform={`rotate(-90 13 ${geo.plot.y + geo.plot.h / 2})`}>{spec.yAxisTitle}</text>
|
|
1054
|
+
{/if}
|
|
1055
|
+
{#if spec.y2AxisTitle}
|
|
1056
|
+
<text class="sv-grid-chart-axis-title" x={geo.width - 5} y={geo.plot.y + geo.plot.h / 2} text-anchor="middle" transform={`rotate(90 ${geo.width - 5} ${geo.plot.y + geo.plot.h / 2})`}>{spec.y2AxisTitle}</text>
|
|
1057
|
+
{/if}
|
|
1058
|
+
{#if spec.xAxisTitle}
|
|
1059
|
+
<text class="sv-grid-chart-axis-title" x={geo.plot.x + geo.plot.w / 2} y={geo.height - 3} text-anchor="middle">{spec.xAxisTitle}</text>
|
|
1060
|
+
{/if}
|
|
1061
|
+
{/if}
|
|
1062
|
+
|
|
1063
|
+
{#each geo.lines as line, li (line.label + li)}
|
|
1064
|
+
<g style={`opacity:${dimOf(line.label)}`}>
|
|
1065
|
+
{#if line.bandPath}
|
|
1066
|
+
<!-- Confidence band: shaded envelope between upperValues / lowerValues. -->
|
|
1067
|
+
<path class="sv-grid-chart-band" d={line.bandPath} fill={line.color} fill-opacity="0.12" stroke="none" />
|
|
1068
|
+
{/if}
|
|
1069
|
+
{#if line.areaPath}
|
|
1070
|
+
<path class="sv-grid-chart-area" d={line.areaPath} fill={seriesFill[line.label] ?? line.color} fill-opacity="0.15" stroke="none" />
|
|
1071
|
+
{/if}
|
|
1072
|
+
<path class="sv-grid-chart-linepath" d={line.path} fill="none" stroke={line.color} stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
|
1073
|
+
{#if dense}
|
|
1074
|
+
<!-- Below ~4px a category the dots touch, so they stop marking the
|
|
1075
|
+
points and start hiding the line. Only the hovered one is drawn,
|
|
1076
|
+
which is the one a reader is actually asking about. -->
|
|
1077
|
+
{@const pt = line.points[activeCat ?? -1]}
|
|
1078
|
+
{#if pt?.defined}
|
|
1079
|
+
<circle class="sv-grid-chart-dot is-active" cx={pt.x} cy={pt.y} r="4" fill={line.color} />
|
|
1080
|
+
{/if}
|
|
1081
|
+
{:else}
|
|
1082
|
+
{#each line.points as pt, pi (pi)}
|
|
1083
|
+
{#if pt.defined}
|
|
1084
|
+
<circle class="sv-grid-chart-dot" class:is-active={activeCat === pi} cx={pt.x} cy={pt.y} r={activeCat === pi ? 4 : 3} fill={line.color} />
|
|
1085
|
+
{#if dataLabels}
|
|
1086
|
+
<text class="sv-grid-chart-datalabel" x={pt.x} y={pt.y - 7} text-anchor="middle">{fmt(pt.value)}</text>
|
|
1087
|
+
{/if}
|
|
1088
|
+
{/if}
|
|
1089
|
+
{/each}
|
|
1090
|
+
{/if}
|
|
1091
|
+
</g>
|
|
1092
|
+
{/each}
|
|
1093
|
+
|
|
1094
|
+
<!-- Custom-series seam, beneath the built-in marks: shaded regions, bands,
|
|
1095
|
+
anything the bars should sit on top of. -->
|
|
1096
|
+
{#if underlay}{@render underlay(renderCtx)}{/if}
|
|
1097
|
+
|
|
1098
|
+
{#each geo.bars as bar, bi (bi)}
|
|
1099
|
+
<rect class="sv-grid-chart-bar" x={bar.x} y={bar.y} width={bar.w} height={bar.h} rx="1" fill={seriesFill[bar.series] ?? bar.color} style={`opacity:${dimOf(bar.series)}`} />
|
|
1100
|
+
{#if dataLabels && isHorizontal && bar.w > 18}
|
|
1101
|
+
<text class="sv-grid-chart-datalabel" class:on-bar={spec.stacked || spec.stacked100} x={spec.stacked || spec.stacked100 ? bar.x + bar.w / 2 : bar.value >= 0 ? bar.x + bar.w + 3 : bar.x - 3} y={bar.y + bar.h / 2 + 3} text-anchor={spec.stacked || spec.stacked100 ? 'middle' : bar.value >= 0 ? 'start' : 'end'}>{fmt(bar.value)}</text>
|
|
1102
|
+
{:else if dataLabels && !isHorizontal && bar.h > 13}
|
|
1103
|
+
<text class="sv-grid-chart-datalabel" class:on-bar={spec.stacked || spec.stacked100} x={bar.x + bar.w / 2} y={spec.stacked || spec.stacked100 ? bar.y + bar.h / 2 + 3 : bar.value >= 0 ? bar.y - 3 : bar.y + bar.h + 11} text-anchor="middle">{fmt(bar.value)}</text>
|
|
1104
|
+
{/if}
|
|
1105
|
+
{/each}
|
|
1106
|
+
|
|
1107
|
+
<!-- Candles paint above gridlines and any volume bars, below the reference
|
|
1108
|
+
lines, annotations, crosshair and hit layer. Hollow-up / filled-down
|
|
1109
|
+
rather than two fills: direction then reads from the shape as well as
|
|
1110
|
+
the hue, which is the same reason this chart ships pattern fills. -->
|
|
1111
|
+
{#each geo.candles as k, ki (ki)}
|
|
1112
|
+
{#if isOhlcBars}
|
|
1113
|
+
<line class="sv-grid-chart-ohlc" x1={k.xCenter} y1={k.yHigh} x2={k.xCenter} y2={k.yLow} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
|
|
1114
|
+
<line class="sv-grid-chart-ohlc" x1={k.x} y1={k.yOpen} x2={k.xCenter} y2={k.yOpen} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
|
|
1115
|
+
<line class="sv-grid-chart-ohlc" x1={k.xCenter} y1={k.yClose} x2={k.x + k.w} y2={k.yClose} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
|
|
1116
|
+
{:else}
|
|
1117
|
+
<line class="sv-grid-chart-wick" x1={k.xCenter} y1={k.yHigh} x2={k.xCenter} y2={k.yLow} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
|
|
1118
|
+
<rect class="sv-grid-chart-candle" x={k.x} y={k.bodyY} width={k.w} height={k.bodyH} fill={k.up ? 'none' : k.color} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
|
|
1119
|
+
{/if}
|
|
1120
|
+
{/each}
|
|
1121
|
+
|
|
1122
|
+
<!-- Box plots. The median is a heavier rule than the box outline because it
|
|
1123
|
+
is the number people actually read; the whisker caps are drawn at the
|
|
1124
|
+
box width so the shape stays legible when the slot is narrow. -->
|
|
1125
|
+
{#each geo.boxes as b, bi (bi)}
|
|
1126
|
+
<line class="sv-grid-chart-whisker" x1={b.xCenter} y1={b.yMax} x2={b.xCenter} y2={b.yMin} stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
|
|
1127
|
+
<line class="sv-grid-chart-whisker" x1={b.x + b.w * 0.25} y1={b.yMax} x2={b.x + b.w * 0.75} y2={b.yMax} stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
|
|
1128
|
+
<line class="sv-grid-chart-whisker" x1={b.x + b.w * 0.25} y1={b.yMin} x2={b.x + b.w * 0.75} y2={b.yMin} stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
|
|
1129
|
+
<rect class="sv-grid-chart-box" x={b.x} y={b.boxY} width={b.w} height={b.boxH} fill={b.color} fill-opacity="0.25" stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
|
|
1130
|
+
<line class="sv-grid-chart-median" x1={b.x} y1={b.yMedian} x2={b.x + b.w} y2={b.yMedian} stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
|
|
1131
|
+
{#each b.outliers as o, oi (oi)}
|
|
1132
|
+
<circle class="sv-grid-chart-outlier" cx={b.xCenter} cy={o.y} r="2" fill="none" stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
|
|
1133
|
+
{/each}
|
|
1134
|
+
{/each}
|
|
1135
|
+
|
|
1136
|
+
<!-- Error bars, on top of the mark they annotate. -->
|
|
1137
|
+
{#each geo.errorBars as eb, ei (ei)}
|
|
1138
|
+
<line class="sv-grid-chart-errorbar" x1={eb.xCenter} y1={eb.yHi} x2={eb.xCenter} y2={eb.yLo} stroke={eb.color} style={`opacity:${dimOf(eb.series)}`} />
|
|
1139
|
+
<line class="sv-grid-chart-errorbar" x1={eb.xCenter - eb.cap} y1={eb.yHi} x2={eb.xCenter + eb.cap} y2={eb.yHi} stroke={eb.color} style={`opacity:${dimOf(eb.series)}`} />
|
|
1140
|
+
<line class="sv-grid-chart-errorbar" x1={eb.xCenter - eb.cap} y1={eb.yLo} x2={eb.xCenter + eb.cap} y2={eb.yLo} stroke={eb.color} style={`opacity:${dimOf(eb.series)}`} />
|
|
1141
|
+
{/each}
|
|
1142
|
+
|
|
1143
|
+
{#each geo.scatterPoints as dot, di (di)}
|
|
1144
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1145
|
+
<circle
|
|
1146
|
+
class="sv-grid-chart-scatter"
|
|
1147
|
+
cx={dot.cx}
|
|
1148
|
+
cy={dot.cy}
|
|
1149
|
+
r={dot.r}
|
|
1150
|
+
fill={dot.color}
|
|
1151
|
+
fill-opacity="0.7"
|
|
1152
|
+
stroke={dot.color}
|
|
1153
|
+
style={`opacity:${dimOf(dot.series)}`}
|
|
1154
|
+
onmousemove={(e) => hoverDot(e.clientX, e.clientY, dot)}
|
|
1155
|
+
onmouseleave={clearActive}
|
|
1156
|
+
/>
|
|
1157
|
+
{/each}
|
|
1158
|
+
|
|
1159
|
+
<!-- Reference / target lines paint on top so they read over the bars/areas. -->
|
|
1160
|
+
{#each geo.referenceLines as ref, ri (ri)}
|
|
1161
|
+
<line class="sv-grid-chart-refline" x1={geo.plot.x} y1={ref.y} x2={geo.plot.x + geo.plot.w} y2={ref.y} stroke={ref.color} stroke-dasharray={ref.dashed ? '5 4' : undefined} />
|
|
1162
|
+
<text class="sv-grid-chart-reflabel" x={geo.plot.x + geo.plot.w - 3} y={ref.y - 3} text-anchor="end" fill={ref.color}>{ref.label}</text>
|
|
1163
|
+
{/each}
|
|
1164
|
+
{#each geo.referenceLinesV as ref, ri (ri)}
|
|
1165
|
+
<line class="sv-grid-chart-refline" x1={ref.x} y1={geo.plot.y} x2={ref.x} y2={geo.plot.y + geo.plot.h} stroke={ref.color} stroke-dasharray={ref.dashed ? '5 4' : undefined} />
|
|
1166
|
+
<text class="sv-grid-chart-reflabel" x={ref.x + 3} y={geo.plot.y + 9} text-anchor="start" fill={ref.color}>{ref.label}</text>
|
|
1167
|
+
{/each}
|
|
1168
|
+
|
|
1169
|
+
<!-- Trend / moving-average overlays. Dashed so they read distinctly
|
|
1170
|
+
from the source series, drawn on top of bars + lines. -->
|
|
1171
|
+
{#each geo.overlays as ovl, oi (ovl.label + oi)}
|
|
1172
|
+
<path class="sv-grid-chart-overlay" d={ovl.path} fill="none" stroke={ovl.color} stroke-width="2" stroke-dasharray="6 4" stroke-linejoin="round" stroke-linecap="round" />
|
|
1173
|
+
{/each}
|
|
1174
|
+
|
|
1175
|
+
<!-- Custom-series seam, above the built-in marks. Deliberately BEFORE the
|
|
1176
|
+
crosshair and the hit layer below, so a custom mark cannot swallow the
|
|
1177
|
+
tooltips, the keyboard navigation or the drill clicks. -->
|
|
1178
|
+
{#if overlay}{@render overlay(renderCtx)}{/if}
|
|
1179
|
+
|
|
1180
|
+
<!-- Pinned annotations: small marker + label. Placement nudges the
|
|
1181
|
+
label position so it sits clear of the data point. -->
|
|
1182
|
+
{#each geo.annotations as ann, ai (ann.label + ai)}
|
|
1183
|
+
{@const dx = ann.placement === 'left' ? -8 : ann.placement === 'right' ? 8 : 0}
|
|
1184
|
+
{@const dy = ann.placement === 'bottom' ? 14 : ann.placement === 'top' ? -8 : 0}
|
|
1185
|
+
{@const anchor = ann.placement === 'left' ? 'end' : ann.placement === 'right' ? 'start' : 'middle'}
|
|
1186
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
|
1187
|
+
<circle
|
|
1188
|
+
class="sv-grid-chart-annotation-marker"
|
|
1189
|
+
class:is-removable={annotating && !!onAnnotationRemove}
|
|
1190
|
+
cx={ann.x}
|
|
1191
|
+
cy={ann.y}
|
|
1192
|
+
r={annotating && onAnnotationRemove ? 6 : 4}
|
|
1193
|
+
fill={ann.color}
|
|
1194
|
+
stroke="var(--sg-bg, #fff)"
|
|
1195
|
+
stroke-width="1.5"
|
|
1196
|
+
role={annotating && onAnnotationRemove ? 'button' : undefined}
|
|
1197
|
+
tabindex={annotating && onAnnotationRemove ? 0 : undefined}
|
|
1198
|
+
aria-label={annotating && onAnnotationRemove ? `Remove note: ${ann.label}` : undefined}
|
|
1199
|
+
onclick={(e) => {
|
|
1200
|
+
if (!annotating || !onAnnotationRemove) return
|
|
1201
|
+
// Or the same click also pins a NEW note where this one is.
|
|
1202
|
+
e.stopPropagation()
|
|
1203
|
+
onAnnotationRemove(ai)
|
|
1204
|
+
}}
|
|
1205
|
+
onkeydown={(e) => {
|
|
1206
|
+
if (!annotating || !onAnnotationRemove) return
|
|
1207
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
1208
|
+
e.preventDefault()
|
|
1209
|
+
e.stopPropagation()
|
|
1210
|
+
onAnnotationRemove(ai)
|
|
1211
|
+
}}
|
|
1212
|
+
/>
|
|
1213
|
+
<text class="sv-grid-chart-annotation-label" x={ann.x + dx} y={ann.y + dy} text-anchor={anchor} fill={ann.color}>{ann.label}</text>
|
|
1214
|
+
{/each}
|
|
1215
|
+
|
|
1216
|
+
{#if isCartesian && activeCat !== null}
|
|
1217
|
+
{#if isHorizontal}
|
|
1218
|
+
{@const cy = geo.plot.y + slot * activeCat + slot / 2}
|
|
1219
|
+
<line class="sv-grid-chart-crosshair" x1={geo.plot.x} y1={cy} x2={geo.plot.x + geo.plot.w} y2={cy} />
|
|
1220
|
+
{:else}
|
|
1221
|
+
<!-- Read the actual point x from the first line series when one
|
|
1222
|
+
exists (line / area / combo charts) so the crosshair lines up
|
|
1223
|
+
with the dots even on a time x-axis (where slot-based math is
|
|
1224
|
+
wrong because points are spaced by time, not index). Fall back
|
|
1225
|
+
to the slot midpoint for pure bar charts. -->
|
|
1226
|
+
{@const linePt = geo.lines[0]?.points[activeCat]}
|
|
1227
|
+
{@const cx = linePt && Number.isFinite(linePt.x)
|
|
1228
|
+
? linePt.x
|
|
1229
|
+
: geo.plot.x + slot * activeCat + slot / 2}
|
|
1230
|
+
<line class="sv-grid-chart-crosshair" x1={cx} y1={geo.plot.y} x2={cx} y2={geo.plot.y + geo.plot.h} />
|
|
1231
|
+
{/if}
|
|
1232
|
+
{/if}
|
|
1233
|
+
|
|
1234
|
+
{#if isHeatmap}
|
|
1235
|
+
<!-- Row labels in the left gutter -->
|
|
1236
|
+
{#each geo.heatmapRowTicks as t (t.value)}
|
|
1237
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x - 8} y={t.y + 3} text-anchor="end">{truncate(t.label, 22)}</text>
|
|
1238
|
+
{/each}
|
|
1239
|
+
<!-- Column labels along the bottom -->
|
|
1240
|
+
{#each geo.heatmapColTicks as t, i (t.label + i)}
|
|
1241
|
+
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 14} text-anchor="middle">{truncate(t.label, 14)}</text>
|
|
1242
|
+
{/each}
|
|
1243
|
+
<!-- Cells -->
|
|
1244
|
+
{#each geo.heatmapCells as cell, ci (ci)}
|
|
1245
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1246
|
+
<rect
|
|
1247
|
+
class="sv-grid-chart-heatcell"
|
|
1248
|
+
x={cell.x} y={cell.y} width={cell.w} height={cell.h}
|
|
1249
|
+
fill={cell.color}
|
|
1250
|
+
role={onSelect ? 'button' : 'presentation'}
|
|
1251
|
+
tabindex={onSelect ? 0 : undefined}
|
|
1252
|
+
aria-label={`${cell.rowLabel} ${cell.colLabel}: ${fmt(cell.value)}`}
|
|
1253
|
+
onmousemove={(e) => hoverCell(e.clientX, e.clientY, cell)}
|
|
1254
|
+
onmouseleave={clearActive}
|
|
1255
|
+
onclick={() => select(cell.colLabel, cell.rowLabel, cell.value)}
|
|
1256
|
+
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(cell.colLabel, cell.rowLabel, cell.value) } }}
|
|
1257
|
+
/>
|
|
1258
|
+
{#if dataLabels && cell.w > 22 && cell.h > 14}
|
|
1259
|
+
<text class="sv-grid-chart-heatlabel" x={cell.x + cell.w / 2} y={cell.y + cell.h / 2 + 3} text-anchor="middle" fill={cell.textColor}>{fmt(cell.value)}</text>
|
|
1260
|
+
{/if}
|
|
1261
|
+
{/each}
|
|
1262
|
+
<!-- Color-scale legend bar on the right -->
|
|
1263
|
+
{#if geo.heatmapLegend.length}
|
|
1264
|
+
{@const lx = geo.plot.x + geo.plot.w + 14}
|
|
1265
|
+
{@const lh = geo.plot.h - 20}
|
|
1266
|
+
{@const stops = geo.heatmapLegend}
|
|
1267
|
+
<defs>
|
|
1268
|
+
<linearGradient id={`${uid}-heatscale`} x1="0" y1="1" x2="0" y2="0">
|
|
1269
|
+
{#each stops as s, i (i)}
|
|
1270
|
+
<stop offset={`${(i / (stops.length - 1)) * 100}%`} stop-color={s.color} />
|
|
1271
|
+
{/each}
|
|
1272
|
+
</linearGradient>
|
|
1273
|
+
</defs>
|
|
1274
|
+
<rect class="sv-grid-chart-heatlegend" x={lx} y={geo.plot.y + 10} width="10" height={lh} fill={`url(#${uid}-heatscale)`} />
|
|
1275
|
+
<text class="sv-grid-chart-axis" x={lx + 14} y={geo.plot.y + 14} text-anchor="start">{stops[stops.length - 1]!.label}</text>
|
|
1276
|
+
<text class="sv-grid-chart-axis" x={lx + 14} y={geo.plot.y + 10 + lh} text-anchor="start">{stops[0]!.label}</text>
|
|
1277
|
+
{/if}
|
|
1278
|
+
{/if}
|
|
1279
|
+
|
|
1280
|
+
{#if isFunnel}
|
|
1281
|
+
<!-- Trapezoid stack with inline label inside each segment. -->
|
|
1282
|
+
{#each geo.funnelSegments as seg, fi (fi)}
|
|
1283
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1284
|
+
<path
|
|
1285
|
+
class="sv-grid-chart-funnel-seg"
|
|
1286
|
+
d={seg.path}
|
|
1287
|
+
fill={seg.color}
|
|
1288
|
+
role={onSelect ? 'button' : 'presentation'}
|
|
1289
|
+
tabindex={onSelect ? 0 : undefined}
|
|
1290
|
+
aria-label={`${seg.label}: ${fmt(seg.value)}`}
|
|
1291
|
+
onmousemove={(e) => hoverFunnel(e.clientX, e.clientY, seg)}
|
|
1292
|
+
onmouseleave={clearActive}
|
|
1293
|
+
onclick={() => select(seg.label, seg.label, seg.value, fi)}
|
|
1294
|
+
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(seg.label, seg.label, seg.value, fi) } }}
|
|
1295
|
+
/>
|
|
1296
|
+
<text class="sv-grid-chart-funnel-label" x={seg.cx} y={seg.cy - 3} text-anchor="middle" fill={seg.textColor}>{truncate(seg.label, 24)}</text>
|
|
1297
|
+
<text class="sv-grid-chart-funnel-value" x={seg.cx} y={seg.cy + 11} text-anchor="middle" fill={seg.textColor}>
|
|
1298
|
+
{fmt(seg.value)} · {(seg.conversion * 100).toFixed(0)}%
|
|
1299
|
+
</text>
|
|
1300
|
+
{/each}
|
|
1301
|
+
{/if}
|
|
1302
|
+
|
|
1303
|
+
{#if isRadar && geo.radarCenter}
|
|
1304
|
+
{@const c = geo.radarCenter}
|
|
1305
|
+
<!-- Concentric grid rings -->
|
|
1306
|
+
{#each geo.radarRings as _v, ri (ri)}
|
|
1307
|
+
{@const ringR = c.r * ((ri + 1) / geo.radarRings.length)}
|
|
1308
|
+
<circle class="sv-grid-chart-radar-ring" cx={c.cx} cy={c.cy} r={ringR} />
|
|
1309
|
+
{/each}
|
|
1310
|
+
<!-- Axis spokes + outer labels -->
|
|
1311
|
+
{#each geo.radarAxes as axis, ai (ai)}
|
|
1312
|
+
<line class="sv-grid-chart-radar-spoke" x1={c.cx} y1={c.cy} x2={axis.x} y2={axis.y} />
|
|
1313
|
+
{@const dx = axis.x - c.cx}
|
|
1314
|
+
{@const dy = axis.y - c.cy}
|
|
1315
|
+
{@const lx = c.cx + dx * 1.08}
|
|
1316
|
+
{@const ly = c.cy + dy * 1.08}
|
|
1317
|
+
<text class="sv-grid-chart-axis" x={lx} y={ly + 3}
|
|
1318
|
+
text-anchor={Math.abs(dx) < 1 ? 'middle' : dx > 0 ? 'start' : 'end'}>{axis.label}</text>
|
|
1319
|
+
{/each}
|
|
1320
|
+
<!-- One translucent polygon per series + dot per vertex. -->
|
|
1321
|
+
{#each geo.radarSeries as rs, si (si)}
|
|
1322
|
+
<path class="sv-grid-chart-radar-poly" d={rs.path} fill={rs.color} fill-opacity="0.18" stroke={rs.color} stroke-width="2" style={`opacity:${dimOf(rs.label)}`} />
|
|
1323
|
+
{#each rs.points as pt, pi (pi)}
|
|
1324
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1325
|
+
<circle class="sv-grid-chart-radar-dot" cx={pt.x} cy={pt.y} r="3" fill={rs.color}
|
|
1326
|
+
onmousemove={(e) => hoverRadarPoint(e.clientX, e.clientY, rs.label, pt.axis, pt.value, rs.color)}
|
|
1327
|
+
onmouseleave={clearActive} />
|
|
1328
|
+
{/each}
|
|
1329
|
+
{/each}
|
|
1330
|
+
{/if}
|
|
1331
|
+
|
|
1332
|
+
{#if isCalendar}
|
|
1333
|
+
<!-- Month labels along the top -->
|
|
1334
|
+
{#each geo.calendarMonthTicks as t, ti (ti)}
|
|
1335
|
+
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y - 8} text-anchor="start">{t.label}</text>
|
|
1336
|
+
{/each}
|
|
1337
|
+
<!-- Day-of-week labels on the left (Mon/Wed/Fri only to save space) -->
|
|
1338
|
+
{#each ['Mon','Wed','Fri'] as dayLabel, di (di)}
|
|
1339
|
+
{@const rowIdx = [1, 3, 5][di]!}
|
|
1340
|
+
{@const cell = geo.calendarCells[rowIdx]}
|
|
1341
|
+
{#if cell}
|
|
1342
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={cell.y + cell.size / 2 + 3} text-anchor="end">{dayLabel}</text>
|
|
1343
|
+
{/if}
|
|
1344
|
+
{/each}
|
|
1345
|
+
<!-- Cells -->
|
|
1346
|
+
{#each geo.calendarCells as cell, ci (ci)}
|
|
1347
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1348
|
+
<rect
|
|
1349
|
+
class="sv-grid-chart-calendar-cell"
|
|
1350
|
+
class:is-blank={!cell.defined}
|
|
1351
|
+
x={cell.x + 1} y={cell.y + 1}
|
|
1352
|
+
width={cell.size - 2} height={cell.size - 2}
|
|
1353
|
+
fill={cell.defined ? cell.color : 'transparent'}
|
|
1354
|
+
rx="2"
|
|
1355
|
+
aria-label={`${cell.date}: ${cell.defined ? fmt(cell.value) : 'no data'}`}
|
|
1356
|
+
onmousemove={(e) => cell.defined && showTip(e.clientX, e.clientY, cell.date, [{ color: cell.color, value: fmt(cell.value) }])}
|
|
1357
|
+
onmouseleave={clearActive}
|
|
1358
|
+
/>
|
|
1359
|
+
{/each}
|
|
1360
|
+
<!-- Legend strip on the right -->
|
|
1361
|
+
{#if geo.calendarLegend.length}
|
|
1362
|
+
{@const lx = geo.plot.x + geo.plot.w + 12}
|
|
1363
|
+
{@const ly = geo.plot.y + 4}
|
|
1364
|
+
<text class="sv-grid-chart-axis" x={lx} y={ly} text-anchor="start">Less</text>
|
|
1365
|
+
{#each geo.calendarLegend as s, i (i)}
|
|
1366
|
+
<rect class="sv-grid-chart-calendar-legend" x={lx} y={ly + 6 + i * 13} width="10" height="10" rx="2" fill={s.color} />
|
|
1367
|
+
<text class="sv-grid-chart-axis" x={lx + 14} y={ly + 14 + i * 13} text-anchor="start">{s.label}</text>
|
|
1368
|
+
{/each}
|
|
1369
|
+
<text class="sv-grid-chart-axis" x={lx} y={ly + 12 + geo.calendarLegend.length * 13} text-anchor="start">More</text>
|
|
1370
|
+
{/if}
|
|
1371
|
+
{/if}
|
|
1372
|
+
|
|
1373
|
+
{#if isGauge && geo.gauge}
|
|
1374
|
+
{@const g = geo.gauge}
|
|
1375
|
+
<!-- Tick marks around the dial (major ticks longer). -->
|
|
1376
|
+
{#each g.ticks as tk, ti (ti)}
|
|
1377
|
+
<line class="sv-grid-chart-gauge-tick" class:is-major={tk.major}
|
|
1378
|
+
x1={tk.x1} y1={tk.y1} x2={tk.x2} y2={tk.y2} />
|
|
1379
|
+
{/each}
|
|
1380
|
+
<!-- Track (grey full arc) -->
|
|
1381
|
+
<path d={g.trackPath} fill="none" stroke="var(--sg-border, #e2e8f0)" stroke-width="16" stroke-linecap="round" />
|
|
1382
|
+
<!-- Range bands: a thin inner reference ring. Deliberately quieter than
|
|
1383
|
+
the value arc - they say what the scale MEANS, while the value arc
|
|
1384
|
+
is what the dial is actually reading. Butt caps so adjacent bands
|
|
1385
|
+
meet cleanly instead of overlapping into each other's colour. -->
|
|
1386
|
+
{#each g.rangePaths as band, bi (bi)}
|
|
1387
|
+
<path d={band.path} fill="none" stroke={band.color} stroke-width="4" stroke-linecap="butt" opacity="0.7" />
|
|
1388
|
+
{/each}
|
|
1389
|
+
<!-- Value arc, colored by the band the value sits in. -->
|
|
1390
|
+
<path d={g.valuePath} fill="none" stroke={g.valueColor ?? 'var(--sg-accent, #2563eb)'} stroke-width="16" stroke-linecap="round" />
|
|
1391
|
+
<!-- Target tick (when set) -->
|
|
1392
|
+
{#if g.target}
|
|
1393
|
+
<line x1={g.target.x1} y1={g.target.y1} x2={g.target.x2} y2={g.target.y2}
|
|
1394
|
+
stroke="var(--sg-fg, #0f172a)" stroke-width="2.5" stroke-linecap="round" />
|
|
1395
|
+
{/if}
|
|
1396
|
+
<!-- Pointer needle + center hub. Deliberately NOT the value colour: the
|
|
1397
|
+
needle used to be filled with the same colour as the arc it sits on
|
|
1398
|
+
top of, so on a nearly-full dial it disappeared into it. An
|
|
1399
|
+
instrument pointer reads against the face, whatever the face is. -->
|
|
1400
|
+
<path class="sv-grid-chart-gauge-needle" d={g.needle.path} />
|
|
1401
|
+
<circle class="sv-grid-chart-gauge-hub" cx={g.cx} cy={g.cy} r={g.needle.hubR} />
|
|
1402
|
+
<circle class="sv-grid-chart-gauge-hub-dot" cx={g.cx} cy={g.cy} r="2.5" />
|
|
1403
|
+
<!-- Scale end labels + center readout. -->
|
|
1404
|
+
<text class="sv-grid-chart-axis" x={g.minLabel.x} y={g.minLabel.y} text-anchor="middle">{fmt(g.min)}</text>
|
|
1405
|
+
<text class="sv-grid-chart-axis" x={g.maxLabel.x} y={g.maxLabel.y} text-anchor="middle">{fmt(g.max)}</text>
|
|
1406
|
+
<text class="sv-grid-chart-gauge-value" x={g.cx} y={g.cy + 38} text-anchor="middle">{fmt(g.value)}{g.unit ? ` ${g.unit}` : ''}</text>
|
|
1407
|
+
{/if}
|
|
1408
|
+
|
|
1409
|
+
{#if isTreemap}
|
|
1410
|
+
{#each geo.treemapCells as cell, ti (ti)}
|
|
1411
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1412
|
+
<rect
|
|
1413
|
+
class="sv-grid-chart-treemap-cell"
|
|
1414
|
+
x={cell.x} y={cell.y} width={cell.w} height={cell.h}
|
|
1415
|
+
fill={cell.color}
|
|
1416
|
+
role={onSelect ? 'button' : 'presentation'}
|
|
1417
|
+
tabindex={onSelect ? 0 : undefined}
|
|
1418
|
+
aria-label={`${cell.name}: ${fmt(cell.value)}`}
|
|
1419
|
+
onmousemove={(e) => showTip(e.clientX, e.clientY, cell.name, [{ color: cell.color, value: fmt(cell.value) }])}
|
|
1420
|
+
onmouseleave={clearActive}
|
|
1421
|
+
onclick={() => select(cell.name, '', cell.value)}
|
|
1422
|
+
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(cell.name, '', cell.value) } }}
|
|
1423
|
+
/>
|
|
1424
|
+
{#if cell.w > 36 && cell.h > 18}
|
|
1425
|
+
<text class="sv-grid-chart-treemap-label" x={cell.x + 4} y={cell.y + 12} fill={cell.textColor}>{truncate(cell.name, Math.max(3, Math.floor(cell.w / 7)))}</text>
|
|
1426
|
+
{/if}
|
|
1427
|
+
{#if cell.w > 60 && cell.h > 30}
|
|
1428
|
+
<text class="sv-grid-chart-treemap-value" x={cell.x + 4} y={cell.y + 24} fill={cell.textColor} opacity="0.85">{fmt(cell.value)}</text>
|
|
1429
|
+
{/if}
|
|
1430
|
+
{/each}
|
|
1431
|
+
{/if}
|
|
1432
|
+
|
|
1433
|
+
{#if isSankey}
|
|
1434
|
+
<!-- Ribbon links (drawn first so node rects sit on top). -->
|
|
1435
|
+
{#each geo.sankeyLinks as link, li (li)}
|
|
1436
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1437
|
+
<path
|
|
1438
|
+
class="sv-grid-chart-sankey-link"
|
|
1439
|
+
d={link.path}
|
|
1440
|
+
fill="none"
|
|
1441
|
+
stroke={link.color}
|
|
1442
|
+
stroke-width={link.width}
|
|
1443
|
+
stroke-opacity="0.35"
|
|
1444
|
+
aria-label={`${link.source} -> ${link.target}: ${fmt(link.value)}`}
|
|
1445
|
+
onmousemove={(e) => showTip(e.clientX, e.clientY, `${link.source} -> ${link.target}`, [{ color: link.color, value: fmt(link.value) }])}
|
|
1446
|
+
onmouseleave={clearActive}
|
|
1447
|
+
/>
|
|
1448
|
+
{/each}
|
|
1449
|
+
<!-- Node rectangles + labels. -->
|
|
1450
|
+
{#each geo.sankeyNodes as node, ni (ni)}
|
|
1451
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1452
|
+
<rect
|
|
1453
|
+
class="sv-grid-chart-sankey-node"
|
|
1454
|
+
x={node.x} y={node.y} width={node.w} height={node.h}
|
|
1455
|
+
fill={node.color}
|
|
1456
|
+
tabindex={0}
|
|
1457
|
+
aria-label={`${node.label}: in ${fmt(node.totalIn)}, out ${fmt(node.totalOut)}`}
|
|
1458
|
+
onmousemove={(e) => showTip(e.clientX, e.clientY, node.label, [
|
|
1459
|
+
{ label: 'in', value: fmt(node.totalIn) },
|
|
1460
|
+
{ label: 'out', value: fmt(node.totalOut) },
|
|
1461
|
+
])}
|
|
1462
|
+
onmouseleave={clearActive}
|
|
1463
|
+
onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); showTip(b.left + b.width / 2, b.top, node.label, [
|
|
1464
|
+
{ label: 'in', value: fmt(node.totalIn) },
|
|
1465
|
+
{ label: 'out', value: fmt(node.totalOut) },
|
|
1466
|
+
]) }}
|
|
1467
|
+
onblur={clearActive}
|
|
1468
|
+
/>
|
|
1469
|
+
<text class="sv-grid-chart-sankey-label"
|
|
1470
|
+
x={node.column === 0 ? node.x + node.w + 4 : node.x - 4}
|
|
1471
|
+
y={node.y + node.h / 2 + 3}
|
|
1472
|
+
text-anchor={node.column === 0 ? 'start' : 'end'}>{node.label}</text>
|
|
1473
|
+
{/each}
|
|
1474
|
+
{/if}
|
|
1475
|
+
|
|
1476
|
+
{#each geo.slices as slice, si (si)}
|
|
1477
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1478
|
+
<path
|
|
1479
|
+
class="sv-grid-chart-slice"
|
|
1480
|
+
d={slice.path}
|
|
1481
|
+
fill={slice.color}
|
|
1482
|
+
stroke="var(--sg-bg, #fff)"
|
|
1483
|
+
stroke-width="1"
|
|
1484
|
+
role={onSelect ? 'button' : 'presentation'}
|
|
1485
|
+
tabindex={onSelect ? 0 : undefined}
|
|
1486
|
+
aria-label={`${slice.label}: ${fmt(slice.value)}, ${slice.percent.toFixed(1)}%`}
|
|
1487
|
+
onmousemove={(e) => hoverSlice(e.clientX, e.clientY, slice.label, slice.value, slice.percent)}
|
|
1488
|
+
onmouseleave={clearActive}
|
|
1489
|
+
onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); hoverSlice(b.left + b.width / 2, b.top, slice.label, slice.value, slice.percent) }}
|
|
1490
|
+
onblur={clearActive}
|
|
1491
|
+
onclick={() => select(slice.label, slice.label, slice.value, si)}
|
|
1492
|
+
onkeydown={(e) => onSliceKey(e, slice.label, slice.value)}
|
|
1493
|
+
/>
|
|
1494
|
+
{#if dataLabels && slice.percent >= 6}
|
|
1495
|
+
<text class="sv-grid-chart-datalabel on-bar" x={slice.cx} y={slice.cy} text-anchor="middle" dominant-baseline="middle">{slice.percent.toFixed(0)}%</text>
|
|
1496
|
+
{/if}
|
|
1497
|
+
{/each}
|
|
1498
|
+
|
|
1499
|
+
{#if geo.donut}
|
|
1500
|
+
<text class="sv-grid-chart-donut-total" x={geo.donut.cx} y={geo.donut.cy - 4} text-anchor="middle">{fmt(geo.donut.total)}</text>
|
|
1501
|
+
<text class="sv-grid-chart-donut-label" x={geo.donut.cx} y={geo.donut.cy + 11} text-anchor="middle">Total</text>
|
|
1502
|
+
{/if}
|
|
1503
|
+
|
|
1504
|
+
{#if isCartesian && !isScatter}
|
|
1505
|
+
<!-- Per-category hover/focus zones drive the unified crosshair
|
|
1506
|
+
tooltip + arrow-key navigation. tabindex=0 on the first rect
|
|
1507
|
+
and -1 on the rest implements roving tabindex: Tab enters the
|
|
1508
|
+
group, arrow keys move within it, Tab leaves.
|
|
1509
|
+
Vertical bands centre on the ACTUAL point x (which differs from
|
|
1510
|
+
slot math when xType is 'time'); their widths bisect the gap to
|
|
1511
|
+
neighbouring points so a mouse over a dot always selects it. -->
|
|
1512
|
+
{#if dense}
|
|
1513
|
+
<!-- One surface instead of n rects. At this density a per-category
|
|
1514
|
+
rect is thinner than the pointer, and each one costs a catRows()
|
|
1515
|
+
call for its aria-label at render time - so 100k categories meant
|
|
1516
|
+
100k tooltip-row computations before anything was even hovered. -->
|
|
1517
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1518
|
+
<rect
|
|
1519
|
+
class="sv-grid-chart-cat-hit"
|
|
1520
|
+
x={geo.plot.x}
|
|
1521
|
+
y={geo.plot.y}
|
|
1522
|
+
width={geo.plot.w}
|
|
1523
|
+
height={geo.plot.h}
|
|
1524
|
+
role={onSelect ? 'button' : 'img'}
|
|
1525
|
+
tabindex={interactive ? 0 : undefined}
|
|
1526
|
+
data-cat-index={denseIndex}
|
|
1527
|
+
data-dense="true"
|
|
1528
|
+
aria-label={`${visibleSpec.categories.length} points, ${visibleSpec.categories[0] ?? ''} to ${visibleSpec.categories[visibleSpec.categories.length - 1] ?? ''}. Arrow keys step through values.`}
|
|
1529
|
+
onmousemove={(e) => {
|
|
1530
|
+
const i = catAtClientX(e.clientX, e.currentTarget)
|
|
1531
|
+
if (i != null) { denseIndex = i; hoverCat(e.clientX, e.clientY, i) }
|
|
1532
|
+
}}
|
|
1533
|
+
onmouseleave={clearActive}
|
|
1534
|
+
onfocus={(e) => focusCat(e.currentTarget, denseIndex)}
|
|
1535
|
+
onblur={clearActive}
|
|
1536
|
+
onclick={() => select(visibleSpec.categories[denseIndex] ?? '', '', 0, denseIndex)}
|
|
1537
|
+
onkeydown={onDenseKey}
|
|
1538
|
+
/>
|
|
1539
|
+
{:else}
|
|
1540
|
+
{#each visibleSpec.categories as cat, i (cat + i)}
|
|
1541
|
+
{@const pts = geo.lines[0]?.points}
|
|
1542
|
+
{@const px = pts?.[i]?.x}
|
|
1543
|
+
{@const useTimeBands = !isHorizontal && pts && pts.length === visibleSpec.categories.length && Number.isFinite(px)}
|
|
1544
|
+
{@const prevX = useTimeBands && i > 0 ? pts![i - 1]!.x : null}
|
|
1545
|
+
{@const nextX = useTimeBands && i < pts!.length - 1 ? pts![i + 1]!.x : null}
|
|
1546
|
+
{@const bandL = !useTimeBands ? geo.plot.x + slot * i
|
|
1547
|
+
: prevX != null ? (prevX + (px as number)) / 2 : geo.plot.x}
|
|
1548
|
+
{@const bandR = !useTimeBands ? geo.plot.x + slot * (i + 1)
|
|
1549
|
+
: nextX != null ? ((px as number) + nextX) / 2 : geo.plot.x + geo.plot.w}
|
|
1550
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1551
|
+
<rect
|
|
1552
|
+
class="sv-grid-chart-cat-hit"
|
|
1553
|
+
x={isHorizontal ? geo.plot.x : bandL}
|
|
1554
|
+
y={isHorizontal ? geo.plot.y + slot * i : geo.plot.y}
|
|
1555
|
+
width={isHorizontal ? geo.plot.w : Math.max(0, bandR - bandL)}
|
|
1556
|
+
height={isHorizontal ? slot : geo.plot.h}
|
|
1557
|
+
role={onSelect ? 'button' : 'img'}
|
|
1558
|
+
tabindex={interactive ? (i === 0 ? 0 : -1) : undefined}
|
|
1559
|
+
data-cat-index={i}
|
|
1560
|
+
aria-label={`${cat}: ${catRows(i).map((r) => `${r.label} ${r.value}`).join(', ')}`}
|
|
1561
|
+
onmousemove={(e) => hoverCat(e.clientX, e.clientY, i)}
|
|
1562
|
+
onmouseleave={clearActive}
|
|
1563
|
+
onfocus={(e) => focusCat(e.currentTarget, i)}
|
|
1564
|
+
onblur={clearActive}
|
|
1565
|
+
onclick={() => select(cat, '', 0, i)}
|
|
1566
|
+
onkeydown={(e) => onCatKey(e, i)}
|
|
1567
|
+
/>
|
|
1568
|
+
{/each}
|
|
1569
|
+
{/if}
|
|
1570
|
+
{/if}
|
|
1571
|
+
|
|
1572
|
+
{#if dragRect}
|
|
1573
|
+
<rect class="sv-grid-chart-zoom-rect"
|
|
1574
|
+
x={dragRect.x} y={dragRect.y} width={dragRect.w} height={dragRect.h} />
|
|
1575
|
+
{/if}
|
|
1576
|
+
</svg>
|
|
1577
|
+
|
|
1578
|
+
{#if brushEligible && brushGeo}
|
|
1579
|
+
{@const bx = brushGeo.plot.x + brushWindow.t0 * brushGeo.plot.w}
|
|
1580
|
+
{@const bw = (brushWindow.t1 - brushWindow.t0) * brushGeo.plot.w}
|
|
1581
|
+
<svg
|
|
1582
|
+
class="sv-grid-chart-brush"
|
|
1583
|
+
viewBox={`0 0 ${brushGeo.width} ${brushGeo.height}`}
|
|
1584
|
+
width="100%"
|
|
1585
|
+
role="img"
|
|
1586
|
+
aria-label="Range brush"
|
|
1587
|
+
onpointermove={onBrushMove}
|
|
1588
|
+
>
|
|
1589
|
+
<!-- Mini chart: paint a faint version of the lines / area / bars
|
|
1590
|
+
so the user sees what region they're picking from. -->
|
|
1591
|
+
{#each brushGeo.lines as line, li (li)}
|
|
1592
|
+
{#if line.areaPath}
|
|
1593
|
+
<path d={line.areaPath} fill={line.color} fill-opacity="0.10" stroke="none" />
|
|
1594
|
+
{/if}
|
|
1595
|
+
<path d={line.path} fill="none" stroke={line.color} stroke-width="1.2" opacity="0.75" />
|
|
1596
|
+
{/each}
|
|
1597
|
+
{#each brushGeo.bars as bar, bi (bi)}
|
|
1598
|
+
<rect x={bar.x} y={bar.y} width={bar.w} height={bar.h} fill={bar.color} opacity="0.55" />
|
|
1599
|
+
{/each}
|
|
1600
|
+
<!-- Faded overlay on the parts NOT in the window. -->
|
|
1601
|
+
<rect class="sv-grid-chart-brush-mask"
|
|
1602
|
+
x={brushGeo.plot.x} y={brushGeo.plot.y}
|
|
1603
|
+
width={bx - brushGeo.plot.x} height={brushGeo.plot.h} />
|
|
1604
|
+
<rect class="sv-grid-chart-brush-mask"
|
|
1605
|
+
x={bx + bw} y={brushGeo.plot.y}
|
|
1606
|
+
width={brushGeo.plot.x + brushGeo.plot.w - (bx + bw)} height={brushGeo.plot.h} />
|
|
1607
|
+
<!-- The window body: pointerdown moves the whole window. -->
|
|
1608
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1609
|
+
<rect class="sv-grid-chart-brush-window"
|
|
1610
|
+
x={bx} y={brushGeo.plot.y} width={bw} height={brushGeo.plot.h}
|
|
1611
|
+
onpointerdown={(e) => onBrushDown(e, 'move')}
|
|
1612
|
+
onpointerup={onBrushUp}
|
|
1613
|
+
onpointercancel={onBrushUp} />
|
|
1614
|
+
<!-- Left + right handles: pointerdown resizes that edge. -->
|
|
1615
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1616
|
+
<rect class="sv-grid-chart-brush-handle"
|
|
1617
|
+
x={bx - 4} y={brushGeo.plot.y} width="8" height={brushGeo.plot.h}
|
|
1618
|
+
onpointerdown={(e) => onBrushDown(e, 'left')}
|
|
1619
|
+
onpointerup={onBrushUp}
|
|
1620
|
+
onpointercancel={onBrushUp} />
|
|
1621
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1622
|
+
<rect class="sv-grid-chart-brush-handle"
|
|
1623
|
+
x={bx + bw - 4} y={brushGeo.plot.y} width="8" height={brushGeo.plot.h}
|
|
1624
|
+
onpointerdown={(e) => onBrushDown(e, 'right')}
|
|
1625
|
+
onpointerup={onBrushUp}
|
|
1626
|
+
onpointercancel={onBrushUp} />
|
|
1627
|
+
</svg>
|
|
1628
|
+
{/if}
|
|
1629
|
+
|
|
1630
|
+
{#if isEmpty}
|
|
1631
|
+
<div class="sv-grid-chart-empty">No data</div>
|
|
1632
|
+
{/if}
|
|
1633
|
+
|
|
1634
|
+
{#if tip}
|
|
1635
|
+
<div class="sv-grid-chart-tooltip" class:is-below={tip.below} style={`left:${tip.left}px; top:${tip.top}px;`}>
|
|
1636
|
+
<span class="sv-grid-chart-tooltip-title">{tip.title}</span>
|
|
1637
|
+
{#each tip.rows as r, ri (ri)}
|
|
1638
|
+
<span class="sv-grid-chart-tooltip-row">
|
|
1639
|
+
{#if r.color}<span class="sv-grid-chart-tooltip-dot" style={`background:${r.color}`}></span>{/if}
|
|
1640
|
+
{#if r.label}<span class="sv-grid-chart-tooltip-row-label">{r.label}</span>{/if}
|
|
1641
|
+
<span class="sv-grid-chart-tooltip-row-value">{r.value}</span>
|
|
1642
|
+
</span>
|
|
1643
|
+
{/each}
|
|
1644
|
+
</div>
|
|
1645
|
+
{/if}
|
|
1646
|
+
|
|
1647
|
+
{#if legend && legendItems.length}
|
|
1648
|
+
<div class="sv-grid-chart-legend">
|
|
1649
|
+
{#each shownLegend as item (item.label)}
|
|
1650
|
+
<button
|
|
1651
|
+
type="button"
|
|
1652
|
+
class="sv-grid-chart-legend-item"
|
|
1653
|
+
class:is-off={item.off}
|
|
1654
|
+
class:is-isolated={isolated === item.label}
|
|
1655
|
+
disabled={!interactive}
|
|
1656
|
+
aria-pressed={!item.off}
|
|
1657
|
+
onclick={() => toggle(item.label)}
|
|
1658
|
+
ondblclick={() => isolate(item.label)}
|
|
1659
|
+
onpointerenter={() => interactive && (dimmed = item.label)}
|
|
1660
|
+
onpointerleave={() => (dimmed = null)}
|
|
1661
|
+
title={interactive ? `${item.off ? 'Show' : 'Hide'} ${item.label} · double-click to isolate` : item.label}
|
|
1662
|
+
>
|
|
1663
|
+
<span class="sv-grid-chart-swatch" style={`background:${item.off ? 'transparent' : item.color}; border-color:${item.color}`}></span>
|
|
1664
|
+
{item.label}
|
|
1665
|
+
</button>
|
|
1666
|
+
{/each}
|
|
1667
|
+
{#if legendOverflow}
|
|
1668
|
+
<button type="button" class="sv-grid-chart-legend-more" onclick={() => (legendExpanded = !legendExpanded)}>
|
|
1669
|
+
{legendExpanded ? 'Show less' : `+${legendItems.length - LEGEND_MAX} more`}
|
|
1670
|
+
</button>
|
|
1671
|
+
{/if}
|
|
1672
|
+
</div>
|
|
1673
|
+
{/if}
|
|
1674
|
+
|
|
1675
|
+
<!-- Visually-hidden data table: the same data for assistive technology.
|
|
1676
|
+
Capped, and the caption says so. A table with 100k rows is not an
|
|
1677
|
+
accessible version of anything - nobody traverses that with a screen
|
|
1678
|
+
reader, and rendering it was a third of the DOM cost of a large chart.
|
|
1679
|
+
Saying "first 1000 of 100000" is more use than silently truncating, and
|
|
1680
|
+
more honest than pretending the whole set is readable here. -->
|
|
1681
|
+
<table id={`${uid}-table`} class="sv-grid-chart-sr-only">
|
|
1682
|
+
<caption>
|
|
1683
|
+
{spec.type} chart data{#if srCapped}, first {SR_ROW_CAP} of {srTable.rows.length} rows{/if}
|
|
1684
|
+
</caption>
|
|
1685
|
+
<thead>
|
|
1686
|
+
<tr>{#each srTable.cols as c (c)}<th>{c}</th>{/each}</tr>
|
|
1687
|
+
</thead>
|
|
1688
|
+
<tbody>
|
|
1689
|
+
{#each srRows as row, ri (ri)}
|
|
1690
|
+
<tr>{#each row as cell, ci (ci)}<td>{cell}</td>{/each}</tr>
|
|
1691
|
+
{/each}
|
|
1692
|
+
</tbody>
|
|
1693
|
+
</table>
|
|
1694
|
+
</div>
|
|
1695
|
+
|
|
1696
|
+
<style>
|
|
1697
|
+
.sv-grid-chart {
|
|
1698
|
+
position: relative;
|
|
1699
|
+
display: flex;
|
|
1700
|
+
flex-direction: column;
|
|
1701
|
+
gap: 8px;
|
|
1702
|
+
width: 100%;
|
|
1703
|
+
}
|
|
1704
|
+
.sv-grid-chart-svg {
|
|
1705
|
+
display: block;
|
|
1706
|
+
width: 100%;
|
|
1707
|
+
height: auto;
|
|
1708
|
+
}
|
|
1709
|
+
.sv-grid-chart-gridline {
|
|
1710
|
+
stroke: var(--sg-border, #e2e8f0);
|
|
1711
|
+
stroke-width: 1;
|
|
1712
|
+
opacity: 0.6;
|
|
1713
|
+
}
|
|
1714
|
+
.sv-grid-chart-gridline.is-zero {
|
|
1715
|
+
stroke: var(--sg-muted, #94a3b8);
|
|
1716
|
+
opacity: 0.9;
|
|
1717
|
+
}
|
|
1718
|
+
.sv-grid-chart-axis {
|
|
1719
|
+
fill: var(--sg-muted, #64748b);
|
|
1720
|
+
font-size: 10px;
|
|
1721
|
+
font-family: inherit;
|
|
1722
|
+
}
|
|
1723
|
+
.sv-grid-chart-axis-title {
|
|
1724
|
+
fill: var(--sg-muted, #64748b);
|
|
1725
|
+
font-size: 11px;
|
|
1726
|
+
font-weight: 600;
|
|
1727
|
+
font-family: inherit;
|
|
1728
|
+
}
|
|
1729
|
+
.sv-grid-chart-group-label {
|
|
1730
|
+
fill: var(--sg-fg, #0f172a);
|
|
1731
|
+
font-weight: 600;
|
|
1732
|
+
}
|
|
1733
|
+
.sv-grid-chart-crosshair {
|
|
1734
|
+
stroke: var(--sg-accent, #2563eb);
|
|
1735
|
+
stroke-width: 1;
|
|
1736
|
+
stroke-dasharray: 3 3;
|
|
1737
|
+
opacity: 0.65;
|
|
1738
|
+
pointer-events: none;
|
|
1739
|
+
}
|
|
1740
|
+
.sv-grid-chart-refline {
|
|
1741
|
+
stroke-width: 1.5;
|
|
1742
|
+
opacity: 0.9;
|
|
1743
|
+
pointer-events: none;
|
|
1744
|
+
}
|
|
1745
|
+
.sv-grid-chart-reflabel {
|
|
1746
|
+
font-size: 9.5px;
|
|
1747
|
+
font-weight: 700;
|
|
1748
|
+
font-family: inherit;
|
|
1749
|
+
pointer-events: none;
|
|
1750
|
+
}
|
|
1751
|
+
.sv-grid-chart-overlay {
|
|
1752
|
+
opacity: 0.85;
|
|
1753
|
+
pointer-events: none;
|
|
1754
|
+
}
|
|
1755
|
+
.sv-grid-chart-annotation-marker {
|
|
1756
|
+
pointer-events: none;
|
|
1757
|
+
}
|
|
1758
|
+
.sv-grid-chart-annotation-label {
|
|
1759
|
+
font-size: 10px;
|
|
1760
|
+
font-weight: 700;
|
|
1761
|
+
font-family: inherit;
|
|
1762
|
+
pointer-events: none;
|
|
1763
|
+
}
|
|
1764
|
+
/* Heatmap cells. Stroke uses the border token (not bg) so the cell
|
|
1765
|
+
separators stay visible in BOTH light and dark themes - on dark mode
|
|
1766
|
+
a bg-colored stroke disappeared into the navy cells. */
|
|
1767
|
+
.sv-grid-chart-heatcell {
|
|
1768
|
+
stroke: var(--sg-border, #cbd5e1);
|
|
1769
|
+
stroke-width: 1;
|
|
1770
|
+
transition: opacity 100ms ease;
|
|
1771
|
+
cursor: default;
|
|
1772
|
+
}
|
|
1773
|
+
.sv-grid-chart-svg.is-clickable .sv-grid-chart-heatcell { cursor: pointer; }
|
|
1774
|
+
.sv-grid-chart-heatcell:hover { opacity: 0.85; }
|
|
1775
|
+
.sv-grid-chart-heatlabel {
|
|
1776
|
+
font-size: 10px;
|
|
1777
|
+
font-weight: 600;
|
|
1778
|
+
font-family: inherit;
|
|
1779
|
+
pointer-events: none;
|
|
1780
|
+
user-select: none;
|
|
1781
|
+
}
|
|
1782
|
+
/* Legend gradient bar gets a visible outline + axis labels next to it
|
|
1783
|
+
pull from the regular axis class so they follow the theme. */
|
|
1784
|
+
.sv-grid-chart-heatlegend {
|
|
1785
|
+
stroke: var(--sg-border, #cbd5e1);
|
|
1786
|
+
stroke-width: 1;
|
|
1787
|
+
}
|
|
1788
|
+
/* Funnel segments + labels. */
|
|
1789
|
+
.sv-grid-chart-funnel-seg {
|
|
1790
|
+
stroke: var(--sg-bg, #ffffff);
|
|
1791
|
+
stroke-width: 2;
|
|
1792
|
+
transition: opacity 100ms ease;
|
|
1793
|
+
cursor: default;
|
|
1794
|
+
}
|
|
1795
|
+
.sv-grid-chart-svg.is-clickable .sv-grid-chart-funnel-seg { cursor: pointer; }
|
|
1796
|
+
.sv-grid-chart-funnel-seg:hover { opacity: 0.88; }
|
|
1797
|
+
.sv-grid-chart-funnel-label {
|
|
1798
|
+
font-size: 11px;
|
|
1799
|
+
font-weight: 700;
|
|
1800
|
+
font-family: inherit;
|
|
1801
|
+
pointer-events: none;
|
|
1802
|
+
}
|
|
1803
|
+
.sv-grid-chart-funnel-value {
|
|
1804
|
+
font-size: 10px;
|
|
1805
|
+
font-family: inherit;
|
|
1806
|
+
opacity: 0.85;
|
|
1807
|
+
pointer-events: none;
|
|
1808
|
+
}
|
|
1809
|
+
/* Radar grid + polygons. */
|
|
1810
|
+
.sv-grid-chart-radar-ring {
|
|
1811
|
+
fill: none;
|
|
1812
|
+
stroke: var(--sg-border, #e2e8f0);
|
|
1813
|
+
stroke-width: 1;
|
|
1814
|
+
opacity: 0.6;
|
|
1815
|
+
}
|
|
1816
|
+
.sv-grid-chart-radar-spoke {
|
|
1817
|
+
stroke: var(--sg-border, #e2e8f0);
|
|
1818
|
+
stroke-width: 1;
|
|
1819
|
+
opacity: 0.6;
|
|
1820
|
+
}
|
|
1821
|
+
.sv-grid-chart-radar-poly {
|
|
1822
|
+
transition: opacity 100ms ease;
|
|
1823
|
+
pointer-events: none;
|
|
1824
|
+
}
|
|
1825
|
+
.sv-grid-chart-radar-dot {
|
|
1826
|
+
transition: r 100ms ease;
|
|
1827
|
+
cursor: default;
|
|
1828
|
+
}
|
|
1829
|
+
.sv-grid-chart-radar-dot:hover { r: 4.5; }
|
|
1830
|
+
/* Calendar heatmap cells. Blank cells get a faint outline so the grid
|
|
1831
|
+
still reads on days with no data. */
|
|
1832
|
+
.sv-grid-chart-calendar-cell {
|
|
1833
|
+
stroke: var(--sg-border, #e2e8f0);
|
|
1834
|
+
stroke-width: 0.5;
|
|
1835
|
+
transition: opacity 100ms ease;
|
|
1836
|
+
cursor: default;
|
|
1837
|
+
}
|
|
1838
|
+
.sv-grid-chart-calendar-cell.is-blank { stroke-opacity: 0.5; }
|
|
1839
|
+
.sv-grid-chart-calendar-cell:hover { opacity: 0.85; }
|
|
1840
|
+
.sv-grid-chart-calendar-legend {
|
|
1841
|
+
stroke: var(--sg-border, #cbd5e1);
|
|
1842
|
+
stroke-width: 0.5;
|
|
1843
|
+
}
|
|
1844
|
+
/* Gauge centre readout. */
|
|
1845
|
+
.sv-grid-chart-gauge-value {
|
|
1846
|
+
fill: var(--sg-fg, #0f172a);
|
|
1847
|
+
font-size: 22px;
|
|
1848
|
+
font-weight: 800;
|
|
1849
|
+
font-family: inherit;
|
|
1850
|
+
pointer-events: none;
|
|
1851
|
+
}
|
|
1852
|
+
/* Gauge tick marks: faint minors, stronger majors. */
|
|
1853
|
+
.sv-grid-chart-gauge-tick {
|
|
1854
|
+
stroke: var(--sg-muted, #94a3b8);
|
|
1855
|
+
stroke-width: 1;
|
|
1856
|
+
opacity: 0.4;
|
|
1857
|
+
}
|
|
1858
|
+
.sv-grid-chart-gauge-tick.is-major {
|
|
1859
|
+
stroke-width: 2;
|
|
1860
|
+
opacity: 0.7;
|
|
1861
|
+
}
|
|
1862
|
+
/* Pointer needle + hub. */
|
|
1863
|
+
.sv-grid-chart-gauge-needle {
|
|
1864
|
+
fill: var(--sg-fg, #0f172a);
|
|
1865
|
+
/* A hairline of the background colour around the needle keeps it legible
|
|
1866
|
+
where it crosses the value arc, which is the same weight and often a
|
|
1867
|
+
similar tone. */
|
|
1868
|
+
stroke: var(--sg-bg, #ffffff);
|
|
1869
|
+
stroke-width: 0.75;
|
|
1870
|
+
stroke-linejoin: round;
|
|
1871
|
+
}
|
|
1872
|
+
.sv-grid-chart-gauge-hub {
|
|
1873
|
+
fill: var(--sg-fg, #0f172a);
|
|
1874
|
+
stroke: var(--sg-bg, #ffffff);
|
|
1875
|
+
stroke-width: 2;
|
|
1876
|
+
}
|
|
1877
|
+
.sv-grid-chart-gauge-hub-dot {
|
|
1878
|
+
fill: var(--sg-bg, #ffffff);
|
|
1879
|
+
}
|
|
1880
|
+
/* Tree-map cells. White separators give the squarified rects breathing
|
|
1881
|
+
room without distracting from the data. */
|
|
1882
|
+
.sv-grid-chart-treemap-cell {
|
|
1883
|
+
stroke: var(--sg-bg, #ffffff);
|
|
1884
|
+
stroke-width: 1;
|
|
1885
|
+
transition: opacity 100ms ease;
|
|
1886
|
+
cursor: default;
|
|
1887
|
+
}
|
|
1888
|
+
.sv-grid-chart-svg.is-clickable .sv-grid-chart-treemap-cell { cursor: pointer; }
|
|
1889
|
+
.sv-grid-chart-treemap-cell:hover { opacity: 0.88; }
|
|
1890
|
+
.sv-grid-chart-treemap-label {
|
|
1891
|
+
font-size: 11px;
|
|
1892
|
+
font-weight: 600;
|
|
1893
|
+
font-family: inherit;
|
|
1894
|
+
pointer-events: none;
|
|
1895
|
+
}
|
|
1896
|
+
.sv-grid-chart-treemap-value {
|
|
1897
|
+
font-size: 10px;
|
|
1898
|
+
font-family: inherit;
|
|
1899
|
+
pointer-events: none;
|
|
1900
|
+
}
|
|
1901
|
+
/* Sankey nodes + links. Links use stroke not fill so per-ribbon width
|
|
1902
|
+
comes from stroke-width directly. */
|
|
1903
|
+
.sv-grid-chart-sankey-node {
|
|
1904
|
+
transition: opacity 100ms ease;
|
|
1905
|
+
cursor: default;
|
|
1906
|
+
}
|
|
1907
|
+
.sv-grid-chart-sankey-node:hover { opacity: 0.88; }
|
|
1908
|
+
.sv-grid-chart-sankey-link {
|
|
1909
|
+
transition: stroke-opacity 100ms ease;
|
|
1910
|
+
cursor: default;
|
|
1911
|
+
}
|
|
1912
|
+
.sv-grid-chart-sankey-link:hover { stroke-opacity: 0.6; }
|
|
1913
|
+
.sv-grid-chart-sankey-label {
|
|
1914
|
+
fill: var(--sg-fg, #0f172a);
|
|
1915
|
+
font-size: 11px;
|
|
1916
|
+
font-weight: 600;
|
|
1917
|
+
font-family: inherit;
|
|
1918
|
+
pointer-events: none;
|
|
1919
|
+
}
|
|
1920
|
+
/* Brush mini-map: a compact secondary chart with a draggable window. */
|
|
1921
|
+
.sv-grid-chart-brush {
|
|
1922
|
+
display: block;
|
|
1923
|
+
width: 100%;
|
|
1924
|
+
height: auto;
|
|
1925
|
+
margin-top: 4px;
|
|
1926
|
+
border-top: 1px solid var(--sg-border, #e2e8f0);
|
|
1927
|
+
user-select: none;
|
|
1928
|
+
touch-action: none;
|
|
1929
|
+
}
|
|
1930
|
+
.sv-grid-chart-brush-mask {
|
|
1931
|
+
fill: var(--sg-bg, #ffffff);
|
|
1932
|
+
fill-opacity: 0.65;
|
|
1933
|
+
pointer-events: none;
|
|
1934
|
+
}
|
|
1935
|
+
.sv-grid-chart-brush-window {
|
|
1936
|
+
fill: var(--sg-accent, #2563eb);
|
|
1937
|
+
fill-opacity: 0.10;
|
|
1938
|
+
stroke: var(--sg-accent, #2563eb);
|
|
1939
|
+
stroke-width: 1;
|
|
1940
|
+
cursor: grab;
|
|
1941
|
+
}
|
|
1942
|
+
.sv-grid-chart-brush-window:active { cursor: grabbing; }
|
|
1943
|
+
.sv-grid-chart-brush-handle {
|
|
1944
|
+
fill: var(--sg-accent, #2563eb);
|
|
1945
|
+
fill-opacity: 0.4;
|
|
1946
|
+
cursor: ew-resize;
|
|
1947
|
+
}
|
|
1948
|
+
.sv-grid-chart-brush-handle:hover { fill-opacity: 0.7; }
|
|
1949
|
+
.sv-grid-chart-scatter {
|
|
1950
|
+
transition: opacity 0.2s ease;
|
|
1951
|
+
stroke-width: 1;
|
|
1952
|
+
}
|
|
1953
|
+
.is-clickable .sv-grid-chart-scatter,
|
|
1954
|
+
.sv-grid-chart-scatter:hover {
|
|
1955
|
+
cursor: pointer;
|
|
1956
|
+
}
|
|
1957
|
+
.sv-grid-chart-cat-hit {
|
|
1958
|
+
fill: transparent;
|
|
1959
|
+
}
|
|
1960
|
+
.sv-grid-chart-cat-hit:focus-visible,
|
|
1961
|
+
.sv-grid-chart-slice:focus-visible {
|
|
1962
|
+
outline: 2px solid var(--sg-accent, #2563eb);
|
|
1963
|
+
outline-offset: 1px;
|
|
1964
|
+
}
|
|
1965
|
+
.sv-grid-chart-datalabel {
|
|
1966
|
+
fill: var(--sg-fg, #0f172a);
|
|
1967
|
+
font-size: 9.5px;
|
|
1968
|
+
font-weight: 600;
|
|
1969
|
+
font-variant-numeric: tabular-nums;
|
|
1970
|
+
pointer-events: none;
|
|
1971
|
+
}
|
|
1972
|
+
.sv-grid-chart-datalabel.on-bar {
|
|
1973
|
+
fill: #fff;
|
|
1974
|
+
}
|
|
1975
|
+
.sv-grid-chart-donut-total {
|
|
1976
|
+
fill: var(--sg-fg, #0f172a);
|
|
1977
|
+
font-size: 16px;
|
|
1978
|
+
font-weight: 800;
|
|
1979
|
+
font-variant-numeric: tabular-nums;
|
|
1980
|
+
}
|
|
1981
|
+
.sv-grid-chart-donut-label {
|
|
1982
|
+
fill: var(--sg-muted, #64748b);
|
|
1983
|
+
font-size: 10px;
|
|
1984
|
+
}
|
|
1985
|
+
.sv-grid-chart-bar {
|
|
1986
|
+
transition: x 0.3s ease, y 0.3s ease, width 0.3s ease, height 0.3s ease;
|
|
1987
|
+
}
|
|
1988
|
+
.sv-grid-chart-dot {
|
|
1989
|
+
transition: cx 0.3s ease, cy 0.3s ease;
|
|
1990
|
+
}
|
|
1991
|
+
.sv-grid-chart-candle {
|
|
1992
|
+
stroke-width: 1;
|
|
1993
|
+
transition: x 0.3s ease, y 0.3s ease, width 0.3s ease, height 0.3s ease;
|
|
1994
|
+
}
|
|
1995
|
+
.sv-grid-chart-wick,
|
|
1996
|
+
.sv-grid-chart-ohlc {
|
|
1997
|
+
stroke-width: 1;
|
|
1998
|
+
shape-rendering: crispEdges;
|
|
1999
|
+
}
|
|
2000
|
+
.sv-grid-chart-box {
|
|
2001
|
+
stroke-width: 1;
|
|
2002
|
+
transition: x 0.3s ease, y 0.3s ease, width 0.3s ease, height 0.3s ease;
|
|
2003
|
+
}
|
|
2004
|
+
.sv-grid-chart-whisker,
|
|
2005
|
+
.sv-grid-chart-errorbar {
|
|
2006
|
+
stroke-width: 1;
|
|
2007
|
+
shape-rendering: crispEdges;
|
|
2008
|
+
}
|
|
2009
|
+
/* The median is the number people read off a box plot, so it gets the
|
|
2010
|
+
weight. Everything else on the mark is 1px. */
|
|
2011
|
+
.sv-grid-chart-median {
|
|
2012
|
+
stroke-width: 2;
|
|
2013
|
+
shape-rendering: crispEdges;
|
|
2014
|
+
}
|
|
2015
|
+
.sv-grid-chart-outlier {
|
|
2016
|
+
stroke-width: 1;
|
|
2017
|
+
}
|
|
2018
|
+
.is-clickable .sv-grid-chart-cat-hit,
|
|
2019
|
+
.is-clickable .sv-grid-chart-slice {
|
|
2020
|
+
cursor: pointer;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
.sv-grid-chart-empty {
|
|
2024
|
+
position: absolute;
|
|
2025
|
+
inset: 0;
|
|
2026
|
+
display: flex;
|
|
2027
|
+
align-items: center;
|
|
2028
|
+
justify-content: center;
|
|
2029
|
+
color: var(--sg-muted, #94a3b8);
|
|
2030
|
+
font-size: 13px;
|
|
2031
|
+
pointer-events: none;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/* Toolbar above the chart: reset-zoom, PNG / SVG / Copy. */
|
|
2035
|
+
.sv-grid-chart-toolbar {
|
|
2036
|
+
display: flex;
|
|
2037
|
+
justify-content: flex-end;
|
|
2038
|
+
gap: 4px;
|
|
2039
|
+
flex-wrap: wrap;
|
|
2040
|
+
}
|
|
2041
|
+
.sv-grid-chart-tool {
|
|
2042
|
+
display: inline-flex;
|
|
2043
|
+
align-items: center;
|
|
2044
|
+
gap: 4px;
|
|
2045
|
+
border: 1px solid var(--sg-border, #cbd5e1);
|
|
2046
|
+
background: var(--sg-bg, #ffffff);
|
|
2047
|
+
color: var(--sg-fg, #1e293b);
|
|
2048
|
+
font-size: 11px;
|
|
2049
|
+
font-weight: 500;
|
|
2050
|
+
padding: 3px 8px;
|
|
2051
|
+
border-radius: 4px;
|
|
2052
|
+
cursor: pointer;
|
|
2053
|
+
transition: background-color 100ms ease, border-color 100ms ease;
|
|
2054
|
+
}
|
|
2055
|
+
.sv-grid-chart-tool:hover {
|
|
2056
|
+
background: var(--sg-row-hover-bg, #f1f5f9);
|
|
2057
|
+
border-color: var(--sg-accent, #2563eb);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/* Annotate mode: the toggle reads as pressed, the plot as a target, and a
|
|
2061
|
+
marker as something you can click to take away. */
|
|
2062
|
+
.sv-grid-chart-tool.is-on {
|
|
2063
|
+
background: var(--sg-accent, #2563eb);
|
|
2064
|
+
border-color: var(--sg-accent, #2563eb);
|
|
2065
|
+
color: var(--sg-on-accent, #fff);
|
|
2066
|
+
}
|
|
2067
|
+
.sv-grid-chart-svg.is-annotating { cursor: copy; }
|
|
2068
|
+
.sv-grid-chart-annotation-marker.is-removable { cursor: pointer; }
|
|
2069
|
+
|
|
2070
|
+
/* Drag-to-zoom: translucent accent rectangle over the plot. */
|
|
2071
|
+
.sv-grid-chart-svg.is-zoomable { cursor: crosshair; }
|
|
2072
|
+
.sv-grid-chart-svg.is-dragging { cursor: crosshair; user-select: none; }
|
|
2073
|
+
.sv-grid-chart-zoom-rect {
|
|
2074
|
+
fill: var(--sg-accent, #2563eb);
|
|
2075
|
+
fill-opacity: 0.12;
|
|
2076
|
+
stroke: var(--sg-accent, #2563eb);
|
|
2077
|
+
stroke-width: 1;
|
|
2078
|
+
stroke-dasharray: 3 3;
|
|
2079
|
+
pointer-events: none;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
.sv-grid-chart-tooltip {
|
|
2083
|
+
position: absolute;
|
|
2084
|
+
z-index: 10;
|
|
2085
|
+
transform: translate(-50%, calc(-100% - 12px));
|
|
2086
|
+
pointer-events: none;
|
|
2087
|
+
white-space: nowrap;
|
|
2088
|
+
padding: 6px 9px;
|
|
2089
|
+
border-radius: 6px;
|
|
2090
|
+
background: var(--sg-fg, #0f172a);
|
|
2091
|
+
color: var(--sg-bg, #fff);
|
|
2092
|
+
font-size: 11.5px;
|
|
2093
|
+
line-height: 1.4;
|
|
2094
|
+
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
|
|
2095
|
+
display: flex;
|
|
2096
|
+
flex-direction: column;
|
|
2097
|
+
gap: 1px;
|
|
2098
|
+
}
|
|
2099
|
+
.sv-grid-chart-tooltip.is-below {
|
|
2100
|
+
transform: translate(-50%, 16px);
|
|
2101
|
+
}
|
|
2102
|
+
.sv-grid-chart-tooltip-title {
|
|
2103
|
+
opacity: 0.75;
|
|
2104
|
+
font-size: 10.5px;
|
|
2105
|
+
margin-bottom: 1px;
|
|
2106
|
+
}
|
|
2107
|
+
.sv-grid-chart-tooltip-row {
|
|
2108
|
+
display: flex;
|
|
2109
|
+
align-items: center;
|
|
2110
|
+
gap: 6px;
|
|
2111
|
+
}
|
|
2112
|
+
.sv-grid-chart-tooltip-dot {
|
|
2113
|
+
width: 8px;
|
|
2114
|
+
height: 8px;
|
|
2115
|
+
border-radius: 2px;
|
|
2116
|
+
flex-shrink: 0;
|
|
2117
|
+
}
|
|
2118
|
+
.sv-grid-chart-tooltip-row-label {
|
|
2119
|
+
opacity: 0.85;
|
|
2120
|
+
}
|
|
2121
|
+
.sv-grid-chart-tooltip-row-value {
|
|
2122
|
+
margin-left: auto;
|
|
2123
|
+
padding-left: 10px;
|
|
2124
|
+
font-weight: 700;
|
|
2125
|
+
font-variant-numeric: tabular-nums;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
.sv-grid-chart-legend {
|
|
2129
|
+
display: flex;
|
|
2130
|
+
flex-wrap: wrap;
|
|
2131
|
+
gap: 6px 12px;
|
|
2132
|
+
font-size: 12px;
|
|
2133
|
+
color: var(--sg-fg, #0f172a);
|
|
2134
|
+
}
|
|
2135
|
+
.sv-grid-chart-legend-item {
|
|
2136
|
+
display: inline-flex;
|
|
2137
|
+
align-items: center;
|
|
2138
|
+
gap: 6px;
|
|
2139
|
+
border: 0;
|
|
2140
|
+
background: transparent;
|
|
2141
|
+
color: inherit;
|
|
2142
|
+
font: inherit;
|
|
2143
|
+
padding: 1px 2px;
|
|
2144
|
+
border-radius: 4px;
|
|
2145
|
+
cursor: pointer;
|
|
2146
|
+
}
|
|
2147
|
+
.sv-grid-chart-legend-item:disabled {
|
|
2148
|
+
cursor: default;
|
|
2149
|
+
}
|
|
2150
|
+
.sv-grid-chart-legend-item.is-off {
|
|
2151
|
+
color: var(--sg-muted, #94a3b8);
|
|
2152
|
+
text-decoration: line-through;
|
|
2153
|
+
}
|
|
2154
|
+
.sv-grid-chart-legend-item.is-isolated {
|
|
2155
|
+
background: var(--sg-header-bg, #f1f5f9);
|
|
2156
|
+
font-weight: 700;
|
|
2157
|
+
}
|
|
2158
|
+
.sv-grid-chart-legend-more {
|
|
2159
|
+
border: 1px dashed var(--sg-border, #cbd5e1);
|
|
2160
|
+
background: transparent;
|
|
2161
|
+
color: var(--sg-muted, #64748b);
|
|
2162
|
+
font: inherit;
|
|
2163
|
+
font-size: 11px;
|
|
2164
|
+
padding: 1px 7px;
|
|
2165
|
+
border-radius: 999px;
|
|
2166
|
+
cursor: pointer;
|
|
2167
|
+
}
|
|
2168
|
+
.sv-grid-chart-legend-more:hover {
|
|
2169
|
+
color: var(--sg-fg, #0f172a);
|
|
2170
|
+
border-color: var(--sg-accent, #2563eb);
|
|
2171
|
+
}
|
|
2172
|
+
.sv-grid-chart-sr-only {
|
|
2173
|
+
position: absolute;
|
|
2174
|
+
width: 1px;
|
|
2175
|
+
height: 1px;
|
|
2176
|
+
padding: 0;
|
|
2177
|
+
margin: -1px;
|
|
2178
|
+
overflow: hidden;
|
|
2179
|
+
clip: rect(0 0 0 0);
|
|
2180
|
+
white-space: nowrap;
|
|
2181
|
+
border: 0;
|
|
2182
|
+
}
|
|
2183
|
+
.sv-grid-chart-swatch {
|
|
2184
|
+
width: 12px;
|
|
2185
|
+
height: 12px;
|
|
2186
|
+
border-radius: 3px;
|
|
2187
|
+
border: 1.5px solid transparent;
|
|
2188
|
+
display: inline-block;
|
|
2189
|
+
box-sizing: border-box;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/* Enter animation. Marks fade in when they are first created; the position
|
|
2193
|
+
transitions above then handle everything after that, so a chart appears
|
|
2194
|
+
smoothly and moves smoothly rather than popping into place and then
|
|
2195
|
+
animating.
|
|
2196
|
+
|
|
2197
|
+
A fade rather than the wipe you would normally reach for. A wipe needs a
|
|
2198
|
+
clip-path over all the marks, and the marks here are direct children of the
|
|
2199
|
+
<svg> - grouping them is a large edit across every chart type, for a nicer
|
|
2200
|
+
version of something already pleasant. Noted rather than done.
|
|
2201
|
+
|
|
2202
|
+
`both` matters: it holds the from-state before the animation starts, so a
|
|
2203
|
+
mark cannot flash at full opacity for a frame first. It also means that
|
|
2204
|
+
when `animation: none` applies below, the rule stops applying entirely and
|
|
2205
|
+
the mark is simply painted - not stuck invisible. */
|
|
2206
|
+
@keyframes sv-grid-chart-in {
|
|
2207
|
+
from { opacity: 0; }
|
|
2208
|
+
to { opacity: 1; }
|
|
2209
|
+
}
|
|
2210
|
+
.sv-grid-chart-bar,
|
|
2211
|
+
.sv-grid-chart-linepath,
|
|
2212
|
+
.sv-grid-chart-area,
|
|
2213
|
+
.sv-grid-chart-slice,
|
|
2214
|
+
.sv-grid-chart-candle,
|
|
2215
|
+
.sv-grid-chart-box,
|
|
2216
|
+
.sv-grid-chart-funnel-seg,
|
|
2217
|
+
.sv-grid-chart-heatcell,
|
|
2218
|
+
.sv-grid-chart-treemap-cell {
|
|
2219
|
+
animation: sv-grid-chart-in 0.3s ease-out both;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/* Reduced motion. Every other animated component in the kit honours this
|
|
2223
|
+
(SvCard, SvCircularProgress, SvCollapsible); the chart never did, so a
|
|
2224
|
+
reader who has asked the OS for less movement still got every bar, dot,
|
|
2225
|
+
candle and box sliding on each re-aggregation - and a chart re-aggregates
|
|
2226
|
+
on every filter keystroke, which is the worst case for it. */
|
|
2227
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2228
|
+
.sv-grid-chart-bar,
|
|
2229
|
+
.sv-grid-chart-dot,
|
|
2230
|
+
.sv-grid-chart-candle,
|
|
2231
|
+
.sv-grid-chart-box {
|
|
2232
|
+
transition: none;
|
|
2233
|
+
}
|
|
2234
|
+
.sv-grid-chart-bar,
|
|
2235
|
+
.sv-grid-chart-linepath,
|
|
2236
|
+
.sv-grid-chart-area,
|
|
2237
|
+
.sv-grid-chart-slice,
|
|
2238
|
+
.sv-grid-chart-candle,
|
|
2239
|
+
.sv-grid-chart-box,
|
|
2240
|
+
.sv-grid-chart-funnel-seg,
|
|
2241
|
+
.sv-grid-chart-heatcell,
|
|
2242
|
+
.sv-grid-chart-treemap-cell {
|
|
2243
|
+
animation: none;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
</style>
|