@ksp-gonogo/kerbcast-react 1.10.3 → 1.11.0-rc.4
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/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +106 -5
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- package/dist/hooks/useCommandDisplayUt.d.ts +22 -0
- package/dist/hooks/useCommandDisplayUt.js +104 -0
- package/dist/hooks/useDisplayedFrameUt.d.ts +11 -0
- package/dist/hooks/useDisplayedFrameUt.js +22 -0
- package/dist/hooks/useReportDisplaySize.d.ts +5 -0
- package/dist/hooks/useReportDisplaySize.js +18 -7
- package/dist/hooks/useTargetBracketsPreference.d.ts +6 -0
- package/dist/hooks/useTargetBracketsPreference.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +291 -2
- package/src/CameraFeed.tsx +234 -4
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- package/src/hooks/useCommandDisplayUt.test.tsx +156 -0
- package/src/hooks/useCommandDisplayUt.ts +135 -0
- package/src/hooks/useDisplayedFrameUt.ts +26 -0
- package/src/hooks/useReportDisplaySize.test.tsx +10 -4
- package/src/hooks/useReportDisplaySize.ts +18 -7
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
package/src/CameraFeed.tsx
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CameraKind,
|
|
4
|
+
CommandAimKind,
|
|
5
|
+
CommandTargetEligibility,
|
|
6
|
+
PanZoomController,
|
|
7
|
+
QualityPreset,
|
|
8
|
+
TrackMode,
|
|
9
|
+
effectiveCommandTrack,
|
|
10
|
+
formatCommandRange,
|
|
11
|
+
} from "@ksp-gonogo/kerbcast";
|
|
3
12
|
import {
|
|
4
13
|
forwardRef,
|
|
5
14
|
useCallback,
|
|
@@ -23,11 +32,14 @@ import {
|
|
|
23
32
|
type FeedAction,
|
|
24
33
|
type FeedActionBarEntry,
|
|
25
34
|
} from "./FeedActionBar";
|
|
35
|
+
import { CommandBlank, CommandBrackets, type CommandBracketOverlay } from "./CommandBrackets";
|
|
36
|
+
import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
|
|
26
37
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
27
38
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
28
39
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
29
40
|
import { useRecordings } from "./hooks/useRecordings";
|
|
30
41
|
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
42
|
+
import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
|
|
31
43
|
import { StandbyIcon } from "./StandbyIcon";
|
|
32
44
|
import { isCameraDestroyed } from "./lifecycle";
|
|
33
45
|
import { usePortalMenu } from "./menuPositioning";
|
|
@@ -290,6 +302,26 @@ export interface CameraFeedProps {
|
|
|
290
302
|
* {@link CameraStreamHook}.
|
|
291
303
|
*/
|
|
292
304
|
useStream?: CameraStreamHook;
|
|
305
|
+
/**
|
|
306
|
+
* Draw target brackets and their readout over command camera feeds, with a
|
|
307
|
+
* per-feed "Target brackets" toggle in the action bar (on by default,
|
|
308
|
+
* remembered per camera in this browser). Default true. A blanked command
|
|
309
|
+
* feed shows its reason whatever this is set to.
|
|
310
|
+
*/
|
|
311
|
+
enableTargetBrackets?: boolean;
|
|
312
|
+
/**
|
|
313
|
+
* Extra drawing, corner styling and readout text for this feed's target
|
|
314
|
+
* brackets. See {@link CommandBracketOverlay}.
|
|
315
|
+
*/
|
|
316
|
+
targetBracketOverlay?: CommandBracketOverlay;
|
|
317
|
+
/**
|
|
318
|
+
* Mission UT the displayed video was captured at, for a host running its
|
|
319
|
+
* own delayed playout. Picks which command-track transition is in force and
|
|
320
|
+
* ages the readout. Defaults to the displayed frame's own capture UT where
|
|
321
|
+
* the browser reports frame RTP timestamps, else the client's capture
|
|
322
|
+
* clock interpolated to now.
|
|
323
|
+
*/
|
|
324
|
+
displayUt?: number;
|
|
293
325
|
}
|
|
294
326
|
|
|
295
327
|
export interface CameraFeedHandle {
|
|
@@ -328,6 +360,9 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
328
360
|
trailingActions,
|
|
329
361
|
showActions = true,
|
|
330
362
|
useStream,
|
|
363
|
+
enableTargetBrackets = true,
|
|
364
|
+
targetBracketOverlay,
|
|
365
|
+
displayUt: displayUtProp,
|
|
331
366
|
},
|
|
332
367
|
ref,
|
|
333
368
|
) {
|
|
@@ -427,7 +462,14 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
427
462
|
// built-in hook is the default. See CameraStreamHook's rules-of-hooks note.
|
|
428
463
|
const resolveStream = useStream ?? useKerbcastStream;
|
|
429
464
|
const stream = resolveStream(flightId);
|
|
430
|
-
const videoRef = useRef<HTMLVideoElement>(null);
|
|
465
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
466
|
+
/* Mirrors videoRef as state so the bracket overlay can measure the element
|
|
467
|
+
once it mounts. Setting a ref never remounts the video. */
|
|
468
|
+
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
|
469
|
+
const attachVideo = useCallback((el: HTMLVideoElement | null) => {
|
|
470
|
+
videoRef.current = el;
|
|
471
|
+
setVideoEl(el);
|
|
472
|
+
}, []);
|
|
431
473
|
// The feed frame; fullscreen targets this and ResizeObserver measures it.
|
|
432
474
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
433
475
|
useEffect(() => {
|
|
@@ -533,6 +575,26 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
533
575
|
const supportsPitch =
|
|
534
576
|
!!camera && camera.panPitchMax - camera.panPitchMin > 0;
|
|
535
577
|
|
|
578
|
+
/* Command camera feeds: target brackets, readout and blank label. */
|
|
579
|
+
const commandCamera =
|
|
580
|
+
camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
|
|
581
|
+
const isCommandFeed = commandCamera !== null;
|
|
582
|
+
const commandTrack = commandCamera?.commandTrack ?? null;
|
|
583
|
+
const commandDisplayUt = useCommandDisplayUt(
|
|
584
|
+
commandTrack !== null,
|
|
585
|
+
displayUtProp,
|
|
586
|
+
commandTrack?.events,
|
|
587
|
+
commandCamera?.flightId ?? null,
|
|
588
|
+
videoEl,
|
|
589
|
+
);
|
|
590
|
+
const commandState = commandTrack
|
|
591
|
+
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
|
|
592
|
+
: null;
|
|
593
|
+
const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(
|
|
594
|
+
commandCamera ? (commandCamera.commandCentre?.stationId ?? String(commandCamera.flightId)) : null,
|
|
595
|
+
);
|
|
596
|
+
const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
|
|
597
|
+
|
|
536
598
|
// -------------------------------------------------------------------------
|
|
537
599
|
// Camera selection callbacks (Next/Prev, picker)
|
|
538
600
|
// onSelectCamera is ONLY called on explicit user picks, never on auto-latch.
|
|
@@ -766,6 +828,36 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
766
828
|
[client, flightId, trackMode, closeTrackingMenu],
|
|
767
829
|
);
|
|
768
830
|
|
|
831
|
+
// -------------------------------------------------------------------------
|
|
832
|
+
// Command camera target picker. Lists every craft the game offers; the
|
|
833
|
+
// ineligible ones stay visible but disabled with the game's reason, and
|
|
834
|
+
// the current target follows the published subjectPersistentId. Picking
|
|
835
|
+
// one aims the camera at it. Hidden while a command authority holds the
|
|
836
|
+
// aim lock, since only it can aim then.
|
|
837
|
+
// -------------------------------------------------------------------------
|
|
838
|
+
const commandTargets = commandCamera?.commandCentre?.targets ?? [];
|
|
839
|
+
const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
|
|
840
|
+
const commandLocked = commandCamera?.commandTrack?.authority != null;
|
|
841
|
+
const commandTargetAvailable =
|
|
842
|
+
isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
|
|
843
|
+
const commandTargetMenuId = useId();
|
|
844
|
+
const commandTargetMenu = usePortalMenu({
|
|
845
|
+
maxWidth: MENU_MAX_WIDTH,
|
|
846
|
+
maxHeight: MENU_MAX_HEIGHT,
|
|
847
|
+
align: "end",
|
|
848
|
+
});
|
|
849
|
+
const closeCommandTargetMenu = commandTargetMenu.close;
|
|
850
|
+
const selectCommandTarget = useCallback(
|
|
851
|
+
(persistentId: number) => {
|
|
852
|
+
if (flightId === null) return;
|
|
853
|
+
client
|
|
854
|
+
.setCommandAim(flightId, { kind: CommandAimKind.Vessel, persistentId })
|
|
855
|
+
.catch(() => {});
|
|
856
|
+
closeCommandTargetMenu();
|
|
857
|
+
},
|
|
858
|
+
[client, flightId, closeCommandTargetMenu],
|
|
859
|
+
);
|
|
860
|
+
|
|
769
861
|
// -------------------------------------------------------------------------
|
|
770
862
|
// REC control (opt-in). A stateful toggle that starts/stops a client-side
|
|
771
863
|
// recording of this feed via the recordings store; state is read from the
|
|
@@ -899,6 +991,13 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
899
991
|
const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
|
|
900
992
|
const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
|
|
901
993
|
const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
|
|
994
|
+
const bracketsLabel = "Target brackets";
|
|
995
|
+
const commandTargetName = commandTargets.find(
|
|
996
|
+
(t) => t.persistentId === commandTargetId,
|
|
997
|
+
)?.vesselName;
|
|
998
|
+
const commandTargetLabel = commandTargetName
|
|
999
|
+
? `Watching ${commandTargetName}`
|
|
1000
|
+
: "Choose target";
|
|
902
1001
|
|
|
903
1002
|
const entries: FeedActionBarEntry[] = [
|
|
904
1003
|
...(actions?.map(feedActionToEntry) ?? []),
|
|
@@ -907,7 +1006,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
907
1006
|
{
|
|
908
1007
|
id: "quality",
|
|
909
1008
|
label: "Quality",
|
|
910
|
-
|
|
1009
|
+
/* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
|
|
1010
|
+
stateful: true,
|
|
911
1011
|
render: () => (
|
|
912
1012
|
<OverlayIconButton
|
|
913
1013
|
ref={qualityMenu.triggerRef}
|
|
@@ -952,6 +1052,52 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
952
1052
|
} satisfies FeedActionBarEntry,
|
|
953
1053
|
]
|
|
954
1054
|
: []),
|
|
1055
|
+
...(targetBracketsAvailable
|
|
1056
|
+
? [
|
|
1057
|
+
{
|
|
1058
|
+
id: "target-brackets",
|
|
1059
|
+
label: bracketsLabel,
|
|
1060
|
+
stateful: true,
|
|
1061
|
+
render: () => (
|
|
1062
|
+
<OverlayIconButton
|
|
1063
|
+
type="button"
|
|
1064
|
+
aria-label={bracketsLabel}
|
|
1065
|
+
aria-pressed={bracketsOn}
|
|
1066
|
+
title={bracketsLabel}
|
|
1067
|
+
$active={bracketsOn}
|
|
1068
|
+
onClick={toggleBrackets}
|
|
1069
|
+
>
|
|
1070
|
+
<TargetBracketsIcon />
|
|
1071
|
+
</OverlayIconButton>
|
|
1072
|
+
),
|
|
1073
|
+
} satisfies FeedActionBarEntry,
|
|
1074
|
+
]
|
|
1075
|
+
: []),
|
|
1076
|
+
...(commandTargetAvailable
|
|
1077
|
+
? [
|
|
1078
|
+
{
|
|
1079
|
+
id: "command-target",
|
|
1080
|
+
label: commandTargetLabel,
|
|
1081
|
+
/* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
|
|
1082
|
+
stateful: true,
|
|
1083
|
+
render: () => (
|
|
1084
|
+
<OverlayIconButton
|
|
1085
|
+
ref={commandTargetMenu.triggerRef}
|
|
1086
|
+
type="button"
|
|
1087
|
+
aria-label="Choose target"
|
|
1088
|
+
aria-haspopup="menu"
|
|
1089
|
+
aria-expanded={commandTargetMenu.open}
|
|
1090
|
+
aria-controls={commandTargetMenuId}
|
|
1091
|
+
title={commandTargetLabel}
|
|
1092
|
+
$active={commandTargetMenu.open}
|
|
1093
|
+
onClick={commandTargetMenu.toggle}
|
|
1094
|
+
>
|
|
1095
|
+
<CommandTargetIcon />
|
|
1096
|
+
</OverlayIconButton>
|
|
1097
|
+
),
|
|
1098
|
+
} satisfies FeedActionBarEntry,
|
|
1099
|
+
]
|
|
1100
|
+
: []),
|
|
955
1101
|
...(trackingAvailable
|
|
956
1102
|
? [
|
|
957
1103
|
{
|
|
@@ -1052,6 +1198,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1052
1198
|
fullscreenAvailable ||
|
|
1053
1199
|
qualityAvailable ||
|
|
1054
1200
|
trackingAvailable ||
|
|
1201
|
+
targetBracketsAvailable ||
|
|
1202
|
+
commandTargetAvailable ||
|
|
1055
1203
|
enableRecording);
|
|
1056
1204
|
const hasActionBar =
|
|
1057
1205
|
showActions &&
|
|
@@ -1094,7 +1242,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1094
1242
|
) : (
|
|
1095
1243
|
<>
|
|
1096
1244
|
<StyledVideo
|
|
1097
|
-
ref={
|
|
1245
|
+
ref={attachVideo}
|
|
1098
1246
|
autoPlay
|
|
1099
1247
|
playsInline
|
|
1100
1248
|
muted
|
|
@@ -1177,6 +1325,59 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1177
1325
|
</QualityMenu>,
|
|
1178
1326
|
document.body,
|
|
1179
1327
|
)}
|
|
1328
|
+
{commandTargetAvailable &&
|
|
1329
|
+
commandTargetMenu.open &&
|
|
1330
|
+
commandTargetMenu.position &&
|
|
1331
|
+
createPortal(
|
|
1332
|
+
<CameraMenu
|
|
1333
|
+
ref={commandTargetMenu.menuRef}
|
|
1334
|
+
id={commandTargetMenuId}
|
|
1335
|
+
role="menu"
|
|
1336
|
+
aria-label="Target"
|
|
1337
|
+
style={commandTargetMenu.position}
|
|
1338
|
+
>
|
|
1339
|
+
{commandTargets.map((t) => {
|
|
1340
|
+
const eligible = t.eligibility === CommandTargetEligibility.Ok;
|
|
1341
|
+
const current = t.persistentId === commandTargetId;
|
|
1342
|
+
return (
|
|
1343
|
+
<CameraMenuItem
|
|
1344
|
+
key={t.persistentId}
|
|
1345
|
+
type="button"
|
|
1346
|
+
role="menuitemradio"
|
|
1347
|
+
aria-checked={current}
|
|
1348
|
+
$selected={current}
|
|
1349
|
+
disabled={!eligible}
|
|
1350
|
+
title={eligible ? undefined : t.eligibilityText}
|
|
1351
|
+
onClick={() => selectCommandTarget(t.persistentId)}
|
|
1352
|
+
>
|
|
1353
|
+
{t.vesselName}
|
|
1354
|
+
{t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : ""}
|
|
1355
|
+
{eligible ? "" : ` · ${t.eligibilityText}`}
|
|
1356
|
+
</CameraMenuItem>
|
|
1357
|
+
);
|
|
1358
|
+
})}
|
|
1359
|
+
</CameraMenu>,
|
|
1360
|
+
document.body,
|
|
1361
|
+
)}
|
|
1362
|
+
{commandState &&
|
|
1363
|
+
!outOfFlight &&
|
|
1364
|
+
(
|
|
1365
|
+
<>
|
|
1366
|
+
{commandState.blank && <CommandBlank reason={commandState.blank} />}
|
|
1367
|
+
{targetBracketsAvailable && bracketsOn && (
|
|
1368
|
+
<CommandBrackets
|
|
1369
|
+
state={commandState}
|
|
1370
|
+
displayUt={commandDisplayUt?.ut ?? commandTrack!.ut}
|
|
1371
|
+
utSource={commandDisplayUt?.source ?? null}
|
|
1372
|
+
video={videoEl}
|
|
1373
|
+
fallbackSize={
|
|
1374
|
+
camera ? { width: camera.renderWidth, height: camera.renderHeight } : null
|
|
1375
|
+
}
|
|
1376
|
+
overlay={targetBracketOverlay}
|
|
1377
|
+
/>
|
|
1378
|
+
)}
|
|
1379
|
+
</>
|
|
1380
|
+
)}
|
|
1180
1381
|
{outOfFlight ? (
|
|
1181
1382
|
showStandbyIcon && (
|
|
1182
1383
|
<StandbyOverlay role="status" aria-label="Standby, no active flight">
|
|
@@ -1415,6 +1616,26 @@ function QualityIcon() {
|
|
|
1415
1616
|
);
|
|
1416
1617
|
}
|
|
1417
1618
|
|
|
1619
|
+
/* Target brackets: the top-left and bottom-right corners around a centre dot. */
|
|
1620
|
+
function TargetBracketsIcon() {
|
|
1621
|
+
return (
|
|
1622
|
+
<svg {...iconProps}>
|
|
1623
|
+
<path d="M2.5 7V2.5H7M13.5 9v4.5H9" />
|
|
1624
|
+
<circle cx="8" cy="8" r="1.2" fill="currentColor" stroke="none" />
|
|
1625
|
+
</svg>
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/* Command camera target: a craft silhouette inside a selection ring. */
|
|
1630
|
+
function CommandTargetIcon() {
|
|
1631
|
+
return (
|
|
1632
|
+
<svg {...iconProps}>
|
|
1633
|
+
<circle cx="8" cy="8" r="6" />
|
|
1634
|
+
<path d="M8 4.5l1.6 4H6.4zM6.6 8.5l-.9 2.5M9.4 8.5l.9 2.5" />
|
|
1635
|
+
</svg>
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1418
1639
|
/* Auto-track: a crosshair / reticle. */
|
|
1419
1640
|
function CrosshairIcon() {
|
|
1420
1641
|
return (
|
|
@@ -1738,6 +1959,15 @@ const CameraMenuItem = styled.button<{ $selected: boolean }>`
|
|
|
1738
1959
|
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1739
1960
|
outline-offset: -2px;
|
|
1740
1961
|
}
|
|
1962
|
+
|
|
1963
|
+
&:disabled {
|
|
1964
|
+
cursor: default;
|
|
1965
|
+
color: rgba(255, 255, 255, 0.45);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
&:disabled:not([aria-checked="true"]) {
|
|
1969
|
+
background: transparent;
|
|
1970
|
+
}
|
|
1741
1971
|
`;
|
|
1742
1972
|
|
|
1743
1973
|
const TopMeta = styled.div`
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { effectiveCommandTrack, type CommandTrackState } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
CommandBlank,
|
|
6
|
+
CommandBrackets,
|
|
7
|
+
bracketGeometry,
|
|
8
|
+
containedContentRect,
|
|
9
|
+
type CommandBracketContext,
|
|
10
|
+
type CommandBracketOverlay,
|
|
11
|
+
} from "./CommandBrackets";
|
|
12
|
+
import {
|
|
13
|
+
MAX_UT_EXTRAPOLATION_MS,
|
|
14
|
+
extrapolateCaptureUt,
|
|
15
|
+
msUntilNextEvent,
|
|
16
|
+
} from "./hooks/useCommandDisplayUt";
|
|
17
|
+
|
|
18
|
+
afterEach(cleanup);
|
|
19
|
+
|
|
20
|
+
function track(overrides: Partial<CommandTrackState> = {}): CommandTrackState {
|
|
21
|
+
return {
|
|
22
|
+
ut: 1000,
|
|
23
|
+
hasFix: true,
|
|
24
|
+
rangeM: 4200,
|
|
25
|
+
occluded: false,
|
|
26
|
+
occluder: "",
|
|
27
|
+
sunlit: true,
|
|
28
|
+
bracketHalfPx: 20,
|
|
29
|
+
frameHeightPx: 576,
|
|
30
|
+
framingFits: true,
|
|
31
|
+
events: [],
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const AUTHORITY = { owner: "gonogo" };
|
|
37
|
+
|
|
38
|
+
describe("containedContentRect", () => {
|
|
39
|
+
it("letterboxes wide media in a tall box", () => {
|
|
40
|
+
expect(containedContentRect(400, 400, 1600, 900)).toEqual({
|
|
41
|
+
x: 0,
|
|
42
|
+
y: 87.5,
|
|
43
|
+
width: 400,
|
|
44
|
+
height: 225,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("pillarboxes tall media in a wide box", () => {
|
|
49
|
+
expect(containedContentRect(800, 300, 400, 400)).toEqual({
|
|
50
|
+
x: 250,
|
|
51
|
+
y: 0,
|
|
52
|
+
width: 300,
|
|
53
|
+
height: 300,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("uses the whole box while the media size is unknown", () => {
|
|
58
|
+
expect(containedContentRect(640, 360, 0, 0)).toEqual({ x: 0, y: 0, width: 640, height: 360 });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("bracketGeometry", () => {
|
|
63
|
+
const rect = { x: 10, y: 20, width: 1024, height: 288 };
|
|
64
|
+
|
|
65
|
+
it("scales capture px by the displayed height and centres on the frame", () => {
|
|
66
|
+
const g = bracketGeometry(rect, 40, 576);
|
|
67
|
+
expect(g.cx).toBe(522);
|
|
68
|
+
expect(g.cy).toBe(164);
|
|
69
|
+
expect(g.half).toBe(20);
|
|
70
|
+
expect(g.arm).toBeCloseTo(12);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("never draws a box smaller than 14 display px half-size", () => {
|
|
74
|
+
const g = bracketGeometry(rect, 2, 576);
|
|
75
|
+
expect(g.half).toBe(14);
|
|
76
|
+
expect(g.arm).toBeCloseTo(8.4);
|
|
77
|
+
expect(bracketGeometry(rect, null, 576).half).toBe(14);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reports both corners around the centre", () => {
|
|
81
|
+
const g = bracketGeometry(rect, 40, 576);
|
|
82
|
+
expect(g.topLeft).toEqual({ x: 502, y: 144 });
|
|
83
|
+
expect(g.bottomRight).toEqual({ x: 542, y: 184 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("extrapolateCaptureUt", () => {
|
|
88
|
+
it("advances at the warp rate and stops at the cap", () => {
|
|
89
|
+
expect(extrapolateCaptureUt(100, 4, 1000, 1500)).toBe(102);
|
|
90
|
+
expect(extrapolateCaptureUt(100, 1, 0, MAX_UT_EXTRAPOLATION_MS * 10)).toBe(
|
|
91
|
+
100 + MAX_UT_EXTRAPOLATION_MS / 1000,
|
|
92
|
+
);
|
|
93
|
+
expect(extrapolateCaptureUt(100, 1, 1000, 500)).toBe(100);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("msUntilNextEvent", () => {
|
|
98
|
+
const ev = (ut: number) => ({ ut, occluded: false, occluder: "", sunlit: true });
|
|
99
|
+
it("times the first event after the displayed UT at the warp rate", () => {
|
|
100
|
+
expect(msUntilNextEvent([ev(99), ev(103), ev(101)], 100, 2)).toBe(500);
|
|
101
|
+
expect(msUntilNextEvent([ev(99)], 100, 1)).toBeNull();
|
|
102
|
+
expect(msUntilNextEvent([], 100, 1)).toBeNull();
|
|
103
|
+
expect(msUntilNextEvent([ev(101)], 100, 0)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function renderBrackets(
|
|
108
|
+
t: CommandTrackState,
|
|
109
|
+
displayUt = 1000,
|
|
110
|
+
overlay?: CommandBracketOverlay,
|
|
111
|
+
) {
|
|
112
|
+
return render(
|
|
113
|
+
<CommandBrackets
|
|
114
|
+
state={effectiveCommandTrack(t, displayUt)}
|
|
115
|
+
displayUt={displayUt}
|
|
116
|
+
utSource="override"
|
|
117
|
+
video={null}
|
|
118
|
+
fallbackSize={{ width: 1024, height: 576 }}
|
|
119
|
+
overlay={overlay}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function corner(container: HTMLElement, which: "topLeft" | "bottomRight") {
|
|
125
|
+
return container.querySelector(`path[data-corner="${which}"]`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("CommandBrackets", () => {
|
|
129
|
+
it("draws only the two corners, solid, when in view", () => {
|
|
130
|
+
const { container } = renderBrackets(track());
|
|
131
|
+
expect(container.querySelectorAll("path")).toHaveLength(2);
|
|
132
|
+
expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
133
|
+
expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
134
|
+
expect(screen.getByText("IN VIEW · 4.2 km")).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("dashes both corners when occluded", () => {
|
|
138
|
+
const { container } = renderBrackets(track({ occluded: true, occluder: "Mun" }));
|
|
139
|
+
expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
140
|
+
expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
141
|
+
expect(screen.getByText("OCCLUDED MUN · 4.2 km")).toBeTruthy();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("mirrors the camera corner under an authority too", () => {
|
|
145
|
+
const { container } = renderBrackets(
|
|
146
|
+
track({ occluded: true, occluder: "Mun", authority: AUTHORITY }),
|
|
147
|
+
);
|
|
148
|
+
expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
149
|
+
expect(screen.getByText("OCCLUDED MUN · 4.2 km")).toBeTruthy();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("draws no box without a fix, only the readout", () => {
|
|
153
|
+
const { container } = renderBrackets(track({ hasFix: false, rangeM: undefined }));
|
|
154
|
+
expect(container.querySelector("svg")).toBeNull();
|
|
155
|
+
expect(screen.getByText("NO FIX")).toBeTruthy();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("CommandBrackets overlay", () => {
|
|
160
|
+
it("still runs the overlay and readout without a fix, with no default corners", () => {
|
|
161
|
+
const { container } = renderBrackets(track({ hasFix: false, rangeM: undefined }), 1000, {
|
|
162
|
+
render: (ctx) => (ctx.state.hasFix ? null : <circle data-testid="held" r={3} />),
|
|
163
|
+
readout: (text) => `${text} · HELD`,
|
|
164
|
+
});
|
|
165
|
+
expect(container.querySelectorAll("path")).toHaveLength(0);
|
|
166
|
+
expect(screen.getByTestId("held").closest("svg")).toBe(container.querySelector("svg"));
|
|
167
|
+
expect(screen.getByText("NO FIX · HELD")).toBeTruthy();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("runs over a blanked feed with an empty default readout", () => {
|
|
171
|
+
let readoutDefault: string | null = null;
|
|
172
|
+
const { container } = renderBrackets(track({ blank: "Lost" }), 1000, {
|
|
173
|
+
render: (ctx) => (ctx.state.blank ? <circle data-testid="held" r={3} /> : null),
|
|
174
|
+
readout: (text) => {
|
|
175
|
+
readoutDefault = text;
|
|
176
|
+
return "HELD 2m";
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
expect(readoutDefault).toBe("");
|
|
180
|
+
expect(container.querySelectorAll("path")).toHaveLength(0);
|
|
181
|
+
expect(screen.getByTestId("held")).toBeTruthy();
|
|
182
|
+
expect(screen.getByText("HELD 2m")).toBeTruthy();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("draws nothing over a blanked feed without an overlay", () => {
|
|
186
|
+
const { container } = renderBrackets(track({ blank: "Lost" }));
|
|
187
|
+
expect(container.querySelector("svg")).toBeNull();
|
|
188
|
+
expect(container.textContent).toBe("");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("renders inside the bracket SVG with the geometry, UT and state", () => {
|
|
192
|
+
let seen: CommandBracketContext | null = null;
|
|
193
|
+
const { container } = renderBrackets(track({ authority: AUTHORITY }), 1234, {
|
|
194
|
+
render: (ctx) => {
|
|
195
|
+
seen = ctx;
|
|
196
|
+
const { x, y } = ctx.geometry.bottomRight;
|
|
197
|
+
return <rect data-testid="mark" x={x - 6} y={y - 6} width={4} height={4} />;
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
const ctx = seen as CommandBracketContext | null;
|
|
201
|
+
expect(ctx).not.toBeNull();
|
|
202
|
+
expect(ctx!.displayUt).toBe(1234);
|
|
203
|
+
expect(ctx!.utSource).toBe("override");
|
|
204
|
+
expect(ctx!.state.authority).toEqual(AUTHORITY);
|
|
205
|
+
expect(ctx!.geometry.stroke).toBe(2);
|
|
206
|
+
expect(ctx!.geometry.bottomRight.x).toBe(ctx!.geometry.cx + ctx!.geometry.half);
|
|
207
|
+
const path = corner(container, "bottomRight")!.getAttribute("d")!;
|
|
208
|
+
const { x, y } = ctx!.geometry.bottomRight;
|
|
209
|
+
expect(path).toContain(`L${x} ${y}`);
|
|
210
|
+
const mark = screen.getByTestId("mark");
|
|
211
|
+
expect(mark.closest("svg")).toBe(container.querySelector("svg"));
|
|
212
|
+
expect(mark.getAttribute("x")).toBe(String(x - 6));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("applies static corner overrides", () => {
|
|
216
|
+
const { container } = renderBrackets(track(), 1000, {
|
|
217
|
+
corners: { bottomRight: { colour: "rgb(1, 2, 3)", dashed: true } },
|
|
218
|
+
});
|
|
219
|
+
const br = corner(container, "bottomRight") as SVGPathElement;
|
|
220
|
+
expect(br.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
221
|
+
expect(br.style.stroke).toBe("rgb(1, 2, 3)");
|
|
222
|
+
expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("hides a corner and takes overrides from a function of the context", () => {
|
|
226
|
+
const { container } = renderBrackets(track({ occluded: true, occluder: "Mun" }), 1000, {
|
|
227
|
+
corners: (ctx) => ({
|
|
228
|
+
bottomRight: { hidden: true },
|
|
229
|
+
topLeft: { dashed: !ctx.state.occluded },
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
expect(corner(container, "bottomRight")).toBeNull();
|
|
233
|
+
expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("extends the readout with the displayed UT", () => {
|
|
237
|
+
renderBrackets(track(), 1060, {
|
|
238
|
+
readout: (text, ctx) => `${text} · AGE ${ctx.displayUt - 1000}s`,
|
|
239
|
+
});
|
|
240
|
+
expect(screen.getByText("IN VIEW · 4.2 km · AGE 60s")).toBeTruthy();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("replaces the readout, or hides it with null", () => {
|
|
244
|
+
renderBrackets(track(), 1000, { readout: () => "CUSTOM" });
|
|
245
|
+
expect(screen.getByText("CUSTOM")).toBeTruthy();
|
|
246
|
+
cleanup();
|
|
247
|
+
renderBrackets(track(), 1000, { readout: () => null });
|
|
248
|
+
expect(screen.queryByText(/IN VIEW/)).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("CommandBlank", () => {
|
|
253
|
+
it("labels the blank with its reason", () => {
|
|
254
|
+
render(<CommandBlank reason="Waiting for gonogo" />);
|
|
255
|
+
expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
|
|
256
|
+
"Waiting for gonogo",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|