panelui-native 0.41.0 → 0.44.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 (63) hide show
  1. package/README.md +3 -2
  2. package/lib/module/components/card/index.js +8 -154
  3. package/lib/module/components/card/index.js.map +1 -1
  4. package/lib/module/components/color-picker/index.js +1395 -0
  5. package/lib/module/components/color-picker/index.js.map +1 -0
  6. package/lib/module/components/combobox/index.js +87 -19
  7. package/lib/module/components/combobox/index.js.map +1 -1
  8. package/lib/module/components/kpi-chart/index.js +648 -0
  9. package/lib/module/components/kpi-chart/index.js.map +1 -0
  10. package/lib/module/components/menu/index.js +40 -10
  11. package/lib/module/components/menu/index.js.map +1 -1
  12. package/lib/module/components/radar-chart/index.js +704 -0
  13. package/lib/module/components/radar-chart/index.js.map +1 -0
  14. package/lib/module/components/section-rail/index.js +32 -7
  15. package/lib/module/components/section-rail/index.js.map +1 -1
  16. package/lib/module/components/slider/index.js +240 -39
  17. package/lib/module/components/slider/index.js.map +1 -1
  18. package/lib/module/components/tabs/index.js +194 -10
  19. package/lib/module/components/tabs/index.js.map +1 -1
  20. package/lib/module/index.js +4 -0
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/theme/use-theme.js +1 -1
  23. package/lib/module/utils/chart.js +44 -0
  24. package/lib/module/utils/chart.js.map +1 -1
  25. package/lib/module/utils/color.js +268 -0
  26. package/lib/module/utils/color.js.map +1 -0
  27. package/lib/typescript/src/components/card/index.d.ts +0 -20
  28. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
  30. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
  31. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  32. package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
  33. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
  34. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
  36. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  38. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/slider/index.d.ts +19 -0
  40. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/tabs/index.d.ts +11 -1
  42. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  43. package/lib/typescript/src/index.d.ts +5 -1
  44. package/lib/typescript/src/index.d.ts.map +1 -1
  45. package/lib/typescript/src/utils/chart.d.ts +21 -0
  46. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  47. package/lib/typescript/src/utils/color.d.ts +74 -0
  48. package/lib/typescript/src/utils/color.d.ts.map +1 -0
  49. package/package.json +2 -1
  50. package/src/components/card/index.tsx +7 -209
  51. package/src/components/color-picker/index.tsx +1618 -0
  52. package/src/components/combobox/index.tsx +89 -19
  53. package/src/components/kpi-chart/index.tsx +719 -0
  54. package/src/components/menu/index.tsx +51 -6
  55. package/src/components/radar-chart/index.tsx +910 -0
  56. package/src/components/section-rail/index.tsx +42 -8
  57. package/src/components/slider/index.tsx +283 -41
  58. package/src/components/tabs/index.tsx +263 -8
  59. package/src/index.ts +61 -1
  60. package/src/theme/use-theme.ts +1 -1
  61. package/src/utils/chart.ts +52 -0
  62. package/src/utils/color.ts +246 -0
  63. package/theme.css +125 -112
@@ -33,18 +33,62 @@ import {
33
33
  type LayoutChangeEvent,
34
34
  type ViewProps,
35
35
  } from 'react-native';
36
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
36
37
  import Animated, {
37
38
  FadeIn,
39
+ runOnJS,
38
40
  useAnimatedStyle,
39
41
  useSharedValue,
40
42
  withSpring,
43
+ type SharedValue,
41
44
  } from 'react-native-reanimated';
42
45
  import { tv } from 'tailwind-variants';
46
+ import { useDirectionSign } from '../../hooks/use-direction';
43
47
  import { Text, textChildren } from '../../primitives/text';
44
48
  import { cn } from '../../utils/cn';
45
49
 
46
50
  const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const;
47
51
 
