☰
地图上画依附于直线的图形:坐标系选择与局部坐标变换实战
2026/10/20 6:32:48 网站建设 项目流程

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,坐标都是经纬度。我们要在直线上某个比例位置 t(0 到 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 是比例参数。t=0 就是 P1,t=1 就是 P2,t=0.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 一个完整的计算流程

我把整个流程串一遍,你可以照着这个顺序写代码:

  1. 拿到直线的两个端点 P1、P2(经纬度)。
  2. 把 P1、P2 转成屏幕坐标。
  3. 根据比例 t,算出直线上目标点的屏幕坐标。
  4. 算出直线的方向角 angle。
  5. 在局部坐标里定义图形顶点。
  6. 用 localToWorld 把顶点变换到屏幕坐标。
  7. 把屏幕坐标转回经纬度(如果需要存储)。
  8. 渲染图形。

这个流程里,第 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. 写在最后的一点个人体会

这类"在地图上画依附图形"的需求,表面上是绘图问题,实际上是坐标变换问题。我做了这么多年地图开发,越来越觉得,地图开发的难点从来不在画什么,而在坐标系。把坐标系理清楚,把转换关系理顺,剩下的都是几何常识。

我自己的习惯是,遇到这类需求,先在纸上画一遍:直线在哪,目标点在哪,图形相对直线是什么姿态,缩放后应该怎么变。想清楚了再写代码,比直接上手敲要快得多。很多时候,代码写不出来不是因为不会写,而是因为没想清楚。

另外,别怕用局部坐标。很多人觉得多一层坐标转换是麻烦,其实恰恰相反,局部坐标是简化问题的利器。把复杂问题放到简单的坐标系里解决,这是数学和工程里通用的思路。

如果你正在做类似的功能,希望这篇内容能帮你少走点弯路。有具体问题也欢迎一起讨论,地图这块的坑,一个人踩是踩不完的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询