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