@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.
@@ -1,5 +1,14 @@
1
1
  import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
2
- import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
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
- stateful: false,
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={videoRef}
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
+ });