Skip to content

Commit 3e669c9

Browse files
arapovclaude
andcommitted
feat: redesign scatter labels as gradient cards with collision callouts
Replace the flat single-colour name boxes with a richer, more legible label: - Pastel proportional gradient fill: the primary->secondary bird colours split at the score ratio, so each box shows the personality blend at a glance. - Borderless rounded card with a soft drop shadow for a little volume. - Boxes never touch: the layout now reserves each box's rounded padding (which the old overlap math ignored) plus a small gap. - Collisions (identical / very similar profiles) are decluttered into a callout: the group's boxes are lifted horizontally toward the birds' side (with a fallback when an edge blocks it), and each person keeps a small black dot at their true point joined by a thin dashed leader line — so even identical profiles stay individually readable. Deterministic and dependency-free. Adds tests for declutter (incl. no visual overlap and the horizontal-separation path), clustering, border colour removal, and the empty-input no-op; scatter.py at 100% line coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b4b2150 commit 3e669c9

2 files changed

Lines changed: 189 additions & 71 deletions

File tree

‎bird_plot/plots/scatter.py‎

Lines changed: 157 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
11
import logging
2-
from collections import Counter
32
from pathlib import Path
43

54
import matplotlib.pyplot as plt
65
import numpy as np
76
import pandas as pd
87
from matplotlib.axes import Axes
8+
from matplotlib.colors import to_rgba
99
from matplotlib.patches import FancyBboxPatch
10+
from matplotlib.patheffects import withSimplePatchShadow
1011

1112
from .base import add_axis_labels, add_bird_images, add_date, add_quadrant_labels, add_quadrants, setup_plot
1213

1314
logger = logging.getLogger(__name__)
1415

1516
_BOX_HEIGHT = 1.0
16-
_CLUSTER_PAD = 0.5 # breathing room kept between boxes
17+
_BOX_PAD = 0.3 # FancyBboxPatch round padding, in data units (expands the box each side)
18+
_BOX_GAP = 0.08 # minimum visual gap kept between boxes (a few px) so they never touch
19+
_CLUSTER_PAD = 2 * _BOX_PAD + _BOX_GAP # reserve each box's padding on both sides + the gap
20+
_CALLOUT_OFFSET = 6.0 # how far a colliding group's stack is moved off its point
21+
_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
23+
_GRADIENT_BAND = 0.12 # half-width of the soft transition between the two fill colours
1724

18-
# Box border colour keyed to a person's dominant bird, so the label reads on any
19-
# quadrant background (white fill) while the border still carries meaning.
20-
_BIRD_BORDER = {"D": "royalblue", "E": "crimson", "O": "goldenrod", "P": "seagreen"}
21-
_NEUTRAL_BORDER = "0.4"
25+
_BIRD_NAME = {"D": "Dove", "E": "Eagle", "O": "Owl", "P": "Peacock"}
26+
# Light bird hues for the label fill (matching the quadrant colours).
27+
_BIRD_LIGHT = {"D": "lightskyblue", "E": "lightcoral", "O": "khaki", "P": "lightgreen"}
28+
_PASTEL = 0.5 # how far the fill colours are blended toward white (0 = none, 1 = white)
29+
30+
# Direction each bird leans toward, matching its quadrant corner:
31+
# Dove top-right, Owl bottom-right, Peacock top-left, Eagle bottom-left.
32+
_BIRD_DIR = {"D": (1.0, 1.0), "O": (1.0, -1.0), "P": (-1.0, 1.0), "E": (-1.0, -1.0)}
2233

2334

2435
def _box_text(row) -> str:
@@ -28,22 +39,87 @@ def _box_text(row) -> str:
2839
return f"{row['Name']} {note_str}".strip()
2940

3041

