Measuring Text Width to Avoid Label Collisions
Your category or time axis renders every tick label, and at narrow widths they overlap into an unreadable grey smear.
The root cause is placing labels by position alone without ever measuring how wide each one actually is. A label centred at pixel 120 might be 30 pixels wide or 90, and only its measured extent tells you whether it collides with its neighbour. This is part of Text & Label Rendering in Charts, and the fix is the same on both engines: measure each label’s rendered width, compute its screen extent, then greedily drop labels that would overlap a kept one.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: draws every label, positioned by centre, never measured.
function drawTicks(ctx: CanvasRenderingContext2D, ticks: Array<{ x: number; label: string }>) {
ctx.font = "12px Inter";
ctx.textAlign = "center";
for (const t of ticks) {
ctx.fillText(t.label, t.x, 20); // adjacent labels silently overlap at any density
}
}
// ✅ FIXED: measure each label, then greedily keep only non-overlapping ones.
interface Tick { x: number; label: string; }
function drawTicksThinned(
ctx: CanvasRenderingContext2D,
ticks: Tick[],
minGap = 6,
): Tick[] {
ctx.font = "12px Inter"; // set BEFORE measuring or widths are wrong
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
// PERF: cache widths by string so recurring labels are measured once.
const cache = new Map<string, number>();
const widthOf = (s: string): number => {
let w = cache.get(s);
if (w === undefined) { w = ctx.measureText(s).width; cache.set(s, w); }
return w;
};
const kept: Tick[] = [];
let lastRight = Number.NEGATIVE_INFINITY;
for (const t of ticks) {
const half = widthOf(t.label) / 2;
const left = t.x - half;
if (left >= lastRight + minGap) { // compare EDGES, not centres
ctx.fillText(t.label, t.x, 20);
kept.push(t);
lastRight = t.x + half;
}
}
// A11Y: expose the full, unthinned tick set via an offscreen data table.
return kept;
}
The critical shift is from centre-based to edge-based reasoning. Two labels do not collide because their centres are close; they collide because the right edge of one crosses the left edge of the next. The moment you have measured widths, left = x - width/2 and right = x + width/2 give you exact extents, and overlap is a single comparison. Keeping a running lastRight cursor makes the whole pass O(n) with no nested loop.
Step-by-step fix
// SVG variant: measure live nodes in a read phase, then hide overlaps.
function thinSvgLabels(labels: SVGTextElement[], minGap = 6): void {
// READ phase: measure all first (each call forces layout — do not interleave writes).
const extents = labels.map((node) => {
const cx = node.getBBox().x + node.getBBox().width / 2;
const w = node.getComputedTextLength();
return { node, left: cx - w / 2, right: cx + w / 2 };
});
// WRITE phase: greedily reveal non-overlapping labels.
let lastRight = Number.NEGATIVE_INFINITY;
for (const e of extents) {
const visible = e.left >= lastRight + minGap;
e.node.style.display = visible ? "" : "none";
if (visible) lastRight = e.right;
}
}
Verification
// Assert no two kept labels overlap after thinning.
function assertNoOverlap(kept: Array<{ x: number; label: string }>, ctx: CanvasRenderingContext2D, minGap = 6): void {
ctx.font = "12px Inter";
let lastRight = Number.NEGATIVE_INFINITY;
for (const t of kept) {
const half = ctx.measureText(t.label).width / 2;
console.assert(t.x - half >= lastRight + minGap, `overlap at "${t.label}"`);
lastRight = t.x + half;
}
}
In DevTools, narrow the chart container to its smallest supported width and confirm the axis drops labels rather than overlapping them; the survivors should stay evenly readable and never touch. Widen it again and dropped labels should reappear as space allows.
Why greedy thinning is the right default
There are more sophisticated label-placement algorithms — simulated annealing, force-directed nudging, or dynamic-programming selection that maximizes the number of labels shown. They matter for scatter-plot annotation where labels can move in two dimensions, but for a one-dimensional axis a greedy left-to-right sweep is almost always the correct tool. It is O(n), it is deterministic (so labels do not flicker between frames as an optimizer finds different local minima), and it produces a stable, predictable result: the leftmost label always survives, and each subsequent label survives if and only if it clears the last one kept. That determinism is worth more on an axis than a marginally denser packing, because a chart whose labels reshuffle every time the data updates reads as broken even when each individual frame is optimal.
The one tuning decision is priority. A naive left-to-right sweep keeps whichever labels happen to come first, which is fine for evenly spaced ticks but wrong when some labels matter more — the min and max of a series, round numbers, or the current value. The fix is to run the greedy pass over a priority-ordered list: always keep the anchor labels first, mark their extents as occupied, then sweep the rest and drop any that collide with an already-placed label. This keeps the meaningful labels and thins the filler, which is what a human would do by hand.
Edge cases & gotchas
- Rotated labels. Once you rotate labels 45° or 90°, their horizontal footprint is no longer the text width — it is the projection of the rotated bounding box. Measure the string, then multiply by
cos(angle)(and account for height) to get the real horizontal extent before thinning. - Proportional vs monospace fonts. Estimating width as
characters × fixed-widthis only valid in a monospace font. In a proportional font “WWW” and “iii” differ by more than 3×, so an estimate collides where a measurement would not. Always measure. - Web font swap. If you measure before a web font loads, widths reflect the fallback face and shift when the real font swaps in. Re-run measurement inside the font’s
document.fonts.readypromise or on theloadingdoneevent. - DPR and measurement.
measureTextreturns CSS-pixel widths regardless ofdevicePixelRatio, so your thinning arithmetic stays in logical pixels — do not multiply widths by DPR, or you will drop far too many labels. - Cache invalidation. A width cache keyed only by string is wrong if the font size changes on resize. Key the cache by
font + text, or clear it wheneverctx.fontchanges. - First and last label clipping. The leftmost and rightmost survivors can still overflow the axis ends. Clamp their anchor or switch their text alignment to
start/endat the extremes so they stay inside the plot.
Frequently Asked Questions
How do I measure text width on a Canvas before drawing it?
Set ctx.font to the exact font you will draw with, then call ctx.measureText(text). The returned object’s width property is the advance width in CSS pixels, and actualBoundingBoxAscent/actualBoundingBoxDescent give you the vertical extent. Because the result depends on the current font, always set ctx.font before measuring or you will get metrics for the wrong typeface.
What is the fastest way to prevent axis labels from overlapping?
A greedy left-to-right sweep. Measure each label’s width, then keep a label only when its left edge clears the previous kept label’s right edge plus a small minimum gap, tracking a running lastRight cursor. It runs in O(n) with no nested loop, is deterministic so labels do not flicker between frames, and matches how a person would thin them by hand.
Can I estimate label width by character count instead of measuring?
Only in a monospace font. In a proportional font, character widths vary by more than three to one between glyphs like “W” and “i”, so a character-count estimate collides where a real measurement would fit, or wastes space where it would not. Measure with measureText or getComputedTextLength and cache the result by font and string so it costs nothing after the first frame.
Related
- Text & Label Rendering in Charts — the parent guide to chart typography.
- Wrapping and truncating SVG axis labels — when thinning is not enough and labels must shrink or wrap.
- Preventing axis label overlap on dense time series — the D3 axis-generator angle on the same problem.
- DOM Impact & Reflow Optimization — why measuring in a batched read phase avoids thrash.