@@ -889,8 +889,8 @@ def _draw_transfer_edge( # noqa: C901
889889 edge_index : int ,
890890 convergence_index : int = 1 ,
891891 convergence_total : int = 1 ,
892- source_index : int = 1 ,
893- source_total : int = 1 ,
892+ source_index : int = 1 , # noqa: ARG001
893+ source_total : int = 1 , # noqa: ARG001
894894 arrowstyle : str = DEFAULT_ARROWSTYLE ,
895895 placed_labels : list [tuple [float , float ]] | None = None ,
896896 color : str | None = None ,
@@ -938,10 +938,10 @@ def _draw_transfer_edge( # noqa: C901
938938 target_h ,
939939 )
940940
941- connection_style = "arc3,rad= 0.0"
941+ rad = 0.0
942942 if edge_index > 1 :
943943 rad = 0.25 * ((- 1 ) ** edge_index ) * ((edge_index + 1 ) // 2 )
944- connection_style = f"arc3,rad={ rad :.2f} "
944+ connection_style = f"arc3,rad={ rad :.2f} "
945945
946946 arrow = FancyArrowPatch (
947947 start ,
@@ -962,10 +962,9 @@ def _draw_transfer_edge( # noqa: C901
962962 include_unit = config .show_rate_unit_per_label ,
963963 )
964964
965- # Compute edge vector and length
965+ # Compute edge vector
966966 edge_dx = end [0 ] - start [0 ]
967967 edge_dy = end [1 ] - start [1 ]
968- edge_length = max ((edge_dx ** 2 + edge_dy ** 2 ) ** 0.5 , 0.01 )
969968
970969 # Place the label at the midpoint of the edge by default.
971970 # When multiple edges converge on the same target, spread labels
@@ -977,22 +976,34 @@ def _draw_transfer_edge( # noqa: C901
977976 t_max = 0.65
978977 t = t_min + (convergence_index - 1 ) * (t_max - t_min ) / (convergence_total - 1 )
979978
980- label_x = start [0 ] + t * (end [0 ] - start [0 ])
981- label_y = start [1 ] + t * (end [1 ] - start [1 ])
982-
983- # Perpendicular offset to keep labels clear of the arrow line, nodes,
984- # and previously placed labels.
985- # Determine which side (left=+1 or right=-1 of the edge direction) is
986- # free of obstacles. When both sides are free, use a preferred side
987- # derived from source_index alternation for visual variety.
988- perp_x = - edge_dy / edge_length
989- perp_y = edge_dx / edge_length
990- base_offset = 0.35
991- preferred_side = 1 if source_total <= 1 or source_index % 2 == 1 else - 1
979+ label_x = start [0 ] + t * edge_dx
980+ label_y = start [1 ] + t * edge_dy
981+
982+ # Account for arc curvature: shift the label onto the actual Bézier
983+ # curve instead of the straight chord. For arc3,rad=r the control
984+ # point is offset from the chord midpoint by (r·dy, -r·dx). At
985+ # parameter t the curve deviates from the chord by 2·t·(1-t) times
986+ # that offset.
987+ if rad != 0.0 :
988+ bezier_weight = 2 * t * (1 - t )
989+ label_x += bezier_weight * rad * edge_dy
990+ label_y -= bezier_weight * rad * edge_dx
991+
992+ # Perpendicular offset to keep labels clear of the arrow line.
993+ # Convention: labels are ALWAYS placed on the right-hand side of the
994+ # arrow direction. Use the Bézier tangent at t (not the chord) so
995+ # the perpendicular follows the curve.
996+ tangent_x = edge_dx + 2 * rad * edge_dy * (1 - 2 * t )
997+ tangent_y = edge_dy + 2 * rad * edge_dx * (2 * t - 1 )
998+ tangent_len = max ((tangent_x ** 2 + tangent_y ** 2 ) ** 0.5 , 0.01 )
999+ perp_x = - tangent_y / tangent_len
1000+ perp_y = tangent_x / tangent_len
1001+ base_offset = 0.18
1002+ side = - 1 # Always right-hand side of arrow direction
9921003
9931004 # Minimum distance between two label centres to consider them non-
9941005 # overlapping. Accounts for typical rate-text width at fontsize 9.
995- label_clearance = 0.55
1006+ label_clearance = 0.40
9961007
9971008 def _collides (cx : float , cy : float ) -> bool :
9981009 """Return True if (cx, cy) overlaps any node or previously placed label.
@@ -1013,8 +1024,8 @@ def _collides(cx: float, cy: float) -> bool:
10131024 nw , nh = _get_node_dimensions (nl , config )
10141025 # Use a smaller margin for the edge's own source/target so we
10151026 # only trigger when the label is truly inside the node box,
1016- # but a generous margin for intermediate nodes.
1017- margin = 0.05 if nl in (edge .source , edge .target ) else 0.15
1027+ # but a tight margin for intermediate nodes.
1028+ margin = 0.05 if nl in (edge .source , edge .target ) else 0.10
10181029 if abs (cx - nx ) < (nw / 2 + margin ) and abs (cy - ny ) < (nh / 2 + margin ):
10191030 return True
10201031 if placed_labels is not None :
@@ -1023,40 +1034,48 @@ def _collides(cx: float, cy: float) -> bool:
10231034 return True
10241035 return False
10251036
1026- # Test both sides at the base offset
1027- left_clear = not _collides (label_x + perp_x * base_offset , label_y + perp_y * base_offset )
1028- right_clear = not _collides (label_x - perp_x * base_offset , label_y - perp_y * base_offset )
1029-
1030- if left_clear and right_clear :
1031- side = preferred_side
1032- offset_magnitude = base_offset
1033- elif left_clear :
1034- side = 1
1035- offset_magnitude = base_offset
1036- elif right_clear :
1037- side = - 1
1038- offset_magnitude = base_offset
1039- else :
1040- # Both sides collide — try bumping each side outward, pick the
1041- # first side that finds a clear spot at the smallest offset.
1042- best_side = preferred_side
1043- best_mag = base_offset
1044- found = False
1045- for try_side in (preferred_side , - preferred_side ):
1046- mag = base_offset
1047- for _bump in range (6 ):
1048- mag += 0.3
1037+ # Try the right-hand side at the base offset; if it collides, first
1038+ # try sliding the label along the edge before bumping perpendicular.
1039+ offset_magnitude = base_offset
1040+ if _collides (label_x + side * perp_x * base_offset , label_y + side * perp_y * base_offset ):
1041+ # Strategy 1: slide along the edge to find a gap.
1042+ _slide_found = False
1043+ for _dt in [0.1 , - 0.1 , 0.2 , - 0.2 , 0.3 , - 0.3 ]:
1044+ t_try = t + _dt
1045+ if t_try < 0.15 or t_try > 0.85 :
1046+ continue
1047+ slide_x = start [0 ] + t_try * edge_dx
1048+ slide_y = start [1 ] + t_try * edge_dy
1049+ if rad != 0.0 :
1050+ bw = 2 * t_try * (1 - t_try )
1051+ slide_x += bw * rad * edge_dy
1052+ slide_y -= bw * rad * edge_dx
1053+ # Recompute tangent and perpendicular at the new t.
1054+ tx = edge_dx + 2 * rad * edge_dy * (1 - 2 * t_try )
1055+ ty = edge_dy + 2 * rad * edge_dx * (2 * t_try - 1 )
1056+ tl = max ((tx ** 2 + ty ** 2 ) ** 0.5 , 0.01 )
1057+ slid_perp_x = - ty / tl
1058+ slid_perp_y = tx / tl
1059+ if not _collides (
1060+ slide_x + side * slid_perp_x * base_offset ,
1061+ slide_y + side * slid_perp_y * base_offset ,
1062+ ):
1063+ label_x = slide_x
1064+ label_y = slide_y
1065+ perp_x = slid_perp_x
1066+ perp_y = slid_perp_y
1067+ _slide_found = True
1068+ break
1069+
1070+ # Strategy 2: bump perpendicular outward with small increments.
1071+ if not _slide_found :
1072+ for _bump in range (10 ):
1073+ offset_magnitude += 0.12
10491074 if not _collides (
1050- label_x + try_side * perp_x * mag ,
1051- label_y + try_side * perp_y * mag ,
1075+ label_x + side * perp_x * offset_magnitude ,
1076+ label_y + side * perp_y * offset_magnitude ,
10521077 ):
1053- if not found or mag < best_mag :
1054- best_side = try_side
1055- best_mag = mag
1056- found = True
10571078 break
1058- side = best_side
1059- offset_magnitude = best_mag
10601079
10611080 final_x = label_x + side * perp_x * offset_magnitude
10621081 final_y = label_y + side * perp_y * offset_magnitude
@@ -1089,9 +1108,9 @@ def _gs_label_side(
10891108) -> int :
10901109 """Choose the less-crowded side for a ground state decay label.
10911110
1092- Counts how many other nodes sit to the left vs. right of *source_label*
1093- within one vertical spacing unit and returns ``-1`` (left) when the
1094- right side is more crowded, ``+1`` (right) otherwise .
1111+ Prefer the left-hand side of the arrow direction (visual right for
1112+ downward decay arrows). Only switch when a neighbouring node would
1113+ actually overlap the label on the preferred side .
10951114
10961115 Parameters
10971116 ----------
@@ -1108,19 +1127,25 @@ def _gs_label_side(
11081127 if source_label not in positions :
11091128 return 1
11101129 sx , sy = positions [source_label ]
1111- left_count = 0
1112- right_count = 0
1130+ # Check whether a node is close enough on either side to overlap a
1131+ # label placed at sx ± offset. Only nodes below or at the same
1132+ # height are relevant (the decay arrow points downward).
1133+ right_blocked = False
1134+ left_blocked = False
11131135 for nl , (nx , ny ) in positions .items ():
11141136 if nl == source_label :
11151137 continue
1116- # Only consider nodes within a reasonable vertical band
1117- if abs (ny - sy ) > 3.0 :
1138+ if ny > sy + 0.5 :
11181139 continue
1119- if nx < sx - 0.3 :
1120- left_count += 1
1121- elif nx > sx + 0.3 :
1122- right_count += 1
1123- return - 1 if right_count > left_count else 1
1140+ if abs (ny - sy ) < 1.2 and 0.0 < (nx - sx ) < 1.0 :
1141+ right_blocked = True
1142+ if abs (ny - sy ) < 1.2 and - 1.0 < (nx - sx ) < 0.0 :
1143+ left_blocked = True
1144+ # Prefer visual right (+1). Fall back to left only when right is
1145+ # blocked and left is free.
1146+ if right_blocked and not left_blocked :
1147+ return - 1
1148+ return 1
11241149
11251150
11261151def _draw_ground_state_decay_arrow (
@@ -1183,7 +1208,7 @@ def _draw_ground_state_decay_arrow(
11831208 )
11841209 mid_y = (start [1 ] + end [1 ]) / 2
11851210 side = _gs_label_side (edge .source , positions )
1186- label_x = sx + side * 0.3
1211+ label_x = sx + side * 0.18
11871212 label_y = mid_y
11881213 ha = "left" if side > 0 else "right"
11891214
@@ -1269,7 +1294,7 @@ def _draw_ground_state_arrow(
12691294 )
12701295 mid_y = (start [1 ] + end [1 ]) / 2
12711296 side = _gs_label_side (edge .source , positions )
1272- label_x = sx + side * 0.3
1297+ label_x = sx + side * 0.18
12731298 label_y = mid_y
12741299 ha = "left" if side > 0 else "right"
12751300
0 commit comments