31-
def _border_color(row) -> str:
32-
"""Pick a border colour from the dominant bird.
42+
def _bird_vector(row) -> np.ndarray:
43+
"""Direction a person leans, from their primary (and secondary) bird.
44+
45+
Uses the Note's two letters (primary weighted more); falls back to the two
46+
highest scores. Returns an unnormalized 2-vector pointing toward that blend.
47+
"""
48+
note = row.get("Note", "")
49+
letters = [c for c in str(note).upper() if c in _BIRD_DIR][:2] if (note and not pd.isna(note)) else []
50+
if not letters:
51+
scores = {b: (row.get(b) or 0) for b in ("Dove", "Eagle", "Owl", "Peacock")}
52+
letters = [b[0] for b in sorted(scores, key=scores.get, reverse=True)[:2]]
53+
vec = np.zeros(2)
54+
for letter, weight in zip(letters, (1.0, 0.5), strict=False):
55+
vec += np.array(_BIRD_DIR[letter]) * weight
56+
return vec
57+
58+
59+
def _gradient_stops(row):
60+
"""Return (primary_colour, secondary_colour, ratio) for the label fill.
3361
34-
Prefers the primary letter of the Note (what the label shows); falls back to
35-
the highest raw score, then to a neutral grey when nothing is available.
62+
Colours are the light bird hues; ratio is the primary bird's share of the
63+
two scores, so the gradient leans toward whichever bird dominates.
3664
"""
3765
note = row.get("Note", "")
38-
if note and not pd.isna(note):
39-
color = _BIRD_BORDER.get(str(note)[:1].upper())
40-
if color:
41-
return color
42-
scores = {b: row.get(b) for b in ("Dove", "Eagle", "Owl", "Peacock")}
43-
scores = {b: v for b, v in scores.items() if v is not None and not pd.isna(v)}
44-
if scores:
45-
return _BIRD_BORDER[max(scores, key=scores.get)[0]]
46-
return _NEUTRAL_BORDER
66+
letters = [c for c in str(note).upper() if c in _BIRD_LIGHT][:2] if (note and not pd.isna(note)) else []
67+
scores = {b: (row.get(b) or 0) for b in ("Dove", "Eagle", "Owl", "Peacock")}
68+
if len(letters) < 2:
69+
for letter in (b[0] for b in sorted(scores, key=scores.get, reverse=True)):
70+
if letter not in letters:
71+
letters.append(letter)
72+
if len(letters) == 2:
73+
break
74+
s1, s2 = scores[_BIRD_NAME[letters[0]]], scores[_BIRD_NAME[letters[1]]]
75+
ratio = s1 / (s1 + s2) if (s1 + s2) else 0.5
76+
return _BIRD_LIGHT[letters[0]], _BIRD_LIGHT[letters[1]], ratio
77+
78+
79+
def _pastel(color) -> np.ndarray:
80+
"""Return an RGBA blended toward white for a softer, pastel fill."""
81+
rgba = np.array(to_rgba(color))
82+
rgba[:3] = rgba[:3] + (1.0 - rgba[:3]) * _PASTEL
83+
return rgba
84+
85+
86+
def _gradient_image(rgba1, rgba2, ratio):
87+
"""A 1xN horizontal gradient that splits primary->secondary at ``ratio``.
88+
89+
Proportional split: the primary colour fills ``ratio`` of the width, the
90+
secondary the rest, with a soft transition band so the share stays legible.
91+
"""
92+
xs = np.linspace(0.0, 1.0, 64)
93+
blend = np.clip((xs - ratio) / (2 * _GRADIENT_BAND) + 0.5, 0.0, 1.0)
94+
return ((1 - blend)[:, None] * rgba1 + blend[:, None] * rgba2).reshape(1, 64, 4)
95+
96+
97+
def _fill_gradient_box(ax, bx, by, w, h, c1, c2, ratio):
98+
"""Draw a rounded, borderless label box filled with a primary->secondary
99+
pastel gradient, plus a soft drop shadow for a bit of volume."""
100+
ratio = min(max(ratio, 0.05), 0.95)
101+
fill = _gradient_image(_pastel(c1), _pastel(c2), ratio)
102+
rounded = f"round,pad={_BOX_PAD}"
103+
104+
# Drop shadow: a hidden white source patch (fully covered by the opaque fill
105+
# on top) whose offset shadow peeks out below-right.
106+
shadow = FancyBboxPatch(
107+
(bx - w / 2, by - h / 2), w, h, boxstyle=rounded, facecolor="white", edgecolor="none", zorder=2.6,
108+
)
109+
shadow.set_path_effects([withSimplePatchShadow(offset=(2.5, -2.5), alpha=0.15, shadow_rgbFace="black")])
110+
ax.add_patch(shadow)
111+
112+
# Gradient fill, clipped to the rounded box.
113+
clip = FancyBboxPatch(
114+
(bx - w / 2, by - h / 2), w, h, boxstyle=rounded, facecolor="none", edgecolor="none", zorder=3,
115+
)
116+
ax.add_patch(clip)
117+
img = ax.imshow(
118+
fill,
119+
extent=(bx - w / 2 - _BOX_PAD, bx + w / 2 + _BOX_PAD, by - h / 2 - _BOX_PAD, by + h / 2 + _BOX_PAD),
120+
aspect="auto", origin="lower", zorder=3, interpolation="bilinear",
121+
)
122+
img.set_clip_path(clip)
47123

48124

49125
def _declutter(centers: np.ndarray, sizes: np.ndarray, max_value: float, iters: int = 400) -> np.ndarray:
@@ -113,53 +189,85 @@ def find(a):
113189

114190

115191
def add_name_boxes(ax: Axes, df: pd.DataFrame, max_value: float) -> None:
116-
"""Draw name boxes, decluttering only where they would overlap.
192+
"""Draw name boxes, with callouts for people who collide.
117193
118-
Each box starts on its person's true (X, Y). Overlapping boxes are pushed
119-
apart; any box that had to move shows a small dot at its true point and a
120-
grey leader line back to it, so people with identical or very similar
121-
profiles stay individually readable. Boxes that don't collide are left
122-
exactly on their point, with no extra marks.
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.
123199
"""
124200
texts = [_box_text(row) for _, row in df.iterrows()]
125201
if not texts:
126202
return
127-
border_colors = [_border_color(row) for _, row in df.iterrows()]
203+
stops = [_gradient_stops(row) for _, row in df.iterrows()] # (primary, secondary, ratio) per person
204+
bird_vecs = [_bird_vector(row) for _, row in df.iterrows()]
128205

