前几天看到一篇新闻里做的下面这张股票热点图,单纯的觉得这个图很好看,于是研究了一下如何自己绘制这个图,后来发现这种类型的图还有个专有的名字,叫treemap图。
▲ 2024.08.09 标普500指数热点图
本文先介绍什么是treemap图,然后展示如何使用C#来生成沪深300指数的treemap。
treemap矩形树图
treemap(矩形树图)最早由美国计算机科学家 Ben Shneiderman 在 1992 年提出。为了应对常见的硬盘已满问题,Ben Shneiderman 创新性地提出生成目录树结构可视化的想法。
▲ 使用treemap显示磁盘文件占用
矩形树图旨在以高效和清晰的方式展示层次数据结构。从最初作为文件组织工具的角色到如今在金融、市场研究等领域有着广泛的应用。
▲ 2024年华东师大新生生源地
▲ 网易云音乐 性格泡泡
矩形树图以一系列嵌套的矩形来展示数据,从根节点开始,屏幕空间根据子节点数目被分为多个矩形,矩形面积对应节点的权重。每个矩形又按照相应节点的子节点递归进行分割,直到叶节点为止 。另外矩形的面积和颜色可以代表每个元素的两个属性,比如矩形的大小表示市值,颜色表示涨跌幅。另外,相比树状图的空间利用率低的问题,矩形树图可以平铺整个显示区域,可以展示更多的数据,显示效率更高。
▲树图与矩形树图
可以看到,左边是一个典型的树状图,树中的叶子节点在右边的矩形树图中以面积来表示。对照这两张图,可以明显的看到相对于传统树形图,矩形树图的上述两个优点。
要创建一个矩形树图,必须定义一个平铺算法(tiling algorithm)。平铺算法就是定义如何对大的矩形分割为小矩形。一个好的平铺算法必须满足一下条件:
- 具有良好的纵横比(aspect ratio,矩形中长边/短边),最好接近1,即为正方形。比较小的长宽比比如矩形,更容易被感知。
- 要保留一些输入数据的顺序特征,比如排序。矩形的面积从大到小。
- 对底层数据的变化不会产生图形的较大变化,即具有稳定性。
这几个特征之间互斥,比如如果优化了纵横比,那么数据的有序性则无法保证。如果有序稳定,那么纵横比可能会退化。平铺算法有很多种,这里引用维基百科上的介绍:
▲ 各种平铺算法
在以上众多算法中,这里介绍Squarified算法,在很多场景中,这个算法比较适用。
Squarified正方化算法
正方化(squarified)树图布局会尽可能的使用指定的纵横比(ratio)来切分矩形,使生成的矩形尽量接近正方形,拥有更佳的平均长宽比,该算法在这篇文章中有详细介绍。正方化算法并不会在一开始就同时考虑所有层级该如何划分,这能避免带来很大的计算量。
其思想主要是:
- 按照自定义的权重,将子节点按顺序从大到小进行排列;
- 从权值最大的节点开始,按照沿最短边优先开始,紧靠左边或者上边的原则,分别从左到右或者从上到下开始填充。
- 当填充第i个子节点时,分别计算同行同列插入和新建一行/列这两种方式,对比第1到i-1个已填充矩形的平均长宽比,选择平均长宽比低(靠近1)的填充结果作为第i个子节点的填充方式。
以数据集:{ 3, 2, 6, 4, 1, 2, 6 }为例,排序后的序列为: {6, 6, 4, 3, 2, 2, 1},假设总面积为每个元素之和24,长度为6,宽度为4。
- Step1:首先确认最短边(总矩形是6×4,最短边是4),将第一个元素6放在最短边处,得到当前元素6的矩形宽度为面积6/4=1.5。则长宽比为max(1.5/4, 4/1.5 )=8/3。
- Step2:取出第二个元素6,让其和上一个区域并列存放,每次都先尝试并列存放。大矩形的短边为4。目前两个元素的总和是12,所以两个为6的矩形的一边是4,另外一边是12/4=3。则长宽比为max(4/3,3/4)=4/3。这个长宽比上一步中的长宽比8/3更小,所以当前放置的方式是合适的。
- Step3:取出第三个元素4,如Step2那样,继续尝试往上叠放。当前总面积是6+6+4=16,最短边是4,则另外一边是4。对于最上面新加入的元素4,则它的另外一边是4/4=1,所以新加入的元素4的长宽比为max(4/1,1/4)=4,这大于之前的3/2,说明当前在上面添加矩形的方式没有改善布局样式。所以回退到Step2结束的时候,尝试在新的一行中加入4。
- Step4:现在将两个6层叠放置后占用边长为4×3的矩形,剩下的了一个4×3的矩形。此时在剩下的布局中,短边为3,要把4放在4×3的短边,所以另外一边长度为4/3。现在的长宽比为max( 3/(4/3), (4/3)/3)=9/4。9/4小于Step2结束时的8/3,所以这样放置是合适的。
- Step5:取出元素3,尝试将它与4并列放置。此时总面积是4+3=7,剩下4×3矩形的最小边是3,所以总矩形的另外一边为7/3,取最小边7/3,则面积为3的小矩形的另外一边为3/7/3=9/7,新增加的元素3的长宽比为max((7/3)/(9/7),(9/7)/(7/3))=49/27,这个值小于第4步放置完成之后的9/4,所以这样摆放是合适的。
- Step6:取出元素2,尝试将它与前面的4和3并列排放,此时总面积是4+3+2=9,剩下4×3矩形的最小边是3,所以总矩形的另外一边为9/3=3,最小边为3,则面积为2的小矩形的另外一边为2/3,新增的元素2的长宽比为max(3/(2/3),(2/3)/3)=9/4,这个值,比上一步的49/27要大,所以当前元素2的摆放形式使得布局样式恶化,所以回退到Step5结束的时候,尝试在新的一行中加入2。
- Step7:继续摆放元素2,现在在Step5元素摆放结束后,剩下的面积为4×3-(4+3)=5,其中一边长度为3,则另外一边为5/3,取最短边5/3摆放,则元素2的另外一边为2/(5/3)=6/5,则新增加的元素2的长宽比为max((5/3)/(6/5), (6/5)/(5/3))=25/18,这个值比Step5结束后的49/27,所以这样摆放是合理的。
- Step8:继续摆放元素2,类似,先将2摆放在上面,则总面积为2+2=4,剩余矩形的一边长度为3,另外一边为5/3,取短边5/3,两个2合并摆放的另外一边为4/(5/3)=12/5,因为是垂直分布摆放,则元素2的另外一边是2/(12/5)=10/12=5/6,则新增元素2的长宽比为max((12/5)/(5/6),((5/6)/(12/5)))=72/25,这个值笔49/27要大,所以这样摆放不合理。
- Step9:重新以水平的方式摆放2,剩余矩形的一边长度为3,另外一边为5/3,因为是水平摆放,所以元素2的一边是5/3,另外一边是2/(5/3)=6/5,则水平方式摆放的元素2的长款比为max((5/3)/(6/5),((6/5)/(5/3)))=25/18,这个值比49/27要小,所以这样摆放是合理的。
- Step10:取出最后一个元素1,继续水平摆放,剩下的面积为1,其中一边为5/3,剩下一边为1/(5/3)=3/5,最后一个值的长宽比为25/9,但是因为是最后一个元素,所以直接结束。
C#的squarified算法实现
C#的squarified算法主要参考stackoverflow中的这个答案,并修改了其中的一些bug。具体实现如下:
首先是表示每个树形图叶子节点的对象,即每个矩形实体:
[DebuggerDisplay("Label={Label}")]
public class TreemapItem
{
private TreemapItem()
{
FillBrush = Brushes.White;
BorderBrush = Brushes.White;
TextBrush = Brushes.Black;
}
public TreemapItem(string label, int area, Brush fillBrush, string tag = "", TreemapItem[] childrens = null) : this()
{
Label = label;
Area = area;
FillBrush = fillBrush;
Children = null;
Tag = tag;
Children = childrens;
}
public TreemapItem(params TreemapItem[] children) : this()
{
// in this implementation if there are children - all other properies are ignored
// but this can be changed in future
Children = children;
}
public string Label { get; set; }
public Brush FillBrush { get; set; }
public Brush BorderBrush { get; set; }
public Brush TextBrush { get; set; }
public int Area { get; set; }
public TreemapItem[] Children { get; set; }
public string Tag { get; set; }//涨跌信息
}
这其中area存储第一维度的数据,其它一些定义了矩形的标题,填充颜色,字体颜色等等,Tag里面可以存储第二维度的信息,比如股票的涨跌幅。接下来实现矩形树图的逻辑:
public class Treemap
{
public Bitmap Build(TreemapItem[] items, int width, int height)
{
var map = BuildMultidimensional(items, width, height, 0, 0);
var bmp = new Bitmap(width + 1, height + 1);
var g = Graphics.FromImage(bmp);
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
g.SmoothingMode = SmoothingMode.AntiAlias;
foreach (var kv in map)
{
var item = kv.Key;
var rect = kv.Value;
g.FillRectangle(item.FillBrush, rect);
g.DrawRectangle(new Pen(item.BorderBrush, 1), rect);
if (!String.IsNullOrWhiteSpace(item.Label))
{
// draw text
var format = new StringFormat();
format.Alignment = kv.Key.Children != null ? StringAlignment.Near : StringAlignment.Center;
format.LineAlignment = StringAlignment.Center;
string txt = item.Label;
if (!string.IsNullOrEmpty(item.Tag))
{
txt = txt + "\n" + item.Tag + "%";
}
float fontSize = 10;
var font = new Font("Arial", fontSize);
// 使用MeasureString来获取文本尺寸
SizeF textSize = g.MeasureString(txt, font);
bool notFoundSize = false;
while (textSize.Width > rect.Width || textSize.Height > rect.Height)
{
font.Dispose();
fontSize -= 0.5f;
font = new Font("Arial", fontSize);
textSize = g.MeasureString(txt, font);
if (fontSize < 6)
{
notFoundSize = true;
break;
}
}
// 检查文本尺寸是否超出了区域大小
if (!notFoundSize)
{
RectangleF f = new RectangleF(rect.X, rect.Y, rect.Width, rect.Height);
// 绘制文本
g.DrawString(txt, font, item.TextBrush, f, format);
}
}
}
return bmp;
}
private Dictionary<TreemapItem, Rectangle> BuildMultidimensional(TreemapItem[] items, int width, int height, int x, int y)
{
var results = new Dictionary<TreemapItem, Rectangle>();
var mergedData = new TreemapItem[items.Length];
for (int i = 0; i < items.Length; i++)
{
// calculate total area of children - current item's area is ignored
mergedData[i] = SumChildren(items[i]);
}
// build a map for this merged items (merged because their area is sum of areas of their children)
var mergedMap = BuildFlat(mergedData, width, height, x, y);
for (int i = 0; i < items.Length; i++)
{
var mergedChild = mergedMap[mergedData[i]];
// inspect children of children in the same way
if (items[i].Children != null)
{
var headerRect = new Rectangle(mergedChild.X, mergedChild.Y, mergedChild.Width, 20);
results.Add(mergedData[i], headerRect);
// reserve 20 pixels of height for header
foreach (var kv in BuildMultidimensional(items[i].Children, mergedChild.Width, mergedChild.Height - 20, mergedChild.X, mergedChild.Y + 20))
{
results.Add(kv.Key, kv.Value);
}
}
else
{
results.Add(mergedData[i], mergedChild);
}
}
return results;
}
private Dictionary<TreemapItem, Rectangle> BuildFlat(TreemapItem[] items, int width, int height, int x, int y)
{
// normalize all area values for given width and height
Normalize(items, width * height);
var result = new Dictionary<TreemapItem, Rectangle>();
Squarify(items, new TreemapItem[0], new TreemapContainer(x, y, width, height), result, items.LastOrDefault());
return result;
}
private void Squarify(TreemapItem[] data, TreemapItem[] currentRow, TreemapContainer container, Dictionary<TreemapItem, Rectangle> stack, TreemapItem lastItem)
{
if (data.Length == 0)
{
foreach (var kv in container.GetCoordinates(currentRow, lastItem))
{
stack.Add(kv.Key, kv.Value);
}
return;
}
var length = container.ShortestEdge;
var nextPoint = data[0];
if (ImprovesRatio(currentRow, nextPoint, length))
{
currentRow = currentRow.Concat(new[] { nextPoint }).ToArray();
Squarify(data.Skip(1).ToArray(), currentRow, container, stack, lastItem);
}
else
{
var newContainer = container.CutArea(currentRow.Select(c => c.Area).Sum());
foreach (var kv in container.GetCoordinates(currentRow, lastItem))// //foreach (var kv in newContainer.GetCoordinates(currentRow))
{
stack.Add(kv.Key, kv.Value);
}
Squarify(data, new TreemapItem[0], newContainer, stack, lastItem);
}
}
private bool ImprovesRatio(TreemapItem[] currentRow, TreemapItem nextNode, int length)
{
// if adding nextNode
if (currentRow.Length == 0)
return true;
var newRow = currentRow.Concat(new[] { nextNode }).ToArray();
var currentRatio = CalculateRatio(currentRow, length);
var newRatio = CalculateRatio(newRow, length);
return currentRatio >= newRatio;
}
private double CalculateRatio(TreemapItem[] row, int length)
{
var min = row.Select(c => c.Area).Min();
var max = row.Select(c => c.Area).Max();
var sum = row.Select(c => c.Area).Sum();
return Math.Max(Math.Pow(length, 2) * max * 1.0 / Math.Pow(sum, 2), Math.Pow(sum, 2) * 1.0 / (Math.Pow(length, 2) * min));
}
private void Normalize(TreemapItem[] data, int area)
{
var sum = data.Select(c => c.Area).Sum();
var multi = area * 1.0 / sum;
int alreadyTotal = 0;
for (int i = 0; i < data.Length; i++)
{
if (i == data.Length - 1)
{
//补齐由于取整导致的误差积累
data[i].Area = area - alreadyTotal;
}
else
{
data[i].Area = (int)Math.Round(data[i].Area * multi);
alreadyTotal += data[i].Area;
}
}
}
private TreemapItem SumChildren(TreemapItem item)
{
int total = 0;
if (item.Children?.Length > 0)
{
total += item.Children.Sum(c => c.Area);
foreach (var child in item.Children)
{
total += SumChildren(child).Area;
}
}
else
{
total = item.Area;
}
return new TreemapItem(item.Label, total, item.FillBrush, item.Tag, item.Children);
}
}
其中用到一个帮助类:
class TreemapContainer
{
public int X { get; }
public int Y { get; }
public int Width { get; }
public int Height { get; }
public TreemapContainer(int x, int y, int width, int height)
{
X = x;
Y = y;
Width = width;
Height = height;
}
public int ShortestEdge => Math.Min(Width, Height);
public IDictionary<TreemapItem, Rectangle> GetCoordinates(TreemapItem[] row, TreemapItem lastItem)
{
// getCoordinates - for a row of boxes which we've placed
// return an array of their cartesian coordinates
var coordinates = new Dictionary<TreemapItem, Rectangle>();
int subx = this.X;
int suby = this.Y;
int subHeight = 0;
int subWidth = 0;
bool isLast = row.Contains(lastItem);
if (Width >= Height)
{
for (int i = 0; i < row.Length; i++)
{
//总的面积来除以高度,得到总宽度,然后用面积除以宽度得到高度.
int height = (int)Math.Round(row[i].Area * 1.0 / (row.Select(c => c.Area).Sum() * 1.0 / Height));
if (i == row.Length - 1)
{
height = Height - subHeight;//全部填充剩余的y
Console.WriteLine($"height:{row[i].Label}");
}
int areaWidth = (int)Math.Round(row.Select(c => c.Area).Sum() * 1.0 / Height);
if (isLast)
{
areaWidth = Width;
}
var rect = new Rectangle(subx, suby, areaWidth, height);
coordinates.Add(row[i], rect);
suby += height;
subHeight += height;
//var rect = new Rectangle(subx, suby, subx + (int)(areaWidth), suby + (int)(row[i].Area / areaWidth));
//coordinates.Add(row[i], rect);
//suby += (int)(row[i].Area / areaWidth);
}
}
else
{
for (int i = 0; i < row.Length; i++)
{
int width = (int)Math.Round(row[i].Area * 1.0 / (row.Select(c => c.Area).Sum() * 1.0 / Width));
if (i == row.Length - 1)
{
width = Width - subWidth;
Console.WriteLine($"width:{row[i].Label}");
}
int areaHeight = (int)Math.Round(row.Select(c => c.Area).Sum() * 1.0 / Width);
if (isLast)
{
areaHeight = Height;
}
var rect = new Rectangle(subx, suby, width, areaHeight);
coordinates.Add(row[i], rect);
subx += width;
subWidth += width;
//var rect = new Rectangle(subx, suby, subx + (int)(row[i].Area / areaHeight), suby + (int)(areaHeight));
//coordinates.Add(row[i], rect);
//subx += (int)(row[i].Area / areaHeight);
}
}
return coordinates;
}
public TreemapContainer CutArea(int area)
{
// cutArea - once we've placed some boxes into an row we then need to identify the remaining area,
// this function takes the area of the boxes we've placed and calculates the location and
// dimensions of the remaining space and returns a container box defined by the remaining area
if (Width >= Height)
{
int areaWidth = (int)Math.Round(area * 1.0 / Height);
int newWidth = Width - areaWidth;
return new TreemapContainer(X + areaWidth, Y, newWidth, Height);
}
else
{
var areaHeight = (int)Math.Round(area * 1.0 / Width);
var newHeight = Height - areaHeight;
return new TreemapContainer(X, Y + areaHeight, Width, newHeight);
}
}
}
使用方法如下:
var map = new[] {
new TreemapItem("ItemA", 0, Brushes.DarkGray) {
Children = new[] {
new TreemapItem("ItemA-1", 200, Brushes.White),
new TreemapItem("ItemA-2", 500, Brushes.BurlyWood),
new TreemapItem("ItemA-3", 600, Brushes.Purple),
}
},
new TreemapItem("ItemB", 1000, Brushes.Yellow) {
},
new TreemapItem("ItemC", 0, Brushes.Red) {
Children = new[] {
new TreemapItem("ItemC-1", 200, Brushes.White),
new TreemapItem("ItemC-2", 500, Brushes.BurlyWood),
new TreemapItem("ItemC-3", 600, Brushes.Purple),
}
},
new TreemapItem("ItemD", 2400, Brushes.Blue) {
},
new TreemapItem("ItemE", 0, Brushes.Cyan) {
Children = new[] {
new TreemapItem("ItemE-1", 200, Brushes.White),
new TreemapItem("ItemE-2", 500, Brushes.BurlyWood),
new TreemapItem("ItemE-3", 600, Brushes.Purple),
}
},
};
using (var bmp = new Treemap().Build(map, 1024, 1024))
{
bmp.Save("output.png", ImageFormat.Png);
}
以上有两点对原始的实现的误差做了修改:
- 原代码里非常简单粗暴的对计算结果进行了向下取整的转换即将double强制转为int,而没有考虑,对这种误差的补足。
- 对最后一个元素的填充没有按照面积进行补全。
上面两个问题会导致在生成矩形树图时,最后一个元素会累计所有前面的计算误差,导致面积出现不足的情况。
▲ 原代码中,由于取整导致误差的积累,在最后几个元素中特别明显
解决方法在上面的代码里面:
- 将强制转换的向下取整逻辑,替换为四舍五入处理
- 对最后一个元素,将整个剩余矩形的长或宽赋给剩余的对象。
处理误差之后的结果如下:
▲ 调整取整误差处理逻辑和对误差进行补偿后的效果。
下面就介绍如何制作沪深300的热点图,即上面这张图的实现。
沪深300热点图的实现
要显示沪深300热点,首先要获取沪深300的成分股以及权重。这些信息在中证沪深300指数中有详细介绍,除此之外,上图中还要显示各成分所述的板块,这个可以从中证行业分类里获取,在制作矩形树图时,第一维度的数据使用股票的市值乘以权重来作为矩形面积的展示,第二维度数据使用涨跌幅来表示,这里的涨跌幅随机生成。
List<StockCategory> industrials = StockCategory.GetHS300Info(1);
TreemapItem[] maps = new TreemapItem[industrials.Count];
for (int i = 0; i < industrials.Count; i++)
{
maps[i] = new TreemapItem(industrials[i].Name, 0, Brushes.DarkGray);
maps[i].Children = new TreemapItem[industrials[i].Stocks.Count];
for (int j = 0; j < industrials[i].Stocks.Count; j++)
{
StockInfo sinfo = industrials[i].Stocks[j];
maps[i].Children[j] = new TreemapItem(sinfo.Name, sinfo.Capital, GetBrush(sinfo.Rise), sinfo.Rise.ToString());
}
maps[i].Children = maps[i].Children.OrderByDescending(x => x.Area).ToArray();
}
maps = maps.OrderByDescending(t => t.Children.Sum(z => z.Area)).ToArray();
using (var bmp = new Treemap().Build(maps, 2049, 1025))
{
//pictureBox1.Image = bmp;
bmp.Save($"HS300_全市场_{mimutes}.png", ImageFormat.Png);
}
StockCategory的GetHS300Info来获取按照行业分类的沪深300,参数1表示按照一级行业,最多4表示四级行业分类。GetBrush根据涨跌幅来获取颜色。
参考
- https://finviz.com/map.ashx
- https://www.anychart.com/products/anychart/gallery/Tree_Map_Charts/ACME_Products_by_Revenue.php
- https://github.com/Giedrius-Kristinaitis/TreeMap/tree/master/C-Sharp%20TreeMap%20Implementation%20and%20Test/TreeMap
- https://swharden.com/blog/2023-03-07-treemapping/
- https://stackoverflow.com/questions/32548949/from-c-sharp-serverside-is-there-anyway-to-generate-a-treemap-and-save-as-an-im
- https://en.wikipedia.org/wiki/Treemapping
- https://github.com/imranghory/treemap-squared/tree/master
- https://www.win.tue.nl/~vanwijk/stm.pdf
- https://pascallaurin42.blogspot.com/2013/12/implementing-treemap-in-c.html
- https://www.csindex.com.cn/#/dataService/industryClassification
- https://www.csindex.com.cn/#/indices/family/detail?indexCode=000300
- https://zhuanlan.zhihu.com/p/410640173
- http://www.cs.umd.edu/hcil/treemap-history/index.shtml
- https://www.jianshu.com/p/43170cad1ba2
- https://docs.devexpress.com/WindowsForms/115730/controls-and-libraries/treemap/getting-started/lesson-2-create-a-treemap-bound-to-flat-data
- https://blog.51cto.com/u_11949039/4558766
- https://www.jianshu.com/p/43170cad1ba2
- https://juejin.cn/post/6948576294145622023#heading-8
- https://blog.csdn.net/u011374344/article/details/112966315