feat(control-station): add configurable environment shell

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 14:18:08 +03:00
parent 42041b37cd
commit 453d760be4
14 changed files with 1473 additions and 246 deletions
+43 -14
View File
@@ -8,10 +8,8 @@ import {
Checker,
ColorField,
ControlRow,
HeaderAvatar,
HeaderNavigation,
HeaderProfile,
HeaderProfileButton,
HeaderWorkspace,
Icon,
Inspector,
@@ -19,6 +17,7 @@ import {
Select,
StatusBadge,
TextField,
UserProfileMenu,
Window,
WindowFooterActions,
useApplicationWorkspace,
@@ -26,7 +25,9 @@ import {
} from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage";
import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow";
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
@@ -149,6 +150,7 @@ function mergeViewerSettings(
export default function App() {
const runtime = useMissionRuntime();
const environment = useEnvironmentSettings();
const { selection } = useDevicePluginHost();
const polygonDatasetRoute = useMemo(
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
@@ -162,6 +164,7 @@ export default function App() {
const [activeRoot, setActiveRoot] = useState<RootId | null>(
polygonDatasetRoute.active ? "data" : null,
);
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
@@ -423,11 +426,6 @@ export default function App() {
const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId);
if (rootId === "polygon") {
workspace.openView("lab-archive");
workspace.openNavigation();
return;
}
workspace.closeView();
workspace.openNavigation();
};
@@ -626,6 +624,7 @@ export default function App() {
const header = (
<AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
brandHref="/"
brandLabel="NODEDC MISSION CORE"
center={
<>
@@ -633,18 +632,35 @@ export default function App() {
<HeaderNavigation
label="Архитектурные блоки пункта управления"
value={activeRoot ?? undefined}
items={visibleRoots.map((root) => ({ value: root.id, label: root.label }))}
items={visibleRoots.map((root) => ({
value: root.id,
label: environment.settings.headerLabels[root.id],
}))}
onChange={selectRoot}
/>
</>
}
right={
<HeaderProfile>
<HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
<span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
{backendLabel(runtime.backendStatus)}
</HeaderProfileButton>
<HeaderAvatar label="DC" />
<UserProfileMenu
displayName="DC"
subtitle="Mission Core"
triggerLabel={null}
actions={[
{
id: "refresh",
label: "Обновить контур",
icon: "refresh",
onSelect: () => void runtime.refresh(),
},
{
id: "settings",
label: "Настройки",
icon: "settings",
onSelect: () => setEnvironmentSettingsOpen(true),
},
]}
/>
</HeaderProfile>
}
/>
@@ -668,6 +684,9 @@ export default function App() {
backendStatus={runtime.backendStatus}
phase={runtime.state?.phase}
message={runtime.state?.message}
background={
environment.settings.backgrounds[activeRoot ?? "home"]
}
onOpenObservation={() => openView("spatial-scene")}
onOpenDevice={() => openView("local-device")}
/>
@@ -764,7 +783,7 @@ export default function App() {
) : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
<StatusBadge tone="accent">Лаборатория</StatusBadge>
null
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
)
@@ -821,6 +840,16 @@ export default function App() {
) : null}
/>
<EnvironmentSettingsWindow
open={environmentSettingsOpen}
settings={environment.settings}
state={environment.state}
error={environment.error}
onClose={() => setEnvironmentSettingsOpen(false)}
onSave={environment.save}
onUpload={environment.upload}
/>
<Window
open={sceneWorkspaceActive && sourceWindowOpen}
title="Визуальный движок"
@@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
FeatureSettingsWindow,
MediaSourceField,
Select,
SettingsCard,
Switch,
TextField,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
cloneEnvironmentSettings,
type EnvironmentBackground,
type EnvironmentMediaKind,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
import { roots, type RootId } from "../productModel";
interface EnvironmentSettingsWindowProps {
open: boolean;
settings: EnvironmentSettings;
state: "loading" | "ready" | "saving" | "error";
error: string | null;
onClose: () => void;
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
onUpload: (
surfaceId: EnvironmentSurfaceId,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
const headerLabelFields: readonly { id: RootId; label: string }[] = [
{ id: "center", label: "Центр" },
{ id: "fleet", label: "Парк" },
{ id: "observation", label: "Наблюдение" },
{ id: "missions", label: "Миссии" },
{ id: "data", label: "Данные" },
{ id: "system", label: "Система" },
{ id: "polygon", label: "Тестировочный контур" },
];
const backgroundSurfaceOptions: Array<{
value: EnvironmentSurfaceId;
label: string;
description: string;
}> = [
{
value: "home",
label: "Mission Core",
description: "Главная страница продукта",
},
...roots.map((root) => ({
value: root.id,
label: root.title,
description: `Стартовая страница раздела «${root.label}»`,
})),
];
function inferMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
function patchBackground(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentBackground>,
): EnvironmentSettings {
return {
...draft,
backgrounds: {
...draft.backgrounds,
[surfaceId]: {
...draft.backgrounds[surfaceId],
...patch,
},
},
};
}
export function EnvironmentSettingsWindow({
open,
settings,
state,
error,
onClose,
onSave,
onUpload,
}: EnvironmentSettingsWindowProps) {
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("home");
const [uploading, setUploading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setDraft(cloneEnvironmentSettings(settings));
setLocalError(null);
}, [open, settings]);
const selectedBackground = draft.backgrounds[surfaceId];
const previewKind = selectedBackground.mediaKind
?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(settings),
[draft, settings],
);
const busy = state === "saving" || uploading;
const updateHeaderLabel = (rootId: RootId, value: string) => {
setDraft((current) => ({
...current,
headerLabels: { ...current.headerLabels, [rootId]: value },
}));
};
const uploadFile = async (file?: File) => {
if (!file) return;
setUploading(true);
setLocalError(null);
try {
const uploaded = await onUpload(surfaceId, file);
setDraft((current) => patchBackground(current, surfaceId, {
enabled: true,
source: "file",
url: uploaded.url,
mediaKind: uploaded.mediaKind,
fileName: uploaded.fileName,
}));
} catch (reason) {
setLocalError(reason instanceof Error
? reason.message
: "Не удалось загрузить фон окружения.");
} finally {
setUploading(false);
}
};
const save = async () => {
const blank = headerLabelFields.find(({ id }) => !draft.headerLabels[id].trim());
if (blank) {
setLocalError(`Название «${blank.label}» не может быть пустым.`);
return;
}
setLocalError(null);
try {
await onSave({
...draft,
headerLabels: Object.fromEntries(headerLabelFields.map(({ id }) => [
id,
draft.headerLabels[id].trim(),
])) as Record<RootId, string>,
});
onClose();
} catch (reason) {
setLocalError(reason instanceof Error
? reason.message
: "Не удалось сохранить настройки окружения.");
}
};
return (
<FeatureSettingsWindow
open={open}
title="Настройки Mission Core"
subtitle="Локальное операторское окружение"
identity={{
title: "DC",
subtitle: "Mission Core",
avatarLabel: "DC",
}}
sections={[
{
id: "environment",
label: "Окружение",
group: "MISSION CORE",
icon: "settings",
},
]}
activeSection="environment"
onSectionChange={() => undefined}
onClose={onClose}
footer={(
<WindowFooterActions>
<Button
variant="secondary"
disabled={!dirty || busy}
onClick={() => {
setDraft(cloneEnvironmentSettings(settings));
setLocalError(null);
}}
>
Сбросить изменения
</Button>
<Button
variant="primary"
disabled={!dirty || busy}
onClick={() => void save()}
>
{state === "saving" ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
)}
>
<div className="environment-settings">
<SettingsCard
eyebrow="ШАПКА"
title="Названия разделов"
description="Подписи применяются к верхней навигации. Продуктовые идентификаторы и маршруты не меняются."
>
<div className="environment-settings__labels">
{headerLabelFields.map((field) => (
<TextField
key={field.id}
label={field.label}
value={draft.headerLabels[field.id]}
maxLength={40}
onChange={(event) => updateHeaderLabel(field.id, event.currentTarget.value)}
/>
))}
</div>
</SettingsCard>
<SettingsCard
eyebrow="ПОДЛОЖКИ"
title="Фото или видео стартовой страницы"
description="Медиа заполняет выбранную стартовую страницу с автокадрированием. Рабочие поверхности и viewer не затрагиваются."
actions={(
<Switch
checked={selectedBackground.enabled}
label="Показывать"
onChange={(enabled) => {
if (enabled && !selectedBackground.url) {
setLocalError("Сначала загрузите файл или укажите URL.");
return;
}
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, { enabled }));
}}
/>
)}
>
<div className="environment-settings__media">
<div className="environment-settings__surface">
<span>Экран</span>
<Select
label="Выбрать стартовую страницу"
value={surfaceId}
options={backgroundSurfaceOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => {
setSurfaceId(value);
setLocalError(null);
}}
/>
</div>
<MediaSourceField
label="Видео / картинка"
kindLabel={previewKind ?? "media"}
source={selectedBackground.source}
url={selectedBackground.url ?? ""}
fileName={selectedBackground.fileName}
uploading={uploading}
previewSrc={selectedBackground.url}
previewKind={previewKind}
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,video/mp4,video/webm,video/quicktime,.png,.jpg,.jpeg,.gif,.webp,.avif,.mp4,.webm,.mov"
path={`${surfaceId}.background → server environment media`}
hint="Файл сохраняется в Mission Core data root. URL должен быть доступен браузеру по HTTP(S)."
error={localError ?? error}
onSourceChange={(source) => setDraft((current) =>
patchBackground(current, surfaceId, { source }))}
onUrlChange={(url) => setDraft((current) =>
patchBackground(current, surfaceId, {
source: "url",
url: url || null,
mediaKind: url ? inferMediaKind(url) : null,
fileName: null,
}))}
onFileChange={uploadFile}
/>
</div>
</SettingsCard>
</div>
</FeatureSettingsWindow>
);
}
@@ -1,6 +1,7 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentBackground } from "../core/environment/environmentSettings";
import type { RootDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
@@ -9,6 +10,7 @@ export interface LandingStageProps {
backendStatus: BackendStatus;
phase?: RuntimePhase | null;
message?: string | null;
background: EnvironmentBackground;
onOpenObservation: () => void;
onOpenDevice: () => void;
}
@@ -18,11 +20,33 @@ export function LandingStage({
backendStatus,
phase,
message,
background,
onOpenObservation,
onOpenDevice,
}: LandingStageProps) {
return (
<section className="landing-stage" data-root={root?.id ?? "home"}>
<section
className="landing-stage"
data-root={root?.id ?? "home"}
data-has-media={background.enabled && background.url ? "true" : undefined}
>
{background.enabled && background.url ? (
<div className="landing-stage__media" aria-hidden="true">
{background.mediaKind === "video" ? (
<video
key={background.url}
src={background.url}
autoPlay
muted
loop
playsInline
/>
) : (
<img src={background.url} alt="" />
)}
</div>
) : null}
<div className="landing-stage__shade" aria-hidden="true" />
<div className="landing-stage__copy">
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
<h1>{root?.title ?? "Mission Core"}</h1>
@@ -0,0 +1,193 @@
import { roots, type RootId } from "../../productModel";
export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url";
export interface EnvironmentBackground {
enabled: boolean;
source: EnvironmentMediaSource;
url: string | null;
mediaKind: EnvironmentMediaKind | null;
fileName: string | null;
}
export interface EnvironmentSettings {
revision: number;
headerLabels: Record<RootId, string>;
backgrounds: Record<EnvironmentSurfaceId, EnvironmentBackground>;
}
export interface UploadedEnvironmentMedia {
surfaceId: EnvironmentSurfaceId;
url: string;
fileName: string;
mediaKind: EnvironmentMediaKind;
mediaType: string;
byteLength: number;
sha256: string;
}
const surfaceIds: readonly EnvironmentSurfaceId[] = [
"home",
"center",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
];
const rootIds = surfaceIds.filter((value): value is RootId => value !== "home");
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
source: "file",
url: null,
mediaKind: null,
fileName: null,
};
}
export function defaultEnvironmentSettings(): EnvironmentSettings {
return {
revision: 0,
headerLabels: Object.fromEntries(
roots.map((root) => [root.id, root.label]),
) as Record<RootId, string>,
backgrounds: Object.fromEntries(
surfaceIds.map((surfaceId) => [surfaceId, emptyBackground()]),
) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
};
}
function requireRecord(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function requireString(value: unknown, path: string, nullable = false): string | null {
if (nullable && value === null) return null;
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${path} должен быть непустой строкой.`);
}
return value;
}
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path);
if (typeof record.enabled !== "boolean") throw new Error(`${path}.enabled должен быть boolean.`);
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
}
if (
record.media_kind !== null
&& !["image", "video"].includes(String(record.media_kind))
) {
throw new Error(`${path}.media_kind не поддерживается.`);
}
return {
enabled: record.enabled,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`, true),
mediaKind: record.media_kind as EnvironmentMediaKind | null,
fileName: requireString(record.file_name, `${path}.file_name`, true),
};
}
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v1") {
throw new Error("Версия настроек окружения не поддерживается.");
}
if (
typeof record.revision !== "number"
|| !Number.isSafeInteger(record.revision)
|| record.revision < 0
) {
throw new Error("Ревизия настроек окружения некорректна.");
}
const labels = requireRecord(record.header_labels, "environment.header_labels");
const backgrounds = requireRecord(record.backgrounds, "environment.backgrounds");
return {
revision: record.revision,
headerLabels: Object.fromEntries(rootIds.map((rootId) => [
rootId,
requireString(labels[rootId], `environment.header_labels.${rootId}`),
])) as Record<RootId, string>,
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId,
decodeBackground(
backgrounds[surfaceId],
`environment.backgrounds.${surfaceId}`,
),
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
};
}
export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknown {
return {
revision: settings.revision,
header_labels: settings.headerLabels,
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => {
const background = settings.backgrounds[surfaceId];
return [surfaceId, {
enabled: background.enabled,
source: background.source,
url: background.url,
media_kind: background.mediaKind,
file_name: background.fileName,
}];
})),
};
}
export function decodeUploadedEnvironmentMedia(
value: unknown,
): UploadedEnvironmentMedia {
const record = requireRecord(value, "environment media");
if (record.schema_version !== "missioncore.operator-environment-media/v1") {
throw new Error("Версия загруженного media не поддерживается.");
}
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
throw new Error("Экран загруженного media не поддерживается.");
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error("Тип загруженного media не поддерживается.");
}
if (
typeof record.byte_length !== "number"
|| !Number.isSafeInteger(record.byte_length)
|| record.byte_length <= 0
) {
throw new Error("Размер загруженного media некорректен.");
}
return {
surfaceId: record.surface_id as EnvironmentSurfaceId,
url: requireString(record.url, "environment media.url")!,
fileName: requireString(record.file_name, "environment media.file_name")!,
mediaKind: record.media_kind as EnvironmentMediaKind,
mediaType: requireString(record.media_type, "environment media.media_type")!,
byteLength: record.byte_length,
sha256: requireString(record.sha256, "environment media.sha256")!,
};
}
export function cloneEnvironmentSettings(
settings: EnvironmentSettings,
): EnvironmentSettings {
return {
revision: settings.revision,
headerLabels: { ...settings.headerLabels },
backgrounds: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId,
{ ...settings.backgrounds[surfaceId] },
])) as Record<EnvironmentSurfaceId, EnvironmentBackground>,
};
}
export const environmentSurfaceIds = surfaceIds;
@@ -0,0 +1,125 @@
import { useCallback, useEffect, useState } from "react";
import {
decodeEnvironmentSettings,
decodeUploadedEnvironmentMedia,
defaultEnvironmentSettings,
encodeEnvironmentSettings,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "./environmentSettings";
interface EnvironmentSettingsController {
settings: EnvironmentSettings;
state: "loading" | "ready" | "saving" | "error";
error: string | null;
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
upload: (
surfaceId: EnvironmentSurfaceId,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
async function responseError(response: Response, fallback: string): Promise<string> {
try {
const body = await response.json() as { detail?: unknown };
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
} catch {
// A non-JSON reverse-proxy response still gets a stable product message.
}
return fallback;
}
export function useEnvironmentSettings(): EnvironmentSettingsController {
const [settings, setSettings] = useState<EnvironmentSettings>(
defaultEnvironmentSettings,
);
const [state, setState] = useState<EnvironmentSettingsController["state"]>(
"loading",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
void fetch("/api/v1/environment/settings", {
signal: controller.signal,
headers: { Accept: "application/json" },
}).then(async (response) => {
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось загрузить настройки окружения.",
));
}
return decodeEnvironmentSettings(await response.json());
}).then((document) => {
setSettings(document);
setState("ready");
setError(null);
}).catch((reason: unknown) => {
if (controller.signal.aborted) return;
setState("error");
setError(reason instanceof Error
? reason.message
: "Не удалось загрузить настройки окружения.");
});
return () => controller.abort();
}, []);
const save = useCallback(async (draft: EnvironmentSettings) => {
setState("saving");
setError(null);
try {
const response = await fetch("/api/v1/environment/settings", {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(encodeEnvironmentSettings(draft)),
});
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось сохранить настройки окружения.",
));
}
const document = decodeEnvironmentSettings(await response.json());
setSettings(document);
setState("ready");
return document;
} catch (reason) {
const message = reason instanceof Error
? reason.message
: "Не удалось сохранить настройки окружения.";
setState("error");
setError(message);
throw new Error(message);
}
}, []);
const upload = useCallback(async (
surfaceId: EnvironmentSurfaceId,
file: File,
) => {
const response = await fetch(`/api/v1/environment/media/${surfaceId}`, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
},
body: file,
});
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось загрузить фон окружения.",
));
}
return decodeUploadedEnvironmentMedia(await response.json());
}, []);
return { settings, state, error, save, upload };
}
+1
View File
@@ -5,3 +5,4 @@
@import "./styles/device.css";
@import "./styles/responsive.css";
@import "./styles/observation.css";
@import "./styles/environment-settings.css";
@@ -0,0 +1,43 @@
.environment-settings {
display: grid;
gap: 1rem;
}
.environment-settings__labels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.environment-settings__media {
display: grid;
gap: 1rem;
}
.environment-settings__surface {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
align-items: center;
gap: 1rem;
}
.environment-settings__surface > span {
color: var(--nodedc-text-secondary);
font-size: 0.74rem;
font-weight: 650;
}
.environment-settings__surface .nodedc-select-anchor,
.environment-settings__surface .nodedc-select {
width: 100%;
}
@media (max-width: 760px) {
.environment-settings__labels {
grid-template-columns: 1fr;
}
.environment-settings__surface {
grid-template-columns: 1fr;
}
}
+28
View File
@@ -58,6 +58,34 @@
background: var(--station-stage);
}
.landing-stage__media,
.landing-stage__shade {
position: absolute;
inset: 0;
}
.landing-stage__media img,
.landing-stage__media video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.landing-stage__shade {
z-index: 1;
background:
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
pointer-events: none;
}
.landing-stage:not([data-has-media="true"]) .landing-stage__shade {
background:
radial-gradient(circle at 68% 42%, rgb(255 255 255 / 0.035), transparent 34%),
linear-gradient(90deg, rgb(5 6 8 / 0.2), transparent 62%);
}
.landing-stage__copy {
position: absolute;
z-index: 2;
+12 -152
View File
@@ -2441,12 +2441,12 @@
line-height: 1.5;
}
.laboratory-selector label {
.laboratory-selector__control {
display: grid;
gap: 0.35rem;
}
.laboratory-selector label > span {
.laboratory-selector__control > span {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
font-weight: 650;
@@ -2454,26 +2454,9 @@
text-transform: uppercase;
}
.laboratory-selector select {
.laboratory-selector__control .nodedc-select-anchor,
.laboratory-selector__control .nodedc-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 {
@@ -2482,16 +2465,14 @@
}
.laboratory-task,
.laboratory-result-summary,
.laboratory-visual-result {
.laboratory-result-summary {
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.laboratory-task > header,
.laboratory-result-summary > header,
.laboratory-visual-result > header {
.laboratory-result-summary > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -2502,15 +2483,12 @@
.laboratory-task p,
.laboratory-task dl,
.laboratory-result-summary h2,
.laboratory-result-summary p,
.laboratory-visual-result h2,
.laboratory-visual-result p {
.laboratory-result-summary p {
margin: 0;
}
.laboratory-task h2,
.laboratory-result-summary h2,
.laboratory-visual-result h2 {
.laboratory-result-summary h2 {
margin-top: 0.3rem;
color: var(--nodedc-text-primary);
font-size: 1rem;
@@ -2518,8 +2496,7 @@
}
.laboratory-task p,
.laboratory-result-summary > p,
.laboratory-visual-result header p {
.laboratory-result-summary > p {
max-width: 66rem;
margin-top: 0.38rem;
color: var(--nodedc-text-muted);
@@ -2566,7 +2543,7 @@
}
.lab-result-surface {
min-height: 36rem;
min-height: 0;
}
.lab-result-surface .spatial-workspace {
@@ -2575,12 +2552,12 @@
.laboratory-result-pending {
display: grid;
min-height: 34rem;
min-height: 10rem;
place-items: center;
align-content: center;
gap: 0.5rem;
border-radius: 1rem;
background: #06070a;
background: rgb(255 255 255 / 0.025);
color: var(--nodedc-text-muted);
text-align: center;
}
@@ -2596,123 +2573,6 @@
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;
@@ -10,6 +10,7 @@ import {
Button,
GlassSurface,
Icon,
Select,
StatusBadge,
} from "@nodedc/ui-react";
@@ -1218,18 +1219,21 @@ function LaboratorySelector<T extends string>({
<h2>{title}</h2>
<p>{description}</p>
</div>
<label>
<div className="laboratory-selector__control">
<span>{label}</span>
<select
<Select
label={`Выбрать: ${label}`}
value={value}
options={options.map((option) => ({
value: option.id,
label: option.label,
}))}
variant="split"
menuWidth="anchor"
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>
onChange={(next) => onChange(next)}
/>
</div>
</section>
);
}
@@ -1268,20 +1272,6 @@ function LaboratoryTask({
}
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,79971,398 с",
"track 470 · 215,324218,708 с",
"track 679 · 282,569283,281 с",
"track 1011 · 354,937355,242 с",
"track 1276 · 402,606402,995 с",
];
return (
<>
<LaboratoryTask
@@ -1296,61 +1286,6 @@ function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
]}
/>
<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>
</section>
<section className="laboratory-result-summary">
<header>
<div>
@@ -1575,7 +1510,7 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
<LaboratorySelector
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача, полноразмерное визуальное доказательство и структурированный результат."
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
label="Работа"
value={workId}
options={workOptions}