129206
sizes = np.array([[max(8, len(t) * 0.4), _BOX_HEIGHT] for t in texts], dtype=float)
130207
anchors = df[["X", "Y"]].to_numpy(dtype=float)
131208

132-
# Boxes start on their true point; overlapping ones get pushed apart.
209+
# Singletons rest on their point; clustered boxes get repositioned below.
133210
pos = _declutter(anchors.copy(), sizes, max_value)
134211

135-
# One marker per overlapping cluster: a single dot at the group's location
136-
# with one leader line to the stack. Coincident people share a point, so a
137-
# per-person dot would be ambiguous; the stacked chips list who is there.
212+
# 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)
138215
for group in _clusters(anchors, sizes):
139216
if len(group) < 2:
140217
continue
141-
anchor_mid = anchors[group].mean(axis=0)
142-
box_mid = pos[group].mean(axis=0)
143-
color = Counter(border_colors[i] for i in group).most_common(1)[0][0]
144-
ax.plot([box_mid[0], anchor_mid[0]], [box_mid[1], anchor_mid[1]], color=color, linewidth=0.9, zorder=2)
145-
ax.scatter([anchor_mid[0]], [anchor_mid[1]], s=16, color=color, zorder=4)
146-
218+
members = sorted(group, key=lambda i: anchors[i, 1], reverse=True)
219+
n = len(members)
220+
cx, cy = anchors[group].mean(axis=0)
221+
half_w = sizes[group][:, 0].max() / 2
222+
half_h = (n - 1) / 2 * _CALLOUT_BOX_GAP + _BOX_HEIGHT / 2
223+
224+
# Offset the stack HORIZONTALLY toward the side the birds lean (Dove/Owl
225+
# -> right, Peacock/Eagle -> left); if that side has no room, use the
226+
# other. A purely horizontal move keeps the dots beside the stack, so the
227+
# leader lines stay clear of the sibling boxes.
228+
bird = np.sum([bird_vecs[i] for i in group], axis=0)
229+
side = 1.0 if bird[0] >= 0 else -1.0
230+
stack_y = np.clip(cy, -max_value + half_h, max_value - half_h)
231+
center = np.array([cx, stack_y])
232+
for sx in (side, -side):
233+
x = np.clip(cx + sx * _CALLOUT_OFFSET, -max_value + half_w, max_value - half_w)
234+
center = np.array([x, stack_y])
235+
if abs(x - cx) >= half_w + 0.5: # enough room to clear the dots
236+
break
237+
238+
for rank, i in enumerate(members):
239+
level = (n - 1) / 2 - rank
240+
pos[i] = [center[0], center[1] + level * _CALLOUT_BOX_GAP]
241+
clusters.append((group, cx, cy))
242+
243+
# A moved stack may now overlap a neighbour; declutter once more so
244+
# everything is collision-free before we draw the leader lines.
245+
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
257+
ax.plot([pos[i][0], dot_x], [pos[i][1], dot_y], color="black", linewidth=0.5, linestyle="--", zorder=2)
258+
ax.scatter([dot_x], [dot_y], s=2, color="black", zorder=4)
259+
260+
# Gradient-filled label boxes (imshow can disturb the axes aspect/limits, so
261+
# snapshot and restore them afterwards).
262+
xlim, ylim = ax.get_xlim(), ax.get_ylim()
147263
for i, text in enumerate(texts):
148264
bx, by = pos[i]
149-
150-
box = FancyBboxPatch(
151-
(bx - sizes[i, 0] / 2, by - sizes[i, 1] / 2),
152-
width=sizes[i, 0],
153-
height=sizes[i, 1],
154-
boxstyle="round,pad=0.3",
155-
edgecolor=border_colors[i],
156-
facecolor="white",
157-
linewidth=1.6,
158-
alpha=0.95,
159-
zorder=3,
160-
)
161-
ax.add_patch(box)
265+
c1, c2, ratio = stops[i]
266+
_fill_gradient_box(ax, bx, by, sizes[i, 0], sizes[i, 1], c1, c2, ratio)
162267
ax.text(bx, by, text, fontsize=10, ha="center", va="center", color="black", zorder=5)
268+
ax.set_aspect("equal", adjustable="box")
269+
ax.set_xlim(*xlim)
270+
ax.set_ylim(*ylim)
163271

