@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,100 @@
1
+ /**
2
+ * Cutting a path short, which is how a shape is drawn on rather than switched on.
3
+ *
4
+ * The cut is by length rather than by segment, so a path whose segments differ in
5
+ * size is drawn at one steady pace. Parameterising by segment instead is cheaper
6
+ * and reads wrong: a long side would be crossed in the same time as a short one
7
+ * and the pen would visibly speed up and slow down.
8
+ */
9
+ import { vec2 } from '../values/vec2.js';
10
+ import { pointOn } from './path.js';
11
+ /** How many samples measure one segment. Sixteen holds the length of a quarter
12
+ * circle to better than a part in ten thousand, which is finer than the curve's
13
+ * own error against a true arc. */
14
+ const SAMPLES = 16;
15
+ /**
16
+ * A cubic cut at a fraction of its own parameter, keeping the first piece.
17
+ *
18
+ * De Casteljau: the same repeated interpolation that evaluates the curve gives
19
+ * the control points of both halves as it goes.
20
+ */
21
+ function splitCubic(from, curve, along) {
22
+ const a = vec2.lerp(from, curve.control1, along);
23
+ const b = vec2.lerp(curve.control1, curve.control2, along);
24
+ const c = vec2.lerp(curve.control2, curve.to, along);
25
+ const d = vec2.lerp(a, b, along);
26
+ const e = vec2.lerp(b, c, along);
27
+ return { control1: a, control2: d, to: vec2.lerp(d, e, along) };
28
+ }
29
+ /** A segment's length, measured by walking it in straight steps. */
30
+ function segmentLength(from, curve) {
31
+ let total = 0;
32
+ let previous = from;
33
+ for (let at = 1; at <= SAMPLES; at++) {
34
+ const point = pointOn(from, curve, at / SAMPLES);
35
+ total += vec2.distance(previous, point);
36
+ previous = point;
37
+ }
38
+ return total;
39
+ }
40
+ /** Every segment's length and the total, which is what a cut by length needs
41
+ * before it can find which segment the cut falls in. */
42
+ function lengths(path) {
43
+ let total = 0;
44
+ const per = path.map((subpath) => {
45
+ let from = subpath.start;
46
+ return subpath.curves.map((curve) => {
47
+ const length = segmentLength(from, curve);
48
+ from = curve.to;
49
+ total += length;
50
+ return length;
51
+ });
52
+ });
53
+ return { per, total };
54
+ }
55
+ /**
56
+ * The path up to a fraction of its total length.
57
+ *
58
+ * A fraction at or past one is the path itself, untouched, so a finished drawing
59
+ * is the same geometry the author wrote rather than a rebuilt copy of it. A
60
+ * subpath the cut has not reached is left out entirely, and the one it lands in
61
+ * ends with a segment split where the cut falls.
62
+ */
63
+ export function trimPath(path, fraction) {
64
+ if (fraction >= 1)
65
+ return path;
66
+ if (fraction <= 0)
67
+ return [];
68
+ const { per, total } = lengths(path);
69
+ if (total === 0)
70
+ return path;
71
+ const wanted = total * fraction;
72
+ let walked = 0;
73
+ const kept = [];
74
+ for (let at = 0; at < path.length; at++) {
75
+ const subpath = path[at];
76
+ const curves = [];
77
+ let from = subpath.start;
78
+ let cut = false;
79
+ for (let piece = 0; piece < subpath.curves.length; piece++) {
80
+ const curve = subpath.curves[piece];
81
+ const length = per[at][piece];
82
+ if (walked + length <= wanted || length === 0) {
83
+ curves.push(curve);
84
+ walked += length;
85
+ from = curve.to;
86
+ continue;
87
+ }
88
+ curves.push(splitCubic(from, curve, (wanted - walked) / length));
89
+ cut = true;
90
+ break;
91
+ }
92
+ // A subpath cut part way through stops being closed, because the join back
93
+ // to its start is one of the parts that has not been drawn yet.
94
+ if (curves.length > 0)
95
+ kept.push({ start: subpath.start, curves, closed: cut ? false : subpath.closed });
96
+ if (cut)
97
+ break;
98
+ }
99
+ return kept;
100
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one door. Nothing outside this package reaches a file inside it by path,
3
+ * which is the same rule the engine is held to, so a caller can never come to
4
+ * depend on where a file sits.
5
+ *
6
+ * There is a line through this package. Values and timing are below it and
7
+ * change almost never; figures and painters will sit above it and change often.
8
+ * Nothing below the line may import anything above it.
9
+ */
10
+ export { clamp, inverseLerp, lerp, remap } from './values/scalar.js';
11
+ export { curveFor, easeIn, easeOut, linear, smoothstep } from './values/ease.js';
12
+ export type { Curve } from './values/ease.js';
13
+ export { vec2 } from './values/vec2.js';
14
+ export type { Vec2 } from './values/vec2.js';
15
+ export { vec3 } from './values/vec3.js';
16
+ export type { Vec3 } from './values/vec3.js';
17
+ export { mat3 } from './values/mat3.js';
18
+ export type { Mat3 } from './values/mat3.js';
19
+ export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
20
+ export type { Key, Track, TrackValue, Tracks } from './timing/track.js';
21
+ export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, straight, transformPath } from './figure/path.js';
22
+ export type { Cubic, Path, Subpath } from './figure/path.js';
23
+ export type { Colour, Fill, Mark, PathMark, Stroke, TextMark } from './figure/mark.js';
24
+ export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
25
+ export type { Extent, ExtentChoice, Fit } from './figure/extent.js';
26
+ export { flatten, group, shape, text } from './figure/node.js';
27
+ export type { GroupNode, Node, ShapeNode, Style, TextNode, TextOptions } from './figure/node.js';
28
+ export { fadeIn, fadeOut, fadeTo, draw, morph, moveBy } from './figure/animation.js';
29
+ export type { Animation } from './figure/animation.js';
30
+ export { Timeline } from './figure/timeline.js';
31
+ export type { PlayOptions, Span } from './figure/timeline.js';
32
+ export { trimPath } from './figure/trim.js';
33
+ export { alignPaths, lerpPath } from './figure/morph.js';
34
+ export { at, durationOf, loops, sameMarks } from './figure/figure.js';
35
+ export type { Figure, Values } from './figure/figure.js';
36
+ export { pathData, paintSvg, svgElements, svgMarkup } from './paint/svg.js';
37
+ export type { ElementMaker, PaintNode, PaintTarget, SvgElement } from './paint/svg.js';
38
+ export { paintCanvas } from './paint/canvas.js';
39
+ export type { CanvasLike } from './paint/canvas.js';
40
+ export { arrow, callout, dot } from './figure/annotate.js';
41
+ export type { ArrowOptions, CalloutOptions } from './figure/annotate.js';
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one door. Nothing outside this package reaches a file inside it by path,
3
+ * which is the same rule the engine is held to, so a caller can never come to
4
+ * depend on where a file sits.
5
+ *
6
+ * There is a line through this package. Values and timing are below it and
7
+ * change almost never; figures and painters will sit above it and change often.
8
+ * Nothing below the line may import anything above it.
9
+ */
10
+ export { clamp, inverseLerp, lerp, remap } from './values/scalar.js';
11
+ export { curveFor, easeIn, easeOut, linear, smoothstep } from './values/ease.js';
12
+ export { vec2 } from './values/vec2.js';
13
+ export { vec3 } from './values/vec3.js';
14
+ export { mat3 } from './values/mat3.js';
15
+ export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
16
+ export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, straight, transformPath } from './figure/path.js';
17
+ export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
18
+ export { flatten, group, shape, text } from './figure/node.js';
19
+ export { fadeIn, fadeOut, fadeTo, draw, morph, moveBy } from './figure/animation.js';
20
+ export { Timeline } from './figure/timeline.js';
21
+ export { trimPath } from './figure/trim.js';
22
+ export { alignPaths, lerpPath } from './figure/morph.js';
23
+ export { at, durationOf, loops, sameMarks } from './figure/figure.js';
24
+ export { pathData, paintSvg, svgElements, svgMarkup } from './paint/svg.js';
25
+ export { paintCanvas } from './paint/canvas.js';
26
+ export { arrow, callout, dot } from './figure/annotate.js';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Marks painted onto a two-dimensional canvas, which is what a recording is.
3
+ *
4
+ * An encoder takes one surface, so a recorded frame cannot be the page's SVG. It
5
+ * is painted again from the same marks rather than rasterised from the page,
6
+ * which is why a test holds the two painters to emitting the same geometry and
7
+ * the same style for every mark.
8
+ *
9
+ * The view is baked into the coordinates here exactly as it is in the SVG
10
+ * painter, rather than set on the context. Setting it would flip the text, since
11
+ * the view turns the y axis over.
12
+ */
13
+ import { type Mat3 } from '../values/mat3.js';
14
+ import type { Mark } from '../figure/mark.js';
15
+ /**
16
+ * Only what a painter uses from a canvas context, named here rather than taken
17
+ * from the DOM types, so this package declares no browser library and a test can
18
+ * hand in a stand-in. A real `CanvasRenderingContext2D` satisfies it.
19
+ *
20
+ * The two style properties are `unknown` because a real context also accepts a
21
+ * gradient and a pattern there, and a narrower type here would refuse the very
22
+ * thing this is meant to be handed.
23
+ */
24
+ export interface CanvasLike {
25
+ save(): void;
26
+ restore(): void;
27
+ beginPath(): void;
28
+ moveTo(x: number, y: number): void;
29
+ bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): void;
30
+ closePath(): void;
31
+ fill(rule?: 'nonzero' | 'evenodd'): void;
32
+ stroke(): void;
33
+ fillText(text: string, x: number, y: number): void;
34
+ setLineDash(segments: number[]): void;
35
+ globalAlpha: number;
36
+ fillStyle: unknown;
37
+ strokeStyle: unknown;
38
+ lineWidth: number;
39
+ lineCap: 'butt' | 'round' | 'square';
40
+ lineJoin: 'miter' | 'round' | 'bevel';
41
+ lineDashOffset: number;
42
+ font: string;
43
+ textAlign: 'start' | 'end' | 'left' | 'right' | 'center';
44
+ textBaseline: 'alphabetic' | 'top' | 'hanging' | 'middle' | 'ideographic' | 'bottom';
45
+ }
46
+ /**
47
+ * Every mark painted, in order.
48
+ *
49
+ * Each one is wrapped in a save and a restore, so a mark that sets an opacity or
50
+ * a dash cannot leak it into the mark after it. A frame drawn on a context that
51
+ * has been used before therefore looks the same as one drawn on a fresh context.
52
+ */
53
+ export declare function paintCanvas(context: CanvasLike, marks: readonly Mark[], view: Mat3): void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Marks painted onto a two-dimensional canvas, which is what a recording is.
3
+ *
4
+ * An encoder takes one surface, so a recorded frame cannot be the page's SVG. It
5
+ * is painted again from the same marks rather than rasterised from the page,
6
+ * which is why a test holds the two painters to emitting the same geometry and
7
+ * the same style for every mark.
8
+ *
9
+ * The view is baked into the coordinates here exactly as it is in the SVG
10
+ * painter, rather than set on the context. Setting it would flip the text, since
11
+ * the view turns the y axis over.
12
+ */
13
+ import { mat3 } from '../values/mat3.js';
14
+ /** SVG says middle where a canvas says center, and the two mean the same place. */
15
+ const ALIGNMENT = { start: 'start', middle: 'center', end: 'end' };
16
+ function tracePath(context, path, view) {
17
+ context.beginPath();
18
+ for (const subpath of path) {
19
+ const start = mat3.transformPoint(view, subpath.start);
20
+ context.moveTo(start.x, start.y);
21
+ for (const curve of subpath.curves) {
22
+ const c1 = mat3.transformPoint(view, curve.control1);
23
+ const c2 = mat3.transformPoint(view, curve.control2);
24
+ const to = mat3.transformPoint(view, curve.to);
25
+ context.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, to.x, to.y);
26
+ }
27
+ if (subpath.closed)
28
+ context.closePath();
29
+ }
30
+ }
31
+ function paintPath(context, mark, view, scale) {
32
+ tracePath(context, mark.path, view);
33
+ if (mark.fill) {
34
+ context.fillStyle = mark.fill.colour;
35
+ context.fill(mark.fill.rule ?? 'nonzero');
36
+ }
37
+ if (mark.stroke) {
38
+ context.strokeStyle = mark.stroke.colour;
39
+ context.lineWidth = mark.stroke.width * scale;
40
+ context.lineCap = mark.stroke.cap ?? 'butt';
41
+ context.lineJoin = mark.stroke.join ?? 'miter';
42
+ // Set every time rather than only when a mark asks for it, because a context
43
+ // holds the last dash it was given and the next mark would inherit it.
44
+ context.setLineDash(mark.stroke.dash ? mark.stroke.dash.map((run) => run * scale) : []);
45
+ context.lineDashOffset = (mark.stroke.dashOffset ?? 0) * scale;
46
+ context.stroke();
47
+ }
48
+ }
49
+ function paintText(context, mark, view, scale) {
50
+ const at = mat3.transformPoint(view, mark.at);
51
+ const weight = mark.weight === undefined ? '' : `${mark.weight} `;
52
+ context.font = `${weight}${mark.size * scale}px ${mark.family}`;
53
+ context.textAlign = ALIGNMENT[mark.align ?? 'start'];
54
+ context.textBaseline = mark.baseline ?? 'alphabetic';
55
+ context.fillStyle = mark.fill.colour;
56
+ context.fillText(mark.text, at.x, at.y);
57
+ }
58
+ /**
59
+ * Every mark painted, in order.
60
+ *
61
+ * Each one is wrapped in a save and a restore, so a mark that sets an opacity or
62
+ * a dash cannot leak it into the mark after it. A frame drawn on a context that
63
+ * has been used before therefore looks the same as one drawn on a fresh context.
64
+ */
65
+ export function paintCanvas(context, marks, view) {
66
+ const scale = mat3.scaleFactor(view);
67
+ for (const mark of marks) {
68
+ context.save();
69
+ context.globalAlpha = mark.opacity ?? 1;
70
+ if (mark.kind === 'path')
71
+ paintPath(context, mark, view, scale);
72
+ else
73
+ paintText(context, mark, view, scale);
74
+ context.restore();
75
+ }
76
+ }
@@ -0,0 +1 @@
1
+ export declare function short(value: number): string;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How a coordinate is written out.
3
+ *
4
+ * Rounding is not a nicety here. Both painters have to agree mark for mark, and
5
+ * two doubles that differ in their last bit would read as a difference where a
6
+ * reader could never see one. A thousandth of a pixel is finer than anything a
7
+ * screen or an encoder can hold.
8
+ */
9
+ const PLACES = 3;
10
+ export function short(value) {
11
+ // A rounded value that lands on an integer keeps no decimal point, and a
12
+ // negative zero is written as zero, because otherwise the same coordinate
13
+ // reached two ways would compare as two strings.
14
+ const rounded = Number(value.toFixed(PLACES));
15
+ return Object.is(rounded, -0) ? '0' : String(rounded);
16
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Marks written as SVG, which is what a figure on a page is.
3
+ *
4
+ * SVG is the painter for the page and not for a recording, and the reasons are
5
+ * things this project has already paid for. A screenshot sweep paints its mask
6
+ * over a whole canvas and is blind inside it, where SVG is in the document and
7
+ * the sweep reads the real thing. Text is text, so a screen reader gets it. CSS
8
+ * custom properties reach it, so a figure follows a theme with nothing watching.
9
+ * And the markup can be written without a browser, so a still frame ships inside
10
+ * a page before any script runs.
11
+ *
12
+ * The view is baked into the coordinates rather than put on a wrapping group. A
13
+ * group transform would be shorter markup and it flips text: the view turns the
14
+ * y axis over, and a mirrored transform mirrors the letters with it.
15
+ */
16
+ import { type Mat3 } from '../values/mat3.js';
17
+ import type { Mark } from '../figure/mark.js';
18
+ import type { Path } from '../figure/path.js';
19
+ /** One element, described rather than built, so the same description can be
20
+ * written as text or made in a document and the two cannot drift. */
21
+ export interface SvgElement {
22
+ tag: 'path' | 'text';
23
+ attributes: Record<string, string>;
24
+ text?: string;
25
+ }
26
+ /** The `d` attribute: a move to the start, a cubic per segment, and a close
27
+ * where the subpath joins back. */
28
+ export declare function pathData(path: Path, view: Mat3): string;
29
+ /** Every mark described as an element, in the order they are drawn. */
30
+ export declare function svgElements(marks: readonly Mark[], view: Mat3): SvgElement[];
31
+ /**
32
+ * A whole `<svg>` as text, for a page that has not run any script yet.
33
+ *
34
+ * It carries no width or height of its own and only a view box, so the element
35
+ * around it decides how big it is and the picture stays where it was put.
36
+ */
37
+ export declare function svgMarkup(marks: readonly Mark[], view: Mat3, width: number, height: number): string;
38
+ /**
39
+ * Only what a painter needs from a document, named here rather than taken from
40
+ * the DOM types.
41
+ *
42
+ * A real `SVGElement` and a real `Document` both satisfy these already, and
43
+ * writing them out means this package declares no browser library at all: it can
44
+ * be checked and tested without one, and a caller can hand in a stand-in.
45
+ */
46
+ export interface PaintNode {
47
+ setAttribute(name: string, value: string): void;
48
+ textContent: string | null;
49
+ }
50
+ export interface PaintTarget {
51
+ replaceChildren(...nodes: PaintNode[]): void;
52
+ }
53
+ export interface ElementMaker {
54
+ createElementNS(namespace: string, tag: string): PaintNode;
55
+ }
56
+ /**
57
+ * The marks put into an element that is already on the page.
58
+ *
59
+ * Every child is replaced rather than matched up and patched. A figure rebuilds
60
+ * its geometry every frame, so almost every attribute would be rewritten anyway,
61
+ * and matching them up first would cost more than it saved while adding a way for
62
+ * two frames to disagree.
63
+ */
64
+ export declare function paintSvg(into: PaintTarget, marks: readonly Mark[], view: Mat3, maker: ElementMaker): void;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Marks written as SVG, which is what a figure on a page is.
3
+ *
4
+ * SVG is the painter for the page and not for a recording, and the reasons are
5
+ * things this project has already paid for. A screenshot sweep paints its mask
6
+ * over a whole canvas and is blind inside it, where SVG is in the document and
7
+ * the sweep reads the real thing. Text is text, so a screen reader gets it. CSS
8
+ * custom properties reach it, so a figure follows a theme with nothing watching.
9
+ * And the markup can be written without a browser, so a still frame ships inside
10
+ * a page before any script runs.
11
+ *
12
+ * The view is baked into the coordinates rather than put on a wrapping group. A
13
+ * group transform would be shorter markup and it flips text: the view turns the
14
+ * y axis over, and a mirrored transform mirrors the letters with it.
15
+ */
16
+ import { mat3 } from '../values/mat3.js';
17
+ import { short } from './number.js';
18
+ /** The `d` attribute: a move to the start, a cubic per segment, and a close
19
+ * where the subpath joins back. */
20
+ export function pathData(path, view) {
21
+ const parts = [];
22
+ for (const subpath of path) {
23
+ const start = mat3.transformPoint(view, subpath.start);
24
+ parts.push(`M${short(start.x)} ${short(start.y)}`);
25
+ for (const curve of subpath.curves) {
26
+ const c1 = mat3.transformPoint(view, curve.control1);
27
+ const c2 = mat3.transformPoint(view, curve.control2);
28
+ const to = mat3.transformPoint(view, curve.to);
29
+ parts.push(`C${short(c1.x)} ${short(c1.y)} ${short(c2.x)} ${short(c2.y)} ${short(to.x)} ${short(to.y)}`);
30
+ }
31
+ if (subpath.closed)
32
+ parts.push('Z');
33
+ }
34
+ return parts.join('');
35
+ }
36
+ function pathElement(mark, view, scale) {
37
+ const attributes = {
38
+ 'data-mark': mark.id,
39
+ d: pathData(mark.path, view),
40
+ fill: mark.fill ? mark.fill.colour : 'none',
41
+ };
42
+ if (mark.fill?.rule === 'evenodd')
43
+ attributes['fill-rule'] = 'evenodd';
44
+ if (mark.stroke) {
45
+ attributes.stroke = mark.stroke.colour;
46
+ attributes['stroke-width'] = short(mark.stroke.width * scale);
47
+ if (mark.stroke.cap)
48
+ attributes['stroke-linecap'] = mark.stroke.cap;
49
+ if (mark.stroke.join)
50
+ attributes['stroke-linejoin'] = mark.stroke.join;
51
+ if (mark.stroke.dash)
52
+ attributes['stroke-dasharray'] = mark.stroke.dash.map((run) => short(run * scale)).join(' ');
53
+ if (mark.stroke.dashOffset !== undefined)
54
+ attributes['stroke-dashoffset'] = short(mark.stroke.dashOffset * scale);
55
+ }
56
+ if (mark.opacity !== undefined && mark.opacity !== 1)
57
+ attributes.opacity = short(mark.opacity);
58
+ return { tag: 'path', attributes };
59
+ }
60
+ function textElement(mark, view, scale) {
61
+ const at = mat3.transformPoint(view, mark.at);
62
+ const attributes = {
63
+ 'data-mark': mark.id,
64
+ x: short(at.x),
65
+ y: short(at.y),
66
+ 'font-family': mark.family,
67
+ 'font-size': short(mark.size * scale),
68
+ fill: mark.fill.colour,
69
+ };
70
+ if (mark.weight !== undefined)
71
+ attributes['font-weight'] = String(mark.weight);
72
+ if (mark.align)
73
+ attributes['text-anchor'] = mark.align;
74
+ if (mark.baseline)
75
+ attributes['dominant-baseline'] = mark.baseline;
76
+ if (mark.opacity !== undefined && mark.opacity !== 1)
77
+ attributes.opacity = short(mark.opacity);
78
+ return { tag: 'text', attributes, text: mark.text };
79
+ }
80
+ /** Every mark described as an element, in the order they are drawn. */
81
+ export function svgElements(marks, view) {
82
+ const scale = mat3.scaleFactor(view);
83
+ return marks.map((mark) => (mark.kind === 'path' ? pathElement(mark, view, scale) : textElement(mark, view, scale)));
84
+ }
85
+ /** The five characters that would otherwise close a tag or open an entity. */
86
+ function escaped(value) {
87
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
88
+ }
89
+ /**
90
+ * A whole `<svg>` as text, for a page that has not run any script yet.
91
+ *
92
+ * It carries no width or height of its own and only a view box, so the element
93
+ * around it decides how big it is and the picture stays where it was put.
94
+ */
95
+ export function svgMarkup(marks, view, width, height) {
96
+ const body = svgElements(marks, view)
97
+ .map((element) => {
98
+ const attributes = Object.entries(element.attributes)
99
+ .map(([name, value]) => `${name}="${escaped(value)}"`)
100
+ .join(' ');
101
+ if (element.text === undefined)
102
+ return `<${element.tag} ${attributes}/>`;
103
+ return `<${element.tag} ${attributes}>${escaped(element.text)}</${element.tag}>`;
104
+ })
105
+ .join('');
106
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${short(width)} ${short(height)}">${body}</svg>`;
107
+ }
108
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
109
+ /**
110
+ * The marks put into an element that is already on the page.
111
+ *
112
+ * Every child is replaced rather than matched up and patched. A figure rebuilds
113
+ * its geometry every frame, so almost every attribute would be rewritten anyway,
114
+ * and matching them up first would cost more than it saved while adding a way for
115
+ * two frames to disagree.
116
+ */
117
+ export function paintSvg(into, marks, view, maker) {
118
+ const elements = svgElements(marks, view);
119
+ into.replaceChildren(...elements.map((element) => {
120
+ const node = maker.createElementNS(SVG_NAMESPACE, element.tag);
121
+ for (const [name, value] of Object.entries(element.attributes))
122
+ node.setAttribute(name, value);
123
+ if (element.text !== undefined)
124
+ node.textContent = element.text;
125
+ return node;
126
+ }));
127
+ }
@@ -0,0 +1,37 @@
1
+ /** What a key can hold. A boolean is here because a control can be a switch,
2
+ * and a list because a control can be a vector or a colour. */
3
+ export type TrackValue = number | readonly number[] | boolean;
4
+ export interface Key {
5
+ /** Seconds into the clip. A key past the clip's end is never reached. */
6
+ time: number;
7
+ value: TrackValue;
8
+ /** Whether the curve is flat here, which eases the segments either side. */
9
+ smooth?: boolean;
10
+ }
11
+ /** One value's keys, in the order the sampler reads them, which is the order
12
+ * `withKey` keeps them in. */
13
+ export type Track = readonly Key[];
14
+ /** Every track by name, which is whatever the caller keys: a uniform, or a
15
+ * property of a mark. */
16
+ export type Tracks = Record<string, Track>;
17
+ /** Two keys at the same instant are one key, so a time this close counts as the
18
+ * same time and setting a key twice replaces rather than stacks. Half a frame at
19
+ * sixty a second. */
20
+ export declare const SAME_TIME: number;
21
+ /**
22
+ * What a track is worth at a time, or null where it has no keys.
23
+ *
24
+ * Outside the keys the nearest one holds, so a track never invents a value
25
+ * before its first key or carries on past its last.
26
+ */
27
+ export declare function sampleTrack(track: Track, seconds: number): TrackValue | null;
28
+ /** Every track's value at a time, leaving out a track with no keys. */
29
+ export declare function sampleTracks(tracks: Tracks, seconds: number): Record<string, TrackValue>;
30
+ /** The track with one key set, replacing the key at that time where there is
31
+ * one, and in the order the sampler reads. */
32
+ export declare function withKey(track: Track, key: Key): Track;
33
+ /** The track with the key at that time taken out. */
34
+ export declare function withoutKey(track: Track, seconds: number): Track;
35
+ /** The key sitting at a time, which is what a control reads to draw its button
36
+ * as set rather than empty. */
37
+ export declare function keyAt(track: Track, seconds: number): Key | undefined;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A value over the length of a clip, read at a time.
3
+ *
4
+ * Something sets a value at one moment and another value later, and everything
5
+ * that draws asks this file what the value is at a given second. A picture on
6
+ * screen and a picture being recorded used to be two answers to that question in
7
+ * two places, which is how a preview and a recording drift apart.
8
+ *
9
+ * A key is flat where it is marked smooth, so two keys give four curves. That is
10
+ * the whole of the shape, and the rest of this file is holding the keys in the
11
+ * order the sampler reads them.
12
+ */
13
+ import { curveFor } from '../values/ease.js';
14
+ /** Two keys at the same instant are one key, so a time this close counts as the
15
+ * same time and setting a key twice replaces rather than stacks. Half a frame at
16
+ * sixty a second. */
17
+ export const SAME_TIME = 1 / 120;
18
+ /**
19
+ * The value part way between two keys.
20
+ *
21
+ * A pair this cannot walk between holds the earlier value until the later key's
22
+ * own time: a boolean has no half, and two lists of different lengths have no
23
+ * component to pair up.
24
+ */
25
+ function walked(from, to, along) {
26
+ if (typeof from === 'number' && typeof to === 'number')
27
+ return from + (to - from) * along;
28
+ if (Array.isArray(from) && Array.isArray(to) && from.length === to.length) {
29
+ return from.map((part, at) => part + (to[at] - part) * along);
30
+ }
31
+ return from;
32
+ }
33
+ /**
34
+ * What a track is worth at a time, or null where it has no keys.
35
+ *
36
+ * Outside the keys the nearest one holds, so a track never invents a value
37
+ * before its first key or carries on past its last.
38
+ */
39
+ export function sampleTrack(track, seconds) {
40
+ if (track.length === 0)
41
+ return null;
42
+ const first = track[0];
43
+ if (seconds <= first.time)
44
+ return first.value;
45
+ const last = track[track.length - 1];
46
+ if (seconds >= last.time)
47
+ return last.value;
48
+ for (let at = 0; at < track.length - 1; at++) {
49
+ const from = track[at];
50
+ const to = track[at + 1];
51
+ if (seconds > to.time)
52
+ continue;
53
+ const span = to.time - from.time;
54
+ if (span <= 0)
55
+ return to.value;
56
+ const along = curveFor(from.smooth === true, to.smooth === true)((seconds - from.time) / span);
57
+ return walked(from.value, to.value, along);
58
+ }
59
+ return last.value;
60
+ }
61
+ /** Every track's value at a time, leaving out a track with no keys. */
62
+ export function sampleTracks(tracks, seconds) {
63
+ const values = {};
64
+ for (const [name, track] of Object.entries(tracks)) {
65
+ const value = sampleTrack(track, seconds);
66
+ if (value !== null)
67
+ values[name] = value;
68
+ }
69
+ return values;
70
+ }
71
+ /** The track with one key set, replacing the key at that time where there is
72
+ * one, and in the order the sampler reads. */
73
+ export function withKey(track, key) {
74
+ const kept = track.filter((held) => Math.abs(held.time - key.time) > SAME_TIME);
75
+ return [...kept, key].sort((one, two) => one.time - two.time);
76
+ }
77
+ /** The track with the key at that time taken out. */
78
+ export function withoutKey(track, seconds) {
79
+ return track.filter((held) => Math.abs(held.time - seconds) > SAME_TIME);
80
+ }
81
+ /** The key sitting at a time, which is what a control reads to draw its button
82
+ * as set rather than empty. */
83
+ export function keyAt(track, seconds) {
84
+ return track.find((held) => Math.abs(held.time - seconds) <= SAME_TIME);
85
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The four curves a value can travel along between two moments.
3
+ *
4
+ * They are the whole of the shape a track can hold. A key is either flat where
5
+ * it sits or it is not, and two keys give four pairings: straight through,
6
+ * still at the start, still at the end, and still at both.
7
+ *
8
+ * Every one of them takes and returns zero to one, so a caller decides what the
9
+ * value at each end is and this decides only the pace between them.
10
+ */
11
+ /** A curve maps how far through a span the clock is onto how far through the
12
+ * change the value is. */
13
+ export type Curve = (along: number) => number;
14
+ /** No easing at all: the value moves at one rate the whole way. */
15
+ export declare const linear: Curve;
16
+ /** Quadratic ease in: flat at the start, so the value leaves from rest and
17
+ * arrives at speed. */
18
+ export declare const easeIn: Curve;
19
+ /** Quadratic ease out: flat at the end, so the value leaves at speed and
20
+ * settles rather than stopping dead. */
21
+ export declare const easeOut: Curve;
22
+ /** Smoothstep: the cubic that is flat at both ends, Ken Perlin. */
23
+ export declare const smoothstep: Curve;
24
+ /**
25
+ * The curve for a span whose ends are flat or not.
26
+ *
27
+ * This is the only place the four are chosen between, so a track and a figure's
28
+ * timeline pace a change the same way rather than each deciding for itself.
29
+ */
30
+ export declare function curveFor(fromFlat: boolean, toFlat: boolean): Curve;