@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/camera2d.ts
CHANGED
|
@@ -1,140 +1,713 @@
|
|
|
1
1
|
// The 2d camera: Godot's Camera2D and Three's MapControls in one control
|
|
2
|
-
// over the layers' shared CameraUpdate -
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// headless-checked; this file adds the input and the reactive face.
|
|
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.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// does for the elements a map control sits on.
|
|
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.
|
|
18
16
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
27
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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.
|
|
33
37
|
//
|
|
34
|
-
// Pose is plain mutable state
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
import
|
|
46
|
-
import {
|
|
47
|
-
import type {
|
|
48
|
-
import
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
65
174
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* drives, or its events arrive in a frame the camera does not show.
|
|
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).
|
|
71
179
|
*/
|
|
72
|
-
|
|
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)
|
|
73
227
|
}
|
|
74
228
|
|
|
75
229
|
/**
|
|
76
230
|
* Create a 2d camera driving `target`'s camera, where `target` is a sprite
|
|
77
|
-
* or record layer (or several: a
|
|
78
|
-
* camera), or anything else with the layers' `setCamera`, such
|
|
79
|
-
* setter feeding `<TileLayer camera>`. The initial pose
|
|
80
|
-
* immediately: the world fitted when `world` is given and no
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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.
|
|
83
241
|
*/
|
|
84
242
|
export function createCamera2d(target: Camera2dTarget | Camera2dTarget[], options: Camera2dOptions): Camera2d {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
let
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
},
|
|
113
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
|
|
114
507
|
}
|
|
508
|
+
dirty = false
|
|
509
|
+
apply()
|
|
510
|
+
changed = false
|
|
511
|
+
notify()
|
|
115
512
|
|
|
116
513
|
return {
|
|
117
|
-
|
|
514
|
+
camera,
|
|
118
515
|
active,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
138
711
|
},
|
|
139
712
|
}
|
|
140
713
|
}
|