@svgrid/grid 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +570 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +429 -0
  8. package/dist/SvGrid.controller.svelte.js +1732 -0
  9. package/dist/SvGrid.css +1709 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.svelte +344 -7043
  13. package/dist/SvGrid.svelte.d.ts +4 -357
  14. package/dist/SvGrid.types.d.ts +436 -0
  15. package/dist/SvGrid.types.js +1 -0
  16. package/dist/SvGridChart.svelte +1060 -23
  17. package/dist/SvGridChart.svelte.d.ts +17 -0
  18. package/dist/build-api.d.ts +5 -0
  19. package/dist/build-api.js +527 -0
  20. package/dist/cell-render.d.ts +15 -0
  21. package/dist/cell-render.js +246 -0
  22. package/dist/cell-values.d.ts +28 -0
  23. package/dist/cell-values.js +89 -0
  24. package/dist/chart.d.ts +370 -3
  25. package/dist/chart.js +1135 -42
  26. package/dist/clipboard.d.ts +15 -0
  27. package/dist/clipboard.js +356 -0
  28. package/dist/columns.d.ts +30 -0
  29. package/dist/columns.js +277 -0
  30. package/dist/core.d.ts +1 -1
  31. package/dist/css.d.ts +3 -0
  32. package/dist/editing.d.ts +24 -0
  33. package/dist/editing.js +343 -0
  34. package/dist/editors/cell-editors.d.ts +1 -1
  35. package/dist/facet-buckets.d.ts +13 -0
  36. package/dist/facet-buckets.js +54 -0
  37. package/dist/features.d.ts +5 -0
  38. package/dist/features.js +30 -0
  39. package/dist/filter-operators.d.ts +16 -0
  40. package/dist/filter-operators.js +69 -0
  41. package/dist/hyperformula-adapter.d.ts +82 -0
  42. package/dist/hyperformula-adapter.js +73 -0
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.js +5 -2
  45. package/dist/keyboard-handlers.d.ts +7 -0
  46. package/dist/keyboard-handlers.js +197 -0
  47. package/dist/menus.d.ts +40 -0
  48. package/dist/menus.js +389 -0
  49. package/dist/named-views.d.ts +27 -0
  50. package/dist/named-views.js +39 -0
  51. package/dist/row-resize.d.ts +43 -0
  52. package/dist/row-resize.js +158 -0
  53. package/dist/scroll-sync.d.ts +9 -0
  54. package/dist/scroll-sync.js +86 -0
  55. package/dist/selection.d.ts +26 -0
  56. package/dist/selection.js +387 -0
  57. package/dist/spreadsheet.d.ts +80 -0
  58. package/dist/spreadsheet.js +194 -0
  59. package/dist/summaries.d.ts +12 -0
  60. package/dist/summaries.js +65 -0
  61. package/dist/svgrid-wrapper.types.d.ts +12 -1
  62. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  63. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  64. package/dist/svgrid.context-menu.test.d.ts +1 -0
  65. package/dist/svgrid.context-menu.test.js +102 -0
  66. package/dist/svgrid.new-features.wrapper.test.js +30 -4
  67. package/dist/svgrid.wrapper.test.js +27 -1
  68. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  69. package/dist/virtualization/scroll-scaling.d.ts +28 -0
  70. package/dist/virtualization/scroll-scaling.js +64 -0
  71. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  72. package/dist/virtualization/scroll-scaling.test.js +86 -0
  73. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  74. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  75. package/dist/virtualization/virtualizer.d.ts +7 -0
  76. package/dist/virtualization/virtualizer.js +30 -0
  77. package/package.json +1 -1
  78. package/src/GridFooter.svelte +164 -0
  79. package/src/GridMenus.svelte +570 -0
  80. package/src/SvGrid.controller.svelte.ts +2195 -0
  81. package/src/SvGrid.css +1747 -0
  82. package/src/SvGrid.helpers.test.ts +415 -0
  83. package/src/SvGrid.helpers.ts +185 -0
  84. package/src/SvGrid.svelte +348 -7043
  85. package/src/SvGrid.types.ts +456 -0
  86. package/src/SvGridChart.svelte +1060 -23
  87. package/src/build-api.coverage.test.ts +532 -0
  88. package/src/build-api.ts +663 -0
  89. package/src/cell-render.test.ts +451 -0
  90. package/src/cell-render.ts +426 -0
  91. package/src/cell-values.ts +114 -0
  92. package/src/chart-export.test.ts +370 -0
  93. package/src/chart.coverage.test.ts +814 -0
  94. package/src/chart.ts +1352 -47
  95. package/src/clipboard.test.ts +731 -0
  96. package/src/clipboard.ts +524 -0
  97. package/src/collaboration.coverage.test.ts +220 -0
  98. package/src/columns.test.ts +702 -0
  99. package/src/columns.ts +419 -0
  100. package/src/core.ts +8 -0
  101. package/src/css.d.ts +3 -0
  102. package/src/editing.test.ts +837 -0
  103. package/src/editing.ts +513 -0
  104. package/src/editors/cell-editors.coverage.test.ts +156 -0
  105. package/src/editors/cell-editors.ts +1 -0
  106. package/src/facet-buckets.test.ts +353 -0
  107. package/src/facet-buckets.ts +67 -0
  108. package/src/features.ts +128 -0
  109. package/src/filter-operators.test.ts +174 -0
  110. package/src/filter-operators.ts +87 -0
  111. package/src/hyperformula-adapter.test.ts +256 -0
  112. package/src/hyperformula-adapter.ts +124 -0
  113. package/src/index.ts +37 -0
  114. package/src/keyboard-handlers.coverage.test.ts +560 -0
  115. package/src/keyboard-handlers.ts +353 -0
  116. package/src/keyboard.ts +97 -97
  117. package/src/menus.test.ts +620 -0
  118. package/src/menus.ts +554 -0
  119. package/src/named-views.coverage.test.ts +210 -0
  120. package/src/named-views.ts +48 -0
  121. package/src/row-resize.test.ts +369 -0
  122. package/src/row-resize.ts +171 -0
  123. package/src/scroll-sync.test.ts +330 -0
  124. package/src/scroll-sync.ts +216 -0
  125. package/src/selection.test.ts +722 -0
  126. package/src/selection.ts +545 -0
  127. package/src/server-data-source.coverage.test.ts +180 -0
  128. package/src/spreadsheet.test.ts +445 -0
  129. package/src/spreadsheet.ts +246 -0
  130. package/src/summaries.ts +204 -0
  131. package/src/sv-grid-scrollbar.ts +13 -1
  132. package/src/svgrid-wrapper.types.ts +12 -1
  133. package/src/svgrid.behavior.test.ts +22 -0
  134. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  135. package/src/svgrid.context-menu.test.ts +126 -0
  136. package/src/svgrid.interaction.test.ts +30 -0
  137. package/src/svgrid.new-features.wrapper.test.ts +65 -4
  138. package/src/svgrid.wrapper.test.ts +27 -1
  139. package/src/test-setup.ts +9 -6
  140. package/src/virtualization/scroll-scaling.test.ts +148 -0
  141. package/src/virtualization/scroll-scaling.ts +121 -0
  142. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  143. package/src/virtualization/virtualizer.ts +26 -0
@@ -21,6 +21,23 @@
21
21
  formatValue?: (value: number) => string
22
22
  /** Fired when a category / slice is clicked (drill into the grid). */
23
23
  onSelect?: (selection: ChartSelection) => void
24
+ /** Like `onSelect`, but only fires when the spec carries `rowIds` and
25
+ * includes them in the payload. Use this to filter / highlight the
26
+ * source grid when the user clicks a chart element. */
27
+ onDrill?: (selection: ChartSelection) => void
28
+ /** Allow drag-to-zoom on the plot area. Double-click resets.
29
+ * Only meaningful for cartesian charts (not pie). Default false. */
30
+ zoomable?: boolean
31
+ /** Show a compact brush / mini-map under the main chart. A draggable
32
+ * window selects what the main chart shows. Pairs naturally with
33
+ * `zoomable`. Default false. */
34
+ brush?: boolean
35
+ /** Height of the brush strip in pixels. Default 88. */
36
+ brushHeight?: number
37
+ /** Show a small toolbar above the chart: reset-zoom (when zoomed) and
38
+ * export (PNG / SVG / copy). Default true when zoomable or onDrill is
39
+ * set, otherwise false. Set to `false` to hide explicitly. */
40
+ toolbar?: boolean
24
41
  }
25
42
  let {
26
43
  spec,
@@ -29,7 +46,13 @@
29
46
  dataLabels = false,
30
47
  formatValue,
31
48
  onSelect,
49
+ onDrill,
50
+ zoomable = false,
51
+ brush = false,
52
+ brushHeight = 88,
53
+ toolbar,
32
54
  }: Props = $props()
55
+ const showToolbar = $derived(toolbar ?? (zoomable || !!onDrill))
33
56
 
34
57
  const fmt = (v: number) =>
35
58
  formatValue
@@ -49,6 +72,33 @@
49
72
  const coloredSeries = $derived(
50
73
  spec.series.map((s, i) => ({ ...s, color: s.color ?? palette[i % palette.length]! })),
51
74
  )
