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, fetchOperationalTaskPresentationHistory, type OperationalTask } from "../api/operational-task-client"; import type { InvestigationMapTarget } from "../api/investigation-report"; 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"; import { createInvestigationDevCase, type InvestigationDevCaseId } from "../utils/investigation-dev-cases"; export type InvestigationChart = Extract< InvestigationPresentationCommand, { action: "open_chart" } >; type Options = { scope?: "investigation" | "chat" | "preview"; scopeId?: string | null; enabled: boolean; task: OperationalTask | null; projectId: string | null; sync: WorkbenchSyncState; getAccessToken?: WorkbenchAccess; mapRef: RefObject; mapReady: boolean; controller: WorkbenchMapController; getPadding: () => PaddingOptions; onClearLayers: () => void; }; function frame(signal: AbortSignal) { return new Promise((resolve, reject) => { signal.throwIfAborted(); 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, scope: string, rendering = () => true ) { const deadline = Date.now() + 5000; while (Date.now() < deadline) { await frame(signal); if (!rendering()) return; const panel = document.querySelector(`[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("[data-investigation-chart]") ?? []) ].find( (item) => item.dataset.investigationChart === chartId && !item.closest('[aria-hidden="true"]') ); if (element && element.getBoundingClientRect().height > 0) { const viewport = panel?.querySelector("[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; } } throw new Error("图表当前不可见"); } export function useInvestigationPresentation(options: Options) { const valuesRef = useRef(options); valuesRef.current = options; 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" | "history" | "disconnected" | "error" >("disconnected"); const [error, setError] = useState(null); const [playbackSpeed, setPlaybackSpeed] = useState(1); const [activity, setActivity] = useState([]); const activityIdRef = useRef(0); const previewRef = useRef(null); const followerRef = useRef(null); const manualRequestRef = useRef(null); const mapCommandsRef = useRef(new AbortController()); 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, pin = false) => { const store = storeRef.current; if (store.selectedId === null) store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera }); if (!store.select(!pin && 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(); 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(() => { localPreviewRef.current = false; setLocalPreview(false); clearMapOverlays(false); update(() => ({ ...emptyPresentationView(), camera: capturePresentationCamera(valuesRef.current.mapRef.current) })); valuesRef.current.onClearLayers(); }, [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 previousView = store.current; const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined; if (eventKey && store.entries.some((entry) => entry.key === eventKey && entry.status === "completed")) 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 pendingEntry = store.entries.find((entry) => entry.id === historyId); 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) => { signal.throwIfAborted(); 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, pendingEntry); 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) { // An unfinished display may already have applied a chart or geometry before // waiting for its frame. Roll back only its own view, never a newer scope. if (signal.aborted && store.current === actionSnapshot) { store.update(previousView); actionSnapshot = previousView; const map = valuesRef.current.mapRef.current; if (map && store.selectedId === null && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, previousView); } const message = signal.aborted ? "展示已取消" : cause instanceof Error ? cause.message : "展示失败"; if (historyId !== null) store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot, pendingEntry); 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 (!taskId || (options.scope && options.scope !== "investigation") || !options.mapReady) { if (!localPreviewRef.current) clear(); return; } const follower = new InvestigationPresentationFollower({ taskId, follow: options.enabled, loadHistory: async (cursor, signal) => { const task = valuesRef.current.task; if (!task || task.task_id !== taskId) return; if (task.execution_generation !== cursor.execution_generation) throw new Error("调查展示代次正在同步,请稍后重试"); 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(); if (valuesRef.current.task?.task_id !== task.task_id || valuesRef.current.task.execution_generation !== task.execution_generation) throw new Error("调查任务已切换,忽略旧展示结果"); 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(!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, 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(() => { if (storeRef.current.selectedId !== null) return; cameraPausedRef.current = true; setCameraPaused(true); }, []); useEffect(() => { const map = options.mapRef.current; if ( !map || !options.mapReady || (!options.enabled && !localPreview && options.scope !== "chat") ) return; const canvas = map.getCanvasContainer(); 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, localPreview, options.scope]); useEffect( () => () => { manualRequestRef.current?.abort(); scene.destroy(); }, [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(); 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 ""; return createInvestigationDevCase(caseId); }, []); const taskEntries = !options.scope || options.scope === "investigation" ? storeRef.current.entries.filter((entry) => entry.taskId === taskId) : storeRef.current.entries; const playing = storeRef.current.playing; const selectedHistoryId = storeRef.current.selectedId; useEffect(() => { if (!playing) return; const timer = window.setTimeout(() => { const store = storeRef.current; store.advancePlayback(); publish(); const map = valuesRef.current.mapRef.current; if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible); }, 1500 / playbackSpeed); return () => window.clearTimeout(timer); }, [playing, selectedHistoryId, playbackSpeed, publish, scene]); const visible = storeRef.current.visible; const reviewing = storeRef.current.selectedId !== null; return { 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, playback: { playing, speed: playbackSpeed, onToggle: () => { const store = storeRef.current; if (store.playing) store.pausePlayback(); else { if (store.selectedId === null) store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera }); store.startPlayback(taskEntries); } publish(); const map = valuesRef.current.mapRef.current; if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible); }, onSpeedChange: (speed: number) => { if ([0.5, 1, 2].includes(speed)) setPlaybackSpeed(speed); }, onSeek: (id: number) => selectHistory(id, true) } }, executeCommand, runPreview, createSample, clearPreview: clear, clearMapOverlays, pauseCamera, resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); }, locateTargets, 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("定位覆盖层"); } } }; }