52
+ /**
53
+ * How far sideways a finger must travel before the panel takes the gesture
54
+ * from whatever is scrolling above it, and how far up or down before it gives
55
+ * up on it. The window between them is what lets a swipe start inside a
56
+ * vertically scrolling panel without either gesture stealing the other.
57
+ */
58
+ const SWIPE_ACTIVATE_X = 12;
59
+ const SWIPE_FAIL_Y = 8;
60
+
61
+ /** A swipe past this share of the panel's width changes tab on release. */
62
+ const SWIPE_DISTANCE_RATIO = 0.25;
63
+ /** …or past this speed, however short it was. */
64
+ const SWIPE_VELOCITY = 500;
65
+
66
+ /**
67
+ * How much of the finger's travel the panel actually follows.
68
+ *
69
+ * Not all of it: a panel that tracked one-to-one would look like a pager that
70
+ * is about to reveal the next panel, and there is nothing behind it to reveal
71
+ * — the panels are separate views, only one of which is mounted. A third of
72
+ * the distance is enough to say the gesture was received.
73
+ */
74
+ const SWIPE_FOLLOW = 0.32;
75
+ /** …and a fraction of that again at the ends, where there is nowhere to go. */
76
+ const SWIPE_FOLLOW_AT_END = 0.1;
77
+
78
+ /**
79
+ * How far off its resting place the arriving panel starts, as a share of the
80
+ * panel's width.
81
+ *
82
+ * A swipe that commits should not look like a swipe that was cancelled. Both
83
+ * end with a panel settling at rest, and the only thing that distinguishes
84
+ * them is which side it settled *from* — so the incoming panel picks up
85
+ * roughly where the outgoing one was let go, on the far side, and travels in.
86
+ */
87
+ const SWIPE_ENTER = 0.3;
88
+
89
+ /** The arriving panel is springier than the one being dragged back into place. */
90
+ const ENTER_SPRING = { damping: 22, stiffness: 240, mass: 0.6 } as const;
91
+
48
92
  export type TabsVariant = 'segmented' | 'underline' | 'pill';
49
93
 
