fix: deduplicate workbench requests and remove unused map paths
This commit is contained in:
@@ -233,35 +233,11 @@ describe("Agent API client sessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when a candidate is not the agent service", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: [],
|
||||
components: [],
|
||||
actions: []
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
);
|
||||
it("uses the configured Agent address once and propagates its error", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
|
||||
schema_version: "agent-ui-registry@1",
|
||||
chart_grammars: [],
|
||||
components: [],
|
||||
actions: []
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://not-agent.local/api/v1/ui-registry",
|
||||
undefined
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
|
||||
await expect(createAgentApiClient("http://agent.local/prefix/").getUiRegistry()).rejects.toThrow("unavailable");
|
||||
expect(fetchMock).toHaveBeenCalledExactlyOnceWith("http://agent.local/prefix/api/v1/ui-registry", undefined);
|
||||
});
|
||||
|
||||
it("sends title update payloads using backend field names", async () => {
|
||||
|
||||
@@ -220,24 +220,16 @@ export type AgentApiClientOptions = {
|
||||
getProjectId?: () => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
|
||||
|
||||
const API_PATH = "/api/v1";
|
||||
|
||||
export function createAgentApiClient(
|
||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
||||
baseUrl: string = env.TJWATER_AGENT_URL,
|
||||
options: AgentApiClientOptions = {}
|
||||
): AgentApiClient {
|
||||
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
||||
let activeBaseUrl = candidates[0] ?? "";
|
||||
const setActiveBaseUrl = (baseUrl: string) => {
|
||||
activeBaseUrl = baseUrl;
|
||||
};
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
||||
const request = (path: string, init?: RequestInit) =>
|
||||
fetchWithFallback(
|
||||
candidates,
|
||||
activeBaseUrl,
|
||||
setActiveBaseUrl,
|
||||
fetchAgent(
|
||||
normalizedBaseUrl,
|
||||
path,
|
||||
init,
|
||||
options.getAccessToken,
|
||||
@@ -265,7 +257,7 @@ export function createAgentApiClient(
|
||||
return token;
|
||||
} : undefined;
|
||||
const answerStateRequest = async (path: string, init?: RequestInit) => {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, path, init,
|
||||
const response = await fetchAgent(normalizedBaseUrl, path, init,
|
||||
checkedAnswerStateToken, options.getProjectId);
|
||||
// An identity change while awaiting the network also invalidates the result.
|
||||
await checkedAnswerStateToken?.();
|
||||
@@ -275,7 +267,7 @@ export function createAgentApiClient(
|
||||
return {
|
||||
...createInteractionRequestApi(answerStateRequest),
|
||||
async getHealth(signal) {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
|
||||
const response = await fetchAgent(normalizedBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
|
||||
if (!response.ok) throw new Error("Agent 服务暂不可用");
|
||||
const data: unknown = await response.json();
|
||||
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
|
||||
@@ -579,19 +571,14 @@ export function createAgentApiClient(
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithFallback(
|
||||
baseUrls: string[],
|
||||
activeBaseUrl: string,
|
||||
setActiveBaseUrl: (baseUrl: string) => void,
|
||||
async function fetchAgent(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getProjectId?: () => Promise<string | undefined>,
|
||||
prefix = API_PATH
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
let lastResponse: Response | null = null;
|
||||
const accessToken = await getAccessToken?.();
|
||||
const projectId = await getProjectId?.();
|
||||
const hasContextHeaders = Boolean(accessToken || projectId);
|
||||
@@ -600,34 +587,7 @@ async function fetchWithFallback(
|
||||
if (projectId) headers?.set("X-Project-Id", projectId);
|
||||
const requestInit = hasContextHeaders ? { ...init, headers } : init;
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
}
|
||||
|
||||
lastResponse = response;
|
||||
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
|
||||
await response.body?.cancel();
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResponse) {
|
||||
return lastResponse;
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
||||
}
|
||||
|
||||
function shouldFallbackOnHttpStatus(status: number) {
|
||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||
return fetch(`${baseUrl}${prefix}${path}`, requestInit);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AgentApiClient } from "../api/client";
|
||||
|
||||
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
|
||||
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
|
||||
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false });
|
||||
([, id]) => client.listAgentMessages(id), { revalidateOnMount: false, revalidateOnFocus: true, shouldRetryOnError: false });
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const mutate = inbox.mutate;
|
||||
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
|
||||
@@ -15,20 +15,27 @@ export function useAgentMessageInbox(client: AgentApiClient, sessionId: string |
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryDelay = 1500;
|
||||
let initialized = false;
|
||||
// Prefer the initial stream notification, but do not wait indefinitely for its first frame.
|
||||
const initialLoadTimer = setTimeout(() => { initialized = true; refreshRef.current(); }, 1500);
|
||||
function subscribe() {
|
||||
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
|
||||
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
|
||||
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { initialized = true; clearTimeout(initialLoadTimer); retryDelay = 1500; refreshRef.current(); } })
|
||||
.catch(() => {
|
||||
// A failed subscription must not prevent the initial list from loading.
|
||||
if (!initialized && !controller.signal.aborted) { initialized = true; clearTimeout(initialLoadTimer); refreshRef.current(); }
|
||||
retryDelay = Math.min(retryDelay * 2, 30_000);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
|
||||
});
|
||||
}
|
||||
subscribe();
|
||||
return () => { controller.abort(); clearTimeout(timer); };
|
||||
return () => { controller.abort(); clearTimeout(timer); clearTimeout(initialLoadTimer); };
|
||||
}, [client, sessionId]);
|
||||
return {
|
||||
messages: inbox.data?.items ?? [],
|
||||
hasMore: Boolean(inbox.data?.next_cursor),
|
||||
loading: inbox.isLoading,
|
||||
loading: Boolean(sessionId) && (inbox.isLoading || (!inbox.data && !inbox.error)),
|
||||
error: inbox.error ? "任务消息暂时无法加载" : null,
|
||||
refresh,
|
||||
loadingMore,
|
||||
|
||||
@@ -138,8 +138,6 @@ function createCommands() {
|
||||
clearValueLabel: vi.fn(),
|
||||
applyLayerGroupStyle: vi.fn(),
|
||||
resetLayerGroupStyle: vi.fn(),
|
||||
renderScadaAnalysis: vi.fn(),
|
||||
clearScadaAnalysis: vi.fn(),
|
||||
resetAll: vi.fn()
|
||||
} satisfies WorkbenchMapCommands;
|
||||
}
|
||||
@@ -163,7 +161,6 @@ function createControllerState(
|
||||
invalid: 0
|
||||
},
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null,
|
||||
...patch
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function useOperationalTasks(
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||
const [eventRetryGeneration, setEventRetryGeneration] = useState(0);
|
||||
const enqueueEvents = useRef<(events: WorkbenchEvent[]) => void>(() => {});
|
||||
const eventRetryMsRef = useRef(1_000);
|
||||
const forceRefreshRef = useRef(false);
|
||||
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
|
||||
@@ -131,50 +131,76 @@ export function useOperationalTasks(
|
||||
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const events = syncEvents;
|
||||
if (events.length === 0) return;
|
||||
const controller = new AbortController();
|
||||
let retryTimer: number | undefined;
|
||||
const lastSequence = events.at(-1)!.event_seq;
|
||||
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
|
||||
if (deletedTaskIds.size > 0) {
|
||||
let pendingEvents: WorkbenchEvent[] = [];
|
||||
let appliedSequence = 0;
|
||||
let processing = false;
|
||||
const removeDeletedTasks = (deletedTaskIds: Set<string>) => {
|
||||
if (deletedTaskIds.size === 0) return;
|
||||
tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id));
|
||||
setTasks(tasksRef.current);
|
||||
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
|
||||
}
|
||||
void Promise.all([
|
||||
notificationsChanged
|
||||
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
||||
: Promise.resolve(null),
|
||||
Promise.all([...taskIds].map((taskId) =>
|
||||
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
||||
))
|
||||
]).then(([nextNotifications, changedTasks]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (nextNotifications) setNotifications(nextNotifications);
|
||||
if (changedTasks.length > 0) {
|
||||
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
|
||||
tasksRef.current = [
|
||||
...changedTasks,
|
||||
...tasksRef.current.filter((task) => !changed.has(task.task_id))
|
||||
];
|
||||
setTasks(tasksRef.current);
|
||||
}
|
||||
acknowledgeEventsThrough(lastSequence);
|
||||
eventRetryMsRef.current = 1_000;
|
||||
setError(null);
|
||||
}).catch((nextError) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
||||
retryTimer = window.setTimeout(() => setEventRetryGeneration((current) => current + 1), eventRetryMsRef.current);
|
||||
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
|
||||
}
|
||||
});
|
||||
};
|
||||
const load = async () => {
|
||||
if (processing || controller.signal.aborted) return;
|
||||
processing = true;
|
||||
window.clearTimeout(retryTimer);
|
||||
try {
|
||||
while (pendingEvents.length > 0 && !controller.signal.aborted) {
|
||||
const events = pendingEvents;
|
||||
const lastSequence = events.at(-1)!.event_seq;
|
||||
const { taskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
|
||||
const [nextNotifications, changedTasks] = await Promise.all([
|
||||
notificationsChanged
|
||||
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
||||
: Promise.resolve(null),
|
||||
Promise.all([...taskIds].map((taskId) =>
|
||||
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
||||
))
|
||||
]);
|
||||
if (controller.signal.aborted) return;
|
||||
// A deletion received during this read must not be undone by its response.
|
||||
const { deletedTaskIds } = collectOperationalTaskEventChanges(pendingEvents);
|
||||
if (nextNotifications) setNotifications(nextNotifications.filter((item) => !deletedTaskIds.has(item.task_id)));
|
||||
const visibleTasks = changedTasks.filter((task) => !deletedTaskIds.has(task.task_id));
|
||||
if (visibleTasks.length > 0) {
|
||||
const changed = new Map(visibleTasks.map((task) => [task.task_id, task]));
|
||||
tasksRef.current = [
|
||||
...visibleTasks,
|
||||
...tasksRef.current.filter((task) => !changed.has(task.task_id))
|
||||
];
|
||||
setTasks(tasksRef.current);
|
||||
}
|
||||
appliedSequence = lastSequence;
|
||||
pendingEvents = pendingEvents.filter((event) => event.event_seq > appliedSequence);
|
||||
acknowledgeEventsThrough(lastSequence);
|
||||
eventRetryMsRef.current = 1_000;
|
||||
setError(null);
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
||||
retryTimer = window.setTimeout(() => void load(), eventRetryMsRef.current);
|
||||
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
|
||||
}
|
||||
} finally { processing = false; }
|
||||
};
|
||||
enqueueEvents.current = (events) => {
|
||||
pendingEvents = events.filter((event) => event.event_seq > appliedSequence);
|
||||
removeDeletedTasks(collectOperationalTaskEventChanges(pendingEvents).deletedTaskIds);
|
||||
void load();
|
||||
};
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
enqueueEvents.current = () => {};
|
||||
window.clearTimeout(retryTimer);
|
||||
};
|
||||
}, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]);
|
||||
}, [acknowledgeEventsThrough, getAccessToken, sync.detailResetGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
enqueueEvents.current(syncEvents);
|
||||
}, [acknowledgeEventsThrough, getAccessToken, sync.detailResetGeneration, syncEvents]);
|
||||
|
||||
const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task),
|
||||
hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated"
|
||||
|
||||
@@ -531,12 +531,15 @@ export function useWorkbenchAgent({
|
||||
() => clientRef.current.listResponseInputs(sessionId!),
|
||||
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
|
||||
);
|
||||
const handledRecovery = useRef({ scopeKey, version: 0 });
|
||||
useEffect(() => {
|
||||
if (handledRecovery.current.scopeKey === scopeKey && handledRecovery.current.version === recoveryVersion) return;
|
||||
handledRecovery.current = { scopeKey, version: recoveryVersion };
|
||||
if (!recoveryVersion) return;
|
||||
void refreshBootstrap();
|
||||
void refreshInteractionRequests();
|
||||
void refreshPendingInputs();
|
||||
}, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
|
||||
}, [scopeKey, recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
|
||||
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
|
||||
if (!sessionIdRef.current) return;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SWRConfig } from "swr";
|
||||
const harness = vi.hoisted(() => ({
|
||||
api: null as any,
|
||||
recovery: 0,
|
||||
flow: vi.fn(),
|
||||
task: vi.fn(),
|
||||
check: vi.fn()
|
||||
}));
|
||||
vi.mock("@/features/agent", async (original) => ({
|
||||
...(await original<any>()),
|
||||
createAgentApiClient: () => harness.api
|
||||
}));
|
||||
vi.mock("@/features/agent/hooks/use-agent-connection", () => ({
|
||||
useAgentConnection: () => ({
|
||||
connectionStatus: "online",
|
||||
recoveryVersion: harness.recovery,
|
||||
recheck: harness.check
|
||||
})
|
||||
}));
|
||||
vi.mock("@/features/workbench/api/realtime-flow-client", () => ({
|
||||
fetchRealtimePipeFlows: harness.flow
|
||||
}));
|
||||
vi.mock("@/features/workbench/api/operational-task-client", async (original) => ({
|
||||
...(await original<any>()),
|
||||
fetchOperationalTask: harness.task,
|
||||
fetchOperationalTaskNotifications: async () => []
|
||||
}));
|
||||
import { useWorkbenchRuntimeData } from "@/features/workbench/hooks/use-workbench-runtime-data";
|
||||
import { useWorkbenchAgent } from "@/features/workbench/hooks/use-workbench-agent";
|
||||
import { useOperationalTasks } from "@/features/workbench/hooks/use-operational-tasks";
|
||||
import { WorkbenchSessionProvider } from "@/features/workbench/hooks/workbench-session-provider";
|
||||
import { useAgentMessageInbox } from "@/features/agent/hooks/use-agent-message-inbox";
|
||||
const access: any = {
|
||||
project: { project_id: "p", gs_workspace: "tjwater_next", status: "active" },
|
||||
getAccessToken: async () => "test"
|
||||
};
|
||||
const sync: any = {
|
||||
snapshot: null,
|
||||
events: [],
|
||||
status: "live",
|
||||
error: null,
|
||||
detailResetGeneration: 0,
|
||||
acknowledgeEventsThrough: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
subscribePresentationEvents: () => () => {}
|
||||
};
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
harness.flow.mockReset();
|
||||
harness.task.mockReset();
|
||||
harness.recovery = 0;
|
||||
});
|
||||
function wrapperFactory() {
|
||||
const cache = new Map();
|
||||
return ({ children }: any) => (
|
||||
<WorkbenchSessionProvider owner={access}>
|
||||
<SWRConfig
|
||||
value={{ provider: () => cache, revalidateOnFocus: false, shouldRetryOnError: false }}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
</WorkbenchSessionProvider>
|
||||
);
|
||||
}
|
||||
it("keeps one flow request and one polling timer when visibility changes during a request", async () => {
|
||||
vi.useFakeTimers();
|
||||
let visibility = "visible";
|
||||
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility as any);
|
||||
const done: Array<() => void> = [];
|
||||
harness.flow.mockImplementation(
|
||||
() => new Promise((resolve) => done.push(() => resolve({ records: [], observedAt: null })))
|
||||
);
|
||||
renderHook(() => useWorkbenchRuntimeData(access, sync));
|
||||
expect(harness.flow).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
visibility = "hidden";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
visibility = "visible";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(harness.flow).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
done[0]();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
});
|
||||
expect(harness.flow).toHaveBeenCalledTimes(2);
|
||||
await act(async () => {
|
||||
done[1]();
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
});
|
||||
expect(harness.flow).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it("queues new task events without cancelling or repeating the current detail request", async () => {
|
||||
const resolve: Array<(value: unknown) => void> = [];
|
||||
harness.task.mockImplementation(() => new Promise((done) => resolve.push(done)));
|
||||
const event = (seq: number, id: string) => ({
|
||||
event_seq: seq,
|
||||
topic: "operational.progress.changed",
|
||||
aggregate_type: "operational_task",
|
||||
aggregate_id: id,
|
||||
metadata: { task_id: id }
|
||||
});
|
||||
const first = event(1, "task-a");
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ events }) => useOperationalTasks(access, { ...sync, events }),
|
||||
{ initialProps: { events: [first] } }
|
||||
);
|
||||
rerender({ events: [first, event(2, "task-b")] });
|
||||
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a"]);
|
||||
expect(harness.task.mock.calls[0][2].aborted).toBe(false);
|
||||
await act(async () => {
|
||||
resolve[0]({
|
||||
task_id: "task-a",
|
||||
title: "A",
|
||||
trigger: { kind: "manual" },
|
||||
source: { kind: "operator" },
|
||||
task_type: "event_investigation",
|
||||
evidence: [],
|
||||
updated_at: "2026-11-09T00:00:00Z"
|
||||
});
|
||||
});
|
||||
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a", "task-b"]);
|
||||
await act(async () => {
|
||||
resolve[1]({
|
||||
task_id: "task-b",
|
||||
title: "B",
|
||||
trigger: { kind: "manual" },
|
||||
source: { kind: "operator" },
|
||||
task_type: "event_investigation",
|
||||
evidence: [],
|
||||
updated_at: "2026-11-09T00:00:00Z"
|
||||
});
|
||||
});
|
||||
expect(result.current.tasks.map((task) => task.task_id).sort()).toEqual(["task-a", "task-b"]);
|
||||
expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(2);
|
||||
unmount();
|
||||
});
|
||||
it("refreshes once per health recovery and uses normal loads on session changes", async () => {
|
||||
const api: any = {
|
||||
getModels: vi.fn(async () => ({ models: [], defaultModel: "" })),
|
||||
getUiRegistry: vi.fn(async () => ({})),
|
||||
getFrontendActionRegistry: vi.fn(async () => ({})),
|
||||
listSessionPage: vi.fn(async () => ({ sessions: [], hasMore: false, nextCursor: null })),
|
||||
listTopics: vi.fn(async () => []),
|
||||
listInteractionRequests: vi.fn(async () => ({ items: [] })),
|
||||
listResponseInputs: vi.fn(async () => []),
|
||||
listAgentMessages: vi.fn(async () => ({ items: [], next_cursor: null })),
|
||||
streamAgentMessages: vi.fn(
|
||||
(_id: any, options: any) =>
|
||||
new Promise<void>((resolve) =>
|
||||
options.signal.addEventListener("abort", () => resolve(), { once: true })
|
||||
)
|
||||
),
|
||||
loadSession: vi.fn(async (id: string) => ({
|
||||
id,
|
||||
sessionId: id,
|
||||
title: id,
|
||||
purpose: "ordinary_chat",
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
runStatus: "completed",
|
||||
isStreaming: false,
|
||||
isTitleManuallyEdited: false
|
||||
}))
|
||||
};
|
||||
harness.api = api;
|
||||
const { result, rerender } = renderHook(
|
||||
() =>
|
||||
useWorkbenchAgent({
|
||||
getAccessToken: access,
|
||||
onUiEnvelope: () => {},
|
||||
onFrontendAction: async () => ({})
|
||||
}),
|
||||
{ wrapper: wrapperFactory() }
|
||||
);
|
||||
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
await result.current.loadHistorySession("a");
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "a").length).toBe(1)
|
||||
);
|
||||
harness.recovery = 1;
|
||||
rerender();
|
||||
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(2));
|
||||
const before = api.getModels.mock.calls.length;
|
||||
await act(async () => {
|
||||
await result.current.loadHistorySession("b");
|
||||
});
|
||||
await waitFor(() => expect(api.getModels.mock.calls.length).toBe(before));
|
||||
await waitFor(() =>
|
||||
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "b").length).toBe(1)
|
||||
);
|
||||
const pending = api.listResponseInputs.mock.calls.filter((c: any) => c[0] === "b").length;
|
||||
expect(pending).toBe(1);
|
||||
});
|
||||
it("loads the inbox once when the stream is ready and refreshes on later notifications", async () => {
|
||||
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||
const stream = vi.fn((_id: any, options: any) => {
|
||||
options.onMessage();
|
||||
return new Promise<void>((resolve) =>
|
||||
options.signal.addEventListener("abort", () => resolve(), { once: true })
|
||||
);
|
||||
});
|
||||
const api = { listAgentMessages: list, streamAgentMessages: stream } as any;
|
||||
renderHook(() => useAgentMessageInbox(api, "s"), { wrapper: wrapperFactory() });
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
stream.mock.calls[0][1].onMessage();
|
||||
});
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("keeps newer task changes and does not revive a task deleted during a read", async () => {
|
||||
const complete: Array<(value: unknown) => void> = [];
|
||||
harness.task.mockImplementation(() => new Promise((resolve) => complete.push(resolve)));
|
||||
const event = (seq: number, deleted = false) => ({
|
||||
event_seq: seq,
|
||||
topic: "operational.task.changed",
|
||||
aggregate_type: "operational_task",
|
||||
aggregate_id: "a",
|
||||
metadata: { task_id: "a", ...(deleted ? { change: "deleted" } : {}) }
|
||||
});
|
||||
const a = event(1);
|
||||
const { result, rerender } = renderHook(
|
||||
({ events }) => useOperationalTasks(access, { ...sync, events }),
|
||||
{
|
||||
initialProps: { events: [a] }
|
||||
}
|
||||
);
|
||||
rerender({ events: [a, event(2)] });
|
||||
const task = {
|
||||
task_id: "a",
|
||||
title: "A",
|
||||
trigger: { kind: "manual" },
|
||||
source: { kind: "operator" },
|
||||
task_type: "event_investigation",
|
||||
evidence: [],
|
||||
updated_at: "2026-11-09T00:00:00Z"
|
||||
};
|
||||
await act(async () => {
|
||||
complete[0](task);
|
||||
});
|
||||
expect(harness.task).toHaveBeenCalledTimes(2);
|
||||
rerender({ events: [a, event(2), event(3, true)] });
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
await act(async () => {
|
||||
complete[1](task);
|
||||
});
|
||||
await waitFor(() => expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(3));
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
expect(harness.task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("loads the initial inbox even when its stream cannot connect", async () => {
|
||||
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||
const api = {
|
||||
listAgentMessages: list,
|
||||
streamAgentMessages: vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
})
|
||||
} as any;
|
||||
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
|
||||
wrapper: wrapperFactory()
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads the inbox when its stream remains pending without a first frame", async () => {
|
||||
vi.useFakeTimers();
|
||||
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
|
||||
const api = {
|
||||
listAgentMessages: list,
|
||||
streamAgentMessages: vi.fn(
|
||||
(_id: string, options: { signal: AbortSignal }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
options.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
})
|
||||
)
|
||||
} as any;
|
||||
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
|
||||
wrapper: wrapperFactory()
|
||||
});
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
});
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
@@ -177,12 +177,15 @@ export function useWorkbenchRuntimeData(
|
||||
const controller = new AbortController();
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
let refreshing = false;
|
||||
const refreshRealtimePipeFlows = async () => {
|
||||
if (controller.signal.aborted || refreshing) return;
|
||||
if (document.visibilityState === "hidden") {
|
||||
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
refreshing = true;
|
||||
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
|
||||
if (!controller.signal.aborted) {
|
||||
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
|
||||
@@ -190,6 +193,7 @@ export function useWorkbenchRuntimeData(
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setRealtimePipeFlows(null);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (!controller.signal.aborted) {
|
||||
timeoutId = window.setTimeout(
|
||||
refreshRealtimePipeFlows,
|
||||
|
||||
@@ -956,19 +956,15 @@ function MapWorkbenchContent({
|
||||
}
|
||||
|
||||
async function handleStartNewAgentSession() {
|
||||
mapController.clearScadaAnalysis();
|
||||
await agent.startNewSession();
|
||||
}
|
||||
|
||||
async function handleLoadAgentHistorySession(sessionId: string) {
|
||||
const changesSession = sessionId !== agent.sessionId;
|
||||
const loaded = await agent.loadHistorySession(sessionId);
|
||||
if (!loaded) throw new Error("会话打开已取消");
|
||||
if (changesSession) mapController.clearScadaAnalysis();
|
||||
}
|
||||
|
||||
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
|
||||
mapController.clearScadaAnalysis();
|
||||
const id = await agent.openTaskCollaboration(taskId, discussion);
|
||||
if (discussion?.reference && id) agent.setContentReference(discussion.reference);
|
||||
readingWorkspace?.close();
|
||||
@@ -980,7 +976,6 @@ function MapWorkbenchContent({
|
||||
target: TaskExecutionHistoryTarget,
|
||||
knownRunId?: string
|
||||
) {
|
||||
mapController.clearScadaAnalysis();
|
||||
let runId = knownRunId;
|
||||
if (!runId) {
|
||||
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
|
||||
@@ -999,12 +994,8 @@ function MapWorkbenchContent({
|
||||
}
|
||||
|
||||
async function handleDeleteAgentHistorySession(sessionId: string) {
|
||||
const clearsActiveAnalysis = sessionId === agent.sessionId;
|
||||
const deleted = await agent.deleteHistorySession(sessionId);
|
||||
if (!deleted) throw new Error("会话删除失败,请重试");
|
||||
if (deleted && clearsActiveAnalysis) {
|
||||
mapController.clearScadaAnalysis();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgentMapAction(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
||||
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
|
||||
@@ -121,19 +121,6 @@ export const scadaAnalysisLayers: Layer[] = [
|
||||
}
|
||||
];
|
||||
|
||||
export function ensureScadaAnalysisLayers(
|
||||
map: Pick<MapLibreMap, "addLayer" | "getLayer">
|
||||
): void {
|
||||
scadaAnalysisLayers.forEach((layer, index) => {
|
||||
if (map.getLayer(layer.id)) return;
|
||||
const beforeId = scadaAnalysisLayers
|
||||
.slice(index + 1)
|
||||
.map((candidate) => candidate.id)
|
||||
.find((candidateId) => Boolean(map.getLayer(candidateId)));
|
||||
map.addLayer(layer, beforeId);
|
||||
});
|
||||
}
|
||||
|
||||
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
|
||||
return { type: "FeatureCollection", features: [] };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
|
||||
|
||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
|
||||
import { SCADA_SOURCE_ID } from "./scada";
|
||||
|
||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||
@@ -325,10 +324,6 @@ export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): S
|
||||
type: "geojson",
|
||||
promoteId: "device_id",
|
||||
data: { type: "FeatureCollection", features: [] }
|
||||
},
|
||||
[SCADA_ANALYSIS_SOURCE_ID]: {
|
||||
type: "geojson",
|
||||
data: createEmptyScadaAnalysisCollection()
|
||||
}
|
||||
};
|
||||
const layers: StyleSpecification["layers"] = [
|
||||
|
||||
@@ -20,29 +20,23 @@ describe("WorkbenchMapController", () => {
|
||||
expect(map.easeTo).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().targets).toEqual([]);
|
||||
});
|
||||
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
|
||||
it("clears controller overlays and preserves layer styles", async () => {
|
||||
const map = createMap();
|
||||
map.getLayer.mockReturnValue({} as never);
|
||||
const setData = vi.fn();
|
||||
map.getSource.mockReturnValue({ setData });
|
||||
let resolveFeatures = (_value: FeatureCollection) => {};
|
||||
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
|
||||
const controller = new WorkbenchMapController({
|
||||
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
|
||||
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
fetchFeatures: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection)
|
||||
fetchFeatures: async () => pointCollection
|
||||
});
|
||||
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
|
||||
const showingFlow = controller.setFlowVisible(true);
|
||||
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
|
||||
controller.clearOverlays();
|
||||
await showingFlow;
|
||||
resolveFeatures(pointCollection);
|
||||
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
|
||||
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
target: null, targets: [], targetResult: null, scadaAnalysis: null,
|
||||
target: null, targets: [], targetResult: null,
|
||||
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
|
||||
});
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
|
||||
@@ -552,37 +546,7 @@ describe("WorkbenchMapController", () => {
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders SCADA analysis through the registered GeoJSON source", async () => {
|
||||
const map = createMap();
|
||||
const source = { setData: vi.fn() };
|
||||
map.getSource.mockReturnValue(source);
|
||||
const controller = createController(map, {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: [120.7, 28] },
|
||||
properties: { device_id: "MP01" }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
|
||||
).resolves.toMatchObject({
|
||||
rendered_ids: ["MP01"],
|
||||
fitted: true
|
||||
});
|
||||
expect(source.setData).toHaveBeenCalled();
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
errorCode: null,
|
||||
pending: false,
|
||||
scadaAnalysis: {
|
||||
rendered_ids: ["MP01"],
|
||||
fitted: true
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
|
||||
import {
|
||||
@@ -36,14 +36,7 @@ import {
|
||||
type ValueLabelSourceId,
|
||||
type ValueLabelStyle
|
||||
} from "./value-label";
|
||||
import {
|
||||
SCADA_ANALYSIS_SOURCE_ID,
|
||||
createEmptyScadaAnalysisCollection,
|
||||
createScadaAnalysisCollection,
|
||||
ensureScadaAnalysisLayers,
|
||||
type ScadaAnalysisItem,
|
||||
type ScadaAnalysisRenderResult
|
||||
} from "./scada-analysis";
|
||||
|
||||
|
||||
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
|
||||
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
|
||||
@@ -56,7 +49,6 @@ export type WorkbenchMapErrorCode =
|
||||
| "INVALID_FEATURE_IDS"
|
||||
| "WFS_UNAVAILABLE"
|
||||
| "SCADA_API_UNAVAILABLE"
|
||||
| "SCADA_FEATURES_NOT_FOUND"
|
||||
| "ACTION_SUPERSEDED"
|
||||
| "FLOW_UNAVAILABLE"
|
||||
| "INVALID_FLOW_CALCULATION"
|
||||
@@ -72,7 +64,6 @@ export type WorkbenchMapControllerState = {
|
||||
flowVisible: boolean;
|
||||
flowSummary: PipeFlowSummary;
|
||||
styledGroups: LayerGroupId[];
|
||||
scadaAnalysis: ScadaAnalysisRenderResult | null;
|
||||
};
|
||||
|
||||
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
|
||||
@@ -89,8 +80,6 @@ export type WorkbenchMapCommands = {
|
||||
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
|
||||
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
|
||||
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
|
||||
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
|
||||
clearScadaAnalysis: () => { cleared: true };
|
||||
resetAll: () => void;
|
||||
};
|
||||
|
||||
@@ -114,15 +103,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null
|
||||
styledGroups: []
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private cachedFeatures = new Map<string, Feature>();
|
||||
private flowState: FlowOverlayState = createFlowOverlayState();
|
||||
private flowUpdateRevision = 0;
|
||||
private flowVisibilityRevision = 0;
|
||||
private scadaAnalysisRevision = 0;
|
||||
private targetRequestRevision = 0;
|
||||
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
|
||||
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
|
||||
@@ -399,125 +386,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
|
||||
}
|
||||
|
||||
async renderScadaAnalysis(
|
||||
items: ScadaAnalysisItem[],
|
||||
signal?: AbortSignal
|
||||
): Promise<ScadaAnalysisRenderResult> {
|
||||
const revision = ++this.scadaAnalysisRevision;
|
||||
const map = this.options.getMap();
|
||||
if (!map || !this.options.isReady()) {
|
||||
this.setError("MAP_NOT_READY");
|
||||
throw new Error("MAP_NOT_READY");
|
||||
}
|
||||
this.patchState({ pending: true, errorCode: null });
|
||||
|
||||
let sourceCollection: FeatureCollection;
|
||||
try {
|
||||
sourceCollection = await this.fetchFeatures(
|
||||
"scada",
|
||||
items.map((item) => item.device_id),
|
||||
signal
|
||||
);
|
||||
} catch {
|
||||
if (revision === this.scadaAnalysisRevision) {
|
||||
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
|
||||
}
|
||||
throw new Error(
|
||||
revision === this.scadaAnalysisRevision
|
||||
? "SCADA_API_UNAVAILABLE"
|
||||
: "ACTION_SUPERSEDED"
|
||||
);
|
||||
}
|
||||
if (revision !== this.scadaAnalysisRevision) {
|
||||
throw new Error("ACTION_SUPERSEDED");
|
||||
}
|
||||
|
||||
const next = createScadaAnalysisCollection(sourceCollection, items);
|
||||
if (next.collection.features.length === 0) {
|
||||
this.patchState({
|
||||
pending: false,
|
||||
errorCode: "SCADA_FEATURES_NOT_FOUND"
|
||||
});
|
||||
throw new Error("SCADA_FEATURES_NOT_FOUND");
|
||||
}
|
||||
const source = map.getSource(
|
||||
SCADA_ANALYSIS_SOURCE_ID
|
||||
) as GeoJSONSource | undefined;
|
||||
if (!source) {
|
||||
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
|
||||
throw new Error("MAP_NOT_READY");
|
||||
}
|
||||
|
||||
try {
|
||||
ensureScadaAnalysisLayers(map);
|
||||
source.setData(next.collection);
|
||||
} catch {
|
||||
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
|
||||
throw new Error("MAP_NOT_READY");
|
||||
}
|
||||
|
||||
const result: ScadaAnalysisRenderResult = {
|
||||
...next.result,
|
||||
fitted: true
|
||||
};
|
||||
this.patchState({
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
scadaAnalysis: result
|
||||
});
|
||||
const coordinates = next.collection.features.map(
|
||||
(feature) => feature.geometry.coordinates
|
||||
);
|
||||
if (coordinates.length === 1) {
|
||||
map.easeTo({
|
||||
center: [coordinates[0][0], coordinates[0][1]],
|
||||
zoom: 18,
|
||||
padding: this.options.getPadding(),
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
});
|
||||
} else {
|
||||
const bounds = coordinates.reduce<[number, number, number, number]>(
|
||||
(value, coordinate) => [
|
||||
Math.min(value[0], coordinate[0]),
|
||||
Math.min(value[1], coordinate[1]),
|
||||
Math.max(value[2], coordinate[0]),
|
||||
Math.max(value[3], coordinate[1])
|
||||
],
|
||||
[Infinity, Infinity, -Infinity, -Infinity]
|
||||
);
|
||||
map.fitBounds(
|
||||
[
|
||||
[bounds[0], bounds[1]],
|
||||
[bounds[2], bounds[3]]
|
||||
],
|
||||
{
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
}
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
clearScadaAnalysis() {
|
||||
this.scadaAnalysisRevision += 1;
|
||||
const map = this.options.getMap();
|
||||
(map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
createEmptyScadaAnalysisCollection()
|
||||
);
|
||||
this.patchState({ scadaAnalysis: null, pending: false, errorCode: null });
|
||||
return { cleared: true as const };
|
||||
}
|
||||
|
||||
clearOverlays = () => {
|
||||
this.clearHighlight();
|
||||
this.clearValueLabel();
|
||||
this.clearScadaAnalysis();
|
||||
this.flowVisibilityRevision += 1;
|
||||
const map = this.options.getMap();
|
||||
if (map) hideFlowOverlay(map, this.flowState);
|
||||
this.patchState({ flowVisible: false });
|
||||
this.patchState({ flowVisible: false, pending: false, errorCode: null });
|
||||
};
|
||||
|
||||
resetAll = () => {
|
||||
@@ -536,8 +411,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null
|
||||
styledGroups: []
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user