panelui-native 0.81.0 → 0.82.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 (67) hide show
  1. package/lib/module/components/bottom-sheet/index.js +90 -17
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/button/index.js +13 -0
  4. package/lib/module/components/button/index.js.map +1 -1
  5. package/lib/module/components/button-group/index.js +3 -0
  6. package/lib/module/components/button-group/index.js.map +1 -1
  7. package/lib/module/components/image-generation/dot-field.js +210 -0
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -0
  9. package/lib/module/components/image-generation/index.js +417 -0
  10. package/lib/module/components/image-generation/index.js.map +1 -0
  11. package/lib/module/components/kpi/index.js +2 -4
  12. package/lib/module/components/kpi/index.js.map +1 -1
  13. package/lib/module/components/menu/index.js +8 -16
  14. package/lib/module/components/menu/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +340 -33
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/select/index.js +127 -23
  18. package/lib/module/components/select/index.js.map +1 -1
  19. package/lib/module/components/slide-button/index.js +610 -0
  20. package/lib/module/components/slide-button/index.js.map +1 -0
  21. package/lib/module/components/slide-button/slide-button-track.js +98 -0
  22. package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
  23. package/lib/module/icons/index.js +553 -1934
  24. package/lib/module/icons/index.js.map +1 -1
  25. package/lib/module/index.js +3 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/primitives/portal.js +15 -1
  28. package/lib/module/primitives/portal.js.map +1 -1
  29. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  30. package/lib/typescript/src/components/button/index.d.ts +12 -0
  31. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  32. package/lib/typescript/src/components/button-group/index.d.ts +9 -0
  33. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/image-generation/dot-field.d.ts +61 -0
  35. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
  36. package/lib/typescript/src/components/image-generation/index.d.ts +126 -0
  37. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
  38. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/panelside/index.d.ts +194 -10
  41. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/select/index.d.ts +75 -2
  43. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
  45. package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
  46. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
  47. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
  48. package/lib/typescript/src/icons/index.d.ts +136 -189
  49. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  50. package/lib/typescript/src/index.d.ts +4 -2
  51. package/lib/typescript/src/index.d.ts.map +1 -1
  52. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  53. package/package.json +3 -2
  54. package/src/components/bottom-sheet/index.tsx +97 -17
  55. package/src/components/button/index.tsx +10 -2
  56. package/src/components/button-group/index.tsx +1 -0
  57. package/src/components/image-generation/dot-field.ts +226 -0
  58. package/src/components/image-generation/index.tsx +488 -0
  59. package/src/components/kpi/index.tsx +2 -4
  60. package/src/components/menu/index.tsx +10 -15
  61. package/src/components/panelside/index.tsx +498 -33
  62. package/src/components/select/index.tsx +158 -31
  63. package/src/components/slide-button/index.tsx +681 -0
  64. package/src/components/slide-button/slide-button-track.ts +100 -0
  65. package/src/icons/index.tsx +338 -1327
  66. package/src/index.ts +28 -0
  67. package/src/primitives/portal.tsx +15 -1
