chore: rename repository to NODEDC MISSION CORE

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 12:10:05 +03:00
parent 8459bb03fb
commit 9225227421
50 changed files with 220 additions and 61 deletions
@@ -0,0 +1,556 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Button,
Checker,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import type { BleDevice } from "../api";
import { MetricCard } from "../components/MetricCard";
import { localizeRuntimeMessage } from "../messages";
import {
backendLabel,
backendTone,
eventStatusLabel,
finiteMetric,
formatNumber,
phaseLabel,
phaseTone,
pipelineLatency,
sourceModeLabel,
} from "../presentation";
import type { useK1Console } from "../useK1Console";
type ConsoleController = ReturnType<typeof useK1Console>;
type SessionIntent = "live" | "replay";
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function WizardStep({
number,
title,
status,
tone = "neutral",
children,
}: {
number: string;
title: string;
status: string;
tone?: StatusTone;
children: ReactNode;
}) {
return (
<section className="wizard-step">
<div className="wizard-step__rail" aria-hidden="true">
<span>{number}</span>
</div>
<div className="wizard-step__content">
<header>
<h3>{title}</h3>
<StatusBadge tone={tone}>{status}</StatusBadge>
</header>
{children}
</div>
</section>
);
}
function DeviceRow({
device,
selected,
onSelect,
}: {
device: BleDevice;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
className="device-row"
data-compatible={device.likely_k1 ? "true" : undefined}
data-selected={selected ? "true" : undefined}
>
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
</span>
<code>{device.device_id}</code>
</div>
</div>
<div className="device-row__action">
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
<Button
size="compact"
variant={selected ? "primary" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Выбрано" : "Выбрать"}
</Button>
</div>
</div>
);
}
function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>Измерений пока нет. График появится после получения реальных данных.</p>
)}
</div>
);
}
export function DeviceWorkspace({
console,
onOpenSpatialScene,
}: {
console: ConsoleController;
onOpenSpatialScene: () => void;
}) {
const {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh,
clearError,
scan,
connect,
startLive,
startReplay,
stop,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
useEffect(() => {
if (state?.selected_device_id) {
setSelectedDeviceId(state.selected_device_id);
return;
}
if (
selectedDeviceId &&
state?.devices &&
!state.devices.some((device) => device.device_id === selectedDeviceId)
) {
setSelectedDeviceId("");
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
const sourceLabel = sourceModeLabel(state?.source_mode);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
});
if (succeeded) setPassword("");
};
const submitLive = async () => {
const host = liveHost.trim();
const started = await startLive(host ? { host } : {});
if (started) onOpenSpatialScene();
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) onOpenSpatialScene();
};
return (
<div className="device-workspace">
{error ? (
<aside className="error-banner" role="alert">
<span className="error-banner__dot" aria-hidden="true" />
<div>
<strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p>
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Повторить
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
</Button>
</div>
</aside>
) : null}
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
<h2>Подключение первого устройства</h2>
<p>
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
операторская модель от конкретного устройства не зависят.
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
<h2>Подключите устройство к сети</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
</header>
<div className="wizard-list">
<WizardStep
number="01"
title="Включите устройство"
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
tone={powerConfirmed ? "success" : "warning"}
>
<div className="nodedc-field">
<span className="nodedc-field__description">
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
</span>
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={setPowerConfirmed}
/>
</div>
</WizardStep>
<WizardStep
number="02"
title="Выберите Bluetooth-устройство"
status={
pendingAction === "scan"
? "Поиск…"
: selectedDeviceId
? "Устройство выбрано"
: `Найдено: ${devices.length}`
}
tone={
pendingAction === "scan"
? "accent"
: selectedDeviceId
? "success"
: "neutral"
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
профиль — только подсказка; окончательный выбор всегда делает оператор.
</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
void scan();
}}
>
{pendingAction === "scan"
? "Сканируем Bluetooth — 6 секунд…"
: "Показать все BLE-устройства"}
</Button>
<div className="device-list">
{devices.length ? (
devices.map((device) => (
<DeviceRow
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
/>
))
) : (
<div className="empty-device-list">
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
повторите поиск.
</div>
)}
</div>
</WizardStep>
<WizardStep
number="03"
title="Передайте настройки Wi‑Fi"
status={state?.k1_ip ? "Подключено" : "Не подключено"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Название сети Wi‑Fi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль Wi‑Fi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
placeholder="Введите пароль"
/>
</div>
<div className="connection-summary">
<span>Устройство</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
</div>
<Button
width="full"
variant="primary"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
</Button>
<p className="safety-note">
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
удаляется из формы после успешного подключения.
</p>
</WizardStep>
</div>
</GlassSurface>
<div className="device-workspace__side">
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
/>
{sessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
<TextField
label="Путь к записи"
hint="Локальный файл исходных данных"
value={replayPath}
onChange={(event) => setReplayPath(event.target.value)}
spellCheck={false}
placeholder="sessions/.../capture.tsv"
/>
<TextField
label="Скорость повтора"
hint="Множитель"
type="number"
min="0.1"
step="0.1"
value={replaySpeed}
onChange={(event) => setReplaySpeed(event.target.value)}
/>
<div className="nodedc-field">
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
<Checker
checked={replayLoop}
label="Повторять по кругу"
onChange={setReplayLoop}
/>
</div>
<Button
variant="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button>
</div>
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
</Button>
</div>
</GlassSurface>
<div className="diagnostics-grid">
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
<h2>Локальный контур</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Канал событий">
<span className="inline-state" data-state={eventStatus}>
{eventStatusLabel(eventStatus)}
</span>
</DetailRow>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес устройства">
<code>{state?.k1_ip || "Не получен"}</code>
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Время до публикации</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>мс</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Старые</span>
<span>Последние</span>
</div>
</GlassSurface>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,574 @@
import { useCallback, useMemo, useState } from "react";
import {
Button,
GlassSurface,
Icon,
StatusBadge,
} from "@nodedc/ui-react";
import type { ConsoleState } from "../api";
import { MetricCard } from "../components/MetricCard";
import { localizeRuntimeMessage } from "../messages";
import {
RerunViewport,
type RerunSelection,
type RerunViewportStatus,
} from "../components/RerunViewport";
import {
capabilityStatusLabel,
type CapabilityStatus,
type WorkspaceDefinition,
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings";
import type { BackendStatus } from "../useK1Console";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
if (status === "ready") return "accent";
if (status === "contract") return "warning";
return "neutral";
}
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
return (
<div className="feature-inventory">
{definition.groups.map((group) => (
<section className="feature-group" key={group.title}>
<header>
<span className="section-eyebrow">ФУНКЦИОНАЛЬНЫЙ БЛОК</span>
<h3>{group.title}</h3>
<p>{group.description}</p>
</header>
<div className="feature-list">
{group.capabilities.map((capability) => (
<div className="feature-row" key={capability.label} data-status={capability.status}>
<span className="feature-row__mark" aria-hidden="true" />
<div>
<strong>{capability.label}</strong>
<p>{capability.description}</p>
</div>
<StatusBadge tone={statusTone(capability.status)}>
{capabilityStatusLabel[capability.status]}
</StatusBadge>
</div>
))}
</div>
</section>
))}
</div>
);
}
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
return (
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>{definition.description}</p>
</div>
{note ? <span className="workspace-lead__note">{note}</span> : null}
</section>
);
}
export interface WorkspaceNavigation {
openView: (viewId: string) => void;
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
}
export interface WorkspaceRendererProps {
definition: WorkspaceDefinition;
state: ConsoleState | null;
backendStatus: BackendStatus;
sourceUrl: string;
sceneSettings: SceneSettings;
navigation: WorkspaceNavigation;
}
function OverviewWorkspace({
definition,
state,
backendStatus,
navigation,
}: WorkspaceRendererProps) {
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const adapterOnline = backendStatus === "online";
const rerunReady = Boolean(state?.rerun_grpc_url);
return (
<div className="standard-workspace overview-workspace">
<WorkspaceLead
definition={definition}
note="Числа появляются только из реального локального контура"
/>
<section className="metrics-grid" aria-label="Оперативные показатели">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение потока"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Без синтетического заполнения"
/>
<MetricCard
eyebrow="РЕЖИМ"
value={sourceModeLabel(state?.source_mode)}
detail="Реальное время, повтор или ожидание"
/>
</section>
<div className="overview-grid">
<GlassSurface className="contour-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">КРИТИЧЕСКИЙ ПУТЬ</span>
<h2>Контур наблюдения</h2>
</div>
<StatusBadge tone={adapterOnline ? "success" : "danger"}>
{adapterOnline ? "Контур доступен" : "Нет связи"}
</StatusBadge>
</header>
<div className="pipeline-strip" aria-label="Путь данных">
<div data-state={state?.k1_ip ? "ready" : "idle"}>
<span>01</span>
<strong>Устройство</strong>
<small>{state?.k1_ip || "не назначено"}</small>
</div>
<Icon name="chevron-right" />
<div data-state={streamActive ? "ready" : "idle"}>
<span>02</span>
<strong>Адаптер</strong>
<small>{sourceModeLabel(state?.source_mode)}</small>
</div>
<Icon name="chevron-right" />
<div data-state={rerunReady ? "ready" : "idle"}>
<span>03</span>
<strong>Rerun</strong>
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
</div>
<Icon name="chevron-right" />
<div data-state="ready">
<span>04</span>
<strong>Оператор</strong>
<small>интерфейс готов</small>
</div>
</div>
</GlassSurface>
<GlassSurface className="next-actions-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">БЫСТРЫЙ ПЕРЕХОД</span>
<h2>Продолжить работу</h2>
</div>
</header>
<div className="action-list">
<button type="button" onClick={() => navigation.openView("local-device")}>
<span><Icon name="network" /></span>
<div>
<strong>Подключить устройство</strong>
<small>Bluetooth, Wi‑Fi и запуск потока</small>
</div>
<Icon name="chevron-right" />
</button>
<button type="button" onClick={() => navigation.openView("spatial-scene")}>
<span><Icon name="globe" /></span>
<div>
<strong>Открыть пространственную сцену</strong>
<small>Облако точек, траектория и слои</small>
</div>
<Icon name="chevron-right" />
</button>
<button type="button" onClick={() => navigation.openView("streams")}>
<span><Icon name="activity" /></span>
<div>
<strong>Проверить потоки</strong>
<small>Транспорт, схемы и готовность</small>
</div>
<Icon name="chevron-right" />
</button>
</div>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
return (
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
<div className="empty-spatial-stage__grid" aria-hidden="true" />
<div className="spatial-axis" aria-hidden="true">
<span data-axis="x">X</span>
<span data-axis="z">Z</span>
</div>
<div className="empty-spatial-stage__message">
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
<strong>Визуальный источник не назначен</strong>
<p>
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
открыть реальные данные.
</p>
</div>
</div>
);
}
function SpatialWorkspace({
state,
sourceUrl,
sceneSettings,
navigation,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
setViewerMessage(message ?? "");
}, []);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const viewerStatusLabel = {
idle: "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
}[viewerStatus];
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
return (
<div className="spatial-workspace">
<div className="spatial-toolbar">
<div className="spatial-toolbar__mode">
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
</div>
<div className="spatial-toolbar__actions">
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
Источник
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
Слои
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
Отображение
</Button>
</div>
</div>
<div className="spatial-viewport-shell">
{sourceUrl.trim() ? (
<RerunViewport
sourceUrl={sourceUrl}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
<div className="scene-status scene-status--top-left">
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
{viewerMessage ? <small>{viewerMessage}</small> : null}
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
<div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
<div>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
</div>
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
</div>
) : null}
{selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : null}
<div className="scene-timeline">
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
<Icon name="chevron-left" />
</Button>
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
Воспроизвести
</Button>
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
</div>
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
ЭФИР
</span>
</div>
</div>
<div className="spatial-contract-strip">
<span><i data-state="ready" />Облако точек</span>
<span><i data-state="ready" />Траектория</span>
<span><i data-state="ready" />Преобразования</span>
<span><i data-state="contract" />Камеры в 3D</span>
<span><i data-state="contract" />Объекты / маски</span>
<span><i data-state="contract" />Компоновка</span>
</div>
</div>
);
}
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
return (
<div className="standard-workspace cameras-workspace">
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
<div className="camera-grid">
{slots.map((slot, index) => (
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
<div className="camera-slot__grid" aria-hidden="true" />
<header>
<span>{slot}</span>
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
</header>
<div className="camera-slot__empty">
<Icon name={index === 3 ? "image" : "video"} />
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
</div>
<footer>
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
</footer>
</div>
))}
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function MapWorkspace({ definition }: WorkspaceRendererProps) {
const isMissionRoute = definition.id === "routes";
return (
<div className="standard-workspace map-workspace">
<WorkspaceLead
definition={definition}
note={isMissionRoute ? "Схема интерфейса — координаты и командный канал не подключены" : "Картографический модуль не подключён"}
/>
<div className="map-layout">
<div className="map-canvas" data-route={isMissionRoute ? "true" : undefined}>
<div className="map-canvas__grid" aria-hidden="true" />
<div className="map-canvas__contours" aria-hidden="true">
<span /><span /><span /><span />
</div>
<svg viewBox="0 0 1000 560" role="img" aria-label="Схематический черновик маршрута без географических координат">
<path d="M165 420 C 235 350, 272 375, 340 292 S 480 235, 545 290 S 680 340, 765 210 S 845 125, 892 155" />
<circle cx="165" cy="420" r="8" />
<circle cx="340" cy="292" r="8" />
<circle cx="545" cy="290" r="8" />
<circle cx="765" cy="210" r="8" />
<circle cx="892" cy="155" r="8" />
</svg>
<div className="map-point-label" style={{ left: "15%", top: "72%" }}>A · старт</div>
<div className="map-point-label" style={{ left: "52%", top: "49%" }}>C · наблюдение</div>
<div className="map-point-label" style={{ left: "84%", top: "22%" }}>E · финиш</div>
<div className="map-canvas__notice">
<StatusBadge tone="warning">Схема интерфейса</StatusBadge>
<span>Не является картой местности и не содержит реальных координат.</span>
</div>
</div>
<GlassSurface className="map-inspector" padding="lg">
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
<h3>Рабочая композиция</h3>
<div className="layer-summary-list">
<div><span data-tone="cyan" /><strong>Маршрут</strong><small>5 точек · схема</small></div>
<div><span data-tone="violet" /><strong>Геозоны</strong><small>не назначены</small></div>
<div><span data-tone="green" /><strong>Высота</strong><small>контракт GridMap</small></div>
<div><span data-tone="orange" /><strong>Проходимость</strong><small>обработчик не подключён</small></div>
</div>
<Button variant="secondary" width="full" disabled>Редактировать точки</Button>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
const rows = useMemo(() => [
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", active: Boolean(state?.phase) },
{ label: "Поток точек", value: state?.source_mode && state.source_mode !== "idle" ? "активен" : "ожидание", active: state?.source_mode !== "idle" && Boolean(state?.source_mode) },
{ label: "Поза", value: state?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) },
{ label: "Камеры", value: "каналы не назначены", active: false },
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
], [state]);
return (
<div className="standard-workspace timeline-workspace">
<WorkspaceLead definition={definition} note="Отображаются только доступные состояния" />
<GlassSurface className="timeline-panel" padding="lg">
<header className="timeline-panel__head">
<div>
<span className="section-eyebrow">СИНХРОННЫЕ КАНАЛЫ</span>
<h3>Текущий интервал</h3>
</div>
<div className="timeline-panel__controls">
<Button size="compact" variant="secondary" disabled>Воспроизвести</Button>
<code>—:—:—.———</code>
</div>
</header>
<div className="timeline-ruler" aria-hidden="true">
{Array.from({ length: 11 }, (_, index) => <span key={index}>{index * 10}</span>)}
</div>
<div className="timeline-lanes">
{rows.map((row, index) => (
<div className="timeline-lane" key={row.label} data-active={row.active ? "true" : undefined}>
<strong>{row.label}</strong>
<div><span style={{ left: row.active ? `${18 + index * 9}%` : "0" }} /></div>
<small>{row.value}</small>
</div>
))}
</div>
</GlassSurface>
<FeatureInventory definition={definition} />
</div>
);
}
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
const steps = [
{ id: "01", label: "Аппарат", value: "Не назначен" },
{ id: "02", label: "Зона", value: "Не задана" },
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
{ id: "04", label: "Наблюдение", value: "Облако точек" },
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
];
return (
<div className="standard-workspace mission-workspace">
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
<div className="mission-layout">
<GlassSurface className="mission-sequence" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
<h2>Новая миссия</h2>
</div>
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
</header>
<div className="mission-steps">
{steps.map((step) => (
<button key={step.id} type="button" disabled>
<span>{step.id}</span>
<div><strong>{step.label}</strong><small>{step.value}</small></div>
<Icon name="chevron-right" />
</button>
))}
</div>
</GlassSurface>
<GlassSurface className="mission-summary" padding="lg">
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
<div className="mission-summary__checks">
<span><i />Аппарат</span>
<span><i />Геометрия</span>
<span><i />Связь</span>
<span><i />Безопасность</span>
</div>
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
const active = definition.groups.reduce(
(count, group) => count + group.capabilities.filter((capability) => capability.status === "active").length,
0,
);
const ready = definition.groups.reduce(
(count, group) => count + group.capabilities.filter((capability) => capability.status === "ready").length,
0,
);
return (
<div className="standard-workspace catalog-workspace">
<WorkspaceLead definition={definition} note="Архитектурный и интерфейсный контракт" />
<section className="capability-summary" aria-label="Готовность функционала">
<div><span>Всего функций</span><strong>{total}</strong></div>
<div><span>Работает сейчас</span><strong>{active}</strong></div>
<div><span>Готово к данным</span><strong>{ready}</strong></div>
<div><span>Без серверной логики</span><strong>{Math.max(0, total - active - ready)}</strong></div>
</section>
<FeatureInventory definition={definition} />
</div>
);
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
switch (props.definition.kind) {
case "overview":
return <OverviewWorkspace {...props} />;
case "spatial":
return <SpatialWorkspace {...props} />;
case "cameras":
return <CamerasWorkspace {...props} />;
case "map":
return <MapWorkspace {...props} />;
case "timeline":
return <TimelineWorkspace {...props} />;
case "missions":
return <MissionWorkspace {...props} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "device":
return null;
}
}