@altpsyche/maths 0.5.0 → 0.7.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 +68 -5
- package/dist/figure/animation.d.ts +19 -0
- package/dist/figure/animation.js +40 -0
- package/dist/figure/equation-match.d.ts +42 -0
- package/dist/figure/equation-match.js +57 -0
- package/dist/figure/equation.d.ts +44 -0
- package/dist/figure/equation.js +181 -0
- package/dist/figure/typeset.d.ts +18 -0
- package/dist/figure/typeset.js +43 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -20,11 +20,13 @@ svgMarkup(at(figure, 0.5), viewMatrix(figure.extent, 'contain', 640, 360), 640,
|
|
|
20
20
|
|
|
21
21
|
## Axes and a plotted function
|
|
22
22
|
|
|
23
|
-
<img src="docs/tangent.svg" width="720" alt="A parabola on a labelled grid, the region under it shaded to a point on the curve, the tangent at that point drawn, and the slope written as a number.">
|
|
23
|
+
<img src="docs/tangent.svg" width="720" alt="A parabola on a labelled grid, the region under it shaded to a point on the curve, the tangent at that point drawn, and the slope written as a number under the typeset rule it comes from.">
|
|
24
24
|
|
|
25
25
|
The picture arrives rather than appearing. The grid fades, the axes draw on, their labels come in one
|
|
26
26
|
after another, the curve draws, the dot grows out of the origin, and the dot is pointed at where the
|
|
27
|
-
slope is nothing. Then it walks the curve at one speed and flashes at the top.
|
|
27
|
+
slope is nothing. Then it walks the curve at one speed and flashes at the top. The number in the
|
|
28
|
+
corner is a value of the typeset rule under it, and that rule reads no slope while the dot is held at
|
|
29
|
+
the stationary point and walks into the one that depends on x as the dot leaves.
|
|
28
30
|
|
|
29
31
|
```ts
|
|
30
32
|
import { axes, coordsOf, group, interval, numberPlane, plot, scaleOf, shape } from '@altpsyche/maths';
|
|
@@ -57,16 +59,77 @@ region is the limit of at the left edge, the right edge or the middle of each on
|
|
|
57
59
|
lays the tangent along the curve, cut where it leaves the graph. `slopeOf` reads the slope itself,
|
|
58
60
|
which is what the number in the corner is.
|
|
59
61
|
|
|
60
|
-
<img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve
|
|
62
|
+
<img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve, the shaded region growing behind it, and the typeset rule in the corner changing from a slope of nothing to one that depends on x.">
|
|
61
63
|
|
|
62
64
|
Four times of one figure, side by side: the picture arrived, the beat at the stationary point, half
|
|
63
65
|
way up, and the top. A moving picture in a README needs a GIF and this package has no encoder, so the
|
|
64
66
|
strip shows the motion in a still.
|
|
65
67
|
|
|
68
|
+
## Equations
|
|
69
|
+
|
|
70
|
+
`equationFromTex` typesets an expression with MathJax and reads the SVG the typesetter wrote back as
|
|
71
|
+
marks, one per glyph. `equationNode` places those marks in a figure, fitted inside a box and centred
|
|
72
|
+
on a point. It fits the width as well as the height, because an expression six times wider than it is
|
|
73
|
+
tall runs off the sides of a figure the moment the height alone decides its size.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { equationFromTex, equationNode, vec2 } from '@altpsyche/maths';
|
|
77
|
+
|
|
78
|
+
const rule = await equationFromTex('\\frac{dy}{dx} = 2x');
|
|
79
|
+
|
|
80
|
+
equationNode('rule', rule, {
|
|
81
|
+
at: vec2(-4.27, 1.74),
|
|
82
|
+
width: 1.2,
|
|
83
|
+
height: 0.6,
|
|
84
|
+
fill: { colour: '#1b1b1b' },
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
An equation is paths on the page as well as in a recording, which is what stops one expression having
|
|
89
|
+
two pictures free to disagree. A glyph is an outline rather than a letter, so no font has to be
|
|
90
|
+
installed anywhere and the LaTeX is the label the picture carries for a reader.
|
|
91
|
+
|
|
92
|
+
MathJax is the one runtime dependency and the typesetting call is what loads it. Importing the door
|
|
93
|
+
reaches none of it, so a consumer who draws figures and typesets nothing pays nothing. What that
|
|
94
|
+
costs is that typesetting answers with a promise.
|
|
95
|
+
|
|
96
|
+
`matchGlyphs` says which glyph of one expression is which glyph of the other, and `morphEquation`
|
|
97
|
+
walks one into the next: the shared sub-expressions stay put and only the difference moves. Two marks
|
|
98
|
+
match on the part of the leaf name after the first dash, which the typesetter's own naming gives, so
|
|
99
|
+
a glyph is `3-1D465` and a fraction bar is `4-rule`. The pairing is the longest common subsequence of
|
|
100
|
+
the two token sequences, which pairs each occurrence of a repeated glyph once and refuses a pair that
|
|
101
|
+
would cross another pair on the way over.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { equationFromTex, equationNode, group, morphEquation, vec2 } from '@altpsyche/maths';
|
|
105
|
+
|
|
106
|
+
const box = { at: vec2(-4.86, 1.74), align: 'start', width: 1.2, height: 0.6, fill: { colour: '#1b1b1b' } } as const;
|
|
107
|
+
|
|
108
|
+
group('rule', [
|
|
109
|
+
equationNode('at-rest', await equationFromTex('\\frac{dy}{dx} = 0'), box),
|
|
110
|
+
equationNode('moving', await equationFromTex('\\frac{dy}{dx} = 2x'), box),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
morphEquation('rule/at-rest', 'rule/moving');
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Both expressions are in the scene at every time and the animation moves one onto the other, because a
|
|
117
|
+
mark that arrived part way through a span would turn up in a comparison between two frames as
|
|
118
|
+
something that changed. A paired glyph is drawn once rather than cross-faded, so the glyph being left
|
|
119
|
+
carries the walk and its partner stays at nothing until it is being stood on exactly. Hang both
|
|
120
|
+
expressions from the same edge with `align`, or the part they share slides sideways as the difference
|
|
121
|
+
arrives.
|
|
122
|
+
|
|
123
|
+
Three things stop a typeset expression rather than being drawn, and each names what it found. A TeX
|
|
124
|
+
error carries the typesetter's own message. A character the font has no outline for arrives as text,
|
|
125
|
+
which would draw with whatever font a browser had and draw nothing at all in a recording. An
|
|
126
|
+
undefined macro is not an error at all, because MathJax draws the macro's own name in red, so a typo
|
|
127
|
+
would otherwise ship as a red word inside the picture.
|
|
128
|
+
|
|
66
129
|
## The animations
|
|
67
130
|
|
|
68
|
-
`fadeIn`, `fadeOut`, `fadeTo`, `draw`, `morph`, `moveBy`, `rotate`, `scale`,
|
|
69
|
-
`indicate`, `flash` and `circumscribe`. A `Timeline` plays them in order, plays several `together`, or
|
|
131
|
+
`fadeIn`, `fadeOut`, `fadeTo`, `draw`, `morph`, `morphEquation`, `moveBy`, `rotate`, `scale`,
|
|
132
|
+
`growFrom`, `moveAlong`, `indicate`, `flash` and `circumscribe`. A `Timeline` plays them in order, plays several `together`, or
|
|
70
133
|
`stagger`s a row so its parts arrive one after another.
|
|
71
134
|
|
|
72
135
|
A turn and a growth happen about a point the marks decide for themselves, which is the middle of the
|
|
@@ -24,6 +24,25 @@ export declare function draw(target: string): Animation;
|
|
|
24
24
|
* until both hold the same points. A mark with no path is left alone.
|
|
25
25
|
*/
|
|
26
26
|
export declare function morph(target: string, into: Path): Animation;
|
|
27
|
+
/**
|
|
28
|
+
* One typeset expression walked into another, the shared glyphs staying put and
|
|
29
|
+
* only the difference moving.
|
|
30
|
+
*
|
|
31
|
+
* Both expressions are in the scene at every time and this moves one onto the
|
|
32
|
+
* other. A mark that arrived part way through a span would turn up in a
|
|
33
|
+
* comparison between two frames as something that changed, which is what a flash
|
|
34
|
+
* keeps its rays for.
|
|
35
|
+
*
|
|
36
|
+
* A paired glyph is drawn once rather than cross-faded. Two copies of one letter
|
|
37
|
+
* sitting on each other at half opacity through the middle of the span is a
|
|
38
|
+
* ghost, so the glyph being left carries the walk and its partner stays at
|
|
39
|
+
* nothing. At the end it is standing exactly on its partner, so nothing has to be
|
|
40
|
+
* swapped at any moment.
|
|
41
|
+
*
|
|
42
|
+
* An unpaired mark has its own opacity multiplied rather than set, so an
|
|
43
|
+
* expression still fading in when a morph starts does not jump to solid.
|
|
44
|
+
*/
|
|
45
|
+
export declare function morphEquation(from: string, to: string): Animation;
|
|
27
46
|
/** A mark's own opacity walked to a value, for a figure that wants a thing dimmed
|
|
28
47
|
* rather than gone. */
|
|
29
48
|
export declare function fadeTo(target: string, opacity: number): Animation;
|
package/dist/figure/animation.js
CHANGED
|
@@ -17,6 +17,7 @@ import { smoothstep } from '../values/ease.js';
|
|
|
17
17
|
import { circle, line, polygon, transformPath } from './path.js';
|
|
18
18
|
import { trimPath } from './trim.js';
|
|
19
19
|
import { lerpPath } from './morph.js';
|
|
20
|
+
import { matchGlyphs } from './equation-match.js';
|
|
20
21
|
import { pointAlong } from './length.js';
|
|
21
22
|
import { boundsOfMarks, centreOf } from './bounds.js';
|
|
22
23
|
import { interval } from '../values/interval.js';
|
|
@@ -79,6 +80,45 @@ export function morph(target, into) {
|
|
|
79
80
|
return { ...mark, path: lerpPath(mark.path, into, along) };
|
|
80
81
|
});
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* One typeset expression walked into another, the shared glyphs staying put and
|
|
85
|
+
* only the difference moving.
|
|
86
|
+
*
|
|
87
|
+
* Both expressions are in the scene at every time and this moves one onto the
|
|
88
|
+
* other. A mark that arrived part way through a span would turn up in a
|
|
89
|
+
* comparison between two frames as something that changed, which is what a flash
|
|
90
|
+
* keeps its rays for.
|
|
91
|
+
*
|
|
92
|
+
* A paired glyph is drawn once rather than cross-faded. Two copies of one letter
|
|
93
|
+
* sitting on each other at half opacity through the middle of the span is a
|
|
94
|
+
* ghost, so the glyph being left carries the walk and its partner stays at
|
|
95
|
+
* nothing. At the end it is standing exactly on its partner, so nothing has to be
|
|
96
|
+
* swapped at any moment.
|
|
97
|
+
*
|
|
98
|
+
* An unpaired mark has its own opacity multiplied rather than set, so an
|
|
99
|
+
* expression still fading in when a morph starts does not jump to solid.
|
|
100
|
+
*/
|
|
101
|
+
export function morphEquation(from, to) {
|
|
102
|
+
return (marks, along) => {
|
|
103
|
+
const leaving = marks.filter((mark) => touches(mark.id, from));
|
|
104
|
+
const arriving = marks.filter((mark) => touches(mark.id, to));
|
|
105
|
+
if (leaving.length === 0 || arriving.length === 0)
|
|
106
|
+
return marks;
|
|
107
|
+
const changed = new Map();
|
|
108
|
+
for (const [left, right] of matchGlyphs(leaving, arriving).pairs) {
|
|
109
|
+
changed.set(left.id, { ...left, path: lerpPath(left.path, right.path, along) });
|
|
110
|
+
changed.set(right.id, { ...right, opacity: 0 });
|
|
111
|
+
}
|
|
112
|
+
const faded = (mark, to) => ({ ...mark, opacity: (mark.opacity ?? 1) * to });
|
|
113
|
+
for (const mark of leaving)
|
|
114
|
+
if (!changed.has(mark.id))
|
|
115
|
+
changed.set(mark.id, faded(mark, 1 - along));
|
|
116
|
+
for (const mark of arriving)
|
|
117
|
+
if (!changed.has(mark.id))
|
|
118
|
+
changed.set(mark.id, faded(mark, along));
|
|
119
|
+
return marks.map((mark) => changed.get(mark.id) ?? mark);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
82
122
|
/** A mark's own opacity walked to a value, for a figure that wants a thing dimmed
|
|
83
123
|
* rather than gone. */
|
|
84
124
|
export function fadeTo(target, opacity) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which glyph of one typeset expression is which glyph of the other.
|
|
3
|
+
*
|
|
4
|
+
* A glyph is named for its place in the expression and the code point the
|
|
5
|
+
* typesetter wrote on it, so `3-1D465` is the fourth mark and the letter x, and
|
|
6
|
+
* a fraction bar is `4-rule`. The token two marks match on is the part of that
|
|
7
|
+
* name after the first dash, which needs no parsing and makes a rule a token of
|
|
8
|
+
* its own.
|
|
9
|
+
*
|
|
10
|
+
* The pairing is the longest common subsequence of the two token sequences,
|
|
11
|
+
* which is the longest run of tokens appearing in both lists in the same order.
|
|
12
|
+
* Matching in order is what stops the x of a numerator pairing with the x of a
|
|
13
|
+
* right-hand side, and it pairs each occurrence of a repeated glyph once rather
|
|
14
|
+
* than pairing several to one partner.
|
|
15
|
+
*
|
|
16
|
+
* The token is read off the id rather than carried beside the mark because a
|
|
17
|
+
* consumer stores a typeset equation rather than typesetting it again, and what
|
|
18
|
+
* it stores is a path and the id it belongs to. A code point kept elsewhere
|
|
19
|
+
* would have to be stored elsewhere too, and the matching would fail on
|
|
20
|
+
* everything read back from a cache that did not.
|
|
21
|
+
*/
|
|
22
|
+
import type { Mark, PathMark } from './mark.js';
|
|
23
|
+
export interface GlyphMatch {
|
|
24
|
+
/** Each glyph of the expression being left beside the one it becomes. */
|
|
25
|
+
readonly pairs: readonly (readonly [PathMark, PathMark])[];
|
|
26
|
+
/** Glyphs of the expression being left that nothing in the other answers. */
|
|
27
|
+
readonly leaving: readonly PathMark[];
|
|
28
|
+
/** Glyphs of the expression being arrived at that nothing in the first
|
|
29
|
+
* answers. */
|
|
30
|
+
readonly arriving: readonly PathMark[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What a mark matches on: its leaf name after the first dash.
|
|
34
|
+
*
|
|
35
|
+
* A mark whose leaf carries no dash is not a glyph a typesetter wrote, and it
|
|
36
|
+
* pairs with nothing rather than pairing with every other mark that is also
|
|
37
|
+
* unnamed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function glyphToken(id: string): string | undefined;
|
|
40
|
+
/** Two typeset expressions paired glyph by glyph, with what neither answers kept
|
|
41
|
+
* apart. Marks that are not paths are left out, since a glyph is an outline. */
|
|
42
|
+
export declare function matchGlyphs(from: readonly Mark[], to: readonly Mark[]): GlyphMatch;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a mark matches on: its leaf name after the first dash.
|
|
3
|
+
*
|
|
4
|
+
* A mark whose leaf carries no dash is not a glyph a typesetter wrote, and it
|
|
5
|
+
* pairs with nothing rather than pairing with every other mark that is also
|
|
6
|
+
* unnamed.
|
|
7
|
+
*/
|
|
8
|
+
export function glyphToken(id) {
|
|
9
|
+
const leaf = id.slice(id.lastIndexOf('/') + 1);
|
|
10
|
+
const dash = leaf.indexOf('-');
|
|
11
|
+
return dash < 0 ? undefined : leaf.slice(dash + 1);
|
|
12
|
+
}
|
|
13
|
+
/** The table of longest common subsequence lengths, read back from the end to
|
|
14
|
+
* give the pairs themselves. */
|
|
15
|
+
function pairedPlaces(from, to) {
|
|
16
|
+
const rows = from.length;
|
|
17
|
+
const columns = to.length;
|
|
18
|
+
const longest = Array.from({ length: rows + 1 }, () => new Array(columns + 1).fill(0));
|
|
19
|
+
for (let row = rows - 1; row >= 0; row--) {
|
|
20
|
+
for (let column = columns - 1; column >= 0; column--) {
|
|
21
|
+
const same = from[row] !== undefined && from[row] === to[column];
|
|
22
|
+
longest[row][column] = same
|
|
23
|
+
? longest[row + 1][column + 1] + 1
|
|
24
|
+
: Math.max(longest[row + 1][column], longest[row][column + 1]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const places = [];
|
|
28
|
+
let row = 0;
|
|
29
|
+
let column = 0;
|
|
30
|
+
while (row < rows && column < columns) {
|
|
31
|
+
if (from[row] !== undefined && from[row] === to[column]) {
|
|
32
|
+
places.push([row, column]);
|
|
33
|
+
row++;
|
|
34
|
+
column++;
|
|
35
|
+
}
|
|
36
|
+
else if (longest[row + 1][column] >= longest[row][column + 1])
|
|
37
|
+
row++;
|
|
38
|
+
else
|
|
39
|
+
column++;
|
|
40
|
+
}
|
|
41
|
+
return places;
|
|
42
|
+
}
|
|
43
|
+
const glyphsOf = (marks) => marks.filter((mark) => mark.kind === 'path');
|
|
44
|
+
/** Two typeset expressions paired glyph by glyph, with what neither answers kept
|
|
45
|
+
* apart. Marks that are not paths are left out, since a glyph is an outline. */
|
|
46
|
+
export function matchGlyphs(from, to) {
|
|
47
|
+
const left = glyphsOf(from);
|
|
48
|
+
const right = glyphsOf(to);
|
|
49
|
+
const places = pairedPlaces(left.map((mark) => glyphToken(mark.id)), right.map((mark) => glyphToken(mark.id)));
|
|
50
|
+
const takenLeft = new Set(places.map(([row]) => row));
|
|
51
|
+
const takenRight = new Set(places.map(([, column]) => column));
|
|
52
|
+
return {
|
|
53
|
+
pairs: places.map(([row, column]) => [left[row], right[column]]),
|
|
54
|
+
leaving: left.filter((_, at) => !takenLeft.has(at)),
|
|
55
|
+
arriving: right.filter((_, at) => !takenRight.has(at)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Vec2 } from '../values/vec2.js';
|
|
2
|
+
import { type GroupNode } from './node.js';
|
|
3
|
+
import { type EquationElement } from './typeset.js';
|
|
4
|
+
import type { Fill, PathMark } from './mark.js';
|
|
5
|
+
/** Where the typesetter put the expression, in the marks' own units and with y
|
|
6
|
+
* counted upward, so `y` is the bottom edge and `y + height` the top. */
|
|
7
|
+
export interface EquationBox {
|
|
8
|
+
readonly x: number;
|
|
9
|
+
readonly y: number;
|
|
10
|
+
readonly width: number;
|
|
11
|
+
readonly height: number;
|
|
12
|
+
}
|
|
13
|
+
export interface Equation {
|
|
14
|
+
readonly marks: readonly PathMark[];
|
|
15
|
+
readonly box: EquationBox;
|
|
16
|
+
}
|
|
17
|
+
export declare function equationMarks(root: EquationElement): Equation;
|
|
18
|
+
/** One expression typeset and read, which is the two halves above in the order
|
|
19
|
+
* they are always used in. */
|
|
20
|
+
export declare function equationFromTex(tex: string): Promise<Equation>;
|
|
21
|
+
export interface EquationOptions {
|
|
22
|
+
/** The point the expression is placed against, in the figure's own units. */
|
|
23
|
+
readonly at: Vec2;
|
|
24
|
+
/** Which edge of the expression sits on that point across, the middle of it
|
|
25
|
+
* unless named. Two expressions placed at one point by their start keep the
|
|
26
|
+
* part they share in the same place. */
|
|
27
|
+
readonly align?: 'start' | 'middle' | 'end';
|
|
28
|
+
/** The box the expression is fitted inside, in the figure's own units. */
|
|
29
|
+
readonly width: number;
|
|
30
|
+
readonly height: number;
|
|
31
|
+
readonly fill: Fill;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A typeset expression placed in a figure: one shape per glyph, fitted inside a
|
|
35
|
+
* box and centred on a point.
|
|
36
|
+
*
|
|
37
|
+
* It fits inside both measurements rather than being sized by the height alone.
|
|
38
|
+
* An expression two units wide for every one it is tall runs off the sides of a
|
|
39
|
+
* narrow figure the moment its height is what decides its size.
|
|
40
|
+
*
|
|
41
|
+
* The group carries the transform rather than the geometry, so the glyphs stay
|
|
42
|
+
* the typesetter's own numbers and moving the expression is one matrix.
|
|
43
|
+
*/
|
|
44
|
+
export declare function equationNode(name: string, equation: Equation, options: EquationOptions): GroupNode;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typeset expression read out of the SVG a typesetter wrote.
|
|
3
|
+
*
|
|
4
|
+
* MathJax describes an expression as nested groups with a transform on each,
|
|
5
|
+
* every glyph as an outline and a fraction bar or a root's rule as a rectangle.
|
|
6
|
+
* A figure holds a flat list of marks with every transform already applied, so
|
|
7
|
+
* this walks the tree, carries the transform stack down it, and hands back the
|
|
8
|
+
* marks with the box the typesetter measured the expression into.
|
|
9
|
+
*
|
|
10
|
+
* The stack starts turned over, because SVG counts y downward and a figure
|
|
11
|
+
* counts it upward. Doing it here means the whole expression arrives in the
|
|
12
|
+
* figure's own space and nothing downstream has to know which way up the
|
|
13
|
+
* typesetter works.
|
|
14
|
+
*
|
|
15
|
+
* A mark comes back with no fill and its id is a leaf name, because both arrive
|
|
16
|
+
* when the equation is placed in a figure: a figure's node tree builds an id out
|
|
17
|
+
* of the names on the way down it, so a full path written here would be a second
|
|
18
|
+
* naming of the same mark. The colour arrives there too, since a figure is handed
|
|
19
|
+
* its palette as it is drawn rather than reading one.
|
|
20
|
+
*
|
|
21
|
+
* An id carries the glyph's own code point, which is what a match between two
|
|
22
|
+
* expressions has to be made on.
|
|
23
|
+
*
|
|
24
|
+
* Three things stop the walk instead of being drawn, and each names what it
|
|
25
|
+
* found. They are written up on `refuse` below.
|
|
26
|
+
*/
|
|
27
|
+
import { mat3 } from '../values/mat3.js';
|
|
28
|
+
import { vec2 } from '../values/vec2.js';
|
|
29
|
+
import { rect, transformPath } from './path.js';
|
|
30
|
+
import { group, shape } from './node.js';
|
|
31
|
+
import { pathFromData } from './path-data.js';
|
|
32
|
+
import { typesetElement } from './typeset.js';
|
|
33
|
+
/** Both transforms a typeset expression uses. Anything else refuses rather than
|
|
34
|
+
* being ignored, which would leave a glyph sitting at the origin. */
|
|
35
|
+
function transformOf(text) {
|
|
36
|
+
let matrix = mat3.IDENTITY;
|
|
37
|
+
for (const match of text.matchAll(/([A-Za-z]+)\s*\(([^)]*)\)/g)) {
|
|
38
|
+
const name = match[1] ?? '';
|
|
39
|
+
const numbers = (match[2] ?? '')
|
|
40
|
+
.split(/[,\s]+/)
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map(Number);
|
|
43
|
+
if (numbers.length === 0 || numbers.some((value) => !Number.isFinite(value)))
|
|
44
|
+
throw new Error(`the transform "${text}" carries something that is not a number`);
|
|
45
|
+
const [first = 0, second] = numbers;
|
|
46
|
+
if (name === 'translate')
|
|
47
|
+
matrix = mat3.multiply(matrix, mat3.translation(vec2(first, second ?? 0)));
|
|
48
|
+
else if (name === 'scale')
|
|
49
|
+
matrix = mat3.multiply(matrix, mat3.scaling(vec2(first, second ?? first)));
|
|
50
|
+
else
|
|
51
|
+
throw new Error(`the transform "${text}" asks for "${name}", which this does not apply`);
|
|
52
|
+
}
|
|
53
|
+
return matrix;
|
|
54
|
+
}
|
|
55
|
+
function attribute(element, name) {
|
|
56
|
+
const value = element.attributes[name];
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
throw new Error(`a "${element.tag}" element in the typeset expression has no ${name}`);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
function numberAttribute(element, name, fallback) {
|
|
62
|
+
const written = element.attributes[name];
|
|
63
|
+
if (written === undefined && fallback !== undefined)
|
|
64
|
+
return fallback;
|
|
65
|
+
const value = Number(attribute(element, name));
|
|
66
|
+
if (!Number.isFinite(value))
|
|
67
|
+
throw new Error(`a "${element.tag}" element has ${name}="${written}", which is not a number`);
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function svgIn(element) {
|
|
71
|
+
if (element.tag === 'svg')
|
|
72
|
+
return element;
|
|
73
|
+
for (const child of element.children) {
|
|
74
|
+
const found = svgIn(child);
|
|
75
|
+
if (found)
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
function svgOf(element) {
|
|
81
|
+
const found = svgIn(element);
|
|
82
|
+
if (!found)
|
|
83
|
+
throw new Error('the typesetter returned no svg element');
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
/** The colour the `noundefined` extension draws a macro the typesetter does not
|
|
87
|
+
* know, which is the only sign that it did not typeset one. */
|
|
88
|
+
const UNKNOWN = 'red';
|
|
89
|
+
/** The characters under an element, read off the code point each glyph carries.
|
|
90
|
+
* It is what names a run the typesetter drew instead of typesetting. */
|
|
91
|
+
function charactersOf(element) {
|
|
92
|
+
const code = element.tag === 'path' ? Number.parseInt(element.attributes['data-c'] ?? '', 16) : NaN;
|
|
93
|
+
const own = Number.isInteger(code) ? String.fromCodePoint(code) : '';
|
|
94
|
+
return own + element.children.map(charactersOf).join('');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The three things that stop the walk rather than being drawn, each naming what
|
|
98
|
+
* it found.
|
|
99
|
+
*
|
|
100
|
+
* A TeX error carries its message on the group MathJax puts the error box in. A
|
|
101
|
+
* character the font has no outline for arrives as a text element, which draws
|
|
102
|
+
* in a browser with whatever font it found and draws nothing at all in a
|
|
103
|
+
* recording. And an undefined macro is not an error under `AllPackages`, since
|
|
104
|
+
* the `noundefined` extension in it draws the macro's own name in red, so a typo
|
|
105
|
+
* would otherwise ship as a red word inside the picture.
|
|
106
|
+
*/
|
|
107
|
+
function refuse(element) {
|
|
108
|
+
const error = element.attributes['data-mjx-error'];
|
|
109
|
+
if (error !== undefined)
|
|
110
|
+
throw new Error(`the typesetter refused the expression: ${error}`);
|
|
111
|
+
if (element.attributes.fill === UNKNOWN)
|
|
112
|
+
throw new Error(`the typesetter does not know "${charactersOf(element)}" and drew it in ${UNKNOWN}`);
|
|
113
|
+
if (element.tag === 'text')
|
|
114
|
+
throw new Error(`the typesetter has no outline for "${element.text ?? ''}" and wrote it as text`);
|
|
115
|
+
}
|
|
116
|
+
/** The four numbers of the `viewBox`, turned over the way the marks are. */
|
|
117
|
+
function boxOf(svg) {
|
|
118
|
+
const numbers = attribute(svg, 'viewBox')
|
|
119
|
+
.split(/[,\s]+/)
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map(Number);
|
|
122
|
+
if (numbers.length !== 4 || numbers.some((value) => !Number.isFinite(value)))
|
|
123
|
+
throw new Error(`the svg has viewBox="${svg.attributes.viewBox}", which is not four numbers`);
|
|
124
|
+
const [x = 0, y = 0, width = 0, height = 0] = numbers;
|
|
125
|
+
return { x, y: -(y + height), width, height };
|
|
126
|
+
}
|
|
127
|
+
export function equationMarks(root) {
|
|
128
|
+
const svg = svgOf(root);
|
|
129
|
+
const marks = [];
|
|
130
|
+
const mark = (path, stack, suffix) => {
|
|
131
|
+
marks.push({ kind: 'path', id: `${marks.length}-${suffix}`, path: transformPath(path, stack) });
|
|
132
|
+
};
|
|
133
|
+
const walk = (element, stack) => {
|
|
134
|
+
for (const child of element.children) {
|
|
135
|
+
refuse(child);
|
|
136
|
+
const written = child.attributes.transform;
|
|
137
|
+
const own = written ? mat3.multiply(stack, transformOf(written)) : stack;
|
|
138
|
+
switch (child.tag) {
|
|
139
|
+
case 'g':
|
|
140
|
+
walk(child, own);
|
|
141
|
+
break;
|
|
142
|
+
case 'path':
|
|
143
|
+
mark(pathFromData(attribute(child, 'd')), own, child.attributes['data-c'] ?? 'glyph');
|
|
144
|
+
break;
|
|
145
|
+
case 'rect':
|
|
146
|
+
mark(rect(vec2(numberAttribute(child, 'x', 0), numberAttribute(child, 'y', 0)), numberAttribute(child, 'width'), numberAttribute(child, 'height')), own, 'rule');
|
|
147
|
+
break;
|
|
148
|
+
default:
|
|
149
|
+
throw new Error(`the typeset expression holds a "${child.tag}" element, which this does not draw`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
walk(svg, mat3.scaling(vec2(1, -1)));
|
|
154
|
+
return { marks, box: boxOf(svg) };
|
|
155
|
+
}
|
|
156
|
+
/** One expression typeset and read, which is the two halves above in the order
|
|
157
|
+
* they are always used in. */
|
|
158
|
+
export async function equationFromTex(tex) {
|
|
159
|
+
return equationMarks(await typesetElement(tex));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A typeset expression placed in a figure: one shape per glyph, fitted inside a
|
|
163
|
+
* box and centred on a point.
|
|
164
|
+
*
|
|
165
|
+
* It fits inside both measurements rather than being sized by the height alone.
|
|
166
|
+
* An expression two units wide for every one it is tall runs off the sides of a
|
|
167
|
+
* narrow figure the moment its height is what decides its size.
|
|
168
|
+
*
|
|
169
|
+
* The group carries the transform rather than the geometry, so the glyphs stay
|
|
170
|
+
* the typesetter's own numbers and moving the expression is one matrix.
|
|
171
|
+
*/
|
|
172
|
+
export function equationNode(name, equation, options) {
|
|
173
|
+
const { box } = equation;
|
|
174
|
+
const fit = Math.min(options.width / box.width, options.height / box.height);
|
|
175
|
+
// The point the expression is hung from, in the typesetter's units, so that
|
|
176
|
+
// scaling about it lands the asked-for edge on the asked-for place.
|
|
177
|
+
const across = options.align === 'start' ? box.x : options.align === 'end' ? box.x + box.width : box.x + box.width / 2;
|
|
178
|
+
const hung = vec2(across, box.y + box.height / 2);
|
|
179
|
+
const transform = mat3.multiply(mat3.translation(options.at), mat3.multiply(mat3.scaling(vec2(fit, fit)), mat3.translation(vec2(-hung.x, -hung.y))));
|
|
180
|
+
return group(name, equation.marks.map((mark) => shape(mark.id, mark.path, { fill: options.fill })), { transform });
|
|
181
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One element of the tree a typesetter wrote, with its attributes as written.
|
|
3
|
+
*
|
|
4
|
+
* The walk that turns this into marks takes this shape rather than the
|
|
5
|
+
* typesetter's own, which is what lets the walk be tested by building a tree by
|
|
6
|
+
* hand with no typesetter behind it.
|
|
7
|
+
*/
|
|
8
|
+
export interface EquationElement {
|
|
9
|
+
readonly tag: string;
|
|
10
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
11
|
+
readonly children: readonly EquationElement[];
|
|
12
|
+
/** A text element's own characters, which is what names it when it is
|
|
13
|
+
* refused. Nothing else here carries any. */
|
|
14
|
+
readonly text?: string;
|
|
15
|
+
}
|
|
16
|
+
/** One expression typeset, as the tree the typesetter wrote it. `display: true`
|
|
17
|
+
* is the centred form rather than the one that sits inside a line of prose. */
|
|
18
|
+
export declare function typesetElement(tex: string): Promise<EquationElement>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Held as the promise rather than the value: two calls arriving together share
|
|
2
|
+
// one load, and registering the handler twice leaves MathJax with two of them.
|
|
3
|
+
let shared;
|
|
4
|
+
async function load() {
|
|
5
|
+
const [{ mathjax }, { TeX }, { SVG }, { liteAdaptor }, { RegisterHTMLHandler }, { AllPackages }] = await Promise.all([
|
|
6
|
+
import('mathjax-full/js/mathjax.js'),
|
|
7
|
+
import('mathjax-full/js/input/tex.js'),
|
|
8
|
+
import('mathjax-full/js/output/svg.js'),
|
|
9
|
+
import('mathjax-full/js/adaptors/liteAdaptor.js'),
|
|
10
|
+
import('mathjax-full/js/handlers/html.js'),
|
|
11
|
+
import('mathjax-full/js/input/tex/AllPackages.js'),
|
|
12
|
+
]);
|
|
13
|
+
const adaptor = liteAdaptor();
|
|
14
|
+
RegisterHTMLHandler(adaptor);
|
|
15
|
+
const document = mathjax.document('', {
|
|
16
|
+
InputJax: new TeX({ packages: AllPackages }),
|
|
17
|
+
OutputJax: new SVG({ fontCache: 'none' }),
|
|
18
|
+
});
|
|
19
|
+
return { adaptor, document };
|
|
20
|
+
}
|
|
21
|
+
/** MathJax's own tree read into the shape above, with a text node's characters
|
|
22
|
+
* gathered onto the element that holds them. */
|
|
23
|
+
function elementOf(adaptor, node) {
|
|
24
|
+
const children = [];
|
|
25
|
+
let text = '';
|
|
26
|
+
for (const child of adaptor.childNodes(node)) {
|
|
27
|
+
if (adaptor.kind(child) === '#text')
|
|
28
|
+
text += adaptor.value(child);
|
|
29
|
+
else
|
|
30
|
+
children.push(elementOf(adaptor, child));
|
|
31
|
+
}
|
|
32
|
+
const attributes = {};
|
|
33
|
+
for (const { name, value } of adaptor.allAttributes(node))
|
|
34
|
+
attributes[name] = value;
|
|
35
|
+
return { tag: adaptor.kind(node), attributes, children, text };
|
|
36
|
+
}
|
|
37
|
+
/** One expression typeset, as the tree the typesetter wrote it. `display: true`
|
|
38
|
+
* is the centred form rather than the one that sits inside a line of prose. */
|
|
39
|
+
export async function typesetElement(tex) {
|
|
40
|
+
shared ??= load();
|
|
41
|
+
const { adaptor, document } = await shared;
|
|
42
|
+
return elementOf(adaptor, document.convert(tex, { display: true }));
|
|
43
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export { labelFor, tickStep, ticksOn } from './figure/ticks.js';
|
|
|
38
38
|
export type { Tick } from './figure/ticks.js';
|
|
39
39
|
export { flatten, group, shape, text } from './figure/node.js';
|
|
40
40
|
export type { GroupNode, Node, ShapeNode, Style, TextNode, TextOptions } from './figure/node.js';
|
|
41
|
-
export { circumscribe, fadeIn, fadeOut, fadeTo, draw, flash, growFrom, indicate, morph, moveAlong, moveBy, rotate, scale } from './figure/animation.js';
|
|
41
|
+
export { circumscribe, fadeIn, fadeOut, fadeTo, draw, flash, growFrom, indicate, morph, morphEquation, moveAlong, moveBy, rotate, scale } from './figure/animation.js';
|
|
42
42
|
export type { AboutOptions, Animation, CircumscribeOptions, FlashOptions, IndicateOptions, ScaleOptions } from './figure/animation.js';
|
|
43
43
|
export { Timeline } from './figure/timeline.js';
|
|
44
44
|
export type { PlayOptions, Span, StaggerOptions } from './figure/timeline.js';
|
|
@@ -53,3 +53,9 @@ export { paintCanvas } from './paint/canvas.js';
|
|
|
53
53
|
export type { CanvasLike } from './paint/canvas.js';
|
|
54
54
|
export { arrow, callout, dot } from './figure/annotate.js';
|
|
55
55
|
export type { ArrowOptions, CalloutOptions } from './figure/annotate.js';
|
|
56
|
+
export { typesetElement } from './figure/typeset.js';
|
|
57
|
+
export type { EquationElement } from './figure/typeset.js';
|
|
58
|
+
export { equationFromTex, equationMarks, equationNode } from './figure/equation.js';
|
|
59
|
+
export type { Equation, EquationBox, EquationOptions } from './figure/equation.js';
|
|
60
|
+
export { glyphToken, matchGlyphs } from './figure/equation-match.js';
|
|
61
|
+
export type { GlyphMatch } from './figure/equation-match.js';
|
package/dist/index.js
CHANGED
|
@@ -23,7 +23,7 @@ export { axes, numberLine, numberPlane } from './figure/axis.js';
|
|
|
23
23
|
export { coordsOf, pointOf, scaleOf, scaled, unscaled } from './figure/scale.js';
|
|
24
24
|
export { labelFor, tickStep, ticksOn } from './figure/ticks.js';
|
|
25
25
|
export { flatten, group, shape, text } from './figure/node.js';
|
|
26
|
-
export { circumscribe, fadeIn, fadeOut, fadeTo, draw, flash, growFrom, indicate, morph, moveAlong, moveBy, rotate, scale } from './figure/animation.js';
|
|
26
|
+
export { circumscribe, fadeIn, fadeOut, fadeTo, draw, flash, growFrom, indicate, morph, morphEquation, moveAlong, moveBy, rotate, scale } from './figure/animation.js';
|
|
27
27
|
export { Timeline } from './figure/timeline.js';
|
|
28
28
|
export { lengthOf, pointAlong } from './figure/length.js';
|
|
29
29
|
export { trimPath } from './figure/trim.js';
|
|
@@ -32,3 +32,6 @@ export { at, durationOf, loops, sameMarks } from './figure/figure.js';
|
|
|
32
32
|
export { pathData, paintSvg, svgElements, svgMarkup } from './paint/svg.js';
|
|
33
33
|
export { paintCanvas } from './paint/canvas.js';
|
|
34
34
|
export { arrow, callout, dot } from './figure/annotate.js';
|
|
35
|
+
export { typesetElement } from './figure/typeset.js';
|
|
36
|
+
export { equationFromTex, equationMarks, equationNode } from './figure/equation.js';
|
|
37
|
+
export { glyphToken, matchGlyphs } from './figure/equation-match.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@altpsyche/maths",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The mathematics AltPsyche's figures are drawn from: vectors, matrices, curves, and a value walked over time.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Siva",
|
|
@@ -47,5 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
|
49
49
|
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"mathjax-full": "^3.2.1"
|
|
50
53
|
}
|
|
51
54
|
}
|