@ksp-gonogo/kerbcast-react 1.11.1 → 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/README.md
CHANGED
|
@@ -92,6 +92,65 @@ while a camera is tracking. `cameraFilter` narrows the selectable cameras
|
|
|
92
92
|
(picker, stepper, auto-latch). `showActions={false}` hides the whole action
|
|
93
93
|
bar without affecting resolution reporting.
|
|
94
94
|
|
|
95
|
+
## Anchored markers
|
|
96
|
+
|
|
97
|
+
`anchors` marks things in the game on the feed's picture (see the core
|
|
98
|
+
SDK's "Anchored markers" for the kinds and fields). The feed sets them on
|
|
99
|
+
the camera it shows, clears them when it moves on or unmounts, and draws a
|
|
100
|
+
marker where each one lands in every displayed frame: four corner marks
|
|
101
|
+
sized from `apparentRadiusPx` (never below 14 px), green when clear, red and
|
|
102
|
+
dashed when occluded.
|
|
103
|
+
|
|
104
|
+
`anchorOverlay` has the same shape as `targetBracketOverlay`: `corners`
|
|
105
|
+
restyles the four marks (`top`, `right`, `bottom`, `left`, each
|
|
106
|
+
`{ colour, dashed, hidden, shape }`), `render` adds SVG in screen pixels,
|
|
107
|
+
`readout` puts text under the marker, and `shape` sets every mark to
|
|
108
|
+
`"corners"` or `"triangles"`. Each is called per anchor with the raw result
|
|
109
|
+
and a context: its screen geometry, the fitted content rect, the displayed
|
|
110
|
+
frame's UT and the screen position of every drawn anchor. `owners` also
|
|
111
|
+
draws anchors set in game by those owners. What a colour means is yours to
|
|
112
|
+
decide; Kerbcast only reports geometry.
|
|
113
|
+
|
|
114
|
+
A direction reticle on a docking port: four triangles, each side coloured
|
|
115
|
+
from the port's axes.
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
const anchors = useMemo(
|
|
119
|
+
() => [{ id: "port", kind: AnchorKind.Part, vessel: targetPid, part: portPid, orientation: true }],
|
|
120
|
+
[targetPid, portPid],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
<CameraFeed
|
|
124
|
+
flightId={dockingCam}
|
|
125
|
+
anchors={anchors}
|
|
126
|
+
anchorOverlay={{
|
|
127
|
+
shape: "triangles",
|
|
128
|
+
corners: (a) => {
|
|
129
|
+
/* The port faces the lens along -z; tilt shows in its forward's x and y. */
|
|
130
|
+
const [fx = 0, fy = 0] = a.forward ?? [];
|
|
131
|
+
const side = (off: number) => ({ colour: Math.abs(off) < 0.02 ? "lime" : "orange" });
|
|
132
|
+
return { top: side(fy), bottom: side(fy), left: side(fx), right: side(fx) };
|
|
133
|
+
},
|
|
134
|
+
readout: (a) => (a.distanceM != null ? `${a.distanceM.toFixed(1)} m` : null),
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
For a video of your own, `AnchorMarkers` draws a frame's anchors over any
|
|
140
|
+
`<video>` and `useDisplayedFrame(enabled, flightId, video)` gives the
|
|
141
|
+
displayed frame with its results.
|
|
142
|
+
|
|
143
|
+
On command feeds the target brackets follow the subject's anchor while it
|
|
144
|
+
is visible and no command lock is held, so the box stays on the craft where
|
|
145
|
+
the picture shows it even while an aim points elsewhere. With the craft out
|
|
146
|
+
of frame or occluded, or under the lock, the box is the 1.11 one: centred on
|
|
147
|
+
the aim, sized from the aimed range, red and dashed when the aim line is
|
|
148
|
+
occluded. `targetBracketOverlay` is unchanged; its context gains `subject`,
|
|
149
|
+
the observed anchor, so you can draw it as a second mark.
|
|
150
|
+
|
|
151
|
+
An observed anchor out of view (see the core SDK) carries no position and
|
|
152
|
+
draws no marker; it is still in `ctx.frame.anchors`.
|
|
153
|
+
|
|
95
154
|
## `KerbalFaceFeed`
|
|
96
155
|
|
|
97
156
|
A single-kerbal face-camera primitive: one crew face in a square frame, keyed
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type DisplayedFrameUt, type FittedFrame, type FrameAnchor, type FrameFit, type FrameUtSource } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
/** Default colours; each is overridable through the named CSS custom property. */
|
|
4
|
+
export declare const ANCHOR_MARKER_COLOURS: {
|
|
5
|
+
readonly clear: "var(--kerbcast-anchor-clear, #4caf50)";
|
|
6
|
+
readonly occluded: "var(--kerbcast-anchor-occluded, #f44336)";
|
|
7
|
+
};
|
|
8
|
+
/** The four marks around an anchor, clockwise from the top. */
|
|
9
|
+
export type AnchorCorner = "top" | "right" | "bottom" | "left";
|
|
10
|
+
/**
|
|
11
|
+
* How a mark is drawn. `corners`: a right-angle corner of a box around the
|
|
12
|
+
* anchor, like the command brackets; each key names the corner that starts
|
|
13
|
+
* its side going clockwise, so `top` is the top left, `right` the top right,
|
|
14
|
+
* `bottom` the bottom right and `left` the bottom left. `triangles`: a
|
|
15
|
+
* triangle outside the box on that side, pointing at the anchor.
|
|
16
|
+
*/
|
|
17
|
+
export type AnchorMarkerShape = "corners" | "triangles";
|
|
18
|
+
export interface AnchorCornerStyle {
|
|
19
|
+
colour: string;
|
|
20
|
+
dashed: boolean;
|
|
21
|
+
shape: AnchorMarkerShape;
|
|
22
|
+
}
|
|
23
|
+
/** Changes to one default mark; unset fields keep the default. */
|
|
24
|
+
export interface AnchorCornerOverride {
|
|
25
|
+
colour?: string;
|
|
26
|
+
dashed?: boolean;
|
|
27
|
+
hidden?: boolean;
|
|
28
|
+
shape?: AnchorMarkerShape;
|
|
29
|
+
}
|
|
30
|
+
export type AnchorCornerOverrides = Partial<Record<AnchorCorner, AnchorCornerOverride>>;
|
|
31
|
+
/** The marker box for one anchor, in the overlay SVG's pixels (the video element's box). */
|
|
32
|
+
export interface AnchorMarkerGeometry {
|
|
33
|
+
/** Where the anchor is on screen. Off the box when it is out of frame. */
|
|
34
|
+
cx: number;
|
|
35
|
+
cy: number;
|
|
36
|
+
/** Half the box side: the anchor's apparent radius on screen, never below a minimum. */
|
|
37
|
+
half: number;
|
|
38
|
+
/** Length of each corner stroke, and the side of each triangle. */
|
|
39
|
+
arm: number;
|
|
40
|
+
stroke: number;
|
|
41
|
+
}
|
|
42
|
+
/** An anchor with its place on screen. */
|
|
43
|
+
export interface PlacedAnchor {
|
|
44
|
+
anchor: FrameAnchor;
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
}
|
|
48
|
+
/** Everything an anchor overlay needs to line up with the drawn markers. */
|
|
49
|
+
export interface AnchorContext {
|
|
50
|
+
geometry: AnchorMarkerGeometry;
|
|
51
|
+
/** Where the frame lands in the element, after `object-fit`. */
|
|
52
|
+
content: FittedFrame;
|
|
53
|
+
/** Size of the overlay SVG, the video element's box. */
|
|
54
|
+
width: number;
|
|
55
|
+
height: number;
|
|
56
|
+
/** Mission UT of the displayed frame the anchors describe. */
|
|
57
|
+
displayUt: number;
|
|
58
|
+
utSource: FrameUtSource;
|
|
59
|
+
/** Every drawn anchor in this frame with its screen position. */
|
|
60
|
+
anchors: readonly PlacedAnchor[];
|
|
61
|
+
/** The displayed frame, with its raw per-frame results and size. */
|
|
62
|
+
frame: DisplayedFrameUt;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Consumer changes to a feed's anchor markers. Each part is optional and is
|
|
66
|
+
* called for every drawn anchor on every displayed frame. What an anchor
|
|
67
|
+
* means, and which colour says so, is the consumer's: Kerbcast only reports
|
|
68
|
+
* where it is.
|
|
69
|
+
*/
|
|
70
|
+
export interface AnchorOverlay {
|
|
71
|
+
/** Default shape of every mark. Default `"corners"`. */
|
|
72
|
+
shape?: AnchorMarkerShape;
|
|
73
|
+
/** Restyle or hide the four marks of one anchor. */
|
|
74
|
+
corners?: AnchorCornerOverrides | ((anchor: FrameAnchor, ctx: AnchorContext) => AnchorCornerOverrides | null | undefined);
|
|
75
|
+
/**
|
|
76
|
+
* SVG content drawn above the marks, in the same pixel space. Runs for an
|
|
77
|
+
* anchor out of frame or behind the lens too (its marks are left out then),
|
|
78
|
+
* so an overlay can point to it from the edge. An observed result out of
|
|
79
|
+
* view carries no position and is not drawn at all; it is still in
|
|
80
|
+
* `ctx.frame.anchors`.
|
|
81
|
+
*/
|
|
82
|
+
render?: (anchor: FrameAnchor, ctx: AnchorContext) => ReactNode;
|
|
83
|
+
/** Text under the marker, or null for none. Default none. */
|
|
84
|
+
readout?: (anchor: FrameAnchor, ctx: AnchorContext) => string | null;
|
|
85
|
+
/**
|
|
86
|
+
* Game-side owners whose anchors are drawn as well as this client's own,
|
|
87
|
+
* e.g. `["my-mod"]`. Default: only this client's.
|
|
88
|
+
*/
|
|
89
|
+
owners?: readonly string[];
|
|
90
|
+
/**
|
|
91
|
+
* Draw Kerbcast's own `aim` anchor too when `"kerbcast"` is among
|
|
92
|
+
* `owners`. Off by default: the brackets already mark the aim.
|
|
93
|
+
*/
|
|
94
|
+
showAim?: boolean;
|
|
95
|
+
}
|
|
96
|
+
/** The marker box for an anchor at `centre` with `radiusPx` capture pixels on a `frameHeight`-tall frame. */
|
|
97
|
+
export declare function anchorMarkerGeometry(centre: {
|
|
98
|
+
x: number;
|
|
99
|
+
y: number;
|
|
100
|
+
}, radiusPx: number, frameHeight: number, content: FittedFrame): AnchorMarkerGeometry;
|
|
101
|
+
export interface AnchorMarkersProps {
|
|
102
|
+
/** The displayed frame whose anchors to draw. */
|
|
103
|
+
frame: DisplayedFrameUt | null;
|
|
104
|
+
video: HTMLVideoElement | null;
|
|
105
|
+
overlay?: AnchorOverlay | null;
|
|
106
|
+
/** How the video fits its box. Default: read from the element's CSS. */
|
|
107
|
+
fit?: FrameFit;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Markers for the anchors in one displayed frame: by default four corner
|
|
111
|
+
* marks around each anchor sized from its apparent radius, green when clear
|
|
112
|
+
* and red and dashed when occluded. Positions come from the frame's own
|
|
113
|
+
* stamp, so they match the picture under any delay.
|
|
114
|
+
*/
|
|
115
|
+
export declare function AnchorMarkers({ frame, video, overlay, fit }: AnchorMarkersProps): import("react").JSX.Element | null;
|
|
116
|
+
/**
|
|
117
|
+
* The displayed frame of `flightId` on `video`, re-rendering on every
|
|
118
|
+
* presented frame while `enabled`. Kept to the component that draws from it,
|
|
119
|
+
* so a feed's other UI does not re-render at the frame rate.
|
|
120
|
+
*/
|
|
121
|
+
export declare function useDisplayedFrame(enabled: boolean, flightId: number | null, video: HTMLVideoElement | null): DisplayedFrameUt | null;
|
|
122
|
+
/** Self-subscribing markers for a feed: watches its displayed frames and draws their anchors. */
|
|
123
|
+
export declare function LiveAnchorMarkers({ flightId, video, overlay, fit, }: {
|
|
124
|
+
flightId: number | null;
|
|
125
|
+
video: HTMLVideoElement | null;
|
|
126
|
+
overlay?: AnchorOverlay | null;
|
|
127
|
+
fit?: FrameFit;
|
|
128
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { COMMAND_AIM_ANCHOR_ID, KERBCAST_ANCHOR_OWNER, anchorPoint, fitFrame, frameToScreen, } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import styled from "styled-components";
|
|
5
|
+
import { useKerbcastClient } from "./context";
|
|
6
|
+
import { useVideoBox, videoFit } from "./videoBox";
|
|
7
|
+
const MARKER_STROKE_PX = 2;
|
|
8
|
+
const MARKER_MIN_HALF_PX = 14;
|
|
9
|
+
const MARKER_ARM_FRACTION = 0.3;
|
|
10
|
+
const MARKER_MIN_ARM_PX = 6;
|
|
11
|
+
const READOUT_GAP_PX = 11;
|
|
12
|
+
/** Default colours; each is overridable through the named CSS custom property. */
|
|
13
|
+
export const ANCHOR_MARKER_COLOURS = {
|
|
14
|
+
clear: "var(--kerbcast-anchor-clear, #4caf50)",
|
|
15
|
+
occluded: "var(--kerbcast-anchor-occluded, #f44336)",
|
|
16
|
+
};
|
|
17
|
+
/** The marker box for an anchor at `centre` with `radiusPx` capture pixels on a `frameHeight`-tall frame. */
|
|
18
|
+
export function anchorMarkerGeometry(centre, radiusPx, frameHeight, content) {
|
|
19
|
+
const scale = frameHeight > 0 ? content.height / frameHeight : 1;
|
|
20
|
+
const half = Math.max(MARKER_MIN_HALF_PX, radiusPx * scale);
|
|
21
|
+
const arm = Math.max(MARKER_MIN_ARM_PX, MARKER_ARM_FRACTION * half * 2);
|
|
22
|
+
return { cx: centre.x, cy: centre.y, half, arm, stroke: MARKER_STROKE_PX };
|
|
23
|
+
}
|
|
24
|
+
const CORNERS = ["top", "right", "bottom", "left"];
|
|
25
|
+
/** SVG path data for one mark. Corners are open strokes; triangles are closed. */
|
|
26
|
+
function markPath(corner, shape, g) {
|
|
27
|
+
const { cx, cy, half: h, arm: a } = g;
|
|
28
|
+
if (shape === "triangles") {
|
|
29
|
+
const t = a * 0.8;
|
|
30
|
+
const w = a / 2;
|
|
31
|
+
switch (corner) {
|
|
32
|
+
case "top":
|
|
33
|
+
return `M${cx} ${cy - h}L${cx - w} ${cy - h - t}L${cx + w} ${cy - h - t}Z`;
|
|
34
|
+
case "right":
|
|
35
|
+
return `M${cx + h} ${cy}L${cx + h + t} ${cy - w}L${cx + h + t} ${cy + w}Z`;
|
|
36
|
+
case "bottom":
|
|
37
|
+
return `M${cx} ${cy + h}L${cx + w} ${cy + h + t}L${cx - w} ${cy + h + t}Z`;
|
|
38
|
+
default:
|
|
39
|
+
return `M${cx - h} ${cy}L${cx - h - t} ${cy + w}L${cx - h - t} ${cy - w}Z`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const l = cx - h, r = cx + h, tp = cy - h, b = cy + h;
|
|
43
|
+
switch (corner) {
|
|
44
|
+
case "top":
|
|
45
|
+
return `M${l} ${tp + a}L${l} ${tp}L${l + a} ${tp}`;
|
|
46
|
+
case "right":
|
|
47
|
+
return `M${r - a} ${tp}L${r} ${tp}L${r} ${tp + a}`;
|
|
48
|
+
case "bottom":
|
|
49
|
+
return `M${r} ${b - a}L${r} ${b}L${r - a} ${b}`;
|
|
50
|
+
default:
|
|
51
|
+
return `M${l + a} ${b}L${l} ${b}L${l} ${b - a}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Whether a feed shows this anchor: this client's own, or one from a listed owner, Kerbcast's aim only on request. */
|
|
55
|
+
function drawn(a, overlay) {
|
|
56
|
+
if (a.owner === undefined)
|
|
57
|
+
return true;
|
|
58
|
+
if (a.owner === KERBCAST_ANCHOR_OWNER && a.id === COMMAND_AIM_ANCHOR_ID && !overlay?.showAim)
|
|
59
|
+
return false;
|
|
60
|
+
return overlay?.owners?.includes(a.owner) ?? false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Markers for the anchors in one displayed frame: by default four corner
|
|
64
|
+
* marks around each anchor sized from its apparent radius, green when clear
|
|
65
|
+
* and red and dashed when occluded. Positions come from the frame's own
|
|
66
|
+
* stamp, so they match the picture under any delay.
|
|
67
|
+
*/
|
|
68
|
+
export function AnchorMarkers({ frame, video, overlay, fit }) {
|
|
69
|
+
const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
|
|
70
|
+
if (!frame?.anchors?.length)
|
|
71
|
+
return null;
|
|
72
|
+
const frameW = frame.frameWidth || mediaW;
|
|
73
|
+
const frameH = frame.frameHeight || mediaH;
|
|
74
|
+
if (!(frameW > 0 && frameH > 0 && boxW > 0 && boxH > 0))
|
|
75
|
+
return null;
|
|
76
|
+
const content = fitFrame(boxW, boxH, frameW, frameH, fit ?? videoFit(video));
|
|
77
|
+
/* An observed result out of view has no position, so it has no marker. */
|
|
78
|
+
const placed = [];
|
|
79
|
+
for (const a of frame.anchors) {
|
|
80
|
+
const point = drawn(a, overlay) ? anchorPoint(a) : null;
|
|
81
|
+
if (point)
|
|
82
|
+
placed.push({ anchor: a, ...frameToScreen(point, frameW, frameH, content) });
|
|
83
|
+
}
|
|
84
|
+
if (placed.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
const shape = overlay?.shape ?? "corners";
|
|
87
|
+
const items = placed.map(({ anchor, x, y }) => {
|
|
88
|
+
const geometry = anchorMarkerGeometry({ x, y }, anchor.apparentRadiusPx ?? 0, frameH, content);
|
|
89
|
+
const ctx = {
|
|
90
|
+
geometry,
|
|
91
|
+
content,
|
|
92
|
+
width: boxW,
|
|
93
|
+
height: boxH,
|
|
94
|
+
displayUt: frame.ut,
|
|
95
|
+
utSource: frame.source,
|
|
96
|
+
anchors: placed,
|
|
97
|
+
frame,
|
|
98
|
+
};
|
|
99
|
+
const overrides = typeof overlay?.corners === "function" ? overlay.corners(anchor, ctx) : overlay?.corners;
|
|
100
|
+
const base = anchor.occluded
|
|
101
|
+
? { colour: ANCHOR_MARKER_COLOURS.occluded, dashed: true, shape }
|
|
102
|
+
: { colour: ANCHOR_MARKER_COLOURS.clear, dashed: false, shape };
|
|
103
|
+
const visible = anchor.inFrame && !anchor.behind;
|
|
104
|
+
const readout = overlay?.readout ? overlay.readout(anchor, ctx) : null;
|
|
105
|
+
return { anchor, geometry, ctx, overrides, base, visible, readout };
|
|
106
|
+
});
|
|
107
|
+
return (_jsxs(_Fragment, { children: [_jsx(MarkerSvg, { "aria-hidden": "true", "data-anchor-markers": "", width: boxW, height: boxH, children: items.map(({ anchor, geometry, ctx, overrides, base, visible }) => (_jsxs("g", { "data-anchor": anchor.id, "data-owner": anchor.owner, children: [visible &&
|
|
108
|
+
CORNERS.map((corner) => {
|
|
109
|
+
const o = overrides?.[corner];
|
|
110
|
+
if (o?.hidden)
|
|
111
|
+
return null;
|
|
112
|
+
const s = o?.shape ?? base.shape;
|
|
113
|
+
const colour = o?.colour ?? base.colour;
|
|
114
|
+
const dashed = o?.dashed ?? base.dashed;
|
|
115
|
+
const filled = s === "triangles";
|
|
116
|
+
return (_jsx("path", { "data-corner": corner, "data-shape": s, d: markPath(corner, s, geometry), strokeWidth: geometry.stroke, strokeDasharray: dashed ? "3 2" : undefined, style: filled ? { fill: dashed ? "none" : colour, stroke: colour } : { fill: "none", stroke: colour } }, corner));
|
|
117
|
+
}), overlay?.render && _jsx("g", { "data-anchor-overlay": "", children: overlay.render(anchor, ctx) })] }, `${anchor.owner ?? ""}/${anchor.id}`))) }), items.map(({ anchor, geometry, readout, visible }) => readout &&
|
|
118
|
+
visible && (_jsx(Readout, { "data-anchor-readout": anchor.id, style: { left: geometry.cx, top: geometry.cy + geometry.half + READOUT_GAP_PX }, children: readout }, `${anchor.owner ?? ""}/${anchor.id}`)))] }));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The displayed frame of `flightId` on `video`, re-rendering on every
|
|
122
|
+
* presented frame while `enabled`. Kept to the component that draws from it,
|
|
123
|
+
* so a feed's other UI does not re-render at the frame rate.
|
|
124
|
+
*/
|
|
125
|
+
export function useDisplayedFrame(enabled, flightId, video) {
|
|
126
|
+
const client = useKerbcastClient();
|
|
127
|
+
const [frame, setFrame] = useState(null);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
setFrame(null);
|
|
130
|
+
if (!enabled || flightId === null || !video)
|
|
131
|
+
return;
|
|
132
|
+
return client.watchDisplayedFrames(flightId, video, (f) =>
|
|
133
|
+
/* Skip re-renders between frames that carry the same results. */
|
|
134
|
+
setFrame((prev) => prev && prev.anchors === f.anchors && prev.rtpTimestamp === f.rtpTimestamp ? prev : f));
|
|
135
|
+
}, [client, enabled, flightId, video]);
|
|
136
|
+
return frame;
|
|
137
|
+
}
|
|
138
|
+
/** Self-subscribing markers for a feed: watches its displayed frames and draws their anchors. */
|
|
139
|
+
export function LiveAnchorMarkers({ flightId, video, overlay, fit, }) {
|
|
140
|
+
const frame = useDisplayedFrame(true, flightId, video);
|
|
141
|
+
return _jsx(AnchorMarkers, { frame: frame, video: video, overlay: overlay, fit: fit });
|
|
142
|
+
}
|
|
143
|
+
const MarkerSvg = styled.svg `
|
|
144
|
+
position: absolute;
|
|
145
|
+
top: 0;
|
|
146
|
+
left: 0;
|
|
147
|
+
z-index: 1;
|
|
148
|
+
overflow: visible;
|
|
149
|
+
pointer-events: none;
|
|
150
|
+
`;
|
|
151
|
+
const Readout = styled.div `
|
|
152
|
+
position: absolute;
|
|
153
|
+
z-index: 1;
|
|
154
|
+
transform: translateX(-50%);
|
|
155
|
+
white-space: nowrap;
|
|
156
|
+
color: #fff;
|
|
157
|
+
font-size: 10px;
|
|
158
|
+
font-weight: 600;
|
|
159
|
+
letter-spacing: 0.12em;
|
|
160
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
161
|
+
pointer-events: none;
|
|
162
|
+
`;
|
package/dist/CameraFeed.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import type { Anchor, CameraState, DisplayedFrameUt, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
2
|
import { type FeedAction } from "./FeedActionBar";
|
|
3
3
|
import { type CommandBracketOverlay } from "./CommandBrackets";
|
|
4
|
+
import { type AnchorOverlay } from "./AnchorMarkers";
|
|
4
5
|
/**
|
|
5
6
|
* The consumer-supplied action shape, and the shared top-right action bar
|
|
6
7
|
* both `CameraFeed` and `KerbalFaceFeed` render, live in `FeedActionBar`.
|
|
@@ -150,8 +151,9 @@ export interface CameraFeedProps {
|
|
|
150
151
|
enableRecording?: boolean;
|
|
151
152
|
/**
|
|
152
153
|
* Record this feed at the operator's full render size for the duration of
|
|
153
|
-
* the built-in REC control's recording (overrides
|
|
154
|
-
* demand
|
|
154
|
+
* the built-in REC control's recording (overrides the display-size
|
|
155
|
+
* demand and any viewer quality preset; still capped by an operator
|
|
156
|
+
* render-size cap and the adaptive shed). Off by default.
|
|
155
157
|
* Threaded straight into `start(flightId, { forceFullResolution })`; has
|
|
156
158
|
* no effect unless `enableRecording` is also set. While the feed is armed
|
|
157
159
|
* (force sent, waiting for the resolution bump) the tile shows an
|
|
@@ -199,14 +201,53 @@ export interface CameraFeedProps {
|
|
|
199
201
|
* brackets. See {@link CommandBracketOverlay}.
|
|
200
202
|
*/
|
|
201
203
|
targetBracketOverlay?: CommandBracketOverlay;
|
|
204
|
+
/**
|
|
205
|
+
* What a command camera feed says while the camera has no subject to aim
|
|
206
|
+
* at yet and shows its placeholder framing (a launch site camera on its
|
|
207
|
+
* pad, a station looking at the horizon). Drawn over the picture, never
|
|
208
|
+
* into the video, so recordings stay clean. Default "Waiting for target";
|
|
209
|
+
* `null` or an empty string shows nothing.
|
|
210
|
+
*/
|
|
211
|
+
waitingForTargetLabel?: string | null;
|
|
212
|
+
/**
|
|
213
|
+
* What the feed says while its camera has no video slot on this connection
|
|
214
|
+
* (`KerbcastCameraHandle.waitingForSlot`): the pool is at its ceiling or
|
|
215
|
+
* cannot grow. The feed takes the next slot another feed gives up. Drawn
|
|
216
|
+
* over the picture; `null` or an empty string shows nothing.
|
|
217
|
+
*/
|
|
218
|
+
noSlotLabel?: string | null;
|
|
202
219
|
/**
|
|
203
220
|
* Mission UT the displayed video was captured at, for a host running its
|
|
204
221
|
* own delayed playout. Picks which command-track transition is in force and
|
|
205
222
|
* ages the readout. Defaults to the displayed frame's own capture UT where
|
|
206
223
|
* the browser reports frame RTP timestamps, else the client's capture
|
|
207
|
-
* clock interpolated to now.
|
|
224
|
+
* clock interpolated to now. While a command lock is held the brackets and
|
|
225
|
+
* readout come from the displayed frame's own state, which a UT alone does
|
|
226
|
+
* not carry: such a host passes `displayFrame` instead.
|
|
208
227
|
*/
|
|
209
228
|
displayUt?: number;
|
|
229
|
+
/**
|
|
230
|
+
* The frame a host's own delayed playout is showing, as
|
|
231
|
+
* `watchDisplayedFrames` reported it when it arrived. Wins over
|
|
232
|
+
* `displayUt`: its `ut` picks the transition in force and its `command`
|
|
233
|
+
* draws the brackets and readout, so they never run ahead of the picture.
|
|
234
|
+
*/
|
|
235
|
+
displayFrame?: DisplayedFrameUt | null;
|
|
236
|
+
/**
|
|
237
|
+
* Things to mark on this feed's picture: parts, craft, points on a body or
|
|
238
|
+
* directions. Set for the displayed camera as this client's anchors and
|
|
239
|
+
* cleared when the feed shows another camera or unmounts; the game
|
|
240
|
+
* projects each through the camera that rendered every frame, and a
|
|
241
|
+
* default marker is drawn where it lands. One set per camera per client:
|
|
242
|
+
* two feeds on the same camera share it, the last to set winning. At
|
|
243
|
+
* most 16. Pass a stable or memoised array; it is compared by value.
|
|
244
|
+
*/
|
|
245
|
+
anchors?: Anchor[];
|
|
246
|
+
/**
|
|
247
|
+
* Restyle the anchor markers, add SVG of your own, put text under them,
|
|
248
|
+
* or draw anchors set in game too. See {@link AnchorOverlay}.
|
|
249
|
+
*/
|
|
250
|
+
anchorOverlay?: AnchorOverlay;
|
|
210
251
|
}
|
|
211
252
|
export interface CameraFeedHandle {
|
|
212
253
|
stepCamera(delta: number): void;
|
package/dist/CameraFeed.js
CHANGED
|
@@ -6,16 +6,19 @@ import styled, { css } from "styled-components";
|
|
|
6
6
|
import { buildCameraLabeler } from "./cameraLabels";
|
|
7
7
|
import { KerbcastProvider, useKerbcastClient } from "./context";
|
|
8
8
|
import { ActionBarRow, FeedActionBar, OverlayIconButton, feedActionToEntry, } from "./FeedActionBar";
|
|
9
|
-
import { CommandBlank,
|
|
9
|
+
import { CommandBlank, LiveCommandBrackets } from "./CommandBrackets";
|
|
10
|
+
import { LiveAnchorMarkers } from "./AnchorMarkers";
|
|
10
11
|
import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
|
|
11
12
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
12
13
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
13
14
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
15
|
+
import { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
|
|
14
16
|
import { useRecordings } from "./hooks/useRecordings";
|
|
15
17
|
import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
16
18
|
import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
|
|
17
19
|
import { StandbyIcon } from "./StandbyIcon";
|
|
18
20
|
import { isCameraDestroyed } from "./lifecycle";
|
|
21
|
+
import { claimAnchors } from "./sharedAnchors";
|
|
19
22
|
import { usePortalMenu } from "./menuPositioning";
|
|
20
23
|
import { formatElapsed, nowMs } from "./timing";
|
|
21
24
|
// ---------------------------------------------------------------------------
|
|
@@ -62,7 +65,7 @@ function exitFullscreen() {
|
|
|
62
65
|
// ---------------------------------------------------------------------------
|
|
63
66
|
// Inner component (reads from context)
|
|
64
67
|
// ---------------------------------------------------------------------------
|
|
65
|
-
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, enableTargetBrackets = true, targetBracketOverlay, displayUt: displayUtProp, }, ref) {
|
|
68
|
+
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, enableTargetBrackets = true, targetBracketOverlay, waitingForTargetLabel = "Waiting for target", noSlotLabel = "Not streaming: too many feeds are open. Close a feed to watch this one.", displayUt: displayUtProp, displayFrame, anchors, anchorOverlay, }, ref) {
|
|
66
69
|
const client = useKerbcastClient();
|
|
67
70
|
// Always called (rules of hooks); `enableRecording` only gates whether the
|
|
68
71
|
// REC entry/badge render, not whether the store is subscribed.
|
|
@@ -96,7 +99,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
96
99
|
}, [showStatic]);
|
|
97
100
|
const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
|
|
98
101
|
// -------------------------------------------------------------------------
|
|
99
|
-
// Selection model
|
|
102
|
+
// Selection model
|
|
100
103
|
//
|
|
101
104
|
// Resolution order:
|
|
102
105
|
// 1. Explicit pick, if still present in the list (destroyed or not).
|
|
@@ -242,6 +245,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
242
245
|
}
|
|
243
246
|
}, []);
|
|
244
247
|
const isDestroyed = camera ? isCameraDestroyed(camera) : false;
|
|
248
|
+
const waitingForSlot = useKerbcastWaitingForSlot(flightId);
|
|
245
249
|
/* Gating here rather than at the render sites covers the whole manual
|
|
246
250
|
path at once: the controls unmount, the effects below zero any in-flight
|
|
247
251
|
rate, and the imperative handle's pan/zoom methods no-op. */
|
|
@@ -259,11 +263,23 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
259
263
|
const commandCamera = camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
|
|
260
264
|
const isCommandFeed = commandCamera !== null;
|
|
261
265
|
const commandTrack = commandCamera?.commandTrack ?? null;
|
|
262
|
-
const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl);
|
|
266
|
+
const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayFrame ? displayFrame.ut : displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl, displayFrame?.command);
|
|
263
267
|
const commandState = commandTrack
|
|
264
|
-
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
|
|
268
|
+
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut, commandDisplayUt?.command)
|
|
265
269
|
: null;
|
|
266
|
-
|
|
270
|
+
/* This feed's anchors go to the camera it displays, and leave with it. */
|
|
271
|
+
const anchorsKey = anchors?.length ? JSON.stringify(anchors) : "";
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (flightId === null || !anchorsKey)
|
|
274
|
+
return;
|
|
275
|
+
return claimAnchors(client, flightId, JSON.parse(anchorsKey));
|
|
276
|
+
}, [client, flightId, anchorsKey]);
|
|
277
|
+
const drawsAnchors = !!anchorsKey || !!anchorOverlay?.owners?.length;
|
|
278
|
+
const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(commandCamera
|
|
279
|
+
? (commandCamera.commandCentre?.siteKey || commandCamera.commandCentre?.nodeName || String(commandCamera.flightId))
|
|
280
|
+
: null,
|
|
281
|
+
// 1.11 keyed the preference by stationId; carry a saved choice over.
|
|
282
|
+
commandCamera?.commandCentre?.stationId ?? null);
|
|
267
283
|
const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
|
|
268
284
|
// -------------------------------------------------------------------------
|
|
269
285
|
// Camera selection callbacks (Next/Prev, picker)
|
|
@@ -489,8 +505,12 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
489
505
|
// one aims the camera at it. Hidden while a command authority holds the
|
|
490
506
|
// aim lock, since only it can aim then.
|
|
491
507
|
// -------------------------------------------------------------------------
|
|
492
|
-
|
|
493
|
-
const
|
|
508
|
+
/* The list as it stood at the displayed frame, so a delayed feed never offers what its picture has not reached. */
|
|
509
|
+
const shownCentre = commandCamera
|
|
510
|
+
? client.commandCentreAt(commandCamera.flightId, commandDisplayUt?.ut)
|
|
511
|
+
: null;
|
|
512
|
+
const commandTargets = shownCentre?.targets ?? [];
|
|
513
|
+
const commandTargetId = shownCentre?.subjectPersistentId ?? 0;
|
|
494
514
|
const commandLocked = commandCamera?.commandTrack?.authority != null;
|
|
495
515
|
const commandTargetAvailable = isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
|
|
496
516
|
const commandTargetMenuId = useId();
|
|
@@ -523,6 +543,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
523
543
|
yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
|
|
524
544
|
place of the REC badge. */
|
|
525
545
|
const armingActive = activeRecording?.arming === true;
|
|
546
|
+
/* Stopped, clip not in the store yet (ActiveRecordingInfo.stopping). */
|
|
547
|
+
const savingActive = activeRecording?.stopping === true;
|
|
526
548
|
// Ticks once a second while recording so the elapsed timer stays live;
|
|
527
549
|
// the timer itself is derived from activeRecording.startedAt each render.
|
|
528
550
|
const [, tickRecordingTimer] = useReducer((c) => c + 1, 0);
|
|
@@ -708,7 +730,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
708
730
|
return (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": current, "$selected": current, disabled: !eligible, title: eligible ? undefined : t.eligibilityText, onClick: () => selectCommandTarget(t.persistentId), children: [t.vesselName, t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : "", eligible ? "" : ` · ${t.eligibilityText}`] }, t.persistentId));
|
|
709
731
|
}) }), document.body), commandState &&
|
|
710
732
|
!outOfFlight &&
|
|
711
|
-
(_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), targetBracketsAvailable && bracketsOn && (_jsx(
|
|
733
|
+
(_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), commandState.waitingForTarget && !commandState.blank && !isDestroyed && waitingForTargetLabel && (_jsx(WaitingBadge, { role: "status", children: waitingForTargetLabel })), targetBracketsAvailable && bracketsOn && (_jsx(LiveCommandBrackets, { flightId: commandCamera?.flightId ?? null, state: commandState, displayUt: commandDisplayUt?.ut ?? commandTrack.ut, utSource: commandDisplayUt?.source ?? null, video: videoEl, fallbackSize: camera ? { width: camera.renderWidth, height: camera.renderHeight } : null, overlay: targetBracketOverlay }))] })), waitingForSlot && !outOfFlight && !isDestroyed && noSlotLabel && (_jsx(NoSlotNotice, { role: "status", children: noSlotLabel })), drawsAnchors && !outOfFlight && !isDestroyed && !commandState?.blank && (_jsx(LiveAnchorMarkers, { flightId: flightId, video: videoEl, overlay: anchorOverlay })), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && !savingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), recordingActive && savingActive && (_jsx(RecBadge, { role: "status", "aria-label": "Saving recording", children: "SAVING" })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
712
734
|
if (e.detail === 0)
|
|
713
735
|
controllerRef.current?.nudgeZoom(-1);
|
|
714
736
|
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
|
|
@@ -742,7 +764,7 @@ export const CameraFeed = forwardRef(function CameraFeed({ client, ...rest }, re
|
|
|
742
764
|
return _jsx(CameraFeedInner, { ref: ref, ...rest });
|
|
743
765
|
});
|
|
744
766
|
// ---------------------------------------------------------------------------
|
|
745
|
-
// Inline UI primitives
|
|
767
|
+
// Inline UI primitives
|
|
746
768
|
// ---------------------------------------------------------------------------
|
|
747
769
|
function ChevronDownIcon(props) {
|
|
748
770
|
return (_jsx("svg", { viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", ...props, children: _jsx("path", { d: "M4 6l4 4 4-4", stroke: "currentColor", strokeWidth: 1.5, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
@@ -1366,6 +1388,52 @@ const StaleScrim = styled.div `
|
|
|
1366
1388
|
background: rgba(0, 0, 0, 0.32);
|
|
1367
1389
|
pointer-events: none;
|
|
1368
1390
|
`;
|
|
1391
|
+
/* Centred in the picture, clear of the chrome along the edges, and wrapping so
|
|
1392
|
+
a small tile still shows the whole sentence. */
|
|
1393
|
+
const NoSlotNotice = styled.div `
|
|
1394
|
+
position: absolute;
|
|
1395
|
+
top: 50%;
|
|
1396
|
+
left: 50%;
|
|
1397
|
+
transform: translate(-50%, -50%);
|
|
1398
|
+
z-index: 2;
|
|
1399
|
+
max-width: calc(100% - 32px);
|
|
1400
|
+
padding: 6px 10px;
|
|
1401
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1402
|
+
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
1403
|
+
border-radius: 3px;
|
|
1404
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1405
|
+
font-size: 12px;
|
|
1406
|
+
line-height: 1.4;
|
|
1407
|
+
text-align: center;
|
|
1408
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1409
|
+
pointer-events: none;
|
|
1410
|
+
`;
|
|
1411
|
+
/* Bottom centre: the zoom controls take the bottom left, pan and the stale
|
|
1412
|
+
badge the bottom right, the title and REC badge the top left and the
|
|
1413
|
+
action bar the top right. */
|
|
1414
|
+
const WaitingBadge = styled.div `
|
|
1415
|
+
position: absolute;
|
|
1416
|
+
bottom: 8px;
|
|
1417
|
+
left: 50%;
|
|
1418
|
+
transform: translateX(-50%);
|
|
1419
|
+
z-index: 2;
|
|
1420
|
+
/* Clear of the stale badge on the bottom right, even on a small tile. */
|
|
1421
|
+
max-width: calc(100% - 180px);
|
|
1422
|
+
overflow: hidden;
|
|
1423
|
+
text-overflow: ellipsis;
|
|
1424
|
+
white-space: nowrap;
|
|
1425
|
+
padding: 2px 6px;
|
|
1426
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1427
|
+
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
1428
|
+
border-radius: 3px;
|
|
1429
|
+
color: rgba(255, 255, 255, 0.9);
|
|
1430
|
+
font-size: 10px;
|
|
1431
|
+
font-weight: 600;
|
|
1432
|
+
letter-spacing: 0.12em;
|
|
1433
|
+
text-transform: uppercase;
|
|
1434
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1435
|
+
pointer-events: none;
|
|
1436
|
+
`;
|
|
1369
1437
|
const StaleBadge = styled.div `
|
|
1370
1438
|
position: absolute;
|
|
1371
1439
|
bottom: 8px;
|