panelui-native 0.60.0 → 0.62.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 (37) hide show
  1. package/README.md +3 -1
  2. package/lib/module/components/area-chart/index.js +86 -1
  3. package/lib/module/components/area-chart/index.js.map +1 -1
  4. package/lib/module/components/bar-chart/index.js +112 -2
  5. package/lib/module/components/bar-chart/index.js.map +1 -1
  6. package/lib/module/components/live-line-chart/index.js +1063 -0
  7. package/lib/module/components/live-line-chart/index.js.map +1 -0
  8. package/lib/module/components/polar-area-chart/index.js +719 -0
  9. package/lib/module/components/polar-area-chart/index.js.map +1 -0
  10. package/lib/module/components/treemap-chart/index.js +957 -0
  11. package/lib/module/components/treemap-chart/index.js.map +1 -0
  12. package/lib/module/index.js +3 -0
  13. package/lib/module/index.js.map +1 -1
  14. package/lib/module/utils/chart.js +171 -4
  15. package/lib/module/utils/chart.js.map +1 -1
  16. package/lib/typescript/src/components/area-chart/index.d.ts +28 -1
  17. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/bar-chart/index.d.ts +33 -3
  19. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  20. package/lib/typescript/src/components/live-line-chart/index.d.ts +293 -0
  21. package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -0
  22. package/lib/typescript/src/components/polar-area-chart/index.d.ts +259 -0
  23. package/lib/typescript/src/components/polar-area-chart/index.d.ts.map +1 -0
  24. package/lib/typescript/src/components/treemap-chart/index.d.ts +317 -0
  25. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -0
  26. package/lib/typescript/src/index.d.ts +3 -0
  27. package/lib/typescript/src/index.d.ts.map +1 -1
  28. package/lib/typescript/src/utils/chart.d.ts +27 -1
  29. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/components/area-chart/index.tsx +86 -1
  32. package/src/components/bar-chart/index.tsx +115 -4
  33. package/src/components/live-line-chart/index.tsx +1294 -0
  34. package/src/components/polar-area-chart/index.tsx +928 -0
  35. package/src/components/treemap-chart/index.tsx +1175 -0
  36. package/src/index.ts +52 -0
  37. package/src/utils/chart.ts +202 -4
