mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Add Google-Earth style world-map LOD with 1:1 coords and sticky tiles.
Keep visited regions when panning, prefetch detail on search locate with a gold flashing target highlight, and shrink default NE icons so dense UME layouts stay readable. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
af331e92ca
commit
c4911b6ddc
10 changed files with 876 additions and 169 deletions
|
|
@ -68,6 +68,8 @@ def _node_out(n: TopoFabricNode) -> FabricNodeOut:
|
|||
device_type=n.device_type or "",
|
||||
attrs=dict(n.attrs or {}),
|
||||
last_seen_at=n.last_seen_at,
|
||||
world_x=float(n.world_x) if n.world_x is not None else None,
|
||||
world_y=float(n.world_y) if n.world_y is not None else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ class FabricNodeOut(BaseModel):
|
|||
region_source: str = ""
|
||||
attrs: dict[str, Any] = Field(default_factory=dict)
|
||||
last_seen_at: datetime | None = None
|
||||
world_x: float | None = None
|
||||
world_y: float | None = None
|
||||
# Inventory link diagnostics (list/enrich); empty when not enriched.
|
||||
link_status: str = "" # managed | ume | both | orphaned
|
||||
managed_alive: bool = False
|
||||
|
|
@ -300,6 +302,20 @@ class ViewEdgeOut(BaseModel):
|
|||
discovered_at: datetime | None = None
|
||||
|
||||
|
||||
class WorldTransformOut(BaseModel):
|
||||
"""Flat world map: display = (world - origin) * scale. scale is 1.0 (1:1)."""
|
||||
|
||||
origin_x: float = 0.0
|
||||
origin_y: float = 0.0
|
||||
scale: float = 1.0
|
||||
full_min_x: float = 0.0
|
||||
full_max_x: float = 0.0
|
||||
full_min_y: float = 0.0
|
||||
full_max_y: float = 0.0
|
||||
total: int = 0
|
||||
lod: str = "overview"
|
||||
|
||||
|
||||
class TopologyViewGraphOut(BaseModel):
|
||||
view: TopologyViewOut
|
||||
nodes: list[ViewNodeOut]
|
||||
|
|
@ -307,6 +323,7 @@ class TopologyViewGraphOut(BaseModel):
|
|||
truncated: bool = False
|
||||
truncate_reason: str = ""
|
||||
outside_peers: list[dict[str, str]] = Field(default_factory=list)
|
||||
world_transform: WorldTransformOut | None = None
|
||||
|
||||
|
||||
class ViewPopulateOut(BaseModel):
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from .topology_schemas import (
|
|||
TopologyViewGraphOut,
|
||||
ViewEdgeOut,
|
||||
ViewNodeOut,
|
||||
WorldTransformOut,
|
||||
)
|
||||
from .topology_views_tree import _get_view_or_404, _view_out
|
||||
from .ume_topology_world import (
|
||||
|
|
@ -31,9 +32,20 @@ from .ume_topology_world import (
|
|||
is_world_view,
|
||||
)
|
||||
|
||||
# Keep flat-world responses at the same browser-safe hard cap as other views.
|
||||
# (Previously 8000 — loading that into React Flow + API RAM starved the whole host.)
|
||||
# Browser-safe caps. Flat world used to ship 2000 RF nodes (~1MB JSON) and freeze
|
||||
# the UI (fitView never settles; workbench feels stuck). Keep membership graphs at
|
||||
# the shared hard cap; flat overview/detail use LOD-specific caps.
|
||||
WORLD_NODE_SOFT_CAP = VIEW_GRAPH_NODE_HARD_CAP
|
||||
WORLD_FLAT_NODE_SOFT_CAP = 400 # legacy alias → detail
|
||||
WORLD_FLAT_OVERVIEW_CAP = 1500
|
||||
# Close-up must be able to show a whole large SBN like the region canvas (≤ hard cap).
|
||||
WORLD_FLAT_DETAIL_CAP = VIEW_GRAPH_NODE_HARD_CAP
|
||||
# Viewport span (display/world units) at/under which we prefer a full region layout
|
||||
# instead of stride-thinning — stops “zoomed in still a peppered blob”.
|
||||
WORLD_FLAT_CLOSE_SPAN = 28000.0
|
||||
# Legacy crush span (pre-LOD). Flat graph is now 1:1 with fabric world_*;
|
||||
# this constant only detects stale TopoViewNode overrides from that era.
|
||||
WORLD_FLAT_DISPLAY_SPAN = 6000.0
|
||||
|
||||
|
||||
def apply_persisted_view_positions(
|
||||
|
|
@ -98,6 +110,7 @@ def get_ume_canvas_graph(
|
|||
max_y=max_y,
|
||||
sbn_id=sbn_id,
|
||||
folder_id=folder_id,
|
||||
lod=lod,
|
||||
status=status,
|
||||
)
|
||||
return get_level_view_graph(db, view, status=status)
|
||||
|
|
@ -465,10 +478,16 @@ def get_flat_view_graph(
|
|||
max_y: float | None = None,
|
||||
sbn_id: str = "",
|
||||
folder_id: str = "",
|
||||
lod: str = "auto",
|
||||
status: str = "active",
|
||||
) -> TopologyViewGraphOut:
|
||||
"""All NEs using composed world_x/y — no region nodes."""
|
||||
"""Flat world map with LOD: overview (global dots) or detail (viewport sample).
|
||||
|
||||
Client bbox is in **display** coordinates (same as returned node x/y). Folder
|
||||
auto-bbox (when only folder_id is set) stays in packed world coordinates.
|
||||
"""
|
||||
region_folder_ids: set[str] | None = None
|
||||
folder_world_bbox: dict[str, float] | None = None
|
||||
if folder_id.strip():
|
||||
bbox = folder_bbox(db, folder_id.strip())
|
||||
folder = db.get(TopoFolder, folder_id.strip())
|
||||
|
|
@ -485,13 +504,13 @@ def get_flat_view_graph(
|
|||
if cid not in region_folder_ids:
|
||||
region_folder_ids.add(cid)
|
||||
stack.append(cid)
|
||||
if bbox and min_x is None:
|
||||
pad_x = max(50.0, (bbox["max_x"] - bbox["min_x"]) * 0.05)
|
||||
pad_y = max(50.0, (bbox["max_y"] - bbox["min_y"]) * 0.05)
|
||||
min_x = float(bbox["min_x"]) - pad_x
|
||||
max_x = float(bbox["max_x"]) + pad_x
|
||||
min_y = float(bbox["min_y"]) - pad_y
|
||||
max_y = float(bbox["max_y"]) + pad_y
|
||||
if bbox is not None:
|
||||
folder_world_bbox = {
|
||||
"min_x": float(bbox["min_x"]),
|
||||
"max_x": float(bbox["max_x"]),
|
||||
"min_y": float(bbox["min_y"]),
|
||||
"max_y": float(bbox["max_y"]),
|
||||
}
|
||||
|
||||
extent_row = (
|
||||
db.query(
|
||||
|
|
@ -504,68 +523,161 @@ def get_flat_view_graph(
|
|||
.filter(TopoFabricNode.world_x.isnot(None), TopoFabricNode.world_y.isnot(None))
|
||||
.one()
|
||||
)
|
||||
if not int(extent_row[4] or 0):
|
||||
total_all = int(extent_row[4] or 0)
|
||||
if not total_all:
|
||||
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
|
||||
|
||||
full_min_x = float(extent_row[0])
|
||||
full_max_x = float(extent_row[1])
|
||||
full_min_y = float(extent_row[2])
|
||||
full_max_y = float(extent_row[3])
|
||||
span_x = max(1.0, full_max_x - full_min_x)
|
||||
span_y = max(1.0, full_max_y - full_min_y)
|
||||
# 1:1 with fabric world_* (only origin-shift to ~0). Infinite zoom keeps
|
||||
# each packed region block identical to its UME local relative layout.
|
||||
scale = 1.0
|
||||
|
||||
if min_x is None or max_x is None or min_y is None or max_y is None:
|
||||
min_x, max_x, min_y, max_y = full_min_x, full_max_x, full_min_y, full_max_y
|
||||
client_bbox = (
|
||||
min_x is not None
|
||||
and max_x is not None
|
||||
and min_y is not None
|
||||
and max_y is not None
|
||||
)
|
||||
lod_norm = str(lod or "auto").strip().lower() or "auto"
|
||||
if lod_norm not in ("overview", "detail", "auto"):
|
||||
lod_norm = "auto"
|
||||
if lod_norm == "auto":
|
||||
lod_norm = "detail" if client_bbox else "overview"
|
||||
|
||||
if client_bbox:
|
||||
# Display == world - origin; pad detail viewport so pan feels continuous.
|
||||
pad = 0.2 if lod_norm == "detail" else 0.0
|
||||
dx0, dx1 = float(min_x), float(max_x)
|
||||
dy0, dy1 = float(min_y), float(max_y)
|
||||
if dx1 < dx0:
|
||||
dx0, dx1 = dx1, dx0
|
||||
if dy1 < dy0:
|
||||
dy0, dy1 = dy1, dy0
|
||||
dw = max(1.0, dx1 - dx0)
|
||||
dh = max(1.0, dy1 - dy0)
|
||||
dx0 -= dw * pad
|
||||
dx1 += dw * pad
|
||||
dy0 -= dh * pad
|
||||
dy1 += dh * pad
|
||||
w_min_x = dx0 / scale + full_min_x
|
||||
w_max_x = dx1 / scale + full_min_x
|
||||
w_min_y = dy0 / scale + full_min_y
|
||||
w_max_y = dy1 / scale + full_min_y
|
||||
elif folder_world_bbox is not None:
|
||||
pad_x = max(50.0, (folder_world_bbox["max_x"] - folder_world_bbox["min_x"]) * 0.05)
|
||||
pad_y = max(50.0, (folder_world_bbox["max_y"] - folder_world_bbox["min_y"]) * 0.05)
|
||||
w_min_x = folder_world_bbox["min_x"] - pad_x
|
||||
w_max_x = folder_world_bbox["max_x"] + pad_x
|
||||
w_min_y = folder_world_bbox["min_y"] - pad_y
|
||||
w_max_y = folder_world_bbox["max_y"] + pad_y
|
||||
else:
|
||||
w_min_x, w_max_x, w_min_y, w_max_y = full_min_x, full_max_x, full_min_y, full_max_y
|
||||
|
||||
q = db.query(TopoFabricNode).filter(
|
||||
TopoFabricNode.world_x.isnot(None),
|
||||
TopoFabricNode.world_y.isnot(None),
|
||||
TopoFabricNode.world_x >= min_x,
|
||||
TopoFabricNode.world_x <= max_x,
|
||||
TopoFabricNode.world_y >= min_y,
|
||||
TopoFabricNode.world_y <= max_y,
|
||||
TopoFabricNode.world_x >= w_min_x,
|
||||
TopoFabricNode.world_x <= w_max_x,
|
||||
TopoFabricNode.world_y >= w_min_y,
|
||||
TopoFabricNode.world_y <= w_max_y,
|
||||
)
|
||||
if region_folder_ids is not None:
|
||||
q = q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids)))
|
||||
|
||||
flat_cap = WORLD_FLAT_OVERVIEW_CAP if lod_norm == "overview" else WORLD_FLAT_DETAIL_CAP
|
||||
truncated = False
|
||||
reason = ""
|
||||
include_edges = lod_norm == "detail"
|
||||
# Unpadded display span of the client viewport (for close-up full-region load).
|
||||
view_span = 0.0
|
||||
if client_bbox:
|
||||
view_span = max(abs(float(max_x) - float(min_x)), abs(float(max_y) - float(min_y)))
|
||||
|
||||
if sbn_id.strip():
|
||||
sid = sbn_id.strip()
|
||||
# Attr JSON filter is rare; keep Python filter but only after a bounded fetch.
|
||||
candidates = q.order_by(TopoFabricNode.id.asc()).limit(WORLD_NODE_SOFT_CAP * 5).all()
|
||||
candidates = q.order_by(TopoFabricNode.id.asc()).limit(flat_cap * 5).all()
|
||||
nodes = [
|
||||
n
|
||||
for n in candidates
|
||||
if str((n.attrs or {}).get("ume_sbn_id") or "") == sid
|
||||
or str(n.region_folder_id or "") == sid
|
||||
]
|
||||
if len(nodes) > WORLD_NODE_SOFT_CAP:
|
||||
nodes = nodes[:WORLD_NODE_SOFT_CAP]
|
||||
if len(nodes) > flat_cap:
|
||||
nodes = nodes[:flat_cap]
|
||||
truncated = True
|
||||
reason = "too_many_viewport_nodes"
|
||||
else:
|
||||
# Cap so we never hydrate 15k ORM rows. Stride across world-order so the
|
||||
# overview is spread out (id-prefix order clumps one corner of the map).
|
||||
id_rows = (
|
||||
q.with_entities(TopoFabricNode.id)
|
||||
.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc())
|
||||
.all()
|
||||
total = int(q.with_entities(func.count(TopoFabricNode.id)).scalar() or 0)
|
||||
close_up = (
|
||||
lod_norm == "detail"
|
||||
and client_bbox
|
||||
and view_span > 0
|
||||
and view_span <= WORLD_FLAT_CLOSE_SPAN
|
||||
)
|
||||
total = len(id_rows)
|
||||
if total <= WORLD_NODE_SOFT_CAP:
|
||||
pick_ids = [str(r[0]) for r in id_rows]
|
||||
if total <= flat_cap:
|
||||
nodes = q.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc()).all()
|
||||
elif close_up:
|
||||
# Zoomed into ~one region: load the dominant SBN in full so layout
|
||||
# matches that region's canvas (no stride peppering).
|
||||
in_view = q.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc()).all()
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
for n in in_view:
|
||||
counts[str((n.attrs or {}).get("ume_sbn_id") or "").strip() or "_"] += 1
|
||||
dominant = max(counts.items(), key=lambda kv: (kv[1], kv[0]))[0] if counts else ""
|
||||
if dominant and dominant != "_" and counts[dominant] >= max(3, len(in_view) // 5):
|
||||
me_uids = [
|
||||
str(r[0])
|
||||
for r in db.query(UmeTopoNode.ume_ne_id)
|
||||
.filter(
|
||||
UmeTopoNode.node_type == "TOPO_NODE_ME",
|
||||
UmeTopoNode.parent_node == dominant,
|
||||
UmeTopoNode.ume_ne_id.isnot(None),
|
||||
)
|
||||
.all()
|
||||
if str(r[0] or "").strip()
|
||||
]
|
||||
nodes = (
|
||||
db.query(TopoFabricNode)
|
||||
.filter(
|
||||
TopoFabricNode.ume_ne_id.in_(me_uids),
|
||||
TopoFabricNode.world_x.isnot(None),
|
||||
TopoFabricNode.world_y.isnot(None),
|
||||
)
|
||||
.all()
|
||||
if me_uids
|
||||
else []
|
||||
)
|
||||
nodes.sort(key=lambda n: (float(n.world_x or 0), float(n.world_y or 0), n.id))
|
||||
if len(nodes) > flat_cap:
|
||||
nodes = nodes[:flat_cap]
|
||||
truncated = True
|
||||
reason = "too_many_viewport_nodes"
|
||||
else:
|
||||
nodes = in_view[:flat_cap]
|
||||
truncated = len(in_view) > flat_cap
|
||||
reason = "too_many_viewport_nodes" if truncated else ""
|
||||
else:
|
||||
stride = max(1, total // WORLD_NODE_SOFT_CAP)
|
||||
pick_ids = [str(r[0]) for r in id_rows[::stride][:WORLD_NODE_SOFT_CAP]]
|
||||
id_rows = (
|
||||
q.with_entities(TopoFabricNode.id)
|
||||
.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc())
|
||||
.all()
|
||||
)
|
||||
stride = max(1, total // flat_cap)
|
||||
pick_ids = [str(r[0]) for r in id_rows[::stride][:flat_cap]]
|
||||
nodes = (
|
||||
db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(pick_ids)).all()
|
||||
if pick_ids
|
||||
else []
|
||||
)
|
||||
order = {nid: i for i, nid in enumerate(pick_ids)}
|
||||
nodes.sort(key=lambda n: order.get(str(n.id), 0))
|
||||
truncated = True
|
||||
reason = "too_many_viewport_nodes"
|
||||
nodes = (
|
||||
db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(pick_ids)).all()
|
||||
if pick_ids
|
||||
else []
|
||||
)
|
||||
# Preserve world-order for stable layout / fitView.
|
||||
order = {nid: i for i, nid in enumerate(pick_ids)}
|
||||
nodes.sort(key=lambda n: order.get(str(n.id), 0))
|
||||
|
||||
fids = [n.id for n in nodes]
|
||||
fid_set = set(fids)
|
||||
|
|
@ -575,8 +687,8 @@ def get_flat_view_graph(
|
|||
managed_ne_id=str(n.managed_ne_id or ""),
|
||||
ume_ne_id=str(n.ume_ne_id or ""),
|
||||
label=(n.name or n.ip or n.id)[:256],
|
||||
x=float(n.world_x or 0),
|
||||
y=float(n.world_y or 0),
|
||||
x=(float(n.world_x or 0) - full_min_x) * scale,
|
||||
y=(float(n.world_y or 0) - full_min_y) * scale,
|
||||
locked=False,
|
||||
name=n.name or "",
|
||||
ip=n.ip or "",
|
||||
|
|
@ -586,10 +698,28 @@ def get_flat_view_graph(
|
|||
)
|
||||
for n in nodes
|
||||
]
|
||||
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
|
||||
# Drag overrides in display space (world - origin). Skip legacy rows saved
|
||||
# under the old ~6000 crush — they would yank nodes to the wrong place.
|
||||
override_rows = (
|
||||
db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).limit(8).all()
|
||||
)
|
||||
display_span = max(span_x, span_y)
|
||||
overrides_ok = False
|
||||
if override_rows and display_span > 0:
|
||||
oxs = [abs(float(r.x or 0)) for r in override_rows]
|
||||
oys = [abs(float(r.y or 0)) for r in override_rows]
|
||||
peak = max(oxs + oys) if (oxs or oys) else 0.0
|
||||
legacy_crush = display_span > WORLD_FLAT_DISPLAY_SPAN * 2 and peak <= WORLD_FLAT_DISPLAY_SPAN * 1.5
|
||||
overrides_ok = (not legacy_crush) and all(
|
||||
0.0 <= float(r.x or 0) <= display_span * 1.2
|
||||
and 0.0 <= float(r.y or 0) <= display_span * 1.2
|
||||
for r in override_rows
|
||||
)
|
||||
if overrides_ok:
|
||||
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
|
||||
|
||||
edges_out: list[ViewEdgeOut] = []
|
||||
if fids:
|
||||
if include_edges and fids:
|
||||
eq = db.query(TopoFabricEdge).filter(
|
||||
TopoFabricEdge.layer == "physical",
|
||||
TopoFabricEdge.a_node_id.in_(fids),
|
||||
|
|
@ -630,4 +760,15 @@ def get_flat_view_graph(
|
|||
edges=edges_out,
|
||||
truncated=truncated,
|
||||
truncate_reason=reason,
|
||||
world_transform=WorldTransformOut(
|
||||
origin_x=full_min_x,
|
||||
origin_y=full_min_y,
|
||||
scale=scale,
|
||||
full_min_x=full_min_x,
|
||||
full_max_x=full_max_x,
|
||||
full_min_y=full_min_y,
|
||||
full_max_y=full_max_y,
|
||||
total=total_all,
|
||||
lod=lod_norm,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue