@svgrid/grid 3.0.2 → 3.0.4

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 (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
@@ -7,125 +7,196 @@
7
7
  * focus tooltips, a clickable legend that toggles series, optional data
8
8
  * labels, and an `onSelect` drill hook. No external charting dependency.
9
9
  */
10
- import type { Snippet } from 'svelte'
11
- import { buildChart, chartScales, DEFAULT_PALETTE, formatChartValue, sliceChartWindow, type ChartSpec, type ChartSelection, type ChartGeometry, type ChartScales } from './chart'
12
-
13
- type Props = {
14
- spec: ChartSpec
15
- /** Show the (clickable) legend. Default true. */
16
- legend?: boolean
17
- /** Enable tooltips + crosshair + legend toggling. Default true. */
18
- interactive?: boolean
19
- /** Draw the value on each bar / point / slice. Default false. */
20
- dataLabels?: boolean
21
- /** Format a value for tooltips, data labels, AND Y-axis ticks. */
22
- formatValue?: (value: number) => string
23
- /** Fired when a category / slice is clicked (drill into the grid). */
24
- onSelect?: (selection: ChartSelection) => void
25
- /** Like `onSelect`, but only fires when the spec carries `rowIds` and
26
- * includes them in the payload. Use this to filter / highlight the
27
- * source grid when the user clicks a chart element. */
28
- onDrill?: (selection: ChartSelection) => void
29
- /** Allow drag-to-zoom on the plot area. Double-click resets.
30
- * Only meaningful for cartesian charts (not pie). Default false. */
31
- zoomable?: boolean
32
- /** Show a compact brush / mini-map under the main chart. A draggable
33
- * window selects what the main chart shows. Pairs naturally with
34
- * `zoomable`. Default false. */
35
- brush?: boolean
36
- /** Height of the brush strip in pixels. Default 88. */
37
- brushHeight?: number
38
- /** Show a small toolbar above the chart: reset-zoom (when zoomed) and
39
- * export (PNG / SVG / copy). Default true when zoomable or onDrill is
40
- * set, otherwise false. Set to `false` to hide explicitly. */
41
- toolbar?: boolean
42
- /** Explicit chart pixel size (viewBox). When set, the chart lays out to
43
- * fit exactly - used by the docked panel to size the chart to its body. */
44
- width?: number
45
- height?: number
46
- /**
47
- * Draw your own marks in the chart's own coordinates - the custom-series
48
- * seam. `underlay` paints beneath the built-in marks (bands, shaded
49
- * regions, a background you want the bars to sit on); `overlay` paints
50
- * above them but below the crosshair and the hit layer, so tooltips and
51
- * clicks keep working over whatever you draw.
52
- *
53
- * Both receive the laid-out geometry and its scales, so a custom mark lands
54
- * on exactly the axis the built-in ones did:
55
- *
56
- * ```svelte
57
- * <SvChart {spec}>
58
- * {#snippet overlay({ geo, xOf, yOf })}
59
- * <circle cx={xOf(3)} cy={yOf(120)} r="5" fill="tomato" />
60
- * {/snippet}
61
- * </SvChart>
62
- * ```
63
- *
64
- * `xOf` / `yOf` are null for the types with no cartesian axes (pie, gauge,
65
- * treemap, sankey, calendar, radar, funnel); `geo` is always there.
66
- */
67
- underlay?: Snippet<[ChartRenderContext]>
68
- overlay?: Snippet<[ChartRenderContext]>
69
- /**
70
- * Let a reader pin annotations on the plot. Adds an "Annotate" toggle to
71
- * the toolbar; while it is on, clicking the plot fires `onAnnotate` with
72
- * the point that was clicked, and clicking an existing marker fires
73
- * `onAnnotationRemove` with its index.
74
- *
75
- * The chart does NOT store them. It owns the gesture - which you cannot
76
- * build from outside, because drag is already zoom and click is already
77
- * drill - and hands back the data point; where the annotations live, and
78
- * whether they survive a reload, is the host's business. Put what comes
79
- * back into `spec.annotations` and they render like any other.
80
- */
81
- annotatable?: boolean
82
- /**
83
- * A point the reader picked while annotating. It is a DATA point, not a
84
- * pixel: annotate mode takes over the category gesture, so a note anchors
85
- * to the category it was pinned on and stays there when the chart is
86
- * re-laid-out, re-sorted or zoomed. That also means it works from the
87
- * keyboard, since the category hit zones already navigate with arrows.
88
- */
89
- onAnnotate?: (at: {
90
- /** The category picked, and its index. */
91
- category: string
92
- index: number
93
- /** The value at that category on the first series. */
94
- value: number
95
- }) => void
96
- /** An existing `spec.annotations` entry was clicked, by index. */
97
- onAnnotationRemove?: (index: number) => void
98
- }
99
- /** What a custom mark is handed. `scales` is null on non-cartesian types. */
100
- type ChartRenderContext = {
101
- geo: ChartGeometry
102
- /** Pixel x at the centre of category `i`. Null when there is no x axis. */
103
- xOf: ChartScales['xOf'] | null
104
- /** Pixel y for a value. Null when there is no value axis. */
105
- yOf: ChartScales['yOf'] | null
106
- scales: ChartScales | null
107
- }
10
+ import { buildChart, chartScales, chartStyleVars, DEFAULT_PALETTE, formatChartValue, sliceChartWindow, decimateSpec, matchResponsiveRules, resolveResponsive, type ChartSpec, type ChartSelection } from './chart'
11
+ import { dateGrain, fmtDateFull } from './chart-scale'
12
+ import { layoutDataLabels, markerPath } from './chart-cartesian'
13
+ import { nearestIndexByTime, panWindow, pinchWindow, presetWindow, wheelWindow, type ChartRangePreset } from './chart-zoom'
14
+ import { publishSyncHover, publishSyncZoom, syncGroupState } from './chart-sync.svelte'
15
+ import { interpolateGeometry } from './chart-motion'
16
+ import { drillTree, pathTo } from './chart-hierarchy'
17
+ import type { ChartDataLabelConfig, ChartDrawing, ChartDrawingKind, ChartGeometry, ChartPointRef, TreeNode } from './chart-types'
18
+ import { downloadChartCsv, chartCsvExportable } from './chart-export'
19
+ import type { MenuItem } from './menu-item'
20
+ import { tick, untrack } from 'svelte'
21
+ import type { ChartZoomWindow } from './chart-types'
22
+ import { chartMessage as msg, resolveChartMessages } from './chart-messages'
23
+ import { DEV } from 'esm-env'
24
+ import { overlayName } from './chart-indicators'
25
+ import type { ChartAnimateConfig, ChartContextTarget, ChartHoverPoint, ChartRenderContext, ChartTooltipContext, ChartTooltipRow, ChartZoomConfig, SvChartProps } from './SvGridChart.types'
108
26
  let {
109
- spec,
27
+ spec: specProp,
110
28
  legend = true,
29
+ legendItem,
111
30
  interactive = true,
112
- dataLabels = false,
31
+ dataLabels,
113
32
  formatValue,
33
+ tooltip,
34
+ tooltipFormat,
35
+ tooltipMode = 'shared',
36
+ tooltipPosition = 'follow',
37
+ tooltipSticky = false,
114
38
  onSelect,
115
39
  onDrill,
116
40
  zoomable = false,
41
+ zoom = $bindable(null),
42
+ onZoom,
43
+ rangePresets = false,
44
+ syncGroup,
45
+ contextMenu = false,
46
+ animate = false,
47
+ drillable = false,
48
+ drillPath = $bindable([]),
49
+ selectable = false,
50
+ selected = $bindable([]),
51
+ onSelectionChange,
52
+ announce = true,
53
+ describe = true,
54
+ live = false,
55
+ onHover,
56
+ hoverHighlight = true,
57
+ localeText,
58
+ crosshairLabels = true,
117
59
  brush = false,
118
60
  brushHeight = 88,
119
61
  toolbar,
120
62
  width,
121
63
  height,
64
+ autosize = false,
122
65
  underlay,
123
66
  overlay,
124
67
  annotatable = false,
125
68
  onAnnotate,
126
69
  onAnnotationRemove,
127
- }: Props = $props()
128
- const showToolbar = $derived(toolbar ?? (zoomable || !!onDrill || annotatable))
70
+ drawable = false,
71
+ onDrawingsChange,
72
+ }: SvChartProps = $props()
73
+ /** The chart's own strings, `localeText` merged over the English defaults. */
74
+ const t = $derived(resolveChartMessages(localeText))
75
+ /** The chart in a sentence, for the SVG's description and the menu. */
76
+ let summary = $state('')
77
+ $effect(() => {
78
+ if (!describe) { summary = ''; return }
79
+ const s = visibleSpec
80
+ let live = true
81
+ void import('./chart-summary').then(({ chartSummary }) => { if (live) summary = chartSummary(s) })
82
+ return () => { live = false }
83
+ })
84
+ async function copySummary() {
85
+ if (!summary) return
86
+ try { await navigator.clipboard?.writeText(summary) } catch { /* no clipboard: the live region still reads it */ }
87
+ liveText = `${t.describeCopied}. ${summary}`
88
+ }
89
+ // In development, say what is off about a spec: a misspelt key, a values
90
+ // array one short of the categories, a log axis pinned at zero. Once per
91
+ // spec object, from the lazily loaded validator, so production and the
92
+ // chart chunk pay nothing for it.
93
+ if (DEV) {
94
+ $effect(() => {
95
+ const s = specProp
96
+ void import('./chart-validate').then(({ warnChartSpec }) => warnChartSpec(s))
97
+ })
98
+ }
99
+ /**
100
+ * The size the responsive rules are judged at: the `width` / `height`
101
+ * props, else the host's box under `autosize`, else the spec's own size.
102
+ * The component's size, not the plot's, on purpose: the plot is narrower
103
+ * when the legend sits beside it, and a rule that moves the legend would
104
+ * otherwise change the very width it was matched on.
105
+ */
106
+ /** The host's box (a ResizeObserver underneath; 0 in jsdom). */
107
+ let hostW = $state(0)
108
+ let hostH = $state(0)
109
+ const ruleW = $derived(width ?? (autosize && hostW > 24 ? hostW : specProp.width ?? 520))
110
+ const ruleH = $derived(height ?? (autosize && hostH > 24 ? hostH : specProp.height ?? 300))
111
+ /** The legend a responsive rule asks for at that size. */
112
+ const responsiveLegend = $derived.by(() => {
113
+ if (!specProp.responsive?.length) return undefined
114
+ let out: boolean | 'top' | 'bottom' | 'left' | 'right' | undefined
115
+ for (const r of matchResponsiveRules(specProp.responsive, ruleW, ruleH)) if (r.legend !== undefined) out = r.legend
116
+ return out
117
+ })
118
+ /**
119
+ * The spec with its responsive rules applied at that size. Resolved here,
120
+ * once, so everything the component reads off the spec (axis titles, the
121
+ * legend's series, the label config) agrees with what the engine draws, and
122
+ * the rules are dropped from what goes on to the engine: it would judge
123
+ * them again at the plot's size, and the brush at its own.
124
+ */
125
+ const spec = $derived.by<ChartSpec>(() => {
126
+ if (!specProp.responsive?.length) return specProp
127
+ const { responsive: _rules, ...resolved } = resolveResponsive(specProp, ruleW, ruleH)
128
+ return resolved
129
+ })
130
+ const legendWanted = $derived(responsiveLegend ?? legend)
131
+ /** Where the legend goes; `true` keeps the historical spot under the plot. */
132
+ const legendPos = $derived(legendWanted === true ? 'bottom' : legendWanted === false ? null : legendWanted)
133
+ const legendSide = $derived(legendPos === 'left' || legendPos === 'right')
134
+
135
+ // ---- Autosize --------------------------------------------------------
136
+ // The host box and the chrome inside it, measured by Svelte's dimension
137
+ // bindings (a ResizeObserver underneath). The SVG is laid out at whatever is
138
+ // left once the toolbar, the legend and the brush have taken their share, so
139
+ // its labels and gutters are computed for the pixels it actually gets rather
140
+ // than scaled down from a default viewBox. jsdom reports 0 for all of these,
141
+ // which falls straight through to the spec size.
142
+ let toolbarH = $state(0)
143
+ let legendW = $state(0)
144
+ let legendH = $state(0)
145
+ let brushH = $state(0)
146
+ const GAP = 8
147
+ const autoSize = $derived.by(() => {
148
+ if (!autosize || hostW <= 24) return null
149
+ // A dimension binding keeps its last value after its element is removed,
150
+ // so a legend hidden at runtime (a responsive rule, `legend={false}`)
151
+ // would go on being subtracted. In a plain block that is a ratchet: the
152
+ // host shrinks by the phantom legend, the plot follows, and again, down
153
+ // to the floor. Read each measurement only while its element is mounted,
154
+ // judged off the raw prop: the resolved spec depends on this size.
155
+ const tb = showToolbar ? toolbarH : 0
156
+ const lgH = legendPos ? legendH : 0
157
+ const lgW = legendPos ? legendW : 0
158
+ const br = brush && !NO_AXIS_TYPES.has(specProp.type) ? brushH : 0
159
+ const w = Math.max(120, Math.round(hostW - (legendSide ? lgW + GAP : 0)))
160
+ const chrome = tb + (legendSide ? 0 : lgH) + br
161
+ // The column is a flex box, so only the chrome that exists adds a gap. A
162
+ // side legend is a three-row grid (toolbar, plot, brush) with a gap
163
+ // between every track, empty or not: two gaps, always. Counting one when
164
+ // the brush was absent left 8px unaccounted for, and in a plain block the
165
+ // host grew by that much every measurement, without end.
166
+ const gaps = legendSide ? 2 * GAP : (tb ? GAP : 0) + (lgH ? GAP : 0) + (br ? GAP : 0)
167
+ const h = hostH > 24 ? Math.max(120, Math.round(hostH - chrome - gaps)) : undefined
168
+ return { width: w, height: h }
169
+ })
170
+ const layoutW = $derived(width ?? autoSize?.width)
171
+ const layoutH = $derived(height ?? autoSize?.height)
172
+
173
+ /** Data-label config with `true` expanded to the defaults. */
174
+ const labelCfg = $derived<ChartDataLabelConfig & { show: boolean }>(
175
+ typeof dataLabels === 'object'
176
+ ? { show: true, ...dataLabels }
177
+ : dataLabels !== undefined
178
+ ? { show: !!dataLabels }
179
+ : spec.dataLabels
180
+ ? { show: spec.dataLabels.show !== false, ...spec.dataLabels }
181
+ : { show: false },
182
+ )
183
+ /** The zoom gestures, with `true` meaning the drag rubber band alone. */
184
+ const zoomCfg = $derived<Required<ChartZoomConfig> | null>(
185
+ !zoomable
186
+ ? null
187
+ : zoomable === true
188
+ ? { drag: true, wheel: false, pinch: false, pan: false, axis: 'x' }
189
+ : {
190
+ drag: zoomable.drag !== false,
191
+ wheel: zoomable.wheel ?? false,
192
+ pinch: zoomable.pinch ?? !!zoomable.wheel,
193
+ pan: zoomable.pan ?? false,
194
+ axis: zoomable.axis ?? 'x',
195
+ },
196
+ )
197
+ /** Pan mode toggled from the toolbar (`pan: 'mode'` or `true`). */
198
+ let panMode = $state(false)
199
+ const showToolbar = $derived(toolbar ?? (!!zoomable || !!onDrill || annotatable || !!rangePresets || drillable || !!drawable))
129
200
  /** Annotate mode is off until the reader asks for it: it takes over the plot
130
201
  * click, which is the drill gesture the rest of the time. */
131
202
  let annotating = $state(false)
@@ -138,8 +209,24 @@
138
209
  : Number.isFinite(v)
139
210
  ? v.toLocaleString(undefined, { maximumFractionDigits: 2 })
140
211
  : String(v)
212
+ // A series on the right axis reads in that axis's format: a margin of 0.29
213
+ // beside a revenue in dollars is "29%", not "$0.29". Without a format of
214
+ // its own the right axis is plain numbers, the way its ticks are drawn.
215
+ const rightFmt = $derived.by<(v: number) => string>(() => {
216
+ const ax = spec.y2Axis
217
+ if (formatValue) return formatValue
218
+ if (ax?.formatter) return (v) => ax.formatter!(v, -1)
219
+ if (ax?.format) return (v) => formatChartValue(v, ax.format!, spec)
220
+ return (v) => (Number.isFinite(v) ? v.toLocaleString(undefined, { maximumFractionDigits: 2 }) : String(v))
221
+ })
222
+ const rightSeries = $derived(new Set(spec.series.filter((s) => s.axis === 'right').map((s) => s.label)))
223
+ /** The formatter for a series' values: the right axis's for a series plotted there, the chart's otherwise. */
224
+ const fmtFor = (series: string | undefined) => (series && rightSeries.has(series) ? rightFmt : fmt)
141
225
 
142
- let hidden = $state(new Set<string>())
226
+ // Series that start hidden come from `visible: false`; after that the set
227
+ // is the reader's, through the legend.
228
+ // svelte-ignore state_referenced_locally
229
+ let hidden = $state(new Set<string>(specProp.series.filter((s) => s.visible === false).map((s) => s.label)))
143
230
  // Isolate: double-clicking a legend chip shows ONLY that series/slice.
144
231
  let isolated = $state<string | null>(null)
145
232
  // Series colours read from --sg-chart-1..8. Declared here because the
@@ -189,17 +276,70 @@
189
276
  // everything else is hidden).
190
277
  const effectiveHidden = $derived.by(() => {
191
278
  if (isolated == null) return hidden
192
- const labels = spec.type === 'pie' ? spec.categories : spec.series.map((s) => s.label)
279
+ const labels = spec.type === 'pie' ? pieCategories : spec.series.map((s) => s.label)
193
280
  return new Set(labels.filter((l) => l !== isolated))
194
281
  })
195
282
 
196
283
  // ---- Zoom state ------------------------------------------------------
197
- // `zoom` holds inclusive integer category indices [i0, i1] of the visible
198
- // window. null = no zoom (show everything). Pie charts don't support
199
- // zoom; their pseudo-categories are slices, not an axis.
200
- let zoom = $state<{ i0: number; i1: number } | null>(null)
284
+ // `zoom` (a bindable prop) holds inclusive integer category indices
285
+ // [i0, i1] of the visible window, plus an optional value-axis window. null
286
+ // = no zoom (show everything). Pie charts don't support zoom; their
287
+ // pseudo-categories are slices, not an axis. Every path that changes it goes
288
+ // through `setZoom` so the `onZoom` callback fires once per change.
201
289
  const isZoomed = $derived(zoom !== null)
202
- function resetZoom() { zoom = null }
290
+ function setZoom(next: ChartZoomWindow | null) {
291
+ const same =
292
+ (next === null && zoom === null) ||
293
+ (next !== null && zoom !== null && next.i0 === zoom.i0 && next.i1 === zoom.i1 &&
294
+ (next.y?.min ?? null) === (zoom.y?.min ?? null) && (next.y?.max ?? null) === (zoom.y?.max ?? null))
295
+ if (same) return
296
+ zoom = next
297
+ onZoom?.(next)
298
+ if (syncGroup && !applyingSync) {
299
+ const n = spec.categories.length
300
+ publishSyncZoom(syncGroup, next, n, me, next ? [spec.categories[next.i0] ?? '', spec.categories[next.i1] ?? ''] : null)
301
+ }
302
+ }
303
+ // ---- Sync group ----------------------------------------------------------
304
+ // Charts in a group publish their hover and window to a shared registry and
305
+ // mirror each other's. `me` tags this chart's writes so it ignores its own
306
+ // echoes; `applyingSync` stops a mirrored zoom from being republished.
307
+ const me = Symbol('svchart')
308
+ let applyingSync = false
309
+ const syncState = $derived(syncGroup ? syncGroupState(syncGroup) : null)
310
+ /** True while the crosshair here comes from another chart in the group. */
311
+ let remoteHover = $state(false)
312
+ /** Zoom to a window from outside: `bind:this={chart}; chart.zoomTo({ i0, i1 })`. */
313
+ export function zoomTo(window: ChartZoomWindow | null) { setZoom(window) }
314
+ /** Show the whole chart again. */
315
+ export function resetZoom() { setZoom(null) }
316
+ /** The category under a client x, in FULL-spec indices (not the window's). */
317
+ function fullIndexAtClientX(clientX: number, target: Element): number | null {
318
+ const i = catAtClientX(clientX, target)
319
+ if (i == null) return null
320
+ return (zoom ? zoom.i0 : 0) + i
321
+ }
322
+ /**
323
+ * Mouse wheel zoom around the pointer. With `wheel: 'modifier'` a plain
324
+ * wheel scrolls the page as usual and Ctrl / Cmd + wheel zooms; with `true`
325
+ * the chart takes every wheel event over the plot.
326
+ */
327
+ function onWheel(e: WheelEvent) {
328
+ if (!zoomCfg?.wheel || !isCartesian || annotating) return
329
+ if (zoomCfg.wheel === 'modifier' && !(e.ctrlKey || e.metaKey)) return
330
+ const p = svgPoint(e)
331
+ if (!p || p.x < geo.plot.x || p.x > geo.plot.x + geo.plot.w) return
332
+ e.preventDefault()
333
+ const anchor = fullIndexAtClientX(e.clientX, e.currentTarget as Element) ?? 0
334
+ const factor = e.deltaY > 0 ? 1.25 : 0.8
335
+ setZoom(wheelWindow(zoom, spec.categories.length, anchor, factor))
336
+ }
337
+ // ---- Pinch: two pointers on the plot. --------------------------------
338
+ const pointers = new Map<number, { x: number; y: number }>()
339
+ let pinch: { dist: number; mid: number; start: ChartZoomWindow | null } | null = null
340
+ let panDrag: { startIndex: number; start: ChartZoomWindow | null } | null = null
341
+ const panActive = (e: PointerEvent) =>
342
+ !!zoomCfg?.pan && isZoomed && (panMode || ((zoomCfg.pan === 'shift' || zoomCfg.pan === true) && e.shiftKey))
203
343
 
204
344
  // Drag-to-zoom: track pointer in SVG-local coordinates so the overlay
205
345
  // rect aligns with what the user is actually selecting on screen.
@@ -215,7 +355,7 @@
215
355
  return w > 4 && h > 4 ? { x, y, w, h } : null
216
356
  })
217
357
 
