@svgrid/grid 2.2.33 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -199
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +178 -139
- package/dist/GridMenus.svelte +705 -705
- package/dist/SvAutoComplete.svelte +165 -165
- 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 +275 -275
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +160 -160
- package/dist/SvDrawer.svelte +254 -254
- package/dist/SvDropDownList.svelte +374 -374
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.css +2814 -2804
- package/dist/SvGrid.svelte +3490 -3490
- package/dist/SvGridChart.svelte +1716 -1716
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +728 -728
- package/dist/SvGridSelect.svelte +266 -266
- package/dist/SvGroupCell.svelte +116 -116
- package/dist/SvListBox.svelte +326 -326
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +289 -289
- package/dist/SvNumberInput.svelte +168 -168
- 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 +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +200 -200
- package/dist/SvSwitchButton.svelte +108 -108
- package/dist/SvTagsInput.svelte +112 -112
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +208 -208
- package/dist/SvTree.svelte +444 -444
- package/dist/SvTreeSelect.svelte +235 -235
- package/dist/cdn/{GridMenus-B7XhhqdN.js → GridMenus-BpVcKyUr.js} +4 -4
- package/dist/cdn/{GridMenus-BxmIvCJh.js → GridMenus-CeXL5IW4.js} +153 -150
- package/dist/cdn/{SvDateTimePicker-ClqtVIj3.js → SvDateTimePicker-DSUhIta8.js} +165 -165
- package/dist/cdn/{SvDateTimePicker-o94wfSUa.js → SvDateTimePicker-h9kPyszT.js} +572 -555
- package/dist/cdn/{SvGridChart-Cr_90pPD.js → SvGridChart-Bs2GIR2Q.js} +28 -21
- package/dist/cdn/{SvGridChart-BhnFdLd0.js → SvGridChart-CwhFz7GV.js} +2 -2
- package/dist/cdn/{SvGridChartPanel-Cmx9DHAS.js → SvGridChartPanel-1WXuStzM.js} +2 -2
- package/dist/cdn/{SvGridChartPanel-ICbxiUvK.js → SvGridChartPanel-e5_nqNP9.js} +20 -12
- package/dist/cdn/{SvGridChartView-Bx42PMvf.js → SvGridChartView-SmPW10dI.js} +3 -3
- package/dist/cdn/{SvGridChartView-B40DTJSX.js → SvGridChartView-eSPuJE6g.js} +2 -2
- package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-B1ZcLpgs.js} +1 -1
- package/dist/cdn/{SvGridDropdown-CUOdm7YA.js → SvGridDropdown-CtdbHcIS.js} +6 -5
- package/dist/cdn/{chart-T1usDN0o.js → chart-i4XcXyHJ.js} +12 -10
- package/dist/cdn/{disclose-version-DljhZohK.js → disclose-version-Dr7rEVv-.js} +989 -1081
- package/dist/cdn/{export-format-JT5pWVT8.js → export-format-Cv1Dll6k.js} +66 -245
- package/dist/cdn/{src-erjBRYfX.js → src-DbeN1ReZ.js} +9221 -8925
- package/dist/cdn/{src-ktbQVOk-.js → src-DmEQFW5_.js} +9021 -8809
- package/dist/cdn/svgrid.js +9 -9
- package/dist/cdn/svgrid.svelte-external.js +9 -9
- package/dist/chart-export.js +8 -8
- package/dist/columns.js +9 -2
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/_svelte_metadata.json +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/_metadata.json +178 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/attachments-qzRy9DX4.js +3843 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/attachments-qzRy9DX4.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/constants-RLNOXCpz.js +23 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/constants-RLNOXCpz.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/dev-DPd12uEU.js +1925 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/dev-DPd12uEU.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/events-CDC4DKuQ.js +206 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/events-CDC4DKuQ.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/flags-BD-lYX7s.js +20 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/flags-BD-lYX7s.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/hydration-D3UBU2t8.js +264 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/hydration-D3UBU2t8.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/legacy-client-B0ZUlbSQ.js +698 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/legacy-client-B0ZUlbSQ.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/package.json +3 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte.js +135 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_animate.js +57 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_animate.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_attachments.js +2 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_compiler.js +46691 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_compiler.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_easing.js +239 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_easing.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_events.js +2 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal.js +6 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_client.js +7 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_disclose-version.js +6 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_disclose-version.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_async.js +7 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_async.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_legacy.js +7 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_legacy.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_tracing.js +7 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_tracing.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_server.js +6 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_legacy.js +85 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_legacy.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_motion.js +561 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_motion.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_reactivity.js +26 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_reactivity.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_reactivity_window.js +124 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_reactivity_window.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_server.js +2 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_store.js +91 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_store.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_transition.js +242 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_transition.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/utils-lObbumRX.js +497 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/utils-lObbumRX.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/utils-mR9mAWyK.js +4929 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/utils-mR9mAWyK.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/validate-CezNmRkM.js +192 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/validate-CezNmRkM.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/version-BHHo0WE-.js +10 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/version-BHHo0WE-.js.map +1 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/warnings-yiExAB06.js +140 -0
- package/dist/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/warnings-yiExAB06.js.map +1 -0
- package/package.json +11 -11
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +178 -139
- package/src/GridMenus.svelte +705 -705
- package/src/SvAutoComplete.svelte +165 -165
- 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 +275 -275
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +160 -160
- package/src/SvDrawer.svelte +254 -254
- package/src/SvDropDownList.svelte +374 -374
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.controller.svelte.ts +3680 -3680
- package/src/SvGrid.css +2814 -2804
- package/src/SvGrid.svelte +3490 -3490
- package/src/SvGrid.types.ts +1708 -1708
- package/src/SvGridChart.svelte +1716 -1716
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +728 -728
- package/src/SvGridSelect.svelte +266 -266
- package/src/SvGroupCell.svelte +116 -116
- package/src/SvListBox.svelte +326 -326
- 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 +289 -289
- package/src/SvNumberInput.svelte +168 -168
- 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 +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +200 -200
- package/src/SvSwitchButton.svelte +108 -108
- package/src/SvTagsInput.svelte +112 -112
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +208 -208
- package/src/SvTree.svelte +444 -444
- package/src/SvTreeSelect.svelte +235 -235
- package/src/a11y/dismissable.test.ts +119 -119
- package/src/a11y/dismissable.ts +114 -114
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1391 -1391
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +805 -805
- package/src/builtin-editors.grid.test.ts +85 -85
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.test.ts +513 -513
- package/src/cell-render.ts +496 -496
- package/src/cell-values.ts +118 -118
- package/src/chart-export.ts +201 -201
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2295 -2295
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/columns.ts +7 -2
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1261 -1261
- 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 +319 -319
- package/src/editing.test.ts +949 -949
- package/src/editing.ts +606 -606
- package/src/editor-contract.ts +164 -164
- 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 +157 -157
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/group-display.test.ts +167 -167
- package/src/group-display.ts +200 -200
- package/src/index.ts +724 -724
- 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 +172 -172
- package/src/menus.ts +567 -567
- package/src/merge-objects.ts +48 -48
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/_svelte_metadata.json +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/_metadata.json +178 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/attachments-qzRy9DX4.js +3843 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/attachments-qzRy9DX4.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/constants-RLNOXCpz.js +23 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/constants-RLNOXCpz.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/dev-DPd12uEU.js +1925 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/dev-DPd12uEU.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/events-CDC4DKuQ.js +206 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/events-CDC4DKuQ.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/flags-BD-lYX7s.js +20 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/flags-BD-lYX7s.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/hydration-D3UBU2t8.js +264 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/hydration-D3UBU2t8.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/legacy-client-B0ZUlbSQ.js +698 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/legacy-client-B0ZUlbSQ.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/package.json +3 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte.js +135 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_animate.js +57 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_animate.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_attachments.js +2 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_compiler.js +46691 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_compiler.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_easing.js +239 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_easing.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_events.js +2 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal.js +6 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_client.js +7 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_disclose-version.js +6 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_disclose-version.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_async.js +7 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_async.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_legacy.js +7 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_legacy.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_tracing.js +7 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_flags_tracing.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_internal_server.js +6 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_legacy.js +85 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_legacy.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_motion.js +561 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_motion.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_reactivity.js +26 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_reactivity.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_reactivity_window.js +124 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_reactivity_window.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_server.js +2 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_store.js +91 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_store.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_transition.js +242 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/svelte_transition.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/utils-lObbumRX.js +497 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/utils-lObbumRX.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/utils-mR9mAWyK.js +4929 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/utils-mR9mAWyK.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/validate-CezNmRkM.js +192 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/validate-CezNmRkM.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/version-BHHo0WE-.js +10 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/version-BHHo0WE-.js.map +1 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/warnings-yiExAB06.js +140 -0
- package/src/node_modules/.vite/vitest/67d8ed22ed986c5e2e948f64d892f30be166cc36/deps___vitest__/warnings-yiExAB06.js.map +1 -0
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/row-resize.ts +210 -210
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +872 -872
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +491 -491
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +488 -488
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +526 -526
- package/src/svgrid.async-editor-options.test.ts +271 -271
- package/src/svgrid.auto-row-height.test.ts +204 -204
- package/src/svgrid.behavior.test.ts +705 -705
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.comments-autocomplete.test.ts +123 -123
- 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 +322 -322
- package/src/svgrid.filter-menu-scroll.test.ts +112 -112
- package/src/svgrid.footer-pagesize.svelte.test.ts +86 -0
- 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 +40 -40
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +257 -257
- package/src/toast-store.svelte.ts +246 -246
- 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 +296 -296
package/dist/SvGridChart.svelte
CHANGED
|
@@ -1,1716 +1,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 { buildChart, DEFAULT_PALETTE, formatChartValue, type ChartSpec, type ChartSelection } from './chart'
|
|
11
|
-
|
|
12
|
-
type Props = {
|
|
13
|
-
spec: ChartSpec
|
|
14
|
-
/** Show the (clickable) legend. Default true. */
|
|
15
|
-
legend?: boolean
|
|
16
|
-
/** Enable tooltips + crosshair + legend toggling. Default true. */
|
|
17
|
-
interactive?: boolean
|
|
18
|
-
/** Draw the value on each bar / point / slice. Default false. */
|
|
19
|
-
dataLabels?: boolean
|
|
20
|
-
/** Format a value for tooltips, data labels, AND Y-axis ticks. */
|
|
21
|
-
formatValue?: (value: number) => string
|
|
22
|
-
/** Fired when a category / slice is clicked (drill into the grid). */
|
|
23
|
-
onSelect?: (selection: ChartSelection) => void
|
|
24
|
-
/** Like `onSelect`, but only fires when the spec carries `rowIds` and
|
|
25
|
-
* includes them in the payload. Use this to filter / highlight the
|
|
26
|
-
* source grid when the user clicks a chart element. */
|
|
27
|
-
onDrill?: (selection: ChartSelection) => void
|
|
28
|
-
/** Allow drag-to-zoom on the plot area. Double-click resets.
|
|
29
|
-
* Only meaningful for cartesian charts (not pie). Default false. */
|
|
30
|
-
zoomable?: boolean
|
|
31
|
-
/** Show a compact brush / mini-map under the main chart. A draggable
|
|
32
|
-
* window selects what the main chart shows. Pairs naturally with
|
|
33
|
-
* `zoomable`. Default false. */
|
|
34
|
-
brush?: boolean
|
|
35
|
-
/** Height of the brush strip in pixels. Default 88. */
|
|
36
|
-
brushHeight?: number
|
|
37
|
-
/** Show a small toolbar above the chart: reset-zoom (when zoomed) and
|
|
38
|
-
* export (PNG / SVG / copy). Default true when zoomable or onDrill is
|
|
39
|
-
* set, otherwise false. Set to `false` to hide explicitly. */
|
|
40
|
-
toolbar?: boolean
|
|
41
|
-
/** Explicit chart pixel size (viewBox). When set, the chart lays out to
|
|
42
|
-
* fit exactly - used by the docked panel to size the chart to its body. */
|
|
43
|
-
width?: number
|
|
44
|
-
height?: number
|
|
45
|
-
}
|
|
46
|
-
let {
|
|
47
|
-
spec,
|
|
48
|
-
legend = true,
|
|
49
|
-
interactive = true,
|
|
50
|
-
dataLabels = false,
|
|
51
|
-
formatValue,
|
|
52
|
-
onSelect,
|
|
53
|
-
onDrill,
|
|
54
|
-
zoomable = false,
|
|
55
|
-
brush = false,
|
|
56
|
-
brushHeight = 88,
|
|
57
|
-
toolbar,
|
|
58
|
-
width,
|
|
59
|
-
height,
|
|
60
|
-
}: Props = $props()
|
|
61
|
-
const showToolbar = $derived(toolbar ?? (zoomable || !!onDrill))
|
|
62
|
-
|
|
63
|
-
const fmt = (v: number) =>
|
|
64
|
-
formatValue
|
|
65
|
-
? formatValue(v)
|
|
66
|
-
: spec.valueFormat
|
|
67
|
-
? formatChartValue(v, spec.valueFormat)
|
|
68
|
-
: Number.isFinite(v)
|
|
69
|
-
? v.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
70
|
-
: String(v)
|
|
71
|
-
|
|
72
|
-
let hidden = $state(new Set<string>())
|
|
73
|
-
// Isolate: double-clicking a legend chip shows ONLY that series/slice.
|
|
74
|
-
let isolated = $state<string | null>(null)
|
|
75
|
-
// Hover-dim: hovering a legend chip dims the other series (visual only).
|
|
76
|
-
let dimmed = $state<string | null>(null)
|
|
77
|
-
const palette = $derived(spec.palette ?? DEFAULT_PALETTE)
|
|
78
|
-
const uid = `svgc-${Math.random().toString(36).slice(2, 8)}`
|
|
79
|
-
|
|
80
|
-
const coloredSeries = $derived(
|
|
81
|
-
spec.series.map((s, i) => ({ ...s, color: s.color ?? palette[i % palette.length]! })),
|
|
82
|
-
)
|
|
83
|
-
/** Resolve effective pattern per series: explicit > cycle (when
|
|
84
|
-
* patternFallback is on) > 'solid'. Cycle skips 'solid' so every series
|
|
85
|
-
* ends up with a distinct texture. */
|
|
86
|
-
const seriesPatterns = $derived(
|
|
87
|
-
spec.series.map((s, i): string => {
|
|
88
|
-
if (s.pattern) return s.pattern
|
|
89
|
-
if (spec.patternFallback) return ['stripe', 'crosshatch', 'dots', 'diagonal'][i % 4]!
|
|
90
|
-
return 'solid'
|
|
91
|
-
}),
|
|
92
|
-
)
|
|
93
|
-
/** Map from series label to fill string used in SVG (either the plain
|
|
94
|
-
* color or `url(#pattern-id)`). */
|
|
95
|
-
const seriesFill = $derived.by<Record<string, string>>(() => {
|
|
96
|
-
const out: Record<string, string> = {}
|
|
97
|
-
coloredSeries.forEach((s, i) => {
|
|
98
|
-
const p = seriesPatterns[i]
|
|
99
|
-
out[s.label] = p === 'solid' || !p ? s.color : `url(#${uid}-pat-${i})`
|
|
100
|
-
})
|
|
101
|
-
return out
|
|
102
|
-
})
|
|
103
|
-
/** Pattern <defs> to emit. Each entry pairs a unique id with the series
|
|
104
|
-
* color so the pattern carries the right hue baked in. */
|
|
105
|
-
const patternDefs = $derived(
|
|
106
|
-
coloredSeries
|
|
107
|
-
.map((s, i) => ({ id: `${uid}-pat-${i}`, color: s.color, kind: seriesPatterns[i] ?? 'solid' }))
|
|
108
|
-
.filter((d) => d.kind !== 'solid'),
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
// Effective hidden set = manual toggles, unless a series is isolated (then
|
|
112
|
-
// everything else is hidden).
|
|
113
|
-
const effectiveHidden = $derived.by(() => {
|
|
114
|
-
if (isolated == null) return hidden
|
|
115
|
-
const labels = spec.type === 'pie' ? spec.categories : spec.series.map((s) => s.label)
|
|
116
|
-
return new Set(labels.filter((l) => l !== isolated))
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
// ---- Zoom state ------------------------------------------------------
|
|
120
|
-
// `zoom` holds inclusive integer category indices [i0, i1] of the visible
|
|
121
|
-
// window. null = no zoom (show everything). Pie charts don't support
|
|
122
|
-
// zoom; their pseudo-categories are slices, not an axis.
|
|
123
|
-
let zoom = $state<{ i0: number; i1: number } | null>(null)
|
|
124
|
-
const isZoomed = $derived(zoom !== null)
|
|
125
|
-
function resetZoom() { zoom = null }
|
|
126
|
-
|
|
127
|
-
// Drag-to-zoom: track pointer in SVG-local coordinates so the overlay
|
|
128
|
-
// rect aligns with what the user is actually selecting on screen.
|
|
129
|
-
let svgEl: SVGSVGElement | null = $state(null)
|
|
130
|
-
let dragStart = $state<{ x: number; y: number } | null>(null)
|
|
131
|
-
let dragEnd = $state<{ x: number; y: number } | null>(null)
|
|
132
|
-
const dragRect = $derived.by(() => {
|
|
133
|
-
if (!dragStart || !dragEnd) return null
|
|
134
|
-
const x = Math.min(dragStart.x, dragEnd.x)
|
|
135
|
-
const y = Math.min(dragStart.y, dragEnd.y)
|
|
136
|
-
const w = Math.abs(dragEnd.x - dragStart.x)
|
|
137
|
-
const h = Math.abs(dragEnd.y - dragStart.y)
|
|
138
|
-
return w > 4 && h > 4 ? { x, y, w, h } : null
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
function svgPoint(e: PointerEvent): { x: number; y: number } | null {
|
|
142
|
-
if (!svgEl) return null
|
|
143
|
-
const r = svgEl.getBoundingClientRect()
|
|
144
|
-
return {
|
|
145
|
-
x: ((e.clientX - r.left) / r.width) * geo.width,
|
|
146
|
-
y: ((e.clientY - r.top) / r.height) * geo.height,
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
/** Clamp a point's main-axis coordinate to an integer category index. */
|
|
150
|
-
function pointToCatIndex(p: { x: number; y: number }): number {
|
|
151
|
-
const coord = isHorizontal ? (p.y - geo.plot.y) : (p.x - geo.plot.x)
|
|
152
|
-
const idx = Math.floor(coord / slot)
|
|
153
|
-
return Math.max(0, Math.min(visibleSpec.categories.length - 1, idx))
|
|
154
|
-
}
|
|
155
|
-
function onZoomDown(e: PointerEvent) {
|
|
156
|
-
if (!zoomable || !isCartesian) return
|
|
157
|
-
const p = svgPoint(e)
|
|
158
|
-
if (!p) return
|
|
159
|
-
// Only start a drag inside the plot rect.
|
|
160
|
-
if (p.x < geo.plot.x || p.x > geo.plot.x + geo.plot.w ||
|
|
161
|
-
p.y < geo.plot.y || p.y > geo.plot.y + geo.plot.h) return
|
|
162
|
-
dragStart = p
|
|
163
|
-
dragEnd = p
|
|
164
|
-
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
|
165
|
-
}
|
|
166
|
-
function onZoomMove(e: PointerEvent) {
|
|
167
|
-
if (!dragStart) return
|
|
168
|
-
dragEnd = svgPoint(e)
|
|
169
|
-
}
|
|
170
|
-
function onZoomUp() {
|
|
171
|
-
if (!dragStart || !dragEnd) { dragStart = null; dragEnd = null; return }
|
|
172
|
-
const r = dragRect
|
|
173
|
-
if (r) {
|
|
174
|
-
// Map the drag's main-axis bounds to category indices. When already
|
|
175
|
-
// zoomed, these indices are relative to the visible slice, so
|
|
176
|
-
// translate back to the original `spec.categories` indexing.
|
|
177
|
-
const i0v = pointToCatIndex({ x: r.x, y: r.y })
|
|
178
|
-
const i1v = pointToCatIndex({ x: r.x + r.w, y: r.y + r.h })
|
|
179
|
-
const offset = zoom ? zoom.i0 : 0
|
|
180
|
-
const lo = offset + Math.min(i0v, i1v)
|
|
181
|
-
const hi = offset + Math.max(i0v, i1v)
|
|
182
|
-
if (hi > lo) zoom = { i0: lo, i1: hi }
|
|
183
|
-
}
|
|
184
|
-
dragStart = null
|
|
185
|
-
dragEnd = null
|
|
186
|
-
}
|
|
187
|
-
function onPlotDblClick() {
|
|
188
|
-
if (zoomable && isZoomed) resetZoom()
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ---- Brush state -----------------------------------------------------
|
|
192
|
-
// The brush is a compact second chart showing the FULL data range with
|
|
193
|
-
// a translucent window over the visible slice. Dragging the body pans
|
|
194
|
-
// the window; dragging either edge resizes one side. Eligible for
|
|
195
|
-
// cartesian charts only (heatmap / pie don't have a single x-axis).
|
|
196
|
-
const brushEligible = $derived(brush && spec.type !== 'pie' && spec.type !== 'heatmap')
|
|
197
|
-
// Build a separate geometry from the un-zoomed spec at brushHeight.
|
|
198
|
-
const brushSpec = $derived<ChartSpec>({
|
|
199
|
-
...spec,
|
|
200
|
-
height: brushHeight,
|
|
201
|
-
// Mute axes in the brush by clearing titles + reference lines.
|
|
202
|
-
yAxisTitle: undefined, y2AxisTitle: undefined, xAxisTitle: undefined,
|
|
203
|
-
referenceLines: undefined,
|
|
204
|
-
annotations: undefined,
|
|
205
|
-
series: coloredSeries.filter((s) => !effectiveHidden.has(s.label)),
|
|
206
|
-
})
|
|
207
|
-
const brushGeo = $derived(brushEligible ? buildChart(brushSpec) : null)
|
|
208
|
-
/** The brush window in fractional [0..1] of the visible data range,
|
|
209
|
-
* derived from `zoom`. When not zoomed, the window covers everything. */
|
|
210
|
-
const brushWindow = $derived.by(() => {
|
|
211
|
-
const n = spec.categories.length
|
|
212
|
-
if (!zoom || n === 0) return { t0: 0, t1: 1 }
|
|
213
|
-
return { t0: zoom.i0 / n, t1: (zoom.i1 + 1) / n }
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
type BrushDrag ={ mode: 'move' | 'left' | 'right'; startX: number; startT0: number; startT1: number }
|
|
217
|
-
let brushDrag = $state<BrushDrag | null>(null)
|
|
218
|
-
function brushSvgX(e: PointerEvent, svg: SVGSVGElement): number {
|
|
219
|
-
const r = svg.getBoundingClientRect()
|
|
220
|
-
if (!brushGeo) return 0
|
|
221
|
-
return ((e.clientX - r.left) / r.width) * brushGeo.width
|
|
222
|
-
}
|
|
223
|
-
function onBrushDown(e: PointerEvent, mode: BrushDrag['mode']) {
|
|
224
|
-
if (!brushGeo) return
|
|
225
|
-
const svg = e.currentTarget as SVGSVGElement
|
|
226
|
-
const x = brushSvgX(e, svg.ownerSVGElement ?? svg)
|
|
227
|
-
brushDrag = { mode, startX: x, startT0: brushWindow.t0, startT1: brushWindow.t1 }
|
|
228
|
-
;(e.currentTarget as Element).setPointerCapture?.(e.pointerId)
|
|
229
|
-
e.stopPropagation()
|
|
230
|
-
}
|
|
231
|
-
function onBrushMove(e: PointerEvent) {
|
|
232
|
-
if (!brushDrag || !brushGeo) return
|
|
233
|
-
const svg = (e.currentTarget as SVGSVGElement).ownerSVGElement ?? (e.currentTarget as SVGSVGElement)
|
|
234
|
-
const x = brushSvgX(e, svg)
|
|
235
|
-
const dx = x - brushDrag.startX
|
|
236
|
-
const tw = brushGeo.plot.w
|
|
237
|
-
const dt = dx / tw
|
|
238
|
-
const n = spec.categories.length
|
|
239
|
-
if (n === 0) return
|
|
240
|
-
let t0 = brushDrag.startT0
|
|
241
|
-
let t1 = brushDrag.startT1
|
|
242
|
-
if (brushDrag.mode === 'move') {
|
|
243
|
-
const w = t1 - t0
|
|
244
|
-
t0 = Math.max(0, Math.min(1 - w, t0 + dt))
|
|
245
|
-
t1 = t0 + w
|
|
246
|
-
} else if (brushDrag.mode === 'left') {
|
|
247
|
-
// Keep at least 1 category visible.
|
|
248
|
-
t0 = Math.max(0, Math.min(t1 - 1 / n, t0 + dt))
|
|
249
|
-
} else {
|
|
250
|
-
t1 = Math.min(1, Math.max(t0 + 1 / n, t1 + dt))
|
|
251
|
-
}
|
|
252
|
-
// Translate back to integer category indices.
|
|
253
|
-
const i0 = Math.max(0, Math.floor(t0 * n))
|
|
254
|
-
const i1 = Math.min(n - 1, Math.ceil(t1 * n) - 1)
|
|
255
|
-
if (i0 === 0 && i1 === n - 1) zoom = null
|
|
256
|
-
else zoom = { i0, i1: Math.max(i0, i1) }
|
|
257
|
-
}
|
|
258
|
-
function onBrushUp(e: PointerEvent) {
|
|
259
|
-
brushDrag = null
|
|
260
|
-
;(e.currentTarget as Element).releasePointerCapture?.(e.pointerId)
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const visibleSpec = $derived.by<ChartSpec>(() => {
|
|
264
|
-
if (spec.type === 'pie') {
|
|
265
|
-
const s = coloredSeries[0]
|
|
266
|
-
if (!s) return spec
|
|
267
|
-
const values = s.values.map((v, i) => (effectiveHidden.has(spec.categories[i] ?? String(i)) ? 0 : v))
|
|
268
|
-
return { ...spec, series: [{ ...s, values }] }
|
|
269
|
-
}
|
|
270
|
-
const visibleSeries = coloredSeries.filter((s) => !effectiveHidden.has(s.label))
|
|
271
|
-
if (!zoom) return { ...spec, series: visibleSeries }
|
|
272
|
-
// Slice categories + each series' values to the zoom window so
|
|
273
|
-
// buildChart re-spreads the visible slice across the full plot.
|
|
274
|
-
const lo = Math.max(0, zoom.i0)
|
|
275
|
-
const hi = Math.min(spec.categories.length - 1, zoom.i1)
|
|
276
|
-
const slice = <T,>(arr: T[]) => arr.slice(lo, hi + 1)
|
|
277
|
-
return {
|
|
278
|
-
...spec,
|
|
279
|
-
categories: slice(spec.categories),
|
|
280
|
-
series: visibleSeries.map((s) => ({
|
|
281
|
-
...s,
|
|
282
|
-
values: slice(s.values),
|
|
283
|
-
rowIds: s.rowIds ? slice(s.rowIds) : undefined,
|
|
284
|
-
})),
|
|
285
|
-
}
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
// Heatmap / calendar cell fills are computed colors (not CSS), so they can't
|
|
289
|
-
// inherit the theme. We detect the surface luminance (see the $effect near
|
|
290
|
-
// chartEl) and hand buildChart a light/dark hint so low-value cells render as
|
|
291
|
-
// "cold" rather than glaring white rectangles on a dark grid.
|
|
292
|
-
let isDark = $state(false)
|
|
293
|
-
const geo = $derived(
|
|
294
|
-
buildChart(width || height ? { ...visibleSpec, width, height } : visibleSpec, isDark ? 'dark' : 'light'),
|
|
295
|
-
)
|
|
296
|
-
const isCartesian = $derived(
|
|
297
|
-
spec.type !== 'pie' && spec.type !== 'heatmap' &&
|
|
298
|
-
spec.type !== 'funnel' && spec.type !== 'radar' &&
|
|
299
|
-
spec.type !== 'calendar' && spec.type !== 'gauge' &&
|
|
300
|
-
spec.type !== 'treemap' && spec.type !== 'sankey',
|
|
301
|
-
)
|
|
302
|
-
const isScatter = $derived(spec.type === 'scatter')
|
|
303
|
-
const isHeatmap = $derived(spec.type === 'heatmap')
|
|
304
|
-
const isFunnel = $derived(spec.type === 'funnel')
|
|
305
|
-
const isRadar = $derived(spec.type === 'radar')
|
|
306
|
-
const isCalendar = $derived(spec.type === 'calendar')
|
|
307
|
-
const isGauge = $derived(spec.type === 'gauge')
|
|
308
|
-
const isTreemap = $derived(spec.type === 'treemap')
|
|
309
|
-
const isSankey = $derived(spec.type === 'sankey')
|
|
310
|
-
const isHorizontal = $derived(geo.orientation === 'horizontal')
|
|
311
|
-
// Per-category band size along the main (category) axis: width when vertical,
|
|
312
|
-
// height when horizontal.
|
|
313
|
-
const slot = $derived(
|
|
314
|
-
(isHorizontal ? geo.plot.h : geo.plot.w) / Math.max(1, visibleSpec.categories.length),
|
|
315
|
-
)
|
|
316
|
-
|
|
317
|
-
// Opacity for a series when another legend chip is being hovered.
|
|
318
|
-
const dimOf = (seriesLabel: string) => (dimmed && dimmed !== seriesLabel ? 0.18 : 1)
|
|
319
|
-
|
|
320
|
-
const legendItems = $derived(
|
|
321
|
-
spec.type === 'pie'
|
|
322
|
-
? spec.categories.map((label, i) => ({ label, color: palette[i % palette.length]!, off: hidden.has(label) }))
|
|
323
|
-
: coloredSeries.map((s) => ({ label: s.label, color: s.color, off: hidden.has(s.label) })),
|
|
324
|
-
)
|
|
325
|
-
// Legend overflow: collapse to the first N chips with a "+N more" toggle so a
|
|
326
|
-
// wide pivot (many series) doesn't flood the chart with legend rows.
|
|
327
|
-
const LEGEND_MAX = 10
|
|
328
|
-
let legendExpanded = $state(false)
|
|
329
|
-
const legendOverflow = $derived(legendItems.length > LEGEND_MAX)
|
|
330
|
-
const shownLegend = $derived(
|
|
331
|
-
legendOverflow && !legendExpanded ? legendItems.slice(0, LEGEND_MAX) : legendItems,
|
|
332
|
-
)
|
|
333
|
-
|
|
334
|
-
const isEmpty = $derived.by(() => {
|
|
335
|
-
if (spec.type === 'pie') return geo.slices.every((s) => s.value <= 0)
|
|
336
|
-
if (spec.type === 'scatter') return geo.scatterPoints.length === 0
|
|
337
|
-
if (spec.type === 'heatmap') return geo.heatmapCells.length === 0
|
|
338
|
-
if (spec.type === 'funnel') return geo.funnelSegments.length === 0
|
|
339
|
-
if (spec.type === 'radar') return geo.radarSeries.length === 0
|
|
340
|
-
if (spec.type === 'calendar') return geo.calendarCells.length === 0
|
|
341
|
-
if (spec.type === 'gauge') return !geo.gauge
|
|
342
|
-
if (spec.type === 'treemap') return geo.treemapCells.length === 0
|
|
343
|
-
if (spec.type === 'sankey') return geo.sankeyNodes.length === 0
|
|
344
|
-
return geo.bars.length === 0 && geo.lines.every((l) => l.points.every((p) => !p.defined))
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
function toggle(label: string) {
|
|
348
|
-
if (!interactive) return
|
|
349
|
-
if (isolated != null) { isolated = null; return }
|
|
350
|
-
const next = new Set(hidden)
|
|
351
|
-
if (next.has(label)) next.delete(label)
|
|
352
|
-
else next.add(label)
|
|
353
|
-
hidden = next
|
|
354
|
-
}
|
|
355
|
-
// Double-click a legend chip to isolate it (show only that one); double-click
|
|
356
|
-
// again (or the same chip) to clear the isolation.
|
|
357
|
-
function isolate(label: string) {
|
|
358
|
-
if (!interactive) return
|
|
359
|
-
isolated = isolated === label ? null : label
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ---- Screen-reader data table -----------------------------------------
|
|
363
|
-
// A visually-hidden table that conveys the same data to assistive tech.
|
|
364
|
-
const srTable = $derived.by(() => {
|
|
365
|
-
if (spec.type === 'pie') {
|
|
366
|
-
const s = coloredSeries[0]
|
|
367
|
-
return {
|
|
368
|
-
cols: ['Category', 'Value'],
|
|
369
|
-
rows: spec.categories.map((c, i) => [c, fmt(s?.values[i] ?? 0)]),
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
if (isScatter) {
|
|
373
|
-
const rows: string[][] = []
|
|
374
|
-
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) : ''])
|
|
375
|
-
return { cols: ['Series', 'X', 'Y', 'Size'], rows }
|
|
376
|
-
}
|
|
377
|
-
return {
|
|
378
|
-
cols: ['Category', ...coloredSeries.map((s) => s.label)],
|
|
379
|
-
rows: spec.categories.map((c, i) => [c, ...coloredSeries.map((s) => fmt(s.values[i] ?? 0))]),
|
|
380
|
-
}
|
|
381
|
-
})
|
|
382
|
-
function truncate(s: string, n = 12): string {
|
|
383
|
-
return s.length > n ? s.slice(0, n - 1) + '…' : s
|
|
384
|
-
}
|
|
385
|
-
const yTickLabel = (value: number, fallback: string) => (formatValue ? formatValue(value) : fallback)
|
|
386
|
-
|
|
387
|
-
// ---- Tooltip + crosshair ----------------------------------------------
|
|
388
|
-
let chartEl: HTMLElement | null = $state(null)
|
|
389
|
-
|
|
390
|
-
// Resolve whether the chart sits on a dark surface by reading the --sg-bg
|
|
391
|
-
// token's luminance. Re-checks on theme toggles (data-theme / class / style
|
|
392
|
-
// changes on the document root) so the heatmap ramp follows light<->dark.
|
|
393
|
-
function surfaceIsDark(color: string): boolean {
|
|
394
|
-
if (!color) return false
|
|
395
|
-
let r = 0, g = 0, b = 0
|
|
396
|
-
const hex = color.trim().startsWith('#') ? color.trim().slice(1) : ''
|
|
397
|
-
if (hex.length === 6) {
|
|
398
|
-
r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16)
|
|
399
|
-
} else {
|
|
400
|
-
const m = /rgba?\(([^)]+)\)/.exec(color)
|
|
401
|
-
if (!m) return false
|
|
402
|
-
const p = m[1]!.split(',').map((s) => parseFloat(s))
|
|
403
|
-
r = p[0] ?? 0; g = p[1] ?? 0; b = p[2] ?? 0
|
|
404
|
-
}
|
|
405
|
-
// Perceived luminance (0..255); below the midpoint reads as a dark surface.
|
|
406
|
-
return 0.299 * r + 0.587 * g + 0.114 * b < 128
|
|
407
|
-
}
|
|
408
|
-
$effect(() => {
|
|
409
|
-
if (!chartEl || typeof window === 'undefined') return
|
|
410
|
-
const el = chartEl
|
|
411
|
-
const check = () => { isDark = surfaceIsDark(getComputedStyle(el).getPropertyValue('--sg-bg')) }
|
|
412
|
-
check()
|
|
413
|
-
const obs = new MutationObserver(check)
|
|
414
|
-
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] })
|
|
415
|
-
return () => obs.disconnect()
|
|
416
|
-
})
|
|
417
|
-
type TipRow = { label?: string; color?: string; value: string }
|
|
418
|
-
let tip = $state<{ left: number; top: number; below: boolean; title: string; rows: TipRow[] } | null>(null)
|
|
419
|
-
let activeCat = $state<number | null>(null)
|
|
420
|
-
|
|
421
|
-
function showTip(clientX: number, clientY: number, title: string, rows: TipRow[]) {
|
|
422
|
-
if (!interactive || !chartEl || rows.length === 0) return
|
|
423
|
-
const r = chartEl.getBoundingClientRect()
|
|
424
|
-
const left = Math.max(70, Math.min(r.width - 70, clientX - r.left))
|
|
425
|
-
const top = clientY - r.top
|
|
426
|
-
tip = { left, top, below: top < 44, title, rows }
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Unified tooltip: every visible series' value at one category.
|
|
430
|
-
function catRows(i: number): TipRow[] {
|
|
431
|
-
return visibleSpec.series
|
|
432
|
-
.map((s) => ({ label: s.label, color: (s as { color?: string }).color, value: s.values[i] }))
|
|
433
|
-
.filter((r) => Number.isFinite(r.value))
|
|
434
|
-
.map((r) => ({ label: r.label, color: r.color, value: fmt(r.value as number) }))
|
|
435
|
-
}
|
|
436
|
-
function hoverCat(clientX: number, clientY: number, i: number) {
|
|
437
|
-
activeCat = i
|
|
438
|
-
showTip(clientX, clientY, visibleSpec.categories[i] ?? '', catRows(i))
|
|
439
|
-
}
|
|
440
|
-
function focusCat(el: Element, i: number) {
|
|
441
|
-
const b = el.getBoundingClientRect()
|
|
442
|
-
hoverCat(b.left + b.width / 2, b.top + 12, i)
|
|
443
|
-
}
|
|
444
|
-
function clearActive() {
|
|
445
|
-
activeCat = null
|
|
446
|
-
tip = null
|
|
447
|
-
}
|
|
448
|
-
function hoverSlice(clientX: number, clientY: number, label: string, value: number, pct: number) {
|
|
449
|
-
showTip(clientX, clientY, label, [{ value: `${fmt(value)} · ${pct.toFixed(1)}%` }])
|
|
450
|
-
}
|
|
451
|
-
function hoverDot(clientX: number, clientY: number, d: (typeof geo.scatterPoints)[number]) {
|
|
452
|
-
const rows: TipRow[] = [
|
|
453
|
-
{ label: 'x', value: fmt(d.x) },
|
|
454
|
-
{ label: 'y', value: fmt(d.y) },
|
|
455
|
-
]
|
|
456
|
-
showTip(clientX, clientY, d.label || d.series, rows)
|
|
457
|
-
}
|
|
458
|
-
function hoverCell(clientX: number, clientY: number, cell: (typeof geo.heatmapCells)[number]) {
|
|
459
|
-
showTip(clientX, clientY, `${cell.rowLabel} · ${cell.colLabel}`, [
|
|
460
|
-
{ color: cell.color, value: fmt(cell.value) },
|
|
461
|
-
])
|
|
462
|
-
}
|
|
463
|
-
function hoverFunnel(clientX: number, clientY: number, seg: (typeof geo.funnelSegments)[number]) {
|
|
464
|
-
const rows: TipRow[] = [
|
|
465
|
-
{ value: fmt(seg.value) },
|
|
466
|
-
{ label: 'conversion', value: `${(seg.conversion * 100).toFixed(1)}%` },
|
|
467
|
-
]
|
|
468
|
-
if (seg.dropoff > 0) rows.push({ label: 'drop-off', value: `${(seg.dropoff * 100).toFixed(1)}%` })
|
|
469
|
-
showTip(clientX, clientY, seg.label, rows)
|
|
470
|
-
}
|
|
471
|
-
function hoverRadarPoint(clientX: number, clientY: number, label: string, axis: string, value: number, color: string) {
|
|
472
|
-
showTip(clientX, clientY, `${label} · ${axis}`, [{ color, value: fmt(value) }])
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function select(category: string, series: string, value: number, catIndex?: number) {
|
|
476
|
-
// Collect contributing row ids when the spec carries them. For a
|
|
477
|
-
// category click (series === ''), flatten across every series; for a
|
|
478
|
-
// single-series click, return just that series' bucket.
|
|
479
|
-
let rowIds: Array<string | number> | undefined
|
|
480
|
-
if (catIndex !== undefined) {
|
|
481
|
-
const buckets: Array<Array<string | number>> = []
|
|
482
|
-
for (const s of visibleSpec.series) {
|
|
483
|
-
if (!s.rowIds) continue
|
|
484
|
-
if (series && s.label !== series) continue
|
|
485
|
-
const ids = s.rowIds[catIndex]
|
|
486
|
-
if (ids?.length) buckets.push(ids)
|
|
487
|
-
}
|
|
488
|
-
if (buckets.length) rowIds = buckets.flat()
|
|
489
|
-
}
|
|
490
|
-
const sel: ChartSelection = { category, series, value, rowIds }
|
|
491
|
-
onSelect?.(sel)
|
|
492
|
-
if (rowIds && rowIds.length) onDrill?.(sel)
|
|
493
|
-
}
|
|
494
|
-
/** Move keyboard focus to the cat-hit rect at index `next`, snapping
|
|
495
|
-
* back to the source element via the same `data-cat-index` attribute. */
|
|
496
|
-
function focusCatIndex(source: Element, next: number) {
|
|
497
|
-
const root = (source as SVGElement).ownerSVGElement ?? source.closest('svg')
|
|
498
|
-
if (!root) return
|
|
499
|
-
const target = root.querySelector<SVGElement>(`[data-cat-index="${next}"]`)
|
|
500
|
-
if (!target) return
|
|
501
|
-
target.focus?.()
|
|
502
|
-
// Mirror the tooltip / crosshair to the new focused category.
|
|
503
|
-
focusCat(target, next)
|
|
504
|
-
}
|
|
505
|
-
function onCatKey(e: KeyboardEvent, i: number) {
|
|
506
|
-
const max = visibleSpec.categories.length - 1
|
|
507
|
-
const src = e.currentTarget as Element
|
|
508
|
-
// Arrow keys + Home / End navigate; Enter / Space select. PageUp/Down
|
|
509
|
-
// jump a tenth of the range at a time for long datasets.
|
|
510
|
-
switch (e.key) {
|
|
511
|
-
case 'ArrowRight':
|
|
512
|
-
case 'ArrowDown':
|
|
513
|
-
e.preventDefault(); focusCatIndex(src, Math.min(max, i + 1)); return
|
|
514
|
-
case 'ArrowLeft':
|
|
515
|
-
case 'ArrowUp':
|
|
516
|
-
e.preventDefault(); focusCatIndex(src, Math.max(0, i - 1)); return
|
|
517
|
-
case 'Home':
|
|
518
|
-
e.preventDefault(); focusCatIndex(src, 0); return
|
|
519
|
-
case 'End':
|
|
520
|
-
e.preventDefault(); focusCatIndex(src, max); return
|
|
521
|
-
case 'PageDown':
|
|
522
|
-
e.preventDefault(); focusCatIndex(src, Math.min(max, i + Math.max(1, Math.floor(max / 10)))); return
|
|
523
|
-
case 'PageUp':
|
|
524
|
-
e.preventDefault(); focusCatIndex(src, Math.max(0, i - Math.max(1, Math.floor(max / 10)))); return
|
|
525
|
-
case 'Enter':
|
|
526
|
-
case ' ':
|
|
527
|
-
if (!onSelect) return
|
|
528
|
-
e.preventDefault()
|
|
529
|
-
select(visibleSpec.categories[i] ?? '', '', 0, i)
|
|
530
|
-
return
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
function onSliceKey(e: KeyboardEvent, label: string, value: number) {
|
|
534
|
-
if (!onSelect) return
|
|
535
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
536
|
-
e.preventDefault()
|
|
537
|
-
select(label, label, value)
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// ---- Export ----------------------------------------------------------
|
|
542
|
-
// PNG / SVG download + copy-as-image. Serialize the live SVG element so
|
|
543
|
-
// the exported file matches the user's current zoom / visibility state.
|
|
544
|
-
let copyStatus = $state<'idle' | 'ok' | 'err'>('idle')
|
|
545
|
-
function serializeSvg(): string | null {
|
|
546
|
-
if (!svgEl) return null
|
|
547
|
-
const clone = svgEl.cloneNode(true) as SVGSVGElement
|
|
548
|
-
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
549
|
-
// Drop our interaction-only classes from the export.
|
|
550
|
-
clone.removeAttribute('class')
|
|
551
|
-
// Inline the computed sizes so the standalone SVG renders at the
|
|
552
|
-
// same pixel dimensions when opened directly.
|
|
553
|
-
const rect = svgEl.getBoundingClientRect()
|
|
554
|
-
if (rect.width) clone.setAttribute('width', String(Math.round(rect.width)))
|
|
555
|
-
if (rect.height) clone.setAttribute('height', String(Math.round(rect.height)))
|
|
556
|
-
// Pull the chart's stylesheet so the standalone SVG is self-contained.
|
|
557
|
-
const styleTag = document.createElement('style')
|
|
558
|
-
const sheets = Array.from(document.styleSheets)
|
|
559
|
-
const rules: string[] = []
|
|
560
|
-
for (const sheet of sheets) {
|
|
561
|
-
try {
|
|
562
|
-
for (const rule of Array.from(sheet.cssRules ?? [])) {
|
|
563
|
-
const t = rule.cssText
|
|
564
|
-
if (t.includes('.sv-grid-chart')) rules.push(t)
|
|
565
|
-
}
|
|
566
|
-
} catch { /* cross-origin sheets - skip */ }
|
|
567
|
-
}
|
|
568
|
-
styleTag.textContent = rules.join('\n')
|
|
569
|
-
clone.insertBefore(styleTag, clone.firstChild)
|
|
570
|
-
return new XMLSerializer().serializeToString(clone)
|
|
571
|
-
}
|
|
572
|
-
function triggerDownload(blob: Blob, filename: string) {
|
|
573
|
-
const url = URL.createObjectURL(blob)
|
|
574
|
-
const a = document.createElement('a')
|
|
575
|
-
a.href = url
|
|
576
|
-
a.download = filename
|
|
577
|
-
document.body.appendChild(a)
|
|
578
|
-
a.click()
|
|
579
|
-
a.remove()
|
|
580
|
-
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
581
|
-
}
|
|
582
|
-
async function rasterizeSvg(svgText: string): Promise<Blob | null> {
|
|
583
|
-
return new Promise((resolve) => {
|
|
584
|
-
const rect = svgEl?.getBoundingClientRect()
|
|
585
|
-
const w = Math.max(1, Math.round(rect?.width ?? geo.width))
|
|
586
|
-
const h = Math.max(1, Math.round(rect?.height ?? geo.height))
|
|
587
|
-
const dpr = window.devicePixelRatio || 1
|
|
588
|
-
const canvas = document.createElement('canvas')
|
|
589
|
-
canvas.width = w * dpr
|
|
590
|
-
canvas.height = h * dpr
|
|
591
|
-
const ctx = canvas.getContext('2d')
|
|
592
|
-
if (!ctx) { resolve(null); return }
|
|
593
|
-
const img = new Image()
|
|
594
|
-
img.onload = () => {
|
|
595
|
-
ctx.scale(dpr, dpr)
|
|
596
|
-
ctx.drawImage(img, 0, 0, w, h)
|
|
597
|
-
canvas.toBlob((b) => resolve(b), 'image/png')
|
|
598
|
-
}
|
|
599
|
-
img.onerror = () => resolve(null)
|
|
600
|
-
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText)
|
|
601
|
-
})
|
|
602
|
-
}
|
|
603
|
-
async function downloadImage(format: 'png' | 'svg') {
|
|
604
|
-
const text = serializeSvg()
|
|
605
|
-
if (!text) return
|
|
606
|
-
const stamp = new Date().toISOString().slice(0, 10)
|
|
607
|
-
if (format === 'svg') {
|
|
608
|
-
triggerDownload(new Blob([text], { type: 'image/svg+xml' }), `chart-${stamp}.svg`)
|
|
609
|
-
return
|
|
610
|
-
}
|
|
611
|
-
const png = await rasterizeSvg(text)
|
|
612
|
-
if (png) triggerDownload(png, `chart-${stamp}.png`)
|
|
613
|
-
}
|
|
614
|
-
async function copyAsImage() {
|
|
615
|
-
const text = serializeSvg()
|
|
616
|
-
if (!text) { copyStatus = 'err'; return }
|
|
617
|
-
try {
|
|
618
|
-
const png = await rasterizeSvg(text)
|
|
619
|
-
if (!png) throw new Error('rasterize failed')
|
|
620
|
-
const Clip = (window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
|
|
621
|
-
if (Clip && navigator.clipboard?.write) {
|
|
622
|
-
await navigator.clipboard.write([new Clip({ 'image/png': png })])
|
|
623
|
-
} else {
|
|
624
|
-
// Fallback: copy SVG markup as text so the user can paste somewhere.
|
|
625
|
-
await navigator.clipboard.writeText(text)
|
|
626
|
-
}
|
|
627
|
-
copyStatus = 'ok'
|
|
628
|
-
} catch {
|
|
629
|
-
copyStatus = 'err'
|
|
630
|
-
}
|
|
631
|
-
setTimeout(() => (copyStatus = 'idle'), 1500)
|
|
632
|
-
}
|
|
633
|
-
</script>
|
|
634
|
-
|
|
635
|
-
<div class="sv-grid-chart" bind:this={chartEl}>
|
|
636
|
-
{#if showToolbar}
|
|
637
|
-
<div class="sv-grid-chart-toolbar">
|
|
638
|
-
{#if zoomable && isZoomed}
|
|
639
|
-
<button type="button" class="sv-grid-chart-tool" onclick={resetZoom} title="Reset zoom (or double-click the plot)">
|
|
640
|
-
<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>
|
|
641
|
-
Reset zoom
|
|
642
|
-
</button>
|
|
643
|
-
{/if}
|
|
644
|
-
<button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('png')} title="Download PNG">PNG</button>
|
|
645
|
-
<button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('svg')} title="Download SVG">SVG</button>
|
|
646
|
-
<button type="button" class="sv-grid-chart-tool" onclick={copyAsImage} title="Copy chart as image">
|
|
647
|
-
{copyStatus === 'ok' ? '✓ Copied' : 'Copy'}
|
|
648
|
-
</button>
|
|
649
|
-
</div>
|
|
650
|
-
{/if}
|
|
651
|
-
<svg
|
|
652
|
-
bind:this={svgEl}
|
|
653
|
-
class="sv-grid-chart-svg"
|
|
654
|
-
class:is-interactive={interactive}
|
|
655
|
-
class:is-clickable={!!onSelect || !!onDrill}
|
|
656
|
-
class:is-zoomable={zoomable && isCartesian}
|
|
657
|
-
class:is-dragging={!!dragStart}
|
|
658
|
-
viewBox={`0 0 ${geo.width} ${geo.height}`}
|
|
659
|
-
width="100%"
|
|
660
|
-
role="img"
|
|
661
|
-
aria-label={`${spec.type} chart`}
|
|
662
|
-
aria-describedby={`${uid}-table`}
|
|
663
|
-
onpointerdown={onZoomDown}
|
|
664
|
-
onpointermove={onZoomMove}
|
|
665
|
-
onpointerup={onZoomUp}
|
|
666
|
-
onpointercancel={onZoomUp}
|
|
667
|
-
ondblclick={onPlotDblClick}
|
|
668
|
-
>
|
|
669
|
-
<!-- Pattern defs: emitted once per series when patternFallback is on
|
|
670
|
-
or a series sets `pattern`. Color is baked into the pattern so
|
|
671
|
-
each series gets a hue-tinted texture without dynamic CSS. -->
|
|
672
|
-
{#if patternDefs.length}
|
|
673
|
-
<defs>
|
|
674
|
-
{#each patternDefs as p (p.id)}
|
|
675
|
-
{#if p.kind === 'stripe'}
|
|
676
|
-
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
677
|
-
<rect width="6" height="6" fill={p.color} />
|
|
678
|
-
<rect width="3" height="6" fill="rgba(255,255,255,0.35)" />
|
|
679
|
-
</pattern>
|
|
680
|
-
{:else if p.kind === 'crosshatch'}
|
|
681
|
-
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse">
|
|
682
|
-
<rect width="6" height="6" fill={p.color} />
|
|
683
|
-
<path d="M0 0L6 6 M6 0L0 6" stroke="rgba(255,255,255,0.45)" stroke-width="1" />
|
|
684
|
-
</pattern>
|
|
685
|
-
{:else if p.kind === 'dots'}
|
|
686
|
-
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse">
|
|
687
|
-
<rect width="6" height="6" fill={p.color} />
|
|
688
|
-
<circle cx="3" cy="3" r="1.2" fill="rgba(255,255,255,0.55)" />
|
|
689
|
-
</pattern>
|
|
690
|
-
{:else if p.kind === 'diagonal'}
|
|
691
|
-
<pattern id={p.id} width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)">
|
|
692
|
-
<rect width="8" height="8" fill={p.color} />
|
|
693
|
-
<line x1="0" y1="0" x2="0" y2="8" stroke="rgba(0,0,0,0.18)" stroke-width="2" />
|
|
694
|
-
</pattern>
|
|
695
|
-
{/if}
|
|
696
|
-
{/each}
|
|
697
|
-
</defs>
|
|
698
|
-
{/if}
|
|
699
|
-
{#if isHorizontal}
|
|
700
|
-
<!-- Value axis (vertical gridlines + bottom labels) -->
|
|
701
|
-
{#each geo.valueTicks as t (t.label + t.x)}
|
|
702
|
-
<line class="sv-grid-chart-gridline" x1={t.x} y1={geo.plot.y} x2={t.x} y2={geo.plot.y + geo.plot.h} />
|
|
703
|
-
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 14} text-anchor="middle">{t.label}</text>
|
|
704
|
-
{/each}
|
|
705
|
-
<!-- Category labels down the left -->
|
|
706
|
-
{#each geo.catTicks as t (t.value)}
|
|
707
|
-
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{truncate(t.label, 18)}</text>
|
|
708
|
-
{/each}
|
|
709
|
-
{#if spec.yAxisTitle}
|
|
710
|
-
<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>
|
|
711
|
-
{/if}
|
|
712
|
-
{#if spec.xAxisTitle}
|
|
713
|
-
<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>
|
|
714
|
-
{/if}
|
|
715
|
-
{:else if isCartesian}
|
|
716
|
-
{#each geo.yTicks as t (t.value)}
|
|
717
|
-
<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} />
|
|
718
|
-
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{yTickLabel(t.value, t.label)}</text>
|
|
719
|
-
{/each}
|
|
720
|
-
{#if geo.hasRightAxis}
|
|
721
|
-
{#each geo.y2Ticks as t (t.value)}
|
|
722
|
-
<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>
|
|
723
|
-
{/each}
|
|
724
|
-
{/if}
|
|
725
|
-
{#each geo.xTicks as t (t.label + t.x)}
|
|
726
|
-
{#if geo.xLabelRotated}
|
|
727
|
-
<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>
|
|
728
|
-
{:else}
|
|
729
|
-
<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>
|
|
730
|
-
{/if}
|
|
731
|
-
{/each}
|
|
732
|
-
<!-- Grouped (nested) category axis: parent tier under the leaf labels. -->
|
|
733
|
-
{#each geo.categoryGroupTicks as g (g.label + g.x0)}
|
|
734
|
-
{@const gy = geo.plot.y + geo.plot.h + (geo.xLabelRotated ? 46 : 32)}
|
|
735
|
-
<line class="sv-grid-chart-gridline" x1={g.x0 + 3} y1={gy - 9} x2={g.x1 - 3} y2={gy - 9} />
|
|
736
|
-
{#if g.x0 > geo.plot.x + 1}
|
|
737
|
-
<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" />
|
|
738
|
-
{/if}
|
|
739
|
-
<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>
|
|
740
|
-
{/each}
|
|
741
|
-
{#if spec.yAxisTitle}
|
|
742
|
-
<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>
|
|
743
|
-
{/if}
|
|
744
|
-
{#if spec.y2AxisTitle}
|
|
745
|
-
<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>
|
|
746
|
-
{/if}
|
|
747
|
-
{#if spec.xAxisTitle}
|
|
748
|
-
<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>
|
|
749
|
-
{/if}
|
|
750
|
-
{/if}
|
|
751
|
-
|
|
752
|
-
{#each geo.lines as line, li (line.label + li)}
|
|
753
|
-
<g style={`opacity:${dimOf(line.label)}`}>
|
|
754
|
-
{#if line.bandPath}
|
|
755
|
-
<!-- Confidence band: shaded envelope between upperValues / lowerValues. -->
|
|
756
|
-
<path class="sv-grid-chart-band" d={line.bandPath} fill={line.color} fill-opacity="0.12" stroke="none" />
|
|
757
|
-
{/if}
|
|
758
|
-
{#if line.areaPath}
|
|
759
|
-
<path class="sv-grid-chart-area" d={line.areaPath} fill={seriesFill[line.label] ?? line.color} fill-opacity="0.15" stroke="none" />
|
|
760
|
-
{/if}
|
|
761
|
-
<path class="sv-grid-chart-linepath" d={line.path} fill="none" stroke={line.color} stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
|
762
|
-
{#each line.points as pt, pi (pi)}
|
|
763
|
-
{#if pt.defined}
|
|
764
|
-
<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} />
|
|
765
|
-
{#if dataLabels}
|
|
766
|
-
<text class="sv-grid-chart-datalabel" x={pt.x} y={pt.y - 7} text-anchor="middle">{fmt(pt.value)}</text>
|
|
767
|
-
{/if}
|
|
768
|
-
{/if}
|
|
769
|
-
{/each}
|
|
770
|
-
</g>
|
|
771
|
-
{/each}
|
|
772
|
-
|
|
773
|
-
{#each geo.bars as bar, bi (bi)}
|
|
774
|
-
<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)}`} />
|
|
775
|
-
{#if dataLabels && isHorizontal && bar.w > 18}
|
|
776
|
-
<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>
|
|
777
|
-
{:else if dataLabels && !isHorizontal && bar.h > 13}
|
|
778
|
-
<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>
|
|
779
|
-
{/if}
|
|
780
|
-
{/each}
|
|
781
|
-
|
|
782
|
-
{#each geo.scatterPoints as dot, di (di)}
|
|
783
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
784
|
-
<circle
|
|
785
|
-
class="sv-grid-chart-scatter"
|
|
786
|
-
cx={dot.cx}
|
|
787
|
-
cy={dot.cy}
|
|
788
|
-
r={dot.r}
|
|
789
|
-
fill={dot.color}
|
|
790
|
-
fill-opacity="0.7"
|
|
791
|
-
stroke={dot.color}
|
|
792
|
-
style={`opacity:${dimOf(dot.series)}`}
|
|
793
|
-
onmousemove={(e) => hoverDot(e.clientX, e.clientY, dot)}
|
|
794
|
-
onmouseleave={clearActive}
|
|
795
|
-
/>
|
|
796
|
-
{/each}
|
|
797
|
-
|
|
798
|
-
<!-- Reference / target lines paint on top so they read over the bars/areas. -->
|
|
799
|
-
{#each geo.referenceLines as ref, ri (ri)}
|
|
800
|
-
<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} />
|
|
801
|
-
<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>
|
|
802
|
-
{/each}
|
|
803
|
-
{#each geo.referenceLinesV as ref, ri (ri)}
|
|
804
|
-
<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} />
|
|
805
|
-
<text class="sv-grid-chart-reflabel" x={ref.x + 3} y={geo.plot.y + 9} text-anchor="start" fill={ref.color}>{ref.label}</text>
|
|
806
|
-
{/each}
|
|
807
|
-
|
|
808
|
-
<!-- Trend / moving-average overlays. Dashed so they read distinctly
|
|
809
|
-
from the source series, drawn on top of bars + lines. -->
|
|
810
|
-
{#each geo.overlays as ovl, oi (ovl.label + oi)}
|
|
811
|
-
<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" />
|
|
812
|
-
{/each}
|
|
813
|
-
|
|
814
|
-
<!-- Pinned annotations: small marker + label. Placement nudges the
|
|
815
|
-
label position so it sits clear of the data point. -->
|
|
816
|
-
{#each geo.annotations as ann, ai (ann.label + ai)}
|
|
817
|
-
{@const dx = ann.placement === 'left' ? -8 : ann.placement === 'right' ? 8 : 0}
|
|
818
|
-
{@const dy = ann.placement === 'bottom' ? 14 : ann.placement === 'top' ? -8 : 0}
|
|
819
|
-
{@const anchor = ann.placement === 'left' ? 'end' : ann.placement === 'right' ? 'start' : 'middle'}
|
|
820
|
-
<circle class="sv-grid-chart-annotation-marker" cx={ann.x} cy={ann.y} r="4" fill={ann.color} stroke="var(--sg-bg, #fff)" stroke-width="1.5" />
|
|
821
|
-
<text class="sv-grid-chart-annotation-label" x={ann.x + dx} y={ann.y + dy} text-anchor={anchor} fill={ann.color}>{ann.label}</text>
|
|
822
|
-
{/each}
|
|
823
|
-
|
|
824
|
-
{#if isCartesian && activeCat !== null}
|
|
825
|
-
{#if isHorizontal}
|
|
826
|
-
{@const cy = geo.plot.y + slot * activeCat + slot / 2}
|
|
827
|
-
<line class="sv-grid-chart-crosshair" x1={geo.plot.x} y1={cy} x2={geo.plot.x + geo.plot.w} y2={cy} />
|
|
828
|
-
{:else}
|
|
829
|
-
<!-- Read the actual point x from the first line series when one
|
|
830
|
-
exists (line / area / combo charts) so the crosshair lines up
|
|
831
|
-
with the dots even on a time x-axis (where slot-based math is
|
|
832
|
-
wrong because points are spaced by time, not index). Fall back
|
|
833
|
-
to the slot midpoint for pure bar charts. -->
|
|
834
|
-
{@const linePt = geo.lines[0]?.points[activeCat]}
|
|
835
|
-
{@const cx = linePt && Number.isFinite(linePt.x)
|
|
836
|
-
? linePt.x
|
|
837
|
-
: geo.plot.x + slot * activeCat + slot / 2}
|
|
838
|
-
<line class="sv-grid-chart-crosshair" x1={cx} y1={geo.plot.y} x2={cx} y2={geo.plot.y + geo.plot.h} />
|
|
839
|
-
{/if}
|
|
840
|
-
{/if}
|
|
841
|
-
|
|
842
|
-
{#if isHeatmap}
|
|
843
|
-
<!-- Row labels in the left gutter -->
|
|
844
|
-
{#each geo.heatmapRowTicks as t (t.value)}
|
|
845
|
-
<text class="sv-grid-chart-axis" x={geo.plot.x - 8} y={t.y + 3} text-anchor="end">{truncate(t.label, 22)}</text>
|
|
846
|
-
{/each}
|
|
847
|
-
<!-- Column labels along the bottom -->
|
|
848
|
-
{#each geo.heatmapColTicks as t, i (t.label + i)}
|
|
849
|
-
<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>
|
|
850
|
-
{/each}
|
|
851
|
-
<!-- Cells -->
|
|
852
|
-
{#each geo.heatmapCells as cell, ci (ci)}
|
|
853
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
854
|
-
<rect
|
|
855
|
-
class="sv-grid-chart-heatcell"
|
|
856
|
-
x={cell.x} y={cell.y} width={cell.w} height={cell.h}
|
|
857
|
-
fill={cell.color}
|
|
858
|
-
role={onSelect ? 'button' : 'presentation'}
|
|
859
|
-
tabindex={onSelect ? 0 : undefined}
|
|
860
|
-
aria-label={`${cell.rowLabel} ${cell.colLabel}: ${fmt(cell.value)}`}
|
|
861
|
-
onmousemove={(e) => hoverCell(e.clientX, e.clientY, cell)}
|
|
862
|
-
onmouseleave={clearActive}
|
|
863
|
-
onclick={() => select(cell.colLabel, cell.rowLabel, cell.value)}
|
|
864
|
-
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(cell.colLabel, cell.rowLabel, cell.value) } }}
|
|
865
|
-
/>
|
|
866
|
-
{#if dataLabels && cell.w > 22 && cell.h > 14}
|
|
867
|
-
<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>
|
|
868
|
-
{/if}
|
|
869
|
-
{/each}
|
|
870
|
-
<!-- Color-scale legend bar on the right -->
|
|
871
|
-
{#if geo.heatmapLegend.length}
|
|
872
|
-
{@const lx = geo.plot.x + geo.plot.w + 14}
|
|
873
|
-
{@const lh = geo.plot.h - 20}
|
|
874
|
-
{@const stops = geo.heatmapLegend}
|
|
875
|
-
<defs>
|
|
876
|
-
<linearGradient id={`${uid}-heatscale`} x1="0" y1="1" x2="0" y2="0">
|
|
877
|
-
{#each stops as s, i (i)}
|
|
878
|
-
<stop offset={`${(i / (stops.length - 1)) * 100}%`} stop-color={s.color} />
|
|
879
|
-
{/each}
|
|
880
|
-
</linearGradient>
|
|
881
|
-
</defs>
|
|
882
|
-
<rect class="sv-grid-chart-heatlegend" x={lx} y={geo.plot.y + 10} width="10" height={lh} fill={`url(#${uid}-heatscale)`} />
|
|
883
|
-
<text class="sv-grid-chart-axis" x={lx + 14} y={geo.plot.y + 14} text-anchor="start">{stops[stops.length - 1]!.label}</text>
|
|
884
|
-
<text class="sv-grid-chart-axis" x={lx + 14} y={geo.plot.y + 10 + lh} text-anchor="start">{stops[0]!.label}</text>
|
|
885
|
-
{/if}
|
|
886
|
-
{/if}
|
|
887
|
-
|
|
888
|
-
{#if isFunnel}
|
|
889
|
-
<!-- Trapezoid stack with inline label inside each segment. -->
|
|
890
|
-
{#each geo.funnelSegments as seg, fi (fi)}
|
|
891
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
892
|
-
<path
|
|
893
|
-
class="sv-grid-chart-funnel-seg"
|
|
894
|
-
d={seg.path}
|
|
895
|
-
fill={seg.color}
|
|
896
|
-
role={onSelect ? 'button' : 'presentation'}
|
|
897
|
-
tabindex={onSelect ? 0 : undefined}
|
|
898
|
-
aria-label={`${seg.label}: ${fmt(seg.value)}`}
|
|
899
|
-
onmousemove={(e) => hoverFunnel(e.clientX, e.clientY, seg)}
|
|
900
|
-
onmouseleave={clearActive}
|
|
901
|
-
onclick={() => select(seg.label, seg.label, seg.value, fi)}
|
|
902
|
-
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(seg.label, seg.label, seg.value, fi) } }}
|
|
903
|
-
/>
|
|
904
|
-
<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>
|
|
905
|
-
<text class="sv-grid-chart-funnel-value" x={seg.cx} y={seg.cy + 11} text-anchor="middle" fill={seg.textColor}>
|
|
906
|
-
{fmt(seg.value)} · {(seg.conversion * 100).toFixed(0)}%
|
|
907
|
-
</text>
|
|
908
|
-
{/each}
|
|
909
|
-
{/if}
|
|
910
|
-
|
|
911
|
-
{#if isRadar && geo.radarCenter}
|
|
912
|
-
{@const c = geo.radarCenter}
|
|
913
|
-
<!-- Concentric grid rings -->
|
|
914
|
-
{#each geo.radarRings as _v, ri (ri)}
|
|
915
|
-
{@const ringR = c.r * ((ri + 1) / geo.radarRings.length)}
|
|
916
|
-
<circle class="sv-grid-chart-radar-ring" cx={c.cx} cy={c.cy} r={ringR} />
|
|
917
|
-
{/each}
|
|
918
|
-
<!-- Axis spokes + outer labels -->
|
|
919
|
-
{#each geo.radarAxes as axis, ai (ai)}
|
|
920
|
-
<line class="sv-grid-chart-radar-spoke" x1={c.cx} y1={c.cy} x2={axis.x} y2={axis.y} />
|
|
921
|
-
{@const dx = axis.x - c.cx}
|
|
922
|
-
{@const dy = axis.y - c.cy}
|
|
923
|
-
{@const lx = c.cx + dx * 1.08}
|
|
924
|
-
{@const ly = c.cy + dy * 1.08}
|
|
925
|
-
<text class="sv-grid-chart-axis" x={lx} y={ly + 3}
|
|
926
|
-
text-anchor={Math.abs(dx) < 1 ? 'middle' : dx > 0 ? 'start' : 'end'}>{axis.label}</text>
|
|
927
|
-
{/each}
|
|
928
|
-
<!-- One translucent polygon per series + dot per vertex. -->
|
|
929
|
-
{#each geo.radarSeries as rs, si (si)}
|
|
930
|
-
<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)}`} />
|
|
931
|
-
{#each rs.points as pt, pi (pi)}
|
|
932
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
933
|
-
<circle class="sv-grid-chart-radar-dot" cx={pt.x} cy={pt.y} r="3" fill={rs.color}
|
|
934
|
-
onmousemove={(e) => hoverRadarPoint(e.clientX, e.clientY, rs.label, pt.axis, pt.value, rs.color)}
|
|
935
|
-
onmouseleave={clearActive} />
|
|
936
|
-
{/each}
|
|
937
|
-
{/each}
|
|
938
|
-
{/if}
|
|
939
|
-
|
|
940
|
-
{#if isCalendar}
|
|
941
|
-
<!-- Month labels along the top -->
|
|
942
|
-
{#each geo.calendarMonthTicks as t, ti (ti)}
|
|
943
|
-
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y - 8} text-anchor="start">{t.label}</text>
|
|
944
|
-
{/each}
|
|
945
|
-
<!-- Day-of-week labels on the left (Mon/Wed/Fri only to save space) -->
|
|
946
|
-
{#each ['Mon','Wed','Fri'] as dayLabel, di (di)}
|
|
947
|
-
{@const rowIdx = [1, 3, 5][di]!}
|
|
948
|
-
{@const cell = geo.calendarCells[rowIdx]}
|
|
949
|
-
{#if cell}
|
|
950
|
-
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={cell.y + cell.size / 2 + 3} text-anchor="end">{dayLabel}</text>
|
|
951
|
-
{/if}
|
|
952
|
-
{/each}
|
|
953
|
-
<!-- Cells -->
|
|
954
|
-
{#each geo.calendarCells as cell, ci (ci)}
|
|
955
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
956
|
-
<rect
|
|
957
|
-
class="sv-grid-chart-calendar-cell"
|
|
958
|
-
class:is-blank={!cell.defined}
|
|
959
|
-
x={cell.x + 1} y={cell.y + 1}
|
|
960
|
-
width={cell.size - 2} height={cell.size - 2}
|
|
961
|
-
fill={cell.defined ? cell.color : 'transparent'}
|
|
962
|
-
rx="2"
|
|
963
|
-
aria-label={`${cell.date}: ${cell.defined ? fmt(cell.value) : 'no data'}`}
|
|
964
|
-
onmousemove={(e) => cell.defined && showTip(e.clientX, e.clientY, cell.date, [{ color: cell.color, value: fmt(cell.value) }])}
|
|
965
|
-
onmouseleave={clearActive}
|
|
966
|
-
/>
|
|
967
|
-
{/each}
|
|
968
|
-
<!-- Legend strip on the right -->
|
|
969
|
-
{#if geo.calendarLegend.length}
|
|
970
|
-
{@const lx = geo.plot.x + geo.plot.w + 12}
|
|
971
|
-
{@const ly = geo.plot.y + 4}
|
|
972
|
-
<text class="sv-grid-chart-axis" x={lx} y={ly} text-anchor="start">Less</text>
|
|
973
|
-
{#each geo.calendarLegend as s, i (i)}
|
|
974
|
-
<rect class="sv-grid-chart-calendar-legend" x={lx} y={ly + 6 + i * 13} width="10" height="10" rx="2" fill={s.color} />
|
|
975
|
-
<text class="sv-grid-chart-axis" x={lx + 14} y={ly + 14 + i * 13} text-anchor="start">{s.label}</text>
|
|
976
|
-
{/each}
|
|
977
|
-
<text class="sv-grid-chart-axis" x={lx} y={ly + 12 + geo.calendarLegend.length * 13} text-anchor="start">More</text>
|
|
978
|
-
{/if}
|
|
979
|
-
{/if}
|
|
980
|
-
|
|
981
|
-
{#if isGauge && geo.gauge}
|
|
982
|
-
{@const g = geo.gauge}
|
|
983
|
-
<!-- Tick marks around the dial (major ticks longer). -->
|
|
984
|
-
{#each g.ticks as tk, ti (ti)}
|
|
985
|
-
<line class="sv-grid-chart-gauge-tick" class:is-major={tk.major}
|
|
986
|
-
x1={tk.x1} y1={tk.y1} x2={tk.x2} y2={tk.y2} />
|
|
987
|
-
{/each}
|
|
988
|
-
<!-- Track (grey full arc) -->
|
|
989
|
-
<path d={g.trackPath} fill="none" stroke="var(--sg-border, #e2e8f0)" stroke-width="16" stroke-linecap="round" />
|
|
990
|
-
<!-- Optional colored range bands (a thinner inner arc). -->
|
|
991
|
-
{#each g.rangePaths as band, bi (bi)}
|
|
992
|
-
<path d={band.path} fill="none" stroke={band.color} stroke-width="6" stroke-linecap="round" opacity="0.85" />
|
|
993
|
-
{/each}
|
|
994
|
-
<!-- Value arc, colored by the band the value sits in. -->
|
|
995
|
-
<path d={g.valuePath} fill="none" stroke={g.valueColor ?? 'var(--sg-accent, #2563eb)'} stroke-width="16" stroke-linecap="round" />
|
|
996
|
-
<!-- Target tick (when set) -->
|
|
997
|
-
{#if g.target}
|
|
998
|
-
<line x1={g.target.x1} y1={g.target.y1} x2={g.target.x2} y2={g.target.y2}
|
|
999
|
-
stroke="var(--sg-fg, #0f172a)" stroke-width="2.5" stroke-linecap="round" />
|
|
1000
|
-
{/if}
|
|
1001
|
-
<!-- Pointer needle + center hub. -->
|
|
1002
|
-
<path class="sv-grid-chart-gauge-needle" d={g.needle.path} fill={g.valueColor ?? 'var(--sg-accent, #2563eb)'} />
|
|
1003
|
-
<circle class="sv-grid-chart-gauge-hub" cx={g.cx} cy={g.cy} r={g.needle.hubR} />
|
|
1004
|
-
<circle class="sv-grid-chart-gauge-hub-dot" cx={g.cx} cy={g.cy} r="2.5" />
|
|
1005
|
-
<!-- Scale end labels + center readout. -->
|
|
1006
|
-
<text class="sv-grid-chart-axis" x={g.minLabel.x} y={g.minLabel.y} text-anchor="middle">{fmt(g.min)}</text>
|
|
1007
|
-
<text class="sv-grid-chart-axis" x={g.maxLabel.x} y={g.maxLabel.y} text-anchor="middle">{fmt(g.max)}</text>
|
|
1008
|
-
<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>
|
|
1009
|
-
{/if}
|
|
1010
|
-
|
|
1011
|
-
{#if isTreemap}
|
|
1012
|
-
{#each geo.treemapCells as cell, ti (ti)}
|
|
1013
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1014
|
-
<rect
|
|
1015
|
-
class="sv-grid-chart-treemap-cell"
|
|
1016
|
-
x={cell.x} y={cell.y} width={cell.w} height={cell.h}
|
|
1017
|
-
fill={cell.color}
|
|
1018
|
-
role={onSelect ? 'button' : 'presentation'}
|
|
1019
|
-
tabindex={onSelect ? 0 : undefined}
|
|
1020
|
-
aria-label={`${cell.name}: ${fmt(cell.value)}`}
|
|
1021
|
-
onmousemove={(e) => showTip(e.clientX, e.clientY, cell.name, [{ color: cell.color, value: fmt(cell.value) }])}
|
|
1022
|
-
onmouseleave={clearActive}
|
|
1023
|
-
onclick={() => select(cell.name, '', cell.value)}
|
|
1024
|
-
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(cell.name, '', cell.value) } }}
|
|
1025
|
-
/>
|
|
1026
|
-
{#if cell.w > 36 && cell.h > 18}
|
|
1027
|
-
<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>
|
|
1028
|
-
{/if}
|
|
1029
|
-
{#if cell.w > 60 && cell.h > 30}
|
|
1030
|
-
<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>
|
|
1031
|
-
{/if}
|
|
1032
|
-
{/each}
|
|
1033
|
-
{/if}
|
|
1034
|
-
|
|
1035
|
-
{#if isSankey}
|
|
1036
|
-
<!-- Ribbon links (drawn first so node rects sit on top). -->
|
|
1037
|
-
{#each geo.sankeyLinks as link, li (li)}
|
|
1038
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1039
|
-
<path
|
|
1040
|
-
class="sv-grid-chart-sankey-link"
|
|
1041
|
-
d={link.path}
|
|
1042
|
-
fill="none"
|
|
1043
|
-
stroke={link.color}
|
|
1044
|
-
stroke-width={link.width}
|
|
1045
|
-
stroke-opacity="0.35"
|
|
1046
|
-
aria-label={`${link.source} -> ${link.target}: ${fmt(link.value)}`}
|
|
1047
|
-
onmousemove={(e) => showTip(e.clientX, e.clientY, `${link.source} -> ${link.target}`, [{ color: link.color, value: fmt(link.value) }])}
|
|
1048
|
-
onmouseleave={clearActive}
|
|
1049
|
-
/>
|
|
1050
|
-
{/each}
|
|
1051
|
-
<!-- Node rectangles + labels. -->
|
|
1052
|
-
{#each geo.sankeyNodes as node, ni (ni)}
|
|
1053
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1054
|
-
<rect
|
|
1055
|
-
class="sv-grid-chart-sankey-node"
|
|
1056
|
-
x={node.x} y={node.y} width={node.w} height={node.h}
|
|
1057
|
-
fill={node.color}
|
|
1058
|
-
tabindex={0}
|
|
1059
|
-
aria-label={`${node.label}: in ${fmt(node.totalIn)}, out ${fmt(node.totalOut)}`}
|
|
1060
|
-
onmousemove={(e) => showTip(e.clientX, e.clientY, node.label, [
|
|
1061
|
-
{ label: 'in', value: fmt(node.totalIn) },
|
|
1062
|
-
{ label: 'out', value: fmt(node.totalOut) },
|
|
1063
|
-
])}
|
|
1064
|
-
onmouseleave={clearActive}
|
|
1065
|
-
onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); showTip(b.left + b.width / 2, b.top, node.label, [
|
|
1066
|
-
{ label: 'in', value: fmt(node.totalIn) },
|
|
1067
|
-
{ label: 'out', value: fmt(node.totalOut) },
|
|
1068
|
-
]) }}
|
|
1069
|
-
onblur={clearActive}
|
|
1070
|
-
/>
|
|
1071
|
-
<text class="sv-grid-chart-sankey-label"
|
|
1072
|
-
x={node.column === 0 ? node.x + node.w + 4 : node.x - 4}
|
|
1073
|
-
y={node.y + node.h / 2 + 3}
|
|
1074
|
-
text-anchor={node.column === 0 ? 'start' : 'end'}>{node.label}</text>
|
|
1075
|
-
{/each}
|
|
1076
|
-
{/if}
|
|
1077
|
-
|
|
1078
|
-
{#each geo.slices as slice, si (si)}
|
|
1079
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1080
|
-
<path
|
|
1081
|
-
class="sv-grid-chart-slice"
|
|
1082
|
-
d={slice.path}
|
|
1083
|
-
fill={slice.color}
|
|
1084
|
-
stroke="var(--sg-bg, #fff)"
|
|
1085
|
-
stroke-width="1"
|
|
1086
|
-
role={onSelect ? 'button' : 'presentation'}
|
|
1087
|
-
tabindex={onSelect ? 0 : undefined}
|
|
1088
|
-
aria-label={`${slice.label}: ${fmt(slice.value)}, ${slice.percent.toFixed(1)}%`}
|
|
1089
|
-
onmousemove={(e) => hoverSlice(e.clientX, e.clientY, slice.label, slice.value, slice.percent)}
|
|
1090
|
-
onmouseleave={clearActive}
|
|
1091
|
-
onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); hoverSlice(b.left + b.width / 2, b.top, slice.label, slice.value, slice.percent) }}
|
|
1092
|
-
onblur={clearActive}
|
|
1093
|
-
onclick={() => select(slice.label, slice.label, slice.value, si)}
|
|
1094
|
-
onkeydown={(e) => onSliceKey(e, slice.label, slice.value)}
|
|
1095
|
-
/>
|
|
1096
|
-
{#if dataLabels && slice.percent >= 6}
|
|
1097
|
-
<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>
|
|
1098
|
-
{/if}
|
|
1099
|
-
{/each}
|
|
1100
|
-
|
|
1101
|
-
{#if geo.donut}
|
|
1102
|
-
<text class="sv-grid-chart-donut-total" x={geo.donut.cx} y={geo.donut.cy - 4} text-anchor="middle">{fmt(geo.donut.total)}</text>
|
|
1103
|
-
<text class="sv-grid-chart-donut-label" x={geo.donut.cx} y={geo.donut.cy + 11} text-anchor="middle">Total</text>
|
|
1104
|
-
{/if}
|
|
1105
|
-
|
|
1106
|
-
{#if isCartesian && !isScatter}
|
|
1107
|
-
<!-- Per-category hover/focus zones drive the unified crosshair
|
|
1108
|
-
tooltip + arrow-key navigation. tabindex=0 on the first rect
|
|
1109
|
-
and -1 on the rest implements roving tabindex: Tab enters the
|
|
1110
|
-
group, arrow keys move within it, Tab leaves.
|
|
1111
|
-
Vertical bands centre on the ACTUAL point x (which differs from
|
|
1112
|
-
slot math when xType is 'time'); their widths bisect the gap to
|
|
1113
|
-
neighbouring points so a mouse over a dot always selects it. -->
|
|
1114
|
-
{#each visibleSpec.categories as cat, i (cat + i)}
|
|
1115
|
-
{@const pts = geo.lines[0]?.points}
|
|
1116
|
-
{@const px = pts?.[i]?.x}
|
|
1117
|
-
{@const useTimeBands = !isHorizontal && pts && pts.length === visibleSpec.categories.length && Number.isFinite(px)}
|
|
1118
|
-
{@const prevX = useTimeBands && i > 0 ? pts![i - 1]!.x : null}
|
|
1119
|
-
{@const nextX = useTimeBands && i < pts!.length - 1 ? pts![i + 1]!.x : null}
|
|
1120
|
-
{@const bandL = !useTimeBands ? geo.plot.x + slot * i
|
|
1121
|
-
: prevX != null ? (prevX + (px as number)) / 2 : geo.plot.x}
|
|
1122
|
-
{@const bandR = !useTimeBands ? geo.plot.x + slot * (i + 1)
|
|
1123
|
-
: nextX != null ? ((px as number) + nextX) / 2 : geo.plot.x + geo.plot.w}
|
|
1124
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1125
|
-
<rect
|
|
1126
|
-
class="sv-grid-chart-cat-hit"
|
|
1127
|
-
x={isHorizontal ? geo.plot.x : bandL}
|
|
1128
|
-
y={isHorizontal ? geo.plot.y + slot * i : geo.plot.y}
|
|
1129
|
-
width={isHorizontal ? geo.plot.w : Math.max(0, bandR - bandL)}
|
|
1130
|
-
height={isHorizontal ? slot : geo.plot.h}
|
|
1131
|
-
role={onSelect ? 'button' : 'img'}
|
|
1132
|
-
tabindex={interactive ? (i === 0 ? 0 : -1) : undefined}
|
|
1133
|
-
data-cat-index={i}
|
|
1134
|
-
aria-label={`${cat}: ${catRows(i).map((r) => `${r.label} ${r.value}`).join(', ')}`}
|
|
1135
|
-
onmousemove={(e) => hoverCat(e.clientX, e.clientY, i)}
|
|
1136
|
-
onmouseleave={clearActive}
|
|
1137
|
-
onfocus={(e) => focusCat(e.currentTarget, i)}
|
|
1138
|
-
onblur={clearActive}
|
|
1139
|
-
onclick={() => select(cat, '', 0, i)}
|
|
1140
|
-
onkeydown={(e) => onCatKey(e, i)}
|
|
1141
|
-
/>
|
|
1142
|
-
{/each}
|
|
1143
|
-
{/if}
|
|
1144
|
-
|
|
1145
|
-
{#if dragRect}
|
|
1146
|
-
<rect class="sv-grid-chart-zoom-rect"
|
|
1147
|
-
x={dragRect.x} y={dragRect.y} width={dragRect.w} height={dragRect.h} />
|
|
1148
|
-
{/if}
|
|
1149
|
-
</svg>
|
|
1150
|
-
|
|
1151
|
-
{#if brushEligible && brushGeo}
|
|
1152
|
-
{@const bx = brushGeo.plot.x + brushWindow.t0 * brushGeo.plot.w}
|
|
1153
|
-
{@const bw = (brushWindow.t1 - brushWindow.t0) * brushGeo.plot.w}
|
|
1154
|
-
<svg
|
|
1155
|
-
class="sv-grid-chart-brush"
|
|
1156
|
-
viewBox={`0 0 ${brushGeo.width} ${brushGeo.height}`}
|
|
1157
|
-
width="100%"
|
|
1158
|
-
role="img"
|
|
1159
|
-
aria-label="Range brush"
|
|
1160
|
-
onpointermove={onBrushMove}
|
|
1161
|
-
>
|
|
1162
|
-
<!-- Mini chart: paint a faint version of the lines / area / bars
|
|
1163
|
-
so the user sees what region they're picking from. -->
|
|
1164
|
-
{#each brushGeo.lines as line, li (li)}
|
|
1165
|
-
{#if line.areaPath}
|
|
1166
|
-
<path d={line.areaPath} fill={line.color} fill-opacity="0.10" stroke="none" />
|
|
1167
|
-
{/if}
|
|
1168
|
-
<path d={line.path} fill="none" stroke={line.color} stroke-width="1.2" opacity="0.75" />
|
|
1169
|
-
{/each}
|
|
1170
|
-
{#each brushGeo.bars as bar, bi (bi)}
|
|
1171
|
-
<rect x={bar.x} y={bar.y} width={bar.w} height={bar.h} fill={bar.color} opacity="0.55" />
|
|
1172
|
-
{/each}
|
|
1173
|
-
<!-- Faded overlay on the parts NOT in the window. -->
|
|
1174
|
-
<rect class="sv-grid-chart-brush-mask"
|
|
1175
|
-
x={brushGeo.plot.x} y={brushGeo.plot.y}
|
|
1176
|
-
width={bx - brushGeo.plot.x} height={brushGeo.plot.h} />
|
|
1177
|
-
<rect class="sv-grid-chart-brush-mask"
|
|
1178
|
-
x={bx + bw} y={brushGeo.plot.y}
|
|
1179
|
-
width={brushGeo.plot.x + brushGeo.plot.w - (bx + bw)} height={brushGeo.plot.h} />
|
|
1180
|
-
<!-- The window body: pointerdown moves the whole window. -->
|
|
1181
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1182
|
-
<rect class="sv-grid-chart-brush-window"
|
|
1183
|
-
x={bx} y={brushGeo.plot.y} width={bw} height={brushGeo.plot.h}
|
|
1184
|
-
onpointerdown={(e) => onBrushDown(e, 'move')}
|
|
1185
|
-
onpointerup={onBrushUp}
|
|
1186
|
-
onpointercancel={onBrushUp} />
|
|
1187
|
-
<!-- Left + right handles: pointerdown resizes that edge. -->
|
|
1188
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1189
|
-
<rect class="sv-grid-chart-brush-handle"
|
|
1190
|
-
x={bx - 4} y={brushGeo.plot.y} width="8" height={brushGeo.plot.h}
|
|
1191
|
-
onpointerdown={(e) => onBrushDown(e, 'left')}
|
|
1192
|
-
onpointerup={onBrushUp}
|
|
1193
|
-
onpointercancel={onBrushUp} />
|
|
1194
|
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1195
|
-
<rect class="sv-grid-chart-brush-handle"
|
|
1196
|
-
x={bx + bw - 4} y={brushGeo.plot.y} width="8" height={brushGeo.plot.h}
|
|
1197
|
-
onpointerdown={(e) => onBrushDown(e, 'right')}
|
|
1198
|
-
onpointerup={onBrushUp}
|
|
1199
|
-
onpointercancel={onBrushUp} />
|
|
1200
|
-
</svg>
|
|
1201
|
-
{/if}
|
|
1202
|
-
|
|
1203
|
-
{#if isEmpty}
|
|
1204
|
-
<div class="sv-grid-chart-empty">No data</div>
|
|
1205
|
-
{/if}
|
|
1206
|
-
|
|
1207
|
-
{#if tip}
|
|
1208
|
-
<div class="sv-grid-chart-tooltip" class:is-below={tip.below} style={`left:${tip.left}px; top:${tip.top}px;`}>
|
|
1209
|
-
<span class="sv-grid-chart-tooltip-title">{tip.title}</span>
|
|
1210
|
-
{#each tip.rows as r, ri (ri)}
|
|
1211
|
-
<span class="sv-grid-chart-tooltip-row">
|
|
1212
|
-
{#if r.color}<span class="sv-grid-chart-tooltip-dot" style={`background:${r.color}`}></span>{/if}
|
|
1213
|
-
{#if r.label}<span class="sv-grid-chart-tooltip-row-label">{r.label}</span>{/if}
|
|
1214
|
-
<span class="sv-grid-chart-tooltip-row-value">{r.value}</span>
|
|
1215
|
-
</span>
|
|
1216
|
-
{/each}
|
|
1217
|
-
</div>
|
|
1218
|
-
{/if}
|
|
1219
|
-
|
|
1220
|
-
{#if legend && legendItems.length}
|
|
1221
|
-
<div class="sv-grid-chart-legend">
|
|
1222
|
-
{#each shownLegend as item (item.label)}
|
|
1223
|
-
<button
|
|
1224
|
-
type="button"
|
|
1225
|
-
class="sv-grid-chart-legend-item"
|
|
1226
|
-
class:is-off={item.off}
|
|
1227
|
-
class:is-isolated={isolated === item.label}
|
|
1228
|
-
disabled={!interactive}
|
|
1229
|
-
aria-pressed={!item.off}
|
|
1230
|
-
onclick={() => toggle(item.label)}
|
|
1231
|
-
ondblclick={() => isolate(item.label)}
|
|
1232
|
-
onpointerenter={() => interactive && (dimmed = item.label)}
|
|
1233
|
-
onpointerleave={() => (dimmed = null)}
|
|
1234
|
-
title={interactive ? `${item.off ? 'Show' : 'Hide'} ${item.label} · double-click to isolate` : item.label}
|
|
1235
|
-
>
|
|
1236
|
-
<span class="sv-grid-chart-swatch" style={`background:${item.off ? 'transparent' : item.color}; border-color:${item.color}`}></span>
|
|
1237
|
-
{item.label}
|
|
1238
|
-
</button>
|
|
1239
|
-
{/each}
|
|
1240
|
-
{#if legendOverflow}
|
|
1241
|
-
<button type="button" class="sv-grid-chart-legend-more" onclick={() => (legendExpanded = !legendExpanded)}>
|
|
1242
|
-
{legendExpanded ? 'Show less' : `+${legendItems.length - LEGEND_MAX} more`}
|
|
1243
|
-
</button>
|
|
1244
|
-
{/if}
|
|
1245
|
-
</div>
|
|
1246
|
-
{/if}
|
|
1247
|
-
|
|
1248
|
-
<!-- Visually-hidden data table: the same data for assistive technology. -->
|
|
1249
|
-
<table id={`${uid}-table`} class="sv-grid-chart-sr-only">
|
|
1250
|
-
<caption>{spec.type} chart data</caption>
|
|
1251
|
-
<thead>
|
|
1252
|
-
<tr>{#each srTable.cols as c (c)}<th>{c}</th>{/each}</tr>
|
|
1253
|
-
</thead>
|
|
1254
|
-
<tbody>
|
|
1255
|
-
{#each srTable.rows as row, ri (ri)}
|
|
1256
|
-
<tr>{#each row as cell, ci (ci)}<td>{cell}</td>{/each}</tr>
|
|
1257
|
-
{/each}
|
|
1258
|
-
</tbody>
|
|
1259
|
-
</table>
|
|
1260
|
-
</div>
|
|
1261
|
-
|
|
1262
|
-
<style>
|
|
1263
|
-
.sv-grid-chart {
|
|
1264
|
-
position: relative;
|
|
1265
|
-
display: flex;
|
|
1266
|
-
flex-direction: column;
|
|
1267
|
-
gap: 8px;
|
|
1268
|
-
width: 100%;
|
|
1269
|
-
}
|
|
1270
|
-
.sv-grid-chart-svg {
|
|
1271
|
-
display: block;
|
|
1272
|
-
width: 100%;
|
|
1273
|
-
height: auto;
|
|
1274
|
-
}
|
|
1275
|
-
.sv-grid-chart-gridline {
|
|
1276
|
-
stroke: var(--sg-border, #e2e8f0);
|
|
1277
|
-
stroke-width: 1;
|
|
1278
|
-
opacity: 0.6;
|
|
1279
|
-
}
|
|
1280
|
-
.sv-grid-chart-gridline.is-zero {
|
|
1281
|
-
stroke: var(--sg-muted, #94a3b8);
|
|
1282
|
-
opacity: 0.9;
|
|
1283
|
-
}
|
|
1284
|
-
.sv-grid-chart-axis {
|
|
1285
|
-
fill: var(--sg-muted, #64748b);
|
|
1286
|
-
font-size: 10px;
|
|
1287
|
-
font-family: inherit;
|
|
1288
|
-
}
|
|
1289
|
-
.sv-grid-chart-axis-title {
|
|
1290
|
-
fill: var(--sg-muted, #64748b);
|
|
1291
|
-
font-size: 11px;
|
|
1292
|
-
font-weight: 600;
|
|
1293
|
-
font-family: inherit;
|
|
1294
|
-
}
|
|
1295
|
-
.sv-grid-chart-group-label {
|
|
1296
|
-
fill: var(--sg-fg, #0f172a);
|
|
1297
|
-
font-weight: 600;
|
|
1298
|
-
}
|
|
1299
|
-
.sv-grid-chart-crosshair {
|
|
1300
|
-
stroke: var(--sg-accent, #2563eb);
|
|
1301
|
-
stroke-width: 1;
|
|
1302
|
-
stroke-dasharray: 3 3;
|
|
1303
|
-
opacity: 0.65;
|
|
1304
|
-
pointer-events: none;
|
|
1305
|
-
}
|
|
1306
|
-
.sv-grid-chart-refline {
|
|
1307
|
-
stroke-width: 1.5;
|
|
1308
|
-
opacity: 0.9;
|
|
1309
|
-
pointer-events: none;
|
|
1310
|
-
}
|
|
1311
|
-
.sv-grid-chart-reflabel {
|
|
1312
|
-
font-size: 9.5px;
|
|
1313
|
-
font-weight: 700;
|
|
1314
|
-
font-family: inherit;
|
|
1315
|
-
pointer-events: none;
|
|
1316
|
-
}
|
|
1317
|
-
.sv-grid-chart-overlay {
|
|
1318
|
-
opacity: 0.85;
|
|
1319
|
-
pointer-events: none;
|
|
1320
|
-
}
|
|
1321
|
-
.sv-grid-chart-annotation-marker {
|
|
1322
|
-
pointer-events: none;
|
|
1323
|
-
}
|
|
1324
|
-
.sv-grid-chart-annotation-label {
|
|
1325
|
-
font-size: 10px;
|
|
1326
|
-
font-weight: 700;
|
|
1327
|
-
font-family: inherit;
|
|
1328
|
-
pointer-events: none;
|
|
1329
|
-
}
|
|
1330
|
-
/* Heatmap cells. Stroke uses the border token (not bg) so the cell
|
|
1331
|
-
separators stay visible in BOTH light and dark themes - on dark mode
|
|
1332
|
-
a bg-colored stroke disappeared into the navy cells. */
|
|
1333
|
-
.sv-grid-chart-heatcell {
|
|
1334
|
-
stroke: var(--sg-border, #cbd5e1);
|
|
1335
|
-
stroke-width: 1;
|
|
1336
|
-
transition: opacity 100ms ease;
|
|
1337
|
-
cursor: default;
|
|
1338
|
-
}
|
|
1339
|
-
.sv-grid-chart-svg.is-clickable .sv-grid-chart-heatcell { cursor: pointer; }
|
|
1340
|
-
.sv-grid-chart-heatcell:hover { opacity: 0.85; }
|
|
1341
|
-
.sv-grid-chart-heatlabel {
|
|
1342
|
-
font-size: 10px;
|
|
1343
|
-
font-weight: 600;
|
|
1344
|
-
font-family: inherit;
|
|
1345
|
-
pointer-events: none;
|
|
1346
|
-
user-select: none;
|
|
1347
|
-
}
|
|
1348
|
-
/* Legend gradient bar gets a visible outline + axis labels next to it
|
|
1349
|
-
pull from the regular axis class so they follow the theme. */
|
|
1350
|
-
.sv-grid-chart-heatlegend {
|
|
1351
|
-
stroke: var(--sg-border, #cbd5e1);
|
|
1352
|
-
stroke-width: 1;
|
|
1353
|
-
}
|
|
1354
|
-
/* Funnel segments + labels. */
|
|
1355
|
-
.sv-grid-chart-funnel-seg {
|
|
1356
|
-
stroke: var(--sg-bg, #ffffff);
|
|
1357
|
-
stroke-width: 2;
|
|
1358
|
-
transition: opacity 100ms ease;
|
|
1359
|
-
cursor: default;
|
|
1360
|
-
}
|
|
1361
|
-
.sv-grid-chart-svg.is-clickable .sv-grid-chart-funnel-seg { cursor: pointer; }
|
|
1362
|
-
.sv-grid-chart-funnel-seg:hover { opacity: 0.88; }
|
|
1363
|
-
.sv-grid-chart-funnel-label {
|
|
1364
|
-
font-size: 11px;
|
|
1365
|
-
font-weight: 700;
|
|
1366
|
-
font-family: inherit;
|
|
1367
|
-
pointer-events: none;
|
|
1368
|
-
}
|
|
1369
|
-
.sv-grid-chart-funnel-value {
|
|
1370
|
-
font-size: 10px;
|
|
1371
|
-
font-family: inherit;
|
|
1372
|
-
opacity: 0.85;
|
|
1373
|
-
pointer-events: none;
|
|
1374
|
-
}
|
|
1375
|
-
/* Radar grid + polygons. */
|
|
1376
|
-
.sv-grid-chart-radar-ring {
|
|
1377
|
-
fill: none;
|
|
1378
|
-
stroke: var(--sg-border, #e2e8f0);
|
|
1379
|
-
stroke-width: 1;
|
|
1380
|
-
opacity: 0.6;
|
|
1381
|
-
}
|
|
1382
|
-
.sv-grid-chart-radar-spoke {
|
|
1383
|
-
stroke: var(--sg-border, #e2e8f0);
|
|
1384
|
-
stroke-width: 1;
|
|
1385
|
-
opacity: 0.6;
|
|
1386
|
-
}
|
|
1387
|
-
.sv-grid-chart-radar-poly {
|
|
1388
|
-
transition: opacity 100ms ease;
|
|
1389
|
-
pointer-events: none;
|
|
1390
|
-
}
|
|
1391
|
-
.sv-grid-chart-radar-dot {
|
|
1392
|
-
transition: r 100ms ease;
|
|
1393
|
-
cursor: default;
|
|
1394
|
-
}
|
|
1395
|
-
.sv-grid-chart-radar-dot:hover { r: 4.5; }
|
|
1396
|
-
/* Calendar heatmap cells. Blank cells get a faint outline so the grid
|
|
1397
|
-
still reads on days with no data. */
|
|
1398
|
-
.sv-grid-chart-calendar-cell {
|
|
1399
|
-
stroke: var(--sg-border, #e2e8f0);
|
|
1400
|
-
stroke-width: 0.5;
|
|
1401
|
-
transition: opacity 100ms ease;
|
|
1402
|
-
cursor: default;
|
|
1403
|
-
}
|
|
1404
|
-
.sv-grid-chart-calendar-cell.is-blank { stroke-opacity: 0.5; }
|
|
1405
|
-
.sv-grid-chart-calendar-cell:hover { opacity: 0.85; }
|
|
1406
|
-
.sv-grid-chart-calendar-legend {
|
|
1407
|
-
stroke: var(--sg-border, #cbd5e1);
|
|
1408
|
-
stroke-width: 0.5;
|
|
1409
|
-
}
|
|
1410
|
-
/* Gauge centre readout. */
|
|
1411
|
-
.sv-grid-chart-gauge-value {
|
|
1412
|
-
fill: var(--sg-fg, #0f172a);
|
|
1413
|
-
font-size: 22px;
|
|
1414
|
-
font-weight: 800;
|
|
1415
|
-
font-family: inherit;
|
|
1416
|
-
pointer-events: none;
|
|
1417
|
-
}
|
|
1418
|
-
/* Gauge tick marks: faint minors, stronger majors. */
|
|
1419
|
-
.sv-grid-chart-gauge-tick {
|
|
1420
|
-
stroke: var(--sg-muted, #94a3b8);
|
|
1421
|
-
stroke-width: 1;
|
|
1422
|
-
opacity: 0.4;
|
|
1423
|
-
}
|
|
1424
|
-
.sv-grid-chart-gauge-tick.is-major {
|
|
1425
|
-
stroke-width: 2;
|
|
1426
|
-
opacity: 0.7;
|
|
1427
|
-
}
|
|
1428
|
-
/* Pointer needle + hub. */
|
|
1429
|
-
.sv-grid-chart-gauge-needle {
|
|
1430
|
-
stroke: var(--sg-bg, #ffffff);
|
|
1431
|
-
stroke-width: 0.75;
|
|
1432
|
-
stroke-linejoin: round;
|
|
1433
|
-
}
|
|
1434
|
-
.sv-grid-chart-gauge-hub {
|
|
1435
|
-
fill: var(--sg-fg, #0f172a);
|
|
1436
|
-
stroke: var(--sg-bg, #ffffff);
|
|
1437
|
-
stroke-width: 2;
|
|
1438
|
-
}
|
|
1439
|
-
.sv-grid-chart-gauge-hub-dot {
|
|
1440
|
-
fill: var(--sg-bg, #ffffff);
|
|
1441
|
-
}
|
|
1442
|
-
/* Tree-map cells. White separators give the squarified rects breathing
|
|
1443
|
-
room without distracting from the data. */
|
|
1444
|
-
.sv-grid-chart-treemap-cell {
|
|
1445
|
-
stroke: var(--sg-bg, #ffffff);
|
|
1446
|
-
stroke-width: 1;
|
|
1447
|
-
transition: opacity 100ms ease;
|
|
1448
|
-
cursor: default;
|
|
1449
|
-
}
|
|
1450
|
-
.sv-grid-chart-svg.is-clickable .sv-grid-chart-treemap-cell { cursor: pointer; }
|
|
1451
|
-
.sv-grid-chart-treemap-cell:hover { opacity: 0.88; }
|
|
1452
|
-
.sv-grid-chart-treemap-label {
|
|
1453
|
-
font-size: 11px;
|
|
1454
|
-
font-weight: 600;
|
|
1455
|
-
font-family: inherit;
|
|
1456
|
-
pointer-events: none;
|
|
1457
|
-
}
|
|
1458
|
-
.sv-grid-chart-treemap-value {
|
|
1459
|
-
font-size: 10px;
|
|
1460
|
-
font-family: inherit;
|
|
1461
|
-
pointer-events: none;
|
|
1462
|
-
}
|
|
1463
|
-
/* Sankey nodes + links. Links use stroke not fill so per-ribbon width
|
|
1464
|
-
comes from stroke-width directly. */
|
|
1465
|
-
.sv-grid-chart-sankey-node {
|
|
1466
|
-
transition: opacity 100ms ease;
|
|
1467
|
-
cursor: default;
|
|
1468
|
-
}
|
|
1469
|
-
.sv-grid-chart-sankey-node:hover { opacity: 0.88; }
|
|
1470
|
-
.sv-grid-chart-sankey-link {
|
|
1471
|
-
transition: stroke-opacity 100ms ease;
|
|
1472
|
-
cursor: default;
|
|
1473
|
-
}
|
|
1474
|
-
.sv-grid-chart-sankey-link:hover { stroke-opacity: 0.6; }
|
|
1475
|
-
.sv-grid-chart-sankey-label {
|
|
1476
|
-
fill: var(--sg-fg, #0f172a);
|
|
1477
|
-
font-size: 11px;
|
|
1478
|
-
font-weight: 600;
|
|
1479
|
-
font-family: inherit;
|
|
1480
|
-
pointer-events: none;
|
|
1481
|
-
}
|
|
1482
|
-
/* Brush mini-map: a compact secondary chart with a draggable window. */
|
|
1483
|
-
.sv-grid-chart-brush {
|
|
1484
|
-
display: block;
|
|
1485
|
-
width: 100%;
|
|
1486
|
-
height: auto;
|
|
1487
|
-
margin-top: 4px;
|
|
1488
|
-
border-top: 1px solid var(--sg-border, #e2e8f0);
|
|
1489
|
-
user-select: none;
|
|
1490
|
-
touch-action: none;
|
|
1491
|
-
}
|
|
1492
|
-
.sv-grid-chart-brush-mask {
|
|
1493
|
-
fill: var(--sg-bg, #ffffff);
|
|
1494
|
-
fill-opacity: 0.65;
|
|
1495
|
-
pointer-events: none;
|
|
1496
|
-
}
|
|
1497
|
-
.sv-grid-chart-brush-window {
|
|
1498
|
-
fill: var(--sg-accent, #2563eb);
|
|
1499
|
-
fill-opacity: 0.10;
|
|
1500
|
-
stroke: var(--sg-accent, #2563eb);
|
|
1501
|
-
stroke-width: 1;
|
|
1502
|
-
cursor: grab;
|
|
1503
|
-
}
|
|
1504
|
-
.sv-grid-chart-brush-window:active { cursor: grabbing; }
|
|
1505
|
-
.sv-grid-chart-brush-handle {
|
|
1506
|
-
fill: var(--sg-accent, #2563eb);
|
|
1507
|
-
fill-opacity: 0.4;
|
|
1508
|
-
cursor: ew-resize;
|
|
1509
|
-
}
|
|
1510
|
-
.sv-grid-chart-brush-handle:hover { fill-opacity: 0.7; }
|
|
1511
|
-
.sv-grid-chart-scatter {
|
|
1512
|
-
transition: opacity 0.2s ease;
|
|
1513
|
-
stroke-width: 1;
|
|
1514
|
-
}
|
|
1515
|
-
.is-clickable .sv-grid-chart-scatter,
|
|
1516
|
-
.sv-grid-chart-scatter:hover {
|
|
1517
|
-
cursor: pointer;
|
|
1518
|
-
}
|
|
1519
|
-
.sv-grid-chart-cat-hit {
|
|
1520
|
-
fill: transparent;
|
|
1521
|
-
}
|
|
1522
|
-
.sv-grid-chart-cat-hit:focus-visible,
|
|
1523
|
-
.sv-grid-chart-slice:focus-visible {
|
|
1524
|
-
outline: 2px solid var(--sg-accent, #2563eb);
|
|
1525
|
-
outline-offset: 1px;
|
|
1526
|
-
}
|
|
1527
|
-
.sv-grid-chart-datalabel {
|
|
1528
|
-
fill: var(--sg-fg, #0f172a);
|
|
1529
|
-
font-size: 9.5px;
|
|
1530
|
-
font-weight: 600;
|
|
1531
|
-
font-variant-numeric: tabular-nums;
|
|
1532
|
-
pointer-events: none;
|
|
1533
|
-
}
|
|
1534
|
-
.sv-grid-chart-datalabel.on-bar {
|
|
1535
|
-
fill: #fff;
|
|
1536
|
-
}
|
|
1537
|
-
.sv-grid-chart-donut-total {
|
|
1538
|
-
fill: var(--sg-fg, #0f172a);
|
|
1539
|
-
font-size: 16px;
|
|
1540
|
-
font-weight: 800;
|
|
1541
|
-
font-variant-numeric: tabular-nums;
|
|
1542
|
-
}
|
|
1543
|
-
.sv-grid-chart-donut-label {
|
|
1544
|
-
fill: var(--sg-muted, #64748b);
|
|
1545
|
-
font-size: 10px;
|
|
1546
|
-
}
|
|
1547
|
-
.sv-grid-chart-bar {
|
|
1548
|
-
transition: x 0.3s ease, y 0.3s ease, width 0.3s ease, height 0.3s ease;
|
|
1549
|
-
}
|
|
1550
|
-
.sv-grid-chart-dot {
|
|
1551
|
-
transition: cx 0.3s ease, cy 0.3s ease;
|
|
1552
|
-
}
|
|
1553
|
-
.is-clickable .sv-grid-chart-cat-hit,
|
|
1554
|
-
.is-clickable .sv-grid-chart-slice {
|
|
1555
|
-
cursor: pointer;
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
.sv-grid-chart-empty {
|
|
1559
|
-
position: absolute;
|
|
1560
|
-
inset: 0;
|
|
1561
|
-
display: flex;
|
|
1562
|
-
align-items: center;
|
|
1563
|
-
justify-content: center;
|
|
1564
|
-
color: var(--sg-muted, #94a3b8);
|
|
1565
|
-
font-size: 13px;
|
|
1566
|
-
pointer-events: none;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
/* Toolbar above the chart: reset-zoom, PNG / SVG / Copy. */
|
|
1570
|
-
.sv-grid-chart-toolbar {
|
|
1571
|
-
display: flex;
|
|
1572
|
-
justify-content: flex-end;
|
|
1573
|
-
gap: 4px;
|
|
1574
|
-
flex-wrap: wrap;
|
|
1575
|
-
}
|
|
1576
|
-
.sv-grid-chart-tool {
|
|
1577
|
-
display: inline-flex;
|
|
1578
|
-
align-items: center;
|
|
1579
|
-
gap: 4px;
|
|
1580
|
-
border: 1px solid var(--sg-border, #cbd5e1);
|
|
1581
|
-
background: var(--sg-bg, #ffffff);
|
|
1582
|
-
color: var(--sg-fg, #1e293b);
|
|
1583
|
-
font-size: 11px;
|
|
1584
|
-
font-weight: 500;
|
|
1585
|
-
padding: 3px 8px;
|
|
1586
|
-
border-radius: 4px;
|
|
1587
|
-
cursor: pointer;
|
|
1588
|
-
transition: background-color 100ms ease, border-color 100ms ease;
|
|
1589
|
-
}
|
|
1590
|
-
.sv-grid-chart-tool:hover {
|
|
1591
|
-
background: var(--sg-row-hover-bg, #f1f5f9);
|
|
1592
|
-
border-color: var(--sg-accent, #2563eb);
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
/* Drag-to-zoom: translucent accent rectangle over the plot. */
|
|
1596
|
-
.sv-grid-chart-svg.is-zoomable { cursor: crosshair; }
|
|
1597
|
-
.sv-grid-chart-svg.is-dragging { cursor: crosshair; user-select: none; }
|
|
1598
|
-
.sv-grid-chart-zoom-rect {
|
|
1599
|
-
fill: var(--sg-accent, #2563eb);
|
|
1600
|
-
fill-opacity: 0.12;
|
|
1601
|
-
stroke: var(--sg-accent, #2563eb);
|
|
1602
|
-
stroke-width: 1;
|
|
1603
|
-
stroke-dasharray: 3 3;
|
|
1604
|
-
pointer-events: none;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
.sv-grid-chart-tooltip {
|
|
1608
|
-
position: absolute;
|
|
1609
|
-
z-index: 10;
|
|
1610
|
-
transform: translate(-50%, calc(-100% - 12px));
|
|
1611
|
-
pointer-events: none;
|
|
1612
|
-
white-space: nowrap;
|
|
1613
|
-
padding: 6px 9px;
|
|
1614
|
-
border-radius: 6px;
|
|
1615
|
-
background: var(--sg-fg, #0f172a);
|
|
1616
|
-
color: var(--sg-bg, #fff);
|
|
1617
|
-
font-size: 11.5px;
|
|
1618
|
-
line-height: 1.4;
|
|
1619
|
-
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
|
|
1620
|
-
display: flex;
|
|
1621
|
-
flex-direction: column;
|
|
1622
|
-
gap: 1px;
|
|
1623
|
-
}
|
|
1624
|
-
.sv-grid-chart-tooltip.is-below {
|
|
1625
|
-
transform: translate(-50%, 16px);
|
|
1626
|
-
}
|
|
1627
|
-
.sv-grid-chart-tooltip-title {
|
|
1628
|
-
opacity: 0.75;
|
|
1629
|
-
font-size: 10.5px;
|
|
1630
|
-
margin-bottom: 1px;
|
|
1631
|
-
}
|
|
1632
|
-
.sv-grid-chart-tooltip-row {
|
|
1633
|
-
display: flex;
|
|
1634
|
-
align-items: center;
|
|
1635
|
-
gap: 6px;
|
|
1636
|
-
}
|
|
1637
|
-
.sv-grid-chart-tooltip-dot {
|
|
1638
|
-
width: 8px;
|
|
1639
|
-
height: 8px;
|
|
1640
|
-
border-radius: 2px;
|
|
1641
|
-
flex-shrink: 0;
|
|
1642
|
-
}
|
|
1643
|
-
.sv-grid-chart-tooltip-row-label {
|
|
1644
|
-
opacity: 0.85;
|
|
1645
|
-
}
|
|
1646
|
-
.sv-grid-chart-tooltip-row-value {
|
|
1647
|
-
margin-left: auto;
|
|
1648
|
-
padding-left: 10px;
|
|
1649
|
-
font-weight: 700;
|
|
1650
|
-
font-variant-numeric: tabular-nums;
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
.sv-grid-chart-legend {
|
|
1654
|
-
display: flex;
|
|
1655
|
-
flex-wrap: wrap;
|
|
1656
|
-
gap: 6px 12px;
|
|
1657
|
-
font-size: 12px;
|
|
1658
|
-
color: var(--sg-fg, #0f172a);
|
|
1659
|
-
}
|
|
1660
|
-
.sv-grid-chart-legend-item {
|
|
1661
|
-
display: inline-flex;
|
|
1662
|
-
align-items: center;
|
|
1663
|
-
gap: 6px;
|
|
1664
|
-
border: 0;
|
|
1665
|
-
background: transparent;
|
|
1666
|
-
color: inherit;
|
|
1667
|
-
font: inherit;
|
|
1668
|
-
padding: 1px 2px;
|
|
1669
|
-
border-radius: 4px;
|
|
1670
|
-
cursor: pointer;
|
|
1671
|
-
}
|
|
1672
|
-
.sv-grid-chart-legend-item:disabled {
|
|
1673
|
-
cursor: default;
|
|
1674
|
-
}
|
|
1675
|
-
.sv-grid-chart-legend-item.is-off {
|
|
1676
|
-
color: var(--sg-muted, #94a3b8);
|
|
1677
|
-
text-decoration: line-through;
|
|
1678
|
-
}
|
|
1679
|
-
.sv-grid-chart-legend-item.is-isolated {
|
|
1680
|
-
background: var(--sg-header-bg, #f1f5f9);
|
|
1681
|
-
font-weight: 700;
|
|
1682
|
-
}
|
|
1683
|
-
.sv-grid-chart-legend-more {
|
|
1684
|
-
border: 1px dashed var(--sg-border, #cbd5e1);
|
|
1685
|
-
background: transparent;
|
|
1686
|
-
color: var(--sg-muted, #64748b);
|
|
1687
|
-
font: inherit;
|
|
1688
|
-
font-size: 11px;
|
|
1689
|
-
padding: 1px 7px;
|
|
1690
|
-
border-radius: 999px;
|
|
1691
|
-
cursor: pointer;
|
|
1692
|
-
}
|
|
1693
|
-
.sv-grid-chart-legend-more:hover {
|
|
1694
|
-
color: var(--sg-fg, #0f172a);
|
|
1695
|
-
border-color: var(--sg-accent, #2563eb);
|
|
1696
|
-
}
|
|
1697
|
-
.sv-grid-chart-sr-only {
|
|
1698
|
-
position: absolute;
|
|
1699
|
-
width: 1px;
|
|
1700
|
-
height: 1px;
|
|
1701
|
-
padding: 0;
|
|
1702
|
-
margin: -1px;
|
|
1703
|
-
overflow: hidden;
|
|
1704
|
-
clip: rect(0 0 0 0);
|
|
1705
|
-
white-space: nowrap;
|
|
1706
|
-
border: 0;
|
|
1707
|
-
}
|
|
1708
|
-
.sv-grid-chart-swatch {
|
|
1709
|
-
width: 12px;
|
|
1710
|
-
height: 12px;
|
|
1711
|
-
border-radius: 3px;
|
|
1712
|
-
border: 1.5px solid transparent;
|
|
1713
|
-
display: inline-block;
|
|
1714
|
-
box-sizing: border-box;
|
|
1715
|
-
}
|
|
1716
|
-
</style>
|
|
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 { buildChart, DEFAULT_PALETTE, formatChartValue, type ChartSpec, type ChartSelection } from './chart'
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
spec: ChartSpec
|
|
14
|
+
/** Show the (clickable) legend. Default true. */
|
|
15
|
+
legend?: boolean
|
|
16
|
+
/** Enable tooltips + crosshair + legend toggling. Default true. */
|
|
17
|
+
interactive?: boolean
|
|
18
|
+
/** Draw the value on each bar / point / slice. Default false. */
|
|
19
|
+
dataLabels?: boolean
|
|
20
|
+
/** Format a value for tooltips, data labels, AND Y-axis ticks. */
|
|
21
|
+
formatValue?: (value: number) => string
|
|
22
|
+
/** Fired when a category / slice is clicked (drill into the grid). */
|
|
23
|
+
onSelect?: (selection: ChartSelection) => void
|
|
24
|
+
/** Like `onSelect`, but only fires when the spec carries `rowIds` and
|
|
25
|
+
* includes them in the payload. Use this to filter / highlight the
|
|
26
|
+
* source grid when the user clicks a chart element. */
|
|
27
|
+
onDrill?: (selection: ChartSelection) => void
|
|
28
|
+
/** Allow drag-to-zoom on the plot area. Double-click resets.
|
|
29
|
+
* Only meaningful for cartesian charts (not pie). Default false. */
|
|
30
|
+
zoomable?: boolean
|
|
31
|
+
/** Show a compact brush / mini-map under the main chart. A draggable
|
|
32
|
+
* window selects what the main chart shows. Pairs naturally with
|
|
33
|
+
* `zoomable`. Default false. */
|
|
34
|
+
brush?: boolean
|
|
35
|
+
/** Height of the brush strip in pixels. Default 88. */
|
|
36
|
+
brushHeight?: number
|
|
37
|
+
/** Show a small toolbar above the chart: reset-zoom (when zoomed) and
|
|
38
|
+
* export (PNG / SVG / copy). Default true when zoomable or onDrill is
|
|
39
|
+
* set, otherwise false. Set to `false` to hide explicitly. */
|
|
40
|
+
toolbar?: boolean
|
|
41
|
+
/** Explicit chart pixel size (viewBox). When set, the chart lays out to
|
|
42
|
+
* fit exactly - used by the docked panel to size the chart to its body. */
|
|
43
|
+
width?: number
|
|
44
|
+
height?: number
|
|
45
|
+
}
|
|
46
|
+
let {
|
|
47
|
+
spec,
|
|
48
|
+
legend = true,
|
|
49
|
+
interactive = true,
|
|
50
|
+
dataLabels = false,
|
|
51
|
+
formatValue,
|
|
52
|
+
onSelect,
|
|
53
|
+
onDrill,
|
|
54
|
+
zoomable = false,
|
|
55
|
+
brush = false,
|
|
56
|
+
brushHeight = 88,
|
|
57
|
+
toolbar,
|
|
58
|
+
width,
|
|
59
|
+
height,
|
|
60
|
+
}: Props = $props()
|
|
61
|
+
const showToolbar = $derived(toolbar ?? (zoomable || !!onDrill))
|
|
62
|
+
|
|
63
|
+
const fmt = (v: number) =>
|
|
64
|
+
formatValue
|
|
65
|
+
? formatValue(v)
|
|
66
|
+
: spec.valueFormat
|
|
67
|
+
? formatChartValue(v, spec.valueFormat)
|
|
68
|
+
: Number.isFinite(v)
|
|
69
|
+
? v.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
70
|
+
: String(v)
|
|
71
|
+
|
|
72
|
+
let hidden = $state(new Set<string>())
|
|
73
|
+
// Isolate: double-clicking a legend chip shows ONLY that series/slice.
|
|
74
|
+
let isolated = $state<string | null>(null)
|
|
75
|
+
// Hover-dim: hovering a legend chip dims the other series (visual only).
|
|
76
|
+
let dimmed = $state<string | null>(null)
|
|
77
|
+
const palette = $derived(spec.palette ?? DEFAULT_PALETTE)
|
|
78
|
+
const uid = `svgc-${Math.random().toString(36).slice(2, 8)}`
|
|
79
|
+
|
|
80
|
+
const coloredSeries = $derived(
|
|
81
|
+
spec.series.map((s, i) => ({ ...s, color: s.color ?? palette[i % palette.length]! })),
|
|
82
|
+
)
|
|
83
|
+
/** Resolve effective pattern per series: explicit > cycle (when
|
|
84
|
+
* patternFallback is on) > 'solid'. Cycle skips 'solid' so every series
|
|
85
|
+
* ends up with a distinct texture. */
|
|
86
|
+
const seriesPatterns = $derived(
|
|
87
|
+
spec.series.map((s, i): string => {
|
|
88
|
+
if (s.pattern) return s.pattern
|
|
89
|
+
if (spec.patternFallback) return ['stripe', 'crosshatch', 'dots', 'diagonal'][i % 4]!
|
|
90
|
+
return 'solid'
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
/** Map from series label to fill string used in SVG (either the plain
|
|
94
|
+
* color or `url(#pattern-id)`). */
|
|
95
|
+
const seriesFill = $derived.by<Record<string, string>>(() => {
|
|
96
|
+
const out: Record<string, string> = {}
|
|
97
|
+
coloredSeries.forEach((s, i) => {
|
|
98
|
+
const p = seriesPatterns[i]
|
|
99
|
+
out[s.label] = p === 'solid' || !p ? s.color : `url(#${uid}-pat-${i})`
|
|
100
|
+
})
|
|
101
|
+
return out
|
|
102
|
+
})
|
|
103
|
+
/** Pattern <defs> to emit. Each entry pairs a unique id with the series
|
|
104
|
+
* color so the pattern carries the right hue baked in. */
|
|
105
|
+
const patternDefs = $derived(
|
|
106
|
+
coloredSeries
|
|
107
|
+
.map((s, i) => ({ id: `${uid}-pat-${i}`, color: s.color, kind: seriesPatterns[i] ?? 'solid' }))
|
|
108
|
+
.filter((d) => d.kind !== 'solid'),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// Effective hidden set = manual toggles, unless a series is isolated (then
|
|
112
|
+
// everything else is hidden).
|
|
113
|
+
const effectiveHidden = $derived.by(() => {
|
|
114
|
+
if (isolated == null) return hidden
|
|
115
|
+
const labels = spec.type === 'pie' ? spec.categories : spec.series.map((s) => s.label)
|
|
116
|
+
return new Set(labels.filter((l) => l !== isolated))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// ---- Zoom state ------------------------------------------------------
|
|
120
|
+
// `zoom` holds inclusive integer category indices [i0, i1] of the visible
|
|
121
|
+
// window. null = no zoom (show everything). Pie charts don't support
|
|
122
|
+
// zoom; their pseudo-categories are slices, not an axis.
|
|
123
|
+
let zoom = $state<{ i0: number; i1: number } | null>(null)
|
|
124
|
+
const isZoomed = $derived(zoom !== null)
|
|
125
|
+
function resetZoom() { zoom = null }
|
|
126
|
+
|
|
127
|
+
// Drag-to-zoom: track pointer in SVG-local coordinates so the overlay
|
|
128
|
+
// rect aligns with what the user is actually selecting on screen.
|
|
129
|
+
let svgEl: SVGSVGElement | null = $state(null)
|
|
130
|
+
let dragStart = $state<{ x: number; y: number } | null>(null)
|
|
131
|
+
let dragEnd = $state<{ x: number; y: number } | null>(null)
|
|
132
|
+
const dragRect = $derived.by(() => {
|
|
133
|
+
if (!dragStart || !dragEnd) return null
|
|
134
|
+
const x = Math.min(dragStart.x, dragEnd.x)
|
|
135
|
+
const y = Math.min(dragStart.y, dragEnd.y)
|
|
136
|
+
const w = Math.abs(dragEnd.x - dragStart.x)
|
|
137
|
+
const h = Math.abs(dragEnd.y - dragStart.y)
|
|
138
|
+
return w > 4 && h > 4 ? { x, y, w, h } : null
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
function svgPoint(e: PointerEvent): { x: number; y: number } | null {
|
|
142
|
+
if (!svgEl) return null
|
|
143
|
+
const r = svgEl.getBoundingClientRect()
|
|
144
|
+
return {
|
|
145
|
+
x: ((e.clientX - r.left) / r.width) * geo.width,
|
|
146
|
+
y: ((e.clientY - r.top) / r.height) * geo.height,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Clamp a point's main-axis coordinate to an integer category index. */
|
|
150
|
+
function pointToCatIndex(p: { x: number; y: number }): number {
|
|
151
|
+
const coord = isHorizontal ? (p.y - geo.plot.y) : (p.x - geo.plot.x)
|
|
152
|
+
const idx = Math.floor(coord / slot)
|
|
153
|
+
return Math.max(0, Math.min(visibleSpec.categories.length - 1, idx))
|
|
154
|
+
}
|
|
155
|
+
function onZoomDown(e: PointerEvent) {
|
|
156
|
+
if (!zoomable || !isCartesian) return
|
|
157
|
+
const p = svgPoint(e)
|
|
158
|
+
if (!p) return
|
|
159
|
+
// Only start a drag inside the plot rect.
|
|
160
|
+
if (p.x < geo.plot.x || p.x > geo.plot.x + geo.plot.w ||
|
|
161
|
+
p.y < geo.plot.y || p.y > geo.plot.y + geo.plot.h) return
|
|
162
|
+
dragStart = p
|
|
163
|
+
dragEnd = p
|
|
164
|
+
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
|
165
|
+
}
|
|
166
|
+
function onZoomMove(e: PointerEvent) {
|
|
167
|
+
if (!dragStart) return
|
|
168
|
+
dragEnd = svgPoint(e)
|
|
169
|
+
}
|
|
170
|
+
function onZoomUp() {
|
|
171
|
+
if (!dragStart || !dragEnd) { dragStart = null; dragEnd = null; return }
|
|
172
|
+
const r = dragRect
|
|
173
|
+
if (r) {
|
|
174
|
+
// Map the drag's main-axis bounds to category indices. When already
|
|
175
|
+
// zoomed, these indices are relative to the visible slice, so
|
|
176
|
+
// translate back to the original `spec.categories` indexing.
|
|
177
|
+
const i0v = pointToCatIndex({ x: r.x, y: r.y })
|
|
178
|
+
const i1v = pointToCatIndex({ x: r.x + r.w, y: r.y + r.h })
|
|
179
|
+
const offset = zoom ? zoom.i0 : 0
|
|
180
|
+
const lo = offset + Math.min(i0v, i1v)
|
|
181
|
+
const hi = offset + Math.max(i0v, i1v)
|
|
182
|
+
if (hi > lo) zoom = { i0: lo, i1: hi }
|
|
183
|
+
}
|
|
184
|
+
dragStart = null
|
|
185
|
+
dragEnd = null
|
|
186
|
+
}
|
|
187
|
+
function onPlotDblClick() {
|
|
188
|
+
if (zoomable && isZoomed) resetZoom()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- Brush state -----------------------------------------------------
|
|
192
|
+
// The brush is a compact second chart showing the FULL data range with
|
|
193
|
+
// a translucent window over the visible slice. Dragging the body pans
|
|
194
|
+
// the window; dragging either edge resizes one side. Eligible for
|
|
195
|
+
// cartesian charts only (heatmap / pie don't have a single x-axis).
|
|
196
|
+
const brushEligible = $derived(brush && spec.type !== 'pie' && spec.type !== 'heatmap')
|
|
197
|
+
// Build a separate geometry from the un-zoomed spec at brushHeight.
|
|
198
|
+
const brushSpec = $derived<ChartSpec>({
|
|
199
|
+
...spec,
|
|
200
|
+
height: brushHeight,
|
|
201
|
+
// Mute axes in the brush by clearing titles + reference lines.
|
|
202
|
+
yAxisTitle: undefined, y2AxisTitle: undefined, xAxisTitle: undefined,
|
|
203
|
+
referenceLines: undefined,
|
|
204
|
+
annotations: undefined,
|
|
205
|
+
series: coloredSeries.filter((s) => !effectiveHidden.has(s.label)),
|
|
206
|
+
})
|
|
207
|
+
const brushGeo = $derived(brushEligible ? buildChart(brushSpec) : null)
|
|
208
|
+
/** The brush window in fractional [0..1] of the visible data range,
|
|
209
|
+
* derived from `zoom`. When not zoomed, the window covers everything. */
|
|
210
|
+
const brushWindow = $derived.by(() => {
|
|
211
|
+
const n = spec.categories.length
|
|
212
|
+
if (!zoom || n === 0) return { t0: 0, t1: 1 }
|
|
213
|
+
return { t0: zoom.i0 / n, t1: (zoom.i1 + 1) / n }
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
type BrushDrag ={ mode: 'move' | 'left' | 'right'; startX: number; startT0: number; startT1: number }
|
|
217
|
+
let brushDrag = $state<BrushDrag | null>(null)
|
|
218
|
+
function brushSvgX(e: PointerEvent, svg: SVGSVGElement): number {
|
|
219
|
+
const r = svg.getBoundingClientRect()
|
|
220
|
+
if (!brushGeo) return 0
|
|
221
|
+
return ((e.clientX - r.left) / r.width) * brushGeo.width
|
|
222
|
+
}
|
|
223
|
+
function onBrushDown(e: PointerEvent, mode: BrushDrag['mode']) {
|
|
224
|
+
if (!brushGeo) return
|
|
225
|
+
const svg = e.currentTarget as SVGSVGElement
|
|
226
|
+
const x = brushSvgX(e, svg.ownerSVGElement ?? svg)
|
|
227
|
+
brushDrag = { mode, startX: x, startT0: brushWindow.t0, startT1: brushWindow.t1 }
|
|
228
|
+
;(e.currentTarget as Element).setPointerCapture?.(e.pointerId)
|
|
229
|
+
e.stopPropagation()
|
|
230
|
+
}
|
|
231
|
+
function onBrushMove(e: PointerEvent) {
|
|
232
|
+
if (!brushDrag || !brushGeo) return
|
|
233
|
+
const svg = (e.currentTarget as SVGSVGElement).ownerSVGElement ?? (e.currentTarget as SVGSVGElement)
|
|
234
|
+
const x = brushSvgX(e, svg)
|
|
235
|
+
const dx = x - brushDrag.startX
|
|
236
|
+
const tw = brushGeo.plot.w
|
|
237
|
+
const dt = dx / tw
|
|
238
|
+
const n = spec.categories.length
|
|
239
|
+
if (n === 0) return
|
|
240
|
+
let t0 = brushDrag.startT0
|
|
241
|
+
let t1 = brushDrag.startT1
|
|
242
|
+
if (brushDrag.mode === 'move') {
|
|
243
|
+
const w = t1 - t0
|
|
244
|
+
t0 = Math.max(0, Math.min(1 - w, t0 + dt))
|
|
245
|
+
t1 = t0 + w
|
|
246
|
+
} else if (brushDrag.mode === 'left') {
|
|
247
|
+
// Keep at least 1 category visible.
|
|
248
|
+
t0 = Math.max(0, Math.min(t1 - 1 / n, t0 + dt))
|
|
249
|
+
} else {
|
|
250
|
+
t1 = Math.min(1, Math.max(t0 + 1 / n, t1 + dt))
|
|
251
|
+
}
|
|
252
|
+
// Translate back to integer category indices.
|
|
253
|
+
const i0 = Math.max(0, Math.floor(t0 * n))
|
|
254
|
+
const i1 = Math.min(n - 1, Math.ceil(t1 * n) - 1)
|
|
255
|
+
if (i0 === 0 && i1 === n - 1) zoom = null
|
|
256
|
+
else zoom = { i0, i1: Math.max(i0, i1) }
|
|
257
|
+
}
|
|
258
|
+
function onBrushUp(e: PointerEvent) {
|
|
259
|
+
brushDrag = null
|
|
260
|
+
;(e.currentTarget as Element).releasePointerCapture?.(e.pointerId)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const visibleSpec = $derived.by<ChartSpec>(() => {
|
|
264
|
+
if (spec.type === 'pie') {
|
|
265
|
+
const s = coloredSeries[0]
|
|
266
|
+
if (!s) return spec
|
|
267
|
+
const values = s.values.map((v, i) => (effectiveHidden.has(spec.categories[i] ?? String(i)) ? 0 : v))
|
|
268
|
+
return { ...spec, series: [{ ...s, values }] }
|
|
269
|
+
}
|
|
270
|
+
const visibleSeries = coloredSeries.filter((s) => !effectiveHidden.has(s.label))
|
|
271
|
+
if (!zoom) return { ...spec, series: visibleSeries }
|
|
272
|
+
// Slice categories + each series' values to the zoom window so
|
|
273
|
+
// buildChart re-spreads the visible slice across the full plot.
|
|
274
|
+
const lo = Math.max(0, zoom.i0)
|
|
275
|
+
const hi = Math.min(spec.categories.length - 1, zoom.i1)
|
|
276
|
+
const slice = <T,>(arr: T[]) => arr.slice(lo, hi + 1)
|
|
277
|
+
return {
|
|
278
|
+
...spec,
|
|
279
|
+
categories: slice(spec.categories),
|
|
280
|
+
series: visibleSeries.map((s) => ({
|
|
281
|
+
...s,
|
|
282
|
+
values: slice(s.values),
|
|
283
|
+
rowIds: s.rowIds ? slice(s.rowIds) : undefined,
|
|
284
|
+
})),
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// Heatmap / calendar cell fills are computed colors (not CSS), so they can't
|
|
289
|
+
// inherit the theme. We detect the surface luminance (see the $effect near
|
|
290
|
+
// chartEl) and hand buildChart a light/dark hint so low-value cells render as
|
|
291
|
+
// "cold" rather than glaring white rectangles on a dark grid.
|
|
292
|
+
let isDark = $state(false)
|
|
293
|
+
const geo = $derived(
|
|
294
|
+
buildChart(width || height ? { ...visibleSpec, width, height } : visibleSpec, isDark ? 'dark' : 'light'),
|
|
295
|
+
)
|
|
296
|
+
const isCartesian = $derived(
|
|
297
|
+
spec.type !== 'pie' && spec.type !== 'heatmap' &&
|
|
298
|
+
spec.type !== 'funnel' && spec.type !== 'radar' &&
|
|
299
|
+
spec.type !== 'calendar' && spec.type !== 'gauge' &&
|
|
300
|
+
spec.type !== 'treemap' && spec.type !== 'sankey',
|
|
301
|
+
)
|
|
302
|
+
const isScatter = $derived(spec.type === 'scatter')
|
|
303
|
+
const isHeatmap = $derived(spec.type === 'heatmap')
|
|
304
|
+
const isFunnel = $derived(spec.type === 'funnel')
|
|
305
|
+
const isRadar = $derived(spec.type === 'radar')
|
|
306
|
+
const isCalendar = $derived(spec.type === 'calendar')
|
|
307
|
+
const isGauge = $derived(spec.type === 'gauge')
|
|
308
|
+
const isTreemap = $derived(spec.type === 'treemap')
|
|
309
|
+
const isSankey = $derived(spec.type === 'sankey')
|
|
310
|
+
const isHorizontal = $derived(geo.orientation === 'horizontal')
|
|
311
|
+
// Per-category band size along the main (category) axis: width when vertical,
|
|
312
|
+
// height when horizontal.
|
|
313
|
+
const slot = $derived(
|
|
314
|
+
(isHorizontal ? geo.plot.h : geo.plot.w) / Math.max(1, visibleSpec.categories.length),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
// Opacity for a series when another legend chip is being hovered.
|
|
318
|
+
const dimOf = (seriesLabel: string) => (dimmed && dimmed !== seriesLabel ? 0.18 : 1)
|
|
319
|
+
|
|
320
|
+
const legendItems = $derived(
|
|
321
|
+
spec.type === 'pie'
|
|
322
|
+
? spec.categories.map((label, i) => ({ label, color: palette[i % palette.length]!, off: hidden.has(label) }))
|
|
323
|
+
: coloredSeries.map((s) => ({ label: s.label, color: s.color, off: hidden.has(s.label) })),
|
|
324
|
+
)
|
|
325
|
+
// Legend overflow: collapse to the first N chips with a "+N more" toggle so a
|
|
326
|
+
// wide pivot (many series) doesn't flood the chart with legend rows.
|
|
327
|
+
const LEGEND_MAX = 10
|
|
328
|
+
let legendExpanded = $state(false)
|
|
329
|
+
const legendOverflow = $derived(legendItems.length > LEGEND_MAX)
|
|
330
|
+
const shownLegend = $derived(
|
|
331
|
+
legendOverflow && !legendExpanded ? legendItems.slice(0, LEGEND_MAX) : legendItems,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
const isEmpty = $derived.by(() => {
|
|
335
|
+
if (spec.type === 'pie') return geo.slices.every((s) => s.value <= 0)
|
|
336
|
+
if (spec.type === 'scatter') return geo.scatterPoints.length === 0
|
|
337
|
+
if (spec.type === 'heatmap') return geo.heatmapCells.length === 0
|
|
338
|
+
if (spec.type === 'funnel') return geo.funnelSegments.length === 0
|
|
339
|
+
if (spec.type === 'radar') return geo.radarSeries.length === 0
|
|
340
|
+
if (spec.type === 'calendar') return geo.calendarCells.length === 0
|
|
341
|
+
if (spec.type === 'gauge') return !geo.gauge
|
|
342
|
+
if (spec.type === 'treemap') return geo.treemapCells.length === 0
|
|
343
|
+
if (spec.type === 'sankey') return geo.sankeyNodes.length === 0
|
|
344
|
+
return geo.bars.length === 0 && geo.lines.every((l) => l.points.every((p) => !p.defined))
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
function toggle(label: string) {
|
|
348
|
+
if (!interactive) return
|
|
349
|
+
if (isolated != null) { isolated = null; return }
|
|
350
|
+
const next = new Set(hidden)
|
|
351
|
+
if (next.has(label)) next.delete(label)
|
|
352
|
+
else next.add(label)
|
|
353
|
+
hidden = next
|
|
354
|
+
}
|
|
355
|
+
// Double-click a legend chip to isolate it (show only that one); double-click
|
|
356
|
+
// again (or the same chip) to clear the isolation.
|
|
357
|
+
function isolate(label: string) {
|
|
358
|
+
if (!interactive) return
|
|
359
|
+
isolated = isolated === label ? null : label
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---- Screen-reader data table -----------------------------------------
|
|
363
|
+
// A visually-hidden table that conveys the same data to assistive tech.
|
|
364
|
+
const srTable = $derived.by(() => {
|
|
365
|
+
if (spec.type === 'pie') {
|
|
366
|
+
const s = coloredSeries[0]
|
|
367
|
+
return {
|
|
368
|
+
cols: ['Category', 'Value'],
|
|
369
|
+
rows: spec.categories.map((c, i) => [c, fmt(s?.values[i] ?? 0)]),
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (isScatter) {
|
|
373
|
+
const rows: string[][] = []
|
|
374
|
+
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) : ''])
|
|
375
|
+
return { cols: ['Series', 'X', 'Y', 'Size'], rows }
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
cols: ['Category', ...coloredSeries.map((s) => s.label)],
|
|
379
|
+
rows: spec.categories.map((c, i) => [c, ...coloredSeries.map((s) => fmt(s.values[i] ?? 0))]),
|
|
380
|
+
}
|
|
381
|
+
})
|
|
382
|
+
function truncate(s: string, n = 12): string {
|
|
383
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s
|
|
384
|
+
}
|
|
385
|
+
const yTickLabel = (value: number, fallback: string) => (formatValue ? formatValue(value) : fallback)
|
|
386
|
+
|
|
387
|
+
// ---- Tooltip + crosshair ----------------------------------------------
|
|
388
|
+
let chartEl: HTMLElement | null = $state(null)
|
|
389
|
+
|
|
390
|
+
// Resolve whether the chart sits on a dark surface by reading the --sg-bg
|
|
391
|
+
// token's luminance. Re-checks on theme toggles (data-theme / class / style
|
|
392
|
+
// changes on the document root) so the heatmap ramp follows light<->dark.
|
|
393
|
+
function surfaceIsDark(color: string): boolean {
|
|
394
|
+
if (!color) return false
|
|
395
|
+
let r = 0, g = 0, b = 0
|
|
396
|
+
const hex = color.trim().startsWith('#') ? color.trim().slice(1) : ''
|
|
397
|
+
if (hex.length === 6) {
|
|
398
|
+
r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16)
|
|
399
|
+
} else {
|
|
400
|
+
const m = /rgba?\(([^)]+)\)/.exec(color)
|
|
401
|
+
if (!m) return false
|
|
402
|
+
const p = m[1]!.split(',').map((s) => parseFloat(s))
|
|
403
|
+
r = p[0] ?? 0; g = p[1] ?? 0; b = p[2] ?? 0
|
|
404
|
+
}
|
|
405
|
+
// Perceived luminance (0..255); below the midpoint reads as a dark surface.
|
|
406
|
+
return 0.299 * r + 0.587 * g + 0.114 * b < 128
|
|
407
|
+
}
|
|
408
|
+
$effect(() => {
|
|
409
|
+
if (!chartEl || typeof window === 'undefined') return
|
|
410
|
+
const el = chartEl
|
|
411
|
+
const check = () => { isDark = surfaceIsDark(getComputedStyle(el).getPropertyValue('--sg-bg')) }
|
|
412
|
+
check()
|
|
413
|
+
const obs = new MutationObserver(check)
|
|
414
|
+
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] })
|
|
415
|
+
return () => obs.disconnect()
|
|
416
|
+
})
|
|
417
|
+
type TipRow = { label?: string; color?: string; value: string }
|
|
418
|
+
let tip = $state<{ left: number; top: number; below: boolean; title: string; rows: TipRow[] } | null>(null)
|
|
419
|
+
let activeCat = $state<number | null>(null)
|
|
420
|
+
|
|
421
|
+
function showTip(clientX: number, clientY: number, title: string, rows: TipRow[]) {
|
|
422
|
+
if (!interactive || !chartEl || rows.length === 0) return
|
|
423
|
+
const r = chartEl.getBoundingClientRect()
|
|
424
|
+
const left = Math.max(70, Math.min(r.width - 70, clientX - r.left))
|
|
425
|
+
const top = clientY - r.top
|
|
426
|
+
tip = { left, top, below: top < 44, title, rows }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Unified tooltip: every visible series' value at one category.
|
|
430
|
+
function catRows(i: number): TipRow[] {
|
|
431
|
+
return visibleSpec.series
|
|
432
|
+
.map((s) => ({ label: s.label, color: (s as { color?: string }).color, value: s.values[i] }))
|
|
433
|
+
.filter((r) => Number.isFinite(r.value))
|
|
434
|
+
.map((r) => ({ label: r.label, color: r.color, value: fmt(r.value as number) }))
|
|
435
|
+
}
|
|
436
|
+
function hoverCat(clientX: number, clientY: number, i: number) {
|
|
437
|
+
activeCat = i
|
|
438
|
+
showTip(clientX, clientY, visibleSpec.categories[i] ?? '', catRows(i))
|
|
439
|
+
}
|
|
440
|
+
function focusCat(el: Element, i: number) {
|
|
441
|
+
const b = el.getBoundingClientRect()
|
|
442
|
+
hoverCat(b.left + b.width / 2, b.top + 12, i)
|
|
443
|
+
}
|
|
444
|
+
function clearActive() {
|
|
445
|
+
activeCat = null
|
|
446
|
+
tip = null
|
|
447
|
+
}
|
|
448
|
+
function hoverSlice(clientX: number, clientY: number, label: string, value: number, pct: number) {
|
|
449
|
+
showTip(clientX, clientY, label, [{ value: `${fmt(value)} · ${pct.toFixed(1)}%` }])
|
|
450
|
+
}
|
|
451
|
+
function hoverDot(clientX: number, clientY: number, d: (typeof geo.scatterPoints)[number]) {
|
|
452
|
+
const rows: TipRow[] = [
|
|
453
|
+
{ label: 'x', value: fmt(d.x) },
|
|
454
|
+
{ label: 'y', value: fmt(d.y) },
|
|
455
|
+
]
|
|
456
|
+
showTip(clientX, clientY, d.label || d.series, rows)
|
|
457
|
+
}
|
|
458
|
+
function hoverCell(clientX: number, clientY: number, cell: (typeof geo.heatmapCells)[number]) {
|
|
459
|
+
showTip(clientX, clientY, `${cell.rowLabel} · ${cell.colLabel}`, [
|
|
460
|
+
{ color: cell.color, value: fmt(cell.value) },
|
|
461
|
+
])
|
|
462
|
+
}
|
|
463
|
+
function hoverFunnel(clientX: number, clientY: number, seg: (typeof geo.funnelSegments)[number]) {
|
|
464
|
+
const rows: TipRow[] = [
|
|
465
|
+
{ value: fmt(seg.value) },
|
|
466
|
+
{ label: 'conversion', value: `${(seg.conversion * 100).toFixed(1)}%` },
|
|
467
|
+
]
|
|
468
|
+
if (seg.dropoff > 0) rows.push({ label: 'drop-off', value: `${(seg.dropoff * 100).toFixed(1)}%` })
|
|
469
|
+
showTip(clientX, clientY, seg.label, rows)
|
|
470
|
+
}
|
|
471
|
+
function hoverRadarPoint(clientX: number, clientY: number, label: string, axis: string, value: number, color: string) {
|
|
472
|
+
showTip(clientX, clientY, `${label} · ${axis}`, [{ color, value: fmt(value) }])
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function select(category: string, series: string, value: number, catIndex?: number) {
|
|
476
|
+
// Collect contributing row ids when the spec carries them. For a
|
|
477
|
+
// category click (series === ''), flatten across every series; for a
|
|
478
|
+
// single-series click, return just that series' bucket.
|
|
479
|
+
let rowIds: Array<string | number> | undefined
|
|
480
|
+
if (catIndex !== undefined) {
|
|
481
|
+
const buckets: Array<Array<string | number>> = []
|
|
482
|
+
for (const s of visibleSpec.series) {
|
|
483
|
+
if (!s.rowIds) continue
|
|
484
|
+
if (series && s.label !== series) continue
|
|
485
|
+
const ids = s.rowIds[catIndex]
|
|
486
|
+
if (ids?.length) buckets.push(ids)
|
|
487
|
+
}
|
|
488
|
+
if (buckets.length) rowIds = buckets.flat()
|
|
489
|
+
}
|
|
490
|
+
const sel: ChartSelection = { category, series, value, rowIds }
|
|
491
|
+
onSelect?.(sel)
|
|
492
|
+
if (rowIds && rowIds.length) onDrill?.(sel)
|
|
493
|
+
}
|
|
494
|
+
/** Move keyboard focus to the cat-hit rect at index `next`, snapping
|
|
495
|
+
* back to the source element via the same `data-cat-index` attribute. */
|
|
496
|
+
function focusCatIndex(source: Element, next: number) {
|
|
497
|
+
const root = (source as SVGElement).ownerSVGElement ?? source.closest('svg')
|
|
498
|
+
if (!root) return
|
|
499
|
+
const target = root.querySelector<SVGElement>(`[data-cat-index="${next}"]`)
|
|
500
|
+
if (!target) return
|
|
501
|
+
target.focus?.()
|
|
502
|
+
// Mirror the tooltip / crosshair to the new focused category.
|
|
503
|
+
focusCat(target, next)
|
|
504
|
+
}
|
|
505
|
+
function onCatKey(e: KeyboardEvent, i: number) {
|
|
506
|
+
const max = visibleSpec.categories.length - 1
|
|
507
|
+
const src = e.currentTarget as Element
|
|
508
|
+
// Arrow keys + Home / End navigate; Enter / Space select. PageUp/Down
|
|
509
|
+
// jump a tenth of the range at a time for long datasets.
|
|
510
|
+
switch (e.key) {
|
|
511
|
+
case 'ArrowRight':
|
|
512
|
+
case 'ArrowDown':
|
|
513
|
+
e.preventDefault(); focusCatIndex(src, Math.min(max, i + 1)); return
|
|
514
|
+
case 'ArrowLeft':
|
|
515
|
+
case 'ArrowUp':
|
|
516
|
+
e.preventDefault(); focusCatIndex(src, Math.max(0, i - 1)); return
|
|
517
|
+
case 'Home':
|
|
518
|
+
e.preventDefault(); focusCatIndex(src, 0); return
|
|
519
|
+
case 'End':
|
|
520
|
+
e.preventDefault(); focusCatIndex(src, max); return
|
|
521
|
+
case 'PageDown':
|
|
522
|
+
e.preventDefault(); focusCatIndex(src, Math.min(max, i + Math.max(1, Math.floor(max / 10)))); return
|
|
523
|
+
case 'PageUp':
|
|
524
|
+
e.preventDefault(); focusCatIndex(src, Math.max(0, i - Math.max(1, Math.floor(max / 10)))); return
|
|
525
|
+
case 'Enter':
|
|
526
|
+
case ' ':
|
|
527
|
+
if (!onSelect) return
|
|
528
|
+
e.preventDefault()
|
|
529
|
+
select(visibleSpec.categories[i] ?? '', '', 0, i)
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function onSliceKey(e: KeyboardEvent, label: string, value: number) {
|
|
534
|
+
if (!onSelect) return
|
|
535
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
536
|
+
e.preventDefault()
|
|
537
|
+
select(label, label, value)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---- Export ----------------------------------------------------------
|
|
542
|
+
// PNG / SVG download + copy-as-image. Serialize the live SVG element so
|
|
543
|
+
// the exported file matches the user's current zoom / visibility state.
|
|
544
|
+
let copyStatus = $state<'idle' | 'ok' | 'err'>('idle')
|
|
545
|
+
function serializeSvg(): string | null {
|
|
546
|
+
if (!svgEl) return null
|
|
547
|
+
const clone = svgEl.cloneNode(true) as SVGSVGElement
|
|
548
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
549
|
+
// Drop our interaction-only classes from the export.
|
|
550
|
+
clone.removeAttribute('class')
|
|
551
|
+
// Inline the computed sizes so the standalone SVG renders at the
|
|
552
|
+
// same pixel dimensions when opened directly.
|
|
553
|
+
const rect = svgEl.getBoundingClientRect()
|
|
554
|
+
if (rect.width) clone.setAttribute('width', String(Math.round(rect.width)))
|
|
555
|
+
if (rect.height) clone.setAttribute('height', String(Math.round(rect.height)))
|
|
556
|
+
// Pull the chart's stylesheet so the standalone SVG is self-contained.
|
|
557
|
+
const styleTag = document.createElement('style')
|
|
558
|
+
const sheets = Array.from(document.styleSheets)
|
|
559
|
+
const rules: string[] = []
|
|
560
|
+
for (const sheet of sheets) {
|
|
561
|
+
try {
|
|
562
|
+
for (const rule of Array.from(sheet.cssRules ?? [])) {
|
|
563
|
+
const t = rule.cssText
|
|
564
|
+
if (t.includes('.sv-grid-chart')) rules.push(t)
|
|
565
|
+
}
|
|
566
|
+
} catch { /* cross-origin sheets - skip */ }
|
|
567
|
+
}
|
|
568
|
+
styleTag.textContent = rules.join('\n')
|
|
569
|
+
clone.insertBefore(styleTag, clone.firstChild)
|
|
570
|
+
return new XMLSerializer().serializeToString(clone)
|
|
571
|
+
}
|
|
572
|
+
function triggerDownload(blob: Blob, filename: string) {
|
|
573
|
+
const url = URL.createObjectURL(blob)
|
|
574
|
+
const a = document.createElement('a')
|
|
575
|
+
a.href = url
|
|
576
|
+
a.download = filename
|
|
577
|
+
document.body.appendChild(a)
|
|
578
|
+
a.click()
|
|
579
|
+
a.remove()
|
|
580
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
581
|
+
}
|
|
582
|
+
async function rasterizeSvg(svgText: string): Promise<Blob | null> {
|
|
583
|
+
return new Promise((resolve) => {
|
|
584
|
+
const rect = svgEl?.getBoundingClientRect()
|
|
585
|
+
const w = Math.max(1, Math.round(rect?.width ?? geo.width))
|
|
586
|
+
const h = Math.max(1, Math.round(rect?.height ?? geo.height))
|
|
587
|
+
const dpr = window.devicePixelRatio || 1
|
|
588
|
+
const canvas = document.createElement('canvas')
|
|
589
|
+
canvas.width = w * dpr
|
|
590
|
+
canvas.height = h * dpr
|
|
591
|
+
const ctx = canvas.getContext('2d')
|
|
592
|
+
if (!ctx) { resolve(null); return }
|
|
593
|
+
const img = new Image()
|
|
594
|
+
img.onload = () => {
|
|
595
|
+
ctx.scale(dpr, dpr)
|
|
596
|
+
ctx.drawImage(img, 0, 0, w, h)
|
|
597
|
+
canvas.toBlob((b) => resolve(b), 'image/png')
|
|
598
|
+
}
|
|
599
|
+
img.onerror = () => resolve(null)
|
|
600
|
+
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText)
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
async function downloadImage(format: 'png' | 'svg') {
|
|
604
|
+
const text = serializeSvg()
|
|
605
|
+
if (!text) return
|
|
606
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
607
|
+
if (format === 'svg') {
|
|
608
|
+
triggerDownload(new Blob([text], { type: 'image/svg+xml' }), `chart-${stamp}.svg`)
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
const png = await rasterizeSvg(text)
|
|
612
|
+
if (png) triggerDownload(png, `chart-${stamp}.png`)
|
|
613
|
+
}
|
|
614
|
+
async function copyAsImage() {
|
|
615
|
+
const text = serializeSvg()
|
|
616
|
+
if (!text) { copyStatus = 'err'; return }
|
|
617
|
+
try {
|
|
618
|
+
const png = await rasterizeSvg(text)
|
|
619
|
+
if (!png) throw new Error('rasterize failed')
|
|
620
|
+
const Clip = (window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
|
|
621
|
+
if (Clip && navigator.clipboard?.write) {
|
|
622
|
+
await navigator.clipboard.write([new Clip({ 'image/png': png })])
|
|
623
|
+
} else {
|
|
624
|
+
// Fallback: copy SVG markup as text so the user can paste somewhere.
|
|
625
|
+
await navigator.clipboard.writeText(text)
|
|
626
|
+
}
|
|
627
|
+
copyStatus = 'ok'
|
|
628
|
+
} catch {
|
|
629
|
+
copyStatus = 'err'
|
|
630
|
+
}
|
|
631
|
+
setTimeout(() => (copyStatus = 'idle'), 1500)
|
|
632
|
+
}
|
|
633
|
+
</script>
|
|
634
|
+
|
|
635
|
+
<div class="sv-grid-chart" bind:this={chartEl}>
|
|
636
|
+
{#if showToolbar}
|
|
637
|
+
<div class="sv-grid-chart-toolbar">
|
|
638
|
+
{#if zoomable && isZoomed}
|
|
639
|
+
<button type="button" class="sv-grid-chart-tool" onclick={resetZoom} title="Reset zoom (or double-click the plot)">
|
|
640
|
+
<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>
|
|
641
|
+
Reset zoom
|
|
642
|
+
</button>
|
|
643
|
+
{/if}
|
|
644
|
+
<button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('png')} title="Download PNG">PNG</button>
|
|
645
|
+
<button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('svg')} title="Download SVG">SVG</button>
|
|
646
|
+
<button type="button" class="sv-grid-chart-tool" onclick={copyAsImage} title="Copy chart as image">
|
|
647
|
+
{copyStatus === 'ok' ? '✓ Copied' : 'Copy'}
|
|
648
|
+
</button>
|
|
649
|
+
</div>
|
|
650
|
+
{/if}
|
|
651
|
+
<svg
|
|
652
|
+
bind:this={svgEl}
|
|
653
|
+
class="sv-grid-chart-svg"
|
|
654
|
+
class:is-interactive={interactive}
|
|
655
|
+
class:is-clickable={!!onSelect || !!onDrill}
|
|
656
|
+
class:is-zoomable={zoomable && isCartesian}
|
|
657
|
+
class:is-dragging={!!dragStart}
|
|
658
|
+
viewBox={`0 0 ${geo.width} ${geo.height}`}
|
|
659
|
+
width="100%"
|
|
660
|
+
role="img"
|
|
661
|
+
aria-label={`${spec.type} chart`}
|
|
662
|
+
aria-describedby={`${uid}-table`}
|
|
663
|
+
onpointerdown={onZoomDown}
|
|
664
|
+
onpointermove={onZoomMove}
|
|
665
|
+
onpointerup={onZoomUp}
|
|
666
|
+
onpointercancel={onZoomUp}
|
|
667
|
+
ondblclick={onPlotDblClick}
|
|
668
|
+
>
|
|
669
|
+
<!-- Pattern defs: emitted once per series when patternFallback is on
|
|
670
|
+
or a series sets `pattern`. Color is baked into the pattern so
|
|
671
|
+
each series gets a hue-tinted texture without dynamic CSS. -->
|
|
672
|
+
{#if patternDefs.length}
|
|
673
|
+
<defs>
|
|
674
|
+
{#each patternDefs as p (p.id)}
|
|
675
|
+
{#if p.kind === 'stripe'}
|
|
676
|
+
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
677
|
+
<rect width="6" height="6" fill={p.color} />
|
|
678
|
+
<rect width="3" height="6" fill="rgba(255,255,255,0.35)" />
|
|
679
|
+
</pattern>
|
|
680
|
+
{:else if p.kind === 'crosshatch'}
|
|
681
|
+
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse">
|
|
682
|
+
<rect width="6" height="6" fill={p.color} />
|
|
683
|
+
<path d="M0 0L6 6 M6 0L0 6" stroke="rgba(255,255,255,0.45)" stroke-width="1" />
|
|
684
|
+
</pattern>
|
|
685
|
+
{:else if p.kind === 'dots'}
|
|
686
|
+
<pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse">
|
|
687
|
+
<rect width="6" height="6" fill={p.color} />
|
|
688
|
+
<circle cx="3" cy="3" r="1.2" fill="rgba(255,255,255,0.55)" />
|
|
689
|
+
</pattern>
|
|
690
|
+
{:else if p.kind === 'diagonal'}
|
|
691
|
+
<pattern id={p.id} width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)">
|
|
692
|
+
<rect width="8" height="8" fill={p.color} />
|
|
693
|
+
<line x1="0" y1="0" x2="0" y2="8" stroke="rgba(0,0,0,0.18)" stroke-width="2" />
|
|
694
|
+
</pattern>
|
|
695
|
+
{/if}
|
|
696
|
+
{/each}
|
|
697
|
+
</defs>
|
|
698
|
+
{/if}
|
|
699
|
+
{#if isHorizontal}
|
|
700
|
+
<!-- Value axis (vertical gridlines + bottom labels) -->
|
|
701
|
+
{#each geo.valueTicks as t (t.label + t.x)}
|
|
702
|
+
<line class="sv-grid-chart-gridline" x1={t.x} y1={geo.plot.y} x2={t.x} y2={geo.plot.y + geo.plot.h} />
|
|
703
|
+
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 14} text-anchor="middle">{t.label}</text>
|
|
704
|
+
{/each}
|
|
705
|
+
<!-- Category labels down the left -->
|
|
706
|
+
{#each geo.catTicks as t (t.value)}
|
|
707
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{truncate(t.label, 18)}</text>
|
|
708
|
+
{/each}
|
|
709
|
+
{#if spec.yAxisTitle}
|
|
710
|
+
<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>
|
|
711
|
+
{/if}
|
|
712
|
+
{#if spec.xAxisTitle}
|
|
713
|
+
<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>
|
|
714
|
+
{/if}
|
|
715
|
+
{:else if isCartesian}
|
|
716
|
+
{#each geo.yTicks as t (t.value)}
|
|
717
|
+
<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} />
|
|
718
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{yTickLabel(t.value, t.label)}</text>
|
|
719
|
+
{/each}
|
|
720
|
+
{#if geo.hasRightAxis}
|
|
721
|
+
{#each geo.y2Ticks as t (t.value)}
|
|
722
|
+
<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>
|
|
723
|
+
{/each}
|
|
724
|
+
{/if}
|
|
725
|
+
{#each geo.xTicks as t (t.label + t.x)}
|
|
726
|
+
{#if geo.xLabelRotated}
|
|
727
|
+
<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>
|
|
728
|
+
{:else}
|
|
729
|
+
<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>
|
|
730
|
+
{/if}
|
|
731
|
+
{/each}
|
|
732
|
+
<!-- Grouped (nested) category axis: parent tier under the leaf labels. -->
|
|
733
|
+
{#each geo.categoryGroupTicks as g (g.label + g.x0)}
|
|
734
|
+
{@const gy = geo.plot.y + geo.plot.h + (geo.xLabelRotated ? 46 : 32)}
|
|
735
|
+
<line class="sv-grid-chart-gridline" x1={g.x0 + 3} y1={gy - 9} x2={g.x1 - 3} y2={gy - 9} />
|
|
736
|
+
{#if g.x0 > geo.plot.x + 1}
|
|
737
|
+
<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" />
|
|
738
|
+
{/if}
|
|
739
|
+
<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>
|
|
740
|
+
{/each}
|
|
741
|
+
{#if spec.yAxisTitle}
|
|
742
|
+
<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>
|
|
743
|
+
{/if}
|
|
744
|
+
{#if spec.y2AxisTitle}
|
|
745
|
+
<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>
|
|
746
|
+
{/if}
|
|
747
|
+
{#if spec.xAxisTitle}
|
|
748
|
+
<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>
|
|
749
|
+
{/if}
|
|
750
|
+
{/if}
|
|
751
|
+
|
|
752
|
+
{#each geo.lines as line, li (line.label + li)}
|
|
753
|
+
<g style={`opacity:${dimOf(line.label)}`}>
|
|
754
|
+
{#if line.bandPath}
|
|
755
|
+
<!-- Confidence band: shaded envelope between upperValues / lowerValues. -->
|
|
756
|
+
<path class="sv-grid-chart-band" d={line.bandPath} fill={line.color} fill-opacity="0.12" stroke="none" />
|
|
757
|
+
{/if}
|
|
758
|
+
{#if line.areaPath}
|
|
759
|
+
<path class="sv-grid-chart-area" d={line.areaPath} fill={seriesFill[line.label] ?? line.color} fill-opacity="0.15" stroke="none" />
|
|
760
|
+
{/if}
|
|
761
|
+
<path class="sv-grid-chart-linepath" d={line.path} fill="none" stroke={line.color} stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
|
762
|
+
{#each line.points as pt, pi (pi)}
|
|
763
|
+
{#if pt.defined}
|
|
764
|
+
<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} />
|
|
765
|
+
{#if dataLabels}
|
|
766
|
+
<text class="sv-grid-chart-datalabel" x={pt.x} y={pt.y - 7} text-anchor="middle">{fmt(pt.value)}</text>
|
|
767
|
+
{/if}
|
|
768
|
+
{/if}
|
|
769
|
+
{/each}
|
|
770
|
+
</g>
|
|
771
|
+
{/each}
|
|
772
|
+
|
|
773
|
+
{#each geo.bars as bar, bi (bi)}
|
|
774
|
+
<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)}`} />
|
|
775
|
+
{#if dataLabels && isHorizontal && bar.w > 18}
|
|
776
|
+
<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>
|
|
777
|
+
{:else if dataLabels && !isHorizontal && bar.h > 13}
|
|
778
|
+
<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>
|
|
779
|
+
{/if}
|
|
780
|
+
{/each}
|
|
781
|
+
|
|
782
|
+
{#each geo.scatterPoints as dot, di (di)}
|
|
783
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
784
|
+
<circle
|
|
785
|
+
class="sv-grid-chart-scatter"
|
|
786
|
+
cx={dot.cx}
|
|
787
|
+
cy={dot.cy}
|
|
788
|
+
r={dot.r}
|
|
789
|
+
fill={dot.color}
|
|
790
|
+
fill-opacity="0.7"
|
|
791
|
+
stroke={dot.color}
|
|
792
|
+
style={`opacity:${dimOf(dot.series)}`}
|
|
793
|
+
onmousemove={(e) => hoverDot(e.clientX, e.clientY, dot)}
|
|
794
|
+
onmouseleave={clearActive}
|
|
795
|
+
/>
|
|
796
|
+
{/each}
|
|
797
|
+
|
|
798
|
+
<!-- Reference / target lines paint on top so they read over the bars/areas. -->
|
|
799
|
+
{#each geo.referenceLines as ref, ri (ri)}
|
|
800
|
+
<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} />
|
|
801
|
+
<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>
|
|
802
|
+
{/each}
|
|
803
|
+
{#each geo.referenceLinesV as ref, ri (ri)}
|
|
804
|
+
<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} />
|
|
805
|
+
<text class="sv-grid-chart-reflabel" x={ref.x + 3} y={geo.plot.y + 9} text-anchor="start" fill={ref.color}>{ref.label}</text>
|
|
806
|
+
{/each}
|
|
807
|
+
|
|
808
|
+
<!-- Trend / moving-average overlays. Dashed so they read distinctly
|
|
809
|
+
from the source series, drawn on top of bars + lines. -->
|
|
810
|
+
{#each geo.overlays as ovl, oi (ovl.label + oi)}
|
|
811
|
+
<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" />
|
|
812
|
+
{/each}
|
|
813
|
+
|
|
814
|
+
<!-- Pinned annotations: small marker + label. Placement nudges the
|
|
815
|
+
label position so it sits clear of the data point. -->
|
|
816
|
+
{#each geo.annotations as ann, ai (ann.label + ai)}
|
|
817
|
+
{@const dx = ann.placement === 'left' ? -8 : ann.placement === 'right' ? 8 : 0}
|
|
818
|
+
{@const dy = ann.placement === 'bottom' ? 14 : ann.placement === 'top' ? -8 : 0}
|
|
819
|
+
{@const anchor = ann.placement === 'left' ? 'end' : ann.placement === 'right' ? 'start' : 'middle'}
|
|
820
|
+
<circle class="sv-grid-chart-annotation-marker" cx={ann.x} cy={ann.y} r="4" fill={ann.color} stroke="var(--sg-bg, #fff)" stroke-width="1.5" />
|
|
821
|
+
<text class="sv-grid-chart-annotation-label" x={ann.x + dx} y={ann.y + dy} text-anchor={anchor} fill={ann.color}>{ann.label}</text>
|
|
822
|
+
{/each}
|
|
823
|
+
|
|
824
|
+
{#if isCartesian && activeCat !== null}
|
|
825
|
+
{#if isHorizontal}
|
|
826
|
+
{@const cy = geo.plot.y + slot * activeCat + slot / 2}
|
|
827
|
+
<line class="sv-grid-chart-crosshair" x1={geo.plot.x} y1={cy} x2={geo.plot.x + geo.plot.w} y2={cy} />
|
|
828
|
+
{:else}
|
|
829
|
+
<!-- Read the actual point x from the first line series when one
|
|
830
|
+
exists (line / area / combo charts) so the crosshair lines up
|
|
831
|
+
with the dots even on a time x-axis (where slot-based math is
|
|
832
|
+
wrong because points are spaced by time, not index). Fall back
|
|
833
|
+
to the slot midpoint for pure bar charts. -->
|
|
834
|
+
{@const linePt = geo.lines[0]?.points[activeCat]}
|
|
835
|
+
{@const cx = linePt && Number.isFinite(linePt.x)
|
|
836
|
+
? linePt.x
|
|
837
|
+
: geo.plot.x + slot * activeCat + slot / 2}
|
|
838
|
+
<line class="sv-grid-chart-crosshair" x1={cx} y1={geo.plot.y} x2={cx} y2={geo.plot.y + geo.plot.h} />
|
|
839
|
+
{/if}
|
|
840
|
+
{/if}
|
|
841
|
+
|
|
842
|
+
{#if isHeatmap}
|
|
843
|
+
<!-- Row labels in the left gutter -->
|
|
844
|
+
{#each geo.heatmapRowTicks as t (t.value)}
|
|
845
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x - 8} y={t.y + 3} text-anchor="end">{truncate(t.label, 22)}</text>
|
|
846
|
+
{/each}
|
|
847
|
+
<!-- Column labels along the bottom -->
|
|
848
|
+
{#each geo.heatmapColTicks as t, i (t.label + i)}
|
|
849
|
+
<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>
|
|
850
|
+
{/each}
|
|
851
|
+
<!-- Cells -->
|
|
852
|
+
{#each geo.heatmapCells as cell, ci (ci)}
|
|
853
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
854
|
+
<rect
|
|
855
|
+
class="sv-grid-chart-heatcell"
|
|
856
|
+
x={cell.x} y={cell.y} width={cell.w} height={cell.h}
|
|
857
|
+
fill={cell.color}
|
|
858
|
+
role={onSelect ? 'button' : 'presentation'}
|
|
859
|
+
tabindex={onSelect ? 0 : undefined}
|
|
860
|
+
aria-label={`${cell.rowLabel} ${cell.colLabel}: ${fmt(cell.value)}`}
|
|
861
|
+
onmousemove={(e) => hoverCell(e.clientX, e.clientY, cell)}
|
|
862
|
+
onmouseleave={clearActive}
|
|
863
|
+
onclick={() => select(cell.colLabel, cell.rowLabel, cell.value)}
|
|
864
|
+
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(cell.colLabel, cell.rowLabel, cell.value) } }}
|
|
865
|
+
/>
|
|
866
|
+
{#if dataLabels && cell.w > 22 && cell.h > 14}
|
|
867
|
+
<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>
|
|
868
|
+
{/if}
|
|
869
|
+
{/each}
|
|
870
|
+
<!-- Color-scale legend bar on the right -->
|
|
871
|
+
{#if geo.heatmapLegend.length}
|
|
872
|
+
{@const lx = geo.plot.x + geo.plot.w + 14}
|
|
873
|
+
{@const lh = geo.plot.h - 20}
|
|
874
|
+
{@const stops = geo.heatmapLegend}
|
|
875
|
+
<defs>
|
|
876
|
+
<linearGradient id={`${uid}-heatscale`} x1="0" y1="1" x2="0" y2="0">
|
|
877
|
+
{#each stops as s, i (i)}
|
|
878
|
+
<stop offset={`${(i / (stops.length - 1)) * 100}%`} stop-color={s.color} />
|
|
879
|
+
{/each}
|
|
880
|
+
</linearGradient>
|
|
881
|
+
</defs>
|
|
882
|
+
<rect class="sv-grid-chart-heatlegend" x={lx} y={geo.plot.y + 10} width="10" height={lh} fill={`url(#${uid}-heatscale)`} />
|
|
883
|
+
<text class="sv-grid-chart-axis" x={lx + 14} y={geo.plot.y + 14} text-anchor="start">{stops[stops.length - 1]!.label}</text>
|
|
884
|
+
<text class="sv-grid-chart-axis" x={lx + 14} y={geo.plot.y + 10 + lh} text-anchor="start">{stops[0]!.label}</text>
|
|
885
|
+
{/if}
|
|
886
|
+
{/if}
|
|
887
|
+
|
|
888
|
+
{#if isFunnel}
|
|
889
|
+
<!-- Trapezoid stack with inline label inside each segment. -->
|
|
890
|
+
{#each geo.funnelSegments as seg, fi (fi)}
|
|
891
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
892
|
+
<path
|
|
893
|
+
class="sv-grid-chart-funnel-seg"
|
|
894
|
+
d={seg.path}
|
|
895
|
+
fill={seg.color}
|
|
896
|
+
role={onSelect ? 'button' : 'presentation'}
|
|
897
|
+
tabindex={onSelect ? 0 : undefined}
|
|
898
|
+
aria-label={`${seg.label}: ${fmt(seg.value)}`}
|
|
899
|
+
onmousemove={(e) => hoverFunnel(e.clientX, e.clientY, seg)}
|
|
900
|
+
onmouseleave={clearActive}
|
|
901
|
+
onclick={() => select(seg.label, seg.label, seg.value, fi)}
|
|
902
|
+
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(seg.label, seg.label, seg.value, fi) } }}
|
|
903
|
+
/>
|
|
904
|
+
<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>
|
|
905
|
+
<text class="sv-grid-chart-funnel-value" x={seg.cx} y={seg.cy + 11} text-anchor="middle" fill={seg.textColor}>
|
|
906
|
+
{fmt(seg.value)} · {(seg.conversion * 100).toFixed(0)}%
|
|
907
|
+
</text>
|
|
908
|
+
{/each}
|
|
909
|
+
{/if}
|
|
910
|
+
|
|
911
|
+
{#if isRadar && geo.radarCenter}
|
|
912
|
+
{@const c = geo.radarCenter}
|
|
913
|
+
<!-- Concentric grid rings -->
|
|
914
|
+
{#each geo.radarRings as _v, ri (ri)}
|
|
915
|
+
{@const ringR = c.r * ((ri + 1) / geo.radarRings.length)}
|
|
916
|
+
<circle class="sv-grid-chart-radar-ring" cx={c.cx} cy={c.cy} r={ringR} />
|
|
917
|
+
{/each}
|
|
918
|
+
<!-- Axis spokes + outer labels -->
|
|
919
|
+
{#each geo.radarAxes as axis, ai (ai)}
|
|
920
|
+
<line class="sv-grid-chart-radar-spoke" x1={c.cx} y1={c.cy} x2={axis.x} y2={axis.y} />
|
|
921
|
+
{@const dx = axis.x - c.cx}
|
|
922
|
+
{@const dy = axis.y - c.cy}
|
|
923
|
+
{@const lx = c.cx + dx * 1.08}
|
|
924
|
+
{@const ly = c.cy + dy * 1.08}
|
|
925
|
+
<text class="sv-grid-chart-axis" x={lx} y={ly + 3}
|
|
926
|
+
text-anchor={Math.abs(dx) < 1 ? 'middle' : dx > 0 ? 'start' : 'end'}>{axis.label}</text>
|
|
927
|
+
{/each}
|
|
928
|
+
<!-- One translucent polygon per series + dot per vertex. -->
|
|
929
|
+
{#each geo.radarSeries as rs, si (si)}
|
|
930
|
+
<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)}`} />
|
|
931
|
+
{#each rs.points as pt, pi (pi)}
|
|
932
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
933
|
+
<circle class="sv-grid-chart-radar-dot" cx={pt.x} cy={pt.y} r="3" fill={rs.color}
|
|
934
|
+
onmousemove={(e) => hoverRadarPoint(e.clientX, e.clientY, rs.label, pt.axis, pt.value, rs.color)}
|
|
935
|
+
onmouseleave={clearActive} />
|
|
936
|
+
{/each}
|
|
937
|
+
{/each}
|
|
938
|
+
{/if}
|
|
939
|
+
|
|
940
|
+
{#if isCalendar}
|
|
941
|
+
<!-- Month labels along the top -->
|
|
942
|
+
{#each geo.calendarMonthTicks as t, ti (ti)}
|
|
943
|
+
<text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y - 8} text-anchor="start">{t.label}</text>
|
|
944
|
+
{/each}
|
|
945
|
+
<!-- Day-of-week labels on the left (Mon/Wed/Fri only to save space) -->
|
|
946
|
+
{#each ['Mon','Wed','Fri'] as dayLabel, di (di)}
|
|
947
|
+
{@const rowIdx = [1, 3, 5][di]!}
|
|
948
|
+
{@const cell = geo.calendarCells[rowIdx]}
|
|
949
|
+
{#if cell}
|
|
950
|
+
<text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={cell.y + cell.size / 2 + 3} text-anchor="end">{dayLabel}</text>
|
|
951
|
+
{/if}
|
|
952
|
+
{/each}
|
|
953
|
+
<!-- Cells -->
|
|
954
|
+
{#each geo.calendarCells as cell, ci (ci)}
|
|
955
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
956
|
+
<rect
|
|
957
|
+
class="sv-grid-chart-calendar-cell"
|
|
958
|
+
class:is-blank={!cell.defined}
|
|
959
|
+
x={cell.x + 1} y={cell.y + 1}
|
|
960
|
+
width={cell.size - 2} height={cell.size - 2}
|
|
961
|
+
fill={cell.defined ? cell.color : 'transparent'}
|
|
962
|
+
rx="2"
|
|
963
|
+
aria-label={`${cell.date}: ${cell.defined ? fmt(cell.value) : 'no data'}`}
|
|
964
|
+
onmousemove={(e) => cell.defined && showTip(e.clientX, e.clientY, cell.date, [{ color: cell.color, value: fmt(cell.value) }])}
|
|
965
|
+
onmouseleave={clearActive}
|
|
966
|
+
/>
|
|
967
|
+
{/each}
|
|
968
|
+
<!-- Legend strip on the right -->
|
|
969
|
+
{#if geo.calendarLegend.length}
|
|
970
|
+
{@const lx = geo.plot.x + geo.plot.w + 12}
|
|
971
|
+
{@const ly = geo.plot.y + 4}
|
|
972
|
+
<text class="sv-grid-chart-axis" x={lx} y={ly} text-anchor="start">Less</text>
|
|
973
|
+
{#each geo.calendarLegend as s, i (i)}
|
|
974
|
+
<rect class="sv-grid-chart-calendar-legend" x={lx} y={ly + 6 + i * 13} width="10" height="10" rx="2" fill={s.color} />
|
|
975
|
+
<text class="sv-grid-chart-axis" x={lx + 14} y={ly + 14 + i * 13} text-anchor="start">{s.label}</text>
|
|
976
|
+
{/each}
|
|
977
|
+
<text class="sv-grid-chart-axis" x={lx} y={ly + 12 + geo.calendarLegend.length * 13} text-anchor="start">More</text>
|
|
978
|
+
{/if}
|
|
979
|
+
{/if}
|
|
980
|
+
|
|
981
|
+
{#if isGauge && geo.gauge}
|
|
982
|
+
{@const g = geo.gauge}
|
|
983
|
+
<!-- Tick marks around the dial (major ticks longer). -->
|
|
984
|
+
{#each g.ticks as tk, ti (ti)}
|
|
985
|
+
<line class="sv-grid-chart-gauge-tick" class:is-major={tk.major}
|
|
986
|
+
x1={tk.x1} y1={tk.y1} x2={tk.x2} y2={tk.y2} />
|
|
987
|
+
{/each}
|
|
988
|
+
<!-- Track (grey full arc) -->
|
|
989
|
+
<path d={g.trackPath} fill="none" stroke="var(--sg-border, #e2e8f0)" stroke-width="16" stroke-linecap="round" />
|
|
990
|
+
<!-- Optional colored range bands (a thinner inner arc). -->
|
|
991
|
+
{#each g.rangePaths as band, bi (bi)}
|
|
992
|
+
<path d={band.path} fill="none" stroke={band.color} stroke-width="6" stroke-linecap="round" opacity="0.85" />
|
|
993
|
+
{/each}
|
|
994
|
+
<!-- Value arc, colored by the band the value sits in. -->
|
|
995
|
+
<path d={g.valuePath} fill="none" stroke={g.valueColor ?? 'var(--sg-accent, #2563eb)'} stroke-width="16" stroke-linecap="round" />
|
|
996
|
+
<!-- Target tick (when set) -->
|
|
997
|
+
{#if g.target}
|
|
998
|
+
<line x1={g.target.x1} y1={g.target.y1} x2={g.target.x2} y2={g.target.y2}
|
|
999
|
+
stroke="var(--sg-fg, #0f172a)" stroke-width="2.5" stroke-linecap="round" />
|
|
1000
|
+
{/if}
|
|
1001
|
+
<!-- Pointer needle + center hub. -->
|
|
1002
|
+
<path class="sv-grid-chart-gauge-needle" d={g.needle.path} fill={g.valueColor ?? 'var(--sg-accent, #2563eb)'} />
|
|
1003
|
+
<circle class="sv-grid-chart-gauge-hub" cx={g.cx} cy={g.cy} r={g.needle.hubR} />
|
|
1004
|
+
<circle class="sv-grid-chart-gauge-hub-dot" cx={g.cx} cy={g.cy} r="2.5" />
|
|
1005
|
+
<!-- Scale end labels + center readout. -->
|
|
1006
|
+
<text class="sv-grid-chart-axis" x={g.minLabel.x} y={g.minLabel.y} text-anchor="middle">{fmt(g.min)}</text>
|
|
1007
|
+
<text class="sv-grid-chart-axis" x={g.maxLabel.x} y={g.maxLabel.y} text-anchor="middle">{fmt(g.max)}</text>
|
|
1008
|
+
<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>
|
|
1009
|
+
{/if}
|
|
1010
|
+
|
|
1011
|
+
{#if isTreemap}
|
|
1012
|
+
{#each geo.treemapCells as cell, ti (ti)}
|
|
1013
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1014
|
+
<rect
|
|
1015
|
+
class="sv-grid-chart-treemap-cell"
|
|
1016
|
+
x={cell.x} y={cell.y} width={cell.w} height={cell.h}
|
|
1017
|
+
fill={cell.color}
|
|
1018
|
+
role={onSelect ? 'button' : 'presentation'}
|
|
1019
|
+
tabindex={onSelect ? 0 : undefined}
|
|
1020
|
+
aria-label={`${cell.name}: ${fmt(cell.value)}`}
|
|
1021
|
+
onmousemove={(e) => showTip(e.clientX, e.clientY, cell.name, [{ color: cell.color, value: fmt(cell.value) }])}
|
|
1022
|
+
onmouseleave={clearActive}
|
|
1023
|
+
onclick={() => select(cell.name, '', cell.value)}
|
|
1024
|
+
onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(cell.name, '', cell.value) } }}
|
|
1025
|
+
/>
|
|
1026
|
+
{#if cell.w > 36 && cell.h > 18}
|
|
1027
|
+
<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>
|
|
1028
|
+
{/if}
|
|
1029
|
+
{#if cell.w > 60 && cell.h > 30}
|
|
1030
|
+
<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>
|
|
1031
|
+
{/if}
|
|
1032
|
+
{/each}
|
|
1033
|
+
{/if}
|
|
1034
|
+
|
|
1035
|
+
{#if isSankey}
|
|
1036
|
+
<!-- Ribbon links (drawn first so node rects sit on top). -->
|
|
1037
|
+
{#each geo.sankeyLinks as link, li (li)}
|
|
1038
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1039
|
+
<path
|
|
1040
|
+
class="sv-grid-chart-sankey-link"
|
|
1041
|
+
d={link.path}
|
|
1042
|
+
fill="none"
|
|
1043
|
+
stroke={link.color}
|
|
1044
|
+
stroke-width={link.width}
|
|
1045
|
+
stroke-opacity="0.35"
|
|
1046
|
+
aria-label={`${link.source} -> ${link.target}: ${fmt(link.value)}`}
|
|
1047
|
+
onmousemove={(e) => showTip(e.clientX, e.clientY, `${link.source} -> ${link.target}`, [{ color: link.color, value: fmt(link.value) }])}
|
|
1048
|
+
onmouseleave={clearActive}
|
|
1049
|
+
/>
|
|
1050
|
+
{/each}
|
|
1051
|
+
<!-- Node rectangles + labels. -->
|
|
1052
|
+
{#each geo.sankeyNodes as node, ni (ni)}
|
|
1053
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1054
|
+
<rect
|
|
1055
|
+
class="sv-grid-chart-sankey-node"
|
|
1056
|
+
x={node.x} y={node.y} width={node.w} height={node.h}
|
|
1057
|
+
fill={node.color}
|
|
1058
|
+
tabindex={0}
|
|
1059
|
+
aria-label={`${node.label}: in ${fmt(node.totalIn)}, out ${fmt(node.totalOut)}`}
|
|
1060
|
+
onmousemove={(e) => showTip(e.clientX, e.clientY, node.label, [
|
|
1061
|
+
{ label: 'in', value: fmt(node.totalIn) },
|
|
1062
|
+
{ label: 'out', value: fmt(node.totalOut) },
|
|
1063
|
+
])}
|
|
1064
|
+
onmouseleave={clearActive}
|
|
1065
|
+
onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); showTip(b.left + b.width / 2, b.top, node.label, [
|
|
1066
|
+
{ label: 'in', value: fmt(node.totalIn) },
|
|
1067
|
+
{ label: 'out', value: fmt(node.totalOut) },
|
|
1068
|
+
]) }}
|
|
1069
|
+
onblur={clearActive}
|
|
1070
|
+
/>
|
|
1071
|
+
<text class="sv-grid-chart-sankey-label"
|
|
1072
|
+
x={node.column === 0 ? node.x + node.w + 4 : node.x - 4}
|
|
1073
|
+
y={node.y + node.h / 2 + 3}
|
|
1074
|
+
text-anchor={node.column === 0 ? 'start' : 'end'}>{node.label}</text>
|
|
1075
|
+
{/each}
|
|
1076
|
+
{/if}
|
|
1077
|
+
|
|
1078
|
+
{#each geo.slices as slice, si (si)}
|
|
1079
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1080
|
+
<path
|
|
1081
|
+
class="sv-grid-chart-slice"
|
|
1082
|
+
d={slice.path}
|
|
1083
|
+
fill={slice.color}
|
|
1084
|
+
stroke="var(--sg-bg, #fff)"
|
|
1085
|
+
stroke-width="1"
|
|
1086
|
+
role={onSelect ? 'button' : 'presentation'}
|
|
1087
|
+
tabindex={onSelect ? 0 : undefined}
|
|
1088
|
+
aria-label={`${slice.label}: ${fmt(slice.value)}, ${slice.percent.toFixed(1)}%`}
|
|
1089
|
+
onmousemove={(e) => hoverSlice(e.clientX, e.clientY, slice.label, slice.value, slice.percent)}
|
|
1090
|
+
onmouseleave={clearActive}
|
|
1091
|
+
onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); hoverSlice(b.left + b.width / 2, b.top, slice.label, slice.value, slice.percent) }}
|
|
1092
|
+
onblur={clearActive}
|
|
1093
|
+
onclick={() => select(slice.label, slice.label, slice.value, si)}
|
|
1094
|
+
onkeydown={(e) => onSliceKey(e, slice.label, slice.value)}
|
|
1095
|
+
/>
|
|
1096
|
+
{#if dataLabels && slice.percent >= 6}
|
|
1097
|
+
<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>
|
|
1098
|
+
{/if}
|
|
1099
|
+
{/each}
|
|
1100
|
+
|
|
1101
|
+
{#if geo.donut}
|
|
1102
|
+
<text class="sv-grid-chart-donut-total" x={geo.donut.cx} y={geo.donut.cy - 4} text-anchor="middle">{fmt(geo.donut.total)}</text>
|
|
1103
|
+
<text class="sv-grid-chart-donut-label" x={geo.donut.cx} y={geo.donut.cy + 11} text-anchor="middle">Total</text>
|
|
1104
|
+
{/if}
|
|
1105
|
+
|
|
1106
|
+
{#if isCartesian && !isScatter}
|
|
1107
|
+
<!-- Per-category hover/focus zones drive the unified crosshair
|
|
1108
|
+
tooltip + arrow-key navigation. tabindex=0 on the first rect
|
|
1109
|
+
and -1 on the rest implements roving tabindex: Tab enters the
|
|
1110
|
+
group, arrow keys move within it, Tab leaves.
|
|
1111
|
+
Vertical bands centre on the ACTUAL point x (which differs from
|
|
1112
|
+
slot math when xType is 'time'); their widths bisect the gap to
|
|
1113
|
+
neighbouring points so a mouse over a dot always selects it. -->
|
|
1114
|
+
{#each visibleSpec.categories as cat, i (cat + i)}
|
|
1115
|
+
{@const pts = geo.lines[0]?.points}
|
|
1116
|
+
{@const px = pts?.[i]?.x}
|
|
1117
|
+
{@const useTimeBands = !isHorizontal && pts && pts.length === visibleSpec.categories.length && Number.isFinite(px)}
|
|
1118
|
+
{@const prevX = useTimeBands && i > 0 ? pts![i - 1]!.x : null}
|
|
1119
|
+
{@const nextX = useTimeBands && i < pts!.length - 1 ? pts![i + 1]!.x : null}
|
|
1120
|
+
{@const bandL = !useTimeBands ? geo.plot.x + slot * i
|
|
1121
|
+
: prevX != null ? (prevX + (px as number)) / 2 : geo.plot.x}
|
|
1122
|
+
{@const bandR = !useTimeBands ? geo.plot.x + slot * (i + 1)
|
|
1123
|
+
: nextX != null ? ((px as number) + nextX) / 2 : geo.plot.x + geo.plot.w}
|
|
1124
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
|
|
1125
|
+
<rect
|
|
1126
|
+
class="sv-grid-chart-cat-hit"
|
|
1127
|
+
x={isHorizontal ? geo.plot.x : bandL}
|
|
1128
|
+
y={isHorizontal ? geo.plot.y + slot * i : geo.plot.y}
|
|
1129
|
+
width={isHorizontal ? geo.plot.w : Math.max(0, bandR - bandL)}
|
|
1130
|
+
height={isHorizontal ? slot : geo.plot.h}
|
|
1131
|
+
role={onSelect ? 'button' : 'img'}
|
|
1132
|
+
tabindex={interactive ? (i === 0 ? 0 : -1) : undefined}
|
|
1133
|
+
data-cat-index={i}
|
|
1134
|
+
aria-label={`${cat}: ${catRows(i).map((r) => `${r.label} ${r.value}`).join(', ')}`}
|
|
1135
|
+
onmousemove={(e) => hoverCat(e.clientX, e.clientY, i)}
|
|
1136
|
+
onmouseleave={clearActive}
|
|
1137
|
+
onfocus={(e) => focusCat(e.currentTarget, i)}
|
|
1138
|
+
onblur={clearActive}
|
|
1139
|
+
onclick={() => select(cat, '', 0, i)}
|
|
1140
|
+
onkeydown={(e) => onCatKey(e, i)}
|
|
1141
|
+
/>
|
|
1142
|
+
{/each}
|
|
1143
|
+
{/if}
|
|
1144
|
+
|
|
1145
|
+
{#if dragRect}
|
|
1146
|
+
<rect class="sv-grid-chart-zoom-rect"
|
|
1147
|
+
x={dragRect.x} y={dragRect.y} width={dragRect.w} height={dragRect.h} />
|
|
1148
|
+
{/if}
|
|
1149
|
+
</svg>
|
|
1150
|
+
|
|
1151
|
+
{#if brushEligible && brushGeo}
|
|
1152
|
+
{@const bx = brushGeo.plot.x + brushWindow.t0 * brushGeo.plot.w}
|
|
1153
|
+
{@const bw = (brushWindow.t1 - brushWindow.t0) * brushGeo.plot.w}
|
|
1154
|
+
<svg
|
|
1155
|
+
class="sv-grid-chart-brush"
|
|
1156
|
+
viewBox={`0 0 ${brushGeo.width} ${brushGeo.height}`}
|
|
1157
|
+
width="100%"
|
|
1158
|
+
role="img"
|
|
1159
|
+
aria-label="Range brush"
|
|
1160
|
+
onpointermove={onBrushMove}
|
|
1161
|
+
>
|
|
1162
|
+
<!-- Mini chart: paint a faint version of the lines / area / bars
|
|
1163
|
+
so the user sees what region they're picking from. -->
|
|
1164
|
+
{#each brushGeo.lines as line, li (li)}
|
|
1165
|
+
{#if line.areaPath}
|
|
1166
|
+
<path d={line.areaPath} fill={line.color} fill-opacity="0.10" stroke="none" />
|
|
1167
|
+
{/if}
|
|
1168
|
+
<path d={line.path} fill="none" stroke={line.color} stroke-width="1.2" opacity="0.75" />
|
|
1169
|
+
{/each}
|
|
1170
|
+
{#each brushGeo.bars as bar, bi (bi)}
|
|
1171
|
+
<rect x={bar.x} y={bar.y} width={bar.w} height={bar.h} fill={bar.color} opacity="0.55" />
|
|
1172
|
+
{/each}
|
|
1173
|
+
<!-- Faded overlay on the parts NOT in the window. -->
|
|
1174
|
+
<rect class="sv-grid-chart-brush-mask"
|
|
1175
|
+
x={brushGeo.plot.x} y={brushGeo.plot.y}
|
|
1176
|
+
width={bx - brushGeo.plot.x} height={brushGeo.plot.h} />
|
|
1177
|
+
<rect class="sv-grid-chart-brush-mask"
|
|
1178
|
+
x={bx + bw} y={brushGeo.plot.y}
|
|
1179
|
+
width={brushGeo.plot.x + brushGeo.plot.w - (bx + bw)} height={brushGeo.plot.h} />
|
|
1180
|
+
<!-- The window body: pointerdown moves the whole window. -->
|
|
1181
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1182
|
+
<rect class="sv-grid-chart-brush-window"
|
|
1183
|
+
x={bx} y={brushGeo.plot.y} width={bw} height={brushGeo.plot.h}
|
|
1184
|
+
onpointerdown={(e) => onBrushDown(e, 'move')}
|
|
1185
|
+
onpointerup={onBrushUp}
|
|
1186
|
+
onpointercancel={onBrushUp} />
|
|
1187
|
+
<!-- Left + right handles: pointerdown resizes that edge. -->
|
|
1188
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1189
|
+
<rect class="sv-grid-chart-brush-handle"
|
|
1190
|
+
x={bx - 4} y={brushGeo.plot.y} width="8" height={brushGeo.plot.h}
|
|
1191
|
+
onpointerdown={(e) => onBrushDown(e, 'left')}
|
|
1192
|
+
onpointerup={onBrushUp}
|
|
1193
|
+
onpointercancel={onBrushUp} />
|
|
1194
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
|
|
1195
|
+
<rect class="sv-grid-chart-brush-handle"
|
|
1196
|
+
x={bx + bw - 4} y={brushGeo.plot.y} width="8" height={brushGeo.plot.h}
|
|
1197
|
+
onpointerdown={(e) => onBrushDown(e, 'right')}
|
|
1198
|
+
onpointerup={onBrushUp}
|
|
1199
|
+
onpointercancel={onBrushUp} />
|
|
1200
|
+
</svg>
|
|
1201
|
+
{/if}
|
|
1202
|
+
|
|
1203
|
+
{#if isEmpty}
|
|
1204
|
+
<div class="sv-grid-chart-empty">No data</div>
|
|
1205
|
+
{/if}
|
|
1206
|
+
|
|
1207
|
+
{#if tip}
|
|
1208
|
+
<div class="sv-grid-chart-tooltip" class:is-below={tip.below} style={`left:${tip.left}px; top:${tip.top}px;`}>
|
|
1209
|
+
<span class="sv-grid-chart-tooltip-title">{tip.title}</span>
|
|
1210
|
+
{#each tip.rows as r, ri (ri)}
|
|
1211
|
+
<span class="sv-grid-chart-tooltip-row">
|
|
1212
|
+
{#if r.color}<span class="sv-grid-chart-tooltip-dot" style={`background:${r.color}`}></span>{/if}
|
|
1213
|
+
{#if r.label}<span class="sv-grid-chart-tooltip-row-label">{r.label}</span>{/if}
|
|
1214
|
+
<span class="sv-grid-chart-tooltip-row-value">{r.value}</span>
|
|
1215
|
+
</span>
|
|
1216
|
+
{/each}
|
|
1217
|
+
</div>
|
|
1218
|
+
{/if}
|
|
1219
|
+
|
|
1220
|
+
{#if legend && legendItems.length}
|
|
1221
|
+
<div class="sv-grid-chart-legend">
|
|
1222
|
+
{#each shownLegend as item (item.label)}
|
|
1223
|
+
<button
|
|
1224
|
+
type="button"
|
|
1225
|
+
class="sv-grid-chart-legend-item"
|
|
1226
|
+
class:is-off={item.off}
|
|
1227
|
+
class:is-isolated={isolated === item.label}
|
|
1228
|
+
disabled={!interactive}
|
|
1229
|
+
aria-pressed={!item.off}
|
|
1230
|
+
onclick={() => toggle(item.label)}
|
|
1231
|
+
ondblclick={() => isolate(item.label)}
|
|
1232
|
+
onpointerenter={() => interactive && (dimmed = item.label)}
|
|
1233
|
+
onpointerleave={() => (dimmed = null)}
|
|
1234
|
+
title={interactive ? `${item.off ? 'Show' : 'Hide'} ${item.label} · double-click to isolate` : item.label}
|
|
1235
|
+
>
|
|
1236
|
+
<span class="sv-grid-chart-swatch" style={`background:${item.off ? 'transparent' : item.color}; border-color:${item.color}`}></span>
|
|
1237
|
+
{item.label}
|
|
1238
|
+
</button>
|
|
1239
|
+
{/each}
|
|
1240
|
+
{#if legendOverflow}
|
|
1241
|
+
<button type="button" class="sv-grid-chart-legend-more" onclick={() => (legendExpanded = !legendExpanded)}>
|
|
1242
|
+
{legendExpanded ? 'Show less' : `+${legendItems.length - LEGEND_MAX} more`}
|
|
1243
|
+
</button>
|
|
1244
|
+
{/if}
|
|
1245
|
+
</div>
|
|
1246
|
+
{/if}
|
|
1247
|
+
|
|
1248
|
+
<!-- Visually-hidden data table: the same data for assistive technology. -->
|
|
1249
|
+
<table id={`${uid}-table`} class="sv-grid-chart-sr-only">
|
|
1250
|
+
<caption>{spec.type} chart data</caption>
|
|
1251
|
+
<thead>
|
|
1252
|
+
<tr>{#each srTable.cols as c (c)}<th>{c}</th>{/each}</tr>
|
|
1253
|
+
</thead>
|
|
1254
|
+
<tbody>
|
|
1255
|
+
{#each srTable.rows as row, ri (ri)}
|
|
1256
|
+
<tr>{#each row as cell, ci (ci)}<td>{cell}</td>{/each}</tr>
|
|
1257
|
+
{/each}
|
|
1258
|
+
</tbody>
|
|
1259
|
+
</table>
|
|
1260
|
+
</div>
|
|
1261
|
+
|
|
1262
|
+
<style>
|
|
1263
|
+
.sv-grid-chart {
|
|
1264
|
+
position: relative;
|
|
1265
|
+
display: flex;
|
|
1266
|
+
flex-direction: column;
|
|
1267
|
+
gap: 8px;
|
|
1268
|
+
width: 100%;
|
|
1269
|
+
}
|
|
1270
|
+
.sv-grid-chart-svg {
|
|
1271
|
+
display: block;
|
|
1272
|
+
width: 100%;
|
|
1273
|
+
height: auto;
|
|
1274
|
+
}
|
|
1275
|
+
.sv-grid-chart-gridline {
|
|
1276
|
+
stroke: var(--sg-border, #e2e8f0);
|
|
1277
|
+
stroke-width: 1;
|
|
1278
|
+
opacity: 0.6;
|
|
1279
|
+
}
|
|
1280
|
+
.sv-grid-chart-gridline.is-zero {
|
|
1281
|
+
stroke: var(--sg-muted, #94a3b8);
|
|
1282
|
+
opacity: 0.9;
|
|
1283
|
+
}
|
|
1284
|
+
.sv-grid-chart-axis {
|
|
1285
|
+
fill: var(--sg-muted, #64748b);
|
|
1286
|
+
font-size: 10px;
|
|
1287
|
+
font-family: inherit;
|
|
1288
|
+
}
|
|
1289
|
+
.sv-grid-chart-axis-title {
|
|
1290
|
+
fill: var(--sg-muted, #64748b);
|
|
1291
|
+
font-size: 11px;
|
|
1292
|
+
font-weight: 600;
|
|
1293
|
+
font-family: inherit;
|
|
1294
|
+
}
|
|
1295
|
+
.sv-grid-chart-group-label {
|
|
1296
|
+
fill: var(--sg-fg, #0f172a);
|
|
1297
|
+
font-weight: 600;
|
|
1298
|
+
}
|
|
1299
|
+
.sv-grid-chart-crosshair {
|
|
1300
|
+
stroke: var(--sg-accent, #2563eb);
|
|
1301
|
+
stroke-width: 1;
|
|
1302
|
+
stroke-dasharray: 3 3;
|
|
1303
|
+
opacity: 0.65;
|
|
1304
|
+
pointer-events: none;
|
|
1305
|
+
}
|
|
1306
|
+
.sv-grid-chart-refline {
|
|
1307
|
+
stroke-width: 1.5;
|
|
1308
|
+
opacity: 0.9;
|
|
1309
|
+
pointer-events: none;
|
|
1310
|
+
}
|
|
1311
|
+
.sv-grid-chart-reflabel {
|
|
1312
|
+
font-size: 9.5px;
|
|
1313
|
+
font-weight: 700;
|
|
1314
|
+
font-family: inherit;
|
|
1315
|
+
pointer-events: none;
|
|
1316
|
+
}
|
|
1317
|
+
.sv-grid-chart-overlay {
|
|
1318
|
+
opacity: 0.85;
|
|
1319
|
+
pointer-events: none;
|
|
1320
|
+
}
|
|
1321
|
+
.sv-grid-chart-annotation-marker {
|
|
1322
|
+
pointer-events: none;
|
|
1323
|
+
}
|
|
1324
|
+
.sv-grid-chart-annotation-label {
|
|
1325
|
+
font-size: 10px;
|
|
1326
|
+
font-weight: 700;
|
|
1327
|
+
font-family: inherit;
|
|
1328
|
+
pointer-events: none;
|
|
1329
|
+
}
|
|
1330
|
+
/* Heatmap cells. Stroke uses the border token (not bg) so the cell
|
|
1331
|
+
separators stay visible in BOTH light and dark themes - on dark mode
|
|
1332
|
+
a bg-colored stroke disappeared into the navy cells. */
|
|
1333
|
+
.sv-grid-chart-heatcell {
|
|
1334
|
+
stroke: var(--sg-border, #cbd5e1);
|
|
1335
|
+
stroke-width: 1;
|
|
1336
|
+
transition: opacity 100ms ease;
|
|
1337
|
+
cursor: default;
|
|
1338
|
+
}
|
|
1339
|
+
.sv-grid-chart-svg.is-clickable .sv-grid-chart-heatcell { cursor: pointer; }
|
|
1340
|
+
.sv-grid-chart-heatcell:hover { opacity: 0.85; }
|
|
1341
|
+
.sv-grid-chart-heatlabel {
|
|
1342
|
+
font-size: 10px;
|
|
1343
|
+
font-weight: 600;
|
|
1344
|
+
font-family: inherit;
|
|
1345
|
+
pointer-events: none;
|
|
1346
|
+
user-select: none;
|
|
1347
|
+
}
|
|
1348
|
+
/* Legend gradient bar gets a visible outline + axis labels next to it
|
|
1349
|
+
pull from the regular axis class so they follow the theme. */
|
|
1350
|
+
.sv-grid-chart-heatlegend {
|
|
1351
|
+
stroke: var(--sg-border, #cbd5e1);
|
|
1352
|
+
stroke-width: 1;
|
|
1353
|
+
}
|
|
1354
|
+
/* Funnel segments + labels. */
|
|
1355
|
+
.sv-grid-chart-funnel-seg {
|
|
1356
|
+
stroke: var(--sg-bg, #ffffff);
|
|
1357
|
+
stroke-width: 2;
|
|
1358
|
+
transition: opacity 100ms ease;
|
|
1359
|
+
cursor: default;
|
|
1360
|
+
}
|
|
1361
|
+
.sv-grid-chart-svg.is-clickable .sv-grid-chart-funnel-seg { cursor: pointer; }
|
|
1362
|
+
.sv-grid-chart-funnel-seg:hover { opacity: 0.88; }
|
|
1363
|
+
.sv-grid-chart-funnel-label {
|
|
1364
|
+
font-size: 11px;
|
|
1365
|
+
font-weight: 700;
|
|
1366
|
+
font-family: inherit;
|
|
1367
|
+
pointer-events: none;
|
|
1368
|
+
}
|
|
1369
|
+
.sv-grid-chart-funnel-value {
|
|
1370
|
+
font-size: 10px;
|
|
1371
|
+
font-family: inherit;
|
|
1372
|
+
opacity: 0.85;
|
|
1373
|
+
pointer-events: none;
|
|
1374
|
+
}
|
|
1375
|
+
/* Radar grid + polygons. */
|
|
1376
|
+
.sv-grid-chart-radar-ring {
|
|
1377
|
+
fill: none;
|
|
1378
|
+
stroke: var(--sg-border, #e2e8f0);
|
|
1379
|
+
stroke-width: 1;
|
|
1380
|
+
opacity: 0.6;
|
|
1381
|
+
}
|
|
1382
|
+
.sv-grid-chart-radar-spoke {
|
|
1383
|
+
stroke: var(--sg-border, #e2e8f0);
|
|
1384
|
+
stroke-width: 1;
|
|
1385
|
+
opacity: 0.6;
|
|
1386
|
+
}
|
|
1387
|
+
.sv-grid-chart-radar-poly {
|
|
1388
|
+
transition: opacity 100ms ease;
|
|
1389
|
+
pointer-events: none;
|
|
1390
|
+
}
|
|
1391
|
+
.sv-grid-chart-radar-dot {
|
|
1392
|
+
transition: r 100ms ease;
|
|
1393
|
+
cursor: default;
|
|
1394
|
+
}
|
|
1395
|
+
.sv-grid-chart-radar-dot:hover { r: 4.5; }
|
|
1396
|
+
/* Calendar heatmap cells. Blank cells get a faint outline so the grid
|
|
1397
|
+
still reads on days with no data. */
|
|
1398
|
+
.sv-grid-chart-calendar-cell {
|
|
1399
|
+
stroke: var(--sg-border, #e2e8f0);
|
|
1400
|
+
stroke-width: 0.5;
|
|
1401
|
+
transition: opacity 100ms ease;
|
|
1402
|
+
cursor: default;
|
|
1403
|
+
}
|
|
1404
|
+
.sv-grid-chart-calendar-cell.is-blank { stroke-opacity: 0.5; }
|
|
1405
|
+
.sv-grid-chart-calendar-cell:hover { opacity: 0.85; }
|
|
1406
|
+
.sv-grid-chart-calendar-legend {
|
|
1407
|
+
stroke: var(--sg-border, #cbd5e1);
|
|
1408
|
+
stroke-width: 0.5;
|
|
1409
|
+
}
|
|
1410
|
+
/* Gauge centre readout. */
|
|
1411
|
+
.sv-grid-chart-gauge-value {
|
|
1412
|
+
fill: var(--sg-fg, #0f172a);
|
|
1413
|
+
font-size: 22px;
|
|
1414
|
+
font-weight: 800;
|
|
1415
|
+
font-family: inherit;
|
|
1416
|
+
pointer-events: none;
|
|
1417
|
+
}
|
|
1418
|
+
/* Gauge tick marks: faint minors, stronger majors. */
|
|
1419
|
+
.sv-grid-chart-gauge-tick {
|
|
1420
|
+
stroke: var(--sg-muted, #94a3b8);
|
|
1421
|
+
stroke-width: 1;
|
|
1422
|
+
opacity: 0.4;
|
|
1423
|
+
}
|
|
1424
|
+
.sv-grid-chart-gauge-tick.is-major {
|
|
1425
|
+
stroke-width: 2;
|
|
1426
|
+
opacity: 0.7;
|
|
1427
|
+
}
|
|
1428
|
+
/* Pointer needle + hub. */
|
|
1429
|
+
.sv-grid-chart-gauge-needle {
|
|
1430
|
+
stroke: var(--sg-bg, #ffffff);
|
|
1431
|
+
stroke-width: 0.75;
|
|
1432
|
+
stroke-linejoin: round;
|
|
1433
|
+
}
|
|
1434
|
+
.sv-grid-chart-gauge-hub {
|
|
1435
|
+
fill: var(--sg-fg, #0f172a);
|
|
1436
|
+
stroke: var(--sg-bg, #ffffff);
|
|
1437
|
+
stroke-width: 2;
|
|
1438
|
+
}
|
|
1439
|
+
.sv-grid-chart-gauge-hub-dot {
|
|
1440
|
+
fill: var(--sg-bg, #ffffff);
|
|
1441
|
+
}
|
|
1442
|
+
/* Tree-map cells. White separators give the squarified rects breathing
|
|
1443
|
+
room without distracting from the data. */
|
|
1444
|
+
.sv-grid-chart-treemap-cell {
|
|
1445
|
+
stroke: var(--sg-bg, #ffffff);
|
|
1446
|
+
stroke-width: 1;
|
|
1447
|
+
transition: opacity 100ms ease;
|
|
1448
|
+
cursor: default;
|
|
1449
|
+
}
|
|
1450
|
+
.sv-grid-chart-svg.is-clickable .sv-grid-chart-treemap-cell { cursor: pointer; }
|
|
1451
|
+
.sv-grid-chart-treemap-cell:hover { opacity: 0.88; }
|
|
1452
|
+
.sv-grid-chart-treemap-label {
|
|
1453
|
+
font-size: 11px;
|
|
1454
|
+
font-weight: 600;
|
|
1455
|
+
font-family: inherit;
|
|
1456
|
+
pointer-events: none;
|
|
1457
|
+
}
|
|
1458
|
+
.sv-grid-chart-treemap-value {
|
|
1459
|
+
font-size: 10px;
|
|
1460
|
+
font-family: inherit;
|
|
1461
|
+
pointer-events: none;
|
|
1462
|
+
}
|
|
1463
|
+
/* Sankey nodes + links. Links use stroke not fill so per-ribbon width
|
|
1464
|
+
comes from stroke-width directly. */
|
|
1465
|
+
.sv-grid-chart-sankey-node {
|
|
1466
|
+
transition: opacity 100ms ease;
|
|
1467
|
+
cursor: default;
|
|
1468
|
+
}
|
|
1469
|
+
.sv-grid-chart-sankey-node:hover { opacity: 0.88; }
|
|
1470
|
+
.sv-grid-chart-sankey-link {
|
|
1471
|
+
transition: stroke-opacity 100ms ease;
|
|
1472
|
+
cursor: default;
|
|
1473
|
+
}
|
|
1474
|
+
.sv-grid-chart-sankey-link:hover { stroke-opacity: 0.6; }
|
|
1475
|
+
.sv-grid-chart-sankey-label {
|
|
1476
|
+
fill: var(--sg-fg, #0f172a);
|
|
1477
|
+
font-size: 11px;
|
|
1478
|
+
font-weight: 600;
|
|
1479
|
+
font-family: inherit;
|
|
1480
|
+
pointer-events: none;
|
|
1481
|
+
}
|
|
1482
|
+
/* Brush mini-map: a compact secondary chart with a draggable window. */
|
|
1483
|
+
.sv-grid-chart-brush {
|
|
1484
|
+
display: block;
|
|
1485
|
+
width: 100%;
|
|
1486
|
+
height: auto;
|
|
1487
|
+
margin-top: 4px;
|
|
1488
|
+
border-top: 1px solid var(--sg-border, #e2e8f0);
|
|
1489
|
+
user-select: none;
|
|
1490
|
+
touch-action: none;
|
|
1491
|
+
}
|
|
1492
|
+
.sv-grid-chart-brush-mask {
|
|
1493
|
+
fill: var(--sg-bg, #ffffff);
|
|
1494
|
+
fill-opacity: 0.65;
|
|
1495
|
+
pointer-events: none;
|
|
1496
|
+
}
|
|
1497
|
+
.sv-grid-chart-brush-window {
|
|
1498
|
+
fill: var(--sg-accent, #2563eb);
|
|
1499
|
+
fill-opacity: 0.10;
|
|
1500
|
+
stroke: var(--sg-accent, #2563eb);
|
|
1501
|
+
stroke-width: 1;
|
|
1502
|
+
cursor: grab;
|
|
1503
|
+
}
|
|
1504
|
+
.sv-grid-chart-brush-window:active { cursor: grabbing; }
|
|
1505
|
+
.sv-grid-chart-brush-handle {
|
|
1506
|
+
fill: var(--sg-accent, #2563eb);
|
|
1507
|
+
fill-opacity: 0.4;
|
|
1508
|
+
cursor: ew-resize;
|
|
1509
|
+
}
|
|
1510
|
+
.sv-grid-chart-brush-handle:hover { fill-opacity: 0.7; }
|
|
1511
|
+
.sv-grid-chart-scatter {
|
|
1512
|
+
transition: opacity 0.2s ease;
|
|
1513
|
+
stroke-width: 1;
|
|
1514
|
+
}
|
|
1515
|
+
.is-clickable .sv-grid-chart-scatter,
|
|
1516
|
+
.sv-grid-chart-scatter:hover {
|
|
1517
|
+
cursor: pointer;
|
|
1518
|
+
}
|
|
1519
|
+
.sv-grid-chart-cat-hit {
|
|
1520
|
+
fill: transparent;
|
|
1521
|
+
}
|
|
1522
|
+
.sv-grid-chart-cat-hit:focus-visible,
|
|
1523
|
+
.sv-grid-chart-slice:focus-visible {
|
|
1524
|
+
outline: 2px solid var(--sg-accent, #2563eb);
|
|
1525
|
+
outline-offset: 1px;
|
|
1526
|
+
}
|
|
1527
|
+
.sv-grid-chart-datalabel {
|
|
1528
|
+
fill: var(--sg-fg, #0f172a);
|
|
1529
|
+
font-size: 9.5px;
|
|
1530
|
+
font-weight: 600;
|
|
1531
|
+
font-variant-numeric: tabular-nums;
|
|
1532
|
+
pointer-events: none;
|
|
1533
|
+
}
|
|
1534
|
+
.sv-grid-chart-datalabel.on-bar {
|
|
1535
|
+
fill: #fff;
|
|
1536
|
+
}
|
|
1537
|
+
.sv-grid-chart-donut-total {
|
|
1538
|
+
fill: var(--sg-fg, #0f172a);
|
|
1539
|
+
font-size: 16px;
|
|
1540
|
+
font-weight: 800;
|
|
1541
|
+
font-variant-numeric: tabular-nums;
|
|
1542
|
+
}
|
|
1543
|
+
.sv-grid-chart-donut-label {
|
|
1544
|
+
fill: var(--sg-muted, #64748b);
|
|
1545
|
+
font-size: 10px;
|
|
1546
|
+
}
|
|
1547
|
+
.sv-grid-chart-bar {
|
|
1548
|
+
transition: x 0.3s ease, y 0.3s ease, width 0.3s ease, height 0.3s ease;
|
|
1549
|
+
}
|
|
1550
|
+
.sv-grid-chart-dot {
|
|
1551
|
+
transition: cx 0.3s ease, cy 0.3s ease;
|
|
1552
|
+
}
|
|
1553
|
+
.is-clickable .sv-grid-chart-cat-hit,
|
|
1554
|
+
.is-clickable .sv-grid-chart-slice {
|
|
1555
|
+
cursor: pointer;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
.sv-grid-chart-empty {
|
|
1559
|
+
position: absolute;
|
|
1560
|
+
inset: 0;
|
|
1561
|
+
display: flex;
|
|
1562
|
+
align-items: center;
|
|
1563
|
+
justify-content: center;
|
|
1564
|
+
color: var(--sg-muted, #94a3b8);
|
|
1565
|
+
font-size: 13px;
|
|
1566
|
+
pointer-events: none;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/* Toolbar above the chart: reset-zoom, PNG / SVG / Copy. */
|
|
1570
|
+
.sv-grid-chart-toolbar {
|
|
1571
|
+
display: flex;
|
|
1572
|
+
justify-content: flex-end;
|
|
1573
|
+
gap: 4px;
|
|
1574
|
+
flex-wrap: wrap;
|
|
1575
|
+
}
|
|
1576
|
+
.sv-grid-chart-tool {
|
|
1577
|
+
display: inline-flex;
|
|
1578
|
+
align-items: center;
|
|
1579
|
+
gap: 4px;
|
|
1580
|
+
border: 1px solid var(--sg-border, #cbd5e1);
|
|
1581
|
+
background: var(--sg-bg, #ffffff);
|
|
1582
|
+
color: var(--sg-fg, #1e293b);
|
|
1583
|
+
font-size: 11px;
|
|
1584
|
+
font-weight: 500;
|
|
1585
|
+
padding: 3px 8px;
|
|
1586
|
+
border-radius: 4px;
|
|
1587
|
+
cursor: pointer;
|
|
1588
|
+
transition: background-color 100ms ease, border-color 100ms ease;
|
|
1589
|
+
}
|
|
1590
|
+
.sv-grid-chart-tool:hover {
|
|
1591
|
+
background: var(--sg-row-hover-bg, #f1f5f9);
|
|
1592
|
+
border-color: var(--sg-accent, #2563eb);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/* Drag-to-zoom: translucent accent rectangle over the plot. */
|
|
1596
|
+
.sv-grid-chart-svg.is-zoomable { cursor: crosshair; }
|
|
1597
|
+
.sv-grid-chart-svg.is-dragging { cursor: crosshair; user-select: none; }
|
|
1598
|
+
.sv-grid-chart-zoom-rect {
|
|
1599
|
+
fill: var(--sg-accent, #2563eb);
|
|
1600
|
+
fill-opacity: 0.12;
|
|
1601
|
+
stroke: var(--sg-accent, #2563eb);
|
|
1602
|
+
stroke-width: 1;
|
|
1603
|
+
stroke-dasharray: 3 3;
|
|
1604
|
+
pointer-events: none;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
.sv-grid-chart-tooltip {
|
|
1608
|
+
position: absolute;
|
|
1609
|
+
z-index: 10;
|
|
1610
|
+
transform: translate(-50%, calc(-100% - 12px));
|
|
1611
|
+
pointer-events: none;
|
|
1612
|
+
white-space: nowrap;
|
|
1613
|
+
padding: 6px 9px;
|
|
1614
|
+
border-radius: 6px;
|
|
1615
|
+
background: var(--sg-fg, #0f172a);
|
|
1616
|
+
color: var(--sg-bg, #fff);
|
|
1617
|
+
font-size: 11.5px;
|
|
1618
|
+
line-height: 1.4;
|
|
1619
|
+
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
|
|
1620
|
+
display: flex;
|
|
1621
|
+
flex-direction: column;
|
|
1622
|
+
gap: 1px;
|
|
1623
|
+
}
|
|
1624
|
+
.sv-grid-chart-tooltip.is-below {
|
|
1625
|
+
transform: translate(-50%, 16px);
|
|
1626
|
+
}
|
|
1627
|
+
.sv-grid-chart-tooltip-title {
|
|
1628
|
+
opacity: 0.75;
|
|
1629
|
+
font-size: 10.5px;
|
|
1630
|
+
margin-bottom: 1px;
|
|
1631
|
+
}
|
|
1632
|
+
.sv-grid-chart-tooltip-row {
|
|
1633
|
+
display: flex;
|
|
1634
|
+
align-items: center;
|
|
1635
|
+
gap: 6px;
|
|
1636
|
+
}
|
|
1637
|
+
.sv-grid-chart-tooltip-dot {
|
|
1638
|
+
width: 8px;
|
|
1639
|
+
height: 8px;
|
|
1640
|
+
border-radius: 2px;
|
|
1641
|
+
flex-shrink: 0;
|
|
1642
|
+
}
|
|
1643
|
+
.sv-grid-chart-tooltip-row-label {
|
|
1644
|
+
opacity: 0.85;
|
|
1645
|
+
}
|
|
1646
|
+
.sv-grid-chart-tooltip-row-value {
|
|
1647
|
+
margin-left: auto;
|
|
1648
|
+
padding-left: 10px;
|
|
1649
|
+
font-weight: 700;
|
|
1650
|
+
font-variant-numeric: tabular-nums;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
.sv-grid-chart-legend {
|
|
1654
|
+
display: flex;
|
|
1655
|
+
flex-wrap: wrap;
|
|
1656
|
+
gap: 6px 12px;
|
|
1657
|
+
font-size: 12px;
|
|
1658
|
+
color: var(--sg-fg, #0f172a);
|
|
1659
|
+
}
|
|
1660
|
+
.sv-grid-chart-legend-item {
|
|
1661
|
+
display: inline-flex;
|
|
1662
|
+
align-items: center;
|
|
1663
|
+
gap: 6px;
|
|
1664
|
+
border: 0;
|
|
1665
|
+
background: transparent;
|
|
1666
|
+
color: inherit;
|
|
1667
|
+
font: inherit;
|
|
1668
|
+
padding: 1px 2px;
|
|
1669
|
+
border-radius: 4px;
|
|
1670
|
+
cursor: pointer;
|
|
1671
|
+
}
|
|
1672
|
+
.sv-grid-chart-legend-item:disabled {
|
|
1673
|
+
cursor: default;
|
|
1674
|
+
}
|
|
1675
|
+
.sv-grid-chart-legend-item.is-off {
|
|
1676
|
+
color: var(--sg-muted, #94a3b8);
|
|
1677
|
+
text-decoration: line-through;
|
|
1678
|
+
}
|
|
1679
|
+
.sv-grid-chart-legend-item.is-isolated {
|
|
1680
|
+
background: var(--sg-header-bg, #f1f5f9);
|
|
1681
|
+
font-weight: 700;
|
|
1682
|
+
}
|
|
1683
|
+
.sv-grid-chart-legend-more {
|
|
1684
|
+
border: 1px dashed var(--sg-border, #cbd5e1);
|
|
1685
|
+
background: transparent;
|
|
1686
|
+
color: var(--sg-muted, #64748b);
|
|
1687
|
+
font: inherit;
|
|
1688
|
+
font-size: 11px;
|
|
1689
|
+
padding: 1px 7px;
|
|
1690
|
+
border-radius: 999px;
|
|
1691
|
+
cursor: pointer;
|
|
1692
|
+
}
|
|
1693
|
+
.sv-grid-chart-legend-more:hover {
|
|
1694
|
+
color: var(--sg-fg, #0f172a);
|
|
1695
|
+
border-color: var(--sg-accent, #2563eb);
|
|
1696
|
+
}
|
|
1697
|
+
.sv-grid-chart-sr-only {
|
|
1698
|
+
position: absolute;
|
|
1699
|
+
width: 1px;
|
|
1700
|
+
height: 1px;
|
|
1701
|
+
padding: 0;
|
|
1702
|
+
margin: -1px;
|
|
1703
|
+
overflow: hidden;
|
|
1704
|
+
clip: rect(0 0 0 0);
|
|
1705
|
+
white-space: nowrap;
|
|
1706
|
+
border: 0;
|
|
1707
|
+
}
|
|
1708
|
+
.sv-grid-chart-swatch {
|
|
1709
|
+
width: 12px;
|
|
1710
|
+
height: 12px;
|
|
1711
|
+
border-radius: 3px;
|
|
1712
|
+
border: 1.5px solid transparent;
|
|
1713
|
+
display: inline-block;
|
|
1714
|
+
box-sizing: border-box;
|
|
1715
|
+
}
|
|
1716
|
+
</style>
|