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

Greedy label thinning by measured extent Top row shows overlapping labels drawn without measurement. Bottom row shows the same axis after greedy thinning keeps only labels whose measured extents do not touch. Before: every label drawn overlapping extents → unreadable After: greedy thinning kept labels clear the minimum gap
Measuring each label's extent turns overlap from a guess into arithmetic: keep a label only when its left edge clears the previous survivor's right edge plus a minimum gap.
Axis labels: colliding vs thinned One dense axis drawn twice — every tick labelled, then thinned and rotated. Same axis, two label strategiesJan 01Jan 08Jan 15Jan 22Jan 29Feb 05Feb 12Feb 19Feb 26Mar 05Mar 12Mar 19Every tick drawn → glyphs collideJan 01Jan 15Jan 29Feb 12Feb 26Mar 12Keep every Nth, rotate 35° → readable
Why text width to avoid label collisions matters: label every tick and glyphs collide; keep every Nth and rotate, and the axis stays legible.

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-width is 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.ready promise or on the loadingdone event.
  • DPR and measurement. measureText returns CSS-pixel widths regardless of devicePixelRatio, 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 whenever ctx.font changes.
  • 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/end at the extremes so they stay inside the plot.
SVG vs Canvas render-cost crossover Frame cost against element count for a DOM-node SVG chart and a single-element Canvas. element count (×1k)frame costSVG (DOM nodes)Canvas (1 element)
The render-cost crossover underlying text width to avoid label collisions: SVG cost climbs with node count while Canvas stays flat, which is why heavy scenes switch engines.

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.