218
- function svgPoint(e: PointerEvent): { x: number; y: number } | null {
358
+ function svgPoint(e: { clientX: number; clientY: number }): { x: number; y: number } | null {
219
359
  if (!svgEl) return null
220
360
  const r = svgEl.getBoundingClientRect()
221
361
  return {
@@ -231,35 +371,82 @@
231
371
  }
232
372
  function onZoomDown(e: PointerEvent) {
233
373
  // Annotate mode takes the plot pointer over. Without this a click to pin an
234
- // annotation also starts a zoom drag, and the reader gets both.
235
- if (annotating) return
236
- if (!zoomable || !isCartesian) return
374
+ // annotation also starts a zoom drag, and the reader gets both. A drawing
375
+ // tool or a handle drag does the same.
376
+ if (annotating || tool || handleDrag) return
377
+ if (!zoomCfg || !isCartesian) return
237
378
  const p = svgPoint(e)
238
379
  if (!p) return
239
380
  // Only start a drag inside the plot rect.
240
381
  if (p.x < geo.plot.x || p.x > geo.plot.x + geo.plot.w ||
241
382
  p.y < geo.plot.y || p.y > geo.plot.y + geo.plot.h) return
383
+ ;(e.target as Element).setPointerCapture?.(e.pointerId)
384
+ if (zoomCfg.pinch) {
385
+ pointers.set(e.pointerId, p)
386
+ if (pointers.size === 2) {
387
+ // A second finger turns the drag into a pinch.
388
+ const [a, b] = [...pointers.values()]
389
+ pinch = { dist: Math.hypot(a!.x - b!.x, a!.y - b!.y) || 1, mid: pointToCatIndex({ x: (a!.x + b!.x) / 2, y: (a!.y + b!.y) / 2 }) + (zoom ? zoom.i0 : 0), start: zoom }
390
+ dragStart = null
391
+ dragEnd = null
392
+ return
393
+ }
394
+ }
395
+ if (panActive(e)) {
396
+ panDrag = { startIndex: pointToCatIndex(p), start: zoom }
397
+ return
398
+ }
399
+ if (!zoomCfg.drag) return
242
400
  dragStart = p
243
401
  dragEnd = p
244
- ;(e.target as Element).setPointerCapture?.(e.pointerId)
245
402
  }
246
403
  function onZoomMove(e: PointerEvent) {
404
+ if (pinch && pointers.has(e.pointerId)) {
405
+ const p = svgPoint(e)
406
+ if (p) pointers.set(e.pointerId, p)
407
+ const [a, b] = [...pointers.values()]
408
+ if (a && b) {
409
+ const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1
410
+ setZoom(pinchWindow(pinch.start, spec.categories.length, pinch.mid, dist / pinch.dist))
411
+ }
412
+ return
413
+ }
414
+ if (panDrag) {
415
+ const p = svgPoint(e)
416
+ if (!p) return
417
+ // Categories moved under the pointer since the drag began; the window
418
+ // slides the other way, so the data follows the hand.
419
+ const delta = panDrag.startIndex - pointToCatIndex(p)
420
+ setZoom(panWindow(panDrag.start, spec.categories.length, delta))
421
+ return
422
+ }
247
423
  if (!dragStart) return
248
424
  dragEnd = svgPoint(e)
249
425
  }
250
- function onZoomUp() {
426
+ function onZoomUp(e?: PointerEvent) {
427
+ if (e) pointers.delete(e.pointerId)
428
+ if (pinch) { if (pointers.size < 2) pinch = null; return }
429
+ if (panDrag) { panDrag = null; return }
251
430
  if (!dragStart || !dragEnd) { dragStart = null; dragEnd = null; return }
252
431
  const r = dragRect
253
432
  if (r) {
254
433
  // Map the drag's main-axis bounds to category indices. When already
255
434
  // zoomed, these indices are relative to the visible slice, so
256
435
  // translate back to the original `spec.categories` indexing.
436
+ const axis = zoomCfg?.axis ?? 'x'
257
437
  const i0v = pointToCatIndex({ x: r.x, y: r.y })
258
438
  const i1v = pointToCatIndex({ x: r.x + r.w, y: r.y + r.h })
259
439
  const offset = zoom ? zoom.i0 : 0
260
- const lo = offset + Math.min(i0v, i1v)
261
- const hi = offset + Math.max(i0v, i1v)
262
- if (hi > lo) zoom = { i0: lo, i1: hi }
440
+ const lo = axis === 'y' ? (zoom?.i0 ?? 0) : offset + Math.min(i0v, i1v)
441
+ const hi = axis === 'y' ? (zoom?.i1 ?? spec.categories.length - 1) : offset + Math.max(i0v, i1v)
442
+ // A y window through the inverse scale, when the gesture covers the y axis.
443
+ let y: ChartZoomWindow['y'] | undefined
444
+ if (axis !== 'x' && renderScales && !isHorizontal) {
445
+ const top = renderScales.yInvert(r.y)
446
+ const bottom = renderScales.yInvert(r.y + r.h)
447
+ if (Number.isFinite(top) && Number.isFinite(bottom) && top !== bottom) y = { min: Math.min(top, bottom), max: Math.max(top, bottom) }
448
+ }
449
+ if (hi > lo || y) setZoom({ i0: lo, i1: Math.max(lo, hi), ...(y ? { y } : {}) })
263
450
  }
264
451
  dragStart = null
265
452
  dragEnd = null
@@ -268,6 +455,139 @@
268
455
  if (zoomable && isZoomed) resetZoom()
269
456
  }
270
457
 
458
+ // ---- Drawing tools -------------------------------------------------------
459
+ // A tool takes the plot click over (like annotate mode) and turns pixels
460
+ // into DATA points, so the drawing the host stores in `spec.drawings` lands
461
+ // on the same values after a zoom, a resize or new data. The component
462
+ // holds nothing but the gesture in flight and which drawing is selected.
463
+ const ALL_TOOLS: ChartDrawingKind[] = ['trend', 'hray', 'fib', 'rect', 'arrow', 'text']
464
+ const TOOL_LABEL = $derived<Record<ChartDrawingKind, string>>({ trend: t.toolTrend, hray: t.toolRay, fib: t.toolFib, rect: t.toolRect, arrow: t.toolArrow, text: t.toolText })
465
+ const toolKinds = $derived<ChartDrawingKind[]>(!drawable ? [] : drawable === true ? ALL_TOOLS : drawable)
466
+ let tool = $state<ChartDrawingKind | null>(null)
467
+ /** The first point of a two-point drawing, waiting for the second. */
468
+ let pending = $state<{ x: number | string; y: number; px: number; py: number } | null>(null)
469
+ /** The pointer while a two-point drawing is being placed, for the preview. */
470
+ let drawCursor = $state<{ x: number; y: number } | null>(null)
471
+ let selectedDrawing = $state<string | null>(null)
472
+ /** A note being typed: its data anchor and where the box sits over the host. */
473
+ let textDraft = $state<{ x: number | string; y: number; left: number; top: number } | null>(null)
474
+ // The typed text lives apart from the draft: a binding into a nullable
475
+ // object reads it once more as the box unmounts.
476
+ let textDraftValue = $state('')
477
+ function commitTextDraft() {
478
+ const d = textDraft
479
+ const text = textDraftValue.trim()
480
+ textDraft = null
481
+ textDraftValue = ''
482
+ if (!d || !text) return
483
+ commitDrawings([...drawingsNow(), { id: `d${Date.now().toString(36)}${drawSeq++}`, kind: 'text', points: [{ x: d.x, y: d.y }], text }])
484
+ }
485
+ /** A handle being dragged: which drawing and which of its points. */
486
+ let handleDrag = $state<{ id: string; index: number } | null>(null)
487
+ let drawSeq = 0
488
+ const drawingsNow = () => spec.drawings ?? []
489
+ /** The data-space x under a plot pixel: a label on a category or ordinal
490
+ * axis, an ISO date on a time axis, a number on a numeric one. */
491
+ function dataXAt(px: number): string | number | null {
492
+ if (!renderScales) return null
493
+ const ax = geo.axes?.x
494
+ if (ax?.type === 'number') return renderScales.xInvertValue(px)
495
+ if (ax?.type === 'time') return new Date(renderScales.xInvertValue(px)).toISOString()
496
+ const i = Math.round(renderScales.xInvert(px))
497
+ return visibleSpec.categories[i] ?? null
498
+ }
499
+ function dataPointAt(e: { clientX: number; clientY: number }): { x: number | string; y: number; px: number; py: number } | null {
500
+ const vp = svgPoint(e)
501
+ if (!vp || !renderScales) return null
502
+ if (vp.x < geo.plot.x || vp.x > geo.plot.x + geo.plot.w || vp.y < geo.plot.y || vp.y > geo.plot.y + geo.plot.h) return null
503
+ const x = dataXAt(vp.x)
504
+ if (x == null) return null
505
+ const y = renderScales.yInvert(vp.y)
506
+ if (!Number.isFinite(y)) return null
507
+ return { x, y: Math.round(y * 1000) / 1000, px: vp.x, py: vp.y }
508
+ }
509
+ function commitDrawings(next: ChartDrawing[]) {
510
+ onDrawingsChange?.(next)
511
+ }
512
+ function pickTool(kind: ChartDrawingKind) {
513
+ tool = tool === kind ? null : kind
514
+ pending = null
515
+ drawCursor = null
516
+ textDraft = null
517
+ }
518
+ function onDrawClick(e: MouseEvent) {
519
+ if (!tool) return
520
+ const pt = dataPointAt(e)
521
+ if (!pt) return
522
+ if (tool === 'text') {
523
+ // The box is positioned over the host at the click; Enter keeps the
524
+ // note, Escape or an empty box drops it.
525
+ const r = chartEl?.getBoundingClientRect()
526
+ textDraftValue = ''
527
+ textDraft = { x: pt.x, y: pt.y, left: r ? e.clientX - r.left : 0, top: r ? e.clientY - r.top : 0 }
528
+ return
529
+ }
530
+ if (tool === 'hray') {
531
+ commitDrawings([...drawingsNow(), { id: `d${Date.now().toString(36)}${drawSeq++}`, kind: tool, points: [{ x: pt.x, y: pt.y }] }])
532
+ return
533
+ }
534
+ if (!pending) { pending = pt; drawCursor = { x: pt.px, y: pt.py }; return }
535
+ const first = pending
536
+ pending = null
537
+ drawCursor = null
538
+ commitDrawings([...drawingsNow(), { id: `d${Date.now().toString(36)}${drawSeq++}`, kind: tool, points: [{ x: first.x, y: first.y }, { x: pt.x, y: pt.y }] }])
539
+ }
540
+ function onDrawMove(e: PointerEvent) {
541
+ if (handleDrag) {
542
+ const pt = dataPointAt(e)
543
+ if (!pt) return
544
+ const { id, index } = handleDrag
545
+ commitDrawings(drawingsNow().map((d) => (d.id === id ? { ...d, points: d.points.map((q, k) => (k === index ? { x: pt.x, y: pt.y } : q)) } : d)))
546
+ return
547
+ }
548
+ if (pending) {
549
+ const vp = svgPoint(e)
550
+ if (vp) drawCursor = vp
551
+ }
552
+ }
553
+ function removeDrawing(id: string) {
554
+ commitDrawings(drawingsNow().filter((d) => d.id !== id))
555
+ if (selectedDrawing === id) selectedDrawing = null
556
+ }
557
+ function onDrawKey(e: KeyboardEvent, id: string) {
558
+ if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); removeDrawing(id) }
559
+ }
560
+ /** Escape drops a half-placed drawing, then the tool. */
561
+ function onSvgKey(e: KeyboardEvent) {
562
+ if (e.key !== 'Escape') return
563
+ if (pinnedTip) { unpinTip(); return }
564
+ if (!tool) return
565
+ if (pending) { pending = null; drawCursor = null } else tool = null
566
+ }
567
+ /** Release a pinned tooltip and clear the hover it was holding. */
568
+ function unpinTip() {
569
+ pinnedTip = null
570
+ clearActive()
571
+ }
572
+ /** Pin the tooltip on a category click, or release it on the same one. */
573
+ function togglePin(i: number) {
574
+ if (!tooltipSticky || !tip) return
575
+ if (pinnedTip && pinnedTip.index === i) { unpinTip(); return }
576
+ pinnedTip = { index: i, series: tip.ctx.series ?? null }
577
+ }
578
+ // While pinned, a pointer down anywhere outside the chart lets go.
579
+ $effect(() => {
580
+ if (!pinnedTip || typeof document === 'undefined') return
581
+ const onDown = (e: PointerEvent) => {
582
+ if (chartEl && e.target instanceof Node && chartEl.contains(e.target)) return
583
+ unpinTip()
584
+ }
585
+ document.addEventListener('pointerdown', onDown, true)
586
+ return () => document.removeEventListener('pointerdown', onDown, true)
587
+ })
588
+ const focusOnMount = (node: HTMLElement) => { node.focus() }
589
+
590
+
271
591
  // Candles are cartesian, and the `isCartesian` negative list below already
272
592
  // covers them, so they inherit gridlines, the crosshair, category hit bands,
273
593
  // keyboard nav, drag-zoom and double-click reset with no new wiring.
@@ -277,37 +597,64 @@
277
597
  /** OHLC draws ticks off a vertical; candlestick draws a body and a wick. */
278
598
  const isOhlcBars = $derived(spec.type === 'ohlc')
279
599
 
600
+ /**
601
+ * Roughly how many pixels the plot will have across, for decimation. The
602
+ * real plot width comes out of `buildChart`, which needs the spec this
603
+ * decides - so an estimate (the layout width minus the usual gutters) breaks
604
+ * the loop. Being 10% off just means 10% more or fewer points than pixels.
605
+ */
606
+ const decimateW = $derived(Math.max(50, (layoutW ?? spec.width ?? 520) - 60))
607
+
280
608
  // ---- Brush state -----------------------------------------------------
281
609
  // The brush is a compact second chart showing the FULL data range with
282
610
  // a translucent window over the visible slice. Dragging the body pans
283
611
  // the window; dragging either edge resizes one side. Eligible for
284
612
  // cartesian charts only (heatmap / pie don't have a single x-axis).
285
- const brushEligible = $derived(brush && spec.type !== 'pie' && spec.type !== 'heatmap')
613
+ /** The families with no single category axis, where a brush means nothing. */
614
+ const NO_AXIS_TYPES = new Set<string>(['pie', 'heatmap', 'funnel', 'radar', 'calendar', 'gauge', 'treemap', 'sankey', 'sunburst', 'radial-bar', 'radial-column', 'nightingale', 'chord', 'bullet'])
615
+ /** Per-series kinds the brush cannot draw; they read as a line at that scale. */
616
+ const STEM_TYPES = new Set<string>(['lollipop', 'dumbbell', 'range-area', 'range-bar', 'stream', 'pareto'])
617
+ const brushEligible = $derived(brush && !NO_AXIS_TYPES.has(spec.type))
286
618
  // Build a separate geometry from the un-zoomed spec at brushHeight.
287
619
  const brushSpec = $derived<ChartSpec>({
288
620
  ...spec,
621
+ width: layoutW ?? spec.width,
289
622
  height: brushHeight,
290
- // Mute axes in the brush by clearing titles + reference lines.
623
+ // Mute the brush: no titles, axis titles or labels, reference marks,
624
+ // data labels or drawings. A chart title and subtitle alone took 45 of
625
+ // the strip's 88 pixels, the x labels another 30, and the mini-map was
626
+ // a five-pixel smear under an empty band.
627
+ title: undefined, subtitle: undefined, caption: undefined,
291
628
  yAxisTitle: undefined, y2AxisTitle: undefined, xAxisTitle: undefined,
629
+ // Axis labels too: at 88px the x labels (auto-rotated for a long
630
+ // category list) took another 30, and the chart above carries them.
631
+ xAxis: { ...spec.xAxis, title: undefined, labels: false },
632
+ yAxis: { ...spec.yAxis, title: undefined, labels: false },
633
+ y2Axis: { ...spec.y2Axis, title: undefined, labels: false },
292
634
  referenceLines: undefined,
635
+ referenceBands: undefined,
293
636
  annotations: undefined,
637
+ drawings: undefined,
638
+ seriesLabels: undefined,
639
+ dataLabels: { show: false },
294
640
  // The mini-map draws candles as a close-price line: at brush scale a
295
641
  // candle is a couple of pixels wide and reads as noise, while a close line
296
642
  // is the shape a reader actually navigates by.
297
643
  // A box gets the same treatment for the same reason: at brush scale it is
298
644
  // a smear, while the median line is a shape you can navigate by.
299
- type: isCandle ? 'line' : spec.type === 'boxplot' ? 'line' : spec.type,
645
+ type: isCandle || spec.type === 'boxplot' || STEM_TYPES.has(spec.type) ? 'line' : spec.type === 'histogram' ? 'bar' : spec.type,
646
+ stackOffset: undefined,
300
647
  series: coloredSeries
301
648
  .filter((s) => !effectiveHidden.has(s.label))
302
649
  .map((s) =>
303
650
  s.ohlc || s.boxes
304
651
  ? { ...s, ohlc: undefined, boxes: undefined, errors: undefined, type: 'line' as const }
305
- : s.errors
306
- ? { ...s, errors: undefined }
652
+ : s.errors || (s.type && STEM_TYPES.has(s.type)) || s.type === 'scatter'
653
+ ? { ...s, errors: undefined, type: s.type && (STEM_TYPES.has(s.type) || s.type === 'scatter') ? ('line' as const) : s.type }
307
654
  : s,
308
655
  ),
309
656
  })
310
- const brushGeo = $derived(brushEligible ? buildChart(brushSpec) : null)
657
+ const brushGeo = $derived(brushEligible ? buildChart(decimateSpec(brushSpec, decimateW)) : null)
311
658
  /** The brush window in fractional [0..1] of the visible data range,
312
659
  * derived from `zoom`. When not zoomed, the window covers everything. */
313
660
  const brushWindow = $derived.by(() => {
@@ -355,29 +702,75 @@
355
702
  // Translate back to integer category indices.
356
703
  const i0 = Math.max(0, Math.floor(t0 * n))
357
704
  const i1 = Math.min(n - 1, Math.ceil(t1 * n) - 1)
358
- if (i0 === 0 && i1 === n - 1) zoom = null
359
- else zoom = { i0, i1: Math.max(i0, i1) }
705
+ if (i0 === 0 && i1 === n - 1) setZoom(null)
706
+ else setZoom({ i0, i1: Math.max(i0, i1), ...(zoom?.y ? { y: zoom.y } : {}) })
360
707
  }
361
708
  function onBrushUp(e: PointerEvent) {
362
709
  brushDrag = null
363
710
  ;(e.currentTarget as Element).releasePointerCapture?.(e.pointerId)
364
711
  }
365
712
 
713
+ // ---- Drilldown ------------------------------------------------------
714
+ // A tree map, sunburst or pie can descend into a node: the clicked node
715
+ // becomes the root of what is drawn, and the toolbar breadcrumb climbs back
716
+ // up. A pie draws the current node's children as its slices, so a pie with
717
+ // a `tree` and no categories of its own is a drillable pie of the top level.
718
+ const drillRoot = $derived<TreeNode | null>(spec.tree ?? spec.treemap ?? null)
719
+ const canDrill = $derived(drillable && !!drillRoot && (spec.type === 'sunburst' || spec.type === 'treemap' || spec.type === 'pie'))
720
+ const drilledRoot = $derived<TreeNode | null>(canDrill && drillPath.length ? drillTree(drillRoot!, drillPath) : drillRoot)
721
+ const treeTotal = (n: TreeNode): number => n.value ?? (n.children ?? []).reduce((a, c) => a + treeTotal(c), 0)
722
+ /** A pie's slices: the drilled node's children when it drills, else the spec's categories. */
723
+ const pieTree = $derived(spec.type === 'pie' && canDrill && drilledRoot ? drilledRoot : null)
724
+ const pieCategories = $derived(pieTree ? (pieTree.children ?? []).map((k) => k.name) : spec.categories)
725
+ const sliceDrillable = (label: string) => !!pieTree && !!pieTree.children?.find((k) => k.name === label)?.children?.length
726
+ function drillTo(path: string[]) {
727
+ drillPath = path
728
+ }
729
+ /** Descend into a named node when it has children; false when it is a leaf. */
730
+ function drillInto(path: string[] | null): boolean {
731
+ if (!canDrill || !path || !drillRoot) return false
732
+ const node = drillTree(drillRoot, path)
733
+ if (!node?.children?.length) return false
734
+ drillTo(path)
735
+ return true
736
+ }
737
+
366
738
  const visibleSpec = $derived.by<ChartSpec>(() => {
367
- if (spec.type === 'pie') {
739
+ if (pieTree) {
740
+ const kids = pieTree.children ?? []
741
+ const label = spec.series[0]?.label ?? pieTree.name
742
+ const values = kids.map((k) => (effectiveHidden.has(k.name) ? 0 : treeTotal(k)))
743
+ const colors = kids.map((k) => k.color ?? null)
744
+ return { ...spec, categories: pieCategories, series: [{ label, values, ...(colors.some(Boolean) ? { colors } : {}) }] }
745
+ }
746
+ if (canDrill && drilledRoot && drillPath.length) {
747
+ const base = { ...spec, tree: drilledRoot, treemap: drilledRoot }
748
+ return spec.type === 'treemap' ? base : { ...base, series: coloredSeries }
749
+ }
750
+ if (spec.type === 'pie' || arcsByCategory) {
368
751
  const s = coloredSeries[0]
369
752
  if (!s) return spec
370
753
  const values = s.values.map((v, i) => (effectiveHidden.has(spec.categories[i] ?? String(i)) ? 0 : v))
371
754
  return { ...spec, series: [{ ...s, values }] }
372
755
  }
373
756
  const visibleSeries = coloredSeries.filter((s) => !effectiveHidden.has(s.label))
374
- if (!zoom) return { ...spec, series: visibleSeries }
375
757
  // Narrow to the zoom window so buildChart re-spreads the visible slice
376
758
  // across the full plot. The slicing itself lives in chart.ts: this used to
377
759
  // be done by hand here and only covered categories / values / rowIds, so
378
760
  // the confidence-band envelopes kept their full length and the band
379
761
  // silently vanished as soon as anyone zoomed.
380
- return sliceChartWindow({ ...spec, series: visibleSeries }, zoom.i0, zoom.i1)
762
+ let windowed = zoom
763
+ ? sliceChartWindow({ ...spec, series: visibleSeries }, zoom.i0, zoom.i1)
764
+ : { ...spec, series: visibleSeries }
765
+ // A y window pins the value axis; the marks outside it are clipped by the
766
+ // plot clip, which is the same rule an explicit yAxis.min / max follows.
767
+ if (zoom?.y) {
768
+ const key = zoom.y.axis === 'right' ? 'y2Axis' : 'yAxis'
769
+ windowed = { ...windowed, [key]: { ...(windowed[key] ?? {}), min: zoom.y.min, max: zoom.y.max, nice: false } }
770
+ }
771
+ // Then thin to about a point per pixel. After the slice, deliberately:
772
+ // zooming into 300 of 100k points should show all 300.
773
+ return decimateSpec(windowed, decimateW)
381
774
  })
382
775
 
383
776
  // Heatmap / calendar cell fills are computed colors (not CSS), so they can't
@@ -389,12 +782,81 @@
389
782
  // colours alone: buildChart also picks from `spec.palette` for the marks
390
783
  // that have no series of their own (pie slices, treemap cells), and those
391
784
  // should follow the theme tokens too.
392
- const geo = $derived(
785
+ const geoRaw = $derived(
393
786
  buildChart(
394
- { ...visibleSpec, palette, ...(width || height ? { width, height } : {}) },
787
+ {
788
+ ...visibleSpec,
789
+ palette,
790
+ ...(layoutW || layoutH ? { width: layoutW, height: layoutH } : {}),
791
+ // The prop's label config reaches the engine (a pie lays out callouts
792
+ // for `placement: 'outside'`); the spec's own is the fallback.
793
+ ...(dataLabels !== undefined ? { dataLabels: labelCfg } : {}),
794
+ },
395
795
  isDark ? 'dark' : 'light',
396
796
  ),
397
797
  )
798
+ // ---- Motion ------------------------------------------------------------
799
+ // `animate` resolves to a config; the data-update tween holds the geometry
800
+ // it started from and how far along it is, and `geo` (what the markup
801
+ // draws) is the interpolation while a tween runs and the real layout
802
+ // otherwise. Reduced-motion readers and dense charts never tween.
803
+ const animCfg = $derived<Required<ChartAnimateConfig> | null>(
804
+ !animate
805
+ ? null
806
+ : animate === true
807
+ ? { duration: 400, enter: 'fade', update: true }
808
+ : { duration: animate.duration ?? 400, enter: animate.enter ?? 'fade', update: animate.update ?? true },
809
+ )
810
+ let reducedMotion = $state(false)
811
+ $effect(() => {
812
+ if (typeof matchMedia !== 'function') return
813
+ const mq = matchMedia('(prefers-reduced-motion: reduce)')
814
+ reducedMotion = mq.matches
815
+ const onChange = () => (reducedMotion = mq.matches)
816
+ mq.addEventListener?.('change', onChange)
817
+ return () => mq.removeEventListener?.('change', onChange)
818
+ })
819
+ // The interpolator is a static import on purpose: as a lazy chunk it pulled
820
+ // the arc and line path builders into a shared chunk, and the split cost
821
+ // more (0.8 KB) than the 1.2 KB it moved out.
822
+ let tween = $state<{ from: ChartGeometry; t: number } | null>(null)
823
+ const geo = $derived(tween && tween.t < 1 ? interpolateGeometry(tween.from, geoRaw, tween.t) : geoRaw)
824
+ let prevGeo: ChartGeometry | null = null
825
+ let tweenFrame = 0
826
+ $effect(() => {
827
+ const next = geoRaw
828
+ untrack(() => {
829
+ const from = prevGeo
830
+ prevGeo = next
831
+ if (!from || !animCfg?.update || reducedMotion || animCfg.duration <= 0 || live) return
832
+ // Thousands of marks a frame is not a tween, it is a stall.
833
+ if (next.bars.length + next.arcs.length > 600 || next.lines.some((l) => l.points.length > 600)) return
834
+ if (from.type !== next.type) return
835
+ // Start from wherever the marks are NOW, mid-tween included.
836
+ const start = tween && tween.t < 1 ? interpolateGeometry(tween.from, from, tween.t) : from
837
+ cancelAnimationFrame(tweenFrame)
838
+ const t0 = performance.now()
839
+ const dur = animCfg.duration
840
+ tween = { from: start, t: 0 }
841
+ const step = (now: number) => {
842
+ const p = Math.min(1, (now - t0) / dur)
843
+ const eased = 1 - Math.pow(1 - p, 3)
844
+ tween = p >= 1 ? null : { from: start, t: eased }
845
+ if (p < 1) tweenFrame = requestAnimationFrame(step)
846
+ }
847
+ tweenFrame = requestAnimationFrame(step)
848
+ })
849
+ })
850
+ const enterFx = $derived(reducedMotion || live ? 'none' : (animCfg?.enter ?? 'fade'))
851
+ /** Gradient <defs> for area series that asked for one, keyed by the
852
+ * line's index so the markup can find its own. */
853
+ const gradientDefs = $derived(
854
+ geo.lines.flatMap((l, i) => (l.style?.gradient ? [{ id: `${uid}-grad-${i}`, ...l.style.gradient }] : [])),
855
+ )
856
+ /** The resolved axis titles (the axis object wins over the flat prop). */
857
+ const yTitle = $derived(spec.yAxis?.title ?? spec.yAxisTitle)
858
+ const y2Title = $derived(spec.y2Axis?.title ?? spec.y2AxisTitle)
859
+ const xTitle = $derived(spec.xAxis?.title ?? spec.xAxisTitle)
398
860
  /** What the `underlay` / `overlay` snippets are handed. Built from the
399
861
  * laid-out geometry, so a custom mark shares the built-in scale exactly. */
400
862
  const renderScales = $derived(chartScales(geo))
@@ -405,11 +867,13 @@
405
867
  yOf: renderScales?.yOf ?? null,
406
868
  })
407
869
 
408
- const isCartesian = $derived(
409
- spec.type !== 'pie' && spec.type !== 'heatmap' &&
410
- spec.type !== 'funnel' && spec.type !== 'radar' &&
411
- spec.type !== 'calendar' && spec.type !== 'gauge' &&
412
- spec.type !== 'treemap' && spec.type !== 'sankey',
870
+ const isCartesian = $derived(!NO_AXIS_TYPES.has(spec.type))
871
+ /** The arc family: sunburst, radial bar / column, nightingale, chord. */
872
+ const isArcs = $derived(spec.type === 'sunburst' || spec.type === 'radial-bar' || spec.type === 'radial-column' || spec.type === 'nightingale' || spec.type === 'chord')
873
+ const isBullet = $derived(spec.type === 'bullet')
874
+ /** Arc types whose legend lists CATEGORIES (one series) rather than series. */
875
+ const arcsByCategory = $derived(
876
+ (spec.type === 'radial-bar' || spec.type === 'radial-column' || spec.type === 'nightingale') && spec.series.length === 1,
413
877
  )
414
878
  const isScatter = $derived(spec.type === 'scatter')
415
879
  const isHeatmap = $derived(spec.type === 'heatmap')
@@ -447,6 +911,18 @@
447
911
  isCartesian && !isScatter && !isHorizontal && slot < DENSE_SLOT_PX && visibleSpec.categories.length > 1,
448
912
  )
449
913
 
914
+ /** How many data points the visible window holds BEFORE decimation. The
915
+ * screen-reader label reports the data, not how many marks survived. */
916
+ const windowCount = $derived(zoom ? zoom.i1 - zoom.i0 + 1 : spec.categories.length)
917
+
918
+ /** Positioned data labels for the cartesian marks, thinned for overlap.
919
+ * Pie slices label themselves in the markup. */
920
+ const dataLabelGeo = $derived(
921
+ labelCfg.show && isCartesian && !isScatter
922
+ ? layoutDataLabels(geo, labelCfg, (v, series) => fmtFor(series)(v), { stacked: !!(spec.stacked || spec.stacked100), dense, share: !!spec.stacked100 })
923
+ : [],
924
+ )
925
+
450
926
  /** Rows the screen-reader table will actually render before it is capped. */
451
927
  const SR_ROW_CAP = 1000
452
928
 
@@ -494,6 +970,7 @@
494
970
  /** Focused category in dense mode, where there is one rect rather than n. */
495
971
  let denseIndex = $state(0)
496
972
  function onDenseKey(e: KeyboardEvent) {
973
+ if (menuKey(e, denseIndex) || keyZoom(e, denseIndex)) return
497
974
  const max = visibleSpec.categories.length - 1
498
975
  const step = Math.max(1, Math.floor(max / 10))
499
976
  let next = denseIndex
@@ -506,7 +983,7 @@
506
983
  case 'PageUp': next = Math.max(0, denseIndex - step); break
507
984
  case 'Enter': case ' ':
508
985
  e.preventDefault()
509
- select(visibleSpec.categories[denseIndex] ?? '', '', 0, denseIndex)
986
+ catClick(e, denseIndex)
510
987
  return
511
988
  default: return
512
989
  }
@@ -516,13 +993,80 @@
516
993
  }
517
994
 
518
995
  // Opacity for a series when another legend chip is being hovered.
519
- const dimOf = (seriesLabel: string) => (dimmed && dimmed !== seriesLabel ? 0.18 : 1)
996
+ /** The series under the pointer, for hover highlight; null off the marks. */
997
+ let hoverSeries = $state<string | null>(null)
998
+ const dimActive = $derived(dimmed ?? (hoverHighlight ? hoverSeries : null))
999
+ const dimOf = (seriesLabel: string) => (dimActive && dimActive !== seriesLabel ? 0.18 : 1)
1000
+ /** How close (viewBox px) the pointer must be to a line for it to be "the" series. */
1001
+ const HOVER_SNAP_PX = 18
1002
+ /** The last point reported to `onHover`, so a 60 Hz mousemove reports once. */
1003
+ let lastHover: ChartHoverPoint | null = null
1004
+ function reportHover(p: ChartHoverPoint | null) {
1005
+ if (!onHover) return
1006
+ const same =
1007
+ p === lastHover ||
1008
+ (p !== null && lastHover !== null && p.category === lastHover.category && p.index === lastHover.index && p.series === lastHover.series && p.value === lastHover.value)
1009
+ if (same) return
1010
+ lastHover = p
1011
+ onHover(p)
1012
+ }
1013
+ // ---- Selection ---------------------------------------------------------
1014
+ const selMode = $derived(selectable === 'multi' ? 'multi' : selectable ? 'single' : null)
1015
+ // A ref and a mark that both know their category index must agree on it:
1016
+ // a grouped axis repeats its leaf labels, and a click on the second Q2
1017
+ // used to select the first Q2 with it. A mark without an index (a line
1018
+ // dot, a slice) still matches on the labels alone.
1019
+ const sameRef = (r: ChartPointRef, category: string, series: string, index?: number) =>
1020
+ r.category === category && r.series === series && (r.index === undefined || index === undefined || r.index === index)
1021
+ const isSel = (category: string, series: string, index?: number) =>
1022
+ selected.some((r) => sameRef(r, category, series, index))
1023
+ /** Extra dimming for marks outside a selection (1 when nothing is selected). */
1024
+ const selDim = (category: string, series: string, index?: number) =>
1025
+ selMode && selected.length && !isSel(category, series, index) ? 0.35 : 1
1026
+ function applySelection(category: string, series: string, e?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }, index?: number) {
1027
+ if (!selMode) return
1028
+ const ref: ChartPointRef = index === undefined ? { category, series } : { category, series, index }
1029
+ const additive = selMode === 'multi' || !!(e && (e.ctrlKey || e.metaKey || e.shiftKey))
1030
+ let next: ChartPointRef[]
1031
+ if (isSel(category, series, index)) next = additive ? selected.filter((r) => !sameRef(r, category, series, index)) : []
1032
+ else next = additive ? [...selected, ref] : [ref]
1033
+ selected = next
1034
+ onSelectionChange?.(next)
1035
+ }
1036
+ // ---- Series focus (keyboard) --------------------------------------------
1037
+ /** The series the arrow keys have stepped onto, or null for the whole category. */
1038
+ let activeSeries = $state<string | null>(null)
1039
+ /** What the live region says. */
1040
+ let liveText = $state('')
520
1041
 
