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
191227def 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
0 commit comments