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);
}
MethodParametersPurpose
AdjustPlotRectGraphics g, AxisLayout axisCtxComputes 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.
DrawAxesGraphics g, AxisLayout axisCtxRenders 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.
DrawTicksGraphics g, AxisLayout axisCtxComputes 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);
}
ParameterDescription
onToggleSeriesCallback invoked with series index when a legend item is clicked. The chart handles visibility toggling.
placementLegendPlacement enum: Right (default), Bottom, Top, or Left. Controls legend panel position relative to the chart.
showLegendMaster visibility flag. When false, the legend is not rendered at all.
hiddenCountNumber 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:

FieldTypeDescription
BoundsRectangleTotal available rectangle for the chart (including margins). Set from the chart's ChartDrawingRect.
PlotRectRectangleInner rectangle where data is plotted (margins subtracted). Computed by AdjustPlotRect.
BottomAxisTypeAxisTypeX-axis data type: Numeric, Date, or Text. Detected by BeepChartDataHelper.
LeftAxisTypeAxisTypeY-axis data type. Nearly always Numeric in practice.
XMin / XMaxfloatCurrent X-axis data range (may differ from data range due to viewport zoom).
YMin / YMaxfloatCurrent Y-axis data range.
XTitle / YTitlestringAxis title labels. Usually XAxisTitle / YAxisTitle chart properties.
XLabelAngle / YLabelAnglefloatRotation angle in degrees for tick labels (0 = horizontal, 90 = vertical, -45 = slanted).
AxisColorColorColor for axis lines, tick marks, and title text. From ChartAxisColor theme token.
GridLineColorColorColor for grid lines extending from tick marks throughout the plot area.
XAxisDateMinDateTimeEpoch/reference date for date axis calculations. Used as the zero-point for day-offset computation.
XCategoriesDictionary<string,int>Category-to-index mapping for Text axis types. Populated by BeepChartDataHelper.
ShowGridboolMaster flag enabling grid line rendering. Tied to chart ShowGrid property.
MaxVisibleAxisLabelsintMaximum number of visible tick labels per axis (default 10). Exceeding this triggers label thinning.
DenseLabelOptimizationboolWhen 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 RangeGranularityIntervalLabel Format
< 1 hourMinutes5-15 min"HH:mm"
< 24 hoursHours1-3 hours"HH:mm" or "h tt"
< 7 daysHours6 hours"ddd HH:mm"
< 30 daysDays2 days"MMM dd"
< 365 daysMonths2 months"MMM yyyy"
>= 1 yearYears1 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 = -45 slants labels to save horizontal space (common for date axes). YLabelAngle defaults to 0. Rotation is applied via Graphics.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 SizeF values per string per font to avoid repeated MeasureString calls 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:

FeatureDescription
Color SquaresEach legend entry shows a 12x12px filled square in the series color, followed by the series name text. Colors cycle through ChartDefaultSeriesColors palette.
Series ToggleClicking 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-OutHidden 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.
EnableLegendIsolateWhen 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 ScrollingIf the number of series exceeds the available vertical space, a scroll offset is tracked and scroll indicators (arrows) are drawn at top/bottom.
Legend PlacementConfigured 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.

PropertyDescription
ShowGridMaster toggle for grid lines. Default true. When false, only tick marks are drawn.
GridLineColorColor of grid lines, typically a very light gray (e.g. #E0E0E0 or theme-derived). Tied to ChartGridLineColor property.
Optimized DensityGrid 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 StyleGrid 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}");
};