第09章 - 相机视图与坐标系

9.1 相机由五个量决定

地图的”视角”在 MapLibre 里叫相机(camera)。它由五个量确定,全部是 MapOptions 与 jumpTo/easeTo/flyTo 的字段:

量 单位 范围 默认值 含义
center 经纬度 [lng, lat] 经度 ±180 环绕,纬度 ±85.051129 [0, 0] 视口中心对准的地理位置
zoom 层级 minZoom(-2) 到 maxZoom(22) 0 每增 1 级,地图放大一倍
bearing 度 0–360,逆时针为正 0 正北相对屏幕向上的旋转
pitch 度 0–85(硬上限 180) 0 视线与竖直方向的夹角
roll 度 绕视线轴 0 相机自转,5.x 引入

纬度上限 85.051129 来自 Web Mercator 的数学性质:它是使投影后的世界成为正方形的临界纬度,源码里写作 MAX_VALID_LATITUDE = 85.051129(src/util/util.ts)。超出这个纬度,Mercator 投影的 y 会趋于无穷。

const map = new maplibregl.Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [116.391, 39.907],
  zoom: 10,
  bearing: -20,
  pitch: 45,
  roll: 0
});

console.log(map.getCenter().toArray()); // [116.391, 39.907]
console.log(map.getZoom(), map.getBearing(), map.getPitch(), map.getRoll());

9.2 三套坐标:经纬度、Mercator、屏幕

地图里同时存在三套坐标,混淆它们是定位类 bug 的主要来源。

坐标系 表示 范围 用途
经纬度(WGS84) LngLat(lng, lat) 经度 ±180,纬度 ±85.051129 业务数据、GeoJSON、API 入参
Mercator 世界坐标 MercatorCoordinate(x, y, z) x、y ∈ [0, 1],z 为海拔 瓦片、自定义图层、GPU 矩阵
屏幕坐标 Point(x, y) 像素,原点左上 点击、弹窗定位、交互计算

三者之间的换算由 Map 的公开方法完成:

// 经纬度 → 屏幕像素
const p = map.project([116.391, 39.907]);
console.log(p.x, p.y);

// 屏幕像素 → 经纬度
const lngLat = map.unproject([400, 300]);
console.log(lngLat.toArray());

// 经纬度 → Mercator 单位坐标(0–1)
const mc = maplibregl.MercatorCoordinate.fromLngLat([116.391, 39.907], 0);
console.log(mc.x, mc.y, mc.z);

// Mercator → 经纬度
console.log(mc.toLngLat().toArray());

MercatorCoordinate 有两个容易被忽略的方法:toAltitude() 把 z 转成米,meterInMercatorCoordinateUnits() 给出”该位置 1 米对应多少 Mercator 单位”。后者在自定义图层里换算模型尺寸时几乎必用——Mercator 单位在低纬度与高纬度的实际米数不同,直接用固定比例会让模型在南北方大小不一。

// 在自定义图层里把"米"换算成 Mercator 单位
const origin = maplibregl.MercatorCoordinate.fromLngLat([116.391, 39.907], 100);
const metersToMercator = origin.meterInMercatorCoordinateUnits();
console.log('1 米 =', metersToMercator, 'Mercator 单位');

9.3 LngLat 与 LngLatBounds

LngLat 是经纬度值对象,不可变,提供了几个常用能力:

方法 说明
wrap() 把经度规整到 [-180, 180)
toArray() 转成 [lng, lat]
toString() 输出 "lng,lat" 字符串,适合写进 URL
distanceTo(other) 两点间球面距离(米)
LngLat.convert(input) 接受数组、对象、LngLat,统一转成 LngLat

distanceTo 用的是球面近似,短距离(几公里内)够用;跨洲距离会有千分级误差,需要精确值用 Turf 或自己按椭球算。

LngLatBounds 表示矩形范围,构造方式很灵活:

// 四种等价的构造
new maplibregl.LngLatBounds([116.2, 39.8, 116.6, 40.0]);           // [west, south, east, north]
new maplibregl.LngLatBounds([116.2, 39.8], [116.6, 40.0]);          // 西南 + 东北
new maplibregl.LngLatBounds({west: 116.2, south: 39.8, east: 116.6, north: 40.0});

const b = new maplibregl.LngLatBounds();
b.extend([116.2, 39.8]).extend([116.6, 40.0]);                      // 逐点扩展

console.log(b.getCenter().toArray());
console.log(b.contains([116.39, 39.9])); // true

跨 180° 经线的范围需要特别注意。LngLatBounds 默认用 west/east 的数值大小判断,跨经线时 west > east,需要 adjustAntiMeridian() 修正。做太平洋区域的业务时,这个坑很隐蔽。

// 跨 180° 经线:从 170°E 到 170°W
const crossing = new maplibregl.LngLatBounds([170, -10, -170, 10]);
crossing.adjustAntiMeridian();
console.log(crossing.getWest(), crossing.getEast()); // 170, 190(内部展开)

9.4 相机方法全家桶

Map 上的相机方法分四类,用错类别的典型症状是”动效不对”或”根本不动”。

类别 方法 是否动画 用途
瞬时 jumpTo(options) 否 立刻跳转,无过渡
逐量设置 setCenter / setZoom / setBearing / setPitch / setRoll 否 改单个维度
相对移动 panBy / panTo / zoomIn / zoomOut / rotateTo / resetNorth / resetNorthPitch 是 相对当前位置移动
动画 easeTo / flyTo / fitBounds / fitScreenCoordinates 是 平滑过渡到目标视图
计算 cameraForBounds / calculateAnchoredCameraOptions / calculateCameraOptionsFromCameraLngLatAltRotation 否 只算参数,不动相机
// 瞬时
map.jumpTo({center: [121.47, 31.23], zoom: 12, bearing: 0, pitch: 0});

// 相对:向下平移 100 px
map.panBy([0, 100], {duration: 300});

// 动画:飞过去
map.flyTo({
  center: [121.47, 31.23],
  zoom: 13,
  bearing: 30,
  pitch: 50,
  speed: 1.2,
  curve: 1.42
});

// 适配范围:给出四角,自动算中心与 zoom
map.fitBounds(
  [[116.2, 39.8], [116.6, 40.0]],
  {padding: {top: 60, bottom: 40, left: 40, right: 40}, duration: 800}
);

fitBounds 内部先调 cameraForBounds 算目标相机,再按 linear 选项选 easeTo(linear: true)或 flyTo(默认)。所以它的参数是 FlyToOptions & CameraForBoundsOptions 的并集,既能给 curve 也能给 padding。

// 只算不动:用于"预览将要跳到哪里"
const target = map.cameraForBounds([[116.2, 39.8], [116.6, 40.0]], {padding: 40});
if (target) {
  console.log(target.center, target.zoom);
}

// 自动驾驶类场景:先算,再自己决定何时应用
map.jumpTo(target);

cameraForBounds 可能返回 undefined——当 padding 超过视口尺寸、或范围退化到一个点时。调用方必须判空,直接 map.jumpTo(cameraForBounds(...)) 会在极端输入下抛错。

9.5 动画参数与默认值

easeTo 与 flyTo 共享 AnimationOptions,各自的默认值在源码里写死:

参数 easeTo 默认 flyTo 默认 说明
duration 500 ms 由 speed/curve 计算 动画时长
easing defaultEasing = bezier(0.25, 0.1, 0.25, 1) 同 缓动函数
speed — 1.2 每秒移动多少个”屏幕跨度”
curve — 1.42 飞行弧线的缩放强度,1 表示圆弧
maxDuration — 无 超过则截断为 0(立即完成)
essential false false true 时忽略 prefers-reduced-motion
animate true true false 时退化为瞬时跳转

