Topic 17.2
Annotation and Narrative Structure
In one line
Annotations are the narrator of a chart: a few words, placed beside the mark they talk about, that tell the eye where to look. The order of the steps (setting up, building, the reveal) decides whether the reader follows, and honest framing decides whether they should.
Think of it like this
A guide in a museum, standing beside one painting, pointing at one corner of it and saying "look at the hand". The guide does not read the whole painting out. A few words and one gesture move your eyes to the one thing that matters. An annotation is that gesture, written on the chart.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- annotation
- Text or a mark added to a chart to point at one thing and say what it is.
- callout
- An annotation joined to its data point by a short line or a dot, so there is no doubt what it refers to.
- bounding box
- The smallest rectangle around an element.
getBBox()returns it for SVG, in the SVG's own units. - halo
- A thin outline in the background colour around text, so it stays readable on top of a line.
- reveal
- The step where the story shows the surprise: the point where the expected pattern breaks.
- so-what
- The step after the reveal that says why it matters or what changed.
- baseline
- The value the axis starts at. Bars need 0; a line may start elsewhere if the axis says so.
Step by step
01Pick the days worth a label
The first example labels five days on the rides line. The numbers come from the rows: the first day (1,193 rides), the busiest day (18 July, 4,380), a day just after it, the outage (14 August, 439) and the last day (31 December, 910).
Five labels is already many. A real story would carry two or three; this example has five so that the awkward cases (an edge, a collision, a long label) all happen.
02Measure the text before placing it
The code adds the text first, asks getBBox() how wide it is, and only then decides where it goes. Placing blind is why labels run off the edge.
The log prints, for each label, the side it chose, how many lines it needed, how far it moved down, how many pixels it would have overflowed if it always went right (85 for 31 December), and whether it ended up inside the chart.
const side = px + 8 + size.width <= W - 4 ? 1 : -1; // 1 = right of the point, -1 = left03Choose the order
The narrative has four moves. Setting up: the whole year. Building: the seasons. The reveal: the outage. The so-what: e-bikes. The same findings in another order tell another story: starting with the outage would make the whole year look like a crisis.
04Check the claim against the data
Before the annotation goes live, put the numbers beside it. The second example shows the outage day was 12 % of a normal August day, that it also rained, and that rainy August days alone averaged 15 % lower. The honest line states the two facts and does not give a total for the cost.
05Show the axis you chose
A line may start above zero, because its job is shape and change, not length. When it does, the axis must show it, and a caption can say so. The e-bike line is drawn twice in the second example: the same data, a very different slope. Pick the one that tells the truth at the size of the change: a 9.4-point rise is real, and a label with the numbers (28 % to 38 %) keeps it honest either way.
Try it yourself
- 1
Squeeze the labels
In the first example, change
wrap(text, 24)towrap(text, 12). The labels now need more lines:1 Jantakes 2, the busiest day 3, the outage 4, and "Summer peak week" moves down 28 px instead of 14. Every label still ends inside the SVG, because the placement is computed from the real text size, and the 31 December label now flips left with 59 px of overflow avoided. Look at the picture, though: the four-line outage label reaches down over the date axis. Theinsidecheck only tests the SVG's edges, not the axis, so a check is only as good as the rule it tests. - 2
Take the axis back to zero
In the second example, change
mini("#cut", [25, 40], ...)tomini("#cut", [0, 50], ...). The last log line ends19% with a 25 baseline(the label is now wrong, the number is right): both charts give 19 % of the height. The picture of the same rise, honestly drawn from zero, is gentle: that is the real size of 9.4 points on a 0 to 50 scale.
Code & diagrams
Each line is one decision. The last label would have run 85 px past the right edge if it always went to the right of its point. Labels on a noisy line still touch the line: the halo keeps them readable.
Preview
Console
Nothing logged yet.Expected console output
1 Jan: 1,193 rides | side: right | lines: 1 | moved down: 0 px | naive overflow: 0 px | inside: true
18 Jul: the busies | side: right | lines: 2 | moved down: 0 px | naive overflow: 0 px | inside: true
Summer peak week | side: right | lines: 1 | moved down: 14 px | naive overflow: 0 px | inside: true
14 Aug: 439 rides. | side: right | lines: 2 | moved down: 0 px | naive overflow: 0 px | inside: true
31 Dec: 910 rides | side: left | lines: 1 | moved down: 0 px | naive overflow: 85 px | inside: trueA chart that looks fine and claims too much: "cost us 3,173 rides" is arithmetic against a normal day, not a measured effect of the outage. The two e-bike charts show the same data; only the second is steep, and its axis says where it starts.
Preview
Console
Nothing logged yet.Expected console output
14 Aug: 439 rides, 12% of a normal August day (3612)
That day also had 8.6 mm of rain. Other rainy August days averaged 3067 rides (-15%)
Too strong : "The app outage cost us 3173 rides."
Honest : "14 Aug: 439 rides. The app was down, and it rained (8.6 mm)."
E-bike share: 28.3% in January, 37.7% in December
The rise fills 19% of the chart's height with a 0 baseline, 63% with a 25 baselineBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Always put the label on the right
In the first example, replace the side line with const side = 1;.
Myth vs fact
Myth
More annotations make a chart clearer.
Fact
Every label competes for the eye. Two or three, placed where the story turns, beat ten that make the reader hunt. The first example uses five only to show the awkward cases.
Myth
If a number is in the annotation, the annotation is honest.
Fact
"The outage cost us 3,173 rides" uses true numbers and claims a cause the data cannot isolate, because it also rained. Honest framing is about what the sentence claims, not only its digits.
Myth
A line chart must start at zero like a bar chart.
Fact
Bars encode length, so they need zero. A line encodes shape and change and may start elsewhere, if the axis shows it and the labels give the real numbers.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
getBBox()measures in the SVG's user units and ignores transforms and the halo's stroke.getBoundingClientRect()gives screen pixels including transforms. The example uses the first to plan and the second to check, which is why the check is a fair test and not the same number twice. Neither is reliable for an element withdisplay: none(zeros in some browsers, an error in others), so measure only what is on screen. - ▸
A label's width depends on the font, which may load after your code runs. Measure after
document.fonts.readyfor a web font, or the box will be the fallback font's size. On a phone the SVG shrinks (Topic 17.5), so measure in user units and let the viewBox scale them. - ▸
Collision avoidance here is greedy: each label moves down until it fits. It works for a handful and can push a label far from its point. For many labels, use a force simulation or a library such as d3-annotation, and keep a leader line from label to point so the link is never in doubt.
Remember this
- 1
The problem this solves: an unannotated chart makes the reader do the guide's job. Lumen's year of rides has a summer hill, a deep spike in August and a rise in e-bikes, and a visitor sees a wiggly line. A label that says "14 Aug: 439 rides. The app was down" turns the spike from a puzzle into a fact.
- 2
An annotation is data, a position and words. It needs a mark (a dot or a line that touches the data), a short label placed beside it, and a rule for where the label goes. The first example computes the position from the row of data, so the label cannot drift away from its point. It uses
getBBox()(the size of the text in the SVG's own units) to learn how wide the label is before placing it. - 3
Placement is where annotations fail. A label placed to the right of a point near the right edge runs outside the chart and is cut off, with no error. A label near the top is clipped. Two labels near each other print on top of each other. The example flips a label to the left of its point near the right edge, keeps labels inside the plot, wraps long text onto lines, and moves a label down until its box no longer overlaps another's. It prints each decision.
- 4
Make the label readable over the data. A label on top of a noisy line is hard to read. A white outline around the text (
strokewhite,paint-order: stroke, so the outline is drawn under the letters) keeps it legible on any background. Put labels in empty space where you can, and keep each to a short phrase with the number in it. - 5
Order is the narrative. A story has the shape every story has. Setting up shows the whole year and the plain pattern the reader already half-expects (summer is busier). Building adds a fact at a time, each one resting on the last. The reveal is the surprise: the one day the pattern breaks. After it, a story needs a so-what: why it matters, or what changed. Lina's order is the year, the seasons, the August outage, then e-bikes rising, which is the thing that will last.
- 6
Frame honestly. The annotation is where a chart most easily claims too much. The 14 August spike also had 8.6 mm of rain, and the other five rainy August days averaged 15 % fewer rides (a small sample), so rain explains some of the fall but not an 88 % drop. A line that says "the outage cost us 3,173 rides" is stronger than the data allows: one day cannot separate two causes. The second example writes both versions. It also shows that a line chart may leave zero (the e-bike rise fills 19 % of the chart's height with a 0 baseline and 63 % with a 25 baseline) as long as the axis says so.
Explain it without notes
Why compute an annotation's position from the data row instead of hard-coding pixels?
What goes wrong when a label is placed next to a point near the right edge, and how do you prevent it?
Name the four moves of the story structure used for Lina's page, and say what each does.
Why is "the outage cost us 3,173 rides" a risky sentence, and what do you write instead?
Practice
Write fits(textBox, chartWidth, chartHeight) that returns true only when the box is entirely inside the chart. Test it with boxes that overflow right, overflow left, overflow the top, and fit exactly.
Draft the annotation for the busiest day (18 July, 4,380 rides). It must state the number and not claim a cause. Say why you left out "because of the sunshine".
Trade-offs
- ↔
Annotations in the SVG scale with the chart and stay attached, but long text is awkward in SVG. HTML captions wrap and translate better, and can drift from the point. Keep short labels in the SVG and longer explanation in the step's text.
- ↔
Greedy collision avoidance is simple and predictable. It can push labels far from their points, which a force-based layout does less, at the cost of a library and labels that move on every redraw.
Done when you can
I can place a label from a data row and measure it with getBBox().
I can stop a label running off the edge, and check that it did not.
I can name the four moves of a story and put findings in order.
I can rewrite an overclaiming annotation so it states only what the data shows.
I can say when a line chart may leave zero, and what the axis must do then.