@solidrt/2d 0.0.59 → 0.0.61

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/src/camera2d.ts CHANGED
@@ -2,8 +2,19 @@
2
2
  // over the layers' shared CameraUpdate - a pose (the world point at a
3
3
  // viewport pivot, zoom, rotation), the contain clamp against world
4
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.
5
+ // follow through Cinemachine's framing (a dead zone, hard limits,
6
+ // damping per axis, lookahead), drag inertia, rotation about the pivot,
7
+ // and the lanes (a screen offset, shakes) - extracted so apps stop
8
+ // rebuilding the same hundred lines.
9
+ //
10
+ // The control runs the pipeline of okf/design/camera-controls.md: a
11
+ // SOURCE (an input nudge, a verb, or the follow) writes the pose; the
12
+ // FRAMING eases the followed point back into its zones; the LANES are
13
+ // summed on top of the pose; the CONSTRAINTS (the zoom range, the
14
+ // contain clamp) apply to pose plus the persistent offset and move the
15
+ // pose, and clip a shake at push without touching it; and the PUSH
16
+ // writes the final camera. pose() is the pose alone; camera() is the
17
+ // final camera as the layers received it (what projectCamera takes).
7
18
  //
8
19
  // The control is pure (ARCHITECTURE.md: controls through an abstraction,
9
20
  // never direct event handling): it consumes three axes, `pan` (vec2),
@@ -16,14 +27,16 @@
16
27
  //
17
28
  // Conventions, decided against Godot's Camera2D, Unity's Cinemachine and
18
29
  // 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.
30
+ // half; okf/notes/2d-camera-conventions.md): the pose is "world point at
31
+ // the pivot" with the pivot defaulting to the viewport center (Godot's
32
+ // drag-center anchor, Cinemachine's screen position, a map's center);
33
+ // bounds CONTAIN the view and center an axis whose view is wider than
34
+ // the world (Cinemachine's confiner skeleton, tldraw's contain origin -
35
+ // Godot's left-wins is the worse behavior), at once for a direct write
36
+ // and eased when `world.damping` says so for a motion (Godot's
37
+ // limit_smoothed); limits ignore rotation, clamping the unrotated view
38
+ // rect (Godot's rule); every ease is exponential and frame-rate
39
+ // independent, zoom eased in log space so a long glide reads evenly.
27
40
  //
28
41
  // Input units: a `pan` delta is finger travel in viewport heights (the
29
42
  // pointer feed's unit), applied as content travel - the world follows the
@@ -33,51 +46,39 @@
33
46
  // unbracketed (a wheel notch) it retargets an eased glide, which is how
34
47
  // the control tells a finger from an impulse. A `roll` delta is turns. A
35
48
  // 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.
49
+ // holds it), its end flings with the release velocity the gesture
50
+ // measured (the pointer feed's, in viewport heights per second: one
51
+ // estimator for every recognizer, velocity.ts in core), decaying.
37
52
  //
38
53
  // Pose is plain mutable state; a nudge or a verb pushes it at once (the
39
54
  // 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.
55
+ // and update(dt) advances glides, follow, inertia, the lanes and the axis
56
+ // rates from the app's own onFrame. `active()` is the reactive frame-loop
57
+ // gate (a glide, fling, fit, follow, shake or damped bound in flight, or
58
+ // a rate driving); a resting camera costs nothing. update() pushes the
59
+ // pose to the layers only when it changed (one setCamera per driven
60
+ // layer: a shared-params write, however many sprites exist) and reports
61
+ // that, so per-frame dependents (labels re-projected via projectCamera)
62
+ // can follow the camera without recomputing every frame.
48
63
 
49
- import { createMemo, createSignal, untrack } from "@solidjs/signals"
64
+ import { untrack } from "@solidjs/signals"
50
65
  import { createAxes } from "@solidrt/core/input"
51
66
  import type { Axes, Vec2 } from "@solidrt/core/input"
67
+ import { checkFollowOptions, checkOffset, checkShake, createActivity, createLanes, createLookahead, easeStep, frame, GLIDE_EASE, GLIDE_EPSILON } from "@solidrt/core/camera-control"
68
+ import type { FollowOptions } from "@solidrt/core/camera-control"
52
69
  import { projectCamera, unprojectCamera } from "./camera.ts"
53
- import type { CameraUpdate } from "./camera.ts"
70
+ import type { CameraState, CameraUpdate } from "./camera.ts"
54
71
 
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
- // (the 3d controls' rate, motion.ts there); `damping` scales a notch's.
58
- const GLIDE_EASE = 9
59
- // A glide lands when the remaining zoom gap is under this (relative) ...
60
- const GLIDE_EPSILON = 0.002
61
- // ... and the remaining travel is under this many screen pixels. Also the
62
- // follow's settle threshold and, times the fling decay, the speed a fling
63
- // stops at (the distance it would still travel).
72
+ // A glide lands when the remaining travel is under this many screen
73
+ // pixels. Also the follow's settle threshold, the damped bound's, and,
74
+ // times the fling decay, the speed a fling stops at (the distance it
75
+ // would still travel).
64
76
  const LAND_PX = 0.5
65
- // Follow damping, e-foldings per second (Godot's default
66
- // position_smoothing_speed).
67
- const FOLLOW_EASE = 5
68
77
  // Inertia after a drag release: velocity decay in e-foldings per second. A
