vue.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > vue.js > Vue3 Leaflet地图可视化组件

Vue3集成Leaflet实现一个功能完整的地图可视化组件

作者:爱健身的小刘同学

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

前言

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

项目背景

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

技术栈

安装配置

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. 性能优化

2. 内存管理

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

3. 错误处理

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. 地图显示空白

问题:容器未正确初始化或样式问题

解决方案

3. 聚合不生效

问题:未正确引入插件或配置错误

解决方案

扩展功能

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 项目中使用。通过合理的架构设计和性能优化,可以轻松处理大量标记点的展示需求。

以上就是Vue3集成Leaflet实现一个功能完整的地图可视化组件的详细内容,更多关于Vue3 Leaflet地图可视化组件的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文