@svgrid/grid 2.2.32 → 2.4.0

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