@altpsyche/maths 0.2.1 → 0.4.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.
@@ -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,261 @@
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, rect, 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
+ const corner = pointOf(coords, left, foot);
203
+ const far = pointOf(coords, right, top);
204
+ children.push(shape(String(bar), rect(corner, far.x - corner.x, far.y - corner.y), {}));
205
+ }
206
+ return group(name, children, { style: { fill: options.fill, stroke: options.stroke } });
207
+ }
208
+ /**
209
+ * The step the central difference is taken over, against the size of x.
210
+ *
211
+ * The cube root of the smallest gap between two doubles is the step where the
212
+ * two errors in a central difference are the same size: the formula's own error
213
+ * falls as the step squared and the rounding error rises as one over the step.
214
+ */
215
+ const STEP = Math.cbrt(Number.EPSILON);
216
+ /**
217
+ * The slope of a function at a point, from the central difference either side of
218
+ * it.
219
+ *
220
+ * The difference either side rather than one side is what makes the error fall
221
+ * as the step squared instead of the step, and it costs the same two calls.
222
+ */
223
+ export function slopeOf(of, x, step = STEP * Math.max(Math.abs(x), 1)) {
224
+ return (of(x + step) - of(x - step)) / (2 * step);
225
+ }
226
+ /**
227
+ * The tangent to a curve at a point, as a straight line held inside the graph.
228
+ *
229
+ * The line is cut where it leaves the graph rather than sampled and broken like
230
+ * a curve, because a straight line crosses each edge once and the crossing is
231
+ * arithmetic rather than a search. A tangent at a steep place otherwise runs the
232
+ * width of the picture and out of it.
233
+ */
234
+ export function tangentAt(coords, of, x, options = {}) {
235
+ const reach = options.reach ?? interval.span(coords.x.graph) / 8;
236
+ const height = of(x);
237
+ const slope = slopeOf(of, x, options.step);
238
+ if (!Number.isFinite(height) || !Number.isFinite(slope))
239
+ return [];
240
+ const graphX = interval.ordered(coords.x.graph);
241
+ const graphY = interval.ordered(coords.y.graph);
242
+ let low = Math.max(x - reach, graphX.from);
243
+ let high = Math.min(x + reach, graphX.to);
244
+ if (slope === 0) {
245
+ if (!interval.holds(graphY, height))
246
+ return [];
247
+ }
248
+ else {
249
+ const atY = (y) => x + (y - height) / slope;
250
+ const first = atY(graphY.from);
251
+ const second = atY(graphY.to);
252
+ low = Math.max(low, Math.min(first, second));
253
+ high = Math.min(high, Math.max(first, second));
254
+ }
255
+ if (!(high > low))
256
+ return [];
257
+ // Held on the graph so an end cut at an edge sits on it rather than a rounding
258
+ // error past it.
259
+ const at = (t) => pointOf(coords, t, interval.clampTo(graphY, height + slope * (t - x)));
260
+ return line(at(low), at(high));
261
+ }
@@ -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,15 +14,26 @@ 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';
20
22
  export type { Key, Track, TrackValue, Tracks } from './timing/track.js';
21
23
  export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, straight, transformPath } from './figure/path.js';
22
24
  export type { Cubic, Path, Subpath } from './figure/path.js';
25
+ export { pathFromData } from './figure/path-data.js';
23
26
  export type { Colour, Fill, Mark, PathMark, Stroke, TextMark } from './figure/mark.js';
24
27
  export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
25
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';
26
37
  export { flatten, group, shape, text } from './figure/node.js';
27
38
  export type { GroupNode, Node, ShapeNode, Style, TextNode, TextOptions } from './figure/node.js';
28
39
  export { fadeIn, fadeOut, fadeTo, draw, morph, moveBy } from './figure/animation.js';
package/dist/index.js CHANGED
@@ -11,10 +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';
18
+ export { pathFromData } from './figure/path-data.js';
17
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';
18
24
  export { flatten, group, shape, text } from './figure/node.js';
19
25
  export { fadeIn, fadeOut, fadeTo, draw, morph, moveBy } from './figure/animation.js';
20
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 {};