feat(ui): refine live observation workspace
This commit is contained in:
parent
2bda1986bd
commit
aa2df560b7
|
|
@ -137,6 +137,14 @@ export default function App() {
|
||||||
runtime.state?.observationSources ?? [],
|
runtime.state?.observationSources ?? [],
|
||||||
runtime.setObservationSourceActive,
|
runtime.setObservationSourceActive,
|
||||||
);
|
);
|
||||||
|
const focusedObservationSource = runtime.state?.observationSources?.find(
|
||||||
|
(source) => source.id === observationLayout.focusedSourceId,
|
||||||
|
);
|
||||||
|
const observationFullscreenActive = Boolean(
|
||||||
|
activeDefinition?.kind === "spatial"
|
||||||
|
? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud"
|
||||||
|
: activeDefinition?.kind === "cameras" && focusedObservationSource,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remote = runtime.state?.viewerSettings;
|
const remote = runtime.state?.viewerSettings;
|
||||||
|
|
@ -290,6 +298,7 @@ export default function App() {
|
||||||
<ApplicationShell
|
<ApplicationShell
|
||||||
data-nodedc-ui
|
data-nodedc-ui
|
||||||
className="control-station"
|
className="control-station"
|
||||||
|
data-observation-fullscreen={observationFullscreenActive ? "true" : undefined}
|
||||||
navigationOpen={workspace.navigationOpen && activeRoot !== null}
|
navigationOpen={workspace.navigationOpen && activeRoot !== null}
|
||||||
contentOpen={workspace.contentOpen && activeDefinition !== null}
|
contentOpen={workspace.contentOpen && activeDefinition !== null}
|
||||||
contentExpanded={workspace.contentExpanded}
|
contentExpanded={workspace.contentExpanded}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,56 @@
|
||||||
import type { RefObject } from "react";
|
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||||
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
||||||
|
|
||||||
export function initialObservationWindowRect(index: number): ObservationWindowRect {
|
const WINDOW_WIDTH = 336;
|
||||||
|
const WINDOW_HEIGHT = 210;
|
||||||
|
const WINDOW_GAP = 16;
|
||||||
|
const WINDOW_INSET = 18;
|
||||||
|
const TIMELINE_CLEARANCE = 64;
|
||||||
|
|
||||||
|
export function initialObservationWindowRect(
|
||||||
|
index: number,
|
||||||
|
count = 1,
|
||||||
|
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
||||||
|
): ObservationWindowRect {
|
||||||
|
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
||||||
|
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
||||||
|
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
||||||
|
const maxColumns = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
||||||
|
);
|
||||||
|
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
||||||
|
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
||||||
|
const rowHeight = Math.max(
|
||||||
|
1,
|
||||||
|
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
||||||
|
);
|
||||||
|
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
||||||
|
const row = Math.floor(index / columns);
|
||||||
|
const column = index % columns;
|
||||||
|
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||||
|
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
||||||
|
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: 18 + (index % 3) * 354,
|
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||||
y: 78 + Math.floor(index / 3) * 226,
|
y: Math.max(
|
||||||
width: 336,
|
0,
|
||||||
height: 210,
|
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FloatingObservationWindow({
|
export function FloatingObservationWindow({
|
||||||
source,
|
source,
|
||||||
index,
|
index,
|
||||||
|
count,
|
||||||
boundsRef,
|
boundsRef,
|
||||||
rect,
|
rect,
|
||||||
maximized,
|
maximized,
|
||||||
|
|
@ -28,6 +62,7 @@ export function FloatingObservationWindow({
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
index: number;
|
index: number;
|
||||||
|
count: number;
|
||||||
boundsRef: RefObject<HTMLElement | null>;
|
boundsRef: RefObject<HTMLElement | null>;
|
||||||
rect?: ObservationWindowRect;
|
rect?: ObservationWindowRect;
|
||||||
maximized: boolean;
|
maximized: boolean;
|
||||||
|
|
@ -37,10 +72,33 @@ export function FloatingObservationWindow({
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const element = boundsRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const next = { width: element.clientWidth, height: element.clientHeight };
|
||||||
|
setBounds((current) => (
|
||||||
|
current?.width === next.width && current.height === next.height ? current : next
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [boundsRef]);
|
||||||
|
|
||||||
|
const initialRect = bounds ? initialObservationWindowRect(index, count, bounds) : null;
|
||||||
|
const windowRect = rect ?? initialRect;
|
||||||
|
if (!windowRect) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceWindow
|
<WorkspaceWindow
|
||||||
boundsRef={boundsRef}
|
boundsRef={boundsRef}
|
||||||
rect={rect ?? initialObservationWindowRect(index)}
|
rect={windowRect}
|
||||||
onRectChange={onRectChange}
|
onRectChange={onRectChange}
|
||||||
maximized={maximized}
|
maximized={maximized}
|
||||||
onMaximizedChange={onMaximizedChange}
|
onMaximizedChange={onMaximizedChange}
|
||||||
|
|
@ -60,8 +118,8 @@ export function FloatingObservationWindow({
|
||||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
minWidth={280}
|
minWidth={Math.min(280, windowRect.width)}
|
||||||
minHeight={190}
|
minHeight={Math.min(190, windowRect.height)}
|
||||||
resizable={source.capabilities.resizable}
|
resizable={source.capabilities.resizable}
|
||||||
active={active}
|
active={active}
|
||||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ body,
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: var(--nodedc-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
#root[data-nodedc-theme="dark"],
|
#root[data-nodedc-theme="dark"],
|
||||||
|
|
@ -331,7 +332,6 @@ body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(12rem, 0.9fr) minmax(0, 1.1fr);
|
grid-template-columns: minmax(12rem, 0.9fr) minmax(0, 1.1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
border-top: 1px solid var(--station-hairline);
|
|
||||||
padding: 0.7rem 0;
|
padding: 0.7rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
border: 1px solid var(--station-hairline);
|
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: rgb(255 255 255 / 0.025);
|
background: rgb(255 255 255 / 0.025);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
@ -74,8 +73,6 @@
|
||||||
.device-model-card dl {
|
.device-model-card dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.42rem;
|
gap: 0.42rem;
|
||||||
border-top: 1px solid var(--station-hairline);
|
|
||||||
border-bottom: 1px solid var(--station-hairline);
|
|
||||||
padding: 0.7rem 0;
|
padding: 0.7rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -441,7 +438,6 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
border-top: 1px solid var(--station-hairline);
|
|
||||||
padding-top: 0.9rem;
|
padding-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -469,7 +465,6 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
|
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
border-top: 1px solid var(--station-hairline);
|
|
||||||
padding: 0.7rem 0;
|
padding: 0.7rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@
|
||||||
|
|
||||||
.observation-source-menu {
|
.observation-source-menu {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-family: var(--nodedc-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
.observation-source-menu__head,
|
.observation-source-menu__head,
|
||||||
|
|
@ -81,7 +82,6 @@
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
border-bottom: 1px solid var(--station-hairline);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.observation-source-menu__head strong,
|
.observation-source-menu__head strong,
|
||||||
|
|
@ -92,13 +92,13 @@
|
||||||
.observation-source-menu__head strong {
|
.observation-source-menu__head strong {
|
||||||
margin-top: 0.34rem;
|
margin-top: 0.34rem;
|
||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
font-size: 0.76rem;
|
font-size: var(--nodedc-font-size-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.observation-source-menu__head small,
|
.observation-source-menu__head small,
|
||||||
.observation-source-menu__foot {
|
.observation-source-menu__foot {
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
font-size: 0.55rem;
|
font-size: var(--nodedc-font-size-xs);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +176,7 @@ i[data-availability="error"] {
|
||||||
}
|
}
|
||||||
|
|
||||||
.observation-source-menu__foot {
|
.observation-source-menu__foot {
|
||||||
border-top: 1px solid var(--station-hairline);
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observation-media__asset,
|
.observation-media__asset,
|
||||||
|
|
@ -307,13 +307,21 @@ i[data-availability="error"] {
|
||||||
background: #06070a;
|
background: #06070a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-observation-window.nodedc-material-rim::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window[data-active="true"] {
|
||||||
|
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
.observation-timeline {
|
.observation-timeline {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
border: 1px solid rgb(255 255 255 / 0.055);
|
border: 0;
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.9rem;
|
||||||
background: rgb(9 10 13 / 0.78);
|
background: rgb(9 10 13 / 0.78);
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
|
|
@ -432,7 +440,7 @@ i[data-availability="error"] {
|
||||||
min-height: 18rem;
|
min-height: 18rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #07080a;
|
background: #07080a;
|
||||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera-slot {
|
.camera-slot {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,36 @@
|
||||||
background: rgb(var(--nodedc-danger-rgb));
|
background: rgb(var(--nodedc-danger-rgb));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-station[data-observation-fullscreen="true"] .nodedc-app-shell__content {
|
||||||
|
z-index: calc(var(--nodedc-layer-panel) + 1);
|
||||||
|
right: var(--nodedc-app-page-pad);
|
||||||
|
left: var(--nodedc-app-page-pad);
|
||||||
|
width: auto;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-station[data-observation-fullscreen="true"] .nodedc-app-shell__navigation {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel {
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
background: #06070a;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel__head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel__body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.landing-stage {
|
.landing-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -79,7 +109,6 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
width: min(24rem, 30vw);
|
width: min(24rem, 30vw);
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
border: 1px solid rgb(255 255 255 / 0.055);
|
|
||||||
border-radius: 1.25rem;
|
border-radius: 1.25rem;
|
||||||
background: rgb(10 10 12 / 0.58);
|
background: rgb(10 10 12 / 0.58);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #06070a;
|
background: #06070a;
|
||||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rerun-viewport,
|
.rerun-viewport,
|
||||||
|
|
@ -45,6 +45,19 @@
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rerun-viewport {
|
||||||
|
--rerun-tab-bar-height: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rerun WebViewer 0.34.1 draws its selected recording tab inside WASM.
|
||||||
|
The native panels are already disabled; crop only that fixed tab row. */
|
||||||
|
.rerun-viewport__canvas {
|
||||||
|
top: calc(-1 * var(--rerun-tab-bar-height));
|
||||||
|
bottom: auto;
|
||||||
|
height: calc(100% + var(--rerun-tab-bar-height));
|
||||||
|
}
|
||||||
|
|
||||||
.rerun-viewport__canvas canvas {
|
.rerun-viewport__canvas canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
|
@ -63,7 +76,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
border: 1px solid var(--station-hairline);
|
border: 0;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: rgb(10 11 14 / 0.82);
|
background: rgb(10 11 14 / 0.82);
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
|
|
@ -179,7 +192,7 @@
|
||||||
width: 3.4rem;
|
width: 3.4rem;
|
||||||
height: 3.4rem;
|
height: 3.4rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid var(--station-hairline-strong);
|
border: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgb(255 255 255 / 0.035);
|
background: rgb(255 255 255 / 0.035);
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
|
|
@ -204,7 +217,7 @@
|
||||||
.scene-timeline {
|
.scene-timeline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
border: 1px solid rgb(255 255 255 / 0.055);
|
border: 0;
|
||||||
background: rgb(9 10 13 / 0.74);
|
background: rgb(9 10 13 / 0.74);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +253,7 @@
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
border-bottom: 1px solid var(--station-hairline);
|
border-bottom: 0;
|
||||||
padding: 0.38rem 0;
|
padding: 0.38rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.6fr);
|
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.6fr);
|
||||||
gap: 1.6rem;
|
gap: 1.6rem;
|
||||||
border-top: 1px solid var(--station-hairline);
|
|
||||||
padding-top: 1.2rem;
|
padding-top: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +46,6 @@
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
border-bottom: 1px solid var(--station-hairline);
|
|
||||||
padding: 0.78rem 0;
|
padding: 0.78rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,7 +268,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #090a0c;
|
background: #090a0c;
|
||||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera-slot[data-primary="true"] {
|
.camera-slot[data-primary="true"] {
|
||||||
|
|
@ -347,7 +345,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #0a0b0d;
|
background: #0a0b0d;
|
||||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-canvas__grid,
|
.map-canvas__grid,
|
||||||
|
|
@ -484,7 +482,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-ruler span {
|
.timeline-ruler span {
|
||||||
border-left: 1px solid var(--station-hairline);
|
|
||||||
padding-left: 0.2rem;
|
padding-left: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -498,7 +495,6 @@
|
||||||
grid-template-columns: 7.5rem minmax(0, 1fr) 10rem;
|
grid-template-columns: 7.5rem minmax(0, 1fr) 10rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
border-top: 1px solid var(--station-hairline);
|
|
||||||
padding: 0.82rem 0;
|
padding: 0.82rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,10 @@ function SpatialWorkspace({
|
||||||
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="spatial-workspace" data-focused={pointCloudFocused ? "true" : undefined}>
|
<div
|
||||||
|
className="spatial-workspace"
|
||||||
|
data-focused={pointCloudFocused || floatingSourceMaximized ? "true" : undefined}
|
||||||
|
>
|
||||||
<div className="spatial-toolbar">
|
<div className="spatial-toolbar">
|
||||||
<div className="spatial-toolbar__mode">
|
<div className="spatial-toolbar__mode">
|
||||||
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
||||||
|
|
@ -428,6 +431,7 @@ function SpatialWorkspace({
|
||||||
key={source.id}
|
key={source.id}
|
||||||
source={source}
|
source={source}
|
||||||
index={index}
|
index={index}
|
||||||
|
count={visibleMediaSources.length}
|
||||||
boundsRef={viewportRef}
|
boundsRef={viewportRef}
|
||||||
rect={observationLayout.windowRects[source.id]}
|
rect={observationLayout.windowRects[source.id]}
|
||||||
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
||||||
|
|
@ -436,7 +440,11 @@ function SpatialWorkspace({
|
||||||
onMaximizedChange={(maximized) =>
|
onMaximizedChange={(maximized) =>
|
||||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||||
onClose={() => void observationLayout.hideSource(source.id)}
|
onClose={() => {
|
||||||
|
if (observationLayout.pendingSourceIds.has(source.id)) return;
|
||||||
|
observationLayout.setFloatingMaximized(source.id, false);
|
||||||
|
void observationLayout.hideSource(source.id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)) : null}
|
)) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -461,6 +469,7 @@ function CameraSourceCard({
|
||||||
selectedPeer,
|
selectedPeer,
|
||||||
onToggle,
|
onToggle,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
|
|
@ -469,6 +478,7 @@ function CameraSourceCard({
|
||||||
selectedPeer: boolean;
|
selectedPeer: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const deliveryActive = Boolean(
|
const deliveryActive = Boolean(
|
||||||
(source.delivery || source.previewUrl) &&
|
(source.delivery || source.previewUrl) &&
|
||||||
|
|
@ -500,7 +510,7 @@ function CameraSourceCard({
|
||||||
<i data-availability={source.availability} aria-hidden="true" />
|
<i data-availability={source.availability} aria-hidden="true" />
|
||||||
{observationSourceStatusLabel(source)}
|
{observationSourceStatusLabel(source)}
|
||||||
</span>
|
</span>
|
||||||
{source.activation ? (
|
{source.activation && !focused ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="camera-slot__activation"
|
className="camera-slot__activation"
|
||||||
|
|
@ -510,7 +520,7 @@ function CameraSourceCard({
|
||||||
{activationLabel}
|
{activationLabel}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{source.capabilities.fullscreen && deliveryActive ? (
|
{source.capabilities.fullscreen && (focused || deliveryActive) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
|
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
|
||||||
|
|
@ -519,6 +529,16 @@ function CameraSourceCard({
|
||||||
<Icon name={focused ? "minimize" : "expand"} size={15} />
|
<Icon name={focused ? "minimize" : "expand"} size={15} />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{focused ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Закрыть ${source.label}`}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={15} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="camera-slot__body">
|
<div className="camera-slot__body">
|
||||||
|
|
@ -563,6 +583,10 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
||||||
onFocus={() => observationLayout.setFocusedSourceId(
|
onFocus={() => observationLayout.setFocusedSourceId(
|
||||||
focusedSource?.id === source.id ? null : source.id,
|
focusedSource?.id === source.id ? null : source.id,
|
||||||
)}
|
)}
|
||||||
|
onClose={() => {
|
||||||
|
observationLayout.setFocusedSourceId(null);
|
||||||
|
void observationLayout.hideSource(source.id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)) : (
|
)) : (
|
||||||
<div className="camera-grid__empty">
|
<div className="camera-grid__empty">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ let openObservationSource;
|
||||||
let shouldRestartObservationSource;
|
let shouldRestartObservationSource;
|
||||||
let consumeCameraLeaseRetry;
|
let consumeCameraLeaseRetry;
|
||||||
let resetCameraLeaseRetryBudget;
|
let resetCameraLeaseRetryBudget;
|
||||||
|
let initialObservationWindowRect;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
|
|
@ -29,6 +30,49 @@ before(async () => {
|
||||||
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
|
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
|
||||||
"/src/components/MseFmp4WebSocketPlayer.tsx",
|
"/src/components/MseFmp4WebSocketPlayer.tsx",
|
||||||
));
|
));
|
||||||
|
({ initialObservationWindowRect } = await server.ssrLoadModule(
|
||||||
|
"/src/components/FloatingObservationWindow.tsx",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||||
|
const bounds = { width: 1280, height: 720 };
|
||||||
|
const left = initialObservationWindowRect(0, 2, bounds);
|
||||||
|
const right = initialObservationWindowRect(1, 2, bounds);
|
||||||
|
|
||||||
|
assert.equal(left.y, right.y);
|
||||||
|
assert.ok(left.x + left.width < right.x);
|
||||||
|
assert.equal(right.x + right.width, bounds.width - 18);
|
||||||
|
assert.ok(right.y + right.height <= bounds.height - 64);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("observation camera windows stack without overlap when the viewport is narrow", () => {
|
||||||
|
const bounds = { width: 420, height: 700 };
|
||||||
|
const lower = initialObservationWindowRect(0, 2, bounds);
|
||||||
|
const upper = initialObservationWindowRect(1, 2, bounds);
|
||||||
|
|
||||||
|
assert.equal(lower.x, upper.x);
|
||||||
|
assert.ok(upper.y + upper.height < lower.y);
|
||||||
|
for (const rect of [lower, upper]) {
|
||||||
|
assert.ok(rect.x >= 0 && rect.y >= 0);
|
||||||
|
assert.ok(rect.x + rect.width <= bounds.width);
|
||||||
|
assert.ok(rect.y + rect.height <= bounds.height - 64);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("observation camera tiling remains in bounds on a constrained viewport", () => {
|
||||||
|
const bounds = { width: 260, height: 280 };
|
||||||
|
const rects = Array.from({ length: 2 }, (_, index) => (
|
||||||
|
initialObservationWindowRect(index, 2, bounds)
|
||||||
|
));
|
||||||
|
|
||||||
|
for (const rect of rects) {
|
||||||
|
assert.ok(rect.width > 0 && rect.height > 0);
|
||||||
|
assert.ok(rect.x >= 0 && rect.y >= 0);
|
||||||
|
assert.ok(rect.x + rect.width <= bounds.width);
|
||||||
|
assert.ok(rect.y + rect.height <= bounds.height - 64);
|
||||||
|
}
|
||||||
|
assert.ok(rects[1].y + rects[1].height <= rects[0].y);
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ class RerunBridge:
|
||||||
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||||
recording.log(
|
recording.log(
|
||||||
"/world/sensor_pose",
|
"/world/sensor_pose",
|
||||||
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
|
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
|
||||||
static=True,
|
static=True,
|
||||||
)
|
)
|
||||||
# Do not expose a URL whose StoreInfo, blueprint and static scene are
|
# Do not expose a URL whose StoreInfo, blueprint and static scene are
|
||||||
|
|
@ -142,7 +142,7 @@ class RerunBridge:
|
||||||
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||||
self._recording.log(
|
self._recording.log(
|
||||||
"/world/sensor_pose",
|
"/world/sensor_pose",
|
||||||
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
|
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
|
||||||
static=True,
|
static=True,
|
||||||
)
|
)
|
||||||
self._recording.send_blueprint(
|
self._recording.send_blueprint(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue