@ksp-gonogo/kerbcast-react 1.11.1 → 1.12.0-rc.2
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/README.md +71 -0
- package/dist/AnchorMarkers.d.ts +128 -0
- package/dist/AnchorMarkers.js +162 -0
- package/dist/CameraFeed.d.ts +45 -4
- package/dist/CameraFeed.js +163 -23
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- package/dist/FeedActionBar.d.ts +7 -0
- package/dist/FeedActionBar.js +4 -4
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +1 -1
- package/dist/RecordingsTray.d.ts +6 -0
- package/dist/RecordingsTray.js +17 -1
- package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
- package/dist/hooks/useCommandDisplayUt.js +57 -16
- package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
- package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
- package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
- package/dist/hooks/useTargetBracketsPreference.js +18 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -1
- package/dist/menuPositioning.d.ts +8 -2
- package/dist/menuPositioning.js +30 -5
- package/dist/sharedAnchors.d.ts +3 -0
- package/dist/sharedAnchors.js +61 -0
- package/dist/videoBox.d.ts +10 -0
- package/dist/videoBox.js +40 -0
- package/package.json +2 -2
- package/src/AnchorMarkers.test.tsx +173 -0
- package/src/AnchorMarkers.tsx +341 -0
- package/src/CameraFeed.test.tsx +687 -6
- package/src/CameraFeed.tsx +240 -27
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- package/src/FeedActionBar.test.ts +22 -0
- package/src/FeedActionBar.tsx +11 -4
- package/src/KerbalFaceFeed.tsx +1 -1
- package/src/RecordingsTray.test.tsx +15 -0
- package/src/RecordingsTray.tsx +25 -1
- package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
- package/src/hooks/useCommandDisplayUt.ts +61 -15
- package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
- package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
- package/src/hooks/useRecordings.test.tsx +6 -3
- package/src/hooks/useTargetBracketsPreference.ts +15 -5
- package/src/index.ts +23 -1
- package/src/menuPositioning.ts +31 -5
- package/src/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { effectiveCommandTrack, type CommandTrackState } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import { effectiveCommandTrack, type CommandTrackState, type FrameAnchor } from "@ksp-gonogo/kerbcast";
|
|
2
2
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
4
|
import {
|
|
@@ -33,7 +33,7 @@ function track(overrides: Partial<CommandTrackState> = {}): CommandTrackState {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const AUTHORITY = { owner: "
|
|
36
|
+
const AUTHORITY = { owner: "mission-control" };
|
|
37
37
|
|
|
38
38
|
describe("containedContentRect", () => {
|
|
39
39
|
it("letterboxes wide media in a tall box", () => {
|
|
@@ -154,6 +154,19 @@ describe("CommandBrackets", () => {
|
|
|
154
154
|
expect(container.querySelector("svg")).toBeNull();
|
|
155
155
|
expect(screen.getByText("NO FIX")).toBeTruthy();
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
it("leaves NO FIX out while the feed says it is waiting for a target", () => {
|
|
159
|
+
render(
|
|
160
|
+
<CommandBrackets
|
|
161
|
+
state={{ ...effectiveCommandTrack(track({ hasFix: false, rangeM: undefined }), 1000), waitingForTarget: true }}
|
|
162
|
+
displayUt={1000}
|
|
163
|
+
utSource="override"
|
|
164
|
+
video={null}
|
|
165
|
+
fallbackSize={{ width: 1024, height: 576 }}
|
|
166
|
+
/>,
|
|
167
|
+
);
|
|
168
|
+
expect(screen.queryByText("NO FIX")).toBeNull();
|
|
169
|
+
});
|
|
157
170
|
});
|
|
158
171
|
|
|
159
172
|
describe("CommandBrackets overlay", () => {
|
|
@@ -249,11 +262,198 @@ describe("CommandBrackets overlay", () => {
|
|
|
249
262
|
});
|
|
250
263
|
});
|
|
251
264
|
|
|
265
|
+
/** A video element with a laid-out box and a decoded size, as a browser reports them. */
|
|
266
|
+
function sizedVideo(boxW: number, boxH: number, mediaW = boxW, mediaH = boxH): HTMLVideoElement {
|
|
267
|
+
const v = document.createElement("video");
|
|
268
|
+
Object.defineProperty(v, "clientWidth", { value: boxW });
|
|
269
|
+
Object.defineProperty(v, "clientHeight", { value: boxH });
|
|
270
|
+
Object.defineProperty(v, "videoWidth", { value: mediaW });
|
|
271
|
+
Object.defineProperty(v, "videoHeight", { value: mediaH });
|
|
272
|
+
return v;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function subjectAnchor(overrides: Partial<FrameAnchor> = {}): FrameAnchor {
|
|
276
|
+
return {
|
|
277
|
+
id: "subject",
|
|
278
|
+
owner: "kerbcast",
|
|
279
|
+
x: 256,
|
|
280
|
+
y: 144,
|
|
281
|
+
inFrame: true,
|
|
282
|
+
behind: false,
|
|
283
|
+
distanceM: 4200,
|
|
284
|
+
apparentRadiusPx: 40,
|
|
285
|
+
angleOffAxis: 12,
|
|
286
|
+
occluded: false,
|
|
287
|
+
...overrides,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
describe("CommandBrackets on the subject anchor", () => {
|
|
292
|
+
function renderOnSubject(
|
|
293
|
+
subject: FrameAnchor | null,
|
|
294
|
+
t: CommandTrackState = track(),
|
|
295
|
+
overlay?: CommandBracketOverlay,
|
|
296
|
+
frame = { width: 1024, height: 576 },
|
|
297
|
+
) {
|
|
298
|
+
return render(
|
|
299
|
+
<CommandBrackets
|
|
300
|
+
state={effectiveCommandTrack(t, 1000)}
|
|
301
|
+
displayUt={1000}
|
|
302
|
+
utSource="exact"
|
|
303
|
+
video={sizedVideo(1024, 576)}
|
|
304
|
+
overlay={overlay}
|
|
305
|
+
subject={subject}
|
|
306
|
+
subjectFrameSize={frame}
|
|
307
|
+
/>,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function seenWith(subject: FrameAnchor | null, t: CommandTrackState = track()) {
|
|
312
|
+
let seen: CommandBracketContext | null = null;
|
|
313
|
+
const view = renderOnSubject(subject, t, { render: (ctx) => ((seen = ctx), null) });
|
|
314
|
+
return { ctx: seen as unknown as CommandBracketContext, view };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* An observed result out of frame or occluded: no coordinates at all. */
|
|
318
|
+
const unseen = (o: Partial<FrameAnchor>): FrameAnchor => ({
|
|
319
|
+
id: "subject",
|
|
320
|
+
owner: "kerbcast",
|
|
321
|
+
observed: true,
|
|
322
|
+
inFrame: true,
|
|
323
|
+
...o,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("brackets the subject where it really is, not the frame centre", () => {
|
|
327
|
+
const { ctx, view } = seenWith(subjectAnchor());
|
|
328
|
+
expect([ctx.geometry.cx, ctx.geometry.cy, ctx.geometry.half]).toEqual([256, 144, 40]);
|
|
329
|
+
expect(ctx.subject?.id).toBe("subject");
|
|
330
|
+
expect(view.container.querySelector("svg")!.getAttribute("data-subject")).toBe("placed");
|
|
331
|
+
expect(corner(view.container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("maps a capture frame larger than the decoded video by the frame's own size", () => {
|
|
335
|
+
let seen: CommandBracketContext | null = null;
|
|
336
|
+
renderOnSubject(
|
|
337
|
+
subjectAnchor({ x: 512, y: 288, apparentRadiusPx: 80 }),
|
|
338
|
+
track(),
|
|
339
|
+
{ render: (ctx) => ((seen = ctx), null) },
|
|
340
|
+
{ width: 2048, height: 1152 },
|
|
341
|
+
);
|
|
342
|
+
const g = (seen as CommandBracketContext | null)!.geometry;
|
|
343
|
+
expect([g.cx, g.cy, g.half]).toEqual([256, 144, 40]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("falls back to the 1.11 box when the subject is occluded: centred, aimed range, aim-line colour", () => {
|
|
347
|
+
const t = track({ occluded: true, occluder: "Mun" });
|
|
348
|
+
const { ctx, view } = seenWith(unseen({ occluded: true }), t);
|
|
349
|
+
expect([ctx.geometry.cx, ctx.geometry.cy]).toEqual([512, 288]);
|
|
350
|
+
expect(ctx.geometry).toEqual(bracketGeometry({ x: 0, y: 0, width: 1024, height: 576 }, 20, 576));
|
|
351
|
+
expect(corner(view.container, "topLeft")!.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
352
|
+
expect(view.container.querySelector("svg")!.getAttribute("data-subject")).toBe("centred");
|
|
353
|
+
expect(ctx.subject?.occluded).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("falls back to the 1.11 box, corners drawn, when the subject is out of frame", () => {
|
|
357
|
+
const { ctx, view } = seenWith(unseen({ inFrame: false, occluded: false }));
|
|
358
|
+
expect([ctx.geometry.cx, ctx.geometry.cy]).toEqual([512, 288]);
|
|
359
|
+
expect(view.container.querySelectorAll("path")).toHaveLength(2);
|
|
360
|
+
expect(screen.getByText("IN VIEW · 4.2 km")).toBeTruthy();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("keeps the 1.11 box under the command lock even with the subject in view", () => {
|
|
364
|
+
/* The authority aims somewhere else; the box is its aim, the anchor a second mark for the consumer. */
|
|
365
|
+
const locked = track({ authority: AUTHORITY, occluded: true, occluder: "Mun", bracketHalfPx: 30 });
|
|
366
|
+
const { ctx, view } = seenWith(subjectAnchor(), locked);
|
|
367
|
+
expect(ctx.geometry).toEqual(bracketGeometry({ x: 0, y: 0, width: 1024, height: 576 }, 30, 576));
|
|
368
|
+
expect(corner(view.container, "topLeft")!.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
369
|
+
expect(ctx.subject).toMatchObject({ id: "subject", x: 256, y: 144 });
|
|
370
|
+
expect(screen.getByText("OCCLUDED MUN · 4.2 km")).toBeTruthy();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("puts the aimed box on the aim anchor while the view turns onto a moved aim", () => {
|
|
374
|
+
const locked = track({ authority: AUTHORITY, bracketHalfPx: 30 });
|
|
375
|
+
let seen: CommandBracketContext | null = null;
|
|
376
|
+
render(
|
|
377
|
+
<CommandBrackets
|
|
378
|
+
state={effectiveCommandTrack(locked, 1000)}
|
|
379
|
+
displayUt={1000}
|
|
380
|
+
utSource="exact"
|
|
381
|
+
video={sizedVideo(1024, 576)}
|
|
382
|
+
overlay={{ render: (ctx) => ((seen = ctx), null) }}
|
|
383
|
+
subject={subjectAnchor()}
|
|
384
|
+
subjectFrameSize={{ width: 1024, height: 576 }}
|
|
385
|
+
aim={{ id: "aim", owner: "kerbcast", observed: true, inFrame: true, x: 700, y: 200 }}
|
|
386
|
+
/>,
|
|
387
|
+
);
|
|
388
|
+
const g = (seen as CommandBracketContext | null)!.geometry;
|
|
389
|
+
expect([g.cx, g.cy]).toEqual([700, 200]);
|
|
390
|
+
expect(g).toEqual(bracketGeometry({ x: 0, y: 0, width: 1024, height: 576 }, 30, 576, { x: 700, y: 200 }));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("keeps the aimed box on the edge nearest an aim off the frame, with a cue toward it", () => {
|
|
394
|
+
const locked = track({ authority: AUTHORITY, bracketHalfPx: 30 });
|
|
395
|
+
let seen: CommandBracketContext | null = null;
|
|
396
|
+
const view = render(
|
|
397
|
+
<CommandBrackets
|
|
398
|
+
state={effectiveCommandTrack(locked, 1000)}
|
|
399
|
+
displayUt={1000}
|
|
400
|
+
utSource="exact"
|
|
401
|
+
video={sizedVideo(1024, 576)}
|
|
402
|
+
overlay={{ render: (ctx) => ((seen = ctx), null) }}
|
|
403
|
+
subjectFrameSize={{ width: 1024, height: 576 }}
|
|
404
|
+
aim={{ id: "aim", owner: "kerbcast", inFrame: false, behind: false, x: 1500, y: 200, observed: false }}
|
|
405
|
+
/>,
|
|
406
|
+
);
|
|
407
|
+
const ctx = seen as CommandBracketContext | null;
|
|
408
|
+
expect(ctx!.aimOffscreen).toBe(true);
|
|
409
|
+
expect(ctx!.geometry.bottomRight.x).toBeLessThanOrEqual(1024);
|
|
410
|
+
expect(ctx!.geometry.cy).toBe(200);
|
|
411
|
+
expect(view.container.querySelector("path[data-aim-cue]")).toBeTruthy();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
function renderAimed(aimAt: { x: number; y: number }, t: CommandTrackState) {
|
|
415
|
+
let seen: CommandBracketContext | null = null;
|
|
416
|
+
const view = render(
|
|
417
|
+
<CommandBrackets
|
|
418
|
+
state={effectiveCommandTrack(t, 1000)}
|
|
419
|
+
displayUt={1000}
|
|
420
|
+
utSource="exact"
|
|
421
|
+
video={sizedVideo(1024, 576)}
|
|
422
|
+
overlay={{ render: (ctx) => ((seen = ctx), null) }}
|
|
423
|
+
subject={subjectAnchor()}
|
|
424
|
+
subjectFrameSize={{ width: 1024, height: 576 }}
|
|
425
|
+
aim={{ id: "aim", owner: "kerbcast", observed: false, inFrame: true, behind: false, ...aimAt }}
|
|
426
|
+
/>,
|
|
427
|
+
);
|
|
428
|
+
return { ctx: seen as unknown as CommandBracketContext, view };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
it("draws the aimed box when it holds the subject, so a craft and its parachute are framed whole", () => {
|
|
432
|
+
/* The view aims between a capsule and the canopy above it; the subject anchor stays on the centre of mass. */
|
|
433
|
+
const { ctx, view } = renderAimed({ x: 256, y: 100 }, track({ bracketHalfPx: 60 }));
|
|
434
|
+
expect(ctx.geometry).toEqual(bracketGeometry({ x: 0, y: 0, width: 1024, height: 576 }, 60, 576, { x: 256, y: 100 }));
|
|
435
|
+
expect(ctx.subject).toMatchObject({ id: "subject", x: 256, y: 144 });
|
|
436
|
+
expect(view.container.querySelector("svg")!.getAttribute("data-subject")).toBe("placed");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("keeps the box on the subject when the aimed box does not hold it", () => {
|
|
440
|
+
const { ctx } = renderAimed({ x: 700, y: 400 }, track({ bracketHalfPx: 60 }));
|
|
441
|
+
expect([ctx.geometry.cx, ctx.geometry.cy, ctx.geometry.half]).toEqual([256, 144, 40]);
|
|
442
|
+
expect(ctx.aimOffscreen).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("centres on the frame as before without a subject anchor", () => {
|
|
446
|
+
const { ctx } = seenWith(null);
|
|
447
|
+
expect([ctx.geometry.cx, ctx.geometry.cy]).toEqual([512, 288]);
|
|
448
|
+
expect(ctx.subject).toBeNull();
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
252
452
|
describe("CommandBlank", () => {
|
|
253
453
|
it("labels the blank with its reason", () => {
|
|
254
|
-
render(<CommandBlank reason="Waiting for
|
|
454
|
+
render(<CommandBlank reason="Waiting for mission control" />);
|
|
255
455
|
expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
|
|
256
|
-
"Waiting for
|
|
456
|
+
"Waiting for mission control",
|
|
257
457
|
);
|
|
258
458
|
});
|
|
259
459
|
});
|
package/src/CommandBrackets.tsx
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
|
+
COMMAND_AIM_ANCHOR_ID,
|
|
3
|
+
COMMAND_SUBJECT_ANCHOR_ID,
|
|
4
|
+
KERBCAST_ANCHOR_OWNER,
|
|
5
|
+
anchorPoint,
|
|
2
6
|
commandReadout,
|
|
7
|
+
findAnchor,
|
|
8
|
+
frameToScreen,
|
|
3
9
|
type EffectiveCommandTrack,
|
|
10
|
+
type FrameAnchor,
|
|
4
11
|
type FrameUtSource,
|
|
5
12
|
} from "@ksp-gonogo/kerbcast";
|
|
6
|
-
import {
|
|
13
|
+
import { type ReactNode } from "react";
|
|
7
14
|
import styled from "styled-components";
|
|
15
|
+
import { useDisplayedFrame } from "./AnchorMarkers";
|
|
16
|
+
import { useVideoBox } from "./videoBox";
|
|
8
17
|
|
|
9
18
|
const BRACKET_STROKE_PX = 2;
|
|
10
19
|
const BRACKET_MIN_HALF_PX = 14;
|
|
@@ -51,7 +60,10 @@ export interface BracketPoint {
|
|
|
51
60
|
|
|
52
61
|
/** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
|
|
53
62
|
export interface BracketGeometry {
|
|
54
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Box centre: where the subject is in the displayed frame, or the frame
|
|
65
|
+
* centre (the aim point) while that is not known.
|
|
66
|
+
*/
|
|
55
67
|
cx: number;
|
|
56
68
|
cy: number;
|
|
57
69
|
/** Half the box side in display px. */
|
|
@@ -65,20 +77,22 @@ export interface BracketGeometry {
|
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
/**
|
|
68
|
-
* The bracket box centred on the displayed frame
|
|
69
|
-
*
|
|
70
|
-
*
|
|
80
|
+
* The bracket box, centred on the displayed frame or on `centre` (in the
|
|
81
|
+
* same pixel space as `rect`). `bracketHalfPx` is in capture px of a
|
|
82
|
+
* `frameHeightPx`-tall frame, so it scales by the displayed height; a
|
|
83
|
+
* missing half-size draws the minimum box.
|
|
71
84
|
*/
|
|
72
85
|
export function bracketGeometry(
|
|
73
86
|
rect: ContentRect,
|
|
74
87
|
bracketHalfPx: number | null,
|
|
75
88
|
frameHeightPx: number,
|
|
89
|
+
centre?: BracketPoint | null,
|
|
76
90
|
): BracketGeometry {
|
|
77
91
|
const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
|
|
78
92
|
const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
|
|
79
93
|
const arm = Math.max(BRACKET_MIN_ARM_PX, BRACKET_ARM_FRACTION * half * 2);
|
|
80
|
-
const cx = rect.x + rect.width / 2;
|
|
81
|
-
const cy = rect.y + rect.height / 2;
|
|
94
|
+
const cx = centre ? centre.x : rect.x + rect.width / 2;
|
|
95
|
+
const cy = centre ? centre.y : rect.y + rect.height / 2;
|
|
82
96
|
return {
|
|
83
97
|
cx,
|
|
84
98
|
cy,
|
|
@@ -118,6 +132,54 @@ export interface CommandBracketContext {
|
|
|
118
132
|
displayUt: number;
|
|
119
133
|
utSource: FrameUtSource | "override" | null;
|
|
120
134
|
state: EffectiveCommandTrack;
|
|
135
|
+
/**
|
|
136
|
+
* The subject's anchor in the displayed frame: where the game projected
|
|
137
|
+
* the craft, whether it is in frame and whether something hides it.
|
|
138
|
+
* `null` without one (an older sidecar or plugin, or before the first
|
|
139
|
+
* stamped frame), when the box is centred on the frame instead.
|
|
140
|
+
*/
|
|
141
|
+
subject?: FrameAnchor | null;
|
|
142
|
+
/**
|
|
143
|
+
* True while the aim lies off the frame (the view is still turning onto
|
|
144
|
+
* it): the box then sits on the frame's edge nearest the aim, with a
|
|
145
|
+
* chevron pointing on toward it.
|
|
146
|
+
*/
|
|
147
|
+
aimOffscreen?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether `p` lies inside the box `g`. */
|
|
151
|
+
function holds(g: BracketGeometry, p: BracketPoint): boolean {
|
|
152
|
+
return p.x >= g.topLeft.x && p.x <= g.bottomRight.x && p.y >= g.topLeft.y && p.y <= g.bottomRight.y;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `g` moved so the whole box lies inside `rect`. */
|
|
156
|
+
function clampToRect(g: BracketGeometry, rect: ContentRect): BracketGeometry {
|
|
157
|
+
const clamp = (v: number, lo: number, hi: number) => (lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v)));
|
|
158
|
+
const cx = clamp(g.cx, rect.x + g.half, rect.x + rect.width - g.half);
|
|
159
|
+
const cy = clamp(g.cy, rect.y + g.half, rect.y + rect.height - g.half);
|
|
160
|
+
return {
|
|
161
|
+
...g,
|
|
162
|
+
cx,
|
|
163
|
+
cy,
|
|
164
|
+
topLeft: { x: cx - g.half, y: cy - g.half },
|
|
165
|
+
bottomRight: { x: cx + g.half, y: cy + g.half },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A chevron just outside the box, pointing from its centre toward `toward`. */
|
|
170
|
+
function edgeCue(g: BracketGeometry, toward: BracketPoint): string | null {
|
|
171
|
+
const dx = toward.x - g.cx;
|
|
172
|
+
const dy = toward.y - g.cy;
|
|
173
|
+
const len = Math.hypot(dx, dy);
|
|
174
|
+
if (!(len > 0)) return null;
|
|
175
|
+
const ux = dx / len;
|
|
176
|
+
const uy = dy / len;
|
|
177
|
+
const tipX = g.cx + ux * (g.half + g.arm);
|
|
178
|
+
const tipY = g.cy + uy * (g.half + g.arm);
|
|
179
|
+
const backX = tipX - ux * g.arm;
|
|
180
|
+
const backY = tipY - uy * g.arm;
|
|
181
|
+
const w = g.arm * 0.6;
|
|
182
|
+
return `M${backX - uy * w} ${backY + ux * w}L${tipX} ${tipY}L${backX + uy * w} ${backY - ux * w}`;
|
|
121
183
|
}
|
|
122
184
|
|
|
123
185
|
/**
|
|
@@ -143,8 +205,8 @@ export interface CommandBracketOverlay {
|
|
|
143
205
|
readout?: (defaultText: string, ctx: CommandBracketContext) => string | null;
|
|
144
206
|
}
|
|
145
207
|
|
|
146
|
-
function cameraCornerStyle(
|
|
147
|
-
return
|
|
208
|
+
function cameraCornerStyle(occluded: boolean): CommandBracketCornerStyle {
|
|
209
|
+
return occluded
|
|
148
210
|
? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
|
|
149
211
|
: { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
|
|
150
212
|
}
|
|
@@ -160,41 +222,6 @@ function applyCorner(
|
|
|
160
222
|
};
|
|
161
223
|
}
|
|
162
224
|
|
|
163
|
-
/** Tracks the rendered box and intrinsic size of a video element. */
|
|
164
|
-
function useVideoBox(video: HTMLVideoElement | null) {
|
|
165
|
-
const [box, setBox] = useState({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
|
|
166
|
-
useEffect(() => {
|
|
167
|
-
if (!video) return;
|
|
168
|
-
const measure = () => {
|
|
169
|
-
const next = {
|
|
170
|
-
boxW: video.clientWidth,
|
|
171
|
-
boxH: video.clientHeight,
|
|
172
|
-
mediaW: video.videoWidth,
|
|
173
|
-
mediaH: video.videoHeight,
|
|
174
|
-
};
|
|
175
|
-
setBox((prev) =>
|
|
176
|
-
prev.boxW === next.boxW &&
|
|
177
|
-
prev.boxH === next.boxH &&
|
|
178
|
-
prev.mediaW === next.mediaW &&
|
|
179
|
-
prev.mediaH === next.mediaH
|
|
180
|
-
? prev
|
|
181
|
-
: next,
|
|
182
|
-
);
|
|
183
|
-
};
|
|
184
|
-
measure();
|
|
185
|
-
const observer = new ResizeObserver(measure);
|
|
186
|
-
observer.observe(video);
|
|
187
|
-
video.addEventListener("loadedmetadata", measure);
|
|
188
|
-
video.addEventListener("resize", measure);
|
|
189
|
-
return () => {
|
|
190
|
-
observer.disconnect();
|
|
191
|
-
video.removeEventListener("loadedmetadata", measure);
|
|
192
|
-
video.removeEventListener("resize", measure);
|
|
193
|
-
};
|
|
194
|
-
}, [video]);
|
|
195
|
-
return box;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
225
|
export interface CommandBracketsProps {
|
|
199
226
|
state: EffectiveCommandTrack;
|
|
200
227
|
displayUt: number;
|
|
@@ -204,14 +231,35 @@ export interface CommandBracketsProps {
|
|
|
204
231
|
/** Frame aspect to assume until the video reports its own size. */
|
|
205
232
|
fallbackSize?: { width: number; height: number } | null;
|
|
206
233
|
overlay?: CommandBracketOverlay | null;
|
|
234
|
+
/**
|
|
235
|
+
* The subject's anchor in the displayed frame. While it is visible and no
|
|
236
|
+
* command lock is held the box sits on it and takes its size from it.
|
|
237
|
+
*/
|
|
238
|
+
subject?: FrameAnchor | null;
|
|
239
|
+
/** The capture frame size `subject` is in (the stamp's width and height). */
|
|
240
|
+
subjectFrameSize?: { width: number; height: number } | null;
|
|
241
|
+
/**
|
|
242
|
+
* The aim anchor in the displayed frame. The aimed box sits on it rather
|
|
243
|
+
* than on the frame centre, so it stays on the aim while the view turns
|
|
244
|
+
* onto a moved one.
|
|
245
|
+
*/
|
|
246
|
+
aim?: FrameAnchor | null;
|
|
207
247
|
}
|
|
208
248
|
|
|
209
249
|
/**
|
|
210
|
-
* Target brackets for a command camera feed
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
250
|
+
* Target brackets for a command camera feed. With no command lock held and
|
|
251
|
+
* the subject's anchor visible in the displayed frame, the box sits on the
|
|
252
|
+
* craft where the game saw it, so it stays on the craft when an aim points
|
|
253
|
+
* elsewhere; while the aimed box is in frame and holds the craft it is drawn
|
|
254
|
+
* instead, since it frames the craft's whole extent as the view does. Otherwise (the anchor out of frame or occluded, which then
|
|
255
|
+
* carries no position, no anchor at all, or a command authority holding the
|
|
256
|
+
* lock) the box is the 1.11 one: on the aim (the frame centre, or the aim
|
|
257
|
+
* anchor while the view turns onto a moved aim), sized from the aimed range,
|
|
258
|
+
* red and dashed when the aim line is occluded. `ctx.subject` always
|
|
259
|
+
* carries the anchor, so a consumer can draw it as a second mark. An overlay
|
|
260
|
+
* can restyle either corner and draw its own marks. Without a fix, or over a
|
|
261
|
+
* blanked feed, the default corners are left out but the overlay and readout
|
|
262
|
+
* still run.
|
|
215
263
|
*/
|
|
216
264
|
export function CommandBrackets({
|
|
217
265
|
state,
|
|
@@ -220,6 +268,9 @@ export function CommandBrackets({
|
|
|
220
268
|
video,
|
|
221
269
|
fallbackSize,
|
|
222
270
|
overlay,
|
|
271
|
+
subject,
|
|
272
|
+
subjectFrameSize,
|
|
273
|
+
aim,
|
|
223
274
|
}: CommandBracketsProps) {
|
|
224
275
|
const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
|
|
225
276
|
const rect = containedContentRect(
|
|
@@ -228,7 +279,36 @@ export function CommandBrackets({
|
|
|
228
279
|
mediaW || fallbackSize?.width || 0,
|
|
229
280
|
mediaH || fallbackSize?.height || 0,
|
|
230
281
|
);
|
|
231
|
-
const
|
|
282
|
+
const frameW = subjectFrameSize?.width || mediaW || fallbackSize?.width || 0;
|
|
283
|
+
const frameH = subjectFrameSize?.height || mediaH || fallbackSize?.height || 0;
|
|
284
|
+
/* Under the lock the box is the authority's aim, as in 1.11. */
|
|
285
|
+
const point =
|
|
286
|
+
subject && !state.authority && frameW > 0 && frameH > 0 ? anchorPoint(subject) : null;
|
|
287
|
+
const placed = point !== null;
|
|
288
|
+
const aimPoint = aim && frameW > 0 && frameH > 0 ? anchorPoint(aim) : null;
|
|
289
|
+
const aimScreen = aimPoint ? frameToScreen(aimPoint, frameW, frameH, { ...rect, scale: 1 }) : null;
|
|
290
|
+
const aimOffscreen = !point && !!aimScreen && (!aim?.inFrame || !!aim?.behind);
|
|
291
|
+
const aimed = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH, aimScreen);
|
|
292
|
+
const subjectScreen = point ? frameToScreen(point, frameW, frameH, { ...rect, scale: 1 }) : null;
|
|
293
|
+
/*
|
|
294
|
+
* The aimed box, when it is in frame and holds the subject, frames the
|
|
295
|
+
* craft as the view does: around a craft and its open parachute rather
|
|
296
|
+
* than a sphere about its centre of mass.
|
|
297
|
+
*/
|
|
298
|
+
const framed =
|
|
299
|
+
!!subjectScreen &&
|
|
300
|
+
!!aimScreen &&
|
|
301
|
+
!!aim?.inFrame &&
|
|
302
|
+
!aim?.behind &&
|
|
303
|
+
state.bracketHalfPx != null &&
|
|
304
|
+
holds(aimed, subjectScreen);
|
|
305
|
+
const unclamped =
|
|
306
|
+
subjectScreen && !framed
|
|
307
|
+
? bracketGeometry(rect, subject?.apparentRadiusPx ?? null, frameH, subjectScreen)
|
|
308
|
+
: aimed;
|
|
309
|
+
/* An aim off the frame (mid-slew) keeps the box on the edge nearest it, with a cue pointing on. */
|
|
310
|
+
const g = aimOffscreen ? clampToRect(unclamped, rect) : unclamped;
|
|
311
|
+
const cue = aimOffscreen && aimScreen ? edgeCue(g, aimScreen) : null;
|
|
232
312
|
const ctx: CommandBracketContext = {
|
|
233
313
|
geometry: g,
|
|
234
314
|
width: boxW,
|
|
@@ -236,23 +316,32 @@ export function CommandBrackets({
|
|
|
236
316
|
displayUt,
|
|
237
317
|
utSource: utSource ?? null,
|
|
238
318
|
state,
|
|
319
|
+
subject: subject ?? null,
|
|
320
|
+
aimOffscreen,
|
|
239
321
|
};
|
|
240
322
|
const overrides =
|
|
241
323
|
typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
|
|
242
|
-
const camera = cameraCornerStyle(state);
|
|
324
|
+
const camera = cameraCornerStyle(placed ? !!subject?.occluded : state.occluded);
|
|
243
325
|
const topLeft = applyCorner(camera, overrides?.topLeft);
|
|
244
326
|
const bottomRight = applyCorner(camera, overrides?.bottomRight);
|
|
245
327
|
const { x: left, y: top } = g.topLeft;
|
|
246
328
|
const { x: right, y: bottom } = g.bottomRight;
|
|
247
329
|
const blanked = !!state.blank;
|
|
248
330
|
const drawCorners = state.hasFix && !blanked;
|
|
249
|
-
|
|
331
|
+
/* A feed waiting for its target says so itself; NO FIX would only repeat it. */
|
|
332
|
+
const defaultReadout = blanked || state.waitingForTarget ? "" : commandReadout(state);
|
|
250
333
|
const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
|
|
251
334
|
|
|
252
335
|
return (
|
|
253
336
|
<>
|
|
254
337
|
{(drawCorners || overlay?.render) && (
|
|
255
|
-
<BracketSvg
|
|
338
|
+
<BracketSvg
|
|
339
|
+
aria-hidden="true"
|
|
340
|
+
data-camera-axis={state.camera}
|
|
341
|
+
data-subject={subject ? (placed ? "placed" : "centred") : undefined}
|
|
342
|
+
width={boxW}
|
|
343
|
+
height={boxH}
|
|
344
|
+
>
|
|
256
345
|
{drawCorners && !topLeft.hidden && (
|
|
257
346
|
<path
|
|
258
347
|
data-corner="topLeft"
|
|
@@ -273,6 +362,15 @@ export function CommandBrackets({
|
|
|
273
362
|
style={{ stroke: bottomRight.colour }}
|
|
274
363
|
/>
|
|
275
364
|
)}
|
|
365
|
+
{drawCorners && cue && (
|
|
366
|
+
<path
|
|
367
|
+
data-aim-cue=""
|
|
368
|
+
d={cue}
|
|
369
|
+
fill="none"
|
|
370
|
+
strokeWidth={g.stroke}
|
|
371
|
+
style={{ stroke: camera.colour }}
|
|
372
|
+
/>
|
|
373
|
+
)}
|
|
276
374
|
{overlay?.render && <g data-bracket-overlay="">{overlay.render(ctx)}</g>}
|
|
277
375
|
</BracketSvg>
|
|
278
376
|
)}
|
|
@@ -288,6 +386,25 @@ export function CommandBrackets({
|
|
|
288
386
|
);
|
|
289
387
|
}
|
|
290
388
|
|
|
389
|
+
/**
|
|
390
|
+
* CommandBrackets fed with the subject's and the aim's anchors from each frame of
|
|
391
|
+
* `flightId` that `video` presents. Re-renders at the frame rate, which is
|
|
392
|
+
* why it is its own component.
|
|
393
|
+
*/
|
|
394
|
+
export function LiveCommandBrackets({
|
|
395
|
+
flightId,
|
|
396
|
+
...props
|
|
397
|
+
}: CommandBracketsProps & { flightId: number | null }) {
|
|
398
|
+
const frame = useDisplayedFrame(true, flightId, props.video);
|
|
399
|
+
const subject = findAnchor(frame?.anchors, COMMAND_SUBJECT_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
|
|
400
|
+
const aim = findAnchor(frame?.anchors, COMMAND_AIM_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
|
|
401
|
+
const size =
|
|
402
|
+
frame?.frameWidth && frame.frameHeight
|
|
403
|
+
? { width: frame.frameWidth, height: frame.frameHeight }
|
|
404
|
+
: null;
|
|
405
|
+
return <CommandBrackets {...props} subject={subject} subjectFrameSize={size} aim={aim} />;
|
|
406
|
+
}
|
|
407
|
+
|
|
291
408
|
/** Reason a command feed is blanked, centred on black over the feed. */
|
|
292
409
|
export function CommandBlank({ reason }: { reason: string }) {
|
|
293
410
|
return (
|
|
@@ -13,6 +13,7 @@ import { partitionActionBarEntries } from "./FeedActionBar";
|
|
|
13
13
|
interface Entry {
|
|
14
14
|
id: string;
|
|
15
15
|
stateful?: boolean;
|
|
16
|
+
inline?: boolean;
|
|
16
17
|
order?: number;
|
|
17
18
|
pinnedTrailing?: boolean;
|
|
18
19
|
}
|
|
@@ -41,6 +42,27 @@ describe("partitionActionBarEntries", () => {
|
|
|
41
42
|
expect(ids(overflow)).toEqual(["quality", "pip", "fullscreen"]);
|
|
42
43
|
});
|
|
43
44
|
|
|
45
|
+
it("keeps an inline entry out of overflow without changing the split", () => {
|
|
46
|
+
const base: Entry[] = [
|
|
47
|
+
{ id: "tracking", stateful: true },
|
|
48
|
+
{ id: "quality" },
|
|
49
|
+
{ id: "pip" },
|
|
50
|
+
{ id: "fullscreen" },
|
|
51
|
+
];
|
|
52
|
+
const pinned = base.map((e) => (e.id === "fullscreen" ? { ...e, inline: true } : e));
|
|
53
|
+
const { primary, overflow } = partitionActionBarEntries(pinned);
|
|
54
|
+
expect(ids(primary)).toEqual(["tracking", "fullscreen"]);
|
|
55
|
+
expect(ids(overflow)).toEqual(["quality", "pip"]);
|
|
56
|
+
|
|
57
|
+
const twoNonStateful: Entry[] = [
|
|
58
|
+
{ id: "tracking", stateful: true },
|
|
59
|
+
{ id: "rec", stateful: true },
|
|
60
|
+
{ id: "pip" },
|
|
61
|
+
{ id: "fullscreen", inline: true },
|
|
62
|
+
];
|
|
63
|
+
expect(ids(partitionActionBarEntries(twoNonStateful).overflow)).toEqual(["pip"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
44
66
|
it("never collapses a single non-stateful item, even with total >= 4", () => {
|
|
45
67
|
const entries: Entry[] = [
|
|
46
68
|
{ id: "tracking", stateful: true },
|
package/src/FeedActionBar.tsx
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { Fragment, useId } from "react";
|
|
30
30
|
import { createPortal } from "react-dom";
|
|
31
31
|
import styled from "styled-components";
|
|
32
|
-
import { usePortalMenu } from "./menuPositioning";
|
|
32
|
+
import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* A consumer-supplied action rendered into a feed's action bar. Lets a host
|
|
@@ -82,6 +82,7 @@ const OVERFLOW_MIN_NON_STATEFUL = 2;
|
|
|
82
82
|
|
|
83
83
|
interface Partitionable {
|
|
84
84
|
stateful?: boolean;
|
|
85
|
+
inline?: boolean;
|
|
85
86
|
order?: number;
|
|
86
87
|
pinnedTrailing?: boolean;
|
|
87
88
|
}
|
|
@@ -113,8 +114,8 @@ export function partitionActionBarEntries<T extends Partitionable>(
|
|
|
113
114
|
return { primary: overflowable, overflow: [], pinnedTrailing };
|
|
114
115
|
}
|
|
115
116
|
return {
|
|
116
|
-
primary: overflowable.filter((e) => e.stateful),
|
|
117
|
-
overflow: nonStateful,
|
|
117
|
+
primary: overflowable.filter((e) => e.stateful || e.inline),
|
|
118
|
+
overflow: nonStateful.filter((e) => !e.inline),
|
|
118
119
|
pinnedTrailing,
|
|
119
120
|
};
|
|
120
121
|
}
|
|
@@ -133,6 +134,12 @@ export interface FeedActionBarEntry {
|
|
|
133
134
|
id: string;
|
|
134
135
|
label: string;
|
|
135
136
|
stateful?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Keeps a non-stateful entry out of the ⋮ menu without changing the split:
|
|
139
|
+
* it still counts as non-stateful toward the overflow threshold, so the
|
|
140
|
+
* rest of the bar lays out exactly as it would without the flag.
|
|
141
|
+
*/
|
|
142
|
+
inline?: boolean;
|
|
136
143
|
order?: number;
|
|
137
144
|
pinnedTrailing?: boolean;
|
|
138
145
|
render: () => React.ReactNode;
|
|
@@ -244,7 +251,7 @@ export function FeedActionBar({ leading, entries, className }: FeedActionBarProp
|
|
|
244
251
|
</OverflowMenuItem>
|
|
245
252
|
))}
|
|
246
253
|
</OverflowMenu>,
|
|
247
|
-
|
|
254
|
+
menuPortalRoot(),
|
|
248
255
|
)}
|
|
249
256
|
{pinnedTrailing.map((e) => (
|
|
250
257
|
<Fragment key={e.id}>{e.render()}</Fragment>
|
package/src/KerbalFaceFeed.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
5
5
|
* reusable building block a crew bar (kerbcast web) or a mission-control
|
|
6
|
-
* surface
|
|
6
|
+
* surface composes: identity-keyed by `flightId`, it binds the live
|
|
7
7
|
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
8
8
|
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
9
9
|
* technique `CameraFeed` uses).
|
|
@@ -153,6 +153,21 @@ describe("RecordingsTray - single clips", () => {
|
|
|
153
153
|
expect(screen.getByText(/unsaved clips/i)).toBeTruthy();
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
it("says why a clip stopped on its own, and nothing for one a person stopped", () => {
|
|
157
|
+
fakeStoreSeed.recordings = [
|
|
158
|
+
makeRecording({ recordingId: "rec-1", endedBy: "camera-gone" }),
|
|
159
|
+
makeRecording({ recordingId: "rec-2", endedBy: "disconnected" }),
|
|
160
|
+
makeRecording({ recordingId: "rec-3", endedBy: "feed-closed" }),
|
|
161
|
+
makeRecording({ recordingId: "rec-4", endedBy: "user" }),
|
|
162
|
+
];
|
|
163
|
+
render(<RecordingsTray />);
|
|
164
|
+
expect(screen.getAllByText(/stopped:/i).map((el) => el.textContent)).toEqual([
|
|
165
|
+
"Stopped: the camera went away",
|
|
166
|
+
"Stopped: the connection was lost",
|
|
167
|
+
"Stopped: its feed was closed",
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
156
171
|
it("falls back to 'Camera <flightId>' when no cameraLabel prop is given", () => {
|
|
157
172
|
fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", flightId: 7 })];
|
|
158
173
|
render(<RecordingsTray />);
|