@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
|
@@ -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
|
+
`;
|