第八章 Leaflet.Draw 实战:实现线段绘制与长度计算功能

在 Web 地图开发中,线段绘制是一个常见需求,无论是路径规划、距离测量还是区域划分都离不开这一功能。本文将基于 Leaflet 和 Leaflet.Draw 插件,详细介绍如何实现线段的绘制、编辑、删除以及长度计算功能,采用纯原生方法避免常见的兼容性问题。

技术选型与优势

核心技术栈

  • Leaflet:轻量级开源地图库,体积小(仅 38KB)且功能完善,适合各种 Web 地图场景
  • Leaflet.Draw:Leaflet 的绘图插件,提供直观的绘图界面和编辑功能
  • 高德地图:作为底图图层,提供精准的国内地图数据

方案优势

  • 纯前端实现,无需后端支持即可完成核心功能
  • 采用 Haversine 公式计算距离,不依赖第三方方法,避免getLength()兼容性问题
  • 功能聚焦,专注于线段绘制的核心需求:绘制、编辑、删除和长度计算
  • 适配各种现代浏览器,兼容性好

实现步骤详解

1. 环境搭建与基础配置

首先需要引入必要的 CSS 和 JS 文件,包括 Leaflet 核心库和 Leaflet.Draw 插件:

<!-- Leaflet核心样式 -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet.Draw样式 -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />

<!-- Leaflet核心脚本 -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet.Draw脚本 -->
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>

创建地图容器并设置基础样式:

<div class="container">
    <h1>线段绘制工具</h1>
    <div id="info">状态: 就绪</div>
    <div id="map"></div>
</div>

<style>
    #map {
        width: 100%;
        height: 600px;
        border: 1px solid #ccc;
    }
    .container {
        max-width: 1200px;
        margin: 20px auto;
        padding: 0 20px;
    }
    #info {
        color: #666;
        margin: 10px 0;
    }
    .drawing-mode {
        color: #e53e3e;
        font-weight: bold;
    }
</style>

2. 地图初始化与图层配置

初始化地图实例并添加高德地图作为底图:

// 初始化地图,中心点设为北京
const map = L.map('map').setView([39.9042, 116.4074], 13);

// 添加高德地图图层
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
    subdomains: ['1', '2', '3', '4']
}).addTo(map);

// 创建图层组存储所有线段
const linesLayer = new L.FeatureGroup();
map.addLayer(linesLayer);

3. 绘图控件配置

配置 Leaflet.Draw 控件,仅启用线段绘制功能:

// 配置绘图控件
const drawControl = new L.Control.Draw({
    position: 'topleft',
    draw: {
        // 仅启用线段绘制
        polyline: {
            allowIntersection: false, // 不允许线段交叉
            showLength: true, // 显示长度提示
            shapeOptions: {
                color: '#4285f4',
                weight: 4
            }
        },
        // 禁用其他所有图形类型
        marker: false,
        polygon: false,
        rectangle: false,
        circle: false,
        circlemarker: false
    },
    // 编辑配置
    edit: {
        featureGroup: linesLayer, // 指定可编辑的图层组
        remove: true, // 允许删除线段
        edit: true    // 允许编辑线段
    }
});
map.addControl(drawControl);

4. 核心功能:线段长度计算

采用 Haversine 公式实现距离计算,避免使用可能存在兼容性问题的getLength()方法:

// 地球半径(米)
const EARTH_RADIUS = 6371000;

// 将角度转换为弧度
function toRadians(degrees) {
    return degrees * Math.PI / 180;
}

// 使用Haversine公式计算两点间距离(米)
function calculateDistance(lat1, lon1, lat2, lon2) {
    const dLat = toRadians(lat2 - lat1);
    const dLon = toRadians(lon2 - lon1);

    const a =
        Math.sin(dLat / 2) * Math.sin(dLat / 2) +
        Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
        Math.sin(dLon / 2) * Math.sin(dLon / 2);
        
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

    return EARTH_RADIUS * c;
}

