leaflet点阵地图

效果

简述

项目上有这个需求,在网上找了一下没有找到相关方案,就结合cursor和问小白的相关回答自己实现了一个效果。这个效果呢,我不知其名,强名为点阵地图。理论上可以直接用图片来做的,但是因为需要根据地区打点,使用图片就无法判断国家的位置(返回的国家和地区不是固定的,不好做固定位置),就只能使用地图工具实际的绘制出来才能实现打点功能。

总结

  1. geoJson地图需要绘制出来,才能提供地图的基准尺寸,同时设置weight=0保持不可见
  2. 代码里面没有提供geoJson,自己在网上找一下
  3. 如果启用了拖动功能,图层的位置可能会出现相对于底部绘制的geoJson的偏移
  4. 通过map.getSize获取了地图尺寸,使用了canvas图层覆盖在上面实现
  5. canvas图层从左上角开始计算图中的点,根据圆点中心坐标转换为经纬度,判断经纬度是否包含(Turf.js实现)在地图的geoJson区域来过滤需要实际绘制出来的点
  6. 通过预计算减少点需要遍历的地区geoJSON坐标范围,优化了性能,优化前canvas区域总7000个点,需要5s-8s,优化后50ms
  7. 通过canvas缓存,避免重复计算
  8. zoomSnap:0.1,配置leaflet最小缩放步长,能更好的填充满容器区域
  9. 总结完毕

完整代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Leaflet GeoJSON 地图</title>

    <!-- Leaflet CSS -->
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
        integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />


    <style>
        body {
            margin: 0;
            padding: 0;
            font-family: Arial, sans-serif;
        }

        #map {
            height: 447px;
            width: 1014px;
            background-color: #F2F5FF;
        }

        .control-panel {
            position: absolute;
            top: 10px;
            right: 10px;
            background: white;
            padding: 15px;
            border-radius: 5px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
            z-index: 1000;
            min-width: 200px;
        }

        .control-panel h3 {
            margin: 0 0 10px 0;
            color: #333;
        }

        .control-panel button {
            width: 100%;
            padding: 8px;
            margin: 5px 0;
            border: none;
            border-radius: 3px;
            background: #007cba;
            color: white;
            cursor: pointer;
            font-size: 14px;
        }

        .control-panel button:hover {
            background: #005a87;
        }
    </style>
</head>

