完成页面的基础配置

This commit is contained in:
JIANG
2025-09-28 15:51:45 +08:00
parent e34dc99330
commit 6d1cc6c9a1
28 changed files with 9753 additions and 52 deletions

View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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 };

View File

View 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;

View 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;