资讯动态

地图上画依附于直线的图形:坐标系选择与局部坐标变换实战

发布时间:2026/10/21 8:17:38 来源:尧图企业网站定制
1. 从一个看起来很简单的需求说起地图开发里有一类需求第一次听到会觉得这不就是画条线吗真动手做才发现处处是坑。比如这个需求在地图上一条直线的某个位置画一个箭头、一条垂直线或者任意其他图形。听起来像是调用一个绘图 API 就完事了但实际做的时候你会立刻撞上几个问题——这个直线上的一点到底怎么表示是屏幕坐标还是地理坐标箭头要跟着线的方向转吗地图缩放、平移之后图形还贴得住吗我自己第一次接这类需求是在做一个路径标注工具的时候。用户点一下路线上的某个位置要在那个点画一个垂直于路线的短横线用来表示这里是一个检查点。当时我天真地以为拿到点击位置的经纬度再算一个垂直方向画上去就完事了。结果地图一缩放那条垂直线就飘了跟路线完全对不上。后来才明白问题的核心不在于怎么画而在于坐标系的选择和转换。这篇内容就是把这套东西彻底讲清楚。不管你是要做箭头指示、垂直标记线还是更复杂的自定义图形底层的思路是相通的。我会从坐标系这个根上讲起然后给出可复现的计算方法最后把缩放平移这些魔鬼细节一个个拆开。适合已经能在地图上画点线面、但一遇到相对位置图形就卡壳的开发者也适合想系统理解地图几何计算的朋友。先给一个结论性的判断在地图上画依附于某条线的图形本质上是一个局部坐标系问题。你得先在线的局部建立一个坐标系在这个坐标系里画好图形再把它变换回地图的全局坐标系。想通这一点后面所有问题都会变得清晰。2. 坐标系选错后面全白干2.1 屏幕坐标、地理坐标、局部坐标到底该用哪个地图开发里同时存在好几套坐标系混用是万恶之源。我先把它们摆清楚。地理坐标经纬度是数据层面的坐标描述的是地球表面的真实位置。它的单位是度而且经度和纬度的实际长度不一样——同样一度纬度方向大约 111 公里经度方向则要乘以纬度的余弦值。这个特性是后面很多坑的根源。屏幕坐标像素坐标是渲染层面的坐标描述的是图形在屏幕上的位置。它的单位是像素原点通常在左上角。用户点击地图时拿到的往往是屏幕坐标需要转换才能变成地理坐标。局部坐标是我自己起的名字指的是以直线上某一点为原点、以直线方向为轴建立的一套相对坐标系。在这套坐标系里直线就是 x 轴垂直方向就是 y 轴。你要画的箭头、垂直线在这套坐标系里都是最简单的形状。为什么一定要引入局部坐标因为如果你直接在地理坐标里算垂直方向会非常痛苦。地理坐标里的垂直不是简单的 (x, y) 互换而是要经过投影、旋转、再反投影。而如果你在屏幕坐标里算又会被地图缩放比例影响算出来的长度忽长忽短。我的经验是计算用局部坐标存储用地理坐标渲染用屏幕坐标。三者各司其职转换关系理清楚代码就不会乱。2.2 为什么直线上的一点不能只存一个经纬度很多人以为直线上的一点就是一个经纬度坐标存下来就完事了。但如果你要在这个点上画一个垂直于直线的图形光有位置是不够的你还必须知道直线在这个点上的方向。方向信息从哪来从直线的两个端点算。假设直线从点 A 到点 B那么这条线的方向向量就是 B 减 A。在局部坐标系里这个方向就是 x 轴的正方向。有了方向你才能算出垂直方向把方向向量旋转 90 度才能画出垂直线。所以直线上的一点实际上应该包含两部分信息位置这个点在哪和方向这个点所在的线朝哪个方向。只存位置不存方向是新手最容易犯的错误会导致图形无法正确旋转。2.3 一个生活化的类比贴瓷砖我用贴瓷砖来类比这件事。你要在墙上贴一块花砖花砖本身有图案相当于你要画的箭头或垂直线。你不能直接把花砖的图案坐标往墙上一扔因为墙有墙的坐标系花砖有花砖的坐标系。正确的做法是先在花砖自己的坐标系里把图案画好局部坐标然后确定花砖贴在墙上的哪个位置、旋转多少度位置和方向最后把花砖的图案印到墙上坐标变换。地图上画依附图形逻辑完全一样。理解了这一点你就会明白画图形的难点从来不是图形本身而是坐标变换。箭头怎么画、垂直线怎么画这些都是几何常识难的是让它们在正确的位置、以正确的角度、在正确的缩放级别下出现。3. 把直线上一点算明白位置与方向的完整推导3.1 从两个端点求方向向量假设直线上有两个已知点 P1 和 P2坐标都是经纬度。我们要在直线上某个比例位置 t0 到 1 之间处画图形。第一步求方向。这里有个关键选择在哪个坐标系里求方向。如果直接在经纬度里求方向向量是 (lon2 - lon1, lat2 - lat1)。但这个向量在屏幕上的视觉方向是不准的因为经度和纬度的缩放比例不同。在高纬度地区这个误差会非常明显。正确的做法是先把 P1、P2 转成屏幕坐标或者转成某种等距投影坐标在屏幕坐标里求方向算完再转回去。这样得到的视觉方向才是对的。我用一个具体例子说明。假设 P1 是 (116.0, 39.0)P2 是 (116.1, 39.1)看起来是个 45 度方向的线。但在屏幕上由于经度方向被压缩这条线可能看起来更陡。如果你按经纬度算垂直方向画出来的垂直线在视觉上就不垂直。实操建议方向计算一律在屏幕坐标或等距投影坐标里做。经纬度只用来存储和传输不用来做几何计算。3.2 求直线上任意一点的坐标有了方向求直线上一点就简单了。在屏幕坐标里点 P 的坐标就是P.x P1.x t * (P2.x - P1.x) P.y P1.y t * (P2.y - P1.y)这里的 t 是比例参数。t0 就是 P1t1 就是 P2t0.5 就是中点。这个公式在屏幕坐标和局部坐标里都成立因为它是线性的。如果你想让用户点击直线上任意位置那么 t 可以通过点击位置到 P1 的距离除以整条线的长度来算。注意这个距离也要在屏幕坐标里算否则同样会有比例问题。3.3 垂直方向怎么求旋转 90 度垂直方向就是把方向向量旋转 90 度。在二维平面里向量 (dx, dy) 旋转 90 度后变成 (-dy, dx) 或 (dy, -dx)两个方向互为相反选哪个取决于你要往哪边画。这里有个容易忽略的点旋转方向要和屏幕坐标的 y 轴方向一致。屏幕坐标的 y 轴通常向下而数学坐标的 y 轴向上。如果你用数学公式算完直接往屏幕上画垂直线可能会画反。我踩过这个坑。当时算出来的垂直线在数学上是对的但画到屏幕上方向反了检查了半天才发现是 y 轴方向的问题。解决办法很简单要么在算之前把 y 轴翻转要么在算完之后把 y 分量取反。我习惯后者代码更直观。3.4 长度单位像素还是米画垂直线、箭头的时候长度用像素还是米这是个必须提前决定的问题。用像素的好处是不管地图缩放到什么级别图形在屏幕上看起来大小恒定。适合做 UI 标记比如箭头指示器。用米的好处是图形代表真实的地理范围缩放时会跟着变大变小。适合做地理标注比如表示 100 米宽的缓冲区。我的建议是箭头、指示器这类装饰性图形用像素标注、范围这类地理性图形用米。如果需求没说清楚优先问一句因为这两种选择在实现上差别很大。如果用米还要注意在地理坐标里100 米在经度和纬度方向对应的度数是不一样的。纬度方向大约是 100/111000 度经度方向要再除以纬度的余弦。这个换算不做画出来的图形会是椭圆的。4. 箭头、垂直线、自定义图形的画法拆解4.1 箭头三个点就够了箭头看起来复杂其实就是三个点连成的三角形加上一条线。在局部坐标系里假设箭头尖端在原点方向朝 x 轴正方向那么三个点可以是尖端(0, 0)左后角(-L, W/2)右后角(-L, -W/2)其中 L 是箭头长度W 是箭头宽度。把这三个点连起来就是一个三角形箭头。如果要带尾巴再加一条从 (-L, 0) 往后的线。关键步骤是先在局部坐标里定义这三个点然后通过旋转和平移把它们变换到地图坐标。旋转角度就是直线方向与 x 轴的夹角平移量就是那个直线上一点的位置。我一般会写一个通用的变换函数function localToWorld(point, origin, angle) { const cos Math.cos(angle); const sin Math.sin(angle); return { x: origin.x point.x * cos - point.y * sin, y: origin.y point.x * sin point.y * cos }; }这个函数把局部坐标点 (point) 绕原点 (origin) 旋转 angle 角度后变换到世界坐标。箭头、垂直线、任何自定义图形都可以用这个函数统一处理。4.2 垂直线注意垂直是相对谁垂直线有两种理解一种是垂直于直线一种是垂直于水平面也就是竖直。这两个完全不同需求里必须确认清楚。如果是垂直于直线那就是把方向向量旋转 90 度从那个点往两边各画一段。长度可以对称也可以只往一边画。如果是垂直于水平面那就简单了直接在那个点上下画一条竖线就行跟直线方向无关。但这种情况下如果直线本身是斜的这条竖线看起来就不垂直于直线视觉上可能不符合预期。我遇到的需求大多是第一种——垂直于直线。实现时先算方向旋转 90 度得到垂直方向然后从中心点往两边延伸。延伸长度用像素的话直接加减就行用米的话要先换算成度数。4.3 自定义图形把图形定义在局部坐标里任何自定义图形思路都是一样的在局部坐标系里定义图形的顶点然后统一变换。比如你要画一个T形标记可以在局部坐标里定义几个点组成 T 的形状。再比如画一个带缺口的圆也是先在局部坐标里画好再变换。这里有个性能上的建议如果图形顶点很多变换计算量会比较大。可以考虑用矩阵变换把所有顶点一次性处理。不过对于地图上这种小图形顶点通常就几个到几十个直接算完全够用不用过度优化。4.4 一个完整的计算流程我把整个流程串一遍你可以照着这个顺序写代码拿到直线的两个端点 P1、P2经纬度。把 P1、P2 转成屏幕坐标。根据比例 t算出直线上目标点的屏幕坐标。算出直线的方向角 angle。在局部坐标里定义图形顶点。用 localToWorld 把顶点变换到屏幕坐标。把屏幕坐标转回经纬度如果需要存储。渲染图形。这个流程里第 2 步和第 7 步是坐标转换第 3 到 6 步是几何计算。把这两部分分开代码会清晰很多。5. 缩放平移之后图形飘了问题出在这5.1 为什么图形会飘图形飘根本原因是你存储的是屏幕坐标但屏幕坐标会随地图状态变化。地图缩放时同样的经纬度对应的屏幕坐标变了。如果你把图形顶点存成屏幕坐标缩放后这些坐标就失效了图形自然就飘了。正确的做法是存储地理坐标每次渲染时重新计算屏幕坐标。这样不管地图怎么变图形都能重新算到正确位置。但这里有个矛盾如果你用像素长度画图形那么图形顶点的地理坐标是依赖于当前缩放级别的。缩放后像素长度对应的地理长度变了你需要重新算顶点。所以严格来说像素长度的图形没法用固定的地理坐标存储必须每次重算。我的处理方式是存储直线上一点的位置和图形参数长度、宽度、角度渲染时实时计算顶点。这样既保证了正确性又避免了存储冗余数据。5.2 监听地图事件重新计算地图库通常都会提供缩放、平移的事件。你需要在事件回调里重新触发图形的计算和渲染。以常见的做法为例伪代码大概是这样map.on(zoomend, () { redrawAllShapes(); }); map.on(moveend, () { redrawAllShapes(); });注意用zoomend和moveend而不是zoom和move。前者是操作结束后触发一次后者是过程中持续触发。用后者会导致频繁重算性能很差。如果图形很多还要考虑节流。我一般会加一个简单的防抖比如 100 毫秒内只重算一次。5.3 屏幕坐标和地理坐标的转换陷阱坐标转换有两个常见陷阱。第一个是转换时机。地图还没加载完的时候转换函数可能返回错误结果。所以一定要等地图的load事件之后再开始计算。第二个是转换精度。有些地图库的转换函数在高缩放级别下会有精度损失算出来的坐标有微小偏差。对于大多数场景这无所谓但如果你要画非常精细的图形可能需要自己实现转换。提示转换函数尽量用地图库官方提供的不要自己手写。自己写的转换很难考虑周全尤其是涉及到投影方式的时候。5.4 一个真实的排查过程我有一次遇到图形在缩放后偏移的问题排查了很久。过程大概是这样的先怀疑是坐标转换的问题把转换前后的坐标打出来对比发现转换本身没问题。然后怀疑是渲染时机的问题加了延迟还是不行。最后发现问题出在图形顶点的计算用了缓存的旧值。我在第一次渲染时把顶点算好存起来了缩放后没有重新算直接用了旧值。这个坑的教训是任何依赖地图状态的中间结果都不能缓存。要么每次重算要么在地图状态变化时清空缓存。我后来养成了一个习惯凡是跟屏幕坐标相关的变量都在渲染函数内部计算不往外传。6. 几个容易被忽略的细节和我的实操心得6.1 角度计算用 atan2别用 atan算方向角的时候用Math.atan2(dy, dx)而不是Math.atan(dy/dx)。原因很简单atan 只能返回 -90 到 90 度无法区分方向atan2 能返回 -180 到 180 度覆盖所有方向。这个细节看起来小但如果你用 atan当直线方向朝左或者朝下的时候算出来的角度就错了图形会画到反方向。我见过不少新手栽在这上面。6.2 图形顶点的顺序影响填充如果你要填充图形比如箭头三角形顶点的顺序很重要。顺时针和逆时针会影响填充结果有些渲染引擎还会因此判断错误。我的习惯是统一用逆时针顺序定义顶点。这样不管在哪个渲染引擎里填充结果都一致。6.3 边界情况直线退化成点如果直线的两个端点重合方向向量就是零向量算角度会得到 NaN。这种情况虽然少见但必须处理否则程序会崩。处理方式很简单算方向之前先判断两点距离如果小于某个阈值就跳过图形绘制或者用一个默认方向。6.4 性能图形多了怎么办如果地图上要画几百上千个这种依附图形性能会成为问题。每个图形都要算顶点、做变换、渲染累积起来很可观。我的优化思路是能合并的合并能简化的简化。比如多个箭头如果样式一样可以考虑用图层批量渲染如果图形很小在低缩放级别可以隐藏减少渲染量。另外计算部分尽量用简单的数学避免调用复杂的库函数。我实测下来纯数学计算比调用地图库的转换函数快不少因为后者内部做了很多额外处理。6.5 一个通用工具函数的封装最后分享一个我常用的封装思路。把在直线上画图形抽象成一个函数参数包括直线端点、比例位置、图形定义、长度单位。函数内部处理所有坐标转换和几何计算返回可以直接渲染的顶点数组。这样封装的好处是不管你要画箭头、垂直线还是别的什么调用方式都一样只是传入的图形定义不同。代码复用率高维护也方便。function drawShapeOnLine(p1, p2, t, shapeDef, unit) { // 转屏幕坐标 const sp1 toScreen(p1); const sp2 toScreen(p2); // 算目标点和方向 const origin lerp(sp1, sp2, t); const angle Math.atan2(sp2.y - sp1.y, sp2.x - sp1.x); // 变换图形顶点 const vertices shapeDef.map(v localToWorld(v, origin, angle)); // 转回地理坐标 return vertices.map(v toGeo(v)); }这个函数不依赖具体的地图库只要替换 toScreen 和 toGeo 两个转换函数就能适配不同的地图平台。我在几个项目里都用过这个思路迁移成本很低。6.6 关于直线上一点的交互设计如果你的需求是让用户点击直线上的某一点来放置图形交互上还有几个细节要注意。第一点击位置到直线的距离要设一个容差。用户很难精确点在线上容差太小会点不中太大又会误触。我一般设 10 到 15 像素。第二点击后要把点击位置投影到直线上得到真正的线上一点。投影的方法就是算垂足用向量点积就能搞定。第三如果直线上已经有图形了要考虑是否允许重复放置或者要不要吸附到已有图形附近。这些是产品层面的决策但实现上都要提前想好。7. 写在最后的一点个人体会这类在地图上画依附图形的需求表面上是绘图问题实际上是坐标变换问题。我做了这么多年地图开发越来越觉得地图开发的难点从来不在画什么而在坐标系。把坐标系理清楚把转换关系理顺剩下的都是几何常识。我自己的习惯是遇到这类需求先在纸上画一遍直线在哪目标点在哪图形相对直线是什么姿态缩放后应该怎么变。想清楚了再写代码比直接上手敲要快得多。很多时候代码写不出来不是因为不会写而是因为没想清楚。另外别怕用局部坐标。很多人觉得多一层坐标转换是麻烦其实恰恰相反局部坐标是简化问题的利器。把复杂问题放到简单的坐标系里解决这是数学和工程里通用的思路。如果你正在做类似的功能希望这篇内容能帮你少走点弯路。有具体问题也欢迎一起讨论地图这块的坑一个人踩是踩不完的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