第12章 - 坐标系统投影与地理计算

12.1 三层坐标:经纬度、投影坐标、像素坐标

Leaflet 内部同时存在三套坐标,混用它们是最常见的错误来源:

坐标 类型 单位 典型来源
地理坐标 LatLng 度 用户输入、GeoJSON、GPS
投影坐标 Point(CRS 单位) EPSG:3857 下为米 WMS 的 bbox、空间查询
像素坐标 Point(整数或小数像素) 像素 DOM 定位、命中测试

三者通过 map 上的固定方法互相转换,方法名就是转换路径:

LatLng ──map.project(latlng, zoom)──▶ 像素坐标(相对 CRS 原点)
像素坐标 ──map.unproject(point, zoom)──▶ LatLng

LatLng ──map.latLngToLayerPoint()──▶ 像素坐标(相对像素原点)
像素坐标 ──map.layerPointToLatLng()──▶ LatLng

LatLng ──map.latLngToContainerPoint()──▶ 相对地图容器的像素
像素 ──map.containerPointToLatLng()──▶ LatLng

两个“原点”要分清:project() 返回的像素坐标原点在 CRS 的固定位置(EPSG:3857 是投影后世界的左上角),不随平移缩放变化;latLngToLayerPoint() 返回的坐标要减去“像素原点” map.getPixelOrigin(),那个原点是当前视口左上角对应的绝对像素位置。源码只有两行:

latLngToLayerPoint(latlng) {
	return this.project(latlng)._subtract(this.getPixelOrigin());
}
layerPointToLatLng(point) {
	return this.unproject(new Point(point).add(this.getPixelOrigin()));
}

containerPoint 与 layerPoint 的差值是地图 pane 的平移量,滚动地图时 pane 被整体位移,而像素原点不变。

import {LeafletMap, TileLayer} from 'leaflet';

const map = new LeafletMap('map', {center: [39.9042, 116.4074], zoom: 12});
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);

const ll = [39.9042, 116.4074];

console.log(map.project(ll));               // Point(12695932, 4852031) 之类的米级像素值
console.log(map.project(ll, 0));           // 同一位置在 zoom 0 的像素
console.log(map.latLngToLayerPoint(ll));   // 相对当前视口左上角
console.log(map.latLngToContainerPoint(ll)); // 相对容器,含 pane 偏移

// 反算校验:往返应回到原值
console.log(map.layerPointToLatLng(map.latLngToLayerPoint(ll)));

2.0 起 latLngToLayerPoint() 和 latLngToContainerPoint() 返回小数像素(1.x 返回整数),需要整数时自己 round()。这是 2.0 用亚像素精度画矢量、消除相邻多边形缝隙的副作用。

12.2 CRS 对象的结构

CRS 不是可实例化的类,它是一组静态方法的命名空间。源码 src/geo/crs/CRS.js 定义了五个核心成员:

成员 类型 作用
code string WMS 请求里用的标准代号,如 'EPSG:3857'
projection Projection 对象 经纬度 ⇄ 投影坐标,提供 project/unproject
transformation Transformation 投影坐标 ⇄ 像素,含缩放与平移
wrapLng / wrapLat [min, max] 经/纬度环绕范围
infinite boolean 坐标空间是否无限(影响世界副本、边界计算)

两个换算方法决定了所有像素计算:

static scale(zoom) {
	return 256 * 2 ** zoom;
}
static zoom(scale) {
	return Math.log(scale / 256) / Math.LN2;
}

scale 的字面含义是“zoom 0 时整个世界宽 256 像素,每升一级翻倍”。所以 zoom 0 世界宽 256px,zoom 10 是 262,144px,zoom 19 是 1.34 亿像素(2^19 × 256 = 134,217,728)。瓦片是 256px 固定尺寸,因此 zoom z 的瓦片总数是 4^z,z19 约为 2.7×10^11,不可能全量预生成,只在视口附近按需请求。

CRS.latLngToPoint() 把两步串起来:

