@motionscript/latex 0.0.0-stage → 0.1.0-alpha.3

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.
@@ -0,0 +1,44 @@
1
+ import type { LatexToken } from "./geometry.js";
2
+ export interface AnimatedToken {
3
+ token: string;
4
+ path: LatexToken["path"];
5
+ /** 0 = invisible, 1 = fully visible */
6
+ opacity: number;
7
+ /** Interpolated position offset applied during morph (x, y in formula space). */
8
+ x: number;
9
+ y: number;
10
+ }
11
+ /**
12
+ * How a `Latex` node interpolates between two formulas: given the tokens it
13
+ * currently holds and the tokens the target formula resolves to, return a
14
+ * pure `t → AnimatedToken[]` frame function. `prepareLatexTween` below is the
15
+ * default implementation (exported as `defaultLatexMorph`) — pass a `morph`
16
+ * of this shape to `<Latex>` to replace it with your own.
17
+ */
18
+ export type LatexMorphStrategy = (from: LatexToken[], to: LatexToken[]) => (t: number) => AnimatedToken[];
19
+ /**
20
+ * Precompute a formula-change morph and return a pure `t → AnimatedToken[]`
21
+ * frame function:
22
+ * - Deleted tokens fade out over the first half of `t`.
23
+ * - Matched tokens are interpolated — place *and* size — across the full range
24
+ * of `t`.
25
+ * - Added tokens fade in over the second half of `t`.
26
+ *
27
+ * A matched glyph is rarely the same size at both ends: the `2` of `b^2` is set
28
+ * at script size and the `2` of `2a` at full size, and a `\frac`'s arguments
29
+ * come back a step smaller than the same symbols on a baseline. So the
30
+ * interpolation has to carry the glyph's *geometry*, not just where it sits.
31
+ * Sliding the target outline from one centroid to the other — which is all this
32
+ * used to do — put the whole size change between the last static frame and the
33
+ * morph's first one: a snap to the new size, then a smooth glide to the new
34
+ * place. Point-by-point lerping (see {@link lerpPath}) is that same
35
+ * interpolation generalised from a glyph's average point to all of them, and it
36
+ * costs nothing extra — the path was already being rebuilt every frame to apply
37
+ * the slide.
38
+ *
39
+ * `t` is normalized `[0, 1]` and already eased — the caller (a `Command`'s
40
+ * `at`) applies easing once, up front, the same eased value driving every
41
+ * concurrent aspect of the morph (props, intrinsic size, tokens) in lockstep.
42
+ */
43
+ export declare function prepareLatexTween(from: LatexToken[], to: LatexToken[]): (t: number) => AnimatedToken[];
44
+ //# sourceMappingURL=tween.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tween.d.ts","sourceRoot":"","sources":["../src/tween.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,aAAa;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC;IACzB,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;;;;GAMG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,IAAI,EAAE,UAAU,EAAE,EAAE,EAAE,EAAE,UAAU,EAAE,KAAK,CAAC,CAAC,EAAE,MAAM,KAAK,aAAa,EAAE,CAAC;AA2J1G;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,iBAAiB,CAC7B,IAAI,EAAE,UAAU,EAAE,EAClB,EAAE,EAAE,UAAU,EAAE,GACjB,CAAC,CAAC,EAAE,MAAM,KAAK,aAAa,EAAE,CAmFhC"}
package/dist/tween.js ADDED
@@ -0,0 +1,233 @@
1
+ import { lerpNumber } from "@motionscript/sdk";
2
+ /** The coordinate pairs a `PathCommand` can carry, as (x, y) field names. */
3
+ const POINT_FIELDS = [["x", "y"], ["x1", "y1"], ["x2", "y2"]];
4
+ /**
5
+ * Compute the centroid of a token's path for position-based interpolation.
6
+ */
7
+ function centroid(path) {
8
+ let sx = 0, sy = 0, n = 0;
9
+ for (const cmd of path) {
10
+ if ("x" in cmd && "y" in cmd) {
11
+ sx += cmd.x;
12
+ sy += cmd.y;
13
+ n++;
14
+ }
15
+ }
16
+ return n > 0 ? { x: sx / n, y: sy / n } : { x: 0, y: 0 };
17
+ }
18
+ /**
19
+ * A glyph's on-screen extent, as the diagonal of its control-point bbox.
20
+ *
21
+ * Only ever used as a *ratio* between the two ends of a match, so the fact that
22
+ * control points overshoot the true outline doesn't matter: both ends overshoot
23
+ * by the same proportion, because they are the same outline.
24
+ */
25
+ function extent(path) {
26
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
27
+ for (const cmd of path) {
28
+ const c = cmd;
29
+ for (const [kx, ky] of POINT_FIELDS) {
30
+ if (kx in c) {
31
+ if (c[kx] < minX)
32
+ minX = c[kx];
33
+ if (c[kx] > maxX)
34
+ maxX = c[kx];
35
+ if (c[ky] < minY)
36
+ minY = c[ky];
37
+ if (c[ky] > maxY)
38
+ maxY = c[ky];
39
+ }
40
+ }
41
+ }
42
+ if (minX > maxX)
43
+ return 0;
44
+ return Math.hypot(maxX - minX, maxY - minY);
45
+ }
46
+ /**
47
+ * Whether two paths can be interpolated point by point: same command count,
48
+ * same command types in the same order.
49
+ *
50
+ * For a matched pair this is the overwhelmingly common case, and not by luck.
51
+ * Tokens are matched on the character their MathJax glyph id decodes to, and
52
+ * that id *is* the key into the `<defs>` dictionary — so the same character in
53
+ * two formulas is the same outline, emitted twice under two different
54
+ * transforms. Same commands, different numbers.
55
+ */
56
+ function isPointwiseCompatible(from, to) {
57
+ if (from.length !== to.length)
58
+ return false;
59
+ for (let i = 0; i < from.length; i++) {
60
+ if (from[i].type !== to[i].type)
61
+ return false;
62
+ }
63
+ return true;
64
+ }
65
+ /**
66
+ * Lerp two structurally identical paths point by point.
67
+ *
68
+ * When the two ends are the same outline under two affine transforms — which is
69
+ * what a matched glyph is — this is exact at both ends *and* correct in
70
+ * between: `(1-t)·A·q + t·B·q = ((1-t)A + tB)·q`, and a blend of two
71
+ * uniform-scale-plus-translate transforms is another one. The intermediate is a
72
+ * properly formed glyph at an intermediate size, not a smeared one.
73
+ */
74
+ function lerpPath(from, to, t) {
75
+ const out = new Array(from.length);
76
+ for (let i = 0; i < from.length; i++) {
77
+ const f = from[i];
78
+ const g = to[i];
79
+ const c = { ...g };
80
+ for (const [kx, ky] of POINT_FIELDS) {
81
+ if (kx in g && kx in f) {
82
+ c[kx] = lerpNumber(f[kx], g[kx], t);
83
+ c[ky] = lerpNumber(f[ky], g[ky], t);
84
+ }
85
+ }
86
+ out[i] = c;
87
+ }
88
+ return out;
89
+ }
90
+ /**
91
+ * Scale a path uniformly about (cx, cy), then translate by (dx, dy).
92
+ *
93
+ * The fallback for a matched pair whose paths *aren't* structurally identical —
94
+ * a stretchy delimiter assembled from a different number of pieces at the two
95
+ * sizes, say. Point-by-point lerping is impossible there, so the target outline
96
+ * is shrunk to the source's size instead and grown back over the morph: the
97
+ * size still changes continuously, which is the whole point, at the cost of
98
+ * showing the target's shape from the start.
99
+ */
100
+ function scaleAbout(path, cx, cy, s, dx, dy) {
101
+ return path.map(cmd => {
102
+ const c = { ...cmd };
103
+ for (const [kx, ky] of POINT_FIELDS) {
104
+ if (kx in c) {
105
+ c[kx] = cx + (c[kx] - cx) * s + dx;
106
+ c[ky] = cy + (c[ky] - cy) * s + dy;
107
+ }
108
+ }
109
+ return c;
110
+ });
111
+ }
112
+ /**
113
+ * Greedily match tokens from `from` to `to` by character key.
114
+ * Returns three lists: matched pairs, deleted tokens (only in from), added tokens (only in to).
115
+ */
116
+ function matchTokens(from, to) {
117
+ const remaining = [...to];
118
+ const matched = [];
119
+ const deleted = [];
120
+ for (const ft of from) {
121
+ // Skip synthetic shapes (rects/paths) — they don't have a natural token key
122
+ if (ft.token.startsWith("__")) {
123
+ deleted.push(ft);
124
+ continue;
125
+ }
126
+ const idx = remaining.findIndex(t => t.token === ft.token && !t.token.startsWith("__"));
127
+ if (idx !== -1) {
128
+ matched.push({ from: ft, to: remaining[idx] });
129
+ remaining.splice(idx, 1);
130
+ }
131
+ else {
132
+ deleted.push(ft);
133
+ }
134
+ }
135
+ // Remaining to-tokens that weren't matched
136
+ const added = remaining;
137
+ return { matched, deleted, added };
138
+ }
139
+ /**
140
+ * Precompute a formula-change morph and return a pure `t → AnimatedToken[]`
141
+ * frame function:
142
+ * - Deleted tokens fade out over the first half of `t`.
143
+ * - Matched tokens are interpolated — place *and* size — across the full range
144
+ * of `t`.
145
+ * - Added tokens fade in over the second half of `t`.
146
+ *
147
+ * A matched glyph is rarely the same size at both ends: the `2` of `b^2` is set
148
+ * at script size and the `2` of `2a` at full size, and a `\frac`'s arguments
149
+ * come back a step smaller than the same symbols on a baseline. So the
150
+ * interpolation has to carry the glyph's *geometry*, not just where it sits.
151
+ * Sliding the target outline from one centroid to the other — which is all this
152
+ * used to do — put the whole size change between the last static frame and the
153
+ * morph's first one: a snap to the new size, then a smooth glide to the new
154
+ * place. Point-by-point lerping (see {@link lerpPath}) is that same
155
+ * interpolation generalised from a glyph's average point to all of them, and it
156
+ * costs nothing extra — the path was already being rebuilt every frame to apply
157
+ * the slide.
158
+ *
159
+ * `t` is normalized `[0, 1]` and already eased — the caller (a `Command`'s
160
+ * `at`) applies easing once, up front, the same eased value driving every
161
+ * concurrent aspect of the morph (props, intrinsic size, tokens) in lockstep.
162
+ */
163
+ export function prepareLatexTween(from, to) {
164
+ const { matched, deleted, added } = matchTokens(from, to);
165
+ // Per-match interpolation data. Structure compatibility, and the centroids
166
+ // and extents behind the fallback, are properties of the pair rather than
167
+ // of `t`, so they are settled once here instead of at every frame.
168
+ const matchedData = matched.map(({ from: f, to: t }) => {
169
+ const toExtent = extent(t.path);
170
+ return {
171
+ pointwise: isPointwiseCompatible(f.path, t.path),
172
+ fromPath: f.path,
173
+ toPath: t.path,
174
+ token: t.token,
175
+ fromCenter: centroid(f.path),
176
+ toCenter: centroid(t.path),
177
+ /** Only the fallback needs it, and only as a ratio. */
178
+ fromScale: toExtent > 0 ? extent(f.path) / toExtent : 1,
179
+ };
180
+ });
181
+ return (t) => {
182
+ const tokens = [];
183
+ // Matched tokens: interpolate place and size across the full duration.
184
+ for (const m of matchedData) {
185
+ if (m.pointwise) {
186
+ tokens.push({
187
+ token: m.token,
188
+ path: lerpPath(m.fromPath, m.toPath, t),
189
+ opacity: 1,
190
+ // Baked into the path above: the lerp carries every point,
191
+ // which includes where the glyph sits.
192
+ x: 0,
193
+ y: 0,
194
+ });
195
+ continue;
196
+ }
197
+ const s = lerpNumber(m.fromScale, 1, t);
198
+ const cx = lerpNumber(m.fromCenter.x, m.toCenter.x, t);
199
+ const cy = lerpNumber(m.fromCenter.y, m.toCenter.y, t);
200
+ tokens.push({
201
+ token: m.token,
202
+ path: scaleAbout(m.toPath, m.toCenter.x, m.toCenter.y, s, cx - m.toCenter.x, cy - m.toCenter.y),
203
+ opacity: 1,
204
+ x: 0,
205
+ y: 0,
206
+ });
207
+ }
208
+ // Deleted tokens: fade out over the first half, gone by t=0.5
209
+ for (const d of deleted) {
210
+ const fadeT = Math.min(t * 2, 1);
211
+ tokens.push({
212
+ token: d.token,
213
+ path: d.path,
214
+ opacity: lerpNumber(1, 0, fadeT),
215
+ x: 0,
216
+ y: 0,
217
+ });
218
+ }
219
+ // Added tokens: fade in over the second half, starting at t=0.5
220
+ for (const a of added) {
221
+ const fadeT = Math.max(t * 2 - 1, 0);
222
+ tokens.push({
223
+ token: a.token,
224
+ path: a.path,
225
+ opacity: lerpNumber(0, 1, fadeT),
226
+ x: 0,
227
+ y: 0,
228
+ });
229
+ }
230
+ return tokens;
231
+ };
232
+ }
233
+ //# sourceMappingURL=tween.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tween.js","sourceRoot":"","sources":["../src/tween.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAoB,MAAM,mBAAmB,CAAC;AAsBjE,6EAA6E;AAC7E,MAAM,YAAY,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAU,CAAC;AAEvE;;GAEG;AACH,SAAS,QAAQ,CAAC,IAAwB;IACtC,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IAC1B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,EAAE,CAAC;YAC3B,EAAE,IAAK,GAAW,CAAC,CAAC,CAAC;YACrB,EAAE,IAAK,GAAW,CAAC,CAAC,CAAC;YACrB,CAAC,EAAE,CAAC;QACR,CAAC;IACL,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;AAC7D,CAAC;AAED;;;;;;GAMG;AACH,SAAS,MAAM,CAAC,IAAwB;IACpC,IAAI,IAAI,GAAG,QAAQ,EAAE,IAAI,GAAG,QAAQ,EAAE,IAAI,GAAG,CAAC,QAAQ,EAAE,IAAI,GAAG,CAAC,QAAQ,CAAC;IACzE,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACrB,MAAM,CAAC,GAAG,GAAU,CAAC;QACrB,KAAK,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,YAAY,EAAE,CAAC;YAClC,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC;gBACV,IAAI,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI;oBAAE,IAAI,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;gBAC/B,IAAI,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI;oBAAE,IAAI,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;gBAC/B,IAAI,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI;oBAAE,IAAI,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;gBAC/B,IAAI,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI;oBAAE,IAAI,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;YACnC,CAAC;QACL,CAAC;IACL,CAAC;IACD,IAAI,IAAI,GAAG,IAAI;QAAE,OAAO,CAAC,CAAC;IAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,qBAAqB,CAAC,IAAmB,EAAE,EAAiB;IACjE,IAAI,IAAI,CAAC,MAAM,KAAK,EAAE,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC5C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACnC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI;YAAE,OAAO,KAAK,CAAC;IAClD,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,QAAQ,CAAC,IAAmB,EAAE,EAAiB,EAAE,CAAS;IAC/D,MAAM,GAAG,GAAkB,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAClD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACnC,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAQ,CAAC;QACzB,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAQ,CAAC;QACvB,MAAM,CAAC,GAAQ,EAAE,GAAG,CAAC,EAAE,CAAC;QACxB,KAAK,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,YAAY,EAAE,CAAC;YAClC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC;gBACrB,CAAC,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;gBACpC,CAAC,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;YACxC,CAAC;QACL,CAAC;QACD,GAAG,CAAC,CAAC,CAAC,GAAG,CAAgB,CAAC;IAC9B,CAAC;IACD,OAAO,GAAG,CAAC;AACf,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,UAAU,CACf,IAAmB,EACnB,EAAU,EACV,EAAU,EACV,CAAS,EACT,EAAU,EACV,EAAU;IAEV,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE;QAClB,MAAM,CAAC,GAAQ,EAAE,GAAI,GAAW,EAAE,CAAC;QACnC,KAAK,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,YAAY,EAAE,CAAC;YAClC,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC;gBACV,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC;gBACnC,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC;YACvC,CAAC;QACL,CAAC;QACD,OAAO,CAAgB,CAAC;IAC5B,CAAC,CAAC,CAAC;AACP,CAAC;AAED;;;GAGG;AACH,SAAS,WAAW,CAChB,IAAkB,EAClB,EAAgB;IAMhB,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;IAC1B,MAAM,OAAO,GAAgD,EAAE,CAAC;IAChE,MAAM,OAAO,GAAiB,EAAE,CAAC;IAEjC,KAAK,MAAM,EAAE,IAAI,IAAI,EAAE,CAAC;QACpB,4EAA4E;QAC5E,IAAI,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YAC5B,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACjB,SAAS;QACb,CAAC;QACD,MAAM,GAAG,GAAG,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;QACxF,IAAI,GAAG,KAAK,CAAC,CAAC,EAAE,CAAC;YACb,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAC/C,SAAS,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;QAC7B,CAAC;aAAM,CAAC;YACJ,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACrB,CAAC;IACL,CAAC;IAED,2CAA2C;IAC3C,MAAM,KAAK,GAAG,SAAS,CAAC;IAExB,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AACvC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,iBAAiB,CAC7B,IAAkB,EAClB,EAAgB;IAEhB,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,WAAW,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IAE1D,2EAA2E;IAC3E,0EAA0E;IAC1E,mEAAmE;IACnE,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE;QACnD,MAAM,QAAQ,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAChC,OAAO;YACH,SAAS,EAAE,qBAAqB,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC;YAChD,QAAQ,EAAE,CAAC,CAAC,IAAI;YAChB,MAAM,EAAE,CAAC,CAAC,IAAI;YACd,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,UAAU,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC;YAC5B,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC;YAC1B,uDAAuD;YACvD,SAAS,EAAE,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;SAC1D,CAAC;IACN,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,CAAS,EAAmB,EAAE;QAClC,MAAM,MAAM,GAAoB,EAAE,CAAC;QAEnC,uEAAuE;QACvE,KAAK,MAAM,CAAC,IAAI,WAAW,EAAE,CAAC;YAC1B,IAAI,CAAC,CAAC,SAAS,EAAE,CAAC;gBACd,MAAM,CAAC,IAAI,CAAC;oBACR,KAAK,EAAE,CAAC,CAAC,KAAK;oBACd,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;oBACvC,OAAO,EAAE,CAAC;oBACV,2DAA2D;oBAC3D,uCAAuC;oBACvC,CAAC,EAAE,CAAC;oBACJ,CAAC,EAAE,CAAC;iBACP,CAAC,CAAC;gBACH,SAAS;YACb,CAAC;YAED,MAAM,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;YACxC,MAAM,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACvD,MAAM,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACvD,MAAM,CAAC,IAAI,CAAC;gBACR,KAAK,EAAE,CAAC,CAAC,KAAK;gBACd,IAAI,EAAE,UAAU,CACZ,CAAC,CAAC,MAAM,EACR,CAAC,CAAC,QAAQ,CAAC,CAAC,EACZ,CAAC,CAAC,QAAQ,CAAC,CAAC,EACZ,CAAC,EACD,EAAE,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,EACjB,EAAE,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,CACpB;gBACD,OAAO,EAAE,CAAC;gBACV,CAAC,EAAE,CAAC;gBACJ,CAAC,EAAE,CAAC;aACP,CAAC,CAAC;QACP,CAAC;QAED,8DAA8D;QAC9D,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;YACtB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;YACjC,MAAM,CAAC,IAAI,CAAC;gBACR,KAAK,EAAE,CAAC,CAAC,KAAK;gBACd,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,OAAO,EAAE,UAAU,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC;gBAChC,CAAC,EAAE,CAAC;gBACJ,CAAC,EAAE,CAAC;aACP,CAAC,CAAC;QACP,CAAC;QAED,gEAAgE;QAChE,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;YACrC,MAAM,CAAC,IAAI,CAAC;gBACR,KAAK,EAAE,CAAC,CAAC,KAAK;gBACd,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,OAAO,EAAE,UAAU,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC;gBAChC,CAAC,EAAE,CAAC;gBACJ,CAAC,EAAE,CAAC;aACP,CAAC,CAAC;QACP,CAAC;QAED,OAAO,MAAM,CAAC;IAClB,CAAC,CAAC;AACN,CAAC","sourcesContent":["import { lerpNumber, type PathCommand } from \"@motionscript/sdk\";\nimport type { LatexToken } from \"./geometry\";\n\nexport interface AnimatedToken {\n token: string;\n path: LatexToken[\"path\"];\n /** 0 = invisible, 1 = fully visible */\n opacity: number;\n /** Interpolated position offset applied during morph (x, y in formula space). */\n x: number;\n y: number;\n}\n\n/**\n * How a `Latex` node interpolates between two formulas: given the tokens it\n * currently holds and the tokens the target formula resolves to, return a\n * pure `t → AnimatedToken[]` frame function. `prepareLatexTween` below is the\n * default implementation (exported as `defaultLatexMorph`) — pass a `morph`\n * of this shape to `<Latex>` to replace it with your own.\n */\nexport type LatexMorphStrategy = (from: LatexToken[], to: LatexToken[]) => (t: number) => AnimatedToken[];\n\n/** The coordinate pairs a `PathCommand` can carry, as (x, y) field names. */\nconst POINT_FIELDS = [[\"x\", \"y\"], [\"x1\", \"y1\"], [\"x2\", \"y2\"]] as const;\n\n/**\n * Compute the centroid of a token's path for position-based interpolation.\n */\nfunction centroid(path: LatexToken[\"path\"]): { x: number; y: number } {\n let sx = 0, sy = 0, n = 0;\n for (const cmd of path) {\n if (\"x\" in cmd && \"y\" in cmd) {\n sx += (cmd as any).x;\n sy += (cmd as any).y;\n n++;\n }\n }\n return n > 0 ? { x: sx / n, y: sy / n } : { x: 0, y: 0 };\n}\n\n/**\n * A glyph's on-screen extent, as the diagonal of its control-point bbox.\n *\n * Only ever used as a *ratio* between the two ends of a match, so the fact that\n * control points overshoot the true outline doesn't matter: both ends overshoot\n * by the same proportion, because they are the same outline.\n */\nfunction extent(path: LatexToken[\"path\"]): number {\n let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;\n for (const cmd of path) {\n const c = cmd as any;\n for (const [kx, ky] of POINT_FIELDS) {\n if (kx in c) {\n if (c[kx] < minX) minX = c[kx];\n if (c[kx] > maxX) maxX = c[kx];\n if (c[ky] < minY) minY = c[ky];\n if (c[ky] > maxY) maxY = c[ky];\n }\n }\n }\n if (minX > maxX) return 0;\n return Math.hypot(maxX - minX, maxY - minY);\n}\n\n/**\n * Whether two paths can be interpolated point by point: same command count,\n * same command types in the same order.\n *\n * For a matched pair this is the overwhelmingly common case, and not by luck.\n * Tokens are matched on the character their MathJax glyph id decodes to, and\n * that id *is* the key into the `<defs>` dictionary — so the same character in\n * two formulas is the same outline, emitted twice under two different\n * transforms. Same commands, different numbers.\n */\nfunction isPointwiseCompatible(from: PathCommand[], to: PathCommand[]): boolean {\n if (from.length !== to.length) return false;\n for (let i = 0; i < from.length; i++) {\n if (from[i].type !== to[i].type) return false;\n }\n return true;\n}\n\n/**\n * Lerp two structurally identical paths point by point.\n *\n * When the two ends are the same outline under two affine transforms — which is\n * what a matched glyph is — this is exact at both ends *and* correct in\n * between: `(1-t)·A·q + t·B·q = ((1-t)A + tB)·q`, and a blend of two\n * uniform-scale-plus-translate transforms is another one. The intermediate is a\n * properly formed glyph at an intermediate size, not a smeared one.\n */\nfunction lerpPath(from: PathCommand[], to: PathCommand[], t: number): PathCommand[] {\n const out: PathCommand[] = new Array(from.length);\n for (let i = 0; i < from.length; i++) {\n const f = from[i] as any;\n const g = to[i] as any;\n const c: any = { ...g };\n for (const [kx, ky] of POINT_FIELDS) {\n if (kx in g && kx in f) {\n c[kx] = lerpNumber(f[kx], g[kx], t);\n c[ky] = lerpNumber(f[ky], g[ky], t);\n }\n }\n out[i] = c as PathCommand;\n }\n return out;\n}\n\n/**\n * Scale a path uniformly about (cx, cy), then translate by (dx, dy).\n *\n * The fallback for a matched pair whose paths *aren't* structurally identical —\n * a stretchy delimiter assembled from a different number of pieces at the two\n * sizes, say. Point-by-point lerping is impossible there, so the target outline\n * is shrunk to the source's size instead and grown back over the morph: the\n * size still changes continuously, which is the whole point, at the cost of\n * showing the target's shape from the start.\n */\nfunction scaleAbout(\n path: PathCommand[],\n cx: number,\n cy: number,\n s: number,\n dx: number,\n dy: number,\n): PathCommand[] {\n return path.map(cmd => {\n const c: any = { ...(cmd as any) };\n for (const [kx, ky] of POINT_FIELDS) {\n if (kx in c) {\n c[kx] = cx + (c[kx] - cx) * s + dx;\n c[ky] = cy + (c[ky] - cy) * s + dy;\n }\n }\n return c as PathCommand;\n });\n}\n\n/**\n * Greedily match tokens from `from` to `to` by character key.\n * Returns three lists: matched pairs, deleted tokens (only in from), added tokens (only in to).\n */\nfunction matchTokens(\n from: LatexToken[],\n to: LatexToken[],\n): {\n matched: Array<{ from: LatexToken; to: LatexToken }>;\n deleted: LatexToken[];\n added: LatexToken[];\n} {\n const remaining = [...to];\n const matched: Array<{ from: LatexToken; to: LatexToken }> = [];\n const deleted: LatexToken[] = [];\n\n for (const ft of from) {\n // Skip synthetic shapes (rects/paths) — they don't have a natural token key\n if (ft.token.startsWith(\"__\")) {\n deleted.push(ft);\n continue;\n }\n const idx = remaining.findIndex(t => t.token === ft.token && !t.token.startsWith(\"__\"));\n if (idx !== -1) {\n matched.push({ from: ft, to: remaining[idx] });\n remaining.splice(idx, 1);\n } else {\n deleted.push(ft);\n }\n }\n\n // Remaining to-tokens that weren't matched\n const added = remaining;\n\n return { matched, deleted, added };\n}\n\n/**\n * Precompute a formula-change morph and return a pure `t → AnimatedToken[]`\n * frame function:\n * - Deleted tokens fade out over the first half of `t`.\n * - Matched tokens are interpolated — place *and* size — across the full range\n * of `t`.\n * - Added tokens fade in over the second half of `t`.\n *\n * A matched glyph is rarely the same size at both ends: the `2` of `b^2` is set\n * at script size and the `2` of `2a` at full size, and a `\\frac`'s arguments\n * come back a step smaller than the same symbols on a baseline. So the\n * interpolation has to carry the glyph's *geometry*, not just where it sits.\n * Sliding the target outline from one centroid to the other — which is all this\n * used to do — put the whole size change between the last static frame and the\n * morph's first one: a snap to the new size, then a smooth glide to the new\n * place. Point-by-point lerping (see {@link lerpPath}) is that same\n * interpolation generalised from a glyph's average point to all of them, and it\n * costs nothing extra — the path was already being rebuilt every frame to apply\n * the slide.\n *\n * `t` is normalized `[0, 1]` and already eased — the caller (a `Command`'s\n * `at`) applies easing once, up front, the same eased value driving every\n * concurrent aspect of the morph (props, intrinsic size, tokens) in lockstep.\n */\nexport function prepareLatexTween(\n from: LatexToken[],\n to: LatexToken[],\n): (t: number) => AnimatedToken[] {\n const { matched, deleted, added } = matchTokens(from, to);\n\n // Per-match interpolation data. Structure compatibility, and the centroids\n // and extents behind the fallback, are properties of the pair rather than\n // of `t`, so they are settled once here instead of at every frame.\n const matchedData = matched.map(({ from: f, to: t }) => {\n const toExtent = extent(t.path);\n return {\n pointwise: isPointwiseCompatible(f.path, t.path),\n fromPath: f.path,\n toPath: t.path,\n token: t.token,\n fromCenter: centroid(f.path),\n toCenter: centroid(t.path),\n /** Only the fallback needs it, and only as a ratio. */\n fromScale: toExtent > 0 ? extent(f.path) / toExtent : 1,\n };\n });\n\n return (t: number): AnimatedToken[] => {\n const tokens: AnimatedToken[] = [];\n\n // Matched tokens: interpolate place and size across the full duration.\n for (const m of matchedData) {\n if (m.pointwise) {\n tokens.push({\n token: m.token,\n path: lerpPath(m.fromPath, m.toPath, t),\n opacity: 1,\n // Baked into the path above: the lerp carries every point,\n // which includes where the glyph sits.\n x: 0,\n y: 0,\n });\n continue;\n }\n\n const s = lerpNumber(m.fromScale, 1, t);\n const cx = lerpNumber(m.fromCenter.x, m.toCenter.x, t);\n const cy = lerpNumber(m.fromCenter.y, m.toCenter.y, t);\n tokens.push({\n token: m.token,\n path: scaleAbout(\n m.toPath,\n m.toCenter.x,\n m.toCenter.y,\n s,\n cx - m.toCenter.x,\n cy - m.toCenter.y,\n ),\n opacity: 1,\n x: 0,\n y: 0,\n });\n }\n\n // Deleted tokens: fade out over the first half, gone by t=0.5\n for (const d of deleted) {\n const fadeT = Math.min(t * 2, 1);\n tokens.push({\n token: d.token,\n path: d.path,\n opacity: lerpNumber(1, 0, fadeT),\n x: 0,\n y: 0,\n });\n }\n\n // Added tokens: fade in over the second half, starting at t=0.5\n for (const a of added) {\n const fadeT = Math.max(t * 2 - 1, 0);\n tokens.push({\n token: a.token,\n path: a.path,\n opacity: lerpNumber(0, 1, fadeT),\n x: 0,\n y: 0,\n });\n }\n\n return tokens;\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "@motionscript/latex",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-alpha.3",
4
+ "type": "module",
5
+ "description": "Animated LaTeX math for Motion Script, rendering formulas to glyph paths via MathJax and morphing them token by token.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/motionscript-dev/motionscript.git",
9
+ "directory": "packages/components/latex"
10
+ },
11
+ "homepage": "https://motionscript.dev",
12
+ "bugs": {
13
+ "url": "https://github.com/motionscript-dev/motionscript/issues"
14
+ },
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "sideEffects": false,
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "registry.json",
22
+ "CHANGELOG.md",
23
+ "!dist/**/*.tsbuildinfo",
24
+ "!src/**/tests/**",
25
+ "!src/**/*.test.ts",
26
+ "!src/**/*.test.tsx",
27
+ "!src/**/*.fixtures.ts"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "keywords": [
36
+ "motion-script",
37
+ "motion script",
38
+ "latex",
39
+ "math",
40
+ "animation"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "license": "Apache-2.0",
46
+ "peerDependencies": {
47
+ "@motionscript/sdk": "^0.1.0-alpha.3"
48
+ },
49
+ "dependencies": {
50
+ "@mathjax/mathjax-newcm-font": "^4.1.1",
51
+ "@mathjax/src": "^4.1.1"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^25.6.2",
55
+ "eslint": "^9.39.4",
56
+ "eslint-import-resolver-typescript": "^4.4.4",
57
+ "eslint-plugin-import-x": "^4.16.1",
58
+ "tsc-alias": "^1.8.16",
59
+ "typescript": "^6.0.3",
60
+ "typescript-eslint": "^8.48.0",
61
+ "vitest": "^4.1.5",
62
+ "@motionscript/sdk": "0.1.0-alpha.3"
63
+ },
64
+ "scripts": {
65
+ "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && node ../../../scripts/build-browser.mjs",
66
+ "dev": "tsc -p tsconfig.build.json --watch & tsc-alias -p tsconfig.build.json --resolve-full-paths -w",
67
+ "clean": "rimraf --glob dist .turbo *.tsbuildinfo",
68
+ "test": "vitest --passWithNoTests",
69
+ "lint": "eslint .",
70
+ "typecheck": "tsc -p tsconfig.json --noEmit"
71
+ }
6
72
  }
