feat(lab): standardize evidence workspace template

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 17:06:13 +03:00
parent 360ec39ff3
commit e9ff29297b
4 changed files with 215 additions and 45 deletions
+2
View File
@@ -315,6 +315,8 @@ export default function App() {
const observationFullscreenActive = Boolean( const observationFullscreenActive = Boolean(
activeDefinition?.kind === "spatial" activeDefinition?.kind === "spatial"
? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud" ? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud"
: activeDefinition?.kind === "lab-archive"
? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud"
: activeDefinition?.kind === "cameras" && focusedObservationSource, : activeDefinition?.kind === "cameras" && focusedObservationSource,
); );
+45 -4
View File
@@ -2385,7 +2385,8 @@
.recordings-workspace, .recordings-workspace,
.lab-archive-workspace, .lab-archive-workspace,
.lab-result-surface { .lab-result-surface,
.laboratory-work-template {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
} }
@@ -2542,12 +2543,52 @@
white-space: nowrap; white-space: nowrap;
} }
.lab-result-surface { .laboratory-evidence-frame {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: 1rem;
}
.lab-result-surface[data-resizable="true"] .laboratory-evidence-frame {
height: clamp(42rem, 68vh, 58rem);
min-height: 42rem;
resize: vertical;
}
.laboratory-evidence-frame > .spatial-workspace {
height: 100%;
min-height: 0; min-height: 0;
} }
.lab-result-surface .spatial-workspace { .laboratory-evidence-frame > .lidar-quality-workspace {
min-height: 34rem; min-height: 42rem;
}
.lab-archive-workspace[data-viewer-focused="true"] {
height: 100%;
min-height: 0;
padding: 0;
}
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-selector,
.lab-archive-workspace[data-viewer-focused="true"]
.laboratory-work-template > :not(.lab-result-surface),
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface > header {
display: none;
}
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-work-output,
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-work-template,
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface,
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
height: 100%;
min-height: 0;
}
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
resize: none;
} }
.laboratory-result-pending { .laboratory-result-pending {
@@ -5,6 +5,7 @@ import {
useRef, useRef,
useState, useState,
type ComponentType, type ComponentType,
type ReactNode,
} from "react"; } from "react";
import { import {
Button, Button,
@@ -1281,6 +1282,54 @@ function LaboratoryTask({
); );
} }
function LaboratoryEvidence({
eyebrow,
title,
resizable = false,
children,
}: {
eyebrow: string;
title: string;
resizable?: boolean;
children: ReactNode;
}) {
return (
<section
className="lab-result-surface"
data-resizable={resizable ? "true" : undefined}
>
<header>
<span className="section-eyebrow">{eyebrow}</span>
<strong>{title}</strong>
</header>
<div className="laboratory-evidence-frame">
{children}
</div>
</section>
);
}
function LaboratoryWorkTemplate({
task,
evidence,
result = null,
details = null,
}: {
task: ReactNode;
evidence: ReactNode;
result?: ReactNode;
details?: ReactNode;
}) {
return (
<div className="laboratory-work-template">
{task}
{evidence}
{result}
{details}
</div>
);
}
function formatSeconds(value: number): string { function formatSeconds(value: number): string {
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`; return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
} }
@@ -1334,7 +1383,8 @@ function E29LaboratoryResult({
}, [result.resultId, selectedFrameIndex]); }, [result.resultId, selectedFrameIndex]);
return ( return (
<> <LaboratoryWorkTemplate
task={(
<LaboratoryTask <LaboratoryTask
title="LAB E29 · camera-first semantics + независимая геометрия" title="LAB E29 · camera-first semantics + независимая геометрия"
description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством." description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
@@ -1355,12 +1405,13 @@ function E29LaboratoryResult({
}, },
]} ]}
/> />
)}
<section className="lab-result-surface"> evidence={(
<header> <LaboratoryEvidence
<span className="section-eyebrow">ИСХОДНЫЕ ДАННЫЕ</span> eyebrow="ИСХОДНЫЕ ДАННЫЕ"
<strong>LiDAR, траектория и камера RAVNOVES00</strong> title="LiDAR, траектория и камера RAVNOVES00"
</header> resizable
>
{replayReady ? ( {replayReady ? (
<SpatialWorkspace {...props} /> <SpatialWorkspace {...props} />
) : ( ) : (
@@ -1374,8 +1425,9 @@ function E29LaboratoryResult({
</p> </p>
</div> </div>
)} )}
</section> </LaboratoryEvidence>
)}
result={(
<section className="laboratory-result-summary"> <section className="laboratory-result-summary">
<header> <header>
<div> <div>
@@ -1410,7 +1462,8 @@ function E29LaboratoryResult({
</div> </div>
<p>{result.decision.nextGate}</p> <p>{result.decision.nextGate}</p>
</section> </section>
)}
details={(
<section className="laboratory-frame-review"> <section className="laboratory-frame-review">
<header> <header>
<div> <div>
@@ -1476,7 +1529,8 @@ function E29LaboratoryResult({
</div> </div>
)} )}
</section> </section>
</> )}
/>
); );
} }
@@ -1524,7 +1578,8 @@ function PublishedLaboratoryResult({
const replayReady = props.recordedReplay?.sessionId === session.id; const replayReady = props.recordedReplay?.sessionId === session.id;
return ( return (
<> <LaboratoryWorkTemplate
task={(
<LaboratoryTask <LaboratoryTask
title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`} title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`}
description="Опубликованная лабораторная работа открывается по своей неизменяемой записи. Viewer показывает исходные синхронные каналы; за ним приведены только метрики, зафиксированные в LAB-provenance этого результата." description="Опубликованная лабораторная работа открывается по своей неизменяемой записи. Viewer показывает исходные синхронные каналы; за ним приведены только метрики, зафиксированные в LAB-provenance этого результата."
@@ -1536,12 +1591,13 @@ function PublishedLaboratoryResult({
{ label: "Полномочия", value: "Диагностика · без навигации и команд" }, { label: "Полномочия", value: "Диагностика · без навигации и команд" },
]} ]}
/> />
)}
<section className="lab-result-surface"> evidence={(
<header> <LaboratoryEvidence
<span className="section-eyebrow">ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО</span> eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
<strong>Исходная запись выбранной лабораторной работы</strong> title="Исходная запись выбранной лабораторной работы"
</header> resizable
>
{replayReady ? ( {replayReady ? (
<SpatialWorkspace {...props} /> <SpatialWorkspace {...props} />
) : ( ) : (
@@ -1555,8 +1611,9 @@ function PublishedLaboratoryResult({
</p> </p>
</div> </div>
)} )}
</section> </LaboratoryEvidence>
)}
result={(
<section className="laboratory-result-summary"> <section className="laboratory-result-summary">
<header> <header>
<div> <div>
@@ -1585,7 +1642,8 @@ function PublishedLaboratoryResult({
)} )}
</div> </div>
</section> </section>
</> )}
/>
); );
} }
@@ -1792,8 +1850,16 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
); );
} }
const viewerFocused = Boolean(
props.observationLayout.focusedSourceId
|| props.observationLayout.maximizedFloatingSourceId,
);
return ( return (
<div className="lab-archive-workspace"> <div
className="lab-archive-workspace"
data-viewer-focused={viewerFocused ? "true" : undefined}
>
<LaboratorySelector <LaboratorySelector
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА" eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel} title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
@@ -1817,7 +1883,8 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
<div className="laboratory-work-output"> <div className="laboratory-work-output">
{workId === "e28-local-surface" ? ( {workId === "e28-local-surface" ? (
<> <LaboratoryWorkTemplate
task={(
<LaboratoryTask <LaboratoryTask
title="LAB E28 · локальная модель поверхности L2.6" title="LAB E28 · локальная модель поверхности L2.6"
description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала." description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала."
@@ -1832,12 +1899,20 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
{ label: "Артефакты", value: "Control plane · read-only · hash verified" }, { label: "Артефакты", value: "Control plane · read-only · hash verified" },
]} ]}
/> />
)}
evidence={(
<LaboratoryEvidence
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
title="Диагностическая поверхность и кадры LAB E28"
>
<LidarQualityWorkspace <LidarQualityWorkspace
embedded embedded
deviceLabel={props.deviceLabel} deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")} onOpenObservation={() => props.navigation.openView("spatial-scene")}
/> />
</> </LaboratoryEvidence>
)}
/>
) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? ( ) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? (
<E29LaboratoryResult <E29LaboratoryResult
props={props} props={props}
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let productModel;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
productModel = await server.ssrLoadModule("/src/productModel.ts");
});
after(async () => {
await server?.close();
});
test("top navigation has no Center and Park owns contour health first", () => {
assert.deepEqual(
productModel.roots.map((root) => root.id),
["fleet", "observation", "missions", "data", "system", "polygon"],
);
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
});
test("every laboratory result uses the shared evidence template", async () => {
const source = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8",
);
const css = await readFile(
new URL("../src/styles/workspaces.css", import.meta.url),
"utf8",
);
const app = await readFile(
new URL("../src/App.tsx", import.meta.url),
"utf8",
);
assert.match(source, /function LaboratoryWorkTemplate\(/);
assert.match(source, /function LaboratoryEvidence\(/);
assert.match(source, /data-viewer-focused=/);
assert.match(css, /height:\s*clamp\(42rem,\s*68vh,\s*58rem\)/);
assert.match(css, /resize:\s*vertical/);
assert.match(app, /activeDefinition\?\.kind === "lab-archive"/);
});