521
1042
  const legendItems = $derived(
522
- spec.type === 'pie'
523
- ? spec.categories.map((label, i) => ({ label, color: palette[i % palette.length]!, off: hidden.has(label) }))
524
- : coloredSeries.map((s) => ({ label: s.label, color: s.color, off: hidden.has(s.label) })),
1043
+ // A heat map or a calendar colours by value and draws its own ramp; a row
1044
+ // of series chips in palette colours the cells never use would be a key
1045
+ // to nothing.
1046
+ spec.type === 'heatmap' || spec.type === 'calendar' || spec.type === 'gauge'
1047
+ ? []
1048
+ : spec.type === 'pie' || arcsByCategory
1049
+ ? pieCategories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? visibleSpec.series[0]?.colors?.[i] ?? palette[i % palette.length]!, off: hidden.has(label) }))
1050
+ : spec.type === 'sunburst' || spec.type === 'chord'
1051
+ // The engine names these (top-level branches, chord groups); they are
1052
+ // labels only, since hiding one means re-laying-out a hierarchy.
1053
+ ? geo.legend.map((l) => ({ label: l.label, color: l.color, off: false }))
1054
+ : coloredSeries.map((s) => ({ label: s.label, color: s.color, off: hidden.has(s.label) })),
1055
+ )
1056
+ // ---- Range presets, for a time axis ------------------------------------
1057
+ const presetList = $derived<ChartRangePreset[]>(
1058
+ !rangePresets ? [] : rangePresets === true ? ['1W', '1M', '3M', '6M', 'YTD', '1Y', 'All'] : rangePresets,
1059
+ )
1060
+ const presetsApply = $derived(
1061
+ presetList.length > 0 && isCartesian && (spec.xType === 'time' || spec.xType === 'ordinal-time' || spec.xAxis?.type === 'time' || spec.xAxis?.type === 'ordinal-time'),
525
1062
  )
1063
+ const presetLabel = (p: ChartRangePreset) => (typeof p === 'string' ? p : p.label)
1064
+ function applyPreset(p: ChartRangePreset) {
1065
+ setZoom(presetWindow(spec.categories, p))
1066
+ }
1067
+
1068
+ /** Sunburst and chord chips are a key, not switches. */
1069
+ const legendToggles = $derived(spec.type !== 'sunburst' && spec.type !== 'chord')
526
1070
  // Legend overflow: collapse to the first N chips with a "+N more" toggle so a
527
1071
  // wide pivot (many series) doesn't flood the chart with legend rows.
528
1072
  const LEGEND_MAX = 10
@@ -544,12 +1088,28 @@
544
1088
  if (spec.type === 'sankey') return geo.sankeyNodes.length === 0
545
1089
  if (isCandle) return geo.candles.length === 0
546
1090
  if (spec.type === 'boxplot') return geo.boxes.length === 0
547
- return geo.bars.length === 0 && geo.lines.every((l) => l.points.every((p) => !p.defined))
1091
+ if (isArcs) return geo.arcs.length === 0
1092
+ if (isBullet) return geo.bullets.length === 0
1093
+ return geo.bars.length === 0 && geo.stems.length === 0 && geo.lines.every((l) => l.points.every((p) => !p.defined))
548
1094
  })
549
1095
 
1096
+ // A double-click arrives as click, click, dblclick, and the two clicks run
1097
+ // through toggle() first. From a clean state they cancel out and the
1098
+ // dblclick isolates. With a chip isolated, the first click clears the
1099
+ // isolation and the second hid the chip, then the dblclick isolated it
1100
+ // again: "double-click again to clear" left the chart exactly as it was
1101
+ // and the chip hidden underneath. So a click that clears an isolation is
1102
+ // remembered for a moment, and the dblclick that follows it on the same
1103
+ // chip undoes the second click instead of isolating; on another chip it
1104
+ // moves the isolation there.
1105
+ let clearedByClick: { label: string; at: number } | null = null
550
1106
  function toggle(label: string) {
551
- if (!interactive) return
552
- if (isolated != null) { isolated = null; return }
1107
+ if (!interactive || !legendToggles) return
1108
+ if (isolated != null) {
1109
+ clearedByClick = { label: isolated, at: Date.now() }
1110
+ isolated = null
1111
+ return
1112
+ }
553
1113
  const next = new Set(hidden)
554
1114
  if (next.has(label)) next.delete(label)
555
1115
  else next.add(label)
@@ -558,18 +1118,32 @@
558
1118
  // Double-click a legend chip to isolate it (show only that one); double-click
559
1119
  // again (or the same chip) to clear the isolation.
560
1120
  function isolate(label: string) {
561
- if (!interactive) return
1121
+ if (!interactive || !legendToggles) return
1122
+ const cleared = clearedByClick
1123
+ clearedByClick = null
1124
+ const shown = new Set(hidden)
1125
+ shown.delete(label)
1126
+ if (cleared && cleared.label === label && Date.now() - cleared.at < 600) {
1127
+ hidden = shown
1128
+ return
1129
+ }
1130
+ // Isolating a chip shows it, whatever a click had done to it before.
1131
+ hidden = shown
562
1132
  isolated = isolated === label ? null : label
563
1133
  }
564
1134
 
565
1135
  // ---- Screen-reader data table -----------------------------------------
566
1136
  // A visually-hidden table that conveys the same data to assistive tech.
1137
+ // A gap is an empty cell. `fmt(NaN)` read "NaN" to a screen reader and a
1138
+ // `null` fell back to 0, which told the reader the outage month had no
1139
+ // sign-ups when the chart says nothing was measured.
1140
+ const srCell = (v: number | null | undefined, series?: string) => (Number.isFinite(v) ? fmtFor(series)(v as number) : '')
567
1141
  const srTable = $derived.by(() => {
568
1142
  if (spec.type === 'pie') {
569
- const s = coloredSeries[0]
1143
+ const s = pieTree ? visibleSpec.series[0] : coloredSeries[0]
570
1144
  return {
571
1145
  cols: ['Category', 'Value'],
572
- rows: spec.categories.map((c, i) => [c, fmt(s?.values[i] ?? 0)]),
1146
+ rows: pieCategories.map((c, i) => [c, srCell(s?.values[i])]),
573
1147
  }
574
1148
  }
575
1149
  if (isScatter) {
@@ -577,6 +1151,18 @@
577
1151
  for (const s of coloredSeries) for (const p of s.points ?? []) rows.push([s.label, fmt(p.x), fmt(p.y), p.r != null ? fmt(p.r) : ''])
578
1152
  return { cols: ['Series', 'X', 'Y', 'Size'], rows }
579
1153
  }
1154
+ if (isArcs) {
1155
+ return {
1156
+ cols: [spec.type === 'chord' ? 'Group' : 'Category', 'Series', 'Value'],
1157
+ rows: geo.arcs.map((a) => [a.nodePath ? a.nodePath.join(' / ') : a.label, a.series, fmt(a.value)]),
1158
+ }
1159
+ }
1160
+ if (isBullet) {
1161
+ return {
1162
+ cols: ['Category', 'Value', 'Target'],
1163
+ rows: geo.bullets.map((b) => [b.label, fmt(b.value), b.target == null ? '' : fmt(b.target)]),
1164
+ }
1165
+ }
580
1166
  if (isCandle) {
581
1167
  // This IS the accessible version of the chart, so it carries the four
582
1168
  // real numbers rather than the closes `values` happens to hold.
@@ -589,9 +1175,20 @@
589
1175
  }
590
1176
  return { cols: ['Date', 'Series', 'Open', 'High', 'Low', 'Close'], rows }
591
1177
  }
1178
+ if (spec.type === 'waterfall') {
1179
+ // The drawn values: a total's running sum rather than the 0 it holds.
1180
+ const first = coloredSeries[0]
1181
+ return {
1182
+ cols: ['Category', first?.label ?? 'Value'],
1183
+ rows: spec.categories.map((c, i) => {
1184
+ const bar = geo.bars.find((b) => b.index === i)
1185
+ return [c, bar ? fmt(bar.value) : srCell(first?.values[i])]
1186
+ }),
1187
+ }
1188
+ }
592
1189
  return {
593
1190
  cols: ['Category', ...coloredSeries.map((s) => s.label)],
594
- rows: spec.categories.map((c, i) => [c, ...coloredSeries.map((s) => fmt(s.values[i] ?? 0))]),
1191
+ rows: spec.categories.map((c, i) => [c, ...coloredSeries.map((s) => srCell(s.values[i], s.label))]),
595
1192
  }
596
1193
  })
597
1194
  /** Declared here rather than in the markup: `{@const}` is only legal as the
@@ -658,21 +1255,81 @@
658
1255
  obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] })
659
1256
  return () => obs.disconnect()
660
1257
  })
661
- type TipRow = { label?: string; color?: string; value: string }
662
- let tip = $state<{ left: number; top: number; below: boolean; title: string; rows: TipRow[] } | null>(null)
1258
+ type TipRow = ChartTooltipRow
1259
+ let tip = $state<{ left: number; top: number; below: boolean; fixed: boolean; title: string; rows: TipRow[]; ctx: ChartTooltipContext } | null>(null)
1260
+ /** The category a click pinned the tooltip on (`tooltipSticky`), or null. */
1261
+ let pinnedTip = $state<{ index: number; series: string | null } | null>(null)
663
1262
  let activeCat = $state<number | null>(null)
664
1263
 
665
- function showTip(clientX: number, clientY: number, title: string, rows: TipRow[]) {
1264
+ function showTip(clientX: number, clientY: number, title: string, rows: TipRow[], ctx?: Partial<ChartTooltipContext>) {
666
1265
  if (!interactive || !chartEl || rows.length === 0) return
1266
+ // A pinned tooltip ignores the pointer until it is released.
1267
+ if (pinnedTip && tip) return
667
1268
  const r = chartEl.getBoundingClientRect()
668
- const left = Math.max(70, Math.min(r.width - 70, clientX - r.left))
669
- const top = clientY - r.top
670
- tip = { left, top, below: top < 44, title, rows }
1269
+ const full: ChartTooltipContext = { category: title, index: -1, rows, ...ctx }
1270
+ // A format hook rewrites the default title / rows; a snippet replaces the
1271
+ // body outright and gets the same context.
1272
+ const fmtd = tooltipFormat?.(full)
1273
+ const fixed = tooltipPosition !== 'follow'
1274
+ let left: number
1275
+ let top: number
1276
+ if (fixed && svgEl) {
1277
+ // A corner of the plot, in the host's pixels: the plot box scaled by
1278
+ // the svg's rendered size, inset by 8px.
1279
+ const sb = svgEl.getBoundingClientRect()
1280
+ const kx = sb.width / (geo.width || 1)
1281
+ const ky = sb.height / (geo.height || 1)
1282
+ const inset = 8
1283
+ const right = tooltipPosition === 'top-right' || tooltipPosition === 'bottom-right'
1284
+ const bottom = tooltipPosition === 'bottom-left' || tooltipPosition === 'bottom-right'
1285
+ left = sb.left - r.left + (right ? (geo.plot.x + geo.plot.w) * kx - inset : geo.plot.x * kx + inset)
1286
+ top = sb.top - r.top + (bottom ? (geo.plot.y + geo.plot.h) * ky - inset : geo.plot.y * ky + inset)
1287
+ } else {
1288
+ left = Math.max(70, Math.min(r.width - 70, clientX - r.left))
1289
+ top = clientY - r.top
1290
+ }
1291
+ tip = { left, top, below: !fixed && top < 44, fixed, title: fmtd?.title ?? title, rows: fmtd?.rows ?? rows, ctx: full }
1292
+ reportHover({ category: full.category, index: full.index, series: full.series ?? null, value: full.value ?? null })
1293
+ }
1294
+
1295
+ /**
1296
+ * The series nearest a pointer, by vertical distance to its mark at category
1297
+ * i. What 'single' tooltip mode shows: the one line you are pointing at,
1298
+ * rather than every series at that x.
1299
+ */
1300
+ function nearestSeriesAt(i: number, vy: number): { label: string; distance: number } | null {
1301
+ let best: string | null = null
1302
+ let bestD = Infinity
1303
+ for (const l of geo.lines) {
1304
+ const p = l.points[i]
1305
+ if (!p?.defined) continue
1306
+ const d = Math.abs(p.y - vy)
1307
+ if (d < bestD) { bestD = d; best = l.label }
1308
+ }
1309
+ for (const b of geo.bars) {
1310
+ if (b.label !== visibleSpec.categories[i]) continue
1311
+ const d = vy < b.y ? b.y - vy : vy > b.y + b.h ? vy - (b.y + b.h) : 0
1312
+ if (d < bestD) { bestD = d; best = b.series }
1313
+ }
1314
+ for (const k of geo.candles) {
1315
+ if (k.label !== visibleSpec.categories[i]) continue
1316
+ const d = vy < k.yHigh ? k.yHigh - vy : vy > k.yLow ? vy - k.yLow : 0
1317
+ if (d < bestD) { bestD = d; best = k.series }
1318
+ }
1319
+ return best === null ? null : { label: best, distance: bestD }
671
1320
  }
672
1321
 
673
1322
  // Unified tooltip: every visible series' value at one category.
674
1323
  function catRows(i: number): TipRow[] {
675
1324
  const rows: TipRow[] = []
1325
+ // A waterfall's total bars carry 0 in the data and their height in the
1326
+ // geometry, so the tooltip reads the bar: "Gross profit $2,460", not "$0".
1327
+ if (spec.type === 'waterfall') {
1328
+ const bar = geo.bars.find((b) => b.index === i)
1329
+ const s = visibleSpec.series[0]
1330
+ if (bar && s) rows.push({ label: s.label, color: bar.color, value: fmt(bar.value) })
1331
+ return rows
1332
+ }
676
1333
  for (const s of visibleSpec.series) {
677
1334
  const color = (s as { color?: string }).color
678
1335
  // A candle carries four numbers, so it gets four rows. Doing it here
@@ -703,42 +1360,192 @@
703
1360
  continue
704
1361
  }
705
1362
  const v = s.values[i]
1363
+ const f = fmtFor(s.label)
1364
+ // A range series reads as "low to high"; a bullet as "value (target)".
1365
+ const lo = s.lowValues?.[i]
1366
+ if (Number.isFinite(v) && Number.isFinite(lo)) {
1367
+ rows.push({ label: s.label, color, value: `${f(lo as number)} to ${f(v as number)}` })
1368
+ continue
1369
+ }
1370
+ const tg = s.targets?.[i]
1371
+ if (Number.isFinite(v) && Number.isFinite(tg)) {
1372
+ rows.push({ label: s.label, color, value: f(v as number) })
1373
+ rows.push({ label: `${s.label} target`, value: f(tg as number) })
1374
+ continue
1375
+ }
706
1376
  if (Number.isFinite(v)) {
707
- rows.push({ label: s.label, color, value: fmt(v as number) })
1377
+ rows.push({ label: s.label, color, value: f(v as number) })
708
1378
  // An error bar is only worth drawing if you can read what it means.
709
1379
  const e = s.errors?.[i]
710
1380
  if (e != null) {
711
1381
  const lo = typeof e === 'number' ? (v as number) - Math.abs(e) : Math.min(e.lo, e.hi)
712
1382
  const hi = typeof e === 'number' ? (v as number) + Math.abs(e) : Math.max(e.lo, e.hi)
713
1383
  if (Number.isFinite(lo) && Number.isFinite(hi)) {
714
- rows.push({ label: `${s.label} range`, value: `${fmt(lo)} - ${fmt(hi)}` })
1384
+ rows.push({ label: `${s.label} range`, value: `${f(lo)} - ${f(hi)}` })
715
1385
  }
716
1386
  }
717
1387
  }
718
1388
  }
1389
+ // Overlays read under their series: the fitted or averaged value at this
1390
+ // category, and a regression's R-squared once, so a reader can judge it.
1391
+ for (const ovl of geo.overlays) {
1392
+ const p = ovl.points[i]
1393
+ if (!p?.defined) continue
1394
+ const src = visibleSpec.series.find((s) => s.overlay && ovl.label === `${s.label} (${s.overlay})`)
1395
+ if (!src?.overlay) continue
1396
+ const r2 = ovl.r2 !== undefined ? ` (${msg(t.rSquared, { value: ovl.r2.toFixed(2) })})` : ''
1397
+ rows.push({ label: `${src.label} ${overlayName(src.overlay)}`, color: ovl.color, value: `${fmtFor(src.label)(p.value)}${r2}` })
1398
+ }
719
1399
  return rows
720
1400
  }
