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:
oliver 2026-08-07 03:57:24 +08:00
parent af331e92ca
commit c4911b6ddc
10 changed files with 876 additions and 169 deletions

View file

@ -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,
),
)