@altpsyche/maths 0.3.0 → 0.4.1
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 +44 -1
- package/dist/figure/axis.d.ts +75 -0
- package/dist/figure/axis.js +133 -0
- package/dist/figure/plot.d.ts +100 -0
- package/dist/figure/plot.js +264 -0
- package/dist/figure/scale.d.ts +32 -0
- package/dist/figure/scale.js +32 -0
- package/dist/figure/ticks.d.ts +46 -0
- package/dist/figure/ticks.js +97 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/values/interval.d.ts +42 -0
- package/dist/values/interval.js +66 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -18,6 +18,49 @@ const figure = {
|
|
|
18
18
|
svgMarkup(at(figure, 0.5), viewMatrix(figure.extent, 'contain', 640, 360), 640, 360);
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
## Axes and a plotted function
|
|
22
|
+
|
|
23
|
+
<img src="docs/tangent.svg" width="720" alt="A parabola on a labelled grid, the region under it shaded to a point on the curve, the tangent at that point drawn, and the slope written as a number.">
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { axes, coordsOf, group, interval, numberPlane, plot, scaleOf, shape } from '@altpsyche/maths';
|
|
27
|
+
|
|
28
|
+
const coords = coordsOf(
|
|
29
|
+
scaleOf(interval(-1, 4), interval(-4.6, 4.6)),
|
|
30
|
+
scaleOf(interval(-1, 9), interval(-2.4, 2.4))
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
group('graph', [
|
|
34
|
+
numberPlane('grid', coords, { stroke: faint, minors: 4 }),
|
|
35
|
+
axes('axes', coords, { stroke: pen, fill: ink, size: 0.26, tip: 0.18 }),
|
|
36
|
+
shape('curve', plot(coords, (x) => x * x), { stroke: drawn }),
|
|
37
|
+
]);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A **scale** is the run of numbers an axis counts through and where that run lands in figure units.
|
|
41
|
+
Two of them are a **coords**, and `pointOf` reads a pair of graph numbers as a point. The steps
|
|
42
|
+
between ticks are one, two or five times a power of ten, because those are the numbers a reader
|
|
43
|
+
adds up in their head.
|
|
44
|
+
|
|
45
|
+
`plot` samples a function at a fixed count and joins the samples with cubics that leave each one at
|
|
46
|
+
the slope the function has there. It cuts the curve where the curve leaves the graph, so a pole
|
|
47
|
+
breaks in two instead of drawing a line up the picture, and the cut end sits on the edge rather
|
|
48
|
+
than a sample short of it. The curve above is cut at x = 3, where the parabola meets the 9 its y
|
|
49
|
+
axis stops at.
|
|
50
|
+
|
|
51
|
+
`areaUnder` closes the region between a curve and a level line, `riemannBars` draws the bars the
|
|
52
|
+
region is the limit of at the left edge, the right edge or the middle of each one, and `tangentAt`
|
|
53
|
+
lays the tangent along the curve, cut where it leaves the graph. `slopeOf` reads the slope itself,
|
|
54
|
+
which is what the number in the corner is.
|
|
55
|
+
|
|
56
|
+
<img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve and the shaded region growing behind it.">
|
|
57
|
+
|
|
58
|
+
Four times of one figure, side by side. A moving picture in a README needs a GIF and this package
|
|
59
|
+
has no encoder, so the strip shows the motion in a still.
|
|
60
|
+
|
|
61
|
+
Both pictures are written by `svgMarkup`, which needs no browser, so `npm run demos` regenerates
|
|
62
|
+
them and a test compares the bytes against the committed files.
|
|
63
|
+
|
|
21
64
|
## What it is built on
|
|
22
65
|
|
|
23
66
|
**A figure at a time is data.** `at(figure, seconds)` is the whole public surface, and it is a
|
|
@@ -56,4 +99,4 @@ arcs included, and a command it does not know stops the read rather than being s
|
|
|
56
99
|
along, and a value walked between keys. That half changes almost never. **Figures and painters**
|
|
57
100
|
are above it. Nothing below the line imports anything above it, and a test says so.
|
|
58
101
|
|
|
59
|
-
One door
|
|
102
|
+
One door. MIT.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type GroupNode } from './node.js';
|
|
2
|
+
import { type Coords, type Scale } from './scale.js';
|
|
3
|
+
import type { Fill, Stroke } from './mark.js';
|
|
4
|
+
export interface NumberLineOptions {
|
|
5
|
+
/** The line, its ticks and the outline of its tips. */
|
|
6
|
+
stroke: Stroke;
|
|
7
|
+
/** The labels and the tips. Nothing is written where this is missing. */
|
|
8
|
+
fill?: Fill;
|
|
9
|
+
/** How big the labels are, in figure units. Nothing is written where this is
|
|
10
|
+
* missing. */
|
|
11
|
+
size?: number;
|
|
12
|
+
/** Where the line sits on the other axis, in figure units. */
|
|
13
|
+
at?: number;
|
|
14
|
+
direction?: 'across' | 'up';
|
|
15
|
+
/** About how many ticks are wanted. The step is a round number, so the count
|
|
16
|
+
* that comes back is near this rather than equal to it. */
|
|
17
|
+
ticks?: number;
|
|
18
|
+
/** How far a tick reaches across the line in total, half of it either side. */
|
|
19
|
+
tickLength?: number;
|
|
20
|
+
/** From the end of a tick to the label's own anchor. */
|
|
21
|
+
gap?: number;
|
|
22
|
+
/** How long the head at each end is. Nothing is drawn where this is zero. */
|
|
23
|
+
tip?: number;
|
|
24
|
+
/** How wide a head is across its base, against its length. */
|
|
25
|
+
spread?: number;
|
|
26
|
+
family?: string;
|
|
27
|
+
weight?: number;
|
|
28
|
+
/** Leaves the label at zero out, which is what a second axis crossing here
|
|
29
|
+
* wants, since both would otherwise write the same number in the same place. */
|
|
30
|
+
skipZero?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One axis as a group: the line, the ticks under `ticks`, the labels under
|
|
34
|
+
* `labels`, and the tips under `tips`.
|
|
35
|
+
*
|
|
36
|
+
* Each tick and each label is named after the number it shows rather than by its
|
|
37
|
+
* position in the list. An animation naming a tick then follows that number when
|
|
38
|
+
* the axis is rebuilt with a different range, where a position would quietly
|
|
39
|
+
* follow whichever tick had moved into the slot.
|
|
40
|
+
*/
|
|
41
|
+
export declare function numberLine(name: string, scale: Scale, options: NumberLineOptions): GroupNode;
|
|
42
|
+
/** Everything a pair of axes hands to each of its two lines. A figure wanting
|
|
43
|
+
* the two to differ builds them as two number lines instead, which is what that
|
|
44
|
+
* call is exported for. */
|
|
45
|
+
export type AxesOptions = Omit<NumberLineOptions, 'at' | 'direction' | 'skipZero'>;
|
|
46
|
+
/**
|
|
47
|
+
* Two number lines under one group, named `x` and `y`, each crossing the other
|
|
48
|
+
* at that other's zero.
|
|
49
|
+
*
|
|
50
|
+
* Where zero is outside an interval the line sits at the near edge of it instead.
|
|
51
|
+
* An axis drawn at a zero the graph never reaches is an axis off the picture, and
|
|
52
|
+
* a reader is left with labels along an edge that has no line on it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function axes(name: string, coords: Coords, options: AxesOptions): GroupNode;
|
|
55
|
+
export interface NumberPlaneOptions {
|
|
56
|
+
/** The lines standing on the ticks. */
|
|
57
|
+
stroke: Stroke;
|
|
58
|
+
/** How many gaps each step is divided into, so one less than this many lines
|
|
59
|
+
* sit between one tick and the next. Nothing extra is drawn below two. */
|
|
60
|
+
minors?: number;
|
|
61
|
+
/** How much of the stroke a minor line is drawn with, since a grid a reader
|
|
62
|
+
* notices is a grid competing with the curve on top of it. */
|
|
63
|
+
minorOpacity?: number;
|
|
64
|
+
ticks?: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The grid behind a graph: a line standing on each tick of both axes, and
|
|
68
|
+
* fainter lines dividing the gaps between them.
|
|
69
|
+
*
|
|
70
|
+
* The minor lines are drawn first and the major ones over them, so a major line
|
|
71
|
+
* a minor one lands on is the one a reader sees. The stroke is handed down from
|
|
72
|
+
* the group rather than set on each line, which is what lets the whole grid fade
|
|
73
|
+
* as one thing.
|
|
74
|
+
*/
|
|
75
|
+
export declare function numberPlane(name: string, coords: Coords, options: NumberPlaneOptions): GroupNode;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A drawn axis: the line, a tick at each of its numbers, and the numbers
|
|
3
|
+
* written beside them.
|
|
4
|
+
*
|
|
5
|
+
* The line takes a direction rather than being rotated into place. A horizontal
|
|
6
|
+
* axis turned on its side would turn its labels with it, and a reader cannot
|
|
7
|
+
* read those, so the two directions place their labels differently and both
|
|
8
|
+
* write them upright.
|
|
9
|
+
*/
|
|
10
|
+
import { interval } from '../values/interval.js';
|
|
11
|
+
import { vec2 } from '../values/vec2.js';
|
|
12
|
+
import { line, polygon } from './path.js';
|
|
13
|
+
import { group, shape, text } from './node.js';
|
|
14
|
+
import { multiplesOn, tickStep, ticksOn } from './ticks.js';
|
|
15
|
+
import { scaled } from './scale.js';
|
|
16
|
+
/** A head pointing along the line, apex at the end and base back along it. */
|
|
17
|
+
function head(apex, back, spread) {
|
|
18
|
+
const along = vec2.normalize(vec2.sub(apex, back));
|
|
19
|
+
const across = vec2.scale(vec2.perpendicular(along), (vec2.distance(apex, back) * spread) / 2);
|
|
20
|
+
return polygon([apex, vec2.add(back, across), vec2.sub(back, across)]);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One axis as a group: the line, the ticks under `ticks`, the labels under
|
|
24
|
+
* `labels`, and the tips under `tips`.
|
|
25
|
+
*
|
|
26
|
+
* Each tick and each label is named after the number it shows rather than by its
|
|
27
|
+
* position in the list. An animation naming a tick then follows that number when
|
|
28
|
+
* the axis is rebuilt with a different range, where a position would quietly
|
|
29
|
+
* follow whichever tick had moved into the slot.
|
|
30
|
+
*/
|
|
31
|
+
export function numberLine(name, scale, options) {
|
|
32
|
+
const across = (options.direction ?? 'across') === 'across';
|
|
33
|
+
const seat = options.at ?? 0;
|
|
34
|
+
const tip = options.tip ?? 0;
|
|
35
|
+
const tickLength = options.tickLength ?? options.stroke.width * 8;
|
|
36
|
+
const size = options.size ?? 0;
|
|
37
|
+
const gap = options.gap ?? size * 0.35;
|
|
38
|
+
const { from: low, to: high } = interval.ordered(scale.units);
|
|
39
|
+
const at = (along, off) => (across ? vec2(along, seat + off) : vec2(seat + off, along));
|
|
40
|
+
// The line stops where a head begins rather than running under it, because a
|
|
41
|
+
// line drawn to the point shows through a head that is not fully opaque.
|
|
42
|
+
const parts = [shape('line', line(at(low + tip, 0), at(high - tip, 0)), { stroke: options.stroke })];
|
|
43
|
+
if (tip > 0 && options.fill) {
|
|
44
|
+
const spread = options.spread ?? 0.6;
|
|
45
|
+
parts.push(group('tips', [
|
|
46
|
+
shape('low', head(at(low, 0), at(low + tip, 0), spread), { fill: options.fill }),
|
|
47
|
+
shape('high', head(at(high, 0), at(high - tip, 0), spread), { fill: options.fill }),
|
|
48
|
+
]));
|
|
49
|
+
}
|
|
50
|
+
const step = tickStep(scale.graph, options.ticks);
|
|
51
|
+
const marked = ticksOn(scale.graph, options.ticks);
|
|
52
|
+
const half = tickLength / 2;
|
|
53
|
+
parts.push(group('ticks', marked.map((tick) => {
|
|
54
|
+
const along = interval.remap(tick.value, scale.graph, scale.units);
|
|
55
|
+
return shape(tick.label, line(at(along, -half), at(along, half)), { stroke: options.stroke });
|
|
56
|
+
})));
|
|
57
|
+
if (options.fill && size > 0) {
|
|
58
|
+
// Placed by an anchor and an alignment and never by how wide the text is,
|
|
59
|
+
// so a long label moves nothing else in the figure.
|
|
60
|
+
const align = across ? 'middle' : 'end';
|
|
61
|
+
const baseline = across ? 'hanging' : 'middle';
|
|
62
|
+
const written = marked.filter((tick) => !(options.skipZero && tick.value === 0));
|
|
63
|
+
parts.push(group('labels', written.map((tick) => {
|
|
64
|
+
const along = interval.remap(tick.value, scale.graph, scale.units);
|
|
65
|
+
return text(tick.label, at(along, -(half + gap)), tick.label, size, {
|
|
66
|
+
fill: options.fill,
|
|
67
|
+
family: options.family,
|
|
68
|
+
weight: options.weight,
|
|
69
|
+
align,
|
|
70
|
+
baseline,
|
|
71
|
+
});
|
|
72
|
+
})));
|
|
73
|
+
}
|
|
74
|
+
return group(name, parts);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Two number lines under one group, named `x` and `y`, each crossing the other
|
|
78
|
+
* at that other's zero.
|
|
79
|
+
*
|
|
80
|
+
* Where zero is outside an interval the line sits at the near edge of it instead.
|
|
81
|
+
* An axis drawn at a zero the graph never reaches is an axis off the picture, and
|
|
82
|
+
* a reader is left with labels along an edge that has no line on it.
|
|
83
|
+
*/
|
|
84
|
+
export function axes(name, coords, options) {
|
|
85
|
+
const holdsOrigin = interval.holds(coords.x.graph, 0) && interval.holds(coords.y.graph, 0);
|
|
86
|
+
const seat = (scale) => scaled(scale, interval.clampTo(scale.graph, 0));
|
|
87
|
+
return group(name, [
|
|
88
|
+
numberLine('x', coords.x, { ...options, at: seat(coords.y), direction: 'across' }),
|
|
89
|
+
numberLine('y', coords.y, { ...options, at: seat(coords.x), direction: 'up', skipZero: holdsOrigin }),
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
/** Whether a value is a whole number of steps from zero, which is what tells a
|
|
93
|
+
* minor line it is standing where a major one already is. */
|
|
94
|
+
function onStep(value, step) {
|
|
95
|
+
return Math.abs(value / step - Math.round(value / step)) < 1e-9;
|
|
96
|
+
}
|
|
97
|
+
/** Lines of constant x reaching the full height, and of constant y reaching the
|
|
98
|
+
* full width, at every multiple of the step. */
|
|
99
|
+
function gridLines(coords, step, along, skipping) {
|
|
100
|
+
const scale = along === 'x' ? coords.x : coords.y;
|
|
101
|
+
const other = interval.ordered(along === 'x' ? coords.y.units : coords.x.units);
|
|
102
|
+
const ends = (at) => along === 'x' ? [vec2(at, other.from), vec2(at, other.to)] : [vec2(other.from, at), vec2(other.to, at)];
|
|
103
|
+
return multiplesOn(scale.graph, step)
|
|
104
|
+
.filter((value) => !(skipping && onStep(value, skipping)))
|
|
105
|
+
.map((value) => {
|
|
106
|
+
const [from, to] = ends(scaled(scale, value));
|
|
107
|
+
return shape(String(value), line(from, to), {});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The grid behind a graph: a line standing on each tick of both axes, and
|
|
112
|
+
* fainter lines dividing the gaps between them.
|
|
113
|
+
*
|
|
114
|
+
* The minor lines are drawn first and the major ones over them, so a major line
|
|
115
|
+
* a minor one lands on is the one a reader sees. The stroke is handed down from
|
|
116
|
+
* the group rather than set on each line, which is what lets the whole grid fade
|
|
117
|
+
* as one thing.
|
|
118
|
+
*/
|
|
119
|
+
export function numberPlane(name, coords, options) {
|
|
120
|
+
const step = { x: tickStep(coords.x.graph, options.ticks), y: tickStep(coords.y.graph, options.ticks) };
|
|
121
|
+
const minors = Math.max(1, Math.round(options.minors ?? 1));
|
|
122
|
+
const parts = [];
|
|
123
|
+
if (minors > 1 && step.x > 0 && step.y > 0) {
|
|
124
|
+
parts.push(group('minors', [
|
|
125
|
+
group('x', gridLines(coords, step.x / minors, 'x', step.x)),
|
|
126
|
+
group('y', gridLines(coords, step.y / minors, 'y', step.y)),
|
|
127
|
+
], { style: { stroke: options.stroke, opacity: options.minorOpacity ?? 0.4 } }));
|
|
128
|
+
}
|
|
129
|
+
parts.push(group('majors', [group('x', gridLines(coords, step.x, 'x')), group('y', gridLines(coords, step.y, 'y'))], {
|
|
130
|
+
style: { stroke: options.stroke },
|
|
131
|
+
}));
|
|
132
|
+
return group(name, parts);
|
|
133
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A function of one number drawn as a curve.
|
|
3
|
+
*
|
|
4
|
+
* The function is sampled at a fixed number of places and the samples are joined
|
|
5
|
+
* by cubics that leave each one at the slope the function has there. Joining them
|
|
6
|
+
* by straight lines instead is what makes a plotted sine look faceted, and the
|
|
7
|
+
* slope costs nothing to work out because it comes from samples already taken.
|
|
8
|
+
*
|
|
9
|
+
* A curve is cut where it leaves the graph. A pole otherwise draws as a line
|
|
10
|
+
* straight up the picture, and the coordinates on either side of it run to
|
|
11
|
+
* numbers a painter has nowhere to put.
|
|
12
|
+
*
|
|
13
|
+
* The count is fixed and the curve is never subdivided by how much it bends.
|
|
14
|
+
* Subdivision hands back a different number of points as the curve changes, and
|
|
15
|
+
* one path is walked into another by pairing their points, so a curve that
|
|
16
|
+
* resamples itself between frames could not be morphed into anything.
|
|
17
|
+
*/
|
|
18
|
+
import { type Interval } from '../values/interval.js';
|
|
19
|
+
import { type Coords } from './scale.js';
|
|
20
|
+
import { type GroupNode } from './node.js';
|
|
21
|
+
import type { Fill, Stroke } from './mark.js';
|
|
22
|
+
import { type Path } from './path.js';
|
|
23
|
+
export interface PlotOptions {
|
|
24
|
+
/** How many pieces the curve is cut into. */
|
|
25
|
+
samples?: number;
|
|
26
|
+
/** The run of x the curve is drawn over, which is the whole width of the graph
|
|
27
|
+
* where it is left out. */
|
|
28
|
+
over?: Interval;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The curve of a function over a run of x, in the figure's own units, as one
|
|
32
|
+
* subpath per stretch of it that is on the graph.
|
|
33
|
+
*
|
|
34
|
+
* Each piece is a Hermite cubic written as a Bézier: the controls sit a third of
|
|
35
|
+
* the way along in x and carry the sample's own slope, which is the placement
|
|
36
|
+
* that makes the cubic pass through both samples at both slopes.
|
|
37
|
+
*/
|
|
38
|
+
export declare function plot(coords: Coords, of: (x: number) => number, options?: PlotOptions): Path;
|
|
39
|
+
export interface AreaOptions extends PlotOptions {
|
|
40
|
+
/** The height the region is measured down to, which is the axis itself where
|
|
41
|
+
* it is left out. A height off the graph sits at the near edge instead. */
|
|
42
|
+
baseline?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The region between a curve and a level line, closed, as one subpath per
|
|
46
|
+
* stretch of the curve that is on the graph.
|
|
47
|
+
*
|
|
48
|
+
* The curve is the same one `plot` draws over the same run, so the top of the
|
|
49
|
+
* region and the curve laid over it are the same geometry rather than two
|
|
50
|
+
* samplings that agree to within a sample.
|
|
51
|
+
*/
|
|
52
|
+
export declare function areaUnder(coords: Coords, of: (x: number) => number, over: Interval, options?: AreaOptions): Path;
|
|
53
|
+
export interface BarsOptions {
|
|
54
|
+
fill?: Fill;
|
|
55
|
+
stroke?: Stroke;
|
|
56
|
+
/** How many bars the run is cut into. */
|
|
57
|
+
bars?: number;
|
|
58
|
+
/** The run of x the bars cover, which is the whole width of the graph where it
|
|
59
|
+
* is left out. */
|
|
60
|
+
over?: Interval;
|
|
61
|
+
/** Where in each bar its height is read: at the left edge, the right edge or
|
|
62
|
+
* the middle. The three are what a reader is shown to see that the first is
|
|
63
|
+
* always short and the second always over. */
|
|
64
|
+
height?: 'left' | 'right' | 'middle';
|
|
65
|
+
/** The level the bars stand on. */
|
|
66
|
+
baseline?: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The bars under a curve, each one named by its place in the run so a stagger
|
|
70
|
+
* can reach them one at a time.
|
|
71
|
+
*
|
|
72
|
+
* A bar whose top is off the graph is cut at the edge, and a bar whose height is
|
|
73
|
+
* not a number is left out. The style sits on the group rather than on each bar,
|
|
74
|
+
* which is what lets the whole run fade as one thing.
|
|
75
|
+
*/
|
|
76
|
+
export declare function riemannBars(name: string, coords: Coords, of: (x: number) => number, options?: BarsOptions): GroupNode;
|
|
77
|
+
/**
|
|
78
|
+
* The slope of a function at a point, from the central difference either side of
|
|
79
|
+
* it.
|
|
80
|
+
*
|
|
81
|
+
* The difference either side rather than one side is what makes the error fall
|
|
82
|
+
* as the step squared instead of the step, and it costs the same two calls.
|
|
83
|
+
*/
|
|
84
|
+
export declare function slopeOf(of: (x: number) => number, x: number, step?: number): number;
|
|
85
|
+
export interface TangentOptions {
|
|
86
|
+
/** How far the line reaches either side of the point, in graph units. */
|
|
87
|
+
reach?: number;
|
|
88
|
+
/** The step the slope is read over, for a function whose own scale asks for a
|
|
89
|
+
* different one. */
|
|
90
|
+
step?: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The tangent to a curve at a point, as a straight line held inside the graph.
|
|
94
|
+
*
|
|
95
|
+
* The line is cut where it leaves the graph rather than sampled and broken like
|
|
96
|
+
* a curve, because a straight line crosses each edge once and the crossing is
|
|
97
|
+
* arithmetic rather than a search. A tangent at a steep place otherwise runs the
|
|
98
|
+
* width of the picture and out of it.
|
|
99
|
+
*/
|
|
100
|
+
export declare function tangentAt(coords: Coords, of: (x: number) => number, x: number, options?: TangentOptions): Path;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A function of one number drawn as a curve.
|
|
3
|
+
*
|
|
4
|
+
* The function is sampled at a fixed number of places and the samples are joined
|
|
5
|
+
* by cubics that leave each one at the slope the function has there. Joining them
|
|
6
|
+
* by straight lines instead is what makes a plotted sine look faceted, and the
|
|
7
|
+
* slope costs nothing to work out because it comes from samples already taken.
|
|
8
|
+
*
|
|
9
|
+
* A curve is cut where it leaves the graph. A pole otherwise draws as a line
|
|
10
|
+
* straight up the picture, and the coordinates on either side of it run to
|
|
11
|
+
* numbers a painter has nowhere to put.
|
|
12
|
+
*
|
|
13
|
+
* The count is fixed and the curve is never subdivided by how much it bends.
|
|
14
|
+
* Subdivision hands back a different number of points as the curve changes, and
|
|
15
|
+
* one path is walked into another by pairing their points, so a curve that
|
|
16
|
+
* resamples itself between frames could not be morphed into anything.
|
|
17
|
+
*/
|
|
18
|
+
import { interval } from '../values/interval.js';
|
|
19
|
+
import { vec2 } from '../values/vec2.js';
|
|
20
|
+
import { pointOf, scaled } from './scale.js';
|
|
21
|
+
import { group, shape } from './node.js';
|
|
22
|
+
import { line, polygon, straight } from './path.js';
|
|
23
|
+
/**
|
|
24
|
+
* How many pieces a curve is cut into when a figure does not say.
|
|
25
|
+
*
|
|
26
|
+
* A sine over two turns at this count leaves the drawn curve within 3.3e-4
|
|
27
|
+
* figure units of the true one, which is under a tenth of a pixel on the largest
|
|
28
|
+
* surface anything here is drawn at.
|
|
29
|
+
*/
|
|
30
|
+
const SAMPLES = 96;
|
|
31
|
+
/**
|
|
32
|
+
* The slope at each sample of one run, from the central difference of its
|
|
33
|
+
* neighbours, which is the tangent a Catmull-Rom spline uses.
|
|
34
|
+
*
|
|
35
|
+
* An end with evenly spaced neighbours takes the three-point one-sided
|
|
36
|
+
* difference, which is second order like the middle and exact for a quadratic.
|
|
37
|
+
* An end that was cut at the edge of the graph is not evenly spaced, so it takes
|
|
38
|
+
* the two-point difference instead: measured on a parabola at 64 samples, the
|
|
39
|
+
* three-point ends leave an uncut curve exact where the two-point ends leave it
|
|
40
|
+
* 3.7e-4 figure units out.
|
|
41
|
+
*/
|
|
42
|
+
function slopes(xs, ys) {
|
|
43
|
+
const last = xs.length - 1;
|
|
44
|
+
if (last < 1)
|
|
45
|
+
return [0];
|
|
46
|
+
const even = (a, b, c) => Math.abs((b - a) - (c - b)) < Math.abs(c - a) * 1e-9;
|
|
47
|
+
const out = [];
|
|
48
|
+
for (let at = 0; at <= last; at++) {
|
|
49
|
+
if (last < 2)
|
|
50
|
+
out.push((ys[last] - ys[0]) / (xs[last] - xs[0]));
|
|
51
|
+
else if (at === 0) {
|
|
52
|
+
out.push(even(xs[0], xs[1], xs[2])
|
|
53
|
+
? (-3 * ys[0] + 4 * ys[1] - ys[2]) / (xs[2] - xs[0])
|
|
54
|
+
: (ys[1] - ys[0]) / (xs[1] - xs[0]));
|
|
55
|
+
}
|
|
56
|
+
else if (at === last) {
|
|
57
|
+
out.push(even(xs[last - 2], xs[last - 1], xs[last])
|
|
58
|
+
? (3 * ys[last] - 4 * ys[last - 1] + ys[last - 2]) / (xs[last] - xs[last - 2])
|
|
59
|
+
: (ys[last] - ys[last - 1]) / (xs[last] - xs[last - 1]));
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
out.push((ys[at + 1] - ys[at - 1]) / (xs[at + 1] - xs[at - 1]));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** How many times the gap either side of the edge is halved when looking for the
|
|
67
|
+
* place the curve crosses it. Twenty-four leaves it within a millionth of one
|
|
68
|
+
* sample's width. */
|
|
69
|
+
const HALVINGS = 24;
|
|
70
|
+
/**
|
|
71
|
+
* The place between a sample on the graph and a sample off it where the curve
|
|
72
|
+
* crosses the edge, by halving the gap between them.
|
|
73
|
+
*
|
|
74
|
+
* The y it hands back is held on the edge rather than taken from the function,
|
|
75
|
+
* so the cut end sits exactly on the boundary instead of a millionth past it.
|
|
76
|
+
*/
|
|
77
|
+
function crossing(of, drawable, bounds, inside, outside) {
|
|
78
|
+
let near = inside;
|
|
79
|
+
let far = outside;
|
|
80
|
+
for (let halving = 0; halving < HALVINGS; halving++) {
|
|
81
|
+
const middle = (near + far) / 2;
|
|
82
|
+
if (drawable(of(middle)))
|
|
83
|
+
near = middle;
|
|
84
|
+
else
|
|
85
|
+
far = middle;
|
|
86
|
+
}
|
|
87
|
+
return { x: near, y: interval.clampTo(bounds, of(near)) };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The curve of a function over a run of x, in the figure's own units, as one
|
|
91
|
+
* subpath per stretch of it that is on the graph.
|
|
92
|
+
*
|
|
93
|
+
* Each piece is a Hermite cubic written as a Bézier: the controls sit a third of
|
|
94
|
+
* the way along in x and carry the sample's own slope, which is the placement
|
|
95
|
+
* that makes the cubic pass through both samples at both slopes.
|
|
96
|
+
*/
|
|
97
|
+
export function plot(coords, of, options = {}) {
|
|
98
|
+
const samples = Math.max(1, Math.round(options.samples ?? SAMPLES));
|
|
99
|
+
const { from, to } = interval.ordered(options.over ?? coords.x.graph);
|
|
100
|
+
if (!(to > from))
|
|
101
|
+
return [];
|
|
102
|
+
const grain = ((to - from) / samples) * 1e-9;
|
|
103
|
+
const drawable = (y) => Number.isFinite(y) && interval.holds(coords.y.graph, y);
|
|
104
|
+
const xs = [];
|
|
105
|
+
const ys = [];
|
|
106
|
+
const on = [];
|
|
107
|
+
for (let at = 0; at <= samples; at++) {
|
|
108
|
+
const x = from + ((to - from) * at) / samples;
|
|
109
|
+
const y = of(x);
|
|
110
|
+
xs.push(x);
|
|
111
|
+
ys.push(y);
|
|
112
|
+
on.push(drawable(y));
|
|
113
|
+
}
|
|
114
|
+
const path = [];
|
|
115
|
+
let at = 0;
|
|
116
|
+
while (at <= samples) {
|
|
117
|
+
if (!on[at]) {
|
|
118
|
+
at++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
let end = at;
|
|
122
|
+
while (end + 1 <= samples && on[end + 1])
|
|
123
|
+
end++;
|
|
124
|
+
const runX = xs.slice(at, end + 1);
|
|
125
|
+
const runY = ys.slice(at, end + 1);
|
|
126
|
+
if (at > 0) {
|
|
127
|
+
const cut = crossing(of, drawable, coords.y.graph, xs[at], xs[at - 1]);
|
|
128
|
+
// A sample sitting exactly on the edge leaves nothing between it and the
|
|
129
|
+
// crossing, and a piece of no width has no slope to leave at.
|
|
130
|
+
if (xs[at] - cut.x > grain) {
|
|
131
|
+
runX.unshift(cut.x);
|
|
132
|
+
runY.unshift(cut.y);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (end < samples) {
|
|
136
|
+
const cut = crossing(of, drawable, coords.y.graph, xs[end], xs[end + 1]);
|
|
137
|
+
if (cut.x - xs[end] > grain) {
|
|
138
|
+
runX.push(cut.x);
|
|
139
|
+
runY.push(cut.y);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (runX.length > 1) {
|
|
143
|
+
const slope = slopes(runX, runY);
|
|
144
|
+
const curves = [];
|
|
145
|
+
for (let piece = 0; piece + 1 < runX.length; piece++) {
|
|
146
|
+
const reach = (runX[piece + 1] - runX[piece]) / 3;
|
|
147
|
+
curves.push({
|
|
148
|
+
control1: pointOf(coords, runX[piece] + reach, runY[piece] + reach * slope[piece]),
|
|
149
|
+
control2: pointOf(coords, runX[piece + 1] - reach, runY[piece + 1] - reach * slope[piece + 1]),
|
|
150
|
+
to: pointOf(coords, runX[piece + 1], runY[piece + 1]),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
path.push({ start: pointOf(coords, runX[0], runY[0]), curves, closed: false });
|
|
154
|
+
}
|
|
155
|
+
at = end + 1;
|
|
156
|
+
}
|
|
157
|
+
return path;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The region between a curve and a level line, closed, as one subpath per
|
|
161
|
+
* stretch of the curve that is on the graph.
|
|
162
|
+
*
|
|
163
|
+
* The curve is the same one `plot` draws over the same run, so the top of the
|
|
164
|
+
* region and the curve laid over it are the same geometry rather than two
|
|
165
|
+
* samplings that agree to within a sample.
|
|
166
|
+
*/
|
|
167
|
+
export function areaUnder(coords, of, over, options = {}) {
|
|
168
|
+
const foot = scaled(coords.y, interval.clampTo(coords.y.graph, options.baseline ?? 0));
|
|
169
|
+
return plot(coords, of, { ...options, over }).map((top) => {
|
|
170
|
+
const last = top.curves.length > 0 ? top.curves[top.curves.length - 1].to : top.start;
|
|
171
|
+
const under = vec2(last.x, foot);
|
|
172
|
+
const back = vec2(top.start.x, foot);
|
|
173
|
+
return {
|
|
174
|
+
start: top.start,
|
|
175
|
+
curves: [...top.curves, straight(last, under), straight(under, back), straight(back, top.start)],
|
|
176
|
+
closed: true,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The bars under a curve, each one named by its place in the run so a stagger
|
|
182
|
+
* can reach them one at a time.
|
|
183
|
+
*
|
|
184
|
+
* A bar whose top is off the graph is cut at the edge, and a bar whose height is
|
|
185
|
+
* not a number is left out. The style sits on the group rather than on each bar,
|
|
186
|
+
* which is what lets the whole run fade as one thing.
|
|
187
|
+
*/
|
|
188
|
+
export function riemannBars(name, coords, of, options = {}) {
|
|
189
|
+
const bars = Math.max(1, Math.round(options.bars ?? 8));
|
|
190
|
+
const { from, to } = interval.ordered(options.over ?? coords.x.graph);
|
|
191
|
+
const foot = interval.clampTo(coords.y.graph, options.baseline ?? 0);
|
|
192
|
+
const read = options.height ?? 'left';
|
|
193
|
+
const children = [];
|
|
194
|
+
for (let bar = 0; bar < bars; bar++) {
|
|
195
|
+
const left = from + ((to - from) * bar) / bars;
|
|
196
|
+
const right = from + ((to - from) * (bar + 1)) / bars;
|
|
197
|
+
const x = read === 'left' ? left : read === 'right' ? right : (left + right) / 2;
|
|
198
|
+
const y = of(x);
|
|
199
|
+
if (!Number.isFinite(y))
|
|
200
|
+
continue;
|
|
201
|
+
const top = interval.clampTo(coords.y.graph, y);
|
|
202
|
+
// Built from its four corners rather than from a corner and a size. A bar
|
|
203
|
+
// whose top is held on the graph's own edge has that edge as an exact
|
|
204
|
+
// number, and adding a height back on to the near corner overshoots it.
|
|
205
|
+
const corner = pointOf(coords, left, foot);
|
|
206
|
+
const far = pointOf(coords, right, top);
|
|
207
|
+
children.push(shape(String(bar), polygon([corner, vec2(far.x, corner.y), far, vec2(corner.x, far.y)]), {}));
|
|
208
|
+
}
|
|
209
|
+
return group(name, children, { style: { fill: options.fill, stroke: options.stroke } });
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The step the central difference is taken over, against the size of x.
|
|
213
|
+
*
|
|
214
|
+
* The cube root of the smallest gap between two doubles is the step where the
|
|
215
|
+
* two errors in a central difference are the same size: the formula's own error
|
|
216
|
+
* falls as the step squared and the rounding error rises as one over the step.
|
|
217
|
+
*/
|
|
218
|
+
const STEP = Math.cbrt(Number.EPSILON);
|
|
219
|
+
/**
|
|
220
|
+
* The slope of a function at a point, from the central difference either side of
|
|
221
|
+
* it.
|
|
222
|
+
*
|
|
223
|
+
* The difference either side rather than one side is what makes the error fall
|
|
224
|
+
* as the step squared instead of the step, and it costs the same two calls.
|
|
225
|
+
*/
|
|
226
|
+
export function slopeOf(of, x, step = STEP * Math.max(Math.abs(x), 1)) {
|
|
227
|
+
return (of(x + step) - of(x - step)) / (2 * step);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The tangent to a curve at a point, as a straight line held inside the graph.
|
|
231
|
+
*
|
|
232
|
+
* The line is cut where it leaves the graph rather than sampled and broken like
|
|
233
|
+
* a curve, because a straight line crosses each edge once and the crossing is
|
|
234
|
+
* arithmetic rather than a search. A tangent at a steep place otherwise runs the
|
|
235
|
+
* width of the picture and out of it.
|
|
236
|
+
*/
|
|
237
|
+
export function tangentAt(coords, of, x, options = {}) {
|
|
238
|
+
const reach = options.reach ?? interval.span(coords.x.graph) / 8;
|
|
239
|
+
const height = of(x);
|
|
240
|
+
const slope = slopeOf(of, x, options.step);
|
|
241
|
+
if (!Number.isFinite(height) || !Number.isFinite(slope))
|
|
242
|
+
return [];
|
|
243
|
+
const graphX = interval.ordered(coords.x.graph);
|
|
244
|
+
const graphY = interval.ordered(coords.y.graph);
|
|
245
|
+
let low = Math.max(x - reach, graphX.from);
|
|
246
|
+
let high = Math.min(x + reach, graphX.to);
|
|
247
|
+
if (slope === 0) {
|
|
248
|
+
if (!interval.holds(graphY, height))
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const atY = (y) => x + (y - height) / slope;
|
|
253
|
+
const first = atY(graphY.from);
|
|
254
|
+
const second = atY(graphY.to);
|
|
255
|
+
low = Math.max(low, Math.min(first, second));
|
|
256
|
+
high = Math.min(high, Math.max(first, second));
|
|
257
|
+
}
|
|
258
|
+
if (!(high > low))
|
|
259
|
+
return [];
|
|
260
|
+
// Held on the graph so an end cut at an edge sits on it rather than a rounding
|
|
261
|
+
// error past it.
|
|
262
|
+
const at = (t) => pointOf(coords, t, interval.clampTo(graphY, height + slope * (t - x)));
|
|
263
|
+
return line(at(low), at(high));
|
|
264
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mapping from the numbers on an axis to places in a figure.
|
|
3
|
+
*
|
|
4
|
+
* A scale is two intervals: the run of numbers a graph counts through, and where
|
|
5
|
+
* that run lands in the figure's own units. Two of them together turn a pair of
|
|
6
|
+
* graph numbers into a point.
|
|
7
|
+
*
|
|
8
|
+
* The mapping is a value a caller holds rather than something read back out of a
|
|
9
|
+
* drawn group. Axes draw two lines and a plotted curve needs the mapping, so a
|
|
10
|
+
* curve drawn over axes that were never drawn has to work.
|
|
11
|
+
*/
|
|
12
|
+
import { type Interval } from '../values/interval.js';
|
|
13
|
+
import { type Vec2 } from '../values/vec2.js';
|
|
14
|
+
export interface Scale {
|
|
15
|
+
/** The numbers the axis counts through. */
|
|
16
|
+
readonly graph: Interval;
|
|
17
|
+
/** Where those numbers land, in the figure's own units. */
|
|
18
|
+
readonly units: Interval;
|
|
19
|
+
}
|
|
20
|
+
export declare function scaleOf(graph: Interval, units: Interval): Scale;
|
|
21
|
+
/** A number on the axis, as a place in the figure's own units. */
|
|
22
|
+
export declare function scaled(scale: Scale, value: number): number;
|
|
23
|
+
/** A place in the figure's own units, as a number on the axis, which is what a
|
|
24
|
+
* reader pointing at the picture is asking for. */
|
|
25
|
+
export declare function unscaled(scale: Scale, place: number): number;
|
|
26
|
+
export interface Coords {
|
|
27
|
+
readonly x: Scale;
|
|
28
|
+
readonly y: Scale;
|
|
29
|
+
}
|
|
30
|
+
export declare function coordsOf(x: Scale, y: Scale): Coords;
|
|
31
|
+
/** A pair of graph numbers as a point in the figure's own units. */
|
|
32
|
+
export declare function pointOf(coords: Coords, x: number, y: number): Vec2;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mapping from the numbers on an axis to places in a figure.
|
|
3
|
+
*
|
|
4
|
+
* A scale is two intervals: the run of numbers a graph counts through, and where
|
|
5
|
+
* that run lands in the figure's own units. Two of them together turn a pair of
|
|
6
|
+
* graph numbers into a point.
|
|
7
|
+
*
|
|
8
|
+
* The mapping is a value a caller holds rather than something read back out of a
|
|
9
|
+
* drawn group. Axes draw two lines and a plotted curve needs the mapping, so a
|
|
10
|
+
* curve drawn over axes that were never drawn has to work.
|
|
11
|
+
*/
|
|
12
|
+
import { interval } from '../values/interval.js';
|
|
13
|
+
import { vec2 } from '../values/vec2.js';
|
|
14
|
+
export function scaleOf(graph, units) {
|
|
15
|
+
return { graph, units };
|
|
16
|
+
}
|
|
17
|
+
/** A number on the axis, as a place in the figure's own units. */
|
|
18
|
+
export function scaled(scale, value) {
|
|
19
|
+
return interval.remap(value, scale.graph, scale.units);
|
|
20
|
+
}
|
|
21
|
+
/** A place in the figure's own units, as a number on the axis, which is what a
|
|
22
|
+
* reader pointing at the picture is asking for. */
|
|
23
|
+
export function unscaled(scale, place) {
|
|
24
|
+
return interval.remap(place, scale.units, scale.graph);
|
|
25
|
+
}
|
|
26
|
+
export function coordsOf(x, y) {
|
|
27
|
+
return { x, y };
|
|
28
|
+
}
|
|
29
|
+
/** A pair of graph numbers as a point in the figure's own units. */
|
|
30
|
+
export function pointOf(coords, x, y) {
|
|
31
|
+
return vec2(scaled(coords.x, x), scaled(coords.y, y));
|
|
32
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the marks along an axis go, and what is written under each one.
|
|
3
|
+
*
|
|
4
|
+
* A reader adds up an axis in their head, so the steps between its ticks have to
|
|
5
|
+
* be numbers a head adds: one, two or five times a power of ten. That is the
|
|
6
|
+
* whole of the choice here, and the rest of this file is walking the interval in
|
|
7
|
+
* those steps and writing each one down without floating point noise in it.
|
|
8
|
+
*/
|
|
9
|
+
import { type Interval } from '../values/interval.js';
|
|
10
|
+
export interface Tick {
|
|
11
|
+
/** In the graph's own units, rounded to the decimals its own label shows, so
|
|
12
|
+
* the number drawn and the number placed are the same number. */
|
|
13
|
+
readonly value: number;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The gap between one tick and the next for an interval that wants about this
|
|
18
|
+
* many of them.
|
|
19
|
+
*
|
|
20
|
+
* The published version rounds the whole span before dividing it, which suits a
|
|
21
|
+
* graph free to move its own bounds outward to the next nice number. An axis
|
|
22
|
+
* here is given its bounds and keeps them, so the span is divided as it stands:
|
|
23
|
+
* over eight ranges that turned a worst count error of four ticks into one.
|
|
24
|
+
*/
|
|
25
|
+
export declare function tickStep(bounds: Interval, about?: number): number;
|
|
26
|
+
/**
|
|
27
|
+
* A tick's value written out, with as many decimals as its step needs and no
|
|
28
|
+
* more.
|
|
29
|
+
*
|
|
30
|
+
* A step of a fifth reaches three fifths as 0.6000000000000001, and printing the
|
|
31
|
+
* number as it stands puts that in the picture. The decimal count comes from the
|
|
32
|
+
* step rather than from the value, so every label along one axis is written to
|
|
33
|
+
* the same width.
|
|
34
|
+
*/
|
|
35
|
+
export declare function labelFor(value: number, step: number): string;
|
|
36
|
+
/**
|
|
37
|
+
* Every multiple of the step inside the interval, from its lower bound upward,
|
|
38
|
+
* whichever way round the interval was given.
|
|
39
|
+
*
|
|
40
|
+
* The values are counted as multiples rather than reached by adding the step
|
|
41
|
+
* over and over, because the additions drift and the last one then misses the
|
|
42
|
+
* bound it sits on.
|
|
43
|
+
*/
|
|
44
|
+
export declare function multiplesOn(bounds: Interval, step: number): readonly number[];
|
|
45
|
+
/** Every tick inside the interval, with the number each one shows. */
|
|
46
|
+
export declare function ticksOn(bounds: Interval, about?: number): readonly Tick[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the marks along an axis go, and what is written under each one.
|
|
3
|
+
*
|
|
4
|
+
* A reader adds up an axis in their head, so the steps between its ticks have to
|
|
5
|
+
* be numbers a head adds: one, two or five times a power of ten. That is the
|
|
6
|
+
* whole of the choice here, and the rest of this file is walking the interval in
|
|
7
|
+
* those steps and writing each one down without floating point noise in it.
|
|
8
|
+
*/
|
|
9
|
+
import { interval as intervalOf } from '../values/interval.js';
|
|
10
|
+
/**
|
|
11
|
+
* The nearest one, two, five or ten times a power of ten, which is Heckbert's
|
|
12
|
+
* nice numbers from Graphics Gems.
|
|
13
|
+
*
|
|
14
|
+
* Rounding up gives a step that never asks for more ticks than were wanted;
|
|
15
|
+
* rounding to nearest gives the count closest to it, which is what an axis
|
|
16
|
+
* wants because a step slightly too small crowds the labels.
|
|
17
|
+
*/
|
|
18
|
+
function niceNumber(value, toNearest) {
|
|
19
|
+
const power = Math.floor(Math.log10(value));
|
|
20
|
+
const decade = 10 ** power;
|
|
21
|
+
const leading = value / decade;
|
|
22
|
+
const nice = toNearest
|
|
23
|
+
? leading < 1.5
|
|
24
|
+
? 1
|
|
25
|
+
: leading < 3
|
|
26
|
+
? 2
|
|
27
|
+
: leading < 7
|
|
28
|
+
? 5
|
|
29
|
+
: 10
|
|
30
|
+
: leading <= 1
|
|
31
|
+
? 1
|
|
32
|
+
: leading <= 2
|
|
33
|
+
? 2
|
|
34
|
+
: leading <= 5
|
|
35
|
+
? 5
|
|
36
|
+
: 10;
|
|
37
|
+
return nice * decade;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The gap between one tick and the next for an interval that wants about this
|
|
41
|
+
* many of them.
|
|
42
|
+
*
|
|
43
|
+
* The published version rounds the whole span before dividing it, which suits a
|
|
44
|
+
* graph free to move its own bounds outward to the next nice number. An axis
|
|
45
|
+
* here is given its bounds and keeps them, so the span is divided as it stands:
|
|
46
|
+
* over eight ranges that turned a worst count error of four ticks into one.
|
|
47
|
+
*/
|
|
48
|
+
export function tickStep(bounds, about = 6) {
|
|
49
|
+
const span = intervalOf.span(bounds);
|
|
50
|
+
if (!Number.isFinite(span) || span === 0)
|
|
51
|
+
return 0;
|
|
52
|
+
return niceNumber(span / Math.max(1, Math.round(about) - 1), true);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A tick's value written out, with as many decimals as its step needs and no
|
|
56
|
+
* more.
|
|
57
|
+
*
|
|
58
|
+
* A step of a fifth reaches three fifths as 0.6000000000000001, and printing the
|
|
59
|
+
* number as it stands puts that in the picture. The decimal count comes from the
|
|
60
|
+
* step rather than from the value, so every label along one axis is written to
|
|
61
|
+
* the same width.
|
|
62
|
+
*/
|
|
63
|
+
export function labelFor(value, step) {
|
|
64
|
+
const decimals = step > 0 ? Math.max(0, -Math.floor(Math.log10(step))) : 0;
|
|
65
|
+
const written = value.toFixed(decimals);
|
|
66
|
+
// A value a hair below zero rounds to a signed zero, which prints its minus.
|
|
67
|
+
return Number(written) === 0 ? (0).toFixed(decimals) : written;
|
|
68
|
+
}
|
|
69
|
+
/** How far past a bound a value may sit and still count as on it, against the
|
|
70
|
+
* step, so a tick landing on its own end is not lost to a rounding error. */
|
|
71
|
+
const ON_THE_BOUND = 1e-9;
|
|
72
|
+
/**
|
|
73
|
+
* Every multiple of the step inside the interval, from its lower bound upward,
|
|
74
|
+
* whichever way round the interval was given.
|
|
75
|
+
*
|
|
76
|
+
* The values are counted as multiples rather than reached by adding the step
|
|
77
|
+
* over and over, because the additions drift and the last one then misses the
|
|
78
|
+
* bound it sits on.
|
|
79
|
+
*/
|
|
80
|
+
export function multiplesOn(bounds, step) {
|
|
81
|
+
if (!(step > 0))
|
|
82
|
+
return [];
|
|
83
|
+
const { from, to } = intervalOf.ordered(bounds);
|
|
84
|
+
const slack = step * ON_THE_BOUND;
|
|
85
|
+
const values = [];
|
|
86
|
+
for (let count = Math.ceil(from / step - ON_THE_BOUND); count * step <= to + slack; count++) {
|
|
87
|
+
// Twelve digits drops the noise the multiplication leaves in the last few
|
|
88
|
+
// and shifts no value a step of any size could land on.
|
|
89
|
+
values.push(Number((count * step).toPrecision(12)));
|
|
90
|
+
}
|
|
91
|
+
return values;
|
|
92
|
+
}
|
|
93
|
+
/** Every tick inside the interval, with the number each one shows. */
|
|
94
|
+
export function ticksOn(bounds, about = 6) {
|
|
95
|
+
const step = tickStep(bounds, about);
|
|
96
|
+
return multiplesOn(bounds, step).map((value) => ({ value, label: labelFor(value, step) }));
|
|
97
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { vec2 } from './values/vec2.js';
|
|
|
14
14
|
export type { Vec2 } from './values/vec2.js';
|
|
15
15
|
export { vec3 } from './values/vec3.js';
|
|
16
16
|
export type { Vec3 } from './values/vec3.js';
|
|
17
|
+
export { interval } from './values/interval.js';
|
|
18
|
+
export type { Interval } from './values/interval.js';
|
|
17
19
|
export { mat3 } from './values/mat3.js';
|
|
18
20
|
export type { Mat3 } from './values/mat3.js';
|
|
19
21
|
export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
|
|
@@ -24,6 +26,14 @@ export { pathFromData } from './figure/path-data.js';
|
|
|
24
26
|
export type { Colour, Fill, Mark, PathMark, Stroke, TextMark } from './figure/mark.js';
|
|
25
27
|
export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
|
|
26
28
|
export type { Extent, ExtentChoice, Fit } from './figure/extent.js';
|
|
29
|
+
export { areaUnder, plot, riemannBars, slopeOf, tangentAt } from './figure/plot.js';
|
|
30
|
+
export type { AreaOptions, BarsOptions, PlotOptions, TangentOptions } from './figure/plot.js';
|
|
31
|
+
export { axes, numberLine, numberPlane } from './figure/axis.js';
|
|
32
|
+
export type { AxesOptions, NumberLineOptions, NumberPlaneOptions } from './figure/axis.js';
|
|
33
|
+
export { coordsOf, pointOf, scaleOf, scaled, unscaled } from './figure/scale.js';
|
|
34
|
+
export type { Coords, Scale } from './figure/scale.js';
|
|
35
|
+
export { labelFor, tickStep, ticksOn } from './figure/ticks.js';
|
|
36
|
+
export type { Tick } from './figure/ticks.js';
|
|
27
37
|
export { flatten, group, shape, text } from './figure/node.js';
|
|
28
38
|
export type { GroupNode, Node, ShapeNode, Style, TextNode, TextOptions } from './figure/node.js';
|
|
29
39
|
export { fadeIn, fadeOut, fadeTo, draw, morph, moveBy } from './figure/animation.js';
|
package/dist/index.js
CHANGED
|
@@ -11,11 +11,16 @@ export { clamp, inverseLerp, lerp, remap } from './values/scalar.js';
|
|
|
11
11
|
export { curveFor, easeIn, easeOut, linear, smoothstep } from './values/ease.js';
|
|
12
12
|
export { vec2 } from './values/vec2.js';
|
|
13
13
|
export { vec3 } from './values/vec3.js';
|
|
14
|
+
export { interval } from './values/interval.js';
|
|
14
15
|
export { mat3 } from './values/mat3.js';
|
|
15
16
|
export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
|
|
16
17
|
export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, straight, transformPath } from './figure/path.js';
|
|
17
18
|
export { pathFromData } from './figure/path-data.js';
|
|
18
19
|
export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
|
|
20
|
+
export { areaUnder, plot, riemannBars, slopeOf, tangentAt } from './figure/plot.js';
|
|
21
|
+
export { axes, numberLine, numberPlane } from './figure/axis.js';
|
|
22
|
+
export { coordsOf, pointOf, scaleOf, scaled, unscaled } from './figure/scale.js';
|
|
23
|
+
export { labelFor, tickStep, ticksOn } from './figure/ticks.js';
|
|
19
24
|
export { flatten, group, shape, text } from './figure/node.js';
|
|
20
25
|
export { fadeIn, fadeOut, fadeTo, draw, morph, moveBy } from './figure/animation.js';
|
|
21
26
|
export { Timeline } from './figure/timeline.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface Interval {
|
|
2
|
+
readonly from: number;
|
|
3
|
+
readonly to: number;
|
|
4
|
+
}
|
|
5
|
+
declare function makeInterval(from: number, to: number): Interval;
|
|
6
|
+
/** How far the interval reaches, without a sign, so an interval given either way
|
|
7
|
+
* round reports the same width. */
|
|
8
|
+
declare function span(interval: Interval): number;
|
|
9
|
+
/** Both bounds counting as inside, whichever way round they were given. */
|
|
10
|
+
declare function holds(interval: Interval, value: number): boolean;
|
|
11
|
+
/** The same two bounds with the lower one first, for anything that has to walk
|
|
12
|
+
* from one end to the other and would otherwise step backwards. */
|
|
13
|
+
declare function ordered(interval: Interval): Interval;
|
|
14
|
+
/** A fraction of the way along, and past either bound when the fraction is
|
|
15
|
+
* outside zero to one. */
|
|
16
|
+
declare function at(interval: Interval, along: number): number;
|
|
17
|
+
/** Held inside the two bounds, whichever way round they were given. */
|
|
18
|
+
declare function clampTo(interval: Interval, value: number): number;
|
|
19
|
+
/**
|
|
20
|
+
* The place a value holds in one interval, read at the same place in another.
|
|
21
|
+
*
|
|
22
|
+
* A source of no width has no place to read, so this hands back the target's
|
|
23
|
+
* first bound rather than an infinity that then spreads through every coordinate
|
|
24
|
+
* built on it.
|
|
25
|
+
*/
|
|
26
|
+
declare function remap(value: number, source: Interval, target: Interval): number;
|
|
27
|
+
/**
|
|
28
|
+
* The interval calls under one name, so a call site says which kind of thing it
|
|
29
|
+
* is reading and an import line says what these operate on.
|
|
30
|
+
*
|
|
31
|
+
* The width is not called `length`: a function's own `length` is how many
|
|
32
|
+
* arguments it takes, it is not writable, and assigning one throws.
|
|
33
|
+
*/
|
|
34
|
+
export declare const interval: typeof makeInterval & {
|
|
35
|
+
span: typeof span;
|
|
36
|
+
holds: typeof holds;
|
|
37
|
+
ordered: typeof ordered;
|
|
38
|
+
at: typeof at;
|
|
39
|
+
clampTo: typeof clampTo;
|
|
40
|
+
remap: typeof remap;
|
|
41
|
+
};
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run of numbers from one bound to the other, and the arithmetic that reads
|
|
3
|
+
* one against another.
|
|
4
|
+
*
|
|
5
|
+
* This is what a graph's own units are given in, and what a figure's units are
|
|
6
|
+
* given in, so mapping a value from a graph onto a picture is one interval read
|
|
7
|
+
* against another rather than four loose numbers passed around together.
|
|
8
|
+
*
|
|
9
|
+
* A bound above its partner is allowed everywhere here. An axis that counts down
|
|
10
|
+
* the screen and a scale that reverses a direction are both an interval given
|
|
11
|
+
* the other way round, and neither is a mistake to be corrected.
|
|
12
|
+
*/
|
|
13
|
+
import { clamp, lerp, remap as remapNumber } from './scalar.js';
|
|
14
|
+
function makeInterval(from, to) {
|
|
15
|
+
return { from, to };
|
|
16
|
+
}
|
|
17
|
+
/** How far the interval reaches, without a sign, so an interval given either way
|
|
18
|
+
* round reports the same width. */
|
|
19
|
+
function span(interval) {
|
|
20
|
+
return Math.abs(interval.to - interval.from);
|
|
21
|
+
}
|
|
22
|
+
/** Both bounds counting as inside, whichever way round they were given. */
|
|
23
|
+
function holds(interval, value) {
|
|
24
|
+
const low = Math.min(interval.from, interval.to);
|
|
25
|
+
const high = Math.max(interval.from, interval.to);
|
|
26
|
+
return value >= low && value <= high;
|
|
27
|
+
}
|
|
28
|
+
/** The same two bounds with the lower one first, for anything that has to walk
|
|
29
|
+
* from one end to the other and would otherwise step backwards. */
|
|
30
|
+
function ordered(interval) {
|
|
31
|
+
return interval.from <= interval.to ? interval : { from: interval.to, to: interval.from };
|
|
32
|
+
}
|
|
33
|
+
/** A fraction of the way along, and past either bound when the fraction is
|
|
34
|
+
* outside zero to one. */
|
|
35
|
+
function at(interval, along) {
|
|
36
|
+
return lerp(interval.from, interval.to, along);
|
|
37
|
+
}
|
|
38
|
+
/** Held inside the two bounds, whichever way round they were given. */
|
|
39
|
+
function clampTo(interval, value) {
|
|
40
|
+
return clamp(value, interval.from, interval.to);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The place a value holds in one interval, read at the same place in another.
|
|
44
|
+
*
|
|
45
|
+
* A source of no width has no place to read, so this hands back the target's
|
|
46
|
+
* first bound rather than an infinity that then spreads through every coordinate
|
|
47
|
+
* built on it.
|
|
48
|
+
*/
|
|
49
|
+
function remap(value, source, target) {
|
|
50
|
+
return remapNumber(value, source.from, source.to, target.from, target.to);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The interval calls under one name, so a call site says which kind of thing it
|
|
54
|
+
* is reading and an import line says what these operate on.
|
|
55
|
+
*
|
|
56
|
+
* The width is not called `length`: a function's own `length` is how many
|
|
57
|
+
* arguments it takes, it is not writable, and assigning one throws.
|
|
58
|
+
*/
|
|
59
|
+
export const interval = Object.assign(makeInterval, {
|
|
60
|
+
span,
|
|
61
|
+
holds,
|
|
62
|
+
ordered,
|
|
63
|
+
at,
|
|
64
|
+
clampTo,
|
|
65
|
+
remap,
|
|
66
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@altpsyche/maths",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "The mathematics AltPsyche's figures are drawn from: vectors, matrices, curves, and a value walked over time.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Siva",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -p tsconfig.build.json",
|
|
38
38
|
"test": "vitest run",
|
|
39
|
+
"demos": "tsc -p tsconfig.demos.json && node .demos/demos/write.js",
|
|
39
40
|
"type-check": "tsc --noEmit -p tsconfig.json",
|
|
40
41
|
"prepack": "npm run build"
|
|
41
42
|
},
|