Line data Source code
1 : import * as React from 'react';
2 :
3 : import { DASHQL_BRAND, OUTLINE_POINTS, SIDB_BRAND, type AgentFaceBrand, VIEWBOX_SIZE, morphPoints, outlinePath, springAtRest, stepSpring, type Spring } from './agent_face_morph.js';
4 : import { useAppFlavor } from '../config/flavor_provider.js';
5 : import * as styles from './agent_face.module.css';
6 :
7 : /// The brand marks by the id of their app flavor
8 1 : const BRANDS_BY_FLAVOR: ReadonlyMap<string, AgentFaceBrand> = new Map([DASHQL_BRAND, SIDB_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 useFlavorAgentFaceBrand(): AgentFaceBrand {
12 1 : return BRANDS_BY_FLAVOR.get(useAppFlavor().id) ?? DASHQL_BRAND;
13 1 : }
14 :
15 : /// The knobs of the agent face animation
16 : export interface AgentFaceTuning {
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_AGENT_FACE_TUNING: AgentFaceTuning = {
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 the agent face shows
60 1 : export enum AgentFaceMode {
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 AgentFaceProps extends Omit<React.SVGProps<SVGSVGElement>, 'ref' | 'mode'> {
98 : /// The brand mark the face morphs into, the brand mark of the app flavor by default
99 : brand?: AgentFaceBrand;
100 : tuning?: Partial<AgentFaceTuning>;
101 : mode?: AgentFaceMode;
102 : /// Stops the animation, e.g. while the face is hidden
103 : paused?: boolean;
104 : /// The accessible label of the face
105 : label?: string;
106 : /// Called when the face starts to morph into the brand mark, and when it is back
107 : onMorph?: (event: AgentFaceMorphEvent) => void;
108 : /// What the eyes look at, they glance around on their own without
109 : gaze?: AgentFaceGaze;
110 : /// The time of the last input the face 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 the agent face look at
116 : export interface AgentFaceGaze {
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 AgentFaceMorphEvent {
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 : /// The agent face, 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 the face once.
146 1 : export function AgentFace(props: AgentFaceProps): React.ReactElement {
147 1 : const flavorBrand = useFlavorAgentFaceBrand();
148 1 : const { brand = flavorBrand, tuning, mode = AgentFaceMode.Cycle, paused = false, label, onMorph, gaze, lastInput, className, ...svgProps } = props;
149 1 : const tuningRef = React.useRef<AgentFaceTuning>(DEFAULT_AGENT_FACE_TUNING);
150 1 : tuningRef.current = { ...DEFAULT_AGENT_FACE_TUNING, ...tuning };
151 1 : const modeRef = React.useRef(mode);
152 1 : modeRef.current = mode;
153 1 : const onMorphRef = React.useRef(onMorph);
154 1 : onMorphRef.current = onMorph;
155 1 : const lastInputRef = React.useRef(lastInput);
156 1 : lastInputRef.current = lastInput;
157 1 : const happyEyeRefs = React.useRef<(SVGPathElement | null)[]>([]);
158 1 : const gazeRef = React.useRef(gaze);
159 1 : gazeRef.current = gaze;
160 1 : const svgRef = React.useRef<SVGSVGElement | null>(null);
161 :
162 1 : const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, '');
163 1 : const maskId = `agent-face-mask-${id}`;
164 1 : const gooId = `agent-face-goo-${id}`;
165 1 : const pathRefs = React.useRef<(SVGPathElement | null)[]>([]);
166 1 : const eyeRefs = React.useRef<(SVGRectElement | null)[]>([]);
167 1 : const gooGroupRef = React.useRef<SVGGElement | null>(null);
168 1 : const blurRef = React.useRef<SVGFEGaussianBlurElement | null>(null);
169 1 : const cutoutsRef = React.useRef<SVGGElement | null>(null);
170 :
171 1 : const reducedMotion = React.useMemo(() => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false, []);
172 :
173 1 : React.useEffect(() => {
174 1 : if (paused) return;
175 1 : const outlines = brand.pieces.map(() => new Float64Array(2 * OUTLINE_POINTS));
176 1 : const scratch = new Float64Array(2 * OUTLINE_POINTS);
177 1 : const morph: Spring = { value: modeRef.current === AgentFaceMode.Logo ? 1 : 0, velocity: 0 };
178 1 : const glance: Spring = { value: 0, velocity: 0 };
179 1 : const lookY: Spring = { value: 0, velocity: 0 };
180 : // The excitement about input, happy eyes
181 1 : const happy: Spring = { value: 0, velocity: 0 };
182 : // The last pointer position, the eyes follow it while it moves
183 1 : let pointer: { x: number; y: number; time: number } | null = null;
184 1 : const onPointerMove = (event: PointerEvent) => {
185 1 : pointer = { x: event.clientX, y: event.clientY, time: performance.now() };
186 1 : };
187 1 : globalThis.addEventListener?.('pointermove', onPointerMove, { passive: true });
188 1 : const gazePoint = (now: number): { x: number; y: number } | null => {
189 1 : const gaze = gazeRef.current;
190 1 : if (gaze == null) return null;
191 1 : let target: { x: number; y: number } | null = null;
192 1 : try {
193 1 : target = gaze.target?.() ?? null;
194 1 : } catch {
195 : // A gaze target that cannot tell its position, e.g. of a detached editor, leaves the eyes to themselves
196 1 : }
197 1 : if (target != null) return target;
198 1 : if (gaze.pointer && pointer != null && now - pointer.time < POINTER_IDLE_MS) return pointer;
199 1 : return null;
200 1 : };
201 1 : let eyesOpen = morph.value === 0 ? 1 : 0;
202 1 : let glanceTarget = 0;
203 1 : let phase = morph.value === 0 ? Phase.Face : Phase.Logo;
204 1 : let phaseStart = performance.now();
205 1 : let faceHold = jitter(tuningRef.current.faceHoldMs);
206 1 : let nextGlance = phaseStart + jitter(tuningRef.current.glanceIntervalMs);
207 1 : let nextBlink = phaseStart + jitter(tuningRef.current.blinkIntervalMs);
208 1 : let last = phaseStart;
209 1 : let gooOn = false;
210 1 : gooGroupRef.current?.removeAttribute('filter');
211 1 : let frame = 0;
212 :
213 1 : const enterPhase = (next: Phase, now: number) => {
214 0 : const previous = phase;
215 0 : phase = next;
216 0 : phaseStart = now;
217 0 : if (next === Phase.Face) faceHold = jitter(tuningRef.current.faceHoldMs);
218 0 : if (previous === Phase.Face && next === Phase.ClosingEyes) onMorphRef.current?.(AgentFaceMorphEvent.Leaving);
219 0 : if (previous !== Phase.Face && next === Phase.Face) onMorphRef.current?.(AgentFaceMorphEvent.Back);
220 0 : };
221 :
222 1 : const render = (now: number) => {
223 1 : const t = tuningRef.current;
224 1 : const dt = Math.min((now - last) / 1000, 1 / 20);
225 1 : last = now;
226 1 : const elapsed = now - phaseStart;
227 1 : const requested = modeRef.current;
228 :
229 : // Advance the cycle
230 1 : switch (phase) {
231 1 : case Phase.Face:
232 1 : if (requested === AgentFaceMode.Logo || (requested === AgentFaceMode.Cycle && elapsed >= faceHold && !reducedMotion)) {
233 1 : enterPhase(Phase.ClosingEyes, now);
234 : }
235 1 : break;
236 1 : case Phase.ClosingEyes:
237 1 : if (requested === AgentFaceMode.Face) enterPhase(Phase.Face, now);
238 1 : else if (elapsed >= EYES_CLOSE_MS) enterPhase(Phase.Logo, now);
239 1 : break;
240 1 : case Phase.Logo:
241 1 : if (requested === AgentFaceMode.Face || (requested === AgentFaceMode.Cycle && elapsed >= t.logoHoldMs)) {
242 1 : enterPhase(Phase.Returning, now);
243 : }
244 1 : break;
245 1 : case Phase.Returning:
246 1 : if (requested === AgentFaceMode.Logo) enterPhase(Phase.Logo, now);
247 1 : else if (morph.value < 0.05) enterPhase(Phase.Face, now);
248 : break;
249 1 : }
250 1 : const morphTarget = phase === Phase.Logo ? 1 : 0;
251 1 : if (reducedMotion) {
252 1 : morph.value = morphTarget;
253 1 : morph.velocity = 0;
254 1 : } else {
255 1 : stepSpring(morph, morphTarget, t.stiffness, t.damping, dt);
256 : }
257 :
258 : // The eyes close before the morph and open once the face is back
259 1 : const eyesTarget = phase === Phase.Face ? 1 : 0;
260 1 : const eyesStep = dt * 1000 / EYES_CLOSE_MS;
261 1 : eyesOpen = eyesTarget > eyesOpen ? Math.min(eyesTarget, eyesOpen + eyesStep) : Math.max(eyesTarget, eyesOpen - eyesStep);
262 : // A gaze target or the pointer draws the eyes, the face otherwise glances around on its own
263 1 : let lookYTarget = 0;
264 1 : const gaze = !reducedMotion && phase === Phase.Face ? gazePoint(now) : null;
265 1 : if (gaze != null && svgRef.current != null) {
266 1 : const rect = svgRef.current.getBoundingClientRect();
267 1 : const dx = gaze.x - (rect.left + rect.width / 2);
268 1 : const dy = gaze.y - (rect.top + (rect.height * EYE_Y) / VIEWBOX_SIZE);
269 1 : glanceTarget = dx / Math.hypot(dx, Math.max(1, rect.width * GAZE_SOFTNESS_X));
270 1 : lookYTarget = dy / Math.hypot(dy, Math.max(1, rect.width * GAZE_SOFTNESS_Y));
271 1 : nextGlance = now + jitter(t.glanceIntervalMs);
272 1 : } else if (!reducedMotion && phase === Phase.Face) {
273 1 : if (now >= nextGlance) {
274 1 : const choices = [-1, -1, -0.6, 0, 0.6, 1, 1].filter(c => c !== glanceTarget);
275 1 : glanceTarget = choices[Math.floor(Math.random() * choices.length)];
276 1 : nextGlance = now + jitter(t.glanceIntervalMs);
277 1 : }
278 : }
279 1 : if (!reducedMotion && phase === Phase.Face && now >= nextBlink + BLINK_MS) nextBlink = now + jitter(t.blinkIntervalMs);
280 1 : if (phase !== Phase.Face) glanceTarget = 0;
281 1 : stepSpring(glance, glanceTarget, 260, 26, dt);
282 1 : stepSpring(lookY, lookYTarget, 260, 26, dt);
283 : // The eyes squint happily while the input goes on
284 1 : const input = lastInputRef.current?.() ?? null;
285 1 : const happyTarget = phase === Phase.Face && input != null && now - input < HAPPY_HOLD_MS ? 1 : 0;
286 1 : if (reducedMotion) {
287 1 : happy.value = happyTarget;
288 : } else {
289 1 : stepSpring(happy, happyTarget, 320, 30, dt);
290 : }
291 :
292 1 : const blinkT = (now - nextBlink) / BLINK_MS;
293 1 : const blink = blinkT > 0 && blinkT < 1 ? Math.abs(2 * blinkT - 1) : 1;
294 1 : const squint = Math.min(Math.max(happy.value, 0), 1);
295 1 : const openness = eyesOpen * blink * (1 - squint);
296 1 : const eyeHeight = Math.max(0.001, EYE_HEIGHT * openness);
297 1 : const eyeWidth = EYE_WIDTH * (1 + 0.25 * (1 - openness));
298 1 : for (let i = 0; i < 2; ++i) {
299 1 : const eye = eyeRefs.current[i];
300 1 : if (eye == null) continue;
301 1 : const cx = VIEWBOX_SIZE / 2 + (i === 0 ? -EYE_DX : EYE_DX) + glance.value * t.glanceAmplitude;
302 1 : const cy = EYE_Y + lookY.value * t.glanceAmplitude * GAZE_VERTICAL;
303 1 : eye.setAttribute('x', (cx - eyeWidth / 2).toFixed(2));
304 1 : eye.setAttribute('y', (cy - eyeHeight / 2).toFixed(2));
305 1 : eye.setAttribute('width', eyeWidth.toFixed(2));
306 1 : eye.setAttribute('height', eyeHeight.toFixed(2));
307 : // The happy eyes, arcs like ^ ^
308 1 : const arc = happyEyeRefs.current[i];
309 1 : if (arc != null) {
310 1 : const w = HAPPY_EYE_WIDTH / 2, h = HAPPY_EYE_HEIGHT / 2;
311 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)}`);
312 1 : arc.setAttribute('opacity', (eyesOpen * squint).toFixed(3));
313 1 : }
314 1 : }
315 :
316 : // Morph the shapes, the exact brand mark once the spring rests at it
317 1 : const time = (now / 1000) * t.wobbleSpeed;
318 1 : const atLogo = morphTarget === 1 && springAtRest(morph, 1);
319 1 : for (let i = 0; i < brand.pieces.length; ++i) {
320 1 : const path = pathRefs.current[i];
321 1 : if (path == null) continue;
322 1 : if (atLogo) {
323 1 : path.setAttribute('d', brand.pieces[i].logoPath);
324 1 : continue;
325 1 : }
326 1 : morphPoints(brand.pieces[i], {
327 1 : progress: morph.value,
328 1 : time,
329 1 : wobble: reducedMotion ? 0 : t.wobble * Math.max(0, 1 - morph.value),
330 1 : lag: t.lag,
331 1 : ripple: reducedMotion ? 0 : t.ripple,
332 1 : }, outlines[i], scratch);
333 1 : path.setAttribute('d', outlinePath(outlines[i]));
334 1 : }
335 :
336 : // The cutouts of the brand mark fade in once the shape is close to it
337 1 : const cutouts = cutoutsRef.current;
338 1 : if (cutouts != null) {
339 1 : const reveal = Math.min(Math.max((morph.value - CUTOUT_REVEAL_FROM) / (1 - CUTOUT_REVEAL_FROM), 0), 1);
340 1 : cutouts.setAttribute('opacity', reveal.toFixed(3));
341 1 : }
342 :
343 : // The goo filter melts the halves together, until they are joined again on the way back.
344 : // The face is a disk the goo leaves as it is, the corners of the brand mark stay crisp at rest.
345 1 : const towardsLogo = Math.min(Math.max((1 - morph.value) / GOO_FADE, 0), 1);
346 1 : const blur = reducedMotion ? 0 : t.gooBlur * towardsLogo * towardsLogo * (3 - 2 * towardsLogo);
347 1 : const wantGoo = blur > 0.2;
348 1 : if (wantGoo !== gooOn) {
349 1 : gooOn = wantGoo;
350 1 : if (wantGoo) gooGroupRef.current?.setAttribute('filter', `url(#${gooId})`);
351 1 : else gooGroupRef.current?.removeAttribute('filter');
352 1 : }
353 1 : if (wantGoo) blurRef.current?.setAttribute('stdDeviation', blur.toFixed(2));
354 :
355 : // With reduced motion, a single frame shows the requested shape
356 1 : frame = reducedMotion ? 0 : requestAnimationFrame(render);
357 1 : };
358 1 : render(performance.now());
359 1 : return () => {
360 1 : if (frame !== 0) cancelAnimationFrame(frame);
361 1 : globalThis.removeEventListener?.('pointermove', onPointerMove);
362 1 : };
363 : // The tuning and the mode are read through refs, only a still face redraws when the mode changes
364 1 : }, [brand, paused, reducedMotion, gooId, reducedMotion ? mode : null]);
365 :
366 1 : const initialPaths = React.useMemo(() => brand.pieces.map(piece => outlinePath(piece.faceRest)), [brand]);
367 1 : return (
368 1 : <svg
369 1 : {...svgProps}
370 1 : ref={svgRef}
371 1 : className={className == null ? styles.root : `${styles.root} ${className}`}
372 1 : viewBox={`0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`}
373 1 : role="img"
374 1 : aria-label={label}
375 : >
376 1 : <defs>
377 1 : <mask id={maskId} maskUnits="userSpaceOnUse" x={-50} y={-50} width={300} height={300}>
378 1 : <rect x={-50} y={-50} width={300} height={300} fill="white" />
379 1 : {[0, 1].map(i => (
380 1 : <rect key={i} ref={el => { eyeRefs.current[i] = el; }}
381 1 : x={VIEWBOX_SIZE / 2 + (i === 0 ? -EYE_DX : EYE_DX) - EYE_WIDTH / 2} y={EYE_Y - EYE_HEIGHT / 2}
382 1 : width={EYE_WIDTH} height={EYE_HEIGHT} rx={EYE_WIDTH / 2} fill="black" />
383 1 : ))}
384 1 : {[0, 1].map(i => (
385 1 : <path key={i} ref={el => { happyEyeRefs.current[i] = el; }} opacity={0} fill="none" stroke="black"
386 1 : strokeWidth={HAPPY_EYE_STROKE} strokeLinecap="round" strokeLinejoin="round" />
387 1 : ))}
388 1 : <g ref={cutoutsRef} opacity={0}>
389 1 : {brand.cutouts.map((cutout, i) => (
390 0 : <path key={i} d={cutout.d} transform={cutout.transform} fill="black" />
391 1 : ))}
392 1 : </g>
393 1 : </mask>
394 1 : <filter id={gooId} x="-20%" y="-20%" width="140%" height="140%" colorInterpolationFilters="sRGB">
395 1 : <feGaussianBlur ref={blurRef} in="SourceGraphic" stdDeviation={0} result="blur" />
396 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" />
397 1 : <feComposite in="SourceGraphic" in2="goo" operator="atop" />
398 1 : </filter>
399 1 : </defs>
400 1 : <g mask={`url(#${maskId})`}>
401 1 : <g ref={gooGroupRef}>
402 1 : {initialPaths.map((d, i) => (
403 1 : <path key={i} ref={el => { pathRefs.current[i] = el; }} d={d} fill="currentColor" />
404 1 : ))}
405 1 : </g>
406 1 : </g>
407 1 : </svg>
408 : );
409 1 : }
|