@altpsyche/maths 0.2.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/figure/animation.d.ts +29 -0
  4. package/dist/figure/animation.js +82 -0
  5. package/dist/figure/annotate.d.ts +55 -0
  6. package/dist/figure/annotate.js +53 -0
  7. package/dist/figure/extent.d.ts +62 -0
  8. package/dist/figure/extent.js +69 -0
  9. package/dist/figure/figure.d.ts +53 -0
  10. package/dist/figure/figure.js +75 -0
  11. package/dist/figure/mark.d.ts +66 -0
  12. package/dist/figure/mark.js +1 -0
  13. package/dist/figure/morph.d.ts +6 -0
  14. package/dist/figure/morph.js +117 -0
  15. package/dist/figure/node.d.ts +64 -0
  16. package/dist/figure/node.js +106 -0
  17. package/dist/figure/path.d.ts +56 -0
  18. package/dist/figure/path.js +142 -0
  19. package/dist/figure/timeline.d.ts +50 -0
  20. package/dist/figure/timeline.js +61 -0
  21. package/dist/figure/trim.d.ts +10 -0
  22. package/dist/figure/trim.js +100 -0
  23. package/dist/index.d.ts +41 -0
  24. package/dist/index.js +26 -0
  25. package/dist/paint/canvas.d.ts +53 -0
  26. package/dist/paint/canvas.js +76 -0
  27. package/dist/paint/number.d.ts +1 -0
  28. package/dist/paint/number.js +16 -0
  29. package/dist/paint/svg.d.ts +64 -0
  30. package/dist/paint/svg.js +127 -0
  31. package/dist/timing/track.d.ts +37 -0
  32. package/dist/timing/track.js +85 -0
  33. package/dist/values/ease.d.ts +30 -0
  34. package/dist/values/ease.js +35 -0
  35. package/dist/values/mat3.d.ts +44 -0
  36. package/dist/values/mat3.js +63 -0
  37. package/dist/values/scalar.d.ts +21 -0
  38. package/dist/values/scalar.js +31 -0
  39. package/dist/values/vec2.d.ts +48 -0
  40. package/dist/values/vec2.js +82 -0
  41. package/dist/values/vec3.d.ts +27 -0
  42. package/dist/values/vec3.js +58 -0
  43. package/package.json +50 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Walking one path into another.
