程云来 / 杭州
← 工程实践
MapLibre GL JS · GeoJSON · 瓦片 · 空间索引

用 MapLibre 构建地图:从地理坐标到可维护的空间交互

在真实街道底图上解释坐标、瓦片、样式与聚合,跟踪 GeoJSON 更新和选择状态,并处理地图服务失败、性能与部署边界。


地图上出现一个标签,背后同时有地理坐标、地图投影、瓦片请求、样式渲染与业务选择状态。把经纬度画成几个可点击方块,只能验证控件;一个可维护的地图应用,还要解释坐标来自哪里、底图谁提供、聚合在什么尺度发生,以及数据刷新后选中对象是否仍然有效。

本页采用 MapLibre GL JS,它是 WebGL 地图引擎,具有与 Mapbox GL JS 同类的 source / layer / camera 架构。默认加载真实 OpenStreetMap 街道瓦片;叠加的 P01—P30 是明确标注的合成设备点位,用于复现选择、过滤与快照更新。街道属于真实地理数据,设备数据属于实验输入,两者分别注明来源。

先决定地图引擎和地理数据各由谁负责

地图引擎负责投影、相机、瓦片调度、渲染和交互。底图服务提供瓦片、样式、字体或图标,业务接口则提供设备、区域或轨迹。换一个引擎,不会自动获得可用地图数据;购买底图服务,也不会替你处理选择状态、错误恢复与坐标转换。

MapLibre 让本例沿用现有开源引擎,无须引入个人 Mapbox Token。Mapbox GL JS 可对接 Mapbox 样式、地形、卫星影像等服务,接入时需要按项目实际使用的服务配置凭据与访问限制,并核对其计费和许可。二者不能靠改一个包名就保证所有扩展、样式和 API 都兼容。当前项目锁定 MapLibre 6.8.0,按安装版本的类型与 Worker 布局验证。

对象本例实际输入换成业务系统时要明确的契约
引擎MapLibre GL JS版本、浏览器/WebGL 支持、Worker 与 CSP
地理底图OpenStreetMap 栅格街道瓦片服务商、许可、缓存、限额、失败策略
点位30 / 24 个合成点的快照坐标参考系、稳定 ID、数据时刻、权限与覆盖范围
样式本地 style v8 + 点位聚合图层顺序、缩放范围、字体/图标与标签语言
相机中心、zoom、pitch、bearing全部点位、选中定位、二维/三维和用户操作优先级

MapLibre 官方介绍说明其 WebGL 地图能力;Mapbox 官方指南可用于另一条引擎/服务接入路径。本站的选型依据是已有运行时与本案例的交互需求,不把某个引擎名字当作“专业”的证明。

经纬度还没有变成屏幕坐标

GeoJSON 使用经度在前、纬度在后,即 [longitude, latitude]。本例 P01 位于 [120.147, 30.264],在杭州附近。若写成 [30.264,120.147],纬度超出合法范围;即使两个值都合法,交换顺序也会把点送到另一个地区。经纬度单位是度,不是米。

RFC 7946定义 GeoJSON 坐标约定。通常输入是 WGS 84 经纬度,而常见街道瓦片按 Web Mercator 组织。地图引擎先把经纬度投影到平面,再用相机变换得到像素。Mercator 的东西向坐标与经度成正比,南北向坐标是纬度的非线性函数;越靠近极地变形越显著,常见瓦片范围截止到约 ±85.0511°。

text
x = (longitude + 180) / 360
φ = latitude × π / 180
 y = (1 − ln(tan(φ) + sec(φ)) / π) / 2
瓦片索引 tx=floor(x × 2^z),ty=floor(y × 2^z)

这里 x、y 是归一化世界平面坐标,z 是瓦片层级。缩放级别越高,视野越小;地图按视口请求对应瓦片,缓存已取得的数据。地图相机允许小数 zoom,实际瓦片选择还受数据源、像素比和过度缩放策略影响,不能把小数 zoom 原样填入 URL。

地图上的像素距离不等于地面距离。聚合半径是屏幕尺度,米制缓冲区、面积与线路长度则要用适合业务地域的投影或测地线方法。中国境内若业务点来自 GCJ-02/BD-09,需要先识别源坐标系与服务约定,使用明确、可验证的转换;给所有点硬加一个经纬度偏移会产生位置依赖误差。本页合成输入直接使用 WGS 84 约定。

样式把 source 和 layer 分开

source 声明数据从哪里来,layer 声明这些数据如何绘制。同一份点位 source 可以同时供聚合圆、数量标签和未聚合点层使用;多个图层引用同一 source,不需要复制业务数据。栅格 source 收到的是已着色的图片,矢量 source 收到的是可由样式继续表达的要素。

本例把 OSM 栅格放在业务点位下方。只切换该图层的 visibility,不调用 setStyle,因此点位 source、选择与相机都能保留。若换整张矢量样式,样式内的业务 source/layer 可能一并被替换,应在样式完成加载后按唯一 ID 重建,不能继续引用旧对象。

