netx/netx_api/topology_region_canvas.py
oliver 1ef43fdcd4 Unify UME World as hex nav with conditional flat world map.
Seed a unique L2 World canvas under the UME World container, show the flat map only when nested regions have NEs, pack blocks without overlap, allow drag overrides on the world map while forbidding direct NE adds, and hide the world-map truncation banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:51:08 +08:00

227 lines
7 KiB
Python

"""Child-region icons on a parent canvas (folder === canvas drill-down).
Synthetic placements use fabric_node_id ``region:<folder_id>`` on the parent's
TopoViewNode rows — same shape as UME level region nodes, persisted so drag/save works.
"""
from __future__ import annotations
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from .models import TopoFolder, TopoView, TopoViewNode
from .topology_common import _utcnow
from .topology_membership import VIEW_KIND_PHYSICAL, normalize_view_kind
from .topology_schemas import ViewNodeOut
REGION_NODE_PREFIX = "region:"
def is_region_canvas_node_id(fabric_node_id: str | None) -> bool:
return str(fabric_node_id or "").startswith(REGION_NODE_PREFIX)
def region_folder_id_from_node(fabric_node_id: str) -> str:
return str(fabric_node_id or "")[len(REGION_NODE_PREFIX) :]
def region_canvas_node_id(folder_id: str) -> str:
return f"{REGION_NODE_PREFIX}{folder_id}"
def _is_world_flat(view: TopoView) -> bool:
filt = dict(view.filter or {})
if filt.get("world_flat"):
return True
return str(view.name or "").strip() == "完整世界地图"
def primary_canvas_view(db: Session, folder_id: str) -> TopoView | None:
"""Parent canvas used for child-region icons (skip flat world map)."""
fid = str(folder_id or "").strip()
if not fid:
return None
views = (
db.query(TopoView)
.filter(TopoView.folder_id == fid)
.order_by(TopoView.sort_order.asc(), TopoView.created_at.asc())
.all()
)
usable = [v for v in views if not _is_world_flat(v)]
if not usable:
return None
for v in usable:
filt = dict(v.filter or {})
if filt.get("ume_level") or filt.get("world"):
return v
for v in usable:
if normalize_view_kind(v.kind) == VIEW_KIND_PHYSICAL:
return v
return usable[0]
def _child_primary_view(db: Session, folder_id: str) -> TopoView | None:
return primary_canvas_view(db, folder_id)
def _default_region_xy(db: Session, view_id: str) -> tuple[float, float]:
rows = db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).all()
if not rows:
return 160.0, 160.0
max_x = max(float(r.x or 0) for r in rows)
ys = [float(r.y or 0) for r in rows]
avg_y = sum(ys) / len(ys) if ys else 160.0
return max_x + 220.0, avg_y
def place_child_region_on_parent_canvas(
db: Session,
parent: TopoFolder,
child: TopoFolder,
*,
x: float | None = None,
y: float | None = None,
) -> TopoViewNode | None:
"""Put a region building icon on the parent's canvas. No-op for root parent."""
if parent is None or child is None:
return None
if str(parent.kind or "") != "region":
return None
view = primary_canvas_view(db, parent.id)
if view is None:
return None
nid = region_canvas_node_id(child.id)
existing = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == nid)
.first()
)
if existing is not None:
if child.name and existing.label != child.name:
existing.label = str(child.name)[:256]
existing.updated_at = _utcnow()
return existing
px, py = _default_region_xy(db, view.id) if x is None or y is None else (float(x), float(y))
now = _utcnow()
row = TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=nid,
x=px,
y=py,
label=str(child.name or "")[:256],
locked=False,
created_at=now,
updated_at=now,
)
db.add(row)
return row
def remove_region_canvas_placements(db: Session, folder_id: str) -> int:
"""Drop this folder's icon from any parent canvas."""
nid = region_canvas_node_id(folder_id)
return (
db.query(TopoViewNode)
.filter(TopoViewNode.fabric_node_id == nid)
.delete(synchronize_session=False)
)
def region_node_out(
db: Session,
*,
folder: TopoFolder,
x: float,
y: float,
locked: bool = False,
label: str = "",
) -> ViewNodeOut:
child_view = _child_primary_view(db, folder.id)
kids = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == folder.id, TopoFolder.kind == "region")
.count()
)
# node_count: prefer view membership count when available
node_count = 0
if child_view is not None:
node_count = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == child_view.id)
.count()
)
display = (label or folder.name or folder.id)[:256]
return ViewNodeOut(
fabric_node_id=region_canvas_node_id(folder.id),
managed_ne_id="",
ume_ne_id="",
label=display,
x=float(x),
y=float(y),
locked=bool(locked),
name=str(folder.name or "")[:256],
ip="",
vendor="",
device_type="region",
kind="region",
folder_id=folder.id,
view_id=child_view.id if child_view else "",
node_count=int(node_count or kids or 0),
)
def _is_ume_managed_folder(folder: TopoFolder) -> bool:
"""UME World / drill / SBN folders are drawn by ume_topology_world_graph, not here."""
ext = str(getattr(folder, "external_ref", None) or "").strip()
return bool(ext)
def child_region_nodes_for_view(db: Session, view: TopoView) -> list[ViewNodeOut]:
"""Manual child regions as canvas icons (skip UME-synced SBN folders)."""
folder_id = str(view.folder_id or "").strip()
if not folder_id or _is_world_flat(view):
return []
children = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == folder_id, TopoFolder.kind == "region")
.order_by(TopoFolder.sort_order.asc(), TopoFolder.name.asc())
.all()
)
children = [c for c in children if not _is_ume_managed_folder(c)]
if not children:
return []
placements = {
vn.fabric_node_id: vn
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
if is_region_canvas_node_id(vn.fabric_node_id)
}
out: list[ViewNodeOut] = []
auto_x, auto_y = _default_region_xy(db, view.id)
for i, child in enumerate(children):
nid = region_canvas_node_id(child.id)
vn = placements.get(nid)
if vn is not None:
out.append(
region_node_out(
db,
folder=child,
x=float(vn.x or 0),
y=float(vn.y or 0),
locked=bool(vn.locked),
label=str(vn.label or child.name or ""),
)
)
else:
out.append(
region_node_out(
db,
folder=child,
x=auto_x + i * 40,
y=auto_y + i * 20,
locked=False,
label=str(child.name or ""),
)
)
return out