On this page
Label placement and direct labelling¶
A dense figure has many labels: named points in a scatter, the top hits of a volcano plot, one name per curve, and callouts on a schematic. inklet places them with one deterministic search, so you should not need to move labels by hand. This page covers what the search avoids, the calls that use it, and what happens when there is not enough room.
What a label avoids¶
Every placer scores each candidate position against everything the panel has drawn, including marks drawn after the call:
- Other labels. Labels never overlap, and each one keeps the theme's
xsgap from the others. This is the same clearance that the lint ruleCROWDINGmeasures. - Labelled points. A label never covers a point that someone else's label names.
- Marks. Markers, bars, error bars, rules and text are covered as little as possible. A stroked line through a label counts as a collision. Confidence bands and shaded areas are measured by their outline, not by their bounding box.
- Leaders. A leader never crosses another leader or passes through another label. It avoids other labelled points, and it runs through as few marks as it can.
The search is joint. It makes a greedy first pass, lets each label move to its
best position given all the others, and then runs an annealing pass with a
seed hashed from the labels' own text and coordinates. A final pass moves
colliding pairs together. The search reads no clock and does not depend on
hash(), so the same figure gives the same SVG bytes in every process.
Named points¶
p.scatter(cloud, color=TH.muted)
p.scatter(hits, color=TH.color(0))
p.label_points(hits, names)
Each label tries eight compass positions next to its point first. If those are
taken, it moves out along sixteen rays, as far as two and a half times reach,
and a hairline leader connects it back to its point. Positions past reach
cost more for every extra millimetre, so a long leader is used only when no
closer position is free. volcano(labels=...) and the embedding scatters use
the same search.
Words at a data point¶
p.text(x, y, content) writes words at one data point, and it does not search
for a free place: the label is put exactly where you say. anchor names the
point of the label that lands on the datum, one of c, n, ne, e, se,
s, sw, w or nw. anchor='w' puts the middle of the west edge on the
datum, so the words run east from it; anchor='nw' puts the top-left corner
there, so they hang below and to the right. offset=(dx, dy) then nudges the
label in millimetres. dx moves it right and dy moves it down, because y
grows downward on the page, so offset=(0, -1) lifts it 1 mm.
p.text(2020, 41.5, 'onset', anchor='w', offset=(1, 0))
p.text(2024, 12.0, 'peak', anchor='nw', offset=(0, 1))
A two-line label centres its lines on one another. Pass align='start' for a
flush left edge; the label markup guide
covers the rest of the text options.
Curve names instead of a legend¶
for g in groups:
p.line(curves[g], name=g)
p.label_lines() # names in a column past the curve ends
p.label_lines(where="inside") # names beside the last stretch of each curve
label_lines() names each line, step or ecdf that was drawn with
name=. Pass name=[...] to label only some of them. Each name is set in
its series' colour, darkened only as much as it needs to be readable on the
paper, so a yellow curve gets an ochre name that passes LOW_CONTRAST.
Pass color=False to set the names in ink.
where="end"puts the names in a column just right of the curve ends, each at the height where its curve ends. When names would collide, they are pushed apart by the smallest total movement that keeps them in order. A name that moves off its curve's end gets a thin leader in the curve's colour.where="inside"puts each name just above or below its own curve, and prefers positions near the end of the curve. The names are chosen together, so that no name sits on another name, is crossed by a curve, or is closer to another curve than to its own.
Keys in the emptiest spot¶
p.legend(corner="best")
corner="best" scores positions across the whole plot area against every
drawn mark and picks the emptiest one. Among equally empty positions, the one
nearest a corner wins. If every position would cover data, the key goes
outside, beside the plot on the right, as it would with side="right". The
older options still work as before: corner="ne" and the other fixed
corners, and corner="auto", which uses the first clear position and raises
an error if there is none.
Callouts on diagrams¶
art = rig
for part, text in LABELS:
art = inklet.annotate(rig.find(part), text, within=art)
art = inklet.place_labels(art, method="joint")
The default method="greedy" is unchanged and still places one label at a
time. method="joint" tries more distances (JOINT_RADII) and decides every
callout together with the same search as above. Use it when callouts end up
on neighbouring parts, or when their leaders tangle.
When a label does not fit¶
The placers never drop or shrink a label. If no clean position exists, the label is still drawn at the least bad position and named in its call's note:
| Call | Note | Key |
|---|---|---|
label_points |
point_labels |
unresolved |
label_lines |
line_labels |
unresolved |
place_labels |
place_labels |
unresolved |
inklet.lint reports each non-empty list as a LABEL_UNPLACED warning, with
the labels named. To fix it, give the labels more room: make the panel larger,
use smaller type or fewer labels, or shorten the labels. point_labels also
lists labels that sit on a background mark under covering_marks. These are
not errors, but they are useful to know about when you are checking a dense
cloud.
Measuring it¶
tools/benchmark_labels.py builds a fixed set of crowded cases: clustered
scatters with 60 and 200 labels, a volcano plot, ten curves, labels over bars
and areas, and two sets of callouts. For each case it reports label overlaps,
labels on marks, leader crossings, unresolved labels, how far each label sits
from its point, lint findings and runtime:
.venv/bin/python tools/benchmark_labels.py --png out/labels