@ksp-gonogo/kerbcast-react 1.10.2 → 1.11.0-rc.10
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 +19 -4
- package/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +172 -33
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- package/dist/KerbalFaceFeed.js +2 -2
- package/dist/hooks/useCommandDisplayUt.d.ts +22 -0
- package/dist/hooks/useCommandDisplayUt.js +104 -0
- package/dist/hooks/useDisplayedFrameUt.d.ts +11 -0
- package/dist/hooks/useDisplayedFrameUt.js +22 -0
- package/dist/hooks/useReportDisplaySize.d.ts +49 -3
- package/dist/hooks/useReportDisplaySize.js +187 -21
- package/dist/hooks/useTargetBracketsPreference.d.ts +6 -0
- package/dist/hooks/useTargetBracketsPreference.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +538 -8
- package/src/CameraFeed.tsx +520 -242
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- package/src/KerbalFaceFeed.tsx +2 -2
- package/src/hooks/useCommandDisplayUt.test.tsx +156 -0
- package/src/hooks/useCommandDisplayUt.ts +135 -0
- package/src/hooks/useDisplayedFrameUt.ts +26 -0
- package/src/hooks/useReportDisplaySize.loop.test.tsx +290 -0
- package/src/hooks/useReportDisplaySize.test.tsx +18 -9
- package/src/hooks/useReportDisplaySize.ts +232 -23
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { effectiveCommandTrack, type CommandTrackState } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
CommandBlank,
|
|
6
|
+
CommandBrackets,
|
|
7
|
+
bracketGeometry,
|
|
8
|
+
containedContentRect,
|
|
9
|
+
type CommandBracketContext,
|
|
10
|
+
type CommandBracketOverlay,
|
|
11
|
+
} from "./CommandBrackets";
|
|
12
|
+
import {
|
|
13
|
+
MAX_UT_EXTRAPOLATION_MS,
|
|
14
|
+
extrapolateCaptureUt,
|
|
15
|
+
msUntilNextEvent,
|
|
16
|
+
} from "./hooks/useCommandDisplayUt";
|
|
17
|
+
|
|
18
|
+
afterEach(cleanup);
|
|
19
|
+
|
|
20
|
+
function track(overrides: Partial<CommandTrackState> = {}): CommandTrackState {
|
|
21
|
+
return {
|
|
22
|
+
ut: 1000,
|
|
23
|
+
hasFix: true,
|
|
24
|
+
rangeM: 4200,
|
|
25
|
+
occluded: false,
|
|
26
|
+
occluder: "",
|
|
27
|
+
sunlit: true,
|
|
28
|
+
bracketHalfPx: 20,
|
|
29
|
+
frameHeightPx: 576,
|
|
30
|
+
framingFits: true,
|
|
31
|
+
events: [],
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const AUTHORITY = { owner: "gonogo" };
|
|
37
|
+
|
|
38
|
+
describe("containedContentRect", () => {
|
|
39
|
+
it("letterboxes wide media in a tall box", () => {
|
|
40
|
+
expect(containedContentRect(400, 400, 1600, 900)).toEqual({
|
|
41
|
+
x: 0,
|
|
42
|
+
y: 87.5,
|
|
43
|
+
width: 400,
|
|
44
|
+
height: 225,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("pillarboxes tall media in a wide box", () => {
|
|
49
|
+
expect(containedContentRect(800, 300, 400, 400)).toEqual({
|
|
50
|
+
x: 250,
|
|
51
|
+
y: 0,
|
|
52
|
+
width: 300,
|
|
53
|
+
height: 300,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("uses the whole box while the media size is unknown", () => {
|
|
58
|
+
expect(containedContentRect(640, 360, 0, 0)).toEqual({ x: 0, y: 0, width: 640, height: 360 });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("bracketGeometry", () => {
|
|
63
|
+
const rect = { x: 10, y: 20, width: 1024, height: 288 };
|
|
64
|
+
|
|
65
|
+
it("scales capture px by the displayed height and centres on the frame", () => {
|
|
66
|
+
const g = bracketGeometry(rect, 40, 576);
|
|
67
|
+
expect(g.cx).toBe(522);
|
|
68
|
+
expect(g.cy).toBe(164);
|
|
69
|
+
expect(g.half).toBe(20);
|
|
70
|
+
expect(g.arm).toBeCloseTo(12);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("never draws a box smaller than 14 display px half-size", () => {
|
|
74
|
+
const g = bracketGeometry(rect, 2, 576);
|
|
75
|
+
expect(g.half).toBe(14);
|
|
76
|
+
expect(g.arm).toBeCloseTo(8.4);
|
|
77
|
+
expect(bracketGeometry(rect, null, 576).half).toBe(14);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reports both corners around the centre", () => {
|
|
81
|
+
const g = bracketGeometry(rect, 40, 576);
|
|
82
|
+
expect(g.topLeft).toEqual({ x: 502, y: 144 });
|
|
83
|
+
expect(g.bottomRight).toEqual({ x: 542, y: 184 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("extrapolateCaptureUt", () => {
|
|
88
|
+
it("advances at the warp rate and stops at the cap", () => {
|
|
89
|
+
expect(extrapolateCaptureUt(100, 4, 1000, 1500)).toBe(102);
|
|
90
|
+
expect(extrapolateCaptureUt(100, 1, 0, MAX_UT_EXTRAPOLATION_MS * 10)).toBe(
|
|
91
|
+
100 + MAX_UT_EXTRAPOLATION_MS / 1000,
|
|
92
|
+
);
|
|
93
|
+
expect(extrapolateCaptureUt(100, 1, 1000, 500)).toBe(100);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("msUntilNextEvent", () => {
|
|
98
|
+
const ev = (ut: number) => ({ ut, occluded: false, occluder: "", sunlit: true });
|
|
99
|
+
it("times the first event after the displayed UT at the warp rate", () => {
|
|
100
|
+
expect(msUntilNextEvent([ev(99), ev(103), ev(101)], 100, 2)).toBe(500);
|
|
101
|
+
expect(msUntilNextEvent([ev(99)], 100, 1)).toBeNull();
|
|
102
|
+
expect(msUntilNextEvent([], 100, 1)).toBeNull();
|
|
103
|
+
expect(msUntilNextEvent([ev(101)], 100, 0)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function renderBrackets(
|
|
108
|
+
t: CommandTrackState,
|
|
109
|
+
displayUt = 1000,
|
|
110
|
+
overlay?: CommandBracketOverlay,
|
|
111
|
+
) {
|
|
112
|
+
return render(
|
|
113
|
+
<CommandBrackets
|
|
114
|
+
state={effectiveCommandTrack(t, displayUt)}
|
|
115
|
+
displayUt={displayUt}
|
|
116
|
+
utSource="override"
|
|
117
|
+
video={null}
|
|
118
|
+
fallbackSize={{ width: 1024, height: 576 }}
|
|
119
|
+
overlay={overlay}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function corner(container: HTMLElement, which: "topLeft" | "bottomRight") {
|
|
125
|
+
return container.querySelector(`path[data-corner="${which}"]`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("CommandBrackets", () => {
|
|
129
|
+
it("draws only the two corners, solid, when in view", () => {
|
|
130
|
+
const { container } = renderBrackets(track());
|
|
131
|
+
expect(container.querySelectorAll("path")).toHaveLength(2);
|
|
132
|
+
expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
133
|
+
expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
134
|
+
expect(screen.getByText("IN VIEW · 4.2 km")).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("dashes both corners when occluded", () => {
|
|
138
|
+
const { container } = renderBrackets(track({ occluded: true, occluder: "Mun" }));
|
|
139
|
+
expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
140
|
+
expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
141
|
+
expect(screen.getByText("OCCLUDED MUN · 4.2 km")).toBeTruthy();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("mirrors the camera corner under an authority too", () => {
|
|
145
|
+
const { container } = renderBrackets(
|
|
146
|
+
track({ occluded: true, occluder: "Mun", authority: AUTHORITY }),
|
|
147
|
+
);
|
|
148
|
+
expect(corner(container, "bottomRight")!.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
149
|
+
expect(screen.getByText("OCCLUDED MUN · 4.2 km")).toBeTruthy();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("draws no box without a fix, only the readout", () => {
|
|
153
|
+
const { container } = renderBrackets(track({ hasFix: false, rangeM: undefined }));
|
|
154
|
+
expect(container.querySelector("svg")).toBeNull();
|
|
155
|
+
expect(screen.getByText("NO FIX")).toBeTruthy();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("CommandBrackets overlay", () => {
|
|
160
|
+
it("still runs the overlay and readout without a fix, with no default corners", () => {
|
|
161
|
+
const { container } = renderBrackets(track({ hasFix: false, rangeM: undefined }), 1000, {
|
|
162
|
+
render: (ctx) => (ctx.state.hasFix ? null : <circle data-testid="held" r={3} />),
|
|
163
|
+
readout: (text) => `${text} · HELD`,
|
|
164
|
+
});
|
|
165
|
+
expect(container.querySelectorAll("path")).toHaveLength(0);
|
|
166
|
+
expect(screen.getByTestId("held").closest("svg")).toBe(container.querySelector("svg"));
|
|
167
|
+
expect(screen.getByText("NO FIX · HELD")).toBeTruthy();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("runs over a blanked feed with an empty default readout", () => {
|
|
171
|
+
let readoutDefault: string | null = null;
|
|
172
|
+
const { container } = renderBrackets(track({ blank: "Lost" }), 1000, {
|
|
173
|
+
render: (ctx) => (ctx.state.blank ? <circle data-testid="held" r={3} /> : null),
|
|
174
|
+
readout: (text) => {
|
|
175
|
+
readoutDefault = text;
|
|
176
|
+
return "HELD 2m";
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
expect(readoutDefault).toBe("");
|
|
180
|
+
expect(container.querySelectorAll("path")).toHaveLength(0);
|
|
181
|
+
expect(screen.getByTestId("held")).toBeTruthy();
|
|
182
|
+
expect(screen.getByText("HELD 2m")).toBeTruthy();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("draws nothing over a blanked feed without an overlay", () => {
|
|
186
|
+
const { container } = renderBrackets(track({ blank: "Lost" }));
|
|
187
|
+
expect(container.querySelector("svg")).toBeNull();
|
|
188
|
+
expect(container.textContent).toBe("");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("renders inside the bracket SVG with the geometry, UT and state", () => {
|
|
192
|
+
let seen: CommandBracketContext | null = null;
|
|
193
|
+
const { container } = renderBrackets(track({ authority: AUTHORITY }), 1234, {
|
|
194
|
+
render: (ctx) => {
|
|
195
|
+
seen = ctx;
|
|
196
|
+
const { x, y } = ctx.geometry.bottomRight;
|
|
197
|
+
return <rect data-testid="mark" x={x - 6} y={y - 6} width={4} height={4} />;
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
const ctx = seen as CommandBracketContext | null;
|
|
201
|
+
expect(ctx).not.toBeNull();
|
|
202
|
+
expect(ctx!.displayUt).toBe(1234);
|
|
203
|
+
expect(ctx!.utSource).toBe("override");
|
|
204
|
+
expect(ctx!.state.authority).toEqual(AUTHORITY);
|
|
205
|
+
expect(ctx!.geometry.stroke).toBe(2);
|
|
206
|
+
expect(ctx!.geometry.bottomRight.x).toBe(ctx!.geometry.cx + ctx!.geometry.half);
|
|
207
|
+
const path = corner(container, "bottomRight")!.getAttribute("d")!;
|
|
208
|
+
const { x, y } = ctx!.geometry.bottomRight;
|
|
209
|
+
expect(path).toContain(`L${x} ${y}`);
|
|
210
|
+
const mark = screen.getByTestId("mark");
|
|
211
|
+
expect(mark.closest("svg")).toBe(container.querySelector("svg"));
|
|
212
|
+
expect(mark.getAttribute("x")).toBe(String(x - 6));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("applies static corner overrides", () => {
|
|
216
|
+
const { container } = renderBrackets(track(), 1000, {
|
|
217
|
+
corners: { bottomRight: { colour: "rgb(1, 2, 3)", dashed: true } },
|
|
218
|
+
});
|
|
219
|
+
const br = corner(container, "bottomRight") as SVGPathElement;
|
|
220
|
+
expect(br.getAttribute("stroke-dasharray")).toBe("3 2");
|
|
221
|
+
expect(br.style.stroke).toBe("rgb(1, 2, 3)");
|
|
222
|
+
expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("hides a corner and takes overrides from a function of the context", () => {
|
|
226
|
+
const { container } = renderBrackets(track({ occluded: true, occluder: "Mun" }), 1000, {
|
|
227
|
+
corners: (ctx) => ({
|
|
228
|
+
bottomRight: { hidden: true },
|
|
229
|
+
topLeft: { dashed: !ctx.state.occluded },
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
expect(corner(container, "bottomRight")).toBeNull();
|
|
233
|
+
expect(corner(container, "topLeft")!.getAttribute("stroke-dasharray")).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("extends the readout with the displayed UT", () => {
|
|
237
|
+
renderBrackets(track(), 1060, {
|
|
238
|
+
readout: (text, ctx) => `${text} · AGE ${ctx.displayUt - 1000}s`,
|
|
239
|
+
});
|
|
240
|
+
expect(screen.getByText("IN VIEW · 4.2 km · AGE 60s")).toBeTruthy();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("replaces the readout, or hides it with null", () => {
|
|
244
|
+
renderBrackets(track(), 1000, { readout: () => "CUSTOM" });
|
|
245
|
+
expect(screen.getByText("CUSTOM")).toBeTruthy();
|
|
246
|
+
cleanup();
|
|
247
|
+
renderBrackets(track(), 1000, { readout: () => null });
|
|
248
|
+
expect(screen.queryByText(/IN VIEW/)).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("CommandBlank", () => {
|
|
253
|
+
it("labels the blank with its reason", () => {
|
|
254
|
+
render(<CommandBlank reason="Waiting for gonogo" />);
|
|
255
|
+
expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
|
|
256
|
+
"Waiting for gonogo",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
commandReadout,
|
|
3
|
+
type EffectiveCommandTrack,
|
|
4
|
+
type FrameUtSource,
|
|
5
|
+
} from "@ksp-gonogo/kerbcast";
|
|
6
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
7
|
+
import styled from "styled-components";
|
|
8
|
+
|
|
9
|
+
const BRACKET_STROKE_PX = 2;
|
|
10
|
+
const BRACKET_MIN_HALF_PX = 14;
|
|
11
|
+
const BRACKET_ARM_FRACTION = 0.3;
|
|
12
|
+
const BRACKET_MIN_ARM_PX = 6;
|
|
13
|
+
const READOUT_GAP_PX = 11;
|
|
14
|
+
|
|
15
|
+
/** Default colours; each is overridable through the named CSS custom property. */
|
|
16
|
+
export const COMMAND_BRACKET_COLOURS = {
|
|
17
|
+
clear: "var(--kerbcast-bracket-clear, #4caf50)",
|
|
18
|
+
occluded: "var(--kerbcast-bracket-occluded, #f44336)",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export interface ContentRect {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where `object-fit: contain` media actually lands inside its box. Falls back
|
|
30
|
+
* to the whole box while the media size is unknown.
|
|
31
|
+
*/
|
|
32
|
+
export function containedContentRect(
|
|
33
|
+
boxWidth: number,
|
|
34
|
+
boxHeight: number,
|
|
35
|
+
mediaWidth: number,
|
|
36
|
+
mediaHeight: number,
|
|
37
|
+
): ContentRect {
|
|
38
|
+
if (mediaWidth <= 0 || mediaHeight <= 0 || boxWidth <= 0 || boxHeight <= 0) {
|
|
39
|
+
return { x: 0, y: 0, width: Math.max(0, boxWidth), height: Math.max(0, boxHeight) };
|
|
40
|
+
}
|
|
41
|
+
const scale = Math.min(boxWidth / mediaWidth, boxHeight / mediaHeight);
|
|
42
|
+
const width = mediaWidth * scale;
|
|
43
|
+
const height = mediaHeight * scale;
|
|
44
|
+
return { x: (boxWidth - width) / 2, y: (boxHeight - height) / 2, width, height };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BracketPoint {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
|
|
53
|
+
export interface BracketGeometry {
|
|
54
|
+
/** Box centre, which is the aim point. */
|
|
55
|
+
cx: number;
|
|
56
|
+
cy: number;
|
|
57
|
+
/** Half the box side in display px. */
|
|
58
|
+
half: number;
|
|
59
|
+
/** Length of each corner stroke. */
|
|
60
|
+
arm: number;
|
|
61
|
+
stroke: number;
|
|
62
|
+
/** The box's top-left and bottom-right corner points. */
|
|
63
|
+
topLeft: BracketPoint;
|
|
64
|
+
bottomRight: BracketPoint;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
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.
|
|
71
|
+
*/
|
|
72
|
+
export function bracketGeometry(
|
|
73
|
+
rect: ContentRect,
|
|
74
|
+
bracketHalfPx: number | null,
|
|
75
|
+
frameHeightPx: number,
|
|
76
|
+
): BracketGeometry {
|
|
77
|
+
const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
|
|
78
|
+
const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
|
|
79
|
+
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;
|
|
82
|
+
return {
|
|
83
|
+
cx,
|
|
84
|
+
cy,
|
|
85
|
+
half,
|
|
86
|
+
arm,
|
|
87
|
+
stroke: BRACKET_STROKE_PX,
|
|
88
|
+
topLeft: { x: cx - half, y: cy - half },
|
|
89
|
+
bottomRight: { x: cx + half, y: cy + half },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type CommandBracketCorner = "topLeft" | "bottomRight";
|
|
94
|
+
|
|
95
|
+
export interface CommandBracketCornerStyle {
|
|
96
|
+
colour: string;
|
|
97
|
+
dashed: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Changes to one default corner; unset fields keep the default. */
|
|
101
|
+
export interface CommandBracketCornerOverride {
|
|
102
|
+
colour?: string;
|
|
103
|
+
dashed?: boolean;
|
|
104
|
+
hidden?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type CommandBracketCornerOverrides = Partial<
|
|
108
|
+
Record<CommandBracketCorner, CommandBracketCornerOverride>
|
|
109
|
+
>;
|
|
110
|
+
|
|
111
|
+
/** Everything a bracket overlay needs to line up with the drawn brackets. */
|
|
112
|
+
export interface CommandBracketContext {
|
|
113
|
+
geometry: BracketGeometry;
|
|
114
|
+
/** Size of the bracket SVG, the video element's box. */
|
|
115
|
+
width: number;
|
|
116
|
+
height: number;
|
|
117
|
+
/** Frame UT the brackets describe. */
|
|
118
|
+
displayUt: number;
|
|
119
|
+
utSource: FrameUtSource | "override" | null;
|
|
120
|
+
state: EffectiveCommandTrack;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Consumer additions to a command feed's brackets. Each part is optional and
|
|
125
|
+
* is called on every render with the context for the displayed frame.
|
|
126
|
+
*/
|
|
127
|
+
export interface CommandBracketOverlay {
|
|
128
|
+
/**
|
|
129
|
+
* SVG content drawn inside the bracket SVG above the default corners, in
|
|
130
|
+
* the same pixel space. Runs in every state, with or without a fix and
|
|
131
|
+
* over a blanked feed; `ctx.state` says which.
|
|
132
|
+
*/
|
|
133
|
+
render?: (ctx: CommandBracketContext) => ReactNode;
|
|
134
|
+
/** Restyle or hide the default corners. */
|
|
135
|
+
corners?:
|
|
136
|
+
| CommandBracketCornerOverrides
|
|
137
|
+
| ((ctx: CommandBracketContext) => CommandBracketCornerOverrides | null | undefined);
|
|
138
|
+
/**
|
|
139
|
+
* The readout text under the box. Receives the default text, which is
|
|
140
|
+
* empty while the feed is blanked; return a replacement (append to keep
|
|
141
|
+
* it), or null to hide the readout. Runs in every state.
|
|
142
|
+
*/
|
|
143
|
+
readout?: (defaultText: string, ctx: CommandBracketContext) => string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cameraCornerStyle(state: EffectiveCommandTrack): CommandBracketCornerStyle {
|
|
147
|
+
return state.occluded
|
|
148
|
+
? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
|
|
149
|
+
: { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function applyCorner(
|
|
153
|
+
base: CommandBracketCornerStyle,
|
|
154
|
+
override: CommandBracketCornerOverride | undefined,
|
|
155
|
+
): CommandBracketCornerStyle & { hidden: boolean } {
|
|
156
|
+
return {
|
|
157
|
+
colour: override?.colour ?? base.colour,
|
|
158
|
+
dashed: override?.dashed ?? base.dashed,
|
|
159
|
+
hidden: override?.hidden ?? false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
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
|
+
export interface CommandBracketsProps {
|
|
199
|
+
state: EffectiveCommandTrack;
|
|
200
|
+
displayUt: number;
|
|
201
|
+
/** Where `displayUt` came from, mirrored to the readout as `data-ut-source`. */
|
|
202
|
+
utSource?: FrameUtSource | "override" | null;
|
|
203
|
+
video: HTMLVideoElement | null;
|
|
204
|
+
/** Frame aspect to assume until the video reports its own size. */
|
|
205
|
+
fallbackSize?: { width: number; height: number } | null;
|
|
206
|
+
overlay?: CommandBracketOverlay | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
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.
|
|
215
|
+
*/
|
|
216
|
+
export function CommandBrackets({
|
|
217
|
+
state,
|
|
218
|
+
displayUt,
|
|
219
|
+
utSource,
|
|
220
|
+
video,
|
|
221
|
+
fallbackSize,
|
|
222
|
+
overlay,
|
|
223
|
+
}: CommandBracketsProps) {
|
|
224
|
+
const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
|
|
225
|
+
const rect = containedContentRect(
|
|
226
|
+
boxW,
|
|
227
|
+
boxH,
|
|
228
|
+
mediaW || fallbackSize?.width || 0,
|
|
229
|
+
mediaH || fallbackSize?.height || 0,
|
|
230
|
+
);
|
|
231
|
+
const g = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH);
|
|
232
|
+
const ctx: CommandBracketContext = {
|
|
233
|
+
geometry: g,
|
|
234
|
+
width: boxW,
|
|
235
|
+
height: boxH,
|
|
236
|
+
displayUt,
|
|
237
|
+
utSource: utSource ?? null,
|
|
238
|
+
state,
|
|
239
|
+
};
|
|
240
|
+
const overrides =
|
|
241
|
+
typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
|
|
242
|
+
const camera = cameraCornerStyle(state);
|
|
243
|
+
const topLeft = applyCorner(camera, overrides?.topLeft);
|
|
244
|
+
const bottomRight = applyCorner(camera, overrides?.bottomRight);
|
|
245
|
+
const { x: left, y: top } = g.topLeft;
|
|
246
|
+
const { x: right, y: bottom } = g.bottomRight;
|
|
247
|
+
const blanked = !!state.blank;
|
|
248
|
+
const drawCorners = state.hasFix && !blanked;
|
|
249
|
+
const defaultReadout = blanked ? "" : commandReadout(state);
|
|
250
|
+
const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<>
|
|
254
|
+
{(drawCorners || overlay?.render) && (
|
|
255
|
+
<BracketSvg aria-hidden="true" data-camera-axis={state.camera} width={boxW} height={boxH}>
|
|
256
|
+
{drawCorners && !topLeft.hidden && (
|
|
257
|
+
<path
|
|
258
|
+
data-corner="topLeft"
|
|
259
|
+
d={`M${left} ${top + g.arm}L${left} ${top}L${left + g.arm} ${top}`}
|
|
260
|
+
fill="none"
|
|
261
|
+
strokeWidth={g.stroke}
|
|
262
|
+
strokeDasharray={topLeft.dashed ? "3 2" : undefined}
|
|
263
|
+
style={{ stroke: topLeft.colour }}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
{drawCorners && !bottomRight.hidden && (
|
|
267
|
+
<path
|
|
268
|
+
data-corner="bottomRight"
|
|
269
|
+
d={`M${right} ${bottom - g.arm}L${right} ${bottom}L${right - g.arm} ${bottom}`}
|
|
270
|
+
fill="none"
|
|
271
|
+
strokeWidth={g.stroke}
|
|
272
|
+
strokeDasharray={bottomRight.dashed ? "3 2" : undefined}
|
|
273
|
+
style={{ stroke: bottomRight.colour }}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
276
|
+
{overlay?.render && <g data-bracket-overlay="">{overlay.render(ctx)}</g>}
|
|
277
|
+
</BracketSvg>
|
|
278
|
+
)}
|
|
279
|
+
{readout && (
|
|
280
|
+
<Readout
|
|
281
|
+
data-ut-source={utSource ?? undefined}
|
|
282
|
+
style={{ left: g.cx, top: bottom + READOUT_GAP_PX }}
|
|
283
|
+
>
|
|
284
|
+
{readout}
|
|
285
|
+
</Readout>
|
|
286
|
+
)}
|
|
287
|
+
</>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Reason a command feed is blanked, centred on black over the feed. */
|
|
292
|
+
export function CommandBlank({ reason }: { reason: string }) {
|
|
293
|
+
return (
|
|
294
|
+
<BlankOverlay role="status" aria-label="Feed blanked">
|
|
295
|
+
<BlankText>{reason}</BlankText>
|
|
296
|
+
</BlankOverlay>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const BracketSvg = styled.svg`
|
|
301
|
+
position: absolute;
|
|
302
|
+
top: 0;
|
|
303
|
+
left: 0;
|
|
304
|
+
z-index: 1;
|
|
305
|
+
overflow: visible;
|
|
306
|
+
pointer-events: none;
|
|
307
|
+
`;
|
|
308
|
+
|
|
309
|
+
const Readout = styled.div`
|
|
310
|
+
position: absolute;
|
|
311
|
+
z-index: 1;
|
|
312
|
+
transform: translateX(-50%);
|
|
313
|
+
white-space: nowrap;
|
|
314
|
+
color: #fff;
|
|
315
|
+
font-size: 10px;
|
|
316
|
+
font-weight: 600;
|
|
317
|
+
letter-spacing: 0.12em;
|
|
318
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
319
|
+
pointer-events: none;
|
|
320
|
+
`;
|
|
321
|
+
|
|
322
|
+
const BlankOverlay = styled.div`
|
|
323
|
+
position: absolute;
|
|
324
|
+
inset: 0;
|
|
325
|
+
z-index: 1;
|
|
326
|
+
display: flex;
|
|
327
|
+
align-items: center;
|
|
328
|
+
justify-content: center;
|
|
329
|
+
padding: 1rem;
|
|
330
|
+
background: #000;
|
|
331
|
+
pointer-events: none;
|
|
332
|
+
`;
|
|
333
|
+
|
|
334
|
+
const BlankText = styled.span`
|
|
335
|
+
color: rgba(255, 255, 255, 0.85);
|
|
336
|
+
font-size: 0.85rem;
|
|
337
|
+
font-weight: 600;
|
|
338
|
+
letter-spacing: 0.12em;
|
|
339
|
+
text-align: center;
|
|
340
|
+
text-transform: uppercase;
|
|
341
|
+
`;
|
package/src/KerbalFaceFeed.tsx
CHANGED
|
@@ -177,12 +177,12 @@ export function KerbalFaceFeed({
|
|
|
177
177
|
// Reporting follows the mount lifecycle; the registry collapses multiple
|
|
178
178
|
// feeds of the same kerbal to one MAX report.
|
|
179
179
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
180
|
-
|
|
180
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
181
|
+
useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize, videoRef });
|
|
181
182
|
|
|
182
183
|
// Bind the stream to the persistent <video> without remounting it — the
|
|
183
184
|
// element stays mounted across every re-layout / prop change, only its
|
|
184
185
|
// srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
|
|
185
|
-
const videoRef = useRef<HTMLVideoElement>(null);
|
|
186
186
|
useEffect(() => {
|
|
187
187
|
if (videoRef.current && stream) {
|
|
188
188
|
videoRef.current.srcObject = stream;
|