69
78
  // 1500 px/s flick travels 500 px; iOS scrolling decelerates at ~2, maps
70
- // are snappier.
79
+ // are snappier. The release speed itself comes from the gesture (a
80
+ // rested finger reads zero there), so nothing gates it here.
71
81
  const FLING_DECAY = 3
72
- // Release speeds under this (px/s) do not fling: a finger that stopped
73
- // before lifting leaves the view where it is.
74
- const FLING_MIN_SPEED = 50
75
- // Per-update EMA weight of the drag velocity estimate: heavy on the newest
76
- // frame so a direction change registers within a frame or two.
77
- const VELOCITY_SMOOTH = 0.5
78
- // Frame time assumed for the drag delta still pending at a release when the
79
- // release arrives in the same frame as the last move.
80
- const FALLBACK_DT = 1 / 60
81
82
  // Rate axes at full deflection, per second: viewport heights of pan,
82
83
  // octaves of zoom (1 = the zoom doubles each second), turns of roll.
83
84
  const PAN_RATE = 1
@@ -103,8 +104,12 @@ export type Camera2dOptions = Camera2dPose & {
103
104
  * clamping and fitting wait for a real size. */
104
105
  viewport: () => { width: number; height: number }
105
106
  /** World bounds (origin 0,0) the view is kept inside: an axis whose view
106
- * is wider than the world centers. Absent = unbounded. */
107
- world?: { width: number; height: number }
107
+ * is wider than the world centers. Absent = unbounded. `damping` eases
108
+ * a MOTION (a follow, a glide, a fling) into the bounds instead of
109
+ * cutting it at the edge, as a multiple of the built-in settle time
110
+ * (Godot's limit_smoothed); a direct write (a drag, set) always clamps
111
+ * at once. Default 0: at once. */
112
+ world?: { width: number; height: number; damping?: number }
108
113
  /** Zoom range. minZoom defaults to the fit zoom (whole world visible)
109
114
  * when `world` is given, else to no lower bound; maxZoom to no upper
110
115
  * bound. An explicit maxZoom below the fit zoom wins, the world then
@@ -115,9 +120,16 @@ export type Camera2dOptions = Camera2dPose & {
115
120
  * its size; default the center. glideTo and follow land their point
116
121
  * here and rotation turns about it. */
117
122
  pivot?: { x: number; y: number }
118
- /** Follow dead zone as fractions of the viewport, centered on the pivot:
119
- * the target roams inside it without moving the camera. Default 0,0. */
120
- deadZone?: { width: number; height: number }
123
+ /** How follow(x, y) chases its point (the framing stage): damping per
124
+ * axis, the dead zone and hard limits as fractions of the viewport
125
+ * centered on the pivot, lookahead. Read where it applies. */
126
+ follow?: FollowOptions
127
+ /** The offset lane: where the pose's world point is shown, in viewport
128
+ * HEIGHTS from the pivot (x right, y down; [0, -0.25] shows it a
129
+ * quarter of the height above the pivot - the look-up of a platformer).
130
+ * Added on top of the pose at push, never in pose(); the bounds
131
+ * contain pose plus offset by moving the pose. */
132
+ offset?: Vec2
121
133
  /** Multipliers over the built-in pan, zoom (pinch, wheel and rate) and
122
134
  * roll sensitivities. */
123
135
  panSpeed?: number
@@ -128,8 +140,6 @@ export type Camera2dOptions = Camera2dPose & {
128
140
  * built-in ease, 2 coasts twice as long, 0 applies notches at once, as
129
141
  * a pinch does. The 3d controls' knob, same meaning. */
130
142
  damping?: number
131
- /** Multiplier over the built-in follow damping. */
132
- followSpeed?: number
133
143
  /** Whether a drag release keeps the view gliding (default true). */
134
144
  inertia?: boolean
135
145
  }
@@ -137,54 +147,68 @@ export type Camera2dOptions = Camera2dPose & {
137
147
  export type Camera2dAxes = { pan: "vec2"; zoom: "axis"; roll: "axis" }
138
148
 
139
149
  export type Camera2d = {
140
- /** The pose as the layers receive it (a fresh object per call): the
141
- * argument for projectCamera/unprojectCamera. */
142
- camera(): CameraUpdate
150
+ /** The final camera as the layers received it (a fresh object per
151
+ * call, every field set): the pose plus the lanes - the argument for
152
+ * projectCamera/unprojectCamera. */
153
+ camera(): CameraState
143
154
  /** Pose snapshot - the control's own four values, the shape set() takes
144
- * (camera() adds the pivot the layers are told). */
155
+ * (the 3d controls' pose()); no lane in it. */
145
156
  pose(): Required<Camera2dPose>
146
157
  /** Merge a pose in (clamps apply) and push it. A snap: an x/y/zoom
147
- * write cancels a glide or fling in flight; rotation alone leaves them
148
- * running. */
158
+ * write drops a glide or fling in flight and ends the follow; a
159
+ * rotation write leaves them running. */
149
160
  set(pose: Camera2dPose): void
150
161
  /** Pan the content by a screen delta (a drag: positive dx slides the
151
- * world rightward) and push. Feeds the inertia estimate; release()
152
- * flings. */
162
+ * world right, the camera point moves left), clamped, and push. */
153
163
  panBy(dx: number, dy: number): void
154
- /** End of a drag: keep gliding with the drag's velocity (inertia). */
155
- release(): void
164
+ /** End of a drag: keep gliding from its release velocity, screen pixels
165
+ * per second of content travel, decaying (nothing while following, or
166
+ * with `inertia` off). */
167
+ release(velocity: Vec2): void
156
168
  /** Zoom by `factor` about a screen point - the world point under it
157
- * stays under it - and push. `glide` eases there instead (a wheel
158
- * notch): notches compound on the pending target, so a fast scroll is
159
- * one long push. */
169
+ * stays under it (factor > 1 zooms in), clamped to the zoom range and
170
+ * the bounds, and push. `glide` eases there instead (a wheel notch),
171
+ * retargeting the pending glide so notches compound. */
160
172
  zoomAt(sx: number, sy: number, factor: number, opts?: { glide?: boolean }): void
161
- /** Turn by radians about the pivot and push. */
173
+ /** Turn the world about the pivot by radians (clockwise, y-down) and push. */
162
174
  rollBy(angle: number): void
163
- /** Ease the pose until world (x, y) sits at the pivot, at `zoom` (default
164
- * the current zoom). Cancels a follow. */
165
- glideTo(x: number, y: number, zoom?: number): void
166
- /** Show a world rect (default the world) whole, centered in the
167
- * viewport, snapping or gliding. Waits for the viewport if unknown. */
175
+ /** Ease the pose to put world (x, y) at the pivot, at `zoom` (default
176
+ * the current, or a pending wheel or double-tap zoom's target) and
177
+ * `rotation` (default the current), landing exactly; a fling or a
178
+ * follow yields. */
179
+ glideTo(x: number, y: number, zoom?: number, rotation?: number): void
180
+ /** Frame a world rect (default the whole world): centered in the view
181
+ * at the zoom that fits it, clamped. A snap unless `glide`. Waits for
182
+ * the viewport when its size is not known yet. */
168
183
  fit(rect?: Rect2d, opts?: { glide?: boolean }): void
169
- /** Keep world (x, y) at the pivot through the dead zone and damping;
170
- * call again whenever the target moves (every frame for a moving one).
171
- * Cancels a pose glide (glideTo, fit) and a fling; a wheel zoom keeps
172
- * gliding - zoom and follow are separate axes. */
184
+ /** Chase a world point (the framing stage, options.follow): the pose
185
+ * eases toward it per axis, through the dead zone and hard limits,
186
+ * with lookahead. Call it every frame for a moving point; the control
187
+ * settles once the point rests inside the zones. A pose glide yields;
188
+ * an anchor glide (a wheel zoom) keeps running - zoom and follow are
189
+ * separate axes. */
173
190
  follow(x: number, y: number): void
174
191
  unfollow(): void
192
+ /** A shake on the lanes: `strength` is the peak displacement in
193
+ * viewport heights, `duration` seconds, an optional frequency (cycles
194
+ * per second) and direction (a unit [x, y], y down). Shakes sum and
195
+ * decay; the pose is untouched, and at a world edge the shake is
196
+ * clipped by the bounds (it never shows the outside). */
197
+ shake(strength: number, duration: number, opts?: { frequency?: number; direction?: Vec2 }): void
175
198
  /** Stop a glide or fling in flight (a press landing). */
176
199
  interrupt(): void
177
200
  /** The world-space bounding box of what the viewport shows. */
178
201
  viewRect(): Rect2d
179
- /** Advance glides, follow, inertia and the axis rates, then push the
180
- * pose to the layers if it changed. Call from onFrame with the frame's
181
- * dt in seconds; returns whether the pose changed since the previous
182
- * update (nudges and verbs included). */
202
+ /** Advance glides, follow, inertia, the lanes and the axis rates, then
203
+ * push the camera to the layers if it changed. Call from onFrame with
204
+ * the frame's dt in seconds; returns whether the camera changed since
205
+ * the previous update (nudges and verbs included). */
183
206
  update(dt: number): boolean
184
207
  /**
185
208
  * Reactive: whether update() still has work - a glide, fling or
186
- * deferred fit in flight, a follow engaged, a write not yet pushed, or
187
- * an axis rate driving. The frame-loop gate: run update(dt) on frames
209
+ * deferred fit in flight, a follow engaged and not settled, a shake
210
+ * running, a damped bound still easing, a write not yet pushed, or an
211
+ * axis rate driving. The frame-loop gate: run update(dt) on frames
188
212
  * while true, and nothing while false (a resting camera costs no frames).
189
213
  */
190
214
  active(): boolean
@@ -195,7 +219,7 @@ export type Camera2d = {
195
219
  axes: Axes<Camera2dAxes>
196
220
  }
197
221
 
198
- type Glide = { kind: "anchor"; target: number; sx: number; sy: number; wx: number; wy: number } | { kind: "pose"; x: number; y: number; zoom: number }
222
+ type Glide = { kind: "anchor"; target: number; sx: number; sy: number; wx: number; wy: number } | { kind: "pose"; x: number; y: number; zoom: number; rotation: number }
199
223
 
200
224
  let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
201
225
 
@@ -214,16 +238,15 @@ function checkPose(verb: string, pose: Camera2dPose): void {
214
238
  if (pose.zoom !== undefined) positive(`${verb} zoom`, pose.zoom)
215
239
  }
216
240
 
217
- function checkFraction(what: string, v: number): void {
218
- if (!(Number.isFinite(v) && v >= 0 && v <= 1)) throw new Error(`createCamera2d: ${what} must be within 0..1, got ${v}`)
219
- }
220
-
221
241
  /** The non-pose options, validated at creation and again at every set()
222
242
  * (the live-option re-clamp entry). */
223
243
  function checkOptions(options: Camera2dOptions): void {
224
244
  if (options.world) {
225
245
  positive("world.width", options.world.width)
226
246
  positive("world.height", options.world.height)
247
+ if (options.world.damping !== undefined && !(Number.isFinite(options.world.damping) && options.world.damping >= 0)) {
248
+ throw new Error(`createCamera2d: world.damping must be a non-negative number, got ${options.world.damping}`)
249
+ }
227
250
  }
228
251
  if (options.minZoom !== undefined) positive("minZoom", options.minZoom)
229
252
  if (options.maxZoom !== undefined) positive("maxZoom", options.maxZoom)
@@ -235,8 +258,8 @@ function checkOptions(options: Camera2dOptions): void {
235
258
  if (options.damping !== undefined && !(Number.isFinite(options.damping) && options.damping >= 0)) {
236
259
  throw new Error(`createCamera2d: damping must be a non-negative number, got ${options.damping}`)
237
260
  }
238
- checkFraction("deadZone.width", options.deadZone?.width ?? 0)
239
- checkFraction("deadZone.height", options.deadZone?.height ?? 0)
261
+ checkFollowOptions("createCamera2d", options.follow)
262
+ checkOffset("createCamera2d", options.offset)
240
263
  }
241
264
 
242
265
  /**
@@ -247,10 +270,10 @@ function checkOptions(options: Camera2dOptions): void {
247
270
  * applies immediately: the world fitted when `world` is given and no
248
271
  * `zoom`. The pose options are initial values; every other option is read
249
272
  * where it applies, so a caller holding the options object (or a props
250
- * object handing out getters) changes bounds, pivot, dead zone and rates
251
- * live - `set({})` re-clamps and pushes the pose under new bounds. In a
252
- * component tree, prefer `<Camera2d>`: it drives the nearest view through
253
- * context and takes an input map as a prop.
273
+ * object handing out getters) changes bounds, pivot, the follow's zones
274
+ * and rates live - `set({})` re-clamps and pushes the pose under new
275
+ * bounds. In a component tree, prefer `<Camera2d>`: it drives the nearest
276
+ * view through context and takes an input map as a prop.
254
277
  */
255
278
  export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], options: Camera2dOptions): Camera2d {
256
279
  let targets = Array.isArray(target) ? target : [target]
@@ -265,14 +288,12 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
265
288
  let maxZoom = () => options.maxZoom ?? Infinity
266
289
  let pivotFx = () => options.pivot?.x ?? 0.5
267
290
  let pivotFy = () => options.pivot?.y ?? 0.5
268
- let deadW = () => options.deadZone?.width ?? 0
269
- let deadH = () => options.deadZone?.height ?? 0
270
291
  let panSpeed = () => options.panSpeed ?? 1
271
292
  let zoomExponent = () => options.zoomSpeed ?? 1
272
293
  let rollSpeed = () => options.rollSpeed ?? 1
273
294
  let damping = () => options.damping ?? 1
274
- let followEase = () => FOLLOW_EASE * (options.followSpeed ?? 1)
275
295
  let inertia = () => options.inertia ?? true
296
+ let boundsDamping = () => options.world?.damping ?? 0
276
297
 
277
298
  let w0 = world()
278
299
  let x = options.x ?? (w0 ? w0.width / 2 : 0)
@@ -302,8 +323,49 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
302
323
  return Math.min(options.minZoom ?? fit, maxZoom())
303
324
  }
304
325
 
326
+ // The pose as a camera (no lanes): what the framing and the anchors
327
+ // measure against.
328
+ let poseCamera = (): CameraState => ({ x, y, zoom, rotation, pivotX: px(), pivotY: py() })
329
+ // Pose x/y that put world (wx, wy) under screen (sx, sy) at zoom z:
330
+ // the inverse of projectCamera solved for the camera point.
331
+ let anchorPose = (wx: number, wy: number, sx: number, sy: number, z: number): [number, number] => {
332
+ let c = Math.cos(rotation)
333
+ let s = Math.sin(rotation)
334
+ let dx = sx - px()
335
+ let dy = sy - py()
336
+ return [wx - (dx * c + dy * s) / z, wy - (dy * c - dx * s) / z]
337
+ }
338
+ // ---- The lanes ----
339
+ let lanes = createLanes()
340
+ // The camera point for a pose point under a lane offset (viewport
341
+ // heights): the pose's world point is shown at the pivot plus the
342
+ // offset, so the point AT the pivot is the one that far back.
343
+ let shifted = (cx: number, cy: number, z: number, ox: number, oy: number): [number, number] => {
344
+ if (ox === 0 && oy === 0) return [cx, cy]
345
+ let c = Math.cos(rotation)
346
+ let s = Math.sin(rotation)
347
+ let dx = ox * vh
348
+ let dy = oy * vh
349
+ return [cx - (dx * c + dy * s) / z, cy - (dy * c - dx * s) / z]
350
+ }
351
+ // The persistent lane only: what the bounds contain (moving the pose)
352
+ // and what a zoom or glide lands against.
353
+ let withOffset = (cx: number, cy: number, z: number): [number, number] => shifted(cx, cy, z, options.offset?.[0] ?? 0, options.offset?.[1] ?? 0)
354
+ let steadyCamera = (): CameraState => {
355
+ let [fx, fy] = withOffset(x, y, zoom)
356
+ return { x: fx, y: fy, zoom, rotation, pivotX: px(), pivotY: py() }
357
+ }
358
+ // The final camera: the shakes on top, clipped by the bounds at push
359
+ // (a shake never enters the pose and never shows the outside).
360
+ let camera = (): CameraState => {
361
+ let [ox, oy] = lanes.total(options.offset)
362
+ let [fx, fy] = shifted(x, y, zoom, ox, oy)
363
+ if (lanes.active()) [fx, fy] = contain(fx, fy, zoom)
364
+ return { x: fx, y: fy, zoom, rotation, pivotX: px(), pivotY: py() }
365
+ }
366
+
305
367
  // The contain clamp on an unrotated view rect (Godot's rule: limits
306
- // ignore rotation): returns the pose's x/y, moved inside the world.
368
+ // ignore rotation): returns the camera point, moved inside the world.
307
369
  let contain = (cx: number, cy: number, z: number): [number, number] => {
308
370
  let w = world()
309
371
  if (!w || !known()) return [cx, cy]
@@ -315,21 +377,34 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
315
377
  top = viewH >= w.height ? (w.height - viewH) / 2 : clampNum(top, 0, w.height - viewH)
316
378
  return [left + px() / z, top + py() / z]
317
379
  }
318
- let clamp = () => {
380
+ // The constraints on pose plus offset: the zoom range, then the contain
381
+ // clamp of the camera point, which moves the pose by the same delta
382
+ // (the offset stays). At once, or eased over dt when the bounds damp a
383
+ // motion; leaves whether the pose is contained now in boundsSettled.
384
+ let boundsSettled = true
385
+ let clampTo = (dt: number | null) => {
319
386
  zoom = clampNum(zoom, minZoom(), maxZoom())
320
- ;[x, y] = contain(x, y, zoom)
321
- }
322
-
323
- let camera = (): CameraUpdate => ({ x, y, zoom, rotation, pivotX: px(), pivotY: py() })
324
- // Pose x/y that put world (wx, wy) under screen (sx, sy) at zoom z:
325
- // the inverse of projectCamera solved for the camera point.
326
- let anchorPose = (wx: number, wy: number, sx: number, sy: number, z: number): [number, number] => {
327
- let c = Math.cos(rotation)
328
- let s = Math.sin(rotation)
329
- let dx = sx - px()
330
- let dy = sy - py()
331
- return [wx - (dx * c + dy * s) / z, wy - (dy * c - dx * s) / z]
387
+ let [fx, fy] = withOffset(x, y, zoom)
388
+ let [cx, cy] = contain(fx, fy, zoom)
389
+ let dx = cx - fx
390
+ let dy = cy - fy
391
+ if (dx === 0 && dy === 0) {
392
+ boundsSettled = true
393
+ return
394
+ }
395
+ let d = boundsDamping()
396
+ if (dt === null || d <= 0 || Math.hypot(dx, dy) * zoom < LAND_PX) {
397
+ x += dx
398
+ y += dy
399
+ boundsSettled = true
400
+ return
401
+ }
402
+ let k = easeStep(GLIDE_EASE / d, dt)
403
+ x += dx * k
404
+ y += dy * k
405
+ boundsSettled = false
332
406
  }
407
+ let clamp = () => clampTo(null)
333
408
  // Slide the content by a screen delta: the camera point moves the
334
409
  // opposite way, the delta un-rotated into world axes.
335
410
  let shift = (dx: number, dy: number) => {
@@ -340,19 +415,15 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
340
415
  }
341
416
 
342
417
  let dirty = false
343
- // Whether the pose changed since the last update() (pushes included).
418
+ // Whether the camera changed since the last update() (pushes included).
344
419
  let changed = false
345
420
  let glide: Glide | null = null
346
421
  let fling: { vx: number; vy: number } | null = null
347
422
  let followAt: { x: number; y: number } | null = null
348
423
  // A follow that reached its target stops writing until something moves.
349
424
  let followSettled = false
350
- // Drag deltas since the last update, and the smoothed velocity (px/s).
351
- let dragDx = 0
352
- let dragDy = 0
353
- let vx = 0
354
- let vy = 0
355
- let lastDt = FALLBACK_DT
425
+ let lookahead = createLookahead()
426
+ let predicted: number[] = [0, 0]
356
427
  // A fit that waits for the viewport (the default pose when a world is
357
428
  // given, or an explicit fit() before the size is known).
358
429
  let pendingFit: { rect: Rect2d | null; glide: boolean } | null = options.zoom === undefined && w0 ? { rect: null, glide: false } : null
@@ -361,19 +432,11 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
361
432
  dirty = true
362
433
  followSettled = false
363
434
  }
364
- // A pan gesture in flight keeps the camera active: update(dt) then folds
365
- // the drag's deltas into the velocity estimate every frame, so the fling
366
- // at release carries the finger's speed and not the whole drag's travel
367
- // over one assumed frame.
368
- let dragging = false
369
- let busy = () => dirty || dragging || glide !== null || fling !== null || followAt !== null || pendingFit !== null
370
- // The motion's half of active(), a signal every public entry refreshes
371
- // (ownedWrite: entries run from component bodies and handlers alike).
372
- let [motionActive, setMotionActive] = createSignal(false, { ownedWrite: true })
373
- let notify = () => {
374
- let now = busy()
375
- if (now !== untrack(motionActive)) setMotionActive(now)
376
- }
435
+ let busy = () => dirty || glide !== null || fling !== null || (followAt !== null && !followSettled) || pendingFit !== null || lanes.active() || !boundsSettled
436
+ // The frame-loop gate (core's createActivity), built once the axes
437
+ // exist; notified at the end of every public entry.
438
+ let activity!: ReturnType<typeof createActivity>
439
+ let notify = () => activity.notify()
377
440
  let interrupt = () => {
378
441
  glide = null
379
442
  fling = null
@@ -393,22 +456,33 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
393
456
  }
394
457
 
395
458
  let zoomTo = (sx: number, sy: number, next: number) => {
396
- let [wx, wy] = unprojectCamera(camera(), sx, sy)
459
+ let [wx, wy] = unprojectCamera(steadyCamera(), sx, sy)
397
460
  zoom = clampNum(next, minZoom(), maxZoom())
398
- ;[x, y] = anchorPose(wx, wy, sx, sy, zoom)
461
+ let [nx, ny] = anchorPose(wx, wy, sx, sy, zoom)
462
+ // anchorPose places the camera point; the pose sits the offset back
463
+ // from it.
464
+ let [lx, ly] = withOffset(0, 0, zoom)
465
+ x = nx - lx
466
+ y = ny - ly
399
467
  clamp()
400
468
  touch()
401
469
  }
402
- let glideTo = (tx: number, ty: number, tz?: number) => {
470
+ let glideTo = (tx: number, ty: number, tz?: number, tr?: number) => {
403
471
  finite("glideTo x", tx)
404
472
  finite("glideTo y", ty)
473
+ finite("glideTo rotation", tr)
405
474
  if (tz !== undefined) positive("glideTo zoom", tz)
406
475
  readViewport()
407
476
  fling = null
408
477
  followAt = null
409
- let z = clampNum(tz ?? zoom, minZoom(), maxZoom())
410
- let [cx, cy] = contain(tx, ty, z)
411
- glide = { kind: "pose", x: cx, y: cy, zoom: z }
478
+ // A pending anchor glide (a wheel notch, a double tap's octave) hands
479
+ // its zoom over: the glide centres the point AND lands that zoom,
480
+ // instead of the later verb cancelling the earlier gesture.
481
+ let pending = glide !== null && glide.kind === "anchor" ? glide.target : zoom
482
+ let z = clampNum(tz ?? pending, minZoom(), maxZoom())
483
+ let [lx, ly] = withOffset(0, 0, z)
484
+ let [cx, cy] = contain(tx + lx, ty + ly, z)
485
+ glide = { kind: "pose", x: cx - lx, y: cy - ly, zoom: z, rotation: tr ?? rotation }
412
486
  }
413
487
  let fitNow = (rect: Rect2d | null, ease: boolean) => {
414
488
  let w = world()
@@ -416,13 +490,15 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
416
490
  if (!r) throw new Error("createCamera2d: fit() needs a rect when the camera has no world")
417
491
  let z = clampNum(Math.min(vw / r.width, vh / r.height), minZoom(), maxZoom())
418
492
  let [nx, ny] = anchorPose(r.x + r.width / 2, r.y + r.height / 2, vw / 2, vh / 2, z)
493
+ let [lx, ly] = withOffset(0, 0, z)
419
494
  if (ease) {
420
- glideTo(nx, ny, z)
495
+ glideTo(nx - lx, ny - ly, z)
421
496
  return
422
497
  }
423
498
  interrupt()
424
- x = nx
425
- y = ny
499
+ followAt = null
500
+ x = nx - lx
501
+ y = ny - ly
426
502
  zoom = z
427
503
  clamp()
428
504
  touch()
@@ -432,27 +508,12 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
432
508
  interrupt()
433
509
  shift(dx, dy)
434
510
  clamp()
435
- dragDx += dx
436
- dragDy += dy
437
511
  touch()
438
512
  }
439
- let release = () => {
440
- // Fold the delta still pending from the release's own frame in with
441
- // the last frame time, then start from rest either way. The fling
442
- // gates on the SMOOTHED velocity too: a finger that stopped before
443
- // lifting has let it decay over the still frames, and the stray delta
444
- // the release frame may still carry (the resampler's last correction)
445
- // must not fling the view on its own.
446
- let smoothed = Math.hypot(vx, vy)
447
- let rvx = vx + (dragDx / lastDt - vx) * VELOCITY_SMOOTH
448
- let rvy = vy + (dragDy / lastDt - vy) * VELOCITY_SMOOTH
449
- dragDx = 0
450
- dragDy = 0
451
- vx = 0
452
- vy = 0
513
+ let release = (velocity: Vec2) => {
453
514
  if (!inertia() || followAt !== null) return
454
- if (smoothed < FLING_MIN_SPEED || Math.hypot(rvx, rvy) < FLING_MIN_SPEED) return
455
- fling = { vx: rvx, vy: rvy }
515
+ if (velocity[0] === 0 && velocity[1] === 0) return
516
+ fling = { vx: velocity[0], vy: velocity[1] }
456
517
  }
457
518
  let zoomAt = (sx: number, sy: number, factor: number, ease: boolean) => {
458
519
  positive("zoomAt factor", factor)
@@ -468,7 +529,7 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
468
529
  // long push; the anchor is the world point under the pointer now.
469
530
  let from = glide !== null ? glide.target : zoom
470
531
  let target = clampNum(from * factor, minZoom(), maxZoom())
471
- let [wx, wy] = unprojectCamera(camera(), sx, sy)
532
+ let [wx, wy] = unprojectCamera(steadyCamera(), sx, sy)
472
533
  glide = { kind: "anchor", target, sx, sy, wx, wy }
473
534
  }
474
535
  let rollBy = (angle: number) => {
@@ -481,18 +542,36 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
481
542
  // gesture has none.
482
543
  let focalPx = (focal: Vec2 | undefined): [number, number] => (focal ? [focal[0] * vw, focal[1] * vh] : [px(), py()])
483
544
 
545
+ // ---- The follow: the framing stage ----
546
+ let stepFollow = (dt: number): boolean => {
547
+ let point = lookahead.predict([followAt!.x, followAt!.y], options.follow?.lookahead, dt, predicted)
548
+ // The point's offset from the pivot in viewport fractions, measured
549
+ // against the POSE camera: the lanes shift the picture afterwards.
550
+ let [sx, sy] = projectCamera(poseCamera(), point[0]!, point[1]!)
551
+ let framed = frame([(sx - px()) / vw, (sy - py()) / vh], options.follow, dt, LAND_PX / vh)
552
+ let moved = framed.x !== 0 || framed.y !== 0
553
+ if (moved) {
554
+ shift(-framed.x * vw, -framed.y * vh)
555
+ clampTo(dt)
556
+ dirty = true
557
+ }
558
+ followSettled = framed.settled
559
+ return moved
560
+ }
561
+
484
562
  let axes = createAxes<Camera2dAxes>(
485
563
  { pan: "vec2", zoom: "axis", roll: "axis" },
486
564
  {
487
565
  onBegin: name => {
488
566
  if (name !== "pan") return
489
- dragging = true
490
567
  interrupt()
491
568
  },
492
- onEnd: name => {
569
+ onEnd: (name, velocity) => {
493
570
  if (name !== "pan") return
494
- dragging = false
495
- release()
571
+ // Viewport heights per second of finger travel to screen pixels
572
+ // per second of content travel, the pan delta's own mapping.
573
+ let v = (velocity as Vec2 | undefined) ?? [0, 0]
574
+ release([v[0] * vh * panSpeed(), v[1] * vh * panSpeed()])
496
575
  notify()
497
576
  },
498
577
  onNudge: (name, delta, focal) => {
@@ -511,7 +590,8 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
511
590
  },
512
591
  },
513
592
  )
514
- let active = createMemo(() => motionActive() || axes.active())
593
+ activity = createActivity(busy, () => axes.active())
594
+ let active = activity.active
515
595
 
516
596
  readViewport()
517
597
  clamp()
@@ -535,7 +615,10 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
535
615
  // change), so the options are validated here as at creation.
536
616
  checkOptions(options)
537
617
  readViewport()
538
- if (pose.x !== undefined || pose.y !== undefined || pose.zoom !== undefined) interrupt()
618
+ if (pose.x !== undefined || pose.y !== undefined || pose.zoom !== undefined) {
619
+ interrupt()
620
+ followAt = null
621
+ }
539
622
  if (pose.x !== undefined) x = pose.x
540
623
  if (pose.y !== undefined) y = pose.y
541
624
  if (pose.zoom !== undefined) zoom = pose.zoom
@@ -550,8 +633,11 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
550
633
  panBy(dx, dy)
551
634
  flush()
552
635
  },
553
- release() {
554
- release()
636
+ release(velocity) {
637
+ if (!Array.isArray(velocity) || velocity.length !== 2) throw new Error(`createCamera2d: release() takes [vx, vy], got ${JSON.stringify(velocity)}`)
638
+ finite("release vx", velocity[0])
639
+ finite("release vy", velocity[1])
640
+ release(velocity)
555
641
  notify()
556
642
  },
557
643
  zoomAt(sx, sy, factor, opts) {
@@ -565,8 +651,8 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
565
651
  rollBy(angle)
566
652
  flush()
567
653
  },
568
- glideTo(tx, ty, tz) {
569
- glideTo(tx, ty, tz)
654
+ glideTo(tx, ty, tz, tr) {
655
+ glideTo(tx, ty, tz, tr)
570
656
  notify()
571
657
  },
572
658
  fit(rect, opts) {
@@ -588,8 +674,10 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
588
674
  // a moving target must not cancel the zoom under it.
589
675
  if (glide !== null && glide.kind === "pose") glide = null
590
676
  fling = null
591
- if (followAt === null) followAt = { x: tx, y: ty }
592
- else {
677
+ if (followAt === null) {
678
+ followAt = { x: tx, y: ty }
679
+ lookahead.reset()
680
+ } else {
593
681
  followAt.x = tx
594
682
  followAt.y = ty
595
683
  }
@@ -600,6 +688,11 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
600
688
  followAt = null
601
689
  notify()
602
690
  },
691
+ shake(strength, duration, opts) {
692
+ checkShake("createCamera2d", strength, duration, opts)
693
+ lanes.shake(strength, duration, opts)
694
+ notify()
695
+ },
603
696
  interrupt,
604
697
  viewRect() {
605
698
  readViewport()
@@ -653,11 +746,6 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
653
746
  clamp()
654
747
  touch()
655
748
  }
656
- vx += (dragDx / dt - vx) * VELOCITY_SMOOTH
657
- vy += (dragDy / dt - vy) * VELOCITY_SMOOTH
658
- dragDx = 0
659
- dragDy = 0
660
- lastDt = dt
661
749
  }
662
750
  if (fling !== null && dt > 0) {
663
751
  shift(fling.vx * dt, fling.vy * dt)
@@ -665,57 +753,53 @@ export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], option
665
753
  fling.vx *= decay
666
754
  fling.vy *= decay
667
755
  if (Math.hypot(fling.vx, fling.vy) < LAND_PX * FLING_DECAY) fling = null
668
- clamp()
756
+ clampTo(dt)
669
757
  touch()
670
758
  }
671
759
  if (glide !== null && dt > 0) {
672
760
  // A wheel notch's glide coasts as `damping` says; a commanded
673
761
  // glide (glideTo, fit) at the built-in rate.
674
- let k = 1 - Math.exp(-(glide.kind === "anchor" ? GLIDE_EASE / damping() : GLIDE_EASE) * dt)
762
+ let k = easeStep(glide.kind === "anchor" ? GLIDE_EASE / damping() : GLIDE_EASE, dt)
675
763
  if (glide.kind === "anchor") {
676
764
  let gap = Math.log(glide.target / zoom)
677
765
  zoom = Math.abs(gap) < GLIDE_EPSILON ? glide.target : zoom * Math.exp(gap * k)
678
- ;[x, y] = anchorPose(glide.wx, glide.wy, glide.sx, glide.sy, zoom)
766
+ let [nx, ny] = anchorPose(glide.wx, glide.wy, glide.sx, glide.sy, zoom)
767
+ let [lx, ly] = withOffset(0, 0, zoom)
768
+ x = nx - lx
769
+ y = ny - ly
679
770
  if (zoom === glide.target) glide = null
680
771
  } else {
681
772
  let gap = Math.log(glide.zoom / zoom)
682
- let landed = Math.abs(gap) < GLIDE_EPSILON && Math.hypot(glide.x - x, glide.y - y) * zoom < LAND_PX
773
+ let turn = glide.rotation - rotation
774
+ let landed = Math.abs(gap) < GLIDE_EPSILON && Math.hypot(glide.x - x, glide.y - y) * zoom < LAND_PX && Math.abs(turn) * Math.hypot(vw, vh) < LAND_PX
683
775
  if (landed) {
684
776
  x = glide.x
685
777
  y = glide.y
686
778
  zoom = glide.zoom
779
+ rotation = glide.rotation
687
780
  glide = null
688
781
  } else {
689
782
  zoom *= Math.exp(gap * k)
690
783
  x += (glide.x - x) * k
691
784
  y += (glide.y - y) * k
785
+ rotation += turn * k
692
786
  }
693
787
  }
694
- clamp()
788
+ clampTo(dt)
695
789
  touch()
696
790
  }
697
791
  if (followAt !== null && !followSettled && dt > 0 && known()) {
698
- // How far the target sits outside the dead zone, in screen pixels;
699
- // the camera eases by that overshoot so the target rides the zone
700
- // edge, or snaps the last sub-pixel and settles.
701
- let [sx, sy] = projectCamera(camera(), followAt.x, followAt.y)
702
- let hw = (deadW() * vw) / 2
703
- let hh = (deadH() * vh) / 2
704
- let ox = sx < px() - hw ? sx - (px() - hw) : sx > px() + hw ? sx - (px() + hw) : 0
705
- let oy = sy < py() - hh ? sy - (py() - hh) : sy > py() + hh ? sy - (py() + hh) : 0
706
- if (Math.hypot(ox, oy) < LAND_PX) {
707
- if (ox !== 0 || oy !== 0) {
708
- shift(-ox, -oy)
709
- clamp()
710
- dirty = true
711
- }
712
- followSettled = true
713
- } else {
714
- let k = 1 - Math.exp(-followEase() * dt)
715
- shift(-ox * k, -oy * k)
716
- clamp()
717
- dirty = true
718
- }
792
+ stepFollow(dt)
793
+ }
794
+ if (lanes.active() && dt > 0) {
795
+ lanes.step(dt)
796
+ dirty = true
797
+ }
798
+ if (!boundsSettled && dt > 0 && glide === null && fling === null && (followAt === null || followSettled)) {
799
+ // A damped bound still easing after the motion that crossed it
800
+ // has landed: finish the way in.
801
+ clampTo(dt)
802
+ dirty = true
719
803
  }
720
804
  if (dirty) {
721
805
  dirty = false