@svgrid/grid 3.0.1 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/GridFooter.svelte +14 -5
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/GridMenus.svelte +18 -10
  4. package/dist/GridMenus.svelte.d.ts +2 -1
  5. package/dist/SvGrid.controller.svelte.d.ts +17 -0
  6. package/dist/SvGrid.controller.svelte.js +128 -14
  7. package/dist/SvGrid.css +79 -10
  8. package/dist/SvGrid.svelte +191 -186
  9. package/dist/SvGrid.types.d.ts +42 -1
  10. package/dist/SvGridChart.svelte +571 -41
  11. package/dist/SvGridChart.svelte.d.ts +63 -1
  12. package/dist/SvGridChartPanel.svelte +169 -21
  13. package/dist/SvGridChartView.svelte +9 -0
  14. package/dist/SvGridChartView.svelte.d.ts +7 -0
  15. package/dist/SvGroupCell.svelte +28 -3
  16. package/dist/SvGroupCell.svelte.d.ts +8 -0
  17. package/dist/SvRowGroupPanel.svelte +20 -4
  18. package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
  19. package/dist/ai.d.ts +11 -2
  20. package/dist/ai.js +51 -7
  21. package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
  22. package/dist/cdn/{GridMenus-DFB6iwF8.js → GridMenus-M2XP9t0_.js} +152 -150
  23. package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
  24. package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
  25. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
  26. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
  27. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-CxfakxR0.js} +5 -3
  28. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-Dm_YEx9L.js} +5 -3
  29. package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
  30. package/dist/cdn/chart-CKJRCPOk.js +1652 -0
  31. package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
  32. package/dist/cdn/{src-C8QjSaq-.js → src-BJ2opd0j.js} +3608 -3414
  33. package/dist/cdn/{src-D0hO2eYN.js → src-DNbz94Ct.js} +8617 -8423
  34. package/dist/cdn/svgrid.js +16 -15
  35. package/dist/cdn/svgrid.svelte-external.js +16 -15
  36. package/dist/cell-formatting.d.ts +7 -0
  37. package/dist/cell-formatting.js +7 -1
  38. package/dist/chart-export.d.ts +23 -2
  39. package/dist/chart-export.js +80 -6
  40. package/dist/chart.d.ts +410 -18
  41. package/dist/chart.js +824 -24
  42. package/dist/grid-icons.d.ts +49 -0
  43. package/dist/grid-icons.js +86 -0
  44. package/dist/index.d.ts +3 -2
  45. package/dist/index.js +3 -2
  46. package/package.json +11 -11
  47. package/src/GridFooter.svelte +14 -5
  48. package/src/GridMenus.svelte +18 -10
  49. package/src/SvGrid.controller.svelte.ts +146 -13
  50. package/src/SvGrid.css +79 -10
  51. package/src/SvGrid.svelte +191 -186
  52. package/src/SvGrid.types.ts +42 -1
  53. package/src/SvGridChart.svelte +571 -41
  54. package/src/SvGridChart.test.ts +243 -1
  55. package/src/SvGridChartPanel.svelte +169 -21
  56. package/src/SvGridChartView.svelte +9 -0
  57. package/src/SvGroupCell.svelte +28 -3
  58. package/src/SvRowGroupPanel.svelte +20 -4
  59. package/src/ai.test.ts +60 -1
  60. package/src/ai.ts +59 -6
  61. package/src/cell-formatting.ts +7 -1
  62. package/src/chart-export.test.ts +112 -0
  63. package/src/chart-export.ts +76 -8
  64. package/src/chart.test.ts +830 -1
  65. package/src/chart.ts +1053 -32
  66. package/src/grid-icons.test.ts +46 -0
  67. package/src/grid-icons.ts +184 -0
  68. package/src/icon-overrides.grid.test.ts +188 -0
  69. package/src/icon-seam.test.ts +100 -0
  70. package/src/index.ts +19 -0
  71. package/src/svgrid.charting.test.ts +120 -0
  72. package/dist/cdn/GridMenus-CYzOvmNQ.js +0 -600
  73. package/dist/cdn/SvGridChart-BEJmNNx9.js +0 -1203
  74. package/dist/cdn/SvGridChart-BwVos976.js +0 -1202
  75. package/dist/cdn/SvGridChartPanel-BOknOkrP.js +0 -561
  76. package/dist/cdn/SvGridChartPanel-D2PjII4O.js +0 -585
  77. package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
@@ -7,7 +7,8 @@
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 { buildChart, DEFAULT_PALETTE, formatChartValue, type ChartSpec, type ChartSelection } from './chart'
10
+ import type { Snippet } from 'svelte'
11
+ import { buildChart, chartScales, DEFAULT_PALETTE, formatChartValue, sliceChartWindow, type ChartSpec, type ChartSelection, type ChartGeometry, type ChartScales } from './chart'
11
12
 
12
13
  type Props = {
13
14
  spec: ChartSpec
@@ -42,6 +43,67 @@
42
43
  * fit exactly - used by the docked panel to size the chart to its body. */
43
44
  width?: number
44
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
45
107
  }
46
108
  let {
47
109
  spec,
@@ -57,14 +119,22 @@
57
119
  toolbar,
58
120
  width,
59
121
  height,
122
+ underlay,
123
+ overlay,
124
+ annotatable = false,
125
+ onAnnotate,
126
+ onAnnotationRemove,
60
127
  }: Props = $props()
61
- const showToolbar = $derived(toolbar ?? (zoomable || !!onDrill))
128
+ const showToolbar = $derived(toolbar ?? (zoomable || !!onDrill || annotatable))
129
+ /** Annotate mode is off until the reader asks for it: it takes over the plot
130
+ * click, which is the drill gesture the rest of the time. */
131
+ let annotating = $state(false)
62
132
 
63
133
  const fmt = (v: number) =>
64
134
  formatValue
65
135
  ? formatValue(v)
66
136
  : spec.valueFormat
67
- ? formatChartValue(v, spec.valueFormat)
137
+ ? formatChartValue(v, spec.valueFormat, spec)
68
138
  : Number.isFinite(v)
69
139
  ? v.toLocaleString(undefined, { maximumFractionDigits: 2 })
70
140
  : String(v)
