一个简单的饼图控件 - 改进的 3D 饼图






4.87/5 (80投票s)
对简单的饼图控件文章的后续,将详细介绍新的改进。

引言
如果您看过这篇文章 一个简单的饼图控件,该文章描述了一个使用 GDI+ 图形功能实现的简单饼图,那么本文将介绍同一个饼图控件,它在 3D 饼图方面进行了许多改进,并增加了一些功能以提高质量。新增的功能主要集中在 3D 饼图方面。本文详细介绍了这些改进、遇到的挑战以及具体的解决方案。对于认真阅读我第一篇文章的读者来说,这将会很有趣。
背景
首先,我的第一个实现有自己独特的绘制 3D 面特征,我出于多种原因坚持使用这些特征。这些特征对于我的第一个实现是有效且相关的,但在进一步改进时却成为障碍。它们是
绘制 3D 饼图的可见部分
这就是“只绘制可见部分”的概念。因此,对于 3D 饼图,绘制的部分是上半椭圆曲面,以及圆柱面 0 到 180 度角之间的部分。
低处理量 - 快速动画
所以第一个原因在某种程度上暗示了第二个原因,或者反之亦然。较少的绘制部分使得饼图在图形对象上的处理量减少,从而使动画稍微快一些。
较少动画 - 简单控件
我的第一个目标是实现一个具有一组简单功能的简单饼图控件。因此,这就意味着要实现像以前那样的 3D 饼图。
但是 3D 饼图有一个小缺点。比例关系会导致一些饼图元素在百分比值很小时消失。

在上图中,饼图中的“Item2”不可见。
因此,需要一种解决方案来在饼图中显示这些小比例的元素,而不会影响比例。解决方案是将这些小比例的元素取出或滑出饼图进行显示。但在现有设计下,这是不可能的。这也是我重写 3D 饼图实现并采用“绘制所有内容”概念的原因。
设计 3D 饼图元素
使用 GDI+ 绘图函数,实现 3D 饼图元素并不难,但真正的挑战在于将它们放置在正确的位置和正确的顺序。这不仅适用于一组元素,也适用于单个元素,还要考虑它的各个面。如果一个元素的绘制顺序或朝向顺序错误,3D 外观就会失真。

