@motionscript/plot 0.0.0-stage → 0.1.0-alpha.3

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 (102) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +201 -0
  3. package/dist/browser/chunks/chunk-37IWXGPH.js +2 -0
  4. package/dist/browser/chunks/chunk-37IWXGPH.js.map +7 -0
  5. package/dist/browser/index.js +84 -0
  6. package/dist/browser/index.js.map +7 -0
  7. package/dist/browser/kit.js +2 -0
  8. package/dist/browser/kit.js.map +7 -0
  9. package/dist/browser/manifest.json +12 -0
  10. package/dist/engine.d.ts +14 -0
  11. package/dist/engine.d.ts.map +1 -0
  12. package/dist/engine.js +14 -0
  13. package/dist/engine.js.map +1 -0
  14. package/dist/graph2d/curve-cache.d.ts +60 -0
  15. package/dist/graph2d/curve-cache.d.ts.map +1 -0
  16. package/dist/graph2d/curve-cache.js +79 -0
  17. package/dist/graph2d/curve-cache.js.map +1 -0
  18. package/dist/graph2d/curve-error.d.ts +12 -0
  19. package/dist/graph2d/curve-error.d.ts.map +1 -0
  20. package/dist/graph2d/curve-error.js +15 -0
  21. package/dist/graph2d/curve-error.js.map +1 -0
  22. package/dist/graph2d/curve.d.ts +104 -0
  23. package/dist/graph2d/curve.d.ts.map +1 -0
  24. package/dist/graph2d/curve.js +531 -0
  25. package/dist/graph2d/curve.js.map +1 -0
  26. package/dist/graph2d/graph2d.d.ts +164 -0
  27. package/dist/graph2d/graph2d.d.ts.map +1 -0
  28. package/dist/graph2d/graph2d.js +404 -0
  29. package/dist/graph2d/graph2d.js.map +1 -0
  30. package/dist/graph2d/index.d.ts +32 -0
  31. package/dist/graph2d/index.d.ts.map +1 -0
  32. package/dist/graph2d/index.js +32 -0
  33. package/dist/graph2d/index.js.map +1 -0
  34. package/dist/graph2d/plane-fill.d.ts +110 -0
  35. package/dist/graph2d/plane-fill.d.ts.map +1 -0
  36. package/dist/graph2d/plane-fill.js +248 -0
  37. package/dist/graph2d/plane-fill.js.map +1 -0
  38. package/dist/graph2d/plane.d.ts +179 -0
  39. package/dist/graph2d/plane.d.ts.map +1 -0
  40. package/dist/graph2d/plane.js +359 -0
  41. package/dist/graph2d/plane.js.map +1 -0
  42. package/dist/graph2d/shared.d.ts +40 -0
  43. package/dist/graph2d/shared.d.ts.map +1 -0
  44. package/dist/graph2d/shared.js +70 -0
  45. package/dist/graph2d/shared.js.map +1 -0
  46. package/dist/graph3d/expression.d.ts +30 -0
  47. package/dist/graph3d/expression.d.ts.map +1 -0
  48. package/dist/graph3d/expression.js +35 -0
  49. package/dist/graph3d/expression.js.map +1 -0
  50. package/dist/graph3d/graph3d.d.ts +186 -0
  51. package/dist/graph3d/graph3d.d.ts.map +1 -0
  52. package/dist/graph3d/graph3d.js +404 -0
  53. package/dist/graph3d/graph3d.js.map +1 -0
  54. package/dist/graph3d/index.d.ts +22 -0
  55. package/dist/graph3d/index.d.ts.map +1 -0
  56. package/dist/graph3d/index.js +22 -0
  57. package/dist/graph3d/index.js.map +1 -0
  58. package/dist/graph3d/shared.d.ts +61 -0
  59. package/dist/graph3d/shared.d.ts.map +1 -0
  60. package/dist/graph3d/shared.js +101 -0
  61. package/dist/graph3d/shared.js.map +1 -0
  62. package/dist/index.d.ts +4 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +4 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/kit/equations.d.ts +56 -0
  67. package/dist/kit/equations.d.ts.map +1 -0
  68. package/dist/kit/equations.js +63 -0
  69. package/dist/kit/equations.js.map +1 -0
  70. package/dist/kit/expression.d.ts +60 -0
  71. package/dist/kit/expression.d.ts.map +1 -0
  72. package/dist/kit/expression.js +268 -0
  73. package/dist/kit/expression.js.map +1 -0
  74. package/dist/kit/index.d.ts +15 -0
  75. package/dist/kit/index.d.ts.map +1 -0
  76. package/dist/kit/index.js +13 -0
  77. package/dist/kit/index.js.map +1 -0
  78. package/dist/nodes.d.ts +19 -0
  79. package/dist/nodes.d.ts.map +1 -0
  80. package/dist/nodes.js +19 -0
  81. package/dist/nodes.js.map +1 -0
  82. package/package.json +68 -3
  83. package/registry.json +34 -0
  84. package/src/engine.ts +13 -0
  85. package/src/graph2d/curve-cache.ts +103 -0
  86. package/src/graph2d/curve-error.ts +19 -0
  87. package/src/graph2d/curve.ts +657 -0
  88. package/src/graph2d/graph2d.ts +586 -0
  89. package/src/graph2d/index.ts +31 -0
  90. package/src/graph2d/plane-fill.ts +308 -0
  91. package/src/graph2d/plane.ts +457 -0
  92. package/src/graph2d/shared.ts +103 -0
  93. package/src/graph3d/expression.ts +51 -0
  94. package/src/graph3d/graph3d.ts +615 -0
  95. package/src/graph3d/index.ts +21 -0
  96. package/src/graph3d/shared.ts +143 -0
  97. package/src/index.ts +3 -0
  98. package/src/kit/equations.ts +102 -0
  99. package/src/kit/expression.ts +323 -0
  100. package/src/kit/index.ts +29 -0
  101. package/src/nodes.ts +19 -0
  102. package/README.md +0 -4
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Why an expression won't compile as `y = f(x)`, in the terms the tile shows.
3
+ *
4
+ * A one-line binding of the shared checker to this node's single free variable,
5
+ * and it is worth its own name for what it *rejects*: `x + y` is a perfectly
6
+ * good surface and a mistake on a curve, and the tile has to say so rather than
7
+ * silently plotting `x`. The 3D graph's `expressionError` is the same function
8
+ * bound the other way.
9
+ */
10
+ /** `null` when `source` compiles as `y = f(x)`, else the reason it doesn't. */
11
+ export declare function curveError(source: string): string | null;
12
+ //# sourceMappingURL=curve-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"curve-error.d.ts","sourceRoot":"","sources":["../../src/graph2d/curve-error.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAExD"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Why an expression won't compile as `y = f(x)`, in the terms the tile shows.
3
+ *
4
+ * A one-line binding of the shared checker to this node's single free variable,
5
+ * and it is worth its own name for what it *rejects*: `x + y` is a perfectly
6
+ * good surface and a mistake on a curve, and the tile has to say so rather than
7
+ * silently plotting `x`. The 3D graph's `expressionError` is the same function
8
+ * bound the other way.
9
+ */
10
+ import { CURVE_VARIABLES, expressionError, } from "../kit/expression.js";
11
+ /** `null` when `source` compiles as `y = f(x)`, else the reason it doesn't. */
12
+ export function curveError(source) {
13
+ return expressionError(source, CURVE_VARIABLES);
14
+ }
15
+ //# sourceMappingURL=curve-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"curve-error.js","sourceRoot":"","sources":["../../src/graph2d/curve-error.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EACL,eAAe,EACf,eAAe,GAChB,MAAM,mBAAmB,CAAA;AAE1B,+EAA+E;AAC/E,MAAM,UAAU,UAAU,CAAC,MAAc;IACvC,OAAO,eAAe,CAAC,MAAM,EAAE,eAAe,CAAC,CAAA;AACjD,CAAC","sourcesContent":["/**\n * Why an expression won't compile as `y = f(x)`, in the terms the tile shows.\n *\n * A one-line binding of the shared checker to this node's single free variable,\n * and it is worth its own name for what it *rejects*: `x + y` is a perfectly\n * good surface and a mistake on a curve, and the tile has to say so rather than\n * silently plotting `x`. The 3D graph's `expressionError` is the same function\n * bound the other way.\n */\n\nimport {\n CURVE_VARIABLES,\n expressionError,\n} from \"../kit/expression\"\n\n/** `null` when `source` compiles as `y = f(x)`, else the reason it doesn't. */\nexport function curveError(source: string): string | null {\n return expressionError(source, CURVE_VARIABLES)\n}\n"]}
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Turning `y = f(x)` into polylines you can actually hand a rasterizer.
3
+ *
4
+ * This is the whole difficulty of a 2D grapher, and it is not the sampling loop
5
+ * anybody writes first. That loop — walk the pixel columns, evaluate, skip
6
+ * anything that isn't on screen — fails in four separate ways, and each failure
7
+ * looks like a *different* bug:
8
+ *
9
+ * 1. **Dropping off-view points instead of clipping segments.** If the last
10
+ * point you keep is the last one that landed inside the box, the curve stops
11
+ * in mid-air an arbitrary distance from the edge. `log(x)` appears to end
12
+ * somewhere over the plot rather than plunging out of the bottom of it. The
13
+ * fix is to build the segment anyway and clip *it* against the frame, so the
14
+ * curve leaves through the edge exactly where it really crosses.
15
+ *
16
+ * 2. **Handing the path API astronomical coordinates.** `log(1e-300)` is about
17
+ * -690, which at any sane zoom is millions of pixels below the box. Skia's
18
+ * scan converter is fixed-point, so a coordinate past roughly ±32k overflows
19
+ * and the contour is *silently dropped* — the curve doesn't render wrong, it
20
+ * doesn't render at all. Clipping (1) already bounds what reaches the path
21
+ * API to the frame plus a hair; {@link COORD_LIMIT} bounds the arithmetic on
22
+ * the way there, so no infinity ever reaches the clipper to turn a `t` into a
23
+ * `NaN`.
24
+ *
25
+ * 3. **Sampling uniformly in x.** Near an asymptote one pixel of x is thousands
26
+ * of pixels of y, so a uniform grid gives a visible polygonal kink where the
27
+ * curve turns; far from one, the same grid spends hundreds of samples on a
28
+ * straight line. {@link sampleCurve} subdivides recursively instead — split
29
+ * any segment that is not flat *in screen space*, stop at a quarter pixel —
30
+ * so sample density follows curvature and comes out dense near `x = 0` for
31
+ * `log` and sparse where it is nearly straight. The flatness test pairs a
32
+ * distance check with an **angle** check, because the distance alone reads a
33
+ * symmetric wiggle (whose midpoint sits exactly on the chord) as flat.
34
+ *
35
+ * 4. **Treating "undefined" and "very large" as the same thing.** `log(-1)` is
36
+ * `NaN`: a genuine hole, and the path has to break. `log(1e-300)` is finite
37
+ * and enormous: the curve is still there, still continuous, and has to keep
38
+ * being drawn until it leaves the frame. Conflating the two is the other half
39
+ * of the cut-off in (1). At a defined/undefined edge this bisects to find
40
+ * where the domain actually ends, so `sqrt(x)` terminates at the origin
41
+ * rather than at whichever sample happened to be last.
42
+ *
43
+ * Everything here is pure and works in node-local pixels, so it is testable with
44
+ * no renderer: the caller supplies the graph→pixel mapping and gets back runs of
45
+ * points ready to stroke.
46
+ */
47
+ import type { Vector2 } from "@motionscript/sdk";
48
+ /**
49
+ * The graph→pixel mapping, as four numbers rather than two closures: this is the
50
+ * hot path (a few thousand projections per curve per frame) and the closures
51
+ * would be called through a megamorphic call site.
52
+ *
53
+ * Pixels are the node's own local space — origin at the node's centre, y **up**,
54
+ * which is both the space `Graphics` draws in and, conveniently, the orientation
55
+ * a graph is thought about in.
56
+ */
57
+ export interface PlaneMap {
58
+ /** Graph x at the node's horizontal centre. */
59
+ centerX: number;
60
+ /** Graph y at the node's vertical centre. */
61
+ centerY: number;
62
+ /** Pixels per graph unit along x. */
63
+ scaleX: number;
64
+ /** Pixels per graph unit along y. */
65
+ scaleY: number;
66
+ }
67
+ /** An axis-aligned rectangle in node-local pixels, y-up. */
68
+ export interface PixelRect {
69
+ left: number;
70
+ right: number;
71
+ bottom: number;
72
+ top: number;
73
+ }
74
+ /** A `y = f(x)` curve, sampled by {@link sampleCurve}. */
75
+ export type CurveFunction = (x: number) => number;
76
+ /**
77
+ * A ceiling on curve evaluations, shared by every curve drawn in one frame.
78
+ *
79
+ * Shared rather than per curve because what a frame can afford is a property of
80
+ * the frame: one curve with a pole every three pixels must not be allowed to
81
+ * cost what six ordinary ones do, and six ordinary ones must not each be held to
82
+ * a sixth of the budget when five of them are straight lines. A cheap curve
83
+ * hands its slack to whatever comes after it.
84
+ */
85
+ export interface SampleBudget {
86
+ /** Evaluations left for the curves still to be drawn. */
87
+ remaining: number;
88
+ /** How many curves are still to be drawn, this one included. */
89
+ pending: number;
90
+ }
91
+ /** A frame's budget, to be handed to each of `curves` {@link sampleCurve} calls. */
92
+ export declare function sampleBudget(curves: number): SampleBudget;
93
+ /**
94
+ * Samples `f` across the x range `clip` covers and returns the polylines to
95
+ * stroke: node-local pixel points, clipped to `clip`, split wherever the curve
96
+ * genuinely stops.
97
+ *
98
+ * `clip` should be the node's box grown by about a stroke width, so a curve
99
+ * running just outside the frame still paints the half of its stroke that falls
100
+ * inside it. Nothing outside that rectangle is returned, which is what keeps the
101
+ * rasterizer's fixed-point limit out of reach — see (2) in the module note.
102
+ */
103
+ export declare function sampleCurve(f: CurveFunction, map: PlaneMap, clip: PixelRect, budget?: SampleBudget): Vector2[][];
104
+ //# sourceMappingURL=curve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"curve.d.ts","sourceRoot":"","sources":["../../src/graph2d/curve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;;GAQG;AACH,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAA;IACf,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAA;IACf,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAA;IACd,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAA;CACf;AAED,4DAA4D;AAC5D,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;CACZ;AAkHD,0DAA0D;AAC1D,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;AAiEjD;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC3B,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAA;IACjB,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAEzD;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CACzB,CAAC,EAAE,aAAa,EAChB,GAAG,EAAE,QAAQ,EACb,IAAI,EAAE,SAAS,EACf,MAAM,CAAC,EAAE,YAAY,GACpB,OAAO,EAAE,EAAE,CAwJb"}
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Turning `y = f(x)` into polylines you can actually hand a rasterizer.
3
+ *
4
+ * This is the whole difficulty of a 2D grapher, and it is not the sampling loop
5
+ * anybody writes first. That loop — walk the pixel columns, evaluate, skip
6
+ * anything that isn't on screen — fails in four separate ways, and each failure
7
+ * looks like a *different* bug:
8
+ *
9
+ * 1. **Dropping off-view points instead of clipping segments.** If the last
10
+ * point you keep is the last one that landed inside the box, the curve stops
11
+ * in mid-air an arbitrary distance from the edge. `log(x)` appears to end
12
+ * somewhere over the plot rather than plunging out of the bottom of it. The
13
+ * fix is to build the segment anyway and clip *it* against the frame, so the
14
+ * curve leaves through the edge exactly where it really crosses.
15
+ *
16
+ * 2. **Handing the path API astronomical coordinates.** `log(1e-300)` is about
17
+ * -690, which at any sane zoom is millions of pixels below the box. Skia's
18
+ * scan converter is fixed-point, so a coordinate past roughly ±32k overflows
19
+ * and the contour is *silently dropped* — the curve doesn't render wrong, it
20
+ * doesn't render at all. Clipping (1) already bounds what reaches the path
21
+ * API to the frame plus a hair; {@link COORD_LIMIT} bounds the arithmetic on
22
+ * the way there, so no infinity ever reaches the clipper to turn a `t` into a
23
+ * `NaN`.
24
+ *
25
+ * 3. **Sampling uniformly in x.** Near an asymptote one pixel of x is thousands
26
+ * of pixels of y, so a uniform grid gives a visible polygonal kink where the
27
+ * curve turns; far from one, the same grid spends hundreds of samples on a
28
+ * straight line. {@link sampleCurve} subdivides recursively instead — split
29
+ * any segment that is not flat *in screen space*, stop at a quarter pixel —
30
+ * so sample density follows curvature and comes out dense near `x = 0` for
31
+ * `log` and sparse where it is nearly straight. The flatness test pairs a
32
+ * distance check with an **angle** check, because the distance alone reads a
33
+ * symmetric wiggle (whose midpoint sits exactly on the chord) as flat.
34
+ *
35
+ * 4. **Treating "undefined" and "very large" as the same thing.** `log(-1)` is
36
+ * `NaN`: a genuine hole, and the path has to break. `log(1e-300)` is finite
37
+ * and enormous: the curve is still there, still continuous, and has to keep
38
+ * being drawn until it leaves the frame. Conflating the two is the other half
39
+ * of the cut-off in (1). At a defined/undefined edge this bisects to find
40
+ * where the domain actually ends, so `sqrt(x)` terminates at the origin
41
+ * rather than at whichever sample happened to be last.
42
+ *
43
+ * Everything here is pure and works in node-local pixels, so it is testable with
44
+ * no renderer: the caller supplies the graph→pixel mapping and gets back runs of
45
+ * points ready to stroke.
46
+ */
47
+ /**
48
+ * Hard bound on a projected coordinate, in node pixels.
49
+ *
50
+ * Not a rendering decision — clipping is what decides what the rasterizer sees.
51
+ * This exists so the *arithmetic* between projection and clipping stays finite:
52
+ * `1/x` at 1e-320 projects to something that overflows to `Infinity`, and an
53
+ * `Infinity` in the clipper's `q / p` produces a `NaN` `t` that quietly discards
54
+ * the segment — which would put the cut-off of (1) back in through a side door.
55
+ *
56
+ * Large enough that no clamped point is ever inside a plausible frame (so the
57
+ * clamp can't bend a visible part of the curve), small enough to stay far below
58
+ * the fixed-point limit even after a parent scales the node up.
59
+ */
60
+ const COORD_LIMIT = 16_000;
61
+ /** How far a sample may sit from the chord before the segment is split, in px. */
62
+ const TOLERANCE_PX = 0.25;
63
+ /**
64
+ * How narrow a segment may get before the subdivision stops, in pixels of x.
65
+ *
66
+ * A **pixel** size rather than a recursion depth, and that is the single change
67
+ * that bounds this module's cost. Leaves tile the frame, so however violent the
68
+ * function is the whole tree costs at most `2 · width / MIN_SEGMENT_PX`
69
+ * evaluations — about seven and a half thousand on a 1920-px node, against the
70
+ * million a depth of ten over five hundred seeds allowed. Half a pixel because
71
+ * that is where one more halving cannot move a stroked line by as much as its
72
+ * own antialiasing.
73
+ */
74
+ const MIN_SEGMENT_PX = 0.5;
75
+ /**
76
+ * How far the two halves of a segment may disagree in direction before it is
77
+ * split, in radians (~2.9°).
78
+ *
79
+ * The reason the distance test alone is not enough: a symmetric wiggle puts its
80
+ * midpoint *exactly* on the chord, so the perpendicular distance is zero and a
81
+ * distance-only test declares a visible kink flat. Two tests, and a segment has
82
+ * to pass both.
83
+ */
84
+ const ANGLE_TOLERANCE = 0.05;
85
+ /**
86
+ * Backstop on the recursion depth.
87
+ *
88
+ * {@link MIN_SEGMENT_PX} stops the subdivision first at every box size
89
+ * {@link seedCount} can produce — the deepest is about six levels on an 8000-px
90
+ * node — so this only ever fires if a seed interval somehow came out enormous.
91
+ * It is here so the recursion cannot run away on a stack rather than because any
92
+ * curve needs it.
93
+ */
94
+ const MAX_DEPTH = 12;
95
+ /**
96
+ * Evaluations one curve may spend in one frame.
97
+ *
98
+ * The refinement alone cannot reach this — see {@link MIN_SEGMENT_PX} — so what
99
+ * this really bounds is the boundary bisection, which is logarithmic per domain
100
+ * edge but unbounded in the *number* of edges: `sqrt(sin(1000x))` has hundreds.
101
+ * At roughly a tenth of a microsecond for a compiled expression tree this is
102
+ * about four milliseconds, which is as much of a 60 fps frame as one curve may
103
+ * have to itself.
104
+ */
105
+ const MAX_EVALUATIONS = 40_000;
106
+ /**
107
+ * Evaluations every curve is guaranteed, however many of them there are: the
108
+ * seed pass with room for two levels of refinement over all of it.
109
+ *
110
+ * A graph carrying twenty curves draws all twenty coarsely rather than the first
111
+ * three well and the rest not at all.
112
+ */
113
+ const MIN_EVALUATIONS = 2_048;
114
+ /**
115
+ * Evaluations all the curves on one node share in a frame.
116
+ *
117
+ * Three at their individual ceiling, or the verification harness's six at twenty
118
+ * thousand each — about twelve milliseconds in the worst case, which is the
119
+ * point past which degrading the curves beats dropping the frame.
120
+ */
121
+ const FRAME_EVALUATIONS = 120_000;
122
+ /**
123
+ * How many times a defined/undefined edge is bisected before the curve is cut.
124
+ *
125
+ * Generous because the interesting case is not `sqrt(x)`, where the value at the
126
+ * boundary is finite and a handful of steps land on it. It is `log(x)`, whose
127
+ * boundary value is unbounded: each halving buys a constant amount of extra
128
+ * descent, so terminating early leaves the curve stopping *inside* the frame,
129
+ * which is the exact artefact this module exists to prevent. The loop exits as
130
+ * soon as the defined side has left the frame, so the full count is only ever
131
+ * paid on a boundary that stays on screen.
132
+ */
133
+ const BOUNDARY_STEPS = 60;
134
+ /** Roughly how far apart the initial uniform samples are laid, in pixels. */
135
+ const SEED_SPACING_PX = 8;
136
+ const MIN_SEEDS = 64;
137
+ const MAX_SEEDS = 512;
138
+ /**
139
+ * Ceiling on the points one curve may emit.
140
+ *
141
+ * Adaptive subdivision is self-limiting on anything anybody plots deliberately;
142
+ * it is not self-limiting on `sin(10000x)`, where every segment genuinely is
143
+ * curved at every scale and the recursion expands to its full width. This is the
144
+ * backstop, and going over it degrades the curve to the resolution already
145
+ * reached rather than dropping it.
146
+ */
147
+ const MAX_POINTS = 24_000;
148
+ function blankSample() {
149
+ return { t: 0, x: 0, y: 0, px: 0, py: 0, ok: false };
150
+ }
151
+ /**
152
+ * Reused {@link Sample} storage.
153
+ *
154
+ * Every evaluation used to be a fresh object. Six curves at sixty frames a
155
+ * second is millions of short-lived allocations a second, which is a collector
156
+ * running *through* the animation rather than between takes. The recursion is a
157
+ * depth-first walk over one seed interval and nothing outlives that interval, so
158
+ * the arena hands out slots and the seed loop resets the mark — which makes
159
+ * every allocation after the first frame a field write on an object whose hidden
160
+ * class never changes.
161
+ *
162
+ * Module scope, and therefore not re-entrant. Nothing re-enters: one
163
+ * {@link sampleCurve} runs to completion before the next begins, and an arena
164
+ * per call would allocate the thing it exists to avoid.
165
+ */
166
+ const arena = [];
167
+ let arenaMark = 0;
168
+ function takeSample() {
169
+ const slot = arena[arenaMark];
170
+ if (slot !== undefined) {
171
+ arenaMark++;
172
+ return slot;
173
+ }
174
+ const fresh = blankSample();
175
+ arena.push(fresh);
176
+ arenaMark++;
177
+ return fresh;
178
+ }
179
+ /** A frame's budget, to be handed to each of `curves` {@link sampleCurve} calls. */
180
+ export function sampleBudget(curves) {
181
+ return { remaining: FRAME_EVALUATIONS, pending: Math.max(1, curves) };
182
+ }
183
+ /**
184
+ * Samples `f` across the x range `clip` covers and returns the polylines to
185
+ * stroke: node-local pixel points, clipped to `clip`, split wherever the curve
186
+ * genuinely stops.
187
+ *
188
+ * `clip` should be the node's box grown by about a stroke width, so a curve
189
+ * running just outside the frame still paints the half of its stroke that falls
190
+ * inside it. Nothing outside that rectangle is returned, which is what keeps the
191
+ * rasterizer's fixed-point limit out of reach — see (2) in the module note.
192
+ */
193
+ export function sampleCurve(f, map, clip, budget) {
194
+ const pen = new CurvePen(clip);
195
+ // Sample across the *clip*, not the box: the extra sliver either side is what
196
+ // lets a curve enter the frame already at full stroke width instead of
197
+ // starting with a cap flush against the edge.
198
+ //
199
+ // In offsets from the centre rather than in x — see {@link Sample.t}.
200
+ const tLo = clip.left / map.scaleX;
201
+ const tHi = clip.right / map.scaleX;
202
+ if (!(tHi > tLo) || !(map.scaleX > 0) || !(map.scaleY > 0))
203
+ return [];
204
+ const seeds = seedCount(clip.right - clip.left);
205
+ const step = (tHi - tLo) / seeds;
206
+ // This curve's slice of the frame's evaluations. Bounded above so one curve
207
+ // can't eat the frame, and below so a graph full of them still draws every one.
208
+ const share = budget
209
+ ? Math.min(MAX_EVALUATIONS, Math.max(MIN_EVALUATIONS, Math.floor(budget.remaining / Math.max(1, budget.pending))))
210
+ : MAX_EVALUATIONS;
211
+ let spent = 0;
212
+ const write = (slot, t) => {
213
+ const x = map.centerX + t;
214
+ const y = f(x);
215
+ spent++;
216
+ const ok = Number.isFinite(y);
217
+ slot.t = t;
218
+ slot.x = x;
219
+ slot.y = y;
220
+ slot.px = clampCoord(t * map.scaleX);
221
+ // A non-finite y has no position; 0 is a placeholder the `ok` flag keeps
222
+ // anyone from reading.
223
+ slot.py = ok ? clampCoord((y - map.centerY) * map.scaleY) : 0;
224
+ slot.ok = ok;
225
+ return slot;
226
+ };
227
+ const at = (t) => write(takeSample(), t);
228
+ /**
229
+ * The last defined sample on the way from `from` towards `to`, where exactly
230
+ * one of the two is defined.
231
+ *
232
+ * Three exits. Off the frame, because past the edge the only thing further
233
+ * halvings buy is a longer segment that is going to be clipped to the same
234
+ * place anyway — this is the one `log` leaves by, and the reason
235
+ * {@link BOUNDARY_STEPS} is generous. *Converged*, because a boundary whose
236
+ * value is finite stops moving after a handful of steps and `sqrt` was paying
237
+ * for all sixty of them. And float resolution, which nothing reaches.
238
+ */
239
+ const edgeOf = (from, to) => {
240
+ let inside = from;
241
+ let outsideT = to.t;
242
+ for (let i = 0; i < BOUNDARY_STEPS; i++) {
243
+ if (spent >= share)
244
+ break;
245
+ if (offFrame(inside, clip))
246
+ break;
247
+ const midT = (inside.t + outsideT) / 2;
248
+ if (midT === inside.t || midT === outsideT)
249
+ break; // float resolution
250
+ const mid = at(midT);
251
+ if (!mid.ok) {
252
+ outsideT = midT;
253
+ continue;
254
+ }
255
+ const settled = Math.abs(mid.px - inside.px) < TOLERANCE_PX &&
256
+ Math.abs(mid.py - inside.py) < TOLERANCE_PX;
257
+ inside = mid;
258
+ if (settled)
259
+ break;
260
+ }
261
+ return inside;
262
+ };
263
+ const emit = (a, m, b, floored) => {
264
+ if (a.ok && b.ok) {
265
+ if (floored && diverges(a, m, b, clip)) {
266
+ pen.break();
267
+ return;
268
+ }
269
+ pen.draw(a, b);
270
+ return;
271
+ }
272
+ if (a.ok) {
273
+ const edge = edgeOf(a, b);
274
+ if (edge.t !== a.t)
275
+ pen.draw(a, edge);
276
+ pen.break();
277
+ return;
278
+ }
279
+ if (b.ok) {
280
+ pen.break();
281
+ const edge = edgeOf(b, a);
282
+ if (edge.t !== b.t)
283
+ pen.draw(edge, b);
284
+ else
285
+ pen.start(b);
286
+ return;
287
+ }
288
+ pen.break();
289
+ };
290
+ const refine = (a, b, depth) => {
291
+ // Out of budget, or out of room in the path: draw the chord. Deliberately
292
+ // *not* routed through the pole test — with no midpoint there is nothing to
293
+ // judge by, and a vertical line through an asymptote is an ugly frame where
294
+ // a missing branch is a wrong one.
295
+ if (spent >= share || pen.points >= MAX_POINTS) {
296
+ emit(a, null, b, false);
297
+ return;
298
+ }
299
+ const m = at((a.t + b.t) / 2);
300
+ const floored = depth >= MAX_DEPTH || b.px - a.px <= MIN_SEGMENT_PX;
301
+ if (floored || flat(a, m, b)) {
302
+ // "Gave up" rather than "settled" is what identifies a pole, which is why
303
+ // the midpoint travels with the verdict — see {@link diverges}.
304
+ emit(a, m, b, floored);
305
+ return;
306
+ }
307
+ refine(a, m, depth + 1);
308
+ refine(m, b, depth + 1);
309
+ };
310
+ // The seed endpoints come from their own pair rather than from the arena:
311
+ // `previous` has to survive the reset that recycles the interval it was the
312
+ // right-hand end of.
313
+ const ends = [blankSample(), blankSample()];
314
+ let previous = write(ends[0], tLo);
315
+ for (let i = 1; i <= seeds; i++) {
316
+ // Recomputed from the ends rather than accumulated, so rounding can't drift
317
+ // the grid off the right-hand edge over five hundred additions.
318
+ const next = write(ends[i & 1], i === seeds ? tHi : tLo + i * step);
319
+ arenaMark = 0;
320
+ refine(previous, next, 0);
321
+ previous = next;
322
+ }
323
+ arenaMark = 0;
324
+ if (budget) {
325
+ budget.remaining = Math.max(0, budget.remaining - spent);
326
+ budget.pending = Math.max(0, budget.pending - 1);
327
+ }
328
+ return pen.finish();
329
+ }
330
+ /** How many uniform samples to lay down before refining anything. */
331
+ function seedCount(widthPx) {
332
+ const wanted = Math.round(widthPx / SEED_SPACING_PX);
333
+ return Math.min(MAX_SEEDS, Math.max(MIN_SEEDS, wanted));
334
+ }
335
+ /**
336
+ * Whether the arc `a → m → b` is straight enough, in **screen** space, to draw
337
+ * as one segment.
338
+ *
339
+ * Screen space and not graph space, because what "straight enough" has to mean
340
+ * is "the eye can't tell" — a tolerance in graph units would subdivide a flat
341
+ * line to death when zoomed out and give up mid-curve when zoomed in.
342
+ */
343
+ function flat(a, m, b) {
344
+ // A domain edge somewhere inside: always split, so the bisection that finds it
345
+ // runs on as short an interval as possible.
346
+ if (a.ok !== m.ok || m.ok !== b.ok)
347
+ return false;
348
+ // Nothing defined anywhere across it — there is no curve here to be wrong
349
+ // about, so stop.
350
+ if (!a.ok)
351
+ return true;
352
+ const dx = b.px - a.px;
353
+ const dy = b.py - a.py;
354
+ const length = Math.hypot(dx, dy) || 1;
355
+ const distance = Math.abs((m.px - a.px) * dy - (m.py - a.py) * dx) / length;
356
+ if (distance > TOLERANCE_PX)
357
+ return false;
358
+ // The second half of the test — see {@link ANGLE_TOLERANCE}.
359
+ const first = Math.atan2(m.py - a.py, m.px - a.px);
360
+ const second = Math.atan2(b.py - m.py, b.px - m.px);
361
+ let turn = Math.abs(first - second);
362
+ if (turn > Math.PI)
363
+ turn = 2 * Math.PI - turn;
364
+ return turn < ANGLE_TOLERANCE;
365
+ }
366
+ /**
367
+ * Whether a segment the sampler had to give up on is an **asymptote** — the one
368
+ * case where drawing nothing beats drawing the chord.
369
+ *
370
+ * Two conditions, and the second is the one this module was missing. The ends
371
+ * must have run off *opposite* edges, which is what a vertical line through the
372
+ * frame looks like. And the curve must actually turn back inside the segment: a
373
+ * midpoint outside the range its own ends span is a pole (`tan` climbs to +∞ on
374
+ * one side of π/2 and arrives from -∞ on the other, so the midpoint is always
375
+ * outside), while a midpoint *between* them is an ordinary steep climb.
376
+ *
377
+ * Leaving the second test out is what cut `log(x)` off in mid-air. Once the y
378
+ * axis is stretched, `log` crosses the whole visible band inside a single leaf;
379
+ * the ends of that leaf are both off the frame, so its neighbours clip away to
380
+ * nothing, and treating the leaf itself as a pole deletes not a gap but the
381
+ * entire crossing — the exact artefact this module exists to prevent, arriving
382
+ * through the door meant to keep it out. `exp`, `x^20` and `1/x` all reach it.
383
+ *
384
+ * A `null` midpoint means the sampler ran out of budget and has nothing to judge
385
+ * by. Draw it: only a curve that has already spent forty thousand evaluations
386
+ * can get here, and a spurious vertical line is the lesser wrong.
387
+ */
388
+ function diverges(a, m, b, clip) {
389
+ if (!straddles(a, b, clip))
390
+ return false;
391
+ if (m === null)
392
+ return false;
393
+ if (!m.ok)
394
+ return true;
395
+ return m.y < Math.min(a.y, b.y) || m.y > Math.max(a.y, b.y);
396
+ }
397
+ /** Whether the two ends have run off *opposite* edges of the frame. */
398
+ function straddles(a, b, clip) {
399
+ return ((a.py > clip.top && b.py < clip.bottom) ||
400
+ (a.py < clip.bottom && b.py > clip.top));
401
+ }
402
+ /** Whether a sample has left the frame entirely. */
403
+ function offFrame(sample, clip) {
404
+ return sample.py > clip.top || sample.py < clip.bottom;
405
+ }
406
+ function clampCoord(value) {
407
+ if (value > COORD_LIMIT)
408
+ return COORD_LIMIT;
409
+ if (value < -COORD_LIMIT)
410
+ return -COORD_LIMIT;
411
+ // `NaN` cannot reach here (the caller checks `ok` first), but a `-0` would
412
+ // print oddly in a test snapshot and costs nothing to normalise.
413
+ return value === 0 ? 0 : value;
414
+ }
415
+ /**
416
+ * Accumulates clipped polylines, one segment at a time.
417
+ *
418
+ * Incremental rather than "collect every point, then clip the polyline" because
419
+ * a curve is built by a recursion that already knows where its breaks are, and
420
+ * because the intermediate list is the thing that would carry the astronomical
421
+ * coordinates this module exists to keep out of the renderer.
422
+ */
423
+ class CurvePen {
424
+ clip;
425
+ runs = [];
426
+ open = [];
427
+ /** The clipped end of the last drawn segment, for joining the next one to. */
428
+ cursor = null;
429
+ /** Points emitted so far, against {@link MAX_POINTS}. */
430
+ points = 0;
431
+ constructor(clip) {
432
+ this.clip = clip;
433
+ }
434
+ /** Draws `a → b`, clipped; continues the open run when it joins on. */
435
+ draw(a, b) {
436
+ const segment = clipSegment(a.px, a.py, b.px, b.py, this.clip);
437
+ if (!segment) {
438
+ // Wholly outside the frame: whatever run was open ended at the edge.
439
+ this.break();
440
+ return;
441
+ }
442
+ const [start, end] = segment;
443
+ if (this.cursor && samePoint(this.cursor, start)) {
444
+ this.push(end);
445
+ }
446
+ else {
447
+ this.break();
448
+ this.push(start);
449
+ this.push(end);
450
+ }
451
+ this.cursor = end;
452
+ }
453
+ /** Begins a run at `b` without drawing anything into it yet. */
454
+ start(b) {
455
+ this.break();
456
+ if (inside(b.px, b.py, this.clip)) {
457
+ this.push({ x: b.px, y: b.py });
458
+ this.cursor = { x: b.px, y: b.py };
459
+ }
460
+ }
461
+ /** Ends the open run. A run of fewer than two points draws nothing. */
462
+ break() {
463
+ if (this.open.length >= 2)
464
+ this.runs.push(this.open);
465
+ this.open = [];
466
+ this.cursor = null;
467
+ }
468
+ finish() {
469
+ this.break();
470
+ return this.runs;
471
+ }
472
+ push(point) {
473
+ this.open.push(point);
474
+ this.points++;
475
+ }
476
+ }
477
+ /** Points within a tenth of a pixel are the same point, for run-joining. */
478
+ function samePoint(a, b) {
479
+ return Math.abs(a.x - b.x) < 0.1 && Math.abs(a.y - b.y) < 0.1;
480
+ }
481
+ function inside(x, y, clip) {
482
+ return x >= clip.left && x <= clip.right && y >= clip.bottom && y <= clip.top;
483
+ }
484
+ /**
485
+ * Liang–Barsky clip of one segment against `clip`. Returns the surviving
486
+ * `[start, end]`, or `null` when the segment lies wholly outside.
487
+ *
488
+ * The piece of this module that turns "the curve stops in mid-air" into "the
489
+ * curve leaves through the edge": the off-frame endpoint is kept and the
490
+ * *segment* is cut, rather than the point being discarded and the segment never
491
+ * built.
492
+ */
493
+ function clipSegment(ax, ay, bx, by, clip) {
494
+ const dx = bx - ax;
495
+ const dy = by - ay;
496
+ let t0 = 0;
497
+ let t1 = 1;
498
+ // Each edge as (p, q): the segment survives where p·t <= q.
499
+ const edges = [
500
+ [-dx, ax - clip.left],
501
+ [dx, clip.right - ax],
502
+ [-dy, ay - clip.bottom],
503
+ [dy, clip.top - ay],
504
+ ];
505
+ for (const [p, q] of edges) {
506
+ if (p === 0) {
507
+ // Parallel to this edge: outside it means the whole segment is out.
508
+ if (q < 0)
509
+ return null;
510
+ continue;
511
+ }
512
+ const r = q / p;
513
+ if (p < 0) {
514
+ if (r > t1)
515
+ return null;
516
+ if (r > t0)
517
+ t0 = r;
518
+ }
519
+ else {
520
+ if (r < t0)
521
+ return null;
522
+ if (r < t1)
523
+ t1 = r;
524
+ }
525
+ }
526
+ return [
527
+ { x: ax + t0 * dx, y: ay + t0 * dy },
528
+ { x: ax + t1 * dx, y: ay + t1 * dy },
529
+ ];
530
+ }
531
+ //# sourceMappingURL=curve.js.map