feat(observatory): admit canonical recorded replay
This commit is contained in:
@@ -1,249 +1 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Icon, SegmentedControl } from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||
import {
|
||||
RerunViewport,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
} from "../../components/RerunViewport";
|
||||
import {
|
||||
CanonicalRecordedLabReplay,
|
||||
useCanonicalRecordedLabReplayState,
|
||||
} from "../../components/laboratory/CanonicalRecordedLabReplay";
|
||||
import {
|
||||
resolveCanonicalLabReplay,
|
||||
type CanonicalLabReplayDescriptor,
|
||||
} from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import { defaultSceneSettings } from "../../sceneSettings";
|
||||
|
||||
type MediaMode = "video" | "camera";
|
||||
type SpatialMode = "3d" | "plan";
|
||||
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
|
||||
type SemanticLayer = "city" | "vegetation";
|
||||
|
||||
interface CanonicalReplayLaunch {
|
||||
base: ObservationSessionReplayLaunch;
|
||||
replay: CanonicalLabReplayDescriptor;
|
||||
}
|
||||
|
||||
export function CanonicalVegetationRerunReplay({
|
||||
resultId,
|
||||
review,
|
||||
}: {
|
||||
resultId: string;
|
||||
review: VegetationFullRouteReview;
|
||||
}) {
|
||||
const {
|
||||
mediaMode,
|
||||
spatialMode,
|
||||
splitPrimarySize,
|
||||
splitOrientation,
|
||||
expanded,
|
||||
onMediaModeChange,
|
||||
onSpatialModeChange,
|
||||
onSplitPrimarySizeChange,
|
||||
onExpandedChange,
|
||||
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||
initialMediaMode: "video",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLaunch(null);
|
||||
setLaunchError(null);
|
||||
void resolveObservationSessionReplay(review.sessionId, {
|
||||
signal: controller.signal,
|
||||
maximumWaitMs: 30 * 60 * 1000,
|
||||
onUpdate: () => undefined,
|
||||
}).then(async (value) => ({
|
||||
base: value,
|
||||
replay: await resolveCanonicalLabReplay(resultId, value, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
})).then((value) => {
|
||||
if (!controller.signal.aborted) setLaunch(value);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLaunchError(
|
||||
caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.",
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId, review.sessionId]);
|
||||
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const sceneSettings = useMemo(() => ({
|
||||
...defaultSceneSettings,
|
||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
||||
showPoints: spatialMode !== null,
|
||||
showTrajectory: spatialMode !== null,
|
||||
showGrid: spatialMode !== null,
|
||||
pointSize: 3.8,
|
||||
}), [spatialLayer, spatialMode]);
|
||||
const profile = launch ? recordedSessionRerunProfile({
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
viewerSourceUrl: launch.replay.viewerSourceUrl,
|
||||
byteLength: launch.replay.byteLength,
|
||||
sha256: launch.replay.sha256,
|
||||
},
|
||||
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
|
||||
autoplayWhenReady: false,
|
||||
presentationGate: "ready",
|
||||
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
||||
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
||||
initialPlaybackStartSeconds: review.timelineStartSeconds,
|
||||
view: mediaMode !== null ? "perception" : "spatial",
|
||||
viewResetGeneration,
|
||||
followTrajectory: true,
|
||||
semanticLayer,
|
||||
unifiedPerception: splitView,
|
||||
planView: spatialMode === "plan",
|
||||
perceptionLayers: {
|
||||
enabled: mediaMode !== null,
|
||||
detections2d: mediaMode === "video",
|
||||
segmentation: mediaMode === "video" && showSemantics,
|
||||
cuboids3d: false,
|
||||
},
|
||||
perceptionRetryGeneration: 0,
|
||||
lockPerceptionCameraInteraction: false,
|
||||
}) : null;
|
||||
|
||||
const mediaLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
<Button
|
||||
size="dense"
|
||||
shape="pill"
|
||||
variant={showSemantics ? "primary" : "secondary"}
|
||||
aria-pressed={showSemantics}
|
||||
onClick={() => setShowSemantics((visible) => !visible)}
|
||||
>
|
||||
СЕМАНТИКА
|
||||
</Button>
|
||||
<SegmentedControl
|
||||
value={semanticLayer}
|
||||
items={[
|
||||
{ value: "city", label: "ГОРОД · EoMT" },
|
||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||
]}
|
||||
label="Источник семантики"
|
||||
size="dense"
|
||||
onChange={(value) => {
|
||||
setSemanticLayer(value);
|
||||
setShowSemantics(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Пространственные слои RAV004"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={spatialLayer}
|
||||
items={[
|
||||
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
||||
{ value: "local", label: "ЛОК. SLAM" },
|
||||
{ value: "tgs", label: "TGS", disabled: true },
|
||||
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
||||
]}
|
||||
label="Пространственные слои"
|
||||
size="dense"
|
||||
onChange={setSpatialLayer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const resetSpatialView = (
|
||||
<Button
|
||||
size="dense"
|
||||
variant="ghost"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
aria-label="Сбросить положение 3D камеры"
|
||||
title="Сбросить положение 3D камеры"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||
>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const transport = playback && playbackController ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="shared-clock"
|
||||
rangeNs={playback.rangeNs}
|
||||
currentNs={playback.currentNs}
|
||||
playing={playback.playing}
|
||||
onSeek={playbackController.seek}
|
||||
onPlayingChange={playbackController.setPlaying}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<CanonicalRecordedLabReplay
|
||||
label="RAVNOVES004TREE · канонический повтор Rerun"
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "ВИДЕО" },
|
||||
{ value: "camera", label: "КАМЕРА" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "ПЛАН" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer
|
||||
unifiedContent={profile ? (
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
/>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role={launchError ? "alert" : "status"}>
|
||||
{launchError ?? "Готовим единый кэш канонического повтора RAV004…"}
|
||||
</div>
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
buildLaboratoryProfiles,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
isLegacyPublishedLaboratoryWork,
|
||||
workOptionsForExperiment,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
import {
|
||||
@@ -499,12 +500,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
onDeleteBegin: props.sessionArchive.onDeleteBegin,
|
||||
});
|
||||
const publishedWorks = useMemo(
|
||||
() => sessions.items.filter((session) => (
|
||||
session.lab !== null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud")
|
||||
)).sort((left, right) => (
|
||||
() => sessions.items.filter(isLegacyPublishedLaboratoryWork).sort((left, right) => (
|
||||
Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc)
|
||||
- Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc)
|
||||
)),
|
||||
|
||||
@@ -459,6 +459,17 @@ function pipelineIdForSession(session: ObservationSessionSummary): string {
|
||||
return session.lab?.resultKind ?? "legacy-perception";
|
||||
}
|
||||
|
||||
/** Keep capability projections owned by Observatory out of the legacy LAB surface. */
|
||||
export function isLegacyPublishedLaboratoryWork(
|
||||
session: ObservationSessionSummary,
|
||||
): boolean {
|
||||
return session.lab !== null
|
||||
&& session.lab.replayCapability === null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud");
|
||||
}
|
||||
|
||||
export function buildLaboratoryCatalog({
|
||||
rigLabel,
|
||||
knownWorks,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
@@ -8,11 +8,38 @@ import {
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
||||
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
||||
import {
|
||||
fetchObservatoryRecordedRunReview,
|
||||
type ObservatoryRecordedRunBinding,
|
||||
} from "../../core/observatory/recordedRun";
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
|
||||
const MAX_PRESENTED_EVIDENCE = 6;
|
||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||
type ObservatoryRecordedRunReview = Awaited<
|
||||
ReturnType<typeof fetchObservatoryRecordedRunReview>
|
||||
>;
|
||||
|
||||
type ObservatoryReplayState =
|
||||
| { readonly kind: "closed" }
|
||||
| {
|
||||
readonly kind: "loading";
|
||||
readonly binding: ObservatoryRecordedRunBinding;
|
||||
}
|
||||
| {
|
||||
readonly kind: "ready";
|
||||
readonly binding: ObservatoryRecordedRunBinding;
|
||||
readonly review: ObservatoryRecordedRunReview;
|
||||
}
|
||||
| {
|
||||
readonly kind: "error";
|
||||
readonly binding: ObservatoryRecordedRunBinding;
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
const statusLabel: Record<ObservationSessionStatus, string> = {
|
||||
recording: "Запись идёт",
|
||||
@@ -74,12 +101,22 @@ export function ObservatoryWorkspace({
|
||||
}) {
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const items = controller.catalog?.items ?? [];
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||
|
||||
const closeReplay = useCallback(() => {
|
||||
replayCoordinatorRef.current.cancel();
|
||||
setReplay((current) => current.kind === "closed" ? current : { kind: "closed" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.some((item) => item.source.id === selectedSessionId)) return;
|
||||
closeReplay();
|
||||
setSelectedSessionId(items[0]?.source.id ?? "");
|
||||
}, [items, selectedSessionId]);
|
||||
}, [closeReplay, items, selectedSessionId]);
|
||||
|
||||
useEffect(() => () => replayCoordinatorRef.current.cancel(), []);
|
||||
|
||||
const selectedSession = items.find(
|
||||
(item) => item.source.id === selectedSessionId,
|
||||
@@ -96,12 +133,51 @@ export function ObservatoryWorkspace({
|
||||
const initialLoading = !controller.catalog
|
||||
&& ["idle", "loading"].includes(controller.state);
|
||||
const unavailable = !controller.catalog && controller.state === "error";
|
||||
const replayEvidenceId = replay.kind === "closed"
|
||||
? null
|
||||
: replay.binding.evidenceSessionId;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
replayEvidenceId === null
|
||||
|| selectedSession?.evidence.some(
|
||||
(evidence) => evidence.sessionId === replayEvidenceId,
|
||||
)
|
||||
) return;
|
||||
closeReplay();
|
||||
}, [closeReplay, replayEvidenceId, selectedSession]);
|
||||
|
||||
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||
const attempt = replayCoordinatorRef.current.begin();
|
||||
setReplay({ kind: "loading", binding });
|
||||
void fetchObservatoryRecordedRunReview(binding, {
|
||||
selectedSourceSessionId: selectedSessionId,
|
||||
signal: attempt.signal,
|
||||
}).then((review) => {
|
||||
if (!attempt.isCurrent() || !attempt.finish()) return;
|
||||
setReplay({ kind: "ready", binding, review });
|
||||
}).catch((caught: unknown) => {
|
||||
if (!attempt.isCurrent() || !attempt.finish()) return;
|
||||
setReplay({
|
||||
kind: "error",
|
||||
binding,
|
||||
message: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Канонический визуальный разбор недоступен.",
|
||||
});
|
||||
});
|
||||
}, [selectedSessionId]);
|
||||
|
||||
const selectSession = useCallback((sessionId: string) => {
|
||||
closeReplay();
|
||||
setSelectedSessionId(sessionId);
|
||||
}, [closeReplay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="observatory-workspace"
|
||||
data-observatory-authority="observation-only"
|
||||
data-observatory-viewer="detached"
|
||||
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
|
||||
>
|
||||
<section className="observatory-lead">
|
||||
<div>
|
||||
@@ -123,7 +199,7 @@ export function ObservatoryWorkspace({
|
||||
<div className="observatory-catalog-bar__copy">
|
||||
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
|
||||
<h3>Сохранённая сессия</h3>
|
||||
<p>Выбор меняет только читаемую карточку и не готовит Rerun-запись в фоне.</p>
|
||||
<p>Выбор меняет только читаемую карточку и не готовит визуальный разбор в фоне.</p>
|
||||
</div>
|
||||
<div className="observatory-catalog-bar__controls">
|
||||
<Select
|
||||
@@ -136,7 +212,7 @@ export function ObservatoryWorkspace({
|
||||
emptyLabel="Сессия не найдена"
|
||||
minMenuWidth={360}
|
||||
menuWidth={460}
|
||||
onChange={setSelectedSessionId}
|
||||
onChange={selectSession}
|
||||
/>
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -241,9 +317,14 @@ export function ObservatoryWorkspace({
|
||||
{presentedEvidence.map((evidence) => (
|
||||
<li key={evidence.sessionId}>
|
||||
<GlassSurface className="observatory-evidence-card" padding="md" tone="soft">
|
||||
<div>
|
||||
<strong>{evidence.lab.labId}</strong>
|
||||
<span>{evidence.label}</span>
|
||||
<div className="observatory-evidence-card__heading">
|
||||
<div>
|
||||
<strong>{evidence.lab.labId}</strong>
|
||||
<span>{evidence.label}</span>
|
||||
</div>
|
||||
{evidence.recordedRun ? (
|
||||
<StatusBadge tone="accent">Записанный разбор</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Тип результата</dt><dd>{evidence.lab.resultKind}</dd></div>
|
||||
@@ -253,6 +334,34 @@ export function ObservatoryWorkspace({
|
||||
</div>
|
||||
<div><dt>Опубликован</dt><dd>{formatTimestamp(evidence.publishedAtUtc)}</dd></div>
|
||||
</dl>
|
||||
{evidence.recordedRun ? (
|
||||
<div className="observatory-evidence-card__action">
|
||||
<span>
|
||||
Записанный маршрут · единая временная шкала · только наблюдение
|
||||
</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="play" size={14} />}
|
||||
disabled={
|
||||
replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
}
|
||||
onClick={() => openReplay(evidence.recordedRun!)}
|
||||
>
|
||||
{replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Проверяем результат"
|
||||
: replay.kind === "ready"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Открыть заново"
|
||||
: replay.kind === "error"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Повторить открытие"
|
||||
: "Открыть визуальный разбор"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
</li>
|
||||
))}
|
||||
@@ -277,6 +386,57 @@ export function ObservatoryWorkspace({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{replay.kind === "loading" ? (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg" role="status">
|
||||
<ActivityIndicator label="Проверяем запечатанный результат" />
|
||||
<div>
|
||||
<h3>Проверяем точную связь результата с исходной сессией</h3>
|
||||
<p>Визуализатор и данные маршрута ещё не запущены.</p>
|
||||
</div>
|
||||
<Button size="compact" variant="ghost" onClick={closeReplay}>Отменить</Button>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "error" ? (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg" role="alert">
|
||||
<Icon name="alert" size={20} />
|
||||
<div>
|
||||
<StatusBadge tone="danger">Просмотр недоступен</StatusBadge>
|
||||
<h3>{replay.message}</h3>
|
||||
</div>
|
||||
<div className="observatory-replay-state__actions">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => openReplay(replay.binding)}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={closeReplay}>Закрыть</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "ready" ? (
|
||||
<section className="observatory-replay" aria-label="Канонический визуальный разбор">
|
||||
<header className="observatory-replay__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ</span>
|
||||
<h3>RAVNOVES004TREE · полный маршрут восприятия</h3>
|
||||
<p>Записанный маршрут синхронизирован по общей временной шкале.</p>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="close" size={14} />}
|
||||
onClick={closeReplay}
|
||||
>
|
||||
Закрыть разбор
|
||||
</Button>
|
||||
</header>
|
||||
<CanonicalVegetationRerunReplay
|
||||
resultId={replay.binding.resultId}
|
||||
review={replay.review}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
|
||||
<StatusBadge tone="warning">Вне среза</StatusBadge>
|
||||
|
||||
Reference in New Issue
Block a user