@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
@@ -0,0 +1,341 @@
1
+ import {
2
+ COMMAND_AIM_ANCHOR_ID,
3
+ KERBCAST_ANCHOR_OWNER,
4
+ anchorPoint,
5
+ fitFrame,
6
+ frameToScreen,
7
+ type DisplayedFrameUt,
8
+ type FittedFrame,
9
+ type FrameAnchor,
10
+ type FrameFit,
11
+ type FrameUtSource,
12
+ } from "@ksp-gonogo/kerbcast";
13
+ import { useEffect, useState, type ReactNode } from "react";
14
+ import styled from "styled-components";
15
+ import { useKerbcastClient } from "./context";
16
+ import { useVideoBox, videoFit } from "./videoBox";
17
+
18
+ const MARKER_STROKE_PX = 2;
19
+ const MARKER_MIN_HALF_PX = 14;
20
+ const MARKER_ARM_FRACTION = 0.3;
21
+ const MARKER_MIN_ARM_PX = 6;
22
+ const READOUT_GAP_PX = 11;
23
+
24
+ /** Default colours; each is overridable through the named CSS custom property. */
25
+ export const ANCHOR_MARKER_COLOURS = {
26
+ clear: "var(--kerbcast-anchor-clear, #4caf50)",
27
+ occluded: "var(--kerbcast-anchor-occluded, #f44336)",
28
+ } as const;
29
+
30
+ /** The four marks around an anchor, clockwise from the top. */
31
+ export type AnchorCorner = "top" | "right" | "bottom" | "left";
32
+
33
+ /**
34
+ * How a mark is drawn. `corners`: a right-angle corner of a box around the
35
+ * anchor, like the command brackets; each key names the corner that starts
36
+ * its side going clockwise, so `top` is the top left, `right` the top right,
37
+ * `bottom` the bottom right and `left` the bottom left. `triangles`: a
38
+ * triangle outside the box on that side, pointing at the anchor.
39
+ */
40
+ export type AnchorMarkerShape = "corners" | "triangles";
41
+
42
+ export interface AnchorCornerStyle {
43
+ colour: string;
44
+ dashed: boolean;
45
+ shape: AnchorMarkerShape;
46
+ }
47
+
48
+ /** Changes to one default mark; unset fields keep the default. */
49
+ export interface AnchorCornerOverride {
50
+ colour?: string;
51
+ dashed?: boolean;
52
+ hidden?: boolean;
53
+ shape?: AnchorMarkerShape;
54
+ }
55
+
56
+ export type AnchorCornerOverrides = Partial<Record<AnchorCorner, AnchorCornerOverride>>;
57
+
58
+ /** The marker box for one anchor, in the overlay SVG's pixels (the video element's box). */
59
+ export interface AnchorMarkerGeometry {
60
+ /** Where the anchor is on screen. Off the box when it is out of frame. */
61
+ cx: number;
62
+ cy: number;
63
+ /** Half the box side: the anchor's apparent radius on screen, never below a minimum. */
64
+ half: number;
65
+ /** Length of each corner stroke, and the side of each triangle. */
66
+ arm: number;
67
+ stroke: number;
68
+ }
69
+
70
+ /** An anchor with its place on screen. */
71
+ export interface PlacedAnchor {
72
+ anchor: FrameAnchor;
73
+ x: number;
74
+ y: number;
75
+ }
76
+
77
+ /** Everything an anchor overlay needs to line up with the drawn markers. */
78
+ export interface AnchorContext {
79
+ geometry: AnchorMarkerGeometry;
80
+ /** Where the frame lands in the element, after `object-fit`. */
81
+ content: FittedFrame;
82
+ /** Size of the overlay SVG, the video element's box. */
83
+ width: number;
84
+ height: number;
85
+ /** Mission UT of the displayed frame the anchors describe. */
86
+ displayUt: number;
87
+ utSource: FrameUtSource;
88
+ /** Every drawn anchor in this frame with its screen position. */
89
+ anchors: readonly PlacedAnchor[];
90
+ /** The displayed frame, with its raw per-frame results and size. */
91
+ frame: DisplayedFrameUt;
92
+ }
93
+
94
+ /**
95
+ * Consumer changes to a feed's anchor markers. Each part is optional and is
96
+ * called for every drawn anchor on every displayed frame. What an anchor
97
+ * means, and which colour says so, is the consumer's: Kerbcast only reports
98
+ * where it is.
99
+ */
100
+ export interface AnchorOverlay {
101
+ /** Default shape of every mark. Default `"corners"`. */
102
+ shape?: AnchorMarkerShape;
103
+ /** Restyle or hide the four marks of one anchor. */
104
+ corners?:
105
+ | AnchorCornerOverrides
106
+ | ((anchor: FrameAnchor, ctx: AnchorContext) => AnchorCornerOverrides | null | undefined);
107
+ /**
108
+ * SVG content drawn above the marks, in the same pixel space. Runs for an
109
+ * anchor out of frame or behind the lens too (its marks are left out then),
110
+ * so an overlay can point to it from the edge. An observed result out of
111
+ * view carries no position and is not drawn at all; it is still in
112
+ * `ctx.frame.anchors`.
113
+ */
114
+ render?: (anchor: FrameAnchor, ctx: AnchorContext) => ReactNode;
115
+ /** Text under the marker, or null for none. Default none. */
116
+ readout?: (anchor: FrameAnchor, ctx: AnchorContext) => string | null;
117
+ /**
118
+ * Game-side owners whose anchors are drawn as well as this client's own,
119
+ * e.g. `["my-mod"]`. Default: only this client's.
120
+ */
121
+ owners?: readonly string[];
122
+ /**
123
+ * Draw Kerbcast's own `aim` anchor too when `"kerbcast"` is among
124
+ * `owners`. Off by default: the brackets already mark the aim.
125
+ */
126
+ showAim?: boolean;
127
+ }
128
+
129
+ /** The marker box for an anchor at `centre` with `radiusPx` capture pixels on a `frameHeight`-tall frame. */
130
+ export function anchorMarkerGeometry(
131
+ centre: { x: number; y: number },
132
+ radiusPx: number,
133
+ frameHeight: number,
134
+ content: FittedFrame,
135
+ ): AnchorMarkerGeometry {
136
+ const scale = frameHeight > 0 ? content.height / frameHeight : 1;
137
+ const half = Math.max(MARKER_MIN_HALF_PX, radiusPx * scale);
138
+ const arm = Math.max(MARKER_MIN_ARM_PX, MARKER_ARM_FRACTION * half * 2);
139
+ return { cx: centre.x, cy: centre.y, half, arm, stroke: MARKER_STROKE_PX };
140
+ }
141
+
142
+ const CORNERS: readonly AnchorCorner[] = ["top", "right", "bottom", "left"];
143
+
144
+ /** SVG path data for one mark. Corners are open strokes; triangles are closed. */
145
+ function markPath(corner: AnchorCorner, shape: AnchorMarkerShape, g: AnchorMarkerGeometry): string {
146
+ const { cx, cy, half: h, arm: a } = g;
147
+ if (shape === "triangles") {
148
+ const t = a * 0.8;
149
+ const w = a / 2;
150
+ switch (corner) {
151
+ case "top":
152
+ return `M${cx} ${cy - h}L${cx - w} ${cy - h - t}L${cx + w} ${cy - h - t}Z`;
153
+ case "right":
154
+ return `M${cx + h} ${cy}L${cx + h + t} ${cy - w}L${cx + h + t} ${cy + w}Z`;
155
+ case "bottom":
156
+ return `M${cx} ${cy + h}L${cx + w} ${cy + h + t}L${cx - w} ${cy + h + t}Z`;
157
+ default:
158
+ return `M${cx - h} ${cy}L${cx - h - t} ${cy + w}L${cx - h - t} ${cy - w}Z`;
159
+ }
160
+ }
161
+ const l = cx - h, r = cx + h, tp = cy - h, b = cy + h;
162
+ switch (corner) {
163
+ case "top":
164
+ return `M${l} ${tp + a}L${l} ${tp}L${l + a} ${tp}`;
165
+ case "right":
166
+ return `M${r - a} ${tp}L${r} ${tp}L${r} ${tp + a}`;
167
+ case "bottom":
168
+ return `M${r} ${b - a}L${r} ${b}L${r - a} ${b}`;
169
+ default:
170
+ return `M${l + a} ${b}L${l} ${b}L${l} ${b - a}`;
171
+ }
172
+ }
173
+
174
+ /** Whether a feed shows this anchor: this client's own, or one from a listed owner, Kerbcast's aim only on request. */
175
+ function drawn(a: FrameAnchor, overlay: AnchorOverlay | null | undefined): boolean {
176
+ if (a.owner === undefined) return true;
177
+ if (a.owner === KERBCAST_ANCHOR_OWNER && a.id === COMMAND_AIM_ANCHOR_ID && !overlay?.showAim) return false;
178
+ return overlay?.owners?.includes(a.owner) ?? false;
179
+ }
180
+
181
+ export interface AnchorMarkersProps {
182
+ /** The displayed frame whose anchors to draw. */
183
+ frame: DisplayedFrameUt | null;
184
+ video: HTMLVideoElement | null;
185
+ overlay?: AnchorOverlay | null;
186
+ /** How the video fits its box. Default: read from the element's CSS. */
187
+ fit?: FrameFit;
188
+ }
189
+
190
+ /**
191
+ * Markers for the anchors in one displayed frame: by default four corner
192
+ * marks around each anchor sized from its apparent radius, green when clear
193
+ * and red and dashed when occluded. Positions come from the frame's own
194
+ * stamp, so they match the picture under any delay.
195
+ */
196
+ export function AnchorMarkers({ frame, video, overlay, fit }: AnchorMarkersProps) {
197
+ const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
198
+ if (!frame?.anchors?.length) return null;
199
+ const frameW = frame.frameWidth || mediaW;
200
+ const frameH = frame.frameHeight || mediaH;
201
+ if (!(frameW > 0 && frameH > 0 && boxW > 0 && boxH > 0)) return null;
202
+ const content = fitFrame(boxW, boxH, frameW, frameH, fit ?? videoFit(video));
203
+ /* An observed result out of view has no position, so it has no marker. */
204
+ const placed: PlacedAnchor[] = [];
205
+ for (const a of frame.anchors) {
206
+ const point = drawn(a, overlay) ? anchorPoint(a) : null;
207
+ if (point) placed.push({ anchor: a, ...frameToScreen(point, frameW, frameH, content) });
208
+ }
209
+ if (placed.length === 0) return null;
210
+ const shape = overlay?.shape ?? "corners";
211
+
212
+ const items = placed.map(({ anchor, x, y }) => {
213
+ const geometry = anchorMarkerGeometry({ x, y }, anchor.apparentRadiusPx ?? 0, frameH, content);
214
+ const ctx: AnchorContext = {
215
+ geometry,
216
+ content,
217
+ width: boxW,
218
+ height: boxH,
219
+ displayUt: frame.ut,
220
+ utSource: frame.source,
221
+ anchors: placed,
222
+ frame,
223
+ };
224
+ const overrides =
225
+ typeof overlay?.corners === "function" ? overlay.corners(anchor, ctx) : overlay?.corners;
226
+ const base: AnchorCornerStyle = anchor.occluded
227
+ ? { colour: ANCHOR_MARKER_COLOURS.occluded, dashed: true, shape }
228
+ : { colour: ANCHOR_MARKER_COLOURS.clear, dashed: false, shape };
229
+ const visible = anchor.inFrame && !anchor.behind;
230
+ const readout = overlay?.readout ? overlay.readout(anchor, ctx) : null;
231
+ return { anchor, geometry, ctx, overrides, base, visible, readout };
232
+ });
233
+
234
+ return (
235
+ <>
236
+ <MarkerSvg aria-hidden="true" data-anchor-markers="" width={boxW} height={boxH}>
237
+ {items.map(({ anchor, geometry, ctx, overrides, base, visible }) => (
238
+ <g key={`${anchor.owner ?? ""}/${anchor.id}`} data-anchor={anchor.id} data-owner={anchor.owner}>
239
+ {visible &&
240
+ CORNERS.map((corner) => {
241
+ const o = overrides?.[corner];
242
+ if (o?.hidden) return null;
243
+ const s = o?.shape ?? base.shape;
244
+ const colour = o?.colour ?? base.colour;
245
+ const dashed = o?.dashed ?? base.dashed;
246
+ const filled = s === "triangles";
247
+ return (
248
+ <path
249
+ key={corner}
250
+ data-corner={corner}
251
+ data-shape={s}
252
+ d={markPath(corner, s, geometry)}
253
+ strokeWidth={geometry.stroke}
254
+ strokeDasharray={dashed ? "3 2" : undefined}
255
+ style={filled ? { fill: dashed ? "none" : colour, stroke: colour } : { fill: "none", stroke: colour }}
256
+ />
257
+ );
258
+ })}
259
+ {overlay?.render && <g data-anchor-overlay="">{overlay.render(anchor, ctx)}</g>}
260
+ </g>
261
+ ))}
262
+ </MarkerSvg>
263
+ {items.map(
264
+ ({ anchor, geometry, readout, visible }) =>
265
+ readout &&
266
+ visible && (
267
+ <Readout
268
+ key={`${anchor.owner ?? ""}/${anchor.id}`}
269
+ data-anchor-readout={anchor.id}
270
+ style={{ left: geometry.cx, top: geometry.cy + geometry.half + READOUT_GAP_PX }}
271
+ >
272
+ {readout}
273
+ </Readout>
274
+ ),
275
+ )}
276
+ </>
277
+ );
278
+ }
279
+
280
+ /**
281
+ * The displayed frame of `flightId` on `video`, re-rendering on every
282
+ * presented frame while `enabled`. Kept to the component that draws from it,
283
+ * so a feed's other UI does not re-render at the frame rate.
284
+ */
285
+ export function useDisplayedFrame(
286
+ enabled: boolean,
287
+ flightId: number | null,
288
+ video: HTMLVideoElement | null,
289
+ ): DisplayedFrameUt | null {
290
+ const client = useKerbcastClient();
291
+ const [frame, setFrame] = useState<DisplayedFrameUt | null>(null);
292
+ useEffect(() => {
293
+ setFrame(null);
294
+ if (!enabled || flightId === null || !video) return;
295
+ return client.watchDisplayedFrames(flightId, video, (f) =>
296
+ /* Skip re-renders between frames that carry the same results. */
297
+ setFrame((prev) =>
298
+ prev && prev.anchors === f.anchors && prev.rtpTimestamp === f.rtpTimestamp ? prev : f,
299
+ ),
300
+ );
301
+ }, [client, enabled, flightId, video]);
302
+ return frame;
303
+ }
304
+
305
+ /** Self-subscribing markers for a feed: watches its displayed frames and draws their anchors. */
306
+ export function LiveAnchorMarkers({
307
+ flightId,
308
+ video,
309
+ overlay,
310
+ fit,
311
+ }: {
312
+ flightId: number | null;
313
+ video: HTMLVideoElement | null;
314
+ overlay?: AnchorOverlay | null;
315
+ fit?: FrameFit;
316
+ }) {
317
+ const frame = useDisplayedFrame(true, flightId, video);
318
+ return <AnchorMarkers frame={frame} video={video} overlay={overlay} fit={fit} />;
319
+ }
320
+
321
+ const MarkerSvg = styled.svg`
322
+ position: absolute;
323
+ top: 0;
324
+ left: 0;
325
+ z-index: 1;
326
+ overflow: visible;
327
+ pointer-events: none;
328
+ `;
329
+
330
+ const Readout = styled.div`
331
+ position: absolute;
332
+ z-index: 1;
333
+ transform: translateX(-50%);
334
+ white-space: nowrap;
335
+ color: #fff;
336
+ font-size: 10px;
337
+ font-weight: 600;
338
+ letter-spacing: 0.12em;
339
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
340
+ pointer-events: none;
341
+ `;
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import {
26
+ AnchorKind,
26
27
  CameraKind,
27
28
  CommandTargetEligibility,
28
29
  KerbcastClient,
@@ -30,10 +31,12 @@ import {
30
31
  TrackMode,
31
32
  } from "@ksp-gonogo/kerbcast";
32
33
  import type {
34
+ Anchor,
33
35
  CameraLifecycle,
34
36
  ClientMessage,
35
37
  CommandCentreInfo,
36
38
  CommandTrackState,
39
+ DisplayedFrameUt,
37
40
  } from "@ksp-gonogo/kerbcast";
38
41
  import { type MockCameraInit, MockSidecar } from "@ksp-gonogo/kerbcast/testing";
39
42
  import {
@@ -2747,9 +2750,54 @@ describe("CameraFeed - recording locks source and close", () => {
2747
2750
  // Command camera target brackets
2748
2751
  // ---------------------------------------------------------------------------
2749
2752
 
2753
+
2754
+ describe("CameraFeed: no video slot", () => {
2755
+ const NOTICE = /Not streaming: too many feeds are open/;
2756
+ const ids = [1, 2, 3, 4, 5];
2757
+ const feeds = (client: KerbcastClient, shown: number[], label?: string | null) => (
2758
+ <KerbcastProvider client={client}>
2759
+ {shown.map((id) => (
2760
+ <CameraFeed
2761
+ key={id}
2762
+ flightId={id}
2763
+ {...(label !== undefined ? { noSlotLabel: label } : {})}
2764
+ />
2765
+ ))}
2766
+ </KerbcastProvider>
2767
+ );
2768
+
2769
+ it("says so on the feed that found every slot taken, until another feed closes", async () => {
2770
+ const { client } = await buildConnectedSource(
2771
+ ids.map((id) => makeCamera({ flightId: id, cameraName: `Cam ${id}` })),
2772
+ );
2773
+ const view = render(feeds(client, ids));
2774
+ await act(async () => {});
2775
+ expect(screen.getAllByText(NOTICE)).toHaveLength(1);
2776
+ expect(client.camera(5).waitingForSlot).toBe(true);
2777
+
2778
+ view.rerender(feeds(client, [2, 3, 4, 5]));
2779
+ await act(async () => {});
2780
+ expect(screen.queryByText(NOTICE)).toBeNull();
2781
+ expect(client.camera(5).waitingForSlot).toBe(false);
2782
+ });
2783
+
2784
+ it("takes a host's own words, or none", async () => {
2785
+ const { client } = await buildConnectedSource(
2786
+ ids.map((id) => makeCamera({ flightId: id, cameraName: `Cam ${id}` })),
2787
+ );
2788
+ const view = render(feeds(client, ids, "No room"));
2789
+ await act(async () => {});
2790
+ expect(screen.getByText("No room")).toBeTruthy();
2791
+ view.rerender(feeds(client, ids, null));
2792
+ expect(screen.queryByText("No room")).toBeNull();
2793
+ expect(screen.queryByText(NOTICE)).toBeNull();
2794
+ });
2795
+ });
2796
+
2750
2797
  describe("CameraFeed - command camera target brackets", () => {
2751
2798
  const STATION: CommandCentreInfo = {
2752
2799
  stationId: "ground:KSC",
2800
+ siteKey: "KSC",
2753
2801
  nodeName: "KSC",
2754
2802
  displayName: "Kerbal Space Center",
2755
2803
  body: "Kerbin",
@@ -2809,7 +2857,7 @@ describe("CameraFeed - command camera target brackets", () => {
2809
2857
  const first = renderFeed(client, { flightId: 301 });
2810
2858
  fireEvent.click(screen.getByRole("button", { name: "Target brackets" }));
2811
2859
  expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
2812
- expect(localStorage.getItem("kerbcast:target-brackets:ground:KSC")).toBe("0");
2860
+ expect(localStorage.getItem("kerbcast:target-brackets:KSC")).toBe("0");
2813
2861
  first.unmount();
2814
2862
 
2815
2863
  renderFeed(client, { flightId: 301 });
@@ -2819,22 +2867,87 @@ describe("CameraFeed - command camera target brackets", () => {
2819
2867
  ).toBe("false");
2820
2868
  });
2821
2869
 
2822
- it("still labels a blanked feed with the brackets toggled off", async () => {
2870
+ it("carries a brackets-off choice saved under the 1.11 key over to the site key", async () => {
2823
2871
  localStorage.setItem("kerbcast:target-brackets:ground:KSC", "0");
2872
+ const { client } = await buildConnectedSource([commandCamera()]);
2873
+ renderFeed(client, { flightId: 301 });
2874
+ expect(
2875
+ screen.getByRole("button", { name: "Target brackets" }).getAttribute("aria-pressed"),
2876
+ ).toBe("false");
2877
+ expect(localStorage.getItem("kerbcast:target-brackets:KSC")).toBe("0");
2878
+ expect(localStorage.getItem("kerbcast:target-brackets:ground:KSC")).toBeNull();
2879
+ });
2880
+
2881
+ it("still labels a blanked feed with the brackets toggled off", async () => {
2882
+ localStorage.setItem("kerbcast:target-brackets:KSC", "0");
2824
2883
  const { client, sidecar } = await buildConnectedSource([commandCamera()]);
2825
2884
  renderFeed(client, { flightId: 301 });
2826
2885
  expect(screen.queryByRole("status", { name: "Feed blanked" })).toBeNull();
2827
2886
  await act(async () => {
2828
- sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for gonogo" }) });
2887
+ sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for mission control" }) });
2829
2888
  });
2830
2889
  expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
2831
- "Waiting for gonogo",
2890
+ "Waiting for mission control",
2832
2891
  );
2833
2892
  });
2834
2893
 
2894
+ describe("waiting for a target", () => {
2895
+ const waitingFrame = (waitingForTarget: boolean): DisplayedFrameUt => ({
2896
+ flightId: 301,
2897
+ ut: 1000,
2898
+ epoch: 0,
2899
+ source: "exact",
2900
+ rtpTimestamp: 1,
2901
+ presentedAt: 0,
2902
+ command: {
2903
+ hasFix: false,
2904
+ frameHeightPx: 576,
2905
+ framingFits: true,
2906
+ occluded: false,
2907
+ occluder: "",
2908
+ sunlit: false,
2909
+ subjectPersistentId: 0,
2910
+ waitingForTarget,
2911
+ },
2912
+ });
2913
+ const feed = (client: KerbcastClient, frame: DisplayedFrameUt, label?: string | null) => (
2914
+ <KerbcastProvider client={client}>
2915
+ <CameraFeed
2916
+ flightId={301}
2917
+ displayFrame={frame}
2918
+ {...(label !== undefined ? { waitingForTargetLabel: label } : {})}
2919
+ />
2920
+ </KerbcastProvider>
2921
+ );
2922
+
2923
+ it("says so while the frame does, and stops once the target arrives", async () => {
2924
+ const { client } = await buildConnectedSource([commandCamera()]);
2925
+ const view = render(feed(client, waitingFrame(true)));
2926
+ expect(screen.getByText("Waiting for target")).toBeTruthy();
2927
+ view.rerender(feed(client, waitingFrame(false)));
2928
+ expect(screen.queryByText("Waiting for target")).toBeNull();
2929
+ });
2930
+
2931
+ it("takes a host's own words, or none", async () => {
2932
+ const { client } = await buildConnectedSource([commandCamera()]);
2933
+ const view = render(feed(client, waitingFrame(true), "Acquiring"));
2934
+ expect(screen.getByText("Acquiring")).toBeTruthy();
2935
+ view.rerender(feed(client, waitingFrame(true), null));
2936
+ expect(screen.queryByText("Acquiring")).toBeNull();
2937
+ expect(screen.queryByText("Waiting for target")).toBeNull();
2938
+ });
2939
+
2940
+ it("gives way to a blank", async () => {
2941
+ const { client } = await buildConnectedSource([commandCamera()]);
2942
+ const frame = waitingFrame(true);
2943
+ render(feed(client, { ...frame, command: { ...frame.command!, blank: "Held" } }));
2944
+ expect(screen.getByRole("status", { name: "Feed blanked" })).toBeTruthy();
2945
+ expect(screen.queryByText("Waiting for target")).toBeNull();
2946
+ });
2947
+ });
2948
+
2835
2949
  it("shows the transition in force at the displayed UT", async () => {
2836
2950
  const track = commandTrack({
2837
- authority: { owner: "gonogo" },
2838
2951
  events: [
2839
2952
  { ut: 990, occluded: true, occluder: "Mun", sunlit: true },
2840
2953
  { ut: 999, occluded: false, occluder: "", sunlit: true },
@@ -2873,6 +2986,46 @@ describe("CameraFeed - command camera target brackets", () => {
2873
2986
  expect(screen.getByText(/ · T\+1060$/)).toBeTruthy();
2874
2987
  });
2875
2988
 
2989
+ it("gives the brackets the subject's anchor from the displayed frame's stamp", async () => {
2990
+ const { client, sidecar } = await buildConnectedSource([commandCamera()]);
2991
+ const subjects: (string | undefined)[] = [];
2992
+ renderFeed(client, {
2993
+ flightId: 301,
2994
+ targetBracketOverlay: {
2995
+ render: (ctx) => {
2996
+ subjects.push(ctx.subject ? `${ctx.subject.owner}/${ctx.subject.id}` : undefined);
2997
+ return null;
2998
+ },
2999
+ },
3000
+ });
3001
+ await act(async () => {
3002
+ sidecar.fireFrameStamp({
3003
+ flightId: 301,
3004
+ rtpTimestamp: 1,
3005
+ captureUt: 1000,
3006
+ captureEpoch: 0,
3007
+ width: 1024,
3008
+ height: 576,
3009
+ anchors: [
3010
+ {
3011
+ id: "subject",
3012
+ owner: "kerbcast",
3013
+ x: 100,
3014
+ y: 100,
3015
+ inFrame: true,
3016
+ behind: false,
3017
+ distanceM: 11_507_905,
3018
+ apparentRadiusPx: 4,
3019
+ angleOffAxis: 3,
3020
+ occluded: false,
3021
+ },
3022
+ ],
3023
+ });
3024
+ });
3025
+ /* Without frame callbacks (jsdom) a stamp is taken as on screen once it is old enough. */
3026
+ await waitFor(() => expect(subjects).toContain("kerbcast/subject"), { timeout: 2000 });
3027
+ });
3028
+
2876
3029
  describe("target picker", () => {
2877
3030
  const TARGETS: CommandCentreInfo = {
2878
3031
  ...STATION,
@@ -2993,7 +3146,7 @@ describe("CameraFeed - command camera target brackets", () => {
2993
3146
  kind: CameraKind.Command,
2994
3147
  cameraName: "KSC tracking",
2995
3148
  commandCentre: TARGETS,
2996
- commandTrack: commandTrack({ authority: { owner: "gonogo" } }),
3149
+ commandTrack: commandTrack({ authority: { owner: "mission-control" } }),
2997
3150
  }),
2998
3151
  ]);
2999
3152
  const lockedView = renderFeed(locked.client, { flightId: 301 });
@@ -3013,3 +3166,67 @@ describe("CameraFeed - command camera target brackets", () => {
3013
3166
  expect(screen.queryByRole("button", { name: "Target brackets" })).toBeNull();
3014
3167
  });
3015
3168
  });
3169
+
3170
+ describe("CameraFeed - anchors", () => {
3171
+ const port = { id: "port", kind: AnchorKind.Part, vessel: 11, part: 22, orientation: true };
3172
+
3173
+ function Feed({ client, flightId, anchors }: { client: KerbcastClient; flightId: number; anchors?: Anchor[] }) {
3174
+ return (
3175
+ <KerbcastProvider client={client}>
3176
+ <CameraFeed flightId={flightId} anchors={anchors} />
3177
+ </KerbcastProvider>
3178
+ );
3179
+ }
3180
+
3181
+ it("sets its anchors on the displayed camera and clears them when it moves or unmounts", async () => {
3182
+ const { client, sidecar } = await buildConnectedSource([
3183
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
3184
+ makeCamera({ flightId: 43, cameraName: "Port Cam" }),
3185
+ ]);
3186
+ const view = render(<Feed client={client} flightId={42} anchors={[port]} />);
3187
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port]));
3188
+
3189
+ /* A new array with the same anchors is no change. */
3190
+ const sent = () => sidecar.commands.filter((c) => c.type === "set-anchors").length;
3191
+ const before = sent();
3192
+ view.rerender(<Feed client={client} flightId={42} anchors={[{ ...port }]} />);
3193
+ expect(sent()).toBe(before);
3194
+
3195
+ view.rerender(<Feed client={client} flightId={43} anchors={[port]} />);
3196
+ await waitFor(() => expect(sidecar.anchorsFor(43)).toEqual([port]));
3197
+ expect(sidecar.anchorsFor(42)).toEqual([]);
3198
+
3199
+ view.unmount();
3200
+ await waitFor(() => expect(sidecar.anchorsFor(43)).toEqual([]));
3201
+ });
3202
+
3203
+ it("gives two feeds on one camera both their anchors, until each unmounts", async () => {
3204
+ const { client, sidecar } = await buildConnectedSource([
3205
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
3206
+ ]);
3207
+ const dock = { id: "dock", kind: AnchorKind.Vessel, vessel: 12 };
3208
+ const first = render(<Feed client={client} flightId={42} anchors={[port]} />);
3209
+ const second = render(<Feed client={client} flightId={42} anchors={[port, dock]} />);
3210
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port, dock]));
3211
+
3212
+ first.unmount();
3213
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port, dock]));
3214
+ second.rerender(<Feed client={client} flightId={42} anchors={[dock]} />);
3215
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([dock]));
3216
+ second.unmount();
3217
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([]));
3218
+ });
3219
+
3220
+ it("warns when two feeds on one camera use one anchor id for different things", async () => {
3221
+ const { client, sidecar } = await buildConnectedSource([makeCamera({ flightId: 42 })]);
3222
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
3223
+ const other = { ...port, part: 23 };
3224
+ render(<Feed client={client} flightId={42} anchors={[port]} />);
3225
+ render(<Feed client={client} flightId={42} anchors={[port]} />);
3226
+ expect(warn).not.toHaveBeenCalled();
3227
+ render(<Feed client={client} flightId={42} anchors={[other]} />);
3228
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("id port"));
3229
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port]));
3230
+ warn.mockRestore();
3231
+ });
3232
+ });