@rokkit/chart 1.3.15 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/Plot.svelte +25 -9
  2. package/dist/PlotState.svelte.d.ts +1 -0
  3. package/dist/PlotState.svelte.js +8 -1
  4. package/dist/Spark.svelte +165 -0
  5. package/dist/Spark.svelte.d.ts +31 -0
  6. package/dist/SparkState.svelte.d.ts +138 -0
  7. package/dist/SparkState.svelte.js +331 -0
  8. package/dist/Sparkline.svelte +59 -232
  9. package/dist/Sparkline.svelte.d.ts +2 -0
  10. package/dist/charts/RadarChart.svelte +106 -0
  11. package/dist/charts/RadarChart.svelte.d.ts +43 -0
  12. package/dist/geoms/Area.svelte +17 -2
  13. package/dist/geoms/Area.svelte.d.ts +2 -0
  14. package/dist/geoms/Bar.svelte +2 -9
  15. package/dist/geoms/Highlight.svelte +2 -7
  16. package/dist/geoms/Line.svelte +2 -8
  17. package/dist/geoms/Point.svelte +2 -8
  18. package/dist/geoms/Radar.svelte +352 -0
  19. package/dist/geoms/Radar.svelte.d.ts +42 -0
  20. package/dist/geoms/lib/aesthetics.d.ts +14 -0
  21. package/dist/geoms/lib/aesthetics.js +21 -0
  22. package/dist/geoms/lib/areas.d.ts +9 -2
  23. package/dist/geoms/lib/areas.js +53 -8
  24. package/dist/geoms/lib/marks/area.d.ts +3 -0
  25. package/dist/geoms/lib/marks/area.js +11 -2
  26. package/dist/geoms/lib/marks/radar.d.ts +68 -0
  27. package/dist/geoms/lib/marks/radar.js +153 -0
  28. package/dist/index.d.ts +6 -0
  29. package/dist/index.js +7 -1
  30. package/dist/lib/brewing/marks/boxes.js +19 -30
  31. package/dist/lib/brewing/polar.d.ts +433 -0
  32. package/dist/lib/brewing/polar.js +584 -0
  33. package/dist/lib/plot/scales.js +8 -5
  34. package/dist/lib/preset.d.ts +1 -0
  35. package/dist/lib/preset.js +2 -1
  36. package/dist/lib/select.d.ts +3 -3
  37. package/dist/lib/select.js +1 -1
  38. package/package.json +1 -1
  39. package/dist/examples/BarChartExample.svelte +0 -78
  40. package/dist/examples/BarChartExample.svelte.d.ts +0 -18
  41. package/dist/lib/brewing/types.d.ts +0 -162
  42. package/dist/lib/brewing/types.js +0 -73
  43. package/dist/lib/xscale.d.ts +0 -11
  44. package/dist/lib/xscale.js +0 -31
@@ -21,6 +21,8 @@ type Props = {
21
21
  baseline?: number;
22
22
  highlight?: HighlightSelector | HighlightSelector[];
23
23
  trend?: TrendMethod | TrendMethod[];
24
+ /** Accessible name, forwarded to `Spark`. Omitted → generated from the series. */
25
+ label?: string;
24
26
  };
25
27
  declare const Sparkline: import("svelte").Component<Props, {}, "">;
26
28
  type Sparkline = ReturnType<typeof Sparkline>;
