fix(observatory): refine catalog and replay UX
This commit is contained in:
@@ -241,7 +241,7 @@ export function CanonicalRecordedLabReplay<
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView}
|
||||
resizable={splitView && !unifiedContent}
|
||||
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||
/>
|
||||
{mediaMode === "none" && spatialMode === "none" ? (
|
||||
|
||||
+180
-46
@@ -1,11 +1,26 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Icon, SegmentedControl } from "@nodedc/ui-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunViewportStatus,
|
||||
} from "../RerunViewport";
|
||||
import {
|
||||
CanonicalRecordedLabReplay,
|
||||
@@ -31,6 +46,9 @@ interface CanonicalReplayLaunch {
|
||||
replay: CanonicalLabReplayDescriptor;
|
||||
}
|
||||
|
||||
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
||||
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
|
||||
|
||||
export function CanonicalVegetationRerunReplay({
|
||||
resultId,
|
||||
review,
|
||||
@@ -52,6 +70,7 @@ export function CanonicalVegetationRerunReplay({
|
||||
initialMediaMode: "video",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||
@@ -59,13 +78,92 @@ export function CanonicalVegetationRerunReplay({
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||
const viewerFrameRef = useRef<HTMLDivElement>(null);
|
||||
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
|
||||
const previousSplitViewRef = useRef(splitView);
|
||||
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
||||
|
||||
const stopNativeSplitTracking = useCallback(() => {
|
||||
nativeSplitTrackingCleanupRef.current?.();
|
||||
nativeSplitTrackingCleanupRef.current = null;
|
||||
}, []);
|
||||
|
||||
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!splitView
|
||||
|| event.button !== 0
|
||||
|| !(event.target instanceof HTMLCanvasElement)
|
||||
) return;
|
||||
const frame = viewerFrameRef.current;
|
||||
if (!frame) return;
|
||||
const bounds = frame.getBoundingClientRect();
|
||||
if (bounds.width <= 0) return;
|
||||
const dividerX = bounds.left
|
||||
+ bounds.width * nativeSplitPercentRef.current / 100;
|
||||
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
|
||||
|
||||
stopNativeSplitTracking();
|
||||
const pointerId = event.pointerId;
|
||||
const update = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
const currentBounds = frame.getBoundingClientRect();
|
||||
if (currentBounds.width <= 0) return;
|
||||
const next = Math.min(90, Math.max(
|
||||
10,
|
||||
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
|
||||
));
|
||||
nativeSplitPercentRef.current = next;
|
||||
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
|
||||
};
|
||||
const stop = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
stopNativeSplitTracking();
|
||||
};
|
||||
window.addEventListener("pointermove", update, true);
|
||||
window.addEventListener("pointerup", stop, true);
|
||||
window.addEventListener("pointercancel", stop, true);
|
||||
nativeSplitTrackingCleanupRef.current = () => {
|
||||
window.removeEventListener("pointermove", update, true);
|
||||
window.removeEventListener("pointerup", stop, true);
|
||||
window.removeEventListener("pointercancel", stop, true);
|
||||
};
|
||||
}, [splitView, stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitView) stopNativeSplitTracking();
|
||||
const launchSha256 = launch?.replay.sha256 ?? null;
|
||||
if (
|
||||
trackedLaunchSha256Ref.current !== launchSha256
|
||||
|| (splitView && !previousSplitViewRef.current)
|
||||
) {
|
||||
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
|
||||
}
|
||||
trackedLaunchSha256Ref.current = launchSha256;
|
||||
previousSplitViewRef.current = splitView;
|
||||
const cameraPanePercent = mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100;
|
||||
viewerFrameRef.current?.style.setProperty(
|
||||
"--canonical-rerun-camera-pane",
|
||||
`${cameraPanePercent}%`,
|
||||
);
|
||||
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLaunch(null);
|
||||
setLaunchError(null);
|
||||
setPlayback(null);
|
||||
setPlaybackController(null);
|
||||
setViewerStatus("idle");
|
||||
void resolveObservationSessionReplay(review.sessionId, {
|
||||
signal: controller.signal,
|
||||
maximumWaitMs: 30 * 60 * 1000,
|
||||
@@ -87,7 +185,13 @@ export function CanonicalVegetationRerunReplay({
|
||||
return () => controller.abort();
|
||||
}, [resultId, review.sessionId]);
|
||||
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const presentationReady = playbackController !== null
|
||||
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
||||
const presentationState = launchError || viewerStatus === "error"
|
||||
? "error"
|
||||
: presentationReady
|
||||
? "ready"
|
||||
: "loading";
|
||||
const sceneSettings = useMemo(() => ({
|
||||
...defaultSceneSettings,
|
||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
||||
@@ -123,7 +227,7 @@ export function CanonicalVegetationRerunReplay({
|
||||
cuboids3d: false,
|
||||
},
|
||||
perceptionRetryGeneration: 0,
|
||||
lockPerceptionCameraInteraction: false,
|
||||
lockPerceptionCameraInteraction: mediaMode !== null,
|
||||
}) : null;
|
||||
|
||||
const mediaLayerControls = (
|
||||
@@ -188,9 +292,9 @@ export function CanonicalVegetationRerunReplay({
|
||||
</Button>
|
||||
);
|
||||
|
||||
const transport = playback && playbackController ? (
|
||||
const transport = presentationReady && playback && playbackController ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline"
|
||||
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
@@ -205,45 +309,75 @@ export function CanonicalVegetationRerunReplay({
|
||||
/>
|
||||
) : 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}
|
||||
/>
|
||||
<div
|
||||
className="canonical-vegetation-rerun-replay"
|
||||
data-presentation-state={presentationState}
|
||||
aria-busy={presentationState === "loading"}
|
||||
>
|
||||
<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 ? (
|
||||
<div
|
||||
ref={viewerFrameRef}
|
||||
className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||
data-split-view={splitView ? "true" : undefined}
|
||||
style={{
|
||||
"--canonical-rerun-camera-pane": `${
|
||||
mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100
|
||||
}%`,
|
||||
} as CSSProperties}
|
||||
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
|
||||
>
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onStatusChange={setViewerStatus}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
/>
|
||||
</div>
|
||||
) : launchError ? (
|
||||
<div className="l3-visual-audit__state" role="alert">
|
||||
{launchError}
|
||||
</div>
|
||||
) : (
|
||||
<div aria-hidden="true" />
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
deckOverlays={presentationState === "loading" ? (
|
||||
<div className="canonical-vegetation-rerun-replay__loading">
|
||||
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||
</div>
|
||||
) : undefined}
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user