<body>
    <div id="map"></div>

    <div class="control-panel">
        <h3>地图控制</h3>
        <button onclick="loadGeoJSON()">加载 GeoJSON</button>
        <button onclick="clearGeoJSON()">清除 GeoJSON</button>
        <button onclick="fitBounds()">适应边界</button>
    </div>


    <!-- Leaflet JavaScript -->
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
        integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>

    <!-- Turf.js for spatial operations -->
    <script src="https://unpkg.com/@turf/turf@6.5.0/turf.min.js"></script>


    <script>
        const dotCanvas = new OffscreenCanvas(6, 6);
        const dotCtx = dotCanvas.getContext('2d');

        // 在图案画布上绘制格点
        dotCtx.beginPath();
        dotCtx.arc(3, 3, 3, 0, 2 * Math.PI);
        dotCtx.fillStyle = '#D6DFFF';
        dotCtx.fill();

        class Point {
            constructor(x, y, lat, lng) {
                this.x = x;           // 像素X坐标
                this.y = y;           // 像素Y坐标
                this.lat = lat;       // 纬度
                this.lng = lng;       // 经度
                this.image = dotCanvas;
                this.turfPoint = null; // turf.js点对象
            }

            draw(ctx) {
                ctx.drawImage(this.image, this.x, this.y);
            }


            // 获取turf.js点对象
            getTurfPoint() {
                if (!this.turfPoint) {
                    this.turfPoint = turf.point([this.lng, this.lat]);
                }
                return this.turfPoint;
            }

            // 检查是否包含在GeoJSON要素内
            isInsideFeature(feature) {
                const point = this.getTurfPoint();
                return turf.booleanPointInPolygon(point, feature);
            }

            // 检查是否包含在GeoJSON要素集合内
            isInsideFeatureCollection(featureCollection) {
                const point = this.getTurfPoint();
                for (const feature of featureCollection.features) {
                    if (turf.booleanPointInPolygon(point, feature)) {
                        return { inside: true, feature: feature };
                    }
                }
                return { inside: false, feature: null };
            }
        }

        /**
         * 记录地图缩放比例对应的格点图
         * @type {Record<number, OffscreenCanvas>}
         */
        const mapZoomPoints = {}

        const createPointCanvas = (w, h, points) => {
            const canvas = new OffscreenCanvas(w, h);
            const ctx = canvas.getContext('2d');
            drawGridPointsOptimized(points, ctx);
            return canvas;
        }

        // ==================== 全局变量和配置 ====================
        const map = L.map('map', {
            zoomAnimation: false,
            keyboard: false,
            scrollWheelZoom: false,
            zoomSnap: 0.1,
            dragging: false,
            doubleClickZoom: false,
            boxZoom: false,
            zoomControl: false,
            attributionControl: false,
            tap: false,
            touchZoom: false,
        }).setView([0, 0], 3); // 全球视图

        // 图层管理
        let geoJSONLayer = null;
        let gridMapLayer = null;
        let worldMapData = null;


        // ==================== 样式配置 ====================
        // 轮廓地图样式
        function styleFeature(feature) {
            return {
                fillColor: 'transparent',
                weight: 0,
                opacity: 1,
                color: '#333333',
                dashArray: '',
                fillOpacity: 0
            };
        }

        // 过滤掉南极洲的函数
        function filterOutAntarctica(originalData) {
            return {
                type: "FeatureCollection",
                features: originalData.features.filter(feature => feature.properties['国家名称'] !== '南极洲')
            };
        }

        // 从几何体中提取所有坐标点
        function getCoordinatesFromGeometry(geometry) {
            const coords = [];

            function extractCoords(coordinateArray) {
                if (Array.isArray(coordinateArray)) {
                    if (coordinateArray.length === 2 && typeof coordinateArray[0] === 'number') {
                        // 这是一个坐标点 [lng, lat]
                        coords.push(coordinateArray);
                    } else {
                        // 这是一个坐标数组,递归处理
                        coordinateArray.forEach(coord => extractCoords(coord));
                    }
                }
            }

            if (geometry.coordinates) {
                extractCoords(geometry.coordinates);
            }

            return coords;
        }


        // ==================== 格点绘制方法 ====================
        /**
         * 原始绘制方法(性能较慢,用于对比)
         * @param {Array} points - 格点数组
         * @param {CanvasRenderingContext2D} ctx - Canvas上下文
         */
        function drawGridPointsOriginal(points, ctx) {
            console.log('使用原始绘制方法(性能较慢)');
            const pointsInCountries = points.filter(point => {
                // 检查格点是否在某个国家内
                if (worldMapData) {
                    const result = point.isInsideFeatureCollection(worldMapData);
                    if (result.inside) {
                        return true;
                    }
                }
                return false;
            });
            pointsInCountries.forEach(point => point.draw(ctx));
            console.log(`总共生成了 ${points.length} 个格点,其中 ${pointsInCountries.length} 个格点在国家区域内`);
        }

        /**
         * 优化绘制方法(性能快速)
         * @param {Array} points - 格点数组
         * @param {CanvasRenderingContext2D} ctx - Canvas上下文
         */
        function drawGridPointsOptimized(points, ctx) {
            console.log('使用优化绘制方法(性能快速)');

            // 性能优化:预计算国家边界框,减少不必要的计算
            const countryBounds = worldMapData ? worldMapData.features.map(feature => {
                const coords = getCoordinatesFromGeometry(feature.geometry);
                if (coords.length === 0) return null;

                const lngs = coords.map(coord => coord[0]);
                const lats = coords.map(coord => coord[1]);

                return {
                    feature: feature,
                    minLng: Math.min(...lngs),
                    maxLng: Math.max(...lngs),
                    minLat: Math.min(...lats),
                    maxLat: Math.max(...lats)
                };
            }).filter(bounds => bounds !== null) : [];

            // 性能优化:创建空间哈希表,快速定位可能包含点的国家
            const spatialHash = new Map();
            const hashSize = 10; // 将地图分为10x10的网格

            countryBounds.forEach(bounds => {
                const minHashLng = Math.floor((bounds.minLng + 180) / 360 * hashSize);
                const maxHashLng = Math.floor((bounds.maxLng + 180) / 360 * hashSize);
                const minHashLat = Math.floor((bounds.minLat + 90) / 180 * hashSize);
                const maxHashLat = Math.floor((bounds.maxLat + 90) / 180 * hashSize);

                for (let lng = minHashLng; lng <= maxHashLng; lng++) {
                    for (let lat = minHashLat; lat <= maxHashLat; lat++) {
                        const key = `${lng},${lat}`;
                        if (!spatialHash.has(key)) {
                            spatialHash.set(key, []);
                        }
                        spatialHash.get(key).push(bounds);
                    }
                }
            });

            // 直接处理所有格点
            const pointsInCountries = points.filter(point => {
                if (!worldMapData) return false;

                // 使用空间哈希表快速定位可能包含点的国家
                const hashLng = Math.floor((point.lng + 180) / 360 * hashSize);
                const hashLat = Math.floor((point.lat + 90) / 180 * hashSize);
                const key = `${hashLng},${hashLat}`;
                const candidateBounds = spatialHash.get(key) || [];

                // 只检查空间哈希表中的候选国家
                for (const bounds of candidateBounds) {
                    if (point.lng >= bounds.minLng && point.lng <= bounds.maxLng &&
                        point.lat >= bounds.minLat && point.lat <= bounds.maxLat) {
                        // 边界框内,进行精确检查
                        const result = point.isInsideFeature(bounds.feature);
                        if (result) {
                            return true; // 找到一个匹配就返回true
                        }
                    }
                }
                return false;
            });

            // 绘制所有匹配的格点
            pointsInCountries.forEach(point => point.draw(ctx));
            console.log(`总共生成了 ${points.length} 个格点,其中 ${pointsInCountries.length} 个格点在国家区域内`);
        }

        // ==================== 数据加载和管理 ====================
        async function loadGeoJSON() {
            try {
                // 加载数据文件
                if (!worldMapData) {
                    console.log('正在加载世界地图数据...');
                    const response = await fetch('./world-map.json');
                    if (!response.ok) {
                        throw new Error(`HTTP error! status: ${response.status}`);
                    }
                    worldMapData = filterOutAntarctica(await response.json());
                    console.log('世界地图数据加载完成,包含', worldMapData.features.length, '个国家/地区');
                }

                // 清除现有图层
                clearLayers();

                // 始终创建轮廓地图
                createOutlineMapLayer();

                createGridMapLayer();

                // 适应边界
                fitBoundsToCurrentLayer();

                console.log('世界地图 GeoJSON 加载成功');
            } catch (error) {
                console.error('加载 GeoJSON 时出错:', error);
                alert('加载世界地图数据时出错: ' + error.message);
            }
        }

        function clearLayers() {
            if (geoJSONLayer) {
                map.removeLayer(geoJSONLayer);
                geoJSONLayer = null;
            }
            if (gridMapLayer) {
                // 移除Canvas元素
                if (gridMapLayer._canvas) {
                    const canvas = gridMapLayer._canvas;
                    if (canvas.parentNode) {
                        canvas.parentNode.removeChild(canvas);
                    }
                }

                // 移除事件监听
                map.off('viewreset', gridMapLayer._draw);
                map.off('zoom', gridMapLayer._draw);
                map.off('move', gridMapLayer._draw);
                map.off('resize', gridMapLayer._draw);

                map.removeLayer(gridMapLayer);
                gridMapLayer = null;
            }
        }

        function createOutlineMapLayer() {
            console.log(`轮廓地图过滤后包含 ${worldMapData.features.length} 个国家/地区`);

            geoJSONLayer = L.geoJSON(worldMapData, {
                style: styleFeature
            });
            geoJSONLayer.addTo(map);
            console.log('轮廓地图加载完成(已排除南极洲)');
        }

        function createGridMapLayer() {
            console.log('正在生成格点地图(Canvas渲染),请稍候...');

            // 使用setTimeout让UI有时间更新
            setTimeout(() => {
                // const gridData = generateGridMapData(worldMapData);

                // 创建Canvas图层
                gridMapLayer = L.layerGroup();

                // 创建Canvas元素
                const canvas = document.createElement('canvas');
                const ctx = canvas.getContext('2d');

                // 设置Canvas样式
                canvas.style.position = 'absolute';
                canvas.style.top = '0';
                canvas.style.left = '0';
                canvas.style.pointerEvents = 'none';
                canvas.style.zIndex = '1000';

                // 绘制函数
                function drawGridPoints() {
                    const bounds = map.getSize();
                    console.log(bounds);
                    canvas.width = bounds.x;
                    canvas.height = bounds.y;

                    // 清除画布
                    ctx.clearRect(0, 0, canvas.width, canvas.height);

                    const zoom = map.getZoom();
                    if (mapZoomPoints[zoom]) {
                        ctx.drawImage(mapZoomPoints[zoom], 0, 0);
                    } else {
                        const points = [];
                        const step = 8;
                        // 创建格点图案
                        for (let i = 0; i < canvas.width; i += step) {
                            for (let j = 0; j < canvas.height; j += step) {
                                // 将像素位置转换为经纬度
                                const pixelPoint = L.point(i + step / 2, j + step / 2);
                                const latLng = map.containerPointToLatLng(pixelPoint);

                                points.push(new Point(i, j, latLng.lat, latLng.lng));
                            }
                        }

                        mapZoomPoints[zoom] = createPointCanvas(canvas.width, canvas.height, points);
                        ctx.drawImage(mapZoomPoints[zoom], 0, 0);
                    }
                }

                // 创建Canvas图层
                const canvasLayer = L.layerGroup();
                canvasLayer._canvas = canvas;
                canvasLayer._draw = drawGridPoints;

                // 添加到地图
                map.getPanes().overlayPane.appendChild(canvas);

                // 监听地图事件
                map.on('viewreset', drawGridPoints);
                map.on('zoom', drawGridPoints);
                map.on('move', drawGridPoints);
                map.on('resize', drawGridPoints);

                // 初始绘制
                drawGridPoints();

                gridMapLayer = canvasLayer;
                gridMapLayer.addTo(map);
            }, 100);
        }



        function fitBounds() {
            fitBoundsToCurrentLayer();
        }

        function fitBoundsToCurrentLayer() {
            // 优先使用轮廓地图的边界,因为它覆盖全球
            if (geoJSONLayer && geoJSONLayer.getBounds().isValid()) {
                map.fitBounds(geoJSONLayer.getBounds());
            }
        }


        // 页面加载完成后自动加载世界地图数据
        window.addEventListener('load', function () {
            console.log('页面加载完成,正在加载世界地图...');
            loadGeoJSON();
        });
    </script>
</body>

</html>
相关推荐
月亮慢慢圆2 小时前
Page Visibility API
前端
高级测试工程师欧阳2 小时前
CSS 属性概述
前端·css
wolfking2612 小时前
elpis里程碑四:动态组件库建设
前端
昔人'2 小时前
纯`css`轻松防止滚动穿透
前端·css
TangAcrab2 小时前
记一次 electron 添加 检测 终端编码,解决终端打印中文乱码问题
前端·javascript·electron
小桥风满袖2 小时前
极简三分钟ES6 - ES7新增
前端·javascript
Asort2 小时前
JavaScript入门:从零开始理解其在前端开发中的核心作用
前端·javascript
阿笑带你学前端2 小时前
Flutter应用架构设计:基于Riverpod的状态管理最佳实践
前端·flutter
江城开朗的豌豆2 小时前
前端数据流之争:React与Vue的"内心戏"大揭秘
前端·javascript·react.js