@@ -0,0 +1,106 @@
1
+ <script lang="ts">
2
+ import Plot from '../Plot.svelte'
3
+ import Radar from '../geoms/Radar.svelte'
4
+
5
+ type Row = Record<string, unknown>
6
+
7
+ type AxisSpec = {
8
+ key: string
9
+ label?: string
10
+ unit?: string
11
+ domain?: [number, number]
12
+ ticks?: number
13
+ tickLabels?: string[]
14
+ format?: (value: number) => unknown
15
+ weight?: number
16
+ }
17
+
18
+ type Props = {
19
+ data?: Row[]
20
+ /** Field naming which axis each row belongs to. */
21
+ axis?: string
22
+ /** Field holding the plotted magnitude. */
23
+ value?: string
24
+ /** Field distinguishing one polygon from another. */
25
+ series?: string
26
+ /** Axis order and per-axis config — bare names, full specs, or a mix. */
27
+ axes?: (string | AxisSpec)[]
28
+ pattern?: string
29
+ grid?: boolean
30
+ rings?: number
31
+ sharedDomain?: boolean
32
+ radiusScale?: 'linear' | 'sqrt' | 'auto'
33
+ alpha?: number
34
+ stat?: string
35
+ width?: number
36
+ height?: number
37
+ mode?: 'light' | 'dark'
38
+ title?: string
39
+ tooltip?: boolean | ((data: Row) => string)
40
+ /** Omit to let it default: on for 2+ series, off for one. */
41
+ legend?: boolean
42
+ /** Plot-level selection: receives the `buildSelectDetail` shape, not a bare row —
43
+ * same as AreaChart/BarChart. The geom's own `onselect` (a row) is reached by
44
+ * composing `<Plot><Radar onselect /></Plot>` directly. */
45
+ onselect?: (detail: unknown) => void
46
+ }
47
+
48
+ let {
49
+ data = [],
50
+ axis = undefined,
51
+ value = undefined,
52
+ series = undefined,
53
+ axes = undefined,
54
+ pattern = undefined,
55
+ grid = true,
56
+ rings = undefined,
57
+ sharedDomain = undefined,
58
+ radiusScale = undefined,
59
+ alpha = undefined,
60
+ stat = 'identity',
61
+ width = 400,
62
+ height = 400,
63
+ mode = undefined,
64
+ title = undefined,
65
+ tooltip = false,
66
+ legend = undefined,
67
+ onselect = undefined
68
+ }: Props = $props()
69
+
70
+ // ⚠ Diverges from PieChart, which defaults `legend` to false. Radar fills are washed out
71
+ // (preset alpha 0.25) and they overlap, so where they intersect the blend is a colour
72
+ // that belongs to neither series — matching a polygon to its series by colour alone is
73
+ // unreliable. A legend is therefore on by default as soon as there is more than one
74
+ // series. Still fully overridable in both directions.
75
+ //
76
+ // With no `series` channel there is exactly one polygon by construction, so the count
77
+ // cannot exceed one and the legend is simply off — no need to distinguish empty data
78
+ // from one implicit series.
79
+ const multiSeries = $derived(series ? new Set(data.map((row) => row[series])).size > 1 : false)
80
+ const showLegend = $derived(legend ?? multiSeries)
81
+ </script>
82
+
83
+ <Plot
84
+ {data}
85
+ {width}
86
+ {height}
87
+ {mode}
88
+ {title}
89
+ grid={false}
90
+ axes={false}
91
+ margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
92
+ legend={showLegend}
93
+ {tooltip}
94
+ {onselect}
95
+ >
96
+ <Radar
97
+ {axis}
98
+ {value}
99
+ {series}
100
+ {axes}
101
+ {pattern}
102
+ {alpha}
103
+ {stat}
104
+ options={{ grid, rings, sharedDomain, radiusScale }}
105
+ />
106
+ </Plot>
@@ -0,0 +1,43 @@
1
+ type Row = Record<string, unknown>;
2
+ type AxisSpec = {
3
+ key: string;
4
+ label?: string;
5
+ unit?: string;
6
+ domain?: [number, number];
7
+ ticks?: number;
8
+ tickLabels?: string[];
9
+ format?: (value: number) => unknown;
10
+ weight?: number;
11
+ };
12
+ type Props = {
13
+ data?: Row[];
14
+ /** Field naming which axis each row belongs to. */
15
+ axis?: string;
16
+ /** Field holding the plotted magnitude. */
17
+ value?: string;
18
+ /** Field distinguishing one polygon from another. */
19
+ series?: string;
20
+ /** Axis order and per-axis config — bare names, full specs, or a mix. */
21
+ axes?: (string | AxisSpec)[];
22
+ pattern?: string;
23
+ grid?: boolean;
24
+ rings?: number;
25
+ sharedDomain?: boolean;
26
+ radiusScale?: 'linear' | 'sqrt' | 'auto';
27
+ alpha?: number;
28
+ stat?: string;
29
+ width?: number;
30
+ height?: number;
31
+ mode?: 'light' | 'dark';
32
+ title?: string;
33
+ tooltip?: boolean | ((data: Row) => string);
34
+ /** Omit to let it default: on for 2+ series, off for one. */
35
+ legend?: boolean;
36
+ /** Plot-level selection: receives the `buildSelectDetail` shape, not a bare row —
37
+ * same as AreaChart/BarChart. The geom's own `onselect` (a row) is reached by
38
+ * composing `<Plot><Radar onselect /></Plot>` directly. */
39
+ onselect?: (detail: unknown) => void;
40
+ };
41
+ declare const RadarChart: import("svelte").Component<Props, {}, "">;
42
+ type RadarChart = ReturnType<typeof RadarChart>;
43
+ export default RadarChart;
@@ -10,6 +10,8 @@
10
10
  /** @deprecated use `position="stack"` — kept as an alias. */
