feat(planning): consolidate recorded-route localization and spatial scene

Preserve the completed teach-and-repeat laboratory stage: reference preparation, cascaded acquisition, local tracking and recovery, recording lifecycle, replay qualification, and persistent Rerun scene controls. Document the open grid-picking regression and Rerun upgrade contract. No autonomous driving or loop-closure optimization is claimed.
This commit is contained in:
DCCONSTRUCTIONS
2026-09-21 08:47:19 +03:00
parent be58d589e2
commit e515ab1b8c
189 changed files with 19074 additions and 758 deletions
+10
View File
@@ -27,6 +27,8 @@ import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
import { useSessionOverviewMode } from "./core/observation/useSessionOverview";
import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace";
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
@@ -150,6 +152,7 @@ export default function App() {
);
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [sourceUrl, setSourceUrl] = useState("");
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false);
@@ -609,6 +612,7 @@ export default function App() {
}, [layoutSaveNotice]);
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
const contentActions = useApplicationPanelActions({
onAddVehicle,
@@ -619,6 +623,7 @@ export default function App() {
saveWorkspaceLayout,
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
systemUtilityActions: computeContourSettings.utilityActions,
sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) },
});
const header = (
@@ -781,6 +786,8 @@ export default function App() {
settleRecordedReplaySwitch(outcome)}
onDeleteBegin={releaseRecordedReplayForDelete}
/>
) : activeDefinition.kind === "missions" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
@@ -804,10 +811,13 @@ export default function App() {
onOpenSpatialScene={() => openView("spatial-scene")}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
) : (
<WorkspaceRenderer
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
@@ -28,6 +28,7 @@ export interface CameraLeaseRetryBudget {
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
export const CAMERA_BUFFERING_NOTICE_DELAY_MS = 1_000;
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
// main-thread stall. Keep one bounded append margin above that complete batch;
// crossing either limit replaces the MSE epoch instead of dropping fragments.
@@ -284,6 +285,7 @@ export function MseFmp4WebSocketPlayer({
let onBufferError: (() => void) | null = null;
let objectUrl = "";
let retryTimer: number | undefined;
let bufferingNoticeTimer: number | undefined;
let receivedMedia = false;
let failed = false;
let playingReported = false;
@@ -295,10 +297,15 @@ export function MseFmp4WebSocketPlayer({
);
const queue: ArrayBuffer[] = [];
let queuedBytes = 0;
const clearBufferingNotice = () => {
if (bufferingNoticeTimer !== undefined) window.clearTimeout(bufferingNoticeTimer);
bufferingNoticeTimer = undefined;
};
const fail = (copy: string) => {
if (!transportIsCurrent() || failed) return;
startupWatchdog?.clear();
clearBufferingNotice();
failed = true;
transportHealthyRef.current = false;
recoveryPendingAuthorityRef.current = null;
@@ -335,6 +342,7 @@ export function MseFmp4WebSocketPlayer({
return;
}
startupWatchdog?.clear();
clearBufferingNotice();
failed = true;
transportHealthyRef.current = false;
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
@@ -392,6 +400,7 @@ export function MseFmp4WebSocketPlayer({
const onPlaying = () => {
if (!transportIsCurrent() || failed) return;
clearBufferingNotice();
startupWatchdog?.markPlaying();
transportHealthyRef.current = true;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
@@ -410,7 +419,21 @@ export function MseFmp4WebSocketPlayer({
}
};
const onWaiting = () => {
if (!transportIsCurrent() || failed || !playingReported) return;
// A short receipt gap must not flicker or restart the acquisition-owned
// stream. A sustained decoder wait must not retain a playing presentation.
if (bufferingNoticeTimer !== undefined) return;
bufferingNoticeTimer = window.setTimeout(() => {
bufferingNoticeTimer = undefined;
if (!transportIsCurrent() || failed || video.readyState >= 3) return;
setStatus("buffering");
setMessage("Ожидание новых кадров.");
}, CAMERA_BUFFERING_NOTICE_DELAY_MS);
};
video.addEventListener("playing", onPlaying);
video.addEventListener("waiting", onWaiting);
video.addEventListener("stalled", onWaiting);
const appendNext = () => {
if (
@@ -594,10 +617,13 @@ export function MseFmp4WebSocketPlayer({
disposed = true;
transportHealthyRef.current = false;
startupWatchdog?.clear();
clearBufferingNotice();
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
queue.length = 0;
socket?.close(1000, "Browser preview transport replaced or hidden");
video.removeEventListener("playing", onPlaying);
video.removeEventListener("waiting", onWaiting);
video.removeEventListener("stalled", onWaiting);
mediaSource.removeEventListener("sourceopen", onSourceOpen);
if (sourceBuffer && onBufferUpdateEnd) {
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
@@ -708,7 +734,7 @@ export function MseFmp4WebSocketPlayer({
{status !== "playing" ? (
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
<Icon name={status === "error" ? "alert" : "video"} size={20} />
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
<strong>{status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"}</strong>
<span>{message}</span>
{status === "error" ? (
<button
@@ -0,0 +1,21 @@
import { Button, LoadingRegion, Window } from "@nodedc/ui-react";
import type { RouteCheck } from "../../core/missions/planner";
export function MissionCheckWindow({ open, onClose, busy, error, report, run }: {
open: boolean; onClose: () => void; busy: boolean; error: string | null; report: RouteCheck | null; run: () => void;
}) {
return <Window open={open} title="Проверка маршрута" size="md" onClose={onClose}><div className="mission-planner__check">
<p>Проверка сохранённой записи и выбранного участка. Новый проход со сканером для этой проверки не требуется.</p>
<LoadingRegion loading={busy} label="Проверка исходных данных">
{error && <p role="alert">{error}</p>}
{report && <><dl>
<div><dt>Исходная запись</dt><dd>{report.source_verified ? "Контрольные суммы совпадают" : "Недоступна"}</dd></div>
<div><dt>Версия черновика</dt><dd>{report.revision}</dd></div>
<div><dt>Длина маршрута</dt><dd>{report.length_m.toFixed(2)} м</dd></div>
<div><dt>Положения сканера</dt><dd>{report.pose_count.toLocaleString("ru-RU")}</dd></div>
<div><dt>Максимальный шаг</dt><dd>{report.max_step_m.toFixed(2)} м</dd></div></dl>
{report.warnings.map(w => <p key={w}>{w}</p>)}
<p>Совмещение с повторным проходом не выполнялось. Записанная траектория описывает движение сканера; проходимость для аппарата по ней не проверена.</p></>}
<Button disabled={busy} onClick={run}>{report ? "Повторить проверку" : "Начать проверку"}</Button>
</LoadingRegion>
</div></Window>;
}
@@ -0,0 +1,29 @@
import { useEffect, useMemo, useState } from "react";
import { RangeControl } from "@nodedc/ui-react";
import { routeLength, type PlanningPose, type PlanningSource } from "../../core/missions/planner";
/** Equal-axis plan projection; the moving marker is an archive pose, not localization. */
export function MissionRoutePreview({ source, poses }: { source: PlanningSource; poses: PlanningPose[] }) {
const [cursor, setCursor] = useState(0);
useEffect(() => setCursor(0), [poses]);
const projection = useMemo(() => {
let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
for (const p of (poses.length ? poses : source.poses)) { xmin = Math.min(xmin, p.position[0]); xmax = Math.max(xmax, p.position[0]); ymin = Math.min(ymin, p.position[1]); ymax = Math.max(ymax, p.position[1]); }
const scale = Math.min(720 / Math.max(1, xmax - xmin), 400 / Math.max(1, ymax - ymin));
const project = (p: PlanningPose) => [400 + (p.position[0] - (xmin + xmax) / 2) * scale, 240 - (p.position[1] - (ymin + ymax) / 2) * scale];
const line = (items: PlanningPose[]) => items.filter((_, i) => i % Math.max(1, Math.ceil(items.length / 5000)) === 0 || i === items.length - 1).map(p => project(p).join(",")).join(" ");
return { project, reference: line(source.poses), selected: line(poses), scale };
}, [source, poses]);
const point = poses[Math.min(cursor, poses.length - 1)];
if (!point) return <div className="session-overview__empty">Выберите участок траектории.</div>;
const [x, y] = projection.project(point);
return <div className="mission-route-preview"><h2>Выбранный маршрут · вид сверху</h2>
<svg viewBox="0 0 800 480" role="img" aria-label="Траектория записи и выбранный маршрут">
<polyline points={projection.reference} className="mission-route-preview__reference" fill="none" />
<polyline points={projection.selected} className="mission-route-preview__selected" fill="none" />
<circle cx={x} cy={y} r="6" className="mission-route-preview__cursor" />
<path d="M40 450h100" className="mission-route-preview__selected" /><text x="40" y="438">{(100 / projection.scale).toFixed(1)} м</text>
</svg><p>Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый</p>
<RangeControl label="Положение на маршруте" value={Math.min(cursor, poses.length - 1) + 1} min={1} max={poses.length} step={1} formatValue={n => `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} />
<p>Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м</p>
</div>;
}
@@ -0,0 +1,12 @@
import type {ReactNode} from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { useSessionOverview } from "../../core/observation/useSessionOverview";
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
export function MissionZonePreview({ sessionId, toolbar }: { sessionId: string; toolbar?:ReactNode }) {
const { data, error, retry } = useSessionOverview(sessionId);
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
const failure = error || (data?.state === "error" ? data.message : null);
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
return <SessionOverviewScene sourceUrl={data.scene_url} toolbar={toolbar} hideTitle />;
}
@@ -0,0 +1,32 @@
import {useEffect,useState,type ReactNode} from 'react';
import {Button,LoadingRegion,StatusBadge,Window} from '@nodedc/ui-react';
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
/** Wrap the existing plugin connection flow, retaining its acquisition authority. */
export function PlanningConnectionWindow({children}:{children:ReactNode}){
const p=usePlanningTest(); const [open,setOpen]=useState(true);
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
useEffect(()=>{
if(!p.selected||!p.test||selection||selectionTransitionPending)return;
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
if(models.length===1)void selectModel(models[0].model.id);
},[p.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]);
if(!p.selected||!p.test)return <>{children}</>;
const t=p.test, terminal=planningTestTerminal(t);
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
<p>Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.</p></>;
return <div className="mission-planner__check">
{details}<p>{t.message}</p>{p.error&&<p role="alert">{p.error}</p>}
<div className="mission-planner__actions"><Button onClick={()=>setOpen(true)}>Подключение сканера</Button>
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
{!terminal&&<Button loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}</div>
<Window open={open} title="Подключение сканера · Планирование" size="lg" className="planning-connection" onClose={()=>setOpen(false)}>
<div className="mission-planner__check">{details}
{t.state==='preparing'?<LoadingRegion loading label="Подготовка эталонного участка" />
: terminal?<p>{t.message}</p>:children}
</div>
</Window>
</div>;
}
@@ -0,0 +1,105 @@
import {useEffect,useRef,useState} from 'react';
import {Button,LoadingRegion,RangeControl} from '@nodedc/ui-react';
import {createIsolatedRerunHost} from '../rerun/isolatedRerunHost';
import {plannerBase} from '../../core/missions/planner';
import {startPlanningSceneStream} from '../../core/missions/planningSceneStream';
import {createPlanningPresentationReporter} from '../../core/missions/planningPresentationTelemetry';
function nextAnimationFrame(limitMs=500):Promise<number|null>{
return new Promise(resolve=>{
let frame:number|undefined,settled=false;
const finish=(value:number|null)=>{
if(settled)return;
settled=true;
if(frame!==undefined&&value===null)cancelAnimationFrame(frame);
clearTimeout(timeout);resolve(value);
};
const timeout=setTimeout(()=>finish(null),limitMs);
frame=requestAnimationFrame(()=>finish(performance.now()));
});
}
/** One persistent viewer/channel. Updates replace entities without resetting the camera. */
type HeightBounds={min:number;max:number};
const CLIP_CEILING_M=80;
export function PlanningLiveScene({runId,options={},active=false,revision=0,heightBounds=null}:{runId:string;options?:Record<string,string|number|boolean>;active?:boolean;revision?:number;heightBounds?:HeightBounds|null}){
const optionsRef=useRef(options);optionsRef.current=options;
const stateRef=useRef({active,revision});stateRef.current={active,revision};
const ceilingRef=useRef<number|null>(null);
const boundsRef=useRef<{min:number;max:number}|null>(null);
const host=useRef<HTMLDivElement>(null);
const [state,setState]=useState('loading'),[retry,setRetry]=useState(0);
const [bounds,setBounds]=useState<{min:number;max:number}|null>(null);
const [ceiling,setCeiling]=useState<number|null>(null);
useEffect(()=>{
ceilingRef.current=null;
const next=heightBounds&&Number.isFinite(heightBounds.min)&&Number.isFinite(heightBounds.max)&&heightBounds.max>heightBounds.min?heightBounds:null;
boundsRef.current=next;
setBounds(next);setCeiling(null);
},[runId,heightBounds?.min,heightBounds?.max]);
useEffect(()=>{
if(!host.current)return;
let disposed=false,sceneAvailable=false,stopStream:(()=>void)|undefined;
const reporter=createPlanningPresentationReporter({
url:`${plannerBase}/live-tests/${runId}/presentation-observations`,
});
const runtime=createIsolatedRerunHost(host.current);setState('loading');
const timeout=setTimeout(()=>{if(!disposed){setState('error');stopStream?.();runtime.dispose();}},60000);
void runtime.ready.then(async({viewer,mount})=>{
if(disposed)return;
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false,allow_fullscreen:false});
if(disposed)return;
const channel=viewer.open_channel('planning-'+runId);
viewer.on('recording_open',()=>{if(!disposed&&sceneAvailable){clearTimeout(timeout);setState('ready');}});
stopStream=startPlanningSceneStream({
url:`${plannerBase}/live-tests/${runId}/scene-delta.rrd`,
snapshot:()=>({...stateRef.current,options:{...optionsRef.current,
...(ceilingRef.current===null?{}:{ceiling_m:ceilingRef.current})}}),
apply:async(bytes,delivery)=>{
if(disposed)return;
if(delivery.heightMinM!==null&&delivery.heightMaxM!==null&&delivery.heightMaxM>delivery.heightMinM){
const next={min:delivery.heightMinM,max:delivery.heightMaxM};
const previous=boundsRef.current;
if(!previous||previous.min!==next.min||previous.max!==next.max){
boundsRef.current=next;setBounds(next);
if(ceilingRef.current!==null&&(ceilingRef.current<next.min||ceilingRef.current>next.max)){
ceilingRef.current=null;setCeiling(null);
}
}
}
sceneAvailable=true;
const admissionStarted=performance.now();
channel.send_rrd(bytes);
const admissionEnded=performance.now();
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
if(viewer.get_active_recording_id()){clearTimeout(timeout);setState('ready');}
// Rerun exposes no canvas-paint receipt. Two bounded browser frame
// opportunities are retained as an explicit proxy, never as a GPU claim.
const first=await nextAnimationFrame();
const second=first===null?null:await nextAnimationFrame();
if(!disposed)reporter.record(delivery,{
rerunAdmissionMs:admissionEnded-admissionStarted,
firstAnimationFrameMs:first===null?null:first-admissionEnded,
secondAnimationFrameMs:second===null?null:second-admissionEnded,
});
},
error:()=>{sceneAvailable=false;if(!disposed)setState('error');},
});
}).catch(()=>{if(!disposed){clearTimeout(timeout);setState('error');}});
return()=>{disposed=true;stopStream?.();reporter.dispose();clearTimeout(timeout);runtime.dispose();};
},[runId,retry]);
const sourceBounds=bounds??heightBounds;
const rangeBounds=sourceBounds?{min:Math.min(sourceBounds.min,0),max:CLIP_CEILING_M}:null;
return <LoadingRegion loading={state==='loading'} label="Загрузка эталона и нового прохода" className="planning-live__scene">
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{visibility:state==='ready'?'visible':'hidden'}} />
{rangeBounds&&state==='ready'&&<div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling??rangeBounds.max} min={rangeBounds.min} max={rangeBounds.max} step="any"
formatValue={value=>`${value.toFixed(1).replace('.',',')} м`} formatLimit={value=>value.toFixed(1).replace('.',',')}
onChange={value=>{
const next=value>=rangeBounds.max-Math.max(1,rangeBounds.max-rangeBounds.min)*1e-9?null:value;
ceilingRef.current=next;setCeiling(next);
}}/>
</div>}
{state==='error'&&<div className="session-overview__empty"><span>Обновление сцены недоступно.</span><Button onClick={()=>setRetry(v=>v+1)}>Повторить подключение сцены</Button></div>}
</LoadingRegion>;
}
@@ -0,0 +1,25 @@
import { Inspector, Icon, StatusBadge } from '@nodedc/ui-react';
import { planningProjectStatus, type PlanningProjectDetail } from '../../core/missions/planningProjects';
export function PlanningProjectResult({project:p}:{project:PlanningProjectDetail}) {
const r=p.result;
return <Inspector variant="panel" defaultOpen={['sources','result']} sections={[
{id:'sources',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<p>{p.name}</p><small>{new Date(p.created_at_utc).toLocaleString('ru-RU')}</small>
<dl><div><dt>Эталон</dt><dd>{p.reference_label}</dd></div><div><dt>Повторный проход</dt><dd>{p.query_label??'—'}</dd></div>
<div><dt>Участок эталона</dt><dd>{p.draft.route.length_m.toFixed(2)} м</dd></div></dl>
</div>},
{id:'result',label:'Результат',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<StatusBadge tone={r?.status==='rejected'?'warning':'neutral'}>{planningProjectStatus(p)}</StatusBadge>
{r?<><dl><div><dt>Точки в пределах 0,5 м</dt><dd>{(r.overlap*100).toFixed(1)}%</dd></div>
<div><dt>Расхождение поверхностей</dt><dd>{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}</dd></div>
{r.correction_m!=null&&<div><dt>Уточнение привязки</dt><dd>{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°</dd></div>}
{r.registration_seconds!=null&&<div><dt>Расчёт</dt><dd>{r.registration_seconds.toFixed(2)} с</dd></div>}
</dl><small>Совпадение поверхностей не является измеренной точностью положения.</small></>:<p>{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}</p>}
{p.evidence_relation==='same_recording'&&<small>Оба участка из одной записи: внутренняя проверка общей карты.</small>}
{p.scene_note&&p.scene_url&&<small>{p.scene_note}</small>}
{r?.reasons.map(reason=><small key={reason}>{reason}</small>)}
{!p.scene_url&&r&&<small>Сохранённое совмещённое облако недоступно.</small>}
</div>},
]}/>;
}
@@ -0,0 +1,47 @@
import { useState } from 'react';
import { ConfirmationModal, Icon, Select, ToastStack } from '@nodedc/ui-react';
import { planningProjectDeletable, planningProjectStatus, type PlanningProject } from '../../core/missions/planningProjects';
export function PlanningProjectSelect({items, value, disabled, onChange, onRemove}: {
items: PlanningProject[];
value: string;
disabled?: boolean;
onChange: (key: string) => void;
onRemove: (project: PlanningProject) => Promise<void>;
}) {
const [target, setTarget] = useState<PlanningProject | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
return <>
<Select label="Совмещённые маршруты" value={value} disabled={disabled || busy} searchable
options={[{value: '', label: 'Совмещённые маршруты'}, ...items.map(item => ({
value: item.key, label: item.name,
description: `${new Date(item.created_at_utc).toLocaleString('ru-RU')} · ${planningProjectStatus(item)}${item.query_label ? ` · ${item.query_label}` : ''}`,
action: {
label: `Удалить совмещённый маршрут ${item.name} · ${new Date(item.created_at_utc).toLocaleString('ru-RU')}`,
icon: <Icon name="trash" />,
tone: 'danger' as const,
disabled: !planningProjectDeletable(item),
onAction: () => { setError(null); setTarget(item); },
},
}))]}
onChange={key => { if (key) onChange(key); }} />
<ConfirmationModal open={target !== null} title="Удалить совмещённый маршрут?"
description={target ? <>
<strong>{target.name}</strong>
<p>{new Date(target.created_at_utc).toLocaleString('ru-RU')} · {planningProjectStatus(target)}</p>
<p>Проект будет удалён из списка совмещённых маршрутов. Эталон, запись прохода и сохранённые отчёты останутся на диске. Другие проекты не изменятся.</p>
</> : null}
confirmLabel="Удалить маршрут" pendingLabel="Удаление…" danger
onClose={() => { if (!busy) setTarget(null); }}
onConfirm={async () => {
if (!target || busy) return;
setBusy(true); setError(null);
try { await onRemove(target); setTarget(null); }
catch (e) { setError(e instanceof Error ? e.message : 'Не удалось удалить проект. Повторите попытку.'); }
finally { setBusy(false); }
}} />
<ToastStack items={error ? [{id: 'planning-project-delete-error', tone: 'error', title: 'Маршрут не удалён', description: error}] : []}
onDismiss={() => setError(null)} />
</>;
}
@@ -0,0 +1,53 @@
import { Button, Icon, Inspector, InspectorSelectField, LoadingRegion, RangeControl, SegmentedControl, TextField } from '@nodedc/ui-react';
import { endAtDistance, indexAtDistance, routeLength, canSelectSession, canStartPlanningRoute } from '../../core/missions/planner';
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import type { useRegistrationTest } from '../../core/missions/useRegistrationTest';
export function PlanningProjectSettings({p,t,mode,setMode,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; t:ReturnType<typeof useRegistrationTest>;
mode:'scanner'|'recording';setMode:(mode:'scanner'|'recording')=>void;onStart:()=>void;starting:boolean;
}) {
const disabled=p.busy||starting;
const length=routeLength(p.poses);
return <Inspector variant="panel" defaultOpen={['project','zone','route','query']} sections={[
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
</div>},
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
{p.source&&<small>{p.source.poses.length.toLocaleString('ru-RU')} положений · {p.source.path_m.toFixed(2)} м</small>}
</div>},
{id:'route',label:'Участок эталона',icon:<Icon name="plan"/>,content:<div className="inspector-control-stack">
{p.source&&!p.sourceChanged?<>
<RangeControl label="Начало участка" value={p.source.poses[p.start]?.distance_m??0} min={0} max={p.source.poses[p.source.poses.length-2].distance_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>{const n=indexAtDistance(p.source!,distance,0,p.source!.poses.length-2);p.setStart(n);if(n>=p.end)p.setEnd(n+1);}}/>
<RangeControl label="Конец участка" value={p.source.poses[p.end]?.distance_m??0} min={p.source.poses[p.start+1]?.distance_m??0} max={p.source.path_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>p.setEnd(indexAtDistance(p.source!,distance,p.start+1,p.source!.poses.length-1,true))}/>
<Button disabled={disabled} onClick={()=>p.setEnd(endAtDistance(p.source!,p.start,30))}>30 м от начала участка</Button>
<Button disabled={disabled} onClick={()=>{p.setStart(0);p.setEnd(p.source!.poses.length-1);}}>Вся траектория</Button>
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
<small>{length.toFixed(2)} м · {p.poses.length.toLocaleString('ru-RU')} положений</small>
</>:<p>Выберите сохранённую запись эталона.</p>}
</div>},
{id:'query',label:'Повторный проход',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<SegmentedControl label="Источник повторного прохода" value={mode} onChange={setMode} items={[{value:'scanner',label:'Новый проход',disabled},{value:'recording',label:'Из записи',disabled}]}/>
{mode==='scanner'?<p>После запуска откроется подключение сканера. Новый проход записывается отдельным проектом.</p>:<>
<InspectorSelectField label="Повторная запись" value={t.sessionId} disabled={disabled} searchable onChange={t.setSessionId}
options={[{value:'',label:'Выберите повторный проход'},...p.sessions.filter(canSelectSession).map(s=>({value:s.id,label:s.label,description:s.id===p.sessionId?'Та же запись · внутренняя проверка':'Сохранённые облако и траектория'}))]}/>
<LoadingRegion loading={t.loading} label="Подготовка повторного прохода">
{t.source&&<div className="inspector-control-stack">
<RangeControl label="Начало повторного участка" min={0} max={t.source.poses[t.source.poses.length-2].distance_m} step="any" value={t.source.poses[t.start].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`}
onChange={n=>{const start=indexAtDistance(t.source!,n,0,t.source!.poses.length-2);t.setStart(start);t.setEnd(endAtDistance(t.source!,start,20));}}/>
<RangeControl label="Конец повторного участка" min={t.source.poses[t.start+1].distance_m} max={t.source.path_m} step="any" value={t.source.poses[t.end].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`} onChange={n=>t.setEnd(indexAtDistance(t.source!,n,t.start+1))}/>
</div>}
</LoadingRegion>
</>}
<small>{mode==='scanner'
?'Длина выбранного участка задаёт предел прохода. Ограничения по времени нет; запись сканера не останавливается автоматически. Неподвижная калибровка просматривает весь выбранный маршрут. После калибровки оставайтесь на месте до статуса «Сопровождение»; при отсутствии или неоднозначности совпадения сцена прямо сообщит причину и не начнёт сопровождение.'
:'Участки от 3 до 40 м. Начальная привязка — выбранное место старта и одинаковое направление.'}</small>
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!canStartPlanningRoute(length,mode)||(mode==='recording'&&(!t.source||t.loading||!canStartPlanningRoute(t.length,'recording')))} onClick={onStart}><Icon name="play"/>{mode==='scanner'?'Начать новый проход':'Запустить совмещение'}</Button>
{(p.error||t.error)&&<p role="alert">{p.error||t.error}</p>}
</div>},
]}/>;
}
@@ -0,0 +1,19 @@
import {useState,type ReactNode,type RefObject} from 'react';
import {WorkspaceWindow,type WorkspaceWindowRect} from '@nodedc/ui-react';
/** A scene-bounded tool: never a portal, backdrop or renderer owner. */
export function PlanningSceneToolWindow({boundsRef,title,children,onClose}:{
boundsRef:RefObject<HTMLDivElement|null>;title:string;children:ReactNode;onClose:()=>void;
}){
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:120,width:390,height:260});
const [maximized,setMaximized]=useState(false);
return <WorkspaceWindow boundsRef={boundsRef} rect={rect} onRectChange={setRect}
title={title} maximized={maximized} onMaximizedChange={setMaximized}
minWidth={280} minHeight={200} active zIndex={100} onClose={onClose}
closeLabel="Закрыть инструмент" moveLabel="Переместить инструмент"
resizeLabel="Изменить размер инструмента" maximizeLabel="Развернуть инструмент" restoreLabel="Восстановить инструмент" onKeyDown={event=>{
if(event.key==='Escape'&&!event.defaultPrevented){event.preventDefault();event.stopPropagation();onClose();}
}}>
<div className="mission-planner__check">{children}</div>
</WorkspaceWindow>;
}
@@ -0,0 +1,30 @@
import { useEffect, useRef, useState } from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
/** One isolated, read-only Rerun realm; disposal releases its WASM memory. */
export function RegistrationScene({ sourceUrl }: { sourceUrl: string }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState("loading"), [retry, setRetry] = useState(0);
useEffect(() => {
if (!host.current) return;
let disposed = false; setState("loading");
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
if (disposed) return;
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
width: "100%", height: "100%", hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
});
if (disposed) return;
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
return () => { disposed = true; clearTimeout(timer); runtime.dispose(); };
}, [sourceUrl, retry]);
return <LoadingRegion loading={state === "loading"} label="Загрузка совмещённых облаков" className="mission-registration__scene">
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
{state === "error" && <div className="session-overview__empty"><span>Облако недоступно.</span><Button onClick={() => setRetry(n => n+1)}>Повторить</Button></div>}
</LoadingRegion>;
}
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from "react";
import { overviewChartPoints } from "../../core/observation/sessionOverview";
export function SessionIntervalChart({ chart, bucketSeconds }: { chart: [number, number][]; bucketSeconds: number }) {
const svg = useRef<SVGSVGElement>(null);
const [size, setSize] = useState({ width: 900, height: 160 });
useEffect(() => {
if (!svg.current) return;
const observer = new ResizeObserver(([entry]) => {
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
}
});
observer.observe(svg.current);
return () => observer.disconnect();
}, [chart.length > 0]);
const width = Math.max(1, size.width - 106);
const height = Math.max(1, size.height - 38);
const { points, xmax, ymax } = overviewChartPoints(chart, width, height);
if (!chart.length) return <div className="session-overview__empty">Временные метки кадров отсутствуют.</div>;
return <div className="session-overview__chart">
<svg ref={svg} viewBox={`0 0 ${size.width} ${size.height}`} role="img" aria-label="Интервалы поступления кадров облака">
<g transform="translate(64,10)">
{(height >= 70 ? [0, .5, 1] : height >= 32 ? [0, 1] : [1]).map(f => <g key={f}>
<line x1="0" x2={width} y1={height * (1 - f)} y2={height * (1 - f)} className="session-overview__grid" />
<text x="-12" y={height * (1 - f) + 4} textAnchor="end">{(f * ymax).toFixed(2)} с</text>
</g>)}
<polyline points={points} fill="none" className="session-overview__series" vectorEffect="non-scaling-stroke" />
{[0, .25, .5, .75, 1].map(f => <text key={f} x={f * width} y={height + 24} textAnchor="middle">{(f * xmax / 60).toFixed(1)} мин</text>)}
</g>
</svg>
<span className="session-overview__note">Максимальный интервал за каждые {bucketSeconds} с · время от первого кадра</span>
</div>;
}
@@ -0,0 +1,80 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode } from "../../core/observation/sessionOverviewSpatial";
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [retry, setRetry] = useState(0);
const [metadata, setMetadata] = useState<OverviewSpatialMetadata | null>(null);
const [ceiling, setCeiling] = useState<number | null>(null);
const [mode, setMode] = useState<OverviewViewMode>("3d");
const [viewError, setViewError] = useState<string | null>(null);
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
const appliedMode = useRef<OverviewViewMode | null>(null);
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
useEffect(() => {
if (!host.current) return;
let disposed = false;
const abort = new AbortController();
appliedMode.current = null;
setState("loading");
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(setMetadata).catch(() => { if (!disposed) setViewError("Параметры среза недоступны."); });
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
if (disposed) return;
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
width: "100%", height: "100%", hide_welcome_screen: true,
enable_history: false, allow_fullscreen: false,
});
if (disposed) return;
controller.current = { viewer, channel: viewer.open_channel("session-overview-controls") };
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
return () => { disposed = true; abort.abort(); controller.current = null; clearTimeout(timer); runtime.dispose(); };
}, [sourceUrl, retry]);
useEffect(() => {
if (state !== "ready" || !metadata || !controller.current) return;
const abort = new AbortController();
const timer = setTimeout(() => {
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal).then(result => {
if (abort.signal.aborted || !controller.current) return;
controller.current.channel.send_rrd(result.bytes);
if (result.eye) controller.current.viewer.configure_camera_journal(result.eye, 0);
appliedMode.current = mode;
setVisiblePoints(result.visiblePoints); setViewError(null);
}).catch(() => { if (!abort.signal.aborted) setViewError("Не удалось обновить вид облака."); });
}, 180);
return () => { abort.abort(); clearTimeout(timer); };
}, [state, metadata, sourceUrl, ceiling, mode, retry]);
const low = metadata?.height_min_m;
const high = metadata?.height_max_m;
return <>
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
{toolbar}
</div>
<LoadingRegion loading={state === "loading"} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
onChange={value => setCeiling(value >= high - Math.max(1, high - low) * 1e-9 ? null : value)} />
</div>}
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</LoadingRegion>
{viewError ? <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>
: <span className="session-overview__note">{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек</span>}
</>;
}
@@ -12,6 +12,7 @@ interface ApplicationPanelActionsOptions {
saveWorkspaceLayout: () => Promise<void>;
workspaceLayoutSaving: boolean;
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
sessionOverview?: { open: boolean; toggle: () => void; available: boolean };
}
export function deviceRuntimeUtilityAction({
@@ -51,6 +52,7 @@ export function useApplicationPanelActions({
saveWorkspaceLayout,
workspaceLayoutSaving,
systemUtilityActions,
sessionOverview,
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = [];
@@ -71,6 +73,11 @@ export function useApplicationPanelActions({
});
}
if (definition?.root === "system") actions.push(...systemUtilityActions);
if (definition?.kind === "recordings" && sessionOverview) actions.push({
label: sessionOverview.open ? "Закрыть информацию о записи" : "Информация о записи",
icon: "info", pressed: sessionOverview.open, disabled: !sessionOverview.available,
onClick: sessionOverview.toggle,
});
return actions;
}, [
definition,
@@ -81,5 +88,6 @@ export function useApplicationPanelActions({
saveWorkspaceLayout,
systemUtilityActions,
workspaceLayoutSaving,
sessionOverview,
]);
}
@@ -97,9 +97,18 @@ export interface DevicePluginHostActions {
activateAutomaticSpatialSource: () => void;
}
/** Session-bound, presentation-only extension of an active spatial workflow. */
export interface SpatialActivityPresentation {
sessionId: string;
label: string;
detail: string;
busy: boolean;
}
export interface DevicePluginConnectionProps {
model: DeviceModelDefinition;
host: DevicePluginHostActions;
spatialActivity?: SpatialActivityPresentation;
}
export interface DeviceUiPlugin {
@@ -40,7 +40,7 @@ const defaultQuickActions: Record<
home: ["spatial-scene", "vehicles"],
fleet: ["vehicles", "contour-health"],
observation: ["cameras", "world-map"],
missions: ["mission-planner", null],
missions: ["routes", null],
data: ["recordings", "datasets"],
system: ["modules", "integrations"],
polygon: ["lab-archive", "local-device"],
@@ -0,0 +1,71 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { plannerBase, plannerRequest, type Draft } from './planner';
export interface PlanningLiveTest {
schema_version: 'missioncore.planning-live-test/v1'; id: string; profile: 'planning'; plugin_id:string; draft: Draft;
state: 'preparing'|'waiting'|'running'|'completed'|'cancelled'|'error'|'interrupted';
planning_phase?: 'preparing'|'waiting-cloud'|'collecting'|'searching'|'refreshing'|'validating'|'tracking'|'lost'|'ended';
tracking_state?: 'acquiring'|'tracking'|'lost';
tracking_established?: boolean;
presentation_state?: 'live'|'historical'|'unlocalized'; scene_revision?:number;
scene_height_min_m?:number|null; scene_height_max_m?:number|null;
message: string; query_session_id: string|null; distance_m: number; query_points?: number;
scene_available: boolean; stale: boolean; frame_age_s: number|null; result_age_s: number|null;
result: {status:'candidate'|'rejected'; overlap:number; inlier_rmse_m:number|null; reasons:string[]}|null;
route_relocalization_policy?: {version?:string;query_radius_m?:number};
}
export interface PlanningRunSummary {id:string;name:string;state:string;query_session_id:string|null;created_at_utc:string}
const endpoint = plannerBase+'/live-tests';
const terminal = new Set(['completed','cancelled','error','interrupted']);
const Context = createContext<{
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<void>; selected: boolean; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>; resume:()=>void;
}|null>(null);
/** Server owns the run; navigation and reload retain its frozen reference. */
export function PlanningTestProvider({children}:{children:ReactNode}) {
const [test,setTest]=useState<PlanningLiveTest|null>(null);
const [dismissed,setDismissed]=useState<string|null>(null);
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
const [pollError,setPollError]=useState<string|null>(null);
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
const generation=useRef(0);
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
const select=useCallback(async(id:string)=>{
generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));setDismissed(null);}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');}
finally{generation.current+=1;setBusy(false);}
},[]);
useEffect(()=>{
let disposed=false, timer:ReturnType<typeof setTimeout>;
const poll=async()=>{
const expected=generation.current;
try { const next=await plannerRequest<PlanningLiveTest|null>(endpoint+'/active'); if(!disposed && expected===generation.current) {setTest(next);setPollError(null);} }
catch(e) {if(!disposed && expected===generation.current)setPollError(e instanceof Error?e.message:'Исследование недоступно.');}
if(!disposed)timer=setTimeout(()=>void poll(),1500);
};void poll();return()=>{disposed=true;clearTimeout(timer);};
},[]);
const begin=useCallback(async(draft:Draft)=>{
generation.current+=1;setBusy(true);setError(null);
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);setDismissed(null);sessionStorage.removeItem('planning-dismissed');return next;}
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
finally{generation.current+=1;setBusy(false);}
},[]);
const finish=useCallback(async()=>{
if(!test)return;generation.current+=1;setBusy(true);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/stop',{method:'POST'}));}
catch(e){setError(e instanceof Error?e.message:'Не удалось завершить тест.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const retryInitialization=useCallback(async()=>{
if(!test)return;generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/reinitialize',{method:'POST'}));}
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]);
return <Context.Provider value={{test,history,select,selected:!!test&&dismissed!==test.id,busy,error:error??pollError,begin,finish,retryInitialization,resume}}>{children}</Context.Provider>;
}
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
@@ -0,0 +1,44 @@
export interface PlanningPose { index: number; message_index: number; position: [number, number, number]; elapsed_s: number | null; distance_m: number }
export interface PlanningSource { schema_version: "missioncore.planning-source/v1"; session_id: string; generation: string; label: string; frame_id: string; units: "m"; poses: PlanningPose[]; path_m: number; decode_errors: number }
export interface SessionOption { id: string; label: string; modalities: string[]; replayable: boolean; lab?: unknown; }
export type Direction = "forward" | "reverse";
export interface Draft {
id: string; revision: number; name: string; updated_at_utc: string; vehicle_id: null;
zone: { session_id: string; generation: string; label: string };
route: { start_index: number; end_index: number; direction: Direction; length_m: number; points: { source_index: number; position: [number, number, number] }[] };
}
export interface RouteCheck { id: string; revision: number; length_m: number; pose_count: number; max_step_m: number; source_verified: boolean; warnings: string[]; localization: "not_run"; vehicle_control: false; }
export const plannerBase = "/api/v1/mission-planner";
export function canStartPlanningRoute(length: number, mode: 'scanner' | 'recording') {
return Number.isFinite(length) && length >= 3 && (mode === 'scanner' || length <= 40);
}
export async function plannerRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...init.headers } });
if (!response.ok) { const doc = await response.json().catch(() => null); throw new Error(typeof doc?.detail === "string" ? doc.detail : "Данные планировщика недоступны."); }
return response.json() as Promise<T>;
}
export function canSelectSession(item: SessionOption) { return item.replayable && !item.lab && item.modalities.includes("point-cloud") && item.modalities.includes("trajectory"); }
export function selectedPoses(source: PlanningSource | null, start: number, end: number, direction: Direction) {
if (!source || start < 0 || end >= source.poses.length || start >= end) return [];
const poses = source.poses.slice(start, end + 1);
return direction === "reverse" ? poses.reverse() : poses;
}
export function routeLength(poses: PlanningPose[]) { return poses.slice(1).reduce((sum, p, index) => sum + Math.hypot(...p.position.map((v, axis) => v - poses[index].position[axis])), 0); }
export function endAtDistance(source: PlanningSource, start: number, length: number) {
const target = source.poses[start].distance_m + length;
const index = source.poses.findIndex((pose, index) => index > start && pose.distance_m >= target);
return index < 0 ? source.poses.length - 1 : index;
}
export function validatePlanningSource(data: PlanningSource, sessionId: string): PlanningSource {
if (data.schema_version !== "missioncore.planning-source/v1" || data.session_id !== sessionId || data.units !== "m"
|| !/^[a-f0-9]{64}$/.test(data.generation) || !Array.isArray(data.poses) || data.poses.length < 2 || data.poses.length > 100_000
|| !data.poses.every((p, i) => p.index === i && p.position.length === 3 && p.position.every(Number.isFinite) && Number.isFinite(p.distance_m))) throw new Error("Траектория записи некорректна.");
return data;
}
/** Distance along the recorded path, so overlapping outbound/return branches remain distinct. */
export function indexAtDistance(source: PlanningSource, distance: number, min = 0, max = source.poses.length - 1, before = false) {
let low = min, high = max;
while (low < high) { const mid = Math.floor((low + high) / 2); if (source.poses[mid].distance_m < distance) low = mid + 1; else high = mid; }
return before && low > min && source.poses[low].distance_m > distance ? low - 1 : low;
}
@@ -0,0 +1,51 @@
import type {SpatialActivityPresentation} from '../device-plugins/contracts';
export interface PlanningMatchState {
state:string;stale:boolean;frame_age_s:number|null;result_age_s:number|null;
result:{status:string}|null;
tracking_state?:string;planning_phase?:string;tracking_established?:boolean;
presentation_state?:'live'|'historical'|'unlocalized';
}
/** Current geometry is insufficient: the temporal gate must also be tracking. */
export function planningMatchCurrent(t:PlanningMatchState,transportError=false){
return !transportError&&t.state==='running'&&t.tracking_state==='tracking'&&!t.stale
&&(t.presentation_state===undefined||t.presentation_state==='live')
&&t.frame_age_s!==null&&t.frame_age_s>=0&&t.frame_age_s<2
&&t.result_age_s!==null&&t.result_age_s>=0&&t.result_age_s<8
&&t.result?.status==='candidate';
}
type Status={label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
type PlanningPresentation=PlanningMatchState & {message:string};
const phaseLabels:Record<string,string>={
collecting:'Накопление данных',searching:'Поиск положения на маршруте',
refreshing:'Подтверждение привязки',validating:'Подтверждение привязки',
};
/** Terminal failures and data freshness take precedence over successful old fits. */
export function planningStatus(t:PlanningPresentation,error:string|null=null):Status{
if(error)return {label:'Исследование недоступно',tone:'danger',message:error};
if(t.state==='error'||t.state==='interrupted')return {label:'Совмещение остановлено',tone:'danger',message:t.message,pulse:true};
if(t.state==='completed'||t.state==='cancelled')return {label:'Исследование завершено',tone:'neutral',message:t.message+(t.presentation_state==='historical'?' Показана последняя принятая привязка; это не текущее положение.':'')};
if(planningMatchCurrent(t))return {label:'Сопровождение',tone:'success',message:t.message};
if(t.planning_phase==='recovering')return {label:'Восстановление привязки',tone:'danger',pulse:true,message:'Остановитесь. Ищем положение по новым данным; запись продолжается.'};
if(t.planning_phase==='lost'&&!t.tracking_established)
return {label:'Маршрут не синхронизирован',tone:'warning',message:t.message};
if(t.planning_phase==='lost'||t.planning_phase==='tracking')
return {label:'Привязка потеряна',tone:'danger',pulse:true,message:'Нет актуального подтверждения привязки. Остановитесь; ожидается восстановление по новым данным.'+(t.presentation_state==='historical'?' Сцена сохранена в последней принятой привязке.':'')};
if(t.planning_phase&&phaseLabels[t.planning_phase])
return {label:phaseLabels[t.planning_phase],tone:'neutral'};
if(t.result?.status==='rejected')return {label:'Совпадение не подтверждено',tone:'warning',message:t.message};
return {label:t.state==='preparing'?'Подготовка эталона':t.stale?'Ожидание данных':'Приём нового прохода',tone:'neutral',message:t.message};
}
/** A plugin may show this only for its matching, authoritative acquiring session. */
export function planningActivity(
t:PlanningPresentation & {query_session_id:string|null},error:string|null=null,
):SpatialActivityPresentation|undefined{
if(!t.query_session_id||!['running','error'].includes(t.state)
||!t.planning_phase||['preparing','waiting-cloud','ended'].includes(t.planning_phase))return undefined;
const status=planningStatus(t,error);
return {sessionId:t.query_session_id,label:status.label,detail:status.message??'',
busy:!error&&t.state==='running'&&Object.hasOwn(phaseLabels,t.planning_phase)};
}
@@ -0,0 +1,93 @@
import type { PlanningSceneDelivery } from './planningSceneStream';
export const PLANNING_BROWSER_PRESENTATION_SCHEMA =
'missioncore.planning-browser-presentation/v1';
type BrowserPresentationSample = {
cloud_revision:number;
cloud_sequence:number;
display_epoch:string;
request_ms:number;
rerun_admission_ms:number;
first_animation_frame_ms:number|null;
second_animation_frame_ms:number|null;
frame_timeout:boolean;
source_to_second_animation_frame_upper_bound_ms:number|null;
};
type ReporterDependencies = {
url:string;
fetcher?:typeof fetch;
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
};
const batchSize=8;
const flushDelayMs=250;
/**
* Bounded, best-effort browser observation. These samples never drive scene
* admission, registration, navigation, or device control.
*/
export function createPlanningPresentationReporter(deps:ReporterDependencies){
const fetcher=deps.fetcher??fetch;
const schedule=deps.schedule??setTimeout;
const cancel=deps.cancel??clearTimeout;
let pending:BrowserPresentationSample[]=[];
let timer:ReturnType<typeof setTimeout>|undefined;
let sending=false,stopped=false;
const flush=async()=>{
if(sending||stopped||!pending.length)return;
if(timer!==undefined){cancel(timer);timer=undefined;}
sending=true;
const samples=pending.splice(0,batchSize);
try{
// Deliberately best-effort: the presentation observer must not make the
// live Rerun channel wait for an unrelated report request.
const response=await fetcher(deps.url,{method:'POST',cache:'no-store',keepalive:true,
headers:{'Content-Type':'application/json'},
body:JSON.stringify({schema_version:PLANNING_BROWSER_PRESENTATION_SCHEMA,samples}),
});
if(!response.ok)throw new Error(`Planning presentation telemetry was not accepted (${response.status})`);
}catch{
// A lost browser report is observable as fewer samples, never retried into
// an unbounded queue and never promoted into a capture/fit failure.
}finally{
sending=false;
if(!stopped&&pending.length)void flush();
}
};
const record=(delivery:PlanningSceneDelivery,timing:{
rerunAdmissionMs:number;firstAnimationFrameMs:number|null;
secondAnimationFrameMs:number|null;
})=>{
if(stopped||delivery.presentation!=='live'||delivery.cloudRevision===null||
delivery.cloudSequence===null||delivery.displayEpoch===null)return;
const second=timing.secondAnimationFrameMs;
pending.push({
cloud_revision:delivery.cloudRevision,
cloud_sequence:delivery.cloudSequence,
display_epoch:delivery.displayEpoch,
request_ms:delivery.requestMs,
rerun_admission_ms:timing.rerunAdmissionMs,
first_animation_frame_ms:timing.firstAnimationFrameMs,
second_animation_frame_ms:second,
frame_timeout:second===null,
source_to_second_animation_frame_upper_bound_ms:second===null?null:
delivery.cloudAgeMs+delivery.requestMs+timing.rerunAdmissionMs+second,
});
if(pending.length>=batchSize)void flush();
else if(timer===undefined)timer=schedule(()=>{timer=undefined;void flush();},flushDelayMs);
};
return {
record,
dispose:()=>{
stopped=true;
if(timer!==undefined)cancel(timer);
pending=[];
},
};
}
@@ -0,0 +1,37 @@
import type { Draft } from './planner';
import type { RegistrationReport } from './useRegistrationTest';
import { plannerBase, plannerRequest } from './planner';
export interface PlanningProject {
key: string; kind: 'recorded'|'live'|'draft'; id: string; name: string;
state: string; created_at_utc: string; result_status: string|null;
reference_label: string; query_label: string|null; draft_id: string; revision: number;
}
export interface PlanningProjectDetail extends PlanningProject {
draft: Draft; result: RegistrationReport['result'] | null; message?: string;
scene_url: string|null; evidence_relation?: string; elapsed_seconds?: number;
scene_note?:string|null;
}
export const planningProjectPending = (p: PlanningProject) => ['queued','running','preparing','waiting'].includes(p.state);
export const planningProjectDeletable = (p: PlanningProject) => p.kind === 'draft'
|| (p.kind === 'recorded' ? ['ready', 'error'] : ['completed', 'cancelled', 'error', 'interrupted']).includes(p.state);
export async function deletePlanningProject(project: PlanningProject) {
if (!planningProjectDeletable(project)) throw new Error('Сначала завершите исследование.');
const receipt = await plannerRequest<{key: string; deleted: boolean}>(
`${plannerBase}/projects/${project.kind}/${encodeURIComponent(project.id)}`,
{method: 'DELETE', body: JSON.stringify({revision: project.revision})},
);
if (receipt.key !== project.key || receipt.deleted !== true) throw new Error('Удаление проекта не подтверждено. Обновите список.');
}
export function planningProjectStatus(p: PlanningProject) {
if (p.kind === 'draft') return 'Подготовка';
if (planningProjectPending(p)) return 'Выполняется';
if (p.result_status === 'candidate') return 'Кандидат совмещения';
if (p.result_status === 'rejected') return 'Совмещение отклонено';
return 'Без результата совмещения';
}
export function defaultPlanningProject(items: PlanningProject[], last: string|null) {
return items.find(p => p.key === last)?.key
?? items.find(p => p.kind === 'recorded' && p.state === 'ready')?.key
?? items[0]?.key ?? '';
}
@@ -0,0 +1,87 @@
/** One in-flight request, latest-only deltas, no browser backlog or authority. */
type Snapshot = {options:Record<string,string|number|boolean>;active:boolean;revision:number};
export type PlanningSceneDelivery = {
presentation:'live'|'historical'; cloudAgeMs:number; fitAgeMs:number;
requestMs:number; cloudRevision:number|null; cloudSequence:number|null;
displayEpoch:string|null; heightMinM:number|null; heightMaxM:number|null;
};
type Dependencies = {
url:string; snapshot:()=>Snapshot;
apply:(bytes:Uint8Array,delivery:PlanningSceneDelivery)=>void|Promise<void>; error:()=>void;
fetcher?:typeof fetch; now?:()=>number;
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
};
export function startPlanningSceneStream(deps:Dependencies){
const fetcher=deps.fetcher??fetch, now=deps.now??(()=>performance.now());
const schedule=deps.schedule??setTimeout, cancel=deps.cancel??clearTimeout;
let stopped=false,cursor='',key='',revision=-1,needsBase=true;
let timer:ReturnType<typeof setTimeout>|undefined;
let request:AbortController|undefined;
const poll=async()=>{
if(stopped)return;
const started=now(), snapshot=deps.snapshot(), nextKey=JSON.stringify(snapshot.options);
if(!needsBase&&cursor&&!snapshot.active&&key===nextKey&&revision===snapshot.revision){
timer=schedule(()=>void poll(),500);return;
}
request=new AbortController();
const deadline=schedule(()=>request?.abort(),2500);
let failed=false;
try{
const params=new URLSearchParams(Object.entries(snapshot.options).map(([name,value])=>[name,String(value)]));
params.set('base',String(needsBase||!cursor||key!==nextKey));
if(cursor)params.set('cursor',cursor);
const response=await fetcher(`${deps.url}?${params}`,{cache:'no-store',signal:request.signal});
if(!response.ok)throw new Error('scene unavailable');
const bytes=response.status===204?null:new Uint8Array(await response.arrayBuffer());
if(stopped)return;
const presentation=response.headers.get('X-Planning-Presentation')==='live'?'live':'historical';
const cloud=Number(response.headers.get('X-Planning-Cloud-Age')??NaN);
const fit=Number(response.headers.get('X-Planning-Fit-Age')??NaN);
const elapsed=now()-started;
if(presentation==='live'){
// Include the entire request duration: a conservative upper bound, without
// comparing clocks on different hosts. Late bytes must not revive green.
if(!Number.isFinite(cloud)||!Number.isFinite(fit)||cloud<0||fit<0||cloud+elapsed/1000>=2||fit+elapsed/1000>=8)
throw new Error('scene expired during delivery');
}
const latest=deps.snapshot();
// Never apply a response from the previous display mode or live/ended state.
if(JSON.stringify(latest.options)!==nextKey||latest.active!==snapshot.active){needsBase=true;return;}
const nextCursor=response.headers.get('X-Planning-Scene-Cursor');
if(!nextCursor)throw new Error('scene cursor missing');
if(bytes)await deps.apply(bytes,{
presentation,cloudAgeMs:presentation==='live'?cloud*1000:0,
fitAgeMs:presentation==='live'?fit*1000:0,requestMs:elapsed,
cloudRevision:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Revision')),
cloudSequence:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Sequence')),
displayEpoch:response.headers.get('X-Planning-Display-Epoch'),
heightMinM:parseFiniteNumber(response.headers.get('X-Planning-Height-Min')),
heightMaxM:parseFiniteNumber(response.headers.get('X-Planning-Height-Max')),
}); // Native admission only; the caller may record a separate presentation proxy.
cursor=nextCursor;key=nextKey;revision=snapshot.revision;needsBase=false;
}catch{
// Keep the last admitted cursor (including camera intent). A full geometry
// repair after a missed/expired response is not permission to reset the eye.
if(!stopped){failed=true;needsBase=true;deps.error();}
}finally{
cancel(deadline);request=undefined;
if(!stopped)timer=schedule(()=>void poll(),failed?500:Math.max(10,(snapshot.active?100:500)-(now()-started)));
}
};
void poll();
return ()=>{stopped=true;request?.abort();if(timer!==undefined)cancel(timer);};
}
function parseNonnegativeInteger(value:string|null){
if(value===null||!/^\d+$/.test(value))return null;
const parsed=Number(value);
return Number.isSafeInteger(parsed)&&parsed>0?parsed:null;
}
function parseFiniteNumber(value:string|null){
if(value===null||value.trim()==='')return null;
const parsed=Number(value);
return Number.isFinite(parsed)?parsed:null;
}
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { canSelectSession, endAtDistance, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
export function useMissionPlanner() {
const initializeRoute = useRef(true);
const [sessions, setSessions] = useState<SessionOption[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Draft[]>([]);
const [saved, setSaved] = useState<Draft | null>(null);
const [name, setName] = useState("");
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [start, setStart] = useState(0);
const [end, setEnd] = useState(1);
const [direction, setDirection] = useState<Direction>("forward");
const [error, setError] = useState<string | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [catalogBusy, setCatalogBusy] = useState(true);
const [loadVersion, setLoadVersion] = useState(0);
const [sourceVersion, setSourceVersion] = useState(0);
const [check, setCheck] = useState<RouteCheck | null>(null);
useEffect(() => {
const abort = new AbortController(); setCatalogBusy(true); setError(null);
void Promise.all([
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?limit=100&pagination=cursor-v1", { signal: abort.signal }),
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
.finally(() => { if (!abort.signal.aborted) setCatalogBusy(false); });
return () => abort.abort();
}, [loadVersion]);
useEffect(() => {
const abort = new AbortController(); setSource(null); setSourceError(null);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) { const next = validatePlanningSource(data, sessionId); setSource(next); if (initializeRoute.current) { setStart(0); setEnd(endAtDistance(next, 0, 30)); initializeRoute.current = false; } } })
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
return () => abort.abort();
}, [sessionId, sourceVersion]);
const currentSource = source?.session_id === sessionId ? source : null;
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
const chooseSource = (id: string) => { initializeRoute.current = true; setSessionId(id); setStart(0); setEnd(1); setCheck(null); };
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
const openDraft = async (id: string) => {
setBusy(true); setError(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
initializeRoute.current = false; setSaved(next); setName(next.name); setSessionId(next.zone.session_id);
setStart(next.route.start_index); setEnd(next.route.end_index); setDirection(next.route.direction); setCheck(null);
setSourceVersion(n => n + 1);
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const save = async () => {
if (!ready || !currentSource) return;
setBusy(true); setError(null); setCheck(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
generation: currentSource.generation, start_index: start, end_index: end, direction,
}) });
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
return next;
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const runCheck = async () => {
if (!saved || dirty || !ready) return;
setBusy(true); setError(null); setCheck(null);
try { setCheck(await plannerRequest<RouteCheck>(`${plannerBase}/drafts/${saved.id}/checks`, { method: "POST", body: JSON.stringify({ revision: saved.revision }) })); }
catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const loadMore = async () => {
if (!cursor) return;
setCatalogBusy(true);
try {
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
};
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource, start, setStart, end, setEnd,
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
description: canSelectSession(item) ? "Облако и траектория" : "Зона недоступна: нет исходного облака и траектории", disabled: !canSelectSession(item) }))] };
}
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { plannerBase, plannerRequest } from './planner';
import { defaultPlanningProject, deletePlanningProject, planningProjectPending, type PlanningProject, type PlanningProjectDetail } from './planningProjects';
const storageKey = 'missioncore.planning-project.selected.v1';
export function usePlanningProjects() {
const [items, setItems] = useState<PlanningProject[]>([]);
const [key, setKey] = useState('');
const [detail, setDetail] = useState<PlanningProjectDetail|null>(null);
const [loading, setLoading] = useState(true), [error, setError] = useState<string|null>(null);
const [version, setVersion] = useState(0);
const initialized = useRef(false);
const selectedKey = useRef('');
const removedKeys = useRef(new Set<string>());
const removing = useRef(false);
const select = useCallback((value: string) => {
initialized.current = true; selectedKey.current = value; setDetail(null); setKey(value);
try { if (value) sessionStorage.setItem(storageKey,value); else sessionStorage.removeItem(storageKey); } catch { /* selection is optional UI state */ }
}, []);
const refresh = useCallback(() => setVersion(n=>n+1), []);
const remove = useCallback(async (project: PlanningProject) => {
if (removing.current) throw new Error('Удаление уже выполняется.');
removing.current = true;
try {
await deletePlanningProject(project);
removedKeys.current.add(project.key);
setItems(items => items.filter(item => item.key !== project.key));
if (selectedKey.current === project.key) select('');
} finally { removing.current = false; }
}, [select]);
useEffect(()=>{
const abort = new AbortController(); setLoading(true); setError(null);
void plannerRequest<{items:PlanningProject[]}>(plannerBase+'/projects',{signal:abort.signal}).then(data=>{
if (abort.signal.aborted) return;
const visible = data.items.filter(item => !removedKeys.current.has(item.key));
setItems(visible);
if (!initialized.current) {
let last=null; try {last=sessionStorage.getItem(storageKey);} catch { /* optional */ }
select(defaultPlanningProject(visible,last));
}
}).catch(e=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});
return ()=>abort.abort();
},[version,select]);
useEffect(()=>{
if (!key) return;
const abort=new AbortController(); let timer:ReturnType<typeof setTimeout>;
setDetail(null); setError(null);
const [kind,id]=key.split(':');
const poll=async()=>{
try {
const next=await plannerRequest<PlanningProjectDetail>(`${plannerBase}/projects/${kind}/${id}`,{signal:abort.signal});
if(abort.signal.aborted || removedKeys.current.has(key))return;
if(next.key!==key)throw new Error('Получен результат другого исследования.');
setDetail(next);setError(null);
setItems(items=>items.some(p=>p.key===key)?items.map(p=>p.key===key?next:p):[next,...items]);
if(planningProjectPending(next))timer=setTimeout(()=>void poll(),2000);
} catch(e) {if(!abort.signal.aborted)setError((e as Error).message);}
};
void poll(); return()=>{abort.abort();clearTimeout(timer);};
},[key,version]);
return {items,key,detail,loading,error,select,refresh,remove};
}
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource } from "./planner";
export interface RegistrationReport {
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
revision: number; created_at_utc: string; evidence_relation: "same_recording" | "different_recordings";
scene_url?: string; elapsed_seconds?: number; reference?: { label: string }; query?: { label: string };
result?: { correspondence_colors?: "accepted-distance-v1"; status: "candidate" | "rejected"; reasons: string[]; overlap: number; inlier_rmse_m: number | null;
correction_m: number; correction_deg: number; registration_seconds: number; reference_points: number; query_points: number };
}
export function useRegistrationTest(draft: Draft | null) {
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
useEffect(() => {
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
.then(value => { if (!abort.signal.aborted) { const data = validatePlanningSource(value, sessionId); setSource(data); setStart(0); setEnd(endAtDistance(data, 0, 20)); } })
.catch(e => { if (!abort.signal.aborted) setError(e.message); })
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
return () => abort.abort();
}, [sessionId]);
const busy = submitting;
const length = source ? (source.poses[end]?.distance_m ?? 0) - (source.poses[start]?.distance_m ?? 0) : 0;
const run = async (savedDraft = draft) => {
if (!source || busy || !savedDraft) return;
setSubmitting(true); setError(null);
try {
const data = await plannerRequest<RegistrationReport>(`${plannerBase}/drafts/${savedDraft.id}/registration-runs`, {
method: "POST", body: JSON.stringify({ revision: savedDraft.revision, session_id: sessionId, generation: source.generation, start_index: start, end_index: end }),
});
return data;
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
};
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run };
}
@@ -0,0 +1,41 @@
export interface SessionOverviewMetrics {
point_frames: number; pose_frames: number; point_count: number; sample_points: number;
decode_errors: number; sequence_gaps: number; sequence_nonincreasing: number;
path_m: number | null; start_end_m: number | null; stream_seconds: number | null;
mean_hz: number | null; interval_p95_s: number | null; interval_max_s: number | null;
interval_statistics_complete: boolean; gaps_over_second: number | null;
arrival_backwards: number; chart: [number, number][]; chart_bucket_seconds: number;
spatial_available: boolean;
}
export interface SessionOverview {
schema_version: "missioncore.session-overview/v1";
state: "queued" | "preparing" | "ready" | "error";
session: { session_id: string; display_name: string; duration_seconds: number | null;
started_at_utc: string | null; total_bytes: number; modalities: string[]; status: string; };
metrics?: SessionOverviewMetrics | null;
scene_url?: string | null;
message?: string;
messages_processed?: number;
}
export async function fetchSessionOverview(sessionId: string, signal: AbortSignal, retry = false): Promise<SessionOverview> {
const response = await fetch(`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/overview${retry ? "/retry" : ""}`, {
signal, method: retry ? "POST" : "GET", cache: "no-store",
});
if (!response.ok) throw new Error("Обзор записи недоступен.");
const data = await response.json() as SessionOverview;
if (data.schema_version !== "missioncore.session-overview/v1" || data.session?.session_id !== sessionId
|| !["queued", "preparing", "ready", "error"].includes(data.state)) throw new Error("Получены некорректные сведения о записи.");
if (data.scene_url) {
const url = new URL(data.scene_url, window.location.origin);
if (url.origin !== window.location.origin || url.pathname !== `/api/v1/observation-sessions/${sessionId}/overview/scene.rrd`) throw new Error("Облако записи недоступно.");
}
return data;
}
export function overviewChartPoints(chart: readonly [number, number][], width: number, height: number) {
const valid = chart.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0);
const xmax = Math.max(1, ...valid.map(p => p[0]));
const ymax = Math.max(.1, ...valid.map(p => p[1])) * 1.1;
return { xmax, ymax, points: valid.map(([x, y]) => `${x / xmax * width},${height - y / ymax * height}`).join(" ") };
}
@@ -0,0 +1,26 @@
export type OverviewViewMode = "3d" | "top";
export interface OverviewSpatialMetadata { height_min_m: number | null; height_max_m: number | null; sample_points: number; }
function endpoint(source: string) {
const url = new URL(source, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.endsWith("/overview/scene.rrd")) throw new Error("Облако недоступно.");
url.pathname = url.pathname.replace(/scene\.rrd$/, "spatial");
return url;
}
export async function fetchOverviewSpatial(source: string, signal: AbortSignal): Promise<OverviewSpatialMetadata> {
const response = await fetch(endpoint(source), { signal, cache: "no-store" });
if (!response.ok) throw new Error("Параметры среза недоступны.");
return response.json();
}
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal) {
const url = endpoint(source);
const generation = url.searchParams.get("generation");
url.search = "";
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect }) });
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
return { bytes, visiblePoints: Number(response.headers.get("X-Overview-Visible-Points")),
eye: response.headers.get("X-Overview-Eye") ? JSON.parse(response.headers.get("X-Overview-Eye")!) : null };
}
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import { fetchSessionOverview, type SessionOverview } from "./sessionOverview";
export function useSessionOverview(sessionId: string) {
const [data, setData] = useState<SessionOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const [retryGeneration, setRetryGeneration] = useState(0);
useEffect(() => {
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
setData(null); setError(null);
const load = async (retry = false) => {
try {
const next = await fetchSessionOverview(sessionId, abort.signal, retry);
if (abort.signal.aborted) return;
setData(next);
if (next.state === "queued" || next.state === "preparing") timer = setTimeout(() => void load(), 1200);
} catch (reason) {
if (!abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Обзор недоступен.");
}
};
void load(retryGeneration > 0);
return () => { abort.abort(); clearTimeout(timer); };
}, [sessionId, retryGeneration]);
return { data: data?.session.session_id === sessionId ? data : null, error,
retry: useCallback(() => setRetryGeneration(n => n + 1), []) };
}
export function useSessionOverviewMode(enabled: boolean) {
const [open, setOpen] = useState(false);
useEffect(() => { if (!enabled) setOpen(false); }, [enabled]);
useEffect(() => {
if (!open) return;
const close = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
};
window.addEventListener("keydown", close, true);
return () => window.removeEventListener("keydown", close, true);
}, [open]);
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
}
+2 -1
View File
@@ -7,6 +7,7 @@ import "@nodedc/ui-core/styles.css";
import App from "./App";
import { installedDevicePlugins, installedNodeSensorContributions } from "./composition/devicePlugins";
import { PlanningTestProvider } from "./core/missions/PlanningTestContext";
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
import { ComputeContourProvider } from "./core/system/ComputeContourContext";
import "./styles.css";
@@ -25,7 +26,7 @@ createRoot(rootElement).render(
<StrictMode>
<DevicePluginHostProvider plugins={installedDevicePlugins} nodeSensorContributions={installedNodeSensorContributions}>
<ComputeContourProvider>
<App />
<PlanningTestProvider><App /></PlanningTestProvider>
</ComputeContourProvider>
</DevicePluginHostProvider>
</StrictMode>,
+5 -6
View File
@@ -402,11 +402,11 @@ export const workspaces: WorkspaceDefinition[] = [
},
{
id: "mission-planner",
root: "missions",
root: "polygon",
label: "Планировщик",
title: "Планировщик миссии",
eyebrow: "МИССИИ / ЧЕРНОВИК",
description: "Черновик миссии, аппараты, зона, маршрут, действия и ограничения.",
title: "Исследование планирования",
eyebrow: "LAB / ПЛАНИРОВАНИЕ",
description: "Эталонный участок и сопоставление нового прохода сканера.",
icon: "edit",
kind: "missions",
groups: [
@@ -414,8 +414,7 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Состав миссии",
description: "Продуктовый контракт без отправки команд на борт.",
capabilities: [
contract("Назначение аппарата", "Один аппарат сейчас, несколько — в будущем."),
contract("Зона и маршрут", "Точки, коридор, высота/скорость и геозоны."),
active("Зона и маршрут", "Выбор сохранённой записи, участка траектории и направления."),
contract("Полезная нагрузка", "Запуск сенсоров и требуемые выходные данные."),
later("Проверка выполнимости", "Расчёт энергии, связи, столкновений и резервов."),
],
@@ -0,0 +1,73 @@
.mission-planner { height: 100%; box-sizing: border-box; min-height: 360px; display: flex; flex-direction: column; gap: 16px; min-width: 0; flex: 1; padding: 8px 16px 20px; }
.mission-planner__toolbar { display: flex; align-items: end; gap: 8px; flex-wrap: wrap; }
.mission-planner__toolbar > .nodedc-field { flex: 1; min-width: 180px; }
.mission-planner__toolbar > .nodedc-select-anchor { width: 260px; max-width: 100%; }
.mission-planner__layout { display: grid; grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); gap: 16px; flex: 1; min-height: 0; }
.mission-planner__editor { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.mission-planner__section { display: flex; flex-direction: column; gap: 10px; }
.mission-planner h2 { font-size: var(--nodedc-font-size-md); margin: 0; font-weight: 600; }
.mission-planner p, .mission-planner__check p { font-size: var(--nodedc-font-size-sm); margin: 0; line-height: 1.5; }
.mission-planner small { font-size: var(--nodedc-font-size-xs); color: var(--nodedc-text-muted); line-height: 1.5; }
.mission-planner__actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mission-planner__viewer { min-height: 0; }
.mission-planner__viewer-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; }
.mission-planner__zone-loading { flex: 1; min-height: 320px; }
.mission-planner__notice { font-size: var(--nodedc-font-size-sm); padding: 8px 0; }
.mission-planner__expanded { width: min(1300px, 94vw); }
.mission-planner__expanded .mission-planner__viewer { height: min(65vh, 620px); min-height: 360px; }
.mission-planner__expanded .mission-route-preview svg { min-height: 140px; }
.mission-route-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; padding: 16px; }
.mission-route-preview svg { width: 100%; flex: 1; min-height: 250px; }
.mission-route-preview p { margin: 0; line-height: 1.5; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
.mission-route-preview__reference { stroke: var(--nodedc-text-muted); opacity: .35; stroke-width: 2; }
.mission-route-preview__selected { stroke: var(--nodedc-text-primary); stroke-width: 2; }
.mission-route-preview__cursor { fill: var(--nodedc-text-primary); }
.mission-route-preview text { fill: var(--nodedc-text-muted); font-size: 12px; }
.mission-planner__check { display: flex; flex-direction: column; gap: 16px; }
.mission-planner__check dl { font-size: var(--nodedc-font-size-sm); }
.mission-planner__check dl > div { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; }
.mission-planner__check dd { margin: 0; text-align: right; }
.mission-planner__check dt { color: var(--nodedc-text-muted); }
@media (max-width: 900px) { .mission-planner { height: auto; } .mission-planner__layout { grid-template-columns: minmax(0, 1fr); } .mission-planner__editor { max-height: 55vh; } .mission-planner__viewer { height: 65vh; } }
.mission-registration { width: min(1100px, 94vw); }
.mission-registration__scene { position: relative; height: 380px; min-height: 300px; overflow: hidden; }
.mission-registration--expanded .mission-registration__scene { height: 65vh; }
.mission-registration .mission-planner__viewer-head { font-size: var(--nodedc-font-size-xs); }
.planning-connection { width: min(1080px, 94vw); }
.planning-live__viewport { overflow: hidden; min-height: 520px; }
.planning-live__scene { position: relative; height: 60vh; min-height: 420px; overflow: hidden; }
.planning-live--expanded { width: min(1400px, 96vw); }
.planning-live--expanded .planning-live__scene { height: 70vh; }
.planning-live .mission-planner__toolbar, .planning-live .mission-planner__actions { font-size: var(--nodedc-font-size-sm); }
.planning-live__scene > .session-overview__empty { position: absolute; inset: 0; }
.spatial-viewport-shell > .planning-live__scene { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; }
.planning-live__summary { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 8px 4px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
.planning-live__camera-note { position: absolute; right: 20px; bottom: 24px; display: flex; align-items: center; gap: 8px; font-size: var(--nodedc-font-size-xs); color: var(--nodedc-text-muted); }
.mission-planner__editor .mission-planner__actions { display: grid; grid-template-columns: minmax(0, 1fr); }
.mission-planner__editor .inspector-control-stack > .nodedc-button, .mission-planner__editor .mission-planner__actions > .nodedc-button { width: 100%; }
.mission-planner__viewer-head--end, .session-overview__scene-head--end { justify-content: flex-end; padding: 12px; }
/* The pinned embedded Rerun canvas reserves 28 px above its 26 px view strip.
Allocate this non-content chrome outside the clipped host; pointer coordinates
remain native and the scene retains the full host height. */
.session-overview__runtime.rerun-single-view-content { top: -54px; }
/* Scene-first planning: settings float within the stage and never allocate a column. */
.planning-project { padding: 8px 16px 16px; }
.planning-project__header-tools { display: flex; align-items: center; gap: 8px; min-width: 0; }
.planning-project__header-tools > .nodedc-select-anchor { width: clamp(200px, 20vw, 300px); min-width: 0; }
.planning-project__stage { position: relative; overflow: hidden; flex: 1; min-height: 360px; }
.planning-project__viewport { position: absolute; inset: 0; overflow: hidden; }
.planning-project__viewport > .mission-registration__scene { flex: 1; height: auto; min-height: 0; }
.planning-project__result-header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 12px 16px; font-size: var(--nodedc-font-size-sm); }
.planning-project__legend { padding: 8px 16px; }
.planning-project__inspector .inspector-control-stack > .nodedc-button { width: 100%; }
.planning-project__inspector p { margin: 0; font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
.planning-project__inspector dl { margin: 0; font-size: var(--nodedc-font-size-sm); }
.planning-project__inspector dl > div { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 6px 0; }
.planning-project__inspector dt { color: var(--nodedc-text-muted); }
.planning-project__inspector dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
@media (max-width: 900px) { .mission-planner.planning-project { height: 100%; } .planning-project__header-tools { flex-wrap: wrap; } }
@@ -187,9 +187,6 @@
text-align: left;
}
.scene-metrics {
display: none;
}
}
/* The shared shell switches to its compact overlay at 760 px, but Mission
@@ -544,11 +541,6 @@
justify-content: center;
}
.scene-status--top-left {
top: 0.6rem;
left: 0.6rem;
}
.scene-device-controls {
top: 0.6rem;
max-width: calc(100% - 8rem);
@@ -0,0 +1,25 @@
.session-overview { height: 100%; min-height: 360px; flex: 1; min-width: 0; }
.session-overview__loading, .session-overview__split { height: 100%; min-height: 0; }
.session-overview__panel { height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; container-type: inline-size; }
.session-overview__panel h2 { margin: 0; padding: 16px 18px; font: inherit; font-size: var(--nodedc-font-size-md); font-weight: 600; flex: none; }
.session-overview__scene { position: relative; flex: 1; min-height: 0; overflow: hidden; }
.session-overview__runtime { position: absolute; inset: 0; }
.session-overview__runtime iframe { display: block; width: 100%; height: 100%; border: 0; }
.session-overview__scene-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding-right: 12px; flex: none; }
.session-overview__height { position: absolute; z-index: 2; top: 44px; right: 12px; bottom: 12px; width: 58px; display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
.session-overview__height > .nodedc-range-scale { flex: 1; min-height: 0; }
.session-overview__empty { display: flex; flex: 1; min-height: 160px; align-items: center; justify-content: center; flex-direction: column; gap: 14px; font-size: var(--nodedc-font-size-sm); color: var(--nodedc-text-muted); }
.session-overview__scene .session-overview__empty { position: absolute; inset: 0; }
.session-overview__note { padding: 8px 16px 12px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); flex: none; }
.session-overview__facts { overflow: auto; min-height: 0; padding: 0 18px 12px; font-size: var(--nodedc-font-size-sm); }
.session-overview__facts dl { margin: 0; }
.session-overview__facts dl > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; padding: 8px 0; }
.session-overview__facts dt { color: var(--nodedc-text-muted); }
.session-overview__facts dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.session-overview__facts p { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); line-height: 1.5; }
.session-overview__chart { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.session-overview__chart svg { width: 100%; flex: 1; min-height: 0; overflow: visible; }
.session-overview__chart text { fill: var(--nodedc-text-muted); font-size: 12px; }
.session-overview__grid { stroke: var(--nodedc-text-muted); opacity: .2; stroke-width: 1; }
.session-overview__series { stroke: var(--nodedc-text-primary); stroke-width: 1.5; }
@container (max-width: 270px) { .session-overview__facts dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } .session-overview__facts dd { text-align: left; overflow-wrap: anywhere; } }
@@ -1,3 +1,4 @@
import { PlanningConnectionWindow } from "../components/missions/PlanningConnectionWindow";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
@@ -40,7 +41,7 @@ function ModelCard({
);
}
export function DeviceWorkspace({
function DeviceConnectionBody({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
@@ -128,3 +129,7 @@ export function DeviceWorkspace({
</div>
);
}
export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) {
return <PlanningConnectionWindow><DeviceConnectionBody {...props}/></PlanningConnectionWindow>;
}
@@ -1,39 +1,26 @@
import {SpatialScene, EmptySpatialStage} from '../../../../packages/spatial-ui/src';
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
import { usePlanningTest } from "../core/missions/PlanningTestContext";
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo } from "react";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import {
ObservationMedia,
ObservationSourcePicker,
observationSourceStatusLabel,
} from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
rerunPresentationStatus,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunSelection,
type RerunViewportStatus,
type RecordedPerceptionLoadState,
type RecordedPointColorLoadState,
} from "../components/RerunViewport";
import {
capabilityStatusLabel,
type CapabilityStatus,
type WorkspaceDefinition,
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import { sourceModeLabel } from "../presentation";
import type { WorkspaceRendererProps } from "./contracts";
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
@@ -91,608 +78,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
</section>
);
}
function SpatialWorkspace({
state,
sourceUrl,
requestedPlaybackSeconds,
recordedReplay,
recordedSessionAdmission,
sceneSettings,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
livePerceptionLayers,
onLivePerceptionLayersChange,
observationLayout,
navigation,
spatialControls,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const lastRequestedPlaybackSeconds = useRef<number | null>(null);
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [showDetections2d, setShowDetections2d] = useState(false);
const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = useState(false);
const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl);
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
? recordedSessionAdmission?.phase ?? "loading"
: "ready";
const recordedPlaybackReady = !recordedSource ||
(recordedSessionGate === "ready" &&
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
// An explicit manual gRPC source has no Mission Core metrics producer. Its
// native Rerun range is therefore the only available activity proof.
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
(liveRerunSource && !streamActive ? 1 : null);
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
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 recordedPerceptionSupported =
recordedSource && perceptionLoad.phase !== "unavailable";
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
const recordedPerceptionEnabled =
showDetections2d || showSegmentation || showCuboids3d;
// The native recorded camera remains the authoritative original. Only 2D
// image-space overlays need Rerun's paired camera/world composition; 3D
// cuboids are added directly to the stable spatial view.
const unifiedPerception = recordedPerceptionSupported &&
(showDetections2d || showSegmentation);
const livePerceptionAvailable = !recordedSource && streamActive;
const detections2dActive = recordedSource
? showDetections2d
: livePerceptionLayers.detections2d;
const segmentationActive = recordedSource
? showSegmentation
: livePerceptionLayers.segmentation;
const cuboids3dActive = recordedSource
? showCuboids3d
: livePerceptionLayers.cuboids3d;
const visibleMediaSources = mediaSources.filter((source) =>
observationLayout.visibleSourceIds.has(source.id) &&
!source.id.startsWith("recorded.perception."),
);
const initialRecordedPlaybackStartSeconds = recordedSource
? mediaSources.reduce<number | undefined>((earliest, source) => {
if (
source.id.startsWith("recorded.perception.") ||
source.delivery?.kind !== "recorded-fmp4-manifest"
) return earliest;
const start = source.delivery.timelineStartSeconds;
return earliest === undefined ? start : Math.min(earliest, start);
}, undefined)
: undefined;
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
const pointCloudFocused = Boolean(
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
);
useEffect(() => {
if (!pointCloudFocused) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
observationLayout.setFocusedSourceId(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
const floatingSourceMaximized = !unifiedPerception &&
Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const intentionalSourceEnd = !recordedSource && state?.sourceMode === "idle" && ["awaiting_external_stop", "stopping", "finalizing", "completed",].includes(state?.acquisition?.state ?? "");
const presentedViewerStatus = intentionalSourceEnd
? "idle"
: rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
setViewerMessage(message ?? "");
if (recordedSessionAdmission) {
recordedSessionAdmission.reportSpatial(
recordedSessionAdmission.key,
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
);
}
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
const onRecordedAdmissionChange = useCallback((
sourceId: string,
next: RecordedCameraAdmissionState,
) => {
if (!recordedSessionAdmission) return;
if (next.admissionKey !== recordedSessionAdmission.key) return;
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
}, [recordedSessionAdmission]);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const onPlaybackChange = useCallback(
(next: RerunPlaybackState | null) => setPlaybackState(next),
[],
);
const onPlaybackControllerChange = useCallback(
(next: RerunPlaybackController | null) => setPlaybackController(next),
[],
);
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
setPerceptionLoad(next);
if (next.phase === "unavailable" || next.phase === "error") {
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
}
}, []);
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
setPointColorLoad(next);
}, []);
useEffect(() => {
setPerceptionLoad({
phase: recordedSource ? "loading" : "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
});
setPerceptionRetryGeneration(0);
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setRecordedViewResetGeneration(0);
setFollowRecordedTrajectory(false);
setPointColorLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}, [recordedSource, sourceUrl]);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
setViewerStatus("idle");
setViewerMessage("");
setSelection(null);
setPlaybackState(null);
setPlaybackController(null);
setPerceptionLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setFollowRecordedTrajectory(false);
setPointColorLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}, [pointCloudVisible, sourceUrl]);
useEffect(() => {
lastRequestedPlaybackSeconds.current = null;
}, [sourceUrl]);
useEffect(() => {
if (
!recordedSource
|| !playbackController
|| requestedPlaybackSeconds === null
|| requestedPlaybackSeconds === undefined
|| !Number.isFinite(requestedPlaybackSeconds)
|| lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds
) return;
lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds;
playbackController.setPlaying(false);
playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000));
}, [playbackController, recordedSource, requestedPlaybackSeconds]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const publishViewportSize = () => {
const bounds = viewport.getBoundingClientRect();
if (bounds.width < 1 || bounds.height < 1) return;
observationLayout.setViewportSize({
width: bounds.width,
height: bounds.height,
});
};
publishViewportSize();
const observer = new ResizeObserver(publishViewportSize);
observer.observe(viewport);
return () => observer.disconnect();
}, [observationLayout.setViewportSize]);
const viewerStatusLabel = {
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
}[presentedViewerStatus];
const viewerStatusTone = presentedViewerStatus === "ready"
? "success"
: presentedViewerStatus === "error"
? "danger"
: "neutral";
const rerunViewerProfile = recordedSource
? recordedSessionRerunProfile({
sourceUrl,
artifact: recordedReplay,
autoplayWhenReady: true,
presentationGate: recordedSessionGate,
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
view: "spatial",
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: followRecordedTrajectory,
perceptionLayers: {
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
},
perceptionRetryGeneration,
lockPerceptionCameraInteraction: unifiedPerception,
})
: liveAcquisitionRerunProfile({
sourceUrl,
liveActivitySequence: livePresentationActivitySequence,
liveStreamId: state?.spatialSource?.id ?? null,
liveRecoveryAuthorityIdentity: streamActive
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
: null,
});
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
toolbar={<> {recordedPerceptionSupported || livePerceptionAvailable ? (
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
<Button
size="compact"
variant="primary"
icon={<Icon name="video" />}
aria-pressed="true"
disabled
>
Оригинал
</Button>
<Button
size="compact"
variant={detections2dActive ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={detections2dActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowDetections2d((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
detections2d: !livePerceptionLayers.detections2d,
})}
>
Объекты 2D
</Button>
<Button
size="compact"
variant={segmentationActive ? "primary" : "secondary"}
icon={<Icon name="image" />}
aria-pressed={segmentationActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowSegmentation((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
segmentation: !livePerceptionLayers.segmentation,
})}
>
Сегментация
</Button>
<Button
size="compact"
variant={cuboids3dActive ? "primary" : "secondary"}
icon={<Icon name="apps" />}
aria-pressed={cuboids3dActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowCuboids3d((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
cuboids3d: !livePerceptionLayers.cuboids3d,
})}
>
Кубы 3D
</Button>
</div>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
variant={followRecordedTrajectory ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={followRecordedTrajectory}
title="Удерживать orbital-пивот на риге до повторного нажатия"
onClick={() => setFollowRecordedTrajectory((current) => !current)}
>
Следовать
</Button>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
variant="secondary"
icon={<Icon name="refresh" />}
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
>
Сброс вида
</Button>
) : null}
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={<> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<RerunViewport
profile={rerunViewerProfile}
sceneSettings={sceneSettings}
onPerceptionLoadChange={onPerceptionLoadChange}
onPointColorLoadChange={onPointColorLoadChange}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
onPlaybackChange={onPlaybackChange}
onPlaybackControllerChange={onPlaybackControllerChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
</>}
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
model={spatialControls.model}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>):null}
sourceControls={<> {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={unifiedPerception
? observationSources.filter((source) => source.modality === "point-cloud")
: observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
pendingSourceIds={observationLayout.pendingSourceIds}
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}
</>}
status={{label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
metrics={<> <div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
<div>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
{streamActive ? (
<div>
<span>AI</span>
<strong>
{aiLatency === null ? "—" : formatNumber(aiLatency)}
<small>{aiLatency === null ? "" : " мс"}</small>
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
</strong>
</div>
) : null}</>} overlays={<> {!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
</div>
) : null}
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : null}
{!floatingSourceMaximized && recordedSource && (
perceptionLoad.phase === "loading" ||
perceptionLoad.phase === "error" ||
pointColorLoad.phase === "loading" ||
pointColorLoad.phase === "error"
) ? (
<div className="scene-operation-status-stack">
{perceptionLoad.phase === "loading" ? (
<div className="scene-operation-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
</div>
) : null}
{perceptionLoad.phase === "error" ? (
<button
type="button"
className="scene-operation-status scene-operation-status--action"
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
>
<Icon name="refresh" size={12} />
<span>Повторить AI</span>
</button>
) : null}
{pointColorLoad.phase === "loading" ? (
<div className="scene-operation-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{pointColorLoad.message}</span>
</div>
) : null}
{pointColorLoad.phase === "error" ? (
<div className="scene-operation-status scene-operation-status--error" role="status">
<span>{pointColorLoad.message}</span>
</div>
) : null}
</div>
) : null}
</>}
navigationReady={presentedViewerStatus==='ready'} timeline={<> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline
active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
mode={recordedSource && playbackState?.rangeNs
? "recorded"
: timeline?.mode}
seekable={recordedSource && playbackState?.rangeNs
? true
: timeline?.seekable}
synchronization={timeline?.synchronization}
rangeNs={playbackState?.rangeNs}
currentNs={playbackState?.currentNs}
playing={playbackState?.playing}
onSeek={playbackController?.seek}
onPlayingChange={playbackController?.setPlaying}
onJumpToEnd={playbackController?.jumpToEnd}
accumulationSeconds={accumulationSeconds}
onAccumulationChange={onAccumulationChange}
onAccumulationCommit={onAccumulationCommit}
className="scene-timeline"
/>
) : null}
</>}
media={<> {visibleMediaSources.map((source, index) => (
<FloatingObservationWindow
key={source.id}
source={source}
index={index}
count={visibleMediaSources.length}
boundsRef={viewportRef}
rect={observationLayout.windowRects[source.id]}
maximized={observationLayout.maximizedFloatingSourceId === source.id}
active={observationLayout.activeFloatingSourceId === source.id}
hidden={pointCloudFocused || unifiedPerception}
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
playback={recordedSource && playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: playbackState.playing,
} : null}
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
onClose={() => {
if (observationLayout.pendingSourceIds.has(source.id)) return;
observationLayout.setFloatingMaximized(source.id, false);
void observationLayout.hideSource(source.id);
}}
/>
))}
{recordedSource ? (
<div className="recorded-session-preloaders" aria-hidden="true">
{mediaSources.filter((source) => (
source.delivery?.kind === "recorded-fmp4-manifest" &&
!observationLayout.visibleSourceIds.has(source.id) &&
shouldPrepareRecordedSource(source.id)
)).map((source) => (
<ObservationMedia
key={source.id}
source={source}
playback={playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: false,
} : null}
prepareRecorded
recordedSessionGate="loading"
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
))}
</div>
) : null}</>} footer={ <div className="spatial-contract-strip">
<span><i data-state="ready" />Облако точек</span>
<span><i data-state="ready" />Траектория</span>
<span><i data-state="ready" />Преобразования</span>
<span><i data-state="contract" />Камеры в 3D</span>
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
<span><i data-state="contract" />Компоновка</span>
</div>}/>;
}
function CameraSourceCard({
source,
focused,
@@ -997,54 +382,6 @@ function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
);
}
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
const steps = [
{ id: "01", label: "Аппарат", value: "Не назначен" },
{ id: "02", label: "Зона", value: "Не задана" },
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
{ id: "04", label: "Наблюдение", value: "Облако точек" },
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
];
return (
<div className="standard-workspace mission-workspace">
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
<div className="mission-layout">
<GlassSurface className="mission-sequence" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
<h2>Новая миссия</h2>
</div>
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
</header>
<div className="mission-steps">
{steps.map((step) => (
<button key={step.id} type="button" disabled>
<span>{step.id}</span>
<div><strong>{step.label}</strong><small>{step.value}</small></div>
<Icon name="chevron-right" />
</button>
))}
</div>
</GlassSurface>
<GlassSurface className="mission-summary" padding="lg">
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
<div className="mission-summary__checks">
<span><i />Аппарат</span>
<span><i />Геометрия</span>
<span><i />Связь</span>
<span><i />Безопасность</span>
</div>
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
const active = definition.groups.reduce(
@@ -1079,9 +416,10 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
const planning = usePlanningTest();
switch (props.definition.kind) {
case "spatial":
return <SpatialWorkspace {...props} />;
return planning.selected ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
case "recordings":
return <RecordingsWorkspace {...props} />;
case "cameras":
@@ -1093,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
case "timeline":
return <TimelineWorkspace {...props} />;
case "missions":
return <MissionWorkspace {...props} />;
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
case "vehicles":
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
case "catalog":
@@ -36,6 +36,7 @@ export interface LaboratoryViewAction {
export interface WorkspaceRendererProps {
fleetCreateRequest?: number;
headerToolsHost?: HTMLElement | null;
definition: WorkspaceDefinition;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, ConfirmationModal, GlassSurface, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge, WorkspaceWindow, type WorkspaceWindowRect } from '@nodedc/ui-react';
import { usePlanningTest } from '../../core/missions/PlanningTestContext';
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import { useRegistrationTest } from '../../core/missions/useRegistrationTest';
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
import { MissionZonePreview } from '../../components/missions/MissionZonePreview';
import { MissionRoutePreview } from '../../components/missions/MissionRoutePreview';
import { RegistrationScene } from '../../components/missions/RegistrationScene';
import { PlanningProjectSettings } from '../../components/missions/PlanningProjectSettings';
import { PlanningProjectResult } from '../../components/missions/PlanningProjectResult';
import { PlanningProjectSelect } from '../../components/missions/PlanningProjectSelect';
import '../../styles/session-overview.css';
import '../../styles/mission-planner.css';
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string)=>void;headerToolsHost?:HTMLElement|null}) {
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
const t=useRegistrationTest(p.saved);
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
const [mode,setMode]=useState<'scanner'|'recording'>('scanner');
const [view,setView]=useState<'cloud'|'route'>('cloud');
const [starting,setStarting]=useState(false);
const [pending,setPending]=useState<(()=>void)|null>(null);
const bounds=useRef<HTMLDivElement>(null);
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:16,width:390,height:600});
const [maximized,setMaximized]=useState(false);
const project=projects.detail;
const editing=creating||project?.kind==='draft';
const loadedDraft=useRef('');
useEffect(()=>{
if(project?.kind==='draft'&&loadedDraft.current!==project.key) {
loadedDraft.current=project.key; void p.openDraft(project.id); setSettingsOpen(true);
}
},[project?.key]);
const replace=(action:()=>void)=>{
if(editing&&p.dirty&&(p.sessionId||p.name))setPending(()=>action);else action();
};
const choose=(key:string)=>replace(()=>{setCreating(false);setSettingsOpen(false);projects.select(key);});
const newProject=()=>replace(()=>{
projects.select('');p.newDraft();t.setSessionId('');setMode('scanner');setView('cloud');
setCreating(true);setSettingsOpen(true);setMaximized(false);loadedDraft.current='';
});
const start=async()=>{
if(starting)return;setStarting(true);
try {
const draft=p.saved&&!p.dirty?p.saved:await p.save();
if(!draft)return;
if(mode==='scanner') {
const next=await live.begin(draft);
if(next){projects.select('live:'+next.id);openView('local-device');}
} else {
const result=await t.run(draft);
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
}
projects.refresh();
} finally {setStarting(false);}
};
const tools=<div className="planning-project__header-tools">
<PlanningProjectSelect value={projects.key} items={projects.items} disabled={starting} onChange={choose}
onRemove={async target => {
await projects.remove(target);
if (target.key === projects.key) {
setCreating(false); setSettingsOpen(false); loadedDraft.current = ''; p.newDraft();
}
}} />
<IconButton label="Создать проект" disabled={starting} onClick={newProject}><Icon name="plus"/></IconButton>
<IconButton label="Обновить проекты" loading={projects.loading} disabled={starting} onClick={()=>{projects.refresh();p.refresh();}}><Icon name="refresh"/></IconButton>
<IconButton label="Настройки" aria-pressed={settingsOpen} onClick={()=>setSettingsOpen(v=>!v)}><Icon name="settings"/></IconButton>
</div>;
const zoneControls=<SegmentedControl label="Представление зоны" value={view} onChange={setView} items={[{value:'cloud',label:'Облако'},{value:'route',label:'Маршрут'}]}/>;
return <div className="mission-planner planning-project">
{headerToolsHost?createPortal(tools,headerToolsHost):tools}
<div ref={bounds} className="planning-project__stage" onKeyDown={event=>{
if(event.key==='Escape'&&!event.defaultPrevented&&settingsOpen){event.preventDefault();event.stopPropagation();setSettingsOpen(false);}
}}>
<GlassSurface className="planning-project__viewport session-overview__panel" radius="panel">
{editing?<>
{(view!=='cloud'||!p.source)&&<header className="mission-planner__viewer-head mission-planner__viewer-head--end">{zoneControls}</header>}
{!p.sessionId?<div className="session-overview__empty">Выберите сохранённую запись эталона в настройках.</div>
:p.sourceError||p.sourceChanged?<div className="session-overview__empty" role="alert"><span>{p.sourceError||'Исходная запись изменилась.'}</span><Button onClick={p.retrySource}>Повторить загрузку</Button></div>
:!p.source?<LoadingRegion loading label="Подготовка эталона" className="mission-planner__zone-loading"/>
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
</>:projects.error?<div className="session-overview__empty" role="alert"><span>{projects.error}</span><Button onClick={projects.refresh}>Повторить</Button></div>
:project?<>
<header className="planning-project__result-header"><span>{project.name}</span><StatusBadge tone={project.result_status==='rejected'?'warning':'neutral'}>{planningProjectStatus(project)}</StatusBadge></header>
{project.scene_url?<RegistrationScene sourceUrl={project.scene_url}/>
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись остаётся в разделе «Данные».</small></div>}
{project.scene_url&&<small className="planning-project__legend">{project.result?.correspondence_colors?'Серый — эталон · цветной — повторный проход · зелёный — точки в пределах 0,5 м':'Серый — эталон · зелёный — повторный проход (ранний формат записи)'}</small>}
</>:projects.loading||projects.key?<LoadingRegion loading label="Загрузка проекта" className="mission-planner__zone-loading"/>
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
</GlassSurface>
{settingsOpen&&<WorkspaceWindow boundsRef={bounds} rect={rect} onRectChange={setRect} maximized={maximized} onMaximizedChange={setMaximized}
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
className="planning-project__inspector">
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{await live.select(project.id);live.resume();openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene');}}>Открыть текущий проход</Button>}
{live.error&&editing&&<p role="alert">{live.error}</p>}
</WorkspaceWindow>}
</div>
<ConfirmationModal open={!!pending} title="Закрыть настройки проекта?" description="Несохранённые изменения будут потеряны." confirmLabel="Продолжить" onClose={()=>setPending(null)} onConfirm={()=>{pending?.();setPending(null);}}/>
</div>;
}
@@ -0,0 +1,51 @@
import {useState} from 'react';
import {Button,Checker,Icon,LoadingRegion,RangeControl,SegmentedControl} from '@nodedc/ui-react';
import {SpatialToolbarActions} from '../../../../../packages/spatial-ui/src/SpatialToolbarActions';
import {planningActivity,planningMatchCurrent,planningStatus} from '../../core/missions/planningPresentation';
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
import {PlanningLiveScene} from '../../components/missions/PlanningLiveScene';
import {PlanningSceneToolWindow} from '../../components/missions/PlanningSceneToolWindow';
import {SpatialWorkspace} from '../spatial/SpatialWorkspace';
import type {WorkspaceRendererProps} from '../contracts';
import '../../styles/mission-planner.css';
/** Planning supplies geometry and evidence to the ordinary scene, never replaces its camera/recording owners. */
export function PlanningSpatialWorkspace(props:WorkspaceRendererProps){
const p=usePlanningTest();
const [tool,setTool]=useState<'layers'|'display'|null>(null);
const [view,setView]=useState('3d'),[reset,setReset]=useState(0);
const [layers,setLayers]=useState({reference:true,query:true,trajectory:true,grid:true});
const [pointSize,setPointSize]=useState(1.8);
const t=p.test;if(!t)return null;
const terminal=planningTestTerminal(t),readyMatch=planningMatchCurrent(t,!!p.error);
const status=planningStatus(t,p.error);
const options={...layers,mode:view,point_size:pointSize,reset};
const heightMin=t.scene_height_min_m,heightMax=t.scene_height_max_m;
const heightBounds=typeof heightMin==='number'&&typeof heightMax==='number'&&Number.isFinite(heightMin)&&Number.isFinite(heightMax)&&heightMax>heightMin?{min:heightMin,max:heightMax}:null;
const renderer=t.scene_available?<PlanningLiveScene runId={t.id} options={options} active={t.state==='running'} revision={t.scene_revision} heightBounds={heightBounds}/>:<LoadingRegion loading={t.state==='preparing'} label="Подготовка эталона" className="planning-live__scene"><p>{t.message}</p></LoadingRegion>;
const toolbar=<>
<SegmentedControl label="Вид сцены планирования" value={view} onChange={setView} items={[{value:'top',label:'Сверху'},{value:'3d',label:'3D'}]}/>
<Button size="compact" icon={<Icon name="refresh"/>} onClick={()=>setReset(v=>v+1)}>Сброс вида</Button>
<SpatialToolbarActions activeTool={tool} openLayers={()=>setTool(v=>v==='layers'?null:'layers')} openDisplay={()=>setTool(v=>v==='display'?null:'display')}/>
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button size="compact" icon={<Icon name="refresh"/>} loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
{!terminal&&<Button size="compact" loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}
</>;
const footer=<div className="planning-live__summary">
<span>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</span>
<span>Серый эталон · цветной новый проход · зелёный совпавшие точки</span>
{t.query_session_id&&<span>Запись прохода сохранена отдельно от расчёта исследования.</span>}
{terminal&&t.result&&<span>Последний расчёт: {t.result.status==='candidate'?'кандидат совмещения':'совпадение не подтверждено'} · {(t.result.overlap*100).toFixed(1)}%.</span>}
</div>;
return <>
<SpatialWorkspace {...props} recordedReplay={null} recordedSessionAdmission={null} visualProfile={{renderer,toolbar,status,footer,activity:planningActivity(t,p.error),
metrics:<><div><span>Пройдено в исследовании</span><strong>{t.distance_m.toFixed(1)} м</strong></div>
<div><span>Точек</span><strong>{(t.query_points??0).toLocaleString('ru-RU')}</strong></div>
<div><span>Совпадение</span><strong>{readyMatch&&t.result?`${(t.result.overlap*100).toFixed(1)}%`:'—'}</strong></div></>,
mediaFallback:<div className="planning-live__camera-note"><Icon name="camera"/><span>Нет активного изображения камеры</span></div>,
tools:boundsRef=>tool&&<PlanningSceneToolWindow boundsRef={boundsRef} title={tool==='layers'?'Слои':'Отображение'} onClose={()=>setTool(null)}>
{tool==='layers'?(['reference','query','trajectory'] as const).map((key,i)=><Checker key={key} checked={layers[key]} label={['Эталон','Новый проход','Траектории'][i]} onChange={value=>setLayers(v=>({...v,[key]:value}))}/>)
:<><RangeControl label="Размер точки" value={pointSize} min={.5} max={12} step={.5} exactValueBounds={{min:.5,max:12}} onChange={setPointSize}/><Checker label="Сетка" checked={layers.grid} onChange={grid=>setLayers(v=>({...v,grid}))}/></>}
</PlanningSceneToolWindow>,
}}/>
</>;
}
@@ -0,0 +1,63 @@
import { useState } from "react";
import { Button, GlassSurface, LoadingRegion, SplitPane } from "@nodedc/ui-react";
import { useSessionOverview } from "../../core/observation/useSessionOverview";
import { SessionOverviewScene } from "../../components/observation/SessionOverviewScene";
import { SessionIntervalChart } from "../../components/observation/SessionIntervalChart";
import "../../styles/session-overview.css";
const fmt = (n: number | null | undefined, digits = 0, suffix = "") => n == null || !Number.isFinite(n)
? "—" : `${n.toLocaleString("ru-RU", { maximumFractionDigits: digits })}${suffix}`;
const duration = (n: number | null | undefined) => n == null ? "—" : `${Math.floor(n / 60)} мин ${Math.round(n % 60)} с`;
function initialSize(key: string, fallback: number) {
try { const n = Number(localStorage.getItem(key)); return n >= 25 && n <= 80 ? n : fallback; } catch { return fallback; }
}
function usePaneSize(name: string, fallback: number) {
const key = `missioncore.session-overview.layout.v1.${name}`;
const [size, setSize] = useState(() => initialSize(key, fallback));
return [size, (n: number) => { setSize(n); try { localStorage.setItem(key, String(n)); } catch { /* layout remains usable */ } }] as const;
}
export function SessionOverviewWorkspace({ sessionId }: { sessionId: string }) {
const { data, error, retry } = useSessionOverview(sessionId);
const [top, setTop] = usePaneSize("top", 70);
const [left, setLeft] = usePaneSize("left", 68);
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
const failure = error || (data?.state === "error" ? data.message : null);
const m = data?.metrics;
const facts: [string, string][] = [
["Начало записи", data?.session.started_at_utc ? new Date(data.session.started_at_utc).toLocaleString("ru-RU") : "—"],
["Длительность сессии", duration(data?.session.duration_seconds)],
["Поток облака", duration(m?.stream_seconds)],
["Кадры облака", fmt(m?.point_frames)],
["Наблюдения точек", fmt(m?.point_count)],
["Положения сканера", fmt(m?.pose_frames)],
["Путь по траектории", fmt(m?.path_m, 2, " м")],
["От старта до финиша", fmt(m?.start_end_m, 2, " м")],
["Средняя частота", fmt(m?.mean_hz, 2, " Гц")],
["Максимальный интервал", fmt(m?.interval_max_s, 3, " с")],
["Интервалы больше секунды", fmt(m?.gaps_over_second)],
["Пропуски номеров", fmt(m?.sequence_gaps)],
["Ошибки чтения кадров", fmt(m?.decode_errors)],
["Объём исходных данных", fmt(data ? data.session.total_bytes / 1_000_000 : null, 1, " МБ")],
];
return <div className="session-overview" aria-label="Информация о записи">
<LoadingRegion loading={pending} label={data?.state === "queued" ? "Ожидание подготовки обзора" : "Подготовка обзора записи"} className="session-overview__loading">
{failure ? <div className="session-overview__empty" role="alert"><span>{failure}</span><Button onClick={retry}>Повторить</Button></div> : pending ? null : <SplitPane orientation="horizontal" primarySize={top} onPrimarySizeChange={setTop} minPrimarySize={35} minSecondarySize={20}
separatorLabel="Высота графика интервалов" className="session-overview__split"
primary={<SplitPane primarySize={left} onPrimarySizeChange={setLeft} minPrimarySize={35} minSecondarySize={25} separatorLabel="Ширина облака и сводки" className="session-overview__split"
primary={<GlassSurface className="session-overview__panel" radius="panel">
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
{!m?.spatial_available && <span className="session-overview__note">Доступны сведения из каталога записи.</span>}
</GlassSurface>}
secondary={<GlassSurface className="session-overview__panel" radius="panel">
<h2>Сведения о записи</h2><div className="session-overview__facts">
<dl>{facts.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
</div>
</GlassSurface>} />}
secondary={<GlassSurface className="session-overview__panel" radius="panel"><h2>Интервалы поступления кадров</h2>
<SessionIntervalChart chart={m?.chart ?? []} bucketSeconds={m?.chart_bucket_seconds ?? 1} />
</GlassSurface>} />}
</LoadingRegion>
</div>;
}
@@ -0,0 +1,642 @@
import {SpatialScene, EmptySpatialStage} from '../../../../../packages/spatial-ui/src';
import {SpatialToolbarActions} from "../../../../../packages/spatial-ui/src/SpatialToolbarActions";
import { type ReactNode, type RefObject, useCallback, useEffect, useRef, useState } from "react";
import { Button, Icon, IconButton } from "@nodedc/ui-react";
import {
ObservationMedia,
ObservationSourcePicker,
} from "../../components/ObservationSources";
import { ObservationTimeline } from "../../components/ObservationTimeline";
import { FloatingObservationWindow } from "../../components/FloatingObservationWindow";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../../core/observation/recordedSessionAdmission";
import { liveRerunRecoveryAuthorityIdentity } from "../../core/observation/liveReceiverWatchdog";
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
rerunPresentationStatus,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunSelection,
type RerunViewportStatus,
type RecordedPerceptionLoadState,
type RecordedPointColorLoadState,
} from "../../components/RerunViewport";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../../presentation";
import type { WorkspaceRendererProps } from "../contracts";
export interface SpatialWorkspaceProfile {
renderer: ReactNode;
toolbar: ReactNode;
status: {label: string; tone: 'neutral'|'success'|'warning'|'danger'; message?: string; pulse?: boolean};
metrics: ReactNode;
footer: ReactNode;
sourceControls?: ReactNode;
mediaFallback?: ReactNode;
tools?: (boundsRef: RefObject<HTMLDivElement|null>) => ReactNode;
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
}
export function SpatialWorkspace({
state,
sourceUrl,
requestedPlaybackSeconds,
recordedReplay,
recordedSessionAdmission,
sceneSettings,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
livePerceptionLayers,
onLivePerceptionLayersChange,
observationLayout,
navigation,
spatialControls,
visualProfile,
}: WorkspaceRendererProps & { visualProfile?: SpatialWorkspaceProfile }) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const lastRequestedPlaybackSeconds = useRef<number | null>(null);
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [showDetections2d, setShowDetections2d] = useState(false);
const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = useState(false);
const recordedSource = !visualProfile && (Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl));
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
? recordedSessionAdmission?.phase ?? "loading"
: "ready";
const recordedPlaybackReady = !recordedSource ||
(recordedSessionGate === "ready" &&
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
// An explicit manual gRPC source has no Mission Core metrics producer. Its
// native Rerun range is therefore the only available activity proof.
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
(liveRerunSource && !streamActive ? 1 : null);
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
const observationSources = (state?.observationSources ?? []).filter(source => !visualProfile || source.transport !== "recording");
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 recordedPerceptionSupported =
recordedSource && perceptionLoad.phase !== "unavailable";
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
const recordedPerceptionEnabled =
showDetections2d || showSegmentation || showCuboids3d;
// The native recorded camera remains the authoritative original. Only 2D
// image-space overlays need Rerun's paired camera/world composition; 3D
// cuboids are added directly to the stable spatial view.
const unifiedPerception = recordedPerceptionSupported &&
(showDetections2d || showSegmentation);
const livePerceptionAvailable = !recordedSource && streamActive;
const detections2dActive = recordedSource
? showDetections2d
: livePerceptionLayers.detections2d;
const segmentationActive = recordedSource
? showSegmentation
: livePerceptionLayers.segmentation;
const cuboids3dActive = recordedSource
? showCuboids3d
: livePerceptionLayers.cuboids3d;
const visibleMediaSources = mediaSources.filter((source) =>
observationLayout.visibleSourceIds.has(source.id) &&
!source.id.startsWith("recorded.perception."),
);
const initialRecordedPlaybackStartSeconds = recordedSource
? mediaSources.reduce<number | undefined>((earliest, source) => {
if (
source.id.startsWith("recorded.perception.") ||
source.delivery?.kind !== "recorded-fmp4-manifest"
) return earliest;
const start = source.delivery.timelineStartSeconds;
return earliest === undefined ? start : Math.min(earliest, start);
}, undefined)
: undefined;
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
const pointCloudFocused = Boolean(
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
);
useEffect(() => {
if (!pointCloudFocused) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
observationLayout.setFocusedSourceId(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
const floatingSourceMaximized = !unifiedPerception &&
Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const intentionalSourceEnd = !recordedSource && state?.sourceMode === "idle" && ["awaiting_external_stop", "stopping", "finalizing", "completed",].includes(state?.acquisition?.state ?? "");
const presentedViewerStatus = intentionalSourceEnd
? "idle"
: rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
setViewerMessage(message ?? "");
if (recordedSessionAdmission) {
recordedSessionAdmission.reportSpatial(
recordedSessionAdmission.key,
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
);
}
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
const onRecordedAdmissionChange = useCallback((
sourceId: string,
next: RecordedCameraAdmissionState,
) => {
if (!recordedSessionAdmission) return;
if (next.admissionKey !== recordedSessionAdmission.key) return;
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
}, [recordedSessionAdmission]);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const onPlaybackChange = useCallback(
(next: RerunPlaybackState | null) => setPlaybackState(next),
[],
);
const onPlaybackControllerChange = useCallback(
(next: RerunPlaybackController | null) => setPlaybackController(next),
[],
);
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
setPerceptionLoad(next);
if (next.phase === "unavailable" || next.phase === "error") {
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
}
}, []);
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
setPointColorLoad(next);
}, []);
useEffect(() => {
setPerceptionLoad({
phase: recordedSource ? "loading" : "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
});
setPerceptionRetryGeneration(0);
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setRecordedViewResetGeneration(0);
setFollowRecordedTrajectory(false);
setPointColorLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}, [recordedSource, sourceUrl]);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
setViewerStatus("idle");
setViewerMessage("");
setSelection(null);
setPlaybackState(null);
setPlaybackController(null);
setPerceptionLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setFollowRecordedTrajectory(false);
setPointColorLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}, [pointCloudVisible, sourceUrl]);
useEffect(() => {
lastRequestedPlaybackSeconds.current = null;
}, [sourceUrl]);
useEffect(() => {
if (
!recordedSource
|| !playbackController
|| requestedPlaybackSeconds === null
|| requestedPlaybackSeconds === undefined
|| !Number.isFinite(requestedPlaybackSeconds)
|| lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds
) return;
lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds;
playbackController.setPlaying(false);
playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000));
}, [playbackController, recordedSource, requestedPlaybackSeconds]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const publishViewportSize = () => {
const bounds = viewport.getBoundingClientRect();
if (bounds.width < 1 || bounds.height < 1) return;
observationLayout.setViewportSize({
width: bounds.width,
height: bounds.height,
});
};
publishViewportSize();
const observer = new ResizeObserver(publishViewportSize);
observer.observe(viewport);
return () => observer.disconnect();
}, [observationLayout.setViewportSize]);
const viewerStatusLabel = {
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
}[presentedViewerStatus];
const viewerStatusTone = presentedViewerStatus === "ready"
? "success"
: presentedViewerStatus === "error"
? "danger"
: "neutral";
const rerunViewerProfile = recordedSource
? recordedSessionRerunProfile({
sourceUrl,
artifact: recordedReplay,
autoplayWhenReady: true,
presentationGate: recordedSessionGate,
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
view: "spatial",
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: followRecordedTrajectory,
perceptionLayers: {
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
},
perceptionRetryGeneration,
lockPerceptionCameraInteraction: unifiedPerception,
})
: liveAcquisitionRerunProfile({
sourceUrl,
liveActivitySequence: livePresentationActivitySequence,
liveStreamId: state?.spatialSource?.id ?? null,
liveRecoveryAuthorityIdentity: streamActive
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
: null,
});
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
toolbar={visualProfile?.toolbar ?? <> {recordedPerceptionSupported || livePerceptionAvailable ? (
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
<Button
size="compact"
variant="primary"
icon={<Icon name="video" />}
aria-pressed="true"
disabled
>
Оригинал
</Button>
<Button
size="compact"
variant={detections2dActive ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={detections2dActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowDetections2d((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
detections2d: !livePerceptionLayers.detections2d,
})}
>
Объекты 2D
</Button>
<Button
size="compact"
variant={segmentationActive ? "primary" : "secondary"}
icon={<Icon name="image" />}
aria-pressed={segmentationActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowSegmentation((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
segmentation: !livePerceptionLayers.segmentation,
})}
>
Сегментация
</Button>
<Button
size="compact"
variant={cuboids3dActive ? "primary" : "secondary"}
icon={<Icon name="apps" />}
aria-pressed={cuboids3dActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowCuboids3d((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
cuboids3d: !livePerceptionLayers.cuboids3d,
})}
>
Кубы 3D
</Button>
</div>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
variant={followRecordedTrajectory ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={followRecordedTrajectory}
title="Удерживать orbital-пивот на риге до повторного нажатия"
onClick={() => setFollowRecordedTrajectory((current) => !current)}
>
Следовать
</Button>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
variant="secondary"
icon={<Icon name="refresh" />}
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
>
Сброс вида
</Button>
) : null}
<SpatialToolbarActions openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={visualProfile?.renderer ?? <> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<RerunViewport
profile={rerunViewerProfile}
sceneSettings={sceneSettings}
onPerceptionLoadChange={onPerceptionLoadChange}
onPointColorLoadChange={onPointColorLoadChange}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
onPlaybackChange={onPlaybackChange}
onPlaybackControllerChange={onPlaybackControllerChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
</>}
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
model={spatialControls.model}
spatialActivity={visualProfile?.activity}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>):null}
sourceControls={visualProfile?.sourceControls ?? <> {pointCloudFocused ? (
<IconButton
className="scene-focus-exit"
label="Выйти из полноэкранного режима облака точек"
onClick={() => observationLayout.setFocusedSourceId(null)}
>
<Icon name="minimize" size={16} />
</IconButton>
) : !floatingSourceMaximized ? (
<div className="scene-source-controls">
<ObservationSourcePicker
sources={unifiedPerception
? observationSources.filter((source) => source.modality === "point-cloud")
: observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
pendingSourceIds={observationLayout.pendingSourceIds}
onToggle={observationLayout.toggleSource}
/>
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && (visualProfile || sourceUrl.trim()) ? (
<IconButton
className="scene-source-control"
label="Развернуть облако точек"
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
>
<Icon name="expand" size={16} />
</IconButton>
) : null}
</div>
) : null}
</>}
status={visualProfile?.status ?? {label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
metrics={visualProfile?.metrics ?? <> <div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
<div>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
{streamActive ? (
<div>
<span>AI</span>
<strong>
{aiLatency === null ? "—" : formatNumber(aiLatency)}
<small>{aiLatency === null ? "" : " мс"}</small>
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
</strong>
</div>
) : null}</>} overlays={<> {!visualProfile && !pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
</div>
) : null}
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : null}
{!floatingSourceMaximized && recordedSource && (
perceptionLoad.phase === "loading" ||
perceptionLoad.phase === "error" ||
pointColorLoad.phase === "loading" ||
pointColorLoad.phase === "error"
) ? (
<div className="scene-operation-status-stack">
{perceptionLoad.phase === "loading" ? (
<div className="scene-operation-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
</div>
) : null}
{perceptionLoad.phase === "error" ? (
<button
type="button"
className="scene-operation-status scene-operation-status--action"
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
>
<Icon name="refresh" size={12} />
<span>Повторить AI</span>
</button>
) : null}
{pointColorLoad.phase === "loading" ? (
<div className="scene-operation-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{pointColorLoad.message}</span>
</div>
) : null}
{pointColorLoad.phase === "error" ? (
<div className="scene-operation-status scene-operation-status--error" role="status">
<span>{pointColorLoad.message}</span>
</div>
) : null}
</div>
) : null}
</>}
navigationReady={visualProfile ? false : presentedViewerStatus==='ready'} timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline
active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
mode={recordedSource && playbackState?.rangeNs
? "recorded"
: timeline?.mode}
seekable={recordedSource && playbackState?.rangeNs
? true
: timeline?.seekable}
synchronization={timeline?.synchronization}
rangeNs={playbackState?.rangeNs}
currentNs={playbackState?.currentNs}
playing={playbackState?.playing}
onSeek={playbackController?.seek}
onPlayingChange={playbackController?.setPlaying}
onJumpToEnd={playbackController?.jumpToEnd}
accumulationSeconds={accumulationSeconds}
onAccumulationChange={onAccumulationChange}
onAccumulationCommit={onAccumulationCommit}
className="scene-timeline"
/>
) : null}
</>}
media={<>{visibleMediaSources.length === 0 ? visualProfile?.mediaFallback : null} {visibleMediaSources.map((source, index) => (
<FloatingObservationWindow
key={source.id}
source={source}
index={index}
count={visibleMediaSources.length}
boundsRef={viewportRef}
rect={observationLayout.windowRects[source.id]}
maximized={observationLayout.maximizedFloatingSourceId === source.id}
active={observationLayout.activeFloatingSourceId === source.id}
hidden={pointCloudFocused || unifiedPerception}
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
playback={recordedSource && playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: playbackState.playing,
} : null}
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
onClose={() => {
if (observationLayout.pendingSourceIds.has(source.id)) return;
observationLayout.setFloatingMaximized(source.id, false);
void observationLayout.hideSource(source.id);
}}
/>
))}
{visualProfile?.tools?.(viewportRef)}
{recordedSource ? (
<div className="recorded-session-preloaders" aria-hidden="true">
{mediaSources.filter((source) => (
source.delivery?.kind === "recorded-fmp4-manifest" &&
!observationLayout.visibleSourceIds.has(source.id) &&
shouldPrepareRecordedSource(source.id)
)).map((source) => (
<ObservationMedia
key={source.id}
source={source}
playback={playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: false,
} : null}
prepareRecorded
recordedSessionGate="loading"
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
))}
</div>
) : null}</>} footer={visualProfile?.footer ?? <div className="spatial-contract-strip">
<span><i data-state="ready" />Облако точек</span>
<span><i data-state="ready" />Траектория</span>
<span><i data-state="ready" />Преобразования</span>
<span><i data-state="contract" />Камеры в 3D</span>
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
<span><i data-state="contract" />Компоновка</span>
</div>}/>;
}
@@ -7673,6 +7673,26 @@ test("spatial controls explain the bounded K1 calibration wait before point data
}
});
test("planning status extends only its current authoritative acquiring session", () => {
const state = runtimeState();
const spatialActivity = {sessionId: "data-session-001", label: "Привязка к эталону", detail: "Ожидание на месте.", busy: true};
const render = (current, activity = spatialActivity, controllerPatch = {}) => renderToStaticMarkup(createElement(K1SpatialControlsView, {
controller: {...acquisitionController(current), ...controllerPatch}, spatialActivity: activity,
}));
assert.match(render(state), /Привязка к эталону/);
assert.match(render(state), /Ожидание на месте/);
assert.doesNotMatch(render(state, {...spatialActivity, sessionId: "other-capture"}), /Привязка к эталону/);
for (const acquisitionState of ["awaiting_external_start", "starting", "awaiting_external_stop", "stopping", "finalizing"]) {
const current = structuredClone(state); current.acquisition.state = acquisitionState;
assert.doesNotMatch(render(current), /Привязка к эталону/);
}
const lost = structuredClone(state); lost.connection_supervisor = supervisor({control: true, data: false, dataPlaneState: "stalled"});
assert.doesNotMatch(render(lost), /Привязка к эталону/);
assert.doesNotMatch(render(state, spatialActivity, {physicalStopInFlight: true}), /Привязка к эталону/);
const cleanup = structuredClone(state); cleanup.acquisition.cleanup_pending = true;
assert.doesNotMatch(render(cleanup), /Привязка к эталону/);
});
test("contour health never promotes selection or replay metrics to live authority", () => {
const selectedOnly = {
phase: "starting",
@@ -804,7 +804,7 @@ test("E40 reports historical visible evaluation with bounded camera-LiDAR case r
});
test("the primary point-cloud viewer restores from fullscreen on Escape", async () => {
const workspacesSource = await readFile(workspacesUrl, "utf8");
const workspacesSource = await readFile(new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url), "utf8");
assert.match(workspacesSource, /if \(!pointCloudFocused\) return;/);
assert.match(workspacesSource, /event\.key !== "Escape"/);
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { createServer } from 'vite';
let server, api;
before(async () => { server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); api = await server.ssrLoadModule('/src/core/missions/planner.ts'); });
after(async () => { await server?.close(); });
const source = { schema_version:'missioncore.planning-source/v1', session_id:'A', generation:'a'.repeat(64), units:'m', poses:Array.from({length:5}, (_, i) => ({index:i, position:[i*3,i*4,0], distance_m:i*5})) };
test('route reverses source order without changing geometry or source', () => {
const result = api.selectedPoses(source, 1, 3, 'reverse');
assert.deepEqual(result.map(p => p.index), [3,2,1]); assert.equal(api.routeLength(result), 10); assert.equal(source.poses[0].index,0);
});
test('bounded section uses travelled distance, including out-and-back', () => {
assert.equal(api.endAtDistance(source, 1, 7),3); assert.equal(api.endAtDistance(source, 1, 30),4); assert.deepEqual(api.selectedPoses(source,4,2,'forward'),[]);
});
test('catalog excludes derived LAB parent and nonspatial sessions from zone binding', () => {
const item={replayable:true,modalities:['point-cloud','trajectory']}; assert.equal(api.canSelectSession(item),true);
assert.equal(api.canSelectSession({...item,lab:{}}),false); assert.equal(api.canSelectSession({...item,modalities:['video']}),false);
});
test('source contract rejects a different session and nonfinite coordinates', () => {
assert.equal(api.validatePlanningSource(source,'A'),source); assert.throws(() => api.validatePlanningSource(source,'B'));
assert.throws(() => api.validatePlanningSource({...source,poses:[{index:0,position:[0,0,NaN],distance_m:0},source.poses[1]]},'A'));
});
test('meter input selects the correct ordered pose and respects the selected branch', () => {
assert.equal(api.indexAtDistance(source, 10), 2);
assert.equal(api.indexAtDistance(source, 10, 3), 3);
assert.equal(api.indexAtDistance(source, 100), 4);
});
test('live distance has no upper cap and endpoint selection does not overshoot typed metres', () => {
for (const n of [3, 30, 50, 100, 200, 300, 10000]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), true);
for (const n of [2.9, NaN, Infinity]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), false);
assert.equal(api.canStartPlanningRoute(50, 'recording'), false);
assert.equal(api.indexAtDistance(source, 9.9, 1, 4, true), 1);
assert.equal(api.indexAtDistance(source, 10, 1, 4, true), 2);
assert.equal(api.indexAtDistance(source, 1, 1, 4, true), 1);
});
test('planning match never promotes stopped or stale evidence', async()=>{
const {planningMatchCurrent}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',tracking_state:'tracking',stale:false,frame_age_s:0,result_age_s:2,result:{status:'candidate'}};
assert.equal(planningMatchCurrent(t),true);
for(const patch of [{state:'completed'},{state:'waiting'},{tracking_state:'acquiring'},{tracking_state:'lost'},{tracking_state:undefined},{stale:true},{frame_age_s:8},{result_age_s:8},{result_age_s:null},{result:{status:'rejected'}}])assert.equal(planningMatchCurrent({...t,...patch}),false);
assert.equal(planningMatchCurrent(t,true),false);
});
test('planning failure is visible even when its last sample is stale', async()=>{
const {planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'error',message:'Разрыв координат',stale:true,frame_age_s:null,result_age_s:null,result:null};
assert.deepEqual(planningStatus(t),{label:'Совмещение остановлено',tone:'danger',message:'Разрыв координат',pulse:true});
assert.equal(planningStatus({...t,state:'waiting'}).label,'Ожидание данных');
});
test('planning activity continues scanner preparation without presenting a prior as tracking', async()=>{
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',query_session_id:'B',tracking_state:'acquiring',stale:false,frame_age_s:0,result_age_s:null,result:null,message:'Ожидание на месте.'};
assert.equal(planningActivity({...t,planning_phase:'waiting-cloud'}),undefined);
for(const [planning_phase,label] of [['collecting','Накопление данных'],['searching','Поиск положения на маршруте'],['refreshing','Подтверждение привязки'],['validating','Подтверждение привязки']]){
assert.deepEqual(planningActivity({...t,planning_phase}),{sessionId:'B',label,detail:'',busy:true});
assert.equal(planningStatus({...t,planning_phase}).tone,'neutral');
}
const live={...t,planning_phase:'tracking',tracking_state:'tracking',result_age_s:0,result:{status:'candidate'},message:'Можно начинать проверочный проход.'};
assert.equal(planningActivity(live).busy,false);
assert.equal(planningStatus(live).tone,'success');
const stale=planningStatus({...live,frame_age_s:9});
assert.equal(stale.label,'Привязка потеряна');assert.doesNotMatch(stale.message,/Можно начинать/);
assert.equal(planningActivity({...t,planning_phase:'searching'},'Нет связи').busy,false);
assert.equal(planningActivity({...live,query_session_id:null}),undefined);
assert.equal(planningActivity({...live,state:'completed'}),undefined);
const recovery=planningStatus({...t,planning_phase:'recovering',tracking_established:true});
assert.equal(recovery.pulse,true);
assert.equal(recovery.tone,'danger');
assert.match(recovery.message,/запись продолжается/);
});
test('project archive restores exact run identity and never promotes a failed live probe', async()=>{
const {defaultPlanningProject,planningProjectStatus}=await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
const items=[{key:'live:failed',kind:'live',state:'error',result_status:null},{key:'recorded:second',kind:'recorded',state:'ready',result_status:'candidate'},{key:'recorded:first',kind:'recorded',state:'ready',result_status:'candidate'}];
assert.equal(defaultPlanningProject(items,null),'recorded:second');
assert.equal(defaultPlanningProject(items,'recorded:first'),'recorded:first');
assert.equal(defaultPlanningProject(items,'missing'),'recorded:second');
assert.equal(planningProjectStatus(items[0]),'Без результата совмещения');
assert.equal(defaultPlanningProject([],null),'');
});
test('failed initial binding never claims previously established tracking was lost', async()=>{
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',query_session_id:'B',planning_phase:'lost',tracking_state:'lost',tracking_established:false,
stale:true,frame_age_s:null,result_age_s:null,result:null,message:'Начальный поиск не завершён.'};
const failed=planningStatus(t);
assert.equal(failed.label,'Маршрут не синхронизирован');
assert.equal(failed.message,'Начальный поиск не завершён.');
assert.deepEqual(planningActivity(t),{sessionId:'B',label:failed.label,detail:failed.message,busy:false});
assert.equal(planningStatus({...t,tracking_established:true}).label,'Привязка потеряна');
assert.equal(planningStatus({...t,state:'completed'}).label,'Исследование завершено');
});
test('freshness of displayed alignment fences green independently of the fit age',async()=>{
const {planningMatchCurrent,planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',tracking_state:'tracking',planning_phase:'tracking',tracking_established:true,
stale:false,frame_age_s:.2,result_age_s:4,result:{status:'candidate'},message:'Привязка подтверждена.',presentation_state:'live'};
assert.equal(planningMatchCurrent(t),true);
assert.equal(planningMatchCurrent({...t,presentation_state:'historical'}),false);
assert.equal(planningMatchCurrent({...t,frame_age_s:2.1}),false);
assert.match(planningStatus({...t,presentation_state:'historical'}).message,/последней принятой/);
assert.match(planningStatus({...t,state:'completed',presentation_state:'historical'}).message,/не текущее положение/);
});
@@ -1066,7 +1066,7 @@ test("recording preparation never presents phase heartbeats as fake percentages"
test("unified recorded AI view keeps the raw camera mounted but does not cover overlays", async () => {
const workspaceSource = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
"utf8",
);
@@ -27,7 +27,7 @@ async function read(relativePath) {
test("Observatory is the third independent Polygon workspace", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.deepEqual(
productModel.workspaceById("observatory"),
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {readFileSync} from 'node:fs';
test('planning live scene reuses the canonical vertical height range inside the existing stage',()=>{
const source=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
assert.match(source,/RangeControl orientation="vertical" limitSide="left" label="Срез"/);
assert.match(source,/className="session-overview__height"/);
assert.match(source,/ceiling_m:ceilingRef\.current/);
assert.match(source,/formatLimit=\{value=>value\.toFixed\(1\)\.replace\('\.',','\)\}/);
assert.match(source,/heightBounds=null/);
assert.match(source,/const CLIP_CEILING_M=80/);
assert.match(source,/max:CLIP_CEILING_M/);
assert.match(source,/setBounds\(next\);setCeiling\(null\);/);
});
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
let server,createReporter;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({createPlanningPresentationReporter:createReporter}=await server.ssrLoadModule(
'/src/core/missions/planningPresentationTelemetry.ts'));
});
after(async()=>{await server?.close();});
const settle=()=>new Promise(resolve=>setImmediate(resolve));
test('presentation observations batch separately from the scene channel and retain the proxy boundary',async()=>{
let timer,posted;
const reporter=createReporter({url:'/observations',schedule:callback=>{timer=callback;return 1;},cancel:()=>{},
fetcher:async(url,init)=>{posted={url,init};return new Response(null,{status:204});},
});
reporter.record({presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12'},
{rerunAdmissionMs:2,firstAnimationFrameMs:12,secondAnimationFrameMs:28});
assert.ok(timer);timer();await settle();
assert.equal(posted.url,'/observations');
const body=JSON.parse(posted.init.body);
assert.equal(body.schema_version,'missioncore.planning-browser-presentation/v1');
assert.deepEqual(body.samples,[{cloud_revision:3,cloud_sequence:4,
display_epoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',request_ms:40,
rerun_admission_ms:2,first_animation_frame_ms:12,second_animation_frame_ms:28,
frame_timeout:false,source_to_second_animation_frame_upper_bound_ms:270}]);
reporter.dispose();
});
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { readFile } from 'node:fs/promises';
import { createServer } from 'vite';
let server, api;
before(async () => {
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
api = await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
});
after(async () => { await server?.close(); });
const item = {key: 'live:a', id: 'a', name: 'same name', kind: 'live', state: 'completed', revision: 2};
test('only terminal runs or drafts admit catalog deletion', () => {
for (const state of ['completed', 'cancelled', 'error', 'interrupted']) assert.equal(api.planningProjectDeletable({...item, state}), true);
for (const state of ['running', 'waiting', 'preparing', 'new-unknown']) assert.equal(api.planningProjectDeletable({...item, state}), false);
assert.equal(api.planningProjectDeletable({...item, kind: 'draft', state: 'draft'}), true);
assert.equal(api.planningProjectDeletable({...item, kind: 'recorded', state: 'ready'}), true);
});
test('delete sends exact kind/id/revision and requires a matching receipt', async t => {
const calls = [];
let receipt = {key: item.key, deleted: true};
t.mock.method(globalThis, 'fetch', async (...args) => { calls.push(args); return {ok: true, json: async () => receipt}; });
await api.deletePlanningProject(item);
assert.equal(calls[0][0], '/api/v1/mission-planner/projects/live/a');
assert.equal(calls[0][1].method, 'DELETE');
assert.deepEqual(JSON.parse(calls[0][1].body), {revision: 2});
receipt = {key: 'live:another-same-name', deleted: true};
await assert.rejects(api.deletePlanningProject(item), /не подтверждено/);
const count = calls.length;
await assert.rejects(api.deletePlanningProject({...item, state: 'running'}), /завершите/);
assert.equal(calls.length, count);
});
test('server refusal stays an error, never a successful deletion', async t => {
t.mock.method(globalThis, 'fetch', async () => ({ok: false, json: async () => ({detail: 'Проект изменён.'})}));
await assert.rejects(api.deletePlanningProject(item), /Проект изменён/);
});
test('UI uses canonical row actions and modal, and stale loads cannot resurrect a deleted item', async () => {
const component = await readFile(new URL('../src/components/missions/PlanningProjectSelect.tsx', import.meta.url), 'utf8');
const hook = await readFile(new URL('../src/core/missions/usePlanningProjects.ts', import.meta.url), 'utf8');
assert.match(component, /<Select/); assert.match(component, /<ConfirmationModal/);
assert.match(component, /disabled: !planningProjectDeletable\(item\)/);
assert.match(component, /await onRemove\(target\); setTarget\(null\)/);
assert.match(component, /<ToastStack/);
assert.match(hook, /removedKeys.current.has\(item.key\)/);
assert.match(hook, /removedKeys.current.has\(key\)/);
assert.match(hook, /if \(selectedKey.current === project.key\) select\(''\)/);
});
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
import {readFileSync} from 'node:fs';
let server,start;
before(async()=>{server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});({startPlanningSceneStream:start}=await server.ssrLoadModule('/src/core/missions/planningSceneStream.ts'));});
after(async()=>{await server?.close();});
const settle=()=>new Promise(resolve=>setImmediate(resolve));
function fixture(){
let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0;
const timers=new Map(),requests=[],applied=[],errors=[];
const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock,
schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id),
fetcher:(url,init)=>new Promise((resolve,reject)=>requests.push({url,init,resolve,reject})),
apply:bytes=>applied.push(bytes),error:()=>errors.push(true)});
return {timers,requests,applied,errors,stop,setState:value=>{state=value;},setClock:value=>{clock=value;},
next:()=>{const [id,timer]=[...timers].find(([,t])=>t.ms!==2500);timers.delete(id);timer.fn();},
answer:(index,cursor='next',status=200)=>requests[index].resolve(new Response(status===204?null:new Uint8Array([1,2]),{status,headers:{'X-Planning-Scene-Cursor':cursor}}))};
}
test('one pending fetch, native cadence, no-op response and cancellation',async()=>{
const f=fixture();assert.equal(f.requests.length,1);
assert.equal(f.timers.size,1); // deadline only: no overlapping polling interval
f.setClock(35);f.answer(0);await settle();
assert.equal(f.applied.length,1);assert.equal([...f.timers.values()][0].ms,65);
f.next();assert.match(f.requests[1].url,/cursor=next/);
f.answer(1,'next',204);await settle();assert.equal(f.applied.length,1);
f.next();f.stop();assert.equal(f.requests[2].init.signal.aborted,true);
f.answer(2);await settle();assert.equal(f.applied.length,1);assert.equal(f.timers.size,0);
});
test('a changed mode or ended run discards the in-flight response and rebases',async()=>{
for(const next of [{active:true,revision:2,options:{mode:'top'}},{active:false,revision:2,options:{mode:'3d'}}]){
const f=fixture();f.setState(next);f.answer(0);await settle();
assert.equal(f.applied.length,0);f.next();assert.match(f.requests[1].url,/base=true/);
f.answer(1);await settle();assert.equal(f.applied.length,1);f.stop();
}
});
test('failure hides stale evidence, repairs geometry but retains admitted camera cursor',async()=>{
const f=fixture();f.answer(0);await settle();f.next();
f.requests[1].reject(new Error('offline'));await settle();
assert.equal(f.errors.length,1);assert.equal([...f.timers.values()][0].ms,500);
f.next();assert.match(f.requests[2].url,/base=true/);assert.match(f.requests[2].url,/cursor=next/);
f.stop();f.answer(2);await settle();assert.equal(f.applied.length,1);
});
test('display changes and discarded responses retain camera identity; reset intent reaches server',async()=>{
const f=fixture();f.answer(0,'admitted-camera');await settle();f.next();
f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:0}});
f.answer(1,'discarded-camera');await settle();
assert.equal(f.applied.length,1);f.next();
assert.match(f.requests[2].url,/cursor=admitted-camera/);
assert.match(f.requests[2].url,/base=true/);
assert.match(f.requests[2].url,/reset=0/);
f.answer(2,'clipped');await settle();
f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:1}});
f.next();assert.match(f.requests[3].url,/reset=1/);
f.answer(3,'reset');await settle();f.stop();
});
test('unchanged terminal views stop transfer but notice a later revision',async()=>{
const f=fixture();f.setState({active:false,revision:2,options:{mode:'3d'}});
f.answer(0);await settle();f.next();f.answer(1);await settle();f.next();
assert.equal(f.requests.length,2);
f.setState({active:false,revision:3,options:{mode:'3d'}});f.next();
assert.equal(f.requests.length,3);f.stop();f.answer(2);await settle();
});
test('cloud or fit expiring during delivery cannot revive green',async()=>{
for(const [cloud,fit] of [['1.8','1'],['.1','7.8'],['invalid','1']]){
const f=fixture();f.setClock(300);
f.requests[0].resolve(new Response(new Uint8Array([1]),{headers:{
'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live',
'X-Planning-Cloud-Age':cloud,'X-Planning-Fit-Age':fit}}));
await settle();assert.equal(f.applied.length,0);assert.equal(f.errors.length,1);f.stop();
}
});
test('a live response waits for bounded browser delivery and passes its exact receipt identity',async()=>{
let release,delivery;
let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0;
const timers=new Map(),requests=[];
const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock,
schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id),
fetcher:(url,init)=>new Promise(resolve=>requests.push({url,init,resolve})),
apply:(_bytes,value)=>{delivery=value;return new Promise(resolve=>{release=resolve;});},error:assert.fail,
});
clock=40;
requests[0].resolve(new Response(new Uint8Array([1]),{headers:{
'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live',
'X-Planning-Cloud-Age':'.2','X-Planning-Fit-Age':'.1',
'X-Planning-Cloud-Revision':'3','X-Planning-Cloud-Sequence':'4',
'X-Planning-Display-Epoch':'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',
'X-Planning-Height-Min':'-1.2','X-Planning-Height-Max':'51.5',
}}));
await settle();
assert.deepEqual(delivery,{presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',
heightMinM:-1.2,heightMaxM:51.5});
assert.equal(timers.size,1);
assert.equal([...timers.values()][0].ms,2500);
release();await settle();
assert.equal([...timers.values()][0].ms,60);
stop();
});
test('profile scene can expand after native capture ends and retains Escape',()=>{
const source=readFileSync(new URL('../src/workspaces/spatial/SpatialWorkspace.tsx',import.meta.url),'utf8');
assert.match(source,/pointCloudVisible && \(visualProfile \|\| sourceUrl.trim\(\)\)/);
assert.match(source,/<IconButton\s+className="scene-source-control"\s+label="Развернуть облако точек"/);
assert.match(source,/event.key !== "Escape"/);
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {readFileSync} from 'node:fs';
let server,Tool,Actions;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({PlanningSceneToolWindow:Tool}=await server.ssrLoadModule('/src/components/missions/PlanningSceneToolWindow.tsx'));
({SpatialToolbarActions:Actions}=await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialToolbarActions.tsx',import.meta.url).pathname));
});
after(async()=>{await server?.close();});
test('scene tools use the bounded modeless window with move, resize and expand controls',()=>{
const html=renderToStaticMarkup(createElement(Tool,{boundsRef:{current:null},title:'Слои',onClose:()=>{}},'CONTROLS'));
assert.match(html,/nodedc-workspace-window/);
assert.match(html,/aria-modal="false"/);
assert.match(html,/Переместить инструмент/);
assert.match(html,/Изменить размер инструмента/);
assert.match(html,/Развернуть инструмент/);
assert.doesNotMatch(html,/nodedc-overlay/);
});
test('spatial toolbar omits source and duplicate planning navigation',()=>{
const html=renderToStaticMarkup(createElement(Actions,{openLayers:()=>{},openDisplay:()=>{},activeTool:'layers'}));
assert.match(html,/Слои/);assert.match(html,/Отображение/);
assert.match(html,/aria-pressed="true"/);assert.doesNotMatch(html,/Движок|Планирование/);
const workspace=readFileSync(new URL('../src/workspaces/missions/PlanningSpatialWorkspace.tsx',import.meta.url),'utf8');
assert.doesNotMatch(workspace,/<Window\s|openSource=|openView\('mission-planner'\)/);
assert.match(workspace,/tools:boundsRef=>tool&&<PlanningSceneToolWindow/);
const viewer=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
assert.match(viewer,/\},\[runId,retry\]\)/); // Presentation never owns viewer lifetime.
});
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -29,7 +29,7 @@ test("top navigation has no Center and Park owns contour health first", () => {
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.equal(productModel.workspaceById("spatial-scene")?.root, "polygon");
assert.equal(productModel.workspacesForRoot("observation").some(({ id }) => id === "spatial-scene"), false);
@@ -295,7 +295,7 @@ test("loading and error overlays fully conceal recorded camera pixels", async ()
test("point-cloud fullscreen keeps the admitted recorded camera worker mounted", async () => {
const source = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
"utf8",
);
assert.match(source, /\{visibleMediaSources\.map\(\(source, index\) => \(/);
@@ -320,7 +320,7 @@ test("one live document owns one native Rerun receiver", async () => {
test("raw replay exercises the same streaming receiver lifecycle as a live scan", async () => {
const source = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
"utf8",
);
assert.match(
@@ -343,7 +343,7 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
);
assert.match(
source,
/const recordedSource = Boolean\(recordedReplay\) \|\| \/\\\.rrd/,
/const recordedSource = !visualProfile && \(Boolean\(recordedReplay\) \|\| \/\\\.rrd/,
);
assert.doesNotMatch(
source,
@@ -353,7 +353,7 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
test("pending K1 STOP keeps the live Rerun source mounted until local capture ends", async () => {
const source = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
"utf8",
);
assert.match(
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { createServer } from 'vite';
import { readFile } from 'node:fs/promises';
let server, api;
before(async () => {
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
api = await server.ssrLoadModule('/src/core/observation/sessionOverview.ts');
});
after(async () => { await server?.close(); });
test('height slice delegates endpoint labels and unit-bearing value to the shared range', async () => {
const source = await readFile(new URL('../src/components/observation/SessionOverviewScene.tsx', import.meta.url), 'utf8');
assert.match(source, /RangeControl orientation="vertical" limitSide="left" label="Срез"/);
assert.match(source, /formatLimit=\{value => value\.toFixed\(1\)\.replace\('\.', ','\)\}/);
assert.match(source, /formatValue=\{value => `\$\{value\.toFixed\(1\)\.replace\('\.', ','\)\} м`\}/);
assert.doesNotMatch(source, /<span>\{(?:high|low)\.toFixed\(1\)\} м<\/span>/);
});
test('interval chart retains the largest pause and rejects invalid values', () => {
const result = api.overviewChartPoints([[0, .1], [30, 1.2], [60, .1], [NaN, 5]], 100, 50);
assert.equal(result.xmax, 60);
assert.equal(result.ymax, 1.32);
assert.equal(result.points.split(' ').length, 3);
assert.doesNotMatch(result.points, /NaN/);
});
test('session overview rejects a result belonging to a different session', async () => {
const original = globalThis.fetch;
globalThis.fetch = async () => ({ ok: true, json: async () => ({ schema_version: 'missioncore.session-overview/v1', state: 'ready', session: { session_id: 'other' } }) });
try { await assert.rejects(api.fetchSessionOverview('selected', new AbortController().signal), /некорректные/); }
finally { globalThis.fetch = original; }
});
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { before, after, test } from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createServer } from 'vite';
let server, SpatialScene;
before(async () => {
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
({ SpatialScene } = await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialScene.tsx', import.meta.url).pathname));
});
after(async () => { await server?.close(); });
const render = (focused = false) => renderToStaticMarkup(createElement(SpatialScene, {
viewportRef: { current: null }, primaryFocused: focused, toolbar: null, renderer: null,
sourceControls: createElement('div', { className: focused ? 'scene-focus-exit' : 'scene-source-controls' }, 'SOURCE_CONTROLS'),
status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' },
metrics: createElement('div', null, 'METRICS'),
}));
test('scene tools, visual engine and metrics share one top-left flow in that order', () => {
const markup = render();
const stack = markup.indexOf('class="scene-information"');
const controls = markup.indexOf('SOURCE_CONTROLS');
const status = markup.indexOf('ВИЗУАЛЬНЫЙ ДВИЖОК');
const metrics = markup.indexOf('METRICS');
assert.ok(stack < controls && controls < status && status < metrics);
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
assert.doesNotMatch(markup, /scene-status--top-left/);
});
test('focus exit remains viewport-owned outside the hidden information stack', () => {
const markup = render(true);
assert.ok(markup.indexOf('scene-focus-exit') < markup.indexOf('scene-information'));
assert.match(markup, /class="scene-information" aria-hidden="true"/);
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
});
test('scene layout uses flow, retains compact metrics and removes only the calibration perimeter', async () => {
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
const responsive = await readFile(new URL('../src/styles/responsive.css', import.meta.url), 'utf8');
const calibration = await readFile(new URL('../../../plugins/xgrids-k1/frontend/src/components/K1SpatialSession.css', import.meta.url), 'utf8');
assert.match(css, /\.scene-information \{[^}]*position: absolute;[^}]*display: grid;/);
assert.match(css, /\.scene-information > \.scene-source-controls \{ position: static; pointer-events: auto;/);
assert.match(css, /\.scene-information\[aria-hidden="true"\] \{ display: none;/);
assert.doesNotMatch(css.match(/\.scene-metrics \{[^}]*\}/)?.[0] ?? '', /top:|right:|position: absolute/);
assert.doesNotMatch(responsive, /\.scene-metrics\s*\{\s*display: none/);
assert.match(calibration, /\.xgrids-k1-spatial-controls \{[^}]*border: 0;/);
});