mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 06:10:58 +08:00
Trim public tools to 14, expose only kept layout actions/recipes, merge Fabric queries, drop createTopologyView/listTopologyViews, and align skill/docs with the live catalog. Co-authored-by: Cursor <cursoragent@cursor.com>
395 lines
14 KiB
Python
395 lines
14 KiB
Python
"""Greedy local untangle: move low-degree nodes to cut crossings (keep ov=0)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import random
|
|
from typing import Any
|
|
|
|
from netx_topology_mcp.layout_metrics import (
|
|
count_edge_crossings,
|
|
crossing_participation,
|
|
crossings_involving_node,
|
|
node_footprint,
|
|
top_crossing_nodes,
|
|
)
|
|
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
|
|
|
|
|
def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]):
|
|
x, y = pos[nid]
|
|
minx, miny, maxx, maxy = node_footprint(names.get(nid, ""))
|
|
return (x + minx, y + miny, x + maxx, y + maxy)
|
|
|
|
|
|
def _node_overlaps_any(
|
|
node: str, pos: dict[str, tuple[float, float]], names: dict[str, str]
|
|
) -> bool:
|
|
ax0, ay0, ax1, ay1 = _box(node, pos, names)
|
|
for b, (x, y) in pos.items():
|
|
if b == node:
|
|
continue
|
|
if abs(x - pos[node][0]) > 80 and abs(y - pos[node][1]) > 60:
|
|
continue
|
|
bx0, by0, bx1, by1 = _box(b, pos, names)
|
|
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _participation(
|
|
pos: dict[str, tuple[float, float]], links: list[tuple[str, str]]
|
|
) -> dict[str, int]:
|
|
_n, hit = crossing_participation(pos, links)
|
|
return hit
|
|
|
|
|
|
# Max jump from current position — prevents radial *scale* candidates from
|
|
# flinging nodes across a giant star and exploding util/bbox.
|
|
# Default raised: BTM-scale stars need ~900px jumps to escape petal local minima.
|
|
_MAX_JUMP = 900.0
|
|
_MAX_FROM_NBS = 1100.0
|
|
# Sparse metro compose can have spokes of several thousand px; a hard 1200
|
|
# cap makes untangle a no-op on the worst bridges.
|
|
_MAX_JUMP_CAP = 4800.0
|
|
|
|
|
|
def _candidates(
|
|
pos: dict[str, tuple[float, float]],
|
|
node: str,
|
|
adj: dict[str, set[str]],
|
|
rng: random.Random,
|
|
*,
|
|
max_jump: float = _MAX_JUMP,
|
|
angle_step: int = 15,
|
|
random_n: int = 12,
|
|
) -> list[tuple[float, float]]:
|
|
x, y = pos[node]
|
|
jump = max(200.0, min(float(max_jump), _MAX_JUMP_CAP))
|
|
nbs_cap = max(_MAX_FROM_NBS, jump * 1.25)
|
|
step = max(10, int(angle_step))
|
|
cands: list[tuple[float, float]] = []
|
|
radii = [80, 120, 180, 260, 360, 480]
|
|
if jump > 520:
|
|
radii = radii + [640, 800]
|
|
if jump > 1200:
|
|
radii = radii + [1200, 1600, 2200, min(jump, 3200)]
|
|
if step >= 25:
|
|
radii = [r for r in radii if r >= 120]
|
|
for ang in range(0, 360, step):
|
|
for r in radii:
|
|
if r > jump + 1:
|
|
continue
|
|
rad = math.radians(ang)
|
|
cands.append((x + r * math.cos(rad), y + r * math.sin(rad)))
|
|
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
|
|
if nbs:
|
|
cx = sum(p[0] for p in nbs) / len(nbs)
|
|
cy = sum(p[1] for p in nbs) / len(nbs)
|
|
dx, dy = x - cx, y - cy
|
|
L = math.hypot(dx, dy) or 1.0
|
|
# Unit steps along/away from neighbor centroid — NOT unbounded *s* of L.
|
|
ux, uy = dx / L, dy / L
|
|
for s in (-360.0, -220.0, -120.0, 120.0, 220.0, 360.0):
|
|
if abs(s) <= jump:
|
|
cands.append((x + ux * s, y + uy * s))
|
|
cands.append((cx + ux * abs(s), cy + uy * abs(s)))
|
|
px, py = -uy, ux
|
|
for s in (-240, -160, -80, 80, 160, 240):
|
|
if abs(s) <= jump:
|
|
cands.append((x + px * s, y + py * s))
|
|
span = min(350.0, jump * 0.7)
|
|
for _ in range(max(0, int(random_n))):
|
|
cands.append((x + rng.uniform(-span, span), y + rng.uniform(-span, span)))
|
|
# Drop absurd jumps (legacy radial scale could send nodes 10k+ px away).
|
|
out: list[tuple[float, float]] = []
|
|
for cx_, cy_ in cands:
|
|
if math.hypot(cx_ - x, cy_ - y) > jump:
|
|
continue
|
|
if nbs:
|
|
mx = sum(p[0] for p in nbs) / len(nbs)
|
|
my = sum(p[1] for p in nbs) / len(nbs)
|
|
if math.hypot(cx_ - mx, cy_ - my) > nbs_cap:
|
|
continue
|
|
out.append((cx_, cy_))
|
|
return out
|
|
|
|
|
|
def untangle_crossings(
|
|
state: LayoutState,
|
|
params: LayoutParams | None = None,
|
|
*,
|
|
max_rounds: int = 200,
|
|
max_degree: int = 7,
|
|
target_crossings: int = 60,
|
|
seed: int = 7,
|
|
protect_rings: bool = False,
|
|
protect_rigid: bool | str = "portals",
|
|
moves_per_round: int = 3,
|
|
max_jump: float | None = None,
|
|
frozen_ids: set[str] | None = None,
|
|
focus_ids: list[str] | None = None,
|
|
rank_cap: int | None = None,
|
|
angle_step: int | None = None,
|
|
refresh_every: int | None = None,
|
|
) -> OpResult:
|
|
"""Move low-degree nodes greedily to reduce global edge crossings.
|
|
|
|
``protect_rigid`` (default ``portals``): freeze shared dual-unit pivots
|
|
only — corridors/tails may still move. Use ``all`` to freeze every
|
|
compose-group member, or ``false`` to freeze nothing.
|
|
"""
|
|
params = params or LayoutParams()
|
|
st = state.copy()
|
|
pos = dict(st.positions)
|
|
names = dict(st.names)
|
|
links = list(st.links)
|
|
adj = {n: set(st.adj.get(n, ())) for n in pos}
|
|
rng = random.Random(seed)
|
|
jump = float(max_jump if max_jump is not None else _MAX_JUMP)
|
|
jump = max(200.0, min(jump, _MAX_JUMP_CAP))
|
|
n_links = len(links)
|
|
# Auto-throttle on large E so MCP stdio stays interactive.
|
|
if rank_cap is None:
|
|
rank_cap = 36 if n_links >= 400 else (60 if n_links >= 200 else 140)
|
|
if angle_step is None:
|
|
angle_step = 30 if n_links >= 400 else (20 if n_links >= 200 else 15)
|
|
if refresh_every is None:
|
|
refresh_every = 3 if n_links >= 200 else 1
|
|
rank_cap = max(8, int(rank_cap))
|
|
angle_step = max(10, int(angle_step))
|
|
refresh_every = max(1, int(refresh_every))
|
|
random_n = 4 if n_links >= 400 else (8 if n_links >= 200 else 12)
|
|
|
|
cur_c = count_edge_crossings(pos, links)
|
|
start_c = cur_c
|
|
moved: set[str] = set()
|
|
frozen: set[str] = set(frozen_ids or ())
|
|
if not frozen and protect_rigid not in (False, "false", "off", "none", "0"):
|
|
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
|
|
|
|
frozen = frozen_ids_for_protect(st, protect_rigid)
|
|
faces = None
|
|
pierce0 = 0
|
|
if protect_rings:
|
|
from netx_topology_mcp.layout_ops.ring_faces import (
|
|
count_ring_pierces,
|
|
extract_ring_faces,
|
|
)
|
|
|
|
faces = extract_ring_faces(st)
|
|
pierce0 = int(count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0)
|
|
|
|
focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)}
|
|
hit: dict[str, int] = {}
|
|
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
|
|
|
|
rounds_total = max(1, int(max_rounds))
|
|
for _round in range(rounds_total):
|
|
if _round % max(1, refresh_every) == 0:
|
|
raise_if_cancelled()
|
|
pct = 70.0 + 4.0 * (_round / rounds_total)
|
|
report_progress(
|
|
"untangle",
|
|
pct=min(74.0, pct),
|
|
message=f"round {_round + 1}/{rounds_total} x={cur_c}",
|
|
step=_round + 1,
|
|
total_steps=rounds_total,
|
|
crossings=cur_c,
|
|
)
|
|
if _round % refresh_every == 0 or not hit:
|
|
hit = _participation(pos, links)
|
|
if not hit:
|
|
break
|
|
# Prefer analyze.top_crossing_nodes / explicit focus_ids first.
|
|
if not focus:
|
|
focus = {
|
|
str(r["fabric_node_id"])
|
|
for r in top_crossing_nodes(
|
|
pos, links, names=names, adj=adj, top_n=5, participation=hit
|
|
)
|
|
}
|
|
# High-degree hubs in top_nodes cannot move under max_degree — pull in
|
|
# their low-degree neighbors that still participate in crossings.
|
|
movable_focus: set[str] = set()
|
|
for nid in focus:
|
|
if len(adj.get(nid, ())) < max_degree and nid in hit:
|
|
movable_focus.add(nid)
|
|
for nb in adj.get(nid, ()):
|
|
if nb in hit and len(adj.get(nb, ())) < max_degree:
|
|
movable_focus.add(nb)
|
|
prefer = movable_focus or focus
|
|
ranked = sorted(
|
|
hit.keys(),
|
|
key=lambda n: (
|
|
0 if n in prefer else 1,
|
|
-hit[n] / max(len(adj.get(n, ())), 1),
|
|
len(adj.get(n, ())),
|
|
-hit[n],
|
|
),
|
|
)
|
|
improved = False
|
|
moves_left = max(1, int(moves_per_round))
|
|
for node in ranked[:rank_cap]:
|
|
if moves_left <= 0:
|
|
break
|
|
if node in frozen:
|
|
continue
|
|
deg = len(adj.get(node, ()))
|
|
if deg >= max_degree:
|
|
continue
|
|
before = crossings_involving_node(node, pos, links, adj)
|
|
if before <= 0:
|
|
continue
|
|
best: tuple[int, tuple[float, float]] | None = None
|
|
x0, y0 = pos[node]
|
|
for cand in _candidates(
|
|
pos,
|
|
node,
|
|
adj,
|
|
rng,
|
|
max_jump=jump,
|
|
angle_step=angle_step,
|
|
random_n=random_n,
|
|
):
|
|
if math.hypot(cand[0] - x0, cand[1] - y0) > jump:
|
|
continue
|
|
trial = dict(pos)
|
|
trial[node] = cand
|
|
if _node_overlaps_any(node, trial, names):
|
|
continue
|
|
after = crossings_involving_node(node, trial, links, adj)
|
|
if after >= before:
|
|
continue
|
|
g2 = cur_c - before + after
|
|
if g2 >= cur_c:
|
|
continue
|
|
if faces is not None:
|
|
from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces
|
|
|
|
pierce1 = int(
|
|
count_ring_pierces(trial, links, faces).get("pierce_crossings")
|
|
or 0
|
|
)
|
|
if pierce1 > pierce0:
|
|
continue
|
|
if best is None or g2 < best[0]:
|
|
best = (g2, cand)
|
|
if best is None:
|
|
continue
|
|
after_local = best[0] - cur_c + before
|
|
pos[node] = best[1]
|
|
moved.add(node)
|
|
cur_c = best[0]
|
|
# Stale hit is ok between refreshes; keep local estimate for ranking.
|
|
if after_local > 0:
|
|
hit[node] = after_local
|
|
else:
|
|
hit.pop(node, None)
|
|
if faces is not None:
|
|
from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces
|
|
|
|
pierce0 = int(
|
|
count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0
|
|
)
|
|
improved = True
|
|
moves_left -= 1
|
|
if not improved:
|
|
break
|
|
if cur_c <= target_crossings:
|
|
break
|
|
|
|
protect_mode = (
|
|
protect_rigid
|
|
if isinstance(protect_rigid, str)
|
|
else ("portals" if protect_rigid else "off")
|
|
)
|
|
st.positions = pos
|
|
# Reuse last hit for meta (avoid an extra O(E²) pass on large graphs).
|
|
if not hit:
|
|
hit = _participation(pos, links)
|
|
st.meta["untangle"] = {
|
|
"start_crossings": start_c,
|
|
"end_crossings": cur_c,
|
|
"moved_n": len(moved),
|
|
"max_degree": max_degree,
|
|
"target_crossings": target_crossings,
|
|
"max_jump": jump,
|
|
"protect_rings": bool(protect_rings),
|
|
"protect_rigid": protect_mode,
|
|
"frozen_n": len(frozen),
|
|
"moves_per_round": int(moves_per_round),
|
|
"focus_n": len(focus),
|
|
"rank_cap": rank_cap,
|
|
"angle_step": angle_step,
|
|
"refresh_every": refresh_every,
|
|
"top_crossing_nodes": top_crossing_nodes(
|
|
pos, links, names=names, adj=adj, top_n=5, participation=hit
|
|
),
|
|
}
|
|
return OpResult(
|
|
state=st,
|
|
moved=moved,
|
|
op="untangle_crossings",
|
|
params={
|
|
"max_rounds": max_rounds,
|
|
"max_degree": max_degree,
|
|
"target_crossings": target_crossings,
|
|
"seed": seed,
|
|
"protect_rings": bool(protect_rings),
|
|
"protect_rigid": protect_mode,
|
|
"frozen_n": len(frozen),
|
|
"moves_per_round": int(moves_per_round),
|
|
"max_jump": jump,
|
|
"focus_n": len(focus),
|
|
},
|
|
note=f"untangle {start_c}->{cur_c} moved={len(moved)} frozen={len(frozen)}",
|
|
)
|
|
|
|
|
|
def untangle_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Pull untangle knobs from layout params overrides (optional)."""
|
|
o = overrides or {}
|
|
out: dict[str, Any] = {}
|
|
for key, cast, default in (
|
|
("max_rounds", int, 200),
|
|
("max_degree", int, 7),
|
|
("target_crossings", int, 60),
|
|
("seed", int, 7),
|
|
("moves_per_round", int, 3),
|
|
):
|
|
if key not in o or o[key] is None:
|
|
out[key] = default
|
|
continue
|
|
try:
|
|
out[key] = cast(o[key])
|
|
except (TypeError, ValueError):
|
|
out[key] = default
|
|
if "protect_rings" in o:
|
|
out["protect_rings"] = bool(o["protect_rings"])
|
|
if "protect_rigid" in o:
|
|
v = o["protect_rigid"]
|
|
if isinstance(v, bool):
|
|
# true → portals (semi-rigid); false → off
|
|
out["protect_rigid"] = "portals" if v else "off"
|
|
else:
|
|
key = str(v).strip().lower()
|
|
if key in {"0", "false", "no", "off", "none"}:
|
|
out["protect_rigid"] = "off"
|
|
elif key in {"1", "true", "yes", "on", "portals", "skeleton"}:
|
|
out["protect_rigid"] = "portals"
|
|
elif key in {"all", "full", "rigid"}:
|
|
out["protect_rigid"] = "all"
|
|
else:
|
|
out["protect_rigid"] = key
|
|
if o.get("max_jump") is not None:
|
|
try:
|
|
out["max_jump"] = float(o["max_jump"])
|
|
except (TypeError, ValueError):
|
|
pass
|
|
focus = o.get("focus_ids") or o.get("focus_node_ids")
|
|
if isinstance(focus, list):
|
|
out["focus_ids"] = [str(x).strip() for x in focus if str(x).strip()]
|
|
elif isinstance(focus, str) and focus.strip():
|
|
out["focus_ids"] = [s.strip() for s in focus.split(",") if s.strip()]
|
|
return out
|