Vue3集成Leaflet實現(xiàn)一個功能完整的地圖可視化組件
前言
在現(xiàn)代 Web 應用中,地圖可視化是一個常見的需求。無論是展示位置信息、軌跡追蹤,還是數(shù)據(jù)統(tǒng)計分析,地圖都能提供直觀的視覺體驗。本文將詳細介紹如何在 Vue 3 項目中集成 Leaflet 地圖庫,實現(xiàn)一個功能完整的地圖可視化組件。
項目背景
本項目是一個檢測管理系統(tǒng),需要在地圖上展示檢測點的位置信息。主要需求包括:
- 支持大量標記點的展示
- 標記點聚合功能,提升性能
- 多種地圖類型切換(路網(wǎng)圖、衛(wèi)星圖、地形圖)
- 標記點點擊查看詳情
- 全屏模式支持
- 實時統(tǒng)計可見標記數(shù)量
技術棧
- Vue 3.5.11 - 漸進式 JavaScript 框架
- Leaflet 1.9.4 - 開源地圖庫
- leaflet.markercluster 1.5.3 - 標記聚類插件
- Ant Design Vue 3 - UI 組件庫
- Vite 5.0 - 構建工具
安裝配置
1. 安裝依賴
npm install leaflet leaflet.markercluster
2. 引入樣式文件
在組件中引入 Leaflet 的 CSS 文件:
import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import 'leaflet.markercluster'; import 'leaflet.markercluster/dist/MarkerCluster.css'; import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
3. 修復圖標路徑問題
Leaflet 在打包后可能會出現(xiàn)圖標路徑問題,需要手動配置:
// 修復 leaflet 默認圖標路徑問題
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});核心功能實現(xiàn)
1. 地圖初始化
let map = null;
const initMap = () => {
if (!mapContainer.value) return;
// 如果地圖已存在,先清理
if (map) {
map.off('moveend', updateVisibleStats);
map.off('zoomend', updateVisibleStats);
map.off('click');
map.remove();
map = null;
}
// 創(chuàng)建地圖實例
map = L.map(mapContainer.value, {
zoomControl: true,
attributionControl: true,
});
// 將縮放控件移到右上角
if (map.zoomControl) {
map.zoomControl.setPosition('topright');
}
// 添加初始瓦片圖層
switchMapType('road');
// 等待地圖加載完成后再添加事件監(jiān)聽
map.whenReady(() => {
map.on('moveend', updateVisibleStats);
map.on('zoomend', updateVisibleStats);
map.on('click', (e) => {
// 點擊地圖空白處取消高亮
if (e.originalEvent && e.originalEvent.target) {
const target = e.originalEvent.target;
if (!target.closest('.leaflet-popup') && !target.closest('.leaflet-marker-icon')) {
clearHighlight();
}
}
});
});
};2. 地圖類型切換
支持路網(wǎng)圖、衛(wèi)星圖、地形圖三種類型:
const switchMapType = (type) => {
if (!map) return;
mapType.value = type || mapType.value;
// 移除舊圖層
if (currentTileLayer) {
map.removeLayer(currentTileLayer);
}
// 根據(jù)類型添加新圖層
let tileUrl = '';
let attribution = '';
switch (mapType.value) {
case 'satellite':
// 高德地圖衛(wèi)星圖
tileUrl = 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}';
attribution = '? 高德地圖';
break;
case 'terrain':
// 高德地圖地形圖
tileUrl = 'https://webst0{s}.is.autonavi.com/appmaptile?style=8&x={x}&y={y}&z={z}';
attribution = '? 高德地圖';
break;
case 'road':
default:
// 高德地圖路網(wǎng)圖
tileUrl = 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}';
attribution = '? 高德地圖';
break;
}
currentTileLayer = L.tileLayer(tileUrl, {
subdomains: ['1', '2', '3', '4'],
attribution: attribution,
maxZoom: 18,
}).addTo(map);
};3. 標記點創(chuàng)建
創(chuàng)建自定義圖標:
// 創(chuàng)建默認圖標
const createDefaultIcon = () => {
return L.icon({
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [0, -41]
});
};
// 創(chuàng)建高亮圖標
const createHighlightIcon = () => {
return L.icon({
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
iconSize: [30, 46],
iconAnchor: [15, 46],
popupAnchor: [0, -46],
className: 'highlighted-marker'
});
};4. 標記聚合功能
使用 leaflet.markercluster 實現(xiàn)標記聚合:
let markerClusterGroup = null;
const renderMarkers = (validData) => {
clearMarkers();
if (displayMode.value === 'cluster') {
// 聚合模式
markerClusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: true,
zoomToBoundsOnClick: true,
iconCreateFunction: function(cluster) {
const count = cluster.getChildCount();
let size = 40;
let fontSize = 14;
let bgColor = '#1890ff';
// 根據(jù)數(shù)量設置不同顏色和大小
if (count > 100) {
size = 50;
fontSize = 16;
bgColor = '#ff4d4f';
} else if (count > 50) {
size = 45;
fontSize = 15;
bgColor = '#fa8c16';
} else if (count > 20) {
size = 42;
fontSize = 14;
bgColor = '#52c41a';
}
return L.divIcon({
html: `<div style="background-color: ${bgColor}; color: white; border-radius: 50%; width: ${size}px; height: ${size}px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: ${fontSize}px; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.3);">${count}</div>`,
className: 'marker-cluster-custom',
iconSize: L.point(size, size)
});
}
});
// 監(jiān)聽聚類點擊事件
markerClusterGroup.on('clusterclick', function(cluster) {
const markers = cluster.getAllChildMarkers();
if (markers && markers.length > 0) {
const firstMarker = markers[0];
const latlng = firstMarker.getLatLng();
const item = validData.find(d =>
d.latitude === latlng.lat && d.longitude === latlng.lng
);
if (item) {
highlightMarker(firstMarker, item);
}
}
});
// 添加標記到聚類組
validData.forEach(item => {
const marker = L.marker([item.latitude, item.longitude], {
icon: createDefaultIcon()
}).bindPopup(createPopupContent(item));
marker.on('click', function() {
highlightMarker(this, item);
});
markerClusterGroup.addLayer(marker);
});
markerClusterGroup.addTo(map);
} else {
// 平鋪模式
validData.forEach(item => {
const marker = L.marker([item.latitude, item.longitude], {
icon: createDefaultIcon()
})
.addTo(map)
.bindPopup(createPopupContent(item));
marker.on('click', function() {
highlightMarker(this, item);
});
markers.push(marker);
});
}
// 自動調整視圖范圍
fitBounds(validData);
};5. 彈窗內容定制
創(chuàng)建豐富的彈窗內容:
const createPopupContent = (item) => {
// 圖片預覽部分
const imagesHtml = item.imageList && item.imageList.length > 0
? `
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #e8e8e8;">
<div style="font-weight: bold; margin-bottom: 8px; color: #333;">?? 現(xiàn)場圖片:</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
${item.imageList.slice(0, 3).map((img, idx) =>
`<img src="${img}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #e8e8e8;" onclick="window.previewImage('${img}')" title="點擊預覽" />`
).join('')}
</div>
</div>
`
: '';
return `
<div style="min-width: 280px; max-width: 400px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #1890ff;">
<h4 style="margin: 0; color: #1890ff; font-size: 16px;">?? ${item.serviceCode || '-'}</h4>
</div>
<div style="margin-bottom: 8px;">
<div style="margin-bottom: 8px; display: flex; align-items: flex-start;">
<span style="font-weight: bold; min-width: 80px; color: #666;">??? 線路:</span>
<span style="flex: 1;">${item.towerNumber || '-'}</span>
</div>
<div style="margin-bottom: 8px; display: flex; align-items: flex-start;">
<span style="font-weight: bold; min-width: 80px; color: #666;">?? 發(fā)布時間:</span>
<span style="flex: 1;">${item.createTime || '-'}</span>
</div>
</div>
${imagesHtml}
<div style="margin-top: 12px; display: flex; gap: 8px; padding-top: 12px; border-top: 1px solid #e8e8e8;">
<button
onclick="window.viewDetail('${item.id}')"
style="flex: 1; padding: 8px 16px; background: #1890ff; color: white; border: none; border-radius: 4px; cursor: pointer;">
查看詳情
</button>
<button
onclick="window.centerMap(${item.latitude}, ${item.longitude})"
style="flex: 1; padding: 8px 16px; background: #52c41a; color: white; border: none; border-radius: 4px; cursor: pointer;">
居中定位
</button>
</div>
</div>
`;
};6. 視圖范圍自適應
自動調整地圖視圖以顯示所有標記點:
const fitBounds = (points) => {
if (points.length === 0 || !map) return;
// 臨時移除事件監(jiān)聽器,避免觸發(fā)統(tǒng)計更新
map.off('moveend', updateVisibleStats);
map.off('zoomend', updateVisibleStats);
try {
if (points.length === 1) {
const point = points[0];
map.setView([point.latitude, point.longitude], 13);
} else {
const bounds = L.latLngBounds(
points.map(p => [p.latitude, p.longitude])
);
map.fitBounds(bounds, { padding: [50, 50] });
}
} catch (e) {
console.warn('設置地圖視圖失敗:', e);
} finally {
// 重新添加事件監(jiān)聽器
setTimeout(() => {
if (map) {
map.on('moveend', updateVisibleStats);
map.on('zoomend', updateVisibleStats);
}
}, 500);
}
};7. 可見標記統(tǒng)計
實時統(tǒng)計當前視圖范圍內的標記數(shù)量:
// 防抖函數(shù)
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
// 更新可見標記統(tǒng)計(防抖處理)
let updateStatsTimer = null;
const updateVisibleStats = () => {
if (!map || currentDataList.length === 0) {
visibleMarkersCount.value = 0;
return;
}
if (updateStatsTimer) {
clearTimeout(updateStatsTimer);
}
updateStatsTimer = setTimeout(() => {
try {
const bounds = map.getBounds();
if (!bounds) {
visibleMarkersCount.value = 0;
return;
}
// 計算當前視圖范圍內的標記數(shù)
const visible = currentDataList.filter(item => {
if (!item.longitude || !item.latitude) return false;
try {
const lat = parseFloat(item.latitude);
const lng = parseFloat(item.longitude);
if (isNaN(lat) || isNaN(lng)) return false;
return bounds.contains([lat, lng]);
} catch (e) {
return false;
}
});
visibleMarkersCount.value = visible.length;
} catch (e) {
console.warn('更新可見標記統(tǒng)計失敗:', e);
visibleMarkersCount.value = 0;
}
}, 200);
};8. 全屏功能
支持全屏查看地圖:
const toggleFullscreen = () => {
if (!mapContainerRef.value) return;
if (!isFullscreen.value) {
if (mapContainerRef.value.requestFullscreen) {
mapContainerRef.value.requestFullscreen();
} else if (mapContainerRef.value.webkitRequestFullscreen) {
mapContainerRef.value.webkitRequestFullscreen();
} else if (mapContainerRef.value.mozRequestFullScreen) {
mapContainerRef.value.mozRequestFullScreen();
} else if (mapContainerRef.value.msRequestFullscreen) {
mapContainerRef.value.msRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
};
// 監(jiān)聽全屏狀態(tài)變化
const handleFullscreenChange = () => {
isFullscreen.value = !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
);
// 全屏時重新調整地圖大小
if (map) {
setTimeout(() => {
map.invalidateSize();
}, 100);
}
};樣式定制
高亮標記樣式
:deep(.highlighted-marker) {
filter: drop-shadow(0 0 8px rgba(24, 144, 255, 0.8));
z-index: 1000 !important;
}聚類樣式
:deep(.marker-cluster-custom) {
background-color: transparent !important;
border: none !important;
}
:deep(.marker-cluster-custom div) {
transition: all 0.3s ease;
}
:deep(.marker-cluster-custom:hover div) {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
}組件使用
<template>
<map-modal ref="mapModalRef" />
<a-button @click="openMap">打開地圖</a-button>
</template>
<script setup>
import { ref } from 'vue';
import MapModal from '@/components/mapModal.vue';
const mapModalRef = ref(null);
const openMap = () => {
const dataList = [
{
id: 1,
latitude: 39.9042,
longitude: 116.4074,
serviceCode: 'BJ001',
towerNumber: '北京-001',
createTime: '2024-01-01',
imageList: ['https://example.com/image1.jpg']
},
// ... 更多數(shù)據(jù)
];
mapModalRef.value?.openMap(dataList);
};
</script>最佳實踐
1. 性能優(yōu)化
- 使用標記聚合:當標記點數(shù)量超過 100 個時,建議使用聚合模式
- 防抖處理:地圖移動和縮放事件使用防抖,避免頻繁計算
- 延遲加載:大數(shù)據(jù)量時,可以分批加載標記點
2. 內存管理
- 及時清理:組件卸載時,務必清理地圖實例和事件監(jiān)聽器
- 避免內存泄漏:使用
onBeforeUnmount鉤子進行清理
onBeforeUnmount(() => {
clearMarkers();
if (map) {
map.off('moveend', updateVisibleStats);
map.off('zoomend', updateVisibleStats);
map.off('click');
map.remove();
map = null;
}
});3. 錯誤處理
- 數(shù)據(jù)驗證:添加標記前,驗證經(jīng)緯度數(shù)據(jù)的有效性
- 異常捕獲:使用 try-catch 包裹可能出錯的操作
const validData = dataList.filter(item =>
item.longitude && item.latitude &&
!isNaN(parseFloat(item.longitude)) &&
!isNaN(parseFloat(item.latitude))
);常見問題
1. 圖標不顯示
問題:打包后圖標路徑錯誤
解決方案:使用 CDN 地址或配置 webpack/vite 的靜態(tài)資源處理
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});2. 地圖顯示空白
問題:容器未正確初始化或樣式問題
解決方案:
- 確保容器有明確的高度
- 使用
nextTick確保 DOM 已渲染 - 調用
map.invalidateSize()重新計算尺寸
3. 聚合不生效
問題:未正確引入插件或配置錯誤
解決方案:
- 確保引入了
leaflet.markercluster及其樣式文件 - 檢查
markerClusterGroup是否正確添加到地圖
擴展功能
1. 添加自定義控件
// 創(chuàng)建自定義控件
const CustomControl = L.Control.extend({
onAdd: function(map) {
const container = L.DomUtil.create('div', 'custom-control');
container.innerHTML = '<button>自定義按鈕</button>';
return container;
}
});
// 添加到地圖
new CustomControl({ position: 'topleft' }).addTo(map);2. 繪制路線
// 使用 Polyline 繪制路線
const polyline = L.polyline([
[39.9042, 116.4074],
[39.9142, 116.4174],
[39.9242, 116.4274]
], {
color: 'red',
weight: 3
}).addTo(map);3. 添加圓形區(qū)域
// 添加圓形區(qū)域
const circle = L.circle([39.9042, 116.4074], {
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5,
radius: 500
}).addTo(map);總結
通過本文的介紹,我們實現(xiàn)了一個功能完整的地圖可視化組件,包括:
- 地圖初始化和配置
- 多種地圖類型切換
- 標記點聚合和平鋪模式
- 豐富的彈窗內容
- 實時統(tǒng)計功能
- 全屏支持
- 性能優(yōu)化
Leaflet 作為一個輕量級、功能強大的地圖庫,非常適合在 Vue 3 項目中使用。通過合理的架構設計和性能優(yōu)化,可以輕松處理大量標記點的展示需求。
以上就是Vue3集成Leaflet實現(xiàn)一個功能完整的地圖可視化組件的詳細內容,更多關于Vue3 Leaflet地圖可視化組件的資料請關注腳本之家其它相關文章!
相關文章
vue.js vue-router如何實現(xiàn)無效路由(404)的友好提示
眾所周知vue-router是Vue.js官方的路由插件,它和vue.js是深度集成的,適合用于構建單頁面應用,下面這篇文章主要給大家介紹了關于vue.js中vue-router如何實現(xiàn)無效路由(404)的友好提示的相關資料,需要的朋友可以參考下。2017-12-12