// 计算线段总长度
function calculateLineLength(layer) {
    try {
        // 从线段获取所有经纬度点
        if (layer.getLatLngs) {
            const points = layer.getLatLngs();
            if (points && points.length > 1) {
                let totalLength = 0;

                // 计算每两个相邻点之间的距离并累加
                for (let i = 0; i < points.length - 1; i++) {
                    const p1 = points[i];
                    const p2 = points[i + 1];
                    totalLength += calculateDistance(p1.lat, p1.lng, p2.lat, p2.lng);
                }

                return totalLength;
            }
        }

        return 0;
    } catch (error) {
        console.error('计算长度时出错:', error);
        return 0;
    }
}

5. 事件处理与状态管理

实现绘图过程中的各种事件处理,包括绘制开始、绘制完成、编辑和删除等:

// 跟踪绘图状态
let isDrawing = false;
let currentDrawHandler = null;

// 更新状态信息
function updateInfo(text, isDrawingState = false) {
    const infoEl = document.getElementById('info');
    infoEl.textContent = `状态: ${text}`;
    infoEl.className = isDrawingState ? 'drawing-mode' : '';
}

// 绘图开始事件
map.on(L.Draw.Event.DRAWSTART, function (e) {
    isDrawing = true;
    currentDrawHandler = e.handler;
    updateInfo('正在绘制线段 - 点击添加节点,按最后一个节点完成', true);
    map.off('dblclick'); // 绘图时禁用双击缩放
});

// 绘图完成事件
map.on(L.Draw.Event.CREATED, function (e) {
    const layer = e.layer;
    const lengthMeters = calculateLineLength(layer);
    const lengthKm = (lengthMeters / 1000).toFixed(2);

    // 添加弹窗显示长度信息
    layer.bindPopup(`长度: ${lengthKm} 千米 (${lengthMeters.toFixed(0)} 米)`);
    linesLayer.addLayer(layer);
    updateInfo(`线段绘制完成 (长度: ${lengthKm} 千米)`);

    // 重置状态
    isDrawing = false;
    currentDrawHandler = null;
    map.on('dblclick', function (e) {
        map.zoomIn({ center: e.latlng });
    });
});

// 编辑完成事件
map.on(L.Draw.Event.EDITED, function (e) {
    e.layers.eachLayer(function (layer) {
        const lengthMeters = calculateLineLength(layer);
        const lengthKm = (lengthMeters / 1000).toFixed(2);
        layer.bindPopup(`长度: ${lengthKm} 千米 (${lengthMeters.toFixed(0)} 米)`);
    });
    updateInfo(`已编辑 ${e.layers.getLayers().length} 条线段`);
});

// 删除事件
map.on(L.Draw.Event.DELETED, function (e) {
    updateInfo(`已删除 ${e.layers.getLayers().length} 条线段`);
});

6. 快捷键支持与按钮修复

添加 Enter 键快捷操作,并修复默认完成按钮可能存在的问题:

// 键盘Enter键结束绘制
document.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' && isDrawing && currentDrawHandler) {
        const pointsCount = currentDrawHandler._path?._parts[0]?.length || 0;
        if (pointsCount >= 2) { // 确保至少有2个点
            currentDrawHandler.completeShape();
            isDrawing = false;
        }
    }
});

// 修复默认完成按钮
setTimeout(() => {
    const finishBtn = document.querySelector('.leaflet-draw-actions a:first-child');
    if (finishBtn) {
        finishBtn.addEventListener('click', function (e) {
            e.stopPropagation();
            if (isDrawing && currentDrawHandler) {
                const pointsCount = currentDrawHandler._path?._parts[0]?.length || 0;
                if (pointsCount >= 2) {
                    currentDrawHandler.completeShape();
                    isDrawing = false;
                }
            }
        });
    }
}, 500);

