@solidrt/2d 0.0.56 → 0.0.58
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/AGENTS.md +560 -267
- package/README.md +3 -3
- package/examples/README.md +38 -13
- package/examples/anim.tsx +6 -8
- package/examples/camera-probe.tsx +16 -14
- package/examples/camera.tsx +21 -15
- package/examples/parity.tsx +11 -9
- package/examples/pick.tsx +43 -7
- package/examples/split-screen.tsx +111 -0
- package/examples/springs.tsx +6 -9
- package/examples/sprites.tsx +9 -12
- package/examples/tiles.tsx +24 -9
- package/examples/views.tsx +177 -0
- package/package.json +2 -2
- package/src/atlas.ts +35 -33
- package/src/camera.ts +26 -1
- package/src/camera2d.ts +692 -119
- package/src/components/auto-oversample.ts +27 -0
- package/src/components/camera2d.tsx +82 -0
- package/src/components/context.ts +48 -0
- package/src/components/group.tsx +68 -0
- package/src/components/index.ts +28 -0
- package/src/components/sprite-layer.tsx +312 -0
- package/src/components/sprite.tsx +72 -0
- package/src/components/tile-layer.tsx +160 -0
- package/src/components/view2d.tsx +151 -0
- package/src/dispatch.ts +7 -7
- package/src/frames.ts +63 -14
- package/src/index.ts +27 -11
- package/src/input.ts +50 -0
- package/src/layer.ts +384 -245
- package/src/oversample-math.ts +1 -1
- package/src/records.ts +33 -138
- package/src/shaders.ts +65 -3
- package/src/tiles.ts +195 -42
- package/src/views.ts +294 -0
- package/src/camera-motion.ts +0 -532
- package/src/components.tsx +0 -611
package/src/camera-motion.ts
DELETED
|
@@ -1,532 +0,0 @@
|
|
|
1
|
-
// The 2d camera's motion, pure: a pose (the world point at a viewport
|
|
2
|
-
// pivot, zoom, rotation), the contain clamp against world bounds, anchored
|
|
3
|
-
// zoom, the eased glides (wheel notch, glideTo, fit), follow with damping
|
|
4
|
-
// and a dead zone, and drag inertia. Everything createCamera2d does except
|
|
5
|
-
// the input glue, which lives in camera2d.ts: this module imports no GUI
|
|
6
|
-
// or core module, so checks/camera2d-check.ts runs it headless on flux.
|
|
7
|
-
//
|
|
8
|
-
// Conventions, decided against Godot's Camera2D, Unity's Cinemachine and
|
|
9
|
-
// Three's MapControls (plus the map/whiteboard libraries for the canvas
|
|
10
|
-
// half): the pose is "world point at the pivot" with the pivot defaulting
|
|
11
|
-
// to the viewport center (Godot's drag-center anchor, Cinemachine's screen
|
|
12
|
-
// position, a map's center); bounds CONTAIN the view and center an axis
|
|
13
|
-
// whose view is wider than the world (Cinemachine's confiner skeleton,
|
|
14
|
-
// tldraw's contain origin - Godot's left-wins is the worse behavior);
|
|
15
|
-
// limits ignore rotation, clamping the unrotated view rect (Godot's rule);
|
|
16
|
-
// every ease is exponential and frame-rate independent, zoom eased in log
|
|
17
|
-
// space so a long glide reads evenly.
|
|
18
|
-
|
|
19
|
-
import { projectCamera, unprojectCamera } from "./camera.ts"
|
|
20
|
-
import type { CameraUpdate } from "./camera.ts"
|
|
21
|
-
|
|
22
|
-
// Zoom exponent per wheel-delta unit (wheel up zooms in); the same rate as
|
|
23
|
-
// @solidrt/3d's orbit camera, so the two feel alike.
|
|
24
|
-
const WHEEL_ZOOM = 0.0015
|
|
25
|
-
// Glide rate toward a pending zoom or pose, e-foldings per second: high
|
|
26
|
-
// enough that a wheel notch reads as one push, low enough to look smooth.
|
|
27
|
-
const GLIDE_EASE = 9
|
|
28
|
-
// A glide lands when the remaining zoom gap is under this (relative) ...
|
|
29
|
-
const GLIDE_EPSILON = 0.002
|
|
30
|
-
// ... and the remaining travel is under this many screen pixels. Also the
|
|
31
|
-
// follow's settle threshold and, times the fling decay, the speed a fling
|
|
32
|
-
// stops at (the distance it would still travel).
|
|
33
|
-
const LAND_PX = 0.5
|
|
34
|
-
// Follow damping, e-foldings per second (Godot's default
|
|
35
|
-
// position_smoothing_speed).
|
|
36
|
-
const FOLLOW_EASE = 5
|
|
37
|
-
// Inertia after a drag release: velocity decay in e-foldings per second. A
|
|
38
|
-
// 1500 px/s flick travels 500 px; iOS scrolling decelerates at ~2, maps
|
|
39
|
-
// are snappier.
|
|
40
|
-
const FLING_DECAY = 3
|
|
41
|
-
// Release speeds under this (px/s) do not fling: a finger that stopped
|
|
42
|
-
// before lifting leaves the view where it is.
|
|
43
|
-
const FLING_MIN_SPEED = 50
|
|
44
|
-
// Per-update EMA weight of the drag velocity estimate: heavy on the newest
|
|
45
|
-
// frame so a direction change registers within a frame or two.
|
|
46
|
-
const VELOCITY_SMOOTH = 0.5
|
|
47
|
-
// Frame time assumed for the drag delta still pending at a release when the
|
|
48
|
-
// release arrives in the same frame as the last move.
|
|
49
|
-
const FALLBACK_DT = 1 / 60
|
|
50
|
-
|
|
51
|
-
/** What a 2d camera drives: anything with the layers' `setCamera`. */
|
|
52
|
-
export type Camera2dTarget = { setCamera(update: CameraUpdate): void }
|
|
53
|
-
|
|
54
|
-
export type Camera2dPose = {
|
|
55
|
-
x?: number
|
|
56
|
-
y?: number
|
|
57
|
-
zoom?: number
|
|
58
|
-
rotation?: number
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export type Rect2d = { x: number; y: number; width: number; height: number }
|
|
62
|
-
|
|
63
|
-
export type Camera2dMotionOptions = Camera2dPose & {
|
|
64
|
-
/** Viewport size in layer pixels, read whenever the camera needs it (a
|
|
65
|
-
* resize re-clamps at the next update). Return zeros while unknown -
|
|
66
|
-
* clamping and fitting wait for a real size. */
|
|
67
|
-
viewport: () => { width: number; height: number }
|
|
68
|
-
/** World bounds (origin 0,0) the view is kept inside: an axis whose view
|
|
69
|
-
* is wider than the world centers. Absent = unbounded. */
|
|
70
|
-
world?: { width: number; height: number }
|
|
71
|
-
/** Zoom range. minZoom defaults to the fit zoom (whole world visible)
|
|
72
|
-
* when `world` is given, else to no lower bound; maxZoom to no upper
|
|
73
|
-
* bound. An explicit maxZoom below the fit zoom wins, the world then
|
|
74
|
-
* floats centered. */
|
|
75
|
-
minZoom?: number
|
|
76
|
-
maxZoom?: number
|
|
77
|
-
/** Where in the viewport the camera's world point sits, as fractions of
|
|
78
|
-
* its size; default the center. glideTo and follow land their point
|
|
79
|
-
* here and rotation turns about it. */
|
|
80
|
-
pivot?: { x: number; y: number }
|
|
81
|
-
/** Follow dead zone as fractions of the viewport, centered on the pivot:
|
|
82
|
-
* the target roams inside it without moving the camera. Default 0,0. */
|
|
83
|
-
deadZone?: { width: number; height: number }
|
|
84
|
-
/** Multiplier over the built-in wheel and pinch sensitivity. */
|
|
85
|
-
zoomSpeed?: number
|
|
86
|
-
/** Multiplier over the built-in follow damping. */
|
|
87
|
-
followSpeed?: number
|
|
88
|
-
/** Whether a drag release keeps the view gliding (default true). */
|
|
89
|
-
inertia?: boolean
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export type Camera2dMotion = {
|
|
93
|
-
/** The pose as the layers receive it (a fresh object per call): the
|
|
94
|
-
* argument for projectCamera/unprojectCamera. */
|
|
95
|
-
camera(): CameraUpdate
|
|
96
|
-
/** Merge a pose in (clamps apply) and reach the layers at the next
|
|
97
|
-
* update(). A snap: an x/y/zoom write cancels a glide or fling in
|
|
98
|
-
* flight; rotation alone leaves them running. */
|
|
99
|
-
set(pose: Camera2dPose): void
|
|
100
|
-
/** Pan the content by a screen delta (a drag: positive dx slides the
|
|
101
|
-
* world rightward). Feeds the inertia estimate; release() flings. */
|
|
102
|
-
panBy(dx: number, dy: number): void
|
|
103
|
-
/** End of a drag: keep gliding with the drag's velocity (inertia). */
|
|
104
|
-
release(): void
|
|
105
|
-
/** Zoom by `factor` about a screen point - the world point under it
|
|
106
|
-
* stays under it. */
|
|
107
|
-
zoomAt(sx: number, sy: number, factor: number): void
|
|
108
|
-
/** A wheel notch at a screen point: retargets an eased, anchored zoom. */
|
|
109
|
-
wheel(sx: number, sy: number, deltaY: number): void
|
|
110
|
-
/** Ease the pose until world (x, y) sits at the pivot, at `zoom` (default
|
|
111
|
-
* the current zoom). Cancels a follow. */
|
|
112
|
-
glideTo(x: number, y: number, zoom?: number): void
|
|
113
|
-
/** Show a world rect (default the world) whole, centered in the
|
|
114
|
-
* viewport, snapping or gliding. Waits for the viewport if unknown. */
|
|
115
|
-
fit(rect?: Rect2d, opts?: { glide?: boolean }): void
|
|
116
|
-
/** Keep world (x, y) at the pivot through the dead zone and damping;
|
|
117
|
-
* call again whenever the target moves. Cancels a glide. */
|
|
118
|
-
follow(x: number, y: number): void
|
|
119
|
-
unfollow(): void
|
|
120
|
-
/** Stop a glide or fling in flight (a press landing). */
|
|
121
|
-
interrupt(): void
|
|
122
|
-
/** The world-space bounding box of what the viewport shows. */
|
|
123
|
-
viewRect(): Rect2d
|
|
124
|
-
/** Advance glides, follow and inertia, then push the pose to the
|
|
125
|
-
* layers if it changed. Call from onFrame with the frame's dt in
|
|
126
|
-
* seconds; returns whether the pose changed. */
|
|
127
|
-
update(dt: number): boolean
|
|
128
|
-
/**
|
|
129
|
-
* Whether update() still has work: a glide, fling or deferred fit in
|
|
130
|
-
* flight, a follow engaged, or a write not yet pushed. The frame-loop
|
|
131
|
-
* gate - run update(dt) on frames while true, and nothing while false
|
|
132
|
-
* (a resting camera costs no frames).
|
|
133
|
-
*/
|
|
134
|
-
active(): boolean
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
type Glide = { kind: "anchor"; target: number; sx: number; sy: number; wx: number; wy: number } | { kind: "pose"; x: number; y: number; zoom: number }
|
|
138
|
-
|
|
139
|
-
let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
|
140
|
-
|
|
141
|
-
function positive(what: string, v: number): void {
|
|
142
|
-
if (!(Number.isFinite(v) && v > 0)) throw new Error(`createCamera2d: ${what} must be a positive number, got ${v}`)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function finite(what: string, v: number | undefined): void {
|
|
146
|
-
if (v !== undefined && !Number.isFinite(v)) throw new Error(`createCamera2d: ${what} must be a finite number, got ${v}`)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function checkPose(verb: string, pose: Camera2dPose): void {
|
|
150
|
-
finite(`${verb} x`, pose.x)
|
|
151
|
-
finite(`${verb} y`, pose.y)
|
|
152
|
-
finite(`${verb} rotation`, pose.rotation)
|
|
153
|
-
if (pose.zoom !== undefined) positive(`${verb} zoom`, pose.zoom)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function checkFraction(what: string, v: number): void {
|
|
157
|
-
if (!(Number.isFinite(v) && v >= 0 && v <= 1)) throw new Error(`createCamera2d: ${what} must be within 0..1, got ${v}`)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The pure motion behind createCamera2d. `onActive` reports each flip of
|
|
162
|
-
* active() as it happens (the reactive face in camera2d.ts feeds a signal
|
|
163
|
-
* from it); pure callers poll active() instead.
|
|
164
|
-
*/
|
|
165
|
-
export function createCameraMotion(target: Camera2dTarget | Camera2dTarget[], options: Camera2dMotionOptions, onActive?: (active: boolean) => void): Camera2dMotion {
|
|
166
|
-
let targets = Array.isArray(target) ? target : [target]
|
|
167
|
-
if (typeof options.viewport !== "function") throw new Error("createCamera2d: viewport must be a function returning { width, height }")
|
|
168
|
-
let world = options.world ?? null
|
|
169
|
-
if (world) {
|
|
170
|
-
positive("world.width", world.width)
|
|
171
|
-
positive("world.height", world.height)
|
|
172
|
-
}
|
|
173
|
-
if (options.minZoom !== undefined) positive("minZoom", options.minZoom)
|
|
174
|
-
if (options.maxZoom !== undefined) positive("maxZoom", options.maxZoom)
|
|
175
|
-
if (options.minZoom !== undefined && options.maxZoom !== undefined && options.minZoom > options.maxZoom) {
|
|
176
|
-
throw new Error(`createCamera2d: minZoom ${options.minZoom} exceeds maxZoom ${options.maxZoom}`)
|
|
177
|
-
}
|
|
178
|
-
checkPose("", options)
|
|
179
|
-
let pivotFx = options.pivot?.x ?? 0.5
|
|
180
|
-
let pivotFy = options.pivot?.y ?? 0.5
|
|
181
|
-
finite("pivot.x", pivotFx)
|
|
182
|
-
finite("pivot.y", pivotFy)
|
|
183
|
-
let deadW = options.deadZone?.width ?? 0
|
|
184
|
-
let deadH = options.deadZone?.height ?? 0
|
|
185
|
-
checkFraction("deadZone.width", deadW)
|
|
186
|
-
checkFraction("deadZone.height", deadH)
|
|
187
|
-
let wheelZoom = WHEEL_ZOOM * (options.zoomSpeed ?? 1)
|
|
188
|
-
let zoomExponent = options.zoomSpeed ?? 1
|
|
189
|
-
let followEase = FOLLOW_EASE * (options.followSpeed ?? 1)
|
|
190
|
-
let inertia = options.inertia ?? true
|
|
191
|
-
let maxZoom = options.maxZoom ?? Infinity
|
|
192
|
-
|
|
193
|
-
let x = options.x ?? (world ? world.width / 2 : 0)
|
|
194
|
-
let y = options.y ?? (world ? world.height / 2 : 0)
|
|
195
|
-
let zoom = options.zoom ?? 1
|
|
196
|
-
let rotation = options.rotation ?? 0
|
|
197
|
-
|
|
198
|
-
// The viewport as last read; zeros mean unknown (nothing clamps or fits
|
|
199
|
-
// until a real size arrives).
|
|
200
|
-
let vw = 0
|
|
201
|
-
let vh = 0
|
|
202
|
-
let known = () => vw > 0 && vh > 0
|
|
203
|
-
let readViewport = () => {
|
|
204
|
-
let v = options.viewport()
|
|
205
|
-
if (!(Number.isFinite(v.width) && Number.isFinite(v.height) && v.width >= 0 && v.height >= 0)) {
|
|
206
|
-
throw new Error(`createCamera2d: viewport() must return non-negative finite width/height, got ${v.width}x${v.height}`)
|
|
207
|
-
}
|
|
208
|
-
vw = v.width
|
|
209
|
-
vh = v.height
|
|
210
|
-
}
|
|
211
|
-
let px = () => vw * pivotFx
|
|
212
|
-
let py = () => vh * pivotFy
|
|
213
|
-
|
|
214
|
-
let minZoom = () => {
|
|
215
|
-
let fit = world && known() ? Math.min(vw / world.width, vh / world.height) : 0
|
|
216
|
-
return Math.min(options.minZoom ?? fit, maxZoom)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// The contain clamp on an unrotated view rect (Godot's rule: limits
|
|
220
|
-
// ignore rotation): returns the pose's x/y, moved inside the world.
|
|
221
|
-
let contain = (cx: number, cy: number, z: number): [number, number] => {
|
|
222
|
-
if (!world || !known()) return [cx, cy]
|
|
223
|
-
let viewW = vw / z
|
|
224
|
-
let viewH = vh / z
|
|
225
|
-
let left = cx - px() / z
|
|
226
|
-
let top = cy - py() / z
|
|
227
|
-
left = viewW >= world.width ? (world.width - viewW) / 2 : clampNum(left, 0, world.width - viewW)
|
|
228
|
-
top = viewH >= world.height ? (world.height - viewH) / 2 : clampNum(top, 0, world.height - viewH)
|
|
229
|
-
return [left + px() / z, top + py() / z]
|
|
230
|
-
}
|
|
231
|
-
let clamp = () => {
|
|
232
|
-
zoom = clampNum(zoom, minZoom(), maxZoom)
|
|
233
|
-
;[x, y] = contain(x, y, zoom)
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
let camera = (): CameraUpdate => ({ x, y, zoom, rotation, pivotX: px(), pivotY: py() })
|
|
237
|
-
// Pose x/y that put world (wx, wy) under screen (sx, sy) at zoom z:
|
|
238
|
-
// the inverse of projectCamera solved for the camera point.
|
|
239
|
-
let anchorPose = (wx: number, wy: number, sx: number, sy: number, z: number): [number, number] => {
|
|
240
|
-
let c = Math.cos(rotation)
|
|
241
|
-
let s = Math.sin(rotation)
|
|
242
|
-
let dx = sx - px()
|
|
243
|
-
let dy = sy - py()
|
|
244
|
-
return [wx - (dx * c + dy * s) / z, wy - (dy * c - dx * s) / z]
|
|
245
|
-
}
|
|
246
|
-
// Slide the content by a screen delta: the camera point moves the
|
|
247
|
-
// opposite way, the delta un-rotated into world axes.
|
|
248
|
-
let shift = (dx: number, dy: number) => {
|
|
249
|
-
let c = Math.cos(rotation)
|
|
250
|
-
let s = Math.sin(rotation)
|
|
251
|
-
x -= (dx * c + dy * s) / zoom
|
|
252
|
-
y -= (dy * c - dx * s) / zoom
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
let dirty = false
|
|
256
|
-
let glide: Glide | null = null
|
|
257
|
-
let fling: { vx: number; vy: number } | null = null
|
|
258
|
-
let followAt: { x: number; y: number } | null = null
|
|
259
|
-
// A follow that reached its target stops writing until something moves.
|
|
260
|
-
let followSettled = false
|
|
261
|
-
// Drag deltas since the last update, and the smoothed velocity (px/s).
|
|
262
|
-
let dragDx = 0
|
|
263
|
-
let dragDy = 0
|
|
264
|
-
let vx = 0
|
|
265
|
-
let vy = 0
|
|
266
|
-
let lastDt = FALLBACK_DT
|
|
267
|
-
// A fit that waits for the viewport (the default pose when a world is
|
|
268
|
-
// given, or an explicit fit() before the size is known).
|
|
269
|
-
let pendingFit: { rect: Rect2d | null; glide: boolean } | null = options.zoom === undefined && world ? { rect: null, glide: false } : null
|
|
270
|
-
|
|
271
|
-
let touch = () => {
|
|
272
|
-
dirty = true
|
|
273
|
-
followSettled = false
|
|
274
|
-
}
|
|
275
|
-
let active = () => dirty || glide !== null || fling !== null || followAt !== null || pendingFit !== null
|
|
276
|
-
// Report flips of active() to the hook; every public entry ends here.
|
|
277
|
-
let wasActive = false
|
|
278
|
-
let notify = () => {
|
|
279
|
-
let now = active()
|
|
280
|
-
if (now === wasActive) return
|
|
281
|
-
wasActive = now
|
|
282
|
-
onActive?.(now)
|
|
283
|
-
}
|
|
284
|
-
let interrupt = () => {
|
|
285
|
-
glide = null
|
|
286
|
-
fling = null
|
|
287
|
-
notify()
|
|
288
|
-
}
|
|
289
|
-
let apply = () => {
|
|
290
|
-
let update = camera()
|
|
291
|
-
for (let t of targets) t.setCamera(update)
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
let zoomTo = (sx: number, sy: number, next: number) => {
|
|
295
|
-
let [wx, wy] = unprojectCamera(camera(), sx, sy)
|
|
296
|
-
zoom = clampNum(next, minZoom(), maxZoom)
|
|
297
|
-
;[x, y] = anchorPose(wx, wy, sx, sy, zoom)
|
|
298
|
-
clamp()
|
|
299
|
-
touch()
|
|
300
|
-
}
|
|
301
|
-
let glideTo = (tx: number, ty: number, tz?: number) => {
|
|
302
|
-
finite("glideTo x", tx)
|
|
303
|
-
finite("glideTo y", ty)
|
|
304
|
-
if (tz !== undefined) positive("glideTo zoom", tz)
|
|
305
|
-
readViewport()
|
|
306
|
-
fling = null
|
|
307
|
-
followAt = null
|
|
308
|
-
let z = clampNum(tz ?? zoom, minZoom(), maxZoom)
|
|
309
|
-
let [cx, cy] = contain(tx, ty, z)
|
|
310
|
-
glide = { kind: "pose", x: cx, y: cy, zoom: z }
|
|
311
|
-
}
|
|
312
|
-
let fitNow = (rect: Rect2d | null, ease: boolean) => {
|
|
313
|
-
let r = rect ?? (world ? { x: 0, y: 0, width: world.width, height: world.height } : null)
|
|
314
|
-
if (!r) throw new Error("createCamera2d: fit() needs a rect when the camera has no world")
|
|
315
|
-
let z = clampNum(Math.min(vw / r.width, vh / r.height), minZoom(), maxZoom)
|
|
316
|
-
let [nx, ny] = anchorPose(r.x + r.width / 2, r.y + r.height / 2, vw / 2, vh / 2, z)
|
|
317
|
-
if (ease) {
|
|
318
|
-
glideTo(nx, ny, z)
|
|
319
|
-
return
|
|
320
|
-
}
|
|
321
|
-
interrupt()
|
|
322
|
-
x = nx
|
|
323
|
-
y = ny
|
|
324
|
-
zoom = z
|
|
325
|
-
clamp()
|
|
326
|
-
touch()
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
readViewport()
|
|
330
|
-
clamp()
|
|
331
|
-
if (pendingFit && known()) {
|
|
332
|
-
fitNow(pendingFit.rect, pendingFit.glide)
|
|
333
|
-
pendingFit = null
|
|
334
|
-
}
|
|
335
|
-
dirty = false
|
|
336
|
-
apply()
|
|
337
|
-
notify()
|
|
338
|
-
|
|
339
|
-
return {
|
|
340
|
-
camera,
|
|
341
|
-
active,
|
|
342
|
-
set(pose) {
|
|
343
|
-
checkPose("set", pose)
|
|
344
|
-
readViewport()
|
|
345
|
-
if (pose.x !== undefined || pose.y !== undefined || pose.zoom !== undefined) interrupt()
|
|
346
|
-
if (pose.x !== undefined) x = pose.x
|
|
347
|
-
if (pose.y !== undefined) y = pose.y
|
|
348
|
-
if (pose.zoom !== undefined) zoom = pose.zoom
|
|
349
|
-
if (pose.rotation !== undefined) rotation = pose.rotation
|
|
350
|
-
clamp()
|
|
351
|
-
touch()
|
|
352
|
-
notify()
|
|
353
|
-
},
|
|
354
|
-
panBy(dx, dy) {
|
|
355
|
-
readViewport()
|
|
356
|
-
interrupt()
|
|
357
|
-
shift(dx, dy)
|
|
358
|
-
clamp()
|
|
359
|
-
dragDx += dx
|
|
360
|
-
dragDy += dy
|
|
361
|
-
touch()
|
|
362
|
-
notify()
|
|
363
|
-
},
|
|
364
|
-
release() {
|
|
365
|
-
// Fold the delta still pending from the release's own frame in with
|
|
366
|
-
// the last frame time, then start from rest either way.
|
|
367
|
-
let rvx = vx + (dragDx / lastDt - vx) * VELOCITY_SMOOTH
|
|
368
|
-
let rvy = vy + (dragDy / lastDt - vy) * VELOCITY_SMOOTH
|
|
369
|
-
dragDx = 0
|
|
370
|
-
dragDy = 0
|
|
371
|
-
vx = 0
|
|
372
|
-
vy = 0
|
|
373
|
-
if (!inertia || followAt !== null) return
|
|
374
|
-
if (Math.hypot(rvx, rvy) < FLING_MIN_SPEED) return
|
|
375
|
-
fling = { vx: rvx, vy: rvy }
|
|
376
|
-
notify()
|
|
377
|
-
},
|
|
378
|
-
zoomAt(sx, sy, factor) {
|
|
379
|
-
positive("zoomAt factor", factor)
|
|
380
|
-
readViewport()
|
|
381
|
-
interrupt()
|
|
382
|
-
zoomTo(sx, sy, zoom * Math.pow(factor, zoomExponent))
|
|
383
|
-
notify()
|
|
384
|
-
},
|
|
385
|
-
wheel(sx, sy, deltaY) {
|
|
386
|
-
readViewport()
|
|
387
|
-
fling = null
|
|
388
|
-
if (glide !== null && glide.kind === "pose") glide = null
|
|
389
|
-
// Notches compound on the pending target, so a fast scroll is one
|
|
390
|
-
// long push; the anchor is the world point under the pointer now.
|
|
391
|
-
let from = glide !== null ? glide.target : zoom
|
|
392
|
-
let target = clampNum(from * Math.exp(-deltaY * wheelZoom), minZoom(), maxZoom)
|
|
393
|
-
let [wx, wy] = unprojectCamera(camera(), sx, sy)
|
|
394
|
-
glide = { kind: "anchor", target, sx, sy, wx, wy }
|
|
395
|
-
notify()
|
|
396
|
-
},
|
|
397
|
-
glideTo(tx, ty, tz) {
|
|
398
|
-
glideTo(tx, ty, tz)
|
|
399
|
-
notify()
|
|
400
|
-
},
|
|
401
|
-
fit(rect, opts) {
|
|
402
|
-
readViewport()
|
|
403
|
-
if (!known()) {
|
|
404
|
-
pendingFit = { rect: rect ?? null, glide: opts?.glide ?? false }
|
|
405
|
-
notify()
|
|
406
|
-
return
|
|
407
|
-
}
|
|
408
|
-
fitNow(rect ?? null, opts?.glide ?? false)
|
|
409
|
-
notify()
|
|
410
|
-
},
|
|
411
|
-
follow(tx, ty) {
|
|
412
|
-
finite("follow x", tx)
|
|
413
|
-
finite("follow y", ty)
|
|
414
|
-
glide = null
|
|
415
|
-
fling = null
|
|
416
|
-
if (followAt === null) followAt = { x: tx, y: ty }
|
|
417
|
-
else {
|
|
418
|
-
followAt.x = tx
|
|
419
|
-
followAt.y = ty
|
|
420
|
-
}
|
|
421
|
-
followSettled = false
|
|
422
|
-
notify()
|
|
423
|
-
},
|
|
424
|
-
unfollow() {
|
|
425
|
-
followAt = null
|
|
426
|
-
notify()
|
|
427
|
-
},
|
|
428
|
-
interrupt,
|
|
429
|
-
viewRect() {
|
|
430
|
-
readViewport()
|
|
431
|
-
let cam = camera()
|
|
432
|
-
let corners = [unprojectCamera(cam, 0, 0), unprojectCamera(cam, vw, 0), unprojectCamera(cam, 0, vh), unprojectCamera(cam, vw, vh)]
|
|
433
|
-
let minX = Infinity
|
|
434
|
-
let minY = Infinity
|
|
435
|
-
let maxX = -Infinity
|
|
436
|
-
let maxY = -Infinity
|
|
437
|
-
for (let [cx, cy] of corners) {
|
|
438
|
-
minX = Math.min(minX, cx)
|
|
439
|
-
minY = Math.min(minY, cy)
|
|
440
|
-
maxX = Math.max(maxX, cx)
|
|
441
|
-
maxY = Math.max(maxY, cy)
|
|
442
|
-
}
|
|
443
|
-
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
|
444
|
-
},
|
|
445
|
-
update(dt) {
|
|
446
|
-
let pw = vw
|
|
447
|
-
let ph = vh
|
|
448
|
-
readViewport()
|
|
449
|
-
if (vw !== pw || vh !== ph) {
|
|
450
|
-
// The pivot moved with the viewport; the world point stays under
|
|
451
|
-
// it. A resize can also change the fit zoom, hence the re-clamp.
|
|
452
|
-
if (pendingFit !== null && known()) {
|
|
453
|
-
fitNow(pendingFit.rect, pendingFit.glide)
|
|
454
|
-
pendingFit = null
|
|
455
|
-
}
|
|
456
|
-
clamp()
|
|
457
|
-
touch()
|
|
458
|
-
}
|
|
459
|
-
if (dt > 0) {
|
|
460
|
-
vx += (dragDx / dt - vx) * VELOCITY_SMOOTH
|
|
461
|
-
vy += (dragDy / dt - vy) * VELOCITY_SMOOTH
|
|
462
|
-
dragDx = 0
|
|
463
|
-
dragDy = 0
|
|
464
|
-
lastDt = dt
|
|
465
|
-
}
|
|
466
|
-
if (fling !== null && dt > 0) {
|
|
467
|
-
shift(fling.vx * dt, fling.vy * dt)
|
|
468
|
-
let decay = Math.exp(-FLING_DECAY * dt)
|
|
469
|
-
fling.vx *= decay
|
|
470
|
-
fling.vy *= decay
|
|
471
|
-
if (Math.hypot(fling.vx, fling.vy) < LAND_PX * FLING_DECAY) fling = null
|
|
472
|
-
clamp()
|
|
473
|
-
touch()
|
|
474
|
-
}
|
|
475
|
-
if (glide !== null && dt > 0) {
|
|
476
|
-
let k = 1 - Math.exp(-GLIDE_EASE * dt)
|
|
477
|
-
if (glide.kind === "anchor") {
|
|
478
|
-
let gap = Math.log(glide.target / zoom)
|
|
479
|
-
zoom = Math.abs(gap) < GLIDE_EPSILON ? glide.target : zoom * Math.exp(gap * k)
|
|
480
|
-
;[x, y] = anchorPose(glide.wx, glide.wy, glide.sx, glide.sy, zoom)
|
|
481
|
-
if (zoom === glide.target) glide = null
|
|
482
|
-
} else {
|
|
483
|
-
let gap = Math.log(glide.zoom / zoom)
|
|
484
|
-
let landed = Math.abs(gap) < GLIDE_EPSILON && Math.hypot(glide.x - x, glide.y - y) * zoom < LAND_PX
|
|
485
|
-
if (landed) {
|
|
486
|
-
x = glide.x
|
|
487
|
-
y = glide.y
|
|
488
|
-
zoom = glide.zoom
|
|
489
|
-
glide = null
|
|
490
|
-
} else {
|
|
491
|
-
zoom *= Math.exp(gap * k)
|
|
492
|
-
x += (glide.x - x) * k
|
|
493
|
-
y += (glide.y - y) * k
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
clamp()
|
|
497
|
-
touch()
|
|
498
|
-
}
|
|
499
|
-
if (followAt !== null && !followSettled && dt > 0 && known()) {
|
|
500
|
-
// How far the target sits outside the dead zone, in screen pixels;
|
|
501
|
-
// the camera eases by that overshoot so the target rides the zone
|
|
502
|
-
// edge, or snaps the last sub-pixel and settles.
|
|
503
|
-
let [sx, sy] = projectCamera(camera(), followAt.x, followAt.y)
|
|
504
|
-
let hw = (deadW * vw) / 2
|
|
505
|
-
let hh = (deadH * vh) / 2
|
|
506
|
-
let ox = sx < px() - hw ? sx - (px() - hw) : sx > px() + hw ? sx - (px() + hw) : 0
|
|
507
|
-
let oy = sy < py() - hh ? sy - (py() - hh) : sy > py() + hh ? sy - (py() + hh) : 0
|
|
508
|
-
if (Math.hypot(ox, oy) < LAND_PX) {
|
|
509
|
-
if (ox !== 0 || oy !== 0) {
|
|
510
|
-
shift(-ox, -oy)
|
|
511
|
-
clamp()
|
|
512
|
-
dirty = true
|
|
513
|
-
}
|
|
514
|
-
followSettled = true
|
|
515
|
-
} else {
|
|
516
|
-
let k = 1 - Math.exp(-followEase * dt)
|
|
517
|
-
shift(-ox * k, -oy * k)
|
|
518
|
-
clamp()
|
|
519
|
-
dirty = true
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
if (!dirty) {
|
|
523
|
-
notify()
|
|
524
|
-
return false
|
|
525
|
-
}
|
|
526
|
-
dirty = false
|
|
527
|
-
apply()
|
|
528
|
-
notify()
|
|
529
|
-
return true
|
|
530
|
-
},
|
|
531
|
-
}
|
|
532
|
-
}
|