morphico 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/morphico.js CHANGED
@@ -1,4 +1,44 @@
1
- /*! morphico v0.1.0 | MIT License */
1
+ /*! morphico v0.3.0 | code: MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE)
2
+ * Icons based on Lucide (https://lucide.dev), some of them from Feather (https://feathericons.com):
3
+ *
4
+ * ISC License
5
+ *
6
+ * Copyright (c) 2026 Lucide Icons and Contributors
7
+ *
8
+ * Permission to use, copy, modify, and/or distribute this software for any
9
+ * purpose with or without fee is hereby granted, provided that the above
10
+ * copyright notice and this permission notice appear in all copies.
11
+ *
12
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
13
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
14
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
15
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
16
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
17
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
18
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
19
+ *
20
+ * The MIT License (MIT) (for the Lucide icons derived from Feather, listed in LICENSE-lucide)
21
+ *
22
+ * Copyright (c) 2013-present Cole Bemis
23
+ *
24
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
25
+ * of this software and associated documentation files (the "Software"), to deal
26
+ * in the Software without restriction, including without limitation the rights
27
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
28
+ * copies of the Software, and to permit persons to whom the Software is
29
+ * furnished to do so, subject to the following conditions:
30
+ *
31
+ * The above copyright notice and this permission notice shall be included in all
32
+ * copies or substantial portions of the Software.
33
+ *
34
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
37
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
38
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
39
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40
+ * SOFTWARE.
41
+ */
2
42
  var morphico = (function () {
3
43
  'use strict';
4
44
  // engine.ts
@@ -31,7 +71,7 @@ return { easings, animate };
31
71
  })();
32
72
 
33
73
  // geometry.ts
