@ksp-gonogo/kerbcast-react 1.11.1 → 1.12.0-rc.2

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 (49) hide show
  1. package/README.md +71 -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 +163 -23
  6. package/dist/CommandBrackets.d.ts +61 -12
  7. package/dist/CommandBrackets.js +106 -53
  8. package/dist/FeedActionBar.d.ts +7 -0
  9. package/dist/FeedActionBar.js +4 -4
  10. package/dist/KerbalFaceFeed.d.ts +1 -1
  11. package/dist/KerbalFaceFeed.js +1 -1
  12. package/dist/RecordingsTray.d.ts +6 -0
  13. package/dist/RecordingsTray.js +17 -1
  14. package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
  15. package/dist/hooks/useCommandDisplayUt.js +57 -16
  16. package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
  17. package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
  18. package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
  19. package/dist/hooks/useTargetBracketsPreference.js +18 -5
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.js +5 -1
  22. package/dist/menuPositioning.d.ts +8 -2
  23. package/dist/menuPositioning.js +30 -5
  24. package/dist/sharedAnchors.d.ts +3 -0
  25. package/dist/sharedAnchors.js +61 -0
  26. package/dist/videoBox.d.ts +10 -0
  27. package/dist/videoBox.js +40 -0
  28. package/package.json +2 -2
  29. package/src/AnchorMarkers.test.tsx +173 -0
  30. package/src/AnchorMarkers.tsx +341 -0
  31. package/src/CameraFeed.test.tsx +687 -6
  32. package/src/CameraFeed.tsx +240 -27
  33. package/src/CommandBrackets.test.tsx +204 -4
  34. package/src/CommandBrackets.tsx +170 -53
  35. package/src/FeedActionBar.test.ts +22 -0
  36. package/src/FeedActionBar.tsx +11 -4
  37. package/src/KerbalFaceFeed.tsx +1 -1
  38. package/src/RecordingsTray.test.tsx +15 -0
  39. package/src/RecordingsTray.tsx +25 -1
  40. package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
  41. package/src/hooks/useCommandDisplayUt.ts +61 -15
  42. package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
  43. package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
  44. package/src/hooks/useRecordings.test.tsx +6 -3
  45. package/src/hooks/useTargetBracketsPreference.ts +15 -5
  46. package/src/index.ts +23 -1
  47. package/src/menuPositioning.ts +31 -5
  48. package/src/sharedAnchors.ts +67 -0
  49. package/src/videoBox.ts +49 -0
@@ -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
  });
@@ -1,10 +1,19 @@
1
1
  import {
2
+ COMMAND_AIM_ANCHOR_ID,
3
+ COMMAND_SUBJECT_ANCHOR_ID,
4
+ KERBCAST_ANCHOR_OWNER,
5
+ anchorPoint,
2
6
  commandReadout,
7
+ findAnchor,
8
+ frameToScreen,
3
9
  type EffectiveCommandTrack,
10
+ type FrameAnchor,
4
11
  type FrameUtSource,
5
12
  } from "@ksp-gonogo/kerbcast";
6
- import { useEffect, useState, type ReactNode } from "react";
13
+ import { type ReactNode } from "react";
7
14
  import styled from "styled-components";
15
+ import { useDisplayedFrame } from "./AnchorMarkers";
16
+ import { useVideoBox } from "./videoBox";
8
17
 
9
18
  const BRACKET_STROKE_PX = 2;
10
19
  const BRACKET_MIN_HALF_PX = 14;
