@svgrid/grid 3.0.0 → 3.0.2

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