3
+ *
4
+ * Two paths can only be walked between when they hold the same number of points,
5
+ * so the one with fewer is subdivided until they match. Everything is a cubic
6
+ * already, which is what makes this possible at all: there is no case where a
7
+ * line has to become an arc, because a line is an arc whose controls sit on it.
8
+ */
9
+ import { vec2 } from '../values/vec2.js';
10
+ import { pointOn } from './path.js';
11
+ /** One segment cut into two at a fraction, both pieces kept, which is how a
12
+ * subpath gains a point without changing shape. */
13
+ function halves(from, curve, along) {
14
+ const a = vec2.lerp(from, curve.control1, along);
15
+ const b = vec2.lerp(curve.control1, curve.control2, along);
16
+ const c = vec2.lerp(curve.control2, curve.to, along);
17
+ const d = vec2.lerp(a, b, along);
18
+ const e = vec2.lerp(b, c, along);
19
+ const middle = vec2.lerp(d, e, along);
20
+ return [
21
+ { control1: a, control2: d, to: middle },
22
+ { control1: e, control2: c, to: curve.to },
23
+ ];
24
+ }
25
+ /**
26
+ * A subpath rewritten to hold exactly this many segments, drawing the same shape.
27
+ *
28
+ * The extra cuts are spread across the longest segments one at a time rather than
29
+ * all put in the first, so the points stay roughly evenly spaced and the walk
30
+ * between two paths does not drag one region while another sits still.
31
+ */
32
+ function withCurves(subpath, wanted) {
33
+ if (subpath.curves.length >= wanted || subpath.curves.length === 0)
34
+ return subpath;
35
+ let curves = [...subpath.curves];
36
+ while (curves.length < wanted) {
37
+ let longest = 0;
38
+ let best = -1;
39
+ let from = subpath.start;
40
+ for (let at = 0; at < curves.length; at++) {
41
+ const span = vec2.distance(from, pointOn(from, curves[at], 1));
42
+ if (span > longest) {
43
+ longest = span;
44
+ best = at;
45
+ }
46
+ from = curves[at].to;
47
+ }
48
+ const cutAt = best < 0 ? 0 : best;
49
+ let start = subpath.start;
50
+ for (let at = 0; at < cutAt; at++)
51
+ start = curves[at].to;
52
+ const [first, second] = halves(start, curves[cutAt], 0.5);
53
+ curves = [...curves.slice(0, cutAt), first, second, ...curves.slice(cutAt + 1)];
54
+ }
55
+ return { start: subpath.start, curves, closed: subpath.closed };
56
+ }
57
+ /** A subpath standing still at one point, for a path that has fewer subpaths
58
+ * than the one it is becoming. It draws nothing and it gives the other side
59
+ * something to walk from. */
60
+ function collapsed(at, curves) {
61
+ return {
62
+ start: at,
63
+ curves: Array.from({ length: curves }, () => ({ control1: at, control2: at, to: at })),
64
+ closed: false,
65
+ };
66
+ }
67
+ /** Where a path sits, for a subpath the other side does not have. Its own first
68
+ * point, so a shape appearing grows out of where the shape beside it starts. */
69
+ function anchorOf(path) {
70
+ return path.length > 0 ? path[0].start : vec2(0, 0);
71
+ }
72
+ /** The two paths rewritten to the same shape of point list, drawing exactly what
73
+ * they drew before. */
74
+ export function alignPaths(from, to) {
75
+ const count = Math.max(from.length, to.length);
76
+ const left = [];
77
+ const right = [];
78
+ for (let at = 0; at < count; at++) {
79
+ const a = from[at];
80
+ const b = to[at];
81
+ if (a && b) {
82
+ const curves = Math.max(a.curves.length, b.curves.length);
83
+ left.push(withCurves(a, curves));
84
+ right.push(withCurves(b, curves));
85
+ continue;
86
+ }
87
+ if (a) {
88
+ left.push(a);
89
+ right.push(collapsed(anchorOf(to), a.curves.length));
90
+ continue;
91
+ }
92
+ if (b) {
93
+ left.push(collapsed(anchorOf(from), b.curves.length));
94
+ right.push(b);
95
+ }
96
+ }
97
+ return [left, right];
98
+ }
99
+ /** Part way from one path to another, point by point, after aligning them. */
100
+ export function lerpPath(from, to, along) {
101
+ const [a, b] = alignPaths(from, to);
102
+ return a.map((subpath, at) => {
103
+ const other = b[at];
104
+ return {
105
+ start: vec2.lerp(subpath.start, other.start, along),
106
+ curves: subpath.curves.map((curve, piece) => {
107
+ const twin = other.curves[piece];
108
+ return {
109
+ control1: vec2.lerp(curve.control1, twin.control1, along),
110
+ control2: vec2.lerp(curve.control2, twin.control2, along),
111
+ to: vec2.lerp(curve.to, twin.to, along),
112
+ };
113
+ }),
114
+ closed: along < 0.5 ? subpath.closed : other.closed,
115
+ };
116
+ });
117
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The tree an author builds, and the flat list a painter is given.
3
+ *
4
+ * The two are different on purpose. A tree is how a picture is written, with a
5
+ * group carrying a transform and a style its children inherit. A flat list is
6
+ * how a picture is drawn, compared and hit tested, with nothing left to inherit
7
+ * and nothing left to walk.
8
+ */
9
+ import { type Mat3 } from '../values/mat3.js';
10
+ import type { Path } from './path.js';
11
+ import type { Vec2 } from '../values/vec2.js';
12
+ import type { Fill, Mark, Stroke } from './mark.js';
13
+ /** What a group hands down and a child may override. */
14
+ export interface Style {
15
+ fill?: Fill;
16
+ stroke?: Stroke;
17
+ opacity?: number;
18
+ family?: string;
19
+ weight?: number;
20
+ }
21
+ interface Named {
22
+ /** Its own name among its siblings. The id a mark carries is the names on the
23
+ * way down joined together. */
24
+ name: string;
25
+ }
26
+ export interface ShapeNode extends Named, Style {
27
+ kind: 'shape';
28
+ path: Path;
29
+ }
30
+ export interface TextNode extends Named, Style {
31
+ kind: 'text';
32
+ at: Vec2;
33
+ text: string;
34
+ size: number;
35
+ align?: 'start' | 'middle' | 'end';
36
+ baseline?: 'alphabetic' | 'middle' | 'hanging';
37
+ }
38
+ export interface GroupNode extends Named {
39
+ kind: 'group';
40
+ transform?: Mat3;
41
+ style?: Style;
42
+ children: readonly Node[];
43
+ }
44
+ export type Node = ShapeNode | TextNode | GroupNode;
45
+ export declare function shape(name: string, path: Path, style?: Style): ShapeNode;
46
+ /** What a text node takes beyond a shared style, which is where it sits against
47
+ * its own anchor point rather than anything a group can hand down. */
48
+ export type TextOptions = Style & Pick<TextNode, 'align' | 'baseline'>;
49
+ export declare function text(name: string, at: Vec2, content: string, size: number, options?: TextOptions): TextNode;
50
+ export declare function group(name: string, children: readonly Node[], options?: {
51
+ transform?: Mat3;
52
+ style?: Style;
53
+ }): GroupNode;
54
+ /**
55
+ * The tree resolved into the list a painter draws.
56
+ *
57
+ * A shape with neither a fill nor a stroke is left out rather than emitted
58
+ * invisible, and so is a text with no fill. An invisible mark still costs a
59
+ * painter an element and still turns up in a comparison between two frames as
60
+ * something that changed, so a picture that draws nothing should be a list with
61
+ * nothing in it.
62
+ */
63
+ export declare function flatten(root: Node, transform?: Mat3, style?: Style): readonly Mark[];
64
+ export {};
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The tree an author builds, and the flat list a painter is given.
3
+ *
4
+ * The two are different on purpose. A tree is how a picture is written, with a
5
+ * group carrying a transform and a style its children inherit. A flat list is
6
+ * how a picture is drawn, compared and hit tested, with nothing left to inherit
7
+ * and nothing left to walk.
8
+ */
9
+ import { mat3 } from '../values/mat3.js';
10
+ import { transformPath } from './path.js';
11
+ export function shape(name, path, style = {}) {
12
+ return { kind: 'shape', name, path, ...style };
13
+ }
14
+ export function text(name, at, content, size, options = {}) {
15
+ return { kind: 'text', name, at, text: content, size, ...options };
16
+ }
17
+ export function group(name, children, options = {}) {
18
+ return { kind: 'group', name, children, transform: options.transform, style: options.style };
19
+ }
20
+ /** The font a text mark falls back to when no group above it named one. Both
21
+ * painters need a family by name, and neither has a sensible default. */
22
+ const DEFAULT_FAMILY = 'sans-serif';
23
+ function inherited(parent, own) {
24
+ return {
25
+ fill: own.fill ?? parent.fill,
26
+ stroke: own.stroke ?? parent.stroke,
27
+ family: own.family ?? parent.family,
28
+ weight: own.weight ?? parent.weight,
29
+ opacity: (parent.opacity ?? 1) * (own.opacity ?? 1),
30
+ };
31
+ }
32
+ /**
33
+ * Sibling names made unique, so two shapes called the same thing do not become
34
+ * one id.
35
+ *
36
+ * A repeated name gets a number rather than an error, because a figure built in
37
+ * a loop names its parts the same way on purpose and the author still needs the
38
+ * ids to be stable frame to frame. Counting per parent keeps them stable: the
39
+ * same tree gives the same ids every time.
40
+ */
41
+ function uniqueNames(children) {
42
+ const seen = new Map();
43
+ return children.map((child) => {
44
+ const count = seen.get(child.name) ?? 0;
45
+ seen.set(child.name, count + 1);
46
+ return count === 0 ? child.name : `${child.name}#${count + 1}`;
47
+ });
48
+ }
49
+ function walk(node, prefix, transform, style, into) {
50
+ const id = prefix === '' ? node.name : `${prefix}/${node.name}`;
51
+ if (node.kind === 'group') {
52
+ const next = node.transform ? mat3.multiply(transform, node.transform) : transform;
53
+ const handed = inherited(style, node.style ?? {});
54
+ const names = uniqueNames(node.children);
55
+ node.children.forEach((child, at) => walk({ ...child, name: names[at] }, id, next, handed, into));
56
+ return;
57
+ }
58
+ const settled = inherited(style, node);
59
+ const opacity = settled.opacity ?? 1;
60
+ // A group that scales makes the lines inside it thicker, the way it makes
61
+ // everything else bigger, so the width travels through the same transform the
62
+ // geometry did rather than staying at the number the author typed.
63
+ const scale = mat3.scaleFactor(transform);
64
+ if (node.kind === 'shape') {
65
+ if (!settled.fill && !settled.stroke)
66
+ return;
67
+ into.push({
68
+ kind: 'path',
69
+ id,
70
+ path: transformPath(node.path, transform),
71
+ fill: settled.fill,
72
+ stroke: settled.stroke ? { ...settled.stroke, width: settled.stroke.width * scale } : undefined,
73
+ opacity,
74
+ });
75
+ return;
76
+ }
77
+ if (!settled.fill)
78
+ return;
79
+ into.push({
80
+ kind: 'text',
81
+ id,
82
+ at: mat3.transformPoint(transform, node.at),
83
+ text: node.text,
84
+ size: node.size * scale,
85
+ family: settled.family ?? DEFAULT_FAMILY,
86
+ weight: settled.weight,
87
+ align: node.align,
88
+ baseline: node.baseline,
89
+ fill: settled.fill,
90
+ opacity,
91
+ });
92
+ }
93
+ /**
94
+ * The tree resolved into the list a painter draws.
95
+ *
96
+ * A shape with neither a fill nor a stroke is left out rather than emitted
97
+ * invisible, and so is a text with no fill. An invisible mark still costs a
98
+ * painter an element and still turns up in a comparison between two frames as
99
+ * something that changed, so a picture that draws nothing should be a list with
100
+ * nothing in it.
101
+ */
102
+ export function flatten(root, transform = mat3.IDENTITY, style = {}) {
103
+ const marks = [];
104
+ walk(root, '', transform, style, marks);
105
+ return marks;
106
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The geometry every drawn shape is made of.
3
+ *
4
+ * A path is a list of subpaths, and a subpath is a starting point followed by
5
+ * cubic Bézier segments. Everything is a cubic, a straight line included, with
6
+ * its two controls placed a third and two thirds of the way along. One shape for
7
+ * every curve is what makes two paths interpolable: walking one path into
8
+ * another is walking each control point to the matching one, and a line turning
9
+ * into an arc needs no special case.
10
+ */
11
+ import { type Vec2 } from '../values/vec2.js';
12
+ import { type Mat3 } from '../values/mat3.js';
13
+ /** One cubic segment, carrying its two controls and where it ends. Where it
14
+ * begins is wherever the segment before it ended. */
15
+ export interface Cubic {
16
+ readonly control1: Vec2;
17
+ readonly control2: Vec2;
18
+ readonly to: Vec2;
19
+ }
20
+ export interface Subpath {
21
+ readonly start: Vec2;
22
+ readonly curves: readonly Cubic[];
23
+ /** A closed subpath joins its end back to its start, which is what decides
24
+ * whether a fill has a straight edge there and whether the stroke has ends. */
25
+ readonly closed: boolean;
26
+ }
27
+ export type Path = readonly Subpath[];
28
+ /** A straight segment written as a cubic, with the controls on the line at a
29
+ * third and two thirds, which is the placement that leaves the pace even. */
30
+ export declare function straight(from: Vec2, to: Vec2): Cubic;
31
+ export declare function line(from: Vec2, to: Vec2): Path;
32
+ /** An open run of straight segments. */
33
+ export declare function polyline(points: readonly Vec2[]): Path;
34
+ /** A closed run of straight segments. */
35
+ export declare function polygon(points: readonly Vec2[]): Path;
36
+ /** An axis-aligned rectangle from its corner and its size. */
37
+ export declare function rect(corner: Vec2, width: number, height: number): Path;
38
+ /** A circle as four cubic quarters, anticlockwise from the positive x axis. */
39
+ export declare function circle(centre: Vec2, radius: number): Path;
40
+ /**
41
+ * An arc as a run of cubics, each covering at most a quarter turn.
42
+ *
43
+ * A single cubic drifts from a true arc as the angle it covers grows, so the
44
+ * sweep is cut into quarters or less and the control distance is derived per
45
+ * piece rather than taken from the circle's constant.
46
+ */
47
+ export declare function arc(centre: Vec2, radius: number, fromAngle: number, toAngle: number): Path;
48
+ /** A point on a cubic, with the segment's own start passed in because a segment
49
+ * carries where it ends and not where it began. */
50
+ export declare function pointOn(from: Vec2, curve: Cubic, along: number): Vec2;
51
+ /** Every point of a path moved by a transform, which is how a group's transform
52
+ * reaches the geometry rather than being carried alongside it. */
53
+ export declare function transformPath(path: Path, m: Mat3): Path;
54
+ /** How many points a path holds, which is what two paths have to agree on
55
+ * before one can be walked into the other. */
56
+ export declare function pointCount(path: Path): number;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The geometry every drawn shape is made of.
3
+ *
4
+ * A path is a list of subpaths, and a subpath is a starting point followed by
5
+ * cubic Bézier segments. Everything is a cubic, a straight line included, with
6
+ * its two controls placed a third and two thirds of the way along. One shape for
7
+ * every curve is what makes two paths interpolable: walking one path into
8
+ * another is walking each control point to the matching one, and a line turning
9
+ * into an arc needs no special case.
10
+ */
11
+ import { vec2 } from '../values/vec2.js';
12
+ import { mat3 } from '../values/mat3.js';
13
+ /** A straight segment written as a cubic, with the controls on the line at a
14
+ * third and two thirds, which is the placement that leaves the pace even. */
15
+ export function straight(from, to) {
16
+ return {
17
+ control1: vec2.lerp(from, to, 1 / 3),
18
+ control2: vec2.lerp(from, to, 2 / 3),
19
+ to,
20
+ };
21
+ }
22
+ export function line(from, to) {
23
+ return [{ start: from, curves: [straight(from, to)], closed: false }];
24
+ }
25
+ function through(points, closed) {
26
+ if (points.length < 2)
27
+ return [];
28
+ const start = points[0];
29
+ const curves = [];
30
+ for (let at = 1; at < points.length; at++)
31
+ curves.push(straight(points[at - 1], points[at]));
32
+ if (closed)
33
+ curves.push(straight(points[points.length - 1], start));
34
+ return [{ start, curves, closed }];
35
+ }
36
+ /** An open run of straight segments. */
37
+ export function polyline(points) {
38
+ return through(points, false);
39
+ }
40
+ /** A closed run of straight segments. */
41
+ export function polygon(points) {
42
+ return through(points, true);
43
+ }
44
+ /** An axis-aligned rectangle from its corner and its size. */
45
+ export function rect(corner, width, height) {
46
+ return polygon([
47
+ corner,
48
+ vec2(corner.x + width, corner.y),
49
+ vec2(corner.x + width, corner.y + height),
50
+ vec2(corner.x, corner.y + height),
51
+ ]);
52
+ }
53
+ /**
54
+ * How far a circle's control points sit from the ends of a quarter arc, as a
55
+ * fraction of the radius.
56
+ *
57
+ * Four cubics cannot be a circle exactly, and this is the value that makes the
58
+ * error smallest: the arc passes through both ends and the midpoint, and never
59
+ * leaves the true radius by more than 2.7 parts in ten thousand of it.
60
+ */
61
+ const KAPPA = 0.5522847498307936;
62
+ /** A circle as four cubic quarters, anticlockwise from the positive x axis. */
63
+ export function circle(centre, radius) {
64
+ const k = radius * KAPPA;
65
+ const right = vec2(centre.x + radius, centre.y);
66
+ const top = vec2(centre.x, centre.y + radius);
67
+ const left = vec2(centre.x - radius, centre.y);
68
+ const bottom = vec2(centre.x, centre.y - radius);
69
+ return [
70
+ {
71
+ start: right,
72
+ curves: [
73
+ { control1: vec2(right.x, right.y + k), control2: vec2(top.x + k, top.y), to: top },
74
+ { control1: vec2(top.x - k, top.y), control2: vec2(left.x, left.y + k), to: left },
75
+ { control1: vec2(left.x, left.y - k), control2: vec2(bottom.x - k, bottom.y), to: bottom },
76
+ { control1: vec2(bottom.x + k, bottom.y), control2: vec2(right.x, right.y - k), to: right },
77
+ ],
78
+ closed: true,
79
+ },
80
+ ];
81
+ }
82
+ /**
83
+ * An arc as a run of cubics, each covering at most a quarter turn.
84
+ *
85
+ * A single cubic drifts from a true arc as the angle it covers grows, so the
86
+ * sweep is cut into quarters or less and the control distance is derived per
87
+ * piece rather than taken from the circle's constant.
88
+ */
89
+ export function arc(centre, radius, fromAngle, toAngle) {
90
+ const sweep = toAngle - fromAngle;
91
+ if (sweep === 0 || radius === 0)
92
+ return [];
93
+ const pieces = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI / 2)));
94
+ const step = sweep / pieces;
95
+ const reach = (4 / 3) * Math.tan(step / 4);
96
+ const at = (angle) => vec2(centre.x + radius * Math.cos(angle), centre.y + radius * Math.sin(angle));
97
+ const start = at(fromAngle);
98
+ const curves = [];
99
+ for (let piece = 0; piece < pieces; piece++) {
100
+ const a0 = fromAngle + step * piece;
101
+ const a1 = a0 + step;
102
+ const p0 = at(a0);
103
+ const p1 = at(a1);
104
+ const t0 = vec2(-Math.sin(a0), Math.cos(a0));
105
+ const t1 = vec2(-Math.sin(a1), Math.cos(a1));
106
+ curves.push({
107
+ control1: vec2.add(p0, vec2.scale(t0, reach * radius)),
108
+ control2: vec2.sub(p1, vec2.scale(t1, reach * radius)),
109
+ to: p1,
110
+ });
111
+ }
112
+ return [{ start, curves, closed: false }];
113
+ }
114
+ /** A point on a cubic, with the segment's own start passed in because a segment
115
+ * carries where it ends and not where it began. */
116
+ export function pointOn(from, curve, along) {
117
+ const u = 1 - along;
118
+ const a = u * u * u;
119
+ const b = 3 * u * u * along;
120
+ const c = 3 * u * along * along;
121
+ const d = along * along * along;
122
+ return vec2(a * from.x + b * curve.control1.x + c * curve.control2.x + d * curve.to.x, a * from.y + b * curve.control1.y + c * curve.control2.y + d * curve.to.y);
123
+ }
124
+ /** Every point of a path moved by a transform, which is how a group's transform
125
+ * reaches the geometry rather than being carried alongside it. */
126
+ export function transformPath(path, m) {
127
+ const point = (v) => mat3.transformPoint(m, v);
128
+ return path.map((subpath) => ({
129
+ start: point(subpath.start),
130
+ curves: subpath.curves.map((curve) => ({
131
+ control1: point(curve.control1),
132
+ control2: point(curve.control2),
133
+ to: point(curve.to),
134
+ })),
135
+ closed: subpath.closed,
136
+ }));
137
+ }
138
+ /** How many points a path holds, which is what two paths have to agree on
139
+ * before one can be walked into the other. */
140
+ export function pointCount(path) {
141
+ return path.reduce((total, subpath) => total + 1 + subpath.curves.length * 3, 0);
142
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Animations in the order they happen, turned into absolute spans.
3
+ *
4
+ * The order is written once and compiled once. Reading it at a time is then a
5
+ * walk over a sorted list, which is what lets the same timeline answer a page
6
+ * playing forward, a reader dragging backwards, and a recorder stepping.
7
+ */
8
+ import { type Curve } from '../values/ease.js';
9
+ import type { Animation } from './animation.js';
10
+ import type { Mark } from './mark.js';
11
+ export interface Span {
12
+ animation: Animation;
13
+ from: number;
14
+ to: number;
15
+ curve: Curve;
16
+ }
17
+ export interface PlayOptions {
18
+ /** How the change is paced. Still at both ends unless a figure says otherwise,
19
+ * because a move that starts and stops abruptly reads as a jump. */
20
+ curve?: Curve;
21
+ /** Seconds after the previous entry finished. A negative wait overlaps this
22
+ * animation with the one before it. */
23
+ after?: number;
24
+ }
25
+ /**
26
+ * The ordered list, built by naming one thing after another.
27
+ *
28
+ * Each call hands back a new timeline rather than changing this one, so a figure
29
+ * that builds a timeline inside a function called every frame cannot accumulate
30
+ * entries it did not mean to.
31
+ */
32
+ export declare class Timeline {
33
+ readonly spans: readonly Span[];
34
+ readonly duration: number;
35
+ private constructor();
36
+ static empty(): Timeline;
37
+ play(animation: Animation, seconds: number, options?: PlayOptions): Timeline;
38
+ /** Several changes over one span, which is how two things move at once. */
39
+ together(animations: readonly Animation[], seconds: number, options?: PlayOptions): Timeline;
40
+ wait(seconds: number): Timeline;
41
+ /**
42
+ * The marks as every span leaves them at a time.
43
+ *
44
+ * A span that has not started yet is applied at nothing and a span already
45
+ * finished is applied in full, which is what makes this a function of time
46
+ * rather than a record of what has been played. A mark waiting to fade in is
47
+ * therefore invisible rather than solid, and one that has faded out stays gone.
48
+ */
49
+ at(marks: readonly Mark[], seconds: number): readonly Mark[];
50
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Animations in the order they happen, turned into absolute spans.
3
+ *
4
+ * The order is written once and compiled once. Reading it at a time is then a
5
+ * walk over a sorted list, which is what lets the same timeline answer a page
6
+ * playing forward, a reader dragging backwards, and a recorder stepping.
7
+ */
8
+ import { curveFor } from '../values/ease.js';
9
+ /**
10
+ * The ordered list, built by naming one thing after another.
11
+ *
12
+ * Each call hands back a new timeline rather than changing this one, so a figure
13
+ * that builds a timeline inside a function called every frame cannot accumulate
14
+ * entries it did not mean to.
15
+ */
16
+ export class Timeline {
17
+ spans;
18
+ duration;
19
+ constructor(spans, duration) {
20
+ this.spans = spans;
21
+ this.duration = duration;
22
+ }
23
+ static empty() {
24
+ return new Timeline([], 0);
25
+ }
26
+ play(animation, seconds, options = {}) {
27
+ const from = this.duration + (options.after ?? 0);
28
+ const to = from + seconds;
29
+ const span = { animation, from, to, curve: options.curve ?? curveFor(true, true) };
30
+ return new Timeline([...this.spans, span], Math.max(this.duration, to));
31
+ }
32
+ /** Several changes over one span, which is how two things move at once. */
33
+ together(animations, seconds, options = {}) {
34
+ let built = this;
35
+ animations.forEach((animation, at) => {
36
+ built = built.play(animation, seconds, at === 0 ? options : { ...options, after: -seconds });
37
+ });
38
+ return built;
39
+ }
40
+ wait(seconds) {
41
+ return new Timeline(this.spans, this.duration + seconds);
42
+ }
43
+ /**
44
+ * The marks as every span leaves them at a time.
45
+ *
46
+ * A span that has not started yet is applied at nothing and a span already
47
+ * finished is applied in full, which is what makes this a function of time
48
+ * rather than a record of what has been played. A mark waiting to fade in is
49
+ * therefore invisible rather than solid, and one that has faded out stays gone.
50
+ */
51
+ at(marks, seconds) {
52
+ let built = marks;
53
+ for (const span of this.spans) {
54
+ const width = span.to - span.from;
55
+ const raw = width <= 0 ? (seconds >= span.to ? 1 : 0) : (seconds - span.from) / width;
56
+ const held = raw <= 0 ? 0 : raw >= 1 ? 1 : raw;
57
+ built = span.animation(built, span.curve(held));
58
+ }
59
+ return built;
60
+ }
61
+ }
@@ -0,0 +1,10 @@
1
+ import { type Path } from './path.js';
2
+ /**
3
+ * The path up to a fraction of its total length.
4
+ *
5
+ * A fraction at or past one is the path itself, untouched, so a finished drawing
6
+ * is the same geometry the author wrote rather than a rebuilt copy of it. A
7
+ * subpath the cut has not reached is left out entirely, and the one it lands in
8
+ * ends with a segment split where the cut falls.
9
+ */
10
+ export declare function trimPath(path: Path, fraction: number): Path;