@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.
Files changed (42) hide show
  1. package/README.md +59 -0
  2. package/dist/AnchorMarkers.d.ts +128 -0
  3. package/dist/AnchorMarkers.js +162 -0
  4. package/dist/CameraFeed.d.ts +45 -4
  5. package/dist/CameraFeed.js +78 -10
  6. package/dist/CommandBrackets.d.ts +61 -12
  7. package/dist/CommandBrackets.js +106 -53
  8. package/dist/KerbalFaceFeed.d.ts +1 -1
  9. package/dist/KerbalFaceFeed.js +1 -1
  10. package/dist/RecordingsTray.d.ts +6 -0
  11. package/dist/RecordingsTray.js +17 -1
  12. package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
  13. package/dist/hooks/useCommandDisplayUt.js +57 -16
  14. package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
  15. package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
  16. package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
  17. package/dist/hooks/useTargetBracketsPreference.js +18 -5
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.js +5 -1
  20. package/dist/sharedAnchors.d.ts +3 -0
  21. package/dist/sharedAnchors.js +61 -0
  22. package/dist/videoBox.d.ts +10 -0
  23. package/dist/videoBox.js +40 -0
  24. package/package.json +2 -2
  25. package/src/AnchorMarkers.test.tsx +173 -0
  26. package/src/AnchorMarkers.tsx +341 -0
  27. package/src/CameraFeed.test.tsx +223 -6
  28. package/src/CameraFeed.tsx +145 -14
  29. package/src/CommandBrackets.test.tsx +204 -4
  30. package/src/CommandBrackets.tsx +170 -53
  31. package/src/KerbalFaceFeed.tsx +1 -1
  32. package/src/RecordingsTray.test.tsx +15 -0
  33. package/src/RecordingsTray.tsx +25 -1
  34. package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
  35. package/src/hooks/useCommandDisplayUt.ts +61 -15
  36. package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
  37. package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
  38. package/src/hooks/useRecordings.test.tsx +6 -3
  39. package/src/hooks/useTargetBracketsPreference.ts +15 -5
  40. package/src/index.ts +23 -1
  41. package/src/sharedAnchors.ts +67 -0
  42. package/src/videoBox.ts +49 -0
@@ -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, CommandBrackets, type CommandBracketOverlay } from "./CommandBrackets";
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 only the display-size
269
- * demand; still yields to the adaptive framerate shed). Off by default.
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 (mirrors gonogo's CameraFeed)
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 ? (commandCamera.commandCentre?.stationId ?? String(commandCamera.flightId)) : null,
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
- const commandTargets = commandCamera?.commandCentre?.targets ?? [];
865
- const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
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
- <CommandBrackets
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 (replacing @gonogo/ui dependencies)
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: "gonogo" };
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 gonogo" />);
454
+ render(<CommandBlank reason="Waiting for mission control" />);
255
455
  expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
256
- "Waiting for gonogo",
456
+ "Waiting for mission control",
257
457
  );
258
458
  });
259
459
  });