LCOV - code coverage report
Current view: top level - dashql-app/src/agent - agent_face.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 97.1 % 280 272
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1