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