LCOV - code coverage report
Current view: top level - dashql-app/src/agent - agent_face_morph.ts Coverage Total Hit
Test: DashQL 915828b Lines: 99.1 % 338 335
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : /// Geometry of the agent face that morphs into the DashQL brand mark and back.
       2              : ///
       3              : /// Morphs between arbitrary paths only look fluid when both shapes share one point structure, so the morph
       4              : /// does not interpolate path strings. The face is a disk split along its diagonal into two halves, the brand mark
       5              : /// has two pieces. Every half and every piece is resampled to the same number of points by arc length, with the
       6              : /// same winding, and the pieces are rotated to the start point that moves the points the least. A frame then blends
       7              : /// point by point. Everything is plain math in the 200x200 viewBox of the brand mark, without the DOM.
       8              : 
       9              : /// The size of the viewBox
      10              : export const VIEWBOX_SIZE = 200;
      11              : /// The points of every outline
      12              : export const OUTLINE_POINTS = 200;
      13              : 
      14              : /// The center and the radius of the face
      15            1 : const FACE_CENTER = 100;
      16            1 : const FACE_RADIUS = 70;
      17              : /// The halves reach past the diagonal by this angle, so the joined halves show no seam
      18            1 : const FACE_SEAM_OVERLAP = (3 * Math.PI) / 180;
      19              : 
      20              : /// The share of a spring overshoot past the face that moves the points
      21            1 : const FACE_OVERSHOOT = 0.3;
      22              : 
      23              : /// The two pieces of the DashQL brand mark, `static/svg/logo/dashql.svg`, with the transforms of their groups
      24            1 : const DASHQL_PIECE_UPPER: BrandShape = {
      25            1 :     d: 'M171.857,146.784C172.231,148.654 173.873,150 175.779,150C189.377,150 241.375,150 257.621,150C258.819,150 259.954,149.463 260.714,148.536C261.474,147.609 261.778,146.391 261.543,145.216C259.52,135.099 254.853,111.766 253.143,103.216C252.769,101.346 251.128,100 249.221,100C241.703,100 223.297,100 215.779,100C213.872,100 212.231,98.654 211.857,96.784C210.051,87.756 204.949,62.244 203.143,53.216C202.769,51.346 201.128,50 199.221,50C190.646,50 167.521,50 157.379,50C156.181,50 155.046,50.537 154.286,51.464C153.526,52.391 153.222,53.609 153.457,54.784C156.947,72.235 168.94,132.201 171.857,146.784Z',
      26            1 :     map: (x, y) => dashqlPoint(x, y, -12.5),
      27            1 : };
      28            1 : const DASHQL_PIECE_LOWER: BrandShape = {
      29            1 :     d: 'M38.457,104.784C38.222,103.609 38.526,102.391 39.286,101.464C40.046,100.537 41.181,100 42.379,100C58.625,100 110.623,100 124.221,100C126.127,100 127.769,101.346 128.143,103.216C131.06,117.799 143.053,177.765 146.543,195.216C146.778,196.391 146.474,197.609 145.714,198.536C144.954,199.463 143.819,200 142.621,200C132.479,200 109.354,200 100.779,200C98.872,200 97.231,198.654 96.857,196.784C95.051,187.756 89.949,162.244 88.143,153.216C87.769,151.346 86.128,150 84.221,150C76.703,150 58.297,150 50.779,150C48.872,150 47.231,148.654 46.857,146.784C45.147,138.234 40.48,114.901 38.457,104.784Z',
      30            1 :     map: (x, y) => dashqlPoint(x, y, 12.5),
      31            1 : };
      32              : 
      33              : /// The DashQL brand mark shrinks a little around the center of the viewBox, so that it does not tower over the disk
      34              : /// of the face it melts from.
      35            1 : const DASHQL_SCALE = 0.9;
      36              : 
      37              : /// Map a point of a DashQL piece into the viewBox, the transform chain of the logo file and the scale
      38            1 : function dashqlPoint(x: number, y: number, dx: number): [number, number] {
      39            1 :     const [px, py] = dashqlFilePoint(x, y, dx);
      40            1 :     const c = VIEWBOX_SIZE / 2;
      41            1 :     return [c + (px - c) * DASHQL_SCALE, c + (py - c) * DASHQL_SCALE];
      42            1 : }
      43              : 
      44              : /// Map a point of a DashQL piece into the viewBox of the logo file
      45            1 : function dashqlFilePoint(x: number, y: number, dx: number): [number, number] {
      46              :     // matrix(1.25,0,0,0.935,-61.401,253.25) ∘ translate(dx, 25)
      47            1 :     let px = 1.25 * (x + dx) - 61.401;
      48            1 :     let py = 0.935 * (y + 25) + 253.25;
      49              :     // matrix(0.8,0,0,1.06952,0,-16.3556)
      50            1 :     px = 0.8 * px;
      51            1 :     py = 1.06952 * py - 16.3556;
      52              :     // translate(0, -304.5)
      53            1 :     return [px, py - 304.5];
      54            1 : }
      55              : 
      56              : /// A cubic Bézier segment
      57              : type Cubic = [number, number, number, number, number, number, number, number];
      58              : 
      59              : /// Parse a closed path of absolute `M`, `C`, `Q`, `L` and `Z` commands into cubic segments
      60            1 : export function parseClosedPath(d: string): Cubic[] {
      61            1 :     const tokens = d.match(/[MCQLZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
      62            1 :     const segments: Cubic[] = [];
      63            1 :     let i = 0;
      64            1 :     let command = '';
      65            1 :     let startX = 0, startY = 0, x = 0, y = 0;
      66            1 :     const num = () => Number(tokens[i++]);
      67            1 :     while (i < tokens.length) {
      68            1 :         if (/^[a-z]$/i.test(tokens[i])) {
      69            1 :             command = tokens[i++];
      70              :         }
      71            1 :         switch (command) {
      72            1 :             case 'M':
      73            1 :                 x = startX = num();
      74            1 :                 y = startY = num();
      75            1 :                 command = 'L';
      76            1 :                 break;
      77            1 :             case 'L': {
      78            1 :                 const nx = num(), ny = num();
      79            1 :                 segments.push([x, y, x, y, nx, ny, nx, ny]);
      80            1 :                 x = nx;
      81            1 :                 y = ny;
      82            1 :                 break;
      83            1 :             }
      84            1 :             case 'C': {
      85            1 :                 const c = [num(), num(), num(), num(), num(), num()];
      86            1 :                 segments.push([x, y, c[0], c[1], c[2], c[3], c[4], c[5]]);
      87            1 :                 x = c[4];
      88            1 :                 y = c[5];
      89            1 :                 break;
      90            1 :             }
      91            1 :             case 'Q': {
      92              :                 // A quadratic segment is a cubic with the control points two thirds towards the quadratic one
      93            1 :                 const qx = num(), qy = num(), nx = num(), ny = num();
      94            1 :                 segments.push([x, y, x + (2 / 3) * (qx - x), y + (2 / 3) * (qy - y), nx + (2 / 3) * (qx - nx), ny + (2 / 3) * (qy - ny), nx, ny]);
      95            1 :                 x = nx;
      96            1 :                 y = ny;
      97            1 :                 break;
      98            1 :             }
      99            1 :             case 'Z':
     100            1 :                 if (Math.hypot(x - startX, y - startY) > 1e-6) {
     101            1 :                     segments.push([x, y, x, y, startX, startY, startX, startY]);
     102              :                 }
     103            1 :                 x = startX;
     104            1 :                 y = startY;
     105            1 :                 command = '';
     106            1 :                 break;
     107            1 :             default:
     108            1 :                 throw new Error(`unsupported path command: ${command} in ${d}`);
     109            1 :         }
     110            1 :     }
     111            1 :     return segments;
     112            1 : }
     113              : 
     114              : /// Flatten cubic segments to a closed polyline, interleaved x and y, without repeating the start
     115            1 : function flattenCubics(segments: Cubic[], steps = 48): number[] {
     116            1 :     const out: number[] = [];
     117            1 :     for (const [x0, y0, x1, y1, x2, y2, x3, y3] of segments) {
     118            1 :         for (let s = 0; s < steps; ++s) {
     119            1 :             const t = s / steps, u = 1 - t;
     120            1 :             const a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, e = t * t * t;
     121            1 :             out.push(a * x0 + b * x1 + c * x2 + e * x3, a * y0 + b * y1 + c * y2 + e * y3);
     122            1 :         }
     123              :     }
     124            1 :     return out;
     125            1 : }
     126              : 
     127              : /// Resample a closed polyline to `n` points spaced evenly by arc length
     128            1 : export function resampleClosed(poly: ArrayLike<number>, n: number): Float64Array {
     129            1 :     const m = poly.length / 2;
     130            1 :     const cumulative = new Float64Array(m + 1);
     131            1 :     for (let i = 0; i < m; ++i) {
     132            1 :         const j = (i + 1) % m;
     133            1 :         cumulative[i + 1] = cumulative[i] + Math.hypot(poly[2 * j] - poly[2 * i], poly[2 * j + 1] - poly[2 * i + 1]);
     134            1 :     }
     135            1 :     const total = cumulative[m];
     136            1 :     const out = new Float64Array(2 * n);
     137            1 :     let k = 0;
     138            1 :     for (let i = 0; i < n; ++i) {
     139            1 :         const s = (i * total) / n;
     140            1 :         while (k < m - 1 && cumulative[k + 1] <= s) ++k;
     141            1 :         const j = (k + 1) % m;
     142            1 :         const span = cumulative[k + 1] - cumulative[k];
     143            1 :         const t = span > 0 ? (s - cumulative[k]) / span : 0;
     144            1 :         out[2 * i] = poly[2 * k] + (poly[2 * j] - poly[2 * k]) * t;
     145            1 :         out[2 * i + 1] = poly[2 * k + 1] + (poly[2 * j + 1] - poly[2 * k + 1]) * t;
     146            1 :     }
     147            1 :     return out;
     148            1 : }
     149              : 
     150              : /// The signed area of a closed outline, positive when clockwise on screen (y down)
     151            1 : export function signedArea(outline: ArrayLike<number>): number {
     152            1 :     const n = outline.length / 2;
     153            1 :     let area = 0;
     154            1 :     for (let i = 0; i < n; ++i) {
     155            1 :         const j = (i + 1) % n;
     156            1 :         area += outline[2 * i] * outline[2 * j + 1] - outline[2 * j] * outline[2 * i + 1];
     157            1 :     }
     158            1 :     return area / 2;
     159            1 : }
     160              : 
     161              : /// Reverse the point order of an outline
     162            1 : function reverseOutline(outline: Float64Array): Float64Array {
     163            1 :     const n = outline.length / 2;
     164            1 :     const out = new Float64Array(outline.length);
     165            1 :     for (let i = 0; i < n; ++i) {
     166            1 :         out[2 * i] = outline[2 * (n - 1 - i)];
     167            1 :         out[2 * i + 1] = outline[2 * (n - 1 - i) + 1];
     168            1 :     }
     169            1 :     return out;
     170            1 : }
     171              : 
     172              : /// The summed squared distances between `a[i]` and `b[(i + offset) % n]`
     173            1 : export function alignmentCost(a: ArrayLike<number>, b: ArrayLike<number>, offset: number): number {
     174            1 :     const n = a.length / 2;
     175            1 :     let cost = 0;
     176            1 :     for (let i = 0; i < n; ++i) {
     177            1 :         const j = (i + offset) % n;
     178            1 :         const dx = a[2 * i] - b[2 * j], dy = a[2 * i + 1] - b[2 * j + 1];
     179            1 :         cost += dx * dx + dy * dy;
     180            1 :     }
     181            1 :     return cost;
     182            1 : }
     183              : 
     184              : /// Rotate `b` to the start point that is closest to the points of `a`
     185            1 : export function alignOutline(a: ArrayLike<number>, b: Float64Array): { outline: Float64Array; offset: number } {
     186            1 :     const n = a.length / 2;
     187            1 :     let best = 0, bestCost = Infinity;
     188            1 :     for (let offset = 0; offset < n; ++offset) {
     189            1 :         const cost = alignmentCost(a, b, offset);
     190            1 :         if (cost < bestCost) {
     191            1 :             bestCost = cost;
     192            1 :             best = offset;
     193            1 :         }
     194            1 :     }
     195            1 :     const out = new Float64Array(b.length);
     196            1 :     for (let i = 0; i < n; ++i) {
     197            1 :         const j = (i + best) % n;
     198            1 :         out[2 * i] = b[2 * j];
     199            1 :         out[2 * i + 1] = b[2 * j + 1];
     200            1 :     }
     201            1 :     return { outline: out, offset: best };
     202            1 : }
     203              : 
     204              : /// A region of the face: arcs of the face outline, clockwise on screen, with chords from the end of one arc to the
     205              : /// start of the next. One arc and its chord is a half, two arcs are a vertical slice, one full turn is the disk.
     206              : interface FaceRegion {
     207              :     /// The arcs, [from, to] angles with from < to
     208              :     arcs: [number, number][];
     209              :     /// Per sample, the angle on an arc or NaN for a chord sample
     210              :     angles: Float64Array;
     211              :     /// Per chord sample, the arc whose end the chord leaves
     212              :     chordArc: Int32Array;
     213              :     /// Per chord sample, the position on the chord
     214              :     chordT: Float64Array;
     215              : }
     216              : 
     217              : /// Sample a face region evenly by length
     218            1 : function sampleFaceRegion(arcs: [number, number][], n: number): FaceRegion {
     219            1 :     const point = (angle: number): [number, number] => [FACE_RADIUS * Math.cos(angle), FACE_RADIUS * Math.sin(angle)];
     220            1 :     const edges: { arc: number; chord: boolean; length: number }[] = [];
     221            1 :     for (let k = 0; k < arcs.length; ++k) {
     222            1 :         const [from, to] = arcs[k];
     223            1 :         const next = arcs[(k + 1) % arcs.length][0];
     224            1 :         const [x0, y0] = point(to), [x1, y1] = point(next);
     225            1 :         edges.push({ arc: k, chord: false, length: FACE_RADIUS * (to - from) });
     226            1 :         edges.push({ arc: k, chord: true, length: Math.hypot(x1 - x0, y1 - y0) });
     227            1 :     }
     228            1 :     const total = edges.reduce((sum, edge) => sum + edge.length, 0);
     229            1 :     const angles = new Float64Array(n);
     230            1 :     const chordArc = new Int32Array(n);
     231            1 :     const chordT = new Float64Array(n);
     232            1 :     let e = 0, edgeStart = 0;
     233            1 :     for (let i = 0; i < n; ++i) {
     234            1 :         const s = (i * total) / n;
     235            1 :         while (e < edges.length - 1 && s >= edgeStart + edges[e].length) edgeStart += edges[e++].length;
     236            1 :         const edge = edges[e];
     237            1 :         const t = edge.length > 0 ? (s - edgeStart) / edge.length : 0;
     238            1 :         if (edge.chord) {
     239            1 :             angles[i] = NaN;
     240            1 :             chordArc[i] = edge.arc;
     241            1 :             chordT[i] = t;
     242            1 :         } else {
     243            1 :             const [from, to] = arcs[edge.arc];
     244            1 :             angles[i] = from + (to - from) * t;
     245            1 :         }
     246            1 :     }
     247            1 :     return { arcs, angles, chordArc, chordT };
     248            1 : }
     249              : 
     250              : /// A vertical slice of the face between two offsets from the center, in face radii in [-1, 1]
     251            1 : function faceSlice(left: number, right: number): [number, number][] {
     252            1 :     const l = Math.acos(Math.max(-1, Math.min(1, left)));
     253            1 :     const r = Math.acos(Math.max(-1, Math.min(1, right)));
     254              :     // The top arc runs from the left to the right, the bottom arc back
     255            1 :     return [[-l, -r], [r, l]];
     256            1 : }
     257              : 
     258              : /// The radius wobble of the liquid face at an angle and a time in seconds
     259            1 : function wobbleRadius(angle: number, time: number, amount: number): number {
     260            1 :     if (amount === 0) return FACE_RADIUS;
     261            1 :     const w = 0.5 * Math.sin(3 * angle + 1.3 * time)
     262            1 :         + 0.3 * Math.sin(2 * angle - 0.9 * time + 1.7)
     263            1 :         + 0.2 * Math.sin(5 * angle + 2.1 * time + 0.4);
     264            1 :     const breath = 0.4 * Math.sin(0.8 * time);
     265            1 :     return FACE_RADIUS * (1 + amount * (w + breath));
     266            1 : }
     267              : 
     268              : /// Write the points of a face region at a time into `out`
     269            1 : function faceRegionPoints(region: FaceRegion, time: number, wobble: number, out: Float64Array) {
     270            1 :     const at = (angle: number): [number, number] => {
     271            1 :         const r = wobbleRadius(angle, time, wobble);
     272            1 :         return [FACE_CENTER + r * Math.cos(angle), FACE_CENTER + r * Math.sin(angle)];
     273            1 :     };
     274            1 :     const ends = region.arcs.map(([from, to]) => ({ from: at(from), to: at(to) }));
     275            1 :     for (let i = 0; i < region.angles.length; ++i) {
     276            1 :         const angle = region.angles[i];
     277            1 :         if (Number.isNaN(angle)) {
     278            1 :             const k = region.chordArc[i], t = region.chordT[i];
     279            1 :             const [x0, y0] = ends[k].to, [x1, y1] = ends[(k + 1) % ends.length].from;
     280            1 :             out[2 * i] = x0 + (x1 - x0) * t;
     281            1 :             out[2 * i + 1] = y0 + (y1 - y0) * t;
     282            1 :         } else {
     283            1 :             const [x, y] = at(angle);
     284            1 :             out[2 * i] = x;
     285            1 :             out[2 * i + 1] = y;
     286            1 :         }
     287            1 :     }
     288            1 : }
     289              : 
     290              : /// One morphing shape: a face region and the brand mark piece it turns into
     291              : export interface MorphPiece {
     292              :     /// The samples of the face region
     293              :     face: FaceRegion;
     294              :     /// The outline of the face region at rest
     295              :     faceRest: Float64Array;
     296              :     /// The outline of the brand mark piece, aligned to the face half
     297              :     logo: Float64Array;
     298              :     /// The exact path of the brand mark piece in the viewBox, drawn when the morph rests at the logo
     299              :     logoPath: string;
     300              :     /// Per point, the normalized travel distance in [0, 1]
     301              :     travel: Float64Array;
     302              :     /// The centroid of the brand mark piece, the ripple pushes away from it
     303              :     logoCenter: [number, number];
     304              : }
     305              : 
     306              : /// A closed outline of a brand mark, mapped into the viewBox
     307              : interface BrandShape {
     308              :     /// The path, absolute `M`, `C`, `L` and `Z` commands
     309              :     d: string;
     310              :     /// Map a point of the path into the viewBox
     311              :     map?: (x: number, y: number) => [number, number];
     312              : }
     313              : 
     314              : /// Build a piece of the brand mark in the viewBox, the outline and the exact path
     315            1 : function buildLogoPiece(piece: BrandShape, n: number): { outline: Float64Array; path: string } {
     316            1 :     const map = piece.map ?? ((x: number, y: number): [number, number] => [x, y]);
     317            1 :     const cubics = parseClosedPath(piece.d).map(c => {
     318            1 :         const out: number[] = [];
     319            1 :         for (let i = 0; i < 8; i += 2) out.push(...map(c[i], c[i + 1]));
     320            1 :         return out as Cubic;
     321            1 :     });
     322            1 :     let outline = resampleClosed(flattenCubics(cubics), n);
     323            1 :     if (signedArea(outline) < 0) outline = reverseOutline(outline);
     324            1 :     const f = (v: number) => Number(v.toFixed(3));
     325            1 :     let path = `M${f(cubics[0][0])},${f(cubics[0][1])}`;
     326            1 :     for (const c of cubics) path += `C${f(c[2])},${f(c[3])} ${f(c[4])},${f(c[5])} ${f(c[6])},${f(c[7])}`;
     327            1 :     return { outline, path: `${path}Z` };
     328            1 : }
     329              : 
     330              : /// Build a morph piece from a face region and a brand mark piece
     331            1 : function buildPiece(arcs: [number, number][], logoPiece: BrandShape, n: number): MorphPiece {
     332            1 :     const face = sampleFaceRegion(arcs, n);
     333            1 :     const faceRest = new Float64Array(2 * n);
     334            1 :     faceRegionPoints(face, 0, 0, faceRest);
     335            1 :     const built = buildLogoPiece(logoPiece, n);
     336            1 :     const { outline: logo } = alignOutline(faceRest, built.outline);
     337            1 :     const travel = new Float64Array(n);
     338            1 :     let maxTravel = 0, cx = 0, cy = 0;
     339            1 :     for (let i = 0; i < n; ++i) {
     340            1 :         travel[i] = Math.hypot(logo[2 * i] - faceRest[2 * i], logo[2 * i + 1] - faceRest[2 * i + 1]);
     341            1 :         maxTravel = Math.max(maxTravel, travel[i]);
     342            1 :         cx += logo[2 * i] / n;
     343            1 :         cy += logo[2 * i + 1] / n;
     344            1 :     }
     345            1 :     for (let i = 0; i < n; ++i) travel[i] = maxTravel > 0 ? travel[i] / maxTravel : 0;
     346            1 :     return { face, faceRest, logo, logoPath: built.path, travel, logoCenter: [cx, cy] };
     347            1 : }
     348              : 
     349              : /// A shape cut out of the brand mark, revealed once the morph arrives at it
     350              : export interface BrandCutout {
     351              :     /// The path data
     352              :     d: string;
     353              :     /// The SVG transform into the viewBox
     354              :     transform: string;
     355              : }
     356              : 
     357              : /// A brand mark the face morphs into
     358              : export interface AgentFaceBrand {
     359              :     /// The id of the brand
     360              :     id: string;
     361              :     /// The morphing shapes, every one starts as a region of the face
     362              :     pieces: readonly MorphPiece[];
     363              :     /// The shapes cut out of the brand mark, like the eyes are cut out of the face
     364              :     cutouts: readonly BrandCutout[];
     365              : }
     366              : 
     367              : /// The DashQL brand mark, `static/svg/logo/dashql.svg`.
     368              : /// The upper right face half turns into the upper right piece of the brand mark, the lower left half into the
     369              : /// lower left piece. The diagonal runs from the top left to the bottom right.
     370            1 : export const DASHQL_BRAND: AgentFaceBrand = {
     371            1 :     id: 'dashql',
     372            1 :     pieces: [
     373            1 :         buildPiece([[(5 * Math.PI) / 4 - FACE_SEAM_OVERLAP, (9 * Math.PI) / 4 + FACE_SEAM_OVERLAP]], DASHQL_PIECE_UPPER, OUTLINE_POINTS),
     374            1 :         buildPiece([[Math.PI / 4 - FACE_SEAM_OVERLAP, (5 * Math.PI) / 4 + FACE_SEAM_OVERLAP]], DASHQL_PIECE_LOWER, OUTLINE_POINTS),
     375            1 :     ],
     376            1 :     cutouts: [],
     377            1 : };
     378              : 
     379              : /// The SIDB wordmark, `static/svg/logo/sidb.svg`, glyph outlines of Bricolage Grotesque ExtraBold.
     380              : /// Every outer contour is a piece, the D is a stem and a bowl, the counters of the B are cut out.
     381            1 : const SIDB_WORDMARK = { minX: 39, maxX: 2270, centerY: -330 };
     382              : /// The wordmark is wider than the viewBox, the face would otherwise seem to lose mass when it melts into the letters
     383              : const SIDB_WIDTH = 204;
     384            1 : const SIDB_SCALE = SIDB_WIDTH / (SIDB_WORDMARK.maxX - SIDB_WORDMARK.minX);
     385            1 : const SIDB_DX = (VIEWBOX_SIZE - SIDB_WIDTH) / 2 - SIDB_WORDMARK.minX * SIDB_SCALE;
     386            1 : const SIDB_DY = VIEWBOX_SIZE / 2 - SIDB_WORDMARK.centerY * SIDB_SCALE;
     387            1 : const sidbPoint = (x: number, y: number): [number, number] => [SIDB_DX + x * SIDB_SCALE, SIDB_DY + y * SIDB_SCALE];
     388            1 : const SIDB_S = 'M331,14Q267,14 215,2Q163,-10 125,-34.5Q87,-59 65,-97Q43,-135 39,-185L180,-232Q183,-190 204,-162Q225,-134 260.5,-120.5Q296,-107 340,-107Q382,-107 410.5,-117.5Q439,-128 453.5,-144.5Q468,-161 468,-180Q468,-202 449,-216.5Q430,-231 397,-241.5Q364,-252 320,-262Q270,-274 222,-288Q174,-302 135,-324.5Q96,-347 73.5,-382.5Q51,-418 51,-472Q51,-533 82,-578Q113,-623 173,-648.5Q233,-674 321,-674Q408,-674 469,-649Q530,-624 563,-579.5Q596,-535 599,-476L455,-435Q455,-464 445.5,-486Q436,-508 419,-522.5Q402,-537 377,-544.5Q352,-552 320,-552Q282,-552 255.5,-543Q229,-534 216,-519.5Q203,-505 203,-486Q203,-462 223.5,-446.5Q244,-431 279.5,-420.5Q315,-410 358,-400Q401,-391 447,-377.5Q493,-364 533,-342Q573,-320 597.5,-282.5Q622,-245 622,-188Q622,-127 589.5,-81.5Q557,-36 492,-11Q427,14 331,14Z';
     389            1 : const SIDB_I = 'M712,0L712,-660L876,-660L876,0L712,0Z';
     390            1 : const SIDB_D_BOWL = 'M1075,0L1075,-130L1266,-130Q1318,-130 1353,-152Q1388,-174 1405.5,-218Q1423,-262 1423,-328Q1423,-381 1412,-419Q1401,-457 1379.5,-481.5Q1358,-506 1324.5,-518Q1291,-530 1246,-530L1075,-530L1075,-660L1240,-660Q1363,-660 1441,-621Q1519,-582 1555,-509.5Q1591,-437 1591,-336Q1591,-258 1572.5,-201Q1554,-144 1522,-105.5Q1490,-67 1448.5,-43.5Q1407,-20 1361.5,-10Q1316,0 1271,0L1075,0Z';
     391            1 : const SIDB_D_STEM = 'M1000,0L1000,-660L1161,-660L1161,0L1000,0Z';
     392            1 : const SIDB_B = 'M1690,0L1690,-660L1975,-660Q2037,-660 2086.5,-649.5Q2136,-639 2171,-617.5Q2206,-596 2224.5,-562.5Q2243,-529 2243,-483Q2243,-444 2226.5,-415Q2210,-386 2175,-368Q2140,-350 2082,-344L2082,-327Q2178,-321 2224,-281Q2270,-241 2270,-173Q2270,-115 2239,-77Q2208,-39 2148,-19.5Q2088,0 2000,0L1690,0Z';
     393            1 : const SIDB_B_COUNTERS = 'M1844,-128L1994,-128Q2055,-128 2084,-145.5Q2113,-163 2113,-201Q2113,-243 2078,-264Q2043,-285 1971,-285L1844,-285L1844,-128ZM1844,-383L1949,-383Q2020,-383 2053,-402.5Q2086,-422 2086,-461Q2086,-499 2055.5,-516.5Q2025,-534 1963,-534L1844,-534L1844,-383Z';
     394              : 
     395              : /// The face splits into vertical slices from left to right, one per piece of the wordmark. The slices end halfway
     396              : /// between the centers of two neighboring pieces, mapped from the width of the wordmark onto the width of the face.
     397            1 : function sidbBrand(): AgentFaceBrand {
     398            1 :     const glyphs = [SIDB_S, SIDB_I, SIDB_D_STEM, SIDB_D_BOWL, SIDB_B];
     399            1 :     const centers = glyphs.map(d => {
     400            1 :         const xs = parseClosedPath(d).flatMap(c => [c[0], c[6]]);
     401            1 :         return (Math.min(...xs) + Math.max(...xs)) / 2;
     402            1 :     });
     403            1 :     const toFace = (x: number) => -1 + (2 * (x - SIDB_WORDMARK.minX)) / (SIDB_WORDMARK.maxX - SIDB_WORDMARK.minX);
     404            1 :     const bounds = [-1, ...centers.slice(1).map((c, i) => toFace((centers[i] + c) / 2)), 1];
     405              :     // The slices overlap a little, so the joined slices show no seam
     406            1 :     const overlap = 0.04;
     407            1 :     return {
     408            1 :         id: 'sidb',
     409            1 :         pieces: glyphs.map((d, i) => buildPiece(
     410            1 :             faceSlice(bounds[i] - (i > 0 ? overlap : 0), bounds[i + 1] + (i < glyphs.length - 1 ? overlap : 0)),
     411            1 :             { d, map: sidbPoint },
     412              :             OUTLINE_POINTS,
     413            1 :         )),
     414            1 :         cutouts: [{ d: SIDB_B_COUNTERS, transform: `translate(${SIDB_DX} ${SIDB_DY}) scale(${SIDB_SCALE})` }],
     415            1 :     };
     416            1 : }
     417            1 : export const SIDB_BRAND: AgentFaceBrand = sidbBrand();
     418              : 
     419              : /// The parameters of one frame of the morph
     420              : export interface MorphFrame {
     421              :     /// The morph progress, 0 is the face and 1 is the brand mark, a spring can overshoot both ends
     422              :     progress: number;
     423              :     /// The time in seconds, drives the wobble and the ripple
     424              :     time: number;
     425              :     /// The radius wobble of the face, relative to the radius
     426              :     wobble: number;
     427              :     /// The share of the progress by which the points that travel the least start later, in [0, 1)
     428              :     lag: number;
     429              :     /// The amplitude of the ripple along the outline while the shape travels, in viewBox units
     430              :     ripple: number;
     431              : }
     432              : 
     433              : /// Compute the points of a piece for a frame into `out`, `scratch` holds the face points
     434            1 : export function morphPoints(piece: MorphPiece, frame: MorphFrame, out: Float64Array, scratch: Float64Array) {
     435            1 :     const n = piece.travel.length;
     436            1 :     faceRegionPoints(piece.face, frame.time, frame.wobble, scratch);
     437            1 :     const lag = Math.min(Math.max(frame.lag, 0), 0.95);
     438            1 :     const x = frame.progress;
     439            1 :     for (let i = 0; i < n; ++i) {
     440              :         // Points that travel far lead, points that travel little follow
     441            1 :         const delay = lag * (1 - piece.travel[i]);
     442              :         // An overshoot past the face only jiggles it, the halves would otherwise part at the seam
     443            1 :         const q = x < 0 ? FACE_OVERSHOOT * x : x < delay ? 0 : (x - delay) / (1 - delay);
     444            1 :         let px = scratch[2 * i] + (piece.logo[2 * i] - scratch[2 * i]) * q;
     445            1 :         let py = scratch[2 * i + 1] + (piece.logo[2 * i + 1] - scratch[2 * i + 1]) * q;
     446            1 :         if (frame.ripple !== 0) {
     447              :             // A wave runs along the outline while the point is in transit, away from the center of the piece
     448            1 :             const transit = Math.sin(Math.PI * Math.min(Math.max(q, 0), 1));
     449            1 :             if (transit > 0) {
     450            1 :                 const dx = px - piece.logoCenter[0], dy = py - piece.logoCenter[1];
     451            1 :                 const len = Math.hypot(dx, dy) || 1;
     452            1 :                 const wave = frame.ripple * transit * Math.sin((6 * Math.PI * i) / n + 5 * frame.time);
     453            1 :                 px += (dx / len) * wave;
     454            1 :                 py += (dy / len) * wave;
     455            1 :             }
     456            1 :         }
     457            1 :         out[2 * i] = px;
     458            1 :         out[2 * i + 1] = py;
     459            1 :     }
     460            1 : }
     461              : 
     462              : /// Write an outline as a closed polygon path
     463            1 : export function outlinePath(outline: ArrayLike<number>): string {
     464            1 :     const n = outline.length / 2;
     465            1 :     const parts = new Array<string>(n);
     466            1 :     for (let i = 0; i < n; ++i) {
     467            1 :         parts[i] = `${outline[2 * i].toFixed(2)} ${outline[2 * i + 1].toFixed(2)}`;
     468              :     }
     469            1 :     return `M${parts.join('L')}Z`;
     470            1 : }
     471              : 
     472              : /// The bounding box of an outline, [minX, minY, maxX, maxY]
     473            1 : export function outlineBounds(outline: ArrayLike<number>): [number, number, number, number] {
     474            1 :     let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
     475            1 :     for (let i = 0; i < outline.length; i += 2) {
     476            1 :         minX = Math.min(minX, outline[i]);
     477            1 :         maxX = Math.max(maxX, outline[i]);
     478            1 :         minY = Math.min(minY, outline[i + 1]);
     479            1 :         maxY = Math.max(maxY, outline[i + 1]);
     480            1 :     }
     481            1 :     return [minX, minY, maxX, maxY];
     482            1 : }
     483              : 
     484              : /// A damped spring toward a target, stepped with the frame time
     485              : export interface Spring {
     486              :     value: number;
     487              :     velocity: number;
     488              : }
     489              : 
     490              : /// Step a spring by `dt` seconds, in small substeps so that stiff springs stay stable
     491            1 : export function stepSpring(spring: Spring, target: number, stiffness: number, damping: number, dt: number) {
     492            1 :     const steps = Math.max(1, Math.ceil(dt / (1 / 240)));
     493            1 :     const h = dt / steps;
     494            1 :     for (let s = 0; s < steps; ++s) {
     495            1 :         const accel = -stiffness * (spring.value - target) - damping * spring.velocity;
     496            1 :         spring.velocity += accel * h;
     497            1 :         spring.value += spring.velocity * h;
     498            1 :     }
     499            1 : }
     500              : 
     501              : /// Is the spring at rest at its target
     502            0 : export function springAtRest(spring: Spring, target: number): boolean {
     503            0 :     return Math.abs(spring.value - target) < 1e-3 && Math.abs(spring.velocity) < 1e-2;
     504            0 : }
        

Generated by: LCOV version 2.0-1