Commit 510bf5f4 authored by daniel's avatar daniel

fix(demo): mapbox接入

parent 465069bf
...@@ -37,6 +37,10 @@ ...@@ -37,6 +37,10 @@
<!-- 添加天地图API --> <!-- 添加天地图API -->
<script <script
src="https://api.tianditu.gov.cn/api?v=4.0&tk=16a595e56ea0cec57ad8c07368d7f1ac"></script> src="https://api.tianditu.gov.cn/api?v=4.0&tk=16a595e56ea0cec57ad8c07368d7f1ac"></script>
<!-- 引入togeojson -->
<script src="https://unpkg.com/@tmcw/togeojson@7.1.2/dist/togeojson.umd.js"></script>
<!-- 引入turf -->
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js"></script>
<style> <style>
...@@ -62,8 +66,8 @@ ...@@ -62,8 +66,8 @@
<body> <body>
<div class="page-content d-flex align-items-stretch main-body"> <div class="page-content d-flex align-items-stretch main-body">
<div id="app" v-cloak> <div id="app" class="flex-1" v-cloak>
<n-config-provider> <n-config-provider :locale="locale" :date-locale="dateLocale">
<n-modal-provider> <n-modal-provider>
<n-message-provider> <n-message-provider>
<n-notification-provider> <n-notification-provider>
...@@ -76,14 +80,472 @@ ...@@ -76,14 +80,472 @@
</div> </div>
<script type="text/x-template" id="my-app"> <script type="text/x-template" id="my-app">
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; margin: 0; padding: 0;"> <div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; margin: 0; padding: 0;">
<div ref="mapContainer" style="width: 100%; height: 100%;"></div> <div id="map" style="width: 100%; height: 100%;z-index: 0;"></div>
<div class="fixed left-0 top-0 z-10 flex flex-row justify-center items-center p-3">
<input
type="file"
accept=".gpx,.kml"
@change="handleFileChange"
style="display: none"
ref="fileInputRef"
/>
<n-card>
<div class="flex flex-col items-start gap-y-2">
<n-button @click="fileInputRef?.click()" type="primary">
导入轨迹
</n-button>
<span class="text-gray text-sm">
只能上传 GPX 或 KML 文件,且不超过 10M。
</span>
<span class="text-gray text-blue-600" v-if="fileName">
{{ fileName }}
</span>
</div> </div>
</n-card>
</div>
<div class="fixed right-3 top-0 z-10 p-3 flex flex-col gap-y-1">
<div class="flex flex-row items-center mb-2 w-full gap-x-1 bg-white p-2 rounded-sm">
<span>点位拾取</span>
<n-switch v-model:value="isAddMode" >
<template #checked>
开启
</template>
<template #unchecked>
关闭
</template>
</n-switch>
<div class="ml-auto">
<n-button @click="editCategory(null,null)">新增分类</n-button>
</div>
</div>
<n-collapse accordion class="bg-white p-2 rounded-sm" style="min-width:250px;">
<n-collapse-item :title="category.name" :name="category.value" v-for="category,categoryIndex in categories" :key="categoryIndex">
<template #header-extra>
<ion-icon @click="(e)=>editCategory(e,category)" class="cursor-pointer" name="create-outline"></ion-icon>
</template>
<n-space vertical style="width:100%;">
<div
v-for="(marker, index) in markers.filter(v=>v.categoryId===category.id)"
:key="index"
class="flex flex-row items-center gap-x-2 py-1"
>
<span class="flex flex-1 flex-row items-center gap-x-1 text-xs">
<span class="line-clamp-1 flex-1">
{{ marker.name }}
</span>
<n-tag size="small" :bordered="false" :type="CheckPointTypeEnum[marker.type].color">
{{ CheckPointTypeEnum[marker.type].name }}
</n-tag>
<n-tag size="small" :bordered="false" :type="StatusEnum[marker.status].color">
{{ StatusEnum[marker.status].name }}
</n-tag>
</span>
<ion-icon @click="editCheckPoint(marker)" class="cursor-pointer" name="create-outline"></ion-icon>
<n-popconfirm
@positive-click="deleteCheckPoint(index)"
>
<template #trigger>
<ion-icon class="text-red cursor-pointer" name="trash-outline"></ion-icon>
</template>
是否删除该标注点
</n-popconfirm>
</div>
</n-space>
</n-collapse-item>
<n-collapse-item title="未分类" name="unknown" >
<n-space vertical style="width:100%;">
<div
v-for="(marker, index) in markers.filter(v=>!v.categoryId)"
:key="index"
class="flex flex-row items-center gap-x-2 py-1"
>
<span class="flex flex-1 flex-row items-center gap-x-1 text-xs">
<span class="line-clamp-1 flex-1">
{{ marker.name }}
</span>
<n-tag size="small" :bordered="false" :type="CheckPointTypeEnum[marker.type].color">
{{ CheckPointTypeEnum[marker.type].name }}
</n-tag>
<n-tag size="small" :bordered="false" :type="StatusEnum[marker.status].color">
{{ StatusEnum[marker.status].name }}
</n-tag>
</span>
<ion-icon @click="editCheckPoint(marker)" class="cursor-pointer" name="create-outline"></ion-icon>
<n-popconfirm
@positive-click="deleteCheckPoint(index)"
>
<template #trigger>
<ion-icon class="text-red cursor-pointer" name="trash-outline"></ion-icon>
</template>
是否删除该标注点
</n-popconfirm>
</div>
</n-space>
</n-collapse-item>
</n-collapse>
</div>
<n-modal
:mask-closable="false"
:loading="loading"
style="width: 400px"
:title="isEditMarker?'编辑标记点':'新增标记点'"
preset="card"
draggable v-model:show="editMarkerModal"
>
<n-form
ref="markerFormRef"
:model="markerForm"
:rules="markerRules"
label-placement="left"
label-width="auto"
>
<n-form-item label="点位名称" path="name">
<n-input v-model:value="markerForm.name" placeholder="请输入点位名称" />
</n-form-item>
<n-form-item label="排序" path="sort">
<n-input-number v-model:value="markerForm.sort" placeholder="请输入排序" />
</n-form-item>
<n-form-item label="经度" path="longitude">
<n-input v-model:value="markerForm.longitude" placeholder="请输入经度" />
</n-form-item>
<n-form-item label="纬度" path="latitude">
<n-input v-model:value="markerForm.latitude" placeholder="请输入纬度" />
</n-form-item>
<n-form-item label="海拔" path="altitude">
<n-input v-model:value="markerForm.altitude" placeholder="请输入海拔" />
</n-form-item>
<n-form-item label="类型" path="type">
<n-select v-model:value="markerForm.type" :options="CheckPointTypeEnum.data.map(v=>({
label:v.name,
value:v.value
}))" />
</n-form-item>
<n-form-item label="分类" path="categoryId">
<n-select v-model:value="markerForm.categoryId" :options="categories.map(v=>({
label:v.name,
value:v.id
}))" />
</n-form-item>
<n-form-item label="状态" path="status">
<n-switch
:checked-value="1"
:unchecked-value="0"
v-model:value="markerForm.status"
>
<template #checked>
开启
</template>
<template #unchecked>
关闭
</template>
</n-switch>
</n-form-item>
</n-form>
<template #footer>
<div class="flex gap-x-2 flex-row items-center" >
<n-button class="flex-1" :loading="loading" @click="onMarkerCancel">
取消
</n-button>
<n-button class="flex-1"
:loading="loading" type='primary' @click="onMarkerSubmit">
保存
</n-button>
</div>
</template>
</n-modal>
<!-- 分类的弹框 -->
<n-modal
:mask-closable="false"
:loading="loading"
preset="card"
style="width: 400px"
:title="isEditCategory?'编辑分类':'新增分类'"
draggable
v-model:show="categoryModal"
>
<n-form
ref="categoryFormRef"
:model="categoryForm"
:rules="categoryRules"
label-placement="left"
label-width="auto"
>
<n-form-item label="分类名称" path="name">
<n-input v-model:value="categoryForm.name" placeholder="请输入分类名称" />
</n-form-item>
<n-form-item label="排序" path="sort">
<n-input-number v-model:value="categoryForm.sort" placeholder="请输入排序" />
</n-form-item>
<n-form-item label="状态" path="status">
<n-switch
:checked-value="1"
:unchecked-value="0"
v-model:value="categoryForm.status"
>
<template #checked>
开启
</template>
<template #unchecked>
关闭
</template>
</n-switch>
</n-form-item>
</n-form>
<template #footer>
<div class="flex gap-x-2 flex-row items-center" >
<n-button class="flex-1" :loading="loading" @click="onCategoryCancel">
取消
</n-button>
<n-button class="flex-1"
:loading="loading" type='primary' @click="onCategorySubmit">
保存
</n-button>
</div>
</template>
</n-modal>
</div>
</script>
<script>
// 枚举class
class SuperEnum {
constructor(param) {
const keyObj = {};
const valueObj = {};
const nameObj = {};
if (!Array.isArray(param)) {
throw new TypeError('param is not an array!');
}
param.forEach((element) => {
if (!element.key || !element.name) {
return;
}
// 使用对象存储键值对,方便快速查找
keyObj[element.key] = element;
valueObj[element.value] = element;
nameObj[element.name] = element;
// 动态添加属性
this[element.key] = element;
this[element.name] = element;
if (element.key !== element.value) {
this[element.value] = element;
}
});
// 保存源数组
this.data = param;
this.keyObj = keyObj;
this.valueObj = valueObj;
this.nameObj = nameObj;
// 防止被修改
Object.freeze(this);
}
getColorByKey(key) {
const prop = this.keyOf(key);
if (!prop) {
const error = new Error('No enum constant');
error.code = 'ENUM_NOT_FOUND';
throw error;
}
return prop.color;
}
getData() {
return this.data;
}
getKeyByName(name) {
const prop = this.nameOf(name);
if (!prop) {
const error = new Error('No enum constant');
error.code = 'ENUM_NOT_FOUND';
throw error;
}
return prop.key;
}
getNameByKey(key) {
const prop = this.keyOf(key);
if (!prop) {
const error = new Error('No enum constant');
error.code = 'ENUM_NOT_FOUND';
throw error;
}
return prop.name;
}
getNameByValue(value) {
const prop = this.valueOf(value);
if (!prop) {
const error = new Error('No enum constant');
error.code = 'ENUM_NOT_FOUND';
throw error;
}
return prop.name;
}
getValueByKey(key) {
const prop = this.keyOf(key);
if (!prop) {
const error = new Error('No enum constant');
error.code = 'ENUM_NOT_FOUND';
throw error;
}
return prop.value;
}
getValueByName(name) {
const prop = this.nameOf(name);
if (!prop) {
const error = new Error('No enum constant');
error.code = 'ENUM_NOT_FOUND';
throw error;
}
return prop.value;
}
keyOf(key) {
return this.keyObj[key];
}
nameOf(name) {
return this.nameObj[name];
}
valueOf(value) {
return this.valueObj[value];
}
}
</script> </script>
<script> <script>
const { createApp, defineComponent, ref, onMounted } = Vue; const { createApp, defineComponent, ref, onMounted, reactive } = Vue;
const { useMessage, useNotification } = naive const { useMessage, useNotification, dateZhCN, zhCN } = naive
const tk = "16a595e56ea0cec57ad8c07368d7f1ac" const tk = "16a595e56ea0cec57ad8c07368d7f1ac"
const mapBoxToken = 'pk.eyJ1IjoieGluZ3NhaSIsImEiOiJjbTc0ZXJ4ZjgwOWF3MmtyMjFxN2E2MHo0In0.pknVy3Lq6fLcXiwvrVLcFg' const mapBoxToken = 'pk.eyJ1IjoieGluZ3NhaSIsImEiOiJjbTc0ZXJ4ZjgwOWF3MmtyMjFxN2E2MHo0In0.pknVy3Lq6fLcXiwvrVLcFg'
// mapKey
const MapKeyEnum = {
// cp点标注相关
CP_ID: 'cp_id',
CP_KEY: 'cp_',
CP_SOURCE_ID: 'cp_source_id',
KM_CIRCLE_ID: 'km_circle_id',
// 公里数相关
KM_KEY: 'km_',
KM_SOURCE_ID: 'km_source_id',
KM_TEXT_ID: 'km_text_id',
// 轨迹相关
LINE_ID: 'line_id',
LINE_KEY: 'line_',
LINE_SOURCE_ID: 'line_source_id',
// 定义的字段
NAME_KEY: 'name',
}
// 点位类型
const pointTypes = [
{
id: 0,
name: '计时点',
},
{
id: 1,
name: '起点',
},
{
id: 2,
name: '终点',
}
]
// 地图用到的图标
const mapIcons = {
0: 'https://geexekimgoss-sxk.oss-cn-hangzhou.aliyuncs.com/gk/common/map-icon/icon-cp.png',
1: 'https://geexekimgoss-sxk.oss-cn-hangzhou.aliyuncs.com/gk/common/map-icon/icon-start.png',
2: 'https://geexekimgoss-sxk.oss-cn-hangzhou.aliyuncs.com/gk/common/map-icon/icon-end.png'
}
// 地图图标枚举
const MapIconEnum = {
'ICON_CP': mapIcons[0],
'ICON_START_POINT': mapIcons[1],
'ICON_END_POINT': mapIcons[2],
}
// 打卡点类型
const CheckPointTypeEnum =
new SuperEnum([
{ key: 'MARKER', name: '标记点', color: 'warning', value: 0 },
{ key: 'START', name: '起点', color: 'primary', value: 1 },
{ key: 'END', name: '终点', color: 'success', value: 2 },
{ key: 'CHECK_POINT', name: '打卡点', color: 'info', value: 3 },
])
// 状态枚举
const StatusEnum =
new SuperEnum([
{ key: 'CLOSE', name: '关闭', color: 'error', value: 0 },
{ key: 'OPEN', name: '开启', color: 'success', value: 1 },
])
/**
* GPS公共配置信息
*/
const GpsConfig = {
// 地图配置
map: {
zoom: 8,
center: [106.67573046471102, 26.411047728805663],
centerPadding: [100, 100, 100, 100],
maxZoom: 20,
minZoom: 3,
centerZoom: 10,
speed: 0.5,
curve: 1.42,
threePitch: 45,
defaultPitch: 0,
minTileCacheSize: 20 * 1024 * 1024, // 20MB
maxTileCacheSize: 100 * 1024 * 1024, // 100MB
duration: 1000,
},
// 文字配置
text: {
textColor: '#ffffff',
padding: 2,
textAlign: 'center',
textBaseline: 'middle',
lineWidth: 1,
lineHeight: 1.5,
backgroundRadius: 5,
haloWidth: 25,
haloPadding: 8,
},
km: {
circleOpacity: 0.5,
circleRadius: 12,
font: ['Arial Unicode MS Regular'],
fontSize: 14,
circleBorderWidth: 4,
circleBorderColor: '#94e697',
textColor: '#6defde',
circleColor: '#ffffff',
textOffset: [0, 0.1],
},
// cp点的配置信息
cp: {
anchor: [0.5, 1],
scale: 0.15,
fontSize: 14,
iconSize: 1,
iconCommonSize: 0.2,
textColor: '#eba236',
haloWidth: 2,
font: ['Arial Unicode MS Regular'],
textOffset: [0, -0.2],
},
// 轨迹配置
path: {
width: 5,
color: "#7367F0"
},
};
// 获取天地图的地址 // 获取天地图的地址
const getTDTUrls = () => { const getTDTUrls = () => {
const services = Array.from({ length: 7 }, (_, i) => i + 1); const services = Array.from({ length: 7 }, (_, i) => i + 1);
...@@ -151,28 +613,42 @@ ...@@ -151,28 +613,42 @@
name: 'MyApp', name: 'MyApp',
template: '#my-app', template: '#my-app',
setup() { setup() {
const loading = ref(false)
const message = useMessage() const message = useMessage()
const notification = useNotification() const notification = useNotification()
const mapContainer = ref(null);
let map = ref(null);
onMounted(() => { onMounted(() => {
if (mapContainer.value) { fetchData()
});
const fetchData = async () => {
// 初始化地图
initMap();
}
/**------------------------ 地图初始化 start ------------------------**/
// 地图对象
let map = ref(null);
// 初始化地图
const initMap = () => {
// 确保容器已完全渲染 // 确保容器已完全渲染
setTimeout(() => { setTimeout(() => {
mapboxgl.config.MAX_PARALLEL_IMAGE_REQUESTS = 256;
map.value = new mapboxgl.Map({ map.value = new mapboxgl.Map({
container: mapContainer.value, container: 'map',
style: defaultStyle, style: defaultStyle,
center: [116.404, 39.915], container: 'map',
zoom: 11, center: GpsConfig.map.center,
minZoom: 5, zoom: GpsConfig.map.zoom,
maxZoom: 18,
pitch: 0,
accessToken: mapBoxToken, accessToken: mapBoxToken,
antialias: true, minZoom: GpsConfig.map.minZoom,
bearing: 0, maxZoom: GpsConfig.map.maxZoom,
interactive: true, pitch: GpsConfig.map.defaultPitch, // 初始倾斜角度,0 表示 2D 模式
antialias: true, // 抗锯齿
bearing: 0, // 初始旋转角度
interactive: true, // 可交互
attributionControl: false, attributionControl: false,
minTileCacheSize: GpsConfig.map.minTileCacheSize,
maxTileCacheSize: GpsConfig.map.maxTileCacheSize, // 调整瓦片缓存大小
}); });
// 添加resize事件监听器 // 添加resize事件监听器
...@@ -180,17 +656,752 @@ ...@@ -180,17 +656,752 @@
map.value.resize(); map.value.resize();
}); });
// 添加地图控件 // 监听 pitchChange 事件
map.value.addControl(new mapboxgl.NavigationControl()); map.value.on('load', () => {
loadMapIcons().then(() => {
refreshLiveMap();
destroyEvent();
initMapEvent();
initMapControls();
});
});
}, 0); }, 0);
} }
/**
* 刷新地图
*/
const refreshLiveMap = () => {
clearMap();
initPolyline();
initMarkers();
initCenter();
};
/**
* 清除地图
*/
const clearMap = () => {
if (!map.value) return;
const layers = map.value.getStyle()?.layers;
layers.forEach((layer) => {
if (
layer.id.includes(MapKeyEnum.KM_KEY) ||
layer.id.includes(MapKeyEnum.CP_KEY) ||
layer.id.includes(MapKeyEnum.LINE_KEY)
) {
// 删除图层
map.value?.removeLayer(layer.id);
// 检查并删除对应的数据源
if (map.value?.getSource(layer.id)) {
map.value.removeSource(layer.id);
}
}
});
};
/**
* 初始化中心
* */
const initCenter = () => {
const allCoordinates = trackCoordinates.value.map(
(v) => [v[0], v[1]],
);
if (allCoordinates.length > 0) {
const bounds = allCoordinates?.reduce(
(bounds, coord) => {
return bounds.extend(coord);
},
new mapboxgl.LngLatBounds(allCoordinates[0], allCoordinates[0]),
);
map.value?.fitBounds(bounds, {
padding: GpsConfig.map.centerPadding,
duration: GpsConfig.map.duration,
essential: true,
});
}
};
/**
* 初始化轨迹
*/
const initPolyline = () => {
if (!map.value) return;
if (!trackCoordinates.value.length) return
// 添加 polyline 到地图
const coordinates = trackCoordinates.value.map((v) => [v[0], v[1]]);
const sourceId = MapKeyEnum.LINE_SOURCE_ID;
const layerId = MapKeyEnum.LINE_ID
// 移除旧的轨迹图层和数据源
if (map.value?.getLayer(layerId)) {
map.value?.removeLayer(layerId);
}
if (map.value?.getSource(sourceId)) {
map.value?.removeSource(sourceId);
}
console.log({ coordinates })
if (!map.value?.getSource(sourceId)) {
map.value?.addSource(sourceId, {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates,
},
},
});
map.value?.addLayer({
id: layerId,
type: 'line',
source: sourceId,
paint: {
'line-color': GpsConfig.path.color,
'line-width': GpsConfig.path.width,
},
});
}
};
/**
* 初始化标记点
*/
const initMarkers = () => {
const kms = trackCoordinates.value?.length ? calculateDistanceWithTurf(
trackCoordinates.value.map((v) => [v.longitude, v.latitude]),
) : [];
// 构建cp点
const cpFeatures = markers.value.map((cp) => {
const { latitude, longitude, name, ID, type } = cp;
let icon = cp.icon;
if (!icon) {
switch (type) {
case pointTypes[0].value: {
icon = MapIconEnum.ICON_CP;
break;
}
case pointTypes[2].value: {
icon = MapIconEnum.ICON_END_POINT;
break;
}
case pointTypes[1].value: {
icon = MapIconEnum.ICON_START_POINT;
break;
}
default: {
icon = MapIconEnum.ICON_CP;
}
}
}
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [longitude, latitude],
},
properties: {
text: name,
color: GpsConfig.cp.textColor,
[MapKeyEnum.NAME_KEY]: ID,
icon,
},
};
});
// 公里数的展示
const kmFeatures = kms.map((km, kmIndex) => {
const kmId = MapKeyEnum.KM_KEY + kmIndex;
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: km,
},
properties: {
text: kmIndex + 1,
color: GpsConfig.text.textColor,
id: kmId,
circleColor: GpsConfig.km.circleColor,
},
};
});
// 异步添加/更新 数据
if (!map.value) return;
// 强制移除旧的数据源和图层
if (map.value.getLayer(MapKeyEnum.KM_CIRCLE_ID)) {
map.value.removeLayer(MapKeyEnum.KM_CIRCLE_ID);
}
if (map.value.getLayer(MapKeyEnum.KM_TEXT_ID)) {
map.value.removeLayer(MapKeyEnum.KM_TEXT_ID);
}
if (map.value.getLayer(MapKeyEnum.CP_ID)) {
map.value.removeLayer(MapKeyEnum.CP_ID);
}
if (map.value.getSource(MapKeyEnum.KM_SOURCE_ID)) {
map.value.removeSource(MapKeyEnum.KM_SOURCE_ID);
}
if (map.value.getSource(MapKeyEnum.CP_SOURCE_ID)) {
map.value.removeSource(MapKeyEnum.CP_SOURCE_ID);
}
// 添加公里数图层
if (map.value.getSource(MapKeyEnum.KM_SOURCE_ID)) {
// 如果 sourceId 存在,设置数据
(map.value.getSource(MapKeyEnum.KM_SOURCE_ID)).setData({
type: 'FeatureCollection',
features: kmFeatures,
});
} else {
// 如果 sourceId 不存在,添加 source
map.value.addSource(MapKeyEnum.KM_SOURCE_ID, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: kmFeatures,
},
});
// 添加图层
map.value.addLayer({
id: MapKeyEnum.KM_CIRCLE_ID,
type: 'circle',
source: MapKeyEnum.KM_SOURCE_ID,
paint: {
'circle-radius': GpsConfig.km.circleRadius,
// 允许文本相互重叠
'circle-color': ['get', 'circleColor'],
'circle-stroke-width': GpsConfig.km.circleBorderWidth, // 边框宽度
'circle-stroke-color': GpsConfig.km.circleBorderColor, // 边框颜色,这里设置为白色
},
});
map.value.addLayer({
id: MapKeyEnum.KM_TEXT_ID,
type: 'symbol',
source: MapKeyEnum.KM_SOURCE_ID,
minzoom: 0,
maxzoom: 24,
layout: {
'text-field': ['get', 'text'],
'text-anchor': 'center',
'text-size': GpsConfig.km.fontSize,
'text-font': GpsConfig.km.font,
'text-padding': GpsConfig.text.padding,
// 允许图标相互重叠
'icon-allow-overlap': true,
// 允许文本相互重叠
'text-allow-overlap': true,
},
paint: {
'text-color': GpsConfig.km.textColor,
},
});
}
// 添加cp点图层
if (map.value.getSource(MapKeyEnum.CP_SOURCE_ID)) {
// 如果 sourceId 存在,设置数据
(map.value.getSource(MapKeyEnum.CP_SOURCE_ID)).setData({
type: 'FeatureCollection',
features: cpFeatures,
});
} else {
// 如果 sourceId 不存在,添加 source
map.value.addSource(MapKeyEnum.CP_SOURCE_ID, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: cpFeatures,
},
});
map.value.addLayer({
id: MapKeyEnum.CP_ID,
type: 'symbol',
source: MapKeyEnum.CP_SOURCE_ID,
// 设置图层在所有缩放级别显示
layout: {
'icon-image': ['get', 'icon'],
'icon-anchor': 'bottom',
'text-anchor': 'top',
'text-size': GpsConfig.cp.fontSize,
'text-padding': GpsConfig.text.padding,
'text-font': GpsConfig.cp.font,
'text-field': ['get', 'text'],
'text-justify': 'center',
'text-pitch-alignment': 'viewport',
'text-line-height': GpsConfig.text.lineHeight,
'icon-size': GpsConfig.cp.iconSize,
'text-offset': GpsConfig.cp.textOffset,
// 允许图标相互重叠
'icon-allow-overlap': true,
// 允许文本相互重叠
'text-allow-overlap': true,
},
paint: {
'text-color': GpsConfig.cp.textColor,
// 添加字体描边颜色
'text-halo-color': GpsConfig.text.textColor, // 可根据需要修改颜色
// 添加字体描边宽度
'text-halo-width': GpsConfig.cp.haloWidth, // 可根据需要修改宽度
},
});
}
};
/**
* 计算总距离和公里点
*/
function calculateDistanceWithTurf(
coordinates,
) {
// 1. 创建LineString
const line = turf.lineString(coordinates);
// 2. 计算总长度(公里)
const totalDistance = turf.length(line, { units: 'kilometers' });
// 3. 计算每公里的点位
const kmPoints = [];
for (let km = 1; km <= Math.floor(totalDistance); km++) {
const along = turf.along(line, km, { units: 'kilometers' });
kmPoints.push(along.geometry.coordinates);
}
return kmPoints;
}
/**
* 初始化地图单位
*/
const initMapControls = () => {
if (!map.value) return;
// 添加比例尺控件到地图
map.value.addControl(new mapboxgl.ScaleControl(), 'bottom-right');
};
/**
* 加载静态资源
*/
const loadMapIcons = () => {
// 加载图标图片
const icons = Object.values(mapIcons);
const loadIcons = icons.map((icon) => {
// 检查图片是否已经存在
if (map.value?.hasImage(icon)) {
return Promise.resolve(true);
}
return new Promise((resolve, reject) => {
map.value?.loadImage(icon, (error, image) => {
if (error) {
// console.error(`Failed to load image: ${icon}`, error);
reject(error);
} else {
if (map.value?.hasImage(icon)) {
return resolve(true);
}
map.value?.addImage(icon, image);
resolve(true);
}
});
});
});
return Promise.all(loadIcons);
};
// 销毁之前的事件监听器
const destroyEvent = () => {
if (!map.value) return;
// 移除地图事件
map.value.off('click', MapKeyEnum.CP_ID, onCp);
map.value.off('click', onMapClick);
map.value.off('mouseenter');
map.value.off('mouseleave');
map.value?.off('mousemove', handleMapMouseMove);
};
/**
* 添加地图点击事件处理函数
* @param event 地图点击事件
*/
const onCp = (event) => {
event.preventDefault()
try {
if (!map.value) return;
// 点击标记点·
// 筛选出 players-icons 图层的要素
const features = map.value.queryRenderedFeatures(event.point, {
layers: [MapKeyEnum.CP_ID],
});
if (features.length > 0) {
const feature = features[0];
if (!feature?.properties) return;
const id = feature.properties[MapKeyEnum.NAME_KEY];
// emits('onMarker', id);
console.log({ markId: id })
}
} catch (error) {
console.error('地图点击事件处理出错:', error);
}
};
// 点位拾取
const onMapClick = (e) => {
if (!map.value || !isAddMode.value) return;
const features = map.value.queryRenderedFeatures(e.point, {
layers: [MapKeyEnum.CP_ID]
}); });
if (features.length) return
// 获取点击位置的坐标
const coordinates = e.lngLat;
// console.log('拾取的点位坐标:', coordinates);
markerForm.latitude = coordinates.lat.toString();
markerForm.longitude = coordinates.lng.toString();
markerForm.name = '新增标记点'
editCheckPoint(null)
// 可以在这里添加自定义逻辑,如:
// 1. 显示坐标信息
// 2. 在地图上添加标记
// 3. 触发其他业务逻辑
}
/**
* 提取 mousemove 事件处理函数
* @param e
*/
const handleMapMouseMove = (e) => {
// 否则光标是十字准线。
if (map.value?.getCanvas()) {
map.value.getCanvas().style.cursor = !isAddMode.value ? 'pointer'
: 'crosshair';
}
};
// 初始化 地图事件
const initMapEvent = () => {
if (!map.value) return;
map.value.on('click', [MapKeyEnum.CP_ID], onCp);
// 鼠标悬停在聚合点上时改变鼠标样式
map.value.on('mouseenter', [MapKeyEnum.CP_ID], () => {
if (!map.value) return;
map.value.getCanvas().style.cursor = 'pointer';
});
// 鼠标离开聚合点时恢复鼠标样式
map.value.on('mouseleave', [MapKeyEnum.CP_ID], () => {
if (!map.value) return;
map.value.getCanvas().style.cursor = '';
});
// 测距的时候监听鼠标悬浮事件
map.value?.on('mousemove', handleMapMouseMove);
// 添加地图点击拾取点位事件
map.value.on('click', onMapClick);
};
/**------------------------ 地图初始化 end ------------------------**/
/**------------------------ 轨迹导入 start ------------------------**/
// 轨迹的名称
const fileName = ref('')
// 轨迹上传的input
const fileInputRef = ref(null);
// 确保是二维数组类型
const trackCoordinates = ref([]);
const markers = ref([]);
// 处理文件选择
const handleFileChange = async (event) => {
const input = event.target;
if (input.files && input.files.length > 0) {
const file = input.files[0];
const fileType = file.name.split('.').pop()?.toLowerCase();
if (fileType === 'gpx' || fileType === 'kml') {
if (file.size <= 10 * 1024 * 1024) {
// 检查文件大小不超过 10M
message.success(`${file.name} 文件选择成功`);
} else {
message.error(`${file.name} 文件大小超过 10M,请重新选择`);
}
} else {
message.error(`只能上传 GPX 或 KML 文件,请重新选择`);
}
if (file) {
fileName.value = file.name;
const text = await file.text();
if (file.name.endsWith('.gpx')) {
parseGPX(text);
} else if (file.name.endsWith('.kml')) {
parseKML(text);
}
}
}
};
// 解析gpx
async function parseGPX(gpxData) {
try {
const goxDom = new DOMParser().parseFromString(gpxData, "text/xml");
const geoJSON = toGeoJSON.gpx(goxDom);
trackCoordinates.value =
geoJSON.features
.filter((f) => f.geometry?.type === 'LineString')
.flatMap((f) =>
f.geometry?.coordinates?.map((coord) => [
coord[0],
coord[1],
coord[2] || 0,
]),
) ?? [];
markers.value = geoJSON.features
.filter((f) => f.geometry?.type === 'Point' && f.properties?.name)
.map(
(p, index) =>
({
name: p.properties?.name || '未命名',
longitude: p.geometry.coordinates[0],
latitude: p.geometry.coordinates[1],
altitude: p.geometry.coordinates[2] || 0,
status: StatusEnum.OPEN.value,
type: CheckPointTypeEnum.MARKER.value,
id: index + 1
}),
);
if (trackCoordinates.value.length > 0) {
importMarkersAndTrackers();
} else {
message.error('GPX 文件中未找到有效的轨迹数据');
}
importMarkersAndTrackers();
} catch (error) {
console.error('Error parsing GPX:', error);
}
}
// 解析kml
async function parseKML(kmlData) {
try {
const kmlDom = new DOMParser().parseFromString(kmlData, "text/xml");
const geoJSON = toGeoJSON.kml(kmlDom);
// 安全提取并转换轨迹坐标
trackCoordinates.value =
geoJSON.features
.filter((f) => f.geometry?.type === 'LineString')
.flatMap((f) =>
f.geometry?.coordinates?.map((coord) => [
coord[0],
coord[1],
coord[2] || 0,
]),
) ?? [];
markers.value = geoJSON.features
.filter((f) => f.geometry?.type === 'Point' && f.properties?.name)
.map(
(p, index) =>
({
name: p.properties?.name || '未命名',
longitude: p.geometry.coordinates[0],
latitude: p.geometry.coordinates[1],
altitude: p.geometry.coordinates[2] || 0,
status: StatusEnum.OPEN.value,
type: CheckPointTypeEnum.MARKER.value,
id: index + 1
}),
)
.filter(Boolean);
if (trackCoordinates.value.length > 0) {
importMarkersAndTrackers();
} else {
message.error('KML 文件中未找到有效的轨迹数据');
}
} catch (error) {
console.error('Error parsing KML:', error);
}
}
// 保存标记点
const importMarkersAndTrackers = async () => {
// TODO 保存标记点
refreshLiveMap();
}
/**------------------------ 轨迹导入 end ------------------------**/
/**------------------------ marker start ------------------------**/
const isEditMarker = ref(false)
const defaultMarkerForm = {
id: undefined,
name: '',
longitude: '',
latitude: '',
altitude: '',
status: 1,
sort: 0,
categoryId: undefined,
type: CheckPointTypeEnum.CHECK_POINT.value,
}
const markerForm = reactive({
id: undefined,
name: '',
longitude: '',
latitude: '',
altitude: '',
status: 1,
sort: 0,
categoryId: undefined,
type: CheckPointTypeEnum.CHECK_POINT.value,
})
// 点位表单
const markerFormRef = ref()
// 是否显示弹框
const editMarkerModal = ref(false)
// 是否是点位添加模式
const isAddMode = ref(false)
// 表单规则
const markerRules = {
name: {
required: true,
message: '请输入点位名称',
trigger: ['blur', 'input'],
},
longitude: {
required: true,
message: '请输入经度',
trigger: ['blur', 'input'],
},
latitude: {
required: true,
message: '请输入纬度',
trigger: ['blur', 'input'],
},
}
// 重置
const resetMarkerForm = () => {
Object.assign(markerForm, defaultMarkerForm)
}
// 编辑点位
const editCheckPoint = (record) => {
isEditMarker.value = !!record
if (record) {
Object.assign(markerForm, { ...record })
}
editMarkerModal.value = true
}
// 删除点位
const deleteCheckPoint = (index) => {
markers.value.splice(index, 1);
message.success(`删除点位:${marker.name}`);
refreshLiveMap()
}
// 取消编辑点
const onMarkerCancel = () => {
editMarkerModal.value = false
resetMarkerForm()
}
// 保存编辑点
const onMarkerSubmit = (e) => {
e.preventDefault()
markerFormRef.value?.validate(async (errors) => {
if (errors) return message.error('请检查表单是否填写完整')
if (isEditMarker.value) {
const index = markers.value.findIndex(v => v.id === markerForm.id)
// 编辑点位
Object.assign(markers.value[index], { ...markerForm })
} else {
// 新增点位
markers.value.push({ ...markerForm, id: markers.value.length + 1 })
}
message.success('操作成功')
editMarkerModal.value = false
resetMarkerForm()
refreshLiveMap()
})
}
/**------------------------ marker end ------------------------**/
/**------------------------ 分类 start ------------------------**/
const defaultCategoryForm = {
name: '',
status: StatusEnum.OPEN.value,
sort: 100,
}
const categoryForm = reactive({
...defaultCategoryForm
})
const categories = ref([])
const isEditCategory = ref(false)
// 分类表单
const categoryFormRef = ref()
const categoryModal = ref(false)
// 分类规则
const categoryRules = {
name: {
required: true,
message: '请输入分类名称',
trigger: ['blur', 'input'],
},
}
// 初始化分类
const resetCategoryForm = async () => {
Object.assign(categoryForm, defaultCategoryForm)
}
// 编辑分类
const editCategory = (e, record) => {
e?.preventDefault()
isEditCategory.value = !!record
if (record) {
Object.assign(categoryForm, { ...record })
}
categoryModal.value = true
}
// 保存分类
const onCategorySubmit = (e) => {
e.preventDefault()
categoryFormRef.value?.validate(async (errors) => {
if (errors) return message.error('请检查表单是否填写完整')
if (isEditCategory.value) {
const index = categories.value.findIndex(v => v.id === categoryForm.id)
// 编辑分类
Object.assign(categories.value[index], { ...categoryForm })
} else {
// 新增分类
categories.value.unshift({ ...categoryForm, id: categories.value.length + 1 })
}
message.success('操作成功')
categoryModal.value = false
resetCategoryForm()
})
}
const onCategoryCancel = () => {
resetCategoryForm()
categoryModal.value = false
}
/**------------------------ 分类 end ------------------------**/
// 对外返回的信息
return {
// 轨迹导入模块
fileInputRef,
fileName,
handleFileChange,
// 公共
CheckPointTypeEnum,
StatusEnum,
// 点位编辑模块
markers,
markerForm,
markerFormRef,
isEditMarker,
editMarkerModal,
editCheckPoint,
deleteCheckPoint,
onMarkerCancel,
onMarkerSubmit,
markerRules,
isAddMode,
// 分类板块
categories,
editCategory,
onCategorySubmit,
onCategoryCancel,
categoryRules,
isEditCategory,
categoryModal,
categoryForm,
categoryFormRef
}
}
})
const app = Vue.createApp({
setup() {
return { return {
mapContainer locale: zhCN,
dateLocale: dateZhCN
} }
} }
}) })
const app = Vue.createApp({})
app.component('my-app', MyApp) app.component('my-app', MyApp)
app.use(naive) app.use(naive)
app.mount('#app') app.mount('#app')
......
...@@ -116,6 +116,22 @@ ...@@ -116,6 +116,22 @@
margin-top: 3rem; margin-top: 3rem;
} }
.ml-auto {
margin-left: auto;
}
.mt-auto {
margin-top: auto;
}
.mb-auto {
margin-bottom: auto;
}
.mr-auto {
margin-right: auto;
}
/* 其他方向margin (mr, mb, ml) 和 padding (p, pt, pr, pb, pl) 同理 */ /* 其他方向margin (mr, mb, ml) 和 padding (p, pt, pr, pb, pl) 同理 */
/* 这里省略类似代码,实际使用时建议补全 */ /* 这里省略类似代码,实际使用时建议补全 */
...@@ -1155,3 +1171,255 @@ ...@@ -1155,3 +1171,255 @@
.bg-opacity-90 { .bg-opacity-90 {
opacity: 0.9; opacity: 0.9;
} }
/* 定位相关 */
.relative {
position: relative;
}
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
.sticky {
position: sticky;
}
.static {
position: static;
}
/* 定位偏移 - 基于rem单位 */
.top-0 {
top: 0;
}
.top-1 {
top: 0.25rem;
}
.top-2 {
top: 0.5rem;
}
.top-3 {
top: 1rem;
}
.top-4 {
top: 1.5rem;
}
.top-5 {
top: 3rem;
}
.right-0 {
right: 0;
}
.right-1 {
right: 0.25rem;
}
.right-2 {
right: 0.5rem;
}
.right-3 {
right: 1rem;
}
.right-4 {
right: 1.5rem;
}
.right-5 {
right: 3rem;
}
.bottom-0 {
bottom: 0;
}
.bottom-1 {
bottom: 0.25rem;
}
.bottom-2 {
bottom: 0.5rem;
}
.bottom-3 {
bottom: 1rem;
}
.bottom-4 {
bottom: 1.5rem;
}
.bottom-5 {
bottom: 3rem;
}
.left-0 {
left: 0;
}
.left-1 {
left: 0.25rem;
}
.left-2 {
left: 0.5rem;
}
.left-3 {
left: 1rem;
}
.left-4 {
left: 1.5rem;
}
.left-5 {
left: 3rem;
}
/* 百分比偏移 */
.top-1\/2 {
top: 50%;
}
.top-1\/3 {
top: 33.333333%;
}
.top-2\/3 {
top: 66.666667%;
}
.right-1\/2 {
right: 50%;
}
.right-1\/3 {
right: 33.333333%;
}
.right-2\/3 {
right: 66.666667%;
}
.bottom-1\/2 {
bottom: 50%;
}
.bottom-1\/3 {
bottom: 33.333333%;
}
.bottom-2\/3 {
bottom: 66.666667%;
}
.left-1\/2 {
left: 50%;
}
.left-1\/3 {
left: 33.333333%;
}
.left-2\/3 {
left: 66.666667%;
}
/* 全屏定位 */
.inset-0 {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.inset-x-0 {
left: 0;
right: 0;
}
.inset-y-0 {
top: 0;
bottom: 0;
}
/* 固定像素值偏移 */
.top-4px {
top: 4px;
}
.top-8px {
top: 8px;
}
.top-12px {
top: 12px;
}
.top-16px {
top: 16px;
}
/* z-index 相关 */
.z-0 {
z-index: 0;
}
.z-1 {
z-index: 1;
}
.z-2 {
z-index: 2;
}
.z-3 {
z-index: 3;
}
.z-4 {
z-index: 4;
}
.z-5 {
z-index: 5;
}
.z-10 {
z-index: 10;
}
.z-20 {
z-index: 20;
}
.z-30 {
z-index: 30;
}
.z-40 {
z-index: 40;
}
.z-50 {
z-index: 50;
}
.z-auto {
z-index: auto;
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment