@johnhenry/math-sample 0.0.0-stage → 0.0.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 CHANGED
@@ -1,3 +1,137 @@
1
- # Temporary Holding Version
1
+ # @johnhenry/math-sample
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sampling for plots, as a library with no DOM in it. Give it a `(x) => number`
4
+ (or an `@johnhenry/math` expression) and a viewport; get back `Float64Array`s
5
+ with the poles and jumps already turned into gaps, plus the viewport maths,
6
+ "nice" ticks and SVG path output a plot needs. Pure ESM, zero runtime
7
+ dependencies, no globals beyond the language: it runs unchanged in a Web
8
+ Worker, so a plotter can send it *sources and a viewport*, and transfer the
9
+ arrays back.
10
+
11
+ ```bash
12
+ npm install @johnhenry/math-sample
13
+ ```
14
+
15
+ ```ts
16
+ import { sampleAdaptive, samplesToSvgPath, axesToSvg, niceTicks } from "@johnhenry/math-sample";
17
+ import { compileExpression } from "@johnhenry/math-sample/expr"; // needs @johnhenry/math
18
+
19
+ const f = compileExpression("a*sin(x)", { params: { a: 2 } });
20
+ const view = { xMin: -10, xMax: 10, yMin: -6, yMax: 6 };
21
+
22
+ const s = sampleAdaptive(f, { min: view.xMin, max: view.xMax }, 400, {
23
+ visibleYRange: { min: view.yMin, max: view.yMax },
24
+ });
25
+ s.x; s.y; // Float64Array, NaN in y = gap
26
+ samplesToSvgPath(s, view, 600, 300); // "M0.00 150.00 L1.50 ..."
27
+ ```
28
+
29
+ ## API
30
+
31
+ ### Samples
32
+
33
+ `Samples` is `{ x: Float64Array; y: Float64Array }`. A row with `y = NaN` is a
34
+ gap: do not draw between its neighbours. Gaps are never first, last or
35
+ adjacent to another gap, so `runsOf(samples)` (half-open row ranges) is exactly
36
+ the polylines to draw.
37
+
38
+ | Function | Does |
39
+ |---|---|
40
+ | `sampleUniform(f, domain, n, options?)` | `n` evenly spaced evaluations, both ends included |
41
+ | `sampleAdaptive(f, domain, n, options?)` | the same base grid, with cells bisected where the curve bends |
42
+ | `runsOf(samples)` | `[start, end)` row ranges of consecutive data |
43
+ | `findDiscontinuities(samples)` | `{ before, after }` points either side of every gap |
44
+ | `findCrossings(samples, signedDistance)` | interpolated crossings of a boundary (`y => y` is the x-axis); not across gaps |
45
+ | `findRoots(f, domain, { resolution? })` | sign changes on a grid, polished by bisection; poles rejected |
46
+ | `findIntersections(f, g, domain, opts?)` | roots of `f - g`, each with `y = f(x)` |
47
+ | `toPoints(samples)` | `[x, y \| null][]`, JSON-safe |
48
+
49
+ `f` is any `(x: number) => number`. Domains are `{ min, max }` (finite, `min < max`);
50
+ `n` is an integer >= 2. Bad input throws `RangeError`.
51
+
52
+ **Options (all samplers):**
53
+
54
+ - `visibleYRange: { min, max }`: the y-range on screen. A sample far outside it
55
+ (a pole's finite but enormous shoulders) is treated as a gap, and the curve
56
+ is capped with an interpolated point just inside the edge, so it runs up to
57
+ the border of the plot instead of stopping short. Also sets the default
58
+ tolerance and, for `sampleAdaptive`, enables jump detection (`sampleUniform` does not detect jumps).
59
+ - `signal` (`{ aborted: boolean }`, so any `AbortSignal`) and `isCancelled: () => boolean`:
60
+ checked every 256 evaluations; either one makes the sampler throw
61
+ `SampleCancelledError`. `isCancelled: () => generation !== latest` is the
62
+ generation-counter pattern for pan/zoom.
63
+
64
+ **Options (`sampleAdaptive`):**
65
+
66
+ - `maxDepth` (4): bisections per base cell.
67
+ - `tolerance`: a cell is split while its midpoint is further than this, in y
68
+ units, from the straight line through its ends. Default `1e-4` of the visible
69
+ y span, so zooming in refines more and zooming out less; with no
70
+ `visibleYRange`, `1e-3`.
71
+ - `jumpFraction` (0.5 with `visibleYRange`; off without): a cell still this far
72
+ apart (as a fraction of the y span) at `maxDepth`, whose height has not
73
+ shrunk as it was halved, is a jump (a step, a pole) and becomes a gap. A
74
+ steep slope loses height as it is halved, so it stays connected. `Infinity` turns it off.
75
+
76
+ ### Viewport
77
+
78
+ `Viewport` is `{ xMin, xMax, yMin, yMax }`. Everything is unit-agnostic: use CSS
79
+ pixels or device pixels, as long as width and height agree.
80
+
81
+ `toScreenX/toScreenY(v, viewport, size)`, `toDataX/toDataY(s, viewport, size)`,
82
+ `panViewport(v, dxPx, dyPx, w, h)` (content follows the pointer),
83
+ `zoomViewport(v, factor, sx, sy, w, h)` (the data under the cursor stays put),
84
+ `viewportFromAnchor(...)`, `wheelZoomFactor(deltaY, step?)`,
85
+ `pinchZoomFactor(startDist, currentDist)`, `fitViewport(x, y, padding?)`, and
86
+ `backingStoreSize(cssW, cssH, dpr)` which returns `{ width, height, scale }` for
87
+ sizing a canvas: set `canvas.width/height` to those, `ctx.scale(scale, scale)`,
88
+ and draw in CSS pixels. A non-finite or non-positive `dpr` counts as 1.
89
+
90
+ ### Ticks
91
+
92
+ `niceTicks(min, max, target = 6)` gives round values (1, 2 or 5 times a power of
93
+ ten) with no float drift (`0.3`, not `0.30000000000000004`). `tickStep` is the
94
+ step it chose, and `formatTick(v, step)` labels with matching decimals.
95
+ Degenerate or non-finite ranges give `[]`.
96
+
97
+ ### SVG
98
+
99
+ `samplesToSvgPath(samples, viewport, w, h, { digits = 2 })` returns path data,
100
+ one `M ... L ...` subpath per run. `axesToSvg(viewport, w, h, { targetTickCount, stroke, labelFill, fontSize })`
101
+ returns `<line>`/`<text>` elements with inline attributes (axes at zero, or
102
+ hugging the nearest edge with labels flipped inward). `svgDocument(w, h, content)`
103
+ wraps them.
104
+
105
+ ### Expressions (`@johnhenry/math-sample/expr`)
106
+
107
+ `compileExpression(exprOrSource, { variable = "x", params = {} })` returns a
108
+ `(x) => number` from an `@johnhenry/math` `Expr` or source string. This is the
109
+ only entry that needs `@johnhenry/math`, which is an **optional peer
110
+ dependency**; the main entry never imports it. In a Worker: post the source
111
+ string and params, compile there, sample, and `postMessage` the arrays with
112
+ `[s.x.buffer, s.y.buffer]` as transferables.
113
+
114
+ ## What it does not do (yet)
115
+
116
+ - **Implicit multiplication is opt-in.** `2x` fails to parse by default; write
117
+ `2*x`, or pass `compileExpression(src, { implicitMultiplication: true })`
118
+ (needs `@johnhenry/math` 0.1.0+; multi-letter variable names such as `theta`
119
+ then become products, use `x_1`).
120
+ - **1D `y = f(x)` only.** Implicit/interval curves, parametric and polar,
121
+ surfaces, ODEs and series samplers are planned as later parts.
122
+ - **Gaps from steep or fast-oscillating functions.** Jump detection is a
123
+ heuristic; a function that oscillates faster than the grid resolves can read
124
+ as a jump. Raise the resolution or `maxDepth`, or set `jumpFraction: Infinity`.
125
+ - **Double-root and tangent roots are missed** by `findRoots` (no sign change).
126
+ - **No memoization or debouncing.** Callers own caching; use `isCancelled` to
127
+ drop stale work.
128
+ - **Typed output is `Float64Array` only.** Convert with `Float32Array.from(s.y)`
129
+ if you need less.
130
+
131
+ ## Tests
132
+
133
+ `npm test -w @johnhenry/math-sample`. Golden values from NumPy, SciPy
134
+ (`brentq`) and SymPy (pole locations) are committed in `test/fixtures/goldens.json`;
135
+ regenerate with `scripts/generate-goldens.py` (the tests never call Python).
136
+ Behaviour was designed against [mallory](https://github.com/johnhenry/mallory)'s
137
+ samplers (MIT, same author); the code here is a rewrite.
package/dist/expr.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { type Expr } from "@johnhenry/math";
2
+ import type { Fn1 } from "./sample.ts";
3
+ export interface CompileOptions {
4
+ /** The free variable that becomes the argument. Default `"x"`. */
5
+ variable?: string;
6
+ /** Fixed values for every other free variable (slider values, say). */
7
+ params?: Record<string, number>;
8
+ /**
9
+ * Read juxtaposition as multiplication in source text (`2x`, `3sin(x)`, `(x+1)(x-1)`), the way people
10
+ * type maths. Default `false`. Needs `@johnhenry/math` 0.1.0 or later; multi-letter variable names are
11
+ * unavailable when it is on (see `ParseOptions` in `@johnhenry/math`). Ignored when `expr` is already a parsed `Expr`.
12
+ */
13
+ implicitMultiplication?: boolean;
14
+ }
15
+ /**
16
+ * Compile an `@johnhenry/math` expression (source text or a parsed `Expr`)
17
+ * into a plain `(x) => number` the samplers take. The AST is walked once; the
18
+ * closure is cheap to call thousands of times. This is the only module that
19
+ * needs `@johnhenry/math` (an optional peer dependency), so a Worker can
20
+ * receive an expression string plus params, compile it here, and sample.
21
+ *
22
+ * Source text uses `@johnhenry/math`'s grammar: write `2*x`, or pass
23
+ * `implicitMultiplication: true` to accept `2x`.
24
+ */
25
+ export declare function compileExpression(expr: Expr | string, options?: CompileOptions): Fn1;
26
+ //# sourceMappingURL=expr.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"expr.d.ts","sourceRoot":"","sources":["../src/expr.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,IAAI,EAAY,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAEvC,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,GAAG,CAYxF"}
package/dist/expr.js ADDED
@@ -0,0 +1,24 @@
1
+ import { Symbolic } from "@johnhenry/math";
2
+ /**
3
+ * Compile an `@johnhenry/math` expression (source text or a parsed `Expr`)
4
+ * into a plain `(x) => number` the samplers take. The AST is walked once; the
5
+ * closure is cheap to call thousands of times. This is the only module that
6
+ * needs `@johnhenry/math` (an optional peer dependency), so a Worker can
7
+ * receive an expression string plus params, compile it here, and sample.
8
+ *
9
+ * Source text uses `@johnhenry/math`'s grammar: write `2*x`, or pass
10
+ * `implicitMultiplication: true` to accept `2x`.
11
+ */
12
+ export function compileExpression(expr, options = {}) {
13
+ const variable = options.variable ?? "x";
14
+ const tree = typeof expr === "string"
15
+ ? Symbolic.parse(expr, { implicitMultiplication: options.implicitMultiplication === true })
16
+ : expr;
17
+ const compiled = Symbolic.compile(tree);
18
+ const env = { ...options.params, [variable]: 0 };
19
+ return (x) => {
20
+ env[variable] = x;
21
+ return compiled(env);
22
+ };
23
+ }
24
+ //# sourceMappingURL=expr.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"expr.js","sourceRoot":"","sources":["../src/expr.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAgBtD;;;;;;;;;GASG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAmB,EAAE,UAA0B,EAAE;IACjF,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,GAAG,CAAC;IACzC,MAAM,IAAI,GACR,OAAO,IAAI,KAAK,QAAQ;QACtB,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,sBAAsB,EAAE,OAAO,CAAC,sBAAsB,KAAK,IAAI,EAAE,CAAC;QAC3F,CAAC,CAAC,IAAI,CAAC;IACX,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,MAAM,GAAG,GAA2B,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;IACzE,OAAO,CAAC,CAAC,EAAE,EAAE;QACX,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAClB,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC;IACvB,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,5 @@
1
+ export { type AdaptiveOptions, type CancelSignal, type Domain, type Fn1, findCrossings, findDiscontinuities, findIntersections, findRoots, type Point, type RootOptions, runsOf, SampleCancelledError, type SampleOptions, type Samples, sampleAdaptive, sampleUniform, toPoints, type YRange, } from "./sample.ts";
2
+ export { type AxesOptions, axesToSvg, type PathOptions, samplesToSvgPath, svgDocument, type XY } from "./svg.ts";
3
+ export { formatTick, niceTicks, tickStep } from "./ticks.ts";
4
+ export { backingStoreSize, fitViewport, panViewport, pinchZoomFactor, toDataX, toDataY, toScreenX, toScreenY, type Viewport, viewportFromAnchor, wheelZoomFactor, zoomViewport, } from "./viewport.ts";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,MAAM,EACX,KAAK,GAAG,EACR,aAAa,EACb,mBAAmB,EACnB,iBAAiB,EACjB,SAAS,EACT,KAAK,KAAK,EACV,KAAK,WAAW,EAChB,MAAM,EACN,oBAAoB,EACpB,KAAK,aAAa,EAClB,KAAK,OAAO,EACZ,cAAc,EACd,aAAa,EACb,QAAQ,EACR,KAAK,MAAM,GACZ,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,KAAK,WAAW,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,gBAAgB,EAAE,WAAW,EAAE,KAAK,EAAE,EAAE,MAAM,UAAU,CAAC;AACjH,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC7D,OAAO,EACL,gBAAgB,EAChB,WAAW,EACX,WAAW,EACX,eAAe,EACf,OAAO,EACP,OAAO,EACP,SAAS,EACT,SAAS,EACT,KAAK,QAAQ,EACb,kBAAkB,EAClB,eAAe,EACf,YAAY,GACb,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { findCrossings, findDiscontinuities, findIntersections, findRoots, runsOf, SampleCancelledError, sampleAdaptive, sampleUniform, toPoints, } from "./sample.js";
2
+ export { axesToSvg, samplesToSvgPath, svgDocument } from "./svg.js";
3
+ export { formatTick, niceTicks, tickStep } from "./ticks.js";
4
+ export { backingStoreSize, fitViewport, panViewport, pinchZoomFactor, toDataX, toDataY, toScreenX, toScreenY, viewportFromAnchor, wheelZoomFactor, zoomViewport, } from "./viewport.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,aAAa,EACb,mBAAmB,EACnB,iBAAiB,EACjB,SAAS,EAGT,MAAM,EACN,oBAAoB,EAGpB,cAAc,EACd,aAAa,EACb,QAAQ,GAET,MAAM,aAAa,CAAC;AACrB,OAAO,EAAoB,SAAS,EAAoB,gBAAgB,EAAE,WAAW,EAAW,MAAM,UAAU,CAAC;AACjH,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC7D,OAAO,EACL,gBAAgB,EAChB,WAAW,EACX,WAAW,EACX,eAAe,EACf,OAAO,EACP,OAAO,EACP,SAAS,EACT,SAAS,EAET,kBAAkB,EAClB,eAAe,EACf,YAAY,GACb,MAAM,eAAe,CAAC"}
@@ -0,0 +1,114 @@
1
+ /** A real function of one variable, e.g. a compiled expression. */
2
+ export type Fn1 = (x: number) => number;
3
+ /** A closed interval of the x-axis. */
4
+ export interface Domain {
5
+ min: number;
6
+ max: number;
7
+ }
8
+ /** The y-range currently on screen; lets the sampler cap blow-ups and scale its tolerance. */
9
+ export interface YRange {
10
+ min: number;
11
+ max: number;
12
+ }
13
+ /**
14
+ * Sampled curve as two parallel typed arrays. A row whose `y` is `NaN` is a
15
+ * gap: the curve is not drawn between the rows on either side of it (a pole,
16
+ * a domain edge, a jump). Gaps are never first, last, or adjacent to another
17
+ * gap.
18
+ */
19
+ export interface Samples {
20
+ x: Float64Array;
21
+ y: Float64Array;
22
+ }
23
+ /** Anything that looks like an `AbortSignal`; kept structural so the package needs no DOM or Node types. */
24
+ export interface CancelSignal {
25
+ readonly aborted: boolean;
26
+ }
27
+ export interface SampleOptions {
28
+ /**
29
+ * The y-range on screen. Samples far outside it (a pole's shoulders, which
30
+ * are finite but huge) are treated as gaps, and the curve is capped with an
31
+ * interpolated point at the edge of the range. Also sets the default
32
+ * adaptive tolerance, and (for `sampleAdaptive` only; `sampleUniform` does
33
+ * not detect jumps) enables jump detection.
34
+ */
35
+ visibleYRange?: YRange;
36
+ /** Abort when `signal.aborted` becomes true. Checked every few hundred evaluations. */
37
+ signal?: CancelSignal;
38
+ /** Polled alongside `signal`; return true to abort (the generation-counter pattern: `() => gen !== latest`). */
39
+ isCancelled?: () => boolean;
40
+ }
41
+ export interface AdaptiveOptions extends SampleOptions {
42
+ /** How many times one base-grid cell may be bisected. Default 4. */
43
+ maxDepth?: number;
44
+ /**
45
+ * Bisect a cell while its midpoint sits further than this (in y units) from
46
+ * the straight line between its ends. Default: `1e-4` of the visible y span
47
+ * when `visibleYRange` is given, else `1e-3`.
48
+ */
49
+ tolerance?: number;
50
+ /**
51
+ * A cell that is still this far apart at `maxDepth` (as a fraction of the
52
+ * visible y span) is a jump, not a steep slope, and becomes a gap. Default
53
+ * `0.5` when `visibleYRange` is given; with no range there is no scale to
54
+ * judge against and jumps are not detected. `Infinity` disables it. To tell
55
+ * a jump from a steep slope, the height must also persist (at least 80%)
56
+ * from the parent cell to the finest one. A function that oscillates faster
57
+ * than the grid can resolve can still look like a jump; raise the
58
+ * resolution or `maxDepth`.
59
+ */
60
+ jumpFraction?: number;
61
+ }
62
+ /** Thrown by the samplers when `signal` or `isCancelled` reports cancellation. */
63
+ export declare class SampleCancelledError extends Error {
64
+ constructor();
65
+ }
66
+ /** Evaluate `f` at `resolution` evenly spaced points over `domain` (both ends included). */
67
+ export declare function sampleUniform(f: Fn1, domain: Domain, resolution: number, options?: SampleOptions): Samples;
68
+ /**
69
+ * Like {@link sampleUniform}, but bisects each cell of the base grid where the
70
+ * curve bends: a cell is split while its midpoint deviates from the straight
71
+ * line through its ends by more than the tolerance, up to `maxDepth` times. A
72
+ * straight stretch costs nothing extra; a sharp bend or narrow spike that a
73
+ * uniform grid would straddle is resolved. Cells that still jump at the
74
+ * deepest level become gaps (see `jumpFraction`).
75
+ */
76
+ export declare function sampleAdaptive(f: Fn1, domain: Domain, baseResolution: number, options?: AdaptiveOptions): Samples;
77
+ /** Half-open row ranges `[start, end)` of consecutive data rows, i.e. the polylines to draw. */
78
+ export declare function runsOf(samples: Samples): [number, number][];
79
+ /**
80
+ * The two edges of every gap: the last point before it and the first after.
81
+ * Where the curve really breaks lies between them; the distance is at most one
82
+ * sampling cell, so mark both edges rather than guessing a position.
83
+ */
84
+ export declare function findDiscontinuities(samples: Samples): {
85
+ before: Point;
86
+ after: Point;
87
+ }[];
88
+ export interface Point {
89
+ x: number;
90
+ y: number;
91
+ }
92
+ /**
93
+ * Where the polyline crosses a boundary. `signedDistance(y)` is negative on
94
+ * one side and non-negative on the other, and linear in y (`y` for the x-axis,
95
+ * `y - c` for the line y = c), so zero can be interpolated between two rows.
96
+ * Pairs across a gap never count.
97
+ */
98
+ export declare function findCrossings(samples: Samples, signedDistance: (y: number) => number): Point[];
99
+ export interface RootOptions extends SampleOptions {
100
+ /** Grid points scanned for sign changes. Default 1000. */
101
+ resolution?: number;
102
+ }
103
+ /**
104
+ * Roots of `f` on `domain`: scan a uniform grid for sign changes (or exact
105
+ * zeros) and polish each by bisection. Poles (sign change through infinity)
106
+ * are not roots and are rejected. Roots where `f` only touches zero without
107
+ * changing sign (such as x^2 at 0) fall between grid points and are missed.
108
+ */
109
+ export declare function findRoots(f: Fn1, domain: Domain, options?: RootOptions): number[];
110
+ /** Where `f` and `g` meet on `domain`: the roots of `f - g`, each with `y = f(x)`. Same caveats as {@link findRoots}. */
111
+ export declare function findIntersections(f: Fn1, g: Fn1, domain: Domain, options?: RootOptions): Point[];
112
+ /** `[x, y]` pairs as plain arrays, with `y = null` at gaps (JSON-safe, unlike NaN). */
113
+ export declare function toPoints(samples: Samples): [number, number | null][];
114
+ //# sourceMappingURL=sample.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sample.d.ts","sourceRoot":"","sources":["../src/sample.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,MAAM,MAAM,GAAG,GAAG,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;AAExC,uCAAuC;AACvC,MAAM,WAAW,MAAM;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,8FAA8F;AAC9F,MAAM,WAAW,MAAM;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,CAAC,EAAE,YAAY,CAAC;IAChB,CAAC,EAAE,YAAY,CAAC;CACjB;AAED,4GAA4G;AAC5G,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uFAAuF;IACvF,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,gHAAgH;IAChH,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,eAAgB,SAAQ,aAAa;IACpD,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,kFAAkF;AAClF,qBAAa,oBAAqB,SAAQ,KAAK;;CAK9C;AAsGD,4FAA4F;AAC5F,wBAAgB,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAY9G;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CA6DrH;AAED,gGAAgG;AAChG,wBAAgB,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAa3D;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG;IAAE,MAAM,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,EAAE,CAUvF;AAED,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,GAAG,KAAK,EAAE,CAe9F;AAED,MAAM,WAAW,WAAY,SAAQ,aAAa;IAChD,0DAA0D;IAC1D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,MAAM,EAAE,CAyCrF;AAED,yHAAyH;AACzH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,KAAK,EAAE,CAEpG;AAED,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,EAAE,CAOpE"}
package/dist/sample.js ADDED
@@ -0,0 +1,308 @@
1
+ /** Thrown by the samplers when `signal` or `isCancelled` reports cancellation. */
2
+ export class SampleCancelledError extends Error {
3
+ constructor() {
4
+ super("sampling cancelled");
5
+ this.name = "SampleCancelledError";
6
+ }
7
+ }
8
+ const CANCEL_CHECK_INTERVAL = 256;
9
+ const DEFAULT_MAX_DEPTH = 4;
10
+ const FALLBACK_TOLERANCE = 1e-3;
11
+ const RELATIVE_TOLERANCE = 1e-4;
12
+ const DEFAULT_JUMP_FRACTION = 0.5;
13
+ const JUMP_PERSISTENCE = 0.8;
14
+ function checkDomain(domain, resolution) {
15
+ if (!Number.isFinite(domain.min) || !Number.isFinite(domain.max) || !(domain.max > domain.min)) {
16
+ throw new RangeError(`domain must be finite with min < max, got [${domain.min}, ${domain.max}]`);
17
+ }
18
+ if (!Number.isInteger(resolution) || resolution < 2) {
19
+ throw new RangeError(`resolution must be an integer >= 2, got ${resolution}`);
20
+ }
21
+ }
22
+ function guardedEval(f, options) {
23
+ const { signal, isCancelled } = options;
24
+ if (!signal && !isCancelled)
25
+ return f;
26
+ let count = 0;
27
+ const poll = () => {
28
+ if (signal?.aborted || isCancelled?.())
29
+ throw new SampleCancelledError();
30
+ };
31
+ poll();
32
+ return (x) => {
33
+ if (++count % CANCEL_CHECK_INTERVAL === 0)
34
+ poll();
35
+ return f(x);
36
+ };
37
+ }
38
+ function isOffRange(y, range) {
39
+ if (!range)
40
+ return false;
41
+ const span = range.max - range.min;
42
+ return Math.abs(y) > Math.abs(range.min) + Math.abs(range.max) + 5 * span;
43
+ }
44
+ /** Where segment a-b crosses the nearer edge of `range`, inset 3% so end markers are not clipped. Null if undefined. */
45
+ function edgeCrossing(ax, ay, bx, by, range) {
46
+ if (!range || !Number.isFinite(ay) || !Number.isFinite(by) || ay === by)
47
+ return null;
48
+ const span = range.max - range.min;
49
+ const edge = by > ay ? range.max - 0.03 * span : range.min + 0.03 * span;
50
+ const t = (edge - ay) / (by - ay);
51
+ if (!Number.isFinite(t) || t < 0 || t > 1)
52
+ return null;
53
+ return [ax + t * (bx - ax), edge];
54
+ }
55
+ /** Turn raw points (NaN / Infinity / off-range = invalid) into the gap-marked `Samples` form. */
56
+ function toSamples(rx, ry, range) {
57
+ const ox = [];
58
+ const oy = [];
59
+ let prevValid = false;
60
+ let px = 0;
61
+ let py = 0;
62
+ let havePrev = false;
63
+ for (let i = 0; i < rx.length; i++) {
64
+ const x = rx[i];
65
+ const y = ry[i];
66
+ const finite = Number.isFinite(y);
67
+ const valid = finite && !isOffRange(y, range);
68
+ if (valid) {
69
+ if (!prevValid && havePrev) {
70
+ const e = edgeCrossing(px, py, x, y, range);
71
+ if (e) {
72
+ ox.push(e[0]);
73
+ oy.push(e[1]);
74
+ }
75
+ }
76
+ ox.push(x);
77
+ oy.push(y);
78
+ }
79
+ else {
80
+ if (oy.length > 0 && !Number.isNaN(oy[oy.length - 1])) {
81
+ if (prevValid && finite) {
82
+ const e = edgeCrossing(px, py, x, y, range);
83
+ if (e) {
84
+ ox.push(e[0]);
85
+ oy.push(e[1]);
86
+ }
87
+ }
88
+ ox.push(x);
89
+ oy.push(Number.NaN);
90
+ }
91
+ }
92
+ px = x;
93
+ py = y;
94
+ prevValid = valid;
95
+ havePrev = true;
96
+ }
97
+ if (oy.length > 0 && Number.isNaN(oy[oy.length - 1])) {
98
+ ox.pop();
99
+ oy.pop();
100
+ }
101
+ return { x: Float64Array.from(ox), y: Float64Array.from(oy) };
102
+ }
103
+ /** Evaluate `f` at `resolution` evenly spaced points over `domain` (both ends included). */
104
+ export function sampleUniform(f, domain, resolution, options = {}) {
105
+ checkDomain(domain, resolution);
106
+ const g = guardedEval(f, options);
107
+ const rx = new Array(resolution);
108
+ const ry = new Array(resolution);
109
+ const span = domain.max - domain.min;
110
+ for (let i = 0; i < resolution; i++) {
111
+ const x = i === resolution - 1 ? domain.max : domain.min + (i / (resolution - 1)) * span;
112
+ rx[i] = x;
113
+ ry[i] = g(x);
114
+ }
115
+ return toSamples(rx, ry, options.visibleYRange);
116
+ }
117
+ /**
118
+ * Like {@link sampleUniform}, but bisects each cell of the base grid where the
119
+ * curve bends: a cell is split while its midpoint deviates from the straight
120
+ * line through its ends by more than the tolerance, up to `maxDepth` times. A
121
+ * straight stretch costs nothing extra; a sharp bend or narrow spike that a
122
+ * uniform grid would straddle is resolved. Cells that still jump at the
123
+ * deepest level become gaps (see `jumpFraction`).
124
+ */
125
+ export function sampleAdaptive(f, domain, baseResolution, options = {}) {
126
+ checkDomain(domain, baseResolution);
127
+ const range = options.visibleYRange;
128
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
129
+ const tolerance = options.tolerance ?? (range ? (range.max - range.min) * RELATIVE_TOLERANCE : FALLBACK_TOLERANCE);
130
+ const jumpFraction = options.jumpFraction ?? (range ? DEFAULT_JUMP_FRACTION : Number.POSITIVE_INFINITY);
131
+ const jumpAbs = range ? jumpFraction * (range.max - range.min) : Number.POSITIVE_INFINITY;
132
+ const g = guardedEval(f, options);
133
+ const rx = [];
134
+ const ry = [];
135
+ // Pushes `a` and the refinement points strictly between a and b (not b).
136
+ const refine = (ax, ay, bx, by, depth, parentDy) => {
137
+ if (!Number.isFinite(ay) || !Number.isFinite(by) || isOffRange(ay, range) || isOffRange(by, range)) {
138
+ rx.push(ax);
139
+ ry.push(ay);
140
+ return;
141
+ }
142
+ if (depth >= maxDepth) {
143
+ rx.push(ax);
144
+ ry.push(ay);
145
+ const dy = Math.abs(by - ay);
146
+ // A jump keeps its height as the cell halves; a steep slope loses about half of it per halving.
147
+ if (dy > jumpAbs && dy >= JUMP_PERSISTENCE * parentDy) {
148
+ rx.push((ax + bx) / 2);
149
+ ry.push(Number.NaN);
150
+ }
151
+ return;
152
+ }
153
+ const mx = (ax + bx) / 2;
154
+ const my = g(mx);
155
+ // A non-finite midpoint is a singularity between a and b: record it as the gap it is.
156
+ if (!Number.isFinite(my)) {
157
+ rx.push(ax, mx);
158
+ ry.push(ay, my);
159
+ return;
160
+ }
161
+ if (Math.abs(my - (ay + by) / 2) <= tolerance) {
162
+ rx.push(ax);
163
+ ry.push(ay);
164
+ return;
165
+ }
166
+ const dy = Math.abs(by - ay);
167
+ refine(ax, ay, mx, my, depth + 1, dy);
168
+ refine(mx, my, bx, by, depth + 1, dy);
169
+ };
170
+ const span = domain.max - domain.min;
171
+ let lx = domain.min;
172
+ let ly = g(lx);
173
+ for (let i = 1; i < baseResolution; i++) {
174
+ const x = i === baseResolution - 1 ? domain.max : domain.min + (i / (baseResolution - 1)) * span;
175
+ const y = g(x);
176
+ refine(lx, ly, x, y, 0, Number.POSITIVE_INFINITY);
177
+ lx = x;
178
+ ly = y;
179
+ }
180
+ rx.push(lx);
181
+ ry.push(ly);
182
+ return toSamples(rx, ry, range);
183
+ }
184
+ /** Half-open row ranges `[start, end)` of consecutive data rows, i.e. the polylines to draw. */
185
+ export function runsOf(samples) {
186
+ const runs = [];
187
+ let start = -1;
188
+ for (let i = 0; i < samples.y.length; i++) {
189
+ if (Number.isNaN(samples.y[i])) {
190
+ if (start >= 0)
191
+ runs.push([start, i]);
192
+ start = -1;
193
+ }
194
+ else if (start < 0) {
195
+ start = i;
196
+ }
197
+ }
198
+ if (start >= 0)
199
+ runs.push([start, samples.y.length]);
200
+ return runs;
201
+ }
202
+ /**
203
+ * The two edges of every gap: the last point before it and the first after.
204
+ * Where the curve really breaks lies between them; the distance is at most one
205
+ * sampling cell, so mark both edges rather than guessing a position.
206
+ */
207
+ export function findDiscontinuities(samples) {
208
+ const out = [];
209
+ for (let i = 1; i < samples.y.length - 1; i++) {
210
+ if (!Number.isNaN(samples.y[i]))
211
+ continue;
212
+ out.push({
213
+ before: { x: samples.x[i - 1], y: samples.y[i - 1] },
214
+ after: { x: samples.x[i + 1], y: samples.y[i + 1] },
215
+ });
216
+ }
217
+ return out;
218
+ }
219
+ /**
220
+ * Where the polyline crosses a boundary. `signedDistance(y)` is negative on
221
+ * one side and non-negative on the other, and linear in y (`y` for the x-axis,
222
+ * `y - c` for the line y = c), so zero can be interpolated between two rows.
223
+ * Pairs across a gap never count.
224
+ */
225
+ export function findCrossings(samples, signedDistance) {
226
+ const out = [];
227
+ for (const [start, end] of runsOf(samples)) {
228
+ for (let i = start + 1; i < end; i++) {
229
+ const d0 = signedDistance(samples.y[i - 1]);
230
+ const d1 = signedDistance(samples.y[i]);
231
+ if (d0 >= 0 === d1 >= 0)
232
+ continue;
233
+ const t = d0 / (d0 - d1);
234
+ out.push({
235
+ x: samples.x[i - 1] + t * (samples.x[i] - samples.x[i - 1]),
236
+ y: samples.y[i - 1] + t * (samples.y[i] - samples.y[i - 1]),
237
+ });
238
+ }
239
+ }
240
+ return out;
241
+ }
242
+ /**
243
+ * Roots of `f` on `domain`: scan a uniform grid for sign changes (or exact
244
+ * zeros) and polish each by bisection. Poles (sign change through infinity)
245
+ * are not roots and are rejected. Roots where `f` only touches zero without
246
+ * changing sign (such as x^2 at 0) fall between grid points and are missed.
247
+ */
248
+ export function findRoots(f, domain, options = {}) {
249
+ const n = options.resolution ?? 1000;
250
+ checkDomain(domain, n);
251
+ const g = guardedEval(f, options);
252
+ const span = domain.max - domain.min;
253
+ const roots = [];
254
+ let ax = domain.min;
255
+ let ay = g(ax);
256
+ if (ay === 0)
257
+ roots.push(ax);
258
+ for (let i = 1; i < n; i++) {
259
+ const bx = i === n - 1 ? domain.max : domain.min + (i / (n - 1)) * span;
260
+ const by = g(bx);
261
+ if (by === 0) {
262
+ roots.push(bx);
263
+ }
264
+ else if (Number.isFinite(ay) && Number.isFinite(by) && ay !== 0 && ay < 0 !== by < 0) {
265
+ let lo = ax;
266
+ let hi = bx;
267
+ let flo = ay;
268
+ for (let k = 0; k < 100; k++) {
269
+ const mid = (lo + hi) / 2;
270
+ if (mid === lo || mid === hi)
271
+ break;
272
+ const fm = g(mid);
273
+ if (fm === 0) {
274
+ lo = hi = mid;
275
+ break;
276
+ }
277
+ if (flo < 0 === fm < 0) {
278
+ lo = mid;
279
+ flo = fm;
280
+ }
281
+ else {
282
+ hi = mid;
283
+ }
284
+ }
285
+ const r = (lo + hi) / 2;
286
+ const fr = g(r);
287
+ if (Math.abs(fr) <= Math.max(Math.abs(ay), Math.abs(by)))
288
+ roots.push(r);
289
+ }
290
+ ax = bx;
291
+ ay = by;
292
+ }
293
+ return roots;
294
+ }
295
+ /** Where `f` and `g` meet on `domain`: the roots of `f - g`, each with `y = f(x)`. Same caveats as {@link findRoots}. */
296
+ export function findIntersections(f, g, domain, options = {}) {
297
+ return findRoots((x) => f(x) - g(x), domain, options).map((x) => ({ x, y: f(x) }));
298
+ }
299
+ /** `[x, y]` pairs as plain arrays, with `y = null` at gaps (JSON-safe, unlike NaN). */
300
+ export function toPoints(samples) {
301
+ const out = new Array(samples.x.length);
302
+ for (let i = 0; i < out.length; i++) {
303
+ const y = samples.y[i];
304
+ out[i] = [samples.x[i], Number.isNaN(y) ? null : y];
305
+ }
306
+ return out;
307
+ }
308
+ //# sourceMappingURL=sample.js.map