75
+ /** Resolve effective pattern per series: explicit > cycle (when
76
+ * patternFallback is on) > 'solid'. Cycle skips 'solid' so every series
77
+ * ends up with a distinct texture. */
78
+ const seriesPatterns = $derived(
79
+ spec.series.map((s, i): string => {
80
+ if (s.pattern) return s.pattern
81
+ if (spec.patternFallback) return ['stripe', 'crosshatch', 'dots', 'diagonal'][i % 4]!
82
+ return 'solid'
83
+ }),
84
+ )
85
+ /** Map from series label to fill string used in SVG (either the plain
86
+ * color or `url(#pattern-id)`). */
87
+ const seriesFill = $derived.by<Record<string, string>>(() => {
88
+ const out: Record<string, string> = {}
89
+ coloredSeries.forEach((s, i) => {
90
+ const p = seriesPatterns[i]
91
+ out[s.label] = p === 'solid' || !p ? s.color : `url(#${uid}-pat-${i})`
92
+ })
93
+ return out
94
+ })
95
+ /** Pattern <defs> to emit. Each entry pairs a unique id with the series
96
+ * color so the pattern carries the right hue baked in. */
97
+ const patternDefs = $derived(
98
+ coloredSeries
99
+ .map((s, i) => ({ id: `${uid}-pat-${i}`, color: s.color, kind: seriesPatterns[i] ?? 'solid' }))
100
+ .filter((d) => d.kind !== 'solid'),
101
+ )
52
102
 
53
103
  // Effective hidden set = manual toggles, unless a series is isolated (then
54
104
  // everything else is hidden).
@@ -58,6 +108,158 @@
58
108
  return new Set(labels.filter((l) => l !== isolated))
59
109
  })
60
110
 
111
+ // ---- Zoom state ------------------------------------------------------
112
+ // `zoom` holds inclusive integer category indices [i0, i1] of the visible
113
+ // window. null = no zoom (show everything). Pie charts don't support
114
+ // zoom; their pseudo-categories are slices, not an axis.
115
+ let zoom = $state<{ i0: number; i1: number } | null>(null)
116
+ const isZoomed = $derived(zoom !== null)
117
+ function resetZoom() { zoom = null }
118
+
119
+ // Drag-to-zoom: track pointer in SVG-local coordinates so the overlay
120
+ // rect aligns with what the user is actually selecting on screen.
121
+ let svgEl: SVGSVGElement | null = $state(null)
122
+ let dragStart = $state<{ x: number; y: number } | null>(null)
123
+ let dragEnd = $state<{ x: number; y: number } | null>(null)
124
+ const dragRect = $derived.by(() => {
125
+ if (!dragStart || !dragEnd) return null
126
+ const x = Math.min(dragStart.x, dragEnd.x)
127
+ const y = Math.min(dragStart.y, dragEnd.y)
128
+ const w = Math.abs(dragEnd.x - dragStart.x)
129
+ const h = Math.abs(dragEnd.y - dragStart.y)
130
+ return w > 4 && h > 4 ? { x, y, w, h } : null
131
+ })
132
+
133
+ function svgPoint(e: PointerEvent): { x: number; y: number } | null {
134
+ if (!svgEl) return null
135
+ const r = svgEl.getBoundingClientRect()
136
+ return {
137
+ x: ((e.clientX - r.left) / r.width) * geo.width,
138
+ y: ((e.clientY - r.top) / r.height) * geo.height,
139
+ }
140
+ }
141
+ /** Clamp a point's main-axis coordinate to an integer category index. */
142
+ function pointToCatIndex(p: { x: number; y: number }): number {
143
+ const coord = isHorizontal ? (p.y - geo.plot.y) : (p.x - geo.plot.x)
144
+ const idx = Math.floor(coord / slot)
145
+ return Math.max(0, Math.min(visibleSpec.categories.length - 1, idx))
146
+ }
147
+ function onZoomDown(e: PointerEvent) {
148
+ if (!zoomable || !isCartesian) return
149
+ const p = svgPoint(e)
150
+ if (!p) return
151
+ // Only start a drag inside the plot rect.
152
+ if (p.x < geo.plot.x || p.x > geo.plot.x + geo.plot.w ||
153
+ p.y < geo.plot.y || p.y > geo.plot.y + geo.plot.h) return
154
+ dragStart = p
155
+ dragEnd = p
156
+ ;(e.target as Element).setPointerCapture?.(e.pointerId)
157
+ }
158
+ function onZoomMove(e: PointerEvent) {
159
+ if (!dragStart) return
160
+ dragEnd = svgPoint(e)
161
+ }
162
+ function onZoomUp() {
163
+ if (!dragStart || !dragEnd) { dragStart = null; dragEnd = null; return }
164
+ const r = dragRect
165
+ if (r) {
166
+ // Map the drag's main-axis bounds to category indices. When already
167
+ // zoomed, these indices are relative to the visible slice, so
168
+ // translate back to the original `spec.categories` indexing.
169
+ const i0v = pointToCatIndex({ x: r.x, y: r.y })
170
+ const i1v = pointToCatIndex({ x: r.x + r.w, y: r.y + r.h })
171
+ const offset = zoom ? zoom.i0 : 0
172
+ const lo = offset + Math.min(i0v, i1v)
173
+ const hi = offset + Math.max(i0v, i1v)
174
+ if (hi > lo) zoom = { i0: lo, i1: hi }
175
+ }
176
+ dragStart = null
177
+ dragEnd = null
178
+ }
179
+ function onPlotDblClick() {
180
+ if (zoomable && isZoomed) resetZoom()
181
+ }
182
+
183
+ // ---- Brush state -----------------------------------------------------
184
+ // The brush is a compact second chart showing the FULL data range with
185
+ // a translucent window over the visible slice. Dragging the body pans
186
+ // the window; dragging either edge resizes one side. Eligible for
187
+ // cartesian charts only (heatmap / pie don't have a single x-axis).
188
+ const brushEligible = $derived(brush && spec.type !== 'pie' && spec.type !== 'heatmap')
189
+ // Build a separate geometry from the un-zoomed spec at brushHeight.
190
+ const brushSpec = $derived<ChartSpec>({
191
+ ...spec,
192
+ height: brushHeight,
193
+ // Mute axes in the brush by clearing titles + reference lines.
194
+ yAxisTitle: undefined, y2AxisTitle: undefined, xAxisTitle: undefined,
195
+ referenceLines: undefined,
196
+ annotations: undefined,
197
+ series: coloredSeries.filter((s) => !effectiveHidden.has(s.label)),
198
+ })
199
+ const brushGeo = $derived(brushEligible ? buildChart(brushSpec) : null)
200
+ /** The brush window in fractional [0..1] of the visible data range,
201
+ * derived from `zoom`. When not zoomed, the window covers everything. */
202
+ const brushWindow = $derived.by(() => {
203
+ const n = spec.categories.length
204
+ if (!zoom || n === 0) return { t0: 0, t1: 1 }
205
+ return { t0: zoom.i0 / n, t1: (zoom.i1 + 1) / n }
206
+ })
207
+
208
+ /** Map a brush-svg-local x to a category index in the FULL data range. */
209
+ function brushXToIndex(localX: number): number {
210
+ if (!brushGeo) return 0
211
+ const w = brushGeo.plot.w
212
+ const x = Math.max(0, Math.min(w, localX - brushGeo.plot.x))
213
+ const n = spec.categories.length
214
+ return Math.max(0, Math.min(n - 1, Math.floor((x / w) * n)))
215
+ }
216
+ type BrushDrag = { mode: 'move' | 'left' | 'right'; startX: number; startT0: number; startT1: number }
217
+ let brushDrag = $state<BrushDrag | null>(null)
218
+ function brushSvgX(e: PointerEvent, svg: SVGSVGElement): number {
219
+ const r = svg.getBoundingClientRect()
220
+ if (!brushGeo) return 0
221
+ return ((e.clientX - r.left) / r.width) * brushGeo.width
222
+ }
223
+ function onBrushDown(e: PointerEvent, mode: BrushDrag['mode']) {
224
+ if (!brushGeo) return
225
+ const svg = e.currentTarget as SVGSVGElement
226
+ const x = brushSvgX(e, svg.ownerSVGElement ?? svg)
227
+ brushDrag = { mode, startX: x, startT0: brushWindow.t0, startT1: brushWindow.t1 }
228
+ ;(e.currentTarget as Element).setPointerCapture?.(e.pointerId)
229
+ e.stopPropagation()
230
+ }
231
+ function onBrushMove(e: PointerEvent) {
232
+ if (!brushDrag || !brushGeo) return
233
+ const svg = (e.currentTarget as SVGSVGElement).ownerSVGElement ?? (e.currentTarget as SVGSVGElement)
234
+ const x = brushSvgX(e, svg)
235
+ const dx = x - brushDrag.startX
236
+ const tw = brushGeo.plot.w
237
+ const dt = dx / tw
238
+ const n = spec.categories.length
239
+ if (n === 0) return
240
+ let t0 = brushDrag.startT0
241
+ let t1 = brushDrag.startT1
242
+ if (brushDrag.mode === 'move') {
243
+ const w = t1 - t0
244
+ t0 = Math.max(0, Math.min(1 - w, t0 + dt))
245
+ t1 = t0 + w
246
+ } else if (brushDrag.mode === 'left') {
247
+ // Keep at least 1 category visible.
248
+ t0 = Math.max(0, Math.min(t1 - 1 / n, t0 + dt))
249
+ } else {
250
+ t1 = Math.min(1, Math.max(t0 + 1 / n, t1 + dt))
251
+ }
252
+ // Translate back to integer category indices.
253
+ const i0 = Math.max(0, Math.floor(t0 * n))
254
+ const i1 = Math.min(n - 1, Math.ceil(t1 * n) - 1)
255
+ if (i0 === 0 && i1 === n - 1) zoom = null
256
+ else zoom = { i0, i1: Math.max(i0, i1) }
257
+ }
258
+ function onBrushUp(e: PointerEvent) {
259
+ brushDrag = null
260
+ ;(e.currentTarget as Element).releasePointerCapture?.(e.pointerId)
261
+ }
262
+
61
263
  const visibleSpec = $derived.by<ChartSpec>(() => {
62
264
  if (spec.type === 'pie') {
63
265
  const s = coloredSeries[0]
@@ -65,17 +267,49 @@
65
267
  const values = s.values.map((v, i) => (effectiveHidden.has(spec.categories[i] ?? String(i)) ? 0 : v))
66
268
  return { ...spec, series: [{ ...s, values }] }
67
269
  }
68
- return { ...spec, series: coloredSeries.filter((s) => !effectiveHidden.has(s.label)) }
270
+ const visibleSeries = coloredSeries.filter((s) => !effectiveHidden.has(s.label))
271
+ if (!zoom) return { ...spec, series: visibleSeries }
272
+ // Slice categories + each series' values to the zoom window so
273
+ // buildChart re-spreads the visible slice across the full plot.
274
+ const lo = Math.max(0, zoom.i0)
275
+ const hi = Math.min(spec.categories.length - 1, zoom.i1)
276
+ const slice = <T,>(arr: T[]) => arr.slice(lo, hi + 1)
277
+ return {
278
+ ...spec,
279
+ categories: slice(spec.categories),
280
+ series: visibleSeries.map((s) => ({
281
+ ...s,
282
+ values: slice(s.values),
283
+ rowIds: s.rowIds ? slice(s.rowIds) : undefined,
284
+ })),
285
+ }
69
286
  })
