morphico 0.3.0 → 0.4.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 +43 -3
- package/dist/morphico.js +669 -30
- package/dist/morphico.mjs +669 -30
- package/dist/types/clip.d.ts +2 -0
- package/dist/types/face.d.ts +31 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/sets/faces.d.ts +25 -0
- package/package.json +1 -1
package/dist/morphico.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! morphico v0.
|
|
1
|
+
/*! morphico v0.4.0 | code: MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE)
|
|
2
2
|
* Icons based on Lucide (https://lucide.dev), some of them from Feather (https://feathericons.com):
|
|
3
3
|
*
|
|
4
4
|
* ISC License
|
|
@@ -3128,7 +3128,7 @@ return { EMPTY_STATE, DEFAULT_DURATION, shapesOf, hasState, prefersReducedMotion
|
|
|
3128
3128
|
// sets/shapes.ts
|
|
3129
3129
|
const { lucideShapeOutlines, clipShapes } = (() => {
|
|
3130
3130
|
// Shapes for clip(): closed outlines that cut out an element. From Lucide 1.61.0 (ISC, see LICENSE-lucide), imported
|
|
3131
|
-
// by `node tools/import-lucide.ts <name>`; tag without its dot. The states are the Lucide outlines made comparable for
|
|
3131
|
+
// by `node tools/import-lucide.ts <name>`; tag without its dot, club without its stem. The states are the Lucide outlines made comparable for
|
|
3132
3132
|
// morphing: clockwise, starting where the outline crosses the vertical middle at the top, scaled evenly to fill the
|
|
3133
3133
|
// 24×24 box and centred (computed on first use, not at import). Roundness 1 = Lucide's own corners … 0 = sharp.
|
|
3134
3134
|
const DEGREE = Math.PI / 180;
|
|
@@ -3339,16 +3339,6 @@ const lucideShapeOutlines = {
|
|
|
3339
3339
|
line(2, 16, 2, 8),
|
|
3340
3340
|
arc(4, 8, 2, 180, 270),
|
|
3341
3341
|
],
|
|
3342
|
-
'rectangle-vertical': [
|
|
3343
|
-
line(8, 2, 16, 2),
|
|
3344
|
-
arc(16, 4, 2, -90, 0),
|
|
3345
|
-
line(18, 4, 18, 20),
|
|
3346
|
-
arc(16, 20, 2, 0, 90),
|
|
3347
|
-
line(16, 22, 8, 22),
|
|
3348
|
-
arc(8, 20, 2, 90, 180),
|
|
3349
|
-
line(6, 20, 6, 4),
|
|
3350
|
-
arc(8, 4, 2, 180, 270),
|
|
3351
|
-
],
|
|
3352
3342
|
'message-circle': [
|
|
3353
3343
|
arc(1.1328, 17.079, 2, -21.6245, 12.4142),
|
|
3354
3344
|
line(3.086, 17.509, 2.021, 20.799),
|
|
@@ -3369,16 +3359,6 @@ const lucideShapeOutlines = {
|
|
|
3369
3359
|
arc(20, 5, 2, -90, 0),
|
|
3370
3360
|
line(22, 5, 22, 17),
|
|
3371
3361
|
],
|
|
3372
|
-
diamond: [
|
|
3373
|
-
arc(4.4033, 12.005, 2.41, -134.9707, -225.0293),
|
|
3374
|
-
line(2.7, 13.71, 10.29, 21.3),
|
|
3375
|
-
arc(11.995, 19.5967, 2.41, 135.0293, 44.9707),
|
|
3376
|
-
line(13.7, 21.3, 21.29, 13.71),
|
|
3377
|
-
arc(19.5867, 12.005, 2.41, 45.0293, -45.0293),
|
|
3378
|
-
line(21.29, 10.3, 13.7, 2.71),
|
|
3379
|
-
arc(11.995, 4.4133, 2.41, -44.9707, -135.0293),
|
|
3380
|
-
line(10.29, 2.71, 2.7, 10.3),
|
|
3381
|
-
],
|
|
3382
3362
|
cloud: [
|
|
3383
3363
|
line(17.5, 19, 9, 19),
|
|
3384
3364
|
arc(9.0018, 12, 7, 90.0147, 343.3984),
|
|
@@ -3411,6 +3391,13 @@ const lucideShapeOutlines = {
|
|
|
3411
3391
|
arc(12, 4.4139, 2.414, -135.0015, -44.9985),
|
|
3412
3392
|
line(13.707, 2.707, 15.39, 4.39),
|
|
3413
3393
|
],
|
|
3394
|
+
// without its stem line (Lucide's second piece)
|
|
3395
|
+
club: [
|
|
3396
|
+
arc(12, 7.51, 5.5, 16.2602, -196.2602),
|
|
3397
|
+
arc(7.5026, 14.494, 5.5, -98.1803, -324.8563),
|
|
3398
|
+
arc(16.5052, 14.5052, 5.5, 144.9978, -81.9018),
|
|
3399
|
+
line(17.28, 9.06, 17.28, 9.05),
|
|
3400
|
+
],
|
|
3414
3401
|
};
|
|
3415
3402
|
// ---- making the outlines comparable ----
|
|
3416
3403
|
const pointOnStroke = (stroke, progress) => strokePiece(stroke, 0, progress)[3];
|
|
@@ -3602,6 +3589,8 @@ const GRID_CENTER = 12;
|
|
|
3602
3589
|
const DEGREE = Math.PI / 180;
|
|
3603
3590
|
/** Masks and their clip regions reach far past the element, so bands and turned shapes are never cut by them. */
|
|
3604
3591
|
const FAR = 100000;
|
|
3592
|
+
/** In objectBoundingBox units (1 = the element's width / height): far around the element. */
|
|
3593
|
+
const INVERTED_SURROUNDING = 'M-1000 -1000H1001V1001H-1000Z';
|
|
3605
3594
|
let clipCount = 0;
|
|
3606
3595
|
const lengthToPixels = (length, reference, name) => {
|
|
3607
3596
|
if (typeof length === 'number' && Number.isFinite(length))
|
|
@@ -3624,7 +3613,7 @@ const checkedOutline = (outline) => {
|
|
|
3624
3613
|
};
|
|
3625
3614
|
/** Cuts `element` out by a morphing shape. */
|
|
3626
3615
|
const clip = (element, options = {}) => {
|
|
3627
|
-
const { set = clipShapes, state: initialState = 'circle', x = 50, y = 50, size, rotation = 0, roundness = 1, outline = false, ...defaultMorphOptions } = options;
|
|
3616
|
+
const { set = clipShapes, state: initialState = 'circle', x = 50, y = 50, size, rotation = 0, roundness = 1, outline = false, inverted = false, ...defaultMorphOptions } = options;
|
|
3628
3617
|
const checkedState = (shapeSet, state) => {
|
|
3629
3618
|
if (!(state in shapeSet.strokes)) {
|
|
3630
3619
|
throw new Error(`morphico: unknown shape "${state}" (known: ${Object.keys(shapeSet.strokes).join(', ')})`);
|
|
@@ -3638,7 +3627,7 @@ const clip = (element, options = {}) => {
|
|
|
3638
3627
|
/** Where the change goes (or what shows when nothing moves). */
|
|
3639
3628
|
const target = {
|
|
3640
3629
|
state: checkedState(set, initialState), set: resolveIconSet(set, roundness), roundness,
|
|
3641
|
-
x, y, size, rotation, outline: checkedOutline(outline),
|
|
3630
|
+
x, y, size, rotation, outline: checkedOutline(outline), inverted,
|
|
3642
3631
|
};
|
|
3643
3632
|
const pieceLength = pieceLengthOf(set);
|
|
3644
3633
|
let frame = null; // null = resting at the target
|
|
@@ -3660,16 +3649,22 @@ const clip = (element, options = {}) => {
|
|
|
3660
3649
|
helper.remove();
|
|
3661
3650
|
maskElement.replaceChildren();
|
|
3662
3651
|
bandPaths = [];
|
|
3652
|
+
clipShape.setAttribute('clip-rule', target.inverted ? 'evenodd' : 'nonzero');
|
|
3663
3653
|
if (!target.outline)
|
|
3664
3654
|
return;
|
|
3665
3655
|
const { width, band = 'inside' } = target.outline;
|
|
3656
|
+
// inverted: everything shows (white), the band is the hole (black)
|
|
3657
|
+
const bandColor = target.inverted ? 'black' : 'white';
|
|
3658
|
+
if (target.inverted) {
|
|
3659
|
+
maskElement.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }));
|
|
3660
|
+
}
|
|
3666
3661
|
const pixelPath = (attributes) => {
|
|
3667
3662
|
const path = createSvgElement('path', attributes);
|
|
3668
3663
|
bandPaths.push(path);
|
|
3669
3664
|
return path;
|
|
3670
3665
|
};
|
|
3671
3666
|
// inside = a line twice as wide, clipped to the shape; outside = a line twice as wide, the shape cut out of it
|
|
3672
|
-
const edgeLine = (lineWidth) => pixelPath({ fill: 'none', stroke:
|
|
3667
|
+
const edgeLine = (lineWidth) => pixelPath({ fill: 'none', stroke: bandColor, 'stroke-width': lineWidth, 'stroke-linejoin': 'miter' });
|
|
3673
3668
|
if (band === 'centre') {
|
|
3674
3669
|
maskElement.append(edgeLine(width));
|
|
3675
3670
|
}
|
|
@@ -3729,7 +3724,9 @@ const clip = (element, options = {}) => {
|
|
|
3729
3724
|
else {
|
|
3730
3725
|
// objectBoundingBox: 0…1 of the element's width / height
|
|
3731
3726
|
const boxUnits = pixels.map((value, index) => value / (index % 2 ? elementHeight : elementWidth));
|
|
3732
|
-
|
|
3727
|
+
// inverted: a rectangle far around the element with the shape as a hole in it (clip-rule evenodd)
|
|
3728
|
+
const surrounding = target.inverted ? INVERTED_SURROUNDING : '';
|
|
3729
|
+
clipShape.setAttribute('d', surrounding + toPathData(boxUnits, pieceLength));
|
|
3733
3730
|
}
|
|
3734
3731
|
};
|
|
3735
3732
|
const measure = (width, height) => {
|
|
@@ -3768,8 +3765,11 @@ const clip = (element, options = {}) => {
|
|
|
3768
3765
|
target.size = changes.size;
|
|
3769
3766
|
if (changes.rotation !== undefined)
|
|
3770
3767
|
target.rotation = changes.rotation;
|
|
3771
|
-
if (changes.outline !== undefined) {
|
|
3772
|
-
|
|
3768
|
+
if (changes.outline !== undefined || changes.inverted !== undefined) {
|
|
3769
|
+
if (changes.outline !== undefined)
|
|
3770
|
+
target.outline = checkedOutline(changes.outline);
|
|
3771
|
+
if (changes.inverted !== undefined)
|
|
3772
|
+
target.inverted = changes.inverted;
|
|
3773
3773
|
buildBand();
|
|
3774
3774
|
applyStyle();
|
|
3775
3775
|
}
|
|
@@ -3866,6 +3866,645 @@ const clip = (element, options = {}) => {
|
|
|
3866
3866
|
return { clip };
|
|
3867
3867
|
})();
|
|
3868
3868
|
|
|
3869
|
+
// sets/faces.ts
|
|
3870
|
+
const { OUTLINE_POINTS, PIECE_NAMES, faceExpressionNames, faceExpressionPieces, interpolateFace, blinked, smoothClosedPath, facePaths } = (() => {
|
|
3871
|
+
const OUTLINE_POINTS = 24;
|
|
3872
|
+
const PIECE_NAMES = ['leftBrow', 'rightBrow', 'leftEye', 'rightEye', 'leftPupil', 'rightPupil', 'mouth'];
|
|
3873
|
+
const DENSE_SAMPLES = 360;
|
|
3874
|
+
const toRadians = (degrees) => (degrees * Math.PI) / 180;
|
|
3875
|
+
const rotatePoint = ([x, y], degrees) => {
|
|
3876
|
+
const angle = toRadians(degrees);
|
|
3877
|
+
return [x * Math.cos(angle) - y * Math.sin(angle), x * Math.sin(angle) + y * Math.cos(angle)];
|
|
3878
|
+
};
|
|
3879
|
+
const denseBlobOutline = (options) => {
|
|
3880
|
+
const { centre, width, height, rotation = 0, squareness = 0, topScale = 1 } = options;
|
|
3881
|
+
const exponent = 2 + squareness * 6;
|
|
3882
|
+
const halfWidth = width / 2;
|
|
3883
|
+
const halfHeight = height / 2;
|
|
3884
|
+
const outline = [];
|
|
3885
|
+
for (let index = 0; index < DENSE_SAMPLES; index++) {
|
|
3886
|
+
const angle = toRadians(-90 + (360 * index) / DENSE_SAMPLES);
|
|
3887
|
+
const cosine = Math.cos(angle);
|
|
3888
|
+
const sine = Math.sin(angle);
|
|
3889
|
+
const y = halfHeight * Math.sign(sine) * Math.abs(sine) ** (2 / exponent);
|
|
3890
|
+
const widthScale = 1 + ((topScale - 1) * (halfHeight - y)) / (2 * halfHeight);
|
|
3891
|
+
const x = halfWidth * widthScale * Math.sign(cosine) * Math.abs(cosine) ** (2 / exponent);
|
|
3892
|
+
const [turnedX, turnedY] = rotatePoint([x, y], rotation);
|
|
3893
|
+
outline.push([centre[0] + turnedX, centre[1] + turnedY]);
|
|
3894
|
+
}
|
|
3895
|
+
const xs = outline.map(([x]) => x);
|
|
3896
|
+
const left = Math.min(...xs);
|
|
3897
|
+
const right = Math.max(...xs);
|
|
3898
|
+
const lineY = ([leftY, rightY], x) => leftY + ((rightY - leftY) * (x - left)) / (right - left);
|
|
3899
|
+
return outline.map(([x, y]) => {
|
|
3900
|
+
let cutY = y;
|
|
3901
|
+
if (options.cutTop)
|
|
3902
|
+
cutY = Math.max(cutY, lineY(options.cutTop, x));
|
|
3903
|
+
if (options.cutBottom)
|
|
3904
|
+
cutY = Math.min(cutY, lineY(options.cutBottom, x));
|
|
3905
|
+
return [x, cutY];
|
|
3906
|
+
});
|
|
3907
|
+
};
|
|
3908
|
+
/** OUTLINE_POINTS points of a star-shaped outline, on rays at even angles from its box's centre (measured in the box
|
|
3909
|
+
* stretched to a square, so a long flat shape still gets points on its round ends). */
|
|
3910
|
+
const resampleByAngle = (outline) => {
|
|
3911
|
+
const xs = outline.map(([x]) => x);
|
|
3912
|
+
const ys = outline.map(([, y]) => y);
|
|
3913
|
+
const centreX = (Math.min(...xs) + Math.max(...xs)) / 2;
|
|
3914
|
+
const centreY = (Math.min(...ys) + Math.max(...ys)) / 2;
|
|
3915
|
+
const halfX = Math.max((Math.max(...xs) - Math.min(...xs)) / 2, 1e-9);
|
|
3916
|
+
const halfY = Math.max((Math.max(...ys) - Math.min(...ys)) / 2, 1e-9);
|
|
3917
|
+
const normalized = outline.map(([x, y]) => [(x - centreX) / halfX, (y - centreY) / halfY]);
|
|
3918
|
+
const points = [];
|
|
3919
|
+
for (let index = 0; index < OUTLINE_POINTS; index++) {
|
|
3920
|
+
const angle = toRadians(-90 + (360 * index) / OUTLINE_POINTS);
|
|
3921
|
+
const directionX = Math.cos(angle);
|
|
3922
|
+
const directionY = Math.sin(angle);
|
|
3923
|
+
let farthest = 0;
|
|
3924
|
+
normalized.forEach((start, edgeIndex) => {
|
|
3925
|
+
const end = normalized[(edgeIndex + 1) % normalized.length];
|
|
3926
|
+
const edgeX = end[0] - start[0];
|
|
3927
|
+
const edgeY = end[1] - start[1];
|
|
3928
|
+
const denominator = directionX * edgeY - directionY * edgeX;
|
|
3929
|
+
if (Math.abs(denominator) < 1e-12)
|
|
3930
|
+
return;
|
|
3931
|
+
const distance = (start[0] * edgeY - start[1] * edgeX) / denominator;
|
|
3932
|
+
const along = (start[0] * directionY - start[1] * directionX) / denominator;
|
|
3933
|
+
// a little slack: a ray passing exactly through a corner of the dense outline must still hit one of its edges
|
|
3934
|
+
if (along >= -1e-9 && along <= 1 + 1e-9 && distance > farthest)
|
|
3935
|
+
farthest = distance;
|
|
3936
|
+
});
|
|
3937
|
+
points.push([centreX + directionX * farthest * halfX, centreY + directionY * farthest * halfY]);
|
|
3938
|
+
}
|
|
3939
|
+
return points;
|
|
3940
|
+
};
|
|
3941
|
+
const blob = (options) => resampleByAngle(denseBlobOutline(options));
|
|
3942
|
+
const band = (options) => {
|
|
3943
|
+
const { from, to, bend, thickness, endShare = 1 } = options;
|
|
3944
|
+
const chordX = to[0] - from[0];
|
|
3945
|
+
const chordY = to[1] - from[1];
|
|
3946
|
+
const chordLength = Math.hypot(chordX, chordY);
|
|
3947
|
+
// the quadratic's control point sits twice as far out as the curve's middle
|
|
3948
|
+
const control = [
|
|
3949
|
+
(from[0] + to[0]) / 2 + ((2 * bend * chordY) / chordLength),
|
|
3950
|
+
(from[1] + to[1]) / 2 - ((2 * bend * chordX) / chordLength),
|
|
3951
|
+
];
|
|
3952
|
+
const pointAt = (along) => {
|
|
3953
|
+
const rest = 1 - along;
|
|
3954
|
+
return [
|
|
3955
|
+
rest * rest * from[0] + 2 * rest * along * control[0] + along * along * to[0],
|
|
3956
|
+
rest * rest * from[1] + 2 * rest * along * control[1] + along * along * to[1],
|
|
3957
|
+
];
|
|
3958
|
+
};
|
|
3959
|
+
const tangentAt = (along) => {
|
|
3960
|
+
const tangentX = 2 * (1 - along) * (control[0] - from[0]) + 2 * along * (to[0] - control[0]);
|
|
3961
|
+
const tangentY = 2 * (1 - along) * (control[1] - from[1]) + 2 * along * (to[1] - control[1]);
|
|
3962
|
+
const length = Math.hypot(tangentX, tangentY);
|
|
3963
|
+
return [tangentX / length, tangentY / length];
|
|
3964
|
+
};
|
|
3965
|
+
const thicknessAt = (along) => thickness * (endShare + (1 - endShare) * Math.sin(Math.PI * along));
|
|
3966
|
+
// side 1 = the upper edge (the left normal of a rightward curve), -1 = the lower edge
|
|
3967
|
+
const edge = (along, side) => {
|
|
3968
|
+
const [x, y] = pointAt(along);
|
|
3969
|
+
const [tangentX, tangentY] = tangentAt(along);
|
|
3970
|
+
const offset = (side * thicknessAt(along)) / 2;
|
|
3971
|
+
return [x + tangentY * offset, y - tangentX * offset];
|
|
3972
|
+
};
|
|
3973
|
+
// a point on the half circle around an end: turn -90° = on the upper edge, 0 = the tip, 90° = on the lower edge
|
|
3974
|
+
const capPoint = (along, turn) => {
|
|
3975
|
+
const [x, y] = pointAt(along);
|
|
3976
|
+
const [tangentX, tangentY] = tangentAt(along);
|
|
3977
|
+
const radius = thicknessAt(along) / 2;
|
|
3978
|
+
const outward = along === 1 ? 1 : -1;
|
|
3979
|
+
const alongTangent = outward * Math.cos(turn) * radius;
|
|
3980
|
+
const towardsLower = outward * Math.sin(turn) * radius;
|
|
3981
|
+
return [x + tangentX * alongTangent - tangentY * towardsLower, y + tangentY * alongTangent + tangentX * towardsLower];
|
|
3982
|
+
};
|
|
3983
|
+
// clockwise from the top middle at the same angles as a blob's points (a quarter = the right end, half = the bottom
|
|
3984
|
+
// middle, three quarters = the left end); the points within 30° of an end lie on its half circle, the rest along the
|
|
3985
|
+
// edges
|
|
3986
|
+
const capShare = Math.sin(Math.PI / 3);
|
|
3987
|
+
return Array.from({ length: OUTLINE_POINTS }, (_, index) => {
|
|
3988
|
+
const angle = (2 * Math.PI * index) / OUTLINE_POINTS;
|
|
3989
|
+
const sine = Math.sin(angle);
|
|
3990
|
+
if (Math.abs(sine) < capShare - 1e-9)
|
|
3991
|
+
return edge(0.5 + (0.5 * sine) / capShare, Math.cos(angle) > 0 ? 1 : -1);
|
|
3992
|
+
// the angle's distance past the cap's start, stretched from ±30° around the end to ±90° around the half circle
|
|
3993
|
+
const endAngle = sine > 0 ? Math.PI / 2 : (3 * Math.PI) / 2;
|
|
3994
|
+
return capPoint(sine > 0 ? 1 : 0, (angle - endAngle) * 3);
|
|
3995
|
+
});
|
|
3996
|
+
};
|
|
3997
|
+
/** A piece that isn't there: every point on one spot (draws nothing). */
|
|
3998
|
+
const hidden = (at) => Array.from({ length: OUTLINE_POINTS }, () => [at[0], at[1]]);
|
|
3999
|
+
/** The piece mirrored onto the other half of the face, still clockwise from the top. */
|
|
4000
|
+
const mirrored = (points) => points.map((_, index) => {
|
|
4001
|
+
const [x, y] = points[(OUTLINE_POINTS - index) % OUTLINE_POINTS];
|
|
4002
|
+
return [24 - x, y];
|
|
4003
|
+
});
|
|
4004
|
+
/** A face from its left half (as seen) + the mouth. */
|
|
4005
|
+
const symmetricFace = ({ brow, eye, pupil, mouth }) => ({
|
|
4006
|
+
leftBrow: brow,
|
|
4007
|
+
rightBrow: mirrored(brow),
|
|
4008
|
+
leftEye: eye,
|
|
4009
|
+
rightEye: mirrored(eye),
|
|
4010
|
+
leftPupil: pupil,
|
|
4011
|
+
rightPupil: mirrored(pupil),
|
|
4012
|
+
mouth,
|
|
4013
|
+
});
|
|
4014
|
+
/** The whole face mirrored: left and right pieces swap sides. */
|
|
4015
|
+
const mirroredFace = (face) => ({
|
|
4016
|
+
leftBrow: mirrored(face.rightBrow),
|
|
4017
|
+
rightBrow: mirrored(face.leftBrow),
|
|
4018
|
+
leftEye: mirrored(face.rightEye),
|
|
4019
|
+
rightEye: mirrored(face.leftEye),
|
|
4020
|
+
leftPupil: mirrored(face.rightPupil),
|
|
4021
|
+
rightPupil: mirrored(face.leftPupil),
|
|
4022
|
+
mouth: mirrored(face.mouth),
|
|
4023
|
+
});
|
|
4024
|
+
// ---- the expressions (names from the user's pictures) ----
|
|
4025
|
+
const faceExpressionNames = [
|
|
4026
|
+
'normal',
|
|
4027
|
+
'smiling',
|
|
4028
|
+
'pleading',
|
|
4029
|
+
'wonder',
|
|
4030
|
+
'focused',
|
|
4031
|
+
'fright',
|
|
4032
|
+
'angry',
|
|
4033
|
+
'asleep',
|
|
4034
|
+
'sad',
|
|
4035
|
+
'surprised',
|
|
4036
|
+
'laughing',
|
|
4037
|
+
'tired',
|
|
4038
|
+
'wink-left',
|
|
4039
|
+
'wink-right',
|
|
4040
|
+
'suspicious',
|
|
4041
|
+
'confused',
|
|
4042
|
+
'smug',
|
|
4043
|
+
'irritated',
|
|
4044
|
+
'bored',
|
|
4045
|
+
'curious',
|
|
4046
|
+
'empathetic',
|
|
4047
|
+
'guilty',
|
|
4048
|
+
'worried',
|
|
4049
|
+
'disappointed',
|
|
4050
|
+
'ashamed',
|
|
4051
|
+
'embarrassed',
|
|
4052
|
+
'yearning',
|
|
4053
|
+
'triumphant',
|
|
4054
|
+
'proud',
|
|
4055
|
+
'excited',
|
|
4056
|
+
'scornful',
|
|
4057
|
+
'disgusted',
|
|
4058
|
+
];
|
|
4059
|
+
const buildFaceExpressions = () => {
|
|
4060
|
+
const winkRight = {
|
|
4061
|
+
...symmetricFace({
|
|
4062
|
+
brow: blob({ centre: [6.2, 4.4], width: 5.2, height: 1.9, squareness: 0.5, rotation: -6 }),
|
|
4063
|
+
eye: blob({ centre: [6.4, 12], width: 8.4, height: 8.4 }),
|
|
4064
|
+
pupil: blob({ centre: [7, 11.4], width: 3.2, height: 3.2 }),
|
|
4065
|
+
mouth: band({ from: [9.6, 18.4], to: [14.8, 18], bend: -0.9, thickness: 1.2, endShare: 0.7 }),
|
|
4066
|
+
}),
|
|
4067
|
+
rightBrow: mirrored(blob({ centre: [6.2, 6], width: 5, height: 1.8, squareness: 0.5, rotation: 8 })),
|
|
4068
|
+
rightEye: mirrored(band({ from: [3.2, 12.8], to: [9.6, 12.8], bend: 1.4, thickness: 1.6, endShare: 0.7 })),
|
|
4069
|
+
rightPupil: hidden([17.6, 12]),
|
|
4070
|
+
};
|
|
4071
|
+
return {
|
|
4072
|
+
normal: symmetricFace({
|
|
4073
|
+
brow: blob({ centre: [6.2, 4.6], width: 5.2, height: 1.9, squareness: 0.5, rotation: -6 }),
|
|
4074
|
+
eye: blob({ centre: [6.4, 12], width: 8.4, height: 8.4 }),
|
|
4075
|
+
pupil: blob({ centre: [7, 10.7], width: 3.2, height: 3.2 }),
|
|
4076
|
+
mouth: blob({ centre: [12, 18.8], width: 4.4, height: 1.8, squareness: 0.6 }),
|
|
4077
|
+
}),
|
|
4078
|
+
smiling: symmetricFace({
|
|
4079
|
+
brow: blob({ centre: [6, 5.4], width: 5, height: 4.4, squareness: 0.2, cutBottom: [5.4, 5.4] }),
|
|
4080
|
+
eye: blob({ centre: [6, 13], width: 9.2, height: 7.6 }),
|
|
4081
|
+
pupil: blob({ centre: [6.8, 13.6], width: 3.6, height: 2.4 }),
|
|
4082
|
+
mouth: band({ from: [9, 18.4], to: [15, 18.4], bend: -1, thickness: 1.3, endShare: 0.7 }),
|
|
4083
|
+
}),
|
|
4084
|
+
pleading: symmetricFace({
|
|
4085
|
+
brow: blob({ centre: [6.6, 3.7], width: 4.6, height: 2, squareness: 0.5, rotation: -16 }),
|
|
4086
|
+
eye: blob({ centre: [6.8, 12.4], width: 8.6, height: 9.6, topScale: 0.75, rotation: 12 }),
|
|
4087
|
+
pupil: blob({ centre: [7.6, 10.7], width: 4, height: 4 }),
|
|
4088
|
+
mouth: blob({ centre: [12, 17.6], width: 4.3, height: 4.8, squareness: 0.3, cutTop: [17.6, 17.6] }),
|
|
4089
|
+
}),
|
|
4090
|
+
wonder: symmetricFace({
|
|
4091
|
+
brow: band({ from: [3.4, 4.8], to: [8.6, 3.3], bend: 0.7, thickness: 1.7 }),
|
|
4092
|
+
eye: blob({ centre: [6.4, 12], width: 7.8, height: 9.6 }),
|
|
4093
|
+
pupil: blob({ centre: [6.8, 11.2], width: 3.2, height: 4.2 }),
|
|
4094
|
+
mouth: blob({ centre: [12, 18.6], width: 3.8, height: 1, squareness: 0.6 }),
|
|
4095
|
+
}),
|
|
4096
|
+
focused: symmetricFace({
|
|
4097
|
+
brow: blob({ centre: [6, 4.4], width: 5.6, height: 2.2, squareness: 0.55, rotation: 3 }),
|
|
4098
|
+
eye: blob({ centre: [6.2, 12.8], width: 9, height: 8, cutTop: [10, 11] }),
|
|
4099
|
+
pupil: blob({ centre: [6.8, 13.2], width: 3.8, height: 2.8 }),
|
|
4100
|
+
mouth: blob({ centre: [12, 18.8], width: 4.2, height: 1, squareness: 0.6 }),
|
|
4101
|
+
}),
|
|
4102
|
+
fright: symmetricFace({
|
|
4103
|
+
brow: blob({ centre: [5.7, 3.5], width: 4.8, height: 2.2, squareness: 0.45, rotation: -8 }),
|
|
4104
|
+
eye: blob({ centre: [6.2, 12.4], width: 8.4, height: 9.8, topScale: 0.7, rotation: 8 }),
|
|
4105
|
+
pupil: blob({ centre: [7.2, 12], width: 3.6, height: 4.2 }),
|
|
4106
|
+
mouth: blob({ centre: [12, 18.6], width: 3.8, height: 1.4, squareness: 0.5 }),
|
|
4107
|
+
}),
|
|
4108
|
+
angry: symmetricFace({
|
|
4109
|
+
brow: blob({ centre: [6.4, 5.4], width: 5.2, height: 2, squareness: 0.5, rotation: 12 }),
|
|
4110
|
+
eye: blob({ centre: [6.2, 12.6], width: 8.6, height: 8, cutTop: [9.2, 12] }),
|
|
4111
|
+
pupil: blob({ centre: [7.4, 12.8], width: 3.4, height: 3.4 }),
|
|
4112
|
+
mouth: blob({ centre: [12, 17.8], width: 4.2, height: 4, squareness: 0.3, cutTop: [17.8, 17.8] }),
|
|
4113
|
+
}),
|
|
4114
|
+
asleep: symmetricFace({
|
|
4115
|
+
brow: blob({ centre: [6, 5.4], width: 4, height: 1.7, squareness: 0.4, rotation: 4 }),
|
|
4116
|
+
eye: band({ from: [3, 12.4], to: [9.6, 12.4], bend: -1.2, thickness: 1.5, endShare: 0.6 }),
|
|
4117
|
+
pupil: hidden([6.3, 13.4]),
|
|
4118
|
+
mouth: blob({ centre: [12, 18.8], width: 2.6, height: 1.8 }),
|
|
4119
|
+
}),
|
|
4120
|
+
// more feelings, Claude's drafts (no pictures); the one-sided ones replace pieces of the right half — written in place
|
|
4121
|
+
// (a blob or band is already clockwise from its top) or drawn on the left and mirrored
|
|
4122
|
+
sad: symmetricFace({
|
|
4123
|
+
brow: blob({ centre: [6.4, 5.2], width: 4.8, height: 1.9, squareness: 0.5, rotation: -18 }),
|
|
4124
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 8, cutTop: [11.2, 9.6] }),
|
|
4125
|
+
pupil: blob({ centre: [6.8, 13.8], width: 3.2, height: 3.4 }),
|
|
4126
|
+
mouth: band({ from: [9.4, 19.2], to: [14.6, 19.2], bend: 0.9, thickness: 1.2, endShare: 0.7 }),
|
|
4127
|
+
}),
|
|
4128
|
+
surprised: symmetricFace({
|
|
4129
|
+
brow: band({ from: [3.6, 3.6], to: [8.6, 2.8], bend: 0.6, thickness: 1.6 }),
|
|
4130
|
+
eye: blob({ centre: [6.4, 12], width: 8.6, height: 9 }),
|
|
4131
|
+
pupil: blob({ centre: [6.4, 12], width: 2.4, height: 2.4 }),
|
|
4132
|
+
mouth: blob({ centre: [12, 19], width: 2.8, height: 3.4 }),
|
|
4133
|
+
}),
|
|
4134
|
+
laughing: symmetricFace({
|
|
4135
|
+
brow: blob({ centre: [6, 4.6], width: 5, height: 4.4, squareness: 0.2, cutBottom: [4.6, 4.6] }),
|
|
4136
|
+
eye: band({ from: [3.2, 13], to: [9.6, 13], bend: 1.6, thickness: 1.6, endShare: 0.7 }),
|
|
4137
|
+
pupil: hidden([6.4, 12]),
|
|
4138
|
+
mouth: blob({ centre: [12, 17], width: 6, height: 6, squareness: 0.2, cutTop: [17, 17] }),
|
|
4139
|
+
}),
|
|
4140
|
+
tired: symmetricFace({
|
|
4141
|
+
brow: blob({ centre: [6, 6], width: 5, height: 1.7, squareness: 0.5, rotation: -4 }),
|
|
4142
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.6, 11.6] }),
|
|
4143
|
+
pupil: blob({ centre: [6.6, 13.6], width: 3.2, height: 3.2 }),
|
|
4144
|
+
mouth: blob({ centre: [12, 19], width: 3.2, height: 0.9, squareness: 0.6 }),
|
|
4145
|
+
}),
|
|
4146
|
+
// wink-left / wink-right = the eye that closes, as seen; one is the other mirrored
|
|
4147
|
+
'wink-left': mirroredFace(winkRight),
|
|
4148
|
+
'wink-right': winkRight,
|
|
4149
|
+
suspicious: {
|
|
4150
|
+
...symmetricFace({
|
|
4151
|
+
brow: band({ from: [3.4, 4], to: [8.8, 3], bend: 0.8, thickness: 1.6 }),
|
|
4152
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.6, height: 7.6, cutTop: [10.8, 10.8] }),
|
|
4153
|
+
pupil: blob({ centre: [4.6, 13.2], width: 3.2, height: 3 }),
|
|
4154
|
+
mouth: blob({ centre: [12.6, 18.8], width: 3.6, height: 1, squareness: 0.6, rotation: -10 }),
|
|
4155
|
+
}),
|
|
4156
|
+
rightBrow: mirrored(blob({ centre: [6.4, 6.4], width: 5.2, height: 1.8, squareness: 0.5, rotation: 10 })),
|
|
4157
|
+
rightEye: mirrored(blob({ centre: [6.4, 12.8], width: 8.6, height: 7.2, cutTop: [11.8, 11.8] })),
|
|
4158
|
+
rightPupil: blob({ centre: [16.2, 13.4], width: 3.2, height: 3 }),
|
|
4159
|
+
},
|
|
4160
|
+
confused: {
|
|
4161
|
+
...symmetricFace({
|
|
4162
|
+
brow: band({ from: [3.4, 3.8], to: [8.6, 2.6], bend: 0.7, thickness: 1.6 }),
|
|
4163
|
+
eye: blob({ centre: [6.4, 12], width: 8.6, height: 9 }),
|
|
4164
|
+
pupil: blob({ centre: [7.6, 10.6], width: 3, height: 3 }),
|
|
4165
|
+
mouth: blob({ centre: [12.4, 19], width: 3.8, height: 1.1, squareness: 0.5, rotation: 12 }),
|
|
4166
|
+
}),
|
|
4167
|
+
rightBrow: mirrored(blob({ centre: [6.2, 5.8], width: 5, height: 1.8, squareness: 0.5, rotation: 10 })),
|
|
4168
|
+
rightEye: mirrored(blob({ centre: [6.4, 12.6], width: 7.4, height: 7.4 })),
|
|
4169
|
+
rightPupil: blob({ centre: [18.4, 11.4], width: 2.8, height: 2.8 }),
|
|
4170
|
+
},
|
|
4171
|
+
// the user's list of feelings (2026-11-01), Claude's drafts; the ones the set had already are skipped
|
|
4172
|
+
smug: {
|
|
4173
|
+
...symmetricFace({
|
|
4174
|
+
brow: blob({ centre: [6.2, 5], width: 5, height: 1.8, squareness: 0.5, rotation: -4 }),
|
|
4175
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 11.4] }),
|
|
4176
|
+
pupil: blob({ centre: [8, 13.4], width: 3, height: 3 }),
|
|
4177
|
+
mouth: band({ from: [10.2, 18.8], to: [15.2, 17.6], bend: -0.6, thickness: 1.2, endShare: 0.6 }),
|
|
4178
|
+
}),
|
|
4179
|
+
rightBrow: mirrored(band({ from: [3.6, 4.2], to: [8.6, 3.4], bend: 0.6, thickness: 1.6 })),
|
|
4180
|
+
rightPupil: blob({ centre: [19.4, 13.4], width: 3, height: 3 }),
|
|
4181
|
+
},
|
|
4182
|
+
irritated: symmetricFace({
|
|
4183
|
+
brow: blob({ centre: [6.4, 5.8], width: 5.4, height: 1.8, squareness: 0.55, rotation: 6 }),
|
|
4184
|
+
eye: blob({ centre: [6.4, 12.8], width: 8.6, height: 7.6, cutTop: [11.2, 11.8], cutBottom: [15.8, 15.8] }),
|
|
4185
|
+
pupil: blob({ centre: [6.8, 13.6], width: 3.2, height: 3 }),
|
|
4186
|
+
mouth: blob({ centre: [12.4, 18.8], width: 3.6, height: 0.9, squareness: 0.6, rotation: -8 }),
|
|
4187
|
+
}),
|
|
4188
|
+
bored: {
|
|
4189
|
+
...symmetricFace({
|
|
4190
|
+
brow: blob({ centre: [6, 6.2], width: 5, height: 1.7, squareness: 0.5 }),
|
|
4191
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.8, 11.8] }),
|
|
4192
|
+
pupil: blob({ centre: [8, 12.8], width: 3, height: 3 }),
|
|
4193
|
+
mouth: blob({ centre: [13.4, 19], width: 3, height: 0.9, squareness: 0.6 }),
|
|
4194
|
+
}),
|
|
4195
|
+
rightPupil: blob({ centre: [19.4, 12.8], width: 3, height: 3 }),
|
|
4196
|
+
},
|
|
4197
|
+
curious: {
|
|
4198
|
+
...symmetricFace({
|
|
4199
|
+
brow: band({ from: [3.4, 3.6], to: [8.6, 2.6], bend: 0.7, thickness: 1.6 }),
|
|
4200
|
+
eye: blob({ centre: [6.4, 12], width: 8.6, height: 8.8 }),
|
|
4201
|
+
pupil: blob({ centre: [7.8, 10.8], width: 3.2, height: 3.2 }),
|
|
4202
|
+
mouth: blob({ centre: [12.6, 18.8], width: 2, height: 2.2 }),
|
|
4203
|
+
}),
|
|
4204
|
+
rightBrow: mirrored(blob({ centre: [6.2, 5.2], width: 5, height: 1.8, squareness: 0.5, rotation: 4 })),
|
|
4205
|
+
rightPupil: blob({ centre: [19, 10.8], width: 3.2, height: 3.2 }),
|
|
4206
|
+
},
|
|
4207
|
+
empathetic: symmetricFace({
|
|
4208
|
+
brow: blob({ centre: [6.4, 4.8], width: 5, height: 1.9, squareness: 0.5, rotation: -10 }),
|
|
4209
|
+
eye: blob({ centre: [6.4, 12.4], width: 8.4, height: 8, cutTop: [10, 9.2] }),
|
|
4210
|
+
pupil: blob({ centre: [6.8, 13], width: 3.4, height: 3.4 }),
|
|
4211
|
+
mouth: band({ from: [10, 18.6], to: [14, 18.6], bend: -0.5, thickness: 1.1, endShare: 0.7 }),
|
|
4212
|
+
}),
|
|
4213
|
+
guilty: {
|
|
4214
|
+
...symmetricFace({
|
|
4215
|
+
brow: blob({ centre: [6.4, 5], width: 4.6, height: 1.8, squareness: 0.5, rotation: -14 }),
|
|
4216
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.2, height: 8, cutTop: [10.6, 10.2] }),
|
|
4217
|
+
pupil: blob({ centre: [5, 14.4], width: 3, height: 3 }),
|
|
4218
|
+
mouth: blob({ centre: [11.4, 19], width: 2.8, height: 0.9, squareness: 0.6, rotation: 6 }),
|
|
4219
|
+
}),
|
|
4220
|
+
rightPupil: blob({ centre: [16.2, 14.4], width: 3, height: 3 }),
|
|
4221
|
+
},
|
|
4222
|
+
worried: symmetricFace({
|
|
4223
|
+
brow: blob({ centre: [6.6, 4.2], width: 4.8, height: 1.9, squareness: 0.5, rotation: -20 }),
|
|
4224
|
+
eye: blob({ centre: [6.4, 12.2], width: 8, height: 8.8, topScale: 0.85, rotation: 6 }),
|
|
4225
|
+
pupil: blob({ centre: [6.8, 12.4], width: 2.6, height: 2.8 }),
|
|
4226
|
+
mouth: band({ from: [10.2, 19], to: [13.8, 19], bend: 0.6, thickness: 1.1, endShare: 0.7 }),
|
|
4227
|
+
}),
|
|
4228
|
+
disappointed: symmetricFace({
|
|
4229
|
+
brow: blob({ centre: [6.2, 5.6], width: 5, height: 1.8, squareness: 0.5, rotation: -8 }),
|
|
4230
|
+
eye: blob({ centre: [6.4, 12.8], width: 8.4, height: 7.6, cutTop: [11.8, 11] }),
|
|
4231
|
+
pupil: blob({ centre: [6.6, 14.4], width: 3, height: 3 }),
|
|
4232
|
+
mouth: band({ from: [9.6, 19.2], to: [14.4, 19.2], bend: 0.4, thickness: 1.1, endShare: 0.8 }),
|
|
4233
|
+
}),
|
|
4234
|
+
ashamed: symmetricFace({
|
|
4235
|
+
brow: blob({ centre: [6.4, 5.6], width: 4.6, height: 1.8, squareness: 0.5, rotation: -16 }),
|
|
4236
|
+
eye: blob({ centre: [6.4, 13], width: 8.2, height: 7.6, cutTop: [12.2, 12.2] }),
|
|
4237
|
+
pupil: blob({ centre: [6.4, 15], width: 3, height: 2.6 }),
|
|
4238
|
+
mouth: blob({ centre: [12, 19.4], width: 2.4, height: 0.9, squareness: 0.6 }),
|
|
4239
|
+
}),
|
|
4240
|
+
embarrassed: {
|
|
4241
|
+
...symmetricFace({
|
|
4242
|
+
brow: blob({ centre: [6.2, 4.8], width: 4.8, height: 1.8, squareness: 0.5, rotation: -12 }),
|
|
4243
|
+
eye: blob({ centre: [6.4, 12.4], width: 8.2, height: 8.2 }),
|
|
4244
|
+
pupil: blob({ centre: [4.4, 13.6], width: 3, height: 3 }),
|
|
4245
|
+
mouth: band({ from: [9.8, 18.8], to: [14.2, 18.6], bend: -0.5, thickness: 1.1, endShare: 0.7 }),
|
|
4246
|
+
}),
|
|
4247
|
+
rightPupil: blob({ centre: [15.8, 13.6], width: 3, height: 3 }),
|
|
4248
|
+
},
|
|
4249
|
+
yearning: symmetricFace({
|
|
4250
|
+
brow: blob({ centre: [6.2, 4], width: 4.8, height: 1.8, squareness: 0.5, rotation: -10 }),
|
|
4251
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.6, height: 8, cutTop: [11, 11] }),
|
|
4252
|
+
pupil: blob({ centre: [6.8, 13.6], width: 4.2, height: 4.2 }),
|
|
4253
|
+
mouth: blob({ centre: [12, 18.8], width: 2.6, height: 1.6 }),
|
|
4254
|
+
}),
|
|
4255
|
+
triumphant: symmetricFace({
|
|
4256
|
+
brow: blob({ centre: [6.2, 4], width: 5.2, height: 2, squareness: 0.5, rotation: 8 }),
|
|
4257
|
+
eye: blob({ centre: [6.4, 11.8], width: 8.6, height: 8.6, cutBottom: [14, 14] }),
|
|
4258
|
+
pupil: blob({ centre: [6.8, 11.6], width: 3.4, height: 3.4 }),
|
|
4259
|
+
mouth: blob({ centre: [12, 16.8], width: 7, height: 6.4, squareness: 0.3, cutTop: [16.8, 16.8] }),
|
|
4260
|
+
}),
|
|
4261
|
+
proud: symmetricFace({
|
|
4262
|
+
brow: blob({ centre: [6.2, 3.8], width: 5, height: 1.9, squareness: 0.5, rotation: -4 }),
|
|
4263
|
+
eye: blob({ centre: [6.4, 12.4], width: 8.6, height: 8, cutTop: [11, 11] }),
|
|
4264
|
+
pupil: blob({ centre: [6.6, 13.6], width: 3.2, height: 3 }),
|
|
4265
|
+
mouth: band({ from: [8.8, 18], to: [15.2, 18], bend: -1.2, thickness: 1.3, endShare: 0.7 }),
|
|
4266
|
+
}),
|
|
4267
|
+
excited: symmetricFace({
|
|
4268
|
+
brow: band({ from: [3.4, 3.6], to: [8.6, 2.8], bend: 0.8, thickness: 1.6 }),
|
|
4269
|
+
eye: blob({ centre: [6.4, 12], width: 9, height: 9.2 }),
|
|
4270
|
+
pupil: blob({ centre: [7, 11], width: 4, height: 4 }),
|
|
4271
|
+
mouth: blob({ centre: [12, 16.8], width: 6, height: 6, squareness: 0.2, cutTop: [16.8, 16.8] }),
|
|
4272
|
+
}),
|
|
4273
|
+
scornful: {
|
|
4274
|
+
...symmetricFace({
|
|
4275
|
+
brow: blob({ centre: [6.2, 5.4], width: 5, height: 1.8, squareness: 0.5, rotation: 2 }),
|
|
4276
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 11.4] }),
|
|
4277
|
+
pupil: blob({ centre: [5.4, 14], width: 3, height: 3 }),
|
|
4278
|
+
mouth: band({ from: [9.2, 18.8], to: [14.6, 18], bend: -0.2, thickness: 1.1, endShare: 0.7 }),
|
|
4279
|
+
}),
|
|
4280
|
+
rightBrow: mirrored(band({ from: [3.6, 4], to: [8.6, 3.2], bend: 0.6, thickness: 1.6 })),
|
|
4281
|
+
rightPupil: blob({ centre: [16.6, 14], width: 3, height: 3 }),
|
|
4282
|
+
},
|
|
4283
|
+
disgusted: symmetricFace({
|
|
4284
|
+
brow: blob({ centre: [6.6, 6], width: 5, height: 1.8, squareness: 0.5, rotation: 14 }),
|
|
4285
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 12.2], cutBottom: [14.8, 14.8] }),
|
|
4286
|
+
pupil: blob({ centre: [6.8, 13.2], width: 3, height: 2.8 }),
|
|
4287
|
+
// an upside-down "D": open, corners pulled down
|
|
4288
|
+
mouth: blob({ centre: [12, 19.6], width: 5, height: 4.8, squareness: 0.2, cutBottom: [19.6, 19.6] }),
|
|
4289
|
+
}),
|
|
4290
|
+
};
|
|
4291
|
+
};
|
|
4292
|
+
let builtExpressions = null;
|
|
4293
|
+
/** Every expression's pieces, built on first use (nothing runs at import). */
|
|
4294
|
+
const faceExpressionPieces = () => (builtExpressions ??= buildFaceExpressions());
|
|
4295
|
+
// ---- morphing and drawing ----
|
|
4296
|
+
const interpolatePoints = (from, to, progress) => from.map(([fromX, fromY], index) => [fromX + (to[index][0] - fromX) * progress, fromY + (to[index][1] - fromY) * progress]);
|
|
4297
|
+
const interpolateFace = (from, to, progress) => Object.fromEntries(PIECE_NAMES.map((name) => [name, interpolatePoints(from[name], to[name], progress)]));
|
|
4298
|
+
const boxCentre = (points) => {
|
|
4299
|
+
const xs = points.map(([x]) => x);
|
|
4300
|
+
const ys = points.map(([, y]) => y);
|
|
4301
|
+
return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
|
|
4302
|
+
};
|
|
4303
|
+
/** The face with its eyes partly closed: openness 1 = as drawn, 0 = shut to a flat slit (pupils gone). */
|
|
4304
|
+
const blinked = (face, openness) => {
|
|
4305
|
+
if (openness >= 1)
|
|
4306
|
+
return face;
|
|
4307
|
+
const eyeScale = 0.25 + 0.75 * openness;
|
|
4308
|
+
const squash = (points, middleY) => points.map(([x, y]) => [x, middleY + (y - middleY) * eyeScale]);
|
|
4309
|
+
const shrink = (points, [centreX, centreY]) => points.map(([x, y]) => [centreX + (x - centreX) * openness, centreY + (y - centreY) * openness]);
|
|
4310
|
+
const leftMiddle = boxCentre(face.leftEye)[1];
|
|
4311
|
+
const rightMiddle = boxCentre(face.rightEye)[1];
|
|
4312
|
+
return {
|
|
4313
|
+
...face,
|
|
4314
|
+
leftEye: squash(face.leftEye, leftMiddle),
|
|
4315
|
+
rightEye: squash(face.rightEye, rightMiddle),
|
|
4316
|
+
leftPupil: squash(shrink(face.leftPupil, boxCentre(face.leftPupil)), leftMiddle),
|
|
4317
|
+
rightPupil: squash(shrink(face.rightPupil, boxCentre(face.rightPupil)), rightMiddle),
|
|
4318
|
+
};
|
|
4319
|
+
};
|
|
4320
|
+
const formatNumber = (value) => String(Math.round(value * 1000) / 1000);
|
|
4321
|
+
/** A smooth closed curve through the points: each handle points along the neighbours' chord and is a third of its own
|
|
4322
|
+
* segment long (no loops where points sit close together, as at a band's round end). Empty for a hidden piece. */
|
|
4323
|
+
const smoothClosedPath = (points) => {
|
|
4324
|
+
const [firstX, firstY] = points[0];
|
|
4325
|
+
if (points.every(([x, y]) => Math.abs(x - firstX) < 1e-6 && Math.abs(y - firstY) < 1e-6))
|
|
4326
|
+
return '';
|
|
4327
|
+
const count = points.length;
|
|
4328
|
+
const directionAt = (index) => {
|
|
4329
|
+
const previous = points[(index - 1 + count) % count];
|
|
4330
|
+
const next = points[(index + 1) % count];
|
|
4331
|
+
const length = Math.hypot(next[0] - previous[0], next[1] - previous[1]) || 1;
|
|
4332
|
+
return [(next[0] - previous[0]) / length, (next[1] - previous[1]) / length];
|
|
4333
|
+
};
|
|
4334
|
+
let path = `M${formatNumber(firstX)} ${formatNumber(firstY)}`;
|
|
4335
|
+
for (let index = 0; index < count; index++) {
|
|
4336
|
+
const start = points[index];
|
|
4337
|
+
const end = points[(index + 1) % count];
|
|
4338
|
+
const handleLength = Math.hypot(end[0] - start[0], end[1] - start[1]) / 3;
|
|
4339
|
+
const startDirection = directionAt(index);
|
|
4340
|
+
const endDirection = directionAt(index + 1);
|
|
4341
|
+
const control1 = [start[0] + startDirection[0] * handleLength, start[1] + startDirection[1] * handleLength];
|
|
4342
|
+
const control2 = [end[0] - endDirection[0] * handleLength, end[1] - endDirection[1] * handleLength];
|
|
4343
|
+
path += `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`;
|
|
4344
|
+
}
|
|
4345
|
+
return `${path}Z`;
|
|
4346
|
+
};
|
|
4347
|
+
const facePaths = (face) => ({
|
|
4348
|
+
features: [face.leftBrow, face.rightBrow, face.leftEye, face.rightEye, face.mouth].map(smoothClosedPath).join(''),
|
|
4349
|
+
eyes: [face.leftEye, face.rightEye].map(smoothClosedPath).join(''),
|
|
4350
|
+
pupils: [face.leftPupil, face.rightPupil].map(smoothClosedPath).join(''),
|
|
4351
|
+
});
|
|
4352
|
+
return { OUTLINE_POINTS, PIECE_NAMES, faceExpressionNames, faceExpressionPieces, interpolateFace, blinked, smoothClosedPath, facePaths };
|
|
4353
|
+
})();
|
|
4354
|
+
|
|
4355
|
+
// face.ts
|
|
4356
|
+
const { face } = (() => {
|
|
4357
|
+
// Layer 4: a face that morphs between feelings. 7 soft filled pieces (sets/faces.ts) — brows, eyes and mouth in the
|
|
4358
|
+
// CSS color, the pupils in their own color and clipped to the eyes. Blinking runs on top of any expression, by itself
|
|
4359
|
+
// every few seconds (option) or on blink().
|
|
4360
|
+
const BLINK_CLOSING = 90;
|
|
4361
|
+
const BLINK_SHUT = 50;
|
|
4362
|
+
const BLINK_OPENING = 150;
|
|
4363
|
+
const AUTO_BLINK_MIN_PAUSE = 2500;
|
|
4364
|
+
const AUTO_BLINK_EXTRA_PAUSE = 3500;
|
|
4365
|
+
const DEFAULT_FACE_DURATION = DEFAULT_DURATION + 100;
|
|
4366
|
+
/** Expressions whose eyes are already shut: blinking does nothing. */
|
|
4367
|
+
const EYES_SHUT = ['asleep', 'laughing'];
|
|
4368
|
+
let faceCount = 0;
|
|
4369
|
+
const checkedExpression = (state) => {
|
|
4370
|
+
if (!faceExpressionNames.includes(state))
|
|
4371
|
+
throw new Error(`morphico: face() has no expression "${state}"`);
|
|
4372
|
+
return state;
|
|
4373
|
+
};
|
|
4374
|
+
const resolveEasing = (easing) => (typeof easing === 'function' ? easing : easings[easing ?? 'easeInOutCubic']);
|
|
4375
|
+
/** Adds a morphing face (an <svg>) to `element`. */
|
|
4376
|
+
const face = (element, options = {}) => {
|
|
4377
|
+
const { state: firstState = 'normal', pupilColor = 'white', blink: blinksByItself = true, size = '100%', ...defaultMorphOptions } = options;
|
|
4378
|
+
const expressions = faceExpressionPieces();
|
|
4379
|
+
let state = checkedExpression(firstState);
|
|
4380
|
+
let shownPieces = expressions[state];
|
|
4381
|
+
let openness = 1;
|
|
4382
|
+
const clipId = `morphico-face-${++faceCount}`;
|
|
4383
|
+
const svg = createSvgElement('svg', { viewBox: '0 0 24 24', fill: 'currentColor', role: 'img' });
|
|
4384
|
+
svg.style.width = size;
|
|
4385
|
+
svg.style.height = size;
|
|
4386
|
+
svg.style.overflow = 'visible';
|
|
4387
|
+
const clipPath = svg.appendChild(createSvgElement('clipPath', { id: clipId }));
|
|
4388
|
+
const eyesPath = clipPath.appendChild(createSvgElement('path'));
|
|
4389
|
+
const featuresPath = svg.appendChild(createSvgElement('path'));
|
|
4390
|
+
const pupilsPath = svg.appendChild(createSvgElement('path', { fill: pupilColor, 'clip-path': `url(#${clipId})` }));
|
|
4391
|
+
const render = () => {
|
|
4392
|
+
const paths = facePaths(blinked(shownPieces, openness));
|
|
4393
|
+
featuresPath.setAttribute('d', paths.features);
|
|
4394
|
+
eyesPath.setAttribute('d', paths.eyes);
|
|
4395
|
+
pupilsPath.setAttribute('d', paths.pupils);
|
|
4396
|
+
};
|
|
4397
|
+
const updateLabel = () => svg.setAttribute('aria-label', state);
|
|
4398
|
+
render();
|
|
4399
|
+
updateLabel();
|
|
4400
|
+
element.appendChild(svg);
|
|
4401
|
+
// ---- morphing ----
|
|
4402
|
+
let morph = null;
|
|
4403
|
+
const endMorph = (interrupted) => {
|
|
4404
|
+
if (!morph)
|
|
4405
|
+
return;
|
|
4406
|
+
const { stopAnimation, resolve, state: morphState } = morph;
|
|
4407
|
+
morph = null;
|
|
4408
|
+
stopAnimation();
|
|
4409
|
+
resolve({ state: morphState, interrupted });
|
|
4410
|
+
};
|
|
4411
|
+
const morphTo = (nextState, morphOptions) => {
|
|
4412
|
+
endMorph(true);
|
|
4413
|
+
state = nextState;
|
|
4414
|
+
updateLabel();
|
|
4415
|
+
const fromPieces = shownPieces;
|
|
4416
|
+
const toPieces = expressions[nextState];
|
|
4417
|
+
const duration = morphOptions.duration ?? DEFAULT_FACE_DURATION;
|
|
4418
|
+
if (prefersReducedMotion() || duration <= 0) {
|
|
4419
|
+
shownPieces = toPieces;
|
|
4420
|
+
render();
|
|
4421
|
+
return Promise.resolve({ state: nextState, interrupted: false });
|
|
4422
|
+
}
|
|
4423
|
+
const easing = resolveEasing(morphOptions.easing);
|
|
4424
|
+
let startTime = null;
|
|
4425
|
+
return new Promise((resolve) => {
|
|
4426
|
+
const stopAnimation = animate((frameTime) => {
|
|
4427
|
+
startTime ??= frameTime;
|
|
4428
|
+
const progress = Math.min((frameTime - startTime) / duration, 1);
|
|
4429
|
+
shownPieces = interpolateFace(fromPieces, toPieces, easing(progress));
|
|
4430
|
+
render();
|
|
4431
|
+
if (progress < 1)
|
|
4432
|
+
return true;
|
|
4433
|
+
endMorph(false);
|
|
4434
|
+
return false;
|
|
4435
|
+
});
|
|
4436
|
+
morph = { stopAnimation, resolve, state: nextState };
|
|
4437
|
+
});
|
|
4438
|
+
};
|
|
4439
|
+
// ---- blinking ----
|
|
4440
|
+
let stopBlink = null;
|
|
4441
|
+
let finishBlink = null;
|
|
4442
|
+
const endBlink = () => {
|
|
4443
|
+
stopBlink?.();
|
|
4444
|
+
stopBlink = null;
|
|
4445
|
+
if (openness < 1) {
|
|
4446
|
+
openness = 1;
|
|
4447
|
+
render();
|
|
4448
|
+
}
|
|
4449
|
+
finishBlink?.();
|
|
4450
|
+
finishBlink = null;
|
|
4451
|
+
};
|
|
4452
|
+
const blink = () => {
|
|
4453
|
+
if (EYES_SHUT.includes(state) || prefersReducedMotion())
|
|
4454
|
+
return Promise.resolve();
|
|
4455
|
+
endBlink();
|
|
4456
|
+
let startTime = null;
|
|
4457
|
+
return new Promise((resolve) => {
|
|
4458
|
+
finishBlink = resolve;
|
|
4459
|
+
stopBlink = animate((frameTime) => {
|
|
4460
|
+
startTime ??= frameTime;
|
|
4461
|
+
const elapsed = frameTime - startTime;
|
|
4462
|
+
if (elapsed < BLINK_CLOSING)
|
|
4463
|
+
openness = 1 - elapsed / BLINK_CLOSING;
|
|
4464
|
+
else if (elapsed < BLINK_CLOSING + BLINK_SHUT)
|
|
4465
|
+
openness = 0;
|
|
4466
|
+
else
|
|
4467
|
+
openness = Math.min((elapsed - BLINK_CLOSING - BLINK_SHUT) / BLINK_OPENING, 1);
|
|
4468
|
+
render();
|
|
4469
|
+
if (openness < 1 || elapsed < BLINK_CLOSING)
|
|
4470
|
+
return true;
|
|
4471
|
+
stopBlink = null;
|
|
4472
|
+
finishBlink = null;
|
|
4473
|
+
resolve();
|
|
4474
|
+
return false;
|
|
4475
|
+
});
|
|
4476
|
+
});
|
|
4477
|
+
};
|
|
4478
|
+
let autoBlinkTimer;
|
|
4479
|
+
const scheduleAutoBlink = () => {
|
|
4480
|
+
autoBlinkTimer = setTimeout(() => {
|
|
4481
|
+
if (typeof document === 'undefined' || !document.hidden)
|
|
4482
|
+
void blink();
|
|
4483
|
+
scheduleAutoBlink();
|
|
4484
|
+
}, AUTO_BLINK_MIN_PAUSE + Math.random() * AUTO_BLINK_EXTRA_PAUSE);
|
|
4485
|
+
};
|
|
4486
|
+
if (blinksByItself && !prefersReducedMotion())
|
|
4487
|
+
scheduleAutoBlink();
|
|
4488
|
+
return {
|
|
4489
|
+
get state() { return state; },
|
|
4490
|
+
to(nextState, { onEnd, ...morphOptions } = {}) {
|
|
4491
|
+
const result = morphTo(checkedExpression(nextState), { ...defaultMorphOptions, ...morphOptions });
|
|
4492
|
+
if (onEnd)
|
|
4493
|
+
result.then(onEnd);
|
|
4494
|
+
return result;
|
|
4495
|
+
},
|
|
4496
|
+
blink,
|
|
4497
|
+
destroy() {
|
|
4498
|
+
clearTimeout(autoBlinkTimer);
|
|
4499
|
+
endMorph(true);
|
|
4500
|
+
endBlink();
|
|
4501
|
+
svg.remove();
|
|
4502
|
+
},
|
|
4503
|
+
};
|
|
4504
|
+
};
|
|
4505
|
+
return { face };
|
|
4506
|
+
})();
|
|
4507
|
+
|
|
3869
4508
|
// icon.ts
|
|
3870
4509
|
const { icon } = (() => {
|
|
3871
4510
|
// Layer 1: one icon that morphs between the states of a set, e.g. a single digit.
|
|
@@ -4000,7 +4639,7 @@ return { text };
|
|
|
4000
4639
|
// index.ts
|
|
4001
4640
|
// morphico public API.
|
|
4002
4641
|
/** Package version, e.g. "0.1.0" (tools/build.ts replaces 'dev'). */
|
|
4003
|
-
const version = '0.
|
|
4642
|
+
const version = '0.4.0';
|
|
4004
4643
|
|
|
4005
|
-
return { clip, easings, icon, iconSets, text, version };
|
|
4644
|
+
return { clip, easings, face, icon, iconSets, faceExpressionNames, text, version };
|
|
4006
4645
|
})();
|