50
94
  const tabsVariants = tv({
@@ -109,10 +153,33 @@ interface TabsContextValue {
109
153
  setValue: (value: string) => void;
110
154
  registerLayout: (value: string, layout: TabLayout) => void;
111
155
  layouts: Record<string, TabLayout>;
156
+ /**
157
+ * Every tab in the order it was declared, which is the order a swipe moves
158
+ * through them. Kept separately from `layouts` because that is a map keyed
159
+ * by value and its order is whatever the layout pass happened to produce —
160
+ * and because under RTL the leftmost trigger is the last one, so a position
161
+ * cannot stand in for a place in the sequence either.
162
+ */
163
+ tabs: string[];
164
+ registerTab: (value: string) => void;
165
+ unregisterTab: (value: string) => void;
112
166
  variant: TabsVariant;
113
167
  scrollable: boolean;
114
168
  setScrollable: (scrollable: boolean) => void;
115
169
  keepMounted: boolean;
170
+ swipeable: boolean;
171
+ /**
172
+ * How far the visible panel is displaced from its resting place, in points.
173
+ *
174
+ * It lives on the root rather than on a panel because the outgoing and
175
+ * incoming panels are different views, and a value that belonged to either
176
+ * of them would be destroyed at the moment of the handover. Shared, the
177
+ * displacement survives it: the panel being let go and the panel arriving
178
+ * are one continuous movement, drawn by two views in turn.
179
+ */
180
+ swipeOffset: SharedValue<number>;
181
+ /** The visible panel's measured width, which the throw above is a share of. */
182
+ panelWidth: SharedValue<number>;
116
183
  }
117
184
 
118
185
  const TabsContext = createContext<TabsContextValue | null>(null);
@@ -141,6 +208,16 @@ export interface TabsProps extends ViewProps {
141
208
  * Costs the render of every panel up front.
142
209
  */
143
210
  keepMounted?: boolean;
211
+ /**
212
+ * Move between tabs by dragging sideways on the panel, as well as by
213
+ * pressing the triggers.
214
+ *
215
+ * Off by default, because a panel is allowed to contain something that
216
+ * already wants a horizontal drag — a carousel, a slider, a row that swipes
217
+ * open — and the two cannot both have it. Turn it on for panels of ordinary
218
+ * scrolling content, where it is the gesture people try first.
219
+ */
220
+ swipeable?: boolean;
144
221
  children: ReactNode;
145
222
  }
146
223
 
@@ -151,11 +228,16 @@ function TabsRoot({
151
228
  defaultValue,
152
229
  variant = 'segmented',
153
230
  keepMounted = false,
231
+ swipeable = false,
154
232
  children,
155
233
  ...props
156
234
  }: TabsProps) {
157
235
  const [internalValue, setInternalValue] = useState(defaultValue);
158
236
  const [layouts, setLayouts] = useState<Record<string, TabLayout>>({});
237
+ const [tabs, setTabs] = useState<string[]>([]);
238
+ const swipeOffset = useSharedValue(0);
239
+ const panelWidth = useSharedValue(0);
240
+ const sign = useDirectionSign();
159
241
  // Published by the List rather than the root, because it is the List that
160
242
  // decides whether it scrolls — but the Triggers below it need to know.
161
243
  const [scrollable, setScrollable] = useState(false);
@@ -164,10 +246,37 @@ function TabsRoot({
164
246
 
165
247
  const setValue = useCallback(
166
248
  (next: string) => {
249
+ /*
250
+ * The arriving panel starts on the side it is arriving from, and travels
251
+ * in. Done here rather than in the panel because only the root knows
252
+ * both the tab being left and the tab being gone to — a panel knows
253
+ * which one it is, not which one it replaced — and because a tab changed
254
+ * by pressing a trigger deserves the same movement as one changed by
255
+ * swiping to it. Without it the two read as different features.
256
+ */
257
+ if (swipeable) {
258
+ const from = tabs.indexOf(resolvedValue);
259
+ const to = tabs.indexOf(next);
260
+ if (from !== -1 && to !== -1 && from !== to) {
261
+ swipeOffset.value =
262
+ (to > from ? 1 : -1) * sign * panelWidth.value * SWIPE_ENTER;
263
+ swipeOffset.value = withSpring(0, ENTER_SPRING);
264
+ }
265
+ }
266
+
167
267
  if (!isControlled) setInternalValue(next);
168
268
  onValueChange?.(next);
169
269
  },
170
- [isControlled, onValueChange]
270
+ [
271
+ isControlled,
272
+ onValueChange,
273
+ swipeable,
274
+ tabs,
275
+ resolvedValue,
276
+ sign,
277
+ swipeOffset,
278
+ panelWidth,
279
+ ]
171
280
  );
172
281
 
173
282
  const registerLayout = useCallback((tab: string, layout: TabLayout) => {
@@ -180,18 +289,50 @@ function TabsRoot({
180
289
  });
181
290
  }, []);
182
291
 
292
+ /*
293
+ * Triggers add themselves on mount, so the order is React's child order —
294
+ * the order they are written in, which is the order they are read in.
295
+ */
296
+ const registerTab = useCallback((tab: string) => {
297
+ setTabs((current) => (current.includes(tab) ? current : [...current, tab]));
298
+ }, []);
299
+
300
+ const unregisterTab = useCallback((tab: string) => {
301
+ setTabs((current) => current.filter((entry) => entry !== tab));
302
+ }, []);
303
+
183
304
  const context = useMemo(
184
305
  () => ({
185
306
  value: resolvedValue,
186
307
  setValue,
187
308
  registerLayout,
188
309
  layouts,
310
+ tabs,
311
+ registerTab,
312
+ unregisterTab,
189
313
  variant,
190
314
  scrollable,
191
315
  setScrollable,
192
316
  keepMounted,
317
+ swipeable,
318
+ swipeOffset,
319
+ panelWidth,
193
320
  }),
194
- [resolvedValue, setValue, registerLayout, layouts, variant, scrollable, keepMounted]
321
+ [
322
+ resolvedValue,
323
+ setValue,
324
+ registerLayout,
325
+ layouts,
326
+ tabs,
327
+ registerTab,
328
+ unregisterTab,
329
+ variant,
330
+ scrollable,
331
+ keepMounted,
332
+ swipeable,
333
+ swipeOffset,
334
+ panelWidth,
335
+ ]
195
336
  );
196
337
 
197
338
  return (
@@ -331,6 +472,15 @@ function TabsTrigger({
331
472
  [context, value]
332
473
  );
333
474
 
475
+ // Separate from the layout registration above, and earlier than it: a swipe
476
+ // needs to know the sequence, which is known at mount, not the positions,
477
+ // which are not known until the row has been laid out.
478
+ const { registerTab, unregisterTab } = context;
479
+ useEffect(() => {
480
+ registerTab(value);
481
+ return () => unregisterTab(value);
482
+ }, [value, registerTab, unregisterTab]);
483
+
334
484
  return (
335
485
  <Pressable
336
486
  accessibilityRole="tab"
@@ -360,6 +510,86 @@ export interface TabsContentProps extends ViewProps {
360
510
  function TabsContent({ className, value, children, style, ...props }: TabsContentProps) {
361
511
  const context = useTabs('Tabs.Content');
362
512
  const active = context.value === value;
513
+ const {
514
+ tabs,
515
+ setValue,
516
+ swipeable,
517
+ swipeOffset: offset,
518
+ panelWidth: width,
519
+ } = context;
520
+ const sign = useDirectionSign();
521
+ // Read on the UI thread while the finger is down, so the resistance at the
522
+ // ends is known without a round trip to JavaScript.
523
+ const index = useSharedValue(0);
524
+ const count = useSharedValue(0);
525
+
526
+ const position = tabs.indexOf(value);
527
+ useEffect(() => {
528
+ index.value = position;
529
+ count.value = tabs.length;
530
+ }, [position, tabs.length, index, count]);
531
+
532
+ const step = useCallback(
533
+ (delta: number) => {
534
+ const from = tabs.indexOf(value);
535
+ const next = tabs[from + delta];
536
+ if (next) setValue(next);
537
+ },
538
+ [tabs, value, setValue]
539
+ );
540
+
541
+ const pan = useMemo(
542
+ () =>
543
+ Gesture.Pan()
544
+ // Sideways past the threshold takes the gesture; any real vertical
545
+ // travel hands it back, so a panel that scrolls still scrolls.
546
+ .activeOffsetX([-SWIPE_ACTIVATE_X, SWIPE_ACTIVATE_X])
547
+ .failOffsetY([-SWIPE_FAIL_Y, SWIPE_FAIL_Y])
548
+ .onUpdate((event) => {
549
+ // Positive is "towards the previous tab" in reading order, which is
550
+ // rightwards under LTR and leftwards under RTL.
551
+ const travel = event.translationX * sign;
552
+ const atEnd =
553
+ (travel > 0 && index.value === 0) ||
554
+ (travel < 0 && index.value >= count.value - 1);
555
+ offset.value =
556
+ event.translationX * (atEnd ? SWIPE_FOLLOW_AT_END : SWIPE_FOLLOW);
557
+ })
558
+ .onEnd((event) => {
559
+ const travel = event.translationX * sign;
560
+ const speed = event.velocityX * sign;
561
+ const far = Math.abs(travel) > width.value * SWIPE_DISTANCE_RATIO;
562
+ const fast = Math.abs(speed) > SWIPE_VELOCITY;
563
+ const atEnd =
564
+ (travel > 0 && index.value === 0) ||
565
+ (travel < 0 && index.value >= count.value - 1);
566
+
567
+ if ((far || fast) && !atEnd) {
568
+ // Distance and speed can disagree — a flick back the way it came
569
+ // reads as a cancel — so the direction comes from whichever of the
570
+ // two crossed its threshold, speed first.
571
+ //
572
+ // No spring back to zero here: `setValue` on the root throws the
573
+ // displacement across to the side the next panel arrives from, and
574
+ // springing to rest first would undo it a frame before it happens.
575
+ runOnJS(step)(fast ? (speed < 0 ? 1 : -1) : travel < 0 ? 1 : -1);
576
+ return;
577
+ }
578
+
579
+ // Nothing changed hands, so this panel simply comes back.
580
+ offset.value = withSpring(0, SPRING);
581
+ })
582
+ .onFinalize((_event, success) => {
583
+ // A cancelled gesture never reaches `onEnd`, and would otherwise
584
+ // leave the panel wherever the finger abandoned it.
585
+ if (!success) offset.value = withSpring(0, SPRING);
586
+ }),
587
+ [sign, step, offset, width, index, count]
588
+ );
589
+
590
+ const followStyle = useAnimatedStyle(() => ({
591
+ transform: [{ translateX: offset.value }],
592
+ }));
363
593
 
364
594
  if (!active && !context.keepMounted) return null;
365
595
 
@@ -369,13 +599,31 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
369
599
  * it out of the reading order too. A screen reader walking through three
370
600
  * panels of a tab set it cannot see is worse than no tabs at all.
371
601
  */
372
- return (
602
+ const panel = (
373
603
  <Animated.View
374
- // Only worth animating when the panel is genuinely arriving. A kept
375
- // panel is already there; fading it in every time it is revealed would
376
- // undo the point of keeping it.
377
- entering={context.keepMounted ? undefined : FadeIn.duration(150)}
378
- style={[!active && { display: 'none' }, style]}
604
+ /*
605
+ * Only worth animating when the panel is genuinely arriving. A kept
606
+ * panel is already there; fading it in every time it is revealed would
607
+ * undo the point of keeping it.
608
+ *
609
+ * And never alongside the swipe: an entering layout animation owns the
610
+ * view's style for the length of it and will overwrite an animated style
611
+ * touching the same view, so the fade would eat the slide. When panels
612
+ * move, the movement is the entrance.
613
+ */
614
+ entering={context.keepMounted || swipeable ? undefined : FadeIn.duration(150)}
615
+ onLayout={(event: LayoutChangeEvent) => {
616
+ // The thresholds are a share of the panel, not of the screen: a tab
617
+ // set inside a card is narrower than the window, and a quarter of the
618
+ // window would be most of the way across it.
619
+ //
620
+ // Zero is ignored. Every panel reports into the same value, and a
621
+ // kept-mounted one is `display: none` — it measures as nothing, and
622
+ // letting it say so would leave the visible panel with no width.
623
+ const measured = event.nativeEvent.layout.width;
624
+ if (measured > 0) width.value = measured;
625
+ }}
626
+ style={[!active && { display: 'none' }, swipeable && followStyle, style]}
379
627
  pointerEvents={active ? 'auto' : 'none'}
380
628
  accessibilityElementsHidden={!active}
381
629
  importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
@@ -385,6 +633,13 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
385
633
  {textChildren(children)}
386
634
  </Animated.View>
387
635
  );
636
+
637
+ // Only the visible panel carries the gesture. A kept-mounted panel is
638
+ // `display: none` and takes no touches anyway, but attaching a detector to
639
+ // each of them would put several competing recognisers in the same tree.
640
+ if (!swipeable || !active) return panel;
641
+
642
+ return <GestureDetector gesture={pan}>{panel}</GestureDetector>;
388
643
  }
389
644
 
390
645
  export const Tabs = Object.assign(TabsRoot, {
package/src/index.ts CHANGED
@@ -231,6 +231,19 @@ export {
231
231
  type LngLatBounds,
232
232
  type ViewState,
233
233
  } from './components/map';
234
+ export {
235
+ RadarChart,
236
+ useRadarChart,
237
+ type RadarChartProps,
238
+ type RadarChartHandle,
239
+ type RadarChartHeaderProps,
240
+ type RadarChartGridProps,
241
+ type RadarChartAxisProps,
242
+ type RadarChartSeriesProps,
243
+ type RadarChartLegendProps,
244
+ type RadarChartDatum,
245
+ type RadarChartStatus,
246
+ } from './components/radar-chart';
234
247
  export {
235
248
  Select,
236
249
  type SelectProps,
@@ -296,7 +309,7 @@ export {
296
309
  type CalendarSelection,
297
310
  type DateRange,
298
311
  } from './components/calendar';
299
- export { Card, type CardProps, type CardWashProps } from './components/card';
312
+ export { Card, type CardProps } from './components/card';
300
313
  export { Checkbox, type CheckboxProps } from './components/checkbox';
301
314
  export {
302
315
  Chip,
@@ -306,6 +319,21 @@ export {
306
319
  type ChipVariant,
307
320
  type ChipSize,
308
321
  } from './components/chip';
322
+ export {
323
+ ColorPicker,
324
+ type ColorPickerProps,
325
+ type ColorPickerFieldProps,
326
+ type ColorPickerAreaProps,
327
+ type ColorPickerWheelProps,
328
+ type ColorPickerChannelProps,
329
+ type ColorPickerChannel,
330
+ type ColorPickerHueProps,
331
+ type ColorPickerBrightnessProps,
332
+ type ColorPickerAlphaProps,
333
+ type ColorPickerPreviewProps,
334
+ type ColorPickerSwatchesProps,
335
+ type ColorPickerSize,
336
+ } from './components/color-picker';
309
337
  export {
310
338
  Combobox,
311
339
  type ComboboxProps,
@@ -423,6 +451,26 @@ export {
423
451
  type ItemHeaderProps,
424
452
  type ItemFooterProps,
425
453
  } from './components/item';
454
+ export {
455
+ KpiChart,
456
+ type KpiChartProps,
457
+ type KpiChartHeaderProps,
458
+ type KpiChartIconProps,
459
+ type KpiChartTitleProps,
460
+ type KpiChartStatProps,
461
+ type KpiChartActionsProps,
462
+ type KpiChartContentProps,
463
+ type KpiChartValueProps,
464
+ type KpiChartTrendProps,
465
+ type KpiChartSparklineProps,
466
+ type KpiChartProgressProps,
467
+ type KpiChartFooterProps,
468
+ type KpiChartSeparatorProps,
469
+ type KpiChartGroupProps,
470
+ type KpiChartGroupOrientation,
471
+ type KpiGoodDirection,
472
+ type KpiTone,
473
+ } from './components/kpi-chart';
426
474
  export { Label, type LabelProps, type LabelTextProps } from './components/label';
427
475
  export {
428
476
  LineChart,
@@ -794,6 +842,18 @@ export * from './hooks';
794
842
 
795
843
  // Utils
796
844
  export { cn } from './utils/cn';
845
+ export {
846
+ formatColor,
847
+ hsvToCss,
848
+ hsvToHex,
849
+ hsvToHsl,
850
+ hsvToRgb,
851
+ isValidColor,
852
+ parseColor,
853
+ rgbToHsv,
854
+ type ColorFormat,
855
+ type HsvaColor,
856
+ } from './utils/color';
797
857
  export {
798
858
  clampTime,
799
859
  compareTime,
@@ -55,7 +55,7 @@ export const PANEL_THEMES: readonly PanelThemeFamily[] = [
55
55
  name: 'Moon',
56
56
  light: 'moon',
57
57
  dark: 'moon-dark',
58
- swatch: ['#0070f3', '#0070f3'],
58
+ swatch: ['#5e6ad2', '#5e6ad2'],
59
59
  },
60
60
  {
61
61
  id: 'grass',
@@ -342,6 +342,58 @@ export function arcPath(
342
342
  return `M${x1},${y1}A${radius},${radius} 0 ${sweep > 0.5 ? 1 : 0} 1 ${x2},${y2}`;
343
343
  }
344
344
 
345
+ /**
346
+ * A point at `radius` from the centre, `turn` of the way round.
347
+ *
348
+ * Turns from twelve o'clock, clockwise, matching `arcPath` — a radar's axes
349
+ * and a ring's arcs are described the same way, and two polar conventions in
350
+ * one file is how a spoke ends up a quarter turn from its own label.
351
+ */
352
+ export function polarPoint(
353
+ cx: number,
354
+ cy: number,
355
+ radius: number,
356
+ turn: number
357
+ ): { x: number; y: number } {
358
+ 'worklet';
359
+ const angle = (turn - 0.25) * Math.PI * 2;
360
+ return { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) };
361
+ }
362
+
363
+ /**
364
+ * A closed polygon through evenly spaced spokes.
365
+ *
366
+ * `values` are already scaled to 0…1 — the fraction of the full radius each
367
+ * axis reaches. A `null` is a missing reading rather than a zero: a radar with
368
+ * a hole punched through to its centre says "none of this" when what happened
369
+ * was "we did not measure this", so the gap is bridged by its neighbours and
370
+ * the shape stays a shape.
371
+ */
372
+ export function radarPath(
373
+ values: (number | null)[],
374
+ cx: number,
375
+ cy: number,
376
+ radius: number
377
+ ): string {
378
+ 'worklet';
379
+ const count = values.length;
380
+ if (count < 3 || radius <= 0) return '';
381
+
382
+ let path = '';
383
+ let drawn = 0;
384
+
385
+ for (let index = 0; index < count; index += 1) {
386
+ const value = values[index];
387
+ if (value === null || value === undefined) continue;
388
+ const point = polarPoint(cx, cy, radius * value, index / count);
389
+ path += `${drawn === 0 ? 'M' : 'L'}${point.x},${point.y}`;
390
+ drawn += 1;
391
+ }
392
+
393
+ if (drawn < 3) return '';
394
+ return `${path}Z`;
395
+ }
396
+
345
397
  /** The numbers of one column, with anything unusable left as a gap. */
346
398
  export function columnValues(
347
399
  data: Record<string, unknown>[],