Command Palette

Search for a command to run...

Hectal
PHASE 12Advanced ~22 min· topic 2 of 7

Topic 12.2

D3 Drawing to Canvas

In one line

D3's scales, shapes and geo paths work without SVG. Give a shape generator a canvas context and it draws instead of returning a string. What you lose is the data join: on canvas you keep your own state and redraw it all.

Think of it like this

A sign painter with a stencil. The stencil (the shape generator) is the same whether she paints a wall (SVG) or a banner (canvas). Only where the paint goes changes. But on a banner she cannot scratch one letter off: to change a word she repaints the whole banner from her notes. The notes are your state.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

context
The object you draw with: canvas.getContext("2d"). It has moveTo, lineTo, fill and more.
generator
A D3 function that turns data into a shape: d3.line(), d3.arc().
state
The data and settings a chart keeps in variables, because the canvas itself remembers nothing.
redraw
Clearing the canvas and drawing everything again from the state.

Step by step

01Keep the scales, add the context

The first example draws Lumen's 365 days with scaleTime and scaleLinear exactly as in Phase 6. The new object is the context: d3.line()....context(ctx), then ctx.beginPath(), line(rows) and ctx.stroke().

the line on canvaswhole filejs
const line = d3.line().x((d) => x(d.date)).y((d) => y(d.rides)).context(ctx);
ctx.beginPath();
line(rows);
ctx.stroke();

02Axis, arc and map

The axis ticks come from y.ticks(5), drawn with moveTo, lineTo and fillText. d3.arc().context(ctx) draws the doughnut (33.3 per cent of the year's rides are e-bike) and d3.geoPath(projection, ctx) the small outline: Phase 13 uses the same call for the real map.

03Update means redraw

The second example keeps a state object and a draw(). Its buttons call update with a full year, a year with August missing, one day and no data. defined breaks the line over August, a single day gets a dot, and no data draws a sentence in pixels.

04Test the pen, not the pixels

The recorder object counts moveTo and lineTo calls: a full year is moveTo 1, lineTo 364, the gap case moveTo 2, lineTo 332, one day lineTo 0, no data nothing.

Test the pen, not the pixelsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Change the curve

    In the first example, add .curve(d3.curveStep) after d3.line() and use only the first 20 days (.slice(0, 20) after the d3.csv(...) call). The line becomes a staircase: curves work on canvas too. (The month labels and the doughnut change because they now describe 20 days, not a year. With all 365 noisy days d3.curveMonotoneX looks almost the same as the straight line, so a short range shows the difference.)

  2. 2

    Fill under the line

    Add the area from Practice 1 before the line. A pale teal region fills from the line down to the axis. Same scales, a second generator, one more fill().

  3. 3

    Take away defined

    In the second example, delete the .defined(...) line and press With a gap. The recorder lines in the Console do not change (they pass their own defined), but the picture does: there is no gap. A missing day's y(null) is undefined, a canvas ignores a lineTo whose position is not a number, and so the line runs straight from the end of July to the start of September. A canvas does not complain: the chart is wrong and silent.

Code & diagrams

Line, axis, arc and map on one canvas New tab

Every scale is the same code you wrote for SVG. The doughnut shows the e-bike share of the year's rides, and the small square is a made-up outline, only to show geoPath with a context.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

line(rows) with a context returns: undefined
the same generator without a context returns a string of length: 5419
days drawn: 365 - e-bike share of rides: 33.3%
A canvas chart with its own state, and its edge cases New tab

Press the buttons. A generator's context is only an object with moveTo, lineTo and closePath: the recorder counts the pen's moves, so each case is checked without looking at pixels.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

full  moveTo 1 lineTo 364
gap   moveTo 2 lineTo 332
one   moveTo 1 lineTo 0
none  moveTo 0 lineTo 0

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget beginPath in draw

In the second example, delete the line ctx.beginPath(); // 3. a NEW path every redraw, then press Full year and With a gap.

terminal
$ In the picture
── what you'll see ──
No error. After `With a gap`, the August gap is filled again: the whole year's line is still there.

Myth vs fact

Myth

D3 only works with SVG.

Fact

D3 is separate modules. Scales, shapes, projections, hierarchies and layouts work with any output. Only the modules that create or change page elements (d3-selection, d3-axis, d3-transition) need them: they have no canvas version, so you write the axis and the animation loop yourself.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    new Path2D(d3.line()(data)) makes a reusable shape from the string form. You can ctx.stroke(path) it and test it with ctx.isPointInStroke(path, x, y) (Topic 12.3). Checked: with lineWidth = 6, a point on the line and one 2 pixels off both hit, and one 20 pixels off does not, so the stroke's width decides how forgiving the test is.

Remember this

  1. 1

    The problem this solves: you already know scales, d3.line and d3.arc. When marks move to canvas you do not need a second library. Most of what you wrote for SVG carries over, and only the part that put elements in the page changes.

  2. 2

    What stays the same. Scales, data helpers (d3.rollup, d3.extent), tick values (scale.ticks()), formats and projections: plain functions from values to numbers and strings. The first example uses scaleTime, scaleLinear and scaleSqrt unchanged.

  3. 3

    What changes: generators take a context. d3.line(), d3.area(), d3.arc() and d3.geoPath() have .context(ctx) (geoPath takes it as its second argument). With a context, calling the generator makes the drawing calls (moveTo, lineTo, arc) on the canvas path and returns undefined; you then stroke() or fill(). Without one it returns text: the example logs undefined, and a string 5,419 characters long for the same line. For a line, a context is only an object with moveTo, lineTo and closePath, so a counting stand-in can test it without a canvas.

  4. 4

    No join: you keep the state. SVG remembers its marks, so selection.data(...).join(...) updates only what changed. A canvas forgets every mark once drawn. So a canvas chart holds its data (and hover or selection) in a variable and has a draw() that clears everything, starts a new path and draws it all again. update(newData) means: store, then draw().

Explain it without notes

01

What does d3.line().context(ctx) return when you call it, and why?

02

Why can you not use d3.axisBottom on a canvas, and what do you use instead?

Practice

01

Draw a pale teal area under the line of the first example with d3.area() and a context. Which two things must you add?

02

Write drawTicks(ctx, scale, width) that draws a pale horizontal line at every tick of a linear scale and returns how many ticks it drew.

Trade-offs

  • ↔

    Reusing D3 shapes keeps a canvas chart close to its SVG twin, and you now write the axis, the redraw and the state yourself. A full redraw is simple and always right, and wasteful when only a hover ring changed: Topic 12.7 splits the picture into layers.

Done when you can

  • I can draw a D3 line, arc and geo path with .context(ctx), and I know it returns undefined.

  • I can say which D3 modules work on canvas and which do not.

  • I keep my own state and redraw from it, starting with clearRect and beginPath.

  • I handled no data, one point and missing values in a canvas chart.