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