feat(observation): add dynamic camera sources
This commit is contained in:
parent
05bdab24a5
commit
a281faf923
|
|
@ -126,7 +126,9 @@ immutable donor revision remains a packaging and CI prerequisite.
|
||||||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||||
inside the Mission Core shell. It can open a compatible RRD file over HTTP(S) or a
|
inside the Mission Core shell. It can open a compatible RRD file over HTTP(S) or a
|
||||||
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
|
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
|
||||||
use an external hosted viewer UI.
|
use an external hosted viewer UI. Dynamic point-cloud and camera source
|
||||||
|
composition, host-owned window layout and the current live-only timeline contract
|
||||||
|
are fixed in [`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
|
||||||
|
|
||||||
The first K1 live session or replay in a `k1link serve` process creates one local
|
The first K1 live session or replay in a `k1link serve` process creates one local
|
||||||
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
|
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { LandingStage } from "./components/LandingStage";
|
||||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||||
|
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
||||||
import {
|
import {
|
||||||
rootById,
|
rootById,
|
||||||
roots,
|
roots,
|
||||||
|
|
@ -132,6 +133,7 @@ export default function App() {
|
||||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||||
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
|
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
|
||||||
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
||||||
|
const observationLayout = useObservationLayout(runtime.state?.observationSources ?? []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remote = runtime.state?.viewerSettings;
|
const remote = runtime.state?.viewerSettings;
|
||||||
|
|
@ -238,7 +240,7 @@ export default function App() {
|
||||||
|
|
||||||
if (activeDefinition?.kind === "spatial") {
|
if (activeDefinition?.kind === "spatial") {
|
||||||
actions.push(
|
actions.push(
|
||||||
{ label: "Настроить источник", icon: "network", onClick: openSource },
|
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource },
|
||||||
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
||||||
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
||||||
);
|
);
|
||||||
|
|
@ -370,6 +372,7 @@ export default function App() {
|
||||||
backendStatus={runtime.backendStatus}
|
backendStatus={runtime.backendStatus}
|
||||||
sourceUrl={effectiveSourceUrl}
|
sourceUrl={effectiveSourceUrl}
|
||||||
sceneSettings={sceneSettings}
|
sceneSettings={sceneSettings}
|
||||||
|
observationLayout={observationLayout}
|
||||||
navigation={{
|
navigation={{
|
||||||
openView,
|
openView,
|
||||||
openSource,
|
openSource,
|
||||||
|
|
@ -384,8 +387,8 @@ export default function App() {
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
open={sourceWindowOpen}
|
open={sourceWindowOpen}
|
||||||
title="Источники"
|
title="Визуальный движок"
|
||||||
subtitle="Потоки и записи пространственной сцены"
|
subtitle="Rerun gRPC и записи пространственной сцены"
|
||||||
placement="end"
|
placement="end"
|
||||||
draggable
|
draggable
|
||||||
closeOnBackdrop={false}
|
closeOnBackdrop={false}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||||
|
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||||
|
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
||||||
|
|
||||||
|
export function initialObservationWindowRect(index: number): ObservationWindowRect {
|
||||||
|
return {
|
||||||
|
x: 18 + (index % 3) * 354,
|
||||||
|
y: 78 + Math.floor(index / 3) * 226,
|
||||||
|
width: 336,
|
||||||
|
height: 210,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingObservationWindow({
|
||||||
|
source,
|
||||||
|
index,
|
||||||
|
boundsRef,
|
||||||
|
rect,
|
||||||
|
maximized,
|
||||||
|
active,
|
||||||
|
onRectChange,
|
||||||
|
onMaximizedChange,
|
||||||
|
onActivate,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
source: ObservationSourceDescriptor;
|
||||||
|
index: number;
|
||||||
|
boundsRef: RefObject<HTMLElement | null>;
|
||||||
|
rect?: ObservationWindowRect;
|
||||||
|
maximized: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onRectChange: (rect: ObservationWindowRect) => void;
|
||||||
|
onMaximizedChange: (maximized: boolean) => void;
|
||||||
|
onActivate: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WorkspaceWindow
|
||||||
|
boundsRef={boundsRef}
|
||||||
|
rect={rect ?? initialObservationWindowRect(index)}
|
||||||
|
onRectChange={onRectChange}
|
||||||
|
maximized={maximized}
|
||||||
|
onMaximizedChange={onMaximizedChange}
|
||||||
|
onActivate={onActivate}
|
||||||
|
onClose={onClose}
|
||||||
|
title={source.label}
|
||||||
|
subtitle={source.description}
|
||||||
|
status={(
|
||||||
|
<span className="floating-observation-window__status">
|
||||||
|
<i data-availability={source.availability} aria-hidden="true" />
|
||||||
|
{observationSourceStatusLabel(source)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
footer={(
|
||||||
|
<span className="floating-observation-window__footer">
|
||||||
|
<span>{source.endpointLabel || source.transport}</span>
|
||||||
|
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
minWidth={280}
|
||||||
|
minHeight={190}
|
||||||
|
resizable={source.capabilities.resizable}
|
||||||
|
active={active}
|
||||||
|
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||||
|
className="floating-observation-window"
|
||||||
|
closeLabel={`Закрыть ${source.label}`}
|
||||||
|
maximizeLabel={`Развернуть ${source.label}`}
|
||||||
|
restoreLabel={`Восстановить ${source.label}`}
|
||||||
|
moveLabel={`Переместить ${source.label}`}
|
||||||
|
resizeLabel={`Изменить размер ${source.label}`}
|
||||||
|
>
|
||||||
|
<ObservationMedia source={source} />
|
||||||
|
</WorkspaceWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ObservationSourceAvailability,
|
||||||
|
ObservationSourceDescriptor,
|
||||||
|
ObservationSourceModality,
|
||||||
|
} from "../core/runtime/contracts";
|
||||||
|
|
||||||
|
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||||
|
"point-cloud": "globe",
|
||||||
|
video: "video",
|
||||||
|
image: "image",
|
||||||
|
depth: "image",
|
||||||
|
};
|
||||||
|
|
||||||
|
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
||||||
|
unverified: "Не подтверждён",
|
||||||
|
declared: "Канал объявлен",
|
||||||
|
available: "Доступен",
|
||||||
|
connecting: "Подключение",
|
||||||
|
streaming: "Эфир",
|
||||||
|
degraded: "Нестабильно",
|
||||||
|
unavailable: "Недоступен",
|
||||||
|
error: "Ошибка",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
||||||
|
return availabilityCopy[source.availability];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
|
||||||
|
if (source.previewUrl && source.modality === "video") {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
className="observation-media__asset"
|
||||||
|
src={source.previewUrl}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.previewUrl && (source.modality === "image" || source.modality === "depth")) {
|
||||||
|
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="observation-media__empty">
|
||||||
|
<Icon name={sourceIcon[source.modality]} size={20} />
|
||||||
|
<strong>{observationSourceStatusLabel(source)}</strong>
|
||||||
|
<span>
|
||||||
|
{source.modality === "video"
|
||||||
|
? "Канал известен, browser-preview ещё не подключён"
|
||||||
|
: source.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObservationSourcePicker({
|
||||||
|
sources,
|
||||||
|
visibleSourceIds,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
sources: readonly ObservationSourceDescriptor[];
|
||||||
|
visibleSourceIds: ReadonlySet<string>;
|
||||||
|
onToggle: (sourceId: string) => void;
|
||||||
|
}) {
|
||||||
|
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
className="scene-source-picker"
|
||||||
|
placement="bottom-start"
|
||||||
|
width={340}
|
||||||
|
offset={10}
|
||||||
|
surfaceRole="dialog"
|
||||||
|
surfaceClassName="observation-source-menu"
|
||||||
|
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||||
|
<button
|
||||||
|
ref={(node) => {
|
||||||
|
setAnchorRef(node);
|
||||||
|
setTriggerRef(node);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className="scene-source-picker__trigger"
|
||||||
|
data-active={open || visibleCount > 0 ? "true" : undefined}
|
||||||
|
aria-label="Источники данных сцены"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={surfaceId}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<Icon name="database" size={17} />
|
||||||
|
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="observation-source-menu__head">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
||||||
|
<strong>Источники данных</strong>
|
||||||
|
</div>
|
||||||
|
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
||||||
|
</header>
|
||||||
|
{sources.length ? (
|
||||||
|
<div className="observation-source-menu__list">
|
||||||
|
{sources.map((source) => {
|
||||||
|
const selected = visibleSourceIds.has(source.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={source.id}
|
||||||
|
type="button"
|
||||||
|
className="nodedc-dropdown-option observation-source-option"
|
||||||
|
data-selected={selected ? "true" : undefined}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => onToggle(source.id)}
|
||||||
|
>
|
||||||
|
<span className="nodedc-dropdown-option__icon">
|
||||||
|
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="nodedc-dropdown-option__body">
|
||||||
|
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
||||||
|
<span className="nodedc-dropdown-option__description">
|
||||||
|
<i data-availability={source.availability} aria-hidden="true" />
|
||||||
|
{observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="nodedc-dropdown-option__check">
|
||||||
|
{selected ? <Icon name="check" size={15} /> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="observation-source-menu__empty">
|
||||||
|
<Icon name="database" size={18} />
|
||||||
|
<strong>Каталог пока пуст</strong>
|
||||||
|
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<footer className="observation-source-menu__foot">
|
||||||
|
Каналы приходят из активного device-плагина; сцена не знает модель оборудования.
|
||||||
|
</footer>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Button, Icon } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||||
|
|
||||||
|
export function ObservationTimeline({
|
||||||
|
active,
|
||||||
|
sourceCount,
|
||||||
|
mode = "live-only",
|
||||||
|
seekable = false,
|
||||||
|
synchronization = "host-arrival-best-effort",
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
sourceCount: number;
|
||||||
|
mode?: ObservationTimelineMode;
|
||||||
|
seekable?: boolean;
|
||||||
|
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const buffered = seekable && (mode === "buffered" || mode === "recorded");
|
||||||
|
const synchronizationLabel = {
|
||||||
|
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||||
|
"shared-clock": "Общие часы",
|
||||||
|
"frame-accurate": "Покадровая синхронизация",
|
||||||
|
}[synchronization];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`observation-timeline ${className}`.trim()}
|
||||||
|
data-active={active ? "true" : undefined}
|
||||||
|
data-mode={mode}
|
||||||
|
>
|
||||||
|
<Button size="compact" variant="ghost" disabled aria-label="Перейти к началу">
|
||||||
|
<Icon name="chevron-left" />
|
||||||
|
</Button>
|
||||||
|
<Button size="compact" variant="secondary" disabled>
|
||||||
|
{buffered ? "Воспроизвести" : "Только эфир"}
|
||||||
|
</Button>
|
||||||
|
<div className="observation-timeline__track" data-disabled={!buffered ? "true" : undefined}>
|
||||||
|
<span style={{ width: active ? "100%" : "0%" }} />
|
||||||
|
</div>
|
||||||
|
<div className="observation-timeline__meta">
|
||||||
|
<code>{active ? "LIVE" : "—:—:—.———"}</code>
|
||||||
|
<small>
|
||||||
|
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<span className="observation-timeline__follow" data-active={active ? "true" : undefined}>
|
||||||
|
ЭФИР
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||||
|
|
||||||
|
export interface ObservationWindowRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationLayoutController {
|
||||||
|
visibleSourceIds: ReadonlySet<string>;
|
||||||
|
focusedSourceId: string | null;
|
||||||
|
activeFloatingSourceId: string | null;
|
||||||
|
maximizedFloatingSourceId: string | null;
|
||||||
|
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||||
|
toggleSource: (sourceId: string) => void;
|
||||||
|
hideSource: (sourceId: string) => void;
|
||||||
|
setFocusedSourceId: (sourceId: string | null) => void;
|
||||||
|
activateFloatingSource: (sourceId: string) => void;
|
||||||
|
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||||
|
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||||
|
if (!source.capabilities.defaultVisible) return false;
|
||||||
|
if (source.availability === "unavailable" || source.availability === "error") return false;
|
||||||
|
return source.modality === "point-cloud" || Boolean(source.previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
||||||
|
return sources
|
||||||
|
.map((source) => [
|
||||||
|
source.id,
|
||||||
|
source.binding.deviceSessionId,
|
||||||
|
source.binding.deviceId,
|
||||||
|
].filter(Boolean).join(":"))
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObservationLayout(
|
||||||
|
sources: readonly ObservationSourceDescriptor[],
|
||||||
|
): ObservationLayoutController {
|
||||||
|
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
||||||
|
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
||||||
|
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
|
||||||
|
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
|
||||||
|
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
|
||||||
|
const initializedCatalog = useRef<string | null>(null);
|
||||||
|
const sourceIdList = sources.map((source) => source.id).sort();
|
||||||
|
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||||
|
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
|
||||||
|
const identity = catalogIdentity(sources);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleIds((current) => {
|
||||||
|
const next = current.filter((sourceId) => sourceIds.has(sourceId));
|
||||||
|
return next.length === current.length ? current : next;
|
||||||
|
});
|
||||||
|
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
||||||
|
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||||
|
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||||
|
setWindowRects((current) => {
|
||||||
|
const entries = Object.entries(current);
|
||||||
|
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||||
|
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
|
||||||
|
});
|
||||||
|
}, [sourceIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!identity) {
|
||||||
|
initializedCatalog.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initializedCatalog.current === identity) return;
|
||||||
|
initializedCatalog.current = identity;
|
||||||
|
const defaults = sources
|
||||||
|
.filter(canOpenByDefault)
|
||||||
|
.map((source) => source.id);
|
||||||
|
setVisibleIds(defaults);
|
||||||
|
const firstFloating = sources.find(
|
||||||
|
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
||||||
|
);
|
||||||
|
setActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||||
|
}, [identity, sources]);
|
||||||
|
|
||||||
|
const toggleSource = useCallback((sourceId: string) => {
|
||||||
|
const selected = visibleIds.includes(sourceId);
|
||||||
|
setVisibleIds((current) => selected
|
||||||
|
? current.filter((candidate) => candidate !== sourceId)
|
||||||
|
: current.includes(sourceId) ? current : [...current, sourceId]);
|
||||||
|
if (!selected) setActiveFloatingSourceId(sourceId);
|
||||||
|
}, [visibleIds]);
|
||||||
|
|
||||||
|
const hideSource = useCallback((sourceId: string) => {
|
||||||
|
setVisibleIds((current) => current.filter((candidate) => candidate !== sourceId));
|
||||||
|
setFocusedSourceIdState((current) => current === sourceId ? null : current);
|
||||||
|
setActiveFloatingSourceId((current) => current === sourceId ? null : current);
|
||||||
|
setMaximizedFloatingSourceId((current) => current === sourceId ? null : current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFocusedSourceId = useCallback((sourceId: string | null) => {
|
||||||
|
setFocusedSourceIdState(sourceId);
|
||||||
|
if (sourceId) setActiveFloatingSourceId(sourceId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activateFloatingSource = useCallback((sourceId: string) => {
|
||||||
|
setActiveFloatingSourceId(sourceId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
|
||||||
|
setMaximizedFloatingSourceId(maximized ? sourceId : null);
|
||||||
|
if (maximized) setActiveFloatingSourceId(sourceId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
|
||||||
|
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleSourceIds,
|
||||||
|
focusedSourceId,
|
||||||
|
activeFloatingSourceId,
|
||||||
|
maximizedFloatingSourceId,
|
||||||
|
windowRects,
|
||||||
|
toggleSource,
|
||||||
|
hideSource,
|
||||||
|
setFocusedSourceId,
|
||||||
|
activateFloatingSource,
|
||||||
|
setFloatingMaximized,
|
||||||
|
setWindowRect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,70 @@ export interface SpatialSourceDescriptor {
|
||||||
kind: "rerun-grpc" | "rrd" | "other";
|
kind: "rerun-grpc" | "rrd" | "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ObservationSourceModality = "point-cloud" | "video" | "image" | "depth";
|
||||||
|
|
||||||
|
export type ObservationSourceAvailability =
|
||||||
|
| "unverified"
|
||||||
|
| "declared"
|
||||||
|
| "available"
|
||||||
|
| "connecting"
|
||||||
|
| "streaming"
|
||||||
|
| "degraded"
|
||||||
|
| "unavailable"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
||||||
|
|
||||||
|
export interface ObservationSourceProvider {
|
||||||
|
pluginId: string;
|
||||||
|
pluginVersion: string;
|
||||||
|
modelId: string;
|
||||||
|
compatibilityProfileId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationSourceBinding {
|
||||||
|
deviceId?: string | null;
|
||||||
|
deviceSessionId?: string | null;
|
||||||
|
acquisitionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationSourceCapabilities {
|
||||||
|
overlay: boolean;
|
||||||
|
fullscreen: boolean;
|
||||||
|
resizable: boolean;
|
||||||
|
defaultVisible: boolean;
|
||||||
|
timelineMode: ObservationTimelineMode;
|
||||||
|
seekable: boolean;
|
||||||
|
sessionRecording: boolean;
|
||||||
|
clockId?: string | null;
|
||||||
|
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationSourceDescriptor {
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
semanticChannelId: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
modality: ObservationSourceModality;
|
||||||
|
role: "primary" | "auxiliary";
|
||||||
|
availability: ObservationSourceAvailability;
|
||||||
|
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
|
||||||
|
endpointLabel?: string | null;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
provider: ObservationSourceProvider;
|
||||||
|
binding: ObservationSourceBinding;
|
||||||
|
capabilities: ObservationSourceCapabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationTimelineSnapshot {
|
||||||
|
mode: ObservationTimelineMode;
|
||||||
|
seekable: boolean;
|
||||||
|
sessionRecording: boolean;
|
||||||
|
synchronization: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||||
|
range: { startSeconds: number; endSeconds: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MissionRuntimeState {
|
export interface MissionRuntimeState {
|
||||||
phase: RuntimePhase;
|
phase: RuntimePhase;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
|
|
@ -80,6 +144,8 @@ export interface MissionRuntimeState {
|
||||||
acquisition?: RuntimeAcquisitionSnapshot | null;
|
acquisition?: RuntimeAcquisitionSnapshot | null;
|
||||||
operations?: readonly RuntimeOperationSnapshot[];
|
operations?: readonly RuntimeOperationSnapshot[];
|
||||||
spatialSource?: SpatialSourceDescriptor | null;
|
spatialSource?: SpatialSourceDescriptor | null;
|
||||||
|
observationSources?: readonly ObservationSourceDescriptor[];
|
||||||
|
observationTimeline?: ObservationTimelineSnapshot;
|
||||||
viewerSettings?: ViewerSettings | null;
|
viewerSettings?: ViewerSettings | null;
|
||||||
sourceMode: SourceMode;
|
sourceMode: SourceMode;
|
||||||
metrics?: StreamMetrics;
|
metrics?: StreamMetrics;
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,22 @@ export interface XgridsK1Metrics {
|
||||||
[key: string]: number | null | undefined;
|
[key: string]: number | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface XgridsSensorCatalogStream {
|
||||||
|
stream_id: string;
|
||||||
|
sensor_kind?: string | null;
|
||||||
|
modality?: string | null;
|
||||||
|
availability?: string | null;
|
||||||
|
decode_status?: string | null;
|
||||||
|
frame_id?: string | null;
|
||||||
|
coordinate_convention?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XgridsSensorCatalog {
|
||||||
|
schema_version?: string | null;
|
||||||
|
revision?: string | null;
|
||||||
|
streams?: XgridsSensorCatalogStream[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface XgridsK1State {
|
export interface XgridsK1State {
|
||||||
contract_version?: string | null;
|
contract_version?: string | null;
|
||||||
phase?: string | null;
|
phase?: string | null;
|
||||||
|
|
@ -141,6 +157,7 @@ export interface XgridsK1State {
|
||||||
acquisition?: XgridsAcquisition | null;
|
acquisition?: XgridsAcquisition | null;
|
||||||
operations?: XgridsOperation[];
|
operations?: XgridsOperation[];
|
||||||
last_operation?: XgridsOperation | null;
|
last_operation?: XgridsOperation | null;
|
||||||
|
sensor_catalog?: XgridsSensorCatalog | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
|
||||||
|
import type {
|
||||||
|
ObservationSourceAvailability,
|
||||||
|
ObservationSourceDescriptor,
|
||||||
|
ObservationSourceProvider,
|
||||||
|
} from "../../core/runtime/contracts";
|
||||||
|
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
|
||||||
|
import { xgridsK1Manifest } from "./manifest";
|
||||||
|
import type { XgridsK1State } from "./api";
|
||||||
|
|
||||||
|
function providerFor(
|
||||||
|
state: XgridsK1State,
|
||||||
|
activeModel: DeviceModelDefinition,
|
||||||
|
): ObservationSourceProvider {
|
||||||
|
return {
|
||||||
|
pluginId: xgridsK1Manifest.metadata.id,
|
||||||
|
pluginVersion: xgridsK1Manifest.metadata.version,
|
||||||
|
modelId: state.device_ref?.model_id || activeModel.id,
|
||||||
|
compatibilityProfileId:
|
||||||
|
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingFor(state: XgridsK1State) {
|
||||||
|
const acquisition = effectiveAcquisition(state);
|
||||||
|
return {
|
||||||
|
deviceId: state.device_ref?.device_id ?? null,
|
||||||
|
deviceSessionId: state.device_session?.device_session_id ?? null,
|
||||||
|
acquisitionId: acquisition?.acquisition_id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogDeclares(state: XgridsK1State, streamId: string): boolean {
|
||||||
|
return Boolean(state.sensor_catalog?.streams?.some((stream) => stream.stream_id === streamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function spatialAvailability(state: XgridsK1State): ObservationSourceAvailability {
|
||||||
|
const mode = confirmedRuntimeSourceMode(state);
|
||||||
|
if (mode !== "idle" && state.rerun_grpc_url?.trim()) return "streaming";
|
||||||
|
if (state.rerun_grpc_url?.trim()) return "available";
|
||||||
|
if (state.device_session?.connectivity === "degraded") return "degraded";
|
||||||
|
if (state.device_session?.connectivity === "connected") return "available";
|
||||||
|
return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cameraAvailability(state: XgridsK1State): ObservationSourceAvailability {
|
||||||
|
const stream = state.sensor_catalog?.streams?.find(
|
||||||
|
(candidate) => candidate.stream_id === "camera.preview.live",
|
||||||
|
);
|
||||||
|
const compatibilityProfileId =
|
||||||
|
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null;
|
||||||
|
if (!stream) return "unavailable";
|
||||||
|
if (stream.availability !== "observed" || !compatibilityProfileId) return "unverified";
|
||||||
|
if (state.device_session?.connectivity === "degraded") return "degraded";
|
||||||
|
// The firmware profile proves that both RTSP channels exist, but Mission Core
|
||||||
|
// does not yet expose a browser-decodable preview URL. Do not report a live
|
||||||
|
// camera merely because the point-cloud acquisition is running.
|
||||||
|
return "declared";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xgridsK1ObservationSources(
|
||||||
|
state: XgridsK1State,
|
||||||
|
activeModel: DeviceModelDefinition,
|
||||||
|
): ObservationSourceDescriptor[] {
|
||||||
|
const provider = providerFor(state, activeModel);
|
||||||
|
const binding = bindingFor(state);
|
||||||
|
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
|
||||||
|
const descriptorId = (sourceId: string) =>
|
||||||
|
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: descriptorId("sensor.lidar.primary"),
|
||||||
|
sourceId: "sensor.lidar.primary",
|
||||||
|
semanticChannelId: "spatial.point-cloud.live",
|
||||||
|
label: "K1 · облако точек",
|
||||||
|
description: "Облако точек, поза и траектория в общей 3D-сцене",
|
||||||
|
modality: "point-cloud",
|
||||||
|
role: "primary",
|
||||||
|
availability: spatialAvailability(state),
|
||||||
|
transport: "rerun-grpc",
|
||||||
|
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
|
||||||
|
previewUrl: state.rerun_grpc_url?.trim() || null,
|
||||||
|
provider,
|
||||||
|
binding,
|
||||||
|
capabilities: {
|
||||||
|
overlay: false,
|
||||||
|
fullscreen: true,
|
||||||
|
resizable: false,
|
||||||
|
defaultVisible: true,
|
||||||
|
timelineMode: "live-only",
|
||||||
|
// Rerun can hold replay data, but the Mission Core host timeline is not
|
||||||
|
// wired to its time controller yet.
|
||||||
|
seekable: false,
|
||||||
|
sessionRecording: false,
|
||||||
|
clockId,
|
||||||
|
spatialRegistration: "native",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: descriptorId("sensor.camera.left"),
|
||||||
|
sourceId: "sensor.camera.left",
|
||||||
|
semanticChannelId: "camera.preview.live",
|
||||||
|
label: "K1 · камера слева",
|
||||||
|
description: "Левый H.264 preview-канал профиля устройства",
|
||||||
|
modality: "video",
|
||||||
|
role: "auxiliary",
|
||||||
|
availability: cameraAvailability(state),
|
||||||
|
transport: "rtsp",
|
||||||
|
endpointLabel: "RTSP · left",
|
||||||
|
previewUrl: null,
|
||||||
|
provider,
|
||||||
|
binding,
|
||||||
|
capabilities: {
|
||||||
|
overlay: true,
|
||||||
|
fullscreen: true,
|
||||||
|
resizable: true,
|
||||||
|
defaultVisible: false,
|
||||||
|
timelineMode: "live-only",
|
||||||
|
seekable: false,
|
||||||
|
sessionRecording: false,
|
||||||
|
clockId,
|
||||||
|
spatialRegistration: "unresolved",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: descriptorId("sensor.camera.right"),
|
||||||
|
sourceId: "sensor.camera.right",
|
||||||
|
semanticChannelId: "camera.preview.live",
|
||||||
|
label: "K1 · камера справа",
|
||||||
|
description: "Правый H.264 preview-канал профиля устройства",
|
||||||
|
modality: "video",
|
||||||
|
role: "auxiliary",
|
||||||
|
availability: cameraAvailability(state),
|
||||||
|
transport: "rtsp",
|
||||||
|
endpointLabel: "RTSP · right",
|
||||||
|
previewUrl: null,
|
||||||
|
provider,
|
||||||
|
binding,
|
||||||
|
capabilities: {
|
||||||
|
overlay: true,
|
||||||
|
fullscreen: true,
|
||||||
|
resizable: true,
|
||||||
|
defaultVisible: false,
|
||||||
|
timelineMode: "live-only",
|
||||||
|
seekable: false,
|
||||||
|
sessionRecording: false,
|
||||||
|
clockId,
|
||||||
|
spatialRegistration: "unresolved",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { localizeRuntimeMessage } from "./messages";
|
import { localizeRuntimeMessage } from "./messages";
|
||||||
import { xgridsK1Manifest } from "./manifest";
|
import { xgridsK1Manifest } from "./manifest";
|
||||||
import { finiteMetric, pipelineLatency } from "./presentation";
|
import { finiteMetric, pipelineLatency } from "./presentation";
|
||||||
|
import { xgridsK1ObservationSources } from "./observationSources";
|
||||||
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
|
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
|
||||||
|
|
||||||
export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
|
export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
|
||||||
|
|
@ -90,6 +91,16 @@ function normalizeState(
|
||||||
kind: "rerun-grpc",
|
kind: "rerun-grpc",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
observationSources: xgridsK1ObservationSources(state, activeModel),
|
||||||
|
observationTimeline: {
|
||||||
|
// This is the shared host timeline. A replayable Rerun source alone does
|
||||||
|
// not make camera and point-cloud time jointly seekable.
|
||||||
|
mode: "live-only",
|
||||||
|
seekable: false,
|
||||||
|
sessionRecording: false,
|
||||||
|
synchronization: "host-arrival-best-effort",
|
||||||
|
range: null,
|
||||||
|
},
|
||||||
viewerSettings: state.viewer_settings,
|
viewerSettings: state.viewer_settings,
|
||||||
sourceMode: confirmedRuntimeSourceMode(state),
|
sourceMode: confirmedRuntimeSourceMode(state),
|
||||||
metrics: {
|
metrics: {
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@
|
||||||
@import "./styles/spatial.css";
|
@import "./styles/spatial.css";
|
||||||
@import "./styles/device.css";
|
@import "./styles/device.css";
|
||||||
@import "./styles/responsive.css";
|
@import "./styles/responsive.css";
|
||||||
|
@import "./styles/observation.css";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,451 @@
|
||||||
|
.scene-source-controls {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
top: 0.85rem;
|
||||||
|
left: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-source-picker__trigger,
|
||||||
|
.scene-source-control,
|
||||||
|
.scene-focus-exit {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.09);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(12 13 16 / 0.82);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-source-picker__trigger {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-source-picker__trigger:hover,
|
||||||
|
.scene-source-control:hover,
|
||||||
|
.scene-focus-exit:hover,
|
||||||
|
.scene-source-picker__trigger[data-active="true"] {
|
||||||
|
border-color: rgb(255 255 255 / 0.18);
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-source-picker__trigger > span {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.2rem;
|
||||||
|
right: -0.2rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--nodedc-text-primary);
|
||||||
|
color: #090a0c;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-focus-exit {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 0.85rem;
|
||||||
|
right: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status--top-left {
|
||||||
|
left: 7.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status[aria-hidden="true"],
|
||||||
|
.scene-metrics[aria-hidden="true"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head,
|
||||||
|
.observation-source-menu__foot {
|
||||||
|
padding: 0.85rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border-bottom: 1px solid var(--station-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head strong,
|
||||||
|
.observation-source-menu__head span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head strong {
|
||||||
|
margin-top: 0.34rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head small,
|
||||||
|
.observation-source-menu__foot {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__list {
|
||||||
|
display: grid;
|
||||||
|
max-height: min(28rem, 60vh);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-option .nodedc-dropdown-option__description {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-option i,
|
||||||
|
.floating-observation-window i,
|
||||||
|
.camera-slot__status i {
|
||||||
|
width: 0.42rem;
|
||||||
|
height: 0.42rem;
|
||||||
|
flex: 0 0 0.42rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
i[data-availability="available"],
|
||||||
|
i[data-availability="streaming"] {
|
||||||
|
background: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
i[data-availability="connecting"],
|
||||||
|
i[data-availability="declared"] {
|
||||||
|
background: rgb(var(--nodedc-warning-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
i[data-availability="unverified"] {
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
i[data-availability="degraded"],
|
||||||
|
i[data-availability="error"] {
|
||||||
|
background: rgb(var(--nodedc-danger-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__empty,
|
||||||
|
.camera-grid__empty,
|
||||||
|
.observation-media__empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__empty {
|
||||||
|
min-height: 10rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__empty strong,
|
||||||
|
.observation-media__empty strong,
|
||||||
|
.camera-grid__empty strong {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__empty span,
|
||||||
|
.observation-media__empty span,
|
||||||
|
.camera-grid__empty span {
|
||||||
|
max-width: 24rem;
|
||||||
|
font-size: 0.59rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__foot {
|
||||||
|
border-top: 1px solid var(--station-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-media__asset,
|
||||||
|
.observation-media__empty {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-media__asset {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #050608;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-media__empty {
|
||||||
|
min-height: 9rem;
|
||||||
|
background: #07080a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window__status,
|
||||||
|
.floating-observation-window__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__head-actions button {
|
||||||
|
display: grid;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 0.05);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__head-actions button:hover {
|
||||||
|
background: rgb(255 255 255 / 0.1);
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window__status {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window__footer {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window .nodedc-workspace-window__body {
|
||||||
|
background: #06070a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.055);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: rgb(9 10 13 / 0.78);
|
||||||
|
padding: 0.4rem;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__track {
|
||||||
|
height: 0.3rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__track[data-disabled="true"] {
|
||||||
|
opacity: 0.46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__meta {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__meta code,
|
||||||
|
.observation-timeline__meta small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.52rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.52rem;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow::before {
|
||||||
|
width: 0.4rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow[data-active="true"] {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow[data-active="true"]::before {
|
||||||
|
background: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-workspace[data-focused="true"] {
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
|
||||||
|
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameras-workspace {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-workspace__catalog {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.8rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
||||||
|
grid-template-rows: none;
|
||||||
|
grid-auto-rows: minmax(16rem, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid[data-count="1"] {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid__empty {
|
||||||
|
min-height: 18rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #07080a;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot header,
|
||||||
|
.camera-slot footer {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__head-actions,
|
||||||
|
.camera-slot__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__status {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot footer {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot footer span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot footer small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-timeline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameras-workspace[data-focused="true"] {
|
||||||
|
height: 100%;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameras-workspace[data-focused="true"] > .workspace-lead,
|
||||||
|
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameras-workspace[data-focused="true"] .camera-grid {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.scene-status--top-left {
|
||||||
|
top: 4.2rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline > .nodedc-button:first-child,
|
||||||
|
.observation-timeline__meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-auto-rows: minmax(14rem, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
|
|
@ -7,7 +7,19 @@ import {
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import type { BackendStatus, MissionRuntimeState } from "../core/runtime/contracts";
|
import {
|
||||||
|
ObservationMedia,
|
||||||
|
ObservationSourcePicker,
|
||||||
|
observationSourceStatusLabel,
|
||||||
|
} from "../components/ObservationSources";
|
||||||
|
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||||
|
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||||
|
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||||
|
import type {
|
||||||
|
BackendStatus,
|
||||||
|
MissionRuntimeState,
|
||||||
|
ObservationSourceDescriptor,
|
||||||
|
} from "../core/runtime/contracts";
|
||||||
import {
|
import {
|
||||||
RerunViewport,
|
RerunViewport,
|
||||||
type RerunSelection,
|
type RerunSelection,
|
||||||
|
|
@ -84,6 +96,7 @@ export interface WorkspaceRendererProps {
|
||||||
backendStatus: BackendStatus;
|
backendStatus: BackendStatus;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
sceneSettings: SceneSettings;
|
sceneSettings: SceneSettings;
|
||||||
|
observationLayout: ObservationLayoutController;
|
||||||
navigation: WorkspaceNavigation;
|
navigation: WorkspaceNavigation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,6 +249,7 @@ function SpatialWorkspace({
|
||||||
state,
|
state,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
sceneSettings,
|
sceneSettings,
|
||||||
|
observationLayout,
|
||||||
navigation,
|
navigation,
|
||||||
}: WorkspaceRendererProps) {
|
}: WorkspaceRendererProps) {
|
||||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||||
|
|
@ -246,6 +260,23 @@ function SpatialWorkspace({
|
||||||
const latency = pipelineLatency(metrics);
|
const latency = pipelineLatency(metrics);
|
||||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||||
const points = finiteMetric(metrics?.pointCount);
|
const points = finiteMetric(metrics?.pointCount);
|
||||||
|
const observationSources = state?.observationSources ?? [];
|
||||||
|
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
|
||||||
|
const pointCloudVisible = pointCloudSource
|
||||||
|
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
|
||||||
|
: Boolean(sourceUrl.trim());
|
||||||
|
const mediaSources = observationSources.filter(
|
||||||
|
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
|
||||||
|
);
|
||||||
|
const visibleMediaSources = mediaSources.filter((source) =>
|
||||||
|
observationLayout.visibleSourceIds.has(source.id),
|
||||||
|
);
|
||||||
|
const pointCloudFocused = Boolean(
|
||||||
|
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
|
||||||
|
);
|
||||||
|
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||||
|
const timeline = state?.observationTimeline;
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||||
setViewerStatus(status);
|
setViewerStatus(status);
|
||||||
|
|
@ -253,6 +284,13 @@ function SpatialWorkspace({
|
||||||
}, []);
|
}, []);
|
||||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||||
|
setViewerStatus("idle");
|
||||||
|
setViewerMessage("");
|
||||||
|
setSelection(null);
|
||||||
|
}, [pointCloudVisible, sourceUrl]);
|
||||||
|
|
||||||
const viewerStatusLabel = {
|
const viewerStatusLabel = {
|
||||||
idle: "Источник не назначен",
|
idle: "Источник не назначен",
|
||||||
loading: "Подключение",
|
loading: "Подключение",
|
||||||
|
|
@ -263,14 +301,14 @@ 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">
|
<div className="spatial-workspace" data-focused={pointCloudFocused ? "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>
|
||||||
</div>
|
</div>
|
||||||
<div className="spatial-toolbar__actions">
|
<div className="spatial-toolbar__actions">
|
||||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||||
Источник
|
Движок
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||||
Слои
|
Слои
|
||||||
|
|
@ -281,8 +319,13 @@ function SpatialWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="spatial-viewport-shell">
|
<div
|
||||||
{sourceUrl.trim() ? (
|
ref={viewportRef}
|
||||||
|
className="spatial-viewport-shell"
|
||||||
|
data-primary-focused={pointCloudFocused ? "true" : undefined}
|
||||||
|
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{sourceUrl.trim() && pointCloudVisible ? (
|
||||||
<RerunViewport
|
<RerunViewport
|
||||||
sourceUrl={sourceUrl}
|
sourceUrl={sourceUrl}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
|
|
@ -292,13 +335,49 @@ function SpatialWorkspace({
|
||||||
<EmptySpatialStage settings={sceneSettings} />
|
<EmptySpatialStage settings={sceneSettings} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="scene-status scene-status--top-left">
|
{pointCloudFocused ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="scene-focus-exit"
|
||||||
|
aria-label="Выйти из полноэкранного режима облака точек"
|
||||||
|
onClick={() => observationLayout.setFocusedSourceId(null)}
|
||||||
|
>
|
||||||
|
<Icon name="minimize" size={16} />
|
||||||
|
</button>
|
||||||
|
) : !floatingSourceMaximized ? (
|
||||||
|
<div className="scene-source-controls">
|
||||||
|
<ObservationSourcePicker
|
||||||
|
sources={observationSources}
|
||||||
|
visibleSourceIds={observationLayout.visibleSourceIds}
|
||||||
|
onToggle={observationLayout.toggleSource}
|
||||||
|
/>
|
||||||
|
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="scene-source-control"
|
||||||
|
aria-label="Развернуть облако точек"
|
||||||
|
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
|
||||||
|
>
|
||||||
|
<Icon name="expand" size={16} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="scene-status scene-status--top-left"
|
||||||
|
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||||
|
>
|
||||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
|
<div
|
||||||
|
className="scene-metrics"
|
||||||
|
aria-label="Метрики пространственной сцены"
|
||||||
|
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<span>КАДР/С</span>
|
<span>КАДР/С</span>
|
||||||
<strong>{formatNumber(frameRate)}</strong>
|
<strong>{formatNumber(frameRate)}</strong>
|
||||||
|
|
@ -313,7 +392,7 @@ function SpatialWorkspace({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||||
<div className="scene-adapter-note">
|
<div className="scene-adapter-note">
|
||||||
<Icon name="alert" />
|
<Icon name="alert" />
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -323,7 +402,7 @@ function SpatialWorkspace({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selection ? (
|
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
|
||||||
<div className="scene-selection">
|
<div className="scene-selection">
|
||||||
<span>Выбрано</span>
|
<span>Выбрано</span>
|
||||||
<strong>{selection.entityPath}</strong>
|
<strong>{selection.entityPath}</strong>
|
||||||
|
|
@ -331,21 +410,33 @@ function SpatialWorkspace({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="scene-timeline">
|
{!pointCloudFocused && !floatingSourceMaximized ? (
|
||||||
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
|
<ObservationTimeline
|
||||||
<Icon name="chevron-left" />
|
active={viewerStatus === "ready"}
|
||||||
</Button>
|
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
|
||||||
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
|
mode={timeline?.mode}
|
||||||
Воспроизвести
|
seekable={timeline?.seekable}
|
||||||
</Button>
|
synchronization={timeline?.synchronization}
|
||||||
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
|
className="scene-timeline"
|
||||||
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
|
/>
|
||||||
</div>
|
) : null}
|
||||||
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
|
|
||||||
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
|
{!pointCloudFocused ? visibleMediaSources.map((source, index) => (
|
||||||
ЭФИР
|
<FloatingObservationWindow
|
||||||
</span>
|
key={source.id}
|
||||||
</div>
|
source={source}
|
||||||
|
index={index}
|
||||||
|
boundsRef={viewportRef}
|
||||||
|
rect={observationLayout.windowRects[source.id]}
|
||||||
|
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
||||||
|
active={observationLayout.activeFloatingSourceId === source.id}
|
||||||
|
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
|
||||||
|
onMaximizedChange={(maximized) =>
|
||||||
|
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||||
|
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||||
|
onClose={() => observationLayout.hideSource(source.id)}
|
||||||
|
/>
|
||||||
|
)) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="spatial-contract-strip">
|
<div className="spatial-contract-strip">
|
||||||
|
|
@ -360,31 +451,85 @@ function SpatialWorkspace({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
|
function CameraSourceCard({
|
||||||
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
|
source,
|
||||||
|
focused,
|
||||||
|
onFocus,
|
||||||
|
}: {
|
||||||
|
source: ObservationSourceDescriptor;
|
||||||
|
focused: boolean;
|
||||||
|
onFocus: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="standard-workspace cameras-workspace">
|
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
||||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
<header>
|
||||||
<div className="camera-grid">
|
<span>{source.label}</span>
|
||||||
{slots.map((slot, index) => (
|
<div className="camera-slot__head-actions">
|
||||||
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
|
<span className="camera-slot__status">
|
||||||
<div className="camera-slot__grid" aria-hidden="true" />
|
<i data-availability={source.availability} aria-hidden="true" />
|
||||||
<header>
|
{observationSourceStatusLabel(source)}
|
||||||
<span>{slot}</span>
|
</span>
|
||||||
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
|
{source.capabilities.fullscreen ? (
|
||||||
</header>
|
<button
|
||||||
<div className="camera-slot__empty">
|
type="button"
|
||||||
<Icon name={index === 3 ? "image" : "video"} />
|
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
|
||||||
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
|
onClick={onFocus}
|
||||||
</div>
|
>
|
||||||
<footer>
|
<Icon name={focused ? "minimize" : "expand"} size={15} />
|
||||||
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
|
</button>
|
||||||
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
|
) : null}
|
||||||
</footer>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
))}
|
<div className="camera-slot__body">
|
||||||
|
<ObservationMedia source={source} />
|
||||||
</div>
|
</div>
|
||||||
<FeatureInventory definition={definition} />
|
<footer>
|
||||||
|
<span>{source.description}</span>
|
||||||
|
<small>{source.endpointLabel || source.transport}</small>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRendererProps) {
|
||||||
|
const sources = (state?.observationSources ?? []).filter(
|
||||||
|
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
|
||||||
|
);
|
||||||
|
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
|
||||||
|
const timeline = state?.observationTimeline;
|
||||||
|
const displayedSources = focusedSource ? [focusedSource] : sources;
|
||||||
|
return (
|
||||||
|
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||||
|
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||||
|
<div className="camera-workspace__catalog">
|
||||||
|
<span>{sources.length ? `${sources.length} визуальных каналов` : "Каталог источников пуст"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="camera-grid" data-count={displayedSources.length}>
|
||||||
|
{displayedSources.length ? displayedSources.map((source) => (
|
||||||
|
<CameraSourceCard
|
||||||
|
key={source.id}
|
||||||
|
source={source}
|
||||||
|
focused={focusedSource?.id === source.id}
|
||||||
|
onFocus={() => observationLayout.setFocusedSourceId(
|
||||||
|
focusedSource?.id === source.id ? null : source.id,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)) : (
|
||||||
|
<div className="camera-grid__empty">
|
||||||
|
<Icon name="video" size={22} />
|
||||||
|
<strong>Камеры не объявлены</strong>
|
||||||
|
<span>Выберите модель устройства — плагин опубликует доступные видеоканалы.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ObservationTimeline
|
||||||
|
active={sources.some((source) => source.availability === "streaming")}
|
||||||
|
sourceCount={sources.length}
|
||||||
|
mode={timeline?.mode}
|
||||||
|
seekable={timeline?.seekable}
|
||||||
|
synchronization={timeline?.synchronization}
|
||||||
|
className="camera-timeline"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let xgridsK1Manifest;
|
||||||
|
let xgridsK1ObservationSources;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({ xgridsK1Manifest } = await server.ssrLoadModule(
|
||||||
|
"/src/device-plugins/xgrids-k1/manifest.ts",
|
||||||
|
));
|
||||||
|
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
|
||||||
|
"/src/device-plugins/xgrids-k1/observationSources.ts",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function model() {
|
||||||
|
const activeModel = xgridsK1Manifest.spec.models[0];
|
||||||
|
assert.ok(activeModel, "XGRIDS plugin must declare at least one model");
|
||||||
|
return activeModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredState() {
|
||||||
|
const activeModel = model();
|
||||||
|
return {
|
||||||
|
phase: "connected",
|
||||||
|
source_mode: "idle",
|
||||||
|
k1_ip: "192.168.7.10",
|
||||||
|
foxglove_ws_url: "ws://192.168.7.10:8765",
|
||||||
|
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
|
||||||
|
compatibility: {
|
||||||
|
profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
||||||
|
camera_preview: "rtsp://192.168.7.10:8554/vendor-preview",
|
||||||
|
},
|
||||||
|
device_ref: {
|
||||||
|
device_id: "device-k1-001",
|
||||||
|
model_id: activeModel.id,
|
||||||
|
identity_stability: "stable",
|
||||||
|
identity_basis: "hardware-identifier",
|
||||||
|
},
|
||||||
|
device_session: {
|
||||||
|
device_session_id: "device-session-001",
|
||||||
|
device_id: "device-k1-001",
|
||||||
|
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
||||||
|
connectivity: "connected",
|
||||||
|
},
|
||||||
|
sensor_catalog: {
|
||||||
|
schema_version: "missioncore.sensor-catalog/v1alpha2",
|
||||||
|
revision: "test-profile",
|
||||||
|
streams: [
|
||||||
|
{ stream_id: "spatial.point-cloud.live", modality: "point-cloud", availability: "observed" },
|
||||||
|
{ stream_id: "camera.preview.live", modality: "encoded-video", availability: "observed" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamingState() {
|
||||||
|
return {
|
||||||
|
...declaredState(),
|
||||||
|
phase: "streaming",
|
||||||
|
source_mode: "live",
|
||||||
|
rerun_grpc_url: "rerun+http://127.0.0.1:9877/proxy",
|
||||||
|
acquisition: {
|
||||||
|
acquisition_id: "acquisition-001",
|
||||||
|
device_id: "device-k1-001",
|
||||||
|
device_session_id: "device-session-001",
|
||||||
|
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
||||||
|
control_mode: "operator-manual",
|
||||||
|
requested_streams: ["spatial.point-cloud.live"],
|
||||||
|
target_host: "127.0.0.1",
|
||||||
|
duration_seconds: 0,
|
||||||
|
evidence_policy: "required",
|
||||||
|
state: "acquiring",
|
||||||
|
state_revision: 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUrlLikeStrings(value, found = []) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value.includes("://")) found.push(value);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) collectUrlLikeStrings(item, found);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
for (const item of Object.values(value)) collectUrlLikeStrings(item, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("K1 maps to one primary point cloud and two auxiliary camera channels", () => {
|
||||||
|
const sources = xgridsK1ObservationSources(declaredState(), model());
|
||||||
|
|
||||||
|
assert.equal(sources.length, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
sources.map(({ sourceId, semanticChannelId, modality, role }) => ({
|
||||||
|
sourceId,
|
||||||
|
semanticChannelId,
|
||||||
|
modality,
|
||||||
|
role,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sourceId: "sensor.lidar.primary",
|
||||||
|
semanticChannelId: "spatial.point-cloud.live",
|
||||||
|
modality: "point-cloud",
|
||||||
|
role: "primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceId: "sensor.camera.left",
|
||||||
|
semanticChannelId: "camera.preview.live",
|
||||||
|
modality: "video",
|
||||||
|
role: "auxiliary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceId: "sensor.camera.right",
|
||||||
|
semanticChannelId: "camera.preview.live",
|
||||||
|
modality: "video",
|
||||||
|
role: "auxiliary",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(new Set(sources.map(({ id }) => id)).size, sources.length);
|
||||||
|
assert.ok(sources.every(({ provider }) => provider.pluginId && provider.modelId));
|
||||||
|
assert.ok(sources.every(({ provider }) => provider.pluginVersion));
|
||||||
|
assert.ok(sources.every(({ binding }) => binding.deviceId === "device-k1-001"));
|
||||||
|
assert.ok(sources.every(({ capabilities }) => capabilities.timelineMode === "live-only"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("observation source ids remain stable while runtime availability changes", () => {
|
||||||
|
const declared = xgridsK1ObservationSources(declaredState(), model());
|
||||||
|
const streaming = xgridsK1ObservationSources(streamingState(), model());
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
streaming.map(({ id }) => id),
|
||||||
|
declared.map(({ id }) => id),
|
||||||
|
);
|
||||||
|
assert.equal(declared[0].availability, "available");
|
||||||
|
assert.equal(streaming[0].availability, "streaming");
|
||||||
|
assert.equal(streaming[0].previewUrl, "rerun+http://127.0.0.1:9877/proxy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("camera descriptors stay declared and URL-free until a browser delivery adapter exists", () => {
|
||||||
|
const sources = xgridsK1ObservationSources(streamingState(), model());
|
||||||
|
const cameras = sources.filter(({ modality }) => modality === "video");
|
||||||
|
|
||||||
|
assert.equal(cameras.length, 2);
|
||||||
|
for (const camera of cameras) {
|
||||||
|
assert.equal(camera.availability, "declared");
|
||||||
|
assert.equal(camera.transport, "rtsp");
|
||||||
|
assert.equal(camera.previewUrl, null);
|
||||||
|
assert.equal(camera.capabilities.spatialRegistration, "unresolved");
|
||||||
|
assert.equal(camera.capabilities.resizable, true);
|
||||||
|
assert.doesNotMatch(camera.endpointLabel ?? "", /:\/\//);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(collectUrlLikeStrings(sources), [
|
||||||
|
"rerun+http://127.0.0.1:9877/proxy",
|
||||||
|
]);
|
||||||
|
const serialized = JSON.stringify(sources);
|
||||||
|
assert.doesNotMatch(serialized, /rtsp:\/\//i);
|
||||||
|
assert.doesNotMatch(serialized, /192\.168\.7\.10/);
|
||||||
|
assert.doesNotMatch(serialized, /vendor-(?:preview|viewer)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unattested camera catalog entries remain unverified", () => {
|
||||||
|
const state = declaredState();
|
||||||
|
state.compatibility.profile_id = null;
|
||||||
|
state.device_session.compatibility_profile_id = null;
|
||||||
|
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
|
||||||
|
stream.stream_id === "camera.preview.live"
|
||||||
|
? { ...stream, availability: "unverified", decode_status: "profile-not-attested" }
|
||||||
|
: stream);
|
||||||
|
|
||||||
|
const cameras = xgridsK1ObservationSources(state, model()).filter(
|
||||||
|
({ modality }) => modality === "video",
|
||||||
|
);
|
||||||
|
assert.equal(cameras.length, 2);
|
||||||
|
assert.ok(cameras.every(({ availability }) => availability === "unverified"));
|
||||||
|
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# ADR 0006: Vendor-neutral observation sources and live-only timeline
|
||||||
|
|
||||||
|
Status: accepted for the experimental Mission Core runtime, 2026-07-16.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The spatial scene and camera workspace must accept one device, a custom rig or a
|
||||||
|
future multi-sensor vehicle without adding vendor-specific branches to the host
|
||||||
|
UI. The currently verified K1 profile exposes one spatial visualization path and
|
||||||
|
two independent H.264 camera preview producers. The browser does not yet have a
|
||||||
|
camera delivery adapter, a shared point-cloud/video clock or confirmed camera
|
||||||
|
intrinsics/extrinsics.
|
||||||
|
|
||||||
|
Hard-coded camera slots, direct RTSP URLs in React components and an apparently
|
||||||
|
seekable timeline would therefore create three false contracts: a fixed sensor
|
||||||
|
count, browser ownership of device transport and replay that does not exist.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. A device plugin normalizes its capabilities into
|
||||||
|
`ObservationSourceDescriptor[]`. Mission Core consumes only these generic
|
||||||
|
descriptors.
|
||||||
|
2. `id` identifies a runtime source instance; `sourceId` identifies the producer
|
||||||
|
inside the plugin model; `semanticChannelId` describes the canonical channel.
|
||||||
|
Transport addresses are not identities.
|
||||||
|
3. Provider metadata and device/session/acquisition binding are separate. A new
|
||||||
|
acquisition may change layout context without changing the logical producer.
|
||||||
|
4. The host owns visibility, z-order, position, resize and fullscreen. A plugin
|
||||||
|
cannot inject global layout behavior.
|
||||||
|
5. Direct device RTSP URLs do not cross into the generic UI. A future read-only
|
||||||
|
camera adapter must publish a browser-decodable local delivery URL.
|
||||||
|
6. Until a bounded buffer and clock mapping exist, the shared timeline is
|
||||||
|
`live-only`, `seekable=false`, `sessionRecording=false` and explicitly marked
|
||||||
|
`host-arrival-best-effort`.
|
||||||
|
7. Directional 3D camera frustums are not rendered until calibration and
|
||||||
|
extrinsics are available and verified. An unresolved camera may be listed as
|
||||||
|
a source, but its orientation must not be invented.
|
||||||
|
|
||||||
|
## Current K1 mapping
|
||||||
|
|
||||||
|
| Producer | Canonical channel | Host representation |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `sensor.lidar.primary` | `spatial.point-cloud.live` | primary Rerun viewport |
|
||||||
|
| `sensor.camera.left` | `camera.preview.live` | auxiliary media window / camera card |
|
||||||
|
| `sensor.camera.right` | `camera.preview.live` | auxiliary media window / camera card |
|
||||||
|
|
||||||
|
Both camera producers share the proven canonical channel and remain distinct by
|
||||||
|
`sourceId`. Their current state is `declared`: the compatibility profile proves
|
||||||
|
the channels, but no browser preview URL is advertised yet.
|
||||||
|
|
||||||
|
## UI consequences
|
||||||
|
|
||||||
|
- The spatial scene source picker renders any number of plugin-published sources.
|
||||||
|
- Auxiliary media uses the design-system `WorkspaceWindow`, constrained to the
|
||||||
|
scene bounds, with controlled drag, resize, close and maximize state.
|
||||||
|
- The Cameras workspace filters media modalities and renders 0, 1, 2 or N cards;
|
||||||
|
only its grid scrolls when the source count exceeds available space.
|
||||||
|
- Point-cloud focus and camera focus stay inside the application panel rather
|
||||||
|
than taking over the browser viewport.
|
||||||
|
- Empty and declared channels remain honest placeholders; no demo frames or
|
||||||
|
synthetic sensor values are substituted.
|
||||||
|
|
||||||
|
## Follow-up gate
|
||||||
|
|
||||||
|
The next implementation step is a plugin-owned, read-only H.264 adapter that
|
||||||
|
terminates RTSP/RTP locally and exposes a browser-supported stream plus measured
|
||||||
|
per-source metrics. Synchronized rewind becomes eligible only after the adapter
|
||||||
|
provides bounded buffering and an evidenced mapping between the RTP 90 kHz clock
|
||||||
|
and the spatial stream clock.
|
||||||
|
|
||||||
|
## Code anchors
|
||||||
|
|
||||||
|
- `apps/control-station/src/core/runtime/contracts.ts`
|
||||||
|
- `apps/control-station/src/core/observation/useObservationLayout.ts`
|
||||||
|
- `apps/control-station/src/components/ObservationSources.tsx`
|
||||||
|
- `apps/control-station/src/components/ObservationTimeline.tsx`
|
||||||
|
- `apps/control-station/src/workspaces/Workspaces.tsx`
|
||||||
|
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
|
||||||
Loading…
Reference in New Issue