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
@@ -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>
);
}