完成页面的基础配置
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
webpack(config) {
|
||||||
|
config.module.rules.push({
|
||||||
|
test: /\.svg$/,
|
||||||
|
use: ['@svgr/webpack'],
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
6549
package-lock.json
generated
6549
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -13,35 +13,44 @@
|
|||||||
"refine": "refine"
|
"refine": "refine"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@refinedev/cli": "^2.16.48",
|
|
||||||
"@refinedev/core": "^5.0.0",
|
|
||||||
"@refinedev/devtools": "^2.0.1",
|
|
||||||
"@refinedev/nextjs-router": "^7.0.0",
|
|
||||||
"@refinedev/kbar": "^2.0.0",
|
|
||||||
"next": "^15.2.4",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"@refinedev/simple-rest": "^6.0.0",
|
|
||||||
"@refinedev/mui": "^7.0.0",
|
|
||||||
"@refinedev/react-hook-form": "^5.0.0",
|
|
||||||
"@mui/icons-material": "^6.1.6",
|
|
||||||
"@emotion/react": "^11.8.2",
|
"@emotion/react": "^11.8.2",
|
||||||
"@emotion/styled": "^11.8.1",
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"@mui/icons-material": "^6.1.6",
|
||||||
"@mui/lab": "^6.0.0-beta.14",
|
"@mui/lab": "^6.0.0-beta.14",
|
||||||
"@mui/material": "^6.1.7",
|
"@mui/material": "^6.1.7",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
|
"@refinedev/cli": "^2.16.48",
|
||||||
|
"@refinedev/core": "^5.0.0",
|
||||||
|
"@refinedev/devtools": "^2.0.1",
|
||||||
|
"@refinedev/kbar": "^2.0.0",
|
||||||
|
"@refinedev/mui": "^7.0.0",
|
||||||
|
"@refinedev/nextjs-router": "^7.0.0",
|
||||||
|
"@refinedev/react-hook-form": "^5.0.0",
|
||||||
|
"@refinedev/simple-rest": "^6.0.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
|
"@turf/turf": "^7.2.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"deck.gl": "^9.1.14",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next-auth": "^4.24.5"
|
"next": "^15.2.4",
|
||||||
|
"next-auth": "^4.24.5",
|
||||||
|
"ol": "^10.6.1",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"tailwindcss": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@svgr/webpack": "^8.1.0",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/node": "^20",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@types/node": "^20",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "^15.0.3",
|
"eslint-config-next": "^15.0.3",
|
||||||
"@types/js-cookie": "^3.0.6"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"refine": {
|
"refine": {
|
||||||
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
"projectId": "4LwOCL-BBaV29-qUYMAJ"
|
||||||
|
|||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
250
src/app/OlMap/Controls/BaseLayers.tsx
Normal file
250
src/app/OlMap/Controls/BaseLayers.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
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 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-outdoors";
|
||||||
|
|
||||||
|
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 outdoorsLayer = new TileLayer({
|
||||||
|
source: new XYZ({
|
||||||
|
url: `https://api.mapbox.com/styles/v1/mapbox/outdoors-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 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-outdoors",
|
||||||
|
name: "户外地图",
|
||||||
|
layer: outdoorsLayer,
|
||||||
|
// layer: tiandituVectorLayerGroup,
|
||||||
|
img: mapboxOutdoors.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-8">
|
||||||
|
<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;
|
||||||
382
src/app/OlMap/Controls/DrawPanel.tsx
Normal file
382
src/app/OlMap/Controls/DrawPanel.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
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)`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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;
|
||||||
63
src/app/OlMap/Controls/PropertyPanel.tsx
Normal file
63
src/app/OlMap/Controls/PropertyPanel.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface BaseProperty {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
unit?: string;
|
||||||
|
formatter?: (value: string | number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropertyPanelProps {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
properties?: BaseProperty[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||||
|
id,
|
||||||
|
type = "未知类型",
|
||||||
|
properties = [],
|
||||||
|
}) => {
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<div className="absolute top-0 right-0 h-auto bg-white p-6 rounded-bl-2xl shadow-lg min-w-[320px]">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">属性面板</h3>
|
||||||
|
<p className="text-gray-500">请选择一个要素以查看其属性。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatValue = (property: BaseProperty) => {
|
||||||
|
if (property.formatter) {
|
||||||
|
return property.formatter(property.value);
|
||||||
|
}
|
||||||
|
if (property.unit) {
|
||||||
|
return `${property.value} ${property.unit}`;
|
||||||
|
}
|
||||||
|
return property.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-0 right-0 h-auto bg-white p-6 rounded-bl-2xl shadow-lg min-w-[320px]">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">属性面板</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">ID:</span>
|
||||||
|
<span>{id}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">类型:</span>
|
||||||
|
<span>{type}</span>
|
||||||
|
</div>
|
||||||
|
{properties.map((property, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<span className="font-medium">{property.label}:</span>
|
||||||
|
<span>{formatValue(property)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyPanel;
|
||||||
47
src/app/OlMap/Controls/ScaleLine.tsx
Normal file
47
src/app/OlMap/Controls/ScaleLine.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useMap } from "../MapComponent";
|
||||||
|
|
||||||
|
const Scale: React.FC = () => {
|
||||||
|
const map = useMap();
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(0);
|
||||||
|
const [coordinates, setCoordinates] = useState<[number, number]>([0, 0]);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.un("moveend", updateZoomLevel);
|
||||||
|
map.un("pointermove", updateCoordinates);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-0 right-0 flex col-auto px-2 bg-white bg-opacity-70 text-black rounded-tl shadow-md text-sm">
|
||||||
|
<div className="px-1">Zoom Level: {zoomLevel.toFixed(1)}</div>
|
||||||
|
<div className="px-1">
|
||||||
|
Coordinates: {coordinates[0]}, {coordinates[1]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Scale;
|
||||||
1005
src/app/OlMap/Controls/StyleEditorPanel.tsx
Normal file
1005
src/app/OlMap/Controls/StyleEditorPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
74
src/app/OlMap/Controls/StyleLegend.tsx
Normal file
74
src/app/OlMap/Controls/StyleLegend.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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[]; // 分段值
|
||||||
|
}
|
||||||
|
// 图例组件
|
||||||
|
// 该组件用于显示图层样式的图例,包含属性名称、颜色、尺寸和分段值等信息
|
||||||
|
// 通过传入的配置对象动态生成图例内容,适用于不同的样式配置
|
||||||
|
// 使用时需要确保传入的 colors、dimensions 和 breaks 数组长度一致
|
||||||
|
|
||||||
|
const StyleLegend: React.FC<LegendStyleConfig> = ({
|
||||||
|
layerName,
|
||||||
|
layerId,
|
||||||
|
property,
|
||||||
|
colors,
|
||||||
|
type, // 图例类型
|
||||||
|
dimensions,
|
||||||
|
breaks,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={layerId}
|
||||||
|
className="bg-white p-3 rounded-xl shadow-lg max-w-xs opacity-95"
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
{layerName} - {property}
|
||||||
|
</Typography>
|
||||||
|
{breaks.map((breakValue, index) => {
|
||||||
|
const color = colors[index]; // 默认颜色为黑色
|
||||||
|
// 获取对应的尺寸和分段值
|
||||||
|
const dimension = dimensions[index]; // 默认尺寸为16
|
||||||
|
const nextValue = breaks[index + 1]; // 下一个值或默认0
|
||||||
|
// 确保分段区间均有意义
|
||||||
|
if (nextValue !== undefined) {
|
||||||
|
return (
|
||||||
|
<Box key={index} className="flex items-center gap-2 mb-1">
|
||||||
|
<Box
|
||||||
|
sx={
|
||||||
|
type === "point"
|
||||||
|
? {
|
||||||
|
width: dimension,
|
||||||
|
height: dimension,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: color,
|
||||||
|
// border: `1px solid ${color}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: 16,
|
||||||
|
height: dimension,
|
||||||
|
backgroundColor: color,
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" className="text-xs">
|
||||||
|
{breakValue?.toFixed(1)} - {nextValue?.toFixed(1)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StyleLegend;
|
||||||
|
export type { LegendStyleConfig };
|
||||||
0
src/app/OlMap/Controls/Timeline.tsx
Normal file
0
src/app/OlMap/Controls/Timeline.tsx
Normal file
471
src/app/OlMap/Controls/Toolbar.tsx
Normal file
471
src/app/OlMap/Controls/Toolbar.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { 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 PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
||||||
|
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
||||||
|
|
||||||
|
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 { Point, LineString, Polygon } from "ol/geom";
|
||||||
|
import { FeatureLike } from "ol/Feature";
|
||||||
|
import Feature from "ol/Feature";
|
||||||
|
import GeoJSON from "ol/format/GeoJSON";
|
||||||
|
import StyleEditorPanel from "./StyleEditorPanel";
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import TileState from "ol/TileState";
|
||||||
|
import { toLonLat } from "ol/proj";
|
||||||
|
import { booleanIntersects, buffer, point, toWgs84 } from "@turf/turf";
|
||||||
|
import RenderFeature from "ol/render/Feature";
|
||||||
|
|
||||||
|
const Toolbar: React.FC = () => {
|
||||||
|
const map = useMap();
|
||||||
|
const [activeTools, setActiveTools] = useState<string[]>([]);
|
||||||
|
const [highlightFeature, setHighlightFeature] = useState<FeatureLike | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [showPropertyPanel, setShowPropertyPanel] = useState<boolean>(false);
|
||||||
|
const [showDrawPanel, setShowDrawPanel] = useState<boolean>(false);
|
||||||
|
const [showStyleEditor, setShowStyleEditor] = useState<boolean>(false);
|
||||||
|
const [highlightLayer, setHighlightLayer] =
|
||||||
|
useState<VectorLayer<VectorSource> | null>(null);
|
||||||
|
|
||||||
|
// 创建高亮图层
|
||||||
|
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)`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer(highLightLayer);
|
||||||
|
setHighlightLayer(highLightLayer);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.removeLayer(highLightLayer);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// 高亮要素的函数
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightLayer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = highlightLayer.getSource();
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 清除之前的高亮
|
||||||
|
source.clear();
|
||||||
|
// 添加新的高亮要素
|
||||||
|
if (highlightFeature instanceof Feature) {
|
||||||
|
source.addFeature(highlightFeature);
|
||||||
|
}
|
||||||
|
}, [highlightFeature]);
|
||||||
|
// 将 RenderFeature 转换为 Feature
|
||||||
|
const renderFeature2Feature = (renderFeature: RenderFeature) => {
|
||||||
|
if (renderFeature) {
|
||||||
|
const geometry = renderFeature.getGeometry();
|
||||||
|
|
||||||
|
if (geometry) {
|
||||||
|
try {
|
||||||
|
let clonedGeometry;
|
||||||
|
|
||||||
|
if (geometry instanceof Geometry) {
|
||||||
|
// 标准 Feature 的几何体
|
||||||
|
clonedGeometry = geometry;
|
||||||
|
} else {
|
||||||
|
// RenderFeature 或其他类型的几何体
|
||||||
|
const type = geometry.getType();
|
||||||
|
const flatCoordinates = geometry.getFlatCoordinates();
|
||||||
|
let coordinates: number[] | number[][] | number[][][];
|
||||||
|
switch (type) {
|
||||||
|
case "Point":
|
||||||
|
// Point: [x, y]
|
||||||
|
coordinates = [flatCoordinates[0], flatCoordinates[1]];
|
||||||
|
clonedGeometry = new Point(coordinates as number[]);
|
||||||
|
break;
|
||||||
|
case "LineString":
|
||||||
|
// LineString: [[x1, y1], [x2, y2], ...]
|
||||||
|
const lineCoords: number[][] = [];
|
||||||
|
for (let i = 0; i < flatCoordinates.length; i += 2) {
|
||||||
|
lineCoords.push([flatCoordinates[i], flatCoordinates[i + 1]]);
|
||||||
|
}
|
||||||
|
clonedGeometry = new LineString(lineCoords);
|
||||||
|
break;
|
||||||
|
case "Polygon":
|
||||||
|
// Polygon: [[[x1, y1], [x2, y2], ...]]
|
||||||
|
// 需要获取环的结束位置
|
||||||
|
const ends = (
|
||||||
|
geometry as { getEnds?: () => number[] }
|
||||||
|
).getEnds?.() || [flatCoordinates.length];
|
||||||
|
const rings: number[][][] = [];
|
||||||
|
let start = 0;
|
||||||
|
|
||||||
|
for (const end of ends) {
|
||||||
|
const ring: number[][] = [];
|
||||||
|
for (let i = start; i < end; i += 2) {
|
||||||
|
ring.push([flatCoordinates[i], flatCoordinates[i + 1]]);
|
||||||
|
}
|
||||||
|
rings.push(ring);
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
clonedGeometry = new Polygon(rings);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log("不支持的几何体类型:", type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry: clonedGeometry,
|
||||||
|
...renderFeature.getProperties(),
|
||||||
|
});
|
||||||
|
return feature;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("RenderFeature转换Feature时出错:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 根据 IDs,通过 Geoserver WFS 服务查询要素
|
||||||
|
const queryFeaturesByIds = async (ids: string[], layer?: string) => {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const geoserverUrl = "http://127.0.0.1:8080/geoserver";
|
||||||
|
const network = "TJWater";
|
||||||
|
const layers = ["geo_pipes_mat", "geo_junctions_mat"];
|
||||||
|
const orFilter = ids.map((id) => `id=${id}`).join(" OR ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!layer) {
|
||||||
|
// 遍历所有图层
|
||||||
|
const promises = layers.map(async (layer) => {
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
`${geoserverUrl}/${network}/ows?` +
|
||||||
|
`service=WFS&version=1.0.0&request=GetFeature&` +
|
||||||
|
`typeName=${network}:${layer}&outputFormat=application/json&` +
|
||||||
|
`CQL_FILTER=${encodeURIComponent(orFilter)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`请求失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`图层 ${layer} 查询失败:`, error);
|
||||||
|
return null; // 返回 null 表示该图层查询失败
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const features = results
|
||||||
|
.filter((json) => json !== null) // 过滤掉失败的请求
|
||||||
|
.flatMap((json) => new GeoJSON().readFeatures(json));
|
||||||
|
console.log("查询到的要素:", features);
|
||||||
|
return features;
|
||||||
|
} else {
|
||||||
|
// 查询指定图层
|
||||||
|
const url =
|
||||||
|
`${geoserverUrl}/${network}/ows?` +
|
||||||
|
`service=WFS&version=1.0.0&request=GetFeature&` +
|
||||||
|
`typeName=${network}:${layer}&outputFormat=application/json&` +
|
||||||
|
`CQL_FILTER=${encodeURIComponent(orFilter)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`请求失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
const features = new GeoJSON().readFeatures(json);
|
||||||
|
console.log("查询到的要素:", features);
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("根据 IDs 查询要素时出错:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 处理地图点击选择要素
|
||||||
|
const handleMapClickSelectFeatures = useCallback(
|
||||||
|
(event: { coordinate: number[] }) => {
|
||||||
|
if (!map) return;
|
||||||
|
const coord = event.coordinate;
|
||||||
|
let z = Math.floor(map.getView().getZoom() || 0) - 1; // 确保 z 是整数
|
||||||
|
const projection = map.getView().getProjection(); // 获取地图的投影
|
||||||
|
const pixelRatio = window.devicePixelRatio; // 获取设备像素比率
|
||||||
|
const [x, y] = coord;
|
||||||
|
|
||||||
|
// 遍历所有的 VectorTileSources
|
||||||
|
const vectorTileSources = map
|
||||||
|
.getAllLayers()
|
||||||
|
.filter((layer) => layer instanceof WebGLVectorTileLayer)
|
||||||
|
.map((layer) => layer.getSource() as VectorTileSource)
|
||||||
|
.filter((source) => source);
|
||||||
|
if (!vectorTileSources.length) return;
|
||||||
|
|
||||||
|
// 按几何类型分类,优先处理级别
|
||||||
|
const points: any[] = [];
|
||||||
|
const lines: any[] = [];
|
||||||
|
const others: any[] = [];
|
||||||
|
|
||||||
|
vectorTileSources.forEach((vectorTileSource) => {
|
||||||
|
const tileGrid = vectorTileSource.getTileGrid();
|
||||||
|
if (tileGrid) {
|
||||||
|
const minZoom = tileGrid.getMinZoom(); // 最小缩放级别
|
||||||
|
const maxZoom = tileGrid.getMaxZoom(); // 最大缩放级别
|
||||||
|
// 确保 z 在有效范围内
|
||||||
|
if (z < minZoom) z = minZoom;
|
||||||
|
if (z > maxZoom) z = maxZoom;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileCoord = tileGrid.getTileCoordForCoordAndZ([x, y], z);
|
||||||
|
// 设置 resolution 用于基于屏幕像素的 buffer 容差计算
|
||||||
|
const resolution = tileGrid.getResolution(tileCoord[0]);
|
||||||
|
const hitTolerance = 5; // 像素容差
|
||||||
|
const hitPoint = point(toLonLat(coord));
|
||||||
|
const buffered = buffer(hitPoint, resolution * hitTolerance, {
|
||||||
|
units: "meters",
|
||||||
|
});
|
||||||
|
// 获取 VectorRenderTile
|
||||||
|
const vectorRenderTile = vectorTileSource.getTile(
|
||||||
|
tileCoord[0],
|
||||||
|
tileCoord[1],
|
||||||
|
tileCoord[2],
|
||||||
|
pixelRatio,
|
||||||
|
projection
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取 SourceTiles
|
||||||
|
const vectorTiles = vectorTileSource.getSourceTiles(
|
||||||
|
pixelRatio,
|
||||||
|
projection,
|
||||||
|
vectorRenderTile
|
||||||
|
);
|
||||||
|
|
||||||
|
vectorTiles.forEach((vectorTile) => {
|
||||||
|
if (vectorTile.getState() === TileState.LOADED) {
|
||||||
|
const renderFeatures = vectorTile.getFeatures();
|
||||||
|
|
||||||
|
const selectedFeatures = renderFeatures
|
||||||
|
.map(
|
||||||
|
(renderFeature) =>
|
||||||
|
renderFeature2Feature(renderFeature) as Feature<any>
|
||||||
|
)
|
||||||
|
.filter((feature) => {
|
||||||
|
if (feature && buffered) {
|
||||||
|
const geoJSONGeometry = new GeoJSON().writeGeometryObject(
|
||||||
|
feature.getGeometry()
|
||||||
|
);
|
||||||
|
const bufferedGeometry = buffered.geometry;
|
||||||
|
return booleanIntersects(
|
||||||
|
toWgs84(geoJSONGeometry),
|
||||||
|
bufferedGeometry
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
selectedFeatures.forEach((selectedFeature) => {
|
||||||
|
const geometryType = selectedFeature.getGeometry()?.getType();
|
||||||
|
if (geometryType === "Point") {
|
||||||
|
points.push(selectedFeature);
|
||||||
|
} else if (geometryType === "LineString") {
|
||||||
|
lines.push(selectedFeature);
|
||||||
|
} else {
|
||||||
|
others.push(selectedFeature);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// 按优先级处理:点 > 线 > 其他
|
||||||
|
const selectedFeatures = [...points, ...lines, ...others];
|
||||||
|
const firstFeature = selectedFeatures[0] as Feature<any>;
|
||||||
|
const queryId = firstFeature?.getProperties().id;
|
||||||
|
// console.log(queryId, "queryId");
|
||||||
|
if (queryId) {
|
||||||
|
queryFeaturesByIds([queryId]).then((features) => {
|
||||||
|
// console.log("查询到的要素:", features);
|
||||||
|
setHighlightFeature(features[0]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setHighlightFeature(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[map, highlightLayer, setHighlightFeature]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 添加矢量属性查询事件监听器
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTools.includes("info") || !map) return;
|
||||||
|
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);
|
||||||
|
setHighlightFeature(null);
|
||||||
|
break;
|
||||||
|
case "draw":
|
||||||
|
setShowDrawPanel(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 激活指定工具并打开对应面板
|
||||||
|
const activateTool = (tool: string) => {
|
||||||
|
switch (tool) {
|
||||||
|
case "info":
|
||||||
|
setShowPropertyPanel(true);
|
||||||
|
break;
|
||||||
|
case "draw":
|
||||||
|
setShowDrawPanel(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭所有面板(除了样式编辑器)
|
||||||
|
const closeAllPanelsExceptStyle = () => {
|
||||||
|
setShowPropertyPanel(false);
|
||||||
|
setHighlightFeature(null);
|
||||||
|
setShowDrawPanel(false);
|
||||||
|
// 样式编辑器保持其当前状态,不自动关闭
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从要素属性中提取属性面板需要的数据
|
||||||
|
const getFeatureProperties = useCallback(() => {
|
||||||
|
if (!highlightFeature) return {};
|
||||||
|
|
||||||
|
const properties = highlightFeature.getProperties();
|
||||||
|
console.log(properties, properties.geometry.type, "properties");
|
||||||
|
if (properties.geometry.getType() === "LineString") {
|
||||||
|
console.log(properties, "properties");
|
||||||
|
return {
|
||||||
|
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 (properties.geometry.getType() === "Point") {
|
||||||
|
return {
|
||||||
|
id: properties.id,
|
||||||
|
type: "节点",
|
||||||
|
properties: [
|
||||||
|
{ label: "海拔", value: properties.elevation.toFixed(1), unit: "m" },
|
||||||
|
{
|
||||||
|
label: "需求量",
|
||||||
|
value: properties.demand.toFixed(1),
|
||||||
|
unit: "m³/s",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}, [highlightFeature]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-4 left-4 bg-white p-1 rounded-xl shadow-lg flex opacity-85 hover:opacity-100 transition-opacity">
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<InfoOutlinedIcon />}
|
||||||
|
name="查看属性"
|
||||||
|
isActive={activeTools.includes("info")}
|
||||||
|
onClick={() => handleToolClick("info")}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<EditOutlinedIcon />}
|
||||||
|
name="矢量编辑"
|
||||||
|
isActive={activeTools.includes("draw")}
|
||||||
|
onClick={() => handleToolClick("draw")}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<PaletteOutlinedIcon />}
|
||||||
|
name="图层样式"
|
||||||
|
isActive={activeTools.includes("style")}
|
||||||
|
onClick={() => handleToolClick("style")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
|
||||||
|
{showDrawPanel && map && <DrawPanel />}
|
||||||
|
{showStyleEditor && <StyleEditorPanel />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toolbar;
|
||||||
104
src/app/OlMap/Controls/Zoom.tsx
Normal file
104
src/app/OlMap/Controls/Zoom.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useMap } from "../MapComponent";
|
||||||
|
import Geolocation from "ol/Geolocation";
|
||||||
|
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||||
|
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
|
||||||
|
import GpsFixedRoundedIcon from "@mui/icons-material/GpsFixedRounded";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const INITIAL_ZOOM = 14; // 默认缩放级别
|
||||||
|
|
||||||
|
const Zoom: React.FC = () => {
|
||||||
|
const map = useMap();
|
||||||
|
const [locateDisabled, setLocateDisabled] = useState(false);
|
||||||
|
|
||||||
|
// 放大函数
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定位功能
|
||||||
|
const handleLocate = () => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const geolocation = new Geolocation({
|
||||||
|
trackingOptions: { enableHighAccuracy: true },
|
||||||
|
projection: map.getView().getProjection(),
|
||||||
|
});
|
||||||
|
|
||||||
|
geolocation.once("change:position", () => {
|
||||||
|
const coords = geolocation.getPosition();
|
||||||
|
if (coords) {
|
||||||
|
map
|
||||||
|
.getView()
|
||||||
|
.animate({ center: coords, zoom: INITIAL_ZOOM, duration: 500 });
|
||||||
|
}
|
||||||
|
geolocation.setTracking(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
geolocation.setTracking(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 包装 handleLocate,点击后禁用按钮一段时间
|
||||||
|
const onLocateClick = () => {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
() => {
|
||||||
|
handleLocate();
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log(error.message);
|
||||||
|
setLocateDisabled(true); // 定位失败后禁用按钮
|
||||||
|
// alert("定位失败,将使用默认位置。");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute right-4 bottom-8">
|
||||||
|
<div className="w-8 h-26 flex flex-col gap-2 items-center">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black",
|
||||||
|
locateDisabled && "text-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-xl hover:bg-white transition-all duration-100"
|
||||||
|
onClick={onLocateClick}
|
||||||
|
disabled={locateDisabled}
|
||||||
|
>
|
||||||
|
<GpsFixedRoundedIcon 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;
|
||||||
547
src/app/OlMap/MapComponent.tsx
Normal file
547
src/app/OlMap/MapComponent.tsx
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
// @refresh reset // 添加此注释强制热重载时重新挂载组件
|
||||||
|
"use client";
|
||||||
|
import { config } from "@/config/config";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { Map as OlMap, VectorTile } from "ol";
|
||||||
|
import View from "ol/View.js";
|
||||||
|
import "ol/ol.css";
|
||||||
|
import MapTools from "./MapTools";
|
||||||
|
|
||||||
|
import { Layer } from "ol/layer"; // 保留导入,但用于继承
|
||||||
|
import VectorTileSource from "ol/source/VectorTile";
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
import MVT from "ol/format/MVT";
|
||||||
|
import { FlatStyleLike } from "ol/style/flat";
|
||||||
|
import { toLonLat } from "ol/proj";
|
||||||
|
import { center } from "@turf/center";
|
||||||
|
import { bearing } from "@turf/turf";
|
||||||
|
import { Deck } from "@deck.gl/core";
|
||||||
|
import { TextLayer } from "@deck.gl/layers";
|
||||||
|
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||||
|
|
||||||
|
// 创建自定义Layer类来包装deck.gl
|
||||||
|
class DeckLayer extends Layer {
|
||||||
|
private deck: Deck;
|
||||||
|
|
||||||
|
constructor(deckInstance: Deck) {
|
||||||
|
super({});
|
||||||
|
this.deck = deckInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(frameState: any): HTMLElement {
|
||||||
|
const { size, viewState } = frameState;
|
||||||
|
const [width, height] = size;
|
||||||
|
const [longitude, latitude] = toLonLat(viewState.center);
|
||||||
|
const zoom = viewState.zoom - 1; // 调整 zoom 以匹配
|
||||||
|
const bearing = (-viewState.rotation * 180) / Math.PI;
|
||||||
|
const deckViewState = { bearing, longitude, latitude, zoom };
|
||||||
|
this.deck.setProps({ width, height, viewState: deckViewState });
|
||||||
|
this.deck.redraw();
|
||||||
|
// 返回deck.gl的canvas元素
|
||||||
|
return document.getElementById("deck-canvas") as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 跨组件传递
|
||||||
|
const MapContext = createContext<OlMap | undefined>(undefined);
|
||||||
|
const extent = config.mapExtent;
|
||||||
|
const backendUrl = config.backendUrl;
|
||||||
|
const mapUrl = config.mapUrl;
|
||||||
|
|
||||||
|
// 添加防抖函数
|
||||||
|
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return (...args: Parameters<F>): void => {
|
||||||
|
if (timeout !== null) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => func(...args), waitFor);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMap = () => {
|
||||||
|
return useContext(MapContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MapComponent: React.FC = () => {
|
||||||
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const deckRef = useRef<Deck | null>(null);
|
||||||
|
|
||||||
|
const [map, setMap] = useState<OlMap>();
|
||||||
|
const [currentTime, setCurrentTime] = useState(
|
||||||
|
new Date("2025-09-17T00:30:00+08:00")
|
||||||
|
);
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
||||||
|
const [pipeData, setPipeDataState] = useState<any[]>([]);
|
||||||
|
const junctionDataIds = useRef(new Set<string>());
|
||||||
|
const pipeDataIds = useRef(new Set<string>());
|
||||||
|
const tileJunctionDataBuffer = useRef<any[]>([]);
|
||||||
|
const tilePipeDataBuffer = useRef<any[]>([]);
|
||||||
|
|
||||||
|
let showJunctionText = true; // 控制节点文本显示
|
||||||
|
let showPipeText = true; // 控制管道文本显示
|
||||||
|
let junctionText = "pressure";
|
||||||
|
let pipeText = "flow";
|
||||||
|
const isAnimating = useRef(false); // 添加动画控制标志
|
||||||
|
|
||||||
|
// 防抖更新函数
|
||||||
|
const debouncedUpdateData = useRef(
|
||||||
|
debounce(() => {
|
||||||
|
if (tileJunctionDataBuffer.current.length > 0) {
|
||||||
|
setJunctionData(tileJunctionDataBuffer.current);
|
||||||
|
tileJunctionDataBuffer.current = [];
|
||||||
|
}
|
||||||
|
if (tilePipeDataBuffer.current.length > 0) {
|
||||||
|
setPipeData(tilePipeDataBuffer.current);
|
||||||
|
tilePipeDataBuffer.current = [];
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
const setJunctionData = (newData: any[]) => {
|
||||||
|
const uniqueNewData = newData.filter((item) => {
|
||||||
|
if (!item || !item.id) return false;
|
||||||
|
if (!junctionDataIds.current.has(item.id)) {
|
||||||
|
junctionDataIds.current.add(item.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (uniqueNewData.length > 0) {
|
||||||
|
setJunctionDataState((prev) => [...prev, ...uniqueNewData]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const setPipeData = (newData: any[]) => {
|
||||||
|
const uniqueNewData = newData.filter((item) => {
|
||||||
|
if (!item || !item.id) return false;
|
||||||
|
if (!pipeDataIds.current.has(item.id)) {
|
||||||
|
pipeDataIds.current.add(item.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (uniqueNewData.length > 0) {
|
||||||
|
setPipeDataState((prev) => [...prev, ...uniqueNewData]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFrameData = async (queryTime: Date) => {
|
||||||
|
const query_time = queryTime.toISOString();
|
||||||
|
console.log("Query Time:", query_time);
|
||||||
|
try {
|
||||||
|
// 定义需要查询的属性
|
||||||
|
const junctionProperties = junctionText;
|
||||||
|
const pipeProperties = pipeText;
|
||||||
|
// 同时查询节点和管道数据
|
||||||
|
const starttime = Date.now();
|
||||||
|
const [nodeResponse, linkResponse] = await Promise.all([
|
||||||
|
fetch(
|
||||||
|
`${backendUrl}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${backendUrl}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nodeRecords = await nodeResponse.json();
|
||||||
|
const linkRecords = await linkResponse.json();
|
||||||
|
// 将 nodeRecords 转换为 Map 以提高查找效率
|
||||||
|
const nodeMap: Map<string, any> = new Map(
|
||||||
|
nodeRecords.results.map((r: any) => [r.ID, r])
|
||||||
|
);
|
||||||
|
// 将 linkRecords 转换为 Map 以提高查找效率
|
||||||
|
const linkMap: Map<string, any> = new Map(
|
||||||
|
linkRecords.results.map((r: any) => [r.ID, r])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新junctionData
|
||||||
|
setJunctionDataState((prev) =>
|
||||||
|
prev.map((j) => {
|
||||||
|
const record = nodeMap.get(j.id);
|
||||||
|
if (record) {
|
||||||
|
return {
|
||||||
|
...j,
|
||||||
|
[junctionProperties]: record.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新pipeData
|
||||||
|
setPipeDataState((prev) =>
|
||||||
|
prev.map((p) => {
|
||||||
|
const record = linkMap.get(p.id);
|
||||||
|
if (record) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
flowFlag: pipeProperties === "flow" && record.value < 0 ? -1 : 1,
|
||||||
|
path:
|
||||||
|
pipeProperties === "flow" && record.value < 0 && p.flowFlag > 0
|
||||||
|
? [...p.path].reverse()
|
||||||
|
: p.path,
|
||||||
|
[pipeProperties]: record.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// 属性为 flow 时启动动画
|
||||||
|
if (pipeProperties === "flow") {
|
||||||
|
isAnimating.current = true;
|
||||||
|
} else {
|
||||||
|
isAnimating.current = false;
|
||||||
|
}
|
||||||
|
const endtime = Date.now();
|
||||||
|
console.log("Data fetch and update time:", endtime - starttime, "ms");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
// 添加 MVT 瓦片加载逻辑
|
||||||
|
const defaultFlatStyle: FlatStyleLike = {
|
||||||
|
"stroke-width": 3,
|
||||||
|
"stroke-color": "rgba(51, 153, 204, 0.9)",
|
||||||
|
"circle-fill-color": "rgba(255,255,255,0.4)",
|
||||||
|
"circle-stroke-color": "rgba(255,255,255,0.9)",
|
||||||
|
"circle-radius": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
12,
|
||||||
|
1, // 在缩放级别 12 时,圆形半径为 1px
|
||||||
|
24,
|
||||||
|
12, // 在缩放级别 24 时,圆形半径为 12px
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const junctionSource = new VectorTileSource({
|
||||||
|
url: `${mapUrl}/gwc/service/tms/1.0.0/TJWater:geo_junctions_mat@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL
|
||||||
|
format: new MVT(),
|
||||||
|
projection: "EPSG:3857",
|
||||||
|
});
|
||||||
|
const pipeSource = new VectorTileSource({
|
||||||
|
url: `${mapUrl}/gwc/service/tms/1.0.0/TJWater:geo_pipes_mat@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, // 替换为你的 MVT 瓦片服务 URL
|
||||||
|
format: new MVT(),
|
||||||
|
projection: "EPSG:3857",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 缓存数据
|
||||||
|
junctionSource.on("tileloadend", (event) => {
|
||||||
|
try {
|
||||||
|
if (event.tile instanceof VectorTile) {
|
||||||
|
const renderFeatures = event.tile.getFeatures();
|
||||||
|
const data = new Map();
|
||||||
|
|
||||||
|
renderFeatures.forEach((renderFeature) => {
|
||||||
|
const props = renderFeature.getProperties();
|
||||||
|
const featureId = props.id;
|
||||||
|
if (featureId && !junctionDataIds.current.has(featureId)) {
|
||||||
|
const geometry = renderFeature.getGeometry();
|
||||||
|
if (geometry) {
|
||||||
|
const coordinates = geometry.getFlatCoordinates();
|
||||||
|
const coordWGS84 = toLonLat(coordinates);
|
||||||
|
data.set(featureId, {
|
||||||
|
id: featureId,
|
||||||
|
position: coordWGS84,
|
||||||
|
elevation: props.elevation || 0,
|
||||||
|
demand: props.demand || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueData = Array.from(data.values());
|
||||||
|
if (uniqueData.length > 0) {
|
||||||
|
tileJunctionDataBuffer.current.push(...uniqueData);
|
||||||
|
debouncedUpdateData.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Junction tile load error:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pipeSource.on("tileloadend", (event) => {
|
||||||
|
try {
|
||||||
|
if (event.tile instanceof VectorTile) {
|
||||||
|
const renderFeatures = event.tile.getFeatures();
|
||||||
|
const data = new Map();
|
||||||
|
|
||||||
|
renderFeatures.forEach((renderFeature) => {
|
||||||
|
try {
|
||||||
|
const props = renderFeature.getProperties();
|
||||||
|
const featureId = props.id;
|
||||||
|
if (featureId && !pipeDataIds.current.has(featureId)) {
|
||||||
|
const geometry = renderFeature.getGeometry();
|
||||||
|
if (geometry) {
|
||||||
|
const flatCoordinates = geometry.getFlatCoordinates();
|
||||||
|
const stride = geometry.getStride(); // 获取步长,通常为 2
|
||||||
|
// 重建为 LineString GeoJSON 格式的 coordinates: [[x1, y1], [x2, y2], ...]
|
||||||
|
const lineCoords = [];
|
||||||
|
for (let i = 0; i < flatCoordinates.length; i += stride) {
|
||||||
|
lineCoords.push([
|
||||||
|
flatCoordinates[i],
|
||||||
|
flatCoordinates[i + 1],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const lineCoordsWGS84 = lineCoords.map((coord) => {
|
||||||
|
const [lon, lat] = toLonLat(coord);
|
||||||
|
return [lon, lat];
|
||||||
|
});
|
||||||
|
// 计算中点
|
||||||
|
const midPoint = center({
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: lineCoordsWGS84,
|
||||||
|
}).geometry.coordinates;
|
||||||
|
// 计算角度
|
||||||
|
let lineAngle = bearing(
|
||||||
|
lineCoordsWGS84[0],
|
||||||
|
lineCoordsWGS84[lineCoordsWGS84.length - 1]
|
||||||
|
);
|
||||||
|
lineAngle = -lineAngle + 90;
|
||||||
|
if (lineAngle < -90 || lineAngle > 90) {
|
||||||
|
lineAngle += 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算时间戳(可选)
|
||||||
|
const numSegments = lineCoordsWGS84.length - 1;
|
||||||
|
const timestamps = [0];
|
||||||
|
if (numSegments > 0) {
|
||||||
|
for (let i = 1; i <= numSegments; i++) {
|
||||||
|
timestamps.push((i / numSegments) * 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.set(featureId, {
|
||||||
|
id: featureId,
|
||||||
|
diameter: props.diameter || 0,
|
||||||
|
path: lineCoordsWGS84, // 使用重建后的坐标
|
||||||
|
position: midPoint,
|
||||||
|
angle: lineAngle,
|
||||||
|
timestamps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (geomError) {
|
||||||
|
console.error("Geometry calculation error:", geomError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueData = Array.from(data.values());
|
||||||
|
if (uniqueData.length > 0) {
|
||||||
|
tilePipeDataBuffer.current.push(...uniqueData);
|
||||||
|
debouncedUpdateData.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Pipe tile load error:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebGL 渲染优化显示
|
||||||
|
const junctionLayer = new WebGLVectorTileLayer({
|
||||||
|
source: junctionSource as any, // 使用 WebGL 渲染
|
||||||
|
style: defaultFlatStyle,
|
||||||
|
extent: extent, // 设置图层范围
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 12,
|
||||||
|
properties: {
|
||||||
|
name: "节点图层", // 设置图层名称
|
||||||
|
value: "junctions",
|
||||||
|
type: "point",
|
||||||
|
properties: [
|
||||||
|
{ name: "需求量", value: "demand" },
|
||||||
|
{ name: "海拔高度", value: "elevation" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pipeLayer = new WebGLVectorTileLayer({
|
||||||
|
source: pipeSource as any, // 使用 WebGL 渲染
|
||||||
|
style: defaultFlatStyle,
|
||||||
|
extent: extent, // 设置图层范围
|
||||||
|
maxZoom: 24,
|
||||||
|
minZoom: 12,
|
||||||
|
properties: {
|
||||||
|
name: "管道图层", // 设置图层名称
|
||||||
|
value: "pipes",
|
||||||
|
type: "linestring",
|
||||||
|
properties: [
|
||||||
|
{ name: "直径", value: "diameter" },
|
||||||
|
{ name: "粗糙度", value: "roughness" },
|
||||||
|
{ name: "局部损失", value: "minor_loss" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const map = new OlMap({
|
||||||
|
target: mapRef.current,
|
||||||
|
view: new View({
|
||||||
|
projection: "EPSG:3857",
|
||||||
|
}),
|
||||||
|
// 图层依面、线、点、标注次序添加
|
||||||
|
layers: [pipeLayer, junctionLayer],
|
||||||
|
controls: [],
|
||||||
|
});
|
||||||
|
setMap(map);
|
||||||
|
map.getView().fit(extent, {
|
||||||
|
padding: [50, 50, 50, 50], // 添加一些内边距
|
||||||
|
duration: 1000, // 动画持续时间
|
||||||
|
});
|
||||||
|
// 初始化 deck.gl
|
||||||
|
const deck = new Deck({
|
||||||
|
initialViewState: {
|
||||||
|
longitude: 0,
|
||||||
|
latitude: 0,
|
||||||
|
zoom: 1,
|
||||||
|
},
|
||||||
|
canvas: "deck-canvas",
|
||||||
|
controller: false, // 由 OpenLayers 控制视图
|
||||||
|
layers: [],
|
||||||
|
});
|
||||||
|
deckRef.current = deck;
|
||||||
|
const deckLayer = new DeckLayer(deck);
|
||||||
|
// deckLayer.setZIndex(1000); // 确保在最上层
|
||||||
|
map.addLayer(deckLayer);
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
map.setTarget(undefined);
|
||||||
|
map.dispose();
|
||||||
|
deck.finalize();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 当数据变化时,更新 deck.gl 图层
|
||||||
|
useEffect(() => {
|
||||||
|
const deck = deckRef.current;
|
||||||
|
if (!deck) return; // 如果 deck 实例还未创建,则退出
|
||||||
|
const newLayers = [
|
||||||
|
new TextLayer({
|
||||||
|
id: "junctionTextLayer",
|
||||||
|
zIndex: 1000,
|
||||||
|
data: showJunctionText ? junctionData : [],
|
||||||
|
getPosition: (d: any) => d.position,
|
||||||
|
fontFamily: "Monaco, monospace",
|
||||||
|
getText: (d: any) =>
|
||||||
|
d[junctionText] ? (d[junctionText] as number).toFixed(3) : "",
|
||||||
|
getSize: 12,
|
||||||
|
getColor: [150, 150, 255],
|
||||||
|
getAngle: 0,
|
||||||
|
getTextAnchor: "middle",
|
||||||
|
getAlignmentBaseline: "center",
|
||||||
|
getPixelOffset: [0, -10],
|
||||||
|
// --- 修改以下属性 ---
|
||||||
|
// characterSet: "auto",
|
||||||
|
// outlineWidth: 4,
|
||||||
|
// outlineColor: [255, 255, 255, 255], // 设置为白色轮廓
|
||||||
|
}),
|
||||||
|
new TextLayer({
|
||||||
|
id: "pipeTextLayer",
|
||||||
|
zIndex: 1000,
|
||||||
|
data: showPipeText ? pipeData : [],
|
||||||
|
getPosition: (d: any) => d.position,
|
||||||
|
fontFamily: "Monaco, monospace",
|
||||||
|
getText: (d: any) =>
|
||||||
|
d[pipeText] ? (d[pipeText] as number).toFixed(3) : "",
|
||||||
|
getSize: 14,
|
||||||
|
getColor: [120, 128, 181],
|
||||||
|
getAngle: (d: any) => d.angle || 0,
|
||||||
|
getPixelOffset: [0, -8],
|
||||||
|
getTextAnchor: "middle",
|
||||||
|
getAlignmentBaseline: "bottom",
|
||||||
|
// --- 修改以下属性 ---
|
||||||
|
// characterSet: "auto",
|
||||||
|
// outlineWidth: 5,
|
||||||
|
// outlineColor: [255, 255, 255, 255], // 设置为白色轮廓
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
deck.setProps({ layers: newLayers });
|
||||||
|
|
||||||
|
// 动画循环
|
||||||
|
const animate = () => {
|
||||||
|
if (!deck || !isAnimating.current) return; // 添加检查,防止空数据或停止旧循环
|
||||||
|
// 动画总时长(秒)
|
||||||
|
if (pipeData.length === 0) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const animationDuration = 10;
|
||||||
|
// 缓冲时间(秒)
|
||||||
|
const bufferTime = 2;
|
||||||
|
// 完整循环周期
|
||||||
|
const loopLength = animationDuration + bufferTime;
|
||||||
|
// 确保时间范围与你的时间戳数据匹配
|
||||||
|
const currentTime = (Date.now() / 1000) % loopLength; // (0,12) 之间循环
|
||||||
|
// console.log("Current Time:", currentTime);
|
||||||
|
const waterflowLayer = new TripsLayer({
|
||||||
|
id: "waterflowLayer",
|
||||||
|
data: pipeData,
|
||||||
|
getPath: (d) => (isAnimating.current ? d.path : []),
|
||||||
|
getTimestamps: (d) => {
|
||||||
|
return d.timestamps; // 这些应该是与 currentTime 匹配的数值
|
||||||
|
},
|
||||||
|
getColor: [0, 220, 255],
|
||||||
|
opacity: 0.8,
|
||||||
|
widthMinPixels: 5,
|
||||||
|
jointRounded: true, // 拐角变圆
|
||||||
|
// capRounded: true, // 端点变圆
|
||||||
|
trailLength: 2, // 水流尾迹淡出时间
|
||||||
|
currentTime: currentTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取当前除 waterflowLayer 之外的所有图层
|
||||||
|
const otherLayers = deck.props.layers.filter(
|
||||||
|
(layer: any) => layer && layer.id !== "waterflowLayer"
|
||||||
|
);
|
||||||
|
|
||||||
|
deck.setProps({
|
||||||
|
layers: [...otherLayers, waterflowLayer],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 继续请求动画帧,每帧执行一次函数
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}, [isAnimating, junctionData, pipeData]);
|
||||||
|
|
||||||
|
// 启动时间更新interval
|
||||||
|
useEffect(() => {
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setCurrentTime((prev) => new Date(prev.getTime() + 1800 * 1000));
|
||||||
|
}, 10 * 1000);
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 当currentTime改变时,获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
await setFrameData(currentTime);
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MapContext.Provider value={map}>
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<div ref={mapRef} className="w-full h-full"></div>
|
||||||
|
<MapTools />
|
||||||
|
</div>
|
||||||
|
<canvas id="deck-canvas" />
|
||||||
|
</MapContext.Provider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MapComponent;
|
||||||
19
src/app/OlMap/MapTools.tsx
Normal file
19
src/app/OlMap/MapTools.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Zoom from "./Controls/Zoom";
|
||||||
|
import BaseLayers from "./Controls/BaseLayers";
|
||||||
|
import MapToolbar from "./Controls/Toolbar";
|
||||||
|
import ScaleLine from "./Controls/ScaleLine";
|
||||||
|
|
||||||
|
const MapTools = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Zoom />
|
||||||
|
<ScaleLine />
|
||||||
|
<BaseLayers />
|
||||||
|
<MapToolbar />
|
||||||
|
{/* 继续添加其他自定义控件 */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MapTools;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GitHubBanner, Refine, type AuthProvider } from "@refinedev/core";
|
import { Refine, type AuthProvider } from "@refinedev/core";
|
||||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||||
import {
|
import {
|
||||||
RefineSnackbarProvider,
|
RefineSnackbarProvider,
|
||||||
@@ -15,6 +15,13 @@ import routerProvider from "@refinedev/nextjs-router";
|
|||||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||||
import { dataProvider } from "@providers/data-provider";
|
import { dataProvider } from "@providers/data-provider";
|
||||||
|
|
||||||
|
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
||||||
|
import { TbDatabaseEdit } from "react-icons/tb";
|
||||||
|
import { LuReplace } from "react-icons/lu";
|
||||||
|
import { PiAsteriskDuotone } from "react-icons/pi";
|
||||||
|
import { TbLocationPin } from "react-icons/tb";
|
||||||
|
import { AiOutlinePartition } from "react-icons/ai";
|
||||||
|
|
||||||
type RefineContextProps = {
|
type RefineContextProps = {
|
||||||
defaultMode?: string;
|
defaultMode?: string;
|
||||||
};
|
};
|
||||||
@@ -105,7 +112,6 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GitHubBanner />
|
|
||||||
<RefineKbarProvider>
|
<RefineKbarProvider>
|
||||||
<ColorModeContextProvider defaultMode={defaultMode}>
|
<ColorModeContextProvider defaultMode={defaultMode}>
|
||||||
<RefineSnackbarProvider>
|
<RefineSnackbarProvider>
|
||||||
@@ -116,23 +122,51 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
resources={[
|
resources={[
|
||||||
{
|
{
|
||||||
name: "blog_posts",
|
name: "管网在线模拟",
|
||||||
list: "/blog-posts",
|
list: "/network-simulation",
|
||||||
create: "/blog-posts/create",
|
|
||||||
edit: "/blog-posts/edit/:id",
|
|
||||||
show: "/blog-posts/show/:id",
|
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||||
|
label: "管网在线模拟",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "categories",
|
name: "SCADA 数据清洗",
|
||||||
list: "/categories",
|
list: "/scada-data-cleaning",
|
||||||
create: "/categories/create",
|
|
||||||
edit: "/categories/edit/:id",
|
|
||||||
show: "/categories/show/:id",
|
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||||
|
label: "SCADA 数据清洗",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "监测点优化布置",
|
||||||
|
list: "/monitoring-place-optimization",
|
||||||
|
meta: {
|
||||||
|
icon: <LuReplace className="w-6 h-6" />,
|
||||||
|
label: "监测点优化布置",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "健康风险分析",
|
||||||
|
list: "/health-risk-analysis",
|
||||||
|
meta: {
|
||||||
|
icon: <PiAsteriskDuotone className="w-6 h-6" />,
|
||||||
|
label: "健康风险分析",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "爆管分析定位",
|
||||||
|
list: "/burst-pipe-analysis",
|
||||||
|
meta: {
|
||||||
|
icon: <TbLocationPin className="w-6 h-6" />,
|
||||||
|
label: "爆管分析定位",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "管网优化分区",
|
||||||
|
list: "/network-partition-optimization",
|
||||||
|
meta: {
|
||||||
|
icon: <AiOutlinePartition className="w-6 h-6" />,
|
||||||
|
label: "管网优化分区",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
9
src/app/globals.css
Normal file
9
src/app/globals.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
@@ -3,13 +3,18 @@ import { cookies } from "next/headers";
|
|||||||
import React, { Suspense } from "react";
|
import React, { Suspense } from "react";
|
||||||
import { RefineContext } from "./_refine_context";
|
import { RefineContext } from "./_refine_context";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||||
title: "Refine",
|
import { Header } from "@components/header";
|
||||||
description: "Generated by create refine app",
|
import { Title } from "@components/title";
|
||||||
icons: {
|
import { ThemedLayout } from "@refinedev/mui";
|
||||||
icon: "/favicon.ico",
|
import { getServerSession } from "next-auth/next";
|
||||||
},
|
import { redirect } from "next/navigation";
|
||||||
};
|
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
import { META_DATA } from "@config/config";
|
||||||
|
|
||||||
|
export const metadata: Metadata = META_DATA;
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -20,13 +25,39 @@ export default async function RootLayout({
|
|||||||
const theme = cookieStore.get("theme");
|
const theme = cookieStore.get("theme");
|
||||||
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
const defaultMode = theme?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
|
const data = await getData();
|
||||||
|
|
||||||
|
if (!data.session?.user) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<RefineContext defaultMode={defaultMode}>{children}</RefineContext>
|
<RefineContext defaultMode={defaultMode}>
|
||||||
|
<ThemedLayout
|
||||||
|
Header={Header}
|
||||||
|
Title={Title}
|
||||||
|
childrenBoxProps={{
|
||||||
|
sx: { height: "100vh", p: 0 },
|
||||||
|
}}
|
||||||
|
containerBoxProps={{
|
||||||
|
sx: { height: "100%" }, // 修改根容器:占满视口
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemedLayout>
|
||||||
|
</RefineContext>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getData() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
11
src/app/network-simulation/page.tsx
Normal file
11
src/app/network-simulation/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
|
<MapComponent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/scada-data-cleaning/page.tsx
Normal file
11
src/app/scada-data-cleaning/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
|
<MapComponent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
src/assets/avatar/avatar-small.jpeg
Normal file
BIN
src/assets/avatar/avatar-small.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
src/assets/map/layers/mapbox-outdoors.png
Normal file
BIN
src/assets/map/layers/mapbox-outdoors.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
src/assets/map/layers/mapbox-satellite-streets.png
Normal file
BIN
src/assets/map/layers/mapbox-satellite-streets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
src/assets/map/layers/mapbox-satellite.png
Normal file
BIN
src/assets/map/layers/mapbox-satellite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
src/assets/map/layers/mapbox-streets.png
Normal file
BIN
src/assets/map/layers/mapbox-streets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
57
src/components/olmap/common/ToolbarButton.tsx
Normal file
57
src/components/olmap/common/ToolbarButton.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const ToolbarButton: React.FC<{
|
||||||
|
icon: React.ReactNode;
|
||||||
|
name: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ icon, name, isActive, onClick, disabled = false }) => {
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"w-8 h-8 rounded-lg m-1 bg-white border border-gray-300 shadow-sm flex items-center justify-center transition-colors duration-150",
|
||||||
|
"hover:bg-blue-50 hover:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-300",
|
||||||
|
"active:bg-blue-600 active:border-blue-700",
|
||||||
|
{
|
||||||
|
"border-blue-600 bg-blue-50 shadow-md ring-1 ring-blue-300": isActive && !disabled,
|
||||||
|
"bg-gray-100 border-gray-200 cursor-not-allowed": disabled,
|
||||||
|
"hover:bg-gray-100 hover:border-gray-200": disabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => !disabled && setShowTooltip(true)}
|
||||||
|
onMouseLeave={() => setShowTooltip(false)}
|
||||||
|
aria-label={name}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-center w-full h-full text-xl active:scale-95 active:text-white",
|
||||||
|
{
|
||||||
|
"text-blue-600": isActive && !disabled,
|
||||||
|
"text-gray-700": !isActive && !disabled,
|
||||||
|
"text-gray-400": disabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showTooltip && !disabled && (
|
||||||
|
<div className="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-3 py-2 text-xs text-gray-800 bg-white border border-gray-300 rounded shadow-lg whitespace-nowrap z-20">
|
||||||
|
{name}
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 -ml-2 w-0 h-0 border-y-8 border-y-transparent border-r-8 border-r-white"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToolbarButton;
|
||||||
18
src/components/title/index.tsx
Normal file
18
src/components/title/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { IoIosWater } from "react-icons/io";
|
||||||
|
import { PROJECT_TITLE } from "@config/config";
|
||||||
|
|
||||||
|
interface TitleProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Title: React.FC<TitleProps> = ({ collapsed = false }) => {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<IoIosWater className="w-6 h-6" style={{ color: "#1976d2" }} />
|
||||||
|
{!collapsed && <span className="select-none"> {PROJECT_TITLE} </span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
src/config/config.ts
Normal file
24
src/config/config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export const config = {
|
||||||
|
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || "http://192.168.1.42:8000",
|
||||||
|
mapUrl: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
|
mapExtent: [13508849, 3608035.75, 13555781, 3633812.75],
|
||||||
|
// 添加其他配置项...
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAPBOX_TOKEN =
|
||||||
|
process.env.NEXT_PUBLIC_MAPBOX_TOKEN ||
|
||||||
|
"pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg";
|
||||||
|
export const TIANDITU_TOKEN =
|
||||||
|
process.env.NEXT_PUBLIC_TIANDITU_TOKEN || "e3e8ad95ee911741fa71ed7bff2717ec";
|
||||||
|
export const PROJECT_TITLE =
|
||||||
|
process.env.NEXT_PUBLIC_PROJECT_TITLE || "TJWater Project";
|
||||||
|
|
||||||
|
export const META_DATA = {
|
||||||
|
title: PROJECT_TITLE,
|
||||||
|
description: "Developed by TJWATER",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.ico",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Reference in New Issue
Block a user