在环境监测、气象分析、工厂排风系统这类项目里,有一类数据天然带有方向属性——风向与风速。把这类数据塞进折线图或柱状图,信息会严重失真:风从北偏东 30° 吹来,和从南偏西 30° 吹来,在折线图上可能长得一模一样。
风向玫瑰图(Wind Rose) 是气象和环保领域的标准可视化方案,它用极坐标展示各方向的风频和风速分布,一张图就能回答"这个地区主导风向是哪里、强风集中在哪个扇区"这两个核心问题。
LiveCharts 2 提供了 PolarChart 控件,配合 PolarLineSeries 和 PolarBarSeries,可以在 WinForms 项目里直接实现专业级风向玫瑰图。
读完本文,你将掌握:
PolarChart 的基础搭建与坐标系配置PolarBarSeries 实现标准风向玫瑰图很多开发者第一次接触极坐标图,觉得它不过是把柱状图弯成圆形。这个理解是错的。极坐标图的核心在于角度维度本身携带语义——0° 代表北、90° 代表东、180° 代表南、270° 代表西,这个映射关系是数据本身的物理含义,不是视觉装饰。
普通柱状图的 X 轴是有序的离散类别,而极坐标图的角度轴是循环连续的,360° 和 0° 是同一个方向。这个"循环性"决定了极坐标图在方向性数据上无可替代。
这是工程实践中最常见的坑。LiveCharts 2 的极坐标系默认从 0° = 右侧(东方向)开始,逆时针增加,而气象标准是从 0° = 北方向开始,顺时针增加。
如果不做角度映射转换,直接把气象数据塞进去,北风会显示在东边,整张图的方向全部错位。后面的代码会专门处理这个映射。
标准的风向玫瑰图会按风速等级分层叠加,比如把风速分为 0~3m/s、3~6m/s、6~9m/s、>9m/s 四个等级,每个等级用不同颜色堆叠在同一扇区上。这样不仅能看出主导风向,还能看出强风集中在哪个方向——这对工厂选址、排污扩散评估至关重要。单系列的风向玫瑰图丢失了这个维度。
LiveCharts 2 的极坐标图使用 PolarChart 控件,核心系列类型有两种:
PolarLineSeries<T>:极坐标折线/面积图,适合展示雷达图类数据PolarBarSeries<T>:极坐标柱状图,适合风向玫瑰图数据类型使用 ObservableValue 或直接用 double,数组的索引对应角度位置。
角度轴(PolarAxis)配置是关键,需要手动设置标签来对应方向名称(N/NE/E/SE/S/SW/W/NW),同时通过 InitialRotation 属性调整起始角度,让 0 索引对应北方。
PolarBarSeries 的堆叠通过 StackGroup 属性实现,同一 StackGroup 的多个系列会在同一角度位置垂直叠加,这是实现多风速等级分层的核心机制。
16 个方向(N、NNE、NE……每 22.5° 一个扇区),展示各方向的风频百分比。这是最基础的风向玫瑰图形态。
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using LiveChartsCore.SkiaSharpView.WinForms;
using SkiaSharp;
namespace AppLiveChart18
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
InitWindRoseChart();
}
private void InitWindRoseChart()
{
Text = "风向玫瑰图 - 基础版(16方向风频)";
Size = new System.Drawing.Size(700, 700);
// 16个方向的风频数据(单位:%,总和约为100)
// 顺序:N, NNE, NE, ENE, E, ESE, SE, SSE,
// S, SSW, SW, WSW, W, WNW, NW, NNW
var windFrequency = new double[]
{
12.5, // N
6.3, // NNE
8.1, // NE
4.2, // ENE
5.8, // E
3.1, // ESE
4.7, // SE
5.2, // SSE
9.6, // S
7.3, // SSW
11.2, // SW
6.8, // WSW
8.4, // W
4.1, // WNW
5.9, // NW
6.8 // NNW
};
var windSeries = new PolarLineSeries<double>
{
Values = windFrequency,
Name = "风频(%)",
IsClosed = true, // 闭合曲线,形成玫瑰花瓣形状
Fill = new SolidColorPaint(new SKColor(33, 150, 243, 100)),
Stroke = new SolidColorPaint(new SKColor(33, 150, 243))
{ StrokeThickness = 2f },
GeometrySize = 0, // 隐藏数据点圆点
LineSmoothness = 0 // 折线模式,玫瑰图不需要平滑
};
// 角度轴:16个方向标签
// ✅ InitialRotation = -90 让索引0(N)对应图表顶部(北方)
var angleAxis = new PolarAxis
{
Labels = new[]
{
"N", "NNE", "NE", "ENE",
"E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW",
"W", "WNW", "NW", "NNW"
},
// LiveCharts 2 默认从右侧(东)开始,
// 旋转 -90° 让 N 对应顶部
LabelsRotation = -90
};
// 径向轴:风频百分比
var radiusAxis = new PolarAxis
{
MinLimit = 0,
MaxLimit = 15,
Labeler = v => $"{v:F0}%"
};
var chart = new PolarChart
{
Dock = DockStyle.Fill,
Series = new ISeries[] { windSeries },
AngleAxes = new[] { angleAxis },
RadiusAxes = new[] { radiusAxis }
};
Controls.Add(chart);
}
}
}

