@johnhenry/math-sample 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +244 -2
  2. package/dist/budget.d.ts +47 -0
  3. package/dist/budget.d.ts.map +1 -0
  4. package/dist/budget.js +44 -0
  5. package/dist/budget.js.map +1 -0
  6. package/dist/complex.d.ts +79 -0
  7. package/dist/complex.d.ts.map +1 -0
  8. package/dist/complex.js +236 -0
  9. package/dist/complex.js.map +1 -0
  10. package/dist/expr.d.ts +38 -0
  11. package/dist/expr.d.ts.map +1 -0
  12. package/dist/expr.js +264 -0
  13. package/dist/expr.js.map +1 -0
  14. package/dist/field2d.d.ts +58 -0
  15. package/dist/field2d.d.ts.map +1 -0
  16. package/dist/field2d.js +199 -0
  17. package/dist/field2d.js.map +1 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +10 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/mesh.d.ts +90 -0
  23. package/dist/mesh.d.ts.map +1 -0
  24. package/dist/mesh.js +294 -0
  25. package/dist/mesh.js.map +1 -0
  26. package/dist/ode.d.ts +147 -0
  27. package/dist/ode.d.ts.map +1 -0
  28. package/dist/ode.js +466 -0
  29. package/dist/ode.js.map +1 -0
  30. package/dist/sample.d.ts +114 -0
  31. package/dist/sample.d.ts.map +1 -0
  32. package/dist/sample.js +308 -0
  33. package/dist/sample.js.map +1 -0
  34. package/dist/series.d.ts +53 -0
  35. package/dist/series.d.ts.map +1 -0
  36. package/dist/series.js +158 -0
  37. package/dist/series.js.map +1 -0
  38. package/dist/svg.d.ts +37 -0
  39. package/dist/svg.d.ts.map +1 -0
  40. package/dist/svg.js +82 -0
  41. package/dist/svg.js.map +1 -0
  42. package/dist/ticks.d.ts +17 -0
  43. package/dist/ticks.d.ts.map +1 -0
  44. package/dist/ticks.js +47 -0
  45. package/dist/ticks.js.map +1 -0
  46. package/dist/viewport.d.ts +59 -0
  47. package/dist/viewport.d.ts.map +1 -0
  48. package/dist/viewport.js +111 -0
  49. package/dist/viewport.js.map +1 -0
  50. package/package.json +70 -4
  51. package/src/budget.ts +72 -0
  52. package/src/complex.ts +327 -0
  53. package/src/expr.ts +283 -0
  54. package/src/field2d.ts +259 -0
  55. package/src/index.ts +112 -0
  56. package/src/mesh.ts +376 -0
  57. package/src/ode.ts +633 -0
  58. package/src/sample.ts +391 -0
  59. package/src/series.ts +209 -0
  60. package/src/svg.ts +124 -0
  61. package/src/ticks.ts +46 -0
  62. package/src/viewport.ts +150 -0
