morphico 0.4.0 → 0.5.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.
Files changed (72) hide show
  1. package/README.md +62 -45
  2. package/dist/esm/all.js +50 -0
  3. package/dist/esm/clip.js +283 -0
  4. package/dist/esm/engine.js +25 -0
  5. package/dist/esm/face.js +153 -0
  6. package/dist/esm/geometry.js +459 -0
  7. package/dist/esm/icon.js +41 -0
  8. package/dist/esm/index.js +50 -0
  9. package/dist/esm/morph.js +107 -0
  10. package/dist/esm/roundness.js +37 -0
  11. package/dist/esm/sets/battery.js +87 -0
  12. package/dist/esm/sets/bell.js +101 -0
  13. package/dist/esm/sets/characters.js +43 -0
  14. package/dist/esm/sets/check-mark.js +74 -0
  15. package/dist/esm/sets/checkbox.js +91 -0
  16. package/dist/esm/sets/chevron.js +70 -0
  17. package/dist/esm/sets/condensed.js +137 -0
  18. package/dist/esm/sets/copy.js +87 -0
  19. package/dist/esm/sets/eye.js +105 -0
  20. package/dist/esm/sets/faces.js +482 -0
  21. package/dist/esm/sets/folder.js +112 -0
  22. package/dist/esm/sets/handwritten.js +136 -0
  23. package/dist/esm/sets/lock.js +75 -0
  24. package/dist/esm/sets/lucide.js +66 -0
  25. package/dist/esm/sets/media.js +109 -0
  26. package/dist/esm/sets/menu.js +84 -0
  27. package/dist/esm/sets/mic.js +102 -0
  28. package/dist/esm/sets/radio.js +84 -0
  29. package/dist/esm/sets/round.js +244 -0
  30. package/dist/esm/sets/shapes.js +455 -0
  31. package/dist/esm/sets/slim.js +148 -0
  32. package/dist/esm/sets/square.js +149 -0
  33. package/dist/esm/sets/theme.js +107 -0
  34. package/dist/esm/sets/toggle.js +73 -0
  35. package/dist/esm/sets/volume.js +89 -0
  36. package/dist/esm/sets/weather.js +691 -0
  37. package/dist/esm/sets/wifi.js +68 -0
  38. package/dist/esm/svg.js +31 -0
  39. package/dist/esm/text.js +88 -0
  40. package/dist/esm/types.js +3 -0
  41. package/dist/morphico.js +3764 -3750
  42. package/dist/types/{iconSets.d.ts → all.d.ts} +0 -2
  43. package/dist/types/clip.d.ts +4 -11
  44. package/dist/types/face.d.ts +2 -6
  45. package/dist/types/icon.d.ts +4 -9
  46. package/dist/types/index.d.ts +1 -3
  47. package/dist/types/roundness.d.ts +8 -0
  48. package/dist/types/sets/battery.d.ts +1 -1
  49. package/dist/types/sets/bell.d.ts +1 -1
  50. package/dist/types/sets/checkbox.d.ts +1 -1
  51. package/dist/types/sets/chevron.d.ts +1 -1
  52. package/dist/types/sets/condensed.d.ts +1 -1
  53. package/dist/types/sets/copy.d.ts +1 -1
  54. package/dist/types/sets/eye.d.ts +1 -1
  55. package/dist/types/sets/folder.d.ts +1 -1
  56. package/dist/types/sets/handwritten.d.ts +1 -1
  57. package/dist/types/sets/lock.d.ts +1 -1
  58. package/dist/types/sets/media.d.ts +1 -1
  59. package/dist/types/sets/menu.d.ts +1 -1
  60. package/dist/types/sets/mic.d.ts +1 -1
  61. package/dist/types/sets/radio.d.ts +1 -1
  62. package/dist/types/sets/round.d.ts +1 -1
  63. package/dist/types/sets/slim.d.ts +1 -1
  64. package/dist/types/sets/square.d.ts +1 -1
  65. package/dist/types/sets/theme.d.ts +1 -1
  66. package/dist/types/sets/toggle.d.ts +1 -1
  67. package/dist/types/sets/volume.d.ts +1 -1
  68. package/dist/types/sets/weather.d.ts +1 -1
  69. package/dist/types/sets/wifi.d.ts +1 -1
  70. package/dist/types/text.d.ts +8 -13
  71. package/package.json +95 -3
  72. package/dist/morphico.mjs +0 -4642
package/dist/morphico.mjs DELETED
@@ -1,4642 +0,0 @@
1
- /*! morphico v0.4.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
- */
42
- // engine.ts
43
- const { easings, animate } = (() => {
44
- // Animation engine: easings and one shared requestAnimationFrame loop for every running morph.
45
- // The loop only runs while something moves; nothing touches the DOM at import time (SSR-safe).
46
- const easings = {
47
- linear: (progress) => progress,
48
- easeInOutCubic: (progress) => (progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2),
49
- easeOutBack: (progress) => 1 + 2.70158 * (progress - 1) ** 3 + 1.70158 * (progress - 1) ** 2,
50
- easeOutElastic: (progress) => (progress === 0 || progress === 1
51
- ? progress
52
- : 2 ** (-10 * progress) * Math.sin((progress * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1),
53
- };
54
- const frameCallbacks = new Set();
55
- let animationFrameId = 0;
56
- const runFrame = (frameTime) => {
57
- for (const callback of frameCallbacks)
58
- if (!callback(frameTime))
59
- frameCallbacks.delete(callback);
60
- animationFrameId = frameCallbacks.size ? requestAnimationFrame(runFrame) : 0;
61
- };
62
- /** Adds a callback to the shared loop. Returns a function that removes it again. */
63
- const animate = (onFrame) => {
64
- frameCallbacks.add(onFrame);
65
- animationFrameId ||= requestAnimationFrame(runFrame);
66
- return () => { frameCallbacks.delete(onFrame); };
67
- };
68
- return { easings, animate };
69
- })();
70
-
71
- // geometry.ts
72
- 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 } = (() => {
73
- const DEGREE = Math.PI / 180;
74
- const ICON_CENTER = 12;
75
- /** Largest arc one cubic segment may cover: up to 90° the cubic is visually exact. */
76
- const MAX_ARC_PER_SEGMENT = 90;
77
- const EPSILON = 1e-9;
78
- // ---- strokes ----
79
- const line = (fromX, fromY, toX, toY) => ({ kind: 'line', from: [fromX, fromY], to: [toX, toY] });
80
- /** Angles in degrees. Because y points down, `endAngle > startAngle` draws clockwise on screen. */
81
- const arc = (centerX, centerY, radius, startAngle, endAngle) => ({ kind: 'arc', center: [centerX, centerY], radius, startAngle, endAngle });
82
- const cubic = (startX, startY, control1X, control1Y, control2X, control2Y, endX, endY) => ({ kind: 'cubic', segment: [[startX, startY], [control1X, control1Y], [control2X, control2Y], [endX, endY]] });
83
- /** The same line or arc, drawn in the opposite direction. */
84
- const reversedStroke = (stroke) => {
85
- if (stroke.kind === 'line')
86
- return { ...stroke, from: stroke.to, to: stroke.from };
87
- if (stroke.kind === 'arc')
88
- return { ...stroke, startAngle: stroke.endAngle, endAngle: stroke.startAngle };
89
- const [start, control1, control2, end] = stroke.segment;
90
- return { kind: 'cubic', segment: [end, control2, control1, start] };
91
- };
92
- const pointOnCircle = (centerX, centerY, radius, angle) => [centerX + radius * Math.cos(angle * DEGREE), centerY + radius * Math.sin(angle * DEGREE)];
93
- /** Control handle length of a quarter ellipse as a fraction of its radius (4/3 · (√2 − 1)). */
94
- const QUARTER_HANDLE = (4 / 3) * (Math.SQRT2 - 1);
95
- /** A full ellipse as four cubic quarters, drawn counter-clockwise on screen, starting at the top. */
96
- const ellipse = (centerX, centerY, radiusX, radiusY) => {
97
- const handleX = QUARTER_HANDLE * radiusX, handleY = QUARTER_HANDLE * radiusY;
98
- const top = centerY - radiusY, bottom = centerY + radiusY, left = centerX - radiusX, right = centerX + radiusX;
99
- return [
100
- cubic(centerX, top, centerX - handleX, top, left, centerY - handleY, left, centerY),
101
- cubic(left, centerY, left, centerY + handleY, centerX - handleX, bottom, centerX, bottom),
102
- cubic(centerX, bottom, centerX + handleX, bottom, right, centerY + handleY, right, centerY),
103
- cubic(right, centerY, right, centerY - handleY, centerX + handleX, top, centerX, top),
104
- ];
105
- };
106
- /**
107
- * A polyline whose corners are rounded: straight lines joined by tangent circle arcs.
108
- * `radii[index]` rounds the corner at `points[index]` (a single number rounds every corner; 0 = sharp corner).
109
- * Straight-through points and reversals (going back along the same line) always stay sharp.
110
- */
111
- const roundedPolyline = (points, radii) => {
112
- const strokes = [];
113
- const addLine = (from, to) => {
114
- if (Math.hypot(to[0] - from[0], to[1] - from[1]) > EPSILON)
115
- strokes.push(line(...from, ...to));
116
- };
117
- let current = points[0];
118
- for (let index = 1; index < points.length; index++) {
119
- const corner = points[index];
120
- const next = points[index + 1];
121
- const radius = Array.isArray(radii) ? (radii[index] ?? 0) : radii;
122
- if (!next || radius <= 0) {
123
- addLine(current, corner);
124
- current = corner;
125
- continue;
126
- }
127
- const incoming = unitVector(current, corner), outgoing = unitVector(corner, next);
128
- const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0];
129
- const turn = Math.atan2(cross, incoming[0] * outgoing[0] + incoming[1] * outgoing[1]); // radians, > 0 = clockwise
130
- if (Math.abs(turn) < 1e-6 || Math.abs(turn) > Math.PI - 1e-6) {
131
- addLine(current, corner);
132
- current = corner;
133
- continue;
134
- }
135
- const tangentDistance = radius * Math.tan(Math.abs(turn) / 2);
136
- const arcStart = [corner[0] - incoming[0] * tangentDistance, corner[1] - incoming[1] * tangentDistance];
137
- // the centre lies on the inside of the turn
138
- const inward = turn > 0 ? [-incoming[1], incoming[0]] : [incoming[1], -incoming[0]];
139
- const center = [arcStart[0] + inward[0] * radius, arcStart[1] + inward[1] * radius];
140
- const startAngle = Math.atan2(arcStart[1] - center[1], arcStart[0] - center[0]) / DEGREE;
141
- addLine(current, arcStart);
142
- strokes.push(arc(center[0], center[1], radius, startAngle, startAngle + turn / DEGREE));
143
- current = [corner[0] + outgoing[0] * tangentDistance, corner[1] + outgoing[1] * tangentDistance];
144
- }
145
- return strokes;
146
- };
147
- /**
148
- * A closed polygon with every corner rounded by `radius` (clamped per corner so neighbouring arcs never overlap).
149
- * Always a line and an arc per corner, whatever the radius, so pair layouts (stroke indices) stay valid; at radius 0
150
- * the arc shrinks to the corner point, so the corner is really sharp (and `stroke-linejoin` decides how it looks).
151
- * Starts after the first corner with a line (`startsWithArc`: with the arc of the first corner), clockwise if the
152
- * corners are listed clockwise on screen.
153
- */
154
- const roundedPolygon = (corners, radius, startsWithArc = false) => {
155
- const cornerCount = corners.length;
156
- const roundedCorners = corners.map((corner, index) => {
157
- const previous = corners[(index + cornerCount - 1) % cornerCount], next = corners[(index + 1) % cornerCount];
158
- const incoming = unitVector(previous, corner), outgoing = unitVector(corner, next);
159
- const turn = Math.atan2(incoming[0] * outgoing[1] - incoming[1] * outgoing[0], incoming[0] * outgoing[0] + incoming[1] * outgoing[1]);
160
- const halfTurnTangent = Math.tan(Math.abs(turn) / 2);
161
- const shortestHalfEdge = Math.min(distanceBetween(previous, corner), distanceBetween(corner, next)) / 2;
162
- const cornerRadius = Math.max(0, Math.min(radius, shortestHalfEdge / halfTurnTangent));
163
- const tangentDistance = cornerRadius * halfTurnTangent;
164
- const arcStart = [corner[0] - incoming[0] * tangentDistance, corner[1] - incoming[1] * tangentDistance];
165
- const arcEnd = [corner[0] + outgoing[0] * tangentDistance, corner[1] + outgoing[1] * tangentDistance];
166
- const inward = turn > 0 ? [-incoming[1], incoming[0]] : [incoming[1], -incoming[0]];
167
- const center = [arcStart[0] + inward[0] * cornerRadius, arcStart[1] + inward[1] * cornerRadius];
168
- const startAngle = Math.atan2(arcStart[1] - center[1], arcStart[0] - center[0]) / DEGREE;
169
- return { arcStart, arcEnd, arc: arc(center[0], center[1], cornerRadius, startAngle, startAngle + turn / DEGREE) };
170
- });
171
- const strokes = roundedCorners.flatMap((roundedCorner, index) => {
172
- const next = roundedCorners[(index + 1) % cornerCount];
173
- return [line(...roundedCorner.arcEnd, ...next.arcStart), next.arc];
174
- });
175
- // the list above starts after corner 0 with a line and ends with corner 0's arc
176
- return startsWithArc ? [strokes[strokes.length - 1], ...strokes.slice(0, -1)] : strokes;
177
- };
178
- const distanceBetween = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
179
- const unitVector = (from, to) => {
180
- const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
181
- return [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
182
- };
183
- const arcSweep = (stroke) => Math.abs(stroke.endAngle - stroke.startAngle);
184
- // ---- path numbers ⇄ segments ----
185
- const segmentCountOf = (path) => (path.length - 2) / 6;
186
- const pointAt = (path, index) => [path[index], path[index + 1]];
187
- const toSegments = (path) => Array.from({ length: segmentCountOf(path) }, (_, segmentIndex) => {
188
- const offset = segmentIndex * 6;
189
- return [pointAt(path, offset), pointAt(path, offset + 2), pointAt(path, offset + 4), pointAt(path, offset + 6)];
190
- });
191
- const fromSegments = (segments) => [...segments[0][0], ...segments.flatMap(([, control1, control2, end]) => [...control1, ...control2, ...end])];
192
- const rotateSegmentOrder = (segments, startSegment) => [...segments.slice(startSegment), ...segments.slice(0, startSegment)];
193
- const reverseSegments = (segments) => segments.slice().reverse().map(([start, control1, control2, end]) => [end, control2, control1, start]);
194
- /** Same picture, different segment order: start a closed loop at another segment and/or draw it backwards. */
195
- const reorderSegments = (path, { startSegment = 0, reversed = false } = {}) => {
196
- const rotated = rotateSegmentOrder(toSegments(path), startSegment);
197
- return fromSegments(reversed ? reverseSegments(rotated) : rotated);
198
- };
199
- const interpolate = (from, to, progress) => from.map((value, index) => value + (to[index] - value) * progress);
200
- const formatNumber = (value) => String(+value.toFixed(3));
201
- /** A piece whose end lands on its start is closed (`Z`): its first corner then gets the line join, not two line ends. */
202
- const isClosedPiece = (piece) => Math.hypot(piece[piece.length - 2] - piece[0], piece[piece.length - 1] - piece[1]) < 1e-6;
203
- const hasLength = ([start, control1, control2, end]) => [control1, control2, end].some((point) => Math.hypot(point[0] - start[0], point[1] - start[1]) > 1e-6);
204
- /**
205
- * The segments of a piece as written to SVG. A closed piece starts at its first segment with length: a zero-length
206
- * first segment (a corner of radius 0) has no direction, so browsers would draw the closing corner wrongly.
207
- * Only the written order changes, not the picture.
208
- */
209
- const writtenSegments = (piece) => {
210
- const segments = toSegments(piece);
211
- if (!isClosedPiece(piece))
212
- return segments;
213
- const firstWithLength = Math.max(0, segments.findIndex(hasLength));
214
- return [...segments.slice(firstWithLength), ...segments.slice(0, firstWithLength)];
215
- };
216
- /** A piece shrunk to a single point: not drawn at all (round line ends would show it as a dot). */
217
- const isPoint = (piece) => piece.every((value, index) => Math.abs(value - piece[index % 2]) < 1e-6);
218
- /**
219
- * SVG `d` attribute: per piece `M x y` followed by one `C` per segment (`pieceLength`: see pieceLengthOf).
220
- * Pieces shrunk to a single point are left out.
221
- */
222
- const toPathData = (path, pieceLength = path.length) => splitPieces(path, pieceLength).filter((piece) => !isPoint(piece)).map((piece) => {
223
- const segments = writtenSegments(piece);
224
- const [startX, startY] = segments[0][0];
225
- return `M${formatNumber(startX)} ${formatNumber(startY)}` + segments
226
- .map(([, control1, control2, end]) => `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`)
227
- .join('') + (isClosedPiece(piece) ? 'Z' : '');
228
- }).join('');
229
- /** Shrinks (factor < 1) or grows the whole shape around the icon center (12, 12). */
230
- const scaleAroundCenter = (path, factor) => path.map((value) => ICON_CENTER + (value - ICON_CENTER) * factor);
231
- /** Rotates the whole shape around the icon center (12, 12). */
232
- const rotateAroundCenter = (path, angle) => {
233
- const cos = Math.cos(angle * DEGREE), sin = Math.sin(angle * DEGREE);
234
- return path.map((value, index) => {
235
- if (index % 2 === 0) {
236
- const y = path[index + 1];
237
- return ICON_CENTER + (value - ICON_CENTER) * cos - (y - ICON_CENTER) * sin;
238
- }
239
- const x = path[index - 1];
240
- return ICON_CENTER + (x - ICON_CENTER) * sin + (value - ICON_CENTER) * cos;
241
- });
242
- };
243
- // ---- pieces of strokes ----
244
- const interpolatePoint = (from, to, progress) => [from[0] + (to[0] - from[0]) * progress, from[1] + (to[1] - from[1]) * progress];
245
- /** Splits a cubic into two cubics at `progress` (de Casteljau). */
246
- const splitCubic = ([start, control1, control2, end], progress) => {
247
- const startToControl1 = interpolatePoint(start, control1, progress);
248
- const control1ToControl2 = interpolatePoint(control1, control2, progress);
249
- const control2ToEnd = interpolatePoint(control2, end, progress);
250
- const leftInner = interpolatePoint(startToControl1, control1ToControl2, progress);
251
- const rightInner = interpolatePoint(control1ToControl2, control2ToEnd, progress);
252
- const splitPoint = interpolatePoint(leftInner, rightInner, progress);
253
- return [[start, startToControl1, leftInner, splitPoint], [splitPoint, rightInner, control2ToEnd, end]];
254
- };
255
- /**
256
- * The part of a stroke between two progress values (0…1) as one cubic segment.
257
- * A piece of a line/arc/cubic is exactly a line/arc/cubic, so segment ends can slide along a stroke
258
- * without changing how it looks.
259
- */
260
- const strokePiece = (stroke, startProgress, endProgress) => {
261
- switch (stroke.kind) {
262
- case 'line': {
263
- const start = interpolatePoint(stroke.from, stroke.to, startProgress);
264
- const end = interpolatePoint(stroke.from, stroke.to, endProgress);
265
- return [start, interpolatePoint(start, end, 1 / 3), interpolatePoint(start, end, 2 / 3), end];
266
- }
267
- case 'arc': {
268
- const { center: [centerX, centerY], radius, startAngle, endAngle } = stroke;
269
- const startRadians = (startAngle + (endAngle - startAngle) * startProgress) * DEGREE;
270
- const endRadians = (startAngle + (endAngle - startAngle) * endProgress) * DEGREE;
271
- // standard arc approximation: control handle length = 4/3 · tan(θ/4) · r
272
- const handleLength = (4 / 3) * Math.tan((endRadians - startRadians) / 4) * radius;
273
- const start = [centerX + radius * Math.cos(startRadians), centerY + radius * Math.sin(startRadians)];
274
- const end = [centerX + radius * Math.cos(endRadians), centerY + radius * Math.sin(endRadians)];
275
- return [
276
- start,
277
- [start[0] - handleLength * Math.sin(startRadians), start[1] + handleLength * Math.cos(startRadians)],
278
- [end[0] + handleLength * Math.sin(endRadians), end[1] - handleLength * Math.cos(endRadians)],
279
- end,
280
- ];
281
- }
282
- case 'cubic': {
283
- if (startProgress >= 1)
284
- return [stroke.segment[3], stroke.segment[3], stroke.segment[3], stroke.segment[3]];
285
- const [, rest] = splitCubic(stroke.segment, startProgress);
286
- return splitCubic(rest, (endProgress - startProgress) / (1 - startProgress))[0];
287
- }
288
- }
289
- };
290
- /** Lays out cubic segments along the strokes; see `PairLayout.breakpoints`. */
291
- const layoutStrokes = (strokes, breakpoints) => fromSegments(breakpoints.slice(1).map((segmentEnd, segmentIndex) => {
292
- const segmentStart = breakpoints[segmentIndex];
293
- const strokeIndex = Math.min(Math.floor(segmentStart + EPSILON), strokes.length - 1);
294
- return strokePiece(strokes[strokeIndex], segmentStart - strokeIndex, segmentEnd - strokeIndex);
295
- }));
296
- const CUBIC_LENGTH_SAMPLES = 64;
297
- const distance = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
298
- const strokeLength = (stroke) => {
299
- switch (stroke.kind) {
300
- case 'line': return distance(stroke.from, stroke.to);
301
- case 'arc': return arcSweep(stroke) * DEGREE * stroke.radius;
302
- case 'cubic': {
303
- let length = 0, previousPoint = stroke.segment[0];
304
- for (let sample = 1; sample <= CUBIC_LENGTH_SAMPLES; sample++) {
305
- const point = strokePiece(stroke, 0, sample / CUBIC_LENGTH_SAMPLES)[3];
306
- length += distance(previousPoint, point);
307
- previousPoint = point;
308
- }
309
- return length;
310
- }
311
- }
312
- };
313
- /** Default breakpoints: segments spread over the strokes by length, arcs get at least one segment per 90°. */
314
- const defaultBreakpoints = (strokes, segmentCount) => {
315
- const lengths = strokes.map(strokeLength);
316
- const segmentsPerStroke = strokes.map((stroke) => (stroke.kind === 'arc' ? Math.ceil(arcSweep(stroke) / MAX_ARC_PER_SEGMENT - EPSILON) : 1));
317
- const usedSegments = () => segmentsPerStroke.reduce((sum, count) => sum + count, 0);
318
- if (usedSegments() > segmentCount)
319
- throw new Error(`needs ${usedSegments()} segments, the set has ${segmentCount}`);
320
- while (usedSegments() < segmentCount) {
321
- // one more segment for the stroke whose segments are currently the longest
322
- let longest = 0;
323
- for (let strokeIndex = 1; strokeIndex < strokes.length; strokeIndex++) {
324
- const segmentLength = lengths[strokeIndex] / segmentsPerStroke[strokeIndex];
325
- if (segmentLength > lengths[longest] / segmentsPerStroke[longest] + EPSILON)
326
- longest = strokeIndex;
327
- }
328
- segmentsPerStroke[longest]++;
329
- }
330
- const breakpoints = [0];
331
- segmentsPerStroke.forEach((count, strokeIndex) => {
332
- for (let part = 1; part <= count; part++)
333
- breakpoints.push(+(strokeIndex + part / count).toFixed(6));
334
- });
335
- return breakpoints;
336
- };
337
- // ---- icon sets ----
338
- // A shape of a set with several pieces is the path numbers of every piece one after another.
339
- /** The set itself is the first piece. */
340
- const piecesOf = (iconSet) => [iconSet, ...(iconSet.morePieces ?? [])];
341
- /** Path numbers of one piece: start point + 6 per segment. */
342
- const pieceLengthOf = (iconSet) => 2 + 6 * iconSet.segmentCount;
343
- /** Splits a shape into the path numbers of its pieces. */
344
- const splitPieces = (path, pieceLength) => Array.from({ length: path.length / pieceLength }, (_, pieceIndex) => path.slice(pieceIndex * pieceLength, (pieceIndex + 1) * pieceLength));
345
- const strokesOf = (pieceSet, state, pieceIndex) => {
346
- const strokes = pieceSet.strokes[state];
347
- if (!strokes)
348
- throw new Error(`state "${state}" has no strokes in piece ${pieceIndex}`);
349
- return strokes;
350
- };
351
- /** Every state of the set laid out with its default breakpoints. */
352
- const defaultShapes = (iconSet) => Object.fromEntries(Object.keys(iconSet.strokes).map((state) => [state, piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => {
353
- const strokes = strokesOf(pieceSet, state, pieceIndex);
354
- return layoutStrokes(strokes, defaultBreakpoints(strokes, iconSet.segmentCount));
355
- })]));
356
- /**
357
- * `shape` pressed flat onto the straight line `from` → `to`: every point moves onto the line, keeping its position
358
- * along the line's direction (the shape's extent along it is stretched to the line's length).
359
- */
360
- const flattenOntoLine = (shape, from, to) => {
361
- const direction = [to[0] - from[0], to[1] - from[1]];
362
- const positions = Array.from({ length: shape.length / 2 }, (_, pointIndex) => shape[2 * pointIndex] * direction[0] + shape[2 * pointIndex + 1] * direction[1]);
363
- const lowest = Math.min(...positions), highest = Math.max(...positions);
364
- return positions.flatMap((position) => {
365
- const along = highest - lowest > EPSILON ? (position - lowest) / (highest - lowest) : 0.5;
366
- return [from[0] + direction[0] * along, from[1] + direction[1] * along];
367
- });
368
- };
369
- /** The shape of `state` laid out for morphing to or from `otherState` (each piece by its own pair layout). */
370
- const shapeForPair = (iconSet, shapes, state, otherState) => {
371
- const pieceLength = pieceLengthOf(iconSet);
372
- const pieceForPair = (pieceSet, pieceIndex, pieceState, pieceOther, mayFlatten) => {
373
- const defaultPiece = splitPieces(shapes[pieceState], pieceLength)[pieceIndex];
374
- const pairLayout = pieceSet.pairLayouts[pieceState]?.[pieceOther];
375
- if (!pairLayout)
376
- return defaultPiece;
377
- if ((pairLayout.draw || pairLayout.wipe) && mayFlatten) {
378
- // not there: the other side's piece shrunk to its start point (not drawn); the morph draws it (see drawnPieces)
379
- const otherPiece = pieceForPair(pieceSet, pieceIndex, pieceOther, pieceState, false);
380
- return Array.from({ length: pieceLength }, (_, index) => otherPiece[index % 2]);
381
- }
382
- if (pairLayout.flatten && mayFlatten) {
383
- const [line] = strokesOf(pieceSet, pieceState, pieceIndex);
384
- if (line.kind !== 'line')
385
- throw new Error(`flatten: state "${pieceState}" must be one straight line`);
386
- // the other side as it is laid out for this pair (if it flattens too, its plain layout)
387
- return flattenOntoLine(pieceForPair(pieceSet, pieceIndex, pieceOther, pieceState, false), line.from, line.to);
388
- }
389
- const strokes = pairLayout.tracing ? pieceSet.alternativeStrokes[pairLayout.tracing] : strokesOf(pieceSet, pieceState, pieceIndex);
390
- const laidOut = pairLayout.breakpoints ? layoutStrokes(strokes, pairLayout.breakpoints) : defaultPiece;
391
- const reordered = reorderSegments(laidOut, pairLayout);
392
- return pairLayout.rotation ? rotateAroundCenter(reordered, pairLayout.rotation) : reordered;
393
- };
394
- return piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => pieceForPair(pieceSet, pieceIndex, state, otherState, true));
395
- };
396
- // ---- drawing a piece along its path (PairLayout.draw) ----
397
- /** Rough length of a cubic segment: the average of its chord and its control polygon. */
398
- const segmentLength = ([start, control1, control2, end]) => {
399
- const distance = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
400
- return (distance(start, end) + distance(start, control1) + distance(control1, control2) + distance(control2, end)) / 2;
401
- };
402
- /**
403
- * The part of a piece between two shares of its length (0…1). The segments outside shrink to the part's ends,
404
- * so the number count stays the same.
405
- */
406
- const pieceBetween = (piece, fromFraction, toFraction) => {
407
- const segments = toSegments(piece);
408
- const lengths = segments.map(segmentLength);
409
- const totalLength = lengths.reduce((sum, length) => sum + length, 0);
410
- const from = fromFraction * totalLength, to = toFraction * totalLength;
411
- let travelled = 0;
412
- const parts = segments.map((segment, index) => {
413
- const segmentStart = travelled;
414
- travelled += lengths[index];
415
- if (!lengths[index] || travelled <= from || segmentStart >= to)
416
- return null;
417
- const startProgress = Math.max(0, (from - segmentStart) / lengths[index]);
418
- const endProgress = Math.min(1, (to - segmentStart) / lengths[index]);
419
- const [head] = splitCubic(segment, endProgress);
420
- return startProgress > 0 ? splitCubic(head, startProgress / endProgress)[1] : head;
421
- });
422
- // before the part: its start point; after (or between): the end of the part drawn so far
423
- let pen = parts.find((part) => part)?.[0] ?? segments[0][0];
424
- return fromSegments(parts.map((part) => {
425
- if (!part)
426
- return [pen, pen, pen, pen];
427
- pen = part[3];
428
- return part;
429
- }));
430
- };
431
- /** The first `fraction` (0…1) of a piece along its length, as if a pen had drawn that far. */
432
- const trimPiece = (piece, fraction) => pieceBetween(piece, 0, Math.min(Math.max(fraction, 0), 1));
433
- /** The x range a wipe's edge moves through (the icon box's drawing area, line width included). */
434
- const WIPE_FROM_X = 2;
435
- const WIPE_TO_X = 22;
436
- const WIPE_SAMPLES_PER_SEGMENT = 40;
437
- /** Points along a piece as [share of its length, x]. */
438
- const xAlong = (piece) => {
439
- const segments = toSegments(piece);
440
- const lengths = segments.map(segmentLength);
441
- const totalLength = lengths.reduce((sum, length) => sum + length, 0) || 1;
442
- const samples = [];
443
- let travelled = 0;
444
- segments.forEach((segment, index) => {
445
- for (let step = 0; step <= WIPE_SAMPLES_PER_SEGMENT; step++) {
446
- const progress = step / WIPE_SAMPLES_PER_SEGMENT;
447
- const [x] = progress < 1 ? splitCubic(segment, progress)[1][0] : segment[3];
448
- samples.push([(travelled + progress * lengths[index]) / totalLength, x]);
449
- }
450
- travelled += lengths[index];
451
- });
452
- return samples;
453
- };
454
- /**
455
- * 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
456
- * to that piece's leading side at rest, so the edge rides at its front and it starts just outside the box.
457
- */
458
- const wipeRange = (slidingPiece, direction) => {
459
- if (!slidingPiece)
460
- return [WIPE_FROM_X, WIPE_TO_X];
461
- const xs = xAlong(slidingPiece).map(([, x]) => x);
462
- return direction === 'right' ? [WIPE_FROM_X, Math.max(...xs)] : [Math.min(...xs), WIPE_TO_X];
463
- };
464
- /**
465
- * A piece behind a vertical edge (PairLayout.wipe): at `fraction` 0…1 of the wipe, the edge has moved that far
466
- * through `edgeRange` in `direction`, and only the part it has passed shows. That part must be one stretch of the
467
- * path. `slides` (PairLayout.slide): the piece moves with the edge from outside the box; only its part inside shows.
468
- */
469
- const wipePiece = (piece, fraction, direction, edgeRange = [WIPE_FROM_X, WIPE_TO_X], slides = false) => {
470
- if (fraction >= 1)
471
- return piece.slice();
472
- const [fromX, toX] = edgeRange;
473
- if (slides) {
474
- // in the piece's own frame the box's side moves the other way: the leading part shows first
475
- const shift = (1 - Math.max(fraction, 0)) * (toX - fromX) * (direction === 'right' ? -1 : 1);
476
- return wipePiece(piece, fraction, direction === 'right' ? 'left' : 'right', edgeRange)
477
- .map((value, index) => (index % 2 ? value : value + shift));
478
- }
479
- const travel = Math.max(fraction, 0) * (toX - fromX);
480
- const edge = direction === 'right' ? fromX + travel : toX - travel;
481
- // samples along the length: [share of the length, > 0 where the edge has passed]
482
- const samples = xAlong(piece).map(([share, x]) => [share, direction === 'right' ? edge - x : x - edge]);
483
- const first = samples.findIndex(([, passed]) => passed >= 0);
484
- if (first < 0)
485
- return pieceBetween(piece, 0, 0);
486
- let last = samples.length - 1;
487
- while (samples[last][1] < 0)
488
- last--;
489
- // where the edge crosses between two samples
490
- const crossing = ([shareA, passedA], [shareB, passedB]) => shareA + (shareB - shareA) * (passedA / (passedA - passedB));
491
- const from = first > 0 ? crossing(samples[first - 1], samples[first]) : 0;
492
- const to = last < samples.length - 1 ? crossing(samples[last], samples[last + 1]) : 1;
493
- return pieceBetween(piece, from, to);
494
- };
495
- /** The pieces drawn (or erased) along their path when morphing `state` → `otherState` (PairLayout.draw). */
496
- const drawnPieces = (iconSet, state, otherState) => piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => {
497
- const windowOf = (draw) => (Array.isArray(draw) ? draw : typeof draw === 'number' ? [1 - draw, 1] : [0, 1]);
498
- // the layout of the state where the piece is absent
499
- const absentIn = (absentState, presentState) => {
500
- const pairLayout = pieceSet.pairLayouts[absentState]?.[presentState];
501
- return pairLayout?.draw || pairLayout?.wipe ? pairLayout : undefined;
502
- };
503
- const drawIn = absentIn(state, otherState);
504
- const pairLayout = drawIn ?? absentIn(otherState, state);
505
- if (!pairLayout)
506
- return [];
507
- const [start, end] = windowOf(pairLayout.draw || true);
508
- return [{ pieceIndex, drawsIn: Boolean(drawIn), start, end, wipe: pairLayout.wipe, slide: pairLayout.slide }];
509
- });
510
- /**
511
- * A frame of a morph `from` → `to` at `progress` with the drawn pieces replaced: drawn in from the start along
512
- * `to`'s piece, or erased back along `from`'s piece.
513
- */
514
- const withDrawnPieces = (frame, from, to, progress, pieceLength, drawn) => {
515
- if (!drawn.length)
516
- return frame;
517
- const presentPiece = ({ pieceIndex, drawsIn }) => (drawsIn ? to : from).slice(pieceIndex * pieceLength, (pieceIndex + 1) * pieceLength);
518
- // one edge for every wipe of the morph, riding at the front of the (first) sliding piece
519
- const slider = drawn.find((piece) => piece.slide && piece.wipe);
520
- const edgeRange = wipeRange(slider ? presentPiece(slider) : null, slider?.wipe ?? 'right');
521
- const result = frame.slice();
522
- for (const drawnPiece of drawn) {
523
- const { pieceIndex, drawsIn, start, end, wipe, slide } = drawnPiece;
524
- const present = presentPiece(drawnPiece);
525
- // erasing is drawing in played backwards
526
- const fraction = ((drawsIn ? progress : 1 - progress) - start) / (end - start);
527
- result.splice(pieceIndex * pieceLength, pieceLength, ...(wipe ? wipePiece(present, fraction, wipe, edgeRange, slide) : trimPiece(present, fraction)));
528
- }
529
- return result;
530
- };
531
- 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 };
532
- })();
533
-
534
- // sets/lucide.ts
535
- const { LUCIDE_SCALE, LUCIDE_STROKE_WIDTH, fromLucide, lucideLength, lucideLine, lucideArc, lucideCubic, lucidePoint, LUCIDE_SLASH } = (() => {
536
- // Shared by every Lucide-based icon set: Lucide draws in y 3…21 (18 units) with a 2-unit line; our icons get the
537
- // same room as the digits (16 units, y 4…20; user's choice 2026-10-26), so every icon is scaled around the centre
538
- // 12,12 — line width included, so each icon looks exactly like Lucide's, only with more margin in its box.
539
- const LUCIDE_SCALE = 16 / 18;
540
- /** Lucide's 2-unit line, scaled with the icon. */
541
- const LUCIDE_STROKE_WIDTH = 2 * LUCIDE_SCALE;
542
- /** A point (or corner) of a Lucide icon moved into our icon box. */
543
- const fromLucide = ([x, y]) => [12 + (x - 12) * LUCIDE_SCALE, 12 + (y - 12) * LUCIDE_SCALE];
544
- /** A length of a Lucide icon (e.g. a corner radius) in our icon box. */
545
- const lucideLength = (length) => length * LUCIDE_SCALE;
546
- // Strokes written in Lucide coordinates (for the icon sets added from 2026-10-28 on).
547
- /** A straight line between two Lucide points. */
548
- const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
549
- /** A circular arc in Lucide coordinates (angles in degrees, `endAngle > startAngle` = clockwise on screen). */
550
- const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
551
- /** A cubic Bézier curve in Lucide coordinates. */
552
- const lucideCubic = (...coordinates) => {
553
- const [startX, startY, control1X, control1Y, control2X, control2Y, endX, endY] = coordinates;
554
- return cubic(...fromLucide([startX, startY]), ...fromLucide([control1X, control1Y]), ...fromLucide([control2X, control2Y]), ...fromLucide([endX, endY]));
555
- };
556
- /** A missing piece at rest: a single point, never drawn (see toPathData). */
557
- const lucidePoint = (x, y) => [lucideLine(x, y, x, y)];
558
- /** The diagonal slash of Lucide's "-off" icons, from the top left. */
559
- const LUCIDE_SLASH = [lucideLine(2, 2, 22, 22)];
560
- return { LUCIDE_SCALE, LUCIDE_STROKE_WIDTH, fromLucide, lucideLength, lucideLine, lucideArc, lucideCubic, lucidePoint, LUCIDE_SLASH };
561
- })();
562
-
563
- // sets/check-mark.ts
564
- const { checkMarkPiece } = (() => {
565
- // The check mark shared by "checkbox" and "radio" (Lucide 1.61.0 "square-check" / "circle-check" and their "-big"
566
- // versions draw the same checks; ISC, see LICENSE-lucide). Coordinates are Lucide's, scaled by fromLucide.
567
- // Drawn left to right, short stroke then long stroke. unchecked has no check: the check is drawn in like a pen
568
- // writing it — down the short stroke, up the long one (the user's sketch; PairLayout.draw) — and erased back.
569
- /** A straight line between two Lucide points, moved into our icon box. */
570
- const lucideLine = (from, to) => line(...fromLucide(from), ...fromLucide(to));
571
- const CHECK = [lucideLine([8, 12], [10.5, 14.5]), lucideLine([10.5, 14.5], [16, 9])];
572
- const BIG_CHECK = [lucideLine([9, 11], [12, 14]), lucideLine([12, 14], [22, 4])];
573
- // every check ↔ check: short stroke 4 segments, long stroke 8 (of 12), so the corners meet
574
- 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] };
575
- const CHECK_STATES = ['unchecked', 'checked', 'checked-big'];
576
- /**
577
- * The check piece of a set with the states unchecked, checked, checked-big (12 segments).
578
- * `hiddenAt`: a Lucide point on the outline at the check's height, where unchecked's check rests at rest
579
- * (tiny, under the outline's line, so it doesn't show — morphs draw the check instead).
580
- */
581
- const checkMarkPiece = (hiddenAt) => {
582
- const [x, y] = hiddenAt;
583
- return {
584
- strokes: {
585
- unchecked: [lucideLine([x, y - 0.1], [x, y]), lucideLine([x, y], [x, y + 0.1])],
586
- checked: CHECK,
587
- 'checked-big': BIG_CHECK,
588
- },
589
- alternativeStrokes: {},
590
- pairLayouts: {
591
- ...Object.fromEntries(CHECK_STATES.map((state) => [state, Object.fromEntries(CHECK_STATES.filter((other) => other !== state).map((other) => [other, CHECK_FOR_CHECK]))])),
592
- unchecked: { checked: { draw: true }, 'checked-big': { draw: true } },
593
- },
594
- };
595
- };
596
- return { checkMarkPiece };
597
- })();
598
-
599
- // sets/checkbox.ts
600
- const { checkboxIcons } = (() => {
601
- // Icon set "checkbox": unchecked, checked, checked-big — Lucide 1.61.0 icons "square", "square-check",
602
- // "square-check-big" (ISC, see LICENSE-lucide). Coordinates below are Lucide's; fromLucide / lucideLength scale them
603
- // into our icon box (more margin, like the digits — see lucide.ts). Built from rounded corners, so `roundness` works.
604
- // Two pieces:
605
- // piece 1, the box: the same square in unchecked and checked (it doesn't move); checked-big opens its top right
606
- // corner where the big check leaves the box — both ends of the gap pull back to the gap's edges.
607
- // piece 2, the check (check-mark.ts, shared with "radio"): drawn in like a pen from unchecked (the user's sketch).
608
- // (Rejected: a box copy folding into the check — tangled; the check sliding in from the left edge — user: bad.)
609
- const SQUARE_CORNERS = [[3, 3], [21, 3], [21, 21], [3, 21]].map(fromLucide);
610
- const LUCIDE_RADIUS = lucideLength(2);
611
- /** square-check-big's box: the square without its top right corner (gap from 17.344,3 to 21,10.656). */
612
- const openBox = (square) => {
613
- const [top, , right, bottomRight, bottom, bottomLeft, left, topLeft] = square;
614
- return [
615
- line(...fromLucide([21, 10.656]), ...right.to),
616
- bottomRight, bottom, bottomLeft, left, topLeft,
617
- line(...top.from, ...fromLucide([17.344, 3])),
618
- ];
619
- };
620
- // box, checked/unchecked ↔ checked-big: the square laid out so its 12 segments line up with the open box's —
621
- // top (2: the part that stays, the part in the gap), top right corner (2, in the gap), right side (2: in the gap,
622
- // the part that stays), bottom right, bottom, bottom left, left (2), top left — started at the corner's second half.
623
- // The open box gets two zero-length segments at each end of the gap, where the vanishing parts pull back to.
624
- 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 };
625
- const OPEN_BOX_FOR_SQUARE = { breakpoints: [0, 0, 0, 1, 2, 3, 4, 4.5, 5, 6, 7, 7, 7] };
626
- /** The check rests (hidden) on the box's left edge. */
627
- const CHECK_PIECE = checkMarkPiece([3, 12]);
628
- const buildCheckboxIcons = (radius) => {
629
- const square = roundedPolygon(SQUARE_CORNERS, radius);
630
- return {
631
- segmentCount: 12,
632
- strokeWidth: LUCIDE_STROKE_WIDTH,
633
- lineCap: 'round',
634
- lineJoin: 'round',
635
- strokes: { unchecked: square, checked: square, 'checked-big': openBox(square) },
636
- alternativeStrokes: {},
637
- pairLayouts: {
638
- unchecked: { 'checked-big': SQUARE_FOR_OPEN_BOX },
639
- checked: { 'checked-big': SQUARE_FOR_OPEN_BOX },
640
- 'checked-big': { unchecked: OPEN_BOX_FOR_SQUARE, checked: OPEN_BOX_FOR_SQUARE },
641
- },
642
- morePieces: [CHECK_PIECE],
643
- spins: {},
644
- withRoundness: (roundness) => buildCheckboxIcons(LUCIDE_RADIUS * roundness),
645
- };
646
- };
647
- const checkboxIcons = buildCheckboxIcons(LUCIDE_RADIUS);
648
- return { checkboxIcons };
649
- })();
650
-
651
- // sets/characters.ts
652
- const { withTextCharacters } = (() => {
653
- // The characters every digit set shares besides 0–9: `-` `/` `.` `,` `:` (space = the empty state, see morph.ts).
654
- // Each is drawn as one straight line, placed by the set's text layout and stroke width, and morphs to and from
655
- // every digit by flattening (the user's choice 2026-10-27): the digit is pressed flat onto the line.
656
- // `:` has two dots, so digit sets get a second piece: for every other state it repeats the first piece exactly
657
- // (same strokes and layouts, drawn on top of itself, so it doesn't show).
658
- const DIGITS = [...'0123456789'];
659
- /** Half the length of a dot's line, relative to the stroke width: round ends make it a dot 1.1 × the stroke wide. */
660
- const DOT_HALF_LENGTH = 0.05;
661
- /** Adds `-` `/` `.` `,` `:` to a digit set (which needs a text layout) as morphing states. */
662
- const withTextCharacters = (iconSet) => {
663
- const { baselineY, colonDotsY } = iconSet.textLayout;
664
- const dotHalfLength = iconSet.strokeWidth * DOT_HALF_LENGTH;
665
- const dotY = baselineY; // dot centre on the line the digits stand on
666
- const dot = (y) => line(12 - dotHalfLength, y, 12 + dotHalfLength, y);
667
- const characters = {
668
- '-': [line(9.5, 12, 14.5, 12)],
669
- '/': [line(9.5, baselineY, 14.5, 24 - baselineY)],
670
- '.': [dot(dotY)],
671
- ',': [line(12.3, dotY, 11, baselineY + 2.2)], // from the dot down to the left
672
- ':': [dot(colonDotsY[0])],
673
- };
674
- const flattenPairs = (layouts) => {
675
- const merged = { ...layouts };
676
- for (const character of Object.keys(characters)) {
677
- merged[character] = { ...merged[character], ...Object.fromEntries(DIGITS.map((digit) => [digit, { flatten: true }])) };
678
- }
679
- return merged;
680
- };
681
- const pairLayouts = flattenPairs(iconSet.pairLayouts);
682
- const strokes = { ...iconSet.strokes, ...characters };
683
- return {
684
- ...iconSet,
685
- strokes,
686
- pairLayouts,
687
- morePieces: [{
688
- strokes: { ...strokes, ':': [dot(colonDotsY[1])] },
689
- alternativeStrokes: iconSet.alternativeStrokes,
690
- pairLayouts,
691
- }],
692
- };
693
- };
694
- return { withTextCharacters };
695
- })();
696
-
697
- // sets/condensed.ts
698
- const { condensedDigits } = (() => {
699
- // Digit set "condensed": tall, narrow, thin "display" digits with rounded corners, drafted from the reference images in
700
- // icons/condensed/ (the set format is described in ../types.ts).
701
- //
702
- // The shapes are written on the images' own pixel grid and converted: x/y are the stroke's middle line in image
703
- // pixels, measured from the digit's ink box. Every digit is 94 px tall with a 6 px stroke, most are 50 px wide:
704
- // middle lines at x 3 / 47 and y 3 (top) / 47 (middle) / 91 (bottom). 5.5 image px = 1 grid unit, so the digits
705
- // span x 8…16, y 4…20.
706
- // - straight strokes with rounded corners (roundedPolyline), round ends
707
- // - 0 = stadium; 8 = two stacked rounded rectangles sharing the middle bar; 9 = 6 turned 180° around 12,12;
708
- // the 5 ends with the same small upturn as the 9
709
- // - T-junctions (4's right stem) are drawn out and back over themselves
710
- const PIXELS_PER_UNIT = 5.5;
711
- /** Image pixels (middle line, measured from the ink box of a digit `inkWidth` px wide) → grid units. */
712
- const fromImage = (inkWidth) => ([x, y]) => [12 + (x - inkWidth / 2) / PIXELS_PER_UNIT, 4 + (y - 3) / PIXELS_PER_UNIT];
713
- /** A rounded polyline given in image pixels; radii in image pixels too. */
714
- const drawn = (inkWidth, points, radii) => roundedPolyline(points.map(fromImage(inkWidth)), Array.isArray(radii) ? radii.map((radius) => radius / PIXELS_PER_UNIT) : radii / PIXELS_PER_UNIT);
715
- const turnedAround = ([x, y]) => [24 - x, 24 - y];
716
- // corner radii (image px), chosen by pixel comparison with the images: one radius for (almost) every corner
717
- const CORNER_RADIUS = 12;
718
- const OUTER_RADIUS = CORNER_RADIUS; // outer corners of the boxes (top and bottom)
719
- const MIDDLE_RADIUS = CORNER_RADIUS; // corners where a box meets the middle bar
720
- const THREE_BOWL_RADIUS = 16; // the 3's bowls are rounder
721
- const THREE_MIDDLE_END_X = 15;
722
- /** End of the small upturn at the bottom left of 5 and 9 (and of the hook at the top right of 6). */
723
- const HOOK_END_Y = 79;
724
- // the 6: hook at the top right, over the top, down the left side, round the bottom, up to the middle bar, back left
725
- const SIX_POINTS = [[47, 94 - HOOK_END_Y], [47, 3], [3, 3], [3, 91], [47, 91], [47, 47], [3, 47]];
726
- const SIX_RADII = [0, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0];
727
- // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
728
- const condensedDigits = withTextCharacters({
729
- segmentCount: 20,
730
- strokeWidth: 6 / PIXELS_PER_UNIT,
731
- lineCap: 'round',
732
- lineJoin: 'round',
733
- strokes: {
734
- // stadium, from the top middle counter-clockwise
735
- 0: drawn(50, [[25, 3], [3, 3], [3, 91], [47, 91], [47, 3], [25, 3]], 22),
736
- // flag → stem
737
- 1: drawn(17, [[3, 3], [14, 3], [14, 91]], 0),
738
- // top bar → right side → middle bar → left side → bottom bar
739
- 2: drawn(50, [[3, 3], [47, 3], [47, 47], [3, 47], [3, 91], [47, 91]], [0, CORNER_RADIUS, CORNER_RADIUS, CORNER_RADIUS, CORNER_RADIUS, 0]),
740
- // top bar → upper bowl → middle bar out to the left and back → lower bowl → bottom bar
741
- 3: drawn(50, [[3, 3], [47, 3], [47, 47], [THREE_MIDDLE_END_X, 47], [47, 47], [47, 91], [3, 91]], [0, THREE_BOWL_RADIUS, THREE_BOWL_RADIUS, 0, THREE_BOWL_RADIUS, THREE_BOWL_RADIUS, 0]),
742
- // left stem down → crossbar → right stem up to the top and back down to the bottom
743
- 4: drawn(50, [[3, 3], [3, 47], [47, 47], [47, 3], [47, 91]], [0, CORNER_RADIUS, 0, 0, 0]),
744
- // top bar right → left, left side down, middle bar → right side → bottom bar → small upturn
745
- 5: drawn(50, [[47, 3], [3, 3], [3, 47], [47, 47], [47, 91], [3, 91], [3, HOOK_END_Y]], [0, 0, 0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, 0]),
746
- 6: drawn(50, SIX_POINTS, SIX_RADII),
747
- // top bar → slanted stroke down
748
- 7: drawn(49, [[3, 3], [46, 3], [24.5, 91]], 0),
749
- // from the waist: top box (left side up, clockwise), along the middle bar, bottom box (counter-clockwise)
750
- 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]),
751
- // the 6 turned 180°, drawn the other way round (box first, then the small upturn)
752
- 9: roundedPolyline(SIX_POINTS.map(fromImage(50)).map(turnedAround).reverse(), [...SIX_RADII].reverse().map((radius) => radius / PIXELS_PER_UNIT)),
753
- },
754
- alternativeStrokes: {
755
- // the 8 traced like the 0 (from the top, counter-clockwise): left side of the top box down to the middle of
756
- // the middle bar, once around the bottom box back to the middle, right side of the top box back up
757
- '8-like-0': drawn(50, [[25, 3], [3, 3], [3, 47], [25, 47], [3, 47], [3, 91], [47, 91], [47, 47], [25, 47], [47, 47], [47, 3], [25, 3]], [0, OUTER_RADIUS, MIDDLE_RADIUS, 0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0, MIDDLE_RADIUS, OUTER_RADIUS, 0]),
758
- // the 8 traced like the 3 (as in the square set), starting and ending at the left part of the middle bar: around the top
759
- // box, along the middle bar out to the left and back, around the bottom box
760
- '8-like-3': drawn(50, [[3 + MIDDLE_RADIUS, 47], [3, 47], [3, 3], [47, 3], [47, 47], [3 + MIDDLE_RADIUS, 47], [47, 47], [47, 91], [3, 91], [3, 47], [3 + MIDDLE_RADIUS, 47]], [0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0]),
761
- },
762
- // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd
763
- pairLayouts: {
764
- 0: {
765
- 1: { startSegment: 5, reversed: true },
766
- 2: { startSegment: 5, reversed: true },
767
- 3: { startSegment: 5, reversed: true },
768
- 4: { breakpoints: [0, 0.6458, 0.6667, 1, 1.2292, 1.4375, 1.4583, 1.4583, 1.4583, 2, 3, 4, 4.5208, 4.5208, 4.5208, 4.5208, 4.75, 5, 5.3125, 5.3125, 6] },
769
- 5: { startSegment: 4, reversed: true },
770
- 7: { startSegment: 5, reversed: true },
771
- 9: { startSegment: 6, reversed: true },
772
- },
773
- 1: {
774
- 0: { breakpoints: [0, 0, 0, 0, 0, 0.5, 1, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.8542, 1.8542, 1.8542, 1.8542, 1.8542, 1.8542, 1.8542, 2] },
775
- },
776
- 2: {
777
- 6: { breakpoints: [0, 1, 1, 1, 1, 2, 3, 4, 5, 6, 7, 7.5, 8, 8.2917, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 9] },
778
- 9: { breakpoints: [0, 0.2292, 0.2292, 0.2292, 0.2292, 0.2292, 0.2292, 0.2292, 0.3333, 0.6875, 1, 1.5, 2, 3, 4, 5, 6, 7, 8, 8, 9] },
779
- 0: { breakpoints: [0, 0, 0, 0.0625, 0.3333, 0.6875, 1, 2, 2.5, 3, 3.5625, 3.6042, 4, 5, 6, 7, 7, 7, 7, 8, 9] },
780
- },
781
- 3: {
782
- 4: { breakpoints: [0, 0, 1, 2, 2, 2, 3, 3.4167, 3.4167, 4, 5, 6, 6.5833, 6.5833, 7, 8, 8.4375, 8.4583, 8.4583, 9, 10] },
783
- 9: { breakpoints: [0, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.375, 0.8333, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9.6667, 10] },
784
- 0: { breakpoints: [0, 0, 0, 0.0833, 0.375, 0.8333, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9.1667, 9.625, 9.9167, 10, 10, 10] },
785
- },
786
- 4: {
787
- 6: { breakpoints: [0, 0, 0, 1, 1.8333, 2, 2.7083, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 3, 4, 5] },
788
- 8: { breakpoints: [0, 0, 0.3333, 0.6875, 1, 2, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.6667, 3, 3.5625, 3.75, 4, 5] },
789
- 9: { breakpoints: [0, 1, 1, 1, 1, 2, 3, 4, 4.125, 4.1875, 4.1875, 4.1875, 4.1875, 4.1875, 4.2292, 4.3958, 4.4375, 4.875, 5, 5, 5] },
790
- 3: { breakpoints: [0, 1, 2, 3, 4, 4.1875, 4.3125, 4.4583, 4.5, 4.5, 4.5, 4.5, 4.5, 4.5, 4.5417, 4.6875, 4.8125, 4.9583, 5, 5, 5] },
791
- 0: { breakpoints: [0, 0.0625, 0.3333, 0.6875, 1, 1.5208, 1.7083, 1.7917, 1.8542, 2, 2.3125, 2.6667, 2.9375, 3, 3, 3, 3.25, 3.5, 3.75, 4, 5] },
792
- },
793
- 5: {
794
- 8: { breakpoints: [0, 0, 0, 0, 0.25, 0.4375, 1, 1.25, 1.4375, 2, 2.3333, 3, 4, 5, 6, 7, 8, 8, 8, 8, 8] },
795
- 0: { breakpoints: [0, 0.8125, 0.8125, 0.8125, 0.8125, 1, 2, 3, 3.3958, 3.4375, 4, 4.5, 5, 6, 6.5, 7, 8, 8, 8, 8, 8] },
796
- },
797
- 6: {
798
- 8: { breakpoints: [0, 1, 1, 1, 1, 1, 2, 3, 3.5417, 3.5417, 3.6458, 4, 5, 6, 7, 8, 9, 9.6667, 10, 10, 10] },
799
- 4: { breakpoints: [0, 1, 2, 3, 4, 5, 6, 7, 8, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 9, 10] },
800
- 2: { breakpoints: [0, 1, 1, 1, 1, 1, 2, 3, 3.5833, 3.6458, 4, 4.5, 5, 5.5, 6, 6.125, 6.125, 7, 8, 9, 10] },
801
- },
802
- 7: {
803
- 8: { breakpoints: [0, 0, 0, 0.2292, 0.6042, 0.7292, 0.8333, 1, 1.0833, 1.125, 1.3542, 1.5208, 1.5417, 1.7292, 1.8125, 1.8125, 1.8125, 1.8125, 1.8125, 1.8125, 2] },
804
- 0: { breakpoints: [0, 0, 0, 0.0417, 0.25, 0.4375, 1, 1.125, 1.2292, 1.3542, 1.4792, 1.5833, 1.7083, 1.8542, 1.8958, 1.8958, 1.8958, 1.8958, 1.8958, 1.8958, 2] },
805
- },
806
- 8: {
807
- // as in round and square: the 0's left and right sides move in to the middle of the 8's middle bar
808
- 0: { tracing: '8-like-0', breakpoints: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9.5, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] },
809
- 4: { startSegment: 5, reversed: true },
810
- 5: { startSegment: 10, reversed: true },
811
- 6: { startSegment: 3 },
812
- 7: { breakpoints: [0, 1, 2, 3, 3.7083, 4, 4.2708, 4.4792, 4.7083, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] },
813
- 9: { startSegment: 17 },
814
- // as in the square set: both ends of the 3 move to the left part of its middle bar, where the 8's boxes close
815
- 3: { tracing: '8-like-3', breakpoints: [0, 1, 2, 3, 4, 5, 6, 6.5, 7, 7.6458, 8, 8.3542, 9, 9.5, 10, 11, 12, 13, 14, 15, 16] },
816
- },
817
- 9: {
818
- 8: { breakpoints: [0, 0, 0.3333, 1, 2, 2.5, 3, 4, 4.5, 5, 6, 6.1458, 6.3542, 6.4583, 6.4583, 7, 8, 9, 9, 9, 10] },
819
- 4: { breakpoints: [0, 1, 2, 2, 2, 3, 4, 5, 6, 6.0625, 6.0625, 6.0625, 6.0625, 6.0625, 6.125, 6.3542, 6.4167, 7, 8, 9, 10] },
820
- 3: { breakpoints: [0, 1, 2, 3, 3.7708, 3.7708, 3.7708, 3.7708, 4, 4.5, 5, 6, 6.2917, 6.5, 6.5, 6.5, 6.7083, 7, 8, 9, 10] },
821
- 2: { breakpoints: [0, 1, 2, 3, 3.7917, 3.7917, 3.7917, 3.7917, 4, 4.4792, 5, 5.5, 6, 6.3542, 6.4167, 7, 8, 9, 9, 9, 10] },
822
- 0: { breakpoints: [0, 1, 2, 2.5, 3, 4, 4.5, 5, 6, 6.1458, 6.3333, 6.5, 6.6667, 6.8542, 7, 8, 8.5, 9, 10, 10, 10] },
823
- // turned back by 180° this is exactly the 6 (same segment split, same direction) → a pure spin
824
- 6: {
825
- breakpoints: [0, 0.5, 1, 2, 2.5, 3, 4, 4.5, 5, 5.5, 6, 6.25, 6.5, 6.75, 7, 7.5, 8, 8.5, 9, 9.5, 10],
826
- reversed: true,
827
- },
828
- },
829
- },
830
- spins: { 6: { 9: 180 }, 9: { 6: -180 } },
831
- textLayout: { cellWidth: 8, spacing: 3.5, baselineY: 20, colonDotsY: [8, 16] },
832
- });
833
- return { condensedDigits };
834
- })();
835
-
836
- // sets/folder.ts
837
- const { folderIcons } = (() => {
838
- // Icon set "folder": closed, open — Lucide 1.61.0 icons "folder", "folder-open" (ISC, see LICENSE-lucide), imported
839
- // with `node tools/import-lucide.ts folder folder-open`. Coordinates below are Lucide's; lucideLine / lucideArc scale
840
- // them into our icon box (more margin, like the digits — see lucide.ts).
841
- // One piece each. The open folder is one line: the front flap (from its left tip over its top edge, down its right
842
- // edge), the bottom, the left side, the tab, the back panel's top edge and a short bit of its right side.
843
- // closed ↔ open: the closed folder is traced the open folder's way ("folder-like-open", same picture) — starting at
844
- // its top edge, which it runs along twice: the first pass drops and tilts into the flap's top edge, the second stays
845
- // as the back panel's top edge.
846
- const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
847
- const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
848
- /** Lucide "folder": counter-clockwise from the bottom right corner. */
849
- const FOLDER = [
850
- lucideArc(20, 18, 2, 90, 0), // bottom right corner
851
- lucideLine(22, 18, 22, 8), // right side, up
852
- lucideArc(20, 8, 2, 0, -90), // top right corner
853
- lucideLine(20, 6, 12.1, 6), // top edge, to the left
854
- lucideArc(12.0804, 4.0001, 2, 89.4383, 146.6363),
855
- lucideLine(10.41, 5.1, 9.6, 3.9), // tab slope
856
- lucideArc(7.9297, 5, 2, -33.367, -89.9906),
857
- lucideLine(7.93, 3, 4, 3), // tab top
858
- lucideArc(4, 5, 2, -90, -180), // top left corner
859
- lucideLine(2, 5, 2, 18), // left side, down
860
- lucideArc(4, 18, 2, 180, 90), // bottom left corner
861
- lucideLine(4, 20, 20, 20), // bottom
862
- ];
863
- /** Lucide "folder-open": from the flap's left tip. */
864
- const FOLDER_OPEN = [
865
- lucideLine(6, 14, 7.5, 11.1), // flap's left tip
866
- lucideArc(9.2863, 11.9995, 2, -153.2735, -91.3273),
867
- lucideLine(9.24, 10, 20, 10), // flap's top edge
868
- lucideArc(20.0035, 12, 2, -90.1005, 14.4776),
869
- lucideLine(21.94, 12.5, 20.4, 18.5), // flap's right edge
870
- lucideArc(18.4635, 18, 2, 14.4762, 90.3867),
871
- lucideLine(18.45, 20, 4, 20), // bottom
872
- lucideArc(4, 18, 2, 90, 180),
873
- lucideLine(2, 18, 2, 5), // left side, up
874
- lucideArc(4, 5, 2, 180, 270),
875
- lucideLine(4, 3, 7.9, 3), // tab top
876
- lucideArc(7.9196, 4.9999, 2, -90.5617, -33.3637),
877
- lucideLine(9.59, 3.9, 10.4, 5.1), // tab slope
878
- lucideArc(12.0703, 4, 2, 146.633, 90.0094),
879
- lucideLine(12.07, 6, 18, 6), // back panel's top edge
880
- lucideArc(18, 8, 2, -90, 0),
881
- lucideLine(20, 8, 20, 10), // back panel's right side, down to the flap
882
- ];
883
- const [bottomRight, rightSide, topRight, topEdge, tabArc1, tabSlope, tabArc2, tabTop, topLeft, leftSide, bottomLeft, bottom] = FOLDER.map(reversedStroke); // clockwise, like the open folder
884
- /** The closed folder traced like the open one: top edge (→ flap), round the outline, top edge again (→ back panel). */
885
- const FOLDER_LIKE_OPEN = [
886
- topEdge, topRight, rightSide, bottomRight, bottom, bottomLeft, leftSide, topLeft,
887
- tabTop, tabArc2, tabSlope, tabArc1, topEdge, topRight, lucideLine(22, 8, 22, 10),
888
- ];
889
- const folderIcons = {
890
- segmentCount: 20,
891
- strokeWidth: LUCIDE_STROKE_WIDTH,
892
- lineCap: 'round',
893
- lineJoin: 'round',
894
- strokes: { closed: FOLDER, open: FOLDER_OPEN },
895
- alternativeStrokes: { 'folder-like-open': FOLDER_LIKE_OPEN },
896
- // fitted with tools/check.ts (cost 7838 → 411)
897
- pairLayouts: {
898
- closed: {
899
- open: {
900
- tracing: 'folder-like-open',
901
- 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],
902
- },
903
- },
904
- },
905
- spins: {},
906
- };
907
- return { folderIcons };
908
- })();
909
-
910
- // sets/handwritten.ts
911
- const { handwrittenDigits } = (() => {
912
- // Digit set "handwritten": hand-drawn marker digits, traced from the reference images in icons/handwritten/
913
- // (the set format is described in ../types.ts).
914
- //
915
- // Free-form strokes, so the geometry is traced rather than constructed (scratch tools, see CLAUDE.md):
916
- // thin decorative side lines removed (only ink of a thick stroke kept), the stroke thinned to its centre line,
917
- // followed through hand-placed waypoints in writing order, smoothed and fitted with cubic Bézier curves
918
- // (Schneider's algorithm). 7.375 image px = 1 grid unit, each digit centred on 12,12; stroke 1.04 (typical width along
919
- // the centre line — the marker stroke varies a little, so the pixel match is ~8–17 %).
920
- // - every digit is one stroke of 3–7 free cubic curves; round ends and corners
921
- // - the 4 is drawn out and back over its crossbar end and right stem (one continuous stroke)
922
- // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
923
- const handwrittenDigits = withTextCharacters({
924
- segmentCount: 16,
925
- strokeWidth: 1.04,
926
- lineCap: 'round',
927
- lineJoin: 'round',
928
- strokes: {
929
- 0: [
930
- cubic(12.61, 4.07, 10.61, 3.51, 9.37, 5.81, 8.74, 7.36),
931
- cubic(8.74, 7.36, 7.57, 10.27, 6.96, 17.17, 9.79, 19.34),
932
- cubic(9.79, 19.34, 12.78, 21.64, 15.47, 18.25, 16, 15.5),
933
- cubic(16, 15.5, 16.6, 12.38, 16.29, 8.49, 14.37, 5.83),
934
- cubic(14.37, 5.83, 13.87, 5.13, 13.21, 4.66, 12.61, 4.07),
935
- ],
936
- 1: [
937
- cubic(10.24, 7.53, 11.22, 6.54, 12.21, 5.24, 13.49, 4.68),
938
- cubic(13.49, 4.68, 13.7, 4.79, 13.86, 4.5, 13.9, 4.89),
939
- cubic(13.9, 4.89, 14.02, 6, 13.9, 7.27, 13.9, 8.42),
940
- cubic(13.9, 8.42, 13.9, 12.28, 13.9, 16.14, 13.9, 20),
941
- ],
942
- 2: [
943
- cubic(9.02, 7.66, 10.04, 4, 14.93, 1.86, 15.22, 7.15),
944
- cubic(15.22, 7.15, 15.49, 11.89, 7.96, 15.48, 8.47, 19.86),
945
- cubic(8.47, 19.86, 9.61, 20.58, 13.97, 20.14, 15.53, 20.14),
946
- ],
947
- 3: [
948
- cubic(9.76, 5.15, 13.23, 2.8, 16.42, 4.68, 14.53, 7.59),
949
- cubic(14.53, 7.59, 13.4, 9.34, 12.35, 9.39, 10.58, 9.9),
950
- cubic(10.58, 9.9, 11.34, 9.75, 12.22, 9.41, 13.02, 9.59),
951
- cubic(13.02, 9.59, 18.13, 10.7, 16.81, 19.23, 11.83, 19.98),
952
- cubic(11.83, 19.98, 9.89, 20.28, 8.7, 19.11, 7.73, 17.63),
953
- ],
954
- 4: [
955
- cubic(8.27, 4.14, 8.27, 6.1, 7.62, 12.38, 8.38, 13.74),
956
- cubic(8.41, 13.76, 10.61, 14.38, 13.29, 14.03, 15.59, 14.03),
957
- cubic(15.59, 14.03, 14.82, 14.03, 14.06, 14.03, 13.29, 14.03),
958
- cubic(13.29, 14.03, 13.29, 10.73, 13.29, 7.44, 13.29, 4.14),
959
- cubic(13.29, 4.14, 13.29, 9.33, 13.29, 14.53, 13.29, 19.73),
960
- ],
961
- 5: [
962
- cubic(14.24, 3.86, 12.8, 3.86, 11.11, 3.74, 9.76, 4.14),
963
- cubic(9.76, 4.14, 9.7, 4.15, 9.31, 4.09, 9.26, 4.23),
964
- cubic(9.26, 4.23, 9.09, 4.74, 9.28, 8.83, 9.36, 9.58),
965
- cubic(9.36, 9.58, 9.41, 10.07, 9.44, 9.66, 9.63, 9.83),
966
- cubic(9.63, 9.83, 10.32, 9.54, 10.97, 9.13, 11.74, 9.04),
967
- cubic(11.74, 9.04, 16.84, 8.46, 16.99, 16.29, 14.13, 18.87),
968
- cubic(14.13, 18.87, 11.84, 20.92, 8.19, 19.68, 8, 16.47),
969
- ],
970
- 6: [
971
- cubic(13.08, 3.93, 9.07, 6.47, 7.2, 12.92, 8.79, 17.24),
972
- cubic(8.79, 17.24, 9.18, 18.28, 9.4, 19.11, 10.46, 19.67),
973
- cubic(10.46, 19.67, 13.85, 21.46, 16.41, 16.37, 15.4, 13.64),
974
- cubic(15.4, 13.64, 13.48, 8.42, 9.15, 16.35, 8.94, 16.42),
975
- cubic(8.94, 16.42, 7.71, 16.83, 8.27, 12.36, 8.47, 11.8),
976
- ],
977
- 7: [
978
- cubic(8.81, 3.86, 10.36, 3.86, 14.03, 3.47, 15.19, 4.08),
979
- cubic(15.19, 4.14, 14.75, 5.71, 13.41, 7.25, 12.7, 8.8),
980
- cubic(12.7, 8.8, 11.16, 12.19, 10.31, 16.13, 10.31, 19.86),
981
- ],
982
- 8: [
983
- cubic(14.51, 5.56, 15.17, 3.17, 9.21, 3.46, 8.86, 6.51),
984
- cubic(8.86, 6.51, 8.64, 8.41, 13.99, 11.13, 14.93, 13.6),
985
- cubic(14.93, 13.6, 15.9, 16.15, 14.74, 20.7, 11.25, 19.94),
986
- cubic(11.25, 19.94, 8.97, 19.44, 8.63, 17.22, 9.02, 15.26),
987
- cubic(9.02, 15.26, 9.77, 11.45, 12.38, 8.68, 14.48, 5.59),
988
- ],
989
- 9: [
990
- cubic(14.98, 6.98, 13.12, 0.31, 6.71, 5.82, 8.47, 10.23),
991
- cubic(8.47, 10.23, 9.92, 13.9, 13.15, 10.88, 14.01, 8.61),
992
- cubic(14.01, 8.61, 14.16, 8.23, 14.66, 6.7, 15.3, 7.44),
993
- cubic(15.3, 7.44, 16.93, 9.31, 14.47, 18.24, 11.59, 19.86),
994
- ],
995
- },
996
- alternativeStrokes: {},
997
- // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd / not sure
998
- pairLayouts: {
999
- 0: {
1000
- 1: { startSegment: 5, reversed: true },
1001
- 2: { startSegment: 5, reversed: true },
1002
- 3: { startSegment: 4, reversed: true },
1003
- 4: { breakpoints: [0, 0.7708, 1, 1.2083, 1.3958, 1.5208, 1.5208, 1.5208, 2, 3, 3.1458, 3.1458, 3.1458, 3.5833, 3.8542, 4, 5] },
1004
- 5: { startSegment: 3, reversed: true },
1005
- 7: { startSegment: 4, reversed: true },
1006
- 9: { startSegment: 5, reversed: true },
1007
- },
1008
- 1: {
1009
- 0: { breakpoints: [0, 0, 0, 0, 0.3542, 1, 2, 3, 3.1667, 3.3958, 3.6042, 3.8125, 3.9167, 3.9167, 3.9167, 3.9167, 4] },
1010
- },
1011
- 2: {
1012
- 6: { breakpoints: [0, 0.4583, 1, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.6458, 1.7083, 2, 3] },
1013
- 0: { breakpoints: [0, 0, 0, 0, 0.25, 0.5417, 0.8958, 1, 1.2083, 1.3125, 1.4167, 1.5417, 1.75, 1.9792, 1.9792, 2, 3] },
1014
- },
1015
- 3: {
1016
- 8: { breakpoints: [0, 0.5833, 0.5833, 0.5833, 0.5833, 1, 2, 3, 3.1458, 3.3958, 3.5208, 3.6667, 3.8333, 4, 4.2917, 5, 5] },
1017
- 0: { breakpoints: [0, 0, 0, 0, 0.2917, 1, 2, 3, 3.4167, 3.5208, 3.7083, 3.8542, 4, 4.4167, 5, 5, 5] },
1018
- },
1019
- 4: {
1020
- 8: { breakpoints: [0, 0.5833, 0.7708, 1, 1.4583, 2, 2.1458, 3, 3.2917, 3.3125, 3.875, 3.8958, 3.8958, 3.8958, 3.8958, 4, 5] },
1021
- 0: { breakpoints: [0, 0.0833, 0.3542, 0.5208, 0.7708, 1, 1.1042, 1.25, 1.5, 1.7292, 1.9583, 2, 3, 3.3958, 3.5625, 4, 5] },
1022
- },
1023
- 5: {
1024
- 0: { breakpoints: [0, 1, 2, 3, 4, 5, 5.3958, 5.5208, 5.6875, 5.875, 6, 6.2917, 6.5625, 6.8958, 7, 7, 7] },
1025
- },
1026
- 6: {
1027
- 2: { breakpoints: [0, 0.0625, 0.0625, 1, 2, 3, 3.6458, 3.6458, 3.6458, 3.6458, 3.6458, 3.6458, 3.6458, 3.7292, 3.875, 4, 5] },
1028
- },
1029
- 7: {
1030
- 8: { breakpoints: [0, 0.7292, 0.7292, 0.7292, 0.7292, 0.7292, 1, 2, 2.1458, 2.1458, 2.1458, 2.1458, 2.4583, 2.75, 2.75, 2.75, 3] },
1031
- 0: { breakpoints: [0, 0, 0, 0.2083, 0.5417, 1, 1.6667, 2, 2.2708, 2.5208, 2.7708, 2.8542, 2.8542, 2.8542, 2.8542, 2.8542, 3] },
1032
- },
1033
- 8: {
1034
- 7: { breakpoints: [0, 0.3125, 0.3125, 0.3125, 0.3125, 0.3125, 0.3125, 1, 1.5625, 1.5625, 1.6042, 1.625, 2, 3, 3.6042, 4, 5] },
1035
- 4: { breakpoints: [0, 0, 0, 0, 0, 0.25, 1, 1.625, 1.6875, 2, 2.0417, 2.0417, 3, 4, 4.3125, 4.5208, 5], reversed: true },
1036
- 3: { breakpoints: [0, 0.125, 0.125, 0.125, 0.125, 1, 1.5208, 1.5625, 1.7708, 2, 2.1667, 2.3958, 2.6458, 3, 3.0417, 4, 5] },
1037
- },
1038
- 9: {
1039
- 0: { breakpoints: [0, 0.9792, 0.9792, 1, 2, 2.7917, 2.7917, 3, 3.3125, 3.4375, 3.5833, 3.7083, 3.8333, 4, 4, 4, 4] },
1040
- },
1041
- },
1042
- spins: {},
1043
- textLayout: { cellWidth: 9, spacing: 4, baselineY: 20.2, colonDotsY: [8, 16] },
1044
- });
1045
- return { handwrittenDigits };
1046
- })();
1047
-
1048
- // sets/media.ts
1049
- const { mediaIcons } = (() => {
1050
- // Icon set "media": play, pause, stop — Lucide 1.61.0 icons (ISC, see LICENSE-lucide).
1051
- // First imported with `node tools/import-lucide.ts play pause square`, then rewritten as rounded polygons so the
1052
- // corners can be made sharper (`withRoundness`); with Lucide's radii (play 2, pause 1, stop 2) the shapes match
1053
- // the import within 0.005 units (Lucide's own rounding).
1054
- // Two pieces: pause has two bars; play and stop draw their single shape in both pieces (the second copy lies
1055
- // exactly on the first, so it doesn't show — during a morph to pause the two copies separate into the bars).
1056
- // Every shape runs clockwise from its top left, like Lucide's. Coordinates below are Lucide's; fromLucide /
1057
- // lucideLength scale them into our icon box (more margin, like the digits — see lucide.ts).
1058
- /** Lucide's play: a triangle (left side x 5, tip at the right) with rounded corners, starting with the top left arc. */
1059
- const PLAY_CORNERS = [[5, 1.5174], [22.9706, 11.9998], [5, 22.4827]].map(fromLucide);
1060
- const RIGHT_BAR_CORNERS = [[14, 3], [19, 3], [19, 21], [14, 21]].map(fromLucide);
1061
- const LEFT_BAR_CORNERS = [[5, 3], [10, 3], [10, 21], [5, 21]].map(fromLucide);
1062
- const SQUARE_CORNERS = [[3, 3], [21, 3], [21, 21], [3, 21]].map(fromLucide);
1063
- const LUCIDE_RADII = { play: lucideLength(2), pause: lucideLength(1), stop: lucideLength(2) };
1064
- // play ↔ stop (both pieces, the user's sketch): corner to corner, edge to edge, and the tip opens up and down
1065
- // into the square's right side. Play (top left corner, top, tip, bottom, bottom left corner, left):
1066
- // 2 + 1 + 4 + 1 + 2 + 2 segments. Stop is laid out from its top edge and then started at its top left corner,
1067
- // 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.
1068
- const PLAY_FOR_STOP = { breakpoints: [0, 0.5, 1, 2, 2.25, 2.5, 2.75, 3, 4, 4.5, 5, 5.5, 6] };
1069
- const STOP_FOR_PLAY = { breakpoints: [0, 1, 2, 2.5, 3, 4, 5, 5.5, 6, 6.5, 7, 7.5, 8], startSegment: 10 };
1070
- const buildMediaIcons = (radii) => {
1071
- const play = roundedPolygon(PLAY_CORNERS, radii.play, true);
1072
- const stop = roundedPolygon(SQUARE_CORNERS, radii.stop);
1073
- return {
1074
- segmentCount: 12,
1075
- strokeWidth: LUCIDE_STROKE_WIDTH,
1076
- lineCap: 'round',
1077
- lineJoin: 'round',
1078
- strokes: { play, pause: roundedPolygon(RIGHT_BAR_CORNERS, radii.pause), stop },
1079
- alternativeStrokes: {},
1080
- // play ↔ stop: the user's sketch (above); play ↔ pause, pause ↔ stop: tools/check.ts
1081
- pairLayouts: {
1082
- 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 },
1083
- pause: { play: { startSegment: 10 }, stop: { startSegment: 11 } },
1084
- stop: { pause: { breakpoints: [0, 0.8333, 1, 2, 2.2917, 2.7083, 3, 4, 4.1667, 5, 6, 7, 8] }, play: STOP_FOR_PLAY },
1085
- },
1086
- morePieces: [
1087
- {
1088
- strokes: { play, pause: roundedPolygon(LEFT_BAR_CORNERS, radii.pause), stop },
1089
- // only the triangle's left side, traced down and back up like the left bar's loop (top left corner out
1090
- // and back, left edge down, bottom left corner out and back, left edge up): the left bar inflates from
1091
- // that edge, while the first piece (the whole triangle) opens into the right bar
1092
- alternativeStrokes: {
1093
- 'play-left-side': [
1094
- play[0], reversedStroke(play[0]), reversedStroke(play[5]), reversedStroke(play[4]), play[4], play[5],
1095
- ],
1096
- },
1097
- pairLayouts: {
1098
- play: {
1099
- pause: { tracing: 'play-left-side', breakpoints: [0, 0.7292, 1, 1.7292, 2, 3, 3.7292, 4, 4.7292, 5, 5.7708, 6, 6] },
1100
- stop: PLAY_FOR_STOP,
1101
- },
1102
- pause: {
1103
- stop: { breakpoints: [0, 1, 2, 2.0208, 2.9792, 3, 4, 5, 6, 6.3333, 6.6667, 7, 8] },
1104
- },
1105
- stop: { pause: { breakpoints: [0, 0.1667, 1, 2, 3, 4, 4.8333, 5, 6, 6.2917, 6.7083, 7, 8] }, play: STOP_FOR_PLAY },
1106
- },
1107
- },
1108
- ],
1109
- spins: {},
1110
- withRoundness: (roundness) => buildMediaIcons({
1111
- play: LUCIDE_RADII.play * roundness, pause: LUCIDE_RADII.pause * roundness, stop: LUCIDE_RADII.stop * roundness,
1112
- }),
1113
- };
1114
- };
1115
- const mediaIcons = buildMediaIcons(LUCIDE_RADII);
1116
- return { mediaIcons };
1117
- })();
1118
-
1119
- // sets/radio.ts
1120
- const { radioIcons } = (() => {
1121
- // Icon set "radio": unchecked, checked, checked-big — Lucide 1.61.0 icons "circle", "circle-check",
1122
- // "circle-check-big" (ISC, see LICENSE-lucide). Coordinates below are Lucide's; fromLucide / lucideLength scale them
1123
- // into our icon box (more margin, like the digits — see lucide.ts). Works like "checkbox", with a circle for the box.
1124
- // Two pieces:
1125
- // piece 1, the circle (radius 10 around 12,12): the same full circle in unchecked and checked (it doesn't move);
1126
- // checked-big opens a gap at the top right (from 17,3.335 clockwise round to 21.801,10 is the open arc) where
1127
- // the big check leaves the circle — both ends of the gap pull back to the gap's edges.
1128
- // piece 2, the check (check-mark.ts, shared with "checkbox"): drawn in like a pen from unchecked.
1129
- const [CENTER_X, CENTER_Y] = fromLucide([12, 12]);
1130
- const RADIUS = lucideLength(10);
1131
- /** The open arc of circle-check-big runs clockwise from 21.801,10 to 17,3.335 (the gap is the rest). */
1132
- const OPEN_START_ANGLE = (Math.atan2(10 - 12, 21.801 - 12) * 180) / Math.PI;
1133
- const OPEN_END_ANGLE = (Math.atan2(3.335 - 12, 17 - 12) * 180) / Math.PI + 360;
1134
- /** The share of the full circle that stays in checked-big. */
1135
- const OPEN_SHARE = (OPEN_END_ANGLE - OPEN_START_ANGLE) / 360;
1136
- /** The full circle starts where the open arc starts, so the gap is its last part. */
1137
- const CIRCLE = [arc(CENTER_X, CENTER_Y, RADIUS, OPEN_START_ANGLE, OPEN_START_ANGLE + 360)];
1138
- const OPEN_CIRCLE = [arc(CENTER_X, CENTER_Y, RADIUS, OPEN_START_ANGLE, OPEN_END_ANGLE)];
1139
- // circle, unchecked/checked ↔ checked-big: the open part 10 segments, the gap 2 (one per half), started at the gap's
1140
- // second half — so it lines up with the open arc, which gets a zero-length segment at each end of the gap where
1141
- // the vanishing halves pull back to.
1142
- const openPart = Array.from({ length: 11 }, (_, index) => +((index / 10) * OPEN_SHARE).toFixed(4));
1143
- const CIRCLE_FOR_OPEN_CIRCLE = { breakpoints: [...openPart, +((1 + OPEN_SHARE) / 2).toFixed(4), 1], startSegment: 11 };
1144
- const OPEN_CIRCLE_FOR_CIRCLE = { breakpoints: [0, ...Array.from({ length: 11 }, (_, index) => index / 10), 1] };
1145
- const radioIcons = {
1146
- segmentCount: 12,
1147
- strokeWidth: LUCIDE_STROKE_WIDTH,
1148
- lineCap: 'round',
1149
- lineJoin: 'round',
1150
- strokes: { unchecked: CIRCLE, checked: CIRCLE, 'checked-big': OPEN_CIRCLE },
1151
- alternativeStrokes: {},
1152
- pairLayouts: {
1153
- unchecked: { 'checked-big': CIRCLE_FOR_OPEN_CIRCLE },
1154
- checked: { 'checked-big': CIRCLE_FOR_OPEN_CIRCLE },
1155
- 'checked-big': { unchecked: OPEN_CIRCLE_FOR_CIRCLE, checked: OPEN_CIRCLE_FOR_CIRCLE },
1156
- },
1157
- // the check rests (hidden) on the circle's left edge
1158
- morePieces: [checkMarkPiece([2, 12])],
1159
- spins: {},
1160
- };
1161
- return { radioIcons };
1162
- })();
1163
-
1164
- // sets/menu.ts
1165
- const { menuIcons } = (() => {
1166
- // Icon set "menu": menu (☰), close (✕), back (←) — Lucide 1.61.0 icons "menu", "x", "arrow-left" (ISC, see
1167
- // LICENSE-lucide), imported with `node tools/import-lucide.ts menu x arrow-left`. Coordinates below are Lucide's;
1168
- // fromLucide scales them into our icon box (more margin, like the digits — see lucide.ts).
1169
- // Three pieces, each one straight line, lined up so the lines of every state correspond:
1170
- // piece 1: top line | "\" of the ✕ | upper half of the arrowhead
1171
- // piece 2: middle line | hidden on the ✕'s centre | the arrow's shaft
1172
- // piece 3: bottom line | "/" of the ✕ | lower half of the arrowhead
1173
- // Lucide draws the arrowhead as one bent line; two lines meeting with round ends look the same.
1174
- // Every line runs from left to right, so lines move without turning over.
1175
- /** A straight line between two Lucide points, moved into our icon box. */
1176
- const lucideLine = (from, to) => [line(...fromLucide(from), ...fromLucide(to))];
1177
- const piece = (strokes, extras = { alternativeStrokes: {}, pairLayouts: {} }) => ({ strokes, ...extras });
1178
- // close ↔ back: the ✕ drawn with its lines swapped ("/" in piece 1, "\" in piece 3), so each line only shortens
1179
- // and tilts into its half of the arrowhead instead of turning over
1180
- const BACKSLASH = lucideLine([6, 6], [18, 18]);
1181
- const SLASH = lucideLine([6, 18], [18, 6]);
1182
- const menuIcons = {
1183
- segmentCount: 1,
1184
- strokeWidth: LUCIDE_STROKE_WIDTH,
1185
- lineCap: 'round',
1186
- lineJoin: 'round',
1187
- ...piece({
1188
- menu: lucideLine([4, 5], [20, 5]),
1189
- close: BACKSLASH,
1190
- back: lucideLine([5, 12], [12, 5]),
1191
- }, { alternativeStrokes: { 'close-swapped': SLASH }, pairLayouts: { close: { back: { tracing: 'close-swapped', breakpoints: [0, 1] } } } }),
1192
- morePieces: [
1193
- piece({
1194
- menu: lucideLine([4, 12], [20, 12]),
1195
- // a tiny line on the centre of the ✕'s "\": hidden under it
1196
- close: lucideLine([11.5, 11.5], [12.5, 12.5]),
1197
- back: lucideLine([5, 12], [19, 12]),
1198
- }),
1199
- piece({
1200
- menu: lucideLine([4, 19], [20, 19]),
1201
- close: SLASH,
1202
- back: lucideLine([5, 12], [12, 19]),
1203
- }, { alternativeStrokes: { 'close-swapped': BACKSLASH }, pairLayouts: { close: { back: { tracing: 'close-swapped', breakpoints: [0, 1] } } } }),
1204
- ],
1205
- spins: {},
1206
- };
1207
- return { menuIcons };
1208
- })();
1209
-
1210
- // sets/round.ts
1211
- const { roundDigits } = (() => {
1212
- // Digit set "round": geometric sans digits, drafted from the reference images in icons/round/
1213
- // (the set format is described in ../types.ts).
1214
- //
1215
- // Measured from the images and tidied up so shared parts are exactly shared:
1216
- // - round digits span y 4…20, bars sit at y 4.2 / 19.8
1217
- // - round line ends: every open end stops half a stroke (END_INSET) before the edge measured in the images
1218
- // (the images have flat ends), so the round cap ends exactly there — bottoms of 1, 4, 7, 9 line up with the base
1219
- // - one big circle radius (2, 3, 6, 8, 9) and one small one (top of 3 and 8); 5 has its own, larger bowl
1220
- // - 3 is drawn on exactly the two circles of 8 (they touch at 12, 11.2)
1221
- // - 9 is exactly 6 turned 180° around 12,12 → 6 ↔ 9 morphs as a pure spin
1222
- const STROKE_WIDTH = 1.2; // the reference images use 1.6; thinner by the user's choice
1223
- /** A round line end reaches this far beyond its end point. */
1224
- const END_INSET = STROKE_WIDTH / 2;
1225
- /** Angle that shortens an arc's end by END_INSET along the circle. */
1226
- const arcInset = (radius) => (END_INSET / radius) * (180 / Math.PI);
1227
- /** The point on the line through `from` and `to` at height `y`. */
1228
- const pointAtY = ([fromX, fromY], [toX, toY], y) => [fromX + ((toX - fromX) * (y - fromY)) / (toY - fromY), y];
1229
- const BIG_RADIUS = 4.4;
1230
- const SMALL_RADIUS = 3.6;
1231
- const TOP_BAR_Y = 4.2;
1232
- const BASE_Y = 19.8;
1233
- /** Where the 6's tail touches its circle (tangent); 9 uses the same angle turned by 180°. */
1234
- const SIX_TAIL_ANGLE = -147.1085;
1235
- const SIX_TAIL_JOIN = pointOnCircle(12, 15.6, BIG_RADIUS, SIX_TAIL_ANGLE);
1236
- /** Top end of the 6's tail: on the measured tail line (from 14.65, 3.4), pulled in so the round end tops at 3.4. */
1237
- const SIX_TAIL_TOP = pointAtY([14.65, 3.4], SIX_TAIL_JOIN, TOP_BAR_Y);
1238
- /** Bottom end of the 9's tail: the 6's tail top turned 180° around 12,12. */
1239
- const NINE_TAIL_END = [24 - SIX_TAIL_TOP[0], 24 - SIX_TAIL_TOP[1]];
1240
- /** Where the 2's diagonal leaves its bowl (tangent towards the corner at 8.3, 19.8). */
1241
- const TWO_DIAGONAL_ANGLE = 39.5191;
1242
- /** Where the 5's short down-stroke meets its bowl. */
1243
- const FIVE_JOIN_ANGLE = 237.2;
1244
- /** The 7's foot: on the measured diagonal (16, 4.2 → 7.45, 20.6), pulled in to the base line. */
1245
- const SEVEN_FOOT = pointAtY([16, TOP_BAR_Y], [7.45, 20.6], BASE_Y);
1246
- // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
1247
- const roundDigits = withTextCharacters({
1248
- segmentCount: 12,
1249
- strokeWidth: STROKE_WIDTH,
1250
- lineCap: 'round',
1251
- lineJoin: 'round',
1252
- strokes: {
1253
- 0: ellipse(12, 12, 5.05, 8),
1254
- 1: [line(10 + END_INSET, TOP_BAR_Y, 13.1, TOP_BAR_Y), line(13.1, TOP_BAR_Y, 13.1, BASE_Y)],
1255
- 2: [
1256
- arc(12, 8.4, BIG_RADIUS, 158.7 + arcInset(BIG_RADIUS), TWO_DIAGONAL_ANGLE + 360),
1257
- line(...pointOnCircle(12, 8.4, BIG_RADIUS, TWO_DIAGONAL_ANGLE), 8.3, BASE_Y),
1258
- line(8.3, BASE_Y, 17.5 - END_INSET, BASE_Y),
1259
- ],
1260
- 3: [
1261
- arc(12, 7.6, SMALL_RADIUS, 180 + arcInset(SMALL_RADIUS), 439.2),
1262
- // the two arcs meet slightly right of the point where the circles touch
1263
- line(...pointOnCircle(12, 7.6, SMALL_RADIUS, 79.2), ...pointOnCircle(12, 15.6, BIG_RADIUS, 278.7)),
1264
- arc(12, 15.6, BIG_RADIUS, 278.7, 536.7 - arcInset(BIG_RADIUS)),
1265
- ],
1266
- // one stroke: crossbar right → left, diagonal up to the top, stem down
1267
- 4: [
1268
- line(17.6 - END_INSET, 14.95, 8, 14.95),
1269
- line(8, 14.95, 14.75, TOP_BAR_Y),
1270
- line(14.75, TOP_BAR_Y, 14.75, BASE_Y),
1271
- ],
1272
- 5: [
1273
- line(17.1 - END_INSET, TOP_BAR_Y, 10.5, TOP_BAR_Y),
1274
- line(10.5, TOP_BAR_Y, ...pointOnCircle(11.9, 15, 4.8, FIVE_JOIN_ANGLE)),
1275
- arc(11.9, 15, 4.8, FIVE_JOIN_ANGLE, 523.3 - arcInset(4.8)),
1276
- ],
1277
- 6: [
1278
- line(...SIX_TAIL_TOP, ...SIX_TAIL_JOIN),
1279
- arc(12, 15.6, BIG_RADIUS, SIX_TAIL_ANGLE, SIX_TAIL_ANGLE - 360),
1280
- ],
1281
- 7: [line(6.7 + END_INSET, TOP_BAR_Y, 16, TOP_BAR_Y), line(16, TOP_BAR_Y, ...SEVEN_FOOT)],
1282
- // closed figure-eight: top circle clockwise, bottom circle counter-clockwise, meeting where they touch
1283
- 8: [arc(12, 7.6, SMALL_RADIUS, 90, 450), arc(12, 15.6, BIG_RADIUS, 270, -90)],
1284
- 9: [
1285
- arc(12, 8.4, BIG_RADIUS, SIX_TAIL_ANGLE + 180 - 360, SIX_TAIL_ANGLE + 180),
1286
- line(...pointOnCircle(12, 8.4, BIG_RADIUS, SIX_TAIL_ANGLE + 180), ...NINE_TAIL_END),
1287
- ],
1288
- },
1289
- alternativeStrokes: {
1290
- // the 8 traced like the 0 (from the top, counter-clockwise): left half of the top circle down to the waist,
1291
- // once around the bottom circle, right half of the top circle back up
1292
- '8-like-0': [
1293
- arc(12, 7.6, SMALL_RADIUS, 270, 90),
1294
- arc(12, 15.6, BIG_RADIUS, 270, -90),
1295
- arc(12, 7.6, SMALL_RADIUS, 90, -90),
1296
- ],
1297
- // the 8 as two clockwise loops, both starting at the waist (like the 3: top arc, then bottom arc)
1298
- '8-two-loops': [arc(12, 7.6, SMALL_RADIUS, 90, 450), arc(12, 15.6, BIG_RADIUS, 270, 630)],
1299
- // the 6 with its circle drawn clockwise (the same way round as the 5's bowl)
1300
- '6-clockwise': [
1301
- line(...SIX_TAIL_TOP, ...SIX_TAIL_JOIN),
1302
- arc(12, 15.6, BIG_RADIUS, SIX_TAIL_ANGLE, SIX_TAIL_ANGLE + 360),
1303
- ],
1304
- // mirrored figure-eight from the waist: top circle counter-clockwise, bottom circle clockwise (like the 5)
1305
- '8-mirrored': [arc(12, 7.6, SMALL_RADIUS, 90, -270), arc(12, 15.6, BIG_RADIUS, 270, 630)],
1306
- // the usual figure-eight, but starting on the right side of the bottom circle (where the 4's crossbar starts)
1307
- '8-from-right': [
1308
- arc(12, 15.6, BIG_RADIUS, 0, -90),
1309
- arc(12, 7.6, SMALL_RADIUS, 90, 450),
1310
- arc(12, 15.6, BIG_RADIUS, 270, 0),
1311
- ],
1312
- },
1313
- pairLayouts: {
1314
- 0: {
1315
- // cut the 0 open at the upper left: the part going up over the top becomes the flag, the rest the stem
1316
- 1: { startSegment: 2, reversed: true },
1317
- // start at the leftmost point and go up, like the 2's bowl
1318
- 2: { startSegment: 3, reversed: true },
1319
- 3: { startSegment: 3, reversed: true },
1320
- // start at the lower right and go left: bottom → crossbar, left side → diagonal, right side → stem
1321
- 4: { startSegment: 7, reversed: true },
1322
- // optimizer: start at the upper left and go clockwise, the same way round as the 5's bowl
1323
- 5: { startSegment: 2, reversed: true },
1324
- // start at the leftmost point and go up: the top becomes the bar, the right side the diagonal
1325
- 7: { startSegment: 3, reversed: true },
1326
- // start at the bottom and go clockwise, the same way round as the 9's circle
1327
- 9: { startSegment: 6, reversed: true },
1328
- },
1329
- 1: {
1330
- 4: { breakpoints: [0, 1, 1.0625, 1.125, 1.1875, 1.2708, 1.2708, 1.6875, 1.6875, 1.6875, 1.6875, 1.6875, 2] },
1331
- // flag curls into the top loop, stem rounds into the bottom loop (optimizer fit)
1332
- 8: { breakpoints: [0, 0, 0, 1, 1.3125, 1.4583, 1.5208, 1.8333, 1.8333, 1.8333, 1.8333, 1.8333, 2] },
1333
- // fewer segments for the 9's circle, more for its tail; the circle unrolls into flag + stem top (optimizer fit)
1334
- 9: { breakpoints: [0, 0, 0, 1, 1.1042, 1.4167, 1.4792, 1.5625, 1.625, 1.6875, 1.7708, 1.8125, 2] },
1335
- },
1336
- 2: {
1337
- // the 4's crossbar shrinks into the 2's starting tip, the diagonal and stem bend into the bowl (optimizer fit)
1338
- 4: { breakpoints: [0, 0, 0.3542, 0.6875, 0.7708, 1, 1.2083, 1.2292, 2, 2.7292, 2.7292, 2.7292, 3] },
1339
- // bowl → the 5's bar and down-stroke, diagonal + base → its bowl (optimizer fit)
1340
- 5: { breakpoints: [0, 0.375, 0.375, 0.375, 0.625, 1, 1.0833, 1.0833, 1.1458, 1.4375, 1.7083, 2, 3] },
1341
- // the bowl closes into the 9's circle (same circle), the base pulls back into the tail (optimizer fit)
1342
- 9: { breakpoints: [0, 0, 0, 0, 0.2083, 0.3958, 0.5833, 0.7708, 1, 1.2083, 1.3542, 2, 3] },
1343
- // 2 → 6 spins (see spins): in the turning frame it is 2 → 9 — the 2's bowl is exactly the 9's circle
1344
- 6: { breakpoints: [0, 0, 0, 0, 0.2292, 0.4167, 0.6042, 0.7708, 1, 1.2083, 1.4167, 2, 3] },
1345
- },
1346
- 3: {
1347
- // the 4's crossbar shrinks into the 3's starting tip, the diagonal becomes the top arc (optimizer fit)
1348
- 4: { breakpoints: [0, 0, 0.3333, 0.6042, 0.7917, 1, 2, 2.1458, 2.3333, 2.5208, 2.5208, 2.6667, 3] },
1349
- // the small top arc grows into the 9's circle, the lower bowl straightens into the tail (optimizer fit)
1350
- 9: { breakpoints: [0, 0, 0, 0, 0.0208, 0.2708, 0.4792, 0.6458, 1, 2, 2.3542, 2.6667, 3] },
1351
- },
1352
- 4: {
1353
- // drawn from the stem's bottom: the 1's stem stays a stem, its flag folds out into diagonal + crossbar
1354
- 1: { breakpoints: [0, 0.4375, 0.4375, 0.4375, 0.4375, 1, 1.6042, 1.6042, 1.7292, 1.8333, 1.8958, 2, 3], reversed: true },
1355
- 2: { breakpoints: [0, 1, 2, 2, 2.1458, 2.3958, 2.5208, 2.6667, 2.8125, 2.9375, 2.9375, 2.9375, 3] },
1356
- 3: { breakpoints: [0, 1, 2, 2.0208, 2.2708, 2.3958, 2.3958, 2.4792, 2.6875, 2.9167, 2.9167, 2.9167, 3] },
1357
- // crossbar → top bar (moves up), diagonal → down-stroke, stem → bowl (optimizer fit)
1358
- 5: { breakpoints: [0, 1, 1.3958, 1.3958, 1.3958, 1.4792, 1.4792, 2, 2.3958, 2.3958, 2.5833, 2.9583, 3] },
1359
- // drawn from the stem's bottom: stem top → the 6's tail top, diagonal + crossbar → circle (optimizer fit)
1360
- 6: { breakpoints: [0, 0.3542, 0.3542, 0.3542, 0.3542, 0.4375, 0.875, 1, 1.1042, 1.3958, 1.6042, 2, 3], reversed: true },
1361
- // drawn from the stem's bottom: stem tips over into the 7's bar, diagonal + crossbar → its diagonal (optimizer fit)
1362
- 7: { breakpoints: [0, 1, 1, 1.1667, 1.3542, 1.5625, 1.7292, 1.9792, 2, 2, 2, 2, 3], reversed: true },
1363
- // drawn backwards (stem bottom first), so the stem's end meets the crossbar's start on the 8's right side
1364
- 8: { reversed: true },
1365
- },
1366
- 5: {
1367
- 0: { breakpoints: [0, 1, 1, 2, 2.2708, 2.3125, 2.4792, 2.625, 2.75, 2.8542, 3, 3, 3] },
1368
- 2: { breakpoints: [0, 1, 1, 1, 2, 2.2917, 2.2917, 2.2917, 2.2917, 2.5417, 2.8542, 2.8542, 3] },
1369
- 4: { breakpoints: [0, 1, 2, 2, 2, 2.0833, 2.1458, 2.1667, 2.1875, 2.25, 2.3958, 2.6875, 3] },
1370
- // bar stays the bar, down-stroke + bowl straighten into the 7's diagonal (optimizer fit)
1371
- 7: { breakpoints: [0, 0.5625, 0.5625, 0.5625, 0.5625, 0.5625, 1, 2, 2.2083, 2.5208, 2.8333, 2.9167, 3] },
1372
- // start of the 5 (bar end) moves down to the 9's start; segments re-spread on both (optimizer fit)
1373
- 9: { breakpoints: [0, 1, 2, 2, 2.0208, 2.1042, 2.1667, 2.2292, 2.2708, 2.3125, 2.5625, 2.75, 3] },
1374
- },
1375
- 6: {
1376
- // the 1's flag shrinks into the top of the tail, its stem tilts into the tail and curls into the circle
1377
- // (optimizer fit, like 7 → 6)
1378
- 1: {
1379
- tracing: '6-clockwise',
1380
- breakpoints: [0, 0.1042, 0.1042, 0.1667, 0.3125, 0.3125, 1, 1.25, 1.25, 1.3542, 1.5, 1.75, 2],
1381
- },
1382
- // turned by 180° this is the 9 laid out for the 2 (optimizer fit for 2 ↔ 9, mirrored onto the 6)
1383
- 2: { breakpoints: [0, 0.1042, 0.5417, 0.75, 1, 1.125, 1.25, 1.375, 1.5, 1.6458, 1.6458, 1.75, 2], reversed: true },
1384
- 4: { breakpoints: [0, 0.3958, 0.4792, 0.75, 1, 1.0625, 1.1458, 1.3958, 1.625, 1.625, 1.625, 1.75, 2] },
1385
- // the 3's lower bowl lies on the 6's circle and closes up; its top arc straightens into the tail (optimizer fit)
1386
- 3: {
1387
- tracing: '6-clockwise',
1388
- breakpoints: [0, 0.2292, 0.2292, 0.2292, 0.3125, 1, 1.2083, 1.2917, 1.4167, 1.5417, 1.6667, 1.7708, 2],
1389
- },
1390
- // the 5's bowl end closes up to where the bowl starts; bar + down-stroke become the tail (optimizer fit)
1391
- 5: {
1392
- tracing: '6-clockwise',
1393
- breakpoints: [0, 0.0833, 0.25, 0.4375, 0.5625, 1, 1.1875, 1.2708, 1.3958, 1.5, 1.6458, 1.75, 2],
1394
- },
1395
- // the 7's bar shrinks into the top of the tail, its diagonal becomes the tail and curls into the circle
1396
- // (optimizer fit)
1397
- 7: {
1398
- tracing: '6-clockwise',
1399
- breakpoints: [0, 0.1667, 0.1667, 0.1667, 0.1667, 0.1667, 0.3125, 1, 1.25, 1.5, 1.75, 1.75, 2],
1400
- },
1401
- },
1402
- 7: {
1403
- 5: { breakpoints: [0, 0.6875, 0.6875, 0.6875, 0.6875, 0.6875, 1, 1.4375, 1.4375, 1.6042, 1.9167, 1.9167, 2] },
1404
- 4: { breakpoints: [0, 1, 1.0833, 1.0833, 1.0833, 1.1042, 1.25, 1.3542, 1.5, 1.625, 1.6667, 1.6667, 2] },
1405
- },
1406
- 8: {
1407
- 1: { breakpoints: [0, 0.25, 0.4792, 0.5625, 0.8125, 1, 1.0625, 1.3125, 1.5625, 1.6042, 1.6042, 1.75, 2] },
1408
- // 0's leftmost and rightmost points (after 3 and 9 segments) both land on the waist
1409
- 0: {
1410
- tracing: '8-like-0',
1411
- breakpoints: [0, 1 / 3, 2 / 3, 1, 1 + 1 / 6, 1 + 2 / 6, 1 + 3 / 6, 1 + 4 / 6, 1 + 5 / 6, 2, 2 + 1 / 3, 2 + 2 / 3, 3],
1412
- },
1413
- // both ends of the 3 slide along their circles into the waist: 5 segments on the top loop like the 3's
1414
- // top arc, a zero-length one at the waist for the 3's middle link, 6 on the bottom loop
1415
- 3: {
1416
- tracing: '8-two-loops',
1417
- breakpoints: [0, 0.2, 0.4, 0.6, 0.8, 1, 1, 1 + 1 / 6, 1 + 2 / 6, 1 + 3 / 6, 1 + 4 / 6, 1 + 5 / 6, 2],
1418
- },
1419
- // the 4's two ends meet on the right side of the bottom circle (optimizer fit)
1420
- 4: {
1421
- tracing: '8-from-right',
1422
- breakpoints: [0, 0, 0.125, 1, 1.25, 1.5, 1.75, 2, 2.1458, 2.2917, 2.625, 2.9583, 3],
1423
- },
1424
- // both ends of the 5 meet at the waist: bar + down-stroke → top loop, bowl → bottom loop (optimizer fit)
1425
- 5: {
1426
- tracing: '8-mirrored',
1427
- breakpoints: [0, 0.25, 0.5, 0.6875, 0.8125, 0.8958, 1, 1.125, 1.2292, 1.3542, 1.5, 1.75, 2],
1428
- },
1429
- // 8 → 9 spins (see spins): in the turning frame it is 8 → 6, the 8's big circle stays the 6's circle
1430
- 9: { startSegment: 1 },
1431
- },
1432
- 9: {
1433
- 1: { breakpoints: [0, 0.25, 0.5, 0.75, 0.8125, 1, 1.1042, 1.2292, 1.3542, 1.4583, 1.6042, 1.6875, 2] },
1434
- 2: { breakpoints: [0, 0.25, 0.375, 0.375, 0.5, 0.625, 0.75, 0.875, 1, 1.2708, 1.4375, 2, 2] },
1435
- 3: { breakpoints: [0, 0.25, 0.4792, 0.4792, 0.4792, 0.625, 0.75, 0.875, 1, 1.1667, 1.3125, 1.9583, 2] },
1436
- 5: { breakpoints: [0, 0, 0.25, 0.2917, 0.3125, 0.5625, 0.8125, 1, 1.0833, 1.0833, 1.5, 1.8542, 2] },
1437
- // turned back by 180° this is exactly the 6 (same segment split, same direction) → a pure spin
1438
- 6: { breakpoints: [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1, 1.25, 1.5, 1.75, 2], reversed: true },
1439
- // turned back by 180° this is the 6 laid out for the 8 (optimizer fit for 6 ↔ 8, mirrored onto the 9)
1440
- 8: { breakpoints: [0, 0.1667, 0.2917, 0.4375, 0.5833, 0.7292, 0.875, 1, 1.3125, 1.7083, 1.75, 1.75, 2], reversed: true },
1441
- },
1442
- },
1443
- // 9 is exactly 6 turned 180°, the 6's circle is the 8's bottom circle and the 2's bowl is the 9's circle:
1444
- // 6 ↔ 9, 8 ↔ 9 and 2 ↔ 6 morph while turning
1445
- spins: {
1446
- 2: { 6: 180 },
1447
- 6: { 9: 180, 2: -180 },
1448
- 8: { 9: 180 },
1449
- 9: { 6: -180, 8: -180 },
1450
- },
1451
- textLayout: { cellWidth: 10, spacing: 4, baselineY: 19.8, colonDotsY: [7.6, 15.6] },
1452
- });
1453
- return { roundDigits };
1454
- })();
1455
-
1456
- // sets/slim.ts
1457
- const { slimDigits } = (() => {
1458
- // Digit set "slim": thin, tall, round digits, drafted from the reference images in icons/slim/
1459
- // (the set format is described in ../types.ts).
1460
- //
1461
- // The images (94 px tall, 6 px stroke, 50 px wide; 5.5 px = 1 grid unit) reveal a strict construction, so the set
1462
- // is written directly in grid units: everything round is an arc of two equal circles of radius 4, stacked and
1463
- // touching at 12,12 — TOP at 12,8 and BOTTOM at 12,16 — inside the box x 8…16, y 4…20.
1464
- // - 8 = both circles; 3 = arcs of both; 2's bowl = TOP; 5's bowl = half of BOTTOM;
1465
- // 6 = arc of TOP + straight left side + a loop (a stadium a bit taller than BOTTOM); 9 = 6 turned 180° around 12,12
1466
- // - 0 = stadium; the 4 is wider and its top point sits lower (y ≈ 5.2), as in the images (its values nudged to the best pixel match)
1467
- // - round ends and corners
1468
- const RADIUS = 4;
1469
- const TOP = [12, 8];
1470
- const BOTTOM = [12, 16];
1471
- const topArc = (startAngle, endAngle) => arc(...TOP, RADIUS, startAngle, endAngle);
1472
- const bottomArc = (startAngle, endAngle) => arc(...BOTTOM, RADIUS, startAngle, endAngle);
1473
- /** Where the 2's diagonal leaves its bowl: tangent from the corner at 8,20 (a 3-4-5 triangle, hence the round angle). */
1474
- const TWO_DIAGONAL_ANGLE = 36.8699;
1475
- /** Where the 6's top arc ends on the right (measured: slightly below the circle's right point). */
1476
- const SIX_HOOK_ANGLE = 8;
1477
- const turnedAround = (stroke) => {
1478
- const turn = ([x, y]) => [24 - x, 24 - y];
1479
- switch (stroke.kind) {
1480
- case 'line': return line(...turn(stroke.to), ...turn(stroke.from));
1481
- case 'arc': return arc(...turn(stroke.center), stroke.radius, stroke.endAngle + 180, stroke.startAngle + 180);
1482
- case 'cubic': return { kind: 'cubic', segment: [turn(stroke.segment[3]), turn(stroke.segment[2]), turn(stroke.segment[1]), turn(stroke.segment[0])] };
1483
- }
1484
- };
1485
- /** The 6's loop is taller than the bottom circle: a stadium from this height down to 20 (measured). */
1486
- const SIX_LOOP_TOP = 10.91;
1487
- // the 6: top arc from the right over to the left, down the left side, round the loop and back into the left side
1488
- const SIX = [
1489
- topArc(SIX_HOOK_ANGLE, -180),
1490
- ...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]),
1491
- ];
1492
- // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
1493
- const slimDigits = withTextCharacters({
1494
- segmentCount: 16,
1495
- strokeWidth: 6 / 5.5,
1496
- lineCap: 'round',
1497
- lineJoin: 'round',
1498
- strokes: {
1499
- // stadium, from the top middle counter-clockwise
1500
- 0: roundedPolyline([[12, 4], [8, 4], [8, 20], [16, 20], [16, 4], [12, 4]], RADIUS),
1501
- // slanted flag up to the top of the stem, stem down
1502
- 1: [line(9.64, 9.1, 14.45, 4), line(14.45, 4, 14.45, 20)],
1503
- // bowl over the top, tangent diagonal down to the corner, base
1504
- 2: [
1505
- topArc(180, TWO_DIAGONAL_ANGLE + 360),
1506
- line(...pointOnCircle(...TOP, RADIUS, TWO_DIAGONAL_ANGLE), 8, 20),
1507
- line(8, 20, 16, 20),
1508
- ],
1509
- // top arc from the left round to the middle, bottom arc from the middle round to the left
1510
- 3: [topArc(180, 450), bottomArc(270, 548)],
1511
- // one stroke: crossbar right → left, diagonal up to the top, stem down
1512
- 4: [line(16.96, 16.03, 7, 16.03), line(7, 16.03, 14.85, 5.17), line(14.85, 5.17, 14.85, 20)],
1513
- // top bar right → left, left side down, middle bar, bowl (half of the bottom circle), bottom bar
1514
- 5: [line(16, 4, 8, 4), line(8, 4, 8, 12), line(8, 12, 12, 12), bottomArc(270, 450), line(12, 20, 8, 20)],
1515
- 6: SIX,
1516
- // top bar → slanted stroke down
1517
- 7: [line(8.09, 4, 15.89, 4), line(15.89, 4, 12.09, 20)],
1518
- // closed figure-eight: top circle clockwise, bottom circle counter-clockwise, meeting where they touch
1519
- 8: [topArc(90, 450), bottomArc(270, -90)],
1520
- // the 6 turned 180°, drawn the other way round
1521
- 9: SIX.map(turnedAround).reverse(),
1522
- },
1523
- alternativeStrokes: {
1524
- // the 8 traced like the 0 (as in the round set): from the top counter-clockwise — left half of TOP down to the waist,
1525
- // once around BOTTOM, right half of TOP back up
1526
- '8-like-0': [topArc(270, 90), bottomArc(270, -90), topArc(90, -90)],
1527
- // the 8 as two clockwise loops from the waist (as in the round set): the 3's ends slide along the circles into the waist
1528
- '8-two-loops': [topArc(90, 450), bottomArc(270, 630)],
1529
- // the usual figure-eight starting on the right side of BOTTOM (as in the round set, where the 4's crossbar starts)
1530
- '8-from-right': [bottomArc(0, -90), topArc(90, 450), bottomArc(270, 0)],
1531
- // mirrored figure-eight from the waist (as in the round set): TOP counter-clockwise, BOTTOM clockwise — like the 5
1532
- '8-mirrored': [topArc(90, -270), bottomArc(270, 630)],
1533
- },
1534
- // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd
1535
- pairLayouts: {
1536
- 0: {
1537
- 1: { startSegment: 6, reversed: true },
1538
- 2: { startSegment: 5, reversed: true },
1539
- 3: { startSegment: 4, reversed: true },
1540
- 4: { startSegment: 10, reversed: true },
1541
- 5: { startSegment: 5, reversed: true },
1542
- 7: { startSegment: 5, reversed: true },
1543
- 9: { startSegment: 5, reversed: true },
1544
- },
1545
- 1: {
1546
- 0: { breakpoints: [0, 0, 0, 0, 0.1875, 0.5208, 0.7708, 1, 1.2708, 1.4167, 1.5833, 1.75, 1.9375, 1.9375, 1.9375, 1.9375, 2] },
1547
- },
1548
- 2: {
1549
- 4: { breakpoints: [0, 0, 0.3958, 0.6458, 0.6458, 0.6667, 0.875, 1, 1.1458, 1.1458, 2, 2.875, 2.875, 2.875, 2.875, 2.875, 3] },
1550
- // 2 → 6 spins (see spins): in the turning frame it is 2 → 9 (same layout as 2 → 9)
1551
- 6: { breakpoints: [0, 0, 0, 0, 0, 0, 0.2083, 0.4167, 0.625, 0.8333, 1, 1.1458, 1.3333, 1.6042, 1.8125, 2, 3] },
1552
- 9: { breakpoints: [0, 0, 0, 0, 0, 0, 0.2083, 0.4167, 0.625, 0.8333, 1, 1.1458, 1.3333, 1.6042, 1.8125, 2, 3] },
1553
- 0: { breakpoints: [0, 0, 0, 0.1458, 0.2708, 0.4167, 0.625, 0.8333, 1, 1.1458, 1.3333, 1.5833, 1.8125, 1.8333, 1.8333, 2, 3] },
1554
- },
1555
- 3: {
1556
- 4: { breakpoints: [0, 0, 0.3333, 0.5208, 0.5208, 0.6042, 0.7708, 0.8333, 1, 1.1667, 1.2083, 1.3958, 1.4792, 1.4792, 1.4792, 1.6875, 2] },
1557
- 0: { breakpoints: [0, 0, 0.1042, 0.2292, 0.3333, 0.5, 0.6667, 1, 1.2292, 1.3125, 1.4792, 1.6458, 1.75, 1.8542, 2, 2, 2] },
1558
- },
1559
- 4: {
1560
- 6: { breakpoints: [0, 0.25, 0.25, 0.25, 0.4375, 1, 1.2917, 1.3542, 1.5208, 1.9583, 2, 2.1875, 2.1875, 2.1875, 2.1875, 2.1875, 3] },
1561
- // as in the round set: drawn backwards, so the stem's end meets the crossbar's start on the 8's right side
1562
- 8: { reversed: true },
1563
- 3: { breakpoints: [0, 1, 1.9792, 1.9792, 2, 2.1458, 2.2917, 2.4375, 2.4583, 2.5, 2.625, 2.7917, 2.9375, 2.9375, 2.9375, 2.9375, 3] },
1564
- 2: { breakpoints: [0, 1, 1.9583, 1.9583, 2, 2.0625, 2.2292, 2.3542, 2.4792, 2.6042, 2.75, 2.875, 3, 3, 3, 3, 3] },
1565
- 0: { breakpoints: [0, 0.5417, 0.7083, 0.8333, 1, 1.2083, 1.375, 1.5208, 1.6667, 1.8333, 1.9583, 2, 2.1875, 2.375, 2.5625, 2.7083, 3] },
1566
- },
1567
- 5: {
1568
- 0: { breakpoints: [0, 1, 1.1875, 1.1875, 1.1875, 1.1875, 2, 3, 3.25, 3.2917, 3.5, 3.75, 4, 4.5208, 4.8958, 5, 5] },
1569
- },
1570
- 6: {
1571
- 8: { breakpoints: [0, 0.4583, 0.5208, 0.5208, 0.5417, 1, 1.875, 2, 2.5, 3, 3.5, 4, 5, 5.4583, 5.9792, 6, 7] },
1572
- 4: { breakpoints: [0, 0, 0, 0, 0, 0.0625, 0.25, 0.5417, 1, 1.3542, 1.5, 2, 3, 4, 5, 6, 7], reversed: true },
1573
- // turned by 180° this is the 9 laid out for the 2 (the 2 ↔ 9 fit, mirrored onto the 6)
1574
- 2: { breakpoints: [0, 0.2917, 0.2917, 0.5417, 1, 1.5833, 1.6875, 2, 2.5, 3, 3.5, 4, 4, 4, 5, 6, 7], reversed: true },
1575
- },
1576
- 7: {
1577
- 0: { breakpoints: [0, 0, 0, 0.0417, 0.25, 0.4792, 1, 1.25, 1.3958, 1.5417, 1.7083, 1.9167, 1.9375, 1.9375, 1.9375, 1.9375, 2] },
1578
- },
1579
- 8: {
1580
- // as in round, square and condensed: the 0's left and right points move in to the waist
1581
- 0: { tracing: '8-like-0', breakpoints: [0, 0.1667, 0.3333, 0.5208, 0.625, 1, 1.25, 1.3333, 1.4167, 1.5, 1.625, 1.75, 2, 2.375, 2.4792, 2.75, 3] },
1582
- // the user's idea from the round set: the 4's two ends meet on the right side of the bottom circle
1583
- 4: { tracing: '8-from-right', breakpoints: [0, 0, 0.1667, 0.5, 1, 1.25, 1.5, 1.7083, 1.9167, 2, 2.125, 2.2292, 2.3333, 2.3542, 2.6667, 2.9792, 3] },
1584
- 6: { breakpoints: [0, 0.25, 0.5, 0.5, 0.75, 1, 1.1875, 1.25, 1.375, 1.5, 1.625, 1.75, 1.7917, 1.8958, 2, 2, 2] },
1585
- // the two-loop tracing fits the 9 best (no spin needed)
1586
- 9: { tracing: '8-two-loops', breakpoints: [0, 0, 0, 0.1042, 0.2083, 0.25, 0.375, 0.5, 0.625, 0.75, 0.8125, 1, 1.25, 1.375, 1.5, 1.75, 2] },
1587
- // the user's idea from the round set: both ends of the 5 meet at the waist — bars → top loop, bowl → bottom loop
1588
- 5: { tracing: '8-mirrored', breakpoints: [0, 0.25, 0.5, 0.5833, 0.625, 0.6875, 0.8125, 0.875, 0.9167, 1, 1.1042, 1.2083, 1.2917, 1.3958, 1.5, 1.75, 2] },
1589
- // the user's idea from the round set: both ends of the 3 slide along the circles into the waist
1590
- 3: { tracing: '8-two-loops', breakpoints: [0, 0.25, 0.4583, 0.5208, 0.625, 0.7083, 0.8125, 0.8958, 1, 1.1042, 1.1875, 1.2917, 1.3958, 1.4792, 1.5625, 1.75, 2] },
1591
- },
1592
- 9: {
1593
- 2: { breakpoints: [0, 1, 2, 3, 3, 3, 3.5, 4, 4.5, 5, 5.3125, 5.4167, 6, 6.4583, 6.7083, 6.7083, 7] },
1594
- 0: { breakpoints: [0, 1, 2, 3, 3.7292, 4, 4.5, 5, 5.3333, 5.6667, 6, 6.2292, 6.4792, 6.6458, 6.7917, 7, 7] },
1595
- // turned back by 180° this is exactly the 6 (same segment split, same direction) → a pure spin
1596
- 6: { breakpoints: [0, 0.5, 1, 1.5, 2, 3, 3.5, 4, 4.5, 5, 16 / 3, 17 / 3, 6, 6.25, 6.5, 6.75, 7], reversed: true },
1597
- },
1598
- },
1599
- // 9 is exactly 6 turned 180°; 2 → 6 also turns (it is 2 → 9 in the turning frame)
1600
- spins: { 2: { 6: 180 }, 6: { 9: 180, 2: -180 }, 9: { 6: -180 } },
1601
- textLayout: { cellWidth: 9.5, spacing: 4, baselineY: 20, colonDotsY: [8, 16] },
1602
- });
1603
- return { slimDigits };
1604
- })();
1605
-
1606
- // sets/square.ts
1607
- const { squareDigits } = (() => {
1608
- // Digit set "square": rounded-square "techno" digits, drafted from the reference images in icons/square/
1609
- // (the set format is described in ../types.ts).
1610
- //
1611
- // The images are tiny (every digit 28 px tall, 4 px stroke), so the shapes are written on the images' own pixel
1612
- // grid and converted: x/y are the stroke's middle line in image pixels, measured from the digit's ink box
1613
- // (y 2…26 = top to bottom middle line; x 2 = middle of a stroke on the left edge). 1.5 image px = 1 grid unit.
1614
- // - straight strokes with rounded corners (roundedPolyline), round ends
1615
- // - 3 and 5 share the same right-side bowls (corner radius 5 px); 8 = two stacked rounded rectangles sharing
1616
- // the middle bar; 9 = 6 turned 180° around 12,12
1617
- // - T-junctions (1's foot, 3's middle bar, 4's crossbar) are drawn out and back over themselves
1618
- const PIXELS_PER_UNIT = 1.5;
1619
- /** The images' stroke is 4 px ≈ 2.67 units; thinner by the user's choice. */
1620
- const STROKE_WIDTH = 1.6;
1621
- /** Image pixels (middle line, measured from the ink box of a digit `inkWidth` px wide) → grid units. */
1622
- const fromImage = (inkWidth) => ([x, y]) => [12 + (x - inkWidth / 2) / PIXELS_PER_UNIT, 4 + (y - 2) / PIXELS_PER_UNIT];
1623
- /** A rounded polyline given in image pixels; radii in image pixels too. */
1624
- const drawn = (inkWidth, points, radii) => roundedPolyline(points.map(fromImage(inkWidth)), Array.isArray(radii) ? radii.map((radius) => radius / PIXELS_PER_UNIT) : radii / PIXELS_PER_UNIT);
1625
- const turnedAround = ([x, y]) => [24 - x, 24 - y];
1626
- // corner radii (image px), each the best pixel match with the images; bowls are exact half-circles
1627
- const ZERO_RADIUS = 8; // half the width: the 0 is a stadium
1628
- const THREE_BOWL_RADIUS = 6; // half of each bowl's height (12 px)
1629
- const FIVE_BOWL_RADIUS = 6.5; // half of the bowl's height (13 px)
1630
- const SIX_RADIUS = 7;
1631
- const EIGHT_RADIUS = 6;
1632
- // the 6: hook from the top right, round the left side, then the closed bottom loop back to the left side
1633
- const SIX_POINTS = [[15, 2], [2, 2], [2, 26], [19, 26], [19, 12], [2, 12]];
1634
- const SIX_RADII = [0, SIX_RADIUS, SIX_RADIUS, SIX_RADIUS, SIX_RADIUS, 0];
1635
- // '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
1636
- const squareDigits = withTextCharacters({
1637
- segmentCount: 20,
1638
- strokeWidth: STROKE_WIDTH,
1639
- lineCap: 'round',
1640
- lineJoin: 'round',
1641
- strokes: {
1642
- // closed rounded rectangle, from the top middle counter-clockwise
1643
- 0: drawn(20, [[10, 2], [2, 2], [2, 26], [18, 26], [18, 2], [10, 2]], ZERO_RADIUS),
1644
- // flag → stem down → foot to the left and back across to the right
1645
- 1: drawn(16, [[2, 2], [8, 2], [8, 26], [2, 26], [14, 26]], 0),
1646
- // top bar → right side → diagonal down to the left → left side → base
1647
- 2: drawn(20, [[3, 2], [18, 2], [18, 9.25], [2, 17.25], [2, 26], [18, 26]], [0, 2, 4.5, 4.5, 0, 0]),
1648
- // top bar → upper bowl → middle bar out to the left and back → lower bowl → bottom bar
1649
- 3: drawn(20, [[2, 2], [18, 2], [18, 14], [6, 14], [18, 14], [18, 26], [2, 26]], [0, THREE_BOWL_RADIUS, THREE_BOWL_RADIUS, 0, THREE_BOWL_RADIUS, THREE_BOWL_RADIUS, 0]),
1650
- // left stem down → crossbar to its right end and back to the right stem → down to the bottom and back up to the top
1651
- 4: drawn(24, [[2, 2], [2, 18], [22, 18], [18, 18], [18, 26], [18, 2]], [0, 1, 0, 0, 0, 0]),
1652
- // top bar right → left, down the left side, middle bar → bowl → bottom bar
1653
- 5: drawn(20, [[16, 2], [2, 2], [2, 13], [18, 13], [18, 26], [2, 26]], [0, 0, 0, FIVE_BOWL_RADIUS, FIVE_BOWL_RADIUS, 0]),
1654
- 6: drawn(21, SIX_POINTS, SIX_RADII),
1655
- // top bar → diagonal down to the left
1656
- 7: drawn(20, [[2, 2], [17.5, 2], [3.7, 26]], 0),
1657
- // from the waist: top rectangle (left side up, clockwise), along the middle bar, bottom rectangle (counter-clockwise)
1658
- 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]),
1659
- // the 6 turned 180°, drawn the other way round (loop first, then the hook)
1660
- 9: roundedPolyline(SIX_POINTS.map(fromImage(21)).map(turnedAround).reverse(), [...SIX_RADII].reverse().map((radius) => radius / PIXELS_PER_UNIT)),
1661
- },
1662
- alternativeStrokes: {
1663
- // the 8 traced like the 0 (from the top, counter-clockwise): left side of the top loop down to the middle of
1664
- // the middle bar, once around the bottom loop back to the middle, right side of the top loop back up
1665
- '8-like-0': drawn(20, [[10, 2], [3, 2], [3, 14], [10, 14], [2, 14], [2, 26], [18, 26], [18, 14], [10, 14], [17, 14], [17, 2], [10, 2]], [0, EIGHT_RADIUS, EIGHT_RADIUS, 0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0, EIGHT_RADIUS, EIGHT_RADIUS, 0]),
1666
- // the 8 traced like the 3, starting and ending at the left part of the middle bar: around the top loop, along
1667
- // the middle bar out to the left and back, around the bottom loop
1668
- '8-like-3': drawn(20, [[9, 14], [3, 14], [3, 2], [17, 2], [17, 14], [8, 14], [18, 14], [18, 26], [2, 26], [2, 14], [8, 14]], [0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0]),
1669
- },
1670
- // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd
1671
- pairLayouts: {
1672
- 0: {
1673
- 1: { startSegment: 5, reversed: true },
1674
- 2: { startSegment: 15 },
1675
- 3: { startSegment: 5, reversed: true },
1676
- 5: { startSegment: 5, reversed: true },
1677
- 7: { startSegment: 5, reversed: true },
1678
- 9: { startSegment: 7, reversed: true },
1679
- },
1680
- 1: {
1681
- 9: { breakpoints: [0, 0.2083, 0.2083, 0.4792, 1, 1, 1.25, 1.4583, 1.5417, 1.75, 2, 2, 2.0208, 2.25, 2.25, 2.25, 2.25, 2.25, 2.25, 3, 4] },
1682
- 0: { breakpoints: [0, 0, 0, 0, 0.5, 1, 1.0208, 1.0833, 1.2083, 1.3333, 1.5, 1.6667, 1.7917, 1.9167, 1.9792, 2, 2.5, 3, 3, 3, 4] },
1683
- },
1684
- 2: {
1685
- 4: { breakpoints: [0, 1, 1, 1, 1, 2, 3, 4, 4.2292, 4.2292, 4.2292, 5, 5.2292, 5.5208, 5.5208, 5.5208, 5.5208, 5.5208, 6, 7, 8] },
1686
- 6: { breakpoints: [0, 1, 2, 3, 4, 5, 5.0833, 5.2917, 6, 6.375, 7, 7.4375, 7.6042, 7.7708, 7.7708, 7.7708, 7.7708, 7.7708, 7.7708, 7.7708, 8] },
1687
- 9: { breakpoints: [0, 0.2708, 0.2708, 0.2708, 0.2708, 0.2708, 0.2708, 0.2708, 0.2708, 0.3958, 0.6458, 1, 2, 3, 4, 4.0833, 4.1875, 5, 6, 7, 8] },
1688
- 0: { breakpoints: [0, 1, 2, 2, 2, 3, 4, 4.6667, 4.7917, 5, 5.1042, 5.6667, 6, 7, 7.3333, 7.5, 7.6875, 7.8542, 7.9792, 8, 8] },
1689
- },
1690
- 3: {
1691
- 4: { breakpoints: [0, 1, 1.3958, 1.3958, 1.3958, 1.5833, 2, 2.4167, 2.6042, 2.6042, 2.6042, 3, 4, 5, 5.5625, 6, 6, 7, 7.9792, 8, 8] },
1692
- 6: { breakpoints: [0, 1, 1.1458, 1.1458, 1.1458, 1.1458, 1.1458, 1.1458, 2, 3, 4, 4, 5, 6, 6.2292, 6.2292, 6.2292, 6.2292, 6.2292, 7, 8] },
1693
- 9: { breakpoints: [0, 0.3333, 0.3333, 0.4792, 1, 1.7292, 2, 2.3125, 2.3125, 3, 4, 5, 5.5833, 5.5833, 5.5833, 6, 6.5, 7, 7.3542, 7.5625, 8] },
1694
- 0: { breakpoints: [0, 0, 0.0417, 0.2292, 0.4792, 0.8333, 1, 1.5, 2, 3, 4, 5, 6, 6.5, 7, 7.1667, 7.5208, 7.7708, 7.9583, 8, 8] },
1695
- },
1696
- 4: {
1697
- 7: { breakpoints: [0, 1, 2, 2.3125, 2.3125, 3, 4, 5, 5.7083, 5.7083, 5.7708, 5.8542, 5.9167, 6, 6, 6, 6, 6, 6, 6, 6], reversed: true },
1698
- 9: { breakpoints: [0, 1, 2, 3, 4, 5, 5.5625, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 6], reversed: true },
1699
- 3: { breakpoints: [0, 1, 2, 3, 4, 5, 5.5, 5.5, 5.5, 5.5, 5.5, 5.5, 5.5208, 5.625, 5.75, 5.875, 5.9792, 6, 6, 6, 6], reversed: true },
1700
- 2: { breakpoints: [0, 1, 2, 2.1042, 2.1042, 2.1042, 2.1042, 2.1042, 2.2083, 2.2083, 3, 4, 5, 5.8125, 5.8125, 5.9375, 6, 6, 6, 6, 6], reversed: true },
1701
- },
1702
- 5: {
1703
- 6: { breakpoints: [0, 0.3542, 0.4792, 1, 1.3333, 1.625, 2, 2, 2, 2.0208, 2.2292, 3, 4, 4, 4, 4, 4, 4, 4, 5, 6] },
1704
- 8: { breakpoints: [0, 0.1042, 0.1042, 0.1042, 0.1042, 0.3542, 0.4583, 1, 1.5625, 2, 2.6667, 2.6667, 3, 4, 4.5, 5, 5.3542, 5.875, 5.8958, 5.8958, 6] },
1705
- 9: { breakpoints: [0, 1, 1.4792, 1.4792, 1.4792, 1.4792, 1.4792, 1.4792, 1.4792, 1.4792, 2, 3, 3.3958, 3.7708, 4, 4.3333, 4.6667, 5, 5.3542, 5.5417, 6] },
1706
- 0: { breakpoints: [0, 1, 1.2083, 1.2083, 1.2083, 2, 3, 3.1875, 3.2708, 3.3958, 3.5208, 4, 4.125, 4.5, 4.875, 5, 5.5208, 5.75, 5.9583, 6, 6] },
1707
- },
1708
- 6: {
1709
- 7: { breakpoints: [0, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 1, 2, 2.625, 3, 4, 5, 6, 7, 8] },
1710
- 8: { breakpoints: [0, 0.2292, 0.2292, 0.2292, 0.2292, 1, 2, 2.5833, 2.6458, 3, 3.5417, 4, 5, 5.4583, 6, 7, 7.3542, 7.3542, 7.6875, 7.8333, 8] },
1711
- 5: { breakpoints: [0, 0.5, 1, 1.4792, 1.6875, 2, 2.375, 2.375, 2.375, 2.375, 3, 4, 5, 5.9792, 5.9792, 5.9792, 5.9792, 5.9792, 6, 7, 8] },
1712
- 3: { breakpoints: [0, 0.1458, 0.1458, 0.1458, 0.1458, 0.1458, 0.1458, 0.1458, 0.1458, 1, 2, 3, 4, 5, 5.625, 5.625, 5.625, 5.625, 6, 7, 8] },
1713
- 2: { breakpoints: [0, 0, 0, 0, 1, 2, 2.8125, 2.8125, 3, 3.3125, 3.5208, 4, 5, 5.2292, 5.2292, 5.2292, 5.2292, 5.2292, 6, 7, 8] },
1714
- },
1715
- 7: {
1716
- 6: { breakpoints: [0, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 1, 1.5208, 1.7083, 1.7083, 1.7083, 1.7083, 1.7083, 1.7083, 2] },
1717
- 4: { breakpoints: [0, 0.9167, 0.9167, 0.9167, 0.9167, 0.9167, 0.9167, 1, 1.1042, 1.125, 1.2083, 1.2292, 1.2917, 1.5833, 1.5833, 1.5833, 1.6667, 1.7083, 1.7292, 1.7292, 2] },
1718
- 0: { breakpoints: [0, 0, 0.0208, 0.1458, 0.3125, 0.5208, 0.6875, 1, 1.1667, 1.2292, 1.375, 1.4792, 1.6042, 1.7083, 1.8125, 1.875, 1.875, 1.875, 1.875, 1.875, 2] },
1719
- },
1720
- 8: {
1721
- 5: { startSegment: 11, reversed: true },
1722
- 6: { startSegment: 4 },
1723
- 7: { tracing: '8-like-3', breakpoints: [0, 1, 1.8542, 2, 3, 3.2083, 3.3958, 3.5208, 3.5833, 3.6875, 4, 4.3542, 4.7708, 5, 6, 7, 8, 9, 10, 11, 12] },
1724
- 9: { tracing: '8-like-3', breakpoints: [0, 0, 0, 0, 0.3542, 0.5833, 1, 1.2083, 1.5625, 2, 3, 3.375, 4, 5, 6, 7, 8, 9, 10, 11, 12] },
1725
- 4: { tracing: '8-like-0', breakpoints: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 9, 9, 9, 10, 11, 12, 13, 14, 15] },
1726
- // the user's idea: both ends of the 3 move to the left tip of its middle bar, where the 8's loops close
1727
- 3: {
1728
- tracing: '8-like-3',
1729
- breakpoints: [0, 1, 2, 3, 3.4375, 3.7083, 4, 4.2917, 4.5625, 5, 5.7292, 6, 6.1458, 7, 7.5, 8, 8.5, 9, 10, 11, 12],
1730
- },
1731
- // the user's idea: the 0's left and right sides move in to the middle of the 8's middle bar
1732
- 0: {
1733
- tracing: '8-like-0',
1734
- breakpoints: [0, 1, 1.6042, 2, 3, 4, 5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 10, 11, 12, 13, 13.3958, 14, 15],
1735
- },
1736
- },
1737
- 9: {
1738
- 5: { breakpoints: [0, 1, 2, 2.1875, 2.1875, 2.1875, 2.1875, 2.1875, 2.1875, 2.1875, 3, 4, 5, 5.8333, 6, 6.375, 6.6875, 7, 7.5417, 8, 8] },
1739
- 4: { breakpoints: [0, 1, 2, 3, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5.5, 6, 6.0208, 6.0208, 7, 8, 8] },
1740
- 3: { breakpoints: [0, 1, 2, 3, 4, 4.6667, 5, 5.1875, 5.4375, 5.5, 5.5, 5.5, 5.5, 5.5, 5.6667, 6, 6.5417, 7, 7.4583, 8, 8] },
1741
- 2: { breakpoints: [0, 1, 2, 2.8333, 2.8333, 2.8333, 2.8333, 2.8333, 2.8333, 3, 4, 4.3542, 4.5833, 4.8125, 5, 5.0625, 6, 7, 8, 8, 8] },
1742
- 1: { breakpoints: [0, 1, 2, 3, 3.5, 4, 5, 5.3958, 5.6042, 6, 7, 7.2708, 7.2708, 7.5208, 7.5208, 7.5208, 7.5208, 7.5208, 7.5208, 7.5208, 8] },
1743
- 0: { breakpoints: [0, 1, 1.6667, 2, 2.2083, 2.5, 3, 3.5, 4, 4.5, 4.7917, 5, 5.5, 6, 6.1875, 6.5417, 6.875, 7, 7.875, 8, 8] },
1744
- // turned back by 180° this is exactly the 6 (same segment split, same direction) → a pure spin
1745
- 6: {
1746
- breakpoints: [0, 0.5, 1, 4 / 3, 5 / 3, 2, 7 / 3, 8 / 3, 3, 4, 13 / 3, 14 / 3, 5, 16 / 3, 17 / 3, 6, 19 / 3, 20 / 3, 7, 7.5, 8],
1747
- reversed: true,
1748
- },
1749
- },
1750
- },
1751
- spins: { 6: { 9: 180 }, 9: { 6: -180 } },
1752
- textLayout: { cellWidth: 13.5, spacing: 4, baselineY: 20, colonDotsY: [8, 16] },
1753
- });
1754
- return { squareDigits };
1755
- })();
1756
-
1757
- // sets/theme.ts
1758
- const { themeIcons } = (() => {
1759
- // Icon set "theme": light (sun), dark (moon) — Lucide 1.61.0 icons "sun", "moon" (ISC, see LICENSE-lucide).
1760
- // Coordinates below are Lucide's; lucideLine / lucideArc / lucideCubic scale them into our icon box (see lucide.ts).
1761
- // Nine pieces:
1762
- // piece 1: the sun's circle | the moon's crescent — the circle grows into the moon and its top right quarter
1763
- // flattens and caves in evenly into the inner curve (the circle starts where the moon's outer arc starts).
1764
- // pieces 2–9: one ray each | nothing — rays run from their outer end inwards; light → dark: they fly outwards and
1765
- // vanish (erased towards their outer tip, PairLayout.draw — the user's sketch) within the first 60 % of the
1766
- // morph; dark → light: they fly back in during the last 60 %. At rest the moon's ray pieces are tiny lines hidden
1767
- // on its outline.
1768
- const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
1769
- const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
1770
- const lucideCubic = (...coordinates) => {
1771
- const [startX, startY, control1X, control1Y, control2X, control2Y, endX, endY] = coordinates;
1772
- return cubic(...fromLucide([startX, startY]), ...fromLucide([control1X, control1Y]), ...fromLucide([control2X, control2Y]), ...fromLucide([endX, endY]));
1773
- };
1774
- /** Lucide "moon": the outer arc clockwise from its right end, the top tip, the inner arc back, the right tip. */
1775
- const MOON = [
1776
- lucideArc(11.9981, 12.0009, 9, 3.09, 266.904),
1777
- lucideCubic(11.512, 3.014, 11.917, 2.992, 12.129, 3.474, 11.914, 3.817),
1778
- lucideArc(17.002, 6.997, 6, -147.9941, -302.0059),
1779
- lucideCubic(20.182, 12.085, 20.526, 11.87, 21.007, 12.081, 20.985, 12.486),
1780
- ];
1781
- /** Lucide "sun"'s circle, started at the angle where the moon's outer arc starts. */
1782
- const SUN_CIRCLE = [lucideArc(12, 12, 4, 3.09, 363.09)];
1783
- /** The share of the circle under the moon's outer arc (3.09° … 266.904°). */
1784
- const OUTER_SHARE = (266.904 - 3.09) / 360;
1785
- const CIRCLE_FOR_MOON = [
1786
- ...Array.from({ length: 9 }, (_, index) => (index / 8) * OUTER_SHARE),
1787
- ...Array.from({ length: 4 }, (_, index) => OUTER_SHARE + ((index + 1) / 4) * (1 - OUTER_SHARE)),
1788
- ].map((breakpoint) => +breakpoint.toFixed(4));
1789
- /** Lucide "sun"'s rays, each from its outer end inwards (erasing keeps the outer end: the ray flies out). */
1790
- const RAYS = [
1791
- lucideLine(12, 2, 12, 4),
1792
- lucideLine(19.07, 4.93, 17.66, 6.34),
1793
- lucideLine(22, 12, 20, 12),
1794
- lucideLine(19.07, 19.07, 17.66, 17.66),
1795
- lucideLine(12, 22, 12, 20),
1796
- lucideLine(4.93, 19.07, 6.34, 17.66),
1797
- lucideLine(2, 12, 4, 12),
1798
- lucideLine(4.93, 4.93, 6.34, 6.34),
1799
- ];
1800
- /** The moon's ray pieces at rest: a tiny line on the bottom of its outline (it doesn't show). */
1801
- const HIDDEN_RAY = [lucideLine(11.95, 21, 12.05, 21)];
1802
- const rayPiece = (ray) => ({
1803
- strokes: { light: [ray], dark: HIDDEN_RAY },
1804
- alternativeStrokes: {},
1805
- pairLayouts: { dark: { light: { draw: 0.6 } } },
1806
- });
1807
- const themeIcons = {
1808
- segmentCount: 12,
1809
- strokeWidth: LUCIDE_STROKE_WIDTH,
1810
- lineCap: 'round',
1811
- lineJoin: 'round',
1812
- strokes: { light: SUN_CIRCLE, dark: MOON },
1813
- alternativeStrokes: {},
1814
- // even, by hand: the circle's part under the outer arc (263.8°) → 8 equal segments of the outer arc; its top right
1815
- // quarter (96.2°) → 4 equal segments: tip, inner curve (2), tip. (The optimizer's fit made a lumpy notch.)
1816
- pairLayouts: {
1817
- light: { dark: { breakpoints: CIRCLE_FOR_MOON } },
1818
- dark: { light: { breakpoints: [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1, 2, 2.5, 3, 4] } },
1819
- },
1820
- morePieces: RAYS.map(rayPiece),
1821
- spins: {},
1822
- };
1823
- return { themeIcons };
1824
- })();
1825
-
1826
- // sets/weather.ts
1827
- const { weatherIcons } = (() => {
1828
- // Icon set "weather": sunny, frosty, partly-cloudy, showers, cloudy, thunder, rain, snow, drizzle — Lucide 1.61.0
1829
- // icons "sun", "sun-snow", "cloud-sun", "cloud-sun-rain", "cloud", "cloud-lightning", "cloud-rain", "cloud-snow",
1830
- // "cloud-drizzle" (ISC, see LICENSE-lucide). frosty is added last, onto the finished set (withFrosty, at the end).
1831
- // Coordinates below are Lucide's; lucideLine / lucideArc scale them into our icon box (see lucide.ts).
1832
- // Pieces:
1833
- // sunny ↔ cloudy (experiment, the user's idea for a more realistic look): the cloud crawls in from the left over
1834
- // the sun — it slides in at full size from the box's left side (PairLayout.wipe + slide; its part outside the
1835
- // box doesn't show), and the sun's circle and rays disappear where its front has passed. (Rejected: the cloud
1836
- // standing still, revealed behind the edge — user: even worse.) (Before: sunny's circle inflated into the cloud with the
1837
- // default layout — confirmed "great" by the user — and the rays flew out, `draw: 0.6`.)
1838
- // piece 1, the cloud.
1839
- // sunny → partly-cloudy / showers: the small cloud crawls in from the left like the big one (user's choice; before:
1840
- // it expanded from its bottom left corner — rejected earlier: a circle copy turning into it, drawing it in).
1841
- // 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
1842
- // morphs it exactly like piece 1. sunny → partly-cloudy: the circle opens behind the cloud — its hidden part pulls
1843
- // back from the middle into both ends of the arc, the arc itself stays.
1844
- // pieces 3–10: one ray each | nothing — rays run from their outer end inwards; sunny → partly-cloudy: the 4 rays partly-cloudy
1845
- // keeps stay, the other 4 fly out. At rest a missing ray is a single point (never drawn, see toPathData).
1846
- // pieces 11–12: showers' rain lines, which are also rain's left and middle line; piece 14: rain's right line —
1847
- // nothing = a single point. partly-cloudy → showers: drawn downwards like a pen, one after the other, while the
1848
- // cloud's bottom opens. cloudy → rain: like cloudy → thunder (the cloud rises and opens), the 3 lines drawn down
1849
- // one after the other, left to right; from partly-cloudy / showers / thunder the same (showers' lines stretch,
1850
- // the bolt is erased); sunny → rain: the cloud crawls in with its rain (its left end carried by the bolt piece).
1851
- // pieces 11, 12, 14 are also snow's top flakes (the top of each rain line), pieces 15–17 its bottom flakes (hidden
1852
- // at the bottom of each rain line in rain): rain → snow: each line shrinks into its top flake while the bottom
1853
- // flake comes out of its lower end. → snow otherwise: the flakes appear one after the other, row by row (ride
1854
- // in with the cloud from sunny).
1855
- // drizzle's 6 short lines sit where snow's flakes are, so they are the same 6 pieces: rain → drizzle: each line
1856
- // shrinks into its short top piece, the bottom piece grows out of its lower end; snow ↔ drizzle: flake ↔ short
1857
- // line (halves, as rain ↔ snow); → drizzle otherwise like → snow (drawn row by row, riding in from sunny).
1858
- // snow and drizzle have rain's cloud: every other piece morphs exactly like rain's (withRainTwins).
1859
- // The rules of this set (the user's): sun → any cloud = the cloud crawls in from the left and covers the sun;
1860
- // partly-cloudy / showers → a cloud without sun = like → cloudy (the sun erased back down).
1861
- // piece 13: the lightning bolt | nothing (a single point) — cloudy → thunder: the cloud rises a little and opens
1862
- // at the bottom (like cloudy → showers), the bolt strikes down from it like a pen. partly-cloudy / showers →
1863
- // thunder: like → cloudy (the cloud grows, the sun is erased back down), plus the bolt (and showers' rain erased).
1864
- // sunny → thunder: the cloud crawls in like sunny → cloudy, the bolt rides with it. The open cloud's lower left
1865
- // end curls back right, so during the crawl it is carried by the first rain piece (else it would poke out of the
1866
- // box's left side): piece 1 is laid out without it — together they look the same.
1867
- // showers otherwise morphs like partly-cloudy (sunny: the cloud crawls in, the circle opens; cloudy: the cloud
1868
- // shrinks and opens, the sun is drawn in), plus the rain: drawn down, or (from sunny) riding in with the cloud.
1869
- const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
1870
- const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
1871
- /** Lucide "cloud", clockwise from the bottom right: bottom line, big bump, top line, right bump. */
1872
- const CLOUD = [
1873
- lucideLine(17.5, 19, 9, 19),
1874
- lucideArc(9.0018, 12, 7, 90.0147, 343.3984),
1875
- lucideLine(15.71, 10, 17.5, 10),
1876
- lucideArc(17.5, 14.5, 4.5, -90, 90),
1877
- ];
1878
- /** sunny → partly-cloudy / showers: the small cloud (and its rain) crawls in a bit later (user's request). */
1879
- const SMALL_CLOUD_CRAWL = [0.4, 1];
1880
- /** CLOUD traced from its leftmost point (sunny ↔ cloudy: it slides in from the left, its left side shows last). */
1881
- const CLOUD_FROM_LEFT = [
1882
- lucideArc(9.0018, 12, 7, 180, 343.3984),
1883
- ...CLOUD.slice(2),
1884
- CLOUD[0],
1885
- lucideArc(9.0018, 12, 7, 90.0147, 180),
1886
- ];
1887
- /** The sun's circle traced from its leftmost point (sunny ↔ cloudy: the wiping edge reaches it first). */
1888
- const CIRCLE_FROM_LEFT = [lucideArc(12, 12, 4, 180, 540)];
1889
- /** A wipe's piece laid out along an alternative tracing with the default spread. */
1890
- const tracedForWipe = (tracing, strokes) => ({ tracing, breakpoints: defaultBreakpoints(strokes, 12) });
1891
- /** The angle (from the icon centre) where the cloud starts, so the circle starts in the same direction. */
1892
- const CIRCLE_START = 51.84;
1893
- /** Lucide "sun"'s circle, clockwise from CIRCLE_START. */
1894
- const SUN_CIRCLE = [lucideArc(12, 12, 4, CIRCLE_START, CIRCLE_START + 360)];
1895
- /** Lucide "cloud-sun"'s small cloud, clockwise from the bottom right (the same build as CLOUD). */
1896
- const SMALL_CLOUD = [
1897
- lucideLine(13, 22, 7, 22),
1898
- lucideArc(7.001, 17, 5, 90.0117, 348.463),
1899
- lucideLine(11.9, 16, 13, 16),
1900
- lucideArc(13, 19, 3, -90, 90),
1901
- ];
1902
- /** SMALL_CLOUD traced from its leftmost point (sunny ↔ partly-cloudy: it slides in like the big cloud). */
1903
- const SMALL_CLOUD_FROM_LEFT = [
1904
- lucideArc(7.001, 17, 5, 180, 348.463),
1905
- ...SMALL_CLOUD.slice(2),
1906
- SMALL_CLOUD[0],
1907
- lucideArc(7.001, 17, 5, 90.0117, 180),
1908
- ];
1909
- /** Lucide "cloud-sun-rain"'s cloud: SMALL_CLOUD open at the bottom (where the rain falls), from its lower left end. */
1910
- const OPEN_SMALL_CLOUD = [
1911
- lucideArc(7.0014, 17.0019, 5, 143.1569, 348.4412),
1912
- lucideLine(11.9, 16, 13, 16),
1913
- lucideArc(13.0044, 19, 3, -90.0842, 48.3025),
1914
- ];
1915
- /** Where SMALL_CLOUD's gap (bottom) has its middle: the gap's two halves pull back into its ends from here. */
1916
- const GAP_MIDDLE_X = 8.77;
1917
- // partly-cloudy ↔ showers: the closed cloud traced from the gap's middle — 2 segments to the gap's left end, the open
1918
- // 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
1919
- // 2 zero-length segments at each end.
1920
- const CLOSED_FOR_OPEN = {
1921
- tracing: 'small-cloud-from-gap-middle',
1922
- breakpoints: [0, 1, 1.2057, 1.4043, 1.6029, 1.8014, 2, 3, 3.2561, 3.5122, 3.7683, 4, 5],
1923
- };
1924
- const OPEN_FOR_CLOSED = { breakpoints: [0, 0, 0, 0.25, 0.5, 0.75, 1, 2, 2.3333, 2.6667, 3, 3, 3] };
1925
- /** The same gap on the big CLOUD (its bottom, from 143.16° on the big bump to 48.3° on the right bump). */
1926
- const BIG_GAP_MIDDLE_X = 11.65;
1927
- // cloudy ↔ showers: the big cloud traced from its gap's middle, split like CLOSED_FOR_OPEN — it shrinks and opens
1928
- const BIG_CLOSED_FOR_OPEN = {
1929
- tracing: 'cloud-from-gap-middle',
1930
- breakpoints: [0, 1, 1.2097, 1.4073, 1.6049, 1.8024, 2, 3, 3.2561, 3.5122, 3.7683, 4, 5],
1931
- };
1932
- /** Lucide "cloud-lightning"'s cloud: CLOUD 2 units higher, open at the bottom (where the bolt strikes), from its
1933
- * lower left end. */
1934
- const THUNDER_CLOUD = [
1935
- lucideArc(9.0026, 10.0027, 7, 115.4004, 343.3756),
1936
- lucideLine(15.71, 8, 17.5, 8),
1937
- lucideArc(17.5079, 12.5, 4.5, -90.1, 83.7212),
1938
- ];
1939
- /** The middle of CLOUD's part that is missing in THUNDER_CLOUD (its bottom, from 115.4° to 83.7°). */
1940
- const THUNDER_GAP_MIDDLE_X = 11.94;
1941
- // cloudy ↔ thunder: CLOUD traced from that middle — 2 segments to the gap's left end, 4 along the big bump, 1 top line,
1942
- // 3 along the right bump, 2 back to the middle; the open cloud gets 2 zero-length segments at each end (OPEN_FOR_CLOSED).
1943
- const CLOUD_FROM_THUNDER_GAP_MIDDLE = [
1944
- lucideLine(THUNDER_GAP_MIDDLE_X, 19, 9, 19),
1945
- ...CLOUD.slice(1),
1946
- lucideLine(17.5, 19, THUNDER_GAP_MIDDLE_X, 19),
1947
- ];
1948
- const CLOUD_FOR_THUNDER = {
1949
- tracing: 'cloud-from-thunder-gap-middle',
1950
- breakpoints: [0, 1, 1.1002, 1.3252, 1.5501, 1.7751, 2, 3, 3.3217, 3.6434, 3.9651, 4, 5],
1951
- };
1952
- /** sunny ↔ thunder: THUNDER_CLOUD split at its leftmost point — the rest (from there over the top), and its lower
1953
- * left end (from there down), each running away from the left side so the crawl shows one stretch of it. */
1954
- const THUNDER_CLOUD_FROM_LEFT = [
1955
- lucideArc(9.0026, 10.0027, 7, 180, 343.3756),
1956
- ...THUNDER_CLOUD.slice(1),
1957
- ];
1958
- const THUNDER_CLOUD_LEFT_END = [lucideArc(9.0026, 10.0027, 7, 180, 115.4004)];
1959
- /** Lucide "cloud-lightning"'s bolt, from the top down. */
1960
- const BOLT = [lucideLine(13, 12, 10, 17), lucideLine(10, 17, 14, 17), lucideLine(14, 17, 11, 22)];
1961
- /** cloudy → thunder: the bolt strikes in the second half of the morph. */
1962
- const BOLT_DRAWN = [0.5, 0.85];
1963
- /** Lucide "cloud-rain"'s cloud (also "cloud-snow", "cloud-drizzle"): THUNDER_CLOUD with a wider gap, from its lower
1964
- * left end. */
1965
- const RAIN_CLOUD = [
1966
- lucideArc(9.0026, 10.0027, 7, 135.615, 343.3757),
1967
- lucideLine(15.71, 8, 17.5, 8),
1968
- lucideArc(17.5005, 12.5, 4.5, -90.0065, 56.2589),
1969
- ];
1970
- /** The middle of CLOUD's part that is missing in RAIN_CLOUD (its bottom, from 135.6° to 56.3°). */
1971
- const RAIN_GAP_MIDDLE_X = 11.79;
1972
- const CLOUD_FROM_RAIN_GAP_MIDDLE = [
1973
- lucideLine(RAIN_GAP_MIDDLE_X, 19, 9, 19),
1974
- ...CLOUD.slice(1),
1975
- lucideLine(17.5, 19, RAIN_GAP_MIDDLE_X, 19),
1976
- ];
1977
- // cloudy ↔ rain: split like CLOUD_FOR_THUNDER (2 | 4 big bump | 1 | 3 right bump | 2), RAIN_CLOUD with OPEN_FOR_CLOSED
1978
- const CLOUD_FOR_RAIN = {
1979
- tracing: 'cloud-from-rain-gap-middle',
1980
- breakpoints: [0, 1, 1.18, 1.385, 1.59, 1.795, 2, 3, 3.2709, 3.5417, 3.8126, 4, 5],
1981
- };
1982
- /** sunny ↔ rain: RAIN_CLOUD split at its leftmost point, like THUNDER_CLOUD_FROM_LEFT / THUNDER_CLOUD_LEFT_END. */
1983
- const RAIN_CLOUD_FROM_LEFT = [lucideArc(9.0026, 10.0027, 7, 180, 343.3757), ...RAIN_CLOUD.slice(1)];
1984
- const RAIN_CLOUD_LEFT_END = [lucideArc(9.0026, 10.0027, 7, 180, 135.615)];
1985
- /** Lucide "cloud-rain"'s lines, left to right, each from the top down. */
1986
- const HEAVY_RAIN_LINES = [lucideLine(8, 14, 8, 20), lucideLine(12, 16, 12, 22), lucideLine(16, 14, 16, 20)];
1987
- /** cloudy → rain: the lines are drawn one after the other. */
1988
- const HEAVY_RAIN_DRAWN = [[0.45, 0.8], [0.55, 0.9], [0.65, 1]]; // once the bottom has opened
1989
- /** Lucide "cloud-snow"'s flake centres for the pieces 11, 12, 14 (top flakes) and 15, 16, 17 (bottom flakes). */
1990
- const SNOWFLAKE_CENTERS = [[8, 15], [12, 17], [16, 15], [8, 19], [12, 21], [16, 19]];
1991
- /**
1992
- * Lucide draws a flake as a dot (a tiny round-capped line, as wide as the line). Ours are a little bigger so they
1993
- * show at small sizes (user's request): a tiny closed octagon of this radius, which the thick line fills in.
1994
- */
1995
- const SNOWFLAKE_RADIUS = 0.25;
1996
- const SNOWFLAKES = SNOWFLAKE_CENTERS.map(([centerX, centerY]) => {
1997
- // clockwise from the top corner
1998
- const corners = Array.from({ length: 8 }, (_, index) => [
1999
- centerX + SNOWFLAKE_RADIUS * Math.cos(((index - 2) * Math.PI) / 4),
2000
- centerY + SNOWFLAKE_RADIUS * Math.sin(((index - 2) * Math.PI) / 4),
2001
- ]);
2002
- return corners.map((corner, index) => lucideLine(...corner, ...corners[(index + 1) % 8]));
2003
- });
2004
- // line → flake (rain / showers → snow): the line traced down and back up, its down pass ↔ the flake's right half, its
2005
- // up pass ↔ the left half — the line narrows into the flake (segment by segment the octagon's corners zigzag)
2006
- const LINE_OUT_AND_BACK = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((index) => +(index / 6).toFixed(4));
2007
- const FLAKE_IN_HALVES = { breakpoints: [0, 0.5, 1, 2, 3, 3.5, 4, 4.5, 5, 6, 7, 7.5, 8] };
2008
- const outAndBack = (stroke) => [stroke, reversedStroke(stroke)];
2009
- /** → snow / drizzle: when each flake / short line appears (row by row: 8,15 · 16,15 · 12,17 · 8,19 · 16,19 · 12,21). */
2010
- const SNOWFLAKE_STARTS = [0.45, 0.59, 0.52, 0.66, 0.8, 0.73];
2011
- const fallingDrawn = (flakeIndex) => ({ draw: [SNOWFLAKE_STARTS[flakeIndex], SNOWFLAKE_STARTS[flakeIndex] + 0.1] });
2012
- /** Lucide "cloud-drizzle"'s short lines (top down), in the order of the flakes (each one where a flake is). */
2013
- const DRIZZLE_LINES = [[8, 14], [12, 16], [16, 14], [8, 19], [12, 21], [16, 19]]
2014
- .map(([x, y]) => lucideLine(x, y, x, y + 1));
2015
- /** Pair layouts of several groups put together (per state). */
2016
- const mergePairLayouts = (...groups) => {
2017
- const merged = {};
2018
- for (const group of groups) {
2019
- for (const [state, byOtherState] of Object.entries(group))
2020
- merged[state] = { ...merged[state], ...byOtherState };
2021
- }
2022
- return merged;
2023
- };
2024
- /** The drizzle pairs of precipitation piece `fallingIndex` (0…5) — showers' own lines (0, 1) just shorten. */
2025
- const drizzlePairLayouts = (fallingIndex, hasShowersLine) => ({
2026
- ...Object.fromEntries(['cloudy', 'partly-cloudy', 'thunder', ...(hasShowersLine ? [] : ['showers'])]
2027
- .map((state) => [state, { drizzle: fallingDrawn(fallingIndex) }])),
2028
- sunny: { drizzle: RIDING_IN },
2029
- snow: { drizzle: FLAKE_IN_HALVES },
2030
- drizzle: { snow: { tracing: 'drizzle-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
2031
- });
2032
- /** sunny → rain / snow: the precipitation rides in with the crawling cloud. */
2033
- const RIDING_IN = { wipe: 'right', slide: true };
2034
- /** Lucide "cloud-sun-rain"'s rain lines, each from the top down. */
2035
- const RAIN_LINES = [lucideLine(7, 19, 7, 21), lucideLine(11, 20, 11, 22)];
2036
- /** partly-cloudy → showers: the rain lines are drawn one after the other while the cloud opens. */
2037
- const RAIN_DRAWN = [[0.35, 0.75], [0.55, 0.95]];
2038
- /** Lucide "cloud-sun"'s sun: the part of the circle above the cloud, clockwise from its left end. */
2039
- const SUN_ARC = [lucideArc(12.0004, 11.9985, 4, -119.6437, 9.3742)];
2040
- /** The middle of the sun's hidden part (behind the cloud), from where it pulls back into both ends of SUN_ARC. */
2041
- const HIDDEN_MIDDLE = (9.3742 + 240.3563) / 2;
2042
- /** Lucide "sun"'s rays, each from its outer end inwards (erasing keeps the outer end: the ray flies out). */
2043
- const RAYS = [
2044
- lucideLine(12, 2, 12, 4),
2045
- lucideLine(19.07, 4.93, 17.66, 6.34),
2046
- lucideLine(22, 12, 20, 12),
2047
- lucideLine(19.07, 19.07, 17.66, 17.66),
2048
- lucideLine(12, 22, 12, 20),
2049
- lucideLine(4.93, 19.07, 6.34, 17.66),
2050
- lucideLine(2, 12, 4, 12),
2051
- lucideLine(4.93, 4.93, 6.34, 6.34),
2052
- ];
2053
- /** A missing piece at rest: a single point (never drawn), here on the cloud's bottom line. */
2054
- const pointAt = (x, y) => [lucideLine(x, y, x, y)];
2055
- const HIDDEN_RAY = pointAt(13, 19);
2056
- /** partly-cloudy's and showers' missing rays (bottom right, bottom, bottom left, left). */
2057
- const HIDDEN_RAY_PARTLY = pointAt(10, 22);
2058
- const MISSING_RAYS_PARTLY = [3, 4, 5, 6];
2059
- // sunny ↔ partly-cloudy, the sun's circle: traced from the middle of the hidden part, 4 segments to the arc's left
2060
- // end, 4 along the arc (it stays), 4 back to the middle; the arc gets 4 zero-length segments at each end.
2061
- const ARC_SHARE = (9.3742 + 119.6437) / 360;
2062
- const CIRCLE_FOR_SUN_ARC = {
2063
- tracing: 'circle-from-hidden-middle',
2064
- breakpoints: [
2065
- ...[0, 1, 2, 3].map((index) => (index / 4) * ((1 - ARC_SHARE) / 2)),
2066
- ...[0, 1, 2, 3].map((index) => (1 - ARC_SHARE) / 2 + (index / 4) * ARC_SHARE),
2067
- ...[0, 1, 2, 3, 4].map((index) => (1 + ARC_SHARE) / 2 + (index / 4) * ((1 - ARC_SHARE) / 2)),
2068
- ].map((breakpoint) => +breakpoint.toFixed(4)),
2069
- };
2070
- const SUN_ARC_FOR_CIRCLE = { breakpoints: [0, 0, 0, 0, 0, 0.25, 0.5, 0.75, 1, 1, 1, 1, 1] };
2071
- // cloudy → partly-cloudy: the sun is drawn like a pen from the arc's lower right end up over the top (the user's
2072
- // direction), and the kept rays follow one after another, each growing out from the sun when the pen passes it.
2073
- const SUN_ARC_DRAWN = [0.45, 0.85]; // after the cloud has shrunk away from the arc's start
2074
- const RAY_DRAW_DURATION = 0.15;
2075
- /** Kept rays → how far along the arc (from its lower right end, 0…1) the pen is when the ray starts. */
2076
- const KEPT_RAY_PEN_POSITIONS = { 2: 0.07, 1: 0.42, 0: 0.77, 7: 1 };
2077
- const rayDrawnAfterPen = (penPosition) => {
2078
- const start = SUN_ARC_DRAWN[0] + penPosition * (SUN_ARC_DRAWN[1] - SUN_ARC_DRAWN[0]);
2079
- return [+start.toFixed(4), +Math.min(start + RAY_DRAW_DURATION, 1).toFixed(4)];
2080
- };
2081
- /** States drawn with rain's cloud: apart from the precipitation they morph exactly like rain. */
2082
- const RAIN_TWINS = ['snow', 'drizzle'];
2083
- /** A piece that is the same in rain and its twins: the twins get rain's strokes and pair layouts. */
2084
- const withRainTwins = (pieceSet) => {
2085
- const pairLayouts = Object.fromEntries(Object.entries(pieceSet.pairLayouts).map(([state, layouts]) => [state, { ...layouts, ...(layouts.rain ? Object.fromEntries(RAIN_TWINS.map((twin) => [twin, layouts.rain])) : {}) }]));
2086
- for (const twin of RAIN_TWINS)
2087
- if (pieceSet.pairLayouts.rain)
2088
- pairLayouts[twin] = pieceSet.pairLayouts.rain;
2089
- return {
2090
- ...pieceSet,
2091
- strokes: { ...pieceSet.strokes, ...Object.fromEntries(RAIN_TWINS.map((twin) => [twin, pieceSet.strokes.rain])) },
2092
- pairLayouts,
2093
- };
2094
- };
2095
- const sunCirclePiece = {
2096
- strokes: { sunny: SUN_CIRCLE, cloudy: CLOUD, 'partly-cloudy': SUN_ARC, showers: SUN_ARC, thunder: THUNDER_CLOUD, rain: RAIN_CLOUD },
2097
- alternativeStrokes: {
2098
- 'cloud-from-rain-gap-middle': CLOUD_FROM_RAIN_GAP_MIDDLE,
2099
- 'cloud-from-thunder-gap-middle': CLOUD_FROM_THUNDER_GAP_MIDDLE,
2100
- 'circle-from-hidden-middle': [lucideArc(12, 12, 4, HIDDEN_MIDDLE, HIDDEN_MIDDLE + 360)],
2101
- 'circle-from-left': CIRCLE_FROM_LEFT,
2102
- },
2103
- pairLayouts: {
2104
- sunny: {
2105
- cloudy: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
2106
- thunder: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
2107
- rain: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
2108
- 'partly-cloudy': CIRCLE_FOR_SUN_ARC,
2109
- showers: CIRCLE_FOR_SUN_ARC,
2110
- },
2111
- cloudy: {
2112
- sunny: { wipe: 'left' },
2113
- 'partly-cloudy': { draw: SUN_ARC_DRAWN },
2114
- showers: { draw: SUN_ARC_DRAWN },
2115
- thunder: CLOUD_FOR_THUNDER,
2116
- rain: CLOUD_FOR_RAIN,
2117
- },
2118
- rain: {
2119
- cloudy: OPEN_FOR_CLOSED,
2120
- sunny: { wipe: 'left' },
2121
- 'partly-cloudy': { draw: SUN_ARC_DRAWN },
2122
- showers: { draw: SUN_ARC_DRAWN },
2123
- },
2124
- 'partly-cloudy': { sunny: SUN_ARC_FOR_CIRCLE, cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
2125
- showers: { sunny: SUN_ARC_FOR_CIRCLE, cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
2126
- // the sun goes like → cloudy: crawled over from sunny, erased back down from partly-cloudy / showers
2127
- thunder: { cloudy: OPEN_FOR_CLOSED, sunny: { wipe: 'left' }, 'partly-cloudy': { draw: SUN_ARC_DRAWN }, showers: { draw: SUN_ARC_DRAWN } },
2128
- },
2129
- };
2130
- const rayPiece = (ray, rayIndex) => {
2131
- const missingInPartly = MISSING_RAYS_PARTLY.includes(rayIndex);
2132
- return {
2133
- strokes: {
2134
- sunny: [ray],
2135
- cloudy: HIDDEN_RAY,
2136
- 'partly-cloudy': missingInPartly ? HIDDEN_RAY_PARTLY : [ray],
2137
- showers: missingInPartly ? HIDDEN_RAY_PARTLY : [ray],
2138
- thunder: HIDDEN_RAY,
2139
- rain: HIDDEN_RAY,
2140
- },
2141
- alternativeStrokes: {},
2142
- pairLayouts: {
2143
- cloudy: { sunny: { wipe: 'left' } },
2144
- thunder: { sunny: { wipe: 'left' } },
2145
- rain: { sunny: { wipe: 'left' } },
2146
- ...(missingInPartly
2147
- ? { 'partly-cloudy': { sunny: { draw: 0.6 } }, showers: { sunny: { draw: 0.6 } } }
2148
- // drawn from the sun outwards (the rays run outer end → inner end, so reversed)
2149
- : {
2150
- cloudy: {
2151
- sunny: { wipe: 'left' },
2152
- 'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2153
- showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2154
- },
2155
- thunder: {
2156
- sunny: { wipe: 'left' },
2157
- 'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2158
- showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2159
- },
2160
- rain: {
2161
- sunny: { wipe: 'left' },
2162
- 'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2163
- showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
2164
- },
2165
- 'partly-cloudy': { cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
2166
- showers: { cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
2167
- }),
2168
- },
2169
- };
2170
- };
2171
- /** Showers' rain line (if it has one) | rain's line: pieces 11, 12 (both) and 14 (rain only). */
2172
- const rainPiece = (heavyRainLine, rainIndex) => {
2173
- const showersLine = RAIN_LINES[rainIndex];
2174
- const top = (showersLine ?? heavyRainLine).from;
2175
- const nothing = [line(...top, ...top)];
2176
- const carriesCloudEnd = rainIndex === 0;
2177
- return {
2178
- strokes: {
2179
- sunny: nothing,
2180
- cloudy: nothing,
2181
- 'partly-cloudy': nothing,
2182
- showers: showersLine ? [showersLine] : nothing,
2183
- thunder: nothing,
2184
- rain: [heavyRainLine],
2185
- snow: SNOWFLAKES[rainIndex],
2186
- drizzle: [DRIZZLE_LINES[rainIndex]],
2187
- },
2188
- alternativeStrokes: {
2189
- ...(carriesCloudEnd ? { 'thunder-cloud-left-end': THUNDER_CLOUD_LEFT_END } : {}),
2190
- 'rain-line-out-and-back': outAndBack(heavyRainLine),
2191
- 'drizzle-line-out-and-back': outAndBack(DRIZZLE_LINES[rainIndex]),
2192
- ...(showersLine ? { 'showers-line-out-and-back': outAndBack(showersLine) } : {}),
2193
- },
2194
- pairLayouts: mergePairLayouts(drizzlePairLayouts(rainIndex, Boolean(showersLine)), showersLine
2195
- ? {
2196
- cloudy: { showers: { draw: RAIN_DRAWN[rainIndex] }, rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] }, snow: fallingDrawn(rainIndex) },
2197
- 'partly-cloudy': {
2198
- showers: { draw: RAIN_DRAWN[rainIndex] },
2199
- rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] },
2200
- snow: fallingDrawn(rainIndex),
2201
- },
2202
- // sunny → showers / rain: the rain rides in with the crawling cloud; sunny → thunder: it carries the cloud's
2203
- // left end
2204
- sunny: {
2205
- showers: { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
2206
- rain: RIDING_IN,
2207
- snow: RIDING_IN,
2208
- ...(carriesCloudEnd ? { thunder: RIDING_IN } : {}),
2209
- },
2210
- // showers → thunder: erased back up; thunder → rain: drawn down
2211
- thunder: {
2212
- showers: { draw: RAIN_DRAWN[rainIndex] },
2213
- rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] },
2214
- snow: fallingDrawn(rainIndex),
2215
- ...(carriesCloudEnd ? { sunny: tracedForWipe('thunder-cloud-left-end', THUNDER_CLOUD_LEFT_END) } : {}),
2216
- },
2217
- rain: { snow: { tracing: 'rain-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
2218
- showers: { snow: { tracing: 'showers-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
2219
- snow: { rain: FLAKE_IN_HALVES, showers: FLAKE_IN_HALVES },
2220
- }
2221
- : {
2222
- ...Object.fromEntries(['cloudy', 'partly-cloudy', 'showers', 'thunder']
2223
- .map((state) => [state, { rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] }, snow: fallingDrawn(rainIndex) }])),
2224
- sunny: { rain: RIDING_IN, snow: RIDING_IN },
2225
- rain: { snow: { tracing: 'rain-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
2226
- snow: { rain: FLAKE_IN_HALVES },
2227
- }),
2228
- };
2229
- };
2230
- /** Snow's bottom flakes / drizzle's bottom lines (pieces 15–17): in rain hidden at the bottom of its line, so
2231
- * rain → snow / drizzle pulls them out of it. */
2232
- const bottomFlakePiece = (flakeIndex) => {
2233
- const nothing = pointAt(...SNOWFLAKE_CENTERS[flakeIndex]);
2234
- const [lineBottomX, lineBottomY] = HEAVY_RAIN_LINES[flakeIndex - 3].to;
2235
- return {
2236
- strokes: {
2237
- sunny: nothing,
2238
- cloudy: nothing,
2239
- 'partly-cloudy': nothing,
2240
- showers: nothing,
2241
- thunder: nothing,
2242
- rain: [line(lineBottomX, lineBottomY, lineBottomX, lineBottomY)],
2243
- snow: SNOWFLAKES[flakeIndex],
2244
- drizzle: [DRIZZLE_LINES[flakeIndex]],
2245
- },
2246
- alternativeStrokes: { 'drizzle-line-out-and-back': outAndBack(DRIZZLE_LINES[flakeIndex]) },
2247
- pairLayouts: mergePairLayouts(drizzlePairLayouts(flakeIndex, false), {
2248
- ...Object.fromEntries(['cloudy', 'partly-cloudy', 'showers', 'thunder'].map((state) => [state, { snow: fallingDrawn(flakeIndex) }])),
2249
- sunny: { snow: RIDING_IN },
2250
- }),
2251
- };
2252
- };
2253
- const boltTop = pointAt(13, 12);
2254
- const boltPiece = {
2255
- strokes: { sunny: boltTop, cloudy: boltTop, 'partly-cloudy': boltTop, showers: boltTop, thunder: BOLT, rain: boltTop },
2256
- // (snow: like rain, withRainTwins)
2257
- alternativeStrokes: { 'rain-cloud-left-end': RAIN_CLOUD_LEFT_END },
2258
- pairLayouts: {
2259
- cloudy: { thunder: { draw: BOLT_DRAWN } },
2260
- 'partly-cloudy': { thunder: { draw: BOLT_DRAWN } },
2261
- showers: { thunder: { draw: BOLT_DRAWN } },
2262
- // thunder → rain: pulled back up into the cloud
2263
- rain: { thunder: { draw: BOLT_DRAWN }, sunny: tracedForWipe('rain-cloud-left-end', RAIN_CLOUD_LEFT_END) },
2264
- // rides in with the crawling cloud; sunny → rain: carries the rain cloud's left end
2265
- sunny: { thunder: { wipe: 'right', slide: true }, rain: { wipe: 'right', slide: true } },
2266
- },
2267
- };
2268
- const rainTwinnedIcons = withRainTwins({
2269
- segmentCount: 12,
2270
- strokeWidth: LUCIDE_STROKE_WIDTH,
2271
- lineCap: 'round',
2272
- lineJoin: 'round',
2273
- strokes: {
2274
- sunny: SUN_CIRCLE,
2275
- cloudy: CLOUD,
2276
- 'partly-cloudy': SMALL_CLOUD,
2277
- showers: OPEN_SMALL_CLOUD,
2278
- thunder: THUNDER_CLOUD,
2279
- rain: RAIN_CLOUD,
2280
- },
2281
- alternativeStrokes: {
2282
- 'cloud-from-rain-gap-middle': CLOUD_FROM_RAIN_GAP_MIDDLE,
2283
- 'cloud-from-thunder-gap-middle': CLOUD_FROM_THUNDER_GAP_MIDDLE,
2284
- 'thunder-cloud-from-left': THUNDER_CLOUD_FROM_LEFT,
2285
- 'rain-cloud-from-left': RAIN_CLOUD_FROM_LEFT,
2286
- 'cloud-from-left': CLOUD_FROM_LEFT,
2287
- 'small-cloud-from-left': SMALL_CLOUD_FROM_LEFT,
2288
- 'cloud-from-gap-middle': [
2289
- lucideLine(BIG_GAP_MIDDLE_X, 19, 9, 19),
2290
- ...CLOUD.slice(1),
2291
- lucideLine(17.5, 19, BIG_GAP_MIDDLE_X, 19),
2292
- ],
2293
- 'small-cloud-from-gap-middle': [
2294
- lucideLine(GAP_MIDDLE_X, 22, 7, 22),
2295
- ...SMALL_CLOUD.slice(1),
2296
- lucideLine(13, 22, GAP_MIDDLE_X, 22),
2297
- ],
2298
- },
2299
- pairLayouts: {
2300
- // every cloud crawls in from the left over the sun
2301
- sunny: {
2302
- cloudy: { wipe: 'right', slide: true },
2303
- 'partly-cloudy': { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
2304
- showers: { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
2305
- thunder: { wipe: 'right', slide: true },
2306
- rain: { wipe: 'right', slide: true },
2307
- },
2308
- cloudy: { sunny: tracedForWipe('cloud-from-left', CLOUD_FROM_LEFT), showers: BIG_CLOSED_FOR_OPEN, thunder: CLOUD_FOR_THUNDER, rain: CLOUD_FOR_RAIN },
2309
- 'partly-cloudy': {
2310
- sunny: tracedForWipe('small-cloud-from-left', SMALL_CLOUD_FROM_LEFT),
2311
- showers: CLOSED_FOR_OPEN,
2312
- thunder: CLOSED_FOR_OPEN,
2313
- rain: CLOSED_FOR_OPEN,
2314
- },
2315
- showers: { 'partly-cloudy': OPEN_FOR_CLOSED, cloudy: OPEN_FOR_CLOSED },
2316
- rain: {
2317
- cloudy: OPEN_FOR_CLOSED,
2318
- 'partly-cloudy': OPEN_FOR_CLOSED,
2319
- sunny: tracedForWipe('rain-cloud-from-left', RAIN_CLOUD_FROM_LEFT),
2320
- },
2321
- thunder: {
2322
- cloudy: OPEN_FOR_CLOSED,
2323
- 'partly-cloudy': OPEN_FOR_CLOSED,
2324
- sunny: tracedForWipe('thunder-cloud-from-left', THUNDER_CLOUD_FROM_LEFT),
2325
- },
2326
- },
2327
- morePieces: [
2328
- ...[sunCirclePiece, ...RAYS.map(rayPiece)].map(withRainTwins),
2329
- ...HEAVY_RAIN_LINES.slice(0, 2).map(rainPiece),
2330
- withRainTwins(boltPiece),
2331
- rainPiece(HEAVY_RAIN_LINES[2], 2),
2332
- ...[3, 4, 5].map(bottomFlakePiece),
2333
- ],
2334
- spins: {},
2335
- });
2336
- // ---- frosty (Lucide "sun-snow"): half a small sun on the left, half a snowflake on the right ----
2337
- // sunny ↔ frosty: the sun shrinks to its left half (its right half pulls back into the half's ends) while a copy of
2338
- // that right half (the bolt piece) straightens into the snowflake's spine; the 3 right rays bend into its branches,
2339
- // the other 5 move to frosty's shorter rays.
2340
- // frosty → any cloud state (the set's rule): the cloud crawls in from the left and covers everything (the rain etc.
2341
- // rides along). A wipe needs every piece to show one stretch, so during the crawl the half sun's upper quarter and
2342
- // the spine (4 x-monotone parts) are carried by 6 spare pieces (a single point at rest, in every state); the 6th
2343
- // carries the open clouds' lower left end. frosty ↔ partly-cloudy / showers: the half sun becomes their sun arc,
2344
- // the kept rays move, the rest is covered.
2345
- /** frosty's half sun, clockwise from its bottom (Lucide: centre 10,12, radius 3). */
2346
- const HALF_SUN = [lucideArc(10, 12, 3, 90, 270)];
2347
- /** For the crawl: from its leftmost point down / up (each one stretch for the wipe). */
2348
- const HALF_SUN_LOWER = [lucideArc(10, 12, 3, 180, 90)];
2349
- const HALF_SUN_UPPER = [lucideArc(10, 12, 3, 180, 270)];
2350
- /** frosty's rays by ray piece (outer end first), the right three bent into the snowflake's branches. */
2351
- const FROSTY_RAYS = [
2352
- [lucideLine(10, 3, 10, 4)],
2353
- [lucideLine(18, 6, 15.25, 6.5), lucideLine(15.25, 6.5, 14, 4)],
2354
- [lucideLine(20, 10, 18.5, 12), lucideLine(18.5, 12, 20, 14)],
2355
- [lucideLine(18, 18, 15.25, 17.5), lucideLine(15.25, 17.5, 14, 20)],
2356
- [lucideLine(10, 21, 10, 20)],
2357
- [lucideLine(3.64, 18.36, 4.34, 17.66)],
2358
- [lucideLine(2, 12, 3, 12)],
2359
- [lucideLine(3.64, 5.64, 4.34, 6.34)],
2360
- ];
2361
- /** The snowflake's spine as one line: upper arm, out along the middle arm and back, lower arm. */
2362
- const SPINE = [
2363
- lucideLine(17, 3, 14, 9), lucideLine(14, 9, 15.5, 12), lucideLine(15.5, 12, 22, 12),
2364
- lucideLine(22, 12, 15.5, 12), lucideLine(15.5, 12, 14, 15), lucideLine(14, 15, 17, 21),
2365
- ];
2366
- /** The spine in parts that each run one way across (for the crawl). */
2367
- const SPINE_PARTS = [
2368
- [lucideLine(17, 3, 14, 9)],
2369
- [lucideLine(14, 9, 15.5, 12), lucideLine(15.5, 12, 22, 12)],
2370
- [lucideLine(14, 15, 15.5, 12)],
2371
- [lucideLine(14, 15, 17, 21)],
2372
- ];
2373
- // sunny ↔ frosty: the circle traced from its right point — 3 segments of its lower right quarter pull back into the half
2374
- // sun's bottom end, 6 along the left half, 3 of the upper right quarter into its top end
2375
- const CIRCLE_FOR_HALF_SUN = {
2376
- tracing: 'circle-from-right',
2377
- breakpoints: Array.from({ length: 13 }, (_, index) => +(index / 12).toFixed(4)),
2378
- };
2379
- const HALF_SUN_FOR_CIRCLE = { breakpoints: [0, 0, 0, 0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1, 1, 1] };
2380
- /** The circle's right half (top → right → bottom): the bolt piece's start in sunny → frosty (under the circle). */
2381
- const CIRCLE_RIGHT_HALF = [lucideArc(12, 12, 4, -90, 90)];
2382
- const CLOUD_STATES = ['cloudy', 'partly-cloudy', 'showers', 'thunder', 'rain', 'snow', 'drizzle'];
2383
- /** Cloud states that keep a sun (its arc and 4 rays). */
2384
- const SUN_CLOUD_STATES = ['partly-cloudy', 'showers'];
2385
- const isNothing = (strokes) => strokes.length === 1 && strokes[0].kind === 'line' && strokes[0].from.every((value, axis) => value === strokes[0].to[axis]);
2386
- const POINT_LAYOUT = { tracing: 'nothing', breakpoints: Array.from({ length: 13 }, (_, index) => +(index / 12).toFixed(4)) };
2387
- /** The open clouds' lower left ends, carried by a spare piece while they crawl in. */
2388
- const CLOUD_LEFT_ENDS = {
2389
- thunder: THUNDER_CLOUD_LEFT_END, rain: RAIN_CLOUD_LEFT_END, snow: RAIN_CLOUD_LEFT_END, drizzle: RAIN_CLOUD_LEFT_END,
2390
- };
2391
- const COVERED = { wipe: 'left' };
2392
- /** `pieceSet` with frosty's strokes and `layouts[state][otherState]` added. */
2393
- const addFrosty = (pieceSet, strokes, layouts, alternativeStrokes = {}) => ({
2394
- strokes: { ...pieceSet.strokes, frosty: strokes },
2395
- alternativeStrokes: { ...pieceSet.alternativeStrokes, ...alternativeStrokes },
2396
- pairLayouts: mergePairLayouts(pieceSet.pairLayouts, layouts),
2397
- });
2398
- /** Layouts for the given cloud states X: `frosty.X` and `X.frosty` (either may be left out). */
2399
- const forCloudStates = (states, frostySide, otherSide) => {
2400
- const layouts = { frosty: {} };
2401
- for (const state of states) {
2402
- const frostyLayout = frostySide?.(state), otherLayout = otherSide?.(state);
2403
- if (frostyLayout)
2404
- layouts.frosty[state] = frostyLayout;
2405
- if (otherLayout)
2406
- layouts[state] = { frosty: otherLayout };
2407
- }
2408
- return layouts;
2409
- };
2410
- /** Erasing runs a window backwards: [0, 0.6] erases during 40 … 100 %, with SMALL_CLOUD_CRAWL. */
2411
- const COVERED_BEFORE_CRAWL_ENDS = [1 - SMALL_CLOUD_CRAWL[1], 1 - SMALL_CLOUD_CRAWL[0]];
2412
- // frosty ↔ partly-cloudy / showers: the spine's centre sits where the sun arc's right end is — the spine traced
2413
- // backwards (lower arm, middle arm out and back, upper arm) shrinks into that end with its first 6 segments while
2414
- // its upper arm unrolls from there up over the top into the arc with the last 6
2415
- 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] };
2416
- 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] };
2417
- /** Covered by the crawling cloud — for partly-cloudy / showers in step with their later crawl. */
2418
- const coveredIn = (state) => (SUN_CLOUD_STATES.includes(state) ? { wipe: 'left', draw: COVERED_BEFORE_CRAWL_ENDS } : { wipe: 'left' });
2419
- const withFrosty = (iconSet) => {
2420
- const [cloudPiece, sunPiece, ...otherPieces] = piecesOf(iconSet);
2421
- const rayPieces = otherPieces.slice(0, 8);
2422
- const [rain1, rain2, bolt, rain3, ...bottomFlakes] = otherPieces.slice(8);
2423
- const nothing = pointAt(12, 12);
2424
- const sparePiece = (layouts, alternativeStrokes) => ({
2425
- strokes: Object.fromEntries([...Object.keys(iconSet.strokes), 'frosty'].map((state) => [state, nothing])),
2426
- alternativeStrokes,
2427
- pairLayouts: layouts,
2428
- });
2429
- const coveredStates = CLOUD_STATES.filter((state) => !SUN_CLOUD_STATES.includes(state));
2430
- const circleFromRight = [lucideArc(12, 12, 4, 0, 360)];
2431
- const sunnyToFrosty = { sunny: { frosty: CIRCLE_FOR_HALF_SUN }, frosty: { sunny: HALF_SUN_FOR_CIRCLE } };
2432
- const cloud = {
2433
- ...iconSet,
2434
- ...addFrosty(cloudPiece, HALF_SUN, mergePairLayouts(
2435
- // sunny ↔ frosty: a copy of the sun (under piece 2), morphing like it
2436
- sunnyToFrosty,
2437
- // → any cloud: it crawls in exactly as from sunny
2438
- forCloudStates(CLOUD_STATES, (state) => iconSet.pairLayouts.sunny[state], (state) => iconSet.pairLayouts[state]?.sunny)), { 'circle-from-right': circleFromRight }),
2439
- };
2440
- const sun = addFrosty(sunPiece, HALF_SUN, mergePairLayouts(sunnyToFrosty,
2441
- // covered by the crawling cloud (its upper quarter on a spare piece)
2442
- forCloudStates(coveredStates, () => tracedForWipe('half-sun-lower', HALF_SUN_LOWER), coveredIn),
2443
- // partly-cloudy / showers: laid out as the spine (the spares carry the half sun), which bends into the sun arc
2444
- forCloudStates(SUN_CLOUD_STATES, () => SPINE_FOR_SUN_ARC, () => SUN_ARC_FOR_SPINE)), {
2445
- 'circle-from-right': circleFromRight,
2446
- 'half-sun-lower': HALF_SUN_LOWER,
2447
- 'spine-reversed': SPINE.map(reversedStroke).reverse(),
2448
- 'sun-arc-reversed': SUN_ARC.map(reversedStroke).reverse(),
2449
- });
2450
- const rays = rayPieces.map((rayPiece, rayIndex) => addFrosty(rayPiece, FROSTY_RAYS[rayIndex],
2451
- // where the cloud state has no such ray: covered, or (partly-cloudy / showers) flying out as from sunny;
2452
- // kept rays move
2453
- forCloudStates(CLOUD_STATES.filter((state) => isNothing(rayPiece.strokes[state])), undefined, (state) => (SUN_CLOUD_STATES.includes(state) ? { draw: 0.6 } : COVERED))));
2454
- // precipitation: rides in with the cloud, as from sunny
2455
- const precipitation = (pieceSet) => addFrosty(pieceSet, pieceSet.strokes.sunny, forCloudStates(CLOUD_STATES.filter((state) => !isNothing(pieceSet.strokes[state])), (state) => pieceSet.pairLayouts.sunny?.[state] ?? RIDING_IN));
2456
- const boltWithSpine = addFrosty(bolt, SPINE, mergePairLayouts({ sunny: { frosty: tracedForWipe('circle-right-half', CIRCLE_RIGHT_HALF) } },
2457
- // during the crawl the spares (or the sun piece) carry the spine; thunder's bolt rides in
2458
- forCloudStates(CLOUD_STATES, (state) => (state === 'thunder' ? RIDING_IN : POINT_LAYOUT))), { 'circle-right-half': CIRCLE_RIGHT_HALF, nothing: [lucideLine(12, 12, 12, 12)] });
2459
- const spares = [
2460
- // the spine's parts (covered); the first also carries the half sun's lower quarter for partly-cloudy / showers
2461
- ...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 })),
2462
- // the half sun's upper quarter
2463
- sparePiece(forCloudStates(CLOUD_STATES, () => tracedForWipe('half-sun-upper', HALF_SUN_UPPER), coveredIn), { 'half-sun-upper': HALF_SUN_UPPER }),
2464
- // the open clouds' lower left end
2465
- 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]))),
2466
- ];
2467
- return {
2468
- ...cloud,
2469
- morePieces: [
2470
- sun, ...rays, precipitation(rain1), precipitation(rain2), boltWithSpine, precipitation(rain3),
2471
- ...bottomFlakes.map(precipitation), ...spares,
2472
- ],
2473
- };
2474
- };
2475
- const weatherIcons = withFrosty(rainTwinnedIcons);
2476
- return { weatherIcons };
2477
- })();
2478
-
2479
- // sets/eye.ts
2480
- const { eyeIcons } = (() => {
2481
- // 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)
2482
- const topLid = (startAngle, endAngle) => lucideArc(12, 15.7506, 10.75, startAngle, endAngle);
2483
- const bottomLid = (startAngle, endAngle) => lucideArc(12, 8.2494, 10.75, startAngle, endAngle);
2484
- const RIGHT_CORNER = lucideArc(21.0005, 12, 1, -20.365, 20.365);
2485
- const LEFT_CORNER = lucideArc(2.9995, 12, 1, 159.635, 200.365);
2486
- /** 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°). */
2487
- const TOP_GAP_MIDDLE = (-120.724 - 96.796) / 2;
2488
- const BOTTOM_GAP_MIDDLE = (37.8063 + 59.3592) / 2;
2489
- // each part: lid, corner, lid — 6 + 2 + 4 segments in both states, so the corner stays and the lids only shorten
2490
- 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] };
2491
- const rightPart = {
2492
- strokes: {
2493
- visible: [topLid(TOP_GAP_MIDDLE, -22.4122), RIGHT_CORNER, bottomLid(22.4122, BOTTOM_GAP_MIDDLE)],
2494
- hidden: [topLid(-96.796, -22.4122), RIGHT_CORNER, bottomLid(22.4122, 37.8063)],
2495
- },
2496
- alternativeStrokes: {},
2497
- pairLayouts: { visible: { hidden: PART_LAYOUT }, hidden: { visible: PART_LAYOUT } },
2498
- };
2499
- const leftPart = {
2500
- strokes: {
2501
- visible: [bottomLid(BOTTOM_GAP_MIDDLE, 157.5878), LEFT_CORNER, topLid(-157.5878, TOP_GAP_MIDDLE)],
2502
- hidden: [bottomLid(59.3592, 157.5878), LEFT_CORNER, topLid(-157.5878, -120.724)],
2503
- },
2504
- alternativeStrokes: {},
2505
- pairLayouts: { visible: { hidden: PART_LAYOUT }, hidden: { visible: PART_LAYOUT } },
2506
- };
2507
- /** eye-off's pupil: the half circle clockwise from 46° (lower right) to 224° (upper left). */
2508
- const HALF_PUPIL = [lucideArc(12, 12, 3, 45.9957, 224.0043)];
2509
- /** The middle of the pupil's gap (upper right), from where the gap pulls back into the half's ends. */
2510
- const PUPIL_GAP_MIDDLE = (224.0043 + 405.9957) / 2;
2511
- const GAP_HALF_SHARE = (405.9957 - 224.0043) / 2 / 360;
2512
- const pupil = {
2513
- strokes: { visible: [lucideArc(12, 12, 3, -90, 270)], hidden: HALF_PUPIL },
2514
- alternativeStrokes: { 'pupil-from-gap-middle': [lucideArc(12, 12, 3, PUPIL_GAP_MIDDLE, PUPIL_GAP_MIDDLE + 360)] },
2515
- pairLayouts: {
2516
- // 3 segments per gap half, 6 along the half circle (it stays)
2517
- visible: {
2518
- hidden: {
2519
- tracing: 'pupil-from-gap-middle',
2520
- breakpoints: [
2521
- ...[0, 1, 2].map((index) => (index / 3) * GAP_HALF_SHARE),
2522
- ...[0, 1, 2, 3, 4, 5].map((index) => GAP_HALF_SHARE + (index / 6) * (1 - 2 * GAP_HALF_SHARE)),
2523
- ...[0, 1, 2, 3].map((index) => 1 - GAP_HALF_SHARE + (index / 3) * GAP_HALF_SHARE),
2524
- ].map((breakpoint) => +breakpoint.toFixed(4)),
2525
- },
2526
- },
2527
- hidden: { visible: { breakpoints: [0, 0, 0, 0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1, 1, 1] } },
2528
- },
2529
- };
2530
- const slash = {
2531
- strokes: { visible: lucidePoint(2, 2), hidden: LUCIDE_SLASH },
2532
- alternativeStrokes: {},
2533
- pairLayouts: { visible: { hidden: { draw: true } } },
2534
- };
2535
- const eyeIcons = {
2536
- segmentCount: 12,
2537
- strokeWidth: LUCIDE_STROKE_WIDTH,
2538
- lineCap: 'round',
2539
- lineJoin: 'round',
2540
- ...rightPart,
2541
- morePieces: [leftPart, pupil, slash],
2542
- spins: {},
2543
- };
2544
- return { eyeIcons };
2545
- })();
2546
-
2547
- // sets/volume.ts
2548
- const { volumeIcons } = (() => {
2549
- /** Lucide's speaker, from the top of its front edge counterclockwise around. */
2550
- const SPEAKER = [
2551
- lucideArc(10.295, 4.703, 0.705, -0.0828, -134.9415),
2552
- lucideLine(9.797, 4.204, 6.413, 7.587),
2553
- lucideArc(5.4201, 6.6, 1.4, 44.8292, 90.168),
2554
- lucideLine(5.416, 8, 3, 8),
2555
- lucideArc(3, 9, 1, -90, -180),
2556
- lucideLine(2, 9, 2, 15),
2557
- lucideArc(3, 15, 1, 180, 90),
2558
- lucideLine(3, 16, 5.416, 16),
2559
- lucideArc(5.4201, 17.4, 1.4, -90.168, -44.8292),
2560
- lucideLine(6.413, 16.413, 9.796, 19.797),
2561
- lucideArc(10.295, 19.299, 0.705, 135.0564, -0.0797),
2562
- lucideLine(11, 19.298, 11, 4.702),
2563
- ];
2564
- /** The waves, each from its top end down. */
2565
- const SMALL_WAVE = [lucideArc(12, 12, 5, -36.8699, 36.8699)];
2566
- const BIG_WAVE = [lucideArc(13.0001, 12, 9, -45.0004, 45.0004)];
2567
- /** The ✕, each line from its top end down (so a wave unfolds into it without turning over). */
2568
- const X_SLASH = [lucideLine(21.5, 9.5, 16.5, 14.5)];
2569
- const X_BACKSLASH = [lucideLine(16.5, 9.5, 21.5, 14.5)];
2570
- /** No wave: a point on the middle of the speaker's front edge. */
2571
- const NO_WAVE = lucidePoint(11, 12);
2572
- const speaker = {
2573
- strokes: { muted: SPEAKER, low: SPEAKER, medium: SPEAKER, high: SPEAKER },
2574
- alternativeStrokes: {},
2575
- pairLayouts: {},
2576
- };
2577
- const smallWave = {
2578
- strokes: { muted: X_SLASH, low: NO_WAVE, medium: SMALL_WAVE, high: SMALL_WAVE },
2579
- alternativeStrokes: {},
2580
- pairLayouts: {},
2581
- };
2582
- const bigWave = {
2583
- strokes: { muted: X_BACKSLASH, low: NO_WAVE, medium: SMALL_WAVE, high: BIG_WAVE },
2584
- alternativeStrokes: {},
2585
- pairLayouts: {},
2586
- };
2587
- const volumeIcons = {
2588
- segmentCount: 16,
2589
- strokeWidth: LUCIDE_STROKE_WIDTH,
2590
- lineCap: 'round',
2591
- lineJoin: 'round',
2592
- ...speaker,
2593
- morePieces: [smallWave, bigWave],
2594
- spins: {},
2595
- };
2596
- return { volumeIcons };
2597
- })();
2598
-
2599
- // sets/toggle.ts
2600
- const { toggleIcons } = (() => {
2601
- const HORIZONTAL = [lucideLine(5, 12, 19, 12)];
2602
- const first = {
2603
- strokes: {
2604
- plus: HORIZONTAL,
2605
- minus: HORIZONTAL,
2606
- close: [lucideLine(6, 6, 18, 18)],
2607
- check: [lucideLine(4, 12, 9, 17)],
2608
- },
2609
- alternativeStrokes: {},
2610
- pairLayouts: {},
2611
- };
2612
- const second = {
2613
- strokes: {
2614
- plus: [lucideLine(12, 5, 12, 19)],
2615
- minus: HORIZONTAL,
2616
- close: [lucideLine(18, 6, 6, 18)],
2617
- check: [lucideLine(20, 6, 9, 17)],
2618
- },
2619
- alternativeStrokes: {},
2620
- pairLayouts: {},
2621
- };
2622
- const toggleIcons = {
2623
- segmentCount: 1,
2624
- strokeWidth: LUCIDE_STROKE_WIDTH,
2625
- lineCap: 'round',
2626
- lineJoin: 'round',
2627
- ...first,
2628
- morePieces: [second],
2629
- // plus turned by 45° clockwise is the ✕ (a little smaller): turn while blending
2630
- spins: { plus: { close: 45 }, close: { plus: -45 } },
2631
- };
2632
- return { toggleIcons };
2633
- })();
2634
-
2635
- // sets/lock.ts
2636
- const { lockIcons } = (() => {
2637
- const BODY = [
2638
- lucideLine(5, 11, 19, 11),
2639
- lucideArc(19, 13, 2, -90, 0),
2640
- lucideLine(21, 13, 21, 20),
2641
- lucideArc(19, 20, 2, 0, 90),
2642
- lucideLine(19, 22, 5, 22),
2643
- lucideArc(5, 20, 2, 90, 180),
2644
- lucideLine(3, 20, 3, 13),
2645
- lucideArc(5, 13, 2, 180, 270),
2646
- ];
2647
- const LEFT_LEG = lucideLine(7, 11, 7, 7);
2648
- const body = { strokes: { locked: BODY, unlocked: BODY }, alternativeStrokes: {}, pairLayouts: {} };
2649
- // left leg 2 segments, arch 6, right leg 4 — unlocked has no right leg: 4 zero-length segments at the arch's end
2650
- const shackle = {
2651
- strokes: {
2652
- locked: [LEFT_LEG, lucideArc(12, 7, 5, 180, 360), lucideLine(17, 7, 17, 11)],
2653
- unlocked: [LEFT_LEG, lucideArc(12, 6.995, 5, 179.9424, 348.5218)],
2654
- },
2655
- alternativeStrokes: {},
2656
- pairLayouts: {
2657
- 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] } },
2658
- unlocked: { locked: { breakpoints: [0, 0.5, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2, 2, 2] } },
2659
- },
2660
- };
2661
- const lockIcons = {
2662
- segmentCount: 12,
2663
- strokeWidth: LUCIDE_STROKE_WIDTH,
2664
- lineCap: 'round',
2665
- lineJoin: 'round',
2666
- ...body,
2667
- morePieces: [shackle],
2668
- spins: {},
2669
- };
2670
- return { lockIcons };
2671
- })();
2672
-
2673
- // sets/bell.ts
2674
- const { bellIcons } = (() => {
2675
- const CLAPPER = [lucideArc(12, 19.9999, 2, 149.9971, 30.0029)];
2676
- /** Where the dome is split in "on": the middle of bell-off's gap on it (196.86° … 236.27°). */
2677
- const DOME_GAP_MIDDLE = (196.8632 + 236.2663) / 2;
2678
- const dome = (startAngle, endAngle) => lucideArc(12, 8, 6, startAngle, endAngle);
2679
- const clapper = { strokes: { on: CLAPPER, off: CLAPPER }, alternativeStrokes: {}, pairLayouts: {} };
2680
- // bottom line (3 segments), left tip (2), left side (4), dome (3) — the same in both states
2681
- 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] };
2682
- const leftPart = {
2683
- strokes: {
2684
- on: [
2685
- lucideLine(20, 17, 4, 17),
2686
- lucideArc(4.0007, 16, 1, 90.0419, 222.3766),
2687
- lucideCubic(3.262, 15.326, 4.589, 13.956, 6, 12.499, 6, 8),
2688
- dome(180, DOME_GAP_MIDDLE),
2689
- ],
2690
- off: [
2691
- lucideLine(17, 17, 4, 17),
2692
- lucideArc(3.9996, 16, 1, 89.9795, 222.299),
2693
- lucideCubic(3.26, 15.327, 4.59, 13.956, 6, 12.499, 6, 8),
2694
- lucideArc(12, 7.9985, 6, 179.9859, 196.8632),
2695
- ],
2696
- },
2697
- alternativeStrokes: {},
2698
- pairLayouts: { on: { off: LEFT_LAYOUT }, off: { on: LEFT_LAYOUT } },
2699
- };
2700
- // dome (4 segments), right side (6), right tip (2) — off has no tip and only the side's upper part: 2 zero-length
2701
- // segments at its end
2702
- const rightPart = {
2703
- strokes: {
2704
- on: [
2705
- dome(DOME_GAP_MIDDLE, 360),
2706
- lucideCubic(18, 8, 18, 12.499, 19.41, 13.956, 20.74, 15.327),
2707
- lucideArc(20.0004, 16, 1, -42.299, 90.0205),
2708
- ],
2709
- off: [
2710
- lucideArc(12, 7.9998, 6, -123.7337, 0.0022),
2711
- lucideCubic(18, 8, 18, 10.687, 18.77, 12.653, 19.707, 14.05),
2712
- ],
2713
- },
2714
- alternativeStrokes: {},
2715
- pairLayouts: {
2716
- 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] } },
2717
- off: { on: { breakpoints: [0, 0.25, 0.5, 0.75, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2] } },
2718
- },
2719
- };
2720
- const slash = {
2721
- strokes: { on: lucidePoint(2, 2), off: LUCIDE_SLASH },
2722
- alternativeStrokes: {},
2723
- pairLayouts: { on: { off: { draw: true } } },
2724
- };
2725
- const bellIcons = {
2726
- segmentCount: 12,
2727
- strokeWidth: LUCIDE_STROKE_WIDTH,
2728
- lineCap: 'round',
2729
- lineJoin: 'round',
2730
- ...clapper,
2731
- morePieces: [leftPart, rightPart, slash],
2732
- spins: {},
2733
- };
2734
- return { bellIcons };
2735
- })();
2736
-
2737
- // sets/mic.ts
2738
- const { micIcons } = (() => {
2739
- const STAND = [lucideLine(12, 19, 12, 22)];
2740
- /** Where the mic's capsule is split: the middles of mic-off's gaps (right side y 11.85, left side y 6.3). */
2741
- const RIGHT_SPLIT_Y = 11.85;
2742
- const LEFT_SPLIT_Y = 6.3;
2743
- /** Where the holder is split: the middle of its gap (10.17° … 45°). */
2744
- const HOLDER_GAP_MIDDLE = (10.1709 + 44.9971) / 2;
2745
- const stand = { strokes: { on: STAND, off: STAND }, alternativeStrokes: {}, pairLayouts: {} };
2746
- // line, arc, line — 3 + 6 + 3 segments; off has no last line: 3 zero-length segments at its end
2747
- 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] };
2748
- const CUT_LAYOUT = { breakpoints: [0, 0.3333, 0.6667, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2, 2] };
2749
- const capsuleTop = {
2750
- strokes: {
2751
- // up the right side, over the top (counterclockwise), down the left side to the split
2752
- on: [lucideLine(15, RIGHT_SPLIT_Y, 15, 5), lucideArc(12, 5, 3, 0, -180), lucideLine(9, 5, 9, LEFT_SPLIT_Y)],
2753
- off: [lucideLine(15, 9.34, 15, 5), lucideArc(12.0001, 5.0181, 3, -0.3452, -153.2975)],
2754
- },
2755
- alternativeStrokes: {},
2756
- pairLayouts: { on: { off: SPLIT_LAYOUT }, off: { on: CUT_LAYOUT } },
2757
- };
2758
- const capsuleBottom = {
2759
- strokes: {
2760
- // down the left side, around the bottom (counterclockwise), up the right side to the split
2761
- on: [lucideLine(9, LEFT_SPLIT_Y, 9, 12), lucideArc(12, 12, 3, 180, 0), lucideLine(15, 12, 15, RIGHT_SPLIT_Y)],
2762
- off: [lucideLine(9, 9, 9, 12), lucideArc(12, 11.9974, 3, 179.9496, 45.0356)],
2763
- },
2764
- alternativeStrokes: {},
2765
- pairLayouts: { on: { off: SPLIT_LAYOUT }, off: { on: CUT_LAYOUT } },
2766
- };
2767
- // arc then line, 6 + 6 segments in both states (the arc only shortens)
2768
- 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] };
2769
- const holderLeft = {
2770
- strokes: {
2771
- on: [lucideArc(12, 12, 7, HOLDER_GAP_MIDDLE, 180), lucideLine(5, 12, 5, 10)],
2772
- off: [lucideArc(12, 12.0005, 7, 44.9971, 180.0041), lucideLine(5, 12, 5, 10)],
2773
- },
2774
- alternativeStrokes: {},
2775
- pairLayouts: { on: { off: HOLDER_LAYOUT }, off: { on: HOLDER_LAYOUT } },
2776
- };
2777
- const holderRight = {
2778
- strokes: {
2779
- on: [lucideArc(12, 12, 7, HOLDER_GAP_MIDDLE, 0), lucideLine(19, 12, 19, 10)],
2780
- off: [lucideArc(12, 11.9939, 7, 10.1709, 0.0499), lucideLine(19, 12, 19, 10)],
2781
- },
2782
- alternativeStrokes: {},
2783
- pairLayouts: { on: { off: HOLDER_LAYOUT }, off: { on: HOLDER_LAYOUT } },
2784
- };
2785
- const slash = {
2786
- strokes: { on: lucidePoint(2, 2), off: LUCIDE_SLASH },
2787
- alternativeStrokes: {},
2788
- pairLayouts: { on: { off: { draw: true } } },
2789
- };
2790
- const micIcons = {
2791
- segmentCount: 12,
2792
- strokeWidth: LUCIDE_STROKE_WIDTH,
2793
- lineCap: 'round',
2794
- lineJoin: 'round',
2795
- ...stand,
2796
- morePieces: [capsuleTop, capsuleBottom, holderLeft, holderRight, slash],
2797
- spins: {},
2798
- };
2799
- return { micIcons };
2800
- })();
2801
-
2802
- // sets/chevron.ts
2803
- const { chevronIcons } = (() => {
2804
- const STATES = ['up', 'right', 'down', 'left'];
2805
- /** chevron-up's corners (right end, tip, left end), turned clockwise by `quarterTurns` × 90° around 12,12. */
2806
- const chevron = (quarterTurns) => {
2807
- const turn = ([x, y]) => {
2808
- let [dx, dy] = [x - 12, y - 12];
2809
- for (let step = 0; step < quarterTurns; step++)
2810
- [dx, dy] = [-dy, dx];
2811
- return [12 + dx, 12 + dy];
2812
- };
2813
- const [rightEnd, tip, leftEnd] = [[18, 15], [12, 9], [6, 15]].map(turn);
2814
- return [lucideLine(...rightEnd, ...tip), lucideLine(...tip, ...leftEnd)];
2815
- };
2816
- /** Degrees from one state to another: the shorter way round (half a turn: clockwise). */
2817
- const turnBetween = (fromIndex, toIndex) => {
2818
- const quarterTurns = (toIndex - fromIndex + 4) % 4;
2819
- return quarterTurns === 3 ? -90 : quarterTurns * 90;
2820
- };
2821
- const chevronIcons = {
2822
- segmentCount: 2,
2823
- strokeWidth: LUCIDE_STROKE_WIDTH,
2824
- lineCap: 'round',
2825
- lineJoin: 'round',
2826
- strokes: Object.fromEntries(STATES.map((state, index) => [state, chevron(index)])),
2827
- alternativeStrokes: {},
2828
- pairLayouts: {},
2829
- spins: Object.fromEntries(STATES.map((state, fromIndex) => [state, Object.fromEntries(STATES
2830
- .filter((other) => other !== state).map((other) => [other, turnBetween(fromIndex, STATES.indexOf(other))]))])),
2831
- };
2832
- return { chevronIcons };
2833
- })();
2834
-
2835
- // sets/copy.ts
2836
- const { copyIcons } = (() => {
2837
- /** The front page, clockwise from its top edge. */
2838
- const FRONT_PAGE = [
2839
- lucideLine(10, 8, 20, 8),
2840
- lucideArc(20, 10, 2, -90, 0),
2841
- lucideLine(22, 10, 22, 20),
2842
- lucideArc(20, 20, 2, 0, 90),
2843
- lucideLine(20, 22, 10, 22),
2844
- lucideArc(10, 20, 2, 90, 180),
2845
- lucideLine(8, 20, 8, 10),
2846
- lucideArc(10, 10, 2, 180, 270),
2847
- ];
2848
- /** The back page: the L that shows behind the front page, from its bottom end up and over. */
2849
- const BACK_PAGE = [
2850
- lucideCubic(4, 16, 2.9, 16, 2, 15.1, 2, 14),
2851
- lucideLine(2, 14, 2, 4),
2852
- lucideCubic(2, 4, 2, 2.9, 2.9, 2, 4, 2),
2853
- lucideLine(4, 2, 14, 2),
2854
- lucideCubic(14, 2, 15.1, 2, 16, 2.9, 16, 4),
2855
- ];
2856
- const CHECK = [lucideLine(20, 6, 9, 17), lucideLine(9, 17, 4, 12)];
2857
- /** The check as a loop matching the page: corner → long end → corner → short end → corner. */
2858
- const CHECK_LOOP = [lucideLine(9, 17, 20, 6), lucideLine(20, 6, 9, 17), lucideLine(9, 17, 4, 12), lucideLine(4, 12, 9, 17)];
2859
- const page = {
2860
- strokes: { copy: FRONT_PAGE, copied: CHECK },
2861
- alternativeStrokes: { 'check-loop': CHECK_LOOP },
2862
- pairLayouts: {
2863
- // the page's default spread: 2 segments per edge, 1 per corner; each edge → one stroke of the loop, each
2864
- // corner → a zero-length segment at its end
2865
- copied: { copy: { tracing: 'check-loop', breakpoints: [0, 0.5, 1, 1, 1.5, 2, 2, 2.5, 3, 3, 3.5, 4, 4] } },
2866
- },
2867
- };
2868
- const backPage = {
2869
- strokes: { copy: BACK_PAGE, copied: lucidePoint(4, 16) },
2870
- alternativeStrokes: {},
2871
- pairLayouts: { copied: { copy: { draw: 0.5 } } },
2872
- };
2873
- const copyIcons = {
2874
- segmentCount: 12,
2875
- strokeWidth: LUCIDE_STROKE_WIDTH,
2876
- lineCap: 'round',
2877
- lineJoin: 'round',
2878
- ...page,
2879
- morePieces: [backPage],
2880
- spins: {},
2881
- };
2882
- return { copyIcons };
2883
- })();
2884
-
2885
- // sets/wifi.ts
2886
- const { wifiIcons } = (() => {
2887
- const STATES = ['none', 'low', 'medium', 'high'];
2888
- const DOT = [lucideLine(12, 20, 12.01, 20)];
2889
- /** The arcs, small to big, each clockwise from its left end. */
2890
- const ARCS = [
2891
- [lucideArc(12, 19.9997, 5, -134.427, -45.573)],
2892
- [lucideArc(12, 20.0004, 10, -134.427, -45.573)],
2893
- [lucideArc(12, 20.0003, 15, -131.8103, -48.1897)],
2894
- ];
2895
- const NO_ARC = lucidePoint(12, 20);
2896
- /** Arc `arcIndex` in each state: itself, else the outermost arc that state has (or a point on the dot). */
2897
- const arcPiece = (arcIndex) => ({
2898
- strokes: Object.fromEntries(STATES.map((state, level) => [state, level > arcIndex ? ARCS[arcIndex] : level > 0 ? ARCS[level - 1] : NO_ARC])),
2899
- alternativeStrokes: {},
2900
- pairLayouts: {},
2901
- });
2902
- const wifiIcons = {
2903
- segmentCount: 4,
2904
- strokeWidth: LUCIDE_STROKE_WIDTH,
2905
- lineCap: 'round',
2906
- lineJoin: 'round',
2907
- strokes: Object.fromEntries(STATES.map((state) => [state, DOT])),
2908
- alternativeStrokes: {},
2909
- pairLayouts: {},
2910
- morePieces: [0, 1, 2].map(arcPiece),
2911
- spins: {},
2912
- };
2913
- return { wifiIcons };
2914
- })();
2915
-
2916
- // sets/battery.ts
2917
- const { batteryIcons } = (() => {
2918
- const STATES = ['empty', 'low', 'medium', 'full'];
2919
- const BODY = [
2920
- lucideLine(4, 6, 16, 6),
2921
- lucideArc(16, 8, 2, -90, 0),
2922
- lucideLine(18, 8, 18, 16),
2923
- lucideArc(16, 16, 2, 0, 90),
2924
- lucideLine(16, 18, 4, 18),
2925
- lucideArc(4, 16, 2, 90, 180),
2926
- lucideLine(2, 16, 2, 8),
2927
- lucideArc(4, 8, 2, 180, 270),
2928
- ];
2929
- const TERMINAL = [lucideLine(22, 14, 22, 10)];
2930
- const BAR_X = [6, 10, 14];
2931
- /**
2932
- * When bar `barIndex` is drawn in from `fromLevel` to `toLevel` (more bars): the bars coming in share the morph,
2933
- * each taking 2 / (count + 1) of it, starting half of that after the one before.
2934
- */
2935
- const barDrawn = (barIndex, fromLevel, toLevel) => {
2936
- const count = toLevel - fromLevel;
2937
- const duration = 2 / (count + 1);
2938
- const start = (barIndex - fromLevel) * (duration / 2);
2939
- return { draw: [+start.toFixed(4), +(start + duration).toFixed(4)] };
2940
- };
2941
- const barPiece = (barIndex) => ({
2942
- strokes: Object.fromEntries(STATES.map((state, level) => [state, level > barIndex
2943
- ? [lucideLine(BAR_X[barIndex], 14, BAR_X[barIndex], 10)]
2944
- : lucidePoint(BAR_X[barIndex], 14)])),
2945
- alternativeStrokes: {},
2946
- // on the state without the bar, towards each state with it
2947
- 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)]))])),
2948
- });
2949
- const fixedPiece = (strokes) => ({
2950
- strokes: Object.fromEntries(STATES.map((state) => [state, strokes])),
2951
- alternativeStrokes: {},
2952
- pairLayouts: {},
2953
- });
2954
- const batteryIcons = {
2955
- segmentCount: 12,
2956
- strokeWidth: LUCIDE_STROKE_WIDTH,
2957
- lineCap: 'round',
2958
- lineJoin: 'round',
2959
- ...fixedPiece(BODY),
2960
- morePieces: [fixedPiece(TERMINAL), ...[0, 1, 2].map(barPiece)],
2961
- spins: {},
2962
- };
2963
- return { batteryIcons };
2964
- })();
2965
-
2966
- // iconSets.ts
2967
- const { iconSets, resolveIconSet } = (() => {
2968
- // The built-in icon sets, by name. `round` is the default.
2969
- const iconSets = {
2970
- round: roundDigits,
2971
- square: squareDigits,
2972
- condensed: condensedDigits,
2973
- slim: slimDigits,
2974
- handwritten: handwrittenDigits,
2975
- media: mediaIcons,
2976
- menu: menuIcons,
2977
- checkbox: checkboxIcons,
2978
- radio: radioIcons,
2979
- folder: folderIcons,
2980
- theme: themeIcons,
2981
- weather: weatherIcons,
2982
- eye: eyeIcons,
2983
- volume: volumeIcons,
2984
- toggle: toggleIcons,
2985
- lock: lockIcons,
2986
- bell: bellIcons,
2987
- mic: micIcons,
2988
- chevron: chevronIcons,
2989
- copy: copyIcons,
2990
- wifi: wifiIcons,
2991
- battery: batteryIcons,
2992
- };
2993
- // sets with sharper corners, built once per set and roundness (so their shapes are computed once too)
2994
- const setsByRoundness = new WeakMap();
2995
- /** A set given by name or as a set object, optionally with sharper corners (`roundness` 0 = sharp … 1 = own). */
2996
- const resolveIconSet = (set = 'round', roundness) => {
2997
- if (typeof set === 'string' && !(set in iconSets)) {
2998
- throw new Error(`morphico: unknown icon set "${set}" (known: ${Object.keys(iconSets).join(', ')})`);
2999
- }
3000
- const iconSet = typeof set === 'string' ? iconSets[set] : set;
3001
- if (roundness === undefined || roundness >= 1)
3002
- return iconSet; // the own corners are the roundest
3003
- if (!iconSet.withRoundness)
3004
- throw new Error('morphico: this icon set has no adjustable roundness');
3005
- const clamped = Math.max(0, roundness);
3006
- let byRoundness = setsByRoundness.get(iconSet);
3007
- if (!byRoundness)
3008
- setsByRoundness.set(iconSet, byRoundness = new Map());
3009
- let sharper = byRoundness.get(clamped);
3010
- if (!sharper)
3011
- byRoundness.set(clamped, sharper = iconSet.withRoundness(clamped));
3012
- return sharper;
3013
- };
3014
- return { iconSets, resolveIconSet };
3015
- })();
3016
-
3017
- // morph.ts
3018
- const { EMPTY_STATE, DEFAULT_DURATION, shapesOf, hasState, prefersReducedMotion, clampToUnit, createMorphingPath } = (() => {
3019
- // One SVG <path> that morphs between the states of an icon set. Shared by icon() and every text() cell.
3020
- /** The empty state: nothing is drawn. Shapes grow out of it and shrink into it while fading. */
3021
- const EMPTY_STATE = ' ';
3022
- /** How small a shape gets while it fades into (or out of) the empty state. */
3023
- const EMPTY_SCALE = 0.3;
3024
- const DEFAULT_DURATION = 400;
3025
- // default layouts of every state, computed once per set on first use (nothing runs at import)
3026
- const shapesBySet = new WeakMap();
3027
- const shapesOf = (iconSet) => {
3028
- let shapes = shapesBySet.get(iconSet);
3029
- if (!shapes) {
3030
- shapes = defaultShapes(iconSet);
3031
- shapesBySet.set(iconSet, shapes);
3032
- }
3033
- return shapes;
3034
- };
3035
- const hasState = (iconSet, state) => state === EMPTY_STATE || state in iconSet.strokes;
3036
- const prefersReducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
3037
- /** Blends towards the target in the start's frame while turning that frame by spin · progress. */
3038
- const blend = (from, to, progress, spin) => (spin
3039
- ? rotateAroundCenter(interpolate(from, rotateAroundCenter(to, -spin), progress), spin * progress)
3040
- : interpolate(from, to, progress));
3041
- const clampToUnit = (value) => Math.min(Math.max(value, 0), 1);
3042
- const createMorphingPath = (pathElement, iconSet, initialState) => {
3043
- const shapes = shapesOf(iconSet);
3044
- const pieceLength = pieceLengthOf(iconSet);
3045
- let state = initialState;
3046
- let currentPath = null; // null while empty
3047
- let currentOpacity = 0;
3048
- let running = null;
3049
- const render = (path, opacity) => {
3050
- currentPath = path;
3051
- currentOpacity = path ? clampToUnit(opacity) : 0;
3052
- if (path)
3053
- pathElement.setAttribute('d', toPathData(path, pieceLength));
3054
- else
3055
- pathElement.removeAttribute('d');
3056
- if (currentOpacity < 1)
3057
- pathElement.setAttribute('opacity', String(+currentOpacity.toFixed(3)));
3058
- else
3059
- pathElement.removeAttribute('opacity');
3060
- };
3061
- /** Ends the running morph and resolves everyone waiting for it. */
3062
- const finish = (interrupted) => {
3063
- if (!running)
3064
- return;
3065
- const { stopAnimation, resolvers } = running;
3066
- running = null;
3067
- stopAnimation();
3068
- for (const resolve of resolvers)
3069
- resolve({ state, interrupted });
3070
- };
3071
- render(state === EMPTY_STATE ? null : shapes[state], 1);
3072
- return {
3073
- get state() { return state; },
3074
- morphTo(nextState, { duration = DEFAULT_DURATION, easing = 'easeInOutCubic' } = {}) {
3075
- // asked again for the state it is already heading to: wait for the running morph
3076
- if (running && nextState === state) {
3077
- const { resolvers } = running;
3078
- return new Promise((resolve) => { resolvers.push(resolve); });
3079
- }
3080
- const settled = !running;
3081
- if (settled && nextState === state)
3082
- return Promise.resolve({ state, interrupted: false });
3083
- finish(true);
3084
- const previousState = state;
3085
- state = nextState;
3086
- // settled: both sides use the pair's layouts; interrupted: start from the shape on screen
3087
- const target = nextState === EMPTY_STATE ? null
3088
- : previousState === EMPTY_STATE ? shapes[nextState] : shapeForPair(iconSet, shapes, nextState, previousState);
3089
- const start = !settled ? currentPath
3090
- : previousState === EMPTY_STATE ? null : shapeForPair(iconSet, shapes, previousState, nextState);
3091
- const startOpacity = currentOpacity;
3092
- const endOpacity = target ? 1 : 0;
3093
- if ((!start && !target) || duration <= 0 || prefersReducedMotion()) {
3094
- render(target, endOpacity);
3095
- return Promise.resolve({ state, interrupted: false });
3096
- }
3097
- const from = start ?? scaleAroundCenter(target, EMPTY_SCALE);
3098
- const to = target ?? scaleAroundCenter(start, EMPTY_SCALE);
3099
- const spin = (settled && iconSet.spins[previousState]?.[nextState]) || 0;
3100
- // pieces that appear or go are drawn along their path (only from a settled shape, between two real states)
3101
- const drawn = settled && start && target ? drawnPieces(iconSet, previousState, nextState) : [];
3102
- const ease = typeof easing === 'function' ? easing : easings[easing];
3103
- const startTime = performance.now();
3104
- return new Promise((resolve) => {
3105
- const stopAnimation = animate((frameTime) => {
3106
- const progress = clampToUnit((frameTime - startTime) / duration);
3107
- if (progress < 1) {
3108
- const easedProgress = ease(progress);
3109
- const frame = withDrawnPieces(blend(from, to, easedProgress, spin), from, to, easedProgress, pieceLength, drawn);
3110
- render(frame, startOpacity + (endOpacity - startOpacity) * easedProgress);
3111
- return true;
3112
- }
3113
- render(target, endOpacity);
3114
- finish(false);
3115
- return false;
3116
- });
3117
- running = { stopAnimation, resolvers: [resolve] };
3118
- });
3119
- },
3120
- stop: () => finish(true),
3121
- };
3122
- };
3123
- return { EMPTY_STATE, DEFAULT_DURATION, shapesOf, hasState, prefersReducedMotion, clampToUnit, createMorphingPath };
3124
- })();
3125
-
3126
- // sets/shapes.ts
3127
- const { lucideShapeOutlines, clipShapes } = (() => {
3128
- // Shapes for clip(): closed outlines that cut out an element. From Lucide 1.61.0 (ISC, see LICENSE-lucide), imported
3129
- // by `node tools/import-lucide.ts <name>`; tag without its dot, club without its stem. The states are the Lucide outlines made comparable for
3130
- // morphing: clockwise, starting where the outline crosses the vertical middle at the top, scaled evenly to fill the
3131
- // 24×24 box and centred (computed on first use, not at import). Roundness 1 = Lucide's own corners … 0 = sharp.
3132
- const DEGREE = Math.PI / 180;
3133
- const GRID_SIZE = 24;
3134
- const GRID_CENTER = 12;
3135
- const SAMPLES_PER_STROKE = 48;
3136
- /** Enough for the shape that needs the most (puzzle: 24 strokes, its knobs ≈ 40 segments); the rest spreads by length. */
3137
- const SEGMENT_COUNT = 48;
3138
- /** The shapes as Lucide draws them (Lucide coordinates); the set's states are made from these. */
3139
- const lucideShapeOutlines = {
3140
- circle: [arc(12, 12, 10, -90, 270)],
3141
- ellipse: ellipse(12, 12, 10, 6),
3142
- heart: [
3143
- arc(7.5, 9.5001, 5.5, -179.9989, -41.9423),
3144
- arc(12, 5.4415, 0.56, 136.9163, 43.0837),
3145
- arc(16.5101, 9.4738, 5.49, -138.3317, 0.2729),
3146
- cubic(22, 9.5, 22, 11.79, 20.5, 13.5, 19, 15),
3147
- line(19, 15, 13.508, 20.313),
3148
- arc(11.9996, 18.9997, 2, 41.0455, 138.2288),
3149
- line(10.508, 20.332, 5, 15),
3150
- cubic(5, 15, 3.5, 13.5, 2, 11.8, 2, 9.5),
3151
- ],
3152
- sparkle: [
3153
- arc(12, 2.9976, 1, -169.4202, -10.5798),
3154
- line(12.983, 2.814, 14.034, 8.372),
3155
- arc(15.9992, 8.0008, 2, 169.3026, 100.6974),
3156
- line(15.628, 9.966, 21.186, 11.017),
3157
- arc(21.0024, 12, 1, -79.4202, 79.4202),
3158
- line(21.186, 12.983, 15.628, 14.034),
3159
- arc(15.9992, 15.9992, 2, -100.6974, -169.3026),
3160
- line(14.034, 15.628, 12.983, 21.186),
3161
- arc(12, 21.0024, 1, 10.5798, 169.4202),
3162
- line(11.017, 21.186, 9.966, 15.628),
3163
- arc(8.0008, 15.9992, 2, -10.6974, -79.3026),
3164
- line(8.372, 14.034, 2.814, 12.983),
3165
- arc(2.9976, 12, 1, 100.5798, 259.4202),
3166
- line(2.814, 11.017, 8.372, 9.966),
3167
- arc(8.0008, 8.0008, 2, 79.3026, 10.6974),
3168
- line(9.966, 8.372, 11.017, 2.814),
3169
- ],
3170
- astroid: [
3171
- arc(12, 21.0024, 1, 10.5798, 169.4202),
3172
- arc(1.1555, 22.8445, 10, -9.5467, -80.4533),
3173
- arc(2.9976, 12, 1, 100.5798, 259.4202),
3174
- arc(1.1555, 1.1555, 10, 80.4533, 9.5467),
3175
- arc(12, 2.9976, 1, -169.4202, -10.5798),
3176
- arc(22.8445, 1.1555, 10, 170.4533, 99.5467),
3177
- arc(21.0024, 12, 1, -79.4202, 79.4202),
3178
- arc(22.8445, 22.8445, 10, -99.5467, -170.4533),
3179
- ],
3180
- bookmark: [
3181
- arc(17, 5, 2, -90, 0),
3182
- line(19, 5, 19, 20),
3183
- arc(18, 19.9997, 1, 0.0185, 119.7357),
3184
- line(17.504, 20.868, 12.992, 18.29),
3185
- arc(12, 20.0266, 2, -60.2643, -119.7357),
3186
- line(11.008, 18.29, 6.496, 20.868),
3187
- arc(6, 19.9997, 1, 60.2643, 179.9815),
3188
- line(5, 20, 5, 5),
3189
- arc(7, 5, 2, 180, 270),
3190
- line(7, 3, 17, 3),
3191
- ],
3192
- shield: [
3193
- cubic(20, 13, 20, 18, 16.5, 20.5, 12.34, 21.95),
3194
- arc(12.0191, 21.0029, 1, 71.2803, 110.4299),
3195
- cubic(11.67, 21.94, 7.5, 20.5, 4, 18, 4, 13),
3196
- line(4, 13, 4, 6),
3197
- arc(5, 6, 1, 180, 270),
3198
- cubic(5, 5, 7, 5, 9.5, 3.8, 11.24, 2.28),
3199
- arc(12, 3.1696, 1.17, -130.5094, -49.4906),
3200
- cubic(12.76, 2.28, 14.51, 3.81, 17, 5, 19, 5),
3201
- arc(19, 6, 1, -90, 0),
3202
- line(20, 6, 20, 13),
3203
- ],
3204
- moon: [
3205
- arc(11.9981, 12.0009, 9, 3.09, 266.904),
3206
- cubic(11.512, 3.014, 11.917, 2.992, 12.129, 3.474, 11.914, 3.817),
3207
- arc(17.002, 6.997, 6, -147.9941, -302.0059),
3208
- cubic(20.182, 12.085, 20.526, 11.87, 21.007, 12.081, 20.985, 12.486),
3209
- ],
3210
- tag: [
3211
- arc(11.1716, 4, 2, -44.9913, -89.9878),
3212
- line(11.172, 2, 4, 2),
3213
- arc(4, 4, 2, -90, -180),
3214
- line(2, 4, 2, 11.172),
3215
- arc(4, 11.1716, 2, 179.9878, 134.9913),
3216
- line(2.586, 12.586, 11.29, 21.29),
3217
- arc(13, 19.5691, 2.426, 134.8186, 45.1814),
3218
- line(14.71, 21.29, 21.29, 14.71),
3219
- arc(19.5691, 13, 2.426, 44.8186, -44.8186),
3220
- line(21.29, 11.29, 12.586, 2.586),
3221
- ],
3222
- square: [
3223
- line(5, 3, 19, 3),
3224
- arc(19, 5, 2, -90, 0),
3225
- line(21, 5, 21, 19),
3226
- arc(19, 19, 2, 0, 90),
3227
- line(19, 21, 5, 21),
3228
- arc(5, 19, 2, 90, 180),
3229
- line(3, 19, 3, 5),
3230
- arc(5, 5, 2, 180, 270),
3231
- ],
3232
- badge: [
3233
- arc(7.7549, 7.7531, 4, 167.4834, 282.6366),
3234
- arc(12, 6.0048, 4, -147.4051, -32.5949),
3235
- arc(16.2474, 7.7526, 4, -102.6711, 12.6711),
3236
- arc(17.9952, 12, 4, -57.4051, 57.4051),
3237
- arc(16.2469, 16.2451, 4, -12.6366, 102.5166),
3238
- arc(12.005, 18.0031, 4, 32.4617, 147.5383),
3239
- arc(7.7549, 16.2469, 4, 77.3634, 192.5166),
3240
- arc(5.9891, 12, 4, 122.3281, 237.6719),
3241
- ],
3242
- triangle: [
3243
- arc(12, 5.0035, 2, -30.1173, -149.8827),
3244
- line(10.27, 4, 2.27, 18),
3245
- arc(4.0021, 19, 2, -150, -269.9413),
3246
- line(4, 21, 20, 21),
3247
- arc(19.9979, 19, 2, 89.9413, -30),
3248
- line(21.73, 18, 13.73, 4),
3249
- ],
3250
- droplet: [
3251
- arc(12, 15, 7, 90, 0),
3252
- cubic(19, 15, 19, 13, 18, 11.1, 16, 9.5),
3253
- cubic(16, 9.5, 14, 7.9, 12.5, 5.5, 12, 3),
3254
- cubic(12, 3, 11.5, 5.5, 10, 7.9, 8, 9.5),
3255
- cubic(8, 9.5, 6, 11.1, 5, 13, 5, 15),
3256
- arc(12, 15, 7, 180, 90),
3257
- ],
3258
- pentagon: [
3259
- arc(12, 4.0021, 2, -125.803, -54.197),
3260
- line(13.17, 2.38, 21.17, 8.12),
3261
- arc(20.0009, 9.7427, 2, -54.2293, 18.2786),
3262
- line(21.9, 10.37, 18.86, 19.63),
3263
- arc(16.9618, 19, 2, 18.3608, 90.052),
3264
- line(16.96, 21, 7.04, 21),
3265
- arc(7.0382, 19, 2, 89.948, 161.6392),
3266
- line(5.14, 19.63, 2.1, 10.37),
3267
- arc(3.9991, 9.7427, 2, 161.7214, 234.2293),
3268
- line(2.83, 8.12, 10.83, 2.38),
3269
- ],
3270
- hexagon: [
3271
- line(21, 16, 21, 8),
3272
- arc(19, 8.0021, 2, -0.0588, -60),
3273
- line(20, 6.27, 13, 2.27),
3274
- arc(12, 4.0021, 2, -60, -120),
3275
- line(11, 2.27, 4, 6.27),
3276
- arc(5, 8.0021, 2, -120, -179.9412),
3277
- line(3, 8, 3, 16),
3278
- arc(5, 15.9979, 2, 179.9412, 120),
3279
- line(4, 17.73, 11, 21.73),
3280
- arc(12, 19.9979, 2, 120, 60),
3281
- line(13, 21.73, 20, 17.73),
3282
- arc(19, 15.9979, 2, 60, 0.0588),
3283
- ],
3284
- octagon: [
3285
- arc(4, 15.3116, 2, 134.9913, 179.9878),
3286
- line(2, 15.312, 2, 8.688),
3287
- arc(4, 8.6884, 2, -179.9878, -134.9913),
3288
- line(2.586, 7.274, 7.274, 2.586),
3289
- arc(8.6884, 4, 2, -135.0087, -90.0122),
3290
- line(8.688, 2, 15.312, 2),
3291
- arc(15.3116, 4, 2, -89.9878, -44.9913),
3292
- line(16.726, 2.586, 21.414, 7.274),
3293
- arc(20, 8.6884, 2, -45.0087, -0.0122),
3294
- line(22, 8.688, 22, 15.312),
3295
- arc(20, 15.3116, 2, 0.0122, 45.0087),
3296
- line(21.414, 16.726, 16.726, 21.414),
3297
- arc(15.3116, 20, 2, 44.9913, 89.9878),
3298
- line(15.312, 22, 8.688, 22),
3299
- arc(8.6884, 20, 2, 90.0122, 135.0087),
3300
- line(7.274, 21.414, 2.586, 16.726),
3301
- ],
3302
- squircle: [
3303
- cubic(12, 3, 19.2, 3, 21, 4.8, 21, 12),
3304
- cubic(21, 12, 21, 19.2, 19.2, 21, 12, 21),
3305
- cubic(12, 21, 4.8, 21, 3, 19.2, 3, 12),
3306
- cubic(3, 12, 3, 4.8, 4.8, 3, 12, 3),
3307
- ],
3308
- star: [
3309
- arc(12, 2.5301, 0.53, -153.6664, -26.3336),
3310
- line(12.475, 2.295, 14.785, 6.974),
3311
- arc(16.6883, 6.0335, 2.123, 153.7044, 98.3503),
3312
- line(16.38, 8.134, 21.546, 8.89),
3313
- arc(21.47, 9.4145, 0.53, -81.756, 45.7247),
3314
- line(21.84, 9.794, 18.104, 13.432),
3315
- arc(19.5857, 14.9525, 2.123, -134.26, -189.6958),
3316
- line(17.493, 15.31, 18.375, 20.45),
3317
- arc(17.8531, 20.5422, 0.53, -10.0155, 118.0314),
3318
- line(17.604, 21.01, 12.986, 18.582),
3319
- arc(11.9995, 20.4608, 2.122, -62.2968, -117.7032),
3320
- line(11.013, 18.582, 6.396, 21.01),
3321
- arc(6.148, 20.5416, 0.53, 62.1036, 189.9512),
3322
- line(5.626, 20.45, 6.507, 15.311),
3323
- arc(4.4155, 14.9522, 2.122, 9.7335, -45.7597),
3324
- line(5.896, 13.432, 2.16, 9.795),
3325
- arc(2.5282, 9.4138, 0.53, 134.0047, 261.9521),
3326
- line(2.454, 8.889, 7.619, 8.134),
3327
- arc(7.3135, 6.0341, 2.122, 81.7227, 26.2909),
3328
- line(9.216, 6.974, 11.525, 2.295),
3329
- ],
3330
- 'rectangle-horizontal': [
3331
- line(4, 6, 20, 6),
3332
- arc(20, 8, 2, -90, 0),
3333
- line(22, 8, 22, 16),
3334
- arc(20, 16, 2, 0, 90),
3335
- line(20, 18, 4, 18),
3336
- arc(4, 16, 2, 90, 180),
3337
- line(2, 16, 2, 8),
3338
- arc(4, 8, 2, 180, 270),
3339
- ],
3340
- 'message-circle': [
3341
- arc(1.1328, 17.079, 2, -21.6245, 12.4142),
3342
- line(3.086, 17.509, 2.021, 20.799),
3343
- arc(3.0005, 21.0005, 1, -168.3779, -284.8626),
3344
- line(3.257, 21.967, 6.67, 20.969),
3345
- arc(7.0591, 22.9308, 2, -101.2192, -69.2104),
3346
- arc(12.0003, 12.0003, 10, 115.0325, -205.7324),
3347
- ],
3348
- 'message-square': [
3349
- arc(20, 17, 2, 0, 90),
3350
- line(20, 19, 6.828, 19),
3351
- arc(6.8284, 21, 2, -90.0122, -135.0087),
3352
- line(5.414, 19.586, 3.212, 21.788),
3353
- arc(2.71, 21.2859, 0.71, 45.0052, 179.9926),
3354
- line(2, 21.286, 2, 5),
3355
- arc(4, 5, 2, 180, 270),
3356
- line(4, 3, 20, 3),
3357
- arc(20, 5, 2, -90, 0),
3358
- line(22, 5, 22, 17),
3359
- ],
3360
- cloud: [
3361
- line(17.5, 19, 9, 19),
3362
- arc(9.0018, 12, 7, 90.0147, 343.3984),
3363
- line(15.71, 10, 17.5, 10),
3364
- arc(17.5, 14.5, 4.5, -90, 90),
3365
- ],
3366
- puzzle: [
3367
- arc(16.0975, 3.6833, 1, 135.0289, 13.459),
3368
- arc(19.5008, 4.5, 2.5, -166.4917, 76.5107),
3369
- arc(20.3167, 7.9035, 1, -103.459, -225.0289),
3370
- line(19.61, 8.611, 21.293, 10.293),
3371
- arc(19.5861, 12, 2.414, -45.0015, 45.0015),
3372
- line(21.293, 13.707, 19.61, 15.39),
3373
- arc(18.9025, 14.6833, 1, 44.9711, 166.541),
3374
- arc(15.4992, 15.5, 2.5, -13.5083, -256.5107),
3375
- arc(14.6833, 18.9035, 1, -76.541, 45.0289),
3376
- line(15.39, 19.611, 13.707, 21.293),
3377
- arc(12, 19.5861, 2.414, 44.9985, 135.0015),
3378
- line(10.293, 21.293, 8.61, 19.61),
3379
- arc(7.9025, 20.3167, 1, -44.9711, -166.541),
3380
- arc(4.4992, 19.5, 2.5, 13.5083, 256.5107),
3381
- arc(3.6833, 16.0965, 1, 76.541, -45.0289),
3382
- line(4.39, 15.389, 2.707, 13.707),
3383
- arc(4.4139, 12, 2.414, 134.9985, 225.0015),
3384
- line(2.707, 10.293, 4.39, 8.61),
3385
- arc(5.0975, 9.3167, 1, -135.0289, -13.459),
3386
- arc(8.5008, 8.5, 2.5, 166.4917, -76.5107),
3387
- arc(9.3167, 5.0965, 1, 103.459, 225.0289),
3388
- line(8.61, 4.389, 10.293, 2.707),
3389
- arc(12, 4.4139, 2.414, -135.0015, -44.9985),
3390
- line(13.707, 2.707, 15.39, 4.39),
3391
- ],
3392
- // without its stem line (Lucide's second piece)
3393
- club: [
3394
- arc(12, 7.51, 5.5, 16.2602, -196.2602),
3395
- arc(7.5026, 14.494, 5.5, -98.1803, -324.8563),
3396
- arc(16.5052, 14.5052, 5.5, 144.9978, -81.9018),
3397
- line(17.28, 9.06, 17.28, 9.05),
3398
- ],
3399
- };
3400
- // ---- making the outlines comparable ----
3401
- const pointOnStroke = (stroke, progress) => strokePiece(stroke, 0, progress)[3];
3402
- const samplesOf = (strokes) => strokes.flatMap((stroke, strokeIndex) => Array.from({ length: SAMPLES_PER_STROKE }, (_, sample) => {
3403
- const progress = sample / SAMPLES_PER_STROKE;
3404
- return { point: pointOnStroke(stroke, progress), strokeIndex, progress };
3405
- }));
3406
- const transformStroke = (stroke, scale, offsetX, offsetY) => {
3407
- const move = ([x, y]) => [x * scale + offsetX, y * scale + offsetY];
3408
- switch (stroke.kind) {
3409
- case 'line': return { ...stroke, from: move(stroke.from), to: move(stroke.to) };
3410
- case 'arc': return { ...stroke, center: move(stroke.center), radius: stroke.radius * scale };
3411
- case 'cubic': return { kind: 'cubic', segment: stroke.segment.map(move) };
3412
- }
3413
- };
3414
- /** Splits a stroke at `progress` into two strokes of the same kind (arcs stay arcs, so they keep their segment rule). */
3415
- const splitStroke = (stroke, progress) => {
3416
- if (stroke.kind === 'arc') {
3417
- const splitAngle = stroke.startAngle + (stroke.endAngle - stroke.startAngle) * progress;
3418
- return [{ ...stroke, endAngle: splitAngle }, { ...stroke, startAngle: splitAngle }];
3419
- }
3420
- if (stroke.kind === 'line') {
3421
- const splitPoint = pointOnStroke(stroke, progress);
3422
- return [{ ...stroke, to: splitPoint }, { ...stroke, from: splitPoint }];
3423
- }
3424
- return [{ kind: 'cubic', segment: strokePiece(stroke, 0, progress) }, { kind: 'cubic', segment: strokePiece(stroke, progress, 1) }];
3425
- };
3426
- const fitToBox = (strokes) => {
3427
- const points = samplesOf(strokes).map(({ point }) => point);
3428
- const xValues = points.map(([x]) => x), yValues = points.map(([, y]) => y);
3429
- const minX = Math.min(...xValues), maxX = Math.max(...xValues), minY = Math.min(...yValues), maxY = Math.max(...yValues);
3430
- const scale = GRID_SIZE / Math.max(maxX - minX, maxY - minY);
3431
- return strokes.map((stroke) => transformStroke(stroke, scale, GRID_CENTER - ((minX + maxX) / 2) * scale, GRID_CENTER - ((minY + maxY) / 2) * scale));
3432
- };
3433
- /** Positive = clockwise on screen (y points down). */
3434
- const signedArea = (strokes) => {
3435
- const points = samplesOf(strokes).map(({ point }) => point);
3436
- return points.reduce((sum, [x, y], index) => {
3437
- const [nextX, nextY] = points[(index + 1) % points.length];
3438
- return sum + x * nextY - nextX * y;
3439
- }, 0);
3440
- };
3441
- const clockwise = (strokes) => (signedArea(strokes) >= 0 ? strokes : strokes.slice().reverse().map(reversedStroke));
3442
- /** Starts the closed outline where it crosses x = 12 highest up (splitting that stroke). */
3443
- const startAtTopMiddle = (strokes) => {
3444
- const samples = samplesOf(strokes);
3445
- let best = null;
3446
- for (let index = 0; index < samples.length; index++) {
3447
- const sample = samples[index], next = samples[(index + 1) % samples.length];
3448
- const [x, y] = sample.point, [nextX, nextY] = next.point;
3449
- if ((x - GRID_CENTER) * (nextX - GRID_CENTER) > 0 || x === nextX)
3450
- continue;
3451
- const fraction = (GRID_CENTER - x) / (nextX - x);
3452
- const crossingY = y + (nextY - y) * fraction;
3453
- if (best && crossingY >= best.y)
3454
- continue;
3455
- const nextProgress = next.strokeIndex === sample.strokeIndex ? next.progress : 1;
3456
- best = { strokeIndex: sample.strokeIndex, progress: sample.progress + (nextProgress - sample.progress) * fraction, y: crossingY };
3457
- }
3458
- if (!best)
3459
- return strokes;
3460
- const { strokeIndex, progress } = best;
3461
- const before = strokes.slice(0, strokeIndex), after = strokes.slice(strokeIndex + 1);
3462
- if (progress < 1e-6)
3463
- return [...strokes.slice(strokeIndex), ...before];
3464
- if (progress > 1 - 1e-6)
3465
- return [...after, ...before, strokes[strokeIndex]];
3466
- const [firstPart, secondPart] = splitStroke(strokes[strokeIndex], progress);
3467
- return [secondPart, ...after, ...before, firstPart];
3468
- };
3469
- // ---- roundness: 1 = Lucide's own corners, 0 = sharp ----
3470
- /** Lucide's corner arcs are small (radius 0.56 … 2.43); bigger arcs are part of the shape (circle, moon, droplet). */
3471
- const LARGEST_CORNER_RADIUS = 2.5;
3472
- const crossingOfLines = (point, direction, otherPoint, otherDirection) => {
3473
- const determinant = direction[0] * otherDirection[1] - direction[1] * otherDirection[0];
3474
- if (Math.abs(determinant) < 1e-9)
3475
- return null;
3476
- const along = ((otherPoint[0] - point[0]) * otherDirection[1] - (otherPoint[1] - point[1]) * otherDirection[0]) / determinant;
3477
- return [point[0] + direction[0] * along, point[1] + direction[1] * along];
3478
- };
3479
- /** Moves a stroke's start (or end) to `point`; a cubic's neighbouring control point moves along, so its direction there stays. */
3480
- const withMovedEnd = (stroke, which, point) => {
3481
- if (stroke.kind === 'line')
3482
- return which === 'start' ? { ...stroke, from: point } : { ...stroke, to: point };
3483
- if (stroke.kind !== 'cubic')
3484
- return stroke;
3485
- const [start, control1, control2, end] = stroke.segment;
3486
- if (which === 'start') {
3487
- const shift = [point[0] - start[0], point[1] - start[1]];
3488
- return { kind: 'cubic', segment: [point, [control1[0] + shift[0], control1[1] + shift[1]], control2, end] };
3489
- }
3490
- const shift = [point[0] - end[0], point[1] - end[1]];
3491
- return { kind: 'cubic', segment: [start, control1, [control2[0] + shift[0], control2[1] + shift[1]], point] };
3492
- };
3493
- /**
3494
- * Every corner arc (small, between two lines / cubics) shrinks towards the sharp corner where its two neighbours'
3495
- * directions meet: radius × roundness, angles unchanged. At 0 the arc is a point on the corner. Same strokes in the
3496
- * same order at any roundness, so morphs keep working.
3497
- */
3498
- const withCornerRoundness = (strokes, roundness) => {
3499
- const result = strokes.slice();
3500
- strokes.forEach((stroke, index) => {
3501
- const previousIndex = (index - 1 + strokes.length) % strokes.length, nextIndex = (index + 1) % strokes.length;
3502
- const isEdge = (neighbour) => neighbour.kind === 'line' || neighbour.kind === 'cubic';
3503
- if (stroke.kind !== 'arc' || stroke.radius > LARGEST_CORNER_RADIUS)
3504
- return;
3505
- if (!isEdge(strokes[previousIndex]) || !isEdge(strokes[nextIndex]))
3506
- return;
3507
- const turning = Math.sign(stroke.endAngle - stroke.startAngle);
3508
- const tangent = (angle) => [-Math.sin(angle * DEGREE) * turning, Math.cos(angle * DEGREE) * turning];
3509
- const start = pointOnStroke(stroke, 0), end = pointOnStroke(stroke, 1);
3510
- const corner = crossingOfLines(start, tangent(stroke.startAngle), end, tangent(stroke.endAngle));
3511
- if (!corner)
3512
- return;
3513
- const towardsCorner = ([x, y]) => [corner[0] + (x - corner[0]) * roundness, corner[1] + (y - corner[1]) * roundness];
3514
- result[index] = { ...stroke, center: towardsCorner(stroke.center), radius: stroke.radius * roundness };
3515
- result[previousIndex] = withMovedEnd(result[previousIndex], 'end', towardsCorner(start));
3516
- result[nextIndex] = withMovedEnd(result[nextIndex], 'start', towardsCorner(end));
3517
- });
3518
- return result;
3519
- };
3520
- /** The shape set at a roundness (0 = sharp … 1 = Lucide's own corners); its strokes are built on first use. */
3521
- const shapeSetWithRoundness = (roundness) => {
3522
- let strokes = null;
3523
- return {
3524
- get strokes() {
3525
- strokes ??= Object.fromEntries(Object.entries(lucideShapeOutlines).map(([name, outline]) => [name, startAtTopMiddle(clockwise(fitToBox(withCornerRoundness(outline, roundness))))]));
3526
- return strokes;
3527
- },
3528
- alternativeStrokes: {},
3529
- pairLayouts: {},
3530
- spins: {},
3531
- segmentCount: SEGMENT_COUNT,
3532
- // shapes are filled (clip), not stroked; these only matter for the outlined band's corners
3533
- strokeWidth: 0,
3534
- lineCap: 'butt',
3535
- lineJoin: 'miter',
3536
- withRoundness: shapeSetWithRoundness,
3537
- };
3538
- };
3539
- /** The shape set of clip(): states `circle`, `ellipse`, `heart` … `puzzle` (see `lucideShapeOutlines`). */
3540
- const clipShapes = shapeSetWithRoundness(1);
3541
- return { lucideShapeOutlines, clipShapes };
3542
- })();
3543
-
3544
- // svg.ts
3545
- const { hasFixedStrokeWidth, createSvgElement, createIconSvg, createStrokedElement } = (() => {
3546
- const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
3547
- /** A CSS length as stroke width: drawn at that width on screen instead of scaling with the icon. */
3548
- const hasFixedStrokeWidth = (style) => typeof style.strokeWidth === 'string';
3549
- const createSvgElement = (tagName, attributes = {}) => {
3550
- const element = document.createElementNS(SVG_NAMESPACE, tagName);
3551
- for (const [name, value] of Object.entries(attributes))
3552
- element.setAttribute(name, String(value));
3553
- return element;
3554
- };
3555
- /** An <svg> drawn in the set's stroke style (or the given style), colored by the CSS `color` (currentColor). */
3556
- const createIconSvg = (iconSet, viewBox, style = {}) => {
3557
- const svg = createSvgElement('svg', {
3558
- viewBox,
3559
- fill: style.fill === true ? 'currentColor' : style.fill || 'none',
3560
- stroke: 'currentColor',
3561
- 'stroke-linecap': style.lineCap ?? iconSet.lineCap,
3562
- 'stroke-linejoin': style.lineJoin ?? iconSet.lineJoin,
3563
- });
3564
- // a CSS length goes into the style (attributes can't hold rem / em / calc reliably); children inherit it
3565
- if (hasFixedStrokeWidth(style))
3566
- svg.style.strokeWidth = style.strokeWidth;
3567
- else
3568
- svg.setAttribute('stroke-width', String(style.strokeWidth ?? iconSet.strokeWidth));
3569
- return svg;
3570
- };
3571
- /** A stroked child element (path, circle); with a fixed (CSS length) stroke width its line doesn't scale. */
3572
- const createStrokedElement = (tagName, style, attributes = {}) => createSvgElement(tagName, {
3573
- ...attributes,
3574
- ...(hasFixedStrokeWidth(style) && { 'vector-effect': 'non-scaling-stroke' }),
3575
- });
3576
- return { hasFixedStrokeWidth, createSvgElement, createIconSvg, createStrokedElement };
3577
- })();
3578
-
3579
- // clip.ts
3580
- const { clip } = (() => {
3581
- // Layer 3: an element cut out by a morphing shape (a star, a heart, a speech bubble …), e.g. a photo or a card.
3582
- // The shape is square (1:1), placed by its centre (x, y) and size in % or px of the element, can be turned and
3583
- // outlined (only a band along its edge shows). Filled → CSS `clip-path: url(#id)`; outlined → `mask: url(#id)`
3584
- // (clip-path ignores line width). The <clipPath> / <mask> live in one hidden <svg> per clip.
3585
- const GRID_SIZE = 24;
3586
- const GRID_CENTER = 12;
3587
- const DEGREE = Math.PI / 180;
3588
- /** Masks and their clip regions reach far past the element, so bands and turned shapes are never cut by them. */
3589
- const FAR = 100000;
3590
- /** In objectBoundingBox units (1 = the element's width / height): far around the element. */
3591
- const INVERTED_SURROUNDING = 'M-1000 -1000H1001V1001H-1000Z';
3592
- let clipCount = 0;
3593
- const lengthToPixels = (length, reference, name) => {
3594
- if (typeof length === 'number' && Number.isFinite(length))
3595
- return (length / 100) * reference;
3596
- const match = typeof length === 'string' ? /^\s*(-?[\d.]+(?:e[-+]?\d+)?)\s*(px|%)\s*$/i.exec(length) : null;
3597
- if (!match)
3598
- throw new Error(`morphico: ${name} "${length}" is not a length — use a number (= %), '40%' or '120px'`);
3599
- const value = Number(match[1]);
3600
- return match[2] === 'px' ? value : (value / 100) * reference;
3601
- };
3602
- const checkedOutline = (outline) => {
3603
- if (!outline)
3604
- return false;
3605
- if (!(outline.width > 0))
3606
- throw new Error('morphico: outline.width must be a number of px above 0');
3607
- if (outline.band && !['inside', 'centre', 'outside'].includes(outline.band)) {
3608
- throw new Error(`morphico: outline.band "${outline.band}" — use 'inside', 'centre' or 'outside'`);
3609
- }
3610
- return outline;
3611
- };
3612
- /** Cuts `element` out by a morphing shape. */
3613
- const clip = (element, options = {}) => {
3614
- const { set = clipShapes, state: initialState = 'circle', x = 50, y = 50, size, rotation = 0, roundness = 1, outline = false, inverted = false, ...defaultMorphOptions } = options;
3615
- const checkedState = (shapeSet, state) => {
3616
- if (!(state in shapeSet.strokes)) {
3617
- throw new Error(`morphico: unknown shape "${state}" (known: ${Object.keys(shapeSet.strokes).join(', ')})`);
3618
- }
3619
- return state;
3620
- };
3621
- for (const [name, length] of [['x', x], ['y', y], ['size', size]]) {
3622
- if (length !== undefined)
3623
- lengthToPixels(length, 1, name);
3624
- }
3625
- /** Where the change goes (or what shows when nothing moves). */
3626
- const target = {
3627
- state: checkedState(set, initialState), set: resolveIconSet(set, roundness), roundness,
3628
- x, y, size, rotation, outline: checkedOutline(outline), inverted,
3629
- };
3630
- const pieceLength = pieceLengthOf(set);
3631
- let frame = null; // null = resting at the target
3632
- let running = null;
3633
- let elementWidth = 0, elementHeight = 0;
3634
- // ---- the hidden <svg> with the <clipPath> (filled) and the <mask> (outlined) ----
3635
- const id = `morphico-clip-${++clipCount}`;
3636
- const svg = createSvgElement('svg', { width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' });
3637
- svg.style.position = 'absolute';
3638
- svg.style.pointerEvents = 'none';
3639
- const clipPathElement = svg.appendChild(createSvgElement('clipPath', { id: `${id}-shape`, clipPathUnits: 'objectBoundingBox' }));
3640
- const clipShape = clipPathElement.appendChild(createSvgElement('path'));
3641
- const farRegion = { maskUnits: 'userSpaceOnUse', x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR };
3642
- const maskElement = svg.appendChild(createSvgElement('mask', { id: `${id}-band`, ...farRegion }));
3643
- /** Every path drawn in element pixels for the outlined band (the band itself and its helpers). */
3644
- let bandPaths = [];
3645
- const buildBand = () => {
3646
- for (const helper of svg.querySelectorAll('.morphico-band-helper'))
3647
- helper.remove();
3648
- maskElement.replaceChildren();
3649
- bandPaths = [];
3650
- clipShape.setAttribute('clip-rule', target.inverted ? 'evenodd' : 'nonzero');
3651
- if (!target.outline)
3652
- return;
3653
- const { width, band = 'inside' } = target.outline;
3654
- // inverted: everything shows (white), the band is the hole (black)
3655
- const bandColor = target.inverted ? 'black' : 'white';
3656
- if (target.inverted) {
3657
- maskElement.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }));
3658
- }
3659
- const pixelPath = (attributes) => {
3660
- const path = createSvgElement('path', attributes);
3661
- bandPaths.push(path);
3662
- return path;
3663
- };
3664
- // inside = a line twice as wide, clipped to the shape; outside = a line twice as wide, the shape cut out of it
3665
- const edgeLine = (lineWidth) => pixelPath({ fill: 'none', stroke: bandColor, 'stroke-width': lineWidth, 'stroke-linejoin': 'miter' });
3666
- if (band === 'centre') {
3667
- maskElement.append(edgeLine(width));
3668
- }
3669
- else if (band === 'inside') {
3670
- const insideClip = createSvgElement('clipPath', { id: `${id}-inside`, class: 'morphico-band-helper' });
3671
- insideClip.append(pixelPath({}));
3672
- svg.append(insideClip);
3673
- const line = edgeLine(2 * width);
3674
- line.setAttribute('clip-path', `url(#${id}-inside)`);
3675
- maskElement.append(line);
3676
- }
3677
- else {
3678
- // painting black over the inside would only work for luminance masks; a mask cut out of the line works for both
3679
- const outsideMask = createSvgElement('mask', { id: `${id}-outside`, class: 'morphico-band-helper', ...farRegion });
3680
- outsideMask.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }), pixelPath({ fill: 'black' }));
3681
- svg.append(outsideMask);
3682
- const line = edgeLine(2 * width);
3683
- line.setAttribute('mask', `url(#${id}-outside)`);
3684
- maskElement.append(line);
3685
- }
3686
- };
3687
- const ownClipPath = element.style.clipPath;
3688
- const ownMask = element.style.mask;
3689
- const applyStyle = () => {
3690
- const mask = target.outline ? `url(#${id}-band)` : ownMask;
3691
- element.style.clipPath = target.outline ? ownClipPath : `url(#${id}-shape)`;
3692
- element.style.mask = mask;
3693
- element.style.setProperty('-webkit-mask', mask);
3694
- };
3695
- // ---- placement and drawing ----
3696
- const restingPlacement = () => ({
3697
- centerX: lengthToPixels(target.x, elementWidth, 'x'),
3698
- centerY: lengthToPixels(target.y, elementHeight, 'y'),
3699
- side: target.size === undefined ? Math.min(elementWidth, elementHeight) : lengthToPixels(target.size, elementWidth, 'size'),
3700
- });
3701
- const restingFrame = () => ({ shape: shapesOf(target.set)[target.state], rotation: target.rotation, placement: restingPlacement() });
3702
- /** The shape in element pixels: scaled to its square, turned around the square's centre, moved into place. */
3703
- const toElementPixels = ({ shape, rotation: angle, placement: { centerX, centerY, side } }) => {
3704
- const scale = side / GRID_SIZE;
3705
- const cos = Math.cos(angle * DEGREE), sin = Math.sin(angle * DEGREE);
3706
- const pixels = [];
3707
- for (let index = 0; index < shape.length; index += 2) {
3708
- const offsetX = (shape[index] - GRID_CENTER) * scale, offsetY = (shape[index + 1] - GRID_CENTER) * scale;
3709
- pixels.push(centerX + offsetX * cos - offsetY * sin, centerY + offsetX * sin + offsetY * cos);
3710
- }
3711
- return pixels;
3712
- };
3713
- const render = () => {
3714
- if (!elementWidth || !elementHeight)
3715
- return;
3716
- const pixels = toElementPixels(frame ?? restingFrame());
3717
- if (target.outline) {
3718
- const pathData = toPathData(pixels, pieceLength);
3719
- for (const path of bandPaths)
3720
- path.setAttribute('d', pathData);
3721
- }
3722
- else {
3723
- // objectBoundingBox: 0…1 of the element's width / height
3724
- const boxUnits = pixels.map((value, index) => value / (index % 2 ? elementHeight : elementWidth));
3725
- // inverted: a rectangle far around the element with the shape as a hole in it (clip-rule evenodd)
3726
- const surrounding = target.inverted ? INVERTED_SURROUNDING : '';
3727
- clipShape.setAttribute('d', surrounding + toPathData(boxUnits, pieceLength));
3728
- }
3729
- };
3730
- const measure = (width, height) => {
3731
- elementWidth = width;
3732
- elementHeight = height;
3733
- render();
3734
- };
3735
- // ---- changes ----
3736
- /** Ends the running change and resolves everyone waiting for it. */
3737
- const finish = (interrupted) => {
3738
- if (!running)
3739
- return;
3740
- const { stopAnimation, resolvers } = running;
3741
- running = null;
3742
- stopAnimation();
3743
- for (const resolve of resolvers)
3744
- resolve({ state: target.state, interrupted });
3745
- };
3746
- /** Updates the target (checked first, so a wrong value throws right away, not in an animation frame). */
3747
- const applyChanges = (changes) => {
3748
- for (const name of ['x', 'y', 'size']) {
3749
- if (changes[name] !== undefined)
3750
- lengthToPixels(changes[name], 1, name);
3751
- }
3752
- if (changes.state !== undefined)
3753
- target.state = checkedState(target.set, changes.state);
3754
- if (changes.roundness !== undefined && changes.roundness !== target.roundness) {
3755
- target.roundness = changes.roundness;
3756
- target.set = resolveIconSet(set, changes.roundness);
3757
- }
3758
- if (changes.x !== undefined)
3759
- target.x = changes.x;
3760
- if (changes.y !== undefined)
3761
- target.y = changes.y;
3762
- if (changes.size !== undefined)
3763
- target.size = changes.size;
3764
- if (changes.rotation !== undefined)
3765
- target.rotation = changes.rotation;
3766
- if (changes.outline !== undefined || changes.inverted !== undefined) {
3767
- if (changes.outline !== undefined)
3768
- target.outline = checkedOutline(changes.outline);
3769
- if (changes.inverted !== undefined)
3770
- target.inverted = changes.inverted;
3771
- buildBand();
3772
- applyStyle();
3773
- }
3774
- };
3775
- const change = (changes, morphOptions) => {
3776
- const settled = !running;
3777
- finish(true);
3778
- const previous = { state: target.state, set: target.set };
3779
- const from = frame ?? restingFrame();
3780
- applyChanges(changes);
3781
- const { duration = DEFAULT_DURATION, easing = 'easeInOutCubic' } = morphOptions;
3782
- if (duration <= 0 || prefersReducedMotion() || !elementWidth || !elementHeight) {
3783
- frame = null;
3784
- render();
3785
- return Promise.resolve({ state: target.state, interrupted: false });
3786
- }
3787
- // settled: both sides use the pair's layouts; interrupted: start from the shape on screen
3788
- const fromShape = settled
3789
- ? shapeForPair(previous.set, shapesOf(previous.set), previous.state, target.state) : from.shape;
3790
- const toShape = shapeForPair(target.set, shapesOf(target.set), target.state, previous.state);
3791
- const to = restingPlacement();
3792
- const ease = typeof easing === 'function' ? easing : easings[easing];
3793
- const startTime = performance.now();
3794
- return new Promise((resolve) => {
3795
- const stopAnimation = animate((frameTime) => {
3796
- const progress = clampToUnit((frameTime - startTime) / duration);
3797
- if (progress >= 1) {
3798
- frame = null;
3799
- render();
3800
- finish(false);
3801
- return false;
3802
- }
3803
- const easedProgress = ease(progress);
3804
- const between = (start, end) => start + (end - start) * easedProgress;
3805
- frame = {
3806
- shape: interpolate(fromShape, toShape, easedProgress),
3807
- rotation: between(from.rotation, target.rotation),
3808
- placement: {
3809
- centerX: between(from.placement.centerX, to.centerX),
3810
- centerY: between(from.placement.centerY, to.centerY),
3811
- side: between(from.placement.side, to.side),
3812
- },
3813
- };
3814
- render();
3815
- return true;
3816
- });
3817
- running = { stopAnimation, resolvers: [resolve] };
3818
- });
3819
- };
3820
- // ---- start ----
3821
- const root = element.getRootNode();
3822
- // inside a shadow root, url(#id) only finds ids of that root
3823
- (root instanceof ShadowRoot ? root : document.body).append(svg);
3824
- buildBand();
3825
- applyStyle();
3826
- const box = element.getBoundingClientRect();
3827
- measure(box.width, box.height);
3828
- const resizeObserver = typeof ResizeObserver === 'function'
3829
- ? new ResizeObserver(([entry]) => {
3830
- const borderBox = entry.borderBoxSize?.[0];
3831
- if (borderBox)
3832
- measure(borderBox.inlineSize, borderBox.blockSize);
3833
- else
3834
- measure(entry.contentRect.width, entry.contentRect.height);
3835
- })
3836
- : null;
3837
- resizeObserver?.observe(element);
3838
- return {
3839
- get state() { return target.state; },
3840
- to(stateOrChanges, options = {}) {
3841
- const { onEnd, duration, easing, ...changes } = typeof stateOrChanges === 'string'
3842
- ? { ...options, state: stateOrChanges } : stateOrChanges;
3843
- const result = change(changes, { ...defaultMorphOptions, ...(duration !== undefined && { duration }), ...(easing && { easing }) });
3844
- if (onEnd)
3845
- result.then(onEnd);
3846
- return result;
3847
- },
3848
- set(changes) {
3849
- finish(true);
3850
- frame = null;
3851
- applyChanges(changes);
3852
- render();
3853
- },
3854
- destroy() {
3855
- finish(true);
3856
- resizeObserver?.disconnect();
3857
- svg.remove();
3858
- element.style.clipPath = ownClipPath;
3859
- element.style.mask = ownMask;
3860
- element.style.setProperty('-webkit-mask', ownMask);
3861
- },
3862
- };
3863
- };
3864
- return { clip };
3865
- })();
3866
-
3867
- // sets/faces.ts
3868
- const { OUTLINE_POINTS, PIECE_NAMES, faceExpressionNames, faceExpressionPieces, interpolateFace, blinked, smoothClosedPath, facePaths } = (() => {
3869
- const OUTLINE_POINTS = 24;
3870
- const PIECE_NAMES = ['leftBrow', 'rightBrow', 'leftEye', 'rightEye', 'leftPupil', 'rightPupil', 'mouth'];
3871
- const DENSE_SAMPLES = 360;
3872
- const toRadians = (degrees) => (degrees * Math.PI) / 180;
3873
- const rotatePoint = ([x, y], degrees) => {
3874
- const angle = toRadians(degrees);
3875
- return [x * Math.cos(angle) - y * Math.sin(angle), x * Math.sin(angle) + y * Math.cos(angle)];
3876
- };
3877
- const denseBlobOutline = (options) => {
3878
- const { centre, width, height, rotation = 0, squareness = 0, topScale = 1 } = options;
3879
- const exponent = 2 + squareness * 6;
3880
- const halfWidth = width / 2;
3881
- const halfHeight = height / 2;
3882
- const outline = [];
3883
- for (let index = 0; index < DENSE_SAMPLES; index++) {
3884
- const angle = toRadians(-90 + (360 * index) / DENSE_SAMPLES);
3885
- const cosine = Math.cos(angle);
3886
- const sine = Math.sin(angle);
3887
- const y = halfHeight * Math.sign(sine) * Math.abs(sine) ** (2 / exponent);
3888
- const widthScale = 1 + ((topScale - 1) * (halfHeight - y)) / (2 * halfHeight);
3889
- const x = halfWidth * widthScale * Math.sign(cosine) * Math.abs(cosine) ** (2 / exponent);
3890
- const [turnedX, turnedY] = rotatePoint([x, y], rotation);
3891
- outline.push([centre[0] + turnedX, centre[1] + turnedY]);
3892
- }
3893
- const xs = outline.map(([x]) => x);
3894
- const left = Math.min(...xs);
3895
- const right = Math.max(...xs);
3896
- const lineY = ([leftY, rightY], x) => leftY + ((rightY - leftY) * (x - left)) / (right - left);
3897
- return outline.map(([x, y]) => {
3898
- let cutY = y;
3899
- if (options.cutTop)
3900
- cutY = Math.max(cutY, lineY(options.cutTop, x));
3901
- if (options.cutBottom)
3902
- cutY = Math.min(cutY, lineY(options.cutBottom, x));
3903
- return [x, cutY];
3904
- });
3905
- };
3906
- /** OUTLINE_POINTS points of a star-shaped outline, on rays at even angles from its box's centre (measured in the box
3907
- * stretched to a square, so a long flat shape still gets points on its round ends). */
3908
- const resampleByAngle = (outline) => {
3909
- const xs = outline.map(([x]) => x);
3910
- const ys = outline.map(([, y]) => y);
3911
- const centreX = (Math.min(...xs) + Math.max(...xs)) / 2;
3912
- const centreY = (Math.min(...ys) + Math.max(...ys)) / 2;
3913
- const halfX = Math.max((Math.max(...xs) - Math.min(...xs)) / 2, 1e-9);
3914
- const halfY = Math.max((Math.max(...ys) - Math.min(...ys)) / 2, 1e-9);
3915
- const normalized = outline.map(([x, y]) => [(x - centreX) / halfX, (y - centreY) / halfY]);
3916
- const points = [];
3917
- for (let index = 0; index < OUTLINE_POINTS; index++) {
3918
- const angle = toRadians(-90 + (360 * index) / OUTLINE_POINTS);
3919
- const directionX = Math.cos(angle);
3920
- const directionY = Math.sin(angle);
3921
- let farthest = 0;
3922
- normalized.forEach((start, edgeIndex) => {
3923
- const end = normalized[(edgeIndex + 1) % normalized.length];
3924
- const edgeX = end[0] - start[0];
3925
- const edgeY = end[1] - start[1];
3926
- const denominator = directionX * edgeY - directionY * edgeX;
3927
- if (Math.abs(denominator) < 1e-12)
3928
- return;
3929
- const distance = (start[0] * edgeY - start[1] * edgeX) / denominator;
3930
- const along = (start[0] * directionY - start[1] * directionX) / denominator;
3931
- // a little slack: a ray passing exactly through a corner of the dense outline must still hit one of its edges
3932
- if (along >= -1e-9 && along <= 1 + 1e-9 && distance > farthest)
3933
- farthest = distance;
3934
- });
3935
- points.push([centreX + directionX * farthest * halfX, centreY + directionY * farthest * halfY]);
3936
- }
3937
- return points;
3938
- };
3939
- const blob = (options) => resampleByAngle(denseBlobOutline(options));
3940
- const band = (options) => {
3941
- const { from, to, bend, thickness, endShare = 1 } = options;
3942
- const chordX = to[0] - from[0];
3943
- const chordY = to[1] - from[1];
3944
- const chordLength = Math.hypot(chordX, chordY);
3945
- // the quadratic's control point sits twice as far out as the curve's middle
3946
- const control = [
3947
- (from[0] + to[0]) / 2 + ((2 * bend * chordY) / chordLength),
3948
- (from[1] + to[1]) / 2 - ((2 * bend * chordX) / chordLength),
3949
- ];
3950
- const pointAt = (along) => {
3951
- const rest = 1 - along;
3952
- return [
3953
- rest * rest * from[0] + 2 * rest * along * control[0] + along * along * to[0],
3954
- rest * rest * from[1] + 2 * rest * along * control[1] + along * along * to[1],
3955
- ];
3956
- };
3957
- const tangentAt = (along) => {
3958
- const tangentX = 2 * (1 - along) * (control[0] - from[0]) + 2 * along * (to[0] - control[0]);
3959
- const tangentY = 2 * (1 - along) * (control[1] - from[1]) + 2 * along * (to[1] - control[1]);
3960
- const length = Math.hypot(tangentX, tangentY);
3961
- return [tangentX / length, tangentY / length];
3962
- };
3963
- const thicknessAt = (along) => thickness * (endShare + (1 - endShare) * Math.sin(Math.PI * along));
3964
- // side 1 = the upper edge (the left normal of a rightward curve), -1 = the lower edge
3965
- const edge = (along, side) => {
3966
- const [x, y] = pointAt(along);
3967
- const [tangentX, tangentY] = tangentAt(along);
3968
- const offset = (side * thicknessAt(along)) / 2;
3969
- return [x + tangentY * offset, y - tangentX * offset];
3970
- };
3971
- // a point on the half circle around an end: turn -90° = on the upper edge, 0 = the tip, 90° = on the lower edge
3972
- const capPoint = (along, turn) => {
3973
- const [x, y] = pointAt(along);
3974
- const [tangentX, tangentY] = tangentAt(along);
3975
- const radius = thicknessAt(along) / 2;
3976
- const outward = along === 1 ? 1 : -1;
3977
- const alongTangent = outward * Math.cos(turn) * radius;
3978
- const towardsLower = outward * Math.sin(turn) * radius;
3979
- return [x + tangentX * alongTangent - tangentY * towardsLower, y + tangentY * alongTangent + tangentX * towardsLower];
3980
- };
3981
- // clockwise from the top middle at the same angles as a blob's points (a quarter = the right end, half = the bottom
3982
- // middle, three quarters = the left end); the points within 30° of an end lie on its half circle, the rest along the
3983
- // edges
3984
- const capShare = Math.sin(Math.PI / 3);
3985
- return Array.from({ length: OUTLINE_POINTS }, (_, index) => {
3986
- const angle = (2 * Math.PI * index) / OUTLINE_POINTS;
3987
- const sine = Math.sin(angle);
3988
- if (Math.abs(sine) < capShare - 1e-9)
3989
- return edge(0.5 + (0.5 * sine) / capShare, Math.cos(angle) > 0 ? 1 : -1);
3990
- // the angle's distance past the cap's start, stretched from ±30° around the end to ±90° around the half circle
3991
- const endAngle = sine > 0 ? Math.PI / 2 : (3 * Math.PI) / 2;
3992
- return capPoint(sine > 0 ? 1 : 0, (angle - endAngle) * 3);
3993
- });
3994
- };
3995
- /** A piece that isn't there: every point on one spot (draws nothing). */
3996
- const hidden = (at) => Array.from({ length: OUTLINE_POINTS }, () => [at[0], at[1]]);
3997
- /** The piece mirrored onto the other half of the face, still clockwise from the top. */
3998
- const mirrored = (points) => points.map((_, index) => {
3999
- const [x, y] = points[(OUTLINE_POINTS - index) % OUTLINE_POINTS];
4000
- return [24 - x, y];
4001
- });
4002
- /** A face from its left half (as seen) + the mouth. */
4003
- const symmetricFace = ({ brow, eye, pupil, mouth }) => ({
4004
- leftBrow: brow,
4005
- rightBrow: mirrored(brow),
4006
- leftEye: eye,
4007
- rightEye: mirrored(eye),
4008
- leftPupil: pupil,
4009
- rightPupil: mirrored(pupil),
4010
- mouth,
4011
- });
4012
- /** The whole face mirrored: left and right pieces swap sides. */
4013
- const mirroredFace = (face) => ({
4014
- leftBrow: mirrored(face.rightBrow),
4015
- rightBrow: mirrored(face.leftBrow),
4016
- leftEye: mirrored(face.rightEye),
4017
- rightEye: mirrored(face.leftEye),
4018
- leftPupil: mirrored(face.rightPupil),
4019
- rightPupil: mirrored(face.leftPupil),
4020
- mouth: mirrored(face.mouth),
4021
- });
4022
- // ---- the expressions (names from the user's pictures) ----
4023
- const faceExpressionNames = [
4024
- 'normal',
4025
- 'smiling',
4026
- 'pleading',
4027
- 'wonder',
4028
- 'focused',
4029
- 'fright',
4030
- 'angry',
4031
- 'asleep',
4032
- 'sad',
4033
- 'surprised',
4034
- 'laughing',
4035
- 'tired',
4036
- 'wink-left',
4037
- 'wink-right',
4038
- 'suspicious',
4039
- 'confused',
4040
- 'smug',
4041
- 'irritated',
4042
- 'bored',
4043
- 'curious',
4044
- 'empathetic',
4045
- 'guilty',
4046
- 'worried',
4047
- 'disappointed',
4048
- 'ashamed',
4049
- 'embarrassed',
4050
- 'yearning',
4051
- 'triumphant',
4052
- 'proud',
4053
- 'excited',
4054
- 'scornful',
4055
- 'disgusted',
4056
- ];
4057
- const buildFaceExpressions = () => {
4058
- const winkRight = {
4059
- ...symmetricFace({
4060
- brow: blob({ centre: [6.2, 4.4], width: 5.2, height: 1.9, squareness: 0.5, rotation: -6 }),
4061
- eye: blob({ centre: [6.4, 12], width: 8.4, height: 8.4 }),
4062
- pupil: blob({ centre: [7, 11.4], width: 3.2, height: 3.2 }),
4063
- mouth: band({ from: [9.6, 18.4], to: [14.8, 18], bend: -0.9, thickness: 1.2, endShare: 0.7 }),
4064
- }),
4065
- rightBrow: mirrored(blob({ centre: [6.2, 6], width: 5, height: 1.8, squareness: 0.5, rotation: 8 })),
4066
- rightEye: mirrored(band({ from: [3.2, 12.8], to: [9.6, 12.8], bend: 1.4, thickness: 1.6, endShare: 0.7 })),
4067
- rightPupil: hidden([17.6, 12]),
4068
- };
4069
- return {
4070
- normal: symmetricFace({
4071
- brow: blob({ centre: [6.2, 4.6], width: 5.2, height: 1.9, squareness: 0.5, rotation: -6 }),
4072
- eye: blob({ centre: [6.4, 12], width: 8.4, height: 8.4 }),
4073
- pupil: blob({ centre: [7, 10.7], width: 3.2, height: 3.2 }),
4074
- mouth: blob({ centre: [12, 18.8], width: 4.4, height: 1.8, squareness: 0.6 }),
4075
- }),
4076
- smiling: symmetricFace({
4077
- brow: blob({ centre: [6, 5.4], width: 5, height: 4.4, squareness: 0.2, cutBottom: [5.4, 5.4] }),
4078
- eye: blob({ centre: [6, 13], width: 9.2, height: 7.6 }),
4079
- pupil: blob({ centre: [6.8, 13.6], width: 3.6, height: 2.4 }),
4080
- mouth: band({ from: [9, 18.4], to: [15, 18.4], bend: -1, thickness: 1.3, endShare: 0.7 }),
4081
- }),
4082
- pleading: symmetricFace({
4083
- brow: blob({ centre: [6.6, 3.7], width: 4.6, height: 2, squareness: 0.5, rotation: -16 }),
4084
- eye: blob({ centre: [6.8, 12.4], width: 8.6, height: 9.6, topScale: 0.75, rotation: 12 }),
4085
- pupil: blob({ centre: [7.6, 10.7], width: 4, height: 4 }),
4086
- mouth: blob({ centre: [12, 17.6], width: 4.3, height: 4.8, squareness: 0.3, cutTop: [17.6, 17.6] }),
4087
- }),
4088
- wonder: symmetricFace({
4089
- brow: band({ from: [3.4, 4.8], to: [8.6, 3.3], bend: 0.7, thickness: 1.7 }),
4090
- eye: blob({ centre: [6.4, 12], width: 7.8, height: 9.6 }),
4091
- pupil: blob({ centre: [6.8, 11.2], width: 3.2, height: 4.2 }),
4092
- mouth: blob({ centre: [12, 18.6], width: 3.8, height: 1, squareness: 0.6 }),
4093
- }),
4094
- focused: symmetricFace({
4095
- brow: blob({ centre: [6, 4.4], width: 5.6, height: 2.2, squareness: 0.55, rotation: 3 }),
4096
- eye: blob({ centre: [6.2, 12.8], width: 9, height: 8, cutTop: [10, 11] }),
4097
- pupil: blob({ centre: [6.8, 13.2], width: 3.8, height: 2.8 }),
4098
- mouth: blob({ centre: [12, 18.8], width: 4.2, height: 1, squareness: 0.6 }),
4099
- }),
4100
- fright: symmetricFace({
4101
- brow: blob({ centre: [5.7, 3.5], width: 4.8, height: 2.2, squareness: 0.45, rotation: -8 }),
4102
- eye: blob({ centre: [6.2, 12.4], width: 8.4, height: 9.8, topScale: 0.7, rotation: 8 }),
4103
- pupil: blob({ centre: [7.2, 12], width: 3.6, height: 4.2 }),
4104
- mouth: blob({ centre: [12, 18.6], width: 3.8, height: 1.4, squareness: 0.5 }),
4105
- }),
4106
- angry: symmetricFace({
4107
- brow: blob({ centre: [6.4, 5.4], width: 5.2, height: 2, squareness: 0.5, rotation: 12 }),
4108
- eye: blob({ centre: [6.2, 12.6], width: 8.6, height: 8, cutTop: [9.2, 12] }),
4109
- pupil: blob({ centre: [7.4, 12.8], width: 3.4, height: 3.4 }),
4110
- mouth: blob({ centre: [12, 17.8], width: 4.2, height: 4, squareness: 0.3, cutTop: [17.8, 17.8] }),
4111
- }),
4112
- asleep: symmetricFace({
4113
- brow: blob({ centre: [6, 5.4], width: 4, height: 1.7, squareness: 0.4, rotation: 4 }),
4114
- eye: band({ from: [3, 12.4], to: [9.6, 12.4], bend: -1.2, thickness: 1.5, endShare: 0.6 }),
4115
- pupil: hidden([6.3, 13.4]),
4116
- mouth: blob({ centre: [12, 18.8], width: 2.6, height: 1.8 }),
4117
- }),
4118
- // more feelings, Claude's drafts (no pictures); the one-sided ones replace pieces of the right half — written in place
4119
- // (a blob or band is already clockwise from its top) or drawn on the left and mirrored
4120
- sad: symmetricFace({
4121
- brow: blob({ centre: [6.4, 5.2], width: 4.8, height: 1.9, squareness: 0.5, rotation: -18 }),
4122
- eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 8, cutTop: [11.2, 9.6] }),
4123
- pupil: blob({ centre: [6.8, 13.8], width: 3.2, height: 3.4 }),
4124
- mouth: band({ from: [9.4, 19.2], to: [14.6, 19.2], bend: 0.9, thickness: 1.2, endShare: 0.7 }),
4125
- }),
4126
- surprised: symmetricFace({
4127
- brow: band({ from: [3.6, 3.6], to: [8.6, 2.8], bend: 0.6, thickness: 1.6 }),
4128
- eye: blob({ centre: [6.4, 12], width: 8.6, height: 9 }),
4129
- pupil: blob({ centre: [6.4, 12], width: 2.4, height: 2.4 }),
4130
- mouth: blob({ centre: [12, 19], width: 2.8, height: 3.4 }),
4131
- }),
4132
- laughing: symmetricFace({
4133
- brow: blob({ centre: [6, 4.6], width: 5, height: 4.4, squareness: 0.2, cutBottom: [4.6, 4.6] }),
4134
- eye: band({ from: [3.2, 13], to: [9.6, 13], bend: 1.6, thickness: 1.6, endShare: 0.7 }),
4135
- pupil: hidden([6.4, 12]),
4136
- mouth: blob({ centre: [12, 17], width: 6, height: 6, squareness: 0.2, cutTop: [17, 17] }),
4137
- }),
4138
- tired: symmetricFace({
4139
- brow: blob({ centre: [6, 6], width: 5, height: 1.7, squareness: 0.5, rotation: -4 }),
4140
- eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.6, 11.6] }),
4141
- pupil: blob({ centre: [6.6, 13.6], width: 3.2, height: 3.2 }),
4142
- mouth: blob({ centre: [12, 19], width: 3.2, height: 0.9, squareness: 0.6 }),
4143
- }),
4144
- // wink-left / wink-right = the eye that closes, as seen; one is the other mirrored
4145
- 'wink-left': mirroredFace(winkRight),
4146
- 'wink-right': winkRight,
4147
- suspicious: {
4148
- ...symmetricFace({
4149
- brow: band({ from: [3.4, 4], to: [8.8, 3], bend: 0.8, thickness: 1.6 }),
4150
- eye: blob({ centre: [6.4, 12.6], width: 8.6, height: 7.6, cutTop: [10.8, 10.8] }),
4151
- pupil: blob({ centre: [4.6, 13.2], width: 3.2, height: 3 }),
4152
- mouth: blob({ centre: [12.6, 18.8], width: 3.6, height: 1, squareness: 0.6, rotation: -10 }),
4153
- }),
4154
- rightBrow: mirrored(blob({ centre: [6.4, 6.4], width: 5.2, height: 1.8, squareness: 0.5, rotation: 10 })),
4155
- rightEye: mirrored(blob({ centre: [6.4, 12.8], width: 8.6, height: 7.2, cutTop: [11.8, 11.8] })),
4156
- rightPupil: blob({ centre: [16.2, 13.4], width: 3.2, height: 3 }),
4157
- },
4158
- confused: {
4159
- ...symmetricFace({
4160
- brow: band({ from: [3.4, 3.8], to: [8.6, 2.6], bend: 0.7, thickness: 1.6 }),
4161
- eye: blob({ centre: [6.4, 12], width: 8.6, height: 9 }),
4162
- pupil: blob({ centre: [7.6, 10.6], width: 3, height: 3 }),
4163
- mouth: blob({ centre: [12.4, 19], width: 3.8, height: 1.1, squareness: 0.5, rotation: 12 }),
4164
- }),
4165
- rightBrow: mirrored(blob({ centre: [6.2, 5.8], width: 5, height: 1.8, squareness: 0.5, rotation: 10 })),
4166
- rightEye: mirrored(blob({ centre: [6.4, 12.6], width: 7.4, height: 7.4 })),
4167
- rightPupil: blob({ centre: [18.4, 11.4], width: 2.8, height: 2.8 }),
4168
- },
4169
- // the user's list of feelings (2026-11-01), Claude's drafts; the ones the set had already are skipped
4170
- smug: {
4171
- ...symmetricFace({
4172
- brow: blob({ centre: [6.2, 5], width: 5, height: 1.8, squareness: 0.5, rotation: -4 }),
4173
- eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 11.4] }),
4174
- pupil: blob({ centre: [8, 13.4], width: 3, height: 3 }),
4175
- mouth: band({ from: [10.2, 18.8], to: [15.2, 17.6], bend: -0.6, thickness: 1.2, endShare: 0.6 }),
4176
- }),
4177
- rightBrow: mirrored(band({ from: [3.6, 4.2], to: [8.6, 3.4], bend: 0.6, thickness: 1.6 })),
4178
- rightPupil: blob({ centre: [19.4, 13.4], width: 3, height: 3 }),
4179
- },
4180
- irritated: symmetricFace({
4181
- brow: blob({ centre: [6.4, 5.8], width: 5.4, height: 1.8, squareness: 0.55, rotation: 6 }),
4182
- eye: blob({ centre: [6.4, 12.8], width: 8.6, height: 7.6, cutTop: [11.2, 11.8], cutBottom: [15.8, 15.8] }),
4183
- pupil: blob({ centre: [6.8, 13.6], width: 3.2, height: 3 }),
4184
- mouth: blob({ centre: [12.4, 18.8], width: 3.6, height: 0.9, squareness: 0.6, rotation: -8 }),
4185
- }),
4186
- bored: {
4187
- ...symmetricFace({
4188
- brow: blob({ centre: [6, 6.2], width: 5, height: 1.7, squareness: 0.5 }),
4189
- eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.8, 11.8] }),
4190
- pupil: blob({ centre: [8, 12.8], width: 3, height: 3 }),
4191
- mouth: blob({ centre: [13.4, 19], width: 3, height: 0.9, squareness: 0.6 }),
4192
- }),
4193
- rightPupil: blob({ centre: [19.4, 12.8], width: 3, height: 3 }),
4194
- },
4195
- curious: {
4196
- ...symmetricFace({
4197
- brow: band({ from: [3.4, 3.6], to: [8.6, 2.6], bend: 0.7, thickness: 1.6 }),
4198
- eye: blob({ centre: [6.4, 12], width: 8.6, height: 8.8 }),
4199
- pupil: blob({ centre: [7.8, 10.8], width: 3.2, height: 3.2 }),
4200
- mouth: blob({ centre: [12.6, 18.8], width: 2, height: 2.2 }),
4201
- }),
4202
- rightBrow: mirrored(blob({ centre: [6.2, 5.2], width: 5, height: 1.8, squareness: 0.5, rotation: 4 })),
4203
- rightPupil: blob({ centre: [19, 10.8], width: 3.2, height: 3.2 }),
4204
- },
4205
- empathetic: symmetricFace({
4206
- brow: blob({ centre: [6.4, 4.8], width: 5, height: 1.9, squareness: 0.5, rotation: -10 }),
4207
- eye: blob({ centre: [6.4, 12.4], width: 8.4, height: 8, cutTop: [10, 9.2] }),
4208
- pupil: blob({ centre: [6.8, 13], width: 3.4, height: 3.4 }),
4209
- mouth: band({ from: [10, 18.6], to: [14, 18.6], bend: -0.5, thickness: 1.1, endShare: 0.7 }),
4210
- }),
4211
- guilty: {
4212
- ...symmetricFace({
4213
- brow: blob({ centre: [6.4, 5], width: 4.6, height: 1.8, squareness: 0.5, rotation: -14 }),
4214
- eye: blob({ centre: [6.4, 12.6], width: 8.2, height: 8, cutTop: [10.6, 10.2] }),
4215
- pupil: blob({ centre: [5, 14.4], width: 3, height: 3 }),
4216
- mouth: blob({ centre: [11.4, 19], width: 2.8, height: 0.9, squareness: 0.6, rotation: 6 }),
4217
- }),
4218
- rightPupil: blob({ centre: [16.2, 14.4], width: 3, height: 3 }),
4219
- },
4220
- worried: symmetricFace({
4221
- brow: blob({ centre: [6.6, 4.2], width: 4.8, height: 1.9, squareness: 0.5, rotation: -20 }),
4222
- eye: blob({ centre: [6.4, 12.2], width: 8, height: 8.8, topScale: 0.85, rotation: 6 }),
4223
- pupil: blob({ centre: [6.8, 12.4], width: 2.6, height: 2.8 }),
4224
- mouth: band({ from: [10.2, 19], to: [13.8, 19], bend: 0.6, thickness: 1.1, endShare: 0.7 }),
4225
- }),
4226
- disappointed: symmetricFace({
4227
- brow: blob({ centre: [6.2, 5.6], width: 5, height: 1.8, squareness: 0.5, rotation: -8 }),
4228
- eye: blob({ centre: [6.4, 12.8], width: 8.4, height: 7.6, cutTop: [11.8, 11] }),
4229
- pupil: blob({ centre: [6.6, 14.4], width: 3, height: 3 }),
4230
- mouth: band({ from: [9.6, 19.2], to: [14.4, 19.2], bend: 0.4, thickness: 1.1, endShare: 0.8 }),
4231
- }),
4232
- ashamed: symmetricFace({
4233
- brow: blob({ centre: [6.4, 5.6], width: 4.6, height: 1.8, squareness: 0.5, rotation: -16 }),
4234
- eye: blob({ centre: [6.4, 13], width: 8.2, height: 7.6, cutTop: [12.2, 12.2] }),
4235
- pupil: blob({ centre: [6.4, 15], width: 3, height: 2.6 }),
4236
- mouth: blob({ centre: [12, 19.4], width: 2.4, height: 0.9, squareness: 0.6 }),
4237
- }),
4238
- embarrassed: {
4239
- ...symmetricFace({
4240
- brow: blob({ centre: [6.2, 4.8], width: 4.8, height: 1.8, squareness: 0.5, rotation: -12 }),
4241
- eye: blob({ centre: [6.4, 12.4], width: 8.2, height: 8.2 }),
4242
- pupil: blob({ centre: [4.4, 13.6], width: 3, height: 3 }),
4243
- mouth: band({ from: [9.8, 18.8], to: [14.2, 18.6], bend: -0.5, thickness: 1.1, endShare: 0.7 }),
4244
- }),
4245
- rightPupil: blob({ centre: [15.8, 13.6], width: 3, height: 3 }),
4246
- },
4247
- yearning: symmetricFace({
4248
- brow: blob({ centre: [6.2, 4], width: 4.8, height: 1.8, squareness: 0.5, rotation: -10 }),
4249
- eye: blob({ centre: [6.4, 12.6], width: 8.6, height: 8, cutTop: [11, 11] }),
4250
- pupil: blob({ centre: [6.8, 13.6], width: 4.2, height: 4.2 }),
4251
- mouth: blob({ centre: [12, 18.8], width: 2.6, height: 1.6 }),
4252
- }),
4253
- triumphant: symmetricFace({
4254
- brow: blob({ centre: [6.2, 4], width: 5.2, height: 2, squareness: 0.5, rotation: 8 }),
4255
- eye: blob({ centre: [6.4, 11.8], width: 8.6, height: 8.6, cutBottom: [14, 14] }),
4256
- pupil: blob({ centre: [6.8, 11.6], width: 3.4, height: 3.4 }),
4257
- mouth: blob({ centre: [12, 16.8], width: 7, height: 6.4, squareness: 0.3, cutTop: [16.8, 16.8] }),
4258
- }),
4259
- proud: symmetricFace({
4260
- brow: blob({ centre: [6.2, 3.8], width: 5, height: 1.9, squareness: 0.5, rotation: -4 }),
4261
- eye: blob({ centre: [6.4, 12.4], width: 8.6, height: 8, cutTop: [11, 11] }),
4262
- pupil: blob({ centre: [6.6, 13.6], width: 3.2, height: 3 }),
4263
- mouth: band({ from: [8.8, 18], to: [15.2, 18], bend: -1.2, thickness: 1.3, endShare: 0.7 }),
4264
- }),
4265
- excited: symmetricFace({
4266
- brow: band({ from: [3.4, 3.6], to: [8.6, 2.8], bend: 0.8, thickness: 1.6 }),
4267
- eye: blob({ centre: [6.4, 12], width: 9, height: 9.2 }),
4268
- pupil: blob({ centre: [7, 11], width: 4, height: 4 }),
4269
- mouth: blob({ centre: [12, 16.8], width: 6, height: 6, squareness: 0.2, cutTop: [16.8, 16.8] }),
4270
- }),
4271
- scornful: {
4272
- ...symmetricFace({
4273
- brow: blob({ centre: [6.2, 5.4], width: 5, height: 1.8, squareness: 0.5, rotation: 2 }),
4274
- eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 11.4] }),
4275
- pupil: blob({ centre: [5.4, 14], width: 3, height: 3 }),
4276
- mouth: band({ from: [9.2, 18.8], to: [14.6, 18], bend: -0.2, thickness: 1.1, endShare: 0.7 }),
4277
- }),
4278
- rightBrow: mirrored(band({ from: [3.6, 4], to: [8.6, 3.2], bend: 0.6, thickness: 1.6 })),
4279
- rightPupil: blob({ centre: [16.6, 14], width: 3, height: 3 }),
4280
- },
4281
- disgusted: symmetricFace({
4282
- brow: blob({ centre: [6.6, 6], width: 5, height: 1.8, squareness: 0.5, rotation: 14 }),
4283
- eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 12.2], cutBottom: [14.8, 14.8] }),
4284
- pupil: blob({ centre: [6.8, 13.2], width: 3, height: 2.8 }),
4285
- // an upside-down "D": open, corners pulled down
4286
- mouth: blob({ centre: [12, 19.6], width: 5, height: 4.8, squareness: 0.2, cutBottom: [19.6, 19.6] }),
4287
- }),
4288
- };
4289
- };
4290
- let builtExpressions = null;
4291
- /** Every expression's pieces, built on first use (nothing runs at import). */
4292
- const faceExpressionPieces = () => (builtExpressions ??= buildFaceExpressions());
4293
- // ---- morphing and drawing ----
4294
- const interpolatePoints = (from, to, progress) => from.map(([fromX, fromY], index) => [fromX + (to[index][0] - fromX) * progress, fromY + (to[index][1] - fromY) * progress]);
4295
- const interpolateFace = (from, to, progress) => Object.fromEntries(PIECE_NAMES.map((name) => [name, interpolatePoints(from[name], to[name], progress)]));
4296
- const boxCentre = (points) => {
4297
- const xs = points.map(([x]) => x);
4298
- const ys = points.map(([, y]) => y);
4299
- return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
4300
- };
4301
- /** The face with its eyes partly closed: openness 1 = as drawn, 0 = shut to a flat slit (pupils gone). */
4302
- const blinked = (face, openness) => {
4303
- if (openness >= 1)
4304
- return face;
4305
- const eyeScale = 0.25 + 0.75 * openness;
4306
- const squash = (points, middleY) => points.map(([x, y]) => [x, middleY + (y - middleY) * eyeScale]);
4307
- const shrink = (points, [centreX, centreY]) => points.map(([x, y]) => [centreX + (x - centreX) * openness, centreY + (y - centreY) * openness]);
4308
- const leftMiddle = boxCentre(face.leftEye)[1];
4309
- const rightMiddle = boxCentre(face.rightEye)[1];
4310
- return {
4311
- ...face,
4312
- leftEye: squash(face.leftEye, leftMiddle),
4313
- rightEye: squash(face.rightEye, rightMiddle),
4314
- leftPupil: squash(shrink(face.leftPupil, boxCentre(face.leftPupil)), leftMiddle),
4315
- rightPupil: squash(shrink(face.rightPupil, boxCentre(face.rightPupil)), rightMiddle),
4316
- };
4317
- };
4318
- const formatNumber = (value) => String(Math.round(value * 1000) / 1000);
4319
- /** A smooth closed curve through the points: each handle points along the neighbours' chord and is a third of its own
4320
- * segment long (no loops where points sit close together, as at a band's round end). Empty for a hidden piece. */
4321
- const smoothClosedPath = (points) => {
4322
- const [firstX, firstY] = points[0];
4323
- if (points.every(([x, y]) => Math.abs(x - firstX) < 1e-6 && Math.abs(y - firstY) < 1e-6))
4324
- return '';
4325
- const count = points.length;
4326
- const directionAt = (index) => {
4327
- const previous = points[(index - 1 + count) % count];
4328
- const next = points[(index + 1) % count];
4329
- const length = Math.hypot(next[0] - previous[0], next[1] - previous[1]) || 1;
4330
- return [(next[0] - previous[0]) / length, (next[1] - previous[1]) / length];
4331
- };
4332
- let path = `M${formatNumber(firstX)} ${formatNumber(firstY)}`;
4333
- for (let index = 0; index < count; index++) {
4334
- const start = points[index];
4335
- const end = points[(index + 1) % count];
4336
- const handleLength = Math.hypot(end[0] - start[0], end[1] - start[1]) / 3;
4337
- const startDirection = directionAt(index);
4338
- const endDirection = directionAt(index + 1);
4339
- const control1 = [start[0] + startDirection[0] * handleLength, start[1] + startDirection[1] * handleLength];
4340
- const control2 = [end[0] - endDirection[0] * handleLength, end[1] - endDirection[1] * handleLength];
4341
- path += `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`;
4342
- }
4343
- return `${path}Z`;
4344
- };
4345
- const facePaths = (face) => ({
4346
- features: [face.leftBrow, face.rightBrow, face.leftEye, face.rightEye, face.mouth].map(smoothClosedPath).join(''),
4347
- eyes: [face.leftEye, face.rightEye].map(smoothClosedPath).join(''),
4348
- pupils: [face.leftPupil, face.rightPupil].map(smoothClosedPath).join(''),
4349
- });
4350
- return { OUTLINE_POINTS, PIECE_NAMES, faceExpressionNames, faceExpressionPieces, interpolateFace, blinked, smoothClosedPath, facePaths };
4351
- })();
4352
-
4353
- // face.ts
4354
- const { face } = (() => {
4355
- // Layer 4: a face that morphs between feelings. 7 soft filled pieces (sets/faces.ts) — brows, eyes and mouth in the
4356
- // CSS color, the pupils in their own color and clipped to the eyes. Blinking runs on top of any expression, by itself
4357
- // every few seconds (option) or on blink().
4358
- const BLINK_CLOSING = 90;
4359
- const BLINK_SHUT = 50;
4360
- const BLINK_OPENING = 150;
4361
- const AUTO_BLINK_MIN_PAUSE = 2500;
4362
- const AUTO_BLINK_EXTRA_PAUSE = 3500;
4363
- const DEFAULT_FACE_DURATION = DEFAULT_DURATION + 100;
4364
- /** Expressions whose eyes are already shut: blinking does nothing. */
4365
- const EYES_SHUT = ['asleep', 'laughing'];
4366
- let faceCount = 0;
4367
- const checkedExpression = (state) => {
4368
- if (!faceExpressionNames.includes(state))
4369
- throw new Error(`morphico: face() has no expression "${state}"`);
4370
- return state;
4371
- };
4372
- const resolveEasing = (easing) => (typeof easing === 'function' ? easing : easings[easing ?? 'easeInOutCubic']);
4373
- /** Adds a morphing face (an <svg>) to `element`. */
4374
- const face = (element, options = {}) => {
4375
- const { state: firstState = 'normal', pupilColor = 'white', blink: blinksByItself = true, size = '100%', ...defaultMorphOptions } = options;
4376
- const expressions = faceExpressionPieces();
4377
- let state = checkedExpression(firstState);
4378
- let shownPieces = expressions[state];
4379
- let openness = 1;
4380
- const clipId = `morphico-face-${++faceCount}`;
4381
- const svg = createSvgElement('svg', { viewBox: '0 0 24 24', fill: 'currentColor', role: 'img' });
4382
- svg.style.width = size;
4383
- svg.style.height = size;
4384
- svg.style.overflow = 'visible';
4385
- const clipPath = svg.appendChild(createSvgElement('clipPath', { id: clipId }));
4386
- const eyesPath = clipPath.appendChild(createSvgElement('path'));
4387
- const featuresPath = svg.appendChild(createSvgElement('path'));
4388
- const pupilsPath = svg.appendChild(createSvgElement('path', { fill: pupilColor, 'clip-path': `url(#${clipId})` }));
4389
- const render = () => {
4390
- const paths = facePaths(blinked(shownPieces, openness));
4391
- featuresPath.setAttribute('d', paths.features);
4392
- eyesPath.setAttribute('d', paths.eyes);
4393
- pupilsPath.setAttribute('d', paths.pupils);
4394
- };
4395
- const updateLabel = () => svg.setAttribute('aria-label', state);
4396
- render();
4397
- updateLabel();
4398
- element.appendChild(svg);
4399
- // ---- morphing ----
4400
- let morph = null;
4401
- const endMorph = (interrupted) => {
4402
- if (!morph)
4403
- return;
4404
- const { stopAnimation, resolve, state: morphState } = morph;
4405
- morph = null;
4406
- stopAnimation();
4407
- resolve({ state: morphState, interrupted });
4408
- };
4409
- const morphTo = (nextState, morphOptions) => {
4410
- endMorph(true);
4411
- state = nextState;
4412
- updateLabel();
4413
- const fromPieces = shownPieces;
4414
- const toPieces = expressions[nextState];
4415
- const duration = morphOptions.duration ?? DEFAULT_FACE_DURATION;
4416
- if (prefersReducedMotion() || duration <= 0) {
4417
- shownPieces = toPieces;
4418
- render();
4419
- return Promise.resolve({ state: nextState, interrupted: false });
4420
- }
4421
- const easing = resolveEasing(morphOptions.easing);
4422
- let startTime = null;
4423
- return new Promise((resolve) => {
4424
- const stopAnimation = animate((frameTime) => {
4425
- startTime ??= frameTime;
4426
- const progress = Math.min((frameTime - startTime) / duration, 1);
4427
- shownPieces = interpolateFace(fromPieces, toPieces, easing(progress));
4428
- render();
4429
- if (progress < 1)
4430
- return true;
4431
- endMorph(false);
4432
- return false;
4433
- });
4434
- morph = { stopAnimation, resolve, state: nextState };
4435
- });
4436
- };
4437
- // ---- blinking ----
4438
- let stopBlink = null;
4439
- let finishBlink = null;
4440
- const endBlink = () => {
4441
- stopBlink?.();
4442
- stopBlink = null;
4443
- if (openness < 1) {
4444
- openness = 1;
4445
- render();
4446
- }
4447
- finishBlink?.();
4448
- finishBlink = null;
4449
- };
4450
- const blink = () => {
4451
- if (EYES_SHUT.includes(state) || prefersReducedMotion())
4452
- return Promise.resolve();
4453
- endBlink();
4454
- let startTime = null;
4455
- return new Promise((resolve) => {
4456
- finishBlink = resolve;
4457
- stopBlink = animate((frameTime) => {
4458
- startTime ??= frameTime;
4459
- const elapsed = frameTime - startTime;
4460
- if (elapsed < BLINK_CLOSING)
4461
- openness = 1 - elapsed / BLINK_CLOSING;
4462
- else if (elapsed < BLINK_CLOSING + BLINK_SHUT)
4463
- openness = 0;
4464
- else
4465
- openness = Math.min((elapsed - BLINK_CLOSING - BLINK_SHUT) / BLINK_OPENING, 1);
4466
- render();
4467
- if (openness < 1 || elapsed < BLINK_CLOSING)
4468
- return true;
4469
- stopBlink = null;
4470
- finishBlink = null;
4471
- resolve();
4472
- return false;
4473
- });
4474
- });
4475
- };
4476
- let autoBlinkTimer;
4477
- const scheduleAutoBlink = () => {
4478
- autoBlinkTimer = setTimeout(() => {
4479
- if (typeof document === 'undefined' || !document.hidden)
4480
- void blink();
4481
- scheduleAutoBlink();
4482
- }, AUTO_BLINK_MIN_PAUSE + Math.random() * AUTO_BLINK_EXTRA_PAUSE);
4483
- };
4484
- if (blinksByItself && !prefersReducedMotion())
4485
- scheduleAutoBlink();
4486
- return {
4487
- get state() { return state; },
4488
- to(nextState, { onEnd, ...morphOptions } = {}) {
4489
- const result = morphTo(checkedExpression(nextState), { ...defaultMorphOptions, ...morphOptions });
4490
- if (onEnd)
4491
- result.then(onEnd);
4492
- return result;
4493
- },
4494
- blink,
4495
- destroy() {
4496
- clearTimeout(autoBlinkTimer);
4497
- endMorph(true);
4498
- endBlink();
4499
- svg.remove();
4500
- },
4501
- };
4502
- };
4503
- return { face };
4504
- })();
4505
-
4506
- // icon.ts
4507
- const { icon } = (() => {
4508
- // Layer 1: one icon that morphs between the states of a set, e.g. a single digit.
4509
- const checkedState = (iconSet, state) => {
4510
- const normalized = state === '' ? EMPTY_STATE : state;
4511
- if (!hasState(iconSet, normalized))
4512
- throw new Error(`morphico: the icon set has no state "${state}"`);
4513
- return normalized;
4514
- };
4515
- /** Adds a morphing icon (an <svg>) to `element`. */
4516
- const icon = (element, options = {}) => {
4517
- const { set, state = EMPTY_STATE, size = '1em', roundness, strokeWidth, lineCap, lineJoin, fill, ...defaultMorphOptions } = options;
4518
- const iconSet = resolveIconSet(set, roundness);
4519
- const style = { strokeWidth, lineCap, lineJoin, fill };
4520
- const svg = createIconSvg(iconSet, '0 0 24 24', style);
4521
- svg.setAttribute('role', 'img');
4522
- svg.style.width = size;
4523
- svg.style.height = size;
4524
- svg.style.overflow = 'visible'; // sharp tips (roundness 0, miter joins) may reach past the 24 × 24 box
4525
- const pathElement = svg.appendChild(createStrokedElement('path', style));
4526
- const morphingPath = createMorphingPath(pathElement, iconSet, checkedState(iconSet, state));
4527
- const updateLabel = () => svg.setAttribute('aria-label', morphingPath.state.trim());
4528
- updateLabel();
4529
- element.appendChild(svg);
4530
- return {
4531
- get state() { return morphingPath.state; },
4532
- to(nextState, { onEnd, ...morphOptions } = {}) {
4533
- const morph = morphingPath.morphTo(checkedState(iconSet, nextState), { ...defaultMorphOptions, ...morphOptions });
4534
- updateLabel();
4535
- if (onEnd)
4536
- morph.then(onEnd);
4537
- return morph;
4538
- },
4539
- destroy() {
4540
- morphingPath.stop();
4541
- svg.remove();
4542
- },
4543
- };
4544
- };
4545
- return { icon };
4546
- })();
4547
-
4548
- // text.ts
4549
- const { text } = (() => {
4550
- // Layer 2: a line of characters (clock, counter, date) in narrow cells; changed digits morph.
4551
- // Every character reuses the 24×24 icon drawing; only the viewBox is narrowed to its cell.
4552
- /** Characters `text()` can show: digits, space, `-` `:` `.` `,` `/` — all of them morph. Anything else is removed. */
4553
- const UNSUPPORTED_CHARACTERS = /[^0-9 :.,\/-]/g;
4554
- /** Vertical part of the 24×24 grid shown in a cell: y 3…21 holds every digit including its stroke. */
4555
- const CELL_TOP = 3;
4556
- const CELL_HEIGHT = 18;
4557
- const formatNumber = (value) => String(+value.toFixed(4));
4558
- /** Adds a line of morphing characters (a <span> of <svg> cells) to `element`. */
4559
- const text = (element, options = {}) => {
4560
- const { set, value: initialValue = '', size = '1em', cellWidth, spacing, strokeWidth, lineCap, lineJoin, fill, ...defaultMorphOptions } = options;
4561
- const style = { strokeWidth, lineCap, lineJoin, fill };
4562
- const iconSet = resolveIconSet(set);
4563
- const { textLayout } = iconSet;
4564
- if (!textLayout)
4565
- throw new Error('morphico: text() needs a digit set (this set has no text layout)');
4566
- const digitCellWidth = cellWidth ?? textLayout.cellWidth;
4567
- const cellSpacing = spacing ?? textLayout.spacing;
4568
- const unitWidth = typeof strokeWidth === 'number' ? strokeWidth : iconSet.strokeWidth; // a CSS length: about the set's own
4569
- const inkBottom = textLayout.baselineY + unitWidth / 2;
4570
- const line = document.createElement('span');
4571
- line.setAttribute('role', 'img');
4572
- line.style.whiteSpace = 'nowrap';
4573
- element.appendChild(line);
4574
- let cells = [];
4575
- let value = '';
4576
- const widthOf = (character) => {
4577
- if (character === '/')
4578
- return digitCellWidth * 0.7;
4579
- return ':.,'.includes(character) ? digitCellWidth / 2 : digitCellWidth;
4580
- };
4581
- /** CSS length: `units` grid units at the line's size. */
4582
- const scaled = (units) => `calc(${size} * ${formatNumber(units / CELL_HEIGHT)})`;
4583
- const createCell = (character, index) => {
4584
- const width = widthOf(character);
4585
- const svg = createIconSvg(iconSet, `${formatNumber(12 - width / 2)} ${CELL_TOP} ${formatNumber(width)} ${CELL_HEIGHT}`, style);
4586
- svg.setAttribute('aria-hidden', 'true');
4587
- Object.assign(svg.style, {
4588
- width: scaled(width),
4589
- height: size,
4590
- overflow: 'visible',
4591
- verticalAlign: scaled(inkBottom - CELL_TOP - CELL_HEIGHT), // digit bottom on the text baseline
4592
- marginLeft: index ? `var(--morphico-spacing, ${scaled(cellSpacing)})` : '0',
4593
- });
4594
- const morphingPath = createMorphingPath(svg.appendChild(createStrokedElement('path', style)), iconSet, character);
4595
- return { svg, morphingPath, width };
4596
- };
4597
- const rebuild = (characters) => {
4598
- for (const cell of cells)
4599
- cell.morphingPath.stop();
4600
- cells = characters.map(createCell);
4601
- line.replaceChildren(...cells.map((cell) => cell.svg));
4602
- };
4603
- const show = (nextValue, { onEnd, ...morphOptions } = {}) => {
4604
- value = [...nextValue.replace(UNSUPPORTED_CHARACTERS, '')].filter((character) => hasState(iconSet, character)).join('');
4605
- line.setAttribute('aria-label', value.trim());
4606
- const characters = [...value];
4607
- const sameLayout = characters.length === cells.length
4608
- && characters.every((character, index) => widthOf(character) === cells[index].width);
4609
- let change;
4610
- if (sameLayout) {
4611
- const morphs = cells.map((cell, index) => cell.morphingPath.morphTo(characters[index], { ...defaultMorphOptions, ...morphOptions }));
4612
- const finishedValue = value;
4613
- change = Promise.all(morphs).then((results) => ({ value: finishedValue, interrupted: results.some((result) => result.interrupted) }));
4614
- }
4615
- else {
4616
- rebuild(characters);
4617
- change = Promise.resolve({ value, interrupted: false });
4618
- }
4619
- if (onEnd)
4620
- change.then(onEnd);
4621
- return change;
4622
- };
4623
- show(initialValue);
4624
- return {
4625
- get value() { return value; },
4626
- set: show,
4627
- destroy() {
4628
- for (const cell of cells)
4629
- cell.morphingPath.stop();
4630
- line.remove();
4631
- },
4632
- };
4633
- };
4634
- return { text };
4635
- })();
4636
-
4637
- // index.ts
4638
- // morphico public API.
4639
- /** Package version, e.g. "0.1.0" (tools/build.ts replaces 'dev'). */
4640
- const version = '0.4.0';
4641
-
4642
- export { clip, easings, face, icon, iconSets, faceExpressionNames, text, version };