Chart Axis & Legend
CartesianAxisPainter — numeric/date/text tick rendering with auto-granularity, plus RightSideLegendPainter for interactive series toggling
Table of Contents
Overview
Namespace: TheTechIdea.Beep.Winform.Controls.Charts
Assembly: TheTechIdea.Beep.Winform.Controls.dll
The chart axis and legend subsystem consists of two independent components that operate within the shared painting pipeline. CartesianAxisPainter handles X/Y axis line drawing, tick mark computation, label rendering, and grid line projection. RightSideLegendPainter renders a compact interactive legend that allows users to toggle series visibility and isolate individual series. Both implement internal interfaces (IChartAxisPainter and IChartLegendPainter) and receive configuration from the AxisLayout data class.
IChartAxisPainter Contract
Defines three methods for the axis rendering lifecycle:
internal interface IChartAxisPainter
{
AxisLayout AdjustPlotRect(Graphics g, AxisLayout axisCtx);
void DrawAxes(Graphics g, AxisLayout axisCtx);
void DrawTicks(Graphics g, AxisLayout axisCtx);
}
| Method | Parameters | Purpose |
|---|---|---|
| AdjustPlotRect | Graphics g, AxisLayout axisCtx | Computes the PlotRect by subtracting margins (48px default on all sides) from the Bounds rectangle. Returns the modified AxisLayout with updated PlotRect. Called before any drawing to establish the coordinate space. |
| DrawAxes | Graphics g, AxisLayout axisCtx | Renders the X and Y axis lines along the edges of the plot rectangle. Draws axis titles (rotated 90° for Y-axis) with theme-aware colors. Axis line width is 1px by default, color from AxisColor. |
| DrawTicks | Graphics g, AxisLayout axisCtx | Computes tick positions and renders tick marks with labels. Dispatches to specialized renderers based on axis type: DrawNumericTicks, DrawDateTicks, or DrawTextTicks. Also renders grid lines if ShowGrid = true. |
IChartLegendPainter Contract
internal interface IChartLegendPainter
{
void DrawLegend(Graphics g, Rectangle chartRect,
List<ChartDataSeries> series, List<Color> palette,
Font font, Color textColor, Color backColor,
Color shapeColor, BaseControl owner,
Action<int> onToggleSeries, LegendPlacement placement,
bool showLegend, int hiddenCount);
}
| Parameter | Description |
|---|---|
onToggleSeries | Callback invoked with series index when a legend item is clicked. The chart handles visibility toggling. |
placement | LegendPlacement enum: Right (default), Bottom, Top, or Left. Controls legend panel position relative to the chart. |
showLegend | Master visibility flag. When false, the legend is not rendered at all. |
hiddenCount | Number of currently hidden series, used to draw crossed-out indicators. |
AxisLayout Data Class
The AxisLayout class is the primary data transfer object between the chart control and the axis painter. It carries all axis-related configuration and computed state:
| Field | Type | Description |
|---|---|---|
| Bounds | Rectangle | Total available rectangle for the chart (including margins). Set from the chart's ChartDrawingRect. |
| PlotRect | Rectangle | Inner rectangle where data is plotted (margins subtracted). Computed by AdjustPlotRect. |
| BottomAxisType | AxisType | X-axis data type: Numeric, Date, or Text. Detected by BeepChartDataHelper. |
| LeftAxisType | AxisType | Y-axis data type. Nearly always Numeric in practice. |
| XMin / XMax | float | Current X-axis data range (may differ from data range due to viewport zoom). |
| YMin / YMax | float | Current Y-axis data range. |
| XTitle / YTitle | string | Axis title labels. Usually XAxisTitle / YAxisTitle chart properties. |
| XLabelAngle / YLabelAngle | float | Rotation angle in degrees for tick labels (0 = horizontal, 90 = vertical, -45 = slanted). |
| AxisColor | Color | Color for axis lines, tick marks, and title text. From ChartAxisColor theme token. |
| GridLineColor | Color | Color for grid lines extending from tick marks throughout the plot area. |
| XAxisDateMin | DateTime | Epoch/reference date for date axis calculations. Used as the zero-point for day-offset computation. |
| XCategories | Dictionary<string,int> | Category-to-index mapping for Text axis types. Populated by BeepChartDataHelper. |
| ShowGrid | bool | Master flag enabling grid line rendering. Tied to chart ShowGrid property. |
| MaxVisibleAxisLabels | int | Maximum number of visible tick labels per axis (default 10). Exceeding this triggers label thinning. |
| DenseLabelOptimization | bool | When true, the painter skips labels that would overlap with neighbors (pixel-based collision detection). |
CartesianAxisPainter
The sole concrete implementation of IChartAxisPainter. It performs a three-phase rendering pipeline on every paint:
Paint() cycle:
1. AdjustPlotRect(g, axisCtx)
- Compute 48px margins from Bounds
- Set PlotRect = Bounds with inset margins
- Account for Y-axis title width (rotated text)
- Return updated AxisLayout
2. DrawAxes(g, axisCtx)
- Draw X-axis line: (PlotRect.Left, PlotRect.Bottom) to (PlotRect.Right, PlotRect.Bottom)
- Draw Y-axis line: (PlotRect.Left, PlotRect.Top) to (PlotRect.Left, PlotRect.Bottom)
- Draw XTitle centered below bottom margin
- Draw YTitle rotated -90° left of left margin
3. DrawTicks(g, axisCtx)
- Dispatch to NumericTicks / DateTicks / TextTicks based on BottomAxisType
- Render tick marks (5px lines outward from axis)
- Render tick labels with rotation angle and density optimization
- Render grid lines if ShowGrid=true (faint lines across plot rect)
The 48px margin is a shared constant from ChartPainterMetrics and provides room for axis titles (14px), tick labels (12px), tick marks (5px), and a small buffer. The Y-axis title is rotated -90° and drawn using Graphics.Transform with a Matrix.RotateAt call.
Tick Rendering
Tick computation and rendering is the most complex part of the axis system, with three distinct algorithms.
NumericTicks
The numeric tick renderer computes "nice" step sizes using a standard algorithm that selects from the sequence: 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, ... and their fractional equivalents (0.1, 0.01, etc.). The goal is to produce readable, round-numbered ticks without excessive decimal places.
// Nice step size calculation:
float range = yMax - yMin;
float roughStep = range / (targetTicks - 1);
float exponent = (float)Math.Floor(Math.Log10(roughStep));
float fraction = roughStep / (float)Math.Pow(10, exponent);
float niceFraction;
if (fraction < 1.5) niceFraction = 1;
else if (fraction < 3.5) niceFraction = 2;
else if (fraction < 7.5) niceFraction = 5;
else niceFraction = 10;
float niceStep = niceFraction * (float)Math.Pow(10, exponent);
// Generate ticks at niceStep intervals
float tickMin = (float)Math.Ceiling(yMin / niceStep) * niceStep;
float tickMax = (float)Math.Floor(yMax / niceStep) * niceStep;
for (float v = tickMin; v <= tickMax; v += niceStep)
DrawTick(g, axisCtx, v, format: v >= 1000 ? $"{v/1000:F1}k" : $"{v:F0}");
DateTicks
The date tick renderer selects a granularity level based on the total time span of the visible data range. Ticks are always drawn at logical calendar boundaries (start of hour, start of day, 1st of month, Jan 1st).
TextTicks
For categorical (text) axes, labels are drawn centered in each category column. Each entry in the XCategories dictionary is rendered at the X position corresponding to its integer index. The label text is the dictionary key (the category name).
foreach (var kvp in axisCtx.XCategories.OrderBy(k => k.Value))
{
float x = PlotRect.Left + (kvp.Value - xMin) / (xMax - xMin) * PlotRect.Width;
float colWidth = PlotRect.Width / (xMax - xMin + 1);
float labelX = x + colWidth / 2f;
// Draw tick mark
g.DrawLine(axisPen, labelX, PlotRect.Bottom, labelX, PlotRect.Bottom + 5);
// Draw label centered under column
SizeF size = g.MeasureString(kvp.Key, tickFont);
g.DrawString(kvp.Key, tickFont, axisBrush,
labelX - size.Width / 2f, PlotRect.Bottom + 6);
}Date Granularity
The date granularity algorithm maps the total time span to an appropriate tick interval. Each span range selects a specific interval and display format:
| Time Range | Granularity | Interval | Label Format |
|---|---|---|---|
| < 1 hour | Minutes | 5-15 min | "HH:mm" |
| < 24 hours | Hours | 1-3 hours | "HH:mm" or "h tt" |
| < 7 days | Hours | 6 hours | "ddd HH:mm" |
| < 30 days | Days | 2 days | "MMM dd" |
| < 365 days | Months | 2 months | "MMM yyyy" |
| >= 1 year | Years | 1 year | "yyyy" |
// Date granularity resolution in CartesianAxisPainter.DrawDateTicks:
TimeSpan span = maxDate - minDate;
string format;
TimeSpan interval;
if (span.TotalHours < 1) { interval = TimeSpan.FromMinutes(10); format = "HH:mm"; }
else if (span.TotalHours < 24) { interval = TimeSpan.FromHours(1); format = "HH:mm"; }
else if (span.TotalDays < 7) { interval = TimeSpan.FromHours(6); format = "ddd HH:mm"; }
else if (span.TotalDays < 30) { interval = TimeSpan.FromDays(2); format = "MMM dd"; }
else if (span.TotalDays < 365) { interval = TimeSpan.FromDays(30); format = "MMM"; }
else { interval = TimeSpan.FromDays(365); format = "yyyy"; }
DateTime current = CeilDateTime(minDate, interval);
while (current <= maxDate)
{
float xPos = PlotRect.Left +
(float)((current - minDate).TotalDays / span.TotalDays) * PlotRect.Width;
DrawTickAt(g, xPos, current.ToString(format));
current += interval;
}
The XTimeGranularity property (Nullable<TimeSpan>) allows manual override of the automatic granularity. Set to override the computed interval. YTimeGranularity works identically for potential time-valued Y axes (rare).
Label Rotation & Density
When tick labels are too dense to render without overlap, the painter applies optimization strategies:
- XLabelAngle / YLabelAngle — Properties controlling label rotation.
XLabelAngle = -45slants labels to save horizontal space (common for date axes).YLabelAngledefaults to 0. Rotation is applied viaGraphics.RotateTransform. - DenseLabelOptimization — When enabled, the painter measures each label's width before drawing and skips labels whose bounding rectangle would overlap the previous label's rectangle. A 2px minimum gap is enforced.
- MaxVisibleAxisLabels — Maximum labels on a single axis (default 10). When the tick count exceeds this, the painter skips every Nth tick using the formula:
skipFactor = Ceil(tickCount / maxLabels). - Label Size Caching — The painter caches measured
SizeFvalues per string per font to avoid repeatedMeasureStringcalls in dense tick scenarios.
// Dense label collision avoidance:
float lastLabelRight = float.MinValue;
for (int i = 0; i < ticks.Count; i++)
{
float xPos = ticks[i].Position;
SizeF labelSize = g.MeasureString(ticks[i].Label, tickFont);
if (xPos - labelSize.Width / 2f > lastLabelRight + 2f)
{
DrawRotatedLabel(g, ticks[i].Label, xPos, yPos, angle, tickFont, brush);
lastLabelRight = xPos + labelSize.Width / 2f;
}
// else: skip this label to avoid overlap
}RightSideLegendPainter
Implements IChartLegendPainter. Renders a compact, interactive legend panel:
| Feature | Description |
|---|---|
| Color Squares | Each legend entry shows a 12x12px filled square in the series color, followed by the series name text. Colors cycle through ChartDefaultSeriesColors palette. |
| Series Toggle | Clicking a legend entry fires the onToggleSeries(int) callback. The chart responds by toggling the corresponding series' visibility (IsVisible property). This triggers a repaint. |
| Hidden Crossed-Out | Hidden series are rendered with a strikethrough line across the color square and faded text (50% alpha). This provides immediate visual feedback about which series are hidden. |
| EnableLegendIsolate | When enabled, clicking a visible legend entry isolates that series (hides all others). Clicking again restores all series. This is a shortcut for toggling all-but-one series. The SeriesIsolationChanged event fires on isolation. |
| Overflow Scrolling | If the number of series exceeds the available vertical space, a scroll offset is tracked and scroll indicators (arrows) are drawn at top/bottom. |
| Legend Placement | Configured via LegendPlacement enum. Right (default): panel vertically on right side. Bottom: horizontal strip below chart. Top: horizontal strip above title. Left: vertical strip on left side. |
// Legend item rendering (simplified):
float yOffset = startY;
for (int i = 0; i < series.Count; i++)
{
if (!series[i].IsVisible)
{
// Draw crossed-out, faded entry
g.DrawLine(strikethroughPen, startX, yOffset + 6, startX + 12, yOffset + 6);
g.DrawString(series[i].SeriesName, font, fadedBrush, startX + 16, yOffset);
}
else
{
g.FillRectangle(new SolidBrush(palette[i]), startX, yOffset, 12, 12);
g.DrawString(series[i].SeriesName, font, textBrush, startX + 16, yOffset);
seriesRects[i] = new Rectangle(startX, yOffset, itemWidth, itemHeight);
}
yOffset += itemHeight + 4;
}
// Hit test on click:
for (int i = 0; i < seriesRects.Length; i++)
if (seriesRects[i].Contains(mousePoint))
onToggleSeries?.Invoke(i);Grid Lines
Grid lines extend from each tick mark vertically or horizontally across the plot rectangle. They provide visual reference for data values against the axis scales.
| Property | Description |
|---|---|
| ShowGrid | Master toggle for grid lines. Default true. When false, only tick marks are drawn. |
| GridLineColor | Color of grid lines, typically a very light gray (e.g. #E0E0E0 or theme-derived). Tied to ChartGridLineColor property. |
| Optimized Density | Grid line density is automatically reduced when the chart has many data points or a small plot area. BeepChartPerformanceHelper.OptimizeGridDensity(size, zoom) determines the skip count for grid lines. The painter then draws every Nth horizontal and vertical grid line. |
| Grid Style | Grid lines use a 1px dashed style by default (DashStyle.Dot), rendered via Pen.DashStyle. The pen width is 1px. |
// Grid line rendering during DrawTicks:
if (axisCtx.ShowGrid)
{
using Pen gridPen = new(axisCtx.GridLineColor, 1f)
{ DashStyle = DashStyle.Dot };
foreach (var tick in computedTicks)
{
// Vertical grid line from top to bottom of PlotRect at tick X
g.DrawLine(gridPen, tick.X, PlotRect.Top, tick.X, PlotRect.Bottom);
}
}Examples
Date Axis with Monthly Ticks
var chart = new BeepChart();
chart.ChartType = ChartType.Line;
chart.ChartTitle = "Monthly Revenue";
chart.BottomAxisType = AxisType.Date;
chart.XLabelAngle = -45f;
chart.ShowGrid = true;
chart.GridLineColor = Color.FromArgb(220, 220, 220);
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "Revenue",
DataPoints = new List<ChartDataPoint>
{
new() { X = "2024-01-01", Y = "45000" },
new() { X = "2024-02-01", Y = "52000" },
new() { X = "2024-03-01", Y = "48000" },
new() { X = "2024-04-01", Y = "61000" },
new() { X = "2024-05-01", Y = "55000" },
new() { X = "2024-06-01", Y = "67000" },
new() { X = "2024-07-01", Y = "72000" },
new() { X = "2024-08-01", Y = "70000" },
new() { X = "2024-09-01", Y = "78000" },
new() { X = "2024-10-01", Y = "82000" },
new() { X = "2024-11-01", Y = "79000" },
new() { X = "2024-12-01", Y = "95000" }
}
});
// Auto-scale to data after adding points
chart.AutoScaleViewport();
Numeric Axis with Custom Ticks
chart.BottomAxisType = AxisType.Numeric;
chart.LeftAxisType = AxisType.Numeric;
chart.XAxisTitle = "Concentration (mg/L)";
chart.YAxisTitle = "Absorbance";
chart.XLabelAngle = 0f;
chart.YLabelAngle = 0f;
chart.MaxVisibleAxisLabels = 8;
chart.DenseLabelOptimization = true;
// Force axis range if auto-scale is insufficient
chart.ViewportXMin = 0f;
chart.ViewportXMax = 100f;
chart.ViewportYMin = 0f;
chart.ViewportYMax = 1.0f;
Legend Configuration with Isolation
chart.ShowLegend = true;
chart.LegendPlacement = LegendPlacement.Right;
chart.EnableLegendIsolate = true;
// Multi-series chart
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "CPU",
DataPoints = GeneratePoints("CPU", 50)
});
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "Memory",
DataPoints = GeneratePoints("Memory", 50)
});
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "Disk IO",
DataPoints = GeneratePoints("DiskIO", 50)
});
// Handle legend interactions
chart.SeriesIsolationChanged += (s, e) =>
{
Console.WriteLine(e.IsolatedSeriesIndex >= 0
? $"Series {e.IsolatedSeriesIndex} isolated"
: "All series restored");
};
Axis Hit Testing
chart.MouseClick += (s, e) =>
{
// The chart internally registers axis hit areas during DrawAxes/DrawTicks
// AxisHit event provides axis type, value, and label string
};
chart.AxisHit += (s, e) =>
{
string axis = e.Axis == AxisTarget.X ? "X" : "Y";
Console.WriteLine($"Clicked {axis}-axis at value {e.Value}, label: {e.Label}");
};