721
- function hoverCat(clientX: number, clientY: number, i: number) {
1401
+ /** The pointer's y in viewBox units while a category is hovered; null from the keyboard. */
1402
+ let hoverVy = $state<number | null>(null)
1403
+ /** The value pill's text: the axis's own formatter, at a precision the axis range earns. */
1404
+ function crosshairValueText(v: number): string {
1405
+ const ax = geo.axes?.y
1406
+ const range = ax ? Math.abs(ax.max - ax.min) : 100
1407
+ const decimals = range >= 100 ? 0 : range >= 10 ? 1 : range >= 1 ? 2 : 3
1408
+ const r = Number(v.toFixed(decimals))
1409
+ const cfg = spec.yAxis
1410
+ if (cfg?.formatter) return cfg.formatter(r, -1)
1411
+ if (cfg?.format) return formatChartValue(r, cfg.format, spec)
1412
+ return yTickLabel(r, fmt(r))
1413
+ }
1414
+ // On a date axis the tooltip and the crosshair pill write the category
1415
+ // out ("Jun 2025", "Jun 1, 2025") rather than echoing the ISO string the
1416
+ // axis labels never show. The raw category still goes out in the hover
1417
+ // point and the selection, since that is what the spec calls it.
1418
+ const dateAxis = $derived(
1419
+ spec.xType === 'time' || spec.xType === 'ordinal-time' || spec.xAxis?.type === 'time' || spec.xAxis?.type === 'ordinal-time',
1420
+ )
1421
+ const catGrain = $derived(dateAxis ? dateGrain(visibleSpec.categories.map((c) => Date.parse(c))) : 'day')
1422
+ /** A calendar day written out ("Apr 7, 2026"), for the tooltip and the cell's name. */
1423
+ const calendarDay = (date: string) => { const t = Date.parse(date); return Number.isFinite(t) ? fmtDateFull(t) : date }
1424
+ function categoryTitle(i: number): string {
1425
+ const c = visibleSpec.categories[i] ?? ''
1426
+ if (!dateAxis) return c
1427
+ const t = Date.parse(c)
1428
+ if (!Number.isFinite(t)) return c
1429
+ return spec.xAxis?.formatter ? spec.xAxis.formatter(t, i) : fmtDateFull(t, catGrain)
1430
+ }
1431
+ function hoverCat(clientX: number, clientY: number, i: number, target?: Element) {
722
1432
  activeCat = i
723
- showTip(clientX, clientY, visibleSpec.categories[i] ?? '', catRows(i))
1433
+ const vy = svgPoint({ clientX, clientY })?.y
1434
+ hoverVy = vy != null && Number.isFinite(vy) && vy >= geo.plot.y && vy <= geo.plot.y + geo.plot.h ? vy : null
1435
+ const category = visibleSpec.categories[i] ?? ''
1436
+ let rows = catRows(i)
1437
+ let series: string | undefined
1438
+ let value: number | undefined
1439
+ // A series stepped onto from the keyboard narrows the tooltip to it, the
1440
+ // way 'single' mode narrows it to the series under the pointer.
1441
+ const focused = activeSeries && visibleSpec.series.some((sr) => sr.label === activeSeries) ? activeSeries : null
1442
+ if (focused) {
1443
+ series = focused
1444
+ value = visibleSpec.series.find((sr) => sr.label === focused)?.values[i]
1445
+ rows = rows.filter((r) => r.label === focused || r.label?.startsWith(focused + ' '))
1446
+ } else {
1447
+ // The series nearest the pointer: 'single' mode narrows the tooltip to
1448
+ // it, and the highlight dims the rest when it is within reach. Off the
1449
+ // keyboard (no target) the point is the hit zone's centre, far from
1450
+ // any line, so nothing dims.
1451
+ const vp = target ? toViewBox(clientX, clientY, target) : svgPoint({ clientX, clientY })
1452
+ const near = vp && Number.isFinite(vp.y) ? nearestSeriesAt(i, vp.y) : null
1453
+ hoverSeries = near && near.distance <= HOVER_SNAP_PX ? near.label : null
1454
+ if (tooltipMode === 'single' && near && target) {
1455
+ series = near.label
1456
+ value = visibleSpec.series.find((s) => s.label === near.label)?.values[i]
1457
+ rows = rows.filter((r) => r.label === near.label || r.label?.startsWith(near.label + ' '))
1458
+ }
1459
+ }
1460
+ showTip(clientX, clientY, categoryTitle(i), rows, { category, index: i, series, value })
1461
+ if (syncGroup && !remoteHover) {
1462
+ const t = Date.parse(category)
1463
+ publishSyncHover(syncGroup, { label: category, index: (zoom ? zoom.i0 : 0) + i, time: Number.isFinite(t) ? t : null, from: me })
1464
+ }
724
1465
  }
725
1466
  function focusCat(el: Element, i: number) {
726
1467
  const b = el.getBoundingClientRect()
727
1468
  hoverCat(b.left + b.width / 2, b.top + 12, i)
1469
+ if (!announce) return
1470
+ const category = categoryTitle(i)
1471
+ if (activeSeries) {
1472
+ const v = visibleSpec.series.find((sr) => sr.label === activeSeries)?.values[i]
1473
+ liveText = msg(t.seriesAtCategory, { series: activeSeries, value: Number.isFinite(v) ? fmtFor(activeSeries)(v as number) : t.noValue, category })
1474
+ } else {
1475
+ liveText = `${category}: ${catRows(i).map((r) => `${r.label ?? ''} ${r.value}`).join(', ')}`
1476
+ }
728
1477
  }
729
1478
  function clearActive() {
1479
+ // A pinned tooltip survives the pointer leaving; Escape or a click lets go.
1480
+ if (pinnedTip) return
730
1481
  activeCat = null
1482
+ hoverVy = null
1483
+ hoverSeries = null
731
1484
  tip = null
1485
+ reportHover(null)
1486
+ if (syncGroup && !remoteHover && syncState?.hover?.from === me) publishSyncHover(syncGroup, null)
732
1487
  }
1488
+ // Mirror the group's hover: find the category by label, then by nearest
1489
+ // date, and raise the crosshair + tooltip at that column.
1490
+ $effect(() => {
1491
+ if (!syncState) return
1492
+ const h = syncState.hover
1493
+ untrack(() => {
1494
+ if (!h) {
1495
+ if (remoteHover) { remoteHover = false; activeCat = null; tip = null }
1496
+ return
1497
+ }
1498
+ if (h.from === me) return
1499
+ let i = visibleSpec.categories.indexOf(h.label)
1500
+ if (i < 0 && h.time != null) i = nearestIndexByTime(visibleSpec.categories, h.time)
1501
+ if (i < 0 || !svgEl || !renderScales) return
1502
+ remoteHover = true
1503
+ const r = svgEl.getBoundingClientRect()
1504
+ const sx = r.width / geo.width
1505
+ const sy = r.height / geo.height
1506
+ const x = renderScales.xOf(i)
1507
+ hoverCat(r.left + x * sx, r.top + (geo.plot.y + 12) * sy, i)
1508
+ remoteHover = true
1509
+ })
1510
+ })
1511
+ // Mirror the group's window: same axis length means the same indices,
1512
+ // otherwise the ends are matched by date.
1513
+ $effect(() => {
1514
+ if (!syncState) return
1515
+ const z = syncState.zoom
1516
+ untrack(() => {
1517
+ if (!z || z.from === me) return
1518
+ const n = spec.categories.length
1519
+ let next: ChartZoomWindow | null = z.window
1520
+ if (next && z.count !== n) {
1521
+ if (!z.labels) return
1522
+ const i0 = nearestIndexByTime(spec.categories, Date.parse(z.labels[0]))
1523
+ const i1 = nearestIndexByTime(spec.categories, Date.parse(z.labels[1]))
1524
+ if (i0 < 0 || i1 < 0) return
1525
+ next = i0 === 0 && i1 === n - 1 ? null : { i0: Math.min(i0, i1), i1: Math.max(i0, i1) }
1526
+ }
1527
+ applyingSync = true
1528
+ try { setZoom(next) } finally { applyingSync = false }
1529
+ })
1530
+ })
733
1531
  function hoverSlice(clientX: number, clientY: number, label: string, value: number, pct: number) {
734
- showTip(clientX, clientY, label, [{ value: `${fmt(value)} · ${pct.toFixed(1)}%` }])
1532
+ showTip(clientX, clientY, label, [{ value: `${fmt(value)} · ${pct.toFixed(1)}%` }], { series: label, value })
735
1533
  }
736
1534
  function hoverDot(clientX: number, clientY: number, d: (typeof geo.scatterPoints)[number]) {
1535
+ hoverSeries = d.series
737
1536
  const rows: TipRow[] = [
738
1537
  { label: 'x', value: fmt(d.x) },
739
1538
  { label: 'y', value: fmt(d.y) },
740
1539
  ]
741
- showTip(clientX, clientY, d.label || d.series, rows)
1540
+ // The series' regression, once: what the fit says at this x and how well
1541
+ // it fits, so a curve that flatters the data is easy to judge.
1542
+ const src = visibleSpec.series.find((s) => s.label === d.series && s.overlay)
1543
+ const ovl = src?.overlay ? geo.overlays.find((o) => o.label === `${src.label} (${src.overlay})`) : undefined
1544
+ if (src?.overlay && ovl) {
1545
+ const r2 = ovl.r2 !== undefined ? ` (${msg(t.rSquared, { value: ovl.r2.toFixed(2) })})` : ''
1546
+ rows.push({ label: overlayName(src.overlay), color: ovl.color, value: `${ovl.equation ?? ''}${r2}` })
1547
+ }
1548
+ showTip(clientX, clientY, d.label || d.series, rows, { series: d.series, value: d.y })
742
1549
  }
743
1550
  function hoverCell(clientX: number, clientY: number, cell: (typeof geo.heatmapCells)[number]) {
744
1551
  showTip(clientX, clientY, `${cell.rowLabel} · ${cell.colLabel}`, [
@@ -751,13 +1558,57 @@
751
1558
  { label: 'conversion', value: `${(seg.conversion * 100).toFixed(1)}%` },
752
1559
  ]
753
1560
  if (seg.dropoff > 0) rows.push({ label: 'drop-off', value: `${(seg.dropoff * 100).toFixed(1)}%` })
754
- showTip(clientX, clientY, seg.label, rows)
1561
+ showTip(clientX, clientY, seg.label, rows, { value: seg.value })
1562
+ }
1563
+ function hoverArc(clientX: number, clientY: number, arc: (typeof geo.arcs)[number]) {
1564
+ hoverSeries = arcsByCategory || spec.type === 'sunburst' || spec.type === 'chord' ? arc.label : arc.series
1565
+ const title = arc.series ? `${arc.label} \u00b7 ${arc.series}` : arc.label
1566
+ showTip(clientX, clientY, title, [{ color: arc.color, value: fmt(arc.value) }], { category: arc.label, series: arc.series || undefined, value: arc.value })
1567
+ }
1568
+ function hoverRibbon(clientX: number, clientY: number, rb: (typeof geo.chordRibbons)[number]) {
1569
+ showTip(clientX, clientY, `${rb.source} \u2192 ${rb.target}`, [{ color: rb.color, value: fmt(rb.value) }], { category: rb.source, series: rb.target, value: rb.value })
1570
+ }
1571
+ function hoverBullet(clientX: number, clientY: number, b: (typeof geo.bullets)[number]) {
1572
+ hoverSeries = b.series
1573
+ const rows: TipRow[] = [{ color: b.color, label: b.series, value: fmt(b.value) }]
1574
+ if (b.target != null) rows.push({ label: 'target', value: fmt(b.target) })
1575
+ showTip(clientX, clientY, b.label, rows, { category: b.label, series: b.series, value: b.value })
1576
+ }
1577
+ function hoverStem(clientX: number, clientY: number, st: (typeof geo.stems)[number]) {
1578
+ hoverSeries = st.series
1579
+ const rows: TipRow[] = [{ color: st.color, label: st.series, value: st.dumbbell && st.value2 != null ? `${fmt(st.value2)} to ${fmt(st.value)}` : fmt(st.value) }]
1580
+ showTip(clientX, clientY, st.label, rows, { category: st.label, series: st.series, value: st.value })
755
1581
  }
756
1582
  function hoverRadarPoint(clientX: number, clientY: number, label: string, axis: string, value: number, color: string) {
757
- showTip(clientX, clientY, `${label} · ${axis}`, [{ color, value: fmt(value) }])
1583
+ hoverSeries = label
1584
+ showTip(clientX, clientY, `${label} · ${axis}`, [{ color, value: fmt(value) }], { category: axis, series: label, value })
758
1585
  }
759
1586
 
760
- function select(category: string, series: string, value: number, catIndex?: number) {
1587
+ /**
1588
+ * A click on a category hit zone as a point. The zones sit above the marks,
1589
+ * so in a browser a click "on a bar" lands here with no series; for a
1590
+ * selection that has to become one: the series the keyboard stepped onto,
1591
+ * else the one nearest the pointer, else the only one there is. Without
1592
+ * `selectable` the category click stays a category click.
1593
+ */
1594
+ function catClick(e: MouseEvent | KeyboardEvent, i: number) {
1595
+ if (tool) return
1596
+ // A sticky tooltip pins on the click; the click still selects below.
1597
+ // Annotate mode owns the click, so it pins a note rather than a tooltip.
1598
+ if (tooltipSticky && 'clientX' in e && !annotating) {
1599
+ if (pinnedTip && pinnedTip.index === i) unpinTip()
1600
+ else { pinnedTip = null; hoverCat(e.clientX, e.clientY, i, e.currentTarget as Element); togglePin(i) }
1601
+ }
1602
+ const category = visibleSpec.categories[i] ?? ''
1603
+ if (!selMode) { select(category, '', 0, i, e); return }
1604
+ const vp = 'clientX' in e ? svgPoint(e) : null
1605
+ const near = vp ? nearestSeriesAt(i, vp.y) : null
1606
+ const series = activeSeries ?? near?.label ?? (visibleSpec.series.length === 1 ? visibleSpec.series[0]!.label : '')
1607
+ const value = visibleSpec.series.find((sr) => sr.label === series)?.values[i] ?? 0
1608
+ select(category, series, value, i, e)
1609
+ }
1610
+ function select(category: string, series: string, value: number, catIndex?: number, ev?: MouseEvent | KeyboardEvent) {
1611
+ applySelection(category, series, ev, catIndex)
761
1612
  // Annotate mode takes over the category gesture rather than adding one of
762
1613
  // its own. That is what makes it work from the keyboard: the hit rects
763
1614
  // already have a roving tabindex and fire this on Enter, so pinning a note
@@ -800,17 +1651,38 @@
800
1651
  focusCat(target, next)
801
1652
  }
802
1653
  function onCatKey(e: KeyboardEvent, i: number) {
1654
+ if (menuKey(e, i)) return
803
1655
  const max = visibleSpec.categories.length - 1
804
1656
  const src = e.currentTarget as Element
1657
+ if (keyZoom(e, i)) return
805
1658
  // Arrow keys + Home / End navigate; Enter / Space select. PageUp/Down
806
1659
  // jump a tenth of the range at a time for long datasets.
1660
+ // Categories run along the main axis, series across it: on a vertical
1661
+ // chart Left / Right step categories and Up / Down step series, on a
1662
+ // horizontal one the pairs swap.
1663
+ const nextKey = isHorizontal ? 'ArrowDown' : 'ArrowRight'
1664
+ const prevKey = isHorizontal ? 'ArrowUp' : 'ArrowLeft'
1665
+ const seriesUp = isHorizontal ? 'ArrowRight' : 'ArrowUp'
1666
+ const seriesDown = isHorizontal ? 'ArrowLeft' : 'ArrowDown'
807
1667
  switch (e.key) {
808
- case 'ArrowRight':
809
- case 'ArrowDown':
1668
+ case nextKey:
810
1669
  e.preventDefault(); focusCatIndex(src, Math.min(max, i + 1)); return
811
- case 'ArrowLeft':
812
- case 'ArrowUp':
1670
+ case prevKey:
813
1671
  e.preventDefault(); focusCatIndex(src, Math.max(0, i - 1)); return
1672
+ case seriesUp:
1673
+ case seriesDown: {
1674
+ e.preventDefault()
1675
+ const labels = visibleSpec.series.map((sr) => sr.label)
1676
+ if (!labels.length) return
1677
+ const at = activeSeries ? labels.indexOf(activeSeries) : -1
1678
+ const step = e.key === seriesDown ? 1 : -1
1679
+ // Cycle through the series and back to "all".
1680
+ const nextAt = at + step
1681
+ activeSeries = nextAt < 0 || nextAt >= labels.length ? null : labels[nextAt]!
1682
+ dimmed = activeSeries
1683
+ focusCat(src, i)
1684
+ return
1685
+ }
814
1686
  case 'Home':
815
1687
  e.preventDefault(); focusCatIndex(src, 0); return
816
1688
  case 'End':
@@ -821,24 +1693,194 @@
821
1693
  e.preventDefault(); focusCatIndex(src, Math.max(0, i - Math.max(1, Math.floor(max / 10)))); return
822
1694
  case 'Enter':
823
1695
  case ' ':
824
- if (!onSelect) return
1696
+ if (!onSelect && !selMode) return
825
1697
  e.preventDefault()
826
- select(visibleSpec.categories[i] ?? '', '', 0, i)
1698
+ catClick(e, i)
1699
+ return
1700
+ case 'Escape':
1701
+ if (activeSeries) { activeSeries = null; dimmed = null; focusCat(src, i) }
827
1702
  return
828
1703
  }
829
1704
  }
830
- function onSliceKey(e: KeyboardEvent, label: string, value: number) {
831
- if (!onSelect) return
1705
+ // ---- Roving focus across marks ----------------------------------------
1706
+ // The non-cartesian families (slices, arcs, cells, segments, nodes, dots)
1707
+ // are one Tab stop each, like the category hit zones: the last focused mark
1708
+ // keeps tabindex 0 and the arrow keys walk the rest, Home / End jump to the
1709
+ // ends, PageUp / PageDown move a tenth. A pie with twenty slices used to be
1710
+ // twenty Tab stops and a heat map nine hundred. `grid` makes the walk
1711
+ // two-dimensional: a heat map is row-major, a calendar column-major (a week
1712
+ // is a column of seven days).
1713
+ let rove = $state<{ sel: string; i: number }>({ sel: '', i: 0 })
1714
+ const roveTab = (sel: string, i: number) => (!interactive ? undefined : (rove.sel === sel ? rove.i : 0) === i ? 0 : -1)
1715
+ function onMarkFocus(sel: string, i: number) {
1716
+ if (rove.sel !== sel || rove.i !== i) rove = { sel, i }
1717
+ }
1718
+ function markKey(
1719
+ e: KeyboardEvent,
1720
+ sel: string,
1721
+ i: number,
1722
+ act?: (() => void) | null,
1723
+ grid?: { cols: number; colMajor?: boolean },
1724
+ ) {
832
1725
  if (e.key === 'Enter' || e.key === ' ') {
833
- e.preventDefault()
834
- select(label, label, value)
1726
+ if (act) { e.preventDefault(); act() }
1727
+ return
1728
+ }
1729
+ const cur = e.currentTarget as SVGElement
1730
+ const root = cur.ownerSVGElement ?? cur.closest('svg')
1731
+ if (!root) return
1732
+ const all = root.querySelectorAll<SVGElement>(sel)
1733
+ const n = all.length
1734
+ if (!n) return
1735
+ const dx = grid ? (grid.colMajor ? grid.cols : 1) : 1
1736
+ const dy = grid ? (grid.colMajor ? 1 : grid.cols) : 1
1737
+ let next: number
1738
+ switch (e.key) {
1739
+ case 'ArrowRight': next = i + dx; break
1740
+ case 'ArrowLeft': next = i - dx; break
1741
+ case 'ArrowDown': next = i + dy; break
1742
+ case 'ArrowUp': next = i - dy; break
1743
+ case 'Home': next = 0; break
1744
+ case 'End': next = n - 1; break
1745
+ case 'PageDown': next = i + Math.max(1, Math.floor(n / 10)); break
1746
+ case 'PageUp': next = i - Math.max(1, Math.floor(n / 10)); break
1747
+ default: return
835
1748
  }
1749
+ e.preventDefault()
1750
+ next = Math.max(0, Math.min(n - 1, next))
1751
+ if (next === i) return
1752
+ rove = { sel, i: next }
1753
+ all[next]!.focus?.()
836
1754
  }
1755
+ /** Focus a mark's tooltip at the mark itself, for keyboard readers. */
1756
+ const box = (el: Element) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top } }
1757
+
1758
+ // ---- Keyboard zoom and pan --------------------------------------------
1759
+ // The plot's own keys, on a focused category: + / = zoom in around it, -
1760
+ // zooms out, 0 resets, Shift + the category arrows pan a zoomed window a
1761
+ // tenth at a time. Focus follows the same category through the change.
1762
+ function keyZoom(e: KeyboardEvent, i: number): boolean {
1763
+ if (!zoomCfg || !isCartesian) return false
1764
+ const n = spec.categories.length
1765
+ const at = (zoom?.i0 ?? 0) + i
1766
+ let next: ChartZoomWindow | null | undefined
1767
+ const nextKey = isHorizontal ? 'ArrowDown' : 'ArrowRight'
1768
+ const prevKey = isHorizontal ? 'ArrowUp' : 'ArrowLeft'
1769
+ if (e.key === '+' || e.key === '=') next = wheelWindow(zoom, n, at, 0.8)
1770
+ else if (e.key === '-' || e.key === '_') next = wheelWindow(zoom, n, at, 1.25)
1771
+ else if (e.key === '0') next = null
1772
+ else if (e.shiftKey && zoom && (e.key === nextKey || e.key === prevKey)) {
1773
+ const span = zoom.i1 - zoom.i0 + 1
1774
+ next = panWindow(zoom, n, (e.key === nextKey ? 1 : -1) * Math.max(1, Math.floor(span / 10)))
1775
+ }
1776
+ if (next === undefined) return false
1777
+ e.preventDefault()
1778
+ setZoom(next)
1779
+ // The window re-slices the categories, so the focused index moves.
1780
+ const local = Math.max(0, Math.min((next ? next.i1 - next.i0 : n - 1), at - (next?.i0 ?? 0)))
1781
+ // After the re-render the old hit zone is gone, so look the new one up
1782
+ // on the svg itself rather than from the stale source element.
1783
+ void tick().then(() => {
1784
+ const target = svgEl?.querySelector<SVGElement>(dense ? '[data-dense="true"]' : `[data-cat-index="${local}"]`)
1785
+ if (!target) return
1786
+ target.focus?.()
1787
+ if (dense) denseIndex = local
1788
+ focusCat(target, local)
1789
+ })
1790
+ return true
1791
+ }
1792
+
1793
+ // ---- Keyboard brush ---------------------------------------------------
1794
+ // The brush is a slider to the keyboard: Left / Right pan the window a
1795
+ // tenth of it (Shift: the whole window), Up / + narrow it, Down / - widen
1796
+ // it, Home / End push it to either end, 0 or Escape reset.
1797
+ function onBrushKey(e: KeyboardEvent) {
1798
+ const n = spec.categories.length
1799
+ if (!n) return
1800
+ const win = zoom ?? { i0: 0, i1: n - 1 }
1801
+ const span = win.i1 - win.i0 + 1
1802
+ const mid = (win.i0 + win.i1) / 2
1803
+ const step = e.shiftKey ? span : Math.max(1, Math.floor(span / 10))
1804
+ let next: ChartZoomWindow | null | undefined
1805
+ switch (e.key) {
1806
+ case 'ArrowRight': next = panWindow(zoom, n, step); break
1807
+ case 'ArrowLeft': next = panWindow(zoom, n, -step); break
1808
+ case 'ArrowUp': case '+': case '=': next = wheelWindow(zoom, n, mid, 0.8); break
1809
+ case 'ArrowDown': case '-': case '_': next = wheelWindow(zoom, n, mid, 1.25); break
1810
+ case 'Home': next = zoom ? { ...zoom, i0: 0, i1: span - 1 } : null; break
1811
+ case 'End': next = zoom ? { ...zoom, i0: n - span, i1: n - 1 } : null; break
1812
+ case '0': case 'Escape': next = null; break
1813
+ default: return
1814
+ }
1815
+ e.preventDefault()
1816
+ setZoom(next)
1817
+ }
1818
+
837
1819
 
838
1820
  // ---- Export ----------------------------------------------------------
839
1821
  // PNG / SVG download + copy-as-image. Serialize the live SVG element so
840
1822
  // the exported file matches the user's current zoom / visibility state.
841
1823
  let copyStatus = $state<'idle' | 'ok' | 'err'>('idle')
1824
+
1825
+ // ---- Context menu ----------------------------------------------------
1826
+ // The menu component is loaded on the first right-click, so a chart that
1827
+ // is never right-clicked never pays for it.
1828
+ let menu = $state<{ x: number; y: number; target: ChartContextTarget } | null>(null)
1829
+ let MenuComp = $state<typeof import('./SvChartMenu.svelte').default | null>(null)
1830
+ function onContextMenu(e: MouseEvent) {
1831
+ if (!contextMenu) return
1832
+ e.preventDefault()
1833
+ const i = isCartesian && !isScatter ? catAtClientX(e.clientX, e.currentTarget as Element) : null
1834
+ openMenuAt(e.clientX, e.clientY, i)
1835
+ }
1836
+ function openMenuAt(x: number, y: number, i: number | null) {
1837
+ const target: ChartContextTarget = i != null
1838
+ ? {
1839
+ category: visibleSpec.categories[i],
1840
+ index: (zoom ? zoom.i0 : 0) + i,
1841
+ ...(activeSeries ? { series: activeSeries, value: visibleSpec.series.find((sr) => sr.label === activeSeries)?.values[i] } : {}),
1842
+ }
1843
+ : {}
1844
+ const open = () => (menu = { x, y, target })
1845
+ if (MenuComp) open()
1846
+ else import('./SvChartMenu.svelte').then((m) => { MenuComp = m.default; open() })
1847
+ }
1848
+ /** Shift + F10 and the Menu key open the context menu on the focused category. */
1849
+ function menuKey(e: KeyboardEvent, i: number): boolean {
1850
+ if (!contextMenu || !(e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey))) return false
1851
+ e.preventDefault()
1852
+ const r = (e.currentTarget as Element).getBoundingClientRect()
1853
+ openMenuAt(r.left + r.width / 2, r.top + Math.min(r.height / 2, 24), i)
1854
+ return true
1855
+ }
1856
+ const menuItems = $derived.by<MenuItem[]>(() => {
1857
+ if (!menu) return []
1858
+ const items: MenuItem[] = [
1859
+ { label: t.downloadPng, onSelect: () => { void downloadImage('png') } },
1860
+ { label: t.downloadSvg, onSelect: () => { void downloadImage('svg') } },
1861
+ { label: t.downloadPdf, onSelect: () => { void exportPdf() } },
1862
+ ...(chartCsvExportable(spec) ? [{ label: t.downloadCsv, onSelect: () => { downloadChartCsv(spec) } }] : []),
1863
+ { label: t.copyAsImage, onSelect: () => { void copyAsImage() } },
1864
+ { label: t.print, onSelect: () => { void printIt() } },
1865
+ ...(describe && summary ? [{ label: t.describeChart, onSelect: () => { void copySummary() } }] : []),
1866
+ ]
1867
+ if (zoomable && isZoomed) items.push({ separator: true }, { label: t.resetZoom, onSelect: resetZoom })
1868
+ if (presetsApply) {
1869
+ items.push({ separator: true }, {
1870
+ label: t.range,
1871
+ children: presetList.map((pr) => ({ label: presetLabel(pr), onSelect: () => applyPreset(pr) })),
1872
+ })
1873
+ }
1874
+ if (legendToggles && legendItems.length > 1) {
1875
+ items.push({ separator: true }, {
1876
+ label: t.series,
1877
+ children: legendItems.map((it) => ({ label: `${it.off ? '\u2002' : '\u2713'} ${it.label}`, onSelect: () => toggle(it.label) })),
1878
+ })
1879
+ }
1880
+ const custom = typeof contextMenu === 'function' ? contextMenu(menu.target) : Array.isArray(contextMenu) ? contextMenu : []
1881
+ if (custom.length) items.push({ separator: true }, ...custom)
1882
+ return items
1883
+ })
842
1884
  function serializeSvg(): string | null {
843
1885
  if (!svgEl) return null
844
1886
  const clone = svgEl.cloneNode(true) as SVGSVGElement
@@ -864,6 +1906,16 @@
864
1906
  }
865
1907
  styleTag.textContent = rules.join('\n')
866
1908
  clone.insertBefore(styleTag, clone.firstChild)
1909
+ // The rules read tokens; inline the host's resolved values (theme and
1910
+ // `spec.style` alike) so a standalone file keeps its colours and font.
1911
+ if (chartEl && typeof getComputedStyle === 'function') {
1912
+ const cs = getComputedStyle(chartEl)
1913
+ const vars = ['--sg-bg', '--sg-fg', '--sg-muted', '--sg-border', '--sg-accent', '--sg-chart-bg', '--sg-chart-font-scale']
1914
+ .map((n) => { const v = cs.getPropertyValue(n).trim(); return v ? `${n}:${v}` : '' })
1915
+ .filter(Boolean)
1916
+ if (cs.fontFamily) vars.push(`font-family:${cs.fontFamily}`)
1917
+ if (vars.length) clone.setAttribute('style', vars.join(';'))
1918
+ }
867
1919
  return new XMLSerializer().serializeToString(clone)
868
1920
  }
869
1921
  function triggerDownload(blob: Blob, filename: string) {
@@ -908,6 +1960,18 @@
908
1960
  const png = await rasterizeSvg(text)
909
1961
  if (png) triggerDownload(png, `chart-${stamp}.png`)
910
1962
  }
1963
+ /** PDF and print live in their own chunk, fetched on first use. */
1964
+ async function exportPdf() {
1965
+ if (!chartEl) return
1966
+ const m = await import('./chart-export-pdf')
1967
+ const stamp = new Date().toISOString().slice(0, 10)
1968
+ await m.downloadChartPdf(chartEl, `chart-${stamp}.pdf`, { title: spec.title, subtitle: spec.subtitle, caption: spec.caption })
1969
+ }
1970
+ async function printIt() {
1971
+ if (!chartEl) return
1972
+ const m = await import('./chart-export-pdf')
1973
+ m.printChart(chartEl, { title: spec.title })
1974
+ }
911
1975
  async function copyAsImage() {
912
1976
  const text = serializeSvg()
913
1977
  if (!text) { copyStatus = 'err'; return }
@@ -929,13 +1993,52 @@
929
1993
  }
930
1994
  </script>
931
1995
 
