Chart Series Painters
Five concrete series painters — Bar, Line, Pie, Area, Bubble — with SeriesPainterFactory mapping, IChartSeriesPainter contract, and SeriesRenderOptions configuration
Table of Contents
Overview
Namespace: TheTechIdea.Beep.Winform.Controls.Charts
Assembly: TheTechIdea.Beep.Winform.Controls.dll
The charting system provides five concrete series painters, each responsible for rendering a distinct chart type. All painters implement the internal IChartSeriesPainter interface and are resolved at runtime via the SeriesPainterFactory static factory class. Each painter receives coordinate conversion delegates (toX / toY) that map data-space values to pixel-space positions within the computed plot rectangle. Series rendering is configured through the SeriesRenderOptions data class, which carries all style, layout, and behavior flags.
| ChartType | Painter Class | Rendering Style |
|---|---|---|
| Bar | BarSeriesPainter | Vertical bars with configurable width/padding, stacked mode, data labels, negative value support |
| Line | LineSeriesPainter | Polyline segments with optional cubic Bezier smoothing, configurable point markers (Circle/Square/Diamond/Triangle) |
| Pie | PieSeriesPainter | Proportional slices with optional donut mode (InnerRadius > 0), exploded slice on hover, pie-slice-shaped hit areas |
| Area | AreaSeriesPainter | Filled polygon below line to baseline, semi-transparent fill, stacked accumulation mode |
| Bubble | BubbleSeriesPainter | Proportional circles at XY coordinates, circle area (not radius) proportional to Size value |
SeriesPainterFactory
The static SeriesPainterFactory class maps ChartType enum values to concrete painter instances. LineSeriesPainter serves as the default fallback for any unrecognized or unspecified chart type.
internal static class SeriesPainterFactory
{
public static IChartSeriesPainter Get(ChartType type)
{
switch (type)
{
case ChartType.Bar: return new BarSeriesPainter();
case ChartType.Line: return new LineSeriesPainter();
case ChartType.Pie: return new PieSeriesPainter();
case ChartType.Area: return new AreaSeriesPainter();
case ChartType.Bubble: return new BubbleSeriesPainter();
default: return new LineSeriesPainter();
}
}
}
The factory is called once during chart initialization in BeepChart.cs and the resolved painter instance is cached for the lifetime of the chart control. If ChartType is changed at runtime, the painter is re-resolved and Initialize() is called again.
IChartSeriesPainter Contract
Every series painter implements this internal interface. The two-method contract separates drawing from hit-test area registration:
internal interface IChartSeriesPainter
{
void Initialize(BaseControl owner);
void DrawSeries(Graphics g, Rectangle plotRect,
List<ChartDataSeries> data,
Func<ChartDataPoint, object> toX,
Func<ChartDataPoint, object> toY,
float xMin, float xMax, float yMin, float yMax,
List<Color> palette, Color axisColor, Color textColor,
SeriesRenderOptions options);
void UpdateHitAreas(BaseControl owner, Rectangle plotRect,
List<ChartDataSeries> data,
Func<ChartDataPoint, PointF> toScreen,
Action<string, Rectangle> notifyAreaHit);
}
| Method | Parameters | Purpose |
|---|---|---|
| Initialize | BaseControl owner | One-time setup when painter is first resolved. Stores owner reference for theme/color access. |
| DrawSeries | Graphics g, Rectangle plotRect, List<ChartDataSeries> data, toX/toY delegates, float xMin/xMax/yMin/yMax, palette, axisColor, textColor, SeriesRenderOptions | Renders all data series onto the graphics surface within the specified plot rectangle. The toX and toY delegates convert each data point's X/Y values from string/object to a numeric pixel coordinate in the range [plotRect.Left..plotRect.Right] and [plotRect.Top..plotRect.Bottom]. |
| UpdateHitAreas | BaseControl owner, Rectangle plotRect, List<ChartDataSeries> data, toScreen delegate, notifyAreaHit callback | Registers per-data-point hit-test rectangles (or polygons for pie) via the notifyAreaHit(string key, Rectangle bounds) callback. These are used by ChartInputHelper for mouse hover, click, and trackball interaction. |
Coordinate Conversion
The toX and toY delegates are closures created in BeepChart.Drawing.cs that encapsulate the axis type detection and value conversion logic:
Func<ChartDataPoint, object> toX = point =>
plotRect.Left + (BeepChartDataHelper.ConvertXValue(chart, point) - xMin)
/ (xMax - xMin) * plotRect.Width;
Func<ChartDataPoint, object> toY = point =>
plotRect.Bottom - (BeepChartDataHelper.ConvertYValue(chart, point) - yMin)
/ (yMax - yMin) * plotRect.Height;BarSeriesPainter
Renders vertical bars for each data point in the series. Key features:
| Feature | Description |
|---|---|
| BarWidth / BarPadding | Control bar thickness as a fraction of the column width (default BarWidth=0.8) and the gap between adjacent bar groups (default BarPadding=0.2). For single-series charts, bars are centered in each X column. For multi-series, bars are grouped side-by-side within each X column. |
| Stacked Mode | When SeriesRenderOptions.Stacked = true, bars from multiple series accumulate vertically within each X column. Each series draws on top of the previous one's rendered height. The bottom series starts at the baseline (yMin). Useful for part-to-whole compositions. |
| Data Labels | When SeriesRenderOptions.ShowDataLabels = true, the Y value of each bar is drawn as text centered above the bar in the chart's text color. For stacked mode, labels appear at the top of each segment. |
| Negative Values | Bars with negative Y values extend downward from the baseline. The baseline is y=0 when the range spans both positive and negative, otherwise it's the yMin of the plot rectangle. |
| Multi-Series Grouping | For N series, each X category is divided into N sub-columns. The group width = BarWidth * columnWidth, and sub-bar width = groupWidth / N. Colors cycle through the palette. |
// BarSeriesPainter core loop (simplified):
int seriesCount = data.Count;
float colWidth = plotRect.Width / (float)(xMax - xMin + 1);
float groupWidth = colWidth * options.BarWidth;
float barWidth = groupWidth / seriesCount;
foreach (var series in data)
{
int seriesIdx = data.IndexOf(series);
Color color = palette[seriesIdx % palette.Count];
float prevY = plotRect.Bottom; // baseline for stacked
foreach (var point in series.DataPoints)
{
float xVal = Convert.ToSingle(toX(point));
float yVal = Convert.ToSingle(toY(point));
float x = xVal - groupWidth / 2f + barWidth * seriesIdx;
float barHeight = options.Stacked
? prevY - yVal
: plotRect.Bottom - yVal;
if (barHeight < 0) // negative values
{
y = plotRect.Bottom;
barHeight = plotRect.Bottom - yVal;
}
g.FillRectangle(new SolidBrush(color), x, yVal, barWidth, barHeight);
if (options.Stacked) prevY -= barHeight;
}
}LineSeriesPainter
Renders connected line segments for each series' data points. Key features:
| Feature | Description |
|---|---|
| Polyline Segments | Each series is drawn as a sequence of connected line segments using Graphics.DrawLines(). The line width is derived from the theme or set to 2px by default. A semi-transparent fill below the line can be enabled via FillOpacity. |
| Cubic Bezier Smoothing | When SeriesRenderOptions.SmoothLines = true, the painter computes cubic Bezier control points between each pair of consecutive data points and renders using Graphics.DrawBeziers(). This produces a smooth curve through all data points. Control points are computed using the Catmull-Rom to Bezier conversion formula with a tension of 0.5. |
| Point Markers | When SeriesRenderOptions.ShowMarkers = true (default), each data point is decorated with a small shape. Available shapes via the MarkerStyle enum: Circle (default) — filled ellipse, Square — filled rectangle, Diamond — rotated square (45°), Triangle — equilateral triangle pointing up. Marker size is controlled by MarkerSize (default 6px radius). |
| Multi-Series | Each series is drawn with its own color from the palette. Series are rendered in order (first series = first color). Lines may overlap; legend toggling is used to declutter. |
// Bezier smoothing calculation (Catmull-Rom to Bezier):
private PointF[] GetBezierControlPoints(PointF p0, PointF p1, PointF p2, PointF p3)
{
float tension = 0.5f;
PointF cp1 = new PointF(
p1.X + (p2.X - p0.X) / (6f / tension),
p1.Y + (p2.Y - p0.Y) / (6f / tension));
PointF cp2 = new PointF(
p2.X - (p3.X - p1.X) / (6f / tension),
p2.Y - (p3.Y - p1.Y) / (6f / tension));
return new PointF[] { p1, cp1, cp2, p2 };
}
// Marker rendering dispatch:
void DrawMarker(Graphics g, PointF pt, MarkerStyle style, int size, Color color)
{
int half = size / 2;
switch (style)
{
case MarkerStyle.Circle:
g.FillEllipse(brush, pt.X - half, pt.Y - half, size, size); break;
case MarkerStyle.Square:
g.FillRectangle(brush, pt.X - half, pt.Y - half, size, size); break;
case MarkerStyle.Diamond:
PointF[] diamond = { new(pt.X, pt.Y-half), new(pt.X+half, pt.Y),
new(pt.X, pt.Y+half), new(pt.X-half, pt.Y) };
g.FillPolygon(brush, diamond); break;
case MarkerStyle.Triangle:
PointF[] tri = { new(pt.X, pt.Y-half), new(pt.X+half, pt.Y+half),
new(pt.X-half, pt.Y+half) };
g.FillPolygon(brush, tri); break;
}
}PieSeriesPainter
Renders proportional pie or donut slices. Key features:
| Feature | Description |
|---|---|
| Proportional Slices | Each data point's angle is proportional to its Y value relative to the series total: angle = (pointY / totalY) * 360°. Slices are drawn counterclockwise starting from 12 o'clock. Each slice uses its series palette color. |
| Donut Mode | When InnerRadius > 0, the center of the pie is hollow, creating a donut chart. InnerRadius is specified as a float (e.g. 0.4 = 40% of the pie radius). The donut hole is rendered as a filled circle in the chart background color to simulate transparency. |
| Exploded Slice | On mouse hover (detected via hit-test), the hovered slice is drawn with an offset of ~10px radially outward from the center, creating an "exploded" visual effect. The original position of surrounding slices remains unchanged. |
| Sliver Protection | Extremely small slices (angle < 1°) are merged into a combined "Other" slice to prevent rendering artifacts. The threshold is configurable. |
| Data Labels | When ShowDataLabels = true, percentage values are drawn near the midpoint of each slice's arc. Labels use the text color and are positioned radially at 70% of the slice radius. |
// Pie slice rendering (simplified):
float sweepAngle = (pointY / totalY) * 360f;
float midAngle = startAngle + sweepAngle / 2f;
float labelRad = (pieRadius * 0.7f);
g.FillPie(brush, pieRect, startAngle, sweepAngle);
g.DrawPie(pen, pieRect, startAngle, sweepAngle);
if (options.ShowDataLabels)
{
float pct = (pointY / totalY) * 100f;
float labelX = center.X + (float)Math.Cos(ToRadians(midAngle)) * labelRad;
float labelY = center.Y + (float)Math.Sin(ToRadians(midAngle)) * labelRad;
g.DrawString($"{pct:F1}%", font, textBrush, labelX, labelY, sf);
}
// Donut hole:
if (options.InnerRadius > 0)
{
float holeSize = pieRadius * options.InnerRadius;
RectangleF holeRect = new(center.X - holeSize, center.Y - holeSize, holeSize*2, holeSize*2);
g.FillEllipse(bgBrush, holeRect);
}AreaSeriesPainter
Renders filled polygon areas below connected line segments. Key features:
| Feature | Description |
|---|---|
| Filled Polygon | For each series, the painter constructs a polygon from the data points (top edge) plus the baseline (bottom edge), forming a closed shape. This polygon is filled with the series color at FillOpacity transparency and outlined with the line color at full opacity. |
| Semi-Transparent Fill | Controlled via FillOpacity (default 0.3 = 30%). A value of 0 produces no fill (line-only), while 1.0 produces a fully opaque fill. Internally, Color.FromArgb((int)(255*fillOpacity), seriesColor) is used. |
| Stacked Mode | When Stacked = true, areas accumulate vertically. Series 0 is filled from pixelZeroLine (bottom of plot rect) to its Y values. Series 1 is filled from Series 0's Y values to Series 1's Y values. This creates a stacked area chart similar to a stream graph layout. The baseline remains the bottom of the plot rectangle for the bottom series. |
| Smooth Lines | When SmoothLines = true, the top edge uses Bezier curves (like LineSeriesPainter), and the fill polygon boundary follows the smoothed path. |
// Area fill construction (simplified):
List<PointF> polygonPoints = new();
foreach (var point in series.DataPoints)
polygonPoints.Add(new PointF(Single(toX(point)), Single(toY(point))));
// Close the polygon at the baseline
if (options.Stacked)
for (int i = series.DataPoints.Count - 1; i >= 0; i--)
polygonPoints.Add(new PointF(Single(toX(series.DataPoints[i])), baseY));
g.FillPolygon(new SolidBrush(Color.FromArgb(fillAlpha, color)), polygonPoints.ToArray());
g.DrawLines(pen, polygonPoints.Take(dataPoints.Count).ToArray());BubbleSeriesPainter
Renders proportional circles at XY coordinates. Key features:
| Feature | Description |
|---|---|
| Proportional Circles | Each data point is rendered as a filled circle at its (X, Y) position. The circle area (not radius) is proportional to the data point's Size value (Y-dimension). This ensures visual accuracy — a value of 200 produces a circle with twice the area of a value of 100. Circle area = Math.PI * sizeScale * yValue, so radius = Math.Sqrt(yValue * sizeScale / Math.PI). |
| Size Scaling | The sizeScale is computed automatically based on the plot rectangle dimensions and the data range: sizeScale = (plotRect.Width * plotRect.Height) / (maxY * 20), ensuring bubbles are visible but not overwhelming. |
| Opacity & Border | Bubbles are rendered with a semi-transparent fill (default alpha 180) and a solid 2px border in the same color. This allows overlapping bubbles to remain visible. |
| Hit Testing | Hit test is a circular distance check: (mouse.X - center.X)^2 + (mouse.Y - center.Y)^2 <= radius^2. The hit rectangle registered is the bounding box of the circle. |
// Bubble rendering (simplified):
float maxSize = data.SelectMany(s => s.DataPoints).Max(p => float.Parse(p.Y));
float sizeScale = (plotRect.Width * plotRect.Height) / (maxSize * 20f);
foreach (var series in data)
{
Color color = palette[data.IndexOf(series) % palette.Count];
using var fillBrush = new SolidBrush(Color.FromArgb(180, color));
using var pen = new Pen(color, 2f);
foreach (var point in series.DataPoints)
{
float cx = Convert.ToSingle(toX(point));
float cy = Convert.ToSingle(toY(point));
float val = float.Parse(point.Y);
float radius = (float)Math.Sqrt(val * sizeScale / Math.PI);
float r = Math.Max(radius, 3f);
g.FillEllipse(fillBrush, cx - r, cy - r, r * 2f, r * 2f);
g.DrawEllipse(pen, cx - r, cy - r, r * 2f, r * 2f);
}
}SeriesRenderOptions Reference
The SeriesRenderOptions data class encapsulates all configurable rendering parameters for series painters. Each painter reads the subset of options relevant to its chart type and ignores others (e.g., BarSeriesPainter uses BarWidth and BarPadding but ignores MarkerShape).
| Property | Type | Default | Used By | Effect |
|---|---|---|---|---|
| BarWidth | float | 0.8 | Bar | Fraction of column width occupied by bar group (0.1-1.0). Lower values create thinner bars with more gap. |
| BarPadding | float | 0.2 | Bar | Fraction of column width used as gap between bar groups. Total column usage = BarWidth + BarPadding. |
| ShowDataLabels | bool | false | Bar, Pie | Display Y values (or percentages for pie) as text labels on or near each visual element. |
| SmoothLines | bool | false | Line, Area | Apply cubic Bezier interpolation between consecutive points for a smooth curve instead of straight line segments. |
| ShowMarkers | bool | true | Line | Draw a shape marker at each data point on the line. |
| MarkerShape | MarkerStyle | Circle | Line | Shape drawn at each data point: Circle, Square, Diamond, or Triangle. |
| MarkerSize | int | 6 | Line | Radius (or half-size) of markers in pixels. Affects both visual size and hit-test area. |
| FillOpacity | float | 0.3 | Area | Transparency of the area fill (0.0 = fully transparent / line-only, 1.0 = fully opaque). |
| InnerRadius | float | 0 | Pie | Hole size for donut mode. 0 = full pie, 0.4 = donut with 40% radius hole, 0.8 = thin ring. |
| Stacked | bool | false | Bar, Area | Accumulate bars or areas vertically across series instead of drawing them side-by-side (bar) or independently (area). |
Additional internal fields: SeriesColor (nullable override color), EnableAnimation (future), AnimationDurationMs (future, default 400).
Examples
Pie Donut Chart
var chart = new BeepChart();
chart.ChartType = ChartType.Pie;
chart.ChartTitle = "Market Share";
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "Browser Share",
DataPoints = new List<ChartDataPoint>
{
new() { X = "Chrome", Y = "65" },
new() { X = "Edge", Y = "12" },
new() { X = "Firefox", Y = "8" },
new() { X = "Safari", Y = "10" },
new() { X = "Other", Y = "5" }
}
});
// Enable donut mode with 50% inner radius
chart.InnerRadius = 0.5f;
chart.ShowDataLabels = true;
Stacked Bar Chart
chart.ChartType = ChartType.Bar;
chart.Stacked = true;
chart.BarWidth = 0.7f;
chart.BarPadding = 0.3f;
chart.ChartTitle = "Quarterly Revenue by Product";
chart.DataSeries.Clear();
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "Product A",
DataPoints = new List<ChartDataPoint>
{
new() { X = "Q1", Y = "1200" },
new() { X = "Q2", Y = "1400" },
new() { X = "Q3", Y = "1100" },
new() { X = "Q4", Y = "1600" }
}
});
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "Product B",
DataPoints = new List<ChartDataPoint>
{
new() { X = "Q1", Y = "800" },
new() { X = "Q2", Y = "700" },
new() { X = "Q3", Y = "900" },
new() { X = "Q4", Y = "600" }
}
});
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "Product C",
DataPoints = new List<ChartDataPoint>
{
new() { X = "Q1", Y = "500" },
new() { X = "Q2", Y = "600" },
new() { X = "Q3", Y = "400" },
new() { X = "Q4", Y = "700" }
}
});
Smooth Line Chart with Markers
chart.ChartType = ChartType.Line;
chart.SmoothLines = true;
chart.ShowMarkers = true;
chart.MarkerShape = MarkerStyle.Diamond;
chart.MarkerSize = 8;
chart.ChartTitle = "Temperature Trend";
chart.DataSeries.Clear();
chart.DataSeries.Add(new ChartDataSeries
{
SeriesName = "Sensor A",
DataPoints = new List<ChartDataPoint>
{
new() { X = "Jan", Y = "22" },
new() { X = "Feb", Y = "24" },
new() { X = "Mar", Y = "28" },
new() { X = "Apr", Y = "32" },
new() { X = "May", Y = "35" },
new() { X = "Jun", Y = "38" }
}
});
// Custom draw hook for additional styling
chart.CustomDrawSeries += (s, e) =>
{
if (e.SeriesIndex == 0)
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
};
Programmatic Painter Selection
// Direct factory usage (internal, for testing/extension):
IChartSeriesPainter painter = SeriesPainterFactory.Get(ChartType.Bubble);
painter.Initialize(chart);
painter.DrawSeries(g, plotRect, chart.DataSeries,
toX, toY, xMin, xMax, yMin, yMax,
palette, axisColor, textColor, options);
painter.UpdateHitAreas(chart, plotRect, chart.DataSeries,
toScreen, (key, rect) => chart.HitAreas[key] = rect);