尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Echarts风速玫瑰图实战:极坐标配置与Vue3封装指南

Echarts风速玫瑰图实战:极坐标配置与Vue3封装指南 先说个真实经历去年做气象数据可视化大屏产品指着设计稿跟我说“这里要一个Echarts的风速玫瑰图跟别人家不一样要有花瓣感”。我当时第一反应是“这不就是饼图换个角度”结果真上手才发现玫瑰图在Echarts里根本不是饼图而是极坐标系下的柱状图。它用角度表达风向用半径长度表达风速或频率靠这个把“哪个方向风多、哪个方向风大”这种二维信息压进一张图里信息密度比普通柱状图高一个量级。这篇文章就把我做风力风速玫瑰图的全过程拆开讲从气象数据怎么聚合成16个方向到Echarts极坐标配置的每一个关键参数再到Vue3大屏项目里的组件封装和自适应适配最后把我踩过的几个坑一并列出来。适合刚接触Echarts数据可视化、或者已经在做气象/能源/环境类大屏但还没搞懂玫瑰图配置的朋友。1. 玫瑰图到底在表达什么风速风向的另一种打开方式1.1 为什么不用折线图或柱状图气象数据最原始的样子是一长串按时间排列的风向和风速记录。用折线图画出来风速是一堆锯齿风向是-180到180之间疯狂跳变的折线别说领导看不清我自己盯着看两分钟都头晕。问题的根源在于风向是角度量不是线性量。你没法在普通直角坐标系里自然地表达“北”和“北偏东22.5度”之间的连续性更没法直观看出“这个站点冬天的风主要集中在西北到北这个扇区里”。而玫瑰图天生就是干这个的——它的坐标系本身就是极坐标角度轴天然就是为风向设计的。1.2 玫瑰图的三种常见变体不同业务场景下玫瑰图画的东西不一样先搞清楚再动手风向频率玫瑰图统计每个方向的风出现次数占总次数的百分比柱子越长代表这个方向“风越来越多”。城市规划、污染扩散分析常用这种。平均风速玫瑰图每个方向上的风速取平均值柱子越长代表这个方向“风越大”。风电场选址、建筑风环境分析用得多。堆叠风速玫瑰图每个方向按照风速等级比如0-3m/s、3-6m/s、6-9m/s分别统计占比一个扇区里分几段颜色。这是最完整的一种信息量最大但配置也最复杂。我这次做的是第二种——平均风速玫瑰图因为大屏上要突出“主导风向”和“强风方向”两个信息单系列最干净图例和配色也不会挤。1.3 和饼图、雷达图的本质区别很多第一次接触玫瑰图的人会问为什么不用饼图饼图每个扇区的角度占比表示数值占比它只有“占比”这一个维度玫瑰图在极坐标下扇区的角度是固定的类别位置16个方位半径才是数值。用一个不严谨但好记的说法饼图是靠“切角大小”骗眼睛玫瑰图是靠“半径长短”说话。雷达图倒是和玫瑰图有点像但雷达图是线性的闭合多边形强调各维度的对比玫瑰图是圆形的扇形填充强调方向分布和归一化在气象场景下默认就是用玫瑰图而不是雷达图。2. 数据准备把气象风数据变成16个扇区的数字2.1 原始数据长什么样不论数据来自气象站、测风塔还是ERA5再分析资料最终到手的通常是这样一张表时间风向度风速m/s2024-01-01 00:003405.22024-01-01 01:003556.12024-01-01 02:00153.82024-01-01 03:00872.1这里有个气象学的基本约定必须注意风向是指风的来向。340度表示风从西北偏北方向吹过来不是吹向西北。做玫瑰图时这个角度直接对应极坐标的方位角不需要做180度的反向修正。2.2 为什么要聚合成16个方位理论上可以把0到359度每个整数度都做成一个柱子360个扇区看起来像一朵密度极高的“菊花”数值上更精细但视觉上完全是噪音label也根本放不下。行业惯例是分成8方位或16方位。8方位就是N、NE、E、SE、S、SW、W、NW每格45度16方位是在8方位中间再插一条每格22.5度精度足够又不会太密。我做16方位因为风向的跃迁往往在22.5度这个尺度上还能看出规律45度会把一些过渡方向抹掉。16方位名称如下索引方位角度范围0N348.75 ~ 11.251NNE11.25 ~ 33.752NE33.75 ~ 56.253ENE56.25 ~ 78.754E78.75 ~ 101.255ESE101.25 ~ 123.756SE123.75 ~ 146.257SSE146.25 ~ 168.758S168.75 ~ 191.259SSW191.25 ~ 213.7510SW213.75 ~ 236.2511WSW236.25 ~ 258.7512W258.75 ~ 281.2513WNW281.25 ~ 303.7514NW303.75 ~ 326.2515NNW326.25 ~ 348.752.3 聚合计算代码写一个纯前端的聚合函数把原始时序数据变成玫瑰图需要的数组。这个函数我直接用JavaScript写方便在静态Demo里演示真实项目里可以把这个逻辑放到后端但算法完全一样function aggregateWindRose(data, bins 16) { // data: [{ direction: number, speed: number }] const step 360 / bins; const sums new Array(bins).fill(0); const counts new Array(bins).fill(0); data.forEach((item) { // 把风向角度映射到最近的方位索引 // step / 2 是为了让边界角度归入正确的扇区 const idx Math.floor(((item.direction % 360) step / 2) / step) % bins; sums[idx] item.speed; counts[idx] 1; }); // 返回每个方位的平均风速没有数据的方向返回0 return sums.map((sum, i) (counts[i] 0 ? 0 : (sum / counts[i]).toFixed(2))); }这个函数有个容易被忽略的细节风向角度的取模。数据里可能出现360度如果直接除以22.5会得到16数组越界。所以必须先direction % 360。边界角度比如11.25度这条线归到哪边都说得通加半个step再取整会更稳定。2.4 一份可以直接用的示例数据后面所有配置我都以这份数据为例方便你直接复制跑出效果// 顺序必须和方位数组一一对应从N开始顺时针 const roseData [4.5, 3.8, 2.6, 2.1, 2.4, 3.0, 3.6, 4.2, 5.0, 4.8, 3.9, 3.1, 2.8, 3.3, 4.0, 4.7]; const windDirs [N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW];这组数据模拟的是某个站点冬季典型风况北风、南风偏强东西风偏弱。你会看到后面玫瑰图呈现南北方向花瓣长、东西方向花瓣短的形态这就是实际气象数据常见的“主导风向”现象。3. Echarts极坐标系配置玫瑰图的核心骨架与series选型3.1 基础配置polar angleAxis radiusAxisEcharts做玫瑰图的推荐方式不是pie漏斗改造而是建立在极坐标系上的柱状图。核心是三件套polar定义极坐标位置和大小angleAxis定义角度轴对应风向radiusAxis定义半径轴对应风速数值最后用type: bar的series挂到coordinateSystem: polar上。一个最简但是能跑的option长这样const option { polar: { center: [50%, 50%], radius: 70% }, angleAxis: { type: category, data: windDirs, startAngle: 90, clockwise: true, axisLabel: { fontSize: 12, color: #7a8a9e }, axisLine: { lineStyle: { color: rgba(255,255,255,0.2) } }, axisTick: { show: false }, splitLine: { show: false } }, radiusAxis: { min: 0, max: 6, axisLabel: { formatter: {value} m/s, color: #7a8a9e }, splitLine: { lineStyle: { color: rgba(255,255,255,0.15), type: dashed } } }, series: [ { type: bar, coordinateSystem: polar, data: roseData, itemStyle: { color: #2f7cf6, opacity: 0.8 } } ] };这里有三个参数新手基本都会踩坑我逐一讲清楚startAngle: 90。这个参数决定了第一个类目在极坐标上的起始方位。Echarts默认startAngle是90正好指向12点钟方向。再配合clockwise: true默认就是true数据会从12点方向开始顺时针排列。这正好符合气象玫瑰图的习惯——北在上、方位顺时针排布。如果你的第一个数据是N那么这样配出来就是标准的“北风朝上”的玫瑰图。radiusAxis的max要手动指定。如果让Echarts自动计算max数据最大值是5的时候max可能算成5.5或者6.5这种带小数的刻度视觉上不干净。做可视化大屏时我习惯手动给一个略大于实际最大值的整数。最大值6对于这组数据的5.0是合适的。极坐标下的bar没有柱宽的概念它的“宽度”是角度。这一点和直角坐标系柱状图完全不同不需要设置barWidth扇区的角度大小由angleAxis的类目数量自动均分。3.2 为什么是bar而不是pie我在1.3节说过玫瑰图和饼图的区别这里补充一个更本质的视角pie系列在极坐标下渲染时每个扇形共享半径只有角度维度承载数值而bar系列在极坐标下每个类目占据的角度是均匀的真正承载数值的是从圆心到外圈的半径距离。Echarts内部对极坐标bar的渲染逻辑就是沿每个角度方向画一个矩形在极坐标下变成扇环矩形半径越长柱子越高这和饼图的几何语义完全不同。如果你非要用pie的roseType来画半径玫瑰图你会发现它表达的是“占比”而你需要的是“绝对值”这两者在视觉上看着像数据和业务含义却差之千里。所以别图省事老老实实用bar挂polar。3.3 堆叠多系列风速等级频率玫瑰图的配置思路如果你的业务需要把风向和风速等级联合起来看可以在series里加多个bar系列每个系列代表一个风速区间然后把stack字段设为同一个值让它们沿着半径方向堆叠。配置示意series: [ { name: 0-3m/s, type: bar, coordinateSystem: polar, stack: wind, data: [1.2, 1.0, 0.8, /* ... */] }, { name: 3-6m/s, type: bar, coordinateSystem: polar, stack: wind, data: [2.5, 2.1, 1.5, /* ... */] }, { name: 6-9m/s, type: bar, coordinateSystem: polar, stack: wind, data: [0.8, 0.7, 0.3, /* ... */] } ] // legend 就需要显示出来了 legend: { data: [0-3m/s, 3-6m/s, 6-9m/s], textStyle: { color: #ccc } }堆叠图里每个方向的柱子总长度表示该方向的总频率或总时长各颜色段表示不同风速等级的占比。这时候legend才真正发挥作用否则单系列玫瑰图根本不需要legend。堆叠系列要注意每个系列的数据必须同顺序、同长度而且要保证每个方向至少有一个系列数据避免空扇区把整个色块断开。4. 让玫瑰图能上台面配色、label、tooltip与图例的细节处理4.1 大屏深色主题下的配色逻辑大屏可视化背景几乎都是深色玫瑰图在这种环境下的配色既要有辨识度又不能刺眼。我个人的经验是先定主色再用透明度分层。主色可以选择蓝色系#2f7cf6因为它和深蓝背景有自然的层次关系。在一个系列的情况下如果所有扇区都用同一个颜色视觉上会显得“糊”所以我会给每个扇区按数值大小分配不同透明度数值大的透明度低更实数值小的透明度高更虚。这样不需要图例就能让眼睛快速定位到“哪个方向风最大”。用一个循环生成每个扇区的颜色const maxVal Math.max(...roseData); const baseColor [47, 124, 246]; // RGB of #2f7cf6 const colors roseData.map((v) { const alpha 0.35 0.55 * (v / maxVal); return rgba(${baseColor[0]}, ${baseColor[1]}, ${baseColor[2]}, ${alpha.toFixed(2)}); });这个方案有一个很明显的优点不需要二次legend说明颜色深浅因为颜色深浅和数值大小的对应关系是直觉性的大屏上看一眼就懂。4.2 每个扇区的渐变效果怎么做很多人想给扇区做“从内到外渐变”的立体感。这里要提醒一个坑Echarts极坐标bar的itemStyle.color支持LinearGradient但这个渐变的x、y坐标是基于直角坐标系的放在极坐标下不会自动变成径向渐变。直接写LinearGradient(0, 0, 1, 0, ...)出来的效果是所有扇区共享同一个水平方向渐变看起来非常奇怪。我试过几种方案最终稳定可用的做法有两种第一种放弃真正的径向渐变改用同色系浓度渐变——也就是每个扇区用一个纯色但在扇区外圈加一圈亮色border模拟“边缘高光”itemStyle: { borderColor: rgba(255, 255, 255, 0.6), borderWidth: 1, borderType: solid }第二种用直角坐标系的LinearGradient做整体从左到右的色彩过渡再叠加一个半透明的深色蒙层。这种适合整张大屏的色调统一但单个扇区的立体感不强。如果你非要径向渐变实现方式是在series的data里给每一项单独配置渐变对象并且把渐变的坐标对齐到极坐标中心。不过实测这个方案在部分Echarts版本里渲染会有锯齿性能也差所以我建议大屏项目直接用“透明度分层border高光”的方案就够了视觉上干净、渲染快还不会出现渐变方向错乱的尴尬。4.3 label和tooltip把数字信息嵌进图里玫瑰图的扇区多了以后label放不放、放哪里直接决定这张图的信息完整度。单系列平均风速玫瑰图我建议每个扇区都显示数值position: outside字体小一点颜色浅一点。配置如下label: { show: true, position: outside, formatter: (params) params.value 0 ? params.value.toFixed(1) : , fontSize: 10, color: #a8b6c7 }有个细节值得强调风速为0的方向label不要显示“0.0”那一整排0会把图弄得全是数字点应该直接显示空字符串。这在formatter里用一个三元判断就能解决。tooltip是交互时补充信息的入口默认只显示数值和方向索引不够友好。大屏上领导hover过去希望看到的是人能读懂的文案所以formatter里自己拼字符串tooltip: { trigger: item, formatter: (params) { const dirNames [北, 北东北, 东北, 东东北, 东, 东东南, 东南, 南东南, 南, 南西南, 西南, 西西南, 西, 西西北, 西北, 北西北]; const dir dirNames[params.dataIndex]; return ${dir}风br/平均风速${params.value} m/s; } }这里要注意触发类型推荐trigger: item而不是axis。在极坐标下axis触发很容易因为鼠标扫过圆心区域而出现奇怪的十字线item触发干净利落。4.4 动画节奏对大屏展示的影响大屏首次加载时玫瑰图从圆心“绽放”出来的动画很有仪式感但很多人忽略了一个问题如果大屏有定时器轮询数据每次setOption都会重播动画整个图就会一直闪。我把动画配置分成两段animation: true, animationDuration: 800, animationEasing: cubicOut, animationDurationUpdate: 300, animationEasingUpdate: quarticOutanimationDuration控制首帧动画animationDurationUpdate控制数据更新时的过渡。更新动画时间短、缓动效果更平滑数据刷新时不会有“炸开”的突兀感。如果你的大屏是那种秒级刷新建议直接把animationDurationUpdate降到0否则视觉上会一直有重新生长动画。5. Vue3项目里的落地组件封装、数据更新与容器自适应5.1 按需引入Echarts而不是全量引入Vue3 Vite的大屏项目最忌讳的就是import * as echarts from echarts打包体积直接多出1MB以上。玫瑰图用到的模块其实就那几个按需引入的写法我已经固定下来了import * as echarts from echarts/core; import { BarChart } from echarts/charts; import { PolarComponent, AngleAxisComponent, RadiusAxisComponent, TooltipComponent, GridComponent } from echarts/components; import { CanvasRenderer } from echarts/renderers; echarts.use([ BarChart, PolarComponent, AngleAxisComponent, RadiusAxisComponent, TooltipComponent, GridComponent, CanvasRenderer ]);注意几个点极坐标的PolarComponent是必须的但很多人会漏掉AngleAxisComponent和RadiusAxisComponent导致运行时图表一片空白且控制台报“Component angleAxis is used but not imported”。另外这里用CanvasRenderer就够了SVGRenderer在这种大量扇区的场景下渲染性能和交互流畅度反而不如Canvas。5.2 封装一个可复用的WindRose组件我习惯把所有echarts图都封装成通用组件但玫瑰图有一些自己的初始化细节单独封装成WindRoseChart.vue更清晰。核心代码如下template div refchartEl classwind-rose-chart :style{ width: 100%, height: height px }/div /template script setup import { ref, onMounted, onBeforeUnmount, watch } from vue; import * as echarts from echarts/core; import { BarChart } from echarts/charts; import { PolarComponent, AngleAxisComponent, RadiusAxisComponent, TooltipComponent } from echarts/components; import { CanvasRenderer } from echarts/renderers; echarts.use([ BarChart, PolarComponent, AngleAxisComponent, RadiusAxisComponent, TooltipComponent, CanvasRenderer ]); const props defineProps({ height: { type: Number, default: 320 }, roseData: { type: Array, required: true }, windDirs: { type: Array, default: () [N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW] }, maxSpeed: { type: Number, default: 6 } }); const chartEl ref(null); let chart null; function calcColors() { const maxVal Math.max(...props.roseData, 1); const baseColor [47, 124, 246]; return props.roseData.map((v) { const alpha 0.35 0.55 * (v / maxVal); return rgba(${baseColor[0]}, ${baseColor[1]}, ${baseColor[2]}, ${alpha.toFixed(2)}); }); } function buildOption() { return { backgroundColor: transparent, polar: { center: [50%, 52%], radius: 70% }, angleAxis: { type: category, data: props.windDirs, startAngle: 90, clockwise: true, axisLabel: { fontSize: 11, color: #8aa0b5 }, axisLine: { show: false }, axisTick: { show: false }, splitLine: { show: false } }, radiusAxis: { min: 0, max: props.maxSpeed, axisLabel: { color: #8aa0b5, formatter: (value) (value 0 ? : value m/s) }, splitLine: { lineStyle: { color: rgba(255,255,255,0.12), type: dashed } } }, series: [ { type: bar, coordinateSystem: polar, data: props.roseData, itemStyle: { color: (params) calcColors()[params.dataIndex], borderColor: rgba(255,255,255,0.45), borderWidth: 1, borderRadius: 0 }, label: { show: true, position: outside, fontSize: 10, color: #a8b6c7, formatter: (params) (params.value 0 ? params.value.toFixed(1) : ) } } ], tooltip: { trigger: item, formatter: (params) { const dirNames [北, 北东北, 东北, 东东北, 东, 东东南, 东南, 南东南, 南, 南西南, 西南, 西西南, 西, 西西北, 西北, 北西北]; const dir dirNames[params.dataIndex]; return b${dir}风/bbr/平均风速${params.value} m/s; } } }; } function render() { if (!chart) return; chart.setOption(buildOption()); } function handleResize() { chart chart.resize(); } onMounted(() { chart echarts.init(chartEl.value); render(); window.addEventListener(resize, handleResize); }); onBeforeUnmount(() { window.removeEventListener(resize, handleResize); if (chart) { chart.dispose(); chart null; } }); watch(() props.roseData, render, { deep: true }); /script这个组件里有几个细节是实战里总结出来的radiusAxis的axisLabel里我让0不显示刻度文字因为极坐标中心区域出现“0 m/s”会显得很挤而刻度线本身还在不影响读数polar.center的y方向偏下一点因为顶部要给方位label留出空间底部要给外围label留出空间resize监听必须挂在window上同时组件销毁时一定要dispose否则大屏长时间运行内存会持续上涨。5.3 容器高度和初始化时机的坑Vue3里最常遇到的问题是mounted时容器还是display: none或者高度为0这时候echarts.init会得到一个宽度或高度为0的实例图表直接白屏。大屏项目里通常是页面加载后先请求数据、再渲染图表偶尔会有tab页签或弹窗场景。遇到这种情况不要硬在mounted里初始化正确的姿势是等容器真正显示后再init或者用nextTick配合container的clientWidth判断async function initWhenReady() { await nextTick(); if (!chartEl.value || chartEl.value.clientWidth 0) { setTimeout(initWhenReady, 200); return; } chart echarts.init(chartEl.value); render(); }另外容器必须有显式的高度光有宽度没有高度echarts会静默失败控制台一个错都不报排查起来非常浪费时间。我在模板里直接:style{ height: height px }就不依赖父级样式了。5.4 大屏整体缩放方案下的适配很多大屏用transform: scale()做整体等比缩放这会导致echarts的resize计算有误差。经验是如果大屏外层用了scale缩放组件内部的resize就不要监听window了而是监听外层容器的ResizeObserver并且在scale变换后手动调用一次chart.resize()强制重算尺寸。实践中最简单的做法是给大屏根节点一个固定设计稿尺寸比如1920x1080然后用transform缩放适配屏幕。在这种情况下echarts容器拿到的尺寸始终是1920宽下的值不需要跟着屏幕变化反而更稳定。6. 实测中容易踩的坑起始角对齐、零值扇区与标签重叠6.1 起始角不对导致“北”不在上方这个问题我印象太深了。第一次写完图表出来“北”这个label跑到了右上方斜45度的位置怎么看怎么别扭。原因就是startAngle的默认行为。Echarts极坐标里startAngle是角度轴的起始角默认90度意思是第一个类目从12点钟方向开始。如果你把startAngle改成0第一个类目会跑到3点钟方向。气象玫瑰图约定俗成是“北在上”所以必须让第一个方位N从12点方向起配置就是startAngle: 90。还有一个备份问题如果数据顺序不是从N开始而是从E开始那你要么调整数据顺序要么把startAngle换成对应的角度。我的建议是统一让后端按N开头的固定顺序返回数据前端不做任何offset调整这样后续维护的人不会有“为什么这个图转了一圈”的困惑。6.2 某个方向完全没有数据零值扇区处理实测中经常出现某个方位一个月都没出现过风比如山谷站点东西风几乎为0。这种扇区如果在数据里填0玫瑰图会塌陷一个缺口视觉上不完整。处理方式有两种。如果业务上0就是没有风保留缺口其实是诚实的表现但要在label里过滤掉0值且radiusAxis.max要手动设避免自动刻度被一堆0拉成从0.5开始。如果业务上想突出“风主要集中在某些方向”可以把0保留但把扇区周围颜色调淡用透明度降低存在感。另外要注意Math.max(...roseData, 1)这种写法在给颜色计算alpha时能防0如果所有数据都是0max至少是1不会出现除以0产生NaN导致渲染异常。6.3 窄扇区label重叠和溢出大屏分辨率不一样时玫瑰图外围的label经常互相叠。尤其是相邻两个方向的风速值都很小、数字位数接近时两个label几乎贴在了一起。解决办法有几个层次。最直接的是把fontSize调小到10px再做一层防御用labelLayout的hideOverlap属性labelLayout: { hideOverlap: true }这个配置会把重叠的label按顺序隐藏鼠标hover时tooltip能补全信息。实测效果不错大屏上偶尔缺一两个数字不影响整体阅读。如果还嫌不够把label改成只在数值大于某个阈值的扇区上显示弱化小数值的视觉噪音。6.4 tooltip单位口径不一致这个不算技术坑是业务坑但我见过太多次了。气象站原始数据用的是m/s但风电领域习惯用“级”蒲福风级做环保的喜欢用km/h。如果在tooltip和radiusAxis的label里不统一单位看数据的人很容易误读。我建议在组件层强制收口入口参数统一接收m/s展示层通过formatter再决定要不要换算。比如要显示级数就在formatter里写windScale(params.value)函数转换而不是让后端各传各的单位。这也是组件封装的一个价值数据口径在交付边界上就锁死后面接不同业务方都不慌。6.5 极坐标下splitLine和axisLabel的层级感最后一个容易被忽略的视觉细节默认情况下radiusAxis的splitLine是实线angleAxis的splitLine默认显示。玫瑰图的外圈如果实线和虚线混在一起会显得很“脏”。我的做法是angleAxis的splitLine关闭因为16个方向的副词分割线会让圆心区域变成一个密密麻麻的蜘蛛网radiusAxis的splitLine打开但改虚线、降透明度。这样视觉焦点自然落在扇区上方向通过外围的label感知就足够了。写在最后风力风速玫瑰图做下来技术上真的不难难的是把气象领域的“方向、风速、频率”这些语义用Echarts的极坐标机制准确翻译出来。只要明白“角度轴表达方向、半径轴表达数值、series用bar挂polar”这个核心逻辑再配合起始角、数据顺序、label策略这几个细节一张能上大屏、能交付业务方的玫瑰图就八九不离十了。如果你们项目里也有类似的气象可视化需求我建议从单系列平均风速玫瑰图起步先把16方位聚合和极坐标配置跑通再往堆叠风速等级方向扩展。留言里可以聊聊你碰到的是哪种场景是风电场选址、污染扩散还是纯粹大屏好看不同用途在配色和数据粒度上差别还挺大。
返回列表