feat(ui): stabilize shared M4.8 review viewer

This commit is contained in:
DCCONSTRUCTIONS
2026-08-24 22:36:08 +03:00
parent 992c5a8b74
commit 4fa6597b18
44 changed files with 6463 additions and 147 deletions
@@ -101,13 +101,14 @@ export function ObservationTimeline({
<Icon name="chevron-left" />
</Button>
<Button
className="observation-timeline__transport"
size="compact"
variant="secondary"
variant="ghost"
disabled={!buffered || !onPlayingChange}
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
icon={<Icon name={playing ? "stop" : "play"} />}
onClick={() => onPlayingChange?.(!playing)}
>
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
</Button>
/>
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
<Select
label="Скорость воспроизведения"
@@ -117,7 +118,7 @@ export function ObservationTimeline({
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
variant="split"
variant="inline"
menuWidth="anchor"
onChange={(value) => onPlaybackRateChange(Number(value))}
/>
@@ -90,6 +90,22 @@ export function recordedMediaSegmentAppendOrder(
return missing;
}
export function recordedMediaCanRollTarget(
previousSequence: number,
nextSequence: number,
playing: boolean,
targetBuffered: boolean,
): boolean {
return Boolean(
playing
&& targetBuffered
&& Number.isInteger(previousSequence)
&& Number.isInteger(nextSequence)
&& previousSequence >= 1
&& nextSequence >= previousSequence
);
}
function recordedMediaTimeRangesContain(
ranges: TimeRanges,
targetSeconds: number,
@@ -1028,12 +1044,13 @@ export function RecordedFmp4Player({
forceReset: false,
resetAttempts: 0,
};
const rollingTarget = Boolean(
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
previousTarget.sequence,
candidateTarget.sequence,
playbackPlayingRef.current
&& previousTarget
&& runtime.notifiedRevision === previousTarget.revision
&& recordedSegmentTargetBuffered(runtime, candidateTarget),
);
&& runtime.notifiedRevision === previousTarget.revision,
recordedSegmentTargetBuffered(runtime, candidateTarget),
));
const reportPumpError = (error: unknown) => {
if (
runtime.disposed
@@ -32,6 +32,7 @@ export function LaboratoryEvidenceViewer<
transport,
trailingActions,
modeControlsVisible = true,
chromeLayout = "overlay",
children,
}: {
label: string;
@@ -52,6 +53,7 @@ export function LaboratoryEvidenceViewer<
transport?: ReactNode;
trailingActions?: ReactNode;
modeControlsVisible?: boolean;
chromeLayout?: "overlay" | "stacked";
children: ReactNode;
}) {
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
@@ -80,6 +82,36 @@ export function LaboratoryEvidenceViewer<
return () => window.removeEventListener("keydown", onKeyDown);
}, [expanded, onExpandedChange]);
const controls = (
<div className="laboratory-evidence-viewer__controls">
{actions}
{modeControlsVisible && secondaryMode ? (
<SegmentedControl
value={secondaryMode.value}
items={[...secondaryMode.modes]}
label={secondaryMode.label}
onChange={secondaryMode.onChange}
/>
) : null}
{modeControlsVisible ? (
<SegmentedControl
value={mode}
items={[...modes]}
label={`${label}: режим представления`}
onChange={onModeChange}
/>
) : null}
{trailingActions}
<IconButton
ref={expandButtonRef}
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
onClick={() => onExpandedChange(!expanded)}
>
<Icon name={expanded ? "minimize" : "expand"} size={16} />
</IconButton>
</div>
);
const viewer = (
<dialog
ref={viewerRef}
@@ -90,44 +122,40 @@ export function LaboratoryEvidenceViewer<
].filter(Boolean).join(" ")}
data-expanded={expanded ? "true" : undefined}
data-mode-controls={modeControlsVisible ? undefined : "content"}
data-chrome-layout={chromeLayout}
aria-label={label}
>
<div className="laboratory-evidence-viewer__stage">
{children}
</div>
{overlay}
{transport ? (
<div className="laboratory-evidence-viewer__transport">
{transport}
</div>
) : null}
<div className="laboratory-evidence-viewer__controls">
{actions}
{modeControlsVisible && secondaryMode ? (
<SegmentedControl
value={secondaryMode.value}
items={[...secondaryMode.modes]}
label={secondaryMode.label}
onChange={secondaryMode.onChange}
/>
) : null}
{modeControlsVisible ? (
<SegmentedControl
value={mode}
items={[...modes]}
label={`${label}: режим представления`}
onChange={onModeChange}
/>
) : null}
{trailingActions}
<IconButton
ref={expandButtonRef}
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
onClick={() => onExpandedChange(!expanded)}
>
<Icon name={expanded ? "minimize" : "expand"} size={16} />
</IconButton>
</div>
{chromeLayout === "stacked" ? (
<>
<div className="laboratory-evidence-viewer__header">
<div className="laboratory-evidence-viewer__header-context">
{overlay}
</div>
{controls}
</div>
<div className="laboratory-evidence-viewer__stage">
{children}
</div>
{transport ? (
<div className="laboratory-evidence-viewer__transport">
{transport}
</div>
) : null}
</>
) : (
<>
<div className="laboratory-evidence-viewer__stage">
{children}
</div>
{overlay}
{transport ? (
<div className="laboratory-evidence-viewer__transport">
{transport}
</div>
) : null}
{controls}
</>
)}
</dialog>
);
@@ -40,6 +40,55 @@ export interface LaboratoryMetricCorridorVisual {
halfWidthM: number;
}
export interface LaboratoryMetricLegendEntry {
id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling";
label: string;
}
export function laboratoryMetricLegendEntries({
pointCloudCount,
localSurfaceCount,
obstacles,
showCurrentIncrement,
showLocalSurface,
showRollingMap,
}: {
pointCloudCount: number;
localSurfaceCount: number;
obstacles: readonly LaboratoryMetricObstacleVisual[];
showCurrentIncrement: boolean;
showLocalSurface: boolean;
showRollingMap: boolean;
}): readonly LaboratoryMetricLegendEntry[] {
const visibleObstacles = obstacles.filter((obstacle) => (
obstacle.state === "current"
? showCurrentIncrement
: obstacle.state === "retained"
? showRollingMap
: false
));
const decisions = new Set(visibleObstacles.map(({ decision }) => decision));
const entries: LaboratoryMetricLegendEntry[] = [];
if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" });
if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" });
if (decisions.has("unknown")) entries.push({ id: "unknown", label: "Неизвестно" });
if (showCurrentIncrement && pointCloudCount > 0) {
entries.push({ id: "context", label: "Текущий кадр" });
}
if (showLocalSurface && localSurfaceCount > 0) {
entries.push({ id: "local-surface", label: "Локальная SLAM-поверхность" });
}
if (
showRollingMap
&& visibleObstacles.some((obstacle) => (
obstacle.state === "retained" && obstacle.cellCentersBodyXyzM.length > 0
))
) {
entries.push({ id: "rolling", label: "Занято на накопленной карте" });
}
return entries;
}
function tokenColor(
host: HTMLElement,
token: string,
@@ -167,7 +216,6 @@ LaboratoryMetricEvidenceSceneHandle,
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(tokenColor(host, "--nodedc-canvas", [5, 5, 6]), 1);
renderer.domElement.setAttribute("aria-label", label);
renderer.domElement.setAttribute("role", "img");
host.prepend(renderer.domElement);
@@ -221,6 +269,11 @@ LaboratoryMetricEvidenceSceneHandle,
staticContentRef.current = null;
dynamicContentRef.current = null;
};
}, []);
useEffect(() => {
const canvas = hostRef.current?.querySelector("canvas");
if (canvas) canvas.setAttribute("aria-label", label);
}, [label]);
useEffect(() => {
@@ -478,6 +531,14 @@ LaboratoryMetricEvidenceSceneHandle,
}];
});
})();
const metricLegendEntries = laboratoryMetricLegendEntries({
pointCloudCount: pointCloudBodyXyzM.length,
localSurfaceCount: localSurfaceBodyXyzM.length,
obstacles,
showCurrentIncrement,
showLocalSurface,
showRollingMap,
});
return (
<div className="laboratory-metric-evidence-scene">
@@ -485,12 +546,9 @@ LaboratoryMetricEvidenceSceneHandle,
{renderError ? <p>{renderError}</p> : null}
</div>
<div className="laboratory-metric-evidence-scene__legend">
<span data-decision="threat">Угроза</span>
<span data-decision="not-threat">Вне коридора</span>
<span data-decision="unknown">Неизвестно</span>
<span data-decision="context">Current increment</span>
<span data-decision="local-surface">Local SLAM surface</span>
<span data-decision="rolling">Rolling-map occupied</span>
{metricLegendEntries.map((entry) => (
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
))}
{semanticLegendEntries.map((entry) => (
<span
key={entry.id}
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Select, StatusBadge } from "@nodedc/ui-react";
import { useId, useState, type ReactNode } from "react";
import { Icon, IconButton, Select, StatusBadge } from "@nodedc/ui-react";
export interface LaboratoryOption<T extends string> {
id: T;
@@ -146,78 +146,94 @@ export function LaboratorySummary({
method?: LaboratoryMethod | null;
}) {
const methodComplete = method?.completeness === "complete";
const [expanded, setExpanded] = useState(false);
const detailsId = useId();
return (
<section className="laboratory-summary">
<section className="laboratory-summary" data-expanded={expanded ? "true" : undefined}>
<header>
<div>
<div className="laboratory-summary__heading">
<span className="section-eyebrow">ЛАБОРАТОРНАЯ РАБОТА</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
<StatusBadge tone={statusTone}>{status}</StatusBadge>
<div className="laboratory-summary__actions">
<StatusBadge tone={statusTone}>{status}</StatusBadge>
<IconButton
label={expanded ? "Свернуть подробности лабораторной работы" : "Раскрыть подробности лабораторной работы"}
aria-expanded={expanded}
aria-controls={detailsId}
onClick={() => setExpanded((current) => !current)}
>
<span className="laboratory-summary__toggle-glyph" aria-hidden="true">
<Icon name="chevron-down" />
</span>
</IconButton>
</div>
</header>
<dl className="laboratory-summary__facts">
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
<dl className="laboratory-summary__brief">
<div>
<dt>Задача</dt>
<dd>{brief.question}</dd>
</div>
<div>
<dt>Как проверяли</dt>
<dd>{brief.approach}</dd>
</div>
<div>
<dt>Главный результат</dt>
<dd>{brief.principalResult}</dd>
</div>
<div>
<dt>Ограничение</dt>
<dd>{brief.limitation}</dd>
</div>
</dl>
{method ? (
<div className="laboratory-summary__method">
<header>
<div>
<span className="section-eyebrow">МЕТОД</span>
<strong>{method.pipelineId}</strong>
<div id={detailsId} className="laboratory-summary__details" hidden={!expanded}>
<p className="laboratory-summary__description">{description}</p>
<dl className="laboratory-summary__facts">
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
<small>
{EXECUTION_LABELS[method.executionClass]}
{" · "}
{methodComplete ? "полная идентичность" : "legacy · частично"}
</small>
</header>
<dl className="laboratory-summary__components">
{method.components.map((component, index) => (
<div key={`${component.kind}:${component.name}:${index}`}>
<dt>{COMPONENT_LABELS[component.kind]}</dt>
<dd>
<strong>{component.name}</strong>
<small>
{component.role}
{" · "}
{component.version}
{component.identitySha256
? ` · ${component.identitySha256.slice(0, 12)}`
: ""}
</small>
</dd>
))}
</dl>
<dl className="laboratory-summary__brief">
<div>
<dt>Задача</dt>
<dd>{brief.question}</dd>
</div>
<div>
<dt>Как проверяли</dt>
<dd>{brief.approach}</dd>
</div>
<div>
<dt>Главный результат</dt>
<dd>{brief.principalResult}</dd>
</div>
<div>
<dt>Ограничение</dt>
<dd>{brief.limitation}</dd>
</div>
</dl>
{method ? (
<div className="laboratory-summary__method">
<header>
<div>
<span className="section-eyebrow">МЕТОД</span>
<strong>{method.pipelineId}</strong>
</div>
))}
</dl>
</div>
) : null}
<small>
{EXECUTION_LABELS[method.executionClass]}
{" · "}
{methodComplete ? "полная идентичность" : "legacy · частично"}
</small>
</header>
<dl className="laboratory-summary__components">
{method.components.map((component, index) => (
<div key={`${component.kind}:${component.name}:${index}`}>
<dt>{COMPONENT_LABELS[component.kind]}</dt>
<dd>
<strong>{component.name}</strong>
<small>
{component.role}
{" · "}
{component.version}
{component.identitySha256
? ` · ${component.identitySha256.slice(0, 12)}`
: ""}
</small>
</dd>
</div>
))}
</dl>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,211 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { SplitPane } from "@nodedc/ui-react";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "../RecordedFmp4Player";
import { ObservationTimeline } from "../ObservationTimeline";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
export const LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT =
"missioncore.laboratory-recorded-clip-viewer/v1" as const;
export interface LaboratoryRecordedClipFrame {
sequence: number;
sourceTimeNs: number;
}
export function nearestLaboratoryRecordedClipFrame(
frames: readonly LaboratoryRecordedClipFrame[],
sourceTimeNs: number,
): LaboratoryRecordedClipFrame | null {
if (!frames.length || !Number.isFinite(sourceTimeNs)) return null;
let left = 0;
let right = frames.length - 1;
while (left < right) {
const middle = Math.floor((left + right) / 2);
if (frames[middle]!.sourceTimeNs < sourceTimeNs) left = middle + 1;
else right = middle;
}
const next = frames[left]!;
const previous = frames[Math.max(0, left - 1)]!;
return Math.abs(previous.sourceTimeNs - sourceTimeNs)
<= Math.abs(next.sourceTimeNs - sourceTimeNs)
? previous
: next;
}
export function laboratoryRecordedClipEndExclusiveNs(
frames: readonly LaboratoryRecordedClipFrame[],
): number | null {
const last = frames.at(-1);
if (!last) return null;
const deltas = frames.slice(1).flatMap((frame, index) => {
const delta = frame.sourceTimeNs - frames[index]!.sourceTimeNs;
return Number.isSafeInteger(delta) && delta > 0 ? [delta] : [];
}).sort((left, right) => left - right);
const typicalDelta = deltas.length
? deltas[Math.floor(deltas.length / 2)]!
: 100_000_000;
return last.sourceTimeNs + typicalDelta;
}
export function LaboratoryRecordedClipPlayer({
source,
segmentCount,
frames,
sequence,
playing,
playbackRate,
cameraPresentation,
continuousPlayback,
sourceCount,
cameraRef,
cameraOverlay,
alternativeScene,
onSequenceChange,
onPlayingChange,
onPlaybackRateChange,
}: {
source: ObservationSourceDescriptor;
segmentCount: number;
frames: readonly LaboratoryRecordedClipFrame[];
sequence: number;
playing: boolean;
playbackRate: number;
cameraPresentation: "primary" | "companion" | "hidden";
continuousPlayback: boolean;
sourceCount: number;
cameraRef?: RefObject<HTMLDivElement | null>;
cameraOverlay?: ReactNode;
alternativeScene?: ReactNode;
onSequenceChange: (sequence: number) => void;
onPlayingChange: (playing: boolean) => void;
onPlaybackRateChange: (rate: number) => void;
}) {
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
const lastEmittedSequenceRef = useRef(sequence);
lastEmittedSequenceRef.current = sequence;
const frame = useMemo(
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
[frames, sequence],
);
const endExclusiveNs = useMemo(
() => laboratoryRecordedClipEndExclusiveNs(frames),
[frames],
);
const playback = useMemo<RecordedObservationPlayback | null>(() => frame ? ({
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
playing: continuousPlayback && playing,
rate: playbackRate,
}) : null, [continuousPlayback, frame, playbackRate, playing]);
useEffect(() => {
if (!continuousPlayback && playing) onPlayingChange(false);
}, [continuousPlayback, onPlayingChange, playing]);
const emitSequence = useCallback((nextSequence: number) => {
if (lastEmittedSequenceRef.current === nextSequence) return;
lastEmittedSequenceRef.current = nextSequence;
onSequenceChange(nextSequence);
}, [onSequenceChange]);
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
const first = frames[0];
if (!first || endExclusiveNs === null) return;
if (sourceTimeNs >= endExclusiveNs) {
emitSequence(first.sequence);
return;
}
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
if (nearest) emitSequence(nearest.sequence);
}, [emitSequence, endExclusiveNs, frames]);
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
const companionVisible = cameraPresentation === "companion";
const spatialSize = companionVisible
? companionSpatialSize
: cameraPresentation === "primary" ? 0 : 100;
const spatialPane = (
<div
className="laboratory-recorded-clip-player__spatial"
aria-hidden={cameraPresentation === "primary"}
>
{cameraPresentation !== "primary" ? alternativeScene : null}
</div>
);
const cameraPane = (
<div
ref={cameraRef}
className="laboratory-recorded-clip-player__camera"
aria-hidden={cameraPresentation === "hidden"}
>
{playback ? (
<RecordedFmp4Player
source={source}
playback={playback}
interactive={false}
prepare
segmentSequence={frame?.sequence}
segmentCount={segmentCount}
admissionKey={`${source.id}:${source.delivery?.id ?? "recorded"}`}
onPlaybackChange={handlePlaybackChange}
onPlayingRejected={() => onPlayingChange(false)}
/>
) : null}
{cameraPresentation !== "hidden" ? cameraOverlay : null}
</div>
);
return (
<div
className="laboratory-recorded-clip-player"
data-contract={LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT}
data-camera-presentation={cameraPresentation}
>
<div className="laboratory-recorded-clip-player__stage">
<SplitPane
className="laboratory-recorded-clip-player__split"
primary={spatialPane}
secondary={cameraPane}
primarySize={spatialSize}
onPrimarySizeChange={setCompanionSpatialSize}
orientation="vertical"
minPrimarySize={companionVisible ? 24 : 0}
minSecondarySize={companionVisible ? 24 : 0}
resizable={companionVisible}
separatorLabel="Изменить размер 3D/ПЛАН и правой камеры"
/>
</div>
<ObservationTimeline
className="laboratory-recorded-clip-player__timeline"
active
sourceCount={sourceCount}
mode="recorded"
seekable
synchronization="frame-accurate"
rangeNs={{ min: timelineStart, max: Math.max(timelineEnd, timelineStart + 1) }}
currentNs={frame?.sourceTimeNs ?? timelineStart}
playing={continuousPlayback && playing}
playbackRate={continuousPlayback ? playbackRate : undefined}
onPlaybackRateChange={continuousPlayback ? onPlaybackRateChange : undefined}
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
onSeek={(timeNs) => {
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
if (nearest) emitSequence(nearest.sequence);
}}
showJumpToEnd={false}
/>
</div>
);
}
@@ -0,0 +1,111 @@
import {
useEffect,
useRef,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function keepFocusInside(
event: ReactKeyboardEvent<HTMLElement>,
frame: HTMLElement,
): void {
if (event.key !== "Tab") return;
const focusable = Array.from(frame.querySelectorAll<HTMLElement>(FOCUSABLE));
if (!focusable.length) {
event.preventDefault();
frame.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export function LaboratoryReviewWorkspaceFrame({
ariaLabel,
toolbar,
stage,
inspector,
overlays,
interactionEnabled = true,
returnFocusTarget,
onClose,
}: {
ariaLabel: string;
toolbar: ReactNode;
stage: ReactNode;
inspector?: ReactNode;
overlays?: ReactNode;
interactionEnabled?: boolean;
returnFocusTarget?: HTMLElement | null;
onClose: () => void;
}) {
const frameRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (typeof document === "undefined") return;
const previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const animationFrame = window.requestAnimationFrame(() => {
const firstFocusable = frameRef.current?.querySelector<HTMLElement>(FOCUSABLE);
(firstFocusable ?? frameRef.current)?.focus();
});
return () => {
window.cancelAnimationFrame(animationFrame);
document.body.style.overflow = previousOverflow;
const target = returnFocusTarget?.isConnected ? returnFocusTarget : previousFocus;
window.requestAnimationFrame(() => target?.focus());
};
}, [returnFocusTarget]);
if (typeof document === "undefined") return null;
return createPortal(
<section
ref={frameRef}
className="laboratory-review-workspace"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
data-has-inspector={inspector ? "true" : undefined}
tabIndex={-1}
onKeyDown={(event) => {
if (!interactionEnabled) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onCloseRef.current();
return;
}
keepFocusInside(event, event.currentTarget);
}}
>
<header className="laboratory-review-workspace__toolbar">{toolbar}</header>
<div className="laboratory-review-workspace__stage">{stage}</div>
{inspector ? <footer className="laboratory-review-workspace__inspector">{inspector}</footer> : null}
{overlays}
</section>,
document.body,
);
}