@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,6 +1,6 @@
1
1
  /**
2
- * Shared geometry + behaviour for a dropdown portaled to document.body.
3
- * Used by CameraFeed's camera / quality / tracking menus and by
2
+ * Shared geometry + behaviour for a portaled dropdown (document.body, or the
3
+ * fullscreen element while one is fullscreen). Used by CameraFeed's camera / quality / tracking menus and by
4
4
  * FeedActionBar's overflow menu, so the anchoring, dismissal, and Escape
5
5
  * handling live in exactly one place.
6
6
  */
@@ -32,8 +32,18 @@ export function computeMenuPosition(trigger, anchor) {
32
32
  const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
33
33
  return { ...inline, top };
34
34
  }
35
+ /**
36
+ * Where a dropdown portals to. Only the fullscreen element and its
37
+ * descendants render while something is fullscreen, so a menu portaled to
38
+ * document.body then would open invisibly behind it.
39
+ */
40
+ export function menuPortalRoot() {
41
+ const d = document;
42
+ return d.fullscreenElement ?? d.webkitFullscreenElement ?? document.body;
43
+ }
35
44
  /*
36
- * Shared behaviour for a dropdown portaled to document.body: fixed position
45
+ * Shared behaviour for a dropdown portaled to document.body (or the
46
+ * fullscreen element, see menuPortalRoot): fixed position
37
47
  * computed from the trigger's rect (so tile overflow cannot clip it),
38
48
  * re-anchored on window resize/scroll while open, Escape to close with focus
39
49
  * returned to the trigger, and portal-aware outside-pointer-down dismissal
@@ -68,24 +78,39 @@ export function usePortalMenu({ maxWidth, maxHeight, align }) {
68
78
  useEffect(() => {
69
79
  if (!open)
70
80
  return;
81
+ /* Capture phase and preventDefault so document-level Escape handlers
82
+ registered earlier (CameraFeed's exit-fullscreen key) see the press
83
+ as already consumed by the menu. */
71
84
  const onKeyDown = (e) => {
72
85
  if (e.key === "Escape") {
86
+ e.preventDefault();
73
87
  e.stopPropagation();
74
88
  setOpen(false);
75
89
  triggerRef.current?.focus();
76
90
  }
77
91
  };
92
+ /* The portal root changes with fullscreen and no resize may fire (kiosk,
93
+ F11), so a menu left open would remount elsewhere with stale
94
+ coordinates. */
95
+ const onFullscreenChange = () => {
96
+ setOpen(false);
97
+ triggerRef.current?.focus();
98
+ };
78
99
  const onPointerDown = (e) => {
79
100
  if (!menuRef.current?.contains(e.target) &&
80
101
  !triggerRef.current?.contains(e.target)) {
81
102
  setOpen(false);
82
103
  }
83
104
  };
84
- document.addEventListener("keydown", onKeyDown);
105
+ document.addEventListener("keydown", onKeyDown, true);
85
106
  document.addEventListener("pointerdown", onPointerDown);
107
+ document.addEventListener("fullscreenchange", onFullscreenChange);
108
+ document.addEventListener("webkitfullscreenchange", onFullscreenChange);
86
109
  return () => {
87
- document.removeEventListener("keydown", onKeyDown);
110
+ document.removeEventListener("keydown", onKeyDown, true);
88
111
  document.removeEventListener("pointerdown", onPointerDown);
112
+ document.removeEventListener("fullscreenchange", onFullscreenChange);
113
+ document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
89
114
  };
90
115
  }, [open]);
91
116
  const toggle = useCallback(() => setOpen((v) => !v), []);
