feat: expand intelligent investigation workspace
This commit is contained in:
@@ -1,40 +1,110 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { executePresentationCommand } from "../map/presentation-command-executor";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
|
||||
import {
|
||||
fetchOperationalTask,
|
||||
fetchOperationalTaskPresentationCursor,
|
||||
fetchOperationalTaskPresentationHistory,
|
||||
type OperationalTask
|
||||
} from "../api/operational-task-client";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||
import {
|
||||
presentationEventKey,
|
||||
type InvestigationPresentationEvent
|
||||
} from "../api/investigation-presentation";
|
||||
import { hydrateInvestigationPresentationArchive } from "../utils/investigation-presentation-archive";
|
||||
import { presentationCommandLabel } from "../utils/presentation-command-label";
|
||||
import type {
|
||||
InvestigationPresentationCommand
|
||||
} from "../api/investigation-presentation";
|
||||
import { InvestigationMapPresentation } from "../map/investigation-map-presentation";
|
||||
import type { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
import { InvestigationPresentationFollower } from "./investigation-presentation-follower";
|
||||
import {
|
||||
type InvestigationDevPreview,
|
||||
type InvestigationPresentationActivity
|
||||
} from "../utils/investigation-dev-preview";
|
||||
import {
|
||||
PresentationViewHistory,
|
||||
emptyPresentationView,
|
||||
type PresentationView
|
||||
} from "../utils/presentation-view-history";
|
||||
import {
|
||||
capturePresentationCamera,
|
||||
fitPresentationCamera,
|
||||
presentationHistoryPorts,
|
||||
renderPresentationSnapshot
|
||||
} from "../map/presentation-history-ports";
|
||||
|
||||
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
|
||||
import {
|
||||
createInvestigationDevCase,
|
||||
type InvestigationDevCaseId
|
||||
} from "../utils/investigation-dev-cases";
|
||||
|
||||
export type InvestigationChart = Extract<
|
||||
InvestigationPresentationCommand,
|
||||
{ action: "open_chart" }
|
||||
>;
|
||||
type Options = {
|
||||
enabled: boolean; task: OperationalTask | null; projectId: string | null;
|
||||
sync: WorkbenchSyncState; getAccessToken?: WorkbenchAccess;
|
||||
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
|
||||
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
|
||||
scope?: "investigation" | "chat" | "preview";
|
||||
scopeId?: string | null;
|
||||
enabled: boolean;
|
||||
task: OperationalTask | null;
|
||||
projectId: string | null;
|
||||
sync: WorkbenchSyncState;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
controller: WorkbenchMapController;
|
||||
getPadding: () => PaddingOptions;
|
||||
onClearLayers: () => void;
|
||||
};
|
||||
|
||||
function frame(signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal.throwIfAborted();
|
||||
const abort = () => { cancelAnimationFrame(id); reject(signal.reason); };
|
||||
const id = requestAnimationFrame(() => { signal.removeEventListener("abort", abort); resolve(); });
|
||||
const abort = () => {
|
||||
cancelAnimationFrame(id);
|
||||
reject(signal.reason);
|
||||
};
|
||||
const id = requestAnimationFrame(() => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
});
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Separate commands need a rendered frame; an open and close in one SSE batch must not collapse into one React update. */
|
||||
async function waitForChart(chartId: string, signal: AbortSignal) {
|
||||
async function waitForChart(
|
||||
chartId: string,
|
||||
signal: AbortSignal,
|
||||
scope: string,
|
||||
rendering = () => true
|
||||
) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
await frame(signal);
|
||||
const element = [...document.querySelectorAll<HTMLElement>("[data-investigation-chart]")].find((item) => item.dataset.investigationChart === chartId);
|
||||
if (!rendering()) return;
|
||||
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`);
|
||||
// A deliberate collapse defers rendering, rather than failing a valid command.
|
||||
if (panel?.dataset.investigationCollapsed === "true") return;
|
||||
const element = [
|
||||
...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? [])
|
||||
].find(
|
||||
(item) => item.dataset.investigationChart === chartId && !item.closest('[aria-hidden="true"]')
|
||||
);
|
||||
if (element && element.getBoundingClientRect().height > 0) {
|
||||
const viewport = panel?.querySelector<HTMLElement>("[data-investigation-content]");
|
||||
if (viewport) {
|
||||
const plot = element.querySelector("svg[role=img]") ?? element;
|
||||
const overflow =
|
||||
plot.getBoundingClientRect().bottom - viewport.getBoundingClientRect().bottom + 12;
|
||||
if (overflow > 0) viewport.scrollTop += overflow;
|
||||
}
|
||||
const visibleUntil = Date.now() + 500;
|
||||
while (Date.now() < visibleUntil) await frame(signal);
|
||||
return;
|
||||
@@ -46,123 +116,484 @@ async function waitForChart(chartId: string, signal: AbortSignal) {
|
||||
export function useInvestigationPresentation(options: Options) {
|
||||
const valuesRef = useRef(options);
|
||||
valuesRef.current = options;
|
||||
const [charts, setCharts] = useState<InvestigationChart[]>([]);
|
||||
const [overlay, setOverlay] = useState<InvestigationOverlay | null>(null);
|
||||
const storeRef = useRef(new PresentationViewHistory());
|
||||
const [, refresh] = useState(0);
|
||||
const publish = useCallback(() => refresh((value) => value + 1), []);
|
||||
const [localPreview, setLocalPreview] = useState(false);
|
||||
const localPreviewRef = useRef(false);
|
||||
const [cameraPaused, setCameraPaused] = useState(false);
|
||||
const cameraPausedRef = useRef(false);
|
||||
const [status, setStatus] = useState<"connecting" | "live" | "disconnected" | "error">("disconnected");
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "live" | "history" | "disconnected" | "error"
|
||||
>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [highlightLayers, setHighlightLayers] = useState<string[]>([]);
|
||||
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
|
||||
const activityIdRef = useRef(0);
|
||||
const previewRef = useRef<AbortController | null>(null);
|
||||
const followerRef = useRef<InvestigationPresentationFollower | null>(null);
|
||||
const manualRequestRef = useRef<AbortController | null>(null);
|
||||
const mapCommandsRef = useRef(new AbortController());
|
||||
const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]);
|
||||
const scene = useMemo(
|
||||
() =>
|
||||
new InvestigationMapPresentation(
|
||||
() => options.mapRef.current,
|
||||
() => valuesRef.current.getPadding(),
|
||||
options.scope
|
||||
),
|
||||
[options.mapRef, options.scope]
|
||||
);
|
||||
const taskId = options.task?.task_id ?? null;
|
||||
const projectRef = useRef(options.projectId);
|
||||
const subscribe = options.sync.subscribePresentationEvents;
|
||||
const source = useCallback(
|
||||
(): InvestigationPresentationActivity["source"] =>
|
||||
valuesRef.current.scope === "chat"
|
||||
? "chat"
|
||||
: valuesRef.current.scope === "preview"
|
||||
? "preview"
|
||||
: "live",
|
||||
[]
|
||||
);
|
||||
const update = useCallback(
|
||||
(updater: (view: PresentationView) => PresentationView) => {
|
||||
storeRef.current.update(updater(storeRef.current.current));
|
||||
publish();
|
||||
},
|
||||
[publish]
|
||||
);
|
||||
const record = useCallback(
|
||||
(label: string, origin = source()) => {
|
||||
storeRef.current.record(label, origin, valuesRef.current.task?.title ?? null, {
|
||||
taskId: valuesRef.current.task?.task_id
|
||||
});
|
||||
publish();
|
||||
},
|
||||
[publish, source]
|
||||
);
|
||||
const selectHistory = useCallback(
|
||||
(id: number | null) => {
|
||||
const store = storeRef.current;
|
||||
if (store.selectedId === null)
|
||||
store.update({
|
||||
...store.current,
|
||||
camera:
|
||||
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
|
||||
});
|
||||
if (!store.select(id === store.currentEntryId ? null : id)) return;
|
||||
publish();
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
|
||||
},
|
||||
[publish, scene]
|
||||
);
|
||||
|
||||
const clearMapOverlays = useCallback((discardPending = true) => {
|
||||
mapCommandsRef.current.abort();
|
||||
mapCommandsRef.current = new AbortController();
|
||||
manualRequestRef.current?.abort();
|
||||
if (discardPending) followerRef.current?.discardPendingMapCommands();
|
||||
scene.clear(); setOverlay(null); setHighlightLayers([]);
|
||||
}, [scene]);
|
||||
|
||||
const clearMapOverlays = useCallback(
|
||||
(discardPending = true) => {
|
||||
mapCommandsRef.current.abort();
|
||||
mapCommandsRef.current = new AbortController();
|
||||
manualRequestRef.current?.abort();
|
||||
previewRef.current?.abort();
|
||||
if (discardPending) followerRef.current?.discardPendingMapCommands();
|
||||
storeRef.current.select(null);
|
||||
scene.clear();
|
||||
update((view) => ({
|
||||
...view,
|
||||
highlights: { type: "FeatureCollection", features: [] },
|
||||
highlightLayers: [],
|
||||
overlay: null,
|
||||
scada: null
|
||||
}));
|
||||
},
|
||||
[scene, update]
|
||||
);
|
||||
const clear = useCallback(() => {
|
||||
// New task generations retain their first queued map command.
|
||||
clearMapOverlays(false); setCharts([]);
|
||||
localPreviewRef.current = false;
|
||||
setLocalPreview(false);
|
||||
clearMapOverlays(false);
|
||||
update(() => ({
|
||||
...emptyPresentationView(),
|
||||
camera: capturePresentationCamera(valuesRef.current.mapRef.current)
|
||||
}));
|
||||
valuesRef.current.onClearLayers();
|
||||
}, [clearMapOverlays]);
|
||||
}, [clearMapOverlays, update]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (
|
||||
command: InvestigationPresentationCommand,
|
||||
signal: AbortSignal,
|
||||
origin: InvestigationPresentationActivity["source"],
|
||||
event?: InvestigationPresentationEvent & { event_seq: number }
|
||||
) => {
|
||||
if (command.action !== "open_chart" && command.action !== "close_chart")
|
||||
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
|
||||
signal.throwIfAborted();
|
||||
const store = storeRef.current;
|
||||
const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined;
|
||||
if (eventKey && store.entries.some((entry) => entry.key === eventKey))
|
||||
return { recorded: true, duplicate: true };
|
||||
update((view) => ({
|
||||
...view,
|
||||
evidence:
|
||||
valuesRef.current.task?.evidence.map(({ evidence_id, summary }) => ({
|
||||
evidence_id,
|
||||
summary
|
||||
})) ?? [],
|
||||
camera:
|
||||
store.selectedId === null && (!view.camera || cameraPausedRef.current)
|
||||
? (capturePresentationCamera(valuesRef.current.mapRef.current) ?? view.camera)
|
||||
: view.camera
|
||||
}));
|
||||
// Cleanup changes current but does not create a selectable historical result.
|
||||
const historyId = command.action.startsWith("clear_")
|
||||
? null
|
||||
: store.begin(
|
||||
presentationCommandLabel(command, store.current),
|
||||
origin,
|
||||
valuesRef.current.task?.title ?? null,
|
||||
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
|
||||
);
|
||||
const id = ++activityIdRef.current;
|
||||
setActivity((current) => [
|
||||
...current,
|
||||
{ id, action: command.action, source: origin, status: "running" }
|
||||
]);
|
||||
let actionSnapshot = store.current;
|
||||
const apply = (updater: (view: PresentationView) => PresentationView) => {
|
||||
update(updater);
|
||||
actionSnapshot = store.current;
|
||||
};
|
||||
try {
|
||||
const result = await executePresentationCommand(
|
||||
command,
|
||||
signal,
|
||||
presentationHistoryPorts({
|
||||
controller: valuesRef.current.controller,
|
||||
scene,
|
||||
map: () => valuesRef.current.mapRef.current,
|
||||
padding: () => valuesRef.current.getPadding(),
|
||||
update: apply,
|
||||
rendering: () => store.selectedId === null,
|
||||
cameraAllowed: () => !cameraPausedRef.current,
|
||||
waitForChart: (chartId, nextSignal) =>
|
||||
waitForChart(
|
||||
chartId,
|
||||
nextSignal,
|
||||
valuesRef.current.scope ?? "investigation",
|
||||
() => store.selectedId === null
|
||||
),
|
||||
frame
|
||||
})
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, status: "completed" } : item))
|
||||
);
|
||||
return store.selectedId === null
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
displayed: false,
|
||||
fitted: false,
|
||||
recorded: historyId !== null,
|
||||
viewing_history: true
|
||||
};
|
||||
} catch (cause) {
|
||||
const message = signal.aborted
|
||||
? "展示已取消"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "展示失败";
|
||||
if (historyId !== null)
|
||||
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, status: "error", message } : item))
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
[scene, publish, update]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectRef.current !== options.projectId) {
|
||||
projectRef.current = options.projectId;
|
||||
storeRef.current.reset();
|
||||
setActivity([]);
|
||||
}
|
||||
clear();
|
||||
}, [clear, taskId, options.projectId, options.task?.execution_generation, options.scopeId]);
|
||||
useEffect(() => {
|
||||
manualRequestRef.current?.abort();
|
||||
cameraPausedRef.current = false;
|
||||
setCameraPaused(false);
|
||||
if (!options.enabled || !taskId) { clear(); return; }
|
||||
if (!taskId || (options.scope && options.scope !== "investigation") || !options.mapReady) {
|
||||
if (!localPreviewRef.current) clear();
|
||||
return;
|
||||
}
|
||||
const follower = new InvestigationPresentationFollower({
|
||||
taskId, subscribe,
|
||||
fetchCursor: (signal) => fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
|
||||
taskId,
|
||||
follow: options.enabled,
|
||||
loadHistory: async (cursor, signal) => {
|
||||
const task = valuesRef.current.task;
|
||||
if (!task || task.task_id !== taskId) return;
|
||||
const events = await fetchOperationalTaskPresentationHistory(
|
||||
taskId,
|
||||
cursor.event_cursor,
|
||||
valuesRef.current.getAccessToken,
|
||||
signal
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
const next = await hydrateInvestigationPresentationArchive({
|
||||
events,
|
||||
task,
|
||||
history: storeRef.current,
|
||||
signal,
|
||||
createPorts: (staging) =>
|
||||
presentationHistoryPorts({
|
||||
controller: valuesRef.current.controller,
|
||||
scene,
|
||||
map: () => valuesRef.current.mapRef.current,
|
||||
padding: () => valuesRef.current.getPadding(),
|
||||
update: (updater) => staging.update(updater(staging.current)),
|
||||
rendering: () => false,
|
||||
cameraAllowed: () => true,
|
||||
waitForChart: async () => undefined,
|
||||
frame: async (nextSignal) => nextSignal.throwIfAborted()
|
||||
})
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
storeRef.current.update(next);
|
||||
publish();
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (map && storeRef.current.selectedId === null)
|
||||
renderPresentationSnapshot(scene, map, next);
|
||||
await frame(signal);
|
||||
},
|
||||
subscribe,
|
||||
fetchCursor: (signal) =>
|
||||
fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
|
||||
fetchIdentity: (signal) => fetchOperationalTask(taskId, options.getAccessToken, signal),
|
||||
clear,
|
||||
stopPulse: () => scene.stopPulse(),
|
||||
onStatus: (next, message) => { setStatus(next); setError(message ?? null); },
|
||||
execute: async (command, signal) => {
|
||||
const sourceSignal = signal;
|
||||
if (command.action !== "open_chart" && command.action !== "close_chart") {
|
||||
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
if (command.action === "highlight_features") {
|
||||
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
|
||||
signal.throwIfAborted();
|
||||
scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current);
|
||||
setHighlightLayers([command.layer]);
|
||||
} else if (command.action === "clear_highlights") {
|
||||
scene.clearHighlights(); setHighlightLayers([]);
|
||||
} else if (command.action === "open_chart") {
|
||||
setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]);
|
||||
await waitForChart(command.chart_id, signal);
|
||||
} else if (command.action === "close_chart") {
|
||||
setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id));
|
||||
await frame(signal);
|
||||
} else if (command.action === "set_overlay") {
|
||||
const next = { ...command, visible: true };
|
||||
scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next);
|
||||
} else {
|
||||
scene.clearOverlay(command.overlay_id);
|
||||
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (signal.aborted && !sourceSignal.aborted) return;
|
||||
throw cause;
|
||||
}
|
||||
onStatus: (next, message) => {
|
||||
setStatus(!valuesRef.current.enabled && next === "live" ? "history" : next);
|
||||
setError(message ?? null);
|
||||
},
|
||||
execute: async (command, signal, event) => {
|
||||
await executeCommand(command, signal, "live", event);
|
||||
}
|
||||
});
|
||||
followerRef.current = follower;
|
||||
follower.start();
|
||||
return () => { follower.stop(); if (followerRef.current === follower) followerRef.current = null; };
|
||||
}, [clear, options.enabled, options.getAccessToken, options.projectId, scene, subscribe, taskId]);
|
||||
|
||||
return () => {
|
||||
follower.stop();
|
||||
if (followerRef.current === follower) followerRef.current = null;
|
||||
};
|
||||
}, [
|
||||
clear,
|
||||
executeCommand,
|
||||
options.enabled,
|
||||
options.mapReady,
|
||||
options.scope,
|
||||
options.task?.execution_generation,
|
||||
options.getAccessToken,
|
||||
options.projectId,
|
||||
publish,
|
||||
scene,
|
||||
subscribe,
|
||||
taskId
|
||||
]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
previewRef.current?.abort();
|
||||
},
|
||||
[taskId, options.projectId, options.task?.execution_generation, options.scopeId]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (options.task) followerRef.current?.updateIdentity(options.task);
|
||||
}, [options.task]);
|
||||
|
||||
const pauseCamera = useCallback(() => {
|
||||
cameraPausedRef.current = true; setCameraPaused(true);
|
||||
if (storeRef.current.selectedId !== null) return;
|
||||
cameraPausedRef.current = true;
|
||||
setCameraPaused(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const map = options.mapRef.current;
|
||||
if (!map || !options.mapReady || !options.enabled) return;
|
||||
if (
|
||||
!map ||
|
||||
!options.mapReady ||
|
||||
(!options.enabled && !localPreview && options.scope !== "chat")
|
||||
)
|
||||
return;
|
||||
const canvas = map.getCanvasContainer();
|
||||
// Canvas input excludes programmatic fitBounds/easeTo and also covers touch/keyboard navigation.
|
||||
canvas.addEventListener("pointerdown", pauseCamera);
|
||||
canvas.addEventListener("wheel", pauseCamera, { passive: true });
|
||||
canvas.addEventListener("keydown", pauseCamera);
|
||||
return () => { canvas.removeEventListener("pointerdown", pauseCamera); canvas.removeEventListener("wheel", pauseCamera); canvas.removeEventListener("keydown", pauseCamera); };
|
||||
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera]);
|
||||
useEffect(() => () => { manualRequestRef.current?.abort(); scene.destroy(); }, [scene]);
|
||||
return () => {
|
||||
canvas.removeEventListener("pointerdown", pauseCamera);
|
||||
canvas.removeEventListener("wheel", pauseCamera);
|
||||
canvas.removeEventListener("keydown", pauseCamera);
|
||||
};
|
||||
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera, localPreview, options.scope]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
manualRequestRef.current?.abort();
|
||||
scene.destroy();
|
||||
},
|
||||
[scene]
|
||||
);
|
||||
|
||||
const locateTargets = useCallback(async (targets: InvestigationMapTarget[]) => {
|
||||
manualRequestRef.current?.abort();
|
||||
const controller = new AbortController(); manualRequestRef.current = controller;
|
||||
pauseCamera();
|
||||
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
|
||||
for (const target of targets) groups.set(target.source_id, [...groups.get(target.source_id) ?? [], target.feature_id]);
|
||||
const collections = await Promise.all([...groups].map(([source, ids]) => valuesRef.current.controller.queryPresentationFeatures(source, ids, controller.signal)));
|
||||
controller.signal.throwIfAborted();
|
||||
const data: FeatureCollection = { type: "FeatureCollection", features: collections.flatMap((collection) => collection.features) };
|
||||
if (!data.features.length) throw new Error("未找到关联地图要素");
|
||||
scene.highlight(data, false, true); setHighlightLayers([...groups.keys()]);
|
||||
}, [pauseCamera, scene]);
|
||||
const locateTargets = useCallback(
|
||||
async (targets: InvestigationMapTarget[]) => {
|
||||
if (storeRef.current.selectedId !== null)
|
||||
throw new Error("请先回到当前,再执行新的要素定位。");
|
||||
manualRequestRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
manualRequestRef.current = controller;
|
||||
pauseCamera();
|
||||
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
|
||||
for (const target of targets)
|
||||
groups.set(target.source_id, [...(groups.get(target.source_id) ?? []), target.feature_id]);
|
||||
const collections = await Promise.all(
|
||||
[...groups].map(([layer, ids]) =>
|
||||
valuesRef.current.controller.queryPresentationFeatures(layer, ids, controller.signal)
|
||||
)
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
const data: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: collections.flatMap((collection) => collection.features)
|
||||
};
|
||||
if (!data.features.length) throw new Error("未找到关联地图要素");
|
||||
if (storeRef.current.selectedId === null) scene.highlight(data, false, true);
|
||||
update((view) => ({
|
||||
...view,
|
||||
highlights: structuredClone(data),
|
||||
highlightLayers: [...groups.keys()],
|
||||
camera: fitPresentationCamera(
|
||||
valuesRef.current.mapRef.current,
|
||||
data,
|
||||
valuesRef.current.getPadding()
|
||||
)
|
||||
}));
|
||||
record("定位关联要素");
|
||||
},
|
||||
[pauseCamera, scene, update, record]
|
||||
);
|
||||
|
||||
const runPreview = useCallback(
|
||||
async (
|
||||
input: InvestigationDevPreview | InvestigationPresentationCommand[],
|
||||
label = "本地结果"
|
||||
) => {
|
||||
const preview = Array.isArray(input) ? { commands: input, blocks: [], records: [] } : input;
|
||||
if (preview.commands.length > 0 && !valuesRef.current.mapReady)
|
||||
throw new Error("请等待地图就绪后执行展示指令。");
|
||||
localPreviewRef.current = true;
|
||||
setLocalPreview(true);
|
||||
previewRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
previewRef.current = controller;
|
||||
update((view) => ({
|
||||
...view,
|
||||
blocks: structuredClone(preview.blocks),
|
||||
records: structuredClone(preview.records)
|
||||
}));
|
||||
if (preview.blocks.length || preview.records.length) record(`展示结果 · ${label}`, "preview");
|
||||
for (const command of preview.commands) {
|
||||
controller.signal.throwIfAborted();
|
||||
await executeCommand(command, controller.signal, "preview");
|
||||
}
|
||||
},
|
||||
[executeCommand, update, record]
|
||||
);
|
||||
const createSample = useCallback((caseId: InvestigationDevCaseId = "combined") => {
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (!map) return "";
|
||||
const task = valuesRef.current.task;
|
||||
const progress =
|
||||
task?.latest_progress?.execution_generation === task?.execution_generation
|
||||
? task?.latest_progress
|
||||
: null;
|
||||
const target = progress?.progress.diagram?.node_refs.flatMap(
|
||||
(node) => node.map_targets ?? []
|
||||
)[0];
|
||||
return createInvestigationDevCase(
|
||||
caseId,
|
||||
map.getCenter().toArray() as [number, number],
|
||||
target
|
||||
);
|
||||
}, []);
|
||||
|
||||
const taskEntries =
|
||||
!options.scope || options.scope === "investigation"
|
||||
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
|
||||
: storeRef.current.entries;
|
||||
const visible = storeRef.current.visible;
|
||||
const reviewing = storeRef.current.selectedId !== null;
|
||||
return {
|
||||
charts, overlay, cameraPaused, status, error, highlightLayers, clearMapOverlays,
|
||||
charts: visible.charts,
|
||||
blocks: visible.blocks,
|
||||
records: visible.records,
|
||||
overlay: visible.overlay,
|
||||
evidence: visible.evidence,
|
||||
highlightLayers: visible.highlightLayers,
|
||||
cameraPaused: reviewing ? false : cameraPaused,
|
||||
status,
|
||||
error,
|
||||
activity,
|
||||
localPreview,
|
||||
hasHistory: taskEntries.length > 0,
|
||||
history: {
|
||||
currentEntryId: storeRef.current.currentEntryId,
|
||||
entries: taskEntries,
|
||||
selectedId: storeRef.current.selectedId,
|
||||
onSelect: selectHistory
|
||||
},
|
||||
executeCommand,
|
||||
runPreview,
|
||||
createSample,
|
||||
clearPreview: clear,
|
||||
clearMapOverlays,
|
||||
pauseCamera,
|
||||
resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); },
|
||||
resumeCamera: () => {
|
||||
cameraPausedRef.current = false;
|
||||
setCameraPaused(false);
|
||||
},
|
||||
locateTargets,
|
||||
closeChart: (id: string) => setCharts((current) => current.filter((item) => item.chart_id !== id)),
|
||||
toggleOverlay: () => { if (overlay) { scene.showOverlay(!overlay.visible); setOverlay({ ...overlay, visible: !overlay.visible }); } },
|
||||
fitOverlay: () => { pauseCamera(); scene.fitOverlay(); }
|
||||
closeChart: (id: string) => {
|
||||
if (!reviewing)
|
||||
void executeCommand(
|
||||
{ action: "close_chart", chart_id: id },
|
||||
new AbortController().signal,
|
||||
source()
|
||||
).catch(() => undefined);
|
||||
},
|
||||
toggleOverlay: () => {
|
||||
if (reviewing || !visible.overlay) return;
|
||||
const next = { ...visible.overlay, visible: !visible.overlay.visible };
|
||||
scene.showOverlay(next.visible);
|
||||
update((view) => ({ ...view, overlay: next }));
|
||||
record(next.visible ? "显示覆盖层" : "隐藏覆盖层");
|
||||
},
|
||||
fitOverlay: () => {
|
||||
pauseCamera();
|
||||
scene.fitOverlay();
|
||||
if (!reviewing && visible.overlay) {
|
||||
update((view) => ({
|
||||
...view,
|
||||
camera: fitPresentationCamera(
|
||||
valuesRef.current.mapRef.current,
|
||||
visible.overlay!.data,
|
||||
valuesRef.current.getPadding()
|
||||
)
|
||||
}));
|
||||
record("定位覆盖层");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user