Friday, October 31, 2008

.Net Pie Chart challenge

Note* This is still running at : https://www.customconfiguration.net/ASP/DefaultPie.aspx
My first response to this was very blah. Having seen this stuff done by every product since Access 2.0, made it unexciting to think about. But then my friend added a twist : make the pie chart clickable in a drill-down way. So as easy as it is, to create pie slices from an image of a circle, I was stumped at how to handle a "hotspot", and did not even know there was an ImageMap control available for a .Net webform.

So to hurry up and get something to chart, I created a dataset with one column of numeric values, and a web user control to house the ImageMap control. My control exposes a method called "Draw" and raises an event called "HotSpotClicked" which is declared as follows :


public event ImageMapEventHandler HotSpotClicked;


The graphics to write the circle and pie slices are easy to call in the System.Drawing namespace. Here is Draw method, which calls a separate method to add a percentage column to the DataSet table passed to it. It uses random colors for the pie slices, and selects the colors from the Brushes namespace of System.Drawing using Reflection. It creates a GUID for a temporary file name in which to store the Bitmap. For each pie slice it draws, it saves coordinates for where to put the map hotspot in another column in the dataset. So how to compute X,Y coordinates of the outer edge of a pie slice. My good enough answer is to use a triangle defined by center and two end points of the slice. Those should be easy enough to figure out by conversion from angles and knowing the radius of the bounding circle. But not when you have a large slice, say greater than 100 degrees. For that size and higher the triangle it defines is squeezed thinner and thinner and loses usability as a hot regaion. So in that case I added one more coordinate dividing the "sweep" angle by 2 in my computation.


public void Draw(DataSet ds)
{
// Rectangle of that is the width of the ImageMap control.
Rectangle rect =
new Rectangle(0, 0,
Convert.ToInt32(ImageMap1.Width.Value),
Convert.ToInt32(ImageMap1.Height.Value));

/ Using floats as they are small enough and liked by the Math namespace.
float radius = rect.Width / 2;

string imageName = System.Guid.NewGuid().ToString();
Bitmap bitmap = new Bitmap(rect.Width, rect.Height);

Graphics g = Graphics.FromImage(bitmap);

g.DrawRectangle(Pens.Khaki, rect);
g.FillRectangle(Brushes.LightBlue, rect);

g.DrawEllipse(Pens.Khaki, rect);
g.FillEllipse(Brushes.LightPink, rect);

calcPercents(ds);

// Using reflection to get an array of colors from which to choose.
PropertyInfo[] penInfos = typeof(Pens).GetProperties();
PropertyInfo[] brushInfos = typeof(Brushes).GetProperties();

float startAngle = 0;

// For selecting colors.
Random rand = new System.Random();

// Add a column to the dataset to record color used.
ds.Tables[0].Columns.Add("ChartColor");

for (int i = 0; i < ds.Tables[0].Rows.Count; i++)
{
DataRow row = ds.Tables[0].Rows[i];

decimal fSweepAngle = 360 * (Convert.ToDecimal(row[1]) / 100);
float sweepAngle = (float)Math.Truncate(fSweepAngle);

if (i == ds.Tables[0].Rows.Count - 1)
{
sweepAngle += 360 - (startAngle + sweepAngle);
}

int colorIndex = rand.Next(brushInfos.Length - 1);

Brush myBrush = (Brush)brushInfos[colorIndex].GetValue(null, null);

g.DrawPie((Pen)penInfos[colorIndex].GetValue(null, null),
rect,
startAngle,
sweepAngle);

g.FillPie(myBrush,
rect,
startAngle,
sweepAngle);


// Calculate the Hot Spot polygon

// First line from center of the pie out to the edge.
string coords = radius.ToString() + "," +
radius.ToString() + ",";

coords += getCoordinates(startAngle, radius);

if (sweepAngle > 90)
{
//TODO: add more coordinates to get better hotspot coverage.
float intermediateAngle = startAngle + (sweepAngle / 2);

coords += getCoordinates(intermediateAngle, radius);
}

// move the needle up to where we just stopped for the next draw operation.
startAngle += sweepAngle;

coords += getCoordinates(startAngle, radius);

row[2] = coords;
row[3] = ((System.Drawing.SolidBrush)myBrush).Color.Name;
}

setHotSpots(ds);

ImageMap1.ImageUrl = "images/" + imageName + ".bmp";

bitmap.Save(Server.MapPath("images/" + imageName + ".bmp"),
System.Drawing.Imaging.ImageFormat.Bmp);
}



Setting hotspots in the map is easy once the coordinates have been solved :


