
上个月接了个需求在百度地图上把台风的24小时和48小时警戒线画出来。听上去很简单——把预报中心点连成一条线不就行了真正做起来才发现这条“线”背后牵扯到坐标系偏移、墨卡托投影、闭合区域生成算法、覆盖物渲染性能一整套问题比想象中复杂得多。这篇东西适合正在做气象可视化、GIS前端、灾害预警系统或者纯粹想在地图上画“业务区域”的同学。我会把从零到一的全过程拆开讲包括为什么不能直接把气象经纬度扔给百度地图、警戒线到底应该用圆还是多边形、24h和48h两层怎么叠才好看以及几个容易让人半夜抓狂的边界问题。1. 警戒线不是线先搞清它到底在表达什么1.1 24小时与48小时警戒区在业务上差在哪很多第一次接触台风可视化的人都会把“警戒线”理解成一条沿台风路径延伸的折线这是最典型的误区。实际上业务上说的“24小时警戒线”“48小时警戒线”指的是台风中心在未来24小时或48小时内可能到达的区域边界是以预报点位为中心、叠加一定影响半径后得到的闭合区域。为什么分两条因为决策节奏完全不同。24小时警戒区代表“马上要准备行动了”通常颜色深、优先级高需要立刻提醒沿海人员、船只避风48小时警戒区则是“提前做预备”重点是提示潜在风险让相关部门有余量去调度资源。所以两条线在视觉上必须能一眼区分不能混成一片。半径怎么定不同单位、不同业务场景差距很大。有些是把每个预报时次的七级风圈半径拿来做外扩有些用固定的经验值比如24h按300公里、48h按480公里。我强烈建议你动手前先问清楚业务口径而不是自己发明一套数值否则画出来的图再漂亮也不具备实际参考价值。代码层面半径做参数化处理就行后续改起来很快。1.2 图形本质闭合包络不是路径折线理解了业务含义技术问题就清晰了我要把一串“带半径的预报点”转换成一个闭合的多边形区域。打个比方把每个预报点想象成一支蘸了颜料的圆头毛笔笔帽半径等于影响半径把每个点都在纸上按一下纸上留下的整体轮廓就是我们要画的警戒区。单一预报点形成的是圆多个预报点叠加后外边界就是一圈不规则的闭合曲线。所以真正的技术难点有两个如何用一组预报点加半径生成这个闭合包络而不是简单把点位连起来。如何把这个闭合区域精准地画到百度地图上并且和底图坐标严格对齐。前者是计算几何问题后者是坐标系和API使用问题。我会按这个顺序往下讲。2. 三个坐标系的坑从WGS84到BD09必须先过两道弯2.1 为什么气象数据的经纬度不能直接扔给百度地图这是整个项目里最容易被忽略、却又最先出错的地方。气象报文里的台风中心经纬度大部分是WGS84标准坐标也就是GPS卫星用的那套全球坐标。但百度地图开放平台对外提供的是BD09坐标系这个坐标是在GCJ02基础上又做了一层非线性偏移。GCJ02就是俗称的“火星坐标”是国内绝大多数地图厂商采用的国测局坐标。直接拿WGS84的经纬度在百度地图上画点整体位置会偏出去一段距离少则几十米多则几百米。这个偏移量单独看一个点可能没那么明显但放在台风警戒区这种几万平方公里的大区域上整个闭合圈会平移出去边界压到不该压的位置。如果沿海地区正好有城镇、港口决策系统跟着这个边界走后果可能很严重。百度地图官方文档也说明它只支持BD09坐标输入。所以正规流程是WGS84先转GCJ02GCJ02再转BD09两步缺一不可。不要试图跳过中间步骤也不要相信某些“近似算法”能一次性到位我实测下来老老实实转两次最稳。2.2 转换代码与验证方法经典转换代码网上很多我用的版本长这样核心是WGS84到GCJ02的偏移计算再加上GCJ02到BD09的极坐标变换const PI Math.PI; const A 6378245.0; const EE 0.006693421622965943; function outOfChina(lng, lat) { return (lng 72.004 || lng 137.8347) || (lat 0.8293 || lat 55.8271); } function transformLat(x, y) { let ret -100.0 2.0 * x 3.0 * y 0.2 * y * y 0.1 * x * y 0.2 * Math.sqrt(Math.abs(x)); ret (20.0 * Math.sin(6.0 * x * PI) 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(y * PI) 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0; ret (160.0 * Math.sin(y / 12.0 * PI) 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0; return ret; } function transformLng(x, y) { let ret 300.0 x 2.0 * y 0.1 * x * x 0.1 * x * y 0.1 * Math.sqrt(Math.abs(x)); ret (20.0 * Math.sin(6.0 * x * PI) 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(x * PI) 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0; ret (150.0 * Math.sin(x / 12.0 * PI) 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0; return ret; } function wgs84ToGcj02(lng, lat) { if (outOfChina(lng, lat)) return [lng, lat]; let dLat transformLat(lng - 105.0, lat - 35.0); let dLng transformLng(lng - 105.0, lat - 35.0); const radLat lat / 180.0 * PI; let magic Math.sin(radLat); magic 1 - EE * magic * magic; const sqrtMagic Math.sqrt(magic); dLat (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); dLng (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI); return [lng dLng, lat dLat]; } function gcj02ToBd09(lng, lat) { const z Math.sqrt(lng * lng lat * lat) 0.00002 * Math.sin(lat * PI); const theta Math.atan2(lat, lng) 0.000003 * Math.cos(lng * PI); return [z * Math.cos(theta) 0.0065, z * Math.sin(theta) 0.006]; } function wgs84ToBd09(lng, lat) { const [gjLng, gjLat] wgs84ToGcj02(lng, lat); return gcj02ToBd09(gjLng, gjLat); }代码转好了怎么确认转换没写错我的土办法是用城市地标验证。找个本地地标的GPS实测坐标也就是WGS84转成BD09后用百度地图“拾取坐标系统”对比一下落在哪里。如果落点正好压在地标建筑上说明转换链路没问题。更工程化的验证方式转换前后都生成Point然后用map.pointToPixel把经纬度转成屏幕像素看两个点在当前缩放级别下的像素差再换算成实际距离。正常情况下应该和网上常说的偏移量数量级一致。2.3 别忘了申请AK和配置Referer白名单坐标系搞完还有个很容易卡住新手的坑地图白屏、控制台报BMapGL is not defined十有八九是AK配置问题。去百度地图开放平台创建一个“浏览器端”应用拿到AK后要把页面域名填进Referer白名单。本地开发可以临时配成*或者localhost但上线前一定要收紧只留实际域名。AK相当于这个地图实例的钥匙泄漏了可能被别人刷流量。引入脚本时GL版本和普通JS API的入口不一样我这里是script typetext/javascript srchttps://api.map.baidu.com/api?typewebglv1.0ak你的AK/script如果你用的是JS API 3.0入口是v3.0全局对象从BMapGL换成BMap后面的Polygon、Circle用法基本一致。3. 警戒区生成算法把预报点变成一条平滑闭合曲线3.1 三种思路的取舍圆圈、凸包还是地理缓冲我在做方案时对比过三种生成警戒区的路径方案优点缺点适合场景每个预报点画一个圆Circle实现最简单代码量最少多个圆互相独立边界不闭合填充效果混乱快速预览、临时调试圆周采样 凸包纯前端可写轻量可控能得到闭合区域路径凹进去的部分会被凸包填平一般可视化项目、路径较平缓Turf.js buffer真正符合地理语义能处理凹包支持球面距离引入依赖需要先把坐标统一到WGS84再算生产系统或后端已经用GeoJSON第一种方案我很快就否了原因是业务要的是“一条警戒线”不是一个一个的圆堆在一起。一堆圆叠起来边界犬牙交错根本没法作为决策依据。第三种方案很专业适合后端强处理但如果项目前端为主、对Turf不熟或者数据量不大用第二种方案完全够而且还不用引入额外依赖。所以最终我选了“圆周采样 凸包”作为主方案。核心思路很简单把每个预报点按指定半径生成一圈圆周采样点把所有采样点丢进凸包算法最终得到的就是包含所有这些点在内的最小凸多边形也就是警戒区边界。3.2 工程化实现圆周采样 凸包附代码先解决一个容易想当然的细节以某个点为中心生成半径R的圆周点能不能直接用lng R / 111不行。在地球表面1°纬度对应的距离大约是111公里这没问题但1°经度对应的距离是111.32 * cos(lat)公里。纬度30°时1°经度实际距离只有约96公里到纬度45°这个值会掉到约79公里。如果直接用lng R / 111这种算法在高纬度地区画出来的圆会在东西方向上明显偏大整个警戒区被拉宽而且越往北越严重。所以必须用球面上的方位角偏移公式给定中心点经纬度、方位角bearing和距离算出目标点经纬度function offsetPoint(lng, lat, bearingDeg, distanceKm) { const R 6371; const lat1 lat * Math.PI / 180; const lng1 lng * Math.PI / 180; const bearing bearingDeg * Math.PI / 180; const lat2 Math.asin( Math.sin(lat1) * Math.cos(distanceKm / R) Math.cos(lat1) * Math.sin(distanceKm / R) * Math.cos(bearing) ); const lng2 lng1 Math.atan2( Math.sin(bearing) * Math.sin(distanceKm / R) * Math.cos(lat1), Math.cos(distanceKm / R) - Math.sin(lat1) * Math.sin(lat2) ); return [lng2 * 180 / Math.PI, lat2 * 180 / Math.PI]; }每次按5°方位角采样一个点一圈就是72个点。如果路径上有8个预报点采样点总数是576个凸包计算毫无压力。步长越小圆越平滑但顶点数会翻倍后面渲染压力也会上来5°基本够了。凸包我用Andrew单调链算法简单稳定function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); } function convexHull(points) { if (points.length 3) return points.slice(); const pts points.slice().sort((a, b) a[0] b[0] ? a[1] - b[1] : a[0] - b[0]); const lower []; const upper []; for (const p of pts) { while (lower.length 2 cross(lower[lower.length - 2], lower[lower.length - 1], p) 0) lower.pop(); lower.push(p); } for (let i pts.length - 1; i 0; i--) { const p pts[i]; while (upper.length 2 cross(upper[upper.length - 2], upper[upper.length - 1], p) 0) upper.pop(); upper.push(p); } lower.pop(); upper.pop(); return lower.concat(upper); }把上面的东西串起来function collectHullPoints(points, maxForecastHour, radiusKm) { const samples []; for (const pt of points) { if (pt.forecastHour maxForecastHour) continue; const [bdLng, bdLat] wgs84ToBd09(pt.lng, pt.lat); for (let bearing 0; bearing 360; bearing 5) { const [oLng, oLat] offsetPoint(bdLng, bdLat, bearing, radiusKm); samples.push([oLng, oLat]); } } return convexHull(samples); }这里我特意把坐标转换放在采样之前也就是先在BD09下做圆周采样。对于几百公里尺度的台风警戒区来说这个近似误差可以接受。如果追求极致精度就在WGS84下用Turf算好buffer最后再整体转成BD09绘制两种路线殊途同归。3.3 算法退化的情况怎么兜底算法看着简单遇到边界数据就会翻车。最常见的情况是路径点只有1个比如台风刚编号只给了一个当前中心位置。这时凸包算法返回的只有一圈采样点组不成一个多边形直接拿它去new Polygon会出问题。兜底方案就是退化成绘制一个Circle覆盖物中心就是这个点半径就是警戒半径。如果只有2个预报点凸包会退化成一条线段视觉上像一条线没有面积。处理方式有两种一是干脆对两个点各自采样再把两个圆的所有采样点一起丢进凸包这样能得到一个类似橄榄球的闭合区域二是直接用BMapGL.Polyline画成一条带宽度提示的线但观感不如闭合区域好。我建议第一种代码几乎不用改只要采样逻辑没写死成“至少3个点”就行。这些兜底逻辑看起来不起眼但实际项目里台风路径数据千奇百怪少了这些判断线上就可能在某个凌晨报警页面崩掉。4. 在BMapGL上落地两条警戒线的完整绘制链路4.1 初始化地图与数据预处理地图初始化和普通的百度地图应用没有区别。我通常会把初始缩放级别放到6左右这样在多数屏幕上能覆盖整个台风路径又不会让警戒区边界过于拥挤。const map new BMapGL.Map(map); const initPoint new BMapGL.Point(125, 24); map.centerAndZoom(initPoint, 6); map.enableScrollWheelZoom(true);数据从后端接口拿到后建议先做一次标准化。我用的结构是const typhoon { id: TY-2024-01, points: [ { lng: 125.3, lat: 18.2, forecastHour: 0, radiusKm: 180 }, { lng: 124.1, lat: 19.5, forecastHour: 6, radiusKm: 190 }, { lng: 122.9, lat: 21.1, forecastHour: 12, radiusKm: 200 }, // 一直到 forecastHour: 72 ] };forecastHour是预报时效0代表当前实况位置。radiusKm是我额外加的字段如果后端能给出每个时次的风圈半径后面每点采样时就用它替代统一半径。4.2 绘制24h和48h两层警戒区核心绘制函数长这样function drawWarningLayer(maxForecastHour, radiusKm, color, fillOpacity) { const hull collectHullPoints(typhoon.points, maxForecastHour, radiusKm); if (hull.length 3) { // 退化为圆形这里省略兜底逻辑 return null; } const path hull.map(p new BMapGL.Point(p[0], p[1])); const polygon new BMapGL.Polygon(path, { strokeColor: color, strokeWeight: 3, strokeOpacity: 0.9, fillColor: color, fillOpacity: fillOpacity, strokeStyle: solid }); map.addOverlay(polygon); return polygon; } const layer24 drawWarningLayer(24, 300, #ff4d4f, 0.2); const layer48 drawWarningLayer(48, 480, #ffa940, 0.15);这里有个细节值得注意collectHullPoints里forecastHour maxForecastHour也就是说24h警戒区会把0到24小时之间的所有预报点都纳入采样48h警戒区则覆盖0到48小时。因为48小时的半径更大、要包进的点更多所以画出来后48h区域天然包含24h区域视觉上形成嵌套关系。如果业务要求的是“24到48小时新增可能影响的区域”那就不是嵌套包含而是多边形差集问题可以用Turf的turf.difference把24h区域从48h区域里挖掉。先想清楚要哪种语义再去选算法。4.3 更新与删除定时刷新的正确姿势台风预报报文通常每6小时更新一次所以页面一般会有个定时器去拉最新数据然后重新绘图。更新的时候不要图省事用map.clearOverlays()。这个方法会把图上所有覆盖物清空包括你自己加的路径点Marker、坐标网格、其他业务图层。正确做法是维护一个数组记录当前警戒区多边形更新时逐个移除再重建const activeLayers []; function refreshWarningLayers() { activeLayers.forEach(layer map.removeOverlay(layer)); activeLayers.length 0; const layer24 drawWarningLayer(24, 300, #ff4d4f, 0.2); const layer48 drawWarningLayer(48, 480, #ffa940, 0.15); if (layer24) activeLayers.push(layer24); if (layer48) activeLayers.push(layer48); } setInterval(refreshWarningLayers, 6 * 60 * 60 * 1000);移除后记得清空数组引用避免内存一直涨。虽然JavaScript有GC但在长期运行的监控大屏页面里手动释放仍然是个好习惯。5. 视觉与交互让两层警戒区既醒目又不遮挡底图5.1 配色、透明度与图层顺序两条警戒区叠在一起最容易出现的问题是分不清优先级。我的配色方案是24h警戒线红色填充透明度0.248h警戒线橙色填充透明度0.15填充用半透明而不是纯色。原因很简单半透明能让底图上的海岸线、城市名透出来业务人员能同时看到“警戒区”和“具体位置”两个信息。如果不透明海面一片死色地图底图等于白底。图层顺序上先画48h再画24h保证24h区域盖在48h上面视觉重心自然落在更紧急的24h上。如果两个区域叠加后重叠处颜色因为透明度叠加变深这反而符合直觉——重叠区意味着双重潜在影响应该更显眼。如果客户明确说“只要警戒线不要填色”那更简单把fillOpacity设为0Polygon就只剩下一圈描边看起来就是纯粹的一条闭合曲线。5.2 图例、悬浮提示与点击详情地图上只有两条线不配图例的话外人根本分不清红和橙分别代表什么。我习惯用HTML绝对定位放一个图例块简单干净div styleposition:absolute;top:12px;right:12px;z-index:999;background:#fff;padding:8px 12px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,0.2);font-size:12px;line-height:1.8; divspan styledisplay:inline-block;width:14px;height:3px;background:#ff4d4f;margin-right:6px;/span24h警戒区/div divspan styledisplay:inline-block;width:14px;height:3px;background:#ffa940;margin-right:6px;/span48h警戒区/div /div交互层面可以给每个Polygon绑定事件。比如点击警戒区弹出一个信息窗口展示影响半径、覆盖的预报时段polygon.addEventListener(click, (e) { const infoWindow new BMapGL.InfoWindow( div台风编号TY-2024-01/divdiv警戒时段未来48小时/divdiv影响半径480km/div, { width: 180, height: 80 } ); map.openInfoWindow(infoWindow, e.latlng); });这里有一个用心的小细节InfoWindow打开的位置要用e.latlng也就是用户点击地图上的实际位置而不是警戒区中心。如果直接用中心点用户点的是边缘窗口却弹在正中间视觉上非常别扭。5.3 反面案例把每个预报点直接铺Circle的效果有同学可能会问既然每个点画Circle那么简单为什么非要绕一圈做凸包我把之前的反面案例描述一下你就有体感了。假设路径上有8个预报点每个点画一个半径为300公里的Circle填透明度0.2屏幕上一眼望去是8个互相交叠的圆片边缘犬牙交错远远看去像一串泡泡。你无法快速回答“警戒区边界到底在哪”因为每个圆的边界都可能成为视觉焦点。而且如果是24h和48h两组Circle叠加那就是16个圆在互相干扰。另外一个被忽略的问题是性能。Circle在百度地图GL里本质上也会被切分成多边形渲染数量一多缩放拖拽时的重绘压力明显上升。我在后面性能章节会再展开。所以无论从视觉还是性能角度结论都是一致的业务区域用Polygon不要靠Circle硬堆。6. 性能与边界问题实际项目中最耗时间的地方6.1 覆盖物数量和浏览器的抗争台风路径页面往往不只是两条警戒线通常还有历史路径点、当前中心位置Marker、未来预报点、风圈半径参考线。如果这些全都用覆盖物堆上去浏览器渲染压力会直线上升。我见过把几百个历史点位全部用Marker渲染的代码拖动地图时明显掉帧。优化思路有几个第一能用一条Polyline画的历史路径就不要拆成几百个Marker。路径线本质上是一个点数组Polyline的渲染效率远超等量Marker。第二Marker数量真的很多时考虑用BMapGL.Label替代部分Marker或者干脆用自定义Canvas图层去画点覆盖物层面只保留少量关键标注。第三数据层面按视野范围裁剪。如果台风历史样本库很大比如要展示过去几十年的路径前端请求时带上当前地图bounds参数后端只返回视野内的数据不要把所有路径一次性灌给浏览器。我在这个项目里实测下来警戒区两个Polygon的顶点数加起来不到1500个完全在BMapGL舒适区内真正卡顿的从来不是这两个面而是其他乱七八糟的覆盖物。6.2 数据异常与边界场景的处理这部分是实际项目里最耗时的地方比写算法本身更磨人。先说缺报。台风预报点列表偶尔会缺某个时次比如只有0、6、12、24小时中间跳过了18小时。算法上跳过缺失点问题不大但要注意每个警戒区至少要凑够3个有效预报点否则会退化。所以刷数据后要先做校验const p24 typhoon.points.filter(p p.forecastHour 24); if (p24.length 3) { // 走降级方案比如只画一个圆或者提示数据不足 }再说跨180°经线。台风路径如果穿过国际日期变更线附近相邻点经纬度可能从179.9度直接跳到-179.9度如果直接采样和连线会拉出一条横跨地图的直线。我的处理方式是在数据预处理阶段检测相邻点经度差值如果绝对值超过180就把后续点统一加360做偏移计算等生成凸包点串后再对超过180的点减360分两段绘制。这个逻辑虽然烦但遇到一次就能救命。最后是半径异常。后端返回的风圈半径偶尔会有脏数据比如0或者几千公里。画图前要对半径做上下限过滤通常设一个10到1000公里的区间超出就取默认值。报警页面宁可显示一个保守估计也不能因为脏数据画出覆盖半个地图的荒谬区域。6.3 现在让我重做一次我会这样设计如果让我重新做一遍这个项目我会把坐标转换和警戒区算法完全放到后端去前端只负责渲染。后端直接用Python的Shapely库对预报点做Buffer操作生成GeoJSON多边形WGS84坐标一路算到底然后前端拿到GeoJSON后再统一转BD09转完直接喂给BMapGL的Polygon。这样做的好处是算法可以写单元测试数据异常可以在后端代码里统一处理前端代码只关注展示和交互职责清晰。毕竟前端再怎么优化算这种地理空间包络终究不如成熟GIS库严谨。但如果你和我一样只是做一个中小型可视化页面没有后端支持前端用“圆周采样凸包”这套方案完全够用。它的精度足以支撑业务判断代码可维护性也好。最后说句实在的地图上的每条“线”都不只是一条线尤其这种带着业务语义的警戒区域一定先把它的含义、坐标系、算法边界全部想清楚再打开编辑器。顺序反了后面大概率要推翻重来。