Files
29WxOrder/29WxOrderWpf/Ctrls/Statistics/Charts/RevenuePieChart.xaml.cs
T
“chfei” 7c6f91f207 新增加 利润统计Window。
隐藏利润统计按钮
增加 Common 文件夹 内部是WPF公用类库
2026-08-05 18:55:03 +08:00

425 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&lt;ShopSalesProportionItem&gt;)即可,数据变化时自动重绘。
/// 设置 <see cref="MaxItems"/>(&gt;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>最多显示的独立扇区数(&gt;0 时其余项合并为“其他”;&lt;=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 &gt; 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));
}
}
}