@@ -72,9 +142,16 @@
72
142
  let hidden = $state(new Set<string>())
73
143
  // Isolate: double-clicking a legend chip shows ONLY that series/slice.
74
144
  let isolated = $state<string | null>(null)
145
+ // Series colours read from --sg-chart-1..8. Declared here because the
146
+ // palette derivation below reads it, and a rune used above its declaration
147
+ // is an error rather than a hoist. Filled by the theme effect further down.
148
+ let tokenPalette = $state<string[] | null>(null)
149
+
75
150
  // Hover-dim: hovering a legend chip dims the other series (visual only).
76
151
  let dimmed = $state<string | null>(null)
77
- const palette = $derived(spec.palette ?? DEFAULT_PALETTE)
152
+ // An explicit `spec.palette` is the author speaking about THIS chart, so it
153
+ // outranks the theme's tokens, which in turn outrank the built-in colours.
154
+ const palette = $derived(spec.palette ?? tokenPalette ?? DEFAULT_PALETTE)
78
155
  const uid = `svgc-${Math.random().toString(36).slice(2, 8)}`
79
156
 
80
157
  const coloredSeries = $derived(
@@ -153,6 +230,9 @@
153
230
  return Math.max(0, Math.min(visibleSpec.categories.length - 1, idx))
154
231
  }
155
232
  function onZoomDown(e: PointerEvent) {
233
+ // Annotate mode takes the plot pointer over. Without this a click to pin an
234
+ // annotation also starts a zoom drag, and the reader gets both.
235
+ if (annotating) return
156
236
  if (!zoomable || !isCartesian) return
157
237
  const p = svgPoint(e)
158
238
  if (!p) return
@@ -188,6 +268,15 @@
188
268
  if (zoomable && isZoomed) resetZoom()
189
269
  }
190
270
 
271
+ // Candles are cartesian, and the `isCartesian` negative list below already
272
+ // covers them, so they inherit gridlines, the crosshair, category hit bands,
273
+ // keyboard nav, drag-zoom and double-click reset with no new wiring.
274
+ // Declared up here because the brush spec reads it, and a `$derived` used
275
+ // above its declaration is an error rather than a hoist.
276
+ const isCandle = $derived(spec.type === 'candlestick' || spec.type === 'ohlc')
277
+ /** OHLC draws ticks off a vertical; candlestick draws a body and a wick. */
278
+ const isOhlcBars = $derived(spec.type === 'ohlc')
279
+
191
280
  // ---- Brush state -----------------------------------------------------
192
281
  // The brush is a compact second chart showing the FULL data range with
193
282
  // a translucent window over the visible slice. Dragging the body pans
@@ -202,7 +291,21 @@
202
291
  yAxisTitle: undefined, y2AxisTitle: undefined, xAxisTitle: undefined,
203
292
  referenceLines: undefined,
204
293
  annotations: undefined,
205
- series: coloredSeries.filter((s) => !effectiveHidden.has(s.label)),
294
+ // The mini-map draws candles as a close-price line: at brush scale a
295
+ // candle is a couple of pixels wide and reads as noise, while a close line
296
+ // is the shape a reader actually navigates by.
297
+ // A box gets the same treatment for the same reason: at brush scale it is
298
+ // a smear, while the median line is a shape you can navigate by.
299
+ type: isCandle ? 'line' : spec.type === 'boxplot' ? 'line' : spec.type,
300
+ series: coloredSeries
301
+ .filter((s) => !effectiveHidden.has(s.label))
302
+ .map((s) =>
303
+ s.ohlc || s.boxes
304
+ ? { ...s, ohlc: undefined, boxes: undefined, errors: undefined, type: 'line' as const }
305
+ : s.errors
306
+ ? { ...s, errors: undefined }
307
+ : s,
308
+ ),
206
309
  })
207
310
  const brushGeo = $derived(brushEligible ? buildChart(brushSpec) : null)
208
311
  /** The brush window in fractional [0..1] of the visible data range,
@@ -269,20 +372,12 @@
269
372
  }
270
373
  const visibleSeries = coloredSeries.filter((s) => !effectiveHidden.has(s.label))
271
374
  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
- }
375
+ // Narrow to the zoom window so buildChart re-spreads the visible slice
376
+ // across the full plot. The slicing itself lives in chart.ts: this used to
377
+ // be done by hand here and only covered categories / values / rowIds, so
378
+ // the confidence-band envelopes kept their full length and the band
379
+ // silently vanished as soon as anyone zoomed.
380
+ return sliceChartWindow({ ...spec, series: visibleSeries }, zoom.i0, zoom.i1)
286
381
  })
287
382
 
288
383
  // Heatmap / calendar cell fills are computed colors (not CSS), so they can't
@@ -290,9 +385,26 @@
290
385
  // chartEl) and hand buildChart a light/dark hint so low-value cells render as
291
386
  // "cold" rather than glaring white rectangles on a dark grid.
292
387
  let isDark = $state(false)
388
+ // Hand the RESOLVED palette down here rather than relying on per-series
389
+ // colours alone: buildChart also picks from `spec.palette` for the marks
390
+ // that have no series of their own (pie slices, treemap cells), and those
391
+ // should follow the theme tokens too.
293
392
  const geo = $derived(
294
- buildChart(width || height ? { ...visibleSpec, width, height } : visibleSpec, isDark ? 'dark' : 'light'),
393
+ buildChart(
394
+ { ...visibleSpec, palette, ...(width || height ? { width, height } : {}) },
395
+ isDark ? 'dark' : 'light',
396
+ ),
295
397
  )
398
+ /** What the `underlay` / `overlay` snippets are handed. Built from the
399
+ * laid-out geometry, so a custom mark shares the built-in scale exactly. */
400
+ const renderScales = $derived(chartScales(geo))
401
+ const renderCtx = $derived<ChartRenderContext>({
402
+ geo,
403
+ scales: renderScales,
404
+ xOf: renderScales?.xOf ?? null,
405
+ yOf: renderScales?.yOf ?? null,
406
+ })
407
+
296
408
  const isCartesian = $derived(
297
409
  spec.type !== 'pie' && spec.type !== 'heatmap' &&
298
410
  spec.type !== 'funnel' && spec.type !== 'radar' &&
@@ -314,6 +426,95 @@
314
426
  (isHorizontal ? geo.plot.h : geo.plot.w) / Math.max(1, visibleSpec.categories.length),
315
427
  )
