morphico 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -41
- package/dist/esm/all.js +50 -0
- package/dist/esm/clip.js +283 -0
- package/dist/esm/engine.js +25 -0
- package/dist/esm/face.js +153 -0
- package/dist/esm/geometry.js +459 -0
- package/dist/esm/icon.js +41 -0
- package/dist/esm/index.js +50 -0
- package/dist/esm/morph.js +107 -0
- package/dist/esm/roundness.js +37 -0
- package/dist/esm/sets/battery.js +87 -0
- package/dist/esm/sets/bell.js +101 -0
- package/dist/esm/sets/characters.js +43 -0
- package/dist/esm/sets/check-mark.js +74 -0
- package/dist/esm/sets/checkbox.js +91 -0
- package/dist/esm/sets/chevron.js +70 -0
- package/dist/esm/sets/condensed.js +137 -0
- package/dist/esm/sets/copy.js +87 -0
- package/dist/esm/sets/eye.js +105 -0
- package/dist/esm/sets/faces.js +482 -0
- package/dist/esm/sets/folder.js +112 -0
- package/dist/esm/sets/handwritten.js +136 -0
- package/dist/esm/sets/lock.js +75 -0
- package/dist/esm/sets/lucide.js +66 -0
- package/dist/esm/sets/media.js +109 -0
- package/dist/esm/sets/menu.js +84 -0
- package/dist/esm/sets/mic.js +102 -0
- package/dist/esm/sets/radio.js +84 -0
- package/dist/esm/sets/round.js +244 -0
- package/dist/esm/sets/shapes.js +455 -0
- package/dist/esm/sets/slim.js +148 -0
- package/dist/esm/sets/square.js +149 -0
- package/dist/esm/sets/theme.js +107 -0
- package/dist/esm/sets/toggle.js +73 -0
- package/dist/esm/sets/volume.js +89 -0
- package/dist/esm/sets/weather.js +691 -0
- package/dist/esm/sets/wifi.js +68 -0
- package/dist/esm/svg.js +31 -0
- package/dist/esm/text.js +88 -0
- package/dist/esm/types.js +3 -0
- package/dist/morphico.js +4033 -3380
- package/dist/types/{iconSets.d.ts → all.d.ts} +0 -2
- package/dist/types/clip.d.ts +6 -11
- package/dist/types/face.d.ts +27 -0
- package/dist/types/icon.d.ts +4 -9
- package/dist/types/index.d.ts +5 -3
- package/dist/types/roundness.d.ts +8 -0
- package/dist/types/sets/battery.d.ts +1 -1
- package/dist/types/sets/bell.d.ts +1 -1
- package/dist/types/sets/checkbox.d.ts +1 -1
- package/dist/types/sets/chevron.d.ts +1 -1
- package/dist/types/sets/condensed.d.ts +1 -1
- package/dist/types/sets/copy.d.ts +1 -1
- package/dist/types/sets/eye.d.ts +1 -1
- package/dist/types/sets/faces.d.ts +25 -0
- package/dist/types/sets/folder.d.ts +1 -1
- package/dist/types/sets/handwritten.d.ts +1 -1
- package/dist/types/sets/lock.d.ts +1 -1
- package/dist/types/sets/media.d.ts +1 -1
- package/dist/types/sets/menu.d.ts +1 -1
- package/dist/types/sets/mic.d.ts +1 -1
- package/dist/types/sets/radio.d.ts +1 -1
- package/dist/types/sets/round.d.ts +1 -1
- package/dist/types/sets/slim.d.ts +1 -1
- package/dist/types/sets/square.d.ts +1 -1
- package/dist/types/sets/theme.d.ts +1 -1
- package/dist/types/sets/toggle.d.ts +1 -1
- package/dist/types/sets/volume.d.ts +1 -1
- package/dist/types/sets/weather.d.ts +1 -1
- package/dist/types/sets/wifi.d.ts +1 -1
- package/dist/types/text.d.ts +8 -13
- package/package.json +95 -3
- package/dist/morphico.mjs +0 -4003
package/dist/esm/face.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
|
|
2
|
+
// Layer 4: a face that morphs between feelings. 7 soft filled pieces (sets/faces.ts) — brows, eyes and mouth in the
|
|
3
|
+
// CSS color, the pupils in their own color and clipped to the eyes. Blinking runs on top of any expression, by itself
|
|
4
|
+
// every few seconds (option) or on blink().
|
|
5
|
+
import { animate, easings } from './engine.js';
|
|
6
|
+
import { DEFAULT_DURATION, prefersReducedMotion } from './morph.js';
|
|
7
|
+
import { blinked, faceExpressionNames, faceExpressionPieces, facePaths, interpolateFace } from './sets/faces.js';
|
|
8
|
+
import { checkedInit } from './roundness.js';
|
|
9
|
+
import { createSvgElement } from './svg.js';
|
|
10
|
+
const BLINK_CLOSING = 90;
|
|
11
|
+
const BLINK_SHUT = 50;
|
|
12
|
+
const BLINK_OPENING = 150;
|
|
13
|
+
const AUTO_BLINK_MIN_PAUSE = 2500;
|
|
14
|
+
const AUTO_BLINK_EXTRA_PAUSE = 3500;
|
|
15
|
+
const DEFAULT_FACE_DURATION = DEFAULT_DURATION + 100;
|
|
16
|
+
/** Expressions whose eyes are already shut: blinking does nothing. */
|
|
17
|
+
const EYES_SHUT = ['asleep', 'laughing'];
|
|
18
|
+
let faceCount = 0;
|
|
19
|
+
const checkedExpression = (state) => {
|
|
20
|
+
if (!faceExpressionNames.includes(state))
|
|
21
|
+
throw new Error(`morphico: face() has no expression "${state}"`);
|
|
22
|
+
return state;
|
|
23
|
+
};
|
|
24
|
+
const resolveEasing = (easing) => (typeof easing === 'function' ? easing : easings[easing ?? 'easeInOutCubic']);
|
|
25
|
+
/** Adds a morphing face (an <svg>) to `element`, showing the expression `init` first. */
|
|
26
|
+
export const face = (element, init, options = {}) => {
|
|
27
|
+
const { pupilColor = 'white', blink: blinksByItself = true, size = '100%', ...defaultMorphOptions } = options;
|
|
28
|
+
const expressions = faceExpressionPieces();
|
|
29
|
+
let state = checkedExpression(checkedInit(init, 'face'));
|
|
30
|
+
let shownPieces = expressions[state];
|
|
31
|
+
let openness = 1;
|
|
32
|
+
const clipId = `morphico-face-${++faceCount}`;
|
|
33
|
+
const svg = createSvgElement('svg', { viewBox: '0 0 24 24', fill: 'currentColor', role: 'img' });
|
|
34
|
+
svg.style.width = size;
|
|
35
|
+
svg.style.height = size;
|
|
36
|
+
svg.style.overflow = 'visible';
|
|
37
|
+
const clipPath = svg.appendChild(createSvgElement('clipPath', { id: clipId }));
|
|
38
|
+
const eyesPath = clipPath.appendChild(createSvgElement('path'));
|
|
39
|
+
const featuresPath = svg.appendChild(createSvgElement('path'));
|
|
40
|
+
const pupilsPath = svg.appendChild(createSvgElement('path', { fill: pupilColor, 'clip-path': `url(#${clipId})` }));
|
|
41
|
+
const render = () => {
|
|
42
|
+
const paths = facePaths(blinked(shownPieces, openness));
|
|
43
|
+
featuresPath.setAttribute('d', paths.features);
|
|
44
|
+
eyesPath.setAttribute('d', paths.eyes);
|
|
45
|
+
pupilsPath.setAttribute('d', paths.pupils);
|
|
46
|
+
};
|
|
47
|
+
const updateLabel = () => svg.setAttribute('aria-label', state);
|
|
48
|
+
render();
|
|
49
|
+
updateLabel();
|
|
50
|
+
element.appendChild(svg);
|
|
51
|
+
// ---- morphing ----
|
|
52
|
+
let morph = null;
|
|
53
|
+
const endMorph = (interrupted) => {
|
|
54
|
+
if (!morph)
|
|
55
|
+
return;
|
|
56
|
+
const { stopAnimation, resolve, state: morphState } = morph;
|
|
57
|
+
morph = null;
|
|
58
|
+
stopAnimation();
|
|
59
|
+
resolve({ state: morphState, interrupted });
|
|
60
|
+
};
|
|
61
|
+
const morphTo = (nextState, morphOptions) => {
|
|
62
|
+
endMorph(true);
|
|
63
|
+
state = nextState;
|
|
64
|
+
updateLabel();
|
|
65
|
+
const fromPieces = shownPieces;
|
|
66
|
+
const toPieces = expressions[nextState];
|
|
67
|
+
const duration = morphOptions.duration ?? DEFAULT_FACE_DURATION;
|
|
68
|
+
if (prefersReducedMotion() || duration <= 0) {
|
|
69
|
+
shownPieces = toPieces;
|
|
70
|
+
render();
|
|
71
|
+
return Promise.resolve({ state: nextState, interrupted: false });
|
|
72
|
+
}
|
|
73
|
+
const easing = resolveEasing(morphOptions.easing);
|
|
74
|
+
let startTime = null;
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const stopAnimation = animate((frameTime) => {
|
|
77
|
+
startTime ??= frameTime;
|
|
78
|
+
const progress = Math.min((frameTime - startTime) / duration, 1);
|
|
79
|
+
shownPieces = interpolateFace(fromPieces, toPieces, easing(progress));
|
|
80
|
+
render();
|
|
81
|
+
if (progress < 1)
|
|
82
|
+
return true;
|
|
83
|
+
endMorph(false);
|
|
84
|
+
return false;
|
|
85
|
+
});
|
|
86
|
+
morph = { stopAnimation, resolve, state: nextState };
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
// ---- blinking ----
|
|
90
|
+
let stopBlink = null;
|
|
91
|
+
let finishBlink = null;
|
|
92
|
+
const endBlink = () => {
|
|
93
|
+
stopBlink?.();
|
|
94
|
+
stopBlink = null;
|
|
95
|
+
if (openness < 1) {
|
|
96
|
+
openness = 1;
|
|
97
|
+
render();
|
|
98
|
+
}
|
|
99
|
+
finishBlink?.();
|
|
100
|
+
finishBlink = null;
|
|
101
|
+
};
|
|
102
|
+
const blink = () => {
|
|
103
|
+
if (EYES_SHUT.includes(state) || prefersReducedMotion())
|
|
104
|
+
return Promise.resolve();
|
|
105
|
+
endBlink();
|
|
106
|
+
let startTime = null;
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
finishBlink = resolve;
|
|
109
|
+
stopBlink = animate((frameTime) => {
|
|
110
|
+
startTime ??= frameTime;
|
|
111
|
+
const elapsed = frameTime - startTime;
|
|
112
|
+
if (elapsed < BLINK_CLOSING)
|
|
113
|
+
openness = 1 - elapsed / BLINK_CLOSING;
|
|
114
|
+
else if (elapsed < BLINK_CLOSING + BLINK_SHUT)
|
|
115
|
+
openness = 0;
|
|
116
|
+
else
|
|
117
|
+
openness = Math.min((elapsed - BLINK_CLOSING - BLINK_SHUT) / BLINK_OPENING, 1);
|
|
118
|
+
render();
|
|
119
|
+
if (openness < 1 || elapsed < BLINK_CLOSING)
|
|
120
|
+
return true;
|
|
121
|
+
stopBlink = null;
|
|
122
|
+
finishBlink = null;
|
|
123
|
+
resolve();
|
|
124
|
+
return false;
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
let autoBlinkTimer;
|
|
129
|
+
const scheduleAutoBlink = () => {
|
|
130
|
+
autoBlinkTimer = setTimeout(() => {
|
|
131
|
+
if (typeof document === 'undefined' || !document.hidden)
|
|
132
|
+
void blink();
|
|
133
|
+
scheduleAutoBlink();
|
|
134
|
+
}, AUTO_BLINK_MIN_PAUSE + Math.random() * AUTO_BLINK_EXTRA_PAUSE);
|
|
135
|
+
};
|
|
136
|
+
if (blinksByItself && !prefersReducedMotion())
|
|
137
|
+
scheduleAutoBlink();
|
|
138
|
+
return {
|
|
139
|
+
to(nextState, { onEnd, ...morphOptions } = {}) {
|
|
140
|
+
const result = morphTo(checkedExpression(nextState), { ...defaultMorphOptions, ...morphOptions });
|
|
141
|
+
if (onEnd)
|
|
142
|
+
result.then(onEnd);
|
|
143
|
+
return result;
|
|
144
|
+
},
|
|
145
|
+
blink,
|
|
146
|
+
destroy() {
|
|
147
|
+
clearTimeout(autoBlinkTimer);
|
|
148
|
+
endMorph(true);
|
|
149
|
+
endBlink();
|
|
150
|
+
svg.remove();
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
};
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
|
|
2
|
+
const DEGREE = Math.PI / 180;
|
|
3
|
+
const ICON_CENTER = 12;
|
|
4
|
+
/** Largest arc one cubic segment may cover: up to 90° the cubic is visually exact. */
|
|
5
|
+
const MAX_ARC_PER_SEGMENT = 90;
|
|
6
|
+
const EPSILON = 1e-9;
|
|
7
|
+
// ---- strokes ----
|
|
8
|
+
export const line = (fromX, fromY, toX, toY) => ({ kind: 'line', from: [fromX, fromY], to: [toX, toY] });
|
|
9
|
+
/** Angles in degrees. Because y points down, `endAngle > startAngle` draws clockwise on screen. */
|
|
10
|
+
export const arc = (centerX, centerY, radius, startAngle, endAngle) => ({ kind: 'arc', center: [centerX, centerY], radius, startAngle, endAngle });
|
|
11
|
+
export const cubic = (startX, startY, control1X, control1Y, control2X, control2Y, endX, endY) => ({ kind: 'cubic', segment: [[startX, startY], [control1X, control1Y], [control2X, control2Y], [endX, endY]] });
|
|
12
|
+
/** The same line or arc, drawn in the opposite direction. */
|
|
13
|
+
export const reversedStroke = (stroke) => {
|
|
14
|
+
if (stroke.kind === 'line')
|
|
15
|
+
return { ...stroke, from: stroke.to, to: stroke.from };
|
|
16
|
+
if (stroke.kind === 'arc')
|
|
17
|
+
return { ...stroke, startAngle: stroke.endAngle, endAngle: stroke.startAngle };
|
|
18
|
+
const [start, control1, control2, end] = stroke.segment;
|
|
19
|
+
return { kind: 'cubic', segment: [end, control2, control1, start] };
|
|
20
|
+
};
|
|
21
|
+
export const pointOnCircle = (centerX, centerY, radius, angle) => [centerX + radius * Math.cos(angle * DEGREE), centerY + radius * Math.sin(angle * DEGREE)];
|
|
22
|
+
/** Control handle length of a quarter ellipse as a fraction of its radius (4/3 · (√2 − 1)). */
|
|
23
|
+
const QUARTER_HANDLE = (4 / 3) * (Math.SQRT2 - 1);
|
|
24
|
+
/** A full ellipse as four cubic quarters, drawn counter-clockwise on screen, starting at the top. */
|
|
25
|
+
export const ellipse = (centerX, centerY, radiusX, radiusY) => {
|
|
26
|
+
const handleX = QUARTER_HANDLE * radiusX, handleY = QUARTER_HANDLE * radiusY;
|
|
27
|
+
const top = centerY - radiusY, bottom = centerY + radiusY, left = centerX - radiusX, right = centerX + radiusX;
|
|
28
|
+
return [
|
|
29
|
+
cubic(centerX, top, centerX - handleX, top, left, centerY - handleY, left, centerY),
|
|
30
|
+
cubic(left, centerY, left, centerY + handleY, centerX - handleX, bottom, centerX, bottom),
|
|
31
|
+
cubic(centerX, bottom, centerX + handleX, bottom, right, centerY + handleY, right, centerY),
|
|
32
|
+
cubic(right, centerY, right, centerY - handleY, centerX + handleX, top, centerX, top),
|
|
33
|
+
];
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A polyline whose corners are rounded: straight lines joined by tangent circle arcs.
|
|
37
|
+
* `radii[index]` rounds the corner at `points[index]` (a single number rounds every corner; 0 = sharp corner).
|
|
38
|
+
* Straight-through points and reversals (going back along the same line) always stay sharp.
|
|
39
|
+
*/
|
|
40
|
+
export const roundedPolyline = (points, radii) => {
|
|
41
|
+
const strokes = [];
|
|
42
|
+
const addLine = (from, to) => {
|
|
43
|
+
if (Math.hypot(to[0] - from[0], to[1] - from[1]) > EPSILON)
|
|
44
|
+
strokes.push(line(...from, ...to));
|
|
45
|
+
};
|
|
46
|
+
let current = points[0];
|
|
47
|
+
for (let index = 1; index < points.length; index++) {
|
|
48
|
+
const corner = points[index];
|
|
49
|
+
const next = points[index + 1];
|
|
50
|
+
const radius = Array.isArray(radii) ? (radii[index] ?? 0) : radii;
|
|
51
|
+
if (!next || radius <= 0) {
|
|
52
|
+
addLine(current, corner);
|
|
53
|
+
current = corner;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const incoming = unitVector(current, corner), outgoing = unitVector(corner, next);
|
|
57
|
+
const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0];
|
|
58
|
+
const turn = Math.atan2(cross, incoming[0] * outgoing[0] + incoming[1] * outgoing[1]); // radians, > 0 = clockwise
|
|
59
|
+
if (Math.abs(turn) < 1e-6 || Math.abs(turn) > Math.PI - 1e-6) {
|
|
60
|
+
addLine(current, corner);
|
|
61
|
+
current = corner;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const tangentDistance = radius * Math.tan(Math.abs(turn) / 2);
|
|
65
|
+
const arcStart = [corner[0] - incoming[0] * tangentDistance, corner[1] - incoming[1] * tangentDistance];
|
|
66
|
+
// the centre lies on the inside of the turn
|
|
67
|
+
const inward = turn > 0 ? [-incoming[1], incoming[0]] : [incoming[1], -incoming[0]];
|
|
68
|
+
const center = [arcStart[0] + inward[0] * radius, arcStart[1] + inward[1] * radius];
|
|
69
|
+
const startAngle = Math.atan2(arcStart[1] - center[1], arcStart[0] - center[0]) / DEGREE;
|
|
70
|
+
addLine(current, arcStart);
|
|
71
|
+
strokes.push(arc(center[0], center[1], radius, startAngle, startAngle + turn / DEGREE));
|
|
72
|
+
current = [corner[0] + outgoing[0] * tangentDistance, corner[1] + outgoing[1] * tangentDistance];
|
|
73
|
+
}
|
|
74
|
+
return strokes;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* A closed polygon with every corner rounded by `radius` (clamped per corner so neighbouring arcs never overlap).
|
|
78
|
+
* Always a line and an arc per corner, whatever the radius, so pair layouts (stroke indices) stay valid; at radius 0
|
|
79
|
+
* the arc shrinks to the corner point, so the corner is really sharp (and `stroke-linejoin` decides how it looks).
|
|
80
|
+
* Starts after the first corner with a line (`startsWithArc`: with the arc of the first corner), clockwise if the
|
|
81
|
+
* corners are listed clockwise on screen.
|
|
82
|
+
*/
|
|
83
|
+
export const roundedPolygon = (corners, radius, startsWithArc = false) => {
|
|
84
|
+
const cornerCount = corners.length;
|
|
85
|
+
const roundedCorners = corners.map((corner, index) => {
|
|
86
|
+
const previous = corners[(index + cornerCount - 1) % cornerCount], next = corners[(index + 1) % cornerCount];
|
|
87
|
+
const incoming = unitVector(previous, corner), outgoing = unitVector(corner, next);
|
|
88
|
+
const turn = Math.atan2(incoming[0] * outgoing[1] - incoming[1] * outgoing[0], incoming[0] * outgoing[0] + incoming[1] * outgoing[1]);
|
|
89
|
+
const halfTurnTangent = Math.tan(Math.abs(turn) / 2);
|
|
90
|
+
const shortestHalfEdge = Math.min(distanceBetween(previous, corner), distanceBetween(corner, next)) / 2;
|
|
91
|
+
const cornerRadius = Math.max(0, Math.min(radius, shortestHalfEdge / halfTurnTangent));
|
|
92
|
+
const tangentDistance = cornerRadius * halfTurnTangent;
|
|
93
|
+
const arcStart = [corner[0] - incoming[0] * tangentDistance, corner[1] - incoming[1] * tangentDistance];
|
|
94
|
+
const arcEnd = [corner[0] + outgoing[0] * tangentDistance, corner[1] + outgoing[1] * tangentDistance];
|
|
95
|
+
const inward = turn > 0 ? [-incoming[1], incoming[0]] : [incoming[1], -incoming[0]];
|
|
96
|
+
const center = [arcStart[0] + inward[0] * cornerRadius, arcStart[1] + inward[1] * cornerRadius];
|
|
97
|
+
const startAngle = Math.atan2(arcStart[1] - center[1], arcStart[0] - center[0]) / DEGREE;
|
|
98
|
+
return { arcStart, arcEnd, arc: arc(center[0], center[1], cornerRadius, startAngle, startAngle + turn / DEGREE) };
|
|
99
|
+
});
|
|
100
|
+
const strokes = roundedCorners.flatMap((roundedCorner, index) => {
|
|
101
|
+
const next = roundedCorners[(index + 1) % cornerCount];
|
|
102
|
+
return [line(...roundedCorner.arcEnd, ...next.arcStart), next.arc];
|
|
103
|
+
});
|
|
104
|
+
// the list above starts after corner 0 with a line and ends with corner 0's arc
|
|
105
|
+
return startsWithArc ? [strokes[strokes.length - 1], ...strokes.slice(0, -1)] : strokes;
|
|
106
|
+
};
|
|
107
|
+
const distanceBetween = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
108
|
+
const unitVector = (from, to) => {
|
|
109
|
+
const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
110
|
+
return [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
|
|
111
|
+
};
|
|
112
|
+
const arcSweep = (stroke) => Math.abs(stroke.endAngle - stroke.startAngle);
|
|
113
|
+
// ---- path numbers ⇄ segments ----
|
|
114
|
+
export const segmentCountOf = (path) => (path.length - 2) / 6;
|
|
115
|
+
const pointAt = (path, index) => [path[index], path[index + 1]];
|
|
116
|
+
export const toSegments = (path) => Array.from({ length: segmentCountOf(path) }, (_, segmentIndex) => {
|
|
117
|
+
const offset = segmentIndex * 6;
|
|
118
|
+
return [pointAt(path, offset), pointAt(path, offset + 2), pointAt(path, offset + 4), pointAt(path, offset + 6)];
|
|
119
|
+
});
|
|
120
|
+
export const fromSegments = (segments) => [...segments[0][0], ...segments.flatMap(([, control1, control2, end]) => [...control1, ...control2, ...end])];
|
|
121
|
+
const rotateSegmentOrder = (segments, startSegment) => [...segments.slice(startSegment), ...segments.slice(0, startSegment)];
|
|
122
|
+
const reverseSegments = (segments) => segments.slice().reverse().map(([start, control1, control2, end]) => [end, control2, control1, start]);
|
|
123
|
+
/** Same picture, different segment order: start a closed loop at another segment and/or draw it backwards. */
|
|
124
|
+
export const reorderSegments = (path, { startSegment = 0, reversed = false } = {}) => {
|
|
125
|
+
const rotated = rotateSegmentOrder(toSegments(path), startSegment);
|
|
126
|
+
return fromSegments(reversed ? reverseSegments(rotated) : rotated);
|
|
127
|
+
};
|
|
128
|
+
export const interpolate = (from, to, progress) => from.map((value, index) => value + (to[index] - value) * progress);
|
|
129
|
+
const formatNumber = (value) => String(+value.toFixed(3));
|
|
130
|
+
/** A piece whose end lands on its start is closed (`Z`): its first corner then gets the line join, not two line ends. */
|
|
131
|
+
const isClosedPiece = (piece) => Math.hypot(piece[piece.length - 2] - piece[0], piece[piece.length - 1] - piece[1]) < 1e-6;
|
|
132
|
+
const hasLength = ([start, control1, control2, end]) => [control1, control2, end].some((point) => Math.hypot(point[0] - start[0], point[1] - start[1]) > 1e-6);
|
|
133
|
+
/**
|
|
134
|
+
* The segments of a piece as written to SVG. A closed piece starts at its first segment with length: a zero-length
|
|
135
|
+
* first segment (a corner of radius 0) has no direction, so browsers would draw the closing corner wrongly.
|
|
136
|
+
* Only the written order changes, not the picture.
|
|
137
|
+
*/
|
|
138
|
+
const writtenSegments = (piece) => {
|
|
139
|
+
const segments = toSegments(piece);
|
|
140
|
+
if (!isClosedPiece(piece))
|
|
141
|
+
return segments;
|
|
142
|
+
const firstWithLength = Math.max(0, segments.findIndex(hasLength));
|
|
143
|
+
return [...segments.slice(firstWithLength), ...segments.slice(0, firstWithLength)];
|
|
144
|
+
};
|
|
145
|
+
/** A piece shrunk to a single point: not drawn at all (round line ends would show it as a dot). */
|
|
146
|
+
const isPoint = (piece) => piece.every((value, index) => Math.abs(value - piece[index % 2]) < 1e-6);
|
|
147
|
+
/**
|
|
148
|
+
* SVG `d` attribute: per piece `M x y` followed by one `C` per segment (`pieceLength`: see pieceLengthOf).
|
|
149
|
+
* Pieces shrunk to a single point are left out.
|
|
150
|
+
*/
|
|
151
|
+
export const toPathData = (path, pieceLength = path.length) => splitPieces(path, pieceLength).filter((piece) => !isPoint(piece)).map((piece) => {
|
|
152
|
+
const segments = writtenSegments(piece);
|
|
153
|
+
const [startX, startY] = segments[0][0];
|
|
154
|
+
return `M${formatNumber(startX)} ${formatNumber(startY)}` + segments
|
|
155
|
+
.map(([, control1, control2, end]) => `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`)
|
|
156
|
+
.join('') + (isClosedPiece(piece) ? 'Z' : '');
|
|
157
|
+
}).join('');
|
|
158
|
+
/** Shrinks (factor < 1) or grows the whole shape around the icon center (12, 12). */
|
|
159
|
+
export const scaleAroundCenter = (path, factor) => path.map((value) => ICON_CENTER + (value - ICON_CENTER) * factor);
|
|
160
|
+
/** Rotates the whole shape around the icon center (12, 12). */
|
|
161
|
+
export const rotateAroundCenter = (path, angle) => {
|
|
162
|
+
const cos = Math.cos(angle * DEGREE), sin = Math.sin(angle * DEGREE);
|
|
163
|
+
return path.map((value, index) => {
|
|
164
|
+
if (index % 2 === 0) {
|
|
165
|
+
const y = path[index + 1];
|
|
166
|
+
return ICON_CENTER + (value - ICON_CENTER) * cos - (y - ICON_CENTER) * sin;
|
|
167
|
+
}
|
|
168
|
+
const x = path[index - 1];
|
|
169
|
+
return ICON_CENTER + (x - ICON_CENTER) * sin + (value - ICON_CENTER) * cos;
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
// ---- pieces of strokes ----
|
|
173
|
+
const interpolatePoint = (from, to, progress) => [from[0] + (to[0] - from[0]) * progress, from[1] + (to[1] - from[1]) * progress];
|
|
174
|
+
/** Splits a cubic into two cubics at `progress` (de Casteljau). */
|
|
175
|
+
const splitCubic = ([start, control1, control2, end], progress) => {
|
|
176
|
+
const startToControl1 = interpolatePoint(start, control1, progress);
|
|
177
|
+
const control1ToControl2 = interpolatePoint(control1, control2, progress);
|
|
178
|
+
const control2ToEnd = interpolatePoint(control2, end, progress);
|
|
179
|
+
const leftInner = interpolatePoint(startToControl1, control1ToControl2, progress);
|
|
180
|
+
const rightInner = interpolatePoint(control1ToControl2, control2ToEnd, progress);
|
|
181
|
+
const splitPoint = interpolatePoint(leftInner, rightInner, progress);
|
|
182
|
+
return [[start, startToControl1, leftInner, splitPoint], [splitPoint, rightInner, control2ToEnd, end]];
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* The part of a stroke between two progress values (0…1) as one cubic segment.
|
|
186
|
+
* A piece of a line/arc/cubic is exactly a line/arc/cubic, so segment ends can slide along a stroke
|
|
187
|
+
* without changing how it looks.
|
|
188
|
+
*/
|
|
189
|
+
export const strokePiece = (stroke, startProgress, endProgress) => {
|
|
190
|
+
switch (stroke.kind) {
|
|
191
|
+
case 'line': {
|
|
192
|
+
const start = interpolatePoint(stroke.from, stroke.to, startProgress);
|
|
193
|
+
const end = interpolatePoint(stroke.from, stroke.to, endProgress);
|
|
194
|
+
return [start, interpolatePoint(start, end, 1 / 3), interpolatePoint(start, end, 2 / 3), end];
|
|
195
|
+
}
|
|
196
|
+
case 'arc': {
|
|
197
|
+
const { center: [centerX, centerY], radius, startAngle, endAngle } = stroke;
|
|
198
|
+
const startRadians = (startAngle + (endAngle - startAngle) * startProgress) * DEGREE;
|
|
199
|
+
const endRadians = (startAngle + (endAngle - startAngle) * endProgress) * DEGREE;
|
|
200
|
+
// standard arc approximation: control handle length = 4/3 · tan(θ/4) · r
|
|
201
|
+
const handleLength = (4 / 3) * Math.tan((endRadians - startRadians) / 4) * radius;
|
|
202
|
+
const start = [centerX + radius * Math.cos(startRadians), centerY + radius * Math.sin(startRadians)];
|
|
203
|
+
const end = [centerX + radius * Math.cos(endRadians), centerY + radius * Math.sin(endRadians)];
|
|
204
|
+
return [
|
|
205
|
+
start,
|
|
206
|
+
[start[0] - handleLength * Math.sin(startRadians), start[1] + handleLength * Math.cos(startRadians)],
|
|
207
|
+
[end[0] + handleLength * Math.sin(endRadians), end[1] - handleLength * Math.cos(endRadians)],
|
|
208
|
+
end,
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
case 'cubic': {
|
|
212
|
+
if (startProgress >= 1)
|
|
213
|
+
return [stroke.segment[3], stroke.segment[3], stroke.segment[3], stroke.segment[3]];
|
|
214
|
+
const [, rest] = splitCubic(stroke.segment, startProgress);
|
|
215
|
+
return splitCubic(rest, (endProgress - startProgress) / (1 - startProgress))[0];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
/** Lays out cubic segments along the strokes; see `PairLayout.breakpoints`. */
|
|
220
|
+
export const layoutStrokes = (strokes, breakpoints) => fromSegments(breakpoints.slice(1).map((segmentEnd, segmentIndex) => {
|
|
221
|
+
const segmentStart = breakpoints[segmentIndex];
|
|
222
|
+
const strokeIndex = Math.min(Math.floor(segmentStart + EPSILON), strokes.length - 1);
|
|
223
|
+
return strokePiece(strokes[strokeIndex], segmentStart - strokeIndex, segmentEnd - strokeIndex);
|
|
224
|
+
}));
|
|
225
|
+
const CUBIC_LENGTH_SAMPLES = 64;
|
|
226
|
+
const distance = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
227
|
+
const strokeLength = (stroke) => {
|
|
228
|
+
switch (stroke.kind) {
|
|
229
|
+
case 'line': return distance(stroke.from, stroke.to);
|
|
230
|
+
case 'arc': return arcSweep(stroke) * DEGREE * stroke.radius;
|
|
231
|
+
case 'cubic': {
|
|
232
|
+
let length = 0, previousPoint = stroke.segment[0];
|
|
233
|
+
for (let sample = 1; sample <= CUBIC_LENGTH_SAMPLES; sample++) {
|
|
234
|
+
const point = strokePiece(stroke, 0, sample / CUBIC_LENGTH_SAMPLES)[3];
|
|
235
|
+
length += distance(previousPoint, point);
|
|
236
|
+
previousPoint = point;
|
|
237
|
+
}
|
|
238
|
+
return length;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/** Default breakpoints: segments spread over the strokes by length, arcs get at least one segment per 90°. */
|
|
243
|
+
export const defaultBreakpoints = (strokes, segmentCount) => {
|
|
244
|
+
const lengths = strokes.map(strokeLength);
|
|
245
|
+
const segmentsPerStroke = strokes.map((stroke) => (stroke.kind === 'arc' ? Math.ceil(arcSweep(stroke) / MAX_ARC_PER_SEGMENT - EPSILON) : 1));
|
|
246
|
+
const usedSegments = () => segmentsPerStroke.reduce((sum, count) => sum + count, 0);
|
|
247
|
+
if (usedSegments() > segmentCount)
|
|
248
|
+
throw new Error(`needs ${usedSegments()} segments, the set has ${segmentCount}`);
|
|
249
|
+
while (usedSegments() < segmentCount) {
|
|
250
|
+
// one more segment for the stroke whose segments are currently the longest
|
|
251
|
+
let longest = 0;
|
|
252
|
+
for (let strokeIndex = 1; strokeIndex < strokes.length; strokeIndex++) {
|
|
253
|
+
const segmentLength = lengths[strokeIndex] / segmentsPerStroke[strokeIndex];
|
|
254
|
+
if (segmentLength > lengths[longest] / segmentsPerStroke[longest] + EPSILON)
|
|
255
|
+
longest = strokeIndex;
|
|
256
|
+
}
|
|
257
|
+
segmentsPerStroke[longest]++;
|
|
258
|
+
}
|
|
259
|
+
const breakpoints = [0];
|
|
260
|
+
segmentsPerStroke.forEach((count, strokeIndex) => {
|
|
261
|
+
for (let part = 1; part <= count; part++)
|
|
262
|
+
breakpoints.push(+(strokeIndex + part / count).toFixed(6));
|
|
263
|
+
});
|
|
264
|
+
return breakpoints;
|
|
265
|
+
};
|
|
266
|
+
// ---- icon sets ----
|
|
267
|
+
// A shape of a set with several pieces is the path numbers of every piece one after another.
|
|
268
|
+
/** The set itself is the first piece. */
|
|
269
|
+
export const piecesOf = (iconSet) => [iconSet, ...(iconSet.morePieces ?? [])];
|
|
270
|
+
/** Path numbers of one piece: start point + 6 per segment. */
|
|
271
|
+
export const pieceLengthOf = (iconSet) => 2 + 6 * iconSet.segmentCount;
|
|
272
|
+
/** Splits a shape into the path numbers of its pieces. */
|
|
273
|
+
export const splitPieces = (path, pieceLength) => Array.from({ length: path.length / pieceLength }, (_, pieceIndex) => path.slice(pieceIndex * pieceLength, (pieceIndex + 1) * pieceLength));
|
|
274
|
+
const strokesOf = (pieceSet, state, pieceIndex) => {
|
|
275
|
+
const strokes = pieceSet.strokes[state];
|
|
276
|
+
if (!strokes)
|
|
277
|
+
throw new Error(`state "${state}" has no strokes in piece ${pieceIndex}`);
|
|
278
|
+
return strokes;
|
|
279
|
+
};
|
|
280
|
+
/** Every state of the set laid out with its default breakpoints. */
|
|
281
|
+
export const defaultShapes = (iconSet) => Object.fromEntries(Object.keys(iconSet.strokes).map((state) => [state, piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => {
|
|
282
|
+
const strokes = strokesOf(pieceSet, state, pieceIndex);
|
|
283
|
+
return layoutStrokes(strokes, defaultBreakpoints(strokes, iconSet.segmentCount));
|
|
284
|
+
})]));
|
|
285
|
+
/**
|
|
286
|
+
* `shape` pressed flat onto the straight line `from` → `to`: every point moves onto the line, keeping its position
|
|
287
|
+
* along the line's direction (the shape's extent along it is stretched to the line's length).
|
|
288
|
+
*/
|
|
289
|
+
export const flattenOntoLine = (shape, from, to) => {
|
|
290
|
+
const direction = [to[0] - from[0], to[1] - from[1]];
|
|
291
|
+
const positions = Array.from({ length: shape.length / 2 }, (_, pointIndex) => shape[2 * pointIndex] * direction[0] + shape[2 * pointIndex + 1] * direction[1]);
|
|
292
|
+
const lowest = Math.min(...positions), highest = Math.max(...positions);
|
|
293
|
+
return positions.flatMap((position) => {
|
|
294
|
+
const along = highest - lowest > EPSILON ? (position - lowest) / (highest - lowest) : 0.5;
|
|
295
|
+
return [from[0] + direction[0] * along, from[1] + direction[1] * along];
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
/** The shape of `state` laid out for morphing to or from `otherState` (each piece by its own pair layout). */
|
|
299
|
+
export const shapeForPair = (iconSet, shapes, state, otherState) => {
|
|
300
|
+
const pieceLength = pieceLengthOf(iconSet);
|
|
301
|
+
const pieceForPair = (pieceSet, pieceIndex, pieceState, pieceOther, mayFlatten) => {
|
|
302
|
+
const defaultPiece = splitPieces(shapes[pieceState], pieceLength)[pieceIndex];
|
|
303
|
+
const pairLayout = pieceSet.pairLayouts[pieceState]?.[pieceOther];
|
|
304
|
+
if (!pairLayout)
|
|
305
|
+
return defaultPiece;
|
|
306
|
+
if ((pairLayout.draw || pairLayout.wipe) && mayFlatten) {
|
|
307
|
+
// not there: the other side's piece shrunk to its start point (not drawn); the morph draws it (see drawnPieces)
|
|
308
|
+
const otherPiece = pieceForPair(pieceSet, pieceIndex, pieceOther, pieceState, false);
|
|
309
|
+
return Array.from({ length: pieceLength }, (_, index) => otherPiece[index % 2]);
|
|
310
|
+
}
|
|
311
|
+
if (pairLayout.flatten && mayFlatten) {
|
|
312
|
+
const [line] = strokesOf(pieceSet, pieceState, pieceIndex);
|
|
313
|
+
if (line.kind !== 'line')
|
|
314
|
+
throw new Error(`flatten: state "${pieceState}" must be one straight line`);
|
|
315
|
+
// the other side as it is laid out for this pair (if it flattens too, its plain layout)
|
|
316
|
+
return flattenOntoLine(pieceForPair(pieceSet, pieceIndex, pieceOther, pieceState, false), line.from, line.to);
|
|
317
|
+
}
|
|
318
|
+
const strokes = pairLayout.tracing ? pieceSet.alternativeStrokes[pairLayout.tracing] : strokesOf(pieceSet, pieceState, pieceIndex);
|
|
319
|
+
const laidOut = pairLayout.breakpoints ? layoutStrokes(strokes, pairLayout.breakpoints) : defaultPiece;
|
|
320
|
+
const reordered = reorderSegments(laidOut, pairLayout);
|
|
321
|
+
return pairLayout.rotation ? rotateAroundCenter(reordered, pairLayout.rotation) : reordered;
|
|
322
|
+
};
|
|
323
|
+
return piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => pieceForPair(pieceSet, pieceIndex, state, otherState, true));
|
|
324
|
+
};
|
|
325
|
+
// ---- drawing a piece along its path (PairLayout.draw) ----
|
|
326
|
+
/** Rough length of a cubic segment: the average of its chord and its control polygon. */
|
|
327
|
+
const segmentLength = ([start, control1, control2, end]) => {
|
|
328
|
+
const distance = (from, to) => Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
329
|
+
return (distance(start, end) + distance(start, control1) + distance(control1, control2) + distance(control2, end)) / 2;
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* The part of a piece between two shares of its length (0…1). The segments outside shrink to the part's ends,
|
|
333
|
+
* so the number count stays the same.
|
|
334
|
+
*/
|
|
335
|
+
const pieceBetween = (piece, fromFraction, toFraction) => {
|
|
336
|
+
const segments = toSegments(piece);
|
|
337
|
+
const lengths = segments.map(segmentLength);
|
|
338
|
+
const totalLength = lengths.reduce((sum, length) => sum + length, 0);
|
|
339
|
+
const from = fromFraction * totalLength, to = toFraction * totalLength;
|
|
340
|
+
let travelled = 0;
|
|
341
|
+
const parts = segments.map((segment, index) => {
|
|
342
|
+
const segmentStart = travelled;
|
|
343
|
+
travelled += lengths[index];
|
|
344
|
+
if (!lengths[index] || travelled <= from || segmentStart >= to)
|
|
345
|
+
return null;
|
|
346
|
+
const startProgress = Math.max(0, (from - segmentStart) / lengths[index]);
|
|
347
|
+
const endProgress = Math.min(1, (to - segmentStart) / lengths[index]);
|
|
348
|
+
const [head] = splitCubic(segment, endProgress);
|
|
349
|
+
return startProgress > 0 ? splitCubic(head, startProgress / endProgress)[1] : head;
|
|
350
|
+
});
|
|
351
|
+
// before the part: its start point; after (or between): the end of the part drawn so far
|
|
352
|
+
let pen = parts.find((part) => part)?.[0] ?? segments[0][0];
|
|
353
|
+
return fromSegments(parts.map((part) => {
|
|
354
|
+
if (!part)
|
|
355
|
+
return [pen, pen, pen, pen];
|
|
356
|
+
pen = part[3];
|
|
357
|
+
return part;
|
|
358
|
+
}));
|
|
359
|
+
};
|
|
360
|
+
/** The first `fraction` (0…1) of a piece along its length, as if a pen had drawn that far. */
|
|
361
|
+
export const trimPiece = (piece, fraction) => pieceBetween(piece, 0, Math.min(Math.max(fraction, 0), 1));
|
|
362
|
+
/** The x range a wipe's edge moves through (the icon box's drawing area, line width included). */
|
|
363
|
+
const WIPE_FROM_X = 2;
|
|
364
|
+
const WIPE_TO_X = 22;
|
|
365
|
+
const WIPE_SAMPLES_PER_SEGMENT = 40;
|
|
366
|
+
/** Points along a piece as [share of its length, x]. */
|
|
367
|
+
const xAlong = (piece) => {
|
|
368
|
+
const segments = toSegments(piece);
|
|
369
|
+
const lengths = segments.map(segmentLength);
|
|
370
|
+
const totalLength = lengths.reduce((sum, length) => sum + length, 0) || 1;
|
|
371
|
+
const samples = [];
|
|
372
|
+
let travelled = 0;
|
|
373
|
+
segments.forEach((segment, index) => {
|
|
374
|
+
for (let step = 0; step <= WIPE_SAMPLES_PER_SEGMENT; step++) {
|
|
375
|
+
const progress = step / WIPE_SAMPLES_PER_SEGMENT;
|
|
376
|
+
const [x] = progress < 1 ? splitCubic(segment, progress)[1][0] : segment[3];
|
|
377
|
+
samples.push([(travelled + progress * lengths[index]) / totalLength, x]);
|
|
378
|
+
}
|
|
379
|
+
travelled += lengths[index];
|
|
380
|
+
});
|
|
381
|
+
return samples;
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* The x range a morph's wipe edge moves through: the box (x 2…22), or — if a piece slides in — from the box's side
|
|
385
|
+
* to that piece's leading side at rest, so the edge rides at its front and it starts just outside the box.
|
|
386
|
+
*/
|
|
387
|
+
export const wipeRange = (slidingPiece, direction) => {
|
|
388
|
+
if (!slidingPiece)
|
|
389
|
+
return [WIPE_FROM_X, WIPE_TO_X];
|
|
390
|
+
const xs = xAlong(slidingPiece).map(([, x]) => x);
|
|
391
|
+
return direction === 'right' ? [WIPE_FROM_X, Math.max(...xs)] : [Math.min(...xs), WIPE_TO_X];
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* A piece behind a vertical edge (PairLayout.wipe): at `fraction` 0…1 of the wipe, the edge has moved that far
|
|
395
|
+
* through `edgeRange` in `direction`, and only the part it has passed shows. That part must be one stretch of the
|
|
396
|
+
* path. `slides` (PairLayout.slide): the piece moves with the edge from outside the box; only its part inside shows.
|
|
397
|
+
*/
|
|
398
|
+
export const wipePiece = (piece, fraction, direction, edgeRange = [WIPE_FROM_X, WIPE_TO_X], slides = false) => {
|
|
399
|
+
if (fraction >= 1)
|
|
400
|
+
return piece.slice();
|
|
401
|
+
const [fromX, toX] = edgeRange;
|
|
402
|
+
if (slides) {
|
|
403
|
+
// in the piece's own frame the box's side moves the other way: the leading part shows first
|
|
404
|
+
const shift = (1 - Math.max(fraction, 0)) * (toX - fromX) * (direction === 'right' ? -1 : 1);
|
|
405
|
+
return wipePiece(piece, fraction, direction === 'right' ? 'left' : 'right', edgeRange)
|
|
406
|
+
.map((value, index) => (index % 2 ? value : value + shift));
|
|
407
|
+
}
|
|
408
|
+
const travel = Math.max(fraction, 0) * (toX - fromX);
|
|
409
|
+
const edge = direction === 'right' ? fromX + travel : toX - travel;
|
|
410
|
+
// samples along the length: [share of the length, > 0 where the edge has passed]
|
|
411
|
+
const samples = xAlong(piece).map(([share, x]) => [share, direction === 'right' ? edge - x : x - edge]);
|
|
412
|
+
const first = samples.findIndex(([, passed]) => passed >= 0);
|
|
413
|
+
if (first < 0)
|
|
414
|
+
return pieceBetween(piece, 0, 0);
|
|
415
|
+
let last = samples.length - 1;
|
|
416
|
+
while (samples[last][1] < 0)
|
|
417
|
+
last--;
|
|
418
|
+
// where the edge crosses between two samples
|
|
419
|
+
const crossing = ([shareA, passedA], [shareB, passedB]) => shareA + (shareB - shareA) * (passedA / (passedA - passedB));
|
|
420
|
+
const from = first > 0 ? crossing(samples[first - 1], samples[first]) : 0;
|
|
421
|
+
const to = last < samples.length - 1 ? crossing(samples[last], samples[last + 1]) : 1;
|
|
422
|
+
return pieceBetween(piece, from, to);
|
|
423
|
+
};
|
|
424
|
+
/** The pieces drawn (or erased) along their path when morphing `state` → `otherState` (PairLayout.draw). */
|
|
425
|
+
export const drawnPieces = (iconSet, state, otherState) => piecesOf(iconSet).flatMap((pieceSet, pieceIndex) => {
|
|
426
|
+
const windowOf = (draw) => (Array.isArray(draw) ? draw : typeof draw === 'number' ? [1 - draw, 1] : [0, 1]);
|
|
427
|
+
// the layout of the state where the piece is absent
|
|
428
|
+
const absentIn = (absentState, presentState) => {
|
|
429
|
+
const pairLayout = pieceSet.pairLayouts[absentState]?.[presentState];
|
|
430
|
+
return pairLayout?.draw || pairLayout?.wipe ? pairLayout : undefined;
|
|
431
|
+
};
|
|
432
|
+
const drawIn = absentIn(state, otherState);
|
|
433
|
+
const pairLayout = drawIn ?? absentIn(otherState, state);
|
|
434
|
+
if (!pairLayout)
|
|
435
|
+
return [];
|
|
436
|
+
const [start, end] = windowOf(pairLayout.draw || true);
|
|
437
|
+
return [{ pieceIndex, drawsIn: Boolean(drawIn), start, end, wipe: pairLayout.wipe, slide: pairLayout.slide }];
|
|
438
|
+
});
|
|
439
|
+
/**
|
|
440
|
+
* A frame of a morph `from` → `to` at `progress` with the drawn pieces replaced: drawn in from the start along
|
|
441
|
+
* `to`'s piece, or erased back along `from`'s piece.
|
|
442
|
+
*/
|
|
443
|
+
export const withDrawnPieces = (frame, from, to, progress, pieceLength, drawn) => {
|
|
444
|
+
if (!drawn.length)
|
|
445
|
+
return frame;
|
|
446
|
+
const presentPiece = ({ pieceIndex, drawsIn }) => (drawsIn ? to : from).slice(pieceIndex * pieceLength, (pieceIndex + 1) * pieceLength);
|
|
447
|
+
// one edge for every wipe of the morph, riding at the front of the (first) sliding piece
|
|
448
|
+
const slider = drawn.find((piece) => piece.slide && piece.wipe);
|
|
449
|
+
const edgeRange = wipeRange(slider ? presentPiece(slider) : null, slider?.wipe ?? 'right');
|
|
450
|
+
const result = frame.slice();
|
|
451
|
+
for (const drawnPiece of drawn) {
|
|
452
|
+
const { pieceIndex, drawsIn, start, end, wipe, slide } = drawnPiece;
|
|
453
|
+
const present = presentPiece(drawnPiece);
|
|
454
|
+
// erasing is drawing in played backwards
|
|
455
|
+
const fraction = ((drawsIn ? progress : 1 - progress) - start) / (end - start);
|
|
456
|
+
result.splice(pieceIndex * pieceLength, pieceLength, ...(wipe ? wipePiece(present, fraction, wipe, edgeRange, slide) : trimPiece(present, fraction)));
|
|
457
|
+
}
|
|
458
|
+
return result;
|
|
459
|
+
};
|
package/dist/esm/icon.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
|
|
2
|
+
// Layer 1: one icon that morphs between the states of a set, e.g. a single digit.
|
|
3
|
+
import { createMorphingPath, EMPTY_STATE, hasState } from './morph.js';
|
|
4
|
+
import { checkedIconSet, checkedInit, resolveIconSet } from './roundness.js';
|
|
5
|
+
import { createIconSvg, createStrokedElement } from './svg.js';
|
|
6
|
+
const checkedState = (iconSet, state) => {
|
|
7
|
+
const normalized = state === '' ? EMPTY_STATE : state;
|
|
8
|
+
if (!hasState(iconSet, normalized))
|
|
9
|
+
throw new Error(`morphico: the icon set has no state "${state}"`);
|
|
10
|
+
return normalized;
|
|
11
|
+
};
|
|
12
|
+
/** Adds a morphing icon (an <svg>) to `element`, showing `init` first (`''` or `' '` = empty). */
|
|
13
|
+
export const icon = (element, init, options) => {
|
|
14
|
+
const firstState = checkedInit(init, 'icon');
|
|
15
|
+
const { set, size = '1em', roundness, strokeWidth, lineCap, lineJoin, fill, ...defaultMorphOptions } = options;
|
|
16
|
+
const iconSet = resolveIconSet(checkedIconSet(set, 'icon'), roundness);
|
|
17
|
+
const style = { strokeWidth, lineCap, lineJoin, fill };
|
|
18
|
+
const svg = createIconSvg(iconSet, '0 0 24 24', style);
|
|
19
|
+
svg.setAttribute('role', 'img');
|
|
20
|
+
svg.style.width = size;
|
|
21
|
+
svg.style.height = size;
|
|
22
|
+
svg.style.overflow = 'visible'; // sharp tips (roundness 0, miter joins) may reach past the 24 × 24 box
|
|
23
|
+
const pathElement = svg.appendChild(createStrokedElement('path', style));
|
|
24
|
+
const morphingPath = createMorphingPath(pathElement, iconSet, checkedState(iconSet, firstState));
|
|
25
|
+
const updateLabel = () => svg.setAttribute('aria-label', morphingPath.state.trim());
|
|
26
|
+
updateLabel();
|
|
27
|
+
element.appendChild(svg);
|
|
28
|
+
return {
|
|
29
|
+
to(nextState, { onEnd, ...morphOptions } = {}) {
|
|
30
|
+
const morph = morphingPath.morphTo(checkedState(iconSet, nextState), { ...defaultMorphOptions, ...morphOptions });
|
|
31
|
+
updateLabel();
|
|
32
|
+
if (onEnd)
|
|
33
|
+
morph.then(onEnd);
|
|
34
|
+
return morph;
|
|
35
|
+
},
|
|
36
|
+
destroy() {
|
|
37
|
+
morphingPath.stop();
|
|
38
|
+
svg.remove();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|