在 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>
功能使用说明
-
绘制线段:
- 点击左上角工具栏中的 "绘制线段" 按钮(折线图标)
- 在地图上点击添加线段节点(至少需要 2 个节点)
- 按 Enter 键或点击工具栏中的 "完成" 按钮结束绘制
-
编辑线段:
- 点击工具栏中的 "编辑" 按钮(铅笔图标)
- 点击线段可拖动节点调整形状
- 调整完成后点击 "保存" 按钮
-
删除线段:
- 点击工具栏中的 "删除" 按钮(垃圾桶图标)
- 点击要删除的线段
- 完成后点击 "保存" 按钮
-
查看长度:
- 绘制完成后点击线段可查看长度信息(千米和米两种单位)
- 编辑线段后长度会自动更新
技术亮点与最佳实践
-
兼容性处理:
- 采用 Haversine 公式手动计算距离,避免使用
layer.getLength()
方法带来的兼容性问题 - 对 DOM 元素访问添加容错处理,确保在各种环境下稳定运行
- 采用 Haversine 公式手动计算距离,避免使用
-
用户体验优化:
- 提供直观的状态提示,引导用户完成操作
- 添加 Enter 键快捷操作,提升绘制效率
- 绘图时自动禁用双击缩放,避免误操作
-
性能考量:
- 使用图层组(FeatureGroup)统一管理线段,便于批量操作
- 简化 DOM 结构,减少不必要的元素和样式
总结与扩展方向
本文实现了一个功能完整、兼容性好的线段绘制工具,核心功能包括线段的绘制、编辑、删除和长度计算。通过采用原生算法和事件处理,避免了常见的兼容性问题,确保在各种浏览器环境下都能稳定运行。
未来可以从以下几个方向进行扩展:
- 添加线段样式自定义功能(颜色、线宽等)
- 实现多条线段的批量操作
- 增加线段数据的导入导出功能
- 集成测量单位切换(米、千米、英里等)
宝子们点点关注,内容持续更新