refactor(control-station): separate lidar diagnostics from datasets

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 13:06:49 +03:00
parent f57d64bf52
commit c01712c30d
14 changed files with 939 additions and 341 deletions
@@ -1,133 +0,0 @@
import { useEffect, useState } from "react";
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
import {
fetchDatasetGatewayCatalog,
type DatasetGatewayCatalog,
} from "../core/lidar/datasetGateway";
const representationLabels: Record<string, string> = {
"native-scan": "Один оборот / скан",
"normalized-scan": "Deskew + bounded cleanup",
"rolling-local-map": "Pose + TTL + voxel map",
};
export function DatasetGatewayPanel() {
const [catalog, setCatalog] = useState<DatasetGatewayCatalog | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
void fetchDatasetGatewayCatalog({ signal: controller.signal })
.then((value) => {
if (!controller.signal.aborted) setCatalog(value);
})
.catch((loadError: unknown) => {
if (controller.signal.aborted) return;
setError(
loadError instanceof Error
? loadError.message
: "Dataset Gateway недоступен",
);
});
return () => controller.abort();
}, []);
return (
<GlassSurface className="dataset-gateway" padding="lg">
<header className="panel-heading dataset-gateway__heading">
<div>
<span className="section-eyebrow">DATASET GATEWAY · S0</span>
<h2>Три разных LiDAR-продукта</h2>
<p>
Кольцевой одиночный скан, очищенный sensor-frame и накопленная карта
больше не считаются одним облаком.
</p>
</div>
<StatusBadge
tone={error ? "danger" : catalog?.storage.admitted ? "success" : "warning"}
>
{error
? "Gateway недоступен"
: catalog?.storage.admitted
? "D: допущен"
: "Ожидает D:"}
</StatusBadge>
</header>
{catalog ? (
<>
<div className="dataset-gateway__representations">
{catalog.representations.map((representation, index) => (
<div key={representation.id}>
<span>0{index + 1}</span>
<strong>
{representationLabels[representation.id] ?? representation.title}
</strong>
<small>{representation.title}</small>
<em>
{representation.accumulation
? "накопление включено явно"
: "без накопления"}
</em>
</div>
))}
</div>
<div className="dataset-gateway__grid">
<section>
<span className="section-eyebrow">ПЕРВЫЙ BASELINE</span>
<h3>{catalog.source.displayName}</h3>
<p>
Один оборот VLS-128 в SemanticKITTI XYZI + point-wise semantic и
instance labels. Это то самое разреженное кольцевое облако,
которое корректно сравнивать с алгоритмами.
</p>
<div className="dataset-gateway__facts">
<span>{catalog.source.platforms.join(" · ")}</span>
<span>{catalog.source.superclasses.length} superclasses</span>
<span>val {catalog.source.validationArchiveGb} ГБ</span>
<span>{catalog.source.license}</span>
</div>
</section>
<section data-warning="true">
<span className="section-eyebrow">ТЕКУЩИЙ DEVICE INPUT</span>
<h3>Mapped feed raw scan</h3>
<p>
Внешний MQTT содержит vendor-mapped increment после LIO. В нём
нет per-point time и line/ring, поэтому из него нельзя честно
восстановить один исходный скан или выполнить deskew.
</p>
<div className="dataset-gateway__facts">
<span>Patchwork++: diagnostic only</span>
<span>rolling map: возможно</span>
<span>raw reconstruction: невозможно</span>
</div>
</section>
</div>
<footer className="dataset-gateway__footer">
<div>
<span>Storage gate</span>
<strong>{catalog.storage.requiredWindowsRoot}</strong>
<small>{catalog.storage.requiredWslRoot}</small>
</div>
<div>
<span>Следующий исполнимый шаг</span>
<strong>
{catalog.storage.admitted
? "Скачать GOOSE validation и импортировать первый кадр"
: "Подключить Dataset Root на D: worker"}
</strong>
<small>Автозагрузка 3.3 ГБ намеренно запрещена</small>
</div>
</footer>
</>
) : (
<p className="dataset-gateway__pending">
{error ?? "Читаем входные контракты Dataset Gateway…"}
</p>
)}
</GlassSurface>
);
}
@@ -0,0 +1,199 @@
import { useEffect, useState } from "react";
import {
Button,
GlassSurface,
StatusBadge,
} from "@nodedc/ui-react";
import {
fetchDatasetGatewayCatalog,
type DatasetGatewayCatalog,
} from "../core/lidar/datasetGateway";
const representationLabels: Record<string, string> = {
"native-scan": "Исходный скан",
"normalized-scan": "Нормализованный скан",
"rolling-local-map": "Локальная карта",
};
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Каталог датасетов недоступен.";
}
export function DatasetGatewayWorkspace() {
const [catalog, setCatalog] = useState<DatasetGatewayCatalog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadGeneration, setReloadGeneration] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
void fetchDatasetGatewayCatalog({ signal: controller.signal })
.then((value) => {
if (!controller.signal.aborted) setCatalog(value);
})
.catch((loadError: unknown) => {
if (controller.signal.aborted) return;
setCatalog(null);
setError(errorMessage(loadError));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [reloadGeneration]);
return (
<div className="standard-workspace dataset-workspace">
<section className="dataset-purpose" aria-label="Назначение датасетов">
<div>
<span className="section-eyebrow">ЗАЧЕМ ЭТО НУЖНО</span>
<h2>Независимая проверка алгоритмов</h2>
<p>
Датасет это вход квалификационного прогона. Мы подаём известные
размеченные данные в выбранный pipeline, измеряем ошибки и сохраняем
воспроизводимое решение. Он не подмешивается в диагностику
реального сенсора и не
заменяет closed-loop симуляцию.
</p>
</div>
<ol aria-label="Последовательность квалификации">
<li><span>01</span>Выбрать данные</li>
<li><span>02</span>Запустить pipeline</li>
<li><span>03</span>Сравнить с разметкой</li>
<li><span>04</span>Принять решение</li>
</ol>
</section>
{loading && !catalog ? (
<GlassSurface className="dataset-state" padding="lg">
<StatusBadge tone="accent">Проверяем каталог</StatusBadge>
<h2>Читаем допущенные источники</h2>
<p>Ни один большой архив не загружается автоматически.</p>
</GlassSurface>
) : error ? (
<GlassSurface className="dataset-state" padding="lg">
<StatusBadge tone="danger">Каталог недоступен</StatusBadge>
<h2>Датасеты не открыты</h2>
<p>{error}</p>
<Button
size="compact"
variant="secondary"
onClick={() => setReloadGeneration((value) => value + 1)}
>
Повторить
</Button>
</GlassSurface>
) : catalog ? (
<>
<GlassSurface className="dataset-library" padding="lg">
<header className="dataset-library__heading">
<div>
<span className="section-eyebrow">КАТАЛОГ</span>
<h2>Доступные источники</h2>
</div>
<StatusBadge tone={catalog.storage.admitted ? "success" : "warning"}>
{catalog.storage.admitted ? "Worker готов" : "Worker не настроен"}
</StatusBadge>
</header>
<article className="dataset-entry">
<div className="dataset-entry__identity">
<span className="dataset-entry__mark">G</span>
<div>
<span>PUBLIC · OFF-ROAD · LABELED</span>
<h3>{catalog.source.displayName}</h3>
<p>
Один оборот VLS-128 с point-wise semantic и instance labels.
Первый baseline для ground segmentation и последующих
LiDAR-native моделей.
</p>
</div>
</div>
<dl>
<div>
<dt>Состояние</dt>
<dd>Не загружен</dd>
</div>
<div>
<dt>Validation</dt>
<dd>{catalog.source.validationArchiveGb} ГБ</dd>
</div>
<div>
<dt>Разметка</dt>
<dd>{catalog.source.superclasses.length} классов</dd>
</div>
<div>
<dt>Лицензия</dt>
<dd>{catalog.source.license}</dd>
</div>
</dl>
<footer>
<p>
{catalog.storage.admitted
? "Следующий шаг: загрузить validation archive на worker и проверить hash/license."
: "Сначала нужно допустить Dataset Root на диске D worker. Сейчас открывать нечего."}
</p>
<div>
<Button
size="compact"
variant="secondary"
disabled
title="Первый реальный кадр ещё не импортирован"
>
Открыть
</Button>
<Button
size="compact"
disabled
title="Прогон станет доступен после импорта датасета"
>
Создать прогон
</Button>
</div>
</footer>
</article>
</GlassSurface>
<details className="dataset-contract">
<summary>
<span>Технический контракт</span>
<small>Представления, storage gate и граница реального сенсора</small>
</summary>
<div className="dataset-contract__content">
<section>
<h3>Представления</h3>
<ul>
{catalog.representations.map((representation) => (
<li key={representation.id}>
<strong>
{representationLabels[representation.id] ?? representation.title}
</strong>
<span>{representation.title}</span>
</li>
))}
</ul>
</section>
<section>
<h3>Storage gate</h3>
<p>{catalog.storage.requiredWindowsRoot}</p>
<p>{catalog.storage.requiredWslRoot}</p>
</section>
<section>
<h3>Граница реального сенсора</h3>
<p>
Текущий MQTT-вход vendor-mapped increment, а не native scan.
Отсутствующие per-point time и ring/line не восстанавливаются.
</p>
</section>
</div>
</details>
</>
) : null}
</div>
);
}
@@ -26,6 +26,16 @@ interface LidarGroundPointCloudProps {
mode: LidarGroundViewMode;
}
function percentile(values: number[], fraction: number): number {
if (!values.length) return 0;
const sorted = [...values].sort((left, right) => left - right);
const index = Math.min(
sorted.length - 1,
Math.max(0, Math.floor((sorted.length - 1) * fraction)),
);
return sorted[index];
}
function setRgb(
target: Float32Array,
offset: number,
@@ -101,6 +111,7 @@ export function LidarGroundPointCloud({
const controlsRef = useRef<OrbitControls | null>(null);
const fogRef = useRef<THREE.FogExp2 | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
const viewRadiusRef = useRef(1);
const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => {
@@ -123,7 +134,7 @@ export function LidarGroundPointCloud({
renderer.setClearColor(0x071018, 0.96);
renderer.domElement.setAttribute(
"aria-label",
"Интерактивное облако ground segmentation",
"Интерактивное облако LiDAR",
);
host.prepend(renderer.domElement);
@@ -161,7 +172,7 @@ export function LidarGroundPointCloud({
materialRef.current = material;
scene.add(new THREE.Points(geometry, material));
const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747);
const grid = new THREE.GridHelper(10, 40, 0x71963d, 0x26312a);
gridRef.current = grid;
const gridMaterials = Array.isArray(grid.material)
? grid.material
@@ -216,6 +227,7 @@ export function LidarGroundPointCloud({
controlsRef.current = null;
fogRef.current = null;
gridRef.current = null;
viewRadiusRef.current = 1;
};
}, []);
@@ -229,20 +241,22 @@ export function LidarGroundPointCloud({
if (!geometry || !material || !camera || !controls || !fog || !grid) return;
const positions = new Float32Array(frame.pointCount * 3);
let minimumX = Number.POSITIVE_INFINITY;
let maximumX = Number.NEGATIVE_INFINITY;
let minimumY = Number.POSITIVE_INFINITY;
let maximumY = Number.NEGATIVE_INFINITY;
let minimumZ = Number.POSITIVE_INFINITY;
let maximumZ = Number.NEGATIVE_INFINITY;
frame.pointsXyzM.forEach(([x, y, z]) => {
minimumX = Math.min(minimumX, x);
maximumX = Math.max(maximumX, x);
minimumY = Math.min(minimumY, y);
maximumY = Math.max(maximumY, y);
minimumZ = Math.min(minimumZ, z);
maximumZ = Math.max(maximumZ, z);
const sampleStep = Math.max(1, Math.floor(frame.pointCount / 5_000));
const sampleX: number[] = [];
const sampleY: number[] = [];
const sampleZ: number[] = [];
frame.pointsXyzM.forEach(([x, y, z], index) => {
if (index % sampleStep !== 0) return;
sampleX.push(x);
sampleY.push(y);
sampleZ.push(z);
});
const minimumX = percentile(sampleX, 0.01);
const maximumX = percentile(sampleX, 0.99);
const minimumY = percentile(sampleY, 0.01);
const maximumY = percentile(sampleY, 0.99);
const minimumZ = percentile(sampleZ, 0.01);
const maximumZ = percentile(sampleZ, 0.99);
const centerX = (minimumX + maximumX) / 2;
const centerY = (minimumY + maximumY) / 2;
frame.pointsXyzM.forEach(([x, y, z], index) => {
@@ -253,13 +267,21 @@ export function LidarGroundPointCloud({
});
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.computeBoundingSphere();
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
const radius = Math.max(
Math.hypot(
(maximumX - minimumX) / 2,
(maximumY - minimumY) / 2,
maximumZ - minimumZ,
),
0.2,
);
viewRadiusRef.current = radius;
material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075);
fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035);
grid.scale.setScalar(Math.max(radius / 12, 1));
grid.scale.setScalar(Math.max(radius / 5, 0.25));
const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15);
const distance = Math.max(radius * 1.8, 1.2);
const targetHeight = Math.max((maximumZ - minimumZ) * 0.42, 0.15);
const distance = Math.max(radius * 1.15, 0.9);
controls.target.set(0, targetHeight, 0);
camera.position.set(distance, distance * 0.72, distance);
camera.near = Math.max(distance / 1_000, 0.005);
@@ -283,10 +305,10 @@ export function LidarGroundPointCloud({
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!geometry || !camera || !controls) return;
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
const distance = Math.max(radius * 1.8, 1.2);
const radius = Math.max(viewRadiusRef.current, 0.2);
const distance = Math.max(radius * 1.15, 0.9);
camera.position.set(distance, distance * 0.72, distance);
controls.target.set(0, radius * 0.18, 0);
controls.target.set(0, radius * 0.25, 0);
controls.update();
};
@@ -21,12 +21,10 @@ import {
type LidarStageReadiness,
} from "../core/lidar/replayQuality";
import { MetricCard } from "../components/MetricCard";
import type { WorkspaceDefinition } from "../productModel";
import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
import { DatasetGatewayPanel } from "./DatasetGatewayPanel";
function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
@@ -60,9 +58,11 @@ function errorMessage(error: unknown): string {
}
export function LidarQualityWorkspace({
definition,
deviceLabel,
onOpenObservation,
}: {
definition: WorkspaceDefinition;
deviceLabel: string | null;
onOpenObservation: () => void;
}) {
const [catalog, setCatalog] = useState<LidarReplayCatalog | null>(null);
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
@@ -73,7 +73,7 @@ export function LidarQualityWorkspace({
const [groundFrameLoading, setGroundFrameLoading] = useState(false);
const [groundFrameError, setGroundFrameError] = useState<string | null>(null);
const [groundViewMode, setGroundViewMode] =
useState<LidarGroundViewMode>("disagreement");
useState<LidarGroundViewMode>("intensity");
const [fieldReview, setFieldReview] = useState<LidarFieldReview | null>(null);
const [fieldWindow, setFieldWindow] =
useState<LidarFieldReviewWindow | null>(null);
@@ -81,7 +81,7 @@ export function LidarQualityWorkspace({
const [fieldLoading, setFieldLoading] = useState(true);
const [fieldError, setFieldError] = useState<string | null>(null);
const [fieldViewMode, setFieldViewMode] =
useState<LidarGroundViewMode>("disagreement");
useState<LidarGroundViewMode>("intensity");
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -116,8 +116,11 @@ export function LidarQualityWorkspace({
if (controller.signal.aborted) return;
setSelectedPackId(target);
setDetail(nextDetail);
setGroundBenchmark(groundCatalog.items[0] ?? null);
setGroundFrameIndex(0);
const nextGroundBenchmark = groundCatalog.items[0] ?? null;
setGroundBenchmark(nextGroundBenchmark);
setGroundFrameIndex(
nextGroundBenchmark ? Math.floor(nextGroundBenchmark.frames / 2) : 0,
);
} catch (loadError) {
if (controller.signal.aborted) return;
setDetail(null);
@@ -212,17 +215,6 @@ export function LidarQualityWorkspace({
return (
<div className="standard-workspace lidar-quality-workspace">
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>{definition.description}</p>
</div>
<span className="workspace-lead__note">Только проверенные replay-артефакты</span>
</section>
<DatasetGatewayPanel />
{loading && !detail ? (
<GlassSurface className="lidar-quality-message" padding="lg">
<StatusBadge tone="accent">Проверка evidence</StatusBadge>
@@ -255,22 +247,49 @@ 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>
<GlassSurface className="lidar-field-review" padding="lg">
<header className="panel-heading lidar-field-review__heading">
<div>
<span className="section-eyebrow">ПОЛЕВОЙ REVIEW · RAVNOVES00</span>
<span className="section-eyebrow">
{fieldReview ? "ОБЛАКО LIDAR · RAVNOVES00" : "ОБЛАКО LIDAR · ЗАПИСЬ"}
</span>
<h2>
{fieldReview?.displayName
?? "Центральный городской интервал"}
{fieldReview
? `Городская сцена: ${selectedFieldWindow?.label ?? fieldReview.displayName}`
: `Кадр ${groundFrameIndex + 1} из ${groundBenchmark?.frames ?? 0}`}
</h2>
<p>
Дорога, дома, автомобили и растительность. Облако накоплено
по исходным LiDAR-сэмплам выбранного окна, а кадр камеры
фиксирует контекст сцены.
{fieldReview
? "Выберите один из пяти интервалов, вращайте облако мышью и при необходимости включите экспериментальные маски обработки."
: "Полевая запись на этом хосте не подключена. Показываем доступную запись выбранного LiDAR без подмешивания публичных датасетов."}
</p>
</div>
<StatusBadge tone={fieldError ? "danger" : "warning"}>
{fieldError ? "Review недоступен" : "Visual review only"}
{fieldError ? "Облако недоступно" : "Только собственные данные"}
</StatusBadge>
</header>
@@ -389,6 +408,7 @@ export function LidarQualityWorkspace({
aria-label="Режим окраски полевого LiDAR"
>
{([
["intensity", "Облако"],
["current", "Current"],
["candidate", "Patchwork++"],
["disagreement", "Расхождения"],
@@ -441,6 +461,87 @@ export function LidarQualityWorkspace({
</div>
</div>
</>
) : groundBenchmark ? (
<section
className="lidar-fallback-review"
aria-label="Доступная запись LiDAR"
>
<header>
<div
className="lidar-ground-modes"
role="group"
aria-label="Режим окраски LiDAR"
>
{([
["intensity", "Облако"],
["current", "Current"],
["candidate", "Patchwork++"],
["disagreement", "Расхождения"],
] as const).map(([mode, label]) => (
<button
type="button"
key={mode}
data-active={groundViewMode === mode ? "true" : undefined}
onClick={() => setGroundViewMode(mode)}
>
{label}
</button>
))}
</div>
<div className="lidar-ground-frame-control">
<button
type="button"
aria-label="Предыдущий LiDAR кадр"
disabled={groundFrameIndex === 0}
onClick={() =>
setGroundFrameIndex((value) => Math.max(0, value - 1))
}
>
</button>
<input
type="range"
aria-label="Номер LiDAR кадра"
min={0}
max={Math.max(groundBenchmark.frames - 1, 0)}
step={1}
value={groundFrameIndex}
onChange={(event) =>
setGroundFrameIndex(Number(event.currentTarget.value))
}
/>
<button
type="button"
aria-label="Следующий LiDAR кадр"
disabled={groundFrameIndex >= groundBenchmark.frames - 1}
onClick={() =>
setGroundFrameIndex((value) =>
Math.min(groundBenchmark.frames - 1, value + 1)
)
}
>
+
</button>
</div>
</header>
{groundFrame ? (
<LidarGroundPointCloud
frame={groundFrame}
mode={groundViewMode}
/>
) : (
<div className="lidar-ground-scene-placeholder">
<StatusBadge tone={groundFrameError ? "danger" : "accent"}>
{groundFrameError ? "Кадр недоступен" : "Загрузка"}
</StatusBadge>
<p>{groundFrameError ?? "Читаем облако LiDAR…"}</p>
</div>
)}
<footer>
Это техническая запись из 66 кадров. Она позволяет проверить
геометрию и управление viewer, но не заменяет полевую сцену.
</footer>
</section>
) : (
<div className="lidar-field-review__empty">
<StatusBadge tone={fieldError ? "danger" : "accent"}>
@@ -481,6 +582,14 @@ export function LidarQualityWorkspace({
/>
</section>
<details className="lidar-technical-details">
<summary>
<span>Технические детали и эксперименты</span>
<small>
Целостность записи, допуски стадий, Patchwork++ и contract slice
</small>
</summary>
<div className="lidar-technical-details__content">
<div className="lidar-quality-grid">
<GlassSurface className="lidar-quality-panel" padding="lg">
<header className="panel-heading">
@@ -813,6 +922,8 @@ export function LidarQualityWorkspace({
))}
</footer>
</GlassSurface>
</div>
</details>
</>
)}
</div>
@@ -58,6 +58,7 @@ import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "..
import type { SceneSettings } from "../sceneSettings";
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
import { LidarQualityWorkspace } from "./LidarQualityWorkspace";
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
@@ -140,6 +141,7 @@ export interface WorkspaceRendererProps {
}) => void;
observationLayout: ObservationLayoutController;
polygonRunRoute: PolygonRunRoute;
deviceLabel: string | null;
navigation: WorkspaceNavigation;
spatialControls: {
View: ComponentType<DevicePluginConnectionProps>;
@@ -1308,7 +1310,14 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
case "catalog":
return <CatalogWorkspace {...props} />;
case "lidar-quality":
return <LidarQualityWorkspace definition={props.definition} />;
return (
<LidarQualityWorkspace
deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")}
/>
);
case "polygon-datasets":
return <DatasetGatewayWorkspace />;
case "polygon-run":
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
case "device":