typescript
// map-scene.ts 的真实底图接入形状
map.addSource('osm', {
  type: 'raster', tileSize: 256,
  tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
  attribution: '© OpenStreetMap contributors',
});
map.addLayer({ id: 'osm', type: 'raster', source: 'osm' }, 'point-query');
// points: GeoJSON source,cluster=true,clusterRadius=48,clusterMaxZoom=16。
// 每条 Feature 用稳定的 point.id;地图显示与列表选择引用相同 ID。

开启真实底图时,本地合成道路、河流、建筑脚印与挤出体全部隐藏,避免虚构街区覆盖真实道路。关闭底图或请求失败后,页面明确标注本地示意图。倾斜 55° 是相机动作:一张栅格图片不会因为相机倾斜就产生真实建筑高度。需要三维城市时,应接入有许可和高度字段的建筑矢量数据,使用 fill-extrusion 的米制 height/base;需要地形则还要 DEM source 与 terrain 设置。

跑一次选择、聚合和快照更新

先从列表选择 P01。选中 ID 写入 React 状态,地图移动到对应经纬度、标签高亮、弹窗读取当前数据。切换三维后再选 P02,pitch 保持,中心与 zoom 改变;回正北只复位 bearing。使用“全部点位”后点击一个聚合,观察展开前后的标签数。

随后选择 P30,切到快照 B:B 只有 P01—P24,旧选择被清除。选 P01 再切 A/B,因为 ID 仍存在,选择保留,但经纬度和数值按新快照刷新。搜索与分组影响的是同一个可见点位集合,列表数量、source 数据和聚合统计应一起改变。

点位与视角

正在准备地图…

30 个点位 · 缩放 14.3 · 俯仰 0°OpenStreetMap 底图 · 合成点位

列表的 0—99 数值只是可重现的测试标量,没有温度或风险的业务单位。生产系统应把 value 拆成明确的变量与单位,附上采样时间和数据质量;一个裸数字无法解释设备状态。弹窗展示的是当前快照,不能把原来 Marker 闭包里的旧详情当作最新结果。

追踪同一份 GeoJSON 的完整更新路径

mapPoints(snapshot) 产生当前快照,filterPoints 依据搜索与分组产生 visiblePoints。React 用它渲染列表,查看器把它变成 FeatureCollection 交给同一个 GeoJSON source。selectedId 只表示身份,不再保存一份可能过期的 point 对象。

正在绘制图表…
查看 Mermaid 源码
flowchart LR
  Snapshot[数据快照] --> Filter[搜索与分组 / visiblePoints]
  Filter --> List[列表]
  Filter --> Source[GeoJSON source / setData]
  Source --> Cluster[Worker 聚合]
  Cluster --> Map[地图标签]
  List --> Select[selectedId]
  Map --> Select
  Select --> Detail[从当前 visiblePoints 读取详情]
  Detail --> Camera[弹窗与定位相机]

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

typescript
// 连续调用关系;正式入口见 MapViewer / createMapScene.update。
const points = filterPoints(mapPoints(snapshot), query, group);
const selectedPoint = points.find(p => p.id === selectedId);
scene.update(points); // 更新 source,清除旧 Marker,刷新有效选中点详情
if (!selectedPoint) setSelected(null);
// source update:
(map.getSource('points') as GeoJSONSource).setData({
  type: 'FeatureCollection',
  features: points.map(p => ({
    type: 'Feature', id: p.id, properties: { ...p },
    geometry: { type: 'Point', coordinates: [p.lng, p.lat] },
  })),
});

source 更新和 Worker 完成聚合存在时间差,source 尚未加载时不能直接把一次空查询当成“没有点位”。当前实现等待数据源就绪,再查询可见 source 要素,并以 clusterId / pointId 去重维护 DOM Marker。聚合展开回调捕获数据 generation;等待期间若快照换了,旧 generation 的展开结果被丢弃。

业务 API 还要给快照增加 version、observedAt 与覆盖范围。接收增量时先按稳定 ID 合并再派生 UI;晚到的旧版本不能覆盖新版本。用户仍在拖图时,新遥测数据不应自动把相机拉回选中点;“更新位置”和“主动定位”需要分别定义触发条件。

聚合半径不是地理服务半径

当前 clusterRadius=48 按屏幕尺度聚合附近点,clusterMaxZoom=16 决定何时停止形成聚合。聚合数字统计当前 source 中的点,本页 source 已先过滤,所以切换 A/B 分组后数字随之更新。这与“显示全部点但隐藏某些图层”的策略不同,后者的聚合仍可能包含视觉上被隐藏的对象。

点击聚合时,向 source 请求 getClusterExpansionZoom(clusterId),得到这些点下一步分开的 zoom,再移动相机。clusterId 随数据与层级重建,只适合当前聚合树,不能作为后端业务 ID 持久化。pointId 则必须稳定。