932
- <div class="sv-grid-chart" bind:this={chartEl}>
1996
+ <div
1997
+ class="sv-grid-chart"
1998
+ class:is-autosize={autosize}
1999
+ class:is-legend-top={legendPos === 'top'}
2000
+ class:is-legend-left={legendPos === 'left'}
2001
+ class:is-legend-right={legendPos === 'right'}
2002
+ bind:this={chartEl}
2003
+ bind:clientWidth={hostW}
2004
+ bind:clientHeight={hostH}
2005
+ style={chartStyleVars(spec.style) || undefined}
2006
+ >
2007
+ {#snippet crumbs()}
2008
+ <nav class="sv-grid-chart-crumbs" aria-label={t.drillPath}>
2009
+ <button type="button" class="sv-grid-chart-tool sv-grid-chart-crumb" onclick={() => drillTo([])}>{drillRoot?.name || t.drillRoot}</button>
2010
+ {#each drillPath as seg, k (k)}
2011
+ <span class="sv-grid-chart-crumb-sep" aria-hidden="true">/</span>
2012
+ {#if k === drillPath.length - 1}
2013
+ <span class="sv-grid-chart-crumb is-current" aria-current="page">{seg}</span>
2014
+ {:else}
2015
+ <button type="button" class="sv-grid-chart-tool sv-grid-chart-crumb" onclick={() => drillTo(drillPath.slice(0, k + 1))}>{seg}</button>
2016
+ {/if}
2017
+ {/each}
2018
+ </nav>
2019
+ {/snippet}
933
2020
  {#if showToolbar}
934
- <div class="sv-grid-chart-toolbar">
2021
+ <div class="sv-grid-chart-toolbar" role="toolbar" bind:clientHeight={toolbarH}>
2022
+ {#if canDrill && drillPath.length}
2023
+ {@render crumbs()}
2024
+ {/if}
2025
+ {#if presetsApply}
2026
+ <span class="sv-grid-chart-presets" role="group" aria-label={t.dateRange}>
2027
+ {#each presetList as p (presetLabel(p))}
2028
+ <button type="button" class="sv-grid-chart-tool sv-grid-chart-preset" onclick={() => applyPreset(p)} title={msg(t.showLast, { preset: presetLabel(p) })}>{presetLabel(p)}</button>
2029
+ {/each}
2030
+ </span>
2031
+ {/if}
2032
+ {#if zoomCfg && (zoomCfg.pan === 'mode' || zoomCfg.pan === true) && isZoomed}
2033
+ <button type="button" class="sv-grid-chart-tool" class:is-on={panMode} aria-pressed={panMode} onclick={() => (panMode = !panMode)} title={t.panTitle}>
2034
+ <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9l-3 3 3 3"/><path d="M9 5l3-3 3 3"/><path d="M15 19l-3 3-3-3"/><path d="M19 9l3 3-3 3"/><path d="M2 12h20"/><path d="M12 2v20"/></svg>
2035
+ {t.pan}
2036
+ </button>
2037
+ {/if}
935
2038
  {#if zoomable && isZoomed}
936
- <button type="button" class="sv-grid-chart-tool" onclick={resetZoom} title="Reset zoom (or double-click the plot)">
937
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
938
- Reset zoom
2039
+ <button type="button" class="sv-grid-chart-tool" onclick={resetZoom} title={t.resetZoomTitle}>
2040
+ <svg aria-hidden="true" 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>
2041
+ {t.resetZoom}
939
2042
  </button>
940
2043
  {/if}
941
2044
  {#if annotatable}
@@ -945,43 +2048,88 @@
945
2048
  class:is-on={annotating}
946
2049
  aria-pressed={annotating}
947
2050
  onclick={() => (annotating = !annotating)}
948
- title={annotating ? 'Click the plot to pin a note, or a marker to remove it' : 'Pin notes on the chart'}
2051
+ title={annotating ? t.annotateOnTitle : t.annotateOffTitle}
949
2052
  >
950
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="11" r="3"/><path d="M12 14v8"/></svg>
951
- Annotate
2053
+ <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="11" r="3"/><path d="M12 14v8"/></svg>
2054
+ {t.annotate}
952
2055
  </button>
953
2056
  {/if}
954
- <button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('png')} title="Download PNG">PNG</button>
955
- <button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('svg')} title="Download SVG">SVG</button>
956
- <button type="button" class="sv-grid-chart-tool" onclick={copyAsImage} title="Copy chart as image">
957
- {copyStatus === 'ok' ? '✓ Copied' : 'Copy'}
2057
+ {#if toolKinds.length && isCartesian}
2058
+ <span class="sv-grid-chart-drawtools" role="group" aria-label={t.drawingTools}>
2059
+ {#each toolKinds as kind (kind)}
2060
+ <button type="button" class="sv-grid-chart-tool sv-grid-chart-drawtool" class:is-on={tool === kind} aria-pressed={tool === kind} onclick={() => pickTool(kind)} title={msg(t.draw, { tool: TOOL_LABEL[kind] })}>{TOOL_LABEL[kind]}</button>
2061
+ {/each}
2062
+ {#if drawingsNow().length}
2063
+ <button type="button" class="sv-grid-chart-tool" disabled={!selectedDrawing} onclick={() => selectedDrawing && removeDrawing(selectedDrawing)} title={t.deleteDrawingTitle}>{t.deleteDrawing}</button>
2064
+ <button type="button" class="sv-grid-chart-tool" onclick={() => commitDrawings([])} title={t.clearDrawingsTitle}>{t.clearDrawings}</button>
2065
+ {/if}
2066
+ </span>
2067
+ {/if}
2068
+ <button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('png')} title={t.downloadPng}>{t.png}</button>
2069
+ <button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('svg')} title={t.downloadSvg}>{t.svg}</button>
2070
+ <button type="button" class="sv-grid-chart-tool" onclick={exportPdf} title={t.downloadPdf}>{t.pdf}</button>
2071
+ <button type="button" class="sv-grid-chart-tool" onclick={printIt} title={t.printTitle}>{t.print}</button>
2072
+ <button type="button" class="sv-grid-chart-tool" onclick={copyAsImage} title={t.copyTitle}>
2073
+ {copyStatus === 'ok' ? t.copied : t.copy}
958
2074
  </button>
959
2075
  </div>
2076
+ {:else if canDrill && drillPath.length}
2077
+ <!-- The toolbar is off, but the breadcrumb is the way back up: a drilled
2078
+ chart without it is a room with no door. -->
2079
+ <div class="sv-grid-chart-toolbar is-crumbs-only" role="toolbar" bind:clientHeight={toolbarH}>
2080
+ {@render crumbs()}
2081
+ </div>
960
2082
  {/if}
2083
+ <!-- A group, not an image, while it is interactive: an image's children are
2084
+ presentational to assistive technology, which would hide the focusable
2085
+ marks inside. A thumbnail (interactive={false}) is one image. -->
2086
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
961
2087
  <svg
962
2088
  bind:this={svgEl}
963
2089
  class="sv-grid-chart-svg"
964
2090
  class:is-interactive={interactive}
965
2091
  class:is-clickable={!!onSelect || !!onDrill}
966
- class:is-zoomable={zoomable && isCartesian && !annotating}
2092
+ class:is-zoomable={!!zoomCfg?.drag && isCartesian && !annotating && !panMode}
2093
+ class:is-pannable={!!zoomCfg?.pan && isCartesian && isZoomed && panMode}
967
2094
  class:is-annotating={annotating}
968
2095
  class:is-dragging={!!dragStart}
969
2096
  viewBox={`0 0 ${geo.width} ${geo.height}`}
970
2097
  width="100%"
971
- role="img"
972
- aria-label={`${spec.type} chart`}
2098
+ style:height={autosize ? `${layoutH ?? spec.height ?? 300}px` : null}
2099
+ role={interactive ? 'group' : 'img'}
2100
+ aria-label={msg(t.chartLabel, { type: spec.type })}
2101
+ aria-description={describe && summary ? summary : undefined}
973
2102
  aria-describedby={`${uid}-table`}
2103
+ class:is-drawing={!!tool}
974
2104
  onpointerdown={onZoomDown}
975
- onpointermove={onZoomMove}
976
- onpointerup={onZoomUp}
977
- onpointercancel={onZoomUp}
2105
+ onpointermove={(e) => { onZoomMove(e); onDrawMove(e) }}
2106
+ onpointerup={(e) => { onZoomUp(e); handleDrag = null }}
2107
+ onpointercancel={(e) => { onZoomUp(e); handleDrag = null }}
2108
+ onclick={onDrawClick}
2109
+ onkeydown={onSvgKey}
2110
+ onwheel={onWheel}
2111
+ oncontextmenu={onContextMenu}
978
2112
  ondblclick={onPlotDblClick}
979
2113
  >
980
2114
  <!-- Pattern defs: emitted once per series when patternFallback is on
981
2115
  or a series sets `pattern`. Color is baked into the pattern so
982
2116
  each series gets a hue-tinted texture without dynamic CSS. -->
983
- {#if patternDefs.length}
2117
+ {#if patternDefs.length || gradientDefs.length || isCartesian}
984
2118
  <defs>
2119
+ {#if isCartesian}
2120
+ <!-- Marks are clipped to the plot so a pinned axis min / max cuts a
2121
+ value off at the edge instead of drawing it over the gutter. A
2122
+ few px of slack keeps a dot sitting on the edge whole. -->
2123
+ <clipPath id={`${uid}-plot`}>
2124
+ <rect class:sv-grid-chart-wipe={enterFx === 'wipe'} x={geo.plot.x - 4} y={geo.plot.y - 4} width={geo.plot.w + 8} height={geo.plot.h + 8} />
2125
+ </clipPath>
2126
+ {/if}
2127
+ {#each gradientDefs as g (g.id)}
2128
+ <linearGradient id={g.id} x1="0" y1="0" x2="0" y2="1">
2129
+ <stop offset="0" stop-color={g.from} />
2130
+ <stop offset="1" stop-color={g.to === 'transparent' ? g.from : g.to} stop-opacity={g.to === 'transparent' ? 0 : 1} />
2131
+ </linearGradient>
2132
+ {/each}
985
2133
  {#each patternDefs as p (p.id)}
986
2134
  {#if p.kind === 'stripe'}
987
2135
  <pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
@@ -1007,37 +2155,67 @@
1007
2155
  {/each}
1008
2156
  </defs>
1009
2157
  {/if}
2158
+ {#if geo.frame.title}
2159
+ <text class="sv-grid-chart-title" x={geo.frame.title.x} y={geo.frame.title.y} text-anchor="middle">{geo.frame.title.text}</text>
2160
+ {/if}
2161
+ {#if geo.frame.subtitle}
2162
+ <text class="sv-grid-chart-subtitle" x={geo.frame.subtitle.x} y={geo.frame.subtitle.y} text-anchor="middle">{geo.frame.subtitle.text}</text>
2163
+ {/if}
2164
+ {#if geo.frame.caption}
2165
+ <text class="sv-grid-chart-caption" x={geo.frame.caption.x} y={geo.frame.caption.y} text-anchor="start">{geo.frame.caption.text}</text>
2166
+ {/if}
1010
2167
  {#if isHorizontal}
1011
2168
  <!-- Value axis (vertical gridlines + bottom labels) -->
1012
2169
  {#each geo.valueTicks as t (t.label + t.x)}
1013
- <line class="sv-grid-chart-gridline" x1={t.x} y1={geo.plot.y} x2={t.x} y2={geo.plot.y + geo.plot.h} />
1014
- <text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 14} text-anchor="middle">{t.label}</text>
2170
+ {#if geo.grid.x}
2171
+ <line class="sv-grid-chart-gridline" x1={t.x} y1={geo.plot.y} x2={t.x} y2={geo.plot.y + geo.plot.h} />
2172
+ {/if}
2173
+ <text class="sv-grid-chart-axis is-x" x={t.x} y={geo.plot.y + geo.plot.h + 14} text-anchor="middle">{t.label}</text>
1015
2174
  {/each}
2175
+ {#if geo.grid.y}
2176
+ {#each geo.catTicks as t (t.value)}
2177
+ <line class="sv-grid-chart-gridline" x1={geo.plot.x} y1={t.y} x2={geo.plot.x + geo.plot.w} y2={t.y} />
2178
+ {/each}
2179
+ {/if}
1016
2180
  <!-- Category labels down the left -->
1017
2181
  {#each geo.catTicks as t (t.value)}
1018
2182
  <text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{truncate(t.label, 18)}</text>
1019
2183
  {/each}
1020
- {#if spec.yAxisTitle}
1021
- <text class="sv-grid-chart-axis-title" x={13} y={geo.plot.y + geo.plot.h / 2} text-anchor="middle" transform={`rotate(-90 13 ${geo.plot.y + geo.plot.h / 2})`}>{spec.yAxisTitle}</text>
2184
+ {#if yTitle}
2185
+ <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})`}>{yTitle}</text>
1022
2186
  {/if}
1023
- {#if spec.xAxisTitle}
1024
- <text class="sv-grid-chart-axis-title" x={geo.plot.x + geo.plot.w / 2} y={geo.height - 3} text-anchor="middle">{spec.xAxisTitle}</text>
2187
+ {#if xTitle}
2188
+ <text class="sv-grid-chart-axis-title" x={geo.plot.x + geo.plot.w / 2} y={geo.height - 3 - geo.frame.bottom} text-anchor="middle">{xTitle}</text>
1025
2189
  {/if}
1026
2190
  {:else if isCartesian}
1027
2191
  {#each geo.yTicks as t (t.value)}
1028
- <line class="sv-grid-chart-gridline" class:is-zero={t.value === 0} x1={geo.plot.x} y1={t.y} x2={geo.plot.x + geo.plot.w} y2={t.y} />
1029
- <text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{yTickLabel(t.value, t.label)}</text>
2192
+ {#if geo.grid.y || t.value === 0}
2193
+ <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} />
2194
+ {/if}
2195
+ {#if t.label}
2196
+ <text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={t.y + 3} text-anchor="end">{yTickLabel(t.value, t.label)}</text>
2197
+ {/if}
1030
2198
  {/each}
2199
+ {#if geo.grid.x}
2200
+ {#each geo.xTicks as t (t.label + t.x)}
2201
+ <line class="sv-grid-chart-gridline" x1={t.x} y1={geo.plot.y} x2={t.x} y2={geo.plot.y + geo.plot.h} />
2202
+ {/each}
2203
+ {/if}
1031
2204
  {#if geo.hasRightAxis}
1032
2205
  {#each geo.y2Ticks as t (t.value)}
1033
- <text class="sv-grid-chart-axis" x={geo.plot.x + geo.plot.w + 6} y={t.y + 3} text-anchor="start">{yTickLabel(t.value, t.label)}</text>
2206
+ {#if t.label}
2207
+ <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>
2208
+ {/if}
1034
2209
  {/each}
1035
2210
  {/if}
1036
2211
  {#each geo.xTicks as t (t.label + t.x)}
1037
- {#if geo.xLabelRotated}
1038
- <text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 12} text-anchor="end" transform={`rotate(-40 ${t.x} ${geo.plot.y + geo.plot.h + 12})`}>{truncate(t.label)}</text>
2212
+ {#if !t.label}
2213
+ <!-- `xAxis.labels: false` (a pane above another) keeps the ticks
2214
+ for the grid lines and draws no text. -->
2215
+ {:else if geo.xLabelRotated}
2216
+ <text class="sv-grid-chart-axis is-x" x={t.x} y={geo.plot.y + geo.plot.h + 12} text-anchor={geo.xLabelAngle < 0 ? 'end' : 'start'} transform={`rotate(${geo.xLabelAngle} ${t.x} ${geo.plot.y + geo.plot.h + 12})`}>{truncate(t.label, Math.abs(geo.xLabelAngle) >= 60 ? 18 : 12)}</text>
1039
2217
  {:else}
1040
- <text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 16} text-anchor="middle">{truncate(t.label, 16)}</text>
2218
+ <text class="sv-grid-chart-axis is-x" x={t.x} y={geo.plot.y + geo.plot.h + 16} text-anchor="middle">{truncate(t.label, 16)}</text>
1041
2219
  {/if}
1042
2220
  {/each}
1043
2221
  <!-- Grouped (nested) category axis: parent tier under the leaf labels. -->
@@ -1049,41 +2227,63 @@
1049
2227
  {/if}
1050
2228
  <text class="sv-grid-chart-axis sv-grid-chart-group-label" x={g.xCenter} y={gy} text-anchor="middle">{truncate(g.label, Math.max(4, Math.floor((g.x1 - g.x0) / 7)))}</text>
1051
2229
  {/each}
1052
- {#if spec.yAxisTitle}
1053
- <text class="sv-grid-chart-axis-title" x={13} y={geo.plot.y + geo.plot.h / 2} text-anchor="middle" transform={`rotate(-90 13 ${geo.plot.y + geo.plot.h / 2})`}>{spec.yAxisTitle}</text>
2230
+ {#if yTitle}
2231
+ <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})`}>{yTitle}</text>
1054
2232
  {/if}
1055
- {#if spec.y2AxisTitle}
1056
- <text class="sv-grid-chart-axis-title" x={geo.width - 5} y={geo.plot.y + geo.plot.h / 2} text-anchor="middle" transform={`rotate(90 ${geo.width - 5} ${geo.plot.y + geo.plot.h / 2})`}>{spec.y2AxisTitle}</text>
2233
+ {#if y2Title}
2234
+ <text class="sv-grid-chart-axis-title" x={geo.width - 12} y={geo.plot.y + geo.plot.h / 2} text-anchor="middle" transform={`rotate(90 ${geo.width - 12} ${geo.plot.y + geo.plot.h / 2})`}>{y2Title}</text>
1057
2235
  {/if}
1058
- {#if spec.xAxisTitle}
1059
- <text class="sv-grid-chart-axis-title" x={geo.plot.x + geo.plot.w / 2} y={geo.height - 3} text-anchor="middle">{spec.xAxisTitle}</text>
2236
+ {#if xTitle}
2237
+ <text class="sv-grid-chart-axis-title" x={geo.plot.x + geo.plot.w / 2} y={geo.height - 3 - geo.frame.bottom} text-anchor="middle">{xTitle}</text>
1060
2238
  {/if}
1061
2239
  {/if}
1062
2240
 
2241
+ <!-- Reference bands sit under everything: they are context, not data. -->
2242
+ {#each geo.referenceBands as band, bi (bi)}
2243
+ <rect class="sv-grid-chart-refband" x={band.x} y={band.y} width={band.w} height={band.h} fill={band.color} fill-opacity={band.opacity} />
2244
+ {#if band.label}
2245
+ {#if band.axis === 'y'}
2246
+ <text class="sv-grid-chart-reflabel" x={band.x + band.w - 3} y={band.y + 10} text-anchor="end" fill={band.color}>{band.label}</text>
2247
+ {:else}
2248
+ <text class="sv-grid-chart-reflabel" x={band.x + band.w / 2} y={band.y + 10} text-anchor="middle" fill={band.color}>{band.label}</text>
2249
+ {/if}
2250
+ {/if}
2251
+ {/each}
2252
+
2253
+ <g class="sv-grid-chart-marks" class:is-enter-grow={enterFx === 'grow'} class:is-enter-none={enterFx === 'none'} clip-path={isCartesian ? `url(#${uid}-plot)` : undefined}>
1063
2254
  {#each geo.lines as line, li (line.label + li)}
1064
- <g style={`opacity:${dimOf(line.label)}`}>
2255
+ {@const gradId = line.style?.gradient ? `${uid}-grad-${li}` : null}
2256
+ <g style={`opacity:${dimOf(line.label) * (line.style?.opacity ?? 1)}`}>
1065
2257
  {#if line.bandPath}
1066
2258
  <!-- Confidence band: shaded envelope between upperValues / lowerValues. -->
1067
2259
  <path class="sv-grid-chart-band" d={line.bandPath} fill={line.color} fill-opacity="0.12" stroke="none" />
1068
2260
  {/if}
1069
2261
  {#if line.areaPath}
1070
- <path class="sv-grid-chart-area" d={line.areaPath} fill={seriesFill[line.label] ?? line.color} fill-opacity="0.15" stroke="none" />
2262
+ <!-- A range area's band is the mark, so it fills stronger than the
2263
+ wash under a line and its two edges are drawn thin. -->
2264
+ <path class="sv-grid-chart-area" d={line.areaPath} fill={gradId ? `url(#${gradId})` : seriesFill[line.label] ?? line.color} fill-opacity={gradId ? 0.6 : line.range ? 0.35 : 0.15} stroke="none" />
1071
2265
  {/if}
1072
- <path class="sv-grid-chart-linepath" d={line.path} fill="none" stroke={line.color} stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
2266
+ <path class="sv-grid-chart-linepath" d={line.path} fill="none" stroke={line.color} stroke-width={line.style?.strokeWidth ?? (line.range ? 1 : 2)} stroke-dasharray={line.style?.dash} stroke-linejoin="round" stroke-linecap="round" />
1073
2267
  {#if dense}
1074
2268
  <!-- Below ~4px a category the dots touch, so they stop marking the
1075
2269
  points and start hiding the line. Only the hovered one is drawn,
1076
2270
  which is the one a reader is actually asking about. -->
1077
2271
  {@const pt = line.points[activeCat ?? -1]}
1078
2272
  {#if pt?.defined}
1079
- <circle class="sv-grid-chart-dot is-active" cx={pt.x} cy={pt.y} r="4" fill={line.color} />
2273
+ <circle class="sv-grid-chart-dot is-active" cx={pt.x} cy={pt.y} r="4" fill={pt.marker?.color ?? line.color} />
1080
2274
  {/if}
1081
2275
  {:else}
1082
2276
  {#each line.points as pt, pi (pi)}
1083
- {#if pt.defined}
1084
- <circle class="sv-grid-chart-dot" class:is-active={activeCat === pi} cx={pt.x} cy={pt.y} r={activeCat === pi ? 4 : 3} fill={line.color} />
1085
- {#if dataLabels}
1086
- <text class="sv-grid-chart-datalabel" x={pt.x} y={pt.y - 7} text-anchor="middle">{fmt(pt.value)}</text>
2277
+ {#if pt.defined && pt.marker?.shape !== 'none'}
2278
+ {@const shape = pt.marker?.shape ?? 'circle'}
2279
+ {@const size = (pt.marker?.size ?? 3) + (activeCat === pi ? 1 : 0)}
2280
+ {@const fill = pt.marker?.color ?? line.color}
2281
+ {#if shape === 'circle'}
2282
+ <circle class="sv-grid-chart-dot" class:is-active={activeCat === pi} class:is-selected={isSel(pt.label, line.label)} cx={pt.x} cy={pt.y} r={size} fill={fill} style={`opacity:${selDim(pt.label, line.label)}`} />
2283
+ {:else if shape === 'cross'}
2284
+ <path class="sv-grid-chart-dot is-stroked" class:is-active={activeCat === pi} d={markerPath(shape, pt.x, pt.y, size)} fill="none" stroke={fill} stroke-width="1.5" />
2285
+ {:else}
2286
+ <path class="sv-grid-chart-dot" class:is-active={activeCat === pi} d={markerPath(shape, pt.x, pt.y, size)} fill={fill} />
1087
2287
  {/if}
1088
2288
  {/if}
1089
2289
  {/each}
@@ -1096,12 +2296,20 @@
1096
2296
  {#if underlay}{@render underlay(renderCtx)}{/if}
1097
2297
 
1098
2298
  {#each geo.bars as bar, bi (bi)}
1099
- <rect class="sv-grid-chart-bar" x={bar.x} y={bar.y} width={bar.w} height={bar.h} rx="1" fill={seriesFill[bar.series] ?? bar.color} style={`opacity:${dimOf(bar.series)}`} />
1100
- {#if dataLabels && isHorizontal && bar.w > 18}
1101
- <text class="sv-grid-chart-datalabel" class:on-bar={spec.stacked || spec.stacked100} x={spec.stacked || spec.stacked100 ? bar.x + bar.w / 2 : bar.value >= 0 ? bar.x + bar.w + 3 : bar.x - 3} y={bar.y + bar.h / 2 + 3} text-anchor={spec.stacked || spec.stacked100 ? 'middle' : bar.value >= 0 ? 'start' : 'end'}>{fmt(bar.value)}</text>
1102
- {:else if dataLabels && !isHorizontal && bar.h > 13}
1103
- <text class="sv-grid-chart-datalabel" class:on-bar={spec.stacked || spec.stacked100} x={bar.x + bar.w / 2} y={spec.stacked || spec.stacked100 ? bar.y + bar.h / 2 + 3 : bar.value >= 0 ? bar.y - 3 : bar.y + bar.h + 11} text-anchor="middle">{fmt(bar.value)}</text>
1104
- {/if}
2299
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
2300
+ <rect class="sv-grid-chart-bar" class:is-selected={isSel(bar.label, bar.series, bar.index)} x={bar.x} y={bar.y} width={bar.w} height={bar.h} rx="1" fill={seriesFill[bar.series]?.startsWith('url(') ? seriesFill[bar.series] : bar.color} style={`opacity:${dimOf(bar.series) * (bar.opacity ?? 1) * selDim(bar.label, bar.series, bar.index)}`} onclick={selMode ? (e) => { e.stopPropagation(); select(bar.label, bar.series, bar.value, bar.index, e) } : undefined} />
2301
+ {/each}
2302
+
2303
+ <!-- Lollipop stems and dumbbells: a rule with a dot (or two). -->
2304
+ {#each geo.stems as st, sti (sti)}
2305
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
2306
+ <g class="sv-grid-chart-stem" style={`opacity:${dimOf(st.series)}`} onmousemove={(e) => hoverStem(e.clientX, e.clientY, st)} onmouseleave={clearActive}>
2307
+ <line x1={st.xCenter} y1={st.y0} x2={st.xCenter} y2={st.y1} stroke={st.dumbbell ? 'var(--sg-muted, #94a3b8)' : st.color} stroke-width={st.dumbbell ? 2 : 1.5} />
2308
+ {#if st.dumbbell}
2309
+ <circle class="sv-grid-chart-dot" cx={st.xCenter} cy={st.y0} r={st.r} fill={st.color2 ?? st.color} fill-opacity="0.55" stroke={st.color2 ?? st.color} />
2310
+ {/if}
2311
+ <circle class="sv-grid-chart-dot" cx={st.xCenter} cy={st.y1} r={st.r} fill={st.color} />
2312
+ </g>
1105
2313
  {/each}
1106
2314
 
1107
2315
  <!-- Candles paint above gridlines and any volume bars, below the reference
@@ -1115,7 +2323,7 @@
1115
2323
  <line class="sv-grid-chart-ohlc" x1={k.xCenter} y1={k.yClose} x2={k.x + k.w} y2={k.yClose} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
1116
2324
  {:else}
1117
2325
  <line class="sv-grid-chart-wick" x1={k.xCenter} y1={k.yHigh} x2={k.xCenter} y2={k.yLow} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
1118
- <rect class="sv-grid-chart-candle" x={k.x} y={k.bodyY} width={k.w} height={k.bodyH} fill={k.up ? 'none' : k.color} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
2326
+ <rect class="sv-grid-chart-candle" x={k.x} y={k.bodyY} width={k.w} height={k.bodyH} fill={k.hollow ? 'none' : k.color} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
1119
2327
  {/if}
1120
2328
  {/each}
1121
2329
 
@@ -1141,25 +2349,48 @@
1141
2349
  {/each}
1142
2350
 
1143
2351
  {#each geo.scatterPoints as dot, di (di)}
1144
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
2352
+ <!-- A point selects like a bar: its label (or its series) is the
2353
+ category, its y the value, and Enter does what a click does. The
2354
+ dots used to have no click handler, so `selectable` and `onSelect`
2355
+ did nothing on a scatter chart. -->
2356
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1145
2357
  <circle
1146
2358
  class="sv-grid-chart-scatter"
2359
+ class:is-selected={isSel(dot.label || dot.series, dot.series, di)}
1147
2360
  cx={dot.cx}
1148
2361
  cy={dot.cy}
1149
2362
  r={dot.r}
1150
2363
  fill={dot.color}
1151
2364
  fill-opacity="0.7"
1152
2365
  stroke={dot.color}
1153
- style={`opacity:${dimOf(dot.series)}`}
2366
+ style={`opacity:${dimOf(dot.series) * selDim(dot.label || dot.series, dot.series, di)}`}
2367
+ role={selMode || onSelect ? 'button' : 'img'}
2368
+ tabindex={roveTab('.sv-grid-chart-scatter', di)}
2369
+ aria-label={`${dot.label || dot.series}: ${fmt(dot.x)}, ${fmt(dot.y)}`}
1154
2370
  onmousemove={(e) => hoverDot(e.clientX, e.clientY, dot)}
1155
2371
  onmouseleave={clearActive}
2372
+ onclick={selMode || onSelect ? (e) => { e.stopPropagation(); select(dot.label || dot.series, dot.series, dot.y, di, e) } : undefined}
2373
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-scatter', di); const p = box(e.currentTarget); hoverDot(p.x, p.y, dot) }}
2374
+ onblur={clearActive}
2375
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-scatter', di, selMode || onSelect ? () => select(dot.label || dot.series, dot.series, dot.y, di, e) : null)}
1156
2376
  />
1157
2377
  {/each}
2378
+ </g>
1158
2379
 
1159
2380
  <!-- Reference / target lines paint on top so they read over the bars/areas. -->
1160
2381
  {#each geo.referenceLines as ref, ri (ri)}
1161
2382
  <line class="sv-grid-chart-refline" x1={geo.plot.x} y1={ref.y} x2={geo.plot.x + geo.plot.w} y2={ref.y} stroke={ref.color} stroke-dasharray={ref.dashed ? '5 4' : undefined} />
1162
- <text class="sv-grid-chart-reflabel" x={geo.plot.x + geo.plot.w - 3} y={ref.y - 3} text-anchor="end" fill={ref.color}>{ref.label}</text>
2383
+ {#if ref.pill}
2384
+ <!-- A last-price pill: the value in a filled tag at the right end of
2385
+ the line, the way a ticker reads it. -->
2386
+ {@const pw = Math.max(28, ref.label.length * 6.4 + 10)}
2387
+ <g class="sv-grid-chart-refpill">
2388
+ <rect x={geo.plot.x + geo.plot.w - pw} y={ref.y - 8} width={pw} height="16" rx="3" fill={ref.color} />
2389
+ <text x={geo.plot.x + geo.plot.w - pw / 2} y={ref.y + 3.5} text-anchor="middle" fill="#fff">{ref.label}</text>
2390
+ </g>
2391
+ {:else}
2392
+ <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>
2393
+ {/if}
1163
2394
  {/each}
1164
2395
  {#each geo.referenceLinesV as ref, ri (ri)}
1165
2396
  <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} />
@@ -1168,9 +2399,30 @@
1168
2399
 
1169
2400
  <!-- Trend / moving-average overlays. Dashed so they read distinctly
1170
2401
  from the source series, drawn on top of bars + lines. -->
2402
+ <g class="sv-grid-chart-marks" class:is-enter-grow={enterFx === 'grow'} class:is-enter-none={enterFx === 'none'} clip-path={isCartesian ? `url(#${uid}-plot)` : undefined}>
1171
2403
  {#each geo.overlays as ovl, oi (ovl.label + oi)}
2404
+ {#if ovl.bandPath}
2405
+ <path class="sv-grid-chart-overlay-band" d={ovl.bandPath} fill={ovl.color} fill-opacity="0.1" stroke={ovl.color} stroke-opacity="0.35" stroke-width="1" />
2406
+ {/if}
1172
2407
  <path class="sv-grid-chart-overlay" d={ovl.path} fill="none" stroke={ovl.color} stroke-width="2" stroke-dasharray="6 4" stroke-linejoin="round" stroke-linecap="round" />
1173
2408
  {/each}
2409
+ </g>
2410
+
2411
+ <!-- Data labels, placed and thinned by the engine so bars, lines and combos
2412
+ share one rule set. Outside the clip: a label above the top bar may
2413
+ legitimately sit in the gutter. -->
2414
+ {#each dataLabelGeo as dl, dli (dli)}
2415
+ {#if dl.leader}
2416
+ <line class="sv-grid-chart-datalabel-leader" x1={dl.leader.x1} y1={dl.leader.y1} x2={dl.leader.x2} y2={dl.leader.y2} style={`opacity:${dimOf(dl.series)}`} />
2417
+ {/if}
2418
+ <text class="sv-grid-chart-datalabel" class:on-bar={dl.onBar} x={dl.x} y={dl.y} text-anchor={dl.anchor} transform={dl.angle ? `rotate(${dl.angle} ${dl.x} ${dl.y})` : undefined} style={`opacity:${dimOf(dl.series)}`}>{dl.text}</text>
2419
+ {/each}
2420
+
2421
+ <!-- Series names at the lines' last points, in the gutter the engine
2422
+ reserved for them. Outside the clip on purpose. -->
2423
+ {#each geo.seriesLabels as sl (sl.series)}
2424
+ <text class="sv-grid-chart-serieslabel" x={sl.x} y={sl.y + 3.5} text-anchor="start" fill={sl.color} style={`opacity:${dimOf(sl.series)}`}>{sl.text}</text>
2425
+ {/each}
1174
2426
 
1175
2427
  <!-- Custom-series seam, above the built-in marks. Deliberately BEFORE the
1176
2428
  crosshair and the hit layer below, so a custom mark cannot swallow the
@@ -1183,19 +2435,18 @@
1183
2435
  {@const dx = ann.placement === 'left' ? -8 : ann.placement === 'right' ? 8 : 0}
1184
2436
  {@const dy = ann.placement === 'bottom' ? 14 : ann.placement === 'top' ? -8 : 0}
1185
2437
  {@const anchor = ann.placement === 'left' ? 'end' : ann.placement === 'right' ? 'start' : 'middle'}
2438
+ {@const removable = annotating && !!onAnnotationRemove}
2439
+ {@const flagW = Math.max(14, ann.label.length * 6.2 + 8)}
2440
+ <!-- The group is the interactive element: removable in annotate mode,
2441
+ hoverable / focusable when the note carries longer text. -->
1186
2442
  <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
1187
- <circle
2443
+ <g
1188
2444
  class="sv-grid-chart-annotation-marker"
1189
- class:is-removable={annotating && !!onAnnotationRemove}
1190
- cx={ann.x}
1191
- cy={ann.y}
1192
- r={annotating && onAnnotationRemove ? 6 : 4}
1193
- fill={ann.color}
1194
- stroke="var(--sg-bg, #fff)"
1195
- stroke-width="1.5"
1196
- role={annotating && onAnnotationRemove ? 'button' : undefined}
1197
- tabindex={annotating && onAnnotationRemove ? 0 : undefined}
1198
- aria-label={annotating && onAnnotationRemove ? `Remove note: ${ann.label}` : undefined}
2445
+ class:is-removable={removable}
2446
+ class:has-text={!!ann.text}
2447
+ role={removable ? 'button' : ann.text ? 'img' : undefined}
2448
+ tabindex={removable || ann.text ? 0 : undefined}
2449
+ aria-label={removable ? msg(t.removeNote, { label: ann.label }) : ann.text ? `${ann.label}: ${ann.text}` : undefined}
1199
2450
  onclick={(e) => {
1200
2451
  if (!annotating || !onAnnotationRemove) return
1201
2452
  // Or the same click also pins a NEW note where this one is.
@@ -1209,10 +2460,97 @@
1209
2460
  e.stopPropagation()
1210
2461
  onAnnotationRemove(ai)
1211
2462
  }}
1212
- />
1213
- <text class="sv-grid-chart-annotation-label" x={ann.x + dx} y={ann.y + dy} text-anchor={anchor} fill={ann.color}>{ann.label}</text>
2463
+ onmouseenter={(e) => { if (ann.text) showTip(e.clientX, e.clientY, ann.label, [{ value: ann.text }]) }}
2464
+ onmouseleave={() => { if (ann.text) tip = null }}
2465
+ onfocus={() => {
2466
+ if (!ann.text || !svgEl) return
2467
+ const r = svgEl.getBoundingClientRect()
2468
+ showTip(r.left + (ann.x * r.width) / geo.width, r.top + (ann.y * r.height) / geo.height, ann.label, [{ value: ann.text }])
2469
+ }}
2470
+ onblur={() => { if (ann.text) tip = null }}
2471
+ >
2472
+ {#if ann.shape === 'flag'}
2473
+ <line x1={ann.x} y1={ann.y} x2={ann.x} y2={ann.y - 24} stroke={ann.color} stroke-width="1.5" />
2474
+ <rect x={ann.x} y={ann.y - 24} width={flagW} height="13" rx="2" fill={ann.color} />
2475
+ <text class="sv-grid-chart-annotation-flagtext" x={ann.x + flagW / 2} y={ann.y - 14.5} text-anchor="middle" fill="#fff">{ann.label}</text>
2476
+ {:else if ann.shape === 'pin'}
2477
+ <path d={`M${ann.x},${ann.y} c-7,-9 -8,-13 -8,-16 a8,8 0 1,1 16,0 c0,3 -1,7 -8,16 z`} fill={ann.color} stroke="var(--sg-bg, #fff)" stroke-width="1.2" />
2478
+ <!-- The head fits one glyph, the initial; the label itself sits beside
2479
+ the pin like a dot's does, and the tooltip carries the text. -->
2480
+ <text class="sv-grid-chart-annotation-flagtext" x={ann.x} y={ann.y - 12.5} text-anchor="middle" fill="#fff">{ann.label.slice(0, 1)}</text>
2481
+ <text class="sv-grid-chart-annotation-label" x={ann.x + dx} y={ann.y + dy} text-anchor={anchor} fill={ann.color}>{ann.label}</text>
2482
+ {:else if ann.shape === 'square'}
2483
+ <rect x={ann.x - 4.5} y={ann.y - 4.5} width="9" height="9" fill={ann.color} stroke="var(--sg-bg, #fff)" stroke-width="1.5" />
2484
+ <text class="sv-grid-chart-annotation-label" x={ann.x + dx} y={ann.y + dy} text-anchor={anchor} fill={ann.color}>{ann.label}</text>
2485
+ {:else}
2486
+ <circle cx={ann.x} cy={ann.y} r={removable ? 6 : 4} fill={ann.color} stroke="var(--sg-bg, #fff)" stroke-width="1.5" />
2487
+ <text class="sv-grid-chart-annotation-label" x={ann.x + dx} y={ann.y + dy} text-anchor={anchor} fill={ann.color}>{ann.label}</text>
2488
+ {/if}
2489
+ </g>
1214
2490
  {/each}
1215
2491
 
2492
+ <!-- Reader drawings, clipped to the plot. Selected ones grow handles that
2493
+ drag their points; Delete removes the focused one. -->
2494
+ {#if geo.drawings.length || pending}
2495
+ <g class="sv-grid-chart-drawings" clip-path={`url(#${uid}-plot)`}>
2496
+ {#each geo.drawings as d (d.id)}
2497
+ {@const p0 = d.points[0]!}
2498
+ {@const p1 = d.points[1]}
2499
+ {@const sel = selectedDrawing === d.id}
2500
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2501
+ <g
2502
+ class={`sv-grid-chart-drawing is-${d.kind}`}
2503
+ class:is-selected={sel}
2504
+ role={drawable ? 'button' : undefined}
2505
+ tabindex={drawable ? 0 : undefined}
2506
+ aria-label={drawable ? `${msg(t.drawing, { tool: TOOL_LABEL[d.kind] })}${d.text ? `: ${d.text}` : ''}` : undefined}
2507
+ onclick={(e) => { if (drawable && !tool) { e.stopPropagation(); selectedDrawing = sel ? null : d.id } }}
2508
+ onkeydown={(e) => { if (drawable) onDrawKey(e, d.id) }}
2509
+ >
2510
+ {#if (d.kind === 'trend' || d.kind === 'arrow') && p1}
2511
+ {@const ang = Math.atan2(p1.y - p0.y, p1.x - p0.x)}
2512
+ <line class="sv-grid-chart-drawing-hit" x1={p0.x} y1={p0.y} x2={p1.x} y2={p1.y} />
2513
+ <line x1={p0.x} y1={p0.y} x2={p1.x} y2={p1.y} stroke={d.color} stroke-width="1.5" />
2514
+ {#if d.kind === 'arrow'}
2515
+ <path d={`M${p1.x},${p1.y} L${p1.x - 10 * Math.cos(ang - 0.45)},${p1.y - 10 * Math.sin(ang - 0.45)} L${p1.x - 10 * Math.cos(ang + 0.45)},${p1.y - 10 * Math.sin(ang + 0.45)} Z`} fill={d.color} />
2516
+ {/if}
2517
+ {:else if d.kind === 'rect' && p1}
2518
+ <rect x={Math.min(p0.x, p1.x)} y={Math.min(p0.y, p1.y)} width={Math.abs(p1.x - p0.x)} height={Math.abs(p1.y - p0.y)} fill={d.color} fill-opacity="0.08" stroke={d.color} stroke-width="1.2" />
2519
+ {:else if d.kind === 'hray'}
2520
+ <line class="sv-grid-chart-drawing-hit" x1={p0.x} y1={p0.y} x2={geo.plot.x + geo.plot.w} y2={p0.y} />
2521
+ <line x1={p0.x} y1={p0.y} x2={geo.plot.x + geo.plot.w} y2={p0.y} stroke={d.color} stroke-width="1.2" stroke-dasharray="4 3" />
2522
+ <text class="sv-grid-chart-drawing-label" x={geo.plot.x + geo.plot.w - 3} y={p0.y - 3} text-anchor="end" fill={d.color}>{d.label}</text>
2523
+ {:else if d.kind === 'fib' && p1 && d.levels}
2524
+ {@const x0 = Math.min(p0.x, p1.x)}
2525
+ {@const x1 = Math.max(p0.x, p1.x)}
2526
+ {#each d.levels as lv (lv.ratio)}
2527
+ <line x1={x0} y1={lv.y} x2={x1} y2={lv.y} stroke={d.color} stroke-width="1" stroke-dasharray={lv.ratio === 0 || lv.ratio === 1 ? undefined : '3 3'} />
2528
+ <text class="sv-grid-chart-drawing-label" x={x0 + 3} y={lv.y - 2} fill={d.color}>{lv.label}</text>
2529
+ {/each}
2530
+ <rect class="sv-grid-chart-drawing-hit" x={x0} y={Math.min(p0.y, p1.y)} width={x1 - x0} height={Math.abs(p1.y - p0.y)} />
2531
+ {:else if d.kind === 'text'}
2532
+ <text class="sv-grid-chart-drawing-text" x={p0.x} y={p0.y} fill={d.color}>{d.text ?? ''}</text>
2533
+ {/if}
2534
+ {#if sel && drawable}
2535
+ {#each d.points as pt, k (k)}
2536
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2537
+ <circle class="sv-grid-chart-drawing-handle" cx={pt.x} cy={pt.y} r="5" fill="var(--sg-bg, #fff)" stroke={d.color} stroke-width="1.5"
2538
+ onpointerdown={(e) => { e.stopPropagation(); e.preventDefault(); handleDrag = { id: d.id, index: k }; svgEl?.setPointerCapture?.(e.pointerId) }} />
2539
+ {/each}
2540
+ {/if}
2541
+ </g>
2542
+ {/each}
2543
+ {#if pending && drawCursor}
2544
+ <circle cx={pending.px} cy={pending.py} r="3.5" fill="var(--sg-accent, #2563eb)" />
2545
+ {#if tool === 'rect'}
2546
+ <rect x={Math.min(pending.px, drawCursor.x)} y={Math.min(pending.py, drawCursor.y)} width={Math.abs(drawCursor.x - pending.px)} height={Math.abs(drawCursor.y - pending.py)} fill="var(--sg-accent, #2563eb)" fill-opacity="0.08" stroke="var(--sg-accent, #2563eb)" stroke-dasharray="4 3" />
2547
+ {:else}
2548
+ <line x1={pending.px} y1={pending.py} x2={drawCursor.x} y2={drawCursor.y} stroke="var(--sg-accent, #2563eb)" stroke-width="1.5" stroke-dasharray="4 3" />
2549
+ {/if}
2550
+ {/if}
2551
+ </g>
2552
+ {/if}
2553
+
1216
2554
  {#if isCartesian && activeCat !== null}
1217
2555
  {#if isHorizontal}
1218
2556
  {@const cy = geo.plot.y + slot * activeCat + slot / 2}
@@ -1228,6 +2566,24 @@
1228
2566
  ? linePt.x
1229
2567
  : geo.plot.x + slot * activeCat + slot / 2}
1230
2568
  <line class="sv-grid-chart-crosshair" x1={cx} y1={geo.plot.y} x2={cx} y2={geo.plot.y + geo.plot.h} />
2569
+ {#if crosshairLabels}
2570
+ <!-- The crosshair read off the axes: the category under it on the
2571
+ x axis, the value under the pointer on the value axis. -->
2572
+ {@const catText = truncate(tip?.title ?? visibleSpec.categories[activeCat] ?? '', 16)}
2573
+ {@const cw = Math.max(24, catText.length * 6.2 + 10)}
2574
+ <g class="sv-grid-chart-crosshair-label" pointer-events="none">
2575
+ <rect x={Math.max(0, Math.min(geo.width - cw, cx - cw / 2))} y={geo.plot.y + geo.plot.h + 2} width={cw} height="15" rx="3" />
2576
+ <text x={Math.max(cw / 2, Math.min(geo.width - cw / 2, cx))} y={geo.plot.y + geo.plot.h + 13} text-anchor="middle">{catText}</text>
2577
+ </g>
2578
+ {#if hoverVy != null && renderScales && geo.axes}
2579
+ {@const yText = crosshairValueText(renderScales.yInvert(hoverVy))}
2580
+ {@const yw = Math.max(24, yText.length * 6.2 + 10)}
2581
+ <g class="sv-grid-chart-crosshair-label" pointer-events="none">
2582
+ <rect x={Math.max(0, geo.plot.x - yw - 2)} y={hoverVy - 7.5} width={yw} height="15" rx="3" />
2583
+ <text x={Math.max(yw / 2, geo.plot.x - 2 - yw / 2)} y={hoverVy + 3.5} text-anchor="middle">{yText}</text>
2584
+ </g>
2585
+ {/if}
2586
+ {/if}
1231
2587
  {/if}
1232
2588
  {/if}
1233
2589
 
@@ -1242,20 +2598,25 @@
1242
2598
  {/each}
1243
2599
  <!-- Cells -->
1244
2600
  {#each geo.heatmapCells as cell, ci (ci)}
1245
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
2601
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1246
2602
  <rect
1247
2603
  class="sv-grid-chart-heatcell"
1248
2604
  x={cell.x} y={cell.y} width={cell.w} height={cell.h}
1249
2605
  fill={cell.color}
1250
- role={onSelect ? 'button' : 'presentation'}
1251
- tabindex={onSelect ? 0 : undefined}
2606
+ role={onSelect || selMode ? 'button' : 'img'}
2607
+ tabindex={roveTab('.sv-grid-chart-heatcell', ci)}
1252
2608
  aria-label={`${cell.rowLabel} ${cell.colLabel}: ${fmt(cell.value)}`}
1253
2609
  onmousemove={(e) => hoverCell(e.clientX, e.clientY, cell)}
1254
2610
  onmouseleave={clearActive}
2611
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-heatcell', ci); const p = box(e.currentTarget); hoverCell(p.x, p.y, cell) }}
2612
+ onblur={clearActive}
1255
2613
  onclick={() => select(cell.colLabel, cell.rowLabel, cell.value)}
1256
- onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(cell.colLabel, cell.rowLabel, cell.value) } }}
2614
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-heatcell', ci, onSelect || selMode ? () => select(cell.colLabel, cell.rowLabel, cell.value) : null, { cols: geo.heatmapColTicks.length })}
1257
2615
  />
1258
- {#if dataLabels && cell.w > 22 && cell.h > 14}
2616
+ <!-- The spec's own `dataLabels` counts here too; the cell used to
2617
+ read the component prop alone, so `dataLabels: { show: true }`
2618
+ in a heat map's spec drew nothing. -->
2619
+ {#if labelCfg.show && cell.w > 22 && cell.h > 14}
1259
2620
  <text class="sv-grid-chart-heatlabel" x={cell.x + cell.w / 2} y={cell.y + cell.h / 2 + 3} text-anchor="middle" fill={cell.textColor}>{fmt(cell.value)}</text>
1260
2621
  {/if}
1261
2622
  {/each}
@@ -1285,18 +2646,24 @@
1285
2646
  class="sv-grid-chart-funnel-seg"
1286
2647
  d={seg.path}
1287
2648
  fill={seg.color}
1288
- role={onSelect ? 'button' : 'presentation'}
1289
- tabindex={onSelect ? 0 : undefined}
2649
+ role={onSelect || selMode ? 'button' : 'img'}
2650
+ tabindex={roveTab('.sv-grid-chart-funnel-seg', fi)}
1290
2651
  aria-label={`${seg.label}: ${fmt(seg.value)}`}
1291
2652
  onmousemove={(e) => hoverFunnel(e.clientX, e.clientY, seg)}
1292
2653
  onmouseleave={clearActive}
2654
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-funnel-seg', fi); const p = box(e.currentTarget); hoverFunnel(p.x, p.y, seg) }}
2655
+ onblur={clearActive}
1293
2656
  onclick={() => select(seg.label, seg.label, seg.value, fi)}
1294
- onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(seg.label, seg.label, seg.value, fi) } }}
2657
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-funnel-seg', fi, onSelect || selMode ? () => select(seg.label, seg.label, seg.value, fi) : null)}
1295
2658
  />
1296
- <text class="sv-grid-chart-funnel-label" x={seg.cx} y={seg.cy - 3} text-anchor="middle" fill={seg.textColor}>{truncate(seg.label, 24)}</text>
1297
- <text class="sv-grid-chart-funnel-value" x={seg.cx} y={seg.cy + 11} text-anchor="middle" fill={seg.textColor}>
1298
- {fmt(seg.value)} · {(seg.conversion * 100).toFixed(0)}%
1299
- </text>
2659
+ <!-- Two lines of text need about 26px of segment; a thumbnail-sized
2660
+ funnel keeps the shapes and drops the words. -->
2661
+ {#if geo.plot.h / Math.max(1, geo.funnelSegments.length) >= 26}
2662
+ <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>
2663
+ <text class="sv-grid-chart-funnel-value" x={seg.cx} y={seg.cy + 11} text-anchor="middle" fill={seg.textColor}>
2664
+ {fmt(seg.value)} · {(seg.conversion * 100).toFixed(0)}%
2665
+ </text>
2666
+ {/if}
1300
2667
  {/each}
1301
2668
  {/if}
1302
2669
 
@@ -1321,10 +2688,17 @@
1321
2688
  {#each geo.radarSeries as rs, si (si)}
1322
2689
  <path class="sv-grid-chart-radar-poly" d={rs.path} fill={rs.color} fill-opacity="0.18" stroke={rs.color} stroke-width="2" style={`opacity:${dimOf(rs.label)}`} />
1323
2690
  {#each rs.points as pt, pi (pi)}
1324
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
2691
+ {@const ri = si * rs.points.length + pi}
2692
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1325
2693
  <circle class="sv-grid-chart-radar-dot" cx={pt.x} cy={pt.y} r="3" fill={rs.color}
2694
+ role="img"
2695
+ tabindex={roveTab('.sv-grid-chart-radar-dot', ri)}
2696
+ aria-label={`${rs.label} ${pt.axis}: ${fmt(pt.value)}`}
1326
2697
  onmousemove={(e) => hoverRadarPoint(e.clientX, e.clientY, rs.label, pt.axis, pt.value, rs.color)}
1327
- onmouseleave={clearActive} />
2698
+ onmouseleave={clearActive}
2699
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-radar-dot', ri); const p = box(e.currentTarget); hoverRadarPoint(p.x, p.y, rs.label, pt.axis, pt.value, rs.color) }}
2700
+ onblur={clearActive}
2701
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-radar-dot', ri)} />
1328
2702
  {/each}
1329
2703
  {/each}
1330
2704
  {/if}
@@ -1344,7 +2718,7 @@
1344
2718
  {/each}
1345
2719
  <!-- Cells -->
1346
2720
  {#each geo.calendarCells as cell, ci (ci)}
1347
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
2721
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1348
2722
  <rect
1349
2723
  class="sv-grid-chart-calendar-cell"
1350
2724
  class:is-blank={!cell.defined}
@@ -1352,9 +2726,14 @@
1352
2726
  width={cell.size - 2} height={cell.size - 2}
1353
2727
  fill={cell.defined ? cell.color : 'transparent'}
1354
2728
  rx="2"
1355
- aria-label={`${cell.date}: ${cell.defined ? fmt(cell.value) : 'no data'}`}
1356
- onmousemove={(e) => cell.defined && showTip(e.clientX, e.clientY, cell.date, [{ color: cell.color, value: fmt(cell.value) }])}
2729
+ role="img"
2730
+ tabindex={roveTab('.sv-grid-chart-calendar-cell', ci)}
2731
+ aria-label={`${calendarDay(cell.date)}: ${cell.defined ? fmt(cell.value) : t.noValue}`}
2732
+ onmousemove={(e) => cell.defined && showTip(e.clientX, e.clientY, calendarDay(cell.date), [{ color: cell.color, value: fmt(cell.value) }])}
1357
2733
  onmouseleave={clearActive}
2734
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-calendar-cell', ci); if (cell.defined) { const p = box(e.currentTarget); showTip(p.x, p.y, calendarDay(cell.date), [{ color: cell.color, value: fmt(cell.value) }]) } }}
2735
+ onblur={clearActive}
2736
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-calendar-cell', ci, null, { cols: 7, colMajor: true })}
1358
2737
  />
1359
2738
  {/each}
1360
2739
  <!-- Legend strip on the right -->
@@ -1403,23 +2782,26 @@
1403
2782
  <!-- Scale end labels + center readout. -->
1404
2783
  <text class="sv-grid-chart-axis" x={g.minLabel.x} y={g.minLabel.y} text-anchor="middle">{fmt(g.min)}</text>
1405
2784
  <text class="sv-grid-chart-axis" x={g.maxLabel.x} y={g.maxLabel.y} text-anchor="middle">{fmt(g.max)}</text>
1406
- <text class="sv-grid-chart-gauge-value" x={g.cx} y={g.cy + 38} text-anchor="middle">{fmt(g.value)}{g.unit ? ` ${g.unit}` : ''}</text>
2785
+ <!-- A symbol unit hugs the number (99.2%, 21°C); a word unit takes a space (480 ms). -->
2786
+ <text class="sv-grid-chart-gauge-value" x={g.cx} y={g.cy + 38} text-anchor="middle">{fmt(g.value)}{g.unit ? (/^[%°′″]/.test(g.unit) ? g.unit : ` ${g.unit}`) : ''}</text>
1407
2787
  {/if}
1408
2788
 
1409
2789
  {#if isTreemap}
1410
2790
  {#each geo.treemapCells as cell, ti (ti)}
1411
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
2791
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1412
2792
  <rect
1413
2793
  class="sv-grid-chart-treemap-cell"
1414
2794
  x={cell.x} y={cell.y} width={cell.w} height={cell.h}
1415
2795
  fill={cell.color}
1416
- role={onSelect ? 'button' : 'presentation'}
1417
- tabindex={onSelect ? 0 : undefined}
2796
+ role={onSelect || selMode || canDrill ? 'button' : 'img'}
2797
+ tabindex={roveTab('.sv-grid-chart-treemap-cell', ti)}
1418
2798
  aria-label={`${cell.name}: ${fmt(cell.value)}`}
1419
2799
  onmousemove={(e) => showTip(e.clientX, e.clientY, cell.name, [{ color: cell.color, value: fmt(cell.value) }])}
1420
2800
  onmouseleave={clearActive}
1421
- onclick={() => select(cell.name, '', cell.value)}
1422
- onkeydown={(e) => { if (onSelect && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(cell.name, '', cell.value) } }}
2801
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-treemap-cell', ti); const p = box(e.currentTarget); showTip(p.x, p.y, cell.name, [{ color: cell.color, value: fmt(cell.value) }]) }}
2802
+ onblur={clearActive}
2803
+ onclick={(e) => { if (canDrill && drillInto([...drillPath, ...(pathTo(drilledRoot!, cell.name) ?? [])])) return; select(cell.name, '', cell.value, undefined, e) }}
2804
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-treemap-cell', ti, () => { if (canDrill && drillInto([...drillPath, ...(pathTo(drilledRoot!, cell.name) ?? [])])) return; if (onSelect || selMode) select(cell.name, '', cell.value, undefined, e) })}
1423
2805
  />
1424
2806
  {#if cell.w > 36 && cell.h > 18}
1425
2807
  <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>
@@ -1433,7 +2815,7 @@
1433
2815
  {#if isSankey}
1434
2816
  <!-- Ribbon links (drawn first so node rects sit on top). -->
1435
2817
  {#each geo.sankeyLinks as link, li (li)}
1436
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
2818
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1437
2819
  <path
1438
2820
  class="sv-grid-chart-sankey-link"
1439
2821
  d={link.path}
@@ -1441,30 +2823,37 @@
1441
2823
  stroke={link.color}
1442
2824
  stroke-width={link.width}
1443
2825
  stroke-opacity="0.35"
1444
- aria-label={`${link.source} -> ${link.target}: ${fmt(link.value)}`}
1445
- onmousemove={(e) => showTip(e.clientX, e.clientY, `${link.source} -> ${link.target}`, [{ color: link.color, value: fmt(link.value) }])}
2826
+ role="img"
2827
+ tabindex={roveTab('.sv-grid-chart-sankey-link', li)}
2828
+ aria-label={`${link.source} → ${link.target}: ${fmt(link.value)}`}
2829
+ onmousemove={(e) => showTip(e.clientX, e.clientY, `${link.source} → ${link.target}`, [{ color: link.color, value: fmt(link.value) }])}
1446
2830
  onmouseleave={clearActive}
2831
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-sankey-link', li); const p = box(e.currentTarget); showTip(p.x, p.y, `${link.source} → ${link.target}`, [{ color: link.color, value: fmt(link.value) }]) }}
2832
+ onblur={clearActive}
2833
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-sankey-link', li)}
1447
2834
  />
1448
2835
  {/each}
1449
2836
  <!-- Node rectangles + labels. -->
1450
2837
  {#each geo.sankeyNodes as node, ni (ni)}
1451
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
2838
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1452
2839
  <rect
1453
2840
  class="sv-grid-chart-sankey-node"
1454
2841
  x={node.x} y={node.y} width={node.w} height={node.h}
1455
2842
  fill={node.color}
1456
- tabindex={0}
1457
- aria-label={`${node.label}: in ${fmt(node.totalIn)}, out ${fmt(node.totalOut)}`}
2843
+ role="img"
2844
+ tabindex={roveTab('.sv-grid-chart-sankey-node', ni)}
2845
+ aria-label={msg(t.sankeyNode, { label: node.label, in: fmt(node.totalIn), out: fmt(node.totalOut) })}
1458
2846
  onmousemove={(e) => showTip(e.clientX, e.clientY, node.label, [
1459
2847
  { label: 'in', value: fmt(node.totalIn) },
1460
2848
  { label: 'out', value: fmt(node.totalOut) },
1461
2849
  ])}
1462
2850
  onmouseleave={clearActive}
1463
- onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); showTip(b.left + b.width / 2, b.top, node.label, [
2851
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-sankey-node', ni); const p = box(e.currentTarget); showTip(p.x, p.y, node.label, [
1464
2852
  { label: 'in', value: fmt(node.totalIn) },
1465
2853
  { label: 'out', value: fmt(node.totalOut) },
1466
2854
  ]) }}
1467
2855
  onblur={clearActive}
2856
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-sankey-node', ni)}
1468
2857
  />
1469
2858
  <text class="sv-grid-chart-sankey-label"
1470
2859
  x={node.column === 0 ? node.x + node.w + 4 : node.x - 4}
@@ -1473,32 +2862,118 @@
1473
2862
  {/each}
1474
2863
  {/if}
1475
2864
 
2865
+ {#if isArcs}
2866
+ <!-- Polar axis for radial columns / nightingale: faint rings at the
2867
+ nice ticks and a category label at each slot's outer edge. -->
2868
+ {#if geo.radarCenter && geo.polarRings.length}
2869
+ {#each geo.polarRings as pr, pri (pri)}
2870
+ <circle class="sv-grid-chart-gridline" cx={geo.radarCenter.cx} cy={geo.radarCenter.cy} r={pr} fill="none" />
2871
+ {/each}
2872
+ {/if}
2873
+ {#each geo.polarAxes as ax, axi (axi)}
2874
+ <text class="sv-grid-chart-axis" x={ax.x} y={ax.y + 3} text-anchor={ax.x < (geo.radarCenter?.cx ?? 0) - 2 ? 'end' : ax.x > (geo.radarCenter?.cx ?? 0) + 2 ? 'start' : 'middle'}>{truncate(ax.label, 14)}</text>
2875
+ {/each}
2876
+ {#each geo.arcs as arc, ai (ai)}
2877
+ {#if arc.trackPath}
2878
+ <path class="sv-grid-chart-arc-track" d={arc.trackPath} />
2879
+ {/if}
2880
+ {/each}
2881
+ {#each geo.chordRibbons as rb, rbi (rbi)}
2882
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
2883
+ <path
2884
+ class="sv-grid-chart-ribbon"
2885
+ d={rb.path}
2886
+ fill={rb.color}
2887
+ fill-opacity="0.45"
2888
+ stroke={rb.color}
2889
+ stroke-opacity="0.6"
2890
+ style={`opacity:${dimOf(rb.source) === 1 && dimOf(rb.target) === 1 ? 1 : 0.18}`}
2891
+ onmousemove={(e) => hoverRibbon(e.clientX, e.clientY, rb)}
2892
+ onmouseleave={clearActive}
2893
+ />
2894
+ {/each}
2895
+ {#each geo.arcs as arc, ai (ai)}
2896
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
2897
+ <path
2898
+ class="sv-grid-chart-arc"
2899
+ class:is-selected={isSel(arc.label, arc.series)}
2900
+ class:is-drillable={canDrill && !!arc.nodePath && !!drillTree(drillRoot!, arc.nodePath)?.children?.length}
2901
+ d={arc.path}
2902
+ fill={arc.color}
2903
+ stroke="var(--sg-bg, #fff)"
2904
+ stroke-width="1"
2905
+ style={`opacity:${dimOf(arcsByCategory || spec.type === 'sunburst' || spec.type === 'chord' ? arc.label : arc.series)}`}
2906
+ role={onSelect || selMode || (canDrill && !!arc.nodePath) ? 'button' : 'img'}
2907
+ tabindex={roveTab('.sv-grid-chart-arc', ai)}
2908
+ aria-label={`${arc.nodePath ? arc.nodePath.join(' / ') : arc.label}${arc.series ? ` ${arc.series}` : ''}: ${fmt(arc.value)}`}
2909
+ onmousemove={(e) => hoverArc(e.clientX, e.clientY, arc)}
2910
+ onmouseleave={clearActive}
2911
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-arc', ai); const p = box(e.currentTarget); hoverArc(p.x, p.y, arc) }}
2912
+ onblur={clearActive}
2913
+ onclick={(e) => { if (arc.nodePath && drillInto(arc.nodePath)) return; select(arc.label, arc.series, arc.value, undefined, e) }}
2914
+ onkeydown={(e) => markKey(e, '.sv-grid-chart-arc', ai, () => { if (arc.nodePath && drillInto(arc.nodePath)) return; if (onSelect || selMode) select(arc.label, arc.series, arc.value, undefined, e) })}
2915
+ />
2916
+ {/each}
2917
+ <!-- Labels: chord groups sit outside the ring; sunburst nodes and data
2918
+ labels sit on the arcs wide enough to hold them. -->
2919
+ {#each geo.arcs as arc, ai (ai)}
2920
+ {#if spec.type === 'chord'}
2921
+ {#if arc.a1 - arc.a0 > 0.12 && spec.xAxis?.labels !== false}
2922
+ <text class="sv-grid-chart-axis" x={arc.lx} y={arc.ly + 3} text-anchor={arc.lx < arc.cx - 2 ? 'end' : arc.lx > arc.cx + 2 ? 'start' : 'middle'}>{truncate(arc.label, 14)}</text>
2923
+ {/if}
2924
+ {:else if (arc.a1 - arc.a0) * (arc.r0 + arc.r1) / 2 > 34 && arc.r1 - arc.r0 > 12 && (spec.type === 'sunburst' || labelCfg.show)}
2925
+ <text class="sv-grid-chart-datalabel on-bar" x={arc.lx} y={arc.ly} text-anchor="middle" dominant-baseline="middle" fill={arc.textColor} pointer-events="none">{truncate(labelCfg.show && spec.type !== 'sunburst' ? fmt(arc.value) : arc.label, 12)}</text>
2926
+ {/if}
2927
+ {/each}
2928
+ {/if}
2929
+
2930
+ {#if isBullet}
2931
+ {#each geo.bullets as b, bli (bli)}
2932
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
2933
+ <g class="sv-grid-chart-bullet" onmousemove={(e) => hoverBullet(e.clientX, e.clientY, b)} onmouseleave={clearActive}>
2934
+ {#each b.ranges as rg, rgi (rgi)}
2935
+ <rect x={rg.x} y={b.y} width={rg.w} height={b.h} fill={rg.color} />
2936
+ {/each}
2937
+ <rect class="sv-grid-chart-bar" x={b.x} y={b.y + b.h * 0.3} width={b.measureW} height={b.h * 0.4} fill={b.color} />
2938
+ {#if b.targetX != null}
2939
+ <line class="sv-grid-chart-bullet-target" x1={b.targetX} y1={b.y + b.h * 0.15} x2={b.targetX} y2={b.y + b.h * 0.85} stroke="var(--sg-fg, #0f172a)" stroke-width="2.5" />
2940
+ {/if}
2941
+ </g>
2942
+ {/each}
2943
+ {/if}
2944
+
1476
2945
  {#each geo.slices as slice, si (si)}
1477
- <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
2946
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1478
2947
  <path
1479
2948
  class="sv-grid-chart-slice"
2949
+ class:is-selected={isSel(slice.label, slice.label)}
2950
+ class:is-drillable={sliceDrillable(slice.label)}
1480
2951
  d={slice.path}
1481
2952
  fill={slice.color}
2953
+ style={`opacity:${selDim(slice.label, slice.label)}`}
1482
2954
  stroke="var(--sg-bg, #fff)"
1483
2955
  stroke-width="1"
1484
- role={onSelect ? 'button' : 'presentation'}
1485
- tabindex={onSelect ? 0 : undefined}
1486
- aria-label={`${slice.label}: ${fmt(slice.value)}, ${slice.percent.toFixed(1)}%`}
2956
+ role={onSelect || selMode || sliceDrillable(slice.label) ? 'button' : 'img'}
2957
+ tabindex={roveTab('.sv-grid-chart-slice', si)}
2958
+ aria-label={`${slice.label}: ${fmt(slice.value)}, ${slice.percent.toFixed(1)}%${sliceDrillable(slice.label) ? t.drillIn : ''}`}
1487
2959
  onmousemove={(e) => hoverSlice(e.clientX, e.clientY, slice.label, slice.value, slice.percent)}
1488
2960
  onmouseleave={clearActive}
1489
- onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); hoverSlice(b.left + b.width / 2, b.top, slice.label, slice.value, slice.percent) }}
2961
+ onfocus={(e) => { onMarkFocus('.sv-grid-chart-slice', si); const p = box(e.currentTarget); hoverSlice(p.x, p.y, slice.label, slice.value, slice.percent) }}
1490
2962
  onblur={clearActive}
1491
- onclick={() => select(slice.label, slice.label, slice.value, si)}
1492
- onkeydown={(e) => onSliceKey(e, slice.label, slice.value)}
2963
+ onclick={(e) => { if (sliceDrillable(slice.label) && drillInto([...drillPath, slice.label])) return; select(slice.label, slice.label, slice.value, si, e) }}
2964
+ onkeydown={(e) => { if (menuKey(e, si)) return; markKey(e, '.sv-grid-chart-slice', si, () => { if (sliceDrillable(slice.label) && drillInto([...drillPath, slice.label])) return; if (onSelect || selMode) select(slice.label, slice.label, slice.value, si, e) }) }}
1493
2965
  />
1494
- {#if dataLabels && slice.percent >= 6}
2966
+ {#if slice.callout}
2967
+ <polyline class="sv-grid-chart-callout" points={`${slice.callout.x1},${slice.callout.y1} ${slice.callout.x2},${slice.callout.y2} ${slice.callout.x3},${slice.callout.y2}`} fill="none" stroke={slice.color} stroke-width="1" />
2968
+ <text class="sv-grid-chart-callout-label" x={slice.callout.tx} y={slice.callout.ty + 3.5} text-anchor={slice.callout.anchor}>{labelCfg.formatter ? labelCfg.formatter(slice.value, { category: slice.label, series: slice.label }) : `${truncate(slice.label, Math.min(18, slice.callout.maxChars))} ${slice.percent.toFixed(0)}%`}</text>
2969
+ {:else if labelCfg.show && labelCfg.placement !== 'outside' && slice.percent >= 6}
1495
2970
  <text class="sv-grid-chart-datalabel on-bar" x={slice.cx} y={slice.cy} text-anchor="middle" dominant-baseline="middle">{slice.percent.toFixed(0)}%</text>
1496
2971
  {/if}
1497
2972
  {/each}
1498
2973
 
1499
2974
  {#if geo.donut}
1500
2975
  <text class="sv-grid-chart-donut-total" x={geo.donut.cx} y={geo.donut.cy - 4} text-anchor="middle">{fmt(geo.donut.total)}</text>
1501
- <text class="sv-grid-chart-donut-label" x={geo.donut.cx} y={geo.donut.cy + 11} text-anchor="middle">Total</text>
2976
+ <text class="sv-grid-chart-donut-label" x={geo.donut.cx} y={geo.donut.cy + 11} text-anchor="middle">{t.total}</text>
1502
2977
  {/if}
1503
2978
 
1504
2979
  {#if isCartesian && !isScatter}
@@ -1525,7 +3000,7 @@
1525
3000
  tabindex={interactive ? 0 : undefined}
1526
3001
  data-cat-index={denseIndex}
1527
3002
  data-dense="true"
1528
- aria-label={`${visibleSpec.categories.length} points, ${visibleSpec.categories[0] ?? ''} to ${visibleSpec.categories[visibleSpec.categories.length - 1] ?? ''}. Arrow keys step through values.`}
3003
+ aria-label={msg(t.densePoints, { count: windowCount, from: visibleSpec.categories[0] ?? '', to: visibleSpec.categories[visibleSpec.categories.length - 1] ?? '' })}
1529
3004
  onmousemove={(e) => {
1530
3005
  const i = catAtClientX(e.clientX, e.currentTarget)
1531
3006
  if (i != null) { denseIndex = i; hoverCat(e.clientX, e.clientY, i) }
@@ -1533,7 +3008,7 @@
1533
3008
  onmouseleave={clearActive}
1534
3009
  onfocus={(e) => focusCat(e.currentTarget, denseIndex)}
1535
3010
  onblur={clearActive}
1536
- onclick={() => select(visibleSpec.categories[denseIndex] ?? '', '', 0, denseIndex)}
3011
+ onclick={(e) => catClick(e, denseIndex)}
1537
3012
  onkeydown={onDenseKey}
1538
3013
  />
1539
3014
  {:else}
@@ -1558,11 +3033,11 @@
1558
3033
  tabindex={interactive ? (i === 0 ? 0 : -1) : undefined}
1559
3034
  data-cat-index={i}
1560
3035
  aria-label={`${cat}: ${catRows(i).map((r) => `${r.label} ${r.value}`).join(', ')}`}
1561
- onmousemove={(e) => hoverCat(e.clientX, e.clientY, i)}
3036
+ onmousemove={(e) => hoverCat(e.clientX, e.clientY, i, e.currentTarget)}
1562
3037
  onmouseleave={clearActive}
1563
3038
  onfocus={(e) => focusCat(e.currentTarget, i)}
1564
3039
  onblur={clearActive}
1565
- onclick={() => select(cat, '', 0, i)}
3040
+ onclick={(e) => catClick(e, i)}
1566
3041
  onkeydown={(e) => onCatKey(e, i)}
1567
3042
  />
1568
3043
  {/each}
@@ -1578,13 +3053,24 @@
1578
3053
  {#if brushEligible && brushGeo}
1579
3054
  {@const bx = brushGeo.plot.x + brushWindow.t0 * brushGeo.plot.w}
1580
3055
  {@const bw = (brushWindow.t1 - brushWindow.t0) * brushGeo.plot.w}
3056
+ <!-- The div exists to be measured: an inline svg reports clientHeight 0 in
3057
+ Chromium, and autosize needs the brush's real height to subtract. -->
3058
+ <div class="sv-grid-chart-brush-host" bind:clientHeight={brushH}>
3059
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
1581
3060
  <svg
1582
3061
  class="sv-grid-chart-brush"
1583
3062
  viewBox={`0 0 ${brushGeo.width} ${brushGeo.height}`}
1584
3063
  width="100%"
1585
- role="img"
1586
- aria-label="Range brush"
3064
+ role="slider"
3065
+ tabindex="0"
3066
+ aria-label={t.rangeBrush}
3067
+ aria-orientation="horizontal"
3068
+ aria-valuemin={0}
3069
+ aria-valuemax={Math.max(0, spec.categories.length - 1)}
3070
+ aria-valuenow={zoom?.i0 ?? 0}
3071
+ aria-valuetext={msg(t.brushRange, { from: spec.categories[zoom?.i0 ?? 0] ?? '', to: spec.categories[zoom?.i1 ?? spec.categories.length - 1] ?? '' })}
1587
3072
  onpointermove={onBrushMove}
3073
+ onkeydown={onBrushKey}
1588
3074
  >
1589
3075
  <!-- Mini chart: paint a faint version of the lines / area / bars
1590
3076
  so the user sees what region they're picking from. -->
@@ -1625,62 +3111,119 @@
1625
3111
  onpointerup={onBrushUp}
1626
3112
  onpointercancel={onBrushUp} />
1627
3113
  </svg>
3114
+ </div>
1628
3115
  {/if}
1629
3116
 
1630
3117
  {#if isEmpty}
1631
- <div class="sv-grid-chart-empty">No data</div>
3118
+ <div class="sv-grid-chart-empty">{t.noData}</div>
3119
+ {/if}
3120
+
3121
+ {#if menu && MenuComp}
3122
+ <MenuComp items={menuItems} x={menu.x} y={menu.y} onClose={() => (menu = null)} />
3123
+ {/if}
3124
+
3125
+ {#if textDraft}
3126
+ <input
3127
+ class="sv-grid-chart-textdraft"
3128
+ type="text"
3129
+ placeholder={t.noteText}
3130
+ aria-label={t.noteText}
3131
+ style={`left:${textDraft.left}px; top:${textDraft.top}px;`}
3132
+ bind:value={textDraftValue}
3133
+ use:focusOnMount
3134
+ onkeydown={(e) => {
3135
+ if (e.key === 'Enter') { e.preventDefault(); commitTextDraft() }
3136
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); textDraft = null; textDraftValue = '' }
3137
+ }}
3138
+ onblur={commitTextDraft}
3139
+ />
1632
3140
  {/if}
1633
3141
 
1634
3142
  {#if tip}
1635
- <div class="sv-grid-chart-tooltip" class:is-below={tip.below} style={`left:${tip.left}px; top:${tip.top}px;`}>
1636
- <span class="sv-grid-chart-tooltip-title">{tip.title}</span>
1637
- {#each tip.rows as r, ri (ri)}
1638
- <span class="sv-grid-chart-tooltip-row">
1639
- {#if r.color}<span class="sv-grid-chart-tooltip-dot" style={`background:${r.color}`}></span>{/if}
1640
- {#if r.label}<span class="sv-grid-chart-tooltip-row-label">{r.label}</span>{/if}
1641
- <span class="sv-grid-chart-tooltip-row-value">{r.value}</span>
1642
- </span>
1643
- {/each}
3143
+ <div
3144
+ class="sv-grid-chart-tooltip"
3145
+ role="tooltip"
3146
+ class:is-below={tip.below}
3147
+ class:is-custom={!!tooltip}
3148
+ class:is-fixed={tip.fixed}
3149
+ class:is-right={tooltipPosition === 'top-right' || tooltipPosition === 'bottom-right'}
3150
+ class:is-bottom={tooltipPosition === 'bottom-left' || tooltipPosition === 'bottom-right'}
3151
+ class:is-pinned={!!pinnedTip}
3152
+ style={`left:${tip.left}px; top:${tip.top}px;`}
3153
+ >
3154
+ {#if tooltip}
3155
+ {@render tooltip(tip.ctx)}
3156
+ {:else}
3157
+ <span class="sv-grid-chart-tooltip-title">{tip.title}</span>
3158
+ {#each tip.rows as r, ri (ri)}
3159
+ <span class="sv-grid-chart-tooltip-row">
3160
+ {#if r.color}<span class="sv-grid-chart-tooltip-dot" style={`background:${r.color}`}></span>{/if}
3161
+ {#if r.label}<span class="sv-grid-chart-tooltip-row-label">{r.label}</span>{/if}
3162
+ <span class="sv-grid-chart-tooltip-row-value">{r.value}</span>
3163
+ </span>
3164
+ {/each}
3165
+ {/if}
1644
3166
  </div>
1645
3167
  {/if}
1646
3168
 
1647
- {#if legend && legendItems.length}
1648
- <div class="sv-grid-chart-legend">
1649
- {#each shownLegend as item (item.label)}
3169
+ {#if legendPos && legendItems.length}
3170
+ <div
3171
+ class="sv-grid-chart-legend"
3172
+ class:is-vertical={legendSide}
3173
+ bind:clientWidth={legendW}
3174
+ bind:clientHeight={legendH}
3175
+ >
3176
+ {#each shownLegend as item, index (item.label)}
1650
3177
  <button
1651
3178
  type="button"
1652
3179
  class="sv-grid-chart-legend-item"
1653
3180
  class:is-off={item.off}
1654
3181
  class:is-isolated={isolated === item.label}
1655
- disabled={!interactive}
1656
- aria-pressed={!item.off}
3182
+ disabled={!interactive || !legendToggles}
3183
+ aria-pressed={legendToggles ? !item.off : undefined}
1657
3184
  onclick={() => toggle(item.label)}
1658
3185
  ondblclick={() => isolate(item.label)}
1659
3186
  onpointerenter={() => interactive && (dimmed = item.label)}
1660
3187
  onpointerleave={() => (dimmed = null)}
1661
- title={interactive ? `${item.off ? 'Show' : 'Hide'} ${item.label} · double-click to isolate` : item.label}
3188
+ title={interactive ? `${item.off ? t.showSeries : t.hideSeries} ${item.label} \u00b7 ${t.isolateHint}` : item.label}
1662
3189
  >
1663
- <span class="sv-grid-chart-swatch" style={`background:${item.off ? 'transparent' : item.color}; border-color:${item.color}`}></span>
1664
- {item.label}
3190
+ {#if legendItem}
3191
+ {@render legendItem({ label: item.label, color: item.color, off: item.off, index })}
3192
+ {:else}
3193
+ <span class="sv-grid-chart-swatch" style={`background:${item.off ? 'transparent' : item.color}; border-color:${item.color}`}></span>
3194
+ {item.label}
3195
+ {/if}
1665
3196
  </button>
1666
3197
  {/each}
1667
3198
  {#if legendOverflow}
1668
3199
  <button type="button" class="sv-grid-chart-legend-more" onclick={() => (legendExpanded = !legendExpanded)}>
1669
- {legendExpanded ? 'Show less' : `+${legendItems.length - LEGEND_MAX} more`}
3200
+ {legendExpanded ? t.showLess : msg(t.showMore, { n: legendItems.length - LEGEND_MAX })}
1670
3201
  </button>
1671
3202
  {/if}
1672
3203
  </div>
1673
3204
  {/if}
1674
3205
 
3206
+ {#if announce}
3207
+ <div class="sv-grid-chart-live sv-grid-chart-sr-only" aria-live="polite" aria-atomic="true">{liveText}</div>
3208
+ {/if}
3209
+
1675
3210
  <!-- Visually-hidden data table: the same data for assistive technology.
1676
3211
  Capped, and the caption says so. A table with 100k rows is not an
1677
3212
  accessible version of anything - nobody traverses that with a screen
1678
3213
  reader, and rendering it was a third of the DOM cost of a large chart.
1679
3214
  Saying "first 1000 of 100000" is more use than silently truncating, and
1680
- more honest than pretending the whole set is readable here. -->
1681
- <table id={`${uid}-table`} class="sv-grid-chart-sr-only">
3215
+ more honest than pretending the whole set is readable here.
3216
+
3217
+ The visually-hidden recipe sits on a wrapping div, not on the table:
3218
+ on a table element `height: 1px` and `width: 1px` are minimums (a table
3219
+ is never smaller than its cells) and `overflow: hidden` does not clip
3220
+ it, so a 1000-row table stood 17,000px tall, invisible but real, and
3221
+ every scroll container around a chart gained that much empty space
3222
+ to scroll through. -->
3223
+ <div class="sv-grid-chart-sr-only">
3224
+ <table id={`${uid}-table`}>
1682
3225
  <caption>
1683
- {spec.type} chart data{#if srCapped}, first {SR_ROW_CAP} of {srTable.rows.length} rows{/if}
3226
+ {msg(t.dataTable, { type: spec.type })}{#if srCapped}{msg(t.dataTableCapped, { shown: SR_ROW_CAP, total: srTable.rows.length })}{/if}{#if describe && summary}. {summary}{/if}
1684
3227
  </caption>
1685
3228
  <thead>
1686
3229
  <tr>{#each srTable.cols as c (c)}<th>{c}</th>{/each}</tr>
@@ -1691,6 +3234,7 @@
1691
3234
  {/each}
1692
3235
  </tbody>
1693
3236
  </table>
3237
+ </div>
1694
3238
  </div>
1695
3239
 
1696
3240
  <style>
@@ -1700,12 +3244,178 @@
1700
3244
  flex-direction: column;
1701
3245
  gap: 8px;
1702
3246
  width: 100%;
3247
+ background: var(--sg-chart-bg, transparent);
3248
+ }
3249
+ /* Legend on top: the legend row moves above the plot, the toolbar stays
3250
+ first. Left / right: a two-column grid, the legend a column of chips
3251
+ beside the plot and the brush, the toolbar spanning both. */
3252
+ .sv-grid-chart.is-legend-top .sv-grid-chart-toolbar { order: -2; }
3253
+ .sv-grid-chart.is-legend-top .sv-grid-chart-legend { order: -1; }
3254
+ .sv-grid-chart.is-legend-left,
3255
+ .sv-grid-chart.is-legend-right {
3256
+ display: grid;
3257
+ grid-template-rows: auto 1fr auto;
3258
+ align-items: start;
3259
+ }
3260
+ .sv-grid-chart.is-legend-left {
3261
+ grid-template-columns: auto minmax(0, 1fr);
3262
+ grid-template-areas: 'tb tb' 'lg svg' 'lg br';
3263
+ }
3264
+ .sv-grid-chart.is-legend-right {
3265
+ grid-template-columns: minmax(0, 1fr) auto;
3266
+ grid-template-areas: 'tb tb' 'svg lg' 'br lg';
3267
+ }
3268
+ .sv-grid-chart.is-legend-left .sv-grid-chart-toolbar,
3269
+ .sv-grid-chart.is-legend-right .sv-grid-chart-toolbar { grid-area: tb; }
3270
+ .sv-grid-chart.is-legend-left .sv-grid-chart-svg,
3271
+ .sv-grid-chart.is-legend-right .sv-grid-chart-svg { grid-area: svg; }
3272
+ .sv-grid-chart.is-legend-left .sv-grid-chart-brush-host,
3273
+ .sv-grid-chart.is-legend-right .sv-grid-chart-brush-host { grid-area: br; }
3274
+ .sv-grid-chart-brush-host {
3275
+ display: block;
3276
+ min-width: 0;
3277
+ }
3278
+ .sv-grid-chart.is-legend-left .sv-grid-chart-legend,
3279
+ .sv-grid-chart.is-legend-right .sv-grid-chart-legend { grid-area: lg; }
3280
+ .sv-grid-chart-legend.is-vertical {
3281
+ flex-direction: column;
3282
+ align-items: flex-start;
3283
+ gap: 4px;
3284
+ max-width: 180px;
3285
+ padding-top: 6px;
3286
+ }
3287
+ /* Autosize: the SVG is laid out at the pixel size the host actually has
3288
+ (see the dimension bindings in the script), so it needs no aspect-ratio
3289
+ scaling of its own. The host takes its height from a flex or grid parent
3290
+ that stretches it, never from a percentage: `height: 100%` inside a parent
3291
+ with other content over-measures by that content, the SVG grows into the
3292
+ gap, and the next measurement grows again, without end. */
3293
+ .sv-grid-chart.is-autosize {
3294
+ min-height: 0;
3295
+ }
3296
+ .sv-grid-chart.is-autosize .sv-grid-chart-svg {
3297
+ flex: none;
3298
+ min-height: 0;
1703
3299
  }
1704
3300
  .sv-grid-chart-svg {
1705
3301
  display: block;
1706
3302
  width: 100%;
1707
3303
  height: auto;
1708
3304
  }
3305
+ .sv-grid-chart-title {
3306
+ fill: var(--sg-fg, #0f172a);
3307
+ font-size: calc(14px * var(--sg-chart-font-scale, 1));
3308
+ font-weight: 600;
3309
+ font-family: inherit;
3310
+ }
3311
+ .sv-grid-chart-subtitle {
3312
+ fill: var(--sg-muted, #64748b);
3313
+ font-size: calc(11.5px * var(--sg-chart-font-scale, 1));
3314
+ font-family: inherit;
3315
+ }
3316
+ .sv-grid-chart-caption {
3317
+ fill: var(--sg-muted, #64748b);
3318
+ font-size: calc(10px * var(--sg-chart-font-scale, 1));
3319
+ font-family: inherit;
3320
+ }
3321
+ .sv-grid-chart-refband {
3322
+ pointer-events: none;
3323
+ }
3324
+ /* Enter effects. Fade is the per-mark keyframe every mark already has;
3325
+ wipe animates the plot clip's width from 0 (a keyframe with only `from`
3326
+ ends at the element's own value); grow scales bars up from their base. */
3327
+ .sv-grid-chart-wipe {
3328
+ animation: sv-grid-chart-wipe 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both;
3329
+ }
3330
+ @keyframes sv-grid-chart-wipe {
3331
+ from { width: 0; }
3332
+ }
3333
+ .sv-grid-chart-marks.is-enter-grow .sv-grid-chart-bar {
3334
+ transform-box: fill-box;
3335
+ transform-origin: 50% 100%;
3336
+ animation: sv-grid-chart-grow 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both;
3337
+ }
3338
+ @keyframes sv-grid-chart-grow {
3339
+ from { transform: scaleY(0.02); }
3340
+ }
3341
+ .sv-grid-chart-marks.is-enter-none *,
3342
+ .sv-grid-chart-marks.is-enter-none {
3343
+ animation: none !important;
3344
+ }
3345
+ .sv-grid-chart-svg.is-pannable {
3346
+ cursor: grab;
3347
+ }
3348
+ .sv-grid-chart-svg.is-pannable:active {
3349
+ cursor: grabbing;
3350
+ }
3351
+ .sv-grid-chart-presets {
3352
+ display: inline-flex;
3353
+ gap: 2px;
3354
+ margin-right: 6px;
3355
+ }
3356
+ .sv-grid-chart-crumbs {
3357
+ display: inline-flex;
3358
+ align-items: center;
3359
+ gap: 2px;
3360
+ margin-right: 8px;
3361
+ font-size: calc(11.5px * var(--sg-chart-font-scale, 1));
3362
+ }
3363
+ .sv-grid-chart-crumb.is-current {
3364
+ font-weight: 600;
3365
+ color: var(--sg-fg, #0f172a);
3366
+ padding: 0 4px;
3367
+ }
3368
+ .sv-grid-chart-crumb-sep {
3369
+ color: var(--sg-muted, #64748b);
3370
+ }
3371
+ .sv-grid-chart-arc.is-drillable,
3372
+ .sv-grid-chart-slice.is-drillable {
3373
+ cursor: zoom-in;
3374
+ }
3375
+ .sv-grid-chart-bar.is-selected,
3376
+ .sv-grid-chart-slice.is-selected,
3377
+ .sv-grid-chart-arc.is-selected {
3378
+ stroke: var(--sg-fg, #0f172a);
3379
+ stroke-width: 2;
3380
+ }
3381
+ .sv-grid-chart-dot.is-selected {
3382
+ stroke: var(--sg-fg, #0f172a);
3383
+ stroke-width: 2;
3384
+ r: 5;
3385
+ }
3386
+ .sv-grid-chart-scatter.is-selected {
3387
+ stroke: var(--sg-fg, #0f172a);
3388
+ stroke-width: 2;
3389
+ fill-opacity: 1;
3390
+ }
3391
+ .sv-grid-chart-preset {
3392
+ padding-inline: 6px;
3393
+ }
3394
+ .sv-grid-chart-arc-track {
3395
+ fill: var(--sg-border, #e2e8f0);
3396
+ opacity: 0.45;
3397
+ pointer-events: none;
3398
+ }
3399
+ .sv-grid-chart-arc {
3400
+ transition: opacity 0.1s ease;
3401
+ animation: sv-grid-chart-in 0.3s ease both;
3402
+ }
3403
+ .sv-grid-chart-ribbon {
3404
+ transition: opacity 0.1s ease;
3405
+ }
3406
+ .sv-grid-chart-stem line {
3407
+ stroke-linecap: round;
3408
+ }
3409
+ .sv-grid-chart-dot.is-stroked {
3410
+ fill: none;
3411
+ }
3412
+ .sv-grid-chart-tooltip.is-custom {
3413
+ padding: 0;
3414
+ background: transparent;
3415
+ color: inherit;
3416
+ border: 0;
3417
+ box-shadow: none;
3418
+ }
1709
3419
  .sv-grid-chart-gridline {
1710
3420
  stroke: var(--sg-border, #e2e8f0);
1711
3421
  stroke-width: 1;
@@ -1717,12 +3427,12 @@
1717
3427
  }
1718
3428
  .sv-grid-chart-axis {
1719
3429
  fill: var(--sg-muted, #64748b);
1720
- font-size: 10px;
3430
+ font-size: calc(10px * var(--sg-chart-font-scale, 1));
1721
3431
  font-family: inherit;
1722
3432
  }
1723
3433
  .sv-grid-chart-axis-title {
1724
3434
  fill: var(--sg-muted, #64748b);
1725
- font-size: 11px;
3435
+ font-size: calc(11px * var(--sg-chart-font-scale, 1));
1726
3436
  font-weight: 600;
1727
3437
  font-family: inherit;
1728
3438
  }
@@ -1737,13 +3447,33 @@
1737
3447
  opacity: 0.65;
1738
3448
  pointer-events: none;
1739
3449
  }
3450
+ .sv-grid-chart-callout {
3451
+ opacity: 0.8;
3452
+ pointer-events: none;
3453
+ }
3454
+ .sv-grid-chart-callout-label {
3455
+ font-size: calc(10.5px * var(--sg-chart-font-scale, 1));
3456
+ font-family: inherit;
3457
+ fill: var(--sg-fg, #0f172a);
3458
+ pointer-events: none;
3459
+ }
3460
+ .sv-grid-chart-crosshair-label rect {
3461
+ fill: var(--sg-fg, #0f172a);
3462
+ opacity: 0.85;
3463
+ }
3464
+ .sv-grid-chart-crosshair-label text {
3465
+ fill: var(--sg-bg, #fff);
3466
+ font-size: calc(9.5px * var(--sg-chart-font-scale, 1));
3467
+ font-weight: 600;
3468
+ font-family: inherit;
3469
+ }
1740
3470
  .sv-grid-chart-refline {
1741
3471
  stroke-width: 1.5;
1742
3472
  opacity: 0.9;
1743
3473
  pointer-events: none;
1744
3474
  }
1745
3475
  .sv-grid-chart-reflabel {
1746
- font-size: 9.5px;
3476
+ font-size: calc(9.5px * var(--sg-chart-font-scale, 1));
1747
3477
  font-weight: 700;
1748
3478
  font-family: inherit;
1749
3479
  pointer-events: none;
@@ -1755,12 +3485,93 @@
1755
3485
  .sv-grid-chart-annotation-marker {
1756
3486
  pointer-events: none;
1757
3487
  }
3488
+ .sv-grid-chart-annotation-marker.has-text {
3489
+ pointer-events: auto;
3490
+ cursor: help;
3491
+ }
3492
+ .sv-grid-chart-annotation-marker:focus-visible {
3493
+ outline: 2px solid var(--sg-accent, #2563eb);
3494
+ outline-offset: 2px;
3495
+ }
1758
3496
  .sv-grid-chart-annotation-label {
1759
- font-size: 10px;
3497
+ font-size: calc(10px * var(--sg-chart-font-scale, 1));
3498
+ font-weight: 700;
3499
+ font-family: inherit;
3500
+ pointer-events: none;
3501
+ }
3502
+ .sv-grid-chart-serieslabel {
3503
+ font-size: calc(10.5px * var(--sg-chart-font-scale, 1));
3504
+ font-weight: 600;
3505
+ font-family: inherit;
3506
+ pointer-events: none;
3507
+ }
3508
+ .sv-grid-chart-annotation-flagtext {
3509
+ font-size: calc(9px * var(--sg-chart-font-scale, 1));
1760
3510
  font-weight: 700;
1761
3511
  font-family: inherit;
1762
3512
  pointer-events: none;
1763
3513
  }
3514
+ .sv-grid-chart-refpill text {
3515
+ font-size: calc(9.5px * var(--sg-chart-font-scale, 1));
3516
+ font-weight: 700;
3517
+ font-family: inherit;
3518
+ pointer-events: none;
3519
+ }
3520
+ /* Drawings. The hit line is a fat invisible stroke so a 1.5px trend line
3521
+ can be clicked; handles only exist on the selected drawing. */
3522
+ .sv-grid-chart-drawing-hit {
3523
+ stroke: transparent;
3524
+ stroke-width: 10;
3525
+ fill: transparent;
3526
+ pointer-events: stroke;
3527
+ }
3528
+ .sv-grid-chart-drawing.is-fib .sv-grid-chart-drawing-hit,
3529
+ .sv-grid-chart-drawing.is-rect rect,
3530
+ .sv-grid-chart-drawing.is-text text {
3531
+ pointer-events: all;
3532
+ }
3533
+ .sv-grid-chart-drawing[role='button'] { cursor: pointer; }
3534
+ .sv-grid-chart-drawing:focus-visible { outline: none; }
3535
+ .sv-grid-chart-drawing.is-selected line,
3536
+ .sv-grid-chart-drawing.is-selected rect,
3537
+ .sv-grid-chart-drawing:focus-visible line,
3538
+ .sv-grid-chart-drawing:focus-visible rect {
3539
+ filter: drop-shadow(0 0 2px var(--sg-accent, #2563eb));
3540
+ }
3541
+ .sv-grid-chart-drawing-label {
3542
+ font-size: calc(9px * var(--sg-chart-font-scale, 1));
3543
+ font-weight: 600;
3544
+ font-family: inherit;
3545
+ pointer-events: none;
3546
+ }
3547
+ .sv-grid-chart-drawing-text {
3548
+ font-size: calc(11px * var(--sg-chart-font-scale, 1));
3549
+ font-weight: 600;
3550
+ font-family: inherit;
3551
+ }
3552
+ .sv-grid-chart-drawing-handle { cursor: move; }
3553
+ .sv-grid-chart-textdraft {
3554
+ position: absolute;
3555
+ z-index: 11;
3556
+ transform: translate(0, -50%);
3557
+ width: 160px;
3558
+ font: inherit;
3559
+ font-size: calc(12px * var(--sg-chart-font-scale, 1));
3560
+ padding: 3px 6px;
3561
+ border: 1px solid var(--sg-accent, #2563eb);
3562
+ border-radius: 4px;
3563
+ background: var(--sg-bg, #fff);
3564
+ color: var(--sg-fg, #0f172a);
3565
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3566
+ }
3567
+ .sv-grid-chart-svg.is-drawing { cursor: crosshair; }
3568
+ .sv-grid-chart-drawtools {
3569
+ display: inline-flex;
3570
+ gap: 2px;
3571
+ padding-right: 6px;
3572
+ margin-right: 2px;
3573
+ border-right: 1px solid var(--sg-border, #e2e8f0);
3574
+ }
1764
3575
  /* Heatmap cells. Stroke uses the border token (not bg) so the cell
1765
3576
  separators stay visible in BOTH light and dark themes - on dark mode
1766
3577
  a bg-colored stroke disappeared into the navy cells. */
@@ -1773,7 +3584,7 @@
1773
3584
  .sv-grid-chart-svg.is-clickable .sv-grid-chart-heatcell { cursor: pointer; }
1774
3585
  .sv-grid-chart-heatcell:hover { opacity: 0.85; }
1775
3586
  .sv-grid-chart-heatlabel {
1776
- font-size: 10px;
3587
+ font-size: calc(10px * var(--sg-chart-font-scale, 1));
1777
3588
  font-weight: 600;
1778
3589
  font-family: inherit;
1779
3590
  pointer-events: none;
@@ -1795,13 +3606,13 @@
1795
3606
  .sv-grid-chart-svg.is-clickable .sv-grid-chart-funnel-seg { cursor: pointer; }
1796
3607
  .sv-grid-chart-funnel-seg:hover { opacity: 0.88; }
1797
3608
  .sv-grid-chart-funnel-label {
1798
- font-size: 11px;
3609
+ font-size: calc(11px * var(--sg-chart-font-scale, 1));
1799
3610
  font-weight: 700;
1800
3611
  font-family: inherit;
1801
3612
  pointer-events: none;
1802
3613
  }
1803
3614
  .sv-grid-chart-funnel-value {
1804
- font-size: 10px;
3615
+ font-size: calc(10px * var(--sg-chart-font-scale, 1));
1805
3616
  font-family: inherit;
1806
3617
  opacity: 0.85;
1807
3618
  pointer-events: none;
@@ -1844,7 +3655,7 @@
1844
3655
  /* Gauge centre readout. */
1845
3656
  .sv-grid-chart-gauge-value {
1846
3657
  fill: var(--sg-fg, #0f172a);
1847
- font-size: 22px;
3658
+ font-size: calc(22px * var(--sg-chart-font-scale, 1));
1848
3659
  font-weight: 800;
1849
3660
  font-family: inherit;
1850
3661
  pointer-events: none;
@@ -1888,13 +3699,13 @@
1888
3699
  .sv-grid-chart-svg.is-clickable .sv-grid-chart-treemap-cell { cursor: pointer; }
1889
3700
  .sv-grid-chart-treemap-cell:hover { opacity: 0.88; }
1890
3701
  .sv-grid-chart-treemap-label {
1891
- font-size: 11px;
3702
+ font-size: calc(11px * var(--sg-chart-font-scale, 1));
1892
3703
  font-weight: 600;
1893
3704
  font-family: inherit;
1894
3705
  pointer-events: none;
1895
3706
  }
1896
3707
  .sv-grid-chart-treemap-value {
1897
- font-size: 10px;
3708
+ font-size: calc(10px * var(--sg-chart-font-scale, 1));
1898
3709
  font-family: inherit;
1899
3710
  pointer-events: none;
1900
3711
  }
@@ -1912,7 +3723,7 @@
1912
3723
  .sv-grid-chart-sankey-link:hover { stroke-opacity: 0.6; }
1913
3724
  .sv-grid-chart-sankey-label {
1914
3725
  fill: var(--sg-fg, #0f172a);
1915
- font-size: 11px;
3726
+ font-size: calc(11px * var(--sg-chart-font-scale, 1));
1916
3727
  font-weight: 600;
1917
3728
  font-family: inherit;
1918
3729
  pointer-events: none;
@@ -1958,29 +3769,44 @@
1958
3769
  fill: transparent;
1959
3770
  }
1960
3771
  .sv-grid-chart-cat-hit:focus-visible,
1961
- .sv-grid-chart-slice:focus-visible {
3772
+ .sv-grid-chart-slice:focus-visible,
3773
+ .sv-grid-chart-arc:focus-visible,
3774
+ .sv-grid-chart-heatcell:focus-visible,
3775
+ .sv-grid-chart-funnel-seg:focus-visible,
3776
+ .sv-grid-chart-treemap-cell:focus-visible,
3777
+ .sv-grid-chart-sankey-node:focus-visible,
3778
+ .sv-grid-chart-sankey-link:focus-visible,
3779
+ .sv-grid-chart-calendar-cell:focus-visible,
3780
+ .sv-grid-chart-radar-dot:focus-visible,
3781
+ .sv-grid-chart-scatter:focus-visible,
3782
+ .sv-grid-chart-brush:focus-visible {
1962
3783
  outline: 2px solid var(--sg-accent, #2563eb);
1963
3784
  outline-offset: 1px;
1964
3785
  }
1965
3786
  .sv-grid-chart-datalabel {
1966
3787
  fill: var(--sg-fg, #0f172a);
1967
- font-size: 9.5px;
3788
+ font-size: calc(9.5px * var(--sg-chart-font-scale, 1));
1968
3789
  font-weight: 600;
1969
3790
  font-variant-numeric: tabular-nums;
1970
3791
  pointer-events: none;
1971
3792
  }
3793
+ .sv-grid-chart-datalabel-leader {
3794
+ stroke: var(--sg-muted, #64748b);
3795
+ stroke-width: 1;
3796
+ pointer-events: none;
3797
+ }
1972
3798
  .sv-grid-chart-datalabel.on-bar {
1973
3799
  fill: #fff;
1974
3800
  }
1975
3801
  .sv-grid-chart-donut-total {
1976
3802
  fill: var(--sg-fg, #0f172a);
1977
- font-size: 16px;
3803
+ font-size: calc(16px * var(--sg-chart-font-scale, 1));
1978
3804
  font-weight: 800;
1979
3805
  font-variant-numeric: tabular-nums;
1980
3806
  }
1981
3807
  .sv-grid-chart-donut-label {
1982
3808
  fill: var(--sg-muted, #64748b);
1983
- font-size: 10px;
3809
+ font-size: calc(10px * var(--sg-chart-font-scale, 1));
1984
3810
  }
1985
3811
  .sv-grid-chart-bar {
1986
3812
  transition: x 0.3s ease, y 0.3s ease, width 0.3s ease, height 0.3s ease;
@@ -2027,7 +3853,7 @@
2027
3853
  align-items: center;
2028
3854
  justify-content: center;
2029
3855
  color: var(--sg-muted, #94a3b8);
2030
- font-size: 13px;
3856
+ font-size: calc(13px * var(--sg-chart-font-scale, 1));
2031
3857
  pointer-events: none;
2032
3858
  }
2033
3859
 
@@ -2045,7 +3871,7 @@
2045
3871
  border: 1px solid var(--sg-border, #cbd5e1);
2046
3872
  background: var(--sg-bg, #ffffff);
2047
3873
  color: var(--sg-fg, #1e293b);
2048
- font-size: 11px;
3874
+ font-size: calc(11px * var(--sg-chart-font-scale, 1));
2049
3875
  font-weight: 500;
2050
3876
  padding: 3px 8px;
2051
3877
  border-radius: 4px;
@@ -2089,7 +3915,7 @@
2089
3915
  border-radius: 6px;
2090
3916
  background: var(--sg-fg, #0f172a);
2091
3917
  color: var(--sg-bg, #fff);
2092
- font-size: 11.5px;
3918
+ font-size: calc(11.5px * var(--sg-chart-font-scale, 1));
2093
3919
  line-height: 1.4;
2094
3920
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
2095
3921
  display: flex;
@@ -2099,9 +3925,28 @@
2099
3925
  .sv-grid-chart-tooltip.is-below {
2100
3926
  transform: translate(-50%, 16px);
2101
3927
  }
3928
+ /* A corner tooltip anchors its own corner to the plot's, no flipping. */
3929
+ .sv-grid-chart-tooltip.is-fixed {
3930
+ transform: none;
3931
+ }
3932
+ .sv-grid-chart-tooltip.is-fixed.is-right {
3933
+ transform: translate(-100%, 0);
3934
+ }
3935
+ .sv-grid-chart-tooltip.is-fixed.is-bottom {
3936
+ transform: translate(0, -100%);
3937
+ }
3938
+ .sv-grid-chart-tooltip.is-fixed.is-right.is-bottom {
3939
+ transform: translate(-100%, -100%);
3940
+ }
3941
+ /* Pinned: selectable text, and a ring so a reader sees it is held. */
3942
+ .sv-grid-chart-tooltip.is-pinned {
3943
+ pointer-events: auto;
3944
+ user-select: text;
3945
+ box-shadow: 0 0 0 2px var(--sg-accent, #2563eb), 0 6px 18px rgba(0, 0, 0, 0.28);
3946
+ }
2102
3947
  .sv-grid-chart-tooltip-title {
2103
3948
  opacity: 0.75;
2104
- font-size: 10.5px;
3949
+ font-size: calc(10.5px * var(--sg-chart-font-scale, 1));
2105
3950
  margin-bottom: 1px;
2106
3951
  }
2107
3952
  .sv-grid-chart-tooltip-row {
@@ -2129,7 +3974,7 @@
2129
3974
  display: flex;
2130
3975
  flex-wrap: wrap;
2131
3976
  gap: 6px 12px;
2132
- font-size: 12px;
3977
+ font-size: calc(12px * var(--sg-chart-font-scale, 1));
2133
3978
  color: var(--sg-fg, #0f172a);
2134
3979
  }
2135
3980
  .sv-grid-chart-legend-item {
@@ -2160,7 +4005,7 @@
2160
4005
  background: transparent;
2161
4006
  color: var(--sg-muted, #64748b);
2162
4007
  font: inherit;
2163
- font-size: 11px;
4008
+ font-size: calc(11px * var(--sg-chart-font-scale, 1));
2164
4009
  padding: 1px 7px;
2165
4010
  border-radius: 999px;
2166
4011
  cursor: pointer;
@@ -2243,4 +4088,87 @@
2243
4088
  animation: none;
2244
4089
  }
2245
4090
  }
4091
+
4092
+ /* ---- Forced colors (Windows High Contrast) ---------------------------
4093
+ The UA forces fill and stroke along with color and background, which
4094
+ would paint every series the same. Colour carries the data, so the
4095
+ marks keep theirs (forced-color-adjust: none, the one case the spec
4096
+ allows it for). Everything that is chrome rather than data takes the
4097
+ system palette instead: text, gridlines, the crosshair and its pills,
4098
+ focus rings, the legend's swatches and the tooltip. */
4099
+ @media (forced-colors: active) {
4100
+ .sv-grid-chart-svg,
4101
+ .sv-grid-chart-brush,
4102
+ .sv-grid-chart-swatch {
4103
+ forced-color-adjust: none;
4104
+ }
4105
+ .sv-grid-chart-axis,
4106
+ .sv-grid-chart-axis-title,
4107
+ .sv-grid-chart-title,
4108
+ .sv-grid-chart-subtitle,
4109
+ .sv-grid-chart-caption,
4110
+ .sv-grid-chart-datalabel,
4111
+ .sv-grid-chart-callout-label,
4112
+ .sv-grid-chart-donut-total,
4113
+ .sv-grid-chart-donut-label,
4114
+ .sv-grid-chart-sankey-label,
4115
+ .sv-grid-chart-annotation-label {
4116
+ fill: CanvasText;
4117
+ }
4118
+ .sv-grid-chart-gridline {
4119
+ stroke: GrayText;
4120
+ opacity: 1;
4121
+ }
4122
+ .sv-grid-chart-crosshair {
4123
+ stroke: Highlight;
4124
+ opacity: 1;
4125
+ }
4126
+ .sv-grid-chart-crosshair-label rect,
4127
+ .sv-grid-chart-refpill rect {
4128
+ fill: Highlight;
4129
+ opacity: 1;
4130
+ }
4131
+ .sv-grid-chart-crosshair-label text,
4132
+ .sv-grid-chart-refpill text {
4133
+ fill: HighlightText;
4134
+ }
4135
+ .sv-grid-chart-cat-hit:focus-visible,
4136
+ .sv-grid-chart-slice:focus-visible,
4137
+ .sv-grid-chart-arc:focus-visible,
4138
+ .sv-grid-chart-heatcell:focus-visible,
4139
+ .sv-grid-chart-funnel-seg:focus-visible,
4140
+ .sv-grid-chart-treemap-cell:focus-visible,
4141
+ .sv-grid-chart-sankey-node:focus-visible,
4142
+ .sv-grid-chart-sankey-link:focus-visible,
4143
+ .sv-grid-chart-calendar-cell:focus-visible,
4144
+ .sv-grid-chart-radar-dot:focus-visible,
4145
+ .sv-grid-chart-scatter:focus-visible,
4146
+ .sv-grid-chart-brush:focus-visible,
4147
+ .sv-grid-chart-annotation-marker:focus-visible {
4148
+ outline: 3px solid Highlight;
4149
+ }
4150
+ .sv-grid-chart-slice.is-selected,
4151
+ .sv-grid-chart-arc.is-selected {
4152
+ stroke: Highlight;
4153
+ stroke-width: 3;
4154
+ }
4155
+ .sv-grid-chart-brush-mask {
4156
+ fill: Canvas;
4157
+ fill-opacity: 0.7;
4158
+ }
4159
+ .sv-grid-chart-brush-window {
4160
+ stroke: Highlight;
4161
+ fill: Highlight;
4162
+ fill-opacity: 0.2;
4163
+ }
4164
+ .sv-grid-chart-brush-handle {
4165
+ fill: Highlight;
4166
+ }
4167
+ .sv-grid-chart-tooltip {
4168
+ border: 1px solid CanvasText;
4169
+ }
4170
+ .sv-grid-chart-legend-item.is-off .sv-grid-chart-swatch {
4171
+ forced-color-adjust: auto;
4172
+ }
4173
+ }
2246
4174
  </style>