使用MapLibre实现多条线平滑拖拽
!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleMapLibre 多线平滑曲线编辑器/title !-- 引入 MapLibre GL JS 5.24 核心库 -- script srchttps://unpkg.com/maplibre-gl5.24.0/dist/maplibre-gl.js/script !-- 引入 MapLibre GL JS 样式表 -- link hrefhttps://unpkg.com/maplibre-gl5.24.0/dist/maplibre-gl.css relstylesheet / style /* 全局样式重置 */ * { margin: 0; padding: 0; box-sizing: border-box; } /* 页面全屏禁止滚动 */ body, html { width: 100%; height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; } /* 地图容器占满整个视口 */ #map { width: 100%; height: 100%; } /* 左上角信息面板 */ .info-panel { position: absolute; top: 12px; left: 12px; background: rgba(255, 255, 255, 0.95); padding: 16px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10; /* 确保面板浮在地图上方 */ max-width: 320px; backdrop-filter: blur(4px); /* 毛玻璃效果 */ } .info-panel h3 { font-size: 16px; margin-bottom: 10px; color: #1a1a1a; display: flex; align-items: center; gap: 6px; } .info-panel p { font-size: 13px; color: #555; line-height: 1.6; margin: 4px 0; } /* 小圆点指示器样式 */ .info-panel .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; } .dot-blue { background: #2196f3; /* 蓝色控制点 */ } .dot-red { background: #f44336; /* 红色曲线 */ } /* 底部输出面板拖拽后显示坐标 */ .output-panel { position: absolute; bottom: 12px; left: 12px; right: 12px; background: rgba(0, 0, 0, 0.85); color: #00ff88; /* 绿色等宽字体类似终端 */ padding: 12px 16px; border-radius: 8px; font-family: Consolas, Monaco, monospace; font-size: 12px; max-height: 200px; overflow-y: auto; /* 内容过多时可滚动 */ z-index: 10; display: none; /* 默认隐藏有坐标时显示 */ } .output-panel.visible { display: block; } .output-panel .label { color: #888; margin-bottom: 4px; } .output-panel pre { margin: 0; white-space: pre-wrap; /* 自动换行 */ word-break: break-all; /* 长单词换行 */ } /style /head body !-- 地图容器 -- div idmap/div !-- 左上角操作说明面板 -- !-- div classinfo-panel h3️ 多线平滑曲线编辑器/h3 pspan classdot dot-blue/span悬停/拖拽蓝色圆点调整曲线/p pspan classdot dot-red/span悬停红色曲线显示控制点/p p✅ 拖拽结束后自动输出最新坐标/p /div -- !-- 底部坐标输出面板初始隐藏 -- div classoutput-panel idoutputPanel div classlabel 最新坐标集合拖拽完成后自动更新/div pre idoutputContent/pre /div script // // 1. 初始化地图 // const map new maplibregl.Map({ container: map, // 地图容器 DOM id style: https://demotiles.maplibre.org/style.json, // 底图样式 URL center: [116.55, 39.87], // 初始中心点 [经度, 纬度] zoom: 9, // 初始缩放级别 antialias: true // 开启抗锯齿线条更平滑 }); // // 2. 多线数据与状态管理 // /** * lines 数组存储所有可编辑的曲线 * 每条线包含 * - id: 唯一标识 * - controlPoints: 控制点坐标数组 [[lng, lat], ...] * - isDragging: 是否正在被拖拽 * - dragIndex: 当前被拖拽的控制点索引 * - isHoveringPoint: 鼠标是否悬停在某个控制点上 * - isHoveringCurve: 鼠标是否悬停在该线的曲线上 */ let lines [ { id: line-1, controlPoints: [ [116.20, 39.950], [116.23, 39.927], [116.26, 39.907], [116.29, 39.891], [116.32, 39.879], [116.35, 39.820], [116.38, 39.840], [116.41, 39.858], [116.44, 39.874], [116.47, 39.887], [116.50, 39.900], [116.53, 39.877], [116.56, 39.857], [116.59, 39.840], [116.62, 39.826], [116.65, 39.780], [116.68, 39.800], [116.71, 39.820], [116.74, 39.842], [116.77, 39.862], [116.80, 39.880] ], isDragging: false, dragIndex: -1, isHoveringPoint: false, isHoveringCurve: false }, { id: line-2, controlPoints: [ [116.3, 39.85], [116.45, 39.75], [116.6, 39.85], [116.75, 39.75], [116.9, 39.85] ], isDragging: false, dragIndex: -1, isHoveringPoint: false, isHoveringCurve: false } ]; // 全局状态变量 let activeLineId null; // 当前正在拖拽的线 id let hoveredPointLineId null; // 鼠标悬停的控制点所属线 id let hoveredCurveLineId null; // 鼠标悬停的曲线所属线 id const CURVE_RESOLUTION 80; // 曲线插值分辨率每段控制点之间生成多少个点 // // 3. Catmull-Rom 样条插值函数 // /** * 生成平滑曲线坐标 * 使用 Catmull-Rom 样条插值保证曲线通过所有控制点且切线连续 * * param {Array} points - 控制点数组 [[lng, lat], ...] * param {Number} segmentsPerCurve - 每段控制点之间的插值点数 * returns {Array} 高密度平滑曲线坐标数组 */ function generateSmoothCurve(points, segmentsPerCurve 60) { // 边界情况少于2个点直接返回 if (points.length 2) return points.map(p [...p]); // 只有2个点时退化为线性插值 if (points.length 2) { const res []; for (let i 0; i segmentsPerCurve; i) { const t i / segmentsPerCurve; res.push([ points[0][0] (points[1][0] - points[0][0]) * t, points[0][1] (points[1][1] - points[0][1]) * t ]); } return res; } const result []; const n points.length; // 遍历每对相邻控制点生成插值 for (let i 0; i n - 1; i) { // 取当前段及前后各一个点边界回退共4个点用于计算 const p0 points[Math.max(0, i - 1)]; // 前一个点边界用自身 const p1 points[i]; // 当前段起点 const p2 points[i 1]; // 当前段终点 const p3 points[Math.min(n - 1, i 2)]; // 后一个点边界用自身 const step 1 / segmentsPerCurve; // 生成当前段的插值点t 从 0 到 1 for (let t 0; t 1 - step / 2; t step) { const t2 t * t; const t3 t2 * t; // Catmull-Rom 公式分别计算经度和纬度 const lng 0.5 * ( 2 * p1[0] (-p0[0] p2[0]) * t (2 * p0[0] - 5 * p1[0] 4 * p2[0] - p3[0]) * t2 (-p0[0] 3 * p1[0] - 3 * p2[0] p3[0]) * t3 ); const lat 0.5 * ( 2 * p1[1] (-p0[1] p2[1]) * t (2 * p0[1] - 5 * p1[1] 4 * p2[1] - p3[1]) * t2 (-p0[1] 3 * p1[1] - 3 * p2[1] p3[1]) * t3 ); result.push([lng, lat]); } } // 确保最后一个控制点被包含 result.push([...points[n - 1]]); return result; } /** * 根据线 id 查找对应的线对象 * param {String} lineId - 线的唯一标识 * returns {Object|null} 线对象或 null */ function getLine(lineId) { return lines.find(l l.id lineId); } /** * 根据全局状态更新鼠标光标样式 * 优先级grabbing(拖拽中) pointer(悬停控制点) copy(悬停曲线) 默认 */ function updateCursor() { const anyDragging lines.some(l l.isDragging); const anyHoverPoint lines.some(l l.isHoveringPoint); const anyHoverCurve lines.some(l l.isHoveringCurve); if (anyDragging) { map.getCanvas().style.cursor grabbing; } else if (anyHoverPoint) { map.getCanvas().style.cursor pointer; } else if (anyHoverCurve) { map.getCanvas().style.cursor copy; } else { map.getCanvas().style.cursor ; } } // // 4. 输出最新坐标拖拽完成后调用 // /** * 收集所有线的最新控制点坐标输出到控制台和页面面板 * returns {Array} 所有线的坐标集合 */ function outputLatestCoordinates() { // 格式化数据保留6位小数 const result lines.map(line ({ id: line.id, controlPoints: line.controlPoints.map(p [ parseFloat(p[0].toFixed(6)), parseFloat(p[1].toFixed(6)) ]) })); // 控制台输出便于开发者调试 console.log( 最新坐标集合 ); console.log(JSON.stringify(result, null, 2)); // 页面底部面板输出 const outputPanel document.getElementById(outputPanel); const outputContent document.getElementById(outputContent); outputContent.textContent JSON.stringify(result, null, 2); outputPanel.classList.add(visible); // 显示面板 return result; } // // 5. 图层更新核心渲染逻辑 // /** * 更新地图上的曲线和控制点图层 * 根据每条线的交互状态决定控制点是否显示 */ function updateSources() { // ---- 5.1 生成所有线的平滑曲线数据 ---- const curveFeatures lines.map(line ({ type: Feature, properties: { lineId: line.id }, // 标记所属线用于事件识别 geometry: { type: LineString, coordinates: generateSmoothCurve(line.controlPoints, CURVE_RESOLUTION) } })); // 更新曲线数据源 map.getSource(curves).setData({ type: FeatureCollection, features: curveFeatures }); // ---- 5.2 收集需要显示的控制点 ---- // 控制点仅在以下情况显示正在拖拽 / 悬停曲线 / 悬停控制点 const controlFeatures []; lines.forEach(line { const showPoints line.isDragging || line.isHoveringCurve || line.isHoveringPoint; if (showPoints) { line.controlPoints.forEach((p, i) { controlFeatures.push({ type: Feature, properties: { lineId: line.id, index: i }, // 标记所属线和索引 geometry: { type: Point, coordinates: p } }); }); } }); // 更新控制点数据源 map.getSource(controls).setData({ type: FeatureCollection, features: controlFeatures }); // 同步更新光标 updateCursor(); } // // 6. 初始化地图图层地图加载完成后执行 // map.on(load, () { // ---- 6.1 创建曲线数据源和图层 ---- map.addSource(curves, { type: geojson, data: { type: FeatureCollection, features: [] } // 初始空数据 }); map.addLayer({ id: curve-lines, // 图层唯一 id type: line, // 线图层 source: curves, // 绑定到 curves 数据源 layout: { line-join: round, // 连接处圆角 line-cap: round // 端点圆角 }, paint: { line-color: #f44336, // 红色曲线 line-width: 4, line-opacity: 0.9 } }); // ---- 6.2 创建控制点数据源和图层 ---- map.addSource(controls, { type: geojson, data: { type: FeatureCollection, features: [] } // 初始空数据 }); map.addLayer({ id: control-points, // 图层唯一 id type: circle, // 圆点图层 source: controls, // 绑定到 controls 数据源 paint: { circle-radius: 5, // 圆点半径小点避免遮挡 circle-color: #2196f3, // 蓝色 circle-stroke-color: #ffffff, // 白色描边 circle-stroke-width: 3, circle-opacity: 0.95 } }); // 初始渲染 updateSources(); // 初始输出坐标 outputLatestCoordinates(); }); // // 7. 交互事件处理 // // ---- 7.1 鼠标悬停控制点 ---- // 当鼠标移入某个控制点时显示该线所有控制点 map.on(mouseenter, control-points, (e) { // 从 feature 属性中获取所属线 id const lineId e.features[0].properties.lineId; const line getLine(lineId); // 如果线不存在或正在拖拽中不处理 if (!line || line.isDragging) return; hoveredPointLineId lineId; // 记录悬停的控制点所属线 line.isHoveringPoint true; // 标记该线控制点被悬停 activeLineId lineId; // 设为当前活跃线 updateSources(); // 重绘显示控制点 }); // 鼠标移出控制点 map.on(mouseleave, control-points, (e) { if (hoveredPointLineId) { const line getLine(hoveredPointLineId); // 只有非拖拽状态才清除悬停标记 if (line !line.isDragging) { line.isHoveringPoint false; } hoveredPointLineId null; } // 如果没有线正在拖拽清除活跃线 if (!lines.some(l l.isDragging)) { activeLineId null; } updateSources(); // 重绘可能隐藏控制点 }); // ---- 7.2 鼠标悬停曲线 ---- // 当鼠标移入某条曲线时显示该线所有控制点 map.on(mouseenter, curve-lines, (e) { const lineId e.features[0].properties.lineId; const line getLine(lineId); if (!line || line.isDragging) return; hoveredCurveLineId lineId; line.isHoveringCurve true; updateSources(); }); // 鼠标移出曲线 map.on(mouseleave, curve-lines, (e) { if (hoveredCurveLineId) { const line getLine(hoveredCurveLineId); if (line !line.isDragging) { line.isHoveringCurve false; } hoveredCurveLineId null; } updateSources(); }); // ---- 7.3 按下控制点开始拖拽 ---- map.on(mousedown, control-points, (e) { e.preventDefault(); // 阻止地图默认拖拽行为 const lineId e.features[0].properties.lineId; const line getLine(lineId); if (!line) return; // 设置拖拽状态 line.isDragging true; line.dragIndex e.features[0].properties.index; // 记录被拖拽的控制点索引 activeLineId lineId; updateSources(); }); // ---- 7.4 拖拽中实时更新坐标 ---- map.on(mousemove, (e) { // 没有活跃线则不处理 if (!activeLineId) return; const line getLine(activeLineId); // 检查是否处于有效拖拽状态 if (!line || !line.isDragging || line.dragIndex 0) return; // 更新被拖拽控制点的坐标 line.controlPoints[line.dragIndex] [e.lngLat.lng, e.lngLat.lat]; updateSources(); // 实时重绘曲线和控制点 }); // // 8. 拖拽结束释放状态并输出坐标 // /** * 拖拽结束处理函数 * 重置拖拽状态并输出最新坐标 */ const endDrag () { // 没有活跃线则不处理 if (!activeLineId) return; const line getLine(activeLineId); if (!line || !line.isDragging) return; // 重置该线的拖拽状态 line.isDragging false; line.dragIndex -1; line.isHoveringPoint false; activeLineId null; // 清除全局活跃线 updateSources(); // 重绘 // 拖拽完成后输出所有线的最新坐标 outputLatestCoordinates(); }; // 地图容器内释放鼠标 map.on(mouseup, endDrag); // 文档级别释放鼠标防止鼠标移出地图容器后状态卡住 document.addEventListener(mouseup, endDrag); // ---- 双击控制点阻止地图默认双击缩放行为 ---- map.on(dblclick, (e) { const features map.queryRenderedFeatures(e.point, { layers: [control-points] }); if (features.length 0) { e.preventDefault(); // 阻止地图缩放 } }); /script /body /html