@ksp-gonogo/kerbcast-react 1.11.0 → 1.12.0-rc.1
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 +59 -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 +78 -10
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- 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/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 +223 -6
- package/src/CameraFeed.tsx +145 -14
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- 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/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
package/src/CameraFeed.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import type { Anchor, CameraState, DisplayedFrameUt, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
2
|
import {
|
|
3
3
|
CameraKind,
|
|
4
4
|
CommandAimKind,
|
|
@@ -32,16 +32,19 @@ import {
|
|
|
32
32
|
type FeedAction,
|
|
33
33
|
type FeedActionBarEntry,
|
|
34
34
|
} from "./FeedActionBar";
|
|
35
|
-
import { CommandBlank,
|
|
35
|
+
import { CommandBlank, LiveCommandBrackets, type CommandBracketOverlay } from "./CommandBrackets";
|
|
36
|
+
import { LiveAnchorMarkers, type AnchorOverlay } from "./AnchorMarkers";
|
|
36
37
|
import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
|
|
37
38
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
38
39
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
39
40
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
41
|
+
import { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
|
|
40
42
|
import { useRecordings } from "./hooks/useRecordings";
|
|
41
43
|
import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
42
44
|
import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
|
|
43
45
|
import { StandbyIcon } from "./StandbyIcon";
|
|
44
46
|
import { isCameraDestroyed } from "./lifecycle";
|
|
47
|
+
import { claimAnchors } from "./sharedAnchors";
|
|
45
48
|
import { usePortalMenu } from "./menuPositioning";
|
|
46
49
|
import { formatElapsed, nowMs } from "./timing";
|
|
47
50
|
|
|
@@ -265,8 +268,9 @@ export interface CameraFeedProps {
|
|
|
265
268
|
enableRecording?: boolean;
|
|
266
269
|
/**
|
|
267
270
|
* Record this feed at the operator's full render size for the duration of
|
|
268
|
-
* the built-in REC control's recording (overrides
|
|
269
|
-
* demand
|
|
271
|
+
* the built-in REC control's recording (overrides the display-size
|
|
272
|
+
* demand and any viewer quality preset; still capped by an operator
|
|
273
|
+
* render-size cap and the adaptive shed). Off by default.
|
|
270
274
|
* Threaded straight into `start(flightId, { forceFullResolution })`; has
|
|
271
275
|
* no effect unless `enableRecording` is also set. While the feed is armed
|
|
272
276
|
* (force sent, waiting for the resolution bump) the tile shows an
|
|
@@ -314,14 +318,53 @@ export interface CameraFeedProps {
|
|
|
314
318
|
* brackets. See {@link CommandBracketOverlay}.
|
|
315
319
|
*/
|
|
316
320
|
targetBracketOverlay?: CommandBracketOverlay;
|
|
321
|
+
/**
|
|
322
|
+
* What a command camera feed says while the camera has no subject to aim
|
|
323
|
+
* at yet and shows its placeholder framing (a launch site camera on its
|
|
324
|
+
* pad, a station looking at the horizon). Drawn over the picture, never
|
|
325
|
+
* into the video, so recordings stay clean. Default "Waiting for target";
|
|
326
|
+
* `null` or an empty string shows nothing.
|
|
327
|
+
*/
|
|
328
|
+
waitingForTargetLabel?: string | null;
|
|
329
|
+
/**
|
|
330
|
+
* What the feed says while its camera has no video slot on this connection
|
|
331
|
+
* (`KerbcastCameraHandle.waitingForSlot`): the pool is at its ceiling or
|
|
332
|
+
* cannot grow. The feed takes the next slot another feed gives up. Drawn
|
|
333
|
+
* over the picture; `null` or an empty string shows nothing.
|
|
334
|
+
*/
|
|
335
|
+
noSlotLabel?: string | null;
|
|
317
336
|
/**
|
|
318
337
|
* Mission UT the displayed video was captured at, for a host running its
|
|
319
338
|
* own delayed playout. Picks which command-track transition is in force and
|
|
320
339
|
* ages the readout. Defaults to the displayed frame's own capture UT where
|
|
321
340
|
* the browser reports frame RTP timestamps, else the client's capture
|
|
322
|
-
* clock interpolated to now.
|
|
341
|
+
* clock interpolated to now. While a command lock is held the brackets and
|
|
342
|
+
* readout come from the displayed frame's own state, which a UT alone does
|
|
343
|
+
* not carry: such a host passes `displayFrame` instead.
|
|
323
344
|
*/
|
|
324
345
|
displayUt?: number;
|
|
346
|
+
/**
|
|
347
|
+
* The frame a host's own delayed playout is showing, as
|
|
348
|
+
* `watchDisplayedFrames` reported it when it arrived. Wins over
|
|
349
|
+
* `displayUt`: its `ut` picks the transition in force and its `command`
|
|
350
|
+
* draws the brackets and readout, so they never run ahead of the picture.
|
|
351
|
+
*/
|
|
352
|
+
displayFrame?: DisplayedFrameUt | null;
|
|
353
|
+
/**
|
|
354
|
+
* Things to mark on this feed's picture: parts, craft, points on a body or
|
|
355
|
+
* directions. Set for the displayed camera as this client's anchors and
|
|
356
|
+
* cleared when the feed shows another camera or unmounts; the game
|
|
357
|
+
* projects each through the camera that rendered every frame, and a
|
|
358
|
+
* default marker is drawn where it lands. One set per camera per client:
|
|
359
|
+
* two feeds on the same camera share it, the last to set winning. At
|
|
360
|
+
* most 16. Pass a stable or memoised array; it is compared by value.
|
|
361
|
+
*/
|
|
362
|
+
anchors?: Anchor[];
|
|
363
|
+
/**
|
|
364
|
+
* Restyle the anchor markers, add SVG of your own, put text under them,
|
|
365
|
+
* or draw anchors set in game too. See {@link AnchorOverlay}.
|
|
366
|
+
*/
|
|
367
|
+
anchorOverlay?: AnchorOverlay;
|
|
325
368
|
}
|
|
326
369
|
|
|
327
370
|
export interface CameraFeedHandle {
|
|
@@ -362,7 +405,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
362
405
|
useStream,
|
|
363
406
|
enableTargetBrackets = true,
|
|
364
407
|
targetBracketOverlay,
|
|
408
|
+
waitingForTargetLabel = "Waiting for target",
|
|
409
|
+
noSlotLabel = "Not streaming: too many feeds are open. Close a feed to watch this one.",
|
|
365
410
|
displayUt: displayUtProp,
|
|
411
|
+
displayFrame,
|
|
412
|
+
anchors,
|
|
413
|
+
anchorOverlay,
|
|
366
414
|
},
|
|
367
415
|
ref,
|
|
368
416
|
) {
|
|
@@ -402,7 +450,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
402
450
|
const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
|
|
403
451
|
|
|
404
452
|
// -------------------------------------------------------------------------
|
|
405
|
-
// Selection model
|
|
453
|
+
// Selection model
|
|
406
454
|
//
|
|
407
455
|
// Resolution order:
|
|
408
456
|
// 1. Explicit pick, if still present in the list (destroyed or not).
|
|
@@ -558,6 +606,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
558
606
|
}, []);
|
|
559
607
|
|
|
560
608
|
const isDestroyed = camera ? isCameraDestroyed(camera) : false;
|
|
609
|
+
const waitingForSlot = useKerbcastWaitingForSlot(flightId);
|
|
561
610
|
/* Gating here rather than at the render sites covers the whole manual
|
|
562
611
|
path at once: the controls unmount, the effects below zero any in-flight
|
|
563
612
|
rate, and the imperative handle's pan/zoom methods no-op. */
|
|
@@ -582,16 +631,29 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
582
631
|
const commandTrack = commandCamera?.commandTrack ?? null;
|
|
583
632
|
const commandDisplayUt = useCommandDisplayUt(
|
|
584
633
|
commandTrack !== null,
|
|
585
|
-
displayUtProp,
|
|
634
|
+
displayFrame ? displayFrame.ut : displayUtProp,
|
|
586
635
|
commandTrack?.events,
|
|
587
636
|
commandCamera?.flightId ?? null,
|
|
588
637
|
videoEl,
|
|
638
|
+
displayFrame?.command,
|
|
589
639
|
);
|
|
590
640
|
const commandState = commandTrack
|
|
591
|
-
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
|
|
641
|
+
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut, commandDisplayUt?.command)
|
|
592
642
|
: null;
|
|
643
|
+
/* This feed's anchors go to the camera it displays, and leave with it. */
|
|
644
|
+
const anchorsKey = anchors?.length ? JSON.stringify(anchors) : "";
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
if (flightId === null || !anchorsKey) return;
|
|
647
|
+
return claimAnchors(client, flightId, JSON.parse(anchorsKey) as Anchor[]);
|
|
648
|
+
}, [client, flightId, anchorsKey]);
|
|
649
|
+
const drawsAnchors = !!anchorsKey || !!anchorOverlay?.owners?.length;
|
|
650
|
+
|
|
593
651
|
const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(
|
|
594
|
-
commandCamera
|
|
652
|
+
commandCamera
|
|
653
|
+
? (commandCamera.commandCentre?.siteKey || commandCamera.commandCentre?.nodeName || String(commandCamera.flightId))
|
|
654
|
+
: null,
|
|
655
|
+
// 1.11 keyed the preference by stationId; carry a saved choice over.
|
|
656
|
+
commandCamera?.commandCentre?.stationId ?? null,
|
|
595
657
|
);
|
|
596
658
|
const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
|
|
597
659
|
|
|
@@ -861,8 +923,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
861
923
|
// one aims the camera at it. Hidden while a command authority holds the
|
|
862
924
|
// aim lock, since only it can aim then.
|
|
863
925
|
// -------------------------------------------------------------------------
|
|
864
|
-
|
|
865
|
-
const
|
|
926
|
+
/* The list as it stood at the displayed frame, so a delayed feed never offers what its picture has not reached. */
|
|
927
|
+
const shownCentre = commandCamera
|
|
928
|
+
? client.commandCentreAt(commandCamera.flightId, commandDisplayUt?.ut)
|
|
929
|
+
: null;
|
|
930
|
+
const commandTargets = shownCentre?.targets ?? [];
|
|
931
|
+
const commandTargetId = shownCentre?.subjectPersistentId ?? 0;
|
|
866
932
|
const commandLocked = commandCamera?.commandTrack?.authority != null;
|
|
867
933
|
const commandTargetAvailable =
|
|
868
934
|
isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
|
|
@@ -900,6 +966,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
900
966
|
yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
|
|
901
967
|
place of the REC badge. */
|
|
902
968
|
const armingActive = activeRecording?.arming === true;
|
|
969
|
+
/* Stopped, clip not in the store yet (ActiveRecordingInfo.stopping). */
|
|
970
|
+
const savingActive = activeRecording?.stopping === true;
|
|
903
971
|
|
|
904
972
|
// Ticks once a second while recording so the elapsed timer stays live;
|
|
905
973
|
// the timer itself is derived from activeRecording.startedAt each render.
|
|
@@ -1391,8 +1459,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1391
1459
|
(
|
|
1392
1460
|
<>
|
|
1393
1461
|
{commandState.blank && <CommandBlank reason={commandState.blank} />}
|
|
1462
|
+
{commandState.waitingForTarget && !commandState.blank && !isDestroyed && waitingForTargetLabel && (
|
|
1463
|
+
<WaitingBadge role="status">{waitingForTargetLabel}</WaitingBadge>
|
|
1464
|
+
)}
|
|
1394
1465
|
{targetBracketsAvailable && bracketsOn && (
|
|
1395
|
-
<
|
|
1466
|
+
<LiveCommandBrackets
|
|
1467
|
+
flightId={commandCamera?.flightId ?? null}
|
|
1396
1468
|
state={commandState}
|
|
1397
1469
|
displayUt={commandDisplayUt?.ut ?? commandTrack!.ut}
|
|
1398
1470
|
utSource={commandDisplayUt?.source ?? null}
|
|
@@ -1405,6 +1477,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1405
1477
|
)}
|
|
1406
1478
|
</>
|
|
1407
1479
|
)}
|
|
1480
|
+
{waitingForSlot && !outOfFlight && !isDestroyed && noSlotLabel && (
|
|
1481
|
+
<NoSlotNotice role="status">{noSlotLabel}</NoSlotNotice>
|
|
1482
|
+
)}
|
|
1483
|
+
{drawsAnchors && !outOfFlight && !isDestroyed && !commandState?.blank && (
|
|
1484
|
+
<LiveAnchorMarkers flightId={flightId} video={videoEl} overlay={anchorOverlay} />
|
|
1485
|
+
)}
|
|
1408
1486
|
{outOfFlight ? (
|
|
1409
1487
|
showStandbyIcon && (
|
|
1410
1488
|
<StandbyOverlay role="status" aria-label="Standby, no active flight">
|
|
@@ -1437,12 +1515,17 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1437
1515
|
ARMING
|
|
1438
1516
|
</ArmingBadge>
|
|
1439
1517
|
)}
|
|
1440
|
-
{recordingActive && !armingActive && (
|
|
1518
|
+
{recordingActive && !armingActive && !savingActive && (
|
|
1441
1519
|
<RecBadge role="status" aria-label="Recording">
|
|
1442
1520
|
<RecDot aria-hidden="true" />
|
|
1443
1521
|
REC {formatElapsed(recordingElapsedMs)}
|
|
1444
1522
|
</RecBadge>
|
|
1445
1523
|
)}
|
|
1524
|
+
{recordingActive && savingActive && (
|
|
1525
|
+
<RecBadge role="status" aria-label="Saving recording">
|
|
1526
|
+
SAVING
|
|
1527
|
+
</RecBadge>
|
|
1528
|
+
)}
|
|
1446
1529
|
{showZoom && (
|
|
1447
1530
|
<ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
|
|
1448
1531
|
<ZoomButton
|
|
@@ -1588,7 +1671,7 @@ export const CameraFeed = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1588
1671
|
);
|
|
1589
1672
|
|
|
1590
1673
|
// ---------------------------------------------------------------------------
|
|
1591
|
-
// Inline UI primitives
|
|
1674
|
+
// Inline UI primitives
|
|
1592
1675
|
// ---------------------------------------------------------------------------
|
|
1593
1676
|
|
|
1594
1677
|
function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
@@ -2326,6 +2409,54 @@ const StaleScrim = styled.div`
|
|
|
2326
2409
|
pointer-events: none;
|
|
2327
2410
|
`;
|
|
2328
2411
|
|
|
2412
|
+
/* Centred in the picture, clear of the chrome along the edges, and wrapping so
|
|
2413
|
+
a small tile still shows the whole sentence. */
|
|
2414
|
+
const NoSlotNotice = styled.div`
|
|
2415
|
+
position: absolute;
|
|
2416
|
+
top: 50%;
|
|
2417
|
+
left: 50%;
|
|
2418
|
+
transform: translate(-50%, -50%);
|
|
2419
|
+
z-index: 2;
|
|
2420
|
+
max-width: calc(100% - 32px);
|
|
2421
|
+
padding: 6px 10px;
|
|
2422
|
+
background: rgba(0, 0, 0, 0.6);
|
|
2423
|
+
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
2424
|
+
border-radius: 3px;
|
|
2425
|
+
color: rgba(255, 255, 255, 0.9);
|
|
2426
|
+
font-size: 12px;
|
|
2427
|
+
line-height: 1.4;
|
|
2428
|
+
text-align: center;
|
|
2429
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
2430
|
+
pointer-events: none;
|
|
2431
|
+
`;
|
|
2432
|
+
|
|
2433
|
+
/* Bottom centre: the zoom controls take the bottom left, pan and the stale
|
|
2434
|
+
badge the bottom right, the title and REC badge the top left and the
|
|
2435
|
+
action bar the top right. */
|
|
2436
|
+
const WaitingBadge = styled.div`
|
|
2437
|
+
position: absolute;
|
|
2438
|
+
bottom: 8px;
|
|
2439
|
+
left: 50%;
|
|
2440
|
+
transform: translateX(-50%);
|
|
2441
|
+
z-index: 2;
|
|
2442
|
+
/* Clear of the stale badge on the bottom right, even on a small tile. */
|
|
2443
|
+
max-width: calc(100% - 180px);
|
|
2444
|
+
overflow: hidden;
|
|
2445
|
+
text-overflow: ellipsis;
|
|
2446
|
+
white-space: nowrap;
|
|
2447
|
+
padding: 2px 6px;
|
|
2448
|
+
background: rgba(0, 0, 0, 0.6);
|
|
2449
|
+
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
2450
|
+
border-radius: 3px;
|
|
2451
|
+
color: rgba(255, 255, 255, 0.9);
|
|
2452
|
+
font-size: 10px;
|
|
2453
|
+
font-weight: 600;
|
|
2454
|
+
letter-spacing: 0.12em;
|
|
2455
|
+
text-transform: uppercase;
|
|
2456
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
2457
|
+
pointer-events: none;
|
|
2458
|
+
`;
|
|
2459
|
+
|
|
2329
2460
|
const StaleBadge = styled.div`
|
|
2330
2461
|
position: absolute;
|
|
2331
2462
|
bottom: 8px;
|
|
@@ -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
|
});
|