@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Siva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @altpsyche/maths
2
+
3
+ The mathematics the figures on [altpsyche.dev](https://altpsyche.dev) are drawn from.
4
+
5
+ A **figure** is a picture that moves and explains itself. Asking one for a time gives back a
6
+ flat list of **marks**, and a **painter** turns marks into something a reader can see.
7
+
8
+ ```ts
9
+ import { Timeline, at, circle, draw, group, shape, svgMarkup, vec2, viewMatrix } from '@altpsyche/maths';
10
+
11
+ const figure = {
12
+ extent: { width: 16, height: 9 },
13
+ still: 1,
14
+ scene: group('fig', [shape('ring', circle(vec2(0, 0), 3), { stroke: { colour: '#fff', width: 0.05 } })]),
15
+ timeline: Timeline.empty().play(draw('fig/ring'), 1),
16
+ };
17
+
18
+ svgMarkup(at(figure, 0.5), viewMatrix(figure.extent, 'contain', 640, 360), 640, 360);
19
+ ```
20
+
21
+ ## What it is built on
22
+
23
+ **A figure at a time is data.** `at(figure, seconds)` is the whole public surface, and it is a
24
+ pure function: ask for four seconds and it gives the picture at four seconds whatever it gave
25
+ before. A page playing forward, a reader dragging a scrub bar backwards and a recorder walking
26
+ a fixed step are three consumers of one answer.
27
+
28
+ **Everything is a cubic**, a straight line included. That is what lets one shape be walked into
29
+ another point by point, with no case where a line has to become an arc.
30
+
31
+ **A mark may only ask for what both painters can do**, rather than the union of them. There are
32
+ no filters, no blend modes, no clipping and no gradients, because a figure reaching for
33
+ something only SVG has would look right on a page and lose it without a word in a recording.
34
+
35
+ **A figure never reads the page.** Colours arrive as a palette the caller hands in.
36
+
37
+ ## The two painters
38
+
39
+ `svgMarkup` and `paintSvg` write SVG, which is what a figure on a page is: the text is text, CSS
40
+ reaches it, and the markup can be written with no browser at all. `paintCanvas` paints the same
41
+ marks onto a two-dimensional canvas, which is what a recording needs, because an encoder takes
42
+ one surface. A test holds the two to emitting the same geometry and the same style for every
43
+ mark.
44
+
45
+ ## The line through the package
46
+
47
+ **Values and timing** are below it: vectors, a transform, the four curves a change can travel
48
+ along, and a value walked between keys. That half changes almost never. **Figures and painters**
49
+ are above it. Nothing below the line imports anything above it, and a test says so.
50
+
51
+ One door, no runtime dependencies. MIT.
@@ -0,0 +1,29 @@
1
+ import { type Vec2 } from '../values/vec2.js';
2
+ import { type Path } from './path.js';
3
+ import type { Mark } from './mark.js';
4
+ export type Animation = (marks: readonly Mark[], along: number) => readonly Mark[];
5
+ /** From nothing to whatever opacity the mark already had, so a mark that is
6
+ * half faded by design does not become solid on the way in. */
7
+ export declare function fadeIn(target: string): Animation;
8
+ export declare function fadeOut(target: string): Animation;
9
+ /** Moved by an offset in figure units, which reaches the geometry rather than
10
+ * riding alongside it, the same way a group's transform does. */
11
+ export declare function moveBy(target: string, offset: Vec2): Animation;
12
+ /**
13
+ * Drawn on from one end rather than switched on.
14
+ *
15
+ * A text mark has no path to walk along, so it fades instead. That is a choice
16
+ * rather than an oversight: drawing letters on stroke by stroke needs outlines,
17
+ * and a text mark is deliberately a string a painter lays out.
18
+ */
19
+ export declare function draw(target: string): Animation;
20
+ /**
21
+ * One shape becoming another, point by point.
22
+ *
23
+ * The two paths are aligned first, so the one with fewer segments is subdivided
24
+ * until both hold the same points. A mark with no path is left alone.
25
+ */
26
+ export declare function morph(target: string, into: Path): Animation;
27
+ /** A mark's own opacity walked to a value, for a figure that wants a thing dimmed
28
+ * rather than gone. */
29
+ export declare function fadeTo(target: string, opacity: number): Animation;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A change to some of the marks, over a span of time.
3
+ *
4
+ * An animation is a function rather than an object with a start and a stop,
5
+ * because the picture at a time has to be the same whichever direction the clock
6
+ * arrived from. Playing forward, dragging a scrub bar backwards and walking a
7
+ * fixed step for a recording all ask the same question and must get the same
8
+ * answer.
9
+ *
10
+ * Each one is given how far through its own span the clock is, already eased, and
11
+ * hands back the marks as they stand at that fraction.
12
+ */
13
+ import { mat3 } from '../values/mat3.js';
14
+ import { vec2 } from '../values/vec2.js';
15
+ import { lerp } from '../values/scalar.js';
16
+ import { transformPath } from './path.js';
17
+ import { trimPath } from './trim.js';
18
+ import { lerpPath } from './morph.js';
19
+ /**
20
+ * Which marks an animation touches.
21
+ *
22
+ * A target is an id or the front of one, so naming a group reaches everything
23
+ * inside it and naming a mark reaches only that mark. A name that matches
24
+ * nothing changes nothing rather than failing, because a figure being written is
25
+ * often a figure whose parts do not all exist yet.
26
+ */
27
+ function touches(id, target) {
28
+ return id === target || id.startsWith(`${target}/`);
29
+ }
30
+ function over(target, change) {
31
+ return (marks, along) => marks.map((mark) => (touches(mark.id, target) ? change(mark, along) : mark));
32
+ }
33
+ /** From nothing to whatever opacity the mark already had, so a mark that is
34
+ * half faded by design does not become solid on the way in. */
35
+ export function fadeIn(target) {
36
+ return over(target, (mark, along) => ({ ...mark, opacity: (mark.opacity ?? 1) * along }));
37
+ }
38
+ export function fadeOut(target) {
39
+ return over(target, (mark, along) => ({ ...mark, opacity: (mark.opacity ?? 1) * (1 - along) }));
40
+ }
41
+ /** Moved by an offset in figure units, which reaches the geometry rather than
42
+ * riding alongside it, the same way a group's transform does. */
43
+ export function moveBy(target, offset) {
44
+ return over(target, (mark, along) => {
45
+ const step = mat3.translation(vec2.scale(offset, along));
46
+ if (mark.kind === 'text')
47
+ return { ...mark, at: mat3.transformPoint(step, mark.at) };
48
+ return { ...mark, path: transformPath(mark.path, step) };
49
+ });
50
+ }
51
+ /**
52
+ * Drawn on from one end rather than switched on.
53
+ *
54
+ * A text mark has no path to walk along, so it fades instead. That is a choice
55
+ * rather than an oversight: drawing letters on stroke by stroke needs outlines,
56
+ * and a text mark is deliberately a string a painter lays out.
57
+ */
58
+ export function draw(target) {
59
+ return over(target, (mark, along) => {
60
+ if (mark.kind === 'text')
61
+ return { ...mark, opacity: (mark.opacity ?? 1) * along };
62
+ return { ...mark, path: trimPath(mark.path, along) };
63
+ });
64
+ }
65
+ /**
66
+ * One shape becoming another, point by point.
67
+ *
68
+ * The two paths are aligned first, so the one with fewer segments is subdivided
69
+ * until both hold the same points. A mark with no path is left alone.
70
+ */
71
+ export function morph(target, into) {
72
+ return over(target, (mark, along) => {
73
+ if (mark.kind === 'text')
74
+ return mark;
75
+ return { ...mark, path: lerpPath(mark.path, into, along) };
76
+ });
77
+ }
78
+ /** A mark's own opacity walked to a value, for a figure that wants a thing dimmed
79
+ * rather than gone. */
80
+ export function fadeTo(target, opacity) {
81
+ return over(target, (mark, along) => ({ ...mark, opacity: lerp(mark.opacity ?? 1, opacity, along) }));
82
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The shapes an annotation is made of, composed from marks rather than being
3
+ * marks of their own.
4
+ *
5
+ * Each one hands back a group, so an arrow is a shaft and a head with ids of
6
+ * their own and an animation naming the arrow reaches both. Making an arrow a
7
+ * single mark instead would mean one path that is stroked along its shaft and
8
+ * filled at its head, and no mark can be both.
9
+ */
10
+ import { type Vec2 } from '../values/vec2.js';
11
+ import { type GroupNode, type TextOptions } from './node.js';
12
+ import type { Fill, Stroke } from './mark.js';
13
+ export interface ArrowOptions {
14
+ stroke: Stroke;
15
+ /** Filled with the shaft's own colour unless a figure asks for another. */
16
+ fill?: Fill;
17
+ /**
18
+ * How long the head is, in figure units. Four times the shaft's width by
19
+ * default, which keeps a head in proportion to its line at any size, since
20
+ * both are in figure units and scale together.
21
+ */
22
+ head?: number;
23
+ /** How wide the head is across its base, against its length. */
24
+ spread?: number;
25
+ }
26
+ /**
27
+ * A line with a head at the far end.
28
+ *
29
+ * The shaft stops where the head begins rather than running under it, because a
30
+ * shaft drawn to the point shows through a head that is not fully opaque.
31
+ */
32
+ export declare function arrow(name: string, from: Vec2, to: Vec2, options: ArrowOptions): GroupNode;
33
+ /** A filled disc, which is what marks a place a line is pointing at. */
34
+ export declare function dot(name: string, at: Vec2, radius: number, fill: Fill): GroupNode;
35
+ export interface CalloutOptions {
36
+ stroke: Stroke;
37
+ fill: Fill;
38
+ size: number;
39
+ /** The disc left on the thing being named. Nothing is drawn where this is zero,
40
+ * which is what a callout pointing at a moving thing wants. */
41
+ marker?: number;
42
+ align?: TextOptions['align'];
43
+ baseline?: TextOptions['baseline'];
44
+ family?: string;
45
+ weight?: number;
46
+ }
47
+ /**
48
+ * A word attached to a place: a disc on the place, a line out to where there is
49
+ * room, and the word at the end of it.
50
+ *
51
+ * This is the annotation a figure over a shader is made of. The words sit away
52
+ * from what they name because a label on top of the picture hides the thing the
53
+ * reader was told to look at.
54
+ */
55
+ export declare function callout(name: string, at: Vec2, to: Vec2, content: string, options: CalloutOptions): GroupNode;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The shapes an annotation is made of, composed from marks rather than being
3
+ * marks of their own.
4
+ *
5
+ * Each one hands back a group, so an arrow is a shaft and a head with ids of
6
+ * their own and an animation naming the arrow reaches both. Making an arrow a
7
+ * single mark instead would mean one path that is stroked along its shaft and
8
+ * filled at its head, and no mark can be both.
9
+ */
10
+ import { vec2 } from '../values/vec2.js';
11
+ import { circle, line, polygon } from './path.js';
12
+ import { group, shape, text } from './node.js';
13
+ /**
14
+ * A line with a head at the far end.
15
+ *
16
+ * The shaft stops where the head begins rather than running under it, because a
17
+ * shaft drawn to the point shows through a head that is not fully opaque.
18
+ */
19
+ export function arrow(name, from, to, options) {
20
+ const head = options.head ?? options.stroke.width * 4;
21
+ const spread = options.spread ?? 0.6;
22
+ const along = vec2.normalize(vec2.sub(to, from));
23
+ const base = vec2.sub(to, vec2.scale(along, head));
24
+ const across = vec2.scale(vec2.perpendicular(along), (head * spread) / 2);
25
+ const fill = options.fill ?? { colour: options.stroke.colour };
26
+ return group(name, [
27
+ shape('shaft', line(from, base), { stroke: options.stroke }),
28
+ shape('head', polygon([to, vec2.add(base, across), vec2.sub(base, across)]), { fill }),
29
+ ]);
30
+ }
31
+ /** A filled disc, which is what marks a place a line is pointing at. */
32
+ export function dot(name, at, radius, fill) {
33
+ return group(name, [shape('disc', circle(at, radius), { fill })]);
34
+ }
35
+ /**
36
+ * A word attached to a place: a disc on the place, a line out to where there is
37
+ * room, and the word at the end of it.
38
+ *
39
+ * This is the annotation a figure over a shader is made of. The words sit away
40
+ * from what they name because a label on top of the picture hides the thing the
41
+ * reader was told to look at.
42
+ */
43
+ export function callout(name, at, to, content, options) {
44
+ const marker = options.marker ?? options.stroke.width * 2;
45
+ const style = { fill: options.fill, family: options.family, weight: options.weight };
46
+ const parts = [
47
+ shape('leader', line(at, to), { stroke: options.stroke }),
48
+ text('word', to, content, options.size, { ...style, align: options.align, baseline: options.baseline }),
49
+ ];
50
+ if (marker > 0)
51
+ parts.unshift(shape('marker', circle(at, marker), { fill: options.fill }));
52
+ return group(name, parts);
53
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * How much of the world a figure shows, and how that lands on a surface.
3
+ *
4
+ * A figure is measured in its own units and never in pixels, so one figure draws
5
+ * at 640 across in a chapter and at 2160 by 3840 in a recording with no second
6
+ * version of the picture and no coordinates rewritten.
7
+ */
8
+ import { type Mat3 } from '../values/mat3.js';
9
+ import { type Vec2 } from '../values/vec2.js';
10
+ export interface Extent {
11
+ width: number;
12
+ height: number;
13
+ }
14
+ /** Whether the extent is held inside the surface, leaving margins where the
15
+ * shapes differ, or fills it and runs off two edges. */
16
+ export type Fit = 'contain' | 'cover';
17
+ /** An extent that never changes, or one chosen from the shape of the surface it
18
+ * is about to be drawn on. */
19
+ export type ExtentChoice = Extent | ((aspect: number) => Extent);
20
+ export declare function resolveExtent(choice: ExtentChoice, aspect: number): Extent;
21
+ /**
22
+ * An extent per shape, for a figure whose composition does not survive being
23
+ * reframed.
24
+ *
25
+ * A wide composition put in a square frame is a different picture rather than a
26
+ * cropped one, which is why this chooses an extent rather than cutting a wide
27
+ * frame down. The two thresholds sit between the three shapes anything here is
28
+ * drawn at, which are sixteen by nine at 1.78, square at 1, and nine by sixteen
29
+ * at 0.5625.
30
+ */
31
+ export declare function byAspect(shapes: {
32
+ wide: Extent;
33
+ square: Extent;
34
+ tall: Extent;
35
+ }): (aspect: number) => Extent;
36
+ /**
37
+ * An extent that follows the shape of whatever it is drawn on.
38
+ *
39
+ * This is what a figure drawn over something else uses. The height is fixed and
40
+ * the width follows the surface, so `contain` fits it exactly and there are no
41
+ * margins at any shape: a figure over a shader covers the shader, at sixteen by
42
+ * nine and at nine by sixteen alike.
43
+ */
44
+ export declare function matchingAspect(height?: number): (aspect: number) => Extent;
45
+ /**
46
+ * A point given as a fraction of the frame rather than in figure units, with
47
+ * nothing at the bottom left and one at the top right.
48
+ *
49
+ * A mark placed this way is in screen space. That is the only placement that is
50
+ * safe over a shader, because putting a mark at a place inside the scene a shader
51
+ * is drawing would need the shader's camera, and nothing can read one.
52
+ */
53
+ export declare function fractionOf(extent: Extent, across: number, up: number): Vec2;
54
+ /**
55
+ * The one matrix taking figure units onto a surface, with the extent centred.
56
+ *
57
+ * The y axis is flipped, because a figure counts upward the way a graph does and
58
+ * both painters count downward from the top of the surface. Doing it here rather
59
+ * than in each painter is what keeps the two from disagreeing about which way up
60
+ * a picture is.
61
+ */
62
+ export declare function viewMatrix(extent: Extent, fit: Fit, surfaceWidth: number, surfaceHeight: number): Mat3;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * How much of the world a figure shows, and how that lands on a surface.
3
+ *
4
+ * A figure is measured in its own units and never in pixels, so one figure draws
5
+ * at 640 across in a chapter and at 2160 by 3840 in a recording with no second
6
+ * version of the picture and no coordinates rewritten.
7
+ */
8
+ import { mat3 } from '../values/mat3.js';
9
+ import { vec2 } from '../values/vec2.js';
10
+ export function resolveExtent(choice, aspect) {
11
+ return typeof choice === 'function' ? choice(aspect) : choice;
12
+ }
13
+ /**
14
+ * An extent per shape, for a figure whose composition does not survive being
15
+ * reframed.
16
+ *
17
+ * A wide composition put in a square frame is a different picture rather than a
18
+ * cropped one, which is why this chooses an extent rather than cutting a wide
19
+ * frame down. The two thresholds sit between the three shapes anything here is
20
+ * drawn at, which are sixteen by nine at 1.78, square at 1, and nine by sixteen
21
+ * at 0.5625.
22
+ */
23
+ export function byAspect(shapes) {
24
+ return (aspect) => {
25
+ if (aspect > 1.15)
26
+ return shapes.wide;
27
+ if (aspect < 0.87)
28
+ return shapes.tall;
29
+ return shapes.square;
30
+ };
31
+ }
32
+ /**
33
+ * An extent that follows the shape of whatever it is drawn on.
34
+ *
35
+ * This is what a figure drawn over something else uses. The height is fixed and
36
+ * the width follows the surface, so `contain` fits it exactly and there are no
37
+ * margins at any shape: a figure over a shader covers the shader, at sixteen by
38
+ * nine and at nine by sixteen alike.
39
+ */
40
+ export function matchingAspect(height = 2) {
41
+ return (aspect) => ({ width: height * aspect, height });
42
+ }
43
+ /**
44
+ * A point given as a fraction of the frame rather than in figure units, with
45
+ * nothing at the bottom left and one at the top right.
46
+ *
47
+ * A mark placed this way is in screen space. That is the only placement that is
48
+ * safe over a shader, because putting a mark at a place inside the scene a shader
49
+ * is drawing would need the shader's camera, and nothing can read one.
50
+ */
51
+ export function fractionOf(extent, across, up) {
52
+ return vec2((across - 0.5) * extent.width, (up - 0.5) * extent.height);
53
+ }
54
+ /**
55
+ * The one matrix taking figure units onto a surface, with the extent centred.
56
+ *
57
+ * The y axis is flipped, because a figure counts upward the way a graph does and
58
+ * both painters count downward from the top of the surface. Doing it here rather
59
+ * than in each painter is what keeps the two from disagreeing about which way up
60
+ * a picture is.
61
+ */
62
+ export function viewMatrix(extent, fit, surfaceWidth, surfaceHeight) {
63
+ const byWidth = surfaceWidth / extent.width;
64
+ const byHeight = surfaceHeight / extent.height;
65
+ const scale = fit === 'cover' ? Math.max(byWidth, byHeight) : Math.min(byWidth, byHeight);
66
+ const centre = mat3.translation(vec2(surfaceWidth / 2, surfaceHeight / 2));
67
+ const flip = mat3.scaling(vec2(scale, -scale));
68
+ return mat3.multiply(centre, flip);
69
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A picture over time, and the one call that reads it.
3
+ *
4
+ * `at` is the whole public surface of a figure. Ask it for four seconds and it
5
+ * gives the picture at four seconds, whatever it gave before, so a page playing
6
+ * forward, a reader dragging a scrub bar backwards and a recorder walking a fixed
7
+ * step are three consumers of one answer rather than three implementations of it.
8
+ *
9
+ * Nothing here touches a screen. What comes back is a list of marks in the
10
+ * figure's own units, and a painter decides what to do with them.
11
+ */
12
+ import { type TrackValue, type Tracks } from '../timing/track.js';
13
+ import { type Node } from './node.js';
14
+ import { Timeline } from './timeline.js';
15
+ import type { ExtentChoice, Fit } from './extent.js';
16
+ import type { Mark } from './mark.js';
17
+ /** The values a scene is rebuilt from, sampled out of the figure's tracks. This
18
+ * is what lets geometry follow a number rather than only be moved about: a
19
+ * radius that is keyed makes a circle that is genuinely a different circle each
20
+ * frame. */
21
+ export type Values = Record<string, TrackValue>;
22
+ export interface Figure {
23
+ /** How much of the world the figure shows, in its own units. */
24
+ extent: ExtentChoice;
25
+ fit?: Fit;
26
+ /** The tree, either fixed or rebuilt from the clock and the sampled values. */
27
+ scene: Node | ((seconds: number, values: Values) => Node);
28
+ tracks?: Tracks;
29
+ timeline?: Timeline;
30
+ /** Overrides the timeline's own length, for a figure that should hold after
31
+ * its last animation finishes. */
32
+ duration?: number;
33
+ /**
34
+ * The one time a reader who asked for reduced motion is shown.
35
+ *
36
+ * Every figure names it, because the last frame is not always the one that
37
+ * explains the most and a figure stopped at zero often explains nothing.
38
+ */
39
+ still: number;
40
+ /** A figure that ends where it began, which a recording can loop without a
41
+ * jump. Held by a test rather than taken on trust. */
42
+ loop?: boolean;
43
+ }
44
+ export declare function durationOf(figure: Figure): number;
45
+ /** The marks a figure shows at a time. */
46
+ export declare function at(figure: Figure, seconds: number): readonly Mark[];
47
+ /** Whether a figure declaring itself a loop actually is one, which is the gate
48
+ * behind that flag. The comparison is by tolerance rather than exactly, because
49
+ * the sine and cosine a figure is built from are not specified to the last bit
50
+ * and differ between engines. */
51
+ export declare function loops(figure: Figure, tolerance?: number): boolean;
52
+ /** Two lists holding the same marks in the same order, to a tolerance. */
53
+ export declare function sameMarks(one: readonly Mark[], two: readonly Mark[], tolerance?: number): boolean;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A picture over time, and the one call that reads it.
3
+ *
4
+ * `at` is the whole public surface of a figure. Ask it for four seconds and it
5
+ * gives the picture at four seconds, whatever it gave before, so a page playing
6
+ * forward, a reader dragging a scrub bar backwards and a recorder walking a fixed
7
+ * step are three consumers of one answer rather than three implementations of it.
8
+ *
9
+ * Nothing here touches a screen. What comes back is a list of marks in the
10
+ * figure's own units, and a painter decides what to do with them.
11
+ */
12
+ import { sampleTracks } from '../timing/track.js';
13
+ import { flatten } from './node.js';
14
+ export function durationOf(figure) {
15
+ return figure.duration ?? figure.timeline?.duration ?? 0;
16
+ }
17
+ /** The marks a figure shows at a time. */
18
+ export function at(figure, seconds) {
19
+ const values = figure.tracks ? sampleTracks(figure.tracks, seconds) : {};
20
+ const tree = typeof figure.scene === 'function' ? figure.scene(seconds, values) : figure.scene;
21
+ const marks = flatten(tree);
22
+ return figure.timeline ? figure.timeline.at(marks, seconds) : marks;
23
+ }
24
+ /** Whether a figure declaring itself a loop actually is one, which is the gate
25
+ * behind that flag. The comparison is by tolerance rather than exactly, because
26
+ * the sine and cosine a figure is built from are not specified to the last bit
27
+ * and differ between engines. */
28
+ export function loops(figure, tolerance = 1e-6) {
29
+ const start = at(figure, 0);
30
+ const end = at(figure, durationOf(figure));
31
+ return sameMarks(start, end, tolerance);
32
+ }
33
+ /** Two lists holding the same marks in the same order, to a tolerance. */
34
+ export function sameMarks(one, two, tolerance = 1e-6) {
35
+ if (one.length !== two.length)
36
+ return false;
37
+ return one.every((mark, at) => sameMark(mark, two[at], tolerance));
38
+ }
39
+ function close(a, b, tolerance) {
40
+ return Math.abs((a ?? 1) - (b ?? 1)) <= tolerance;
41
+ }
42
+ function sameMark(one, two, tolerance) {
43
+ if (one.kind !== two.kind || one.id !== two.id)
44
+ return false;
45
+ if (!close(one.opacity, two.opacity, tolerance))
46
+ return false;
47
+ if (one.kind === 'text' && two.kind === 'text') {
48
+ return (one.text === two.text &&
49
+ Math.abs(one.at.x - two.at.x) <= tolerance &&
50
+ Math.abs(one.at.y - two.at.y) <= tolerance &&
51
+ Math.abs(one.size - two.size) <= tolerance);
52
+ }
53
+ if (one.kind !== 'path' || two.kind !== 'path')
54
+ return false;
55
+ if (one.path.length !== two.path.length)
56
+ return false;
57
+ return one.path.every((subpath, index) => {
58
+ const other = two.path[index];
59
+ if (subpath.curves.length !== other.curves.length)
60
+ return false;
61
+ if (Math.abs(subpath.start.x - other.start.x) > tolerance)
62
+ return false;
63
+ if (Math.abs(subpath.start.y - other.start.y) > tolerance)
64
+ return false;
65
+ return subpath.curves.every((curve, piece) => {
66
+ const twin = other.curves[piece];
67
+ return (Math.abs(curve.control1.x - twin.control1.x) <= tolerance &&
68
+ Math.abs(curve.control1.y - twin.control1.y) <= tolerance &&
69
+ Math.abs(curve.control2.x - twin.control2.x) <= tolerance &&
70
+ Math.abs(curve.control2.y - twin.control2.y) <= tolerance &&
71
+ Math.abs(curve.to.x - twin.to.x) <= tolerance &&
72
+ Math.abs(curve.to.y - twin.to.y) <= tolerance);
73
+ });
74
+ });
75
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * One drawn item, resolved: its geometry is in the figure's own units with every
3
+ * transform already applied, and its style is settled rather than inherited.
4
+ *
5
+ * What a mark may ask for is the intersection of what an SVG element and a
6
+ * two-dimensional canvas can both do, rather than the union. A figure reaching
7
+ * for something only one of them has would look right on the page and lose it
8
+ * without a word in a recording, which is the worst way to find out. So there
9
+ * are no filters, no blend modes, no clipping and no gradients here, and adding
10
+ * one means adding it to both painters in the same change.
11
+ */
12
+ import type { Vec2 } from '../values/vec2.js';
13
+ import type { Path } from './path.js';
14
+ /** A colour either painter accepts, which is any CSS colour written as text. A
15
+ * figure is handed these in a palette rather than reading them from a page. */
16
+ export type Colour = string;
17
+ export interface Stroke {
18
+ colour: Colour;
19
+ /** In figure units, scaled with everything else, so a line reads the same
20
+ * weight at every size the figure is drawn at. */
21
+ width: number;
22
+ cap?: 'butt' | 'round' | 'square';
23
+ join?: 'miter' | 'round' | 'bevel';
24
+ /** Lengths of the drawn and undrawn runs, in figure units. */
25
+ dash?: readonly number[];
26
+ dashOffset?: number;
27
+ }
28
+ export interface Fill {
29
+ colour: Colour;
30
+ /** How a shape that crosses itself decides what is inside. Both painters
31
+ * carry both answers under different names. */
32
+ rule?: 'nonzero' | 'evenodd';
33
+ }
34
+ interface Common {
35
+ /**
36
+ * Stable across frames, and built from the names on the way down the tree.
37
+ *
38
+ * Hit testing reads the flat list rather than walking the tree again, and
39
+ * comparing one frame against another needs to know which mark is which, so
40
+ * an id that changed between frames would make both impossible.
41
+ */
42
+ id: string;
43
+ opacity?: number;
44
+ }
45
+ export interface PathMark extends Common {
46
+ kind: 'path';
47
+ path: Path;
48
+ fill?: Fill;
49
+ stroke?: Stroke;
50
+ }
51
+ export interface TextMark extends Common {
52
+ kind: 'text';
53
+ at: Vec2;
54
+ text: string;
55
+ /** In figure units, like a stroke width. */
56
+ size: number;
57
+ family: string;
58
+ weight?: number;
59
+ /** Which end of the text sits at the anchor point. */
60
+ align?: 'start' | 'middle' | 'end';
61
+ /** Where the anchor point sits against the line of text. */
62
+ baseline?: 'alphabetic' | 'middle' | 'hanging';
63
+ fill: Fill;
64
+ }
65
+ export type Mark = PathMark | TextMark;
66
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { Path } from './path.js';
2
+ /** The two paths rewritten to the same shape of point list, drawing exactly what
3
+ * they drew before. */
4
+ export declare function alignPaths(from: Path, to: Path): [Path, Path];
5
+ /** Part way from one path to another, point by point, after aligning them. */
6
+ export declare function lerpPath(from: Path, to: Path, along: number): Path;