First published on Homingo Learn.
A label that fits today can land on its neighbor tomorrow when someone renames a metric or adds a series, and you may not notice until the chart is on screen. You can flag likely collisions without rendering by estimating a box for each label and checking the boxes against each other.
The technique
Each label's box comes from arithmetic on its text:
- Width is
len(text) * size * 0.6, which assumes an average character is 0.6 em wide. - Height is
size * 1.2, which assumes a line height of 1.2 em.
Both constants are assumptions, and the program never compares them with rendered text.
To find collisions, sort the labels by left edge and walk through them, keeping an active list of boxes that may still overlap something. Each new label does three things:
- It removes every box from
activethat ends at or before its own left edge. - It tests its y range against each remaining box.
- It joins
active.
from dataclasses import dataclass from itertools import combinations CHAR_WIDTH_EM = 0.6 LINE_HEIGHT_EM = 1.2 @dataclass(frozen=True) class Label: text: str x0: float y0: float size: float = 10 @property def x1(self): return self.x0 + len(self.text) * self.size * CHAR_WIDTH_EM @property def y1(self): return self.y0 + self.size * LINE_HEIGHT_EM def overlap_size(a, b): width = min(a.x1, b.x1) - max(a.x0, b.x0) height = min(a.y1, b.y1) - max(a.y0, b.y0) return width, height def sweep_collisions(labels): active, hits, y_tests = [], [], 0 for label in sorted(labels, key=lambda item: item.x0): # Strict comparison: a box ending exactly at this x0 only touches it. active = [other for other in active if other.x1 > label.x0] for other in active: y_tests += 1 if other.y0 < label.y1 and label.y0 < other.y1: hits.append((other, label)) active.append(label) return hits, y_tests def brute_force_collisions(labels): pairs = set() for a, b in combinations(labels, 2): width, height = overlap_size(a, b) if width > 0 and height > 0: pairs.add(frozenset((a.text, b.text))) return pairs LABELS = [ Label("Revenue", 10, 20), Label("Refunds", 48, 26), Label("Net margin", 90, 26), Label("Churn", 140, 30), Label("Active users", 20, 60), Label("Signups", 60, 72), Label("Q3 forecast", 130, 36, size=8), ] hits, y_tests = sweep_collisions(LABELS) found = {frozenset((a.text, b.text)) for a, b in hits} assert found == brute_force_collisions(LABELS), "sweep disagrees with brute force" print("Estimated boxes:") for label in LABELS: print( f" {label.text:<13} x {label.x0:6.1f} to {label.x1:6.1f}" f" y {label.y0:5.1f} to {label.y1:5.1f}" ) print("\nColliding pairs:") for a, b in hits: width, height = overlap_size(a, b) print(f" {a.text} / {b.text}: {width:.1f} x {height:.1f} = {width * height:.1f} sq units") total_pairs = len(LABELS) * (len(LABELS) - 1) // 2 print(f"\nSweep ran {y_tests} y tests; checking all pairs would take {total_pairs}.") print(f"Layout check: {'FAIL' if hits else 'PASS'} ({len(hits)} colliding pairs)") Why the sweep works
Removing a box is safe because of the sort order. Every later label starts at or to the right of the current one, so a box that ends at or before the current left edge can't overlap any of them.
Every box left in active starts at or before the current left edge and ends after it. For a label with nonzero width, that guarantees overlap in x, so only y needs testing.
Comparisons are strict, so boxes that merely touch don't count as colliding.
What the run shows
Estimated boxes: Revenue x 10.0 to 52.0 y 20.0 to 32.0 Refunds x 48.0 to 90.0 y 26.0 to 38.0 Net margin x 90.0 to 150.0 y 26.0 to 38.0 Churn x 140.0 to 170.0 y 30.0 to 42.0 Active users x 20.0 to 92.0 y 60.0 to 72.0 Signups x 60.0 to 102.0 y 72.0 to 84.0 Q3 forecast x 130.0 to 182.8 y 36.0 to 45.6 Colliding pairs: Revenue / Refunds: 4.0 x 6.0 = 24.0 sq units Net margin / Q3 forecast: 20.0 x 2.0 = 40.0 sq units Net margin / Churn: 10.0 x 8.0 = 80.0 sq units Q3 forecast / Churn: 30.0 x 6.0 = 180.0 sq units Sweep ran 10 y tests; checking all pairs would take 21. Layout check: FAIL (4 colliding pairs) The check reports four colliding pairs and prints FAIL. Net margin, Q3 forecast, and Churn all collide with one another, while Revenue and Refunds collide separately, so moving one label won't clear the report.
The strict comparisons leave out two pairs of boxes that touch:
- Refunds ends at x 90, exactly where Net margin begins.
- Active users ends at y 72, exactly where Signups begins.
The sweep ran 10 y tests where checking all pairs would take 21, but that saving depends on this layout. The count also leaves out the sort and the x comparisons that filter active.
If every pair of boxes overlaps in x, nothing is ever removed from active, and the sweep tests all pairs. A single left-aligned column of non-empty labels does this. The sweep saves y tests only when some boxes don't overlap in x.
What was checked
The program asserts that the sweep finds the same set of pairs as a brute-force pass over all combinations. This check can fail: change other.x1 > label.x0 to >=, and Refunds stays active when Net margin arrives. The sweep then reports a pair that brute force rejects, and the assertion fires.
The assertion has limits:
- It covers one input. It only compares results for these seven labels, so it doesn't prove the sweep correct in general.
- It compares pairs by text. Duplicate strings collapse together, so it can miss differences between labels that share the same text.
- It doesn't test the estimates. Both methods use the same boxes, so they can agree and still be wrong about the rendered result.
Where it bites
Small overlaps may not be real. Revenue and Refunds overlap by 4.0 units in x, while one estimated character at size 10 is 6 units wide. Real glyphs could shift that result either way, and the same goes for the exact touch at x 90. To flag near misses as well, inflate each box by a margin before testing. The program doesn't do this, so you'd need to choose and test a margin yourself.
Text length is a crude measure of width. At the same size, a string of i and a string of W get the same estimated width if they have the same length. len() also counts code points, so emoji, combining marks, and wide scripts can be badly misjudged.
Coordinates must mean what the code assumes. x0 is the left edge and y0 is the edge from which height is added. Convert centered or right-aligned labels before building a Label.
Empty labels break the x guarantee. A zero-width box inside another box's x range and overlapping its y range is reported by the sweep but rejected by brute force. Filter out empty strings first.
Printing FAIL won't stop a build. The script exits normally when it finds collisions. To use it as a gate, import sys and call sys.exit(1) when hits is not empty.
Where this stops being enough
This check is an early warning based on estimated boxes, not a verdict on the rendered result. It stops being enough when the exact font matters, or when labels are rotated, wrap across lines, or get moved at render time. It also ignores collisions with anything that isn't a label, such as lines and markers. In those cases, measure the rendered text and check the measured boxes, using the same sweep if the boxes are axis-aligned.
Disclosure: I'm the founder of Homingo; Homingo sells this skill, and this article was written with AI assistance, with the code run before publishing. This is one small idea behind what we package in Isometric Explainers, our Claude Code skill: Claude Code draws how-it-works machines for your docs, blogs and landing pages, with live readouts and built-in layout, motion and geometry checks, and one command turns the same figure into a 1080x1920 explainer reel. Skill Rs 1,499 (about US$17), Complete Kit Rs 2,499 (about US$28; adds 3 more machine reel projects, the finished demo reel, 4 video lessons and a written tutorial), one-time, prices in INR, international cards accepted; it needs Claude access with Claude Code and Node.js 20.10+, Claude plans not included: Isometric Explainers. Everything above works without it.



