@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 +52 -1
- package/dist/figure/axis.d.ts +75 -0
- package/dist/figure/axis.js +133 -0
- package/dist/figure/path-data.d.ts +29 -0
- package/dist/figure/path-data.js +283 -0
- package/dist/figure/plot.d.ts +100 -0
- package/dist/figure/plot.js +261 -0
- package/dist/figure/scale.d.ts +32 -0
- package/dist/figure/scale.js +32 -0
- package/dist/figure/ticks.d.ts +46 -0
- package/dist/figure/ticks.js +97 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/values/interval.d.ts +42 -0
- package/dist/values/interval.js +66 -0
- package/package.json +2 -1
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
|
|
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
|
+
}
|