morphico 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/morphico.js CHANGED
@@ -1,10 +1,1378 @@
1
- /*! morphico v0.0.1 | MIT License */
1
+ /*! morphico v0.1.0 | MIT License */
2
2
  var morphico = (function () {
3
3
  'use strict';
4
+ // engine.ts
5
+ const { easings, animate } = (() => {
6
+ // Animation engine: easings and one shared requestAnimationFrame loop for every running morph.
7
+ // The loop only runs while something moves; nothing touches the DOM at import time (SSR-safe).
8
+ const easings = {
9
+ linear: (progress) => progress,
10
+ easeInOutCubic: (progress) => (progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2),
11
+ easeOutBack: (progress) => 1 + 2.70158 * (progress - 1) ** 3 + 1.70158 * (progress - 1) ** 2,
12
+ easeOutElastic: (progress) => (progress === 0 || progress === 1
13
+ ? progress
14
+ : 2 ** (-10 * progress) * Math.sin((progress * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1),
15
+ };
16
+ const frameCallbacks = new Set();
17
+ let animationFrameId = 0;
18
+ const runFrame = (frameTime) => {
19
+ for (const callback of frameCallbacks)
20
+ if (!callback(frameTime))
21
+ frameCallbacks.delete(callback);
22
+ animationFrameId = frameCallbacks.size ? requestAnimationFrame(runFrame) : 0;
23
+ };
24
+ /** Adds a callback to the shared loop. Returns a function that removes it again. */
25
+ const animate = (onFrame) => {
26
+ frameCallbacks.add(onFrame);
27
+ animationFrameId ||= requestAnimationFrame(runFrame);
28
+ return () => { frameCallbacks.delete(onFrame); };
29
+ };
30
+ return { easings, animate };
31
+ })();
32
+
33
+ // geometry.ts
34
+ const { line, arc, cubic, pointOnCircle, ellipse, roundedPolyline, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, defaultShapes, shapeForPair } = (() => {
35
+ const DEGREE = Math.PI / 180;
36
+ const ICON_CENTER = 12;
37
+ /** Largest arc one cubic segment may cover: up to 90° the cubic is visually exact. */
38
+ const MAX_ARC_PER_SEGMENT = 90;
39
+ const EPSILON = 1e-9;
40
+ // ---- strokes ----
41
+ const line = (fromX, fromY, toX, toY) => ({ kind: 'line', from: [fromX, fromY], to: [toX, toY] });
42
+ /** Angles in degrees. Because y points down, `endAngle > startAngle` draws clockwise on screen. */
43
+ const arc = (centerX, centerY, radius, startAngle, endAngle) => ({ kind: 'arc', center: [centerX, centerY], radius, startAngle, endAngle });
44
+ const cubic = (startX, startY, control1X, control1Y, control2X, control2Y, endX, endY) => ({ kind: 'cubic', segment: [[startX, startY], [control1X, control1Y], [control2X, control2Y], [endX, endY]] });
45
+ const pointOnCircle = (centerX, centerY, radius, angle) => [centerX + radius * Math.cos(angle * DEGREE), centerY + radius * Math.sin(angle * DEGREE)];
46
+ /** Control handle length of a quarter ellipse as a fraction of its radius (4/3 · (√2 − 1)). */
47
+ const QUARTER_HANDLE = (4 / 3) * (Math.SQRT2 - 1);
48
+ /** A full ellipse as four cubic quarters, drawn counter-clockwise on screen, starting at the top. */
49
+ const ellipse = (centerX, centerY, radiusX, radiusY) => {
50
+ const handleX = QUARTER_HANDLE * radiusX, handleY = QUARTER_HANDLE * radiusY;
51
+ const top = centerY - radiusY, bottom = centerY + radiusY, left = centerX - radiusX, right = centerX + radiusX;
52
+ return [
53
+ cubic(centerX, top, centerX - handleX, top, left, centerY - handleY, left, centerY),
54
+ cubic(left, centerY, left, centerY + handleY, centerX - handleX, bottom, centerX, bottom),
55
+ cubic(centerX, bottom, centerX + handleX, bottom, right, centerY + handleY, right, centerY),
56
+ cubic(right, centerY, right, centerY - handleY, centerX + handleX, top, centerX, top),
57
+ ];
58
+ };
59
+ /**
60
+ * A polyline whose corners are rounded: straight lines joined by tangent circle arcs.
61
+ * `radii[index]` rounds the corner at `points[index]` (a single number rounds every corner; 0 = sharp corner).
62
+ * Straight-through points and reversals (going back along the same line) always stay sharp.
63
+ */
64
+ const roundedPolyline = (points, radii) => {
65
+ const strokes = [];
66
+ const addLine = (from, to) => {
67
+ if (Math.hypot(to[0] - from[0], to[1] - from[1]) > EPSILON)
68
+ strokes.push(line(...from, ...to));
69
+ };
70
+ let current = points[0];
71
+ for (let index = 1; index < points.length; index++) {
72
+ const corner = points[index];
73
+ const next = points[index + 1];
74
+ const radius = Array.isArray(radii) ? (radii[index] ?? 0) : radii;
75
+ if (!next || radius <= 0) {
76
+ addLine(current, corner);
77
+ current = corner;
78
+ continue;
79
+ }
80
+ const incoming = unitVector(current, corner), outgoing = unitVector(corner, next);
81
+ const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0];
82
+ const turn = Math.atan2(cross, incoming[0] * outgoing[0] + incoming[1] * outgoing[1]); // radians, > 0 = clockwise
83
+ if (Math.abs(turn) < 1e-6 || Math.abs(turn) > Math.PI - 1e-6) {
84
+ addLine(current, corner);
85
+ current = corner;
86
+ continue;
87
+ }
88
+ const tangentDistance = radius * Math.tan(Math.abs(turn) / 2);
89
+ const arcStart = [corner[0] - incoming[0] * tangentDistance, corner[1] - incoming[1] * tangentDistance];
90
+ // the centre lies on the inside of the turn
91
+ const inward = turn > 0 ? [-incoming[1], incoming[0]] : [incoming[1], -incoming[0]];
92
+ const center = [arcStart[0] + inward[0] * radius, arcStart[1] + inward[1] * radius];
93
+ const startAngle = Math.atan2(arcStart[1] - center[1], arcStart[0] - center[0]) / DEGREE;
94
+ addLine(current, arcStart);
95
+ strokes.push(arc(center[0], center[1], radius, startAngle, startAngle + turn / DEGREE));
96
+ current = [corner[0] + outgoing[0] * tangentDistance, corner[1] + outgoing[1] * tangentDistance];
97
+ }
98
+ return strokes;
99
+ };
100
+ const unitVector = (from, to) => {
101
+ const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
102
+ return [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
103
+ };
104
+ const arcSweep = (stroke) => Math.abs(stroke.endAngle - stroke.startAngle);
105
+ // ---- path numbers ⇄ segments ----
106
+ const segmentCountOf = (path) => (path.length - 2) / 6;
107
+ const pointAt = (path, index) => [path[index], path[index + 1]];
108
+ const toSegments = (path) => Array.from({ length: segmentCountOf(path) }, (_, segmentIndex) => {
109
+ const offset = segmentIndex * 6;
110
+ return [pointAt(path, offset), pointAt(path, offset + 2), pointAt(path, offset + 4), pointAt(path, offset + 6)];
111
+ });
112
+ const fromSegments = (segments) => [...segments[0][0], ...segments.flatMap(([, control1, control2, end]) => [...control1, ...control2, ...end])];
113
+ const rotateSegmentOrder = (segments, startSegment) => [...segments.slice(startSegment), ...segments.slice(0, startSegment)];
114
+ const reverseSegments = (segments) => segments.slice().reverse().map(([start, control1, control2, end]) => [end, control2, control1, start]);
115
+ /** Same picture, different segment order: start a closed loop at another segment and/or draw it backwards. */
116
+ const reorderSegments = (path, { startSegment = 0, reversed = false } = {}) => {
117
+ const rotated = rotateSegmentOrder(toSegments(path), startSegment);
118
+ return fromSegments(reversed ? reverseSegments(rotated) : rotated);
119
+ };
120
+ const interpolate = (from, to, progress) => from.map((value, index) => value + (to[index] - value) * progress);
121
+ const formatNumber = (value) => String(+value.toFixed(3));
122
+ /** SVG `d` attribute: `M x y` followed by one `C` per segment. */
123
+ const toPathData = (path) => `M${formatNumber(path[0])} ${formatNumber(path[1])}` + toSegments(path)
124
+ .map(([, control1, control2, end]) => `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`)
125
+ .join('');
126
+ /** Shrinks (factor < 1) or grows the whole shape around the icon center (12, 12). */
127
+ const scaleAroundCenter = (path, factor) => path.map((value) => ICON_CENTER + (value - ICON_CENTER) * factor);
128
+ /** Rotates the whole shape around the icon center (12, 12). */
129
+ const rotateAroundCenter = (path, angle) => {
130
+ const cos = Math.cos(angle * DEGREE), sin = Math.sin(angle * DEGREE);
131
+ return path.map((value, index) => {
132
+ if (index % 2 === 0) {
133
+ const y = path[index + 1];
134
+ return ICON_CENTER + (value - ICON_CENTER) * cos - (y - ICON_CENTER) * sin;
135
+ }
136
+ const x = path[index - 1];
137
+ return ICON_CENTER + (x - ICON_CENTER) * sin + (value - ICON_CENTER) * cos;
138
+ });
139
+ };
140
+ // ---- pieces of strokes ----
141
+ const interpolatePoint = (from, to, progress) => [from[0] + (to[0] - from[0]) * progress, from[1] + (to[1] - from[1]) * progress];
142
+ /** Splits a cubic into two cubics at `progress` (de Casteljau). */
143
+ const splitCubic = ([start, control1, control2, end], progress) => {
144
+ const startToControl1 = interpolatePoint(start, control1, progress);
145
+ const control1ToControl2 = interpolatePoint(control1, control2, progress);
146
+ const control2ToEnd = interpolatePoint(control2, end, progress);
147
+ const leftInner = interpolatePoint(startToControl1, control1ToControl2, progress);
148
+ const rightInner = interpolatePoint(control1ToControl2, control2ToEnd, progress);
149
+ const splitPoint = interpolatePoint(leftInner, rightInner, progress);
150
+ return [[start, startToControl1, leftInner, splitPoint], [splitPoint, rightInner, control2ToEnd, end]];
151
+ };
152
+ /**
153
+ * The part of a stroke between two progress values (0…1) as one cubic segment.
154
+ * A piece of a line/arc/cubic is exactly a line/arc/cubic, so segment ends can slide along a stroke
155
+ * without changing how it looks.
156
+ */
157
+ const strokePiece = (stroke, startProgress, endProgress) => {
158
+ switch (stroke.kind) {
159
+ case 'line': {
160
+ const start = interpolatePoint(stroke.from, stroke.to, startProgress);
161
+ const end = interpolatePoint(stroke.from, stroke.to, endProgress);
162
+ return [start, interpolatePoint(start, end, 1 / 3), interpolatePoint(start, end, 2 / 3), end];
163
+ }
164
+ case 'arc': {
165
+ const { center: [centerX, centerY], radius, startAngle, endAngle } = stroke;
166
+ const startRadians = (startAngle + (endAngle - startAngle) * startProgress) * DEGREE;
167
+ const endRadians = (startAngle + (endAngle - startAngle) * endProgress) * DEGREE;
168
+ // standard arc approximation: control handle length = 4/3 · tan(θ/4) · r
169
+ const handleLength = (4 / 3) * Math.tan((endRadians - startRadians) / 4) * radius;
170
+ const start = [centerX + radius * Math.cos(startRadians), centerY + radius * Math.sin(startRadians)];
171
+ const end = [centerX + radius * Math.cos(endRadians), centerY + radius * Math.sin(endRadians)];
172
+ return [
173
+ start,
174
+ [start[0] - handleLength * Math.sin(startRadians), start[1] + handleLength * Math.cos(startRadians)],
175
+ [end[0] + handleLength * Math.sin(endRadians), end[1] - handleLength * Math.cos(endRadians)],
176
+ end,
177
+ ];
178
+ }
179
+ case 'cubic': {
180
+ if (startProgress >= 1)
181
+ return [stroke.segment[3], stroke.segment[3], stroke.segment[3], stroke.segment[3]];
182
+ const [, rest] = splitCubic(stroke.segment, startProgress);
183
+ return splitCubic(rest, (endProgress - startProgress) / (1 - startProgress))[0];
184
+ }
185
+ }
186
+ };
187
+ /** Lays out cubic segments along the strokes; see `PairLayout.breakpoints`. */
188
+ const layoutStrokes = (strokes, breakpoints) => fromSegments(breakpoints.slice(1).map((segmentEnd, segmentIndex) => {
189
+ const segmentStart = breakpoints[segmentIndex];
190
+ const strokeIndex = Math.min(Math.floor(segmentStart + EPSILON), strokes.length - 1);
191
+ return strokePiece(strokes[strokeIndex], segmentStart - strokeIndex, segmentEnd - strokeIndex);
192
+ }));
193
+ const CUBIC_LENGTH_SAMPLES = 64;
194
+ const distance = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
195
+ const strokeLength = (stroke) => {
196
+ switch (stroke.kind) {
197
+ case 'line': return distance(stroke.from, stroke.to);
198
+ case 'arc': return arcSweep(stroke) * DEGREE * stroke.radius;
199
+ case 'cubic': {
200
+ let length = 0, previousPoint = stroke.segment[0];
201
+ for (let sample = 1; sample <= CUBIC_LENGTH_SAMPLES; sample++) {
202
+ const point = strokePiece(stroke, 0, sample / CUBIC_LENGTH_SAMPLES)[3];
203
+ length += distance(previousPoint, point);
204
+ previousPoint = point;
205
+ }
206
+ return length;
207
+ }
208
+ }
209
+ };
210
+ /** Default breakpoints: segments spread over the strokes by length, arcs get at least one segment per 90°. */
211
+ const defaultBreakpoints = (strokes, segmentCount) => {
212
+ const lengths = strokes.map(strokeLength);
213
+ const segmentsPerStroke = strokes.map((stroke) => (stroke.kind === 'arc' ? Math.ceil(arcSweep(stroke) / MAX_ARC_PER_SEGMENT - EPSILON) : 1));
214
+ const usedSegments = () => segmentsPerStroke.reduce((sum, count) => sum + count, 0);
215
+ if (usedSegments() > segmentCount)
216
+ throw new Error(`needs ${usedSegments()} segments, the set has ${segmentCount}`);
217
+ while (usedSegments() < segmentCount) {
218
+ // one more segment for the stroke whose segments are currently the longest
219
+ let longest = 0;
220
+ for (let strokeIndex = 1; strokeIndex < strokes.length; strokeIndex++) {
221
+ const segmentLength = lengths[strokeIndex] / segmentsPerStroke[strokeIndex];
222
+ if (segmentLength > lengths[longest] / segmentsPerStroke[longest] + EPSILON)
223
+ longest = strokeIndex;
224
+ }
225
+ segmentsPerStroke[longest]++;
226
+ }
227
+ const breakpoints = [0];
228
+ segmentsPerStroke.forEach((count, strokeIndex) => {
229
+ for (let part = 1; part <= count; part++)
230
+ breakpoints.push(+(strokeIndex + part / count).toFixed(6));
231
+ });
232
+ return breakpoints;
233
+ };
234
+ // ---- icon sets ----
235
+ /** Every state of the set laid out with its default breakpoints. */
236
+ const defaultShapes = (iconSet) => Object.fromEntries(Object.entries(iconSet.strokes)
237
+ .map(([state, strokes]) => [state, layoutStrokes(strokes, defaultBreakpoints(strokes, iconSet.segmentCount))]));
238
+ /** The shape of `state` laid out for morphing to or from `otherState`. */
239
+ const shapeForPair = (iconSet, shapes, state, otherState) => {
240
+ const pairLayout = iconSet.pairLayouts[state]?.[otherState];
241
+ if (!pairLayout)
242
+ return shapes[state];
243
+ const strokes = pairLayout.tracing ? iconSet.alternativeStrokes[pairLayout.tracing] : iconSet.strokes[state];
244
+ const laidOut = pairLayout.breakpoints ? layoutStrokes(strokes, pairLayout.breakpoints) : shapes[state];
245
+ const reordered = reorderSegments(laidOut, pairLayout);
246
+ return pairLayout.rotation ? rotateAroundCenter(reordered, pairLayout.rotation) : reordered;
247
+ };
248
+ return { line, arc, cubic, pointOnCircle, ellipse, roundedPolyline, segmentCountOf, toSegments, fromSegments, reorderSegments, interpolate, toPathData, scaleAroundCenter, rotateAroundCenter, strokePiece, layoutStrokes, defaultBreakpoints, defaultShapes, shapeForPair };
249
+ })();
250
+
251
+ // sets/condensed.ts
252
+ const { condensedDigits } = (() => {
253
+ // Digit set "condensed": tall, narrow, thin "display" digits with rounded corners, drafted from the reference images in
254
+ // icons/condensed/ (the set format is described in ../types.ts).
255
+ //
256
+ // The shapes are written on the images' own pixel grid and converted: x/y are the stroke's middle line in image
257
+ // pixels, measured from the digit's ink box. Every digit is 94 px tall with a 6 px stroke, most are 50 px wide:
258
+ // middle lines at x 3 / 47 and y 3 (top) / 47 (middle) / 91 (bottom). 5.5 image px = 1 grid unit, so the digits
259
+ // span x 8…16, y 4…20.
260
+ // - straight strokes with rounded corners (roundedPolyline), round ends
261
+ // - 0 = stadium; 8 = two stacked rounded rectangles sharing the middle bar; 9 = 6 turned 180° around 12,12;
262
+ // the 5 ends with the same small upturn as the 9
263
+ // - T-junctions (4's right stem) are drawn out and back over themselves
264
+ const PIXELS_PER_UNIT = 5.5;
265
+ /** Image pixels (middle line, measured from the ink box of a digit `inkWidth` px wide) → grid units. */
266
+ const fromImage = (inkWidth) => ([x, y]) => [12 + (x - inkWidth / 2) / PIXELS_PER_UNIT, 4 + (y - 3) / PIXELS_PER_UNIT];
267
+ /** A rounded polyline given in image pixels; radii in image pixels too. */
268
+ const drawn = (inkWidth, points, radii) => roundedPolyline(points.map(fromImage(inkWidth)), Array.isArray(radii) ? radii.map((radius) => radius / PIXELS_PER_UNIT) : radii / PIXELS_PER_UNIT);
269
+ const turnedAround = ([x, y]) => [24 - x, 24 - y];
270
+ // corner radii (image px), chosen by pixel comparison with the images: one radius for (almost) every corner
271
+ const CORNER_RADIUS = 12;
272
+ const OUTER_RADIUS = CORNER_RADIUS; // outer corners of the boxes (top and bottom)
273
+ const MIDDLE_RADIUS = CORNER_RADIUS; // corners where a box meets the middle bar
274
+ const THREE_BOWL_RADIUS = 16; // the 3's bowls are rounder
275
+ const THREE_MIDDLE_END_X = 15;
276
+ /** End of the small upturn at the bottom left of 5 and 9 (and of the hook at the top right of 6). */
277
+ const HOOK_END_Y = 79;
278
+ // the 6: hook at the top right, over the top, down the left side, round the bottom, up to the middle bar, back left
279
+ const SIX_POINTS = [[47, 94 - HOOK_END_Y], [47, 3], [3, 3], [3, 91], [47, 91], [47, 47], [3, 47]];
280
+ const SIX_RADII = [0, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0];
281
+ const condensedDigits = {
282
+ segmentCount: 20,
283
+ strokeWidth: 6 / PIXELS_PER_UNIT,
284
+ lineCap: 'round',
285
+ lineJoin: 'round',
286
+ strokes: {
287
+ // stadium, from the top middle counter-clockwise
288
+ 0: drawn(50, [[25, 3], [3, 3], [3, 91], [47, 91], [47, 3], [25, 3]], 22),
289
+ // flag → stem
290
+ 1: drawn(17, [[3, 3], [14, 3], [14, 91]], 0),
291
+ // top bar → right side → middle bar → left side → bottom bar
292
+ 2: drawn(50, [[3, 3], [47, 3], [47, 47], [3, 47], [3, 91], [47, 91]], [0, CORNER_RADIUS, CORNER_RADIUS, CORNER_RADIUS, CORNER_RADIUS, 0]),
293
+ // top bar → upper bowl → middle bar out to the left and back → lower bowl → bottom bar
294
+ 3: drawn(50, [[3, 3], [47, 3], [47, 47], [THREE_MIDDLE_END_X, 47], [47, 47], [47, 91], [3, 91]], [0, THREE_BOWL_RADIUS, THREE_BOWL_RADIUS, 0, THREE_BOWL_RADIUS, THREE_BOWL_RADIUS, 0]),
295
+ // left stem down → crossbar → right stem up to the top and back down to the bottom
296
+ 4: drawn(50, [[3, 3], [3, 47], [47, 47], [47, 3], [47, 91]], [0, CORNER_RADIUS, 0, 0, 0]),
297
+ // top bar right → left, left side down, middle bar → right side → bottom bar → small upturn
298
+ 5: drawn(50, [[47, 3], [3, 3], [3, 47], [47, 47], [47, 91], [3, 91], [3, HOOK_END_Y]], [0, 0, 0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, 0]),
299
+ 6: drawn(50, SIX_POINTS, SIX_RADII),
300
+ // top bar → slanted stroke down
301
+ 7: drawn(49, [[3, 3], [46, 3], [24.5, 91]], 0),
302
+ // from the waist: top box (left side up, clockwise), along the middle bar, bottom box (counter-clockwise)
303
+ 8: drawn(50, [[3 + MIDDLE_RADIUS, 47], [3, 47], [3, 3], [47, 3], [47, 47], [3, 47], [3, 91], [47, 91], [47, 47], [3 + MIDDLE_RADIUS, 47]], [0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0]),
304
+ // the 6 turned 180°, drawn the other way round (box first, then the small upturn)
305
+ 9: roundedPolyline(SIX_POINTS.map(fromImage(50)).map(turnedAround).reverse(), [...SIX_RADII].reverse().map((radius) => radius / PIXELS_PER_UNIT)),
306
+ '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
307
+ },
308
+ alternativeStrokes: {
309
+ // the 8 traced like the 0 (from the top, counter-clockwise): left side of the top box down to the middle of
310
+ // the middle bar, once around the bottom box back to the middle, right side of the top box back up
311
+ '8-like-0': drawn(50, [[25, 3], [3, 3], [3, 47], [25, 47], [3, 47], [3, 91], [47, 91], [47, 47], [25, 47], [47, 47], [47, 3], [25, 3]], [0, OUTER_RADIUS, MIDDLE_RADIUS, 0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0, MIDDLE_RADIUS, OUTER_RADIUS, 0]),
312
+ // the 8 traced like the 3 (as in the square set), starting and ending at the left part of the middle bar: around the top
313
+ // box, along the middle bar out to the left and back, around the bottom box
314
+ '8-like-3': drawn(50, [[3 + MIDDLE_RADIUS, 47], [3, 47], [3, 3], [47, 3], [47, 47], [3 + MIDDLE_RADIUS, 47], [47, 47], [47, 91], [3, 91], [3, 47], [3 + MIDDLE_RADIUS, 47]], [0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0, MIDDLE_RADIUS, OUTER_RADIUS, OUTER_RADIUS, MIDDLE_RADIUS, 0]),
315
+ },
316
+ // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd
317
+ pairLayouts: {
318
+ 0: {
319
+ 1: { startSegment: 5, reversed: true },
320
+ 2: { startSegment: 5, reversed: true },
321
+ 3: { startSegment: 5, reversed: true },
322
+ 4: { breakpoints: [0, 0.6458, 0.6667, 1, 1.2292, 1.4375, 1.4583, 1.4583, 1.4583, 2, 3, 4, 4.5208, 4.5208, 4.5208, 4.5208, 4.75, 5, 5.3125, 5.3125, 6] },
323
+ 5: { startSegment: 4, reversed: true },
324
+ 7: { startSegment: 5, reversed: true },
325
+ 9: { startSegment: 6, reversed: true },
326
+ },
327
+ 1: {
328
+ 0: { breakpoints: [0, 0, 0, 0, 0, 0.5, 1, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.8542, 1.8542, 1.8542, 1.8542, 1.8542, 1.8542, 1.8542, 2] },
329
+ },
330
+ 2: {
331
+ 6: { breakpoints: [0, 1, 1, 1, 1, 2, 3, 4, 5, 6, 7, 7.5, 8, 8.2917, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 9] },
332
+ 9: { breakpoints: [0, 0.2292, 0.2292, 0.2292, 0.2292, 0.2292, 0.2292, 0.2292, 0.3333, 0.6875, 1, 1.5, 2, 3, 4, 5, 6, 7, 8, 8, 9] },
333
+ 0: { breakpoints: [0, 0, 0, 0.0625, 0.3333, 0.6875, 1, 2, 2.5, 3, 3.5625, 3.6042, 4, 5, 6, 7, 7, 7, 7, 8, 9] },
334
+ },
335
+ 3: {
336
+ 4: { breakpoints: [0, 0, 1, 2, 2, 2, 3, 3.4167, 3.4167, 4, 5, 6, 6.5833, 6.5833, 7, 8, 8.4375, 8.4583, 8.4583, 9, 10] },
337
+ 9: { breakpoints: [0, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.375, 0.8333, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9.6667, 10] },
338
+ 0: { breakpoints: [0, 0, 0, 0.0833, 0.375, 0.8333, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9.1667, 9.625, 9.9167, 10, 10, 10] },
339
+ },
340
+ 4: {
341
+ 6: { breakpoints: [0, 0, 0, 1, 1.8333, 2, 2.7083, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 2.8125, 3, 4, 5] },
342
+ 8: { breakpoints: [0, 0, 0.3333, 0.6875, 1, 2, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.3125, 2.6667, 3, 3.5625, 3.75, 4, 5] },
343
+ 9: { breakpoints: [0, 1, 1, 1, 1, 2, 3, 4, 4.125, 4.1875, 4.1875, 4.1875, 4.1875, 4.1875, 4.2292, 4.3958, 4.4375, 4.875, 5, 5, 5] },
344
+ 3: { breakpoints: [0, 1, 2, 3, 4, 4.1875, 4.3125, 4.4583, 4.5, 4.5, 4.5, 4.5, 4.5, 4.5, 4.5417, 4.6875, 4.8125, 4.9583, 5, 5, 5] },
345
+ 0: { breakpoints: [0, 0.0625, 0.3333, 0.6875, 1, 1.5208, 1.7083, 1.7917, 1.8542, 2, 2.3125, 2.6667, 2.9375, 3, 3, 3, 3.25, 3.5, 3.75, 4, 5] },
346
+ },
347
+ 5: {
348
+ 8: { breakpoints: [0, 0, 0, 0, 0.25, 0.4375, 1, 1.25, 1.4375, 2, 2.3333, 3, 4, 5, 6, 7, 8, 8, 8, 8, 8] },
349
+ 0: { breakpoints: [0, 0.8125, 0.8125, 0.8125, 0.8125, 1, 2, 3, 3.3958, 3.4375, 4, 4.5, 5, 6, 6.5, 7, 8, 8, 8, 8, 8] },
350
+ },
351
+ 6: {
352
+ 8: { breakpoints: [0, 1, 1, 1, 1, 1, 2, 3, 3.5417, 3.5417, 3.6458, 4, 5, 6, 7, 8, 9, 9.6667, 10, 10, 10] },
353
+ 4: { breakpoints: [0, 1, 2, 3, 4, 5, 6, 7, 8, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 8.7292, 9, 10] },
354
+ 2: { breakpoints: [0, 1, 1, 1, 1, 1, 2, 3, 3.5833, 3.6458, 4, 4.5, 5, 5.5, 6, 6.125, 6.125, 7, 8, 9, 10] },
355
+ },
356
+ 7: {
357
+ 8: { breakpoints: [0, 0, 0, 0.2292, 0.6042, 0.7292, 0.8333, 1, 1.0833, 1.125, 1.3542, 1.5208, 1.5417, 1.7292, 1.8125, 1.8125, 1.8125, 1.8125, 1.8125, 1.8125, 2] },
358
+ 0: { breakpoints: [0, 0, 0, 0.0417, 0.25, 0.4375, 1, 1.125, 1.2292, 1.3542, 1.4792, 1.5833, 1.7083, 1.8542, 1.8958, 1.8958, 1.8958, 1.8958, 1.8958, 1.8958, 2] },
359
+ },
360
+ 8: {
361
+ // as in round and square: the 0's left and right sides move in to the middle of the 8's middle bar
362
+ 0: { tracing: '8-like-0', breakpoints: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9.5, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] },
363
+ 4: { startSegment: 5, reversed: true },
364
+ 5: { startSegment: 10, reversed: true },
365
+ 6: { startSegment: 3 },
366
+ 7: { breakpoints: [0, 1, 2, 3, 3.7083, 4, 4.2708, 4.4792, 4.7083, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] },
367
+ 9: { startSegment: 17 },
368
+ // as in the square set: both ends of the 3 move to the left part of its middle bar, where the 8's boxes close
369
+ 3: { tracing: '8-like-3', breakpoints: [0, 1, 2, 3, 4, 5, 6, 6.5, 7, 7.6458, 8, 8.3542, 9, 9.5, 10, 11, 12, 13, 14, 15, 16] },
370
+ },
371
+ 9: {
372
+ 8: { breakpoints: [0, 0, 0.3333, 1, 2, 2.5, 3, 4, 4.5, 5, 6, 6.1458, 6.3542, 6.4583, 6.4583, 7, 8, 9, 9, 9, 10] },
373
+ 4: { breakpoints: [0, 1, 2, 2, 2, 3, 4, 5, 6, 6.0625, 6.0625, 6.0625, 6.0625, 6.0625, 6.125, 6.3542, 6.4167, 7, 8, 9, 10] },
374
+ 3: { breakpoints: [0, 1, 2, 3, 3.7708, 3.7708, 3.7708, 3.7708, 4, 4.5, 5, 6, 6.2917, 6.5, 6.5, 6.5, 6.7083, 7, 8, 9, 10] },
375
+ 2: { breakpoints: [0, 1, 2, 3, 3.7917, 3.7917, 3.7917, 3.7917, 4, 4.4792, 5, 5.5, 6, 6.3542, 6.4167, 7, 8, 9, 9, 9, 10] },
376
+ 0: { breakpoints: [0, 1, 2, 2.5, 3, 4, 4.5, 5, 6, 6.1458, 6.3333, 6.5, 6.6667, 6.8542, 7, 8, 8.5, 9, 10, 10, 10] },
377
+ // turned back by 180° this is exactly the 6 (same segment split, same direction) → a pure spin
378
+ 6: {
379
+ breakpoints: [0, 0.5, 1, 2, 2.5, 3, 4, 4.5, 5, 5.5, 6, 6.25, 6.5, 6.75, 7, 7.5, 8, 8.5, 9, 9.5, 10],
380
+ reversed: true,
381
+ },
382
+ },
383
+ },
384
+ spins: { 6: { 9: 180 }, 9: { 6: -180 } },
385
+ textLayout: { cellWidth: 8, spacing: 3.5, baselineY: 20, colonDotsY: [8, 16] },
386
+ };
387
+ return { condensedDigits };
388
+ })();
389
+
390
+ // sets/handwritten.ts
391
+ const { handwrittenDigits } = (() => {
392
+ // Digit set "handwritten": hand-drawn marker digits, traced from the reference images in icons/handwritten/
393
+ // (the set format is described in ../types.ts).
394
+ //
395
+ // Free-form strokes, so the geometry is traced rather than constructed (scratch tools, see CLAUDE.md):
396
+ // thin decorative side lines removed (only ink of a thick stroke kept), the stroke thinned to its centre line,
397
+ // followed through hand-placed waypoints in writing order, smoothed and fitted with cubic Bézier curves
398
+ // (Schneider's algorithm). 7.375 image px = 1 grid unit, each digit centred on 12,12; stroke 1.04 (typical width along
399
+ // the centre line — the marker stroke varies a little, so the pixel match is ~8–17 %).
400
+ // - every digit is one stroke of 3–7 free cubic curves; round ends and corners
401
+ // - the 4 is drawn out and back over its crossbar end and right stem (one continuous stroke)
402
+ const handwrittenDigits = {
403
+ segmentCount: 16,
404
+ strokeWidth: 1.04,
405
+ lineCap: 'round',
406
+ lineJoin: 'round',
407
+ strokes: {
408
+ 0: [
409
+ cubic(12.61, 4.07, 10.61, 3.51, 9.37, 5.81, 8.74, 7.36),
410
+ cubic(8.74, 7.36, 7.57, 10.27, 6.96, 17.17, 9.79, 19.34),
411
+ cubic(9.79, 19.34, 12.78, 21.64, 15.47, 18.25, 16, 15.5),
412
+ cubic(16, 15.5, 16.6, 12.38, 16.29, 8.49, 14.37, 5.83),
413
+ cubic(14.37, 5.83, 13.87, 5.13, 13.21, 4.66, 12.61, 4.07),
414
+ ],
415
+ 1: [
416
+ cubic(10.24, 7.53, 11.22, 6.54, 12.21, 5.24, 13.49, 4.68),
417
+ cubic(13.49, 4.68, 13.7, 4.79, 13.86, 4.5, 13.9, 4.89),
418
+ cubic(13.9, 4.89, 14.02, 6, 13.9, 7.27, 13.9, 8.42),
419
+ cubic(13.9, 8.42, 13.9, 12.28, 13.9, 16.14, 13.9, 20),
420
+ ],
421
+ 2: [
422
+ cubic(9.02, 7.66, 10.04, 4, 14.93, 1.86, 15.22, 7.15),
423
+ cubic(15.22, 7.15, 15.49, 11.89, 7.96, 15.48, 8.47, 19.86),
424
+ cubic(8.47, 19.86, 9.61, 20.58, 13.97, 20.14, 15.53, 20.14),
425
+ ],
426
+ 3: [
427
+ cubic(9.76, 5.15, 13.23, 2.8, 16.42, 4.68, 14.53, 7.59),
428
+ cubic(14.53, 7.59, 13.4, 9.34, 12.35, 9.39, 10.58, 9.9),
429
+ cubic(10.58, 9.9, 11.34, 9.75, 12.22, 9.41, 13.02, 9.59),
430
+ cubic(13.02, 9.59, 18.13, 10.7, 16.81, 19.23, 11.83, 19.98),
431
+ cubic(11.83, 19.98, 9.89, 20.28, 8.7, 19.11, 7.73, 17.63),
432
+ ],
433
+ 4: [
434
+ cubic(8.27, 4.14, 8.27, 6.1, 7.62, 12.38, 8.38, 13.74),
435
+ cubic(8.41, 13.76, 10.61, 14.38, 13.29, 14.03, 15.59, 14.03),
436
+ cubic(15.59, 14.03, 14.82, 14.03, 14.06, 14.03, 13.29, 14.03),
437
+ cubic(13.29, 14.03, 13.29, 10.73, 13.29, 7.44, 13.29, 4.14),
438
+ cubic(13.29, 4.14, 13.29, 9.33, 13.29, 14.53, 13.29, 19.73),
439
+ ],
440
+ 5: [
441
+ cubic(14.24, 3.86, 12.8, 3.86, 11.11, 3.74, 9.76, 4.14),
442
+ cubic(9.76, 4.14, 9.7, 4.15, 9.31, 4.09, 9.26, 4.23),
443
+ cubic(9.26, 4.23, 9.09, 4.74, 9.28, 8.83, 9.36, 9.58),
444
+ cubic(9.36, 9.58, 9.41, 10.07, 9.44, 9.66, 9.63, 9.83),
445
+ cubic(9.63, 9.83, 10.32, 9.54, 10.97, 9.13, 11.74, 9.04),
446
+ cubic(11.74, 9.04, 16.84, 8.46, 16.99, 16.29, 14.13, 18.87),
447
+ cubic(14.13, 18.87, 11.84, 20.92, 8.19, 19.68, 8, 16.47),
448
+ ],
449
+ 6: [
450
+ cubic(13.08, 3.93, 9.07, 6.47, 7.2, 12.92, 8.79, 17.24),
451
+ cubic(8.79, 17.24, 9.18, 18.28, 9.4, 19.11, 10.46, 19.67),
452
+ cubic(10.46, 19.67, 13.85, 21.46, 16.41, 16.37, 15.4, 13.64),
453
+ cubic(15.4, 13.64, 13.48, 8.42, 9.15, 16.35, 8.94, 16.42),
454
+ cubic(8.94, 16.42, 7.71, 16.83, 8.27, 12.36, 8.47, 11.8),
455
+ ],
456
+ 7: [
457
+ cubic(8.81, 3.86, 10.36, 3.86, 14.03, 3.47, 15.19, 4.08),
458
+ cubic(15.19, 4.14, 14.75, 5.71, 13.41, 7.25, 12.7, 8.8),
459
+ cubic(12.7, 8.8, 11.16, 12.19, 10.31, 16.13, 10.31, 19.86),
460
+ ],
461
+ 8: [
462
+ cubic(14.51, 5.56, 15.17, 3.17, 9.21, 3.46, 8.86, 6.51),
463
+ cubic(8.86, 6.51, 8.64, 8.41, 13.99, 11.13, 14.93, 13.6),
464
+ cubic(14.93, 13.6, 15.9, 16.15, 14.74, 20.7, 11.25, 19.94),
465
+ cubic(11.25, 19.94, 8.97, 19.44, 8.63, 17.22, 9.02, 15.26),
466
+ cubic(9.02, 15.26, 9.77, 11.45, 12.38, 8.68, 14.48, 5.59),
467
+ ],
468
+ 9: [
469
+ cubic(14.98, 6.98, 13.12, 0.31, 6.71, 5.82, 8.47, 10.23),
470
+ cubic(8.47, 10.23, 9.92, 13.9, 13.15, 10.88, 14.01, 8.61),
471
+ cubic(14.01, 8.61, 14.16, 8.23, 14.66, 6.7, 15.3, 7.44),
472
+ cubic(15.3, 7.44, 16.93, 9.31, 14.47, 18.24, 11.59, 19.86),
473
+ ],
474
+ '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
475
+ },
476
+ alternativeStrokes: {},
477
+ // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd / not sure
478
+ pairLayouts: {
479
+ 0: {
480
+ 1: { startSegment: 5, reversed: true },
481
+ 2: { startSegment: 5, reversed: true },
482
+ 3: { startSegment: 4, reversed: true },
483
+ 4: { breakpoints: [0, 0.7708, 1, 1.2083, 1.3958, 1.5208, 1.5208, 1.5208, 2, 3, 3.1458, 3.1458, 3.1458, 3.5833, 3.8542, 4, 5] },
484
+ 5: { startSegment: 3, reversed: true },
485
+ 7: { startSegment: 4, reversed: true },
486
+ 9: { startSegment: 5, reversed: true },
487
+ },
488
+ 1: {
489
+ 0: { breakpoints: [0, 0, 0, 0, 0.3542, 1, 2, 3, 3.1667, 3.3958, 3.6042, 3.8125, 3.9167, 3.9167, 3.9167, 3.9167, 4] },
490
+ },
491
+ 2: {
492
+ 6: { breakpoints: [0, 0.4583, 1, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.5833, 1.6458, 1.7083, 2, 3] },
493
+ 0: { breakpoints: [0, 0, 0, 0, 0.25, 0.5417, 0.8958, 1, 1.2083, 1.3125, 1.4167, 1.5417, 1.75, 1.9792, 1.9792, 2, 3] },
494
+ },
495
+ 3: {
496
+ 8: { breakpoints: [0, 0.5833, 0.5833, 0.5833, 0.5833, 1, 2, 3, 3.1458, 3.3958, 3.5208, 3.6667, 3.8333, 4, 4.2917, 5, 5] },
497
+ 0: { breakpoints: [0, 0, 0, 0, 0.2917, 1, 2, 3, 3.4167, 3.5208, 3.7083, 3.8542, 4, 4.4167, 5, 5, 5] },
498
+ },
499
+ 4: {
500
+ 8: { breakpoints: [0, 0.5833, 0.7708, 1, 1.4583, 2, 2.1458, 3, 3.2917, 3.3125, 3.875, 3.8958, 3.8958, 3.8958, 3.8958, 4, 5] },
501
+ 0: { breakpoints: [0, 0.0833, 0.3542, 0.5208, 0.7708, 1, 1.1042, 1.25, 1.5, 1.7292, 1.9583, 2, 3, 3.3958, 3.5625, 4, 5] },
502
+ },
503
+ 5: {
504
+ 0: { breakpoints: [0, 1, 2, 3, 4, 5, 5.3958, 5.5208, 5.6875, 5.875, 6, 6.2917, 6.5625, 6.8958, 7, 7, 7] },
505
+ },
506
+ 6: {
507
+ 2: { breakpoints: [0, 0.0625, 0.0625, 1, 2, 3, 3.6458, 3.6458, 3.6458, 3.6458, 3.6458, 3.6458, 3.6458, 3.7292, 3.875, 4, 5] },
508
+ },
509
+ 7: {
510
+ 8: { breakpoints: [0, 0.7292, 0.7292, 0.7292, 0.7292, 0.7292, 1, 2, 2.1458, 2.1458, 2.1458, 2.1458, 2.4583, 2.75, 2.75, 2.75, 3] },
511
+ 0: { breakpoints: [0, 0, 0, 0.2083, 0.5417, 1, 1.6667, 2, 2.2708, 2.5208, 2.7708, 2.8542, 2.8542, 2.8542, 2.8542, 2.8542, 3] },
512
+ },
513
+ 8: {
514
+ 7: { breakpoints: [0, 0.3125, 0.3125, 0.3125, 0.3125, 0.3125, 0.3125, 1, 1.5625, 1.5625, 1.6042, 1.625, 2, 3, 3.6042, 4, 5] },
515
+ 4: { breakpoints: [0, 0, 0, 0, 0, 0.25, 1, 1.625, 1.6875, 2, 2.0417, 2.0417, 3, 4, 4.3125, 4.5208, 5], reversed: true },
516
+ 3: { breakpoints: [0, 0.125, 0.125, 0.125, 0.125, 1, 1.5208, 1.5625, 1.7708, 2, 2.1667, 2.3958, 2.6458, 3, 3.0417, 4, 5] },
517
+ },
518
+ 9: {
519
+ 0: { breakpoints: [0, 0.9792, 0.9792, 1, 2, 2.7917, 2.7917, 3, 3.3125, 3.4375, 3.5833, 3.7083, 3.8333, 4, 4, 4, 4] },
520
+ },
521
+ },
522
+ spins: {},
523
+ textLayout: { cellWidth: 9, spacing: 4, baselineY: 20.2, colonDotsY: [8, 16] },
524
+ };
525
+ return { handwrittenDigits };
526
+ })();
527
+
528
+ // sets/round.ts
529
+ const { roundDigits } = (() => {
530
+ // Digit set "round": geometric sans digits, drafted from the reference images in icons/round/
531
+ // (the set format is described in ../types.ts).
532
+ //
533
+ // Measured from the images and tidied up so shared parts are exactly shared:
534
+ // - round digits span y 4…20, bars sit at y 4.2 / 19.8
535
+ // - round line ends: every open end stops half a stroke (END_INSET) before the edge measured in the images
536
+ // (the images have flat ends), so the round cap ends exactly there — bottoms of 1, 4, 7, 9 line up with the base
537
+ // - one big circle radius (2, 3, 6, 8, 9) and one small one (top of 3 and 8); 5 has its own, larger bowl
538
+ // - 3 is drawn on exactly the two circles of 8 (they touch at 12, 11.2)
539
+ // - 9 is exactly 6 turned 180° around 12,12 → 6 ↔ 9 morphs as a pure spin
540
+ const STROKE_WIDTH = 1.2; // the reference images use 1.6; thinner by the user's choice
541
+ /** A round line end reaches this far beyond its end point. */
542
+ const END_INSET = STROKE_WIDTH / 2;
543
+ /** Angle that shortens an arc's end by END_INSET along the circle. */
544
+ const arcInset = (radius) => (END_INSET / radius) * (180 / Math.PI);
545
+ /** The point on the line through `from` and `to` at height `y`. */
546
+ const pointAtY = ([fromX, fromY], [toX, toY], y) => [fromX + ((toX - fromX) * (y - fromY)) / (toY - fromY), y];
547
+ const BIG_RADIUS = 4.4;
548
+ const SMALL_RADIUS = 3.6;
549
+ const TOP_BAR_Y = 4.2;
550
+ const BASE_Y = 19.8;
551
+ /** Where the 6's tail touches its circle (tangent); 9 uses the same angle turned by 180°. */
552
+ const SIX_TAIL_ANGLE = -147.1085;
553
+ const SIX_TAIL_JOIN = pointOnCircle(12, 15.6, BIG_RADIUS, SIX_TAIL_ANGLE);
554
+ /** Top end of the 6's tail: on the measured tail line (from 14.65, 3.4), pulled in so the round end tops at 3.4. */
555
+ const SIX_TAIL_TOP = pointAtY([14.65, 3.4], SIX_TAIL_JOIN, TOP_BAR_Y);
556
+ /** Bottom end of the 9's tail: the 6's tail top turned 180° around 12,12. */
557
+ const NINE_TAIL_END = [24 - SIX_TAIL_TOP[0], 24 - SIX_TAIL_TOP[1]];
558
+ /** Where the 2's diagonal leaves its bowl (tangent towards the corner at 8.3, 19.8). */
559
+ const TWO_DIAGONAL_ANGLE = 39.5191;
560
+ /** Where the 5's short down-stroke meets its bowl. */
561
+ const FIVE_JOIN_ANGLE = 237.2;
562
+ /** The 7's foot: on the measured diagonal (16, 4.2 → 7.45, 20.6), pulled in to the base line. */
563
+ const SEVEN_FOOT = pointAtY([16, TOP_BAR_Y], [7.45, 20.6], BASE_Y);
564
+ const roundDigits = {
565
+ segmentCount: 12,
566
+ strokeWidth: STROKE_WIDTH,
567
+ lineCap: 'round',
568
+ lineJoin: 'round',
569
+ strokes: {
570
+ 0: ellipse(12, 12, 5.05, 8),
571
+ 1: [line(10 + END_INSET, TOP_BAR_Y, 13.1, TOP_BAR_Y), line(13.1, TOP_BAR_Y, 13.1, BASE_Y)],
572
+ 2: [
573
+ arc(12, 8.4, BIG_RADIUS, 158.7 + arcInset(BIG_RADIUS), TWO_DIAGONAL_ANGLE + 360),
574
+ line(...pointOnCircle(12, 8.4, BIG_RADIUS, TWO_DIAGONAL_ANGLE), 8.3, BASE_Y),
575
+ line(8.3, BASE_Y, 17.5 - END_INSET, BASE_Y),
576
+ ],
577
+ 3: [
578
+ arc(12, 7.6, SMALL_RADIUS, 180 + arcInset(SMALL_RADIUS), 439.2),
579
+ // the two arcs meet slightly right of the point where the circles touch
580
+ line(...pointOnCircle(12, 7.6, SMALL_RADIUS, 79.2), ...pointOnCircle(12, 15.6, BIG_RADIUS, 278.7)),
581
+ arc(12, 15.6, BIG_RADIUS, 278.7, 536.7 - arcInset(BIG_RADIUS)),
582
+ ],
583
+ // one stroke: crossbar right → left, diagonal up to the top, stem down
584
+ 4: [
585
+ line(17.6 - END_INSET, 14.95, 8, 14.95),
586
+ line(8, 14.95, 14.75, TOP_BAR_Y),
587
+ line(14.75, TOP_BAR_Y, 14.75, BASE_Y),
588
+ ],
589
+ 5: [
590
+ line(17.1 - END_INSET, TOP_BAR_Y, 10.5, TOP_BAR_Y),
591
+ line(10.5, TOP_BAR_Y, ...pointOnCircle(11.9, 15, 4.8, FIVE_JOIN_ANGLE)),
592
+ arc(11.9, 15, 4.8, FIVE_JOIN_ANGLE, 523.3 - arcInset(4.8)),
593
+ ],
594
+ 6: [
595
+ line(...SIX_TAIL_TOP, ...SIX_TAIL_JOIN),
596
+ arc(12, 15.6, BIG_RADIUS, SIX_TAIL_ANGLE, SIX_TAIL_ANGLE - 360),
597
+ ],
598
+ 7: [line(6.7 + END_INSET, TOP_BAR_Y, 16, TOP_BAR_Y), line(16, TOP_BAR_Y, ...SEVEN_FOOT)],
599
+ // closed figure-eight: top circle clockwise, bottom circle counter-clockwise, meeting where they touch
600
+ 8: [arc(12, 7.6, SMALL_RADIUS, 90, 450), arc(12, 15.6, BIG_RADIUS, 270, -90)],
601
+ 9: [
602
+ arc(12, 8.4, BIG_RADIUS, SIX_TAIL_ANGLE + 180 - 360, SIX_TAIL_ANGLE + 180),
603
+ line(...pointOnCircle(12, 8.4, BIG_RADIUS, SIX_TAIL_ANGLE + 180), ...NINE_TAIL_END),
604
+ ],
605
+ '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
606
+ },
607
+ alternativeStrokes: {
608
+ // the 8 traced like the 0 (from the top, counter-clockwise): left half of the top circle down to the waist,
609
+ // once around the bottom circle, right half of the top circle back up
610
+ '8-like-0': [
611
+ arc(12, 7.6, SMALL_RADIUS, 270, 90),
612
+ arc(12, 15.6, BIG_RADIUS, 270, -90),
613
+ arc(12, 7.6, SMALL_RADIUS, 90, -90),
614
+ ],
615
+ // the 8 as two clockwise loops, both starting at the waist (like the 3: top arc, then bottom arc)
616
+ '8-two-loops': [arc(12, 7.6, SMALL_RADIUS, 90, 450), arc(12, 15.6, BIG_RADIUS, 270, 630)],
617
+ // the 6 with its circle drawn clockwise (the same way round as the 5's bowl)
618
+ '6-clockwise': [
619
+ line(...SIX_TAIL_TOP, ...SIX_TAIL_JOIN),
620
+ arc(12, 15.6, BIG_RADIUS, SIX_TAIL_ANGLE, SIX_TAIL_ANGLE + 360),
621
+ ],
622
+ // mirrored figure-eight from the waist: top circle counter-clockwise, bottom circle clockwise (like the 5)
623
+ '8-mirrored': [arc(12, 7.6, SMALL_RADIUS, 90, -270), arc(12, 15.6, BIG_RADIUS, 270, 630)],
624
+ // the usual figure-eight, but starting on the right side of the bottom circle (where the 4's crossbar starts)
625
+ '8-from-right': [
626
+ arc(12, 15.6, BIG_RADIUS, 0, -90),
627
+ arc(12, 7.6, SMALL_RADIUS, 90, 450),
628
+ arc(12, 15.6, BIG_RADIUS, 270, 0),
629
+ ],
630
+ },
631
+ pairLayouts: {
632
+ 0: {
633
+ // cut the 0 open at the upper left: the part going up over the top becomes the flag, the rest the stem
634
+ 1: { startSegment: 2, reversed: true },
635
+ // start at the leftmost point and go up, like the 2's bowl
636
+ 2: { startSegment: 3, reversed: true },
637
+ 3: { startSegment: 3, reversed: true },
638
+ // start at the lower right and go left: bottom → crossbar, left side → diagonal, right side → stem
639
+ 4: { startSegment: 7, reversed: true },
640
+ // optimizer: start at the upper left and go clockwise, the same way round as the 5's bowl
641
+ 5: { startSegment: 2, reversed: true },
642
+ // start at the leftmost point and go up: the top becomes the bar, the right side the diagonal
643
+ 7: { startSegment: 3, reversed: true },
644
+ // start at the bottom and go clockwise, the same way round as the 9's circle
645
+ 9: { startSegment: 6, reversed: true },
646
+ },
647
+ 1: {
648
+ 4: { breakpoints: [0, 1, 1.0625, 1.125, 1.1875, 1.2708, 1.2708, 1.6875, 1.6875, 1.6875, 1.6875, 1.6875, 2] },
649
+ // flag curls into the top loop, stem rounds into the bottom loop (optimizer fit)
650
+ 8: { breakpoints: [0, 0, 0, 1, 1.3125, 1.4583, 1.5208, 1.8333, 1.8333, 1.8333, 1.8333, 1.8333, 2] },
651
+ // fewer segments for the 9's circle, more for its tail; the circle unrolls into flag + stem top (optimizer fit)
652
+ 9: { breakpoints: [0, 0, 0, 1, 1.1042, 1.4167, 1.4792, 1.5625, 1.625, 1.6875, 1.7708, 1.8125, 2] },
653
+ },
654
+ 2: {
655
+ // the 4's crossbar shrinks into the 2's starting tip, the diagonal and stem bend into the bowl (optimizer fit)
656
+ 4: { breakpoints: [0, 0, 0.3542, 0.6875, 0.7708, 1, 1.2083, 1.2292, 2, 2.7292, 2.7292, 2.7292, 3] },
657
+ // bowl → the 5's bar and down-stroke, diagonal + base → its bowl (optimizer fit)
658
+ 5: { breakpoints: [0, 0.375, 0.375, 0.375, 0.625, 1, 1.0833, 1.0833, 1.1458, 1.4375, 1.7083, 2, 3] },
659
+ // the bowl closes into the 9's circle (same circle), the base pulls back into the tail (optimizer fit)
660
+ 9: { breakpoints: [0, 0, 0, 0, 0.2083, 0.3958, 0.5833, 0.7708, 1, 1.2083, 1.3542, 2, 3] },
661
+ // 2 → 6 spins (see spins): in the turning frame it is 2 → 9 — the 2's bowl is exactly the 9's circle
662
+ 6: { breakpoints: [0, 0, 0, 0, 0.2292, 0.4167, 0.6042, 0.7708, 1, 1.2083, 1.4167, 2, 3] },
663
+ },
664
+ 3: {
665
+ // the 4's crossbar shrinks into the 3's starting tip, the diagonal becomes the top arc (optimizer fit)
666
+ 4: { breakpoints: [0, 0, 0.3333, 0.6042, 0.7917, 1, 2, 2.1458, 2.3333, 2.5208, 2.5208, 2.6667, 3] },
667
+ // the small top arc grows into the 9's circle, the lower bowl straightens into the tail (optimizer fit)
668
+ 9: { breakpoints: [0, 0, 0, 0, 0.0208, 0.2708, 0.4792, 0.6458, 1, 2, 2.3542, 2.6667, 3] },
669
+ },
670
+ 4: {
671
+ // drawn from the stem's bottom: the 1's stem stays a stem, its flag folds out into diagonal + crossbar
672
+ 1: { breakpoints: [0, 0.4375, 0.4375, 0.4375, 0.4375, 1, 1.6042, 1.6042, 1.7292, 1.8333, 1.8958, 2, 3], reversed: true },
673
+ 2: { breakpoints: [0, 1, 2, 2, 2.1458, 2.3958, 2.5208, 2.6667, 2.8125, 2.9375, 2.9375, 2.9375, 3] },
674
+ 3: { breakpoints: [0, 1, 2, 2.0208, 2.2708, 2.3958, 2.3958, 2.4792, 2.6875, 2.9167, 2.9167, 2.9167, 3] },
675
+ // crossbar → top bar (moves up), diagonal → down-stroke, stem → bowl (optimizer fit)
676
+ 5: { breakpoints: [0, 1, 1.3958, 1.3958, 1.3958, 1.4792, 1.4792, 2, 2.3958, 2.3958, 2.5833, 2.9583, 3] },
677
+ // drawn from the stem's bottom: stem top → the 6's tail top, diagonal + crossbar → circle (optimizer fit)
678
+ 6: { breakpoints: [0, 0.3542, 0.3542, 0.3542, 0.3542, 0.4375, 0.875, 1, 1.1042, 1.3958, 1.6042, 2, 3], reversed: true },
679
+ // drawn from the stem's bottom: stem tips over into the 7's bar, diagonal + crossbar → its diagonal (optimizer fit)
680
+ 7: { breakpoints: [0, 1, 1, 1.1667, 1.3542, 1.5625, 1.7292, 1.9792, 2, 2, 2, 2, 3], reversed: true },
681
+ // drawn backwards (stem bottom first), so the stem's end meets the crossbar's start on the 8's right side
682
+ 8: { reversed: true },
683
+ },
684
+ 5: {
685
+ 0: { breakpoints: [0, 1, 1, 2, 2.2708, 2.3125, 2.4792, 2.625, 2.75, 2.8542, 3, 3, 3] },
686
+ 2: { breakpoints: [0, 1, 1, 1, 2, 2.2917, 2.2917, 2.2917, 2.2917, 2.5417, 2.8542, 2.8542, 3] },
687
+ 4: { breakpoints: [0, 1, 2, 2, 2, 2.0833, 2.1458, 2.1667, 2.1875, 2.25, 2.3958, 2.6875, 3] },
688
+ // bar stays the bar, down-stroke + bowl straighten into the 7's diagonal (optimizer fit)
689
+ 7: { breakpoints: [0, 0.5625, 0.5625, 0.5625, 0.5625, 0.5625, 1, 2, 2.2083, 2.5208, 2.8333, 2.9167, 3] },
690
+ // start of the 5 (bar end) moves down to the 9's start; segments re-spread on both (optimizer fit)
691
+ 9: { breakpoints: [0, 1, 2, 2, 2.0208, 2.1042, 2.1667, 2.2292, 2.2708, 2.3125, 2.5625, 2.75, 3] },
692
+ },
693
+ 6: {
694
+ // the 1's flag shrinks into the top of the tail, its stem tilts into the tail and curls into the circle
695
+ // (optimizer fit, like 7 → 6)
696
+ 1: {
697
+ tracing: '6-clockwise',
698
+ breakpoints: [0, 0.1042, 0.1042, 0.1667, 0.3125, 0.3125, 1, 1.25, 1.25, 1.3542, 1.5, 1.75, 2],
699
+ },
700
+ // turned by 180° this is the 9 laid out for the 2 (optimizer fit for 2 ↔ 9, mirrored onto the 6)
701
+ 2: { breakpoints: [0, 0.1042, 0.5417, 0.75, 1, 1.125, 1.25, 1.375, 1.5, 1.6458, 1.6458, 1.75, 2], reversed: true },
702
+ 4: { breakpoints: [0, 0.3958, 0.4792, 0.75, 1, 1.0625, 1.1458, 1.3958, 1.625, 1.625, 1.625, 1.75, 2] },
703
+ // the 3's lower bowl lies on the 6's circle and closes up; its top arc straightens into the tail (optimizer fit)
704
+ 3: {
705
+ tracing: '6-clockwise',
706
+ breakpoints: [0, 0.2292, 0.2292, 0.2292, 0.3125, 1, 1.2083, 1.2917, 1.4167, 1.5417, 1.6667, 1.7708, 2],
707
+ },
708
+ // the 5's bowl end closes up to where the bowl starts; bar + down-stroke become the tail (optimizer fit)
709
+ 5: {
710
+ tracing: '6-clockwise',
711
+ breakpoints: [0, 0.0833, 0.25, 0.4375, 0.5625, 1, 1.1875, 1.2708, 1.3958, 1.5, 1.6458, 1.75, 2],
712
+ },
713
+ // the 7's bar shrinks into the top of the tail, its diagonal becomes the tail and curls into the circle
714
+ // (optimizer fit)
715
+ 7: {
716
+ tracing: '6-clockwise',
717
+ breakpoints: [0, 0.1667, 0.1667, 0.1667, 0.1667, 0.1667, 0.3125, 1, 1.25, 1.5, 1.75, 1.75, 2],
718
+ },
719
+ },
720
+ 7: {
721
+ 5: { breakpoints: [0, 0.6875, 0.6875, 0.6875, 0.6875, 0.6875, 1, 1.4375, 1.4375, 1.6042, 1.9167, 1.9167, 2] },
722
+ 4: { breakpoints: [0, 1, 1.0833, 1.0833, 1.0833, 1.1042, 1.25, 1.3542, 1.5, 1.625, 1.6667, 1.6667, 2] },
723
+ },
724
+ 8: {
725
+ 1: { breakpoints: [0, 0.25, 0.4792, 0.5625, 0.8125, 1, 1.0625, 1.3125, 1.5625, 1.6042, 1.6042, 1.75, 2] },
726
+ // 0's leftmost and rightmost points (after 3 and 9 segments) both land on the waist
727
+ 0: {
728
+ tracing: '8-like-0',
729
+ breakpoints: [0, 1 / 3, 2 / 3, 1, 1 + 1 / 6, 1 + 2 / 6, 1 + 3 / 6, 1 + 4 / 6, 1 + 5 / 6, 2, 2 + 1 / 3, 2 + 2 / 3, 3],
730
+ },
731
+ // both ends of the 3 slide along their circles into the waist: 5 segments on the top loop like the 3's
732
+ // top arc, a zero-length one at the waist for the 3's middle link, 6 on the bottom loop
733
+ 3: {
734
+ tracing: '8-two-loops',
735
+ breakpoints: [0, 0.2, 0.4, 0.6, 0.8, 1, 1, 1 + 1 / 6, 1 + 2 / 6, 1 + 3 / 6, 1 + 4 / 6, 1 + 5 / 6, 2],
736
+ },
737
+ // the 4's two ends meet on the right side of the bottom circle (optimizer fit)
738
+ 4: {
739
+ tracing: '8-from-right',
740
+ breakpoints: [0, 0, 0.125, 1, 1.25, 1.5, 1.75, 2, 2.1458, 2.2917, 2.625, 2.9583, 3],
741
+ },
742
+ // both ends of the 5 meet at the waist: bar + down-stroke → top loop, bowl → bottom loop (optimizer fit)
743
+ 5: {
744
+ tracing: '8-mirrored',
745
+ breakpoints: [0, 0.25, 0.5, 0.6875, 0.8125, 0.8958, 1, 1.125, 1.2292, 1.3542, 1.5, 1.75, 2],
746
+ },
747
+ // 8 → 9 spins (see spins): in the turning frame it is 8 → 6, the 8's big circle stays the 6's circle
748
+ 9: { startSegment: 1 },
749
+ },
750
+ 9: {
751
+ 1: { breakpoints: [0, 0.25, 0.5, 0.75, 0.8125, 1, 1.1042, 1.2292, 1.3542, 1.4583, 1.6042, 1.6875, 2] },
752
+ 2: { breakpoints: [0, 0.25, 0.375, 0.375, 0.5, 0.625, 0.75, 0.875, 1, 1.2708, 1.4375, 2, 2] },
753
+ 3: { breakpoints: [0, 0.25, 0.4792, 0.4792, 0.4792, 0.625, 0.75, 0.875, 1, 1.1667, 1.3125, 1.9583, 2] },
754
+ 5: { breakpoints: [0, 0, 0.25, 0.2917, 0.3125, 0.5625, 0.8125, 1, 1.0833, 1.0833, 1.5, 1.8542, 2] },
755
+ // turned back by 180° this is exactly the 6 (same segment split, same direction) → a pure spin
756
+ 6: { breakpoints: [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1, 1.25, 1.5, 1.75, 2], reversed: true },
757
+ // turned back by 180° this is the 6 laid out for the 8 (optimizer fit for 6 ↔ 8, mirrored onto the 9)
758
+ 8: { breakpoints: [0, 0.1667, 0.2917, 0.4375, 0.5833, 0.7292, 0.875, 1, 1.3125, 1.7083, 1.75, 1.75, 2], reversed: true },
759
+ },
760
+ },
761
+ // 9 is exactly 6 turned 180°, the 6's circle is the 8's bottom circle and the 2's bowl is the 9's circle:
762
+ // 6 ↔ 9, 8 ↔ 9 and 2 ↔ 6 morph while turning
763
+ spins: {
764
+ 2: { 6: 180 },
765
+ 6: { 9: 180, 2: -180 },
766
+ 8: { 9: 180 },
767
+ 9: { 6: -180, 8: -180 },
768
+ },
769
+ textLayout: { cellWidth: 10, spacing: 4, baselineY: 19.8, colonDotsY: [7.6, 15.6] },
770
+ };
771
+ return { roundDigits };
772
+ })();
773
+
774
+ // sets/slim.ts
775
+ const { slimDigits } = (() => {
776
+ // Digit set "slim": thin, tall, round digits, drafted from the reference images in icons/slim/
777
+ // (the set format is described in ../types.ts).
778
+ //
779
+ // The images (94 px tall, 6 px stroke, 50 px wide; 5.5 px = 1 grid unit) reveal a strict construction, so the set
780
+ // is written directly in grid units: everything round is an arc of two equal circles of radius 4, stacked and
781
+ // touching at 12,12 — TOP at 12,8 and BOTTOM at 12,16 — inside the box x 8…16, y 4…20.
782
+ // - 8 = both circles; 3 = arcs of both; 2's bowl = TOP; 5's bowl = half of BOTTOM;
783
+ // 6 = arc of TOP + straight left side + a loop (a stadium a bit taller than BOTTOM); 9 = 6 turned 180° around 12,12
784
+ // - 0 = stadium; the 4 is wider and its top point sits lower (y ≈ 5.2), as in the images (its values nudged to the best pixel match)
785
+ // - round ends and corners
786
+ const RADIUS = 4;
787
+ const TOP = [12, 8];
788
+ const BOTTOM = [12, 16];
789
+ const topArc = (startAngle, endAngle) => arc(...TOP, RADIUS, startAngle, endAngle);
790
+ const bottomArc = (startAngle, endAngle) => arc(...BOTTOM, RADIUS, startAngle, endAngle);
791
+ /** Where the 2's diagonal leaves its bowl: tangent from the corner at 8,20 (a 3-4-5 triangle, hence the round angle). */
792
+ const TWO_DIAGONAL_ANGLE = 36.8699;
793
+ /** Where the 6's top arc ends on the right (measured: slightly below the circle's right point). */
794
+ const SIX_HOOK_ANGLE = 8;
795
+ const turnedAround = (stroke) => {
796
+ const turn = ([x, y]) => [24 - x, 24 - y];
797
+ switch (stroke.kind) {
798
+ case 'line': return line(...turn(stroke.to), ...turn(stroke.from));
799
+ case 'arc': return arc(...turn(stroke.center), stroke.radius, stroke.endAngle + 180, stroke.startAngle + 180);
800
+ case 'cubic': return { kind: 'cubic', segment: [turn(stroke.segment[3]), turn(stroke.segment[2]), turn(stroke.segment[1]), turn(stroke.segment[0])] };
801
+ }
802
+ };
803
+ /** The 6's loop is taller than the bottom circle: a stadium from this height down to 20 (measured). */
804
+ const SIX_LOOP_TOP = 10.91;
805
+ // the 6: top arc from the right over to the left, down the left side, round the loop and back into the left side
806
+ const SIX = [
807
+ topArc(SIX_HOOK_ANGLE, -180),
808
+ ...roundedPolyline([[8, 8], [8, 20], [16, 20], [16, SIX_LOOP_TOP], [8, SIX_LOOP_TOP], [8, SIX_LOOP_TOP + RADIUS]], [0, RADIUS, RADIUS, RADIUS, RADIUS, 0]),
809
+ ];
810
+ const slimDigits = {
811
+ segmentCount: 16,
812
+ strokeWidth: 6 / 5.5,
813
+ lineCap: 'round',
814
+ lineJoin: 'round',
815
+ strokes: {
816
+ // stadium, from the top middle counter-clockwise
817
+ 0: roundedPolyline([[12, 4], [8, 4], [8, 20], [16, 20], [16, 4], [12, 4]], RADIUS),
818
+ // slanted flag up to the top of the stem, stem down
819
+ 1: [line(9.64, 9.1, 14.45, 4), line(14.45, 4, 14.45, 20)],
820
+ // bowl over the top, tangent diagonal down to the corner, base
821
+ 2: [
822
+ topArc(180, TWO_DIAGONAL_ANGLE + 360),
823
+ line(...pointOnCircle(...TOP, RADIUS, TWO_DIAGONAL_ANGLE), 8, 20),
824
+ line(8, 20, 16, 20),
825
+ ],
826
+ // top arc from the left round to the middle, bottom arc from the middle round to the left
827
+ 3: [topArc(180, 450), bottomArc(270, 548)],
828
+ // one stroke: crossbar right → left, diagonal up to the top, stem down
829
+ 4: [line(16.96, 16.03, 7, 16.03), line(7, 16.03, 14.85, 5.17), line(14.85, 5.17, 14.85, 20)],
830
+ // top bar right → left, left side down, middle bar, bowl (half of the bottom circle), bottom bar
831
+ 5: [line(16, 4, 8, 4), line(8, 4, 8, 12), line(8, 12, 12, 12), bottomArc(270, 450), line(12, 20, 8, 20)],
832
+ 6: SIX,
833
+ // top bar → slanted stroke down
834
+ 7: [line(8.09, 4, 15.89, 4), line(15.89, 4, 12.09, 20)],
835
+ // closed figure-eight: top circle clockwise, bottom circle counter-clockwise, meeting where they touch
836
+ 8: [topArc(90, 450), bottomArc(270, -90)],
837
+ // the 6 turned 180°, drawn the other way round
838
+ 9: SIX.map(turnedAround).reverse(),
839
+ '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
840
+ },
841
+ alternativeStrokes: {
842
+ // the 8 traced like the 0 (as in the round set): from the top counter-clockwise — left half of TOP down to the waist,
843
+ // once around BOTTOM, right half of TOP back up
844
+ '8-like-0': [topArc(270, 90), bottomArc(270, -90), topArc(90, -90)],
845
+ // the 8 as two clockwise loops from the waist (as in the round set): the 3's ends slide along the circles into the waist
846
+ '8-two-loops': [topArc(90, 450), bottomArc(270, 630)],
847
+ // the usual figure-eight starting on the right side of BOTTOM (as in the round set, where the 4's crossbar starts)
848
+ '8-from-right': [bottomArc(0, -90), topArc(90, 450), bottomArc(270, 0)],
849
+ // mirrored figure-eight from the waist (as in the round set): TOP counter-clockwise, BOTTOM clockwise — like the 5
850
+ '8-mirrored': [topArc(90, -270), bottomArc(270, 630)],
851
+ },
852
+ // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd
853
+ pairLayouts: {
854
+ 0: {
855
+ 1: { startSegment: 6, reversed: true },
856
+ 2: { startSegment: 5, reversed: true },
857
+ 3: { startSegment: 4, reversed: true },
858
+ 4: { startSegment: 10, reversed: true },
859
+ 5: { startSegment: 5, reversed: true },
860
+ 7: { startSegment: 5, reversed: true },
861
+ 9: { startSegment: 5, reversed: true },
862
+ },
863
+ 1: {
864
+ 0: { breakpoints: [0, 0, 0, 0, 0.1875, 0.5208, 0.7708, 1, 1.2708, 1.4167, 1.5833, 1.75, 1.9375, 1.9375, 1.9375, 1.9375, 2] },
865
+ },
866
+ 2: {
867
+ 4: { breakpoints: [0, 0, 0.3958, 0.6458, 0.6458, 0.6667, 0.875, 1, 1.1458, 1.1458, 2, 2.875, 2.875, 2.875, 2.875, 2.875, 3] },
868
+ // 2 → 6 spins (see spins): in the turning frame it is 2 → 9 (same layout as 2 → 9)
869
+ 6: { breakpoints: [0, 0, 0, 0, 0, 0, 0.2083, 0.4167, 0.625, 0.8333, 1, 1.1458, 1.3333, 1.6042, 1.8125, 2, 3] },
870
+ 9: { breakpoints: [0, 0, 0, 0, 0, 0, 0.2083, 0.4167, 0.625, 0.8333, 1, 1.1458, 1.3333, 1.6042, 1.8125, 2, 3] },
871
+ 0: { breakpoints: [0, 0, 0, 0.1458, 0.2708, 0.4167, 0.625, 0.8333, 1, 1.1458, 1.3333, 1.5833, 1.8125, 1.8333, 1.8333, 2, 3] },
872
+ },
873
+ 3: {
874
+ 4: { breakpoints: [0, 0, 0.3333, 0.5208, 0.5208, 0.6042, 0.7708, 0.8333, 1, 1.1667, 1.2083, 1.3958, 1.4792, 1.4792, 1.4792, 1.6875, 2] },
875
+ 0: { breakpoints: [0, 0, 0.1042, 0.2292, 0.3333, 0.5, 0.6667, 1, 1.2292, 1.3125, 1.4792, 1.6458, 1.75, 1.8542, 2, 2, 2] },
876
+ },
877
+ 4: {
878
+ 6: { breakpoints: [0, 0.25, 0.25, 0.25, 0.4375, 1, 1.2917, 1.3542, 1.5208, 1.9583, 2, 2.1875, 2.1875, 2.1875, 2.1875, 2.1875, 3] },
879
+ // as in the round set: drawn backwards, so the stem's end meets the crossbar's start on the 8's right side
880
+ 8: { reversed: true },
881
+ 3: { breakpoints: [0, 1, 1.9792, 1.9792, 2, 2.1458, 2.2917, 2.4375, 2.4583, 2.5, 2.625, 2.7917, 2.9375, 2.9375, 2.9375, 2.9375, 3] },
882
+ 2: { breakpoints: [0, 1, 1.9583, 1.9583, 2, 2.0625, 2.2292, 2.3542, 2.4792, 2.6042, 2.75, 2.875, 3, 3, 3, 3, 3] },
883
+ 0: { breakpoints: [0, 0.5417, 0.7083, 0.8333, 1, 1.2083, 1.375, 1.5208, 1.6667, 1.8333, 1.9583, 2, 2.1875, 2.375, 2.5625, 2.7083, 3] },
884
+ },
885
+ 5: {
886
+ 0: { breakpoints: [0, 1, 1.1875, 1.1875, 1.1875, 1.1875, 2, 3, 3.25, 3.2917, 3.5, 3.75, 4, 4.5208, 4.8958, 5, 5] },
887
+ },
888
+ 6: {
889
+ 8: { breakpoints: [0, 0.4583, 0.5208, 0.5208, 0.5417, 1, 1.875, 2, 2.5, 3, 3.5, 4, 5, 5.4583, 5.9792, 6, 7] },
890
+ 4: { breakpoints: [0, 0, 0, 0, 0, 0.0625, 0.25, 0.5417, 1, 1.3542, 1.5, 2, 3, 4, 5, 6, 7], reversed: true },
891
+ // turned by 180° this is the 9 laid out for the 2 (the 2 ↔ 9 fit, mirrored onto the 6)
892
+ 2: { breakpoints: [0, 0.2917, 0.2917, 0.5417, 1, 1.5833, 1.6875, 2, 2.5, 3, 3.5, 4, 4, 4, 5, 6, 7], reversed: true },
893
+ },
894
+ 7: {
895
+ 0: { breakpoints: [0, 0, 0, 0.0417, 0.25, 0.4792, 1, 1.25, 1.3958, 1.5417, 1.7083, 1.9167, 1.9375, 1.9375, 1.9375, 1.9375, 2] },
896
+ },
897
+ 8: {
898
+ // as in round, square and condensed: the 0's left and right points move in to the waist
899
+ 0: { tracing: '8-like-0', breakpoints: [0, 0.1667, 0.3333, 0.5208, 0.625, 1, 1.25, 1.3333, 1.4167, 1.5, 1.625, 1.75, 2, 2.375, 2.4792, 2.75, 3] },
900
+ // the user's idea from the round set: the 4's two ends meet on the right side of the bottom circle
901
+ 4: { tracing: '8-from-right', breakpoints: [0, 0, 0.1667, 0.5, 1, 1.25, 1.5, 1.7083, 1.9167, 2, 2.125, 2.2292, 2.3333, 2.3542, 2.6667, 2.9792, 3] },
902
+ 6: { breakpoints: [0, 0.25, 0.5, 0.5, 0.75, 1, 1.1875, 1.25, 1.375, 1.5, 1.625, 1.75, 1.7917, 1.8958, 2, 2, 2] },
903
+ // the two-loop tracing fits the 9 best (no spin needed)
904
+ 9: { tracing: '8-two-loops', breakpoints: [0, 0, 0, 0.1042, 0.2083, 0.25, 0.375, 0.5, 0.625, 0.75, 0.8125, 1, 1.25, 1.375, 1.5, 1.75, 2] },
905
+ // the user's idea from the round set: both ends of the 5 meet at the waist — bars → top loop, bowl → bottom loop
906
+ 5: { tracing: '8-mirrored', breakpoints: [0, 0.25, 0.5, 0.5833, 0.625, 0.6875, 0.8125, 0.875, 0.9167, 1, 1.1042, 1.2083, 1.2917, 1.3958, 1.5, 1.75, 2] },
907
+ // the user's idea from the round set: both ends of the 3 slide along the circles into the waist
908
+ 3: { tracing: '8-two-loops', breakpoints: [0, 0.25, 0.4583, 0.5208, 0.625, 0.7083, 0.8125, 0.8958, 1, 1.1042, 1.1875, 1.2917, 1.3958, 1.4792, 1.5625, 1.75, 2] },
909
+ },
910
+ 9: {
911
+ 2: { breakpoints: [0, 1, 2, 3, 3, 3, 3.5, 4, 4.5, 5, 5.3125, 5.4167, 6, 6.4583, 6.7083, 6.7083, 7] },
912
+ 0: { breakpoints: [0, 1, 2, 3, 3.7292, 4, 4.5, 5, 5.3333, 5.6667, 6, 6.2292, 6.4792, 6.6458, 6.7917, 7, 7] },
913
+ // turned back by 180° this is exactly the 6 (same segment split, same direction) → a pure spin
914
+ 6: { breakpoints: [0, 0.5, 1, 1.5, 2, 3, 3.5, 4, 4.5, 5, 16 / 3, 17 / 3, 6, 6.25, 6.5, 6.75, 7], reversed: true },
915
+ },
916
+ },
917
+ // 9 is exactly 6 turned 180°; 2 → 6 also turns (it is 2 → 9 in the turning frame)
918
+ spins: { 2: { 6: 180 }, 6: { 9: 180, 2: -180 }, 9: { 6: -180 } },
919
+ textLayout: { cellWidth: 9.5, spacing: 4, baselineY: 20, colonDotsY: [8, 16] },
920
+ };
921
+ return { slimDigits };
922
+ })();
923
+
924
+ // sets/square.ts
925
+ const { squareDigits } = (() => {
926
+ // Digit set "square": rounded-square "techno" digits, drafted from the reference images in icons/square/
927
+ // (the set format is described in ../types.ts).
928
+ //
929
+ // The images are tiny (every digit 28 px tall, 4 px stroke), so the shapes are written on the images' own pixel
930
+ // grid and converted: x/y are the stroke's middle line in image pixels, measured from the digit's ink box
931
+ // (y 2…26 = top to bottom middle line; x 2 = middle of a stroke on the left edge). 1.5 image px = 1 grid unit.
932
+ // - straight strokes with rounded corners (roundedPolyline), round ends
933
+ // - 3 and 5 share the same right-side bowls (corner radius 5 px); 8 = two stacked rounded rectangles sharing
934
+ // the middle bar; 9 = 6 turned 180° around 12,12
935
+ // - T-junctions (1's foot, 3's middle bar, 4's crossbar) are drawn out and back over themselves
936
+ const PIXELS_PER_UNIT = 1.5;
937
+ /** The images' stroke is 4 px ≈ 2.67 units; thinner by the user's choice. */
938
+ const STROKE_WIDTH = 1.6;
939
+ /** Image pixels (middle line, measured from the ink box of a digit `inkWidth` px wide) → grid units. */
940
+ const fromImage = (inkWidth) => ([x, y]) => [12 + (x - inkWidth / 2) / PIXELS_PER_UNIT, 4 + (y - 2) / PIXELS_PER_UNIT];
941
+ /** A rounded polyline given in image pixels; radii in image pixels too. */
942
+ const drawn = (inkWidth, points, radii) => roundedPolyline(points.map(fromImage(inkWidth)), Array.isArray(radii) ? radii.map((radius) => radius / PIXELS_PER_UNIT) : radii / PIXELS_PER_UNIT);
943
+ const turnedAround = ([x, y]) => [24 - x, 24 - y];
944
+ // corner radii (image px), each the best pixel match with the images; bowls are exact half-circles
945
+ const ZERO_RADIUS = 8; // half the width: the 0 is a stadium
946
+ const THREE_BOWL_RADIUS = 6; // half of each bowl's height (12 px)
947
+ const FIVE_BOWL_RADIUS = 6.5; // half of the bowl's height (13 px)
948
+ const SIX_RADIUS = 7;
949
+ const EIGHT_RADIUS = 6;
950
+ // the 6: hook from the top right, round the left side, then the closed bottom loop back to the left side
951
+ const SIX_POINTS = [[15, 2], [2, 2], [2, 26], [19, 26], [19, 12], [2, 12]];
952
+ const SIX_RADII = [0, SIX_RADIUS, SIX_RADIUS, SIX_RADIUS, SIX_RADIUS, 0];
953
+ const squareDigits = {
954
+ segmentCount: 20,
955
+ strokeWidth: STROKE_WIDTH,
956
+ lineCap: 'round',
957
+ lineJoin: 'round',
958
+ strokes: {
959
+ // closed rounded rectangle, from the top middle counter-clockwise
960
+ 0: drawn(20, [[10, 2], [2, 2], [2, 26], [18, 26], [18, 2], [10, 2]], ZERO_RADIUS),
961
+ // flag → stem down → foot to the left and back across to the right
962
+ 1: drawn(16, [[2, 2], [8, 2], [8, 26], [2, 26], [14, 26]], 0),
963
+ // top bar → right side → diagonal down to the left → left side → base
964
+ 2: drawn(20, [[3, 2], [18, 2], [18, 9.25], [2, 17.25], [2, 26], [18, 26]], [0, 2, 4.5, 4.5, 0, 0]),
965
+ // top bar → upper bowl → middle bar out to the left and back → lower bowl → bottom bar
966
+ 3: drawn(20, [[2, 2], [18, 2], [18, 14], [6, 14], [18, 14], [18, 26], [2, 26]], [0, THREE_BOWL_RADIUS, THREE_BOWL_RADIUS, 0, THREE_BOWL_RADIUS, THREE_BOWL_RADIUS, 0]),
967
+ // left stem down → crossbar to its right end and back to the right stem → down to the bottom and back up to the top
968
+ 4: drawn(24, [[2, 2], [2, 18], [22, 18], [18, 18], [18, 26], [18, 2]], [0, 1, 0, 0, 0, 0]),
969
+ // top bar right → left, down the left side, middle bar → bowl → bottom bar
970
+ 5: drawn(20, [[16, 2], [2, 2], [2, 13], [18, 13], [18, 26], [2, 26]], [0, 0, 0, FIVE_BOWL_RADIUS, FIVE_BOWL_RADIUS, 0]),
971
+ 6: drawn(21, SIX_POINTS, SIX_RADII),
972
+ // top bar → diagonal down to the left
973
+ 7: drawn(20, [[2, 2], [17.5, 2], [3.7, 26]], 0),
974
+ // from the waist: top rectangle (left side up, clockwise), along the middle bar, bottom rectangle (counter-clockwise)
975
+ 8: drawn(20, [[7, 14], [3, 14], [3, 2], [17, 2], [17, 14], [2, 14], [2, 26], [18, 26], [18, 14], [7, 14]], [0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0]),
976
+ // the 6 turned 180°, drawn the other way round (loop first, then the hook)
977
+ 9: roundedPolyline(SIX_POINTS.map(fromImage(21)).map(turnedAround).reverse(), [...SIX_RADII].reverse().map((radius) => radius / PIXELS_PER_UNIT)),
978
+ '-': [line(9.5, 12, 14.5, 12)], // minus / hyphen: morphs like a digit
979
+ },
980
+ alternativeStrokes: {
981
+ // the 8 traced like the 0 (from the top, counter-clockwise): left side of the top loop down to the middle of
982
+ // the middle bar, once around the bottom loop back to the middle, right side of the top loop back up
983
+ '8-like-0': drawn(20, [[10, 2], [3, 2], [3, 14], [10, 14], [2, 14], [2, 26], [18, 26], [18, 14], [10, 14], [17, 14], [17, 2], [10, 2]], [0, EIGHT_RADIUS, EIGHT_RADIUS, 0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0, EIGHT_RADIUS, EIGHT_RADIUS, 0]),
984
+ // the 8 traced like the 3, starting and ending at the left part of the middle bar: around the top loop, along
985
+ // the middle bar out to the left and back, around the bottom loop
986
+ '8-like-3': drawn(20, [[9, 14], [3, 14], [3, 2], [17, 2], [17, 14], [8, 14], [18, 14], [18, 26], [2, 26], [2, 14], [8, 14]], [0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, EIGHT_RADIUS, 0]),
987
+ },
988
+ // optimizer fits (tools/check.ts) for the pairs the user rated bad / midd
989
+ pairLayouts: {
990
+ 0: {
991
+ 1: { startSegment: 5, reversed: true },
992
+ 2: { startSegment: 15 },
993
+ 3: { startSegment: 5, reversed: true },
994
+ 5: { startSegment: 5, reversed: true },
995
+ 7: { startSegment: 5, reversed: true },
996
+ 9: { startSegment: 7, reversed: true },
997
+ },
998
+ 1: {
999
+ 9: { breakpoints: [0, 0.2083, 0.2083, 0.4792, 1, 1, 1.25, 1.4583, 1.5417, 1.75, 2, 2, 2.0208, 2.25, 2.25, 2.25, 2.25, 2.25, 2.25, 3, 4] },
1000
+ 0: { breakpoints: [0, 0, 0, 0, 0.5, 1, 1.0208, 1.0833, 1.2083, 1.3333, 1.5, 1.6667, 1.7917, 1.9167, 1.9792, 2, 2.5, 3, 3, 3, 4] },
1001
+ },
1002
+ 2: {
1003
+ 4: { breakpoints: [0, 1, 1, 1, 1, 2, 3, 4, 4.2292, 4.2292, 4.2292, 5, 5.2292, 5.5208, 5.5208, 5.5208, 5.5208, 5.5208, 6, 7, 8] },
1004
+ 6: { breakpoints: [0, 1, 2, 3, 4, 5, 5.0833, 5.2917, 6, 6.375, 7, 7.4375, 7.6042, 7.7708, 7.7708, 7.7708, 7.7708, 7.7708, 7.7708, 7.7708, 8] },
1005
+ 9: { breakpoints: [0, 0.2708, 0.2708, 0.2708, 0.2708, 0.2708, 0.2708, 0.2708, 0.2708, 0.3958, 0.6458, 1, 2, 3, 4, 4.0833, 4.1875, 5, 6, 7, 8] },
1006
+ 0: { breakpoints: [0, 1, 2, 2, 2, 3, 4, 4.6667, 4.7917, 5, 5.1042, 5.6667, 6, 7, 7.3333, 7.5, 7.6875, 7.8542, 7.9792, 8, 8] },
1007
+ },
1008
+ 3: {
1009
+ 4: { breakpoints: [0, 1, 1.3958, 1.3958, 1.3958, 1.5833, 2, 2.4167, 2.6042, 2.6042, 2.6042, 3, 4, 5, 5.5625, 6, 6, 7, 7.9792, 8, 8] },
1010
+ 6: { breakpoints: [0, 1, 1.1458, 1.1458, 1.1458, 1.1458, 1.1458, 1.1458, 2, 3, 4, 4, 5, 6, 6.2292, 6.2292, 6.2292, 6.2292, 6.2292, 7, 8] },
1011
+ 9: { breakpoints: [0, 0.3333, 0.3333, 0.4792, 1, 1.7292, 2, 2.3125, 2.3125, 3, 4, 5, 5.5833, 5.5833, 5.5833, 6, 6.5, 7, 7.3542, 7.5625, 8] },
1012
+ 0: { breakpoints: [0, 0, 0.0417, 0.2292, 0.4792, 0.8333, 1, 1.5, 2, 3, 4, 5, 6, 6.5, 7, 7.1667, 7.5208, 7.7708, 7.9583, 8, 8] },
1013
+ },
1014
+ 4: {
1015
+ 7: { breakpoints: [0, 1, 2, 2.3125, 2.3125, 3, 4, 5, 5.7083, 5.7083, 5.7708, 5.8542, 5.9167, 6, 6, 6, 6, 6, 6, 6, 6], reversed: true },
1016
+ 9: { breakpoints: [0, 1, 2, 3, 4, 5, 5.5625, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 5.7083, 6], reversed: true },
1017
+ 3: { breakpoints: [0, 1, 2, 3, 4, 5, 5.5, 5.5, 5.5, 5.5, 5.5, 5.5, 5.5208, 5.625, 5.75, 5.875, 5.9792, 6, 6, 6, 6], reversed: true },
1018
+ 2: { breakpoints: [0, 1, 2, 2.1042, 2.1042, 2.1042, 2.1042, 2.1042, 2.2083, 2.2083, 3, 4, 5, 5.8125, 5.8125, 5.9375, 6, 6, 6, 6, 6], reversed: true },
1019
+ },
1020
+ 5: {
1021
+ 6: { breakpoints: [0, 0.3542, 0.4792, 1, 1.3333, 1.625, 2, 2, 2, 2.0208, 2.2292, 3, 4, 4, 4, 4, 4, 4, 4, 5, 6] },
1022
+ 8: { breakpoints: [0, 0.1042, 0.1042, 0.1042, 0.1042, 0.3542, 0.4583, 1, 1.5625, 2, 2.6667, 2.6667, 3, 4, 4.5, 5, 5.3542, 5.875, 5.8958, 5.8958, 6] },
1023
+ 9: { breakpoints: [0, 1, 1.4792, 1.4792, 1.4792, 1.4792, 1.4792, 1.4792, 1.4792, 1.4792, 2, 3, 3.3958, 3.7708, 4, 4.3333, 4.6667, 5, 5.3542, 5.5417, 6] },
1024
+ 0: { breakpoints: [0, 1, 1.2083, 1.2083, 1.2083, 2, 3, 3.1875, 3.2708, 3.3958, 3.5208, 4, 4.125, 4.5, 4.875, 5, 5.5208, 5.75, 5.9583, 6, 6] },
1025
+ },
1026
+ 6: {
1027
+ 7: { breakpoints: [0, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 0.375, 1, 2, 2.625, 3, 4, 5, 6, 7, 8] },
1028
+ 8: { breakpoints: [0, 0.2292, 0.2292, 0.2292, 0.2292, 1, 2, 2.5833, 2.6458, 3, 3.5417, 4, 5, 5.4583, 6, 7, 7.3542, 7.3542, 7.6875, 7.8333, 8] },
1029
+ 5: { breakpoints: [0, 0.5, 1, 1.4792, 1.6875, 2, 2.375, 2.375, 2.375, 2.375, 3, 4, 5, 5.9792, 5.9792, 5.9792, 5.9792, 5.9792, 6, 7, 8] },
1030
+ 3: { breakpoints: [0, 0.1458, 0.1458, 0.1458, 0.1458, 0.1458, 0.1458, 0.1458, 0.1458, 1, 2, 3, 4, 5, 5.625, 5.625, 5.625, 5.625, 6, 7, 8] },
1031
+ 2: { breakpoints: [0, 0, 0, 0, 1, 2, 2.8125, 2.8125, 3, 3.3125, 3.5208, 4, 5, 5.2292, 5.2292, 5.2292, 5.2292, 5.2292, 6, 7, 8] },
1032
+ },
1033
+ 7: {
1034
+ 6: { breakpoints: [0, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 0.6667, 1, 1.5208, 1.7083, 1.7083, 1.7083, 1.7083, 1.7083, 1.7083, 2] },
1035
+ 4: { breakpoints: [0, 0.9167, 0.9167, 0.9167, 0.9167, 0.9167, 0.9167, 1, 1.1042, 1.125, 1.2083, 1.2292, 1.2917, 1.5833, 1.5833, 1.5833, 1.6667, 1.7083, 1.7292, 1.7292, 2] },
1036
+ 0: { breakpoints: [0, 0, 0.0208, 0.1458, 0.3125, 0.5208, 0.6875, 1, 1.1667, 1.2292, 1.375, 1.4792, 1.6042, 1.7083, 1.8125, 1.875, 1.875, 1.875, 1.875, 1.875, 2] },
1037
+ },
1038
+ 8: {
1039
+ 5: { startSegment: 11, reversed: true },
1040
+ 6: { startSegment: 4 },
1041
+ 7: { tracing: '8-like-3', breakpoints: [0, 1, 1.8542, 2, 3, 3.2083, 3.3958, 3.5208, 3.5833, 3.6875, 4, 4.3542, 4.7708, 5, 6, 7, 8, 9, 10, 11, 12] },
1042
+ 9: { tracing: '8-like-3', breakpoints: [0, 0, 0, 0, 0.3542, 0.5833, 1, 1.2083, 1.5625, 2, 3, 3.375, 4, 5, 6, 7, 8, 9, 10, 11, 12] },
1043
+ 4: { tracing: '8-like-0', breakpoints: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9, 9, 9, 9, 10, 11, 12, 13, 14, 15] },
1044
+ // the user's idea: both ends of the 3 move to the left tip of its middle bar, where the 8's loops close
1045
+ 3: {
1046
+ tracing: '8-like-3',
1047
+ breakpoints: [0, 1, 2, 3, 3.4375, 3.7083, 4, 4.2917, 4.5625, 5, 5.7292, 6, 6.1458, 7, 7.5, 8, 8.5, 9, 10, 11, 12],
1048
+ },
1049
+ // the user's idea: the 0's left and right sides move in to the middle of the 8's middle bar
1050
+ 0: {
1051
+ tracing: '8-like-0',
1052
+ breakpoints: [0, 1, 1.6042, 2, 3, 4, 5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 10, 11, 12, 13, 13.3958, 14, 15],
1053
+ },
1054
+ },
1055
+ 9: {
1056
+ 5: { breakpoints: [0, 1, 2, 2.1875, 2.1875, 2.1875, 2.1875, 2.1875, 2.1875, 2.1875, 3, 4, 5, 5.8333, 6, 6.375, 6.6875, 7, 7.5417, 8, 8] },
1057
+ 4: { breakpoints: [0, 1, 2, 3, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5.5, 6, 6.0208, 6.0208, 7, 8, 8] },
1058
+ 3: { breakpoints: [0, 1, 2, 3, 4, 4.6667, 5, 5.1875, 5.4375, 5.5, 5.5, 5.5, 5.5, 5.5, 5.6667, 6, 6.5417, 7, 7.4583, 8, 8] },
1059
+ 2: { breakpoints: [0, 1, 2, 2.8333, 2.8333, 2.8333, 2.8333, 2.8333, 2.8333, 3, 4, 4.3542, 4.5833, 4.8125, 5, 5.0625, 6, 7, 8, 8, 8] },
1060
+ 1: { breakpoints: [0, 1, 2, 3, 3.5, 4, 5, 5.3958, 5.6042, 6, 7, 7.2708, 7.2708, 7.5208, 7.5208, 7.5208, 7.5208, 7.5208, 7.5208, 7.5208, 8] },
1061
+ 0: { breakpoints: [0, 1, 1.6667, 2, 2.2083, 2.5, 3, 3.5, 4, 4.5, 4.7917, 5, 5.5, 6, 6.1875, 6.5417, 6.875, 7, 7.875, 8, 8] },
1062
+ // turned back by 180° this is exactly the 6 (same segment split, same direction) → a pure spin
1063
+ 6: {
1064
+ breakpoints: [0, 0.5, 1, 4 / 3, 5 / 3, 2, 7 / 3, 8 / 3, 3, 4, 13 / 3, 14 / 3, 5, 16 / 3, 17 / 3, 6, 19 / 3, 20 / 3, 7, 7.5, 8],
1065
+ reversed: true,
1066
+ },
1067
+ },
1068
+ },
1069
+ spins: { 6: { 9: 180 }, 9: { 6: -180 } },
1070
+ textLayout: { cellWidth: 13.5, spacing: 4, baselineY: 20, colonDotsY: [8, 16] },
1071
+ };
1072
+ return { squareDigits };
1073
+ })();
1074
+
1075
+ // iconSets.ts
1076
+ const { iconSets, resolveIconSet } = (() => {
1077
+ // The built-in icon sets, by name. `round` is the default.
1078
+ const iconSets = {
1079
+ round: roundDigits,
1080
+ square: squareDigits,
1081
+ condensed: condensedDigits,
1082
+ slim: slimDigits,
1083
+ handwritten: handwrittenDigits,
1084
+ };
1085
+ /** A set given by name or as a set object. */
1086
+ const resolveIconSet = (set = 'round') => {
1087
+ if (typeof set !== 'string')
1088
+ return set;
1089
+ if (!(set in iconSets))
1090
+ throw new Error(`morphico: unknown icon set "${set}" (known: ${Object.keys(iconSets).join(', ')})`);
1091
+ return iconSets[set];
1092
+ };
1093
+ return { iconSets, resolveIconSet };
1094
+ })();
1095
+
1096
+ // morph.ts
1097
+ const { EMPTY_STATE, hasState, createMorphingPath } = (() => {
1098
+ // One SVG <path> that morphs between the states of an icon set. Shared by icon() and every text() cell.
1099
+ /** The empty state: nothing is drawn. Shapes grow out of it and shrink into it while fading. */
1100
+ const EMPTY_STATE = ' ';
1101
+ /** How small a shape gets while it fades into (or out of) the empty state. */
1102
+ const EMPTY_SCALE = 0.3;
1103
+ const DEFAULT_DURATION = 400;
1104
+ // default layouts of every state, computed once per set on first use (nothing runs at import)
1105
+ const shapesBySet = new WeakMap();
1106
+ const shapesOf = (iconSet) => {
1107
+ let shapes = shapesBySet.get(iconSet);
1108
+ if (!shapes) {
1109
+ shapes = defaultShapes(iconSet);
1110
+ shapesBySet.set(iconSet, shapes);
1111
+ }
1112
+ return shapes;
1113
+ };
1114
+ const hasState = (iconSet, state) => state === EMPTY_STATE || state in iconSet.strokes;
1115
+ const prefersReducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
1116
+ /** Blends towards the target in the start's frame while turning that frame by spin · progress. */
1117
+ const blend = (from, to, progress, spin) => (spin
1118
+ ? rotateAroundCenter(interpolate(from, rotateAroundCenter(to, -spin), progress), spin * progress)
1119
+ : interpolate(from, to, progress));
1120
+ const clampToUnit = (value) => Math.min(Math.max(value, 0), 1);
1121
+ const createMorphingPath = (pathElement, iconSet, initialState) => {
1122
+ const shapes = shapesOf(iconSet);
1123
+ let state = initialState;
1124
+ let currentPath = null; // null while empty
1125
+ let currentOpacity = 0;
1126
+ let running = null;
1127
+ const render = (path, opacity) => {
1128
+ currentPath = path;
1129
+ currentOpacity = path ? clampToUnit(opacity) : 0;
1130
+ if (path)
1131
+ pathElement.setAttribute('d', toPathData(path));
1132
+ else
1133
+ pathElement.removeAttribute('d');
1134
+ if (currentOpacity < 1)
1135
+ pathElement.setAttribute('opacity', String(+currentOpacity.toFixed(3)));
1136
+ else
1137
+ pathElement.removeAttribute('opacity');
1138
+ };
1139
+ /** Ends the running morph and resolves everyone waiting for it. */
1140
+ const finish = (interrupted) => {
1141
+ if (!running)
1142
+ return;
1143
+ const { stopAnimation, resolvers } = running;
1144
+ running = null;
1145
+ stopAnimation();
1146
+ for (const resolve of resolvers)
1147
+ resolve({ state, interrupted });
1148
+ };
1149
+ render(state === EMPTY_STATE ? null : shapes[state], 1);
1150
+ return {
1151
+ get state() { return state; },
1152
+ morphTo(nextState, { duration = DEFAULT_DURATION, easing = 'easeInOutCubic' } = {}) {
1153
+ // asked again for the state it is already heading to: wait for the running morph
1154
+ if (running && nextState === state) {
1155
+ const { resolvers } = running;
1156
+ return new Promise((resolve) => { resolvers.push(resolve); });
1157
+ }
1158
+ const settled = !running;
1159
+ if (settled && nextState === state)
1160
+ return Promise.resolve({ state, interrupted: false });
1161
+ finish(true);
1162
+ const previousState = state;
1163
+ state = nextState;
1164
+ // settled: both sides use the pair's layouts; interrupted: start from the shape on screen
1165
+ const target = nextState === EMPTY_STATE ? null
1166
+ : previousState === EMPTY_STATE ? shapes[nextState] : shapeForPair(iconSet, shapes, nextState, previousState);
1167
+ const start = !settled ? currentPath
1168
+ : previousState === EMPTY_STATE ? null : shapeForPair(iconSet, shapes, previousState, nextState);
1169
+ const startOpacity = currentOpacity;
1170
+ const endOpacity = target ? 1 : 0;
1171
+ if ((!start && !target) || duration <= 0 || prefersReducedMotion()) {
1172
+ render(target, endOpacity);
1173
+ return Promise.resolve({ state, interrupted: false });
1174
+ }
1175
+ const from = start ?? scaleAroundCenter(target, EMPTY_SCALE);
1176
+ const to = target ?? scaleAroundCenter(start, EMPTY_SCALE);
1177
+ const spin = (settled && iconSet.spins[previousState]?.[nextState]) || 0;
1178
+ const ease = typeof easing === 'function' ? easing : easings[easing];
1179
+ const startTime = performance.now();
1180
+ return new Promise((resolve) => {
1181
+ const stopAnimation = animate((frameTime) => {
1182
+ const progress = clampToUnit((frameTime - startTime) / duration);
1183
+ if (progress < 1) {
1184
+ const easedProgress = ease(progress);
1185
+ render(blend(from, to, easedProgress, spin), startOpacity + (endOpacity - startOpacity) * easedProgress);
1186
+ return true;
1187
+ }
1188
+ render(target, endOpacity);
1189
+ finish(false);
1190
+ return false;
1191
+ });
1192
+ running = { stopAnimation, resolvers: [resolve] };
1193
+ });
1194
+ },
1195
+ stop: () => finish(true),
1196
+ };
1197
+ };
1198
+ return { EMPTY_STATE, hasState, createMorphingPath };
1199
+ })();
1200
+
1201
+ // svg.ts
1202
+ const { createSvgElement, createIconSvg } = (() => {
1203
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
1204
+ const createSvgElement = (tagName, attributes = {}) => {
1205
+ const element = document.createElementNS(SVG_NAMESPACE, tagName);
1206
+ for (const [name, value] of Object.entries(attributes))
1207
+ element.setAttribute(name, String(value));
1208
+ return element;
1209
+ };
1210
+ /** An <svg> drawn in the set's stroke style, coloured by the CSS `color` (currentColor). */
1211
+ const createIconSvg = (iconSet, viewBox) => createSvgElement('svg', {
1212
+ viewBox,
1213
+ fill: 'none',
1214
+ stroke: 'currentColor',
1215
+ 'stroke-width': iconSet.strokeWidth,
1216
+ 'stroke-linecap': iconSet.lineCap,
1217
+ 'stroke-linejoin': iconSet.lineJoin,
1218
+ });
1219
+ return { createSvgElement, createIconSvg };
1220
+ })();
1221
+
1222
+ // icon.ts
1223
+ const { icon } = (() => {
1224
+ // Layer 1: one icon that morphs between the states of a set, e.g. a single digit.
1225
+ const checkedState = (iconSet, state) => {
1226
+ const normalized = state === '' ? EMPTY_STATE : state;
1227
+ if (!hasState(iconSet, normalized))
1228
+ throw new Error(`morphico: the icon set has no state "${state}"`);
1229
+ return normalized;
1230
+ };
1231
+ /** Adds a morphing icon (an <svg>) to `element`. */
1232
+ const icon = (element, options = {}) => {
1233
+ const { set, state = EMPTY_STATE, size = '1em', ...defaultMorphOptions } = options;
1234
+ const iconSet = resolveIconSet(set);
1235
+ const svg = createIconSvg(iconSet, '0 0 24 24');
1236
+ svg.setAttribute('role', 'img');
1237
+ svg.style.width = size;
1238
+ svg.style.height = size;
1239
+ const pathElement = svg.appendChild(createSvgElement('path'));
1240
+ const morphingPath = createMorphingPath(pathElement, iconSet, checkedState(iconSet, state));
1241
+ const updateLabel = () => svg.setAttribute('aria-label', morphingPath.state.trim());
1242
+ updateLabel();
1243
+ element.appendChild(svg);
1244
+ return {
1245
+ get state() { return morphingPath.state; },
1246
+ to(nextState, { onEnd, ...morphOptions } = {}) {
1247
+ const morph = morphingPath.morphTo(checkedState(iconSet, nextState), { ...defaultMorphOptions, ...morphOptions });
1248
+ updateLabel();
1249
+ if (onEnd)
1250
+ morph.then(onEnd);
1251
+ return morph;
1252
+ },
1253
+ destroy() {
1254
+ morphingPath.stop();
1255
+ svg.remove();
1256
+ },
1257
+ };
1258
+ };
1259
+ return { icon };
1260
+ })();
1261
+
1262
+ // text.ts
1263
+ const { text } = (() => {
1264
+ // Layer 2: a line of characters (clock, counter, date) in narrow cells; changed digits morph.
1265
+ // Every character reuses the 24×24 icon drawing; only the viewBox is narrowed to its cell.
1266
+ /** Characters `text()` can show: digits, `-` and space morph; `:` `.` `,` `/` stay still. Anything else is removed. */
1267
+ const UNSUPPORTED_CHARACTERS = /[^0-9 :.,\/-]/g;
1268
+ /** Vertical part of the 24×24 grid shown in a cell: y 3…21 holds every digit including its stroke. */
1269
+ const CELL_TOP = 3;
1270
+ const CELL_HEIGHT = 18;
1271
+ /** Dot radius of `:` `.` `,` relative to the stroke width. */
1272
+ const DOT_SIZE = 0.6;
1273
+ const formatNumber = (value) => String(+value.toFixed(4));
1274
+ /** Draws `:` `.` `,` or `/` into the cell, matching the set's stroke width and digit positions. */
1275
+ const drawStaticCharacter = (svg, character, iconSet) => {
1276
+ const { baselineY, colonDotsY } = iconSet.textLayout;
1277
+ const dotRadius = iconSet.strokeWidth * DOT_SIZE;
1278
+ const baseDotY = baselineY + iconSet.strokeWidth / 2 - dotRadius; // dot bottom = digit bottom
1279
+ const dot = (y) => svg.appendChild(createSvgElement('circle', {
1280
+ cx: 12, cy: formatNumber(y), r: formatNumber(dotRadius), fill: 'currentColor', stroke: 'none',
1281
+ }));
1282
+ const line = (fromX, fromY, toX, toY) => svg.appendChild(createSvgElement('path', {
1283
+ d: `M${formatNumber(fromX)} ${formatNumber(fromY)}L${formatNumber(toX)} ${formatNumber(toY)}`,
1284
+ }));
1285
+ if (character === ':')
1286
+ colonDotsY.forEach(dot);
1287
+ if (character === '.' || character === ',')
1288
+ dot(baseDotY);
1289
+ if (character === ',')
1290
+ line(12 + dotRadius * 0.4, baseDotY, 11, baselineY + 2.2);
1291
+ if (character === '/')
1292
+ line(9.5, baselineY, 14.5, 24 - baselineY);
1293
+ };
1294
+ /** Adds a line of morphing characters (a <span> of <svg> cells) to `element`. */
1295
+ const text = (element, options = {}) => {
1296
+ const { set, value: initialValue = '', size = '1em', cellWidth, spacing, ...defaultMorphOptions } = options;
1297
+ const iconSet = resolveIconSet(set);
1298
+ const digitCellWidth = cellWidth ?? iconSet.textLayout.cellWidth;
1299
+ const cellSpacing = spacing ?? iconSet.textLayout.spacing;
1300
+ const inkBottom = iconSet.textLayout.baselineY + iconSet.strokeWidth / 2;
1301
+ const line = document.createElement('span');
1302
+ line.setAttribute('role', 'img');
1303
+ line.style.whiteSpace = 'nowrap';
1304
+ element.appendChild(line);
1305
+ let cells = [];
1306
+ let value = '';
1307
+ const isMorphing = (character) => hasState(iconSet, character);
1308
+ const widthOf = (character) => {
1309
+ if (isMorphing(character))
1310
+ return digitCellWidth;
1311
+ return character === '/' ? digitCellWidth * 0.7 : digitCellWidth / 2;
1312
+ };
1313
+ /** CSS length: `units` grid units at the line's size. */
1314
+ const scaled = (units) => `calc(${size} * ${formatNumber(units / CELL_HEIGHT)})`;
1315
+ const createCell = (character, index) => {
1316
+ const width = widthOf(character);
1317
+ const svg = createIconSvg(iconSet, `${formatNumber(12 - width / 2)} ${CELL_TOP} ${formatNumber(width)} ${CELL_HEIGHT}`);
1318
+ svg.setAttribute('aria-hidden', 'true');
1319
+ Object.assign(svg.style, {
1320
+ width: scaled(width),
1321
+ height: size,
1322
+ overflow: 'visible',
1323
+ verticalAlign: scaled(inkBottom - CELL_TOP - CELL_HEIGHT), // digit bottom on the text baseline
1324
+ marginLeft: index ? `var(--morphico-spacing, ${scaled(cellSpacing)})` : '0',
1325
+ });
1326
+ let morphingPath = null;
1327
+ if (isMorphing(character))
1328
+ morphingPath = createMorphingPath(svg.appendChild(createSvgElement('path')), iconSet, character);
1329
+ else
1330
+ drawStaticCharacter(svg, character, iconSet);
1331
+ return { svg, morphingPath, character };
1332
+ };
1333
+ const rebuild = (characters) => {
1334
+ for (const cell of cells)
1335
+ cell.morphingPath?.stop();
1336
+ cells = characters.map(createCell);
1337
+ line.replaceChildren(...cells.map((cell) => cell.svg));
1338
+ };
1339
+ const show = (nextValue, { onEnd, ...morphOptions } = {}) => {
1340
+ value = nextValue.replace(UNSUPPORTED_CHARACTERS, '');
1341
+ line.setAttribute('aria-label', value.trim());
1342
+ const characters = [...value];
1343
+ const sameLayout = characters.length === cells.length && characters.every((character, index) => (cells[index].morphingPath ? isMorphing(character) : character === cells[index].character));
1344
+ let change;
1345
+ if (sameLayout) {
1346
+ const morphs = cells.map((cell, index) => cell.morphingPath?.morphTo(characters[index], { ...defaultMorphOptions, ...morphOptions }));
1347
+ const finishedValue = value;
1348
+ change = Promise.all(morphs).then((results) => ({ value: finishedValue, interrupted: results.some((result) => result?.interrupted) }));
1349
+ }
1350
+ else {
1351
+ rebuild(characters);
1352
+ change = Promise.resolve({ value, interrupted: false });
1353
+ }
1354
+ if (onEnd)
1355
+ change.then(onEnd);
1356
+ return change;
1357
+ };
1358
+ show(initialValue);
1359
+ return {
1360
+ get value() { return value; },
1361
+ set: show,
1362
+ destroy() {
1363
+ for (const cell of cells)
1364
+ cell.morphingPath?.stop();
1365
+ line.remove();
1366
+ },
1367
+ };
1368
+ };
1369
+ return { text };
1370
+ })();
1371
+
4
1372
  // index.ts
5
- // morphico public API. The icon() / text() layers come later; for now only the version is exported.
6
- /** Package version, e.g. "0.0.1" (tools/build.ts replaces 'dev'). */
7
- const version = '0.0.1';
1373
+ // morphico public API.
1374
+ /** Package version, e.g. "0.1.0" (tools/build.ts replaces 'dev'). */
1375
+ const version = '0.1.0';
8
1376
 
9
- return { version };
1377
+ return { easings, icon, iconSets, text, version };
10
1378
  })();