@solidrt/2d 0.0.55 → 0.0.57

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.
@@ -0,0 +1,713 @@
1
+ // The 2d camera: Godot's Camera2D and Three's MapControls in one control
2
+ // over the layers' shared CameraUpdate - a pose (the world point at a
3
+ // viewport pivot, zoom, rotation), the contain clamp against world
4
+ // bounds, anchored zoom, eased glides (a wheel notch, glideTo, fit),
5
+ // follow with damping and a dead zone, drag inertia, rotation about the
6
+ // pivot - extracted so apps stop rebuilding the same hundred lines.
7
+ //
8
+ // The control is pure (ARCHITECTURE.md: controls through an abstraction,
9
+ // never direct event handling): it consumes three axes, `pan` (vec2),
10
+ // `zoom` (axis) and `roll` (axis), in device-free units, and exposes
11
+ // pose verbs for scripted moves. What drives the axes is the app's
12
+ // business - an input map over the view's pointer feed, a pad, a debug
13
+ // command - and `camera2dBindings` in input.ts is the standard wiring
14
+ // (drag pans, pinch and wheel zoom, twist rolls). This module imports no
15
+ // GUI or runtime module, so checks/camera2d-check.ts runs it headless.
16
+ //
17
+ // Conventions, decided against Godot's Camera2D, Unity's Cinemachine and
18
+ // Three's MapControls (plus the map/whiteboard libraries for the canvas
19
+ // half): the pose is "world point at the pivot" with the pivot defaulting
20
+ // to the viewport center (Godot's drag-center anchor, Cinemachine's screen
21
+ // position, a map's center); bounds CONTAIN the view and center an axis
22
+ // whose view is wider than the world (Cinemachine's confiner skeleton,
23
+ // tldraw's contain origin - Godot's left-wins is the worse behavior);
24
+ // limits ignore rotation, clamping the unrotated view rect (Godot's rule);
25
+ // every ease is exponential and frame-rate independent, zoom eased in log
26
+ // space so a long glide reads evenly.
27
+ //
28
+ // Input units: a `pan` delta is finger travel in viewport heights (the
29
+ // pointer feed's unit), applied as content travel - the world follows the
30
+ // finger; a `pan` rate slides at PAN_RATE viewport heights per second. A
31
+ // `zoom` delta is octaves about the gesture's focal point (a fraction of
32
+ // the viewport): bracketed by a gesture (a pinch) it applies exactly,
33
+ // unbracketed (a wheel notch) it retargets an eased glide, which is how
34
+ // the control tells a finger from an impulse. A `roll` delta is turns. A
35
+ // pan gesture's begin stops any glide (a finger landing on a gliding view
36
+ // holds it), its end flings with the drag's velocity.
37
+ //
38
+ // Pose is plain mutable state; a nudge or a verb pushes it at once (the
39
+ // next paint carries the new camera, no frame loop needed for a drag),
40
+ // and update(dt) advances glides, follow, inertia and the axis rates from
41
+ // the app's own onFrame. `active()` is the reactive frame-loop gate (a
42
+ // glide, fling, fit or follow in flight, or a rate driving); a resting
43
+ // camera costs nothing. update() pushes the pose to the layers only when
44
+ // it changed (one setCamera per driven layer: a shared-params write,
45
+ // however many sprites exist) and reports that, so per-frame dependents
46
+ // (labels re-projected via projectCamera) can follow the camera without
47
+ // recomputing every frame.
48
+
49
+ import { createMemo, createSignal, untrack } from "@solidjs/signals"
50
+ import { createAxes } from "@solidrt/core/input"
51
+ import type { Axes, Vec2 } from "@solidrt/core/input"
52
+ import { projectCamera, unprojectCamera } from "./camera.ts"
53
+ import type { CameraUpdate } from "./camera.ts"
54
+
55
+ // Glide rate toward a pending zoom or pose, e-foldings per second: high
56
+ // enough that a wheel notch reads as one push, low enough to look smooth.
57
+ const GLIDE_EASE = 9
58
+ // A glide lands when the remaining zoom gap is under this (relative) ...
59
+ const GLIDE_EPSILON = 0.002
60
+ // ... and the remaining travel is under this many screen pixels. Also the
61
+ // follow's settle threshold and, times the fling decay, the speed a fling
62
+ // stops at (the distance it would still travel).
63
+ const LAND_PX = 0.5
64
+ // Follow damping, e-foldings per second (Godot's default
65
+ // position_smoothing_speed).
66
+ const FOLLOW_EASE = 5
67
+ // Inertia after a drag release: velocity decay in e-foldings per second. A
68
+ // 1500 px/s flick travels 500 px; iOS scrolling decelerates at ~2, maps
69
+ // are snappier.
70
+ const FLING_DECAY = 3
71
+ // Release speeds under this (px/s) do not fling: a finger that stopped
72
+ // before lifting leaves the view where it is.
73
+ const FLING_MIN_SPEED = 50
74
+ // Per-update EMA weight of the drag velocity estimate: heavy on the newest
75
+ // frame so a direction change registers within a frame or two.
76
+ const VELOCITY_SMOOTH = 0.5
77
+ // Frame time assumed for the drag delta still pending at a release when the
78
+ // release arrives in the same frame as the last move.
79
+ const FALLBACK_DT = 1 / 60
80
+ // Rate axes at full deflection, per second: viewport heights of pan,
81
+ // octaves of zoom (1 = the zoom doubles each second), turns of roll.
82
+ const PAN_RATE = 1
83
+ const ZOOM_RATE = 1
84
+ const ROLL_RATE = 0.5
85
+
86
+ /** What a 2d camera drives: anything with the layers' `setCamera`. */
87
+ export type Camera2dTarget = { setCamera(update: CameraUpdate): void }
88
+
89
+ export type Camera2dPose = {
90
+ x?: number
91
+ y?: number
92
+ zoom?: number
93
+ rotation?: number
94
+ }
95
+
96
+ export type Rect2d = { x: number; y: number; width: number; height: number }
97
+
98
+ export type Camera2dOptions = Camera2dPose & {
99
+ /** Viewport size in layer pixels, read whenever the camera needs it (a
100
+ * resize re-clamps at the next update). Return zeros while unknown -
101
+ * clamping and fitting wait for a real size. */
102
+ viewport: () => { width: number; height: number }
103
+ /** World bounds (origin 0,0) the view is kept inside: an axis whose view
104
+ * is wider than the world centers. Absent = unbounded. */
105
+ world?: { width: number; height: number }
106
+ /** Zoom range. minZoom defaults to the fit zoom (whole world visible)
107
+ * when `world` is given, else to no lower bound; maxZoom to no upper
108
+ * bound. An explicit maxZoom below the fit zoom wins, the world then
109
+ * floats centered. */
110
+ minZoom?: number
111
+ maxZoom?: number
112
+ /** Where in the viewport the camera's world point sits, as fractions of
113
+ * its size; default the center. glideTo and follow land their point
114
+ * here and rotation turns about it. */
115
+ pivot?: { x: number; y: number }
116
+ /** Follow dead zone as fractions of the viewport, centered on the pivot:
117
+ * the target roams inside it without moving the camera. Default 0,0. */
118
+ deadZone?: { width: number; height: number }
119
+ /** Multipliers over the built-in pan, zoom (pinch, wheel and rate) and
120
+ * roll sensitivities. */
121
+ panSpeed?: number
122
+ zoomSpeed?: number
123
+ rollSpeed?: number
124
+ /** Multiplier over the built-in follow damping. */
125
+ followSpeed?: number
126
+ /** Whether a drag release keeps the view gliding (default true). */
127
+ inertia?: boolean
128
+ }
129
+
130
+ export type Camera2dAxes = { pan: "vec2"; zoom: "axis"; roll: "axis" }
131
+
132
+ export type Camera2d = {
133
+ /** The pose as the layers receive it (a fresh object per call): the
134
+ * argument for projectCamera/unprojectCamera. */
135
+ camera(): CameraUpdate
136
+ /** Merge a pose in (clamps apply) and push it. A snap: an x/y/zoom
137
+ * write cancels a glide or fling in flight; rotation alone leaves them
138
+ * running. */
139
+ set(pose: Camera2dPose): void
140
+ /** Pan the content by a screen delta (a drag: positive dx slides the
141
+ * world rightward) and push. Feeds the inertia estimate; release()
142
+ * flings. */
143
+ panBy(dx: number, dy: number): void
144
+ /** End of a drag: keep gliding with the drag's velocity (inertia). */
145
+ release(): void
146
+ /** Zoom by `factor` about a screen point - the world point under it
147
+ * stays under it - and push. `glide` eases there instead (a wheel
148
+ * notch): notches compound on the pending target, so a fast scroll is
149
+ * one long push. */
150
+ zoomAt(sx: number, sy: number, factor: number, opts?: { glide?: boolean }): void
151
+ /** Turn by radians about the pivot and push. */
152
+ rollBy(angle: number): void
153
+ /** Ease the pose until world (x, y) sits at the pivot, at `zoom` (default
154
+ * the current zoom). Cancels a follow. */
155
+ glideTo(x: number, y: number, zoom?: number): void
156
+ /** Show a world rect (default the world) whole, centered in the
157
+ * viewport, snapping or gliding. Waits for the viewport if unknown. */
158
+ fit(rect?: Rect2d, opts?: { glide?: boolean }): void
159
+ /** Keep world (x, y) at the pivot through the dead zone and damping;
160
+ * call again whenever the target moves (every frame for a moving one).
161
+ * Cancels a pose glide (glideTo, fit) and a fling; a wheel zoom keeps
162
+ * gliding - zoom and follow are separate axes. */
163
+ follow(x: number, y: number): void
164
+ unfollow(): void
165
+ /** Stop a glide or fling in flight (a press landing). */
166
+ interrupt(): void
167
+ /** The world-space bounding box of what the viewport shows. */
168
+ viewRect(): Rect2d
169
+ /** Advance glides, follow, inertia and the axis rates, then push the
170
+ * pose to the layers if it changed. Call from onFrame with the frame's
171
+ * dt in seconds; returns whether the pose changed since the previous
172
+ * update (nudges and verbs included). */
173
+ update(dt: number): boolean
174
+ /**
175
+ * Reactive: whether update() still has work - a glide, fling or
176
+ * deferred fit in flight, a follow engaged, a write not yet pushed, or
177
+ * an axis rate driving. The frame-loop gate: run update(dt) on frames
178
+ * while true, and nothing while false (a resting camera costs no frames).
179
+ */
180
+ active(): boolean
181
+ /** The input abstraction: `pan` (vec2, viewport heights of travel, the
182
+ * world follows), `zoom` (axis, octaves, positive in) and `roll` (axis,
183
+ * turns). An input map drives it by name (InputMap.drive); an app may
184
+ * add sources and nudge directly. */
185
+ axes: Axes<Camera2dAxes>
186
+ }
187
+
188
+ type Glide = { kind: "anchor"; target: number; sx: number; sy: number; wx: number; wy: number } | { kind: "pose"; x: number; y: number; zoom: number }
189
+
190
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
191
+
192
+ function positive(what: string, v: number): void {
193
+ if (!(Number.isFinite(v) && v > 0)) throw new Error(`createCamera2d: ${what} must be a positive number, got ${v}`)
194
+ }
195
+
196
+ function finite(what: string, v: number | undefined): void {
197
+ if (v !== undefined && !Number.isFinite(v)) throw new Error(`createCamera2d: ${what} must be a finite number, got ${v}`)
198
+ }
199
+
200
+ function checkPose(verb: string, pose: Camera2dPose): void {
201
+ finite(`${verb} x`, pose.x)
202
+ finite(`${verb} y`, pose.y)
203
+ finite(`${verb} rotation`, pose.rotation)
204
+ if (pose.zoom !== undefined) positive(`${verb} zoom`, pose.zoom)
205
+ }
206
+
207
+ function checkFraction(what: string, v: number): void {
208
+ if (!(Number.isFinite(v) && v >= 0 && v <= 1)) throw new Error(`createCamera2d: ${what} must be within 0..1, got ${v}`)
209
+ }
210
+
211
+ /** The non-pose options, validated at creation and again at every set()
212
+ * (the live-option re-clamp entry). */
213
+ function checkOptions(options: Camera2dOptions): void {
214
+ if (options.world) {
215
+ positive("world.width", options.world.width)
216
+ positive("world.height", options.world.height)
217
+ }
218
+ if (options.minZoom !== undefined) positive("minZoom", options.minZoom)
219
+ if (options.maxZoom !== undefined) positive("maxZoom", options.maxZoom)
220
+ if (options.minZoom !== undefined && options.maxZoom !== undefined && options.minZoom > options.maxZoom) {
221
+ throw new Error(`createCamera2d: minZoom ${options.minZoom} exceeds maxZoom ${options.maxZoom}`)
222
+ }
223
+ finite("pivot.x", options.pivot?.x)
224
+ finite("pivot.y", options.pivot?.y)
225
+ checkFraction("deadZone.width", options.deadZone?.width ?? 0)
226
+ checkFraction("deadZone.height", options.deadZone?.height ?? 0)
227
+ }
228
+
229
+ /**
230
+ * Create a 2d camera driving `target`'s camera, where `target` is a sprite
231
+ * or record layer's view (or several: a view and its overlay layer's
232
+ * share one camera), or anything else with the layers' `setCamera`, such
233
+ * as a signal setter feeding `<TileLayer camera>`. The initial pose
234
+ * applies immediately: the world fitted when `world` is given and no
235
+ * `zoom`. The pose options are initial values; every other option is read
236
+ * where it applies, so a caller holding the options object (or a props
237
+ * object handing out getters) changes bounds, pivot, dead zone and rates
238
+ * live - `set({})` re-clamps and pushes the pose under new bounds. In a
239
+ * component tree, prefer `<Camera2d>`: it drives the nearest view through
240
+ * context and takes an input map as a prop.
241
+ */
242
+ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], options: Camera2dOptions): Camera2d {
243
+ let targets = Array.isArray(target) ? target : [target]
244
+ if (typeof options.viewport !== "function") throw new Error("createCamera2d: viewport must be a function returning { width, height }")
245
+ checkOptions(options)
246
+ checkPose("", options)
247
+ // Everything below the pose is read from `options` where it applies
248
+ // (the 3d orbit camera's rule): a prop object's getters stay live, so a
249
+ // clamp, a pivot or a rate follows its prop, and set({}) re-clamps the
250
+ // pose under new bounds.
251
+ let world = () => options.world ?? null
252
+ let maxZoom = () => options.maxZoom ?? Infinity
253
+ let pivotFx = () => options.pivot?.x ?? 0.5
254
+ let pivotFy = () => options.pivot?.y ?? 0.5
255
+ let deadW = () => options.deadZone?.width ?? 0
256
+ let deadH = () => options.deadZone?.height ?? 0
257
+ let panSpeed = () => options.panSpeed ?? 1
258
+ let zoomExponent = () => options.zoomSpeed ?? 1
259
+ let rollSpeed = () => options.rollSpeed ?? 1
260
+ let followEase = () => FOLLOW_EASE * (options.followSpeed ?? 1)
261
+ let inertia = () => options.inertia ?? true
262
+
263
+ let w0 = world()
264
+ let x = options.x ?? (w0 ? w0.width / 2 : 0)
265
+ let y = options.y ?? (w0 ? w0.height / 2 : 0)
266
+ let zoom = options.zoom ?? 1
267
+ let rotation = options.rotation ?? 0
268
+
269
+ // The viewport as last read; zeros mean unknown (nothing clamps or fits
270
+ // until a real size arrives).
271
+ let vw = 0
272
+ let vh = 0
273
+ let known = () => vw > 0 && vh > 0
274
+ let readViewport = () => {
275
+ let v = options.viewport()
276
+ if (!(Number.isFinite(v.width) && Number.isFinite(v.height) && v.width >= 0 && v.height >= 0)) {
277
+ throw new Error(`createCamera2d: viewport() must return non-negative finite width/height, got ${v.width}x${v.height}`)
278
+ }
279
+ vw = v.width
280
+ vh = v.height
281
+ }
282
+ let px = () => vw * pivotFx()
283
+ let py = () => vh * pivotFy()
284
+
285
+ let minZoom = () => {
286
+ let w = world()
287
+ let fit = w && known() ? Math.min(vw / w.width, vh / w.height) : 0
288
+ return Math.min(options.minZoom ?? fit, maxZoom())
289
+ }
290
+
291
+ // The contain clamp on an unrotated view rect (Godot's rule: limits
292
+ // ignore rotation): returns the pose's x/y, moved inside the world.
293
+ let contain = (cx: number, cy: number, z: number): [number, number] => {
294
+ let w = world()
295
+ if (!w || !known()) return [cx, cy]
296
+ let viewW = vw / z
297
+ let viewH = vh / z
298
+ let left = cx - px() / z
299
+ let top = cy - py() / z
300
+ left = viewW >= w.width ? (w.width - viewW) / 2 : clampNum(left, 0, w.width - viewW)
301
+ top = viewH >= w.height ? (w.height - viewH) / 2 : clampNum(top, 0, w.height - viewH)
302
+ return [left + px() / z, top + py() / z]
303
+ }
304
+ let clamp = () => {
305
+ zoom = clampNum(zoom, minZoom(), maxZoom())
306
+ ;[x, y] = contain(x, y, zoom)
307
+ }
308
+
309
+ let camera = (): CameraUpdate => ({ x, y, zoom, rotation, pivotX: px(), pivotY: py() })
310
+ // Pose x/y that put world (wx, wy) under screen (sx, sy) at zoom z:
311
+ // the inverse of projectCamera solved for the camera point.
312
+ let anchorPose = (wx: number, wy: number, sx: number, sy: number, z: number): [number, number] => {
313
+ let c = Math.cos(rotation)
314
+ let s = Math.sin(rotation)
315
+ let dx = sx - px()
316
+ let dy = sy - py()
317
+ return [wx - (dx * c + dy * s) / z, wy - (dy * c - dx * s) / z]
318
+ }
319
+ // Slide the content by a screen delta: the camera point moves the
320
+ // opposite way, the delta un-rotated into world axes.
321
+ let shift = (dx: number, dy: number) => {
322
+ let c = Math.cos(rotation)
323
+ let s = Math.sin(rotation)
324
+ x -= (dx * c + dy * s) / zoom
325
+ y -= (dy * c - dx * s) / zoom
326
+ }
327
+
328
+ let dirty = false
329
+ // Whether the pose changed since the last update() (pushes included).
330
+ let changed = false
331
+ let glide: Glide | null = null
332
+ let fling: { vx: number; vy: number } | null = null
333
+ let followAt: { x: number; y: number } | null = null
334
+ // A follow that reached its target stops writing until something moves.
335
+ let followSettled = false
336
+ // Drag deltas since the last update, and the smoothed velocity (px/s).
337
+ let dragDx = 0
338
+ let dragDy = 0
339
+ let vx = 0
340
+ let vy = 0
341
+ let lastDt = FALLBACK_DT
342
+ // A fit that waits for the viewport (the default pose when a world is
343
+ // given, or an explicit fit() before the size is known).
344
+ let pendingFit: { rect: Rect2d | null; glide: boolean } | null = options.zoom === undefined && w0 ? { rect: null, glide: false } : null
345
+
346
+ let touch = () => {
347
+ dirty = true
348
+ followSettled = false
349
+ }
350
+ // A pan gesture in flight keeps the camera active: update(dt) then folds
351
+ // the drag's deltas into the velocity estimate every frame, so the fling
352
+ // at release carries the finger's speed and not the whole drag's travel
353
+ // over one assumed frame.
354
+ let dragging = false
355
+ let busy = () => dirty || dragging || glide !== null || fling !== null || followAt !== null || pendingFit !== null
356
+ // The motion's half of active(), a signal every public entry refreshes
357
+ // (ownedWrite: entries run from component bodies and handlers alike).
358
+ let [motionActive, setMotionActive] = createSignal(false, { ownedWrite: true })
359
+ let notify = () => {
360
+ let now = busy()
361
+ if (now !== untrack(motionActive)) setMotionActive(now)
362
+ }
363
+ let interrupt = () => {
364
+ glide = null
365
+ fling = null
366
+ notify()
367
+ }
368
+ let apply = () => {
369
+ let update = camera()
370
+ for (let t of targets) t.setCamera(update)
371
+ changed = true
372
+ }
373
+ // Push a pending write now: the synchronous half of update(dt).
374
+ let flush = () => {
375
+ if (!dirty) return
376
+ dirty = false
377
+ apply()
378
+ notify()
379
+ }
380
+
381
+ let zoomTo = (sx: number, sy: number, next: number) => {
382
+ let [wx, wy] = unprojectCamera(camera(), sx, sy)
383
+ zoom = clampNum(next, minZoom(), maxZoom())
384
+ ;[x, y] = anchorPose(wx, wy, sx, sy, zoom)
385
+ clamp()
386
+ touch()
387
+ }
388
+ let glideTo = (tx: number, ty: number, tz?: number) => {
389
+ finite("glideTo x", tx)
390
+ finite("glideTo y", ty)
391
+ if (tz !== undefined) positive("glideTo zoom", tz)
392
+ readViewport()
393
+ fling = null
394
+ followAt = null
395
+ let z = clampNum(tz ?? zoom, minZoom(), maxZoom())
396
+ let [cx, cy] = contain(tx, ty, z)
397
+ glide = { kind: "pose", x: cx, y: cy, zoom: z }
398
+ }
399
+ let fitNow = (rect: Rect2d | null, ease: boolean) => {
400
+ let w = world()
401
+ let r = rect ?? (w ? { x: 0, y: 0, width: w.width, height: w.height } : null)
402
+ if (!r) throw new Error("createCamera2d: fit() needs a rect when the camera has no world")
403
+ let z = clampNum(Math.min(vw / r.width, vh / r.height), minZoom(), maxZoom())
404
+ let [nx, ny] = anchorPose(r.x + r.width / 2, r.y + r.height / 2, vw / 2, vh / 2, z)
405
+ if (ease) {
406
+ glideTo(nx, ny, z)
407
+ return
408
+ }
409
+ interrupt()
410
+ x = nx
411
+ y = ny
412
+ zoom = z
413
+ clamp()
414
+ touch()
415
+ }
416
+ let panBy = (dx: number, dy: number) => {
417
+ readViewport()
418
+ interrupt()
419
+ shift(dx, dy)
420
+ clamp()
421
+ dragDx += dx
422
+ dragDy += dy
423
+ touch()
424
+ }
425
+ let release = () => {
426
+ // Fold the delta still pending from the release's own frame in with
427
+ // the last frame time, then start from rest either way. The fling
428
+ // gates on the SMOOTHED velocity too: a finger that stopped before
429
+ // lifting has let it decay over the still frames, and the stray delta
430
+ // the release frame may still carry (the resampler's last correction)
431
+ // must not fling the view on its own.
432
+ let smoothed = Math.hypot(vx, vy)
433
+ let rvx = vx + (dragDx / lastDt - vx) * VELOCITY_SMOOTH
434
+ let rvy = vy + (dragDy / lastDt - vy) * VELOCITY_SMOOTH
435
+ dragDx = 0
436
+ dragDy = 0
437
+ vx = 0
438
+ vy = 0
439
+ if (!inertia() || followAt !== null) return
440
+ if (smoothed < FLING_MIN_SPEED || Math.hypot(rvx, rvy) < FLING_MIN_SPEED) return
441
+ fling = { vx: rvx, vy: rvy }
442
+ }
443
+ let zoomAt = (sx: number, sy: number, factor: number, ease: boolean) => {
444
+ positive("zoomAt factor", factor)
445
+ readViewport()
446
+ if (!ease) {
447
+ interrupt()
448
+ zoomTo(sx, sy, zoom * factor)
449
+ return
450
+ }
451
+ fling = null
452
+ if (glide !== null && glide.kind === "pose") glide = null
453
+ // Notches compound on the pending target, so a fast scroll is one
454
+ // long push; the anchor is the world point under the pointer now.
455
+ let from = glide !== null ? glide.target : zoom
456
+ let target = clampNum(from * factor, minZoom(), maxZoom())
457
+ let [wx, wy] = unprojectCamera(camera(), sx, sy)
458
+ glide = { kind: "anchor", target, sx, sy, wx, wy }
459
+ }
460
+ let rollBy = (angle: number) => {
461
+ finite("rollBy angle", angle)
462
+ rotation += angle
463
+ clamp()
464
+ touch()
465
+ }
466
+ // The focal of a zoom nudge in viewport pixels, the pivot when the
467
+ // gesture has none.
468
+ let focalPx = (focal: Vec2 | undefined): [number, number] => (focal ? [focal[0] * vw, focal[1] * vh] : [px(), py()])
469
+
470
+ let axes = createAxes<Camera2dAxes>(
471
+ { pan: "vec2", zoom: "axis", roll: "axis" },
472
+ {
473
+ onBegin: name => {
474
+ if (name !== "pan") return
475
+ dragging = true
476
+ interrupt()
477
+ },
478
+ onEnd: name => {
479
+ if (name !== "pan") return
480
+ dragging = false
481
+ release()
482
+ notify()
483
+ },
484
+ onNudge: (name, delta, focal) => {
485
+ readViewport()
486
+ if (name === "pan") {
487
+ let d = delta as Vec2
488
+ panBy(d[0] * vh * panSpeed(), d[1] * vh * panSpeed())
489
+ } else if (name === "zoom") {
490
+ let [sx, sy] = focalPx(focal)
491
+ zoomAt(sx, sy, Math.pow(2, (delta as number) * zoomExponent()), !axes.inGesture("zoom"))
492
+ } else {
493
+ rollBy((delta as number) * 2 * Math.PI * rollSpeed())
494
+ }
495
+ flush()
496
+ notify()
497
+ },
498
+ },
499
+ )
500
+ let active = createMemo(() => motionActive() || axes.active())
501
+
502
+ readViewport()
503
+ clamp()
504
+ if (pendingFit && known()) {
505
+ fitNow(pendingFit.rect, pendingFit.glide)
506
+ pendingFit = null
507
+ }
508
+ dirty = false
509
+ apply()
510
+ changed = false
511
+ notify()
512
+
513
+ return {
514
+ camera,
515
+ active,
516
+ axes,
517
+ set(pose) {
518
+ checkPose("set", pose)
519
+ // The re-clamp entry for live options too (set({}) after a bounds
520
+ // change), so the options are validated here as at creation.
521
+ checkOptions(options)
522
+ readViewport()
523
+ if (pose.x !== undefined || pose.y !== undefined || pose.zoom !== undefined) interrupt()
524
+ if (pose.x !== undefined) x = pose.x
525
+ if (pose.y !== undefined) y = pose.y
526
+ if (pose.zoom !== undefined) zoom = pose.zoom
527
+ if (pose.rotation !== undefined) rotation = pose.rotation
528
+ clamp()
529
+ touch()
530
+ flush()
531
+ },
532
+ panBy(dx, dy) {
533
+ finite("panBy dx", dx)
534
+ finite("panBy dy", dy)
535
+ panBy(dx, dy)
536
+ flush()
537
+ },
538
+ release() {
539
+ release()
540
+ notify()
541
+ },
542
+ zoomAt(sx, sy, factor, opts) {
543
+ finite("zoomAt sx", sx)
544
+ finite("zoomAt sy", sy)
545
+ zoomAt(sx, sy, factor, opts?.glide ?? false)
546
+ flush()
547
+ notify()
548
+ },
549
+ rollBy(angle) {
550
+ rollBy(angle)
551
+ flush()
552
+ },
553
+ glideTo(tx, ty, tz) {
554
+ glideTo(tx, ty, tz)
555
+ notify()
556
+ },
557
+ fit(rect, opts) {
558
+ readViewport()
559
+ if (!known()) {
560
+ pendingFit = { rect: rect ?? null, glide: opts?.glide ?? false }
561
+ notify()
562
+ return
563
+ }
564
+ fitNow(rect ?? null, opts?.glide ?? false)
565
+ flush()
566
+ notify()
567
+ },
568
+ follow(tx, ty) {
569
+ finite("follow x", tx)
570
+ finite("follow y", ty)
571
+ // A pose glide yields to the follow; an anchor glide (a wheel zoom)
572
+ // keeps running, as a notch itself lets one - a per-frame follow of
573
+ // a moving target must not cancel the zoom under it.
574
+ if (glide !== null && glide.kind === "pose") glide = null
575
+ fling = null
576
+ if (followAt === null) followAt = { x: tx, y: ty }
577
+ else {
578
+ followAt.x = tx
579
+ followAt.y = ty
580
+ }
581
+ followSettled = false
582
+ notify()
583
+ },
584
+ unfollow() {
585
+ followAt = null
586
+ notify()
587
+ },
588
+ interrupt,
589
+ viewRect() {
590
+ readViewport()
591
+ let cam = camera()
592
+ let corners = [unprojectCamera(cam, 0, 0), unprojectCamera(cam, vw, 0), unprojectCamera(cam, 0, vh), unprojectCamera(cam, vw, vh)]
593
+ let minX = Infinity
594
+ let minY = Infinity
595
+ let maxX = -Infinity
596
+ let maxY = -Infinity
597
+ for (let [cx, cy] of corners) {
598
+ minX = Math.min(minX, cx)
599
+ minY = Math.min(minY, cy)
600
+ maxX = Math.max(maxX, cx)
601
+ maxY = Math.max(maxY, cy)
602
+ }
603
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
604
+ },
605
+ update(dt) {
606
+ finite("update dt", dt)
607
+ let pw = vw
608
+ let ph = vh
609
+ readViewport()
610
+ if (vw !== pw || vh !== ph) {
611
+ // The pivot moved with the viewport; the world point stays under
612
+ // it. A resize can also change the fit zoom, hence the re-clamp.
613
+ if (pendingFit !== null && known()) {
614
+ fitNow(pendingFit.rect, pendingFit.glide)
615
+ pendingFit = null
616
+ }
617
+ clamp()
618
+ touch()
619
+ }
620
+ if (dt > 0) {
621
+ // The axis rates: content travel in viewport heights per second,
622
+ // zoom in octaves per second about the pivot, roll in turns.
623
+ let [rx, ry] = untrack(() => axes.rate("pan"))
624
+ if (rx !== 0 || ry !== 0) {
625
+ interrupt()
626
+ shift(rx * PAN_RATE * panSpeed() * vh * dt, ry * PAN_RATE * panSpeed() * vh * dt)
627
+ clamp()
628
+ touch()
629
+ }
630
+ let rz = untrack(() => axes.rate("zoom"))
631
+ if (rz !== 0) {
632
+ interrupt()
633
+ zoomTo(px(), py(), zoom * Math.pow(2, rz * ZOOM_RATE * zoomExponent() * dt))
634
+ }
635
+ let rr = untrack(() => axes.rate("roll"))
636
+ if (rr !== 0) {
637
+ rotation += rr * ROLL_RATE * rollSpeed() * 2 * Math.PI * dt
638
+ clamp()
639
+ touch()
640
+ }
641
+ vx += (dragDx / dt - vx) * VELOCITY_SMOOTH
642
+ vy += (dragDy / dt - vy) * VELOCITY_SMOOTH
643
+ dragDx = 0
644
+ dragDy = 0
645
+ lastDt = dt
646
+ }
647
+ if (fling !== null && dt > 0) {
648
+ shift(fling.vx * dt, fling.vy * dt)
649
+ let decay = Math.exp(-FLING_DECAY * dt)
650
+ fling.vx *= decay
651
+ fling.vy *= decay
652
+ if (Math.hypot(fling.vx, fling.vy) < LAND_PX * FLING_DECAY) fling = null
653
+ clamp()
654
+ touch()
655
+ }
656
+ if (glide !== null && dt > 0) {
657
+ let k = 1 - Math.exp(-GLIDE_EASE * dt)
658
+ if (glide.kind === "anchor") {
659
+ let gap = Math.log(glide.target / zoom)
660
+ zoom = Math.abs(gap) < GLIDE_EPSILON ? glide.target : zoom * Math.exp(gap * k)
661
+ ;[x, y] = anchorPose(glide.wx, glide.wy, glide.sx, glide.sy, zoom)
662
+ if (zoom === glide.target) glide = null
663
+ } else {
664
+ let gap = Math.log(glide.zoom / zoom)
665
+ let landed = Math.abs(gap) < GLIDE_EPSILON && Math.hypot(glide.x - x, glide.y - y) * zoom < LAND_PX
666
+ if (landed) {
667
+ x = glide.x
668
+ y = glide.y
669
+ zoom = glide.zoom
670
+ glide = null
671
+ } else {
672
+ zoom *= Math.exp(gap * k)
673
+ x += (glide.x - x) * k
674
+ y += (glide.y - y) * k
675
+ }
676
+ }
677
+ clamp()
678
+ touch()
679
+ }
680
+ if (followAt !== null && !followSettled && dt > 0 && known()) {
681
+ // How far the target sits outside the dead zone, in screen pixels;
682
+ // the camera eases by that overshoot so the target rides the zone
683
+ // edge, or snaps the last sub-pixel and settles.
684
+ let [sx, sy] = projectCamera(camera(), followAt.x, followAt.y)
685
+ let hw = (deadW() * vw) / 2
686
+ let hh = (deadH() * vh) / 2
687
+ let ox = sx < px() - hw ? sx - (px() - hw) : sx > px() + hw ? sx - (px() + hw) : 0
688
+ let oy = sy < py() - hh ? sy - (py() - hh) : sy > py() + hh ? sy - (py() + hh) : 0
689
+ if (Math.hypot(ox, oy) < LAND_PX) {
690
+ if (ox !== 0 || oy !== 0) {
691
+ shift(-ox, -oy)
692
+ clamp()
693
+ dirty = true
694
+ }
695
+ followSettled = true
696
+ } else {
697
+ let k = 1 - Math.exp(-followEase() * dt)
698
+ shift(-ox * k, -oy * k)
699
+ clamp()
700
+ dirty = true
701
+ }
702
+ }
703
+ if (dirty) {
704
+ dirty = false
705
+ apply()
706
+ }
707
+ notify()
708
+ let result = changed
709
+ changed = false
710
+ return result
711
+ },
712
+ }
713
+ }