static latLngToPoint(latlng, zoom) {
	const projectedPoint = this.projection.project(latlng),
	scale = this.scale(zoom);
	return this.transformation._transform(projectedPoint, scale);
}

_transform(point, scale) 的计算是 (a*x + b) * scale, (c*y + d) * scale,a/b/c/d 来自 transformation。这套设计让不同 CRS 只需换 projection 和 transformation 两组常量。

12.3 内置 CRS 对照

CRS 投影 输出单位 zoom 0 世界尺寸 适用场景
EPSG3857(默认) 球面墨卡托 米 256×256 px 所有主流在线瓦片
EPSG3395 椭球墨卡托 米 256×256 px 少数商业瓦片服务
EPSG4326 等距圆柱(经纬度直投) 度 512×256 px 要求经纬度直投的 WMS/TMS
SimpleCRS 无投影 任意地图单位 由 transformation 决定 游戏地图、平面图、扫描图
EPSG900913 同 EPSG3857 米 256×256 px 遗留 WMS 只认旧代号时

EPSG4326 的 512×256 不是笔误。源码里它的 transformation 是 new Transformation(1/180, 1, -1/180, 0.5),代入 zoom 0 的 scale=256:经度 -180~180 映射到 x=0~512,纬度 90~-90 映射到 y=0~256。这正是官方文档说的“zoom 0 用两块 256×256 瓦片覆盖全球,原点在 (-180, +90)”,用 tms: true 时原点变为 (-180, -90)。

import {LeafletMap, TileLayer, EPSG4326} from 'leaflet';

const wgs84 = new LeafletMap('map', {
	crs: EPSG4326,
	center: [39.9, 116.4],
	zoom: 4
});

new TileLayer('https://example.com/wgs84/{z}/{x}/{y}.png', {
	tms: true,          // 原点在左下角时必须开
	maxZoom: 10,
	attribution: '© 数据提供方'
}).addTo(wgs84);

12.4 EPSG:3857 的数学与精度

球面墨卡托的投影公式在 SphericalMercatorProjection 里,用的地球半径是 6378137 米:

project(latlng) {
	const d = Math.PI / 180;
	const lat = Math.max(Math.min(85.0511287798, latlng.lat), -85.0511287798);
	const sin = Math.sin(lat * d);

	return new Point(
		this.R * latlng.lng * d,
		this.R * Math.log((1 + sin) / (1 - sin)) / 2);
}

两个常数值得记住:

  • MAX_LATITUDE = 85.0511287798。这是让投影后世界成为正方形的纬度截断值,超过它的输入会被夹到边界上,不是报错。南极洲、北极点附近的坐标在地图上会被“压平”。
  • 投影范围是 ±Rπ = ±20037508.342789244 米。这就是 WMS 的 EPSG:3857 bbox 上限,也是 map.getPixelWorldBounds(0) 返回的范围。

分辨率:每像素代表多少米,公式是 156543.03 × cos(φ) / 2^z。156543.03 = 40075016.686 / 256,即赤道处 zoom 0 的米/像素。手算几档:

zoom 赤道 m/px 北纬 40° m/px
0 156,543 119,914
8 611.5 468.4
12 38.22 29.28
16 2.389 1.830
19 0.2986 0.2287

这个表解释了两件事:为什么高纬度的“1 像素”代表的真实距离更短(投影拉伸),以及为什么 zoom 19 之后再加缩放级别对普通屏幕已无意义(亚像素)。ScaleControl 用 map.distance() 算比例尺,在 3857 下按球面距离反推,纬度越高,比例尺读数与屏幕米数的偏差越大。

12.5 SimpleCRS:给非地理平面图用

SimpleCRS 的源码只有 38 行,把“经纬度”直接当平面坐标:

export class SimpleCRS extends CRS {
	static projection = LonLatProjection;          // x = lng, y = lat
	static transformation = new Transformation(1, 0, -1, 0);
	static scale(zoom) { return 2 ** zoom; }       // 不是 256×2^z
	static zoom(scale) { return Math.log(scale) / Math.LN2; }
	static distance(a, b) {                        // 欧氏距离,不是大圆距离
		const dx = b.lng - a.lng, dy = b.lat - a.lat;
		return Math.sqrt(dx * dx + dy * dy);
	}
	static infinite = true;
}