316
428
 
429
+ /**
430
+ * A chart with more categories than the plot has room for.
431
+ *
432
+ * `buildChart` itself scales fine - 100k line points is about 140ms of pure
433
+ * maths. What does not scale is what the DOM is asked to hold: one `<circle>`
434
+ * per point, one hit `<rect>` per category whose `aria-label` costs a
435
+ * `catRows()` call AT RENDER TIME, and one screen-reader table row per
436
+ * category. That is roughly 300k nodes for a 100k-point series, and the chart
437
+ * stops responding long before the engine breaks a sweat.
438
+ *
439
+ * Below ~4px per category none of that machinery is buying anything anyway:
440
+ * the dots overlap into a smear that hides the line they are meant to mark,
441
+ * and a hit rect thinner than the pointer cannot be aimed at. So under the
442
+ * threshold the marks collapse to the line path, the hit rects collapse to one
443
+ * surface that computes the index from the cursor, and the table is capped.
444
+ */
445
+ const DENSE_SLOT_PX = 4
446
+ const dense = $derived(
447
+ isCartesian && !isScatter && !isHorizontal && slot < DENSE_SLOT_PX && visibleSpec.categories.length > 1,
448
+ )
449
+
450
+ /** Rows the screen-reader table will actually render before it is capped. */
451
+ const SR_ROW_CAP = 1000
452
+
453
+ /** Which category the cursor is over, in dense mode. Binary search over the
454
+ * first series' point x's, so a non-uniform (time) axis is handled too;
455
+ * uniform slot maths when there are no line points to search. */
456
+ /** Client px -> viewBox px. The svg scales to its container, so the two
457
+ * differ whenever the chart is not rendered at its natural size. */
458
+ function toViewBox(clientX: number, clientY: number, target: Element): { x: number; y: number } | null {
459
+ const svg = (target as SVGGraphicsElement).ownerSVGElement ?? (target as SVGSVGElement)
460
+ if (!svg || typeof svg.getBoundingClientRect !== 'function') return null
461
+ const box = svg.getBoundingClientRect()
462
+ if (!box.width || !box.height) return null
463
+ const vb = (svg as SVGSVGElement).viewBox?.baseVal
464
+ const sx = vb && vb.width ? vb.width / box.width : 1
465
+ const sy = vb && vb.height ? vb.height / box.height : 1
466
+ return {
467
+ x: (clientX - box.left) * sx + (vb ? vb.x : 0),
468
+ y: (clientY - box.top) * sy + (vb ? vb.y : 0),
469
+ }
470
+ }
471
+
472
+ function catAtClientX(clientX: number, target: Element): number | null {
473
+ const vp = toViewBox(clientX, 0, target)
474
+ if (!vp) return null
475
+ const x = vp.x
476
+ const pts = geo.lines[0]?.points
477
+ const n = visibleSpec.categories.length
478
+ if (!pts || pts.length !== n) {
479
+ const i = Math.floor((x - geo.plot.x) / slot)
480
+ return i >= 0 && i < n ? i : null
481
+ }
482
+ let lo = 0
483
+ let hi = pts.length - 1
484
+ while (lo < hi) {
485
+ const mid = (lo + hi) >> 1
486
+ if (pts[mid]!.x < x) lo = mid + 1
487
+ else hi = mid
488
+ }
489
+ // Land on the nearer of the two neighbours rather than always rounding up.
490
+ if (lo > 0 && Math.abs(pts[lo - 1]!.x - x) <= Math.abs(pts[lo]!.x - x)) lo -= 1
491
+ return lo
492
+ }
493
+
494
+ /** Focused category in dense mode, where there is one rect rather than n. */
495
+ let denseIndex = $state(0)
496
+ function onDenseKey(e: KeyboardEvent) {
497
+ const max = visibleSpec.categories.length - 1
498
+ const step = Math.max(1, Math.floor(max / 10))
499
+ let next = denseIndex
500
+ switch (e.key) {
501
+ case 'ArrowRight': case 'ArrowDown': next = Math.min(max, denseIndex + 1); break
502
+ case 'ArrowLeft': case 'ArrowUp': next = Math.max(0, denseIndex - 1); break
503
+ case 'Home': next = 0; break
504
+ case 'End': next = max; break
505
+ case 'PageDown': next = Math.min(max, denseIndex + step); break
506
+ case 'PageUp': next = Math.max(0, denseIndex - step); break
507
+ case 'Enter': case ' ':
508
+ e.preventDefault()
509
+ select(visibleSpec.categories[denseIndex] ?? '', '', 0, denseIndex)
510
+ return
511
+ default: return
512
+ }
513
+ e.preventDefault()
514
+ denseIndex = next
515
+ focusCat(e.currentTarget as Element, next)
516
+ }
517
+
317
518
  // Opacity for a series when another legend chip is being hovered.
318
519
  const dimOf = (seriesLabel: string) => (dimmed && dimmed !== seriesLabel ? 0.18 : 1)
319
520
 
@@ -341,6 +542,8 @@
341
542
  if (spec.type === 'gauge') return !geo.gauge
342
543
  if (spec.type === 'treemap') return geo.treemapCells.length === 0
343
544
  if (spec.type === 'sankey') return geo.sankeyNodes.length === 0
545
+ if (isCandle) return geo.candles.length === 0
546
+ if (spec.type === 'boxplot') return geo.boxes.length === 0
344
547
  return geo.bars.length === 0 && geo.lines.every((l) => l.points.every((p) => !p.defined))
345
548
  })
346
549
 
@@ -374,11 +577,30 @@
374
577
  for (const s of coloredSeries) for (const p of s.points ?? []) rows.push([s.label, fmt(p.x), fmt(p.y), p.r != null ? fmt(p.r) : ''])
375
578
  return { cols: ['Series', 'X', 'Y', 'Size'], rows }
376
579
  }