@@ -51,7 +60,10 @@ export interface BracketPoint {
51
60
 
52
61
  /** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
53
62
  export interface BracketGeometry {
54
- /** Box centre, which is the aim point. */
63
+ /**
64
+ * Box centre: where the subject is in the displayed frame, or the frame
65
+ * centre (the aim point) while that is not known.
66
+ */
55
67
  cx: number;
56
68
  cy: number;
57
69
  /** Half the box side in display px. */
@@ -65,20 +77,22 @@ export interface BracketGeometry {
65
77
  }
66
78
 
67
79
  /**
68
- * The bracket box centred on the displayed frame. `bracketHalfPx` is in
69
- * capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
70
- * height; a missing half-size draws the minimum box.
80
+ * The bracket box, centred on the displayed frame or on `centre` (in the
81
+ * same pixel space as `rect`). `bracketHalfPx` is in capture px of a
82
+ * `frameHeightPx`-tall frame, so it scales by the displayed height; a
83
+ * missing half-size draws the minimum box.
71
84
  */
72
85
  export function bracketGeometry(
73
86
  rect: ContentRect,
74
87
  bracketHalfPx: number | null,
75
88
  frameHeightPx: number,
89
+ centre?: BracketPoint | null,
76
90
  ): BracketGeometry {
77
91
  const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
78
92
  const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
79
93
  const arm = Math.max(BRACKET_MIN_ARM_PX, BRACKET_ARM_FRACTION * half * 2);
80
- const cx = rect.x + rect.width / 2;
81
- const cy = rect.y + rect.height / 2;
94
+ const cx = centre ? centre.x : rect.x + rect.width / 2;
95
+ const cy = centre ? centre.y : rect.y + rect.height / 2;
82
96
  return {
83
97
  cx,
84
98
  cy,
@@ -118,6 +132,54 @@ export interface CommandBracketContext {
118
132
  displayUt: number;
119
133
  utSource: FrameUtSource | "override" | null;
120
134
  state: EffectiveCommandTrack;
135
+ /**
136
+ * The subject's anchor in the displayed frame: where the game projected
137
+ * the craft, whether it is in frame and whether something hides it.
138
+ * `null` without one (an older sidecar or plugin, or before the first
139
+ * stamped frame), when the box is centred on the frame instead.
140
+ */
141
+ subject?: FrameAnchor | null;
142
+ /**
143
+ * True while the aim lies off the frame (the view is still turning onto
144
+ * it): the box then sits on the frame's edge nearest the aim, with a
145
+ * chevron pointing on toward it.
146
+ */
147
+ aimOffscreen?: boolean;
148
+ }
149
+
150
+ /** Whether `p` lies inside the box `g`. */
151
+ function holds(g: BracketGeometry, p: BracketPoint): boolean {
152
+ return p.x >= g.topLeft.x && p.x <= g.bottomRight.x && p.y >= g.topLeft.y && p.y <= g.bottomRight.y;
153
+ }
154
+
155
+ /** `g` moved so the whole box lies inside `rect`. */
156
+ function clampToRect(g: BracketGeometry, rect: ContentRect): BracketGeometry {
157
+ const clamp = (v: number, lo: number, hi: number) => (lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v)));
158
+ const cx = clamp(g.cx, rect.x + g.half, rect.x + rect.width - g.half);
159
+ const cy = clamp(g.cy, rect.y + g.half, rect.y + rect.height - g.half);
160
+ return {
161
+ ...g,
162
+ cx,
163
+ cy,
164
+ topLeft: { x: cx - g.half, y: cy - g.half },
165
+ bottomRight: { x: cx + g.half, y: cy + g.half },
166
+ };
167
+ }
168
+
169
+ /** A chevron just outside the box, pointing from its centre toward `toward`. */
170
+ function edgeCue(g: BracketGeometry, toward: BracketPoint): string | null {
171
+ const dx = toward.x - g.cx;
172
+ const dy = toward.y - g.cy;
173
+ const len = Math.hypot(dx, dy);
174
+ if (!(len > 0)) return null;
175
+ const ux = dx / len;
176
+ const uy = dy / len;
177
+ const tipX = g.cx + ux * (g.half + g.arm);
178
+ const tipY = g.cy + uy * (g.half + g.arm);
179
+ const backX = tipX - ux * g.arm;
180
+ const backY = tipY - uy * g.arm;
181
+ const w = g.arm * 0.6;
182
+ return `M${backX - uy * w} ${backY + ux * w}L${tipX} ${tipY}L${backX + uy * w} ${backY - ux * w}`;
121
183
  }
122
184
 
123
185
  /**
@@ -143,8 +205,8 @@ export interface CommandBracketOverlay {
143
205
  readout?: (defaultText: string, ctx: CommandBracketContext) => string | null;
144
206
  }
145
207
 
146
- function cameraCornerStyle(state: EffectiveCommandTrack): CommandBracketCornerStyle {
147
- return state.occluded
208
+ function cameraCornerStyle(occluded: boolean): CommandBracketCornerStyle {
209
+ return occluded
148
210
  ? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
149
211
  : { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
150
212
  }
@@ -160,41 +222,6 @@ function applyCorner(
160
222
  };
161
223
  }
162
224
 
163
- /** Tracks the rendered box and intrinsic size of a video element. */
164
- function useVideoBox(video: HTMLVideoElement | null) {
165
- const [box, setBox] = useState({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
166
- useEffect(() => {
167
- if (!video) return;
168
- const measure = () => {
169
- const next = {
170
- boxW: video.clientWidth,
171
- boxH: video.clientHeight,
172
- mediaW: video.videoWidth,
173
- mediaH: video.videoHeight,
174
- };
175
- setBox((prev) =>
176
- prev.boxW === next.boxW &&
177
- prev.boxH === next.boxH &&
178
- prev.mediaW === next.mediaW &&
179
- prev.mediaH === next.mediaH
180
- ? prev
181
- : next,
182
- );
183
- };
184
- measure();
185
- const observer = new ResizeObserver(measure);
186
- observer.observe(video);
187
- video.addEventListener("loadedmetadata", measure);
188
- video.addEventListener("resize", measure);
189
- return () => {
190
- observer.disconnect();
191
- video.removeEventListener("loadedmetadata", measure);
192
- video.removeEventListener("resize", measure);
193
- };
194
- }, [video]);
195
- return box;
196
- }
197
-
198
225
  export interface CommandBracketsProps {
199
226
  state: EffectiveCommandTrack;
200
227
  displayUt: number;
@@ -204,14 +231,35 @@ export interface CommandBracketsProps {
204
231
  /** Frame aspect to assume until the video reports its own size. */
205
232
  fallbackSize?: { width: number; height: number } | null;
206
233
  overlay?: CommandBracketOverlay | null;
234
+ /**
235
+ * The subject's anchor in the displayed frame. While it is visible and no
236
+ * command lock is held the box sits on it and takes its size from it.
237
+ */
238
+ subject?: FrameAnchor | null;
239
+ /** The capture frame size `subject` is in (the stamp's width and height). */
240
+ subjectFrameSize?: { width: number; height: number } | null;
241
+ /**
242
+ * The aim anchor in the displayed frame. The aimed box sits on it rather
243
+ * than on the frame centre, so it stays on the aim while the view turns
244
+ * onto a moved one.
245
+ */
246
+ aim?: FrameAnchor | null;
207
247
  }
208
248
 
209
249
  /**
210
- * Target brackets for a command camera feed: the aim point sits at frame
211
- * centre, so the box is always centred on the displayed video. Both corners
212
- * show the camera axis by default; an overlay can restyle either and draw
213
- * its own marks. Without a fix, or over a blanked feed, the default corners
214
- * are left out but the overlay and readout still run.
250
+ * Target brackets for a command camera feed. With no command lock held and
251
+ * the subject's anchor visible in the displayed frame, the box sits on the
252
+ * craft where the game saw it, so it stays on the craft when an aim points
253
+ * elsewhere; while the aimed box is in frame and holds the craft it is drawn
254
+ * instead, since it frames the craft's whole extent as the view does. Otherwise (the anchor out of frame or occluded, which then
255
+ * carries no position, no anchor at all, or a command authority holding the
256
+ * lock) the box is the 1.11 one: on the aim (the frame centre, or the aim
257
+ * anchor while the view turns onto a moved aim), sized from the aimed range,
258
+ * red and dashed when the aim line is occluded. `ctx.subject` always
259
+ * carries the anchor, so a consumer can draw it as a second mark. An overlay
260
+ * can restyle either corner and draw its own marks. Without a fix, or over a
261
+ * blanked feed, the default corners are left out but the overlay and readout
262
+ * still run.
215
263
  */
216
264
  export function CommandBrackets({
217
265
  state,
@@ -220,6 +268,9 @@ export function CommandBrackets({
220
268
  video,
221
269
  fallbackSize,
222
270
  overlay,
271
+ subject,
272
+ subjectFrameSize,
273
+ aim,
223
274
  }: CommandBracketsProps) {
224
275
  const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
225
276
  const rect = containedContentRect(
@@ -228,7 +279,36 @@ export function CommandBrackets({
228
279
  mediaW || fallbackSize?.width || 0,
229
280
  mediaH || fallbackSize?.height || 0,
230
281
  );
231
- const g = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH);
282
+ const frameW = subjectFrameSize?.width || mediaW || fallbackSize?.width || 0;
283
+ const frameH = subjectFrameSize?.height || mediaH || fallbackSize?.height || 0;
284
+ /* Under the lock the box is the authority's aim, as in 1.11. */
285
+ const point =
286
+ subject && !state.authority && frameW > 0 && frameH > 0 ? anchorPoint(subject) : null;
287
+ const placed = point !== null;
288
+ const aimPoint = aim && frameW > 0 && frameH > 0 ? anchorPoint(aim) : null;
289
+ const aimScreen = aimPoint ? frameToScreen(aimPoint, frameW, frameH, { ...rect, scale: 1 }) : null;
290
+ const aimOffscreen = !point && !!aimScreen && (!aim?.inFrame || !!aim?.behind);
291
+ const aimed = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH, aimScreen);
292
+ const subjectScreen = point ? frameToScreen(point, frameW, frameH, { ...rect, scale: 1 }) : null;
293
+ /*
294
+ * The aimed box, when it is in frame and holds the subject, frames the
295
+ * craft as the view does: around a craft and its open parachute rather
296
+ * than a sphere about its centre of mass.
297
+ */
298
+ const framed =
299
+ !!subjectScreen &&
300
+ !!aimScreen &&
301
+ !!aim?.inFrame &&
302
+ !aim?.behind &&
303
+ state.bracketHalfPx != null &&
304
+ holds(aimed, subjectScreen);
305
+ const unclamped =
306
+ subjectScreen && !framed
307
+ ? bracketGeometry(rect, subject?.apparentRadiusPx ?? null, frameH, subjectScreen)
308
+ : aimed;
309
+ /* An aim off the frame (mid-slew) keeps the box on the edge nearest it, with a cue pointing on. */
310
+ const g = aimOffscreen ? clampToRect(unclamped, rect) : unclamped;
311
+ const cue = aimOffscreen && aimScreen ? edgeCue(g, aimScreen) : null;
232
312
  const ctx: CommandBracketContext = {
233
313
  geometry: g,
234
314
  width: boxW,
@@ -236,23 +316,32 @@ export function CommandBrackets({
236
316
  displayUt,
237
317
  utSource: utSource ?? null,
238
318
  state,
319
+ subject: subject ?? null,
320
+ aimOffscreen,
239
321
  };
240
322
  const overrides =
241
323
  typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
242
- const camera = cameraCornerStyle(state);
324
+ const camera = cameraCornerStyle(placed ? !!subject?.occluded : state.occluded);
243
325
  const topLeft = applyCorner(camera, overrides?.topLeft);
244
326
  const bottomRight = applyCorner(camera, overrides?.bottomRight);
245
327
  const { x: left, y: top } = g.topLeft;
246
328
  const { x: right, y: bottom } = g.bottomRight;
247
329
  const blanked = !!state.blank;
248
330
  const drawCorners = state.hasFix && !blanked;
249
- const defaultReadout = blanked ? "" : commandReadout(state);
331
+ /* A feed waiting for its target says so itself; NO FIX would only repeat it. */
332
+ const defaultReadout = blanked || state.waitingForTarget ? "" : commandReadout(state);
250
333
  const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
251
334
 
252
335
  return (
253
336
  <>
254
337
  {(drawCorners || overlay?.render) && (
255
- <BracketSvg aria-hidden="true" data-camera-axis={state.camera} width={boxW} height={boxH}>
338
+ <BracketSvg
339
+ aria-hidden="true"
340
+ data-camera-axis={state.camera}
341
+ data-subject={subject ? (placed ? "placed" : "centred") : undefined}
342
+ width={boxW}
343
+ height={boxH}
344
+ >
256
345
  {drawCorners && !topLeft.hidden && (
257
346
  <path
258
347
  data-corner="topLeft"
@@ -273,6 +362,15 @@ export function CommandBrackets({
273
362
  style={{ stroke: bottomRight.colour }}
274
363
  />
275
364
  )}
365
+ {drawCorners && cue && (
366
+ <path
367
+ data-aim-cue=""
368
+ d={cue}
369
+ fill="none"
370
+ strokeWidth={g.stroke}
371
+ style={{ stroke: camera.colour }}
372
+ />
373
+ )}
276
374
  {overlay?.render && <g data-bracket-overlay="">{overlay.render(ctx)}</g>}
277
375
  </BracketSvg>
278
376
  )}
@@ -288,6 +386,25 @@ export function CommandBrackets({
288
386
  );
289
387
  }
290
388
 
389
+ /**
390
+ * CommandBrackets fed with the subject's and the aim's anchors from each frame of
391
+ * `flightId` that `video` presents. Re-renders at the frame rate, which is
392
+ * why it is its own component.
393
+ */
394
+ export function LiveCommandBrackets({
395
+ flightId,
396
+ ...props
397
+ }: CommandBracketsProps & { flightId: number | null }) {
398
+ const frame = useDisplayedFrame(true, flightId, props.video);
399
+ const subject = findAnchor(frame?.anchors, COMMAND_SUBJECT_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
400
+ const aim = findAnchor(frame?.anchors, COMMAND_AIM_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
401
+ const size =
402
+ frame?.frameWidth && frame.frameHeight
403
+ ? { width: frame.frameWidth, height: frame.frameHeight }
404
+ : null;
405
+ return <CommandBrackets {...props} subject={subject} subjectFrameSize={size} aim={aim} />;
406
+ }
407
+
291
408
  /** Reason a command feed is blanked, centred on black over the feed. */
292
409
  export function CommandBlank({ reason }: { reason: string }) {
293
410
  return (
@@ -13,6 +13,7 @@ import { partitionActionBarEntries } from "./FeedActionBar";
13
13
  interface Entry {
14
14
  id: string;
15
15
  stateful?: boolean;
16
+ inline?: boolean;
16
17
  order?: number;
17
18
  pinnedTrailing?: boolean;
18
19
  }
@@ -41,6 +42,27 @@ describe("partitionActionBarEntries", () => {
41
42
  expect(ids(overflow)).toEqual(["quality", "pip", "fullscreen"]);
42
43
  });
43
44
 
45
+ it("keeps an inline entry out of overflow without changing the split", () => {
46
+ const base: Entry[] = [
47
+ { id: "tracking", stateful: true },
48
+ { id: "quality" },
49
+ { id: "pip" },
50
+ { id: "fullscreen" },
51
+ ];
52
+ const pinned = base.map((e) => (e.id === "fullscreen" ? { ...e, inline: true } : e));
53
+ const { primary, overflow } = partitionActionBarEntries(pinned);
54
+ expect(ids(primary)).toEqual(["tracking", "fullscreen"]);
55
+ expect(ids(overflow)).toEqual(["quality", "pip"]);
56
+
57
+ const twoNonStateful: Entry[] = [
58
+ { id: "tracking", stateful: true },
59
+ { id: "rec", stateful: true },
60
+ { id: "pip" },
61
+ { id: "fullscreen", inline: true },
62
+ ];
63
+ expect(ids(partitionActionBarEntries(twoNonStateful).overflow)).toEqual(["pip"]);
64
+ });
65
+
44
66
  it("never collapses a single non-stateful item, even with total >= 4", () => {
45
67
  const entries: Entry[] = [
46
68
  { id: "tracking", stateful: true },
@@ -29,7 +29,7 @@
29
29
  import { Fragment, useId } from "react";
30
30
  import { createPortal } from "react-dom";
31
31
  import styled from "styled-components";
32
- import { usePortalMenu } from "./menuPositioning";
32
+ import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
33
33
 
34
34
  /**
35
35
  * A consumer-supplied action rendered into a feed's action bar. Lets a host
@@ -82,6 +82,7 @@ const OVERFLOW_MIN_NON_STATEFUL = 2;
82
82
 
83
83
  interface Partitionable {
84
84
  stateful?: boolean;
85
+ inline?: boolean;
85
86
  order?: number;
86
87
  pinnedTrailing?: boolean;
87
88
  }
@@ -113,8 +114,8 @@ export function partitionActionBarEntries<T extends Partitionable>(
113
114
  return { primary: overflowable, overflow: [], pinnedTrailing };
114
115
  }
115
116
  return {
116
- primary: overflowable.filter((e) => e.stateful),
117
- overflow: nonStateful,
117
+ primary: overflowable.filter((e) => e.stateful || e.inline),
118
+ overflow: nonStateful.filter((e) => !e.inline),
118
119
  pinnedTrailing,
119
120
  };
120
121
  }
@@ -133,6 +134,12 @@ export interface FeedActionBarEntry {
133
134
  id: string;
134
135
  label: string;
135
136
  stateful?: boolean;
137
+ /**
138
+ * Keeps a non-stateful entry out of the ⋮ menu without changing the split:
139
+ * it still counts as non-stateful toward the overflow threshold, so the
140
+ * rest of the bar lays out exactly as it would without the flag.
141
+ */
142
+ inline?: boolean;
136
143
  order?: number;
137
144
  pinnedTrailing?: boolean;
138
145
  render: () => React.ReactNode;
@@ -244,7 +251,7 @@ export function FeedActionBar({ leading, entries, className }: FeedActionBarProp
244
251
  </OverflowMenuItem>
245
252
  ))}
246
253
  </OverflowMenu>,
247
- document.body,
254
+ menuPortalRoot(),
248
255
  )}
249
256
  {pinnedTrailing.map((e) => (
250
257
  <Fragment key={e.id}>{e.render()}</Fragment>
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
5
5
  * reusable building block a crew bar (kerbcast web) or a mission-control
6
- * surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
6
+ * surface composes: identity-keyed by `flightId`, it binds the live
7
7
  * `MediaStream` to a persistent `<video>` and NEVER remounts that element on
8
8
  * re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
9
9
  * technique `CameraFeed` uses).
@@ -153,6 +153,21 @@ describe("RecordingsTray - single clips", () => {
153
153
  expect(screen.getByText(/unsaved clips/i)).toBeTruthy();
154
154
  });
155
155
 
156
+ it("says why a clip stopped on its own, and nothing for one a person stopped", () => {
157
+ fakeStoreSeed.recordings = [
158
+ makeRecording({ recordingId: "rec-1", endedBy: "camera-gone" }),
159
+ makeRecording({ recordingId: "rec-2", endedBy: "disconnected" }),
160
+ makeRecording({ recordingId: "rec-3", endedBy: "feed-closed" }),
161
+ makeRecording({ recordingId: "rec-4", endedBy: "user" }),
162
+ ];
163
+ render(<RecordingsTray />);
164
+ expect(screen.getAllByText(/stopped:/i).map((el) => el.textContent)).toEqual([
165
+ "Stopped: the camera went away",
166
+ "Stopped: the connection was lost",
167
+ "Stopped: its feed was closed",
168
+ ]);
169
+ });
170
+
156
171
  it("falls back to 'Camera <flightId>' when no cameraLabel prop is given", () => {
157
172
  fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", flightId: 7 })];
158
173
  render(<RecordingsTray />);