Every control we have used so far — buttons, labels, text fields, grids — was drawn for us by the LCL and the operating system. We placed them on a form, set their properties, and they appeared. But what happens when you need something that no...
Learning Objectives
- Understand the TCanvas coordinate system and drawing model
- Draw lines, rectangles, ellipses, polygons, and arcs using TCanvas methods
- Use TColor, TPen, and TBrush to control appearance
- Render text on the canvas with TextOut and font selection
- Load and display images with TImage, TBitmap, and TPicture
- Build pie charts, bar charts, and line charts from scratch
- Create custom painted controls using TPaintBox and OnPaint
- Implement basic animation with TTimer and double buffering
In This Chapter
Chapter 30: Drawing, Graphics, and Custom Controls
"The purpose of visualization is insight, not pictures." — Ben Shneiderman
Every control we have used so far — buttons, labels, text fields, grids — was drawn for us by the LCL and the operating system. We placed them on a form, set their properties, and they appeared. But what happens when you need something that no pre-built control provides? A spending pie chart. A progress ring. A custom gauge. A game board. A network graph.
For these, you draw. You put pixels on the screen yourself, one line, one shape, one color at a time. This chapter teaches you how. We explore the TCanvas object — the LCL's drawing surface — and learn to draw primitives, render text, display images, and build complete data visualizations from scratch. By the end, PennyWise will have a spending pie chart and a monthly bar chart, both drawn pixel by pixel, both updated in real time as expenses are added.
This is where native compiled code truly shines. Canvas drawing in Lazarus is fast — not because of clever optimization, but because it compiles to native machine code that talks directly to the operating system's graphics API. No JavaScript rendering engine, no virtual machine, no abstraction layers. Just your code and the GPU.
Canvas drawing is a fundamental programming skill that extends far beyond charting. Games, simulations, scientific visualizations, image editors, CAD tools, map renderers — all of these are built on the same primitives you will learn in this chapter. Once you can draw lines, shapes, and text on a canvas, you can build any visual representation you can imagine.
The learning curve has a sweet spot: the basic primitives are simple (lines, rectangles, ellipses), but combining them to build meaningful visualizations requires both programming skill and design thinking. We will focus on both — the mechanics of drawing and the principles of visual communication.
30.1 The Canvas
Every visible control in the LCL has a Canvas property — a TCanvas object that represents the control's drawing surface. The canvas is your sketchpad: you draw on it, and the result appears on screen.
The Coordinate System
The canvas uses a Cartesian coordinate system with the origin at the top-left corner:
(0,0) ──────────────────────── (Width,0)
│ │
│ Canvas Area │
│ │
│ (x,y) │
│ • │
│ │
(0,Height) ──────────────── (Width,Height)
- X increases to the right. X=0 is the left edge.
- Y increases downward. Y=0 is the top edge. This is inverted compared to the mathematical convention where Y increases upward.
- Coordinates are in pixels — the smallest addressable unit on the screen.
The inverted Y-axis catches mathematicians by surprise. If you want to plot a mathematical function where Y increases upward, you must flip the Y coordinate: ScreenY := CanvasHeight - MathY. We will do this when building charts.
For most UI drawing (text, shapes, layout), the top-left origin is natural: you read text from top to bottom and lay out controls from top-left to bottom-right. The coordinate system matches the visual flow.
When to Draw: The OnPaint Event
You do not draw on a canvas whenever you feel like it. The operating system controls when painting happens. When a window needs to be redrawn — because it was obscured by another window, because it was resized, because you called Invalidate — the OS sends a paint message. The LCL translates this into the OnPaint event.
The golden rule of canvas drawing: do all your drawing inside the OnPaint event handler (or in OnDrawCell for grids, OnDrawItem for list boxes, etc.). If you draw outside OnPaint, your drawing will be erased the next time the control repaints.
procedure TForm1.PaintBox1Paint(Sender: TObject);
begin
with PaintBox1.Canvas do
begin
Pen.Color := clBlack;
Brush.Color := clYellow;
Rectangle(10, 10, 200, 100);
end;
end;
To force a repaint, call Invalidate on the control:
procedure TForm1.Button1Click(Sender: TObject);
begin
FColor := clRed; { change some state }
PaintBox1.Invalidate; { request a repaint }
end;
Invalidate does not paint immediately — it tells the OS that the control needs repainting. The actual paint happens when the event loop processes the next paint message. This batching prevents redundant repaints when multiple properties change in quick succession.
The Painting Lifecycle
Understanding the complete painting lifecycle helps you avoid common mistakes:
- Something triggers a repaint: the form appears for the first time, the user resizes the window, another window uncovers part of your form, or your code calls
Invalidate. - The OS marks the control's area (or a portion of it) as "invalid" — needing redraw.
- The event loop processes the paint message.
- The LCL erases the control's background (filling it with the
Colorproperty). - The LCL fires the
OnPaintevent. - Your
OnPainthandler draws everything. - The OS copies the result to the screen.
This means your OnPaint handler must draw everything every time it is called. You cannot draw something once and expect it to persist — the next repaint will erase it and call OnPaint again. Store your drawing data in fields (arrays, lists, records) and redraw from that data in OnPaint.
30.2 Drawing Primitives
Lines
Canvas.Pen.Color := clBlack;
Canvas.Pen.Width := 2;
Canvas.MoveTo(10, 10); { move the "pen" to starting position }
Canvas.LineTo(200, 100); { draw a line from current position to (200,100) }
Canvas.LineTo(200, 200); { continue from (200,100) to (200,200) }
MoveTo positions the pen without drawing. LineTo draws a line from the current position to the specified point and updates the current position.
For drawing a sequence of connected line segments (a polyline), you can chain LineTo calls or use the Polyline method:
var
Points: array[0..4] of TPoint;
begin
Points[0] := Point(10, 100);
Points[1] := Point(60, 20);
Points[2] := Point(110, 80);
Points[3] := Point(160, 30);
Points[4] := Point(210, 90);
Canvas.Polyline(Points); { draws connected lines through all points }
end;
To draw a single straight line between two points without affecting the pen position, use Line:
Canvas.Line(10, 10, 200, 100); { from (10,10) to (200,100) }
Rectangles
{ Filled rectangle }
Canvas.Brush.Color := clLime;
Canvas.Pen.Color := clBlack;
Canvas.Rectangle(50, 50, 200, 150); { left, top, right, bottom }
{ Rounded rectangle }
Canvas.RoundRect(50, 50, 200, 150, 20, 20); { + corner radius X, Y }
{ Unfilled rectangle (outline only) }
Canvas.Brush.Style := bsClear;
Canvas.Rectangle(50, 50, 200, 150);
Canvas.Brush.Style := bsSolid; { restore default }
{ Frame (outline only, using pen color) }
Canvas.Frame(50, 50, 200, 150);
{ FillRect (fill only, no outline) }
Canvas.Brush.Color := clYellow;
Canvas.FillRect(50, 50, 200, 150);
The Rectangle method draws both the outline (controlled by Pen) and the fill (controlled by Brush). To draw only the outline, set Brush.Style := bsClear. To draw only the fill, use FillRect (which ignores the pen).
Note the parameter order: (Left, Top, Right, Bottom). The right and bottom coordinates are exclusive — the pixel at (Right, Bottom) is not drawn. A rectangle from (50, 50) to (200, 150) is 150 pixels wide and 100 pixels tall.
Ellipses and Circles
{ Ellipse bounded by a rectangle }
Canvas.Brush.Color := clAqua;
Canvas.Ellipse(50, 50, 250, 150); { left, top, right, bottom }
{ Circle — an ellipse where width equals height }
Canvas.Ellipse(100, 100, 200, 200); { 100x100 square = circle }
An ellipse is defined by its bounding rectangle. If the bounding rectangle is a square, the ellipse is a circle.
Polygons
var
Points: array[0..2] of TPoint;
begin
Points[0] := Point(150, 50); { top }
Points[1] := Point(50, 200); { bottom-left }
Points[2] := Point(250, 200); { bottom-right }
Canvas.Brush.Color := clRed;
Canvas.Polygon(Points); { draws a filled triangle }
end;
Polygon closes the shape automatically (connects the last point to the first). For an open polyline (not filled, not closed), use Polyline.
You can draw any shape with polygons: pentagons, hexagons, stars, arrows. Here is a five-pointed star:
procedure DrawStar(Canvas: TCanvas; CX, CY, OuterR, InnerR: Integer);
var
Points: array[0..9] of TPoint;
I: Integer;
Angle: Double;
begin
for I := 0 to 9 do
begin
Angle := (I * 36 - 90) * Pi / 180; { 36 degrees per point, start at top }
if Odd(I) then
begin
Points[I].X := CX + Round(InnerR * Cos(Angle));
Points[I].Y := CY + Round(InnerR * Sin(Angle));
end
else
begin
Points[I].X := CX + Round(OuterR * Cos(Angle));
Points[I].Y := CY + Round(OuterR * Sin(Angle));
end;
end;
Canvas.Polygon(Points);
end;
Arcs and Pie Slices
{ Arc — a portion of an ellipse outline }
Canvas.Arc(50, 50, 250, 250, { bounding rectangle }
200, 50, { start point on ellipse }
50, 200); { end point on ellipse }
{ Pie slice — a filled wedge }
Canvas.Pie(50, 50, 250, 250,
200, 50,
50, 200);
The Arc and Pie methods are defined by a bounding rectangle and two points that determine the start and end angles. The points do not need to be on the ellipse — the method projects them radially. This API is somewhat unintuitive; for pie charts, we will use a helper function that works with angles.
Radial Arc Drawing (Cross-Platform)
The LCL also provides RadialArc on some platforms, but for maximum portability, we can write an angle-based pie slice function:
procedure DrawPieSlice(Canvas: TCanvas; CX, CY, Radius: Integer;
StartDeg, SweepDeg: Double);
var
X1, Y1, X2, Y2: Integer;
begin
X1 := CX + Round(Radius * Cos(DegToRad(StartDeg)));
Y1 := CY - Round(Radius * Sin(DegToRad(StartDeg)));
X2 := CX + Round(Radius * Cos(DegToRad(StartDeg + SweepDeg)));
Y2 := CY - Round(Radius * Sin(DegToRad(StartDeg + SweepDeg)));
Canvas.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
X1, Y1, X2, Y2);
end;
Now you can draw a 90-degree wedge starting at the top (12 o'clock) simply:
DrawPieSlice(Canvas, 150, 150, 100, 90, 90); { 90-degree slice from top }
💡 Intuition: The Painter's Model Canvas drawing works like painting on a physical canvas. You choose your tools (pen for outlines, brush for fills), you draw shapes, and the results layer on top of each other. The last thing drawn is on top. There is no z-ordering or object model — just pixels. If you draw a red rectangle and then a blue circle that overlaps it, the blue pixels replace the red pixels in the overlapping area.
Drawing a Grid Background
A common drawing pattern is a grid background — useful for graph paper, pixel editors, or coordinate reference:
procedure DrawGrid(Canvas: TCanvas; Width, Height, CellSize: Integer);
var
X, Y: Integer;
begin
Canvas.Pen.Color := clSilver;
Canvas.Pen.Width := 1;
Canvas.Pen.Style := psSolid;
{ Vertical lines }
X := 0;
while X <= Width do
begin
Canvas.MoveTo(X, 0);
Canvas.LineTo(X, Height);
Inc(X, CellSize);
end;
{ Horizontal lines }
Y := 0;
while Y <= Height do
begin
Canvas.MoveTo(0, Y);
Canvas.LineTo(Width, Y);
Inc(Y, CellSize);
end;
end;
Coordinate Transformation Helper
When building charts, you frequently need to transform between data coordinates (where the Y axis increases upward and values have real-world units like dollars) and screen coordinates (where Y increases downward and values are in pixels). A helper record keeps this clean:
type
TChartTransform = record
DataMinX, DataMaxX: Double;
DataMinY, DataMaxY: Double;
ScreenLeft, ScreenTop, ScreenRight, ScreenBottom: Integer;
end;
function DataToScreenX(const T: TChartTransform; DataX: Double): Integer;
begin
Result := T.ScreenLeft + Round(
(DataX - T.DataMinX) / (T.DataMaxX - T.DataMinX) *
(T.ScreenRight - T.ScreenLeft));
end;
function DataToScreenY(const T: TChartTransform; DataY: Double): Integer;
begin
{ Note: screen Y is inverted (0 at top) }
Result := T.ScreenBottom - Round(
(DataY - T.DataMinY) / (T.DataMaxY - T.DataMinY) *
(T.ScreenBottom - T.ScreenTop));
end;
With this helper, you can plot any data range into any screen rectangle without repeating the transformation math in every drawing function.
30.3 Colors, Pens, and Brushes
TColor
Colors in the LCL are represented by the TColor type. You can use predefined constants or construct custom colors:
{ Predefined constants }
Canvas.Pen.Color := clRed;
Canvas.Pen.Color := clBlue;
Canvas.Pen.Color := clBlack;
Canvas.Pen.Color := clWhite;
Canvas.Pen.Color := clNavy;
{ RGB function — (Red, Green, Blue) each 0..255 }
Canvas.Pen.Color := RGBToColor(255, 128, 0); { orange }
{ Hex notation — $BBGGRR (note: BGR, not RGB) }
Canvas.Pen.Color := $00FF80; { same orange, written as BGR hex }
⚠️ Caution: BGR Byte Order
Lazarus TColor uses BGR byte ordering internally: $00BBGGRR. The RGBToColor(R, G, B) function handles this for you and is the recommended way to construct custom colors.
Common color constants: clBlack, clWhite, clRed, clGreen, clBlue, clYellow, clFuchsia, clAqua, clNavy, clMaroon, clOlive, clTeal, clSilver, clGray, clLime, clPurple.
System colors reference the current theme: clBtnFace (button background), clWindow (window background), clWindowText (default text color), clHighlight (selection highlight), clHighlightText (selected text color). Using system colors makes your application respect the user's theme settings.
Extracting Color Components
To break a TColor into its RGB components:
var
R, G, B: Byte;
begin
RedGreenBlue(SomeColor, R, G, B);
{ Now R, G, B hold the individual components }
end;
TPen
The Pen controls the outline of shapes and lines:
Canvas.Pen.Color := clDkGray;
Canvas.Pen.Width := 3; { line width in pixels }
Canvas.Pen.Style := psDash; { psSolid, psDash, psDot, psDashDot, psClear }
Pen styles:
| Style | Appearance | Use |
|---|---|---|
psSolid |
Solid line | Default for all outlines |
psDash |
Dashed line | Grid lines, guidelines |
psDot |
Dotted line | Secondary grid lines |
psDashDot |
Alternating dash-dot | Borders, annotations |
psDashDotDot |
Dash-dot-dot | Rarely used |
psClear |
No line drawn | Shapes without outlines |
Note: when Pen.Width is greater than 1, some platforms render all non-solid styles as solid. This is a platform limitation, not a bug.
Setting Pen.Style := psClear draws shapes without outlines.
TBrush
The Brush controls the fill of shapes:
Canvas.Brush.Color := clYellow;
Canvas.Brush.Style := bsSolid; { bsSolid, bsClear, bsHorizontal, bsVertical, }
{ bsFDiagonal, bsBDiagonal, bsCross, bsDiagCross }
Brush styles:
| Style | Appearance | Use |
|---|---|---|
bsSolid |
Solid fill | Default for all fills |
bsClear |
No fill (transparent) | Outline-only shapes |
bsHorizontal |
Horizontal lines | Hatched fills |
bsVertical |
Vertical lines | Hatched fills |
bsFDiagonal |
Forward diagonal lines | Hatched fills |
bsBDiagonal |
Backward diagonal lines | Hatched fills |
bsCross |
Grid pattern | Hatched fills |
bsDiagCross |
Diamond pattern | Hatched fills |
Setting Brush.Style := bsClear draws shapes without fills (outline only).
30.4 Text on Canvas
TextOut
The simplest way to draw text:
Canvas.Font.Name := 'Arial';
Canvas.Font.Size := 14;
Canvas.Font.Color := clNavy;
Canvas.Font.Style := [fsBold];
Canvas.TextOut(50, 30, 'Hello, Canvas!');
TextOut(X, Y, Text) draws the text with its top-left corner at (X, Y). The text is drawn using the canvas's current Font settings and the Brush color fills behind the text. To draw text without a background fill, set Brush.Style := bsClear.
TextRect
To draw text constrained within a rectangle (with clipping and alignment):
var
R: TRect;
begin
R := Rect(50, 50, 250, 80);
Canvas.Font.Size := 12;
Canvas.Brush.Style := bsClear;
Canvas.TextRect(R, R.Left + 4, R.Top + 2, 'Clipped text here');
end;
The TextRect overload with a TTextStyle parameter provides more control:
var
R: TRect;
Style: TTextStyle;
begin
R := Rect(50, 50, 250, 80);
Style := Canvas.TextStyle;
Style.Alignment := taCenter; { horizontal alignment }
Style.Layout := tlCenter; { vertical alignment }
Style.Wordbreak := True; { wrap text }
Style.SingleLine := False; { allow multiple lines }
Canvas.TextRect(R, 0, 0, 'Centered text in a box', Style);
end;
Measuring Text
Before drawing text, you often need to know how much space it will occupy:
var
W, H: Integer;
begin
W := Canvas.TextWidth('Hello'); { width in pixels }
H := Canvas.TextHeight('Hello'); { height in pixels }
{ Center the text in a 300-pixel wide area: }
Canvas.TextOut((300 - W) div 2, 50, 'Hello');
end;
Text measurement is essential for charts. When you draw labels on a pie chart or axis labels on a bar chart, you need to know the pixel width and height of each label to position it correctly and avoid overlaps.
Drawing Rotated Text
The LCL supports rotated text through the Font.Orientation property:
Canvas.Font.Orientation := 900; { angle in tenths of degrees: 900 = 90 degrees }
Canvas.TextOut(30, 200, 'Vertical text');
Canvas.Font.Orientation := 0; { reset to horizontal }
This is useful for drawing Y-axis labels on charts.
Drawing Anti-Aliased Text
By default, canvas text rendering uses the system's anti-aliasing settings. On modern systems, this typically means ClearType (Windows) or subpixel rendering (Linux/macOS). If you need to ensure smooth text on a bitmap canvas (for off-screen rendering), set the font quality:
Canvas.Font.Quality := fqAntialiased; { force anti-aliasing }
Canvas.Font.Quality := fqNonAntialiased; { disable anti-aliasing for pixel art }
Canvas.Font.Quality := fqDefault; { use system default }
Practical Text Layout: Multi-Line Labels
Sometimes you need to draw multi-line text manually — for example, wrapping text inside a custom component. The TextRect method with Wordbreak handles this:
procedure DrawWrappedText(Canvas: TCanvas; const ARect: TRect;
const AText: string);
var
Style: TTextStyle;
begin
Style := Canvas.TextStyle;
Style.Wordbreak := True;
Style.SingleLine := False;
Style.Alignment := taLeftJustify;
Style.Layout := tlTop;
Style.Clipping := True;
Canvas.Brush.Style := bsClear;
Canvas.TextRect(ARect, ARect.Left, ARect.Top, AText, Style);
end;
This draws the text within the specified rectangle, wrapping at word boundaries and clipping any text that overflows the rectangle.
30.5 Loading and Displaying Images
TImage
The simplest way to display an image in Lazarus is the TImage component. Drop it on a form and set its Picture property:
Image1.Picture.LoadFromFile('photo.png');
Image1.Stretch := True; { scale image to fit the control }
Image1.Proportional := True; { maintain aspect ratio }
Image1.Center := True; { center within the control }
TBitmap
For programmatic image manipulation, use TBitmap:
var
Bmp: TBitmap;
begin
Bmp := TBitmap.Create;
try
Bmp.Width := 200;
Bmp.Height := 200;
{ Draw on the bitmap's canvas }
Bmp.Canvas.Brush.Color := clWhite;
Bmp.Canvas.FillRect(0, 0, 200, 200);
Bmp.Canvas.Pen.Color := clRed;
Bmp.Canvas.Ellipse(10, 10, 190, 190);
{ Display the bitmap in a TImage }
Image1.Picture.Bitmap := Bmp;
{ Or save to file }
Bmp.SaveToFile('circle.bmp');
finally
Bmp.Free;
end;
end;
Drawing Bitmaps on Canvas
{ Draw a bitmap at position (x, y) }
Canvas.Draw(50, 50, MyBitmap);
{ Draw a bitmap stretched to a rectangle }
Canvas.StretchDraw(Rect(50, 50, 250, 150), MyBitmap);
PNG Support
Free Pascal supports PNG images through the Graphics unit. Load and display them like bitmaps:
var
Png: TPortableNetworkGraphic;
begin
Png := TPortableNetworkGraphic.Create;
try
Png.LoadFromFile('icon.png');
Canvas.Draw(10, 10, Png);
finally
Png.Free;
end;
end;
Copying Canvas Regions
You can copy rectangular regions from one canvas to another using CopyRect:
DestCanvas.CopyRect(
Rect(0, 0, 100, 100), { destination rectangle }
SourceCanvas,
Rect(50, 50, 150, 150) { source rectangle }
);
This is useful for implementing scroll, zoom, or "magnifying glass" effects.
30.6 Building Charts from Scratch
This is where everything comes together. We build three chart types — pie, bar, and line — using only the canvas drawing primitives we have learned. No charting libraries. No third-party components. Just math and pixels.
Pie Chart
A pie chart divides a circle into slices, where each slice's angle is proportional to its value relative to the total.
procedure DrawPieChart(Canvas: TCanvas; CenterX, CenterY, Radius: Integer;
const Values: array of Double; const Colors: array of TColor;
const Labels: array of string);
var
Total: Double;
I: Integer;
StartAngle, SweepAngle: Double;
X1, Y1, X2, Y2: Integer;
LabelAngle: Double;
LabelX, LabelY: Integer;
begin
{ Calculate total }
Total := 0;
for I := 0 to High(Values) do
Total := Total + Values[I];
if Total = 0 then Exit;
{ Bounding rectangle for the ellipse }
X1 := CenterX - Radius;
Y1 := CenterY - Radius;
X2 := CenterX + Radius;
Y2 := CenterY + Radius;
{ Draw each slice }
StartAngle := 0;
for I := 0 to High(Values) do
begin
SweepAngle := (Values[I] / Total) * 360;
Canvas.Brush.Color := Colors[I mod Length(Colors)];
Canvas.Pen.Color := clWhite;
Canvas.Pen.Width := 2;
{ Pie uses radial endpoint coordinates.
Convert angle to endpoint on the ellipse. }
Canvas.Pie(X1, Y1, X2, Y2,
CenterX + Round(Radius * Cos(DegToRad(StartAngle))),
CenterY - Round(Radius * Sin(DegToRad(StartAngle))),
CenterX + Round(Radius * Cos(DegToRad(StartAngle + SweepAngle))),
CenterY - Round(Radius * Sin(DegToRad(StartAngle + SweepAngle))));
{ Draw label at the midpoint of the slice }
if (Length(Labels) > I) and (SweepAngle > 15) then
begin
LabelAngle := DegToRad(StartAngle + SweepAngle / 2);
LabelX := CenterX + Round((Radius * 0.65) * Cos(LabelAngle));
LabelY := CenterY - Round((Radius * 0.65) * Sin(LabelAngle));
Canvas.Brush.Style := bsClear;
Canvas.Font.Color := clWhite;
Canvas.Font.Style := [fsBold];
Canvas.Font.Size := 9;
Canvas.TextOut(
LabelX - Canvas.TextWidth(Labels[I]) div 2,
LabelY - Canvas.TextHeight(Labels[I]) div 2,
Labels[I]);
Canvas.Brush.Style := bsSolid;
end;
StartAngle := StartAngle + SweepAngle;
end;
end;
Add Math to your uses clause for DegToRad, Cos, and Sin.
Adding a Legend to the Pie Chart
A pie chart without a legend is hard to read, especially when slices are small. Here is a legend-drawing procedure:
procedure DrawLegend(Canvas: TCanvas; X, Y: Integer;
const Labels: array of string; const Values: array of Double;
const Colors: array of TColor; Total: Double);
var
I, BoxSize, LineHeight: Integer;
Pct: Double;
LabelText: string;
begin
BoxSize := 12;
LineHeight := 20;
Canvas.Font.Size := 9;
Canvas.Font.Style := [];
Canvas.Font.Color := clBlack;
for I := 0 to High(Labels) do
begin
{ Color box }
Canvas.Brush.Color := Colors[I mod Length(Colors)];
Canvas.Pen.Color := clBlack;
Canvas.Pen.Width := 1;
Canvas.Rectangle(X, Y + I * LineHeight, X + BoxSize, Y + I * LineHeight + BoxSize);
{ Label with percentage }
Canvas.Brush.Style := bsClear;
if Total > 0 then
Pct := Values[I] / Total * 100
else
Pct := 0;
LabelText := Format('%s (%.1f%%)', [Labels[I], Pct]);
Canvas.TextOut(X + BoxSize + 6, Y + I * LineHeight, LabelText);
Canvas.Brush.Style := bsSolid;
end;
end;
Bar Chart
procedure DrawBarChart(Canvas: TCanvas; ARect: TRect;
const Values: array of Double; const Colors: array of TColor;
const Labels: array of string; const Title: string);
var
I, BarCount, BarWidth, Gap: Integer;
MaxVal, Scale: Double;
BarHeight, X, Y: Integer;
ChartLeft, ChartTop, ChartRight, ChartBottom: Integer;
begin
BarCount := Length(Values);
if BarCount = 0 then Exit;
{ Chart margins for labels and title }
ChartLeft := ARect.Left + 50; { space for Y-axis labels }
ChartTop := ARect.Top + 30; { space for title }
ChartRight := ARect.Right - 10;
ChartBottom := ARect.Bottom - 35; { space for X-axis labels }
{ Draw title }
Canvas.Font.Size := 11;
Canvas.Font.Style := [fsBold];
Canvas.Font.Color := clBlack;
Canvas.Brush.Style := bsClear;
Canvas.TextOut(
(ChartLeft + ChartRight - Canvas.TextWidth(Title)) div 2,
ARect.Top + 5, Title);
{ Find maximum value for scaling }
MaxVal := 0;
for I := 0 to High(Values) do
if Values[I] > MaxVal then
MaxVal := Values[I];
if MaxVal = 0 then Exit;
{ Round MaxVal up to a nice number for the Y axis }
Scale := (ChartBottom - ChartTop) / MaxVal;
{ Calculate bar dimensions }
Gap := 8;
BarWidth := (ChartRight - ChartLeft - Gap * (BarCount + 1)) div BarCount;
{ Draw background }
Canvas.Brush.Color := clWhite;
Canvas.FillRect(ARect);
{ Draw horizontal grid lines }
Canvas.Pen.Color := clSilver;
Canvas.Pen.Width := 1;
Canvas.Pen.Style := psDot;
Canvas.Font.Size := 8;
Canvas.Font.Style := [];
for I := 0 to 4 do
begin
Y := ChartBottom - Round(I * (ChartBottom - ChartTop) / 4);
Canvas.MoveTo(ChartLeft, Y);
Canvas.LineTo(ChartRight, Y);
{ Y-axis label }
Canvas.TextOut(ChartLeft - 45,
Y - Canvas.TextHeight('0') div 2,
Format('$%.0f', [I * MaxVal / 4]));
end;
Canvas.Pen.Style := psSolid;
{ Draw bars }
for I := 0 to High(Values) do
begin
BarHeight := Round(Values[I] * Scale);
X := ChartLeft + Gap + I * (BarWidth + Gap);
Y := ChartBottom - BarHeight;
Canvas.Brush.Color := Colors[I mod Length(Colors)];
Canvas.Pen.Color := clBlack;
Canvas.Pen.Width := 1;
Canvas.Rectangle(X, Y, X + BarWidth, ChartBottom);
{ Value label above bar }
Canvas.Brush.Style := bsClear;
Canvas.Font.Color := clBlack;
Canvas.Font.Size := 8;
Canvas.TextOut(
X + (BarWidth - Canvas.TextWidth(Format('$%.0f', [Values[I]]))) div 2,
Y - 16,
Format('$%.0f', [Values[I]]));
{ Category label below bar }
if I <= High(Labels) then
Canvas.TextOut(
X + (BarWidth - Canvas.TextWidth(Labels[I])) div 2,
ChartBottom + 5,
Labels[I]);
Canvas.Brush.Style := bsSolid;
end;
{ Draw axes }
Canvas.Pen.Color := clBlack;
Canvas.Pen.Width := 2;
Canvas.MoveTo(ChartLeft, ChartTop);
Canvas.LineTo(ChartLeft, ChartBottom);
Canvas.LineTo(ChartRight, ChartBottom);
end;
Line Chart
procedure DrawLineChart(Canvas: TCanvas; ARect: TRect;
const Values: array of Double; LineColor: TColor;
const XLabels: array of string; const Title: string);
var
I, Count: Integer;
MaxVal, Scale: Double;
StepX, X, Y: Integer;
ChartLeft, ChartTop, ChartRight, ChartBottom: Integer;
begin
Count := Length(Values);
if Count < 2 then Exit;
ChartLeft := ARect.Left + 50;
ChartTop := ARect.Top + 30;
ChartRight := ARect.Right - 10;
ChartBottom := ARect.Bottom - 35;
{ Draw title }
Canvas.Font.Size := 11;
Canvas.Font.Style := [fsBold];
Canvas.Font.Color := clBlack;
Canvas.Brush.Style := bsClear;
Canvas.TextOut(
(ChartLeft + ChartRight - Canvas.TextWidth(Title)) div 2,
ARect.Top + 5, Title);
MaxVal := 0;
for I := 0 to High(Values) do
if Values[I] > MaxVal then MaxVal := Values[I];
if MaxVal = 0 then MaxVal := 1;
Scale := (ChartBottom - ChartTop) / MaxVal;
StepX := (ChartRight - ChartLeft) div (Count - 1);
{ Draw grid lines }
Canvas.Pen.Color := clSilver;
Canvas.Pen.Width := 1;
Canvas.Pen.Style := psDot;
Canvas.Font.Size := 8;
Canvas.Font.Style := [];
for I := 0 to 4 do
begin
Y := ChartBottom - Round(I * (ChartBottom - ChartTop) / 4);
Canvas.MoveTo(ChartLeft, Y);
Canvas.LineTo(ChartRight, Y);
Canvas.TextOut(ChartLeft - 45,
Y - Canvas.TextHeight('0') div 2,
Format('$%.0f', [I * MaxVal / 4]));
end;
Canvas.Pen.Style := psSolid;
{ Draw axes }
Canvas.Pen.Color := clBlack;
Canvas.Pen.Width := 2;
Canvas.MoveTo(ChartLeft, ChartTop);
Canvas.LineTo(ChartLeft, ChartBottom);
Canvas.LineTo(ChartRight, ChartBottom);
{ Draw the line }
Canvas.Pen.Color := LineColor;
Canvas.Pen.Width := 3;
X := ChartLeft;
Y := ChartBottom - Round(Values[0] * Scale);
Canvas.MoveTo(X, Y);
for I := 1 to High(Values) do
begin
X := ChartLeft + I * StepX;
Y := ChartBottom - Round(Values[I] * Scale);
Canvas.LineTo(X, Y);
end;
{ Draw data points and X labels }
Canvas.Brush.Color := LineColor;
Canvas.Font.Size := 8;
for I := 0 to High(Values) do
begin
X := ChartLeft + I * StepX;
Y := ChartBottom - Round(Values[I] * Scale);
Canvas.Ellipse(X - 4, Y - 4, X + 4, Y + 4);
{ X-axis label }
if I <= High(XLabels) then
begin
Canvas.Brush.Style := bsClear;
Canvas.Font.Color := clBlack;
Canvas.TextOut(
X - Canvas.TextWidth(XLabels[I]) div 2,
ChartBottom + 5,
XLabels[I]);
Canvas.Brush.Style := bsSolid;
Canvas.Brush.Color := LineColor;
end;
end;
end;
📊 Design Principle: Charts as Communication A chart is not decoration — it is communication. Every pixel should serve the viewer's understanding. Use color to distinguish categories, not to be pretty. Label axes and data points. Show gridlines for reference. Omit chart junk (3D effects, gradients, unnecessary borders). Edward Tufte's principle applies: maximize the data-ink ratio.
Choosing the Right Chart Type
Different data relationships call for different chart types:
Pie chart — Shows parts of a whole. Use when you want to show how a total is divided into categories. Works best with 3-7 categories. More than 7 slices becomes hard to read; collapse small categories into "Other." PennyWise uses a pie chart for expense categories because Rosa wants to see what fraction of her budget goes to food vs. housing vs. entertainment.
Bar chart — Compares discrete values. Use when you want to compare magnitudes across categories or time periods. Bars are easier to compare than pie slices because humans judge length more accurately than angle. PennyWise uses a bar chart for monthly spending because Tomas wants to see the trend over time and compare months side by side.
Line chart — Shows trends over continuous time. Use when you want to show how a value changes over an ordered sequence (days, weeks, months). The connecting line implies continuity — the value between data points exists and can be interpolated. A line chart of daily expenses would show spending patterns over time.
Scatter plot — Shows relationships between two variables. Each point represents one data item plotted on two axes. Use when you want to explore correlations. Not applicable to PennyWise, but useful in scientific and analytical applications.
When building charts from scratch, start with the simplest chart that communicates the data effectively. A bar chart with clear labels is almost always better than a fancy 3D pie chart with shadow effects. The goal is insight, not impressiveness.
Why Build Charts from Scratch?
Lazarus has third-party charting components (TAChart is included in the default installation). So why did we build charts from scratch?
First, understanding canvas drawing is essential for any custom UI work. Charting is an excellent exercise because it combines coordinate math, color management, text rendering, and layout — all the skills you need for custom controls.
Second, hand-built charts are fully customizable. If Rosa wants the pie chart to highlight the slice the user hovers over, or Tomas wants the bar chart to animate when new data arrives, we can implement these features because we control every pixel.
Third, the principles transfer. The drawing primitives, coordinate transformations, and text measurement techniques we used for charts apply to any custom visualization: dashboards, gauges, floor plans, network diagrams, game boards.
For production applications with complex charting needs (multiple axes, zoom/pan, dozens of chart types), use TAChart or a similar library. For simple, customized visualizations like PennyWise's spending charts, building from scratch gives you exactly what you need with no dependencies.
30.7 Custom Controls
TPaintBox
TPaintBox is a lightweight control designed specifically for custom drawing. It has an OnPaint event where you draw your content:
procedure TForm1.PaintBox1Paint(Sender: TObject);
begin
DrawPieChart(PaintBox1.Canvas,
PaintBox1.Width div 2, PaintBox1.Height div 2,
Min(PaintBox1.Width, PaintBox1.Height) div 2 - 10,
FValues, FColors, FLabels);
end;
TPaintBox is a TGraphicControl — it draws on its parent's surface and has no window handle. This makes it lightweight but means it cannot receive keyboard focus. It can, however, respond to mouse events (OnMouseDown, OnMouseMove, OnMouseUp), which is useful for interactive charts.
Building a Simple Paint Program
To demonstrate mouse interaction with TPaintBox, here is a minimal paint program:
type
TfrmPaint = class(TForm)
PaintBox1: TPaintBox;
procedure PaintBox1MouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PaintBox1MouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer);
procedure PaintBox1MouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PaintBox1Paint(Sender: TObject);
procedure FormCreate(Sender: TObject);
private
FDrawing: Boolean;
FBitmap: TBitmap;
FLastX, FLastY: Integer;
end;
procedure TfrmPaint.FormCreate(Sender: TObject);
begin
FBitmap := TBitmap.Create;
FBitmap.Width := PaintBox1.Width;
FBitmap.Height := PaintBox1.Height;
FBitmap.Canvas.Brush.Color := clWhite;
FBitmap.Canvas.FillRect(0, 0, FBitmap.Width, FBitmap.Height);
FDrawing := False;
end;
procedure TfrmPaint.PaintBox1MouseDown(Sender: TObject;
Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
begin
if Button = mbLeft then
begin
FDrawing := True;
FLastX := X;
FLastY := Y;
end;
end;
procedure TfrmPaint.PaintBox1MouseMove(Sender: TObject;
Shift: TShiftState; X, Y: Integer);
begin
if FDrawing then
begin
FBitmap.Canvas.Pen.Color := clBlack;
FBitmap.Canvas.Pen.Width := 3;
FBitmap.Canvas.MoveTo(FLastX, FLastY);
FBitmap.Canvas.LineTo(X, Y);
FLastX := X;
FLastY := Y;
PaintBox1.Invalidate;
end;
end;
procedure TfrmPaint.PaintBox1MouseUp(Sender: TObject;
Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
begin
FDrawing := False;
end;
procedure TfrmPaint.PaintBox1Paint(Sender: TObject);
begin
PaintBox1.Canvas.Draw(0, 0, FBitmap);
end;
The key insight: we draw on a TBitmap (the off-screen buffer), not directly on the PaintBox canvas. The OnPaint handler simply copies the bitmap to the screen. This way, our drawing persists across repaints.
Creating Reusable Custom Components
For more sophisticated custom controls, create a new class that descends from TCustomControl or TGraphicControl and override the Paint method:
type
TProgressRing = class(TGraphicControl)
private
FPercent: Integer;
FRingColor: TColor;
FBackColor: TColor;
procedure SetPercent(Value: Integer);
protected
procedure Paint; override;
public
constructor Create(AOwner: TComponent); override;
published
property Percent: Integer read FPercent write SetPercent;
property RingColor: TColor read FRingColor write FRingColor;
property BackColor: TColor read FBackColor write FBackColor;
end;
constructor TProgressRing.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
Width := 100;
Height := 100;
FPercent := 0;
FRingColor := clBlue;
FBackColor := clSilver;
end;
procedure TProgressRing.SetPercent(Value: Integer);
begin
if Value < 0 then Value := 0;
if Value > 100 then Value := 100;
if FPercent <> Value then
begin
FPercent := Value;
Invalidate; { request repaint }
end;
end;
procedure TProgressRing.Paint;
var
CX, CY, R: Integer;
SweepAngle: Double;
begin
CX := Width div 2;
CY := Height div 2;
R := Min(Width, Height) div 2 - 4;
{ Background ring }
Canvas.Pen.Color := FBackColor;
Canvas.Pen.Width := 8;
Canvas.Brush.Style := bsClear;
Canvas.Ellipse(CX - R, CY - R, CX + R, CY + R);
{ Progress arc }
if FPercent > 0 then
begin
SweepAngle := FPercent * 3.6; { 360 degrees = 100% }
Canvas.Pen.Color := FRingColor;
Canvas.Arc(CX - R, CY - R, CX + R, CY + R,
CX, CY - R, { start at top (12 o'clock) }
CX + Round(R * Sin(DegToRad(SweepAngle))),
CY - Round(R * Cos(DegToRad(SweepAngle))));
end;
{ Center text }
Canvas.Font.Size := R div 3;
Canvas.Font.Color := FRingColor;
Canvas.Font.Style := [fsBold];
Canvas.Brush.Style := bsClear;
Canvas.TextOut(
CX - Canvas.TextWidth(IntToStr(FPercent) + '%') div 2,
CY - Canvas.TextHeight(IntToStr(FPercent) + '%') div 2,
IntToStr(FPercent) + '%');
end;
This TProgressRing component can be created at runtime and used like any other control:
var
Ring: TProgressRing;
begin
Ring := TProgressRing.Create(Self);
Ring.Parent := pnlCharts;
Ring.Left := 20;
Ring.Top := 20;
Ring.Percent := 75;
Ring.RingColor := clGreen;
end;
Choosing Between TGraphicControl and TCustomControl
When creating a reusable custom component, you have two base classes to choose from:
TGraphicControl (used by TProgressRing above) is lightweight. It does not have its own window handle — it draws on its parent's canvas. This makes it efficient (no OS resources consumed) but limits its capabilities: it cannot receive keyboard focus, it cannot contain child controls, and on some platforms it cannot be transparent over complex backgrounds.
TCustomControl has its own window handle. It can receive keyboard focus, host child controls, and interact with the OS windowing system directly. It is heavier but more capable.
Use TGraphicControl for simple visual elements: progress rings, custom labels, status indicators, decorative shapes. Use TCustomControl for interactive components that need keyboard input: custom text editors, game boards, interactive canvases, or any control that the user interacts with via keyboard.
Our TProgressRing is display-only (the user views it but does not interact with it via keyboard), so TGraphicControl is the right choice. If we were building a custom chart that the user could click on to select data points, TCustomControl would be more appropriate because we might want keyboard navigation between data points.
Invalidate vs. Repaint
You have two ways to request a repaint: Invalidate and Repaint. They are subtly different:
-
Invalidate marks the control as needing a repaint but does not paint immediately. The actual painting happens later, when the event loop processes the paint message. Multiple calls to
Invalidatebetween event handler returns are coalesced into a single repaint. This is efficient. -
Repaint paints immediately. It forces an immediate
OnPaintcall without waiting for the event loop. UseRepaintonly when you need the drawing to be visible right now — for example, when updating a progress indicator inside a long loop. In most cases,Invalidateis preferred because it avoids redundant repaints.
For animation, always use Invalidate (called from a timer handler). The event loop batches the invalidation and repaints efficiently. Using Repaint from a timer would work but offers no advantage and bypasses the OS's paint optimization.
30.8 Animation Basics
TTimer
TTimer fires its OnTimer event at regular intervals:
Timer1.Interval := 50; { 50ms = 20 frames per second }
Timer1.Enabled := True; { start the timer }
procedure TForm1.Timer1Timer(Sender: TObject);
begin
FX := FX + FDeltaX;
FY := FY + FDeltaY;
if (FX <= 0) or (FX >= PaintBox1.Width - FBallSize) then
FDeltaX := -FDeltaX;
if (FY <= 0) or (FY >= PaintBox1.Height - FBallSize) then
FDeltaY := -FDeltaY;
PaintBox1.Invalidate; { request repaint }
end;
Double Buffering
Without double buffering, animation flickers. The screen is cleared and redrawn frame by frame, and the user sees the blank intermediate state. Double buffering solves this by drawing to an off-screen bitmap first, then copying the completed frame to the screen in one operation:
procedure TForm1.PaintBox1Paint(Sender: TObject);
var
Buffer: TBitmap;
begin
Buffer := TBitmap.Create;
try
Buffer.Width := PaintBox1.Width;
Buffer.Height := PaintBox1.Height;
{ Draw everything on the buffer }
Buffer.Canvas.Brush.Color := clWhite;
Buffer.Canvas.FillRect(0, 0, Buffer.Width, Buffer.Height);
Buffer.Canvas.Brush.Color := clRed;
Buffer.Canvas.Ellipse(FX, FY, FX + FBallSize, FY + FBallSize);
{ Copy the buffer to the screen in one operation }
PaintBox1.Canvas.Draw(0, 0, Buffer);
finally
Buffer.Free;
end;
end;
For production code, allocate the buffer once (in FormCreate or as a form field) and reuse it, rather than creating a new bitmap every frame:
procedure TfrmMain.FormCreate(Sender: TObject);
begin
FBuffer := TBitmap.Create;
FBuffer.Width := PaintBox1.Width;
FBuffer.Height := PaintBox1.Height;
end;
procedure TfrmMain.FormDestroy(Sender: TObject);
begin
FBuffer.Free;
end;
procedure TfrmMain.PaintBox1Paint(Sender: TObject);
begin
{ Resize buffer if the paintbox was resized }
if (FBuffer.Width <> PaintBox1.Width) or
(FBuffer.Height <> PaintBox1.Height) then
begin
FBuffer.Width := PaintBox1.Width;
FBuffer.Height := PaintBox1.Height;
end;
{ Draw on the buffer }
FBuffer.Canvas.Brush.Color := clWhite;
FBuffer.Canvas.FillRect(0, 0, FBuffer.Width, FBuffer.Height);
FBuffer.Canvas.Brush.Color := clRed;
FBuffer.Canvas.Ellipse(FX, FY, FX + FBallSize, FY + FBallSize);
{ Copy to screen }
PaintBox1.Canvas.Draw(0, 0, FBuffer);
end;
Alternatively, set the form's DoubleBuffered property to True for a simpler approach — the LCL handles the buffering automatically. However, this may not work on all platforms, and manual buffering gives you more control.
⚠️ Caution: Timer Performance Do not set timer intervals below 15ms — the operating system's timer resolution may not support it, and you will not get faster updates. For smooth animation, 16ms (~60 fps) or 33ms (~30 fps) is typical. Creating a new TBitmap every frame is simple but allocates memory; for production animations, reuse the buffer by storing it as a field.
Practical Animation: Smooth Chart Transitions
In PennyWise, we can use animation to smoothly transition chart values when the data changes. Instead of the pie chart jumping instantly from one state to another, the slices can animate from the old values to the new values:
type
TChartAnimator = class
private
FTimer: TTimer;
FOldValues: array of Double;
FNewValues: array of Double;
FCurrentValues: array of Double;
FProgress: Double; { 0.0 to 1.0 }
FOnFrame: TNotifyEvent;
procedure TimerTick(Sender: TObject);
public
constructor Create;
destructor Destroy; override;
procedure AnimateTo(const NewValues: array of Double);
property CurrentValues: array of Double read FCurrentValues;
property OnFrame: TNotifyEvent read FOnFrame write FOnFrame;
end;
procedure TChartAnimator.TimerTick(Sender: TObject);
var
I: Integer;
begin
FProgress := FProgress + 0.05; { 20 frames to complete }
if FProgress >= 1.0 then
begin
FProgress := 1.0;
FTimer.Enabled := False;
end;
{ Interpolate between old and new values }
for I := 0 to High(FCurrentValues) do
FCurrentValues[I] := FOldValues[I] +
(FNewValues[I] - FOldValues[I]) * FProgress;
if Assigned(FOnFrame) then
FOnFrame(Self);
end;
The form's OnFrame handler calls PaintBox.Invalidate, which triggers a repaint with the interpolated values. Over 20 frames (at 50ms intervals, completing in 1 second), the chart smoothly transitions from the old data to the new data.
This technique — interpolating between old and new state over a series of timer ticks — is the foundation of all UI animation. The same principle applies to scrolling, fading, sliding panels, and progress indicators.
Canvas Drawing Performance Tips
Canvas drawing is fast in Lazarus because it compiles to native code. However, you can still encounter performance issues with complex drawings. Here are optimization strategies:
Minimize state changes. Each time you change Pen.Color, Brush.Color, or Font, the LCL must update the underlying graphics context. If you draw 100 lines in 10 different colors, group them by color: draw all red lines first, then all blue lines, rather than alternating colors.
Clip to the visible area. When only part of a control needs repainting (because only part was obscured), the OS provides a clip rectangle. Drawing outside this rectangle is wasted. For complex drawings, check whether each element intersects the clip rectangle before drawing it.
Cache expensive calculations. If your chart layout requires trigonometric calculations (pie charts) or text measurements (axis labels), compute them once when the data changes and store the results. Do not recompute them in every OnPaint call.
Use off-screen bitmaps for complex static content. If your background is complex (a grid, a gradient, a map) but does not change often, draw it once to a bitmap and Draw the bitmap in OnPaint. Only regenerate the bitmap when the data or size changes.
30.9 Project Checkpoint: PennyWise Charts
We add a chart panel to PennyWise that displays a pie chart of expenses by category and a bar chart of monthly totals.
The Chart Panel
type
{ TfrmMain — additions for charts }
TfrmMain = class(TForm)
{ ... existing controls ... }
pnlCharts: TPanel;
pbPieChart: TPaintBox;
pbBarChart: TPaintBox;
Splitter2: TSplitter;
procedure pbPieChartPaint(Sender: TObject);
procedure pbBarChartPaint(Sender: TObject);
private
FCategoryColors: array[0..9] of TColor;
procedure InitializeColors;
procedure CalculateCategoryTotals(out Categories: array of string;
out Totals: array of Double; out Count: Integer);
procedure CalculateMonthlyTotals(out Months: array of string;
out Totals: array of Double; out Count: Integer);
end;
procedure TfrmMain.InitializeColors;
begin
FCategoryColors[0] := RGBToColor(46, 134, 193); { blue }
FCategoryColors[1] := RGBToColor(231, 76, 60); { red }
FCategoryColors[2] := RGBToColor(46, 204, 113); { green }
FCategoryColors[3] := RGBToColor(241, 196, 15); { yellow }
FCategoryColors[4] := RGBToColor(155, 89, 182); { purple }
FCategoryColors[5] := RGBToColor(230, 126, 34); { orange }
FCategoryColors[6] := RGBToColor(52, 73, 94); { dark gray }
FCategoryColors[7] := RGBToColor(26, 188, 156); { teal }
FCategoryColors[8] := RGBToColor(192, 57, 43); { dark red }
FCategoryColors[9] := RGBToColor(127, 140, 141); { gray }
end;
procedure TfrmMain.pbPieChartPaint(Sender: TObject);
var
Categories: array[0..9] of string;
Totals: array[0..9] of Double;
Count: Integer;
begin
CalculateCategoryTotals(Categories, Totals, Count);
if Count = 0 then
begin
pbPieChart.Canvas.Brush.Style := bsClear;
pbPieChart.Canvas.Font.Size := 12;
pbPieChart.Canvas.Font.Color := clGray;
pbPieChart.Canvas.TextOut(
pbPieChart.Width div 2 - 60,
pbPieChart.Height div 2 - 10,
'No expenses yet');
Exit;
end;
DrawPieChart(pbPieChart.Canvas,
pbPieChart.Width div 3,
pbPieChart.Height div 2,
Min(pbPieChart.Width div 3, pbPieChart.Height div 2) - 20,
Slice(Totals, Count),
Slice(FCategoryColors, Count),
Slice(Categories, Count));
{ Draw legend to the right of the pie }
DrawLegend(pbPieChart.Canvas,
pbPieChart.Width div 3 * 2 + 10,
20,
Slice(Categories, Count),
Slice(Totals, Count),
Slice(FCategoryColors, Count),
GetTotalExpenses);
end;
procedure TfrmMain.pbBarChartPaint(Sender: TObject);
var
Months: array[0..11] of string;
Totals: array[0..11] of Double;
Count: Integer;
begin
CalculateMonthlyTotals(Months, Totals, Count);
if Count = 0 then
begin
pbBarChart.Canvas.Brush.Style := bsClear;
pbBarChart.Canvas.Font.Size := 12;
pbBarChart.Canvas.Font.Color := clGray;
pbBarChart.Canvas.TextOut(
pbBarChart.Width div 2 - 60,
pbBarChart.Height div 2 - 10,
'No data for chart');
Exit;
end;
DrawBarChart(pbBarChart.Canvas,
Rect(10, 10, pbBarChart.Width - 10, pbBarChart.Height - 10),
Slice(Totals, Count),
Slice(FCategoryColors, Count),
Slice(Months, Count),
'Monthly Spending');
end;
After adding or deleting expenses, call pbPieChart.Invalidate and pbBarChart.Invalidate to update the charts.
Layout
The chart panel sits below the expense grid, separated by a splitter:
{ In FormCreate: }
sgExpenses.Align := alClient;
Splitter2.Align := alBottom;
Splitter2.Height := 5;
pnlCharts.Align := alBottom;
pnlCharts.Height := 250;
pbPieChart.Align := alLeft;
pbPieChart.Width := pnlCharts.Width div 2;
pbBarChart.Align := alClient;
The user can drag the splitter to show more or less of the chart area. When the form is resized, the charts automatically adjust because the PaintBox controls use Align and their OnPaint handlers use the current dimensions.
Interactive Charts: Hover and Click
To make the pie chart interactive — highlighting a slice when the user hovers over it — handle the OnMouseMove event of the PaintBox:
procedure TfrmMain.pbPieChartMouseMove(Sender: TObject;
Shift: TShiftState; X, Y: Integer);
var
CX, CY, Radius: Integer;
DX, DY: Double;
Distance, Angle, TotalAngle, SliceAngle: Double;
I, NewHover: Integer;
Total: Double;
begin
CX := pbPieChart.Width div 3;
CY := pbPieChart.Height div 2;
Radius := Min(CX, CY) - 20;
DX := X - CX;
DY := CY - Y; { invert Y for standard math }
Distance := Sqrt(DX * DX + DY * DY);
NewHover := -1; { no slice hovered }
if Distance <= Radius then
begin
{ Calculate the angle of the mouse position }
Angle := ArcTan2(DY, DX) * 180 / Pi;
if Angle < 0 then Angle := Angle + 360;
{ Find which slice contains this angle }
Total := 0;
for I := 0 to FCategoryCount - 1 do
Total := Total + FCategoryTotals[I];
TotalAngle := 0;
for I := 0 to FCategoryCount - 1 do
begin
SliceAngle := (FCategoryTotals[I] / Total) * 360;
if (Angle >= TotalAngle) and (Angle < TotalAngle + SliceAngle) then
begin
NewHover := I;
Break;
end;
TotalAngle := TotalAngle + SliceAngle;
end;
end;
if NewHover <> FHoveredSlice then
begin
FHoveredSlice := NewHover;
pbPieChart.Invalidate; { repaint to highlight the hovered slice }
if NewHover >= 0 then
pbPieChart.Hint := Format('%s: $%.2f',
[FCategoryNames[NewHover], FCategoryTotals[NewHover]])
else
pbPieChart.Hint := '';
end;
end;
In the OnPaint handler, draw the hovered slice slightly larger (offset from center) or with a brighter color. This kind of interactive feedback makes charts feel professional and engaging.
Rosa notices that she can hover over pie slices to see exact amounts. Tomas immediately asks if we can add click-to-filter — clicking a category slice filters the expense grid to show only that category. We leave that as an exercise, but the pattern is clear: handle OnMouseDown, determine which slice was clicked, and update the grid filter.
✅ Checkpoint Checklist - [ ] Chart panel displays below the expense grid with a draggable splitter - [ ] Pie chart shows expenses by category with color-coded slices and labels - [ ] Pie chart has a legend with category names and percentages - [ ] Bar chart shows monthly expense totals with value labels, axes, and grid lines - [ ] Charts update when expenses are added or deleted - [ ] Empty state shows "No expenses yet" message - [ ] Colors are distinct and accessible
30.10 Summary
This chapter took you from pre-built controls to raw pixel drawing — the foundation of custom visualization and custom UI components.
What we covered:
- TCanvas is the drawing surface. Coordinates start at (0,0) in the top-left with Y increasing downward. Always draw inside
OnPaint, and understand the full painting lifecycle: invalidate, erase, paint, display. - Drawing primitives —
MoveTo/LineTofor lines,Polylinefor connected segments,Rectangle/RoundRect/FillRect/Framefor rectangles,Ellipsefor ellipses/circles,Polygonfor arbitrary shapes,Arc/Piefor arcs and wedges. We built a helper function for angle-based pie slices. - Colors, pens, and brushes —
TColorwithRGBToColorand system colors,TPenfor outlines (color, width, style including dashed and dotted),TBrushfor fills (color, style including hatched patterns). - Text on canvas —
TextOutfor drawing,TextRectwithTTextStylefor aligned/clipped text,TextWidth/TextHeightfor measurement,Font.Orientationfor rotated text. - Images —
TImagefor display,TBitmapfor programmatic creation,Draw/StretchDrawfor canvas rendering,CopyRectfor region copying, PNG support viaTPortableNetworkGraphic. - Charts from scratch — pie chart with labels and legend (angle calculation, Pie method), bar chart with axes, grid lines, and Y-axis labels (scaling, rectangles), line chart with data points and X-axis labels (MoveTo/LineTo with data points).
- Custom controls — TPaintBox for one-off drawing with mouse interaction (mini paint program),
TGraphicControl/TCustomControldescendants for reusable components with properties (TProgressRing example). - Animation — TTimer for periodic updates, double buffering to prevent flicker (both manual and automatic),
Invalidateto request repaints, buffer reuse for production code. - PennyWise now has visual spending analytics: a pie chart with legend by category and a bar chart with axes by month.
In Chapter 31, we connect PennyWise to a real database — SQLite — replacing our flat-file storage with SQL queries and data-aware controls.