|
|
|
@@ -1,6 +1,11 @@
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
|
|
|
|
|
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
|
|
|
|
import {
|
|
|
|
|
closeObservationSource,
|
|
|
|
|
openObservationSource,
|
|
|
|
|
shouldRestartObservationSource,
|
|
|
|
|
} from "./layoutPolicy";
|
|
|
|
|
|
|
|
|
|
export interface ObservationWindowRect {
|
|
|
|
|
x: number;
|
|
|
|
@@ -15,8 +20,9 @@ export interface ObservationLayoutController {
|
|
|
|
|
activeFloatingSourceId: string | null;
|
|
|
|
|
maximizedFloatingSourceId: string | null;
|
|
|
|
|
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
|
|
|
|
toggleSource: (sourceId: string) => void;
|
|
|
|
|
hideSource: (sourceId: string) => void;
|
|
|
|
|
pendingSourceIds: ReadonlySet<string>;
|
|
|
|
|
toggleSource: (sourceId: string) => Promise<boolean>;
|
|
|
|
|
hideSource: (sourceId: string) => Promise<boolean>;
|
|
|
|
|
setFocusedSourceId: (sourceId: string | null) => void;
|
|
|
|
|
activateFloatingSource: (sourceId: string) => void;
|
|
|
|
|
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
|
|
|
@@ -26,7 +32,11 @@ export interface ObservationLayoutController {
|
|
|
|
|
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);
|
|
|
|
|
return (
|
|
|
|
|
source.modality === "point-cloud" ||
|
|
|
|
|
Boolean(source.previewUrl) ||
|
|
|
|
|
Boolean(source.delivery && (!source.activation || source.activation.selected))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
|
|
|
@@ -42,8 +52,11 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
|
|
|
|
|
|
|
|
|
|
export function useObservationLayout(
|
|
|
|
|
sources: readonly ObservationSourceDescriptor[],
|
|
|
|
|
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
|
|
|
|
|
): ObservationLayoutController {
|
|
|
|
|
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
|
|
|
|
const visibleIdsRef = useRef<string[]>([]);
|
|
|
|
|
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
|
|
|
|
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
|
|
|
|
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
|
|
|
|
|
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
|
|
|
|
@@ -54,11 +67,23 @@ export function useObservationLayout(
|
|
|
|
|
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
|
|
|
|
|
const identity = catalogIdentity(sources);
|
|
|
|
|
|
|
|
|
|
const commitVisibleIds = useCallback((next: string[]) => {
|
|
|
|
|
visibleIdsRef.current = next;
|
|
|
|
|
setVisibleIds(next);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const clearPresentation = useCallback((removedIds: readonly string[]) => {
|
|
|
|
|
if (!removedIds.length) return;
|
|
|
|
|
const removed = new Set(removedIds);
|
|
|
|
|
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current);
|
|
|
|
|
setActiveFloatingSourceId((current) => current && removed.has(current) ? null : current);
|
|
|
|
|
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setVisibleIds((current) => {
|
|
|
|
|
const next = current.filter((sourceId) => sourceIds.has(sourceId));
|
|
|
|
|
return next.length === current.length ? current : next;
|
|
|
|
|
});
|
|
|
|
|
const currentVisible = visibleIdsRef.current;
|
|
|
|
|
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
|
|
|
|
|
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
|
|
|
|
|
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
|
|
|
|
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
|
|
|
|
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
|
|
|
@@ -67,7 +92,7 @@ export function useObservationLayout(
|
|
|
|
|
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
|
|
|
|
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
|
|
|
|
|
});
|
|
|
|
|
}, [sourceIds]);
|
|
|
|
|
}, [commitVisibleIds, sourceIds]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!identity) {
|
|
|
|
@@ -76,30 +101,91 @@ export function useObservationLayout(
|
|
|
|
|
}
|
|
|
|
|
if (initializedCatalog.current === identity) return;
|
|
|
|
|
initializedCatalog.current = identity;
|
|
|
|
|
const defaults = sources
|
|
|
|
|
.filter(canOpenByDefault)
|
|
|
|
|
.map((source) => source.id);
|
|
|
|
|
setVisibleIds(defaults);
|
|
|
|
|
let defaults: string[] = [];
|
|
|
|
|
for (const source of sources.filter(canOpenByDefault)) {
|
|
|
|
|
defaults = openObservationSource(defaults, source.id, sources).visibleIds;
|
|
|
|
|
}
|
|
|
|
|
commitVisibleIds(defaults);
|
|
|
|
|
const firstFloating = sources.find(
|
|
|
|
|
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
|
|
|
|
);
|
|
|
|
|
setActiveFloatingSourceId(firstFloating?.id ?? null);
|
|
|
|
|
}, [identity, sources]);
|
|
|
|
|
}, [commitVisibleIds, 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 selectedDeliveryIdentity = sources
|
|
|
|
|
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
|
|
|
|
|
.map((source) => [
|
|
|
|
|
source.id,
|
|
|
|
|
source.delivery?.id,
|
|
|
|
|
source.activation?.groupId,
|
|
|
|
|
source.activation?.maxActive,
|
|
|
|
|
].join(":"))
|
|
|
|
|
.sort()
|
|
|
|
|
.join("|");
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}, []);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const selected = sources.filter(
|
|
|
|
|
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
|
|
|
|
|
);
|
|
|
|
|
if (!selected.length) return;
|
|
|
|
|
let change = { visibleIds: visibleIdsRef.current, removedIds: [] as string[] };
|
|
|
|
|
const removed = new Set<string>();
|
|
|
|
|
for (const source of selected) {
|
|
|
|
|
change = openObservationSource(change.visibleIds, source.id, sources);
|
|
|
|
|
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
|
|
|
|
}
|
|
|
|
|
commitVisibleIds(change.visibleIds);
|
|
|
|
|
clearPresentation([...removed]);
|
|
|
|
|
}, [clearPresentation, commitVisibleIds, selectedDeliveryIdentity]);
|
|
|
|
|
|
|
|
|
|
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
|
|
|
|
|
const groupId = source.activation?.groupId;
|
|
|
|
|
const affected = groupId
|
|
|
|
|
? sources.filter((candidate) => candidate.activation?.groupId === groupId).map(({ id }) => id)
|
|
|
|
|
: [source.id];
|
|
|
|
|
setPendingIds((current) => pending
|
|
|
|
|
? [...new Set([...current, ...affected])]
|
|
|
|
|
: current.filter((candidate) => !affected.includes(candidate)));
|
|
|
|
|
}, [sources]);
|
|
|
|
|
|
|
|
|
|
const hideSource = useCallback(async (sourceId: string) => {
|
|
|
|
|
const source = sources.find((candidate) => candidate.id === sourceId);
|
|
|
|
|
if (!source) return false;
|
|
|
|
|
if (source.activation?.selected && source.activation.controllable) {
|
|
|
|
|
if (!setSourceActive) return false;
|
|
|
|
|
markPending(source, true);
|
|
|
|
|
try {
|
|
|
|
|
if (!await setSourceActive(source.sourceId, false)) return false;
|
|
|
|
|
} finally {
|
|
|
|
|
markPending(source, false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const change = closeObservationSource(visibleIdsRef.current, sourceId);
|
|
|
|
|
commitVisibleIds(change.visibleIds);
|
|
|
|
|
clearPresentation(change.removedIds);
|
|
|
|
|
return true;
|
|
|
|
|
}, [clearPresentation, commitVisibleIds, markPending, setSourceActive, sources]);
|
|
|
|
|
|
|
|
|
|
const toggleSource = useCallback(async (sourceId: string) => {
|
|
|
|
|
const source = sources.find((candidate) => candidate.id === sourceId);
|
|
|
|
|
if (!source) return false;
|
|
|
|
|
const restart = shouldRestartObservationSource(source);
|
|
|
|
|
if (visibleIdsRef.current.includes(sourceId) && !restart) return hideSource(sourceId);
|
|
|
|
|
if (source.activation && (!source.activation.selected || restart)) {
|
|
|
|
|
if (!source.activation.controllable || !setSourceActive) return false;
|
|
|
|
|
markPending(source, true);
|
|
|
|
|
try {
|
|
|
|
|
if (!await setSourceActive(source.sourceId, true)) return false;
|
|
|
|
|
} finally {
|
|
|
|
|
markPending(source, false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
|
|
|
|
|
commitVisibleIds(change.visibleIds);
|
|
|
|
|
clearPresentation(change.removedIds);
|
|
|
|
|
setActiveFloatingSourceId(sourceId);
|
|
|
|
|
return true;
|
|
|
|
|
}, [clearPresentation, commitVisibleIds, hideSource, markPending, setSourceActive, sources]);
|
|
|
|
|
|
|
|
|
|
const setFocusedSourceId = useCallback((sourceId: string | null) => {
|
|
|
|
|
setFocusedSourceIdState(sourceId);
|
|
|
|
@@ -120,6 +206,7 @@ export function useObservationLayout(
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
|
|
|
|
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
visibleSourceIds,
|
|
|
|
@@ -127,6 +214,7 @@ export function useObservationLayout(
|
|
|
|
|
activeFloatingSourceId,
|
|
|
|
|
maximizedFloatingSourceId,
|
|
|
|
|
windowRects,
|
|
|
|
|
pendingSourceIds,
|
|
|
|
|
toggleSource,
|
|
|
|
|
hideSource,
|
|
|
|
|
setFocusedSourceId,
|
|
|
|
|