
第一次接“风玫瑰图”需求时我对着气象部门发来的Excel看了半天里面是某风电场一整年的逐小时测风数据时间、风向、风速三列密密麻麻八千多条记录。对方要的东西说得很朴素“每个方向吹风占多少天、风速有多大画成一张图就行。”我当时脑子里已经有预期这种图大概率要用Echarts来实现因为它生态成熟、配置直观尤其适合数据可视化大屏这类场景。后来这张图在项目汇报时一次通过甲方领导指着一瓣最长的“花瓣”问“这是不是就是主导风向”那一刻我就知道这套实现思路值得沉淀下来分享给更多人。这篇文章适合谁看准备做风电、环保、气象、建筑通风类可视化项目的开发者或者是产品经理想搞懂风玫瑰图原理的再或者是刚接触Echarts极坐标、想找一个能直接抄作业的完整案例的初学者。我会从数据处理的数学逻辑讲到Echarts的极坐标配置再到实际踩过的坑尽量做到一条线讲透。1. 风玫瑰图到底在表达什么1.1 读图逻辑花瓣长度代表频率颜色分层代表风速等级很多人第一次看到风玫瑰图会误以为它是雷达图其实读图逻辑完全不同。雷达图看的是“某项指标在不同维度上的数值分布”而风玫瑰图的每一根“花瓣”代表一个风向扇区花瓣越长说明这个方向吹风的总频率越高。花瓣内部再用不同颜色堆叠出几个风速区间比如0-3m/s、3-6m/s、6-9m/s、9m/s以上这样一眼就能看出“这个方向刮了几级风、占了多少时间”。举个具体例子如果北方向的花瓣总长对应10%其中蓝色段占6%、黄色段占3%、红色段占1%那就说明在统计周期内北风出现了10%的时间其中6%的时间风速在3-6m/s之间。这个信息对风电选址极有价值——风机排布要迎着主导风向才有最大发电效率对城市规划也很重要通风廊道的设计要顺着城市主导风向才能有效扩散污染物。我当时处理这批数据时统计周期是全年8760个小时所以“频率”背后的实际含义就是“小时数”。频率5%约等于438小时这个换算关系在汇报时很有用因为业务方更关心“一年有多少小时在吹这个方向的风”而不是抽象百分比。1.2 为什么轮子选Echarts做风玫瑰图可选的技术路线不少我实际对比过几种方案各有优劣。最原始的做法是用Canvas或SVG从零画灵活性最高但是开发成本也高光是要处理角度映射、扇区圆弧、颜色分层这些事就要花费不少时间而且后期要加tooltip、图例等交互还得自己造轮子。我也试过高性能的D3.js它做自定义可视化确实很强数据绑定思想一流但学习曲线陡峭团队里新上手的人容易卡在enter/exit更新逻辑上。Highcharts虽然也支持极坐标柱状图并且有现成的wind rose示例但商业授权对很多公司是个门槛。最终选了Echarts核心原因是它在“配置驱动”和“可定制性”之间取了一个很好的平衡极坐标Polar体系本身就是Echarts的一等公民这块到目前为止没有明显被短板卡住过。加上它对Vue3和React都有现成的封装方案比如vue-echarts数据驱动更新非常顺滑做数据可视化大屏时也能直接用。方案开发成本交互能力学习曲线适合场景Echarts低强平缓中后台、大屏、快速交付D3.js高强陡峭复杂定制、独特交互Highcharts中强平缓有授权预算的商业项目原生Canvas/SVG极高弱中极致轻量或特殊定制1.3 一张图能用在哪些场景风玫瑰图的应用场景比很多人想象中宽泛。我接过的项目里风电场选址是最典型的通过全年测风数据画出风玫瑰图可以判断主导风向和次主导风向风机排布方式行列式还是错列式直接由这个结果决定。城市规划里也有用城市通风廊道设计需要研究区域主导风向避免高楼阻挡形成静风区。建筑设计领域用得更频繁高层建筑的风荷载估算、自然通风设计都得知道项目所在地的风环境。环保领域则关注大气污染物扩散方向一句话概括凡是关心“风从哪里来、有多大”的行业都可能是风玫瑰图的用户。在数据可视化大屏这个场景里风玫瑰图还有一个天然优势视觉辨识度极高。在一堆折线图、柱状图、饼图中间它那个“花朵”形状特别抓眼球而且信息密度适中非常适合作为大屏中央的展示型图表。2. 绘图之前先把数据“翻译”成图表能吃的格式2.1 气象原始数据长什么样这是最容易被人忽略的一步。很多人拿到数据就直接塞给Echarts结果画出来一团乱麻。气象原始数据通常是长表格结构每一行是一条时间记录核心字段只有三个时间戳、风向、风速。但风向有两种常见格式一种是用角度表示北0°、东90°、南180°、西270°另一种是用16方位缩写表示比如N、NNE、NE、ENE等实测中第一种更常见于自动气象站输出。数据清洗有几个关键动作。第一剔除无效记录比如风向为0或999且风速为0的静风记录这种记录没有方向含义直接参与统计会稀释其他方向频率第二处理缺失值如果某小时没有记录整条丢弃并记录有效样本数在报告里注明“有效样本8760小时缺失12小时”保证数据可信度第三确认方向单位有些气象站输出的是“来向”角度有些可能混入了“去向”需要和业务方核对。2.2 风向分桶与风速分级的计算逻辑风玫瑰图不是把每个原始数据点扔到图上而是先做“分桶统计”。风向分桶最常用的是16方位每个方位占360°的十六分之一也就是22.5°。以正北0°为起点扇区边界依次是0°、22.5°、45°……直到337.5°每一条数据根据它的风向角度落入对应的扇区。风速分级的划分没有绝对标准通常结合行业规范来定。气象上常用蒲福风级但工程上更习惯用连续的阈值我常用的方案是0-3m/s软风到轻风、3-6m/s清风、6-9m/s强风、9m/s以上大风及以上。这套分档在风电场项目中基本够用因为绝大多数测风塔的切入风速在3m/s左右额定风速在10-12m/s所以把0-3和3-6单独拆开能直观看出有多少时间风机在“空转”或“低效运行”。统计逻辑其实很简单先初始化一个16行、N列N为风速等级数的全零矩阵然后遍历每条数据根据风向角度算出扇区下标根据风速大小算出等级下标对应矩阵格子加一。最后把矩阵里每个值除以总样本数再乘以100就得到了频率百分比。2.3 一段可直接复用的数据聚合函数下面这段代码是我实际项目里的简化版可以直接抄走。它接收原始数组返回一个16 × 等级数的频率矩阵以及每个方向的总频率数组后者是用来在tooltip里展示的。function buildWindRoseData(rawData, speedLevels) { const sectorCount 16; const sectorAngle 360 / sectorCount; const levelCount speedLevels.length 1; const counts Array.from({ length: sectorCount }, () new Array(levelCount).fill(0)); // 16方位角度表兼容文本风向 const dirMap { N: 0, NNE: 22.5, NE: 45, ENE: 67.5, E: 90, ESE: 112.5, SE: 135, SSE: 157.5, S: 180, SSW: 202.5, SW: 225, WSW: 247.5, W: 270, WNW: 292.5, NW: 315, NNW: 337.5 }; rawData.forEach(item { const angle typeof item.dir number ? item.dir : dirMap[item.dir]; if (angle null) return; const sectorIndex Math.floor((angle % 360) / sectorAngle) % sectorCount; let levelIndex speedLevels.findIndex(threshold item.speed threshold); levelIndex levelIndex -1 ? levelCount - 1 : levelIndex; counts[sectorIndex][levelIndex]; }); const total rawData.length; const frequencies counts.map(sectorCounts sectorCounts.map(count (count / total * 100)) ); // 每个方向的总频率供tooltip使用 const directionTotal frequencies.map(levels levels.reduce((sum, val) sum val, 0) ); return { frequencies, directionTotal, total }; }这个函数的两个细节值得一说。findIndex找的是第一个满足speed threshold的等级下标所以speedLevels必须从小到大排列如果风速超过了所有阈值findIndex返回-1就归到最后一个等级也就是“以上”那档。角度取% 360是为了防住那种写成了360°的数据避免扇区下标越界。3. 用Echarts画风玫瑰图核心代码与配置拆解3.1 极坐标系的搭建polar、angleAxis、radiusAxisEcharts画风玫瑰图的核心是极坐标系由三个组件组成polar定义坐标系本身angleAxis定义角度轴对应风向radiusAxis定义半径轴对应频率。三者的关系可以类比成polar是一张圆形草稿纸angleAxis在纸上画了一圈刻度线radiusAxis从圆心向圆周画了数根距离标尺。先看基础配置option { polar: [ { radius: [8%, 80%] } ], angleAxis: { type: category, data: [N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW], startAngle: 90, clockwise: false, boundaryGap: true, axisLabel: { fontSize: 12, fontWeight: bold } }, radiusAxis: { min: 0, max: 10, splitNumber: 5, axisLabel: { formatter: {value}% } } };radius: [8%, 80%]的意思是极坐标内边界在容器半径的8%处、外边界在80%处留出空间给标签和图例。这个比例经验值很好用标签太挤就调大内边界柱子太长被截断就调小外边界到90%。angleAxis这里用了category类型这是最容易踩坑的地方我放在第4章详细说这里先记住最关键的两点startAngle: 90让第一个类目从正北开始clockwise: false让扇区按逆时针方向排列。为什么因为气象学里风向角是“北0°、东90°、南180°、西270°”顺时针增加而Echarts默认的角度轴方向不是这样的所以必须调整来贴合气象习惯。如果你的业务方不要求气象习惯想从正东开始逆时针排那把这两项改掉就行。radiusAxis.max我这里是写死10%实际项目里应该根据数据的最大总频率动态计算否则如果某方向频率超过10%柱子就会被截断。下面这段是动态计算的思路const maxTotal Math.max(...directionTotal); const radiusMax Math.ceil(maxTotal / 2) * 2; // 向上取整到2的倍数为什么要取整到2的倍数因为这样纵轴刻度看起来干净比如6.4%会变成8%10.2%会变成12%标签不会出现0.5之类的碎数值。3.2 series堆叠柱从“并列”到“累加”的关键series是风玫瑰图最核心的部分。每个风速等级对应一个bar类型的series必须设置coordinateSystem: polar并且多个series共用一个stack字段这样才能实现“同一方向柱长累加”的效果。const levelConfig [ { name: 0-3m/s, color: #2E7DB8 }, { name: 3-6m/s, color: #48A9C5 }, { name: 6-9m/s, color: #F6C041 }, { name: 9m/s, color: #D64545 } ]; const series levelConfig.map((level, index) ({ name: level.name, type: bar, coordinateSystem: polar, stack: total, data: frequencies.map(row Number(row[index].toFixed(2))), itemStyle: { color: level.color }, barCategoryGap: 15% }));stack: total的解释很重要不写stack四个风速等级的柱子就会在每个方向扇区内并排排列每个柱子都从0开始视觉上就是四根柱子挤在一个扇形里完全不是玫瑰图。写了stack以后它们会在半径方向上一段接一段地累加柱长总和正好等于该方向的总频率。这就是风玫瑰图能同时表达“方向占比”和“等级构成”的原理。很多人会问为什么用柱状图而不是用面积图因为极坐标柱状图在Echarts里就是围绕圆心的“环形扇区”每个数据点占一个固定角度宽度柱子的长度沿半径方向延伸天然就是“花瓣”的形态。如果用散点或折线没法形成这种实心扇形效果。3.3 tooltip、legend和颜色这些细节决定专业感tooltip是风玫瑰图的“信息解释器”。默认的tooltip只能显示“当前风速等级的频率”但业务方通常想知道某个方向的总频率。所以需要自定义formatter。我在项目里用一个外部数组directionTotal配合params.dataIndex来补全信息tooltip: { trigger: item, formatter: function (params) { const dir directionNames[params.dataIndex]; const total directionTotal[params.dataIndex].toFixed(2); const level levelConfig.find(l l.name params.seriesName); const val Number(params.value).toFixed(2); return [ strong${dir}/strong, 总频率${total}%, span styledisplay:inline-block;width:10px;height:10px;border-radius:50%;background:${level.color};margin-right:4px;/span${level.name}${val}% ].join(br/); } }这里有个细节directionTotal数组的顺序必须和frequencies的行顺序一致否则tooltip里显示的总频率就是错的。数据聚合函数里我直接返回了两者天然一致。legend配置相对简单就是把四个风速等级的名称放上去。但有个隐藏问题后面第4章会说图例可点击筛选有时候会坑人。颜色选择上推荐低风速用冷色蓝、青高风速用暖色黄、红这符合直觉——颜色越“热”风越强。3.4 从jQuery/Ajax到Vue3怎么接入业务风玫瑰图的代码写好了最终要接进业务系统。最传统的Web页面场景用jQueryAjax拉数据是最常见的$(document).ready(function () { const chart echarts.init(document.getElementById(windRose)); $.ajax({ url: /api/wind-data, method: GET, dataType: json, success: function (res) { const { frequencies, directionTotal, total } buildWindRoseData(res.data, [3, 6, 9]); const option buildWindRoseOption(frequencies, directionTotal); chart.setOption(option); }, error: function (err) { console.error(拉取气象数据失败, err); } }); window.addEventListener(resize, function () { chart.resize(); }); });Vue3生态下更推荐组件化封装。核心思路是用ref绑定DOM节点onMounted里初始化图表onBeforeUnmount里销毁图表防止内存泄漏。数据变化时用watch触发setOption。script setup import * as echarts from echarts; import { ref, onMounted, onBeforeUnmount, watch } from vue; const chartRef ref(null); let chart null; const windData ref([]); onMounted(() { chart echarts.init(chartRef.value); // 这里假设从接口拿数据 fetch(/api/wind-data).then(res res.json()).then(res { windData.value res.data; renderChart(); }); }); function renderChart() { const { frequencies, directionTotal } buildWindRoseData(windData.value, [3, 6, 9]); const option buildWindRoseOption(frequencies, directionTotal); chart.setOption(option); } window.addEventListener(resize, () chart chart.resize()); onBeforeUnmount(() { chart chart.dispose(); chart null; }); /script template div refchartRef stylewidth:100%;height:520px;/div /template大屏场景里还有一个适配技巧大屏容器尺寸可能是动态计算的甚至在各种分辨率下要等比缩放。我建议用ResizeObserver监听容器尺寸变化而不是只监听window.resize事件因为大屏里的卡片内容可能会折叠或展开容器尺寸变了但浏览器窗口没变。4. 实操踩坑与排查技巧4.1 方位错位起始角和方向的“左右互搏”这是风玫瑰图最容易翻车的地方我见过不止一个同事把北风画到了正东方向。根源是气象学和图形学的角度基准不一致。气象风向0°是正北角度顺时针增大Echarts极坐标默认从某个固定方向开始角度增加方向也不一定合你心意。解决思路有两层。第一层是startAngle它决定了第一个类目也就是data数组的第一个元素落在圆的哪个位置。想要正北朝上就设置startAngle: 90这是我在多个Echarts版本下验证过的经验值。第二层是clockwise它决定了后续类目是顺时针排列还是逆时针排列。我的配置是clockwise: false配合data数组按照N、NNE、NE、ENE……的顺序排列呈现出来的效果才符合气象惯例。排查方法很简单画完图只显示一个类目的数据其他都填0看那一根柱子落在哪个方向和自己预期是否一致。这比瞪着眼睛猜快得多。4.2 扇区间距花瓣之间到底要不要留缝Echarts默认的barCategoryGap是20%左右在普通柱状图里看着还行但在极坐标下会导致一个问题原本应该紧密排列成“花环”的扇区中间出现了一圈明显的空白缝隙视觉上像被切开的饼挺影响美观的。我把barCategoryGap调成15%到20%之间基本能兼顾“扇区边界清晰”和“不露底”。另一个相关参数是barGap它控制的是同一类目下不同series之间的间距。因为我们用了stack同一方向的所有等级段本来就应该紧密连接所以barGap保持默认即可不要乱调调大了会让同一个“花瓣”的四个颜色段之间出现裂缝非常难看。还有个颗粒度问题。16方位每格22.5°柱子比较宽如果数据量足够大想看得更细可以用36方位每格10°甚至72方位每格5°。方位越多花瓣越窄图像越接近一个连续的风速分布热力图。这时候barCategoryGap要相应缩小否则缝隙会很明显。4.3 堆叠图例与tooltip的“显示陷阱”堆叠图有一个反直觉的坑默认图例是可以点击的点击隐藏某个风速等级后柱子的总长度会变短。这在业务上会造成误导——明明某个方向总频率是10%因为隐藏了“9m/s”这一档占1%柱子变成了9%。不了解图例交互的业务方看了会以为统计错了。我的处理方式有两种看项目需要。如果展示需求简单直接把legend的selectedMode设为false禁止点击筛选保证柱子总高度永远代表真实总频率。如果确实需要交互查看单层级那就保留图例筛选但要在图表下方加一行文字提示“点击图例可查看各风速等级单独分布柱子高度为该等级频率。”至少让看图的人知道当前状态。tooltip的坑在前面已经说过了主要就是“默认只显示单series数值缺少总频率”的问题这个用formatter补上就行。还有一个小细节如果trigger用了axis鼠标移动到某个扇区时会同时弹出这个方向上所有等级的数据表格可能很长在大屏上视觉很乱所以我更推荐trigger: item加自定义formatter信息密度刚刚好。4.4 进阶拓展地图叠加和立体效果有些项目需求不止画一张图而是要把多个站点的风玫瑰图叠到地理地图上。Echarts的做法是用geo坐标系配合series的markPoint或scatter把风玫瑰图形做成自定义symbol。具体思路是先为每个站点生成一张风玫瑰图的SVG片段利用Echarts的getDataURL导出图片或者自己拼多边形路径再通过markPoint.symbol设为image://...传入图片URL配合symbolSize控制显示大小。这个方案我已经在多个站点分布场景验证过效果直观且性能可接受站点太多时要注意图片资源大小。至于3D效果网上很多“Echarts 3D饼图”的酷炫效果让人心动但3D风玫瑰图目前不是Echarts的强项。ECharts GL的bar3D并不直接支持极坐标把极坐标映射成三维柱体需要自己算坐标投入产出比不高。我自己的经验是2D极坐标柱状图配上一套精心调过的渐变色和阴影已经能做出很好的大屏视觉冲击力没必要为了炫技硬上3D纯粹增加调试成本。另外补充一个实际项目里常用的小技巧风玫瑰图的配色方案最好不要花哨。我用过一套“蓝青渐变红色高亮”的方案大屏深色背景下效果很理想。核心是把0-3m/s设成深蓝、3-6m/s设成天青、6-9m/s设成橙色、9m/s以上设成红色。为什么这么选因为高频低温的风应该是“冷静”的高频强风是“危险”的颜色直接给人情绪暗示看图的人不用读图例也能大致判断风况。5. 写在最后的个人经验做风玫瑰图这几个月下来我最大的体会是这类项目真正花时间的往往不是Echarts配置本身而是数据准备阶段——风向角度怎么映射、缺失值怎么处理、等级怎么划分、频率怎么计算。配置写错了会立刻显示错误数据算错了图表照样能画出来但业务方一眼就能看出“这图不对”那种返工才是真头疼。所以我建议所有准备做风玫瑰图的人动手敲代码之前先花半小时跑通buildWindRoseData这个函数把你统计数据出的结果打印出来对着业务方要的数据口径一项项核对确认无误再开始写图表配置。代码是死的数据是活的先把数据搞干净后面的图就是个水到渠成的事。另外再分享一个小技巧调试风玫瑰图时不要只用全年完整数据先取一天或者一个小时的极小数据集这样你能手工算出每个方向每个等级的期望值然后对比图表显示的是不是这个值。验证逻辑正确后再切回全量数据效率会高很多。这个习惯帮我避开过不少低级错误。