diff --git a/netx_api/topology_region_canvas.py b/netx_api/topology_region_canvas.py index bfa6f4c..22f6be9 100644 --- a/netx_api/topology_region_canvas.py +++ b/netx_api/topology_region_canvas.py @@ -34,7 +34,9 @@ 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() == "完整世界地图" + from .ume_topology_world import is_world_flat_view_name + + return is_world_flat_view_name(str(view.name or "").strip()) def primary_canvas_view(db: Session, folder_id: str) -> TopoView | None: diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 0f93f7f..367ac46 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -302,6 +302,13 @@ class ViewEdgeOut(BaseModel): discovered_at: datetime | None = None +class WorldScatterPointOut(BaseModel): + """Lightweight display-space point for world-map starfield (no labels/edges).""" + + x: float = 0.0 + y: float = 0.0 + + class WorldTransformOut(BaseModel): """Flat world map: display = (world - origin) * scale. scale is 1.0 (1:1).""" @@ -314,6 +321,8 @@ class WorldTransformOut(BaseModel): full_max_y: float = 0.0 total: int = 0 lod: str = "overview" + # Dock ME count when fabric world coords are empty — UI heal hint. + dock_me_count: int = 0 class TopologyViewGraphOut(BaseModel): @@ -324,6 +333,8 @@ class TopologyViewGraphOut(BaseModel): truncate_reason: str = "" outside_peers: list[dict[str, str]] = Field(default_factory=list) world_transform: WorldTransformOut | None = None + # Overview starfield: all (or capped) display coords; RF nodes stay empty far out. + scatter: list[WorldScatterPointOut] = Field(default_factory=list) class ViewPopulateOut(BaseModel): diff --git a/netx_api/ume_topology_world.py b/netx_api/ume_topology_world.py index 9dbe607..a4544b9 100644 --- a/netx_api/ume_topology_world.py +++ b/netx_api/ume_topology_world.py @@ -20,12 +20,19 @@ WORLD_DRILL_FOLDER_NAME = "World" WORLD_FOLDER_REF = "ume:world" WORLD_DRILL_REF = "ume:world:drill" WORLD_VIEW_NAME = "World" -WORLD_FLAT_VIEW_NAME = "完整世界地图" +WORLD_FLAT_VIEW_NAME = "世界地图" +# Pre-rename label kept so existing TopoView rows still match. +WORLD_FLAT_VIEW_NAME_LEGACY = "完整世界地图" # Drill-down root: children of MD (root SBNs + rare direct MEs). WORLD_LEVEL_FILTER = {"ume_level": True, "parent": "md"} WORLD_FLAT_FILTER = {"world_flat": True} +def is_world_flat_view_name(name: str | None) -> bool: + n = str(name or "").strip() + return n == WORLD_FLAT_VIEW_NAME or n == WORLD_FLAT_VIEW_NAME_LEGACY + + def _filt(view: TopoView | None) -> dict[str, Any]: if view is None: return {} @@ -40,7 +47,7 @@ def is_ume_level_view(view: TopoView | None) -> bool: def is_world_flat_view(view: TopoView | None) -> bool: filt = _filt(view) - return bool(filt.get("world_flat")) or str(getattr(view, "name", "") or "") == WORLD_FLAT_VIEW_NAME + return bool(filt.get("world_flat")) or is_world_flat_view_name(str(getattr(view, "name", "") or "")) def is_ume_world_container(folder: TopoFolder | None) -> bool: @@ -326,7 +333,7 @@ def ensure_ume_world_and_sbn_folders(db: Session) -> dict[str, Any]: UME World (nav only — hex browse) ├─ World (unique L2 folder ume:world:drill — SBNs hang here) │ └─ - └─ 完整世界地图 (flat view — only when rule 2A holds) + └─ 世界地图 (flat view — only when rule 2A holds) """ now = _utcnow() stats: dict[str, Any] = { diff --git a/netx_api/ume_topology_world_graph.py b/netx_api/ume_topology_world_graph.py index 07a7397..135b51d 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, + WorldScatterPointOut, WorldTransformOut, ) from .topology_views_tree import _get_view_or_404, _view_out @@ -34,10 +35,12 @@ from .ume_topology_world import ( # 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. +# the shared hard cap; flat overview uses a lightweight scatter starfield (all +# coords) while detail uses viewport RF nodes. WORLD_NODE_SOFT_CAP = VIEW_GRAPH_NODE_HARD_CAP WORLD_FLAT_NODE_SOFT_CAP = 400 # legacy alias → detail -WORLD_FLAT_OVERVIEW_CAP = 1500 +WORLD_FLAT_OVERVIEW_CAP = 1500 # legacy RF sample; overview now prefers scatter +WORLD_FLAT_SCATTER_CAP = 40000 # 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 @@ -481,7 +484,7 @@ def get_flat_view_graph( lod: str = "auto", status: str = "active", ) -> TopologyViewGraphOut: - """Flat world map with LOD: overview (global dots) or detail (viewport sample). + """Flat world map with LOD: overview (scatter starfield) or detail (viewport RF). 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. @@ -524,8 +527,27 @@ def get_flat_view_graph( .one() ) total_all = int(extent_row[4] or 0) + dock_me_count = 0 if not total_all: - return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[]) + try: + dock_me_count = int( + db.query(func.count(UmeTopoNode.node_id)) + .filter(UmeTopoNode.node_type == "TOPO_NODE_ME") + .scalar() + or 0 + ) + except Exception: + dock_me_count = 0 + return TopologyViewGraphOut( + view=_view_out(view), + nodes=[], + edges=[], + world_transform=WorldTransformOut( + total=0, + lod=str(lod or "overview").strip().lower() or "overview", + dock_me_count=dock_me_count, + ), + ) full_min_x = float(extent_row[0]) full_max_x = float(extent_row[1]) @@ -549,6 +571,54 @@ def get_flat_view_graph( if lod_norm == "auto": lod_norm = "detail" if client_bbox else "overview" + def _world_transform(*, total: int | None = None) -> WorldTransformOut: + return 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=int(total if total is not None else total_all), + lod=lod_norm, + dock_me_count=0, + ) + + # Overview: lightweight starfield only (no RF nodes / edges). Screen-space + # canvas draws these so fitView zoom no longer shrinks dots to sub-pixels. + if lod_norm == "overview" and not sbn_id.strip(): + scatter_q = db.query(TopoFabricNode.world_x, TopoFabricNode.world_y).filter( + TopoFabricNode.world_x.isnot(None), + TopoFabricNode.world_y.isnot(None), + ) + if region_folder_ids is not None: + scatter_q = scatter_q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids))) + rows = ( + scatter_q.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc()) + .limit(WORLD_FLAT_SCATTER_CAP + 1) + .all() + ) + truncated = len(rows) > WORLD_FLAT_SCATTER_CAP + if truncated: + rows = rows[:WORLD_FLAT_SCATTER_CAP] + scatter = [ + WorldScatterPointOut( + x=(float(wx or 0) - full_min_x) * scale, + y=(float(wy or 0) - full_min_y) * scale, + ) + for wx, wy in rows + ] + return TopologyViewGraphOut( + view=_view_out(view), + nodes=[], + edges=[], + truncated=truncated, + truncate_reason="too_many_scatter_points" if truncated else "", + world_transform=_world_transform(total=total_all), + scatter=scatter, + ) + if client_bbox: # Display == world - origin; pad detail viewport so pan feels continuous. pad = 0.2 if lod_norm == "detail" else 0.0 @@ -760,15 +830,6 @@ 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, - ), + world_transform=_world_transform(), + scatter=[], ) diff --git a/tests/test_ume_hierarchy.py b/tests/test_ume_hierarchy.py index f2c7276..30e3ba0 100644 --- a/tests/test_ume_hierarchy.py +++ b/tests/test_ume_hierarchy.py @@ -267,6 +267,18 @@ class UmeHierarchyTests(unittest.TestCase): drill = [c for c in region_kids if c.external_ref == "ume:world:drill" or c.name == "World"] self.assertEqual(len(drill), 1) + def test_world_flat_view_name_legacy_compat(self): + from netx_api.ume_topology_world import ( + WORLD_FLAT_VIEW_NAME, + WORLD_FLAT_VIEW_NAME_LEGACY, + is_world_flat_view_name, + ) + + self.assertEqual(WORLD_FLAT_VIEW_NAME, "世界地图") + self.assertTrue(is_world_flat_view_name(WORLD_FLAT_VIEW_NAME)) + self.assertTrue(is_world_flat_view_name(WORLD_FLAT_VIEW_NAME_LEGACY)) + self.assertFalse(is_world_flat_view_name("World")) + def test_world_map_forbids_add_nes(self): from fastapi import HTTPException @@ -291,7 +303,6 @@ class UmeHierarchyTests(unittest.TestCase): 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, ) @@ -305,17 +316,14 @@ class UmeHierarchyTests(unittest.TestCase): 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.nodes), 0) + self.assertGreater(len(overview.scatter), 0) + self.assertEqual(len(overview.scatter), int(overview.world_transform.total or 0)) 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 viewport around the first scatter point (display coords). + hit = overview.scatter[0] detail = get_flat_view_graph( self.db, flat, @@ -328,10 +336,14 @@ class UmeHierarchyTests(unittest.TestCase): 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)) + # Intra-block spacing matches UME local deltas (1:1, not crushed). + a1 = next(n for n in detail.nodes if n.ume_ne_id == "me-a1" or n.name == "A1") + a2 = next(n for n in detail.nodes if n.ume_ne_id == "me-a2" or n.name == "A2") + self.assertAlmostEqual(float(a2.x) - float(a1.x), 40.0) auto = get_flat_view_graph(self.db, flat, lod="auto") self.assertEqual(auto.world_transform.lod, "overview") + self.assertGreater(len(auto.scatter), 0) def test_flat_drag_override_survives_recompute(self): from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch @@ -344,22 +356,43 @@ class UmeHierarchyTests(unittest.TestCase): ensure_ume_world_and_sbn_folders(self.db) flat = get_world_flat_view(self.db) self.assertIsNotNone(flat) - g0 = get_flat_view_graph(self.db, flat) + overview = get_flat_view_graph(self.db, flat, lod="overview") + self.assertGreater(len(overview.scatter), 0) + # Need RF node payloads for drag overrides — use a wide detail window. + g0 = get_flat_view_graph( + self.db, + flat, + lod="detail", + min_x=-1e9, + max_x=1e9, + min_y=-1e9, + max_y=1e9, + ) self.assertGreater(len(g0.nodes), 0) target = g0.nodes[0] + ox = float(target.x) + 17.0 + oy = float(target.y) + 23.0 patch_view_positions( self.db, flat.id, ViewPositionsPatch( - positions=[ViewNodeIn(fabric_node_id=target.fabric_node_id, x=1111.0, y=2222.0)], + positions=[ViewNodeIn(fabric_node_id=target.fabric_node_id, x=ox, y=oy)], return_graph=False, ), ) recompute_flat_world_coords(self.db) - g1 = get_flat_view_graph(self.db, flat) + g1 = get_flat_view_graph( + self.db, + flat, + lod="detail", + min_x=-1e9, + max_x=1e9, + min_y=-1e9, + max_y=1e9, + ) moved = next(n for n in g1.nodes if n.fabric_node_id == target.fabric_node_id) - self.assertEqual(moved.x, 1111.0) - self.assertEqual(moved.y, 2222.0) + self.assertEqual(moved.x, ox) + self.assertEqual(moved.y, oy) def test_create_folder_under_ume_world_remounts_to_drill(self): from netx_api.topology_schemas import TopologyFolderCreate diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f82205d..ba1f42d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1440,8 +1440,15 @@ const en = { newSubRegion: "New sub-region", newSubRegionName: "New sub-region", newSubRegionPrompt: "Sub-region name", - subRegionNotOnFlat: "The flat world map has no regions. Create sub-regions on World or a lower-level canvas.", + subRegionNotOnFlat: "The world map has no regions. Create sub-regions on World or a lower-level canvas.", worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.", + worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}", + worldEmptyCoords: "World map has no coordinates yet", + worldEmptyCoordsHint: + "Dock has {{dock}} NEs, but Fabric / world coords are empty. Apply without pulling UME.", + worldApply: "Apply to world map", + worldApplying: "Applying…", + worldApplyOk: "Applied to world map", subRegionNeedParent: "Open a root map or region canvas first, then create a sub-region.", regionCreated: "Created", regionDeleted: "Deleted", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index cc50aec..694e190 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1430,8 +1430,14 @@ const zh = { newSubRegion: "新建子区域", newSubRegionName: "新子区域", newSubRegionPrompt: "子区域名称", - subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。", + subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。", worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。", + worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}", + worldEmptyCoords: "世界地图还没有坐标", + worldEmptyCoordsHint: "码头已有 {{dock}} 个网元,但尚未灌入 Fabric / 世界坐标。可一键应用(不拉 UME)。", + worldApply: "应用到世界地图", + worldApplying: "正在应用…", + worldApplyOk: "已应用到世界地图", subRegionNeedParent: "请先打开根图或区域画布,再新建子区域。", regionCreated: "已创建", regionDeleted: "已删除", diff --git a/web/src/index.css b/web/src/index.css index e1d25c0..eae77a7 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -7183,6 +7183,40 @@ pre { pointer-events: auto; } +.topo-world-hud { + position: absolute; + z-index: 6; + top: 10px; + left: 12px; + max-width: min(520px, calc(100% - 24px)); + padding: 4px 8px; + border-radius: 6px; + background: rgba(15, 23, 42, 0.72); + color: #cbd5e1; + font-size: 11px; + line-height: 1.35; + pointer-events: none; + backdrop-filter: blur(2px); +} + +.topo-world-scatter { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + pointer-events: none; +} + +.topo-world-scatter-panel.react-flow__panel { + margin: 0 !important; +} + +.topo-canvas__overlay--empty.is-interactive { + pointer-events: auto; + background: rgba(11, 18, 32, 0.35); +} + .topo-node__pixel { display: block; width: 4px; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 4236538..c1ae5e4 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -62,12 +62,14 @@ import { updateLldpCollectPolicy, updateTopologyFolder, updateTopologyMap, + applyUmeTopologyToFabric, } from "../services/api"; import { queryKeys } from "../constants/queryKeys"; import { HelpHint } from "../components/HelpHint"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { openOrFocusModule } from "../utils/moduleWindows"; +import { WorldScatterLayer } from "./topology/WorldScatterLayer"; import type { FabricNodeSearchHit, TopologyDiscoverJob, @@ -177,7 +179,8 @@ function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined): } function isWorldFlatViewName(name: string | undefined | null): boolean { - return String(name || "").trim() === "完整世界地图"; + const n = String(name || "").trim(); + return n === "世界地图" || n === "完整世界地图"; } /** Visual LOD for 1:1 world coords (~1e5 span). fitView overview is ~0.01. */ @@ -187,6 +190,20 @@ function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" { return "full"; } +function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): { + x: number; + y: number; + width: number; + height: number; +} | null { + if (!wt) return null; + const scale = Number(wt.scale) || 1; + const width = Math.max(1, (Number(wt.full_max_x) - Number(wt.full_min_x)) * scale); + const height = Math.max(1, (Number(wt.full_max_y) - Number(wt.full_min_y)) * scale); + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + return { x: 0, y: 0, width, height }; +} + /** * 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. @@ -303,6 +320,7 @@ function mergeFlatWorldGraph( nodes, edges, world_transform: next.world_transform ?? prev.world_transform, + scatter: next.scatter?.length ? next.scatter : prev.scatter, 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, @@ -1103,7 +1121,7 @@ function graphFingerprint(graph: TopologyViewGraph): string { .map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`) .sort() .join("|"); - return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}`; + return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}#${graph.scatter?.length || 0}#${graph.world_transform?.total || 0}`; } function applyViewGraph( @@ -1400,17 +1418,30 @@ export function TopologyPage() { ); }; try { - // Far out: keep accumulated detail tiles — don't wipe with a sparse overview. - if (zoom < 0.025) { + // Far / mid zoom: starfield scatter only (screen-space canvas). Drop heavy RF tiles. + if (zoom < 0.12) { const prev = queryClient.getQueryData(queryKeys.topologyGraph(mapId)); - if (prev && prev.nodes.length > 1600) return; - const g = await fetchTopologyGraph(mapId, { lod: "overview" }); - if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return; - const cx = - g.nodes.reduce((s, n) => s + Number(n.x || 0), 0) / Math.max(1, g.nodes.length); - const cy = - g.nodes.reduce((s, n) => s + Number(n.y || 0), 0) / Math.max(1, g.nodes.length); - commit(g, cx, cy); + if (!prev?.scatter?.length) { + const g = await fetchTopologyGraph(mapId, { lod: "overview" }); + if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return; + const bounds = worldDisplayBounds(g.world_transform); + const cx = bounds ? bounds.x + bounds.width / 2 : 0; + const cy = bounds ? bounds.y + bounds.height / 2 : 0; + commit(g, cx, cy); + } else if (prev.nodes.length > 0 && zoom < 0.04) { + commit( + { + ...prev, + nodes: [], + edges: [], + world_transform: prev.world_transform + ? { ...prev.world_transform, lod: "overview" } + : prev.world_transform, + }, + 0, + 0, + ); + } return; } const pane = canvasRef.current?.getBoundingClientRect(); @@ -1445,6 +1476,17 @@ export function TopologyPage() { }, 200); }, [isWorldFlatCanvas, refreshFlatViewport]); + const fitCanvas = useCallback(() => { + const inst = rfRef.current; + if (!inst) return; + const bounds = isWorldFlatCanvas + ? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform) + : null; + if (bounds) inst.fitBounds(bounds, { padding: 0.12, duration: 0 }); + else inst.fitView({ ...FIT_VIEW_OPTS }); + setCanvasZoom(inst.getZoom()); + }, [isWorldFlatCanvas, graphQuery.data?.world_transform]); + useEffect(() => { return () => { if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current); @@ -1828,13 +1870,19 @@ export function TopologyPage() { bumpHistory(); // 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; + const hasScatter = (graphQuery.data.scatter?.length || 0) > 0; + const hasWorld = Boolean(worldDisplayBounds(graphQuery.data.world_transform)); + pendingFitRef.current = nextNodes.length > 0 || hasScatter || hasWorld; } historyLockRef.current = false; }, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]); useEffect(() => { - if (!pendingFitRef.current || !nodes.length) return; + if (!pendingFitRef.current) return; + const wt = graphQuery.data?.world_transform; + const bounds = isWorldFlatCanvas ? worldDisplayBounds(wt) : null; + const canFitNodes = nodes.length > 0; + if (!bounds && !canFitNodes) return; let cancelled = false; let tries = 0; const run = () => { @@ -1845,8 +1893,12 @@ export function TopologyPage() { return; } pendingFitRef.current = false; - // duration 0 — animated fit with 400+ nodes often never paints the first frame. - inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 }); + if (bounds) { + inst.fitBounds(bounds, { padding: 0.12, duration: 0 }); + } else { + // duration 0 — animated fit with 400+ nodes often never paints the first frame. + inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 }); + } setCanvasZoom(inst.getZoom()); }; const t = window.setTimeout(run, 0); @@ -1854,7 +1906,7 @@ export function TopologyPage() { cancelled = true; window.clearTimeout(t); }; - }, [nodes, mapId]); + }, [nodes, mapId, isWorldFlatCanvas, graphQuery.data?.world_transform]); useEffect(() => { setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults))); @@ -2101,6 +2153,17 @@ export function TopologyPage() { onError: (err) => showError(String(err)), }); + const applyWorldMut = useMutation({ + mutationFn: () => applyUmeTopologyToFabric(), + onSuccess: async () => { + showOk(t("topology.worldApplyOk")); + await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) }); + await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); + pendingFitRef.current = true; + }, + onError: (err) => showError(String(err)), + }); + const promptNewRegion = useCallback(() => { setNewRootDialog({ name: t("topology.newRegionName") }); }, [t]); @@ -2145,7 +2208,7 @@ export function TopologyPage() { const drillFolder = isWorldDrillFolder(folder); const umeNav = isUmeWorldNavFolder(folder); // Region === canvas: hide physical/custom view rows; only child regions. - // UME World container: only「完整世界地图」as a sibling view. + // UME World container: only「世界地图」as a sibling view. const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || "")); const visibleViews = canvasRegion ? [] @@ -3881,13 +3944,21 @@ export function TopologyPage() { const outsidePeers = graphQuery.data?.outside_peers || []; const graphTruncated = Boolean(graphQuery.data?.truncated); const truncateReason = String(graphQuery.data?.truncate_reason || "").trim(); + const worldScatter = graphQuery.data?.scatter || []; + const worldTotal = Number(graphQuery.data?.world_transform?.total || 0); + const worldDockMe = Number(graphQuery.data?.world_transform?.dock_me_count || 0); + const showWorldScatter = + isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0; const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data); const canvasGraphEmpty = Boolean(mapId) && graphQuery.isSuccess && !graphQuery.isFetching && (graphQuery.data?.nodes?.length ?? 0) === 0 && - nodes.length === 0; + worldScatter.length === 0 && + nodes.length === 0 && + worldTotal === 0; + const worldNeedsApply = isWorldFlatCanvas && canvasGraphEmpty && worldDockMe > 0; const canvasGraphRefreshing = Boolean(mapId) && liveSync && graphQuery.isFetching && Boolean(graphQuery.data) && !canvasGraphLoading; const truncateBannerText = useMemo(() => { @@ -4312,8 +4383,8 @@ export function TopologyPage() { @@ -5226,7 +5297,7 @@ export function TopologyPage() { type="button" className="topo-fs-toolbar__btn" title={t("topology.fit")} - onClick={() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS })} + onClick={() => fitCanvas()} > {t("topology.fit")} @@ -5242,8 +5313,25 @@ export function TopologyPage() { ) : null} {treeRoot ? ( + {isWorldFlatCanvas ? ( +
+ + {t("topology.worldHud") + .replace("{{total}}", String(worldTotal || worldScatter.length || 0)) + .replace("{{cached}}", String(nodes.length)) + .replace("{{zoom}}", canvasZoom.toFixed(3)) + .replace("{{lod}}", worldVisualLod)} + + {graphTruncated ? ( + · {truncateReason || "truncated"} + ) : null} +
+ ) : null} { + nodes={ + isWorldFlatCanvas && worldVisualLod !== "full" + ? [] + : nodes.map((n) => { const isRegion = n.data.kind === "region"; const lod = isWorldFlatCanvas ? worldVisualLod : "full"; const sized = @@ -5259,8 +5347,9 @@ export function TopologyPage() { className: "is-search-hit", } : { ...sized, className: undefined }; - })} - edges={displayEdges} + }) + } + edges={isWorldFlatCanvas && worldVisualLod !== "full" ? [] : displayEdges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} onlyRenderVisibleElements={ @@ -5369,11 +5458,17 @@ export function TopologyPage() { }} onInit={(inst) => { rfRef.current = inst as ReactFlowInstance, Edge>; - if (pendingFitRef.current && nodesRef.current.length > 0) { + if (pendingFitRef.current) { pendingFitRef.current = false; - // Defer one frame so RF has committed node internals. window.requestAnimationFrame(() => { - inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 }); + const bounds = isWorldFlatCanvas + ? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform) + : null; + if (bounds) { + inst.fitBounds(bounds, { padding: 0.12, duration: 0 }); + } else if (nodesRef.current.length > 0) { + inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 }); + } setCanvasZoom(inst.getZoom()); }); } @@ -5382,6 +5477,11 @@ export function TopologyPage() { deleteKeyCode={null} edgesFocusable > + ) : null} {treeRoot && canvasGraphEmpty ? ( -
-

{t("topology.canvasEmpty")}

-

{t("topology.canvasEmptyHint")}

+
+

+ {worldNeedsApply ? t("topology.worldEmptyCoords") : t("topology.canvasEmpty")} +

+

+ {worldNeedsApply + ? t("topology.worldEmptyCoordsHint").replace("{{dock}}", String(worldDockMe)) + : t("topology.canvasEmptyHint")} +

+ {worldNeedsApply ? ( +
+ +
+ ) : null}
) : null} {treeRoot && canvasGraphRefreshing ? ( diff --git a/web/src/pages/topology/WorldScatterLayer.tsx b/web/src/pages/topology/WorldScatterLayer.tsx new file mode 100644 index 0000000..65abb05 --- /dev/null +++ b/web/src/pages/topology/WorldScatterLayer.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef } from "react"; +import { Panel, useStore, type ReactFlowState } from "@xyflow/react"; + +export type WorldScatterPoint = { x: number; y: number }; + +type Props = { + points: WorldScatterPoint[]; + /** "dot" = tiny starfield; "pin" = slightly larger. */ + mode: "dot" | "pin"; + visible: boolean; +}; + +const selectViewport = (s: ReactFlowState) => s.transform; + +/** + * Screen-space starfield for the flat world map. + * Mounted via Panel (outside the RF viewport transform) so dots stay ~constant + * pixel size when zoomed far out — unlike RF DOM nodes. + */ +export function WorldScatterLayer({ points, mode, visible }: Props) { + const canvasRef = useRef(null); + const transform = useStore(selectViewport); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !visible) return; + const parent = canvas.parentElement; + if (!parent) return; + + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = parent.clientWidth || 1; + const h = parent.clientHeight || 1; + if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) { + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + } + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + if (!points.length) return; + + const [tx, ty, zoom] = transform; + const z = Math.max(Number(zoom) || 0.001, 0.001); + const r = mode === "pin" ? 3.5 : 2; + const useDensity = points.length > 6000 || z < 0.02; + + if (useDensity) { + const cell = mode === "pin" ? 8 : 6; + const cols = Math.ceil(w / cell) + 1; + const rows = Math.ceil(h / cell) + 1; + const counts = new Uint16Array(cols * rows); + let maxC = 1; + for (const p of points) { + const sx = Number(p.x) * z + tx; + const sy = Number(p.y) * z + ty; + if (sx < -cell || sy < -cell || sx > w + cell || sy > h + cell) continue; + const cx = Math.min(cols - 1, Math.max(0, Math.floor(sx / cell))); + const cy = Math.min(rows - 1, Math.max(0, Math.floor(sy / cell))); + const i = cy * cols + cx; + const n = (counts[i] = (counts[i] + 1) as number); + if (n > maxC) maxC = n; + } + for (let i = 0; i < counts.length; i++) { + const c = counts[i]; + if (!c) continue; + const cx = (i % cols) * cell + cell / 2; + const cy = Math.floor(i / cols) * cell + cell / 2; + const t = Math.min(1, c / Math.max(4, maxC * 0.35)); + const radius = r * (0.7 + t * 1.4); + ctx.beginPath(); + ctx.fillStyle = `rgba(56, 189, 248, ${0.25 + t * 0.7})`; + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + } + return; + } + + ctx.fillStyle = "rgba(56, 189, 248, 0.85)"; + for (const p of points) { + const sx = Number(p.x) * z + tx; + const sy = Number(p.y) * z + ty; + if (sx < -4 || sy < -4 || sx > w + 4 || sy > h + 4) continue; + ctx.beginPath(); + ctx.arc(sx, sy, r, 0, Math.PI * 2); + ctx.fill(); + } + }, [points, mode, visible, transform]); + + if (!visible) return null; + return ( + + + ); +} diff --git a/web/src/types.ts b/web/src/types.ts index eea9026..c6af4ec 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -565,6 +565,12 @@ export type TopologyWorldTransform = { full_max_y: number; total: number; lod: string; + dock_me_count?: number; +}; + +export type TopologyWorldScatterPoint = { + x: number; + y: number; }; export type TopologyViewGraph = { @@ -575,6 +581,7 @@ export type TopologyViewGraph = { truncate_reason?: string; outside_peers?: TopologyOutsidePeer[]; world_transform?: TopologyWorldTransform | null; + scatter?: TopologyWorldScatterPoint[]; }; export type FabricSummary = {