第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:3857bbox 上限,也是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 不同,必须知道:
scale(zoom) = 2^zoom,没有 256 这个基准。zoom 0 时 1 个地图单位 = 1 像素,放大一级像素密度翻倍。distance()是欧氏距离,单位是“地图单位”,不是米。map.distance()、ScaleControl、toBounds()在这个 CRS 下都没有地理意义。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 的坐标顺序相反 |