Skip to content

Commit c7f0d0e

Browse files
arapovclaude
andcommitted
fix: scatter callouts mark true positions and trigger only on real text overlap
Two corrections to the colliding-label callouts: - Dots now sit on each person's true (X, Y). They were drawn at the cluster centroid x and fanned vertically by rank, so the leader lines pointed to fabricated spots instead of where people actually are. - A label is decluttered only when another opaque card would cover its text, measured from the real rendered text extents rather than the fixed 8-wide padded box. Short labels carry a lot of empty card, so the old box-overlap rule pulled them aside needlessly; now cards may overlap as long as every label stays legible. Regenerate the example scatter; add tests pinning the text-aware behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b8b6808 commit c7f0d0e

3 files changed

Lines changed: 112 additions & 40 deletions

File tree

‎charts/scatter_all.png‎

Lines changed: 2 additions & 2 deletions
Loading

‎dope_plot/plots/scatter.py‎

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
_CLUSTER_PAD = 2 * _BOX_PAD + _BOX_GAP # reserve each box's padding on both sides + the gap
2020
_CALLOUT_OFFSET = 6.0 # how far a colliding group's stack is moved off its point
2121
_CALLOUT_BOX_GAP = _BOX_HEIGHT + _CLUSTER_PAD # center-to-center spacing of stacked callout boxes
22-
_CALLOUT_DOT_GAP = 0.8 # vertical spacing of the per-person dots at the true point
2322
_GRADIENT_BAND = 0.12 # half-width of the soft transition between the two fill colours
2423

2524
_BIRD_NAME = {"D": "Dove", "E": "Eagle", "O": "Owl", "P": "Peacock"}
@@ -122,13 +121,50 @@ def _fill_gradient_box(ax, bx, by, w, h, c1, c2, ratio):
122121
img.set_clip_path(clip)
123122

124123

125-
def _declutter(centers: np.ndarray, sizes: np.ndarray, max_value: float, iters: int = 400) -> np.ndarray:
126-
"""Push overlapping label boxes apart, deterministically.
124+
def _pair_thresholds(i: int, j: int, sizes: np.ndarray, text_sizes) -> tuple:
125+
"""Minimum centre separation on each axis before labels i, j 'conflict'.
127126
128-
Each pair that overlaps (axis-aligned, with _CLUSTER_PAD breathing room) is
129-
separated along its axis of least penetration. Boxes are wide and short, so
130-
this naturally stacks coincident people vertically. Identical positions use
131-
an index-based tie-break so the result is reproducible (no randomness).
127+
Default (``text_sizes`` is None): the full boxes mustn't overlap, keeping
128+
_CLUSTER_PAD of breathing room so cards never even touch. When ``text_sizes``
129+
is given, only forbid an opaque box from reaching the *other box's text* — so
130+
cards may overlap freely as long as every label stays fully legible.
131+
"""
132+
if text_sizes is None:
133+
tx = (sizes[i, 0] + sizes[j, 0]) / 2 + _CLUSTER_PAD
134+
ty = (sizes[i, 1] + sizes[j, 1]) / 2 + _CLUSTER_PAD
135+
else:
136+
tx = max(sizes[i, 0] + text_sizes[j, 0], sizes[j, 0] + text_sizes[i, 0]) / 2 + _BOX_GAP
137+
ty = max(sizes[i, 1] + text_sizes[j, 1], sizes[j, 1] + text_sizes[i, 1]) / 2 + _BOX_GAP
138+
return tx, ty
139+
140+
141+
def _text_data_extent(ax: Axes, s: str, fontsize: int, fallback: tuple) -> tuple:
142+
"""Measure the rendered (width, height) of ``s`` in data units at ``fontsize``.
143+
144+
Falls back to a rough estimate if no renderer is available yet.
145+
"""
146+
try:
147+
renderer = ax.figure.canvas.get_renderer()
148+
t = ax.text(0, 0, s, fontsize=fontsize, ha="center", va="center")
149+
ext = t.get_window_extent(renderer)
150+
t.remove()
151+
inv = ax.transData.inverted()
152+
x0, y0 = inv.transform((ext.x0, ext.y0))
153+
x1, y1 = inv.transform((ext.x1, ext.y1))
154+
return abs(x1 - x0), abs(y1 - y0)
155+
except Exception:
156+
return fallback
157+
158+
159+
def _declutter(
160+
centers: np.ndarray, sizes: np.ndarray, max_value: float, iters: int = 400, text_sizes: np.ndarray | None = None
161+
) -> np.ndarray:
162+
"""Push conflicting label boxes apart, deterministically.
163+
164+
Each conflicting pair (see :func:`_pair_thresholds`) is separated along its
165+
axis of least penetration. Boxes are wide and short, so this naturally stacks
166+
coincident people vertically. Identical positions use an index-based tie-break
167+
so the result is reproducible (no randomness).
132168
"""
133169
pos = centers.astype(float).copy()
134170
n = len(pos)
@@ -137,8 +173,9 @@ def _declutter(centers: np.ndarray, sizes: np.ndarray, max_value: float, iters:
137173
for i in range(n):
138174
for j in range(i + 1, n):
139175
dx, dy = pos[i, 0] - pos[j, 0], pos[i, 1] - pos[j, 1]
140-
overlap_x = (sizes[i, 0] + sizes[j, 0]) / 2 + _CLUSTER_PAD - abs(dx)
141-
overlap_y = (sizes[i, 1] + sizes[j, 1]) / 2 + _CLUSTER_PAD - abs(dy)
176+
tx, ty = _pair_thresholds(i, j, sizes, text_sizes)
177+
overlap_x = tx - abs(dx)
178+
overlap_y = ty - abs(dy)
142179
if overlap_x > 0 and overlap_y > 0:
143180
if overlap_y <= overlap_x:
144181
shift = overlap_y / 2
@@ -159,10 +196,10 @@ def _declutter(centers: np.ndarray, sizes: np.ndarray, max_value: float, iters:
159196
return pos
160197

161198

162-
def _clusters(anchors: np.ndarray, sizes: np.ndarray) -> list:
163-
"""Group people whose boxes overlap at their true points (connected components).
199+
def _clusters(anchors: np.ndarray, sizes: np.ndarray, text_sizes: np.ndarray | None = None) -> list:
200+
"""Group people whose labels conflict at their true points (connected components).
164201
165-
Returns a list of index lists; a list of length 1 is an isolated person.
202+
Conflict uses :func:`_pair_thresholds`; a list of length 1 is an isolated person.
166203
"""
167204
n = len(anchors)
168205
parent = list(range(n))
@@ -177,9 +214,8 @@ def find(a):
177214
for j in range(i + 1, n):
178215
dx = abs(anchors[i, 0] - anchors[j, 0])
179216
dy = abs(anchors[i, 1] - anchors[j, 1])
180-
near_x = dx < (sizes[i, 0] + sizes[j, 0]) / 2 + _CLUSTER_PAD
181-
near_y = dy < (sizes[i, 1] + sizes[j, 1]) / 2 + _CLUSTER_PAD
182-
if near_x and near_y:
217+
tx, ty = _pair_thresholds(i, j, sizes, text_sizes)
218+
if dx < tx and dy < ty:
183219
parent[find(i)] = find(j)
184220