580
+ if (isCandle) {
581
+ // This IS the accessible version of the chart, so it carries the four
582
+ // real numbers rather than the closes `values` happens to hold.
583
+ const rows: string[][] = []
584
+ for (const s of coloredSeries) {
585
+ ;(s.ohlc ?? []).forEach((k, i) => {
586
+ if (!k) return
587
+ rows.push([spec.categories[i] ?? String(i), s.label, fmt(k.o), fmt(k.h), fmt(k.l), fmt(k.c)])
588
+ })
589
+ }
590
+ return { cols: ['Date', 'Series', 'Open', 'High', 'Low', 'Close'], rows }
591
+ }
377
592
  return {
378
593
  cols: ['Category', ...coloredSeries.map((s) => s.label)],
379
594
  rows: spec.categories.map((c, i) => [c, ...coloredSeries.map((s) => fmt(s.values[i] ?? 0))]),
380
595
  }
381
596
  })
597
+ /** Declared here rather than in the markup: `{@const}` is only legal as the
598
+ * immediate child of a block, and the table sits at the template top level.
599
+ * Below `srTable` because a `$derived` used above its declaration is an
600
+ * error, not a hoist. */
601
+ const srCapped = $derived(srTable.rows.length > SR_ROW_CAP)
602
+ const srRows = $derived(srCapped ? srTable.rows.slice(0, SR_ROW_CAP) : srTable.rows)
603
+
382
604
  function truncate(s: string, n = 12): string {
383
605
  return s.length > n ? s.slice(0, n - 1) + '…' : s
384
606
  }
@@ -405,10 +627,32 @@
405
627
  // Perceived luminance (0..255); below the midpoint reads as a dark surface.
406
628
  return 0.299 * r + 0.587 * g + 0.114 * b < 128
407
629
  }
630
+ /**
631
+ * Series colours declared as `--sg-chart-1` .. `--sg-chart-8`.
632
+ *
633
+ * The palette used to be a hard-coded array in `chart.ts`, which meant a
634
+ * theme could restyle every part of a chart except the data - the one part
635
+ * a brand actually cares about. It stays out of `chart.ts` because that
636
+ * module is deliberately DOM-free and SSR-safe, so the tokens are read here
637
+ * and handed down as an ordinary `palette`.
638
+ *
639
+ * Partial sets are honoured: declare two tokens and the rest fall back, so a
640
+ * theme can set a brand primary and secondary without inventing six more.
641
+ */
408
642
  $effect(() => {
409
643
  if (!chartEl || typeof window === 'undefined') return
410
644
  const el = chartEl
411
- const check = () => { isDark = surfaceIsDark(getComputedStyle(el).getPropertyValue('--sg-bg')) }
645
+ const check = () => {
646
+ const cs = getComputedStyle(el)
647
+ isDark = surfaceIsDark(cs.getPropertyValue('--sg-bg'))
648
+ const next: string[] = []
649
+ for (let i = 1; i <= 8; i += 1) {
650
+ const v = cs.getPropertyValue(`--sg-chart-${i}`).trim()
651
+ next.push(v || DEFAULT_PALETTE[i - 1]!)
652
+ }
653
+ const changed = !tokenPalette || next.some((c, i) => c !== tokenPalette![i])
654
+ if (changed) tokenPalette = next.some((c, i) => c !== DEFAULT_PALETTE[i]) ? next : null
655
+ }
412
656
  check()
413
657
  const obs = new MutationObserver(check)
414
658
  obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] })
@@ -428,10 +672,51 @@
428
672
 
429
673
  // Unified tooltip: every visible series' value at one category.
430
674
  function catRows(i: number): TipRow[] {
431
- return visibleSpec.series
432
- .map((s) => ({ label: s.label, color: (s as { color?: string }).color, value: s.values[i] }))
433
- .filter((r) => Number.isFinite(r.value))
434
- .map((r) => ({ label: r.label, color: r.color, value: fmt(r.value as number) }))
675
+ const rows: TipRow[] = []
676
+ for (const s of visibleSpec.series) {
677
+ const color = (s as { color?: string }).color
678
+ // A candle carries four numbers, so it gets four rows. Doing it here
679
+ // rather than in a parallel tooltip path means the crosshair, the
680
+ // keyboard focus tooltip and the hit rect's aria-label all follow, and a
681
+ // volume series on the right axis still lists itself underneath.
682
+ const k = s.ohlc?.[i]
683
+ if (k) {
684
+ rows.push({ label: `${s.label} O`, color, value: fmt(k.o) })
685
+ rows.push({ label: `${s.label} H`, value: fmt(k.h) })
686
+ rows.push({ label: `${s.label} L`, value: fmt(k.l) })
687
+ rows.push({ label: `${s.label} C`, value: fmt(k.c) })
688
+ continue
689
+ }
690
+ // A box is a five-number summary, and reading it off the picture is
691
+ // exactly what a tooltip is for. Same reasoning as the candle above:
692
+ // one place, so crosshair / keyboard / aria-label all agree.
693
+ const b = s.boxes?.[i]
694
+ if (b) {
695
+ rows.push({ label: `${s.label} max`, color, value: fmt(b.max) })
696
+ rows.push({ label: `${s.label} Q3`, value: fmt(b.q3) })
697
+ rows.push({ label: `${s.label} median`, value: fmt(b.median) })
698
+ rows.push({ label: `${s.label} Q1`, value: fmt(b.q1) })
699
+ rows.push({ label: `${s.label} min`, value: fmt(b.min) })
700
+ if (b.outliers?.length) {
701
+ rows.push({ label: `${s.label} outliers`, value: String(b.outliers.length) })
702
+ }
703
+ continue
704
+ }
705
+ const v = s.values[i]
706
+ if (Number.isFinite(v)) {
707
+ rows.push({ label: s.label, color, value: fmt(v as number) })
708
+ // An error bar is only worth drawing if you can read what it means.
709
+ const e = s.errors?.[i]
710
+ if (e != null) {
711
+ const lo = typeof e === 'number' ? (v as number) - Math.abs(e) : Math.min(e.lo, e.hi)
712
+ const hi = typeof e === 'number' ? (v as number) + Math.abs(e) : Math.max(e.lo, e.hi)
713
+ if (Number.isFinite(lo) && Number.isFinite(hi)) {
714
+ rows.push({ label: `${s.label} range`, value: `${fmt(lo)} - ${fmt(hi)}` })
715
+ }
716
+ }
717
+ }
718
+ }
719
+ return rows
435
720
  }
