Skip to content

Instantly share code, notes, and snippets.

@wassname
Last active July 5, 2026 06:10
Show Gist options
  • Select an option

  • Save wassname/b0b34492cd1679f1daeb5892ef714dce to your computer and use it in GitHub Desktop.

Select an option

Save wassname/b0b34492cd1679f1daeb5892ef714dce to your computer and use it in GitHub Desktop.
plotly - Minimal greedy non-overlapping label placement for plotly (textalloc/D3-Labeler in miniature, deterministic + pixel-offset arrows). No maintained plotly-native labeler exists as of 2025 (plotly.js #4674 open since 2020).
"""Minimal greedy non-overlapping label placement for plotly.
`place_labels` returns plotly annotation dicts that place text labels around
labelled scatter points without overlapping each other or nearby obstacle
markers. No maintained plotly-native label de-overlap exists as of 2025
(plotly.js issue #4674 open since 2020); this is textalloc / D3-Labeler in
miniature, with two deliberate differences:
1. Determinism. The canvas is fixed (fig_w/fig_h/margins) and labels are
placed in input order, so a given (points, obstacles) set always yields
the same layout. Iterative / annealing solvers (textalloc, adjustText)
don't give you that for free.
2. Pixel-offset arrows anchored at the chosen label midpoint. `ax/ay` are
pixel offsets from the data point to the label center, so the leader
line ties to the chosen placement, not the dot. plotly's native
`ax/ay` integer-arrowshift mode can't do this cleanly.
Prior art: GrossmanJoshua/plotly_annotations (2018, unmaintained, single
author) is the one prior Python plotly-native attempt; cite it if you build
on this.
Caveat: determinism depends on the fixed canvas. If you drop this into a
responsive figure whose pixel dimensions change, the pixel-offset arrows
break. Pin your figure size.
Public domain. attribution appreciated but not required.
"""
from __future__ import annotations
import math
from typing import Iterable
__all__ = ["place_labels"]
def place_labels(
points: list[dict],
x_range: tuple[float, float],
y_range: tuple[float, float],
*,
obstacles: Iterable[tuple[float, float]] = (),
fig_w: int = 1240,
fig_h: int = 640,
margin: dict | None = None,
char_w: float = 6.0,
line_h: float = 15.0,
radii: tuple = (40, 62, 88, 118),
angles: tuple = (90, 45, 135, 0, 180, -45, -135, -90),
font: dict | None = None,
bgcolor: str = "rgba(253,250,244,0.72)",
arrowcolor: str = "rgba(45,24,16,0.35)",
overlap_cost: float = 50.0,
overlap_cost_label: float = 1.0 / 50.0,
pad: float = 7.0,
edge_pad: float = 4.0,
) -> list[dict]:
"""Greedy non-overlapping label placement -> plotly annotation dicts.
Args:
points: list of dicts with keys ``x``, ``y``, ``text`` (plotly HTML,
use ``<br>`` for line breaks), and ``color`` (hex/rgba, used as
the label font color). Order is the placement order; first-served.
x_range, y_range: data extents matching the scatter's axis range, so
data->pixel mapping matches what plotly will render.
obstacles: (x, y) data coordinates of obstacle markers (e.g. unlabelled
dots) to push labels off of.
fig_w, fig_h, margin: the EXACT layout you pass to
``plotly.graph_objects.Figure.update_layout(width=, height=,
margin=)``. Pin them or the pixel offsets break.
char_w, line_h: approx per-char width and per-line height in pixels
for your font/size. Used to size label boxes for overlap tests.
radii, angles: candidate radii (px) and angles (deg) swept around each
anchor; first candidate at cost 0 wins.
font, bgcolor, arrowcolor: plotly annotation styling.
overlap_cost: penalty added when a candidate box sits on an obstacle.
overlap_cost_label: weight on the label-on-label intersection *area*.
pad: half-width of the "on top of an obstacle" band around each box.
edge_pad: distance from the canvas edge that starts to incur cost.
Returns:
list of plotly annotation dicts. Pass straight to
``fig.update_layout(annotations=...)``.
Algorithm: for each labelled point in order, try candidate box centers at
``radii x angles`` around the anchor; score each (off-canvas + on-obstacle
+ on-placed-label), keep the lowest, early-exit at cost 0. Each emitted
annotation uses ``axref="pixel", ayref="pixel"`` with ``ax/ay`` = the
pixel delta from data point to chosen label center.
"""
margin = margin or {"l": 90, "r": 90, "t": 70, "b": 70}
pw = fig_w - margin["l"] - margin["r"]
ph = fig_h - margin["t"] - margin["b"]
(x0, x1), (y0, y1) = x_range, y_range
def to_px(x: float, y: float) -> tuple[float, float]:
px = margin["l"] + (x - x0) / (x1 - x0) * pw
py = margin["t"] + (1 - (y - y0) / (y1 - y0)) * ph
return px, py
anchor_px = [to_px(p["x"], p["y"]) for p in points]
obstacle_px = [to_px(x, y) for x, y in obstacles]
placed: list[tuple] = [] # (cx, cy, bw, bh)
annotations: list[dict] = []
def cost(cx: float, cy: float, bw: float, bh: float) -> float:
left = cx - bw / 2
right = cx + bw / 2
top = cy - bh / 2
bottom = cy + bh / 2
c = 0.0
c += max(0.0, edge_pad - left) + max(0.0, right - (fig_w - edge_pad))
c += max(0.0, edge_pad - top) + max(0.0, bottom - (fig_h - edge_pad))
for mx, my in obstacle_px:
if left - pad <= mx <= right + pad and top - pad <= my <= bottom + pad:
c += overlap_cost
for ox, oy, ow, oh in placed:
ix = max(0.0, min(right, ox + ow / 2) - max(left, ox - ow / 2))
iy = max(0.0, min(bottom, oy + oh / 2) - max(top, oy - oh / 2))
c += ix * iy * overlap_cost_label
return c
font = font or dict(size=11, family="Georgia, serif")
for i, p in enumerate(points):
lines = p["text"].split("<br>")
bw = max(len(s) for s in lines) * char_w + 10
bh = len(lines) * line_h + 6
px, py = anchor_px[i]
best = None
for r in radii:
for a in angles:
cx = px + r * math.cos(math.radians(a))
cy = py - r * math.sin(math.radians(a))
c = cost(cx, cy, bw, bh)
if best is None or c < best[0]:
best = (c, cx, cy)
if c == 0:
break
if best[0] == 0:
break
_, cx, cy = best
placed.append((cx, cy, bw, bh))
annotations.append(dict(
x=p["x"], y=p["y"], text=p["text"], showarrow=True,
ax=cx - px, ay=cy - py, axref="pixel", ayref="pixel",
font={**font, "color": p["color"]},
align="center", bgcolor=bgcolor,
arrowhead=0, arrowwidth=1, arrowcolor=arrowcolor,
))
return annotations
# ---------------------------------------------------------------------------
# Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import plotly.graph_objects as go
# 12 labelled points clustered so labels must spread to avoid overlap.
pts = [
{"x": 0.10 + 0.03 * (i % 3), "y": 0.10 + 0.03 * (i // 3),
"text": f"point {i}<br>cluster", "color": "#2d1810"}
for i in range(12)
]
# A few unlabelled dots to push labels off of.
obstacles = [(0.12, 0.12), (0.16, 0.14), (0.14, 0.18)]
fig = go.Figure(go.Scatter(
x=[p["x"] for p in pts] + [o[0] for o in obstacles],
y=[p["y"] for p in pts] + [o[1] for o in obstacles],
mode="markers",
marker=dict(size=9, color=["#a33"] * len(pts) + ["#bbb"] * len(obstacles)),
showlegend=False,
))
fig.update_layout(
width=1240, height=640,
margin=dict(l=90, r=90, t=70, b=70),
xaxis=dict(range=[0, 0.3]),
yaxis=dict(range=[0, 0.3]),
annotations=place_labels(
pts, x_range=(0, 0.3), y_range=(0, 0.3), obstacles=obstacles,
),
)
fig.show()
@wassname

wassname commented Jul 5, 2026

Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment