@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.
- package/dist/Plot.svelte +25 -9
- package/dist/PlotState.svelte.d.ts +1 -0
- package/dist/PlotState.svelte.js +8 -1
- package/dist/Spark.svelte +165 -0
- package/dist/Spark.svelte.d.ts +31 -0
- package/dist/SparkState.svelte.d.ts +138 -0
- package/dist/SparkState.svelte.js +331 -0
- package/dist/Sparkline.svelte +59 -232
- package/dist/Sparkline.svelte.d.ts +2 -0
- package/dist/charts/RadarChart.svelte +106 -0
- package/dist/charts/RadarChart.svelte.d.ts +43 -0
- package/dist/geoms/Area.svelte +17 -2
- package/dist/geoms/Area.svelte.d.ts +2 -0
- package/dist/geoms/Bar.svelte +2 -9
- package/dist/geoms/Highlight.svelte +2 -7
- package/dist/geoms/Line.svelte +2 -8
- package/dist/geoms/Point.svelte +2 -8
- package/dist/geoms/Radar.svelte +352 -0
- package/dist/geoms/Radar.svelte.d.ts +42 -0
- package/dist/geoms/lib/aesthetics.d.ts +14 -0
- package/dist/geoms/lib/aesthetics.js +21 -0
- package/dist/geoms/lib/areas.d.ts +9 -2
- package/dist/geoms/lib/areas.js +53 -8
- package/dist/geoms/lib/marks/area.d.ts +3 -0
- package/dist/geoms/lib/marks/area.js +11 -2
- package/dist/geoms/lib/marks/radar.d.ts +68 -0
- package/dist/geoms/lib/marks/radar.js +153 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -1
- package/dist/lib/brewing/marks/boxes.js +19 -30
- package/dist/lib/brewing/polar.d.ts +433 -0
- package/dist/lib/brewing/polar.js +584 -0
- package/dist/lib/plot/scales.js +8 -5
- package/dist/lib/preset.d.ts +1 -0
- package/dist/lib/preset.js +2 -1
- package/dist/lib/select.d.ts +3 -3
- package/dist/lib/select.js +1 -1
- package/package.json +1 -1
- package/dist/examples/BarChartExample.svelte +0 -78
- package/dist/examples/BarChartExample.svelte.d.ts +0 -18
- package/dist/lib/brewing/types.d.ts +0 -162
- package/dist/lib/brewing/types.js +0 -73
- package/dist/lib/xscale.d.ts +0 -11
- 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;
|
package/dist/geoms/Area.svelte
CHANGED
|
@@ -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: {
|
|
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
|
|
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;
|
package/dist/geoms/Bar.svelte
CHANGED
|
@@ -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
|
package/dist/geoms/Line.svelte
CHANGED
|
@@ -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)}
|
package/dist/geoms/Point.svelte
CHANGED
|
@@ -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>
|