LCOV - code coverage report
Current view: top level - dashql-app/src/agent - wobble.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 99.6 % 273 272
Test Date: 2026-11-09 20:22:13

            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 : }
        

Generated by: LCOV version 2.0-1