From c4911b6ddc6fa9241678a3fb934452c80b3f05af Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 7 Aug 2026 03:57:24 +0800 Subject: [PATCH] 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 --- netx_api/topology_fabric_nodes.py | 2 + netx_api/topology_schemas.py | 17 + netx_api/ume_topology_world_graph.py | 229 +++++++++--- tests/test_ume_hierarchy.py | 46 +++ web/src/i18n/en.ts | 2 + web/src/i18n/zh.ts | 1 + web/src/index.css | 199 +++++++--- web/src/pages/TopologyPage.tsx | 532 +++++++++++++++++++++++---- web/src/services/api.ts | 2 +- web/src/types.ts | 15 + 10 files changed, 876 insertions(+), 169 deletions(-) diff --git a/netx_api/topology_fabric_nodes.py b/netx_api/topology_fabric_nodes.py index 40014fb..8b2bd86 100644 --- a/netx_api/topology_fabric_nodes.py +++ b/netx_api/topology_fabric_nodes.py @@ -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, ) diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index d6baf25..0f93f7f 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -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): diff --git a/netx_api/ume_topology_world_graph.py b/netx_api/ume_topology_world_graph.py index de37aa4..07a7397 100644 --- a/netx_api/ume_topology_world_graph.py +++ b/netx_api/ume_topology_world_graph.py @@ -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, + ), ) diff --git a/tests/test_ume_hierarchy.py b/tests/test_ume_hierarchy.py index d6223ca..f2c7276 100644 --- a/tests/test_ume_hierarchy.py +++ b/tests/test_ume_hierarchy.py @@ -287,6 +287,52 @@ class UmeHierarchyTests(unittest.TestCase): ) self.assertEqual(ctx.exception.detail, "world_map_no_direct_nes") + def test_flat_lod_overview_and_detail(self): + from netx_api.ume_topology_world import get_world_flat_view + from netx_api.ume_topology_world_graph import ( + WORLD_FLAT_DETAIL_CAP, + WORLD_FLAT_OVERVIEW_CAP, + get_flat_view_graph, + ) + + self._seed_tree() + apply_ume_topology_to_fabric(self.db) + ensure_ume_world_and_sbn_folders(self.db) + recompute_flat_world_coords(self.db) + flat = get_world_flat_view(self.db) + self.assertIsNotNone(flat) + + overview = get_flat_view_graph(self.db, flat, lod="overview") + self.assertIsNotNone(overview.world_transform) + self.assertEqual(overview.world_transform.lod, "overview") + self.assertGreater(len(overview.nodes), 0) + self.assertLessEqual(len(overview.nodes), WORLD_FLAT_OVERVIEW_CAP) + self.assertEqual(len(overview.edges), 0) + self.assertEqual(float(overview.world_transform.scale or 0), 1.0) + # Intra-block spacing matches UME local deltas (1:1, not crushed). + a1 = next(n for n in overview.nodes if n.ume_ne_id == "me-a1" or n.name == "A1") + a2 = next(n for n in overview.nodes if n.ume_ne_id == "me-a2" or n.name == "A2") + self.assertAlmostEqual(float(a2.x) - float(a1.x), 40.0) + + # Viewport around an existing overview node (display coords). + hit = overview.nodes[0] + detail = get_flat_view_graph( + self.db, + flat, + lod="detail", + min_x=float(hit.x) - 50, + max_x=float(hit.x) + 50, + min_y=float(hit.y) - 50, + max_y=float(hit.y) + 50, + ) + self.assertEqual(detail.world_transform.lod, "detail") + self.assertGreater(len(detail.nodes), 0) + self.assertLessEqual(len(detail.nodes), WORLD_FLAT_DETAIL_CAP) + self.assertTrue(any(n.fabric_node_id == hit.fabric_node_id for n in detail.nodes)) + + auto = get_flat_view_graph(self.db, flat, lod="auto") + self.assertEqual(auto.world_transform.lod, "overview") + def test_flat_drag_override_survives_recompute(self): from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch from netx_api.topology_views_graph import patch_view_positions diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 454b7eb..ad5dc43 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1404,6 +1404,8 @@ const en = { treeSearchEmpty: "No matching NEs", treeSearchNoViews: "Not on any map", treeSearchNotOnMap: "This NE is not placed on any topology map yet", + locateNotOnCanvas: + "This NE is missing from the world-map sample and has no world coordinates. Open a region map instead.", treeSearchViewCount: "{{count}} map(s)", treeSearchTruncated: "Showing first 30 of {{total}}", rootName: "Roots", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 2498d2c..a4c3e1a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1395,6 +1395,7 @@ const zh = { treeSearchEmpty: "没有匹配的网元", treeSearchNoViews: "未上图", treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上", + locateNotOnCanvas: "当前世界地图抽样中没有该网元,且缺少世界坐标,请到下级区域画布查找。", treeSearchViewCount: "{{count}} 张图", treeSearchTruncated: "仅显示前 30 条(共 {{total}})", rootName: "根", diff --git a/web/src/index.css b/web/src/index.css index 4ed9365..1bf3b24 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -6408,13 +6408,13 @@ pre { } .topo-node.is-connect-mode .topo-node__handle { - width: 28px !important; - height: 28px !important; - min-width: 28px !important; - min-height: 28px !important; + width: 14px !important; + height: 14px !important; + min-width: 14px !important; + min-height: 14px !important; opacity: 0.9 !important; background: rgba(14, 165, 233, 0.35) !important; - border: 2px solid #38bdf8 !important; + border: 1px solid #38bdf8 !important; border-radius: 50% !important; z-index: 5; } @@ -7149,17 +7149,72 @@ pre { } .topo-node { - min-width: 72px; - max-width: 160px; + position: relative; + width: 80px; + min-width: 0; + max-width: none; + height: 25px; padding: 0; border: 0; background: transparent; box-shadow: none; - font-size: 11px; + font-size: 8px; text-align: center; --topo-icon-color: var(--topo-vendor-gray, #94a3b8); } +/* Flat-world LOD: pixel / pin overview (Google-Earth style). */ +.topo-node--dot, +.topo-node--pin { + min-width: 0; + max-width: none; + width: 100%; + height: 100%; + display: grid; + place-items: center; + color: var(--topo-icon-color, #94a3b8); + pointer-events: auto; +} + +.topo-node__pixel { + display: block; + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.35); +} + +.topo-node--dot.is-selected .topo-node__pixel { + width: 6px; + height: 6px; + box-shadow: 0 0 0 2px #38bdf8; +} + +.topo-node__pin { + display: block; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + border: 1px solid rgba(15, 23, 42, 0.45); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} + +.topo-node--pin.is-selected .topo-node__pin { + box-shadow: 0 0 0 2px #38bdf8; +} + +.topo-node__handle--dot { + opacity: 0 !important; + width: 1px !important; + height: 1px !important; + min-width: 0 !important; + min-height: 0 !important; + border: 0 !important; + pointer-events: none !important; +} + .topo-node.is-placeholder .topo-node__glyph { outline: 1px dashed rgba(148, 163, 184, 0.65); outline-offset: 2px; @@ -7168,16 +7223,16 @@ pre { .topo-node__badge { position: absolute; - top: -4px; - right: -6px; + top: -3px; + right: -4px; z-index: 2; - min-width: 22px; - padding: 1px 4px; - border-radius: 4px; + min-width: 14px; + padding: 0 2px; + border-radius: 2px; background: rgba(15, 23, 42, 0.92); border: 1px solid rgba(148, 163, 184, 0.45); color: #fbbf24; - font-size: 9px; + font-size: 6px; font-weight: 700; line-height: 1.2; text-transform: uppercase; @@ -7271,45 +7326,91 @@ pre { gap: 2px; } +.topo-node.is-region { + height: 17px; +} + .topo-node__glyph { position: relative; - width: 56px; - height: 56px; + width: 25px; + height: 25px; margin: 0 auto; } .topo-node.is-region .topo-node__glyph { - width: 56px; - height: 38px; -} - -.topo-node.is-region .topo-node__caption { - margin-top: 8px; + width: 25px; + height: 17px; } .topo-node__icon--region { - width: 56px; - height: 38px; + width: 25px; + height: 17px; } -.react-flow__node.is-search-hit .topo-node__glyph { - box-shadow: none; - outline: 2px solid rgba(245, 158, 11, 0.95); +.react-flow__node.is-search-hit { + z-index: 1000 !important; +} + +.react-flow__node.is-search-hit .topo-node { + --topo-icon-color: #fbbf24; + filter: drop-shadow(0 0 8px rgba(251, 191, 36, 0.95)); + animation: topo-search-flash 0.7s ease-in-out infinite; +} + +.react-flow__node.is-search-hit .topo-node__glyph, +.react-flow__node.is-search-hit .topo-node__pixel, +.react-flow__node.is-search-hit .topo-node__pin { + outline: 3px solid #fbbf24; outline-offset: 3px; - border-radius: 4px; - animation: topo-search-pulse 0.85s ease 2; + border-radius: 50%; } -@keyframes topo-search-pulse { +.react-flow__node.is-search-hit .topo-node__pixel, +.react-flow__node.is-search-hit .topo-node__pin { + background: #fbbf24 !important; + color: #fbbf24; + box-shadow: 0 0 10px rgba(251, 191, 36, 0.95); +} + +.react-flow__node.is-search-hit .topo-node__icon-tint { + background-color: #fbbf24 !important; +} + +.react-flow__node.is-search-hit .topo-node__caption { + opacity: 1; + z-index: 6; +} + +.react-flow__node.is-search-hit .topo-node__caption-name { + color: #fde68a; + font-weight: 700; + font-size: 10px; + max-width: 140px; + text-shadow: + 0 0 4px rgba(0, 0, 0, 1), + 0 0 8px rgba(251, 191, 36, 0.85); +} + +@keyframes topo-search-flash { 0%, 100% { - outline-color: rgba(245, 158, 11, 0.95); + filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.7)); + opacity: 1; + transform: scale(1); } 50% { - outline-color: rgba(245, 158, 11, 0.35); + filter: drop-shadow(0 0 14px rgba(251, 191, 36, 1)); + opacity: 0.72; + transform: scale(1.18); } } +.topo-node.is-selected .topo-node__glyph { + outline: 2px solid #38bdf8; + outline-offset: 2px; + border-radius: 4px; +} + .topo-node__handle { width: 10px !important; height: 10px !important; @@ -7333,12 +7434,6 @@ pre { transform: translate(-50%, -50%) !important; } -.topo-node.is-selected .topo-node__glyph { - outline: 2px solid #60a5fa; - outline-offset: 3px; - border-radius: 4px; -} - .topo-node.is-selected .topo-node__icon { transform: none; } @@ -7361,8 +7456,8 @@ pre { .topo-node__icon { position: relative; display: block; - width: 56px; - height: 56px; + width: 25px; + height: 25px; margin: 0; pointer-events: none; user-select: none; @@ -7395,25 +7490,31 @@ pre { } .topo-node__caption { + position: absolute; + left: 50%; + top: calc(100% + 1px); + transform: translateX(-50%); + z-index: 2; display: flex; flex-direction: column; align-items: center; - gap: 1px; - margin-top: 3px; + gap: 0; + margin-top: 0; color: var(--topo-label-color, #f1f5f9); - line-height: 1.2; - max-width: 160px; + line-height: 1.15; + width: max-content; + max-width: 80px; pointer-events: none; text-shadow: - 0 0 3px rgba(15, 23, 42, 0.95), - 0 1px 2px rgba(15, 23, 42, 0.85); + 0 0 2px rgba(15, 23, 42, 0.95), + 0 1px 1px rgba(15, 23, 42, 0.85); } .topo-node__caption-name { display: block; max-width: 100%; - font-size: 11px; - font-weight: 700; + font-size: 8px; + font-weight: 600; color: var(--topo-label-color, #f1f5f9); white-space: nowrap; overflow: hidden; @@ -7423,7 +7524,7 @@ pre { .topo-node__caption-meta { display: block; max-width: 100%; - font-size: 10px; + font-size: 7px; color: color-mix(in srgb, var(--topo-label-color, #f1f5f9) 55%, #64748b); font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; white-space: nowrap; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 2c1c4d0..aa7833e 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -81,6 +81,7 @@ import type { TopologyViewGraph, TopologyViewNodeItem, TopologyViewRole, + TopologyWorldTransform, } from "../types"; import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph"; import { ParallelEdge } from "./topology/ParallelEdge"; @@ -179,6 +180,13 @@ function isWorldFlatViewName(name: string | undefined | null): boolean { return String(name || "").trim() === "完整世界地图"; } +/** Visual LOD for 1:1 world coords (~1e5 span). fitView overview is ~0.01. */ +function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" { + if (zoom < 0.04) return "dot"; + if (zoom < 0.12) return "pin"; + return "full"; +} + /** * Region row IS the canvas for nested / UME SBN / World drill. * Top-level under Network root (and UME World container) are hex-nav only — same as UME World. @@ -238,6 +246,68 @@ const SNAP_GRID: [number, number] = [16, 16]; * skips those. includeHiddenNodes uses declared width/height so fit still works. */ const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const; +/** Zoom after locate on flat world — neighborhood visible with full icons, not a lone pin. */ +const WORLD_LOCATE_ZOOM = 0.22; +/** Half-extent (display/world units) fetched around the target on locate. */ +const WORLD_LOCATE_HALF = 12000; +/** Soft cap for accumulated flat-world tiles (Google-Earth style keep-alive). */ +const WORLD_FLAT_ACCUM_CAP = 8000; + +/** Merge newly fetched flat LOD into the existing cache so visited areas stay. */ +function mergeFlatWorldGraph( + prev: TopologyViewGraph | undefined, + next: TopologyViewGraph, + opts?: { centerX?: number; centerY?: number }, +): TopologyViewGraph { + if (!prev?.nodes?.length) return next; + if (!next?.nodes?.length) { + return { + ...prev, + world_transform: next.world_transform ?? prev.world_transform, + }; + } + + const nodeMap = new Map(); + for (const n of prev.nodes) nodeMap.set(String(n.fabric_node_id), n); + for (const n of next.nodes) nodeMap.set(String(n.fabric_node_id), n); + + const edgeMap = new Map(); + for (const e of prev.edges || []) edgeMap.set(String(e.id), e); + for (const e of next.edges || []) edgeMap.set(String(e.id), e); + + let nodes = [...nodeMap.values()]; + const cx = opts?.centerX; + const cy = opts?.centerY; + if (nodes.length > WORLD_FLAT_ACCUM_CAP && cx != null && cy != null) { + const prefer = new Set(next.nodes.map((n) => String(n.fabric_node_id))); + const kept = next.nodes.slice(); + const extras = nodes + .filter((n) => !prefer.has(String(n.fabric_node_id))) + .sort((a, b) => { + const da = (Number(a.x) - cx) ** 2 + (Number(a.y) - cy) ** 2; + const db = (Number(b.x) - cx) ** 2 + (Number(b.y) - cy) ** 2; + return da - db; + }); + const room = Math.max(0, WORLD_FLAT_ACCUM_CAP - kept.length); + nodes = kept.concat(extras.slice(0, room)); + } + + const idSet = new Set(nodes.map((n) => String(n.fabric_node_id))); + const edges = [...edgeMap.values()].filter( + (e) => idSet.has(String(e.a_node_id)) && idSet.has(String(e.b_node_id)), + ); + + return { + ...next, + view: next.view || prev.view, + nodes, + edges, + world_transform: next.world_transform ?? prev.world_transform, + truncated: Boolean(prev.truncated || next.truncated), + truncate_reason: next.truncate_reason || prev.truncate_reason || "", + outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers, + }; +} const UNDO_MAX = 40; const PALETTE_DND = "application/x-netx-topo-palette"; @@ -410,6 +480,8 @@ type TopoDisplayOpts = { connectMode: boolean; /** Show TOPO/LLDP placeholder corner badge on nodes. */ showPlaceholderBadge: boolean; + /** Flat world map visual LOD (Google-Earth style). */ + worldVisualLod: "dot" | "pin" | "full"; }; type CtxMenu = @@ -424,6 +496,7 @@ const TopoDisplayContext = createContext({ hidePorts: true, connectMode: false, showPlaceholderBadge: false, + worldVisualLod: "full", }); function newId(): string { @@ -533,25 +606,52 @@ function RegionCanvasIcon() { ); } -/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */ -const TOPO_NODE_W = 160; -const TOPO_NODE_H = 88; -/** Must match `.topo-node__glyph` (56×56, top-centered) + `--center` handles. */ -const TOPO_ICON = 56; +/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). + * Caption is absolutely positioned under the glyph so the RF box is icon-only. */ +const TOPO_NODE_W = 80; +const TOPO_NODE_H = 25; +/** Must match `.topo-node__glyph` (25×25, top-centered) + `--center` handles. */ +const TOPO_ICON = 25; /** region-building.png is 195×133 — keep aspect so icon center == handle. */ -const TOPO_REGION_ICON_H = Math.round((56 * 133) / 195); // 38 +const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195); const TOPO_HANDLE_X = TOPO_NODE_W / 2; const TOPO_HANDLE_Y = TOPO_ICON / 2; const TOPO_REGION_HANDLE_X = TOPO_NODE_W / 2; const TOPO_REGION_HANDLE_Y = TOPO_REGION_ICON_H / 2; -/** Region API x/y is icon center; RF position is top-left of the node box. */ +/** API x/y is icon center; RF position is top-left of the node box. */ +function iconFlowPosition( + apiX: number, + apiY: number, + handleX: number, + handleY: number, +): { x: number; y: number } { + return { x: apiX - handleX, y: apiY - handleY }; +} + +function iconApiPosition( + flowX: number, + flowY: number, + handleX: number, + handleY: number, +): { x: number; y: number } { + return { x: flowX + handleX, y: flowY + handleY }; +} + function regionFlowPosition(apiX: number, apiY: number): { x: number; y: number } { - return { x: apiX - TOPO_REGION_HANDLE_X, y: apiY - TOPO_REGION_HANDLE_Y }; + return iconFlowPosition(apiX, apiY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y); } function regionApiPosition(flowX: number, flowY: number): { x: number; y: number } { - return { x: flowX + TOPO_REGION_HANDLE_X, y: flowY + TOPO_REGION_HANDLE_Y }; + return iconApiPosition(flowX, flowY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y); +} + +function neFlowPosition(apiX: number, apiY: number): { x: number; y: number } { + return iconFlowPosition(apiX, apiY, TOPO_HANDLE_X, TOPO_HANDLE_Y); +} + +function neApiPosition(flowX: number, flowY: number): { x: number; y: number } { + return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y); } function isPlaceholderSource(source: string | undefined, neIp: string): boolean { @@ -561,7 +661,8 @@ function isPlaceholderSource(source: string | undefined, neIp: string): boolean } const NeNode = memo(function NeNode({ data, selected }: NodeProps>) { - const { hideIp, hideVendor, connectMode, showPlaceholderBadge } = useContext(TopoDisplayContext); + const { hideIp, hideVendor, connectMode, showPlaceholderBadge, worldVisualLod } = + useContext(TopoDisplayContext); const isRegion = data.kind === "region"; const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id); const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip); @@ -574,6 +675,32 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps + + +