@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.
- package/README.md +59 -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 +78 -10
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- 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/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 +223 -6
- package/src/CameraFeed.tsx +145 -14
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- 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/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
|
12
12
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
13
13
|
export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
|
|
14
14
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
15
|
+
export { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
|
|
15
16
|
export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
16
17
|
export { useRecordings } from "./hooks/useRecordings";
|
|
17
18
|
export type { RecordingsStore, ActiveRecordingInfo } from "./hooks/useRecordings";
|
|
@@ -25,9 +26,11 @@ export { CameraFeed } from "./CameraFeed";
|
|
|
25
26
|
export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
|
|
26
27
|
export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
|
|
27
28
|
export type { BracketGeometry, BracketPoint, CommandBracketContext, CommandBracketCorner, CommandBracketCornerOverride, CommandBracketCornerOverrides, CommandBracketCornerStyle, CommandBracketOverlay, ContentRect, } from "./CommandBrackets";
|
|
29
|
+
export { AnchorMarkers, ANCHOR_MARKER_COLOURS, anchorMarkerGeometry, useDisplayedFrame, } from "./AnchorMarkers";
|
|
30
|
+
export type { AnchorContext, AnchorCorner, AnchorCornerOverride, AnchorCornerOverrides, AnchorCornerStyle, AnchorMarkerGeometry, AnchorMarkerShape, AnchorMarkersProps, AnchorOverlay, PlacedAnchor, } from "./AnchorMarkers";
|
|
28
31
|
export { FeedActionBar } from "./FeedActionBar";
|
|
29
32
|
export type { FeedActionBarEntry } from "./FeedActionBar";
|
|
30
33
|
export { KerbalFaceFeed } from "./KerbalFaceFeed";
|
|
31
34
|
export type { KerbalFaceFeedProps, KerbalFeedState } from "./KerbalFaceFeed";
|
|
32
|
-
export { RecordingsTray } from "./RecordingsTray";
|
|
35
|
+
export { RecordingsTray, recordingEndNote } from "./RecordingsTray";
|
|
33
36
|
export type { RecordingsTrayProps } from "./RecordingsTray";
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
|
13
13
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
14
14
|
export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
|
|
15
15
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
16
|
+
export { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
|
|
16
17
|
export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
17
18
|
export { useRecordings } from "./hooks/useRecordings";
|
|
18
19
|
// Shared elapsed-timer helpers (REC badges, RecGroupBar's toolbar)
|
|
@@ -27,6 +28,9 @@ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
|
|
|
27
28
|
export { CameraFeed } from "./CameraFeed";
|
|
28
29
|
// Target brackets geometry and the overlay API for drawing on top of them
|
|
29
30
|
export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
|
|
31
|
+
// Anchored markers: CameraFeed's `anchors` and `anchorOverlay`, and the
|
|
32
|
+
// pieces for drawing a frame's anchors over a video of your own
|
|
33
|
+
export { AnchorMarkers, ANCHOR_MARKER_COLOURS, anchorMarkerGeometry, useDisplayedFrame, } from "./AnchorMarkers";
|
|
30
34
|
// FeedActionBar: the shared action row (tracking, fullscreen, PiP, quality,
|
|
31
35
|
// close, REC) both CameraFeed and KerbalFaceFeed render, with the issue-#6
|
|
32
36
|
// overflow (⋮) engine.
|
|
@@ -34,4 +38,4 @@ export { FeedActionBar } from "./FeedActionBar";
|
|
|
34
38
|
// KerbalFaceFeed — shared single-kerbal-face primitive
|
|
35
39
|
export { KerbalFaceFeed } from "./KerbalFaceFeed";
|
|
36
40
|
// RecordingsTray: a presentational drawer over the recordings store
|
|
37
|
-
export { RecordingsTray } from "./RecordingsTray";
|
|
41
|
+
export { RecordingsTray, recordingEndNote } from "./RecordingsTray";
|
|
@@ -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.1",
|
|
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.1"
|
|
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
|
+
});
|