@motionscript/plot 0.0.0-stage → 0.1.0-alpha.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 (102) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +201 -0
  3. package/dist/browser/chunks/chunk-RCFWYW6O.js +2 -0
  4. package/dist/browser/chunks/chunk-RCFWYW6O.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 +614 -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,457 @@
1
+ /**
2
+ * What part of the plane a 2D graph is looking at, and the furniture that makes
3
+ * it readable: the graph→pixel mapping, the tick ladder, and how a number on an
4
+ * axis is written.
5
+ *
6
+ * ## The view is four numbers, and none of them is a matrix
7
+ *
8
+ * `centerX`/`centerY` say where you are, `xSpan`/`ySpan` say how much you can
9
+ * see. Four independently tweenable numbers rather than a transform, for exactly
10
+ * the reason the 3D graph's camera is three: the studio renders a *timeline*, so
11
+ * every frame has to be reproducible from stored values under scrubbing and
12
+ * export, and "pan across while zooming in" has to be one command rather than a
13
+ * hand-built parallel over matrix entries.
14
+ *
15
+ * ## `ySpan: 0` means "keep the grid square"
16
+ *
17
+ * The vertical span is the one number that usually shouldn't be typed. A grapher
18
+ * whose units are square is one where a circle is round and a 45° line looks
19
+ * like one, and the span that achieves that depends on the node's *box* — which
20
+ * the author changes by dragging a handle, not by editing this field. So zero
21
+ * means "derive it", the same convention a node's own width and height use for
22
+ * auto-sizing, and any positive value takes over.
23
+ *
24
+ * Nothing is lost by it: the moment a gesture or a command scales the axes
25
+ * apart, the derived value is written down and the field is an ordinary number
26
+ * from then on — the same handover the canvas makes when a resize drag turns a
27
+ * filling axis into a fixed one.
28
+ */
29
+
30
+ import { niceStep } from "@motionscript/core/component"
31
+ import type { PixelRect, PlaneMap } from "./curve"
32
+
33
+ /** Where a 2D graph is looking, as the four numbers its props hold. */
34
+ export interface PlaneView {
35
+ /** Graph x at the centre of the node's box. */
36
+ centerX: number
37
+ /** Graph y at the centre of the node's box. */
38
+ centerY: number
39
+ /** Width of the visible window, in graph units. */
40
+ xSpan: number
41
+ /** Height of the visible window in graph units, or 0 to keep units square. */
42
+ ySpan: number
43
+ }
44
+
45
+ /**
46
+ * Bounds on a span.
47
+ *
48
+ * A hundred decades inside the double's own range, so nothing downstream —
49
+ * `width / span`, `step * scale`, `y * scale` — can overflow or fall into the
50
+ * denormals on the way through.
51
+ *
52
+ * Deliberately far wider than anything usable, because what actually limits how
53
+ * far a plane can be zoomed is not a floor on the span but a **ratio**:
54
+ * `x - centerX` keeps about sixteen significant digits, so a window narrower
55
+ * than `|centerX| · 4.3e-13` has fewer distinct representable x values across it
56
+ * than it has pixels, and the curve staircases whatever these constants say.
57
+ * That limit belongs to the *gesture* — see `planeSpanLimits` — which is the
58
+ * thing a person can be stopped at. A scene that deliberately authored a deeper
59
+ * zoom still renders, badly and visibly, rather than being silently clamped to
60
+ * something it never asked for.
61
+ */
62
+ export const MIN_SPAN = 1e-200
63
+ export const MAX_SPAN = 1e200
64
+
65
+ /** A {@link PlaneView} resolved against a box: everything in concrete numbers. */
66
+ export interface ResolvedPlane extends PlaneMap {
67
+ /** The box, in node-local pixels. */
68
+ width: number
69
+ height: number
70
+ /** Visible graph range, low to high. */
71
+ xMin: number
72
+ xMax: number
73
+ yMin: number
74
+ yMax: number
75
+ /** The vertical span actually in force — the derived one when `ySpan` is 0. */
76
+ ySpan: number
77
+ }
78
+
79
+ /**
80
+ * Resolves a view against the node's box: what a graph unit is worth in pixels,
81
+ * and which part of the plane that puts on screen.
82
+ *
83
+ * The single place `ySpan: 0` is turned into a number, so nothing downstream —
84
+ * the grid, the curves, the gesture — has to know the convention exists.
85
+ */
86
+ export function resolvePlane(
87
+ view: PlaneView,
88
+ width: number,
89
+ height: number
90
+ ): ResolvedPlane {
91
+ const w = Math.max(1, width)
92
+ const h = Math.max(1, height)
93
+ const xSpan = clampSpan(view.xSpan)
94
+ const scaleX = w / xSpan
95
+ // Square units: a graph unit is the same number of pixels either way, so the
96
+ // visible height is however many of them fit in the box.
97
+ const ySpan = view.ySpan > 0 ? clampSpan(view.ySpan) : h / scaleX
98
+ const scaleY = h / ySpan
99
+
100
+ return {
101
+ centerX: view.centerX,
102
+ centerY: view.centerY,
103
+ scaleX,
104
+ scaleY,
105
+ width: w,
106
+ height: h,
107
+ xMin: view.centerX - xSpan / 2,
108
+ xMax: view.centerX + xSpan / 2,
109
+ yMin: view.centerY - ySpan / 2,
110
+ yMax: view.centerY + ySpan / 2,
111
+ ySpan,
112
+ }
113
+ }
114
+
115
+ /** Holds a span inside the range a double can still draw a grid over. */
116
+ export function clampSpan(span: number): number {
117
+ if (!Number.isFinite(span) || span <= 0) return MIN_SPAN
118
+ return Math.min(MAX_SPAN, Math.max(MIN_SPAN, span))
119
+ }
120
+
121
+ /** The node's box as a pixel rectangle, y-up and centred on the origin. */
122
+ export function planeBox(plane: ResolvedPlane): PixelRect {
123
+ return {
124
+ left: -plane.width / 2,
125
+ right: plane.width / 2,
126
+ bottom: -plane.height / 2,
127
+ top: plane.height / 2,
128
+ }
129
+ }
130
+
131
+ /** Grows a rectangle by `margin` on every side. */
132
+ export function inflate(rect: PixelRect, margin: number): PixelRect {
133
+ return {
134
+ left: rect.left - margin,
135
+ right: rect.right + margin,
136
+ bottom: rect.bottom - margin,
137
+ top: rect.top + margin,
138
+ }
139
+ }
140
+
141
+ /** Graph x → node-local pixels. */
142
+ export function toPx(plane: PlaneMap, x: number): number {
143
+ return (x - plane.centerX) * plane.scaleX
144
+ }
145
+
146
+ /** Graph y → node-local pixels, y-up. */
147
+ export function toPy(plane: PlaneMap, y: number): number {
148
+ return (y - plane.centerY) * plane.scaleY
149
+ }
150
+
151
+ // --- Moving the view -------------------------------------------------------
152
+
153
+ /** Slides the view by a delta in **graph units**. Spans are untouched. */
154
+ export function panPlane(
155
+ view: PlaneView,
156
+ dx: number,
157
+ dy: number
158
+ ): PlaneView {
159
+ return {
160
+ ...view,
161
+ centerX: view.centerX + dx,
162
+ centerY: view.centerY + dy,
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Scales the view about a fixed point in **graph units** — the gesture every map
168
+ * makes: whatever was under the cursor stays under the cursor.
169
+ *
170
+ * `factorX`/`factorY` are how much bigger the *window* gets, so a factor above 1
171
+ * zooms out. Passing the same factor twice keeps a square grid square, and is
172
+ * the only case that leaves an auto {@link PlaneView.ySpan} auto: scaling one
173
+ * axis on its own is the statement "this axis is mine now", so the derived span
174
+ * is written down and stops following the box.
175
+ */
176
+ export function zoomPlane(
177
+ view: PlaneView,
178
+ plane: ResolvedPlane,
179
+ factorX: number,
180
+ factorY: number,
181
+ anchorX: number,
182
+ anchorY: number
183
+ ): PlaneView {
184
+ const uniform = factorX === factorY && view.ySpan <= 0
185
+ const xSpan = clampSpan(plane.xMax - plane.xMin) * factorX
186
+ const ySpan = plane.ySpan * factorY
187
+ return {
188
+ // The anchor keeps its distance from each edge as a *fraction* of the span,
189
+ // which is what "stays under the cursor" means once the span has changed.
190
+ //
191
+ // Written as a *displacement from the current centre* rather than as
192
+ // `anchor + (centre - anchor) · factor`, which is the same expression
193
+ // rearranged and much better conditioned. The anchor is up to half a span
194
+ // away from the centre, so at a wide view the direct form subtracts two
195
+ // enormous numbers to recover a small one and loses it: at a span of 1e200 a
196
+ // centre of 4 comes back as 0, and at a stop — where the factor is exactly 1
197
+ // and the centre must not move at all — it came back as 0 rather than as 4.
198
+ // This form multiplies the big quantity by `1 - factor`, which is what is
199
+ // actually small, and is bit-exact when the wheel is doing nothing.
200
+ centerX: view.centerX + (anchorX - view.centerX) * (1 - factorX),
201
+ centerY: view.centerY + (anchorY - view.centerY) * (1 - factorY),
202
+ xSpan: clampSpan(xSpan),
203
+ ySpan: uniform ? 0 : clampSpan(ySpan),
204
+ }
205
+ }
206
+
207
+ // --- Ticks -----------------------------------------------------------------
208
+
209
+ /** One axis's tick ladder: where the numbers go and how finely to rule between. */
210
+ export interface TickLadder {
211
+ /** Distance between labelled ticks, in graph units. */
212
+ step: number
213
+ /** Distance between the faint lines between them. */
214
+ minorStep: number
215
+ /**
216
+ * Faint lines inside one labelled step — `step / minorStep`, as a whole
217
+ * number.
218
+ *
219
+ * The faint lines have no list of their own any more: they are generated by
220
+ * the plane's shader from a pitch and a phase, so what the ladder has to hand
221
+ * over is how many of them fit rather than where each one is. Building and
222
+ * filtering a few hundred numbers per axis per frame for a paint that never
223
+ * reads them was most of what the old ladder cost.
224
+ */
225
+ divisions: number
226
+ /** Labelled positions across the visible range, ascending. */
227
+ major: number[]
228
+ }
229
+
230
+ /**
231
+ * Cap on how many *numbers* one axis may carry.
232
+ *
233
+ * The step is chosen from how close two labels may sit, so this is unreachable
234
+ * in normal use — a 1920-px axis at the tightest legal pitch holds about
235
+ * seventeen. It is there because `xSpan` is an animatable number and a tween
236
+ * passing through an absurd value must not try to shape a thousand strings for
237
+ * one frame. Half what it was, because it no longer bounds the grid: the shader
238
+ * rules the plane at O(1) however far it is zoomed.
239
+ */
240
+ const MAX_TICKS = 200
241
+
242
+ /**
243
+ * Largest tick index the ladder will build from.
244
+ *
245
+ * Past `2^53` an integer is no longer exactly representable, so `first + i`
246
+ * repeats values and `(first + i) * step` produces garbage. At that view the
247
+ * ticks genuinely have no distinct values, and an empty ladder says so.
248
+ */
249
+ const MAX_INDEX = Number.MAX_SAFE_INTEGER
250
+
251
+ /**
252
+ * The decade a positive number sits in: `1` for 42, `-2` for 0.03.
253
+ *
254
+ * The nudge is not cosmetic. `Math.log10(1e-7)` is `-7.000000000000001` in V8,
255
+ * so a bare `floor` answers -8 for an exact power of ten — which put the leading
256
+ * digit of a 1e-7 step at 10 and handed {@link minorDivisions} the wrong answer
257
+ * on several perfectly ordinary zooms.
258
+ */
259
+ function decadeOf(value: number): number {
260
+ return Math.floor(Math.log10(value) + 1e-12)
261
+ }
262
+
263
+ /**
264
+ * The tick ladder for a range, at a scale, given the closest two labels may sit.
265
+ *
266
+ * Steps climb the 1–2–5 ladder, which is the one every plotting tool uses
267
+ * because those are the numbers people can subdivide in their heads. Minor lines
268
+ * subdivide a step into 5 — or into 4 when the step is a 2, so that the faint
269
+ * lines land on halves rather than on fifths of a two.
270
+ */
271
+ export function tickLadder(
272
+ min: number,
273
+ max: number,
274
+ pxPerUnit: number,
275
+ minSpacingPx: number
276
+ ): TickLadder {
277
+ const span = max - min
278
+ if (!(span > 0) || !(pxPerUnit > 0)) {
279
+ return { step: 1, minorStep: 1, divisions: 1, major: [] }
280
+ }
281
+
282
+ const step = niceStep(Math.max(minSpacingPx, 1) / pxPerUnit)
283
+ const divisions = minorDivisions(step)
284
+
285
+ return {
286
+ step,
287
+ minorStep: step / divisions,
288
+ divisions,
289
+ major: multiplesWithin(min, max, step),
290
+ }
291
+ }
292
+
293
+ /**
294
+ * A digit's advance in the built-in face, as a fraction of the font size.
295
+ *
296
+ * Approximate on purpose: the node draws its numbers through the renderer's
297
+ * shaper and this arithmetic runs in `scene-core`, which has no font metrics and
298
+ * shouldn't grow any. It only has to be close enough to decide whether two
299
+ * labels would collide, and it errs wide.
300
+ */
301
+ const DIGIT_EM = 0.58
302
+ /** Blank either side of a number, in font sizes. */
303
+ const LABEL_MARGIN = 0.8
304
+ /**
305
+ * Room an x-axis number needs before the ladder steps up, as multiples of its
306
+ * font size — the pitch {@link labelLadder} widens *from*.
307
+ *
308
+ * Wider than the five characters it nominally buys, and left that way: it is
309
+ * what every graph anywhere near the origin has always been ruled at, and
310
+ * tightening it here would re-space every existing scene to fix a problem only
311
+ * deep zoom has.
312
+ */
313
+ const BASE_LABEL_PITCH = 5
314
+
315
+ /**
316
+ * A ladder whose numbers are guaranteed not to overlap, however long they are.
317
+ *
318
+ * The base pitch assumes a label of about five characters, which is what an
319
+ * axis anywhere near the origin actually carries. Zoom to `x = 1.23456789` at a
320
+ * span of 1e-6 and every number is fourteen, so the ladder is rebuilt once at a
321
+ * pitch wide enough to hold the widest one it just produced. One extra pass and
322
+ * not a loop: the second ladder is coarser than the first, so its labels are no
323
+ * longer, and a third pass could only ever agree with the second.
324
+ */
325
+ export function labelLadder(
326
+ min: number,
327
+ max: number,
328
+ pxPerUnit: number,
329
+ fontSize: number
330
+ ): TickLadder {
331
+ const base = fontSize * BASE_LABEL_PITCH
332
+ const ladder = tickLadder(min, max, pxPerUnit, base)
333
+ const needed = fontSize * labelWidthEm(ladder)
334
+ return needed > base ? tickLadder(min, max, pxPerUnit, needed) : ladder
335
+ }
336
+
337
+ /**
338
+ * Room the widest number on `ladder` needs beside its neighbour, in font sizes.
339
+ *
340
+ * Also what the node aligns its numbers within — a right-aligned `-1200` and a
341
+ * right-aligned `5` end in the same place only if the box holds the longer of
342
+ * the two.
343
+ */
344
+ export function labelWidthEm(ladder: TickLadder): number {
345
+ return DIGIT_EM * widestLabel(ladder) + LABEL_MARGIN
346
+ }
347
+
348
+ /**
349
+ * How wide a number on this ladder can be, in characters.
350
+ *
351
+ * The longest is not always at an extreme — `-0.5` is longer than `10` — so this
352
+ * scans, which costs at most {@link MAX_TICKS} formats and is a rounding error
353
+ * beside the labels the node is about to shape anyway.
354
+ */
355
+ export function widestLabel(ladder: TickLadder): number {
356
+ let widest = 0
357
+ for (const value of ladder.major) {
358
+ const length = formatTick(value, ladder.step).length
359
+ if (length > widest) widest = length
360
+ }
361
+ return widest
362
+ }
363
+
364
+ /**
365
+ * How many faint lines a step is divided into. A 1 or a 5 takes five, a 2 takes
366
+ * four — see {@link tickLadder}.
367
+ */
368
+ function minorDivisions(step: number): number {
369
+ const magnitude = Math.pow(10, decadeOf(step))
370
+ const leading = Math.round(step / magnitude)
371
+ return leading === 2 ? 4 : 5
372
+ }
373
+
374
+ /**
375
+ * Every multiple of `step` within `[min, max]`, ascending.
376
+ *
377
+ * Built from an integer index times the step rather than by accumulation, so the
378
+ * hundredth tick is still exactly a hundred steps out — repeated addition drifts
379
+ * enough to put a `0.30000000000000004` on an axis.
380
+ */
381
+ function multiplesWithin(min: number, max: number, step: number): number[] {
382
+ // The nudge absorbs the case where a bound *is* a multiple and lands a hair
383
+ // outside it after the division, which would drop the tick on the edge.
384
+ const first = Math.ceil(min / step - 1e-9)
385
+ const last = Math.floor(max / step + 1e-9)
386
+ if (!Number.isFinite(first) || !Number.isFinite(last)) return []
387
+ if (Math.abs(first) > MAX_INDEX || Math.abs(last) > MAX_INDEX) return []
388
+
389
+ const count = last - first + 1
390
+ if (count <= 0 || count > MAX_TICKS) return []
391
+
392
+ const out: number[] = new Array(count)
393
+ for (let i = 0; i < count; i++) out[i] = (first + i) * step
394
+ return out
395
+ }
396
+
397
+ /**
398
+ * Largest number of digits worth writing out in full, before the exponent is the
399
+ * shorter answer.
400
+ */
401
+ const MAX_DIGITS = 14
402
+ /** `toFixed` and `toExponential` both refuse an argument past 100. */
403
+ const MAX_FRACTION = 100
404
+
405
+ /**
406
+ * A tick value as it is written on the axis.
407
+ *
408
+ * The precision comes from the **step**, not from the value: what a number on an
409
+ * axis has to do is tell you which tick it is, so a ladder of 0.2s reads
410
+ * `0.2 0.4 0.6` and the same values on a ladder of 1s would be rounded away
411
+ * rather than shown as `0.2` next to `1`.
412
+ *
413
+ * That is also the rule for choosing the exponent, and the reason the two fixed
414
+ * magnitude thresholds this used to carry are gone. `1e6` as a ceiling is wrong
415
+ * about *both* directions at once: pan to `x = 1e7` at the default zoom and a
416
+ * whole axis of distinct ticks reads `1e7`, `1e7`, `1e7`; while at a span of
417
+ * 1e-9 around `x = 1` no threshold on the magnitude helps at all, because the
418
+ * values are all near 1 and it is the *step* that has run out of room. What
419
+ * decides it is how many significant digits it takes to tell two neighbouring
420
+ * ticks apart: the exponent is only shorter when that count is small and the
421
+ * number is far from 1.
422
+ */
423
+ export function formatTick(value: number, step: number): string {
424
+ if (value === 0) return "0"
425
+ if (!Number.isFinite(value)) return ""
426
+ if (!(step > 0) || !Number.isFinite(step)) return String(value)
427
+
428
+ const decade = decadeOf(Math.abs(value))
429
+ // At least one: a value smaller than its own step isn't a tick of this ladder,
430
+ // but it is still a number somebody may ask this to write.
431
+ const digits = Math.max(1, decade - decadeOf(step) + 1)
432
+
433
+ // Far from 1 and cheap to write in scientific form — `2e7`, `1.5e-9`.
434
+ if ((decade >= 6 || decade <= -5) && digits <= 6) {
435
+ return trimExponent(value.toExponential(digits - 1))
436
+ }
437
+
438
+ if (digits <= MAX_DIGITS) {
439
+ const decimals = Math.min(MAX_FRACTION, Math.max(0, -decadeOf(step)))
440
+ const text = value.toFixed(decimals)
441
+ // `toFixed` keeps the zeros a nice step can't produce (0.50 for a 0.1 ladder
442
+ // is only ever written that way by the formatter).
443
+ return decimals > 0 ? text.replace(/\.?0+$/, "") : text
444
+ }
445
+
446
+ // More digits than anybody can read off an axis. The exponent at least keeps
447
+ // the ticks distinguishable from one another.
448
+ const precision = Math.min(17, Math.max(0, digits - 1))
449
+ return trimExponent(value.toExponential(precision))
450
+ }
451
+
452
+ /** `1.20e-7` → `1.2e-7`, and `1.00e+21` → `1e21`. */
453
+ function trimExponent(text: string): string {
454
+ const [mantissa, exponent] = text.split("e")
455
+ const trimmed = mantissa.replace(/\.?0+$/, "")
456
+ return `${trimmed}e${Number(exponent)}`
457
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The mappers and tweens the {@link Graph2D} node is built from — the same shape
3
+ * `graph3d/impl/shared.ts` has, and mostly the same short list, because a
4
+ * `@property` is only as good as the `mapper`/`tween` it is declared with.
5
+ *
6
+ * Almost everything here is a re-export. That is the point: the match-by-id
7
+ * equation tween lives in `nodes/graph-kit` because both graphs animate a list of
8
+ * equations the same way, and colour normalisation lives in `nodes/view3d-kit`
9
+ * because the 3D nodes needed it first. What is genuinely this node's own is one
10
+ * function: turning a two-argument compiled expression into the one-argument
11
+ * curve the sampler asks for, without losing the identity the tween compares.
12
+ */
13
+
14
+ import {
15
+ CURVE_VARIABLES,
16
+ compileExpressionCached,
17
+ lerpGraphEquations,
18
+ type GraphEquationResolved,
19
+ } from "../kit"
20
+ import type { CurveFunction } from "./curve"
21
+
22
+ /**
23
+ * Colour normalisation and its tween, plus the two number tweens that fix what a
24
+ * plain lerp gets wrong.
25
+ *
26
+ * They live in `view3d-kit` because the 3D nodes needed them first, and there is
27
+ * nothing three-dimensional about any of them — a `NormalizedColor` is an RGBA
28
+ * tuple, which is both interpolatable and still a valid `Color` to hand back to
29
+ * `Graphics`. Renamed on the way through so the call sites don't read as though
30
+ * this node draws in perspective.
31
+ */
32
+ export {
33
+ resolveColor3D as resolveColor,
34
+ lerpColor3D as lerpColor,
35
+ lerpCount,
36
+ snapFlag,
37
+ snapValue,
38
+ } from "@motionscript/core/component"
39
+
40
+ /**
41
+ * One curve after {@link Graph2D}'s mapper has run: compiled, resolved and fully
42
+ * defaulted, so the per-frame draw never re-derives anything and the tween never
43
+ * tests for an absent field.
44
+ */
45
+ export type CurveResolved = GraphEquationResolved<CurveFunction>
46
+
47
+ /**
48
+ * Compiles `source` as `y = f(x)`, memoised, returning `null` for anything that
49
+ * doesn't parse.
50
+ *
51
+ * Two caches deep and both earn their place. The inner one memoises the *parse*,
52
+ * which is what keeps a node that re-draws sixty times a second from re-reading
53
+ * its own expressions; this one memoises the **adapter**, and without it every
54
+ * write would produce a fresh closure over the same parse — which the equation
55
+ * tween reads as "this expression changed" and would blend a function into
56
+ * itself, at double the sampling cost, for no visible difference.
57
+ *
58
+ * The adapter exists because arity is fixed in the parser for the sake of the
59
+ * hot path (see `CompiledExpression`) while the sampler quite reasonably wants
60
+ * to call `f(x)`. The unused second argument is 0 and unreachable: a curve is
61
+ * compiled with `x` as its only variable, so nothing in the tree reads it.
62
+ */
63
+ const curves = new Map<string, CurveFunction | null>()
64
+
65
+ export function compileCurveCached(source: string): CurveFunction | null {
66
+ const trimmed = source.trim()
67
+ if (trimmed === "") return null
68
+
69
+ const hit = curves.get(trimmed)
70
+ if (hit !== undefined) return hit
71
+
72
+ const compiled = compileExpressionCached(trimmed, CURVE_VARIABLES)
73
+ const curve: CurveFunction | null =
74
+ compiled === null ? null : (x: number) => compiled(x, 0)
75
+ curves.set(trimmed, curve)
76
+ return curve
77
+ }
78
+
79
+ /**
80
+ * Tween for the curve list — the shared match-by-id walk, with the one thing a
81
+ * curve does differently: mid-morph its height is sampled from both functions
82
+ * and blended, so it deforms into the new one rather than cross-fading through
83
+ * a frame where both are drawn.
84
+ */
85
+ export function lerpCurves(
86
+ from: CurveResolved[],
87
+ to: CurveResolved[],
88
+ t: number
89
+ ): CurveResolved[] {
90
+ return lerpGraphEquations(from, to, t, blendCurve)
91
+ }
92
+
93
+ /** How two curves blend mid-tween. */
94
+ function blendCurve(
95
+ a: CurveFunction,
96
+ b: CurveFunction,
97
+ t: number
98
+ ): CurveFunction {
99
+ return (x) => {
100
+ const start = a(x)
101
+ return start + (b(x) - start) * t
102
+ }
103
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The `z = f(x, y)` expression language, as this node reaches for it.
3
+ *
4
+ * The parser itself now lives in `nodes/graph-kit/expression`, because the 2D
5
+ * graph writes its curves in the same language and neither node owns it — the
6
+ * same move `shared.ts` made when the Protein node needed the colour and camera
7
+ * helpers. What is left here is the *binding*: a surface's two free variables
8
+ * are `x` and `y`, which is the one thing about the grammar that differs between
9
+ * the two graphs, and every function below has it applied.
10
+ *
11
+ * Re-exported rather than re-implemented so this stays the one import a graph3d
12
+ * module reaches for, and so the vocabulary the inspector lists cannot drift
13
+ * from the names the parser will actually accept.
14
+ */
15
+
16
+ import {
17
+ SURFACE_VARIABLES,
18
+ compileExpression as compile,
19
+ compileExpressionCached as compileCached,
20
+ expressionError as errorOf,
21
+ type CompiledExpression,
22
+ } from "../kit/expression"
23
+
24
+ export { EXPRESSION_VOCABULARY } from "../kit/expression"
25
+ export type {
26
+ CompiledExpression,
27
+ ExpressionVocabulary,
28
+ } from "../kit/expression"
29
+
30
+ /** Compiles `source` as `z = f(x, y)`. Throws on a syntax error. */
31
+ export function compileExpression(source: string): CompiledExpression {
32
+ return compile(source, SURFACE_VARIABLES)
33
+ }
34
+
35
+ /** Why `source` won't compile as `z = f(x, y)`, or `null` when it does. */
36
+ export function expressionError(source: string): string | null {
37
+ return errorOf(source, SURFACE_VARIABLES)
38
+ }
39
+
40
+ /**
41
+ * Compiles with memoisation, returning `null` for anything that doesn't parse.
42
+ *
43
+ * The memoisation is what makes the equation tween cheap — equal source compiles
44
+ * to the identical closure, so "did this equation change" is an `===` — and what
45
+ * `surfaceRevision` reduces to a number. See the shared module for the rest.
46
+ */
47
+ export function compileExpressionCached(
48
+ source: string
49
+ ): CompiledExpression | null {
50
+ return compileCached(source, SURFACE_VARIABLES)
51
+ }