34
- const { line, arc, cubic, pointOnCircle, ellipse, roundedPolyline, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, defaultShapes, shapeForPair } = (() => {
74
+ const { line, arc, cubic, reversedStroke, pointOnCircle, ellipse, roundedPolyline, roundedPolygon, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, piecesOf, pieceLengthOf, splitPieces, defaultShapes, flattenOntoLine, shapeForPair, trimPiece, wipeRange, wipePiece, drawnPieces, withDrawnPieces } = (() => {
35
75
  const DEGREE = Math.PI / 180;
36
76
  const ICON_CENTER = 12;
37
77
  /** Largest arc one cubic segment may cover: up to 90° the cubic is visually exact. */
@@ -42,6 +82,15 @@ const line = (fromX, fromY, toX, toY) => ({ kind: 'line', from: [fromX, fromY],
42
82
  /** Angles in degrees. Because y points down, `endAngle > startAngle` draws clockwise on screen. */
43
83
  const arc = (centerX, centerY, radius, startAngle, endAngle) => ({ kind: 'arc', center: [centerX, centerY], radius, startAngle, endAngle });
44
84
  const cubic = (startX, startY, control1X, control1Y, control2X, control2Y, endX, endY) => ({ kind: 'cubic', segment: [[startX, startY], [control1X, control1Y], [control2X, control2Y], [endX, endY]] });
85
+ /** The same line or arc, drawn in the opposite direction. */
86
+ const reversedStroke = (stroke) => {
87
+ if (stroke.kind === 'line')
88
+ return { ...stroke, from: stroke.to, to: stroke.from };
89
+ if (stroke.kind === 'arc')
90
+ return { ...stroke, startAngle: stroke.endAngle, endAngle: stroke.startAngle };
91
+ const [start, control1, control2, end] = stroke.segment;
92
+ return { kind: 'cubic', segment: [end, control2, control1, start] };
93
+ };
45
94
  const pointOnCircle = (centerX, centerY, radius, angle) => [centerX + radius * Math.cos(angle * DEGREE), centerY + radius * Math.sin(angle * DEGREE)];
46
95
  /** Control handle length of a quarter ellipse as a fraction of its radius (4/3 · (√2 − 1)). */
47
96
  const QUARTER_HANDLE = (4 / 3) * (Math.SQRT2 - 1);
@@ -97,6 +146,38 @@ const roundedPolyline = (points, radii) => {
97
146
  }
98
147
  return strokes;
99
148
  };
149
+ /**
150
+ * A closed polygon with every corner rounded by `radius` (clamped per corner so neighbouring arcs never overlap).
151
+ * Always a line and an arc per corner, whatever the radius, so pair layouts (stroke indices) stay valid; at radius 0
152
+ * the arc shrinks to the corner point, so the corner is really sharp (and `stroke-linejoin` decides how it looks).
153
+ * Starts after the first corner with a line (`startsWithArc`: with the arc of the first corner), clockwise if the
154
+ * corners are listed clockwise on screen.
155
+ */
156
+ const roundedPolygon = (corners, radius, startsWithArc = false) => {
157
+ const cornerCount = corners.length;
158
+ const roundedCorners = corners.map((corner, index) => {
159
+ const previous = corners[(index + cornerCount - 1) % cornerCount], next = corners[(index + 1) % cornerCount];
160
+ const incoming = unitVector(previous, corner), outgoing = unitVector(corner, next);
161
+ const turn = Math.atan2(incoming[0] * outgoing[1] - incoming[1] * outgoing[0], incoming[0] * outgoing[0] + incoming[1] * outgoing[1]);
162
+ const halfTurnTangent = Math.tan(Math.abs(turn) / 2);
163
+ const shortestHalfEdge = Math.min(distanceBetween(previous, corner), distanceBetween(corner, next)) / 2;
164
+ const cornerRadius = Math.max(0, Math.min(radius, shortestHalfEdge / halfTurnTangent));
165
+ const tangentDistance = cornerRadius * halfTurnTangent;
166
+ const arcStart = [corner[0] - incoming[0] * tangentDistance, corner[1] - incoming[1] * tangentDistance];
167
+ const arcEnd = [corner[0] + outgoing[0] * tangentDistance, corner[1] + outgoing[1] * tangentDistance];
168
+ const inward = turn > 0 ? [-incoming[1], incoming[0]] : [incoming[1], -incoming[0]];
169
+ const center = [arcStart[0] + inward[0] * cornerRadius, arcStart[1] + inward[1] * cornerRadius];
170
+ const startAngle = Math.atan2(arcStart[1] - center[1], arcStart[0] - center[0]) / DEGREE;
171
+ return { arcStart, arcEnd, arc: arc(center[0], center[1], cornerRadius, startAngle, startAngle + turn / DEGREE) };
172
+ });
173
+ const strokes = roundedCorners.flatMap((roundedCorner, index) => {
174
+ const next = roundedCorners[(index + 1) % cornerCount];
175
+ return [line(...roundedCorner.arcEnd, ...next.arcStart), next.arc];
176
+ });
177
+ // the list above starts after corner 0 with a line and ends with corner 0's arc
178
+ return startsWithArc ? [strokes[strokes.length - 1], ...strokes.slice(0, -1)] : strokes;
179
+ };
180
+ const distanceBetween = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
100
181
  const unitVector = (from, to) => {
101
182
  const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
102
183
  return [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
@@ -119,10 +200,34 @@ const reorderSegments = (path, { startSegment = 0, reversed = false } = {}) => {
119
200
  };
120
201
  const interpolate = (from, to, progress) => from.map((value, index) => value + (to[index] - value) * progress);
121
202
  const formatNumber = (value) => String(+value.toFixed(3));
122
- /** SVG `d` attribute: `M x y` followed by one `C` per segment. */
123
- const toPathData = (path) => `M${formatNumber(path[0])} ${formatNumber(path[1])}` + toSegments(path)
124
- .map(([, control1, control2, end]) => `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`)
125
- .join('');
203
+ /** A piece whose end lands on its start is closed (`Z`): its first corner then gets the line join, not two line ends. */
204
+ const isClosedPiece = (piece) => Math.hypot(piece[piece.length - 2] - piece[0], piece[piece.length - 1] - piece[1]) < 1e-6;
205
+ const hasLength = ([start, control1, control2, end]) => [control1, control2, end].some((point) => Math.hypot(point[0] - start[0], point[1] - start[1]) > 1e-6);
206
+ /**
207
+ * The segments of a piece as written to SVG. A closed piece starts at its first segment with length: a zero-length
208
+ * first segment (a corner of radius 0) has no direction, so browsers would draw the closing corner wrongly.
209
+ * Only the written order changes, not the picture.
210
+ */
211
+ const writtenSegments = (piece) => {
212
+ const segments = toSegments(piece);
213
+ if (!isClosedPiece(piece))
214
+ return segments;
215
+ const firstWithLength = Math.max(0, segments.findIndex(hasLength));
216
+ return [...segments.slice(firstWithLength), ...segments.slice(0, firstWithLength)];
217
+ };
218
+ /** A piece shrunk to a single point: not drawn at all (round line ends would show it as a dot). */
219
+ const isPoint = (piece) => piece.every((value, index) => Math.abs(value - piece[index % 2]) < 1e-6);
220
+ /**
221
+ * SVG `d` attribute: per piece `M x y` followed by one `C` per segment (`pieceLength`: see pieceLengthOf).
222
+ * Pieces shrunk to a single point are left out.
223
+ */
224
+ const toPathData = (path, pieceLength = path.length) => splitPieces(path, pieceLength).filter((piece) => !isPoint(piece)).map((piece) => {
225
+ const segments = writtenSegments(piece);
226
+ const [startX, startY] = segments[0][0];
227
+ return `M${formatNumber(startX)} ${formatNumber(startY)}` + segments
228
+ .map(([, control1, control2, end]) => `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`)
229
+ .join('') + (isClosedPiece(piece) ? 'Z' : '');
230
+ }).join('');
126
231
  /** Shrinks (factor < 1) or grows the whole shape around the icon center (12, 12). */
127
232
  const scaleAroundCenter = (path, factor) => path.map((value) => ICON_CENTER + (value - ICON_CENTER) * factor);
128
233
  /** Rotates the whole shape around the icon center (12, 12). */
@@ -232,20 +337,363 @@ const defaultBreakpoints = (strokes, segmentCount) => {
232
337
  return breakpoints;
233
338
  };
234
339
  // ---- icon sets ----
340
+ // A shape of a set with several pieces is the path numbers of every piece one after another.
341
+ /** The set itself is the first piece. */
342
+ const piecesOf = (iconSet) => [iconSet, ...(iconSet.morePieces ?? [])];
343
+ /** Path numbers of one piece: start point + 6 per segment. */
344
+ const pieceLengthOf = (iconSet) => 2 + 6 * iconSet.segmentCount;
345
+ /** Splits a shape into the path numbers of its pieces. */
346
+ const splitPieces = (path, pieceLength) => Array.from({ length: path.length / pieceLength }, (_, pieceIndex) => path.slice(pieceIndex * pieceLength, (pieceIndex + 1) * pieceLength));
347
+ const strokesOf = (pieceSet, state, pieceIndex) => {
348
+ const strokes = pieceSet.strokes[state];
349
+ if (!strokes)
350
+ throw new Error(`state "${state}" has no strokes in piece ${pieceIndex}`);
351
+ return strokes;
352
+ };
235
353
  /** Every state of the set laid out with its default breakpoints. */
236
- const defaultShapes = (iconSet) => Object.fromEntries(Object.entries(iconSet.strokes)
237
- .map(([state, strokes]) => [state, layoutStrokes(strokes, defaultBreakpoints(strokes, iconSet.segmentCount))]));
238
- /** The shape of `state` laid out for morphing to or from `otherState`. */
354
+ const defaultShapes = (iconSet) => Object.fromEntries(Object.keys(iconSet.strokes).map((state) => [state, piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => {
355
+ const strokes = strokesOf(pieceSet, state, pieceIndex);
356
+ return layoutStrokes(strokes, defaultBreakpoints(strokes, iconSet.segmentCount));
357
+ })]));
358
+ /**
359
+ * `shape` pressed flat onto the straight line `from` → `to`: every point moves onto the line, keeping its position
360
+ * along the line's direction (the shape's extent along it is stretched to the line's length).
361
+ */
362
+ const flattenOntoLine = (shape, from, to) => {
363
+ const direction = [to[0] - from[0], to[1] - from[1]];
364
+ const positions = Array.from({ length: shape.length / 2 }, (_, pointIndex) => shape[2 * pointIndex] * direction[0] + shape[2 * pointIndex + 1] * direction[1]);
365
+ const lowest = Math.min(...positions), highest = Math.max(...positions);
366
+ return positions.flatMap((position) => {
367
+ const along = highest - lowest > EPSILON ? (position - lowest) / (highest - lowest) : 0.5;
368
+ return [from[0] + direction[0] * along, from[1] + direction[1] * along];
369
+ });
370
+ };
371
+ /** The shape of `state` laid out for morphing to or from `otherState` (each piece by its own pair layout). */
239
372
  const shapeForPair = (iconSet, shapes, state, otherState) => {
240
- const pairLayout = iconSet.pairLayouts[state]?.[otherState];
373
+ const pieceLength = pieceLengthOf(iconSet);
374
+ const pieceForPair = (pieceSet, pieceIndex, pieceState, pieceOther, mayFlatten) => {
375
+ const defaultPiece = splitPieces(shapes[pieceState], pieceLength)[pieceIndex];
376
+ const pairLayout = pieceSet.pairLayouts[pieceState]?.[pieceOther];
377
+ if (!pairLayout)
378
+ return defaultPiece;
379
+ if ((pairLayout.draw || pairLayout.wipe) && mayFlatten) {
380
+ // not there: the other side's piece shrunk to its start point (not drawn); the morph draws it (see drawnPieces)
381
+ const otherPiece = pieceForPair(pieceSet, pieceIndex, pieceOther, pieceState, false);
382
+ return Array.from({ length: pieceLength }, (_, index) => otherPiece[index % 2]);
383
+ }
384
+ if (pairLayout.flatten && mayFlatten) {
385
+ const [line] = strokesOf(pieceSet, pieceState, pieceIndex);
386
+ if (line.kind !== 'line')
387
+ throw new Error(`flatten: state "${pieceState}" must be one straight line`);
388
+ // the other side as it is laid out for this pair (if it flattens too, its plain layout)
389
+ return flattenOntoLine(pieceForPair(pieceSet, pieceIndex, pieceOther, pieceState, false), line.from, line.to);
390
+ }
391
+ const strokes = pairLayout.tracing ? pieceSet.alternativeStrokes[pairLayout.tracing] : strokesOf(pieceSet, pieceState, pieceIndex);
392
+ const laidOut = pairLayout.breakpoints ? layoutStrokes(strokes, pairLayout.breakpoints) : defaultPiece;
393
+ const reordered = reorderSegments(laidOut, pairLayout);
394
+ return pairLayout.rotation ? rotateAroundCenter(reordered, pairLayout.rotation) : reordered;
395
+ };
396
+ return piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => pieceForPair(pieceSet, pieceIndex, state, otherState, true));
397
+ };
398
+ // ---- drawing a piece along its path (PairLayout.draw) ----
399
+ /** Rough length of a cubic segment: the average of its chord and its control polygon. */
400
+ const segmentLength = ([start, control1, control2, end]) => {
401
+ const distance = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
402
+ return (distance(start, end) + distance(start, control1) + distance(control1, control2) + distance(control2, end)) / 2;
403
+ };
404
+ /**
405
+ * The part of a piece between two shares of its length (0…1). The segments outside shrink to the part's ends,
406
+ * so the number count stays the same.
407
+ */
408
+ const pieceBetween = (piece, fromFraction, toFraction) => {
409
+ const segments = toSegments(piece);
410
+ const lengths = segments.map(segmentLength);
411
+ const totalLength = lengths.reduce((sum, length) => sum + length, 0);
412
+ const from = fromFraction * totalLength, to = toFraction * totalLength;
413
+ let travelled = 0;
414
+ const parts = segments.map((segment, index) => {
415
+ const segmentStart = travelled;
416
+ travelled += lengths[index];
417
+ if (!lengths[index] || travelled <= from || segmentStart >= to)
418
+ return null;
419
+ const startProgress = Math.max(0, (from - segmentStart) / lengths[index]);
420
+ const endProgress = Math.min(1, (to - segmentStart) / lengths[index]);
421
+ const [head] = splitCubic(segment, endProgress);
422
+ return startProgress > 0 ? splitCubic(head, startProgress / endProgress)[1] : head;
423
+ });
424
+ // before the part: its start point; after (or between): the end of the part drawn so far
425
+ let pen = parts.find((part) => part)?.[0] ?? segments[0][0];
426
+ return fromSegments(parts.map((part) => {
427
+ if (!part)
428
+ return [pen, pen, pen, pen];
429
+ pen = part[3];
430
+ return part;
431
+ }));
432
+ };
433
+ /** The first `fraction` (0…1) of a piece along its length, as if a pen had drawn that far. */
434
+ const trimPiece = (piece, fraction) => pieceBetween(piece, 0, Math.min(Math.max(fraction, 0), 1));
435
+ /** The x range a wipe's edge moves through (the icon box's drawing area, line width included). */
436
+ const WIPE_FROM_X = 2;
437
+ const WIPE_TO_X = 22;
438
+ const WIPE_SAMPLES_PER_SEGMENT = 40;
439
+ /** Points along a piece as [share of its length, x]. */
440
+ const xAlong = (piece) => {
441
+ const segments = toSegments(piece);
442
+ const lengths = segments.map(segmentLength);
443
+ const totalLength = lengths.reduce((sum, length) => sum + length, 0) || 1;
444
+ const samples = [];
445
+ let travelled = 0;
446
+ segments.forEach((segment, index) => {
447
+ for (let step = 0; step <= WIPE_SAMPLES_PER_SEGMENT; step++) {
448
+ const progress = step / WIPE_SAMPLES_PER_SEGMENT;
449
+ const [x] = progress < 1 ? splitCubic(segment, progress)[1][0] : segment[3];
450
+ samples.push([(travelled + progress * lengths[index]) / totalLength, x]);
451
+ }
452
+ travelled += lengths[index];
453
+ });
454
+ return samples;
455
+ };
456
+ /**
457
+ * The x range a morph's wipe edge moves through: the box (x 2…22), or — if a piece slides in — from the box's side
458
+ * to that piece's leading side at rest, so the edge rides at its front and it starts just outside the box.
459
+ */
460
+ const wipeRange = (slidingPiece, direction) => {
461
+ if (!slidingPiece)
462
+ return [WIPE_FROM_X, WIPE_TO_X];
463
+ const xs = xAlong(slidingPiece).map(([, x]) => x);
464
+ return direction === 'right' ? [WIPE_FROM_X, Math.max(...xs)] : [Math.min(...xs), WIPE_TO_X];
465
+ };
466
+ /**
467
+ * A piece behind a vertical edge (PairLayout.wipe): at `fraction` 0…1 of the wipe, the edge has moved that far
468
+ * through `edgeRange` in `direction`, and only the part it has passed shows. That part must be one stretch of the
469
+ * path. `slides` (PairLayout.slide): the piece moves with the edge from outside the box; only its part inside shows.
470
+ */
471
+ const wipePiece = (piece, fraction, direction, edgeRange = [WIPE_FROM_X, WIPE_TO_X], slides = false) => {
472
+ if (fraction >= 1)
473
+ return piece.slice();
474
+ const [fromX, toX] = edgeRange;
475
+ if (slides) {
476
+ // in the piece's own frame the box's side moves the other way: the leading part shows first
477
+ const shift = (1 - Math.max(fraction, 0)) * (toX - fromX) * (direction === 'right' ? -1 : 1);
478
+ return wipePiece(piece, fraction, direction === 'right' ? 'left' : 'right', edgeRange)
479
+ .map((value, index) => (index % 2 ? value : value + shift));
480
+ }
481
+ const travel = Math.max(fraction, 0) * (toX - fromX);
482
+ const edge = direction === 'right' ? fromX + travel : toX - travel;
483
+ // samples along the length: [share of the length, > 0 where the edge has passed]
484
+ const samples = xAlong(piece).map(([share, x]) => [share, direction === 'right' ? edge - x : x - edge]);
485
+ const first = samples.findIndex(([, passed]) => passed >= 0);
486
+ if (first < 0)
487
+ return pieceBetween(piece, 0, 0);
488
+ let last = samples.length - 1;
489
+ while (samples[last][1] < 0)
490
+ last--;
491
+ // where the edge crosses between two samples
492
+ const crossing = ([shareA, passedA], [shareB, passedB]) => shareA + (shareB - shareA) * (passedA / (passedA - passedB));
493
+ const from = first > 0 ? crossing(samples[first - 1], samples[first]) : 0;
494
+ const to = last < samples.length - 1 ? crossing(samples[last], samples[last + 1]) : 1;
495
+ return pieceBetween(piece, from, to);
496
+ };
497
+ /** The pieces drawn (or erased) along their path when morphing `state` → `otherState` (PairLayout.draw). */
498
+ const drawnPieces = (iconSet, state, otherState) => piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => {
499
+ const windowOf = (draw) => (Array.isArray(draw) ? draw : typeof draw === 'number' ? [1 - draw, 1] : [0, 1]);
500
+ // the layout of the state where the piece is absent
501
+ const absentIn = (absentState, presentState) => {
502
+ const pairLayout = pieceSet.pairLayouts[absentState]?.[presentState];
503
+ return pairLayout?.draw || pairLayout?.wipe ? pairLayout : undefined;
504
+ };
505
+ const drawIn = absentIn(state, otherState);
506
+ const pairLayout = drawIn ?? absentIn(otherState, state);
241
507
  if (!pairLayout)
242
- return shapes[state];
243
- const strokes = pairLayout.tracing ? iconSet.alternativeStrokes[pairLayout.tracing] : iconSet.strokes[state];
244
- const laidOut = pairLayout.breakpoints ? layoutStrokes(strokes, pairLayout.breakpoints) : shapes[state];
245
- const reordered = reorderSegments(laidOut, pairLayout);
246
- return pairLayout.rotation ? rotateAroundCenter(reordered, pairLayout.rotation) : reordered;
508
+ return [];
509
+ const [start, end] = windowOf(pairLayout.draw || true);
510
+ return [{ pieceIndex, drawsIn: Boolean(drawIn), start, end, wipe: pairLayout.wipe, slide: pairLayout.slide }];
511
+ });
512
+ /**
513
+ * A frame of a morph `from` → `to` at `progress` with the drawn pieces replaced: drawn in from the start along
514
+ * `to`'s piece, or erased back along `from`'s piece.
515
+ */
516
+ const withDrawnPieces = (frame, from, to, progress, pieceLength, drawn) => {
517
+ if (!drawn.length)
518
+ return frame;
519
+ const presentPiece = ({ pieceIndex, drawsIn }) => (drawsIn ? to : from).slice(pieceIndex * pieceLength, (pieceIndex + 1) * pieceLength);
520
+ // one edge for every wipe of the morph, riding at the front of the (first) sliding piece
521
+ const slider = drawn.find((piece) => piece.slide && piece.wipe);
522
+ const edgeRange = wipeRange(slider ? presentPiece(slider) : null, slider?.wipe ?? 'right');
523
+ const result = frame.slice();
524
+ for (const drawnPiece of drawn) {
525
+ const { pieceIndex, drawsIn, start, end, wipe, slide } = drawnPiece;
526
+ const present = presentPiece(drawnPiece);
527
+ // erasing is drawing in played backwards
528
+ const fraction = ((drawsIn ? progress : 1 - progress) - start) / (end - start);
529
+ result.splice(pieceIndex * pieceLength, pieceLength, ...(wipe ? wipePiece(present, fraction, wipe, edgeRange, slide) : trimPiece(present, fraction)));
530
+ }
531
+ return result;
247
532
  };
248
- return { line, arc, cubic, pointOnCircle, ellipse, roundedPolyline, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, defaultShapes, shapeForPair };
533
+ return { line, arc, cubic, reversedStroke, pointOnCircle, ellipse, roundedPolyline, roundedPolygon, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, piecesOf, pieceLengthOf, splitPieces, defaultShapes, flattenOntoLine, shapeForPair, trimPiece, wipeRange, wipePiece, drawnPieces, withDrawnPieces };
534
+ })();
535
+
536
+ // sets/lucide.ts
537
+ const { LUCIDE_SCALE, LUCIDE_STROKE_WIDTH, fromLucide, lucideLength, lucideLine, lucideArc, lucideCubic, lucidePoint, LUCIDE_SLASH } = (() => {
538
+ // Shared by every Lucide-based icon set: Lucide draws in y 3…21 (18 units) with a 2-unit line; our icons get the
539
+ // same room as the digits (16 units, y 4…20; user's choice 2026-10-26), so every icon is scaled around the centre
540
+ // 12,12 — line width included, so each icon looks exactly like Lucide's, only with more margin in its box.
541
+ const LUCIDE_SCALE = 16 / 18;
542
+ /** Lucide's 2-unit line, scaled with the icon. */
543
+ const LUCIDE_STROKE_WIDTH = 2 * LUCIDE_SCALE;
544
+ /** A point (or corner) of a Lucide icon moved into our icon box. */
545
+ const fromLucide = ([x, y]) => [12 + (x - 12) * LUCIDE_SCALE, 12 + (y - 12) * LUCIDE_SCALE];
546
+ /** A length of a Lucide icon (e.g. a corner radius) in our icon box. */
547
+ const lucideLength = (length) => length * LUCIDE_SCALE;
548
+ // Strokes written in Lucide coordinates (for the icon sets added from 2026-10-28 on).
549
+ /** A straight line between two Lucide points. */
550
+ const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
551
+ /** A circular arc in Lucide coordinates (angles in degrees, `endAngle > startAngle` = clockwise on screen). */
552
+ const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
553
+ /** A cubic Bézier curve in Lucide coordinates. */
554
+ const lucideCubic = (...coordinates) => {
555
+ const [startX, startY, control1X, control1Y, control2X, control2Y, endX, endY] = coordinates;
556
+ return cubic(...fromLucide([startX, startY]), ...fromLucide([control1X, control1Y]), ...fromLucide([control2X, control2Y]), ...fromLucide([endX, endY]));
557
+ };
558
+ /** A missing piece at rest: a single point, never drawn (see toPathData). */
559
+ const lucidePoint = (x, y) => [lucideLine(x, y, x, y)];
560
+ /** The diagonal slash of Lucide's "-off" icons, from the top left. */
561
+ const LUCIDE_SLASH = [lucideLine(2, 2, 22, 22)];
562
+ return { LUCIDE_SCALE, LUCIDE_STROKE_WIDTH, fromLucide, lucideLength, lucideLine, lucideArc, lucideCubic, lucidePoint, LUCIDE_SLASH };
563
+ })();
564
+
565
+ // sets/check-mark.ts
566
+ const { checkMarkPiece } = (() => {
567
+ // The check mark shared by "checkbox" and "radio" (Lucide 1.61.0 "square-check" / "circle-check" and their "-big"
568
+ // versions draw the same checks; ISC, see LICENSE-lucide). Coordinates are Lucide's, scaled by fromLucide.
569
+ // Drawn left to right, short stroke then long stroke. unchecked has no check: the check is drawn in like a pen
570
+ // writing it — down the short stroke, up the long one (the user's sketch; PairLayout.draw) — and erased back.
571
+ /** A straight line between two Lucide points, moved into our icon box. */
572
+ const lucideLine = (from, to) => line(...fromLucide(from), ...fromLucide(to));
573
+ const CHECK = [lucideLine([8, 12], [10.5, 14.5]), lucideLine([10.5, 14.5], [16, 9])];
574
+ const BIG_CHECK = [lucideLine([9, 11], [12, 14]), lucideLine([12, 14], [22, 4])];
575
+ // every check ↔ check: short stroke 4 segments, long stroke 8 (of 12), so the corners meet
576
+ const CHECK_FOR_CHECK = { breakpoints: [0, 0.25, 0.5, 0.75, 1, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.875, 2] };
577
+ const CHECK_STATES = ['unchecked', 'checked', 'checked-big'];
578
+ /**
579
+ * The check piece of a set with the states unchecked, checked, checked-big (12 segments).
580
+ * `hiddenAt`: a Lucide point on the outline at the check's height, where unchecked's check rests at rest
581
+ * (tiny, under the outline's line, so it doesn't show — morphs draw the check instead).
582
+ */
583
+ const checkMarkPiece = (hiddenAt) => {
584
+ const [x, y] = hiddenAt;
585
+ return {
586
+ strokes: {
587
+ unchecked: [lucideLine([x, y - 0.1], [x, y]), lucideLine([x, y], [x, y + 0.1])],
588
+ checked: CHECK,
589
+ 'checked-big': BIG_CHECK,
590
+ },
591
+ alternativeStrokes: {},
592
+ pairLayouts: {
593
+ ...Object.fromEntries(CHECK_STATES.map((state) => [state, Object.fromEntries(CHECK_STATES.filter((other) => other !== state).map((other) => [other, CHECK_FOR_CHECK]))])),
594
+ unchecked: { checked: { draw: true }, 'checked-big': { draw: true } },
595
+ },
596
+ };
597
+ };
598
+ return { checkMarkPiece };
599
+ })();
600
+
601
+ // sets/checkbox.ts
602
+ const { checkboxIcons } = (() => {
603
+ // Icon set "checkbox": unchecked, checked, checked-big — Lucide 1.61.0 icons "square", "square-check",
604
+ // "square-check-big" (ISC, see LICENSE-lucide). Coordinates below are Lucide's; fromLucide / lucideLength scale them
605
+ // into our icon box (more margin, like the digits — see lucide.ts). Built from rounded corners, so `roundness` works.
606
+ // Two pieces:
607
+ // piece 1, the box: the same square in unchecked and checked (it doesn't move); checked-big opens its top right
608
+ // corner where the big check leaves the box — both ends of the gap pull back to the gap's edges.
609
+ // piece 2, the check (check-mark.ts, shared with "radio"): drawn in like a pen from unchecked (the user's sketch).
610
+ // (Rejected: a box copy folding into the check — tangled; the check sliding in from the left edge — user: bad.)
611
+ const SQUARE_CORNERS = [[3, 3], [21, 3], [21, 21], [3, 21]].map(fromLucide);
612
+ const LUCIDE_RADIUS = lucideLength(2);
613
+ /** square-check-big's box: the square without its top right corner (gap from 17.344,3 to 21,10.656). */
614
+ const openBox = (square) => {
615
+ const [top, , right, bottomRight, bottom, bottomLeft, left, topLeft] = square;
616
+ return [
617
+ line(...fromLucide([21, 10.656]), ...right.to),
618
+ bottomRight, bottom, bottomLeft, left, topLeft,
619
+ line(...top.from, ...fromLucide([17.344, 3])),
620
+ ];
621
+ };
622
+ // box, checked/unchecked ↔ checked-big: the square laid out so its 12 segments line up with the open box's —
623
+ // top (2: the part that stays, the part in the gap), top right corner (2, in the gap), right side (2: in the gap,
624
+ // the part that stays), bottom right, bottom, bottom left, left (2), top left — started at the corner's second half.
625
+ // The open box gets two zero-length segments at each end of the gap, where the vanishing parts pull back to.
626
+ const SQUARE_FOR_OPEN_BOX = { breakpoints: [0, 0.8817, 1, 1.5, 2, 2.404, 3, 4, 5, 6, 6.5, 7, 8], startSegment: 3 };
627
+ const OPEN_BOX_FOR_SQUARE = { breakpoints: [0, 0, 0, 1, 2, 3, 4, 4.5, 5, 6, 7, 7, 7] };
628
+ /** The check rests (hidden) on the box's left edge. */
629
+ const CHECK_PIECE = checkMarkPiece([3, 12]);
630
+ const buildCheckboxIcons = (radius) => {
631
+ const square = roundedPolygon(SQUARE_CORNERS, radius);
632
+ return {
633
+ segmentCount: 12,
634
+ strokeWidth: LUCIDE_STROKE_WIDTH,
635
+ lineCap: 'round',
636
+ lineJoin: 'round',
637
+ strokes: { unchecked: square, checked: square, 'checked-big': openBox(square) },
638
+ alternativeStrokes: {},
639
+ pairLayouts: {
640
+ unchecked: { 'checked-big': SQUARE_FOR_OPEN_BOX },
641
+ checked: { 'checked-big': SQUARE_FOR_OPEN_BOX },
642
+ 'checked-big': { unchecked: OPEN_BOX_FOR_SQUARE, checked: OPEN_BOX_FOR_SQUARE },
643
+ },
644
+ morePieces: [CHECK_PIECE],
645
+ spins: {},
646
+ withRoundness: (roundness) => buildCheckboxIcons(LUCIDE_RADIUS * roundness),
647
+ };
648
+ };
649
+ const checkboxIcons = buildCheckboxIcons(LUCIDE_RADIUS);
650
+ return { checkboxIcons };
651
+ })();
652
+
653
+ // sets/characters.ts
654
+ const { withTextCharacters } = (() => {
655
+ // The characters every digit set shares besides 0–9: `-` `/` `.` `,` `:` (space = the empty state, see morph.ts).
656
+ // Each is drawn as one straight line, placed by the set's text layout and stroke width, and morphs to and from
657
+ // every digit by flattening (the user's choice 2026-10-27): the digit is pressed flat onto the line.
658
+ // `:` has two dots, so digit sets get a second piece: for every other state it repeats the first piece exactly
659
+ // (same strokes and layouts, drawn on top of itself, so it doesn't show).
660
+ const DIGITS = [...'0123456789'];
661
+ /** Half the length of a dot's line, relative to the stroke width: round ends make it a dot 1.1 × the stroke wide. */
662
+ const DOT_HALF_LENGTH = 0.05;
663
+ /** Adds `-` `/` `.` `,` `:` to a digit set (which needs a text layout) as morphing states. */
664
+ const withTextCharacters = (iconSet) => {
665
+ const { baselineY, colonDotsY } = iconSet.textLayout;
666
+ const dotHalfLength = iconSet.strokeWidth * DOT_HALF_LENGTH;
667
+ const dotY = baselineY; // dot centre on the line the digits stand on
668
+ const dot = (y) => line(12 - dotHalfLength, y, 12 + dotHalfLength, y);
669
+ const characters = {
670
+ '-': [line(9.5, 12, 14.5, 12)],
671
+ '/': [line(9.5, baselineY, 14.5, 24 - baselineY)],
672
+ '.': [dot(dotY)],
673
+ ',': [line(12.3, dotY, 11, baselineY + 2.2)], // from the dot down to the left
674
+ ':': [dot(colonDotsY[0])],
675
+ };
676
+ const flattenPairs = (layouts) => {
677
+ const merged = { ...layouts };
678
+ for (const character of Object.keys(characters)) {
679
+ merged[character] = { ...merged[character], ...Object.fromEntries(DIGITS.map((digit) => [digit, { flatten: true }])) };
680
+ }
681
+ return merged;
682
+ };
683
+ const pairLayouts = flattenPairs(iconSet.pairLayouts);
684
+ const strokes = { ...iconSet.strokes, ...characters };
685
+ return {
686
+ ...iconSet,
687
+ strokes,
688
+ pairLayouts,
689
+ morePieces: [{
690
+ strokes: { ...strokes, ':': [dot(colonDotsY[1])] },
691
+ alternativeStrokes: iconSet.alternativeStrokes,
692
+ pairLayouts,
693
+ }],
694
+ };
695
+ };
696
+ return { withTextCharacters };
249
697
  })();
250
698
 
251
699
  // sets/condensed.ts
@@ -278,7 +726,8 @@ const HOOK_END_Y = 79;
278
726
  // the 6: hook at the top right, over the top, down the left side, round the bottom, up to the middle bar, back left
279
727
  const SIX_POINTS = [[47, 94 - HOOK_END_Y], [47, 3], [3, 3], [3, 91], [47, 91], [47, 47], [3, 47]];
280
728
  const SIX_RADII = [0, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0];
281
- const condensedDigits = {
729
+ // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
730
+ const condensedDigits = withTextCharacters({
282
731
  segmentCount: 20,
283
732
  strokeWidth: 6 / PIXELS_PER_UNIT,
284
733
  lineCap: 'round',
@@ -303,7 +752,6 @@ const condensedDigits = {
303
752
  8: drawn(50, [[3 + MIDDLE_RADIUS, 47], [3, 47], [3, 3], [47, 3], [47, 47], [3, 47], [3, 91], [47, 91], [47, 47], [3 + MIDDLE_RADIUS, 47]], [0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0]),
304
753
  // the 6 turned 180°, drawn the other way round (box first, then the small upturn)
305
754
  9: roundedPolyline(SIX_POINTS.map(fromImage(50)).map(turnedAround).reverse(), [...SIX_RADII].reverse().map((radius) => radius / PIXELS_PER_UNIT)),
306
- '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
307
755
  },
308
756
  alternativeStrokes: {
309
757
  // the 8 traced like the 0 (from the top, counter-clockwise): left side of the top box down to the middle of
@@ -383,10 +831,84 @@ const condensedDigits = {
383
831
  },
384
832
  spins: { 6: { 9: 180 }, 9: { 6: -180 } },
385
833
  textLayout: { cellWidth: 8, spacing: 3.5, baselineY: 20, colonDotsY: [8, 16] },
386
- };
834
+ });
387
835
  return { condensedDigits };
388
836
  })();
389
837
 
838
+ // sets/folder.ts
839
+ const { folderIcons } = (() => {
840
+ // Icon set "folder": closed, open — Lucide 1.61.0 icons "folder", "folder-open" (ISC, see LICENSE-lucide), imported
841
+ // with `node tools/import-lucide.ts folder folder-open`. Coordinates below are Lucide's; lucideLine / lucideArc scale
842
+ // them into our icon box (more margin, like the digits — see lucide.ts).
843
+ // One piece each. The open folder is one line: the front flap (from its left tip over its top edge, down its right
844
+ // edge), the bottom, the left side, the tab, the back panel's top edge and a short bit of its right side.
845
+ // closed ↔ open: the closed folder is traced the open folder's way ("folder-like-open", same picture) — starting at
846
+ // its top edge, which it runs along twice: the first pass drops and tilts into the flap's top edge, the second stays
847
+ // as the back panel's top edge.
848
+ const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
849
+ const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
850
+ /** Lucide "folder": counter-clockwise from the bottom right corner. */
851
+ const FOLDER = [
852
+ lucideArc(20, 18, 2, 90, 0), // bottom right corner
853
+ lucideLine(22, 18, 22, 8), // right side, up
854
+ lucideArc(20, 8, 2, 0, -90), // top right corner
855
+ lucideLine(20, 6, 12.1, 6), // top edge, to the left
856
+ lucideArc(12.0804, 4.0001, 2, 89.4383, 146.6363),
857
+ lucideLine(10.41, 5.1, 9.6, 3.9), // tab slope
858
+ lucideArc(7.9297, 5, 2, -33.367, -89.9906),
859
+ lucideLine(7.93, 3, 4, 3), // tab top
860
+ lucideArc(4, 5, 2, -90, -180), // top left corner
861
+ lucideLine(2, 5, 2, 18), // left side, down
862
+ lucideArc(4, 18, 2, 180, 90), // bottom left corner
863
+ lucideLine(4, 20, 20, 20), // bottom
864
+ ];
865
+ /** Lucide "folder-open": from the flap's left tip. */
866
+ const FOLDER_OPEN = [
867
+ lucideLine(6, 14, 7.5, 11.1), // flap's left tip
868
+ lucideArc(9.2863, 11.9995, 2, -153.2735, -91.3273),
869
+ lucideLine(9.24, 10, 20, 10), // flap's top edge
870
+ lucideArc(20.0035, 12, 2, -90.1005, 14.4776),
871
+ lucideLine(21.94, 12.5, 20.4, 18.5), // flap's right edge
872
+ lucideArc(18.4635, 18, 2, 14.4762, 90.3867),
873
+ lucideLine(18.45, 20, 4, 20), // bottom
874
+ lucideArc(4, 18, 2, 90, 180),
875
+ lucideLine(2, 18, 2, 5), // left side, up
876
+ lucideArc(4, 5, 2, 180, 270),
877
+ lucideLine(4, 3, 7.9, 3), // tab top
878
+ lucideArc(7.9196, 4.9999, 2, -90.5617, -33.3637),
879
+ lucideLine(9.59, 3.9, 10.4, 5.1), // tab slope
880
+ lucideArc(12.0703, 4, 2, 146.633, 90.0094),
881
+ lucideLine(12.07, 6, 18, 6), // back panel's top edge
882
+ lucideArc(18, 8, 2, -90, 0),
883
+ lucideLine(20, 8, 20, 10), // back panel's right side, down to the flap
884
+ ];
885
+ const [bottomRight, rightSide, topRight, topEdge, tabArc1, tabSlope, tabArc2, tabTop, topLeft, leftSide, bottomLeft, bottom] = FOLDER.map(reversedStroke); // clockwise, like the open folder
886
+ /** The closed folder traced like the open one: top edge (→ flap), round the outline, top edge again (→ back panel). */
887
+ const FOLDER_LIKE_OPEN = [
888
+ topEdge, topRight, rightSide, bottomRight, bottom, bottomLeft, leftSide, topLeft,
889
+ tabTop, tabArc2, tabSlope, tabArc1, topEdge, topRight, lucideLine(22, 8, 22, 10),
890
+ ];
891
+ const folderIcons = {
892
+ segmentCount: 20,
893
+ strokeWidth: LUCIDE_STROKE_WIDTH,
894
+ lineCap: 'round',
895
+ lineJoin: 'round',
896
+ strokes: { closed: FOLDER, open: FOLDER_OPEN },
897
+ alternativeStrokes: { 'folder-like-open': FOLDER_LIKE_OPEN },
898
+ // fitted with tools/check.ts (cost 7838 → 411)
899
+ pairLayouts: {
900
+ closed: {
901
+ open: {
902
+ tracing: 'folder-like-open',
903
+ breakpoints: [0, 0, 0, 1, 2, 2.5208, 3, 4, 4.5625, 5, 6, 6.5, 7, 8, 9, 10, 11, 12, 13, 14, 15],
904
+ },
905
+ },
906
+ },
907
+ spins: {},
908
+ };
909
+ return { folderIcons };
910
+ })();
911
+
390
912
  // sets/handwritten.ts
391
913
  const { handwrittenDigits } = (() => {
392
914
  // Digit set "handwritten": hand-drawn marker digits, traced from the reference images in icons/handwritten/
@@ -399,7 +921,8 @@ const { handwrittenDigits } = (() => {
399
921
  // the centre line — the marker stroke varies a little, so the pixel match is ~8–17 %).
400
922
  // - every digit is one stroke of 3–7 free cubic curves; round ends and corners
401
923
  // - the 4 is drawn out and back over its crossbar end and right stem (one continuous stroke)
402
- const handwrittenDigits = {
924
+ // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
925
+ const handwrittenDigits = withTextCharacters({
403
926
  segmentCount: 16,
404
927
  strokeWidth: 1.04,
405
928
  lineCap: 'round',
@@ -471,7 +994,6 @@ const handwrittenDigits = {
471
994
  cubic(14.01, 8.61, 14.16, 8.23, 14.66, 6.7, 15.3, 7.44),
472
995
  cubic(15.3, 7.44, 16.93, 9.31, 14.47, 18.24, 11.59, 19.86),
473
996
  ],
474
- '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
475
997
  },
476
998
  alternativeStrokes: {},
477
999
  // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd / not sure
@@ -521,10 +1043,172 @@ const handwrittenDigits = {
521
1043
  },
522
1044
  spins: {},
523
1045
  textLayout: { cellWidth: 9, spacing: 4, baselineY: 20.2, colonDotsY: [8, 16] },
524
- };
1046
+ });
525
1047
  return { handwrittenDigits };
526
1048
  })();
527
1049
 
1050
+ // sets/media.ts
1051
+ const { mediaIcons } = (() => {
1052
+ // Icon set "media": play, pause, stop — Lucide 1.61.0 icons (ISC, see LICENSE-lucide).
1053
+ // First imported with `node tools/import-lucide.ts play pause square`, then rewritten as rounded polygons so the
1054
+ // corners can be made sharper (`withRoundness`); with Lucide's radii (play 2, pause 1, stop 2) the shapes match
1055
+ // the import within 0.005 units (Lucide's own rounding).
1056
+ // Two pieces: pause has two bars; play and stop draw their single shape in both pieces (the second copy lies
1057
+ // exactly on the first, so it doesn't show — during a morph to pause the two copies separate into the bars).
1058
+ // Every shape runs clockwise from its top left, like Lucide's. Coordinates below are Lucide's; fromLucide /
1059
+ // lucideLength scale them into our icon box (more margin, like the digits — see lucide.ts).
1060
+ /** Lucide's play: a triangle (left side x 5, tip at the right) with rounded corners, starting with the top left arc. */
1061
+ const PLAY_CORNERS = [[5, 1.5174], [22.9706, 11.9998], [5, 22.4827]].map(fromLucide);
1062
+ const RIGHT_BAR_CORNERS = [[14, 3], [19, 3], [19, 21], [14, 21]].map(fromLucide);
1063
+ const LEFT_BAR_CORNERS = [[5, 3], [10, 3], [10, 21], [5, 21]].map(fromLucide);
1064
+ const SQUARE_CORNERS = [[3, 3], [21, 3], [21, 21], [3, 21]].map(fromLucide);
1065
+ const LUCIDE_RADII = { play: lucideLength(2), pause: lucideLength(1), stop: lucideLength(2) };
1066
+ // play ↔ stop (both pieces, the user's sketch): corner to corner, edge to edge, and the tip opens up and down
1067
+ // into the square's right side. Play (top left corner, top, tip, bottom, bottom left corner, left):
1068
+ // 2 + 1 + 4 + 1 + 2 + 2 segments. Stop is laid out from its top edge and then started at its top left corner,
1069
+ // so its segments line up: top left 2, top 1, top right 1, right side 2, bottom right 1, bottom 1, bottom left 2, left 2.
1070
+ const PLAY_FOR_STOP = { breakpoints: [0, 0.5, 1, 2, 2.25, 2.5, 2.75, 3, 4, 4.5, 5, 5.5, 6] };
1071
+ const STOP_FOR_PLAY = { breakpoints: [0, 1, 2, 2.5, 3, 4, 5, 5.5, 6, 6.5, 7, 7.5, 8], startSegment: 10 };
1072
+ const buildMediaIcons = (radii) => {
1073
+ const play = roundedPolygon(PLAY_CORNERS, radii.play, true);
1074
+ const stop = roundedPolygon(SQUARE_CORNERS, radii.stop);
1075
+ return {
1076
+ segmentCount: 12,
1077
+ strokeWidth: LUCIDE_STROKE_WIDTH,
1078
+ lineCap: 'round',
1079
+ lineJoin: 'round',
1080
+ strokes: { play, pause: roundedPolygon(RIGHT_BAR_CORNERS, radii.pause), stop },
1081
+ alternativeStrokes: {},
1082
+ // play ↔ stop: the user's sketch (above); play ↔ pause, pause ↔ stop: tools/check.ts
1083
+ pairLayouts: {
1084
+ play: { pause: { breakpoints: [0, 0.7292, 1, 1.6458, 2, 2.25, 3, 3.3125, 3.3125, 4, 4.2708, 5, 6] }, stop: PLAY_FOR_STOP },
1085
+ pause: { play: { startSegment: 10 }, stop: { startSegment: 11 } },
1086
+ stop: { pause: { breakpoints: [0, 0.8333, 1, 2, 2.2917, 2.7083, 3, 4, 4.1667, 5, 6, 7, 8] }, play: STOP_FOR_PLAY },
1087
+ },
1088
+ morePieces: [
1089
+ {
1090
+ strokes: { play, pause: roundedPolygon(LEFT_BAR_CORNERS, radii.pause), stop },
1091
+ // only the triangle's left side, traced down and back up like the left bar's loop (top left corner out
1092
+ // and back, left edge down, bottom left corner out and back, left edge up): the left bar inflates from
1093
+ // that edge, while the first piece (the whole triangle) opens into the right bar
1094
+ alternativeStrokes: {
1095
+ 'play-left-side': [
1096
+ play[0], reversedStroke(play[0]), reversedStroke(play[5]), reversedStroke(play[4]), play[4], play[5],
1097
+ ],
1098
+ },
1099
+ pairLayouts: {
1100
+ play: {
1101
+ pause: { tracing: 'play-left-side', breakpoints: [0, 0.7292, 1, 1.7292, 2, 3, 3.7292, 4, 4.7292, 5, 5.7708, 6, 6] },
1102
+ stop: PLAY_FOR_STOP,
1103
+ },
1104
+ pause: {
1105
+ stop: { breakpoints: [0, 1, 2, 2.0208, 2.9792, 3, 4, 5, 6, 6.3333, 6.6667, 7, 8] },
1106
+ },
1107
+ stop: { pause: { breakpoints: [0, 0.1667, 1, 2, 3, 4, 4.8333, 5, 6, 6.2917, 6.7083, 7, 8] }, play: STOP_FOR_PLAY },
1108
+ },
1109
+ },
1110
+ ],
1111
+ spins: {},
1112
+ withRoundness: (roundness) => buildMediaIcons({
1113
+ play: LUCIDE_RADII.play * roundness, pause: LUCIDE_RADII.pause * roundness, stop: LUCIDE_RADII.stop * roundness,
1114
+ }),
1115
+ };
1116
+ };
1117
+ const mediaIcons = buildMediaIcons(LUCIDE_RADII);
1118
+ return { mediaIcons };
1119
+ })();
1120
+
1121
+ // sets/radio.ts
1122
+ const { radioIcons } = (() => {
1123
+ // Icon set "radio": unchecked, checked, checked-big — Lucide 1.61.0 icons "circle", "circle-check",
1124
+ // "circle-check-big" (ISC, see LICENSE-lucide). Coordinates below are Lucide's; fromLucide / lucideLength scale them
1125
+ // into our icon box (more margin, like the digits — see lucide.ts). Works like "checkbox", with a circle for the box.
1126
+ // Two pieces:
1127
+ // piece 1, the circle (radius 10 around 12,12): the same full circle in unchecked and checked (it doesn't move);
1128
+ // checked-big opens a gap at the top right (from 17,3.335 clockwise round to 21.801,10 is the open arc) where
1129
+ // the big check leaves the circle — both ends of the gap pull back to the gap's edges.
1130
+ // piece 2, the check (check-mark.ts, shared with "checkbox"): drawn in like a pen from unchecked.
1131
+ const [CENTER_X, CENTER_Y] = fromLucide([12, 12]);
1132
+ const RADIUS = lucideLength(10);
1133
+ /** The open arc of circle-check-big runs clockwise from 21.801,10 to 17,3.335 (the gap is the rest). */
1134
+ const OPEN_START_ANGLE = (Math.atan2(10 - 12, 21.801 - 12) * 180) / Math.PI;
1135
+ const OPEN_END_ANGLE = (Math.atan2(3.335 - 12, 17 - 12) * 180) / Math.PI + 360;
1136
+ /** The share of the full circle that stays in checked-big. */
1137
+ const OPEN_SHARE = (OPEN_END_ANGLE - OPEN_START_ANGLE) / 360;
1138
+ /** The full circle starts where the open arc starts, so the gap is its last part. */
1139
+ const CIRCLE = [arc(CENTER_X, CENTER_Y, RADIUS, OPEN_START_ANGLE, OPEN_START_ANGLE + 360)];
1140
+ const OPEN_CIRCLE = [arc(CENTER_X, CENTER_Y, RADIUS, OPEN_START_ANGLE, OPEN_END_ANGLE)];
1141
+ // circle, unchecked/checked ↔ checked-big: the open part 10 segments, the gap 2 (one per half), started at the gap's
1142
+ // second half — so it lines up with the open arc, which gets a zero-length segment at each end of the gap where
1143
+ // the vanishing halves pull back to.
1144
+ const openPart = Array.from({ length: 11 }, (_, index) => +((index / 10) * OPEN_SHARE).toFixed(4));
1145
+ const CIRCLE_FOR_OPEN_CIRCLE = { breakpoints: [...openPart, +((1 + OPEN_SHARE) / 2).toFixed(4), 1], startSegment: 11 };
1146
+ const OPEN_CIRCLE_FOR_CIRCLE = { breakpoints: [0, ...Array.from({ length: 11 }, (_, index) => index / 10), 1] };
1147
+ const radioIcons = {
1148
+ segmentCount: 12,
1149
+ strokeWidth: LUCIDE_STROKE_WIDTH,
1150
+ lineCap: 'round',
1151
+ lineJoin: 'round',
1152
+ strokes: { unchecked: CIRCLE, checked: CIRCLE, 'checked-big': OPEN_CIRCLE },
1153
+ alternativeStrokes: {},
1154
+ pairLayouts: {
1155
+ unchecked: { 'checked-big': CIRCLE_FOR_OPEN_CIRCLE },
1156
+ checked: { 'checked-big': CIRCLE_FOR_OPEN_CIRCLE },
1157
+ 'checked-big': { unchecked: OPEN_CIRCLE_FOR_CIRCLE, checked: OPEN_CIRCLE_FOR_CIRCLE },
1158
+ },
1159
+ // the check rests (hidden) on the circle's left edge
1160
+ morePieces: [checkMarkPiece([2, 12])],
1161
+ spins: {},
1162
+ };
1163
+ return { radioIcons };
1164
+ })();
1165
+
1166
+ // sets/menu.ts
1167
+ const { menuIcons } = (() => {
1168
+ // Icon set "menu": menu (☰), close (✕), back (←) — Lucide 1.61.0 icons "menu", "x", "arrow-left" (ISC, see
1169
+ // LICENSE-lucide), imported with `node tools/import-lucide.ts menu x arrow-left`. Coordinates below are Lucide's;
1170
+ // fromLucide scales them into our icon box (more margin, like the digits — see lucide.ts).
1171
+ // Three pieces, each one straight line, lined up so the lines of every state correspond:
1172
+ // piece 1: top line | "\" of the ✕ | upper half of the arrowhead
1173
+ // piece 2: middle line | hidden on the ✕'s centre | the arrow's shaft
1174
+ // piece 3: bottom line | "/" of the ✕ | lower half of the arrowhead
1175
+ // Lucide draws the arrowhead as one bent line; two lines meeting with round ends look the same.
1176
+ // Every line runs from left to right, so lines move without turning over.
1177
+ /** A straight line between two Lucide points, moved into our icon box. */
1178
+ const lucideLine = (from, to) => [line(...fromLucide(from), ...fromLucide(to))];
1179
+ const piece = (strokes, extras = { alternativeStrokes: {}, pairLayouts: {} }) => ({ strokes, ...extras });
1180
+ // close ↔ back: the ✕ drawn with its lines swapped ("/" in piece 1, "\" in piece 3), so each line only shortens
1181
+ // and tilts into its half of the arrowhead instead of turning over
1182
+ const BACKSLASH = lucideLine([6, 6], [18, 18]);
1183
+ const SLASH = lucideLine([6, 18], [18, 6]);
1184
+ const menuIcons = {
1185
+ segmentCount: 1,
1186
+ strokeWidth: LUCIDE_STROKE_WIDTH,
1187
+ lineCap: 'round',
1188
+ lineJoin: 'round',
1189
+ ...piece({
1190
+ menu: lucideLine([4, 5], [20, 5]),
1191
+ close: BACKSLASH,
1192
+ back: lucideLine([5, 12], [12, 5]),
1193
+ }, { alternativeStrokes: { 'close-swapped': SLASH }, pairLayouts: { close: { back: { tracing: 'close-swapped', breakpoints: [0, 1] } } } }),
1194
+ morePieces: [
1195
+ piece({
1196
+ menu: lucideLine([4, 12], [20, 12]),
1197
+ // a tiny line on the centre of the ✕'s "\": hidden under it
1198
+ close: lucideLine([11.5, 11.5], [12.5, 12.5]),
1199
+ back: lucideLine([5, 12], [19, 12]),
1200
+ }),
1201
+ piece({
1202
+ menu: lucideLine([4, 19], [20, 19]),
1203
+ close: SLASH,
1204
+ back: lucideLine([5, 12], [12, 19]),
1205
+ }, { alternativeStrokes: { 'close-swapped': BACKSLASH }, pairLayouts: { close: { back: { tracing: 'close-swapped', breakpoints: [0, 1] } } } }),
1206
+ ],
1207
+ spins: {},
1208
+ };
1209
+ return { menuIcons };
1210
+ })();
1211
+
528
1212
  // sets/round.ts
529
1213
  const { roundDigits } = (() => {
530
1214
  // Digit set "round": geometric sans digits, drafted from the reference images in icons/round/
@@ -561,7 +1245,8 @@ const TWO_DIAGONAL_ANGLE = 39.5191;
561
1245
  const FIVE_JOIN_ANGLE = 237.2;
562
1246
  /** The 7's foot: on the measured diagonal (16, 4.2 → 7.45, 20.6), pulled in to the base line. */
563
1247
  const SEVEN_FOOT = pointAtY([16, TOP_BAR_Y], [7.45, 20.6], BASE_Y);
564
- const roundDigits = {
1248
+ // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
1249
+ const roundDigits = withTextCharacters({
565
1250
  segmentCount: 12,
566
1251
  strokeWidth: STROKE_WIDTH,
567
1252
  lineCap: 'round',
@@ -602,7 +1287,6 @@ const roundDigits = {
602
1287
  arc(12, 8.4, BIG_RADIUS, SIX_TAIL_ANGLE + 180 - 360, SIX_TAIL_ANGLE + 180),
603
1288
  line(...pointOnCircle(12, 8.4, BIG_RADIUS, SIX_TAIL_ANGLE + 180), ...NINE_TAIL_END),
604
1289
  ],
605
- '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
606
1290
  },
607
1291
  alternativeStrokes: {
608
1292
  // the 8 traced like the 0 (from the top, counter-clockwise): left half of the top circle down to the waist,
@@ -767,7 +1451,7 @@ const roundDigits = {
767
1451
  9: { 6: -180, 8: -180 },
768
1452
  },
769
1453
  textLayout: { cellWidth: 10, spacing: 4, baselineY: 19.8, colonDotsY: [7.6, 15.6] },
770
- };
1454
+ });
771
1455
  return { roundDigits };
772
1456
  })();
773
1457
 
@@ -807,7 +1491,8 @@ const SIX = [
807
1491
  topArc(SIX_HOOK_ANGLE, -180),
808
1492
  ...roundedPolyline([[8, 8], [8, 20], [16, 20], [16, SIX_LOOP_TOP], [8, SIX_LOOP_TOP], [8, SIX_LOOP_TOP + RADIUS]], [0, RADIUS, RADIUS, RADIUS, RADIUS, 0]),
809
1493
  ];
810
- const slimDigits = {
1494
+ // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
1495
+ const slimDigits = withTextCharacters({
811
1496
  segmentCount: 16,
812
1497
  strokeWidth: 6 / 5.5,
813
1498
  lineCap: 'round',
@@ -836,7 +1521,6 @@ const slimDigits = {
836
1521
  8: [topArc(90, 450), bottomArc(270, -90)],
837
1522
  // the 6 turned 180°, drawn the other way round
838
1523
  9: SIX.map(turnedAround).reverse(),
839
- '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
840
1524
  },
841
1525
  alternativeStrokes: {
842
1526
  // the 8 traced like the 0 (as in the round set): from the top counter-clockwise — left half of TOP down to the waist,
@@ -917,7 +1601,7 @@ const slimDigits = {
917
1601
  // 9 is exactly 6 turned 180°; 2 → 6 also turns (it is 2 → 9 in the turning frame)
918
1602
  spins: { 2: { 6: 180 }, 6: { 9: 180, 2: -180 }, 9: { 6: -180 } },
919
1603
  textLayout: { cellWidth: 9.5, spacing: 4, baselineY: 20, colonDotsY: [8, 16] },
920
- };
1604
+ });
921
1605
  return { slimDigits };
922
1606
  })();
923
1607
 
@@ -950,7 +1634,8 @@ const EIGHT_RADIUS = 6;
950
1634
  // the 6: hook from the top right, round the left side, then the closed bottom loop back to the left side
951
1635
  const SIX_POINTS = [[15, 2], [2, 2], [2, 26], [19, 26], [19, 12], [2, 12]];
952
1636
  const SIX_RADII = [0, SIX_RADIUS, SIX_RADIUS, SIX_RADIUS, SIX_RADIUS, 0];
953
- const squareDigits = {
1637
+ // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
1638
+ const squareDigits = withTextCharacters({
954
1639
  segmentCount: 20,
955
1640
  strokeWidth: STROKE_WIDTH,
956
1641
  lineCap: 'round',
@@ -975,7 +1660,6 @@ const squareDigits = {
975
1660
  8: drawn(20, [[7, 14], [3, 14], [3, 2], [17, 2], [17, 14], [2, 14], [2, 26], [18, 26], [18, 14], [7, 14]], [0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0]),
976
1661
  // the 6 turned 180°, drawn the other way round (loop first, then the hook)
977
1662
  9: roundedPolyline(SIX_POINTS.map(fromImage(21)).map(turnedAround).reverse(), [...SIX_RADII].reverse().map((radius) => radius / PIXELS_PER_UNIT)),
978
- '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
979
1663
  },
980
1664
  alternativeStrokes: {
981
1665
  // the 8 traced like the 0 (from the top, counter-clockwise): left side of the top loop down to the middle of
@@ -1068,10 +1752,1219 @@ const squareDigits = {
1068
1752
  },
1069
1753
  spins: { 6: { 9: 180 }, 9: { 6: -180 } },
1070
1754
  textLayout: { cellWidth: 13.5, spacing: 4, baselineY: 20, colonDotsY: [8, 16] },
1071
- };
1755
+ });
1072
1756
  return { squareDigits };
1073
1757
  })();
1074
1758
 
1759
+ // sets/theme.ts
1760
+ const { themeIcons } = (() => {
1761
+ // Icon set "theme": light (sun), dark (moon) — Lucide 1.61.0 icons "sun", "moon" (ISC, see LICENSE-lucide).
1762
+ // Coordinates below are Lucide's; lucideLine / lucideArc / lucideCubic scale them into our icon box (see lucide.ts).
1763
+ // Nine pieces:
1764
+ // piece 1: the sun's circle | the moon's crescent — the circle grows into the moon and its top right quarter
1765
+ // flattens and caves in evenly into the inner curve (the circle starts where the moon's outer arc starts).
1766
+ // pieces 2–9: one ray each | nothing — rays run from their outer end inwards; light → dark: they fly outwards and
1767
+ // vanish (erased towards their outer tip, PairLayout.draw — the user's sketch) within the first 60 % of the
1768
+ // morph; dark → light: they fly back in during the last 60 %. At rest the moon's ray pieces are tiny lines hidden
1769
+ // on its outline.
1770
+ const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
1771
+ const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
1772
+ const lucideCubic = (...coordinates) => {
1773
+ const [startX, startY, control1X, control1Y, control2X, control2Y, endX, endY] = coordinates;
1774
+ return cubic(...fromLucide([startX, startY]), ...fromLucide([control1X, control1Y]), ...fromLucide([control2X, control2Y]), ...fromLucide([endX, endY]));
1775
+ };
1776
+ /** Lucide "moon": the outer arc clockwise from its right end, the top tip, the inner arc back, the right tip. */
1777
+ const MOON = [
1778
+ lucideArc(11.9981, 12.0009, 9, 3.09, 266.904),
1779
+ lucideCubic(11.512, 3.014, 11.917, 2.992, 12.129, 3.474, 11.914, 3.817),
1780
+ lucideArc(17.002, 6.997, 6, -147.9941, -302.0059),
1781
+ lucideCubic(20.182, 12.085, 20.526, 11.87, 21.007, 12.081, 20.985, 12.486),
1782
+ ];
1783
+ /** Lucide "sun"'s circle, started at the angle where the moon's outer arc starts. */
1784
+ const SUN_CIRCLE = [lucideArc(12, 12, 4, 3.09, 363.09)];
1785
+ /** The share of the circle under the moon's outer arc (3.09° … 266.904°). */
1786
+ const OUTER_SHARE = (266.904 - 3.09) / 360;
1787
+ const CIRCLE_FOR_MOON = [
1788
+ ...Array.from({ length: 9 }, (_, index) => (index / 8) * OUTER_SHARE),
1789
+ ...Array.from({ length: 4 }, (_, index) => OUTER_SHARE + ((index + 1) / 4) * (1 - OUTER_SHARE)),
1790
+ ].map((breakpoint) => +breakpoint.toFixed(4));
1791
+ /** Lucide "sun"'s rays, each from its outer end inwards (erasing keeps the outer end: the ray flies out). */
1792
+ const RAYS = [
1793
+ lucideLine(12, 2, 12, 4),
1794
+ lucideLine(19.07, 4.93, 17.66, 6.34),
1795
+ lucideLine(22, 12, 20, 12),
1796
+ lucideLine(19.07, 19.07, 17.66, 17.66),
1797
+ lucideLine(12, 22, 12, 20),
1798
+ lucideLine(4.93, 19.07, 6.34, 17.66),
1799
+ lucideLine(2, 12, 4, 12),
1800
+ lucideLine(4.93, 4.93, 6.34, 6.34),
1801
+ ];
1802
+ /** The moon's ray pieces at rest: a tiny line on the bottom of its outline (it doesn't show). */
1803
+ const HIDDEN_RAY = [lucideLine(11.95, 21, 12.05, 21)];
1804
+ const rayPiece = (ray) => ({
1805
+ strokes: { light: [ray], dark: HIDDEN_RAY },
1806
+ alternativeStrokes: {},
1807
+ pairLayouts: { dark: { light: { draw: 0.6 } } },
1808
+ });
1809
+ const themeIcons = {
1810
+ segmentCount: 12,
1811
+ strokeWidth: LUCIDE_STROKE_WIDTH,
1812
+ lineCap: 'round',
1813
+ lineJoin: 'round',
1814
+ strokes: { light: SUN_CIRCLE, dark: MOON },
1815
+ alternativeStrokes: {},
1816
+ // even, by hand: the circle's part under the outer arc (263.8°) → 8 equal segments of the outer arc; its top right
1817
+ // quarter (96.2°) → 4 equal segments: tip, inner curve (2), tip. (The optimizer's fit made a lumpy notch.)
1818
+ pairLayouts: {
1819
+ light: { dark: { breakpoints: CIRCLE_FOR_MOON } },
1820
+ dark: { light: { breakpoints: [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1, 2, 2.5, 3, 4] } },
1821
+ },
1822
+ morePieces: RAYS.map(rayPiece),
1823
+ spins: {},
1824
+ };
1825
+ return { themeIcons };
1826
+ })();
1827
+
1828
+ // sets/weather.ts
1829
+ const { weatherIcons } = (() => {
1830
+ // Icon set "weather": sunny, frosty, partly-cloudy, showers, cloudy, thunder, rain, snow, drizzle — Lucide 1.61.0
1831
+ // icons "sun", "sun-snow", "cloud-sun", "cloud-sun-rain", "cloud", "cloud-lightning", "cloud-rain", "cloud-snow",
1832
+ // "cloud-drizzle" (ISC, see LICENSE-lucide). frosty is added last, onto the finished set (withFrosty, at the end).
1833
+ // Coordinates below are Lucide's; lucideLine / lucideArc scale them into our icon box (see lucide.ts).
1834
+ // Pieces:
1835
+ // sunny ↔ cloudy (experiment, the user's idea for a more realistic look): the cloud crawls in from the left over
1836
+ // the sun — it slides in at full size from the box's left side (PairLayout.wipe + slide; its part outside the
1837
+ // box doesn't show), and the sun's circle and rays disappear where its front has passed. (Rejected: the cloud
1838
+ // standing still, revealed behind the edge — user: even worse.) (Before: sunny's circle inflated into the cloud with the
1839
+ // default layout — confirmed "great" by the user — and the rays flew out, `draw: 0.6`.)
1840
+ // piece 1, the cloud.
1841
+ // sunny → partly-cloudy / showers: the small cloud crawls in from the left like the big one (user's choice; before:
1842
+ // it expanded from its bottom left corner — rejected earlier: a circle copy turning into it, drawing it in).
1843
+ // piece 2, the sun's circle: in cloudy a copy of the cloud (drawn on top of it, so it doesn't show) — sunny ↔ cloudy
1844
+ // morphs it exactly like piece 1. sunny → partly-cloudy: the circle opens behind the cloud — its hidden part pulls
1845
+ // back from the middle into both ends of the arc, the arc itself stays.
1846
+ // pieces 3–10: one ray each | nothing — rays run from their outer end inwards; sunny → partly-cloudy: the 4 rays partly-cloudy
1847
+ // keeps stay, the other 4 fly out. At rest a missing ray is a single point (never drawn, see toPathData).
1848
+ // pieces 11–12: showers' rain lines, which are also rain's left and middle line; piece 14: rain's right line —
1849
+ // nothing = a single point. partly-cloudy → showers: drawn downwards like a pen, one after the other, while the
1850
+ // cloud's bottom opens. cloudy → rain: like cloudy → thunder (the cloud rises and opens), the 3 lines drawn down
1851
+ // one after the other, left to right; from partly-cloudy / showers / thunder the same (showers' lines stretch,
1852
+ // the bolt is erased); sunny → rain: the cloud crawls in with its rain (its left end carried by the bolt piece).
1853
+ // pieces 11, 12, 14 are also snow's top flakes (the top of each rain line), pieces 15–17 its bottom flakes (hidden
1854
+ // at the bottom of each rain line in rain): rain → snow: each line shrinks into its top flake while the bottom
1855
+ // flake comes out of its lower end. → snow otherwise: the flakes appear one after the other, row by row (ride
1856
+ // in with the cloud from sunny).
1857
+ // drizzle's 6 short lines sit where snow's flakes are, so they are the same 6 pieces: rain → drizzle: each line
1858
+ // shrinks into its short top piece, the bottom piece grows out of its lower end; snow ↔ drizzle: flake ↔ short
1859
+ // line (halves, as rain ↔ snow); → drizzle otherwise like → snow (drawn row by row, riding in from sunny).
1860
+ // snow and drizzle have rain's cloud: every other piece morphs exactly like rain's (withRainTwins).
1861
+ // The rules of this set (the user's): sun → any cloud = the cloud crawls in from the left and covers the sun;
1862
+ // partly-cloudy / showers → a cloud without sun = like → cloudy (the sun erased back down).
1863
+ // piece 13: the lightning bolt | nothing (a single point) — cloudy → thunder: the cloud rises a little and opens
1864
+ // at the bottom (like cloudy → showers), the bolt strikes down from it like a pen. partly-cloudy / showers →
1865
+ // thunder: like → cloudy (the cloud grows, the sun is erased back down), plus the bolt (and showers' rain erased).
1866
+ // sunny → thunder: the cloud crawls in like sunny → cloudy, the bolt rides with it. The open cloud's lower left
1867
+ // end curls back right, so during the crawl it is carried by the first rain piece (else it would poke out of the
1868
+ // box's left side): piece 1 is laid out without it — together they look the same.
1869
+ // showers otherwise morphs like partly-cloudy (sunny: the cloud crawls in, the circle opens; cloudy: the cloud
1870
+ // shrinks and opens, the sun is drawn in), plus the rain: drawn down, or (from sunny) riding in with the cloud.
1871
+ const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
1872
+ const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
1873
+ /** Lucide "cloud", clockwise from the bottom right: bottom line, big bump, top line, right bump. */
1874
+ const CLOUD = [
1875
+ lucideLine(17.5, 19, 9, 19),
1876
+ lucideArc(9.0018, 12, 7, 90.0147, 343.3984),
1877
+ lucideLine(15.71, 10, 17.5, 10),
1878
+ lucideArc(17.5, 14.5, 4.5, -90, 90),
1879
+ ];
1880
+ /** sunny → partly-cloudy / showers: the small cloud (and its rain) crawls in a bit later (user's request). */
1881
+ const SMALL_CLOUD_CRAWL = [0.4, 1];
1882
+ /** CLOUD traced from its leftmost point (sunny ↔ cloudy: it slides in from the left, its left side shows last). */
1883
+ const CLOUD_FROM_LEFT = [
1884
+ lucideArc(9.0018, 12, 7, 180, 343.3984),
1885
+ ...CLOUD.slice(2),
1886
+ CLOUD[0],
1887
+ lucideArc(9.0018, 12, 7, 90.0147, 180),
1888
+ ];
1889
+ /** The sun's circle traced from its leftmost point (sunny ↔ cloudy: the wiping edge reaches it first). */
1890
+ const CIRCLE_FROM_LEFT = [lucideArc(12, 12, 4, 180, 540)];
1891
+ /** A wipe's piece laid out along an alternative tracing with the default spread. */
1892
+ const tracedForWipe = (tracing, strokes) => ({ tracing, breakpoints: defaultBreakpoints(strokes, 12) });
1893
+ /** The angle (from the icon centre) where the cloud starts, so the circle starts in the same direction. */
1894
+ const CIRCLE_START = 51.84;
1895
+ /** Lucide "sun"'s circle, clockwise from CIRCLE_START. */
1896
+ const SUN_CIRCLE = [lucideArc(12, 12, 4, CIRCLE_START, CIRCLE_START + 360)];
1897
+ /** Lucide "cloud-sun"'s small cloud, clockwise from the bottom right (the same build as CLOUD). */
1898
+ const SMALL_CLOUD = [
1899
+ lucideLine(13, 22, 7, 22),
1900
+ lucideArc(7.001, 17, 5, 90.0117, 348.463),
1901
+ lucideLine(11.9, 16, 13, 16),
1902
+ lucideArc(13, 19, 3, -90, 90),
1903
+ ];
1904
+ /** SMALL_CLOUD traced from its leftmost point (sunny ↔ partly-cloudy: it slides in like the big cloud). */
1905
+ const SMALL_CLOUD_FROM_LEFT = [
1906
+ lucideArc(7.001, 17, 5, 180, 348.463),
1907
+ ...SMALL_CLOUD.slice(2),
1908
+ SMALL_CLOUD[0],
1909
+ lucideArc(7.001, 17, 5, 90.0117, 180),
1910
+ ];
1911
+ /** Lucide "cloud-sun-rain"'s cloud: SMALL_CLOUD open at the bottom (where the rain falls), from its lower left end. */
1912
+ const OPEN_SMALL_CLOUD = [
1913
+ lucideArc(7.0014, 17.0019, 5, 143.1569, 348.4412),
1914
+ lucideLine(11.9, 16, 13, 16),
1915
+ lucideArc(13.0044, 19, 3, -90.0842, 48.3025),
1916
+ ];
1917
+ /** Where SMALL_CLOUD's gap (bottom) has its middle: the gap's two halves pull back into its ends from here. */
1918
+ const GAP_MIDDLE_X = 8.77;
1919
+ // partly-cloudy ↔ showers: the closed cloud traced from the gap's middle — 2 segments to the gap's left end, the open
1920
+ // cloud's part (4 along the big bump, 1 top line, 3 along the right bump), 2 back to the middle; the open cloud gets
1921
+ // 2 zero-length segments at each end.
1922
+ const CLOSED_FOR_OPEN = {
1923
+ tracing: 'small-cloud-from-gap-middle',
1924
+ breakpoints: [0, 1, 1.2057, 1.4043, 1.6029, 1.8014, 2, 3, 3.2561, 3.5122, 3.7683, 4, 5],
1925
+ };
1926
+ const OPEN_FOR_CLOSED = { breakpoints: [0, 0, 0, 0.25, 0.5, 0.75, 1, 2, 2.3333, 2.6667, 3, 3, 3] };
1927
+ /** The same gap on the big CLOUD (its bottom, from 143.16° on the big bump to 48.3° on the right bump). */
1928
+ const BIG_GAP_MIDDLE_X = 11.65;
1929
+ // cloudy ↔ showers: the big cloud traced from its gap's middle, split like CLOSED_FOR_OPEN — it shrinks and opens
1930
+ const BIG_CLOSED_FOR_OPEN = {
1931
+ tracing: 'cloud-from-gap-middle',
1932
+ breakpoints: [0, 1, 1.2097, 1.4073, 1.6049, 1.8024, 2, 3, 3.2561, 3.5122, 3.7683, 4, 5],
1933
+ };
1934
+ /** Lucide "cloud-lightning"'s cloud: CLOUD 2 units higher, open at the bottom (where the bolt strikes), from its
1935
+ * lower left end. */
1936
+ const THUNDER_CLOUD = [
1937
+ lucideArc(9.0026, 10.0027, 7, 115.4004, 343.3756),
1938
+ lucideLine(15.71, 8, 17.5, 8),
1939
+ lucideArc(17.5079, 12.5, 4.5, -90.1, 83.7212),
1940
+ ];
1941
+ /** The middle of CLOUD's part that is missing in THUNDER_CLOUD (its bottom, from 115.4° to 83.7°). */
1942
+ const THUNDER_GAP_MIDDLE_X = 11.94;
1943
+ // cloudy ↔ thunder: CLOUD traced from that middle — 2 segments to the gap's left end, 4 along the big bump, 1 top line,
1944
+ // 3 along the right bump, 2 back to the middle; the open cloud gets 2 zero-length segments at each end (OPEN_FOR_CLOSED).
1945
+ const CLOUD_FROM_THUNDER_GAP_MIDDLE = [
1946
+ lucideLine(THUNDER_GAP_MIDDLE_X, 19, 9, 19),
1947
+ ...CLOUD.slice(1),
1948
+ lucideLine(17.5, 19, THUNDER_GAP_MIDDLE_X, 19),
1949
+ ];
1950
+ const CLOUD_FOR_THUNDER = {
1951
+ tracing: 'cloud-from-thunder-gap-middle',
1952
+ breakpoints: [0, 1, 1.1002, 1.3252, 1.5501, 1.7751, 2, 3, 3.3217, 3.6434, 3.9651, 4, 5],
1953
+ };
1954
+ /** sunny ↔ thunder: THUNDER_CLOUD split at its leftmost point — the rest (from there over the top), and its lower
1955
+ * left end (from there down), each running away from the left side so the crawl shows one stretch of it. */
1956
+ const THUNDER_CLOUD_FROM_LEFT = [
1957
+ lucideArc(9.0026, 10.0027, 7, 180, 343.3756),
1958
+ ...THUNDER_CLOUD.slice(1),
1959
+ ];
1960
+ const THUNDER_CLOUD_LEFT_END = [lucideArc(9.0026, 10.0027, 7, 180, 115.4004)];
1961
+ /** Lucide "cloud-lightning"'s bolt, from the top down. */
1962
+ const BOLT = [lucideLine(13, 12, 10, 17), lucideLine(10, 17, 14, 17), lucideLine(14, 17, 11, 22)];
1963
+ /** cloudy → thunder: the bolt strikes in the second half of the morph. */
1964
+ const BOLT_DRAWN = [0.5, 0.85];
1965
+ /** Lucide "cloud-rain"'s cloud (also "cloud-snow", "cloud-drizzle"): THUNDER_CLOUD with a wider gap, from its lower
1966
+ * left end. */
1967
+ const RAIN_CLOUD = [
1968
+ lucideArc(9.0026, 10.0027, 7, 135.615, 343.3757),
1969
+ lucideLine(15.71, 8, 17.5, 8),
1970
+ lucideArc(17.5005, 12.5, 4.5, -90.0065, 56.2589),
1971
+ ];
1972
+ /** The middle of CLOUD's part that is missing in RAIN_CLOUD (its bottom, from 135.6° to 56.3°). */
1973
+ const RAIN_GAP_MIDDLE_X = 11.79;
1974
+ const CLOUD_FROM_RAIN_GAP_MIDDLE = [
1975
+ lucideLine(RAIN_GAP_MIDDLE_X, 19, 9, 19),
1976
+ ...CLOUD.slice(1),
1977
+ lucideLine(17.5, 19, RAIN_GAP_MIDDLE_X, 19),
1978
+ ];
1979
+ // cloudy ↔ rain: split like CLOUD_FOR_THUNDER (2 | 4 big bump | 1 | 3 right bump | 2), RAIN_CLOUD with OPEN_FOR_CLOSED
1980
+ const CLOUD_FOR_RAIN = {
1981
+ tracing: 'cloud-from-rain-gap-middle',
1982
+ breakpoints: [0, 1, 1.18, 1.385, 1.59, 1.795, 2, 3, 3.2709, 3.5417, 3.8126, 4, 5],
1983
+ };
1984
+ /** sunny ↔ rain: RAIN_CLOUD split at its leftmost point, like THUNDER_CLOUD_FROM_LEFT / THUNDER_CLOUD_LEFT_END. */
1985
+ const RAIN_CLOUD_FROM_LEFT = [lucideArc(9.0026, 10.0027, 7, 180, 343.3757), ...RAIN_CLOUD.slice(1)];
1986
+ const RAIN_CLOUD_LEFT_END = [lucideArc(9.0026, 10.0027, 7, 180, 135.615)];
1987
+ /** Lucide "cloud-rain"'s lines, left to right, each from the top down. */
1988
+ const HEAVY_RAIN_LINES = [lucideLine(8, 14, 8, 20), lucideLine(12, 16, 12, 22), lucideLine(16, 14, 16, 20)];
1989
+ /** cloudy → rain: the lines are drawn one after the other. */
1990
+ const HEAVY_RAIN_DRAWN = [[0.45, 0.8], [0.55, 0.9], [0.65, 1]]; // once the bottom has opened
1991
+ /** Lucide "cloud-snow"'s flake centres for the pieces 11, 12, 14 (top flakes) and 15, 16, 17 (bottom flakes). */
1992
+ const SNOWFLAKE_CENTERS = [[8, 15], [12, 17], [16, 15], [8, 19], [12, 21], [16, 19]];
1993
+ /**
1994
+ * Lucide draws a flake as a dot (a tiny round-capped line, as wide as the line). Ours are a little bigger so they
1995
+ * show at small sizes (user's request): a tiny closed octagon of this radius, which the thick line fills in.
1996
+ */
1997
+ const SNOWFLAKE_RADIUS = 0.25;
1998
+ const SNOWFLAKES = SNOWFLAKE_CENTERS.map(([centerX, centerY]) => {
1999
+ // clockwise from the top corner
2000
+ const corners = Array.from({ length: 8 }, (_, index) => [
2001
+ centerX + SNOWFLAKE_RADIUS * Math.cos(((index - 2) * Math.PI) / 4),
2002
+ centerY + SNOWFLAKE_RADIUS * Math.sin(((index - 2) * Math.PI) / 4),
2003
+ ]);
2004
+ return corners.map((corner, index) => lucideLine(...corner, ...corners[(index + 1) % 8]));
2005
+ });
2006
+ // line → flake (rain / showers → snow): the line traced down and back up, its down pass ↔ the flake's right half, its
2007
+ // up pass ↔ the left half — the line narrows into the flake (segment by segment the octagon's corners zigzag)
2008
+ const LINE_OUT_AND_BACK = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((index) => +(index / 6).toFixed(4));
2009
+ const FLAKE_IN_HALVES = { breakpoints: [0, 0.5, 1, 2, 3, 3.5, 4, 4.5, 5, 6, 7, 7.5, 8] };
2010
+ const outAndBack = (stroke) => [stroke, reversedStroke(stroke)];
2011
+ /** → snow / drizzle: when each flake / short line appears (row by row: 8,15 · 16,15 · 12,17 · 8,19 · 16,19 · 12,21). */
2012
+ const SNOWFLAKE_STARTS = [0.45, 0.59, 0.52, 0.66, 0.8, 0.73];
2013
+ const fallingDrawn = (flakeIndex) => ({ draw: [SNOWFLAKE_STARTS[flakeIndex], SNOWFLAKE_STARTS[flakeIndex] + 0.1] });
2014
+ /** Lucide "cloud-drizzle"'s short lines (top down), in the order of the flakes (each one where a flake is). */
2015
+ const DRIZZLE_LINES = [[8, 14], [12, 16], [16, 14], [8, 19], [12, 21], [16, 19]]
2016
+ .map(([x, y]) => lucideLine(x, y, x, y + 1));
2017
+ /** Pair layouts of several groups put together (per state). */
2018
+ const mergePairLayouts = (...groups) => {
2019
+ const merged = {};
2020
+ for (const group of groups) {
2021
+ for (const [state, byOtherState] of Object.entries(group))
2022
+ merged[state] = { ...merged[state], ...byOtherState };
2023
+ }
2024
+ return merged;
2025
+ };
2026
+ /** The drizzle pairs of precipitation piece `fallingIndex` (0…5) — showers' own lines (0, 1) just shorten. */
2027
+ const drizzlePairLayouts = (fallingIndex, hasShowersLine) => ({
2028
+ ...Object.fromEntries(['cloudy', 'partly-cloudy', 'thunder', ...(hasShowersLine ? [] : ['showers'])]
2029
+ .map((state) => [state, { drizzle: fallingDrawn(fallingIndex) }])),
2030
+ sunny: { drizzle: RIDING_IN },
2031
+ snow: { drizzle: FLAKE_IN_HALVES },
2032
+ drizzle: { snow: { tracing: 'drizzle-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
2033
+ });
2034
+ /** sunny → rain / snow: the precipitation rides in with the crawling cloud. */
2035
+ const RIDING_IN = { wipe: 'right', slide: true };
2036
+ /** Lucide "cloud-sun-rain"'s rain lines, each from the top down. */
2037
+ const RAIN_LINES = [lucideLine(7, 19, 7, 21), lucideLine(11, 20, 11, 22)];
2038
+ /** partly-cloudy → showers: the rain lines are drawn one after the other while the cloud opens. */
2039
+ const RAIN_DRAWN = [[0.35, 0.75], [0.55, 0.95]];
2040
+ /** Lucide "cloud-sun"'s sun: the part of the circle above the cloud, clockwise from its left end. */
2041
+ const SUN_ARC = [lucideArc(12.0004, 11.9985, 4, -119.6437, 9.3742)];
2042
+ /** The middle of the sun's hidden part (behind the cloud), from where it pulls back into both ends of SUN_ARC. */
2043
+ const HIDDEN_MIDDLE = (9.3742 + 240.3563) / 2;
2044
+ /** Lucide "sun"'s rays, each from its outer end inwards (erasing keeps the outer end: the ray flies out). */
2045
+ const RAYS = [
2046
+ lucideLine(12, 2, 12, 4),
2047
+ lucideLine(19.07, 4.93, 17.66, 6.34),
2048
+ lucideLine(22, 12, 20, 12),
2049
+ lucideLine(19.07, 19.07, 17.66, 17.66),
2050
+ lucideLine(12, 22, 12, 20),
2051
+ lucideLine(4.93, 19.07, 6.34, 17.66),
2052
+ lucideLine(2, 12, 4, 12),
2053
+ lucideLine(4.93, 4.93, 6.34, 6.34),
2054
+ ];
2055
+ /** A missing piece at rest: a single point (never drawn), here on the cloud's bottom line. */
2056
+ const pointAt = (x, y) => [lucideLine(x, y, x, y)];
2057
+ const HIDDEN_RAY = pointAt(13, 19);
2058
+ /** partly-cloudy's and showers' missing rays (bottom right, bottom, bottom left, left). */
2059
+ const HIDDEN_RAY_PARTLY = pointAt(10, 22);
2060
+ const MISSING_RAYS_PARTLY = [3, 4, 5, 6];
2061
+ // sunny ↔ partly-cloudy, the sun's circle: traced from the middle of the hidden part, 4 segments to the arc's left
2062
+ // end, 4 along the arc (it stays), 4 back to the middle; the arc gets 4 zero-length segments at each end.
2063
+ const ARC_SHARE = (9.3742 + 119.6437) / 360;
2064
+ const CIRCLE_FOR_SUN_ARC = {
2065
+ tracing: 'circle-from-hidden-middle',
2066
+ breakpoints: [
2067
+ ...[0, 1, 2, 3].map((index) => (index / 4) * ((1 - ARC_SHARE) / 2)),
2068
+ ...[0, 1, 2, 3].map((index) => (1 - ARC_SHARE) / 2 + (index / 4) * ARC_SHARE),
2069
+ ...[0, 1, 2, 3, 4].map((index) => (1 + ARC_SHARE) / 2 + (index / 4) * ((1 - ARC_SHARE) / 2)),
2070
+ ].map((breakpoint) => +breakpoint.toFixed(4)),
2071
+ };
2072
+ const SUN_ARC_FOR_CIRCLE = { breakpoints: [0, 0, 0, 0, 0, 0.25, 0.5, 0.75, 1, 1, 1, 1, 1] };
2073
+ // cloudy → partly-cloudy: the sun is drawn like a pen from the arc's lower right end up over the top (the user's
2074
+ // direction), and the kept rays follow one after another, each growing out from the sun when the pen passes it.
2075
+ const SUN_ARC_DRAWN = [0.45, 0.85]; // after the cloud has shrunk away from the arc's start
2076
+ const RAY_DRAW_DURATION = 0.15;
2077
+ /** Kept rays → how far along the arc (from its lower right end, 0…1) the pen is when the ray starts. */
2078
+ const KEPT_RAY_PEN_POSITIONS = { 2: 0.07, 1: 0.42, 0: 0.77, 7: 1 };
2079
+ const rayDrawnAfterPen = (penPosition) => {
2080
+ const start = SUN_ARC_DRAWN[0] + penPosition * (SUN_ARC_DRAWN[1] - SUN_ARC_DRAWN[0]);
2081
+ return [+start.toFixed(4), +Math.min(start + RAY_DRAW_DURATION, 1).toFixed(4)];
2082
+ };
2083
+ /** States drawn with rain's cloud: apart from the precipitation they morph exactly like rain. */
2084
+ const RAIN_TWINS = ['snow', 'drizzle'];
2085
+ /** A piece that is the same in rain and its twins: the twins get rain's strokes and pair layouts. */
2086
+ const withRainTwins = (pieceSet) => {
2087
+ const pairLayouts = Object.fromEntries(Object.entries(pieceSet.pairLayouts).map(([state, layouts]) => [state, { ...layouts, ...(layouts.rain ? Object.fromEntries(RAIN_TWINS.map((twin) => [twin, layouts.rain])) : {}) }]));
2088
+ for (const twin of RAIN_TWINS)
2089
+ if (pieceSet.pairLayouts.rain)
2090
+ pairLayouts[twin] = pieceSet.pairLayouts.rain;
2091
+ return {
2092
+ ...pieceSet,
2093
+ strokes: { ...pieceSet.strokes, ...Object.fromEntries(RAIN_TWINS.map((twin) => [twin, pieceSet.strokes.rain])) },
2094
+ pairLayouts,
2095
+ };
2096
+ };
2097
+ const sunCirclePiece = {
2098
+ strokes: { sunny: SUN_CIRCLE, cloudy: CLOUD, 'partly-cloudy': SUN_ARC, showers: SUN_ARC, thunder: THUNDER_CLOUD, rain: RAIN_CLOUD },
2099
+ alternativeStrokes: {
2100
+ 'cloud-from-rain-gap-middle': CLOUD_FROM_RAIN_GAP_MIDDLE,
2101
+ 'cloud-from-thunder-gap-middle': CLOUD_FROM_THUNDER_GAP_MIDDLE,
2102
+ 'circle-from-hidden-middle': [lucideArc(12, 12, 4, HIDDEN_MIDDLE, HIDDEN_MIDDLE + 360)],
2103
+ 'circle-from-left': CIRCLE_FROM_LEFT,
2104
+ },
2105
+ pairLayouts: {
2106
+ sunny: {
2107
+ cloudy: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
2108
+ thunder: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
2109
+ rain: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
2110
+ 'partly-cloudy': CIRCLE_FOR_SUN_ARC,
2111
+ showers: CIRCLE_FOR_SUN_ARC,
2112
+ },
2113
+ cloudy: {
2114
+ sunny: { wipe: 'left' },
2115
+ 'partly-cloudy': { draw: SUN_ARC_DRAWN },
2116
+ showers: { draw: SUN_ARC_DRAWN },
2117
+ thunder: CLOUD_FOR_THUNDER,
2118
+ rain: CLOUD_FOR_RAIN,
2119
+ },
2120
+ rain: {
2121
+ cloudy: OPEN_FOR_CLOSED,
2122
+ sunny: { wipe: 'left' },
2123
+ 'partly-cloudy': { draw: SUN_ARC_DRAWN },
2124
+ showers: { draw: SUN_ARC_DRAWN },
2125
+ },
2126
+ 'partly-cloudy': { sunny: SUN_ARC_FOR_CIRCLE, cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
2127
+ showers: { sunny: SUN_ARC_FOR_CIRCLE, cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
2128
+ // the sun goes like → cloudy: crawled over from sunny, erased back down from partly-cloudy / showers
2129
+ thunder: { cloudy: OPEN_FOR_CLOSED, sunny: { wipe: 'left' }, 'partly-cloudy': { draw: SUN_ARC_DRAWN }, showers: { draw: SUN_ARC_DRAWN } },
2130
+ },
2131
+ };
2132
+ const rayPiece = (ray, rayIndex) => {
2133
+ const missingInPartly = MISSING_RAYS_PARTLY.includes(rayIndex);
2134
+ return {
2135
+ strokes: {
2136
+ sunny: [ray],
2137
+ cloudy: HIDDEN_RAY,
2138
+ 'partly-cloudy': missingInPartly ? HIDDEN_RAY_PARTLY : [ray],
2139
+ showers: missingInPartly ? HIDDEN_RAY_PARTLY : [ray],
2140
+ thunder: HIDDEN_RAY,
2141
+ rain: HIDDEN_RAY,
2142
+ },
2143
+ alternativeStrokes: {},
2144
+ pairLayouts: {
2145
+ cloudy: { sunny: { wipe: 'left' } },
2146
+ thunder: { sunny: { wipe: 'left' } },
2147
+ rain: { sunny: { wipe: 'left' } },
2148
+ ...(missingInPartly
2149
+ ? { 'partly-cloudy': { sunny: { draw: 0.6 } }, showers: { sunny: { draw: 0.6 } } }
2150
+ // drawn from the sun outwards (the rays run outer end → inner end, so reversed)
2151
+ : {
2152
+ cloudy: {
2153
+ sunny: { wipe: 'left' },
2154
+ 'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2155
+ showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2156
+ },
2157
+ thunder: {
2158
+ sunny: { wipe: 'left' },
2159
+ 'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2160
+ showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2161
+ },
2162
+ rain: {
2163
+ sunny: { wipe: 'left' },
2164
+ 'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2165
+ showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2166
+ },
2167
+ 'partly-cloudy': { cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
2168
+ showers: { cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
2169
+ }),
2170
+ },
2171
+ };
2172
+ };
2173
+ /** Showers' rain line (if it has one) | rain's line: pieces 11, 12 (both) and 14 (rain only). */
2174
+ const rainPiece = (heavyRainLine, rainIndex) => {
2175
+ const showersLine = RAIN_LINES[rainIndex];
2176
+ const top = (showersLine ?? heavyRainLine).from;
2177
+ const nothing = [line(...top, ...top)];
2178
+ const carriesCloudEnd = rainIndex === 0;
2179
+ return {
2180
+ strokes: {
2181
+ sunny: nothing,
2182
+ cloudy: nothing,
2183
+ 'partly-cloudy': nothing,
2184
+ showers: showersLine ? [showersLine] : nothing,
2185
+ thunder: nothing,
2186
+ rain: [heavyRainLine],
2187
+ snow: SNOWFLAKES[rainIndex],
2188
+ drizzle: [DRIZZLE_LINES[rainIndex]],
2189
+ },
2190
+ alternativeStrokes: {
2191
+ ...(carriesCloudEnd ? { 'thunder-cloud-left-end': THUNDER_CLOUD_LEFT_END } : {}),
2192
+ 'rain-line-out-and-back': outAndBack(heavyRainLine),
2193
+ 'drizzle-line-out-and-back': outAndBack(DRIZZLE_LINES[rainIndex]),
2194
+ ...(showersLine ? { 'showers-line-out-and-back': outAndBack(showersLine) } : {}),
2195
+ },
2196
+ pairLayouts: mergePairLayouts(drizzlePairLayouts(rainIndex, Boolean(showersLine)), showersLine
2197
+ ? {
2198
+ cloudy: { showers: { draw: RAIN_DRAWN[rainIndex] }, rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] }, snow: fallingDrawn(rainIndex) },
2199
+ 'partly-cloudy': {
2200
+ showers: { draw: RAIN_DRAWN[rainIndex] },
2201
+ rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] },
2202
+ snow: fallingDrawn(rainIndex),
2203
+ },
2204
+ // sunny → showers / rain: the rain rides in with the crawling cloud; sunny → thunder: it carries the cloud's
2205
+ // left end
2206
+ sunny: {
2207
+ showers: { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
2208
+ rain: RIDING_IN,
2209
+ snow: RIDING_IN,
2210
+ ...(carriesCloudEnd ? { thunder: RIDING_IN } : {}),
2211
+ },
2212
+ // showers → thunder: erased back up; thunder → rain: drawn down
2213
+ thunder: {
2214
+ showers: { draw: RAIN_DRAWN[rainIndex] },
2215
+ rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] },
2216
+ snow: fallingDrawn(rainIndex),
2217
+ ...(carriesCloudEnd ? { sunny: tracedForWipe('thunder-cloud-left-end', THUNDER_CLOUD_LEFT_END) } : {}),
2218
+ },
2219
+ rain: { snow: { tracing: 'rain-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
2220
+ showers: { snow: { tracing: 'showers-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
2221
+ snow: { rain: FLAKE_IN_HALVES, showers: FLAKE_IN_HALVES },
2222
+ }
2223
+ : {
2224
+ ...Object.fromEntries(['cloudy', 'partly-cloudy', 'showers', 'thunder']
2225
+ .map((state) => [state, { rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] }, snow: fallingDrawn(rainIndex) }])),
2226
+ sunny: { rain: RIDING_IN, snow: RIDING_IN },
2227
+ rain: { snow: { tracing: 'rain-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
2228
+ snow: { rain: FLAKE_IN_HALVES },
2229
+ }),
2230
+ };
2231
+ };
2232
+ /** Snow's bottom flakes / drizzle's bottom lines (pieces 15–17): in rain hidden at the bottom of its line, so
2233
+ * rain → snow / drizzle pulls them out of it. */
2234
+ const bottomFlakePiece = (flakeIndex) => {
2235
+ const nothing = pointAt(...SNOWFLAKE_CENTERS[flakeIndex]);
2236
+ const [lineBottomX, lineBottomY] = HEAVY_RAIN_LINES[flakeIndex - 3].to;
2237
+ return {
2238
+ strokes: {
2239
+ sunny: nothing,
2240
+ cloudy: nothing,
2241
+ 'partly-cloudy': nothing,
2242
+ showers: nothing,
2243
+ thunder: nothing,
2244
+ rain: [line(lineBottomX, lineBottomY, lineBottomX, lineBottomY)],
2245
+ snow: SNOWFLAKES[flakeIndex],
2246
+ drizzle: [DRIZZLE_LINES[flakeIndex]],
2247
+ },
2248
+ alternativeStrokes: { 'drizzle-line-out-and-back': outAndBack(DRIZZLE_LINES[flakeIndex]) },
2249
+ pairLayouts: mergePairLayouts(drizzlePairLayouts(flakeIndex, false), {
2250
+ ...Object.fromEntries(['cloudy', 'partly-cloudy', 'showers', 'thunder'].map((state) => [state, { snow: fallingDrawn(flakeIndex) }])),
2251
+ sunny: { snow: RIDING_IN },
2252
+ }),
2253
+ };
2254
+ };
2255
+ const boltTop = pointAt(13, 12);
2256
+ const boltPiece = {
2257
+ strokes: { sunny: boltTop, cloudy: boltTop, 'partly-cloudy': boltTop, showers: boltTop, thunder: BOLT, rain: boltTop },
2258
+ // (snow: like rain, withRainTwins)
2259
+ alternativeStrokes: { 'rain-cloud-left-end': RAIN_CLOUD_LEFT_END },
2260
+ pairLayouts: {
2261
+ cloudy: { thunder: { draw: BOLT_DRAWN } },
2262
+ 'partly-cloudy': { thunder: { draw: BOLT_DRAWN } },
2263
+ showers: { thunder: { draw: BOLT_DRAWN } },
2264
+ // thunder → rain: pulled back up into the cloud
2265
+ rain: { thunder: { draw: BOLT_DRAWN }, sunny: tracedForWipe('rain-cloud-left-end', RAIN_CLOUD_LEFT_END) },
2266
+ // rides in with the crawling cloud; sunny → rain: carries the rain cloud's left end
2267
+ sunny: { thunder: { wipe: 'right', slide: true }, rain: { wipe: 'right', slide: true } },
2268
+ },
2269
+ };
2270
+ const rainTwinnedIcons = withRainTwins({
2271
+ segmentCount: 12,
2272
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2273
+ lineCap: 'round',
2274
+ lineJoin: 'round',
2275
+ strokes: {
2276
+ sunny: SUN_CIRCLE,
2277
+ cloudy: CLOUD,
2278
+ 'partly-cloudy': SMALL_CLOUD,
2279
+ showers: OPEN_SMALL_CLOUD,
2280
+ thunder: THUNDER_CLOUD,
2281
+ rain: RAIN_CLOUD,
2282
+ },
2283
+ alternativeStrokes: {
2284
+ 'cloud-from-rain-gap-middle': CLOUD_FROM_RAIN_GAP_MIDDLE,
2285
+ 'cloud-from-thunder-gap-middle': CLOUD_FROM_THUNDER_GAP_MIDDLE,
2286
+ 'thunder-cloud-from-left': THUNDER_CLOUD_FROM_LEFT,
2287
+ 'rain-cloud-from-left': RAIN_CLOUD_FROM_LEFT,
2288
+ 'cloud-from-left': CLOUD_FROM_LEFT,
2289
+ 'small-cloud-from-left': SMALL_CLOUD_FROM_LEFT,
2290
+ 'cloud-from-gap-middle': [
2291
+ lucideLine(BIG_GAP_MIDDLE_X, 19, 9, 19),
2292
+ ...CLOUD.slice(1),
2293
+ lucideLine(17.5, 19, BIG_GAP_MIDDLE_X, 19),
2294
+ ],
2295
+ 'small-cloud-from-gap-middle': [
2296
+ lucideLine(GAP_MIDDLE_X, 22, 7, 22),
2297
+ ...SMALL_CLOUD.slice(1),
2298
+ lucideLine(13, 22, GAP_MIDDLE_X, 22),
2299
+ ],
2300
+ },
2301
+ pairLayouts: {
2302
+ // every cloud crawls in from the left over the sun
2303
+ sunny: {
2304
+ cloudy: { wipe: 'right', slide: true },
2305
+ 'partly-cloudy': { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
2306
+ showers: { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
2307
+ thunder: { wipe: 'right', slide: true },
2308
+ rain: { wipe: 'right', slide: true },
2309
+ },
2310
+ cloudy: { sunny: tracedForWipe('cloud-from-left', CLOUD_FROM_LEFT), showers: BIG_CLOSED_FOR_OPEN, thunder: CLOUD_FOR_THUNDER, rain: CLOUD_FOR_RAIN },
2311
+ 'partly-cloudy': {
2312
+ sunny: tracedForWipe('small-cloud-from-left', SMALL_CLOUD_FROM_LEFT),
2313
+ showers: CLOSED_FOR_OPEN,
2314
+ thunder: CLOSED_FOR_OPEN,
2315
+ rain: CLOSED_FOR_OPEN,
2316
+ },
2317
+ showers: { 'partly-cloudy': OPEN_FOR_CLOSED, cloudy: OPEN_FOR_CLOSED },
2318
+ rain: {
2319
+ cloudy: OPEN_FOR_CLOSED,
2320
+ 'partly-cloudy': OPEN_FOR_CLOSED,
2321
+ sunny: tracedForWipe('rain-cloud-from-left', RAIN_CLOUD_FROM_LEFT),
2322
+ },
2323
+ thunder: {
2324
+ cloudy: OPEN_FOR_CLOSED,
2325
+ 'partly-cloudy': OPEN_FOR_CLOSED,
2326
+ sunny: tracedForWipe('thunder-cloud-from-left', THUNDER_CLOUD_FROM_LEFT),
2327
+ },
2328
+ },
2329
+ morePieces: [
2330
+ ...[sunCirclePiece, ...RAYS.map(rayPiece)].map(withRainTwins),
2331
+ ...HEAVY_RAIN_LINES.slice(0, 2).map(rainPiece),
2332
+ withRainTwins(boltPiece),
2333
+ rainPiece(HEAVY_RAIN_LINES[2], 2),
2334
+ ...[3, 4, 5].map(bottomFlakePiece),
2335
+ ],
2336
+ spins: {},
2337
+ });
2338
+ // ---- frosty (Lucide "sun-snow"): half a small sun on the left, half a snowflake on the right ----
2339
+ // sunny ↔ frosty: the sun shrinks to its left half (its right half pulls back into the half's ends) while a copy of
2340
+ // that right half (the bolt piece) straightens into the snowflake's spine; the 3 right rays bend into its branches,
2341
+ // the other 5 move to frosty's shorter rays.
2342
+ // frosty → any cloud state (the set's rule): the cloud crawls in from the left and covers everything (the rain etc.
2343
+ // rides along). A wipe needs every piece to show one stretch, so during the crawl the half sun's upper quarter and
2344
+ // the spine (4 x-monotone parts) are carried by 6 spare pieces (a single point at rest, in every state); the 6th
2345
+ // carries the open clouds' lower left end. frosty ↔ partly-cloudy / showers: the half sun becomes their sun arc,
2346
+ // the kept rays move, the rest is covered.
2347
+ /** frosty's half sun, clockwise from its bottom (Lucide: centre 10,12, radius 3). */
2348
+ const HALF_SUN = [lucideArc(10, 12, 3, 90, 270)];
2349
+ /** For the crawl: from its leftmost point down / up (each one stretch for the wipe). */
2350
+ const HALF_SUN_LOWER = [lucideArc(10, 12, 3, 180, 90)];
2351
+ const HALF_SUN_UPPER = [lucideArc(10, 12, 3, 180, 270)];
2352
+ /** frosty's rays by ray piece (outer end first), the right three bent into the snowflake's branches. */
2353
+ const FROSTY_RAYS = [
2354
+ [lucideLine(10, 3, 10, 4)],
2355
+ [lucideLine(18, 6, 15.25, 6.5), lucideLine(15.25, 6.5, 14, 4)],
2356
+ [lucideLine(20, 10, 18.5, 12), lucideLine(18.5, 12, 20, 14)],
2357
+ [lucideLine(18, 18, 15.25, 17.5), lucideLine(15.25, 17.5, 14, 20)],
2358
+ [lucideLine(10, 21, 10, 20)],
2359
+ [lucideLine(3.64, 18.36, 4.34, 17.66)],
2360
+ [lucideLine(2, 12, 3, 12)],
2361
+ [lucideLine(3.64, 5.64, 4.34, 6.34)],
2362
+ ];
2363
+ /** The snowflake's spine as one line: upper arm, out along the middle arm and back, lower arm. */
2364
+ const SPINE = [
2365
+ lucideLine(17, 3, 14, 9), lucideLine(14, 9, 15.5, 12), lucideLine(15.5, 12, 22, 12),
2366
+ lucideLine(22, 12, 15.5, 12), lucideLine(15.5, 12, 14, 15), lucideLine(14, 15, 17, 21),
2367
+ ];
2368
+ /** The spine in parts that each run one way across (for the crawl). */
2369
+ const SPINE_PARTS = [
2370
+ [lucideLine(17, 3, 14, 9)],
2371
+ [lucideLine(14, 9, 15.5, 12), lucideLine(15.5, 12, 22, 12)],
2372
+ [lucideLine(14, 15, 15.5, 12)],
2373
+ [lucideLine(14, 15, 17, 21)],
2374
+ ];
2375
+ // sunny ↔ frosty: the circle traced from its right point — 3 segments of its lower right quarter pull back into the half
2376
+ // sun's bottom end, 6 along the left half, 3 of the upper right quarter into its top end
2377
+ const CIRCLE_FOR_HALF_SUN = {
2378
+ tracing: 'circle-from-right',
2379
+ breakpoints: Array.from({ length: 13 }, (_, index) => +(index / 12).toFixed(4)),
2380
+ };
2381
+ const HALF_SUN_FOR_CIRCLE = { breakpoints: [0, 0, 0, 0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1, 1, 1] };
2382
+ /** The circle's right half (top → right → bottom): the bolt piece's start in sunny → frosty (under the circle). */
2383
+ const CIRCLE_RIGHT_HALF = [lucideArc(12, 12, 4, -90, 90)];
2384
+ const CLOUD_STATES = ['cloudy', 'partly-cloudy', 'showers', 'thunder', 'rain', 'snow', 'drizzle'];
2385
+ /** Cloud states that keep a sun (its arc and 4 rays). */
2386
+ const SUN_CLOUD_STATES = ['partly-cloudy', 'showers'];
2387
+ const isNothing = (strokes) => strokes.length === 1 && strokes[0].kind === 'line' && strokes[0].from.every((value, axis) => value === strokes[0].to[axis]);
2388
+ const POINT_LAYOUT = { tracing: 'nothing', breakpoints: Array.from({ length: 13 }, (_, index) => +(index / 12).toFixed(4)) };
2389
+ /** The open clouds' lower left ends, carried by a spare piece while they crawl in. */
2390
+ const CLOUD_LEFT_ENDS = {
2391
+ thunder: THUNDER_CLOUD_LEFT_END, rain: RAIN_CLOUD_LEFT_END, snow: RAIN_CLOUD_LEFT_END, drizzle: RAIN_CLOUD_LEFT_END,
2392
+ };
2393
+ const COVERED = { wipe: 'left' };
2394
+ /** `pieceSet` with frosty's strokes and `layouts[state][otherState]` added. */
2395
+ const addFrosty = (pieceSet, strokes, layouts, alternativeStrokes = {}) => ({
2396
+ strokes: { ...pieceSet.strokes, frosty: strokes },
2397
+ alternativeStrokes: { ...pieceSet.alternativeStrokes, ...alternativeStrokes },
2398
+ pairLayouts: mergePairLayouts(pieceSet.pairLayouts, layouts),
2399
+ });
2400
+ /** Layouts for the given cloud states X: `frosty.X` and `X.frosty` (either may be left out). */
2401
+ const forCloudStates = (states, frostySide, otherSide) => {
2402
+ const layouts = { frosty: {} };
2403
+ for (const state of states) {
2404
+ const frostyLayout = frostySide?.(state), otherLayout = otherSide?.(state);
2405
+ if (frostyLayout)
2406
+ layouts.frosty[state] = frostyLayout;
2407
+ if (otherLayout)
2408
+ layouts[state] = { frosty: otherLayout };
2409
+ }
2410
+ return layouts;
2411
+ };
2412
+ /** Erasing runs a window backwards: [0, 0.6] erases during 40 … 100 %, with SMALL_CLOUD_CRAWL. */
2413
+ const COVERED_BEFORE_CRAWL_ENDS = [1 - SMALL_CLOUD_CRAWL[1], 1 - SMALL_CLOUD_CRAWL[0]];
2414
+ // frosty ↔ partly-cloudy / showers: the spine's centre sits where the sun arc's right end is — the spine traced
2415
+ // backwards (lower arm, middle arm out and back, upper arm) shrinks into that end with its first 6 segments while
2416
+ // its upper arm unrolls from there up over the top into the arc with the last 6
2417
+ const SPINE_FOR_SUN_ARC = { tracing: 'spine-reversed', breakpoints: [0, 0.3333, 0.6667, 1, 2, 3, 4, 4.5, 5, 5.25, 5.5, 5.75, 6] };
2418
+ const SUN_ARC_FOR_SPINE = { tracing: 'sun-arc-reversed', breakpoints: [0, 0, 0, 0, 0, 0, 0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1] };
2419
+ /** Covered by the crawling cloud — for partly-cloudy / showers in step with their later crawl. */
2420
+ const coveredIn = (state) => (SUN_CLOUD_STATES.includes(state) ? { wipe: 'left', draw: COVERED_BEFORE_CRAWL_ENDS } : { wipe: 'left' });
2421
+ const withFrosty = (iconSet) => {
2422
+ const [cloudPiece, sunPiece, ...otherPieces] = piecesOf(iconSet);
2423
+ const rayPieces = otherPieces.slice(0, 8);
2424
+ const [rain1, rain2, bolt, rain3, ...bottomFlakes] = otherPieces.slice(8);
2425
+ const nothing = pointAt(12, 12);
2426
+ const sparePiece = (layouts, alternativeStrokes) => ({
2427
+ strokes: Object.fromEntries([...Object.keys(iconSet.strokes), 'frosty'].map((state) => [state, nothing])),
2428
+ alternativeStrokes,
2429
+ pairLayouts: layouts,
2430
+ });
2431
+ const coveredStates = CLOUD_STATES.filter((state) => !SUN_CLOUD_STATES.includes(state));
2432
+ const circleFromRight = [lucideArc(12, 12, 4, 0, 360)];
2433
+ const sunnyToFrosty = { sunny: { frosty: CIRCLE_FOR_HALF_SUN }, frosty: { sunny: HALF_SUN_FOR_CIRCLE } };
2434
+ const cloud = {
2435
+ ...iconSet,
2436
+ ...addFrosty(cloudPiece, HALF_SUN, mergePairLayouts(
2437
+ // sunny ↔ frosty: a copy of the sun (under piece 2), morphing like it
2438
+ sunnyToFrosty,
2439
+ // → any cloud: it crawls in exactly as from sunny
2440
+ forCloudStates(CLOUD_STATES, (state) => iconSet.pairLayouts.sunny[state], (state) => iconSet.pairLayouts[state]?.sunny)), { 'circle-from-right': circleFromRight }),
2441
+ };
2442
+ const sun = addFrosty(sunPiece, HALF_SUN, mergePairLayouts(sunnyToFrosty,
2443
+ // covered by the crawling cloud (its upper quarter on a spare piece)
2444
+ forCloudStates(coveredStates, () => tracedForWipe('half-sun-lower', HALF_SUN_LOWER), coveredIn),
2445
+ // partly-cloudy / showers: laid out as the spine (the spares carry the half sun), which bends into the sun arc
2446
+ forCloudStates(SUN_CLOUD_STATES, () => SPINE_FOR_SUN_ARC, () => SUN_ARC_FOR_SPINE)), {
2447
+ 'circle-from-right': circleFromRight,
2448
+ 'half-sun-lower': HALF_SUN_LOWER,
2449
+ 'spine-reversed': SPINE.map(reversedStroke).reverse(),
2450
+ 'sun-arc-reversed': SUN_ARC.map(reversedStroke).reverse(),
2451
+ });
2452
+ const rays = rayPieces.map((rayPiece, rayIndex) => addFrosty(rayPiece, FROSTY_RAYS[rayIndex],
2453
+ // where the cloud state has no such ray: covered, or (partly-cloudy / showers) flying out as from sunny;
2454
+ // kept rays move
2455
+ forCloudStates(CLOUD_STATES.filter((state) => isNothing(rayPiece.strokes[state])), undefined, (state) => (SUN_CLOUD_STATES.includes(state) ? { draw: 0.6 } : COVERED))));
2456
+ // precipitation: rides in with the cloud, as from sunny
2457
+ const precipitation = (pieceSet) => addFrosty(pieceSet, pieceSet.strokes.sunny, forCloudStates(CLOUD_STATES.filter((state) => !isNothing(pieceSet.strokes[state])), (state) => pieceSet.pairLayouts.sunny?.[state] ?? RIDING_IN));
2458
+ const boltWithSpine = addFrosty(bolt, SPINE, mergePairLayouts({ sunny: { frosty: tracedForWipe('circle-right-half', CIRCLE_RIGHT_HALF) } },
2459
+ // during the crawl the spares (or the sun piece) carry the spine; thunder's bolt rides in
2460
+ forCloudStates(CLOUD_STATES, (state) => (state === 'thunder' ? RIDING_IN : POINT_LAYOUT))), { 'circle-right-half': CIRCLE_RIGHT_HALF, nothing: [lucideLine(12, 12, 12, 12)] });
2461
+ const spares = [
2462
+ // the spine's parts (covered); the first also carries the half sun's lower quarter for partly-cloudy / showers
2463
+ ...SPINE_PARTS.map((part, partIndex) => sparePiece(mergePairLayouts(forCloudStates(coveredStates, () => tracedForWipe(`spine-part-${partIndex}`, part), coveredIn), partIndex === 0 ? forCloudStates(SUN_CLOUD_STATES, () => tracedForWipe('half-sun-lower', HALF_SUN_LOWER), coveredIn) : {}), { [`spine-part-${partIndex}`]: part, 'half-sun-lower': HALF_SUN_LOWER })),
2464
+ // the half sun's upper quarter
2465
+ sparePiece(forCloudStates(CLOUD_STATES, () => tracedForWipe('half-sun-upper', HALF_SUN_UPPER), coveredIn), { 'half-sun-upper': HALF_SUN_UPPER }),
2466
+ // the open clouds' lower left end
2467
+ sparePiece(forCloudStates(Object.keys(CLOUD_LEFT_ENDS), () => RIDING_IN, (state) => tracedForWipe(`${state}-cloud-left-end`, CLOUD_LEFT_ENDS[state])), Object.fromEntries(Object.entries(CLOUD_LEFT_ENDS).map(([state, end]) => [`${state}-cloud-left-end`, end]))),
2468
+ ];
2469
+ return {
2470
+ ...cloud,
2471
+ morePieces: [
2472
+ sun, ...rays, precipitation(rain1), precipitation(rain2), boltWithSpine, precipitation(rain3),
2473
+ ...bottomFlakes.map(precipitation), ...spares,
2474
+ ],
2475
+ };
2476
+ };
2477
+ const weatherIcons = withFrosty(rainTwinnedIcons);
2478
+ return { weatherIcons };
2479
+ })();
2480
+
2481
+ // sets/eye.ts
2482
+ const { eyeIcons } = (() => {
2483
+ // the lids: top lid = arc around 12,15.7506 (r 10.75), bottom lid = arc around 12,8.2494; the corners: small arcs (r 1)
2484
+ const topLid = (startAngle, endAngle) => lucideArc(12, 15.7506, 10.75, startAngle, endAngle);
2485
+ const bottomLid = (startAngle, endAngle) => lucideArc(12, 8.2494, 10.75, startAngle, endAngle);
2486
+ const RIGHT_CORNER = lucideArc(21.0005, 12, 1, -20.365, 20.365);
2487
+ const LEFT_CORNER = lucideArc(2.9995, 12, 1, 159.635, 200.365);
2488
+ /** The middles of eye-off's gaps: on the top lid (between -120.724° and -96.796°) and the bottom lid (37.8° … 59.4°). */
2489
+ const TOP_GAP_MIDDLE = (-120.724 - 96.796) / 2;
2490
+ const BOTTOM_GAP_MIDDLE = (37.8063 + 59.3592) / 2;
2491
+ // each part: lid, corner, lid — 6 + 2 + 4 segments in both states, so the corner stays and the lids only shorten
2492
+ const PART_LAYOUT = { breakpoints: [0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1.5, 2, 2.25, 2.5, 2.75, 3] };
2493
+ const rightPart = {
2494
+ strokes: {
2495
+ visible: [topLid(TOP_GAP_MIDDLE, -22.4122), RIGHT_CORNER, bottomLid(22.4122, BOTTOM_GAP_MIDDLE)],
2496
+ hidden: [topLid(-96.796, -22.4122), RIGHT_CORNER, bottomLid(22.4122, 37.8063)],
2497
+ },
2498
+ alternativeStrokes: {},
2499
+ pairLayouts: { visible: { hidden: PART_LAYOUT }, hidden: { visible: PART_LAYOUT } },
2500
+ };
2501
+ const leftPart = {
2502
+ strokes: {
2503
+ visible: [bottomLid(BOTTOM_GAP_MIDDLE, 157.5878), LEFT_CORNER, topLid(-157.5878, TOP_GAP_MIDDLE)],
2504
+ hidden: [bottomLid(59.3592, 157.5878), LEFT_CORNER, topLid(-157.5878, -120.724)],
2505
+ },
2506
+ alternativeStrokes: {},
2507
+ pairLayouts: { visible: { hidden: PART_LAYOUT }, hidden: { visible: PART_LAYOUT } },
2508
+ };
2509
+ /** eye-off's pupil: the half circle clockwise from 46° (lower right) to 224° (upper left). */
2510
+ const HALF_PUPIL = [lucideArc(12, 12, 3, 45.9957, 224.0043)];
2511
+ /** The middle of the pupil's gap (upper right), from where the gap pulls back into the half's ends. */
2512
+ const PUPIL_GAP_MIDDLE = (224.0043 + 405.9957) / 2;
2513
+ const GAP_HALF_SHARE = (405.9957 - 224.0043) / 2 / 360;
2514
+ const pupil = {
2515
+ strokes: { visible: [lucideArc(12, 12, 3, -90, 270)], hidden: HALF_PUPIL },
2516
+ alternativeStrokes: { 'pupil-from-gap-middle': [lucideArc(12, 12, 3, PUPIL_GAP_MIDDLE, PUPIL_GAP_MIDDLE + 360)] },
2517
+ pairLayouts: {
2518
+ // 3 segments per gap half, 6 along the half circle (it stays)
2519
+ visible: {
2520
+ hidden: {
2521
+ tracing: 'pupil-from-gap-middle',
2522
+ breakpoints: [
2523
+ ...[0, 1, 2].map((index) => (index / 3) * GAP_HALF_SHARE),
2524
+ ...[0, 1, 2, 3, 4, 5].map((index) => GAP_HALF_SHARE + (index / 6) * (1 - 2 * GAP_HALF_SHARE)),
2525
+ ...[0, 1, 2, 3].map((index) => 1 - GAP_HALF_SHARE + (index / 3) * GAP_HALF_SHARE),
2526
+ ].map((breakpoint) => +breakpoint.toFixed(4)),
2527
+ },
2528
+ },
2529
+ hidden: { visible: { breakpoints: [0, 0, 0, 0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1, 1, 1] } },
2530
+ },
2531
+ };
2532
+ const slash = {
2533
+ strokes: { visible: lucidePoint(2, 2), hidden: LUCIDE_SLASH },
2534
+ alternativeStrokes: {},
2535
+ pairLayouts: { visible: { hidden: { draw: true } } },
2536
+ };
2537
+ const eyeIcons = {
2538
+ segmentCount: 12,
2539
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2540
+ lineCap: 'round',
2541
+ lineJoin: 'round',
2542
+ ...rightPart,
2543
+ morePieces: [leftPart, pupil, slash],
2544
+ spins: {},
2545
+ };
2546
+ return { eyeIcons };
2547
+ })();
2548
+
2549
+ // sets/volume.ts
2550
+ const { volumeIcons } = (() => {
2551
+ /** Lucide's speaker, from the top of its front edge counterclockwise around. */
2552
+ const SPEAKER = [
2553
+ lucideArc(10.295, 4.703, 0.705, -0.0828, -134.9415),
2554
+ lucideLine(9.797, 4.204, 6.413, 7.587),
2555
+ lucideArc(5.4201, 6.6, 1.4, 44.8292, 90.168),
2556
+ lucideLine(5.416, 8, 3, 8),
2557
+ lucideArc(3, 9, 1, -90, -180),
2558
+ lucideLine(2, 9, 2, 15),
2559
+ lucideArc(3, 15, 1, 180, 90),
2560
+ lucideLine(3, 16, 5.416, 16),
2561
+ lucideArc(5.4201, 17.4, 1.4, -90.168, -44.8292),
2562
+ lucideLine(6.413, 16.413, 9.796, 19.797),
2563
+ lucideArc(10.295, 19.299, 0.705, 135.0564, -0.0797),
2564
+ lucideLine(11, 19.298, 11, 4.702),
2565
+ ];
2566
+ /** The waves, each from its top end down. */
2567
+ const SMALL_WAVE = [lucideArc(12, 12, 5, -36.8699, 36.8699)];
2568
+ const BIG_WAVE = [lucideArc(13.0001, 12, 9, -45.0004, 45.0004)];
2569
+ /** The ✕, each line from its top end down (so a wave unfolds into it without turning over). */
2570
+ const X_SLASH = [lucideLine(21.5, 9.5, 16.5, 14.5)];
2571
+ const X_BACKSLASH = [lucideLine(16.5, 9.5, 21.5, 14.5)];
2572
+ /** No wave: a point on the middle of the speaker's front edge. */
2573
+ const NO_WAVE = lucidePoint(11, 12);
2574
+ const speaker = {
2575
+ strokes: { muted: SPEAKER, low: SPEAKER, medium: SPEAKER, high: SPEAKER },
2576
+ alternativeStrokes: {},
2577
+ pairLayouts: {},
2578
+ };
2579
+ const smallWave = {
2580
+ strokes: { muted: X_SLASH, low: NO_WAVE, medium: SMALL_WAVE, high: SMALL_WAVE },
2581
+ alternativeStrokes: {},
2582
+ pairLayouts: {},
2583
+ };
2584
+ const bigWave = {
2585
+ strokes: { muted: X_BACKSLASH, low: NO_WAVE, medium: SMALL_WAVE, high: BIG_WAVE },
2586
+ alternativeStrokes: {},
2587
+ pairLayouts: {},
2588
+ };
2589
+ const volumeIcons = {
2590
+ segmentCount: 16,
2591
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2592
+ lineCap: 'round',
2593
+ lineJoin: 'round',
2594
+ ...speaker,
2595
+ morePieces: [smallWave, bigWave],
2596
+ spins: {},
2597
+ };
2598
+ return { volumeIcons };
2599
+ })();
2600
+
2601
+ // sets/toggle.ts
2602
+ const { toggleIcons } = (() => {
2603
+ const HORIZONTAL = [lucideLine(5, 12, 19, 12)];
2604
+ const first = {
2605
+ strokes: {
2606
+ plus: HORIZONTAL,
2607
+ minus: HORIZONTAL,
2608
+ close: [lucideLine(6, 6, 18, 18)],
2609
+ check: [lucideLine(4, 12, 9, 17)],
2610
+ },
2611
+ alternativeStrokes: {},
2612
+ pairLayouts: {},
2613
+ };
2614
+ const second = {
2615
+ strokes: {
2616
+ plus: [lucideLine(12, 5, 12, 19)],
2617
+ minus: HORIZONTAL,
2618
+ close: [lucideLine(18, 6, 6, 18)],
2619
+ check: [lucideLine(20, 6, 9, 17)],
2620
+ },
2621
+ alternativeStrokes: {},
2622
+ pairLayouts: {},
2623
+ };
2624
+ const toggleIcons = {
2625
+ segmentCount: 1,
2626
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2627
+ lineCap: 'round',
2628
+ lineJoin: 'round',
2629
+ ...first,
2630
+ morePieces: [second],
2631
+ // plus turned by 45° clockwise is the ✕ (a little smaller): turn while blending
2632
+ spins: { plus: { close: 45 }, close: { plus: -45 } },
2633
+ };
2634
+ return { toggleIcons };
2635
+ })();
2636
+
2637
+ // sets/lock.ts
2638
+ const { lockIcons } = (() => {
2639
+ const BODY = [
2640
+ lucideLine(5, 11, 19, 11),
2641
+ lucideArc(19, 13, 2, -90, 0),
2642
+ lucideLine(21, 13, 21, 20),
2643
+ lucideArc(19, 20, 2, 0, 90),
2644
+ lucideLine(19, 22, 5, 22),
2645
+ lucideArc(5, 20, 2, 90, 180),
2646
+ lucideLine(3, 20, 3, 13),
2647
+ lucideArc(5, 13, 2, 180, 270),
2648
+ ];
2649
+ const LEFT_LEG = lucideLine(7, 11, 7, 7);
2650
+ const body = { strokes: { locked: BODY, unlocked: BODY }, alternativeStrokes: {}, pairLayouts: {} };
2651
+ // left leg 2 segments, arch 6, right leg 4 — unlocked has no right leg: 4 zero-length segments at the arch's end
2652
+ const shackle = {
2653
+ strokes: {
2654
+ locked: [LEFT_LEG, lucideArc(12, 7, 5, 180, 360), lucideLine(17, 7, 17, 11)],
2655
+ unlocked: [LEFT_LEG, lucideArc(12, 6.995, 5, 179.9424, 348.5218)],
2656
+ },
2657
+ alternativeStrokes: {},
2658
+ pairLayouts: {
2659
+ locked: { unlocked: { breakpoints: [0, 0.5, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2.25, 2.5, 2.75, 3] } },
2660
+ unlocked: { locked: { breakpoints: [0, 0.5, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2, 2, 2] } },
2661
+ },
2662
+ };
2663
+ const lockIcons = {
2664
+ segmentCount: 12,
2665
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2666
+ lineCap: 'round',
2667
+ lineJoin: 'round',
2668
+ ...body,
2669
+ morePieces: [shackle],
2670
+ spins: {},
2671
+ };
2672
+ return { lockIcons };
2673
+ })();
2674
+
2675
+ // sets/bell.ts
2676
+ const { bellIcons } = (() => {
2677
+ const CLAPPER = [lucideArc(12, 19.9999, 2, 149.9971, 30.0029)];
2678
+ /** Where the dome is split in "on": the middle of bell-off's gap on it (196.86° … 236.27°). */
2679
+ const DOME_GAP_MIDDLE = (196.8632 + 236.2663) / 2;
2680
+ const dome = (startAngle, endAngle) => lucideArc(12, 8, 6, startAngle, endAngle);
2681
+ const clapper = { strokes: { on: CLAPPER, off: CLAPPER }, alternativeStrokes: {}, pairLayouts: {} };
2682
+ // bottom line (3 segments), left tip (2), left side (4), dome (3) — the same in both states
2683
+ const LEFT_LAYOUT = { breakpoints: [0, 0.3333, 0.6667, 1, 1.5, 2, 2.25, 2.5, 2.75, 3, 3.3333, 3.6667, 4] };
2684
+ const leftPart = {
2685
+ strokes: {
2686
+ on: [
2687
+ lucideLine(20, 17, 4, 17),
2688
+ lucideArc(4.0007, 16, 1, 90.0419, 222.3766),
2689
+ lucideCubic(3.262, 15.326, 4.589, 13.956, 6, 12.499, 6, 8),
2690
+ dome(180, DOME_GAP_MIDDLE),
2691
+ ],
2692
+ off: [
2693
+ lucideLine(17, 17, 4, 17),
2694
+ lucideArc(3.9996, 16, 1, 89.9795, 222.299),
2695
+ lucideCubic(3.26, 15.327, 4.59, 13.956, 6, 12.499, 6, 8),
2696
+ lucideArc(12, 7.9985, 6, 179.9859, 196.8632),
2697
+ ],
2698
+ },
2699
+ alternativeStrokes: {},
2700
+ pairLayouts: { on: { off: LEFT_LAYOUT }, off: { on: LEFT_LAYOUT } },
2701
+ };
2702
+ // dome (4 segments), right side (6), right tip (2) — off has no tip and only the side's upper part: 2 zero-length
2703
+ // segments at its end
2704
+ const rightPart = {
2705
+ strokes: {
2706
+ on: [
2707
+ dome(DOME_GAP_MIDDLE, 360),
2708
+ lucideCubic(18, 8, 18, 12.499, 19.41, 13.956, 20.74, 15.327),
2709
+ lucideArc(20.0004, 16, 1, -42.299, 90.0205),
2710
+ ],
2711
+ off: [
2712
+ lucideArc(12, 7.9998, 6, -123.7337, 0.0022),
2713
+ lucideCubic(18, 8, 18, 10.687, 18.77, 12.653, 19.707, 14.05),
2714
+ ],
2715
+ },
2716
+ alternativeStrokes: {},
2717
+ pairLayouts: {
2718
+ on: { off: { breakpoints: [0, 0.25, 0.5, 0.75, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2.5, 3] } },
2719
+ off: { on: { breakpoints: [0, 0.25, 0.5, 0.75, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2] } },
2720
+ },
2721
+ };
2722
+ const slash = {
2723
+ strokes: { on: lucidePoint(2, 2), off: LUCIDE_SLASH },
2724
+ alternativeStrokes: {},
2725
+ pairLayouts: { on: { off: { draw: true } } },
2726
+ };
2727
+ const bellIcons = {
2728
+ segmentCount: 12,
2729
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2730
+ lineCap: 'round',
2731
+ lineJoin: 'round',
2732
+ ...clapper,
2733
+ morePieces: [leftPart, rightPart, slash],
2734
+ spins: {},
2735
+ };
2736
+ return { bellIcons };
2737
+ })();
2738
+
2739
+ // sets/mic.ts
2740
+ const { micIcons } = (() => {
2741
+ const STAND = [lucideLine(12, 19, 12, 22)];
2742
+ /** Where the mic's capsule is split: the middles of mic-off's gaps (right side y 11.85, left side y 6.3). */
2743
+ const RIGHT_SPLIT_Y = 11.85;
2744
+ const LEFT_SPLIT_Y = 6.3;
2745
+ /** Where the holder is split: the middle of its gap (10.17° … 45°). */
2746
+ const HOLDER_GAP_MIDDLE = (10.1709 + 44.9971) / 2;
2747
+ const stand = { strokes: { on: STAND, off: STAND }, alternativeStrokes: {}, pairLayouts: {} };
2748
+ // line, arc, line — 3 + 6 + 3 segments; off has no last line: 3 zero-length segments at its end
2749
+ const SPLIT_LAYOUT = { breakpoints: [0, 0.3333, 0.6667, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2.3333, 2.6667, 3] };
2750
+ const CUT_LAYOUT = { breakpoints: [0, 0.3333, 0.6667, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2, 2] };
2751
+ const capsuleTop = {
2752
+ strokes: {
2753
+ // up the right side, over the top (counterclockwise), down the left side to the split
2754
+ on: [lucideLine(15, RIGHT_SPLIT_Y, 15, 5), lucideArc(12, 5, 3, 0, -180), lucideLine(9, 5, 9, LEFT_SPLIT_Y)],
2755
+ off: [lucideLine(15, 9.34, 15, 5), lucideArc(12.0001, 5.0181, 3, -0.3452, -153.2975)],
2756
+ },
2757
+ alternativeStrokes: {},
2758
+ pairLayouts: { on: { off: SPLIT_LAYOUT }, off: { on: CUT_LAYOUT } },
2759
+ };
2760
+ const capsuleBottom = {
2761
+ strokes: {
2762
+ // down the left side, around the bottom (counterclockwise), up the right side to the split
2763
+ on: [lucideLine(9, LEFT_SPLIT_Y, 9, 12), lucideArc(12, 12, 3, 180, 0), lucideLine(15, 12, 15, RIGHT_SPLIT_Y)],
2764
+ off: [lucideLine(9, 9, 9, 12), lucideArc(12, 11.9974, 3, 179.9496, 45.0356)],
2765
+ },
2766
+ alternativeStrokes: {},
2767
+ pairLayouts: { on: { off: SPLIT_LAYOUT }, off: { on: CUT_LAYOUT } },
2768
+ };
2769
+ // arc then line, 6 + 6 segments in both states (the arc only shortens)
2770
+ const HOLDER_LAYOUT = { breakpoints: [0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2] };
2771
+ const holderLeft = {
2772
+ strokes: {
2773
+ on: [lucideArc(12, 12, 7, HOLDER_GAP_MIDDLE, 180), lucideLine(5, 12, 5, 10)],
2774
+ off: [lucideArc(12, 12.0005, 7, 44.9971, 180.0041), lucideLine(5, 12, 5, 10)],
2775
+ },
2776
+ alternativeStrokes: {},
2777
+ pairLayouts: { on: { off: HOLDER_LAYOUT }, off: { on: HOLDER_LAYOUT } },
2778
+ };
2779
+ const holderRight = {
2780
+ strokes: {
2781
+ on: [lucideArc(12, 12, 7, HOLDER_GAP_MIDDLE, 0), lucideLine(19, 12, 19, 10)],
2782
+ off: [lucideArc(12, 11.9939, 7, 10.1709, 0.0499), lucideLine(19, 12, 19, 10)],
2783
+ },
2784
+ alternativeStrokes: {},
2785
+ pairLayouts: { on: { off: HOLDER_LAYOUT }, off: { on: HOLDER_LAYOUT } },
2786
+ };
2787
+ const slash = {
2788
+ strokes: { on: lucidePoint(2, 2), off: LUCIDE_SLASH },
2789
+ alternativeStrokes: {},
2790
+ pairLayouts: { on: { off: { draw: true } } },
2791
+ };
2792
+ const micIcons = {
2793
+ segmentCount: 12,
2794
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2795
+ lineCap: 'round',
2796
+ lineJoin: 'round',
2797
+ ...stand,
2798
+ morePieces: [capsuleTop, capsuleBottom, holderLeft, holderRight, slash],
2799
+ spins: {},
2800
+ };
2801
+ return { micIcons };
2802
+ })();
2803
+
2804
+ // sets/chevron.ts
2805
+ const { chevronIcons } = (() => {
2806
+ const STATES = ['up', 'right', 'down', 'left'];
2807
+ /** chevron-up's corners (right end, tip, left end), turned clockwise by `quarterTurns` × 90° around 12,12. */
2808
+ const chevron = (quarterTurns) => {
2809
+ const turn = ([x, y]) => {
2810
+ let [dx, dy] = [x - 12, y - 12];
2811
+ for (let step = 0; step < quarterTurns; step++)
2812
+ [dx, dy] = [-dy, dx];
2813
+ return [12 + dx, 12 + dy];
2814
+ };
2815
+ const [rightEnd, tip, leftEnd] = [[18, 15], [12, 9], [6, 15]].map(turn);
2816
+ return [lucideLine(...rightEnd, ...tip), lucideLine(...tip, ...leftEnd)];
2817
+ };
2818
+ /** Degrees from one state to another: the shorter way round (half a turn: clockwise). */
2819
+ const turnBetween = (fromIndex, toIndex) => {
2820
+ const quarterTurns = (toIndex - fromIndex + 4) % 4;
2821
+ return quarterTurns === 3 ? -90 : quarterTurns * 90;
2822
+ };
2823
+ const chevronIcons = {
2824
+ segmentCount: 2,
2825
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2826
+ lineCap: 'round',
2827
+ lineJoin: 'round',
2828
+ strokes: Object.fromEntries(STATES.map((state, index) => [state, chevron(index)])),
2829
+ alternativeStrokes: {},
2830
+ pairLayouts: {},
2831
+ spins: Object.fromEntries(STATES.map((state, fromIndex) => [state, Object.fromEntries(STATES
2832
+ .filter((other) => other !== state).map((other) => [other, turnBetween(fromIndex, STATES.indexOf(other))]))])),
2833
+ };
2834
+ return { chevronIcons };
2835
+ })();
2836
+
2837
+ // sets/copy.ts
2838
+ const { copyIcons } = (() => {
2839
+ /** The front page, clockwise from its top edge. */
2840
+ const FRONT_PAGE = [
2841
+ lucideLine(10, 8, 20, 8),
2842
+ lucideArc(20, 10, 2, -90, 0),
2843
+ lucideLine(22, 10, 22, 20),
2844
+ lucideArc(20, 20, 2, 0, 90),
2845
+ lucideLine(20, 22, 10, 22),
2846
+ lucideArc(10, 20, 2, 90, 180),
2847
+ lucideLine(8, 20, 8, 10),
2848
+ lucideArc(10, 10, 2, 180, 270),
2849
+ ];
2850
+ /** The back page: the L that shows behind the front page, from its bottom end up and over. */
2851
+ const BACK_PAGE = [
2852
+ lucideCubic(4, 16, 2.9, 16, 2, 15.1, 2, 14),
2853
+ lucideLine(2, 14, 2, 4),
2854
+ lucideCubic(2, 4, 2, 2.9, 2.9, 2, 4, 2),
2855
+ lucideLine(4, 2, 14, 2),
2856
+ lucideCubic(14, 2, 15.1, 2, 16, 2.9, 16, 4),
2857
+ ];
2858
+ const CHECK = [lucideLine(20, 6, 9, 17), lucideLine(9, 17, 4, 12)];
2859
+ /** The check as a loop matching the page: corner → long end → corner → short end → corner. */
2860
+ const CHECK_LOOP = [lucideLine(9, 17, 20, 6), lucideLine(20, 6, 9, 17), lucideLine(9, 17, 4, 12), lucideLine(4, 12, 9, 17)];
2861
+ const page = {
2862
+ strokes: { copy: FRONT_PAGE, copied: CHECK },
2863
+ alternativeStrokes: { 'check-loop': CHECK_LOOP },
2864
+ pairLayouts: {
2865
+ // the page's default spread: 2 segments per edge, 1 per corner; each edge → one stroke of the loop, each
2866
+ // corner → a zero-length segment at its end
2867
+ copied: { copy: { tracing: 'check-loop', breakpoints: [0, 0.5, 1, 1, 1.5, 2, 2, 2.5, 3, 3, 3.5, 4, 4] } },
2868
+ },
2869
+ };
2870
+ const backPage = {
2871
+ strokes: { copy: BACK_PAGE, copied: lucidePoint(4, 16) },
2872
+ alternativeStrokes: {},
2873
+ pairLayouts: { copied: { copy: { draw: 0.5 } } },
2874
+ };
2875
+ const copyIcons = {
2876
+ segmentCount: 12,
2877
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2878
+ lineCap: 'round',
2879
+ lineJoin: 'round',
2880
+ ...page,
2881
+ morePieces: [backPage],
2882
+ spins: {},
2883
+ };
2884
+ return { copyIcons };
2885
+ })();
2886
+
2887
+ // sets/wifi.ts
2888
+ const { wifiIcons } = (() => {
2889
+ const STATES = ['none', 'low', 'medium', 'high'];
2890
+ const DOT = [lucideLine(12, 20, 12.01, 20)];
2891
+ /** The arcs, small to big, each clockwise from its left end. */
2892
+ const ARCS = [
2893
+ [lucideArc(12, 19.9997, 5, -134.427, -45.573)],
2894
+ [lucideArc(12, 20.0004, 10, -134.427, -45.573)],
2895
+ [lucideArc(12, 20.0003, 15, -131.8103, -48.1897)],
2896
+ ];
2897
+ const NO_ARC = lucidePoint(12, 20);
2898
+ /** Arc `arcIndex` in each state: itself, else the outermost arc that state has (or a point on the dot). */
2899
+ const arcPiece = (arcIndex) => ({
2900
+ strokes: Object.fromEntries(STATES.map((state, level) => [state, level > arcIndex ? ARCS[arcIndex] : level > 0 ? ARCS[level - 1] : NO_ARC])),
2901
+ alternativeStrokes: {},
2902
+ pairLayouts: {},
2903
+ });
2904
+ const wifiIcons = {
2905
+ segmentCount: 4,
2906
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2907
+ lineCap: 'round',
2908
+ lineJoin: 'round',
2909
+ strokes: Object.fromEntries(STATES.map((state) => [state, DOT])),
2910
+ alternativeStrokes: {},
2911
+ pairLayouts: {},
2912
+ morePieces: [0, 1, 2].map(arcPiece),
2913
+ spins: {},
2914
+ };
2915
+ return { wifiIcons };
2916
+ })();
2917
+
2918
+ // sets/battery.ts
2919
+ const { batteryIcons } = (() => {
2920
+ const STATES = ['empty', 'low', 'medium', 'full'];
2921
+ const BODY = [
2922
+ lucideLine(4, 6, 16, 6),
2923
+ lucideArc(16, 8, 2, -90, 0),
2924
+ lucideLine(18, 8, 18, 16),
2925
+ lucideArc(16, 16, 2, 0, 90),
2926
+ lucideLine(16, 18, 4, 18),
2927
+ lucideArc(4, 16, 2, 90, 180),
2928
+ lucideLine(2, 16, 2, 8),
2929
+ lucideArc(4, 8, 2, 180, 270),
2930
+ ];
2931
+ const TERMINAL = [lucideLine(22, 14, 22, 10)];
2932
+ const BAR_X = [6, 10, 14];
2933
+ /**
2934
+ * When bar `barIndex` is drawn in from `fromLevel` to `toLevel` (more bars): the bars coming in share the morph,
2935
+ * each taking 2 / (count + 1) of it, starting half of that after the one before.
2936
+ */
2937
+ const barDrawn = (barIndex, fromLevel, toLevel) => {
2938
+ const count = toLevel - fromLevel;
2939
+ const duration = 2 / (count + 1);
2940
+ const start = (barIndex - fromLevel) * (duration / 2);
2941
+ return { draw: [+start.toFixed(4), +(start + duration).toFixed(4)] };
2942
+ };
2943
+ const barPiece = (barIndex) => ({
2944
+ strokes: Object.fromEntries(STATES.map((state, level) => [state, level > barIndex
2945
+ ? [lucideLine(BAR_X[barIndex], 14, BAR_X[barIndex], 10)]
2946
+ : lucidePoint(BAR_X[barIndex], 14)])),
2947
+ alternativeStrokes: {},
2948
+ // on the state without the bar, towards each state with it
2949
+ pairLayouts: Object.fromEntries(STATES.slice(0, barIndex + 1).map((state, fromLevel) => [state, Object.fromEntries(STATES.slice(barIndex + 1).map((other, offset) => [other, barDrawn(barIndex, fromLevel, barIndex + 1 + offset)]))])),
2950
+ });
2951
+ const fixedPiece = (strokes) => ({
2952
+ strokes: Object.fromEntries(STATES.map((state) => [state, strokes])),
2953
+ alternativeStrokes: {},
2954
+ pairLayouts: {},
2955
+ });
2956
+ const batteryIcons = {
2957
+ segmentCount: 12,
2958
+ strokeWidth: LUCIDE_STROKE_WIDTH,
2959
+ lineCap: 'round',
2960
+ lineJoin: 'round',
2961
+ ...fixedPiece(BODY),
2962
+ morePieces: [fixedPiece(TERMINAL), ...[0, 1, 2].map(barPiece)],
2963
+ spins: {},
2964
+ };
2965
+ return { batteryIcons };
2966
+ })();
2967
+
1075
2968
  // iconSets.ts
1076
2969
  const { iconSets, resolveIconSet } = (() => {
1077
2970
  // The built-in icon sets, by name. `round` is the default.
@@ -1081,20 +2974,50 @@ const iconSets = {
1081
2974
  condensed: condensedDigits,
1082
2975
  slim: slimDigits,
1083
2976
  handwritten: handwrittenDigits,
2977
+ media: mediaIcons,
2978
+ menu: menuIcons,
2979
+ checkbox: checkboxIcons,
2980
+ radio: radioIcons,
2981
+ folder: folderIcons,
2982
+ theme: themeIcons,
2983
+ weather: weatherIcons,
2984
+ eye: eyeIcons,
2985
+ volume: volumeIcons,
2986
+ toggle: toggleIcons,
2987
+ lock: lockIcons,
2988
+ bell: bellIcons,
2989
+ mic: micIcons,
2990
+ chevron: chevronIcons,
2991
+ copy: copyIcons,
2992
+ wifi: wifiIcons,
2993
+ battery: batteryIcons,
1084
2994
  };
1085
- /** A set given by name or as a set object. */
1086
- const resolveIconSet = (set = 'round') => {
1087
- if (typeof set !== 'string')
1088
- return set;
1089
- if (!(set in iconSets))
2995
+ // sets with sharper corners, built once per set and roundness (so their shapes are computed once too)
2996
+ const setsByRoundness = new WeakMap();
2997
+ /** A set given by name or as a set object, optionally with sharper corners (`roundness` 0 = sharp … 1 = own). */
2998
+ const resolveIconSet = (set = 'round', roundness) => {
2999
+ if (typeof set === 'string' && !(set in iconSets)) {
1090
3000
  throw new Error(`morphico: unknown icon set "${set}" (known: ${Object.keys(iconSets).join(', ')})`);
1091
- return iconSets[set];
3001
+ }
3002
+ const iconSet = typeof set === 'string' ? iconSets[set] : set;
3003
+ if (roundness === undefined || roundness >= 1)
3004
+ return iconSet; // the own corners are the roundest
3005
+ if (!iconSet.withRoundness)
3006
+ throw new Error('morphico: this icon set has no adjustable roundness');
3007
+ const clamped = Math.max(0, roundness);
3008
+ let byRoundness = setsByRoundness.get(iconSet);
3009
+ if (!byRoundness)
3010
+ setsByRoundness.set(iconSet, byRoundness = new Map());
3011
+ let sharper = byRoundness.get(clamped);
3012
+ if (!sharper)
3013
+ byRoundness.set(clamped, sharper = iconSet.withRoundness(clamped));
3014
+ return sharper;
1092
3015
  };
1093
3016
  return { iconSets, resolveIconSet };
1094
3017
  })();
1095
3018
 
1096
3019
  // morph.ts
1097
- const { EMPTY_STATE, hasState, createMorphingPath } = (() => {
3020
+ const { EMPTY_STATE, DEFAULT_DURATION, shapesOf, hasState, prefersReducedMotion, clampToUnit, createMorphingPath } = (() => {
1098
3021
  // One SVG <path> that morphs between the states of an icon set. Shared by icon() and every text() cell.
1099
3022
  /** The empty state: nothing is drawn. Shapes grow out of it and shrink into it while fading. */
1100
3023
  const EMPTY_STATE = ' ';
@@ -1120,6 +3043,7 @@ const blend = (from, to, progress, spin) => (spin
1120
3043
  const clampToUnit = (value) => Math.min(Math.max(value, 0), 1);
1121
3044
  const createMorphingPath = (pathElement, iconSet, initialState) => {
1122
3045
  const shapes = shapesOf(iconSet);
3046
+ const pieceLength = pieceLengthOf(iconSet);
1123
3047
  let state = initialState;
1124
3048
  let currentPath = null; // null while empty
1125
3049
  let currentOpacity = 0;
@@ -1128,7 +3052,7 @@ const createMorphingPath = (pathElement, iconSet, initialState) => {
1128
3052
  currentPath = path;
1129
3053
  currentOpacity = path ? clampToUnit(opacity) : 0;
1130
3054
  if (path)
1131
- pathElement.setAttribute('d', toPathData(path));
3055
+ pathElement.setAttribute('d', toPathData(path, pieceLength));
1132
3056
  else
1133
3057
  pathElement.removeAttribute('d');
1134
3058
  if (currentOpacity < 1)
@@ -1175,6 +3099,8 @@ const createMorphingPath = (pathElement, iconSet, initialState) => {
1175
3099
  const from = start ?? scaleAroundCenter(target, EMPTY_SCALE);
1176
3100
  const to = target ?? scaleAroundCenter(start, EMPTY_SCALE);
1177
3101
  const spin = (settled && iconSet.spins[previousState]?.[nextState]) || 0;
3102
+ // pieces that appear or go are drawn along their path (only from a settled shape, between two real states)
3103
+ const drawn = settled && start && target ? drawnPieces(iconSet, previousState, nextState) : [];
1178
3104
  const ease = typeof easing === 'function' ? easing : easings[easing];
1179
3105
  const startTime = performance.now();
1180
3106
  return new Promise((resolve) => {
@@ -1182,7 +3108,8 @@ const createMorphingPath = (pathElement, iconSet, initialState) => {
1182
3108
  const progress = clampToUnit((frameTime - startTime) / duration);
1183
3109
  if (progress < 1) {
1184
3110
  const easedProgress = ease(progress);
1185
- render(blend(from, to, easedProgress, spin), startOpacity + (endOpacity - startOpacity) * easedProgress);
3111
+ const frame = withDrawnPieces(blend(from, to, easedProgress, spin), from, to, easedProgress, pieceLength, drawn);
3112
+ render(frame, startOpacity + (endOpacity - startOpacity) * easedProgress);
1186
3113
  return true;
1187
3114
  }
1188
3115
  render(target, endOpacity);
@@ -1195,28 +3122,748 @@ const createMorphingPath = (pathElement, iconSet, initialState) => {
1195
3122
  stop: () => finish(true),
1196
3123
  };
1197
3124
  };
1198
- return { EMPTY_STATE, hasState, createMorphingPath };
3125
+ return { EMPTY_STATE, DEFAULT_DURATION, shapesOf, hasState, prefersReducedMotion, clampToUnit, createMorphingPath };
3126
+ })();
3127
+
3128
+ // sets/shapes.ts
3129
+ const { lucideShapeOutlines, clipShapes } = (() => {
3130
+ // Shapes for clip(): closed outlines that cut out an element. From Lucide 1.61.0 (ISC, see LICENSE-lucide), imported
3131
+ // by `node tools/import-lucide.ts <name>`; tag without its dot. The states are the Lucide outlines made comparable for
3132
+ // morphing: clockwise, starting where the outline crosses the vertical middle at the top, scaled evenly to fill the
3133
+ // 24×24 box and centred (computed on first use, not at import). Roundness 1 = Lucide's own corners … 0 = sharp.
3134
+ const DEGREE = Math.PI / 180;
3135
+ const GRID_SIZE = 24;
3136
+ const GRID_CENTER = 12;
3137
+ const SAMPLES_PER_STROKE = 48;
3138
+ /** Enough for the shape that needs the most (puzzle: 24 strokes, its knobs ≈ 40 segments); the rest spreads by length. */
3139
+ const SEGMENT_COUNT = 48;
3140
+ /** The shapes as Lucide draws them (Lucide coordinates); the set's states are made from these. */
3141
+ const lucideShapeOutlines = {
3142
+ circle: [arc(12, 12, 10, -90, 270)],
3143
+ ellipse: ellipse(12, 12, 10, 6),
3144
+ heart: [
3145
+ arc(7.5, 9.5001, 5.5, -179.9989, -41.9423),
3146
+ arc(12, 5.4415, 0.56, 136.9163, 43.0837),
3147
+ arc(16.5101, 9.4738, 5.49, -138.3317, 0.2729),
3148
+ cubic(22, 9.5, 22, 11.79, 20.5, 13.5, 19, 15),
3149
+ line(19, 15, 13.508, 20.313),
3150
+ arc(11.9996, 18.9997, 2, 41.0455, 138.2288),
3151
+ line(10.508, 20.332, 5, 15),
3152
+ cubic(5, 15, 3.5, 13.5, 2, 11.8, 2, 9.5),
3153
+ ],
3154
+ sparkle: [
3155
+ arc(12, 2.9976, 1, -169.4202, -10.5798),
3156
+ line(12.983, 2.814, 14.034, 8.372),
3157
+ arc(15.9992, 8.0008, 2, 169.3026, 100.6974),
3158
+ line(15.628, 9.966, 21.186, 11.017),
3159
+ arc(21.0024, 12, 1, -79.4202, 79.4202),
3160
+ line(21.186, 12.983, 15.628, 14.034),
3161
+ arc(15.9992, 15.9992, 2, -100.6974, -169.3026),
3162
+ line(14.034, 15.628, 12.983, 21.186),
3163
+ arc(12, 21.0024, 1, 10.5798, 169.4202),
3164
+ line(11.017, 21.186, 9.966, 15.628),
3165
+ arc(8.0008, 15.9992, 2, -10.6974, -79.3026),
3166
+ line(8.372, 14.034, 2.814, 12.983),
3167
+ arc(2.9976, 12, 1, 100.5798, 259.4202),
3168
+ line(2.814, 11.017, 8.372, 9.966),
3169
+ arc(8.0008, 8.0008, 2, 79.3026, 10.6974),
3170
+ line(9.966, 8.372, 11.017, 2.814),
3171
+ ],
3172
+ astroid: [
3173
+ arc(12, 21.0024, 1, 10.5798, 169.4202),
3174
+ arc(1.1555, 22.8445, 10, -9.5467, -80.4533),
3175
+ arc(2.9976, 12, 1, 100.5798, 259.4202),
3176
+ arc(1.1555, 1.1555, 10, 80.4533, 9.5467),
3177
+ arc(12, 2.9976, 1, -169.4202, -10.5798),
3178
+ arc(22.8445, 1.1555, 10, 170.4533, 99.5467),
3179
+ arc(21.0024, 12, 1, -79.4202, 79.4202),
3180
+ arc(22.8445, 22.8445, 10, -99.5467, -170.4533),
3181
+ ],
3182
+ bookmark: [
3183
+ arc(17, 5, 2, -90, 0),
3184
+ line(19, 5, 19, 20),
3185
+ arc(18, 19.9997, 1, 0.0185, 119.7357),
3186
+ line(17.504, 20.868, 12.992, 18.29),
3187
+ arc(12, 20.0266, 2, -60.2643, -119.7357),
3188
+ line(11.008, 18.29, 6.496, 20.868),
3189
+ arc(6, 19.9997, 1, 60.2643, 179.9815),
3190
+ line(5, 20, 5, 5),
3191
+ arc(7, 5, 2, 180, 270),
3192
+ line(7, 3, 17, 3),
3193
+ ],
3194
+ shield: [
3195
+ cubic(20, 13, 20, 18, 16.5, 20.5, 12.34, 21.95),
3196
+ arc(12.0191, 21.0029, 1, 71.2803, 110.4299),
3197
+ cubic(11.67, 21.94, 7.5, 20.5, 4, 18, 4, 13),
3198
+ line(4, 13, 4, 6),
3199
+ arc(5, 6, 1, 180, 270),
3200
+ cubic(5, 5, 7, 5, 9.5, 3.8, 11.24, 2.28),
3201
+ arc(12, 3.1696, 1.17, -130.5094, -49.4906),
3202
+ cubic(12.76, 2.28, 14.51, 3.81, 17, 5, 19, 5),
3203
+ arc(19, 6, 1, -90, 0),
3204
+ line(20, 6, 20, 13),
3205
+ ],
3206
+ moon: [
3207
+ arc(11.9981, 12.0009, 9, 3.09, 266.904),
3208
+ cubic(11.512, 3.014, 11.917, 2.992, 12.129, 3.474, 11.914, 3.817),
3209
+ arc(17.002, 6.997, 6, -147.9941, -302.0059),
3210
+ cubic(20.182, 12.085, 20.526, 11.87, 21.007, 12.081, 20.985, 12.486),
3211
+ ],
3212
+ tag: [
3213
+ arc(11.1716, 4, 2, -44.9913, -89.9878),
3214
+ line(11.172, 2, 4, 2),
3215
+ arc(4, 4, 2, -90, -180),
3216
+ line(2, 4, 2, 11.172),
3217
+ arc(4, 11.1716, 2, 179.9878, 134.9913),
3218
+ line(2.586, 12.586, 11.29, 21.29),
3219
+ arc(13, 19.5691, 2.426, 134.8186, 45.1814),
3220
+ line(14.71, 21.29, 21.29, 14.71),
3221
+ arc(19.5691, 13, 2.426, 44.8186, -44.8186),
3222
+ line(21.29, 11.29, 12.586, 2.586),
3223
+ ],
3224
+ square: [
3225
+ line(5, 3, 19, 3),
3226
+ arc(19, 5, 2, -90, 0),
3227
+ line(21, 5, 21, 19),
3228
+ arc(19, 19, 2, 0, 90),
3229
+ line(19, 21, 5, 21),
3230
+ arc(5, 19, 2, 90, 180),
3231
+ line(3, 19, 3, 5),
3232
+ arc(5, 5, 2, 180, 270),
3233
+ ],
3234
+ badge: [
3235
+ arc(7.7549, 7.7531, 4, 167.4834, 282.6366),
3236
+ arc(12, 6.0048, 4, -147.4051, -32.5949),
3237
+ arc(16.2474, 7.7526, 4, -102.6711, 12.6711),
3238
+ arc(17.9952, 12, 4, -57.4051, 57.4051),
3239
+ arc(16.2469, 16.2451, 4, -12.6366, 102.5166),
3240
+ arc(12.005, 18.0031, 4, 32.4617, 147.5383),
3241
+ arc(7.7549, 16.2469, 4, 77.3634, 192.5166),
3242
+ arc(5.9891, 12, 4, 122.3281, 237.6719),
3243
+ ],
3244
+ triangle: [
3245
+ arc(12, 5.0035, 2, -30.1173, -149.8827),
3246
+ line(10.27, 4, 2.27, 18),
3247
+ arc(4.0021, 19, 2, -150, -269.9413),
3248
+ line(4, 21, 20, 21),
3249
+ arc(19.9979, 19, 2, 89.9413, -30),
3250
+ line(21.73, 18, 13.73, 4),
3251
+ ],
3252
+ droplet: [
3253
+ arc(12, 15, 7, 90, 0),
3254
+ cubic(19, 15, 19, 13, 18, 11.1, 16, 9.5),
3255
+ cubic(16, 9.5, 14, 7.9, 12.5, 5.5, 12, 3),
3256
+ cubic(12, 3, 11.5, 5.5, 10, 7.9, 8, 9.5),
3257
+ cubic(8, 9.5, 6, 11.1, 5, 13, 5, 15),
3258
+ arc(12, 15, 7, 180, 90),
3259
+ ],
3260
+ pentagon: [
3261
+ arc(12, 4.0021, 2, -125.803, -54.197),
3262
+ line(13.17, 2.38, 21.17, 8.12),
3263
+ arc(20.0009, 9.7427, 2, -54.2293, 18.2786),
3264
+ line(21.9, 10.37, 18.86, 19.63),
3265
+ arc(16.9618, 19, 2, 18.3608, 90.052),
3266
+ line(16.96, 21, 7.04, 21),
3267
+ arc(7.0382, 19, 2, 89.948, 161.6392),
3268
+ line(5.14, 19.63, 2.1, 10.37),
3269
+ arc(3.9991, 9.7427, 2, 161.7214, 234.2293),
3270
+ line(2.83, 8.12, 10.83, 2.38),
3271
+ ],
3272
+ hexagon: [
3273
+ line(21, 16, 21, 8),
3274
+ arc(19, 8.0021, 2, -0.0588, -60),
3275
+ line(20, 6.27, 13, 2.27),
3276
+ arc(12, 4.0021, 2, -60, -120),
3277
+ line(11, 2.27, 4, 6.27),
3278
+ arc(5, 8.0021, 2, -120, -179.9412),
3279
+ line(3, 8, 3, 16),
3280
+ arc(5, 15.9979, 2, 179.9412, 120),
3281
+ line(4, 17.73, 11, 21.73),
3282
+ arc(12, 19.9979, 2, 120, 60),
3283
+ line(13, 21.73, 20, 17.73),
3284
+ arc(19, 15.9979, 2, 60, 0.0588),
3285
+ ],
3286
+ octagon: [
3287
+ arc(4, 15.3116, 2, 134.9913, 179.9878),
3288
+ line(2, 15.312, 2, 8.688),
3289
+ arc(4, 8.6884, 2, -179.9878, -134.9913),
3290
+ line(2.586, 7.274, 7.274, 2.586),
3291
+ arc(8.6884, 4, 2, -135.0087, -90.0122),
3292
+ line(8.688, 2, 15.312, 2),
3293
+ arc(15.3116, 4, 2, -89.9878, -44.9913),
3294
+ line(16.726, 2.586, 21.414, 7.274),
3295
+ arc(20, 8.6884, 2, -45.0087, -0.0122),
3296
+ line(22, 8.688, 22, 15.312),
3297
+ arc(20, 15.3116, 2, 0.0122, 45.0087),
3298
+ line(21.414, 16.726, 16.726, 21.414),
3299
+ arc(15.3116, 20, 2, 44.9913, 89.9878),
3300
+ line(15.312, 22, 8.688, 22),
3301
+ arc(8.6884, 20, 2, 90.0122, 135.0087),
3302
+ line(7.274, 21.414, 2.586, 16.726),
3303
+ ],
3304
+ squircle: [
3305
+ cubic(12, 3, 19.2, 3, 21, 4.8, 21, 12),
3306
+ cubic(21, 12, 21, 19.2, 19.2, 21, 12, 21),
3307
+ cubic(12, 21, 4.8, 21, 3, 19.2, 3, 12),
3308
+ cubic(3, 12, 3, 4.8, 4.8, 3, 12, 3),
3309
+ ],
3310
+ star: [
3311
+ arc(12, 2.5301, 0.53, -153.6664, -26.3336),
3312
+ line(12.475, 2.295, 14.785, 6.974),
3313
+ arc(16.6883, 6.0335, 2.123, 153.7044, 98.3503),
3314
+ line(16.38, 8.134, 21.546, 8.89),
3315
+ arc(21.47, 9.4145, 0.53, -81.756, 45.7247),
3316
+ line(21.84, 9.794, 18.104, 13.432),
3317
+ arc(19.5857, 14.9525, 2.123, -134.26, -189.6958),
3318
+ line(17.493, 15.31, 18.375, 20.45),
3319
+ arc(17.8531, 20.5422, 0.53, -10.0155, 118.0314),
3320
+ line(17.604, 21.01, 12.986, 18.582),
3321
+ arc(11.9995, 20.4608, 2.122, -62.2968, -117.7032),
3322
+ line(11.013, 18.582, 6.396, 21.01),
3323
+ arc(6.148, 20.5416, 0.53, 62.1036, 189.9512),
3324
+ line(5.626, 20.45, 6.507, 15.311),
3325
+ arc(4.4155, 14.9522, 2.122, 9.7335, -45.7597),
3326
+ line(5.896, 13.432, 2.16, 9.795),
3327
+ arc(2.5282, 9.4138, 0.53, 134.0047, 261.9521),
3328
+ line(2.454, 8.889, 7.619, 8.134),
3329
+ arc(7.3135, 6.0341, 2.122, 81.7227, 26.2909),
3330
+ line(9.216, 6.974, 11.525, 2.295),
3331
+ ],
3332
+ 'rectangle-horizontal': [
3333
+ line(4, 6, 20, 6),
3334
+ arc(20, 8, 2, -90, 0),
3335
+ line(22, 8, 22, 16),
3336
+ arc(20, 16, 2, 0, 90),
3337
+ line(20, 18, 4, 18),
3338
+ arc(4, 16, 2, 90, 180),
3339
+ line(2, 16, 2, 8),
3340
+ arc(4, 8, 2, 180, 270),
3341
+ ],
3342
+ 'rectangle-vertical': [
3343
+ line(8, 2, 16, 2),
3344
+ arc(16, 4, 2, -90, 0),
3345
+ line(18, 4, 18, 20),
3346
+ arc(16, 20, 2, 0, 90),
3347
+ line(16, 22, 8, 22),
3348
+ arc(8, 20, 2, 90, 180),
3349
+ line(6, 20, 6, 4),
3350
+ arc(8, 4, 2, 180, 270),
3351
+ ],
3352
+ 'message-circle': [
3353
+ arc(1.1328, 17.079, 2, -21.6245, 12.4142),
3354
+ line(3.086, 17.509, 2.021, 20.799),
3355
+ arc(3.0005, 21.0005, 1, -168.3779, -284.8626),
3356
+ line(3.257, 21.967, 6.67, 20.969),
3357
+ arc(7.0591, 22.9308, 2, -101.2192, -69.2104),
3358
+ arc(12.0003, 12.0003, 10, 115.0325, -205.7324),
3359
+ ],
3360
+ 'message-square': [
3361
+ arc(20, 17, 2, 0, 90),
3362
+ line(20, 19, 6.828, 19),
3363
+ arc(6.8284, 21, 2, -90.0122, -135.0087),
3364
+ line(5.414, 19.586, 3.212, 21.788),
3365
+ arc(2.71, 21.2859, 0.71, 45.0052, 179.9926),
3366
+ line(2, 21.286, 2, 5),
3367
+ arc(4, 5, 2, 180, 270),
3368
+ line(4, 3, 20, 3),
3369
+ arc(20, 5, 2, -90, 0),
3370
+ line(22, 5, 22, 17),
3371
+ ],
3372
+ diamond: [
3373
+ arc(4.4033, 12.005, 2.41, -134.9707, -225.0293),
3374
+ line(2.7, 13.71, 10.29, 21.3),
3375
+ arc(11.995, 19.5967, 2.41, 135.0293, 44.9707),
3376
+ line(13.7, 21.3, 21.29, 13.71),
3377
+ arc(19.5867, 12.005, 2.41, 45.0293, -45.0293),
3378
+ line(21.29, 10.3, 13.7, 2.71),
3379
+ arc(11.995, 4.4133, 2.41, -44.9707, -135.0293),
3380
+ line(10.29, 2.71, 2.7, 10.3),
3381
+ ],
3382
+ cloud: [
3383
+ line(17.5, 19, 9, 19),
3384
+ arc(9.0018, 12, 7, 90.0147, 343.3984),
3385
+ line(15.71, 10, 17.5, 10),
3386
+ arc(17.5, 14.5, 4.5, -90, 90),
3387
+ ],
3388
+ puzzle: [
3389
+ arc(16.0975, 3.6833, 1, 135.0289, 13.459),
3390
+ arc(19.5008, 4.5, 2.5, -166.4917, 76.5107),
3391
+ arc(20.3167, 7.9035, 1, -103.459, -225.0289),
3392
+ line(19.61, 8.611, 21.293, 10.293),
3393
+ arc(19.5861, 12, 2.414, -45.0015, 45.0015),
3394
+ line(21.293, 13.707, 19.61, 15.39),
3395
+ arc(18.9025, 14.6833, 1, 44.9711, 166.541),
3396
+ arc(15.4992, 15.5, 2.5, -13.5083, -256.5107),
3397
+ arc(14.6833, 18.9035, 1, -76.541, 45.0289),
3398
+ line(15.39, 19.611, 13.707, 21.293),
3399
+ arc(12, 19.5861, 2.414, 44.9985, 135.0015),
3400
+ line(10.293, 21.293, 8.61, 19.61),
3401
+ arc(7.9025, 20.3167, 1, -44.9711, -166.541),
3402
+ arc(4.4992, 19.5, 2.5, 13.5083, 256.5107),
3403
+ arc(3.6833, 16.0965, 1, 76.541, -45.0289),
3404
+ line(4.39, 15.389, 2.707, 13.707),
3405
+ arc(4.4139, 12, 2.414, 134.9985, 225.0015),
3406
+ line(2.707, 10.293, 4.39, 8.61),
3407
+ arc(5.0975, 9.3167, 1, -135.0289, -13.459),
3408
+ arc(8.5008, 8.5, 2.5, 166.4917, -76.5107),
3409
+ arc(9.3167, 5.0965, 1, 103.459, 225.0289),
3410
+ line(8.61, 4.389, 10.293, 2.707),
3411
+ arc(12, 4.4139, 2.414, -135.0015, -44.9985),
3412
+ line(13.707, 2.707, 15.39, 4.39),
3413
+ ],
3414
+ };
3415
+ // ---- making the outlines comparable ----
3416
+ const pointOnStroke = (stroke, progress) => strokePiece(stroke, 0, progress)[3];
3417
+ const samplesOf = (strokes) => strokes.flatMap((stroke, strokeIndex) => Array.from({ length: SAMPLES_PER_STROKE }, (_, sample) => {
3418
+ const progress = sample / SAMPLES_PER_STROKE;
3419
+ return { point: pointOnStroke(stroke, progress), strokeIndex, progress };
3420
+ }));
3421
+ const transformStroke = (stroke, scale, offsetX, offsetY) => {
3422
+ const move = ([x, y]) => [x * scale + offsetX, y * scale + offsetY];
3423
+ switch (stroke.kind) {
3424
+ case 'line': return { ...stroke, from: move(stroke.from), to: move(stroke.to) };
3425
+ case 'arc': return { ...stroke, center: move(stroke.center), radius: stroke.radius * scale };
3426
+ case 'cubic': return { kind: 'cubic', segment: stroke.segment.map(move) };
3427
+ }
3428
+ };
3429
+ /** Splits a stroke at `progress` into two strokes of the same kind (arcs stay arcs, so they keep their segment rule). */
3430
+ const splitStroke = (stroke, progress) => {
3431
+ if (stroke.kind === 'arc') {
3432
+ const splitAngle = stroke.startAngle + (stroke.endAngle - stroke.startAngle) * progress;
3433
+ return [{ ...stroke, endAngle: splitAngle }, { ...stroke, startAngle: splitAngle }];
3434
+ }
3435
+ if (stroke.kind === 'line') {
3436
+ const splitPoint = pointOnStroke(stroke, progress);
3437
+ return [{ ...stroke, to: splitPoint }, { ...stroke, from: splitPoint }];
3438
+ }
3439
+ return [{ kind: 'cubic', segment: strokePiece(stroke, 0, progress) }, { kind: 'cubic', segment: strokePiece(stroke, progress, 1) }];
3440
+ };
3441
+ const fitToBox = (strokes) => {
3442
+ const points = samplesOf(strokes).map(({ point }) => point);
3443
+ const xValues = points.map(([x]) => x), yValues = points.map(([, y]) => y);
3444
+ const minX = Math.min(...xValues), maxX = Math.max(...xValues), minY = Math.min(...yValues), maxY = Math.max(...yValues);
3445
+ const scale = GRID_SIZE / Math.max(maxX - minX, maxY - minY);
3446
+ return strokes.map((stroke) => transformStroke(stroke, scale, GRID_CENTER - ((minX + maxX) / 2) * scale, GRID_CENTER - ((minY + maxY) / 2) * scale));
3447
+ };
3448
+ /** Positive = clockwise on screen (y points down). */
3449
+ const signedArea = (strokes) => {
3450
+ const points = samplesOf(strokes).map(({ point }) => point);
3451
+ return points.reduce((sum, [x, y], index) => {
3452
+ const [nextX, nextY] = points[(index + 1) % points.length];
3453
+ return sum + x * nextY - nextX * y;
3454
+ }, 0);
3455
+ };
3456
+ const clockwise = (strokes) => (signedArea(strokes) >= 0 ? strokes : strokes.slice().reverse().map(reversedStroke));
3457
+ /** Starts the closed outline where it crosses x = 12 highest up (splitting that stroke). */
3458
+ const startAtTopMiddle = (strokes) => {
3459
+ const samples = samplesOf(strokes);
3460
+ let best = null;
3461
+ for (let index = 0; index < samples.length; index++) {
3462
+ const sample = samples[index], next = samples[(index + 1) % samples.length];
3463
+ const [x, y] = sample.point, [nextX, nextY] = next.point;
3464
+ if ((x - GRID_CENTER) * (nextX - GRID_CENTER) > 0 || x === nextX)
3465
+ continue;
3466
+ const fraction = (GRID_CENTER - x) / (nextX - x);
3467
+ const crossingY = y + (nextY - y) * fraction;
3468
+ if (best && crossingY >= best.y)
3469
+ continue;
3470
+ const nextProgress = next.strokeIndex === sample.strokeIndex ? next.progress : 1;
3471
+ best = { strokeIndex: sample.strokeIndex, progress: sample.progress + (nextProgress - sample.progress) * fraction, y: crossingY };
3472
+ }
3473
+ if (!best)
3474
+ return strokes;
3475
+ const { strokeIndex, progress } = best;
3476
+ const before = strokes.slice(0, strokeIndex), after = strokes.slice(strokeIndex + 1);
3477
+ if (progress < 1e-6)
3478
+ return [...strokes.slice(strokeIndex), ...before];
3479
+ if (progress > 1 - 1e-6)
3480
+ return [...after, ...before, strokes[strokeIndex]];
3481
+ const [firstPart, secondPart] = splitStroke(strokes[strokeIndex], progress);
3482
+ return [secondPart, ...after, ...before, firstPart];
3483
+ };
3484
+ // ---- roundness: 1 = Lucide's own corners, 0 = sharp ----
3485
+ /** Lucide's corner arcs are small (radius 0.56 … 2.43); bigger arcs are part of the shape (circle, moon, droplet). */
3486
+ const LARGEST_CORNER_RADIUS = 2.5;
3487
+ const crossingOfLines = (point, direction, otherPoint, otherDirection) => {
3488
+ const determinant = direction[0] * otherDirection[1] - direction[1] * otherDirection[0];
3489
+ if (Math.abs(determinant) < 1e-9)
3490
+ return null;
3491
+ const along = ((otherPoint[0] - point[0]) * otherDirection[1] - (otherPoint[1] - point[1]) * otherDirection[0]) / determinant;
3492
+ return [point[0] + direction[0] * along, point[1] + direction[1] * along];
3493
+ };
3494
+ /** Moves a stroke's start (or end) to `point`; a cubic's neighbouring control point moves along, so its direction there stays. */
3495
+ const withMovedEnd = (stroke, which, point) => {
3496
+ if (stroke.kind === 'line')
3497
+ return which === 'start' ? { ...stroke, from: point } : { ...stroke, to: point };
3498
+ if (stroke.kind !== 'cubic')
3499
+ return stroke;
3500
+ const [start, control1, control2, end] = stroke.segment;
3501
+ if (which === 'start') {
3502
+ const shift = [point[0] - start[0], point[1] - start[1]];
3503
+ return { kind: 'cubic', segment: [point, [control1[0] + shift[0], control1[1] + shift[1]], control2, end] };
3504
+ }
3505
+ const shift = [point[0] - end[0], point[1] - end[1]];
3506
+ return { kind: 'cubic', segment: [start, control1, [control2[0] + shift[0], control2[1] + shift[1]], point] };
3507
+ };
3508
+ /**
3509
+ * Every corner arc (small, between two lines / cubics) shrinks towards the sharp corner where its two neighbours'
3510
+ * directions meet: radius × roundness, angles unchanged. At 0 the arc is a point on the corner. Same strokes in the
3511
+ * same order at any roundness, so morphs keep working.
3512
+ */
3513
+ const withCornerRoundness = (strokes, roundness) => {
3514
+ const result = strokes.slice();
3515
+ strokes.forEach((stroke, index) => {
3516
+ const previousIndex = (index - 1 + strokes.length) % strokes.length, nextIndex = (index + 1) % strokes.length;
3517
+ const isEdge = (neighbour) => neighbour.kind === 'line' || neighbour.kind === 'cubic';
3518
+ if (stroke.kind !== 'arc' || stroke.radius > LARGEST_CORNER_RADIUS)
3519
+ return;
3520
+ if (!isEdge(strokes[previousIndex]) || !isEdge(strokes[nextIndex]))
3521
+ return;
3522
+ const turning = Math.sign(stroke.endAngle - stroke.startAngle);
3523
+ const tangent = (angle) => [-Math.sin(angle * DEGREE) * turning, Math.cos(angle * DEGREE) * turning];
3524
+ const start = pointOnStroke(stroke, 0), end = pointOnStroke(stroke, 1);
3525
+ const corner = crossingOfLines(start, tangent(stroke.startAngle), end, tangent(stroke.endAngle));
3526
+ if (!corner)
3527
+ return;
3528
+ const towardsCorner = ([x, y]) => [corner[0] + (x - corner[0]) * roundness, corner[1] + (y - corner[1]) * roundness];
3529
+ result[index] = { ...stroke, center: towardsCorner(stroke.center), radius: stroke.radius * roundness };
3530
+ result[previousIndex] = withMovedEnd(result[previousIndex], 'end', towardsCorner(start));
3531
+ result[nextIndex] = withMovedEnd(result[nextIndex], 'start', towardsCorner(end));
3532
+ });
3533
+ return result;
3534
+ };
3535
+ /** The shape set at a roundness (0 = sharp … 1 = Lucide's own corners); its strokes are built on first use. */
3536
+ const shapeSetWithRoundness = (roundness) => {
3537
+ let strokes = null;
3538
+ return {
3539
+ get strokes() {
3540
+ strokes ??= Object.fromEntries(Object.entries(lucideShapeOutlines).map(([name, outline]) => [name, startAtTopMiddle(clockwise(fitToBox(withCornerRoundness(outline, roundness))))]));
3541
+ return strokes;
3542
+ },
3543
+ alternativeStrokes: {},
3544
+ pairLayouts: {},
3545
+ spins: {},
3546
+ segmentCount: SEGMENT_COUNT,
3547
+ // shapes are filled (clip), not stroked; these only matter for the outlined band's corners
3548
+ strokeWidth: 0,
3549
+ lineCap: 'butt',
3550
+ lineJoin: 'miter',
3551
+ withRoundness: shapeSetWithRoundness,
3552
+ };
3553
+ };
3554
+ /** The shape set of clip(): states `circle`, `ellipse`, `heart` … `puzzle` (see `lucideShapeOutlines`). */
3555
+ const clipShapes = shapeSetWithRoundness(1);
3556
+ return { lucideShapeOutlines, clipShapes };
1199
3557
  })();
1200
3558
 
1201
3559
  // svg.ts
1202
- const { createSvgElement, createIconSvg } = (() => {
3560
+ const { hasFixedStrokeWidth, createSvgElement, createIconSvg, createStrokedElement } = (() => {
1203
3561
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
3562
+ /** A CSS length as stroke width: drawn at that width on screen instead of scaling with the icon. */
3563
+ const hasFixedStrokeWidth = (style) => typeof style.strokeWidth === 'string';
1204
3564
  const createSvgElement = (tagName, attributes = {}) => {
1205
3565
  const element = document.createElementNS(SVG_NAMESPACE, tagName);
1206
3566
  for (const [name, value] of Object.entries(attributes))
1207
3567
  element.setAttribute(name, String(value));
1208
3568
  return element;
1209
3569
  };
1210
- /** An <svg> drawn in the set's stroke style, coloured by the CSS `color` (currentColor). */
1211
- const createIconSvg = (iconSet, viewBox) => createSvgElement('svg', {
1212
- viewBox,
1213
- fill: 'none',
1214
- stroke: 'currentColor',
1215
- 'stroke-width': iconSet.strokeWidth,
1216
- 'stroke-linecap': iconSet.lineCap,
1217
- 'stroke-linejoin': iconSet.lineJoin,
3570
+ /** An <svg> drawn in the set's stroke style (or the given style), colored by the CSS `color` (currentColor). */
3571
+ const createIconSvg = (iconSet, viewBox, style = {}) => {
3572
+ const svg = createSvgElement('svg', {
3573
+ viewBox,
3574
+ fill: style.fill === true ? 'currentColor' : style.fill || 'none',
3575
+ stroke: 'currentColor',
3576
+ 'stroke-linecap': style.lineCap ?? iconSet.lineCap,
3577
+ 'stroke-linejoin': style.lineJoin ?? iconSet.lineJoin,
3578
+ });
3579
+ // a CSS length goes into the style (attributes can't hold rem / em / calc reliably); children inherit it
3580
+ if (hasFixedStrokeWidth(style))
3581
+ svg.style.strokeWidth = style.strokeWidth;
3582
+ else
3583
+ svg.setAttribute('stroke-width', String(style.strokeWidth ?? iconSet.strokeWidth));
3584
+ return svg;
3585
+ };
3586
+ /** A stroked child element (path, circle); with a fixed (CSS length) stroke width its line doesn't scale. */
3587
+ const createStrokedElement = (tagName, style, attributes = {}) => createSvgElement(tagName, {
3588
+ ...attributes,
3589
+ ...(hasFixedStrokeWidth(style) && { 'vector-effect': 'non-scaling-stroke' }),
1218
3590
  });
1219
- return { createSvgElement, createIconSvg };
3591
+ return { hasFixedStrokeWidth, createSvgElement, createIconSvg, createStrokedElement };
3592
+ })();
3593
+
3594
+ // clip.ts
3595
+ const { clip } = (() => {
3596
+ // Layer 3: an element cut out by a morphing shape (a star, a heart, a speech bubble …), e.g. a photo or a card.
3597
+ // The shape is square (1:1), placed by its centre (x, y) and size in % or px of the element, can be turned and
3598
+ // outlined (only a band along its edge shows). Filled → CSS `clip-path: url(#id)`; outlined → `mask: url(#id)`
3599
+ // (clip-path ignores line width). The <clipPath> / <mask> live in one hidden <svg> per clip.
3600
+ const GRID_SIZE = 24;
3601
+ const GRID_CENTER = 12;
3602
+ const DEGREE = Math.PI / 180;
3603
+ /** Masks and their clip regions reach far past the element, so bands and turned shapes are never cut by them. */
3604
+ const FAR = 100000;
3605
+ let clipCount = 0;
3606
+ const lengthToPixels = (length, reference, name) => {
3607
+ if (typeof length === 'number' && Number.isFinite(length))
3608
+ return (length / 100) * reference;
3609
+ const match = typeof length === 'string' ? /^\s*(-?[\d.]+(?:e[-+]?\d+)?)\s*(px|%)\s*$/i.exec(length) : null;
3610
+ if (!match)
3611
+ throw new Error(`morphico: ${name} "${length}" is not a length — use a number (= %), '40%' or '120px'`);
3612
+ const value = Number(match[1]);
3613
+ return match[2] === 'px' ? value : (value / 100) * reference;
3614
+ };
3615
+ const checkedOutline = (outline) => {
3616
+ if (!outline)
3617
+ return false;
3618
+ if (!(outline.width > 0))
3619
+ throw new Error('morphico: outline.width must be a number of px above 0');
3620
+ if (outline.band && !['inside', 'centre', 'outside'].includes(outline.band)) {
3621
+ throw new Error(`morphico: outline.band "${outline.band}" — use 'inside', 'centre' or 'outside'`);
3622
+ }
3623
+ return outline;
3624
+ };
3625
+ /** Cuts `element` out by a morphing shape. */
3626
+ const clip = (element, options = {}) => {
3627
+ const { set = clipShapes, state: initialState = 'circle', x = 50, y = 50, size, rotation = 0, roundness = 1, outline = false, ...defaultMorphOptions } = options;
3628
+ const checkedState = (shapeSet, state) => {
3629
+ if (!(state in shapeSet.strokes)) {
3630
+ throw new Error(`morphico: unknown shape "${state}" (known: ${Object.keys(shapeSet.strokes).join(', ')})`);
3631
+ }
3632
+ return state;
3633
+ };
3634
+ for (const [name, length] of [['x', x], ['y', y], ['size', size]]) {
3635
+ if (length !== undefined)
3636
+ lengthToPixels(length, 1, name);
3637
+ }
3638
+ /** Where the change goes (or what shows when nothing moves). */
3639
+ const target = {
3640
+ state: checkedState(set, initialState), set: resolveIconSet(set, roundness), roundness,
3641
+ x, y, size, rotation, outline: checkedOutline(outline),
3642
+ };
3643
+ const pieceLength = pieceLengthOf(set);
3644
+ let frame = null; // null = resting at the target
3645
+ let running = null;
3646
+ let elementWidth = 0, elementHeight = 0;
3647
+ // ---- the hidden <svg> with the <clipPath> (filled) and the <mask> (outlined) ----
3648
+ const id = `morphico-clip-${++clipCount}`;
3649
+ const svg = createSvgElement('svg', { width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' });
3650
+ svg.style.position = 'absolute';
3651
+ svg.style.pointerEvents = 'none';
3652
+ const clipPathElement = svg.appendChild(createSvgElement('clipPath', { id: `${id}-shape`, clipPathUnits: 'objectBoundingBox' }));
3653
+ const clipShape = clipPathElement.appendChild(createSvgElement('path'));
3654
+ const farRegion = { maskUnits: 'userSpaceOnUse', x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR };
3655
+ const maskElement = svg.appendChild(createSvgElement('mask', { id: `${id}-band`, ...farRegion }));
3656
+ /** Every path drawn in element pixels for the outlined band (the band itself and its helpers). */
3657
+ let bandPaths = [];
3658
+ const buildBand = () => {
3659
+ for (const helper of svg.querySelectorAll('.morphico-band-helper'))
3660
+ helper.remove();
3661
+ maskElement.replaceChildren();
3662
+ bandPaths = [];
3663
+ if (!target.outline)
3664
+ return;
3665
+ const { width, band = 'inside' } = target.outline;
3666
+ const pixelPath = (attributes) => {
3667
+ const path = createSvgElement('path', attributes);
3668
+ bandPaths.push(path);
3669
+ return path;
3670
+ };
3671
+ // inside = a line twice as wide, clipped to the shape; outside = a line twice as wide, the shape cut out of it
3672
+ const edgeLine = (lineWidth) => pixelPath({ fill: 'none', stroke: 'white', 'stroke-width': lineWidth, 'stroke-linejoin': 'miter' });
3673
+ if (band === 'centre') {
3674
+ maskElement.append(edgeLine(width));
3675
+ }
3676
+ else if (band === 'inside') {
3677
+ const insideClip = createSvgElement('clipPath', { id: `${id}-inside`, class: 'morphico-band-helper' });
3678
+ insideClip.append(pixelPath({}));
3679
+ svg.append(insideClip);
3680
+ const line = edgeLine(2 * width);
3681
+ line.setAttribute('clip-path', `url(#${id}-inside)`);
3682
+ maskElement.append(line);
3683
+ }
3684
+ else {
3685
+ // painting black over the inside would only work for luminance masks; a mask cut out of the line works for both
3686
+ const outsideMask = createSvgElement('mask', { id: `${id}-outside`, class: 'morphico-band-helper', ...farRegion });
3687
+ outsideMask.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }), pixelPath({ fill: 'black' }));
3688
+ svg.append(outsideMask);
3689
+ const line = edgeLine(2 * width);
3690
+ line.setAttribute('mask', `url(#${id}-outside)`);
3691
+ maskElement.append(line);
3692
+ }
3693
+ };
3694
+ const ownClipPath = element.style.clipPath;
3695
+ const ownMask = element.style.mask;
3696
+ const applyStyle = () => {
3697
+ const mask = target.outline ? `url(#${id}-band)` : ownMask;
3698
+ element.style.clipPath = target.outline ? ownClipPath : `url(#${id}-shape)`;
3699
+ element.style.mask = mask;
3700
+ element.style.setProperty('-webkit-mask', mask);
3701
+ };
3702
+ // ---- placement and drawing ----
3703
+ const restingPlacement = () => ({
3704
+ centerX: lengthToPixels(target.x, elementWidth, 'x'),
3705
+ centerY: lengthToPixels(target.y, elementHeight, 'y'),
3706
+ side: target.size === undefined ? Math.min(elementWidth, elementHeight) : lengthToPixels(target.size, elementWidth, 'size'),
3707
+ });
3708
+ const restingFrame = () => ({ shape: shapesOf(target.set)[target.state], rotation: target.rotation, placement: restingPlacement() });
3709
+ /** The shape in element pixels: scaled to its square, turned around the square's centre, moved into place. */
3710
+ const toElementPixels = ({ shape, rotation: angle, placement: { centerX, centerY, side } }) => {
3711
+ const scale = side / GRID_SIZE;
3712
+ const cos = Math.cos(angle * DEGREE), sin = Math.sin(angle * DEGREE);
3713
+ const pixels = [];
3714
+ for (let index = 0; index < shape.length; index += 2) {
3715
+ const offsetX = (shape[index] - GRID_CENTER) * scale, offsetY = (shape[index + 1] - GRID_CENTER) * scale;
3716
+ pixels.push(centerX + offsetX * cos - offsetY * sin, centerY + offsetX * sin + offsetY * cos);
3717
+ }
3718
+ return pixels;
3719
+ };
3720
+ const render = () => {
3721
+ if (!elementWidth || !elementHeight)
3722
+ return;
3723
+ const pixels = toElementPixels(frame ?? restingFrame());
3724
+ if (target.outline) {
3725
+ const pathData = toPathData(pixels, pieceLength);
3726
+ for (const path of bandPaths)
3727
+ path.setAttribute('d', pathData);
3728
+ }
3729
+ else {
3730
+ // objectBoundingBox: 0…1 of the element's width / height
3731
+ const boxUnits = pixels.map((value, index) => value / (index % 2 ? elementHeight : elementWidth));
3732
+ clipShape.setAttribute('d', toPathData(boxUnits, pieceLength));
3733
+ }
3734
+ };
3735
+ const measure = (width, height) => {
3736
+ elementWidth = width;
3737
+ elementHeight = height;
3738
+ render();
3739
+ };
3740
+ // ---- changes ----
3741
+ /** Ends the running change and resolves everyone waiting for it. */
3742
+ const finish = (interrupted) => {
3743
+ if (!running)
3744
+ return;
3745
+ const { stopAnimation, resolvers } = running;
3746
+ running = null;
3747
+ stopAnimation();
3748
+ for (const resolve of resolvers)
3749
+ resolve({ state: target.state, interrupted });
3750
+ };
3751
+ /** Updates the target (checked first, so a wrong value throws right away, not in an animation frame). */
3752
+ const applyChanges = (changes) => {
3753
+ for (const name of ['x', 'y', 'size']) {
3754
+ if (changes[name] !== undefined)
3755
+ lengthToPixels(changes[name], 1, name);
3756
+ }
3757
+ if (changes.state !== undefined)
3758
+ target.state = checkedState(target.set, changes.state);
3759
+ if (changes.roundness !== undefined && changes.roundness !== target.roundness) {
3760
+ target.roundness = changes.roundness;
3761
+ target.set = resolveIconSet(set, changes.roundness);
3762
+ }
3763
+ if (changes.x !== undefined)
3764
+ target.x = changes.x;
3765
+ if (changes.y !== undefined)
3766
+ target.y = changes.y;
3767
+ if (changes.size !== undefined)
3768
+ target.size = changes.size;
3769
+ if (changes.rotation !== undefined)
3770
+ target.rotation = changes.rotation;
3771
+ if (changes.outline !== undefined) {
3772
+ target.outline = checkedOutline(changes.outline);
3773
+ buildBand();
3774
+ applyStyle();
3775
+ }
3776
+ };
3777
+ const change = (changes, morphOptions) => {
3778
+ const settled = !running;
3779
+ finish(true);
3780
+ const previous = { state: target.state, set: target.set };
3781
+ const from = frame ?? restingFrame();
3782
+ applyChanges(changes);
3783
+ const { duration = DEFAULT_DURATION, easing = 'easeInOutCubic' } = morphOptions;
3784
+ if (duration <= 0 || prefersReducedMotion() || !elementWidth || !elementHeight) {
3785
+ frame = null;
3786
+ render();
3787
+ return Promise.resolve({ state: target.state, interrupted: false });
3788
+ }
3789
+ // settled: both sides use the pair's layouts; interrupted: start from the shape on screen
3790
+ const fromShape = settled
3791
+ ? shapeForPair(previous.set, shapesOf(previous.set), previous.state, target.state) : from.shape;
3792
+ const toShape = shapeForPair(target.set, shapesOf(target.set), target.state, previous.state);
3793
+ const to = restingPlacement();
3794
+ const ease = typeof easing === 'function' ? easing : easings[easing];
3795
+ const startTime = performance.now();
3796
+ return new Promise((resolve) => {
3797
+ const stopAnimation = animate((frameTime) => {
3798
+ const progress = clampToUnit((frameTime - startTime) / duration);
3799
+ if (progress >= 1) {
3800
+ frame = null;
3801
+ render();
3802
+ finish(false);
3803
+ return false;
3804
+ }
3805
+ const easedProgress = ease(progress);
3806
+ const between = (start, end) => start + (end - start) * easedProgress;
3807
+ frame = {
3808
+ shape: interpolate(fromShape, toShape, easedProgress),
3809
+ rotation: between(from.rotation, target.rotation),
3810
+ placement: {
3811
+ centerX: between(from.placement.centerX, to.centerX),
3812
+ centerY: between(from.placement.centerY, to.centerY),
3813
+ side: between(from.placement.side, to.side),
3814
+ },
3815
+ };
3816
+ render();
3817
+ return true;
3818
+ });
3819
+ running = { stopAnimation, resolvers: [resolve] };
3820
+ });
3821
+ };
3822
+ // ---- start ----
3823
+ const root = element.getRootNode();
3824
+ // inside a shadow root, url(#id) only finds ids of that root
3825
+ (root instanceof ShadowRoot ? root : document.body).append(svg);
3826
+ buildBand();
3827
+ applyStyle();
3828
+ const box = element.getBoundingClientRect();
3829
+ measure(box.width, box.height);
3830
+ const resizeObserver = typeof ResizeObserver === 'function'
3831
+ ? new ResizeObserver(([entry]) => {
3832
+ const borderBox = entry.borderBoxSize?.[0];
3833
+ if (borderBox)
3834
+ measure(borderBox.inlineSize, borderBox.blockSize);
3835
+ else
3836
+ measure(entry.contentRect.width, entry.contentRect.height);
3837
+ })
3838
+ : null;
3839
+ resizeObserver?.observe(element);
3840
+ return {
3841
+ get state() { return target.state; },
3842
+ to(stateOrChanges, options = {}) {
3843
+ const { onEnd, duration, easing, ...changes } = typeof stateOrChanges === 'string'
3844
+ ? { ...options, state: stateOrChanges } : stateOrChanges;
3845
+ const result = change(changes, { ...defaultMorphOptions, ...(duration !== undefined && { duration }), ...(easing && { easing }) });
3846
+ if (onEnd)
3847
+ result.then(onEnd);
3848
+ return result;
3849
+ },
3850
+ set(changes) {
3851
+ finish(true);
3852
+ frame = null;
3853
+ applyChanges(changes);
3854
+ render();
3855
+ },
3856
+ destroy() {
3857
+ finish(true);
3858
+ resizeObserver?.disconnect();
3859
+ svg.remove();
3860
+ element.style.clipPath = ownClipPath;
3861
+ element.style.mask = ownMask;
3862
+ element.style.setProperty('-webkit-mask', ownMask);
3863
+ },
3864
+ };
3865
+ };
3866
+ return { clip };
1220
3867
  })();
1221
3868
 
1222
3869
  // icon.ts
@@ -1230,13 +3877,15 @@ const checkedState = (iconSet, state) => {
1230
3877
  };
1231
3878
  /** Adds a morphing icon (an <svg>) to `element`. */
1232
3879
  const icon = (element, options = {}) => {
1233
- const { set, state = EMPTY_STATE, size = '1em', ...defaultMorphOptions } = options;
1234
- const iconSet = resolveIconSet(set);
1235
- const svg = createIconSvg(iconSet, '0 0 24 24');
3880
+ const { set, state = EMPTY_STATE, size = '1em', roundness, strokeWidth, lineCap, lineJoin, fill, ...defaultMorphOptions } = options;
3881
+ const iconSet = resolveIconSet(set, roundness);
3882
+ const style = { strokeWidth, lineCap, lineJoin, fill };
3883
+ const svg = createIconSvg(iconSet, '0 0 24 24', style);
1236
3884
  svg.setAttribute('role', 'img');
1237
3885
  svg.style.width = size;
1238
3886
  svg.style.height = size;
1239
- const pathElement = svg.appendChild(createSvgElement('path'));
3887
+ svg.style.overflow = 'visible'; // sharp tips (roundness 0, miter joins) may reach past the 24 × 24 box
3888
+ const pathElement = svg.appendChild(createStrokedElement('path', style));
1240
3889
  const morphingPath = createMorphingPath(pathElement, iconSet, checkedState(iconSet, state));
1241
3890
  const updateLabel = () => svg.setAttribute('aria-label', morphingPath.state.trim());
1242
3891
  updateLabel();
@@ -1263,58 +3912,40 @@ return { icon };
1263
3912
  const { text } = (() => {
1264
3913
  // Layer 2: a line of characters (clock, counter, date) in narrow cells; changed digits morph.
1265
3914
  // Every character reuses the 24×24 icon drawing; only the viewBox is narrowed to its cell.
1266
- /** Characters `text()` can show: digits, `-` and space morph; `:` `.` `,` `/` stay still. Anything else is removed. */
3915
+ /** Characters `text()` can show: digits, space, `-` `:` `.` `,` `/` — all of them morph. Anything else is removed. */
1267
3916
  const UNSUPPORTED_CHARACTERS = /[^0-9 :.,\/-]/g;
1268
3917
  /** Vertical part of the 24×24 grid shown in a cell: y 3…21 holds every digit including its stroke. */
1269
3918
  const CELL_TOP = 3;
1270
3919
  const CELL_HEIGHT = 18;
1271
- /** Dot radius of `:` `.` `,` relative to the stroke width. */
1272
- const DOT_SIZE = 0.6;
1273
3920
  const formatNumber = (value) => String(+value.toFixed(4));
1274
- /** Draws `:` `.` `,` or `/` into the cell, matching the set's stroke width and digit positions. */
1275
- const drawStaticCharacter = (svg, character, iconSet) => {
1276
- const { baselineY, colonDotsY } = iconSet.textLayout;
1277
- const dotRadius = iconSet.strokeWidth * DOT_SIZE;
1278
- const baseDotY = baselineY + iconSet.strokeWidth / 2 - dotRadius; // dot bottom = digit bottom
1279
- const dot = (y) => svg.appendChild(createSvgElement('circle', {
1280
- cx: 12, cy: formatNumber(y), r: formatNumber(dotRadius), fill: 'currentColor', stroke: 'none',
1281
- }));
1282
- const line = (fromX, fromY, toX, toY) => svg.appendChild(createSvgElement('path', {
1283
- d: `M${formatNumber(fromX)} ${formatNumber(fromY)}L${formatNumber(toX)} ${formatNumber(toY)}`,
1284
- }));
1285
- if (character === ':')
1286
- colonDotsY.forEach(dot);
1287
- if (character === '.' || character === ',')
1288
- dot(baseDotY);
1289
- if (character === ',')
1290
- line(12 + dotRadius * 0.4, baseDotY, 11, baselineY + 2.2);
1291
- if (character === '/')
1292
- line(9.5, baselineY, 14.5, 24 - baselineY);
1293
- };
1294
3921
  /** Adds a line of morphing characters (a <span> of <svg> cells) to `element`. */
1295
3922
  const text = (element, options = {}) => {
1296
- const { set, value: initialValue = '', size = '1em', cellWidth, spacing, ...defaultMorphOptions } = options;
3923
+ const { set, value: initialValue = '', size = '1em', cellWidth, spacing, strokeWidth, lineCap, lineJoin, fill, ...defaultMorphOptions } = options;
3924
+ const style = { strokeWidth, lineCap, lineJoin, fill };
1297
3925
  const iconSet = resolveIconSet(set);
1298
- const digitCellWidth = cellWidth ?? iconSet.textLayout.cellWidth;
1299
- const cellSpacing = spacing ?? iconSet.textLayout.spacing;
1300
- const inkBottom = iconSet.textLayout.baselineY + iconSet.strokeWidth / 2;
3926
+ const { textLayout } = iconSet;
3927
+ if (!textLayout)
3928
+ throw new Error('morphico: text() needs a digit set (this set has no text layout)');
3929
+ const digitCellWidth = cellWidth ?? textLayout.cellWidth;
3930
+ const cellSpacing = spacing ?? textLayout.spacing;
3931
+ const unitWidth = typeof strokeWidth === 'number' ? strokeWidth : iconSet.strokeWidth; // a CSS length: about the set's own
3932
+ const inkBottom = textLayout.baselineY + unitWidth / 2;
1301
3933
  const line = document.createElement('span');
1302
3934
  line.setAttribute('role', 'img');
1303
3935
  line.style.whiteSpace = 'nowrap';
1304
3936
  element.appendChild(line);
1305
3937
  let cells = [];
1306
3938
  let value = '';
1307
- const isMorphing = (character) => hasState(iconSet, character);
1308
3939
  const widthOf = (character) => {
1309
- if (isMorphing(character))
1310
- return digitCellWidth;
1311
- return character === '/' ? digitCellWidth * 0.7 : digitCellWidth / 2;
3940
+ if (character === '/')
3941
+ return digitCellWidth * 0.7;
3942
+ return ':.,'.includes(character) ? digitCellWidth / 2 : digitCellWidth;
1312
3943
  };
1313
3944
  /** CSS length: `units` grid units at the line's size. */
1314
3945
  const scaled = (units) => `calc(${size} * ${formatNumber(units / CELL_HEIGHT)})`;
1315
3946
  const createCell = (character, index) => {
1316
3947
  const width = widthOf(character);
1317
- const svg = createIconSvg(iconSet, `${formatNumber(12 - width / 2)} ${CELL_TOP} ${formatNumber(width)} ${CELL_HEIGHT}`);
3948
+ const svg = createIconSvg(iconSet, `${formatNumber(12 - width / 2)} ${CELL_TOP} ${formatNumber(width)} ${CELL_HEIGHT}`, style);
1318
3949
  svg.setAttribute('aria-hidden', 'true');
1319
3950
  Object.assign(svg.style, {
1320
3951
  width: scaled(width),
@@ -1323,29 +3954,26 @@ const text = (element, options = {}) => {
1323
3954
  verticalAlign: scaled(inkBottom - CELL_TOP - CELL_HEIGHT), // digit bottom on the text baseline
1324
3955
  marginLeft: index ? `var(--morphico-spacing, ${scaled(cellSpacing)})` : '0',
1325
3956
  });
1326
- let morphingPath = null;
1327
- if (isMorphing(character))
1328
- morphingPath = createMorphingPath(svg.appendChild(createSvgElement('path')), iconSet, character);
1329
- else
1330
- drawStaticCharacter(svg, character, iconSet);
1331
- return { svg, morphingPath, character };
3957
+ const morphingPath = createMorphingPath(svg.appendChild(createStrokedElement('path', style)), iconSet, character);
3958
+ return { svg, morphingPath, width };
1332
3959
  };
1333
3960
  const rebuild = (characters) => {
1334
3961
  for (const cell of cells)
1335
- cell.morphingPath?.stop();
3962
+ cell.morphingPath.stop();
1336
3963
  cells = characters.map(createCell);
1337
3964
  line.replaceChildren(...cells.map((cell) => cell.svg));
1338
3965
  };
1339
3966
  const show = (nextValue, { onEnd, ...morphOptions } = {}) => {
1340
- value = nextValue.replace(UNSUPPORTED_CHARACTERS, '');
3967
+ value = [...nextValue.replace(UNSUPPORTED_CHARACTERS, '')].filter((character) => hasState(iconSet, character)).join('');
1341
3968
  line.setAttribute('aria-label', value.trim());
1342
3969
  const characters = [...value];
1343
- const sameLayout = characters.length === cells.length && characters.every((character, index) => (cells[index].morphingPath ? isMorphing(character) : character === cells[index].character));
3970
+ const sameLayout = characters.length === cells.length
3971
+ && characters.every((character, index) => widthOf(character) === cells[index].width);
1344
3972
  let change;
1345
3973
  if (sameLayout) {
1346
- const morphs = cells.map((cell, index) => cell.morphingPath?.morphTo(characters[index], { ...defaultMorphOptions, ...morphOptions }));
3974
+ const morphs = cells.map((cell, index) => cell.morphingPath.morphTo(characters[index], { ...defaultMorphOptions, ...morphOptions }));
1347
3975
  const finishedValue = value;
1348
- change = Promise.all(morphs).then((results) => ({ value: finishedValue, interrupted: results.some((result) => result?.interrupted) }));
3976
+ change = Promise.all(morphs).then((results) => ({ value: finishedValue, interrupted: results.some((result) => result.interrupted) }));
1349
3977
  }
1350
3978
  else {
1351
3979
  rebuild(characters);
@@ -1361,7 +3989,7 @@ const text = (element, options = {}) => {
1361
3989
  set: show,
1362
3990
  destroy() {
1363
3991
  for (const cell of cells)
1364
- cell.morphingPath?.stop();
3992
+ cell.morphingPath.stop();
1365
3993
  line.remove();
1366
3994
  },
1367
3995
  };
@@ -1372,7 +4000,7 @@ return { text };
1372
4000
  // index.ts
1373
4001
  // morphico public API.
1374
4002
  /** Package version, e.g. "0.1.0" (tools/build.ts replaces 'dev'). */
1375
- const version = '0.1.0';
4003
+ const version = '0.3.0';
1376
4004
 
1377
- return { easings, icon, iconSets, text, version };
4005
+ return { clip, easings, icon, iconSets, text, version };
1378
4006
  })();