Introduction
When I was developing a previous application, I needed to display various data in the form of line graphs. What I wanted was a universal control that could add and remove graphs (functions) as needed and, most important, this graph had to be displayed simultaneously and fit into the chart area regardless of their range.
Perhaps such solutions existed from third parties, but I wanted to stay with plain .NET, so I had to use Microsoft Chart controls and extend its functionality to make it suitable for my needs.
Background
The problem with various graphs is that they tend to have very different ranges, which makes it difficult to combine them on the same chart. Because the chart control sizes its scale to fit ALL the graphs, a graph that ranges from 1 to 100 would make another graph that ranges from 0.001 to 0.01 virtually invisible. Of course, at times this is what we need, but what if we wanted to visually compare two graphs regardless of their ranges?
We therefore need a chart area per graph or graphs that can be grouped (i.e., more or less equal ranges). These chart areas have to be stacked and each one must have its own axes, sized accordingly to the displayed graph. The result is a chart that has its graphs always scaled to fit the whole chart area.
Using the Code
Structure
The chart is basically a collection of different chart areas which are stacked together. For every single graph (or a collection of graphs of similar proportions), we need 3 different chart areas: one for displaying the graph, one for axis X and one for the axis Y. These chart areas are bound together with a guid number and make a whole.
As the original .NET chart does not provide such functionality, we tag every chart area using the purpose built class ChartAreaTagger
:
private class ChartAreaTagger
{
public ChartAreaTagger(Guid _guid, ChartAreaType _chartAreaType, Color _chartColor)
{
this.guid = _guid;
this.chartAreaType = _chartAreaType;
this.chartColor = _chartColor;
}
private Guid guid;
public Guid @Guid
{
get { return guid; }
}
private ChartAreaType chartAreaType;
public ChartAreaType @ChartAreaType
{
get { return chartAreaType; }
}
private Color chartColor;
public Color ChartColor
{
get { return chartColor; }
}
}
private enum ChartAreaType
{
AXIS_X = 2,
AXIS_Y = 4,
CHART = 8
}
The class ChartAreaTagger
provides all the information we need to put the various graphs on the chart and perform the layout.
GUI Elements
The chart inherits a UserControl
and has a single object: the .NET chart control. The control has a chart area and a legend added in the designer - these are the two basic objects that will never change.
As we add and remove chart areas as needed, we do not provide specific formatting in the designer - we need a method in our code to do the job, so all chart areas will be formatted the same:
private void FormatChartArea(ChartArea _chartArea)
{
...
}
However, the base chart area, which we use for displaying the grid and holding all the graphs, needs a few exceptions, so we take care of this in the Load
method:
this.chart1.ChartAreas[0].AxisX.LabelStyle.Enabled = false;
this.chart1.ChartAreas[0].AxisX.MajorTickMark.Enabled = false;
this.chart1.ChartAreas[0].AxisX.LineColor = Color.Transparent;
this.chart1.ChartAreas[0].AxisY.LabelStyle.Enabled = false;
this.chart1.ChartAreas[0].AxisY.MajorTickMark.Enabled = false;
this.chart1.ChartAreas[0].AxisY.LineColor = Color.Transparent;
Adding a Graph
Adding a graph starts with the checking whether a suitable chart area already exists on our chart. We do this by obtaining the minimums and maximums of the new graph and comparing this data with the existing one.
float _minX = this.GetMinX(_points);
float _maxX = this.GetMaxX(_points);
float _minY = this.GetMinY(_points);
float _maxY = this.GetMaxY(_points);
ChartArea _chartAreaAxisX = null;
ChartArea _chartAreaAxisY = null;
ChartArea _chartAreaChart = this.GetBestSuitedChartArea(
_minX, _maxX,
_minY, _maxY,
out _chartAreaAxisX, out _chartAreaAxisY);
We are checking the percentage difference, so it is possible that the computed difference is going to be quite big when we are in fact dealing with small values.
For instance, we have two graphs, first has the range 0-100 and the second has the range 1-100. Obviously, the given graph could be put on the same chart area, but the percentage difference would compute 100% for the minimum (0 vs 1). As this is more than the MAX_PERCENTAL_DIFFERENCE
, which is set to 10%, the graph would be put to its own chart area.
To deal with this, we also check the percentage difference in graphs proportions. So, if minimums, maximums and the proportions are out of MAX_PERCENTAL_DIFFERENCE
, then the chart area is not suitable.
private ChartArea GetBestSuitedChartArea(
float _minX, float _maxX,
float _minY, float _maxY,
out ChartArea _suitableAxisX, out ChartArea _suitableAxisY)
{
List<ChartArea> _suitableAxisXtmp = new List<ChartArea>();
List<ChartArea> _suitableAxisYtmp = new List<ChartArea>();
ChartArea _suitableChartArea = null;
foreach (ChartArea _chartArea in this.chart1.ChartAreas)
{
if (double.IsNaN(_chartArea.AxisX.Minimum))
{
continue;
}
if (double.IsInfinity(_chartArea.AxisX.Maximum))
{
continue;
}
if (double.IsNaN(_chartArea.AxisY.Minimum))
{
continue;
}
if (double.IsInfinity(_chartArea.AxisY.Maximum))
{
continue;
}
if (_chartArea.Tag is ChartAreaTagger)
{
ChartAreaTagger _chartAreaTagger = (ChartAreaTagger)_chartArea.Tag;
#region "for X axis"
if (this.GetPercentageDifference
(_chartArea.AxisX.Minimum, _minX) > MAX_PERCENTAGE_DIFFERENCE)
{
if ((this.GetPercentageDifference(_chartArea.AxisX.Maximum - _chartArea.AxisX.Minimum,
_maxX - _minX) > MAX_PERCENTAGE_DIFFERENCE)
|| (this.GetPercentageDifference(_chartArea.AxisX.Maximum, _maxX)
> MAX_PERCENTAGE_DIFFERENCE))
{
continue;
}
}
if (this.GetPercentageDifference
(_chartArea.AxisX.Maximum, _maxX) > MAX_PERCENTAGE_DIFFERENCE)
{
if ((this.GetPercentageDifference(_chartArea.AxisX.Maximum -
_chartArea.AxisX.Minimum, _maxX - _minX) > MAX_PERCENTAGE_DIFFERENCE)
|| (this.GetPercentageDifference
(_chartArea.AxisX.Minimum, _minX) > MAX_PERCENTAGE_DIFFERENCE))
{
continue;
}
}
if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_X)
{
_suitableAxisXtmp.Add(_chartArea);
}
#endregion "for X axis"
#region "for Y axis"
if (this.GetPercentageDifference(_chartArea.AxisY.Minimum, _minY)
> MAX_PERCENTAGE_DIFFERENCE)
{
if ((this.GetPercentageDifference(_chartArea.AxisY.Maximum -
_chartArea.AxisY.Minimum, _maxY - _minY) > MAX_PERCENTAGE_DIFFERENCE)
|| (this.GetPercentageDifference
(_chartArea.AxisY.Maximum, _maxY) > MAX_PERCENTAGE_DIFFERENCE))
{
continue;
}
}
if (this.GetPercentageDifference
(_chartArea.AxisY.Maximum, _maxY) > MAX_PERCENTAGE_DIFFERENCE)
{
if ((this.GetPercentageDifference(_chartArea.AxisY.Maximum -
_chartArea.AxisY.Minimum, _maxY - _minY) > MAX_PERCENTAGE_DIFFERENCE)
|| (this.GetPercentageDifference
(_chartArea.AxisY.Minimum, _minY) > MAX_PERCENTAGE_DIFFERENCE))
{
continue;
}
}
if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_Y)
{
_suitableAxisYtmp.Add(_chartArea);
}
#endregion "for Y axis"
if (_chartAreaTagger.ChartAreaType == ChartAreaType.CHART)
{
_suitableChartArea = _chartArea;
}
}
}
if (_suitableAxisXtmp.Count > 0)
{
_suitableAxisX = _suitableAxisXtmp[0];
}
else
{
_suitableAxisX = null;
}
if (_suitableAxisYtmp.Count > 0)
{
_suitableAxisY = _suitableAxisYtmp[0];
}
else
{
_suitableAxisY = null;
}
return _suitableChartArea;
}
If there isn't any suitable chart area, then:
- we create a new one
- format it
- size its scale accordingly to the graph
Guid _guid = Guid.NewGuid();
if ((_chartAreaAxisX == null) || (_chartAreaAxisY == null))
{
#region "one for showing the chart"
_chartAreaChart = new ChartArea(Guid.NewGuid().ToString());
this.FormatChartArea(_chartAreaChart);
_chartAreaChart.Tag = new ChartAreaTagger(_guid, ChartAreaType.CHART, _color);
_chartAreaChart.BackColor = Color.Transparent;
_chartAreaChart.IsSameFontSizeForAllAxes = this.chart1.ChartAreas[0].IsSameFontSizeForAllAxes;
this.chart1.ChartAreas.Add(_chartAreaChart);
this.FormatAxis(_chartAreaChart.AxisX,
_minX, _maxX,
false, true, false);
this.FormatAxis(_chartAreaChart.AxisY,
_minY, _maxY,
false, true, false);
#endregion "one for showing the chart"
}
if (_chartAreaAxisX == null)
{
#region "one for X axis"
_chartAreaAxisX = new ChartArea(Guid.NewGuid().ToString());
this.FormatChartArea(_chartAreaAxisX);
_chartAreaAxisX.BackColor = Color.Transparent;
_chartAreaAxisX.IsSameFontSizeForAllAxes = this.chart1.ChartAreas[0].IsSameFontSizeForAllAxes;
_chartAreaAxisX.Tag = new ChartAreaTagger(_guid, ChartAreaType.AXIS_X, _color);
_chartAreaAxisX.AxisX.LabelStyle.ForeColor = _color;
this.chart1.ChartAreas.Add(_chartAreaAxisX);
this.FormatAxis(_chartAreaAxisX.AxisX,
_minX, _maxX,
true, false, true);
this.FormatAxis(_chartAreaAxisX.AxisY,
_minY, _maxY,
false, false, false);
#endregion "one for X axis"
}
if (_chartAreaAxisY == null)
{
#region "one for Y axis"
_chartAreaAxisY = new ChartArea(Guid.NewGuid().ToString());
this.FormatChartArea(_chartAreaAxisY);
_chartAreaAxisY.BackColor = Color.Transparent;
_chartAreaAxisY.IsSameFontSizeForAllAxes = this.chart1.ChartAreas[0].IsSameFontSizeForAllAxes;
_chartAreaAxisY.Tag = new ChartAreaTagger(_guid, ChartAreaType.AXIS_Y, _color);
_chartAreaAxisY.AxisY.LabelStyle.ForeColor = _color;
this.chart1.ChartAreas.Add(_chartAreaAxisY);
this.FormatAxis(_chartAreaAxisY.AxisX,
_minX, _maxX,
false, false, false);
this.FormatAxis(_chartAreaAxisY.AxisY,
_minY, _maxY,
true, false, true);
#endregion "one for Y axis"
}
If we can find a chart area that matches our minimums and maximums (+/- defined percentage difference), then:
- we put our new chart onto it
- resize the scale of the found chart area so it can accept both graphs
#region "check minimums and maximums of the eventual existing charts"
IEnumerable<PointF>[] _functionsOnChartArea = this.GetFunctionsOnChartArea(_chartAreaChart);
foreach (IEnumerable<PointF> _functionTmp in _functionsOnChartArea)
{
float _functionMinX = this.GetMinX(_functionTmp);
if (_functionMinX < _minX)
{
_minX = _functionMinX;
}
float _functionMaxX = this.GetMaxX(_functionTmp);
if (_functionMaxX > _maxX)
{
_maxX = _functionMaxX;
}
float _functionMinY = this.GetMinY(_functionTmp);
if (_functionMinY < _minY)
{
_minY = _functionMinY;
}
float _functionMaxY = this.GetMaxY(_functionTmp);
if (_functionMaxY > _maxY)
{
_maxY = _functionMaxY;
}
}
#endregion "check minimums and maximums of the eventual existing charts"
#region "and adjust the axes accordingly, so they can take the old chart plus the newly added"
this.FormatAxis(_chartAreaAxisX.AxisX,
_minX, _maxX,
true, false, true);
this.FormatAxis(_chartAreaAxisX.AxisY,
_minY, _maxY,
false, false, false);
this.FormatAxis(_chartAreaAxisY.AxisX,
_minX, _maxX,
false, false, false);
this.FormatAxis(_chartAreaAxisY.AxisY,
_minY, _maxY,
true, false, true);
this.FormatAxis(_chartAreaChart.AxisX,
_minX, _maxX,
false, true, false);
this.FormatAxis(_chartAreaChart.AxisY,
_minY, _maxY,
false, true, false);
#endregion "and adjust the axes accordingly, so they can take the old chart plus the newly added"
#region "then draw our chart"
this.DrawGraph(_chartAreaAxisX, _points, null, Color.Transparent);
this.DrawGraph(_chartAreaAxisY, _points, null, Color.Transparent);
this.DrawGraph(_chartAreaChart, _points, _legendTitle, _color);
#endregion "then draw our chart"
The mentioned procedures must be done for axis X, axis Y and the chart!
The graph can thus be drawn. We need to do a finally step - the layout.
Layout
Now, the most important part: the layout. The single most important thing is that the given graphs can have very different ranges, so we have to base everything on percentages.
To properly position additional axes, we have to take labels into account: for X axes, the label HEIGHT
is important and for Y axes, the WIDTH
is what matters. We thus compute our plot positions of the additional chart areas, each one offsetting by the labels size.
private void PerformChartLayout()
{
if (this.chart1.Series.Count == 0)
{
this.chart1.ChartAreas[0].Visible = false;
}
else
{
this.chart1.ChartAreas[0].Visible = true;
if (!this.chart1.Series.Any(delegate (Series _series)
{
if (_series.ChartArea == this.chart1.ChartAreas[0].Name)
{
return true;
}
else
{
return false;
}
}))
{
this.DrawGraph(
this.chart1.ChartAreas[0],
new PointF[]
{
new PointF(1f, 1f),
new PointF(100f, 100f)
},
null,
Color.Transparent);
}
float _offsetX = 0;
float _offsetY = 0;
#region "position the chart plot positions with respect to label sizes"
foreach (ChartArea _chartArea in this.chart1.ChartAreas.Reverse())
{
_chartArea.Position.FromRectangleF(this.ChartPosition);
if (_chartArea.Tag is ChartAreaTagger)
{
ChartAreaTagger _chartAreaTagger = (ChartAreaTagger)_chartArea.Tag;
if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_X)
{
_chartArea.AxisX.MajorTickMark.Size = this.TickMarkSizePercentageY;
float _axisXLabelHeight = this.GetAxisXLabelHeightPercentage
(_chartArea) + this.TickMarkSizePercentageY;
RectangleF _chartInnerPlotPosition = this.GetChartInnerPlotPosition();
_chartInnerPlotPosition.Y -= (_axisXLabelHeight + _offsetY);
_chartInnerPlotPosition.Height -= (_axisXLabelHeight + _offsetY);
_chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition);
_offsetY += _axisXLabelHeight;
}
else if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_Y)
{
_chartArea.AxisY.MajorTickMark.Size = this.TickMarkSizePercentageX;
float _axisYLabelWidth = this.GetAxisYLabelWidthPercentage
(_chartArea) + this.TickMarkSizePercentageX;
RectangleF _chartInnerPlotPosition = this.GetChartInnerPlotPosition();
_chartInnerPlotPosition.X += (_axisYLabelWidth + _offsetX);
_chartInnerPlotPosition.Width -= (_axisYLabelWidth + _offsetX);
_chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition);
_offsetX += _axisYLabelWidth;
}
}
}
#endregion "position the chart plot positions with respect to label sizes"
#region "position the areas with respect to the offset"
foreach (ChartArea _chartArea in this.chart1.ChartAreas)
{
_chartArea.Position.FromRectangleF(this.ChartPosition);
if (_chartArea.Tag is ChartAreaTagger)
{
ChartAreaTagger _chartAreaTagger = (ChartAreaTagger)_chartArea.Tag;
if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_X)
{
RectangleF _chartInnerPlotPosition = _chartArea.InnerPlotPosition.ToRectangleF();
_chartInnerPlotPosition.X += _offsetX;
_chartInnerPlotPosition.Width -= _offsetX;
_chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition);
}
else if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_Y)
{
RectangleF _chartInnerPlotPosition = _chartArea.InnerPlotPosition.ToRectangleF();
_chartInnerPlotPosition.Height -= (_offsetY * 2);
_chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition);
}
else if (_chartAreaTagger.ChartAreaType == ChartAreaType.CHART)
{
RectangleF _chartInnerPlotPosition = this.GetChartInnerPlotPosition();
_chartInnerPlotPosition.X += _offsetX;
_chartInnerPlotPosition.Width -= _offsetX;
_chartInnerPlotPosition.Height -= (_offsetY * 2);
_chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition);
}
}
else if (_chartArea == this.chart1.ChartAreas[0]) {
RectangleF _chartInnerPlotPosition = this.GetChartInnerPlotPosition();
_chartInnerPlotPosition.X += _offsetX;
_chartInnerPlotPosition.Width -= _offsetX;
_chartInnerPlotPosition.Height -= (_offsetY * 2);
this.chart1.ChartAreas[0].InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition);
}
}
#endregion "position the areas with respect to the offset"
}
}
We used the PerformLayout()
method for one more thing: we made sure that there is always a (transparent!) graph present on the base chart area, so the grid will be displayed.
Points of Interest
The Microsoft Chart control is not particularly suitable for such heavy customizations. The legend positioning must be better ironed out, but this could be quite difficult, because it is never clear when an internal chart element is set or not, so the sizes are uncertain.