@altpsyche/maths 0.9.5 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ svgMarkup(at(figure, 0.5), viewMatrix(figure.extent, 'contain', 640, 360), 640,
20
20
 
21
21
  ## Axes and a plotted function
22
22
 
23
- <img src="docs/tangent.svg" width="720" alt="A parabola on a labelled grid, the region under it shaded to a point on the curve, the tangent at that point drawn, and the slope written as a number under the typeset rule it comes from.">
23
+ <img src="docs/tangent.svg" width="720" alt="A parabola on a labelled grid over a field of small blue arrows, the region under it shaded to a point on the curve, the tangent at that point drawn, and the slope written as a number under the typeset rule it comes from.">
24
24
 
25
25
  The picture arrives rather than appearing. The grid fades, the axes draw on, their labels come in one
26
26
  after another, the curve draws, the dot grows out of the origin, and the dot is pointed at where the
@@ -60,7 +60,7 @@ region is the limit of at the left edge, the right edge or the middle of each on
60
60
  lays the tangent along the curve, cut where it leaves the graph. `slopeOf` reads the slope itself,
61
61
  which is what the number in the corner is.
62
62
 
63
- <img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve, the shaded region growing behind it, the typeset rule in the corner changing from a slope of nothing to one that depends on x, and a brace measuring the rise in the last frame.">
63
+ <img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve over the field of slope arrows, the shaded region growing behind it, the typeset rule in the corner changing from a slope of nothing to one that depends on x, and a brace measuring the rise in the last frame.">
64
64
 
65
65
  Four times of one figure, side by side: the picture arrived, the beat at the stationary point, half
66
66
  way up, and the top. A moving picture in a README needs a GIF and this package has no encoder, so the
@@ -241,6 +241,84 @@ The quarters of the turn. The whole turn is left off the strip because it draws
241
241
  nothing draws: this is the first figure here to declare itself a loop, and `loops(figure)` is the gate
242
242
  behind that flag, comparing the marks at the duration against the marks at zero.
243
243
 
244
+ ## A surface in space
245
+
246
+ <img src="docs/surface.svg" width="720" alt="A saddle-shaped surface drawn as a grid of shaded cells, with a flat pane cutting through it at one height and the two branches of the curve where they meet drawn in orange along the surface. Blue arrows across the pane show the way the saddle falls and three green runs of steepest descent are drawn on it. Three axes with their numbers stand behind it and the equation of the surface is typeset in the top left.">
247
+
248
+ A figure's camera is a value the caller holds. `camera3({ eye, target, up, projection })` answers
249
+ where a point in space lands in the figure's own units, how far off it is along the way the camera
250
+ looks, and whether it is in front of the eye at all. `polyline3`, `dot3`, `text3` and `surface3` take
251
+ points in space and hand back the same flat nodes everything else here draws, so `fadeIn` and `draw`
252
+ reach a mark in space with no change to either of them. Nothing in the marks, the tree, the flattening
253
+ or the two painters knows that space exists.
254
+
255
+ `space(name, items, camera)` puts the pieces in the order they are painted, near over far. That is
256
+ the painter's algorithm, and what it cannot do is worth knowing before it is used: two pieces that
257
+ pass through each other have no one order at all. The answer for those is smaller pieces, which is why
258
+ `surfaceCells` cuts a surface into a grid and why the saddle and the pane above are sorted together
259
+ rather than one after the other.
260
+
261
+ `sectionOf` finds the curve where a plane cuts a surface, by marching squares over the grid the
262
+ surface is already drawn from. Every point it finds lies on the plane exactly, because signed distance
263
+ to a plane changes evenly along a straight line. What it does not lie on exactly is the surface: it
264
+ sits on the chord between two samples of it, 4.870e-4 of a unit off at the resolution the demo uses,
265
+ and halving the cell size quarters that.
266
+
267
+ The camera is driven by a track and never by an animation, which is the call the flat demo's walk
268
+ already made: a span's eased fraction and a track's value are unrelated numbers, and a camera on one
269
+ with a surface on the other would be two clocks free to disagree.
270
+
271
+ <img src="docs/surface-strip.svg" width="820" alt="Four frames in two rows, showing the same saddle, pane, field arrows and runs of descent from four points around one orbit of the eye.">
272
+
273
+ The quarters of one orbit. The eye comes back to where it started, which the gate holds by comparing
274
+ the marks at the end of the entrance against the marks one orbit later, mark for mark by name.
275
+
276
+ ## Fields and streamlines
277
+
278
+ `vectorField(name, coords, of, options)` samples a grid over a graph and draws an arrow at each
279
+ sample. A field is a function from a place to a vector, so nothing here stores one. How long an arrow
280
+ is and what colour it is are both the author's, taken from the magnitude of the vector at that
281
+ sample: a field drawn at its true lengths is unreadable the moment two samples differ by a factor of
282
+ ten. The count is fixed by the resolution and never by the field, so a gate can hold it. An arrow's
283
+ length is in figure units, like the width of its shaft, and only its direction comes from the
284
+ mapping of its own vector. The flat demo's own axes count at 1.84 and 0.415 figure units to the graph
285
+ unit, and a length in graph units would draw a level arrow there 4.43 times longer than an upright one
286
+ beside it.
287
+
288
+ The arrows above are the slope field of the curve they sit under, read from the curve itself with
289
+ `slopeOf`. `streamlineOf(of, from, options)` walks Runge-Kutta 4 through a field and hands back the
290
+ points, and the run through the origin of that field never leaves the plotted parabola by more than
291
+ 4.689e-10 of a figure unit. Two answers to one question.
292
+
293
+ The step is a distance rather than a time: the field is read as a direction and its magnitude decides
294
+ nothing about how far a step moves, which keeps the points evenly spaced in a field whose strength
295
+ changes across the picture. It is fixed and never adaptive, because an adaptive step hands back a
296
+ different number of points as the field changes, and one path is walked into another by pairing their
297
+ points. Three rules stop a run and each has a measurement: a seed outside the region comes back as one
298
+ point, a field that is nothing everywhere stops at one point rather than at its cap, and a run leaving
299
+ its region stops at the last point inside it. In the field that turns a point about the origin,
300
+ halving the step divides the error along the curve by 15.1 and then 15.6, which is the fourth order
301
+ the integrator is named for.
302
+
303
+ `arrow3` and `fieldArrows3` do the same in space. An arrow there is measured in the world's own units
304
+ rather than the figure's, since a far arrow drawing shorter than a near one of the same magnitude is
305
+ what says which is far, and its head is a flat triangle at the projected tip so it stays readable
306
+ however steeply the arrow points away. The solid demo runs three streamlines of steepest descent down
307
+ its saddle: each is walked in the plane the surface is drawn over and lifted onto it, so every point
308
+ lies on the surface exactly and the height falls at every step.
309
+
310
+ ## A view that follows
311
+
312
+ `Extent` carries a `centre`, which is where the middle of the frame sits in figure units, and an
313
+ extent may be a function of the shape of the surface and of the time. `viewAt(figure, seconds, width,
314
+ height)` hands a painter its matrix at a time in one call, so a figure whose extent moves cannot be
315
+ asked for its extent at one time and its marks at another.
316
+
317
+ The flat demo's view follows its dot across. The dot stays within 1.2 figure units of the middle of
318
+ the frame, where before it crossed 2.76. `fractionOf` reads the centre off the extent it is handed, so
319
+ the reading and the typeset rule stay where they are on the surface while the grid slides under them:
320
+ they drift 1.14e-15 figure units over the whole walk.
321
+
244
322
  ## The animations
245
323
 
246
324
  `fadeIn`, `fadeOut`, `fadeTo`, `draw`, `morph`, `morphEquation`, `countTo`, `moveBy`, `rotate`,
@@ -256,7 +334,7 @@ moving fast, which on a quarter circle is a 6.9% difference between the longest
256
334
  and on the demo's own walk is 82%.
257
335
 
258
336
  Every picture here is written by `svgMarkup`, which needs no browser, so `npm run demos` regenerates
259
- all six and a test compares the bytes against the committed files.
337
+ all eight and a test compares the bytes against the committed files.
260
338
 
261
339
  ## What it is built on
262
340
 
@@ -29,6 +29,9 @@ export interface ArrowOptions {
29
29
  *
30
30
  * The shaft stops where the head begins rather than running under it, because a
31
31
  * shaft drawn to the point shows through a head that is not fully opaque.
32
+ *
33
+ * The head is never longer than the arrow. A head longer than that puts its own
34
+ * base behind the tail, which draws the shaft pointing back the way it came.
32
35
  */
33
36
  export declare function arrow(name: string, from: Vec2, to: Vec2, options: ArrowOptions): GroupNode;
34
37
  /** A filled disc, which is what marks a place a line is pointing at. */
@@ -15,9 +15,12 @@ import { group, shape, text } from './node.js';
15
15
  *
16
16
  * The shaft stops where the head begins rather than running under it, because a
17
17
  * shaft drawn to the point shows through a head that is not fully opaque.
18
+ *
19
+ * The head is never longer than the arrow. A head longer than that puts its own
20
+ * base behind the tail, which draws the shaft pointing back the way it came.
18
21
  */
19
22
  export function arrow(name, from, to, options) {
20
- const head = options.head ?? options.stroke.width * 4;
23
+ const head = Math.min(options.head ?? options.stroke.width * 4, vec2.distance(from, to));
21
24
  const spread = options.spread ?? 0.6;
22
25
  const along = vec2.normalize(vec2.sub(to, from));
23
26
  const base = vec2.sub(to, vec2.scale(along, head));
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Three number lines in space, with a tick at each of their numbers and the
3
+ * numbers written beside them.
4
+ *
5
+ * A tick reaches along the next axis round, x along y, y along z and z along x,
6
+ * so every tick lies in a plane the axis is part of and no tick needs the camera
7
+ * to decide which way to point. A label is flat text at the projected tick,
8
+ * standing off it in the direction that leads away from the projected origin, so
9
+ * labels fall outside the picture at every pose rather than over it.
10
+ */
11
+ import { type Interval } from '../values/interval.js';
12
+ import { type GroupNode } from './node.js';
13
+ import type { Camera3 } from './camera.js';
14
+ import type { Fill, Stroke } from './mark.js';
15
+ export interface Axes3Options {
16
+ /** The run of each axis in world units, minus one to one unless named. */
17
+ x?: Interval;
18
+ y?: Interval;
19
+ z?: Interval;
20
+ /** The lines and their ticks. */
21
+ stroke: Stroke;
22
+ /** The labels. Nothing is written where this is missing. */
23
+ fill?: Fill;
24
+ /** How big the labels are, in figure units. Nothing is written where this is
25
+ * missing. */
26
+ size?: number;
27
+ /** About how many ticks are wanted on each axis. The step is a round number,
28
+ * so the count that comes back is near this rather than equal to it. */
29
+ ticks?: number;
30
+ /** How far a tick reaches across its axis in world units, half either side. */
31
+ tickLength?: number;
32
+ /** From the projected tick to the label's own anchor, in figure units. */
33
+ gap?: number;
34
+ family?: string;
35
+ weight?: number;
36
+ }
37
+ /**
38
+ * The three axes as a group, one child per axis, each holding its line under
39
+ * `line`, its ticks under `ticks` and its labels under `labels`.
40
+ *
41
+ * Each tick and each label is named after the number it shows rather than by its
42
+ * place in the list, so an animation naming a tick follows that number when the
43
+ * axes are rebuilt over a different range.
44
+ */
45
+ export declare function axes3(name: string, camera: Camera3, options: Axes3Options): GroupNode;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Three number lines in space, with a tick at each of their numbers and the
3
+ * numbers written beside them.
4
+ *
5
+ * A tick reaches along the next axis round, x along y, y along z and z along x,
6
+ * so every tick lies in a plane the axis is part of and no tick needs the camera
7
+ * to decide which way to point. A label is flat text at the projected tick,
8
+ * standing off it in the direction that leads away from the projected origin, so
9
+ * labels fall outside the picture at every pose rather than over it.
10
+ */
11
+ import { interval } from '../values/interval.js';
12
+ import { vec2 } from '../values/vec2.js';
13
+ import { vec3 } from '../values/vec3.js';
14
+ import { group } from './node.js';
15
+ import { ticksOn } from './ticks.js';
16
+ import { polyline3, text3 } from './space.js';
17
+ const ALONG = {
18
+ x: vec3(1, 0, 0),
19
+ y: vec3(0, 1, 0),
20
+ z: vec3(0, 0, 1),
21
+ };
22
+ const ACROSS = { x: ALONG.y, y: ALONG.z, z: ALONG.x };
23
+ /** Which way a label leans away from where the three lines cross, or straight
24
+ * down where the tick lands on that crossing and there is no direction to lean
25
+ * in. */
26
+ function leaning(from, to, gap) {
27
+ const away = vec2.sub(to, from);
28
+ if (vec2.magnitude(away) < 1e-12)
29
+ return vec2(0, -gap);
30
+ return vec2.scale(vec2.normalize(away), gap);
31
+ }
32
+ function oneAxis(which, bounds, camera, options) {
33
+ const along = ALONG[which];
34
+ const across = ACROSS[which];
35
+ const size = options.size ?? 0;
36
+ const tickLength = options.tickLength ?? options.stroke.width * 8;
37
+ const gap = options.gap ?? size * 0.35;
38
+ const { from: low, to: high } = interval.ordered(bounds);
39
+ const at = (value) => vec3.scale(along, value);
40
+ const marked = ticksOn(bounds, options.ticks);
41
+ const half = tickLength / 2;
42
+ const origin = camera.project(vec3.ZERO).at;
43
+ const parts = [polyline3('line', [at(low), at(high)], camera, { stroke: options.stroke })];
44
+ parts.push(group('ticks', marked.map((tick) => polyline3(tick.label, [vec3.sub(at(tick.value), vec3.scale(across, half)), vec3.add(at(tick.value), vec3.scale(across, half))], camera, { stroke: options.stroke }))));
45
+ if (options.fill && size > 0) {
46
+ // The three lines cross at the origin, so only one of them writes the number
47
+ // there and the other two would write it again in the same place.
48
+ const written = marked.filter((tick) => which === 'x' || tick.value !== 0);
49
+ parts.push(group('labels', written.map((tick) => text3(tick.label, at(tick.value), tick.label, size, camera, {
50
+ fill: options.fill,
51
+ family: options.family,
52
+ weight: options.weight,
53
+ align: 'middle',
54
+ baseline: 'middle',
55
+ offset: leaning(origin, camera.project(at(tick.value)).at, gap),
56
+ }))));
57
+ }
58
+ return group(which, parts);
59
+ }
60
+ /**
61
+ * The three axes as a group, one child per axis, each holding its line under
62
+ * `line`, its ticks under `ticks` and its labels under `labels`.
63
+ *
64
+ * Each tick and each label is named after the number it shows rather than by its
65
+ * place in the list, so an animation naming a tick follows that number when the
66
+ * axes are rebuilt over a different range.
67
+ */
68
+ export function axes3(name, camera, options) {
69
+ const span = interval(-1, 1);
70
+ return group(name, [
71
+ oneAxis('x', options.x ?? span, camera, options),
72
+ oneAxis('y', options.y ?? span, camera, options),
73
+ oneAxis('z', options.z ?? span, camera, options),
74
+ ]);
75
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A figure's own camera: where an eye is, what it looks at, and how a point in
3
+ * space becomes a point in the figure's units.
4
+ *
5
+ * A camera is a value the caller holds, the way a `Scale` is, and nothing inside
6
+ * a figure owns one. That is what keeps the seam: `at(figure, seconds)` gives
7
+ * back marks measured in the figure's own units, so a point in space has to
8
+ * become a point in those units before it is a mark at all. A camera inside the
9
+ * flattening would make every animation say whether it acts on the shape in
10
+ * space or on the picture of it, which is a question `fadeIn` should never be
11
+ * asked.
12
+ *
13
+ * So a camera moves by being rebuilt from values a track samples, and never by an
14
+ * animation. A span's eased fraction and a track's value are unrelated numbers,
15
+ * and a camera on one with a surface on the other is two clocks free to disagree.
16
+ */
17
+ import { type Mat4 } from '../values/mat4.js';
18
+ import { type Vec3 } from '../values/vec3.js';
19
+ import type { Vec2 } from '../values/vec2.js';
20
+ /**
21
+ * How a point that the eye has already lined up becomes a point on the page.
22
+ *
23
+ * `place` takes a point in view space, where the eye is at the origin looking
24
+ * down the negative z axis, and gives its place in figure units measured from the
25
+ * middle of the frame. `near` is how close to the eye a point may come before it
26
+ * has no place on the page at all.
27
+ */
28
+ export type Projection = {
29
+ near: number;
30
+ place: (view: Vec3) => Vec2;
31
+ };
32
+ export type OrthographicChoice = {
33
+ /** How many figure units across the frame one world unit becomes. */
34
+ scale?: number;
35
+ };
36
+ /**
37
+ * An eye that sees everything at the size it is, however far off it is.
38
+ *
39
+ * A parallel projection is a multiplication rather than a divide, so there is no
40
+ * matrix here and no clip box: near and far planes are what a divide needs, and
41
+ * this has none. Nothing shrinks with distance, so a point behind the eye lands
42
+ * where the point in front of it that it lines up with lands, and the near plane
43
+ * is at negative infinity to say that nothing is ever cut away.
44
+ */
45
+ export declare function orthographic({ scale }?: OrthographicChoice): Projection;
46
+ export type PerspectiveChoice = {
47
+ /** The angle the frame covers up and down, in radians. */
48
+ fov?: number;
49
+ /** How tall the frame is in figure units, so handing this the extent's own
50
+ * height makes the picture fill the frame. */
51
+ height?: number;
52
+ near?: number;
53
+ far?: number;
54
+ };
55
+ /**
56
+ * An eye that sees things smaller the further off they are.
57
+ *
58
+ * The matrix is built at an aspect of one and only its x and y are read, so the
59
+ * same shrinking factor is used across and up and a circle facing the eye stays a
60
+ * circle at every shape of surface. How wide the frame is comes from the extent,
61
+ * and how deep a point is comes from view space, where it is a distance rather
62
+ * than the squeezed value a projection writes into z.
63
+ */
64
+ export declare function perspective({ fov, height, near, far, }?: PerspectiveChoice): Projection;
65
+ export type Camera3Choice = {
66
+ eye: Vec3;
67
+ target: Vec3;
68
+ up?: Vec3;
69
+ projection?: Projection;
70
+ };
71
+ /** Where a point in space landed, how far off it is, and whether the eye can see
72
+ * it at all. */
73
+ export type Projected = {
74
+ /** The place in the figure's own units, measured from the middle of the frame. */
75
+ at: Vec2;
76
+ /** How far the point is from the eye along the way the camera looks, which is
77
+ * what a depth sort orders by and not the straight-line distance to the eye. */
78
+ depth: number;
79
+ /** Whether the point is further off than the near plane. A point that is not is
80
+ * still given a place, and that place is meaningless. */
81
+ inFront: boolean;
82
+ };
83
+ export type Camera3 = {
84
+ eye: Vec3;
85
+ target: Vec3;
86
+ up: Vec3;
87
+ projection: Projection;
88
+ /** The matrix that lines the world up with the eye, kept so a caller that has
89
+ * many points to place does not rebuild it per point. */
90
+ view: Mat4;
91
+ project: (point: Vec3) => Projected;
92
+ };
93
+ /**
94
+ * An eye at `eye` looking at `target`, with `up` saying which way is up.
95
+ *
96
+ * An `up` lying along the line of sight has no sideways direction in it and gives
97
+ * a camera that places every point at the middle of the frame, which is a pose
98
+ * the caller has to avoid rather than one this can fix.
99
+ */
100
+ export declare function camera3({ eye, target, up, projection, }: Camera3Choice): Camera3;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A figure's own camera: where an eye is, what it looks at, and how a point in
3
+ * space becomes a point in the figure's units.
4
+ *
5
+ * A camera is a value the caller holds, the way a `Scale` is, and nothing inside
6
+ * a figure owns one. That is what keeps the seam: `at(figure, seconds)` gives
7
+ * back marks measured in the figure's own units, so a point in space has to
8
+ * become a point in those units before it is a mark at all. A camera inside the
9
+ * flattening would make every animation say whether it acts on the shape in
10
+ * space or on the picture of it, which is a question `fadeIn` should never be
11
+ * asked.
12
+ *
13
+ * So a camera moves by being rebuilt from values a track samples, and never by an
14
+ * animation. A span's eased fraction and a track's value are unrelated numbers,
15
+ * and a camera on one with a surface on the other is two clocks free to disagree.
16
+ */
17
+ import { mat4 } from '../values/mat4.js';
18
+ import { vec3 } from '../values/vec3.js';
19
+ /**
20
+ * An eye that sees everything at the size it is, however far off it is.
21
+ *
22
+ * A parallel projection is a multiplication rather than a divide, so there is no
23
+ * matrix here and no clip box: near and far planes are what a divide needs, and
24
+ * this has none. Nothing shrinks with distance, so a point behind the eye lands
25
+ * where the point in front of it that it lines up with lands, and the near plane
26
+ * is at negative infinity to say that nothing is ever cut away.
27
+ */
28
+ export function orthographic({ scale = 1 } = {}) {
29
+ return {
30
+ near: -Infinity,
31
+ place: (view) => ({ x: view.x * scale, y: view.y * scale }),
32
+ };
33
+ }
34
+ /**
35
+ * An eye that sees things smaller the further off they are.
36
+ *
37
+ * The matrix is built at an aspect of one and only its x and y are read, so the
38
+ * same shrinking factor is used across and up and a circle facing the eye stays a
39
+ * circle at every shape of surface. How wide the frame is comes from the extent,
40
+ * and how deep a point is comes from view space, where it is a distance rather
41
+ * than the squeezed value a projection writes into z.
42
+ */
43
+ export function perspective({ fov = Math.PI / 4, height = 2, near = 0.01, far = 1000, } = {}) {
44
+ const matrix = mat4.perspective({ fov, aspect: 1, near, far });
45
+ return {
46
+ near,
47
+ place: (view) => {
48
+ const clip = mat4.transformPoint(matrix, view);
49
+ return { x: (clip.x * height) / 2, y: (clip.y * height) / 2 };
50
+ },
51
+ };
52
+ }
53
+ /**
54
+ * An eye at `eye` looking at `target`, with `up` saying which way is up.
55
+ *
56
+ * An `up` lying along the line of sight has no sideways direction in it and gives
57
+ * a camera that places every point at the middle of the frame, which is a pose
58
+ * the caller has to avoid rather than one this can fix.
59
+ */
60
+ export function camera3({ eye, target, up = vec3(0, 1, 0), projection = perspective(), }) {
61
+ const view = mat4.lookAt(eye, target, up);
62
+ return {
63
+ eye,
64
+ target,
65
+ up,
66
+ projection,
67
+ view,
68
+ project: (point) => {
69
+ const seen = mat4.transformPoint(view, point);
70
+ const depth = -seen.z;
71
+ return { at: projection.place(seen), depth, inFront: depth > projection.near };
72
+ },
73
+ };
74
+ }
@@ -10,14 +10,18 @@ import { type Vec2 } from '../values/vec2.js';
10
10
  export interface Extent {
11
11
  width: number;
12
12
  height: number;
13
+ /** Where the middle of the frame sits in the figure's own units, the origin
14
+ * unless named. A figure whose view follows something moves this rather than
15
+ * moving everything it draws. */
16
+ centre?: Vec2;
13
17
  }
14
18
  /** Whether the extent is held inside the surface, leaving margins where the
15
19
  * shapes differ, or fills it and runs off two edges. */
16
20
  export type Fit = 'contain' | 'cover';
17
21
  /** 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;
22
+ * is about to be drawn on and the time it is drawn at. */
23
+ export type ExtentChoice = Extent | ((aspect: number, seconds: number) => Extent);
24
+ export declare function resolveExtent(choice: ExtentChoice, aspect: number, seconds?: number): Extent;
21
25
  /**
22
26
  * An extent per shape, for a figure whose composition does not survive being
23
27
  * reframed.
@@ -7,8 +7,8 @@
7
7
  */
8
8
  import { mat3 } from '../values/mat3.js';
9
9
  import { vec2 } from '../values/vec2.js';
10
- export function resolveExtent(choice, aspect) {
11
- return typeof choice === 'function' ? choice(aspect) : choice;
10
+ export function resolveExtent(choice, aspect, seconds = 0) {
11
+ return typeof choice === 'function' ? choice(aspect, seconds) : choice;
12
12
  }
13
13
  /**
14
14
  * An extent per shape, for a figure whose composition does not survive being
@@ -49,7 +49,8 @@ export function matchingAspect(height = 2) {
49
49
  * is drawing would need the shader's camera, and nothing can read one.
50
50
  */
51
51
  export function fractionOf(extent, across, up) {
52
- return vec2((across - 0.5) * extent.width, (up - 0.5) * extent.height);
52
+ const centre = extent.centre ?? vec2(0, 0);
53
+ return vec2(centre.x + (across - 0.5) * extent.width, centre.y + (up - 0.5) * extent.height);
53
54
  }
54
55
  /**
55
56
  * The one matrix taking figure units onto a surface, with the extent centred.
@@ -63,7 +64,9 @@ export function viewMatrix(extent, fit, surfaceWidth, surfaceHeight) {
63
64
  const byWidth = surfaceWidth / extent.width;
64
65
  const byHeight = surfaceHeight / extent.height;
65
66
  const scale = fit === 'cover' ? Math.max(byWidth, byHeight) : Math.min(byWidth, byHeight);
66
- const centre = mat3.translation(vec2(surfaceWidth / 2, surfaceHeight / 2));
67
+ const middle = mat3.translation(vec2(surfaceWidth / 2, surfaceHeight / 2));
67
68
  const flip = mat3.scaling(vec2(scale, -scale));
68
- return mat3.multiply(centre, flip);
69
+ const seen = extent.centre ?? vec2(0, 0);
70
+ const follow = mat3.translation(vec2(-seen.x, -seen.y));
71
+ return mat3.multiply(mat3.multiply(middle, flip), follow);
69
72
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A field of vectors sampled over a graph and drawn as one arrow per sample.
3
+ *
4
+ * Nothing here stores a field. A field is a function from a place to a vector,
5
+ * so what this adds is where it is read and how what it reads is drawn.
6
+ *
7
+ * How long an arrow is and what colour it is are both the author's, taken from
8
+ * the vector's own magnitude. A field drawn at its true lengths is unreadable
9
+ * the moment two samples differ by a factor of ten, and choosing the scale needs
10
+ * numbers about the picture this package does not have.
11
+ *
12
+ * The count is fixed by the resolution and never by the field, so a gate can
13
+ * hold it. The one sample that draws nothing is the one whose vector is nothing,
14
+ * and the count is what says where that happened.
15
+ */
16
+ import { type Interval } from '../values/interval.js';
17
+ import { type Vec2 } from '../values/vec2.js';
18
+ import { type Coords } from './scale.js';
19
+ import { type GroupNode } from './node.js';
20
+ import type { Colour } from './mark.js';
21
+ export interface VectorFieldOptions {
22
+ /** How long an arrow is, in figure units, from the magnitude of the vector at
23
+ * its own sample. */
24
+ lengthOf: (magnitude: number) => number;
25
+ /** What colour an arrow is, from that same magnitude. */
26
+ colourFor: (magnitude: number) => Colour;
27
+ /** How wide a shaft is, in figure units, like any other stroke here. */
28
+ width: number;
29
+ /** How many samples across and up. One number is both. */
30
+ resolution?: number | {
31
+ x: number;
32
+ y: number;
33
+ };
34
+ /** The run of x sampled, which is the whole width of the graph where it is
35
+ * left out. */
36
+ overX?: Interval;
37
+ /** The run of y sampled, which is the whole height of the graph where it is
38
+ * left out. */
39
+ overY?: Interval;
40
+ /** How long a head is, in figure units. Four times the shaft's width unless
41
+ * named, which is what an arrow takes when nothing says. */
42
+ head?: number;
43
+ /** How wide a head is across its base, against its length. */
44
+ spread?: number;
45
+ }
46
+ /**
47
+ * The arrows of a field over a graph, one group per sample, named by its column
48
+ * and row so a stagger can reach them one at a time.
49
+ *
50
+ * A sample sits at the middle of its cell rather than on the grid line. An
51
+ * inclusive grid would put the arrows of the outer row and column half outside
52
+ * the graph, where a painter has to clip them.
53
+ *
54
+ * An arrow points where the mapping of its own vector points, so it lies along
55
+ * the curves drawn over the same coordinates. Taking the direction in graph
56
+ * units instead would tilt every arrow wherever the two axes count at different
57
+ * rates.
58
+ *
59
+ * Its length is in figure units, like the width of its shaft and the length of
60
+ * its head. A length in graph units under two axes counting at different rates
61
+ * would draw the arrows pointing one way several times shorter than the arrows
62
+ * pointing the other, at the same magnitude.
63
+ */
64
+ export declare function vectorField(name: string, coords: Coords, of: (at: Vec2) => Vec2, options: VectorFieldOptions): GroupNode;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A field of vectors sampled over a graph and drawn as one arrow per sample.
3
+ *
4
+ * Nothing here stores a field. A field is a function from a place to a vector,
5
+ * so what this adds is where it is read and how what it reads is drawn.
6
+ *
7
+ * How long an arrow is and what colour it is are both the author's, taken from
8
+ * the vector's own magnitude. A field drawn at its true lengths is unreadable
9
+ * the moment two samples differ by a factor of ten, and choosing the scale needs
10
+ * numbers about the picture this package does not have.
11
+ *
12
+ * The count is fixed by the resolution and never by the field, so a gate can
13
+ * hold it. The one sample that draws nothing is the one whose vector is nothing,
14
+ * and the count is what says where that happened.
15
+ */
16
+ import { interval } from '../values/interval.js';
17
+ import { vec2 } from '../values/vec2.js';
18
+ import { pointOf } from './scale.js';
19
+ import { group } from './node.js';
20
+ import { arrow } from './annotate.js';
21
+ function stepsOf(resolution) {
22
+ return typeof resolution === 'number' ? { x: resolution, y: resolution } : resolution;
23
+ }
24
+ /**
25
+ * The arrows of a field over a graph, one group per sample, named by its column
26
+ * and row so a stagger can reach them one at a time.
27
+ *
28
+ * A sample sits at the middle of its cell rather than on the grid line. An
29
+ * inclusive grid would put the arrows of the outer row and column half outside
30
+ * the graph, where a painter has to clip them.
31
+ *
32
+ * An arrow points where the mapping of its own vector points, so it lies along
33
+ * the curves drawn over the same coordinates. Taking the direction in graph
34
+ * units instead would tilt every arrow wherever the two axes count at different
35
+ * rates.
36
+ *
37
+ * Its length is in figure units, like the width of its shaft and the length of
38
+ * its head. A length in graph units under two axes counting at different rates
39
+ * would draw the arrows pointing one way several times shorter than the arrows
40
+ * pointing the other, at the same magnitude.
41
+ */
42
+ export function vectorField(name, coords, of, options) {
43
+ const steps = stepsOf(options.resolution ?? 12);
44
+ const overX = interval.ordered(options.overX ?? coords.x.graph);
45
+ const overY = interval.ordered(options.overY ?? coords.y.graph);
46
+ const children = [];
47
+ for (let column = 0; column < steps.x; column += 1) {
48
+ for (let row = 0; row < steps.y; row += 1) {
49
+ const x = interval.at(overX, (column + 0.5) / steps.x);
50
+ const y = interval.at(overY, (row + 0.5) / steps.y);
51
+ const vector = of(vec2(x, y));
52
+ const magnitude = Math.hypot(vector.x, vector.y);
53
+ const length = options.lengthOf(magnitude);
54
+ if (!(magnitude > 0) || !Number.isFinite(length) || !(length > 0))
55
+ continue;
56
+ const from = pointOf(coords, x, y);
57
+ const along = vec2.sub(pointOf(coords, x + vector.x, y + vector.y), from);
58
+ if (!(vec2.magnitude(along) > 0))
59
+ continue;
60
+ const to = vec2.add(from, vec2.scale(vec2.normalize(along), length));
61
+ children.push(arrow(`${column}-${row}`, from, to, {
62
+ stroke: { colour: options.colourFor(magnitude), width: options.width },
63
+ head: options.head,
64
+ spread: options.spread,
65
+ }));
66
+ }
67
+ }
68
+ return group(name, children);
69
+ }
@@ -12,7 +12,8 @@
12
12
  import { type TrackValue, type Tracks } from '../timing/track.js';
13
13
  import { type Node } from './node.js';
14
14
  import { Timeline } from './timeline.js';
15
- import type { ExtentChoice, Fit } from './extent.js';
15
+ import { type ExtentChoice, type Fit } from './extent.js';
16
+ import type { Mat3 } from '../values/mat3.js';
16
17
  import type { Mark } from './mark.js';
17
18
  /** The values a scene is rebuilt from, sampled out of the figure's tracks. This
18
19
  * is what lets geometry follow a number rather than only be moved about: a
@@ -44,6 +45,15 @@ export interface Figure {
44
45
  export declare function durationOf(figure: Figure): number;
45
46
  /** The marks a figure shows at a time. */
46
47
  export declare function at(figure: Figure, seconds: number): readonly Mark[];
48
+ /**
49
+ * The matrix a painter needs at a time, in one call.
50
+ *
51
+ * A figure whose extent is a function of the clock has to be asked for its
52
+ * extent at the same time its marks were asked for, and a consumer writing that
53
+ * as two calls has two chances to pass different times. What the painter is
54
+ * handed is the matrix, so the extent and the centring stay in here.
55
+ */
56
+ export declare function viewAt(figure: Figure, seconds: number, width: number, height: number): Mat3;
47
57
  /** Whether a figure declaring itself a loop actually is one, which is the gate
48
58
  * behind that flag. The comparison is by tolerance rather than exactly, because
49
59
  * the sine and cosine a figure is built from are not specified to the last bit