fix(lab): align spatial replay controls

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 09:14:20 +03:00
parent e38f2fb1da
commit b8fb4ebcba
7 changed files with 119 additions and 86 deletions
@@ -14,6 +14,7 @@ export function ObservationTimeline({
onSeek,
onPlayingChange,
onJumpToEnd,
showJumpToEnd = true,
playbackRate,
onPlaybackRateChange,
accumulationSeconds,
@@ -32,6 +33,7 @@ export function ObservationTimeline({
onSeek?: (timeNs: number) => void;
onPlayingChange?: (playing: boolean) => void;
onJumpToEnd?: () => void;
showJumpToEnd?: boolean;
playbackRate?: number;
onPlaybackRateChange?: (rate: number) => void;
accumulationSeconds?: number;
@@ -144,15 +146,17 @@ export function ObservationTimeline({
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
{showJumpToEnd ? (
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
) : null}
</div>
</div>
);
@@ -30,6 +30,7 @@ export function LaboratoryEvidenceViewer<
secondaryMode,
overlay,
transport,
trailingActions,
children,
}: {
label: string;
@@ -48,6 +49,7 @@ export function LaboratoryEvidenceViewer<
};
overlay?: ReactNode;
transport?: ReactNode;
trailingActions?: ReactNode;
children: ReactNode;
}) {
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
@@ -98,6 +100,7 @@ export function LaboratoryEvidenceViewer<
label={`${label}: режим представления`}
onChange={onModeChange}
/>
{trailingActions}
<IconButton
ref={expandButtonRef}
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
@@ -1,5 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { Button, Icon } from "@nodedc/ui-react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
@@ -91,15 +96,13 @@ function decisionColor(
return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]);
}
export function LaboratoryMetricEvidenceScene({
pointCloudBodyXyzM,
obstacles,
rig,
corridor,
occupiedVoxelSizeM,
mode,
label,
}: {
export interface LaboratoryMetricEvidenceSceneHandle {
resetView: () => void;
}
export const LaboratoryMetricEvidenceScene = forwardRef<
LaboratoryMetricEvidenceSceneHandle,
{
pointCloudBodyXyzM: readonly LaboratoryMetricPoint3[];
obstacles: readonly LaboratoryMetricObstacleVisual[];
rig: LaboratoryMetricRigVisual;
@@ -107,7 +110,20 @@ export function LaboratoryMetricEvidenceScene({
occupiedVoxelSizeM: number;
mode: LaboratoryMetricSceneMode;
label: string;
}) {
showCurrentIncrement: boolean;
showRollingMap: boolean;
}
>(function LaboratoryMetricEvidenceScene({
pointCloudBodyXyzM,
obstacles,
rig,
corridor,
occupiedVoxelSizeM,
mode,
label,
showCurrentIncrement,
showRollingMap,
}, ref) {
const hostRef = useRef<HTMLDivElement | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
@@ -115,8 +131,6 @@ export function LaboratoryMetricEvidenceScene({
const staticContentRef = useRef<THREE.Group | null>(null);
const dynamicContentRef = useRef<THREE.Group | null>(null);
const [renderError, setRenderError] = useState<string | null>(null);
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
const [showRollingMap, setShowRollingMap] = useState(true);
useEffect(() => {
const host = hostRef.current;
@@ -370,39 +384,13 @@ export function LaboratoryMetricEvidenceScene({
};
useEffect(resetView, [corridor.forwardLengthM, mode]);
useImperativeHandle(ref, () => ({ resetView }));
return (
<div className="laboratory-metric-evidence-scene">
<div ref={hostRef} className="laboratory-metric-evidence-scene__viewport">
{renderError ? <p>{renderError}</p> : null}
</div>
<div className="laboratory-metric-evidence-scene__toolbar">
<Button
variant="secondary"
size="compact"
icon={<Icon name="refresh" size={14} />}
onClick={resetView}
>
Сбросить ракурс
</Button>
<Button
variant={showCurrentIncrement ? "primary" : "secondary"}
size="compact"
aria-pressed={showCurrentIncrement}
onClick={() => setShowCurrentIncrement((visible) => !visible)}
>
CURRENT INCREMENT
</Button>
<Button
variant={showRollingMap ? "primary" : "secondary"}
size="compact"
aria-pressed={showRollingMap}
onClick={() => setShowRollingMap((visible) => !visible)}
>
ROLLING MAP
</Button>
<span>ЛКМ · вращение · колесо · масштаб · ПКМ · панорама</span>
</div>
<div className="laboratory-metric-evidence-scene__legend">
<span data-decision="threat">Угроза</span>
<span data-decision="not-threat">Вне коридора</span>
@@ -412,4 +400,4 @@ export function LaboratoryMetricEvidenceScene({
</div>
</div>
);
}
});
@@ -88,15 +88,9 @@
inset: 0;
}
.m4-replay-threat-evidence-viewer .laboratory-metric-evidence-scene__toolbar {
top: 5.4rem;
max-width: calc(100% - 1.2rem);
flex-wrap: wrap;
}
.m4-replay-threat-evidence-viewer .laboratory-metric-evidence-scene__legend,
.m4-replay-threat-evidence-viewer .m4-replay-threat-visual__overlay {
bottom: 5.3rem;
bottom: 6.2rem;
}
.m4-replay-threat-visual__timeline {
@@ -54,6 +54,30 @@
transform: translateX(-50%);
}
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__controls {
width: calc(100% - 1.2rem);
}
.m4-replay-threat-evidence-viewer .l3-visual-audit__actions {
justify-content: space-between;
}
.m4-replay-threat-visual__layer-controls {
margin-left: auto;
}
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__transport {
bottom: 0.3rem;
}
.m4-replay-threat-visual__timeline {
padding: 0.5rem 0.55rem;
}
.m4-replay-threat-visual__timeline .observation-timeline__playback {
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
}
.laboratory-metric-evidence-scene__viewport {
position: absolute;
inset: 0;
@@ -81,30 +105,11 @@
font-size: 0.62rem;
}
.laboratory-metric-evidence-scene__toolbar {
position: absolute;
z-index: 3;
top: 0.6rem;
left: 0.6rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.laboratory-metric-evidence-scene__toolbar .nodedc-button:not([data-variant="primary"]),
.laboratory-metric-evidence-scene__toolbar > span,
.laboratory-metric-evidence-scene__legend {
background: var(--nodedc-floating-surface);
backdrop-filter: blur(var(--nodedc-blur-control));
}
.laboratory-metric-evidence-scene__toolbar > span {
border-radius: var(--nodedc-radius-control-compact);
padding: 0.43rem 0.55rem;
color: var(--nodedc-text-secondary);
font-size: 0.5rem;
}
.laboratory-metric-evidence-scene__legend {
position: absolute;
z-index: 3;
@@ -150,9 +155,3 @@
border: 1px solid rgb(var(--nodedc-accent-rgb));
background: transparent;
}
@media (max-width: 900px) {
.laboratory-metric-evidence-scene__toolbar > span {
display: none;
}
}
@@ -4,6 +4,7 @@ import { Icon, IconButton } from "@nodedc/ui-react";
import { ObservationTimeline } from "../../components/ObservationTimeline";
import {
LaboratoryMetricEvidenceScene,
type LaboratoryMetricEvidenceSceneHandle,
type LaboratoryMetricSceneMode,
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
@@ -66,7 +67,10 @@ function SpatialState({ message: text }: { message: string }) {
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
const [mode, setMode] = useState<M4ThreatViewMode>("video");
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
const [showRollingMap, setShowRollingMap] = useState(true);
const [expanded, setExpanded] = useState(false);
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
const metadata = useM4ThreatTimelineMetadata(resultId);
const playbackRange = useMemo(() => metadata.timeline ? ({
startSeconds: metadata.timeline.timelineStartSeconds,
@@ -189,9 +193,44 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
<Icon name="chevron-right" size={16} />
</IconButton>
</div>
{mode === "3d" || mode === "plan" ? (
<div
className="nodedc-segmented m4-replay-threat-visual__layer-controls"
role="group"
aria-label="Слои пространственного evidence"
>
<button
type="button"
className="nodedc-segmented__item"
data-active={showCurrentIncrement ? "true" : undefined}
aria-pressed={showCurrentIncrement}
onClick={() => setShowCurrentIncrement((visible) => !visible)}
>
CURRENT INCREMENT
</button>
<button
type="button"
className="nodedc-segmented__item"
data-active={showRollingMap ? "true" : undefined}
aria-pressed={showRollingMap}
onClick={() => setShowRollingMap((visible) => !visible)}
>
ROLLING MAP
</button>
</div>
) : null}
</div>
);
const trailingActions = mode === "3d" || mode === "plan" ? (
<IconButton
label="Сбросить ракурс"
onClick={() => metricSceneRef.current?.resetView()}
>
<Icon name="refresh" size={16} />
</IconButton>
) : null;
const overlay = metadata.timeline && frame ? (
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay">
<div>
@@ -287,6 +326,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
>
{frame ? (
<LaboratoryMetricEvidenceScene
ref={metricSceneRef}
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
obstacles={sceneObstacles}
rig={timeline.rig}
@@ -294,6 +334,8 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
mode={spatialMode}
label="M4.6 recorded-realtime current increment and rolling occupancy"
showCurrentIncrement={showCurrentIncrement}
showRollingMap={showRollingMap}
/>
) : null}
</div>
@@ -336,7 +378,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
onSeek={(timeNs) => playbackController.seek(timeNs / 1_000_000_000)}
onPlayingChange={playbackController.setPlaying}
onPlaybackRateChange={playbackController.setRate}
onJumpToEnd={() => playbackController.seek(timeline.timelineEndSeconds)}
showJumpToEnd={false}
/>
) : undefined;
@@ -358,6 +400,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
actions={actions}
overlay={overlay}
transport={transport}
trailingActions={trailingActions}
>
{content}
</LaboratoryEvidenceViewer>
@@ -345,6 +345,8 @@ test("M4.6 viewer reuses shared camera, video and metric evidence renderers", as
assert.match(videoScene, /<RecordedFmp4Player/);
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
assert.match(metricScene, /OrbitControls/);
assert.match(metricScene, /CURRENT INCREMENT/);
assert.match(metricScene, /ROLLING MAP/);
assert.match(visual, /CURRENT INCREMENT/);
assert.match(visual, /ROLLING MAP/);
assert.match(visual, /showJumpToEnd=\{false\}/);
assert.doesNotMatch(metricScene, /ЛКМ · вращение/);
});