Render world map overview as a screen-space starfield and rename it to 世界地图.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 11:02:23 +08:00
parent 8444d85005
commit fdab188cc1
11 changed files with 470 additions and 67 deletions

View file

@ -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:

View file

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

View file

@ -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)
│ └─ <root SBNs…>
└─ 完整世界地图 (flat view — only when rule 2A holds)
└─ 世界地图 (flat view — only when rule 2A holds)
"""
now = _utcnow()
stats: dict[str, Any] = {

View file

@ -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=[],
)

View file

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

View file

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

View file

@ -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: "已删除",

View file

@ -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;

View file

@ -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<TopologyViewGraph>(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() {
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={nodes.length === 0}
onClick={() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS })}
disabled={!isWorldFlatCanvas && nodes.length === 0}
onClick={() => fitCanvas()}
>
{t("topology.fit")}
</button>
@ -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")}
</button>
@ -5242,8 +5313,25 @@ export function TopologyPage() {
) : null}
{treeRoot ? (
<TopoDisplayContext.Provider value={displayOpts}>
{isWorldFlatCanvas ? (
<div className="topo-world-hud" role="status">
<span>
{t("topology.worldHud")
.replace("{{total}}", String(worldTotal || worldScatter.length || 0))
.replace("{{cached}}", String(nodes.length))
.replace("{{zoom}}", canvasZoom.toFixed(3))
.replace("{{lod}}", worldVisualLod)}
</span>
{graphTruncated ? (
<span className="muted"> · {truncateReason || "truncated"}</span>
) : null}
</div>
) : null}
<ReactFlow
nodes={nodes.map((n) => {
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<Node<NeNodeData>, 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
>
<WorldScatterLayer
points={worldScatter}
mode={worldVisualLod === "pin" ? "pin" : "dot"}
visible={showWorldScatter}
/>
<Background
variant={BackgroundVariant.Dots}
gap={16}
@ -5453,9 +5553,34 @@ export function TopologyPage() {
</div>
) : null}
{treeRoot && canvasGraphEmpty ? (
<div className="topo-canvas__overlay topo-canvas__overlay--empty" role="status">
<p className="topo-canvas__overlay-title">{t("topology.canvasEmpty")}</p>
<p className="topo-canvas__overlay-hint">{t("topology.canvasEmptyHint")}</p>
<div
className={`topo-canvas__overlay topo-canvas__overlay--empty${
worldNeedsApply ? " is-interactive" : ""
}`}
role="status"
>
<p className="topo-canvas__overlay-title">
{worldNeedsApply ? t("topology.worldEmptyCoords") : t("topology.canvasEmpty")}
</p>
<p className="topo-canvas__overlay-hint">
{worldNeedsApply
? t("topology.worldEmptyCoordsHint").replace("{{dock}}", String(worldDockMe))
: t("topology.canvasEmptyHint")}
</p>
{worldNeedsApply ? (
<div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}>
<button
type="button"
className="btn btn--sm"
disabled={applyWorldMut.isPending}
onClick={() => applyWorldMut.mutate()}
>
{applyWorldMut.isPending
? t("topology.worldApplying")
: t("topology.worldApply")}
</button>
</div>
) : null}
</div>
) : null}
{treeRoot && canvasGraphRefreshing ? (

View file

@ -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<HTMLCanvasElement | null>(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 (
<Panel
position="top-left"
className="topo-world-scatter-panel"
style={{
margin: 0,
width: "100%",
height: "100%",
maxWidth: "none",
pointerEvents: "none",
zIndex: 1,
}}
>
<canvas ref={canvasRef} className="topo-world-scatter" aria-hidden="true" />
</Panel>
);
}

View file

@ -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 = {