MapLibre 自定义比例尺:Web 墨卡托屏幕距离换算公式与实现

摘要:MapLibre 没有内置比例尺控件时,如何自己画一个贴合 UI、实时刷新的比例尺。本文给出地面分辨率公式(米/像素)、"1/2/5×10ⁿ"整档归一算法与自定义 View 完整实现。

文章导读

问题引入

地图 SDK 自带的 MapLibre 比例尺 往往不能满足自定义 UI 需求,真实项目里常要自己画一个:贴合自有风格、随缩放级别换分段、底图切换保持一致。自己画的前提只有一个------知道当前缩放级别下"屏幕上的 1 像素 ≈ 地面多少米"。本文给出 Web 墨卡托投影下可直接落地的地面分辨率公式与 Kotlin 实现,并做"公式 ↔ 代码"逐项核对。与 osmdroid 篇(自带 ScaleBarOverlay)同源不同控件机制。

副标题:自己画一个能贴合 UI、实时刷新的比例尺。与 osmdroid 篇(自带 ScaleBarOverlay)同源不同控件机制。

一、为什么不用地图 SDK 自带比例尺

移动端地图 SDK 通常自带比例尺控件,但真实项目里常常需要自己画一个:要贴合自有 UI 风格、要在特定缩放级别展示特定分段、要在卫星影像/矢量底图切换时保持一致,甚至要把比例尺叠加到自定义 HUD 上。

自己画的前提只有一个:知道当前缩放级别下,"屏幕上的 1 像素 ≈ 地面多少米"。本文给出一套可直接落地的计算公式与实现。

二、核心公式:地面分辨率

Web 墨卡托(EPSG:3857)投影下,地面分辨率(每像素代表的地面距离,单位:米/像素)为:

text 复制代码
resolution(zoom) = (地球周长_赤道) / (256 * 2^zoom)
                = (2 * PI * R) / (256 * 2^zoom)

其中:

  • R = 6378137.0(WGS-84 椭球长半轴,米)
  • 256 是标准瓦片像素边长
  • 2^zoom 是该级别下的瓦片总数(单边)

代入 R 后常用形式:

text 复制代码
resolution(zoom) = 156543.03392804062 / 2^zoom    // 米/像素(赤道处)

纬度修正

上面是赤道分辨率。分辨率随纬度变化(墨卡托在两极被拉伸),需乘 cos(lat)

text 复制代码
resolution(zoom, lat) = 156543.03392804062 * cos(lat * PI / 180) / 2^zoom

公式 ↔ 代码一致性核对 :DEMO 中比例尺核心计算写成

val metersPerPixel = 40075016.686 * cos(latRad) / (256.0 * 2.0.pow(zoom))

其中 40075016.686 / 256 = 156543.03392804062,与文本公式 156543.0339... / 2^zoom 完全等价;latRad = cameraLat * PI / 180 正确;niceNumber 阈值用 1.5 / 3.5 / 7.5 选档(文本第三节同步)。无偏差。

三、由分辨率反推"好看的整段距离"

屏幕宽度固定(比如打算让比例尺条占 100 dp),但"100 dp × metersPerPixel"往往是个奇怪的数(如 347.2 米)。用户更希望看到 100m / 200m / 500m / 1km 这类整齐刻度。

做法是:先算当前 100dp 对应多少米,再向上取到"1/2/5 × 10ⁿ"的整齐档:

kotlin 复制代码
fun niceRound(rawMeters: Double): Double {
    val exp = floor(log10(rawMeters))      // 数量级
    val base = 10.0.pow(exp)
    val frac = rawMeters / base
    val nice = when {
        frac < 1.5 -> 1.0
        frac < 3.5 -> 2.0
        frac < 7.5 -> 5.0
        else -> 10.0
    }
    return nice * base
}

然后反算这段距离在屏幕上该画多宽:

kotlin 复制代码
screenWidthDp = niceMeters / metersPerPixel

把这个 screenWidthDp 作为比例尺条的长度,旁边标注 niceMeters 的友好文本(>=1000 时显示 km)。

公式 ↔ 代码一致性核对 :代码 niceRoundlog10 取数量级、10^exp 取基、按 frac 落在 (1,2,5,10] 区间选档,与文本完全一致;screenWidth = niceMeters / metersPerPixel 与文本一致。无偏差。

四、自定义 View 的绘制要点

