Una vez, cuando estaba desarrollando una aplicación, necesitaba mostrar diversos datos en forma de gráficos de líneas. Lo que yo quería era un control universal al que pudiese agregar y quitar gráficos (funciones), según me fuese necesario y, lo más importante, este gráfico tendría que mostrarse de forma simultánea y encajar en el área del gráfico independientemente de su rango.
Tal vez este tipo de soluciones existan en páginas de terceros, pero quería quedarme con mi .NET llano y puro, así que tuve que utilizar los controles de Microsoft Chart y extender su funcionalidad para que se adecúe a mis necesidades.
El problema de tener gráficos distintos es que tienden a tener diferentes rangos, lo que hace difícil combinarlos en el mismo gráfico. Debido a que el control de gráfico escala su tamaño para adaptarse a todos los gráficos, un gráfico que va del 1 aa 100 haría que otro gráfico que fuese del 0,001 al 0,01 fuese prácticamente invisible. Por supuesto, a veces esto es lo que necesitamos pero, ¿qué pasa si queremos comparar visualmente dos gráficos independientemente de sus rangos?
Por tanto, necesitamos un área de gráficos por gráfico o gráficos que se pueden agrupar (es decir, por rangos más o menos iguales). Estas áreas de gráficos deben ser apiladas y cada una debe tener sus propios ejes, de tamaño de acuerdo a la gráfica que se muestra. El resultado será un gráfico que tiene sus gráficos siempre escalados para adaptarse a toda el área del gráfico.
Estructura
El gráfico es básicamente una colección de diferentes áreas de gráfico que se apilan juntas. Por cada gráfico individual (o una colección de gráficos de proporciones similares), tenemos 3 diferentes zonas de gráficos: una para la visualización de la gráfica, una para el eje X y otra para el eje Y. Estas áreas de gráficos están unidas con un número guid y hacen un todo.
Como el gráfico original de .NET no proporciona dicha funcionalidad, etiquetamos cada área del gráfico utilizando la clase ChartAreaTagger:
private class ChartAreaTagger { public ChartAreaTagger(Guid _guid, ChartAreaType _chartAreaType, Color _chartColor) { this.guid = _guid; this.chartAreaType = _chartAreaType; this.chartColor = _chartColor; } private Guid guid; /// /// This is the unique value that binds all three chart areas together (X, Y and Chart). /// public Guid @Guid { get { return guid; } } private ChartAreaType chartAreaType; /// /// This defines which type of chart area are we dealing with. /// public ChartAreaType @ChartAreaType { get { return chartAreaType; } } private Color chartColor; public Color ChartColor { get { return chartColor; } } } /// /// This enumerates the three possible chart area types. /// private enum ChartAreaType { /// /// This is for X axis labels. /// AXIS_X = 2, /// /// This is for Y axis labels. /// AXIS_Y = 4, /// /// This is for displaying our chart. /// CHART = 8 }
La clase ChartAreaTagger nos proporciona toda la información que necesitamos para poner varios gráficos en el chart y realizar el diseño.
Elementos GUI
El gráfico hereda un UserControl y tiene un solo objeto: el control para gráficos de .NET. El control tiene un área de gráfico y una leyenda añadida en el diseñador - estos son los dos objetos básicos que nunca van a cambiar.
Como añadiremos y eliminaremos áreas según nos sea necesario, no proporcionan un formato específico en el diseñador - necesitamos un método en nuestro código para hacer el trabajo, por lo que todas las áreas se formatean de la misma manera:
private void FormatChartArea(ChartArea _chartArea) { ... }
Sin embargo, el área del gráfico base, que se utiliza para la visualización del grid y mantener todos los gráficos, necesita unas cuantas excepciones, por lo que nosotros nos encargaremos de esto en el método Load:
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;
Añadiendo un gráfico
El añadir un gráfico comienza con la comprobación de si un área del gráfico ya existe en nuestro chart. Hacemos esto mediante la obtención de los mínimos y máximos de la nueva gráfica y la comparación de estos datos con la existente.
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);
Estamos comprobando la diferencia porcentual, por lo que es posible que la diferencia calculada vaya a ser bastante grande cuando estamos tratando con pequeños valores, de hecho.
Por ejemplo, tenemos dos gráficas, la primera tiene un rango de 0 a 100 y la segunda tiene un rango de 1 a 100. Obviamente, el gráfico dado podría ser puesto en el mismo chart, pero la diferencia porcentual sería para calcular 100% para el mínimo (0 vs 1). Como esto es más que la MAX_PERCENTAL_DIFFERENCE, que se establece a 10%, el gráfico se someterá a su propia área de la gráfica.
Para hacer frente a esto, también comprobamos la diferencia porcentual en gráficos proporcionales. Así pues, si los mínimos, máximos y las proporciones son de MAX_PERCENTAL_DIFFERENCE, entonces el área del gráfico no es adecuada.
private ChartArea GetBestSuitedChartArea( float _minX, float _maxX, float _minY, float _maxY, out ChartArea _suitableAxisX, out ChartArea _suitableAxisY) { List _suitableAxisXtmp = new List(); List _suitableAxisYtmp = new List(); ChartArea _suitableChartArea = null; foreach (ChartArea _chartArea in this.chart1.ChartAreas) { //just a quick check if this chart area is even worth considering 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 we are here, that means that both X and Y axes are suitable, //so the chart area is suitable as well. if (_chartAreaTagger.ChartAreaType == ChartAreaType.CHART) { _suitableChartArea = _chartArea; } } } //when there are many suitable areas, we take the first ones. if (_suitableAxisXtmp.Count > 0) { _suitableAxisX = _suitableAxisXtmp[0]; } else { _suitableAxisX = null; } if (_suitableAxisYtmp.Count > 0) { _suitableAxisY = _suitableAxisYtmp[0]; } else { _suitableAxisY = null; } return _suitableChartArea; }
Si no hay ningún área de gráfico adecuada, a continuación:
- Creamos una nueva
- La formateamos
- Dimensionamos su escala de acuerdo a la gráfica
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); //format for chart (no labels) 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); //format for X axis this.FormatAxis(_chartAreaAxisX.AxisX, _minX, _maxX, true, false, true); //this is dummy 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 is dummy this.FormatAxis(_chartAreaAxisY.AxisX, _minX, _maxX, false, false, false); //format for Y axis this.FormatAxis(_chartAreaAxisY.AxisY, _minY, _maxY, true, false, true); #endregion "one for Y axis" }
Si podemos encontrar un área del gráfico que coincida con nuestros mínimos y máximos (+/- diferencia porcentual definida), entonces:
- Ponemos nuestro chart sobre ella
- Cambiamos el tamaño de la escala del área del gráfico encontrado, lo que afectará a ambos gráficos
#region "check minimums and maximums of the eventual existing charts" IEnumerable[] _functionsOnChartArea = this.GetFunctionsOnChartArea(_chartAreaChart); foreach (IEnumerable _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" //set the color to transparent, because we don't want chart to be shown on this axis this.DrawGraph(_chartAreaAxisX, _points, null, Color.Transparent); //set the color to transparent, because we don't want chart to be shown on this axis this.DrawGraph(_chartAreaAxisY, _points, null, Color.Transparent); //finally, add the chart and draw it this.DrawGraph(_chartAreaChart, _points, _legendTitle, _color); #endregion "then draw our chart"
Los procedimientos mencionados se deben hacer para el eje X, eje Y y el chart.
La gráfica de este modo se puede dibujar. Tenemos que hacer un último paso, el diseño.
Diseño
Ahora, la parte más importante: el diseño. La cosa más importante es que los gráficos dados pueden tener muy diferentes rangos, así que tenemos que basar todo en porcentajes.
Para colocar correctamente los ejes adicionales, tenemos que tomar en cuenta las etiquetas: para ejes X, la altura de la etiqueta es importante y para los ejes Y, el ancho es lo que importa. Por lo tanto, calculamos nuestras posiciones argumentales de las áreas gráfico adicionales, cada una compensará por el tamaño de las etiquetas.
private void PerformChartLayout() { if (this.chart1.Series.Count == 0) { //hide the base chart area this.chart1.ChartAreas[0].Visible = false; } else { //make the base chart area visible this.chart1.ChartAreas[0].Visible = true; //we must have a simple graph on our base series, otherwise the grid //would be hidden because of the MS chart's logic (but we make it transparent) if (!this.chart1.Series.Any(delegate (Series _series) { if (_series.ChartArea == this.chart1.ChartAreas[0].Name) { return true; } else { return false; } })) { //as we set our grid in percentages, the graph must be of suitable range 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" //we do this reversed, so the most recently added charts expand to outside foreach (ChartArea _chartArea in this.chart1.ChartAreas.Reverse()) { //set the whole chart area position _chartArea.Position.FromRectangleF(this.ChartPosition); if (_chartArea.Tag is ChartAreaTagger) { ChartAreaTagger _chartAreaTagger = (ChartAreaTagger)_chartArea.Tag; if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_X) { //set the size of the tick marks _chartArea.AxisX.MajorTickMark.Size = this.TickMarkSizePercentageY; //then measure the height of the labels float _axisXLabelHeight = this.GetAxisXLabelHeightPercentage (_chartArea) + this.TickMarkSizePercentageY; //and compute the position of the chart plot position //based on the size of the label and the offset, //which increases for every chart area RectangleF _chartInnerPlotPosition = this.GetChartInnerPlotPosition(); _chartInnerPlotPosition.Y -= (_axisXLabelHeight + _offsetY); _chartInnerPlotPosition.Height -= (_axisXLabelHeight + _offsetY); _chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition); //increase the offset! _offsetY += _axisXLabelHeight; } else if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_Y) { //set the size of the tick marks _chartArea.AxisY.MajorTickMark.Size = this.TickMarkSizePercentageX; //then measure the width of the labels float _axisYLabelWidth = this.GetAxisYLabelWidthPercentage (_chartArea) + this.TickMarkSizePercentageX; //and compute the position of the chart plot position //based on the size of the label and the offset, //which increases for every chart area RectangleF _chartInnerPlotPosition = this.GetChartInnerPlotPosition(); _chartInnerPlotPosition.X += (_axisYLabelWidth + _offsetX); _chartInnerPlotPosition.Width -= (_axisYLabelWidth + _offsetX); _chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition); //increase the offset! _offsetX += _axisYLabelWidth; } } } #endregion "position the chart plot positions with respect to label sizes" //the chart areas are now positioned accordingly to the label sizes, //but this is not enough; we must position them also by the full offsets //(which we computed while positioning them), //so we just iterate through chart areas once again and use the computed offsets. //The areas will thus start from offset and not from zero. #region "position the areas with respect to the offset" foreach (ChartArea _chartArea in this.chart1.ChartAreas) { //set the whole chart area position _chartArea.Position.FromRectangleF(this.ChartPosition); if (_chartArea.Tag is ChartAreaTagger) { ChartAreaTagger _chartAreaTagger = (ChartAreaTagger)_chartArea.Tag; if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_X) { //this moves to the right of the screen and decreases in width RectangleF _chartInnerPlotPosition = _chartArea.InnerPlotPosition.ToRectangleF(); _chartInnerPlotPosition.X += _offsetX; _chartInnerPlotPosition.Width -= _offsetX; _chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition); } else if (_chartAreaTagger.ChartAreaType == ChartAreaType.AXIS_Y) { //this doesn't move, but decreases in height RectangleF _chartInnerPlotPosition = _chartArea.InnerPlotPosition.ToRectangleF(); _chartInnerPlotPosition.Height -= (_offsetY * 2); _chartArea.InnerPlotPosition.FromRectangleF(_chartInnerPlotPosition); } else if (_chartAreaTagger.ChartAreaType == ChartAreaType.CHART) { //this moves to the right of the screen //(while not moving by Y), decreases in width and in height 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]) //don't forget the base chart area, //which also must take the offset into account { //this does the same as the other CHART chart areas 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" } }