436
721
  function hoverCat(clientX: number, clientY: number, i: number) {
437
722
  activeCat = i
@@ -473,6 +758,18 @@
473
758
  }
474
759
 
475
760
  function select(category: string, series: string, value: number, catIndex?: number) {
761
+ // Annotate mode takes over the category gesture rather than adding one of
762
+ // its own. That is what makes it work from the keyboard: the hit rects
763
+ // already have a roving tabindex and fire this on Enter, so pinning a note
764
+ // is Tab, arrow, Enter - with no extra handler and no click-only feature.
765
+ if (annotating && onAnnotate && catIndex !== undefined) {
766
+ // A CATEGORY click carries no value - `select(cat, '', 0, i)` passes zero
767
+ // because a drill only needs the category - so read the real one off the
768
+ // first series. A click on a specific bar or slice already has one.
769
+ const picked = series ? value : visibleSpec.series[0]?.values[catIndex]
770
+ onAnnotate({ category, index: catIndex, value: Number.isFinite(picked) ? (picked as number) : 0 })
771
+ return
772
+ }
476
773
  // Collect contributing row ids when the spec carries them. For a
477
774
  // category click (series === ''), flatten across every series; for a
478
775
  // single-series click, return just that series' bucket.
@@ -641,6 +938,19 @@
641
938
  Reset zoom
642
939
  </button>
643
940
  {/if}
941
+ {#if annotatable}
942
+ <button
943
+ type="button"
944
+ class="sv-grid-chart-tool"
945
+ class:is-on={annotating}
946
+ aria-pressed={annotating}
947
+ onclick={() => (annotating = !annotating)}
948
+ title={annotating ? 'Click the plot to pin a note, or a marker to remove it' : 'Pin notes on the chart'}
949
+ >
950
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="11" r="3"/><path d="M12 14v8"/></svg>
951
+ Annotate
952
+ </button>
953
+ {/if}
644
954
  <button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('png')} title="Download PNG">PNG</button>
645
955
  <button type="button" class="sv-grid-chart-tool" onclick={() => downloadImage('svg')} title="Download SVG">SVG</button>
646
956
  <button type="button" class="sv-grid-chart-tool" onclick={copyAsImage} title="Copy chart as image">
@@ -653,7 +963,8 @@
653
963
  class="sv-grid-chart-svg"
654
964
  class:is-interactive={interactive}
655
965
  class:is-clickable={!!onSelect || !!onDrill}
656
- class:is-zoomable={zoomable && isCartesian}
966
+ class:is-zoomable={zoomable && isCartesian && !annotating}
967
+ class:is-annotating={annotating}
657
968
  class:is-dragging={!!dragStart}
658
969
  viewBox={`0 0 ${geo.width} ${geo.height}`}
659
970
  width="100%"
@@ -759,17 +1070,31 @@
759
1070
  <path class="sv-grid-chart-area" d={line.areaPath} fill={seriesFill[line.label] ?? line.color} fill-opacity="0.15" stroke="none" />
760
1071
  {/if}
761
1072
  <path class="sv-grid-chart-linepath" d={line.path} fill="none" stroke={line.color} stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
762
- {#each line.points as pt, pi (pi)}
763
- {#if pt.defined}
764
- <circle class="sv-grid-chart-dot" class:is-active={activeCat === pi} cx={pt.x} cy={pt.y} r={activeCat === pi ? 4 : 3} fill={line.color} />
765
- {#if dataLabels}
766
- <text class="sv-grid-chart-datalabel" x={pt.x} y={pt.y - 7} text-anchor="middle">{fmt(pt.value)}</text>
767
- {/if}
1073
+ {#if dense}
1074
+ <!-- Below ~4px a category the dots touch, so they stop marking the
1075
+ points and start hiding the line. Only the hovered one is drawn,
1076
+ which is the one a reader is actually asking about. -->
1077
+ {@const pt = line.points[activeCat ?? -1]}
1078
+ {#if pt?.defined}
1079
+ <circle class="sv-grid-chart-dot is-active" cx={pt.x} cy={pt.y} r="4" fill={line.color} />
768
1080
  {/if}
769
- {/each}
1081
+ {:else}
1082
+ {#each line.points as pt, pi (pi)}
1083
+ {#if pt.defined}
1084
+ <circle class="sv-grid-chart-dot" class:is-active={activeCat === pi} cx={pt.x} cy={pt.y} r={activeCat === pi ? 4 : 3} fill={line.color} />
1085
+ {#if dataLabels}
1086
+ <text class="sv-grid-chart-datalabel" x={pt.x} y={pt.y - 7} text-anchor="middle">{fmt(pt.value)}</text>
1087
+ {/if}
1088
+ {/if}
1089
+ {/each}
1090
+ {/if}
770
1091
  </g>
771
1092
  {/each}
772
1093
 
1094
+ <!-- Custom-series seam, beneath the built-in marks: shaded regions, bands,
1095
+ anything the bars should sit on top of. -->
1096
+ {#if underlay}{@render underlay(renderCtx)}{/if}
1097
+
773
1098
  {#each geo.bars as bar, bi (bi)}
774
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)}`} />
775
1100
  {#if dataLabels && isHorizontal && bar.w > 18}
@@ -779,6 +1104,42 @@
779
1104
  {/if}
780
1105
  {/each}
781
1106
 
1107
+ <!-- Candles paint above gridlines and any volume bars, below the reference
1108
+ lines, annotations, crosshair and hit layer. Hollow-up / filled-down
1109
+ rather than two fills: direction then reads from the shape as well as
1110
+ the hue, which is the same reason this chart ships pattern fills. -->
1111
+ {#each geo.candles as k, ki (ki)}
1112
+ {#if isOhlcBars}
1113
+ <line class="sv-grid-chart-ohlc" x1={k.xCenter} y1={k.yHigh} x2={k.xCenter} y2={k.yLow} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
1114
+ <line class="sv-grid-chart-ohlc" x1={k.x} y1={k.yOpen} x2={k.xCenter} y2={k.yOpen} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
1115
+ <line class="sv-grid-chart-ohlc" x1={k.xCenter} y1={k.yClose} x2={k.x + k.w} y2={k.yClose} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
1116
+ {:else}
1117
+ <line class="sv-grid-chart-wick" x1={k.xCenter} y1={k.yHigh} x2={k.xCenter} y2={k.yLow} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
1118
+ <rect class="sv-grid-chart-candle" x={k.x} y={k.bodyY} width={k.w} height={k.bodyH} fill={k.up ? 'none' : k.color} stroke={k.color} style={`opacity:${dimOf(k.series)}`} />
1119
+ {/if}
1120
+ {/each}
1121
+
1122
+ <!-- Box plots. The median is a heavier rule than the box outline because it
1123
+ is the number people actually read; the whisker caps are drawn at the
1124
+ box width so the shape stays legible when the slot is narrow. -->
1125
+ {#each geo.boxes as b, bi (bi)}
1126
+ <line class="sv-grid-chart-whisker" x1={b.xCenter} y1={b.yMax} x2={b.xCenter} y2={b.yMin} stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
1127
+ <line class="sv-grid-chart-whisker" x1={b.x + b.w * 0.25} y1={b.yMax} x2={b.x + b.w * 0.75} y2={b.yMax} stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
1128
+ <line class="sv-grid-chart-whisker" x1={b.x + b.w * 0.25} y1={b.yMin} x2={b.x + b.w * 0.75} y2={b.yMin} stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
1129
+ <rect class="sv-grid-chart-box" x={b.x} y={b.boxY} width={b.w} height={b.boxH} fill={b.color} fill-opacity="0.25" stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
1130
+ <line class="sv-grid-chart-median" x1={b.x} y1={b.yMedian} x2={b.x + b.w} y2={b.yMedian} stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
1131
+ {#each b.outliers as o, oi (oi)}
1132
+ <circle class="sv-grid-chart-outlier" cx={b.xCenter} cy={o.y} r="2" fill="none" stroke={b.color} style={`opacity:${dimOf(b.series)}`} />
1133
+ {/each}
1134
+ {/each}
1135
+
1136
+ <!-- Error bars, on top of the mark they annotate. -->
1137
+ {#each geo.errorBars as eb, ei (ei)}
1138
+ <line class="sv-grid-chart-errorbar" x1={eb.xCenter} y1={eb.yHi} x2={eb.xCenter} y2={eb.yLo} stroke={eb.color} style={`opacity:${dimOf(eb.series)}`} />
1139
+ <line class="sv-grid-chart-errorbar" x1={eb.xCenter - eb.cap} y1={eb.yHi} x2={eb.xCenter + eb.cap} y2={eb.yHi} stroke={eb.color} style={`opacity:${dimOf(eb.series)}`} />
1140
+ <line class="sv-grid-chart-errorbar" x1={eb.xCenter - eb.cap} y1={eb.yLo} x2={eb.xCenter + eb.cap} y2={eb.yLo} stroke={eb.color} style={`opacity:${dimOf(eb.series)}`} />
1141
+ {/each}
1142
+
782
1143
  {#each geo.scatterPoints as dot, di (di)}
783
1144
  <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events -->
784
1145
  <circle
@@ -811,13 +1172,44 @@
811
1172
  <path class="sv-grid-chart-overlay" d={ovl.path} fill="none" stroke={ovl.color} stroke-width="2" stroke-dasharray="6 4" stroke-linejoin="round" stroke-linecap="round" />
812
1173
  {/each}
813
1174
 
1175
+ <!-- Custom-series seam, above the built-in marks. Deliberately BEFORE the
1176
+ crosshair and the hit layer below, so a custom mark cannot swallow the
1177
+ tooltips, the keyboard navigation or the drill clicks. -->
1178
+ {#if overlay}{@render overlay(renderCtx)}{/if}
1179
+
814
1180
  <!-- Pinned annotations: small marker + label. Placement nudges the
815
1181
  label position so it sits clear of the data point. -->
816
1182
  {#each geo.annotations as ann, ai (ann.label + ai)}
817
1183
  {@const dx = ann.placement === 'left' ? -8 : ann.placement === 'right' ? 8 : 0}
818
1184
  {@const dy = ann.placement === 'bottom' ? 14 : ann.placement === 'top' ? -8 : 0}
819
1185
  {@const anchor = ann.placement === 'left' ? 'end' : ann.placement === 'right' ? 'start' : 'middle'}
820
- <circle class="sv-grid-chart-annotation-marker" cx={ann.x} cy={ann.y} r="4" fill={ann.color} stroke="var(--sg-bg, #fff)" stroke-width="1.5" />
1186
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
1187
+ <circle
1188
+ class="sv-grid-chart-annotation-marker"
1189
+ class:is-removable={annotating && !!onAnnotationRemove}
1190
+ cx={ann.x}
1191
+ cy={ann.y}
1192
+ r={annotating && onAnnotationRemove ? 6 : 4}
1193
+ fill={ann.color}
1194
+ stroke="var(--sg-bg, #fff)"
1195
+ stroke-width="1.5"
1196
+ role={annotating && onAnnotationRemove ? 'button' : undefined}
1197
+ tabindex={annotating && onAnnotationRemove ? 0 : undefined}
1198
+ aria-label={annotating && onAnnotationRemove ? `Remove note: ${ann.label}` : undefined}
1199
+ onclick={(e) => {
1200
+ if (!annotating || !onAnnotationRemove) return
1201
+ // Or the same click also pins a NEW note where this one is.
1202
+ e.stopPropagation()
1203
+ onAnnotationRemove(ai)
1204
+ }}
1205
+ onkeydown={(e) => {
1206
+ if (!annotating || !onAnnotationRemove) return
1207
+ if (e.key !== 'Enter' && e.key !== ' ') return
1208
+ e.preventDefault()
1209
+ e.stopPropagation()
1210
+ onAnnotationRemove(ai)
1211
+ }}
1212
+ />
821
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>
822
1214
  {/each}
823
1215
 
@@ -987,9 +1379,12 @@
987
1379
  {/each}
988
1380
  <!-- Track (grey full arc) -->
989
1381
  <path d={g.trackPath} fill="none" stroke="var(--sg-border, #e2e8f0)" stroke-width="16" stroke-linecap="round" />
990
- <!-- Optional colored range bands (a thinner inner arc). -->
1382
+ <!-- Range bands: a thin inner reference ring. Deliberately quieter than
1383
+ the value arc - they say what the scale MEANS, while the value arc
1384
+ is what the dial is actually reading. Butt caps so adjacent bands
1385
+ meet cleanly instead of overlapping into each other's colour. -->
991
1386
  {#each g.rangePaths as band, bi (bi)}
992
- <path d={band.path} fill="none" stroke={band.color} stroke-width="6" stroke-linecap="round" opacity="0.85" />
1387
+ <path d={band.path} fill="none" stroke={band.color} stroke-width="4" stroke-linecap="butt" opacity="0.7" />
993
1388
  {/each}
994
1389
  <!-- Value arc, colored by the band the value sits in. -->
995
1390
  <path d={g.valuePath} fill="none" stroke={g.valueColor ?? 'var(--sg-accent, #2563eb)'} stroke-width="16" stroke-linecap="round" />
@@ -998,8 +1393,11 @@
998
1393
  <line x1={g.target.x1} y1={g.target.y1} x2={g.target.x2} y2={g.target.y2}
999
1394
  stroke="var(--sg-fg, #0f172a)" stroke-width="2.5" stroke-linecap="round" />
1000
1395
  {/if}
1001
- <!-- Pointer needle + center hub. -->
1002
- <path class="sv-grid-chart-gauge-needle" d={g.needle.path} fill={g.valueColor ?? 'var(--sg-accent, #2563eb)'} />
1396
+ <!-- Pointer needle + center hub. Deliberately NOT the value colour: the
1397
+ needle used to be filled with the same colour as the arc it sits on
1398
+ top of, so on a nearly-full dial it disappeared into it. An
1399
+ instrument pointer reads against the face, whatever the face is. -->
1400
+ <path class="sv-grid-chart-gauge-needle" d={g.needle.path} />
1003
1401
  <circle class="sv-grid-chart-gauge-hub" cx={g.cx} cy={g.cy} r={g.needle.hubR} />
1004
1402
  <circle class="sv-grid-chart-gauge-hub-dot" cx={g.cx} cy={g.cy} r="2.5" />
1005
1403
  <!-- Scale end labels + center readout. -->
@@ -1111,6 +1509,34 @@
1111
1509
  Vertical bands centre on the ACTUAL point x (which differs from
1112
1510
  slot math when xType is 'time'); their widths bisect the gap to
1113
1511
  neighbouring points so a mouse over a dot always selects it. -->
1512
+ {#if dense}
1513
+ <!-- One surface instead of n rects. At this density a per-category
1514
+ rect is thinner than the pointer, and each one costs a catRows()
1515
+ call for its aria-label at render time - so 100k categories meant
1516
+ 100k tooltip-row computations before anything was even hovered. -->
1517
+ <!-- svelte-ignore a11y_no_static_element_interactions a11y_mouse_events_have_key_events a11y_no_noninteractive_tabindex -->
1518
+ <rect
1519
+ class="sv-grid-chart-cat-hit"
1520
+ x={geo.plot.x}
1521
+ y={geo.plot.y}
1522
+ width={geo.plot.w}
1523
+ height={geo.plot.h}
1524
+ role={onSelect ? 'button' : 'img'}
1525
+ tabindex={interactive ? 0 : undefined}
1526
+ data-cat-index={denseIndex}
1527
+ data-dense="true"
1528
+ aria-label={`${visibleSpec.categories.length} points, ${visibleSpec.categories[0] ?? ''} to ${visibleSpec.categories[visibleSpec.categories.length - 1] ?? ''}. Arrow keys step through values.`}
1529
+ onmousemove={(e) => {
1530
+ const i = catAtClientX(e.clientX, e.currentTarget)
1531
+ if (i != null) { denseIndex = i; hoverCat(e.clientX, e.clientY, i) }
1532
+ }}
1533
+ onmouseleave={clearActive}
1534
+ onfocus={(e) => focusCat(e.currentTarget, denseIndex)}
1535
+ onblur={clearActive}
1536
+ onclick={() => select(visibleSpec.categories[denseIndex] ?? '', '', 0, denseIndex)}
1537
+ onkeydown={onDenseKey}
1538
+ />
1539
+ {:else}
1114
1540
  {#each visibleSpec.categories as cat, i (cat + i)}
1115
1541
  {@const pts = geo.lines[0]?.points}
1116
1542
  {@const px = pts?.[i]?.x}
@@ -1140,6 +1566,7 @@
1140
1566
  onkeydown={(e) => onCatKey(e, i)}
1141
1567
  />
1142
1568
  {/each}
1569
+ {/if}
1143
1570
  {/if}
1144
1571
 
1145
1572
  {#if dragRect}
@@ -1245,14 +1672,21 @@
1245
1672
  </div>
1246
1673
  {/if}
1247
1674
 
1248
- <!-- Visually-hidden data table: the same data for assistive technology. -->
1675
+ <!-- Visually-hidden data table: the same data for assistive technology.
1676
+ Capped, and the caption says so. A table with 100k rows is not an
1677
+ accessible version of anything - nobody traverses that with a screen
1678
+ reader, and rendering it was a third of the DOM cost of a large chart.
1679
+ Saying "first 1000 of 100000" is more use than silently truncating, and
1680
+ more honest than pretending the whole set is readable here. -->
1249
1681
  <table id={`${uid}-table`} class="sv-grid-chart-sr-only">
1250
- <caption>{spec.type} chart data</caption>
1682
+ <caption>
1683
+ {spec.type} chart data{#if srCapped}, first {SR_ROW_CAP} of {srTable.rows.length} rows{/if}
1684
+ </caption>
1251
1685
  <thead>
1252
1686
  <tr>{#each srTable.cols as c (c)}<th>{c}</th>{/each}</tr>
1253
1687
  </thead>
1254
1688
  <tbody>
1255
- {#each srTable.rows as row, ri (ri)}
1689
+ {#each srRows as row, ri (ri)}
1256
1690
  <tr>{#each row as cell, ci (ci)}<td>{cell}</td>{/each}</tr>
1257
1691
  {/each}
1258
1692
  </tbody>
@@ -1427,6 +1861,10 @@
1427
1861
  }
1428
1862
  /* Pointer needle + hub. */
1429
1863
  .sv-grid-chart-gauge-needle {
1864
+ fill: var(--sg-fg, #0f172a);
1865
+ /* A hairline of the background colour around the needle keeps it legible
1866
+ where it crosses the value arc, which is the same weight and often a
1867
+ similar tone. */
1430
1868
  stroke: var(--sg-bg, #ffffff);
1431
1869
  stroke-width: 0.75;
1432
1870
  stroke-linejoin: round;
@@ -1550,6 +1988,33 @@
1550
1988
  .sv-grid-chart-dot {
1551
1989
  transition: cx 0.3s ease, cy 0.3s ease;
1552
1990
  }
1991
+ .sv-grid-chart-candle {
1992
+ stroke-width: 1;
1993
+ transition: x 0.3s ease, y 0.3s ease, width 0.3s ease, height 0.3s ease;
1994
+ }
1995
+ .sv-grid-chart-wick,
1996
+ .sv-grid-chart-ohlc {
1997
+ stroke-width: 1;
1998
+ shape-rendering: crispEdges;
1999
+ }
2000
+ .sv-grid-chart-box {
2001
+ stroke-width: 1;
2002
+ transition: x 0.3s ease, y 0.3s ease, width 0.3s ease, height 0.3s ease;
2003
+ }
2004
+ .sv-grid-chart-whisker,
2005
+ .sv-grid-chart-errorbar {
2006
+ stroke-width: 1;
2007
+ shape-rendering: crispEdges;
2008
+ }
2009
+ /* The median is the number people read off a box plot, so it gets the
2010
+ weight. Everything else on the mark is 1px. */
2011
+ .sv-grid-chart-median {
2012
+ stroke-width: 2;
2013
+ shape-rendering: crispEdges;
2014
+ }
2015
+ .sv-grid-chart-outlier {
2016
+ stroke-width: 1;
2017
+ }
1553
2018
  .is-clickable .sv-grid-chart-cat-hit,
1554
2019
  .is-clickable .sv-grid-chart-slice {
1555
2020
  cursor: pointer;
@@ -1592,6 +2057,16 @@
1592
2057
  border-color: var(--sg-accent, #2563eb);
1593
2058
  }
1594
2059
 
2060
+ /* Annotate mode: the toggle reads as pressed, the plot as a target, and a
2061
+ marker as something you can click to take away. */
2062
+ .sv-grid-chart-tool.is-on {
2063
+ background: var(--sg-accent, #2563eb);
2064
+ border-color: var(--sg-accent, #2563eb);
2065
+ color: var(--sg-on-accent, #fff);
2066
+ }
2067
+ .sv-grid-chart-svg.is-annotating { cursor: copy; }
2068
+ .sv-grid-chart-annotation-marker.is-removable { cursor: pointer; }
2069
+
1595
2070
  /* Drag-to-zoom: translucent accent rectangle over the plot. */
1596
2071
  .sv-grid-chart-svg.is-zoomable { cursor: crosshair; }
1597
2072
  .sv-grid-chart-svg.is-dragging { cursor: crosshair; user-select: none; }
@@ -1713,4 +2188,59 @@
1713
2188
  display: inline-block;
1714
2189
  box-sizing: border-box;
1715
2190
  }
2191
+
2192
+ /* Enter animation. Marks fade in when they are first created; the position
2193
+ transitions above then handle everything after that, so a chart appears
2194
+ smoothly and moves smoothly rather than popping into place and then
2195
+ animating.
2196
+
2197
+ A fade rather than the wipe you would normally reach for. A wipe needs a
2198
+ clip-path over all the marks, and the marks here are direct children of the
2199
+ <svg> - grouping them is a large edit across every chart type, for a nicer
2200
+ version of something already pleasant. Noted rather than done.
2201
+
2202
+ `both` matters: it holds the from-state before the animation starts, so a
2203
+ mark cannot flash at full opacity for a frame first. It also means that
2204
+ when `animation: none` applies below, the rule stops applying entirely and
2205
+ the mark is simply painted - not stuck invisible. */
2206
+ @keyframes sv-grid-chart-in {
2207
+ from { opacity: 0; }
2208
+ to { opacity: 1; }
2209
+ }
2210
+ .sv-grid-chart-bar,
2211
+ .sv-grid-chart-linepath,
2212
+ .sv-grid-chart-area,
2213
+ .sv-grid-chart-slice,
2214
+ .sv-grid-chart-candle,
2215
+ .sv-grid-chart-box,
2216
+ .sv-grid-chart-funnel-seg,
2217
+ .sv-grid-chart-heatcell,
2218
+ .sv-grid-chart-treemap-cell {
2219
+ animation: sv-grid-chart-in 0.3s ease-out both;
2220
+ }
2221
+
2222
+ /* Reduced motion. Every other animated component in the kit honours this
2223
+ (SvCard, SvCircularProgress, SvCollapsible); the chart never did, so a
2224
+ reader who has asked the OS for less movement still got every bar, dot,
2225
+ candle and box sliding on each re-aggregation - and a chart re-aggregates
2226
+ on every filter keystroke, which is the worst case for it. */
2227
+ @media (prefers-reduced-motion: reduce) {
2228
+ .sv-grid-chart-bar,
2229
+ .sv-grid-chart-dot,
2230
+ .sv-grid-chart-candle,
2231
+ .sv-grid-chart-box {
2232
+ transition: none;
2233
+ }
2234
+ .sv-grid-chart-bar,
2235
+ .sv-grid-chart-linepath,
2236
+ .sv-grid-chart-area,
2237
+ .sv-grid-chart-slice,
2238
+ .sv-grid-chart-candle,
2239
+ .sv-grid-chart-box,
2240
+ .sv-grid-chart-funnel-seg,
2241
+ .sv-grid-chart-heatcell,
2242
+ .sv-grid-chart-treemap-cell {
2243
+ animation: none;
2244
+ }
2245
+ }
1716
2246
  </style>