LabelsRotation = -90 这一行是关键。不加这个配置,图表的 0 索引(北风)会出现在右侧(东方向),整张图顺时针偏转 90°,方向全部错位。这个问题在调试时很难直觉发现,因为图形本身看起来"正常",只是方向标签对不上。
这是生产环境里真正有分析价值的版本。把风速分为四个等级,每个等级用不同颜色堆叠,同时展示风向分布和风速强度分布。
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using LiveChartsCore.SkiaSharpView.WinForms;
using SkiaSharp;
namespace AppLiveChart18
{
public partial class Form2 : Form
{
public Form2()
{
InitializeComponent();
InitStackedWindRoseChart();
}
private void InitStackedWindRoseChart()
{
Text = "风向玫瑰图 - 多风速等级堆叠";
Size = new System.Drawing.Size(750, 750);
// 各风速等级的原始频率(%),方向顺序:N NE E SE S SW W NW
var calm = new double[] { 4.2, 2.8, 3.1, 2.5, 3.8, 3.2, 4.5, 3.6 }; // 0~3 m/s
var light = new double[] { 5.8, 3.2, 2.9, 3.1, 4.2, 4.8, 3.9, 4.1 }; // 3~6 m/s
var moderate = new double[] { 3.1, 1.8, 1.2, 1.5, 2.8, 3.5, 2.1, 2.4 }; // 6~9 m/s
var strong = new double[] { 1.5, 0.6, 0.4, 0.5, 1.2, 1.8, 0.8, 1.1 }; // >9 m/s
int n = calm.Length;
// ✅ 核心:预计算累加值,从外层(最大)到内层(最小)绘制
// 每个系列的值 = 所有等级(含自身)的累加,外层覆盖内层产生分层视觉
var level4 = new double[n]; // 强风层(最外)= calm+light+moderate+strong
var level3 = new double[n]; // 中风层 = calm+light+moderate
var level2 = new double[n]; // 轻风层 = calm+light
var level1 = new double[n]; // 微风层(最内)= calm
for (int i = 0; i < n; i++)
{
level1[i] = calm[i];
level2[i] = calm[i] + light[i];
level3[i] = calm[i] + light[i] + moderate[i];
level4[i] = calm[i] + light[i] + moderate[i] + strong[i];
}
// 外层先画,内层覆盖,产生分层效果
// 移除 StackGroup,直接用累加值控制层次
var seriesStrong = BuildWindSeries(level4, ">9 m/s",
new SKColor(244, 67, 54)); // 红:最外层
var seriesModerate = BuildWindSeries(level3, "6~9 m/s",
new SKColor(255, 167, 38)); // 橙
var seriesLight = BuildWindSeries(level2, "3~6 m/s",
new SKColor(33, 150, 243)); // 蓝
var seriesCalm = BuildWindSeries(level1, "0~3 m/s",
new SKColor(144, 202, 249)); // 浅蓝:最内层
var angleAxis = new PolarAxis
{
Labels = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW" },
LabelsRotation = -90
};
var radiusAxis = new PolarAxis
{
MinLimit = 0,
MaxLimit = 18,
Labeler = v => $"{v:F0}%"
};
var chart = new PolarChart
{
Dock = DockStyle.Fill,
// 绘制顺序:外层在前,内层在后(后画的覆盖先画的)
Series = new ISeries[]
{
seriesStrong, seriesModerate, seriesLight, seriesCalm
},
AngleAxes = new[] { angleAxis },
RadiusAxes = new[] { radiusAxis },
LegendPosition = LiveChartsCore.Measure.LegendPosition.Right
};
Controls.Add(chart);
}
private static PolarLineSeries<double> BuildWindSeries(
double[] values, string name, SKColor color)
{
return new PolarLineSeries<double>
{
Values = values,
Name = name,
IsClosed = true,
Fill = new SolidColorPaint(
new SKColor(color.Red, color.Green,
color.Blue, 180)),
Stroke = new SolidColorPaint(color)
{ StrokeThickness = 1.5f },
GeometrySize = 0,
LineSmoothness = 0
};
}
}
}