完整代码实现

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>线段绘制工具</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
    <link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />

    <style>
        #map {
            width: 100%;
            height: 600px;
            border: 1px solid #ccc;
        }

        .container {
            max-width: 1200px;
            margin: 20px auto;
            padding: 0 20px;
        }

        #info {
            color: #666;
            margin: 10px 0;
        }

        .drawing-mode {
            color: #e53e3e;
            font-weight: bold;
        }
    </style>
</head>

<body>
    <div class="container">
        <h1>线段绘制工具</h1>
        <div id="info">状态: 就绪</div>
        <div id="map"></div>
    </div>

    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
    <script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>

    <script>
        // 初始化地图
        const map = L.map('map').setView([39.9042, 116.4074], 13);

        // 添加地图图层
        L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
            subdomains: ['1', '2', '3', '4']
        }).addTo(map);

        // 存储线段的图层组
        const linesLayer = new L.FeatureGroup();
        map.addLayer(linesLayer);

        // 跟踪绘图状态
        let isDrawing = false;
        let currentDrawHandler = null;

        // 更新状态信息
        function updateInfo(text, isDrawingState = false) {
            const infoEl = document.getElementById('info');
            infoEl.textContent = `状态: ${text}`;
            infoEl.className = isDrawingState ? 'drawing-mode' : '';
        }

        // 地球半径(米)
        const EARTH_RADIUS = 6371000;

        // 将角度转换为弧度
        function toRadians(degrees) {
            return degrees * Math.PI / 180;
        }

        // 使用Haversine公式计算两点间的实际距离(米)
        function calculateDistance(lat1, lon1, lat2, lon2) {
            const dLat = toRadians(lat2 - lat1);
            const dLon = toRadians(lon2 - lon1);

            const a =
                Math.sin(dLat / 2) * Math.sin(dLat / 2) +
                Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
                Math.sin(dLon / 2) * Math.sin(dLon / 2);

            const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

            return EARTH_RADIUS * c;
        }

        // 计算线段总长度
        function calculateLineLength(layer) {
            try {
                // 从线段获取所有经纬度点
                if (layer.getLatLngs) {
                    const points = layer.getLatLngs();
                    if (points && points.length > 1) {
                        let totalLength = 0;

                        // 计算每两个相邻点之间的距离并累加
                        for (let i = 0; i < points.length - 1; i++) {
                            const p1 = points[i];
                            const p2 = points[i + 1];
                            totalLength += calculateDistance(p1.lat, p1.lng, p2.lat, p2.lng);
                        }

                        return totalLength;
                    }
                }

                return 0;
            } catch (error) {
                console.error('计算长度时出错:', error);
                return 0;
            }
        }

        // 配置绘图控件
        const drawControl = new L.Control.Draw({
            position: 'topleft',
            draw: {
                polyline: {
                    allowIntersection: false,
                    showLength: true,
                    shapeOptions: {
                        color: '#4285f4',
                        weight: 4
                    }
                },
                marker: false,
                polygon: false,
                rectangle: false,
                circle: false,
                circlemarker: false,
            },
            edit: {
                featureGroup: linesLayer,
                remove: true,
                edit: true
            }
        });
        map.addControl(drawControl);

        // 绘图开始事件
        map.on(L.Draw.Event.DRAWSTART, function (e) {
            isDrawing = true;
            currentDrawHandler = e.handler;
            updateInfo('正在绘制线段 - 点击添加节点,按最后一个节点完成', true);
            map.off('dblclick');
        });

        // 绘图完成事件
        map.on(L.Draw.Event.CREATED, function (e) {
            const layer = e.layer;
            const lengthMeters = calculateLineLength(layer);
            const lengthKm = (lengthMeters / 1000).toFixed(2);

            layer.bindPopup(`长度: ${lengthKm} 千米 (${lengthMeters.toFixed(0)} 米)`);
            linesLayer.addLayer(layer);
            updateInfo(`线段绘制完成 (长度: ${lengthKm} 千米)`);

            isDrawing = false;
            currentDrawHandler = null;
            map.on('dblclick', function (e) {
                map.zoomIn({ center: e.latlng });
            });
        });

        // 编辑完成事件
        map.on(L.Draw.Event.EDITED, function (e) {
            e.layers.eachLayer(function (layer) {
                const lengthMeters = calculateLineLength(layer);
                const lengthKm = (lengthMeters / 1000).toFixed(2);
                layer.bindPopup(`长度: ${lengthKm} 千米 (${lengthMeters.toFixed(0)} 米)`);
            });
            updateInfo(`已编辑 ${e.layers.getLayers().length} 条线段`);
        });

        // 键盘Enter键结束绘制
        document.addEventListener('keydown', function (e) {
            if (e.key === 'Enter' && isDrawing && currentDrawHandler) {
                const pointsCount = currentDrawHandler._path?._parts[0]?.length || 0;
                if (pointsCount >= 2) {
                    currentDrawHandler.completeShape();
                    isDrawing = false;
                }
            }
        });

        // 删除事件
        map.on(L.Draw.Event.DELETED, function (e) {
            updateInfo(`已删除 ${e.layers.getLayers().length} 条线段`);
        });

        // 修复默认完成按钮
        setTimeout(() => {
            const finishBtn = document.querySelector('.leaflet-draw-actions a:first-child');
            if (finishBtn) {
                finishBtn.addEventListener('click', function (e) {
                    e.stopPropagation();
                    if (isDrawing && currentDrawHandler) {
                        const pointsCount = currentDrawHandler._path?._parts[0]?.length || 0;
                        if (pointsCount >= 2) {
                            currentDrawHandler.completeShape();
                            isDrawing = false;
                        }
                    }
                });
            }
        }, 500);
    </script>
