@altpsyche/maths 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,49 @@ const figure = {
18
18
  svgMarkup(at(figure, 0.5), viewMatrix(figure.extent, 'contain', 640, 360), 640, 360);
19
19
  ```
20
20
 
21
+ ## Axes and a plotted function
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.">
24
+
25
+ ```ts
26
+ import { axes, coordsOf, group, interval, numberPlane, plot, scaleOf, shape } from '@altpsyche/maths';
27
+
28
+ const coords = coordsOf(
29
+ scaleOf(interval(-1, 4), interval(-4.6, 4.6)),
30
+ scaleOf(interval(-1, 9), interval(-2.4, 2.4))
31
+ );
32
+
33
+ group('graph', [
34
+ numberPlane('grid', coords, { stroke: faint, minors: 4 }),
35
+ axes('axes', coords, { stroke: pen, fill: ink, size: 0.26, tip: 0.18 }),
36
+ shape('curve', plot(coords, (x) => x * x), { stroke: drawn }),
37
+ ]);
38
+ ```
39
+
40
+ A **scale** is the run of numbers an axis counts through and where that run lands in figure units.
41
+ Two of them are a **coords**, and `pointOf` reads a pair of graph numbers as a point. The steps
42
+ between ticks are one, two or five times a power of ten, because those are the numbers a reader
43
+ adds up in their head.
44
+
45
+ `plot` samples a function at a fixed count and joins the samples with cubics that leave each one at
46
+ the slope the function has there. It cuts the curve where the curve leaves the graph, so a pole
47
+ breaks in two instead of drawing a line up the picture, and the cut end sits on the edge rather
48
+ than a sample short of it. The curve above is cut at x = 3, where the parabola meets the 9 its y
49
+ axis stops at.
50
+
51
+ `areaUnder` closes the region between a curve and a level line, `riemannBars` draws the bars the
52
+ region is the limit of at the left edge, the right edge or the middle of each one, and `tangentAt`
53
+ lays the tangent along the curve, cut where it leaves the graph. `slopeOf` reads the slope itself,
54
+ which is what the number in the corner is.
55
+
56
+ <img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve and the shaded region growing behind it.">
57
+
58
+ Four times of one figure, side by side. A moving picture in a README needs a GIF and this package
59
+ has no encoder, so the strip shows the motion in a still.
60
+
61
+ Both pictures are written by `svgMarkup`, which needs no browser, so `npm run demos` regenerates
62
+ them and a test compares the bytes against the committed files.
63
+
21
64
  ## What it is built on
22
65
 
23
66
  **A figure at a time is data.** `at(figure, seconds)` is the whole public surface, and it is a
@@ -42,10 +85,18 @@ marks onto a two-dimensional canvas, which is what a recording needs, because an
42
85
  one surface. A test holds the two to emitting the same geometry and the same style for every
43
86
  mark.
44
87
 
88
+ ## The way in
89
+
90
+ `pathFromData` reads an SVG `d` attribute as a path, which is the inverse of what the SVG painter
91
+ writes. Without it the only shapes that exist are the ones the builders here make, so a glyph from
92
+ a typesetter or an outline from a drawing program could not be trimmed, aligned or walked into
93
+ another shape, and those are the operations this package is for. Every command is read, elliptical
94
+ arcs included, and a command it does not know stops the read rather than being skipped.
95
+
45
96
  ## The line through the package
46
97
 
47
98
  **Values and timing** are below it: vectors, a transform, the four curves a change can travel
48
99
  along, and a value walked between keys. That half changes almost never. **Figures and painters**
49
100
  are above it. Nothing below the line imports anything above it, and a test says so.
50
101
 
51
- One door, no runtime dependencies. MIT.
102
+ One door. MIT.
@@ -0,0 +1,75 @@
1
+ import { type GroupNode } from './node.js';
2
+ import { type Coords, type Scale } from './scale.js';
3
+ import type { Fill, Stroke } from './mark.js';
4
+ export interface NumberLineOptions {
5
+ /** The line, its ticks and the outline of its tips. */
6
+ stroke: Stroke;
7
+ /** The labels and the tips. Nothing is written where this is missing. */
8
+ fill?: Fill;
9
+ /** How big the labels are, in figure units. Nothing is written where this is
10
+ * missing. */
11
+ size?: number;
12
+ /** Where the line sits on the other axis, in figure units. */
13
+ at?: number;
14
+ direction?: 'across' | 'up';
15
+ /** About how many ticks are wanted. The step is a round number, so the count
16
+ * that comes back is near this rather than equal to it. */
17
+ ticks?: number;
18
+ /** How far a tick reaches across the line in total, half of it either side. */
19
+ tickLength?: number;
20
+ /** From the end of a tick to the label's own anchor. */
21
+ gap?: number;
22
+ /** How long the head at each end is. Nothing is drawn where this is zero. */
23
+ tip?: number;
24
+ /** How wide a head is across its base, against its length. */
25
+ spread?: number;
26
+ family?: string;
27
+ weight?: number;
28
+ /** Leaves the label at zero out, which is what a second axis crossing here
29
+ * wants, since both would otherwise write the same number in the same place. */
30
+ skipZero?: boolean;
31
+ }
32
+ /**
33
+ * One axis as a group: the line, the ticks under `ticks`, the labels under
34
+ * `labels`, and the tips under `tips`.
35
+ *
36
+ * Each tick and each label is named after the number it shows rather than by its
37
+ * position in the list. An animation naming a tick then follows that number when
38
+ * the axis is rebuilt with a different range, where a position would quietly
39
+ * follow whichever tick had moved into the slot.
40
+ */
41
+ export declare function numberLine(name: string, scale: Scale, options: NumberLineOptions): GroupNode;
42
+ /** Everything a pair of axes hands to each of its two lines. A figure wanting
43
+ * the two to differ builds them as two number lines instead, which is what that
44
+ * call is exported for. */
45
+ export type AxesOptions = Omit<NumberLineOptions, 'at' | 'direction' | 'skipZero'>;
46
+ /**
47
+ * Two number lines under one group, named `x` and `y`, each crossing the other
48
+ * at that other's zero.
49
+ *
50
+ * Where zero is outside an interval the line sits at the near edge of it instead.
51
+ * An axis drawn at a zero the graph never reaches is an axis off the picture, and
52
+ * a reader is left with labels along an edge that has no line on it.
53
+ */
54
+ export declare function axes(name: string, coords: Coords, options: AxesOptions): GroupNode;
55
+ export interface NumberPlaneOptions {
56
+ /** The lines standing on the ticks. */
57
+ stroke: Stroke;
58
+ /** How many gaps each step is divided into, so one less than this many lines
59
+ * sit between one tick and the next. Nothing extra is drawn below two. */
60
+ minors?: number;
61
+ /** How much of the stroke a minor line is drawn with, since a grid a reader
62
+ * notices is a grid competing with the curve on top of it. */
63
+ minorOpacity?: number;
64
+ ticks?: number;
65
+ }
66
+ /**
67
+ * The grid behind a graph: a line standing on each tick of both axes, and
68
+ * fainter lines dividing the gaps between them.
69
+ *
70
+ * The minor lines are drawn first and the major ones over them, so a major line
71
+ * a minor one lands on is the one a reader sees. The stroke is handed down from
72
+ * the group rather than set on each line, which is what lets the whole grid fade
73
+ * as one thing.
74
+ */
75
+ export declare function numberPlane(name: string, coords: Coords, options: NumberPlaneOptions): GroupNode;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A drawn axis: the line, a tick at each of its numbers, and the numbers
3
+ * written beside them.
4
+ *
5
+ * The line takes a direction rather than being rotated into place. A horizontal
6
+ * axis turned on its side would turn its labels with it, and a reader cannot
7
+ * read those, so the two directions place their labels differently and both
8
+ * write them upright.
9
+ */
10
+ import { interval } from '../values/interval.js';
11
+ import { vec2 } from '../values/vec2.js';
12
+ import { line, polygon } from './path.js';
13
+ import { group, shape, text } from './node.js';
14
+ import { multiplesOn, tickStep, ticksOn } from './ticks.js';
15
+ import { scaled } from './scale.js';
16
+ /** A head pointing along the line, apex at the end and base back along it. */
17
+ function head(apex, back, spread) {
18
+ const along = vec2.normalize(vec2.sub(apex, back));
19
+ const across = vec2.scale(vec2.perpendicular(along), (vec2.distance(apex, back) * spread) / 2);
20
+ return polygon([apex, vec2.add(back, across), vec2.sub(back, across)]);
21
+ }
22
+ /**
23
+ * One axis as a group: the line, the ticks under `ticks`, the labels under
24
+ * `labels`, and the tips under `tips`.
25
+ *
26
+ * Each tick and each label is named after the number it shows rather than by its
27
+ * position in the list. An animation naming a tick then follows that number when
28
+ * the axis is rebuilt with a different range, where a position would quietly
29
+ * follow whichever tick had moved into the slot.
30
+ */
31
+ export function numberLine(name, scale, options) {
32
+ const across = (options.direction ?? 'across') === 'across';
33
+ const seat = options.at ?? 0;
34
+ const tip = options.tip ?? 0;
35
+ const tickLength = options.tickLength ?? options.stroke.width * 8;
36
+ const size = options.size ?? 0;
37
+ const gap = options.gap ?? size * 0.35;
38
+ const { from: low, to: high } = interval.ordered(scale.units);
39
+ const at = (along, off) => (across ? vec2(along, seat + off) : vec2(seat + off, along));
40
+ // The line stops where a head begins rather than running under it, because a
41
+ // line drawn to the point shows through a head that is not fully opaque.
42
+ const parts = [shape('line', line(at(low + tip, 0), at(high - tip, 0)), { stroke: options.stroke })];
43
+ if (tip > 0 && options.fill) {
44
+ const spread = options.spread ?? 0.6;
45
+ parts.push(group('tips', [
46
+ shape('low', head(at(low, 0), at(low + tip, 0), spread), { fill: options.fill }),
47
+ shape('high', head(at(high, 0), at(high - tip, 0), spread), { fill: options.fill }),
48
+ ]));
49
+ }
50
+ const step = tickStep(scale.graph, options.ticks);
51
+ const marked = ticksOn(scale.graph, options.ticks);
52
+ const half = tickLength / 2;
53
+ parts.push(group('ticks', marked.map((tick) => {
54
+ const along = interval.remap(tick.value, scale.graph, scale.units);
55
+ return shape(tick.label, line(at(along, -half), at(along, half)), { stroke: options.stroke });
56
+ })));
57
+ if (options.fill && size > 0) {
58
+ // Placed by an anchor and an alignment and never by how wide the text is,
59
+ // so a long label moves nothing else in the figure.
60
+ const align = across ? 'middle' : 'end';
61
+ const baseline = across ? 'hanging' : 'middle';
62
+ const written = marked.filter((tick) => !(options.skipZero && tick.value === 0));
63
+ parts.push(group('labels', written.map((tick) => {
64
+ const along = interval.remap(tick.value, scale.graph, scale.units);
65
+ return text(tick.label, at(along, -(half + gap)), tick.label, size, {
66
+ fill: options.fill,
67
+ family: options.family,
68
+ weight: options.weight,
69
+ align,
70
+ baseline,
71
+ });
72
+ })));
73
+ }
74
+ return group(name, parts);
75
+ }
76
+ /**
77
+ * Two number lines under one group, named `x` and `y`, each crossing the other
78
+ * at that other's zero.
79
+ *
80
+ * Where zero is outside an interval the line sits at the near edge of it instead.
81
+ * An axis drawn at a zero the graph never reaches is an axis off the picture, and
82
+ * a reader is left with labels along an edge that has no line on it.
83
+ */
84
+ export function axes(name, coords, options) {
85
+ const holdsOrigin = interval.holds(coords.x.graph, 0) && interval.holds(coords.y.graph, 0);
86
+ const seat = (scale) => scaled(scale, interval.clampTo(scale.graph, 0));
87
+ return group(name, [
88
+ numberLine('x', coords.x, { ...options, at: seat(coords.y), direction: 'across' }),
89
+ numberLine('y', coords.y, { ...options, at: seat(coords.x), direction: 'up', skipZero: holdsOrigin }),
90
+ ]);
91
+ }
92
+ /** Whether a value is a whole number of steps from zero, which is what tells a
93
+ * minor line it is standing where a major one already is. */
94
+ function onStep(value, step) {
95
+ return Math.abs(value / step - Math.round(value / step)) < 1e-9;
96
+ }
97
+ /** Lines of constant x reaching the full height, and of constant y reaching the
98
+ * full width, at every multiple of the step. */
99
+ function gridLines(coords, step, along, skipping) {
100
+ const scale = along === 'x' ? coords.x : coords.y;
101
+ const other = interval.ordered(along === 'x' ? coords.y.units : coords.x.units);
102
+ const ends = (at) => along === 'x' ? [vec2(at, other.from), vec2(at, other.to)] : [vec2(other.from, at), vec2(other.to, at)];
103
+ return multiplesOn(scale.graph, step)
104
+ .filter((value) => !(skipping && onStep(value, skipping)))
105
+ .map((value) => {
106
+ const [from, to] = ends(scaled(scale, value));
107
+ return shape(String(value), line(from, to), {});
108
+ });
109
+ }
110
+ /**
111
+ * The grid behind a graph: a line standing on each tick of both axes, and
112
+ * fainter lines dividing the gaps between them.
113
+ *
114
+ * The minor lines are drawn first and the major ones over them, so a major line
115
+ * a minor one lands on is the one a reader sees. The stroke is handed down from
116
+ * the group rather than set on each line, which is what lets the whole grid fade
117
+ * as one thing.
118
+ */
119
+ export function numberPlane(name, coords, options) {
120
+ const step = { x: tickStep(coords.x.graph, options.ticks), y: tickStep(coords.y.graph, options.ticks) };
121
+ const minors = Math.max(1, Math.round(options.minors ?? 1));
122
+ const parts = [];
123
+ if (minors > 1 && step.x > 0 && step.y > 0) {
124
+ parts.push(group('minors', [
125
+ group('x', gridLines(coords, step.x / minors, 'x', step.x)),
126
+ group('y', gridLines(coords, step.y / minors, 'y', step.y)),
127
+ ], { style: { stroke: options.stroke, opacity: options.minorOpacity ?? 0.4 } }));
128
+ }
129
+ parts.push(group('majors', [group('x', gridLines(coords, step.x, 'x')), group('y', gridLines(coords, step.y, 'y'))], {
130
+ style: { stroke: options.stroke },
131
+ }));
132
+ return group(name, parts);
133
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * An SVG path string read as geometry, which is the way in to everything above.
3
+ *
4
+ * `pathData` writes a path out and this reads one back. Without it the only
5
+ * paths that exist are the ones the builders beside this file make, so a glyph
6
+ * from a typesetter, an icon from a designer or anything else a drawing program
7
+ * exported cannot be trimmed, aligned or walked into another shape, and those
8
+ * are the operations this package is for.
9
+ *
10
+ * Everything becomes a cubic, the way it does everywhere here. A straight run
11
+ * takes the controls a third and two thirds along, a quadratic elevates exactly,
12
+ * and an elliptical arc is cut into pieces of at most a quarter turn each. So no
13
+ * segment read here is an approximation of the one the string described, apart
14
+ * from the arc, which no Bézier can be exactly.
15
+ *
16
+ * A command it does not know stops the read rather than being skipped. Skipping
17
+ * leaves a shape with a piece missing, and a piece missing from a letter or an
18
+ * outline reads as a mistake in the drawing rather than in the reading of it.
19
+ */
20
+ import { type Path } from './path.js';
21
+ /**
22
+ * The path a `d` attribute describes.
23
+ *
24
+ * Both cases of every command are read, so a relative run is resolved against
25
+ * where the last one ended. A command letter followed by more numbers than it
26
+ * takes repeats, which is the shorthand the grammar allows and which a moveto
27
+ * repeats as a lineto.
28
+ */
29
+ export declare function pathFromData(d: string): Path;
@@ -0,0 +1,283 @@
1
+ /**
2
+ * An SVG path string read as geometry, which is the way in to everything above.
3
+ *
4
+ * `pathData` writes a path out and this reads one back. Without it the only
5
+ * paths that exist are the ones the builders beside this file make, so a glyph
6
+ * from a typesetter, an icon from a designer or anything else a drawing program
7
+ * exported cannot be trimmed, aligned or walked into another shape, and those
8
+ * are the operations this package is for.
9
+ *
10
+ * Everything becomes a cubic, the way it does everywhere here. A straight run
11
+ * takes the controls a third and two thirds along, a quadratic elevates exactly,
12
+ * and an elliptical arc is cut into pieces of at most a quarter turn each. So no
13
+ * segment read here is an approximation of the one the string described, apart
14
+ * from the arc, which no Bézier can be exactly.
15
+ *
16
+ * A command it does not know stops the read rather than being skipped. Skipping
17
+ * leaves a shape with a piece missing, and a piece missing from a letter or an
18
+ * outline reads as a mistake in the drawing rather than in the reading of it.
19
+ */
20
+ import { vec2 } from '../values/vec2.js';
21
+ import { mat3 } from '../values/mat3.js';
22
+ import { straight } from './path.js';
23
+ /** Every command in the path grammar. */
24
+ const DRAWN = 'MLHVCSQTAZ';
25
+ /** A letter, a number, a run of separators, or one character that is none of
26
+ * those. The last group is what turns an unexpected character into a refusal
27
+ * rather than leaving it in the string unread. */
28
+ const SCANNER = /([A-Za-z])|([+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?)|([,\s]+)|([\s\S])/g;
29
+ function tokenize(d) {
30
+ const tokens = [];
31
+ SCANNER.lastIndex = 0;
32
+ for (let match = SCANNER.exec(d); match; match = SCANNER.exec(d)) {
33
+ if (match[1])
34
+ tokens.push({ command: match[1] });
35
+ else if (match[2] !== undefined)
36
+ tokens.push({ number: Number(match[2]) });
37
+ else if (match[3] === undefined)
38
+ throw new Error(`path data holds "${match[4]}", which is neither a command nor a number`);
39
+ }
40
+ return tokens;
41
+ }
42
+ /** A quadratic written as a cubic, both controls two thirds of the way from an
43
+ * end towards the single control. The two describe the same curve. */
44
+ const elevate = (from, control, to) => ({
45
+ control1: vec2.lerp(from, control, 2 / 3),
46
+ control2: vec2.lerp(to, control, 2 / 3),
47
+ to,
48
+ });
49
+ /** The control a smooth segment uses, which is the previous one turned through
50
+ * the point the two segments share. */
51
+ const reflect = (control, about) => vec2.sub(vec2.scale(about, 2), control);
52
+ /** How far a cubic's controls sit from the ends of a piece of a unit arc, along
53
+ * the tangent. It is the same derivation `arc` uses and it holds for any sweep
54
+ * short enough, which is why the sweep is cut into quarters first. */
55
+ const reachFor = (sweep) => (4 / 3) * Math.tan(sweep / 4);
56
+ /**
57
+ * An SVG elliptical arc as cubics, from the endpoint form the string carries.
58
+ *
59
+ * SVG gives an arc as where it ends plus two radii, a rotation and two flags,
60
+ * and every one of the four arcs that fit those endpoints is selected by the
61
+ * flags. The centre and the two angles are recovered first, which is the
62
+ * conversion in the SVG specification, and then the sweep is walked in pieces
63
+ * of at most a quarter turn on a unit circle. The ellipse is an affine map of
64
+ * that circle, and an affine map takes a cubic to a cubic, so the control
65
+ * points can be mapped straight through rather than derived again.
66
+ */
67
+ function arcCurves(from, radii, rotation, largeArc, sweep, to) {
68
+ // A radius of zero is a straight line by the specification, and so is an arc
69
+ // whose endpoints are the same point, which the specification drops entirely.
70
+ if (radii.x === 0 || radii.y === 0)
71
+ return [straight(from, to)];
72
+ const angle = (rotation * Math.PI) / 180;
73
+ const cos = Math.cos(angle);
74
+ const sin = Math.sin(angle);
75
+ const half = vec2.scale(vec2.sub(from, to), 0.5);
76
+ const turned = vec2(cos * half.x + sin * half.y, -sin * half.x + cos * half.y);
77
+ // Radii too small to reach both endpoints are scaled up until they just do,
78
+ // which the specification asks for rather than treating as an error.
79
+ let rx = Math.abs(radii.x);
80
+ let ry = Math.abs(radii.y);
81
+ const short = (turned.x * turned.x) / (rx * rx) + (turned.y * turned.y) / (ry * ry);
82
+ if (short > 1) {
83
+ rx *= Math.sqrt(short);
84
+ ry *= Math.sqrt(short);
85
+ }
86
+ const denominator = rx * rx * turned.y * turned.y + ry * ry * turned.x * turned.x;
87
+ const numerator = Math.max(0, rx * rx * ry * ry - denominator);
88
+ const spread = (largeArc === sweep ? -1 : 1) * Math.sqrt(numerator / denominator);
89
+ const centreTurned = vec2((spread * rx * turned.y) / ry, (-spread * ry * turned.x) / rx);
90
+ const centre = vec2.add(vec2(cos * centreTurned.x - sin * centreTurned.y, sin * centreTurned.x + cos * centreTurned.y), vec2.scale(vec2.add(from, to), 0.5));
91
+ const at = (point) => vec2((point.x - centreTurned.x) / rx, (point.y - centreTurned.y) / ry);
92
+ const opening = at(turned);
93
+ const closing = at(vec2.scale(turned, -1));
94
+ const first = vec2.angle(opening);
95
+ let swept = vec2.angle(closing) - first;
96
+ if (!sweep && swept > 0)
97
+ swept -= 2 * Math.PI;
98
+ if (sweep && swept < 0)
99
+ swept += 2 * Math.PI;
100
+ // The whole ellipse, placed and turned, as one matrix. Every point of the
101
+ // unit arc goes through it, controls included.
102
+ const place = mat3.multiply(mat3.translation(centre), mat3.multiply(mat3.rotation(angle), mat3.scaling(vec2(rx, ry))));
103
+ const pieces = Math.max(1, Math.ceil(Math.abs(swept) / (Math.PI / 2)));
104
+ const step = swept / pieces;
105
+ const reach = reachFor(step);
106
+ const curves = [];
107
+ for (let piece = 0; piece < pieces; piece++) {
108
+ const a0 = first + step * piece;
109
+ const a1 = a0 + step;
110
+ const p0 = vec2(Math.cos(a0), Math.sin(a0));
111
+ const p1 = vec2(Math.cos(a1), Math.sin(a1));
112
+ const t0 = vec2(-Math.sin(a0), Math.cos(a0));
113
+ const t1 = vec2(-Math.sin(a1), Math.cos(a1));
114
+ curves.push({
115
+ control1: mat3.transformPoint(place, vec2.add(p0, vec2.scale(t0, reach))),
116
+ control2: mat3.transformPoint(place, vec2.sub(p1, vec2.scale(t1, reach))),
117
+ // The endpoint the string named rather than the one the angles give back,
118
+ // so a run of arcs cannot drift away from where it said it ends.
119
+ to: piece === pieces - 1 ? to : mat3.transformPoint(place, p1),
120
+ });
121
+ }
122
+ return curves;
123
+ }
124
+ /**
125
+ * The path a `d` attribute describes.
126
+ *
127
+ * Both cases of every command are read, so a relative run is resolved against
128
+ * where the last one ended. A command letter followed by more numbers than it
129
+ * takes repeats, which is the shorthand the grammar allows and which a moveto
130
+ * repeats as a lineto.
131
+ */
132
+ export function pathFromData(d) {
133
+ const tokens = tokenize(d);
134
+ const subpaths = [];
135
+ let curves = [];
136
+ let start = vec2.ZERO;
137
+ let at = vec2.ZERO;
138
+ let open = false;
139
+ let moved = false;
140
+ let command = '';
141
+ let index = 0;
142
+ // Held per kind because an S reflects a cubic's second control and a T a
143
+ // quadratic's only one, and either falls back to the current point when the
144
+ // segment before it was neither.
145
+ let lastCubic;
146
+ let lastQuadratic;
147
+ const take = (count) => {
148
+ const values = [];
149
+ while (values.length < count) {
150
+ const token = tokens[index++];
151
+ if (!token || !('number' in token))
152
+ throw new Error(`path command "${command}" wants ${count} numbers and the run ends short`);
153
+ values.push(token.number);
154
+ }
155
+ return values;
156
+ };
157
+ const flush = (closed) => {
158
+ if (open)
159
+ subpaths.push({ start, curves, closed });
160
+ open = false;
161
+ };
162
+ const segment = (...added) => {
163
+ if (!moved)
164
+ throw new Error('path data draws before it moves to a starting point');
165
+ // A segment after a close begins again where the closed subpath began,
166
+ // which is the point the close left as the current one.
167
+ if (!open) {
168
+ start = at;
169
+ curves = [];
170
+ open = true;
171
+ }
172
+ curves.push(...added);
173
+ at = added[added.length - 1]?.to ?? at;
174
+ };
175
+ while (index < tokens.length) {
176
+ const token = tokens[index];
177
+ if (token && 'command' in token) {
178
+ command = token.command;
179
+ index++;
180
+ if (!DRAWN.includes(command.toUpperCase()))
181
+ throw new Error(`path data holds command "${command}", which is not one of "${DRAWN}"`);
182
+ }
183
+ else if (!command) {
184
+ throw new Error('path data opens on a number rather than a command');
185
+ }
186
+ const relative = command === command.toLowerCase();
187
+ const absolute = (x, y) => (relative ? vec2(at.x + x, at.y + y) : vec2(x, y));
188
+ switch (command.toUpperCase()) {
189
+ case 'M': {
190
+ const [x = 0, y = 0] = take(2);
191
+ flush(false);
192
+ at = absolute(x, y);
193
+ start = at;
194
+ curves = [];
195
+ open = true;
196
+ moved = true;
197
+ lastCubic = undefined;
198
+ lastQuadratic = undefined;
199
+ // A second pair under one moveto is a line rather than a second move,
200
+ // and the repetition carries the case the moveto was written in.
201
+ command = relative ? 'l' : 'L';
202
+ break;
203
+ }
204
+ case 'L': {
205
+ const [x = 0, y = 0] = take(2);
206
+ segment(straight(at, absolute(x, y)));
207
+ lastCubic = undefined;
208
+ lastQuadratic = undefined;
209
+ break;
210
+ }
211
+ case 'H': {
212
+ const [x = 0] = take(1);
213
+ segment(straight(at, relative ? vec2(at.x + x, at.y) : vec2(x, at.y)));
214
+ lastCubic = undefined;
215
+ lastQuadratic = undefined;
216
+ break;
217
+ }
218
+ case 'V': {
219
+ const [y = 0] = take(1);
220
+ segment(straight(at, relative ? vec2(at.x, at.y + y) : vec2(at.x, y)));
221
+ lastCubic = undefined;
222
+ lastQuadratic = undefined;
223
+ break;
224
+ }
225
+ case 'C': {
226
+ const [x1 = 0, y1 = 0, x2 = 0, y2 = 0, x = 0, y = 0] = take(6);
227
+ const control2 = absolute(x2, y2);
228
+ segment({ control1: absolute(x1, y1), control2, to: absolute(x, y) });
229
+ lastCubic = control2;
230
+ lastQuadratic = undefined;
231
+ break;
232
+ }
233
+ case 'S': {
234
+ const [x2 = 0, y2 = 0, x = 0, y = 0] = take(4);
235
+ const control1 = lastCubic ? reflect(lastCubic, at) : at;
236
+ const control2 = absolute(x2, y2);
237
+ segment({ control1, control2, to: absolute(x, y) });
238
+ lastCubic = control2;
239
+ lastQuadratic = undefined;
240
+ break;
241
+ }
242
+ case 'Q': {
243
+ const [qx = 0, qy = 0, x = 0, y = 0] = take(4);
244
+ const control = absolute(qx, qy);
245
+ segment(elevate(at, control, absolute(x, y)));
246
+ lastCubic = undefined;
247
+ lastQuadratic = control;
248
+ break;
249
+ }
250
+ case 'T': {
251
+ const [x = 0, y = 0] = take(2);
252
+ const control = lastQuadratic ? reflect(lastQuadratic, at) : at;
253
+ segment(elevate(at, control, absolute(x, y)));
254
+ lastCubic = undefined;
255
+ lastQuadratic = control;
256
+ break;
257
+ }
258
+ case 'A': {
259
+ const [rx = 0, ry = 0, turn = 0, large = 0, sweep = 0, x = 0, y = 0] = take(7);
260
+ const to = absolute(x, y);
261
+ // An arc that ends where it began is dropped rather than drawn, which
262
+ // is the specification's own wording: there is no such ellipse to pick.
263
+ if (to.x !== at.x || to.y !== at.y)
264
+ segment(...arcCurves(at, vec2(rx, ry), turn, large !== 0, sweep !== 0, to));
265
+ lastCubic = undefined;
266
+ lastQuadratic = undefined;
267
+ break;
268
+ }
269
+ case 'Z': {
270
+ flush(true);
271
+ at = start;
272
+ lastCubic = undefined;
273
+ lastQuadratic = undefined;
274
+ // A close ends the run of one command, so a number after it has nothing
275
+ // to repeat and is refused rather than read as a line.
276
+ command = '';
277
+ break;
278
+ }
279
+ }
280
+ }
281
+ flush(false);
282
+ return subpaths;
283
+ }