panelui-native 0.41.0 → 0.43.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/README.md +3 -2
- package/lib/module/components/color-picker/index.js +1395 -0
- package/lib/module/components/color-picker/index.js.map +1 -0
- package/lib/module/components/combobox/index.js +87 -19
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/kpi-chart/index.js +648 -0
- package/lib/module/components/kpi-chart/index.js.map +1 -0
- package/lib/module/components/menu/index.js +40 -10
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/radar-chart/index.js +704 -0
- package/lib/module/components/radar-chart/index.js.map +1 -0
- package/lib/module/components/section-rail/index.js +32 -7
- package/lib/module/components/section-rail/index.js.map +1 -1
- package/lib/module/components/slider/index.js +240 -39
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +194 -10
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/theme/use-theme.js +1 -1
- package/lib/module/utils/chart.js +44 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/module/utils/color.js +268 -0
- package/lib/module/utils/color.js.map +1 -0
- package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
- package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
- package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
- package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
- package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts +19 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +11 -1
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +21 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/lib/typescript/src/utils/color.d.ts +74 -0
- package/lib/typescript/src/utils/color.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/components/color-picker/index.tsx +1618 -0
- package/src/components/combobox/index.tsx +89 -19
- package/src/components/kpi-chart/index.tsx +719 -0
- package/src/components/menu/index.tsx +51 -6
- package/src/components/radar-chart/index.tsx +910 -0
- package/src/components/section-rail/index.tsx +42 -8
- package/src/components/slider/index.tsx +283 -41
- package/src/components/tabs/index.tsx +263 -8
- package/src/index.ts +60 -0
- package/src/theme/use-theme.ts +1 -1
- package/src/utils/chart.ts +52 -0
- package/src/utils/color.ts +246 -0
- package/theme.css +125 -112
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RadarChart — several measures of one thing, on one shape.
|
|
3
|
+
*
|
|
4
|
+
* A radar answers a question a bar chart cannot: not "which of these is
|
|
5
|
+
* biggest" but "what shape is this". Five scores read as five bars are five
|
|
6
|
+
* facts; read as a polygon they are a profile, and two profiles laid over each
|
|
7
|
+
* other are comparable at a glance in a way two groups of bars never are.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <RadarChart data={scores} axisKey="skill">
|
|
11
|
+
* <RadarChart.Header title="Team profile" legend />
|
|
12
|
+
* <RadarChart.Grid />
|
|
13
|
+
* <RadarChart.Axis />
|
|
14
|
+
* <RadarChart.Series dataKey="you" colorIndex={1} />
|
|
15
|
+
* <RadarChart.Series dataKey="team" colorIndex={2} />
|
|
16
|
+
* </RadarChart>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* That is also the shape's limit, and worth saying out loud: the order of the
|
|
20
|
+
* axes changes the outline, and the outline is what people read. Two datasets
|
|
21
|
+
* are only comparable on one radar if the axes are in the same order, and a
|
|
22
|
+
* radar is the wrong chart for data whose axes have no natural order at all.
|
|
23
|
+
*
|
|
24
|
+
* The reveal grows the polygons out of the centre rather than sweeping across
|
|
25
|
+
* them, because a polar chart has no left-hand edge for a sweep to start at.
|
|
26
|
+
* Everything below the root is drawn on the UI thread.
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
Children,
|
|
30
|
+
createContext,
|
|
31
|
+
forwardRef,
|
|
32
|
+
isValidElement,
|
|
33
|
+
useContext,
|
|
34
|
+
useEffect,
|
|
35
|
+
useId,
|
|
36
|
+
useImperativeHandle,
|
|
37
|
+
useMemo,
|
|
38
|
+
useRef,
|
|
39
|
+
useState,
|
|
40
|
+
type ReactNode,
|
|
41
|
+
} from 'react';
|
|
42
|
+
import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
43
|
+
import Svg, {
|
|
44
|
+
Circle,
|
|
45
|
+
G,
|
|
46
|
+
Line,
|
|
47
|
+
Path,
|
|
48
|
+
Polygon,
|
|
49
|
+
Text as SvgText,
|
|
50
|
+
} from 'react-native-svg';
|
|
51
|
+
import Animated, {
|
|
52
|
+
Easing,
|
|
53
|
+
useAnimatedProps,
|
|
54
|
+
useDerivedValue,
|
|
55
|
+
useReducedMotion,
|
|
56
|
+
useSharedValue,
|
|
57
|
+
withTiming,
|
|
58
|
+
type SharedValue,
|
|
59
|
+
} from 'react-native-reanimated';
|
|
60
|
+
import { useCSSVariable } from 'uniwind';
|
|
61
|
+
import { Text } from '../../primitives/text';
|
|
62
|
+
import { cn } from '../../utils/cn';
|
|
63
|
+
import {
|
|
64
|
+
polarPoint,
|
|
65
|
+
radarPath,
|
|
66
|
+
useSeriesColor,
|
|
67
|
+
type SeriesColorIndex,
|
|
68
|
+
} from '../../utils/chart';
|
|
69
|
+
|
|
70
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
71
|
+
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Room reserved around the rings for the axis labels — wider than it is tall.
|
|
75
|
+
*
|
|
76
|
+
* Labels sit outside the shape rather than along an edge, and they are
|
|
77
|
+
* horizontal text: the one at three o'clock needs its whole width to the right
|
|
78
|
+
* of the ring, while the one at twelve needs a single line's height above it.
|
|
79
|
+
* A square box therefore cannot hold them — either the sides are clipped or
|
|
80
|
+
* the top and bottom are wasted — which is also why a radar drawn in a square
|
|
81
|
+
* ends up so much taller than the wide charts beside it.
|
|
82
|
+
*/
|
|
83
|
+
const LABEL_ROOM = { x: 62, y: 26 };
|
|
84
|
+
/** …and with no axis labels asking for it, only enough not to clip the stroke. */
|
|
85
|
+
const BARE_ROOM = { x: 6, y: 6 };
|
|
86
|
+
|
|
87
|
+
/** How long the polygons take to grow out of the centre. */
|
|
88
|
+
const REVEAL_DURATION = 900;
|
|
89
|
+
/** …and how long one profile takes to travel to the next when the data changes. */
|
|
90
|
+
const MORPH_DURATION = 420;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ring diameter when nothing says otherwise.
|
|
94
|
+
*
|
|
95
|
+
* The other charts here fill their container, because they are wide and a
|
|
96
|
+
* wider one carries more. A radar filling a panel is as tall as the panel is
|
|
97
|
+
* wide — twice the height of the chart beside it, for the same handful of
|
|
98
|
+
* numbers — so it sizes itself instead, and centres.
|
|
99
|
+
*/
|
|
100
|
+
const DEFAULT_SIZE = 180;
|
|
101
|
+
|
|
102
|
+
export type RadarChartStatus = 'loading' | 'ready';
|
|
103
|
+
|
|
104
|
+
/** One row is one axis: its label, and one value per series. */
|
|
105
|
+
export type RadarChartDatum = Record<string, string | number | null | undefined>;
|
|
106
|
+
|
|
107
|
+
interface RadarChartContextValue {
|
|
108
|
+
data: RadarChartDatum[];
|
|
109
|
+
axisKey: string;
|
|
110
|
+
/** Centre of the rings, in view coordinates. */
|
|
111
|
+
cx: number;
|
|
112
|
+
cy: number;
|
|
113
|
+
/** Radius of the outermost ring. */
|
|
114
|
+
radius: number;
|
|
115
|
+
/** The drawing box, so a label can be kept inside it rather than clipped. */
|
|
116
|
+
width: number;
|
|
117
|
+
height: number;
|
|
118
|
+
status: RadarChartStatus;
|
|
119
|
+
/** The value the outermost ring stands for, tweened. */
|
|
120
|
+
domainMax: SharedValue<number>;
|
|
121
|
+
/** …and the innermost, which is usually but not always zero. */
|
|
122
|
+
domainMin: SharedValue<number>;
|
|
123
|
+
reveal: SharedValue<number>;
|
|
124
|
+
series: [string, string][];
|
|
125
|
+
registerSeries: (key: string, color: string) => void;
|
|
126
|
+
unregisterSeries: (key: string) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const RadarChartContext = createContext<RadarChartContextValue | null>(null);
|
|
130
|
+
|
|
131
|
+
/** The chart's geometry and data, for a part or a readout beside one. */
|
|
132
|
+
export function useRadarChart(): RadarChartContextValue {
|
|
133
|
+
const context = useContext(RadarChartContext);
|
|
134
|
+
if (!context) throw new Error('useRadarChart must be used inside <RadarChart>.');
|
|
135
|
+
return context;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function useChart(part: string): RadarChartContextValue {
|
|
139
|
+
const context = useContext(RadarChartContext);
|
|
140
|
+
if (!context) throw new Error(`${part} must be used inside <RadarChart>.`);
|
|
141
|
+
return context;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Which layer a part draws into. */
|
|
145
|
+
type Layer = 'svg' | 'overlay' | 'header';
|
|
146
|
+
|
|
147
|
+
function partition(children: ReactNode) {
|
|
148
|
+
const svg: ReactNode[] = [];
|
|
149
|
+
const overlay: ReactNode[] = [];
|
|
150
|
+
const header: ReactNode[] = [];
|
|
151
|
+
Children.forEach(children, (child, index) => {
|
|
152
|
+
if (!isValidElement(child)) return;
|
|
153
|
+
const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
|
|
154
|
+
const slot = <ChildSlot key={index}>{child}</ChildSlot>;
|
|
155
|
+
(layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);
|
|
156
|
+
});
|
|
157
|
+
return { svg, overlay, header };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Keeps a child's own key out of the array index the partition gives it. */
|
|
161
|
+
function ChildSlot({ children }: { children: ReactNode }) {
|
|
162
|
+
return <>{children}</>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A theme token as a colour, with the usual guard for a token that is not one. */
|
|
166
|
+
function useToken(variable: string, fallback: string): string {
|
|
167
|
+
const raw = useCSSVariable(variable);
|
|
168
|
+
return typeof raw === 'string' ? raw : fallback;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ------------------------------------------------------------------ *
|
|
172
|
+
* Root.
|
|
173
|
+
* ------------------------------------------------------------------ */
|
|
174
|
+
|
|
175
|
+
export interface RadarChartProps extends Omit<ViewProps, 'children'> {
|
|
176
|
+
className?: string;
|
|
177
|
+
/** One row per axis, in the order they go round. */
|
|
178
|
+
data: RadarChartDatum[];
|
|
179
|
+
/** Key holding each row's axis label. */
|
|
180
|
+
axisKey?: string;
|
|
181
|
+
/**
|
|
182
|
+
* `loading` holds the shape at the centre until the data arrives, then grows
|
|
183
|
+
* it — one component throughout rather than a spinner swapped for a chart,
|
|
184
|
+
* because swapping loses the transition.
|
|
185
|
+
*/
|
|
186
|
+
status?: RadarChartStatus;
|
|
187
|
+
/**
|
|
188
|
+
* Diameter of the outermost ring, in points. The view is that plus the room
|
|
189
|
+
* the axis labels need around it, and centres itself.
|
|
190
|
+
*
|
|
191
|
+
* The ring rather than the box, because the ring is the thing being sized —
|
|
192
|
+
* a box measurement would mean "bigger" also meant "labels further from the
|
|
193
|
+
* shape", and the chart would grow without the drawing growing with it.
|
|
194
|
+
*
|
|
195
|
+
* Pass `size={undefined}` with an `aspectRatio` to fill the container the
|
|
196
|
+
* way the other charts do.
|
|
197
|
+
*/
|
|
198
|
+
size?: number;
|
|
199
|
+
/**
|
|
200
|
+
* Width ÷ height when `size` is not given. `1` is the square a radar wants;
|
|
201
|
+
* the rings stay circular whatever it is set to.
|
|
202
|
+
*/
|
|
203
|
+
aspectRatio?: number;
|
|
204
|
+
/**
|
|
205
|
+
* Fix the scale instead of deriving it from the data. A radar almost always
|
|
206
|
+
* wants this: the shape only means something against a known maximum, and a
|
|
207
|
+
* scale that moves with the data makes two charts incomparable.
|
|
208
|
+
*/
|
|
209
|
+
domain?: [number, number];
|
|
210
|
+
/** Milliseconds for the reveal on mount. */
|
|
211
|
+
animationDuration?: number;
|
|
212
|
+
/** Drop the room reserved for axis labels, for a radar with none. */
|
|
213
|
+
compact?: boolean;
|
|
214
|
+
children?: ReactNode;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Imperative handle: re-run the reveal on demand, for a "replay" control. */
|
|
218
|
+
export interface RadarChartHandle {
|
|
219
|
+
replay: () => void;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const RadarChartRoot = forwardRef<RadarChartHandle, RadarChartProps>(
|
|
223
|
+
function RadarChartRoot(
|
|
224
|
+
{
|
|
225
|
+
className,
|
|
226
|
+
data,
|
|
227
|
+
axisKey = 'axis',
|
|
228
|
+
status = 'ready',
|
|
229
|
+
size: fixedSize = DEFAULT_SIZE,
|
|
230
|
+
aspectRatio = 1,
|
|
231
|
+
domain,
|
|
232
|
+
animationDuration = REVEAL_DURATION,
|
|
233
|
+
compact = false,
|
|
234
|
+
children,
|
|
235
|
+
...props
|
|
236
|
+
},
|
|
237
|
+
ref
|
|
238
|
+
) {
|
|
239
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
240
|
+
const [series, setSeries] = useState<[string, string][]>([]);
|
|
241
|
+
|
|
242
|
+
const reveal = useSharedValue(0);
|
|
243
|
+
const domainMin = useSharedValue(0);
|
|
244
|
+
const domainMax = useSharedValue(0);
|
|
245
|
+
const reducedMotion = useReducedMotion();
|
|
246
|
+
|
|
247
|
+
const registerSeries = useMemo(
|
|
248
|
+
() => (key: string, color: string) =>
|
|
249
|
+
setSeries((current) => {
|
|
250
|
+
const existing = current.find(([k]) => k === key);
|
|
251
|
+
if (existing?.[1] === color) return current;
|
|
252
|
+
return [...current.filter(([k]) => k !== key), [key, color]];
|
|
253
|
+
}),
|
|
254
|
+
[]
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const unregisterSeries = useMemo(
|
|
258
|
+
() => (key: string) => setSeries((current) => current.filter(([k]) => k !== key)),
|
|
259
|
+
[]
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const room = compact ? BARE_ROOM : LABEL_ROOM;
|
|
263
|
+
const cx = size.width / 2;
|
|
264
|
+
const cy = size.height / 2;
|
|
265
|
+
// Measured rather than assumed, so the `aspectRatio` path — where the box
|
|
266
|
+
// is whatever the container gave it — lands on the same geometry.
|
|
267
|
+
const radius = Math.max(
|
|
268
|
+
Math.min(size.width / 2 - room.x, size.height / 2 - room.y),
|
|
269
|
+
0
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const seriesKeys = series.map(([key]) => key).join('|');
|
|
273
|
+
const extent = useMemo<[number, number]>(() => {
|
|
274
|
+
if (domain) return domain;
|
|
275
|
+
const keys = seriesKeys ? seriesKeys.split('|') : [];
|
|
276
|
+
let max = -Infinity;
|
|
277
|
+
|
|
278
|
+
for (const row of data) {
|
|
279
|
+
for (const key of keys) {
|
|
280
|
+
const value = row[key];
|
|
281
|
+
if (typeof value !== 'number' || Number.isNaN(value)) continue;
|
|
282
|
+
if (value > max) max = value;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (max === -Infinity) return [0, 1];
|
|
287
|
+
/*
|
|
288
|
+
* From zero, always. A radar's rings are read as fractions of the whole —
|
|
289
|
+
* "three quarters of the way out" — and a floor above zero makes a small
|
|
290
|
+
* value look like a large one, which is the failure mode this chart is
|
|
291
|
+
* most prone to.
|
|
292
|
+
*/
|
|
293
|
+
return [0, max === 0 ? 1 : max * 1.05];
|
|
294
|
+
}, [data, domain, seriesKeys]);
|
|
295
|
+
|
|
296
|
+
const loading = status === 'loading';
|
|
297
|
+
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (loading) return;
|
|
300
|
+
const [min, max] = extent;
|
|
301
|
+
domainMin.value = min;
|
|
302
|
+
domainMax.value = max;
|
|
303
|
+
}, [extent, loading, domainMin, domainMax]);
|
|
304
|
+
|
|
305
|
+
const revealed = useRef(false);
|
|
306
|
+
const playReveal = useMemo(
|
|
307
|
+
() => () => {
|
|
308
|
+
if (reducedMotion) {
|
|
309
|
+
reveal.value = 1;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
reveal.value = 0;
|
|
313
|
+
reveal.value = withTiming(1, {
|
|
314
|
+
duration: animationDuration,
|
|
315
|
+
easing: Easing.bezier(0.85, 0, 0.15, 1),
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
[reducedMotion, animationDuration, reveal]
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (revealed.current || loading || radius <= 0 || !data.length) return;
|
|
323
|
+
revealed.current = true;
|
|
324
|
+
playReveal();
|
|
325
|
+
}, [loading, radius, data.length, playReveal]);
|
|
326
|
+
|
|
327
|
+
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
|
|
328
|
+
|
|
329
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
330
|
+
const { width, height } = event.nativeEvent.layout;
|
|
331
|
+
setSize((current) =>
|
|
332
|
+
Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
|
|
333
|
+
? current
|
|
334
|
+
: { width, height }
|
|
335
|
+
);
|
|
336
|
+
props.onLayout?.(event);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const context = useMemo<RadarChartContextValue>(
|
|
340
|
+
() => ({
|
|
341
|
+
data,
|
|
342
|
+
axisKey,
|
|
343
|
+
cx,
|
|
344
|
+
cy,
|
|
345
|
+
radius,
|
|
346
|
+
width: size.width,
|
|
347
|
+
height: size.height,
|
|
348
|
+
status,
|
|
349
|
+
domainMin,
|
|
350
|
+
domainMax,
|
|
351
|
+
reveal,
|
|
352
|
+
series,
|
|
353
|
+
registerSeries,
|
|
354
|
+
unregisterSeries,
|
|
355
|
+
}),
|
|
356
|
+
[
|
|
357
|
+
data,
|
|
358
|
+
axisKey,
|
|
359
|
+
cx,
|
|
360
|
+
cy,
|
|
361
|
+
radius,
|
|
362
|
+
size.width,
|
|
363
|
+
size.height,
|
|
364
|
+
status,
|
|
365
|
+
domainMin,
|
|
366
|
+
domainMax,
|
|
367
|
+
reveal,
|
|
368
|
+
series,
|
|
369
|
+
registerSeries,
|
|
370
|
+
unregisterSeries,
|
|
371
|
+
]
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const { svg, overlay, header } = partition(children);
|
|
375
|
+
|
|
376
|
+
/*
|
|
377
|
+
* Two views, because the header is not part of the plot. `aspectRatio` and
|
|
378
|
+
* the layout measurement belong to the drawing area alone — measured on the
|
|
379
|
+
* outer view they would take in the header too, and the rings would lose as
|
|
380
|
+
* much radius as the header took while still claiming the shape asked for.
|
|
381
|
+
*/
|
|
382
|
+
return (
|
|
383
|
+
<RadarChartContext.Provider value={context}>
|
|
384
|
+
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
385
|
+
{header}
|
|
386
|
+
<View
|
|
387
|
+
onLayout={onLayout}
|
|
388
|
+
style={
|
|
389
|
+
fixedSize
|
|
390
|
+
? {
|
|
391
|
+
// The ring plus its label room — wider than tall, because
|
|
392
|
+
// the labels are.
|
|
393
|
+
width: fixedSize + room.x * 2,
|
|
394
|
+
height: fixedSize + room.y * 2,
|
|
395
|
+
maxWidth: '100%',
|
|
396
|
+
}
|
|
397
|
+
: { aspectRatio }
|
|
398
|
+
}
|
|
399
|
+
className={fixedSize ? 'self-center' : 'w-full'}
|
|
400
|
+
>
|
|
401
|
+
{radius > 0 ? (
|
|
402
|
+
<>
|
|
403
|
+
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
|
|
404
|
+
{svg}
|
|
405
|
+
</Svg>
|
|
406
|
+
{overlay}
|
|
407
|
+
</>
|
|
408
|
+
) : null}
|
|
409
|
+
</View>
|
|
410
|
+
</View>
|
|
411
|
+
</RadarChartContext.Provider>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
/* ------------------------------------------------------------------ *
|
|
417
|
+
* Grid — the rings, and the spokes under them.
|
|
418
|
+
* ------------------------------------------------------------------ */
|
|
419
|
+
|
|
420
|
+
export interface RadarChartGridProps {
|
|
421
|
+
/** How many rings, including the outermost. */
|
|
422
|
+
rings?: number;
|
|
423
|
+
/** Overrides the themed hairline colour. */
|
|
424
|
+
color?: string;
|
|
425
|
+
/** Draw the rings as circles rather than as polygons through the spokes. */
|
|
426
|
+
circular?: boolean;
|
|
427
|
+
/** Draw a line from the centre out to each axis. */
|
|
428
|
+
spokes?: boolean;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The scale, drawn as rings.
|
|
433
|
+
*
|
|
434
|
+
* Polygonal by default rather than circular, because the rings are read
|
|
435
|
+
* against the shape laid over them — a round ring behind an angular polygon
|
|
436
|
+
* gives every axis a different apparent distance to the edge, and the whole
|
|
437
|
+
* point of the rings is to say how far out something is.
|
|
438
|
+
*/
|
|
439
|
+
function RadarChartGrid({
|
|
440
|
+
rings = 4,
|
|
441
|
+
color,
|
|
442
|
+
circular = false,
|
|
443
|
+
spokes = true,
|
|
444
|
+
}: RadarChartGridProps) {
|
|
445
|
+
const { data, cx, cy, radius } = useChart('RadarChart.Grid');
|
|
446
|
+
const themed = useToken('--color-border', 'rgba(128,128,128,0.2)');
|
|
447
|
+
const stroke = color ?? themed;
|
|
448
|
+
const count = data.length;
|
|
449
|
+
|
|
450
|
+
if (count < 3) return null;
|
|
451
|
+
|
|
452
|
+
const levels = Array.from({ length: rings }, (_, index) => ((index + 1) / rings) * radius);
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
<G>
|
|
456
|
+
{levels.map((r, index) =>
|
|
457
|
+
circular ? (
|
|
458
|
+
<Circle key={index} cx={cx} cy={cy} r={r} fill="none" stroke={stroke} strokeWidth={1} />
|
|
459
|
+
) : (
|
|
460
|
+
<Polygon
|
|
461
|
+
key={index}
|
|
462
|
+
points={Array.from({ length: count }, (_, spoke) => {
|
|
463
|
+
const point = polarPoint(cx, cy, r, spoke / count);
|
|
464
|
+
return `${point.x},${point.y}`;
|
|
465
|
+
}).join(' ')}
|
|
466
|
+
fill="none"
|
|
467
|
+
stroke={stroke}
|
|
468
|
+
strokeWidth={1}
|
|
469
|
+
/>
|
|
470
|
+
)
|
|
471
|
+
)}
|
|
472
|
+
{spokes
|
|
473
|
+
? Array.from({ length: count }, (_, index) => {
|
|
474
|
+
const point = polarPoint(cx, cy, radius, index / count);
|
|
475
|
+
return (
|
|
476
|
+
<Line
|
|
477
|
+
key={index}
|
|
478
|
+
x1={cx}
|
|
479
|
+
y1={cy}
|
|
480
|
+
x2={point.x}
|
|
481
|
+
y2={point.y}
|
|
482
|
+
stroke={stroke}
|
|
483
|
+
strokeWidth={1}
|
|
484
|
+
/>
|
|
485
|
+
);
|
|
486
|
+
})
|
|
487
|
+
: null}
|
|
488
|
+
</G>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/* ------------------------------------------------------------------ *
|
|
493
|
+
* Axis — the labels round the outside.
|
|
494
|
+
* ------------------------------------------------------------------ */
|
|
495
|
+
|
|
496
|
+
export interface RadarChartAxisProps {
|
|
497
|
+
/** Overrides the themed label colour. */
|
|
498
|
+
color?: string;
|
|
499
|
+
/** Label size in points. */
|
|
500
|
+
fontSize?: number;
|
|
501
|
+
/** How far outside the rings the labels sit. */
|
|
502
|
+
offset?: number;
|
|
503
|
+
/** Rewrites a label — to shorten it, or to add a unit. */
|
|
504
|
+
formatLabel?: (label: string, index: number) => string;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The axis names, placed around the rings.
|
|
509
|
+
*
|
|
510
|
+
* Each label is anchored by which side of the circle it is on rather than
|
|
511
|
+
* centred on its point: a label centred at three o'clock overlaps the shape it
|
|
512
|
+
* belongs to, and one centred at nine o'clock overlaps the view's edge.
|
|
513
|
+
*/
|
|
514
|
+
function RadarChartAxis({ color, fontSize = 11, offset = 10, formatLabel }: RadarChartAxisProps) {
|
|
515
|
+
const { data, axisKey, cx, cy, radius, width, height } = useChart('RadarChart.Axis');
|
|
516
|
+
const themed = useToken('--color-muted-foreground', '#737373');
|
|
517
|
+
const fill = color ?? themed;
|
|
518
|
+
const count = data.length;
|
|
519
|
+
|
|
520
|
+
if (count < 3) return null;
|
|
521
|
+
|
|
522
|
+
return (
|
|
523
|
+
<G>
|
|
524
|
+
{data.map((row, index) => {
|
|
525
|
+
const raw = row[axisKey];
|
|
526
|
+
const label = formatLabel
|
|
527
|
+
? formatLabel(String(raw ?? ''), index)
|
|
528
|
+
: String(raw ?? '');
|
|
529
|
+
const point = polarPoint(cx, cy, radius + offset, index / count);
|
|
530
|
+
|
|
531
|
+
// Within a few points of the vertical it is a top or bottom label and
|
|
532
|
+
// wants centring; either side of that it wants to run away from the
|
|
533
|
+
// chart rather than across it.
|
|
534
|
+
const dx = point.x - cx;
|
|
535
|
+
const anchor = Math.abs(dx) < radius * 0.15 ? 'middle' : dx > 0 ? 'start' : 'end';
|
|
536
|
+
|
|
537
|
+
/*
|
|
538
|
+
* Pull the anchor back inside the view before drawing.
|
|
539
|
+
*
|
|
540
|
+
* SVG clips at its viewport and does not reflow, so a label wider than
|
|
541
|
+
* the room left for it loses its tail with no sign that anything is
|
|
542
|
+
* missing — the reader sees "Accur" and has no reason to think it was
|
|
543
|
+
* ever longer. Nudging the anchor in costs a couple of points of gap
|
|
544
|
+
* between the label and the ring, which is the cheaper of the two.
|
|
545
|
+
*/
|
|
546
|
+
const estimated = label.length * fontSize * 0.55;
|
|
547
|
+
const x =
|
|
548
|
+
anchor === 'start'
|
|
549
|
+
? Math.min(point.x, Math.max(width - estimated, cx))
|
|
550
|
+
: anchor === 'end'
|
|
551
|
+
? Math.max(point.x, Math.min(estimated, cx))
|
|
552
|
+
: point.x;
|
|
553
|
+
|
|
554
|
+
// Text is anchored on its baseline, so a label below the chart needs
|
|
555
|
+
// pushing down by roughly its cap height to look level with one above.
|
|
556
|
+
const dy = point.y < cy ? 0 : fontSize * 0.72;
|
|
557
|
+
const y = Math.min(Math.max(point.y + dy, fontSize), height - 2);
|
|
558
|
+
|
|
559
|
+
return (
|
|
560
|
+
<SvgText
|
|
561
|
+
key={index}
|
|
562
|
+
x={x}
|
|
563
|
+
y={y}
|
|
564
|
+
fill={fill}
|
|
565
|
+
fontSize={fontSize}
|
|
566
|
+
fontWeight="500"
|
|
567
|
+
textAnchor={anchor}
|
|
568
|
+
>
|
|
569
|
+
{label}
|
|
570
|
+
</SvgText>
|
|
571
|
+
);
|
|
572
|
+
})}
|
|
573
|
+
</G>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/* ------------------------------------------------------------------ *
|
|
578
|
+
* Series — one polygon.
|
|
579
|
+
* ------------------------------------------------------------------ */
|
|
580
|
+
|
|
581
|
+
export interface RadarChartSeriesProps {
|
|
582
|
+
/** Key holding this series' value on each row. */
|
|
583
|
+
dataKey: string;
|
|
584
|
+
/** Name for the legend. Defaults to `dataKey`. */
|
|
585
|
+
name?: string;
|
|
586
|
+
/**
|
|
587
|
+
* Stroke colour. Defaults to the `--color-chart-*` token at `colorIndex`, so
|
|
588
|
+
* a series follows the theme without the call site naming a colour.
|
|
589
|
+
*/
|
|
590
|
+
color?: string;
|
|
591
|
+
/** Which `--color-chart-*` token to take when `color` is not given. */
|
|
592
|
+
colorIndex?: SeriesColorIndex;
|
|
593
|
+
strokeWidth?: number;
|
|
594
|
+
/**
|
|
595
|
+
* Opacity of the fill. Two filled polygons over each other make a third
|
|
596
|
+
* colour that means nothing, so drop it towards `0` — or to `0` — on the
|
|
597
|
+
* second and subsequent series.
|
|
598
|
+
*/
|
|
599
|
+
fillOpacity?: number;
|
|
600
|
+
/** A dot at each vertex. Worth it on a radar with few axes. */
|
|
601
|
+
showDots?: boolean;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** One profile. */
|
|
605
|
+
function RadarChartSeries({
|
|
606
|
+
dataKey,
|
|
607
|
+
name,
|
|
608
|
+
color,
|
|
609
|
+
colorIndex = 1,
|
|
610
|
+
strokeWidth = 2,
|
|
611
|
+
fillOpacity = 0.18,
|
|
612
|
+
showDots = false,
|
|
613
|
+
}: RadarChartSeriesProps) {
|
|
614
|
+
const { data, cx, cy, radius, domainMin, domainMax, reveal, status, registerSeries, unregisterSeries } =
|
|
615
|
+
useChart('RadarChart.Series');
|
|
616
|
+
const stroke = useSeriesColor(color, colorIndex);
|
|
617
|
+
const count = data.length;
|
|
618
|
+
|
|
619
|
+
useEffect(() => {
|
|
620
|
+
registerSeries(dataKey, stroke);
|
|
621
|
+
return () => unregisterSeries(dataKey);
|
|
622
|
+
}, [dataKey, stroke, registerSeries, unregisterSeries]);
|
|
623
|
+
|
|
624
|
+
const values = useMemo(
|
|
625
|
+
() =>
|
|
626
|
+
data.map((row) => {
|
|
627
|
+
const value = row[dataKey];
|
|
628
|
+
return typeof value === 'number' && !Number.isNaN(value) ? value : null;
|
|
629
|
+
}),
|
|
630
|
+
[data, dataKey]
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
const loading = status === 'loading';
|
|
634
|
+
|
|
635
|
+
/*
|
|
636
|
+
* Where the shape is coming from and where it is going, and how far between
|
|
637
|
+
* them it currently is.
|
|
638
|
+
*
|
|
639
|
+
* A radar is the chart people put behind a switch — this quarter or last,
|
|
640
|
+
* you or the team — and a polygon that jumps from one profile to the next
|
|
641
|
+
* loses the only thing worth showing at the moment of the switch, which is
|
|
642
|
+
* which axes moved and by how much. Held as two arrays and tweened per
|
|
643
|
+
* vertex, the outline travels and the eye follows the parts of it that
|
|
644
|
+
* travelled furthest.
|
|
645
|
+
*/
|
|
646
|
+
const from = useSharedValue<(number | null)[]>(values);
|
|
647
|
+
const to = useSharedValue<(number | null)[]>(values);
|
|
648
|
+
const morph = useSharedValue(1);
|
|
649
|
+
const settled = useRef(false);
|
|
650
|
+
const reducedMotion = useReducedMotion();
|
|
651
|
+
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
// The first values are not a change from anything.
|
|
654
|
+
if (!settled.current) {
|
|
655
|
+
settled.current = true;
|
|
656
|
+
from.value = values;
|
|
657
|
+
to.value = values;
|
|
658
|
+
morph.value = 1;
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (reducedMotion) {
|
|
663
|
+
from.value = values;
|
|
664
|
+
to.value = values;
|
|
665
|
+
morph.value = 1;
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Leave from wherever the last tween had got to, so a switch part way
|
|
670
|
+
// through another one carries on from the shape actually on screen.
|
|
671
|
+
const t = morph.value;
|
|
672
|
+
const previous = from.value;
|
|
673
|
+
const current = to.value;
|
|
674
|
+
from.value = current.map((value, index) => {
|
|
675
|
+
const start = previous[index];
|
|
676
|
+
if (value === null || start === null || start === undefined) return value ?? start ?? null;
|
|
677
|
+
return start + (value - start) * t;
|
|
678
|
+
});
|
|
679
|
+
to.value = values;
|
|
680
|
+
morph.value = 0;
|
|
681
|
+
morph.value = withTiming(1, { duration: MORPH_DURATION });
|
|
682
|
+
}, [values, reducedMotion, from, to, morph]);
|
|
683
|
+
|
|
684
|
+
/*
|
|
685
|
+
* The polygon is rebuilt on the UI thread every frame the reveal or the
|
|
686
|
+
* morph is running. Scaling the *values* rather than transforming the group
|
|
687
|
+
* is what makes the shape grow along its own axes — a `scale` transform
|
|
688
|
+
* would grow the stroke and the dots with it, and arrive at the wrong
|
|
689
|
+
* stroke width.
|
|
690
|
+
*/
|
|
691
|
+
const animatedProps = useAnimatedProps(() => {
|
|
692
|
+
const span = Math.max(domainMax.value - domainMin.value, 1e-6);
|
|
693
|
+
const target = to.value;
|
|
694
|
+
const start = from.value;
|
|
695
|
+
const scaled = target.map((value, index) => {
|
|
696
|
+
const a = start[index] ?? value;
|
|
697
|
+
const b = value ?? a;
|
|
698
|
+
if (a === null || a === undefined || b === null || b === undefined) return null;
|
|
699
|
+
const mixed = a + (b - a) * morph.value;
|
|
700
|
+
return ((mixed - domainMin.value) / span) * reveal.value;
|
|
701
|
+
});
|
|
702
|
+
return { d: loading ? '' : radarPath(scaled, cx, cy, radius) };
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
return (
|
|
706
|
+
<G>
|
|
707
|
+
<AnimatedPath
|
|
708
|
+
animatedProps={animatedProps}
|
|
709
|
+
fill={stroke}
|
|
710
|
+
fillOpacity={fillOpacity}
|
|
711
|
+
stroke={stroke}
|
|
712
|
+
strokeWidth={strokeWidth}
|
|
713
|
+
strokeLinejoin="round"
|
|
714
|
+
/>
|
|
715
|
+
{showDots && count >= 3
|
|
716
|
+
? values.map((value, index) =>
|
|
717
|
+
value === null ? null : (
|
|
718
|
+
<RadarDot
|
|
719
|
+
key={index}
|
|
720
|
+
index={index}
|
|
721
|
+
turn={index / count}
|
|
722
|
+
cx={cx}
|
|
723
|
+
cy={cy}
|
|
724
|
+
radius={radius}
|
|
725
|
+
color={stroke}
|
|
726
|
+
from={from}
|
|
727
|
+
to={to}
|
|
728
|
+
morph={morph}
|
|
729
|
+
domainMin={domainMin}
|
|
730
|
+
domainMax={domainMax}
|
|
731
|
+
reveal={reveal}
|
|
732
|
+
/>
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
: null}
|
|
736
|
+
</G>
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** One vertex, riding the same reveal and the same morph as its polygon. */
|
|
741
|
+
function RadarDot({
|
|
742
|
+
index,
|
|
743
|
+
turn,
|
|
744
|
+
cx,
|
|
745
|
+
cy,
|
|
746
|
+
radius,
|
|
747
|
+
color,
|
|
748
|
+
from,
|
|
749
|
+
to,
|
|
750
|
+
morph,
|
|
751
|
+
domainMin,
|
|
752
|
+
domainMax,
|
|
753
|
+
reveal,
|
|
754
|
+
}: {
|
|
755
|
+
index: number;
|
|
756
|
+
turn: number;
|
|
757
|
+
cx: number;
|
|
758
|
+
cy: number;
|
|
759
|
+
radius: number;
|
|
760
|
+
color: string;
|
|
761
|
+
from: SharedValue<(number | null)[]>;
|
|
762
|
+
to: SharedValue<(number | null)[]>;
|
|
763
|
+
morph: SharedValue<number>;
|
|
764
|
+
domainMin: SharedValue<number>;
|
|
765
|
+
domainMax: SharedValue<number>;
|
|
766
|
+
reveal: SharedValue<number>;
|
|
767
|
+
}) {
|
|
768
|
+
const point = useDerivedValue(() => {
|
|
769
|
+
const span = Math.max(domainMax.value - domainMin.value, 1e-6);
|
|
770
|
+
const target = to.value[index];
|
|
771
|
+
const start = from.value[index] ?? target;
|
|
772
|
+
if (target === null || target === undefined || start === null || start === undefined) {
|
|
773
|
+
return { x: cx, y: cy };
|
|
774
|
+
}
|
|
775
|
+
const mixed = start + (target - start) * morph.value;
|
|
776
|
+
const fraction = ((mixed - domainMin.value) / span) * reveal.value;
|
|
777
|
+
return polarPoint(cx, cy, radius * fraction, turn);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const animatedProps = useAnimatedProps(() => ({
|
|
781
|
+
cx: point.value.x,
|
|
782
|
+
cy: point.value.y,
|
|
783
|
+
}));
|
|
784
|
+
|
|
785
|
+
return <AnimatedCircle animatedProps={animatedProps} r={3} fill={color} />;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/* ------------------------------------------------------------------ *
|
|
789
|
+
* Header and legend.
|
|
790
|
+
* ------------------------------------------------------------------ */
|
|
791
|
+
|
|
792
|
+
export interface RadarChartHeaderProps {
|
|
793
|
+
className?: string;
|
|
794
|
+
/** Small caption above the value. */
|
|
795
|
+
title?: string;
|
|
796
|
+
/** The headline figure, if there is one. */
|
|
797
|
+
value?: string;
|
|
798
|
+
/** A line under the value. */
|
|
799
|
+
caption?: string;
|
|
800
|
+
/** Draw the series legend on the trailing end of the strip. */
|
|
801
|
+
legend?: boolean;
|
|
802
|
+
children?: ReactNode;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** The strip above the rings. */
|
|
806
|
+
function RadarChartHeader({
|
|
807
|
+
className,
|
|
808
|
+
title,
|
|
809
|
+
value,
|
|
810
|
+
caption,
|
|
811
|
+
legend = false,
|
|
812
|
+
children,
|
|
813
|
+
}: RadarChartHeaderProps) {
|
|
814
|
+
const { series } = useChart('RadarChart.Header');
|
|
815
|
+
|
|
816
|
+
return (
|
|
817
|
+
<View className={cn('flex-row items-start justify-between gap-3 pb-2', className)}>
|
|
818
|
+
<View className="flex-1">
|
|
819
|
+
{title ? (
|
|
820
|
+
<Text size="sm" muted>
|
|
821
|
+
{title}
|
|
822
|
+
</Text>
|
|
823
|
+
) : null}
|
|
824
|
+
{value ? (
|
|
825
|
+
<Text size="2xl" weight="bold">
|
|
826
|
+
{value}
|
|
827
|
+
</Text>
|
|
828
|
+
) : null}
|
|
829
|
+
{caption ? (
|
|
830
|
+
<Text size="sm" muted>
|
|
831
|
+
{caption}
|
|
832
|
+
</Text>
|
|
833
|
+
) : null}
|
|
834
|
+
</View>
|
|
835
|
+
{legend ? (
|
|
836
|
+
<View className="items-end gap-1">
|
|
837
|
+
{series.map(([key, color]) => (
|
|
838
|
+
<View key={key} className="flex-row items-center gap-1.5">
|
|
839
|
+
<View
|
|
840
|
+
className="h-2 w-2 rounded-full"
|
|
841
|
+
style={{ backgroundColor: color }}
|
|
842
|
+
/>
|
|
843
|
+
<Text size="sm" muted>
|
|
844
|
+
{key}
|
|
845
|
+
</Text>
|
|
846
|
+
</View>
|
|
847
|
+
))}
|
|
848
|
+
</View>
|
|
849
|
+
) : null}
|
|
850
|
+
{children}
|
|
851
|
+
</View>
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export interface RadarChartLegendProps {
|
|
856
|
+
className?: string;
|
|
857
|
+
/** Rewrites a series' name — the key is rarely what a reader should see. */
|
|
858
|
+
formatName?: (key: string) => string;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* The series, named and coloured.
|
|
863
|
+
*
|
|
864
|
+
* In the bottom-left of the plot rather than under it, which on a radar costs
|
|
865
|
+
* nothing: the shape is a circle in a square box, so the corners are empty by
|
|
866
|
+
* construction. Move it with `className`.
|
|
867
|
+
*/
|
|
868
|
+
function RadarChartLegend({ className, formatName }: RadarChartLegendProps) {
|
|
869
|
+
const { series } = useChart('RadarChart.Legend');
|
|
870
|
+
if (!series.length) return null;
|
|
871
|
+
|
|
872
|
+
return (
|
|
873
|
+
<View
|
|
874
|
+
className={cn('absolute bottom-0 left-0 gap-1', className)}
|
|
875
|
+
style={{ pointerEvents: 'none' }}
|
|
876
|
+
>
|
|
877
|
+
{series.map(([key, color]) => (
|
|
878
|
+
<View key={key} className="flex-row items-center gap-1.5">
|
|
879
|
+
<View className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
|
|
880
|
+
<Text size="sm" muted>
|
|
881
|
+
{formatName ? formatName(key) : key}
|
|
882
|
+
</Text>
|
|
883
|
+
</View>
|
|
884
|
+
))}
|
|
885
|
+
</View>
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
RadarChartRoot.displayName = 'RadarChart';
|
|
890
|
+
RadarChartGrid.displayName = 'RadarChart.Grid';
|
|
891
|
+
RadarChartAxis.displayName = 'RadarChart.Axis';
|
|
892
|
+
RadarChartSeries.displayName = 'RadarChart.Series';
|
|
893
|
+
RadarChartHeader.displayName = 'RadarChart.Header';
|
|
894
|
+
RadarChartLegend.displayName = 'RadarChart.Legend';
|
|
895
|
+
|
|
896
|
+
// Which layer each part draws into. Read by `partition` on the root, so a part
|
|
897
|
+
// can be written in any order and still land in the right place.
|
|
898
|
+
(RadarChartGrid as { layer?: Layer }).layer = 'svg';
|
|
899
|
+
(RadarChartAxis as { layer?: Layer }).layer = 'svg';
|
|
900
|
+
(RadarChartSeries as { layer?: Layer }).layer = 'svg';
|
|
901
|
+
(RadarChartHeader as { layer?: Layer }).layer = 'header';
|
|
902
|
+
(RadarChartLegend as { layer?: Layer }).layer = 'overlay';
|
|
903
|
+
|
|
904
|
+
export const RadarChart = Object.assign(RadarChartRoot, {
|
|
905
|
+
Header: RadarChartHeader,
|
|
906
|
+
Grid: RadarChartGrid,
|
|
907
|
+
Axis: RadarChartAxis,
|
|
908
|
+
Series: RadarChartSeries,
|
|
909
|
+
Legend: RadarChartLegend,
|
|
910
|
+
});
|