70
287
 
71
- const geo = $derived(buildChart(visibleSpec))
72
- const isCartesian = $derived(spec.type !== 'pie')
288
+ // Heatmap / calendar cell fills are computed colors (not CSS), so they can't
289
+ // inherit the theme. We detect the surface luminance (see the $effect near
290
+ // chartEl) and hand buildChart a light/dark hint so low-value cells render as
291
+ // "cold" rather than glaring white rectangles on a dark grid.
292
+ let isDark = $state(false)
293
+ const geo = $derived(buildChart(visibleSpec, isDark ? 'dark' : 'light'))
294
+ const isCartesian = $derived(
295
+ spec.type !== 'pie' && spec.type !== 'heatmap' &&
296
+ spec.type !== 'funnel' && spec.type !== 'radar' &&
297
+ spec.type !== 'calendar' && spec.type !== 'gauge' &&
298
+ spec.type !== 'treemap' && spec.type !== 'sankey',
299
+ )
73
300
  const isScatter = $derived(spec.type === 'scatter')
301
+ const isHeatmap = $derived(spec.type === 'heatmap')
302
+ const isFunnel = $derived(spec.type === 'funnel')
303
+ const isRadar = $derived(spec.type === 'radar')
304
+ const isCalendar = $derived(spec.type === 'calendar')
305
+ const isGauge = $derived(spec.type === 'gauge')
306
+ const isTreemap = $derived(spec.type === 'treemap')
307
+ const isSankey = $derived(spec.type === 'sankey')
74
308
  const isHorizontal = $derived(geo.orientation === 'horizontal')
75
309
  // Per-category band size along the main (category) axis: width when vertical,
76
310
  // height when horizontal.
77
311
  const slot = $derived(
78
- (isHorizontal ? geo.plot.h : geo.plot.w) / Math.max(1, spec.categories.length),
312
+ (isHorizontal ? geo.plot.h : geo.plot.w) / Math.max(1, visibleSpec.categories.length),
79
313
  )
80
314
 
81
315
  // Opacity for a series when another legend chip is being hovered.
@@ -98,6 +332,13 @@
98
332
  const isEmpty = $derived.by(() => {
99
333
  if (spec.type === 'pie') return geo.slices.every((s) => s.value <= 0)
100
334
  if (spec.type === 'scatter') return geo.scatterPoints.length === 0
335
+ if (spec.type === 'heatmap') return geo.heatmapCells.length === 0
336
+ if (spec.type === 'funnel') return geo.funnelSegments.length === 0
337
+ if (spec.type === 'radar') return geo.radarSeries.length === 0
338
+ if (spec.type === 'calendar') return geo.calendarCells.length === 0
339
+ if (spec.type === 'gauge') return !geo.gauge
340
+ if (spec.type === 'treemap') return geo.treemapCells.length === 0
341
+ if (spec.type === 'sankey') return geo.sankeyNodes.length === 0
101
342
  return geo.bars.length === 0 && geo.lines.every((l) => l.points.every((p) => !p.defined))
102
343
  })
103
344
 
@@ -143,6 +384,34 @@
143
384
 
144
385
  // ---- Tooltip + crosshair ----------------------------------------------
145
386
  let chartEl: HTMLElement | null = $state(null)
387
+
388
+ // Resolve whether the chart sits on a dark surface by reading the --sg-bg
389
+ // token's luminance. Re-checks on theme toggles (data-theme / class / style
390
+ // changes on the document root) so the heatmap ramp follows light<->dark.
391
+ function surfaceIsDark(color: string): boolean {
392
+ if (!color) return false
393
+ let r = 0, g = 0, b = 0
394
+ const hex = color.trim().startsWith('#') ? color.trim().slice(1) : ''
395
+ if (hex.length === 6) {
396
+ r = parseInt(hex.slice(0, 2), 16); g = parseInt(hex.slice(2, 4), 16); b = parseInt(hex.slice(4, 6), 16)
397
+ } else {
398
+ const m = /rgba?\(([^)]+)\)/.exec(color)
399
+ if (!m) return false
400
+ const p = m[1]!.split(',').map((s) => parseFloat(s))
401
+ r = p[0] ?? 0; g = p[1] ?? 0; b = p[2] ?? 0
402
+ }
403
+ // Perceived luminance (0..255); below the midpoint reads as a dark surface.
404
+ return 0.299 * r + 0.587 * g + 0.114 * b < 128
405
+ }
406
+ $effect(() => {
407
+ if (!chartEl || typeof window === 'undefined') return
408
+ const el = chartEl
409
+ const check = () => { isDark = surfaceIsDark(getComputedStyle(el).getPropertyValue('--sg-bg')) }
410
+ check()
411
+ const obs = new MutationObserver(check)
412
+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] })
413
+ return () => obs.disconnect()
414
+ })
146
415
  type TipRow = { label?: string; color?: string; value: string }
147
416
  let tip = $state<{ left: number; top: number; below: boolean; title: string; rows: TipRow[] } | null>(null)
148
417
  let activeCat = $state<number | null>(null)
@@ -164,7 +433,7 @@
164
433
  }
165
434
  function hoverCat(clientX: number, clientY: number, i: number) {
166
435
  activeCat = i
167
- showTip(clientX, clientY, spec.categories[i] ?? '', catRows(i))
436
+ showTip(clientX, clientY, visibleSpec.categories[i] ?? '', catRows(i))
168
437
  }
169
438
  function focusCat(el: Element, i: number) {
170
439
  const b = el.getBoundingClientRect()
@@ -184,15 +453,79 @@
184
453
  ]
185
454
  showTip(clientX, clientY, d.label || d.series, rows)
186
455
  }
456
+ function hoverCell(clientX: number, clientY: number, cell: (typeof geo.heatmapCells)[number]) {
457
+ showTip(clientX, clientY, `${cell.rowLabel} · ${cell.colLabel}`, [
458
+ { color: cell.color, value: fmt(cell.value) },
459
+ ])
460
+ }
461
+ function hoverFunnel(clientX: number, clientY: number, seg: (typeof geo.funnelSegments)[number]) {
462
+ const rows: TipRow[] = [
463
+ { value: fmt(seg.value) },
464
+ { label: 'conversion', value: `${(seg.conversion * 100).toFixed(1)}%` },
465
+ ]
466
+ if (seg.dropoff > 0) rows.push({ label: 'drop-off', value: `${(seg.dropoff * 100).toFixed(1)}%` })
467
+ showTip(clientX, clientY, seg.label, rows)
468
+ }
469
+ function hoverRadarPoint(clientX: number, clientY: number, label: string, axis: string, value: number, color: string) {
470
+ showTip(clientX, clientY, `${label} · ${axis}`, [{ color, value: fmt(value) }])
471
+ }
187
472
 
