Last active
July 5, 2026 06:10
-
-
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """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() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://github.com/ckjellson/textalloc
https://github.com/Phlya/adjustText