面的顺序是根据元素将要放置的位置来确定的。这分为四类
- 右侧
- 左侧
- 靠后的元素
- 靠前的元素
在这些情况下,会根据绘制的位置创建元素并赋予 3D 外观。
按正确顺序放置元素
这是根据基本的视觉特征决定的。
- 首先绘制离前面最远的元素。该元素应穿过上半 Y 轴(或更确切地说,270° 角线)。
- 绘制除最前面元素之外的其他元素,将它们归类为右侧或左侧。
- 最后绘制最前面的元素
函数的实现如下
iter = map_pChart.begin();
totalAngle = fl_startAngleIncline;
for(; iter != map_pChart.end(); ++iter){
ele = iter->second;
if (ele){
if (totalAngle >= 360)
totalAngle -= 360;
if ((totalAngle <= 90 &&
totalAngle+ ele->pie_3d_props.f_InclineAngle > 90) ||
totalAngle >= 90 && (totalAngle+ ele->
pie_3d_props.f_InclineAngle - 360) > 90){
pFront = ele;
i_pieElement_front = iter->first;//save the
// index of the front element
// which will be drawn
}
if (totalAngle <= 270 && totalAngle+ ele->
pie_3d_props.f_InclineAngle > 270 ||
totalAngle >= 270 && (totalAngle+ ele->
pie_3d_props.f_InclineAngle - 360) > 270){
ielementLeft = iter->first;
ielementRight = iter->first;
i_pieElement_last = iter->first;//save the index
//of the last element will be drawn
totalRight = totalAngle + ele->
pie_3d_props.f_InclineAngle;
totalLeft = totalAngle;
pLast = iter->second;
}
totalAngle += ele->pie_3d_props.f_InclineAngle;
}
}
if (pLast == pFront){
if (totalLeft < 90 || totalLeft > 270)
bRightAngled = true; //The elements are on the
//right hand side!
else if(totalLeft > 90 && totalLeft < 270)
bLeftAngled = true;//The elements are on the right hand side!
}
else{
bLeftAngled = true; //Elements are on both sides!
bRightAngled = true; //
}
if(pLast){
if (pLast == pFront && bRightAngled)
Construct3DElementSpecific(graphics, pLast,
(REAL)totalLeft, PIE_LAST_RIGHT, PointF(0, 0));//Draw the pie
| //element which is the front and last
else if(pLast == pFront && bLeftAngled)//element.
// First draw only the latter part of the element.
Construct3DElementSpecific(graphics, pLast,
(REAL)totalLeft, PIE_LAST_LEFT, PointF(0, 0));
else
Construct3DElement(graphics, pLast, (REAL)totalLeft,
PIE_LAST, PointF(0, 0));//Draw the normal element
}
SolidBrush brtest(Color(255,25,25));
ele = NULL;
while(pFront != ele && bLeftAngled){
ielementLeft--;
iter = map_pChart.find(ielementLeft);
if(iter!= map_pChart.end()){
ele = iter->second;
if (ele != pFront){
totalLeft -= ele->pie_3d_props.f_InclineAngle;
Construct3DElement(graphics, ele,
(REAL)totalLeft, LEFT_ANGELD, PointF(0, 0));
}
}else{
ielementLeft = i_elementIndex;
}
}
ele = NULL;
while(pFront != ele && bRightAngled){
ielementRight++;
iter = map_pChart.find(ielementRight);
if(iter!= map_pChart.end()){
if (totalRight >= 360)
totalRight -= 360;
ele = iter->second;
if (ele != pFront){
Construct3DElement(graphics, ele, (REAL)
totalRight, RIGHT_ANGLED, PointF(0, 0));
totalRight += ele->
pie_3d_props.f_InclineAngle;
}
}else{
ielementRight = -1;
}
}
if(pFront){
if (pLast == pFront && bRightAngled)//Draw the pie element
// which is the front and last
Construct3DElementSpecific(graphics, pFront, totalRight,
PIE_FRONT_RIGHT, PointF(0, 0)); //element. First draw
//only the front part of the element.
else if(pLast == pFront && bLeftAngled)
Construct3DElementSpecific(graphics, pFront,
REAL(totalRight - pFront->pie_3d_props.f_InclineAngle),
PIE_FRONT_LEFT, PointF(0, 0));
else
Construct3DElement(graphics, pFront,
(REAL)totalRight, PIE_FRONT, PointF(0, 0));
}
另一个有趣的实现是反向遍历元素。即从最前面的元素到最后一个元素,这在鼠标单击时是需要的。在第一个实现中,这是通过保存每个元素圆柱面的图形路径来完成的。由于现在要保存的图形对象太多,因此需要反向遍历以使控件在内存消耗方面更可行。
新增功能
相对可移动的饼图元素
使用绘制的 3D 元素以及其他两种设计,可以通过设置从饼图中心点的距离索引,在角方向上移动元素。
3D 饼图元素的透明度
这是通过构建所有元素面获得的一个不错的结果。
水平移动饼图
这似乎不必要,但在元素需要更多移动空间时很有用。

设置透明度、与中心的距离和水平位置的新函数如下
BOOL SetDistanceIndex(PIECHARTITEM pItem, int iDistIndex);
BOOL SetDistanceIndexAll(int iDistIndex);
BOOL SetElementTransparency(PIECHARTITEM pItem, float flPercentage);
BOOL SetElementTransparencyAll(float flPercentage);
BOOL SetHorizontalOffset(int iHrzOffset);
关注点
新更改的一个明显缺点是,动画由于需要进行更多处理而变得有点慢。但我认为这可以通过新增的质量和功能来弥补。此外,我还有机会纠正了第一个实现中的一些视觉缺陷。其中之一是饼图绘制正方形所需对称划分。这意味着正方形的面积应能对称地划分,而不会产生浮点值。换句话说,边长应为偶数。否则,两个部分将不相等,大小为 1,会导致高度倾斜的 3D 饼图出现视觉缺陷。
void CPieChartWnd::GetBoundRect(LPRECT rect){
CRect rectWn;
GetClientRect(rectWn);
rectWn.right = lb_param.xGapLeft;
rectWn.top += 100;
rectWn.left += 30;
rectWn.bottom -= 30;
if (rectWn.Height() > rectWn.Width()){
rectWn.right = max(rectWn.right, (rectWn.left + MAX_PIECHARTPARAMS));
rectWn.bottom = rectWn.top + rectWn.Width();
}
else{
rectWn.bottom = max(rectWn.bottom, (rectWn.top + MAX_PIECHARTPARAMS));
rectWn.right = rectWn.left + rectWn.Height();
}
if (rectWn.Height() % 2 != 0){ //This is added to preserve the
//symmetry of the pie
rectWn.bottom += 1; //Much important on larger inclinations
rectWn.right += 1;
lb_param.xGapLeft += 1;
}
rectWn.left += bkg_params.i_HorizontalOffset;
rectWn.right += bkg_params.i_HorizontalOffset;
CopyRect(rect, &rectWn);
}
我认为本文将对所有阅读过并对我第一篇文章提出反馈的人来说都很有趣。我本可以将源代码和演示添加到第一篇文章中,但认为写一篇新文章也不错。特别是,我想感谢 samal.subhashree,他为实现这些新改进提供了绝佳的想法和反馈。
设置合适的数值后,我得到的最漂亮的图表之一 :)

