diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx index 1b69d68..cecf6fd 100644 --- a/src/components/chat/AgentProgressTimeline.tsx +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -81,6 +81,7 @@ const formatToolTitle = (item: ChatProgress) => { if (text.includes("locate_features")) return "地图定位"; if (text.includes("view_history")) return "打开历史曲线"; if (text.includes("view_scada")) return "打开 SCADA 面板"; + if (text.includes("render_junctions")) return "渲染节点"; return item.title; }; diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx index a05a497..d396a76 100644 --- a/src/components/chat/ChatToolCallBlock.tsx +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -131,6 +131,12 @@ const TOOL_META: Record = { actionLabel: "显示", color: "#73c0de", }, + render_junctions: { + label: "渲染节点", + icon: , + actionLabel: "应用渲染", + color: "#3b82f6", + }, }; /* ---------- helpers ---------- */ @@ -261,6 +267,14 @@ function getToolDescription(toolCall: ToolCall): string { case "show_chart": { return (params.title as string | undefined) ?? "数据图表"; } + case "render_junctions": { + const nodeAreaMap = + params.node_area_map && typeof params.node_area_map === "object" + ? (params.node_area_map as Record) + : {}; + const areaIds = Array.isArray(params.area_ids) ? params.area_ids : []; + return `${Object.keys(nodeAreaMap).length} 个节点 · ${areaIds.length || new Set(Object.values(nodeAreaMap).map(String)).size} 个分区`; + } default: return ""; } @@ -383,6 +397,31 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { xAxisName: params.x_axis_name as string | undefined, yAxisName: params.y_axis_name as string | undefined, }; + case "render_junctions": { + const nodeAreaMap = + params.node_area_map && typeof params.node_area_map === "object" + ? Object.fromEntries( + Object.entries(params.node_area_map as Record) + .map(([key, value]) => [String(key), String(value ?? "")]) + .filter(([, value]) => value.trim().length > 0), + ) + : {}; + return { + type: "render_junctions", + nodeAreaMap, + areaIds: Array.isArray(params.area_ids) + ? params.area_ids.map((item) => String(item).trim()).filter(Boolean) + : [], + areaColors: + params.area_colors && typeof params.area_colors === "object" + ? Object.fromEntries( + Object.entries(params.area_colors as Record) + .map(([key, value]) => [String(key), String(value ?? "")]) + .filter(([, value]) => value.trim().length > 0), + ) + : {}, + }; + } default: return null; } diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts index 7d57e62..6017bb1 100644 --- a/src/components/chat/hooks/useAgentToolActions.ts +++ b/src/components/chat/hooks/useAgentToolActions.ts @@ -136,6 +136,26 @@ const resolveTimeRange = (params: Record) => ({ (params.end as string | undefined), }); +const resolveStringRecord = (value: unknown): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record) + .map(([key, recordValue]) => [String(key), String(recordValue ?? "")]) + .filter(([, recordValue]) => recordValue.trim().length > 0), + ); +}; + +const resolveStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value.map((item) => String(item).trim()).filter(Boolean); +}; + const compactNames = (names: string[]) => { if (!names.length) return ""; return names.length > 3 @@ -230,6 +250,24 @@ const buildToolAction = ( }; } + if (tool === "render_junctions") { + const nodeAreaMap = resolveStringRecord(params.node_area_map); + const areaIds = resolveStringArray(params.area_ids); + const areaColors = resolveStringRecord(params.area_colors); + + return { + action: { + type: "render_junctions", + nodeAreaMap, + areaIds, + areaColors, + }, + kind: "map", + title: "渲染节点分区", + description: `${Object.keys(nodeAreaMap).length} 个节点`, + }; + } + return { action: null, kind: "tool", diff --git a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx index 40d4c69..f3892d4 100644 --- a/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx +++ b/src/components/olmap/DMALeakDetection/DMALeakDetectionPanel.tsx @@ -17,18 +17,14 @@ import { ChevronRight, FormatListBulleted, } from "@mui/icons-material"; -import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; -import VectorTileSource from "ol/source/VectorTile"; -import { VectorTile } from "ol"; -import { FlatStyleLike } from "ol/style/flat"; import { useMap } from "@components/olmap/core/MapComponent"; import StyleLegend from "@components/olmap/core/Controls/StyleLegend"; import AnalysisParameters from "./AnalysisParameters"; import SchemeQuery from "./SchemeQuery"; import RecognitionResults from "./RecognitionResults"; +import { applyJunctionAreaRender } from "./applyJunctionAreaRender"; import { getAreaColor } from "./utils"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; -import { config } from "@/config/config"; const TabPanel = ({ value, @@ -82,101 +78,26 @@ const DMALeakDetectionPanel: React.FC = () => { useEffect(() => { if (!map) return; - const junctionLayer = map - .getAllLayers() - .find( - (layer) => - layer instanceof WebGLVectorTileLayer && layer.get("value") === "junctions", - ) as WebGLVectorTileLayer | undefined; - if (!junctionLayer) return; - const source = junctionLayer.getSource() as VectorTileSource; - if (!source) return; - - if (!loadedResult || !loadedResult.node_area_map) { - junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); - return; - } const fallbackAreaIds = Array.from( - new Set(Object.values(loadedResult.node_area_map || {}).map(String)), + new Set(Object.values(loadedResult?.node_area_map ?? {}).map(String)), ); - const areaIds = (loadedResult.areas || []).length - ? loadedResult.areas.map((area) => String(area.area_id)) + const areaIds = (loadedResult?.areas ?? []).length + ? (loadedResult?.areas ?? []).map((area) => String(area.area_id)) : fallbackAreaIds; - if (areaIds.length === 0) { - junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); - return; - } - - const areaIdToIndex = new Map(); - areaIds.forEach((areaId, index) => { - areaIdToIndex.set(areaId, index + 1); - }); - - const nodeAreaIndexMap = new Map(); - Object.entries(loadedResult.node_area_map || {}).forEach(([nodeId, areaId]) => { - const idx = areaIdToIndex.get(String(areaId)); - if (idx !== undefined) { - nodeAreaIndexMap.set(String(nodeId), idx); - } - }); - - const applyFeatureAreaIndex = (renderFeature: any) => { - const featureId = String(renderFeature.get("id") ?? ""); - const areaIndex = nodeAreaIndexMap.get(featureId); - if (areaIndex !== undefined) { - renderFeature.properties_[DMA_AREA_INDEX_PROPERTY] = areaIndex; - } - }; - - const sourceTiles = (source as any).sourceTiles_; - if (sourceTiles) { - Object.values(sourceTiles).forEach((vectorTile: any) => { - const renderFeatures = vectorTile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - applyFeatureAreaIndex(renderFeature); - }); - }); - } - - const listener = (event: any) => { - try { - if (event.tile instanceof VectorTile) { - const renderFeatures = event.tile.getFeatures(); - if (!renderFeatures || renderFeatures.length === 0) return; - renderFeatures.forEach((renderFeature: any) => { - applyFeatureAreaIndex(renderFeature); - }); - } - } catch (error) { - console.error("Error applying DMA area mapping:", error); - } - }; - source.on("tileloadend", listener); - - const fillCases: any[] = []; - areaIds.forEach((areaId, index) => { - fillCases.push( - ["==", ["get", DMA_AREA_INDEX_PROPERTY], index + 1], - getAreaColor(areaId), - ); - }); - const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]); - const defaultStrokeColor = String( - config.MAP_DEFAULT_STYLE["circle-stroke-color"], + const areaColors = Object.fromEntries( + areaIds.map((areaId) => [areaId, getAreaColor(areaId)]), ); - const dmaStyle: FlatStyleLike = { - ...config.MAP_DEFAULT_STYLE, - "circle-fill-color": ["case", ...fillCases, defaultFillColor], - "circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], - }; - junctionLayer.setStyle(dmaStyle); - return () => { - source.un("tileloadend", listener); - junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); - }; + return applyJunctionAreaRender( + map, + { + nodeAreaMap: loadedResult?.node_area_map ?? {}, + areaIds, + areaColors, + }, + { propertyKey: DMA_AREA_INDEX_PROPERTY }, + ); }, [map, loadedResult]); return ( diff --git a/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts b/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts new file mode 100644 index 0000000..bd3c7c4 --- /dev/null +++ b/src/components/olmap/DMALeakDetection/applyJunctionAreaRender.ts @@ -0,0 +1,147 @@ +import { Map as OlMap, VectorTile } from "ol"; +import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; +import VectorTileSource from "ol/source/VectorTile"; +import { FlatStyleLike } from "ol/style/flat"; + +import { config } from "@/config/config"; +import { getAreaColor } from "./utils"; + +const JUNCTION_LAYER_VALUE = "junctions"; +const RENDER_OWNER_KEY = "junction-area-render-owner"; + +export type JunctionAreaRenderPayload = { + nodeAreaMap: Record; + areaIds?: string[]; + areaColors?: Record; +}; + +type ApplyJunctionAreaRenderOptions = { + propertyKey?: string; +}; + +const DEFAULT_PROPERTY_KEY = "junction_area_render_index"; + +const getJunctionLayer = (map: OlMap) => + map + .getAllLayers() + .find( + (layer) => + layer instanceof WebGLVectorTileLayer && + layer.get("value") === JUNCTION_LAYER_VALUE, + ) as WebGLVectorTileLayer | undefined; + +export const applyJunctionAreaRender = ( + map: OlMap, + payload: JunctionAreaRenderPayload, + options: ApplyJunctionAreaRenderOptions = {}, +) => { + const propertyKey = options.propertyKey ?? DEFAULT_PROPERTY_KEY; + const junctionLayer = getJunctionLayer(map); + if (!junctionLayer) { + return () => {}; + } + + const source = junctionLayer.getSource() as VectorTileSource | null; + if (!source) { + return () => {}; + } + + const ownerId = `${propertyKey}-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`; + + const normalizedNodeAreaMap = Object.fromEntries( + Object.entries(payload.nodeAreaMap ?? {}).map(([nodeId, areaId]) => [ + String(nodeId), + String(areaId), + ]), + ); + + const areaIds = ( + payload.areaIds?.length + ? payload.areaIds + : Array.from(new Set(Object.values(normalizedNodeAreaMap))) + ) + .map(String) + .filter(Boolean); + + if (Object.keys(normalizedNodeAreaMap).length === 0 || areaIds.length === 0) { + junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); + return () => {}; + } + + const areaIdToIndex = new Map(); + areaIds.forEach((areaId, index) => { + areaIdToIndex.set(areaId, index + 1); + }); + + const nodeAreaIndexMap = new Map(); + Object.entries(normalizedNodeAreaMap).forEach(([nodeId, areaId]) => { + const areaIndex = areaIdToIndex.get(areaId); + if (areaIndex !== undefined) { + nodeAreaIndexMap.set(nodeId, areaIndex); + } + }); + + const applyFeatureAreaIndex = (renderFeature: any) => { + const featureId = String(renderFeature.get("id") ?? ""); + const areaIndex = nodeAreaIndexMap.get(featureId); + if (areaIndex !== undefined) { + renderFeature.properties_[propertyKey] = areaIndex; + } + }; + + const sourceTiles = (source as any).sourceTiles_; + if (sourceTiles) { + Object.values(sourceTiles).forEach((vectorTile: any) => { + const renderFeatures = vectorTile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) return; + renderFeatures.forEach((renderFeature: any) => { + applyFeatureAreaIndex(renderFeature); + }); + }); + } + + const listener = (event: any) => { + try { + if (!(event.tile instanceof VectorTile)) return; + const renderFeatures = event.tile.getFeatures(); + if (!renderFeatures || renderFeatures.length === 0) return; + renderFeatures.forEach((renderFeature: any) => { + applyFeatureAreaIndex(renderFeature); + }); + } catch (error) { + console.error("Error applying junction area render:", error); + } + }; + + source.on("tileloadend", listener); + + const fillCases: any[] = []; + areaIds.forEach((areaId, index) => { + fillCases.push( + ["==", ["get", propertyKey], index + 1], + payload.areaColors?.[areaId] ?? getAreaColor(areaId), + ); + }); + + const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]); + const defaultStrokeColor = String( + config.MAP_DEFAULT_STYLE["circle-stroke-color"], + ); + + junctionLayer.set(RENDER_OWNER_KEY, ownerId); + junctionLayer.setStyle({ + ...config.MAP_DEFAULT_STYLE, + "circle-fill-color": ["case", ...fillCases, defaultFillColor], + "circle-stroke-color": ["case", ...fillCases, defaultStrokeColor], + } as FlatStyleLike); + + return () => { + source.un("tileloadend", listener); + if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) { + junctionLayer.unset(RENDER_OWNER_KEY, true); + junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike); + } + }; +}; diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 58b6721..e2a0747 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { useData, useMap } from "../MapComponent"; import ToolbarButton from "@/components/olmap/common/ToolbarButton"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; @@ -24,6 +24,7 @@ import StyleLegend from "./StyleLegend"; // 引入图例组件 import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } from "@/utils/mapQueryService"; import { useNotification } from "@refinedev/core"; import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler"; +import { applyJunctionAreaRender } from "@components/olmap/DMALeakDetection/applyJunctionAreaRender"; import { config } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; @@ -81,8 +82,16 @@ const Toolbar: React.FC = ({ startTime?: string; endTime?: string; } | null>(null); + const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null); - // Wire up chat tool actions (locate, view_history, view_scada) + const disposeChatJunctionRender = useCallback(() => { + chatJunctionRenderCleanupRef.current?.(); + chatJunctionRenderCleanupRef.current = null; + }, []); + + useEffect(() => () => disposeChatJunctionRender(), [disposeChatJunctionRender]); + + // Wire up chat tool actions (locate, view_history, view_scada, render_junctions) useChatToolActionHandler( useCallback( (action) => { @@ -161,9 +170,29 @@ const Toolbar: React.FC = ({ }); break; } + case "render_junctions": { + disposeChatJunctionRender(); + + if (Object.keys(action.nodeAreaMap).length === 0) { + break; + } + + if (map) { + chatJunctionRenderCleanupRef.current = applyJunctionAreaRender( + map, + { + nodeAreaMap: action.nodeAreaMap, + areaIds: action.areaIds, + areaColors: action.areaColors, + }, + { propertyKey: "chat_junction_render_index" }, + ); + } + break; + } } }, - [map], + [disposeChatJunctionRender, map], ), ); diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index c226489..5d44ecf 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -34,6 +34,12 @@ export type ChatToolAction = series?: Array<{ name: string; data: number[]; type?: "line" | "bar" }>; xAxisName?: string; yAxisName?: string; + } + | { + type: "render_junctions"; + nodeAreaMap: Record; + areaIds?: string[]; + areaColors?: Record; }; interface ChatToolState {