Beschreibung:
Im Rahmen einer ASP.NET MVC-Anwendung brauchte ich eine Komponente, die bestimmte Sachverhalte als Kreisdiagramm darstellt. Dabei hat mir das 3D Pie Chart auf CodeProject sehr gut gefallen, allerdings war beispielsweise die Beschriftung nicht ganz so, wie ich mir das gewünscht hätte. Zudem war die ganze Interaktivität dieser Komponente in einer Webanwendung nutzlos (wie gesagt sollte einfach nur ein Bild erstellt werden).
Also habe ich mich davon inspirieren lassen und eine eigene Komponente zum Erstellen von Kreisdiagrammen erstellt, die ich euch nicht vorenthalten möchte 😉
Aufbau:
Die Komponente besteht im Wesentlichen aus drei Teilen:*Kuchenstücke (PieSlice
) mit Angaben über Start-/Endwinkel, Farbe, Beschriftung, Entfernung von der Mitte des Kreisdiagramms.
*Helferklasse (PieChartHelper
), die für eine Schlüsselwertpaar-Auflistung die entsprechenden Kuchenstücke erstellt. UPDATE: Oder auch generisch für beliebige Auflistungen mit benutzerdefinierten Beschriftungen.
*Klasse, die einfach stur die übergebenen Kuchenstücke in 3D zeichnet (PieChart
; Animation des Algorithmus siehe unten). Dabei sind Höhe, Breite und Tiefe des Kreisdiagramms, Transparenz der Farben, Schriftart für Beschriftung und Entfernung der Beschriftungen einstellbar.
Die "mitgelieferte" Helferklasse erzeugt Tortenstücke und Beschriftung entsprechend des prozentualen Anteils eines Elements. Die Farbe der Stücke wird automatisch generiert (gleichmäßig eine Runde durch den HSB-Farbraum). Außerdem bietet sie die Möglichkeit, bestimmte Stücke hervorzuheben, indem sie etwas aus der Mitte herausgerückt werden.
Die Größe des von der Zeichenkomponente erstellten Bildes richtet sich nach dem Inhalt. Die einstellbare Größe des Kreisdiagramms bezieht sich dabei immer auf die Ausmaße des Kreisdiagramms ohne Beschriftung und ohne herausgeschobene Stücke. Das heißt beispielsweise, je länger die Beschriftungen, desto breiter wird das resultierende Bild.
Beispiele:
15 Elemente gesamt, Elemente mit Anteil kleiner 5% werden zusammengefasst, Stücke "Zehn" und "Sechs" hervorgehoben:
// Schlüsselwertpaare erzeugen, die visualisiert werden sollen
Dictionary<string,int> dict = new Dictionary<string,int> { { "Eins",1 },{ "Zwei",2 },{ "Drei",3 },{ "Vier",4 },{ "Fünf",5 },{ "Sechs",6 },{ "Sieben",7 },{ "Acht",8 },{ "Neun",9 },{ "Zehn",10 },{ "Elf",11 },{ "Zwölf",12 },{ "Dreizehn",13 },{ "Zwanzig",20 },{ "Dreißig",30 }, };
IEnumerable<KeyValuePair<string,double>> values = dict.Keys
.Select(key => new KeyValuePair<string,double>(key,dict[key]))
.OrderByDescending(kvp => kvp.Key);
// Zeichenklasse erstellen
PieChart pieChart = new PieChart
{
EllipseWidth = 200,
EllipseHeight = 100,
PieHeight = 15
};
// (ExtensionMethod der) Helferklasse aufrufen
// mehrere Stücke unter 5% werden zusammengefasst, zwei Stücke hervorheben
Bitmap bitmap = pieChart.DrawPercent(values,5f,"Sechs","Zehn");
10 Elemente gesamt, Elemente mit Anteil kleiner 5% werden zusammengefasst, Stücke "Zehn" und "Sechs" hervorgehoben:
15 Elemente gesamt, Elemente mit Anteil kleiner 5% werden zusammengefasst:
22 Elemente gesamt, Elemente mit Anteil kleiner 2,5% werden zusammengefasst:
Visualisierung des Algorithmus zum Zeichnen:
**bekannte Probleme:***Ein 100% Stück und zusätzliche 0%-Stücke können nicht dargestellt werden (da alle Start und Endwinkel gleich wären, kann nicht ermittelt werden, welches Stück 360° und welche 0° aufspannen). *Bei einem Kreisdiagramm, das Stücke mit unterschiedlichen Verschiebungsweiten aus dem Mittelpunkt kann es dazu kommen, dass Seitenflächen über andere eigentlich weiter vorn liegende Flächen gezeichnet werden (was aber nicht weiter auffällt). *Der Hintergrund ist Momentan weiß (und nicht konfigurierbar). Für transparente Hintergründe müsste noch DrawString auf transparenten Hintergrund implementiert werden.
Code:
Achtung: Für diese Hilfsklasse gibt es weiter unten noch eine erweiterte und generische Variante.
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
namespace Charting
{
public static class PieChartHelper
{
/// <summary>
/// Erstellt ein Kreisdiagramm für die übergebenen Elemente, die mit dem jeweiligen prozentualen Anteil beschriftet sind
/// </summary>
/// <param name="pieChart">Komponente zum Zeichnen von Kreisdiagrammen</param>
/// <param name="values">Auflistung mit den Elementen, die als Kuchenstücke visualisiert werden sollen.</param>
/// <param name="percentageThreshold">Prozent-Schwellwert, ab dem ein Element unter "sonstiges" verbucht wird.</param>
/// <param name="emphasizeKeys">Namen der Elemente, die durch weiteres Herausschieben aus der Mitte des Kreisdiagramms hervorgehoben werden sollen.</param>
/// <returns>Bitmap-Objekt, auf das das Kreisdiagramm gezeichnet wurde.</returns>
public static Bitmap DrawPercent(this PieChart pieChart,IEnumerable<KeyValuePair<string,double>> values,float percentageThreshold,params string[] emphasizeKeys)
{
// prozentuale Anteile ermitteln
double sum = values.Sum(kvp => kvp.Value);
var percentages = values.Select(kvp => new { Text = kvp.Key,Percent = kvp.Value*100/sum });
// wenn es mehrere Kuchenstücke gibt, deren prozentualer Anteil unter dem Schwellwert liegen, diese zu einem einzigen Stück zusammenfassen
var slicesToMerge = percentages.Where(item => item.Percent<percentageThreshold && !emphasizeKeys.Contains(item.Text));
if (slicesToMerge.Count()>1)
{
var mergedSlice = new { Text = slicesToMerge.Count()+" other <"+percentageThreshold+"%",Percent = slicesToMerge.Sum(item => item.Percent) };
percentages = percentages.Where(item => !slicesToMerge.Contains(item)).Concat(new int[] { 1 }.Select((j) => mergedSlice));
}
// Kuchenstückobjekte erzeugen
float startAngle = 20;
List<PieChart.PieSlice> pieSlices = percentages.Select((item,index) => new PieChart.PieSlice
{
Offset = (emphasizeKeys.Contains(item.Text)) ? 15 : 3,
Text = item.Text+"|"+item.Percent.ToString("f1")+"%",
StartAngle = startAngle,
EndAngle = startAngle = (float)(startAngle+(item.Percent/100)*360)%360,
Color = PieChartHelper.generateColor(index,percentages.Count())
}).ToList();
// Kreisdiagramm zeichnen
return pieChart.Draw(pieSlices);
}
/// <summary>
/// Generiert eine Farbe
/// </summary>
/// <param name="index">Index der Farbe</param>
/// <param name="count">Anzahl der Farben, die generiert werden sollen/können.</param>
/// <returns>generierte Farbe</returns>
private static Color generateColor(int index,int count)
{
// einmal quer durch die Farbtöne im HSB-Farbraum
float angle = (index/(float)count) * 360;
return ColorHelper.FromHSB(angle,1,1);
}
}
}
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
namespace Charting
{
/// <summary>
/// Stellt Funktionen bereit, um Kreisdiagramme zu erstellen
/// </summary>
public class PieChart
{
/// <summary>
/// Dargestelltes Kuchenstück des Kreisdiagramms/"Kuchendiagramm"
/// </summary>
public class PieSlice
{
/// <summary> Ruft die Beschriftung ab oder legt diese fest. </summary>
public string Text { get; set; }
/// <summary> Ruft den Winkel, bei dem das Kuchenstück anfängt, ab oder legt diesen fest. </summary>
public float StartAngle { get; set; }
/// <summary> Ruft den Winkel, bei dem das Kuchenstück aufhört, ab oder legt diesen fest. </summary>
public float EndAngle { get; set; }
/// <summary> Ruft den Winkel, der vom Kuchenstück aufgespannt wird, ab. </summary>
public float SweepAngle { get { return (this.EndAngle-this.StartAngle+360)%360; } }
/// <summary> Ruft die Farbe des Kuchenstücks ab oder legt diese fest. </summary>
public Color Color { get; set; }
/// <summary> Ruft die Verschiebung des Kuchenstücks aus der Mittelpunkt des Kreisdiagramms ab oder legt diese fest. </summary>
public int Offset { get; set; }
}
/// <summary> Ruft die Breite des Kreisdiagramms ab oder legt diese fest. </summary>
public int EllipseWidth { get; set; }
/// <summary> Ruft die Höhe des Kreisdiagramms ab oder legt diese fest. </summary>
public int EllipseHeight { get; set; }
/// <summary> Ruft die Höhe das Kuchenstücke ("Kuchenstücke") ab oder legt diese fest. </summary>
public int PieHeight { get; set; }
/// <summary> Ruft die Durchlässigkeit der Farben der Flächen des Kreisdiagramms ab oder legt diese fest- </summary>
public byte Opacity { get; set; }
/// <summary> Ruft die Schriftart für die Anzeige von Text ab oder legt diese fest. </summary>
public Font LegendFont { get; set; }
/// <summary> Ruft die Entfernung der Beschriftung vom Rand eines Kuchenstücks ab oder legt diese fest. </summary>
public int LegendDistance { get; set; }
/// <summary> Ruft die Breite der gesamten Zeichenfläche ab. </summary>
public int Width { get; private set; }
/// <summary> Ruft die Höhe der gesamten Zeichenfläche ab. </summary>
public int Height { get; private set; }
/// <summary>
/// Initialisiert eine neue PieChart-Klasse
/// </summary>
public PieChart()
{
this.LegendFont = new Font("Verdana",7.8f,FontStyle.Bold);
this.LegendDistance = 20;
this.EllipseWidth = 200;
this.EllipseHeight = 100;
this.Opacity = 130;
this.PieHeight = 15;
}
/// <summary>
/// Zeichnet ein neues Kreisdiagramm
/// </summary>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen.</param>
/// <returns>Bitmap-Objekt, auf das das Kreisdiagramm gezeichnet wurde.</returns>
public Bitmap Draw(IList<PieSlice> pieSlices)
{
// (Henne-Ei-Problem: Um die Bitmap-Größe zu bestimmen, braucht man ein Graphics-Objekt, für ein Graphics-Objekt braucht man ein Bitmap)
Bitmap bitmap = new Bitmap(1,1);
Graphics graphics = Graphics.FromImage(bitmap);
if (pieSlices.Count==0)
return bitmap;
// Höhe und Breite des Bitmaps anhand der Texte der Kuchenstücke, der Offsets und der Entfernung der Beschriftungen ermitteln
int maxWidthOffset = pieSlices.Max(ps => ps.Offset) + this.LegendDistance + pieSlices.Max(ps => (int)graphics.MeasureString(ps.Text,this.LegendFont).Width);
int maxHeightOffset = pieSlices.Max(ps => ps.Offset) + this.LegendDistance + pieSlices.Max(ps => (int)graphics.MeasureString(ps.Text,this.LegendFont).Height);
this.Width = this.EllipseWidth + 2*maxWidthOffset + 1;
this.Height = this.EllipseHeight + 2*maxHeightOffset + this.PieHeight + 1;
// (so, jetzt das richtige Bild erstellen)
graphics.Dispose();
bitmap.Dispose();
bitmap = new Bitmap(this.Width,this.Height);
using (graphics = Graphics.FromImage(bitmap))
{
// weißer Hintergrund und AntiAlias
graphics.FillRectangle(Brushes.White,0,0,this.Width,this.Height);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
// Kuchenstücke zeichnen
graphics.TranslateTransform(maxWidthOffset,maxHeightOffset+this.PieHeight); // Koordinatensystem so schieben, dass die Beschriftungen links und oben ranpassen
if (pieSlices.Count==1 && pieSlices[0].StartAngle==pieSlices[0].EndAngle)
this.DrawLonelySlice(graphics,pieSlices[0]); // 100%-Stück
else
{
this.DrawBottoms(graphics,pieSlices); // Kreisausschnitte unten
this.DrawBackgroundSurfaces(graphics,pieSlices); // hintere Seitenflächen
this.DrawCuttingEdges(graphics,pieSlices); // Schnittflächen
this.DrawForegroundSurfaces(graphics,pieSlices); // vordere Seitenflächen
this.DrawTops(graphics,pieSlices); // Kreisausschnitte oben
}
// Beschriftungen hinzufügen
this.DrawTexts(graphics,pieSlices);
}
return bitmap;
}
/// <summary>
/// Zeichnet ein einzelnes 100%-Stück
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlice">Kuchenstück, das gezeichnet werden soll</param>
private void DrawLonelySlice(Graphics graphics,PieSlice pieSlice)
{
using (Pen pen = new Pen(pieSlice.Color.ChangeBrightness(0.8f)))
using (Brush brush = new SolidBrush(Color.FromArgb(this.Opacity,pieSlice.Color)))
{
PointF sliceFocus = this.GetSliceFocus(pieSlice);
// Fläche und Kontur unten
graphics.FillEllipse(brush,sliceFocus.X,sliceFocus.Y,this.EllipseWidth,this.EllipseHeight);
graphics.DrawEllipse(pen,sliceFocus.X,sliceFocus.Y,this.EllipseWidth,this.EllipseHeight);
// Seitenflächen hinten und vorn
this.DrawSurfaces(graphics,new PieSlice[] { pieSlice },ps => 180,ps => 360);
this.DrawSurfaces(graphics,new PieSlice[] { pieSlice },ps => 0,ps => 180);
// Fläche und Kontur oben
graphics.FillEllipse(brush,sliceFocus.X,sliceFocus.Y-this.PieHeight,this.EllipseWidth,this.EllipseHeight);
graphics.DrawEllipse(pen,sliceFocus.X,sliceFocus.Y-this.PieHeight,this.EllipseWidth,this.EllipseHeight);
}
}
/// <summary>
/// Zeichnet die Bodenflächen aller Kuchenstücke
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen</param>
private void DrawBottoms(Graphics graphics,IEnumerable<PieSlice> pieSlices)
{
this.DrawPlanes(graphics,pieSlices,0);
}
/// <summary>
/// Zeichnet die Oberseiten aller Kuchenstücke
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen</param>
private void DrawTops(Graphics graphics,IEnumerable<PieSlice> pieSlices)
{
this.DrawPlanes(graphics,pieSlices,this.PieHeight);
}
/// <summary>
/// Zeichnet die Kreisausschnittflächen der Kuchenstücke
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen</param>
/// <param name="altitude">relative Höhe der Flächen</param>
private void DrawPlanes(Graphics graphics,IEnumerable<PieSlice> pieSlices,int altitude)
{
foreach (PieSlice pieSlice in pieSlices)
using (Pen pen = new Pen(pieSlice.Color.ChangeBrightness(0.8f)))
using (Brush brush = new SolidBrush(Color.FromArgb(this.Opacity,pieSlice.Color)))
{
// Mittelpunkt der Ellipse und transformierte Start-/Endwinkel ermitteln
float startAngleT = TransformAngle(pieSlice.StartAngle);
float sweepAngleT = (TransformAngle(pieSlice.EndAngle)-startAngleT+360)%360;
PointF sliceFocus = this.GetSliceFocus(pieSlice);
// Kreisausschnittfläche und Kontur zeichnen
graphics.FillPie(brush,sliceFocus.X,sliceFocus.Y-altitude,this.EllipseWidth,this.EllipseHeight,startAngleT,sweepAngleT);
graphics.DrawPie(pen,sliceFocus.X,sliceFocus.Y-altitude,this.EllipseWidth,this.EllipseHeight,startAngleT,sweepAngleT);
}
}
/// <summary>
/// Zeichnet alle Seitenflächen der Kuchenstücke, die im Hintergrund liegen
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen</param>
private void DrawBackgroundSurfaces(Graphics graphics,IEnumerable<PieSlice> pieSlices)
{
// Seitenflächen ermitteln, die im Hintergrund liegen, und von hinten nach vorn sortieren
IEnumerable<PieSlice> slicesWithBackground = pieSlices.Where(p => p.StartAngle>180 || p.EndAngle>180 || p.StartAngle>p.EndAngle);
slicesWithBackground = slicesWithBackground.OrderBy(ps => Math.Abs(ps.StartAngle+ps.SweepAngle/2-270));
// entsprechende Seitenflächen zeichnen (fangen frühestens bei 180° an und hören spätestens bei 360° auf)
Func<PieSlice,float> start = (pieSlice) => Math.Max(180,pieSlice.StartAngle);
Func<PieSlice,float> end = (pieSlice) => (pieSlice.EndAngle>180) ? pieSlice.EndAngle : 0;
this.DrawSurfaces(graphics,slicesWithBackground,start,end);
}
/// <summary>
/// Zeichnet alle Seitenflächen der Kuchenstücke, die im Vordergrund liegen
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen</param>
private void DrawForegroundSurfaces(Graphics graphics,IEnumerable<PieSlice> pieSlices)
{
// Seitenflächen ermitteln, die im Vordergrund liegen, und von hinten nach vorn sortieren
IEnumerable<PieSlice> slicesWithForeground = pieSlices.Where(p => p.StartAngle<180 || p.EndAngle<180 || p.StartAngle>p.EndAngle);
slicesWithForeground = slicesWithForeground.OrderByDescending(ps => Math.Abs(ps.StartAngle+ps.SweepAngle/2-90));
// entsprechende Seitenflächen zeichnen (fangen frühestens bei 0° an und hören spätestens bei 180° auf)
Func<PieSlice,float> start = (pieSlice) => (pieSlice.StartAngle<180) ? pieSlice.StartAngle : 0;
Func<PieSlice,float> end = (pieSlice) => Math.Min(180,pieSlice.EndAngle);
this.DrawSurfaces(graphics,slicesWithForeground,start,end);
}
/// <summary>
/// Zeichnet die Seitenflächen der Kuchenstücke
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen</param>
/// <param name="getStartAngle">Delegat zur Bestimmung des Startwinkels eines Kreisausschnitts.</param>
/// <param name="getEndAngle">Delegat zur Bestimmung des Endwinkels eines Kreisausschnitts.</param>
private void DrawSurfaces(Graphics graphics,IEnumerable<PieSlice> pieSlices,Func<PieSlice,float> getStartAngle,Func<PieSlice,float> getEndAngle)
{
foreach (PieSlice pieSlice in pieSlices)
using (Pen pen = new Pen(pieSlice.Color.ChangeBrightness(0.8f)))
{
// Brush für die Seitenfläche erstellen
ColorBlend colorBlend = new ColorBlend();
Color color = Color.FromArgb(this.Opacity,pieSlice.Color);
colorBlend.Colors = new Color[]
{
color.ChangeBrightness(0.5f),
color.ChangeBrightness(0.8f),
color,
color.ChangeBrightness(0.8f),
color.ChangeBrightness(0.5f)
};
colorBlend.Positions = new float[] { 0f,0.15f,0.5f,0.85f,1.0f };
using (LinearGradientBrush linearGradientBrush = new LinearGradientBrush(new Point(0,0),new Point(this.EllipseWidth,0),Color.Blue,Color.White))
{
linearGradientBrush.InterpolationColors = colorBlend;
// Mittelpunkt und transformierte Start-/Endwinkel bestimmen
PointF sliceFocus = this.GetSliceFocus(pieSlice);
float startAngleT = TransformAngle(getStartAngle(pieSlice));
float sweepAngleT = (TransformAngle(getEndAngle(pieSlice))-startAngleT+360)%360;
using (GraphicsPath graphicsPath = new GraphicsPath())
{
// Path für die Seitenfläche erstellen
graphicsPath.AddArc(sliceFocus.X, sliceFocus.Y, this.EllipseWidth, this.EllipseHeight, startAngleT, sweepAngleT);
graphicsPath.AddArc(sliceFocus.X, sliceFocus.Y-this.PieHeight, this.EllipseWidth, this.EllipseHeight, startAngleT+sweepAngleT, -sweepAngleT);
graphicsPath.CloseFigure();
// Seitenfläche und Kontur zeichnen
graphics.FillPath(linearGradientBrush,graphicsPath);
graphics.DrawPath(pen,graphicsPath);
}
}
}
}
/// <summary>
/// Zeichnet die Schnittflächen der Kuchenstücke
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen</param>
private void DrawCuttingEdges(Graphics graphics,IEnumerable<PieSlice> pieSlices)
{
// Auflistung der ganzen Seitenflächen der Kuchenstücke erstellen (ein Kuchenstück hat ja zwei Seitenflächen)
var cuttingEdges = pieSlices.Select(ps => new { Angle = ps.StartAngle,PieSlice = ps }).Concat(pieSlices.Select(ps => new { Angle = ps.EndAngle,PieSlice = ps }));
// Seitenflächen ermitteln, die auf der linken Seite des Kreisdiagramms liegen, von hinten nach vorn sortieren und zeichnen
var leftCuttingEdges = cuttingEdges.Where(ce => ce.Angle>90 && ce.Angle<270).OrderByDescending(ce => ce.Angle).ThenByDescending(ce => ce.PieSlice.StartAngle);
foreach (var cuttingEdge in leftCuttingEdges)
this.DrawCuttingEdge(graphics,cuttingEdge.Angle,cuttingEdge.PieSlice);
// Seitenflächen ermitteln, die auf der rechten Seite des Kreisdiagramms liegen, von hinten nach vorn sortieren und zeichnen
var rightCuttingEdges = cuttingEdges.Where(ce => !(ce.Angle>90 && ce.Angle<270)).OrderBy(ce => (ce.Angle>90) ? ce.Angle : ce.Angle+360).ThenBy(ce => (ce.PieSlice.StartAngle>90) ? ce.PieSlice.StartAngle : ce.PieSlice.StartAngle+360);
foreach (var cuttingEdge in rightCuttingEdges)
this.DrawCuttingEdge(graphics,cuttingEdge.Angle,cuttingEdge.PieSlice);
}
/// <summary>
/// Zeichnet eine Seitenfläche
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="angle">Winkel der Seitenfläche</param>
/// <param name="pieSlice">Kuchenstück, zu dem die Seitenfläche gehört</param>
private void DrawCuttingEdge(Graphics graphics,float angle,PieSlice pieSlice)
{
// Mittelpunkt bestimmen und Koordinatensystem so verschieben, dass eine Seite der Seitenfläche im Koordinatenursprung beginnt
PointF sliceFocus = this.GetSliceFocus(pieSlice);
PointF newPointOfOrigin = new PointF(sliceFocus.X+this.EllipseWidth/2,sliceFocus.Y+this.EllipseHeight/2);
graphics.TranslateTransform(newPointOfOrigin.X,newPointOfOrigin.Y);
{
using (Pen pen = new Pen(pieSlice.Color.ChangeBrightness(0.8f)))
using (Brush brush = new SolidBrush(Color.FromArgb(this.Opacity,pieSlice.Color)))
{
// Winkel und Radius/Länge der Seitenfläche bestimmen
float angleT = TransformAngle(angle);
float radius = this.GetEllipseRadius(angleT);
// Koordinaten auf dem Außenkreis berechnen
float x = (float)Math.Cos(Radian(angleT)) * radius;
float y = (float)Math.Sin(Radian(angleT)) * radius;
// Umriss der Seitenfläche erstellen, Fläche und Kontur zeichnen
PointF[] points = new PointF[]
{
new PointF(0,0),
new PointF(0,-this.PieHeight),
new PointF(x,y-this.PieHeight),
new PointF(x,y)
};
graphics.FillPolygon(brush,points);
graphics.DrawPolygon(pen,points);
}
}
// Koordinatensystem da hin schieben, wo es vorher war
graphics.TranslateTransform(-newPointOfOrigin.X,-newPointOfOrigin.Y);
}
/// <summary>
/// Zeichnet die Beschriftung der Kuchenstücke
/// </summary>
/// <param name="graphics">Zeichnungsoberfläche, auf die gezeichnet werden soll</param>
/// <param name="pieSlices">Kuchenstücke, die gezeichnet werden sollen</param>
private void DrawTexts(Graphics graphics,IEnumerable<PieSlice> pieSlices)
{
foreach (PieSlice pieSlice in pieSlices)
if (!String.IsNullOrEmpty(pieSlice.Text))
using (Pen pen = new Pen(pieSlice.Color.ChangeBrightness(0.5f)))
using (Brush brush = new SolidBrush(pieSlice.Color.ChangeBrightness(0.5f)))
{
// Mittelpunkt und Winkel der Winkelhalbierenden des Kuchenstücks bestimmen
PointF sliceFocus = this.GetSliceFocus(pieSlice);
float bisectorAngleT = TransformAngle(pieSlice.StartAngle+pieSlice.SweepAngle/2);
// (Vorberechnungen)
float radius = this.GetEllipseRadius(bisectorAngleT);
float sin = (float)Math.Sin(Radian(bisectorAngleT));
float cos = (float)Math.Cos(Radian(bisectorAngleT));
// die hinteren Kuchenstücke bekommen den Strich an die Oberkante, die vorderen an die Unterkante
int altitude = (bisectorAngleT<178 && bisectorAngleT>2) ? 0 : this.PieHeight;
// Koordinaten für den Strich bestimmen und diesen Zeichnen
float x1 = cos*radius + this.EllipseWidth/2+sliceFocus.X;
float y1 = sin*radius + this.EllipseHeight/2+sliceFocus.Y - altitude;
float x2 = cos*(radius+this.LegendDistance) + this.EllipseWidth/2+sliceFocus.X;
float y2 = sin*(radius+this.LegendDistance) + this.EllipseHeight/2+sliceFocus.Y - altitude;
graphics.DrawLine(pen,x1,y1,x2,y2);
// Schrift auf der linken Seite rechtsbündig und auf der rechten Seite linksbündig zeichnen
using (StringFormat stringFormat = new StringFormat())
{
stringFormat.Alignment = (bisectorAngleT<90 || bisectorAngleT>270) ? StringAlignment.Near : StringAlignment.Far;
stringFormat.LineAlignment = StringAlignment.Center;
graphics.DrawString(pieSlice.Text,this.LegendFont,brush,new PointF(x2,y2),stringFormat);
}
}
}
/// <summary>
/// Transformiert einen Winkel in einem Kreis so, dass die Flächenverhältnisse in der perspektivischen Projektion (Ellipse) gewahrt bleiben
/// </summary>
/// <param name="angle">Winkel aus dem Kreis</param>
/// <returns>Winkel für eine Ellipse, die dem perspektivisch projizierten Kreis entspricht</returns>
private float TransformAngle(float angle)
{
double x = this.EllipseWidth * Math.Cos(Radian(angle));
double y = this.EllipseHeight * Math.Sin(Radian(angle));
return (float)(Math.Atan2(y,x)*180/Math.PI + 360)%360;
}
/// <summary>
/// Berechnet den Winkel im Bogenmaß
/// </summary>
/// <param name="degree">der Winkel im Gradmaß</param>
/// <returns>den Winkel im Bogenmaß</returns>
private static float Radian(float degree)
{
return (float)(degree*Math.PI/180f);
}
/// <summary>
/// Ermittelt den Mittelpunkt eines Kreisausschnitts (also den Mittelpunkt des eigentlichen Kreises)
/// </summary>
/// <param name="pieSlice">der Kreisausschnitt, dessen Mittelpunkt bestimmt werden soll</param>
/// <returns>den Mittelpunkt des Kreisausschnitts</returns>
private PointF GetSliceFocus(PieSlice pieSlice)
{
// Winkelhalbierende bestimmen...
float startAngleT = TransformAngle(pieSlice.StartAngle);
float sweepAngleT = (TransformAngle(pieSlice.EndAngle)-startAngleT+360)%360;
float bisectorAngleT = startAngleT + sweepAngleT/2;
// ... und entsprechend des Offsets dort entlang verschieben
float x = (float)Math.Cos(Radian(bisectorAngleT))*pieSlice.Offset;
float y = (float)Math.Sin(Radian(bisectorAngleT))*pieSlice.Offset;
return new PointF(x,y);
}
/// <summary>
/// Bestimmt den "Radius" einer Ellipse an einem bestimmten Winkel
/// </summary>
/// <param name="angle">der Winkel zur Bestimmung des "Radius"</param>
/// <returns>"Radius" einer Ellipse</returns>
private float GetEllipseRadius(float angle)
{
float a = this.EllipseWidth/2;
float b = this.EllipseHeight/2;
return (float)(b / Math.Sqrt(1-(1-(b*b)/(a*a))*Math.Pow(Math.Cos(Radian(angle)),2)));
}
}
}
using System;
using System.Drawing;
namespace Charting
{
public static class ColorHelper
{
/// <summary>
/// Erstellt eine System.Drawing.Color-Struktur, in dem die Helligkeit der übergebenen Farbe geändert wird
/// </summary>
/// <param name="color">Farbe, deren Helligkeit geändert werden soll</param>
/// <param name="factor">Faktor, der auf die ARGB-Komponenten angewendet wird</param>
/// <returns>Farbe mit geänderter Helligkeit</returns>
public static Color ChangeBrightness(this Color color,float factor)
{
return Color.FromArgb(
Math.Min(255,(int)(color.A*(2-factor))),
Math.Min(255,(int)(color.R*factor)),
Math.Min(255,(int)(color.G*factor)),
Math.Min(255,(int)(color.B*factor)));
}
/// <summary>
/// Erstellt eine System.Drawing.Color-Struktur aus den drei HSB-Komponenten (Farbton, Sättigung, Helligkeit)
/// </summary>
/// <param name="Hue">Farbton (0..360)</param>
/// <param name="Saturation">Sättigung (0..1)</param>
/// <param name="Brightness">Helligkeit (0..1)</param>
/// <returns>eine System.Drawing.Color-Struktur</returns>
public static Color FromHSB(float hue,float saturation,float brightness)
{
// Parameter prüfen
if (hue<0 || hue>360)
throw new ArgumentException("hue 0..360");
if (saturation<0 || saturation>1)
throw new ArgumentException("saturation 0..1");
if (brightness<0 || brightness>1)
throw new ArgumentException("brightness 0..1");
if (saturation==0)
{
// achromatische Farbe
byte rgb = (byte)(brightness*255);
return Color.FromArgb(rgb,rgb,rgb);
}
else
{
float fHexHue = (6.0f/360.0f) * hue;
float fHexSector = (float)Math.Floor((double)fHexHue);
float fHexSectorPos = fHexHue - fHexSector;
float fBrightness = brightness*255.0f;
float fSaturation = saturation;
byte bWashOut = (byte)(0.5f + fBrightness*(1.0f-fSaturation));
byte bHueModifierOddSector = (byte)(0.5f + fBrightness * (1.0f - fSaturation*fHexSectorPos));
byte bHueModifierEvenSector = (byte)(0.5f + fBrightness * (1.0f - fSaturation*(1.0f-fHexSectorPos)));
// RGB-Farben abhängig vom Sektor erzeugen
switch ((int)fHexSector)
{
case 0:
return Color.FromArgb((byte)(brightness*255),bHueModifierEvenSector,bWashOut);
case 1:
return Color.FromArgb(bHueModifierOddSector,(byte)(brightness*255),bWashOut);
case 2:
return Color.FromArgb(bWashOut,(byte)(brightness*255),bHueModifierEvenSector);
case 3:
return Color.FromArgb(bWashOut,bHueModifierOddSector,(int)(brightness*255));
case 4:
return Color.FromArgb(bHueModifierEvenSector,bWashOut,(byte)(brightness*255));
case 5:
return Color.FromArgb((byte)(brightness*255),bWashOut,bHueModifierOddSector);
default:
return Color.FromArgb(0,0,0);
}
}
}
}
}
Schlagwörter: Kreisdiagramm, Kuchendiagramm, Tortendiagramm, pie chart
Schick! Wäre noch cool wenn man die %-Werte in der Bescriftung nicht immer mit anzeigen müsste (falls man z.B. Stückzahlen o.Ä. haben möchte)
Wäre noch cool wenn man die %-Werte in der Beschriftung nicht immer mit anzeigen müsste
Da brauchst du nur die Methode in der Hilfsklasse (PieChartHelper
) anpassen/erweitern. Bisher sind einige Werte hartverdrahtet, da sich in meinem konkreten Anwendungsfall keine Notwendigkeit für etwas anderes ergeben hat.
Unten mal eine Erweiterung/ein Ersatz für die Hilfsklasse, womit die Ermittlung der Zahlwerte und die Beschriftung über Delegates injiziert wird.
Bitmap bitmap = pieChart.DrawPercent(values,kvp => kvp.Value,(kvp,percentage) => kvp.Key +"|"+percentage.ToString("f1")+"%",5f,"Sechs","Zehn");
Damit wäre dann auch leicht eine Darstellung von z.B. Stückzahlen denkbar:
Bitmap bitmap = pieChart.DrawPercent(
values,
article => article.Quantity,
(article,percentage) => article.ID +"|"+article.Quantity+" Stück",
(articles,percentage) => articles.Count()+" sonstige Artikel|zusammen "+articles.Sum(a => a.Quantity)+" Stück",
5f);
Code:
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
namespace Charting
{
public static class PieChartHelper
{
/// <summary>
/// Erstellt ein Kreisdiagramm für die übergebenen Elemente, die mit dem jeweiligen prozentualen Anteil beschriftet sind
/// </summary>
/// <param name="pieChart">Komponente zum Zeichnen von Kreisdiagrammen</param>
/// <param name="values">Auflistung mit den Elementen, die als Kuchenstücke visualisiert werden sollen.</param>
/// <param name="percentageThreshold">Prozent-Schwellwert, ab dem ein Element unter "sonstiges" verbucht wird.</param>
/// <param name="emphasizeKeys">Namen der Elemente, die durch weiteres Herausschieben aus der Mitte des Kreisdiagramms hervorgehoben werden sollen.</param>
/// <returns>Bitmap-Objekt, auf das das Kreisdiagramm gezeichnet wurde.</returns>
public static Bitmap DrawPercent(this PieChart pieChart,IEnumerable<KeyValuePair<string,double>> values,float percentageThreshold,params string[] emphasizeKeys)
{
return DrawPercent<KeyValuePair<string,double>>(pieChart,values,
kvp => kvp.Value,
(kvp,percentage) => kvp.Key+"|"+percentage.ToString("f2")+"%",
(kvps,percentage) => kvps.Count()+" andere|"+percentage.ToString("f2")+"%",
percentageThreshold,values.Where(kvp => emphasizeKeys.Contains(kvp.Key)) );
}
/// <summary>
/// Erstellt ein Kreisdiagramm für die übergebenen Elemente, die mit dem jeweiligen prozentualen Anteil beschriftet sind
/// </summary>
/// <typeparam name="T">Der Typ der Elemente, die visualisiert werden sollen.</typeparam>
/// <param name="pieChart">Komponente zum Zeichnen von Kreisdiagrammen</param>
/// <param name="elements">Auflistung mit den Elementen, die als Kuchenstücke visualisiert werden sollen.</param>
/// <param name="getValue">Delegat zur Ermittlung der Größe, anhand der der prozentuale Anteil bestimmt wird. </param>
/// <param name="getText">Delegat zur Ermittlung der Beschriftung eines Elements. (Der prozentuale Anteil dieses Elements wird mit übergeben.) </param>
/// <param name="getMergedText">Delegate zur Ermitllung der Beschriftung der zusammengefassten Elemente. (Der prozentuale Anteil dieser Elemente wird mit übergeben.) </param>
/// <param name="percentageThreshold">Prozent-Schwellwert, ab dem ein Element unter "sonstiges" verbucht wird.</param>
/// <param name="emphasizedElements">Auflistung der Elemente, die durch weiteres Herausschieben aus der Mitte des Kreisdiagramms hervorgehoben werden sollen.</param>
/// <returns>Bitmap-Objekt, auf das das Kreisdiagramm gezeichnet wurde.</returns>
public static Bitmap DrawPercent<T>(this PieChart pieChart,IEnumerable<T> elements,Func<T,double> getValue,Func<T,double,string> getText,Func<IEnumerable<T>,double,string> getMergedText,float percentageThreshold,IEnumerable<T> emphasizedElements)
{
return DrawPercent<T> (pieChart, elements, getValue,getText, getMergedText,percentageThreshold, emphasizedElements.ToArray());
}
/// <summary>
/// Erstellt ein Kreisdiagramm für die übergebenen Elemente, die mit dem jeweiligen prozentualen Anteil beschriftet sind
/// </summary>
/// <typeparam name="T">Der Typ der Elemente, die visualisiert werden sollen.</typeparam>
/// <param name="pieChart">Komponente zum Zeichnen von Kreisdiagrammen</param>
/// <param name="elements">Auflistung mit den Elementen, die als Kuchenstücke visualisiert werden sollen.</param>
/// <param name="getValue">Delegat zur Ermittlung der Größe, anhand der der prozentuale Anteil bestimmt wird. </param>
/// <param name="getText">Delegat zur Ermittlung der Beschriftung eines Elements. (Der prozentuale Anteil dieses Elements wird mit übergeben.) </param>
/// <param name="getMergedText">Delegate zur Ermitllung der Beschriftung der zusammengefassten Elemente. (Der prozentuale Anteil dieser Elemente wird mit übergeben.) </param>
/// <param name="percentageThreshold">Prozent-Schwellwert, ab dem ein Element unter "sonstiges" verbucht wird.</param>
/// <param name="emphasizedElements">Auflistung der Elemente, die durch weiteres Herausschieben aus der Mitte des Kreisdiagramms hervorgehoben werden sollen.</param>
/// <returns>Bitmap-Objekt, auf das das Kreisdiagramm gezeichnet wurde.</returns>
public static Bitmap DrawPercent<T>(this PieChart pieChart,IEnumerable<T> elements,Func<T,double> getValue,Func<T,double,string> getText,Func<IEnumerable<T>,double,string> getMergedText,float percentageThreshold,params T[] emphasizedElements)
{
// prozentuale Anteile ermitteln
double sum = elements.Sum(element => getValue(element));
var items = elements.Select(element => new { Key = element,Percent = getValue(element)*100/sum });
// wenn es mehrere Kuchenstücke gibt, deren prozentualer Anteil unter dem Schwellwert liegen, diese zu einem einzigen Stück zusammenfassen
var slicesToMerge = items.Where(item => item.Percent<percentageThreshold && !emphasizedElements.Contains(item.Key));
var slices =
items.Where(item => slicesToMerge.Count()==0 || !slicesToMerge.Contains(item))
.Select(item => new { Text = getText(item.Key,item.Percent),Percent = item.Percent,IsEmphasized = emphasizedElements.Contains(item.Key) })
.Concat(slicesToMerge.Where(item => slicesToMerge.Count()>1).Take(1)
.Select(item => new { Text = getMergedText(slicesToMerge.Select(i => i.Key),slicesToMerge.Sum(i => i.Percent)),Percent = slicesToMerge.Sum(i => i.Percent),IsEmphasized = emphasizedElements.Contains(item.Key) }));
// Kuchenstückobjekte erzeugen
float startAngle = 20;
List<PieChart.PieSlice> pieSlices = slices.Select((item,index) => new PieChart.PieSlice
{
Offset = (item.IsEmphasized) ? 15 : 3,
Text = item.Text,
StartAngle = startAngle,
EndAngle = startAngle = (float)(startAngle+(item.Percent/100)*360)%360,
Color = PieChartHelper.generateColor(index,slices.Count())
}).ToList();
// Kreisdiagramm zeichnen
return pieChart.Draw(pieSlices);
}
/// <summary>
/// Generiert eine Farbe
/// </summary>
/// <param name="index">Index der Farbe</param>
/// <param name="count">Anzahl der Farben, die generiert werden sollen/können.</param>
/// <returns>generierte Farbe</returns>
private static Color generateColor(int index,int count)
{
// einmal quer durch die Farbtöne im HSB-Farbraum
float angle = (index/(float)count) * 360;
return ColorHelper.FromHSB(angle,1,1);
}
}
}
Beste Grüße,
dN!3L
Hy,
ich finde das Projekt hier :Komponente zum Erstellen von Kreis-/Tortendiagrammen richtig richtig cool und wollte es daher bei mir auch mal zum laufen bekommen.
Ich komme eher von der C#-Schiene und hab noch nie ein ASP Projekt angelegt. Daher konnte ich leider auch kein Projekt anlegen, was tatsächlich build-fähig war.
Ich nutze MonoDevelop und habe zunächst eine C# Solution angelegt was aber nicht recht funktionieren wollte, trotz eigens erzeugter MAIN wollte sich das Diagramm nicht so recht in der Form darstellen 😦
Dann habe ich es nun doch mal gewagt und eine ASP.NET MVC-Anwendung erstellt.
Beim erzeugen habe ich die nötige Filestruktur von MONO erstellen lassen. Die 3 C# files ColorHelper, PieChart und PieChartHelper habe ich einfach mal in den Content hinein.
funktioniert hat es dann, als ich den relevanten Code
bitmap.Save("PIE.bmp");
in den Controller kopiert habe.
in der View konnte ich dann auf das bmp zugreifen
<img src="PIE.bmp">
Geht's auch irgendwie eleganter oder vielleicht sogar in einer C# Solution innerhalb einer Form?
Danke schonmal und schöne Grüße
Andi
Hallo Andi,
Ich nutze MonoDevelop und habe zunächst eine C# Solution angelegt was aber nicht recht funktionieren wollte, trotz eigens erzeugter MAIN wollte sich das Diagramm nicht so recht in der Form darstellen
Eine WinForm-Anwendung? Am einfachsten ist es dort, eine PictureBox anzulegen und dann einfach das erzeugte Bitmap des Tortendiagramms dem Image-Property der PictureBox zuweisen.
funktioniert hat es dann, als ich den relevanten Code
bitmap.Save("PIE.bmp");
in den Controller kopiert habe.
in der View konnte ich dann auf das bmp zugreifen<img src="PIE.bmp">
Geht's auch irgendwie eleganter
Ich habe in meinem ASP.NET MVC-Projekt das ImageResult für ASP.NET MVC verwendet. Löst die Sache m.E. sehr elegant 😉
ich finde das Projekt hier :Komponente zum Erstellen von Kreis-/Tortendiagrammen richtig richtig cool
Vielen Dank. 8)
Beste Grüße,
dN!3L
Hallo,
Ich weiss der Post ist schon 2 Jahre alt, vll hat aber doch jemand Lust zu antworten. Ich finde das Diagramm sehr gelungen ich habe es gerade in eine Anwendung eingebunden. Meine Frage ist, wie man das fixen kann das das Diagramm auch Stücke mit 100 % anzeigen kann ?
Meine Frage ist, wie man das fixen kann das das Diagramm auch Stücke mit 100 % anzeigen kann?
Eigentlich sollte das doch klappen. Ich habe gerade nochmal den Code hier mit dem aus einer Anwendung, in der 100%-Stücke gezeichnet werden können, verglichen und keine Unterschiede feststellen können.
Der Codeteil, der für 100%-Stücke zuständig ist, ist folgender:
if (pieSlices.Count==1 && pieSlices[0].StartAngle==pieSlices[0].EndAngle)
this.DrawLonelySlice(graphics,pieSlices[0]); // 100%-Stück
Hast du wirklich nur ein Stück und sind Start- und Endwinkel auch gleich?
Hallo dN!3L,
vielen Dank für die schnelle Antwort, ich habe mich nicht ganz klar ausgedrückt ich meinte den Fall den du beschrieben hast d.h ein Stück mit 100% und z.b ein weiteres mit 0 % kann ja nicht dargestellt werden. Da dies aber sowieso Schwachsinnig wäre habe ich jetzt einfach mein Programm so angepasst das in so einem Fall nur ein Slice angelegt wird und dann funktioniert es auch mit der Darstellung so wie du es beschrieben hast. Vielen Dank !
Genau. Da die Komponente mit Start- und Endwinkeln arbeitet, kann bei deren Gleichheit nicht ermittelt werden, ob es nun ein 100%- oder 0%-Stück war. In diesem Fall musst du - wie du schon rausgefunden hast - die 0%-Stücke vorher rausfiltern, sodass nur ein einziges (100%-)Stück übrig bleibt.