ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Leaflet与PIXI的雷达扇形可视化:坐标转换与渲染优化实践

基于Leaflet与PIXI的雷达扇形可视化:坐标转换与渲染优化实践 去年做交通基础设施雷达监控可视化的时候客户提了一个需求把每台雷达设备的探测范围直接画到Leaflet地图上范围不是圆形而是扇形——中心点固定半径三五公里从某个起始方位角扫到终止方位角。这种扇形雷达绘制看起来只是“画个饼状图”但真正落到 Leaflet PIXI 这套组合上牵扯出来的全是坐标系、投影、地图旋转和长度精度优化这些硬骨头。我把整个踩坑和最终落地的过程整理出来给后面接同类需求的朋友省点时间。先说结论扇形绘制本身不难难的是“画出来”和“画得准”完全是两件事。尤其当你需要在地图上做长度标注、在用户旋转地图后保持方位角正确、在高缩放下让弧线依然圆滑这些需求会逼着你把坐标转换的每一步都想清楚。1. 扇形雷达为什么放着Leaflet原生渲染不用非要接个PIXI1.1 雷达扇区这个数据的真实样子先看数据模型。每个雷达扇区通常包含这几个核心字段{ id: radar_001, center: [113.321, 23.155], radius: 5000, startAngle: 45, endAngle: 135, color: #ff6633, opacity: 0.35 }其中center是雷达站经纬度radius是探测半径单位米startAngle和endAngle是方位角正北为0度、顺时针增加。气象雷达、港口监控雷达、风力发电噪声评估基本都是这套模型。一开始我天真地以为这东西用 Leaflet 自带的 Polygon 图层就能搞定。但实际一跑才发现问题一个中型项目地图上同时挂几十个雷达站每个雷达站在刷新周期内要动态生成、更新、移除扇形再加上用户频繁缩放拖拽Leaflet 的 SVG 渲染器直接开始掉帧。更麻烦的是后续还要叠加目标轨迹点、区域热力块如果全堆在 Leaflet 的图层体系里性能会越来越难看。1.2 原生渲染的两个硬伤DOM节点爆炸与重绘风暴Leaflet 默认的 SVG renderer原理是给每个矢量要素创建一个pathDOM节点。几十个扇形就是几十个 path配合动态刷新每一帧都在改 DOM 属性浏览器 layout 和 paint 的开销很大。Canvas renderer 比 SVG 好一些但 Leaflet 的 canvas 图层在每次 move/zoom 时都会全量重绘而且它不会帮你做“局部脏矩形”优化。这种场景恰恰是 PIXI 的主场。PIXI 基于 WebGL图形数据在 GPU 里批量渲染几百个扇形也只是若干 draw call 的事远比分批操作 DOM 节点高效。而且 PIXI 的 Graphics 对象支持 fill 和 lineStyle天然适合画这种带填充色和边框的扇形。1.3 PIXI叠加层接入Leaflet的基础框架把 PIXI 接进来不是直接把它当 Leaflet 图层塞进去那样 canvas 会被盖在瓦片下面。正确姿势是自建一个自定义L.Layer把 PIXI 的 canvas 挂载到地图容器上通过 z-index 控制层级import * as L from leaflet; import * as PIXI from pixi.js; export const RadarPixiLayer L.Layer.extend({ onAdd(map: L.Map) { this._map map; const container map.getContainer(); const size map.getSize(); this._canvas document.createElement(canvas); this._canvas.className radar-pixi-layer; container.appendChild(this._canvas); this._app new PIXI.Application({ view: this._canvas, width: size.x, height: size.y, backgroundAlpha: 0, antialias: true, resolution: Math.min(window.devicePixelRatio, 2), }); this._sectorContainer new PIXI.Container(); this._app.stage.addChild(this._sectorContainer); map.on(zoomend moveend resize, this._redraw, this); this._redraw(); }, onRemove(map: L.Map) { map.off(zoomend moveend resize, this._redraw, this); this._app.destroy(true); if (this._canvas.parentNode) { this._canvas.parentNode.removeChild(this._canvas); } }, _redraw() { // 交给上层业务逻辑重绘所有扇形 }, });对应的 canvas 样式关键就两条.radar-pixi-layer { position: absolute; left: 0; top: 0; z-index: 600; pointer-events: none; }pointer-events: none必须加否则 canvas 会挡住 Leaflet 的地图拖拽和点击事件。如果你需要在 canvas 上做扇形 hover 或点击再单独处理命中检测而不是把 pointer-events 放开。注意 PIXI 版本差别我用的是 PIXI v7backgroundAlpha在 v6 及更早版本里是transparent: true升级 v8 之后 API 又有调整。网上很多老帖子的代码直接搬过来会报错先确认你锁定的版本。2. 像素坐标转换地图旋转之后latLngToContainerPoint为什么突然不靠谱2.1 LatLngToContainerPoint 为什么是“半成品”要往 PIXI 里画东西第一步是把经纬度转成屏幕像素坐标。Leaflet 提供了现成方法const pt map.latLngToContainerPoint(latlng);这个方法返回的是相对于地图容器左上角的像素坐标。在“地图完全没有旋转”的前提下直接拿这个点去 PIXI 里画结果是正确的。很多教程也就到此为止但实际做雷达可视化用户经常要旋转地图——尤其是港口、航道、机场这类场景旋转后航向与屏幕方向一致看起来很直观。2.2 地图旋转之后到底什么被改变了麻烦在于Leaflet 官方是不支持地图旋转的。项目里要实现旋转通常是自己给map-pane加 CSS transform或者集成了某个地图旋转插件。不管怎么实现底层逻辑都是瓦片层整体绕地图容器的中心点旋转了某个角度。问题来了地图的 CSS transform 旋转后map.latLngToContainerPoint()返回的坐标依然是“旋转前坐标系”下的坐标。也就是说旋转前的某点经纬度对应屏幕左上角为原点的坐标旋转后你如果还拿这个坐标画扇形扇形就不会跟着底图转中心点偏离雷达站标记方位角也全错。我一开始在这个问题上踩了很深的坑误以为给 PIXI 的 Container 设置rotation就能解决结果发现 Container 是绕自己的原点转的而底图是绕容器中心转的两者旋转基准不一样扇形和底图始终对不齐。2.3 锚点加旋转矩阵我最终采用的转换方案正确的做法不是去旋转 PIXI 容器而是在坐标转换阶段就把旋转考虑进去。具体分四步记录地图当前的旋转角度rotateAngle弧度顺时针为正确定旋转中心通常是地图容器中心点的容器像素坐标rotatePivot先用map.latLngToContainerPoint()算出某点旋转前的坐标rawP把rawP相对rotatePivot的偏移量旋转rotateAngle再加上rotatePivot得到最终 PIXI 渲染坐标。旋转公式看起来简单但符号很容易搞反。容器坐标系里 y 轴向下顺时针旋转对应数学上的正角度旋转dx rawP.x - pivot.x dy rawP.y - pivot.y x pivot.x dx * cos(angle) - dy * sin(angle) y pivot.y dx * sin(angle) dy * cos(angle)这里最大的坑是旋转中心不是扇形自己的中心而是地图容器的中心。底图旋转时底图上每一个点都在绕同一个旋转中心转。如果像我最初那样“以扇形中心为锚点做旋转”在平移过地图或者旋转角度较大时扇形会明显错位。2.4 一套可以直接用的 TypeScript 坐标转换模块把上面的思路封装成一个模块后续所有扇形顶点、标签、中心点渲染都走这一个入口interface PixiProjector { toScreen(latlng: L.LatLng): { x: number; y: number }; updateRotate(angle: number): void; updatePivot(size: L.Point): void; } export function createPixiProjector(map: L.Map): PixiProjector { let rotateAngle 0; let pivot { x: map.getSize().x / 2, y: map.getSize().y / 2 }; function toScreen(latlng: L.LatLng) { const raw map.latLngToContainerPoint(latlng); const dx raw.x - pivot.x; const dy raw.y - pivot.y; const cos Math.cos(rotateAngle); const sin Math.sin(rotateAngle); return { x: pivot.x dx * cos - dy * sin, y: pivot.y dx * sin dy * cos, }; } return { toScreen, updateRotate(angle: number) { rotateAngle angle; }, updatePivot(size: L.Point) { pivot { x: size.x / 2, y: size.y / 2 }; }, }; }用这个模块之后地图旋转时只需要调projector.updateRotate(newAngle)然后重新执行一次绘制所有扇形的位置和方向都会跟着底图转。3. 用“方位角距离”生成弧线顶点而不是在画布上直接画圆3.1 用“中心点距离方位角”逐个生成弧上顶点很多人画扇形的时候习惯先在像素坐标系里画一个圆然后根据角度截取一段弧。这样做在“纯前端展示不管地理精度”的时候没问题但雷达扇形需要标注“半径5km”必须保证显示出来的半径在地理上是准的就不能在像素坐标系里直接画圆了。正确思路始终在经纬度空间里用“中心点 距离 方位角”计算出弧线上每个顶点的经纬度最后渲染时才通过 projector 转成屏幕坐标。给定中心点、距离 d 和方位角 θ求目标点经纬度用的是球面大圆航线公式δ d / R φ2 asin( sin φ1 * cos δ cos φ1 * sin δ * cos θ ) λ2 λ1 atan2( sin θ * sin δ * cos φ1, cos δ - sin φ1 * sin φ2 )其中 R 取地球平均半径 6371000 米φ 和 λ 都是弧度。代码实现function destPoint( lat: number, lon: number, distance: number, bearingDeg: number ) { const R 6371000; const DEG Math.PI / 180; const δ distance / R; const φ1 lat * DEG; const λ1 lon * DEG; const θ bearingDeg * DEG; const φ2 Math.asin( Math.sin(φ1) * Math.cos(δ) Math.cos(φ1) * Math.sin(δ) * Math.cos(θ) ); const λ2 λ1 Math.atan2( Math.sin(θ) * Math.sin(δ) * Math.cos(φ1), Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2) ); return { lat: φ2 / DEG, lon: λ2 / DEG, }; }有两点值得说明一这个公式算出来的是大圆路径上的点不是等角航线。雷达电磁波传播路径更接近大圆而且几十公里范围内两者差异微乎其微画出来完全看不出区别。如果你要更椭球精度的计算可以用 turf 的turf.destination但纯绘图场景没必要引入额外依赖。二有了这个函数扇形的“长度精度”就从源头被保证了。无论地图放大缩小、旋转多少度弧上每个点的经纬度都是严格按真实地理距离生成的不会出现“标注5km但实际只有4.8km”的问题。3.2 顶点数量怎么定弦高误差公式与自适应采样弧线不是连续的要用一系列直线段逼近。顶点太少放大后能看到明显折线顶点太多GPU 开销浪费。关键是怎么动态决定顶点数。一个常用的几何近似把弧线看成圆的一部分内接多边形与圆之间的最大弦高误差h ≈ R_px * (1 - cos(step / 2))反过来给定允许的最大误差 h可以反解出每段弧的圆心角step 2 * acos(1 - h / R_px)其中R_px是当前缩放级别下扇形半径对应的像素长度。我一般取h 0.5px人眼基本感知不到。几个典型数值算出来大概是这样的关系扇形半径像素值 R_px允许误差 0.5px 时的每段角度60度扇区所需顶点数200px约 8.1°约 9 个500px约 5.1°约 13 个1000px约 3.6°约 18 个3000px约 2.1°约 30 个5000px约 1.6°约 39 个所以实际代码里每次绘制前先算出R_px再根据误差反推 step最后生成弧线顶点function buildArcPoints( centerLat: number, centerLon: number, radius: number, startAngle: number, endAngle: number, radiusPx: number ) { const maxErrPx 0.5; const safeR Math.max(radiusPx, 10); const step safeR maxErrPx ? 2 * Math.acos(1 - maxErrPx / safeR) : Math.PI / 36; const stepDeg Math.max(1, (step * 180) / Math.PI); let end endAngle; if (end startAngle) end 360; const points []; for (let a startAngle; a end; a stepDeg) { const p destPoint(centerLat, centerLon, radius, ((a % 360) 360) % 360); points.push(p); } const last destPoint(centerLat, centerLon, radius, ((end % 360) 360) % 360); points.push(last); return points; }半径特别小或 zoom 级别很低时R_px可能只有十几像素这时候没必要追求 0.5px 误差直接限制 step 不小于 1 度就行避免生成大量无意义的顶点。3.3 跨0°方向、退化扇区与PIXI.Graphics绘图实现雷达扇区经常会出现跨正北方向的情况比如从 350° 扫到 10°。如果直接写for (let a start; a end; a step)循环体一次都不会执行。所以我在buildArcPoints里做了处理当endAngle startAngle时给end加 360生成顶点时再对角度取模。另外要注意退化扇区半径小于等于 0直接不绘制起始角与终止角差值小于 0.0001°退化成一条线可以用 line 代替 fill 扇形避免beginFill画出一个面积为零的图形。PIXI 里画扇形最直接的方式是 Graphicsconst g new PIXI.Graphics(); g.beginFill(0xff6633, 0.35); g.lineStyle(1.5, 0xff6633, 0.9); const centerPx projector.toScreen(centerLatLng); const arcPts buildArcPoints(...).map((p) projector.toScreen(L.latLng(p.lat, p.lon)) ); g.moveTo(centerPx.x, centerPx.y); for (const pt of arcPts) { g.lineTo(pt.x, pt.y); } g.lineTo(centerPx.x, centerPx.y); g.endFill(); sectorContainer.addChild(g);几十个扇形如果每个都 new 一个 Graphics内存和状态切换还是有点压力。我实际的做法是所有扇形塞进同一个 PIXI.Container每个扇形一个独立的 Graphics 子对象方便后续单独更新某个扇区如果确认所有扇形样式一致、不需要 hover也可以把全部扇形画到一个大的 Graphics 里性能再提升一截。前者是“可维护性优先”后者是“极致性能优先”按业务需求选。3.4 距离标签不随地图缩放的PIXI.Text处理扇区上要显示距离数值比如“3.0 km”。这个标签如果直接放在世界坐标里跟随地图缩放会出现两种尴尬zoom 小时文字小到看不清zoom 大时文字模糊成马赛克。我的处理方式把标签放入一个独立的uiContainer不随 zoom 缩放但每次重绘时根据“弧线中点”的当前屏幕坐标重新定位const label new PIXI.Text(3.0 km, { fontFamily: Arial, fontSize: 13, fill: 0xffffff, resolution: Math.min(window.devicePixelRatio, 2), }); label.anchor.set(0.5); // 每次重绘时更新位置 const midAngle (startAngle endAngle) / 2; const midLatLng destPoint(lat, lon, radius, midAngle); const midPx projector.toScreen(L.latLng(midLatLng.lat, midLatLng.lon)); label.position.set(midPx.x, midPx.y); uiContainer.addChild(label);这里有两个细节文本内容是“地理距离”不是“像素距离换算出来的公里数”。直接拿扇区的 radius 转字符串就行用户输入 5000 米就显示 5.0 km不经过任何像素换算保证数值和真实地理距离一致。resolution要设置。PIXI 在高分屏下如果 resolution 不匹配文字会发虚。我一般设成Math.min(window.devicePixelRatio, 2)兼顾清晰度和性能不要无脑上 3 或 4。4. 长度精度优化的三个实测环节半径校验、旋转联动、缩放重采4.1 半径与像素的换算校验长度精度优化的第一步是确认“半径米数”和“渲染像素”之间的换算没有跑偏。很多人喜欢手动算当前缩放下每像素对应多少米然后 radius / resolution 得到像素。这个方案在 Web 墨卡托投影下很容易被纬度和缩放级别搞晕尤其是不同纬度下每像素对应的米数不一样。我更推荐直接用投影结果来算。比如要验证半径方向上“中心点到弧线端点”的像素距离可以这样const centerLatLng L.latLng(center[1], center[0]); const edgeLatLng destPoint(center[1], center[0], radius, startAngle); const centerPx projector.toScreen(centerLatLng); const edgePx projector.toScreen(edgeLatLng); const radiusPx Math.hypot(edgePx.x - centerPx.x, edgePx.y - centerPx.y);这个radiusPx就是当前缩放和旋转状态下半径在地图上的真实像素长度。后续弧线顶点采样、碰撞检测、标注摆放都优先用这个值而不是手动换算。因为它完全由 Leaflet 的投影链和旋转矩阵推导而出跟最终渲染结果是同一套坐标系。另外可以用 Leaflet 自带的map.distance()做一次地理层校验const measured map.distance(centerLatLng, edgeLatLng); // measured 应该约等于 radius误差一般在亚米级如果发现measured和radius偏差超过 0.5 米大概率是destPoint里的单位或者角度转弧度写错了。4.2 旋转联动时如何保持扇区贴合地图地图支持旋转后旋转过程要实时更新扇形否则旋转结束前用户看到的是一堆错位的图形。但也不能每帧都重建几十个扇形的所有顶点那样性能扛不住。我的方案是分两档旋转过程中rotate 事件高频触发做“轻量级重绘”只更新 PIXI Container 的位置和旋转或者只重新计算已经缓存好的顶点的屏幕坐标不重新生成弧线经纬度顶点旋转结束后通常通过mouseup、touchend或自定义事件做一次完整重绘重新生成所有顶点、文字位置确保精度。旋转角度的监听要看项目具体集成了哪个旋转插件。有的项目是给 map-pane 加 CSS transform有的项目内部有一个统一的 store。不管哪种都建议在旋转角度变化时调用projector.updateRotate(currentRotateAngle); redrawSectors();这里有个容易忽略的点旋转中心必须和底图旋转中心一致。前面 2.3 节已经强调过旋转中心是地图容器中心不是扇形中心。写成代码就是const size map.getSize(); projector.updatePivot({ x: size.x / 2, y: size.y / 2 });在resize事件里也要重新调用否则窗口尺寸改变后旋转中心还是旧的扇形会整体偏移。用这个方案地图任意角度旋转扇形的位置、长度、方向都能和底图瓦片严格对齐。我不建议直接把 PIXI 的整个 stage 用 CSS transform 旋转那样文字、线宽都会被拉伸而且 canvas 上的交互坐标会乱掉。要转就在投影矩阵里转别碰渲染层的 transform。4.3 缩放级别变化时的顶点重采样同一个扇形zoom 10 的时候半径可能只有 20pxzoom 18 的时候变成 3000px。如果顶点数固定不变会出现两种问题低缩放下顶点数太多浪费 GPU高缩放下顶点数不够弧线变成多边形。所以zoomend之后必须重新计算radiusPx、重新生成弧线顶点、重新定位标签。这一整套流程不能少。连续缩放的时候zoomend可能会触发得很频繁。我加了一个简单的防抖let redrawTimer: number | null null; map.on(zoomend, () { if (redrawTimer) return; redrawTimer window.setTimeout(() { redrawSectors(); redrawTimer null; }, 150); });150ms 的防抖既能避免连续缩放时疯狂重绘又不会让用户觉得图形更新有明显延迟。实测下来几十个扇区的重绘耗时在 10ms 量级完全够用。如果你对内存和性能更敏感可以在zoomstart时先把旧的 Graphics 对象clear()掉而不是等zoomend再一次性重建。这样缩放过程中不会出现旧扇形叠加在新扇形上的残影。叶子图层的 canvas 默认不缓存PIXI 的 Graphics 对象重建成本也不高但要注意别在事件回调里闭包引用已经销毁的容器。4.4 用turf和Leaflet自带测距做最终对拍最后说一个我觉得很有价值的验证方法拿第三方库对拍。画出来的扇形到底准不准不能靠肉眼得用数据说话。我的验证脚本会做三件事生成扇形弧线顶点之后随机抽几个顶点用map.distance(centerLatLng, vertexLatLng)和设定半径对比确认所有顶点离中心点的距离误差都在 0.5 米以内如果项目里已经引入了 turf用turf.distance再对拍一遍。turf 默认走 WGS84 椭球模型和 Leaflet 的球面距离会有细微差别但只要都在 0.5 米以内就可以接受旋转地图到几个典型角度30°、45°、90°、180°在画面上取扇区边缘点的 PIXI 屏幕坐标反向做一次逆旋转和map.containerPointToLatLng()再算一次距离验证旋转后长度没有漂移。第 3 步是很多人会跳过的。实际踩坑告诉我如果旋转矩阵的符号写反或者旋转中心取错在 0° 的时候一切正常旋转 90° 后长度偏差能到几十米。反向验证写的辅助函数大概是function screenPxToLatLng(px: { x: number; y: number }, rotateAngle: number) { const cos Math.cos(-rotateAngle); const sin Math.sin(-rotateAngle); const dx px.x - pivot.x; const dy px.y - pivot.y; const rawX pivot.x dx * cos - dy * sin; const rawY pivot.y dx * sin dy * cos; return map.containerPointToLatLng(L.point(rawX, rawY)); }把这个函数包装好每次改完坐标转换逻辑都跑一遍自动化对拍用例能省下大量手动调 bug 的时间。这套 Leaflet PIXI 的扇形雷达绘制方案核心收获其实不是某段代码而是一条思路扇形绘制看着是图形问题本质是坐标系问题。只要把“经纬度生成弧线顶点 → 投影到容器像素 → 套旋转矩阵”这条链路理顺长度精度、旋转联动、缩放宽高全都是同一个转换模块派生出来的附属品。后面你再遇到圆形覆盖范围、扇形扫描区域、热力辐射范围这类需求基本可以复用这套框架改改几何生成函数就行。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进