11
11
  stack?: boolean
12
12
  curve?: 'linear' | 'smooth' | 'step'
13
+ /** Data-space value to anchor/split the fill at; splits the area into above/below segments. */
14
+ baseline?: number
13
15
  }
14
16
 
15
17
  type Props = {
@@ -40,7 +42,12 @@
40
42
  type: 'area',
41
43
  channels: { x, y, color, fill, pattern },
42
44
  stat,
43
- options: { stack: options?.stack ?? false, position: resolvedPosition, curve: options?.curve },
45
+ options: {
46
+ stack: options?.stack ?? false,
47
+ position: resolvedPosition,
48
+ curve: options?.curve,
49
+ baseline: options?.baseline
50
+ },
44
51
  alpha,
45
52
  build: buildAreaMarks
46
53
  }))
@@ -80,9 +87,17 @@
80
87
  fill-opacity={seg.patternId ? 1 : seg.alpha}
81
88
  stroke={seg.stroke ?? 'none'}
82
89
  data-plot-element="area"
90
+ data-plot-area
91
+ data-plot-area-sign={seg.sign}
83
92
  />
84
93
  {#if seg.patternId}
85
- <path d={seg.d} fill="url(#{seg.patternId})" data-plot-element="area" />
94
+ <path
95
+ d={seg.d}
96
+ fill="url(#{seg.patternId})"
97
+ data-plot-element="area"
98
+ data-plot-area
99
+ data-plot-area-sign={seg.sign}
100
+ />
86
101
  {/if}
87
102
  {/each}
88
103
  <!-- Invisible hit circles for tooltip: one per data point -->
@@ -2,6 +2,8 @@ type Options = {
2
2
  /** @deprecated use `position="stack"` — kept as an alias. */
3
3
  stack?: boolean;
4
4
  curve?: 'linear' | 'smooth' | 'step';
5
+ /** Data-space value to anchor/split the fill at; splits the area into above/below segments. */
6
+ baseline?: number;
5
7
  };
6
8
  type Props = {
7
9
  x?: string;
@@ -4,6 +4,7 @@
4
4
  import type { createCrossFilter } from '../crossfilter/createCrossFilter.svelte.js'
5
5
  import { GeomState } from './lib/GeomState.svelte.js'
6
6
  import { buildBarMarks } from './lib/marks/bar.js'
7
+ import { resolveLabel } from './lib/aesthetics.js'
7
8
  import { keyboardNav } from '../lib/keyboard-nav.js'
8
9
  import { buildSelectDetail } from '../lib/select.js'
9
10
  import LabelPill from './LabelPill.svelte'
@@ -60,14 +61,6 @@
60
61
  // `options.stack: true` is the back-compat alias for position='stack'.
61
62
  const resolvedPosition = $derived(position ?? (options?.stack ? 'stack' : 'dodge'))
62
63
 
63
- function resolveLabel(data: Row, defaultField: string | undefined): string | null {
64
- if (!label) return null
65
- if (label === true) return String((defaultField ? data[defaultField] : undefined) ?? '')
66
- if (typeof label === 'function') return String(label(data) ?? '')
67
- if (typeof label === 'string') return String(data[label] ?? '')
68
- return null
69
- }
70
-
71
64
  /**
72
65
  * Pick white or dark text based on perceived luminance of a hex fill color.
73
66
  */
@@ -198,7 +191,7 @@
198
191
  />
199
192
  {/if}
200
193
  {#if label}
201
- {@const text = resolveLabel(bar.data, effectiveOrientation === 'horizontal' ? x : y)}
194
+ {@const text = resolveLabel(label, bar.data, effectiveOrientation === 'horizontal' ? x : y)}
202
195
  {#if text}
203
196
  {#if effectiveOrientation === 'horizontal'}
204
197
  {#if options.labelInside}
@@ -3,6 +3,7 @@
3
3
  import type { PlotState } from '../PlotState.svelte.js'
4
4
  import { resolveHighlight } from '../lib/highlight.js'
5
5
  import { scalePos } from '../lib/scale.js'
6
+ import { resolveLabel } from './lib/aesthetics.js'
6
7
 
7
8
  type Row = Record<string, unknown>
8
9
  type Props = {
@@ -52,12 +53,6 @@
52
53
  return out.filter((m) => Number.isFinite(m.cx) && Number.isFinite(m.cy))
53
54
  })
54
55
 
55
- function resolveLabel(row: Row): string | null {
56
- if (!label) return null
57
- if (label === true) return String((y ? row[y] : '') ?? '')
58
- if (typeof label === 'function') return String(label(row) ?? '')
59
- return String(row[label] ?? '')
60
- }
61
56
  </script>
62
57
 
63
58
  {#if marks.length}
@@ -70,7 +65,7 @@
70
65
  data-plot-selected={m.selected ? 'true' : undefined}
71
66
  />
72
67
  {#if label}
73
- {@const text = resolveLabel(m.row)}
68
+ {@const text = resolveLabel(label, m.row, y)}
74
69
  {#if text}
75
70
  <text x={m.cx} y={m.cy} dy="-8" text-anchor="middle" data-plot-highlight-label
76
71
  >{text}</text
@@ -3,6 +3,7 @@
3
3
  import type { PlotState } from '../PlotState.svelte.js'
4
4
  import { GeomState } from './lib/GeomState.svelte.js'
5
5
  import { buildLineMarks } from './lib/marks/line.js'
6
+ import { resolveLabel } from './lib/aesthetics.js'
6
7
  import { buildSymbolPath } from '../lib/brewing/marks/points.js'
7
8
  import { keyboardNav } from '../lib/keyboard-nav.js'
8
9
  import { buildSelectDetail } from '../lib/select.js'
@@ -47,13 +48,6 @@
47
48
  keyboard = false
48
49
  }: Props = $props()
49
50
 
50
- function resolveLabel(data: Row): string | null {
51
- if (!label) return null
52
- if (label === true) return String((y ? data[y] : undefined) ?? '')
53
- if (typeof label === 'function') return String(label(data) ?? '')
54
- return typeof label === 'string' ? String(data[label] ?? '') : null
55
- }
56
-
57
51
  const plotState = getContext<PlotState>('plot-state')
58
52
 
59
53
  const geom = new GeomState(plotState, () => ({
@@ -113,7 +107,7 @@
113
107
  {/if}
114
108
  {#if label}
115
109
  {#each seg.points as pt (`label::${pt.i}`)}
116
- {@const text = resolveLabel(pt.data)}
110
+ {@const text = resolveLabel(label, pt.data, y)}
117
111
  {#if text}
118
112
  <LabelPill
119
113
  x={pt.x + (options.labelOffset?.x ?? 0)}
@@ -3,6 +3,7 @@
3
3
  import type { PlotState } from '../PlotState.svelte.js'
4
4
  import { GeomState } from './lib/GeomState.svelte.js'
5
5
  import { buildPointMarks } from './lib/marks/point.js'
6
+ import { resolveLabel } from './lib/aesthetics.js'
6
7
  import { keyboardNav } from '../lib/keyboard-nav.js'
7
8
  import { buildSelectDetail } from '../lib/select.js'
8
9
  import LabelPill from './LabelPill.svelte'
@@ -49,13 +50,6 @@
49
50
  keyboard = false
50
51
  }: Props = $props()
51
52
 
52
- function resolveLabel(data: Row): string | null {
53
- if (!label) return null
54
- if (label === true) return String((y ? data[y] : undefined) ?? '')
55
- if (typeof label === 'function') return String(label(data) ?? '')
56
- return typeof label === 'string' ? String(data[label] ?? '') : null
57
- }
58
-
59
53
  const plotState = getContext<PlotState>('plot-state')
60
54
 
61
55
  // All plumbing (register/data/scales/colors) + mark building (geometry + fill/color/alpha)
@@ -141,7 +135,7 @@
141
135
  />
142
136
  {/if}
143
137
  {#if label}
144
- {@const text = resolveLabel(pt.data)}
138
+ {@const text = resolveLabel(label, pt.data, y)}
145
139
  {#if text}
146
140
  <LabelPill
147
141
  x={pt.cx + (options.labelOffset?.x ?? 0)}
@@ -0,0 +1,352 @@
1
+ <script lang="ts">
2
+ import { getContext, onMount, onDestroy } from 'svelte'
3
+ import type { PlotState } from '../PlotState.svelte.js'
4
+ import { GeomState } from './lib/GeomState.svelte.js'
5
+ import { buildRadarMarks, resolveRadarRadius } from './lib/marks/radar.js'
6
+ import { buildRadarLayout } from '../lib/brewing/polar.js'
7
+ import { buildSelectDetail } from '../lib/select.js'
8
+
9
+ type Row = Record<string, unknown>
10
+
11
+ type AxisSpec = {
12
+ key: string
13
+ label?: string
14
+ unit?: string
15
+ domain?: [number, number]
16
+ ticks?: number
17
+ tickLabels?: string[]
18
+ format?: (value: number) => unknown
19
+ weight?: number
20
+ }
21
+
22
+ // The shape `buildRadarMarks` documents in its `@returns`. Other geoms leave `geom.marks`
23
+ // untyped because they only ever spread it in a template; this one also reads it in script
24
+ // (see `strokeBySeries`), which needs a real element type.
25
+ type RadarMark = {
26
+ d: string
27
+ fill: string
28
+ stroke: string
29
+ alpha: number
30
+ key: string
31
+ patternId: string | null
32
+ }
33
+
34
+ type Options = {
35
+ grid?: boolean
36
+ rings?: number
37
+ sharedDomain?: boolean
38
+ radiusScale?: 'linear' | 'sqrt' | 'auto'
39
+ /** Alias for the `axes` prop, for callers that can only reach `options` — notably
40
+ * Plot's spec-driven path, whose passthrough is a fixed prop list. */
41
+ axes?: (string | AxisSpec)[]
42
+ }
43
+
44
+ type Props = {
45
+ axes?: (string | AxisSpec)[]
46
+ axis?: string
47
+ value?: string
48
+ series?: string
49
+ /** Generic-pipeline aliases for `axis`/`value`/`series`. Radar's own names say what
50
+ * the channels mean here (an axis is not an x position), but Plot's spec-driven
51
+ * path passes only the generic set, so these let a spec drive radar too. The
52
+ * specific names win when both are given. */
53
+ x?: string
54
+ y?: string
55
+ color?: string
56
+ pattern?: string
57
+ /** Fixed area opacity 0–1; defaults to the per-geom preset (radar = 0.25). */
58
+ alpha?: number
59
+ stat?: string
60
+ options?: Options
61
+ onselect?: (data: Row) => void
62
+ }
63
+
64
+ let {
65
+ axes: axesProp,
66
+ axis: axisProp,
67
+ value: valueProp,
68
+ series: seriesProp,
69
+ x = undefined,
70
+ y = undefined,
71
+ color = undefined,
72
+ pattern,
73
+ alpha,
74
+ stat = 'identity',
75
+ options = {},
76
+ onselect = undefined
77
+ }: Props = $props()
78
+
79
+ // Radar's own channel names take precedence; the generic ones are the fallback so a
80
+ // spec-driven <Plot> (which passes only x/y/color) can drive this geom too.
81
+ const axis = $derived(axisProp ?? x)
82
+ const value = $derived(valueProp ?? y)
83
+ const series = $derived(seriesProp ?? color)
84
+ const axes = $derived(axesProp ?? options.axes)
85
+
86
+ const LABEL_GAP = 12
87
+ const ZERO_MARKER_HALF_SPAN_DEG = 6
88
+ const VERTEX_RADIUS = 3
89
+
90
+ const plotState = getContext<PlotState>('plot-state')
91
+
92
+ const geom = new GeomState(plotState, () => ({
93
+ type: 'radar',
94
+ channels: { x: axis, y: value, color: series, pattern },
95
+ stat,
96
+ options: {
97
+ axes,
98
+ sharedDomain: options.sharedDomain,
99
+ rings: options.rings,
100
+ radiusScale: options.radiusScale
101
+ },
102
+ alpha,
103
+ build: buildRadarMarks
104
+ }))
105
+ onMount(geom.register)
106
+ onDestroy(geom.destroy)
107
+ $effect(geom.sync)
108
+
109
+ const marks: RadarMark[] = $derived(geom.marks)
110
+ const w = $derived(plotState.innerWidth)
111
+ const h = $derived(plotState.innerHeight)
112
+
113
+ // One shared definition of the radius AND of which form we're drawing — the adapter
114
+ // builds its polygons from the very same call, so the grid can no longer disagree
115
+ // with them. `micro` is derived from the space actually available rather than from
116
+ // "am I inside a Spark": that keeps the geom free of any container-specific branch
117
+ // (the spec's requirement) and correctly also covers a Plot too small for chrome.
118
+ //
119
+ // Deliberately NOT `plotState.interactive`, which the plan proposed: that is
120
+ // `Boolean(onselect) || selectable`, so an ordinary static <Plot> reads false too and
121
+ // would silently lose its rings, spokes and axis labels.
122
+ const geometry = $derived(resolveRadarRadius(w, h))
123
+ const R = $derived(geometry.R)
124
+ const micro = $derived(geometry.micro)
125
+
126
+ // Radar takes its own channel props — like `GeomState.marks`, this never falls back to the
127
+ // container's `x`/`y`. Without both `axis` and `value` there is nothing coherent to lay a
128
+ // polar grid out against, so no layout is computed at all (mirrors `buildRadarMarks`'s own
129
+ // `!channels?.x || !channels?.y` guard for the polygons).
130
+ const layout = $derived(
131
+ axis !== undefined && value !== undefined
132
+ ? buildRadarLayout(
133
+ geom.data,
134
+ { x: axis, y: value, color: series },
135
+ {
136
+ axes,
137
+ sharedDomain: options.sharedDomain,
138
+ rings: options.rings,
139
+ radiusScale: options.radiusScale,
140
+ R
141
+ }
142
+ )
143
+ : null
144
+ )
145
+
146
+ function toXY(angleDeg: number, radius: number) {
147
+ const rad = (angleDeg * Math.PI) / 180
148
+ return { x: radius * Math.cos(rad), y: radius * Math.sin(rad) }
149
+ }
150
+
151
+ // cos(angle) is within float error of 0 only at true vertical (±90°, top/bottom); every
152
+ // other axis angle sits comfortably to one side, so a tight epsilon can't misclassify a
153
+ // real left/right axis as 'middle'.
154
+ function anchorFor(angleDeg: number): 'start' | 'middle' | 'end' {
155
+ const cosA = Math.cos((angleDeg * Math.PI) / 180)
156
+ if (Math.abs(cosA) < 1e-6) return 'middle'
157
+ return cosA > 0 ? 'start' : 'end'
158
+ }
159
+
160
+ // `buildRadarMarks` keys every mark `${seriesKey}::fill` / `${seriesKey}::stroke` so paint
161
+ // order (all fills, then all strokes) is inspectable from the key alone; this recovers the
162
+ // series value from that key for the `data-plot-series` hook.
163
+ function seriesOf(key: string) {
164
+ return key.slice(0, key.lastIndexOf('::'))
165
+ }
166
+
167
+ // Series stroke colour, recovered from the adapter's stroke marks (the ones with no fill)
168
+ // rather than re-resolving the palette here. A vertex dot has to match its own polygon's
169
+ // outline, and the only way to guarantee that is to read the same value the outline used.
170
+ const strokeBySeries = $derived(
171
+ new Map(
172
+ marks.filter((mark) => mark.fill === 'none').map((mark) => [seriesOf(mark.key), mark.stroke])
173
+ )
174
+ )
175
+
176
+ // A vertex is reachable when either the geom was handed its own callback or the plot is
177
+ // running interactively. Deliberately NOT the `keyboardNav` action: that walks a 1-D
178
+ // category list in DOM order, and radar's vertices are a 2-D series × axis grid, so
179
+ // left/right traversal has no meaning here. Tab reach plus Enter/Space, like Point/Arc.
180
+ const reachable = $derived(Boolean(onselect) || plotState.interactive)
181
+
182
+ function selectVertex(row: Row, seriesKey: unknown, event: MouseEvent | KeyboardEvent) {
183
+ onselect?.(row)
184
+ if (plotState.interactive)
185
+ plotState.handleSelect(
186
+ buildSelectDetail(
187
+ row,
188
+ // `row` is one of the container's original row objects — averaging happens
189
+ // inside polar.js precisely so this lookup resolves instead of returning -1.
190
+ plotState.data.indexOf(row),
191
+ { x: axis, y: value },
192
+ 'radar',
193
+ seriesKey,
194
+ event
195
+ )
196
+ )
197
+ }
198
+
199
+ // A short dashed arc at the radius where value 0 sits on this one spoke — see
200
+ // `zeroRingFor`'s doc comment for why this can't just be a shared ring.
201
+ function zeroMarkerPath(angleDeg: number, radius: number) {
202
+ const p1 = toXY(angleDeg - ZERO_MARKER_HALF_SPAN_DEG, radius)
203
+ const p2 = toXY(angleDeg + ZERO_MARKER_HALF_SPAN_DEG, radius)
204
+ return `M${p1.x},${p1.y} A${radius},${radius} 0 0 1 ${p2.x},${p2.y}`
205
+ }
206
+ </script>
207
+
208
+ <g data-plot-geom="radar" transform="translate({w / 2}, {h / 2})">
209
+ {#if layout && options.grid && !micro}
210
+ {#each layout.rings as ring, i (i)}
211
+ <circle data-plot-element="radar-grid-ring" cx="0" cy="0" r={ring.radius} />
212
+ {/each}
213
+ {#each layout.axes as ax, i (ax.key)}
214
+ {@const { x: sx, y: sy } = toXY(layout.angles[i], layout.radius)}
215
+ <line
216
+ data-plot-element="radar-grid-spoke"
217
+ data-plot-axis={ax.key}
218
+ x1="0"
219
+ y1="0"
220
+ x2={sx}
221
+ y2={sy}
222
+ />
223
+ {/each}
224
+ {#each layout.zeroRings as zr, i (i)}
225
+ {#if zr !== null}
226
+ <path data-plot-element="radar-zero-ring" d={zeroMarkerPath(layout.angles[i], zr)} />
227
+ {/if}
228
+ {/each}
229
+ {#if layout.axes.length > 0}
230
+ {#each layout.rings as ring, ringIndex (ringIndex)}
231
+ {@const { x: lx, y: ly } = toXY(layout.angles[0], ring.radius)}
232
+ <text class="radar-ring-label" x={lx} y={ly} dy="-2">{ring.labels[0]}</text>
233
+ {/each}
234
+ {/if}
235
+ {/if}
236
+
237
+ {#each marks as mark (mark.key)}
238
+ {#if mark.fill !== 'none'}
239
+ <path
240
+ d={mark.d}
241
+ fill={mark.fill}
242
+ fill-opacity={mark.alpha}
243
+ stroke="none"
244
+ data-plot-element="radar-area"
245
+ data-plot-series={seriesOf(mark.key)}
246
+ />
247
+ {#if mark.patternId}
248
+ <path
249
+ d={mark.d}
250
+ fill="url(#{mark.patternId})"
251
+ stroke="none"
252
+ pointer-events="none"
253
+ data-plot-element="radar-area"
254
+ data-plot-series={seriesOf(mark.key)}
255
+ />
256
+ {/if}
257
+ {:else}
258
+ <path
259
+ d={mark.d}
260
+ fill="none"
261
+ stroke={mark.stroke}
262
+ stroke-width="2"
263
+ data-plot-element="radar-outline"
264
+ data-plot-series={seriesOf(mark.key)}
265
+ />
266
+ {/if}
267
+ {/each}
268
+
269
+ {#if layout && !micro}
270
+ {#each [...layout.series] as [seriesKey, seriesVertices] (seriesKey)}
271
+ {#each seriesVertices as vertex, i (i)}
272
+ <!-- A null vertex is a genuine gap in the data — no reading for this (series, axis)
273
+ cell — so it renders as nothing at all. Plotting it at the centre would invent
274
+ a zero the source never contained. -->
275
+ {#if vertex}
276
+ {@const { x: vx, y: vy } = toXY(vertex.angle, vertex.radius)}
277
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
278
+ <circle
279
+ data-plot-element="radar-vertex"
280
+ data-plot-series={String(seriesKey)}
281
+ data-plot-axis={vertex.axisKey}
282
+ cx={vx}
283
+ cy={vy}
284
+ r={VERTEX_RADIUS}
285
+ fill={strokeBySeries.get(String(seriesKey))}
286
+ role={reachable ? 'button' : 'graphics-symbol'}
287
+ tabindex={reachable ? 0 : undefined}
288
+ style:cursor={reachable ? 'pointer' : undefined}
289
+ aria-label="{layout.axes[i].displayLabel}, {vertex.value}"
290
+ onmouseenter={() => plotState.setHovered(vertex.row)}
291
+ onmouseleave={() => plotState.clearHovered()}
292
+ onclick={reachable ? (e) => selectVertex(vertex.row, seriesKey, e) : undefined}
293
+ onkeydown={reachable
294
+ ? (e) =>
295
+ (e.key === 'Enter' || e.key === ' ') && selectVertex(vertex.row, seriesKey, e)
296
+ : undefined}
297
+ />
298
+ {/if}
299
+ {/each}
300
+ {/each}
301
+ {/if}
302
+
303
+ {#if layout && !micro}
304
+ {#each layout.axes as ax, i (ax.key)}
305
+ {@const { x: lx, y: ly } = toXY(layout.angles[i], layout.radius + LABEL_GAP)}
306
+ <text
307
+ data-plot-element="radar-axis-label"
308
+ data-plot-axis={ax.key}
309
+ x={lx}
310
+ y={ly}
311
+ text-anchor={anchorFor(layout.angles[i])}
312
+ dominant-baseline="central">{ax.displayLabel}</text
313
+ >
314
+ {/each}
315
+ {/if}
316
+ </g>
317
+
318
+ <style>
319
+ [data-plot-element='radar-grid-ring'] {
320
+ fill: none;
321
+ stroke: var(--chart-grid-color, currentColor);
322
+ stroke-width: var(--chart-grid-width, 1);
323
+ stroke-dasharray: var(--chart-grid-dash, 2 4);
324
+ opacity: var(--chart-grid-opacity, 0.15);
325
+ pointer-events: none;
326
+ }
327
+ [data-plot-element='radar-grid-spoke'] {
328
+ stroke: var(--chart-grid-color, currentColor);
329
+ stroke-width: var(--chart-grid-width, 1);
330
+ opacity: var(--chart-grid-opacity, 0.15);
331
+ pointer-events: none;
332
+ }
333
+ [data-plot-element='radar-zero-ring'] {
334
+ fill: none;
335
+ stroke: var(--chart-grid-color, currentColor);
336
+ stroke-width: var(--chart-grid-width, 1);
337
+ stroke-dasharray: 3 3;
338
+ opacity: 0.5;
339
+ pointer-events: none;
340
+ }
341
+ [data-plot-element='radar-axis-label'] {
342
+ font-size: 11px;
343
+ fill: currentColor;
344
+ pointer-events: none;
345
+ }
346
+ .radar-ring-label {
347
+ font-size: 9px;
348
+ fill: currentColor;
349
+ opacity: 0.6;
350
+ pointer-events: none;
351
+ }
352
+ </style>