@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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +201 -0
- package/dist/browser/chunks/chunk-37IWXGPH.js +2 -0
- package/dist/browser/chunks/chunk-37IWXGPH.js.map +7 -0
- package/dist/browser/index.js +84 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/kit.js +2 -0
- package/dist/browser/kit.js.map +7 -0
- package/dist/browser/manifest.json +12 -0
- package/dist/engine.d.ts +14 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +14 -0
- package/dist/engine.js.map +1 -0
- package/dist/graph2d/curve-cache.d.ts +60 -0
- package/dist/graph2d/curve-cache.d.ts.map +1 -0
- package/dist/graph2d/curve-cache.js +79 -0
- package/dist/graph2d/curve-cache.js.map +1 -0
- package/dist/graph2d/curve-error.d.ts +12 -0
- package/dist/graph2d/curve-error.d.ts.map +1 -0
- package/dist/graph2d/curve-error.js +15 -0
- package/dist/graph2d/curve-error.js.map +1 -0
- package/dist/graph2d/curve.d.ts +104 -0
- package/dist/graph2d/curve.d.ts.map +1 -0
- package/dist/graph2d/curve.js +531 -0
- package/dist/graph2d/curve.js.map +1 -0
- package/dist/graph2d/graph2d.d.ts +164 -0
- package/dist/graph2d/graph2d.d.ts.map +1 -0
- package/dist/graph2d/graph2d.js +404 -0
- package/dist/graph2d/graph2d.js.map +1 -0
- package/dist/graph2d/index.d.ts +32 -0
- package/dist/graph2d/index.d.ts.map +1 -0
- package/dist/graph2d/index.js +32 -0
- package/dist/graph2d/index.js.map +1 -0
- package/dist/graph2d/plane-fill.d.ts +110 -0
- package/dist/graph2d/plane-fill.d.ts.map +1 -0
- package/dist/graph2d/plane-fill.js +248 -0
- package/dist/graph2d/plane-fill.js.map +1 -0
- package/dist/graph2d/plane.d.ts +179 -0
- package/dist/graph2d/plane.d.ts.map +1 -0
- package/dist/graph2d/plane.js +359 -0
- package/dist/graph2d/plane.js.map +1 -0
- package/dist/graph2d/shared.d.ts +40 -0
- package/dist/graph2d/shared.d.ts.map +1 -0
- package/dist/graph2d/shared.js +70 -0
- package/dist/graph2d/shared.js.map +1 -0
- package/dist/graph3d/expression.d.ts +30 -0
- package/dist/graph3d/expression.d.ts.map +1 -0
- package/dist/graph3d/expression.js +35 -0
- package/dist/graph3d/expression.js.map +1 -0
- package/dist/graph3d/graph3d.d.ts +186 -0
- package/dist/graph3d/graph3d.d.ts.map +1 -0
- package/dist/graph3d/graph3d.js +404 -0
- package/dist/graph3d/graph3d.js.map +1 -0
- package/dist/graph3d/index.d.ts +22 -0
- package/dist/graph3d/index.d.ts.map +1 -0
- package/dist/graph3d/index.js +22 -0
- package/dist/graph3d/index.js.map +1 -0
- package/dist/graph3d/shared.d.ts +61 -0
- package/dist/graph3d/shared.d.ts.map +1 -0
- package/dist/graph3d/shared.js +101 -0
- package/dist/graph3d/shared.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/kit/equations.d.ts +56 -0
- package/dist/kit/equations.d.ts.map +1 -0
- package/dist/kit/equations.js +63 -0
- package/dist/kit/equations.js.map +1 -0
- package/dist/kit/expression.d.ts +60 -0
- package/dist/kit/expression.d.ts.map +1 -0
- package/dist/kit/expression.js +268 -0
- package/dist/kit/expression.js.map +1 -0
- package/dist/kit/index.d.ts +15 -0
- package/dist/kit/index.d.ts.map +1 -0
- package/dist/kit/index.js +13 -0
- package/dist/kit/index.js.map +1 -0
- package/dist/nodes.d.ts +19 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +19 -0
- package/dist/nodes.js.map +1 -0
- package/package.json +68 -3
- package/registry.json +34 -0
- package/src/engine.ts +13 -0
- package/src/graph2d/curve-cache.ts +103 -0
- package/src/graph2d/curve-error.ts +19 -0
- package/src/graph2d/curve.ts +657 -0
- package/src/graph2d/graph2d.ts +586 -0
- package/src/graph2d/index.ts +31 -0
- package/src/graph2d/plane-fill.ts +308 -0
- package/src/graph2d/plane.ts +457 -0
- package/src/graph2d/shared.ts +103 -0
- package/src/graph3d/expression.ts +51 -0
- package/src/graph3d/graph3d.ts +615 -0
- package/src/graph3d/index.ts +21 -0
- package/src/graph3d/shared.ts +143 -0
- package/src/index.ts +3 -0
- package/src/kit/equations.ts +102 -0
- package/src/kit/expression.ts +323 -0
- package/src/kit/index.ts +29 -0
- package/src/nodes.ts +19 -0
- 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/sdk/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/sdk/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
|
+
}
|