mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +08:00
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:
parent
8444d85005
commit
fdab188cc1
11 changed files with 470 additions and 67 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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=[],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "已删除",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
110
web/src/pages/topology/WorldScatterLayer.tsx
Normal file
110
web/src/pages/topology/WorldScatterLayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue