Laden...

Komponente zum Erstellen von Kreis-/Tortendiagrammen

Erstellt von dN!3L vor 14 Jahren Letzter Beitrag vor 11 Jahren 18.046 Views
dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 14 Jahren
Komponente zum Erstellen von Kreis-/Tortendiagrammen

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

2.760 Beiträge seit 2006
vor 14 Jahren

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)

dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 14 Jahren

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

A
9 Beiträge seit 2009
vor 14 Jahren
Darstellung in WinForms/ASP.NET MVC

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

dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 14 Jahren

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

A
2 Beiträge seit 2012
vor 11 Jahren
Diagrammstück mit 100%

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 ?

dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 11 Jahren

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?

A
2 Beiträge seit 2012
vor 11 Jahren
100% Stücke

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 !

dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 11 Jahren

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.