前端项目结构调整

This commit is contained in:
JIANG
2026-03-10 11:04:30 +08:00
parent 7f25bd34d5
commit 520e1cb3f1
52 changed files with 242 additions and 345 deletions
@@ -20,7 +20,7 @@ import {
Map as MapIcon,
} from "@mui/icons-material";
import dayjs from "dayjs";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import Feature from "ol/Feature";
@@ -16,7 +16,7 @@ import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; // 引入中文包
import dayjs, { Dayjs } from "dayjs";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Stroke, Icon } from "ol/style";
@@ -13,7 +13,7 @@ import {
LocationOn as LocationIcon,
} from "@mui/icons-material";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
@@ -32,7 +32,7 @@ import { config, NETWORK_NAME } from "@config/config";
import { useNotification } from "@refinedev/core";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useData, useMap } from "@app/OlMap/MapComponent";
import { useData, useMap } from "@components/olmap/core/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
@@ -48,7 +48,7 @@ import {
} from "@turf/turf";
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import Timeline from "@app/OlMap/Controls/Timeline";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemaItem, SchemeRecord } from "./types";
interface SchemeQueryProps {
@@ -35,7 +35,7 @@ import {
queryFeaturesByIds,
handleMapClickSelectFeatures,
} from "@/utils/mapQueryService";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
@@ -19,7 +19,7 @@ import dayjs, { Dayjs } from "dayjs";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Stroke, Fill, Circle as CircleStyle, Icon } from "ol/style";
@@ -30,14 +30,14 @@ import moment from "moment";
import { useNotification } from "@refinedev/core";
import { config, NETWORK_NAME } from "@config/config";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useData, useMap } from "@app/OlMap/MapComponent";
import { useData, useMap } from "@components/olmap/core/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@app/OlMap/Controls/Timeline";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
interface SchemeQueryProps {
@@ -19,7 +19,7 @@ import {
} from "@mui/icons-material";
import ContaminantAnalysisParameters from "./AnalysisParameters";
import ContaminantSchemeQuery from "./SchemeQuery";
import { useData } from "@app/OlMap/MapComponent";
import { useData } from "@components/olmap/core/MapComponent";
interface WaterQualityPanelProps {
open?: boolean;
@@ -21,8 +21,8 @@ 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 "@app/OlMap/MapComponent";
import StyleLegend from "@app/OlMap/Controls/StyleLegend";
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";
@@ -18,7 +18,7 @@ import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Style, Stroke, Fill, Circle as CircleStyle } from "ol/style";
@@ -30,7 +30,7 @@ import { api } from "@/lib/api";
import moment from "moment";
import { config, NETWORK_NAME } from "@config/config";
import { useNotification } from "@refinedev/core";
import { useData, useMap } from "@app/OlMap/MapComponent";
import { useData, useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
@@ -38,7 +38,7 @@ import VectorSource from "ol/source/Vector";
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@app/OlMap/Controls/Timeline";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemeRecord, SchemaItem } from "./types";
import { FLOW_DISPLAY_UNIT } from "@utils/units";
@@ -27,10 +27,10 @@ import dayjs from "dayjs";
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
import { TbArrowBackUp, TbArrowForwardUp } from "react-icons/tb";
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
import { useData } from "../../../app/OlMap/MapComponent";
import { useData } from "@components/olmap/core/MapComponent";
import { config, NETWORK_NAME } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { useMap } from "../../../app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { useHealthRisk } from "./HealthRiskContext";
import {
PredictionResult,
@@ -28,7 +28,7 @@ import { api } from "@/lib/api";
import moment from "moment";
import { config, NETWORK_NAME } from "@config/config";
import { useNotification } from "@refinedev/core";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
@@ -6,7 +6,7 @@ import Fill from "ol/style/Fill";
import { Stroke } from "ol/style";
import GeoJson from "ol/format/GeoJSON";
import config from "@config/config";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { useProject } from "@/contexts/ProjectContext";
interface PropertyItem {
@@ -52,7 +52,7 @@ import { api } from "@/lib/api";
import { useGetIdentity } from "@refinedev/core";
import config from "@/config/config";
import { useMap } from "@app/OlMap/MapComponent";
import { useMap } from "@components/olmap/core/MapComponent";
import { useProject } from "@/contexts/ProjectContext";
import { GeoJSON } from "ol/format";
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
@@ -0,0 +1,251 @@
import React, { useState, useEffect } from "react";
import { useMap } from "../MapComponent";
import TileLayer from "ol/layer/Tile.js";
import XYZ from "ol/source/XYZ.js";
import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png";
import mapboxLight from "@assets/map/layers/mapbox-light.png";
import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png";
import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png";
import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
import clsx from "clsx";
import Group from "ol/layer/Group";
import { MAPBOX_TOKEN } from "@config/config";
import { TIANDITU_TOKEN } from "@config/config";
const INITIAL_LAYER = "mapbox-light";
const streetsLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const lightMapLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const satelliteLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const satelliteStreetsLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const tiandituVectorLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituVectorAnnotationLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituImageLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituImageAnnotationLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
projection: "EPSG:3857",
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituVectorLayerGroup = new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
});
const tiandituImageLayerGroup = new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
});
const baseLayers = [
{
id: "mapbox-light",
name: "默认地图",
layer: lightMapLayer,
// layer: tiandituVectorLayerGroup,
img: mapboxLight.src,
},
{
id: "mapbox-satellite",
name: "卫星地图",
layer: satelliteLayer,
// layer: tiandituImageLayerGroup,
img: mapboxSatellite.src,
},
{
id: "mapbox-satellite-streets",
name: "卫星街道地图",
layer: satelliteStreetsLayer,
img: mapboxSatelliteStreet.src,
},
{
id: "mapbox-streets",
name: "街道地图",
layer: streetsLayer,
img: mapboxStreets.src,
},
];
const BaseLayers: React.FC = () => {
const map = useMap();
// 切换底图选项展开,控制显示和卸载
const [isShow, setShow] = useState(false);
const [isExpanded, setExpanded] = useState(false);
// 快速切换底图
const [activeId, setActiveId] = useState(INITIAL_LAYER);
// 初始化默认底图
useEffect(() => {
if (!map) return;
// 添加所有底图至地图并根据 activeId 控制可见性
baseLayers.forEach((layerInfo) => {
const layers = map.getLayers().getArray();
if (!layers.includes(layerInfo.layer)) {
map.getLayers().insertAt(0, layerInfo.layer);
}
layerInfo.layer.setVisible(layerInfo.id === activeId);
});
}, [map]);
const changeMapLayers = (id: string) => {
if (map) {
// 根据 id 设置每个图层的可见性
baseLayers.forEach(({ id: lid, layer }) => {
layer.setVisible(lid === id);
});
}
};
const handleQuickSwitch = () => {
const nextId =
activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id;
setActiveId(nextId);
handleMapLayers(nextId);
};
const handleMapLayers = (id: string) => {
setActiveId(id);
changeMapLayers(id);
};
// 记录定时器,避免多次触发
const hideTimer = React.useRef<NodeJS.Timeout | null>(null);
const handleEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setShow(true);
setExpanded(true);
};
const handleLeave = () => {
setShow(false);
hideTimer.current = setTimeout(() => {
setExpanded(false);
}, 300);
};
return (
<div className="absolute right-17 bottom-11 z-1300">
<div
className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
<div className="w-20 h-20 p-1">
<button onClick={() => handleQuickSwitch()}>
<img
width={240}
height={100}
src={
activeId === baseLayers[0].id
? baseLayers[1].img
: baseLayers[0].img
}
alt={
activeId === baseLayers[0].id
? baseLayers[1].name
: baseLayers[0].name
}
className="object-cover object-left w-18 h-18 rounded-xl"
/>
<div className=" absolute left-1 bottom-1 flex w-18 h-auto items-center justify-center rounded-b-xl text-xs text-white bg-black opacity-80">
<span>
{activeId === baseLayers[0].id
? baseLayers[1].name
: baseLayers[0].name}
</span>
</div>
</button>
</div>
</div>
{isExpanded && (
<div
className={clsx(
"absolute flex right-24 bottom-0 w-90 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
isShow ? "opacity-100" : "opacity-0"
)}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
{baseLayers.map((item) => (
<button
key={item.id}
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
onClick={() => handleMapLayers(item.id)}
>
<img
width={240}
height={100}
src={item.img}
alt={item.name}
className={clsx(
"object-cover object-left w-16 h-16 rounded-md border-2 border-white hover:ring-2 ring-blue-300",
{
"ring-1 ring-blue-300": activeId === item.id,
}
)}
/>
<span className="pt-1">{item.name}</span>
</button>
))}
</div>
)}
</div>
);
};
export default BaseLayers;
@@ -0,0 +1,388 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import ToolbarButton from "@components/olmap/common/ToolbarButton";
// 导入Material-UI图标
import BackHandOutlinedIcon from "@mui/icons-material/BackHandOutlined";
import BorderColorOutlinedIcon from "@mui/icons-material/BorderColorOutlined";
import MoreHorizOutlinedIcon from "@mui/icons-material/MoreHorizOutlined";
import TimelineIcon from "@mui/icons-material/Timeline";
import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined";
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import GestureIcon from "@mui/icons-material/Gesture";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import DeleteIcon from "@mui/icons-material/Delete";
import SaveIcon from "@mui/icons-material/Save";
// 导入OpenLayers绘图相关模块
import Draw, {
DrawEvent,
createBox,
GeometryFunction,
} from "ol/interaction/Draw";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import { Style, Stroke, Fill, Circle } from "ol/style";
import { Geometry } from "ol/geom";
import Feature from "ol/Feature";
import { Type as GeometryType } from "ol/geom/Geometry";
import { useMap } from "../MapComponent";
const DrawPanel: React.FC = () => {
const map = useMap();
const [activeTool, setActiveTool] = useState<string>("pan");
const [drawLayer, setDrawLayer] = useState<VectorLayer<VectorSource> | null>(
null
);
const [drawnFeatures, setDrawnFeatures] = useState<Feature<Geometry>[]>([]);
const [historyStack, setHistoryStack] = useState<Feature<Geometry>[][]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const drawInteractionRef = useRef<Draw | null>(null);
// 创建并添加绘图图层
useEffect(() => {
if (!map) return;
const drawSource = new VectorSource();
const drawVectorLayer = new VectorLayer({
source: drawSource,
style: new Style({
stroke: new Stroke({
color: `rgba(255, 152, 0, 0.9)`,
width: 2,
}),
fill: new Fill({
color: `rgba(255, 152, 0, 0.3)`,
}),
image: new Circle({
radius: 7,
stroke: new Stroke({
color: `rgba(255, 152, 0, 0.9)`,
width: 2,
}),
fill: new Fill({
color: `rgba(255, 152, 0, 0.3)`,
}),
}),
}),
properties: {
name: "绘制图层", // 设置图层名称
value: "drawLayer",
type: "multigeometry",
properties: [],
},
});
map.addLayer(drawVectorLayer);
setDrawLayer(drawVectorLayer);
return () => {
if (drawInteractionRef.current && map) {
map.removeInteraction(drawInteractionRef.current);
drawInteractionRef.current = null;
}
map.removeLayer(drawVectorLayer);
};
}, [map, drawInteractionRef]);
// 保存到历史记录
const saveToHistory = useCallback(
(features: Feature<Geometry>[]) => {
setHistoryStack((prevStack) => {
const newHistory = prevStack.slice(0, historyIndex + 1);
newHistory.push([...features]);
setHistoryIndex(newHistory.length - 1);
return newHistory;
});
},
[historyIndex]
);
// 添加绘图交互
const addDrawInteraction = (
type: GeometryType,
geometryFunction?: GeometryFunction
) => {
if (!drawLayer) return;
if (!map) return;
// 清除现有的绘图交互
if (drawInteractionRef.current && map) {
map.removeInteraction(drawInteractionRef.current);
}
const source = drawLayer.getSource();
if (!source) return;
const drawOptions: {
source: VectorSource;
type: GeometryType;
style: Style;
geometryFunction?: GeometryFunction;
} = {
source: source,
type: type,
style: new Style({
stroke: new Stroke({
color: `rgba(255, 152, 0, 0.9)`,
width: 2,
}),
fill: new Fill({
color: `rgba(255, 152, 0, 0.3)`,
}),
image: new Circle({
radius: 7,
stroke: new Stroke({
color: `rgba(255, 152, 0, 0.9)`,
width: 2,
}),
fill: new Fill({
color: `rgba(255, 152, 0, 0.3)`,
}),
}),
}),
};
// 如果有几何函数,添加它
if (geometryFunction) {
drawOptions.geometryFunction = geometryFunction;
}
const draw = new Draw(drawOptions);
// 绘图完成事件
draw.on("drawend", (event: DrawEvent) => {
const feature = event.feature;
const currentFeatures = [...drawnFeatures, feature];
setDrawnFeatures(currentFeatures);
saveToHistory(currentFeatures);
});
map.addInteraction(draw);
drawInteractionRef.current = draw;
};
// 处理工具点击
const handleToolClick = (tool: string) => {
// 如果点击的是当前激活的工具,则取消激活
// console.log("当前激活的工具:", activeTool);
// console.log("点击的工具:", tool);
if (activeTool === tool) {
setActiveTool("");
if (drawInteractionRef.current && map) {
map.removeInteraction(drawInteractionRef.current);
drawInteractionRef.current = null;
}
return;
}
if (
tool !== "undo" &&
tool !== "redo" &&
tool !== "delete" &&
tool !== "save"
) {
setActiveTool(tool);
}
// 根据工具类型处理不同的交互
switch (tool) {
case "pan":
// 平移地图,移除所有绘图交互
if (drawInteractionRef.current && map) {
map.removeInteraction(drawInteractionRef.current);
drawInteractionRef.current = null;
}
break;
case "select":
// 选定要素,移除所有绘图交互
if (drawInteractionRef.current && map) {
map.removeInteraction(drawInteractionRef.current);
drawInteractionRef.current = null;
}
break;
case "edit":
// 编辑要素,移除所有绘图交互
if (drawInteractionRef.current && map) {
map.removeInteraction(drawInteractionRef.current);
drawInteractionRef.current = null;
}
break;
case "point":
addDrawInteraction("Point");
break;
case "line":
addDrawInteraction("LineString");
break;
case "circle":
addDrawInteraction("Circle");
break;
case "box":
// 使用矩形绘制函数
addDrawInteraction("Circle", createBox());
break;
case "polygon":
addDrawInteraction("Polygon");
break;
case "undo":
handleUndo();
break;
case "redo":
handleRedo();
break;
case "delete":
handleDelete();
break;
case "save":
handleSave();
break;
default:
if (drawInteractionRef.current && map) {
map.removeInteraction(drawInteractionRef.current);
drawInteractionRef.current = null;
}
}
};
// 撤销功能
const handleUndo = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const previousFeatures = historyStack[newIndex];
updateDrawLayer(previousFeatures);
setDrawnFeatures(previousFeatures);
setHistoryIndex(newIndex);
}
};
// 重做功能
const handleRedo = () => {
if (historyIndex < historyStack.length - 1) {
const newIndex = historyIndex + 1;
const nextFeatures = historyStack[newIndex];
updateDrawLayer(nextFeatures);
setDrawnFeatures(nextFeatures);
setHistoryIndex(newIndex);
}
};
// 删除所有绘制的要素
const handleDelete = () => {
if (!drawLayer) return;
const source = drawLayer.getSource();
if (source) {
source.clear();
const emptyFeatures: Feature<Geometry>[] = [];
setDrawnFeatures(emptyFeatures);
saveToHistory(emptyFeatures);
}
};
// 保存绘制的要素
const handleSave = () => {};
// 更新绘图图层
const updateDrawLayer = (features: Feature<Geometry>[]) => {
if (!drawLayer) return;
const source = drawLayer.getSource();
if (source) {
source.clear();
source.addFeatures(features);
}
};
// 初始化历史记录
useEffect(() => {
// 初始化空的历史记录
if (historyStack.length === 0) {
saveToHistory([]);
}
}, [historyStack.length, saveToHistory]);
// 判断按钮是否应该禁用
const isUndoDisabled = historyIndex <= 0;
const isRedoDisabled = historyIndex >= historyStack.length - 1;
const isDeleteDisabled = drawnFeatures.length === 0;
const isSaveDisabled = drawnFeatures.length === 0;
return (
<div className="absolute top-20 left-4 bg-white p-1 rounded-xl shadow-lg flex flex-col opacity-85 hover:opacity-100 transition-opacity z-1300">
<div className="flex">
<ToolbarButton
icon={<BackHandOutlinedIcon />}
name="平移地图"
isActive={activeTool === "pan"}
onClick={() => handleToolClick("pan")}
/>
<ToolbarButton
icon={<BorderColorOutlinedIcon />}
name="矢量编辑"
isActive={activeTool === "edit"}
onClick={() => handleToolClick("edit")}
/>
<ToolbarButton
icon={<MoreHorizOutlinedIcon />}
name="绘制点"
isActive={activeTool === "point"}
onClick={() => handleToolClick("point")}
/>
<ToolbarButton
icon={<TimelineIcon />}
name="绘制线"
isActive={activeTool === "line"}
onClick={() => handleToolClick("line")}
/>
<ToolbarButton
icon={<CircleOutlinedIcon />}
name="绘制圆"
isActive={activeTool === "circle"}
onClick={() => handleToolClick("circle")}
/>
<ToolbarButton
icon={<CheckBoxOutlineBlankIcon />}
name="绘制框"
isActive={activeTool === "box"}
onClick={() => handleToolClick("box")}
/>
<ToolbarButton
icon={<GestureIcon />}
name="绘制多边形"
isActive={activeTool === "polygon"}
onClick={() => handleToolClick("polygon")}
/>
</div>
<div className="flex mt-1 border-t-1 pt-1">
<ToolbarButton
icon={<UndoIcon />}
name="撤销"
isActive={false}
onClick={() => handleToolClick("undo")}
disabled={isUndoDisabled}
/>
<ToolbarButton
icon={<RedoIcon />}
name="重做"
isActive={false}
onClick={() => handleToolClick("redo")}
disabled={isRedoDisabled}
/>
<ToolbarButton
icon={<DeleteIcon />}
name="删除"
isActive={false}
onClick={() => handleToolClick("delete")}
disabled={isDeleteDisabled}
/>
{/* <ToolbarButton
icon={<SaveIcon />}
name="保存"
isActive={false}
onClick={() => handleToolClick("save")}
disabled={isSaveDisabled}
/> */}
</div>
</div>
);
};
export default DrawPanel;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
import React, { useState, useEffect, useCallback } from "react";
import { useData, useMap } from "../MapComponent";
import { Checkbox, FormControlLabel } from "@mui/material";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorLayer from "ol/layer/Vector";
import VectorTileLayer from "ol/layer/VectorTile";
import { DeckLayer } from "@utils/layers";
// 定义统一的图层项接口
interface LayerItem {
id: string;
name: string;
visible: boolean;
type: "ol" | "deck";
layerRef: any; // OpenLayers Layer 实例或 deck.gl layer 对象
}
const LayerControl: React.FC = () => {
const map = useMap();
const data = useData();
if (!data) return;
const {
deckLayer,
isContourLayerAvailable,
isWaterflowLayerAvailable,
setShowWaterflowLayer,
setShowContourLayer,
} = data;
const [layerItems, setLayerItems] = useState<LayerItem[]>([]);
const layerOrder = [
"junctions",
"reservoirs",
"tanks",
"pipes",
"pumps",
"valves",
"scada",
"waterflowLayer",
"junctionContourLayer",
];
// 更新图层列表
const updateLayers = useCallback(() => {
if (!map || !data) return;
const items: LayerItem[] = [];
// 1. 获取 OpenLayers 图层
const mapLayers = map.getLayers().getArray();
mapLayers.forEach((layer) => {
// 筛选特定类型的 OpenLayers 图层
if (
layer instanceof WebGLVectorTileLayer ||
layer instanceof VectorTileLayer ||
layer instanceof VectorLayer
) {
const value = layer.get("value");
const name = layer.get("name");
// 只有设置了 value (作为 ID) 的图层才会被纳入控制
if (value) {
items.push({
id: value,
name: name || value,
visible: layer.getVisible(),
type: "ol",
layerRef: layer,
});
}
}
});
// 2. 获取 DeckLayer 中的子图层
if (deckLayer && deckLayer instanceof DeckLayer) {
const deckLayers = deckLayer.getDeckLayers();
deckLayers.forEach((layer: any) => {
if (layer && layer.id) {
// 仅处理 junctionContourLayer 和 waterflowLayer
if (
layer.id !== "junctionContourLayer" &&
layer.id !== "waterflowLayer"
) {
return;
}
// 检查可用性
if (
(layer.id === "junctionContourLayer" && !isContourLayerAvailable) ||
(layer.id === "waterflowLayer" && !isWaterflowLayerAvailable)
) {
return; // 跳过不可用图层
}
const visible =
deckLayer.getDeckLayerVisible(layer.id) ??
layer.props?.visible ??
true;
items.push({
id: layer.props.id,
name: layer.props.name, // 使用 name 属性作为显示名称
visible: visible,
type: "deck",
layerRef: layer,
});
}
});
}
// 过滤并排序
const sortedItems = items
.filter((item) => layerOrder.includes(item.id))
.sort((a, b) => {
const indexA = layerOrder.indexOf(a.id);
const indexB = layerOrder.indexOf(b.id);
return indexA - indexB;
});
setLayerItems(sortedItems);
}, [map, deckLayer, isWaterflowLayerAvailable, isContourLayerAvailable]);
useEffect(() => {
updateLayers();
if (map) {
const layerCollection = map.getLayers();
layerCollection.on("change:length", updateLayers);
}
return () => {
if (map) {
map.getLayers().un("change:length", updateLayers);
}
};
}, [map, updateLayers]);
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
if (item.type === "ol") {
item.layerRef.setVisible(checked);
} else if (item.type === "deck" && deckLayer) {
if (item.id === "junctionContourLayer") {
setShowContourLayer && setShowContourLayer(checked);
}
if (item.id === "waterflowLayer") {
setShowWaterflowLayer && setShowWaterflowLayer(checked);
}
}
setLayerItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, visible: checked } : i)),
);
};
if (!data) {
return <div>Loading...</div>;
}
return (
<div className="absolute left-4 bottom-4 bg-white rounded-md drop-shadow-lg z-1300 opacity-85 hover:opacity-100 transition-opacity max-w-xs">
<div className="ml-3 grid grid-cols-3">
{layerItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={item.visible}
onChange={(e) => handleVisibilityChange(item, e.target.checked)}
size="small"
/>
}
label={item.name}
sx={{
fontSize: "0.7rem",
"& .MuiFormControlLabel-label": { fontSize: "0.7rem" },
}}
/>
))}
</div>
</div>
);
};
export default LayerControl;
@@ -0,0 +1,234 @@
import React from "react";
interface BaseProperty {
label: string;
value: string | number;
unit?: string;
formatter?: (value: string | number) => string;
}
// 新增:表格型属性(用于二级数据)
interface TableProperty {
type: "table";
label: string;
columns: string[]; // 表头
rows: (string | number)[][]; // 每行的数据
}
type PropertyItem = BaseProperty | TableProperty;
interface PropertyPanelProps {
id?: string;
type?: string;
properties?: PropertyItem[];
}
const PropertyPanel: React.FC<PropertyPanelProps> = ({
id,
type = "未知类型",
properties = [],
}) => {
const formatValue = (property: BaseProperty) => {
if (property.formatter) {
return property.formatter(property.value);
}
if (property.unit) {
return `${property.value} ${property.unit}`;
}
return property.value;
};
const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"];
// 统计属性数量(表格型按行数计入)
const totalProps = id
? 2 +
properties.reduce((sum, p) => {
if ("type" in p && p.type === "table") return sum + p.rows.length;
return sum + 1;
}, 0)
: 0;
return (
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100 transition-all duration-300 ">
{/* 头部 */}
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="text-lg font-semibold"></h3>
</div>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{!id ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg
className="w-16 h-16 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="space-y-2">
{/* ID 属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
ID
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{id}
</span>
</div>
</div>
{/* 类型属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{type}
</span>
</div>
</div>
{/* 其他属性(包含二级表格) */}
{properties.map((property, index) => {
// 二级表格
if ("type" in property && property.type === "table") {
return (
<div
key={`table-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
{property.label}
</span>
</div>
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
<table className="w-full text-xs">
<thead className="bg-gray-200 text-gray-700">
<tr>
{property.columns.map((col, ci) => (
<th
key={ci}
className="px-3 py-2 text-left font-semibold"
>
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-300">
{property.rows.map((row, ri) => (
<tr key={ri} className="bg-white hover:bg-gray-50">
{row.map((cell, cci) => (
<td
key={cci}
className="px-3 py-2 text-gray-800"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// 普通属性
const base = property as BaseProperty;
const isImportant = isImportantKeys.includes(base.label);
return (
<div
key={`prop-${index}`}
className={`group rounded-lg p-3 transition-all duration-200 ${
isImportant
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
: "bg-gray-50 hover:bg-gray-100"
}`}
>
<div className="flex justify-between items-start gap-3">
<span
className={`font-medium text-xs uppercase tracking-wide ${
isImportant ? "text-blue-700" : "text-gray-600"
}`}
>
{base.label}
</span>
<span
className={`text-sm font-semibold text-right flex-1 ${
isImportant ? "text-blue-900" : "text-gray-800"
}`}
>
{formatValue(base)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 底部统计区域 */}
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600 flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
{totalProps}
</span>
{id && (
<span className="text-green-600 flex items-center gap-1 font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
</span>
)}
</div>
</div>
</div>
);
};
export default PropertyPanel;
@@ -0,0 +1,88 @@
import React, { useEffect, useState, useRef } from "react";
import { useMap } from "../MapComponent";
import { ScaleLine } from "ol/control";
const Scale: React.FC = () => {
const map = useMap();
const [zoomLevel, setZoomLevel] = useState(0);
const [coordinates, setCoordinates] = useState<[number, number]>([0, 0]);
const scaleLineRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!map) return;
const updateZoomLevel = () => {
const zoom = map.getView().getZoom();
setZoomLevel(zoom ?? 0); // 如果 zoom 是 undefined,则使用默认值 0
};
const updateCoordinates = (event: any) => {
const coords = event.coordinate;
const transformedCoords = coords.map((c: number) =>
parseFloat(c.toFixed(4))
);
setCoordinates(transformedCoords);
};
map.on("moveend", updateZoomLevel);
map.on("pointermove", updateCoordinates);
// Initialize values
updateZoomLevel();
// ScaleLine control
const scaleControl = new ScaleLine({
target: scaleLineRef.current || undefined,
units: "metric",
bar: false,
steps: 4,
text: true,
minWidth: 64,
});
map.addControl(scaleControl);
return () => {
map.un("moveend", updateZoomLevel);
map.un("pointermove", updateCoordinates);
map.removeControl(scaleControl);
};
}, [map]);
return (
<>
<style>
{`
.custom-scale-line .ol-scale-line {
position: static;
background: transparent;
padding: 0;
}
.custom-scale-line .ol-scale-line-inner {
border: 1px solid #475569;
border-top: none;
color: #334155;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.3s;
}
`}
</style>
<div className="absolute bottom-0 right-0 flex items-center gap-2 px-3 py-1.5 bg-white/90 hover:bg-white rounded-tl-xl shadow-lg backdrop-blur-sm text-xs font-medium text-slate-700 z-1300 transition-all duration-300 pointer-events-auto">
<div
ref={scaleLineRef}
className="custom-scale-line flex items-center justify-center min-w-[60px]"
/>
<div className="h-3 w-px bg-slate-300 mx-1" />
<div className="min-w-[60px] text-center">
: {zoomLevel.toFixed(1)}
</div>
<div className="h-3 w-px bg-slate-300 mx-1" />
<div className="tabular-nums min-w-[140px] text-center">
: {coordinates[0]}, {coordinates[1]}
</div>
</div>
</>
);
};
export default Scale;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
import React from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
interface LegendStyleConfig {
layerName: string;
layerId: string;
property: string;
colors: string[];
type: string; // 图例类型
dimensions: number[]; // 尺寸大小
breaks: number[]; // 分段值
labels?: string[]; // 可选标签(用于离散分类)
columns?: number;
itemsPerColumn?: number;
}
// 图例组件
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
const StyleLegend: React.FC<LegendStyleConfig> = ({
layerName,
layerId,
property,
colors,
type, // 图例类型
dimensions,
breaks,
labels,
columns = 1,
itemsPerColumn,
}) => {
return (
<Box
key={layerId}
className="bg-white p-3 rounded-xl max-w-xs opacity-95 transition-opacity duration-300 hover:opacity-100"
>
<Typography variant="subtitle2" gutterBottom>
{layerName} - {property}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns:
itemsPerColumn && itemsPerColumn > 0
? undefined
: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,
gridTemplateRows:
itemsPerColumn && itemsPerColumn > 0
? `repeat(${itemsPerColumn}, minmax(0, auto))`
: undefined,
gridAutoFlow:
itemsPerColumn && itemsPerColumn > 0 ? "column" : undefined,
columnGap: 1.5,
rowGap: 0.5,
}}
>
{[...Array(breaks.length)].map((_, index) => {
const color = colors[index]; // 默认颜色为黑色
const dimension = dimensions[index]; // 默认尺寸为16
// // 处理第一个区间(小于 breaks[0])
// if (index === 0) {
// return (
// <Box key={index} className="flex items-center gap-2 mb-1">
// <Box
// sx={
// type === "point"
// ? {
// width: dimension,
// height: dimension,
// borderRadius: "50%",
// backgroundColor: color,
// }
// : {
// width: 16,
// height: dimension,
// backgroundColor: color,
// border: `1px solid ${color}`,
// }
// }
// />
// <Typography variant="caption" className="text-xs">
// {"<"} {breaks[0]?.toFixed(1)}
// </Typography>
// </Box>
// );
// }
// 处理中间区间(breaks[index] - breaks[index + 1]
if (index + 1 < breaks.length) {
const prevValue = breaks[index];
const currentValue = breaks[index + 1];
return (
<Box key={index} className="flex items-center gap-2">
<Box
sx={
type === "point"
? {
width: dimension,
height: dimension,
borderRadius: "50%",
backgroundColor: color,
}
: {
width: 16,
height: dimension,
backgroundColor: color,
border: `1px solid ${color}`,
}
}
/>
<Typography variant="caption" className="text-xs">
{labels?.[index] ??
`${prevValue?.toFixed(1)} - ${currentValue?.toFixed(1)}`}
</Typography>
</Box>
);
}
return null;
})}
</Box>
</Box>
);
};
export default StyleLegend;
export type { LegendStyleConfig };
@@ -0,0 +1,849 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useNotification } from "@refinedev/core";
import Draggable from "react-draggable";
import {
Box,
Button,
Slider,
Typography,
Paper,
MenuItem,
Select,
FormControl,
InputLabel,
IconButton,
Stack,
Tooltip,
} from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn"; // 引入中文包
import dayjs from "dayjs";
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
import { TbRewindBackward15, TbRewindForward15 } from "react-icons/tb";
import { FiSkipBack, FiSkipForward } from "react-icons/fi";
import { useData } from "../MapComponent";
import { config, NETWORK_NAME } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { useMap } from "../MapComponent";
interface TimelineProps {
schemeDate?: Date;
timeRange?: { start: Date; end: Date };
disableDateSelection?: boolean;
schemeName?: string;
schemeType?: string;
}
const Timeline: React.FC<TimelineProps> = ({
schemeDate,
timeRange,
disableDateSelection = false,
schemeName = "",
schemeType = "burst_Analysis",
}) => {
const data = useData();
if (!data) {
return <div>Loading...</div>; // 或其他占位符
}
const {
currentTime,
setCurrentTime,
selectedDate,
setSelectedDate,
setCurrentJunctionCalData,
setCurrentPipeCalData,
junctionText,
pipeText,
} = data;
if (
setCurrentTime === undefined ||
currentTime === undefined ||
selectedDate === undefined ||
setSelectedDate === undefined
) {
return <div>Loading...</div>; // 或其他占位符
}
const { open } = useNotification();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [playInterval, setPlayInterval] = useState<number>(15000); // 毫秒
const [calculatedInterval, setCalculatedInterval] = useState<number>(15); // 分钟
const [isCalculating, setIsCalculating] = useState<boolean>(false);
// 计算时间轴范围
const minTime = timeRange
? timeRange.start.getHours() * 60 + timeRange.start.getMinutes()
: 0;
const maxTime = timeRange
? timeRange.end.getHours() * 60 + timeRange.end.getMinutes()
: 1440;
useEffect(() => {
if (schemeDate) {
setSelectedDate(schemeDate);
}
}, [schemeDate]);
// 新增:用于 Draggable 的 nodeRef
const draggableRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timelineRef = useRef<HTMLDivElement>(null);
// 添加缓存引用
const nodeCacheRef = useRef<Map<string, any[]>>(new Map());
const linkCacheRef = useRef<Map<string, any[]>>(new Map());
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const fetchFrameData = async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
schemeName: string,
schemeType: string,
) => {
const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] };
let linkRecords: any = { results: [] };
const requests: Promise<Response>[] = [];
let nodePromise: Promise<any> | null = null;
let linkPromise: Promise<any> | null = null;
// 检查node缓存
if (junctionProperties !== "" && junctionProperties !== "elevation") {
const nodeCacheKey = `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`;
if (nodeCacheRef.current.has(nodeCacheKey)) {
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else {
disableDateSelection && schemeName
? (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
))
: (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
));
requests.push(nodePromise);
}
}
// 处理特殊属性名称
if (pipeProperties === "unit_headloss") pipeProperties = "headloss";
// 检查link缓存
if (pipeProperties !== "" && pipeProperties !== "diameter") {
const linkCacheKey = `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`;
if (linkCacheRef.current.has(linkCacheKey)) {
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else {
disableDateSelection && schemeName
? (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${pipeProperties}`,
))
: (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`,
));
requests.push(linkPromise);
}
}
// 等待所有有效请求
const responses = await Promise.all(requests);
if (nodePromise) {
const nodeResponse = responses.shift()!;
if (!nodeResponse.ok)
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
nodeRecords = await nodeResponse.json();
// 缓存数据(修复键以包含 schemeName
nodeCacheRef.current.set(
`${query_time}_${junctionProperties}_${schemeName}_${schemeType}`,
nodeRecords || [],
);
}
if (linkPromise) {
const linkResponse = responses.shift()!;
if (!linkResponse.ok)
throw new Error(`Link fetch failed: ${linkResponse.status}`);
linkRecords = await linkResponse.json();
// 缓存数据(修复键以包含 schemeName
linkCacheRef.current.set(
`${query_time}_${pipeProperties}_${schemeName}_${schemeType}`,
linkRecords || [],
);
}
// 更新状态
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
};
// 提取更新状态的逻辑
const updateDataStates = (nodeResults: any[], linkResults: any[]) => {
if (setCurrentJunctionCalData) {
setCurrentJunctionCalData(nodeResults);
} else {
console.log("setCurrentJunctionCalData is undefined");
}
if (setCurrentPipeCalData) {
setCurrentPipeCalData(linkResults);
} else {
console.log("setCurrentPipeCalData is undefined");
}
};
// 时间刻度数组 (每5分钟一个刻度)
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
value: i * 5,
label: i % 24 === 0 ? formatTime(i * 5) : "",
}));
// 格式化时间显示
function formatTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, "0")}:${mins
.toString()
.padStart(2, "0")}`;
}
function currentTimeToDate(selectedDate: Date, minutes: number): Date {
const date = new Date(selectedDate);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
date.setHours(hours, mins, 0, 0);
return date;
}
// 播放时间间隔选项
const intervalOptions = [
{ value: 5000, label: "5秒" },
{ value: 10000, label: "10秒" },
{ value: 15000, label: "15秒" },
{ value: 20000, label: "20秒" },
];
// 强制计算时间段选项
const calculatedIntervalOptions = [
{ value: 1440, label: "1 天" },
{ value: 60, label: "1 小时" },
{ value: 30, label: "30 分钟" },
{ value: 15, label: "15 分钟" },
{ value: 5, label: "5 分钟" },
];
// 处理时间轴滑动
const handleSliderChange = useCallback(
(event: Event, newValue: number | number[]) => {
const value = Array.isArray(newValue) ? newValue[0] : newValue;
// 如果有时间范围限制,只允许在范围内拖动
if (timeRange && (value < minTime || value > maxTime)) {
return;
}
// 防抖设置currentTime,避免频繁触发数据获取
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setCurrentTime(value);
}, 500); // 500ms 防抖延迟
},
[timeRange, minTime, maxTime],
);
// 播放控制
const handlePlay = useCallback(() => {
if (!isPlaying) {
// if (junctionText === "" && pipeText === "") {
// open?.({
// type: "error",
// message: "请至少设定并应用一个图层的样式。",
// });
// return;
// }
setIsPlaying(true);
intervalRef.current = setInterval(() => {
setCurrentTime((prev) => {
let next = prev + 15;
if (timeRange) {
if (next > maxTime) next = minTime;
} else {
if (next >= 1440) next = 0;
}
return next;
});
}, playInterval);
}
}, [isPlaying, playInterval]);
const handlePause = useCallback(() => {
setIsPlaying(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
const handleStop = useCallback(() => {
setIsPlaying(false);
// 设置为当前时间
const currentTime = new Date();
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
setCurrentTime(minutes); // 组件卸载时重置时间
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// 步进控制
const handleDayStepBackward = useCallback(() => {
setSelectedDate((prev) => {
const newDate = new Date(prev);
newDate.setDate(newDate.getDate() - 1);
return newDate;
});
}, []);
const handleDayStepForward = useCallback(() => {
setSelectedDate((prev) => {
const newDate = new Date(prev);
newDate.setDate(newDate.getDate() + 1);
return newDate;
});
}, []);
const handleStepBackward = useCallback(() => {
setCurrentTime((prev) => {
let next = prev - 15;
if (timeRange) {
if (next < minTime) next = maxTime;
} else {
if (next < 0) next += 1440;
}
return next;
});
}, [timeRange, minTime, maxTime]);
const handleStepForward = useCallback(() => {
setCurrentTime((prev) => {
let next = prev + 15;
if (timeRange) {
if (next > maxTime) next = minTime;
} else {
if (next >= 1440) next = 0;
}
return next;
});
}, [timeRange, minTime, maxTime]);
// 日期选择处理
const handleDateChange = useCallback((newDate: Date | null) => {
if (newDate) {
setSelectedDate(newDate);
}
}, []);
// 播放间隔改变处理
const handleIntervalChange = useCallback(
(event: any) => {
const newInterval = event.target.value;
setPlayInterval(newInterval);
// 如果正在播放,重新启动定时器
if (isPlaying && intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setCurrentTime((prev) => {
let next = prev + 15;
if (timeRange) {
if (next > maxTime) next = minTime;
} else {
if (next >= 1440) next = 0;
}
return next;
});
}, newInterval);
}
},
[isPlaying],
);
// 计算时间段改变处理
const handleCalculatedIntervalChange = useCallback((event: any) => {
const newInterval = event.target.value;
setCalculatedInterval(newInterval);
}, []);
// 添加 useEffect 来监听 currentTime 和 selectedDate 的变化,并获取数据
useEffect(() => {
// 首次加载时,如果 selectedDate 或 currentTime 未定义,则跳过执行,避免报错
if (selectedDate && currentTime !== undefined && currentTime !== -1) {
// 检查至少一个属性有值
// const junctionProperties = junctionText;
// const pipeProperties = pipeText;
// if (junctionProperties === "" && pipeProperties === "") {
// open?.({
// type: "error",
// message: "请至少设定并应用一个图层的样式。",
// });
// return;
// }
fetchFrameData(
currentTimeToDate(selectedDate, currentTime),
junctionText,
pipeText,
schemeName,
schemeType,
);
}
}, [
junctionText,
pipeText,
currentTime,
selectedDate,
schemeName,
schemeType,
]);
// 组件卸载时清理定时器和防抖
useEffect(() => {
// 设置为当前时间
const currentTime = new Date();
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
// 找到最近的前15分钟刻度
const roundedMinutes = Math.floor(minutes / 15) * 15;
setCurrentTime(roundedMinutes); // 组件卸载时重置时间
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
// 当 timeRange 改变时,设置 currentTime 到 minTime
useEffect(() => {
if (timeRange) {
setCurrentTime(minTime);
}
}, [timeRange, minTime]);
// 获取地图实例
const map = useMap();
// 这里防止地图缩放时,瓦片重新加载引起的属性更新出错
useEffect(() => {
// 监听地图缩放事件,缩放时停止播放
if (map) {
const onZoom = () => {
handlePause();
};
map.getView().on("change:resolution", onZoom);
// 清理事件监听
return () => {
map.getView().un("change:resolution", onZoom);
};
}
}, [map, handlePause]);
// 清除当天当前时间点后的缓存并重新获取数据
const clearCacheAndRefetch = (date: Date, timeInMinutes: number) => {
const dateStr = date.toISOString().split("T")[0];
const clearCache = (
cacheRef: ReturnType<typeof useRef<Map<string, any[]>>>,
) => {
if (!cacheRef.current) return;
const cacheKeys = Array.from(cacheRef.current.keys());
cacheKeys.forEach((key) => {
const keyParts = key.split("_");
const cacheDate = keyParts[0].split("T")[0];
const cacheTimeStr = keyParts[0].split("T")[1];
if (cacheDate === dateStr && cacheTimeStr) {
const [hours, minutes] = cacheTimeStr.split(":");
const cacheTimeInMinutes =
(parseInt(hours) + 8) * 60 + parseInt(minutes);
if (cacheTimeInMinutes >= timeInMinutes && cacheRef.current) {
cacheRef.current.delete(key);
}
}
});
};
clearCache(nodeCacheRef);
clearCache(linkCacheRef);
// 重新获取当前时刻的新数据
fetchFrameData(
currentTimeToDate(selectedDate, currentTime),
junctionText,
pipeText,
schemeName,
schemeType,
);
};
const handleForceCalculate = async () => {
if (!NETWORK_NAME) {
open?.({
type: "error",
message: "管网名称缺失,无法进行强制计算。",
});
return;
}
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
const calculationDate = selectedDate;
const calculationTime = currentTime;
const calculationDateStr = calculationDate.toISOString().split("T")[0];
setIsCalculating(true);
// 显示处理中的通知
open?.({
type: "progress",
message: "正在强制计算,请稍候...",
undoableTimeout: 3,
});
try {
const body = {
name: NETWORK_NAME,
simulation_date: calculationDateStr, // YYYY-MM-DD
start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00
duration: calculatedInterval,
};
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/runsimulationmanuallybydate/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
if (response.ok) {
open?.({
type: "success",
message: "重新计算成功",
});
// 清空当天当前时刻及之后的缓存并重新获取数据
clearCacheAndRefetch(calculationDate, calculationTime);
} else {
open?.({
type: "error",
message: "重新计算失败",
});
}
} catch (error) {
console.error("Recalculation failed:", error);
open?.({
type: "error",
message: "重新计算时发生错误",
});
} finally {
setIsCalculating(false);
}
};
return (
<Draggable nodeRef={draggableRef} handle=".drag-handle">
<div
ref={draggableRef}
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[920px] opacity-90 hover:opacity-100 transition-opacity duration-300"
>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<Paper
elevation={3}
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
p: 2,
pt: 1, // 减小顶部内边距,为拖拽柄留出空间
backgroundColor: "rgba(255, 255, 255, 0.95)",
backdropFilter: "blur(10px)",
}}
>
{/* 拖拽柄区域 */}
<Box
className="drag-handle"
sx={{
width: "100%",
height: "16px",
display: "flex",
justifyContent: "center",
alignItems: "center",
cursor: "move",
mb: 1,
borderRadius: "4px 4px 0 0",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.05)",
},
}}
>
<Box
sx={{
width: "40px",
height: "4px",
backgroundColor: "grey.400",
borderRadius: "2px",
}}
/>
</Box>
<Box sx={{ width: "100%" }}>
{/* 控制按钮栏 */}
<Stack
direction="row"
spacing={2}
alignItems="center"
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
>
<Tooltip title="后退一天">
<span>
<IconButton
color="primary"
onClick={handleDayStepBackward}
size="small"
disabled={disableDateSelection}
>
<FiSkipBack />
</IconButton>
</span>
</Tooltip>
{/* 日期选择器 */}
<DatePicker
label="模拟数据日期选择"
value={dayjs(selectedDate)}
onChange={(value) =>
value && handleDateChange(value.toDate())
}
enableAccessibleFieldDOMStructure={false}
format="YYYY-MM-DD"
sx={{ width: 180 }}
slotProps={{
textField: {
size: "small",
},
}}
maxDate={dayjs(new Date())} // 禁止选取未来的日期
disabled={disableDateSelection}
/>
<Tooltip title="前进一天">
<span>
<IconButton
color="primary"
onClick={handleDayStepForward}
size="small"
disabled={
disableDateSelection ||
selectedDate.toDateString() ===
new Date().toDateString()
}
>
<FiSkipForward />
</IconButton>
</span>
</Tooltip>
{/* 播放控制按钮 */}
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
{/* 播放间隔选择 */}
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel></InputLabel>
<Select
value={playInterval}
label="播放间隔"
onChange={handleIntervalChange}
>
{intervalOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title="后退一步">
<IconButton
color="primary"
onClick={handleStepBackward}
size="small"
>
<TbRewindBackward15 />
</IconButton>
</Tooltip>
<Tooltip title={isPlaying ? "暂停" : "播放"}>
<IconButton
color="primary"
onClick={isPlaying ? handlePause : handlePlay}
size="small"
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
</Tooltip>
<Tooltip title="前进一步">
<IconButton
color="primary"
onClick={handleStepForward}
size="small"
>
<TbRewindForward15 />
</IconButton>
</Tooltip>
<Tooltip title="停止">
<IconButton
color="secondary"
onClick={handleStop}
size="small"
>
<Stop />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
{/* 强制计算时间段 */}
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel></InputLabel>
<Select
value={calculatedInterval}
label="强制计算时间段"
onChange={handleCalculatedIntervalChange}
>
{calculatedIntervalOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
{/* 功能按钮 */}
<Tooltip title="强制计算">
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleForceCalculate}
disabled={isCalculating}
>
</Button>
</Tooltip>
</Box>
{/* 当前时间显示 */}
<Typography
variant="h6"
sx={{
ml: "auto",
fontWeight: "bold",
color: "primary.main",
}}
>
{formatTime(currentTime)}
</Typography>
</Stack>
<Box ref={timelineRef} sx={{ px: 2, position: "relative" }}>
<Slider
value={currentTime}
min={0}
max={1440} // 24:00 = 1440分钟
step={15} // 每15分钟一个步进
marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记
onChange={handleSliderChange}
valueLabelDisplay="auto"
valueLabelFormat={formatTime}
sx={{
zIndex: 10,
height: 8,
"& .MuiSlider-track": {
backgroundColor: "primary.main",
height: 6,
display: timeRange ? "none" : "block",
},
"& .MuiSlider-rail": {
backgroundColor: "grey.300",
height: 6,
},
"& .MuiSlider-thumb": {
height: 20,
width: 20,
backgroundColor: "primary.main",
border: "2px solid #fff",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
"&:hover": {
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
},
},
"& .MuiSlider-mark": {
backgroundColor: "grey.400",
height: 4,
width: 2,
},
"& .MuiSlider-markActive": {
backgroundColor: "primary.main",
},
"& .MuiSlider-markLabel": {
fontSize: "0.75rem",
color: "grey.600",
},
}}
/>
{/* 禁用区域遮罩 */}
{timeRange && (
<>
{/* 左侧禁用区域 */}
{minTime > 0 && (
<Box
sx={{
position: "absolute",
left: "14px",
top: "30%",
transform: "translateY(-50%)",
width: `${(minTime / 1440) * 856 + 2}px`,
height: "20px",
backgroundColor: "rgba(189, 189, 189, 0.4)",
pointerEvents: "none",
backdropFilter: "blur(1px)",
borderRadius: "2.5px",
rounded: "true",
}}
/>
)}
{/* 右侧禁用区域 */}
{maxTime < 1440 && (
<Box
sx={{
position: "absolute",
left: `${16 + (maxTime / 1440) * 856}px`,
top: "30%",
transform: "translateY(-50%)",
width: `${((1440 - maxTime) / 1440) * 856}px`,
height: "20px",
backgroundColor: "rgba(189, 189, 189, 0.4)",
pointerEvents: "none",
backdropFilter: "blur(1px)",
borderRadius: "2.5px",
}}
/>
)}
</>
)}
</Box>
</Box>
</Paper>
</LocalizationProvider>
</div>
</Draggable>
);
};
export default Timeline;
@@ -0,0 +1,869 @@
import React, { useState, useEffect, useCallback } from "react";
import { useData, useMap } from "../MapComponent";
import ToolbarButton from "@/components/olmap/common/ToolbarButton";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import { Style, Stroke, Fill, Circle } from "ol/style";
import Feature from "ol/Feature";
import StyleEditorPanel from "./StyleEditorPanel";
import { LayerStyleState } from "./StyleEditorPanel";
import StyleLegend from "./StyleLegend"; // 引入图例组件
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
import { useNotification } from "@refinedev/core";
import { config } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
// 添加接口定义隐藏按钮的props
interface ToolbarProps {
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
queryType?: string; // 可选的查询类型参数
schemeType?: string; // 可选的方案类型参数
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
}
const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons,
queryType,
schemeType,
HistoryPanel,
}) => {
const map = useMap();
const data = useData();
const { open } = useNotification();
if (!data) return null;
const { currentTime, selectedDate, schemeName } = data;
const [activeTools, setActiveTools] = useState<string[]>([]);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [showPropertyPanel, setShowPropertyPanel] = useState<boolean>(false);
const [showDrawPanel, setShowDrawPanel] = useState<boolean>(false);
const [showStyleEditor, setShowStyleEditor] = useState<boolean>(false);
const [showHistoryPanel, setShowHistoryPanel] = useState<boolean>(false);
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
{
isActive: false, // 默认不激活,不显示图例
layerId: "junctions",
layerName: "节点",
styleConfig: {
property: "pressure",
classificationMethod: "custom_breaks",
customBreaks: [16, 18, 20, 22, 24, 26],
customColors: [
"rgba(255, 0, 0, 1)",
"rgba(255, 127, 0, 1)",
"rgba(255, 215, 0, 1)",
"rgba(199, 224, 0, 1)",
"rgba(76, 175, 80, 1)",
"rgba(0, 158, 115, 1)",
],
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "rainbow",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "junctions",
layerName: "节点",
property: "压力", // 暂时为空,等计算后更新
colors: [],
type: "point",
dimensions: [],
breaks: [],
},
},
{
isActive: false, // 默认不激活,不显示图例
layerId: "pipes",
layerName: "管道",
styleConfig: {
property: "flow",
classificationMethod: "pretty_breaks",
segments: 6,
minSize: 4,
maxSize: 12,
minStrokeWidth: 2,
maxStrokeWidth: 8,
fixedStrokeWidth: 3,
colorType: "gradient",
singlePaletteIndex: 0,
gradientPaletteIndex: 0,
rainbowPaletteIndex: 0,
showLabels: false,
showId: false,
opacity: 0.9,
adjustWidthByProperty: true,
},
legendConfig: {
layerId: "pipes",
layerName: "管道",
property: "流量", // 暂时为空,等计算后更新
colors: [],
type: "linestring",
dimensions: [],
breaks: [],
},
},
]);
// 计算激活的图例配置
const activeLegendConfigs = layerStyleStates
.filter((state) => state.isActive && state.legendConfig.property)
.map((state) => ({
...state.legendConfig,
layerName: state.layerName,
layerId: state.layerId,
}));
// 创建高亮图层
useEffect(() => {
if (!map) return;
const highLightSource = new VectorSource();
const highLightLayer = new VectorLayer({
source: highLightSource,
style: new Style({
stroke: new Stroke({
color: `rgba(255, 0, 0, 1)`,
width: 5,
}),
fill: new Fill({
color: `rgba(255, 0, 0, 0.2)`,
}),
image: new Circle({
radius: 7,
stroke: new Stroke({
color: `rgba(255, 0, 0, 1)`,
width: 3,
}),
fill: new Fill({
color: `rgba(255, 0, 0, 0.2)`,
}),
}),
}),
properties: {
name: "属性查询高亮图层", // 设置图层名称
value: "info_highlight_layer",
type: "multigeometry",
properties: [],
},
});
map.addLayer(highLightLayer);
setHighlightLayer(highLightLayer);
return () => {
map.removeLayer(highLightLayer);
};
}, [map]);
// 高亮要素的函数
useEffect(() => {
if (!highlightLayer) {
return;
}
const source = highlightLayer.getSource();
if (!source) {
return;
}
// 清除之前的高亮
source.clear();
// 添加新的高亮要素
highlightFeatures.forEach((feature) => {
if (feature instanceof Feature) {
source.addFeature(feature);
}
});
}, [highlightFeatures, highlightLayer]);
// 地图点击选择要素事件处理函数
const handleMapClickSelectFeatures = useCallback(
async (event: { coordinate: number[] }) => {
if (!map) return;
const feature = await mapClickSelectFeatures(event, map); // 调用导入的函数
if (!feature || !(feature instanceof Feature)) {
// 如果没有点击到要素,且当前是 info 模式,则清除高亮
if (activeTools.includes("info")) {
setHighlightFeatures([]);
}
return;
}
if (activeTools.includes("history")) {
// 历史查询模式:支持同类型多选
const featureId = feature.getProperties().id;
const layerId = feature.getId()?.toString().split(".")[0] || "";
console.log("点击选择要素", feature, "图层:", layerId);
// 简单的类型检查函数
const getBaseType = (lid: string) => {
if (lid.includes("pipe")) return "pipe";
if (lid.includes("junction")) return "junction";
if (lid.includes("tank")) return "tank";
if (lid.includes("reservoir")) return "reservoir";
if (lid.includes("pump")) return "pump";
if (lid.includes("valve")) return "valve";
return lid;
};
// 检查是否与已选要素类型一致
if (highlightFeatures.length > 0) {
const firstLayerId =
highlightFeatures[0].getId()?.toString().split(".")[0] || "";
if (getBaseType(layerId) !== getBaseType(firstLayerId)) {
// 如果点击的是已选中的要素(为了取消选中),则不报错
const isAlreadySelected = highlightFeatures.some(
(f) => f.getProperties().id === featureId,
);
if (!isAlreadySelected) {
open?.({
type: "error",
message: "请选择相同类型的要素进行多选查询。",
});
return;
}
}
}
setHighlightFeatures((prev) => {
const existingIndex = prev.findIndex(
(f) => f.getProperties().id === featureId,
);
if (existingIndex !== -1) {
// 如果已存在,移除
return prev.filter((_, i) => i !== existingIndex);
} else {
// 如果不存在,添加
return [...prev, feature];
}
});
} else {
// 其他模式(如 info):单选
setHighlightFeatures([feature]);
}
},
[map, activeTools, highlightFeatures, open],
);
// 添加矢量属性查询事件监听器
useEffect(() => {
if (!map) return;
// 监听 info 或 history 工具激活时添加
if (activeTools.includes("info") || activeTools.includes("history")) {
map.on("click", handleMapClickSelectFeatures);
return () => {
map.un("click", handleMapClickSelectFeatures);
};
}
}, [activeTools, map, handleMapClickSelectFeatures]);
// 处理工具栏按钮点击事件
const handleToolClick = (tool: string) => {
// 样式工具的特殊处理 - 只有再次点击时才会取消激活和关闭
if (tool === "style") {
if (activeTools.includes("style")) {
// 如果样式工具已激活,点击时关闭
setShowStyleEditor(false);
setActiveTools((prev) => prev.filter((t) => t !== "style"));
} else {
// 激活样式工具,打开样式面板
setActiveTools((prev) => [...prev, "style"]);
setShowStyleEditor(true);
}
return;
}
// 其他工具的处理逻辑
if (activeTools.includes(tool)) {
// 如果当前工具已激活,再次点击时取消激活并关闭面板
deactivateTool(tool);
setActiveTools((prev) => prev.filter((t) => t !== tool));
} else {
// 如果当前工具未激活,先关闭所有其他工具,然后激活当前工具
// 关闭所有面板(但保持样式编辑器状态)
closeAllPanelsExceptStyle();
// 取消激活所有非样式工具
setActiveTools((prev) => {
const styleActive = prev.includes("style");
return styleActive ? ["style", tool] : [tool];
});
// 激活当前工具并打开对应面板
activateTool(tool);
}
};
// 取消激活指定工具并关闭对应面板
const deactivateTool = (tool: string) => {
switch (tool) {
case "info":
setShowPropertyPanel(false);
setHighlightFeatures([]);
break;
case "draw":
setShowDrawPanel(false);
break;
case "history":
setShowHistoryPanel(false);
setHighlightFeatures([]);
break;
}
};
// 激活指定工具并打开对应面板
const activateTool = (tool: string) => {
switch (tool) {
case "info":
setShowPropertyPanel(true);
break;
case "draw":
setShowDrawPanel(true);
break;
case "history":
setShowHistoryPanel(true);
// 激活历史查询后:HistoryDataPanel 自行负责根据传入的 props 拉取数据。
break;
}
};
// 关闭所有面板(除了样式编辑器)
const closeAllPanelsExceptStyle = () => {
setShowPropertyPanel(false);
setHighlightFeatures([]);
setShowDrawPanel(false);
setShowHistoryPanel(false);
// 样式编辑器保持其当前状态,不自动关闭
};
const [computedProperties, setComputedProperties] = useState<
Record<string, any>
>({});
// 添加 useEffect 来查询计算属性
useEffect(() => {
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
setComputedProperties({});
return;
}
const highlightFeature = highlightFeatures[0];
const id = highlightFeature.getProperties().id;
if (!id) {
setComputedProperties({});
return;
}
const queryComputedProperties = async () => {
try {
const properties = highlightFeature?.getProperties?.() || {};
const type =
properties.geometry?.getType?.() === "LineString" ? "link" : "node";
// selectedDate 格式化为 YYYY-MM-DD
let dateObj: Date;
if (selectedDate instanceof Date) {
dateObj = new Date(selectedDate);
} else {
dateObj = new Date(selectedDate);
}
const minutes = Number(currentTime) || 0;
dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
// 转为 UTC ISO 字符串
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
let response;
if (queryType === "scheme") {
response = await apiFetch(
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
);
} else {
response = await apiFetch(
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-id-time?id=${id}&type=${type}&query_time=${querytime}`,
);
}
if (!response.ok) {
throw new Error("API request failed");
}
const data = await response.json();
if (!data.result || data.result.length === 0) {
setComputedProperties({});
} else {
setComputedProperties(data.result[0] || {});
console.log("查询到的计算属性:", data.result[0]);
console.log(computedProperties);
}
} catch (error) {
console.error("Error querying computed properties:", error);
setComputedProperties({});
}
};
// 仅当 currentTime 有效时查询
if (currentTime !== -1 && queryType) queryComputedProperties();
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType]);
// 从要素属性中提取属性面板需要的数据
const getFeatureProperties = useCallback(() => {
if (highlightFeatures.length === 0) return {};
const highlightFeature = highlightFeatures[0];
const layer = highlightFeature?.getId()?.toString().split(".")[0];
const properties = highlightFeature.getProperties();
// 计算属性字段,增加 key 字段
const pipeComputedFields = [
{ key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "friction", label: "摩阻", unit: "" },
{ key: "headloss", label: "水头损失", unit: "m" },
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
{ key: "quality", label: "水质", unit: "mg/L" },
{ key: "reaction", label: "反应", unit: "1/d" },
{ key: "setting", label: "设置", unit: "" },
{ key: "status", label: "状态", unit: "" },
{ key: "velocity", label: "流速", unit: "m/s" },
];
const nodeComputedFields = [
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "total_head", label: "水头", unit: "m" },
{ key: "pressure", label: "压力", unit: "m" },
{ key: "quality", label: "水质", unit: "mg/L" },
];
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
let result = {
id: properties.id,
type: "管道",
properties: [
{ label: "起始节点ID", value: properties.node1 },
{ label: "终点节点ID", value: properties.node2 },
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
{
label: "管径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{ label: "粗糙度", value: properties.roughness },
{ label: "局部损失", value: properties.minor_loss },
{ label: "初始状态", value: "开" },
],
};
// 追加计算属性
if (computedProperties) {
pipeComputedFields.forEach(({ key, label, unit }) => {
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
}
// 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km)
if (
key === "unit_headloss" &&
value === undefined &&
computedProperties.headloss !== undefined &&
properties.length
) {
value = (computedProperties.headloss / properties.length) * 1000;
}
if (value !== undefined) {
result.properties.push({
label,
value: typeof value === "number" ? value.toFixed(3) : value,
unit,
});
}
});
}
return result;
}
if (layer === "geo_junctions_mat" || layer === "geo_junctions") {
let result = {
id: properties.id,
type: "节点",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
// 将 demand1~demand5 与 pattern1~pattern5 作为二级表格展示
{
type: "table",
label: "基本需水量",
columns: ["demand", "pattern"],
rows: Array.from({ length: 5 }, (_, i) => i + 1)
.map((idx) => {
let d = properties?.[`demand${idx}`];
const p = properties?.[`pattern${idx}`];
// 仅当 demand 有效时展示该行
if (d !== undefined && d !== null && d !== "") {
d = toM3h(Number(d), "lps");
return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"];
}
})
.filter(Boolean) as (string | number)[][],
} as any,
],
};
// 追加计算属性
if (computedProperties) {
nodeComputedFields.forEach(({ key, label, unit }) => {
if (computedProperties[key] !== undefined) {
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
}
result.properties.push({
label,
value:
value?.toFixed?.(3) || value,
unit,
});
}
});
}
return result;
}
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
return {
id: properties.id,
type: "水池",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
{
label: "初始水位",
value: properties.init_level?.toFixed?.(1),
unit: "m",
},
{
label: "最低水位",
value: properties.min_level?.toFixed?.(1),
unit: "m",
},
{
label: "最高水位",
value: properties.max_level?.toFixed?.(1),
unit: "m",
},
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "m",
},
{
label: "最小容积",
value: properties.min_vol?.toFixed?.(1),
unit: "m³",
},
// {
// label: "容积曲线",
// value: properties.vol_curve,
// },
{
label: "溢出",
value: properties.overflow ? "是" : "否",
},
],
};
}
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
return {
id: properties.id,
type: "水库",
properties: [
{
label: "水头",
value: properties.head?.toFixed?.(1),
unit: "m",
},
// {
// label: "模式",
// value: properties.pattern,
// },
],
};
}
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
return {
id: properties.id,
type: "水泵",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "功率",
value: properties.power?.toFixed?.(1),
unit: "kW",
},
{
label: "扬程",
value: properties.head?.toFixed?.(1),
unit: "m",
},
{
label: "转速",
value: properties.speed?.toFixed?.(1),
unit: "rpm",
},
{
label: "模式",
value: properties.pattern,
},
],
};
}
if (layer === "geo_valves_mat" || layer === "geo_valves") {
return {
id: properties.id,
type: "阀门",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{
label: "阀门类型",
value: properties.v_type,
},
// {
// label: "设置",
// value: properties.setting?.toFixed?.(2),
// },
{
label: "局部损失",
value: properties.minor_loss?.toFixed?.(2),
},
],
};
}
// 传输频率文字对应
const getTransmissionFrequency = (transmission_frequency: string) => {
// 传输频率文本:00:01:0000:05:0000:10:0000:30:0001:00:00,转换为分钟数
const parts = transmission_frequency.split(":");
if (parts.length !== 3) return transmission_frequency;
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parseInt(parts[2], 10);
const totalMinutes = hours * 60 + minutes + (seconds >= 30 ? 1 : 0);
return totalMinutes;
};
// 可靠度文字映射
const getReliability = (reliability: number) => {
switch (reliability) {
case 1:
return "高";
case 2:
return "中";
case 3:
return "低";
default:
return "未知";
}
};
if (layer === "geo_scada_mat" || layer === "geo_scada") {
let result = {
id: properties.id,
type: "SCADA设备",
properties: [
{
label: "类型",
value:
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器",
},
{
label: "关联节点 ID",
value: properties.associated_element_id,
},
{
label: "传输模式",
value:
properties.transmission_mode === "non_realtime"
? "定时传输"
: "实时传输",
},
{
label: "传输频率",
value: getTransmissionFrequency(properties.transmission_frequency),
unit: "分钟",
},
{
label: "可靠性",
value: getReliability(properties.reliability),
},
],
};
return result;
}
return {};
}, [highlightFeatures, computedProperties]);
return (
<>
<div className="absolute top-4 left-4 bg-white p-1 rounded-xl shadow-lg flex opacity-85 hover:opacity-100 transition-opacity">
{!hiddenButtons?.includes("info") && (
<ToolbarButton
icon={<InfoOutlinedIcon />}
name="查看属性"
isActive={activeTools.includes("info")}
onClick={() => handleToolClick("info")}
/>
)}
{!hiddenButtons?.includes("history") && (
<ToolbarButton
icon={<QueryStatsOutlinedIcon />}
name="查询历史数据"
isActive={activeTools.includes("history")}
onClick={() => handleToolClick("history")}
/>
)}
{!hiddenButtons?.includes("draw") && (
<ToolbarButton
icon={<EditOutlinedIcon />}
name="标记绘制"
isActive={activeTools.includes("draw")}
onClick={() => handleToolClick("draw")}
/>
)}
{!hiddenButtons?.includes("style") && (
<ToolbarButton
icon={<PaletteOutlinedIcon />}
name="图层样式"
isActive={activeTools.includes("style")}
onClick={() => handleToolClick("style")}
/>
)}
</div>
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
{showDrawPanel && map && <DrawPanel />}
<div style={{ display: showStyleEditor ? "block" : "none" }}>
<StyleEditorPanel
layerStyleStates={layerStyleStates}
setLayerStyleStates={setLayerStyleStates}
/>
</div>
{showHistoryPanel &&
(HistoryPanel ? (
<HistoryPanel
featureInfos={(() => {
if (highlightFeatures.length === 0 || !showHistoryPanel)
return [];
return highlightFeatures
.map((feature) => {
const properties = feature.getProperties();
const id = properties.id;
if (!id) return null;
// 从图层名称推断类型
const layerId =
feature.getId()?.toString().split(".")[0] || "";
let type = "unknown";
if (layerId.includes("pipe")) {
type = "pipe";
} else if (layerId.includes("junction")) {
type = "junction";
} else if (layerId.includes("tank")) {
type = "tank";
} else if (layerId.includes("reservoir")) {
type = "reservoir";
} else if (layerId.includes("pump")) {
type = "pump";
} else if (layerId.includes("valve")) {
type = "valve";
}
// 仅处理 type 为 pipe 或 junction 的情况
if (type !== "pipe" && type !== "junction") {
return null;
}
return [id, type];
})
.filter(Boolean) as [string, string][];
})()}
scheme_type="burst_Analysis"
scheme_name={schemeName}
type={queryType as "realtime" | "scheme" | "none"}
/>
) : (
<HistoryDataPanel
featureInfos={(() => {
if (highlightFeatures.length === 0 || !showHistoryPanel)
return [];
return highlightFeatures
.map((feature) => {
const properties = feature.getProperties();
const id = properties.id;
if (!id) return null;
// 从图层名称推断类型
const layerId =
feature.getId()?.toString().split(".")[0] || "";
let type = "unknown";
if (layerId.includes("pipe")) {
type = "pipe";
} else if (layerId.includes("junction")) {
type = "junction";
} else if (layerId.includes("tank")) {
type = "tank";
} else if (layerId.includes("reservoir")) {
type = "reservoir";
} else if (layerId.includes("pump")) {
type = "pump";
} else if (layerId.includes("valve")) {
type = "valve";
}
// 仅处理 type 为 pipe 或 junction 的情况
if (type !== "pipe" && type !== "junction") {
return null;
}
return [id, type];
})
.filter(Boolean) as [string, string][];
})()}
scheme_type="burst_Analysis"
scheme_name={schemeName}
type={queryType as "realtime" | "scheme" | "none"}
/>
))}
{/* 图例显示 */}
{activeLegendConfigs.length > 0 && (
<div className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
<div className="flex flex-row gap-3">
{activeLegendConfigs.map((config, index) => (
<StyleLegend key={`${config.layerId}-${index}`} {...config} />
))}
</div>
</div>
)}
</>
);
};
export default Toolbar;
@@ -0,0 +1,65 @@
import React from "react";
import { useMap } from "../MapComponent";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
import FitScreenIcon from "@mui/icons-material/FitScreen";
import { config } from "@config/config";
const Zoom: React.FC = () => {
const map = useMap();
// 放大函数
const handleZoomIn = () => {
if (!map) return;
const view = map.getView();
view.animate({ zoom: (view.getZoom() ?? 0) + 1, duration: 200 });
};
// 缩小函数
const handleZoomOut = () => {
if (!map) return;
const view = map.getView();
view.animate({ zoom: (view.getZoom() ?? 0) - 1, duration: 200 });
};
// 缩放到全局 Extent
const handleFitScreen = () => {
if (!map) return;
const view = map.getView();
view.fit(config.MAP_EXTENT, { duration: 500 });
};
return (
<div className="absolute right-4 bottom-11 z-1300">
<div className="w-8 h-26 flex flex-col gap-2 items-center">
<div className="w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black">
<button
className="w-6 h-6 flex items-center justify-center rounded-xl hover:bg-white transition-all duration-100"
onClick={handleFitScreen}
>
<FitScreenIcon fontSize="small" />
</button>
</div>
<div className="w-8 h-16 flex flex-col items-center justify-center bg-gray-50 rounded-xl drop-shadow-xl shadow-black">
<button
className="w-6 h-6 flex items-center justify-center rounded-xl hover:bg-white transition-all duration-100"
onClick={handleZoomIn}
aria-label="放大"
>
<AddRoundedIcon />
</button>
<div className="m-0.5 border-t-1 border-gray-300 w-6"></div>
<button
className="w-6 h-6 flex items-center justify-center rounded-xl hover:bg-white transition-all duration-100"
onClick={handleZoomOut}
aria-label="缩小"
>
<RemoveRoundedIcon />
</button>
</div>
</div>
</div>
);
};
export default Zoom;
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
import React from "react";
import Zoom from "./Controls/Zoom";
import BaseLayers from "./Controls/BaseLayers";
import ScaleLine from "./Controls/ScaleLine";
import LayerControl from "./Controls/LayerControl";
interface MapToolsProps {}
const MapTools: React.FC<MapToolsProps> = () => {
return (
<>
<Zoom />
<ScaleLine />
<BaseLayers />
<LayerControl />
{/* 继续添加其他自定义控件 */}
</>
);
};
export default MapTools;