Topic 11.2
Paths, Styles and Text
In one line
Canvas draws shapes by building a path and then painting it with fill or stroke. Forgetting beginPath is the classic bug, and text needs measuring because a canvas never wraps or shrinks it for you.
Think of it like this
A pen plotter. You lift the pen and move to a spot (moveTo), lower it and trace lines (lineTo), and only when the route is planned do you say "now ink it" (stroke) or "colour the inside" (fill). The route is separate from the ink.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- path
- A list of points and curves the context remembers until you paint it with
fill()orstroke(). - beginPath
- Starts a new, empty path. Forget it and the old path is painted again.
- radian
- The angle unit canvas uses: a full turn is
2π, about 6.283. - stroke
- Draws the outline of the path with
lineWidthandstrokeStyle. - fill
- Colours the inside of the path with
fillStyle. It closes an open path for you.
Step by step
01Two lines, one forgotten beginPath
The first example draws a Monday line in orange and a Tuesday line in teal, twice. In the left canvas the second line skips beginPath(). Stroking again paints the Monday line again too, now teal: both lines are teal.
The log reads one pixel from each line. Left: Monday's pixel is rgb(42,157,143), the teal. Right: it stays orange rgb(231,111,81).
02Read the gallery
The second example paints an open and a closed triangle, three arcs and the three caps and joins. It then samples pixels to prove what the picture shows: the open triangle's left side is missing (alpha 0), the closed one has it, the clockwise half-circle is the bottom half and the anticlockwise one the top half.
For the caps, a 16-pixel line from x = 30 to x = 60 is tested 4 pixels before its start (the middle of the end) and at a corner 7 pixels before it and 7 above: butt is empty at both, round covers the middle but not the corner, square covers both.
03Strokes and fills are separate
A path is only a plan until stroke() or fill(). fill() closes the open triangle by itself, so the filled one has all three sides. stroke() paints only the sides you built. The log reads the pixel on the closing side to prove it.
04Measure before you draw text
The third example shortens station names to fit 70 pixels, using measureText in a loop. It prints decisions (the short name stays, long names end with an ellipsis, the empty string stays empty) and not widths, because widths depend on the font on your computer.
The picture shows textAlign with a red guide line at x = 180 and textBaseline with a green guide at y = 120. Long text drawn with a width argument is squeezed, not cut.
Try it yourself
- 1
Fix the left canvas
In the first example, change the last-but-one line
run("wrong", false);torun("wrong", true);. The second line now starts a fresh path, and the log prints the same two colours, orange then teal, for both canvases. - 2
Close the open triangle
In the second example, add
ctx.closePath();before the firstctx.stroke();. The open triangle gets its left side back, and the pixel at (25, 38) turns42,157,143,255.
Code & diagrams
A failure that prints no error: the picture is wrong, and the only clue is the colour.
Preview
Console
Nothing logged yet.Expected console output
wrong: Monday's line is now rgb(42,157,143), Tuesday's is rgb(42,157,143)
right: Monday's line is now rgb(231,111,81), Tuesday's is rgb(42,157,143)Every claim is a pixel read from the picture, not a guess.
Preview
Console
Nothing logged yet.Expected console output
open triangle, middle of the missing left side: 0,0,0,0
closed triangle, middle of the closing left side: 42,157,143,255
clockwise half (bottom) at (270, 65): 42,157,143,255 (top) at (270, 15): 0,0,0,0
anticlockwise half (top) at (340, 15): 42,157,143,255
4 px before the start of a 16 px line (corner: 7 px before, 7 px above) -> butt: middle empty, corner empty | round: middle covered, corner empty | square: middle covered, corner coveredThe log prints decisions, not widths: widths depend on the fonts of the computer.
Preview
Console
Nothing logged yet.Expected console output
'WWWW' is wider than 'iiii': true | empty string width: 0
short name unchanged: true | long names end with an ellipsis: true | empty stays empty: true
every result fits: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Draw many circles without moveTo
In the second example, replace the whole step 3 (from // 3. arc( down to the last console.log of that step) with two circles in one path, stroked: ctx.beginPath(); ctx.arc(190, 30, 10, 0, Math.PI * 2); ctx.arc(250, 60, 10, 0, Math.PI * 2); ctx.stroke();, then read the pixel at (230, 45).
Myth vs fact
Myth
fill() and stroke() erase the path after painting.
Fact
The path stays until the next beginPath(). That is why a forgotten beginPath() repaints old shapes.
Myth
Canvas text wraps if I give it a width.
Fact
The third argument squeezes the text horizontally. There is no wrapping: you measure and split lines yourself.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Path2Dobjects hold a path outside the context:const p = new Path2D("M10 10 H 90")accepts the samedtext as an SVG path, thenctx.stroke(p)paints it. You can reuse one path for many marks, andctx.isPointInPath(p, x, y)hit-tests it (Topic 12.3). - ▸
Bad numbers are mostly ignored, with no error. In a run,
lineTo(NaN, 50)added nothing (the line went straight from the point before it to the point after it),lineTo(Infinity, 20)was skipped the same way, andfillRect(NaN, 0, 20, 20)painted nothing. So a chart whose data has aNaNor anundefinedturns into a gap or a blank, not a console message: check your values before you draw. A few calls do throw:arc(50, 50, -5, 0, Math.PI * 2)raisedIndexSizeError: Failed to execute 'arc' on 'CanvasRenderingContext2D': The radius provided (-5) is negative. - ▸
A web font that has not loaded yet is not used by canvas text. Draw after
await document.fonts.load("14px MyFont")or the first paint uses a fallback and stays that way until you redraw.
Remember this
- 1
The problem this solves:
fillRectonly makes rectangles. Lines, dots, curves and polygons are paths: a list of points the context remembers until you paint it. Every line chart on a canvas is one path (SVG'sdstring from Topic 1.3, built by calls instead of text). - 2
Build the path, then paint it.
beginPath()starts a new empty path.moveTo(x, y)jumps without drawing.lineTo(x, y)adds a straight side.arc(x, y, radius, start, end, anticlockwise)adds a circle part. Nothing is visible untilstroke()draws the outline orfill()colours the inside.fill()closes an open path for you;stroke()does not, so an open triangle has only two sides unless you callclosePath(). - 3
**Forget
beginPath()and the old path comes back.** The context keeps one current path. If you do not start a new one, your nextstroke()paints the new line and every earlier line again, in the new colour. The first example draws two lines: the left canvas forgotbeginPath()and both lines are teal. - 4
Angles are radians, 0 is at 3 o'clock, and clockwise is the default. A half turn is
Math.PI, a full circleMath.PI * 2.arc(x, y, r, 0, Math.PI)is the bottom half; addingtrue(anticlockwise) gives the top half.arcjoins to the previous point with a line unless youmoveTothe circle's start first: this matters when you draw many dots in one path (Topic 11.6). - 5
Fill and stroke are separate settings.
fillStyleandstrokeStyletake any CSS colour (includingrgb(...),hsl(...)and gradients).lineWidthis centred on the path: a 6-pixel line covers 3 pixels on each side.lineCap(butt,round,square) shapes the ends andlineJoin(miter,round,bevel) shapes the corners. The second example shows all three caps and joins, and which pixels each covers. - 6
Text is painted pixels too.
fillText(text, x, y)paints one line; it never wraps and it overflows silently.ctx.fonttakes the CSSfontshorthand.textAligndecides whetherxis the left, centre or right of the text, andtextBaselinedecides whetheryis the top, middle or bottom.measureText(text).widthreturns the width in the current font, so you can shorten a label with an ellipsis until it fits (third example). Font widths differ from computer to computer, so measure at run time and never hard-code a width.
Explain it without notes
Why does a second line change colour if beginPath() is missing?
What is the difference between how fill() and stroke() treat an open path?
How do you shorten a canvas label to fit a width?
Practice
Draw a polyline for the numbers [3, 5, 2, 6] on a 200 × 100 canvas, one x step of 50 pixels, with a round line join and a 3-pixel width.
Write dot(ctx, x, y, r) that paints one filled circle and cannot leak into other drawings.
Trade-offs
- ↔
One path with many shapes and a single
fill()is much faster than one path per shape, but all the shapes then share one colour.
Done when you can
I can build and paint a path with
beginPath,moveTo,lineTo,arc,fillandstroke.I know why a missing
beginPath()repaints old shapes.I can name what
lineCap,lineJoin,textAlignandtextBaselinechange.I measure text with
measureTextand shorten labels instead of letting them overflow.