@@ -0,0 +1,3 @@
1
+ import type { Anchor, KerbcastClient } from "@ksp-gonogo/kerbcast";
2
+ /** Add `anchors` to `flightId`'s set until the returned release is called. */
3
+ export declare function claimAnchors(client: KerbcastClient, flightId: number, anchors: readonly Anchor[]): () => void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A client holds one anchor set per camera, so feeds showing the same
3
+ * camera each claim their anchors here and the camera gets them all: the
4
+ * first claim of an id wins, and the set stops at the client's cap.
5
+ */
6
+ const claims = new WeakMap();
7
+ /** Add `anchors` to `flightId`'s set until the returned release is called. */
8
+ export function claimAnchors(client, flightId, anchors) {
9
+ let byFlight = claims.get(client);
10
+ if (!byFlight) {
11
+ byFlight = new Map();
12
+ claims.set(client, byFlight);
13
+ }
14
+ let feeds = byFlight.get(flightId);
15
+ if (!feeds) {
16
+ feeds = new Map();
17
+ byFlight.set(flightId, feeds);
18
+ }
19
+ const token = Symbol("anchors");
20
+ feeds.set(token, anchors);
21
+ publish(client, flightId, feeds);
22
+ return () => {
23
+ if (!feeds.delete(token))
24
+ return;
25
+ if (feeds.size === 0)
26
+ byFlight.delete(flightId);
27
+ publish(client, flightId, feeds);
28
+ };
29
+ }
30
+ function publish(client, flightId, feeds) {
31
+ const ids = new Map();
32
+ const merged = [];
33
+ let dropped = 0;
34
+ for (const list of feeds.values()) {
35
+ for (const a of list) {
36
+ const seen = ids.get(a.id);
37
+ if (seen !== undefined) {
38
+ if (seen !== JSON.stringify(a)) {
39
+ console.warn(`[kerbcast] two feeds on camera ${flightId} set different anchors with id ${a.id}; the first is kept`);
40
+ }
41
+ continue;
42
+ }
43
+ if (merged.length >= client.maxAnchorsPerCamera) {
44
+ dropped++;
45
+ continue;
46
+ }
47
+ ids.set(a.id, JSON.stringify(a));
48
+ merged.push(a);
49
+ }
50
+ }
51
+ if (dropped > 0) {
52
+ console.warn(`[kerbcast] ${dropped} anchors over the ${client.maxAnchorsPerCamera} per camera left out`);
53
+ }
54
+ if (merged.length === 0) {
55
+ void client.setAnchors(flightId, null).catch(() => { });
56
+ return;
57
+ }
58
+ client.setAnchors(flightId, merged).catch((err) => {
59
+ console.warn("[kerbcast] anchors refused", err);
60
+ });
61
+ }
@@ -0,0 +1,10 @@
1
+ export interface VideoBox {
2
+ boxW: number;
3
+ boxH: number;
4
+ mediaW: number;
5
+ mediaH: number;
6
+ }
7
+ /** Tracks the rendered box and intrinsic size of a video element. */
8
+ export declare function useVideoBox(video: HTMLVideoElement | null): VideoBox;
9
+ /** The element's CSS `object-fit`, as the two fits a feed uses. */
10
+ export declare function videoFit(video: HTMLVideoElement | null): "contain" | "cover";
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from "react";
2
+ /** Tracks the rendered box and intrinsic size of a video element. */
3
+ export function useVideoBox(video) {
4
+ const [box, setBox] = useState({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
5
+ useEffect(() => {
6
+ if (!video)
7
+ return;
8
+ const measure = () => {
9
+ const next = {
10
+ boxW: video.clientWidth,
11
+ boxH: video.clientHeight,
12
+ mediaW: video.videoWidth,
13
+ mediaH: video.videoHeight,
14
+ };
15
+ setBox((prev) => prev.boxW === next.boxW &&
16
+ prev.boxH === next.boxH &&
17
+ prev.mediaW === next.mediaW &&
18
+ prev.mediaH === next.mediaH
19
+ ? prev
20
+ : next);
21
+ };
22
+ measure();
23
+ const observer = new ResizeObserver(measure);
24
+ observer.observe(video);
25
+ video.addEventListener("loadedmetadata", measure);
26
+ video.addEventListener("resize", measure);
27
+ return () => {
28
+ observer.disconnect();
29
+ video.removeEventListener("loadedmetadata", measure);
30
+ video.removeEventListener("resize", measure);
31
+ };
32
+ }, [video]);
33
+ return box;
34
+ }
35
+ /** The element's CSS `object-fit`, as the two fits a feed uses. */
36
+ export function videoFit(video) {
37
+ if (!video || typeof getComputedStyle !== "function")
38
+ return "contain";
39
+ return getComputedStyle(video).objectFit === "cover" ? "cover" : "contain";
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast-react",
3
- "version": "1.11.1",
3
+ "version": "1.12.0-rc.2",
4
4
  "description": "React components and hooks for kerbcast camera feeds.",
5
5
  "license": "MIT",
6
6
  "author": "Jon Pepler",
@@ -45,7 +45,7 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
- "@ksp-gonogo/kerbcast": "^1.11.1"
48
+ "@ksp-gonogo/kerbcast": "^1.12.0-rc.2"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",
@@ -0,0 +1,173 @@
1
+ import type { DisplayedFrameUt, FrameAnchor } from "@ksp-gonogo/kerbcast";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import {
5
+ ANCHOR_MARKER_COLOURS,
6
+ AnchorMarkers,
7
+ anchorMarkerGeometry,
8
+ type AnchorContext,
9
+ type AnchorOverlay,
10
+ } from "./AnchorMarkers";
11
+
12
+ afterEach(cleanup);
13
+
14
+ function sizedVideo(boxW: number, boxH: number, fit?: "cover"): HTMLVideoElement {
15
+ const v = document.createElement("video");
16
+ Object.defineProperty(v, "clientWidth", { value: boxW });
17
+ Object.defineProperty(v, "clientHeight", { value: boxH });
18
+ Object.defineProperty(v, "videoWidth", { value: 1280 });
19
+ Object.defineProperty(v, "videoHeight", { value: 720 });
20
+ if (fit) v.style.objectFit = fit;
21
+ return v;
22
+ }
23
+
24
+ function anchor(overrides: Partial<FrameAnchor> = {}): FrameAnchor {
25
+ return {
26
+ id: "port",
27
+ x: 640,
28
+ y: 360,
29
+ inFrame: true,
30
+ behind: false,
31
+ distanceM: 30,
32
+ apparentRadiusPx: 40,
33
+ angleOffAxis: 0.5,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function frame(anchors: FrameAnchor[]): DisplayedFrameUt {
39
+ return {
40
+ ut: 5000,
41
+ epoch: 1,
42
+ source: "exact",
43
+ flightId: 7,
44
+ rtpTimestamp: 90_000,
45
+ presentedAt: 0,
46
+ anchors,
47
+ fov: 30,
48
+ frameWidth: 1280,
49
+ frameHeight: 720,
50
+ };
51
+ }
52
+
53
+ function draw(anchors: FrameAnchor[], overlay?: AnchorOverlay, video = sizedVideo(640, 360)) {
54
+ return render(<AnchorMarkers frame={frame(anchors)} video={video} overlay={overlay} />);
55
+ }
56
+
57
+ const marks = (c: HTMLElement) => [...c.querySelectorAll("path[data-corner]")] as SVGPathElement[];
58
+
59
+ describe("anchorMarkerGeometry", () => {
60
+ it("sizes from the apparent radius on the displayed frame, never below the minimum", () => {
61
+ const content = { x: 0, y: 0, width: 640, height: 360, scale: 0.5 };
62
+ expect(anchorMarkerGeometry({ x: 1, y: 2 }, 40, 720, content)).toMatchObject({ cx: 1, cy: 2, half: 20 });
63
+ expect(anchorMarkerGeometry({ x: 0, y: 0 }, 2, 720, content).half).toBe(14);
64
+ });
65
+ });
66
+
67
+ describe("AnchorMarkers", () => {
68
+ it("draws four solid corner marks around this client's anchor", () => {
69
+ const { container } = draw([anchor()]);
70
+ const paths = marks(container);
71
+ expect(paths.map((p) => p.getAttribute("data-corner"))).toEqual(["top", "right", "bottom", "left"]);
72
+ expect(paths.every((p) => p.getAttribute("data-shape") === "corners")).toBe(true);
73
+ expect(paths.every((p) => p.getAttribute("stroke-dasharray") === null)).toBe(true);
74
+ expect(paths[0].style.stroke).toBe(ANCHOR_MARKER_COLOURS.clear);
75
+ /* Centre (320, 180) on a half-size display, half 20: the top-left corner starts at (300, 160). */
76
+ expect(paths[0].getAttribute("d")).toContain("L300 160");
77
+ });
78
+
79
+ it("dashes an occluded anchor in the occluded colour", () => {
80
+ const { container } = draw([anchor({ occluded: true })]);
81
+ expect(marks(container).every((p) => p.getAttribute("stroke-dasharray") === "3 2")).toBe(true);
82
+ expect(marks(container)[0].style.stroke).toBe(ANCHOR_MARKER_COLOURS.occluded);
83
+ });
84
+
85
+ it("draws a direction reticle: triangles coloured per side by the consumer", () => {
86
+ const seen: AnchorContext[] = [];
87
+ const { container } = draw([anchor({ forward: [0, 0.1, -1], up: [0, 1, 0] })], {
88
+ shape: "triangles",
89
+ corners: (a, ctx) => {
90
+ seen.push(ctx);
91
+ const tilted = (a.forward?.[1] ?? 0) > 0.05;
92
+ return { top: { colour: tilted ? "red" : "lime" }, bottom: { colour: tilted ? "red" : "lime" } };
93
+ },
94
+ });
95
+ const paths = marks(container);
96
+ expect(paths.every((p) => p.getAttribute("data-shape") === "triangles")).toBe(true);
97
+ expect(paths[0].style.stroke).toBe("red");
98
+ expect(paths[0].style.fill).toBe("red");
99
+ expect(paths[1].style.stroke).toBe(ANCHOR_MARKER_COLOURS.clear);
100
+ expect(seen[0].geometry.cx).toBe(320);
101
+ expect(seen[0].displayUt).toBe(5000);
102
+ expect(seen[0].frame.fov).toBe(30);
103
+ });
104
+
105
+ it("hides a mark and switches the shape of one", () => {
106
+ const { container } = draw([anchor()], {
107
+ corners: { right: { hidden: true }, left: { shape: "triangles" } },
108
+ });
109
+ const paths = marks(container);
110
+ expect(paths.map((p) => p.getAttribute("data-corner"))).toEqual(["top", "bottom", "left"]);
111
+ expect(paths[2].getAttribute("data-shape")).toBe("triangles");
112
+ });
113
+
114
+ it("runs render for an anchor out of frame but leaves its marks out", () => {
115
+ const { container } = draw([anchor({ inFrame: false, behind: true, x: -500 })], {
116
+ render: (a, ctx) => <circle data-testid="edge" cx={Math.max(0, ctx.geometry.cx)} r={2} data-id={a.id} />,
117
+ });
118
+ expect(marks(container)).toHaveLength(0);
119
+ expect(screen.getByTestId("edge").getAttribute("data-id")).toBe("port");
120
+ });
121
+
122
+ it("draws only this client's anchors unless an owner is listed", () => {
123
+ const game = anchor({ id: "reticle", owner: "mission-control" });
124
+ const subject = anchor({ id: "subject", owner: "kerbcast" });
125
+ const mine = draw([anchor(), game, subject]);
126
+ expect(mine.container.querySelectorAll("g[data-anchor]")).toHaveLength(1);
127
+ cleanup();
128
+ const withGame = draw([anchor(), game, subject], { owners: ["mission-control"] });
129
+ const ids = [...withGame.container.querySelectorAll("g[data-anchor]")].map((g) => g.getAttribute("data-anchor"));
130
+ expect(ids).toEqual(["port", "reticle"]);
131
+ });
132
+
133
+ it("leaves Kerbcast's aim anchor out unless asked for", () => {
134
+ const subject = anchor({ id: "subject", owner: "kerbcast" });
135
+ const aim = anchor({ id: "aim", owner: "kerbcast" });
136
+ const plain = draw([subject, aim], { owners: ["kerbcast"] });
137
+ const ids = (c: HTMLElement) => [...c.querySelectorAll("g[data-anchor]")].map((g) => g.getAttribute("data-anchor"));
138
+ expect(ids(plain.container)).toEqual(["subject"]);
139
+ cleanup();
140
+ const asked = draw([subject, aim], { owners: ["kerbcast"], showAim: true });
141
+ expect(ids(asked.container)).toEqual(["subject", "aim"]);
142
+ });
143
+
144
+ it("puts the consumer's readout under the marker", () => {
145
+ draw([anchor()], { readout: (a) => `${a.distanceM} m` });
146
+ const el = screen.getByText("30 m");
147
+ expect(el.style.top).toBe(`${180 + 20 + 11}px`);
148
+ });
149
+
150
+ it("maps positions under object-fit cover", () => {
151
+ /* 1280x720 cropped into 400x400: scale 400/720, 311 px cut off each side. */
152
+ const { container } = draw([anchor({ x: 0, y: 360, apparentRadiusPx: 0 })], {}, sizedVideo(400, 400, "cover"));
153
+ const top = marks(container)[0].getAttribute("d")!;
154
+ const cx = -(1280 * (400 / 720) - 400) / 2;
155
+ expect(top.startsWith(`M${cx - 14} `)).toBe(true);
156
+ });
157
+
158
+ it("draws no marker for an observed anchor out of view, which carries no position", () => {
159
+ const hidden: FrameAnchor = { id: "port", observed: true, inFrame: true, occluded: true };
160
+ let rendered = 0;
161
+ const { container } = draw([hidden, anchor({ id: "seen", observed: true, occluded: false })], {
162
+ render: () => (rendered++, null),
163
+ });
164
+ const ids = [...container.querySelectorAll("g[data-anchor]")].map((g) => g.getAttribute("data-anchor"));
165
+ expect(ids).toEqual(["seen"]);
166
+ expect(rendered).toBe(1);
167
+ });
168
+
169
+ it("draws nothing for a frame without anchors", () => {
170
+ const { container } = render(<AnchorMarkers frame={null} video={sizedVideo(640, 360)} />);
171
+ expect(container.innerHTML).toBe("");
172
+ });
173
+ });