@juspay/svelte-ui-components 4.32.1 → 4.33.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.
@@ -0,0 +1,344 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { attract, GRAVITY_DEFAULTS, type GravityPointer } from './orb/gravity';
4
+ import { paintFrame } from './orb/paint';
5
+ import { MODES } from './orb/modes';
6
+ import { prefersReducedMotion } from '../utils';
7
+ import { parseCssColor } from '../VoiceOrb/orbMath';
8
+ import type { Rgb } from '../types';
9
+ import type {
10
+ GravityOptions,
11
+ OrbState,
12
+ ThinkingOrbProperties,
13
+ ThinkingOrbSize
14
+ } from './properties';
15
+
16
+ let {
17
+ state: orbState = 'working',
18
+ size = 64,
19
+ speed = 1,
20
+ paused = false,
21
+ color,
22
+ dots = 1,
23
+ dotSize = 1,
24
+ gravity = false,
25
+ ariaLabel,
26
+ classes,
27
+ testId,
28
+ onfirstframe
29
+ }: ThinkingOrbProperties = $props();
30
+
31
+ const SIZES: readonly ThinkingOrbSize[] = [64, 32, 20];
32
+ const FALLBACK_TINT: Rgb = { r: 128, g: 128, b: 128 };
33
+ /** A fixed, representative instant a reduced-motion orb holds — any single
34
+ * frame works, since every mode is required to be non-blank at every `t`. */
35
+ const REDUCED_MOTION_INSTANT = 2.4;
36
+ /** The backing store never scales past this multiple of the CSS size, so a
37
+ * very-high-density display can't push the canvas past a sane pixel budget. */
38
+ const MAX_BACKING_SCALE = 2;
39
+
40
+ // <sui-thinking-orb> passes `state`/`size` straight from string/number
41
+ // attributes, so a value no mode or size knows about can reach here. Fall
42
+ // back rather than let a missing MODES entry leave nothing drawn.
43
+ const safeState: OrbState = $derived(Object.hasOwn(MODES, orbState) ? orbState : 'working');
44
+ const safeSize: ThinkingOrbSize = $derived(SIZES.includes(size) ? size : 64);
45
+ const density = $derived(Math.max(0.1, dots));
46
+ const dotScale = $derived(Math.max(0.1, dotSize));
47
+
48
+ /*
49
+ * Gravity is motion: under reduced motion it is switched off outright
50
+ * rather than merely un-animated, so a still frame never carries a pointer
51
+ * pull a user asked motion to not see.
52
+ */
53
+ const gravityOptions = $derived.by((): GravityOptions | null => {
54
+ if (!gravity || reducedMotion) {
55
+ return null;
56
+ }
57
+ return gravity === true ? GRAVITY_DEFAULTS : gravity;
58
+ });
59
+
60
+ let canvas: HTMLCanvasElement | null = $state(null);
61
+ /*
62
+ * `reducedMotion`/`onScreen`/`tabVisible` feed `shouldRun` below (a
63
+ * `$derived`), so they must be real `$state` for it to react when they
64
+ * change. `dark` is `$state` for the same reason `draw` below reads it
65
+ * synchronously inside the main `$effect` -- writing it must retrigger
66
+ * that effect, otherwise a theme flip while the loop is stopped (paused,
67
+ * or reduced motion, which paints exactly once) would leave the ink ramp
68
+ * on the wrong theme for as long as the orb stays still. `pointer` is a
69
+ * plain `let`, not `$state`: a still orb repaints explicitly on pointer
70
+ * events (see `repaintIfStill`), and a running orb's loop already reads
71
+ * the latest pointer every tick, so retriggering the whole effect on every
72
+ * pointer move would only tear down and restart the loop for nothing.
73
+ */
74
+ let reducedMotion = $state(prefersReducedMotion());
75
+ let onScreen = $state(true);
76
+ let tabVisible = $state(true);
77
+ let dark = $state(false);
78
+ let announcedFirstFrame = false;
79
+ let resolvedColorInput = '';
80
+ let resolvedTint: Rgb = FALLBACK_TINT;
81
+ let pointer: GravityPointer = null;
82
+ let animationHandle = 0;
83
+ /** The orb's own clock: speed-scaled seconds, starting at 0 on mount and
84
+ * advancing only while the loop actually runs (see the main `$effect`). A
85
+ * still orb simply stops touching it, so it holds the exact instant it
86
+ * stopped at with no extra bookkeeping. */
87
+ let clock = 0;
88
+
89
+ const shouldRun = $derived(!paused && !reducedMotion && onScreen && tabVisible);
90
+
91
+ /** What a still orb paints: the fixed instant under reduced motion, else
92
+ * whatever `clock` last reached. */
93
+ const heldInstant = (): number => (reducedMotion ? REDUCED_MOTION_INSTANT : clock);
94
+
95
+ /*
96
+ * Assigning an unparseable colour to `fillStyle` is a spec no-op, so the
97
+ * candidate is tried against two different backgrounds and the results
98
+ * compared -- agreement means the browser accepted and normalised it (see
99
+ * VoiceOrb.svelte, which uses the same trick).
100
+ */
101
+ const normalizeColor = (context: CanvasRenderingContext2D, value: string): string | null => {
102
+ if (value.length === 0) {
103
+ return null;
104
+ }
105
+ const previous = context.fillStyle;
106
+ context.fillStyle = '#000000';
107
+ context.fillStyle = value;
108
+ const onBlack = context.fillStyle;
109
+ context.fillStyle = '#ffffff';
110
+ context.fillStyle = value;
111
+ const onWhite = context.fillStyle;
112
+ context.fillStyle = previous;
113
+ return typeof onBlack === 'string' && onBlack === onWhite ? onBlack : null;
114
+ };
115
+
116
+ const readColorInput = (
117
+ target: HTMLCanvasElement
118
+ ): { declared: string; inherited: string; key: string } => {
119
+ const styles = getComputedStyle(target);
120
+ const declared = styles.getPropertyValue('--sui-thinking-orb-color').trim();
121
+ const inherited = styles.color.trim();
122
+ return { declared, inherited, key: `${declared}|${inherited}` };
123
+ };
124
+
125
+ /** `color` prop wins outright; otherwise re-read via getComputedStyle, cached by the raw input string. */
126
+ const resolveTint = (context: CanvasRenderingContext2D): Rgb => {
127
+ if (color) {
128
+ return color;
129
+ }
130
+ if (canvas === null) {
131
+ return resolvedTint;
132
+ }
133
+ const { declared, inherited, key } = readColorInput(canvas);
134
+ if (key === resolvedColorInput) {
135
+ return resolvedTint;
136
+ }
137
+ resolvedColorInput = key;
138
+ const normalized = normalizeColor(context, declared) ?? normalizeColor(context, inherited);
139
+ resolvedTint = (normalized === null ? null : parseCssColor(normalized)) ?? FALLBACK_TINT;
140
+ return resolvedTint;
141
+ };
142
+
143
+ /** A still orb has no running loop to pick up a moved pointer on its own,
144
+ * so gravity repaints it directly; a running orb's loop already paints the
145
+ * latest pointer every tick and needs no help here. */
146
+ const repaintIfStill = (): void => {
147
+ if (shouldRun || gravityOptions === null) {
148
+ return;
149
+ }
150
+ draw(heldInstant());
151
+ };
152
+
153
+ const trackPointer = (event: PointerEvent): void => {
154
+ if (canvas === null || !gravity) {
155
+ return;
156
+ }
157
+ const rect = canvas.getBoundingClientRect();
158
+ pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
159
+ repaintIfStill();
160
+ };
161
+
162
+ const releasePointer = (): void => {
163
+ pointer = null;
164
+ repaintIfStill();
165
+ };
166
+
167
+ /** SSR renders with no `window`; the backing store is left at 1x until the
168
+ * component actually mounts in a browser and can ask it for a real ratio. */
169
+ const resolveBackingScale = (): number => {
170
+ if (typeof window === 'undefined') {
171
+ return 1;
172
+ }
173
+ return Math.min(MAX_BACKING_SCALE, window.devicePixelRatio ?? 1);
174
+ };
175
+
176
+ /** Paints one instant. `instant` is already speed-scaled seconds -- either
177
+ * the orb's own running `clock` or the fixed reduced-motion instant. Safe
178
+ * to call whether or not the loop is running. */
179
+ const draw = (instant: number): void => {
180
+ if (canvas === null) {
181
+ return;
182
+ }
183
+ const context = canvas.getContext('2d');
184
+ if (context === null) {
185
+ return;
186
+ }
187
+
188
+ const dpr = resolveBackingScale();
189
+ const backingPx = Math.round(safeSize * dpr);
190
+ if (canvas.width !== backingPx || canvas.height !== backingPx) {
191
+ canvas.width = backingPx;
192
+ canvas.height = backingPx;
193
+ }
194
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
195
+
196
+ const frame = MODES[safeState](instant, { size: safeSize, density, dotScale });
197
+ const gOpts = gravityOptions;
198
+ const painted = gOpts === null ? frame : attract(frame, pointer, gOpts);
199
+ paintFrame(context, painted, { dark, tint: resolveTint(context) });
200
+
201
+ if (!announcedFirstFrame) {
202
+ announcedFirstFrame = true;
203
+ onfirstframe?.();
204
+ }
205
+ };
206
+
207
+ /*
208
+ * Re-runs whenever `shouldRun` or anything the leading `draw` reads changes
209
+ * (`safeState`, `safeSize`, `density`, `dotScale`, `gravityOptions`, `dark`,
210
+ * `color`, `reducedMotion`, ...) -- Svelte tracks every reactive read inside
211
+ * this body, including transitively through that synchronous `draw` call,
212
+ * so a prop change tears down the previous rAF (the cleanup below) and
213
+ * repaints from scratch with no dependency list to maintain by hand.
214
+ *
215
+ * `clock` itself is never read synchronously here, only inside the `loop`
216
+ * closure below, which runs later from `requestAnimationFrame` -- so
217
+ * advancing it never retriggers this effect. That's what lets `speed`
218
+ * change the clock's rate starting next tick with no jump and no loop
219
+ * restart: `loop` reads the live `speed` prop itself, every tick.
220
+ */
221
+ // eslint-disable-next-line no-restricted-syntax
222
+ $effect(() => {
223
+ // A still orb (paused, reduced motion or off screen) never touches
224
+ // `clock`, so it simply holds whatever instant it last reached -- no
225
+ // separate "was it frozen" bookkeeping needed. At least one frame is
226
+ // always painted, so the canvas is never blank.
227
+ draw(heldInstant());
228
+ if (!shouldRun) {
229
+ return;
230
+ }
231
+ // Reset on every (re)start of the loop, including a resume from
232
+ // stillness: the first tick after a (re)start must not count the time
233
+ // that passed before it, so it advances `clock` by zero and every tick
234
+ // after that measures real elapsed time again.
235
+ let previousFrameTime: number | null = null;
236
+ const loop = (frameTime: number): void => {
237
+ const deltaSeconds = previousFrameTime === null ? 0 : (frameTime - previousFrameTime) / 1000;
238
+ previousFrameTime = frameTime;
239
+ clock += deltaSeconds * speed;
240
+ draw(clock);
241
+ animationHandle = requestAnimationFrame(loop);
242
+ };
243
+ animationHandle = requestAnimationFrame(loop);
244
+ return () => cancelAnimationFrame(animationHandle);
245
+ });
246
+
247
+ onMount(() => {
248
+ if (canvas === null) {
249
+ return;
250
+ }
251
+
252
+ // Every optional platform API below follows the same shape: build it
253
+ // when the browser exposes the constructor/method it needs, otherwise
254
+ // leave the binding `null` so the optional-chaining calls below no-op.
255
+ let intersectionObserver: IntersectionObserver | null = null;
256
+ if (typeof IntersectionObserver !== 'undefined') {
257
+ intersectionObserver = new IntersectionObserver(([entry]) => {
258
+ onScreen = entry.isIntersecting;
259
+ });
260
+ intersectionObserver.observe(canvas);
261
+ }
262
+
263
+ const onVisibilityChange = (): void => {
264
+ tabVisible = document.visibilityState !== 'hidden';
265
+ };
266
+ document.addEventListener('visibilitychange', onVisibilityChange);
267
+
268
+ /*
269
+ * This repo's `ThemeSwitcher` stamps `data-theme="dark"` on
270
+ * `document.documentElement` -- one attribute on one element -- so a
271
+ * `MutationObserver` there alone catches every theme change. It exists
272
+ * only to keep `paintFrame`'s `dark` argument current: the tint colour
273
+ * itself comes from CSS, resolved in `resolveTint` above.
274
+ */
275
+ const readTheme = (): void => {
276
+ dark = document.documentElement.getAttribute('data-theme') === 'dark';
277
+ };
278
+ readTheme();
279
+ let themeObserver: MutationObserver | null = null;
280
+ if (typeof MutationObserver !== 'undefined') {
281
+ themeObserver = new MutationObserver(readTheme);
282
+ themeObserver.observe(document.documentElement, {
283
+ attributes: true,
284
+ attributeFilter: ['data-theme']
285
+ });
286
+ }
287
+
288
+ const onMotionChange = (event: MediaQueryListEvent): void => {
289
+ reducedMotion = event.matches;
290
+ };
291
+ let motionQuery: MediaQueryList | null = null;
292
+ if (typeof window.matchMedia === 'function') {
293
+ motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
294
+ motionQuery.addEventListener('change', onMotionChange);
295
+ }
296
+
297
+ /*
298
+ * A still orb only repaints on its own when `dark` flips. A host that
299
+ * transitions its `color` (this repo's own layout fades `body` across a
300
+ * theme switch) leaves a just-frozen frame at the outgoing colour, with
301
+ * nothing else to redraw it. Re-read once the transition lands and
302
+ * repaint the same frozen instant if the resolved colour actually moved.
303
+ * A running orb re-reads every frame already and needs none of this.
304
+ */
305
+ const onColorTransitionEnd = (event: TransitionEvent): void => {
306
+ if (shouldRun || color || canvas === null) {
307
+ return;
308
+ }
309
+ if (event.propertyName !== 'color' && event.propertyName !== '--sui-thinking-orb-color') {
310
+ return;
311
+ }
312
+ if (readColorInput(canvas).key !== resolvedColorInput) {
313
+ draw(heldInstant());
314
+ }
315
+ };
316
+ document.addEventListener('transitionend', onColorTransitionEnd);
317
+
318
+ return () => {
319
+ intersectionObserver?.disconnect();
320
+ document.removeEventListener('visibilitychange', onVisibilityChange);
321
+ document.removeEventListener('transitionend', onColorTransitionEnd);
322
+ themeObserver?.disconnect();
323
+ motionQuery?.removeEventListener('change', onMotionChange);
324
+ };
325
+ });
326
+ </script>
327
+
328
+ <!-- The canvas is a picture with a label, not a control: the pointer handlers
329
+ only feed the optional gravity effect and never act, so role="img" is
330
+ accurate. -->
331
+ <!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
332
+ <canvas
333
+ bind:this={canvas}
334
+ role="img"
335
+ aria-label={ariaLabel ?? `${safeState.charAt(0).toUpperCase()}${safeState.slice(1)}…`}
336
+ class={classes}
337
+ data-pw={typeof testId === 'string' ? testId : null}
338
+ style:width={`${safeSize}px`}
339
+ style:height={`${safeSize}px`}
340
+ style:display="block"
341
+ onpointermove={trackPointer}
342
+ onpointerleave={releasePointer}
343
+ onpointercancel={releasePointer}
344
+ ></canvas>
@@ -0,0 +1,4 @@
1
+ import type { ThinkingOrbProperties } from './properties';
2
+ declare const ThinkingOrb: import("svelte").Component<ThinkingOrbProperties, {}, "">;
3
+ type ThinkingOrb = ReturnType<typeof ThinkingOrb>;
4
+ export default ThinkingOrb;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pulls a frame's dots toward a pointer position, so an orb can feel alive
3
+ * under the cursor without any mode ever knowing pointers exist -- the same
4
+ * separation `paintFrame` keeps between a mode and the canvas.
5
+ */
6
+ import type { Frame } from './types';
7
+ /**
8
+ * Where gravity pulls toward, in the same coordinate space as a `Dot`.
9
+ * `null` means nothing is hovering, and gravity does nothing.
10
+ */
11
+ export type GravityPointer = {
12
+ x: number;
13
+ y: number;
14
+ } | null;
15
+ /** How far gravity reaches (`radius`, px) and how hard it pulls at its centre (`strength`, px). */
16
+ export type GravityOptions = {
17
+ radius: number;
18
+ strength: number;
19
+ };
20
+ /** A reach and pull tuned to read as a subtle few px of drift, never a snap to the cursor. */
21
+ export declare const GRAVITY_DEFAULTS: GravityOptions;
22
+ /**
23
+ * Returns a NEW `Frame` with every dot and stroke endpoint inside
24
+ * `options.radius` of `pointer` pulled toward it, the pull smoothly fading
25
+ * to nothing at the radius edge; marks outside are returned untouched. Each
26
+ * stroke endpoint moves by the same amount a dot at that point would, so a
27
+ * line stays attached to the nodes it joins. A `null` pointer is a no-op:
28
+ * `frame` is returned as-is. Never mutates `frame` or anything inside it.
29
+ */
30
+ export declare const attract: (frame: Frame, pointer: GravityPointer, options: GravityOptions) => Frame;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pulls a frame's dots toward a pointer position, so an orb can feel alive
3
+ * under the cursor without any mode ever knowing pointers exist -- the same
4
+ * separation `paintFrame` keeps between a mode and the canvas.
5
+ */
6
+ import { easeOutCubic } from './space';
7
+ /** A reach and pull tuned to read as a subtle few px of drift, never a snap to the cursor. */
8
+ export const GRAVITY_DEFAULTS = {
9
+ radius: 32,
10
+ strength: 6
11
+ };
12
+ const clamp01 = (value) => Math.max(0, Math.min(1, value));
13
+ /**
14
+ * Where one point ends up after `pointer` pulls on it. The pull is capped at
15
+ * the point's own distance from `pointer` -- without that cap, a point
16
+ * already close to the pointer (distance smaller than `strength`) would
17
+ * overshoot past it. Shared by dots and stroke endpoints so a line stays
18
+ * attached to the nodes it joins: each endpoint moves exactly as far as a
19
+ * dot sitting at that same spot would.
20
+ */
21
+ const attractPoint = (point, pointer, options) => {
22
+ const dx = pointer.x - point.x;
23
+ const dy = pointer.y - point.y;
24
+ const distance = Math.hypot(dx, dy);
25
+ if (distance === 0 || distance >= options.radius) {
26
+ return point;
27
+ }
28
+ const proximity = clamp01(1 - distance / options.radius);
29
+ const pull = Math.min(easeOutCubic(proximity) * options.strength, distance);
30
+ const scale = pull / distance;
31
+ return { x: point.x + dx * scale, y: point.y + dy * scale };
32
+ };
33
+ const attractDot = (dot, pointer, options) => {
34
+ const moved = attractPoint(dot, pointer, options);
35
+ return moved.x === dot.x && moved.y === dot.y ? dot : { ...dot, x: moved.x, y: moved.y };
36
+ };
37
+ const attractStroke = (stroke, pointer, options) => {
38
+ const start = attractPoint({ x: stroke.x1, y: stroke.y1 }, pointer, options);
39
+ const end = attractPoint({ x: stroke.x2, y: stroke.y2 }, pointer, options);
40
+ if (start.x === stroke.x1 &&
41
+ start.y === stroke.y1 &&
42
+ end.x === stroke.x2 &&
43
+ end.y === stroke.y2) {
44
+ return stroke;
45
+ }
46
+ return { ...stroke, x1: start.x, y1: start.y, x2: end.x, y2: end.y };
47
+ };
48
+ /**
49
+ * Returns a NEW `Frame` with every dot and stroke endpoint inside
50
+ * `options.radius` of `pointer` pulled toward it, the pull smoothly fading
51
+ * to nothing at the radius edge; marks outside are returned untouched. Each
52
+ * stroke endpoint moves by the same amount a dot at that point would, so a
53
+ * line stays attached to the nodes it joins. A `null` pointer is a no-op:
54
+ * `frame` is returned as-is. Never mutates `frame` or anything inside it.
55
+ */
56
+ export const attract = (frame, pointer, options) => {
57
+ if (pointer === null) {
58
+ return frame;
59
+ }
60
+ return {
61
+ dots: frame.dots.map((dot) => attractDot(dot, pointer, options)),
62
+ strokes: frame.strokes.map((stroke) => attractStroke(stroke, pointer, options))
63
+ };
64
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The "form" modes: composing, breathing and shaping. Each one reads as a
3
+ * single evolving surface or ring rather than a swarm of independent points,
4
+ * so the motion stays legible down at 20px instead of dissolving into noise
5
+ * the way a large loose point cloud would.
6
+ *
7
+ * All three share one placement idiom: build each dot in its own object
8
+ * space (a band or ring parametrised by its index, deformed however that
9
+ * mode's intent calls for), spin the whole thing with `rotateY` for a sense
10
+ * of turning, tilt it with `rotateX` so it reads in 3D, then `project`. That
11
+ * split keeps "the shape's own motion" and "the camera's fixed angle on it"
12
+ * from tangling together in the per-dot math.
13
+ */
14
+ import type { ModeFn } from '../types';
15
+ /**
16
+ * A ribbon of dots looped once around the sphere on its own tilted great
17
+ * circle -- distinct from the spin axis -- rippling along its length like a
18
+ * travelling wave through cloth while the whole ribbon turns.
19
+ */
20
+ export declare const composing: ModeFn;
21
+ /**
22
+ * A single ring that swells and relaxes on a slow sine, with a faint,
23
+ * higher-harmonic wobble layered on top so its outline never reads as a
24
+ * perfectly static circle even at rest.
25
+ */
26
+ export declare const breathing: ModeFn;
27
+ /**
28
+ * A ring that morphs circle to triangle to square and back over a fixed
29
+ * cycle, each leg eased so the shape comes to rest at every corner instead
30
+ * of snapping into it -- which also makes the wrap from the last leg back to
31
+ * the first exactly continuous.
32
+ */
33
+ export declare const shaping: ModeFn;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The "form" modes: composing, breathing and shaping. Each one reads as a
3
+ * single evolving surface or ring rather than a swarm of independent points,
4
+ * so the motion stays legible down at 20px instead of dissolving into noise
5
+ * the way a large loose point cloud would.
6
+ *
7
+ * All three share one placement idiom: build each dot in its own object
8
+ * space (a band or ring parametrised by its index, deformed however that
9
+ * mode's intent calls for), spin the whole thing with `rotateY` for a sense
10
+ * of turning, tilt it with `rotateX` so it reads in 3D, then `project`. That
11
+ * split keeps "the shape's own motion" and "the camera's fixed angle on it"
12
+ * from tangling together in the per-dot math.
13
+ */
14
+ import { easeInOutCubic, hashUnit, modeSizing, project, rotateX, rotateY, rotateZ } from '../space';
15
+ const TWO_PI = Math.PI * 2;
16
+ /** The shared "reads as a form inside a square" contract: radius as a share of `size`. */
17
+ const FORM_RADIUS_RATIO = 0.4;
18
+ const clamp01 = (value) => Math.max(0, Math.min(1, value));
19
+ /** Nearer dots read as more saturated tint; `paintFrame` turns low ink into more of `tint`. */
20
+ const depthToInk = (depth) => clamp01(1 - depth);
21
+ /** Farther dots stay at partial opacity rather than fading to nothing, so no instant reads blank. */
22
+ const depthToAlpha = (depth) => clamp01(0.5 + depth * 0.5);
23
+ /** Nearer dots draw a touch larger, reinforcing the depth `paintFrame`'s draw order already implies. */
24
+ const depthToRadiusFactor = (depth) => 0.7 + depth * 0.3;
25
+ /** Builds one `Dot` from a projected point; `jitter` gives per-dot size variety from `hashUnit`. */
26
+ const dotFrom = (projected, baseRadius, sizing, jitter) => ({
27
+ x: projected.x,
28
+ y: projected.y,
29
+ depth: projected.depth,
30
+ radius: sizing.radius(baseRadius) * depthToRadiusFactor(projected.depth) * jitter,
31
+ ink: depthToInk(projected.depth),
32
+ alpha: depthToAlpha(projected.depth)
33
+ });
34
+ /** Turns an object-space point into a placed, projected `Dot` via the shared spin/tilt/project steps. */
35
+ const place = (point, spinAngle, tiltAngle, originPx, worldRadius, baseRadius, sizing, jitter) => {
36
+ const spun = rotateY(point, spinAngle);
37
+ const tilted = rotateX(spun, tiltAngle);
38
+ const projected = project(tilted, originPx, worldRadius);
39
+ return dotFrom(projected, baseRadius, sizing, jitter);
40
+ };
41
+ const EMPTY_STROKES = [];
42
+ // --- composing ---------------------------------------------------------
43
+ const RIBBON_BASE_COUNT = 260;
44
+ const RIBBON_BASE_DOT_RADIUS = 2.2;
45
+ const RIBBON_THREADS = { 64: 7, 32: 5, 20: 4 };
46
+ const RIBBON_HALF_WIDTH = 0.4;
47
+ /** The ribbon's own great circle sits tilted off the spin axis by this much,
48
+ * so spinning genuinely sweeps its width through the frame -- a band that
49
+ * happened to lie flat on the equator would look identical at every spin
50
+ * angle, since an equatorial ring is rotationally symmetric about that axis. */
51
+ const RIBBON_AXIS_TILT = 0.68;
52
+ const RIBBON_RIPPLE_WAVES = 3;
53
+ const RIBBON_RIPPLE_SPEED = 1.15;
54
+ const RIBBON_RIPPLE_AMPLITUDE = 0.5;
55
+ const RIBBON_THREAD_JITTER = 0.1;
56
+ const RIBBON_SPIN_SPEED = 0.48;
57
+ const RIBBON_CAMERA_TILT = 0.45;
58
+ /** Edge threads only dim toward the background; nothing here shrinks a dot's
59
+ * radius, so the ribbon keeps a full-width row of legible marks even where a
60
+ * combined ink/alpha/radius fade would collapse an edge row to a speck. */
61
+ const RIBBON_EDGE_ALPHA_FLOOR = 0.5;
62
+ const RIBBON_EDGE_INK_LIFT = 0.22;
63
+ /**
64
+ * A ribbon of dots looped once around the sphere on its own tilted great
65
+ * circle -- distinct from the spin axis -- rippling along its length like a
66
+ * travelling wave through cloth while the whole ribbon turns.
67
+ */
68
+ export const composing = (t, ctx) => {
69
+ const sizing = modeSizing(ctx.size, ctx.density, ctx.dotScale);
70
+ const worldRadius = ctx.size * FORM_RADIUS_RATIO;
71
+ const originPx = ctx.size / 2;
72
+ const threads = RIBBON_THREADS[ctx.size];
73
+ const perThread = Math.max(1, Math.ceil(sizing.count(RIBBON_BASE_COUNT) / threads));
74
+ const total = threads * perThread;
75
+ const dots = [];
76
+ for (let index = 0; index < total; index += 1) {
77
+ const thread = index % threads;
78
+ const step = Math.floor(index / threads);
79
+ // w ranges -1 (one edge thread) .. 0 (centre) .. 1 (other edge thread).
80
+ const w = threads > 1 ? (thread / (threads - 1)) * 2 - 1 : 0;
81
+ const u = (step / perThread) * TWO_PI;
82
+ const ripple = Math.sin(u * RIBBON_RIPPLE_WAVES + t * RIBBON_RIPPLE_SPEED) * RIBBON_RIPPLE_AMPLITUDE;
83
+ // A standard spherical parametrisation keeps every point exactly on a
84
+ // sphere of `worldRadius` regardless of `phi`, so the ribbon stays safely
85
+ // inside the canvas no matter how far the ripple bends it.
86
+ const phi = w * RIBBON_HALF_WIDTH + ripple;
87
+ const onSphere = {
88
+ x: Math.cos(phi) * Math.cos(u) * worldRadius,
89
+ y: Math.sin(phi) * worldRadius,
90
+ z: Math.cos(phi) * Math.sin(u) * worldRadius
91
+ };
92
+ const onAxis = rotateZ(onSphere, RIBBON_AXIS_TILT);
93
+ const jitter = 1 - RIBBON_THREAD_JITTER / 2 + hashUnit(index * 7 + 1) * RIBBON_THREAD_JITTER;
94
+ const placed = place(onAxis, t * RIBBON_SPIN_SPEED, RIBBON_CAMERA_TILT, originPx, worldRadius, RIBBON_BASE_DOT_RADIUS, sizing, jitter);
95
+ const edgeFeather = Math.abs(w);
96
+ dots.push({
97
+ ...placed,
98
+ ink: clamp01(placed.ink + edgeFeather * RIBBON_EDGE_INK_LIFT),
99
+ alpha: clamp01(placed.alpha * (1 - edgeFeather * (1 - RIBBON_EDGE_ALPHA_FLOOR)))
100
+ });
101
+ }
102
+ return { dots, strokes: EMPTY_STROKES };
103
+ };
104
+ // --- breathing -----------------------------------------------------------
105
+ const BREATHING_BASE_COUNT = 210;
106
+ const BREATHING_BASE_DOT_RADIUS = 1.7;
107
+ /** Sits below composing/shaping's ~0.4: at the peak of its breath this ring
108
+ * already swells by its amplitude plus its shape wobble below, so a resting
109
+ * radius this much smaller still lands at about the same on-screen extent as
110
+ * its siblings' fixed radius, without the peak pushing past the canvas. */
111
+ const BREATHING_WORLD_RADIUS_RATIO = 0.34;
112
+ const BREATHING_AMPLITUDE = 0.09;
113
+ const BREATHING_SPEED = 0.55;
114
+ const BREATHING_SHAPE_HARMONIC = 5;
115
+ const BREATHING_SHAPE_SPEED = 0.22;
116
+ const BREATHING_SHAPE_AMPLITUDE = 0.035;
117
+ const BREATHING_SPIN_SPEED = 0.12;
118
+ const BREATHING_TILT = 0.62;
119
+ /**
120
+ * A single ring that swells and relaxes on a slow sine, with a faint,
121
+ * higher-harmonic wobble layered on top so its outline never reads as a
122
+ * perfectly static circle even at rest.
123
+ */
124
+ export const breathing = (t, ctx) => {
125
+ const sizing = modeSizing(ctx.size, ctx.density, ctx.dotScale);
126
+ const worldRadius = ctx.size * BREATHING_WORLD_RADIUS_RATIO;
127
+ const originPx = ctx.size / 2;
128
+ const count = sizing.count(BREATHING_BASE_COUNT);
129
+ const breathe = Math.sin(t * BREATHING_SPEED);
130
+ const dots = [];
131
+ for (let index = 0; index < count; index += 1) {
132
+ const angle = (index / count) * TWO_PI;
133
+ const wobble = Math.sin(angle * BREATHING_SHAPE_HARMONIC + t * BREATHING_SHAPE_SPEED) *
134
+ BREATHING_SHAPE_AMPLITUDE;
135
+ const radiusFactor = 1 + breathe * BREATHING_AMPLITUDE + wobble;
136
+ const point = {
137
+ x: Math.cos(angle) * radiusFactor * worldRadius,
138
+ y: 0,
139
+ z: Math.sin(angle) * radiusFactor * worldRadius
140
+ };
141
+ const jitter = 0.88 + hashUnit(index * 11 + 5) * 0.24;
142
+ dots.push(place(point, t * BREATHING_SPIN_SPEED, BREATHING_TILT, originPx, worldRadius, BREATHING_BASE_DOT_RADIUS, sizing, jitter));
143
+ }
144
+ return { dots, strokes: EMPTY_STROKES };
145
+ };
146
+ // --- shaping ---------------------------------------------------------------
147
+ const SHAPING_BASE_COUNT = 210;
148
+ const SHAPING_BASE_DOT_RADIUS = 1.7;
149
+ const SHAPING_CYCLE_SECONDS = 12;
150
+ // One full turn per morph cycle, so the shape and its orientation both land
151
+ // back where they started at the same instant -- a true seamless loop.
152
+ const SHAPING_SPIN_SPEED = (2 * Math.PI) / SHAPING_CYCLE_SECONDS;
153
+ const SHAPING_TILT = 0.6;
154
+ /** The radial distance of a regular `sides`-gon's straight edge at `angle`, circumradius 1. */
155
+ const polygonRadius = (angle, sides) => {
156
+ const segment = TWO_PI / sides;
157
+ const half = segment / 2;
158
+ const wrapped = ((angle % segment) + segment) % segment;
159
+ return Math.cos(half) / Math.cos(wrapped - half);
160
+ };
161
+ const lerp = (from, to, amount) => from + (to - from) * amount;
162
+ /**
163
+ * A ring that morphs circle to triangle to square and back over a fixed
164
+ * cycle, each leg eased so the shape comes to rest at every corner instead
165
+ * of snapping into it -- which also makes the wrap from the last leg back to
166
+ * the first exactly continuous.
167
+ */
168
+ export const shaping = (t, ctx) => {
169
+ const sizing = modeSizing(ctx.size, ctx.density, ctx.dotScale);
170
+ const worldRadius = ctx.size * FORM_RADIUS_RATIO;
171
+ const originPx = ctx.size / 2;
172
+ const count = sizing.count(SHAPING_BASE_COUNT);
173
+ const cyclePos = (((t / SHAPING_CYCLE_SECONDS) % 1) + 1) % 1;
174
+ const phaseFloat = cyclePos * 3;
175
+ const phaseIndex = Math.min(2, Math.floor(phaseFloat));
176
+ const eased = easeInOutCubic(phaseFloat - phaseIndex);
177
+ const dots = [];
178
+ for (let index = 0; index < count; index += 1) {
179
+ const angle = (index / count) * TWO_PI;
180
+ const triangleR = polygonRadius(angle, 3);
181
+ const squareR = polygonRadius(angle, 4);
182
+ let radiusFactor;
183
+ if (phaseIndex === 0) {
184
+ radiusFactor = lerp(1, triangleR, eased);
185
+ }
186
+ else if (phaseIndex === 1) {
187
+ radiusFactor = lerp(triangleR, squareR, eased);
188
+ }
189
+ else {
190
+ radiusFactor = lerp(squareR, 1, eased);
191
+ }
192
+ const point = {
193
+ x: Math.cos(angle) * radiusFactor * worldRadius,
194
+ y: 0,
195
+ z: Math.sin(angle) * radiusFactor * worldRadius
196
+ };
197
+ const jitter = 0.9 + hashUnit(index * 17 + 9) * 0.2;
198
+ dots.push(place(point, t * SHAPING_SPIN_SPEED, SHAPING_TILT, originPx, worldRadius, SHAPING_BASE_DOT_RADIUS, sizing, jitter));
199
+ }
200
+ return { dots, strokes: EMPTY_STROKES };
201
+ };