三处与地理 CRS 不同,必须知道:

  1. scale(zoom) = 2^zoom,没有 256 这个基准。zoom 0 时 1 个地图单位 = 1 像素,放大一级像素密度翻倍。
  2. distance() 是欧氏距离,单位是“地图单位”,不是米。map.distance()、ScaleControl、toBounds() 在这个 CRS 下都没有地理意义。
  3. infinite = true,getPixelWorldBounds() 返回 null,worldCopyJump 不生效,也没有经度环绕。

典型用法是配 ImageOverlay 显示扫描图:

import {LeafletMap, ImageOverlay, Marker, SimpleCRS, LatLng} from 'leaflet';

const map = new LeafletMap('map', {
	crs: SimpleCRS,
	minZoom: -3,        // 允许缩小到图片完整可见
	maxZoom: 3
});

// bounds 是 [y, x] 顺序,不是 [x, y]
const bounds = [[0, 0], [1000, 1000]];
new ImageOverlay('/assets/floorplan.png', bounds).addTo(map);
map.fitBounds(bounds);

// 图片上的一个点:先 y 后 x
new Marker(new LatLng(320, 480), {alt: '设备 A'}).addTo(map);

坐标顺序是这块最大的坑:SimpleCRS 里 LatLng(lat, lng) 实际是 [y, x]。上面的 marker 在图片的 y=320、x=480 处。映射到屏幕时 y 轴向下增长,这与图片像素坐标方向一致,但和数学坐标系相反,所以 transformation 的第三个分量是 -1。

如果尺寸与地图单位不一致,fitBounds 会显得“缩不到位”。Leaflet 官方示例里图片 2315px 宽、地图只有 1000 单位,必须用 minZoom: -5 才能把整图塞进容器。这是 SimpleCRS 下 zoom 可以为负的直接后果。

12.6 自定义 CRS

需要非内置投影时,常规路线是 Proj4Leaflet 插件(官方插件页把它标为尚未兼容 Leaflet 2,升级前要确认)。如果只是“固定比例的平面图”,照着 SimpleCRS 的写法定义静态成员即可:

import {LeafletMap, CRS, LonLatProjection, Transformation} from 'leaflet';

// 1 个地图单位 = 2 像素,y 轴向下
class DoubleScaleCRS extends CRS {
	static projection = LonLatProjection;
	static transformation = new Transformation(2, 0, -2, 0);
	static scale(zoom) { return 2 ** zoom; }
	static zoom(scale) { return Math.log(scale) / Math.LN2; }
	static distance(a, b) {
		const dx = b.lng - a.lng, dy = b.lat - a.lat;
		return Math.hypot(dx, dy);
	}
	static infinite = true;
}

const map = new LeafletMap('map', {crs: DoubleScaleCRS, minZoom: -2, maxZoom: 4});

CRS 只能在创建地图时通过 crs 选项指定。运行期没有 setCRS(),换坐标系必须销毁重建地图——因为 CRS 决定了瓦片编号、像素原点、所有图层的投影结果。

12.7 地理计算:距离、边界、精度

12.7.1 distance 用的是球面模型

map.distance(a, b) 直接委托给 crs.distance。地理 CRS 继承 EarthCRS,其中:

export class EarthCRS extends CRS {
	static wrapLng = [-180, 180];
	static R = 6371000;
	static distance(latlng1, latlng2) {
		return haversineDistance(latlng1, latlng2, this.R);
	}
}

LatLng.distanceTo() 用同一套 Haversine 公式,半径也是 6371000 米(IUGG 平均半径)。两个结论:

  • 距离计算与地图投影无关。切到 EPSG4326 或 EPSG3395,同两点的 map.distance() 结果一样,因为它只读经纬度。
  • 球面模型与 WGS84 椭球测地线相比有千分之几量级的差异。做航海、航空、精密量测时不要用 map.distance() 当最终值,改用 Turf.js 的椭球算法或服务端计算。