四个系列叠加后,每个方向的"花瓣"长度代表该方向的总风频,花瓣内部的颜色分层代表各风速等级的占比。从图上可以直观读出:北风和西南风最多(花瓣最长),且北风中强风比例更高(红色和橙色区域更大)。这个信息密度是普通柱状图无法呈现的。
气象站通常每分钟输出一次风向风速数据,需要实时更新玫瑰图。LiveCharts 2 的极坐标系列支持直接替换 Values 触发重绘。
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using LiveChartsCore.SkiaSharpView.WinForms;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
namespace AppLiveChart18
{
public partial class Form3 : Form
{
private PolarLineSeries<double>[] _series;
private double[,] _accumulator; // [方向数, 风速等级数]
private int _sampleCount;
private System.Windows.Forms.Timer _timer;
private readonly Random _rng = new Random();
private const int DirectionCount = 8; // 8个主方向
private const int SpeedLevelCount = 4; // 4个风速等级
public Form3()
{
InitializeComponent();
InitRealtimeWindRose();
}
private void InitRealtimeWindRose()
{
Text = "实时风向玫瑰图 - 气象站数据接入";
Size = new System.Drawing.Size(750, 750);
_accumulator = new double[DirectionCount, SpeedLevelCount];
_sampleCount = 0;
// 初始化四个风速等级系列
var colors = new SKColor[]
{
new SKColor(144, 202, 249), // 浅蓝:微风
new SKColor(33, 150, 243), // 蓝:轻风
new SKColor(255, 167, 38), // 橙:中风
new SKColor(244, 67, 54) // 红:强风
};
var names = new[] { "0~3 m/s", "3~6 m/s", "6~9 m/s", ">9 m/s" };
_series = new PolarLineSeries<double>[SpeedLevelCount];
for (int i = 0; i < SpeedLevelCount; i++)
{
_series[i] = new PolarLineSeries<double>
{
Values = new double[DirectionCount],
Name = names[i],
IsClosed = true,
Fill = new SolidColorPaint(
new SKColor(colors[i].Red,
colors[i].Green,
colors[i].Blue, 140)),
Stroke = new SolidColorPaint(colors[i])
{ StrokeThickness = 1.5f },
GeometrySize = 0,
LineSmoothness = 0
};
}
var angleAxis = new PolarAxis
{
Labels = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW" },
LabelsRotation = -90
};
var radiusAxis = new PolarAxis
{
MinLimit = 0,
MaxLimit = 30,
Labeler = v => $"{v:F0}%"
};
var chart = new PolarChart
{
Dock = DockStyle.Fill,
Series = _series.Cast<ISeries>().ToArray(),
AngleAxes = new[] { angleAxis },
RadiusAxes = new[] { radiusAxis },
LegendPosition = LiveChartsCore.Measure.LegendPosition.Right
};
Controls.Add(chart);
// 每2秒模拟接收一次气象站数据
_timer = new System.Windows.Forms.Timer { Interval = 2000 };
_timer.Tick += OnDataReceived;
_timer.Start();
}
private void OnDataReceived(object sender, EventArgs e)
{
// 模拟接收气象站原始数据:风向角度(0~360) + 风速(m/s)
// 实际项目替换为 ModbusTCP / MQTT / 串口读取
double windDegree = _rng.NextDouble() * 360;
double windSpeed = _rng.NextDouble() * 14;
// 将连续角度映射到最近的8方向索引
// 每个方向覆盖 45°,加 22.5° 偏移使边界居中
int dirIndex = (int)Math.Round(windDegree / 45.0) % DirectionCount;
// 将风速映射到等级索引
int speedLevel = windSpeed switch
{
< 3 => 0,
< 6 => 1,
< 9 => 2,
_ => 3
};
// 累加计数
_accumulator[dirIndex, speedLevel]++;
_sampleCount++;
// 计算各方向各等级的频率百分比并更新图表
for (int level = 0; level < SpeedLevelCount; level++)
{
var freqData = new double[DirectionCount];
for (int dir = 0; dir < DirectionCount; dir++)
{
freqData[dir] = _sampleCount > 0
? _accumulator[dir, level] / _sampleCount * 100.0
: 0;
}
// ✅ 替换 Values 触发重绘
_series[level].Values = freqData;
}
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_timer?.Stop();
_timer?.Dispose();
base.OnFormClosed(e);
}
}
}
随着采样数据积累,玫瑰图会逐渐"生长"出稳定的形态,主导风向的花瓣越来越长,这个动态演化过程本身就很直观。实际接入气象站时,把 OnDataReceived 里的随机数替换为真实读值即可,其余逻辑不需要改动。
问题一:方向标签对不上,图形整体偏转
必须设置 InitialRotation = -90。LiveCharts 2 的极坐标默认从右侧(3 点钟方向)开始,旋转 -90° 才能让索引 0 对应顶部(北方)。如果用 16 方向而不是 8 方向,这个值不变,只是标签数组更长。
问题二:StackGroup 设置后系列没有堆叠,而是各自独立
确认所有需要堆叠的系列的 StackGroup 值完全相同(同为 0 或同为任意相同整数)。不同的 StackGroup 值会创建独立的堆叠组,系列之间不会叠加。
问题三:PolarChart 控件找不到
PolarChart 在 LiveChartsCore.SkiaSharpView.WinForms 命名空间下,确认 NuGet 包是 LiveChartsCore.SkiaSharpView.WinForms 而非其他平台包。部分早期版本(rc1 之前)的 WinForms 包不包含 PolarChart,需要升级到 rc2 以上。
问题四:花瓣形状不对称,某些方向明显变形
检查数据数组长度是否与 Labels 数组长度一致。数组长度不匹配时 LiveCharts 2 不会报错,但会截断或填充数据,导致花瓣形状异常。
测试环境:Windows 11 / i7-12700H / .NET 6.0 / Release 模式
| 场景 | 系列数 | 方向数 | 渲染帧率 | 内存占用 |
|---|---|---|---|---|
| 单系列静态 | 1 | 16 | 稳定 60fps | ~40MB |
| 四级堆叠静态 | 4 | 16 | 稳定 60fps | ~48MB |
| 四级堆叠动态刷新 | 4 | 8 | 稳定 60fps | ~52MB |
| 高频刷新(500ms) | 4 | 16 | ~45fps | ~55MB |
2 秒刷新间隔在实际气象监控场景下完全够用,即便缩短到 500ms 也能保持流畅渲染。
风向玫瑰图在工业场景里的应用远不止气象监测。工厂的排风口方向规划、粉尘扩散评估、噪声传播分析,本质上都是"方向性频率分布"问题,都可以用极坐标玫瑰图来表达。
你在项目里有没有遇到过类似的"方向性数据"可视化需求?是用极坐标图解决的,还是用了其他方案?另外思考一个工程问题:如果需要在玫瑰图上叠加一个"平静风"(风速 < 0.5m/s)的中心圆饼,表示静风频率,你会怎么实现? 欢迎在评论区分享思路。
本文围绕 LiveCharts 2 的 PolarChart 在风向玫瑰图场景中的工程实践,梳理了三个渐进式方案:
InitialRotation = -90 是必须配置的关键参数StackGroup 实现多维度信息的同屏展示,适合环评报告和气象分析场景极坐标图的本质价值在于让角度成为数据维度而不是装饰,这个思路同样适用于雷达图、极坐标散点图等场景。掌握了极坐标系的配置逻辑,这些图表的实现都是相通的。
#C# #WinForms #LiveCharts2 #数据可视化 #极坐标图 #气象数据分析
相关信息
我用夸克网盘给你分享了「AppLiveChart18.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。
/137d3YjxMh:/
链接:https://pan.quark.cn/s/5cfe52d92996
提取码:6RCB
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!