@svgrid/grid 3.0.1 → 3.0.3
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.
- package/dist/GridFooter.svelte +14 -5
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/GridMenus.svelte +18 -10
- package/dist/GridMenus.svelte.d.ts +2 -1
- package/dist/SvGrid.controller.svelte.d.ts +17 -0
- package/dist/SvGrid.controller.svelte.js +128 -14
- package/dist/SvGrid.css +79 -10
- package/dist/SvGrid.svelte +191 -186
- package/dist/SvGrid.types.d.ts +42 -1
- package/dist/SvGridChart.svelte +571 -41
- package/dist/SvGridChart.svelte.d.ts +63 -1
- package/dist/SvGridChartPanel.svelte +169 -21
- package/dist/SvGridChartView.svelte +9 -0
- package/dist/SvGridChartView.svelte.d.ts +7 -0
- package/dist/SvGroupCell.svelte +28 -3
- package/dist/SvGroupCell.svelte.d.ts +8 -0
- package/dist/SvRowGroupPanel.svelte +20 -4
- package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
- package/dist/ai.d.ts +11 -2
- package/dist/ai.js +51 -7
- package/dist/cdn/GridMenus-CHOMnW-c.js +602 -0
- package/dist/cdn/{GridMenus-DFB6iwF8.js → GridMenus-DhdPF700.js} +152 -150
- package/dist/cdn/SvGridChart-1PMSp2LZ.js +1481 -0
- package/dist/cdn/SvGridChart-D02avhyr.js +1480 -0
- package/dist/cdn/SvGridChartPanel-DJnViOv4.js +834 -0
- package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +810 -0
- package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-C--HhdUo.js} +5 -3
- package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-KyyFG-wY.js} +5 -3
- package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
- package/dist/cdn/chart-DLg3_zTQ.js +1652 -0
- package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
- package/dist/cdn/{src-C8QjSaq-.js → src-Bugf5XlI.js} +3608 -3414
- package/dist/cdn/{src-D0hO2eYN.js → src-D6XvA1Ij.js} +8617 -8423
- package/dist/cdn/svgrid.js +16 -15
- package/dist/cdn/svgrid.svelte-external.js +16 -15
- package/dist/cell-formatting.d.ts +7 -0
- package/dist/cell-formatting.js +7 -1
- package/dist/chart-export.d.ts +23 -2
- package/dist/chart-export.js +80 -6
- package/dist/chart.d.ts +410 -18
- package/dist/chart.js +824 -24
- package/dist/grid-icons.d.ts +49 -0
- package/dist/grid-icons.js +86 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/package.json +11 -11
- package/src/GridFooter.svelte +14 -5
- package/src/GridMenus.svelte +18 -10
- package/src/SvGrid.controller.svelte.ts +146 -13
- package/src/SvGrid.css +79 -10
- package/src/SvGrid.svelte +191 -186
- package/src/SvGrid.types.ts +42 -1
- package/src/SvGridChart.svelte +571 -41
- package/src/SvGridChart.test.ts +243 -1
- package/src/SvGridChartPanel.svelte +169 -21
- package/src/SvGridChartView.svelte +9 -0
- package/src/SvGroupCell.svelte +28 -3
- package/src/SvRowGroupPanel.svelte +20 -4
- package/src/ai.test.ts +60 -1
- package/src/ai.ts +59 -6
- package/src/cell-formatting.ts +7 -1
- package/src/chart-export.test.ts +112 -0
- package/src/chart-export.ts +76 -8
- package/src/chart.test.ts +830 -1
- package/src/chart.ts +1053 -32
- package/src/grid-icons.test.ts +46 -0
- package/src/grid-icons.ts +184 -0
- package/src/icon-overrides.grid.test.ts +188 -0
- package/src/icon-seam.test.ts +100 -0
- package/src/index.ts +19 -0
- package/src/svgrid.charting.test.ts +120 -0
- package/dist/cdn/GridMenus-CYzOvmNQ.js +0 -600
- package/dist/cdn/SvGridChart-BEJmNNx9.js +0 -1203
- package/dist/cdn/SvGridChart-BwVos976.js +0 -1202
- package/dist/cdn/SvGridChartPanel-BOknOkrP.js +0 -561
- package/dist/cdn/SvGridChartPanel-D2PjII4O.js +0 -585
- package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
package/src/SvGridChart.svelte
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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(
|
|
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 = () => {
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
{#
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
<!--
|
|
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="
|
|
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
|
-
|
|
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>
|
|
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
|
|
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>
|