@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.
- package/README.md +71 -0
- package/dist/AnchorMarkers.d.ts +128 -0
- package/dist/AnchorMarkers.js +162 -0
- package/dist/CameraFeed.d.ts +45 -4
- package/dist/CameraFeed.js +163 -23
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- package/dist/FeedActionBar.d.ts +7 -0
- package/dist/FeedActionBar.js +4 -4
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +1 -1
- package/dist/RecordingsTray.d.ts +6 -0
- package/dist/RecordingsTray.js +17 -1
- package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
- package/dist/hooks/useCommandDisplayUt.js +57 -16
- package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
- package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
- package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
- package/dist/hooks/useTargetBracketsPreference.js +18 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -1
- package/dist/menuPositioning.d.ts +8 -2
- package/dist/menuPositioning.js +30 -5
- package/dist/sharedAnchors.d.ts +3 -0
- package/dist/sharedAnchors.js +61 -0
- package/dist/videoBox.d.ts +10 -0
- package/dist/videoBox.js +40 -0
- package/package.json +2 -2
- package/src/AnchorMarkers.test.tsx +173 -0
- package/src/AnchorMarkers.tsx +341 -0
- package/src/CameraFeed.test.tsx +687 -6
- package/src/CameraFeed.tsx +240 -27
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- package/src/FeedActionBar.test.ts +22 -0
- package/src/FeedActionBar.tsx +11 -4
- package/src/KerbalFaceFeed.tsx +1 -1
- package/src/RecordingsTray.test.tsx +15 -0
- package/src/RecordingsTray.tsx +25 -1
- package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
- package/src/hooks/useCommandDisplayUt.ts +61 -15
- package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
- package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
- package/src/hooks/useRecordings.test.tsx +6 -3
- package/src/hooks/useTargetBracketsPreference.ts +15 -5
- package/src/index.ts +23 -1
- package/src/menuPositioning.ts +31 -5
- package/src/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
package/dist/menuPositioning.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared geometry + behaviour for a
|
|
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
|
|
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,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";
|
package/dist/videoBox.js
ADDED
|
@@ -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.
|
|
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.
|
|
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
|
+
});
|