Files
next-tjwater-frontend/src/features/workbench/hooks/use-investigation-presentation.ts
T

639 lines
23 KiB
TypeScript

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<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();
});
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<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;
}
}
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<string | null>(null);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
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.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<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 "";
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("定位覆盖层");
}
}
};
}