Line data Source code
1 : import * as React from 'react';
2 :
3 : import { DASHQL_BRAND, OUTLINE_POINTS, DBCOM_BRAND, type WobbleBrand, VIEWBOX_SIZE, morphPoints, outlinePath, springAtRest, stepSpring, type Spring } from '@ankoh/dashql-ui/wobble/wobble_morph';
4 : import { useAppFlavor } from '../config/flavor_provider.js';
5 : import * as styles from './wobble.module.css';
6 :
7 : /// The brand marks by the id of their app flavor
8 1 : const BRANDS_BY_FLAVOR: ReadonlyMap<string, WobbleBrand> = new Map([DASHQL_BRAND, DBCOM_BRAND].map(brand => [brand.id, brand]));
9 :
10 : /// The brand mark of the app flavor, the DashQL brand mark for a flavor without one
11 1 : export function useFlavorWobbleBrand(): WobbleBrand {
12 1 : return BRANDS_BY_FLAVOR.get(useAppFlavor().id) ?? DASHQL_BRAND;
13 1 : }
14 :
15 : /// The knobs of the animation of Wobble
16 : export interface WobbleTuning {
17 : /// The stiffness of the morph spring
18 : stiffness: number;
19 : /// The damping of the morph spring, low values overshoot
20 : damping: number;
21 : /// The share of the morph by which the points that travel the least start later
22 : lag: number;
23 : /// The amplitude of the ripple along the outline while the shape travels
24 : ripple: number;
25 : /// The radius wobble of the face, relative to the radius
26 : wobble: number;
27 : /// The speed of the wobble
28 : wobbleSpeed: number;
29 : /// The blur of the goo filter, full from the face to the middle of the morph and fading out towards the
30 : /// brand mark, 0 disables it
31 : gooBlur: number;
32 : /// How long the face shows before it morphs, randomized by ±20%
33 : faceHoldMs: number;
34 : /// How long the brand mark shows
35 : logoHoldMs: number;
36 : /// The mean time between two glances
37 : glanceIntervalMs: number;
38 : /// How far the eyes glance, in viewBox units
39 : glanceAmplitude: number;
40 : /// The mean time between two blinks
41 : blinkIntervalMs: number;
42 : }
43 :
44 1 : export const DEFAULT_WOBBLE_TUNING: WobbleTuning = {
45 1 : stiffness: 70,
46 1 : damping: 9,
47 1 : lag: 0.35,
48 1 : ripple: 3,
49 1 : wobble: 0.02,
50 1 : wobbleSpeed: 1,
51 1 : gooBlur: 5,
52 1 : faceHoldMs: 10000,
53 1 : logoHoldMs: 3000,
54 1 : glanceIntervalMs: 2400,
55 1 : glanceAmplitude: 15,
56 1 : blinkIntervalMs: 4500,
57 1 : };
58 :
59 : /// What Wobble shows
60 1 : export enum WobbleMode {
61 : /// The face, morphing into the brand mark now and then
62 1 : Cycle = 'cycle',
63 : /// Only the face
64 1 : Face = 'face',
65 : /// Only the brand mark
66 1 : Logo = 'logo',
67 1 : }
68 :
69 : /// The eyes, pills cut out of the face
70 1 : const EYE_Y = 90;
71 1 : const EYE_DX = 24;
72 1 : const EYE_WIDTH = 17;
73 1 : const EYE_HEIGHT = 27;
74 : /// The happy eyes, arcs like ^ ^
75 1 : const HAPPY_EYE_WIDTH = 20;
76 1 : const HAPPY_EYE_HEIGHT = 10;
77 1 : const HAPPY_EYE_STROKE = 6;
78 : /// How long the eyes stay happy after the last input
79 1 : const HAPPY_HOLD_MS = 700;
80 : /// How far the eyes look up and down, relative to the glance to the sides
81 1 : const GAZE_VERTICAL = 1.1;
82 : /// The distance at which the eyes look halfway to a gaze target, in face widths, to the sides and up or down.
83 : /// The eyes react to closer targets up or down, a caret below the face draws them down clearly.
84 1 : const GAZE_SOFTNESS_X = 2;
85 1 : const GAZE_SOFTNESS_Y = 0.8;
86 : /// How long the eyes follow the pointer after it stopped
87 1 : const POINTER_IDLE_MS = 2500;
88 : /// The duration of a blink
89 1 : const BLINK_MS = 150;
90 : /// The share of the morph next to the brand mark over which the goo fades out
91 1 : const GOO_FADE = 0.45;
92 : /// The morph progress from which the cutouts of the brand mark fade in
93 1 : const CUTOUT_REVEAL_FROM = 0.8;
94 : /// How long the eyes take to close before a morph
95 1 : const EYES_CLOSE_MS = 180;
96 :
97 : interface WobbleProps extends Omit<React.SVGProps<SVGSVGElement>, 'ref' | 'mode'> {
98 : /// The brand mark Wobble morphs into, the brand mark of the app flavor by default
99 : brand?: WobbleBrand;
100 : tuning?: Partial<WobbleTuning>;
101 : mode?: WobbleMode;
102 : /// Stops the animation, e.g. while Wobble is hidden
103 : paused?: boolean;
104 : /// The accessible label of Wobble
105 : label?: string;
106 : /// Called when the face starts to morph into the brand mark, and when it is back
107 : onMorph?: (event: WobbleMorphEvent) => void;
108 : /// What the eyes look at, they glance around on their own without
109 : gaze?: WobbleGaze;
110 : /// The time of the last input Wobble reacts to, on the clock of `performance.now()`, read every frame.
111 : /// The eyes squint happily while the input goes on.
112 : lastInput?: () => number | null;
113 : }
114 :
115 : /// What the eyes of Wobble look at
116 : export interface WobbleGaze {
117 : /// Follow the mouse pointer while it moves
118 : pointer?: boolean;
119 : /// A point in client coordinates, read every frame, it wins over the pointer. Null when there is none.
120 : target?: () => { x: number; y: number } | null;
121 : }
122 :
123 : /// The moments of the morph that `onMorph` reports
124 1 : export enum WobbleMorphEvent {
125 : /// The eyes close and the face starts to morph into the brand mark
126 1 : Leaving = 'leaving',
127 : /// The face is back and opens its eyes
128 1 : Back = 'back',
129 1 : }
130 :
131 : /// Jitter a duration by ±20%
132 1 : function jitter(ms: number): number {
133 1 : return ms * (0.8 + 0.4 * Math.random());
134 1 : }
135 :
136 : /// The phase of the cycle
137 1 : enum Phase {
138 1 : Face,
139 1 : ClosingEyes,
140 1 : Logo,
141 1 : Returning,
142 1 : }
143 :
144 : /// Wobble, a liquid disk with eyes that glance around and morphs into the DashQL brand mark and back.
145 : /// The frames write the path data through refs, React renders Wobble once.
146 : /// It keeps moving with prefers-reduced-motion.
147 1 : export function Wobble(props: WobbleProps): React.ReactElement {
148 1 : const flavorBrand = useFlavorWobbleBrand();
149 1 : const { brand = flavorBrand, tuning, mode = WobbleMode.Cycle, paused = false, label, onMorph, gaze, lastInput, className, ...svgProps } = props;
150 1 : const tuningRef = React.useRef<WobbleTuning>(DEFAULT_WOBBLE_TUNING);
151 1 : tuningRef.current = { ...DEFAULT_WOBBLE_TUNING, ...tuning };
152 1 : const modeRef = React.useRef(mode);
153 1 : modeRef.current = mode;
154 1 : const onMorphRef = React.useRef(onMorph);
155 1 : onMorphRef.current = onMorph;
156 1 : const lastInputRef = React.useRef(lastInput);
157 1 : lastInputRef.current = lastInput;
158 1 : const happyEyeRefs = React.useRef<(SVGPathElement | null)[]>([]);
159 1 : const gazeRef = React.useRef(gaze);
160 1 : gazeRef.current = gaze;
161 1 : const svgRef = React.useRef<SVGSVGElement | null>(null);
162 :
163 1 : const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, '');
164 1 : const maskId = `wobble-mask-${id}`;
165 1 : const gooId = `wobble-goo-${id}`;
166 1 : const pathRefs = React.useRef<(SVGPathElement | null)[]>([]);
167 1 : const eyeRefs = React.useRef<(SVGRectElement | null)[]>([]);
168 1 : const gooGroupRef = React.useRef<SVGGElement | null>(null);
169 1 : const blurRef = React.useRef<SVGFEGaussianBlurElement | null>(null);
170 1 : const cutoutsRef = React.useRef<SVGGElement | null>(null);
171 :
172 1 : React.useEffect(() => {
173 1 : if (paused) return;
174 1 : const outlines = brand.pieces.map(() => new Float64Array(2 * OUTLINE_POINTS));
175 1 : const scratch = new Float64Array(2 * OUTLINE_POINTS);
176 1 : const morph: Spring = { value: modeRef.current === WobbleMode.Logo ? 1 : 0, velocity: 0 };
177 1 : const glance: Spring = { value: 0, velocity: 0 };
178 1 : const lookY: Spring = { value: 0, velocity: 0 };
179 : // The excitement about input, happy eyes
180 1 : const happy: Spring = { value: 0, velocity: 0 };
181 : // The last pointer position, the eyes follow it while it moves
182 1 : let pointer: { x: number; y: number; time: number } | null = null;
183 1 : const onPointerMove = (event: PointerEvent) => {
184 1 : pointer = { x: event.clientX, y: event.clientY, time: performance.now() };
185 1 : };
186 1 : globalThis.addEventListener?.('pointermove', onPointerMove, { passive: true });
187 1 : const gazePoint = (now: number): { x: number; y: number } | null => {
188 1 : const gaze = gazeRef.current;
189 1 : if (gaze == null) return null;
190 1 : let target: { x: number; y: number } | null = null;
191 1 : try {
192 1 : target = gaze.target?.() ?? null;
193 1 : } catch {
194 : // A gaze target that cannot tell its position, e.g. of a detached editor, leaves the eyes to themselves
195 1 : }
196 1 : if (target != null) return target;
197 1 : if (gaze.pointer && pointer != null && now - pointer.time < POINTER_IDLE_MS) return pointer;
198 1 : return null;
199 1 : };
200 1 : let eyesOpen = morph.value === 0 ? 1 : 0;
201 1 : let glanceTarget = 0;
202 1 : let phase = morph.value === 0 ? Phase.Face : Phase.Logo;
203 1 : let phaseStart = performance.now();
204 1 : let faceHold = jitter(tuningRef.current.faceHoldMs);
205 1 : let nextGlance = phaseStart + jitter(tuningRef.current.glanceIntervalMs);
206 1 : let nextBlink = phaseStart + jitter(tuningRef.current.blinkIntervalMs);
207 1 : let last = phaseStart;
208 1 : let gooOn = false;
209 1 : gooGroupRef.current?.removeAttribute('filter');
210 1 : let frame = 0;
211 :
212 1 : const enterPhase = (next: Phase, now: number) => {
213 1 : const previous = phase;
214 1 : phase = next;
215 1 : phaseStart = now;
216 1 : if (next === Phase.Face) faceHold = jitter(tuningRef.current.faceHoldMs);
217 1 : if (previous === Phase.Face && next === Phase.ClosingEyes) onMorphRef.current?.(WobbleMorphEvent.Leaving);
218 1 : if (previous !== Phase.Face && next === Phase.Face) onMorphRef.current?.(WobbleMorphEvent.Back);
219 1 : };
220 :
221 1 : const render = (now: number) => {
222 1 : const t = tuningRef.current;
223 1 : const dt = Math.min((now - last) / 1000, 1 / 20);
224 1 : last = now;
225 1 : const elapsed = now - phaseStart;
226 1 : const requested = modeRef.current;
227 :
228 : // Advance the cycle
229 1 : switch (phase) {
230 1 : case Phase.Face:
231 1 : if (requested === WobbleMode.Logo || (requested === WobbleMode.Cycle && elapsed >= faceHold)) {
232 1 : enterPhase(Phase.ClosingEyes, now);
233 : }
234 1 : break;
235 1 : case Phase.ClosingEyes:
236 1 : if (requested === WobbleMode.Face) enterPhase(Phase.Face, now);
237 1 : else if (elapsed >= EYES_CLOSE_MS) enterPhase(Phase.Logo, now);
238 1 : break;
239 1 : case Phase.Logo:
240 1 : if (requested === WobbleMode.Face || (requested === WobbleMode.Cycle && elapsed >= t.logoHoldMs)) {
241 1 : enterPhase(Phase.Returning, now);
242 : }
243 1 : break;
244 1 : case Phase.Returning:
245 1 : if (requested === WobbleMode.Logo) enterPhase(Phase.Logo, now);
246 1 : else if (morph.value < 0.05) enterPhase(Phase.Face, now);
247 : break;
248 1 : }
249 1 : const morphTarget = phase === Phase.Logo ? 1 : 0;
250 1 : stepSpring(morph, morphTarget, t.stiffness, t.damping, dt);
251 :
252 : // The eyes close before the morph and open once the face is back
253 1 : const eyesTarget = phase === Phase.Face ? 1 : 0;
254 1 : const eyesStep = dt * 1000 / EYES_CLOSE_MS;
255 1 : eyesOpen = eyesTarget > eyesOpen ? Math.min(eyesTarget, eyesOpen + eyesStep) : Math.max(eyesTarget, eyesOpen - eyesStep);
256 : // A gaze target or the pointer draws the eyes, the face otherwise glances around on its own
257 1 : let lookYTarget = 0;
258 1 : const gaze = phase === Phase.Face ? gazePoint(now) : null;
259 1 : if (gaze != null && svgRef.current != null) {
260 1 : const rect = svgRef.current.getBoundingClientRect();
261 1 : const dx = gaze.x - (rect.left + rect.width / 2);
262 1 : const dy = gaze.y - (rect.top + (rect.height * EYE_Y) / VIEWBOX_SIZE);
263 1 : glanceTarget = dx / Math.hypot(dx, Math.max(1, rect.width * GAZE_SOFTNESS_X));
264 1 : lookYTarget = dy / Math.hypot(dy, Math.max(1, rect.width * GAZE_SOFTNESS_Y));
265 1 : nextGlance = now + jitter(t.glanceIntervalMs);
266 1 : } else if (phase === Phase.Face) {
267 1 : if (now >= nextGlance) {
268 1 : const choices = [-1, -1, -0.6, 0, 0.6, 1, 1].filter(c => c !== glanceTarget);
269 1 : glanceTarget = choices[Math.floor(Math.random() * choices.length)];
270 1 : nextGlance = now + jitter(t.glanceIntervalMs);
271 1 : }
272 : }
273 1 : if (phase === Phase.Face && now >= nextBlink + BLINK_MS) nextBlink = now + jitter(t.blinkIntervalMs);
274 1 : if (phase !== Phase.Face) glanceTarget = 0;
275 1 : stepSpring(glance, glanceTarget, 260, 26, dt);
276 1 : stepSpring(lookY, lookYTarget, 260, 26, dt);
277 : // The eyes squint happily while the input goes on
278 1 : const input = lastInputRef.current?.() ?? null;
279 1 : const happyTarget = phase === Phase.Face && input != null && now - input < HAPPY_HOLD_MS ? 1 : 0;
280 1 : stepSpring(happy, happyTarget, 320, 30, dt);
281 :
282 1 : const blinkT = (now - nextBlink) / BLINK_MS;
283 1 : const blink = blinkT > 0 && blinkT < 1 ? Math.abs(2 * blinkT - 1) : 1;
284 1 : const squint = Math.min(Math.max(happy.value, 0), 1);
285 1 : const openness = eyesOpen * blink * (1 - squint);
286 1 : const eyeHeight = Math.max(0.001, EYE_HEIGHT * openness);
287 1 : const eyeWidth = EYE_WIDTH * (1 + 0.25 * (1 - openness));
288 1 : for (let i = 0; i < 2; ++i) {
289 1 : const eye = eyeRefs.current[i];
290 1 : if (eye == null) continue;
291 1 : const cx = VIEWBOX_SIZE / 2 + (i === 0 ? -EYE_DX : EYE_DX) + glance.value * t.glanceAmplitude;
292 1 : const cy = EYE_Y + lookY.value * t.glanceAmplitude * GAZE_VERTICAL;
293 1 : eye.setAttribute('x', (cx - eyeWidth / 2).toFixed(2));
294 1 : eye.setAttribute('y', (cy - eyeHeight / 2).toFixed(2));
295 1 : eye.setAttribute('width', eyeWidth.toFixed(2));
296 1 : eye.setAttribute('height', eyeHeight.toFixed(2));
297 : // The happy eyes, arcs like ^ ^
298 1 : const arc = happyEyeRefs.current[i];
299 1 : if (arc != null) {
300 1 : const w = HAPPY_EYE_WIDTH / 2, h = HAPPY_EYE_HEIGHT / 2;
301 1 : arc.setAttribute('d', `M${(cx - w).toFixed(2)} ${(cy + h).toFixed(2)}Q${cx.toFixed(2)} ${(cy - 2.2 * h).toFixed(2)} ${(cx + w).toFixed(2)} ${(cy + h).toFixed(2)}`);
302 1 : arc.setAttribute('opacity', (eyesOpen * squint).toFixed(3));
303 1 : }
304 1 : }
305 :
306 : // Morph the shapes, the exact brand mark once the spring rests at it
307 1 : const time = (now / 1000) * t.wobbleSpeed;
308 1 : const atLogo = morphTarget === 1 && springAtRest(morph, 1);
309 1 : for (let i = 0; i < brand.pieces.length; ++i) {
310 1 : const path = pathRefs.current[i];
311 1 : if (path == null) continue;
312 1 : if (atLogo) {
313 1 : path.setAttribute('d', brand.pieces[i].logoPath);
314 1 : continue;
315 1 : }
316 1 : morphPoints(brand.pieces[i], {
317 1 : progress: morph.value,
318 1 : time,
319 1 : wobble: t.wobble * Math.max(0, 1 - morph.value),
320 1 : lag: t.lag,
321 1 : ripple: t.ripple,
322 1 : }, outlines[i], scratch);
323 1 : path.setAttribute('d', outlinePath(outlines[i]));
324 1 : }
325 :
326 : // The cutouts of the brand mark fade in once the shape is close to it
327 1 : const cutouts = cutoutsRef.current;
328 1 : if (cutouts != null) {
329 1 : const reveal = Math.min(Math.max((morph.value - CUTOUT_REVEAL_FROM) / (1 - CUTOUT_REVEAL_FROM), 0), 1);
330 1 : cutouts.setAttribute('opacity', reveal.toFixed(3));
331 1 : }
332 :
333 : // The goo filter melts the halves together, until they are joined again on the way back.
334 : // The face is a disk the goo leaves as it is, the corners of the brand mark stay crisp at rest.
335 1 : const towardsLogo = Math.min(Math.max((1 - morph.value) / GOO_FADE, 0), 1);
336 1 : const blur = t.gooBlur * towardsLogo * towardsLogo * (3 - 2 * towardsLogo);
337 1 : const wantGoo = blur > 0.2;
338 1 : if (wantGoo !== gooOn) {
339 1 : gooOn = wantGoo;
340 1 : if (wantGoo) gooGroupRef.current?.setAttribute('filter', `url(#${gooId})`);
341 1 : else gooGroupRef.current?.removeAttribute('filter');
342 1 : }
343 1 : if (wantGoo) blurRef.current?.setAttribute('stdDeviation', blur.toFixed(2));
344 :
345 1 : frame = requestAnimationFrame(render);
346 1 : };
347 1 : render(performance.now());
348 1 : return () => {
349 1 : cancelAnimationFrame(frame);
350 1 : globalThis.removeEventListener?.('pointermove', onPointerMove);
351 1 : };
352 : // The tuning and the mode are read through refs
353 1 : }, [brand, paused, gooId]);
354 :
355 1 : const initialPaths = React.useMemo(() => brand.pieces.map(piece => outlinePath(piece.faceRest)), [brand]);
356 1 : return (
357 1 : <svg
358 1 : {...svgProps}
359 1 : ref={svgRef}
360 1 : className={className == null ? styles.root : `${styles.root} ${className}`}
361 1 : viewBox={`0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`}
362 1 : role="img"
363 1 : aria-label={label}
364 : >
365 1 : <defs>
366 1 : <mask id={maskId} maskUnits="userSpaceOnUse" x={-50} y={-50} width={300} height={300}>
367 1 : <rect x={-50} y={-50} width={300} height={300} fill="white" />
368 1 : {[0, 1].map(i => (
369 1 : <rect key={i} ref={el => { eyeRefs.current[i] = el; }}
370 1 : x={VIEWBOX_SIZE / 2 + (i === 0 ? -EYE_DX : EYE_DX) - EYE_WIDTH / 2} y={EYE_Y - EYE_HEIGHT / 2}
371 1 : width={EYE_WIDTH} height={EYE_HEIGHT} rx={EYE_WIDTH / 2} fill="black" />
372 1 : ))}
373 1 : {[0, 1].map(i => (
374 1 : <path key={i} ref={el => { happyEyeRefs.current[i] = el; }} opacity={0} fill="none" stroke="black"
375 1 : strokeWidth={HAPPY_EYE_STROKE} strokeLinecap="round" strokeLinejoin="round" />
376 1 : ))}
377 1 : <g ref={cutoutsRef} opacity={0}>
378 1 : {brand.cutouts.map((cutout, i) => (
379 0 : <path key={i} d={cutout.d} transform={cutout.transform} fill="black" />
380 1 : ))}
381 1 : </g>
382 1 : </mask>
383 1 : <filter id={gooId} x="-20%" y="-20%" width="140%" height="140%" colorInterpolationFilters="sRGB">
384 1 : <feGaussianBlur ref={blurRef} in="SourceGraphic" stdDeviation={0} result="blur" />
385 1 : <feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9" result="goo" />
386 1 : <feComposite in="SourceGraphic" in2="goo" operator="atop" />
387 1 : </filter>
388 1 : </defs>
389 1 : <g mask={`url(#${maskId})`}>
390 1 : <g ref={gooGroupRef}>
391 1 : {initialPaths.map((d, i) => (
392 1 : <path key={i} ref={el => { pathRefs.current[i] = el; }} d={d} fill="currentColor" />
393 1 : ))}
394 1 : </g>
395 1 : </g>
396 1 : </svg>
397 : );
398 1 : }
|