fix(control-station): separate lab contours from recordings
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
@@ -752,7 +753,14 @@ export default function App() {
|
||||
) : null}
|
||||
</div>
|
||||
) : activeDefinition.kind === "recordings" ? (
|
||||
<StatusBadge tone="accent">Архив записей</StatusBadge>
|
||||
<ObservationSessionSelect
|
||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||
blockedReason={sourceSwitchBlockedReason}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={acceptRecordedReplay}
|
||||
onReplaySettled={(_session, outcome) =>
|
||||
settleRecordedReplaySwitch(outcome)}
|
||||
/>
|
||||
) : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
|
||||
@@ -153,10 +153,10 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
{
|
||||
id: "lab-archive",
|
||||
root: "polygon",
|
||||
label: "Лабораторные работы",
|
||||
title: "Архив лабораторных работ",
|
||||
label: "Лабораторные контуры",
|
||||
title: "Лабораторные контуры",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ЛАБОРАТОРИЯ",
|
||||
description: "Конфигурации, ход эксперимента и визуальные результаты принятых лабораторных работ.",
|
||||
description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
|
||||
icon: "clipboard",
|
||||
kind: "lab-archive",
|
||||
groups: [],
|
||||
|
||||
@@ -2385,18 +2385,17 @@
|
||||
|
||||
.recordings-workspace,
|
||||
.lab-archive-workspace,
|
||||
.recordings-workspace__viewer,
|
||||
.lab-result-surface {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.recordings-workspace__viewer,
|
||||
.lab-result-surface {
|
||||
min-height: 30rem;
|
||||
.recordings-workspace {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.recordings-workspace__viewer > header,
|
||||
.lab-result-surface > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2404,102 +2403,346 @@
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.recordings-workspace__viewer > header strong,
|
||||
.lab-result-surface > header strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.recordings-workspace__empty {
|
||||
.lab-archive-workspace {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector {
|
||||
display: grid;
|
||||
min-height: 27rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(0 0 0 / 0.24);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recordings-workspace__empty strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.recordings-workspace__empty p {
|
||||
margin: 0;
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
|
||||
.lab-configuration {
|
||||
display: flex;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
gap: 1.4rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.lab-configuration h2,
|
||||
.lab-configuration p {
|
||||
.laboratory-selector h2,
|
||||
.laboratory-selector p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lab-configuration h2 {
|
||||
.laboratory-selector h2 {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.lab-configuration p {
|
||||
max-width: 55rem;
|
||||
.laboratory-selector p {
|
||||
max-width: 60rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lab-primary-result {
|
||||
.laboratory-selector label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.lab-primary-result button {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.3rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.8rem 1rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lab-primary-result button[data-active="true"] {
|
||||
background: rgb(255 255 255 / 0.065);
|
||||
}
|
||||
|
||||
.lab-primary-result button > span {
|
||||
grid-row: 1 / 3;
|
||||
align-self: center;
|
||||
color: var(--nodedc-accent);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lab-primary-result button > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.lab-primary-result button > small {
|
||||
.laboratory-selector label > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-selector select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
outline: 0;
|
||||
background: rgb(255 255 255 / 0.075);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.58rem 0.85rem;
|
||||
font: inherit;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.laboratory-selector select:focus-visible {
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
.laboratory-selector select:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.laboratory-work-output {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-task,
|
||||
.laboratory-result-summary,
|
||||
.laboratory-visual-result {
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-task > header,
|
||||
.laboratory-result-summary > header,
|
||||
.laboratory-visual-result > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.laboratory-task h2,
|
||||
.laboratory-task p,
|
||||
.laboratory-task dl,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-result-summary p,
|
||||
.laboratory-visual-result h2,
|
||||
.laboratory-visual-result p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-task h2,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-visual-result h2 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.laboratory-task p,
|
||||
.laboratory-result-summary > p,
|
||||
.laboratory-visual-result header p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.laboratory-task dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-task dl > div,
|
||||
.laboratory-result-metrics > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-task dt,
|
||||
.laboratory-task dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-task dt,
|
||||
.laboratory-result-metrics span,
|
||||
.laboratory-result-metrics small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-task dd,
|
||||
.laboratory-result-metrics strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 660;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lab-result-surface {
|
||||
min-height: 36rem;
|
||||
}
|
||||
|
||||
.lab-result-surface .spatial-workspace {
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.laboratory-result-pending {
|
||||
display: grid;
|
||||
min-height: 34rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: #06070a;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.laboratory-result-pending strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.laboratory-result-pending p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.laboratory-visual-result {
|
||||
display: grid;
|
||||
min-height: 34rem;
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
background: #08090c;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__bar {
|
||||
display: flex;
|
||||
height: 1.1rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__bar i[data-status="agree"],
|
||||
.laboratory-e29-chart__legend i[data-status="agree"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__bar i[data-status="camera"],
|
||||
.laboratory-e29-chart__legend i[data-status="camera"] {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__bar i[data-status="conflict"],
|
||||
.laboratory-e29-chart__legend i[data-status="conflict"] {
|
||||
min-width: 0.25rem;
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend > div {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.45rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend i {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend span,
|
||||
.laboratory-e29-chart__legend small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-chart__legend small {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence article {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.45rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence article > div {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.laboratory-e29-evidence article > div span {
|
||||
border-radius: 0.6rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.laboratory-result-summary {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.laboratory-selector {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.laboratory-task dl,
|
||||
.laboratory-result-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.dataset-entry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr);
|
||||
|
||||
@@ -61,9 +61,11 @@ function errorMessage(error: unknown): string {
|
||||
export function LidarQualityWorkspace({
|
||||
deviceLabel,
|
||||
onOpenObservation,
|
||||
embedded = false,
|
||||
}: {
|
||||
deviceLabel: string | null;
|
||||
onOpenObservation: () => void;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<LidarReplayCatalog | null>(null);
|
||||
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
|
||||
@@ -248,29 +250,31 @@ export function LidarQualityWorkspace({
|
||||
</GlassSurface>
|
||||
) : (
|
||||
<>
|
||||
<section className="lidar-device-context" aria-label="Выбранный LiDAR и запись">
|
||||
<div className="lidar-device-context__identity">
|
||||
<span className="section-eyebrow">ВЫБРАННЫЙ СЕНСОР</span>
|
||||
<strong>{deviceLabel ?? "Архивный источник"} · LiDAR</strong>
|
||||
<small>
|
||||
Запись {fieldReview?.sessionId ?? detail.pack.sessionId}
|
||||
</small>
|
||||
</div>
|
||||
<div className="lidar-device-context__verdict">
|
||||
<StatusBadge tone="warning">Диагностическая запись</StatusBadge>
|
||||
<p>
|
||||
Данные воспроизводятся и доступны для визуального разбора.
|
||||
Физическая точность сенсора этой записью не аттестована.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={onOpenObservation}
|
||||
>
|
||||
Открыть наблюдение
|
||||
</Button>
|
||||
</section>
|
||||
{!embedded ? (
|
||||
<section className="lidar-device-context" aria-label="Выбранный LiDAR и запись">
|
||||
<div className="lidar-device-context__identity">
|
||||
<span className="section-eyebrow">ВЫБРАННЫЙ СЕНСОР</span>
|
||||
<strong>{deviceLabel ?? "Архивный источник"} · LiDAR</strong>
|
||||
<small>
|
||||
Запись {fieldReview?.sessionId ?? detail.pack.sessionId}
|
||||
</small>
|
||||
</div>
|
||||
<div className="lidar-device-context__verdict">
|
||||
<StatusBadge tone="warning">Диагностическая запись</StatusBadge>
|
||||
<p>
|
||||
Данные воспроизводятся и доступны для визуального разбора.
|
||||
Физическая точность сенсора этой записью не аттестована.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={onOpenObservation}
|
||||
>
|
||||
Открыть наблюдение
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<GlassSurface className="lidar-field-review" padding="lg">
|
||||
<header className="panel-heading lidar-field-review__heading">
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
ObservationSessionArchive,
|
||||
type ObservationSessionReplayCallbacks,
|
||||
} from "../components/ObservationSessionSelect";
|
||||
import {
|
||||
@@ -24,7 +23,11 @@ import {
|
||||
} from "../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type {
|
||||
ObservationSessionReplayLaunch,
|
||||
ObservationSessionSummary,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../core/observation/useObservationSessions";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
@@ -1176,84 +1179,447 @@ function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
function RecordingsWorkspace(props: WorkspaceRendererProps) {
|
||||
return (
|
||||
<div className="recordings-workspace">
|
||||
<ObservationSessionArchive {...props.sessionArchive} />
|
||||
{props.recordedReplay ? (
|
||||
<section className="recordings-workspace__viewer">
|
||||
<header>
|
||||
<span className="section-eyebrow">ВОСПРОИЗВЕДЕНИЕ</span>
|
||||
<strong>Выбранная пространственная запись</strong>
|
||||
</header>
|
||||
<SpatialWorkspace {...props} />
|
||||
</section>
|
||||
) : (
|
||||
<section className="recordings-workspace__empty">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Выберите сохранённую сессию</strong>
|
||||
<p>Viewer появится здесь; эфир в разделе «Наблюдение» останется неизменным.</p>
|
||||
</section>
|
||||
)}
|
||||
<SpatialWorkspace {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
const [surface, setSurface] = useState<"lidar-e29" | "replay">(
|
||||
props.recordedReplay ? "replay" : "lidar-e29",
|
||||
type LaboratoryProfileId = "sensor-fusion" | "published-perception";
|
||||
type LaboratoryWorkId = "e28-local-surface" | "e29-camera-geometry" | `session:${string}`;
|
||||
|
||||
interface LaboratoryOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function LaboratorySelector<T extends string>({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly LaboratoryOption<T>[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-selector">
|
||||
<div>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.currentTarget.value as T)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
);
|
||||
const replayActions: WorkspaceRendererProps["sessionArchive"] = {
|
||||
...props.sessionArchive,
|
||||
onReplayAccepted: async (session, launch) => {
|
||||
await props.sessionArchive.onReplayAccepted?.(session, launch);
|
||||
setSurface("replay");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function LaboratoryTask({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
facts,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
facts: readonly { label: string; value: string }[];
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-task">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ОПИСАНИЕ ЛАБОРАТОРНОЙ РАБОТЫ</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge tone="warning">{status}</StatusBadge>
|
||||
</header>
|
||||
<dl>
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
|
||||
const semanticTotal = 19_625;
|
||||
const statuses = [
|
||||
{ id: "agree", label: "Камера + геометрия", value: 6_341 },
|
||||
{ id: "camera", label: "Только камера", value: 13_246 },
|
||||
{ id: "conflict", label: "Конфликт", value: 38 },
|
||||
] as const;
|
||||
const conflictEpisodes = [
|
||||
"track 115 · 69,799–71,398 с",
|
||||
"track 470 · 215,324–218,708 с",
|
||||
"track 679 · 282,569–283,281 с",
|
||||
"track 1011 · 354,937–355,242 с",
|
||||
"track 1276 · 402,606–402,995 с",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="lab-archive-workspace">
|
||||
<section className="lab-configuration">
|
||||
<div>
|
||||
<span className="section-eyebrow">ОБЪЕКТ И КОНФИГУРАЦИЯ ИССЛЕДОВАНИЯ</span>
|
||||
<h2>Текущий профиль · camera + LiDAR</h2>
|
||||
<p>
|
||||
Объект и точная конфигурация берутся из LAB-provenance выбранной записи.
|
||||
Исходный сенсор остаётся read-only, вычисление выполняется на worker D.
|
||||
</p>
|
||||
<>
|
||||
<LaboratoryTask
|
||||
title="LAB E29 · camera-first semantics + независимая геометрия"
|
||||
description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
|
||||
status="Диагностический replay"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
|
||||
{ label: "Источник", value: "RAVNOVES00 · 4 489 кадров" },
|
||||
{ label: "Объектов", value: "20 513 наблюдений" },
|
||||
{ label: "Полномочия", value: "Только shadow · команды запрещены" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="laboratory-visual-result" aria-label="Визуальный результат LAB E29">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РЕЗУЛЬТАТ</span>
|
||||
<h2>Покрытие семантических наблюдений геометрией</h2>
|
||||
<p>
|
||||
Это покрытие одного воспроизводимого replay, а не accuracy и не допуск
|
||||
планировщика. Конфликты сохранены для ручного покадрового разбора.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone="warning">16 конфликтных эпизодов</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="laboratory-e29-chart" role="img" aria-label="Распределение статусов геометрии">
|
||||
<div className="laboratory-e29-chart__bar">
|
||||
{statuses.map((status) => (
|
||||
<i
|
||||
key={status.id}
|
||||
data-status={status.id}
|
||||
style={{ width: `${(status.value / semanticTotal) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="laboratory-e29-chart__legend">
|
||||
{statuses.map((status) => (
|
||||
<div key={status.id}>
|
||||
<i data-status={status.id} />
|
||||
<span>{status.label}</span>
|
||||
<strong>{status.value.toLocaleString("ru-RU")}</strong>
|
||||
<small>{((status.value / semanticTotal) * 100).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
})}%</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="laboratory-e29-evidence">
|
||||
<article>
|
||||
<span className="section-eyebrow">НЕЗАВИСИМАЯ ГЕОМЕТРИЯ</span>
|
||||
<strong>21 321 компонент</strong>
|
||||
<p>
|
||||
Незасемантизированные занятые компоненты сохранены отдельным слоем:
|
||||
им не назначается выдуманный класс и они не считаются свободным местом.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<span className="section-eyebrow">КОНФЛИКТЫ ДЛЯ РАЗБОРА</span>
|
||||
<div>
|
||||
{conflictEpisodes.map((episode) => <span key={episode}>{episode}</span>)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<StatusBadge tone="accent">Активная серия</StatusBadge>
|
||||
</section>
|
||||
|
||||
<section className="lab-primary-result">
|
||||
<button
|
||||
type="button"
|
||||
data-active={surface === "lidar-e29" ? "true" : undefined}
|
||||
onClick={() => setSurface("lidar-e29")}
|
||||
>
|
||||
<span>LAB E29</span>
|
||||
<strong>Локальная модель поверхности L2.6</strong>
|
||||
<small>Диагностический replay всей записи и кадры для разбора</small>
|
||||
</button>
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||
<h2>Архитектурная схема подтверждена, safety-gate не пройден</h2>
|
||||
</div>
|
||||
<StatusBadge tone="success">Replay завершён</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
<div><span>Поддержка геометрией</span><strong>6 341</strong><small>32,31% current observations</small></div>
|
||||
<div><span>Camera-only</span><strong>13 246</strong><small>Семантика сохранена</small></div>
|
||||
<div><span>Postprocess p95</span><strong>2,517 мс</strong><small>8,467 с полный build</small></div>
|
||||
<div><span>Geometry-only</span><strong>21 321</strong><small>Занято / класс неизвестен</small></div>
|
||||
</div>
|
||||
<p>
|
||||
Следующий gate — покадровый разбор конфликтов, camera-only пробелов и
|
||||
geometry-only компонентов, затем source-paced shadow с deadline и staleness.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<ObservationSessionArchive {...replayActions} labsOnly />
|
||||
function formatLaboratoryMetric(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||||
if (typeof value === "number") {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function laboratoryMetricLabel(key: string): string {
|
||||
return key
|
||||
.replace(/_p95_ms$/, " · p95 мс")
|
||||
.replaceAll("_", " ")
|
||||
.replace(/^./, (value) => value.toLocaleUpperCase("ru-RU"));
|
||||
}
|
||||
|
||||
function laboratorySessionTitle(session: ObservationSessionSummary): string {
|
||||
const labPrefix = session.lab ? `${session.lab.labId} · ` : "";
|
||||
return labPrefix && session.label.startsWith(labPrefix)
|
||||
? session.label.slice(labPrefix.length)
|
||||
: session.label;
|
||||
}
|
||||
|
||||
function PublishedLaboratoryResult({
|
||||
props,
|
||||
session,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
props: WorkspaceRendererProps;
|
||||
session: ObservationSessionSummary;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const lab = session.lab;
|
||||
const metrics = Object.entries(lab?.provenance ?? {})
|
||||
.filter(([key, value]) => (
|
||||
(typeof value === "number" || typeof value === "boolean")
|
||||
&& !key.includes("sha256")
|
||||
&& !key.includes("authority")
|
||||
))
|
||||
.slice(0, 8);
|
||||
const replayReady = props.recordedReplay?.sessionId === session.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LaboratoryTask
|
||||
title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`}
|
||||
description="Опубликованная лабораторная работа открывается по своей неизменяемой записи. Viewer показывает исходные синхронные каналы; за ним приведены только метрики, зафиксированные в LAB-provenance этого результата."
|
||||
status="Зафиксированный результат"
|
||||
facts={[
|
||||
{ label: "Тип результата", value: lab?.resultKind ?? "—" },
|
||||
{ label: "Источник", value: lab?.sourceSessionId ?? session.id },
|
||||
{ label: "Конфигурация", value: lab?.configSha256?.slice(0, 16) ?? "Не зафиксирована" },
|
||||
{ label: "Полномочия", value: "Диагностика · без навигации и команд" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="lab-result-surface">
|
||||
<header>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО</span>
|
||||
<strong>
|
||||
{surface === "replay" && props.recordedReplay
|
||||
? "Запись выбранной лабораторной работы"
|
||||
: "LAB E29 · результат L2.6"}
|
||||
</strong>
|
||||
<strong>Исходная запись выбранной лабораторной работы</strong>
|
||||
</header>
|
||||
{surface === "replay" && props.recordedReplay ? (
|
||||
{replayReady ? (
|
||||
<SpatialWorkspace {...props} />
|
||||
) : (
|
||||
<LidarQualityWorkspace
|
||||
deviceLabel={props.deviceLabel}
|
||||
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
||||
/>
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
|
||||
<strong>{loading ? "Подготавливаем лабораторную запись" : "Запись не открыта"}</strong>
|
||||
<p>
|
||||
{error ?? (loading
|
||||
? "Связанное визуальное доказательство откроется после проверки записи."
|
||||
: "Выберите работу ещё раз, чтобы открыть связанное визуальное доказательство.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЗАФИКСИРОВАННЫЕ МЕТРИКИ</span>
|
||||
<h2>Результат из LAB-provenance</h2>
|
||||
</div>
|
||||
<StatusBadge tone={
|
||||
lab?.provenance.benchmark_passed === true ? "success" : "warning"
|
||||
}>
|
||||
{lab?.provenance.benchmark_passed === true ? "Benchmark passed" : "Требует разбора"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
{metrics.length ? metrics.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span>{laboratoryMetricLabel(key)}</span>
|
||||
<strong>{formatLaboratoryMetric(value)}</strong>
|
||||
<small>Immutable provenance</small>
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<span>Метрики</span>
|
||||
<strong>Не опубликованы</strong>
|
||||
<small>Доступна исходная запись</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
|
||||
const sessions = useObservationSessions({
|
||||
limit: 100,
|
||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||
onReplayBegin: props.sessionArchive.onReplayBegin,
|
||||
onReplayAccepted: props.sessionArchive.onReplayAccepted,
|
||||
onReplaySettled: props.sessionArchive.onReplaySettled,
|
||||
});
|
||||
const publishedWorks = useMemo(
|
||||
() => sessions.items.filter((session) => session.lab !== null),
|
||||
[sessions.items],
|
||||
);
|
||||
const rigLabel = useMemo(() => {
|
||||
if (props.deviceLabel) return props.deviceLabel;
|
||||
const coordinateFrame = publishedWorks
|
||||
.map((session) => session.lab?.provenance.coordinate_frame)
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const sensorToken = typeof coordinateFrame === "string"
|
||||
? coordinateFrame.split("-")[0]?.trim()
|
||||
: "";
|
||||
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
|
||||
}, [props.deviceLabel, publishedWorks]);
|
||||
const profiles: readonly LaboratoryOption<LaboratoryProfileId>[] = [
|
||||
{
|
||||
id: "sensor-fusion",
|
||||
label: `${rigLabel} · камера + LiDAR · worker D`,
|
||||
},
|
||||
{
|
||||
id: "published-perception",
|
||||
label: `${rigLabel} · опубликованный perception pipeline`,
|
||||
},
|
||||
];
|
||||
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
|
||||
profileId === "sensor-fusion"
|
||||
? [
|
||||
{ id: "e28-local-surface", label: "LAB E28 · локальная поверхность L2.6" },
|
||||
{ id: "e29-camera-geometry", label: "LAB E29 · camera-first + geometry" },
|
||||
]
|
||||
: publishedWorks.map((session) => ({
|
||||
id: `session:${session.id}` as const,
|
||||
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
||||
}));
|
||||
const selectedSessionId = workId.startsWith("session:")
|
||||
? workId.slice("session:".length)
|
||||
: null;
|
||||
const selectedSession = selectedSessionId
|
||||
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (profileId !== "published-perception" || workId.startsWith("session:")) return;
|
||||
const first = publishedWorks[0];
|
||||
if (!first) return;
|
||||
setWorkId(`session:${first.id}`);
|
||||
}, [profileId, publishedWorks, workId]);
|
||||
|
||||
const selectProfile = (next: LaboratoryProfileId) => {
|
||||
setProfileId(next);
|
||||
if (next === "sensor-fusion") {
|
||||
setWorkId("e28-local-surface");
|
||||
return;
|
||||
}
|
||||
const first = publishedWorks[0];
|
||||
if (!first) return;
|
||||
const nextWork = `session:${first.id}` as const;
|
||||
setWorkId(nextWork);
|
||||
void sessions.replay(first.id);
|
||||
};
|
||||
|
||||
const selectWork = (next: LaboratoryWorkId) => {
|
||||
setWorkId(next);
|
||||
if (!next.startsWith("session:")) return;
|
||||
void sessions.replay(next.slice("session:".length));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lab-archive-workspace">
|
||||
<LaboratorySelector
|
||||
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
|
||||
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
|
||||
description="Профиль фиксирует объект исследования, сенсорные модули и вычислительный контур. Исходные данные остаются read-only; профиль объединяет серию сопоставимых лабораторных работ."
|
||||
label="Профиль"
|
||||
value={profileId}
|
||||
options={profiles}
|
||||
onChange={selectProfile}
|
||||
/>
|
||||
|
||||
<LaboratorySelector
|
||||
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
||||
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача, полноразмерное визуальное доказательство и структурированный результат."
|
||||
label="Работа"
|
||||
value={workId}
|
||||
options={workOptions}
|
||||
disabled={workOptions.length === 0}
|
||||
onChange={selectWork}
|
||||
/>
|
||||
|
||||
<div className="laboratory-work-output">
|
||||
{workId === "e28-local-surface" ? (
|
||||
<>
|
||||
<LaboratoryTask
|
||||
title="LAB E28 · локальная модель поверхности L2.6"
|
||||
description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала."
|
||||
status="Диагностический replay"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
|
||||
{ label: "Покрытие", value: "3 928 / 3 928 кадров" },
|
||||
{ label: "Режим", value: "Recorded-source-paced shadow" },
|
||||
{ label: "Полномочия", value: "Навигация и команды запрещены" },
|
||||
]}
|
||||
/>
|
||||
<LidarQualityWorkspace
|
||||
embedded
|
||||
deviceLabel={props.deviceLabel}
|
||||
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
||||
/>
|
||||
</>
|
||||
) : workId === "e29-camera-geometry" ? (
|
||||
<E29LaboratoryResult rigLabel={rigLabel} />
|
||||
) : selectedSession ? (
|
||||
<PublishedLaboratoryResult
|
||||
props={props}
|
||||
session={selectedSession}
|
||||
loading={sessions.replayingSessionId === selectedSession.id}
|
||||
error={sessions.failedSessionId === selectedSession.id ? sessions.error : null}
|
||||
/>
|
||||
) : (
|
||||
<div className="laboratory-result-pending">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>В этом профиле пока нет опубликованных работ</strong>
|
||||
<p>Работы появятся после публикации проверенного LAB-provenance.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,6 +172,31 @@ test("opened archive is named in the scene header and trash hover has no pill",
|
||||
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
|
||||
});
|
||||
|
||||
test("data recordings keep the compact session dropdown and laboratory results stay separate", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const workspaceSource = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const productSource = await readFile(
|
||||
new URL("../src/productModel.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const recordingsSource = workspaceSource.slice(
|
||||
workspaceSource.indexOf("function RecordingsWorkspace"),
|
||||
workspaceSource.indexOf("type LaboratoryProfileId"),
|
||||
);
|
||||
|
||||
assert.match(appSource, /activeDefinition\.kind === "recordings"[\s\S]*<ObservationSessionSelect/);
|
||||
assert.match(recordingsSource, /<SpatialWorkspace \{\.\.\.props\} \/>/);
|
||||
assert.doesNotMatch(recordingsSource, /ObservationSessionArchive/);
|
||||
assert.match(productSource, /label: "Лабораторные контуры"/);
|
||||
assert.match(workspaceSource, /ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА/);
|
||||
assert.match(workspaceSource, /ЛАБОРАТОРНАЯ РАБОТА/);
|
||||
assert.match(workspaceSource, /e28-local-surface/);
|
||||
assert.match(workspaceSource, /e29-camera-geometry/);
|
||||
});
|
||||
|
||||
test("session catalog exposes authoritative background preparation state", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
|
||||
Reference in New Issue
Block a user