Vue 3 + Leaflet 地图可视化

📖 前言

在现代 Web 应用中,地图可视化是一个常见的需求。无论是展示位置信息、轨迹追踪,还是数据统计分析,地图都能提供直观的视觉体验。本文将详细介绍如何在 Vue 3 项目中集成 Leaflet 地图库,实现一个功能完整的地图可视化组件。

🎯 项目背景

本项目是一个检测管理系统,需要在地图上展示检测点的位置信息。主要需求包括:

  • 支持大量标记点的展示
  • 标记点聚合功能,提升性能
  • 多种地图类型切换(路网图、卫星图、地形图)
  • 标记点点击查看详情
  • 全屏模式支持
  • 实时统计可见标记数量

📦 技术栈

  • 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 在打包后可能会出现图标路径问题,需要手动配置:

复制代码
// 修复 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',
});

🏗️ 核心功能实现

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;
    }
    
    // 创建地图实例
    map = L.map(mapContainer.value, {
        zoomControl: true,
        attributionControl: true,
    });

    // 将缩放控件移到右上角
    if (map.zoomControl) {
        map.zoomControl.setPosition('topright');
    }

    // 添加初始瓦片图层
    switchMapType('road');
    
    // 等待地图加载完成后再添加事件监听
    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. 地图类型切换

支持路网图、卫星图、地形图三种类型:

复制代码
const switchMapType = (type) => {
    if (!map) return;
    
    mapType.value = type || mapType.value;
    
    // 移除旧图层
    if (currentTileLayer) {
        map.removeLayer(currentTileLayer);
    }
    
    // 根据类型添加新图层
    let tileUrl = '';
    let attribution = '';
    
    switch (mapType.value) {
        case 'satellite':
            // 高德地图卫星图
            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:
            // 高德地图路网图
            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. 标记点创建

创建自定义图标:

复制代码
// 创建默认图标
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]
    });
};

// 创建高亮图标
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 实现标记聚合:

复制代码
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';
                
                // 根据数量设置不同颜色和大小
                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)
                });
            }
        });

        // 监听聚类点击事件
        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. 弹窗内容定制

创建丰富的弹窗内容:

复制代码
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;">📷 现场图片:</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;">📅 发布时间:</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;
    
    // 临时移除事件监听器,避免触发统计更新
    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 {
        // 重新添加事件监听器
        setTimeout(() => {
            if (map) {
                map.on('moveend', updateVisibleStats);
                map.on('zoomend', updateVisibleStats);
            }
        }, 500);
    }
};

7. 可见标记统计

实时统计当前视图范围内的标记数量:

复制代码
// 防抖函数
const debounce = (func, wait) => {
    let timeout;
    return function executedFunction(...args) {
        const later = () => {
            clearTimeout(timeout);
            func(...args);
        };
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);
    };
};

// 更新可见标记统计(防抖处理)
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;
            }
            
            // 计算当前视图范围内的标记数
            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('更新可见标记统计失败:', 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();
        }
    }
};

// 监听全屏状态变化
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']
        },
        // ... 更多数据
    ];
    
    mapModalRef.value?.openMap(dataList);
};
</script>

💡 最佳实践

1. 性能优化

  • 使用标记聚合:当标记点数量超过 100 个时,建议使用聚合模式
  • 防抖处理:地图移动和缩放事件使用防抖,避免频繁计算
  • 延迟加载:大数据量时,可以分批加载标记点

2. 内存管理

  • 及时清理:组件卸载时,务必清理地图实例和事件监听器

  • 避免内存泄漏 :使用 onBeforeUnmount 钩子进行清理

    onBeforeUnmount(() => {
    clearMarkers();
    if (map) {
    map.off('moveend', updateVisibleStats);
    map.off('zoomend', updateVisibleStats);
    map.off('click');
    map.remove();
    map = null;
    }
    });

3. 错误处理

  • 数据验证:添加标记前,验证经纬度数据的有效性

  • 异常捕获:使用 try-catch 包裹可能出错的操作

    const validData = dataList.filter(item =>
    item.longitude && item.latitude &&
    !isNaN(parseFloat(item.longitude)) &&
    !isNaN(parseFloat(item.latitude))
    );

🐛 常见问题

1. 图标不显示

问题:打包后图标路径错误

解决方案:使用 CDN 地址或配置 webpack/vite 的静态资源处理

复制代码
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. 添加自定义控件

复制代码
// 创建自定义控件
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. 添加圆形区域

复制代码
// 添加圆形区域
const circle = L.circle([39.9042, 116.4074], {
    color: 'red',
    fillColor: '#f03',
    fillOpacity: 0.5,
    radius: 500
}).addTo(map);

🎯 总结

通过本文的介绍,我们实现了一个功能完整的地图可视化组件,包括:

  • ✅ 地图初始化和配置
  • ✅ 多种地图类型切换
  • ✅ 标记点聚合和平铺模式
  • ✅ 丰富的弹窗内容
  • ✅ 实时统计功能
  • ✅ 全屏支持
  • ✅ 性能优化

Leaflet 作为一个轻量级、功能强大的地图库,非常适合在 Vue 3 项目中使用。通过合理的架构设计和性能优化,可以轻松处理大量标记点的展示需求。

📖 参考资源

相关推荐
神秘的猪头15 小时前
Ajax 数据请求:从零开始掌握异步通信
前端·javascript
黛色正浓15 小时前
leetCode-热题100-贪心合集(JavaScript)
javascript·算法·leetcode
稀饭5215 小时前
用changeset来管理你的npm包版本
前端·npm
TeamDev15 小时前
基于 Angular UI 的 C# 桌面应用
前端·后端·angular.js
Komorebi゛16 小时前
【CSS】斜角流光样式
前端·css
Irene199116 小时前
CSS 废弃属性分类总结
前端·css
青莲84316 小时前
Android 事件分发机制 - 事件流向详解
android·前端·面试
musashi16 小时前
用 Electron 写了一个 macOS 版本的 wallpaper(附源码、下载地址)
前端·vue.js·electron
满天星辰16 小时前
Typescript之类型总结大全
前端·typescript