Skip to content

Commit e53dd9b

Browse files
committed
Improvement to layout of rates
1 parent f2a0bcf commit e53dd9b

3 files changed

Lines changed: 160 additions & 67 deletions

File tree

pyglotaran_extras/inspect/kinetic_scheme/_layout.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def compute_layout(
9595
if horizontal_spacing <= 0:
9696
from pyglotaran_extras.inspect.kinetic_scheme._constants import DEFAULT_NODE_WIDTH
9797

98-
horizontal_spacing = 3.0 * DEFAULT_NODE_WIDTH
98+
horizontal_spacing = 3.5 * DEFAULT_NODE_WIDTH
9999

100100
if algorithm == LayoutAlgorithm.MANUAL:
101101
positions = _manual_layout(graph, manual_positions)

pyglotaran_extras/inspect/kinetic_scheme/plot_kinetic_scheme.py

Lines changed: 91 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -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

11261151
def _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

tests/inspect/kinetic_scheme/test_plot_kinetic_scheme.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,71 @@ def test_convergent_labels_at_distinct_positions(self) -> None:
457457

458458
# All positions should be distinct
459459
assert len(text_positions) == len(set(text_positions))
460+
461+
462+
class TestBidirectionalLabelConvention:
463+
"""Tests for consistent label placement on bidirectional edge pairs."""
464+
465+
def test_labels_on_right_hand_side_of_arrow_direction(self) -> None:
466+
"""Each rate label should be on the right-hand side of its own arrow.
467+
468+
For a pair A↔B, the A→B label should be on the right of the A→B direction, and the B→A
469+
label should be on the right of the B→A direction. Since the arrow directions are
470+
opposite, the labels end up on opposite geometric sides of the line connecting A and B.
471+
"""
472+
from pyglotaran_extras.inspect.kinetic_scheme._k_matrix_parser import Transition
473+
from pyglotaran_extras.inspect.kinetic_scheme._kinetic_graph import KineticGraph
474+
from pyglotaran_extras.inspect.kinetic_scheme._layout import LayoutAlgorithm
475+
from pyglotaran_extras.inspect.kinetic_scheme._layout import compute_layout
476+
from pyglotaran_extras.inspect.kinetic_scheme.plot_kinetic_scheme import _draw_all_edges
477+
478+
# Bidirectional pair: A→B (large rate) and B→A (small rate)
479+
transitions = [
480+
Transition("A", "B", 0.234, "rates.k_AB", False, "mc1"),
481+
Transition("B", "A", 0.070, "rates.k_BA", False, "mc1"),
482+
]
483+
graph = KineticGraph.from_transitions(transitions)
484+
config = KineticSchemeConfig()
485+
positions = compute_layout(graph, LayoutAlgorithm.HIERARCHICAL)
486+
487+
fig = Figure()
488+
ax = fig.add_subplot(111)
489+
_draw_all_edges(ax, graph, positions, config)
490+
491+
# Find the two rate labels by their text content
492+
rate_texts = [t for t in ax.texts if any(c.isdigit() for c in t.get_text())]
493+
assert len(rate_texts) == 2
494+
495+
# Identify which label belongs to the forward (A→B) and back (B→A) rate
496+
forward_label = next(t for t in rate_texts if "234" in t.get_text())
497+
back_label = next(t for t in rate_texts if "70" in t.get_text())
498+
499+
# Compute the A→B direction vector
500+
ax_pos, ay_pos = positions["A"]
501+
bx_pos, by_pos = positions["B"]
502+
dx = bx_pos - ax_pos
503+
dy = by_pos - ay_pos
504+
length = (dx**2 + dy**2) ** 0.5
505+
506+
# Left perpendicular of A→B direction: (-dy, dx)
507+
perp_x = -dy / length
508+
perp_y = dx / length
509+
510+
# Midpoint of the edge
511+
mid_x = (ax_pos + bx_pos) / 2
512+
mid_y = (ay_pos + by_pos) / 2
513+
514+
# Project label positions onto the perpendicular axis.
515+
# Positive projection = left of A→B direction.
516+
# Negative projection = right of A→B direction.
517+
fwd_proj = (forward_label.get_position()[0] - mid_x) * perp_x + (
518+
forward_label.get_position()[1] - mid_y
519+
) * perp_y
520+
back_proj = (back_label.get_position()[0] - mid_x) * perp_x + (
521+
back_label.get_position()[1] - mid_y
522+
) * perp_y
523+
524+
# A→B label on the RIGHT of A→B direction = negative projection
525+
# B→A label on the RIGHT of B→A direction = LEFT of A→B = positive projection
526+
assert fwd_proj < 0, "A→B rate label should be on the right of A→B direction"
527+
assert back_proj > 0, "B→A rate label should be on the right of B→A direction"

0 commit comments

Comments
 (0)