</body>

</html>

功能使用说明

  1. 绘制线段

    • 点击左上角工具栏中的 "绘制线段" 按钮(折线图标)
    • 在地图上点击添加线段节点(至少需要 2 个节点)
    • 按 Enter 键或点击工具栏中的 "完成" 按钮结束绘制
  2. 编辑线段

    • 点击工具栏中的 "编辑" 按钮(铅笔图标)
    • 点击线段可拖动节点调整形状
    • 调整完成后点击 "保存" 按钮
  3. 删除线段

    • 点击工具栏中的 "删除" 按钮(垃圾桶图标)
    • 点击要删除的线段
    • 完成后点击 "保存" 按钮
  4. 查看长度

    • 绘制完成后点击线段可查看长度信息(千米和米两种单位)
    • 编辑线段后长度会自动更新

技术亮点与最佳实践

  1. 兼容性处理

    • 采用 Haversine 公式手动计算距离,避免使用layer.getLength()方法带来的兼容性问题
    • 对 DOM 元素访问添加容错处理,确保在各种环境下稳定运行
  2. 用户体验优化

    • 提供直观的状态提示,引导用户完成操作
    • 添加 Enter 键快捷操作,提升绘制效率
    • 绘图时自动禁用双击缩放,避免误操作
  3. 性能考量

    • 使用图层组(FeatureGroup)统一管理线段,便于批量操作
    • 简化 DOM 结构,减少不必要的元素和样式

总结与扩展方向

本文实现了一个功能完整、兼容性好的线段绘制工具,核心功能包括线段的绘制、编辑、删除和长度计算。通过采用原生算法和事件处理,避免了常见的兼容性问题,确保在各种浏览器环境下都能稳定运行。

未来可以从以下几个方向进行扩展:

  • 添加线段样式自定义功能(颜色、线宽等)
  • 实现多条线段的批量操作
  • 增加线段数据的导入导出功能
  • 集成测量单位切换(米、千米、英里等)

宝子们点点关注,内容持续更新

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冒气er

伸出你发财的小手叮咚一下

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值