@@ -0,0 +1,928 @@
1
+ /**
2
+ * PolarAreaChart — several parts, compared on one measure.
3
+ *
4
+ * ```tsx
5
+ * <PolarAreaChart data={scores}>
6
+ * <PolarAreaChart.Header title="Scores" />
7
+ * <PolarAreaChart.Grid />
8
+ * <PolarAreaChart.Wedges />
9
+ * <PolarAreaChart.Labels />
10
+ * <PolarAreaChart.Legend />
11
+ * </PolarAreaChart>
12
+ * ```
13
+ *
14
+ * ## What it is, against the pie beside it
15
+ *
16
+ * A pie divides one total: the angles are the quantity and they must come to a
17
+ * full turn, so a slice only means anything next to the others. Here every
18
+ * wedge takes the same angle and the *radius* is the quantity, which means the
19
+ * values do not have to add up to anything. Six unrelated readings on one scale
20
+ * are a polar area chart; six parts of one budget are a pie.
21
+ *
22
+ * What it buys over a bar chart is the shape. Bars are easier to read one at a
23
+ * time, and a reader who has to rank the middle three should be given bars. A
24
+ * dial is for the silhouette — which direction the weight sits in, whether one
25
+ * reading runs away from the rest — read at a glance and without a legend walk.
26
+ *
27
+ * ## Radius, and what it overstates
28
+ *
29
+ * `scale` decides what the radius means, and the two answers are not the same
30
+ * chart:
31
+ *
32
+ * - `radius` (the default) puts the value straight on the radius, so a reading
33
+ * can be counted off the rings. The cost is that a wedge worth twice another
34
+ * covers four times the area, and area is what the eye adds up first.
35
+ * - `area` puts it on the square root instead, so the ink is proportional to
36
+ * the value and nothing is overstated. The cost is the rings: they still mark
37
+ * equal steps, but they are no longer equally spaced.
38
+ *
39
+ * The grid is drawn through the same conversion either way, so a ring is always
40
+ * where its value falls rather than where an even split would put it.
41
+ *
42
+ * Touch, not hover: a wedge is selected by pressing it, and pressing the same
43
+ * one again clears the selection.
44
+ */
45
+ import {
46
+ Children,
47
+ createContext,
48
+ forwardRef,
49
+ isValidElement,
50
+ useContext,
51
+ useEffect,
52
+ useImperativeHandle,
53
+ useMemo,
54
+ useRef,
55
+ useState,
56
+ type ReactNode,
57
+ } from 'react';
58
+ import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
59
+ import Animated, {
60
+ Easing,
61
+ useAnimatedProps,
62
+ useReducedMotion,
63
+ useSharedValue,
64
+ withTiming,
65
+ type SharedValue,
66
+ } from 'react-native-reanimated';
67
+ import Svg, { Circle, G, Line, Path } from 'react-native-svg';
68
+ import { useCSSVariable } from 'uniwind';
69
+ import { Text } from '../../primitives/text';
70
+ import {
71
+ compactNumber,
72
+ inkOn,
73
+ polarPoint,
74
+ seriesColorAt,
75
+ useSeriesColor,
76
+ wedgePath,
77
+ } from '../../utils/chart';
78
+ import { cn } from '../../utils/cn';
79
+
80
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
81
+
82
+ /** How far along its own radius a wedge's label sits. */
83
+ const LABEL_AT = 0.62;
84
+
85
+ /** The side of the invisible box a label is centred in. */
86
+ const LABEL_BOX = 64;
87
+
88
+ /** Below this radius in points a wedge is left unlabelled. */
89
+ const DEFAULT_MIN_LABEL_RADIUS = 34;
90
+
91
+ const TOOLTIP_WIDTH = 132;
92
+ const TOOLTIP_HEIGHT = 30;
93
+
94
+ /** Where a child is drawn: inside the SVG, over it, above it, or under it. */
95
+ type Slot = 'svg' | 'overlay' | 'header' | 'footer';
96
+
97
+ /** Whether the chart is showing data or waiting for it. */
98
+ export type PolarAreaChartStatus = 'loading' | 'ready';
99
+
100
+ /** What a wedge's radius stands for. */
101
+ export type PolarAreaScale = 'radius' | 'area';
102
+
103
+ /** One wedge. Its angle is fixed, so only the value decides how far it reaches. */
104
+ export interface PolarAreaDatum {
105
+ /** Name for the legend, the readout and the accessibility label. */
106
+ label: string;
107
+ /** How far the wedge reaches, against `maxValue`. Negatives are treated as zero. */
108
+ value: number;
109
+ /** Explicit colour, overriding the `--color-chart-*` token. */
110
+ color?: string;
111
+ }
112
+
113
+ /** A wedge's place on the dial, in turns clockwise from twelve o'clock. */
114
+ interface WedgeGeometry {
115
+ from: number;
116
+ to: number;
117
+ /** Halfway along it, which is where its label sits. */
118
+ mid: number;
119
+ /** How far out it reaches, in points. */
120
+ radius: number;
121
+ /** Its value against the maximum, 0 to 1. */
122
+ fraction: number;
123
+ }
124
+
125
+ interface PolarAreaChartContextValue {
126
+ data: PolarAreaDatum[];
127
+ size: number;
128
+ /** Outer radius in points — the outermost ring, standing for `maxValue`. */
129
+ radius: number;
130
+ /** The value the outermost ring stands for. */
131
+ maxValue: number;
132
+ scale: PolarAreaScale;
133
+ colors: string[];
134
+ wedges: WedgeGeometry[];
135
+ /** A value's distance from the centre, in points, under the current scale. */
136
+ radiusOf: (value: number) => number;
137
+ /** 0 to 1 as the wedges grow out of the centre. */
138
+ reveal: SharedValue<number>;
139
+ status: PolarAreaChartStatus;
140
+ activeIndex: number;
141
+ setActiveIndex: (index: number) => void;
142
+ }
143
+
144
+ const PolarAreaChartContext = createContext<PolarAreaChartContextValue | null>(null);
145
+
146
+ function useChart(component: string): PolarAreaChartContextValue {
147
+ const context = useContext(PolarAreaChartContext);
148
+ if (!context) {
149
+ throw new Error(`${component} must be used within a <PolarAreaChart>`);
150
+ }
151
+ return context;
152
+ }
153
+
154
+ /** The selected wedge and its reading, for something rendered inside the chart. */
155
+ export function usePolarAreaChart() {
156
+ const { data, wedges, activeIndex } = useChart('usePolarAreaChart');
157
+ return {
158
+ activeIndex,
159
+ activeWedge: activeIndex >= 0 ? (data[activeIndex] ?? null) : null,
160
+ /** The selected wedge's value against the maximum, 0 to 1. */
161
+ activeFraction: activeIndex >= 0 ? (wedges[activeIndex]?.fraction ?? 0) : 0,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * The steps a scale is read in, as multiples of a power of ten.
167
+ *
168
+ * Finer than the usual 1/2/5 ladder on purpose. Here the maximum is a *radius*
169
+ * as well as a number: rounding 140 up to 200 leaves the largest wedge stopping
170
+ * at seven tenths of the dial with the outer third of it empty on every chart
171
+ * whose readings happen to start with a one.
172
+ */
173
+ const NICE_STEPS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
174
+
175
+ /**
176
+ * The first round number at or above `value`.
177
+ *
178
+ * The outermost ring is the number every other reading is judged against, so it
179
+ * is worth it landing on 150 rather than on 147.
180
+ */
181
+ function niceMax(value: number): number {
182
+ if (!Number.isFinite(value) || value <= 0) return 1;
183
+
184
+ const magnitude = 10 ** Math.floor(Math.log10(value));
185
+ const scaled = value / magnitude;
186
+ return (NICE_STEPS.find((step) => scaled <= step) ?? 10) * magnitude;
187
+ }
188
+
189
+ export interface PolarAreaChartProps extends ViewProps {
190
+ className?: string;
191
+ /** One entry per wedge, drawn in the order they are listed. */
192
+ data: PolarAreaDatum[];
193
+ /** Diameter in points. Left out, the chart fills its column as a square. */
194
+ size?: number;
195
+ /**
196
+ * The value the outermost ring stands for. Defaults to the largest value
197
+ * rounded up to a round number.
198
+ *
199
+ * Fix it to compare two dials against each other — the same reading has to be
200
+ * the same distance out on both, and a maximum derived per chart makes the
201
+ * largest wedge of each one reach the edge whatever it is worth.
202
+ */
203
+ maxValue?: number;
204
+ /** Whether the radius or the area carries the value. */
205
+ scale?: PolarAreaScale;
206
+ /** Where the first wedge starts, in degrees clockwise from twelve o'clock. */
207
+ startAngle?: number;
208
+ /** Gap between wedges, in degrees. */
209
+ padAngle?: number;
210
+ /** `loading` draws the dial undivided, with nothing split up yet. */
211
+ status?: PolarAreaChartStatus;
212
+ /** Milliseconds for the wedges to grow out of the centre. */
213
+ animationDuration?: number;
214
+ /** The selected wedge, to drive the selection from outside. */
215
+ activeIndex?: number;
216
+ onActiveIndexChange?: (index: number) => void;
217
+ children?: ReactNode;
218
+ }
219
+
220
+ export interface PolarAreaChartHandle {
221
+ /** Play the growth again. */
222
+ replay: () => void;
223
+ }
224
+
225
+ const PolarAreaChartRoot = forwardRef<PolarAreaChartHandle, PolarAreaChartProps>(
226
+ function PolarAreaChartRoot(
227
+ {
228
+ className,
229
+ data,
230
+ size,
231
+ maxValue: maxValueProp,
232
+ scale = 'radius',
233
+ startAngle = 0,
234
+ padAngle = 0,
235
+ status = 'ready',
236
+ animationDuration = 800,
237
+ activeIndex: activeIndexProp,
238
+ onActiveIndexChange,
239
+ children,
240
+ ...props
241
+ },
242
+ ref
243
+ ) {
244
+ const [measured, setMeasured] = useState(0);
245
+ const [internalActive, setInternalActive] = useState(-1);
246
+ const reveal = useSharedValue(0);
247
+ const reducedMotion = useReducedMotion();
248
+
249
+ const controlled = activeIndexProp !== undefined;
250
+ const activeIndex = controlled ? activeIndexProp : internalActive;
251
+
252
+ const setActiveIndex = useMemo(
253
+ () => (index: number) => {
254
+ if (!controlled) setInternalActive(index);
255
+ onActiveIndexChange?.(index);
256
+ },
257
+ [controlled, onActiveIndexChange]
258
+ );
259
+
260
+ const box = size ?? measured;
261
+ const radius = Math.max(box / 2, 0);
262
+
263
+ const maxValue = useMemo(() => {
264
+ if (maxValueProp !== undefined) return Math.max(maxValueProp, 0) || 1;
265
+ return niceMax(data.reduce((top, datum) => Math.max(top, datum.value), 0));
266
+ }, [data, maxValueProp]);
267
+
268
+ /*
269
+ * One conversion, shared by the wedges, the rings and the readout. Two
270
+ * copies of it would be two chances for a ring to sit somewhere its own
271
+ * value does not.
272
+ */
273
+ const radiusOf = useMemo(
274
+ () => (value: number) => {
275
+ const fraction = Math.min(Math.max(value, 0) / maxValue, 1);
276
+ return radius * (scale === 'area' ? Math.sqrt(fraction) : fraction);
277
+ },
278
+ [maxValue, radius, scale]
279
+ );
280
+
281
+ const origin = startAngle / 360;
282
+
283
+ const wedges = useMemo<WedgeGeometry[]>(() => {
284
+ const count = data.length;
285
+ if (!count || radius <= 0) return [];
286
+
287
+ const each = 1 / count;
288
+ // Never more than half a wedge from each side, or the two gaps meet in
289
+ // the middle and the wedge inverts.
290
+ const pad = Math.min(padAngle / 360, each / 2);
291
+
292
+ return data.map((datum, index) => {
293
+ const from = origin + each * index + pad / 2;
294
+ const to = origin + each * (index + 1) - pad / 2;
295
+ return {
296
+ from,
297
+ to,
298
+ mid: (from + to) / 2,
299
+ radius: radiusOf(datum.value),
300
+ fraction: Math.min(Math.max(datum.value, 0) / maxValue, 1),
301
+ };
302
+ });
303
+ }, [data, radius, origin, padAngle, radiusOf, maxValue]);
304
+
305
+ const playReveal = useMemo(
306
+ () => () => {
307
+ if (reducedMotion) {
308
+ reveal.value = 1;
309
+ return;
310
+ }
311
+ reveal.value = 0;
312
+ reveal.value = withTiming(1, {
313
+ duration: animationDuration,
314
+ easing: Easing.bezier(0.85, 0, 0.15, 1),
315
+ });
316
+ },
317
+ [reducedMotion, animationDuration, reveal]
318
+ );
319
+
320
+ const loading = status === 'loading';
321
+ const revealed = useRef(false);
322
+
323
+ useEffect(() => {
324
+ if (loading) {
325
+ revealed.current = false;
326
+ reveal.value = 0;
327
+ return;
328
+ }
329
+ if (revealed.current || box <= 0 || !wedges.length) return;
330
+ revealed.current = true;
331
+ playReveal();
332
+ }, [loading, box, wedges.length, playReveal, reveal]);
333
+
334
+ useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
335
+
336
+ // Resolved here rather than inside the wedges, so the legend, the header
337
+ // and the readout can name a wedge's colour without drawing one.
338
+ const c1 = useSeriesColor(undefined, 1);
339
+ const c2 = useSeriesColor(undefined, 2);
340
+ const c3 = useSeriesColor(undefined, 3);
341
+ const c4 = useSeriesColor(undefined, 4);
342
+ const c5 = useSeriesColor(undefined, 5);
343
+ const palette = useMemo(() => [c1, c2, c3, c4, c5], [c1, c2, c3, c4, c5]);
344
+ const colors = useMemo(
345
+ () => data.map((datum, index) => datum.color ?? seriesColorAt(palette, index)),
346
+ [data, palette]
347
+ );
348
+
349
+ const onLayout = (event: LayoutChangeEvent) => {
350
+ const { width, height } = event.nativeEvent.layout;
351
+ const next = Math.round(Math.min(width, height));
352
+ if (next !== measured) setMeasured(next);
353
+ props.onLayout?.(event);
354
+ };
355
+
356
+ const context = useMemo<PolarAreaChartContextValue>(
357
+ () => ({
358
+ data,
359
+ size: box,
360
+ radius,
361
+ maxValue,
362
+ scale,
363
+ colors,
364
+ wedges,
365
+ radiusOf,
366
+ reveal,
367
+ status,
368
+ activeIndex,
369
+ setActiveIndex,
370
+ }),
371
+ [
372
+ data,
373
+ box,
374
+ radius,
375
+ maxValue,
376
+ scale,
377
+ colors,
378
+ wedges,
379
+ radiusOf,
380
+ reveal,
381
+ status,
382
+ activeIndex,
383
+ setActiveIndex,
384
+ ]
385
+ );
386
+
387
+ const slots: Record<Slot, ReactNode[]> = {
388
+ svg: [],
389
+ overlay: [],
390
+ header: [],
391
+ footer: [],
392
+ };
393
+ Children.forEach(children, (child, index) => {
394
+ if (!isValidElement(child)) return;
395
+ const slot = (child.type as { slot?: Slot }).slot ?? 'overlay';
396
+ slots[slot in slots ? slot : 'overlay'].push(<ChildSlot key={index}>{child}</ChildSlot>);
397
+ });
398
+
399
+ return (
400
+ <PolarAreaChartContext.Provider value={context}>
401
+ {/*
402
+ * Two views, because the header is not part of the dial. The square and
403
+ * the layout measurement belong to the drawing area alone — measured on
404
+ * the outer view they would take in the header too, and the dial would
405
+ * be laid out inside a box taller than the one it is drawn in.
406
+ */}
407
+ <View {...props} style={props.style} className={cn('w-full', className)}>
408
+ {slots.header}
409
+ <View
410
+ onLayout={onLayout}
411
+ style={size ? { width: size, height: size } : { aspectRatio: 1 }}
412
+ className={cn('items-center justify-center', size ? 'self-center' : 'w-full')}
413
+ >
414
+ {box > 0 ? (
415
+ <>
416
+ <Svg width={box} height={box}>
417
+ {slots.svg}
418
+ </Svg>
419
+ {/*
420
+ * Over the SVG rather than inside it: these are text, and SVG
421
+ * text ignores the platform's text scaling and the theme's font.
422
+ */}
423
+ <View
424
+ pointerEvents="box-none"
425
+ style={{ position: 'absolute', width: box, height: box }}
426
+ >
427
+ {slots.overlay}
428
+ </View>
429
+ </>
430
+ ) : null}
431
+ </View>
432
+ {slots.footer}
433
+ </View>
434
+ </PolarAreaChartContext.Provider>
435
+ );
436
+ }
437
+ );
438
+ PolarAreaChartRoot.displayName = 'PolarAreaChart';
439
+
440
+ function ChildSlot({ children }: { children: ReactNode }) {
441
+ return <>{children}</>;
442
+ }
443
+
444
+ export interface PolarAreaChartWedgesProps {
445
+ /** Rounds the four turns of each wedge, in points. */
446
+ cornerRadius?: number;
447
+ /** Opacity of the wedges that are not selected, once one is. */
448
+ dimOpacity?: number;
449
+ }
450
+
451
+ /**
452
+ * Every wedge, drawn in the order the data lists them.
453
+ *
454
+ * One part rather than one per datum: the wedges share a dial, a maximum and a
455
+ * scale by definition, and a chart where one of them could be given a different
456
+ * maximum would be a chart drawing a lie.
457
+ */
458
+ function PolarAreaChartWedges({
459
+ cornerRadius = 0,
460
+ dimOpacity = 0.35,
461
+ }: PolarAreaChartWedgesProps) {
462
+ const { data, wedges, colors, size, reveal, status, activeIndex, setActiveIndex } =
463
+ useChart('PolarAreaChart.Wedges');
464
+
465
+ if (status === 'loading' || !wedges.length) return null;
466
+
467
+ return (
468
+ <G>
469
+ {wedges.map((wedge, index) => {
470
+ const datum = data[index];
471
+ if (!datum || wedge.radius <= 0 || wedge.to <= wedge.from) return null;
472
+ return (
473
+ <Wedge
474
+ key={`${datum.label}-${index}`}
475
+ wedge={wedge}
476
+ fill={colors[index] ?? colors[0]!}
477
+ centre={size / 2}
478
+ cornerRadius={cornerRadius}
479
+ reveal={reveal}
480
+ dimmed={activeIndex >= 0 && activeIndex !== index}
481
+ dimOpacity={dimOpacity}
482
+ label={datum.label}
483
+ value={datum.value}
484
+ onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
485
+ />
486
+ );
487
+ })}
488
+ </G>
489
+ );
490
+ }
491
+ PolarAreaChartWedges.displayName = 'PolarAreaChart.Wedges';
492
+ PolarAreaChartWedges.slot = 'svg' as const;
493
+
494
+ /**
495
+ * One wedge, rebuilt on the UI thread every frame it is growing.
496
+ *
497
+ * It grows outward rather than sweeping round, because the radius is the
498
+ * quantity here: a wedge half drawn is a smaller reading, which is the thing
499
+ * the chart is animating towards. A sweep would animate the one dimension that
500
+ * carries nothing.
501
+ */
502
+ function Wedge({
503
+ wedge,
504
+ fill,
505
+ centre,
506
+ cornerRadius,
507
+ reveal,
508
+ dimmed,
509
+ dimOpacity,
510
+ label,
511
+ value,
512
+ onPress,
513
+ }: {
514
+ wedge: WedgeGeometry;
515
+ fill: string;
516
+ centre: number;
517
+ cornerRadius: number;
518
+ reveal: SharedValue<number>;
519
+ dimmed: boolean;
520
+ dimOpacity: number;
521
+ label: string;
522
+ value: number;
523
+ onPress: () => void;
524
+ }) {
525
+ const { from, to, radius } = wedge;
526
+
527
+ const animatedProps = useAnimatedProps(() => ({
528
+ d: wedgePath(centre, centre, radius * reveal.value, 0, from, to, cornerRadius),
529
+ }));
530
+
531
+ return (
532
+ <AnimatedPath
533
+ animatedProps={animatedProps}
534
+ fill={fill}
535
+ fillOpacity={dimmed ? dimOpacity : 1}
536
+ onPress={onPress}
537
+ // An SVG node takes a label but not a role, so the wedges are reachable
538
+ // and named without being announced as buttons. `PolarAreaChart.Legend`
539
+ // is the properly wired way through the same selection.
540
+ accessibilityLabel={`${label}, ${compactNumber(value)}`}
541
+ />
542
+ );
543
+ }
544
+
545
+ export interface PolarAreaChartGridProps {
546
+ /** How many rings, including the outermost. */
547
+ rings?: number;
548
+ /** Overrides the themed hairline colour. */
549
+ color?: string;
550
+ /** Draw a line from the centre out along each wedge's edge. */
551
+ spokes?: boolean;
552
+ }
553
+
554
+ /**
555
+ * The scale, drawn as rings.
556
+ *
557
+ * Each ring stands for an even step of the value and is placed where that value
558
+ * falls, which under `scale="area"` is not an even step of the radius. Spacing
559
+ * them evenly instead would be quicker and would put every ring in the wrong
560
+ * place on half the charts.
561
+ */
562
+ function PolarAreaChartGrid({ rings = 4, color, spokes = false }: PolarAreaChartGridProps) {
563
+ const { size, radius, maxValue, radiusOf, wedges } = useChart('PolarAreaChart.Grid');
564
+ const token = useCSSVariable('--color-border');
565
+ const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
566
+
567
+ if (radius <= 0 || rings < 1) return null;
568
+
569
+ const centre = size / 2;
570
+
571
+ return (
572
+ <G>
573
+ {Array.from({ length: rings }, (_, index) => {
574
+ const r = radiusOf(((index + 1) / rings) * maxValue);
575
+ if (r <= 0) return null;
576
+ return (
577
+ <Circle
578
+ key={index}
579
+ cx={centre}
580
+ cy={centre}
581
+ r={r}
582
+ fill="none"
583
+ stroke={stroke}
584
+ strokeWidth={1}
585
+ />
586
+ );
587
+ })}
588
+ {spokes
589
+ ? wedges.map((wedge, index) => {
590
+ const point = polarPoint(centre, centre, radius, wedge.from);
591
+ return (
592
+ <Line
593
+ key={index}
594
+ x1={centre}
595
+ y1={centre}
596
+ x2={point.x}
597
+ y2={point.y}
598
+ stroke={stroke}
599
+ strokeWidth={1}
600
+ />
601
+ );
602
+ })
603
+ : null}
604
+ </G>
605
+ );
606
+ }
607
+ PolarAreaChartGrid.displayName = 'PolarAreaChart.Grid';
608
+ PolarAreaChartGrid.slot = 'svg' as const;
609
+
610
+ export interface PolarAreaChartLabelsProps {
611
+ /** Format the value. Defaults to a compact number. */
612
+ formatValue?: (value: number, datum: PolarAreaDatum) => string;
613
+ /** Wedges reaching less far than this, in points, are left unlabelled. */
614
+ minRadius?: number;
615
+ className?: string;
616
+ }
617
+
618
+ /**
619
+ * The reading on each wedge that has room for it.
620
+ *
621
+ * A wedge shorter than `minRadius` is left blank — the label would sit outside
622
+ * the wedge it belongs to, next to a neighbour it does not describe. Those are
623
+ * read through `Tooltip` and the legend instead.
624
+ *
625
+ * Each label takes its colour from the wedge under it. A wedge is a theme
626
+ * colour and a theme is free to set that anywhere on the scale, so a fixed
627
+ * white label disappears on the pale ones.
628
+ */
629
+ function PolarAreaChartLabels({
630
+ formatValue,
631
+ minRadius = DEFAULT_MIN_LABEL_RADIUS,
632
+ className,
633
+ }: PolarAreaChartLabelsProps) {
634
+ const { data, wedges, colors, size, status } = useChart('PolarAreaChart.Labels');
635
+ const backdrop = useCSSVariable('--color-background');
636
+
637
+ if (status === 'loading' || !wedges.length) return null;
638
+
639
+ const format = formatValue ?? ((value: number) => compactNumber(value));
640
+ const behind = typeof backdrop === 'string' ? backdrop : undefined;
641
+ const centre = size / 2;
642
+
643
+ return (
644
+ <>
645
+ {wedges.map((wedge, index) => {
646
+ const datum = data[index];
647
+ if (!datum || wedge.radius < minRadius) return null;
648
+
649
+ const point = polarPoint(centre, centre, wedge.radius * LABEL_AT, wedge.mid);
650
+ const ink = inkOn(colors[index] ?? colors[0]!, behind, 1);
651
+
652
+ return (
653
+ <View
654
+ key={`${datum.label}-${index}`}
655
+ // Not a target: the wedge underneath is one, and a box this size
656
+ // over the middle of a dial would take the presses meant for its
657
+ // neighbours as well as its own.
658
+ pointerEvents="none"
659
+ style={{
660
+ position: 'absolute',
661
+ left: point.x - LABEL_BOX / 2,
662
+ top: point.y - LABEL_BOX / 2,
663
+ width: LABEL_BOX,
664
+ height: LABEL_BOX,
665
+ }}
666
+ className={cn('items-center justify-center', className)}
667
+ >
668
+ <Text size="xs" weight="semibold" numberOfLines={1} style={{ color: ink.color }}>
669
+ {format(datum.value, datum)}
670
+ </Text>
671
+ </View>
672
+ );
673
+ })}
674
+ </>
675
+ );
676
+ }
677
+ PolarAreaChartLabels.displayName = 'PolarAreaChart.Labels';
678
+ PolarAreaChartLabels.slot = 'overlay' as const;
679
+
680
+ export interface PolarAreaChartTooltipProps {
681
+ /** Format the value. Defaults to a compact number. */
682
+ formatValue?: (value: number, datum: PolarAreaDatum) => string;
683
+ className?: string;
684
+ }
685
+
686
+ /**
687
+ * The readout for the selected wedge, floating over the dial.
688
+ *
689
+ * This is how the short wedges are named. They are the ones with no room for a
690
+ * label, so without it the chart answers questions about its largest readings
691
+ * only — which is the half the reader could already see.
692
+ */
693
+ function PolarAreaChartTooltip({ formatValue, className }: PolarAreaChartTooltipProps) {
694
+ const { data, wedges, size, activeIndex, status } = useChart('PolarAreaChart.Tooltip');
695
+
696
+ if (status === 'loading' || activeIndex < 0) return null;
697
+
698
+ const datum = data[activeIndex];
699
+ const wedge = wedges[activeIndex];
700
+ if (!datum || !wedge) return null;
701
+
702
+ const format = formatValue ?? ((value: number) => compactNumber(value));
703
+ const centre = size / 2;
704
+ const point = polarPoint(centre, centre, wedge.radius * LABEL_AT, wedge.mid);
705
+
706
+ // Over the wedge, then pushed back inside the square — a readout half off the
707
+ // edge is one the reader has to guess the rest of.
708
+ const left = Math.max(0, Math.min(size - TOOLTIP_WIDTH, point.x - TOOLTIP_WIDTH / 2));
709
+ const top = Math.max(0, Math.min(size - TOOLTIP_HEIGHT, point.y - TOOLTIP_HEIGHT / 2));
710
+
711
+ return (
712
+ <View
713
+ pointerEvents="none"
714
+ style={{ position: 'absolute', left, top, width: TOOLTIP_WIDTH }}
715
+ className={cn(
716
+ 'items-center rounded-lg border border-border bg-popover px-2 py-1 shadow-sm',
717
+ className
718
+ )}
719
+ >
720
+ <Text size="xs" weight="medium" numberOfLines={1}>
721
+ {datum.label}
722
+ </Text>
723
+ <Text size="xs" muted numberOfLines={1}>
724
+ {format(datum.value, datum)}
725
+ </Text>
726
+ </View>
727
+ );
728
+ }
729
+ PolarAreaChartTooltip.displayName = 'PolarAreaChart.Tooltip';
730
+ PolarAreaChartTooltip.slot = 'overlay' as const;
731
+
732
+ export interface PolarAreaChartLegendProps extends ViewProps {
733
+ className?: string;
734
+ /** Show each wedge's reading beside its name. */
735
+ showValue?: boolean;
736
+ /** Format the value. Defaults to a compact number. */
737
+ formatValue?: (value: number, datum: PolarAreaDatum) => string;
738
+ }
739
+
740
+ /**
741
+ * A swatch, a name and a reading per wedge, under the dial and across the width
742
+ * of it. Pressable in the same way the wedges are, and the easier target of the
743
+ * two for anything short.
744
+ *
745
+ * The reading rather than a share, unlike the pie next door: these values need
746
+ * not add up to anything, so a percentage of their sum would be a number about
747
+ * nothing.
748
+ */
749
+ function PolarAreaChartLegend({
750
+ className,
751
+ showValue = true,
752
+ formatValue,
753
+ ...props
754
+ }: PolarAreaChartLegendProps) {
755
+ const { data, colors, activeIndex, setActiveIndex } = useChart('PolarAreaChart.Legend');
756
+
757
+ if (!data.length) return null;
758
+
759
+ const format = formatValue ?? ((value: number) => compactNumber(value));
760
+
761
+ return (
762
+ <View
763
+ {...props}
764
+ className={cn(
765
+ 'w-full flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1.5 pt-3',
766
+ className
767
+ )}
768
+ >
769
+ {data.map((datum, index) => {
770
+ const dimmed = activeIndex >= 0 && activeIndex !== index;
771
+ return (
772
+ <Pressable
773
+ key={`${datum.label}-${index}`}
774
+ accessibilityRole="button"
775
+ accessibilityState={{ selected: activeIndex === index }}
776
+ accessibilityLabel={`${datum.label}, ${format(datum.value, datum)}`}
777
+ onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
778
+ style={{ opacity: dimmed ? 0.4 : 1 }}
779
+ className="max-w-full flex-row items-center gap-1.5"
780
+ >
781
+ <View
782
+ style={{
783
+ width: 8,
784
+ height: 8,
785
+ borderRadius: 4,
786
+ backgroundColor: colors[index],
787
+ }}
788
+ />
789
+ <Text size="xs" muted numberOfLines={1} className="shrink">
790
+ {datum.label}
791
+ </Text>
792
+ {showValue ? (
793
+ <Text size="xs" weight="medium">
794
+ {format(datum.value, datum)}
795
+ </Text>
796
+ ) : null}
797
+ </Pressable>
798
+ );
799
+ })}
800
+ </View>
801
+ );
802
+ }
803
+ PolarAreaChartLegend.displayName = 'PolarAreaChart.Legend';
804
+ PolarAreaChartLegend.slot = 'footer' as const;
805
+
806
+ export interface PolarAreaChartSkeletonProps {
807
+ color?: string;
808
+ }
809
+
810
+ /**
811
+ * The loading state: the dial as one plain disc, with nothing divided up yet.
812
+ *
813
+ * Deliberately undivided. Placeholder wedges would be a made-up set of
814
+ * readings, and a reader has no way to tell an invented one from a real one
815
+ * until it changes under them.
816
+ */
817
+ function PolarAreaChartSkeleton({ color }: PolarAreaChartSkeletonProps) {
818
+ const { size, radius, status } = useChart('PolarAreaChart.Skeleton');
819
+ const token = useCSSVariable('--color-skeleton');
820
+ const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
821
+
822
+ if (status !== 'loading' || radius <= 0) return null;
823
+
824
+ return <Circle cx={size / 2} cy={size / 2} r={radius * 0.62} fill={fill} />;
825
+ }
826
+ PolarAreaChartSkeleton.displayName = 'PolarAreaChart.Skeleton';
827
+ PolarAreaChartSkeleton.slot = 'svg' as const;
828
+
829
+ export interface PolarAreaChartHeaderProps extends ViewProps {
830
+ className?: string;
831
+ /** Small line above the value — what the chart is of. */
832
+ title?: string;
833
+ /** The readout. The largest thing on the card, and the first thing read. */
834
+ value?: string;
835
+ /** One muted line under the value — a period, a comparison, a caveat. */
836
+ caption?: string;
837
+ /** Prettier names for the wedges, keyed by their `label`. */
838
+ labels?: Record<string, string>;
839
+ /**
840
+ * Draw a swatch and a name per wedge along the trailing edge.
841
+ *
842
+ * For two or three short names. Past that use `PolarAreaChart.Legend`, which
843
+ * runs under the dial across the full width.
844
+ */
845
+ legend?: boolean;
846
+ /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
847
+ children?: ReactNode;
848
+ }
849
+
850
+ /**
851
+ * The strip above the dial: what the chart is of, what it reads, and what the
852
+ * colours mean.
853
+ *
854
+ * The value is not derived here even though there are values to derive one
855
+ * from, because the formatting is not the chart's to guess: 18420 is a count, a
856
+ * currency or a duration depending on what was measured.
857
+ */
858
+ function PolarAreaChartHeader({
859
+ className,
860
+ title,
861
+ value,
862
+ caption,
863
+ labels,
864
+ legend = false,
865
+ children,
866
+ ...props
867
+ }: PolarAreaChartHeaderProps) {
868
+ const { data, colors } = useChart('PolarAreaChart.Header');
869
+ const trailing =
870
+ children ??
871
+ (legend && data.length ? (
872
+ <View className="flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1">
873
+ {data.map((datum, index) => (
874
+ <View key={`${datum.label}-${index}`} className="flex-row items-center gap-1.5">
875
+ <View
876
+ style={{
877
+ width: 8,
878
+ height: 8,
879
+ borderRadius: 4,
880
+ backgroundColor: colors[index],
881
+ }}
882
+ />
883
+ <Text size="xs" muted numberOfLines={1}>
884
+ {labels?.[datum.label] ?? datum.label}
885
+ </Text>
886
+ </View>
887
+ ))}
888
+ </View>
889
+ ) : null);
890
+
891
+ return (
892
+ <View
893
+ {...props}
894
+ className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
895
+ >
896
+ <View className="flex-1 gap-0.5">
897
+ {title ? (
898
+ <Text size="xs" muted>
899
+ {title}
900
+ </Text>
901
+ ) : null}
902
+ {value ? (
903
+ <Text size="xl" weight="bold">
904
+ {value}
905
+ </Text>
906
+ ) : null}
907
+ {caption ? (
908
+ <Text size="xs" muted>
909
+ {caption}
910
+ </Text>
911
+ ) : null}
912
+ </View>
913
+ {trailing ? <View className="max-w-[55%] shrink pt-1">{trailing}</View> : null}
914
+ </View>
915
+ );
916
+ }
917
+ PolarAreaChartHeader.displayName = 'PolarAreaChart.Header';
918
+ PolarAreaChartHeader.slot = 'header' as const;
919
+
920
+ export const PolarAreaChart = Object.assign(PolarAreaChartRoot, {
921
+ Header: PolarAreaChartHeader,
922
+ Grid: PolarAreaChartGrid,
923
+ Wedges: PolarAreaChartWedges,
924
+ Labels: PolarAreaChartLabels,
925
+ Tooltip: PolarAreaChartTooltip,
926
+ Legend: PolarAreaChartLegend,
927
+ Skeleton: PolarAreaChartSkeleton,
928
+ });