package/registry.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "kit": { "barrel": "src/engine.ts", "specifier": "@motionscript/latex" },
3
+ "items": [
4
+ { "name": "latex", "root": "src", "files": ["node.ts"], "flatten": true }
5
+ ]
6
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A bounded, least-recently-used memo.
3
+ *
4
+ * Sits under `geometry.ts`, and imports nothing, so it is testable in bare Node:
5
+ * everything else in this package reaches MathJax's browser adaptor at module
6
+ * scope, which means a test that touches it needs a DOM. The one piece of logic
7
+ * here worth being sure about — what gets thrown away when the memo is full —
8
+ * is exactly the piece that does not need one.
9
+ *
10
+ * Least-recently-used rather than oldest-first, and that matters for the shape
11
+ * of work this sees. A `Latex` node re-resolves its formula whenever `fontSize`
12
+ * changes, so animating the size of one formula inserts a fresh key every frame
13
+ * and would, under a first-in-first-out rule, walk the whole memo out of it in a
14
+ * couple of seconds — including the entries for every *other* formula in the
15
+ * scene, none of which had done anything wrong. Touching an entry on read keeps
16
+ * the formulas that are actually being asked for ahead of the ones streaming
17
+ * past.
18
+ */
19
+
20
+ export interface BoundedCache<T> {
21
+ get(key: string): T | undefined;
22
+ set(key: string, value: T): void;
23
+ /** Entries held. For tests and for anyone measuring. */
24
+ readonly size: number;
25
+ }
26
+
27
+ /**
28
+ * @param limit Most entries to hold. One more arriving evicts the least
29
+ * recently read or written.
30
+ */
31
+ export function createBoundedCache<T>(limit: number): BoundedCache<T> {
32
+ // Insertion order is the eviction order, which is what makes a plain `Map`
33
+ // an LRU as long as every read re-inserts.
34
+ const entries = new Map<string, T>();
35
+
36
+ return {
37
+ get(key) {
38
+ if (!entries.has(key)) return undefined;
39
+ const value = entries.get(key) as T;
40
+ // Delete-then-set moves the key to the end, so the next eviction
41
+ // takes the genuinely coldest entry rather than the oldest one.
42
+ entries.delete(key);
43
+ entries.set(key, value);
44
+ return value;
45
+ },
46
+
47
+ set(key, value) {
48
+ entries.delete(key);
49
+ entries.set(key, value);
50
+ while (entries.size > limit) {
51
+ const coldest = entries.keys().next().value;
52
+ if (coldest === undefined) break;
53
+ entries.delete(coldest);
54
+ }
55
+ },
56
+
57
+ get size() {
58
+ return entries.size;
59
+ },
60
+ };
61
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,11 @@
1
+ // The Latex "engine": MathJax-backed glyph-path building and morph
2
+ // interpolation, kept separate from node.ts so `ms add latex` can copy just
3
+ // the node declaration and import this behind `@motionscript/latex` instead
4
+ // What gets copied is whatever this package's registry.json declares, which
5
+ // for this component is node.ts; packages/site/scripts/build-registry.mjs
6
+ // enforces that anything it reaches for is either copied alongside it or
7
+ // re-exported from here.
8
+ export { buildLatexPath } from './geometry';
9
+ export type { LatexToken, LatexPathResult } from './geometry';
10
+ export { prepareLatexTween as defaultLatexMorph } from './tween';
11
+ export type { AnimatedToken, LatexMorphStrategy } from './tween';