11import logging
2- from collections import Counter
32from pathlib import Path
43
54import matplotlib .pyplot as plt
65import numpy as np
76import pandas as pd
87from matplotlib .axes import Axes
8+ from matplotlib .colors import to_rgba
99from matplotlib .patches import FancyBboxPatch
10+ from matplotlib .patheffects import withSimplePatchShadow
1011
1112from .base import add_axis_labels , add_bird_images , add_date , add_quadrant_labels , add_quadrants , setup_plot
1213
1314logger = 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
2435def _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
49125def _declutter (centers : np .ndarray , sizes : np .ndarray , max_value : float , iters : int = 400 ) -> np .ndarray :
@@ -113,53 +189,85 @@ def find(a):
113189
114190
115191def 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
165273def add_grid (ax : Axes ) -> None :
0 commit comments