Files
xinnengyuan/plus-ui/src/components/openLayersMap/index.vue
2025-06-24 10:44:10 +08:00

598 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="flex justify-between" v-loading="treeLoading">
<el-tree-v2
style="width: 340px; overflow: auto"
show-checkbox
:data="jsonData"
:height="500"
@check-change="handleCheckChange"
:props="treeProps"
@node-contextmenu="showMenu"
ref="treeRef"
@node-click="isMenuVisible = false"
>
<template #default="{ node, data }">
<span @dblclick="handlePosition(data, node)">{{ data.name }}</span>
</template>
</el-tree-v2>
<div>
<div class="ol-map" id="olMap"></div>
<div class="h15 mt-2" v-if="!selectLayer.length"></div>
<div class="m-0 c-white text-3 flex w-237.5 mt-2 flex-wrap" v-else>
<p
v-for="(item, index) in selectLayer"
class="pl-xl border-rd pr p-3 w-111 mr-1 bg-#909399 flex items-center cursor-pointer justify-between"
@click="delLayer(index, item.option)"
>
{{ item.location.name + '被选中为' + item.option }}
<el-icon>
<Close />
</el-icon>
</p>
</div>
<!-- <el-form-item label="类型" class="items-center">
<el-radio-group v-model="layerType">
<el-radio :value="1" size="large">光伏板</el-radio>
<el-radio :value="2" size="large">桩点/支架</el-radio>
<el-radio :value="3" size="large">方阵</el-radio>
<el-radio :value="4" size="large">逆变器</el-radio>
<el-radio :value="5" size="large">箱变</el-radio>
</el-radio-group>
</el-form-item> -->
</div>
<div v-if="isMenuVisible" :style="{ left: menuX + 'px', top: menuY + 'px' }" class="fixed bg-white shadow-md rounded-md overflow-hidden">
<ul class="py-1 pl-0">
<li
v-for="(item, index) in layerTypeList"
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
@click="handleMenuItemClick(item, index + 1)"
>
<i class="fa-solid fa-check mr-2"></i>{{ item }}
</li>
<li class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer" @click="handleMenuItemClick('名称', null)">
<i class="fa-solid fa-times mr-2"></i>名称
</li>
</ul>
</div>
</div>
<div class="float-right">
<el-button @click="emit('close')">取消</el-button>
<el-button type="primary" @click="addFacilities" :loading="loading">确定</el-button>
</div>
</template>
<script lang="ts" setup>
import Map from 'ol/Map'; // OpenLayers的主要类用于创建和管理地图
import View from 'ol/View'; // OpenLayers的视图类定义地图的视图属性
import { Tile as TileLayer } from 'ol/layer'; // OpenLayers的瓦片图层类
import { XYZ, OSM } from 'ol/source'; // OpenLayers的瓦片数据源包括XYZ格式和OpenStreetMap专用的数据源
import { fromLonLat, toLonLat } from 'ol/proj'; // OpenLayers的投影转换函数用于经纬度坐标和投影坐标之间的转换
import { defaults as defaultInteractions, DragRotateAndZoom } from 'ol/interaction'; // OpenLayers的交互类包括默认的交互集合和特定的旋转缩放交互
import { defaults as defaultControls, defaults, FullScreen, MousePosition, ScaleLine } from 'ol/control'; // OpenLayers的控件类包括默认的控件集合和特定的全屏、鼠标位置、比例尺控件
import Feature from 'ol/Feature'; // OpenLayers的要素类表示地图上的一个对象或实体
import Point from 'ol/geom/Point'; // OpenLayers的点几何类用于表示点状的地理数据
import { Vector as VectorLayer } from 'ol/layer'; // OpenLayers的矢量图层类用于显示矢量数据
import { Vector as VectorSource } from 'ol/source'; // OpenLayers的矢量数据源类用于管理和提供矢量数据
import { Circle, Style, Stroke, Fill, Icon, Text } from 'ol/style'; // OpenLayers的样式类用于定义图层的样式包括圆形样式、基本样式、边框、填充和图标
import LineString from 'ol/geom/LineString'; // OpenLayers的线几何类用于表示线状的地理数据
import Polygon from 'ol/geom/Polygon'; // OpenLayers的多边形几何类用于表示面状的地理数据
import GeoJSON from 'ol/format/GeoJSON';
import * as turf from '@turf/turf';
import { FeatureCollection, Geometry } from 'geojson';
import { MapViewFitter } from '@/utils/setMapCenter';
import { TreeInstance } from 'element-plus';
import { addProjectFacilities, addProjectPilePoint, addProjectSquare, listDXFProject, addInverter, addBoxTransformer } from '@/api/project/project';
import { BatchUploader } from '@/utils/batchUpload';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const props = defineProps({
projectId: String,
designId: String
});
const treeData = ref<any>([]);
const layerType = ref(null);
const layerTypeList = ref(['光伏板', '桩点/支架', '方阵', '逆变器', '箱变']);
const contextMenu = ref(null);
const selectLayer = ref([]);
const treeRef = ref<TreeInstance>();
const treeProps = {
value: 'name'
};
const loading = ref(false);
const treeLoading = ref(false);
const emit = defineEmits(['handleCheckChange', 'close']);
let map: any = null;
const layerData = reactive<any>({});
const centerPosition = ref(fromLonLat([107.13761560163239, 23.80480003743964]));
const jsonData = computed(() => {
let id = 0;
let arr = [];
treeData.value.forEach((item: any, index: any) => {
arr.push({
name: item.name,
index
});
for (const itm of item.features) {
if (itm.geometry.id) {
break;
}
itm.geometry.id = ++id;
itm.geometry.coordinates = convertStrToNum(itm.geometry.coordinates);
}
});
return arr; // treeData.value;
});
const handlePosition = (data: any, node: any) => {
const fitter = new MapViewFitter(map); // 传入你的 OpenLayers 地图实例
const features = treeData.value[data.index]?.features; //features数组
console.log('🚀 ~ handlePosition ~ features:', features);
if (features?.length) {
const featureCollection: FeatureCollection<Geometry> = {
type: 'FeatureCollection',
features
};
fitter.fit(featureCollection);
}
};
const handleCheckChange = (data: any, bool: boolean) => {
if (isMenuVisible.value) isMenuVisible.value = false;
const features = treeData.value[data.index].features;
if (bool) {
features.forEach((item: any) => {
const fid = item.geometry.id;
// 没创建过就先创建
if (!featureMap[fid]) {
creatPoint(item.geometry.coordinates, item.geometry.type, fid, item.properties.text);
}
// 添加到共享 source 中(避免重复添加)
const feature = featureMap[fid];
if (!sharedSource.hasFeature(feature)) {
sharedSource.addFeature(feature);
}
});
} else {
features.forEach((item: any) => {
const fid = item.geometry.id;
const feature = featureMap[fid];
if (feature && sharedSource.hasFeature(feature)) {
sharedSource.removeFeature(feature);
}
});
}
};
function initOLMap() {
// 创造地图实例
map = new Map({
// 设置地图容器的ID
target: 'olMap',
// 定义地图的图层列表,用于显示特定的地理信息。
layers: [
// 高德地图
// TileLayer表示一个瓦片图层它由一系列瓦片通常是图片组成用于在地图上显示地理数据。
new TileLayer({
// 设置图层的数据源为XYZ类型。XYZ是一个通用的瓦片图层源它允许你通过URL模板来获取瓦片
source: new XYZ({
url: 'https://webrd04.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}'
})
})
],
// 设置地图的视图参数
// View表示地图的视图它定义了地图的中心点、缩放级别、旋转角度等参数。
view: new View({
// fromLonLat是一个函数用于将经纬度坐标转换为地图的坐标系统。
center: centerPosition.value, //地图中心点
zoom: 15, // 缩放级别
minZoom: 0, // 最小缩放级别
// maxZoom: 18, // 最大缩放级别
constrainResolution: true // 因为存在非整数的缩放级别所以设置该参数为true来让每次缩放结束后自动缩放到距离最近的一个整数级别这个必须要设置当缩放在非整数级别时地图会糊
// projection: 'EPSG:4326' // 投影坐标系默认是3857
}),
// 按住shift进行旋转
// interactions: defaultInteractions().extend([new DragRotateAndZoom()]),
// 控件
// controls: defaults().extend([
// new FullScreen(), // 全屏
// new MousePosition(), // 显示鼠标当前位置的地图坐标
// new ScaleLine() // 显示比例尺
// ])
//加载控件到地图容器中
controls: defaultControls({
zoom: false,
rotate: false,
attribution: false
}).extend([
new FullScreen() // 全屏
])
});
// 事件
// map.on('moveend', (e: any) => {
// // console.log('地图移动', e);
// // 获取当前缩放级别
// var zoomLevel = map.getView().getZoom();
// // console.log('当前缩放级别:', zoomLevel);
// });
// map.on('rendercomplete', () => {
// // console.log('渲染完成');
// });
// map.on('click', (e: any) => {
// var coordinate = e.coordinate;
// // 将投影坐标转换为经纬度坐标
// var lonLatCoordinate = toLonLat(coordinate);
// // 输出转换后的经纬度坐标
// console.log('经纬度坐标:', lonLatCoordinate);
// });
}
//递归字符串数组变成数字
function convertStrToNum(arr) {
if (typeof arr === 'string') {
const num = Number(arr);
return isNaN(num) ? arr : num;
} else if (Array.isArray(arr)) {
return arr.map((item) => convertStrToNum(item));
}
return arr;
}
/**
* 创建图层
* @param {*} pointObj 坐标数组
* @param {*} type 类型
* @param {*} id 唯一id
* @param {*} name 名称
* */
// 共享 source 和图层(全局一次性创建)
const sharedSource = new VectorSource();
const sharedLayer = new VectorLayer({
source: sharedSource,
renderMode: 'image' // 提高渲染性能
} as any);
// id => Feature 映射表
const featureMap: Record<string, Feature> = {};
const creatPoint = (pointObj: Array<any>, type: string, id: string, name?: string) => {
let geometry;
if (type === 'Point') {
geometry = new Point(fromLonLat(pointObj));
} else if (type === 'LineString') {
const coords = pointObj.map((arr: any) => fromLonLat(arr));
// 注意:这里虽然是 LineString 类型,但数据实际表示的是闭合面
geometry = new Polygon([coords]);
} else {
const coords = pointObj.map((arr: any) => arr.map((i: any) => fromLonLat(i)));
geometry = new Polygon(coords);
}
const feature = new Feature({ geometry });
const pointStyle = new Style({
image: new Circle({
radius: 2,
fill: new Fill({ color: 'red' })
}),
text: new Text({
font: '12px Microsoft YaHei',
text: name,
scale: 1,
fill: new Fill({ color: '#7bdd63' })
})
});
const polygonStyle = new Style({
stroke: new Stroke({
color: type === 'LineString' ? 'skyblue' : 'purple',
width: 2
}),
fill: new Fill({ color: 'transparent' })
});
feature.setStyle(type === 'Point' ? pointStyle : polygonStyle);
// 缓存 feature用于后续判断
featureMap[id] = feature;
};
// 控制菜单是否显示
const isMenuVisible = ref(false);
// 菜单的 x 坐标
const menuX = ref(0);
// 菜单的 y 坐标
const menuY = ref(0);
// 显示菜单的方法
const showMenu = (event: MouseEvent, data) => {
console.log('🚀 ~ showMenu ~ data:', data, treeData.value[data.index]);
contextMenu.value = data;
isMenuVisible.value = true;
menuX.value = event.clientX;
menuY.value = event.clientY;
};
// 处理菜单项点击事件的方法
const handleMenuItemClick = (option: string, index: number) => {
isMenuVisible.value = false;
if (selectLayer.value.some((item) => item.location.name === contextMenu.value.name)) {
return proxy?.$modal.msgError('已选择该图层,请勿重复选择');
}
if (selectLayer.value.some((item) => item.option !== '名称' && item.option !== '箱变' && item.option !== '光伏板')) {
if (option !== '名称' && option !== '箱变') return proxy?.$modal.msgError('只能选择一个类型');
}
selectLayer.value.push({ location: contextMenu.value, option });
layerType.value = index ? index : layerType.value; // 设置 layerType 为对应的索引值
emit('handleCheckChange', selectLayer.value);
};
//删除菜单
const delLayer = (index, option) => {
selectLayer.value.splice(index, 1);
if (option != '名称') {
if (selectLayer.value.every((item) => item.option == '名称')) layerType.value = null;
}
emit('handleCheckChange', selectLayer.value);
};
// 点击页面其他区域隐藏菜单
const closeMenuOnClickOutside = (event: MouseEvent) => {
if (isMenuVisible.value) {
const menuElement = document.querySelector('.fixed.bg-white');
if (menuElement && !menuElement.contains(event.target as Node)) {
isMenuVisible.value = false;
}
}
};
// 添加全局点击事件监听器
window.addEventListener('click', closeMenuOnClickOutside);
const getTreeData = async () => {
treeLoading.value = true;
try {
const res = await listDXFProject(props.designId);
treeData.value = res.data.layers;
treeLoading.value = false;
} catch (err) {
treeLoading.value = false;
}
};
// 组件卸载时移除事件监听器
const onUnmounted = () => {
window.removeEventListener('click', closeMenuOnClickOutside);
};
type LayerConfig = {
optionB: string;
apiFunc: (data: any) => Promise<any>;
};
const LAYER_CONFIG: Record<number, LayerConfig> = {
1: { optionB: '光伏板', apiFunc: addProjectFacilities },
3: { optionB: '方阵', apiFunc: addProjectSquare },
4: { optionB: '逆变器', apiFunc: addInverter },
5: { optionB: '箱变', apiFunc: addBoxTransformer }
};
const showError = (msg: string) => proxy?.$modal.msgError(msg);
const showSuccess = (msg: string) => proxy?.$modal.msgSuccess(msg);
const getGeoJsonData = (nameOption = '名称', secondOption: string): { nameGeoJson: any[]; locationGeoJson: any | null } | null => {
const nameLayers = selectLayer.value.filter((item) => item.option === nameOption);
const secondLayer = selectLayer.value.filter((item) => item.option === secondOption);
if (!nameLayers.length || !secondLayer) {
showError(`请选择${nameOption}${secondOption}`);
return null;
}
const nameGeoJson = nameLayers.map((item) => treeData.value[item.location.index]);
const locationGeoJson = secondLayer.map((item) => treeData.value[item.location.index]);
return { nameGeoJson, locationGeoJson };
};
const handleTwoLayerUpload = async (optionB: string, apiFunc: (data: any) => Promise<any>) => {
const geoJson = getGeoJsonData('名称', optionB);
if (!geoJson) return;
if (optionB == '光伏板') return uploadPhotovoltaic(geoJson, apiFunc);
const data = {
projectId: props.projectId,
nameGeoJson: geoJson.nameGeoJson,
locationGeoJson: geoJson.locationGeoJson,
pointGeoJson: null
};
loading.value = true;
await apiFunc(data);
await showSuccess('添加成功');
};
//上传光伏板
const uploadPhotovoltaic = async (geoJson: { nameGeoJson: any[]; locationGeoJson: any }, apiFunc: (data: any) => Promise<any>) => {
// 提取原始 features
let rawNameFeatures = geoJson.nameGeoJson || [];
let rawLocationFeatures = geoJson.locationGeoJson || [];
console.log('🚀 nameGeoJson:', rawNameFeatures);
console.log('🚀 locationGeoJson:', rawLocationFeatures);
// 扁平化处理 FeatureCollection
const nameFeatures = rawNameFeatures.flatMap((fc) => fc.features || []).map((f) => ({ ...f, __source: 'name' }));
const locationFeatures = rawLocationFeatures.flatMap((fc) => fc.features).map((f) => ({ ...f, __source: 'location' }));
// 配对成上传单元
type FeaturePair = { nameFeature: any; locationFeature: any };
const pairedFeatures: FeaturePair[] = nameFeatures.map((name, i) => ({
nameFeature: name,
locationFeature: locationFeatures[i]
}));
// 启动上传
loading.value = true;
const sessionId = new Date().getTime().toString(36) + Math.random().toString(36).substring(2, 15);
const uploader = new BatchUploader({
dataList: pairedFeatures,
chunkSize: 3000, // 一次上传3000对
delay: 200,
uploadFunc: async (chunk, batchNum, totalBatch) => {
const chunkNameFeatures = chunk.map((pair) => pair.nameFeature);
const chunkLocationFeatures = chunk.map((pair) => pair.locationFeature);
console.log(`🚀 上传第 ${batchNum}/${totalBatch} 批,条数:`, chunk.length);
await apiFunc({
projectId: props.projectId,
nameGeoJson: [{ type: 'FeatureCollection', features: chunkNameFeatures }],
locationGeoJson: [{ type: 'FeatureCollection', features: chunkLocationFeatures }],
pointGeoJson: null,
sessionId,
totalBatch,
batchNum
});
},
onComplete: () => {
showSuccess('图层上传完成');
loading.value = false;
}
});
await uploader.start();
};
const handlePointUpload = async () => {
if (selectLayer.value.length > 1) return showError('最多选择一个桩点/支架');
if (selectLayer.value[0].option !== '桩点/支架') return showError('请选择类型为桩点/支架');
const features = treeData.value[selectLayer.value[0].location.index]?.features || [];
if (!features.length) return showError('桩点数据为空');
loading.value = true;
const sessionId = new Date().getTime().toString(36) + Math.random().toString(36).substring(2, 15);
const uploader = new BatchUploader({
dataList: features,
chunkSize: 15000,
delay: 200,
uploadFunc: async (chunk, batchNum, totalBatch) => {
await addProjectPilePoint({
projectId: props.projectId,
locationGeoJson: {
type: 'FeatureCollection',
features: chunk
},
sessionId,
totalBatch,
batchNum
});
},
onComplete: () => {
showSuccess('桩点上传完成');
reset();
loading.value = false;
}
});
await uploader.start();
};
const addFacilities = async () => {
if (!layerType.value) return showError('请选择图层类型');
if (!selectLayer.value.length) return showError('请选择需要上传的图层');
const config = LAYER_CONFIG[layerType.value];
try {
if (layerType.value == 2) {
await handlePointUpload();
} else if (config) {
await handleTwoLayerUpload(config.optionB, config.apiFunc);
} else {
showError('不支持的图层类型');
}
} finally {
reset();
loading.value = false;
}
};
const reset = () => {
selectLayer.value = [];
treeRef.value?.setCheckedKeys([]);
sharedSource.clear(); // 清空共享 source 中的所有要素
layerType.value = null;
};
watch(
() => props.designId,
(newId, oldId) => {
if (newId !== oldId) {
reset();
getTreeData();
}
},
{ immediate: true }
);
onMounted(() => {
// 地图初始化
initOLMap();
map.addLayer(sharedLayer);
// creatPoint(
// [
// [
// [107.13205125908726, 23.806785824010216],
// [107.13218187963494, 23.806867960389773],
// [107.13215698891558, 23.806902336258318],
// [107.13202636835067, 23.8068201998575],
// [107.13205125908726, 23.806785824010216]
// ]
// ],
// 'Polygon',
// '1',
// '测试方阵'
// );
});
</script>
<style scoped lang="scss">
.ol-map {
height: 450px;
width: 950px;
margin: 0 auto;
}
.ol-custome-full-screen {
border-radius: 3px;
font-size: 12px;
padding: 5px 11px;
height: 24px;
background-color: #409eff;
color: #fff;
border: 1px solid #409eff;
&:active {
background-color: #337ecc;
border-color: #66b1ff;
}
&:hover {
background-color: #79bbff;
border-color: #79bbff;
}
}
li {
list-style-type: none;
}
</style>