@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,586 @@
|
|
|
1
|
+
import {
|
|
2
|
+
node,
|
|
3
|
+
Clip,
|
|
4
|
+
Fills,
|
|
5
|
+
Graphics2D,
|
|
6
|
+
Node2D,
|
|
7
|
+
clamp,
|
|
8
|
+
lerpNumber,
|
|
9
|
+
property,
|
|
10
|
+
type Color,
|
|
11
|
+
type Node2DProps,
|
|
12
|
+
type NormalizedColor,
|
|
13
|
+
type RenderContext2D,
|
|
14
|
+
} from "@motionscript/sdk"
|
|
15
|
+
|
|
16
|
+
import { sampleBudget, type PixelRect } from "./curve"
|
|
17
|
+
import { CurveRunsCache, runsKeyOf } from "./curve-cache"
|
|
18
|
+
import {
|
|
19
|
+
formatTick,
|
|
20
|
+
inflate,
|
|
21
|
+
labelLadder,
|
|
22
|
+
labelWidthEm,
|
|
23
|
+
planeBox,
|
|
24
|
+
resolvePlane,
|
|
25
|
+
tickLadder,
|
|
26
|
+
toPx,
|
|
27
|
+
toPy,
|
|
28
|
+
type PlaneView,
|
|
29
|
+
type ResolvedPlane,
|
|
30
|
+
type TickLadder,
|
|
31
|
+
} from "./plane"
|
|
32
|
+
import {
|
|
33
|
+
PLANE_SOURCE,
|
|
34
|
+
planeUniforms,
|
|
35
|
+
planeVisible,
|
|
36
|
+
type PlaneStyle,
|
|
37
|
+
} from "./plane-fill"
|
|
38
|
+
import {
|
|
39
|
+
compileCurveCached,
|
|
40
|
+
lerpColor,
|
|
41
|
+
lerpCurves,
|
|
42
|
+
resolveColor,
|
|
43
|
+
snapFlag,
|
|
44
|
+
snapValue,
|
|
45
|
+
type CurveResolved,
|
|
46
|
+
} from "./shared"
|
|
47
|
+
|
|
48
|
+
// --- Authored props --------------------------------------------------------
|
|
49
|
+
// The loose shapes a caller writes. Each has a matching `*Resolved` type below:
|
|
50
|
+
// what the `@property` mapper turns it into and what the tween interpolates.
|
|
51
|
+
|
|
52
|
+
/** One plotted curve, `y = f(x)`. */
|
|
53
|
+
export interface Graph2DEquation {
|
|
54
|
+
/** Identity across list changes — see {@link Graph2D.equations}. */
|
|
55
|
+
id: string | number
|
|
56
|
+
/** The expression, evaluated as `y = f(x)` (e.g. `sin(x)`, `log(x)`). */
|
|
57
|
+
expression: string
|
|
58
|
+
/** The curve's colour: any {@link Color} — a CSS string or an RGBA tuple. */
|
|
59
|
+
color: Color
|
|
60
|
+
/** Whether the curve is drawn. Defaults to true. */
|
|
61
|
+
visible?: boolean
|
|
62
|
+
/** How opaque the curve is, 0–1. Defaults to 1. */
|
|
63
|
+
opacity?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The paper the curves are drawn on: the grid, the axes and their numbers. */
|
|
67
|
+
export interface Graph2DGrid {
|
|
68
|
+
/** Whether to rule the plane at all. Defaults to true. */
|
|
69
|
+
showGrid?: boolean
|
|
70
|
+
/** Whether to draw the faint lines between the numbered ones. Defaults to true. */
|
|
71
|
+
showMinorGrid?: boolean
|
|
72
|
+
/** Whether to draw the two lines through the origin. Defaults to true. */
|
|
73
|
+
showAxes?: boolean
|
|
74
|
+
/** Whether to number the axes. Defaults to true. */
|
|
75
|
+
showLabels?: boolean
|
|
76
|
+
colorGrid?: Color
|
|
77
|
+
colorMinorGrid?: Color
|
|
78
|
+
colorAxis?: Color
|
|
79
|
+
colorLabel?: Color
|
|
80
|
+
/** Width of a grid line. The minor lines are drawn at two thirds of it. */
|
|
81
|
+
lineWidth?: number
|
|
82
|
+
/** Width of the two axis lines. */
|
|
83
|
+
axisWidth?: number
|
|
84
|
+
/** Size of the axis numbers. Also sets how close two of them may sit. */
|
|
85
|
+
fontSize?: number
|
|
86
|
+
/** Family the axis numbers are set in. Empty takes the built-in face. */
|
|
87
|
+
fontFamily?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Graph2DProps extends Node2DProps {
|
|
91
|
+
/** The curves to plot, each evaluated as `y = f(x)` across the visible range. */
|
|
92
|
+
equations: Graph2DEquation[]
|
|
93
|
+
/** Graph x at the centre of the box — see {@link PlaneView}. */
|
|
94
|
+
centerX: number
|
|
95
|
+
/** Graph y at the centre of the box. */
|
|
96
|
+
centerY: number
|
|
97
|
+
/** Width of the visible window, in graph units. */
|
|
98
|
+
xSpan: number
|
|
99
|
+
/**
|
|
100
|
+
* Height of the visible window in graph units, or **0 to keep units square**,
|
|
101
|
+
* which is the default and almost always what you want. See {@link PlaneView}.
|
|
102
|
+
*/
|
|
103
|
+
ySpan: number
|
|
104
|
+
grid: Graph2DGrid
|
|
105
|
+
/** The paper's colour. */
|
|
106
|
+
background: Color
|
|
107
|
+
/** How opaque the paper is, 0–1. At 0 the graph draws over whatever is behind it. */
|
|
108
|
+
backgroundOpacity: number
|
|
109
|
+
/** How thick a plotted curve is drawn. */
|
|
110
|
+
lineWidth: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Resolved props --------------------------------------------------------
|
|
114
|
+
// Canonical, fully defaulted, interpolatable. Nothing here is optional, so the
|
|
115
|
+
// draw never re-derives a default and the tweens never test for one.
|
|
116
|
+
|
|
117
|
+
/** {@link Graph2DGrid} after {@link resolveGrid}. */
|
|
118
|
+
interface GridResolved {
|
|
119
|
+
showGrid: boolean
|
|
120
|
+
showMinorGrid: boolean
|
|
121
|
+
showAxes: boolean
|
|
122
|
+
showLabels: boolean
|
|
123
|
+
colorGrid: NormalizedColor
|
|
124
|
+
colorMinorGrid: NormalizedColor
|
|
125
|
+
colorAxis: NormalizedColor
|
|
126
|
+
colorLabel: NormalizedColor
|
|
127
|
+
lineWidth: number
|
|
128
|
+
axisWidth: number
|
|
129
|
+
fontSize: number
|
|
130
|
+
fontFamily: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The grid a node with nothing said about it draws — the same dark palette the
|
|
135
|
+
* 3D graph opens on, so the two read as one family when they share a scene.
|
|
136
|
+
*/
|
|
137
|
+
const DEFAULT_GRID: GridResolved = {
|
|
138
|
+
showGrid: true,
|
|
139
|
+
showMinorGrid: true,
|
|
140
|
+
showAxes: true,
|
|
141
|
+
showLabels: true,
|
|
142
|
+
colorGrid: resolveColor("#374151"),
|
|
143
|
+
colorMinorGrid: resolveColor("#1f2937"),
|
|
144
|
+
colorAxis: resolveColor("#6b7280"),
|
|
145
|
+
colorLabel: resolveColor("#9ca3af"),
|
|
146
|
+
lineWidth: 2,
|
|
147
|
+
axisWidth: 3,
|
|
148
|
+
fontSize: 22,
|
|
149
|
+
fontFamily: "",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** How far a curve may run past the box before it is cut away, in stroke widths. */
|
|
153
|
+
const CLIP_MARGIN = 1
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* How close two stacked numbers may sit before the ladder steps up, in font
|
|
157
|
+
* sizes.
|
|
158
|
+
*
|
|
159
|
+
* Only the vertical one lives here. Across, how close two numbers may sit
|
|
160
|
+
* depends on how *long* they are — five characters near the origin, fourteen at
|
|
161
|
+
* a deep zoom — so that ladder is built by `labelLadder`, which measures what it
|
|
162
|
+
* just produced and widens itself once if it has to. Down, a number is one line
|
|
163
|
+
* whatever it says.
|
|
164
|
+
*/
|
|
165
|
+
const LABEL_PITCH_Y = 2.6
|
|
166
|
+
const LABEL_GAP = 0.45
|
|
167
|
+
/**
|
|
168
|
+
* Narrowest the box a number is aligned within may be, in font sizes.
|
|
169
|
+
*
|
|
170
|
+
* Deliberately wider than the numbers an ordinary view carries, so a
|
|
171
|
+
* right-aligned `-1200` and a right-aligned `5` end in the same place. It only
|
|
172
|
+
* ever grows from here — see `labelWidthEm`.
|
|
173
|
+
*/
|
|
174
|
+
const LABEL_BOX = 6
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A 2D function grapher — any number of `y = f(x)` curves over a ruled plane,
|
|
178
|
+
* with numbered axes.
|
|
179
|
+
*
|
|
180
|
+
* <Graph2D
|
|
181
|
+
* width={960} height={540}
|
|
182
|
+
* equations={[{ id: 1, expression: "log(x)", color: "#3B82F6" }]}
|
|
183
|
+
* />
|
|
184
|
+
*
|
|
185
|
+
* The sibling of {@link Graph3D}, and deliberately built the same way: the same
|
|
186
|
+
* little expression language, the same equation tiles, the same match-by-id
|
|
187
|
+
* tween over the list. What it does *not* share is the hard part, which is not
|
|
188
|
+
* the maths but the drawing — see `curve.ts`, which exists because the obvious
|
|
189
|
+
* sampling loop cuts `log(x)` off in mid-air, silently drops `1/x` entirely on a
|
|
190
|
+
* fixed-point rasterizer, and puts a visible polygonal kink wherever a curve
|
|
191
|
+
* turns sharply.
|
|
192
|
+
*
|
|
193
|
+
* Every prop is a `@property`, so a constant, a `() => signal()` binding, and
|
|
194
|
+
* `set()`/`to()` on a ref all work and agree. The non-scalar props each declare
|
|
195
|
+
* a `mapper` (loose authored shape → one canonical internal shape) and a `tween`
|
|
196
|
+
* (how that shape interpolates) — without the tween, `to({ grid: … })` would hold
|
|
197
|
+
* the old value and snap at the end, which is the standard trap for an
|
|
198
|
+
* object-valued attribute.
|
|
199
|
+
*
|
|
200
|
+
* **The view is four plain numbers, not a transform.** `centerX`/`centerY` and
|
|
201
|
+
* `xSpan`/`ySpan` are each independently tweenable, which is what makes "pan
|
|
202
|
+
* across while zooming in" one command and what keeps every frame reproducible
|
|
203
|
+
* under scrubbing and export. The canvas's pan/zoom gesture writes exactly these
|
|
204
|
+
* four, so a view flown by hand is a view a `to` can animate afterwards.
|
|
205
|
+
*/
|
|
206
|
+
@node({
|
|
207
|
+
key: "graph2d",
|
|
208
|
+
parentKey: "node",
|
|
209
|
+
forkable: true,
|
|
210
|
+
layout: {
|
|
211
|
+
children: "freeform",
|
|
212
|
+
defaultWidthMode: "fixed",
|
|
213
|
+
defaultHeightMode: "fixed",
|
|
214
|
+
acceptsChildren: true,
|
|
215
|
+
},
|
|
216
|
+
seed: {
|
|
217
|
+
width: 960,
|
|
218
|
+
height: 540,
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
export class Graph2D extends Node2D<Graph2DProps> {
|
|
222
|
+
/**
|
|
223
|
+
* The plotted curves.
|
|
224
|
+
*
|
|
225
|
+
* The mapper compiles each expression **once per write** (memoised by source),
|
|
226
|
+
* so the draw — which re-runs every frame — never re-parses; and it folds
|
|
227
|
+
* `visible`/`opacity` into a single number, so hiding is expressible as a
|
|
228
|
+
* fade. An expression that doesn't parse is dropped and the rest still draw,
|
|
229
|
+
* which is what lets the inspector commit on every keystroke.
|
|
230
|
+
*
|
|
231
|
+
* The tween matches the two lists by `id` — see `lerpCurves`.
|
|
232
|
+
*/
|
|
233
|
+
@property({ default: [], mapper: resolveEquations, tween: lerpCurves })
|
|
234
|
+
declare equations: Graph2DEquation[]
|
|
235
|
+
|
|
236
|
+
@property({ default: 0 }) declare centerX: number
|
|
237
|
+
@property({ default: 0 }) declare centerY: number
|
|
238
|
+
@property({ default: 20 }) declare xSpan: number
|
|
239
|
+
@property({ default: 0 }) declare ySpan: number
|
|
240
|
+
|
|
241
|
+
/** The paper. Flags snap at the end of a tween; the rest lerp. */
|
|
242
|
+
@property({ default: {}, mapper: resolveGrid, tween: lerpGrid })
|
|
243
|
+
declare grid: Graph2DGrid
|
|
244
|
+
|
|
245
|
+
@property({ default: "#111827", mapper: resolveColor, tween: lerpColor })
|
|
246
|
+
declare background: Color
|
|
247
|
+
@property({ default: 1 }) declare backgroundOpacity: number
|
|
248
|
+
@property({ default: 5 }) declare lineWidth: number
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The node's one piece of memoisation, and it earns its keep: a graph that is
|
|
252
|
+
* holding still re-samples nothing at all. See `curve-cache.ts`.
|
|
253
|
+
*/
|
|
254
|
+
private readonly runs = new CurveRunsCache()
|
|
255
|
+
|
|
256
|
+
/** Where the node is looking, as the four props hold it. */
|
|
257
|
+
private get view(): PlaneView {
|
|
258
|
+
return {
|
|
259
|
+
centerX: this.centerX,
|
|
260
|
+
centerY: this.centerY,
|
|
261
|
+
xSpan: this.xSpan,
|
|
262
|
+
ySpan: this.ySpan,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Confines every mark to the node's own box — its own drawing as well as its
|
|
268
|
+
* children's, which is why this is `clipPathSelf` and not `clipSelf`.
|
|
269
|
+
*
|
|
270
|
+
* Belt and braces over the geometric clipping in `curve.ts` rather than a
|
|
271
|
+
* substitute for it: that clipping is what keeps astronomical coordinates away
|
|
272
|
+
* from the rasterizer, and this is what stops a stroke's half-width and its
|
|
273
|
+
* round joins from painting a hair outside the frame at the edges.
|
|
274
|
+
*/
|
|
275
|
+
protected override clipPathSelf(): Clip | null {
|
|
276
|
+
const width = this.layoutBounds.width
|
|
277
|
+
const height = this.layoutBounds.height
|
|
278
|
+
if (width <= 0 || height <= 0) return null
|
|
279
|
+
return new Clip().rect({ x: 0, y: 0, width, height })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- Drawing ------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
protected override renderSelf(ctx: RenderContext2D): void {
|
|
285
|
+
const width = this.layoutBounds.width
|
|
286
|
+
const height = this.layoutBounds.height
|
|
287
|
+
if (width <= 0 || height <= 0) return
|
|
288
|
+
|
|
289
|
+
const plane = resolvePlane(this.view, width, height)
|
|
290
|
+
const grid = this.grid as unknown as GridResolved
|
|
291
|
+
const box = planeBox(plane)
|
|
292
|
+
|
|
293
|
+
// One ladder per axis, chosen from the *scale* rather than from a fixed
|
|
294
|
+
// count: what decides how finely the plane is ruled is how close two numbers
|
|
295
|
+
// would sit on screen, which is the only thing that stays true as the view is
|
|
296
|
+
// flown around.
|
|
297
|
+
const xTicks = labelLadder(plane.xMin, plane.xMax, plane.scaleX, grid.fontSize)
|
|
298
|
+
const yTicks = tickLadder(
|
|
299
|
+
plane.yMin,
|
|
300
|
+
plane.yMax,
|
|
301
|
+
plane.scaleY,
|
|
302
|
+
grid.fontSize * LABEL_PITCH_Y
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
this.drawPlane(ctx, plane, grid, xTicks, yTicks)
|
|
306
|
+
if (grid.showLabels) this.drawLabels(ctx, plane, box, grid, xTicks, yTicks)
|
|
307
|
+
|
|
308
|
+
this.drawCurves(ctx, plane, box)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The paper, the two orders of ruling and the two axes — one rect, one shader,
|
|
313
|
+
* one draw, whatever the zoom.
|
|
314
|
+
*
|
|
315
|
+
* This was six hundred filled rects and three draws, and the rect count is the
|
|
316
|
+
* part that mattered: it changed as the ladder stepped, and the renderer keys
|
|
317
|
+
* its geometry cache by shape *index*, so a grid that gained a line invalidated
|
|
318
|
+
* every curve behind it. One shape means the curves' keys never move. See
|
|
319
|
+
* `plane-fill.ts` for why the built-in grid fill can't do this job and for what
|
|
320
|
+
* the phase uniforms are protecting against.
|
|
321
|
+
*/
|
|
322
|
+
private drawPlane(
|
|
323
|
+
ctx: RenderContext2D,
|
|
324
|
+
plane: ResolvedPlane,
|
|
325
|
+
grid: GridResolved,
|
|
326
|
+
xTicks: TickLadder,
|
|
327
|
+
yTicks: TickLadder
|
|
328
|
+
): void {
|
|
329
|
+
const style: PlaneStyle = {
|
|
330
|
+
paper: this.background as NormalizedColor,
|
|
331
|
+
paperOpacity: clamp01(this.backgroundOpacity),
|
|
332
|
+
showGrid: grid.showGrid,
|
|
333
|
+
showMinorGrid: grid.showMinorGrid,
|
|
334
|
+
showAxes: grid.showAxes,
|
|
335
|
+
colorGrid: grid.colorGrid,
|
|
336
|
+
colorMinorGrid: grid.colorMinorGrid,
|
|
337
|
+
colorAxis: grid.colorAxis,
|
|
338
|
+
lineWidth: grid.lineWidth,
|
|
339
|
+
axisWidth: grid.axisWidth,
|
|
340
|
+
}
|
|
341
|
+
// Nothing to paint at all — clear paper, no rulings, no axes — is a whole
|
|
342
|
+
// fragment pass over the box that would come out transparent.
|
|
343
|
+
if (!planeVisible(style)) return
|
|
344
|
+
|
|
345
|
+
ctx.draw(
|
|
346
|
+
new Graphics2D()
|
|
347
|
+
.rect({ x: 0, y: 0, width: plane.width, height: plane.height })
|
|
348
|
+
.fill(
|
|
349
|
+
Fills.shader(PLANE_SOURCE, {
|
|
350
|
+
uniforms: planeUniforms(plane, style, xTicks, yTicks),
|
|
351
|
+
coords: "local",
|
|
352
|
+
})
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The numbers along each axis.
|
|
359
|
+
*
|
|
360
|
+
* Pinned to the frame when their axis has left it, which is the behaviour
|
|
361
|
+
* every map and every grapher has and the reason it is worth the arithmetic:
|
|
362
|
+
* pan far enough that the origin is off screen and a graph whose numbers went
|
|
363
|
+
* with it is a graph you can no longer read. The row also flips to the other
|
|
364
|
+
* side of its axis when there is no room on the near one, so a view sitting
|
|
365
|
+
* just above the x-axis still numbers it.
|
|
366
|
+
*/
|
|
367
|
+
private drawLabels(
|
|
368
|
+
ctx: RenderContext2D,
|
|
369
|
+
plane: ResolvedPlane,
|
|
370
|
+
box: PixelRect,
|
|
371
|
+
grid: GridResolved,
|
|
372
|
+
xTicks: TickLadder,
|
|
373
|
+
yTicks: TickLadder
|
|
374
|
+
): void {
|
|
375
|
+
const labels = new Graphics2D()
|
|
376
|
+
const gap = grid.fontSize * LABEL_GAP
|
|
377
|
+
// Per axis, because the two ladders carry different numbers: a view can be
|
|
378
|
+
// ten units wide and a billionth of one tall.
|
|
379
|
+
const xBox = grid.fontSize * Math.max(LABEL_BOX, labelWidthEm(xTicks))
|
|
380
|
+
const yBox = grid.fontSize * Math.max(LABEL_BOX, labelWidthEm(yTicks))
|
|
381
|
+
const font = {
|
|
382
|
+
fontSize: grid.fontSize,
|
|
383
|
+
// An empty family is dropped rather than passed on: the renderer's font
|
|
384
|
+
// provider holds only what the manifest registered, so asking it for a
|
|
385
|
+
// family called `""` shapes against nothing and paints no glyphs. Left
|
|
386
|
+
// unset it inherits the scene's own default face.
|
|
387
|
+
...(grid.fontFamily === "" ? {} : { fontFamily: grid.fontFamily }),
|
|
388
|
+
wrap: false,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// The x-axis row: under the axis when there is room beneath it, over it when
|
|
392
|
+
// there isn't.
|
|
393
|
+
const axisY = clamp(toPy(plane, 0), box.bottom, box.top)
|
|
394
|
+
const under = axisY - gap - grid.fontSize >= box.bottom
|
|
395
|
+
const rowY = under ? axisY - gap - grid.fontSize / 2 : axisY + gap + grid.fontSize / 2
|
|
396
|
+
|
|
397
|
+
for (const x of xTicks.major) {
|
|
398
|
+
labels.text({
|
|
399
|
+
...font,
|
|
400
|
+
text: formatTick(x, xTicks.step),
|
|
401
|
+
x: toPx(plane, x),
|
|
402
|
+
y: rowY,
|
|
403
|
+
width: xBox,
|
|
404
|
+
textAlign: "center",
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The y-axis column: to the left of the axis when there is room, to the
|
|
409
|
+
// right when the axis is against the left edge.
|
|
410
|
+
// Room for a couple of characters, not for the whole alignment box: the box
|
|
411
|
+
// is deliberately wide (a right-aligned `-1200` and a right-aligned `5` end
|
|
412
|
+
// in the same place), and demanding all of it would push the column to the
|
|
413
|
+
// wrong side of an axis that has plenty of room for the numbers it holds.
|
|
414
|
+
const axisX = clamp(toPx(plane, 0), box.left, box.right)
|
|
415
|
+
const leftOf = axisX - gap - grid.fontSize * 1.5 >= box.left
|
|
416
|
+
const columnX = leftOf ? axisX - gap - yBox / 2 : axisX + gap + yBox / 2
|
|
417
|
+
|
|
418
|
+
// Zero belongs to the row, not the column: drawn in both it would be two
|
|
419
|
+
// numbers stacked on the origin. Skipped only when the row is actually
|
|
420
|
+
// showing it, so a view with the y-axis off screen still numbers its own 0.
|
|
421
|
+
const rowHasZero = plane.xMin <= 0 && 0 <= plane.xMax
|
|
422
|
+
|
|
423
|
+
for (const y of yTicks.major) {
|
|
424
|
+
if (y === 0 && rowHasZero) continue
|
|
425
|
+
labels.text({
|
|
426
|
+
...font,
|
|
427
|
+
text: formatTick(y, yTicks.step),
|
|
428
|
+
x: columnX,
|
|
429
|
+
y: toPy(plane, y),
|
|
430
|
+
width: yBox,
|
|
431
|
+
textAlign: leftOf ? "right" : "left",
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
ctx.draw(labels.fill(Fills.color(grid.colorLabel)))
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The curves themselves, one stroked polyline per run.
|
|
440
|
+
*
|
|
441
|
+
* One `draw` per run rather than one per curve, and that is not an
|
|
442
|
+
* optimisation to undo: shapes chained before a `stroke()` are combined into a
|
|
443
|
+
* single surface, and stroking a union of disjoint open paths paints nothing —
|
|
444
|
+
* so a curve that leaves the frame and comes back would lose *both* halves.
|
|
445
|
+
* `ChartLine` learned the same thing about its spotlight runs.
|
|
446
|
+
*/
|
|
447
|
+
private drawCurves(
|
|
448
|
+
ctx: RenderContext2D,
|
|
449
|
+
plane: ResolvedPlane,
|
|
450
|
+
box: PixelRect
|
|
451
|
+
): void {
|
|
452
|
+
const equations = this.equations as unknown as CurveResolved[]
|
|
453
|
+
const weight = Math.max(0.1, this.lineWidth)
|
|
454
|
+
const clip = inflate(box, weight * CLIP_MARGIN)
|
|
455
|
+
const key = runsKeyOf(plane, clip)
|
|
456
|
+
|
|
457
|
+
// What actually has to be sampled, counted before any of it is — so a frame
|
|
458
|
+
// where five of six curves are unchanged hands the whole evaluation budget
|
|
459
|
+
// to the one that moved rather than reserving five sixths of it for work
|
|
460
|
+
// that is already done.
|
|
461
|
+
const live: string[] = []
|
|
462
|
+
let misses = 0
|
|
463
|
+
for (const equation of equations) {
|
|
464
|
+
// Fully faded out (hidden, or the far end of a cross-fade): sample nothing.
|
|
465
|
+
if (equation.opacity <= 0.001) continue
|
|
466
|
+
live.push(equation.id)
|
|
467
|
+
if (!this.runs.holds(equation.id, equation.sample, key)) misses++
|
|
468
|
+
}
|
|
469
|
+
const budget = misses > 0 ? sampleBudget(misses) : undefined
|
|
470
|
+
this.runs.retain(live)
|
|
471
|
+
|
|
472
|
+
for (const equation of equations) {
|
|
473
|
+
if (equation.opacity <= 0.001) continue
|
|
474
|
+
|
|
475
|
+
const stroke = {
|
|
476
|
+
weight,
|
|
477
|
+
fill: Fills.color(equation.color, {
|
|
478
|
+
opacity: clamp01(equation.opacity),
|
|
479
|
+
}),
|
|
480
|
+
// Round on both counts because this is a *curve*: a miter join on a
|
|
481
|
+
// sharp turn throws a spike, and a butt cap leaves a visible flat end
|
|
482
|
+
// wherever a run stops at a domain edge.
|
|
483
|
+
cap: "round" as const,
|
|
484
|
+
join: "round" as const,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const runs = this.runs.runs(
|
|
488
|
+
equation.id,
|
|
489
|
+
equation.sample,
|
|
490
|
+
key,
|
|
491
|
+
plane,
|
|
492
|
+
clip,
|
|
493
|
+
budget
|
|
494
|
+
)
|
|
495
|
+
for (const run of runs) {
|
|
496
|
+
if (run.length < 2) continue
|
|
497
|
+
ctx.draw(new Graphics2D().line({ points: run, radius: 0 }).stroke(stroke))
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// --- Mappers & tweens ------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
/** Mapper for {@link Graph2D.equations}. */
|
|
506
|
+
function resolveEquations(
|
|
507
|
+
input: Graph2DEquation[] | undefined
|
|
508
|
+
): CurveResolved[] {
|
|
509
|
+
if (!input) return []
|
|
510
|
+
const out: CurveResolved[] = []
|
|
511
|
+
for (const equation of input) {
|
|
512
|
+
const sample = compileCurveCached(equation.expression)
|
|
513
|
+
if (!sample) continue // unparseable or empty — drop it, keep the rest
|
|
514
|
+
out.push({
|
|
515
|
+
id: String(equation.id),
|
|
516
|
+
sample,
|
|
517
|
+
color: resolveColor(equation.color),
|
|
518
|
+
opacity: equation.visible === false ? 0 : (equation.opacity ?? 1),
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
return out
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Mapper for {@link Graph2D.grid}.
|
|
526
|
+
*
|
|
527
|
+
* Merges onto `previous` rather than onto the bare defaults, which is what makes
|
|
528
|
+
* a partial write mean "change this much and leave the rest". It matters most on
|
|
529
|
+
* a `to` command, whose target states only the fields that command touches: a
|
|
530
|
+
* tween that dims the grid must not silently hand the axis numbers back to their
|
|
531
|
+
* default size on the way.
|
|
532
|
+
*/
|
|
533
|
+
function resolveGrid(
|
|
534
|
+
input: Graph2DGrid | undefined,
|
|
535
|
+
previous?: GridResolved
|
|
536
|
+
): GridResolved {
|
|
537
|
+
const base = previous ?? DEFAULT_GRID
|
|
538
|
+
return {
|
|
539
|
+
showGrid: input?.showGrid ?? base.showGrid,
|
|
540
|
+
showMinorGrid: input?.showMinorGrid ?? base.showMinorGrid,
|
|
541
|
+
showAxes: input?.showAxes ?? base.showAxes,
|
|
542
|
+
showLabels: input?.showLabels ?? base.showLabels,
|
|
543
|
+
colorGrid: colorOr(input?.colorGrid, base.colorGrid),
|
|
544
|
+
colorMinorGrid: colorOr(input?.colorMinorGrid, base.colorMinorGrid),
|
|
545
|
+
colorAxis: colorOr(input?.colorAxis, base.colorAxis),
|
|
546
|
+
colorLabel: colorOr(input?.colorLabel, base.colorLabel),
|
|
547
|
+
lineWidth: input?.lineWidth ?? base.lineWidth,
|
|
548
|
+
axisWidth: input?.axisWidth ?? base.axisWidth,
|
|
549
|
+
fontSize: input?.fontSize ?? base.fontSize,
|
|
550
|
+
fontFamily: input?.fontFamily ?? base.fontFamily,
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function colorOr(
|
|
555
|
+
input: Color | undefined,
|
|
556
|
+
fallback: NormalizedColor
|
|
557
|
+
): NormalizedColor {
|
|
558
|
+
return input === undefined ? fallback : resolveColor(input)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Tween for {@link Graph2D.grid}. */
|
|
562
|
+
function lerpGrid(
|
|
563
|
+
from: GridResolved,
|
|
564
|
+
to: GridResolved,
|
|
565
|
+
t: number
|
|
566
|
+
): GridResolved {
|
|
567
|
+
return {
|
|
568
|
+
showGrid: snapFlag(from.showGrid, to.showGrid, t),
|
|
569
|
+
showMinorGrid: snapFlag(from.showMinorGrid, to.showMinorGrid, t),
|
|
570
|
+
showAxes: snapFlag(from.showAxes, to.showAxes, t),
|
|
571
|
+
showLabels: snapFlag(from.showLabels, to.showLabels, t),
|
|
572
|
+
colorGrid: lerpColor(from.colorGrid, to.colorGrid, t),
|
|
573
|
+
colorMinorGrid: lerpColor(from.colorMinorGrid, to.colorMinorGrid, t),
|
|
574
|
+
colorAxis: lerpColor(from.colorAxis, to.colorAxis, t),
|
|
575
|
+
colorLabel: lerpColor(from.colorLabel, to.colorLabel, t),
|
|
576
|
+
lineWidth: lerpNumber(from.lineWidth, to.lineWidth, t),
|
|
577
|
+
axisWidth: lerpNumber(from.axisWidth, to.axisWidth, t),
|
|
578
|
+
fontSize: lerpNumber(from.fontSize, to.fontSize, t),
|
|
579
|
+
// A face has no halfway point, so it changes once the tween lands.
|
|
580
|
+
fontFamily: snapValue(from.fontFamily, to.fontFamily, t),
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function clamp01(value: number): number {
|
|
585
|
+
return Number.isFinite(value) ? clamp(value, 0, 1) : 1
|
|
586
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Graph 2D node — a **native** node type (see `node-types.ts` /
|
|
3
|
+
* `NODE_CLASS_BY_KEY`) like the 3D graph beside it: built, themed and animated
|
|
4
|
+
* from the inspector rather than loaded as a code package.
|
|
5
|
+
*
|
|
6
|
+
* It plots any number of `y = f(x)` curves over a ruled plane, sampling each
|
|
7
|
+
* from an expression the author types. The language is shared with the 3D graph
|
|
8
|
+
* (`nodes/graph-kit/expression`) and lives in this package rather than in a maths
|
|
9
|
+
* library for the same two reasons: an expression is a value stored in a scene
|
|
10
|
+
* document, so compiling it as JavaScript would make every scene file an
|
|
11
|
+
* execution vector; and a curve is a few thousand evaluations per frame, which a
|
|
12
|
+
* general evaluator would not survive.
|
|
13
|
+
*
|
|
14
|
+
* What is genuinely this node's own is `curve.ts`. Turning `y = f(x)` into
|
|
15
|
+
* polylines is where a grapher is actually won or lost — the naive loop cuts
|
|
16
|
+
* `log(x)` off in mid-air, silently drops `1/x` on a fixed-point rasterizer, and
|
|
17
|
+
* kinks visibly wherever a curve turns — and that module is the four fixes for
|
|
18
|
+
* it, written out with the reasoning.
|
|
19
|
+
*
|
|
20
|
+
* Unlike the 3D graph there *is* a pointer here: double-clicking the node opens a
|
|
21
|
+
* pan/zoom session on the canvas. It stays reproducible because what the gesture
|
|
22
|
+
* writes is the same four plain numbers a `to` command animates — see
|
|
23
|
+
* `plane-camera.ts`, which holds the arithmetic and no DOM.
|
|
24
|
+
*/
|
|
25
|
+
export * from "./curve";
|
|
26
|
+
export * from "./curve-cache";
|
|
27
|
+
export * from "./curve-error";
|
|
28
|
+
export * from "./graph2d";
|
|
29
|
+
export * from "./plane";
|
|
30
|
+
export * from "./plane-fill";
|
|
31
|
+
export * from "./shared";
|