diff --git a/netx_api/ume_topology_world_graph.py b/netx_api/ume_topology_world_graph.py index 2f2e045..de37aa4 100644 --- a/netx_api/ume_topology_world_graph.py +++ b/netx_api/ume_topology_world_graph.py @@ -525,6 +525,9 @@ def get_flat_view_graph( ) if region_folder_ids is not None: q = q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids))) + + truncated = False + reason = "" if sbn_id.strip(): sid = sbn_id.strip() # Attr JSON filter is rare; keep Python filter but only after a bounded fetch. @@ -535,16 +538,34 @@ def get_flat_view_graph( 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] + truncated = True + reason = "too_many_viewport_nodes" else: - # Cap in SQL so we never hydrate 15k ORM rows for a single browser canvas. - nodes = q.order_by(TopoFabricNode.id.asc()).limit(WORLD_NODE_SOFT_CAP + 1).all() - - truncated = False - reason = "" - if len(nodes) > WORLD_NODE_SOFT_CAP: - nodes = nodes[:WORLD_NODE_SOFT_CAP] - truncated = True - reason = "too_many_viewport_nodes" + # 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 = len(id_rows) + if total <= WORLD_NODE_SOFT_CAP: + pick_ids = [str(r[0]) for r in id_rows] + else: + stride = max(1, total // WORLD_NODE_SOFT_CAP) + pick_ids = [str(r[0]) for r in id_rows[::stride][:WORLD_NODE_SOFT_CAP]] + 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) diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 1e24e12..2c1c4d0 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -1095,6 +1095,8 @@ export function TopologyPage() { const dirtyRef = useRef(false); const appliedMapIdRef = useRef(""); const appliedGraphFpRef = useRef(""); + /** Fit once after graph hydrate — world coords are far outside the default viewport. */ + const pendingFitRef = useRef(false); const nodesRef = useRef[]>([]); const historyRef = useRef([]); const redoRef = useRef([]); @@ -1337,7 +1339,10 @@ export function TopologyPage() { return { drill, flatView }; }, [activeRegion]); - const isWorldFlatCanvas = isWorldFlatViewName(activeView?.name); + const isWorldFlatCanvas = + isWorldFlatViewName(activeView?.name) || + isWorldFlatViewName(graphQuery.data?.view?.name) || + Boolean(graphQuery.data?.view?.filter?.world_flat); // Resolve World drill view id for shortcuts; do not auto-open canvas. useEffect(() => { @@ -1587,11 +1592,34 @@ export function TopologyPage() { pendingEdgeCreatesRef.current = new Set(); clearDirty(); bumpHistory(); - // Do not auto-fit on canvas switch — user clicks 适应画布. + // World / large canvases use absolute coords far outside the default RF + // viewport — without an initial fit the canvas looks empty after load. + pendingFitRef.current = nextNodes.length > 0; } historyLockRef.current = false; }, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]); + useEffect(() => { + if (!pendingFitRef.current || !nodes.length) return; + let cancelled = false; + let tries = 0; + const run = () => { + if (cancelled) return; + const inst = rfRef.current; + if (!inst) { + if (tries++ < 20) window.setTimeout(run, 50); + return; + } + pendingFitRef.current = false; + inst.fitView({ ...FIT_VIEW_OPTS, duration: 200 }); + }; + const t = window.setTimeout(run, 40); + return () => { + cancelled = true; + window.clearTimeout(t); + }; + }, [nodes, mapId]); + useEffect(() => { setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults))); }, [edgeDefaults, setEdges]); @@ -3495,13 +3523,15 @@ export function TopologyPage() { const truncateBannerText = useMemo(() => { if (!graphTruncated) return ""; // World map soft-caps viewport nodes; the banner is noise there. - if (isWorldFlatViewName(activeView?.name)) return ""; + if (isWorldFlatViewName(activeView?.name) || Boolean(graphQuery.data?.view?.filter?.world_flat)) { + return ""; + } if (truncateReason === "membership_cap") return t("topology.truncatedMembership"); if (truncateReason === "membership_frozen") return t("topology.truncatedFrozen"); if (truncateReason === "too_many_view_nodes") return t("topology.truncatedNodes"); if (truncateReason === "too_many_edges") return t("topology.truncatedEdges"); return t("topology.truncatedGeneric"); - }, [graphTruncated, truncateReason, activeView?.name, t]); + }, [graphTruncated, truncateReason, activeView?.name, graphQuery.data?.view?.filter?.world_flat, t]); const activeLeafName = useMemo(() => { if (!mapId) return ""; if (activeView?.name) return activeView.name; @@ -4822,7 +4852,7 @@ export function TopologyPage() { connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }} defaultEdgeOptions={{ type: "straight", labelShowBg: false }} proOptions={{ hideAttribution: true }} - minZoom={0.05} + minZoom={isWorldFlatCanvas ? 0.005 : 0.05} maxZoom={4} nodesDraggable={toolBehavior.nodesDraggable} nodesConnectable={toolBehavior.nodesConnectable}