|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import math |
| 6 | +from collections import defaultdict |
6 | 7 | from collections.abc import Iterable |
7 | 8 |
|
8 | 9 | import numpy as np |
@@ -173,6 +174,7 @@ def _compute_component_layout_2d( |
173 | 174 | ) |
174 | 175 |
|
175 | 176 | _snap_virtual_nodes_to_barycenters(component, positions) |
| 177 | + _spread_colocated_virtual_hubs_2d(component, positions) |
176 | 178 | _place_trimmed_leaf_nodes_2d(component, positions) |
177 | 179 | return _center_positions(positions, node_ids=node_ids) |
178 | 180 |
|
@@ -244,6 +246,46 @@ def _snap_virtual_nodes_to_barycenters( |
244 | 246 | ) |
245 | 247 |
|
246 | 248 |
|
| 249 | +def _spread_colocated_virtual_hubs_2d( |
| 250 | + component: _LayoutComponent, |
| 251 | + positions: NodePositions, |
| 252 | +) -> None: |
| 253 | + """Separate virtual hubs that share the same visible neighbors (identical barycenters).""" |
| 254 | + groups: defaultdict[frozenset[int], list[int]] = defaultdict(list) |
| 255 | + graph_nx = component.contraction_graph |
| 256 | + for vid in component.virtual_node_ids: |
| 257 | + groups[frozenset(graph_nx.neighbors(vid))].append(vid) |
| 258 | + |
| 259 | + spacing = float(_VIRTUAL_HUB_MIN_SEPARATION) |
| 260 | + for neighbor_set, vids in groups.items(): |
| 261 | + if len(vids) < 2: |
| 262 | + continue |
| 263 | + neighbors = sorted(neighbor_set, key=lambda nid: float(positions[nid][0])) |
| 264 | + base = np.mean( |
| 265 | + np.stack([positions[nid] for nid in neighbors]), |
| 266 | + axis=0, |
| 267 | + ) |
| 268 | + if len(neighbors) >= 2: |
| 269 | + p0 = np.asarray(positions[neighbors[0]], dtype=float).reshape(-1)[:2] |
| 270 | + p1 = np.asarray(positions[neighbors[-1]], dtype=float).reshape(-1)[:2] |
| 271 | + chord = p1 - p0 |
| 272 | + chord_len = float(np.linalg.norm(chord)) |
| 273 | + if chord_len > 1e-9: |
| 274 | + perp = np.array([-chord[1], chord[0]], dtype=float) / chord_len |
| 275 | + else: |
| 276 | + perp = np.array([0.0, 1.0], dtype=float) |
| 277 | + else: |
| 278 | + perp = np.array([0.0, 1.0], dtype=float) |
| 279 | + |
| 280 | + vids_sorted = sorted(vids) |
| 281 | + n_v = len(vids_sorted) |
| 282 | + offsets = np.linspace(-0.5 * (n_v - 1), 0.5 * (n_v - 1), n_v) |
| 283 | + for vid, off in zip(vids_sorted, offsets, strict=True): |
| 284 | + pos = np.asarray(positions[vid], dtype=float).copy() |
| 285 | + pos[:2] = base[:2] + perp * spacing * float(off) |
| 286 | + positions[vid] = pos |
| 287 | + |
| 288 | + |
247 | 289 | def _place_trimmed_leaf_nodes_2d( |
248 | 290 | component: _LayoutComponent, |
249 | 291 | positions: NodePositions, |
@@ -1104,5 +1146,6 @@ def _orthogonal_unit(vector: Vector) -> Vector: |
1104 | 1146 | "_segment_point_min_distance_sq_3d", |
1105 | 1147 | "_segments_cross_2d", |
1106 | 1148 | "_snap_virtual_nodes_to_barycenters", |
| 1149 | + "_spread_colocated_virtual_hubs_2d", |
1107 | 1150 | "_used_axis_directions", |
1108 | 1151 | ] |
0 commit comments