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 hasmoveTo,lineTo,filland 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().
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.
Try it yourself
- 1
Change the curve
In the first example, add
.curve(d3.curveStep)afterd3.line()and use only the first 20 days (.slice(0, 20)after thed3.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 daysd3.curveMonotoneXlooks almost the same as the straight line, so a short range shows the difference.) - 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
Take away defined
In the second example, delete the
.defined(...)line and pressWith a gap. The recorder lines in the Console do not change (they pass their owndefined), but the picture does: there is no gap. A missing day'sy(null)isundefined, a canvas ignores alineTowhose 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
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
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%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
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 0Break 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.
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 canctx.stroke(path)it and test it withctx.isPointInStroke(path, x, y)(Topic 12.3). Checked: withlineWidth = 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
The problem this solves: you already know scales,
d3.lineandd3.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
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 usesscaleTime,scaleLinearandscaleSqrtunchanged. - 3
What changes: generators take a context.
d3.line(),d3.area(),d3.arc()andd3.geoPath()have.context(ctx)(geoPathtakes it as its second argument). With a context, calling the generator makes the drawing calls (moveTo,lineTo,arc) on the canvas path and returnsundefined; you thenstroke()orfill(). Without one it returns text: the example logsundefined, and a string 5,419 characters long for the same line. For a line, a context is only an object withmoveTo,lineToandclosePath, so a counting stand-in can test it without a canvas. - 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 adraw()that clears everything, starts a new path and draws it all again.update(newData)means: store, thendraw().
Explain it without notes
What does d3.line().context(ctx) return when you call it, and why?
Why can you not use d3.axisBottom on a canvas, and what do you use instead?
Practice
Draw a pale teal area under the line of the first example with d3.area() and a context. Which two things must you add?
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 returnsundefined.I can say which D3 modules work on canvas and which do not.
I keep my own state and redraw from it, starting with
clearRectandbeginPath.I handled no data, one point and missing values in a canvas chart.