resetNorthPitch 与 rotateTo 的内部调用用的是 duration: 1000,比 easeTo 的 500 ms 慢一倍。如果产品的”回正北”按钮感觉迟钝,多半是继承了 1000 ms 的默认值。

prefers-reduced-motion 是必须处理的系统设置。用户开启”减少动态效果”后,flyTo 会自动缩短或跳过动画(除非 essential: true)。6.14 修过一个相关 bug:reduced-motion 下 flyTo 会丢掉 offset 与 around,导致落点和动画版不一致,升级时值得注意。

// 尊重系统设置:关键动效标 essential,其余让它自动降级
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

map.flyTo({
  center: [121.47, 31.23],
  zoom: 13,
  essential: !reduced,     // 用户要求减少动效时不强制动画
  duration: reduced ? 0 : undefined
});

map.stop() 可以中断正在进行的动画。连续触发 flyTo 时,前一个动画会被自动取消,但 easeTo 与手势之间可能产生竞争,交互场景下推荐在开始新动画前显式 stop()。

9.6 缩放级别与瓦片、分辨率的关系

zoom 不是抽象数字,它直接对应地面分辨率。在 Web Mercator 下,zoom=z 时整个世界被切成 2^z 个瓦片,一个瓦片在赤道处覆盖的地面宽度约为:

地面宽度 = 40075016.686 m / 2^z          (赤道处,整幅世界周长)
每像素米数 = 地面宽度 / (512 * 2^z)       (按 512px 瓦片计)

代入具体值:zoom=0 时赤道处每像素约 78 km,zoom=10 时约 76 m,zoom=18 时约 0.3 m。纬度 φ 处的实际分辨率要乘 cos(φ),北京(约 40°N)在 zoom=18 时约 0.23 m/px。

这个换算决定了”什么时候该切换到另一级数据”。比例尺控件(ScaleControl)用的就是这套计算,它在 globe 投影下会改用球面距离采样。

// 估算当前视口的实际范围(米)
function viewportMeters(map) {
  const b = map.getBounds();
  const sw = b.getSouthWest();
  const ne = b.getNorthEast();
  const widthMeters = sw.distanceTo(new maplibregl.LngLat(ne.lng, sw.lat));
  const heightMeters = sw.distanceTo(new maplibregl.LngLat(sw.lng, ne.lat));
  return {widthMeters: Math.round(widthMeters), heightMeters: Math.round(heightMeters)};
}

map.on('moveend', () => console.log(viewportMeters(map)));

9.7 边界与约束

三种方式限制用户能去的地方,语义不同:

方式 API 行为
范围约束 maxBounds / setMaxBounds() 视野不能被拖出该范围
层级约束 minZoom / maxZoom 限制缩放
俯仰约束 minPitch / maxPitch 限制倾斜
自定义约束 transformConstrain 用回调替换默认约束逻辑

maxBounds 的行为是”约束中心点 + 由视口尺寸推导可到边界”。6.11 起支持只约束一个轴(例如只限纬度、不限经度),6.12 起尊重 padding,让加了 padding 的视口也能拖到边界。

map.setMaxBounds([[73, 18], [135, 54]]);

// 只约束纬度轴(6.11+):经度不限
map.setMaxBounds([[73, 18], [135, 54]], {constrainLng: false});

// 自定义约束:不允许进入某个禁区
map.setTransformConstrain(({center, zoom, bearing, pitch}) => {
  // 返回修正后的相机;具体签名见 TransformConstrainFunction
  return {center, zoom, bearing, pitch};
});

transformConstrain 是一个高级钩子,默认实现就做 maxBounds 的约束。自定义它能实现”按缩放级别动态扩大可移动范围”“禁区避让”等需求,代价是要自己处理跨经线与 globe 投影。

9.8 地形与高程

开启地形后,”相机中心离地多高”变成一个有实际意义的量:

map.setTerrain({source: 'dem', exaggeration: 1.5});

// 查询某点地形高程(米),没有地形时返回 null
const elev = map.queryTerrainElevation([116.391, 39.907]);
console.log('高程', elev);

// 查询"相机看到的目标点"海拔
console.log(map.getCameraTargetElevation());

// 中心点海拔:默认随地形自动更新
console.log(map.getCenterElevation());

// 关闭自动贴地(pitch > 90 时必须)
map.setCenterClampedToGround(false);

centerClampedToGround 默认 true,意味着 getCenter() 返回的中心点会带上地形高程。这个默认值在多数场景下是对的,但在做”俯视到地下”或 pitch 超过 90 度的实验性视角时,会把相机拽回地面。

6.12 修过一批地形相关 bug:jumpTo/easeTo/flyTo 在 centerClampedToGround: false 时会把中心海拔拉回地形,以及手势过程中地形加载导致相机跳动。用 6.12 之前的版本时,地形动画要留出验收时间。

9.9 常见坑

project 返回的坐标对不上点击位置。 map.project 返回的是相对容器左上角的 CSS 像素;如果地图容器有 padding 或页面有滚动,差的不是地图算错,而是没减掉容器偏移。

fitBounds 后视野被裁掉一部分。 忘了加 padding,或者 padding 超过视口一半导致 cameraForBounds 返回 undefined 而静默失败。

跨 180° 经线时标注跑到另一侧。 业务范围跨经线要用 adjustAntiMeridian(),或者把经度展开到 0–360 连续区间再计算。

相机”回弹”。 设了 maxBounds 但视口比 bounds 还大,相机会反复被约束到边界。先确保 bounds 的尺寸大于视口覆盖范围。

pitch 超过 85 度出现黑边或撕裂。 接近 90 度时投影的远平面趋于无穷,源码用 maxMercatorHorizonAngle = 89.25 限制地平线角度。超过这个值不是”配置能解决”的问题。

flyTo 在长距离时”绕地球半圈”。 直线飞行会选择较短方向,但经度未规整时可能反向。传参前先用 new LngLat(lng, lat).wrap() 规整。

9.10 生产建议

相机状态单独持久化。 用 map.getCenter() / getZoom() 等取值存到 URL 或本地存储,恢复时用 jumpTo 而不是 flyTo,避免刷新页面时先飞一段。

慎用 hash: true。 它会把相机写进 URL,便于分享,但每次移动都改 URL,在 SPA 里可能与路由冲突。需要分享链接时手动取相机状态拼参数更可控。

动画考虑设备性能。 低端设备上连续 flyTo 会掉帧。用 map.isMoving() 判断,动画进行中不要叠加新的相机指令。

用 cameraForBounds 做”一键回全图”。 它比 fitBounds 更灵活,可以先算再判断是否需要动,避免无意义的动画。

地形场景统一用 elevation 而非手动偏移。 地形开启后,用 queryTerrainElevation 加高度做标记定位,比假设地面为 0 更准。

9.11 本章小结

主题 结论
相机五元 center、zoom、bearing、pitch、roll;默认 0,0 / 0 / 0 / 0 / 0
纬度上限 85.051129(MAX_VALID_LATITUDE)
三套坐标 LngLat、MercatorCoordinate(0–1)、屏幕 Point
换算入口 project / unproject / MercatorCoordinate.fromLngLat
方法分类 jumpTo(瞬移)、set*(单维)、panBy/zoomIn(相对)、easeTo/flyTo/fitBounds(动画)、cameraForBounds(只算)
动画默认 easeTo duration 500、defaultEasing = bezier(0.25,0.1,0.25,1);flyTo speed 1.2、curve 1.42
约束 maxBounds(6.11 起可单轴)、min/maxZoom、min/maxPitch、transformConstrain
地形 queryTerrainElevation、getCenterElevation、centerClampedToGround 默认 true