@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.
- package/dist/ThinkingOrb/ThinkingOrb.svelte +344 -0
- package/dist/ThinkingOrb/ThinkingOrb.svelte.d.ts +4 -0
- package/dist/ThinkingOrb/orb/gravity.d.ts +30 -0
- package/dist/ThinkingOrb/orb/gravity.js +64 -0
- package/dist/ThinkingOrb/orb/modes/form.d.ts +33 -0
- package/dist/ThinkingOrb/orb/modes/form.js +201 -0
- package/dist/ThinkingOrb/orb/modes/index.d.ts +9 -0
- package/dist/ThinkingOrb/orb/modes/index.js +20 -0
- package/dist/ThinkingOrb/orb/modes/network.d.ts +12 -0
- package/dist/ThinkingOrb/orb/modes/network.js +181 -0
- package/dist/ThinkingOrb/orb/modes/sphere.d.ts +11 -0
- package/dist/ThinkingOrb/orb/modes/sphere.js +364 -0
- package/dist/ThinkingOrb/orb/paint.d.ts +25 -0
- package/dist/ThinkingOrb/orb/paint.js +47 -0
- package/dist/ThinkingOrb/orb/space.d.ts +63 -0
- package/dist/ThinkingOrb/orb/space.js +115 -0
- package/dist/ThinkingOrb/orb/types.d.ts +52 -0
- package/dist/ThinkingOrb/orb/types.js +7 -0
- package/dist/ThinkingOrb/properties.d.ts +68 -0
- package/dist/ThinkingOrb/properties.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
- package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
- package/dist-wc/index.js +1044 -433
- package/package.json +1 -1
|
@@ -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,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
|
+
};
|