48 像素在不同缩放、纬度和设备上对应不同地面范围,因此不能解释成“48 米内设备”。如果业务要求按行政区统计、半径告警或固定网格聚合,应先建立业务空间查询和统计口径,再用地图表达结果。浏览器聚合适合视觉密度管理,不代替服务端的地理业务计算。

从几十个标签扩展到大量点位

本例使用可聚焦的 DOM 按钮 Marker,方便键盘、aria 选中态与列表联动。几十个标签时清楚可测;大量点位时,每个 DOM 节点都有布局、事件与同步成本,不能只依据 WebGL 每秒帧数判断性能。数据规模上升,应优先把非选中点与聚合表达迁移到 circle/symbol 图层,保留少量详情弹窗。

规模与访问方式在该条件下的建议验证什么
数十点、详情频繁DOM 标签 + 稳定 ID 列表键盘可达、状态同步、卸载清理
数千至数万点GeoJSON source + GPU circle/symbol + Worker 聚合首次解析、setData 耗时、内存、交互延迟
大范围持续更新服务端空间索引、视口查询或矢量瓦片查询边界、版本、分页/瓦片缓存和跨区域连续性

这些是选型方向,规模阈值要在目标手机、数据属性数量和更新频率上实测。频繁更新巨大 FeatureCollection 会带来序列化与重建成本;业务增量和只传必要属性通常比单纯加大聚合半径更有效。记录“接收数据→更新 source→可交互”的时间,并和地图瓦片加载耗时分别分析。

卸载时移除地图、ResizeObserver、Marker 和弹窗;不能让路由返回后出现两个 canvas。WebGL 不可用时,列表筛选与正文仍能工作。减少动态效果时相机直接到位;自动连续动画不是地图可用性的前提。

真实底图失败时,该保留什么

底图空白,点位却还在。 检查瓦片 URL、HTTP 状态、CORS、网络、样式与访问凭据。失败只影响底图时,保留点位 source、列表与选择,提示地图背景已经回退。本页 OSM source 的错误会触发这一条恢复路径;底图状态不借用 WebGL 查看器错误状态。

地图一直“准备中”。 确认 Worker URL 与共享资源已同版本部署。本项目在 dev/build 前执行 prepare-maplibre.mjs,复制安装包中的运行时,使用 basePath 前缀构建 Worker URL。CSP、脚本类型或错误的子目录会导致 Worker 不启动。验证实际 Worker 请求与控制台错误,避免加一个更长超时掩盖问题。

点位漂移,但底图道路正常。 对照已知坐标、经纬度顺序和源坐标系;检查业务接口是否把投影平面米坐标装进 GeoJSON 经纬度。修复后选同一测试点,核对列表、Feature.geometry 与弹窗坐标一致,再核对它在真实地物上的位置。

刷新快照后弹窗仍显示已删除的设备。 检查选中状态是否保存整份旧对象、Marker 是否捕获旧属性。把选择收敛为 ID,详情由当前快照查找;更新时清理消失 ID,并拒绝旧 generation 的聚合回调。以 P30 在快照 B 消失、P01 在 B 更新作为固定回归。

底图服务与部署是一项明确依赖

OpenStreetMap 数据许可与公共瓦片服务容量是两件事。当前页面直接请求公共街道瓦片,只请求当前视口,保留浏览器缓存、可见署名与 Referer,不预下载、不批量离线抓取。应遵守 OSM 瓦片使用政策;公共服务不提供你的业务 SLA。正式业务规模与可用性要求上升后,应选择有相应服务承诺的提供商或自建符合许可的数据与瓦片链路。

如果接入 Mapbox 或其他服务,浏览器公开凭据应按服务能力限制来源域、作用范围与额度;私密管理凭据不应放进前端。字体、图标、矢量瓦片和地形资源也都是外部请求,应一起核对可访问性与许可。对外提供真实设备位置时,数据访问控制应该在 API 执行,前端隐藏图层不能替代授权。

复现与工程检查

bash
npm run dev
# 打开 /zh/engineering/map
# Worker 与共享资源会在 predev / prebuild 中自动复制。
ENGINEERING_BASE_URL=http://localhost:3001 node examples/stage-02-engineering/verify.mjs

公开入口是 EngineeringExample → MapViewer → createMapScene。断点从唯一可见点位集开始,随后看 asGeoJSON、GeoJSONSource.setData、updateMarkers 和 select。验收以同一份数据驱动列表/地图、删除选择清理、有效选择刷新、聚合过期回调拒绝、相机保留、底图失败回退与无 WebGL 列表操作为依据。

底图来源、业务数据来源、坐标系、采样时刻和服务失败状态都能查到,标签与列表在刷新后仍一致,地图才具备可维护的工程语义。资料核对日期:2026-10-10;当前实现使用仓库锁定的 MapLibre 6.8.0,不包含真实设备监控后端。