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