package/src/svg.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { formatTick, niceTicks, tickStep } from "./ticks.ts";
2
+ import { toScreenX, toScreenY, type Viewport } from "./viewport.ts";
3
+
4
+ /** Anything with parallel `x` / `y` arrays, e.g. {@link Samples}. `NaN` in `y` breaks the path. */
5
+ export interface XY {
6
+ x: ArrayLike<number>;
7
+ y: ArrayLike<number>;
8
+ }
9
+
10
+ export interface PathOptions {
11
+ /** Decimal places for screen coordinates. Default 2. */
12
+ digits?: number;
13
+ }
14
+
15
+ /**
16
+ * SVG path data (`d` attribute) for a sampled curve, mapped through the
17
+ * viewport to a `width` x `height` screen box. Each run of finite rows is one
18
+ * `M ... L ...` subpath, so gaps stay gaps. Empty input gives `""`.
19
+ */
20
+ export function samplesToSvgPath(
21
+ samples: XY,
22
+ viewport: Viewport,
23
+ width: number,
24
+ height: number,
25
+ options: PathOptions = {},
26
+ ): string {
27
+ const digits = options.digits ?? 2;
28
+ const parts: string[] = [];
29
+ let pen = false;
30
+ const n = Math.min(samples.x.length, samples.y.length);
31
+ for (let i = 0; i < n; i++) {
32
+ const y = samples.y[i];
33
+ const x = samples.x[i];
34
+ if (!Number.isFinite(y) || !Number.isFinite(x)) {
35
+ pen = false;
36
+ continue;
37
+ }
38
+ const sx = toScreenX(x, viewport, width).toFixed(digits);
39
+ const sy = toScreenY(y, viewport, height).toFixed(digits);
40
+ parts.push(`${pen ? "L" : "M"}${sx} ${sy}`);
41
+ pen = true;
42
+ }
43
+ return parts.join(" ");
44
+ }
45
+
46
+ /** Escape a value for use inside a double-quoted SVG attribute, so a caller-supplied colour cannot break out of it. */
47
+ function attr(value: string): string {
48
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
49
+ }
50
+
51
+ export interface AxesOptions {
52
+ /** Approximate tick count per axis. Default 6. */
53
+ targetTickCount?: number;
54
+ /** Axis and tick colour. Default `#888`. */
55
+ stroke?: string;
56
+ /** Label colour. Default `#222`. */
57
+ labelFill?: string;
58
+ /** Label size in px. Default 11. */
59
+ fontSize?: number;
60
+ }
61
+
62
+ /**
63
+ * SVG elements (`<line>` and `<text>` with inline attributes, no stylesheet)
64
+ * for x/y axes with ticks and labels. An axis sits at data zero when that is
65
+ * on screen and otherwise hugs the nearest edge, labels flipping inward so
66
+ * they never clip. The origin is labelled once. Returns `""` for a
67
+ * degenerate viewport.
68
+ */
69
+ export function axesToSvg(viewport: Viewport, width: number, height: number, options: AxesOptions = {}): string {
70
+ const { xMin, xMax, yMin, yMax } = viewport;
71
+ if (!(xMax > xMin) || !(yMax > yMin)) return "";
72
+ const stroke = attr(options.stroke ?? "#888");
73
+ const fill = attr(options.labelFill ?? "#222");
74
+ const fontSize = options.fontSize !== undefined && Number.isFinite(options.fontSize) ? options.fontSize : 11;
75
+ const count = options.targetTickCount ?? 6;
76
+ const half = 4;
77
+ const font = `font-size="${fontSize}" font-family="system-ui, sans-serif"`;
78
+
79
+ const xAxisAtBottom = yMin > 0;
80
+ const xAxisAtTop = yMax < 0;
81
+ const axisY = xAxisAtBottom ? height : xAxisAtTop ? 0 : toScreenY(0, viewport, height);
82
+ const yAxisAtRight = xMax < 0;
83
+ const yAxisAtLeft = xMin > 0;
84
+ const axisX = yAxisAtRight ? width : yAxisAtLeft ? 0 : toScreenX(0, viewport, width);
85
+
86
+ const out: string[] = [
87
+ `<line x1="0" y1="${axisY}" x2="${width}" y2="${axisY}" stroke="${stroke}" stroke-width="1" />`,
88
+ `<line x1="${axisX}" y1="0" x2="${axisX}" y2="${height}" stroke="${stroke}" stroke-width="1" />`,
89
+ ];
90
+
91
+ const xStep = tickStep(xMin, xMax, count);
92
+ const labelBelow = !xAxisAtBottom;
93
+ for (const v of niceTicks(xMin, xMax, count)) {
94
+ const sx = toScreenX(v, viewport, width);
95
+ out.push(
96
+ `<line x1="${sx.toFixed(2)}" y1="${(axisY - half).toFixed(2)}" x2="${sx.toFixed(2)}" y2="${(axisY + half).toFixed(2)}" stroke="${stroke}" stroke-width="1" />`,
97
+ );
98
+ if (v !== 0) {
99
+ const ty = axisY + (labelBelow ? half + 2 : -(half + 2));
100
+ out.push(
101
+ `<text x="${sx.toFixed(2)}" y="${ty.toFixed(2)}" fill="${fill}" ${font} text-anchor="middle" dominant-baseline="${labelBelow ? "hanging" : "auto"}">${formatTick(v, xStep)}</text>`,
102
+ );
103
+ }
104
+ }
105
+
106
+ const yStep = tickStep(yMin, yMax, count);
107
+ const labelLeft = !yAxisAtLeft;
108
+ for (const v of niceTicks(yMin, yMax, count)) {
109
+ const sy = toScreenY(v, viewport, height);
110
+ out.push(
111
+ `<line x1="${(axisX - half).toFixed(2)}" y1="${sy.toFixed(2)}" x2="${(axisX + half).toFixed(2)}" y2="${sy.toFixed(2)}" stroke="${stroke}" stroke-width="1" />`,
112
+ );
113
+ const tx = axisX + (labelLeft ? -(half + 4) : half + 4);
114
+ out.push(
115
+ `<text x="${tx.toFixed(2)}" y="${sy.toFixed(2)}" fill="${fill}" ${font} text-anchor="${labelLeft ? "end" : "start"}" dominant-baseline="middle">${formatTick(v, yStep)}</text>`,
116
+ );
117
+ }
118
+ return out.join("\n");
119
+ }
120
+
121
+ /** Wrap SVG `content` in a standalone `<svg>` document of the given size. */
122
+ export function svgDocument(width: number, height: number, content: string): string {
123
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n${content}\n</svg>`;
124
+ }
package/src/ticks.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The step "nice numbers" tick generation uses for `[min, max]`: the value of
3
+ * 1, 2, 5 or 10 times a power of ten at or just above `(max - min) / targetCount`
4
+ * (rounded up, so there are never more than about `targetCount` ticks).
5
+ * Returns `NaN` for a degenerate range.
6
+ */
7
+ export function tickStep(min: number, max: number, targetCount = 6): number {
8
+ if (!(max > min) || !Number.isFinite(max - min) || !(targetCount >= 1)) return Number.NaN;
9
+ const roughStep = (max - min) / targetCount;
10
+ const magnitude = 10 ** Math.floor(Math.log10(roughStep));
11
+ const residual = roughStep / magnitude;
12
+ const nice = residual > 5 ? 10 : residual > 2 ? 5 : residual > 1 ? 2 : 1;
13
+ return nice * magnitude;
14
+ }
15
+
16
+ /**
17
+ * Round tick values inside `[min, max]`, about `targetCount` of them (default
18
+ * 6). Ticks are integer multiples of the step, rounded to the step's own
19
+ * decimal places, so `0.1 * 3` reads `0.3` and not `0.30000000000000004`.
20
+ * Returns `[]` for a degenerate or non-finite range.
21
+ */
22
+ export function niceTicks(min: number, max: number, targetCount = 6): number[] {
23
+ const step = tickStep(min, max, targetCount);
24
+ if (!Number.isFinite(step) || step < 1e-20) return []; // below this, fixed-point rounding would collapse every tick to 0
25
+ const decimals = decimalsOf(step);
26
+ const first = Math.ceil(min / step - 1e-9);
27
+ const last = Math.floor(max / step + 1e-9);
28
+ const ticks: number[] = [];
29
+ for (let i = first; i <= last; i++) ticks.push(roundTo(i * step, decimals));
30
+ return ticks;
31
+ }
32
+
33
+ /** A tick label with as many decimals as `step` needs, so a run of labels reads uniformly (`3.0, 3.5, 4.0`). */
34
+ export function formatTick(value: number, step: number): string {
35
+ if (value === 0) return "0";
36
+ return value.toFixed(decimalsOf(step));
37
+ }
38
+
39
+ function decimalsOf(step: number): number {
40
+ return Math.min(20, Math.max(0, -Math.floor(Math.log10(step) + 1e-9)));
41
+ }
42
+
43
+ function roundTo(value: number, decimals: number): number {
44
+ const r = Number(value.toFixed(decimals));
45
+ return r === 0 ? 0 : r; // fold -0 into 0
46
+ }
@@ -0,0 +1,150 @@
1
+ /** A rectangle of data space: the part of the plane a plot currently shows. */
2
+ export interface Viewport {
3
+ xMin: number;
4
+ xMax: number;
5
+ yMin: number;
6
+ yMax: number;
7
+ }
8
+
9
+ /** Data x to a screen coordinate in `[0, width]` (left to right). */
10
+ export function toScreenX(x: number, viewport: Viewport, width: number): number {
11
+ return ((x - viewport.xMin) / (viewport.xMax - viewport.xMin)) * width;
12
+ }
13
+
14
+ /** Data y to a screen coordinate in `[0, height]` (screen y grows downward, so this flips). */
15
+ export function toScreenY(y: number, viewport: Viewport, height: number): number {
16
+ return height - ((y - viewport.yMin) / (viewport.yMax - viewport.yMin)) * height;
17
+ }
18
+
19
+ /** Inverse of {@link toScreenX}. */
20
+ export function toDataX(sx: number, viewport: Viewport, width: number): number {
21
+ return viewport.xMin + (sx / width) * (viewport.xMax - viewport.xMin);
22
+ }
23
+
24
+ /** Inverse of {@link toScreenY}. */
25
+ export function toDataY(sy: number, viewport: Viewport, height: number): number {
26
+ return viewport.yMin + ((height - sy) / height) * (viewport.yMax - viewport.yMin);
27
+ }
28
+
29
+ /**
30
+ * The viewport of the given data-space span in which data point
31
+ * (`anchorX`, `anchorY`) sits under screen point (`sx`, `sy`). Panning,
32
+ * wheel-zoom and pinch-zoom all reduce to this: panning keeps the span and
33
+ * moves the anchor's screen position; zooming keeps the anchor's screen
34
+ * position and changes the span.
35
+ */
36
+ export function viewportFromAnchor(
37
+ anchorX: number,
38
+ anchorY: number,
39
+ sx: number,
40
+ sy: number,
41
+ spanX: number,
42
+ spanY: number,
43
+ width: number,
44
+ height: number,
45
+ ): Viewport {
46
+ const xMin = anchorX - (sx / width) * spanX;
47
+ const yMin = anchorY - ((height - sy) / height) * spanY;
48
+ return { xMin, xMax: xMin + spanX, yMin, yMax: yMin + spanY };
49
+ }
50
+
51
+ /**
52
+ * Pan by a pointer drag of (`dxPx`, `dyPx`) screen pixels: the content follows
53
+ * the pointer, so dragging right moves the window left and dragging down moves
54
+ * it up. Pixels are whatever unit `width`/`height` use (CSS px or device px,
55
+ * as long as they agree).
56
+ */
57
+ export function panViewport(viewport: Viewport, dxPx: number, dyPx: number, width: number, height: number): Viewport {
58
+ const dx = (dxPx / width) * (viewport.xMax - viewport.xMin);
59
+ const dy = (dyPx / height) * (viewport.yMax - viewport.yMin);
60
+ return {
61
+ xMin: viewport.xMin - dx,
62
+ xMax: viewport.xMax - dx,
63
+ yMin: viewport.yMin + dy,
64
+ yMax: viewport.yMax + dy,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Scale both spans by `factor` (below 1 zooms in, above 1 zooms out) while
70
+ * keeping the data point under screen point (`sx`, `sy`) fixed there.
71
+ */
72
+ export function zoomViewport(
73
+ viewport: Viewport,
74
+ factor: number,
75
+ sx: number,
76
+ sy: number,
77
+ width: number,
78
+ height: number,
79
+ ): Viewport {
80
+ const ax = toDataX(sx, viewport, width);
81
+ const ay = toDataY(sy, viewport, height);
82
+ return viewportFromAnchor(
83
+ ax,
84
+ ay,
85
+ sx,
86
+ sy,
87
+ (viewport.xMax - viewport.xMin) * factor,
88
+ (viewport.yMax - viewport.yMin) * factor,
89
+ width,
90
+ height,
91
+ );
92
+ }
93
+
94
+ /** Wheel step: scrolling down (`deltaY > 0`) zooms out (factor above 1), up zooms in. Feed to {@link zoomViewport}. */
95
+ export function wheelZoomFactor(deltaY: number, step = 1.1): number {
96
+ return deltaY > 0 ? step : 1 / step;
97
+ }
98
+
99
+ /** Pinch span factor: fingers moving apart zoom in (factor below 1). Feed to {@link zoomViewport}. */
100
+ export function pinchZoomFactor(startDistancePx: number, currentDistancePx: number): number {
101
+ return startDistancePx / currentDistancePx;
102
+ }
103
+
104
+ /**
105
+ * The viewport that bounds the finite `(x, y)` points, grown by `padding` (a
106
+ * fraction of each span, default 0.05). A degenerate extent (one point, or a
107
+ * constant) is widened to a span of 2 so the result is always drawable.
108
+ * Non-finite values (gaps) are ignored. With no finite points, returns `[-1, 1]^2`.
109
+ */
110
+ export function fitViewport(x: ArrayLike<number>, y: ArrayLike<number>, padding = 0.05): Viewport {
111
+ let xMin = Number.POSITIVE_INFINITY;
112
+ let xMax = Number.NEGATIVE_INFINITY;
113
+ let yMin = Number.POSITIVE_INFINITY;
114
+ let yMax = Number.NEGATIVE_INFINITY;
115
+ const n = Math.min(x.length, y.length);
116
+ for (let i = 0; i < n; i++) {
117
+ const px = x[i];
118
+ const py = y[i];
119
+ if (!Number.isFinite(px) || !Number.isFinite(py)) continue;
120
+ if (px < xMin) xMin = px;
121
+ if (px > xMax) xMax = px;
122
+ if (py < yMin) yMin = py;
123
+ if (py > yMax) yMax = py;
124
+ }
125
+ if (xMin > xMax) return { xMin: -1, xMax: 1, yMin: -1, yMax: 1 };
126
+ const pad = (lo: number, hi: number): [number, number] => {
127
+ if (hi === lo) return [lo - 1, hi + 1];
128
+ const p = (hi - lo) * padding;
129
+ return [lo - p, hi + p];
130
+ };
131
+ const [x0, x1] = pad(xMin, xMax);
132
+ const [y0, y1] = pad(yMin, yMax);
133
+ return { xMin: x0, xMax: x1, yMin: y0, yMax: y1 };
134
+ }
135
+
136
+ /**
137
+ * Canvas backing-store size for a CSS-pixel box at a device pixel ratio:
138
+ * set `canvas.width/height` to `width`/`height` and `ctx.scale(scale, scale)`,
139
+ * then draw in CSS pixels. A non-finite or non-positive `dpr` is treated as 1.
140
+ * Sample against the CSS size (or the backing size) consistently; the
141
+ * viewport maths is unit-agnostic.
142
+ */
143
+ export function backingStoreSize(
144
+ cssWidth: number,
145
+ cssHeight: number,
146
+ dpr: number,
147
+ ): { width: number; height: number; scale: number } {
148
+ const scale = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
149
+ return { width: Math.round(cssWidth * scale), height: Math.round(cssHeight * scale), scale };
150
+ }