164272

165273
def add_grid(ax: Axes) -> None:

‎tests/test_scatter_render.py‎

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
import numpy as np
55
import pandas as pd
66
import pytest
7-
from matplotlib.patches import FancyBboxPatch
87

98
from bird_plot.plots.base import setup_plot
10-
from bird_plot.plots.scatter import _border_color, _clusters, _declutter, add_grid, add_name_boxes, scatter_chart
9+
from bird_plot.plots.scatter import _BOX_GAP, _BOX_PAD, _clusters, _declutter, add_grid, add_name_boxes, scatter_chart
1110

1211
MV = 25
1312

@@ -37,15 +36,15 @@ def test_add_grid_adds_two_lines(fig_ax):
3736
assert len(ax.get_lines()) - before == 2
3837

3938

40-
def test_add_name_boxes_one_box_and_text_per_row(fig_ax):
39+
def test_add_name_boxes_one_label_per_row(fig_ax):
4140
_, ax = fig_ax
4241
df = _df()
43-
boxes_before = sum(isinstance(p, FancyBboxPatch) for p in ax.patches)
4442
texts_before = len(ax.texts)
43+
images_before = len(ax.images)
4544
add_name_boxes(ax, df, MV)
46-
boxes_after = sum(isinstance(p, FancyBboxPatch) for p in ax.patches)
47-
assert boxes_after - boxes_before == len(df)
45+
# One text label and one gradient-fill image per person.
4846
assert len(ax.texts) - texts_before == len(df)
47+
assert len(ax.images) - images_before == len(df)
4948
assert any("Alice" in t.get_text() for t in ax.texts)
5049

5150