@@ -1 +1 @@
1
- {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../../src/primitives/portal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAsCf,wBAAgB,cAAc,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAgBnE;AAUD,4EAA4E;AAC5E,wBAAgB,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,QAU3D;AAED,wDAAwD;AACxD,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAehE;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAOvE;AAED,qDAAqD;AACrD,wBAAgB,uBAAuB,IAAI,OAAO,CAGjD;AAkBD,mFAAmF;AACnF,wBAAgB,UAAU,gCAWzB"}
1
+ {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../../src/primitives/portal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAsCf,wBAAgB,cAAc,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAgBnE;AAUD,4EAA4E;AAC5E,wBAAgB,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,QAwB3D;AAED,wDAAwD;AACxD,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAehE;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAOvE;AAED,qDAAqD;AACrD,wBAAgB,uBAAuB,IAAI,OAAO,CAGjD;AAkBD,mFAAmF;AACnF,wBAAgB,UAAU,gCAWzB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panelui-native",
3
- "version": "0.81.0",
3
+ "version": "0.82.0",
4
4
  "description": "High-performance React Native UI components for Expo. Semantic design tokens, powered by Uniwind (Tailwind v4) and Reanimated.",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",
@@ -149,8 +149,9 @@
149
149
  "uniwind": ">=1.0.0"
150
150
  },
151
151
  "dependencies": {
152
+ "@hugeicons/core-free-icons": "^4.3.0",
153
+ "@hugeicons/react-native": "^1.0.16",
152
154
  "clsx": "^2.1.1",
153
- "lucide-react-native": "^1.28.0",
154
155
  "tailwind-merge": "^3.3.1",
155
156
  "tailwind-variants": "^3.2.2"
156
157
  },
@@ -6,6 +6,7 @@ import {
6
6
  useContext,
7
7
  useEffect,
8
8
  useMemo,
9
+ useRef,
9
10
  useState,
10
11
  type ComponentProps,
11
12
  type ReactElement,
@@ -23,10 +24,9 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
23
24
  import Animated, {
24
25
  Extrapolation,
25
26
  FadeIn,
27
+ cancelAnimation,
26
28
  interpolate,
27
- FadeOut,
28
29
  SlideInDown,
29
- SlideOutDown,
30
30
  runOnJS,
31
31
  useAnimatedScrollHandler,
32
32
  useAnimatedStyle,
@@ -80,10 +80,6 @@ const DISMISS_VELOCITY = 800;
80
80
  * is a new object every commit, and the sheet re-renders while it is open.
81
81
  */
82
82
  const ENTERING = SlideInDown.springify().dampingRatio(0.9).duration(420);
83
- // Leaving is quicker than arriving, but not by much: the sheet is still
84
- // crossing the whole screen, and a surface that big snapping out of existence
85
- // is more startling than the delay it saves is worth.
86
- const EXITING = SlideOutDown.springify().dampingRatio(1).duration(340);
87
83
 
88
84
  /**
89
85
  * Where a flick would come to rest if it kept decelerating — Apple's
@@ -375,15 +371,98 @@ function BottomSheetContent({
375
371
  useBackHandler(open && dismissible, close);
376
372
 
377
373
  /*
378
- * The offset survives a close — the early return below sits after every
379
- * hook, so this component keeps its state while the sheet is hidden. A
380
- * swipe-dismiss leaves it parked a screen height down, and without this the
381
- * next open would draw the sheet below the fold behind a full backdrop:
382
- * a dimmed screen with nothing on it and no reachable close button.
374
+ * The sheet outlives `open`, and this is what keeps it alive.
375
+ *
376
+ * It used to be removed from the tree in the same commit `open` flipped,
377
+ * while an `exiting` layout animation was still declared on it. Reanimated
378
+ * then held the detached views until that spring settled — and a `duration`
379
+ * on a `springify()` animation is a target rather than a stop, so how long
380
+ * that took was not something this file decided. Reopening inside that window
381
+ * put a second sheet on screen with nothing coordinating the two.
382
+ *
383
+ * Now one animation owns the whole travel. Closing springs the sheet down and
384
+ * clears `presented` from the spring's own completion callback, so the tree
385
+ * comes down *after* the movement rather than during it. The backdrop is
386
+ * derived from the same value, so it goes with it.
387
+ *
388
+ * Reopening mid-exit is the case this really fixes: `presented` is still
389
+ * true, so nothing remounts and nothing new is scheduled — the spring is
390
+ * cancelled and the same sheet is caught on its way down.
383
391
  */
392
+ const [presented, setPresented] = useState(open);
393
+
384
394
  useEffect(() => {
385
- if (open) translateY.value = 0;
386
- }, [open, translateY]);
395
+ if (open) {
396
+ cancelAnimation(translateY);
397
+ if (!presented) {
398
+ // A fresh mount starts at rest and ENTERING does the travel. Catching
399
+ // one that is still on screen must not reset it under the finger, so
400
+ // this only runs when the sheet had actually gone.
401
+ translateY.value = 0;
402
+ setPresented(true);
403
+ return;
404
+ }
405
+ translateY.value = reducedMotion
406
+ ? withTiming(0, { duration: 150 })
407
+ : withSpring(0, SPRING);
408
+ return;
409
+ }
410
+
411
+ if (!presented) return;
412
+
413
+ const land = (finished?: boolean) => {
414
+ 'worklet';
415
+ if (finished) runOnJS(setPresented)(false);
416
+ };
417
+ translateY.value = reducedMotion
418
+ ? withTiming(screenHeight, { duration: 150 }, land)
419
+ : withSpring(screenHeight, EXIT_SPRING, land);
420
+ }, [open, presented, reducedMotion, screenHeight, translateY]);
421
+
422
+ /*
423
+ * The native sheet's own presentation queue, which the platform does not have.
424
+ *
425
+ * UIKit refuses to present a sheet while the previous one is still being
426
+ * dismissed, and the request is dropped rather than queued — so a close
427
+ * followed immediately by an open leaves nothing on screen, and the reader
428
+ * sees a sheet that "took a moment" or never came at all.
429
+ *
430
+ * `isPresented` is therefore driven from here rather than straight from
431
+ * `open`. A present that arrives during a dismissal is held, and replayed
432
+ * from the platform's own `onDismiss`, which is the only signal that says the
433
+ * previous sheet has finished going away.
434
+ */
435
+ const [nativePresented, setNativePresented] = useState(open);
436
+ const nativeDismissing = useRef(false);
437
+ const openRef = useRef(open);
438
+ openRef.current = open;
439
+
440
+ useEffect(() => {
441
+ if (!nativeSheet) return;
442
+
443
+ if (open) {
444
+ if (nativeDismissing.current) return;
445
+ setNativePresented(true);
446
+ return;
447
+ }
448
+
449
+ if (nativePresented) nativeDismissing.current = true;
450
+ setNativePresented(false);
451
+ }, [nativePresented, nativeSheet, open]);
452
+
453
+ const onNativeDismiss = useCallback(() => {
454
+ // Ours, or the reader's? A dismissal we asked for has to hand the queue
455
+ // back; one the reader performed has to be reported as a close.
456
+ const wasOurs = nativeDismissing.current;
457
+ nativeDismissing.current = false;
458
+
459
+ if (wasOurs) {
460
+ if (openRef.current) setNativePresented(true);
461
+ return;
462
+ }
463
+
464
+ if (dismissible) close();
465
+ }, [close, dismissible]);
387
466
 
388
467
  const pan = useMemo(
389
468
  () =>
@@ -501,8 +580,8 @@ function BottomSheetContent({
501
580
  return (
502
581
  <Host matchContents ignoreSafeArea="keyboard" style={{ position: 'absolute' }}>
503
582
  <NativeBottomSheet
504
- isPresented={open}
505
- onDismiss={dismissible ? close : () => {}}
583
+ isPresented={nativePresented}
584
+ onDismiss={onNativeDismiss}
506
585
  snapPoints={snapPoints}
507
586
  >
508
587
  <RNHostView matchContents>
@@ -536,7 +615,9 @@ function BottomSheetContent({
536
615
  );
537
616
  }
538
617
 
539
- if (!open) return null;
618
+ // `presented`, not `open`: the sheet stays in the tree until its own exit
619
+ // animation has finished putting it away.
620
+ if (!presented) return null;
540
621
 
541
622
  return (
542
623
  <ModalPortal>
@@ -571,7 +652,6 @@ function BottomSheetContent({
571
652
  * setting is about.
572
653
  */
573
654
  entering={reducedMotion ? FadeIn.duration(150) : ENTERING}
574
- exiting={reducedMotion ? FadeOut.duration(150) : EXITING}
575
655
  accessibilityViewIsModal
576
656
  className={sheetVariants({ detached, className })}
577
657
  {...props}
@@ -63,6 +63,12 @@ const buttonVariants = tv({
63
63
  sm: { root: 'min-h-9 min-w-9 gap-1.5 px-2.5 py-2', label: 'text-[14px]' },
64
64
  md: { root: 'min-h-11 min-w-11 px-4 py-2.5', label: 'text-[16px]' },
65
65
  lg: { root: 'min-h-12 px-6 py-2.5', label: 'text-[18px]' },
66
+ // A step above `lg`, for the one control a screen is built around — a
67
+ // compose button in a navigation panel, a primary action alone at the
68
+ // bottom of a sheet. It maps onto the platform's own extra-large control
69
+ // size under `native`, which is the size it exists to reach: below it,
70
+ // `large` is the biggest a platform button can be asked to be.
71
+ xl: { root: 'min-h-14 px-8 py-3', label: 'text-[18px]' },
66
72
  // Icon-only controls have no scalable label, so their square is stable.
67
73
  icon: { root: 'h-11 w-11 px-0' },
68
74
  },
@@ -140,10 +146,10 @@ export function useButtonGroup(): ButtonGroupContextValue | null {
140
146
  */
141
147
  const ATTACHED = 'rounded-none border-transparent shadow-none active:bg-accent';
142
148
 
143
- const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', icon: 'sm' } as const;
149
+ const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', xl: 'md', icon: 'sm' } as const;
144
150
 
145
151
  /** Extra room around the styled sizes so even the compact button reaches 48dp. */
146
- const BUTTON_HIT_SLOP = { sm: 6, md: 2, lg: 0, icon: 2 } as const;
152
+ const BUTTON_HIT_SLOP = { sm: 6, md: 2, lg: 0, xl: 0, icon: 2 } as const;
147
153
 
148
154
  /**
149
155
  * The theme token each variant's content reads against. Icons in the content
@@ -239,6 +245,7 @@ const NATIVE_HEIGHT: Record<NonNullable<ButtonVariantProps['size']>, number> = {
239
245
  sm: 36,
240
246
  md: 44,
241
247
  lg: 48,
248
+ xl: 56,
242
249
  icon: 44,
243
250
  };
244
251
 
@@ -283,6 +290,7 @@ const NATIVE_CONTROL_SIZE = {
283
290
  sm: 'small',
284
291
  md: 'regular',
285
292
  lg: 'large',
293
+ xl: 'extraLarge',
286
294
  icon: 'regular',
287
295
  } as const;
288
296
 
@@ -85,6 +85,7 @@ const buttonGroupVariants = tv({
85
85
  sm: { root: 'rounded-lg' },
86
86
  md: { root: 'rounded-xl' },
87
87
  lg: { root: 'rounded-xl' },
88
+ xl: { root: 'rounded-2xl' },
88
89
  icon: { root: 'rounded-xl' },
89
90
  },
90
91
  fullWidth: {
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The dot field's drawing, kept out of the component so it can be checked
3
+ * without a renderer — the same split `progress-button-hold` uses.
4
+ *
5
+ * ## Nothing is built while it is running
6
+ *
7
+ * The field is hundreds of dots and it is on screen for as long as a model
8
+ * takes to answer, on a page that may be showing several at once. So every
9
+ * frame it will ever draw is built when the box is measured, and playing it is
10
+ * handing react-native-svg a string that already exists.
11
+ *
12
+ * Two earlier versions did this the obvious ways and both stalled a page of
13
+ * them on scroll: one rebuilt every dot as a path string on every frame, the
14
+ * other clipped a moving shape of light to a path with hundreds of subpaths in
15
+ * it, which is re-rasterized every time the view is drawn.
16
+ */
17
+
18
+ /** Points between one dot and the next. */
19
+ export const DOT_GAP = 10;
20
+
21
+ /** The dot's radius where the light is not, and where it is strongest. */
22
+ export const DOT_RADIUS = 1;
23
+ export const DOT_LIT_RADIUS = 1.9;
24
+
25
+ /** How visible the resting grid is, under everything. */
26
+ export const DOT_REST_OPACITY = 0.16;
27
+
28
+ /** How many still frames one loop is drawn as. */
29
+ export const FRAMES = 24;
30
+
31
+ /**
32
+ * The most dots the field will draw, whatever box it is given.
33
+ *
34
+ * The grid opens up rather than the count running away: a full-width field on
35
+ * a tablet gets the same picture drawn coarser, which costs the same and looks
36
+ * the same at arm's length.
37
+ */
38
+ const MAX_DOTS = 700;
39
+
40
+ /** How the light moves through the field. */
41
+ export type DotFieldAnimation = 'drift' | 'pulse' | 'scan';
42
+
43
+ /** The gap this box is drawn at, opened up if the grid would be too dense. */
44
+ export function gapFor(width: number, height: number): number {
45
+ if (width <= 0 || height <= 0) return DOT_GAP;
46
+ const cells = (width / DOT_GAP + 1) * (height / DOT_GAP + 1);
47
+ return cells > MAX_DOTS ? DOT_GAP * Math.sqrt(cells / MAX_DOTS) : DOT_GAP;
48
+ }
49
+
50
+ /** A circle as an SVG subpath, which is two half arcs. */
51
+ function circle(x: number, y: number, r: number): string {
52
+ const d = (r * 2).toFixed(2);
53
+ return `M${(x - r).toFixed(2)},${y.toFixed(2)}a${r},${r} 0 1,0 ${d},0a${r},${r} 0 1,0 -${d},0`;
54
+ }
55
+
56
+ /**
57
+ * Every dot's anchor, in reading order.
58
+ *
59
+ * Laid out inside the box by the widest a dot can be drawn, and counted with
60
+ * `floor` rather than `ceil`, so no part of any dot falls outside it.
61
+ *
62
+ * Counted with `ceil` the grid overhangs by up to half a gap on every side.
63
+ * That is invisible while the light stays near the middle and obvious the
64
+ * moment it reaches an edge — which the scanning band does by design, and a
65
+ * short wide box does simply by being short. The dots then draw outside the
66
+ * card, which looks like the animation has escaped it.
67
+ *
68
+ * The row and column are still centred in whatever is left over, because laid
69
+ * out from the origin the field ends with a strip of nothing down one edge.
70
+ */
71
+ function anchors(width: number, height: number): [number, number][] {
72
+ const gap = gapFor(width, height);
73
+ const margin = DOT_LIT_RADIUS;
74
+ const innerWidth = Math.max(0, width - margin * 2);
75
+ const innerHeight = Math.max(0, height - margin * 2);
76
+
77
+ const columns = Math.floor(innerWidth / gap) + 1;
78
+ const rows = Math.floor(innerHeight / gap) + 1;
79
+ const offsetX = margin + (innerWidth - (columns - 1) * gap) / 2;
80
+ const offsetY = margin + (innerHeight - (rows - 1) * gap) / 2;
81
+
82
+ const out: [number, number][] = [];
83
+ for (let row = 0; row < rows; row += 1) {
84
+ for (let column = 0; column < columns; column += 1) {
85
+ out.push([offsetX + column * gap, offsetY + row * gap]);
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** The resting grid as one path. Drawn once, under the light. */
92
+ export function gridPath(width: number, height: number): string {
93
+ if (width <= 0 || height <= 0) return '';
94
+ let path = '';
95
+ for (const [x, y] of anchors(width, height)) path += circle(x, y, DOT_RADIUS);
96
+ return path;
97
+ }
98
+
99
+ /** How many dots {@link gridPath} would draw for a box. */
100
+ export function dotCount(width: number, height: number): number {
101
+ if (width <= 0 || height <= 0) return 0;
102
+ return anchors(width, height).length;
103
+ }
104
+
105
+ /**
106
+ * How strongly a dot at `distance` from the light is lit.
107
+ *
108
+ * Smoothstep rather than a linear ramp: linear gives the light a hard rim,
109
+ * because the eye finds the discontinuity in the first derivative. This one
110
+ * arrives and leaves at zero slope, so the light has no edge.
111
+ */
112
+ export function influenceAt(distance: number, radius: number): number {
113
+ if (radius <= 0) return 0;
114
+ const proximity = Math.max(0, 1 - distance / radius);
115
+ return proximity * proximity * (3 - 2 * proximity);
116
+ }
117
+
118
+ /**
119
+ * Where the light is at a phase of the loop, and how wide it reaches.
120
+ *
121
+ * `drift` wanders around the middle — two periods that do not divide into each
122
+ * other, so the path never closes into a loop the eye can learn, and small
123
+ * amplitudes because a light that reaches the corners stops reading as one
124
+ * source. `pulse` is a ring leaving the centre. `scan` crosses as a band, which
125
+ * is the same maths with the light infinitely tall.
126
+ */
127
+ function lightAt(
128
+ animation: DotFieldAnimation,
129
+ phase: number,
130
+ width: number,
131
+ height: number
132
+ ): { x: number; y: number; radius: number; ring: number } {
133
+ const short = Math.min(width, height);
134
+ const turn = phase * Math.PI * 2;
135
+
136
+ if (animation === 'pulse') {
137
+ const reach = Math.sqrt(width * width + height * height) / 2;
138
+ return { x: width / 2, y: height / 2, radius: short * 0.3, ring: phase * reach };
139
+ }
140
+
141
+ if (animation === 'scan') {
142
+ // A band: infinitely tall, so only the horizontal distance counts.
143
+ //
144
+ // It starts and ends half a band clear of the edges rather than a whole
145
+ // one — far enough to ramp in and out, near enough that some of it is
146
+ // always on the field. Travelling fully off, the loop spends several
147
+ // frames drawing nothing at all, which reads as the component having
148
+ // stopped rather than as a band that has passed.
149
+ const radius = width * 0.28;
150
+ return {
151
+ x: -radius * 0.5 + phase * (width + radius),
152
+ y: Number.NaN,
153
+ radius,
154
+ ring: 0,
155
+ };
156
+ }
157
+
158
+ return {
159
+ x: width / 2 + Math.sin(turn) * width * 0.26,
160
+ y: height / 2 + Math.cos(turn * 1.37) * height * 0.22,
161
+ radius: short * 0.42,
162
+ ring: 0,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * The lit dots at one phase of the loop, as a path.
168
+ *
169
+ * Only the dots the light actually reaches are in it — the rest are already
170
+ * drawn by the resting grid underneath, so this is a fraction of the field
171
+ * rather than all of it.
172
+ */
173
+ export function litPath(
174
+ width: number,
175
+ height: number,
176
+ phase: number,
177
+ animation: DotFieldAnimation = 'drift'
178
+ ): string {
179
+ if (width <= 0 || height <= 0) return '';
180
+
181
+ const light = lightAt(animation, phase, width, height);
182
+ let path = '';
183
+
184
+ for (const [x, y] of anchors(width, height)) {
185
+ const deltaX = x - light.x;
186
+ const deltaY = Number.isNaN(light.y) ? 0 : y - light.y;
187
+ const distance = Math.abs(
188
+ Math.sqrt(deltaX * deltaX + deltaY * deltaY) - light.ring
189
+ );
190
+ const influence = influenceAt(distance, light.radius);
191
+ if (influence <= 0.02) continue;
192
+ path += circle(x, y, DOT_RADIUS + influence * (DOT_LIT_RADIUS - DOT_RADIUS));
193
+ }
194
+
195
+ return path;
196
+ }
197
+
198
+ /**
199
+ * Every frame of the loop, built once.
200
+ *
201
+ * A still frame the light is somewhere legible in is also what a paused or
202
+ * reduced-motion field shows, so there is no separate code path for holding it.
203
+ */
204
+ export function litFrames(
205
+ width: number,
206
+ height: number,
207
+ animation: DotFieldAnimation = 'drift'
208
+ ): string[] {
209
+ return Array.from({ length: FRAMES }, (_unused, index) =>
210
+ litPath(width, height, index / FRAMES, animation)
211
+ );
212
+ }
213
+
214
+ /** One pass of the loop, in milliseconds, per animation. */
215
+ export const PERIOD: Record<DotFieldAnimation, number> = {
216
+ drift: 4200,
217
+ pulse: 2200,
218
+ scan: 1900,
219
+ };
220
+
221
+ /** Which frame a moment falls on. */
222
+ export function frameAt(time: number, animation: DotFieldAnimation): number {
223
+ 'worklet';
224
+ const phase = (time % PERIOD[animation]) / PERIOD[animation];
225
+ return Math.min(FRAMES - 1, Math.floor(phase * FRAMES));
226
+ }