morphico 0.1.0 → 0.2.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 +41 -6
- package/dist/morphico.js +2020 -98
- package/dist/morphico.mjs +2020 -98
- 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 +1 -0
- 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/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.mjs
CHANGED
|
@@ -1,4 +1,44 @@
|
|
|
1
|
-
/*! morphico v0.
|
|
1
|
+
/*! morphico v0.2.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
|
// engine.ts
|
|
3
43
|
const { easings, animate } = (() => {
|
|
4
44
|
// Animation engine: easings and one shared requestAnimationFrame loop for every running morph.
|
|
@@ -29,7 +69,7 @@ return { easings, animate };
|
|
|
29
69
|
})();
|
|
30
70
|
|
|
31
71
|
// geometry.ts
|
|
32
|
-
const { line, arc, cubic, pointOnCircle, ellipse, roundedPolyline, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, defaultShapes, shapeForPair } = (() => {
|
|
72
|
+
const { line, arc, cubic, reversedStroke, pointOnCircle, ellipse, roundedPolyline, roundedPolygon, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, piecesOf, pieceLengthOf, splitPieces, defaultShapes, flattenOntoLine, shapeForPair, trimPiece, wipeRange, wipePiece, drawnPieces, withDrawnPieces } = (() => {
|
|
33
73
|
const DEGREE = Math.PI / 180;
|
|
34
74
|
const ICON_CENTER = 12;
|
|
35
75
|
/** Largest arc one cubic segment may cover: up to 90° the cubic is visually exact. */
|
|
@@ -40,6 +80,15 @@ const line = (fromX, fromY, toX, toY) => ({ kind: 'line', from: [fromX, fromY],
|
|
|
40
80
|
/** Angles in degrees. Because y points down, `endAngle > startAngle` draws clockwise on screen. */
|
|
41
81
|
const arc = (centerX, centerY, radius, startAngle, endAngle) => ({ kind: 'arc', center: [centerX, centerY], radius, startAngle, endAngle });
|
|
42
82
|
const cubic = (startX, startY, control1X, control1Y, control2X, control2Y, endX, endY) => ({ kind: 'cubic', segment: [[startX, startY], [control1X, control1Y], [control2X, control2Y], [endX, endY]] });
|
|
83
|
+
/** The same line or arc, drawn in the opposite direction. */
|
|
84
|
+
const reversedStroke = (stroke) => {
|
|
85
|
+
if (stroke.kind === 'line')
|
|
86
|
+
return { ...stroke, from: stroke.to, to: stroke.from };
|
|
87
|
+
if (stroke.kind === 'arc')
|
|
88
|
+
return { ...stroke, startAngle: stroke.endAngle, endAngle: stroke.startAngle };
|
|
89
|
+
const [start, control1, control2, end] = stroke.segment;
|
|
90
|
+
return { kind: 'cubic', segment: [end, control2, control1, start] };
|
|
91
|
+
};
|
|
43
92
|
const pointOnCircle = (centerX, centerY, radius, angle) => [centerX + radius * Math.cos(angle * DEGREE), centerY + radius * Math.sin(angle * DEGREE)];
|
|
44
93
|
/** Control handle length of a quarter ellipse as a fraction of its radius (4/3 · (√2 − 1)). */
|
|
45
94
|
const QUARTER_HANDLE = (4 / 3) * (Math.SQRT2 - 1);
|
|
@@ -95,6 +144,38 @@ const roundedPolyline = (points, radii) => {
|
|
|
95
144
|
}
|
|
96
145
|
return strokes;
|
|
97
146
|
};
|
|
147
|
+
/**
|
|
148
|
+
* A closed polygon with every corner rounded by `radius` (clamped per corner so neighbouring arcs never overlap).
|
|
149
|
+
* Always a line and an arc per corner, whatever the radius, so pair layouts (stroke indices) stay valid; at radius 0
|
|
150
|
+
* the arc shrinks to the corner point, so the corner is really sharp (and `stroke-linejoin` decides how it looks).
|
|
151
|
+
* Starts after the first corner with a line (`startsWithArc`: with the arc of the first corner), clockwise if the
|
|
152
|
+
* corners are listed clockwise on screen.
|
|
153
|
+
*/
|
|
154
|
+
const roundedPolygon = (corners, radius, startsWithArc = false) => {
|
|
155
|
+
const cornerCount = corners.length;
|
|
156
|
+
const roundedCorners = corners.map((corner, index) => {
|
|
157
|
+
const previous = corners[(index + cornerCount - 1) % cornerCount], next = corners[(index + 1) % cornerCount];
|
|
158
|
+
const incoming = unitVector(previous, corner), outgoing = unitVector(corner, next);
|
|
159
|
+
const turn = Math.atan2(incoming[0] * outgoing[1] - incoming[1] * outgoing[0], incoming[0] * outgoing[0] + incoming[1] * outgoing[1]);
|
|
160
|
+
const halfTurnTangent = Math.tan(Math.abs(turn) / 2);
|
|
161
|
+
const shortestHalfEdge = Math.min(distanceBetween(previous, corner), distanceBetween(corner, next)) / 2;
|
|
162
|
+
const cornerRadius = Math.max(0, Math.min(radius, shortestHalfEdge / halfTurnTangent));
|
|
163
|
+
const tangentDistance = cornerRadius * halfTurnTangent;
|
|
164
|
+
const arcStart = [corner[0] - incoming[0] * tangentDistance, corner[1] - incoming[1] * tangentDistance];
|
|
165
|
+
const arcEnd = [corner[0] + outgoing[0] * tangentDistance, corner[1] + outgoing[1] * tangentDistance];
|
|
166
|
+
const inward = turn > 0 ? [-incoming[1], incoming[0]] : [incoming[1], -incoming[0]];
|
|
167
|
+
const center = [arcStart[0] + inward[0] * cornerRadius, arcStart[1] + inward[1] * cornerRadius];
|
|
168
|
+
const startAngle = Math.atan2(arcStart[1] - center[1], arcStart[0] - center[0]) / DEGREE;
|
|
169
|
+
return { arcStart, arcEnd, arc: arc(center[0], center[1], cornerRadius, startAngle, startAngle + turn / DEGREE) };
|
|
170
|
+
});
|
|
171
|
+
const strokes = roundedCorners.flatMap((roundedCorner, index) => {
|
|
172
|
+
const next = roundedCorners[(index + 1) % cornerCount];
|
|
173
|
+
return [line(...roundedCorner.arcEnd, ...next.arcStart), next.arc];
|
|
174
|
+
});
|
|
175
|
+
// the list above starts after corner 0 with a line and ends with corner 0's arc
|
|
176
|
+
return startsWithArc ? [strokes[strokes.length - 1], ...strokes.slice(0, -1)] : strokes;
|
|
177
|
+
};
|
|
178
|
+
const distanceBetween = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
98
179
|
const unitVector = (from, to) => {
|
|
99
180
|
const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
100
181
|
return [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
|
|
@@ -117,10 +198,34 @@ const reorderSegments = (path, { startSegment = 0, reversed = false } = {}) => {
|
|
|
117
198
|
};
|
|
118
199
|
const interpolate = (from, to, progress) => from.map((value, index) => value + (to[index] - value) * progress);
|
|
119
200
|
const formatNumber = (value) => String(+value.toFixed(3));
|
|
120
|
-
/**
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
201
|
+
/** A piece whose end lands on its start is closed (`Z`): its first corner then gets the line join, not two line ends. */
|
|
202
|
+
const isClosedPiece = (piece) => Math.hypot(piece[piece.length - 2] - piece[0], piece[piece.length - 1] - piece[1]) < 1e-6;
|
|
203
|
+
const hasLength = ([start, control1, control2, end]) => [control1, control2, end].some((point) => Math.hypot(point[0] - start[0], point[1] - start[1]) > 1e-6);
|
|
204
|
+
/**
|
|
205
|
+
* The segments of a piece as written to SVG. A closed piece starts at its first segment with length: a zero-length
|
|
206
|
+
* first segment (a corner of radius 0) has no direction, so browsers would draw the closing corner wrongly.
|
|
207
|
+
* Only the written order changes, not the picture.
|
|
208
|
+
*/
|
|
209
|
+
const writtenSegments = (piece) => {
|
|
210
|
+
const segments = toSegments(piece);
|
|
211
|
+
if (!isClosedPiece(piece))
|
|
212
|
+
return segments;
|
|
213
|
+
const firstWithLength = Math.max(0, segments.findIndex(hasLength));
|
|
214
|
+
return [...segments.slice(firstWithLength), ...segments.slice(0, firstWithLength)];
|
|
215
|
+
};
|
|
216
|
+
/** A piece shrunk to a single point: not drawn at all (round line ends would show it as a dot). */
|
|
217
|
+
const isPoint = (piece) => piece.every((value, index) => Math.abs(value - piece[index % 2]) < 1e-6);
|
|
218
|
+
/**
|
|
219
|
+
* SVG `d` attribute: per piece `M x y` followed by one `C` per segment (`pieceLength`: see pieceLengthOf).
|
|
220
|
+
* Pieces shrunk to a single point are left out.
|
|
221
|
+
*/
|
|
222
|
+
const toPathData = (path, pieceLength = path.length) => splitPieces(path, pieceLength).filter((piece) => !isPoint(piece)).map((piece) => {
|
|
223
|
+
const segments = writtenSegments(piece);
|
|
224
|
+
const [startX, startY] = segments[0][0];
|
|
225
|
+
return `M${formatNumber(startX)} ${formatNumber(startY)}` + segments
|
|
226
|
+
.map(([, control1, control2, end]) => `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`)
|
|
227
|
+
.join('') + (isClosedPiece(piece) ? 'Z' : '');
|
|
228
|
+
}).join('');
|
|
124
229
|
/** Shrinks (factor < 1) or grows the whole shape around the icon center (12, 12). */
|
|
125
230
|
const scaleAroundCenter = (path, factor) => path.map((value) => ICON_CENTER + (value - ICON_CENTER) * factor);
|
|
126
231
|
/** Rotates the whole shape around the icon center (12, 12). */
|
|
@@ -230,20 +335,363 @@ const defaultBreakpoints = (strokes, segmentCount) => {
|
|
|
230
335
|
return breakpoints;
|
|
231
336
|
};
|
|
232
337
|
// ---- icon sets ----
|
|
338
|
+
// A shape of a set with several pieces is the path numbers of every piece one after another.
|
|
339
|
+
/** The set itself is the first piece. */
|
|
340
|
+
const piecesOf = (iconSet) => [iconSet, ...(iconSet.morePieces ?? [])];
|
|
341
|
+
/** Path numbers of one piece: start point + 6 per segment. */
|
|
342
|
+
const pieceLengthOf = (iconSet) => 2 + 6 * iconSet.segmentCount;
|
|
343
|
+
/** Splits a shape into the path numbers of its pieces. */
|
|
344
|
+
const splitPieces = (path, pieceLength) => Array.from({ length: path.length / pieceLength }, (_, pieceIndex) => path.slice(pieceIndex * pieceLength, (pieceIndex + 1) * pieceLength));
|
|
345
|
+
const strokesOf = (pieceSet, state, pieceIndex) => {
|
|
346
|
+
const strokes = pieceSet.strokes[state];
|
|
347
|
+
if (!strokes)
|
|
348
|
+
throw new Error(`state "${state}" has no strokes in piece ${pieceIndex}`);
|
|
349
|
+
return strokes;
|
|
350
|
+
};
|
|
233
351
|
/** Every state of the set laid out with its default breakpoints. */
|
|
234
|
-
const defaultShapes = (iconSet) => Object.fromEntries(Object.
|
|
235
|
-
|
|
236
|
-
|
|
352
|
+
const defaultShapes = (iconSet) => Object.fromEntries(Object.keys(iconSet.strokes).map((state) => [state, piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => {
|
|
353
|
+
const strokes = strokesOf(pieceSet, state, pieceIndex);
|
|
354
|
+
return layoutStrokes(strokes, defaultBreakpoints(strokes, iconSet.segmentCount));
|
|
355
|
+
})]));
|
|
356
|
+
/**
|
|
357
|
+
* `shape` pressed flat onto the straight line `from` → `to`: every point moves onto the line, keeping its position
|
|
358
|
+
* along the line's direction (the shape's extent along it is stretched to the line's length).
|
|
359
|
+
*/
|
|
360
|
+
const flattenOntoLine = (shape, from, to) => {
|
|
361
|
+
const direction = [to[0] - from[0], to[1] - from[1]];
|
|
362
|
+
const positions = Array.from({ length: shape.length / 2 }, (_, pointIndex) => shape[2 * pointIndex] * direction[0] + shape[2 * pointIndex + 1] * direction[1]);
|
|
363
|
+
const lowest = Math.min(...positions), highest = Math.max(...positions);
|
|
364
|
+
return positions.flatMap((position) => {
|
|
365
|
+
const along = highest - lowest > EPSILON ? (position - lowest) / (highest - lowest) : 0.5;
|
|
366
|
+
return [from[0] + direction[0] * along, from[1] + direction[1] * along];
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
/** The shape of `state` laid out for morphing to or from `otherState` (each piece by its own pair layout). */
|
|
237
370
|
const shapeForPair = (iconSet, shapes, state, otherState) => {
|
|
238
|
-
const
|
|
371
|
+
const pieceLength = pieceLengthOf(iconSet);
|
|
372
|
+
const pieceForPair = (pieceSet, pieceIndex, pieceState, pieceOther, mayFlatten) => {
|
|
373
|
+
const defaultPiece = splitPieces(shapes[pieceState], pieceLength)[pieceIndex];
|
|
374
|
+
const pairLayout = pieceSet.pairLayouts[pieceState]?.[pieceOther];
|
|
375
|
+
if (!pairLayout)
|
|
376
|
+
return defaultPiece;
|
|
377
|
+
if ((pairLayout.draw || pairLayout.wipe) && mayFlatten) {
|
|
378
|
+
// not there: the other side's piece shrunk to its start point (not drawn); the morph draws it (see drawnPieces)
|
|
379
|
+
const otherPiece = pieceForPair(pieceSet, pieceIndex, pieceOther, pieceState, false);
|
|
380
|
+
return Array.from({ length: pieceLength }, (_, index) => otherPiece[index % 2]);
|
|
381
|
+
}
|
|
382
|
+
if (pairLayout.flatten && mayFlatten) {
|
|
383
|
+
const [line] = strokesOf(pieceSet, pieceState, pieceIndex);
|
|
384
|
+
if (line.kind !== 'line')
|
|
385
|
+
throw new Error(`flatten: state "${pieceState}" must be one straight line`);
|
|
386
|
+
// the other side as it is laid out for this pair (if it flattens too, its plain layout)
|
|
387
|
+
return flattenOntoLine(pieceForPair(pieceSet, pieceIndex, pieceOther, pieceState, false), line.from, line.to);
|
|
388
|
+
}
|
|
389
|
+
const strokes = pairLayout.tracing ? pieceSet.alternativeStrokes[pairLayout.tracing] : strokesOf(pieceSet, pieceState, pieceIndex);
|
|
390
|
+
const laidOut = pairLayout.breakpoints ? layoutStrokes(strokes, pairLayout.breakpoints) : defaultPiece;
|
|
391
|
+
const reordered = reorderSegments(laidOut, pairLayout);
|
|
392
|
+
return pairLayout.rotation ? rotateAroundCenter(reordered, pairLayout.rotation) : reordered;
|
|
393
|
+
};
|
|
394
|
+
return piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => pieceForPair(pieceSet, pieceIndex, state, otherState, true));
|
|
395
|
+
};
|
|
396
|
+
// ---- drawing a piece along its path (PairLayout.draw) ----
|
|
397
|
+
/** Rough length of a cubic segment: the average of its chord and its control polygon. */
|
|
398
|
+
const segmentLength = ([start, control1, control2, end]) => {
|
|
399
|
+
const distance = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
400
|
+
return (distance(start, end) + distance(start, control1) + distance(control1, control2) + distance(control2, end)) / 2;
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* The part of a piece between two shares of its length (0…1). The segments outside shrink to the part's ends,
|
|
404
|
+
* so the number count stays the same.
|
|
405
|
+
*/
|
|
406
|
+
const pieceBetween = (piece, fromFraction, toFraction) => {
|
|
407
|
+
const segments = toSegments(piece);
|
|
408
|
+
const lengths = segments.map(segmentLength);
|
|
409
|
+
const totalLength = lengths.reduce((sum, length) => sum + length, 0);
|
|
410
|
+
const from = fromFraction * totalLength, to = toFraction * totalLength;
|
|
411
|
+
let travelled = 0;
|
|
412
|
+
const parts = segments.map((segment, index) => {
|
|
413
|
+
const segmentStart = travelled;
|
|
414
|
+
travelled += lengths[index];
|
|
415
|
+
if (!lengths[index] || travelled <= from || segmentStart >= to)
|
|
416
|
+
return null;
|
|
417
|
+
const startProgress = Math.max(0, (from - segmentStart) / lengths[index]);
|
|
418
|
+
const endProgress = Math.min(1, (to - segmentStart) / lengths[index]);
|
|
419
|
+
const [head] = splitCubic(segment, endProgress);
|
|
420
|
+
return startProgress > 0 ? splitCubic(head, startProgress / endProgress)[1] : head;
|
|
421
|
+
});
|
|
422
|
+
// before the part: its start point; after (or between): the end of the part drawn so far
|
|
423
|
+
let pen = parts.find((part) => part)?.[0] ?? segments[0][0];
|
|
424
|
+
return fromSegments(parts.map((part) => {
|
|
425
|
+
if (!part)
|
|
426
|
+
return [pen, pen, pen, pen];
|
|
427
|
+
pen = part[3];
|
|
428
|
+
return part;
|
|
429
|
+
}));
|
|
430
|
+
};
|
|
431
|
+
/** The first `fraction` (0…1) of a piece along its length, as if a pen had drawn that far. */
|
|
432
|
+
const trimPiece = (piece, fraction) => pieceBetween(piece, 0, Math.min(Math.max(fraction, 0), 1));
|
|
433
|
+
/** The x range a wipe's edge moves through (the icon box's drawing area, line width included). */
|
|
434
|
+
const WIPE_FROM_X = 2;
|
|
435
|
+
const WIPE_TO_X = 22;
|
|
436
|
+
const WIPE_SAMPLES_PER_SEGMENT = 40;
|
|
437
|
+
/** Points along a piece as [share of its length, x]. */
|
|
438
|
+
const xAlong = (piece) => {
|
|
439
|
+
const segments = toSegments(piece);
|
|
440
|
+
const lengths = segments.map(segmentLength);
|
|
441
|
+
const totalLength = lengths.reduce((sum, length) => sum + length, 0) || 1;
|
|
442
|
+
const samples = [];
|
|
443
|
+
let travelled = 0;
|
|
444
|
+
segments.forEach((segment, index) => {
|
|
445
|
+
for (let step = 0; step <= WIPE_SAMPLES_PER_SEGMENT; step++) {
|
|
446
|
+
const progress = step / WIPE_SAMPLES_PER_SEGMENT;
|
|
447
|
+
const [x] = progress < 1 ? splitCubic(segment, progress)[1][0] : segment[3];
|
|
448
|
+
samples.push([(travelled + progress * lengths[index]) / totalLength, x]);
|
|
449
|
+
}
|
|
450
|
+
travelled += lengths[index];
|
|
451
|
+
});
|
|
452
|
+
return samples;
|
|
453
|
+
};
|
|
454
|
+
/**
|
|
455
|
+
* The x range a morph's wipe edge moves through: the box (x 2…22), or — if a piece slides in — from the box's side
|
|
456
|
+
* to that piece's leading side at rest, so the edge rides at its front and it starts just outside the box.
|
|
457
|
+
*/
|
|
458
|
+
const wipeRange = (slidingPiece, direction) => {
|
|
459
|
+
if (!slidingPiece)
|
|
460
|
+
return [WIPE_FROM_X, WIPE_TO_X];
|
|
461
|
+
const xs = xAlong(slidingPiece).map(([, x]) => x);
|
|
462
|
+
return direction === 'right' ? [WIPE_FROM_X, Math.max(...xs)] : [Math.min(...xs), WIPE_TO_X];
|
|
463
|
+
};
|
|
464
|
+
/**
|
|
465
|
+
* A piece behind a vertical edge (PairLayout.wipe): at `fraction` 0…1 of the wipe, the edge has moved that far
|
|
466
|
+
* through `edgeRange` in `direction`, and only the part it has passed shows. That part must be one stretch of the
|
|
467
|
+
* path. `slides` (PairLayout.slide): the piece moves with the edge from outside the box; only its part inside shows.
|
|
468
|
+
*/
|
|
469
|
+
const wipePiece = (piece, fraction, direction, edgeRange = [WIPE_FROM_X, WIPE_TO_X], slides = false) => {
|
|
470
|
+
if (fraction >= 1)
|
|
471
|
+
return piece.slice();
|
|
472
|
+
const [fromX, toX] = edgeRange;
|
|
473
|
+
if (slides) {
|
|
474
|
+
// in the piece's own frame the box's side moves the other way: the leading part shows first
|
|
475
|
+
const shift = (1 - Math.max(fraction, 0)) * (toX - fromX) * (direction === 'right' ? -1 : 1);
|
|
476
|
+
return wipePiece(piece, fraction, direction === 'right' ? 'left' : 'right', edgeRange)
|
|
477
|
+
.map((value, index) => (index % 2 ? value : value + shift));
|
|
478
|
+
}
|
|
479
|
+
const travel = Math.max(fraction, 0) * (toX - fromX);
|
|
480
|
+
const edge = direction === 'right' ? fromX + travel : toX - travel;
|
|
481
|
+
// samples along the length: [share of the length, > 0 where the edge has passed]
|
|
482
|
+
const samples = xAlong(piece).map(([share, x]) => [share, direction === 'right' ? edge - x : x - edge]);
|
|
483
|
+
const first = samples.findIndex(([, passed]) => passed >= 0);
|
|
484
|
+
if (first < 0)
|
|
485
|
+
return pieceBetween(piece, 0, 0);
|
|
486
|
+
let last = samples.length - 1;
|
|
487
|
+
while (samples[last][1] < 0)
|
|
488
|
+
last--;
|
|
489
|
+
// where the edge crosses between two samples
|
|
490
|
+
const crossing = ([shareA, passedA], [shareB, passedB]) => shareA + (shareB - shareA) * (passedA / (passedA - passedB));
|
|
491
|
+
const from = first > 0 ? crossing(samples[first - 1], samples[first]) : 0;
|
|
492
|
+
const to = last < samples.length - 1 ? crossing(samples[last], samples[last + 1]) : 1;
|
|
493
|
+
return pieceBetween(piece, from, to);
|
|
494
|
+
};
|
|
495
|
+
/** The pieces drawn (or erased) along their path when morphing `state` → `otherState` (PairLayout.draw). */
|
|
496
|
+
const drawnPieces = (iconSet, state, otherState) => piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => {
|
|
497
|
+
const windowOf = (draw) => (Array.isArray(draw) ? draw : typeof draw === 'number' ? [1 - draw, 1] : [0, 1]);
|
|
498
|
+
// the layout of the state where the piece is absent
|
|
499
|
+
const absentIn = (absentState, presentState) => {
|
|
500
|
+
const pairLayout = pieceSet.pairLayouts[absentState]?.[presentState];
|
|
501
|
+
return pairLayout?.draw || pairLayout?.wipe ? pairLayout : undefined;
|
|
502
|
+
};
|
|
503
|
+
const drawIn = absentIn(state, otherState);
|
|
504
|
+
const pairLayout = drawIn ?? absentIn(otherState, state);
|
|
239
505
|
if (!pairLayout)
|
|
240
|
-
return
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
506
|
+
return [];
|
|
507
|
+
const [start, end] = windowOf(pairLayout.draw || true);
|
|
508
|
+
return [{ pieceIndex, drawsIn: Boolean(drawIn), start, end, wipe: pairLayout.wipe, slide: pairLayout.slide }];
|
|
509
|
+
});
|
|
510
|
+
/**
|
|
511
|
+
* A frame of a morph `from` → `to` at `progress` with the drawn pieces replaced: drawn in from the start along
|
|
512
|
+
* `to`'s piece, or erased back along `from`'s piece.
|
|
513
|
+
*/
|
|
514
|
+
const withDrawnPieces = (frame, from, to, progress, pieceLength, drawn) => {
|
|
515
|
+
if (!drawn.length)
|
|
516
|
+
return frame;
|
|
517
|
+
const presentPiece = ({ pieceIndex, drawsIn }) => (drawsIn ? to : from).slice(pieceIndex * pieceLength, (pieceIndex + 1) * pieceLength);
|
|
518
|
+
// one edge for every wipe of the morph, riding at the front of the (first) sliding piece
|
|
519
|
+
const slider = drawn.find((piece) => piece.slide && piece.wipe);
|
|
520
|
+
const edgeRange = wipeRange(slider ? presentPiece(slider) : null, slider?.wipe ?? 'right');
|
|
521
|
+
const result = frame.slice();
|
|
522
|
+
for (const drawnPiece of drawn) {
|
|
523
|
+
const { pieceIndex, drawsIn, start, end, wipe, slide } = drawnPiece;
|
|
524
|
+
const present = presentPiece(drawnPiece);
|
|
525
|
+
// erasing is drawing in played backwards
|
|
526
|
+
const fraction = ((drawsIn ? progress : 1 - progress) - start) / (end - start);
|
|
527
|
+
result.splice(pieceIndex * pieceLength, pieceLength, ...(wipe ? wipePiece(present, fraction, wipe, edgeRange, slide) : trimPiece(present, fraction)));
|
|
528
|
+
}
|
|
529
|
+
return result;
|
|
530
|
+
};
|
|
531
|
+
return { line, arc, cubic, reversedStroke, pointOnCircle, ellipse, roundedPolyline, roundedPolygon, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, piecesOf, pieceLengthOf, splitPieces, defaultShapes, flattenOntoLine, shapeForPair, trimPiece, wipeRange, wipePiece, drawnPieces, withDrawnPieces };
|
|
532
|
+
})();
|
|
533
|
+
|
|
534
|
+
// sets/lucide.ts
|
|
535
|
+
const { LUCIDE_SCALE, LUCIDE_STROKE_WIDTH, fromLucide, lucideLength, lucideLine, lucideArc, lucideCubic, lucidePoint, LUCIDE_SLASH } = (() => {
|
|
536
|
+
// Shared by every Lucide-based icon set: Lucide draws in y 3…21 (18 units) with a 2-unit line; our icons get the
|
|
537
|
+
// same room as the digits (16 units, y 4…20; user's choice 2026-10-26), so every icon is scaled around the centre
|
|
538
|
+
// 12,12 — line width included, so each icon looks exactly like Lucide's, only with more margin in its box.
|
|
539
|
+
const LUCIDE_SCALE = 16 / 18;
|
|
540
|
+
/** Lucide's 2-unit line, scaled with the icon. */
|
|
541
|
+
const LUCIDE_STROKE_WIDTH = 2 * LUCIDE_SCALE;
|
|
542
|
+
/** A point (or corner) of a Lucide icon moved into our icon box. */
|
|
543
|
+
const fromLucide = ([x, y]) => [12 + (x - 12) * LUCIDE_SCALE, 12 + (y - 12) * LUCIDE_SCALE];
|
|
544
|
+
/** A length of a Lucide icon (e.g. a corner radius) in our icon box. */
|
|
545
|
+
const lucideLength = (length) => length * LUCIDE_SCALE;
|
|
546
|
+
// Strokes written in Lucide coordinates (for the icon sets added from 2026-10-28 on).
|
|
547
|
+
/** A straight line between two Lucide points. */
|
|
548
|
+
const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
|
|
549
|
+
/** A circular arc in Lucide coordinates (angles in degrees, `endAngle > startAngle` = clockwise on screen). */
|
|
550
|
+
const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
|
|
551
|
+
/** A cubic Bézier curve in Lucide coordinates. */
|
|
552
|
+
const lucideCubic = (...coordinates) => {
|
|
553
|
+
const [startX, startY, control1X, control1Y, control2X, control2Y, endX, endY] = coordinates;
|
|
554
|
+
return cubic(...fromLucide([startX, startY]), ...fromLucide([control1X, control1Y]), ...fromLucide([control2X, control2Y]), ...fromLucide([endX, endY]));
|
|
245
555
|
};
|
|
246
|
-
|
|
556
|
+
/** A missing piece at rest: a single point, never drawn (see toPathData). */
|
|
557
|
+
const lucidePoint = (x, y) => [lucideLine(x, y, x, y)];
|
|
558
|
+
/** The diagonal slash of Lucide's "-off" icons, from the top left. */
|
|
559
|
+
const LUCIDE_SLASH = [lucideLine(2, 2, 22, 22)];
|
|
560
|
+
return { LUCIDE_SCALE, LUCIDE_STROKE_WIDTH, fromLucide, lucideLength, lucideLine, lucideArc, lucideCubic, lucidePoint, LUCIDE_SLASH };
|
|
561
|
+
})();
|
|
562
|
+
|
|
563
|
+
// sets/check-mark.ts
|
|
564
|
+
const { checkMarkPiece } = (() => {
|
|
565
|
+
// The check mark shared by "checkbox" and "radio" (Lucide 1.61.0 "square-check" / "circle-check" and their "-big"
|
|
566
|
+
// versions draw the same checks; ISC, see LICENSE-lucide). Coordinates are Lucide's, scaled by fromLucide.
|
|
567
|
+
// Drawn left to right, short stroke then long stroke. unchecked has no check: the check is drawn in like a pen
|
|
568
|
+
// writing it — down the short stroke, up the long one (the user's sketch; PairLayout.draw) — and erased back.
|
|
569
|
+
/** A straight line between two Lucide points, moved into our icon box. */
|
|
570
|
+
const lucideLine = (from, to) => line(...fromLucide(from), ...fromLucide(to));
|
|
571
|
+
const CHECK = [lucideLine([8, 12], [10.5, 14.5]), lucideLine([10.5, 14.5], [16, 9])];
|
|
572
|
+
const BIG_CHECK = [lucideLine([9, 11], [12, 14]), lucideLine([12, 14], [22, 4])];
|
|
573
|
+
// every check ↔ check: short stroke 4 segments, long stroke 8 (of 12), so the corners meet
|
|
574
|
+
const CHECK_FOR_CHECK = { breakpoints: [0, 0.25, 0.5, 0.75, 1, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.875, 2] };
|
|
575
|
+
const CHECK_STATES = ['unchecked', 'checked', 'checked-big'];
|
|
576
|
+
/**
|
|
577
|
+
* The check piece of a set with the states unchecked, checked, checked-big (12 segments).
|
|
578
|
+
* `hiddenAt`: a Lucide point on the outline at the check's height, where unchecked's check rests at rest
|
|
579
|
+
* (tiny, under the outline's line, so it doesn't show — morphs draw the check instead).
|
|
580
|
+
*/
|
|
581
|
+
const checkMarkPiece = (hiddenAt) => {
|
|
582
|
+
const [x, y] = hiddenAt;
|
|
583
|
+
return {
|
|
584
|
+
strokes: {
|
|
585
|
+
unchecked: [lucideLine([x, y - 0.1], [x, y]), lucideLine([x, y], [x, y + 0.1])],
|
|
586
|
+
checked: CHECK,
|
|
587
|
+
'checked-big': BIG_CHECK,
|
|
588
|
+
},
|
|
589
|
+
alternativeStrokes: {},
|
|
590
|
+
pairLayouts: {
|
|
591
|
+
...Object.fromEntries(CHECK_STATES.map((state) => [state, Object.fromEntries(CHECK_STATES.filter((other) => other !== state).map((other) => [other, CHECK_FOR_CHECK]))])),
|
|
592
|
+
unchecked: { checked: { draw: true }, 'checked-big': { draw: true } },
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
};
|
|
596
|
+
return { checkMarkPiece };
|
|
597
|
+
})();
|
|
598
|
+
|
|
599
|
+
// sets/checkbox.ts
|
|
600
|
+
const { checkboxIcons } = (() => {
|
|
601
|
+
// Icon set "checkbox": unchecked, checked, checked-big — Lucide 1.61.0 icons "square", "square-check",
|
|
602
|
+
// "square-check-big" (ISC, see LICENSE-lucide). Coordinates below are Lucide's; fromLucide / lucideLength scale them
|
|
603
|
+
// into our icon box (more margin, like the digits — see lucide.ts). Built from rounded corners, so `roundness` works.
|
|
604
|
+
// Two pieces:
|
|
605
|
+
// piece 1, the box: the same square in unchecked and checked (it doesn't move); checked-big opens its top right
|
|
606
|
+
// corner where the big check leaves the box — both ends of the gap pull back to the gap's edges.
|
|
607
|
+
// piece 2, the check (check-mark.ts, shared with "radio"): drawn in like a pen from unchecked (the user's sketch).
|
|
608
|
+
// (Rejected: a box copy folding into the check — tangled; the check sliding in from the left edge — user: bad.)
|
|
609
|
+
const SQUARE_CORNERS = [[3, 3], [21, 3], [21, 21], [3, 21]].map(fromLucide);
|
|
610
|
+
const LUCIDE_RADIUS = lucideLength(2);
|
|
611
|
+
/** square-check-big's box: the square without its top right corner (gap from 17.344,3 to 21,10.656). */
|
|
612
|
+
const openBox = (square) => {
|
|
613
|
+
const [top, , right, bottomRight, bottom, bottomLeft, left, topLeft] = square;
|
|
614
|
+
return [
|
|
615
|
+
line(...fromLucide([21, 10.656]), ...right.to),
|
|
616
|
+
bottomRight, bottom, bottomLeft, left, topLeft,
|
|
617
|
+
line(...top.from, ...fromLucide([17.344, 3])),
|
|
618
|
+
];
|
|
619
|
+
};
|
|
620
|
+
// box, checked/unchecked ↔ checked-big: the square laid out so its 12 segments line up with the open box's —
|
|
621
|
+
// top (2: the part that stays, the part in the gap), top right corner (2, in the gap), right side (2: in the gap,
|
|
622
|
+
// the part that stays), bottom right, bottom, bottom left, left (2), top left — started at the corner's second half.
|
|
623
|
+
// The open box gets two zero-length segments at each end of the gap, where the vanishing parts pull back to.
|
|
624
|
+
const SQUARE_FOR_OPEN_BOX = { breakpoints: [0, 0.8817, 1, 1.5, 2, 2.404, 3, 4, 5, 6, 6.5, 7, 8], startSegment: 3 };
|
|
625
|
+
const OPEN_BOX_FOR_SQUARE = { breakpoints: [0, 0, 0, 1, 2, 3, 4, 4.5, 5, 6, 7, 7, 7] };
|
|
626
|
+
/** The check rests (hidden) on the box's left edge. */
|
|
627
|
+
const CHECK_PIECE = checkMarkPiece([3, 12]);
|
|
628
|
+
const buildCheckboxIcons = (radius) => {
|
|
629
|
+
const square = roundedPolygon(SQUARE_CORNERS, radius);
|
|
630
|
+
return {
|
|
631
|
+
segmentCount: 12,
|
|
632
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
633
|
+
lineCap: 'round',
|
|
634
|
+
lineJoin: 'round',
|
|
635
|
+
strokes: { unchecked: square, checked: square, 'checked-big': openBox(square) },
|
|
636
|
+
alternativeStrokes: {},
|
|
637
|
+
pairLayouts: {
|
|
638
|
+
unchecked: { 'checked-big': SQUARE_FOR_OPEN_BOX },
|
|
639
|
+
checked: { 'checked-big': SQUARE_FOR_OPEN_BOX },
|
|
640
|
+
'checked-big': { unchecked: OPEN_BOX_FOR_SQUARE, checked: OPEN_BOX_FOR_SQUARE },
|
|
641
|
+
},
|
|
642
|
+
morePieces: [CHECK_PIECE],
|
|
643
|
+
spins: {},
|
|
644
|
+
withRoundness: (roundness) => buildCheckboxIcons(LUCIDE_RADIUS * roundness),
|
|
645
|
+
};
|
|
646
|
+
};
|
|
647
|
+
const checkboxIcons = buildCheckboxIcons(LUCIDE_RADIUS);
|
|
648
|
+
return { checkboxIcons };
|
|
649
|
+
})();
|
|
650
|
+
|
|
651
|
+
// sets/characters.ts
|
|
652
|
+
const { withTextCharacters } = (() => {
|
|
653
|
+
// The characters every digit set shares besides 0–9: `-` `/` `.` `,` `:` (space = the empty state, see morph.ts).
|
|
654
|
+
// Each is drawn as one straight line, placed by the set's text layout and stroke width, and morphs to and from
|
|
655
|
+
// every digit by flattening (the user's choice 2026-10-27): the digit is pressed flat onto the line.
|
|
656
|
+
// `:` has two dots, so digit sets get a second piece: for every other state it repeats the first piece exactly
|
|
657
|
+
// (same strokes and layouts, drawn on top of itself, so it doesn't show).
|
|
658
|
+
const DIGITS = [...'0123456789'];
|
|
659
|
+
/** Half the length of a dot's line, relative to the stroke width: round ends make it a dot 1.1 × the stroke wide. */
|
|
660
|
+
const DOT_HALF_LENGTH = 0.05;
|
|
661
|
+
/** Adds `-` `/` `.` `,` `:` to a digit set (which needs a text layout) as morphing states. */
|
|
662
|
+
const withTextCharacters = (iconSet) => {
|
|
663
|
+
const { baselineY, colonDotsY } = iconSet.textLayout;
|
|
664
|
+
const dotHalfLength = iconSet.strokeWidth * DOT_HALF_LENGTH;
|
|
665
|
+
const dotY = baselineY; // dot centre on the line the digits stand on
|
|
666
|
+
const dot = (y) => line(12 - dotHalfLength, y, 12 + dotHalfLength, y);
|
|
667
|
+
const characters = {
|
|
668
|
+
'-': [line(9.5, 12, 14.5, 12)],
|
|
669
|
+
'/': [line(9.5, baselineY, 14.5, 24 - baselineY)],
|
|
670
|
+
'.': [dot(dotY)],
|
|
671
|
+
',': [line(12.3, dotY, 11, baselineY + 2.2)], // from the dot down to the left
|
|
672
|
+
':': [dot(colonDotsY[0])],
|
|
673
|
+
};
|
|
674
|
+
const flattenPairs = (layouts) => {
|
|
675
|
+
const merged = { ...layouts };
|
|
676
|
+
for (const character of Object.keys(characters)) {
|
|
677
|
+
merged[character] = { ...merged[character], ...Object.fromEntries(DIGITS.map((digit) => [digit, { flatten: true }])) };
|
|
678
|
+
}
|
|
679
|
+
return merged;
|
|
680
|
+
};
|
|
681
|
+
const pairLayouts = flattenPairs(iconSet.pairLayouts);
|
|
682
|
+
const strokes = { ...iconSet.strokes, ...characters };
|
|
683
|
+
return {
|
|
684
|
+
...iconSet,
|
|
685
|
+
strokes,
|
|
686
|
+
pairLayouts,
|
|
687
|
+
morePieces: [{
|
|
688
|
+
strokes: { ...strokes, ':': [dot(colonDotsY[1])] },
|
|
689
|
+
alternativeStrokes: iconSet.alternativeStrokes,
|
|
690
|
+
pairLayouts,
|
|
691
|
+
}],
|
|
692
|
+
};
|
|
693
|
+
};
|
|
694
|
+
return { withTextCharacters };
|
|
247
695
|
})();
|
|
248
696
|
|
|
249
697
|
// sets/condensed.ts
|
|
@@ -276,7 +724,8 @@ const HOOK_END_Y = 79;
|
|
|
276
724
|
// the 6: hook at the top right, over the top, down the left side, round the bottom, up to the middle bar, back left
|
|
277
725
|
const SIX_POINTS = [[47, 94 - HOOK_END_Y], [47, 3], [3, 3], [3, 91], [47, 91], [47, 47], [3, 47]];
|
|
278
726
|
const SIX_RADII = [0, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0];
|
|
279
|
-
|
|
727
|
+
// '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
|
|
728
|
+
const condensedDigits = withTextCharacters({
|
|
280
729
|
segmentCount: 20,
|
|
281
730
|
strokeWidth: 6 / PIXELS_PER_UNIT,
|
|
282
731
|
lineCap: 'round',
|
|
@@ -301,7 +750,6 @@ const condensedDigits = {
|
|
|
301
750
|
8: drawn(50, [[3 + MIDDLE_RADIUS, 47], [3, 47], [3, 3], [47, 3], [47, 47], [3, 47], [3, 91], [47, 91], [47, 47], [3 + MIDDLE_RADIUS, 47]], [0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0]),
|
|
302
751
|
// the 6 turned 180°, drawn the other way round (box first, then the small upturn)
|
|
303
752
|
9: roundedPolyline(SIX_POINTS.map(fromImage(50)).map(turnedAround).reverse(), [...SIX_RADII].reverse().map((radius) => radius / PIXELS_PER_UNIT)),
|
|
304
|
-
'-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
|
|
305
753
|
},
|
|
306
754
|
alternativeStrokes: {
|
|
307
755
|
// the 8 traced like the 0 (from the top, counter-clockwise): left side of the top box down to the middle of
|
|
@@ -381,10 +829,84 @@ const condensedDigits = {
|
|
|
381
829
|
},
|
|
382
830
|
spins: { 6: { 9: 180 }, 9: { 6: -180 } },
|
|
383
831
|
textLayout: { cellWidth: 8, spacing: 3.5, baselineY: 20, colonDotsY: [8, 16] },
|
|
384
|
-
};
|
|
832
|
+
});
|
|
385
833
|
return { condensedDigits };
|
|
386
834
|
})();
|
|
387
835
|
|
|
836
|
+
// sets/folder.ts
|
|
837
|
+
const { folderIcons } = (() => {
|
|
838
|
+
// Icon set "folder": closed, open — Lucide 1.61.0 icons "folder", "folder-open" (ISC, see LICENSE-lucide), imported
|
|
839
|
+
// with `node tools/import-lucide.ts folder folder-open`. Coordinates below are Lucide's; lucideLine / lucideArc scale
|
|
840
|
+
// them into our icon box (more margin, like the digits — see lucide.ts).
|
|
841
|
+
// One piece each. The open folder is one line: the front flap (from its left tip over its top edge, down its right
|
|
842
|
+
// edge), the bottom, the left side, the tab, the back panel's top edge and a short bit of its right side.
|
|
843
|
+
// closed ↔ open: the closed folder is traced the open folder's way ("folder-like-open", same picture) — starting at
|
|
844
|
+
// its top edge, which it runs along twice: the first pass drops and tilts into the flap's top edge, the second stays
|
|
845
|
+
// as the back panel's top edge.
|
|
846
|
+
const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
|
|
847
|
+
const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
|
|
848
|
+
/** Lucide "folder": counter-clockwise from the bottom right corner. */
|
|
849
|
+
const FOLDER = [
|
|
850
|
+
lucideArc(20, 18, 2, 90, 0), // bottom right corner
|
|
851
|
+
lucideLine(22, 18, 22, 8), // right side, up
|
|
852
|
+
lucideArc(20, 8, 2, 0, -90), // top right corner
|
|
853
|
+
lucideLine(20, 6, 12.1, 6), // top edge, to the left
|
|
854
|
+
lucideArc(12.0804, 4.0001, 2, 89.4383, 146.6363),
|
|
855
|
+
lucideLine(10.41, 5.1, 9.6, 3.9), // tab slope
|
|
856
|
+
lucideArc(7.9297, 5, 2, -33.367, -89.9906),
|
|
857
|
+
lucideLine(7.93, 3, 4, 3), // tab top
|
|
858
|
+
lucideArc(4, 5, 2, -90, -180), // top left corner
|
|
859
|
+
lucideLine(2, 5, 2, 18), // left side, down
|
|
860
|
+
lucideArc(4, 18, 2, 180, 90), // bottom left corner
|
|
861
|
+
lucideLine(4, 20, 20, 20), // bottom
|
|
862
|
+
];
|
|
863
|
+
/** Lucide "folder-open": from the flap's left tip. */
|
|
864
|
+
const FOLDER_OPEN = [
|
|
865
|
+
lucideLine(6, 14, 7.5, 11.1), // flap's left tip
|
|
866
|
+
lucideArc(9.2863, 11.9995, 2, -153.2735, -91.3273),
|
|
867
|
+
lucideLine(9.24, 10, 20, 10), // flap's top edge
|
|
868
|
+
lucideArc(20.0035, 12, 2, -90.1005, 14.4776),
|
|
869
|
+
lucideLine(21.94, 12.5, 20.4, 18.5), // flap's right edge
|
|
870
|
+
lucideArc(18.4635, 18, 2, 14.4762, 90.3867),
|
|
871
|
+
lucideLine(18.45, 20, 4, 20), // bottom
|
|
872
|
+
lucideArc(4, 18, 2, 90, 180),
|
|
873
|
+
lucideLine(2, 18, 2, 5), // left side, up
|
|
874
|
+
lucideArc(4, 5, 2, 180, 270),
|
|
875
|
+
lucideLine(4, 3, 7.9, 3), // tab top
|
|
876
|
+
lucideArc(7.9196, 4.9999, 2, -90.5617, -33.3637),
|
|
877
|
+
lucideLine(9.59, 3.9, 10.4, 5.1), // tab slope
|
|
878
|
+
lucideArc(12.0703, 4, 2, 146.633, 90.0094),
|
|
879
|
+
lucideLine(12.07, 6, 18, 6), // back panel's top edge
|
|
880
|
+
lucideArc(18, 8, 2, -90, 0),
|
|
881
|
+
lucideLine(20, 8, 20, 10), // back panel's right side, down to the flap
|
|
882
|
+
];
|
|
883
|
+
const [bottomRight, rightSide, topRight, topEdge, tabArc1, tabSlope, tabArc2, tabTop, topLeft, leftSide, bottomLeft, bottom] = FOLDER.map(reversedStroke); // clockwise, like the open folder
|
|
884
|
+
/** The closed folder traced like the open one: top edge (→ flap), round the outline, top edge again (→ back panel). */
|
|
885
|
+
const FOLDER_LIKE_OPEN = [
|
|
886
|
+
topEdge, topRight, rightSide, bottomRight, bottom, bottomLeft, leftSide, topLeft,
|
|
887
|
+
tabTop, tabArc2, tabSlope, tabArc1, topEdge, topRight, lucideLine(22, 8, 22, 10),
|
|
888
|
+
];
|
|
889
|
+
const folderIcons = {
|
|
890
|
+
segmentCount: 20,
|
|
891
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
892
|
+
lineCap: 'round',
|
|
893
|
+
lineJoin: 'round',
|
|
894
|
+
strokes: { closed: FOLDER, open: FOLDER_OPEN },
|
|
895
|
+
alternativeStrokes: { 'folder-like-open': FOLDER_LIKE_OPEN },
|
|
896
|
+
// fitted with tools/check.ts (cost 7838 → 411)
|
|
897
|
+
pairLayouts: {
|
|
898
|
+
closed: {
|
|
899
|
+
open: {
|
|
900
|
+
tracing: 'folder-like-open',
|
|
901
|
+
breakpoints: [0, 0, 0, 1, 2, 2.5208, 3, 4, 4.5625, 5, 6, 6.5, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
spins: {},
|
|
906
|
+
};
|
|
907
|
+
return { folderIcons };
|
|
908
|
+
})();
|
|
909
|
+
|
|
388
910
|
// sets/handwritten.ts
|
|
389
911
|
const { handwrittenDigits } = (() => {
|
|
390
912
|
// Digit set "handwritten": hand-drawn marker digits, traced from the reference images in icons/handwritten/
|
|
@@ -397,7 +919,8 @@ const { handwrittenDigits } = (() => {
|
|
|
397
919
|
// the centre line — the marker stroke varies a little, so the pixel match is ~8–17 %).
|
|
398
920
|
// - every digit is one stroke of 3–7 free cubic curves; round ends and corners
|
|
399
921
|
// - the 4 is drawn out and back over its crossbar end and right stem (one continuous stroke)
|
|
400
|
-
|
|
922
|
+
// '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
|
|
923
|
+
const handwrittenDigits = withTextCharacters({
|
|
401
924
|
segmentCount: 16,
|
|
402
925
|
strokeWidth: 1.04,
|
|
403
926
|
lineCap: 'round',
|
|
@@ -469,7 +992,6 @@ const handwrittenDigits = {
|
|
|
469
992
|
cubic(14.01, 8.61, 14.16, 8.23, 14.66, 6.7, 15.3, 7.44),
|
|
470
993
|
cubic(15.3, 7.44, 16.93, 9.31, 14.47, 18.24, 11.59, 19.86),
|
|
471
994
|
],
|
|
472
|
-
'-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
|
|
473
995
|
},
|
|
474
996
|
alternativeStrokes: {},
|
|
475
997
|
// optimizer fits (tools/check.ts) for the pairs the user rated bad / midd / not sure
|
|
@@ -519,10 +1041,172 @@ const handwrittenDigits = {
|
|
|
519
1041
|
},
|
|
520
1042
|
spins: {},
|
|
521
1043
|
textLayout: { cellWidth: 9, spacing: 4, baselineY: 20.2, colonDotsY: [8, 16] },
|
|
522
|
-
};
|
|
1044
|
+
});
|
|
523
1045
|
return { handwrittenDigits };
|
|
524
1046
|
})();
|
|
525
1047
|
|
|
1048
|
+
// sets/media.ts
|
|
1049
|
+
const { mediaIcons } = (() => {
|
|
1050
|
+
// Icon set "media": play, pause, stop — Lucide 1.61.0 icons (ISC, see LICENSE-lucide).
|
|
1051
|
+
// First imported with `node tools/import-lucide.ts play pause square`, then rewritten as rounded polygons so the
|
|
1052
|
+
// corners can be made sharper (`withRoundness`); with Lucide's radii (play 2, pause 1, stop 2) the shapes match
|
|
1053
|
+
// the import within 0.005 units (Lucide's own rounding).
|
|
1054
|
+
// Two pieces: pause has two bars; play and stop draw their single shape in both pieces (the second copy lies
|
|
1055
|
+
// exactly on the first, so it doesn't show — during a morph to pause the two copies separate into the bars).
|
|
1056
|
+
// Every shape runs clockwise from its top left, like Lucide's. Coordinates below are Lucide's; fromLucide /
|
|
1057
|
+
// lucideLength scale them into our icon box (more margin, like the digits — see lucide.ts).
|
|
1058
|
+
/** Lucide's play: a triangle (left side x 5, tip at the right) with rounded corners, starting with the top left arc. */
|
|
1059
|
+
const PLAY_CORNERS = [[5, 1.5174], [22.9706, 11.9998], [5, 22.4827]].map(fromLucide);
|
|
1060
|
+
const RIGHT_BAR_CORNERS = [[14, 3], [19, 3], [19, 21], [14, 21]].map(fromLucide);
|
|
1061
|
+
const LEFT_BAR_CORNERS = [[5, 3], [10, 3], [10, 21], [5, 21]].map(fromLucide);
|
|
1062
|
+
const SQUARE_CORNERS = [[3, 3], [21, 3], [21, 21], [3, 21]].map(fromLucide);
|
|
1063
|
+
const LUCIDE_RADII = { play: lucideLength(2), pause: lucideLength(1), stop: lucideLength(2) };
|
|
1064
|
+
// play ↔ stop (both pieces, the user's sketch): corner to corner, edge to edge, and the tip opens up and down
|
|
1065
|
+
// into the square's right side. Play (top left corner, top, tip, bottom, bottom left corner, left):
|
|
1066
|
+
// 2 + 1 + 4 + 1 + 2 + 2 segments. Stop is laid out from its top edge and then started at its top left corner,
|
|
1067
|
+
// so its segments line up: top left 2, top 1, top right 1, right side 2, bottom right 1, bottom 1, bottom left 2, left 2.
|
|
1068
|
+
const PLAY_FOR_STOP = { breakpoints: [0, 0.5, 1, 2, 2.25, 2.5, 2.75, 3, 4, 4.5, 5, 5.5, 6] };
|
|
1069
|
+
const STOP_FOR_PLAY = { breakpoints: [0, 1, 2, 2.5, 3, 4, 5, 5.5, 6, 6.5, 7, 7.5, 8], startSegment: 10 };
|
|
1070
|
+
const buildMediaIcons = (radii) => {
|
|
1071
|
+
const play = roundedPolygon(PLAY_CORNERS, radii.play, true);
|
|
1072
|
+
const stop = roundedPolygon(SQUARE_CORNERS, radii.stop);
|
|
1073
|
+
return {
|
|
1074
|
+
segmentCount: 12,
|
|
1075
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
1076
|
+
lineCap: 'round',
|
|
1077
|
+
lineJoin: 'round',
|
|
1078
|
+
strokes: { play, pause: roundedPolygon(RIGHT_BAR_CORNERS, radii.pause), stop },
|
|
1079
|
+
alternativeStrokes: {},
|
|
1080
|
+
// play ↔ stop: the user's sketch (above); play ↔ pause, pause ↔ stop: tools/check.ts
|
|
1081
|
+
pairLayouts: {
|
|
1082
|
+
play: { pause: { breakpoints: [0, 0.7292, 1, 1.6458, 2, 2.25, 3, 3.3125, 3.3125, 4, 4.2708, 5, 6] }, stop: PLAY_FOR_STOP },
|
|
1083
|
+
pause: { play: { startSegment: 10 }, stop: { startSegment: 11 } },
|
|
1084
|
+
stop: { pause: { breakpoints: [0, 0.8333, 1, 2, 2.2917, 2.7083, 3, 4, 4.1667, 5, 6, 7, 8] }, play: STOP_FOR_PLAY },
|
|
1085
|
+
},
|
|
1086
|
+
morePieces: [
|
|
1087
|
+
{
|
|
1088
|
+
strokes: { play, pause: roundedPolygon(LEFT_BAR_CORNERS, radii.pause), stop },
|
|
1089
|
+
// only the triangle's left side, traced down and back up like the left bar's loop (top left corner out
|
|
1090
|
+
// and back, left edge down, bottom left corner out and back, left edge up): the left bar inflates from
|
|
1091
|
+
// that edge, while the first piece (the whole triangle) opens into the right bar
|
|
1092
|
+
alternativeStrokes: {
|
|
1093
|
+
'play-left-side': [
|
|
1094
|
+
play[0], reversedStroke(play[0]), reversedStroke(play[5]), reversedStroke(play[4]), play[4], play[5],
|
|
1095
|
+
],
|
|
1096
|
+
},
|
|
1097
|
+
pairLayouts: {
|
|
1098
|
+
play: {
|
|
1099
|
+
pause: { tracing: 'play-left-side', breakpoints: [0, 0.7292, 1, 1.7292, 2, 3, 3.7292, 4, 4.7292, 5, 5.7708, 6, 6] },
|
|
1100
|
+
stop: PLAY_FOR_STOP,
|
|
1101
|
+
},
|
|
1102
|
+
pause: {
|
|
1103
|
+
stop: { breakpoints: [0, 1, 2, 2.0208, 2.9792, 3, 4, 5, 6, 6.3333, 6.6667, 7, 8] },
|
|
1104
|
+
},
|
|
1105
|
+
stop: { pause: { breakpoints: [0, 0.1667, 1, 2, 3, 4, 4.8333, 5, 6, 6.2917, 6.7083, 7, 8] }, play: STOP_FOR_PLAY },
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
],
|
|
1109
|
+
spins: {},
|
|
1110
|
+
withRoundness: (roundness) => buildMediaIcons({
|
|
1111
|
+
play: LUCIDE_RADII.play * roundness, pause: LUCIDE_RADII.pause * roundness, stop: LUCIDE_RADII.stop * roundness,
|
|
1112
|
+
}),
|
|
1113
|
+
};
|
|
1114
|
+
};
|
|
1115
|
+
const mediaIcons = buildMediaIcons(LUCIDE_RADII);
|
|
1116
|
+
return { mediaIcons };
|
|
1117
|
+
})();
|
|
1118
|
+
|
|
1119
|
+
// sets/radio.ts
|
|
1120
|
+
const { radioIcons } = (() => {
|
|
1121
|
+
// Icon set "radio": unchecked, checked, checked-big — Lucide 1.61.0 icons "circle", "circle-check",
|
|
1122
|
+
// "circle-check-big" (ISC, see LICENSE-lucide). Coordinates below are Lucide's; fromLucide / lucideLength scale them
|
|
1123
|
+
// into our icon box (more margin, like the digits — see lucide.ts). Works like "checkbox", with a circle for the box.
|
|
1124
|
+
// Two pieces:
|
|
1125
|
+
// piece 1, the circle (radius 10 around 12,12): the same full circle in unchecked and checked (it doesn't move);
|
|
1126
|
+
// checked-big opens a gap at the top right (from 17,3.335 clockwise round to 21.801,10 is the open arc) where
|
|
1127
|
+
// the big check leaves the circle — both ends of the gap pull back to the gap's edges.
|
|
1128
|
+
// piece 2, the check (check-mark.ts, shared with "checkbox"): drawn in like a pen from unchecked.
|
|
1129
|
+
const [CENTER_X, CENTER_Y] = fromLucide([12, 12]);
|
|
1130
|
+
const RADIUS = lucideLength(10);
|
|
1131
|
+
/** The open arc of circle-check-big runs clockwise from 21.801,10 to 17,3.335 (the gap is the rest). */
|
|
1132
|
+
const OPEN_START_ANGLE = (Math.atan2(10 - 12, 21.801 - 12) * 180) / Math.PI;
|
|
1133
|
+
const OPEN_END_ANGLE = (Math.atan2(3.335 - 12, 17 - 12) * 180) / Math.PI + 360;
|
|
1134
|
+
/** The share of the full circle that stays in checked-big. */
|
|
1135
|
+
const OPEN_SHARE = (OPEN_END_ANGLE - OPEN_START_ANGLE) / 360;
|
|
1136
|
+
/** The full circle starts where the open arc starts, so the gap is its last part. */
|
|
1137
|
+
const CIRCLE = [arc(CENTER_X, CENTER_Y, RADIUS, OPEN_START_ANGLE, OPEN_START_ANGLE + 360)];
|
|
1138
|
+
const OPEN_CIRCLE = [arc(CENTER_X, CENTER_Y, RADIUS, OPEN_START_ANGLE, OPEN_END_ANGLE)];
|
|
1139
|
+
// circle, unchecked/checked ↔ checked-big: the open part 10 segments, the gap 2 (one per half), started at the gap's
|
|
1140
|
+
// second half — so it lines up with the open arc, which gets a zero-length segment at each end of the gap where
|
|
1141
|
+
// the vanishing halves pull back to.
|
|
1142
|
+
const openPart = Array.from({ length: 11 }, (_, index) => +((index / 10) * OPEN_SHARE).toFixed(4));
|
|
1143
|
+
const CIRCLE_FOR_OPEN_CIRCLE = { breakpoints: [...openPart, +((1 + OPEN_SHARE) / 2).toFixed(4), 1], startSegment: 11 };
|
|
1144
|
+
const OPEN_CIRCLE_FOR_CIRCLE = { breakpoints: [0, ...Array.from({ length: 11 }, (_, index) => index / 10), 1] };
|
|
1145
|
+
const radioIcons = {
|
|
1146
|
+
segmentCount: 12,
|
|
1147
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
1148
|
+
lineCap: 'round',
|
|
1149
|
+
lineJoin: 'round',
|
|
1150
|
+
strokes: { unchecked: CIRCLE, checked: CIRCLE, 'checked-big': OPEN_CIRCLE },
|
|
1151
|
+
alternativeStrokes: {},
|
|
1152
|
+
pairLayouts: {
|
|
1153
|
+
unchecked: { 'checked-big': CIRCLE_FOR_OPEN_CIRCLE },
|
|
1154
|
+
checked: { 'checked-big': CIRCLE_FOR_OPEN_CIRCLE },
|
|
1155
|
+
'checked-big': { unchecked: OPEN_CIRCLE_FOR_CIRCLE, checked: OPEN_CIRCLE_FOR_CIRCLE },
|
|
1156
|
+
},
|
|
1157
|
+
// the check rests (hidden) on the circle's left edge
|
|
1158
|
+
morePieces: [checkMarkPiece([2, 12])],
|
|
1159
|
+
spins: {},
|
|
1160
|
+
};
|
|
1161
|
+
return { radioIcons };
|
|
1162
|
+
})();
|
|
1163
|
+
|
|
1164
|
+
// sets/menu.ts
|
|
1165
|
+
const { menuIcons } = (() => {
|
|
1166
|
+
// Icon set "menu": menu (☰), close (✕), back (←) — Lucide 1.61.0 icons "menu", "x", "arrow-left" (ISC, see
|
|
1167
|
+
// LICENSE-lucide), imported with `node tools/import-lucide.ts menu x arrow-left`. Coordinates below are Lucide's;
|
|
1168
|
+
// fromLucide scales them into our icon box (more margin, like the digits — see lucide.ts).
|
|
1169
|
+
// Three pieces, each one straight line, lined up so the lines of every state correspond:
|
|
1170
|
+
// piece 1: top line | "\" of the ✕ | upper half of the arrowhead
|
|
1171
|
+
// piece 2: middle line | hidden on the ✕'s centre | the arrow's shaft
|
|
1172
|
+
// piece 3: bottom line | "/" of the ✕ | lower half of the arrowhead
|
|
1173
|
+
// Lucide draws the arrowhead as one bent line; two lines meeting with round ends look the same.
|
|
1174
|
+
// Every line runs from left to right, so lines move without turning over.
|
|
1175
|
+
/** A straight line between two Lucide points, moved into our icon box. */
|
|
1176
|
+
const lucideLine = (from, to) => [line(...fromLucide(from), ...fromLucide(to))];
|
|
1177
|
+
const piece = (strokes, extras = { alternativeStrokes: {}, pairLayouts: {} }) => ({ strokes, ...extras });
|
|
1178
|
+
// close ↔ back: the ✕ drawn with its lines swapped ("/" in piece 1, "\" in piece 3), so each line only shortens
|
|
1179
|
+
// and tilts into its half of the arrowhead instead of turning over
|
|
1180
|
+
const BACKSLASH = lucideLine([6, 6], [18, 18]);
|
|
1181
|
+
const SLASH = lucideLine([6, 18], [18, 6]);
|
|
1182
|
+
const menuIcons = {
|
|
1183
|
+
segmentCount: 1,
|
|
1184
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
1185
|
+
lineCap: 'round',
|
|
1186
|
+
lineJoin: 'round',
|
|
1187
|
+
...piece({
|
|
1188
|
+
menu: lucideLine([4, 5], [20, 5]),
|
|
1189
|
+
close: BACKSLASH,
|
|
1190
|
+
back: lucideLine([5, 12], [12, 5]),
|
|
1191
|
+
}, { alternativeStrokes: { 'close-swapped': SLASH }, pairLayouts: { close: { back: { tracing: 'close-swapped', breakpoints: [0, 1] } } } }),
|
|
1192
|
+
morePieces: [
|
|
1193
|
+
piece({
|
|
1194
|
+
menu: lucideLine([4, 12], [20, 12]),
|
|
1195
|
+
// a tiny line on the centre of the ✕'s "\": hidden under it
|
|
1196
|
+
close: lucideLine([11.5, 11.5], [12.5, 12.5]),
|
|
1197
|
+
back: lucideLine([5, 12], [19, 12]),
|
|
1198
|
+
}),
|
|
1199
|
+
piece({
|
|
1200
|
+
menu: lucideLine([4, 19], [20, 19]),
|
|
1201
|
+
close: SLASH,
|
|
1202
|
+
back: lucideLine([5, 12], [12, 19]),
|
|
1203
|
+
}, { alternativeStrokes: { 'close-swapped': BACKSLASH }, pairLayouts: { close: { back: { tracing: 'close-swapped', breakpoints: [0, 1] } } } }),
|
|
1204
|
+
],
|
|
1205
|
+
spins: {},
|
|
1206
|
+
};
|
|
1207
|
+
return { menuIcons };
|
|
1208
|
+
})();
|
|
1209
|
+
|
|
526
1210
|
// sets/round.ts
|
|
527
1211
|
const { roundDigits } = (() => {
|
|
528
1212
|
// Digit set "round": geometric sans digits, drafted from the reference images in icons/round/
|
|
@@ -559,7 +1243,8 @@ const TWO_DIAGONAL_ANGLE = 39.5191;
|
|
|
559
1243
|
const FIVE_JOIN_ANGLE = 237.2;
|
|
560
1244
|
/** The 7's foot: on the measured diagonal (16, 4.2 → 7.45, 20.6), pulled in to the base line. */
|
|
561
1245
|
const SEVEN_FOOT = pointAtY([16, TOP_BAR_Y], [7.45, 20.6], BASE_Y);
|
|
562
|
-
|
|
1246
|
+
// '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
|
|
1247
|
+
const roundDigits = withTextCharacters({
|
|
563
1248
|
segmentCount: 12,
|
|
564
1249
|
strokeWidth: STROKE_WIDTH,
|
|
565
1250
|
lineCap: 'round',
|
|
@@ -600,7 +1285,6 @@ const roundDigits = {
|
|
|
600
1285
|
arc(12, 8.4, BIG_RADIUS, SIX_TAIL_ANGLE + 180 - 360, SIX_TAIL_ANGLE + 180),
|
|
601
1286
|
line(...pointOnCircle(12, 8.4, BIG_RADIUS, SIX_TAIL_ANGLE + 180), ...NINE_TAIL_END),
|
|
602
1287
|
],
|
|
603
|
-
'-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
|
|
604
1288
|
},
|
|
605
1289
|
alternativeStrokes: {
|
|
606
1290
|
// the 8 traced like the 0 (from the top, counter-clockwise): left half of the top circle down to the waist,
|
|
@@ -765,7 +1449,7 @@ const roundDigits = {
|
|
|
765
1449
|
9: { 6: -180, 8: -180 },
|
|
766
1450
|
},
|
|
767
1451
|
textLayout: { cellWidth: 10, spacing: 4, baselineY: 19.8, colonDotsY: [7.6, 15.6] },
|
|
768
|
-
};
|
|
1452
|
+
});
|
|
769
1453
|
return { roundDigits };
|
|
770
1454
|
})();
|
|
771
1455
|
|
|
@@ -805,7 +1489,8 @@ const SIX = [
|
|
|
805
1489
|
topArc(SIX_HOOK_ANGLE, -180),
|
|
806
1490
|
...roundedPolyline([[8, 8], [8, 20], [16, 20], [16, SIX_LOOP_TOP], [8, SIX_LOOP_TOP], [8, SIX_LOOP_TOP + RADIUS]], [0, RADIUS, RADIUS, RADIUS, RADIUS, 0]),
|
|
807
1491
|
];
|
|
808
|
-
|
|
1492
|
+
// '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
|
|
1493
|
+
const slimDigits = withTextCharacters({
|
|
809
1494
|
segmentCount: 16,
|
|
810
1495
|
strokeWidth: 6 / 5.5,
|
|
811
1496
|
lineCap: 'round',
|
|
@@ -834,7 +1519,6 @@ const slimDigits = {
|
|
|
834
1519
|
8: [topArc(90, 450), bottomArc(270, -90)],
|
|
835
1520
|
// the 6 turned 180°, drawn the other way round
|
|
836
1521
|
9: SIX.map(turnedAround).reverse(),
|
|
837
|
-
'-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
|
|
838
1522
|
},
|
|
839
1523
|
alternativeStrokes: {
|
|
840
1524
|
// the 8 traced like the 0 (as in the round set): from the top counter-clockwise — left half of TOP down to the waist,
|
|
@@ -915,7 +1599,7 @@ const slimDigits = {
|
|
|
915
1599
|
// 9 is exactly 6 turned 180°; 2 → 6 also turns (it is 2 → 9 in the turning frame)
|
|
916
1600
|
spins: { 2: { 6: 180 }, 6: { 9: 180, 2: -180 }, 9: { 6: -180 } },
|
|
917
1601
|
textLayout: { cellWidth: 9.5, spacing: 4, baselineY: 20, colonDotsY: [8, 16] },
|
|
918
|
-
};
|
|
1602
|
+
});
|
|
919
1603
|
return { slimDigits };
|
|
920
1604
|
})();
|
|
921
1605
|
|
|
@@ -948,7 +1632,8 @@ const EIGHT_RADIUS = 6;
|
|
|
948
1632
|
// the 6: hook from the top right, round the left side, then the closed bottom loop back to the left side
|
|
949
1633
|
const SIX_POINTS = [[15, 2], [2, 2], [2, 26], [19, 26], [19, 12], [2, 12]];
|
|
950
1634
|
const SIX_RADII = [0, SIX_RADIUS, SIX_RADIUS, SIX_RADIUS, SIX_RADIUS, 0];
|
|
951
|
-
|
|
1635
|
+
// '-' '/' '.' ',' ':' are added by withTextCharacters (characters.ts)
|
|
1636
|
+
const squareDigits = withTextCharacters({
|
|
952
1637
|
segmentCount: 20,
|
|
953
1638
|
strokeWidth: STROKE_WIDTH,
|
|
954
1639
|
lineCap: 'round',
|
|
@@ -973,7 +1658,6 @@ const squareDigits = {
|
|
|
973
1658
|
8: drawn(20, [[7, 14], [3, 14], [3, 2], [17, 2], [17, 14], [2, 14], [2, 26], [18, 26], [18, 14], [7, 14]], [0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0]),
|
|
974
1659
|
// the 6 turned 180°, drawn the other way round (loop first, then the hook)
|
|
975
1660
|
9: roundedPolyline(SIX_POINTS.map(fromImage(21)).map(turnedAround).reverse(), [...SIX_RADII].reverse().map((radius) => radius / PIXELS_PER_UNIT)),
|
|
976
|
-
'-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
|
|
977
1661
|
},
|
|
978
1662
|
alternativeStrokes: {
|
|
979
1663
|
// the 8 traced like the 0 (from the top, counter-clockwise): left side of the top loop down to the middle of
|
|
@@ -1066,10 +1750,1219 @@ const squareDigits = {
|
|
|
1066
1750
|
},
|
|
1067
1751
|
spins: { 6: { 9: 180 }, 9: { 6: -180 } },
|
|
1068
1752
|
textLayout: { cellWidth: 13.5, spacing: 4, baselineY: 20, colonDotsY: [8, 16] },
|
|
1069
|
-
};
|
|
1753
|
+
});
|
|
1070
1754
|
return { squareDigits };
|
|
1071
1755
|
})();
|
|
1072
1756
|
|
|
1757
|
+
// sets/theme.ts
|
|
1758
|
+
const { themeIcons } = (() => {
|
|
1759
|
+
// Icon set "theme": light (sun), dark (moon) — Lucide 1.61.0 icons "sun", "moon" (ISC, see LICENSE-lucide).
|
|
1760
|
+
// Coordinates below are Lucide's; lucideLine / lucideArc / lucideCubic scale them into our icon box (see lucide.ts).
|
|
1761
|
+
// Nine pieces:
|
|
1762
|
+
// piece 1: the sun's circle | the moon's crescent — the circle grows into the moon and its top right quarter
|
|
1763
|
+
// flattens and caves in evenly into the inner curve (the circle starts where the moon's outer arc starts).
|
|
1764
|
+
// pieces 2–9: one ray each | nothing — rays run from their outer end inwards; light → dark: they fly outwards and
|
|
1765
|
+
// vanish (erased towards their outer tip, PairLayout.draw — the user's sketch) within the first 60 % of the
|
|
1766
|
+
// morph; dark → light: they fly back in during the last 60 %. At rest the moon's ray pieces are tiny lines hidden
|
|
1767
|
+
// on its outline.
|
|
1768
|
+
const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
|
|
1769
|
+
const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
|
|
1770
|
+
const lucideCubic = (...coordinates) => {
|
|
1771
|
+
const [startX, startY, control1X, control1Y, control2X, control2Y, endX, endY] = coordinates;
|
|
1772
|
+
return cubic(...fromLucide([startX, startY]), ...fromLucide([control1X, control1Y]), ...fromLucide([control2X, control2Y]), ...fromLucide([endX, endY]));
|
|
1773
|
+
};
|
|
1774
|
+
/** Lucide "moon": the outer arc clockwise from its right end, the top tip, the inner arc back, the right tip. */
|
|
1775
|
+
const MOON = [
|
|
1776
|
+
lucideArc(11.9981, 12.0009, 9, 3.09, 266.904),
|
|
1777
|
+
lucideCubic(11.512, 3.014, 11.917, 2.992, 12.129, 3.474, 11.914, 3.817),
|
|
1778
|
+
lucideArc(17.002, 6.997, 6, -147.9941, -302.0059),
|
|
1779
|
+
lucideCubic(20.182, 12.085, 20.526, 11.87, 21.007, 12.081, 20.985, 12.486),
|
|
1780
|
+
];
|
|
1781
|
+
/** Lucide "sun"'s circle, started at the angle where the moon's outer arc starts. */
|
|
1782
|
+
const SUN_CIRCLE = [lucideArc(12, 12, 4, 3.09, 363.09)];
|
|
1783
|
+
/** The share of the circle under the moon's outer arc (3.09° … 266.904°). */
|
|
1784
|
+
const OUTER_SHARE = (266.904 - 3.09) / 360;
|
|
1785
|
+
const CIRCLE_FOR_MOON = [
|
|
1786
|
+
...Array.from({ length: 9 }, (_, index) => (index / 8) * OUTER_SHARE),
|
|
1787
|
+
...Array.from({ length: 4 }, (_, index) => OUTER_SHARE + ((index + 1) / 4) * (1 - OUTER_SHARE)),
|
|
1788
|
+
].map((breakpoint) => +breakpoint.toFixed(4));
|
|
1789
|
+
/** Lucide "sun"'s rays, each from its outer end inwards (erasing keeps the outer end: the ray flies out). */
|
|
1790
|
+
const RAYS = [
|
|
1791
|
+
lucideLine(12, 2, 12, 4),
|
|
1792
|
+
lucideLine(19.07, 4.93, 17.66, 6.34),
|
|
1793
|
+
lucideLine(22, 12, 20, 12),
|
|
1794
|
+
lucideLine(19.07, 19.07, 17.66, 17.66),
|
|
1795
|
+
lucideLine(12, 22, 12, 20),
|
|
1796
|
+
lucideLine(4.93, 19.07, 6.34, 17.66),
|
|
1797
|
+
lucideLine(2, 12, 4, 12),
|
|
1798
|
+
lucideLine(4.93, 4.93, 6.34, 6.34),
|
|
1799
|
+
];
|
|
1800
|
+
/** The moon's ray pieces at rest: a tiny line on the bottom of its outline (it doesn't show). */
|
|
1801
|
+
const HIDDEN_RAY = [lucideLine(11.95, 21, 12.05, 21)];
|
|
1802
|
+
const rayPiece = (ray) => ({
|
|
1803
|
+
strokes: { light: [ray], dark: HIDDEN_RAY },
|
|
1804
|
+
alternativeStrokes: {},
|
|
1805
|
+
pairLayouts: { dark: { light: { draw: 0.6 } } },
|
|
1806
|
+
});
|
|
1807
|
+
const themeIcons = {
|
|
1808
|
+
segmentCount: 12,
|
|
1809
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
1810
|
+
lineCap: 'round',
|
|
1811
|
+
lineJoin: 'round',
|
|
1812
|
+
strokes: { light: SUN_CIRCLE, dark: MOON },
|
|
1813
|
+
alternativeStrokes: {},
|
|
1814
|
+
// even, by hand: the circle's part under the outer arc (263.8°) → 8 equal segments of the outer arc; its top right
|
|
1815
|
+
// quarter (96.2°) → 4 equal segments: tip, inner curve (2), tip. (The optimizer's fit made a lumpy notch.)
|
|
1816
|
+
pairLayouts: {
|
|
1817
|
+
light: { dark: { breakpoints: CIRCLE_FOR_MOON } },
|
|
1818
|
+
dark: { light: { breakpoints: [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1, 2, 2.5, 3, 4] } },
|
|
1819
|
+
},
|
|
1820
|
+
morePieces: RAYS.map(rayPiece),
|
|
1821
|
+
spins: {},
|
|
1822
|
+
};
|
|
1823
|
+
return { themeIcons };
|
|
1824
|
+
})();
|
|
1825
|
+
|
|
1826
|
+
// sets/weather.ts
|
|
1827
|
+
const { weatherIcons } = (() => {
|
|
1828
|
+
// Icon set "weather": sunny, frosty, partly-cloudy, showers, cloudy, thunder, rain, snow, drizzle — Lucide 1.61.0
|
|
1829
|
+
// icons "sun", "sun-snow", "cloud-sun", "cloud-sun-rain", "cloud", "cloud-lightning", "cloud-rain", "cloud-snow",
|
|
1830
|
+
// "cloud-drizzle" (ISC, see LICENSE-lucide). frosty is added last, onto the finished set (withFrosty, at the end).
|
|
1831
|
+
// Coordinates below are Lucide's; lucideLine / lucideArc scale them into our icon box (see lucide.ts).
|
|
1832
|
+
// Pieces:
|
|
1833
|
+
// sunny ↔ cloudy (experiment, the user's idea for a more realistic look): the cloud crawls in from the left over
|
|
1834
|
+
// the sun — it slides in at full size from the box's left side (PairLayout.wipe + slide; its part outside the
|
|
1835
|
+
// box doesn't show), and the sun's circle and rays disappear where its front has passed. (Rejected: the cloud
|
|
1836
|
+
// standing still, revealed behind the edge — user: even worse.) (Before: sunny's circle inflated into the cloud with the
|
|
1837
|
+
// default layout — confirmed "great" by the user — and the rays flew out, `draw: 0.6`.)
|
|
1838
|
+
// piece 1, the cloud.
|
|
1839
|
+
// sunny → partly-cloudy / showers: the small cloud crawls in from the left like the big one (user's choice; before:
|
|
1840
|
+
// it expanded from its bottom left corner — rejected earlier: a circle copy turning into it, drawing it in).
|
|
1841
|
+
// piece 2, the sun's circle: in cloudy a copy of the cloud (drawn on top of it, so it doesn't show) — sunny ↔ cloudy
|
|
1842
|
+
// morphs it exactly like piece 1. sunny → partly-cloudy: the circle opens behind the cloud — its hidden part pulls
|
|
1843
|
+
// back from the middle into both ends of the arc, the arc itself stays.
|
|
1844
|
+
// pieces 3–10: one ray each | nothing — rays run from their outer end inwards; sunny → partly-cloudy: the 4 rays partly-cloudy
|
|
1845
|
+
// keeps stay, the other 4 fly out. At rest a missing ray is a single point (never drawn, see toPathData).
|
|
1846
|
+
// pieces 11–12: showers' rain lines, which are also rain's left and middle line; piece 14: rain's right line —
|
|
1847
|
+
// nothing = a single point. partly-cloudy → showers: drawn downwards like a pen, one after the other, while the
|
|
1848
|
+
// cloud's bottom opens. cloudy → rain: like cloudy → thunder (the cloud rises and opens), the 3 lines drawn down
|
|
1849
|
+
// one after the other, left to right; from partly-cloudy / showers / thunder the same (showers' lines stretch,
|
|
1850
|
+
// the bolt is erased); sunny → rain: the cloud crawls in with its rain (its left end carried by the bolt piece).
|
|
1851
|
+
// pieces 11, 12, 14 are also snow's top flakes (the top of each rain line), pieces 15–17 its bottom flakes (hidden
|
|
1852
|
+
// at the bottom of each rain line in rain): rain → snow: each line shrinks into its top flake while the bottom
|
|
1853
|
+
// flake comes out of its lower end. → snow otherwise: the flakes appear one after the other, row by row (ride
|
|
1854
|
+
// in with the cloud from sunny).
|
|
1855
|
+
// drizzle's 6 short lines sit where snow's flakes are, so they are the same 6 pieces: rain → drizzle: each line
|
|
1856
|
+
// shrinks into its short top piece, the bottom piece grows out of its lower end; snow ↔ drizzle: flake ↔ short
|
|
1857
|
+
// line (halves, as rain ↔ snow); → drizzle otherwise like → snow (drawn row by row, riding in from sunny).
|
|
1858
|
+
// snow and drizzle have rain's cloud: every other piece morphs exactly like rain's (withRainTwins).
|
|
1859
|
+
// The rules of this set (the user's): sun → any cloud = the cloud crawls in from the left and covers the sun;
|
|
1860
|
+
// partly-cloudy / showers → a cloud without sun = like → cloudy (the sun erased back down).
|
|
1861
|
+
// piece 13: the lightning bolt | nothing (a single point) — cloudy → thunder: the cloud rises a little and opens
|
|
1862
|
+
// at the bottom (like cloudy → showers), the bolt strikes down from it like a pen. partly-cloudy / showers →
|
|
1863
|
+
// thunder: like → cloudy (the cloud grows, the sun is erased back down), plus the bolt (and showers' rain erased).
|
|
1864
|
+
// sunny → thunder: the cloud crawls in like sunny → cloudy, the bolt rides with it. The open cloud's lower left
|
|
1865
|
+
// end curls back right, so during the crawl it is carried by the first rain piece (else it would poke out of the
|
|
1866
|
+
// box's left side): piece 1 is laid out without it — together they look the same.
|
|
1867
|
+
// showers otherwise morphs like partly-cloudy (sunny: the cloud crawls in, the circle opens; cloudy: the cloud
|
|
1868
|
+
// shrinks and opens, the sun is drawn in), plus the rain: drawn down, or (from sunny) riding in with the cloud.
|
|
1869
|
+
const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
|
|
1870
|
+
const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
|
|
1871
|
+
/** Lucide "cloud", clockwise from the bottom right: bottom line, big bump, top line, right bump. */
|
|
1872
|
+
const CLOUD = [
|
|
1873
|
+
lucideLine(17.5, 19, 9, 19),
|
|
1874
|
+
lucideArc(9.0018, 12, 7, 90.0147, 343.3984),
|
|
1875
|
+
lucideLine(15.71, 10, 17.5, 10),
|
|
1876
|
+
lucideArc(17.5, 14.5, 4.5, -90, 90),
|
|
1877
|
+
];
|
|
1878
|
+
/** sunny → partly-cloudy / showers: the small cloud (and its rain) crawls in a bit later (user's request). */
|
|
1879
|
+
const SMALL_CLOUD_CRAWL = [0.4, 1];
|
|
1880
|
+
/** CLOUD traced from its leftmost point (sunny ↔ cloudy: it slides in from the left, its left side shows last). */
|
|
1881
|
+
const CLOUD_FROM_LEFT = [
|
|
1882
|
+
lucideArc(9.0018, 12, 7, 180, 343.3984),
|
|
1883
|
+
...CLOUD.slice(2),
|
|
1884
|
+
CLOUD[0],
|
|
1885
|
+
lucideArc(9.0018, 12, 7, 90.0147, 180),
|
|
1886
|
+
];
|
|
1887
|
+
/** The sun's circle traced from its leftmost point (sunny ↔ cloudy: the wiping edge reaches it first). */
|
|
1888
|
+
const CIRCLE_FROM_LEFT = [lucideArc(12, 12, 4, 180, 540)];
|
|
1889
|
+
/** A wipe's piece laid out along an alternative tracing with the default spread. */
|
|
1890
|
+
const tracedForWipe = (tracing, strokes) => ({ tracing, breakpoints: defaultBreakpoints(strokes, 12) });
|
|
1891
|
+
/** The angle (from the icon centre) where the cloud starts, so the circle starts in the same direction. */
|
|
1892
|
+
const CIRCLE_START = 51.84;
|
|
1893
|
+
/** Lucide "sun"'s circle, clockwise from CIRCLE_START. */
|
|
1894
|
+
const SUN_CIRCLE = [lucideArc(12, 12, 4, CIRCLE_START, CIRCLE_START + 360)];
|
|
1895
|
+
/** Lucide "cloud-sun"'s small cloud, clockwise from the bottom right (the same build as CLOUD). */
|
|
1896
|
+
const SMALL_CLOUD = [
|
|
1897
|
+
lucideLine(13, 22, 7, 22),
|
|
1898
|
+
lucideArc(7.001, 17, 5, 90.0117, 348.463),
|
|
1899
|
+
lucideLine(11.9, 16, 13, 16),
|
|
1900
|
+
lucideArc(13, 19, 3, -90, 90),
|
|
1901
|
+
];
|
|
1902
|
+
/** SMALL_CLOUD traced from its leftmost point (sunny ↔ partly-cloudy: it slides in like the big cloud). */
|
|
1903
|
+
const SMALL_CLOUD_FROM_LEFT = [
|
|
1904
|
+
lucideArc(7.001, 17, 5, 180, 348.463),
|
|
1905
|
+
...SMALL_CLOUD.slice(2),
|
|
1906
|
+
SMALL_CLOUD[0],
|
|
1907
|
+
lucideArc(7.001, 17, 5, 90.0117, 180),
|
|
1908
|
+
];
|
|
1909
|
+
/** Lucide "cloud-sun-rain"'s cloud: SMALL_CLOUD open at the bottom (where the rain falls), from its lower left end. */
|
|
1910
|
+
const OPEN_SMALL_CLOUD = [
|
|
1911
|
+
lucideArc(7.0014, 17.0019, 5, 143.1569, 348.4412),
|
|
1912
|
+
lucideLine(11.9, 16, 13, 16),
|
|
1913
|
+
lucideArc(13.0044, 19, 3, -90.0842, 48.3025),
|
|
1914
|
+
];
|
|
1915
|
+
/** Where SMALL_CLOUD's gap (bottom) has its middle: the gap's two halves pull back into its ends from here. */
|
|
1916
|
+
const GAP_MIDDLE_X = 8.77;
|
|
1917
|
+
// partly-cloudy ↔ showers: the closed cloud traced from the gap's middle — 2 segments to the gap's left end, the open
|
|
1918
|
+
// cloud's part (4 along the big bump, 1 top line, 3 along the right bump), 2 back to the middle; the open cloud gets
|
|
1919
|
+
// 2 zero-length segments at each end.
|
|
1920
|
+
const CLOSED_FOR_OPEN = {
|
|
1921
|
+
tracing: 'small-cloud-from-gap-middle',
|
|
1922
|
+
breakpoints: [0, 1, 1.2057, 1.4043, 1.6029, 1.8014, 2, 3, 3.2561, 3.5122, 3.7683, 4, 5],
|
|
1923
|
+
};
|
|
1924
|
+
const OPEN_FOR_CLOSED = { breakpoints: [0, 0, 0, 0.25, 0.5, 0.75, 1, 2, 2.3333, 2.6667, 3, 3, 3] };
|
|
1925
|
+
/** The same gap on the big CLOUD (its bottom, from 143.16° on the big bump to 48.3° on the right bump). */
|
|
1926
|
+
const BIG_GAP_MIDDLE_X = 11.65;
|
|
1927
|
+
// cloudy ↔ showers: the big cloud traced from its gap's middle, split like CLOSED_FOR_OPEN — it shrinks and opens
|
|
1928
|
+
const BIG_CLOSED_FOR_OPEN = {
|
|
1929
|
+
tracing: 'cloud-from-gap-middle',
|
|
1930
|
+
breakpoints: [0, 1, 1.2097, 1.4073, 1.6049, 1.8024, 2, 3, 3.2561, 3.5122, 3.7683, 4, 5],
|
|
1931
|
+
};
|
|
1932
|
+
/** Lucide "cloud-lightning"'s cloud: CLOUD 2 units higher, open at the bottom (where the bolt strikes), from its
|
|
1933
|
+
* lower left end. */
|
|
1934
|
+
const THUNDER_CLOUD = [
|
|
1935
|
+
lucideArc(9.0026, 10.0027, 7, 115.4004, 343.3756),
|
|
1936
|
+
lucideLine(15.71, 8, 17.5, 8),
|
|
1937
|
+
lucideArc(17.5079, 12.5, 4.5, -90.1, 83.7212),
|
|
1938
|
+
];
|
|
1939
|
+
/** The middle of CLOUD's part that is missing in THUNDER_CLOUD (its bottom, from 115.4° to 83.7°). */
|
|
1940
|
+
const THUNDER_GAP_MIDDLE_X = 11.94;
|
|
1941
|
+
// cloudy ↔ thunder: CLOUD traced from that middle — 2 segments to the gap's left end, 4 along the big bump, 1 top line,
|
|
1942
|
+
// 3 along the right bump, 2 back to the middle; the open cloud gets 2 zero-length segments at each end (OPEN_FOR_CLOSED).
|
|
1943
|
+
const CLOUD_FROM_THUNDER_GAP_MIDDLE = [
|
|
1944
|
+
lucideLine(THUNDER_GAP_MIDDLE_X, 19, 9, 19),
|
|
1945
|
+
...CLOUD.slice(1),
|
|
1946
|
+
lucideLine(17.5, 19, THUNDER_GAP_MIDDLE_X, 19),
|
|
1947
|
+
];
|
|
1948
|
+
const CLOUD_FOR_THUNDER = {
|
|
1949
|
+
tracing: 'cloud-from-thunder-gap-middle',
|
|
1950
|
+
breakpoints: [0, 1, 1.1002, 1.3252, 1.5501, 1.7751, 2, 3, 3.3217, 3.6434, 3.9651, 4, 5],
|
|
1951
|
+
};
|
|
1952
|
+
/** sunny ↔ thunder: THUNDER_CLOUD split at its leftmost point — the rest (from there over the top), and its lower
|
|
1953
|
+
* left end (from there down), each running away from the left side so the crawl shows one stretch of it. */
|
|
1954
|
+
const THUNDER_CLOUD_FROM_LEFT = [
|
|
1955
|
+
lucideArc(9.0026, 10.0027, 7, 180, 343.3756),
|
|
1956
|
+
...THUNDER_CLOUD.slice(1),
|
|
1957
|
+
];
|
|
1958
|
+
const THUNDER_CLOUD_LEFT_END = [lucideArc(9.0026, 10.0027, 7, 180, 115.4004)];
|
|
1959
|
+
/** Lucide "cloud-lightning"'s bolt, from the top down. */
|
|
1960
|
+
const BOLT = [lucideLine(13, 12, 10, 17), lucideLine(10, 17, 14, 17), lucideLine(14, 17, 11, 22)];
|
|
1961
|
+
/** cloudy → thunder: the bolt strikes in the second half of the morph. */
|
|
1962
|
+
const BOLT_DRAWN = [0.5, 0.85];
|
|
1963
|
+
/** Lucide "cloud-rain"'s cloud (also "cloud-snow", "cloud-drizzle"): THUNDER_CLOUD with a wider gap, from its lower
|
|
1964
|
+
* left end. */
|
|
1965
|
+
const RAIN_CLOUD = [
|
|
1966
|
+
lucideArc(9.0026, 10.0027, 7, 135.615, 343.3757),
|
|
1967
|
+
lucideLine(15.71, 8, 17.5, 8),
|
|
1968
|
+
lucideArc(17.5005, 12.5, 4.5, -90.0065, 56.2589),
|
|
1969
|
+
];
|
|
1970
|
+
/** The middle of CLOUD's part that is missing in RAIN_CLOUD (its bottom, from 135.6° to 56.3°). */
|
|
1971
|
+
const RAIN_GAP_MIDDLE_X = 11.79;
|
|
1972
|
+
const CLOUD_FROM_RAIN_GAP_MIDDLE = [
|
|
1973
|
+
lucideLine(RAIN_GAP_MIDDLE_X, 19, 9, 19),
|
|
1974
|
+
...CLOUD.slice(1),
|
|
1975
|
+
lucideLine(17.5, 19, RAIN_GAP_MIDDLE_X, 19),
|
|
1976
|
+
];
|
|
1977
|
+
// cloudy ↔ rain: split like CLOUD_FOR_THUNDER (2 | 4 big bump | 1 | 3 right bump | 2), RAIN_CLOUD with OPEN_FOR_CLOSED
|
|
1978
|
+
const CLOUD_FOR_RAIN = {
|
|
1979
|
+
tracing: 'cloud-from-rain-gap-middle',
|
|
1980
|
+
breakpoints: [0, 1, 1.18, 1.385, 1.59, 1.795, 2, 3, 3.2709, 3.5417, 3.8126, 4, 5],
|
|
1981
|
+
};
|
|
1982
|
+
/** sunny ↔ rain: RAIN_CLOUD split at its leftmost point, like THUNDER_CLOUD_FROM_LEFT / THUNDER_CLOUD_LEFT_END. */
|
|
1983
|
+
const RAIN_CLOUD_FROM_LEFT = [lucideArc(9.0026, 10.0027, 7, 180, 343.3757), ...RAIN_CLOUD.slice(1)];
|
|
1984
|
+
const RAIN_CLOUD_LEFT_END = [lucideArc(9.0026, 10.0027, 7, 180, 135.615)];
|
|
1985
|
+
/** Lucide "cloud-rain"'s lines, left to right, each from the top down. */
|
|
1986
|
+
const HEAVY_RAIN_LINES = [lucideLine(8, 14, 8, 20), lucideLine(12, 16, 12, 22), lucideLine(16, 14, 16, 20)];
|
|
1987
|
+
/** cloudy → rain: the lines are drawn one after the other. */
|
|
1988
|
+
const HEAVY_RAIN_DRAWN = [[0.45, 0.8], [0.55, 0.9], [0.65, 1]]; // once the bottom has opened
|
|
1989
|
+
/** Lucide "cloud-snow"'s flake centres for the pieces 11, 12, 14 (top flakes) and 15, 16, 17 (bottom flakes). */
|
|
1990
|
+
const SNOWFLAKE_CENTERS = [[8, 15], [12, 17], [16, 15], [8, 19], [12, 21], [16, 19]];
|
|
1991
|
+
/**
|
|
1992
|
+
* Lucide draws a flake as a dot (a tiny round-capped line, as wide as the line). Ours are a little bigger so they
|
|
1993
|
+
* show at small sizes (user's request): a tiny closed octagon of this radius, which the thick line fills in.
|
|
1994
|
+
*/
|
|
1995
|
+
const SNOWFLAKE_RADIUS = 0.25;
|
|
1996
|
+
const SNOWFLAKES = SNOWFLAKE_CENTERS.map(([centerX, centerY]) => {
|
|
1997
|
+
// clockwise from the top corner
|
|
1998
|
+
const corners = Array.from({ length: 8 }, (_, index) => [
|
|
1999
|
+
centerX + SNOWFLAKE_RADIUS * Math.cos(((index - 2) * Math.PI) / 4),
|
|
2000
|
+
centerY + SNOWFLAKE_RADIUS * Math.sin(((index - 2) * Math.PI) / 4),
|
|
2001
|
+
]);
|
|
2002
|
+
return corners.map((corner, index) => lucideLine(...corner, ...corners[(index + 1) % 8]));
|
|
2003
|
+
});
|
|
2004
|
+
// line → flake (rain / showers → snow): the line traced down and back up, its down pass ↔ the flake's right half, its
|
|
2005
|
+
// up pass ↔ the left half — the line narrows into the flake (segment by segment the octagon's corners zigzag)
|
|
2006
|
+
const LINE_OUT_AND_BACK = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((index) => +(index / 6).toFixed(4));
|
|
2007
|
+
const FLAKE_IN_HALVES = { breakpoints: [0, 0.5, 1, 2, 3, 3.5, 4, 4.5, 5, 6, 7, 7.5, 8] };
|
|
2008
|
+
const outAndBack = (stroke) => [stroke, reversedStroke(stroke)];
|
|
2009
|
+
/** → snow / drizzle: when each flake / short line appears (row by row: 8,15 · 16,15 · 12,17 · 8,19 · 16,19 · 12,21). */
|
|
2010
|
+
const SNOWFLAKE_STARTS = [0.45, 0.59, 0.52, 0.66, 0.8, 0.73];
|
|
2011
|
+
const fallingDrawn = (flakeIndex) => ({ draw: [SNOWFLAKE_STARTS[flakeIndex], SNOWFLAKE_STARTS[flakeIndex] + 0.1] });
|
|
2012
|
+
/** Lucide "cloud-drizzle"'s short lines (top down), in the order of the flakes (each one where a flake is). */
|
|
2013
|
+
const DRIZZLE_LINES = [[8, 14], [12, 16], [16, 14], [8, 19], [12, 21], [16, 19]]
|
|
2014
|
+
.map(([x, y]) => lucideLine(x, y, x, y + 1));
|
|
2015
|
+
/** Pair layouts of several groups put together (per state). */
|
|
2016
|
+
const mergePairLayouts = (...groups) => {
|
|
2017
|
+
const merged = {};
|
|
2018
|
+
for (const group of groups) {
|
|
2019
|
+
for (const [state, byOtherState] of Object.entries(group))
|
|
2020
|
+
merged[state] = { ...merged[state], ...byOtherState };
|
|
2021
|
+
}
|
|
2022
|
+
return merged;
|
|
2023
|
+
};
|
|
2024
|
+
/** The drizzle pairs of precipitation piece `fallingIndex` (0…5) — showers' own lines (0, 1) just shorten. */
|
|
2025
|
+
const drizzlePairLayouts = (fallingIndex, hasShowersLine) => ({
|
|
2026
|
+
...Object.fromEntries(['cloudy', 'partly-cloudy', 'thunder', ...(hasShowersLine ? [] : ['showers'])]
|
|
2027
|
+
.map((state) => [state, { drizzle: fallingDrawn(fallingIndex) }])),
|
|
2028
|
+
sunny: { drizzle: RIDING_IN },
|
|
2029
|
+
snow: { drizzle: FLAKE_IN_HALVES },
|
|
2030
|
+
drizzle: { snow: { tracing: 'drizzle-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
|
|
2031
|
+
});
|
|
2032
|
+
/** sunny → rain / snow: the precipitation rides in with the crawling cloud. */
|
|
2033
|
+
const RIDING_IN = { wipe: 'right', slide: true };
|
|
2034
|
+
/** Lucide "cloud-sun-rain"'s rain lines, each from the top down. */
|
|
2035
|
+
const RAIN_LINES = [lucideLine(7, 19, 7, 21), lucideLine(11, 20, 11, 22)];
|
|
2036
|
+
/** partly-cloudy → showers: the rain lines are drawn one after the other while the cloud opens. */
|
|
2037
|
+
const RAIN_DRAWN = [[0.35, 0.75], [0.55, 0.95]];
|
|
2038
|
+
/** Lucide "cloud-sun"'s sun: the part of the circle above the cloud, clockwise from its left end. */
|
|
2039
|
+
const SUN_ARC = [lucideArc(12.0004, 11.9985, 4, -119.6437, 9.3742)];
|
|
2040
|
+
/** The middle of the sun's hidden part (behind the cloud), from where it pulls back into both ends of SUN_ARC. */
|
|
2041
|
+
const HIDDEN_MIDDLE = (9.3742 + 240.3563) / 2;
|
|
2042
|
+
/** Lucide "sun"'s rays, each from its outer end inwards (erasing keeps the outer end: the ray flies out). */
|
|
2043
|
+
const RAYS = [
|
|
2044
|
+
lucideLine(12, 2, 12, 4),
|
|
2045
|
+
lucideLine(19.07, 4.93, 17.66, 6.34),
|
|
2046
|
+
lucideLine(22, 12, 20, 12),
|
|
2047
|
+
lucideLine(19.07, 19.07, 17.66, 17.66),
|
|
2048
|
+
lucideLine(12, 22, 12, 20),
|
|
2049
|
+
lucideLine(4.93, 19.07, 6.34, 17.66),
|
|
2050
|
+
lucideLine(2, 12, 4, 12),
|
|
2051
|
+
lucideLine(4.93, 4.93, 6.34, 6.34),
|
|
2052
|
+
];
|
|
2053
|
+
/** A missing piece at rest: a single point (never drawn), here on the cloud's bottom line. */
|
|
2054
|
+
const pointAt = (x, y) => [lucideLine(x, y, x, y)];
|
|
2055
|
+
const HIDDEN_RAY = pointAt(13, 19);
|
|
2056
|
+
/** partly-cloudy's and showers' missing rays (bottom right, bottom, bottom left, left). */
|
|
2057
|
+
const HIDDEN_RAY_PARTLY = pointAt(10, 22);
|
|
2058
|
+
const MISSING_RAYS_PARTLY = [3, 4, 5, 6];
|
|
2059
|
+
// sunny ↔ partly-cloudy, the sun's circle: traced from the middle of the hidden part, 4 segments to the arc's left
|
|
2060
|
+
// end, 4 along the arc (it stays), 4 back to the middle; the arc gets 4 zero-length segments at each end.
|
|
2061
|
+
const ARC_SHARE = (9.3742 + 119.6437) / 360;
|
|
2062
|
+
const CIRCLE_FOR_SUN_ARC = {
|
|
2063
|
+
tracing: 'circle-from-hidden-middle',
|
|
2064
|
+
breakpoints: [
|
|
2065
|
+
...[0, 1, 2, 3].map((index) => (index / 4) * ((1 - ARC_SHARE) / 2)),
|
|
2066
|
+
...[0, 1, 2, 3].map((index) => (1 - ARC_SHARE) / 2 + (index / 4) * ARC_SHARE),
|
|
2067
|
+
...[0, 1, 2, 3, 4].map((index) => (1 + ARC_SHARE) / 2 + (index / 4) * ((1 - ARC_SHARE) / 2)),
|
|
2068
|
+
].map((breakpoint) => +breakpoint.toFixed(4)),
|
|
2069
|
+
};
|
|
2070
|
+
const SUN_ARC_FOR_CIRCLE = { breakpoints: [0, 0, 0, 0, 0, 0.25, 0.5, 0.75, 1, 1, 1, 1, 1] };
|
|
2071
|
+
// cloudy → partly-cloudy: the sun is drawn like a pen from the arc's lower right end up over the top (the user's
|
|
2072
|
+
// direction), and the kept rays follow one after another, each growing out from the sun when the pen passes it.
|
|
2073
|
+
const SUN_ARC_DRAWN = [0.45, 0.85]; // after the cloud has shrunk away from the arc's start
|
|
2074
|
+
const RAY_DRAW_DURATION = 0.15;
|
|
2075
|
+
/** Kept rays → how far along the arc (from its lower right end, 0…1) the pen is when the ray starts. */
|
|
2076
|
+
const KEPT_RAY_PEN_POSITIONS = { 2: 0.07, 1: 0.42, 0: 0.77, 7: 1 };
|
|
2077
|
+
const rayDrawnAfterPen = (penPosition) => {
|
|
2078
|
+
const start = SUN_ARC_DRAWN[0] + penPosition * (SUN_ARC_DRAWN[1] - SUN_ARC_DRAWN[0]);
|
|
2079
|
+
return [+start.toFixed(4), +Math.min(start + RAY_DRAW_DURATION, 1).toFixed(4)];
|
|
2080
|
+
};
|
|
2081
|
+
/** States drawn with rain's cloud: apart from the precipitation they morph exactly like rain. */
|
|
2082
|
+
const RAIN_TWINS = ['snow', 'drizzle'];
|
|
2083
|
+
/** A piece that is the same in rain and its twins: the twins get rain's strokes and pair layouts. */
|
|
2084
|
+
const withRainTwins = (pieceSet) => {
|
|
2085
|
+
const pairLayouts = Object.fromEntries(Object.entries(pieceSet.pairLayouts).map(([state, layouts]) => [state, { ...layouts, ...(layouts.rain ? Object.fromEntries(RAIN_TWINS.map((twin) => [twin, layouts.rain])) : {}) }]));
|
|
2086
|
+
for (const twin of RAIN_TWINS)
|
|
2087
|
+
if (pieceSet.pairLayouts.rain)
|
|
2088
|
+
pairLayouts[twin] = pieceSet.pairLayouts.rain;
|
|
2089
|
+
return {
|
|
2090
|
+
...pieceSet,
|
|
2091
|
+
strokes: { ...pieceSet.strokes, ...Object.fromEntries(RAIN_TWINS.map((twin) => [twin, pieceSet.strokes.rain])) },
|
|
2092
|
+
pairLayouts,
|
|
2093
|
+
};
|
|
2094
|
+
};
|
|
2095
|
+
const sunCirclePiece = {
|
|
2096
|
+
strokes: { sunny: SUN_CIRCLE, cloudy: CLOUD, 'partly-cloudy': SUN_ARC, showers: SUN_ARC, thunder: THUNDER_CLOUD, rain: RAIN_CLOUD },
|
|
2097
|
+
alternativeStrokes: {
|
|
2098
|
+
'cloud-from-rain-gap-middle': CLOUD_FROM_RAIN_GAP_MIDDLE,
|
|
2099
|
+
'cloud-from-thunder-gap-middle': CLOUD_FROM_THUNDER_GAP_MIDDLE,
|
|
2100
|
+
'circle-from-hidden-middle': [lucideArc(12, 12, 4, HIDDEN_MIDDLE, HIDDEN_MIDDLE + 360)],
|
|
2101
|
+
'circle-from-left': CIRCLE_FROM_LEFT,
|
|
2102
|
+
},
|
|
2103
|
+
pairLayouts: {
|
|
2104
|
+
sunny: {
|
|
2105
|
+
cloudy: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
|
|
2106
|
+
thunder: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
|
|
2107
|
+
rain: tracedForWipe('circle-from-left', CIRCLE_FROM_LEFT),
|
|
2108
|
+
'partly-cloudy': CIRCLE_FOR_SUN_ARC,
|
|
2109
|
+
showers: CIRCLE_FOR_SUN_ARC,
|
|
2110
|
+
},
|
|
2111
|
+
cloudy: {
|
|
2112
|
+
sunny: { wipe: 'left' },
|
|
2113
|
+
'partly-cloudy': { draw: SUN_ARC_DRAWN },
|
|
2114
|
+
showers: { draw: SUN_ARC_DRAWN },
|
|
2115
|
+
thunder: CLOUD_FOR_THUNDER,
|
|
2116
|
+
rain: CLOUD_FOR_RAIN,
|
|
2117
|
+
},
|
|
2118
|
+
rain: {
|
|
2119
|
+
cloudy: OPEN_FOR_CLOSED,
|
|
2120
|
+
sunny: { wipe: 'left' },
|
|
2121
|
+
'partly-cloudy': { draw: SUN_ARC_DRAWN },
|
|
2122
|
+
showers: { draw: SUN_ARC_DRAWN },
|
|
2123
|
+
},
|
|
2124
|
+
'partly-cloudy': { sunny: SUN_ARC_FOR_CIRCLE, cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
|
|
2125
|
+
showers: { sunny: SUN_ARC_FOR_CIRCLE, cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
|
|
2126
|
+
// the sun goes like → cloudy: crawled over from sunny, erased back down from partly-cloudy / showers
|
|
2127
|
+
thunder: { cloudy: OPEN_FOR_CLOSED, sunny: { wipe: 'left' }, 'partly-cloudy': { draw: SUN_ARC_DRAWN }, showers: { draw: SUN_ARC_DRAWN } },
|
|
2128
|
+
},
|
|
2129
|
+
};
|
|
2130
|
+
const rayPiece = (ray, rayIndex) => {
|
|
2131
|
+
const missingInPartly = MISSING_RAYS_PARTLY.includes(rayIndex);
|
|
2132
|
+
return {
|
|
2133
|
+
strokes: {
|
|
2134
|
+
sunny: [ray],
|
|
2135
|
+
cloudy: HIDDEN_RAY,
|
|
2136
|
+
'partly-cloudy': missingInPartly ? HIDDEN_RAY_PARTLY : [ray],
|
|
2137
|
+
showers: missingInPartly ? HIDDEN_RAY_PARTLY : [ray],
|
|
2138
|
+
thunder: HIDDEN_RAY,
|
|
2139
|
+
rain: HIDDEN_RAY,
|
|
2140
|
+
},
|
|
2141
|
+
alternativeStrokes: {},
|
|
2142
|
+
pairLayouts: {
|
|
2143
|
+
cloudy: { sunny: { wipe: 'left' } },
|
|
2144
|
+
thunder: { sunny: { wipe: 'left' } },
|
|
2145
|
+
rain: { sunny: { wipe: 'left' } },
|
|
2146
|
+
...(missingInPartly
|
|
2147
|
+
? { 'partly-cloudy': { sunny: { draw: 0.6 } }, showers: { sunny: { draw: 0.6 } } }
|
|
2148
|
+
// drawn from the sun outwards (the rays run outer end → inner end, so reversed)
|
|
2149
|
+
: {
|
|
2150
|
+
cloudy: {
|
|
2151
|
+
sunny: { wipe: 'left' },
|
|
2152
|
+
'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
|
|
2153
|
+
showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
|
|
2154
|
+
},
|
|
2155
|
+
thunder: {
|
|
2156
|
+
sunny: { wipe: 'left' },
|
|
2157
|
+
'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
|
|
2158
|
+
showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
|
|
2159
|
+
},
|
|
2160
|
+
rain: {
|
|
2161
|
+
sunny: { wipe: 'left' },
|
|
2162
|
+
'partly-cloudy': { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
|
|
2163
|
+
showers: { draw: rayDrawnAfterPen(KEPT_RAY_PEN_POSITIONS[rayIndex]) },
|
|
2164
|
+
},
|
|
2165
|
+
'partly-cloudy': { cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
|
|
2166
|
+
showers: { cloudy: { reversed: true }, thunder: { reversed: true }, rain: { reversed: true } },
|
|
2167
|
+
}),
|
|
2168
|
+
},
|
|
2169
|
+
};
|
|
2170
|
+
};
|
|
2171
|
+
/** Showers' rain line (if it has one) | rain's line: pieces 11, 12 (both) and 14 (rain only). */
|
|
2172
|
+
const rainPiece = (heavyRainLine, rainIndex) => {
|
|
2173
|
+
const showersLine = RAIN_LINES[rainIndex];
|
|
2174
|
+
const top = (showersLine ?? heavyRainLine).from;
|
|
2175
|
+
const nothing = [line(...top, ...top)];
|
|
2176
|
+
const carriesCloudEnd = rainIndex === 0;
|
|
2177
|
+
return {
|
|
2178
|
+
strokes: {
|
|
2179
|
+
sunny: nothing,
|
|
2180
|
+
cloudy: nothing,
|
|
2181
|
+
'partly-cloudy': nothing,
|
|
2182
|
+
showers: showersLine ? [showersLine] : nothing,
|
|
2183
|
+
thunder: nothing,
|
|
2184
|
+
rain: [heavyRainLine],
|
|
2185
|
+
snow: SNOWFLAKES[rainIndex],
|
|
2186
|
+
drizzle: [DRIZZLE_LINES[rainIndex]],
|
|
2187
|
+
},
|
|
2188
|
+
alternativeStrokes: {
|
|
2189
|
+
...(carriesCloudEnd ? { 'thunder-cloud-left-end': THUNDER_CLOUD_LEFT_END } : {}),
|
|
2190
|
+
'rain-line-out-and-back': outAndBack(heavyRainLine),
|
|
2191
|
+
'drizzle-line-out-and-back': outAndBack(DRIZZLE_LINES[rainIndex]),
|
|
2192
|
+
...(showersLine ? { 'showers-line-out-and-back': outAndBack(showersLine) } : {}),
|
|
2193
|
+
},
|
|
2194
|
+
pairLayouts: mergePairLayouts(drizzlePairLayouts(rainIndex, Boolean(showersLine)), showersLine
|
|
2195
|
+
? {
|
|
2196
|
+
cloudy: { showers: { draw: RAIN_DRAWN[rainIndex] }, rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] }, snow: fallingDrawn(rainIndex) },
|
|
2197
|
+
'partly-cloudy': {
|
|
2198
|
+
showers: { draw: RAIN_DRAWN[rainIndex] },
|
|
2199
|
+
rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] },
|
|
2200
|
+
snow: fallingDrawn(rainIndex),
|
|
2201
|
+
},
|
|
2202
|
+
// sunny → showers / rain: the rain rides in with the crawling cloud; sunny → thunder: it carries the cloud's
|
|
2203
|
+
// left end
|
|
2204
|
+
sunny: {
|
|
2205
|
+
showers: { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
|
|
2206
|
+
rain: RIDING_IN,
|
|
2207
|
+
snow: RIDING_IN,
|
|
2208
|
+
...(carriesCloudEnd ? { thunder: RIDING_IN } : {}),
|
|
2209
|
+
},
|
|
2210
|
+
// showers → thunder: erased back up; thunder → rain: drawn down
|
|
2211
|
+
thunder: {
|
|
2212
|
+
showers: { draw: RAIN_DRAWN[rainIndex] },
|
|
2213
|
+
rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] },
|
|
2214
|
+
snow: fallingDrawn(rainIndex),
|
|
2215
|
+
...(carriesCloudEnd ? { sunny: tracedForWipe('thunder-cloud-left-end', THUNDER_CLOUD_LEFT_END) } : {}),
|
|
2216
|
+
},
|
|
2217
|
+
rain: { snow: { tracing: 'rain-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
|
|
2218
|
+
showers: { snow: { tracing: 'showers-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
|
|
2219
|
+
snow: { rain: FLAKE_IN_HALVES, showers: FLAKE_IN_HALVES },
|
|
2220
|
+
}
|
|
2221
|
+
: {
|
|
2222
|
+
...Object.fromEntries(['cloudy', 'partly-cloudy', 'showers', 'thunder']
|
|
2223
|
+
.map((state) => [state, { rain: { draw: HEAVY_RAIN_DRAWN[rainIndex] }, snow: fallingDrawn(rainIndex) }])),
|
|
2224
|
+
sunny: { rain: RIDING_IN, snow: RIDING_IN },
|
|
2225
|
+
rain: { snow: { tracing: 'rain-line-out-and-back', breakpoints: LINE_OUT_AND_BACK } },
|
|
2226
|
+
snow: { rain: FLAKE_IN_HALVES },
|
|
2227
|
+
}),
|
|
2228
|
+
};
|
|
2229
|
+
};
|
|
2230
|
+
/** Snow's bottom flakes / drizzle's bottom lines (pieces 15–17): in rain hidden at the bottom of its line, so
|
|
2231
|
+
* rain → snow / drizzle pulls them out of it. */
|
|
2232
|
+
const bottomFlakePiece = (flakeIndex) => {
|
|
2233
|
+
const nothing = pointAt(...SNOWFLAKE_CENTERS[flakeIndex]);
|
|
2234
|
+
const [lineBottomX, lineBottomY] = HEAVY_RAIN_LINES[flakeIndex - 3].to;
|
|
2235
|
+
return {
|
|
2236
|
+
strokes: {
|
|
2237
|
+
sunny: nothing,
|
|
2238
|
+
cloudy: nothing,
|
|
2239
|
+
'partly-cloudy': nothing,
|
|
2240
|
+
showers: nothing,
|
|
2241
|
+
thunder: nothing,
|
|
2242
|
+
rain: [line(lineBottomX, lineBottomY, lineBottomX, lineBottomY)],
|
|
2243
|
+
snow: SNOWFLAKES[flakeIndex],
|
|
2244
|
+
drizzle: [DRIZZLE_LINES[flakeIndex]],
|
|
2245
|
+
},
|
|
2246
|
+
alternativeStrokes: { 'drizzle-line-out-and-back': outAndBack(DRIZZLE_LINES[flakeIndex]) },
|
|
2247
|
+
pairLayouts: mergePairLayouts(drizzlePairLayouts(flakeIndex, false), {
|
|
2248
|
+
...Object.fromEntries(['cloudy', 'partly-cloudy', 'showers', 'thunder'].map((state) => [state, { snow: fallingDrawn(flakeIndex) }])),
|
|
2249
|
+
sunny: { snow: RIDING_IN },
|
|
2250
|
+
}),
|
|
2251
|
+
};
|
|
2252
|
+
};
|
|
2253
|
+
const boltTop = pointAt(13, 12);
|
|
2254
|
+
const boltPiece = {
|
|
2255
|
+
strokes: { sunny: boltTop, cloudy: boltTop, 'partly-cloudy': boltTop, showers: boltTop, thunder: BOLT, rain: boltTop },
|
|
2256
|
+
// (snow: like rain, withRainTwins)
|
|
2257
|
+
alternativeStrokes: { 'rain-cloud-left-end': RAIN_CLOUD_LEFT_END },
|
|
2258
|
+
pairLayouts: {
|
|
2259
|
+
cloudy: { thunder: { draw: BOLT_DRAWN } },
|
|
2260
|
+
'partly-cloudy': { thunder: { draw: BOLT_DRAWN } },
|
|
2261
|
+
showers: { thunder: { draw: BOLT_DRAWN } },
|
|
2262
|
+
// thunder → rain: pulled back up into the cloud
|
|
2263
|
+
rain: { thunder: { draw: BOLT_DRAWN }, sunny: tracedForWipe('rain-cloud-left-end', RAIN_CLOUD_LEFT_END) },
|
|
2264
|
+
// rides in with the crawling cloud; sunny → rain: carries the rain cloud's left end
|
|
2265
|
+
sunny: { thunder: { wipe: 'right', slide: true }, rain: { wipe: 'right', slide: true } },
|
|
2266
|
+
},
|
|
2267
|
+
};
|
|
2268
|
+
const rainTwinnedIcons = withRainTwins({
|
|
2269
|
+
segmentCount: 12,
|
|
2270
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2271
|
+
lineCap: 'round',
|
|
2272
|
+
lineJoin: 'round',
|
|
2273
|
+
strokes: {
|
|
2274
|
+
sunny: SUN_CIRCLE,
|
|
2275
|
+
cloudy: CLOUD,
|
|
2276
|
+
'partly-cloudy': SMALL_CLOUD,
|
|
2277
|
+
showers: OPEN_SMALL_CLOUD,
|
|
2278
|
+
thunder: THUNDER_CLOUD,
|
|
2279
|
+
rain: RAIN_CLOUD,
|
|
2280
|
+
},
|
|
2281
|
+
alternativeStrokes: {
|
|
2282
|
+
'cloud-from-rain-gap-middle': CLOUD_FROM_RAIN_GAP_MIDDLE,
|
|
2283
|
+
'cloud-from-thunder-gap-middle': CLOUD_FROM_THUNDER_GAP_MIDDLE,
|
|
2284
|
+
'thunder-cloud-from-left': THUNDER_CLOUD_FROM_LEFT,
|
|
2285
|
+
'rain-cloud-from-left': RAIN_CLOUD_FROM_LEFT,
|
|
2286
|
+
'cloud-from-left': CLOUD_FROM_LEFT,
|
|
2287
|
+
'small-cloud-from-left': SMALL_CLOUD_FROM_LEFT,
|
|
2288
|
+
'cloud-from-gap-middle': [
|
|
2289
|
+
lucideLine(BIG_GAP_MIDDLE_X, 19, 9, 19),
|
|
2290
|
+
...CLOUD.slice(1),
|
|
2291
|
+
lucideLine(17.5, 19, BIG_GAP_MIDDLE_X, 19),
|
|
2292
|
+
],
|
|
2293
|
+
'small-cloud-from-gap-middle': [
|
|
2294
|
+
lucideLine(GAP_MIDDLE_X, 22, 7, 22),
|
|
2295
|
+
...SMALL_CLOUD.slice(1),
|
|
2296
|
+
lucideLine(13, 22, GAP_MIDDLE_X, 22),
|
|
2297
|
+
],
|
|
2298
|
+
},
|
|
2299
|
+
pairLayouts: {
|
|
2300
|
+
// every cloud crawls in from the left over the sun
|
|
2301
|
+
sunny: {
|
|
2302
|
+
cloudy: { wipe: 'right', slide: true },
|
|
2303
|
+
'partly-cloudy': { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
|
|
2304
|
+
showers: { wipe: 'right', slide: true, draw: SMALL_CLOUD_CRAWL },
|
|
2305
|
+
thunder: { wipe: 'right', slide: true },
|
|
2306
|
+
rain: { wipe: 'right', slide: true },
|
|
2307
|
+
},
|
|
2308
|
+
cloudy: { sunny: tracedForWipe('cloud-from-left', CLOUD_FROM_LEFT), showers: BIG_CLOSED_FOR_OPEN, thunder: CLOUD_FOR_THUNDER, rain: CLOUD_FOR_RAIN },
|
|
2309
|
+
'partly-cloudy': {
|
|
2310
|
+
sunny: tracedForWipe('small-cloud-from-left', SMALL_CLOUD_FROM_LEFT),
|
|
2311
|
+
showers: CLOSED_FOR_OPEN,
|
|
2312
|
+
thunder: CLOSED_FOR_OPEN,
|
|
2313
|
+
rain: CLOSED_FOR_OPEN,
|
|
2314
|
+
},
|
|
2315
|
+
showers: { 'partly-cloudy': OPEN_FOR_CLOSED, cloudy: OPEN_FOR_CLOSED },
|
|
2316
|
+
rain: {
|
|
2317
|
+
cloudy: OPEN_FOR_CLOSED,
|
|
2318
|
+
'partly-cloudy': OPEN_FOR_CLOSED,
|
|
2319
|
+
sunny: tracedForWipe('rain-cloud-from-left', RAIN_CLOUD_FROM_LEFT),
|
|
2320
|
+
},
|
|
2321
|
+
thunder: {
|
|
2322
|
+
cloudy: OPEN_FOR_CLOSED,
|
|
2323
|
+
'partly-cloudy': OPEN_FOR_CLOSED,
|
|
2324
|
+
sunny: tracedForWipe('thunder-cloud-from-left', THUNDER_CLOUD_FROM_LEFT),
|
|
2325
|
+
},
|
|
2326
|
+
},
|
|
2327
|
+
morePieces: [
|
|
2328
|
+
...[sunCirclePiece, ...RAYS.map(rayPiece)].map(withRainTwins),
|
|
2329
|
+
...HEAVY_RAIN_LINES.slice(0, 2).map(rainPiece),
|
|
2330
|
+
withRainTwins(boltPiece),
|
|
2331
|
+
rainPiece(HEAVY_RAIN_LINES[2], 2),
|
|
2332
|
+
...[3, 4, 5].map(bottomFlakePiece),
|
|
2333
|
+
],
|
|
2334
|
+
spins: {},
|
|
2335
|
+
});
|
|
2336
|
+
// ---- frosty (Lucide "sun-snow"): half a small sun on the left, half a snowflake on the right ----
|
|
2337
|
+
// sunny ↔ frosty: the sun shrinks to its left half (its right half pulls back into the half's ends) while a copy of
|
|
2338
|
+
// that right half (the bolt piece) straightens into the snowflake's spine; the 3 right rays bend into its branches,
|
|
2339
|
+
// the other 5 move to frosty's shorter rays.
|
|
2340
|
+
// frosty → any cloud state (the set's rule): the cloud crawls in from the left and covers everything (the rain etc.
|
|
2341
|
+
// rides along). A wipe needs every piece to show one stretch, so during the crawl the half sun's upper quarter and
|
|
2342
|
+
// the spine (4 x-monotone parts) are carried by 6 spare pieces (a single point at rest, in every state); the 6th
|
|
2343
|
+
// carries the open clouds' lower left end. frosty ↔ partly-cloudy / showers: the half sun becomes their sun arc,
|
|
2344
|
+
// the kept rays move, the rest is covered.
|
|
2345
|
+
/** frosty's half sun, clockwise from its bottom (Lucide: centre 10,12, radius 3). */
|
|
2346
|
+
const HALF_SUN = [lucideArc(10, 12, 3, 90, 270)];
|
|
2347
|
+
/** For the crawl: from its leftmost point down / up (each one stretch for the wipe). */
|
|
2348
|
+
const HALF_SUN_LOWER = [lucideArc(10, 12, 3, 180, 90)];
|
|
2349
|
+
const HALF_SUN_UPPER = [lucideArc(10, 12, 3, 180, 270)];
|
|
2350
|
+
/** frosty's rays by ray piece (outer end first), the right three bent into the snowflake's branches. */
|
|
2351
|
+
const FROSTY_RAYS = [
|
|
2352
|
+
[lucideLine(10, 3, 10, 4)],
|
|
2353
|
+
[lucideLine(18, 6, 15.25, 6.5), lucideLine(15.25, 6.5, 14, 4)],
|
|
2354
|
+
[lucideLine(20, 10, 18.5, 12), lucideLine(18.5, 12, 20, 14)],
|
|
2355
|
+
[lucideLine(18, 18, 15.25, 17.5), lucideLine(15.25, 17.5, 14, 20)],
|
|
2356
|
+
[lucideLine(10, 21, 10, 20)],
|
|
2357
|
+
[lucideLine(3.64, 18.36, 4.34, 17.66)],
|
|
2358
|
+
[lucideLine(2, 12, 3, 12)],
|
|
2359
|
+
[lucideLine(3.64, 5.64, 4.34, 6.34)],
|
|
2360
|
+
];
|
|
2361
|
+
/** The snowflake's spine as one line: upper arm, out along the middle arm and back, lower arm. */
|
|
2362
|
+
const SPINE = [
|
|
2363
|
+
lucideLine(17, 3, 14, 9), lucideLine(14, 9, 15.5, 12), lucideLine(15.5, 12, 22, 12),
|
|
2364
|
+
lucideLine(22, 12, 15.5, 12), lucideLine(15.5, 12, 14, 15), lucideLine(14, 15, 17, 21),
|
|
2365
|
+
];
|
|
2366
|
+
/** The spine in parts that each run one way across (for the crawl). */
|
|
2367
|
+
const SPINE_PARTS = [
|
|
2368
|
+
[lucideLine(17, 3, 14, 9)],
|
|
2369
|
+
[lucideLine(14, 9, 15.5, 12), lucideLine(15.5, 12, 22, 12)],
|
|
2370
|
+
[lucideLine(14, 15, 15.5, 12)],
|
|
2371
|
+
[lucideLine(14, 15, 17, 21)],
|
|
2372
|
+
];
|
|
2373
|
+
// sunny ↔ frosty: the circle traced from its right point — 3 segments of its lower right quarter pull back into the half
|
|
2374
|
+
// sun's bottom end, 6 along the left half, 3 of the upper right quarter into its top end
|
|
2375
|
+
const CIRCLE_FOR_HALF_SUN = {
|
|
2376
|
+
tracing: 'circle-from-right',
|
|
2377
|
+
breakpoints: Array.from({ length: 13 }, (_, index) => +(index / 12).toFixed(4)),
|
|
2378
|
+
};
|
|
2379
|
+
const HALF_SUN_FOR_CIRCLE = { breakpoints: [0, 0, 0, 0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1, 1, 1] };
|
|
2380
|
+
/** The circle's right half (top → right → bottom): the bolt piece's start in sunny → frosty (under the circle). */
|
|
2381
|
+
const CIRCLE_RIGHT_HALF = [lucideArc(12, 12, 4, -90, 90)];
|
|
2382
|
+
const CLOUD_STATES = ['cloudy', 'partly-cloudy', 'showers', 'thunder', 'rain', 'snow', 'drizzle'];
|
|
2383
|
+
/** Cloud states that keep a sun (its arc and 4 rays). */
|
|
2384
|
+
const SUN_CLOUD_STATES = ['partly-cloudy', 'showers'];
|
|
2385
|
+
const isNothing = (strokes) => strokes.length === 1 && strokes[0].kind === 'line' && strokes[0].from.every((value, axis) => value === strokes[0].to[axis]);
|
|
2386
|
+
const POINT_LAYOUT = { tracing: 'nothing', breakpoints: Array.from({ length: 13 }, (_, index) => +(index / 12).toFixed(4)) };
|
|
2387
|
+
/** The open clouds' lower left ends, carried by a spare piece while they crawl in. */
|
|
2388
|
+
const CLOUD_LEFT_ENDS = {
|
|
2389
|
+
thunder: THUNDER_CLOUD_LEFT_END, rain: RAIN_CLOUD_LEFT_END, snow: RAIN_CLOUD_LEFT_END, drizzle: RAIN_CLOUD_LEFT_END,
|
|
2390
|
+
};
|
|
2391
|
+
const COVERED = { wipe: 'left' };
|
|
2392
|
+
/** `pieceSet` with frosty's strokes and `layouts[state][otherState]` added. */
|
|
2393
|
+
const addFrosty = (pieceSet, strokes, layouts, alternativeStrokes = {}) => ({
|
|
2394
|
+
strokes: { ...pieceSet.strokes, frosty: strokes },
|
|
2395
|
+
alternativeStrokes: { ...pieceSet.alternativeStrokes, ...alternativeStrokes },
|
|
2396
|
+
pairLayouts: mergePairLayouts(pieceSet.pairLayouts, layouts),
|
|
2397
|
+
});
|
|
2398
|
+
/** Layouts for the given cloud states X: `frosty.X` and `X.frosty` (either may be left out). */
|
|
2399
|
+
const forCloudStates = (states, frostySide, otherSide) => {
|
|
2400
|
+
const layouts = { frosty: {} };
|
|
2401
|
+
for (const state of states) {
|
|
2402
|
+
const frostyLayout = frostySide?.(state), otherLayout = otherSide?.(state);
|
|
2403
|
+
if (frostyLayout)
|
|
2404
|
+
layouts.frosty[state] = frostyLayout;
|
|
2405
|
+
if (otherLayout)
|
|
2406
|
+
layouts[state] = { frosty: otherLayout };
|
|
2407
|
+
}
|
|
2408
|
+
return layouts;
|
|
2409
|
+
};
|
|
2410
|
+
/** Erasing runs a window backwards: [0, 0.6] erases during 40 … 100 %, with SMALL_CLOUD_CRAWL. */
|
|
2411
|
+
const COVERED_BEFORE_CRAWL_ENDS = [1 - SMALL_CLOUD_CRAWL[1], 1 - SMALL_CLOUD_CRAWL[0]];
|
|
2412
|
+
// frosty ↔ partly-cloudy / showers: the spine's centre sits where the sun arc's right end is — the spine traced
|
|
2413
|
+
// backwards (lower arm, middle arm out and back, upper arm) shrinks into that end with its first 6 segments while
|
|
2414
|
+
// its upper arm unrolls from there up over the top into the arc with the last 6
|
|
2415
|
+
const SPINE_FOR_SUN_ARC = { tracing: 'spine-reversed', breakpoints: [0, 0.3333, 0.6667, 1, 2, 3, 4, 4.5, 5, 5.25, 5.5, 5.75, 6] };
|
|
2416
|
+
const SUN_ARC_FOR_SPINE = { tracing: 'sun-arc-reversed', breakpoints: [0, 0, 0, 0, 0, 0, 0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1] };
|
|
2417
|
+
/** Covered by the crawling cloud — for partly-cloudy / showers in step with their later crawl. */
|
|
2418
|
+
const coveredIn = (state) => (SUN_CLOUD_STATES.includes(state) ? { wipe: 'left', draw: COVERED_BEFORE_CRAWL_ENDS } : { wipe: 'left' });
|
|
2419
|
+
const withFrosty = (iconSet) => {
|
|
2420
|
+
const [cloudPiece, sunPiece, ...otherPieces] = piecesOf(iconSet);
|
|
2421
|
+
const rayPieces = otherPieces.slice(0, 8);
|
|
2422
|
+
const [rain1, rain2, bolt, rain3, ...bottomFlakes] = otherPieces.slice(8);
|
|
2423
|
+
const nothing = pointAt(12, 12);
|
|
2424
|
+
const sparePiece = (layouts, alternativeStrokes) => ({
|
|
2425
|
+
strokes: Object.fromEntries([...Object.keys(iconSet.strokes), 'frosty'].map((state) => [state, nothing])),
|
|
2426
|
+
alternativeStrokes,
|
|
2427
|
+
pairLayouts: layouts,
|
|
2428
|
+
});
|
|
2429
|
+
const coveredStates = CLOUD_STATES.filter((state) => !SUN_CLOUD_STATES.includes(state));
|
|
2430
|
+
const circleFromRight = [lucideArc(12, 12, 4, 0, 360)];
|
|
2431
|
+
const sunnyToFrosty = { sunny: { frosty: CIRCLE_FOR_HALF_SUN }, frosty: { sunny: HALF_SUN_FOR_CIRCLE } };
|
|
2432
|
+
const cloud = {
|
|
2433
|
+
...iconSet,
|
|
2434
|
+
...addFrosty(cloudPiece, HALF_SUN, mergePairLayouts(
|
|
2435
|
+
// sunny ↔ frosty: a copy of the sun (under piece 2), morphing like it
|
|
2436
|
+
sunnyToFrosty,
|
|
2437
|
+
// → any cloud: it crawls in exactly as from sunny
|
|
2438
|
+
forCloudStates(CLOUD_STATES, (state) => iconSet.pairLayouts.sunny[state], (state) => iconSet.pairLayouts[state]?.sunny)), { 'circle-from-right': circleFromRight }),
|
|
2439
|
+
};
|
|
2440
|
+
const sun = addFrosty(sunPiece, HALF_SUN, mergePairLayouts(sunnyToFrosty,
|
|
2441
|
+
// covered by the crawling cloud (its upper quarter on a spare piece)
|
|
2442
|
+
forCloudStates(coveredStates, () => tracedForWipe('half-sun-lower', HALF_SUN_LOWER), coveredIn),
|
|
2443
|
+
// partly-cloudy / showers: laid out as the spine (the spares carry the half sun), which bends into the sun arc
|
|
2444
|
+
forCloudStates(SUN_CLOUD_STATES, () => SPINE_FOR_SUN_ARC, () => SUN_ARC_FOR_SPINE)), {
|
|
2445
|
+
'circle-from-right': circleFromRight,
|
|
2446
|
+
'half-sun-lower': HALF_SUN_LOWER,
|
|
2447
|
+
'spine-reversed': SPINE.map(reversedStroke).reverse(),
|
|
2448
|
+
'sun-arc-reversed': SUN_ARC.map(reversedStroke).reverse(),
|
|
2449
|
+
});
|
|
2450
|
+
const rays = rayPieces.map((rayPiece, rayIndex) => addFrosty(rayPiece, FROSTY_RAYS[rayIndex],
|
|
2451
|
+
// where the cloud state has no such ray: covered, or (partly-cloudy / showers) flying out as from sunny;
|
|
2452
|
+
// kept rays move
|
|
2453
|
+
forCloudStates(CLOUD_STATES.filter((state) => isNothing(rayPiece.strokes[state])), undefined, (state) => (SUN_CLOUD_STATES.includes(state) ? { draw: 0.6 } : COVERED))));
|
|
2454
|
+
// precipitation: rides in with the cloud, as from sunny
|
|
2455
|
+
const precipitation = (pieceSet) => addFrosty(pieceSet, pieceSet.strokes.sunny, forCloudStates(CLOUD_STATES.filter((state) => !isNothing(pieceSet.strokes[state])), (state) => pieceSet.pairLayouts.sunny?.[state] ?? RIDING_IN));
|
|
2456
|
+
const boltWithSpine = addFrosty(bolt, SPINE, mergePairLayouts({ sunny: { frosty: tracedForWipe('circle-right-half', CIRCLE_RIGHT_HALF) } },
|
|
2457
|
+
// during the crawl the spares (or the sun piece) carry the spine; thunder's bolt rides in
|
|
2458
|
+
forCloudStates(CLOUD_STATES, (state) => (state === 'thunder' ? RIDING_IN : POINT_LAYOUT))), { 'circle-right-half': CIRCLE_RIGHT_HALF, nothing: [lucideLine(12, 12, 12, 12)] });
|
|
2459
|
+
const spares = [
|
|
2460
|
+
// the spine's parts (covered); the first also carries the half sun's lower quarter for partly-cloudy / showers
|
|
2461
|
+
...SPINE_PARTS.map((part, partIndex) => sparePiece(mergePairLayouts(forCloudStates(coveredStates, () => tracedForWipe(`spine-part-${partIndex}`, part), coveredIn), partIndex === 0 ? forCloudStates(SUN_CLOUD_STATES, () => tracedForWipe('half-sun-lower', HALF_SUN_LOWER), coveredIn) : {}), { [`spine-part-${partIndex}`]: part, 'half-sun-lower': HALF_SUN_LOWER })),
|
|
2462
|
+
// the half sun's upper quarter
|
|
2463
|
+
sparePiece(forCloudStates(CLOUD_STATES, () => tracedForWipe('half-sun-upper', HALF_SUN_UPPER), coveredIn), { 'half-sun-upper': HALF_SUN_UPPER }),
|
|
2464
|
+
// the open clouds' lower left end
|
|
2465
|
+
sparePiece(forCloudStates(Object.keys(CLOUD_LEFT_ENDS), () => RIDING_IN, (state) => tracedForWipe(`${state}-cloud-left-end`, CLOUD_LEFT_ENDS[state])), Object.fromEntries(Object.entries(CLOUD_LEFT_ENDS).map(([state, end]) => [`${state}-cloud-left-end`, end]))),
|
|
2466
|
+
];
|
|
2467
|
+
return {
|
|
2468
|
+
...cloud,
|
|
2469
|
+
morePieces: [
|
|
2470
|
+
sun, ...rays, precipitation(rain1), precipitation(rain2), boltWithSpine, precipitation(rain3),
|
|
2471
|
+
...bottomFlakes.map(precipitation), ...spares,
|
|
2472
|
+
],
|
|
2473
|
+
};
|
|
2474
|
+
};
|
|
2475
|
+
const weatherIcons = withFrosty(rainTwinnedIcons);
|
|
2476
|
+
return { weatherIcons };
|
|
2477
|
+
})();
|
|
2478
|
+
|
|
2479
|
+
// sets/eye.ts
|
|
2480
|
+
const { eyeIcons } = (() => {
|
|
2481
|
+
// the lids: top lid = arc around 12,15.7506 (r 10.75), bottom lid = arc around 12,8.2494; the corners: small arcs (r 1)
|
|
2482
|
+
const topLid = (startAngle, endAngle) => lucideArc(12, 15.7506, 10.75, startAngle, endAngle);
|
|
2483
|
+
const bottomLid = (startAngle, endAngle) => lucideArc(12, 8.2494, 10.75, startAngle, endAngle);
|
|
2484
|
+
const RIGHT_CORNER = lucideArc(21.0005, 12, 1, -20.365, 20.365);
|
|
2485
|
+
const LEFT_CORNER = lucideArc(2.9995, 12, 1, 159.635, 200.365);
|
|
2486
|
+
/** The middles of eye-off's gaps: on the top lid (between -120.724° and -96.796°) and the bottom lid (37.8° … 59.4°). */
|
|
2487
|
+
const TOP_GAP_MIDDLE = (-120.724 - 96.796) / 2;
|
|
2488
|
+
const BOTTOM_GAP_MIDDLE = (37.8063 + 59.3592) / 2;
|
|
2489
|
+
// each part: lid, corner, lid — 6 + 2 + 4 segments in both states, so the corner stays and the lids only shorten
|
|
2490
|
+
const PART_LAYOUT = { breakpoints: [0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1.5, 2, 2.25, 2.5, 2.75, 3] };
|
|
2491
|
+
const rightPart = {
|
|
2492
|
+
strokes: {
|
|
2493
|
+
visible: [topLid(TOP_GAP_MIDDLE, -22.4122), RIGHT_CORNER, bottomLid(22.4122, BOTTOM_GAP_MIDDLE)],
|
|
2494
|
+
hidden: [topLid(-96.796, -22.4122), RIGHT_CORNER, bottomLid(22.4122, 37.8063)],
|
|
2495
|
+
},
|
|
2496
|
+
alternativeStrokes: {},
|
|
2497
|
+
pairLayouts: { visible: { hidden: PART_LAYOUT }, hidden: { visible: PART_LAYOUT } },
|
|
2498
|
+
};
|
|
2499
|
+
const leftPart = {
|
|
2500
|
+
strokes: {
|
|
2501
|
+
visible: [bottomLid(BOTTOM_GAP_MIDDLE, 157.5878), LEFT_CORNER, topLid(-157.5878, TOP_GAP_MIDDLE)],
|
|
2502
|
+
hidden: [bottomLid(59.3592, 157.5878), LEFT_CORNER, topLid(-157.5878, -120.724)],
|
|
2503
|
+
},
|
|
2504
|
+
alternativeStrokes: {},
|
|
2505
|
+
pairLayouts: { visible: { hidden: PART_LAYOUT }, hidden: { visible: PART_LAYOUT } },
|
|
2506
|
+
};
|
|
2507
|
+
/** eye-off's pupil: the half circle clockwise from 46° (lower right) to 224° (upper left). */
|
|
2508
|
+
const HALF_PUPIL = [lucideArc(12, 12, 3, 45.9957, 224.0043)];
|
|
2509
|
+
/** The middle of the pupil's gap (upper right), from where the gap pulls back into the half's ends. */
|
|
2510
|
+
const PUPIL_GAP_MIDDLE = (224.0043 + 405.9957) / 2;
|
|
2511
|
+
const GAP_HALF_SHARE = (405.9957 - 224.0043) / 2 / 360;
|
|
2512
|
+
const pupil = {
|
|
2513
|
+
strokes: { visible: [lucideArc(12, 12, 3, -90, 270)], hidden: HALF_PUPIL },
|
|
2514
|
+
alternativeStrokes: { 'pupil-from-gap-middle': [lucideArc(12, 12, 3, PUPIL_GAP_MIDDLE, PUPIL_GAP_MIDDLE + 360)] },
|
|
2515
|
+
pairLayouts: {
|
|
2516
|
+
// 3 segments per gap half, 6 along the half circle (it stays)
|
|
2517
|
+
visible: {
|
|
2518
|
+
hidden: {
|
|
2519
|
+
tracing: 'pupil-from-gap-middle',
|
|
2520
|
+
breakpoints: [
|
|
2521
|
+
...[0, 1, 2].map((index) => (index / 3) * GAP_HALF_SHARE),
|
|
2522
|
+
...[0, 1, 2, 3, 4, 5].map((index) => GAP_HALF_SHARE + (index / 6) * (1 - 2 * GAP_HALF_SHARE)),
|
|
2523
|
+
...[0, 1, 2, 3].map((index) => 1 - GAP_HALF_SHARE + (index / 3) * GAP_HALF_SHARE),
|
|
2524
|
+
].map((breakpoint) => +breakpoint.toFixed(4)),
|
|
2525
|
+
},
|
|
2526
|
+
},
|
|
2527
|
+
hidden: { visible: { breakpoints: [0, 0, 0, 0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1, 1, 1] } },
|
|
2528
|
+
},
|
|
2529
|
+
};
|
|
2530
|
+
const slash = {
|
|
2531
|
+
strokes: { visible: lucidePoint(2, 2), hidden: LUCIDE_SLASH },
|
|
2532
|
+
alternativeStrokes: {},
|
|
2533
|
+
pairLayouts: { visible: { hidden: { draw: true } } },
|
|
2534
|
+
};
|
|
2535
|
+
const eyeIcons = {
|
|
2536
|
+
segmentCount: 12,
|
|
2537
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2538
|
+
lineCap: 'round',
|
|
2539
|
+
lineJoin: 'round',
|
|
2540
|
+
...rightPart,
|
|
2541
|
+
morePieces: [leftPart, pupil, slash],
|
|
2542
|
+
spins: {},
|
|
2543
|
+
};
|
|
2544
|
+
return { eyeIcons };
|
|
2545
|
+
})();
|
|
2546
|
+
|
|
2547
|
+
// sets/volume.ts
|
|
2548
|
+
const { volumeIcons } = (() => {
|
|
2549
|
+
/** Lucide's speaker, from the top of its front edge counterclockwise around. */
|
|
2550
|
+
const SPEAKER = [
|
|
2551
|
+
lucideArc(10.295, 4.703, 0.705, -0.0828, -134.9415),
|
|
2552
|
+
lucideLine(9.797, 4.204, 6.413, 7.587),
|
|
2553
|
+
lucideArc(5.4201, 6.6, 1.4, 44.8292, 90.168),
|
|
2554
|
+
lucideLine(5.416, 8, 3, 8),
|
|
2555
|
+
lucideArc(3, 9, 1, -90, -180),
|
|
2556
|
+
lucideLine(2, 9, 2, 15),
|
|
2557
|
+
lucideArc(3, 15, 1, 180, 90),
|
|
2558
|
+
lucideLine(3, 16, 5.416, 16),
|
|
2559
|
+
lucideArc(5.4201, 17.4, 1.4, -90.168, -44.8292),
|
|
2560
|
+
lucideLine(6.413, 16.413, 9.796, 19.797),
|
|
2561
|
+
lucideArc(10.295, 19.299, 0.705, 135.0564, -0.0797),
|
|
2562
|
+
lucideLine(11, 19.298, 11, 4.702),
|
|
2563
|
+
];
|
|
2564
|
+
/** The waves, each from its top end down. */
|
|
2565
|
+
const SMALL_WAVE = [lucideArc(12, 12, 5, -36.8699, 36.8699)];
|
|
2566
|
+
const BIG_WAVE = [lucideArc(13.0001, 12, 9, -45.0004, 45.0004)];
|
|
2567
|
+
/** The ✕, each line from its top end down (so a wave unfolds into it without turning over). */
|
|
2568
|
+
const X_SLASH = [lucideLine(21.5, 9.5, 16.5, 14.5)];
|
|
2569
|
+
const X_BACKSLASH = [lucideLine(16.5, 9.5, 21.5, 14.5)];
|
|
2570
|
+
/** No wave: a point on the middle of the speaker's front edge. */
|
|
2571
|
+
const NO_WAVE = lucidePoint(11, 12);
|
|
2572
|
+
const speaker = {
|
|
2573
|
+
strokes: { muted: SPEAKER, low: SPEAKER, medium: SPEAKER, high: SPEAKER },
|
|
2574
|
+
alternativeStrokes: {},
|
|
2575
|
+
pairLayouts: {},
|
|
2576
|
+
};
|
|
2577
|
+
const smallWave = {
|
|
2578
|
+
strokes: { muted: X_SLASH, low: NO_WAVE, medium: SMALL_WAVE, high: SMALL_WAVE },
|
|
2579
|
+
alternativeStrokes: {},
|
|
2580
|
+
pairLayouts: {},
|
|
2581
|
+
};
|
|
2582
|
+
const bigWave = {
|
|
2583
|
+
strokes: { muted: X_BACKSLASH, low: NO_WAVE, medium: SMALL_WAVE, high: BIG_WAVE },
|
|
2584
|
+
alternativeStrokes: {},
|
|
2585
|
+
pairLayouts: {},
|
|
2586
|
+
};
|
|
2587
|
+
const volumeIcons = {
|
|
2588
|
+
segmentCount: 16,
|
|
2589
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2590
|
+
lineCap: 'round',
|
|
2591
|
+
lineJoin: 'round',
|
|
2592
|
+
...speaker,
|
|
2593
|
+
morePieces: [smallWave, bigWave],
|
|
2594
|
+
spins: {},
|
|
2595
|
+
};
|
|
2596
|
+
return { volumeIcons };
|
|
2597
|
+
})();
|
|
2598
|
+
|
|
2599
|
+
// sets/toggle.ts
|
|
2600
|
+
const { toggleIcons } = (() => {
|
|
2601
|
+
const HORIZONTAL = [lucideLine(5, 12, 19, 12)];
|
|
2602
|
+
const first = {
|
|
2603
|
+
strokes: {
|
|
2604
|
+
plus: HORIZONTAL,
|
|
2605
|
+
minus: HORIZONTAL,
|
|
2606
|
+
close: [lucideLine(6, 6, 18, 18)],
|
|
2607
|
+
check: [lucideLine(4, 12, 9, 17)],
|
|
2608
|
+
},
|
|
2609
|
+
alternativeStrokes: {},
|
|
2610
|
+
pairLayouts: {},
|
|
2611
|
+
};
|
|
2612
|
+
const second = {
|
|
2613
|
+
strokes: {
|
|
2614
|
+
plus: [lucideLine(12, 5, 12, 19)],
|
|
2615
|
+
minus: HORIZONTAL,
|
|
2616
|
+
close: [lucideLine(18, 6, 6, 18)],
|
|
2617
|
+
check: [lucideLine(20, 6, 9, 17)],
|
|
2618
|
+
},
|
|
2619
|
+
alternativeStrokes: {},
|
|
2620
|
+
pairLayouts: {},
|
|
2621
|
+
};
|
|
2622
|
+
const toggleIcons = {
|
|
2623
|
+
segmentCount: 1,
|
|
2624
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2625
|
+
lineCap: 'round',
|
|
2626
|
+
lineJoin: 'round',
|
|
2627
|
+
...first,
|
|
2628
|
+
morePieces: [second],
|
|
2629
|
+
// plus turned by 45° clockwise is the ✕ (a little smaller): turn while blending
|
|
2630
|
+
spins: { plus: { close: 45 }, close: { plus: -45 } },
|
|
2631
|
+
};
|
|
2632
|
+
return { toggleIcons };
|
|
2633
|
+
})();
|
|
2634
|
+
|
|
2635
|
+
// sets/lock.ts
|
|
2636
|
+
const { lockIcons } = (() => {
|
|
2637
|
+
const BODY = [
|
|
2638
|
+
lucideLine(5, 11, 19, 11),
|
|
2639
|
+
lucideArc(19, 13, 2, -90, 0),
|
|
2640
|
+
lucideLine(21, 13, 21, 20),
|
|
2641
|
+
lucideArc(19, 20, 2, 0, 90),
|
|
2642
|
+
lucideLine(19, 22, 5, 22),
|
|
2643
|
+
lucideArc(5, 20, 2, 90, 180),
|
|
2644
|
+
lucideLine(3, 20, 3, 13),
|
|
2645
|
+
lucideArc(5, 13, 2, 180, 270),
|
|
2646
|
+
];
|
|
2647
|
+
const LEFT_LEG = lucideLine(7, 11, 7, 7);
|
|
2648
|
+
const body = { strokes: { locked: BODY, unlocked: BODY }, alternativeStrokes: {}, pairLayouts: {} };
|
|
2649
|
+
// left leg 2 segments, arch 6, right leg 4 — unlocked has no right leg: 4 zero-length segments at the arch's end
|
|
2650
|
+
const shackle = {
|
|
2651
|
+
strokes: {
|
|
2652
|
+
locked: [LEFT_LEG, lucideArc(12, 7, 5, 180, 360), lucideLine(17, 7, 17, 11)],
|
|
2653
|
+
unlocked: [LEFT_LEG, lucideArc(12, 6.995, 5, 179.9424, 348.5218)],
|
|
2654
|
+
},
|
|
2655
|
+
alternativeStrokes: {},
|
|
2656
|
+
pairLayouts: {
|
|
2657
|
+
locked: { unlocked: { breakpoints: [0, 0.5, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2.25, 2.5, 2.75, 3] } },
|
|
2658
|
+
unlocked: { locked: { breakpoints: [0, 0.5, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2, 2, 2] } },
|
|
2659
|
+
},
|
|
2660
|
+
};
|
|
2661
|
+
const lockIcons = {
|
|
2662
|
+
segmentCount: 12,
|
|
2663
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2664
|
+
lineCap: 'round',
|
|
2665
|
+
lineJoin: 'round',
|
|
2666
|
+
...body,
|
|
2667
|
+
morePieces: [shackle],
|
|
2668
|
+
spins: {},
|
|
2669
|
+
};
|
|
2670
|
+
return { lockIcons };
|
|
2671
|
+
})();
|
|
2672
|
+
|
|
2673
|
+
// sets/bell.ts
|
|
2674
|
+
const { bellIcons } = (() => {
|
|
2675
|
+
const CLAPPER = [lucideArc(12, 19.9999, 2, 149.9971, 30.0029)];
|
|
2676
|
+
/** Where the dome is split in "on": the middle of bell-off's gap on it (196.86° … 236.27°). */
|
|
2677
|
+
const DOME_GAP_MIDDLE = (196.8632 + 236.2663) / 2;
|
|
2678
|
+
const dome = (startAngle, endAngle) => lucideArc(12, 8, 6, startAngle, endAngle);
|
|
2679
|
+
const clapper = { strokes: { on: CLAPPER, off: CLAPPER }, alternativeStrokes: {}, pairLayouts: {} };
|
|
2680
|
+
// bottom line (3 segments), left tip (2), left side (4), dome (3) — the same in both states
|
|
2681
|
+
const LEFT_LAYOUT = { breakpoints: [0, 0.3333, 0.6667, 1, 1.5, 2, 2.25, 2.5, 2.75, 3, 3.3333, 3.6667, 4] };
|
|
2682
|
+
const leftPart = {
|
|
2683
|
+
strokes: {
|
|
2684
|
+
on: [
|
|
2685
|
+
lucideLine(20, 17, 4, 17),
|
|
2686
|
+
lucideArc(4.0007, 16, 1, 90.0419, 222.3766),
|
|
2687
|
+
lucideCubic(3.262, 15.326, 4.589, 13.956, 6, 12.499, 6, 8),
|
|
2688
|
+
dome(180, DOME_GAP_MIDDLE),
|
|
2689
|
+
],
|
|
2690
|
+
off: [
|
|
2691
|
+
lucideLine(17, 17, 4, 17),
|
|
2692
|
+
lucideArc(3.9996, 16, 1, 89.9795, 222.299),
|
|
2693
|
+
lucideCubic(3.26, 15.327, 4.59, 13.956, 6, 12.499, 6, 8),
|
|
2694
|
+
lucideArc(12, 7.9985, 6, 179.9859, 196.8632),
|
|
2695
|
+
],
|
|
2696
|
+
},
|
|
2697
|
+
alternativeStrokes: {},
|
|
2698
|
+
pairLayouts: { on: { off: LEFT_LAYOUT }, off: { on: LEFT_LAYOUT } },
|
|
2699
|
+
};
|
|
2700
|
+
// dome (4 segments), right side (6), right tip (2) — off has no tip and only the side's upper part: 2 zero-length
|
|
2701
|
+
// segments at its end
|
|
2702
|
+
const rightPart = {
|
|
2703
|
+
strokes: {
|
|
2704
|
+
on: [
|
|
2705
|
+
dome(DOME_GAP_MIDDLE, 360),
|
|
2706
|
+
lucideCubic(18, 8, 18, 12.499, 19.41, 13.956, 20.74, 15.327),
|
|
2707
|
+
lucideArc(20.0004, 16, 1, -42.299, 90.0205),
|
|
2708
|
+
],
|
|
2709
|
+
off: [
|
|
2710
|
+
lucideArc(12, 7.9998, 6, -123.7337, 0.0022),
|
|
2711
|
+
lucideCubic(18, 8, 18, 10.687, 18.77, 12.653, 19.707, 14.05),
|
|
2712
|
+
],
|
|
2713
|
+
},
|
|
2714
|
+
alternativeStrokes: {},
|
|
2715
|
+
pairLayouts: {
|
|
2716
|
+
on: { off: { breakpoints: [0, 0.25, 0.5, 0.75, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2.5, 3] } },
|
|
2717
|
+
off: { on: { breakpoints: [0, 0.25, 0.5, 0.75, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2] } },
|
|
2718
|
+
},
|
|
2719
|
+
};
|
|
2720
|
+
const slash = {
|
|
2721
|
+
strokes: { on: lucidePoint(2, 2), off: LUCIDE_SLASH },
|
|
2722
|
+
alternativeStrokes: {},
|
|
2723
|
+
pairLayouts: { on: { off: { draw: true } } },
|
|
2724
|
+
};
|
|
2725
|
+
const bellIcons = {
|
|
2726
|
+
segmentCount: 12,
|
|
2727
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2728
|
+
lineCap: 'round',
|
|
2729
|
+
lineJoin: 'round',
|
|
2730
|
+
...clapper,
|
|
2731
|
+
morePieces: [leftPart, rightPart, slash],
|
|
2732
|
+
spins: {},
|
|
2733
|
+
};
|
|
2734
|
+
return { bellIcons };
|
|
2735
|
+
})();
|
|
2736
|
+
|
|
2737
|
+
// sets/mic.ts
|
|
2738
|
+
const { micIcons } = (() => {
|
|
2739
|
+
const STAND = [lucideLine(12, 19, 12, 22)];
|
|
2740
|
+
/** Where the mic's capsule is split: the middles of mic-off's gaps (right side y 11.85, left side y 6.3). */
|
|
2741
|
+
const RIGHT_SPLIT_Y = 11.85;
|
|
2742
|
+
const LEFT_SPLIT_Y = 6.3;
|
|
2743
|
+
/** Where the holder is split: the middle of its gap (10.17° … 45°). */
|
|
2744
|
+
const HOLDER_GAP_MIDDLE = (10.1709 + 44.9971) / 2;
|
|
2745
|
+
const stand = { strokes: { on: STAND, off: STAND }, alternativeStrokes: {}, pairLayouts: {} };
|
|
2746
|
+
// line, arc, line — 3 + 6 + 3 segments; off has no last line: 3 zero-length segments at its end
|
|
2747
|
+
const SPLIT_LAYOUT = { breakpoints: [0, 0.3333, 0.6667, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2.3333, 2.6667, 3] };
|
|
2748
|
+
const CUT_LAYOUT = { breakpoints: [0, 0.3333, 0.6667, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2, 2, 2, 2] };
|
|
2749
|
+
const capsuleTop = {
|
|
2750
|
+
strokes: {
|
|
2751
|
+
// up the right side, over the top (counterclockwise), down the left side to the split
|
|
2752
|
+
on: [lucideLine(15, RIGHT_SPLIT_Y, 15, 5), lucideArc(12, 5, 3, 0, -180), lucideLine(9, 5, 9, LEFT_SPLIT_Y)],
|
|
2753
|
+
off: [lucideLine(15, 9.34, 15, 5), lucideArc(12.0001, 5.0181, 3, -0.3452, -153.2975)],
|
|
2754
|
+
},
|
|
2755
|
+
alternativeStrokes: {},
|
|
2756
|
+
pairLayouts: { on: { off: SPLIT_LAYOUT }, off: { on: CUT_LAYOUT } },
|
|
2757
|
+
};
|
|
2758
|
+
const capsuleBottom = {
|
|
2759
|
+
strokes: {
|
|
2760
|
+
// down the left side, around the bottom (counterclockwise), up the right side to the split
|
|
2761
|
+
on: [lucideLine(9, LEFT_SPLIT_Y, 9, 12), lucideArc(12, 12, 3, 180, 0), lucideLine(15, 12, 15, RIGHT_SPLIT_Y)],
|
|
2762
|
+
off: [lucideLine(9, 9, 9, 12), lucideArc(12, 11.9974, 3, 179.9496, 45.0356)],
|
|
2763
|
+
},
|
|
2764
|
+
alternativeStrokes: {},
|
|
2765
|
+
pairLayouts: { on: { off: SPLIT_LAYOUT }, off: { on: CUT_LAYOUT } },
|
|
2766
|
+
};
|
|
2767
|
+
// arc then line, 6 + 6 segments in both states (the arc only shortens)
|
|
2768
|
+
const HOLDER_LAYOUT = { breakpoints: [0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1, 1.1667, 1.3333, 1.5, 1.6667, 1.8333, 2] };
|
|
2769
|
+
const holderLeft = {
|
|
2770
|
+
strokes: {
|
|
2771
|
+
on: [lucideArc(12, 12, 7, HOLDER_GAP_MIDDLE, 180), lucideLine(5, 12, 5, 10)],
|
|
2772
|
+
off: [lucideArc(12, 12.0005, 7, 44.9971, 180.0041), lucideLine(5, 12, 5, 10)],
|
|
2773
|
+
},
|
|
2774
|
+
alternativeStrokes: {},
|
|
2775
|
+
pairLayouts: { on: { off: HOLDER_LAYOUT }, off: { on: HOLDER_LAYOUT } },
|
|
2776
|
+
};
|
|
2777
|
+
const holderRight = {
|
|
2778
|
+
strokes: {
|
|
2779
|
+
on: [lucideArc(12, 12, 7, HOLDER_GAP_MIDDLE, 0), lucideLine(19, 12, 19, 10)],
|
|
2780
|
+
off: [lucideArc(12, 11.9939, 7, 10.1709, 0.0499), lucideLine(19, 12, 19, 10)],
|
|
2781
|
+
},
|
|
2782
|
+
alternativeStrokes: {},
|
|
2783
|
+
pairLayouts: { on: { off: HOLDER_LAYOUT }, off: { on: HOLDER_LAYOUT } },
|
|
2784
|
+
};
|
|
2785
|
+
const slash = {
|
|
2786
|
+
strokes: { on: lucidePoint(2, 2), off: LUCIDE_SLASH },
|
|
2787
|
+
alternativeStrokes: {},
|
|
2788
|
+
pairLayouts: { on: { off: { draw: true } } },
|
|
2789
|
+
};
|
|
2790
|
+
const micIcons = {
|
|
2791
|
+
segmentCount: 12,
|
|
2792
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2793
|
+
lineCap: 'round',
|
|
2794
|
+
lineJoin: 'round',
|
|
2795
|
+
...stand,
|
|
2796
|
+
morePieces: [capsuleTop, capsuleBottom, holderLeft, holderRight, slash],
|
|
2797
|
+
spins: {},
|
|
2798
|
+
};
|
|
2799
|
+
return { micIcons };
|
|
2800
|
+
})();
|
|
2801
|
+
|
|
2802
|
+
// sets/chevron.ts
|
|
2803
|
+
const { chevronIcons } = (() => {
|
|
2804
|
+
const STATES = ['up', 'right', 'down', 'left'];
|
|
2805
|
+
/** chevron-up's corners (right end, tip, left end), turned clockwise by `quarterTurns` × 90° around 12,12. */
|
|
2806
|
+
const chevron = (quarterTurns) => {
|
|
2807
|
+
const turn = ([x, y]) => {
|
|
2808
|
+
let [dx, dy] = [x - 12, y - 12];
|
|
2809
|
+
for (let step = 0; step < quarterTurns; step++)
|
|
2810
|
+
[dx, dy] = [-dy, dx];
|
|
2811
|
+
return [12 + dx, 12 + dy];
|
|
2812
|
+
};
|
|
2813
|
+
const [rightEnd, tip, leftEnd] = [[18, 15], [12, 9], [6, 15]].map(turn);
|
|
2814
|
+
return [lucideLine(...rightEnd, ...tip), lucideLine(...tip, ...leftEnd)];
|
|
2815
|
+
};
|
|
2816
|
+
/** Degrees from one state to another: the shorter way round (half a turn: clockwise). */
|
|
2817
|
+
const turnBetween = (fromIndex, toIndex) => {
|
|
2818
|
+
const quarterTurns = (toIndex - fromIndex + 4) % 4;
|
|
2819
|
+
return quarterTurns === 3 ? -90 : quarterTurns * 90;
|
|
2820
|
+
};
|
|
2821
|
+
const chevronIcons = {
|
|
2822
|
+
segmentCount: 2,
|
|
2823
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2824
|
+
lineCap: 'round',
|
|
2825
|
+
lineJoin: 'round',
|
|
2826
|
+
strokes: Object.fromEntries(STATES.map((state, index) => [state, chevron(index)])),
|
|
2827
|
+
alternativeStrokes: {},
|
|
2828
|
+
pairLayouts: {},
|
|
2829
|
+
spins: Object.fromEntries(STATES.map((state, fromIndex) => [state, Object.fromEntries(STATES
|
|
2830
|
+
.filter((other) => other !== state).map((other) => [other, turnBetween(fromIndex, STATES.indexOf(other))]))])),
|
|
2831
|
+
};
|
|
2832
|
+
return { chevronIcons };
|
|
2833
|
+
})();
|
|
2834
|
+
|
|
2835
|
+
// sets/copy.ts
|
|
2836
|
+
const { copyIcons } = (() => {
|
|
2837
|
+
/** The front page, clockwise from its top edge. */
|
|
2838
|
+
const FRONT_PAGE = [
|
|
2839
|
+
lucideLine(10, 8, 20, 8),
|
|
2840
|
+
lucideArc(20, 10, 2, -90, 0),
|
|
2841
|
+
lucideLine(22, 10, 22, 20),
|
|
2842
|
+
lucideArc(20, 20, 2, 0, 90),
|
|
2843
|
+
lucideLine(20, 22, 10, 22),
|
|
2844
|
+
lucideArc(10, 20, 2, 90, 180),
|
|
2845
|
+
lucideLine(8, 20, 8, 10),
|
|
2846
|
+
lucideArc(10, 10, 2, 180, 270),
|
|
2847
|
+
];
|
|
2848
|
+
/** The back page: the L that shows behind the front page, from its bottom end up and over. */
|
|
2849
|
+
const BACK_PAGE = [
|
|
2850
|
+
lucideCubic(4, 16, 2.9, 16, 2, 15.1, 2, 14),
|
|
2851
|
+
lucideLine(2, 14, 2, 4),
|
|
2852
|
+
lucideCubic(2, 4, 2, 2.9, 2.9, 2, 4, 2),
|
|
2853
|
+
lucideLine(4, 2, 14, 2),
|
|
2854
|
+
lucideCubic(14, 2, 15.1, 2, 16, 2.9, 16, 4),
|
|
2855
|
+
];
|
|
2856
|
+
const CHECK = [lucideLine(20, 6, 9, 17), lucideLine(9, 17, 4, 12)];
|
|
2857
|
+
/** The check as a loop matching the page: corner → long end → corner → short end → corner. */
|
|
2858
|
+
const CHECK_LOOP = [lucideLine(9, 17, 20, 6), lucideLine(20, 6, 9, 17), lucideLine(9, 17, 4, 12), lucideLine(4, 12, 9, 17)];
|
|
2859
|
+
const page = {
|
|
2860
|
+
strokes: { copy: FRONT_PAGE, copied: CHECK },
|
|
2861
|
+
alternativeStrokes: { 'check-loop': CHECK_LOOP },
|
|
2862
|
+
pairLayouts: {
|
|
2863
|
+
// the page's default spread: 2 segments per edge, 1 per corner; each edge → one stroke of the loop, each
|
|
2864
|
+
// corner → a zero-length segment at its end
|
|
2865
|
+
copied: { copy: { tracing: 'check-loop', breakpoints: [0, 0.5, 1, 1, 1.5, 2, 2, 2.5, 3, 3, 3.5, 4, 4] } },
|
|
2866
|
+
},
|
|
2867
|
+
};
|
|
2868
|
+
const backPage = {
|
|
2869
|
+
strokes: { copy: BACK_PAGE, copied: lucidePoint(4, 16) },
|
|
2870
|
+
alternativeStrokes: {},
|
|
2871
|
+
pairLayouts: { copied: { copy: { draw: 0.5 } } },
|
|
2872
|
+
};
|
|
2873
|
+
const copyIcons = {
|
|
2874
|
+
segmentCount: 12,
|
|
2875
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2876
|
+
lineCap: 'round',
|
|
2877
|
+
lineJoin: 'round',
|
|
2878
|
+
...page,
|
|
2879
|
+
morePieces: [backPage],
|
|
2880
|
+
spins: {},
|
|
2881
|
+
};
|
|
2882
|
+
return { copyIcons };
|
|
2883
|
+
})();
|
|
2884
|
+
|
|
2885
|
+
// sets/wifi.ts
|
|
2886
|
+
const { wifiIcons } = (() => {
|
|
2887
|
+
const STATES = ['none', 'low', 'medium', 'high'];
|
|
2888
|
+
const DOT = [lucideLine(12, 20, 12.01, 20)];
|
|
2889
|
+
/** The arcs, small to big, each clockwise from its left end. */
|
|
2890
|
+
const ARCS = [
|
|
2891
|
+
[lucideArc(12, 19.9997, 5, -134.427, -45.573)],
|
|
2892
|
+
[lucideArc(12, 20.0004, 10, -134.427, -45.573)],
|
|
2893
|
+
[lucideArc(12, 20.0003, 15, -131.8103, -48.1897)],
|
|
2894
|
+
];
|
|
2895
|
+
const NO_ARC = lucidePoint(12, 20);
|
|
2896
|
+
/** Arc `arcIndex` in each state: itself, else the outermost arc that state has (or a point on the dot). */
|
|
2897
|
+
const arcPiece = (arcIndex) => ({
|
|
2898
|
+
strokes: Object.fromEntries(STATES.map((state, level) => [state, level > arcIndex ? ARCS[arcIndex] : level > 0 ? ARCS[level - 1] : NO_ARC])),
|
|
2899
|
+
alternativeStrokes: {},
|
|
2900
|
+
pairLayouts: {},
|
|
2901
|
+
});
|
|
2902
|
+
const wifiIcons = {
|
|
2903
|
+
segmentCount: 4,
|
|
2904
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2905
|
+
lineCap: 'round',
|
|
2906
|
+
lineJoin: 'round',
|
|
2907
|
+
strokes: Object.fromEntries(STATES.map((state) => [state, DOT])),
|
|
2908
|
+
alternativeStrokes: {},
|
|
2909
|
+
pairLayouts: {},
|
|
2910
|
+
morePieces: [0, 1, 2].map(arcPiece),
|
|
2911
|
+
spins: {},
|
|
2912
|
+
};
|
|
2913
|
+
return { wifiIcons };
|
|
2914
|
+
})();
|
|
2915
|
+
|
|
2916
|
+
// sets/battery.ts
|
|
2917
|
+
const { batteryIcons } = (() => {
|
|
2918
|
+
const STATES = ['empty', 'low', 'medium', 'full'];
|
|
2919
|
+
const BODY = [
|
|
2920
|
+
lucideLine(4, 6, 16, 6),
|
|
2921
|
+
lucideArc(16, 8, 2, -90, 0),
|
|
2922
|
+
lucideLine(18, 8, 18, 16),
|
|
2923
|
+
lucideArc(16, 16, 2, 0, 90),
|
|
2924
|
+
lucideLine(16, 18, 4, 18),
|
|
2925
|
+
lucideArc(4, 16, 2, 90, 180),
|
|
2926
|
+
lucideLine(2, 16, 2, 8),
|
|
2927
|
+
lucideArc(4, 8, 2, 180, 270),
|
|
2928
|
+
];
|
|
2929
|
+
const TERMINAL = [lucideLine(22, 14, 22, 10)];
|
|
2930
|
+
const BAR_X = [6, 10, 14];
|
|
2931
|
+
/**
|
|
2932
|
+
* When bar `barIndex` is drawn in from `fromLevel` to `toLevel` (more bars): the bars coming in share the morph,
|
|
2933
|
+
* each taking 2 / (count + 1) of it, starting half of that after the one before.
|
|
2934
|
+
*/
|
|
2935
|
+
const barDrawn = (barIndex, fromLevel, toLevel) => {
|
|
2936
|
+
const count = toLevel - fromLevel;
|
|
2937
|
+
const duration = 2 / (count + 1);
|
|
2938
|
+
const start = (barIndex - fromLevel) * (duration / 2);
|
|
2939
|
+
return { draw: [+start.toFixed(4), +(start + duration).toFixed(4)] };
|
|
2940
|
+
};
|
|
2941
|
+
const barPiece = (barIndex) => ({
|
|
2942
|
+
strokes: Object.fromEntries(STATES.map((state, level) => [state, level > barIndex
|
|
2943
|
+
? [lucideLine(BAR_X[barIndex], 14, BAR_X[barIndex], 10)]
|
|
2944
|
+
: lucidePoint(BAR_X[barIndex], 14)])),
|
|
2945
|
+
alternativeStrokes: {},
|
|
2946
|
+
// on the state without the bar, towards each state with it
|
|
2947
|
+
pairLayouts: Object.fromEntries(STATES.slice(0, barIndex + 1).map((state, fromLevel) => [state, Object.fromEntries(STATES.slice(barIndex + 1).map((other, offset) => [other, barDrawn(barIndex, fromLevel, barIndex + 1 + offset)]))])),
|
|
2948
|
+
});
|
|
2949
|
+
const fixedPiece = (strokes) => ({
|
|
2950
|
+
strokes: Object.fromEntries(STATES.map((state) => [state, strokes])),
|
|
2951
|
+
alternativeStrokes: {},
|
|
2952
|
+
pairLayouts: {},
|
|
2953
|
+
});
|
|
2954
|
+
const batteryIcons = {
|
|
2955
|
+
segmentCount: 12,
|
|
2956
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
2957
|
+
lineCap: 'round',
|
|
2958
|
+
lineJoin: 'round',
|
|
2959
|
+
...fixedPiece(BODY),
|
|
2960
|
+
morePieces: [fixedPiece(TERMINAL), ...[0, 1, 2].map(barPiece)],
|
|
2961
|
+
spins: {},
|
|
2962
|
+
};
|
|
2963
|
+
return { batteryIcons };
|
|
2964
|
+
})();
|
|
2965
|
+
|
|
1073
2966
|
// iconSets.ts
|
|
1074
2967
|
const { iconSets, resolveIconSet } = (() => {
|
|
1075
2968
|
// The built-in icon sets, by name. `round` is the default.
|
|
@@ -1079,14 +2972,44 @@ const iconSets = {
|
|
|
1079
2972
|
condensed: condensedDigits,
|
|
1080
2973
|
slim: slimDigits,
|
|
1081
2974
|
handwritten: handwrittenDigits,
|
|
2975
|
+
media: mediaIcons,
|
|
2976
|
+
menu: menuIcons,
|
|
2977
|
+
checkbox: checkboxIcons,
|
|
2978
|
+
radio: radioIcons,
|
|
2979
|
+
folder: folderIcons,
|
|
2980
|
+
theme: themeIcons,
|
|
2981
|
+
weather: weatherIcons,
|
|
2982
|
+
eye: eyeIcons,
|
|
2983
|
+
volume: volumeIcons,
|
|
2984
|
+
toggle: toggleIcons,
|
|
2985
|
+
lock: lockIcons,
|
|
2986
|
+
bell: bellIcons,
|
|
2987
|
+
mic: micIcons,
|
|
2988
|
+
chevron: chevronIcons,
|
|
2989
|
+
copy: copyIcons,
|
|
2990
|
+
wifi: wifiIcons,
|
|
2991
|
+
battery: batteryIcons,
|
|
1082
2992
|
};
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
if (!(set in iconSets))
|
|
2993
|
+
// sets with sharper corners, built once per set and roundness (so their shapes are computed once too)
|
|
2994
|
+
const setsByRoundness = new WeakMap();
|
|
2995
|
+
/** A set given by name or as a set object, optionally with sharper corners (`roundness` 0 = sharp … 1 = own). */
|
|
2996
|
+
const resolveIconSet = (set = 'round', roundness) => {
|
|
2997
|
+
if (typeof set === 'string' && !(set in iconSets)) {
|
|
1088
2998
|
throw new Error(`morphico: unknown icon set "${set}" (known: ${Object.keys(iconSets).join(', ')})`);
|
|
1089
|
-
|
|
2999
|
+
}
|
|
3000
|
+
const iconSet = typeof set === 'string' ? iconSets[set] : set;
|
|
3001
|
+
if (roundness === undefined || roundness >= 1)
|
|
3002
|
+
return iconSet; // the own corners are the roundest
|
|
3003
|
+
if (!iconSet.withRoundness)
|
|
3004
|
+
throw new Error('morphico: this icon set has no adjustable roundness');
|
|
3005
|
+
const clamped = Math.max(0, roundness);
|
|
3006
|
+
let byRoundness = setsByRoundness.get(iconSet);
|
|
3007
|
+
if (!byRoundness)
|
|
3008
|
+
setsByRoundness.set(iconSet, byRoundness = new Map());
|
|
3009
|
+
let sharper = byRoundness.get(clamped);
|
|
3010
|
+
if (!sharper)
|
|
3011
|
+
byRoundness.set(clamped, sharper = iconSet.withRoundness(clamped));
|
|
3012
|
+
return sharper;
|
|
1090
3013
|
};
|
|
1091
3014
|
return { iconSets, resolveIconSet };
|
|
1092
3015
|
})();
|
|
@@ -1118,6 +3041,7 @@ const blend = (from, to, progress, spin) => (spin
|
|
|
1118
3041
|
const clampToUnit = (value) => Math.min(Math.max(value, 0), 1);
|
|
1119
3042
|
const createMorphingPath = (pathElement, iconSet, initialState) => {
|
|
1120
3043
|
const shapes = shapesOf(iconSet);
|
|
3044
|
+
const pieceLength = pieceLengthOf(iconSet);
|
|
1121
3045
|
let state = initialState;
|
|
1122
3046
|
let currentPath = null; // null while empty
|
|
1123
3047
|
let currentOpacity = 0;
|
|
@@ -1126,7 +3050,7 @@ const createMorphingPath = (pathElement, iconSet, initialState) => {
|
|
|
1126
3050
|
currentPath = path;
|
|
1127
3051
|
currentOpacity = path ? clampToUnit(opacity) : 0;
|
|
1128
3052
|
if (path)
|
|
1129
|
-
pathElement.setAttribute('d', toPathData(path));
|
|
3053
|
+
pathElement.setAttribute('d', toPathData(path, pieceLength));
|
|
1130
3054
|
else
|
|
1131
3055
|
pathElement.removeAttribute('d');
|
|
1132
3056
|
if (currentOpacity < 1)
|
|
@@ -1173,6 +3097,8 @@ const createMorphingPath = (pathElement, iconSet, initialState) => {
|
|
|
1173
3097
|
const from = start ?? scaleAroundCenter(target, EMPTY_SCALE);
|
|
1174
3098
|
const to = target ?? scaleAroundCenter(start, EMPTY_SCALE);
|
|
1175
3099
|
const spin = (settled && iconSet.spins[previousState]?.[nextState]) || 0;
|
|
3100
|
+
// pieces that appear or go are drawn along their path (only from a settled shape, between two real states)
|
|
3101
|
+
const drawn = settled && start && target ? drawnPieces(iconSet, previousState, nextState) : [];
|
|
1176
3102
|
const ease = typeof easing === 'function' ? easing : easings[easing];
|
|
1177
3103
|
const startTime = performance.now();
|
|
1178
3104
|
return new Promise((resolve) => {
|
|
@@ -1180,7 +3106,8 @@ const createMorphingPath = (pathElement, iconSet, initialState) => {
|
|
|
1180
3106
|
const progress = clampToUnit((frameTime - startTime) / duration);
|
|
1181
3107
|
if (progress < 1) {
|
|
1182
3108
|
const easedProgress = ease(progress);
|
|
1183
|
-
|
|
3109
|
+
const frame = withDrawnPieces(blend(from, to, easedProgress, spin), from, to, easedProgress, pieceLength, drawn);
|
|
3110
|
+
render(frame, startOpacity + (endOpacity - startOpacity) * easedProgress);
|
|
1184
3111
|
return true;
|
|
1185
3112
|
}
|
|
1186
3113
|
render(target, endOpacity);
|
|
@@ -1197,24 +3124,38 @@ return { EMPTY_STATE, hasState, createMorphingPath };
|
|
|
1197
3124
|
})();
|
|
1198
3125
|
|
|
1199
3126
|
// svg.ts
|
|
1200
|
-
const { createSvgElement, createIconSvg } = (() => {
|
|
3127
|
+
const { hasFixedStrokeWidth, createSvgElement, createIconSvg, createStrokedElement } = (() => {
|
|
1201
3128
|
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
3129
|
+
/** A CSS length as stroke width: drawn at that width on screen instead of scaling with the icon. */
|
|
3130
|
+
const hasFixedStrokeWidth = (style) => typeof style.strokeWidth === 'string';
|
|
1202
3131
|
const createSvgElement = (tagName, attributes = {}) => {
|
|
1203
3132
|
const element = document.createElementNS(SVG_NAMESPACE, tagName);
|
|
1204
3133
|
for (const [name, value] of Object.entries(attributes))
|
|
1205
3134
|
element.setAttribute(name, String(value));
|
|
1206
3135
|
return element;
|
|
1207
3136
|
};
|
|
1208
|
-
/** An <svg> drawn in the set's stroke style,
|
|
1209
|
-
const createIconSvg = (iconSet, viewBox) =>
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
3137
|
+
/** An <svg> drawn in the set's stroke style (or the given style), colored by the CSS `color` (currentColor). */
|
|
3138
|
+
const createIconSvg = (iconSet, viewBox, style = {}) => {
|
|
3139
|
+
const svg = createSvgElement('svg', {
|
|
3140
|
+
viewBox,
|
|
3141
|
+
fill: style.fill === true ? 'currentColor' : style.fill || 'none',
|
|
3142
|
+
stroke: 'currentColor',
|
|
3143
|
+
'stroke-linecap': style.lineCap ?? iconSet.lineCap,
|
|
3144
|
+
'stroke-linejoin': style.lineJoin ?? iconSet.lineJoin,
|
|
3145
|
+
});
|
|
3146
|
+
// a CSS length goes into the style (attributes can't hold rem / em / calc reliably); children inherit it
|
|
3147
|
+
if (hasFixedStrokeWidth(style))
|
|
3148
|
+
svg.style.strokeWidth = style.strokeWidth;
|
|
3149
|
+
else
|
|
3150
|
+
svg.setAttribute('stroke-width', String(style.strokeWidth ?? iconSet.strokeWidth));
|
|
3151
|
+
return svg;
|
|
3152
|
+
};
|
|
3153
|
+
/** A stroked child element (path, circle); with a fixed (CSS length) stroke width its line doesn't scale. */
|
|
3154
|
+
const createStrokedElement = (tagName, style, attributes = {}) => createSvgElement(tagName, {
|
|
3155
|
+
...attributes,
|
|
3156
|
+
...(hasFixedStrokeWidth(style) && { 'vector-effect': 'non-scaling-stroke' }),
|
|
1216
3157
|
});
|
|
1217
|
-
return { createSvgElement, createIconSvg };
|
|
3158
|
+
return { hasFixedStrokeWidth, createSvgElement, createIconSvg, createStrokedElement };
|
|
1218
3159
|
})();
|
|
1219
3160
|
|
|
1220
3161
|
// icon.ts
|
|
@@ -1228,13 +3169,15 @@ const checkedState = (iconSet, state) => {
|
|
|
1228
3169
|
};
|
|
1229
3170
|
/** Adds a morphing icon (an <svg>) to `element`. */
|
|
1230
3171
|
const icon = (element, options = {}) => {
|
|
1231
|
-
const { set, state = EMPTY_STATE, size = '1em', ...defaultMorphOptions } = options;
|
|
1232
|
-
const iconSet = resolveIconSet(set);
|
|
1233
|
-
const
|
|
3172
|
+
const { set, state = EMPTY_STATE, size = '1em', roundness, strokeWidth, lineCap, lineJoin, fill, ...defaultMorphOptions } = options;
|
|
3173
|
+
const iconSet = resolveIconSet(set, roundness);
|
|
3174
|
+
const style = { strokeWidth, lineCap, lineJoin, fill };
|
|
3175
|
+
const svg = createIconSvg(iconSet, '0 0 24 24', style);
|
|
1234
3176
|
svg.setAttribute('role', 'img');
|
|
1235
3177
|
svg.style.width = size;
|
|
1236
3178
|
svg.style.height = size;
|
|
1237
|
-
|
|
3179
|
+
svg.style.overflow = 'visible'; // sharp tips (roundness 0, miter joins) may reach past the 24 × 24 box
|
|
3180
|
+
const pathElement = svg.appendChild(createStrokedElement('path', style));
|
|
1238
3181
|
const morphingPath = createMorphingPath(pathElement, iconSet, checkedState(iconSet, state));
|
|
1239
3182
|
const updateLabel = () => svg.setAttribute('aria-label', morphingPath.state.trim());
|
|
1240
3183
|
updateLabel();
|
|
@@ -1261,58 +3204,40 @@ return { icon };
|
|
|
1261
3204
|
const { text } = (() => {
|
|
1262
3205
|
// Layer 2: a line of characters (clock, counter, date) in narrow cells; changed digits morph.
|
|
1263
3206
|
// Every character reuses the 24×24 icon drawing; only the viewBox is narrowed to its cell.
|
|
1264
|
-
/** Characters `text()` can show: digits,
|
|
3207
|
+
/** Characters `text()` can show: digits, space, `-` `:` `.` `,` `/` — all of them morph. Anything else is removed. */
|
|
1265
3208
|
const UNSUPPORTED_CHARACTERS = /[^0-9 :.,\/-]/g;
|
|
1266
3209
|
/** Vertical part of the 24×24 grid shown in a cell: y 3…21 holds every digit including its stroke. */
|
|
1267
3210
|
const CELL_TOP = 3;
|
|
1268
3211
|
const CELL_HEIGHT = 18;
|
|
1269
|
-
/** Dot radius of `:` `.` `,` relative to the stroke width. */
|
|
1270
|
-
const DOT_SIZE = 0.6;
|
|
1271
3212
|
const formatNumber = (value) => String(+value.toFixed(4));
|
|
1272
|
-
/** Draws `:` `.` `,` or `/` into the cell, matching the set's stroke width and digit positions. */
|
|
1273
|
-
const drawStaticCharacter = (svg, character, iconSet) => {
|
|
1274
|
-
const { baselineY, colonDotsY } = iconSet.textLayout;
|
|
1275
|
-
const dotRadius = iconSet.strokeWidth * DOT_SIZE;
|
|
1276
|
-
const baseDotY = baselineY + iconSet.strokeWidth / 2 - dotRadius; // dot bottom = digit bottom
|
|
1277
|
-
const dot = (y) => svg.appendChild(createSvgElement('circle', {
|
|
1278
|
-
cx: 12, cy: formatNumber(y), r: formatNumber(dotRadius), fill: 'currentColor', stroke: 'none',
|
|
1279
|
-
}));
|
|
1280
|
-
const line = (fromX, fromY, toX, toY) => svg.appendChild(createSvgElement('path', {
|
|
1281
|
-
d: `M${formatNumber(fromX)} ${formatNumber(fromY)}L${formatNumber(toX)} ${formatNumber(toY)}`,
|
|
1282
|
-
}));
|
|
1283
|
-
if (character === ':')
|
|
1284
|
-
colonDotsY.forEach(dot);
|
|
1285
|
-
if (character === '.' || character === ',')
|
|
1286
|
-
dot(baseDotY);
|
|
1287
|
-
if (character === ',')
|
|
1288
|
-
line(12 + dotRadius * 0.4, baseDotY, 11, baselineY + 2.2);
|
|
1289
|
-
if (character === '/')
|
|
1290
|
-
line(9.5, baselineY, 14.5, 24 - baselineY);
|
|
1291
|
-
};
|
|
1292
3213
|
/** Adds a line of morphing characters (a <span> of <svg> cells) to `element`. */
|
|
1293
3214
|
const text = (element, options = {}) => {
|
|
1294
|
-
const { set, value: initialValue = '', size = '1em', cellWidth, spacing, ...defaultMorphOptions } = options;
|
|
3215
|
+
const { set, value: initialValue = '', size = '1em', cellWidth, spacing, strokeWidth, lineCap, lineJoin, fill, ...defaultMorphOptions } = options;
|
|
3216
|
+
const style = { strokeWidth, lineCap, lineJoin, fill };
|
|
1295
3217
|
const iconSet = resolveIconSet(set);
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
3218
|
+
const { textLayout } = iconSet;
|
|
3219
|
+
if (!textLayout)
|
|
3220
|
+
throw new Error('morphico: text() needs a digit set (this set has no text layout)');
|
|
3221
|
+
const digitCellWidth = cellWidth ?? textLayout.cellWidth;
|
|
3222
|
+
const cellSpacing = spacing ?? textLayout.spacing;
|
|
3223
|
+
const unitWidth = typeof strokeWidth === 'number' ? strokeWidth : iconSet.strokeWidth; // a CSS length: about the set's own
|
|
3224
|
+
const inkBottom = textLayout.baselineY + unitWidth / 2;
|
|
1299
3225
|
const line = document.createElement('span');
|
|
1300
3226
|
line.setAttribute('role', 'img');
|
|
1301
3227
|
line.style.whiteSpace = 'nowrap';
|
|
1302
3228
|
element.appendChild(line);
|
|
1303
3229
|
let cells = [];
|
|
1304
3230
|
let value = '';
|
|
1305
|
-
const isMorphing = (character) => hasState(iconSet, character);
|
|
1306
3231
|
const widthOf = (character) => {
|
|
1307
|
-
if (
|
|
1308
|
-
return digitCellWidth;
|
|
1309
|
-
return
|
|
3232
|
+
if (character === '/')
|
|
3233
|
+
return digitCellWidth * 0.7;
|
|
3234
|
+
return ':.,'.includes(character) ? digitCellWidth / 2 : digitCellWidth;
|
|
1310
3235
|
};
|
|
1311
3236
|
/** CSS length: `units` grid units at the line's size. */
|
|
1312
3237
|
const scaled = (units) => `calc(${size} * ${formatNumber(units / CELL_HEIGHT)})`;
|
|
1313
3238
|
const createCell = (character, index) => {
|
|
1314
3239
|
const width = widthOf(character);
|
|
1315
|
-
const svg = createIconSvg(iconSet, `${formatNumber(12 - width / 2)} ${CELL_TOP} ${formatNumber(width)} ${CELL_HEIGHT}
|
|
3240
|
+
const svg = createIconSvg(iconSet, `${formatNumber(12 - width / 2)} ${CELL_TOP} ${formatNumber(width)} ${CELL_HEIGHT}`, style);
|
|
1316
3241
|
svg.setAttribute('aria-hidden', 'true');
|
|
1317
3242
|
Object.assign(svg.style, {
|
|
1318
3243
|
width: scaled(width),
|
|
@@ -1321,29 +3246,26 @@ const text = (element, options = {}) => {
|
|
|
1321
3246
|
verticalAlign: scaled(inkBottom - CELL_TOP - CELL_HEIGHT), // digit bottom on the text baseline
|
|
1322
3247
|
marginLeft: index ? `var(--morphico-spacing, ${scaled(cellSpacing)})` : '0',
|
|
1323
3248
|
});
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
morphingPath = createMorphingPath(svg.appendChild(createSvgElement('path')), iconSet, character);
|
|
1327
|
-
else
|
|
1328
|
-
drawStaticCharacter(svg, character, iconSet);
|
|
1329
|
-
return { svg, morphingPath, character };
|
|
3249
|
+
const morphingPath = createMorphingPath(svg.appendChild(createStrokedElement('path', style)), iconSet, character);
|
|
3250
|
+
return { svg, morphingPath, width };
|
|
1330
3251
|
};
|
|
1331
3252
|
const rebuild = (characters) => {
|
|
1332
3253
|
for (const cell of cells)
|
|
1333
|
-
cell.morphingPath
|
|
3254
|
+
cell.morphingPath.stop();
|
|
1334
3255
|
cells = characters.map(createCell);
|
|
1335
3256
|
line.replaceChildren(...cells.map((cell) => cell.svg));
|
|
1336
3257
|
};
|
|
1337
3258
|
const show = (nextValue, { onEnd, ...morphOptions } = {}) => {
|
|
1338
|
-
value = nextValue.replace(UNSUPPORTED_CHARACTERS, '');
|
|
3259
|
+
value = [...nextValue.replace(UNSUPPORTED_CHARACTERS, '')].filter((character) => hasState(iconSet, character)).join('');
|
|
1339
3260
|
line.setAttribute('aria-label', value.trim());
|
|
1340
3261
|
const characters = [...value];
|
|
1341
|
-
const sameLayout = characters.length === cells.length
|
|
3262
|
+
const sameLayout = characters.length === cells.length
|
|
3263
|
+
&& characters.every((character, index) => widthOf(character) === cells[index].width);
|
|
1342
3264
|
let change;
|
|
1343
3265
|
if (sameLayout) {
|
|
1344
|
-
const morphs = cells.map((cell, index) => cell.morphingPath
|
|
3266
|
+
const morphs = cells.map((cell, index) => cell.morphingPath.morphTo(characters[index], { ...defaultMorphOptions, ...morphOptions }));
|
|
1345
3267
|
const finishedValue = value;
|
|
1346
|
-
change = Promise.all(morphs).then((results) => ({ value: finishedValue, interrupted: results.some((result) => result
|
|
3268
|
+
change = Promise.all(morphs).then((results) => ({ value: finishedValue, interrupted: results.some((result) => result.interrupted) }));
|
|
1347
3269
|
}
|
|
1348
3270
|
else {
|
|
1349
3271
|
rebuild(characters);
|
|
@@ -1359,7 +3281,7 @@ const text = (element, options = {}) => {
|
|
|
1359
3281
|
set: show,
|
|
1360
3282
|
destroy() {
|
|
1361
3283
|
for (const cell of cells)
|
|
1362
|
-
cell.morphingPath
|
|
3284
|
+
cell.morphingPath.stop();
|
|
1363
3285
|
line.remove();
|
|
1364
3286
|
},
|
|
1365
3287
|
};
|
|
@@ -1370,6 +3292,6 @@ return { text };
|
|
|
1370
3292
|
// index.ts
|
|
1371
3293
|
// morphico public API.
|
|
1372
3294
|
/** Package version, e.g. "0.1.0" (tools/build.ts replaces 'dev'). */
|
|
1373
|
-
const version = '0.
|
|
3295
|
+
const version = '0.2.0';
|
|
1374
3296
|
|
|
1375
3297
|
export { easings, icon, iconSets, text, version };
|