lingbot-map 是我最近一段时间整理得比较勤的小项目,名字是自己随手起的:lingbot 算个代号,map 就是地图那层意思。起因是某次帮一个户外巡检的朋友整理点位,发现大量坐标散落在群聊天记录、Excel 表格、App 收藏夹里,真正要汇总的时候根本找不到哪个是新的、哪个已经失效。于是我把这堆零散信息做成了一个能随时打开、能加标注、能记轨迹、还能离线查看的地图工具。说白了,lingbot-map 是给“位置信息整理”这件事用的个人工具箱,适合户外调研、巡检打卡、探店清单、工地记录、活动布点这类场景。如果你也经常面对一堆坐标和地点,又不想被大而全的商用地图软件绑住手脚,这篇文章应该对你有参考价值。
1. 项目到底做了什么:把散落的位置信息收进一张会成长的图
1.1 为什么会做 lingbot-map
这个项目的出发点特别朴素:我们太容易把“地点”变成一条文本消息或一行 Excel 数据,但地点本质上是空间信息,只有放到地图上才有意义。常见的商用地图 App 虽然能收藏地点,可自定义字段少得可怜,想给一个点打上“已核查”“待复查”“设备编号 A-13”这种标签非常费劲,而且数据导出基本是黑盒,想二次处理也难。
我当时在纸上列了几个必须解决的痛点。第一,点位信息要和坐标强绑定,不能只存一个地址文字,因为地址会变,经纬度不会。第二,要能在野外没信号的时候继续用,巡检场景里经常一进山区就失联,离线地图是硬需求。第三,数据必须能自由导入导出,最好是一种通用格式,能落进自己的数据库,也能随时迁移到别的地图工具。
lingbot-map 就是围绕这三个痛点做的:以经纬度为骨架,以自定义属性为血肉,以 GeoJSON 和离线瓦片为双保险。开发过程中也踩了不少坑,尤其坐标系偏移和浏览器定位精度这两个问题,折腾了我将近一个星期,后面会详细写。
1.2 核心功能与适用场景
当时的规划是先做最小可用版本,再慢慢加功能。最后成型的是一个纯前端为主、带一个轻量同步后端的地图工具,功能分成四块:
- 地图浏览与标记:加载 OpenStreetMap 瓦片,支持点击地图新增点位,也可以拖动已有标记修改位置。
- 空间数据管理:点位的图层分类、颜色图标、属性备注都保存在 GeoJSON 结构里,支持按分类筛选显示。
- 轨迹记录:调用浏览器定位接口记录行走路线,实时显示路径和累计里程。
- 导入导出与同步:能导入 GeoJSON/CSV,能导出 GeoJSON,局域网内还能把数据同步到一台私有服务器上。
适用场景我梳理下来非常宽,包括户外勘察记录水源和营地、物业管理巡查设备点位、活动策划踩点布置舞台位置、甚至个人旅行时标记喜欢的小店。只要是你想“把一批地方整理清楚”的事情,这种轻量地图工具都合适。
1.3 技术栈与选型思路
地图引擎我选了 Leaflet,没有选大而全的 SDK。Leaflet 体积小、API 直观、插件生态成熟,对 GeoJSON 的支持是标准级别的,两三行代码就能把一份坐标集合渲染成地图要素,特别适合中小规模的数据展示。如果换成重型三维引擎,加载时间会拖垮低端手机,对于记录点位这种需求属于过度设计。
前端结构没有上重型框架,只用原生 JavaScript 加模块化管理。原因很简单:项目核心操作是地图交互和本地数据读写,框架带来的组件化收益有限,反而会增加打包体积。数据存储放在了浏览器 IndexedDB,因为 localStorage 有字符串大小限制,存不了几十 MB 的离线瓦片。离线地图这块用 Service Worker 拦截瓦片请求,把网络图片缓存到 Cache API,实测在断网状态下缩放浏览非常流畅。
后端我后来补了一个很小的 Node.js 服务,用 SQLite 存点位数据,主要解决“多台设备之间的数据同步”问题。这个部分属于锦上添花,单人单机使用完全可以不要后端。
2. 数据这套东西是怎么设计的:从 GeoJSON 到离线缓存
2.1 标注数据的结构设计
lingbot-map 的核心数据格式是 GeoJSON 的 FeatureCollection。一个完整的标注点长这样:
{ "type": "FeatureCollection", "features": [ { "type": "Feature", "geometry": { "type": "Point", "coordinates": [116.3975, 39.9087] }, "properties": { "name": "三号检查点", "category": "checkpoint", "status": "active", "remark": "设备正常,下次巡检 2025-09-01", "icon": "flag", "updatedAt": "2025-06-14T10:00:00+08:00", "id": "uuid-v4-xxxx" } } ] }这里有一个非常容易误导新手的细节:GeoJSON 的坐标顺序是[经度, 纬度],也就是先写 longitude 再写 latitude,和习惯上“先说纬度再说经度”的日常口述顺序正好相反。我第一次写反了,结果标记点飞到了另一个半球附近,排查好久才意识到是坐标顺序问题,后来我统一用一个转换函数封装,任何地方不要直接手写坐标数组。
properties 字段是业务数据的自由空间,我设计了几个基础字段,name 是展示名称,category 用于图层分类,status 表示当前状态,remark 放长文本备注,icon 控制显示图标。这种设计的优势在于:geometry 只负责“在哪里”,业务信息全部放进 properties,上游系统导出、下游系统解析都非常干净。
2.2 图层分类与样式管理
点位一多,如果没有分类筛选,地图会变成一团乱麻。我在设计里把 category 作为一二级分类维度,然后用一个查找表统一管理图标和颜色,而不是在渲染时写一堆 if-else。
const CATEGORY_STYLE = { checkpoint: { color: '#1a73e8', icon: 'flag', label: '检查点' }, danger: { color: '#d93025', icon: 'alert', label: '危险区域' }, resource: { color: '#188038', icon: 'water', label: '物资点' }, poi: { color: '#f9ab00', icon: 'star', label: '兴趣点' } };每个 feature 在渲染时通过 category 直接查表拿样式。这样做的实际收益是:后期想改颜色体系,只需要改一个对象,不需要满项目找散落的判断逻辑。筛选功能也简单了,地图上放置一组 checkbox,对应分类的图层用 addLayer / removeLayer 控制显隐。
如果你点位数量能到几千以上,建议用插件做聚合渲染,标记聚合可以避免浏览器卡成幻灯片,这个我后面在踩坑章节里专门讲。
2.3 离线瓦片缓存:在没有信号的地方找回地图
离线地图是这个项目里我最看重的一块。刚开始想用现成的离线地图 App 方案,但那些方案要么要整套绑定某个地图服务商,要么文件格式不开放。后来我选了 Service Worker 加 Cache API,逻辑非常顺:用户在线浏览哪个区域的瓦片,Service Worker 就顺手把图片存进缓存,下次断网时同一区域直接取缓存。
核心实现思路是注册一个 Service Worker,拦截托管在相同域名下的瓦片请求:
self.addEventListener('fetch', (event) => { if (event.request.url.includes('/tiles/')) { event.respondWith( caches.match(event.request).then((cached) => { const networkFetch = fetch(event.request).then((response) => { if (response.ok) { const clone = response.clone(); caches.open('lingbot-tiles-v1').then((cache) => { cache.put(event.request, clone); }); } return response; }).catch(() => cached); return cached || networkFetch; }) ); } });注意这个策略是“缓存优先加网络兜底”,也就是只要有本地缓存就先给本地,没有缓存才请求网络。好处是第二次浏览同一区域速度更快,坏处是瓦片地图如果长期不更新可能会偏旧。对巡检记录场景来说,地图道路建筑变化没那么快,实测下来这个取舍是划算的。
在断网测试的时候我踩了个小坑:iOS 上 Safari 对 Service Worker 的域名要求必须 HTTPS,本地调试只能通过 localhost 才行。解决办法是用内网穿透或者局域网 HTTPS 证书,但是实际使用中也没必要强求手机 Safari,Chrome 和安卓浏览器基本没有这个问题。
3. 三大核心功能逐个落地
3.1 点击地图新增标注
新增标注是整个工具最常用的操作,交互流程是:点击地图任意位置,弹出一个自定义信息窗口,填写名称、分类和备注,确认后把这个 feature 存入数据栈并重新渲染一次地图图层。
这里我有一个体验上的心得:不要用浏览器原生 prompt 弹窗做信息录入,那东西丑且不踏实,字段多一点就白屏。我最后用的是 Leaflet popup 里塞一个简单的 DOM 表单,这样弹出的编辑面板会像气泡一样钉在标记旁边,操作起来很顺手。
map.on('click', async (e) => { const { lat, lng } = e.latlng; const formHtml = `<div class="point-form"> <input id="f-name" placeholder="点位名称" /> <select id="f-category">...</select> <textarea id="f-remark" placeholder="备注"></textarea> <button id="f-save">保存</button> </div>`; const popup = L.popup().setLatLng(e.latlng).setContent(formHtml).openOn(map); document.getElementById('f-save').addEventListener('click', () => { const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { name: document.getElementById('f-name').value, category: document.getElementById('f-category').value, remark: document.getElementById('f-remark').value, updatedAt: new Date().toISOString() } }; addFeature(feature); map.closePopup(); }); });注意这里我保存的 coordinates 故意写成 [lng, lat],和点击事件返回的 latlng 做了一次顺序调换。有几次我改代码时不小心传成 [lat, lng],然后又跑到海上去找标记,这种低级错误可以通过写一个reverseCoord函数集中规避。
3.2 轨迹记录与距离计算
轨迹记录是 lingbot-map 的第二个核心功能。思路是启动一个定时采集器,调用浏览器 Geolocation API 的watchPosition持续获取当前坐标,把坐标追加进数组,然后连成一条 polyline 显示在地图上。
采集坐标的代码不长:
let trackPoints = []; function startTracking() { trackerId = navigator.geolocation.watchPosition( (pos) => { const point = [pos.coords.longitude, pos.coords.latitude]; trackPoints.push(point); updatePolyline(trackPoints); updateDistance(trackPoints); }, handleError, { enableHighAccuracy: true, maximumAge: 1000, timeout: 5000 } ); }距离计算我用的 Haversine 公式,它把地球近似成球体,能保证公里级误差不超过 0.5%,对户外徒步、巡检记录完全够用。写出来大概就是:
function haversineKm(coordA, coordB) { const R = 6371; const [lng1, lat1] = coordA; const [lng2, lat2] = coordB; const rad = Math.PI / 180; const dLat = (lat2 - lat1) * rad; const dLng = (lng2 - lng1) * rad; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); }轨迹记录有个状态机问题特别容易被忽略:开始采集、暂停采集、结束保存必须严格分开。我只做了开始和结束两个按钮,结果中途接电话或者切后台,定位数据被系统断掉后再次唤醒时会产生一条长距离的“瞬移线段”。后来加了个去鬼影逻辑:如果相邻两点距离超过 200 米,就认为发生了跳点,主动断开线段。
3.3 导入导出:把 Excel 里的坐标变成地图上的点
数据导入导出是这类工具的隐藏刚需。很多人手里有一张整理好的 Excel 表格,列名是“名称、经度、纬度、备注”,想直接变成地图上的点。我的解决方案是让系统直接接受 GeoJSON 文件,同时支持一种简单的 CSV 格式。
CSV 的标准格式长这样:
name,longitude,latitude,category,remark 一号点,116.3975,39.9087,checkpoint,正常 二号点,117.0000,40.1000,resource,物资点解析的时候注意 CSV 编码,Excel 导出的文件经常是 GBK 编码,浏览器 fetch 拿到的字符串会乱码,需要在读取文件时用TextDecoder('gbk')做一次解码。这个细节不写在文档里,我顺手写进了代码注释,否则几个月后你自己都会忘记为什么当时要多一段解码逻辑。
导出更简单,把所有 feature 装进一个 FeatureCollection,然后用 Blob 生成下载文件:
function exportGeoJSON(features) { const geojson = { type: 'FeatureCollection', features }; const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/geo+json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'lingbot-export.geojson'; a.click(); URL.revokeObjectURL(url); }这里有个看似微小但很重要的小动作:URL.revokeObjectURL(url)。如果不调这行,Chrome 会在网络面板里显示一个永不释放的 Blob 对象,导出多了内存会悄悄涨。
4. 数据同步:单人工具到多端协作
4.1 最简单的后端同步方案
前端工具做到后面,发现一个很现实的问题:我经常在手机上看点位,在电脑上导入大量数据,两边数据不一致,手动拷贝 GeoJSON 文件实在太原始。所以我又补了一个轻量后端,用 Node.js 加 SQLite 存储服务端数据。
后端接口设计得很朴素,核心是两个:
GET /api/features 拉取全量点位数据 POST /api/sync 上传变更点位数据数据落地直接存 JSON 字符串,数据库表只有 id、data、updated_at。同步采用“全量拉取加增量上传”的方式:客户端维护一个本地更新时间戳,每次同步先拉取服务端在时间戳之后变化的数据,再把本地修改过的点位批量上传。这个方案在大几千点位规模下是完全可用的。
我来回折腾过更复杂的认证、多用户、权限隔离,最后都砍掉了,因为单机工具场景根本不需要。如果未来真的要做多人协作文档,那我会换成成熟的数据库和消息推送方案,而不是在个人项目里硬造轮子。
4.2 增量同步与冲突处理
同步最容易翻车的场景是两个设备同时改了同一个点位,后同步的人覆盖了先同步的人。我采用的处理策略非常务实:每个点位在 properties 里都有一个 updatedAt 字段,同步时如果本地和服务端的点位 ID 相同,就比较更新时间,哪个新用哪个。
function mergeFeature(local, remote) { if (!local) return remote; if (!remote) return local; return new Date(local.properties.updatedAt) >= new Date(remote.properties.updatedAt) ? local : remote; }这种策略叫 last-write-wins,在个人工具和野外巡检场景下体验最好,因为两个设备同时改一个点位的概率极低。真要是撞上了,你至少还能在服务端历史备份里翻出更早的版本。
说到备份,我强烈建议给后端加一个每晚定时备份 SQLite 文件的脚本,不需要花哨,用系统 crontab 把 db 文件复制一份带时间戳的副本就行。个人项目最大的风险不是功能缺少,而是数据丢了什么都没了。
4.3 部署与权限控制
后端我部署在一台家里长期开机的电脑上,挂了个域名,然后用系统自带的反向代理只暴露一个端口。权限控制只做了两件事:一个管理密码、一个 token 校验。
不是因为不想做复杂权限,而是这种工具的定位就是私有小工具,做一个完整的用户系统反而会增加使用摩擦。如果哪天需要多人同时使用,再套一层轻量认证就足够了,架构上不需要提前预支复杂度。我用这个方案跑了三个月,没有出过安全或者数据完整性问题。
5. 踩坑笔记:坐标偏移、性能卡顿、定位误差
5.1 坐标系偏了八百米:GCJ-02 与 WGS-84
这个坑是我在 lingbot-map 开发中最刻骨铭心的一笔。国内大部分地图服务用的是加密坐标系统,俗称火星坐标系,而浏览器 Geolocation API 返回和绝大多数国际地图工具使用的一套国际标准坐标系。这两个坐标系之间的差别是数百米到近千米,不处理的话点位会整体漂移,直接对不上地图底图。
需要区分一个事实:如果底层用的开源地图瓦片是国际标准坐标系的,那你用国际标准坐标数据就天然对齐;但如果叠加的是国内地图商提供的瓦片,用国际坐标就会偏。问题常常出现在有人导出了一个点位的经纬度,看着是小数,却不知道它来自不同的坐标基准,结果混在一起全乱了。
处理办法是写一个标准的坐标转换函数,内置经纬度变换算法。我没有用第三方库,因为算法本身不复杂,直接写可以避免引入额外依赖。这类转换算法在很多开源项目里都有公开版本,自行搜索要注意来源可信度,我这里只提醒大家务必保留“数据来自哪个坐标系”这个元信息。很多数据污染问题,根源就是一开始没有记录坐标系来源。
5.2 marker 太多导致页面卡死
点位超过几百个之后,直接使用 L.marker 在 Leaflet 上会明显卡顿。我一开始没意识到,测试环境只放了三十多个标记,真正导入几百个点位时发现拖动地图掉帧,再往上到一千个左右,页面基本就处于半假死状态。
解决方案是引入 marker cluster 插件。把普通 marker 换成聚合标记后,缩放级别低时多个点会聚合成一个圆圈,数字代表数量,缩放放大会自动散开。配合 GeoJSON 的批量渲染,几百上千个点根本不在话下。
对于更大规模的数据,我会额外上一层“视口裁剪”:根据当前地图边界,只渲染落在视野内的要素。地图引擎本身没有这个机制,需要手动在 moveend 事件里对 features 做一次 bbox 过滤。很多项目不需要走到这一步,但理解这个机制能帮你在数据量大时不慌。
5.3 手机浏览器定位不准的问题
Geolocation API 在手机上的表现一言难尽。印象最深的一次是测试轨迹记录,明明沿着一条直线走了大概一百米,记录出来的轨迹像在不停地画圈。查下来发现是enableHighAccuracy: true没有生效,手机上同时打开了省电模式,系统把 GPS 精度降级了。
另外,使用基于浏览器的定位的前提是用户必须授权,且页面必须处于焦点状态。一旦切后台,很多安卓系统会直接杀掉定位定时器,轨迹会自动断。我在实测中总结出几条经验:一是设置maximumAge小于 5000 毫秒,让系统尽量返回新鲜坐标;二是真机测试时不要用电脑上模拟的“本机坐标”,那玩意儿和真实 GPS 差很远;三是建议用户关掉省电模式,不然结果就是轨迹乱飞。
如果你要稳定、连续的轨迹,建议把浏览器方案换成基于原生地图 SDK 的采集组件,或者直接记录到手机后台进程。浏览器能做的只是“够用”,做不到专业运动 App 那种精度。
5.4 地图瓦片加载乱序与白块闪烁
用 Leaflet 加载在线瓦片时,偶尔会出现地图局部白块,尤其断网重连后特别明显。原因是浏览器对并发请求有数量上限,瓦片请求被阻塞在队列里,一旦网络恢复,后排瓦片可能超时失败。
我做的补救措施是监听瓦片加载失败事件,对失败的瓦片做重载:
tileLayer.on('tileerror', (err) => { const tile = err.tile; const src = tile.src; setTimeout(() => { tile.src = ''; tile.src = src; }, 800); });这种简单粗暴的重载有时候能救回瓦片,但频率太高会放大网络压力。更好的处理是利用我在 2.3 节提到的离线缓存,断网时优先从缓存读,虽然瓦片不够新,至少不会白屏。对使用频繁的地区,可以再做一层预下载:遍历地图某个矩形区域的瓦片坐标,手动请求一遍,让缓存提前热起来。
5.5 可以少走弯路的几条建议
写完这个项目,我回过头来最想提醒大家的是三件事,都是一个个晚上调 bug 换来的教训。
第一,坐标系问题要在一开始就定清楚,在数据入口统一转换成目标坐标系。等数据铺开了再转坐标系,到处都是脏数据,比写十个新功能还累。第二,浏览器 API 能解决问题但别对它指望太高,定位、离线缓存、后台运行这些需求如果场景严肃,还是要评估用原生客户端实现。第三,个人工具的开发节奏要克制,需求永远可以加,但功能每多一层,维护成本就多一截。我最后把后端、前端、离线、同步全部做完之后,反倒最珍惜那个“点击地图加一个点”的极简路径,好用才是这个小工具存在的理由。