@@ -58,15 +57,15 @@ def test_add_name_boxes_no_dot_without_collision(fig_ax):
5857
assert len(ax.collections) == before
5958

6059

61-
def test_add_name_boxes_one_dot_per_cluster(fig_ax):
60+
def test_add_name_boxes_dot_per_collided_person(fig_ax):
6261
_, ax = fig_ax
63-
# Three coincident people form ONE cluster -> a single dot, not three.
62+
# Three coincident people -> a distinct dot each (callout).
6463
df = pd.DataFrame(
6564
{"Name": ["A", "B", "C"], "Note": ["D/O", "D/O", "D/O"], "X": [5.0, 5.0, 5.0], "Y": [5.0, 5.0, 5.0]}
6665
)
6766
before = len(ax.collections)
6867
add_name_boxes(ax, df, MV)
69-
assert len(ax.collections) - before == 1
68+
assert len(ax.collections) - before == 3
7069

7170

7271
def test_add_name_boxes_handles_nan_note(fig_ax):
@@ -118,23 +117,34 @@ def test_clusters_groups_overlapping_separates_distant():
118117
assert sizes_of_groups == [1, 2] # two coincident together, the far one alone
119118

120119

121-
# --- border colour ---
122-
123-
124-
def test_border_color_from_note_primary_letter():
125-
assert _border_color(pd.Series({"Name": "X", "Note": "D/O"})) == "royalblue"
126-
assert _border_color(pd.Series({"Name": "X", "Note": "E/P"})) == "crimson"
127-
assert _border_color(pd.Series({"Name": "X", "Note": "O/D"})) == "goldenrod"
128-
assert _border_color(pd.Series({"Name": "X", "Note": "P/D"})) == "seagreen"
120+
def test_declutter_separates_horizontally():
121+
# Boxes overlapping more vertically than horizontally separate along x.
122+
centers = np.array([[0.0, 0.0], [8.5, 0.0]])
123+
sizes = np.array([[9.0, 1.0], [9.0, 1.0]])
124+
out = _declutter(centers, sizes, MV)
125+
assert abs(out[0, 0] - out[1, 0]) >= 9.0 # cleared on the x-axis
129126

130127

131-
def test_border_color_falls_back_to_dominant_score():
132-
row = pd.Series({"Name": "X", "Note": np.nan, "Dove": 1, "Eagle": 2, "Owl": 9, "Peacock": 3})
133-
assert _border_color(row) == "goldenrod" # Owl highest
128+
def test_declutter_leaves_no_visual_overlap():
129+
# A pile of overlapping boxes must end up with no two boxes visually
130+
# overlapping (accounting for each box's rounded padding on every side).
131+
centers = np.array([[0.0, 0.0]] * 6)
132+
sizes = np.array([[9.0, 1.0]] * 6)
133+
out = _declutter(centers, sizes, MV)
134+
for i in range(len(out)):
135+
for j in range(i + 1, len(out)):
136+
dx, dy = abs(out[i, 0] - out[j, 0]), abs(out[i, 1] - out[j, 1])
137+
need_x = (sizes[i, 0] + sizes[j, 0]) / 2 + 2 * _BOX_PAD + _BOX_GAP
138+
need_y = (sizes[i, 1] + sizes[j, 1]) / 2 + 2 * _BOX_PAD + _BOX_GAP
139+
# Cleared (visual extents disjoint) on at least one axis.
140+
assert dx >= need_x - 1e-9 or dy >= need_y - 1e-9
134141

135142

136-
def test_border_color_neutral_when_unknown():
137-
assert _border_color(pd.Series({"Name": "X", "Note": ""})) == "0.4"
143+
def test_add_name_boxes_empty_df_is_noop(fig_ax):
144+
_, ax = fig_ax
145+
before = (len(ax.texts), len(ax.images), len(ax.patches))
146+
add_name_boxes(ax, pd.DataFrame({"Name": [], "Note": [], "X": [], "Y": []}), MV)
147+
assert (len(ax.texts), len(ax.images), len(ax.patches)) == before
138148

139149

140150
def test_scatter_chart_writes_png(base_config, tmp_path):

0 commit comments

Comments
 (0)