第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 |