const a = [39.9042, 116.4074]; // 北京
const b = [31.2304, 121.4737]; // 上海

const meters = map.distance(a, b);
console.log(Math.round(meters / 1000) + ' km'); // 约 1068 km(球面距离)

// 等价写法:不依赖地图实例
console.log(new LatLng(a).distanceTo(b)); // 同样的数字

12.7.2 LatLng 的容差与取整

LatLng.equals(other, maxMargin) 的默认容差是 1e-9 度,远小于任何实际定位精度。用 === 比较两个 LatLng 必然失败(对象引用不同),用 equals 才正确。

坐标序列化默认保留 6 位小数(Util.formatNum 的默认精度),toGeoJSON() 走的也是它。6 位小数在赤道约等于 0.11 米,够用;如果数据本身是毫米级工程坐标,序列化时显式传 precision。

LatLng.toBounds(sizeInMeters) 生成以该点为中心、边长 sizeInMeters 的矩形:

const area = new LatLng(39.9042, 116.4074).toBounds(1000);
// 纬度方向半宽 180*1000/40075017 ≈ 0.00449 度(约 500 m)
// 经度方向再除以 cos(lat)
console.log(area.getWest(), area.getEast());

源码里纬度半宽用的是 180 * sizeInMeters / 40075017,分母是赤道周长;经度半宽再除以 cos(lat)。高纬度地区这个近似仍然成立,因为它只做局部小范围换算。

12.7.3 跨 180° 经线的边界

LatLngBounds 用西南角与东北角表示矩形,天然表达不了跨日界线的范围。源码注释直接说明:跨反子午线时必须把角点写在 [-180, 180] 之外,例如西经 170° 到东经 170° 的跨线区域写成 [[-10, 170], [10, 190]]:

import {LatLngBounds, LeafletMap} from 'leaflet';

// 跨 180 度经线:东经写成 190 而不是 -170
const crossing = new LatLngBounds([-10, 170], [10, 190]);
map.fitBounds(crossing);

写成 [[-10, 170], [10, -170]] 得到的边界宽度是 340°(覆盖大半个地球),fitBounds 会把地图缩到最小级别。这是处理太平洋数据时最典型的现象。

12.7.4 经度环绕

map.wrapLatLng() 按 CRS 的 wrapLng 把经度折回 [-180, 180]:

map.wrapLatLng([40, 190]);    // LatLng(40, -170)
map.wrapLatLng([40, -190]);   // LatLng(40, 170)

这个行为对跨线数据有副作用:一个标记放在经度 190,地图会把它环绕到 -170,视觉上出现在日界线另一侧。要做“同一世界副本内连续显示”的轨迹,用 worldCopyJump: true 并保持原始经度值,不要提前手动 wrap。

12.8 参数与换算速查

方法/属性 输入 输出 备注
map.project(latlng, zoom?) LatLng 像素(CRS 原点) zoom 省略时用当前级别
map.unproject(point, zoom?) 像素 LatLng 逆运算
map.latLngToLayerPoint(latlng) LatLng 像素(相对像素原点) 2.0 返回小数
map.latLngToContainerPoint(latlng) LatLng 像素(相对容器)  
map.getPixelOrigin() — Point 视口左上角的绝对像素
map.getPixelWorldBounds(zoom?) zoom Bounds 或 null 无限 CRS 返回 null
map.getZoomScale(to, from?) zoom 比例因子 crs.scale(to)/crs.scale(from)
map.getScaleZoom(scale, from?) 比例 zoom 逆运算
map.distance(a, b) LatLng 米(地理 CRS) 与投影无关
CRS.scale(zoom) zoom 像素比例 256 * 2^zoom(SimpleCRS 为 2^zoom)
CRS.zoom(scale) scale zoom  
LatLng.distanceTo(other) LatLng 米 Haversine,R=6371000
LatLng.equals(other, margin?) LatLng boolean 默认容差 1e-9
LatLng.toBounds(meters) 米 LatLngBounds 以点为中心的方框
map.wrapLatLng(latlng) LatLng LatLng 按 CRS 环绕
Util.formatNum(num, precision?) 数字 数字 默认 6 位小数

