@motionscript/latex 0.0.0-stage → 0.1.0-alpha.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/src/node.ts ADDED
@@ -0,0 +1,381 @@
1
+ import { RenderContext2D, Graphics2D, EasingFunction, command, node, lerpNumber, NodeConfig, ShapeNode, ShapeProps, InsetsProps, Size2D, SizeConstraints, toPathString, InsetsResolved, property, insetsOps, FillResolved, ShadowResolved, driveCommand, type Command, type CommandArgs, type TweenStepper } from "@motionscript/core";
2
+ import { buildLatexPath, defaultLatexMorph, type AnimatedToken, type LatexMorphStrategy, type LatexToken } from "./engine";
3
+
4
+ export interface LatexProps extends ShapeProps {
5
+ latex: string;
6
+ fontSize: number;
7
+ padding: InsetsProps;
8
+ }
9
+
10
+ /**
11
+ * Construction-time-only options: pluggable engine behavior, set once when
12
+ * the node is created. Deliberately *not* part of {@link LatexProps} — every
13
+ * `@property` field accepts either a value or a zero-arg reactive binding
14
+ * (`value | (() => value)`, see `PropInputs`), and the node's own reactive
15
+ * write path (`_writeProp`) treats any function value as the latter. A
16
+ * strategy *is* a function, so it can't be told apart from a binding that
17
+ * computes one — it has to stay outside the reactive prop system entirely.
18
+ */
19
+ export interface LatexStrategies {
20
+ /** How the node morphs between two formulas. Defaults to `defaultLatexMorph`. */
21
+ morph?: LatexMorphStrategy;
22
+ }
23
+
24
+ @node({
25
+ key: "latex",
26
+ parentKey: "shape",
27
+ forkable: true,
28
+ layout: {
29
+ children: "freeform",
30
+ defaultWidthMode: "hug",
31
+ defaultHeightMode: "hug",
32
+ acceptsChildren: true,
33
+ },
34
+ seed: {
35
+ fill: [
36
+ {
37
+ type: "solid",
38
+ color: [
39
+ 0.412,
40
+ 0.565,
41
+ 0.867,
42
+ 1,
43
+ ],
44
+ opacity: 1,
45
+ enabled: true,
46
+ },
47
+ ],
48
+ },
49
+ })
50
+ export class Latex extends ShapeNode<LatexProps> {
51
+ @property({ default: "" }) declare readonly latex: string;
52
+ @property({ default: 16 }) declare readonly fontSize: number;
53
+ @property({ default: 0, mapper: insetsOps.resolve, tween: insetsOps.lerp }) declare readonly padding: InsetsResolved;
54
+
55
+ /**
56
+ * How this node morphs between two formulas. Construction-time-only (see
57
+ * {@link LatexStrategies}) — resolved once here rather than left as
58
+ * `?? defaultLatexMorph` at every call site.
59
+ */
60
+ private readonly morph: LatexMorphStrategy;
61
+
62
+ private _intrinsicWidth: number = 0;
63
+ private _intrinsicHeight: number = 0;
64
+
65
+ /**
66
+ * Shared center frame for all current tokens, [minX, minY, maxX, maxY] in
67
+ * token space. Passed to every per-token path so glyphs keep their relative
68
+ * layout instead of each centering on its own bbox.
69
+ */
70
+ private _bounds: [number, number, number, number] = [0, 0, 0, 0];
71
+
72
+ /** Current tokens to render. Each has its own opacity for tween transitions. */
73
+ private _tokens: AnimatedToken[] = [];
74
+
75
+ /** Suppresses retokenization while a custom to() is driving frames. */
76
+ private _animating: boolean = false;
77
+
78
+ /** The `fontSize|latex` the live tokens were built from. See {@link _ensureTokens}. */
79
+ private _tokenizedFrom: string | null = null;
80
+
81
+ constructor(props: NodeConfig<Latex, LatexProps> & LatexStrategies) {
82
+ super(props);
83
+ this.morph = props.morph ?? defaultLatexMorph;
84
+ this.applyProp("width", props.width ?? "hug");
85
+ this.applyProp("height", props.height ?? "hug");
86
+
87
+ this._ensureTokens();
88
+ }
89
+
90
+ /**
91
+ * Rebuild the token list when the formula or size has moved since it last
92
+ * was built.
93
+ *
94
+ * Pulled from every reader rather than pushed from a prop write, because
95
+ * `latex` can be a binding — and a binding has no write to hang a
96
+ * subscription on. The key is what the current tokens were built from;
97
+ * `_animating` hands ownership to the morph command for its open interval.
98
+ */
99
+ private _ensureTokens(): void {
100
+ if (this._animating) return;
101
+ const key = `${this.fontSize}|${this.latex}`;
102
+ if (key === this._tokenizedFrom) return;
103
+ this._tokenizedFrom = key;
104
+ this._updateTokens();
105
+ }
106
+
107
+ private _updateTokens() {
108
+ if (this.latex) {
109
+ const result = buildLatexPath(this.latex, this.fontSize);
110
+ this._intrinsicWidth = result.width;
111
+ this._intrinsicHeight = result.height;
112
+ this._bounds = result.bounds;
113
+ this._tokens = result.tokens.map(t => ({
114
+ token: t.token,
115
+ path: t.path,
116
+ opacity: 1,
117
+ x: 0,
118
+ y: 0,
119
+ }));
120
+ } else {
121
+ this._tokens = [];
122
+ this._intrinsicWidth = 0;
123
+ this._intrinsicHeight = 0;
124
+ this._bounds = [0, 0, 0, 0];
125
+ }
126
+ }
127
+
128
+ override measure(constraints: SizeConstraints): Partial<Size2D> {
129
+ this._ensureTokens();
130
+ const pad = this.padding;
131
+ const wm = this.width;
132
+ const hm = this.height;
133
+
134
+ const resolvedW = typeof wm === "number"
135
+ ? wm
136
+ : wm === "hug"
137
+ ? this._intrinsicWidth + pad.left + pad.right
138
+ : constraints.maxWidth ?? 0;
139
+
140
+ const resolvedH = typeof hm === "number"
141
+ ? hm
142
+ : hm === "hug"
143
+ ? this._intrinsicHeight + pad.top + pad.bottom
144
+ : constraints.maxHeight ?? 0;
145
+
146
+ return { width: resolvedW, height: resolvedH };
147
+ }
148
+
149
+ /**
150
+ * A LaTeX morph — one {@link Command}, like every other `to()`.
151
+ *
152
+ * Sequence two of them with `sequence(...)` rather than chaining: the chain
153
+ * builder this used to return existed only so `latexRef().to(a, 1).to(b, 1)`
154
+ * would read as two steps, and it could not compose with anything that was
155
+ * not another Latex morph.
156
+ */
157
+ override to<D extends Partial<LatexProps> = Partial<LatexProps>>(args: CommandArgs<D> & { duration: number }): Command<LatexProps> {
158
+ return this._buildMorph(args.data ?? {}, args.duration, args.easing);
159
+ }
160
+
161
+ /**
162
+ * Morph this formula into another — a document-facing name for {@link to},
163
+ * which already routes through {@link _buildMorph}.
164
+ */
165
+ @command({ args: [{ key: "latex", kind: "text", multiline: true }] })
166
+ morphTo(args: CommandArgs<{ latex: string }> & { duration: number }): Command<LatexProps> {
167
+ return this.to({ data: { latex: args.data?.latex } as Partial<LatexProps>, duration: args.duration, easing: args.easing });
168
+ }
169
+
170
+ /**
171
+ * Build a single LaTeX morph as a {@link Command}: every ordinary
172
+ * `ShapeProps` field (via the inherited `_prepareStep`), plus the
173
+ * intrinsic size, shared center frame and per-glyph token list — state a
174
+ * plain `set()` can't reach — driven together by one eased `t`, matching
175
+ * the concurrent `parallel` the generator version ran.
176
+ *
177
+ * The "from" snapshot (`fromTokens`, `fromLatex`, …) is captured
178
+ * **lazily, on the command's first evaluation**, not when this is called.
179
+ * That is what lets two morphs be sequenced: `commandSequence` settles each
180
+ * prior step before evaluating the next, so a second morph's "from" reads
181
+ * the first one's end state rather than whatever the node held before either
182
+ * of them ran.
183
+ *
184
+ * `_animating` suppresses {@link _ensureTokens} for the open interval, and
185
+ * both ends commit: `t === 1`
186
+ * lands on the target formula, `t === 0` restores the source one — the same
187
+ * "membership for the open interval, commit at either end" shape
188
+ * `packages/components/code`'s `runTransition` uses, so seeking into either
189
+ * side of the morph (not just running it forward) shows the right thing.
190
+ *
191
+ * `@internal` — the entry point for authors is {@link to}.
192
+ */
193
+ _buildMorph(to: Partial<LatexProps>, duration: number, easing?: EasingFunction): Command<LatexProps> {
194
+ let setupDone = false;
195
+ let fromAnimTokens: AnimatedToken[] = [];
196
+ let fromLatex = "";
197
+ let fromFontSize = 0;
198
+ let fromWidth = 0;
199
+ let fromHeight = 0;
200
+ let fromBounds: [number, number, number, number] = [0, 0, 0, 0];
201
+ let toFontSize = 0;
202
+ let toLatex = "";
203
+ let toResult: ReturnType<typeof buildLatexPath>;
204
+ let propStep: TweenStepper;
205
+ let latexFrame: (t: number) => AnimatedToken[];
206
+
207
+ const setup = (): void => {
208
+ setupDone = true;
209
+ this._ensureTokens();
210
+ const fromTokens: LatexToken[] = this._tokens.map(t => ({ token: t.token, path: t.path }));
211
+ fromAnimTokens = this._tokens.map(t => ({ ...t }));
212
+ fromLatex = this.latex;
213
+ fromFontSize = this.fontSize;
214
+ fromWidth = this._intrinsicWidth;
215
+ fromHeight = this._intrinsicHeight;
216
+ fromBounds = this._bounds;
217
+
218
+ toFontSize = to.fontSize !== undefined ? to.fontSize : this.fontSize;
219
+ toLatex = to.latex !== undefined ? to.latex : this.latex;
220
+ toResult = buildLatexPath(toLatex, toFontSize);
221
+
222
+ propStep = this._prepareStep(to, duration);
223
+ latexFrame = this.morph(fromTokens, toResult.tokens);
224
+ };
225
+
226
+ return driveCommand(duration, (t) => {
227
+ if (!setupDone) setup();
228
+ const toBounds = toResult.bounds;
229
+
230
+ this._animating = true;
231
+ propStep.seek(t * duration);
232
+ this.set({ fontSize: lerpNumber(fromFontSize, toFontSize, t) });
233
+ // Track the measured size so a hugging box grows/shrinks smoothly
234
+ // across the morph rather than jumping when the end commits.
235
+ this._intrinsicWidth = lerpNumber(fromWidth, toResult.width, t);
236
+ this._intrinsicHeight = lerpNumber(fromHeight, toResult.height, t);
237
+ // Interpolate the shared center frame in lockstep so glyphs stay
238
+ // centered within the resizing box.
239
+ this._bounds = [
240
+ lerpNumber(fromBounds[0], toBounds[0], t),
241
+ lerpNumber(fromBounds[1], toBounds[1], t),
242
+ lerpNumber(fromBounds[2], toBounds[2], t),
243
+ lerpNumber(fromBounds[3], toBounds[3], t),
244
+ ];
245
+ this._tokens = latexFrame(t);
246
+
247
+ if (t >= 1) {
248
+ this.set({ latex: toLatex, fontSize: toFontSize });
249
+ this._tokens = toResult.tokens.map(tok => ({ token: tok.token, path: tok.path, opacity: 1, x: 0, y: 0 }));
250
+ this._intrinsicWidth = toResult.width;
251
+ this._intrinsicHeight = toResult.height;
252
+ this._bounds = toResult.bounds;
253
+ this._animating = false;
254
+ this._tokenizedFrom = `${toFontSize}|${toLatex}`;
255
+ } else if (t <= 0) {
256
+ this.set({ latex: fromLatex, fontSize: fromFontSize });
257
+ this._tokens = fromAnimTokens;
258
+ this._intrinsicWidth = fromWidth;
259
+ this._intrinsicHeight = fromHeight;
260
+ this._bounds = fromBounds;
261
+ this._animating = false;
262
+ this._tokenizedFrom = `${fromFontSize}|${fromLatex}`;
263
+ }
264
+ }, easing) as Command<LatexProps>;
265
+ }
266
+
267
+ /**
268
+ * The shadow op comes **first**, as it does on every other painted node.
269
+ *
270
+ * The renderer does not draw a shadow when it sees one: `.shadow()` stores
271
+ * it pending and the *next* fill or stroke op casts it (`_shadow` →
272
+ * `storePendingShadows`, `_fill`/`_stroke` → `takePendingShadows`). Appended
273
+ * last, it was stored by every token and then thrown away by the reset at
274
+ * the end of the draw — a shadow set on a LaTeX node simply never appeared,
275
+ * with no error to say why. Same failure {@link PaintedNode.renderStroke}
276
+ * documents for a fill-less stroked shape, and the same one-op fix.
277
+ *
278
+ * Order also carries the fill-less case for free: an empty fill returns
279
+ * before it takes the pending shadow, so the stroke below casts it from the
280
+ * glyph outlines instead — which is what `castsShadowFromStroke` arranges on
281
+ * a plain shape.
282
+ */
283
+ protected renderSelf(ctx: RenderContext2D): void {
284
+ this.eachToken(ctx, (graphics, opacity) => graphics
285
+ .shadow(scaleShadowOpacity(this.shadow as ShadowResolved[], opacity))
286
+ .fill(scaleFillopacity(this.fill as FillResolved[], opacity))
287
+ .stroke(this.stroke));
288
+ }
289
+
290
+ /**
291
+ * The overlay, painted *through* the glyphs.
292
+ *
293
+ * `ShapeNode`'s inherited overlay pass fills whatever {@link
294
+ * ShapeNode.shapeGraphics} describes, and this node describes nothing there:
295
+ * a formula has no single fillable silhouette — its silhouette *is* the list
296
+ * of token paths, which is why `renderSelf` is overridden rather than a
297
+ * `shapeGraphics` supplied. So the generic pass drew nothing and an overlay
298
+ * set on a LaTeX node simply never appeared, with no error to say why.
299
+ *
300
+ * Painted the same way the fill is: one `Graphics2D` per token, all sharing the
301
+ * centre frame, each scaled by its token's animated opacity — so a morph
302
+ * carries the overlay along with the glyph it is laid over instead of leaving
303
+ * a wash hanging over glyphs that have faded out.
304
+ *
305
+ * The stroke is *not* re-drawn here. `renderSelf` already strokes each token
306
+ * (there is no silhouette for the deferred {@link ShapeNode.renderStroke} to
307
+ * outline), so it is painted under the overlay rather than over it — the one
308
+ * place this node's draw order differs from a plain shape's, and the price of
309
+ * a stroke that follows glyphs rather than a box.
310
+ */
311
+ protected override renderOverlay(ctx: RenderContext2D): void {
312
+ const overlay = this.overlay as FillResolved[];
313
+ if (overlay.length === 0) return;
314
+ this.eachToken(ctx, (graphics, opacity) => graphics.fill(scaleFillopacity(overlay, opacity)));
315
+ }
316
+
317
+ /**
318
+ * Draws every visible token's path once, handing each one to `paint` to have
319
+ * its paint ops appended.
320
+ *
321
+ * A fresh `Graphics2D` per token per pass, because `.fill()`/`.stroke()` push
322
+ * onto one op list and return `this` — a silhouette shared between the fill
323
+ * pass and the overlay pass would accumulate both.
324
+ */
325
+ private eachToken(ctx: RenderContext2D, paint: (graphics: Graphics2D, opacity: number) => Graphics2D): void {
326
+ // The one funnel every paint pass goes through, so the tokens a frame
327
+ // draws cannot be older than the formula it holds.
328
+ this._ensureTokens();
329
+ for (const token of this._tokens) {
330
+ if (token.opacity <= 0) continue;
331
+
332
+ // Translate the path by token's interpolated position offset
333
+ const pathStr = token.x !== 0 || token.y !== 0
334
+ ? toPathString(offsetPath(token.path, token.x, token.y))
335
+ : toPathString(token.path);
336
+
337
+ ctx.draw(paint(new Graphics2D()
338
+ .path({
339
+ data: pathStr,
340
+ start: this.start,
341
+ end: this.end,
342
+ // All tokens share one center frame so they keep their relative
343
+ // layout — without this each glyph centers on its own bbox and
344
+ // they all stack on the origin.
345
+ centerBounds: this._bounds,
346
+ }), token.opacity));
347
+ }
348
+ }
349
+
350
+ }
351
+
352
+ function scaleFillopacity(fills: FillResolved[], opacity: number): FillResolved[] {
353
+ if (opacity >= 1) return fills;
354
+ return fills.map(f => ({ ...f, opacity: (f.opacity ?? 1) * opacity }));
355
+ }
356
+
357
+ /**
358
+ * A morphing token fades, and its shadow has to fade with it — the paints are
359
+ * scaled the same way the fill's are, so a half-faded glyph casts a half-strength
360
+ * shadow rather than sitting over a shadow at full weight.
361
+ */
362
+ function scaleShadowOpacity(shadows: ShadowResolved[], opacity: number): ShadowResolved[] {
363
+ if (opacity >= 1) return shadows;
364
+ return shadows.map(s => ({ ...s, fill: scaleFillopacity(s.fill, opacity) }));
365
+ }
366
+
367
+ function offsetPath(
368
+ path: LatexToken["path"],
369
+ dx: number,
370
+ dy: number,
371
+ ): LatexToken["path"] {
372
+ return path.map(cmd => {
373
+ const c = cmd as any;
374
+ const shifted: any = { ...c };
375
+ if ("x" in c) shifted.x = c.x + dx;
376
+ if ("y" in c) shifted.y = c.y + dy;
377
+ if ("x1" in c) { shifted.x1 = c.x1 + dx; shifted.y1 = c.y1 + dy; }
378
+ if ("x2" in c) { shifted.x2 = c.x2 + dx; shifted.y2 = c.y2 + dy; }
379
+ return shifted;
380
+ });
381
+ }
package/src/nodes.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { Latex } from "./node";
2
+
3
+ /**
4
+ * Every node type this package publishes.
5
+ *
6
+ * A host registers these by handing the array to an engine —
7
+ * `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
8
+ * key. The decorator only *declares* that key: nothing is registered by the mere
9
+ * act of importing a module, so a registry holds exactly what its owner asked
10
+ * for and two engines in one process can differ about it.
11
+ *
12
+ * Listing class **values** is also what survives bundling. This package declares
13
+ * `sideEffects: false`, which licenses a bundler to drop a module nothing
14
+ * imports a value from, and a document names a node type by string — so
15
+ * `import "./node"` for its side effect is exactly what gets shaken out, where an
16
+ * array of bindings is a data dependency that cannot be.
17
+ */
18
+ export const NODES = [Latex];
package/src/tween.ts ADDED
@@ -0,0 +1,286 @@
1
+ import { lerpNumber, type PathCommand } from "@motionscript/core";
2
+ import type { LatexToken } from "./geometry";
3
+
4
+ export interface AnimatedToken {
5
+ token: string;
6
+ path: LatexToken["path"];
7
+ /** 0 = invisible, 1 = fully visible */
8
+ opacity: number;
9
+ /** Interpolated position offset applied during morph (x, y in formula space). */
10
+ x: number;
11
+ y: number;
12
+ }
13
+
14
+ /**
15
+ * How a `Latex` node interpolates between two formulas: given the tokens it
16
+ * currently holds and the tokens the target formula resolves to, return a
17
+ * pure `t → AnimatedToken[]` frame function. `prepareLatexTween` below is the
18
+ * default implementation (exported as `defaultLatexMorph`) — pass a `morph`
19
+ * of this shape to `<Latex>` to replace it with your own.
20
+ */
21
+ export type LatexMorphStrategy = (from: LatexToken[], to: LatexToken[]) => (t: number) => AnimatedToken[];
22
+
23
+ /** The coordinate pairs a `PathCommand` can carry, as (x, y) field names. */
24
+ const POINT_FIELDS = [["x", "y"], ["x1", "y1"], ["x2", "y2"]] as const;
25
+
26
+ /**
27
+ * Compute the centroid of a token's path for position-based interpolation.
28
+ */
29
+ function centroid(path: LatexToken["path"]): { x: number; y: number } {
30
+ let sx = 0, sy = 0, n = 0;
31
+ for (const cmd of path) {
32
+ if ("x" in cmd && "y" in cmd) {
33
+ sx += (cmd as any).x;
34
+ sy += (cmd as any).y;
35
+ n++;
36
+ }
37
+ }
38
+ return n > 0 ? { x: sx / n, y: sy / n } : { x: 0, y: 0 };
39
+ }
40
+
41
+ /**
42
+ * A glyph's on-screen extent, as the diagonal of its control-point bbox.
43
+ *
44
+ * Only ever used as a *ratio* between the two ends of a match, so the fact that
45
+ * control points overshoot the true outline doesn't matter: both ends overshoot
46
+ * by the same proportion, because they are the same outline.
47
+ */
48
+ function extent(path: LatexToken["path"]): number {
49
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
50
+ for (const cmd of path) {
51
+ const c = cmd as any;
52
+ for (const [kx, ky] of POINT_FIELDS) {
53
+ if (kx in c) {
54
+ if (c[kx] < minX) minX = c[kx];
55
+ if (c[kx] > maxX) maxX = c[kx];
56
+ if (c[ky] < minY) minY = c[ky];
57
+ if (c[ky] > maxY) maxY = c[ky];
58
+ }
59
+ }
60
+ }
61
+ if (minX > maxX) return 0;
62
+ return Math.hypot(maxX - minX, maxY - minY);
63
+ }
64
+
65
+ /**
66
+ * Whether two paths can be interpolated point by point: same command count,
67
+ * same command types in the same order.
68
+ *
69
+ * For a matched pair this is the overwhelmingly common case, and not by luck.
70
+ * Tokens are matched on the character their MathJax glyph id decodes to, and
71
+ * that id *is* the key into the `<defs>` dictionary — so the same character in
72
+ * two formulas is the same outline, emitted twice under two different
73
+ * transforms. Same commands, different numbers.
74
+ */
75
+ function isPointwiseCompatible(from: PathCommand[], to: PathCommand[]): boolean {
76
+ if (from.length !== to.length) return false;
77
+ for (let i = 0; i < from.length; i++) {
78
+ if (from[i].type !== to[i].type) return false;
79
+ }
80
+ return true;
81
+ }
82
+
83
+ /**
84
+ * Lerp two structurally identical paths point by point.
85
+ *
86
+ * When the two ends are the same outline under two affine transforms — which is
87
+ * what a matched glyph is — this is exact at both ends *and* correct in
88
+ * between: `(1-t)·A·q + t·B·q = ((1-t)A + tB)·q`, and a blend of two
89
+ * uniform-scale-plus-translate transforms is another one. The intermediate is a
90
+ * properly formed glyph at an intermediate size, not a smeared one.
91
+ */
92
+ function lerpPath(from: PathCommand[], to: PathCommand[], t: number): PathCommand[] {
93
+ const out: PathCommand[] = new Array(from.length);
94
+ for (let i = 0; i < from.length; i++) {
95
+ const f = from[i] as any;
96
+ const g = to[i] as any;
97
+ const c: any = { ...g };
98
+ for (const [kx, ky] of POINT_FIELDS) {
99
+ if (kx in g && kx in f) {
100
+ c[kx] = lerpNumber(f[kx], g[kx], t);
101
+ c[ky] = lerpNumber(f[ky], g[ky], t);
102
+ }
103
+ }
104
+ out[i] = c as PathCommand;
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * Scale a path uniformly about (cx, cy), then translate by (dx, dy).
111
+ *
112
+ * The fallback for a matched pair whose paths *aren't* structurally identical —
113
+ * a stretchy delimiter assembled from a different number of pieces at the two
114
+ * sizes, say. Point-by-point lerping is impossible there, so the target outline
115
+ * is shrunk to the source's size instead and grown back over the morph: the
116
+ * size still changes continuously, which is the whole point, at the cost of
117
+ * showing the target's shape from the start.
118
+ */
119
+ function scaleAbout(
120
+ path: PathCommand[],
121
+ cx: number,
122
+ cy: number,
123
+ s: number,
124
+ dx: number,
125
+ dy: number,
126
+ ): PathCommand[] {
127
+ return path.map(cmd => {
128
+ const c: any = { ...(cmd as any) };
129
+ for (const [kx, ky] of POINT_FIELDS) {
130
+ if (kx in c) {
131
+ c[kx] = cx + (c[kx] - cx) * s + dx;
132
+ c[ky] = cy + (c[ky] - cy) * s + dy;
133
+ }
134
+ }
135
+ return c as PathCommand;
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Greedily match tokens from `from` to `to` by character key.
141
+ * Returns three lists: matched pairs, deleted tokens (only in from), added tokens (only in to).
142
+ */
143
+ function matchTokens(
144
+ from: LatexToken[],
145
+ to: LatexToken[],
146
+ ): {
147
+ matched: Array<{ from: LatexToken; to: LatexToken }>;
148
+ deleted: LatexToken[];
149
+ added: LatexToken[];
150
+ } {
151
+ const remaining = [...to];
152
+ const matched: Array<{ from: LatexToken; to: LatexToken }> = [];
153
+ const deleted: LatexToken[] = [];
154
+
155
+ for (const ft of from) {
156
+ // Skip synthetic shapes (rects/paths) — they don't have a natural token key
157
+ if (ft.token.startsWith("__")) {
158
+ deleted.push(ft);
159
+ continue;
160
+ }
161
+ const idx = remaining.findIndex(t => t.token === ft.token && !t.token.startsWith("__"));
162
+ if (idx !== -1) {
163
+ matched.push({ from: ft, to: remaining[idx] });
164
+ remaining.splice(idx, 1);
165
+ } else {
166
+ deleted.push(ft);
167
+ }
168
+ }
169
+
170
+ // Remaining to-tokens that weren't matched
171
+ const added = remaining;
172
+
173
+ return { matched, deleted, added };
174
+ }
175
+
176
+ /**
177
+ * Precompute a formula-change morph and return a pure `t → AnimatedToken[]`
178
+ * frame function:
179
+ * - Deleted tokens fade out over the first half of `t`.
180
+ * - Matched tokens are interpolated — place *and* size — across the full range
181
+ * of `t`.
182
+ * - Added tokens fade in over the second half of `t`.
183
+ *
184
+ * A matched glyph is rarely the same size at both ends: the `2` of `b^2` is set
185
+ * at script size and the `2` of `2a` at full size, and a `\frac`'s arguments
186
+ * come back a step smaller than the same symbols on a baseline. So the
187
+ * interpolation has to carry the glyph's *geometry*, not just where it sits.
188
+ * Sliding the target outline from one centroid to the other — which is all this
189
+ * used to do — put the whole size change between the last static frame and the
190
+ * morph's first one: a snap to the new size, then a smooth glide to the new
191
+ * place. Point-by-point lerping (see {@link lerpPath}) is that same
192
+ * interpolation generalised from a glyph's average point to all of them, and it
193
+ * costs nothing extra — the path was already being rebuilt every frame to apply
194
+ * the slide.
195
+ *
196
+ * `t` is normalized `[0, 1]` and already eased — the caller (a `Command`'s
197
+ * `at`) applies easing once, up front, the same eased value driving every
198
+ * concurrent aspect of the morph (props, intrinsic size, tokens) in lockstep.
199
+ */
200
+ export function prepareLatexTween(
201
+ from: LatexToken[],
202
+ to: LatexToken[],
203
+ ): (t: number) => AnimatedToken[] {
204
+ const { matched, deleted, added } = matchTokens(from, to);
205
+
206
+ // Per-match interpolation data. Structure compatibility, and the centroids
207
+ // and extents behind the fallback, are properties of the pair rather than
208
+ // of `t`, so they are settled once here instead of at every frame.
209
+ const matchedData = matched.map(({ from: f, to: t }) => {
210
+ const toExtent = extent(t.path);
211
+ return {
212
+ pointwise: isPointwiseCompatible(f.path, t.path),
213
+ fromPath: f.path,
214
+ toPath: t.path,
215
+ token: t.token,
216
+ fromCenter: centroid(f.path),
217
+ toCenter: centroid(t.path),
218
+ /** Only the fallback needs it, and only as a ratio. */
219
+ fromScale: toExtent > 0 ? extent(f.path) / toExtent : 1,
220
+ };
221
+ });
222
+
223
+ return (t: number): AnimatedToken[] => {
224
+ const tokens: AnimatedToken[] = [];
225
+
226
+ // Matched tokens: interpolate place and size across the full duration.
227
+ for (const m of matchedData) {
228
+ if (m.pointwise) {
229
+ tokens.push({
230
+ token: m.token,
231
+ path: lerpPath(m.fromPath, m.toPath, t),
232
+ opacity: 1,
233
+ // Baked into the path above: the lerp carries every point,
234
+ // which includes where the glyph sits.
235
+ x: 0,
236
+ y: 0,
237
+ });
238
+ continue;
239
+ }
240
+
241
+ const s = lerpNumber(m.fromScale, 1, t);
242
+ const cx = lerpNumber(m.fromCenter.x, m.toCenter.x, t);
243
+ const cy = lerpNumber(m.fromCenter.y, m.toCenter.y, t);
244
+ tokens.push({
245
+ token: m.token,
246
+ path: scaleAbout(
247
+ m.toPath,
248
+ m.toCenter.x,
249
+ m.toCenter.y,
250
+ s,
251
+ cx - m.toCenter.x,
252
+ cy - m.toCenter.y,
253
+ ),
254
+ opacity: 1,
255
+ x: 0,
256
+ y: 0,
257
+ });
258
+ }
259
+
260
+ // Deleted tokens: fade out over the first half, gone by t=0.5
261
+ for (const d of deleted) {
262
+ const fadeT = Math.min(t * 2, 1);
263
+ tokens.push({
264
+ token: d.token,
265
+ path: d.path,
266
+ opacity: lerpNumber(1, 0, fadeT),
267
+ x: 0,
268
+ y: 0,
269
+ });
270
+ }
271
+
272
+ // Added tokens: fade in over the second half, starting at t=0.5
273
+ for (const a of added) {
274
+ const fadeT = Math.max(t * 2 - 1, 0);
275
+ tokens.push({
276
+ token: a.token,
277
+ path: a.path,
278
+ opacity: lerpNumber(0, 1, fadeT),
279
+ x: 0,
280
+ y: 0,
281
+ });
282
+ }
283
+
284
+ return tokens;
285
+ };
286
+ }