188
- function select(category: string, series: string, value: number) {
189
- onSelect?.({ category, series, value })
473
+ function select(category: string, series: string, value: number, catIndex?: number) {
474
+ // Collect contributing row ids when the spec carries them. For a
475
+ // category click (series === ''), flatten across every series; for a
476
+ // single-series click, return just that series' bucket.
477
+ let rowIds: Array<string | number> | undefined
478
+ if (catIndex !== undefined) {
479
+ const buckets: Array<Array<string | number>> = []
480
+ for (const s of visibleSpec.series) {
481
+ if (!s.rowIds) continue
482
+ if (series && s.label !== series) continue
483
+ const ids = s.rowIds[catIndex]
484
+ if (ids?.length) buckets.push(ids)
485
+ }
486
+ if (buckets.length) rowIds = buckets.flat()
487
+ }
488
+ const sel: ChartSelection = { category, series, value, rowIds }
489
+ onSelect?.(sel)
490
+ if (rowIds && rowIds.length) onDrill?.(sel)
491
+ }
492
+ /** Move keyboard focus to the cat-hit rect at index `next`, snapping
493
+ * back to the source element via the same `data-cat-index` attribute. */
494
+ function focusCatIndex(source: Element, next: number) {
495
+ const root = (source as SVGElement).ownerSVGElement ?? source.closest('svg')
496
+ if (!root) return
497
+ const target = root.querySelector<SVGElement>(`[data-cat-index="${next}"]`)
498
+ if (!target) return
499
+ target.focus?.()
500
+ // Mirror the tooltip / crosshair to the new focused category.
501
+ focusCat(target, next)
190
502
  }
191
503
  function onCatKey(e: KeyboardEvent, i: number) {
192
- if (!onSelect) return
193
- if (e.key === 'Enter' || e.key === ' ') {
194
- e.preventDefault()
195
- select(spec.categories[i] ?? '', '', 0)
504
+ const max = visibleSpec.categories.length - 1
505
+ const src = e.currentTarget as Element
506
+ // Arrow keys + Home / End navigate; Enter / Space select. PageUp/Down
507
+ // jump a tenth of the range at a time for long datasets.
508
+ switch (e.key) {
509
+ case 'ArrowRight':
510
+ case 'ArrowDown':
511
+ e.preventDefault(); focusCatIndex(src, Math.min(max, i + 1)); return
512
+ case 'ArrowLeft':
513
+ case 'ArrowUp':
514
+ e.preventDefault(); focusCatIndex(src, Math.max(0, i - 1)); return
515
+ case 'Home':
516
+ e.preventDefault(); focusCatIndex(src, 0); return
517
+ case 'End':
518
+ e.preventDefault(); focusCatIndex(src, max); return
519
+ case 'PageDown':
520
+ e.preventDefault(); focusCatIndex(src, Math.min(max, i + Math.max(1, Math.floor(max / 10)))); return
521
+ case 'PageUp':
522
+ e.preventDefault(); focusCatIndex(src, Math.max(0, i - Math.max(1, Math.floor(max / 10)))); return
523
+ case 'Enter':
524
+ case ' ':
525
+ if (!onSelect) return
526
+ e.preventDefault()
527
+ select(visibleSpec.categories[i] ?? '', '', 0, i)
528
+ return
196
529
  }
197
530
  }
198
531
  function onSliceKey(e: KeyboardEvent, label: string, value: number) {
@@ -202,19 +535,165 @@
202
535
  select(label, label, value)
203
536
  }
204
537
  }
538
+
539
+ // ---- Export ----------------------------------------------------------
540
+ // PNG / SVG download + copy-as-image. Serialize the live SVG element so
541
+ // the exported file matches the user's current zoom / visibility state.
542
+ let copyStatus = $state<'idle' | 'ok' | 'err'>('idle')
543
+ function serializeSvg(): string | null {
544
+ if (!svgEl) return null
545
+ const clone = svgEl.cloneNode(true) as SVGSVGElement
546
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
547
+ // Drop our interaction-only classes from the export.
548
+ clone.removeAttribute('class')
549
+ // Inline the computed sizes so the standalone SVG renders at the
550
+ // same pixel dimensions when opened directly.
551
+ const rect = svgEl.getBoundingClientRect()
552
+ if (rect.width) clone.setAttribute('width', String(Math.round(rect.width)))
553
+ if (rect.height) clone.setAttribute('height', String(Math.round(rect.height)))
554
+ // Pull the chart's stylesheet so the standalone SVG is self-contained.
555
+ const styleTag = document.createElement('style')
556
+ const sheets = Array.from(document.styleSheets)
557
+ const rules: string[] = []
558
+ for (const sheet of sheets) {
559
+ try {
560
+ for (const rule of Array.from(sheet.cssRules ?? [])) {
561
+ const t = rule.cssText
562
+ if (t.includes('.sv-grid-chart')) rules.push(t)
563
+ }
564
+ } catch { /* cross-origin sheets - skip */ }
565
+ }
566
+ styleTag.textContent = rules.join('\n')
567
+ clone.insertBefore(styleTag, clone.firstChild)
568
+ return new XMLSerializer().serializeToString(clone)
569
+ }
570
+ function triggerDownload(blob: Blob, filename: string) {
571
+ const url = URL.createObjectURL(blob)
572
+ const a = document.createElement('a')
573
+ a.href = url
574
+ a.download = filename
575
+ document.body.appendChild(a)
576
+ a.click()
577
+ a.remove()
578
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
579
+ }
580
+ async function rasterizeSvg(svgText: string): Promise<Blob | null> {
581
+ return new Promise((resolve) => {
582
+ const rect = svgEl?.getBoundingClientRect()
583
+ const w = Math.max(1, Math.round(rect?.width ?? geo.width))
584
+ const h = Math.max(1, Math.round(rect?.height ?? geo.height))
585
+ const dpr = window.devicePixelRatio || 1
586
+ const canvas = document.createElement('canvas')
587
+ canvas.width = w * dpr
588
+ canvas.height = h * dpr
589
+ const ctx = canvas.getContext('2d')
590
+ if (!ctx) { resolve(null); return }
591
+ const img = new Image()
592
+ img.onload = () => {
593
+ ctx.scale(dpr, dpr)
594
+ ctx.drawImage(img, 0, 0, w, h)
595
+ canvas.toBlob((b) => resolve(b), 'image/png')
596
+ }
597
+ img.onerror = () => resolve(null)
598
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText)
599
+ })
600
+ }
601
+ async function downloadImage(format: 'png' | 'svg') {
602
+ const text = serializeSvg()
603
+ if (!text) return
604
+ const stamp = new Date().toISOString().slice(0, 10)
605
+ if (format === 'svg') {
606
+ triggerDownload(new Blob([text], { type: 'image/svg+xml' }), `chart-${stamp}.svg`)
607
+ return
608
+ }
609
+ const png = await rasterizeSvg(text)
610
+ if (png) triggerDownload(png, `chart-${stamp}.png`)
611
+ }
612
+ async function copyAsImage() {
613
+ const text = serializeSvg()
614
+ if (!text) { copyStatus = 'err'; return }
615
+ try {
616
+ const png = await rasterizeSvg(text)
617
+ if (!png) throw new Error('rasterize failed')
618
+ const Clip = (window as unknown as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
619
+ if (Clip && navigator.clipboard?.write) {
620
+ await navigator.clipboard.write([new Clip({ 'image/png': png })])
621
+ } else {
622
+ // Fallback: copy SVG markup as text so the user can paste somewhere.
623
+ await navigator.clipboard.writeText(text)
624
+ }
625
+ copyStatus = 'ok'
626
+ } catch {
627
+ copyStatus = 'err'
628
+ }
629
+ setTimeout(() => (copyStatus = 'idle'), 1500)
630
+ }
205
631
  </script>
206
632
 
207
633
  <div class="sv-grid-chart" bind:this={chartEl}>
634
+ {#if showToolbar}
635
+ <div class="sv-grid-chart-toolbar">
636
+ {#if zoomable && isZoomed}
637
+ <button type="button" class="sv-grid-chart-tool" onclick={resetZoom} title="Reset zoom (or double-click the plot)">
638
+ <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>
639
+ Reset zoom
640
+ </button>
641
+ {/if}
642
+ <button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('png')} title="Download PNG">PNG</button>
643
+ <button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('svg')} title="Download SVG">SVG</button>
644
+ <button type="button" class="sv-grid-chart-tool" onclick={copyAsImage} title="Copy chart as image">
645
+ {copyStatus === 'ok' ? '✓ Copied' : 'Copy'}
646
+ </button>
647
+ </div>
648
+ {/if}
208
649
  <svg
650
+ bind:this={svgEl}
209
651
  class="sv-grid-chart-svg"
210
652
  class:is-interactive={interactive}
211
- class:is-clickable={!!onSelect}
653
+ class:is-clickable={!!onSelect || !!onDrill}
654
+ class:is-zoomable={zoomable && isCartesian}
655
+ class:is-dragging={!!dragStart}
212
656
  viewBox={`0 0 ${geo.width} ${geo.height}`}
213
657
  width="100%"
214
658
  role="img"
215
659
  aria-label={`${spec.type} chart`}
216
660
  aria-describedby={`${uid}-table`}
661
+ onpointerdown={onZoomDown}
662
+ onpointermove={onZoomMove}
663
+ onpointerup={onZoomUp}
664
+ onpointercancel={onZoomUp}
665
+ ondblclick={onPlotDblClick}
217
666
  >
667
+ <!-- Pattern defs: emitted once per series when patternFallback is on
668
+ or a series sets `pattern`. Color is baked into the pattern so
669
+ each series gets a hue-tinted texture without dynamic CSS. -->
670
+ {#if patternDefs.length}
671
+ <defs>
672
+ {#each patternDefs as p (p.id)}
673
+ {#if p.kind === 'stripe'}
674
+ <pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
675
+ <rect width="6" height="6" fill={p.color} />
676
+ <rect width="3" height="6" fill="rgba(255,255,255,0.35)" />
677
+ </pattern>
678
+ {:else if p.kind === 'crosshatch'}
679
+ <pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse">
680
+ <rect width="6" height="6" fill={p.color} />
681
+ <path d="M0 0L6 6 M6 0L0 6" stroke="rgba(255,255,255,0.45)" stroke-width="1" />
682
+ </pattern>
683
+ {:else if p.kind === 'dots'}
684
+ <pattern id={p.id} width="6" height="6" patternUnits="userSpaceOnUse">
685
+ <rect width="6" height="6" fill={p.color} />
686
+ <circle cx="3" cy="3" r="1.2" fill="rgba(255,255,255,0.55)" />
687
+ </pattern>
688
+ {:else if p.kind === 'diagonal'}
689
+ <pattern id={p.id} width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)">
690
+ <rect width="8" height="8" fill={p.color} />
691
+ <line x1="0" y1="0" x2="0" y2="8" stroke="rgba(0,0,0,0.18)" stroke-width="2" />
692
+ </pattern>
693
+ {/if}
694
+ {/each}
695
+ </defs>
696
+ {/if}
218
697
  {#if isHorizontal}
219
698
  <!-- Value axis (vertical gridlines + bottom labels) -->
220
699
  {#each geo.valueTicks as t (t.label + t.x)}
@@ -261,8 +740,12 @@
261
740
 
262
741
  {#each geo.lines as line, li (line.label + li)}
263
742
  <g style={`opacity:${dimOf(line.label)}`}>
743
+ {#if line.bandPath}
744
+ <!-- Confidence band: shaded envelope between upperValues / lowerValues. -->
745
+ <path class="sv-grid-chart-band" d={line.bandPath} fill={line.color} fill-opacity="0.12" stroke="none" />
746
+ {/if}
264
747
  {#if line.areaPath}
265
- <path class="sv-grid-chart-area" d={line.areaPath} fill={line.color} fill-opacity="0.15" stroke="none" />
748
+ <path class="sv-grid-chart-area" d={line.areaPath} fill={seriesFill[line.label] ?? line.color} fill-opacity="0.15" stroke="none" />
266
749
  {/if}
267
750
  <path class="sv-grid-chart-linepath" d={line.path} fill="none" stroke={line.color} stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
268
751
  {#each line.points as pt, pi (pi)}
@@ -277,7 +760,7 @@
277
760
  {/each}
278
761
 
279
762
  {#each geo.bars as bar, bi (bi)}
280
- <rect class="sv-grid-chart-bar" x={bar.x} y={bar.y} width={bar.w} height={bar.h} rx="1" fill={bar.color} style={`opacity:${dimOf(bar.series)}`} />
763
+ <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)}`} />
281
764
  {#if dataLabels && isHorizontal && bar.w > 18}
282
765
  <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>
283
766
  {:else if dataLabels && !isHorizontal && bar.h > 13}
@@ -311,16 +794,265 @@
311
794
  <text class="sv-grid-chart-reflabel" x={ref.x + 3} y={geo.plot.y + 9} text-anchor="start" fill={ref.color}>{ref.label}</text>
312
795
  {/each}
313
796
 
797
+ <!-- Trend / moving-average overlays. Dashed so they read distinctly
798
+ from the source series, drawn on top of bars + lines. -->
799
+ {#each geo.overlays as ovl, oi (ovl.label + oi)}
800
+ <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" />
801
+ {/each}
802
+
803
+ <!-- Pinned annotations: small marker + label. Placement nudges the
804
+ label position so it sits clear of the data point. -->
805
+ {#each geo.annotations as ann, ai (ann.label + ai)}
806
+ {@const dx = ann.placement === 'left' ? -8 : ann.placement === 'right' ? 8 : 0}
807
+ {@const dy = ann.placement === 'bottom' ? 14 : ann.placement === 'top' ? -8 : 0}
808
+ {@const anchor = ann.placement === 'left' ? 'end' : ann.placement === 'right' ? 'start' : 'middle'}
809
+ <circle class="sv-grid-chart-annotation-marker" cx={ann.x} cy={ann.y} r="4" fill={ann.color} stroke="var(--sg-bg, #fff)" stroke-width="1.5" />
810
+ <text class="sv-grid-chart-annotation-label" x={ann.x + dx} y={ann.y + dy} text-anchor={anchor} fill={ann.color}>{ann.label}</text>
811
+ {/each}
812
+
314
813
  {#if isCartesian && activeCat !== null}
315
814
  {#if isHorizontal}
316
815
  {@const cy = geo.plot.y + slot * activeCat + slot / 2}
317
816
  <line class="sv-grid-chart-crosshair" x1={geo.plot.x} y1={cy} x2={geo.plot.x + geo.plot.w} y2={cy} />
318
817
  {:else}
319
- {@const cx = geo.plot.x + slot * activeCat + slot / 2}
818
+ <!-- Read the actual point x from the first line series when one
819
+ exists (line / area / combo charts) so the crosshair lines up
820
+ with the dots even on a time x-axis (where slot-based math is
821
+ wrong because points are spaced by time, not index). Fall back
822
+ to the slot midpoint for pure bar charts. -->
823
+ {@const linePt = geo.lines[0]?.points[activeCat]}
824
+ {@const cx = linePt && Number.isFinite(linePt.x)
825
+ ? linePt.x
826
+ : geo.plot.x + slot * activeCat + slot / 2}
320
827
  <line class="sv-grid-chart-crosshair" x1={cx} y1={geo.plot.y} x2={cx} y2={geo.plot.y + geo.plot.h} />
321
828
  {/if}
322
829
  {/if}
323
830
 
831
+ {#if isHeatmap}
832
+ <!-- Row labels in the left gutter -->
833
+ {#each geo.heatmapRowTicks as t (t.value)}
834
+ <text class="sv-grid-chart-axis" x={geo.plot.x - 8} y={t.y + 3} text-anchor="end">{truncate(t.label, 22)}</text>
835
+ {/each}
836
+ <!-- Column labels along the bottom -->
837
+ {#each geo.heatmapColTicks as t, i (t.label + i)}
838
+ <text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y + geo.plot.h + 14} text-anchor="middle">{truncate(t.label, 14)}</text>
839
+ {/each}
840
+ <!-- Cells -->
841
+ {#each geo.heatmapCells as cell, ci (ci)}
842
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
843
+ <rect
844
+ class="sv-grid-chart-heatcell"
845
+ x={cell.x} y={cell.y} width={cell.w} height={cell.h}
846
+ fill={cell.color}
847
+ role={onSelect ? 'button' : 'presentation'}
848
+ tabindex={onSelect ? 0 : undefined}
849
+ aria-label={`${cell.rowLabel} ${cell.colLabel}: ${fmt(cell.value)}`}
850
+ onmousemove={(e) => hoverCell(e.clientX, e.clientY, cell)}
851
+ onmouseleave={clearActive}
852
+ onclick={() => select(cell.colLabel, cell.rowLabel, cell.value)}
853
+ />
854
+ {#if dataLabels && cell.w > 22 && cell.h > 14}
855
+ <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>
856
+ {/if}
857
+ {/each}
858
+ <!-- Color-scale legend bar on the right -->
859
+ {#if geo.heatmapLegend.length}
860
+ {@const lx = geo.plot.x + geo.plot.w + 14}
861
+ {@const lh = geo.plot.h - 20}
862
+ {@const stops = geo.heatmapLegend}
863
+ <defs>
864
+ <linearGradient id={`${uid}-heatscale`} x1="0" y1="1" x2="0" y2="0">
865
+ {#each stops as s, i (i)}
866
+ <stop offset={`${(i / (stops.length - 1)) * 100}%`} stop-color={s.color} />
867
+ {/each}
868
+ </linearGradient>
869
+ </defs>
870
+ <rect class="sv-grid-chart-heatlegend" x={lx} y={geo.plot.y + 10} width="10" height={lh} fill={`url(#${uid}-heatscale)`} />
871
+ <text class="sv-grid-chart-axis" x={lx + 14} y={geo.plot.y + 14} text-anchor="start">{stops[stops.length - 1]!.label}</text>
872
+ <text class="sv-grid-chart-axis" x={lx + 14} y={geo.plot.y + 10 + lh} text-anchor="start">{stops[0]!.label}</text>
873
+ {/if}
874
+ {/if}
875
+
876
+ {#if isFunnel}
877
+ <!-- Trapezoid stack with inline label inside each segment. -->
878
+ {#each geo.funnelSegments as seg, fi (fi)}
879
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
880
+ <path
881
+ class="sv-grid-chart-funnel-seg"
882
+ d={seg.path}
883
+ fill={seg.color}
884
+ role={onSelect ? 'button' : 'presentation'}
885
+ tabindex={onSelect ? 0 : undefined}
886
+ aria-label={`${seg.label}: ${fmt(seg.value)}`}
887
+ onmousemove={(e) => hoverFunnel(e.clientX, e.clientY, seg)}
888
+ onmouseleave={clearActive}
889
+ onclick={() => select(seg.label, seg.label, seg.value, fi)}
890
+ />
891
+ <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>
892
+ <text class="sv-grid-chart-funnel-value" x={seg.cx} y={seg.cy + 11} text-anchor="middle" fill={seg.textColor}>
893
+ {fmt(seg.value)} · {(seg.conversion * 100).toFixed(0)}%
894
+ </text>
895
+ {/each}
896
+ {/if}
897
+
898
+ {#if isRadar && geo.radarCenter}
899
+ {@const c = geo.radarCenter}
900
+ <!-- Concentric grid rings -->
901
+ {#each geo.radarRings as v, ri (ri)}
902
+ {@const ringR = c.r * ((ri + 1) / geo.radarRings.length)}
903
+ <circle class="sv-grid-chart-radar-ring" cx={c.cx} cy={c.cy} r={ringR} />
904
+ {/each}
905
+ <!-- Axis spokes + outer labels -->
906
+ {#each geo.radarAxes as axis, ai (ai)}
907
+ <line class="sv-grid-chart-radar-spoke" x1={c.cx} y1={c.cy} x2={axis.x} y2={axis.y} />
908
+ {@const dx = axis.x - c.cx}
909
+ {@const dy = axis.y - c.cy}
910
+ {@const lx = c.cx + dx * 1.08}
911
+ {@const ly = c.cy + dy * 1.08}
912
+ <text class="sv-grid-chart-axis" x={lx} y={ly + 3}
913
+ text-anchor={Math.abs(dx) < 1 ? 'middle' : dx > 0 ? 'start' : 'end'}>{axis.label}</text>
914
+ {/each}
915
+ <!-- One translucent polygon per series + dot per vertex. -->
916
+ {#each geo.radarSeries as rs, si (si)}
917
+ <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)}`} />
918
+ {#each rs.points as pt, pi (pi)}
919
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
920
+ <circle class="sv-grid-chart-radar-dot" cx={pt.x} cy={pt.y} r="3" fill={rs.color}
921
+ onmousemove={(e) => hoverRadarPoint(e.clientX, e.clientY, rs.label, pt.axis, pt.value, rs.color)}
922
+ onmouseleave={clearActive} />
923
+ {/each}
924
+ {/each}
925
+ {/if}
926
+
927
+ {#if isCalendar}
928
+ <!-- Month labels along the top -->
929
+ {#each geo.calendarMonthTicks as t, ti (ti)}
930
+ <text class="sv-grid-chart-axis" x={t.x} y={geo.plot.y - 8} text-anchor="start">{t.label}</text>
931
+ {/each}
932
+ <!-- Day-of-week labels on the left (Mon/Wed/Fri only to save space) -->
933
+ {#each ['Mon','Wed','Fri'] as dayLabel, di (di)}
934
+ {@const rowIdx = [1, 3, 5][di]!}
935
+ {@const cell = geo.calendarCells[rowIdx]}
936
+ {#if cell}
937
+ <text class="sv-grid-chart-axis" x={geo.plot.x - 6} y={cell.y + cell.size / 2 + 3} text-anchor="end">{dayLabel}</text>
938
+ {/if}
939
+ {/each}
940
+ <!-- Cells -->
941
+ {#each geo.calendarCells as cell, ci (ci)}
942
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
943
+ <rect
944
+ class="sv-grid-chart-calendar-cell"
945
+ class:is-blank={!cell.defined}
946
+ x={cell.x + 1} y={cell.y + 1}
947
+ width={cell.size - 2} height={cell.size - 2}
948
+ fill={cell.defined ? cell.color : 'transparent'}
949
+ rx="2"
950
+ aria-label={`${cell.date}: ${cell.defined ? fmt(cell.value) : 'no data'}`}
951
+ onmousemove={(e) => cell.defined && showTip(e.clientX, e.clientY, cell.date, [{ color: cell.color, value: fmt(cell.value) }])}
952
+ onmouseleave={clearActive}
953
+ />
954
+ {/each}
955
+ <!-- Legend strip on the right -->
956
+ {#if geo.calendarLegend.length}
957
+ {@const lx = geo.plot.x + geo.plot.w + 12}
958
+ {@const ly = geo.plot.y + 4}
959
+ <text class="sv-grid-chart-axis" x={lx} y={ly} text-anchor="start">Less</text>
960
+ {#each geo.calendarLegend as s, i (i)}
961
+ <rect class="sv-grid-chart-calendar-legend" x={lx} y={ly + 6 + i * 13} width="10" height="10" rx="2" fill={s.color} />
962
+ <text class="sv-grid-chart-axis" x={lx + 14} y={ly + 14 + i * 13} text-anchor="start">{s.label}</text>
963
+ {/each}
964
+ <text class="sv-grid-chart-axis" x={lx} y={ly + 12 + geo.calendarLegend.length * 13} text-anchor="start">More</text>
965
+ {/if}
966
+ {/if}
967
+
968
+ {#if isGauge && geo.gauge}
969
+ {@const g = geo.gauge}
970
+ <!-- Tick marks around the dial (major ticks longer). -->
971
+ {#each g.ticks as tk, ti (ti)}
972
+ <line class="sv-grid-chart-gauge-tick" class:is-major={tk.major}
973
+ x1={tk.x1} y1={tk.y1} x2={tk.x2} y2={tk.y2} />
974
+ {/each}
975
+ <!-- Track (grey full arc) -->
976
+ <path d={g.trackPath} fill="none" stroke="var(--sg-border, #e2e8f0)" stroke-width="16" stroke-linecap="round" />
977
+ <!-- Optional colored range bands (a thinner inner arc). -->
978
+ {#each g.rangePaths as band, bi (bi)}
979
+ <path d={band.path} fill="none" stroke={band.color} stroke-width="6" stroke-linecap="round" opacity="0.85" />
980
+ {/each}
981
+ <!-- Value arc, colored by the band the value sits in. -->
982
+ <path d={g.valuePath} fill="none" stroke={g.valueColor ?? 'var(--sg-accent, #2563eb)'} stroke-width="16" stroke-linecap="round" />
983
+ <!-- Target tick (when set) -->
984
+ {#if g.target}
985
+ <line x1={g.target.x1} y1={g.target.y1} x2={g.target.x2} y2={g.target.y2}
986
+ stroke="var(--sg-fg, #0f172a)" stroke-width="2.5" stroke-linecap="round" />
987
+ {/if}
988
+ <!-- Pointer needle + center hub. -->
989
+ <path class="sv-grid-chart-gauge-needle" d={g.needle.path} fill={g.valueColor ?? 'var(--sg-accent, #2563eb)'} />
990
+ <circle class="sv-grid-chart-gauge-hub" cx={g.cx} cy={g.cy} r={g.needle.hubR} />
991
+ <circle class="sv-grid-chart-gauge-hub-dot" cx={g.cx} cy={g.cy} r="2.5" />
992
+ <!-- Scale end labels + center readout. -->
993
+ <text class="sv-grid-chart-axis" x={g.minLabel.x} y={g.minLabel.y} text-anchor="middle">{fmt(g.min)}</text>
994
+ <text class="sv-grid-chart-axis" x={g.maxLabel.x} y={g.maxLabel.y} text-anchor="middle">{fmt(g.max)}</text>
995
+ <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>
996
+ {/if}
997
+
998
+ {#if isTreemap}
999
+ {#each geo.treemapCells as cell, ti (ti)}
1000
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
1001
+ <rect
1002
+ class="sv-grid-chart-treemap-cell"
1003
+ x={cell.x} y={cell.y} width={cell.w} height={cell.h}
1004
+ fill={cell.color}
1005
+ aria-label={`${cell.name}: ${fmt(cell.value)}`}
1006
+ onmousemove={(e) => showTip(e.clientX, e.clientY, cell.name, [{ color: cell.color, value: fmt(cell.value) }])}
1007
+ onmouseleave={clearActive}
1008
+ onclick={() => select(cell.name, '', cell.value)}
1009
+ />
1010
+ {#if cell.w > 36 && cell.h > 18}
1011
+ <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>
1012
+ {/if}
1013
+ {#if cell.w > 60 && cell.h > 30}
1014
+ <text class="sv-grid-chart-treemap-value" x={cell.x + 4} y={cell.y + 24} fill={cell.textColor} opacity="0.85">{fmt(cell.value)}</text>
1015
+ {/if}
1016
+ {/each}
1017
+ {/if}
1018
+
1019
+ {#if isSankey}
1020
+ <!-- Ribbon links (drawn first so node rects sit on top). -->
1021
+ {#each geo.sankeyLinks as link, li (li)}
1022
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
1023
+ <path
1024
+ class="sv-grid-chart-sankey-link"
1025
+ d={link.path}
1026
+ fill="none"
1027
+ stroke={link.color}
1028
+ stroke-width={link.width}
1029
+ stroke-opacity="0.35"
1030
+ aria-label={`${link.source} -> ${link.target}: ${fmt(link.value)}`}
1031
+ onmousemove={(e) => showTip(e.clientX, e.clientY, `${link.source} -> ${link.target}`, [{ color: link.color, value: fmt(link.value) }])}
1032
+ onmouseleave={clearActive}
1033
+ />
1034
+ {/each}
1035
+ <!-- Node rectangles + labels. -->
1036
+ {#each geo.sankeyNodes as node, ni (ni)}
1037
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
1038
+ <rect
1039
+ class="sv-grid-chart-sankey-node"
1040
+ x={node.x} y={node.y} width={node.w} height={node.h}
1041
+ fill={node.color}
1042
+ aria-label={`${node.label}: in ${fmt(node.totalIn)}, out ${fmt(node.totalOut)}`}
1043
+ onmousemove={(e) => showTip(e.clientX, e.clientY, node.label, [
1044
+ { label: 'in', value: fmt(node.totalIn) },
1045
+ { label: 'out', value: fmt(node.totalOut) },
1046
+ ])}
1047
+ onmouseleave={clearActive}
1048
+ />
1049
+ <text class="sv-grid-chart-sankey-label"
1050
+ x={node.column === 0 ? node.x + node.w + 4 : node.x - 4}
1051
+ y={node.y + node.h / 2 + 3}
1052
+ text-anchor={node.column === 0 ? 'start' : 'end'}>{node.label}</text>
1053
+ {/each}
1054
+ {/if}
1055
+
324
1056
  {#each geo.slices as slice, si (si)}
325
1057
  <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
326
1058
  <path
@@ -336,7 +1068,7 @@
336
1068
  onmouseleave={clearActive}
337
1069
  onfocus={(e) => { const b = e.currentTarget.getBoundingClientRect(); hoverSlice(b.left + b.width / 2, b.top, slice.label, slice.value, slice.percent) }}
338
1070
  onblur={clearActive}
339
- onclick={() => select(slice.label, slice.label, slice.value)}
1071
+ onclick={() => select(slice.label, slice.label, slice.value, si)}
340
1072
  onkeydown={(e) => onSliceKey(e, slice.label, slice.value)}
341
1073
  />
342
1074
  {#if dataLabels && slice.percent >= 6}
@@ -350,29 +1082,102 @@
350
1082
  {/if}
351
1083
 
352
1084
  {#if isCartesian && !isScatter}
353
- <!-- Per-category hover/focus zones drive the unified crosshair tooltip. -->
354
- {#each spec.categories as cat, i (cat + i)}
1085
+ <!-- Per-category hover/focus zones drive the unified crosshair
1086
+ tooltip + arrow-key navigation. tabindex=0 on the first rect
1087
+ and -1 on the rest implements roving tabindex: Tab enters the
1088
+ group, arrow keys move within it, Tab leaves.
1089
+ Vertical bands centre on the ACTUAL point x (which differs from
1090
+ slot math when xType is 'time'); their widths bisect the gap to
1091
+ neighbouring points so a mouse over a dot always selects it. -->
1092
+ {#each visibleSpec.categories as cat, i (cat + i)}
1093
+ {@const pts = geo.lines[0]?.points}
1094
+ {@const px = pts?.[i]?.x}
1095
+ {@const useTimeBands = !isHorizontal && pts && pts.length === visibleSpec.categories.length && Number.isFinite(px)}
1096
+ {@const prevX = useTimeBands && i > 0 ? pts![i - 1]!.x : null}
1097
+ {@const nextX = useTimeBands && i < pts!.length - 1 ? pts![i + 1]!.x : null}
1098
+ {@const bandL = !useTimeBands ? geo.plot.x + slot * i
1099
+ : prevX != null ? (prevX + (px as number)) / 2 : geo.plot.x}
1100
+ {@const bandR = !useTimeBands ? geo.plot.x + slot * (i + 1)
1101
+ : nextX != null ? ((px as number) + nextX) / 2 : geo.plot.x + geo.plot.w}
355
1102
  <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
356
1103
  <rect
357
1104
  class="sv-grid-chart-cat-hit"
358
- x={isHorizontal ? geo.plot.x : geo.plot.x + slot * i}
1105
+ x={isHorizontal ? geo.plot.x : bandL}
359
1106
  y={isHorizontal ? geo.plot.y + slot * i : geo.plot.y}
360
- width={isHorizontal ? geo.plot.w : slot}
1107
+ width={isHorizontal ? geo.plot.w : Math.max(0, bandR - bandL)}
361
1108
  height={isHorizontal ? slot : geo.plot.h}
362
- role={onSelect ? 'button' : 'presentation'}
363
- tabindex={onSelect ? 0 : undefined}
1109
+ role={onSelect ? 'button' : 'img'}
1110
+ tabindex={interactive ? (i === 0 ? 0 : -1) : undefined}
1111
+ data-cat-index={i}
364
1112
  aria-label={`${cat}: ${catRows(i).map((r) => `${r.label} ${r.value}`).join(', ')}`}
365
1113
  onmousemove={(e) => hoverCat(e.clientX, e.clientY, i)}
366
1114
  onmouseleave={clearActive}
367
1115
  onfocus={(e) => focusCat(e.currentTarget, i)}
368
1116
  onblur={clearActive}
369
- onclick={() => select(cat, '', 0)}
1117
+ onclick={() => select(cat, '', 0, i)}
370
1118
  onkeydown={(e) => onCatKey(e, i)}
371
1119
  />
372
1120
  {/each}
373
1121
  {/if}
1122
+
1123
+ {#if dragRect}
1124
+ <rect class="sv-grid-chart-zoom-rect"
1125
+ x={dragRect.x} y={dragRect.y} width={dragRect.w} height={dragRect.h} />
1126
+ {/if}
374
1127
  </svg>
375
1128
 
1129
+ {#if brushEligible && brushGeo}
1130
+ {@const bx = brushGeo.plot.x + brushWindow.t0 * brushGeo.plot.w}
1131
+ {@const bw = (brushWindow.t1 - brushWindow.t0) * brushGeo.plot.w}
1132
+ <svg
1133
+ class="sv-grid-chart-brush"
1134
+ viewBox={`0 0 ${brushGeo.width} ${brushGeo.height}`}
1135
+ width="100%"
1136
+ role="img"
1137
+ aria-label="Range brush"
1138
+ onpointermove={onBrushMove}
1139
+ >
1140
+ <!-- Mini chart: paint a faint version of the lines / area / bars
1141
+ so the user sees what region they're picking from. -->
1142
+ {#each brushGeo.lines as line, li (li)}
1143
+ {#if line.areaPath}
1144
+ <path d={line.areaPath} fill={line.color} fill-opacity="0.10" stroke="none" />
1145
+ {/if}
1146
+ <path d={line.path} fill="none" stroke={line.color} stroke-width="1.2" opacity="0.75" />
1147
+ {/each}
1148
+ {#each brushGeo.bars as bar, bi (bi)}
1149
+ <rect x={bar.x} y={bar.y} width={bar.w} height={bar.h} fill={bar.color} opacity="0.55" />
1150
+ {/each}
1151
+ <!-- Faded overlay on the parts NOT in the window. -->
1152
+ <rect class="sv-grid-chart-brush-mask"
1153
+ x={brushGeo.plot.x} y={brushGeo.plot.y}
1154
+ width={bx - brushGeo.plot.x} height={brushGeo.plot.h} />
1155
+ <rect class="sv-grid-chart-brush-mask"
1156
+ x={bx + bw} y={brushGeo.plot.y}
1157
+ width={brushGeo.plot.x + brushGeo.plot.w - (bx + bw)} height={brushGeo.plot.h} />
1158
+ <!-- The window body: pointerdown moves the whole window. -->
1159
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
1160
+ <rect class="sv-grid-chart-brush-window"
1161
+ x={bx} y={brushGeo.plot.y} width={bw} height={brushGeo.plot.h}
1162
+ onpointerdown={(e) => onBrushDown(e, 'move')}
1163
+ onpointerup={onBrushUp}
1164
+ onpointercancel={onBrushUp} />
1165
+ <!-- Left + right handles: pointerdown resizes that edge. -->
1166
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
1167
+ <rect class="sv-grid-chart-brush-handle"
1168
+ x={bx - 4} y={brushGeo.plot.y} width="8" height={brushGeo.plot.h}
1169
+ onpointerdown={(e) => onBrushDown(e, 'left')}
1170
+ onpointerup={onBrushUp}
1171
+ onpointercancel={onBrushUp} />
1172
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
1173
+ <rect class="sv-grid-chart-brush-handle"
1174
+ x={bx + bw - 4} y={brushGeo.plot.y} width="8" height={brushGeo.plot.h}
1175
+ onpointerdown={(e) => onBrushDown(e, 'right')}
1176
+ onpointerup={onBrushUp}
1177
+ onpointercancel={onBrushUp} />
1178
+ </svg>
1179
+ {/if}
1180
+
376
1181
  {#if isEmpty}
377
1182
  <div class="sv-grid-chart-empty">No data</div>
378
1183
  {/if}
@@ -483,6 +1288,200 @@
483
1288
  font-family: inherit;
484
1289
  pointer-events: none;
485
1290
  }
1291
+ .sv-grid-chart-overlay {
1292
+ opacity: 0.85;
1293
+ pointer-events: none;
1294
+ }
1295
+ .sv-grid-chart-annotation-marker {
1296
+ pointer-events: none;
1297
+ }
1298
+ .sv-grid-chart-annotation-label {
1299
+ font-size: 10px;
1300
+ font-weight: 700;
1301
+ font-family: inherit;
1302
+ pointer-events: none;
1303
+ }
1304
+ /* Heatmap cells. Stroke uses the border token (not bg) so the cell
1305
+ separators stay visible in BOTH light and dark themes - on dark mode
1306
+ a bg-colored stroke disappeared into the navy cells. */
1307
+ .sv-grid-chart-heatcell {
1308
+ stroke: var(--sg-border, #cbd5e1);
1309
+ stroke-width: 1;
1310
+ transition: opacity 100ms ease;
1311
+ cursor: default;
1312
+ }
1313
+ .sv-grid-chart-svg.is-clickable .sv-grid-chart-heatcell { cursor: pointer; }
1314
+ .sv-grid-chart-heatcell:hover { opacity: 0.85; }
1315
+ .sv-grid-chart-heatlabel {
1316
+ font-size: 10px;
1317
+ font-weight: 600;
1318
+ font-family: inherit;
1319
+ pointer-events: none;
1320
+ user-select: none;
1321
+ }
1322
+ /* Legend gradient bar gets a visible outline + axis labels next to it
1323
+ pull from the regular axis class so they follow the theme. */
1324
+ .sv-grid-chart-heatlegend {
1325
+ stroke: var(--sg-border, #cbd5e1);
1326
+ stroke-width: 1;
1327
+ }
1328
+ /* Funnel segments + labels. */
1329
+ .sv-grid-chart-funnel-seg {
1330
+ stroke: var(--sg-bg, #ffffff);
1331
+ stroke-width: 2;
1332
+ transition: opacity 100ms ease;
1333
+ cursor: default;
1334
+ }
1335
+ .sv-grid-chart-svg.is-clickable .sv-grid-chart-funnel-seg { cursor: pointer; }
1336
+ .sv-grid-chart-funnel-seg:hover { opacity: 0.88; }
1337
+ .sv-grid-chart-funnel-label {
1338
+ font-size: 11px;
1339
+ font-weight: 700;
1340
+ font-family: inherit;
1341
+ pointer-events: none;
1342
+ }
1343
+ .sv-grid-chart-funnel-value {
1344
+ font-size: 10px;
1345
+ font-family: inherit;
1346
+ opacity: 0.85;
1347
+ pointer-events: none;
1348
+ }
1349
+ /* Radar grid + polygons. */
1350
+ .sv-grid-chart-radar-ring {
1351
+ fill: none;
1352
+ stroke: var(--sg-border, #e2e8f0);
1353
+ stroke-width: 1;
1354
+ opacity: 0.6;
1355
+ }
1356
+ .sv-grid-chart-radar-spoke {
1357
+ stroke: var(--sg-border, #e2e8f0);
1358
+ stroke-width: 1;
1359
+ opacity: 0.6;
1360
+ }
1361
+ .sv-grid-chart-radar-poly {
1362
+ transition: opacity 100ms ease;
1363
+ pointer-events: none;
1364
+ }
1365
+ .sv-grid-chart-radar-dot {
1366
+ transition: r 100ms ease;
1367
+ cursor: default;
1368
+ }
1369
+ .sv-grid-chart-radar-dot:hover { r: 4.5; }
1370
+ /* Calendar heatmap cells. Blank cells get a faint outline so the grid
1371
+ still reads on days with no data. */
1372
+ .sv-grid-chart-calendar-cell {
1373
+ stroke: var(--sg-border, #e2e8f0);
1374
+ stroke-width: 0.5;
1375
+ transition: opacity 100ms ease;
1376
+ cursor: default;
1377
+ }
1378
+ .sv-grid-chart-calendar-cell.is-blank { stroke-opacity: 0.5; }
1379
+ .sv-grid-chart-calendar-cell:hover { opacity: 0.85; }
1380
+ .sv-grid-chart-calendar-legend {
1381
+ stroke: var(--sg-border, #cbd5e1);
1382
+ stroke-width: 0.5;
1383
+ }
1384
+ /* Gauge centre readout. */
1385
+ .sv-grid-chart-gauge-value {
1386
+ fill: var(--sg-fg, #0f172a);
1387
+ font-size: 22px;
1388
+ font-weight: 800;
1389
+ font-family: inherit;
1390
+ pointer-events: none;
1391
+ }
1392
+ /* Gauge tick marks: faint minors, stronger majors. */
1393
+ .sv-grid-chart-gauge-tick {
1394
+ stroke: var(--sg-muted, #94a3b8);
1395
+ stroke-width: 1;
1396
+ opacity: 0.4;
1397
+ }
1398
+ .sv-grid-chart-gauge-tick.is-major {
1399
+ stroke-width: 2;
1400
+ opacity: 0.7;
1401
+ }
1402
+ /* Pointer needle + hub. */
1403
+ .sv-grid-chart-gauge-needle {
1404
+ stroke: var(--sg-bg, #ffffff);
1405
+ stroke-width: 0.75;
1406
+ stroke-linejoin: round;
1407
+ }
1408
+ .sv-grid-chart-gauge-hub {
1409
+ fill: var(--sg-fg, #0f172a);
1410
+ stroke: var(--sg-bg, #ffffff);
1411
+ stroke-width: 2;
1412
+ }
1413
+ .sv-grid-chart-gauge-hub-dot {
1414
+ fill: var(--sg-bg, #ffffff);
1415
+ }
1416
+ /* Tree-map cells. White separators give the squarified rects breathing
1417
+ room without distracting from the data. */
1418
+ .sv-grid-chart-treemap-cell {
1419
+ stroke: var(--sg-bg, #ffffff);
1420
+ stroke-width: 1;
1421
+ transition: opacity 100ms ease;
1422
+ cursor: default;
1423
+ }
1424
+ .sv-grid-chart-svg.is-clickable .sv-grid-chart-treemap-cell { cursor: pointer; }
1425
+ .sv-grid-chart-treemap-cell:hover { opacity: 0.88; }
1426
+ .sv-grid-chart-treemap-label {
1427
+ font-size: 11px;
1428
+ font-weight: 600;
1429
+ font-family: inherit;
1430
+ pointer-events: none;
1431
+ }
1432
+ .sv-grid-chart-treemap-value {
1433
+ font-size: 10px;
1434
+ font-family: inherit;
1435
+ pointer-events: none;
1436
+ }
1437
+ /* Sankey nodes + links. Links use stroke not fill so per-ribbon width
1438
+ comes from stroke-width directly. */
1439
+ .sv-grid-chart-sankey-node {
1440
+ transition: opacity 100ms ease;
1441
+ cursor: default;
1442
+ }
1443
+ .sv-grid-chart-sankey-node:hover { opacity: 0.88; }
1444
+ .sv-grid-chart-sankey-link {
1445
+ transition: stroke-opacity 100ms ease;
1446
+ cursor: default;
1447
+ }
1448
+ .sv-grid-chart-sankey-link:hover { stroke-opacity: 0.6; }
1449
+ .sv-grid-chart-sankey-label {
1450
+ fill: var(--sg-fg, #0f172a);
1451
+ font-size: 11px;
1452
+ font-weight: 600;
1453
+ font-family: inherit;
1454
+ pointer-events: none;
1455
+ }
1456
+ /* Brush mini-map: a compact secondary chart with a draggable window. */
1457
+ .sv-grid-chart-brush {
1458
+ display: block;
1459
+ width: 100%;
1460
+ height: auto;
1461
+ margin-top: 4px;
1462
+ border-top: 1px solid var(--sg-border, #e2e8f0);
1463
+ user-select: none;
1464
+ touch-action: none;
1465
+ }
1466
+ .sv-grid-chart-brush-mask {
1467
+ fill: var(--sg-bg, #ffffff);
1468
+ fill-opacity: 0.65;
1469
+ pointer-events: none;
1470
+ }
1471
+ .sv-grid-chart-brush-window {
1472
+ fill: var(--sg-accent, #2563eb);
1473
+ fill-opacity: 0.10;
1474
+ stroke: var(--sg-accent, #2563eb);
1475
+ stroke-width: 1;
1476
+ cursor: grab;
1477
+ }
1478
+ .sv-grid-chart-brush-window:active { cursor: grabbing; }
1479
+ .sv-grid-chart-brush-handle {
1480
+ fill: var(--sg-accent, #2563eb);
1481
+ fill-opacity: 0.4;
1482
+ cursor: ew-resize;
1483
+ }
1484
+ .sv-grid-chart-brush-handle:hover { fill-opacity: 0.7; }
486
1485
  .sv-grid-chart-scatter {
487
1486
  transition: opacity 0.2s ease;
488
1487
  stroke-width: 1;
@@ -541,6 +1540,44 @@
541
1540
  pointer-events: none;
542
1541
  }
543
1542
 
1543
+ /* Toolbar above the chart: reset-zoom, PNG / SVG / Copy. */
1544
+ .sv-grid-chart-toolbar {
1545
+ display: flex;
1546
+ justify-content: flex-end;
1547
+ gap: 4px;
1548
+ flex-wrap: wrap;
1549
+ }
1550
+ .sv-grid-chart-tool {
1551
+ display: inline-flex;
1552
+ align-items: center;
1553
+ gap: 4px;
1554
+ border: 1px solid var(--sg-border, #cbd5e1);
1555
+ background: var(--sg-bg, #ffffff);
1556
+ color: var(--sg-fg, #1e293b);
1557
+ font-size: 11px;
1558
+ font-weight: 500;
1559
+ padding: 3px 8px;
1560
+ border-radius: 4px;
1561
+ cursor: pointer;
1562
+ transition: background-color 100ms ease, border-color 100ms ease;
1563
+ }
1564
+ .sv-grid-chart-tool:hover {
1565
+ background: var(--sg-row-hover-bg, #f1f5f9);
1566
+ border-color: var(--sg-accent, #2563eb);
1567
+ }
1568
+
1569
+ /* Drag-to-zoom: translucent accent rectangle over the plot. */
1570
+ .sv-grid-chart-svg.is-zoomable { cursor: crosshair; }
1571
+ .sv-grid-chart-svg.is-dragging { cursor: crosshair; user-select: none; }
1572
+ .sv-grid-chart-zoom-rect {
1573
+ fill: var(--sg-accent, #2563eb);
1574
+ fill-opacity: 0.12;
1575
+ stroke: var(--sg-accent, #2563eb);
1576
+ stroke-width: 1;
1577
+ stroke-dasharray: 3 3;
1578
+ pointer-events: none;
1579
+ }
1580
+
544
1581
  .sv-grid-chart-tooltip {
545
1582
  position: absolute;
546
1583
  z-index: 10;