@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/CHANGELOG.md +186 -0
- package/LICENSE +369 -0
- package/README.md +112 -0
- package/dist/__generated__/types.d.ts +529 -0
- package/dist/__generated__/types.js +50 -0
- package/dist/client.d.ts +433 -0
- package/dist/client.js +821 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/noise.d.ts +34 -0
- package/dist/noise.js +274 -0
- package/dist/panZoom.d.ts +190 -0
- package/dist/panZoom.js +302 -0
- package/dist/testing/index.d.ts +217 -0
- package/dist/testing/index.js +606 -0
- package/package.json +57 -0
- package/src/__generated__/types.ts +488 -0
- package/src/client.test.ts +1673 -0
- package/src/client.ts +1219 -0
- package/src/domStubs.test.ts +49 -0
- package/src/index.ts +29 -0
- package/src/noise.test.ts +340 -0
- package/src/noise.ts +317 -0
- package/src/panZoom.test.ts +404 -0
- package/src/panZoom.ts +405 -0
- package/src/testing/index.ts +689 -0
- package/src/testing.test.ts +156 -0
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
|
+
}
|