Preserve onboard preview recordings across backpressure and share the complete spatial scene

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 17:47:10 +03:00
parent 9b6534287a
commit 9bba44f7c4
30 changed files with 957 additions and 494 deletions
+4 -39
View File
@@ -124,19 +124,8 @@ export interface XgridsHostFailureDiagnostic {
redacted: true;
}
export type AcquisitionState =
| "preparing"
| "prepared"
| "awaiting_external_start"
| "starting"
| "acquiring"
| "awaiting_external_stop"
| "stopping"
| "finalizing"
| "completed"
| "failed"
| "aborted"
| "interrupted";
export type {AcquisitionState} from './spatialSessionModel';
import type {AcquisitionState} from './spatialSessionModel';
export type OperationStatus =
| "accepted"
@@ -832,32 +821,8 @@ export interface XgridsConnectionAttempt extends XgridsConnectionAttemptSummary
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
}
export interface XgridsK1Metrics {
pcl_frames?: number | null;
pose_frames?: number | null;
mqtt_to_decode_ms?: number | null;
decode_ms?: number | null;
publish_ms?: number | null;
pipeline_ms?: number | null;
end_to_end_ms?: number | null;
frame_rate?: number | null;
frame_rate_hz?: number | null;
point_count?: number | null;
dropped_preview_frames?: number | null;
ai_end_to_end_ms?: number | null;
ai_end_to_end_p95_ms?: number | null;
ai_frame_rate_hz?: number | null;
ai_dropped_frames?: number | null;
ai_stale_ms?: number | null;
device_elapsed_seconds?: number | null;
device_route_distance_meters?: number | null;
device_speed_meters_per_second?: number | null;
device_speed_mps?: number | null;
elapsed_seconds?: number | null;
route_distance_meters?: number | null;
speed_meters_per_second?: number | null;
[key: string]: number | null | undefined;
}
export type {XgridsK1Metrics} from './spatialSessionModel';
import type {XgridsK1Metrics} from './spatialSessionModel';
export interface XgridsCameraPreviewDelivery {
id: string;
@@ -1,9 +1,9 @@
import { ActivityIndicator, Button } from "@nodedc/ui-react";
import {K1SpatialSession, k1SpatialPhasePresentation} from './K1SpatialSession';
export {k1SpatialPhasePresentation} from './K1SpatialSession';
import { Button } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import type {
AcquisitionState,
XgridsAcquisition,
XgridsK1State,
} from "../api";
import {
@@ -21,7 +21,6 @@ import {
} from "../lifecycle";
import {
deviceTelemetry,
formatNumber,
spatialActionFailure,
} from "../presentation";
import {
@@ -34,12 +33,6 @@ import {
} from "../runtimeContext";
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
interface PhasePresentation {
label: string;
detail: string;
busy: boolean;
}
export interface K1SpatialAuthorityState {
controlAuthoritative: boolean;
dataAuthoritative: boolean;
@@ -64,85 +57,6 @@ export function k1SpatialAuthorityState(
};
}
export function k1SpatialPhasePresentation(
acquisition: XgridsAcquisition,
softwareCommanded: boolean,
): PhasePresentation {
const presentations: Record<AcquisitionState, PhasePresentation> = {
preparing: {
label: "Подготовка локального приёма",
detail: "Проверка контура и создание сессии записи.",
busy: true,
},
prepared: {
label: "Приём подготовлен",
detail: softwareCommanded
? "Можно инициировать работу устройства из Mission Core."
: "Программная команда K1 недоступна в текущем профиле.",
busy: false,
},
awaiting_external_start: {
label: softwareCommanded
? "K1 калибруется и готовит облако точек"
: "Ожидание запуска на устройстве",
detail: softwareCommanded
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
: "Запустите сканирование физической кнопкой K1.",
busy: true,
},
starting: {
label: softwareCommanded
? "K1 калибруется и готовит облако точек"
: "Подготовка локального приёмника",
detail: softwareCommanded
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
: "Mission Core запускает запись до физического старта K1.",
busy: true,
},
acquiring: {
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
detail: softwareCommanded
? "Состояние получено из профилированного контура управления K1."
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
busy: false,
},
awaiting_external_stop: {
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: softwareCommanded
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true,
},
stopping: {
label: softwareCommanded ? "Остановка K1 и записи" : "Остановка локального приёма",
detail: softwareCommanded
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
: "Физическое состояние K1 остаётся неизвестным.",
busy: true,
},
finalizing: {
label: "Сохранение локальной записи",
detail: "Не закрывайте Mission Core до завершения финализации.",
busy: true,
},
completed: { label: "Приём завершён", detail: "", busy: false },
failed: { label: "Ошибка приёма", detail: "", busy: false },
aborted: { label: "Приём прерван", detail: "", busy: false },
interrupted: { label: "Приём прерван", detail: "", busy: false },
};
return presentations[acquisition.state];
}
function formatDuration(seconds: number): string {
const wholeSeconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(wholeSeconds / 3_600);
const minutes = Math.floor((wholeSeconds % 3_600) / 60);
const remainder = wholeSeconds % 60;
return hours > 0
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
}
export function runSpatialActiveStreamForceFinish(
controller: Pick<
XgridsK1Controller,
@@ -276,30 +190,7 @@ export function K1SpatialControlsView({
);
const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
return (
<section
className="xgrids-k1-spatial-controls"
aria-label="Управление сессией XGRIDS K1"
aria-busy={phase.busy}
data-busy={phase.busy ? "true" : undefined}
>
<div className="xgrids-k1-spatial-controls__phase">
{phase.busy ? <ActivityIndicator size="compact" /> : null}
<span>
<strong>{phase.label}</strong>
<small>{phase.detail}</small>
</span>
</div>
<div className="xgrids-k1-spatial-controls__telemetry" aria-label="Телеметрия маршрута K1">
{telemetry.elapsedSeconds !== null ? (
<span><small>Время сканирования</small><strong>{formatDuration(telemetry.elapsedSeconds)}</strong></span>
) : null}
{telemetry.routeDistanceMeters !== null ? (
<span><small>Маршрут устройства</small><strong>{formatNumber(telemetry.routeDistanceMeters, 2)} м</strong></span>
) : null}
{telemetry.speedMetersPerSecond !== null ? (
<span><small>Скорость</small><strong>{formatNumber(telemetry.speedMetersPerSecond, 2)} м/с</strong></span>
) : null}
</div>
<K1SpatialSession phase={phase} telemetry={telemetry}>
{actionFailure ? (
<div className="xgrids-k1-spatial-controls__error" role="alert">
<strong>{actionFailure.title}</strong>
@@ -345,7 +236,7 @@ export function K1SpatialControlsView({
</span>
</Button>
) : null}
</section>
</K1SpatialSession>
);
}
@@ -0,0 +1,105 @@
.xgrids-k1-spatial-controls {
display: flex;
min-width: min(42rem, 100%);
max-width: 100%;
align-items: center;
gap: 0.85rem;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 1rem;
background: rgb(9 10 13 / 0.88);
padding: 0.55rem 0.65rem 0.55rem 0.75rem;
color: var(--nodedc-text-primary);
box-shadow: 0 0.9rem 2.4rem rgb(0 0 0 / 0.3);
backdrop-filter: blur(20px);
}
.xgrids-k1-spatial-controls__phase {
display: flex;
min-width: 11rem;
flex: 1 1 15rem;
align-items: center;
gap: 0.58rem;
}
.xgrids-k1-spatial-controls__phase > span:last-child {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.xgrids-k1-spatial-controls__phase strong,
.xgrids-k1-spatial-controls__phase small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__phase strong {
font-size: 0.66rem;
}
.xgrids-k1-spatial-controls__phase small {
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.xgrids-k1-spatial-controls__telemetry {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 0.65rem;
}
.xgrids-k1-spatial-controls__telemetry > span {
display: grid;
gap: 0.12rem;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__telemetry small {
color: var(--nodedc-text-muted);
font-size: 0.48rem;
}
.xgrids-k1-spatial-controls__telemetry strong {
font-size: 0.61rem;
}
.xgrids-k1-spatial-controls__error {
display: grid;
max-width: 17rem;
gap: 0.12rem;
color: rgb(var(--nodedc-danger-rgb));
}
.xgrids-k1-spatial-controls__error strong {
font-size: 0.61rem;
}
.xgrids-k1-spatial-controls__error small {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.51rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__action-label {
display: block;
width: 9.75rem;
font-size: 0.66rem;
line-height: 1.08;
text-align: center;
white-space: normal;
}
.xgrids-k1-spatial-controls__action-label--local {
width: 8.75rem;
}
@media (max-width: 960px) {
.xgrids-k1-spatial-controls {min-width:0}
.xgrids-k1-spatial-controls__phase small,
.xgrids-k1-spatial-controls__telemetry,
.xgrids-k1-spatial-controls__error small {display:none}
}
@@ -0,0 +1,120 @@
import type {ReactNode} from 'react';
import {ActivityIndicator} from '@nodedc/ui-react';
import type {AcquisitionState} from '../spatialSessionModel';
import {formatNumber, type K1DeviceTelemetry} from '../spatialSessionModel';
import './K1SpatialSession.css';
interface PhasePresentation {
label: string;
detail: string;
busy: boolean;
}
export function k1SpatialPhasePresentation(
acquisition: {state:AcquisitionState},
softwareCommanded: boolean,
): PhasePresentation {
const presentations: Record<AcquisitionState, PhasePresentation> = {
preparing: {
label: "Подготовка локального приёма",
detail: "Проверка контура и создание сессии записи.",
busy: true,
},
prepared: {
label: "Приём подготовлен",
detail: softwareCommanded
? "Можно инициировать работу устройства из Mission Core."
: "Программная команда K1 недоступна в текущем профиле.",
busy: false,
},
awaiting_external_start: {
label: softwareCommanded
? "K1 калибруется и готовит облако точек"
: "Ожидание запуска на устройстве",
detail: softwareCommanded
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
: "Запустите сканирование физической кнопкой K1.",
busy: true,
},
starting: {
label: softwareCommanded
? "K1 калибруется и готовит облако точек"
: "Подготовка локального приёмника",
detail: softwareCommanded
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
: "Mission Core запускает запись до физического старта K1.",
busy: true,
},
acquiring: {
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
detail: softwareCommanded
? "Состояние получено из профилированного контура управления K1."
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
busy: false,
},
awaiting_external_stop: {
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: softwareCommanded
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true,
},
stopping: {
label: softwareCommanded ? "Остановка K1 и записи" : "Остановка локального приёма",
detail: softwareCommanded
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
: "Физическое состояние K1 остаётся неизвестным.",
busy: true,
},
finalizing: {
label: "Сохранение локальной записи",
detail: "Не закрывайте Mission Core до завершения финализации.",
busy: true,
},
completed: { label: "Приём завершён", detail: "", busy: false },
failed: { label: "Ошибка приёма", detail: "", busy: false },
aborted: { label: "Приём прерван", detail: "", busy: false },
interrupted: { label: "Приём прерван", detail: "", busy: false },
};
return presentations[acquisition.state];
}
function formatDuration(seconds: number): string {
const wholeSeconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(wholeSeconds / 3_600);
const minutes = Math.floor((wholeSeconds % 3_600) / 60);
const remainder = wholeSeconds % 60;
return hours > 0
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
}
export function K1SpatialSession({phase,telemetry,children}:{phase:PhasePresentation;telemetry:K1DeviceTelemetry;children?:ReactNode}) {
return (
<section
className="xgrids-k1-spatial-controls"
aria-label="Управление сессией XGRIDS K1"
aria-busy={phase.busy}
data-busy={phase.busy ? "true" : undefined}
>
<div className="xgrids-k1-spatial-controls__phase">
{phase.busy ? <ActivityIndicator size="compact" /> : null}
<span>
<strong>{phase.label}</strong>
<small>{phase.detail}</small>
</span>
</div>
<div className="xgrids-k1-spatial-controls__telemetry" aria-label="Телеметрия маршрута K1">
{telemetry.elapsedSeconds !== null ? (
<span><small>Время сканирования</small><strong>{formatDuration(telemetry.elapsedSeconds)}</strong></span>
) : null}
{telemetry.routeDistanceMeters !== null ? (
<span><small>Маршрут устройства</small><strong>{formatNumber(telemetry.routeDistanceMeters, 2)} м</strong></span>
) : null}
{telemetry.speedMetersPerSecond !== null ? (
<span><small>Скорость</small><strong>{formatNumber(telemetry.speedMetersPerSecond, 2)} м/с</strong></span>
) : null}
</div>
{children}
</section>
);
}
+3 -39
View File
@@ -1,3 +1,6 @@
import {finiteMetric} from './spatialSessionModel';
export {finiteMetric, deviceTelemetry, formatNumber} from './spatialSessionModel';
export type {K1DeviceTelemetry} from './spatialSessionModel';
import type { StatusTone } from "@nodedc/ui-react";
import type { BackendStatus } from "@mission-core/plugin-sdk";
@@ -295,9 +298,6 @@ export function eventStatusLabel(status: string): string {
}[status] ?? "неизвестно";
}
export function finiteMetric(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number | null {
if (!metrics) return null;
@@ -308,17 +308,6 @@ export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number |
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
function nonNegativeMetric(value: number | null | undefined): number | null {
const finite = finiteMetric(value);
return finite !== null && finite >= 0 ? finite : null;
}
export interface K1DeviceTelemetry {
elapsedSeconds: number | null;
routeDistanceMeters: number | null;
speedMetersPerSecond: number | null;
}
export interface SpatialActionFailure {
title: string;
detail: string;
@@ -336,31 +325,6 @@ export function spatialActionFailure(
: null;
}
export function deviceTelemetry(
metrics: XgridsK1Metrics | null | undefined,
): K1DeviceTelemetry {
return {
elapsedSeconds: nonNegativeMetric(
metrics?.device_elapsed_seconds ?? metrics?.elapsed_seconds,
),
routeDistanceMeters: nonNegativeMetric(
metrics?.device_route_distance_meters ?? metrics?.route_distance_meters,
),
speedMetersPerSecond: nonNegativeMetric(
metrics?.device_speed_meters_per_second ??
metrics?.device_speed_mps ??
metrics?.speed_meters_per_second,
),
};
}
export function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
export function sourceModeLabel(mode: string | null | undefined): string {
if (mode === "live") return "Реальное время";
@@ -116,7 +116,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind
}
return renderWindow({busy:pending,
actions:confirmed?<Button variant="primary" disabled={pending}
actions:confirmed?<Button variant="primary" style={{width:'100%'}} disabled={pending}
aria-busy={busy==='opening'} icon={busy==='opening'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void configure()}>Настроить устройство</Button>:undefined,
content:<div className="sensor-content" ref={content}>
@@ -1,4 +1,7 @@
import {useRef,useState} from 'react';
import {K1SpatialSession, k1SpatialPhasePresentation} from '../components/K1SpatialSession';
import {deviceTelemetry} from '../spatialSessionModel';
import type {AcquisitionState} from '../spatialSessionModel';
import {useEffect,useRef,useState} from 'react';
import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './runtime';
import {K1LiveView} from './K1LiveView';
@@ -11,6 +14,9 @@ import {pendingPreview} from './useK1Preview';
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
const [busy,setBusy]=useState(''),[tool,setTool]=useState<SceneTool|null>(null),[operationError,setOperationError]=useState('');
const [preview,setPreview]=useState(pendingPreview);
const [sceneOpen,setSceneOpen]=useState(['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition));
const acquisitionId=device.control?.acquisition_id;
useEffect(()=>{if(acquisitionId&&['preparing','starting','streaming','stopping'].includes(device.snapshot.acquisition))setSceneOpen(true);},[acquisitionId]);
const scene=useK1SceneSettings(device,transport,failure);
const running=useRef(false);
const failedAction=useRef('');
@@ -19,6 +25,7 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
const pending=busy||(['preparing','starting'].includes(device.snapshot.acquisition)?'start':device.snapshot.acquisition==='stopping'?'stop':'');
async function act(action:'start'|'stop'|'verify'){
if(running.current||!enabled)return;
if(action==='start')setSceneOpen(true);
running.current=true;setBusy(action);failedAction.current='';setOperationError('');failure(null);
try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();}
catch{failedAction.current=action;setOperationError(action==='start'?'Результат запуска пока не подтверждён.':action==='stop'?'Результат остановки пока не подтверждён.':'Не удалось подтвердить связь с K1.');await refresh().catch(()=>{});}
@@ -26,6 +33,11 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
}
const confirmed=(failedAction.current==='start'&&streaming)||(failedAction.current==='stop'&&device.control?.phase==='completed')||(failedAction.current==='verify'&&manual.connected);
const summary=(!confirmed&&operationError)||(pending==='start'?'Запускаем устройство. Ожидаем завершения калибровки.':pending==='stop'?'Останавливаем устройство. Ожидаем подтверждения.':!manual.connected?k1ConnectionNotice(device,enabled):status.label);
const phaseName = (device.control?.acquisition_phase || (pending==='start'?'starting':pending==='stop'?'stopping':streaming?'acquiring':'completed')) as AcquisitionState;
const phase = k1SpatialPhasePresentation({state:phaseName},true) ?? {label:summary,detail:'',busy:!!pending};
const sessionControls = <K1SpatialSession phase={!enabled?{label:'Нет свежих сведений с БК',detail:'Ожидаем восстановления связи.',busy:false}:phase} telemetry={deviceTelemetry(device.frames)}>
{manual.showStop&&<Button size="compact" disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'} icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}><span className="xgrids-k1-spatial-controls__action-label">{pending==='stop'?'Остановка устройства…':'Остановить устройство и запись'}</span></Button>}
</K1SpatialSession>;
return <div className="sensor-content">
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
@@ -38,6 +50,7 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
</SettingsCard>
<SettingsCard title="Статус" role="status" aria-live="polite"><p className="k1-preview-status">{streaming?`${summary}. ${preview.lidar}. ${preview.camera}.`:summary}</p></SettingsCard>
{manual.connected&&<K1SceneWindows tool={tool} close={()=>setTool(null)} scene={scene} connected={enabled}/>}
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled} scene={scene} openTool={setTool}/>}
{!sceneOpen&&manual.showStop&&<Button onClick={()=>setSceneOpen(true)}>Открыть пространственную сцену</Button>}
{sceneOpen&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled&&streaming} scene={scene} openTool={setTool} close={()=>setSceneOpen(false)} sessionControls={sessionControls}/>}
</div>;
}
@@ -1,11 +1,12 @@
import type {ReactNode} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Icon, IconButton, SettingsCard, StatusBadge } from '@nodedc/ui-react';
import { FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
import { ApplicationPanel, Icon, IconButton, StatusBadge } from '@nodedc/ui-react';
import { SpatialScene, EmptySpatialStage, FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
import type { Sensor, SensorTransport } from './runtime';
import type { K1SceneState, SceneTool } from './K1SceneWindows';
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
import './livePreview.css';
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool }: {
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool, close, sessionControls }: {
device: Sensor;
transport: SensorTransport;
createRerunHost: RerunHostFactory;
@@ -13,8 +14,10 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
enabled: boolean;
scene: K1SceneState;
openTool: (tool: SceneTool) => void;
close: () => void; sessionControls: ReactNode;
}) {
const spatial = useRef<HTMLDivElement>(null), viewport = useRef<HTMLDivElement>(null), video = useRef<HTMLVideoElement>(null);
const [focused, setFocused] = useState(false);
const [expanded, setExpanded] = useState(false), [cameraMaximized, setCameraMaximized] = useState(false), [cameraRect, setCameraRect] = useState<ObservationWindowRect>();
const [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview);
@@ -31,6 +34,7 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
return; const timer = setTimeout(() => { attempts.current = 0; }, 10000); return () => clearTimeout(timer); }, [status.presented]);
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
setExpanded(false);
setFocused(false);
setCameraMaximized(false);
} }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []);
const sources: SpatialSourceDescriptor[] = [
@@ -41,29 +45,38 @@ export function K1LiveView({ device, transport, createRerunHost, onStatus, enabl
next.delete(id);
else
next.add(id); return next; });
return <SettingsCard title="Пространственная модель" className={expanded ? 'k1-preview sensor-viewer-expanded' : 'k1-preview'} actions={<IconButton label={expanded ? 'Свернуть просмотр' : 'Развернуть просмотр'} onClick={() => setExpanded(value => !value)}><Icon name={expanded ? 'minimize' : 'expand'}/></IconButton>}>
<div className="spatial-workspace" data-focused={cameraMaximized ? 'true' : undefined}>
<div className="spatial-toolbar" data-viewer-controls="v1"><div className="spatial-toolbar__actions"><SpatialToolbarActions openSource={() => openTool('source')} openLayers={() => openTool('layers')} openDisplay={() => openTool('display')}/></div></div>
<div ref={viewport} className="spatial-viewport-shell" data-media-maximized={cameraMaximized ? 'true' : undefined}>
<div className="rerun-viewport" data-status={status.presented ? 'ready' : 'loading'}>
<div className="rerun-viewport__canvas" data-presented={status.presented && visible.has('lidar') ? 'true' : 'false'} ref={spatial}/>
</div>
{!cameraMaximized && <div className="scene-source-controls"><ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/></div>}
<div className="scene-status scene-status--top-left" aria-hidden={cameraMaximized}>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span><StatusBadge tone={status.presented ? 'success' : 'neutral'}>{status.presented ? 'Визуализатор готов' : status.retry ? 'Восстановление связи' : 'Ожидаем свежие данные'}</StatusBadge>
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены" aria-hidden={cameraMaximized}>
<div><span>КАДР/С</span><strong>{status.presented ? (device.frames?.pcl_fps ?? 0).toFixed(1) : '—'}</strong></div>
<div><span>До публикации</span><strong>{status.presented && device.frames?.mqtt_to_publish_ms != null ? device.frames.mqtt_to_publish_ms.toFixed(1) : '—'}<small> мс</small></strong></div>
<div><span>Точек в кадре</span><strong>{status.presented ? status.points.toLocaleString('ru-RU') : '—'}</strong></div>
</div>
{status.presented && !cameraMaximized && <div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
{!cameraMaximized && <ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds => scene.stage({ accumulationSeconds })} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
<FloatingMediaWindow title="Камера K1" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={() => { }} onClose={() => { setCameraMaximized(false); setVisible(current => { const next = new Set(current); next.delete('camera'); return next; }); }} status={<span className="floating-observation-window__status">{status.cameraPresented ? 'Эфир' : 'Ожидание'}</span>} footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></span>}>
<video className="observation-media__asset" style={{ visibility: status.cameraPresented ? 'visible' : 'hidden' }} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
{!status.cameraPresented && <div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
</FloatingMediaWindow>
</div>
</div>
</SettingsCard>;
const hasScene = status.hasScene;
const focusedAny = focused || cameraMaximized;
return <ApplicationPanel title="Пространственная сцена" className={expanded ? 'k1-preview sensor-viewer-expanded' : 'k1-preview'}
expanded={expanded} onExpandedChange={setExpanded} onClose={close}
headerTools={<StatusBadge tone={status.presented?'success':'neutral'}>{status.presented?'Эфир':'Ожидание эфира'}</StatusBadge>}>
<SpatialScene viewportRef={viewport} focused={focusedAny} primaryFocused={focused} mediaMaximized={cameraMaximized}
toolbar={<SpatialToolbarActions openSource={()=>openTool('source')} openLayers={()=>openTool('layers')} openDisplay={()=>openTool('display')}/>}
renderer={<>
<div className="rerun-viewport" data-status={hasScene?'ready':'loading'}>
<div className="rerun-viewport__canvas" data-presented={hasScene&&visible.has('lidar')?'true':'false'} ref={spatial}/>
</div>
{!hasScene&&<EmptySpatialStage settings={scene.draft} title="Ожидаем данные K1" detail="Пространственная сцена появится после получения облака точек."/>}
</>}
deviceControls={sessionControls}
sourceControls={focused?<IconButton className="scene-focus-exit" label="Выйти из полноэкранного режима облака точек" onClick={()=>setFocused(false)}><Icon name="minimize"/></IconButton>:!cameraMaximized&&<div className="scene-source-controls">
<ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/>
{hasScene&&visible.has('lidar')&&<IconButton className="scene-source-control" label="Развернуть облако точек" onClick={()=>setFocused(true)}><Icon name="expand"/></IconButton>}
</div>}
status={{tone:status.presented?'success':'neutral',label:status.presented?'Визуализатор готов':status.retry?'Восстановление связи':hasScene?'Нет свежих данных':'Ожидание источника'}}
metrics={<>
<div><span>КАДР/С</span><strong>{status.presented?(device.frames?.pcl_fps??device.frames?.frame_rate_hz??0).toFixed(1):'—'}</strong></div>
<div><span>Точек</span><strong>{status.presented?status.points.toLocaleString('ru-RU'):'—'}</strong></div>
<div><span>До публикации</span><strong>{status.presented&&device.frames?.mqtt_to_publish_ms!=null?device.frames.mqtt_to_publish_ms.toFixed(1):'—'}<small> мс</small></strong></div>
</>}
navigationReady={hasScene}
timeline={!focusedAny&&<ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds=>scene.stage({accumulationSeconds})} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
media={<FloatingMediaWindow title="K1 · камера справа" subtitle="Видеоканал, опубликованный активным устройством" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={focused||!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={()=>{}} onClose={()=>{setCameraMaximized(false);setVisible(current=>{const next=new Set(current);next.delete('camera');return next;});}}
status={<span className="floating-observation-window__status">{status.cameraPresented?'Эфир':'Ожидание'}</span>}
footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></span>}>
<video className="observation-media__asset" style={{visibility:status.cameraPresented?'visible':'hidden'}} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
{!status.cameraPresented&&<div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
</FloatingMediaWindow>}
/>
</ApplicationPanel>;
}
@@ -1,5 +1,5 @@
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
export const MEDIA_PROTOCOL='missioncore.node-preview/v2';
export const MEDIA_PROTOCOL='missioncore.node-preview/v3';
const MAX_PAYLOAD=8*1024*1024,FRAGMENT_BYTES=16384;
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
let pending:Uint8Array<ArrayBuffer>|null=null,offset=0;
@@ -23,3 +23,17 @@ export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
export function assertRrd(bytes:Uint8Array){
if(bytes.length<12||bytes[0]!==82||bytes[1]!==82||bytes[2]!==70||bytes[3]!==50)throw new Error('Invalid RRD recording');
}
/** Kept by the acquisition view while disposable media peers reconnect. */
export function previewRecording(send:(payload:Uint8Array<ArrayBuffer>)=>void) {
let after=0;
return {
get after(){return after;},
accept(sequence:number,payload:Uint8Array<ArrayBuffer>) {
assertRrd(payload);
if(!Number.isSafeInteger(sequence)||sequence<1||sequence>after+1)throw new Error('Preview cursor gap');
if(sequence<=after)return false;
send(payload);after=sequence;return true;
},
};
}
@@ -1,7 +1,7 @@
import { useEffect, useState, type RefObject } from 'react';
import type { RerunHostFactory, LiveRerunViewer } from '@mission-core/sensor-sdk';
import { perform, type Sensor, type SensorTransport } from './runtime';
import { assertRrd, MEDIA_PROTOCOL, previewFrames } from './previewFrames';
import { MEDIA_PROTOCOL, previewFrames, previewRecording } from './previewFrames';
import { previewCamera } from './previewCamera';
import { previewFreshness } from './previewFreshness';
export type PreviewStatus = {
@@ -9,10 +9,11 @@ export type PreviewStatus = {
camera: string;
retry: boolean;
presented: boolean;
hasScene: boolean;
cameraPresented: boolean;
points: number;
};
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, cameraPresented: false, points: 0 };
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, hasScene: false, cameraPresented: false, points: 0 };
function privateCandidate(sdp: string): string {
return sdp.split('\r\n').filter(line => {
if (!line.startsWith('a=candidate:'))
@@ -24,8 +25,7 @@ function privateCandidate(sdp: string): string {
}
export function useK1Preview(device: Sensor, transport: SensorTransport, createRerunHost: RerunHostFactory, spatial: RefObject<HTMLDivElement | null>, video: RefObject<HTMLVideoElement | null>, onStatus: (value: PreviewStatus) => void, generation = 0, enabled = true) {
const [native, setNative] = useState<LiveRerunViewer | null>(null);
// The native runtime belongs to the view. Media recovery opens a new recording
// channel inside it; it never downloads/recreates the WASM iframe on each retry.
// The runtime and native channel belong to this acquisition view, not its peers.
useEffect(() => {
let active = true;
const host = createRerunHost(spatial.current!);
@@ -43,13 +43,31 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
onStatus({ ...pendingPreview, lidar: 'Визуализатор не загрузился. Обновите страницу.' }); });
return () => { active = false; setNative(null); host.dispose(); };
}, [createRerunHost, spatial, onStatus]);
const [view, setView] = useState<{
id: string; session: string; acquisition: string | null | undefined; channel: ReturnType<LiveRerunViewer['open_channel']>;
recording: ReturnType<typeof previewRecording>; previousRecording: string | null; hasScene: boolean;
} | null>(null);
useEffect(() => {
onStatus(enabled ? { ...pendingPreview } : { ...pendingPreview, lidar: 'Лидар: нет связи с БК', camera: 'Камера: нет связи с БК' });
if (!native || !enabled)
if (!native) return;
const id = crypto.randomUUID();
const channel = native.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + id);
setView({id, session:device.snapshot.context.session_id, acquisition:device.control?.acquisition_id, channel, recording: previewRecording(bytes => {if (!channel.ready) throw new Error('Viewer unavailable'); channel.send_rrd(bytes);}), previousRecording: native.get_active_recording_id(), hasScene: false});
return () => { setView(null); channel.close();
void perform(transport, device, 'close-peer', {view_id:id, retire_view:true}).catch(()=>{});
};
}, [native, device.snapshot.context.session_id, device.control?.acquisition_id, transport]);
useEffect(() => {
const currentView = view?.session === device.snapshot.context.session_id && view?.acquisition === device.control?.acquisition_id;
const paused = device.snapshot.acquisition === 'streaming'
? {lidar:'Лидар: нет связи с БК', camera:'Камера: нет связи с БК'}
: ['starting','preparing'].includes(device.snapshot.acquisition)
? {} : {lidar:'Лидар: приём завершён', camera:'Камера: изображение остановлено'};
onStatus({...pendingPreview, hasScene:currentView ? view?.hasScene ?? false : false, ...(!enabled ? paused : {})});
if (!native || !view || !currentView || !enabled)
return;
let active = true, failed = false, peerID: string | undefined;
let keepalive: ReturnType<typeof setInterval> | undefined, follow: ReturnType<typeof setInterval> | undefined, iceTimeout: ReturnType<typeof setTimeout> | undefined;
let status = { ...pendingPreview };
let status = { ...pendingPreview, hasScene: view.hasScene };
const update = (patch: Partial<PreviewStatus>) => { if (active) {
const next = { ...status, ...patch };
if (JSON.stringify(next) !== JSON.stringify(status)) {
@@ -62,16 +80,23 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
const rrd = pc.createDataChannel('rrd', { ordered: true }), camera = pc.createDataChannel('camera', { ordered: true });
rrd.binaryType = 'arraybuffer';
camera.binaryType = 'arraybuffer';
const previousRecording = viewer.get_active_recording_id();
const channel = viewer.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + generation);
const {channel, previousRecording} = view;
const fail = () => { if (!active || failed)
return; failed = true; update({ lidar: 'Лидар: восстанавливаем связь', camera: 'Камера: ожидаем соединение', retry: true, presented: false, cameraPresented: false }); clearInterval(follow); clearInterval(keepalive); pc.close(); };
let cameraDecoded = false, cameraFailed = false, lastVideoTime = -1, lastVideoProgress = 0, seenLidar = false;
const cameraFail = () => { cameraFailed = true; update({ camera: 'Камера: изображение недоступно', cameraPresented: false }); camera.close(); };
const decoder = previewCamera(video.current!, () => { cameraDecoded = true; lastVideoProgress = performance.now(); }, cameraFail);
let decoder = previewCamera(video.current!, () => { cameraDecoded = true; lastVideoProgress = performance.now(); }, cameraFail);
const cameraFrames = previewFrames(payload => decoder.push(payload));
const rrdFrames = previewFrames(payload => { assertRrd(payload); if (!channel.ready)
throw new Error('Viewer unavailable'); channel.send_rrd(payload); });
let batch: {sequence: number; lidar: Record<string, unknown>; received: number} | null = null;
const rrdFrames = previewFrames(payload => {
if (!batch || !channel.ready) throw new Error('Viewer unavailable');
if (view.recording.accept(batch.sequence, payload)) {
const age = batch.lidar.age_ms;
freshness.receive({...batch.lidar, age_ms: typeof age === 'number' ? age + performance.now() - batch.received : age});
}
rrd.send(JSON.stringify({ack: batch.sequence}));
batch = null;
});
rrd.onclose = () => { if (active && !failed)
fail(); };
camera.onclose = () => { if (active && !failed)
@@ -80,8 +105,17 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
if (!active)
return;
try {
if (typeof event.data === 'string')
freshness.receive(JSON.parse(event.data));
if (typeof event.data === 'string') {
const value = JSON.parse(event.data);
if (value.type === 'preview-unavailable' && value.code === 'resume-expired') {
failed = true; clearInterval(follow); clearInterval(keepalive);
update({retry:false, presented:false, cameraPresented:false, lidar:'Сессия просмотра истекла. Закройте и откройте пространственную сцену.'});
pc.close(); return;
}
if (batch || value.type !== 'rrd-batch' || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !value.lidar)
throw new Error('Invalid RRD batch');
batch = {...value, received: performance.now()};
}
else if (event.data instanceof ArrayBuffer)
rrdFrames.push(event.data);
}
@@ -97,6 +131,9 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
const metadata = JSON.parse(event.data);
if (metadata.type !== 'camera-ready' || typeof metadata.mime !== 'string')
throw new Error('Invalid camera metadata');
decoder.close();
cameraDecoded = false; cameraFailed = false; lastVideoTime = -1;
decoder = previewCamera(video.current!, () => {cameraDecoded = true; lastVideoProgress = performance.now();}, cameraFail);
decoder.open(metadata.mime);
}
else if (event.data instanceof ArrayBuffer)
@@ -128,13 +165,14 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
}
catch { /* Native recording/timeline creation is asynchronous. */ }
seenLidar ||= presented;
view.hasScene ||= presented;
const v = video.current!;
if (cameraDecoded && v.currentTime !== lastVideoTime) {
lastVideoTime = v.currentTime;
lastVideoProgress = performance.now();
}
const cameraPresented = cameraDecoded && !cameraFailed && performance.now() - lastVideoProgress < 2000;
update({ presented, cameraPresented, points: presented ? freshness.points : 0,
update({ presented, hasScene: view.hasScene, cameraPresented, points: presented ? freshness.points : 0,
lidar: presented ? 'Лидар: данные поступают' : seenLidar ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
}, 250);
@@ -154,7 +192,7 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
sdp: string;
type: 'answer';
media_protocol: string;
}>(transport, device, 'offer', { sdp: privateCandidate(pc.localDescription!.sdp), acquisition_id: device.control?.acquisition_id });
}>(transport, device, 'offer', { sdp: privateCandidate(pc.localDescription!.sdp), acquisition_id: device.control?.acquisition_id, view_id: view.id, after: view.recording.after });
peerID = answer.peer_id;
if (!active) {
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
@@ -190,9 +228,8 @@ export function useK1Preview(device: Sensor, transport: SensorTransport, createR
rrdFrames.close();
cameraFrames.close();
decoder.close();
channel.close();
if (peerID)
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
};
}, [native, device.snapshot.context.session_id, device.control?.acquisition_id, transport, onStatus, video, generation, enabled]);
}, [native, view, device.snapshot.context.session_id, device.control?.acquisition_id, transport, onStatus, video, generation, enabled]);
}
@@ -0,0 +1,84 @@
/** Pure K1 telemetry and phase vocabulary, shared by direct and onboard views. */
export type AcquisitionState =
| "preparing"
| "prepared"
| "awaiting_external_start"
| "starting"
| "acquiring"
| "awaiting_external_stop"
| "stopping"
| "finalizing"
| "completed"
| "failed"
| "aborted"
| "interrupted";
export interface XgridsK1Metrics {
pcl_frames?: number | null;
pose_frames?: number | null;
mqtt_to_decode_ms?: number | null;
decode_ms?: number | null;
publish_ms?: number | null;
pipeline_ms?: number | null;
end_to_end_ms?: number | null;
frame_rate?: number | null;
frame_rate_hz?: number | null;
point_count?: number | null;
dropped_preview_frames?: number | null;
ai_end_to_end_ms?: number | null;
ai_end_to_end_p95_ms?: number | null;
ai_frame_rate_hz?: number | null;
ai_dropped_frames?: number | null;
ai_stale_ms?: number | null;
device_elapsed_seconds?: number | null;
device_route_distance_meters?: number | null;
device_speed_meters_per_second?: number | null;
device_speed_mps?: number | null;
elapsed_seconds?: number | null;
route_distance_meters?: number | null;
speed_meters_per_second?: number | null;
[key: string]: number | null | undefined;
}
export function finiteMetric(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function nonNegativeMetric(value: number | null | undefined): number | null {
const finite = finiteMetric(value);
return finite !== null && finite >= 0 ? finite : null;
}
export interface K1DeviceTelemetry {
elapsedSeconds: number | null;
routeDistanceMeters: number | null;
speedMetersPerSecond: number | null;
}
export function deviceTelemetry(
metrics: XgridsK1Metrics | null | undefined,
): K1DeviceTelemetry {
return {
elapsedSeconds: nonNegativeMetric(
metrics?.device_elapsed_seconds ?? metrics?.elapsed_seconds,
),
routeDistanceMeters: nonNegativeMetric(
metrics?.device_route_distance_meters ?? metrics?.route_distance_meters,
),
speedMetersPerSecond: nonNegativeMetric(
metrics?.device_speed_meters_per_second ??
metrics?.device_speed_mps ??
metrics?.speed_meters_per_second,
),
};
}
export function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
-99
View File
@@ -984,21 +984,6 @@ box-sizing: border-box;
}
}
.xgrids-k1-spatial-controls {
display: flex;
min-width: min(42rem, 100%);
max-width: 100%;
align-items: center;
gap: 0.85rem;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 1rem;
background: rgb(9 10 13 / 0.88);
padding: 0.55rem 0.65rem 0.55rem 0.75rem;
color: var(--nodedc-text-primary);
box-shadow: 0 0.9rem 2.4rem rgb(0 0 0 / 0.3);
backdrop-filter: blur(20px);
}
.xgrids-k1-spatial-controls--recovery {
display: grid;
min-width: min(46rem, 100%);
@@ -1108,90 +1093,6 @@ box-sizing: border-box;
display: none;
}
.xgrids-k1-spatial-controls__phase {
display: flex;
min-width: 11rem;
flex: 1 1 15rem;
align-items: center;
gap: 0.58rem;
}
.xgrids-k1-spatial-controls__phase > span:last-child {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.xgrids-k1-spatial-controls__phase strong,
.xgrids-k1-spatial-controls__phase small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__phase strong {
font-size: 0.66rem;
}
.xgrids-k1-spatial-controls__phase small {
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.xgrids-k1-spatial-controls__telemetry {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 0.65rem;
}
.xgrids-k1-spatial-controls__telemetry > span {
display: grid;
gap: 0.12rem;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__telemetry small {
color: var(--nodedc-text-muted);
font-size: 0.48rem;
}
.xgrids-k1-spatial-controls__telemetry strong {
font-size: 0.61rem;
}
.xgrids-k1-spatial-controls__error {
display: grid;
max-width: 17rem;
gap: 0.12rem;
color: rgb(var(--nodedc-danger-rgb));
}
.xgrids-k1-spatial-controls__error strong {
font-size: 0.61rem;
}
.xgrids-k1-spatial-controls__error small {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.51rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__action-label {
display: block;
width: 9.75rem;
font-size: 0.66rem;
line-height: 1.08;
text-align: center;
white-space: normal;
}
.xgrids-k1-spatial-controls__action-label--local {
width: 8.75rem;
}
.connection-action-progress {
display: flex;
min-height: 2.75rem;