比例尺本质是一个自定义 View,在 onDraw 里:

  1. 从地图相机拿到当前 zoom 与中心 lat(DEMO 通过相机移动监听实时刷新)。
  2. 按上面的公式算 metersPerPixelniceMetersbarWidthPx
  3. 画一条横线 + 两端短竖线(经典比例尺样式),中间填 niceMeters 文本。

伪实现骨架(Kotlin):

kotlin 复制代码
override fun onDraw(canvas: Canvas) {
    val zoom = currentZoom
    val lat = currentLat
    val mpp = 156543.03392804062 * cos(lat * PI / 180) / 2.0.pow(zoom)
    val raw = TARGET_DP * mpp           // 打算画 TARGET_DP 像素对应的米
    val nice = niceRound(raw)
    val barPx = (nice / mpp).toFloat()  // 实际像素宽

    canvas.drawLine(0f, h/2f, barPx, h/2f, paint)
    canvas.drawLine(0f, 0f, 0f, h, paint)
    canvas.drawLine(barPx, 0f, barPx, h, paint)
    drawLabel(if (nice >= 1000) "${nice/1000} km" else "${nice.toInt()} m")
}

五、两个易错点

  • 错点 1:忘记纬度修正。直接拿赤道分辨率算高纬度地区的比例尺,会偏小(实际地面距离被低估)。墨卡托越靠极地 1 像素代表的地面越短。
  • 错点 2:用瓦片"名义分辨率"而非"实际 DPI" 。公式给的是逻辑像素(dp)下的米/像素;若 View 用物理像素绘制,需按设备密度 density 换算,否则真机上比例尺会偏 density 倍。

六、与 osmdroid 的异同:通用原理 vs MapLibre 实现

通用原理(完全一致) :地面分辨率 156543.0339... * cos(lat) / 2^zoom 是 Web 墨卡托的固有公式,与引擎无关;"1/2/5×10ⁿ 整档归一"也是通用做法。osmdroid 与 MapLibre 下,算出来的"每像素米数"在同等 zoom/lat 下数值相同。

MapLibre 专属实现差异

维度 osmdroid 本文(MapLibre)
控件机制 内置 ScaleBarOverlay,挂到 MapView 即可 需自定义 View,手动注册相机移动监听(cameraIdle/move)实时刷新
数据来源 MapView 的投影/缩放直接读 从相机(CameraPosition 的 zoom + 中心 lat)读取后自行算 metersPerPixel
刷新时机 Overlay 随地图重绘自动更新 必须主动监听相机变化并 invalidate() 自定义 View

一句话:公式通用、控件机制不同。MapLibre 把"比例尺"交还给开发者自己画,好处是 UI 完全可控、能叠加到任意 HUD;代价是要自己接相机监听。若想省事,也可直接用 MapLibre 插件生态里的现成比例尺组件,原理仍是本节公式。

七、小结

  • 比例尺 = 由 zoomlatmetersPerPixel,再选整齐档。
  • 必须乘 cos(lat),否则高纬度失真。
  • "整段距离"用 1/2/5×10ⁿ 归一,体验远好于直接显示原始米数。
  • 自定义 View 配合相机移动监听即可做到实时刷新。

完整工程源码见:https://gitcode.com/qq_16064871/android_osmdroid_maplibre

相关推荐
可乐鸡翅yeah_1 小时前
HLS CORS 跨域问题完整排查实战,解决 M3U8 与 TS 分片跨域报错
android·ios·音视频·m3u8·音视频在线播放
邪修king2 小时前
Linux系统篇(二十三) 基础 IO:从“文件”到“文件描述符”,彻底理解重定向
android·java·linux
灵境(虚幻知音)2 小时前
效能评估指标体系构建:方法、流程与模板
android·数据库
光影少年2 小时前
React18 对RN 的影响
android·前端·react.js·ios·前端框架
杉氧3 小时前
丝滑的奥秘:Reanimated 3 动画引擎与手势处理(Gesture Handler)
android·前端·react native
我命由我123453 小时前
Compose Codelab 学习 - Jetpack Compose 中的状态
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
sbjdhjd3 小时前
在 8 字符、字母数字过滤与 PHP 版本差异下复盘临时文件命令执行链 | (8字符绕过)
android·java·开发语言·安全·web安全·数据挖掘·php
小七在进步3 小时前
C++入门(3)
android·java·c++
码农coding4 小时前
android12 SystemUI下拉通知栏的加载流程
android