12.9 排错

地图一片灰,瓦片 404。 检查 crs 与瓦片服务是否匹配。EPSG:3857 的服务配成 EPSG4326,请求的 x/y 编号完全不同,服务端必然 404。反过来用 WGS84 瓦片(tms: true)却用默认 CRS,同样全 404。改 crs 后记得同步检查 tms 与 minZoom。

标记位置偏移几百米。 常见于把 WGS84 经纬度直接放到 GCJ-02 底图上(国内地图数据有偏移加密),或把投影坐标(米)当经纬度传进 new LatLng(x, y)。LatLng 只接受度,输入上百万的数值代表你拿错了数据。

new LatLng(null, null) 抛 Invalid LatLng object。 2.0 收紧了校验,null、''、布尔值不再被转成 0 或 1,非数字字符串也抛错。脏数据先用 LatLng.validate(v) 过滤,它和构造函数接受的范围完全一致。

fitBounds 缩放到最小级别。 边界跨 180° 经线且角点写法不对,或者传入了 [lng, lat] 顺序的数组。GeoJSON 是 [lng, lat],Leaflet 是 [lat, lng],两者转换必须显式做。

SimpleCRS 里 ScaleControl 显示荒谬数值。 SimpleCRS.distance() 返回的是地图单位,比例尺控件把它当米处理。去掉比例尺,或者自己按“地图单位/像素”画一个自定义标尺。

比例尺随拖动跳变。 ScaleControl 默认 updateWhenIdle: false,拖动过程中持续重算。投影导致不同纬度算出的距离不同,控件宽度会在取整边界来回跳。改成 updateWhenIdle: true 可以稳定读数。

map.getPixelOrigin() 报 Set map center and zoom first。 地图还没设置视图就调了依赖像素原点的方法。放到 map.whenReady() 或 load 事件之后。

12.10 生产建议

  • 默认坚持 EPSG3857。除非瓦片服务强制,换 CRS 意味着放弃主流底图,收益通常抵不过成本。
  • 国内业务先解决坐标系问题再谈精度。GPS 原始 WGS84、国测局 GCJ-02、百度 BD-09 三套坐标混用,偏移可达数百米。转换放在数据进入地图之前,不要在地图库里做。
  • 距离计算不要用 map.distance() 当交付值。球面近似在 1000 km 尺度上可能差几公里;用服务端或椭球算法,前端只做展示。
  • SimpleCRS 的坐标单位与图片像素不是一回事。定一个“1 地图单位 = N 像素”的换算常量,全项目统一,否则 fitBounds 与 marker 定位会反复对不上。
  • 坐标序列化统一 6 位小数够用;但存储时保留原始精度,只在输出时取整,避免链式计算累积误差。
  • 跨日界线的数据在入库前决定策略:统一用 0~360 经度,还是拆成两个不跨线的要素。不要指望 LatLngBounds 自动处理。

12.11 本章小结

要点 结论
三套坐标 经纬度(度)、投影坐标(米/CRS 单位)、像素;转换方法在 map 上
两种像素原点 project 相对 CRS 原点;latLngToLayerPoint 相对视口像素原点
scale 256 × 2^zoom(SimpleCRS 为 2^zoom),zoom 0 世界 256px
默认 CRS EPSG3857,球面墨卡托,R=6378137,纬度截断 85.0511287798
EPSG4326 经纬度直投,zoom 0 为 512×256,TMS 原点在左下
SimpleCRS 地图单位即像素比例基准,欧氏距离,无限空间,坐标是 [y, x]
分辨率 156543.03 × cos(φ) / 2^z 米/像素
距离 Haversine,R=6371000,与投影无关,球面近似
精度 equals 容差 1e-9;序列化默认 6 位小数 ≈ 0.11 m
跨线边界 角点写在 [-180,180] 之外,GeoJSON 与 Leaflet 的坐标顺序相反