feat(observation): add dynamic camera sources
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||
|
||||
export interface ObservationWindowRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutController {
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
focusedSourceId: string | null;
|
||||
activeFloatingSourceId: string | null;
|
||||
maximizedFloatingSourceId: string | null;
|
||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||
toggleSource: (sourceId: string) => void;
|
||||
hideSource: (sourceId: string) => void;
|
||||
setFocusedSourceId: (sourceId: string | null) => void;
|
||||
activateFloatingSource: (sourceId: string) => void;
|
||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||
}
|
||||
|
||||
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||
if (!source.capabilities.defaultVisible) return false;
|
||||
if (source.availability === "unavailable" || source.availability === "error") return false;
|
||||
return source.modality === "point-cloud" || Boolean(source.previewUrl);
|
||||
}
|
||||
|
||||
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
||||
return sources
|
||||
.map((source) => [
|
||||
source.id,
|
||||
source.binding.deviceSessionId,
|
||||
source.binding.deviceId,
|
||||
].filter(Boolean).join(":"))
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
export function useObservationLayout(
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
): ObservationLayoutController {
|
||||
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
||||
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
||||
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
|
||||
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
|
||||
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
|
||||
const initializedCatalog = useRef<string | null>(null);
|
||||
const sourceIdList = sources.map((source) => source.id).sort();
|
||||
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
|
||||
const identity = catalogIdentity(sources);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleIds((current) => {
|
||||
const next = current.filter((sourceId) => sourceIds.has(sourceId));
|
||||
return next.length === current.length ? current : next;
|
||||
});
|
||||
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
||||
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||
setWindowRects((current) => {
|
||||
const entries = Object.entries(current);
|
||||
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
|
||||
});
|
||||
}, [sourceIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!identity) {
|
||||
initializedCatalog.current = null;
|
||||
return;
|
||||
}
|
||||
if (initializedCatalog.current === identity) return;
|
||||
initializedCatalog.current = identity;
|
||||
const defaults = sources
|
||||
.filter(canOpenByDefault)
|
||||
.map((source) => source.id);
|
||||
setVisibleIds(defaults);
|
||||
const firstFloating = sources.find(
|
||||
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
||||
);
|
||||
setActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||
}, [identity, sources]);
|
||||
|
||||
const toggleSource = useCallback((sourceId: string) => {
|
||||
const selected = visibleIds.includes(sourceId);
|
||||
setVisibleIds((current) => selected
|
||||
? current.filter((candidate) => candidate !== sourceId)
|
||||
: current.includes(sourceId) ? current : [...current, sourceId]);
|
||||
if (!selected) setActiveFloatingSourceId(sourceId);
|
||||
}, [visibleIds]);
|
||||
|
||||
const hideSource = useCallback((sourceId: string) => {
|
||||
setVisibleIds((current) => current.filter((candidate) => candidate !== sourceId));
|
||||
setFocusedSourceIdState((current) => current === sourceId ? null : current);
|
||||
setActiveFloatingSourceId((current) => current === sourceId ? null : current);
|
||||
setMaximizedFloatingSourceId((current) => current === sourceId ? null : current);
|
||||
}, []);
|
||||
|
||||
const setFocusedSourceId = useCallback((sourceId: string | null) => {
|
||||
setFocusedSourceIdState(sourceId);
|
||||
if (sourceId) setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
|
||||
const activateFloatingSource = useCallback((sourceId: string) => {
|
||||
setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
|
||||
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
|
||||
setMaximizedFloatingSourceId(maximized ? sourceId : null);
|
||||
if (maximized) setActiveFloatingSourceId(sourceId);
|
||||
}, []);
|
||||
|
||||
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
|
||||
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
|
||||
}, []);
|
||||
|
||||
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
||||
|
||||
return {
|
||||
visibleSourceIds,
|
||||
focusedSourceId,
|
||||
activeFloatingSourceId,
|
||||
maximizedFloatingSourceId,
|
||||
windowRects,
|
||||
toggleSource,
|
||||
hideSource,
|
||||
setFocusedSourceId,
|
||||
activateFloatingSource,
|
||||
setFloatingMaximized,
|
||||
setWindowRect,
|
||||
};
|
||||
}
|
||||
@@ -72,6 +72,70 @@ export interface SpatialSourceDescriptor {
|
||||
kind: "rerun-grpc" | "rrd" | "other";
|
||||
}
|
||||
|
||||
export type ObservationSourceModality = "point-cloud" | "video" | "image" | "depth";
|
||||
|
||||
export type ObservationSourceAvailability =
|
||||
| "unverified"
|
||||
| "declared"
|
||||
| "available"
|
||||
| "connecting"
|
||||
| "streaming"
|
||||
| "degraded"
|
||||
| "unavailable"
|
||||
| "error";
|
||||
|
||||
export type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
||||
|
||||
export interface ObservationSourceProvider {
|
||||
pluginId: string;
|
||||
pluginVersion: string;
|
||||
modelId: string;
|
||||
compatibilityProfileId?: string | null;
|
||||
}
|
||||
|
||||
export interface ObservationSourceBinding {
|
||||
deviceId?: string | null;
|
||||
deviceSessionId?: string | null;
|
||||
acquisitionId?: string | null;
|
||||
}
|
||||
|
||||
export interface ObservationSourceCapabilities {
|
||||
overlay: boolean;
|
||||
fullscreen: boolean;
|
||||
resizable: boolean;
|
||||
defaultVisible: boolean;
|
||||
timelineMode: ObservationTimelineMode;
|
||||
seekable: boolean;
|
||||
sessionRecording: boolean;
|
||||
clockId?: string | null;
|
||||
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
|
||||
}
|
||||
|
||||
export interface ObservationSourceDescriptor {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
semanticChannelId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
modality: ObservationSourceModality;
|
||||
role: "primary" | "auxiliary";
|
||||
availability: ObservationSourceAvailability;
|
||||
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
|
||||
endpointLabel?: string | null;
|
||||
previewUrl?: string | null;
|
||||
provider: ObservationSourceProvider;
|
||||
binding: ObservationSourceBinding;
|
||||
capabilities: ObservationSourceCapabilities;
|
||||
}
|
||||
|
||||
export interface ObservationTimelineSnapshot {
|
||||
mode: ObservationTimelineMode;
|
||||
seekable: boolean;
|
||||
sessionRecording: boolean;
|
||||
synchronization: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
range: { startSeconds: number; endSeconds: number } | null;
|
||||
}
|
||||
|
||||
export interface MissionRuntimeState {
|
||||
phase: RuntimePhase;
|
||||
message?: string | null;
|
||||
@@ -80,6 +144,8 @@ export interface MissionRuntimeState {
|
||||
acquisition?: RuntimeAcquisitionSnapshot | null;
|
||||
operations?: readonly RuntimeOperationSnapshot[];
|
||||
spatialSource?: SpatialSourceDescriptor | null;
|
||||
observationSources?: readonly ObservationSourceDescriptor[];
|
||||
observationTimeline?: ObservationTimelineSnapshot;
|
||||
viewerSettings?: ViewerSettings | null;
|
||||
sourceMode: SourceMode;
|
||||
metrics?: StreamMetrics;
|
||||
|
||||
Reference in New Issue
Block a user