@ksp-gonogo/kerbcast 1.4.0

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/panZoom.ts ADDED
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Headless pan/zoom control state machine extracted from the CameraFeed
3
+ * component. Manages rate deduplication, analog deadzone, optimistic
4
+ * accumulators for discrete nudges, debounced FoV slider, and echo-sync
5
+ * while idle -- all without any DOM or React dependency.
6
+ *
7
+ * `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
8
+ * camera handle directly as the sink and the controller will drive it.
9
+ *
10
+ * ```ts
11
+ * const cam = client.camera(flightId);
12
+ * const ctrl = new PanZoomController(cam);
13
+ *
14
+ * // Analog stick input
15
+ * ctrl.setPanRate(stickX, stickY);
16
+ *
17
+ * // Discrete nudge buttons
18
+ * ctrl.nudgePan(1, 0); // pan right one step
19
+ * ctrl.nudgeZoom(-1); // zoom in one step
20
+ *
21
+ * // FoV slider
22
+ * ctrl.setFovSliderDragging(true);
23
+ * ctrl.fovSliderInput(newFov);
24
+ * ctrl.setFovSliderDragging(false); // flushes immediately
25
+ *
26
+ * // Sync echoed state (call from camera "change" listener)
27
+ * ctrl.syncFromState({ fov, panYaw, panPitch, fovMin, fovMax,
28
+ * panYawMin, panYawMax, panPitchMin, panPitchMax });
29
+ *
30
+ * // Cleanup on unmount
31
+ * ctrl.stop();
32
+ * ```
33
+ */
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Command sink that the controller writes to. `KerbcastCameraHandle`
41
+ * satisfies this interface structurally -- no adapter needed.
42
+ */
43
+ export interface PanZoomCommandSink {
44
+ setPan(yaw: number, pitch: number): Promise<void> | void;
45
+ setPanRate(yawRate: number, pitchRate: number): Promise<void> | void;
46
+ setFov(fov: number): Promise<void> | void;
47
+ setZoomRate(rate: number): Promise<void> | void;
48
+ }
49
+
50
+ /** Camera FoV and pan bounds for clamping. */
51
+ export interface PanZoomBounds {
52
+ fovMin: number;
53
+ fovMax: number;
54
+ panYawMin: number;
55
+ panYawMax: number;
56
+ panPitchMin: number;
57
+ panPitchMax: number;
58
+ }
59
+
60
+ /** Tuning knobs for {@link PanZoomController}. All have safe defaults. */
61
+ export interface PanZoomControllerOptions {
62
+ /**
63
+ * Degrees moved per discrete pan nudge (arrow buttons, keyboard).
64
+ * Default: 5.
65
+ */
66
+ panNudgeDeg?: number;
67
+ /**
68
+ * Degrees moved per discrete zoom nudge (zoom buttons, keyboard).
69
+ * Default: 5.
70
+ */
71
+ fovNudgeDeg?: number;
72
+ /**
73
+ * Debounce window (ms) for the FoV slider. Only the settled value is
74
+ * sent; intermediate drag positions do not stream commands.
75
+ * Default: 120.
76
+ */
77
+ fovSliderDebounceMs?: number;
78
+ /**
79
+ * Magnitude below which an analog axis is snapped to 0. Prevents tiny
80
+ * dithering near centre from emitting a stream of non-zero rate commands.
81
+ * Default: 0.05.
82
+ */
83
+ analogDeadzone?: number;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Helpers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function clamp(v: number, lo: number, hi: number): number {
91
+ return Math.max(lo, Math.min(hi, v));
92
+ }
93
+
94
+ function applyDeadzone(v: number, deadzone: number): number {
95
+ return Math.abs(v) < deadzone ? 0 : v;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // PanZoomController
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Headless pan/zoom state machine. Manages rate deduplication, analog
104
+ * deadzone, optimistic accumulators, FoV slider debounce, and echo-sync
105
+ * idle rules. Mirrors the behaviour of the CameraFeed component in gonogo
106
+ * (CameraFeed.tsx lines 323-592) without any React or DOM coupling.
107
+ */
108
+ export class PanZoomController {
109
+ private readonly sink: PanZoomCommandSink;
110
+ private readonly panNudgeDeg: number;
111
+ private readonly fovNudgeDeg: number;
112
+ private readonly fovSliderDebounceMs: number;
113
+ private readonly analogDeadzone: number;
114
+
115
+ // Last-sent rates (dedupe against re-sending the same value).
116
+ private sentPanRate = { yaw: 0, pitch: 0 };
117
+ private sentZoomRate = 0;
118
+
119
+ // Optimistic accumulators for discrete nudges.
120
+ private localPan = { yaw: 0, pitch: 0 };
121
+ private localFov = 0;
122
+
123
+ // FoV slider state.
124
+ private _sliderFov = 0;
125
+ private sliderDragging = false;
126
+ private fovDebounceTimer: ReturnType<typeof setTimeout> | null = null;
127
+ private pendingFov: number | null = null;
128
+
129
+ // Drag-ball flag (used only for the idle rule; pixel math lives in the UI).
130
+ private ballDragging = false;
131
+
132
+ // Last-known bounds for clamping.
133
+ private bounds: PanZoomBounds = {
134
+ fovMin: 0,
135
+ fovMax: 180,
136
+ panYawMin: -180,
137
+ panYawMax: 180,
138
+ panPitchMin: -90,
139
+ panPitchMax: 90,
140
+ };
141
+
142
+ // sliderFov change subscribers.
143
+ private readonly sliderFovListeners = new Set<(fov: number) => void>();
144
+
145
+ constructor(sink: PanZoomCommandSink, opts: PanZoomControllerOptions = {}) {
146
+ this.sink = sink;
147
+ this.panNudgeDeg = opts.panNudgeDeg ?? 5;
148
+ this.fovNudgeDeg = opts.fovNudgeDeg ?? 5;
149
+ this.fovSliderDebounceMs = opts.fovSliderDebounceMs ?? 120;
150
+ this.analogDeadzone = opts.analogDeadzone ?? 0.05;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Public getters
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Optimistic FoV value tracked by the slider. Follows the pointer while
159
+ * dragging, then the camera echo when idle. Reactive via
160
+ * {@link onSliderFov}.
161
+ */
162
+ get sliderFov(): number {
163
+ return this._sliderFov;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Echo-sync
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Sync from the camera's echoed state (call from the camera's `"change"`
172
+ * event listener). Applies the echoed pan to the pan accumulator only when
173
+ * the controller is pan-idle (no active pan rate, no active ball drag).
174
+ * Applies the echoed FoV to the accumulator and slider only when the
175
+ * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
176
+ * debounced send). Mirrors CameraFeed.tsx:370-387.
177
+ */
178
+ syncFromState(
179
+ state: { fov: number; panYaw: number; panPitch: number } & PanZoomBounds,
180
+ ): void {
181
+ this.bounds = {
182
+ fovMin: state.fovMin,
183
+ fovMax: state.fovMax,
184
+ panYawMin: state.panYawMin,
185
+ panYawMax: state.panYawMax,
186
+ panPitchMin: state.panPitchMin,
187
+ panPitchMax: state.panPitchMax,
188
+ };
189
+
190
+ const panIdle =
191
+ !this.ballDragging &&
192
+ this.sentPanRate.yaw === 0 &&
193
+ this.sentPanRate.pitch === 0;
194
+ if (panIdle) {
195
+ this.localPan = { yaw: state.panYaw, pitch: state.panPitch };
196
+ }
197
+
198
+ const zoomIdle =
199
+ this.sentZoomRate === 0 &&
200
+ !this.sliderDragging &&
201
+ this.pendingFov === null;
202
+ if (zoomIdle) {
203
+ this.localFov = state.fov;
204
+ this.setSliderFovInternal(state.fov);
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Pan rate
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * Set a persistent normalised pan velocity (-1..1 per axis). Clamps,
214
+ * applies the analog deadzone, and deduplicates against the last-sent
215
+ * value before forwarding to the sink. Mirrors sendPanRate.
216
+ */
217
+ setPanRate(yaw: number, pitch: number): void {
218
+ const y = applyDeadzone(clamp(yaw, -1, 1), this.analogDeadzone);
219
+ const p = applyDeadzone(clamp(pitch, -1, 1), this.analogDeadzone);
220
+ if (y === this.sentPanRate.yaw && p === this.sentPanRate.pitch) return;
221
+ this.sentPanRate = { yaw: y, pitch: p };
222
+ void this.sink.setPanRate(y, p);
223
+ }
224
+
225
+ /**
226
+ * Update one pan axis, preserving the other. Allows two independent inputs
227
+ * (e.g. a serial stick axis and the ball) to compose rather than clobber
228
+ * each other. Mirrors setPanAxis.
229
+ */
230
+ setPanAxis(axis: "yaw" | "pitch", value: number): void {
231
+ const cur = this.sentPanRate;
232
+ if (axis === "yaw") this.setPanRate(value, cur.pitch);
233
+ else this.setPanRate(cur.yaw, value);
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Zoom rate
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Set a persistent normalised zoom velocity (-1..1; +1 = zoom in, FoV
242
+ * decreasing). Clamps, applies deadzone, deduplicates. Mirrors sendZoomRate.
243
+ */
244
+ setZoomRate(rate: number): void {
245
+ const r = applyDeadzone(clamp(rate, -1, 1), this.analogDeadzone);
246
+ if (r === this.sentZoomRate) return;
247
+ this.sentZoomRate = r;
248
+ void this.sink.setZoomRate(r);
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Discrete nudges
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Discrete pan step (+1/-1 per axis sign). Moves the pan accumulator by
257
+ * `panNudgeDeg`, clamped to bounds, then sends an absolute `setPan`.
258
+ * Mirrors nudgePan.
259
+ */
260
+ nudgePan(yawSign: number, pitchSign: number): void {
261
+ const b = this.bounds;
262
+ this.localPan.yaw = clamp(
263
+ this.localPan.yaw + yawSign * this.panNudgeDeg,
264
+ b.panYawMin,
265
+ b.panYawMax,
266
+ );
267
+ this.localPan.pitch = clamp(
268
+ this.localPan.pitch + pitchSign * this.panNudgeDeg,
269
+ b.panPitchMin,
270
+ b.panPitchMax,
271
+ );
272
+ void this.sink.setPan(this.localPan.yaw, this.localPan.pitch);
273
+ }
274
+
275
+ /**
276
+ * Discrete FoV step. `deltaSign: -1` = zoom in (FoV decreases by
277
+ * `fovNudgeDeg`), `+1` = zoom out (FoV increases). Clamped to fov bounds.
278
+ * Mirrors nudgeZoom (which calls onFovChange internally).
279
+ */
280
+ nudgeZoom(deltaSign: number): void {
281
+ const b = this.bounds;
282
+ const next = clamp(
283
+ this.localFov + deltaSign * this.fovNudgeDeg,
284
+ b.fovMin,
285
+ b.fovMax,
286
+ );
287
+ this.localFov = next;
288
+ void this.sink.setFov(next);
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // FoV slider
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Update the optimistic slider value and schedule a debounced `setFov`.
297
+ * The settled (paused) value is what gets sent; rapid drag input does not
298
+ * stream a command per pixel. Mirrors scheduleFovSlider.
299
+ */
300
+ fovSliderInput(fov: number): void {
301
+ this.pendingFov = fov;
302
+ this.setSliderFovInternal(fov);
303
+ if (this.fovDebounceTimer !== null) clearTimeout(this.fovDebounceTimer);
304
+ this.fovDebounceTimer = setTimeout(() => {
305
+ this.fovDebounceTimer = null;
306
+ this.flushFovSlider();
307
+ }, this.fovSliderDebounceMs);
308
+ }
309
+
310
+ /**
311
+ * Declare slider drag start/end. While dragging, echo-sync does not
312
+ * override the slider (the user is in control). Setting `false` flushes
313
+ * any pending debounced FoV immediately. Mirrors the pointer-up
314
+ * flushFovSlider call.
315
+ */
316
+ setFovSliderDragging(dragging: boolean): void {
317
+ this.sliderDragging = dragging;
318
+ if (!dragging) this.flushFovSlider();
319
+ }
320
+
321
+ /**
322
+ * Subscribe to slider FoV changes. The callback fires on `fovSliderInput`
323
+ * and on idle echo-sync (when the camera echo updates the slider). Returns
324
+ * an unsubscribe function.
325
+ */
326
+ onSliderFov(cb: (fov: number) => void): () => void {
327
+ this.sliderFovListeners.add(cb);
328
+ return () => {
329
+ this.sliderFovListeners.delete(cb);
330
+ };
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Ball drag
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Declare ball-drag start/end. While dragging, echo-sync does not apply
339
+ * the echoed pan to the local accumulator (the user is in control).
340
+ * The pixel-deflection-to-rate conversion happens in the UI layer; the
341
+ * UI calls `setPanRate` with the normalised result.
342
+ */
343
+ setBallDragging(dragging: boolean): void {
344
+ this.ballDragging = dragging;
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Lifecycle
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Send zero pan and zoom rates if (and only if) a non-zero rate is currently
353
+ * active, and clear drag flags and any pending FoV timer without sending it.
354
+ * Use on component unmount or camera hide -- mirrors the cleanup effects at
355
+ * CameraFeed.tsx:477-508.
356
+ */
357
+ stop(): void {
358
+ if (this.sentPanRate.yaw !== 0 || this.sentPanRate.pitch !== 0) {
359
+ this.sentPanRate = { yaw: 0, pitch: 0 };
360
+ void this.sink.setPanRate(0, 0);
361
+ }
362
+ if (this.sentZoomRate !== 0) {
363
+ this.sentZoomRate = 0;
364
+ void this.sink.setZoomRate(0);
365
+ }
366
+ this.ballDragging = false;
367
+ this.sliderDragging = false;
368
+ if (this.fovDebounceTimer !== null) {
369
+ clearTimeout(this.fovDebounceTimer);
370
+ this.fovDebounceTimer = null;
371
+ }
372
+ this.pendingFov = null;
373
+ }
374
+
375
+ /**
376
+ * Stop the controller and drop all slider-change subscribers. Call when
377
+ * the owning component is permanently destroyed.
378
+ */
379
+ dispose(): void {
380
+ this.stop();
381
+ this.sliderFovListeners.clear();
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Private helpers
386
+ // ---------------------------------------------------------------------------
387
+
388
+ private flushFovSlider(): void {
389
+ if (this.fovDebounceTimer !== null) {
390
+ clearTimeout(this.fovDebounceTimer);
391
+ this.fovDebounceTimer = null;
392
+ }
393
+ if (this.pendingFov !== null) {
394
+ const fov = clamp(this.pendingFov, this.bounds.fovMin, this.bounds.fovMax);
395
+ this.localFov = fov;
396
+ this.pendingFov = null;
397
+ void this.sink.setFov(fov);
398
+ }
399
+ }
400
+
401
+ private setSliderFovInternal(fov: number): void {
402
+ this._sliderFov = fov;
403
+ for (const cb of this.sliderFovListeners) cb(fov);
404
+ }
405
+ }