185221
groups = {}
@@ -189,13 +225,14 @@ def find(a):
189225

190226

191227
def add_name_boxes(ax: Axes, df: pd.DataFrame, max_value: float) -> None:
192-
"""Draw name boxes, with callouts for people who collide.
193-
194-
Each box starts on its person's true (X, Y). Where boxes overlap, the whole
195-
group is lifted aside (horizontally, toward the birds' side) and each person
196-
gets their own dot fanned at the true location, joined to their box by a thin
197-
dashed leader line — so even identical profiles stay individually readable.
198-
People who don't collide are left exactly on their point.
228+
"""Draw name boxes, with callouts only where labels would obscure each other.
229+
230+
Each box starts on its person's true (X, Y). A label is moved only when another
231+
opaque card would actually cover its text; cards are free to overlap as long as
232+
every label stays legible. Where text genuinely collides, the whole group is
233+
lifted aside (horizontally, toward the birds' side) and each person gets their
234+
own dot at their true location, joined to their box by a thin dashed leader line
235+
— so even identical profiles stay individually readable.
199236
"""
200237
texts = [_box_text(row) for _, row in df.iterrows()]
201238
if not texts:
@@ -204,15 +241,20 @@ def add_name_boxes(ax: Axes, df: pd.DataFrame, max_value: float) -> None:
204241
bird_vecs = [_bird_vector(row) for _, row in df.iterrows()]
205242

206243
sizes = np.array([[max(8, len(t) * 0.4), _BOX_HEIGHT] for t in texts], dtype=float)
244+
# Real rendered text extents (data units): a label is only "in the way" when
245+
# another opaque card would cover this text — not when the padded boxes graze.
246+
text_sizes = np.array(
247+
[_text_data_extent(ax, t, 10, (len(t) * 0.4, _BOX_HEIGHT * 0.7)) for t in texts], dtype=float
248+
)
207249
anchors = df[["X", "Y"]].to_numpy(dtype=float)
208250

209251
# Singletons rest on their point; clustered boxes get repositioned below.
210-
pos = _declutter(anchors.copy(), sizes, max_value)
252+
pos = _declutter(anchors.copy(), sizes, max_value, text_sizes=text_sizes)
211253

212254
# For each overlapping group, lift the whole stack along its bird direction
213-
# to make room for a dot per person fanned at the true location.
214-
clusters = [] # (member indices, true cx, cy)
215-
for group in _clusters(anchors, sizes):
255+
# to make room for a dot per person at their true location.
256+
clusters = [] # member-index lists for groups whose text actually collides
257+
for group in _clusters(anchors, sizes, text_sizes=text_sizes):
216258
if len(group) < 2:
217259
continue
218260
members = sorted(group, key=lambda i: anchors[i, 1], reverse=True)
@@ -238,22 +280,21 @@ def add_name_boxes(ax: Axes, df: pd.DataFrame, max_value: float) -> None:
238280
for rank, i in enumerate(members):
239281
level = (n - 1) / 2 - rank
240282
pos[i] = [center[0], center[1] + level * _CALLOUT_BOX_GAP]
241-
clusters.append((group, cx, cy))
283+
clusters.append(group)
242284

243285
# A moved stack may now overlap a neighbour; declutter once more so
244286
# everything is collision-free before we draw the leader lines.
245287
if clusters:
246-
pos = _declutter(pos, sizes, max_value)
247-
248-
# One dot per collided person at its true location, matched to the FINAL
249-
# vertical order of the boxes. The line simply targets the box centre — the
250-
# opaque gradient box drawn on top hides the part of the line beneath it.
251-
for group, cx, cy in clusters:
252-
members = sorted(group, key=lambda i: pos[i][1], reverse=True)
253-
n = len(members)
254-
for rank, i in enumerate(members):
255-
level = (n - 1) / 2 - rank
256-
dot_x, dot_y = cx, cy + level * _CALLOUT_DOT_GAP
288+
pos = _declutter(pos, sizes, max_value, text_sizes=text_sizes)
289+
290+
# One dot per collided person at their TRUE (X, Y) — the whole point of the
291+
# callout is to mark where the person actually is, then lead the eye to their
292+
# moved box. The line targets the box centre; the opaque gradient box drawn on
293+
# top hides the part of the line beneath it. (Identical profiles share a point,
294+
# so their dots coincide and their leader lines simply converge there.)
295+
for group in clusters:
296+
for i in group:
297+
dot_x, dot_y = anchors[i]
257298
ax.plot([pos[i][0], dot_x], [pos[i][1], dot_y], color="black", linewidth=0.5, linestyle="--", zorder=2)
258299
ax.scatter([dot_x], [dot_y], s=2, color="black", zorder=4)
259300

‎tests/test_scatter_render.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,37 @@ def test_declutter_leaves_no_visual_overlap():
140140
assert dx >= need_x - 1e-9 or dy >= need_y - 1e-9
141141

142142

143+
# --- text-aware collision: cards may overlap as long as the text stays clear ---
144+
145+
146+
def test_clusters_text_aware_ignores_box_only_overlap():
147+
# 8-wide boxes overlap at dx=6.5, but the real 4-wide texts are clear apart.
148+
anchors = np.array([[0.0, 0.0], [6.5, 0.0]])
149+
fat = np.array([[8.0, 1.0], [8.0, 1.0]])
150+
text = np.array([[4.0, 1.0], [4.0, 1.0]])
151+
# Default rule clusters them (padded boxes touch)...
152+
assert sorted(len(g) for g in _clusters(anchors, fat)) == [2]
153+
# ...text-aware leaves them as separate singletons (no callout).
154+
assert sorted(len(g) for g in _clusters(anchors, fat, text_sizes=text)) == [1, 1]
155+
156+
157+
def test_clusters_text_aware_still_groups_real_text_overlap():
158+
# Close enough that the texts themselves overlap -> still one callout group.
159+
anchors = np.array([[0.0, 0.0], [3.0, 0.0]])
160+
fat = np.array([[8.0, 1.0], [8.0, 1.0]])
161+
text = np.array([[4.0, 1.0], [4.0, 1.0]])
162+
assert sorted(len(g) for g in _clusters(anchors, fat, text_sizes=text)) == [2]
163+
164+
165+
def test_declutter_text_aware_leaves_box_only_overlap_untouched():
166+
# The box-overlap-but-text-clear pair must not be pushed apart.
167+
centers = np.array([[0.0, 0.0], [6.5, 0.0]])
168+
fat = np.array([[8.0, 1.0], [8.0, 1.0]])
169+
text = np.array([[4.0, 1.0], [4.0, 1.0]])
170+
out = _declutter(centers, fat, MV, text_sizes=text)
171+
assert np.allclose(out, centers)
172+
173+
143174
def test_add_name_boxes_empty_df_is_noop(fig_ax):
144175
_, ax = fig_ax
145176
before = (len(ax.texts), len(ax.images), len(ax.patches))

0 commit comments

Comments
 (0)