@altpsyche/maths 0.6.0 → 0.8.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 +70 -5
- package/dist/figure/animation.d.ts +30 -0
- package/dist/figure/animation.js +53 -0
- package/dist/figure/annotate.d.ts +47 -0
- package/dist/figure/annotate.js +75 -1
- package/dist/figure/equation-match.d.ts +42 -0
- package/dist/figure/equation-match.js +57 -0
- package/dist/figure/equation.d.ts +5 -1
- package/dist/figure/equation.js +5 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,8 +24,10 @@ svgMarkup(at(figure, 0.5), viewMatrix(figure.extent, 'contain', 640, 360), 640,
|
|
|
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
|
|
28
|
-
|
|
27
|
+
slope is nothing. Then it walks the curve at one speed and flashes at the top, and a brace measures
|
|
28
|
+
how far it climbed with its number counting up to the rise. The number in the corner is a value of
|
|
29
|
+
the typeset rule under it, and that rule reads no slope while the dot is held at the stationary point
|
|
30
|
+
and walks into the one that depends on x as the dot leaves.
|
|
29
31
|
|
|
30
32
|
```ts
|
|
31
33
|
import { axes, coordsOf, group, interval, numberPlane, plot, scaleOf, shape } from '@altpsyche/maths';
|
|
@@ -58,7 +60,7 @@ region is the limit of at the left edge, the right edge or the middle of each on
|
|
|
58
60
|
lays the tangent along the curve, cut where it leaves the graph. `slopeOf` reads the slope itself,
|
|
59
61
|
which is what the number in the corner is.
|
|
60
62
|
|
|
61
|
-
<img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve
|
|
63
|
+
<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, the typeset rule in the corner changing from a slope of nothing to one that depends on x, and a brace measuring the rise in the last frame.">
|
|
62
64
|
|
|
63
65
|
Four times of one figure, side by side: the picture arrived, the beat at the stationary point, half
|
|
64
66
|
way up, and the top. A moving picture in a README needs a GIF and this package has no encoder, so the
|
|
@@ -92,16 +94,79 @@ MathJax is the one runtime dependency and the typesetting call is what loads it.
|
|
|
92
94
|
reaches none of it, so a consumer who draws figures and typesets nothing pays nothing. What that
|
|
93
95
|
costs is that typesetting answers with a promise.
|
|
94
96
|
|
|
97
|
+
`matchGlyphs` says which glyph of one expression is which glyph of the other, and `morphEquation`
|
|
98
|
+
walks one into the next: the shared sub-expressions stay put and only the difference moves. Two marks
|
|
99
|
+
match on the part of the leaf name after the first dash, which the typesetter's own naming gives, so
|
|
100
|
+
a glyph is `3-1D465` and a fraction bar is `4-rule`. The pairing is the longest common subsequence of
|
|
101
|
+
the two token sequences, which pairs each occurrence of a repeated glyph once and refuses a pair that
|
|
102
|
+
would cross another pair on the way over.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { equationFromTex, equationNode, group, morphEquation, vec2 } from '@altpsyche/maths';
|
|
106
|
+
|
|
107
|
+
const box = { at: vec2(-4.86, 1.74), align: 'start', width: 1.2, height: 0.6, fill: { colour: '#1b1b1b' } } as const;
|
|
108
|
+
|
|
109
|
+
group('rule', [
|
|
110
|
+
equationNode('at-rest', await equationFromTex('\\frac{dy}{dx} = 0'), box),
|
|
111
|
+
equationNode('moving', await equationFromTex('\\frac{dy}{dx} = 2x'), box),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
morphEquation('rule/at-rest', 'rule/moving');
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Both expressions are in the scene at every time and the animation moves one onto the other, because a
|
|
118
|
+
mark that arrived part way through a span would turn up in a comparison between two frames as
|
|
119
|
+
something that changed. A paired glyph is drawn once rather than cross-faded, so the glyph being left
|
|
120
|
+
carries the walk and its partner stays at nothing until it is being stood on exactly. Hang both
|
|
121
|
+
expressions from the same edge with `align`, or the part they share slides sideways as the difference
|
|
122
|
+
arrives.
|
|
123
|
+
|
|
95
124
|
Three things stop a typeset expression rather than being drawn, and each names what it found. A TeX
|
|
96
125
|
error carries the typesetter's own message. A character the font has no outline for arrives as text,
|
|
97
126
|
which would draw with whatever font a browser had and draw nothing at all in a recording. An
|
|
98
127
|
undefined macro is not an error at all, because MathJax draws the macro's own name in red, so a typo
|
|
99
128
|
would otherwise ship as a red word inside the picture.
|
|
100
129
|
|
|
130
|
+
## Braces and a number that counts
|
|
131
|
+
|
|
132
|
+
`bracePath` draws a curly brace from one point to another with its tip standing off the line between
|
|
133
|
+
them, and `brace` is that path with a word beyond the tip. It is one open subpath of six pieces: a
|
|
134
|
+
curl out of each end, a run along at the curl's own height, and two curls meeting at the tip. The tip
|
|
135
|
+
is a corner rather than a smooth turn, which is what a brace has and what says which point of it is
|
|
136
|
+
doing the pointing. The tip stands at the depth asked for whatever the span, and only the curl
|
|
137
|
+
narrows when the span is short, so two close points get a shallower brace rather than one whose
|
|
138
|
+
halves cross.
|
|
139
|
+
|
|
140
|
+
The label is anchored and never measured, because nothing about a figure's layout may depend on how
|
|
141
|
+
wide some text is.
|
|
142
|
+
|
|
143
|
+
`countTo` writes the value a count has reached into a text mark. How the value is written is the
|
|
144
|
+
caller's, so a count of a length and a count of a population can round differently and this holds no
|
|
145
|
+
opinion about either.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { brace, countTo, labelFor, pointOf, vec2 } from '@altpsyche/maths';
|
|
149
|
+
|
|
150
|
+
brace('rise', pointOf(coords, 3, 9), pointOf(coords, 3, 0), labelFor(9, 0.01), {
|
|
151
|
+
depth: 0.3,
|
|
152
|
+
padding: 0.28,
|
|
153
|
+
stroke: pen,
|
|
154
|
+
fill: ink,
|
|
155
|
+
size: 0.3,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
countTo('rise/word', 0, 9, (value) => labelFor(value, 0.01));
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A number driven by the clock is not the same as a reading driven by a track, and the difference
|
|
162
|
+
matters. The slope in the demo is a value of the walk, so it is worked out by the scene from the
|
|
163
|
+
track and cannot drift from the dot. The rise is counted by the clock, which is honest only because
|
|
164
|
+
the dot has stopped by the time it counts: two clocks running at once would be free to disagree.
|
|
165
|
+
|
|
101
166
|
## The animations
|
|
102
167
|
|
|
103
|
-
`fadeIn`, `fadeOut`, `fadeTo`, `draw`, `morph`, `
|
|
104
|
-
`indicate`, `flash` and `circumscribe`. A `Timeline` plays them in order, plays several `together`, or
|
|
168
|
+
`fadeIn`, `fadeOut`, `fadeTo`, `draw`, `morph`, `morphEquation`, `countTo`, `moveBy`, `rotate`,
|
|
169
|
+
`scale`, `growFrom`, `moveAlong`, `indicate`, `flash` and `circumscribe`. A `Timeline` plays them in order, plays several `together`, or
|
|
105
170
|
`stagger`s a row so its parts arrive one after another.
|
|
106
171
|
|
|
107
172
|
A turn and a growth happen about a point the marks decide for themselves, which is the middle of the
|
|
@@ -24,6 +24,36 @@ 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;
|
|
46
|
+
/**
|
|
47
|
+
* A number ticking from one value to another, written into a text mark.
|
|
48
|
+
*
|
|
49
|
+
* How the value is written is the caller's, so this holds no opinion about
|
|
50
|
+
* decimal places: a count of a length and a count of a population want different
|
|
51
|
+
* rounding and neither is this function's to choose.
|
|
52
|
+
*
|
|
53
|
+
* The scene writes the value the count ends at, which is what this writes at the
|
|
54
|
+
* end of its span, so the two never disagree about what the number settles on.
|
|
55
|
+
*/
|
|
56
|
+
export declare function countTo(target: string, from: number, to: number, write: (value: number) => string): Animation;
|
|
27
57
|
/** A mark's own opacity walked to a value, for a figure that wants a thing dimmed
|
|
28
58
|
* rather than gone. */
|
|
29
59
|
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,58 @@ 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
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A number ticking from one value to another, written into a text mark.
|
|
124
|
+
*
|
|
125
|
+
* How the value is written is the caller's, so this holds no opinion about
|
|
126
|
+
* decimal places: a count of a length and a count of a population want different
|
|
127
|
+
* rounding and neither is this function's to choose.
|
|
128
|
+
*
|
|
129
|
+
* The scene writes the value the count ends at, which is what this writes at the
|
|
130
|
+
* end of its span, so the two never disagree about what the number settles on.
|
|
131
|
+
*/
|
|
132
|
+
export function countTo(target, from, to, write) {
|
|
133
|
+
return over(target, (mark, along) => (mark.kind === 'text' ? { ...mark, text: write(lerp(from, to, along)) } : mark));
|
|
134
|
+
}
|
|
82
135
|
/** A mark's own opacity walked to a value, for a figure that wants a thing dimmed
|
|
83
136
|
* rather than gone. */
|
|
84
137
|
export function fadeTo(target, opacity) {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* filled at its head, and no mark can be both.
|
|
9
9
|
*/
|
|
10
10
|
import { type Vec2 } from '../values/vec2.js';
|
|
11
|
+
import { type Path } from './path.js';
|
|
11
12
|
import { type GroupNode, type TextOptions } from './node.js';
|
|
12
13
|
import type { Fill, Stroke } from './mark.js';
|
|
13
14
|
export interface ArrowOptions {
|
|
@@ -32,6 +33,52 @@ export interface ArrowOptions {
|
|
|
32
33
|
export declare function arrow(name: string, from: Vec2, to: Vec2, options: ArrowOptions): GroupNode;
|
|
33
34
|
/** A filled disc, which is what marks a place a line is pointing at. */
|
|
34
35
|
export declare function dot(name: string, at: Vec2, radius: number, fill: Fill): GroupNode;
|
|
36
|
+
export interface BraceOptions {
|
|
37
|
+
/** How far the tip stands off the line between the two points, in figure
|
|
38
|
+
* units. A negative depth puts the brace on the other side of that line. */
|
|
39
|
+
depth: number;
|
|
40
|
+
/**
|
|
41
|
+
* How wide the curl at each end and at the tip is, in figure units. Half the
|
|
42
|
+
* depth unless named, which is the curl a quarter circle gives, and never more
|
|
43
|
+
* than a quarter of the span, since two curls wider than that would cross.
|
|
44
|
+
*/
|
|
45
|
+
curl?: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A curly brace from one point to the other, with its tip standing off the line
|
|
49
|
+
* between them.
|
|
50
|
+
*
|
|
51
|
+
* It is one open subpath of six pieces: a curl out of each end, a run along at
|
|
52
|
+
* the curl's own height, and two curls meeting at the tip. The tip is a corner
|
|
53
|
+
* rather than a smooth turn, which is what a brace has and what says which point
|
|
54
|
+
* of it is being pointed at.
|
|
55
|
+
*
|
|
56
|
+
* The tip stands at the depth asked for whatever the span, and only the curl
|
|
57
|
+
* narrows when the span is short, so a brace between two close points is a
|
|
58
|
+
* shallower shape rather than one whose halves cross.
|
|
59
|
+
*/
|
|
60
|
+
export declare function bracePath(from: Vec2, to: Vec2, options: BraceOptions): Path;
|
|
61
|
+
export interface BracedOptions extends BraceOptions {
|
|
62
|
+
stroke: Stroke;
|
|
63
|
+
fill: Fill;
|
|
64
|
+
size: number;
|
|
65
|
+
/** How far beyond the tip the label's anchor sits, in figure units. */
|
|
66
|
+
padding?: number;
|
|
67
|
+
align?: TextOptions['align'];
|
|
68
|
+
baseline?: TextOptions['baseline'];
|
|
69
|
+
family?: string;
|
|
70
|
+
weight?: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A brace with a word on it, placed beyond the tip on the far side from the two
|
|
74
|
+
* points.
|
|
75
|
+
*
|
|
76
|
+
* The label is anchored and never measured. Nothing about a figure's layout may
|
|
77
|
+
* depend on how wide some text is, because the width depends on which fonts the
|
|
78
|
+
* machine has and a box sized to fit a label would be a different box on two
|
|
79
|
+
* machines.
|
|
80
|
+
*/
|
|
81
|
+
export declare function brace(name: string, from: Vec2, to: Vec2, content: string, options: BracedOptions): GroupNode;
|
|
35
82
|
export interface CalloutOptions {
|
|
36
83
|
stroke: Stroke;
|
|
37
84
|
fill: Fill;
|
package/dist/figure/annotate.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* filled at its head, and no mark can be both.
|
|
9
9
|
*/
|
|
10
10
|
import { vec2 } from '../values/vec2.js';
|
|
11
|
-
import { circle, line, polygon } from './path.js';
|
|
11
|
+
import { circle, line, polygon, straight } from './path.js';
|
|
12
12
|
import { group, shape, text } from './node.js';
|
|
13
13
|
/**
|
|
14
14
|
* A line with a head at the far end.
|
|
@@ -32,6 +32,80 @@ export function arrow(name, from, to, options) {
|
|
|
32
32
|
export function dot(name, at, radius, fill) {
|
|
33
33
|
return group(name, [shape('disc', circle(at, radius), { fill })]);
|
|
34
34
|
}
|
|
35
|
+
/** The control distance a quarter circle wants, which is what makes each curl of
|
|
36
|
+
* a brace an arc rather than a corner rounded by eye. */
|
|
37
|
+
const QUARTER = (4 / 3) * (Math.SQRT2 - 1);
|
|
38
|
+
/**
|
|
39
|
+
* A curly brace from one point to the other, with its tip standing off the line
|
|
40
|
+
* between them.
|
|
41
|
+
*
|
|
42
|
+
* It is one open subpath of six pieces: a curl out of each end, a run along at
|
|
43
|
+
* the curl's own height, and two curls meeting at the tip. The tip is a corner
|
|
44
|
+
* rather than a smooth turn, which is what a brace has and what says which point
|
|
45
|
+
* of it is being pointed at.
|
|
46
|
+
*
|
|
47
|
+
* The tip stands at the depth asked for whatever the span, and only the curl
|
|
48
|
+
* narrows when the span is short, so a brace between two close points is a
|
|
49
|
+
* shallower shape rather than one whose halves cross.
|
|
50
|
+
*/
|
|
51
|
+
export function bracePath(from, to, options) {
|
|
52
|
+
const span = vec2.distance(from, to);
|
|
53
|
+
if (span === 0)
|
|
54
|
+
return [];
|
|
55
|
+
const along = vec2.normalize(vec2.sub(to, from));
|
|
56
|
+
const out = vec2.perpendicular(along);
|
|
57
|
+
const depth = options.depth;
|
|
58
|
+
const curl = Math.min(Math.abs(options.curl ?? depth / 2), span / 4);
|
|
59
|
+
const rise = Math.sign(depth || 1) * curl;
|
|
60
|
+
const at = (forward, off) => vec2.add(from, vec2.add(vec2.scale(along, forward), vec2.scale(out, off)));
|
|
61
|
+
const shoulder = at(curl, rise);
|
|
62
|
+
const beforeTip = at(span / 2 - curl, rise);
|
|
63
|
+
const tip = at(span / 2, depth);
|
|
64
|
+
const afterTip = at(span / 2 + curl, rise);
|
|
65
|
+
const beyond = at(span - curl, rise);
|
|
66
|
+
const reach = QUARTER * curl;
|
|
67
|
+
const lift = QUARTER * (depth - rise);
|
|
68
|
+
const piece = (control1, control2, end) => ({ control1, control2, to: end });
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
start: from,
|
|
72
|
+
curves: [
|
|
73
|
+
piece(vec2.add(from, vec2.scale(out, rise * QUARTER)), vec2.sub(shoulder, vec2.scale(along, reach)), shoulder),
|
|
74
|
+
straight(shoulder, beforeTip),
|
|
75
|
+
piece(vec2.add(beforeTip, vec2.scale(along, reach)), vec2.sub(tip, vec2.scale(out, lift)), tip),
|
|
76
|
+
piece(vec2.sub(tip, vec2.scale(out, lift)), vec2.sub(afterTip, vec2.scale(along, reach)), afterTip),
|
|
77
|
+
straight(afterTip, beyond),
|
|
78
|
+
piece(vec2.add(beyond, vec2.scale(along, reach)), vec2.add(to, vec2.scale(out, rise * QUARTER)), to),
|
|
79
|
+
],
|
|
80
|
+
closed: false,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A brace with a word on it, placed beyond the tip on the far side from the two
|
|
86
|
+
* points.
|
|
87
|
+
*
|
|
88
|
+
* The label is anchored and never measured. Nothing about a figure's layout may
|
|
89
|
+
* depend on how wide some text is, because the width depends on which fonts the
|
|
90
|
+
* machine has and a box sized to fit a label would be a different box on two
|
|
91
|
+
* machines.
|
|
92
|
+
*/
|
|
93
|
+
export function brace(name, from, to, content, options) {
|
|
94
|
+
const path = bracePath(from, to, options);
|
|
95
|
+
const padding = options.padding ?? Math.abs(options.depth) / 2;
|
|
96
|
+
const out = vec2.perpendicular(vec2.normalize(vec2.sub(to, from)));
|
|
97
|
+
const middle = vec2.lerp(from, to, 0.5);
|
|
98
|
+
const at = vec2.add(middle, vec2.scale(out, options.depth + Math.sign(options.depth || 1) * padding));
|
|
99
|
+
const style = { fill: options.fill, family: options.family, weight: options.weight };
|
|
100
|
+
return group(name, [
|
|
101
|
+
shape('brace', path, { stroke: options.stroke }),
|
|
102
|
+
text('word', at, content, options.size, {
|
|
103
|
+
...style,
|
|
104
|
+
align: options.align ?? 'middle',
|
|
105
|
+
baseline: options.baseline ?? 'middle',
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
35
109
|
/**
|
|
36
110
|
* A word attached to a place: a disc on the place, a line out to where there is
|
|
37
111
|
* room, and the word at the end of it.
|
|
@@ -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
|
+
}
|
|
@@ -19,8 +19,12 @@ export declare function equationMarks(root: EquationElement): Equation;
|
|
|
19
19
|
* they are always used in. */
|
|
20
20
|
export declare function equationFromTex(tex: string): Promise<Equation>;
|
|
21
21
|
export interface EquationOptions {
|
|
22
|
-
/**
|
|
22
|
+
/** The point the expression is placed against, in the figure's own units. */
|
|
23
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';
|
|
24
28
|
/** The box the expression is fitted inside, in the figure's own units. */
|
|
25
29
|
readonly width: number;
|
|
26
30
|
readonly height: number;
|
package/dist/figure/equation.js
CHANGED
|
@@ -172,7 +172,10 @@ export async function equationFromTex(tex) {
|
|
|
172
172
|
export function equationNode(name, equation, options) {
|
|
173
173
|
const { box } = equation;
|
|
174
174
|
const fit = Math.min(options.width / box.width, options.height / box.height);
|
|
175
|
-
|
|
176
|
-
|
|
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))));
|
|
177
180
|
return group(name, equation.marks.map((mark) => shape(mark.id, mark.path, { fill: options.fill })), { transform });
|
|
178
181
|
}
|
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, countTo, 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';
|
|
@@ -51,9 +51,11 @@ export { pathData, paintSvg, svgElements, svgMarkup } from './paint/svg.js';
|
|
|
51
51
|
export type { ElementMaker, PaintNode, PaintTarget, SvgElement } from './paint/svg.js';
|
|
52
52
|
export { paintCanvas } from './paint/canvas.js';
|
|
53
53
|
export type { CanvasLike } from './paint/canvas.js';
|
|
54
|
-
export { arrow, callout, dot } from './figure/annotate.js';
|
|
55
|
-
export type { ArrowOptions, CalloutOptions } from './figure/annotate.js';
|
|
54
|
+
export { arrow, brace, bracePath, callout, dot } from './figure/annotate.js';
|
|
55
|
+
export type { ArrowOptions, BraceOptions, BracedOptions, CalloutOptions } from './figure/annotate.js';
|
|
56
56
|
export { typesetElement } from './figure/typeset.js';
|
|
57
57
|
export type { EquationElement } from './figure/typeset.js';
|
|
58
58
|
export { equationFromTex, equationMarks, equationNode } from './figure/equation.js';
|
|
59
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, countTo, 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';
|
|
@@ -31,6 +31,7 @@ export { alignPaths, lerpPath } from './figure/morph.js';
|
|
|
31
31
|
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
|
-
export { arrow, callout, dot } from './figure/annotate.js';
|
|
34
|
+
export { arrow, brace, bracePath, callout, dot } from './figure/annotate.js';
|
|
35
35
|
export { typesetElement } from './figure/typeset.js';
|
|
36
36
|
export { equationFromTex, equationMarks, equationNode } from './figure/equation.js';
|
|
37
|
+
export { glyphToken, matchGlyphs } from './figure/equation-match.js';
|
package/package.json
CHANGED