private void setHotSpots(DataSet ds)
{
ImageMap1.HotSpots.Clear();

foreach (DataRow row in ds.Tables[0].Rows)
{
PolygonHotSpot phs = new PolygonHotSpot();
phs.Coordinates = row[2].ToString();
phs.AlternateText = row[0].ToString() + " "
+ row[1].ToString() + " "
+ row[3].ToString();
phs.PostBackValue = row[0].ToString();
phs.HotSpotMode = HotSpotMode.PostBack;
phs.NavigateUrl = "";
ImageMap1.HotSpots.Add(phs);
}
}


Code for calculating the percentage value of each data point provided in the dataset is very trivial ,but I list it here for future reference :


private void calcPercents(DataSet ds)
{
DataColumn PercentColumn = ds.Tables[0].Columns.Add("DataPercentage");
decimal total = 0;

foreach (DataRow row in ds.Tables[0].Rows)
{
total += Convert.ToDecimal(row[0]);
}

decimal totalPercent = 0;
foreach (DataRow row in ds.Tables[0].Rows)
{

decimal percentage = Convert.ToDecimal(row[0]) / total;
row[1] = Math.Round(percentage * 100, 1);
totalPercent += Convert.ToDecimal(row[1]);
}


if (totalPercent < 100)
{
DataRow lastRow = ds.Tables[0].Rows[ds.Tables[0].Rows.Count - 1];
decimal lastRowPercent = Convert.ToDecimal(lastRow[1]) + (100 - totalPercent);
lastRow[1] = lastRowPercent;
}

// add a column for coordinate set.
DataColumn coordinatesColumn = ds.Tables[0].Columns.Add("MapAreaCoordinates");

ds.AcceptChanges();
}


The real brain candy of the project was how to get the X.Y coordinates for an ImageMap knowing the size of the circle in the drawing and the "Sweep Angle" or angle of the pie slice. After some trial and error and merely remembering where to look from high school math, I wrote some calculations using SIN and COSINE. The fatal trap from hell, is that MATH.SIN does not accept Degrees, it accepts Radians. Intellisense doesn't tell us that. So here are the computations :


float Sin90 = (float)Math.Sin(ToRadian(90));

private string getCoordinates(float startAngle,
float radius)
{
float rise = 0;
float run = radius;
string coords = string.Empty;

rise = (radius * SinOfDegree(startAngle)) / Sin90;
run = (radius * CosOfDegree(startAngle)) / Sin90;

coords += Math.Round(radius + run, 0).ToString() + "," +
Math.Round(radius + rise, 0).ToString() + ",";

return coords;
}

private float SinOfDegree(float Degrees)
{
float radianOfDegree = ToRadian(Degrees);
return (float)Math.Sin(radianOfDegree);
}


private float CosOfDegree(float Degrees)
{
float radianOfDegree = ToRadian(Degrees);
return (float)Math.Cos(radianOfDegree);
}

private static float ToRadian(float Degrees)
{
return Degrees * (float)Math.PI / 180;
}

Sunday, October 26, 2008

Update to Config Builder

It has been requested that my tool create configuration elements that look more like old .Net Framework configuration elements, without the "add, remove, clear" syntax.

The new syntax looks like the following :

<BaseballConfig>
<WorldSeriess>
<add Year="2008">
<Teams>
<add Name="Rays" />
</Teams>
</add>
</WorldSeriess>
</BaseballConfig>


While the older, more traditional format looks like :

<BaseballConfig>
<WorldSeriess>
<WorldSeries Year="2008">
<Teams>
<Team Name="Phillies" />
<Team Name="Rays" />
</Teams>
</WorldSeries>
</WorldSeriess>
</BaseballConfig>


In order to get the xml elements to match the configuration class names, an override can be added to the ElementCollection class as follows :


public override ConfigurationElementCollectionType CollectionType
{
get
{
return ConfigurationElementCollectionType.BasicMap;
}
}



So to handle this update (or not) I have created the following function for my custom configuration tool:


private static void addConfigurationElementCollectionTypeProperty(CodeTypeDeclaration configElements)
{
CodeTypeReferenceExpression ctre = new CodeTypeReferenceExpression(typeof(ConfigurationElementCollectionType));
CodeTypeReference ctr =
new CodeTypeReference(typeof(ConfigurationElementCollectionType));
CodeMemberProperty propCollectionType = new CodeMemberProperty();
propCollectionType.Name = "CollectionType";
propCollectionType.Attributes = MemberAttributes.Override | MemberAttributes.Public;
propCollectionType.HasSet = false;
propCollectionType.HasGet = true;
propCollectionType.Type = ctr;
propCollectionType.GetStatements.Add(
new CodeMethodReturnStatement(
new CodePropertyReferenceExpression(ctre, "BasicMap")));
configElements.Members.Add(propCollectionType);
}