文章更新
在此,我想解决用户(包括我自己)在之前的实现中发现的一些缺陷。这还包括一些使饼图控件更易于使用的改进。此外,新的演示包含一个新的简单项目,演示如何使用新功能。
1. 缺陷一
当只有一个元素存在时,饼图在某些倾斜角度下会消失。
这是由于以浮点值进行的角度计算。
以前
if(inClineAngle - flStartIncline < 0.001 && ele->f_angle == 360)
现在
if(ele->f_angle == 360) //+ changed the comparison since
//floats are not equal all the time
因此,由于浮点值,起始角度的倾斜与饼图元素结束角度的比较会出错。因此,即使是差异比较 `inClineAngle - flStartIncline < 0.001` 也会出错。修复方法是将其删除。
2. 缺陷二
对于某些字符串,标签会被末尾字符剪裁。
原因在于,在之前的实现中,为标签保留的长度是由最长字符串值决定的。例如,有两个标签,“Item1
”和“Item11
”,最长的是“Item11
”,有 6 个字符,保留的矩形区域由字符串‘Item11
’确定。但这却是错误的。由于字符在绘制时可以采用不同的宽度,因此应该考虑“视觉长度”来确定最长字符串,而不是字符数。
在这里,“Arts-Crafts”是 11 个字符。而“Recreation”是 10 个字符。由于 11 个字符,“Arts-Crafts”被用来确定标签矩形区域的长度。这导致“Recreation”显示为“Recreatio”,因为单词“Recreation”具有最长的视觉长度。
新代码已修复此问题。
3. 缺陷三
饼图在特定角度集合下外观失真。
原因在于,在 0、90、360 度角的一些特定边界处,角度比较没有进行相等比较(只进行小于或大于比较)。新代码已修复此问题。
4. 缺陷四
标签被剪裁,而不是适应给定的窗口区域。
原因在于实现设计。我的第一个实现是在最大区域内绘制饼图,并在可用空间中绘制剩余的标签区域。因此,如果空间不足以同时绘制两者,标签就会被从末尾剪裁,用户需要分配更多区域才能完全可见。但这并不是一个好的设计。所以新的实现将
- 首先在窗口右侧相对位置绘制标签,
- 然后将饼图绘制在可用矩形区域内。
这将把组件压缩在给定窗口区域内,而不会剪裁标签区域。
5. 改进
能够为饼图和标签矩形指定绝对位置。
之前的实现都是关于相对绘制组件的。因此,它不会让用户担心将饼图和标签放置在合适的位置。但当有人需要按照自己的喜好将它们安排在特定位置时,它也提供了有限的功能。所以,在新实现中,可以指定饼图和标签矩形的矩形区域并进行定位。这将能够充分利用可用空间,使其看起来更紧凑。
示例:指定绝对位置后。
6. 附加功能
-
BOOL RemoveItemAll()
一次性移除所有项目。
-
void SetPieChartRectOverride(CRect rectPie) :
覆盖饼图矩形以指定饼图的绝对定位。
-
void SetLabalRectOverride(CRect rectLabel) :
设置标签区域的绝对定位
-
void SetLabelFontSizeOverride(int fSize) :
此功能是为了定义标签的字体大小。您可能已经注意到,字体大小是相对于饼图窗口区域的大小来确定的。通过此功能,任何人都可以覆盖该设置,并提供特定的字体大小。
注释
在最初的实现中,这些缺陷是由几位用户发现的,我在修复这些问题时也发现了一些。饼图和标签的绝对定位将是一个不错的改进,因为它允许用户定义位置并按要求进行排列。但使用它将使用户需要处理饼图窗口区域的调整大小以及这些组件如何根据这些变化移动或调整大小。我决定写这篇文章,因为我相信我的前一篇文章的读者会觉得这些改进很有趣。
演示包含一个名为 `pieChart2` 的新简单项目,该项目演示了如何为饼图和标签区域这两个组件设置绝对定位。
此外,我想感谢那些帮助发现这些问题的用户。 :)