425 lines
14 KiB
C#
425 lines
14 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Windows;
|
||
using System.Windows.Controls;
|
||
using System.Windows.Media;
|
||
using System.Windows.Shapes;
|
||
using _29WxOrderWpf.Ctrls.Statistics.Models;
|
||
|
||
namespace _29WxOrderWpf.Ctrls.Statistics.Charts
|
||
{
|
||
/// <summary>
|
||
/// 店铺销售占比饼图(轻量自绘,无第三方依赖)。
|
||
/// 绑定 <see cref="ItemsSource"/>(IList<ShopSalesProportionItem>)即可,数据变化时自动重绘。
|
||
/// 设置 <see cref="MaxItems"/>(>0)可只显示前 N 项,其余项合并为一个“<see cref="OtherLabel"/>”扇区(百分比为剩余总和,整体仍为 100%)。
|
||
/// 设置 <see cref="Title"/> 可在环形图中央留白处显示标题(<see cref="TitlePlacement"/> 可切换为顶部),不会遮挡扇形;
|
||
/// 图例通过 <see cref="LegendPosition"/> 可放置到 4 个角,饼图区域由独立 panel 包裹,方便整体挪动和调整大小。
|
||
/// </summary>
|
||
public partial class RevenuePieChart : UserControl
|
||
{
|
||
/// <summary>标题显示位置</summary>
|
||
public enum PieTitlePlacement
|
||
{
|
||
/// <summary>居中显示在环形图中央留白处(默认,不遮挡扇形)</summary>
|
||
Center,
|
||
|
||
/// <summary>显示在图表顶部</summary>
|
||
Top
|
||
}
|
||
|
||
/// <summary>图例显示位置(4 个角)</summary>
|
||
public enum PieLegendPosition
|
||
{
|
||
/// <summary>左上角</summary>
|
||
TopLeft,
|
||
|
||
/// <summary>右上角(默认)</summary>
|
||
TopRight,
|
||
|
||
/// <summary>左下角</summary>
|
||
BottomLeft,
|
||
|
||
/// <summary>右下角</summary>
|
||
BottomRight
|
||
}
|
||
|
||
/// <summary>待绘制扇区(含颜色)</summary>
|
||
private sealed class DisplaySlice
|
||
{
|
||
public ShopSalesProportionItem Item;
|
||
public Brush Brush;
|
||
}
|
||
|
||
/// <summary>ECharts 默认配色</summary>
|
||
private static readonly Color[] Palette =
|
||
{
|
||
Color.FromRgb(84, 112, 198),
|
||
Color.FromRgb(145, 204, 117),
|
||
Color.FromRgb(250, 200, 88),
|
||
Color.FromRgb(238, 102, 102),
|
||
Color.FromRgb(115, 192, 222),
|
||
Color.FromRgb(59, 162, 114),
|
||
Color.FromRgb(252, 132, 82),
|
||
Color.FromRgb(154, 96, 180),
|
||
Color.FromRgb(234, 124, 204)
|
||
};
|
||
|
||
/// <summary>“其他”合并项默认颜色(灰色)</summary>
|
||
private static readonly Color OtherColor = Color.FromRgb(192, 196, 204);
|
||
|
||
public static readonly DependencyProperty ItemsSourceProperty =
|
||
DependencyProperty.Register("ItemsSource", typeof(IList<ShopSalesProportionItem>), typeof(RevenuePieChart),
|
||
new PropertyMetadata(null, OnRenderPropertyChanged));
|
||
|
||
/// <summary>占比数据</summary>
|
||
public IList<ShopSalesProportionItem> ItemsSource
|
||
{
|
||
get { return (IList<ShopSalesProportionItem>)GetValue(ItemsSourceProperty); }
|
||
set { SetValue(ItemsSourceProperty, value); }
|
||
}
|
||
|
||
public static readonly DependencyProperty MaxItemsProperty =
|
||
DependencyProperty.Register("MaxItems", typeof(int), typeof(RevenuePieChart),
|
||
new PropertyMetadata(0, OnRenderPropertyChanged));
|
||
|
||
/// <summary>最多显示的独立扇区数(>0 时其余项合并为“其他”;<=0 显示全部)</summary>
|
||
public int MaxItems
|
||
{
|
||
get { return (int)GetValue(MaxItemsProperty); }
|
||
set { SetValue(MaxItemsProperty, value); }
|
||
}
|
||
|
||
public static readonly DependencyProperty OtherLabelProperty =
|
||
DependencyProperty.Register("OtherLabel", typeof(string), typeof(RevenuePieChart),
|
||
new PropertyMetadata("其他", OnRenderPropertyChanged));
|
||
|
||
/// <summary>合并项的显示名称(默认“其他”)</summary>
|
||
public string OtherLabel
|
||
{
|
||
get { return (string)GetValue(OtherLabelProperty); }
|
||
set { SetValue(OtherLabelProperty, value); }
|
||
}
|
||
|
||
public static readonly DependencyProperty OtherBrushProperty =
|
||
DependencyProperty.Register("OtherBrush", typeof(Brush), typeof(RevenuePieChart),
|
||
new PropertyMetadata(new SolidColorBrush(OtherColor), OnRenderPropertyChanged));
|
||
|
||
/// <summary>合并项的颜色(默认灰色)</summary>
|
||
public Brush OtherBrush
|
||
{
|
||
get { return (Brush)GetValue(OtherBrushProperty); }
|
||
set { SetValue(OtherBrushProperty, value); }
|
||
}
|
||
|
||
public static readonly DependencyProperty TitleProperty =
|
||
DependencyProperty.Register("Title", typeof(string), typeof(RevenuePieChart),
|
||
new PropertyMetadata(null, OnRenderPropertyChanged));
|
||
|
||
/// <summary>标题文本(空/Null 不显示)</summary>
|
||
public string Title
|
||
{
|
||
get { return (string)GetValue(TitleProperty); }
|
||
set { SetValue(TitleProperty, value); }
|
||
}
|
||
|
||
public static readonly DependencyProperty TitlePlacementProperty =
|
||
DependencyProperty.Register("TitlePlacement", typeof(PieTitlePlacement), typeof(RevenuePieChart),
|
||
new PropertyMetadata(PieTitlePlacement.Center, OnRenderPropertyChanged));
|
||
|
||
/// <summary>标题显示位置(默认居中显示在环形图留白处,不遮挡扇形)</summary>
|
||
public PieTitlePlacement TitlePlacement
|
||
{
|
||
get { return (PieTitlePlacement)GetValue(TitlePlacementProperty); }
|
||
set { SetValue(TitlePlacementProperty, value); }
|
||
}
|
||
|
||
public static readonly DependencyProperty TitleFontSizeProperty =
|
||
DependencyProperty.Register("TitleFontSize", typeof(double), typeof(RevenuePieChart),
|
||
new PropertyMetadata(14d, OnRenderPropertyChanged));
|
||
|
||
/// <summary>标题字号</summary>
|
||
public double TitleFontSize
|
||
{
|
||
get { return (double)GetValue(TitleFontSizeProperty); }
|
||
set { SetValue(TitleFontSizeProperty, value); }
|
||
}
|
||
|
||
public static readonly DependencyProperty TitleBrushProperty =
|
||
DependencyProperty.Register("TitleBrush", typeof(Brush), typeof(RevenuePieChart),
|
||
new PropertyMetadata(new SolidColorBrush(Color.FromRgb(51, 51, 51)), OnRenderPropertyChanged));
|
||
|
||
/// <summary>标题颜色</summary>
|
||
public Brush TitleBrush
|
||
{
|
||
get { return (Brush)GetValue(TitleBrushProperty); }
|
||
set { SetValue(TitleBrushProperty, value); }
|
||
}
|
||
|
||
public static readonly DependencyProperty LegendPositionProperty =
|
||
DependencyProperty.Register("LegendPosition", typeof(PieLegendPosition), typeof(RevenuePieChart),
|
||
new PropertyMetadata(PieLegendPosition.TopRight, OnRenderPropertyChanged));
|
||
|
||
/// <summary>图例显示位置(默认右上角,与饼图分列放置,不遮挡)</summary>
|
||
public PieLegendPosition LegendPosition
|
||
{
|
||
get { return (PieLegendPosition)GetValue(LegendPositionProperty); }
|
||
set { SetValue(LegendPositionProperty, value); }
|
||
}
|
||
|
||
public RevenuePieChart()
|
||
{
|
||
InitializeComponent();
|
||
Loaded += delegate { Render(); };
|
||
SizeChanged += delegate { Render(); };
|
||
}
|
||
|
||
private static void OnRenderPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||
{
|
||
((RevenuePieChart)d).Render();
|
||
}
|
||
|
||
private void Render()
|
||
{
|
||
LayoutRoot.Children.Clear();
|
||
var items = ItemsSource;
|
||
if (items == null || items.Count == 0 || ActualWidth <= 20 || ActualHeight <= 20)
|
||
return;
|
||
|
||
double width = ActualWidth;
|
||
double height = ActualHeight;
|
||
|
||
// 图例列宽:图例占左/右一列,饼图占另一列,天然不会互相遮挡
|
||
double legendWidth = Math.Min(210, width * 0.38);
|
||
bool legendOnLeft = LegendPosition == PieLegendPosition.TopLeft
|
||
|| LegendPosition == PieLegendPosition.BottomLeft;
|
||
|
||
double pieLeft = legendOnLeft ? legendWidth : 0;
|
||
double pieWidth = Math.Max(10, width - legendWidth);
|
||
double pieHeight = height;
|
||
|
||
// 顶部标题:先预留高度,饼图相应下移,避免遮挡
|
||
double topTitleHeight = 0;
|
||
if (TitlePlacement == PieTitlePlacement.Top && !string.IsNullOrEmpty(Title))
|
||
{
|
||
var probe = CreateTitleBlock(pieWidth);
|
||
probe.Measure(new Size(pieWidth, double.PositiveInfinity));
|
||
topTitleHeight = Math.Ceiling(probe.DesiredSize.Height) + 8;
|
||
}
|
||
|
||
// 饼图区域用独立 Canvas 包裹,方便整体挪动(SetLeft/SetTop)和修改大小(Width/Height)
|
||
var piePanel = new Canvas { Width = pieWidth, Height = pieHeight };
|
||
LayoutRoot.Children.Add(piePanel);
|
||
Canvas.SetLeft(piePanel, pieLeft);
|
||
Canvas.SetTop(piePanel, 0);
|
||
|
||
double cx = pieWidth / 2;
|
||
double cy = (topTitleHeight + pieHeight) / 2;
|
||
double radius = Math.Max(10, Math.Min(pieWidth, pieHeight - topTitleHeight) / 2 - 18);
|
||
|
||
// 0. 生成实际绘制的扇区列表(MaxItems > 0 时其余项合并为“其他”)
|
||
var slices = BuildSlices(items);
|
||
|
||
// 1. 绘制扇形
|
||
double startAngle = -90; // 从正上方开始,顺时针
|
||
foreach (var slice in slices)
|
||
{
|
||
double sweep = slice.Item.Percentage / 100d * 360d;
|
||
if (sweep < 0) sweep = 0;
|
||
var brush = slice.Brush;
|
||
|
||
if (sweep >= 359.9)
|
||
{
|
||
var circle = new Ellipse
|
||
{
|
||
Width = radius * 2,
|
||
Height = radius * 2,
|
||
Fill = brush
|
||
};
|
||
piePanel.Children.Add(circle);
|
||
Canvas.SetLeft(circle, cx - radius);
|
||
Canvas.SetTop(circle, cy - radius);
|
||
}
|
||
else if (sweep > 0.01)
|
||
{
|
||
piePanel.Children.Add(CreateSlice(cx, cy, radius, startAngle, sweep, brush));
|
||
}
|
||
startAngle += sweep;
|
||
}
|
||
|
||
// 2. 中间留白圆心
|
||
double holeRadius = radius * 0.55;
|
||
var hole = new Ellipse
|
||
{
|
||
Width = holeRadius * 2,
|
||
Height = holeRadius * 2,
|
||
Fill = Brushes.White
|
||
};
|
||
piePanel.Children.Add(hole);
|
||
Canvas.SetLeft(hole, cx - holeRadius);
|
||
Canvas.SetTop(hole, cy - holeRadius);
|
||
|
||
// 3. 标题(居中显示在环形图留白处,宽度限制在留白直径内,不遮挡扇形)
|
||
if (!string.IsNullOrEmpty(Title))
|
||
{
|
||
double maxW = TitlePlacement == PieTitlePlacement.Center
|
||
? Math.Max(20, holeRadius * 2 - 8)
|
||
: pieWidth;
|
||
var titleBlock = CreateTitleBlock(maxW);
|
||
titleBlock.Measure(new Size(maxW, double.PositiveInfinity));
|
||
double w = Math.Min(titleBlock.DesiredSize.Width, maxW);
|
||
double h = titleBlock.DesiredSize.Height;
|
||
|
||
piePanel.Children.Add(titleBlock);
|
||
Canvas.SetLeft(titleBlock, cx - w / 2);
|
||
Canvas.SetTop(titleBlock, TitlePlacement == PieTitlePlacement.Center ? cy - h / 2 : 0);
|
||
}
|
||
|
||
// 4. 图例(4 个角可设置,与饼图分列,不遮挡)
|
||
var legendPanel = CreateLegendPanel(slices, legendWidth - 16);
|
||
legendPanel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||
double legendW = legendPanel.DesiredSize.Width;
|
||
double legendH = legendPanel.DesiredSize.Height;
|
||
|
||
const double padding = 8;
|
||
double lx, ly;
|
||
if (LegendPosition == PieLegendPosition.TopLeft || LegendPosition == PieLegendPosition.TopRight)
|
||
ly = padding;
|
||
else
|
||
ly = Math.Max(padding, height - legendH - padding);
|
||
lx = legendOnLeft ? padding : width - legendW - padding;
|
||
|
||
LayoutRoot.Children.Add(legendPanel);
|
||
Canvas.SetLeft(legendPanel, lx);
|
||
Canvas.SetTop(legendPanel, ly);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据 <see cref="MaxItems"/> 生成实际绘制的扇区列表:
|
||
/// 按占比从大到小取前 N 项单独显示,其余项合并为一个“其他”扇区(百分比为剩余总和,整体仍为 100%)。
|
||
/// </summary>
|
||
private List<DisplaySlice> BuildSlices(IList<ShopSalesProportionItem> items)
|
||
{
|
||
var result = new List<DisplaySlice>();
|
||
if (items == null || items.Count == 0)
|
||
return result;
|
||
|
||
var ordered = items
|
||
.OrderByDescending(i => i.Percentage)
|
||
.ThenByDescending(i => i.SalesAmount)
|
||
.ToList();
|
||
|
||
int max = MaxItems > 0 ? MaxItems : ordered.Count;
|
||
if (max >= ordered.Count)
|
||
{
|
||
for (int i = 0; i < ordered.Count; i++)
|
||
result.Add(new DisplaySlice
|
||
{
|
||
Item = ordered[i],
|
||
Brush = new SolidColorBrush(Palette[i % Palette.Length])
|
||
});
|
||
return result;
|
||
}
|
||
|
||
for (int i = 0; i < max; i++)
|
||
result.Add(new DisplaySlice
|
||
{
|
||
Item = ordered[i],
|
||
Brush = new SolidColorBrush(Palette[i % Palette.Length])
|
||
});
|
||
|
||
var rest = ordered.Skip(max).ToList();
|
||
result.Add(new DisplaySlice
|
||
{
|
||
Item = new ShopSalesProportionItem
|
||
{
|
||
ShopName = string.IsNullOrEmpty(OtherLabel) ? "其他" : OtherLabel,
|
||
Percentage = rest.Sum(r => r.Percentage),
|
||
SalesAmount = rest.Sum(r => r.SalesAmount)
|
||
},
|
||
Brush = OtherBrush ?? new SolidColorBrush(OtherColor)
|
||
});
|
||
return result;
|
||
}
|
||
|
||
private TextBlock CreateTitleBlock(double maxWidth)
|
||
{
|
||
var brush = TitleBrush ?? new SolidColorBrush(Color.FromRgb(51, 51, 51));
|
||
return new TextBlock
|
||
{
|
||
Text = Title,
|
||
FontSize = TitleFontSize,
|
||
FontWeight = FontWeights.Bold,
|
||
Foreground = brush,
|
||
TextAlignment = TextAlignment.Center,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
MaxWidth = maxWidth
|
||
};
|
||
}
|
||
|
||
private static StackPanel CreateLegendPanel(IList<DisplaySlice> slices, double maxWidth)
|
||
{
|
||
double maxLegendWidth = Math.Max(60, maxWidth);
|
||
var legendPanel = new StackPanel { MaxWidth = maxLegendWidth };
|
||
foreach (var slice in slices)
|
||
{
|
||
var legendRow = new StackPanel
|
||
{
|
||
Orientation = Orientation.Horizontal,
|
||
Margin = new Thickness(0, 4, 0, 4)
|
||
};
|
||
legendRow.Children.Add(new Rectangle
|
||
{
|
||
Width = 12,
|
||
Height = 12,
|
||
Fill = slice.Brush
|
||
});
|
||
|
||
var text = new TextBlock
|
||
{
|
||
Text = slice.Item.ShopName + " " + slice.Item.Percentage.ToString("0.0") + "%",
|
||
FontSize = 12,
|
||
Foreground = new SolidColorBrush(Color.FromRgb(102, 102, 102)),
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
MaxWidth = Math.Max(40, maxLegendWidth - 18),
|
||
TextTrimming = TextTrimming.CharacterEllipsis
|
||
};
|
||
legendRow.Children.Add(text);
|
||
legendPanel.Children.Add(legendRow);
|
||
}
|
||
return legendPanel;
|
||
}
|
||
|
||
private static Path CreateSlice(double cx, double cy, double radius, double startAngle, double sweep, Brush fill)
|
||
{
|
||
var start = PointOnCircle(cx, cy, radius, startAngle);
|
||
var end = PointOnCircle(cx, cy, radius, startAngle + sweep);
|
||
|
||
var figure = new PathFigure { StartPoint = new Point(cx, cy), IsClosed = true, IsFilled = true };
|
||
figure.Segments.Add(new LineSegment { Point = start });
|
||
figure.Segments.Add(new ArcSegment
|
||
{
|
||
Point = end,
|
||
Size = new Size(radius, radius),
|
||
IsLargeArc = sweep > 180,
|
||
SweepDirection = SweepDirection.Clockwise
|
||
});
|
||
|
||
return new Path
|
||
{
|
||
Data = new PathGeometry { Figures = { figure } },
|
||
Fill = fill,
|
||
Stroke = Brushes.White,
|
||
StrokeThickness = 1
|
||
};
|
||
}
|
||
|
||
private static Point PointOnCircle(double cx, double cy, double radius, double angle)
|
||
{
|
||
double rad = angle * Math.PI / 180d;
|
||
return new Point(cx + radius * Math.Cos(rad), cy + radius * Math.Sin(rad));
|
||
}
|
||
}
|
||
} |