mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Add Google-Earth style world-map LOD with 1:1 coords and sticky tiles.
Keep visited regions when panning, prefetch detail on search locate with a gold flashing target highlight, and shrink default NE icons so dense UME layouts stay readable. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
af331e92ca
commit
c4911b6ddc
10 changed files with 876 additions and 169 deletions
|
|
@ -68,6 +68,8 @@ def _node_out(n: TopoFabricNode) -> FabricNodeOut:
|
|||
device_type=n.device_type or "",
|
||||
attrs=dict(n.attrs or {}),
|
||||
last_seen_at=n.last_seen_at,
|
||||
world_x=float(n.world_x) if n.world_x is not None else None,
|
||||
world_y=float(n.world_y) if n.world_y is not None else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ class FabricNodeOut(BaseModel):
|
|||
region_source: str = ""
|
||||
attrs: dict[str, Any] = Field(default_factory=dict)
|
||||
last_seen_at: datetime | None = None
|
||||
world_x: float | None = None
|
||||
world_y: float | None = None
|
||||
# Inventory link diagnostics (list/enrich); empty when not enriched.
|
||||
link_status: str = "" # managed | ume | both | orphaned
|
||||
managed_alive: bool = False
|
||||
|
|
@ -300,6 +302,20 @@ class ViewEdgeOut(BaseModel):
|
|||
discovered_at: datetime | None = None
|
||||
|
||||
|
||||
class WorldTransformOut(BaseModel):
|
||||
"""Flat world map: display = (world - origin) * scale. scale is 1.0 (1:1)."""
|
||||
|
||||
origin_x: float = 0.0
|
||||
origin_y: float = 0.0
|
||||
scale: float = 1.0
|
||||
full_min_x: float = 0.0
|
||||
full_max_x: float = 0.0
|
||||
full_min_y: float = 0.0
|
||||
full_max_y: float = 0.0
|
||||
total: int = 0
|
||||
lod: str = "overview"
|
||||
|
||||
|
||||
class TopologyViewGraphOut(BaseModel):
|
||||
view: TopologyViewOut
|
||||
nodes: list[ViewNodeOut]
|
||||
|
|
@ -307,6 +323,7 @@ class TopologyViewGraphOut(BaseModel):
|
|||
truncated: bool = False
|
||||
truncate_reason: str = ""
|
||||
outside_peers: list[dict[str, str]] = Field(default_factory=list)
|
||||
world_transform: WorldTransformOut | None = None
|
||||
|
||||
|
||||
class ViewPopulateOut(BaseModel):
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from .topology_schemas import (
|
|||
TopologyViewGraphOut,
|
||||
ViewEdgeOut,
|
||||
ViewNodeOut,
|
||||
WorldTransformOut,
|
||||
)
|
||||
from .topology_views_tree import _get_view_or_404, _view_out
|
||||
from .ume_topology_world import (
|
||||
|
|
@ -31,9 +32,20 @@ from .ume_topology_world import (
|
|||
is_world_view,
|
||||
)
|
||||
|
||||
# Keep flat-world responses at the same browser-safe hard cap as other views.
|
||||
# (Previously 8000 — loading that into React Flow + API RAM starved the whole host.)
|
||||
# Browser-safe caps. Flat world used to ship 2000 RF nodes (~1MB JSON) and freeze
|
||||
# the UI (fitView never settles; workbench feels stuck). Keep membership graphs at
|
||||
# the shared hard cap; flat overview/detail use LOD-specific caps.
|
||||
WORLD_NODE_SOFT_CAP = VIEW_GRAPH_NODE_HARD_CAP
|
||||
WORLD_FLAT_NODE_SOFT_CAP = 400 # legacy alias → detail
|
||||
WORLD_FLAT_OVERVIEW_CAP = 1500
|
||||
# Close-up must be able to show a whole large SBN like the region canvas (≤ hard cap).
|
||||
WORLD_FLAT_DETAIL_CAP = VIEW_GRAPH_NODE_HARD_CAP
|
||||
# Viewport span (display/world units) at/under which we prefer a full region layout
|
||||
# instead of stride-thinning — stops “zoomed in still a peppered blob”.
|
||||
WORLD_FLAT_CLOSE_SPAN = 28000.0
|
||||
# Legacy crush span (pre-LOD). Flat graph is now 1:1 with fabric world_*;
|
||||
# this constant only detects stale TopoViewNode overrides from that era.
|
||||
WORLD_FLAT_DISPLAY_SPAN = 6000.0
|
||||
|
||||
|
||||
def apply_persisted_view_positions(
|
||||
|
|
@ -98,6 +110,7 @@ def get_ume_canvas_graph(
|
|||
max_y=max_y,
|
||||
sbn_id=sbn_id,
|
||||
folder_id=folder_id,
|
||||
lod=lod,
|
||||
status=status,
|
||||
)
|
||||
return get_level_view_graph(db, view, status=status)
|
||||
|
|
@ -465,10 +478,16 @@ def get_flat_view_graph(
|
|||
max_y: float | None = None,
|
||||
sbn_id: str = "",
|
||||
folder_id: str = "",
|
||||
lod: str = "auto",
|
||||
status: str = "active",
|
||||
) -> TopologyViewGraphOut:
|
||||
"""All NEs using composed world_x/y — no region nodes."""
|
||||
"""Flat world map with LOD: overview (global dots) or detail (viewport sample).
|
||||
|
||||
Client bbox is in **display** coordinates (same as returned node x/y). Folder
|
||||
auto-bbox (when only folder_id is set) stays in packed world coordinates.
|
||||
"""
|
||||
region_folder_ids: set[str] | None = None
|
||||
folder_world_bbox: dict[str, float] | None = None
|
||||
if folder_id.strip():
|
||||
bbox = folder_bbox(db, folder_id.strip())
|
||||
folder = db.get(TopoFolder, folder_id.strip())
|
||||
|
|
@ -485,13 +504,13 @@ def get_flat_view_graph(
|
|||
if cid not in region_folder_ids:
|
||||
region_folder_ids.add(cid)
|
||||
stack.append(cid)
|
||||
if bbox and min_x is None:
|
||||
pad_x = max(50.0, (bbox["max_x"] - bbox["min_x"]) * 0.05)
|
||||
pad_y = max(50.0, (bbox["max_y"] - bbox["min_y"]) * 0.05)
|
||||
min_x = float(bbox["min_x"]) - pad_x
|
||||
max_x = float(bbox["max_x"]) + pad_x
|
||||
min_y = float(bbox["min_y"]) - pad_y
|
||||
max_y = float(bbox["max_y"]) + pad_y
|
||||
if bbox is not None:
|
||||
folder_world_bbox = {
|
||||
"min_x": float(bbox["min_x"]),
|
||||
"max_x": float(bbox["max_x"]),
|
||||
"min_y": float(bbox["min_y"]),
|
||||
"max_y": float(bbox["max_y"]),
|
||||
}
|
||||
|
||||
extent_row = (
|
||||
db.query(
|
||||
|
|
@ -504,68 +523,161 @@ def get_flat_view_graph(
|
|||
.filter(TopoFabricNode.world_x.isnot(None), TopoFabricNode.world_y.isnot(None))
|
||||
.one()
|
||||
)
|
||||
if not int(extent_row[4] or 0):
|
||||
total_all = int(extent_row[4] or 0)
|
||||
if not total_all:
|
||||
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
|
||||
|
||||
full_min_x = float(extent_row[0])
|
||||
full_max_x = float(extent_row[1])
|
||||
full_min_y = float(extent_row[2])
|
||||
full_max_y = float(extent_row[3])
|
||||
span_x = max(1.0, full_max_x - full_min_x)
|
||||
span_y = max(1.0, full_max_y - full_min_y)
|
||||
# 1:1 with fabric world_* (only origin-shift to ~0). Infinite zoom keeps
|
||||
# each packed region block identical to its UME local relative layout.
|
||||
scale = 1.0
|
||||
|
||||
if min_x is None or max_x is None or min_y is None or max_y is None:
|
||||
min_x, max_x, min_y, max_y = full_min_x, full_max_x, full_min_y, full_max_y
|
||||
client_bbox = (
|
||||
min_x is not None
|
||||
and max_x is not None
|
||||
and min_y is not None
|
||||
and max_y is not None
|
||||
)
|
||||
lod_norm = str(lod or "auto").strip().lower() or "auto"
|
||||
if lod_norm not in ("overview", "detail", "auto"):
|
||||
lod_norm = "auto"
|
||||
if lod_norm == "auto":
|
||||
lod_norm = "detail" if client_bbox else "overview"
|
||||
|
||||
if client_bbox:
|
||||
# Display == world - origin; pad detail viewport so pan feels continuous.
|
||||
pad = 0.2 if lod_norm == "detail" else 0.0
|
||||
dx0, dx1 = float(min_x), float(max_x)
|
||||
dy0, dy1 = float(min_y), float(max_y)
|
||||
if dx1 < dx0:
|
||||
dx0, dx1 = dx1, dx0
|
||||
if dy1 < dy0:
|
||||
dy0, dy1 = dy1, dy0
|
||||
dw = max(1.0, dx1 - dx0)
|
||||
dh = max(1.0, dy1 - dy0)
|
||||
dx0 -= dw * pad
|
||||
dx1 += dw * pad
|
||||
dy0 -= dh * pad
|
||||
dy1 += dh * pad
|
||||
w_min_x = dx0 / scale + full_min_x
|
||||
w_max_x = dx1 / scale + full_min_x
|
||||
w_min_y = dy0 / scale + full_min_y
|
||||
w_max_y = dy1 / scale + full_min_y
|
||||
elif folder_world_bbox is not None:
|
||||
pad_x = max(50.0, (folder_world_bbox["max_x"] - folder_world_bbox["min_x"]) * 0.05)
|
||||
pad_y = max(50.0, (folder_world_bbox["max_y"] - folder_world_bbox["min_y"]) * 0.05)
|
||||
w_min_x = folder_world_bbox["min_x"] - pad_x
|
||||
w_max_x = folder_world_bbox["max_x"] + pad_x
|
||||
w_min_y = folder_world_bbox["min_y"] - pad_y
|
||||
w_max_y = folder_world_bbox["max_y"] + pad_y
|
||||
else:
|
||||
w_min_x, w_max_x, w_min_y, w_max_y = full_min_x, full_max_x, full_min_y, full_max_y
|
||||
|
||||
q = db.query(TopoFabricNode).filter(
|
||||
TopoFabricNode.world_x.isnot(None),
|
||||
TopoFabricNode.world_y.isnot(None),
|
||||
TopoFabricNode.world_x >= min_x,
|
||||
TopoFabricNode.world_x <= max_x,
|
||||
TopoFabricNode.world_y >= min_y,
|
||||
TopoFabricNode.world_y <= max_y,
|
||||
TopoFabricNode.world_x >= w_min_x,
|
||||
TopoFabricNode.world_x <= w_max_x,
|
||||
TopoFabricNode.world_y >= w_min_y,
|
||||
TopoFabricNode.world_y <= w_max_y,
|
||||
)
|
||||
if region_folder_ids is not None:
|
||||
q = q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids)))
|
||||
|
||||
flat_cap = WORLD_FLAT_OVERVIEW_CAP if lod_norm == "overview" else WORLD_FLAT_DETAIL_CAP
|
||||
truncated = False
|
||||
reason = ""
|
||||
include_edges = lod_norm == "detail"
|
||||
# Unpadded display span of the client viewport (for close-up full-region load).
|
||||
view_span = 0.0
|
||||
if client_bbox:
|
||||
view_span = max(abs(float(max_x) - float(min_x)), abs(float(max_y) - float(min_y)))
|
||||
|
||||
if sbn_id.strip():
|
||||
sid = sbn_id.strip()
|
||||
# Attr JSON filter is rare; keep Python filter but only after a bounded fetch.
|
||||
candidates = q.order_by(TopoFabricNode.id.asc()).limit(WORLD_NODE_SOFT_CAP * 5).all()
|
||||
candidates = q.order_by(TopoFabricNode.id.asc()).limit(flat_cap * 5).all()
|
||||
nodes = [
|
||||
n
|
||||
for n in candidates
|
||||
if str((n.attrs or {}).get("ume_sbn_id") or "") == sid
|
||||
or str(n.region_folder_id or "") == sid
|
||||
]
|
||||
if len(nodes) > WORLD_NODE_SOFT_CAP:
|
||||
nodes = nodes[:WORLD_NODE_SOFT_CAP]
|
||||
if len(nodes) > flat_cap:
|
||||
nodes = nodes[:flat_cap]
|
||||
truncated = True
|
||||
reason = "too_many_viewport_nodes"
|
||||
else:
|
||||
# Cap so we never hydrate 15k ORM rows. Stride across world-order so the
|
||||
# overview is spread out (id-prefix order clumps one corner of the map).
|
||||
id_rows = (
|
||||
q.with_entities(TopoFabricNode.id)
|
||||
.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc())
|
||||
.all()
|
||||
total = int(q.with_entities(func.count(TopoFabricNode.id)).scalar() or 0)
|
||||
close_up = (
|
||||
lod_norm == "detail"
|
||||
and client_bbox
|
||||
and view_span > 0
|
||||
and view_span <= WORLD_FLAT_CLOSE_SPAN
|
||||
)
|
||||
total = len(id_rows)
|
||||
if total <= WORLD_NODE_SOFT_CAP:
|
||||
pick_ids = [str(r[0]) for r in id_rows]
|
||||
if total <= flat_cap:
|
||||
nodes = q.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc()).all()
|
||||
elif close_up:
|
||||
# Zoomed into ~one region: load the dominant SBN in full so layout
|
||||
# matches that region's canvas (no stride peppering).
|
||||
in_view = q.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc()).all()
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
for n in in_view:
|
||||
counts[str((n.attrs or {}).get("ume_sbn_id") or "").strip() or "_"] += 1
|
||||
dominant = max(counts.items(), key=lambda kv: (kv[1], kv[0]))[0] if counts else ""
|
||||
if dominant and dominant != "_" and counts[dominant] >= max(3, len(in_view) // 5):
|
||||
me_uids = [
|
||||
str(r[0])
|
||||
for r in db.query(UmeTopoNode.ume_ne_id)
|
||||
.filter(
|
||||
UmeTopoNode.node_type == "TOPO_NODE_ME",
|
||||
UmeTopoNode.parent_node == dominant,
|
||||
UmeTopoNode.ume_ne_id.isnot(None),
|
||||
)
|
||||
.all()
|
||||
if str(r[0] or "").strip()
|
||||
]
|
||||
nodes = (
|
||||
db.query(TopoFabricNode)
|
||||
.filter(
|
||||
TopoFabricNode.ume_ne_id.in_(me_uids),
|
||||
TopoFabricNode.world_x.isnot(None),
|
||||
TopoFabricNode.world_y.isnot(None),
|
||||
)
|
||||
.all()
|
||||
if me_uids
|
||||
else []
|
||||
)
|
||||
nodes.sort(key=lambda n: (float(n.world_x or 0), float(n.world_y or 0), n.id))
|
||||
if len(nodes) > flat_cap:
|
||||
nodes = nodes[:flat_cap]
|
||||
truncated = True
|
||||
reason = "too_many_viewport_nodes"
|
||||
else:
|
||||
nodes = in_view[:flat_cap]
|
||||
truncated = len(in_view) > flat_cap
|
||||
reason = "too_many_viewport_nodes" if truncated else ""
|
||||
else:
|
||||
stride = max(1, total // WORLD_NODE_SOFT_CAP)
|
||||
pick_ids = [str(r[0]) for r in id_rows[::stride][:WORLD_NODE_SOFT_CAP]]
|
||||
id_rows = (
|
||||
q.with_entities(TopoFabricNode.id)
|
||||
.order_by(TopoFabricNode.world_x.asc(), TopoFabricNode.world_y.asc())
|
||||
.all()
|
||||
)
|
||||
stride = max(1, total // flat_cap)
|
||||
pick_ids = [str(r[0]) for r in id_rows[::stride][:flat_cap]]
|
||||
nodes = (
|
||||
db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(pick_ids)).all()
|
||||
if pick_ids
|
||||
else []
|
||||
)
|
||||
order = {nid: i for i, nid in enumerate(pick_ids)}
|
||||
nodes.sort(key=lambda n: order.get(str(n.id), 0))
|
||||
truncated = True
|
||||
reason = "too_many_viewport_nodes"
|
||||
nodes = (
|
||||
db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(pick_ids)).all()
|
||||
if pick_ids
|
||||
else []
|
||||
)
|
||||
# Preserve world-order for stable layout / fitView.
|
||||
order = {nid: i for i, nid in enumerate(pick_ids)}
|
||||
nodes.sort(key=lambda n: order.get(str(n.id), 0))
|
||||
|
||||
fids = [n.id for n in nodes]
|
||||
fid_set = set(fids)
|
||||
|
|
@ -575,8 +687,8 @@ def get_flat_view_graph(
|
|||
managed_ne_id=str(n.managed_ne_id or ""),
|
||||
ume_ne_id=str(n.ume_ne_id or ""),
|
||||
label=(n.name or n.ip or n.id)[:256],
|
||||
x=float(n.world_x or 0),
|
||||
y=float(n.world_y or 0),
|
||||
x=(float(n.world_x or 0) - full_min_x) * scale,
|
||||
y=(float(n.world_y or 0) - full_min_y) * scale,
|
||||
locked=False,
|
||||
name=n.name or "",
|
||||
ip=n.ip or "",
|
||||
|
|
@ -586,10 +698,28 @@ def get_flat_view_graph(
|
|||
)
|
||||
for n in nodes
|
||||
]
|
||||
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
|
||||
# Drag overrides in display space (world - origin). Skip legacy rows saved
|
||||
# under the old ~6000 crush — they would yank nodes to the wrong place.
|
||||
override_rows = (
|
||||
db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).limit(8).all()
|
||||
)
|
||||
display_span = max(span_x, span_y)
|
||||
overrides_ok = False
|
||||
if override_rows and display_span > 0:
|
||||
oxs = [abs(float(r.x or 0)) for r in override_rows]
|
||||
oys = [abs(float(r.y or 0)) for r in override_rows]
|
||||
peak = max(oxs + oys) if (oxs or oys) else 0.0
|
||||
legacy_crush = display_span > WORLD_FLAT_DISPLAY_SPAN * 2 and peak <= WORLD_FLAT_DISPLAY_SPAN * 1.5
|
||||
overrides_ok = (not legacy_crush) and all(
|
||||
0.0 <= float(r.x or 0) <= display_span * 1.2
|
||||
and 0.0 <= float(r.y or 0) <= display_span * 1.2
|
||||
for r in override_rows
|
||||
)
|
||||
if overrides_ok:
|
||||
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
|
||||
|
||||
edges_out: list[ViewEdgeOut] = []
|
||||
if fids:
|
||||
if include_edges and fids:
|
||||
eq = db.query(TopoFabricEdge).filter(
|
||||
TopoFabricEdge.layer == "physical",
|
||||
TopoFabricEdge.a_node_id.in_(fids),
|
||||
|
|
@ -630,4 +760,15 @@ def get_flat_view_graph(
|
|||
edges=edges_out,
|
||||
truncated=truncated,
|
||||
truncate_reason=reason,
|
||||
world_transform=WorldTransformOut(
|
||||
origin_x=full_min_x,
|
||||
origin_y=full_min_y,
|
||||
scale=scale,
|
||||
full_min_x=full_min_x,
|
||||
full_max_x=full_max_x,
|
||||
full_min_y=full_min_y,
|
||||
full_max_y=full_max_y,
|
||||
total=total_all,
|
||||
lod=lod_norm,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -287,6 +287,52 @@ class UmeHierarchyTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(ctx.exception.detail, "world_map_no_direct_nes")
|
||||
|
||||
def test_flat_lod_overview_and_detail(self):
|
||||
from netx_api.ume_topology_world import get_world_flat_view
|
||||
from netx_api.ume_topology_world_graph import (
|
||||
WORLD_FLAT_DETAIL_CAP,
|
||||
WORLD_FLAT_OVERVIEW_CAP,
|
||||
get_flat_view_graph,
|
||||
)
|
||||
|
||||
self._seed_tree()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
ensure_ume_world_and_sbn_folders(self.db)
|
||||
recompute_flat_world_coords(self.db)
|
||||
flat = get_world_flat_view(self.db)
|
||||
self.assertIsNotNone(flat)
|
||||
|
||||
overview = get_flat_view_graph(self.db, flat, lod="overview")
|
||||
self.assertIsNotNone(overview.world_transform)
|
||||
self.assertEqual(overview.world_transform.lod, "overview")
|
||||
self.assertGreater(len(overview.nodes), 0)
|
||||
self.assertLessEqual(len(overview.nodes), WORLD_FLAT_OVERVIEW_CAP)
|
||||
self.assertEqual(len(overview.edges), 0)
|
||||
self.assertEqual(float(overview.world_transform.scale or 0), 1.0)
|
||||
# Intra-block spacing matches UME local deltas (1:1, not crushed).
|
||||
a1 = next(n for n in overview.nodes if n.ume_ne_id == "me-a1" or n.name == "A1")
|
||||
a2 = next(n for n in overview.nodes if n.ume_ne_id == "me-a2" or n.name == "A2")
|
||||
self.assertAlmostEqual(float(a2.x) - float(a1.x), 40.0)
|
||||
|
||||
# Viewport around an existing overview node (display coords).
|
||||
hit = overview.nodes[0]
|
||||
detail = get_flat_view_graph(
|
||||
self.db,
|
||||
flat,
|
||||
lod="detail",
|
||||
min_x=float(hit.x) - 50,
|
||||
max_x=float(hit.x) + 50,
|
||||
min_y=float(hit.y) - 50,
|
||||
max_y=float(hit.y) + 50,
|
||||
)
|
||||
self.assertEqual(detail.world_transform.lod, "detail")
|
||||
self.assertGreater(len(detail.nodes), 0)
|
||||
self.assertLessEqual(len(detail.nodes), WORLD_FLAT_DETAIL_CAP)
|
||||
self.assertTrue(any(n.fabric_node_id == hit.fabric_node_id for n in detail.nodes))
|
||||
|
||||
auto = get_flat_view_graph(self.db, flat, lod="auto")
|
||||
self.assertEqual(auto.world_transform.lod, "overview")
|
||||
|
||||
def test_flat_drag_override_survives_recompute(self):
|
||||
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
|
||||
from netx_api.topology_views_graph import patch_view_positions
|
||||
|
|
|
|||
|
|
@ -1404,6 +1404,8 @@ const en = {
|
|||
treeSearchEmpty: "No matching NEs",
|
||||
treeSearchNoViews: "Not on any map",
|
||||
treeSearchNotOnMap: "This NE is not placed on any topology map yet",
|
||||
locateNotOnCanvas:
|
||||
"This NE is missing from the world-map sample and has no world coordinates. Open a region map instead.",
|
||||
treeSearchViewCount: "{{count}} map(s)",
|
||||
treeSearchTruncated: "Showing first 30 of {{total}}",
|
||||
rootName: "Roots",
|
||||
|
|
|
|||
|
|
@ -1395,6 +1395,7 @@ const zh = {
|
|||
treeSearchEmpty: "没有匹配的网元",
|
||||
treeSearchNoViews: "未上图",
|
||||
treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上",
|
||||
locateNotOnCanvas: "当前世界地图抽样中没有该网元,且缺少世界坐标,请到下级区域画布查找。",
|
||||
treeSearchViewCount: "{{count}} 张图",
|
||||
treeSearchTruncated: "仅显示前 30 条(共 {{total}})",
|
||||
rootName: "根",
|
||||
|
|
|
|||
|
|
@ -6408,13 +6408,13 @@ pre {
|
|||
}
|
||||
|
||||
.topo-node.is-connect-mode .topo-node__handle {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
min-width: 28px !important;
|
||||
min-height: 28px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
min-width: 14px !important;
|
||||
min-height: 14px !important;
|
||||
opacity: 0.9 !important;
|
||||
background: rgba(14, 165, 233, 0.35) !important;
|
||||
border: 2px solid #38bdf8 !important;
|
||||
border: 1px solid #38bdf8 !important;
|
||||
border-radius: 50% !important;
|
||||
z-index: 5;
|
||||
}
|
||||
|
|
@ -7149,17 +7149,72 @@ pre {
|
|||
}
|
||||
|
||||
.topo-node {
|
||||
min-width: 72px;
|
||||
max-width: 160px;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 25px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-size: 11px;
|
||||
font-size: 8px;
|
||||
text-align: center;
|
||||
--topo-icon-color: var(--topo-vendor-gray, #94a3b8);
|
||||
}
|
||||
|
||||
/* Flat-world LOD: pixel / pin overview (Google-Earth style). */
|
||||
.topo-node--dot,
|
||||
.topo-node--pin {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--topo-icon-color, #94a3b8);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.topo-node__pixel {
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.topo-node--dot.is-selected .topo-node__pixel {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
box-shadow: 0 0 0 2px #38bdf8;
|
||||
}
|
||||
|
||||
.topo-node__pin {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
border: 1px solid rgba(15, 23, 42, 0.45);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.topo-node--pin.is-selected .topo-node__pin {
|
||||
box-shadow: 0 0 0 2px #38bdf8;
|
||||
}
|
||||
|
||||
.topo-node__handle--dot {
|
||||
opacity: 0 !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
border: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.topo-node.is-placeholder .topo-node__glyph {
|
||||
outline: 1px dashed rgba(148, 163, 184, 0.65);
|
||||
outline-offset: 2px;
|
||||
|
|
@ -7168,16 +7223,16 @@ pre {
|
|||
|
||||
.topo-node__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
top: -3px;
|
||||
right: -4px;
|
||||
z-index: 2;
|
||||
min-width: 22px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
min-width: 14px;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
color: #fbbf24;
|
||||
font-size: 9px;
|
||||
font-size: 6px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -7271,45 +7326,91 @@ pre {
|
|||
gap: 2px;
|
||||
}
|
||||
|
||||
.topo-node.is-region {
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.topo-node__glyph {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topo-node.is-region .topo-node__glyph {
|
||||
width: 56px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.topo-node.is-region .topo-node__caption {
|
||||
margin-top: 8px;
|
||||
width: 25px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.topo-node__icon--region {
|
||||
width: 56px;
|
||||
height: 38px;
|
||||
width: 25px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.react-flow__node.is-search-hit .topo-node__glyph {
|
||||
box-shadow: none;
|
||||
outline: 2px solid rgba(245, 158, 11, 0.95);
|
||||
.react-flow__node.is-search-hit {
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.react-flow__node.is-search-hit .topo-node {
|
||||
--topo-icon-color: #fbbf24;
|
||||
filter: drop-shadow(0 0 8px rgba(251, 191, 36, 0.95));
|
||||
animation: topo-search-flash 0.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.react-flow__node.is-search-hit .topo-node__glyph,
|
||||
.react-flow__node.is-search-hit .topo-node__pixel,
|
||||
.react-flow__node.is-search-hit .topo-node__pin {
|
||||
outline: 3px solid #fbbf24;
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
animation: topo-search-pulse 0.85s ease 2;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes topo-search-pulse {
|
||||
.react-flow__node.is-search-hit .topo-node__pixel,
|
||||
.react-flow__node.is-search-hit .topo-node__pin {
|
||||
background: #fbbf24 !important;
|
||||
color: #fbbf24;
|
||||
box-shadow: 0 0 10px rgba(251, 191, 36, 0.95);
|
||||
}
|
||||
|
||||
.react-flow__node.is-search-hit .topo-node__icon-tint {
|
||||
background-color: #fbbf24 !important;
|
||||
}
|
||||
|
||||
.react-flow__node.is-search-hit .topo-node__caption {
|
||||
opacity: 1;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.react-flow__node.is-search-hit .topo-node__caption-name {
|
||||
color: #fde68a;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
max-width: 140px;
|
||||
text-shadow:
|
||||
0 0 4px rgba(0, 0, 0, 1),
|
||||
0 0 8px rgba(251, 191, 36, 0.85);
|
||||
}
|
||||
|
||||
@keyframes topo-search-flash {
|
||||
0%,
|
||||
100% {
|
||||
outline-color: rgba(245, 158, 11, 0.95);
|
||||
filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.7));
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
outline-color: rgba(245, 158, 11, 0.35);
|
||||
filter: drop-shadow(0 0 14px rgba(251, 191, 36, 1));
|
||||
opacity: 0.72;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
.topo-node.is-selected .topo-node__glyph {
|
||||
outline: 2px solid #38bdf8;
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.topo-node__handle {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
|
|
@ -7333,12 +7434,6 @@ pre {
|
|||
transform: translate(-50%, -50%) !important;
|
||||
}
|
||||
|
||||
.topo-node.is-selected .topo-node__glyph {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.topo-node.is-selected .topo-node__icon {
|
||||
transform: none;
|
||||
}
|
||||
|
|
@ -7361,8 +7456,8 @@ pre {
|
|||
.topo-node__icon {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
|
@ -7395,25 +7490,31 @@ pre {
|
|||
}
|
||||
|
||||
.topo-node__caption {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(100% + 1px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
margin-top: 3px;
|
||||
gap: 0;
|
||||
margin-top: 0;
|
||||
color: var(--topo-label-color, #f1f5f9);
|
||||
line-height: 1.2;
|
||||
max-width: 160px;
|
||||
line-height: 1.15;
|
||||
width: max-content;
|
||||
max-width: 80px;
|
||||
pointer-events: none;
|
||||
text-shadow:
|
||||
0 0 3px rgba(15, 23, 42, 0.95),
|
||||
0 1px 2px rgba(15, 23, 42, 0.85);
|
||||
0 0 2px rgba(15, 23, 42, 0.95),
|
||||
0 1px 1px rgba(15, 23, 42, 0.85);
|
||||
}
|
||||
|
||||
.topo-node__caption-name {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--topo-label-color, #f1f5f9);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -7423,7 +7524,7 @@ pre {
|
|||
.topo-node__caption-meta {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
font-size: 10px;
|
||||
font-size: 7px;
|
||||
color: color-mix(in srgb, var(--topo-label-color, #f1f5f9) 55%, #64748b);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import type {
|
|||
TopologyViewGraph,
|
||||
TopologyViewNodeItem,
|
||||
TopologyViewRole,
|
||||
TopologyWorldTransform,
|
||||
} from "../types";
|
||||
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
|
||||
import { ParallelEdge } from "./topology/ParallelEdge";
|
||||
|
|
@ -179,6 +180,13 @@ function isWorldFlatViewName(name: string | undefined | null): boolean {
|
|||
return String(name || "").trim() === "完整世界地图";
|
||||
}
|
||||
|
||||
/** Visual LOD for 1:1 world coords (~1e5 span). fitView overview is ~0.01. */
|
||||
function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" {
|
||||
if (zoom < 0.04) return "dot";
|
||||
if (zoom < 0.12) return "pin";
|
||||
return "full";
|
||||
}
|
||||
|
||||
/**
|
||||
* Region row IS the canvas for nested / UME SBN / World drill.
|
||||
* Top-level under Network root (and UME World container) are hex-nav only — same as UME World.
|
||||
|
|
@ -238,6 +246,68 @@ const SNAP_GRID: [number, number] = [16, 16];
|
|||
* skips those. includeHiddenNodes uses declared width/height so fit still works.
|
||||
*/
|
||||
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
|
||||
/** Zoom after locate on flat world — neighborhood visible with full icons, not a lone pin. */
|
||||
const WORLD_LOCATE_ZOOM = 0.22;
|
||||
/** Half-extent (display/world units) fetched around the target on locate. */
|
||||
const WORLD_LOCATE_HALF = 12000;
|
||||
/** Soft cap for accumulated flat-world tiles (Google-Earth style keep-alive). */
|
||||
const WORLD_FLAT_ACCUM_CAP = 8000;
|
||||
|
||||
/** Merge newly fetched flat LOD into the existing cache so visited areas stay. */
|
||||
function mergeFlatWorldGraph(
|
||||
prev: TopologyViewGraph | undefined,
|
||||
next: TopologyViewGraph,
|
||||
opts?: { centerX?: number; centerY?: number },
|
||||
): TopologyViewGraph {
|
||||
if (!prev?.nodes?.length) return next;
|
||||
if (!next?.nodes?.length) {
|
||||
return {
|
||||
...prev,
|
||||
world_transform: next.world_transform ?? prev.world_transform,
|
||||
};
|
||||
}
|
||||
|
||||
const nodeMap = new Map<string, TopologyViewNodeItem>();
|
||||
for (const n of prev.nodes) nodeMap.set(String(n.fabric_node_id), n);
|
||||
for (const n of next.nodes) nodeMap.set(String(n.fabric_node_id), n);
|
||||
|
||||
const edgeMap = new Map<string, TopologyViewEdgeItem>();
|
||||
for (const e of prev.edges || []) edgeMap.set(String(e.id), e);
|
||||
for (const e of next.edges || []) edgeMap.set(String(e.id), e);
|
||||
|
||||
let nodes = [...nodeMap.values()];
|
||||
const cx = opts?.centerX;
|
||||
const cy = opts?.centerY;
|
||||
if (nodes.length > WORLD_FLAT_ACCUM_CAP && cx != null && cy != null) {
|
||||
const prefer = new Set(next.nodes.map((n) => String(n.fabric_node_id)));
|
||||
const kept = next.nodes.slice();
|
||||
const extras = nodes
|
||||
.filter((n) => !prefer.has(String(n.fabric_node_id)))
|
||||
.sort((a, b) => {
|
||||
const da = (Number(a.x) - cx) ** 2 + (Number(a.y) - cy) ** 2;
|
||||
const db = (Number(b.x) - cx) ** 2 + (Number(b.y) - cy) ** 2;
|
||||
return da - db;
|
||||
});
|
||||
const room = Math.max(0, WORLD_FLAT_ACCUM_CAP - kept.length);
|
||||
nodes = kept.concat(extras.slice(0, room));
|
||||
}
|
||||
|
||||
const idSet = new Set(nodes.map((n) => String(n.fabric_node_id)));
|
||||
const edges = [...edgeMap.values()].filter(
|
||||
(e) => idSet.has(String(e.a_node_id)) && idSet.has(String(e.b_node_id)),
|
||||
);
|
||||
|
||||
return {
|
||||
...next,
|
||||
view: next.view || prev.view,
|
||||
nodes,
|
||||
edges,
|
||||
world_transform: next.world_transform ?? prev.world_transform,
|
||||
truncated: Boolean(prev.truncated || next.truncated),
|
||||
truncate_reason: next.truncate_reason || prev.truncate_reason || "",
|
||||
outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers,
|
||||
};
|
||||
}
|
||||
const UNDO_MAX = 40;
|
||||
const PALETTE_DND = "application/x-netx-topo-palette";
|
||||
|
||||
|
|
@ -410,6 +480,8 @@ type TopoDisplayOpts = {
|
|||
connectMode: boolean;
|
||||
/** Show TOPO/LLDP placeholder corner badge on nodes. */
|
||||
showPlaceholderBadge: boolean;
|
||||
/** Flat world map visual LOD (Google-Earth style). */
|
||||
worldVisualLod: "dot" | "pin" | "full";
|
||||
};
|
||||
|
||||
type CtxMenu =
|
||||
|
|
@ -424,6 +496,7 @@ const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
|||
hidePorts: true,
|
||||
connectMode: false,
|
||||
showPlaceholderBadge: false,
|
||||
worldVisualLod: "full",
|
||||
});
|
||||
|
||||
function newId(): string {
|
||||
|
|
@ -533,25 +606,52 @@ function RegionCanvasIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
|
||||
const TOPO_NODE_W = 160;
|
||||
const TOPO_NODE_H = 88;
|
||||
/** Must match `.topo-node__glyph` (56×56, top-centered) + `--center` handles. */
|
||||
const TOPO_ICON = 56;
|
||||
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set).
|
||||
* Caption is absolutely positioned under the glyph so the RF box is icon-only. */
|
||||
const TOPO_NODE_W = 80;
|
||||
const TOPO_NODE_H = 25;
|
||||
/** Must match `.topo-node__glyph` (25×25, top-centered) + `--center` handles. */
|
||||
const TOPO_ICON = 25;
|
||||
/** region-building.png is 195×133 — keep aspect so icon center == handle. */
|
||||
const TOPO_REGION_ICON_H = Math.round((56 * 133) / 195); // 38
|
||||
const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195);
|
||||
const TOPO_HANDLE_X = TOPO_NODE_W / 2;
|
||||
const TOPO_HANDLE_Y = TOPO_ICON / 2;
|
||||
const TOPO_REGION_HANDLE_X = TOPO_NODE_W / 2;
|
||||
const TOPO_REGION_HANDLE_Y = TOPO_REGION_ICON_H / 2;
|
||||
|
||||
/** Region API x/y is icon center; RF position is top-left of the node box. */
|
||||
/** API x/y is icon center; RF position is top-left of the node box. */
|
||||
function iconFlowPosition(
|
||||
apiX: number,
|
||||
apiY: number,
|
||||
handleX: number,
|
||||
handleY: number,
|
||||
): { x: number; y: number } {
|
||||
return { x: apiX - handleX, y: apiY - handleY };
|
||||
}
|
||||
|
||||
function iconApiPosition(
|
||||
flowX: number,
|
||||
flowY: number,
|
||||
handleX: number,
|
||||
handleY: number,
|
||||
): { x: number; y: number } {
|
||||
return { x: flowX + handleX, y: flowY + handleY };
|
||||
}
|
||||
|
||||
function regionFlowPosition(apiX: number, apiY: number): { x: number; y: number } {
|
||||
return { x: apiX - TOPO_REGION_HANDLE_X, y: apiY - TOPO_REGION_HANDLE_Y };
|
||||
return iconFlowPosition(apiX, apiY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y);
|
||||
}
|
||||
|
||||
function regionApiPosition(flowX: number, flowY: number): { x: number; y: number } {
|
||||
return { x: flowX + TOPO_REGION_HANDLE_X, y: flowY + TOPO_REGION_HANDLE_Y };
|
||||
return iconApiPosition(flowX, flowY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y);
|
||||
}
|
||||
|
||||
function neFlowPosition(apiX: number, apiY: number): { x: number; y: number } {
|
||||
return iconFlowPosition(apiX, apiY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
|
||||
}
|
||||
|
||||
function neApiPosition(flowX: number, flowY: number): { x: number; y: number } {
|
||||
return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
|
||||
}
|
||||
|
||||
function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
|
||||
|
|
@ -561,7 +661,8 @@ function isPlaceholderSource(source: string | undefined, neIp: string): boolean
|
|||
}
|
||||
|
||||
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||
const { hideIp, hideVendor, connectMode, showPlaceholderBadge } = useContext(TopoDisplayContext);
|
||||
const { hideIp, hideVendor, connectMode, showPlaceholderBadge, worldVisualLod } =
|
||||
useContext(TopoDisplayContext);
|
||||
const isRegion = data.kind === "region";
|
||||
const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
|
||||
const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip);
|
||||
|
|
@ -574,6 +675,32 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
|||
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
|
||||
hideVendor || !data.vendor ? "" : data.vendor,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!isRegion && worldVisualLod === "dot") {
|
||||
return (
|
||||
<div
|
||||
className={`topo-node topo-node--dot topo-node--${tone}${selected ? " is-selected" : ""}`}
|
||||
title={name}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
|
||||
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
|
||||
<span className="topo-node__pixel" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isRegion && worldVisualLod === "pin") {
|
||||
return (
|
||||
<div
|
||||
className={`topo-node topo-node--pin topo-node--${tone}${selected ? " is-selected" : ""}`}
|
||||
title={name}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
|
||||
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
|
||||
<span className="topo-node__pin" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
|
||||
|
|
@ -584,7 +711,7 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
|||
? "Open region canvas"
|
||||
: showBadge
|
||||
? data.managed_source || "placeholder"
|
||||
: undefined
|
||||
: name
|
||||
}
|
||||
>
|
||||
<div className="topo-node__glyph">
|
||||
|
|
@ -887,8 +1014,10 @@ function graphToFlow(
|
|||
n.kind === "region" || n.device_type === "region" || String(n.fabric_node_id || "").startsWith("region:");
|
||||
const apiX = Number(n.x) || 0;
|
||||
const apiY = Number(n.y) || 0;
|
||||
// Region coords are icon-center; NE coords stay top-left of the node box (legacy).
|
||||
const position = isRegion ? regionFlowPosition(apiX, apiY) : { x: apiX, y: apiY };
|
||||
// API coords are icon-center (UME + region); RF position is top-left of the icon box.
|
||||
const position = isRegion
|
||||
? regionFlowPosition(apiX, apiY)
|
||||
: neFlowPosition(apiX, apiY);
|
||||
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
|
||||
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
|
||||
return {
|
||||
|
|
@ -896,9 +1025,8 @@ function graphToFlow(
|
|||
type: "neNode",
|
||||
position,
|
||||
width: TOPO_NODE_W,
|
||||
height: TOPO_NODE_H,
|
||||
// Predetermined handles must match DOM anchors (icon center), not the 160px box edges —
|
||||
// otherwise edges float in the gap and never touch the router glyph.
|
||||
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
|
||||
// Predetermined handles must match DOM anchors (icon center).
|
||||
handles: [
|
||||
{ type: "target", position: Position.Left, x: hx, y: hy },
|
||||
{ type: "source", position: Position.Right, x: hx, y: hy },
|
||||
|
|
@ -952,7 +1080,7 @@ function flowToPositions(nodes: Node<NeNodeData>[]) {
|
|||
const isRegion = n.data.kind === "region" || String(n.id || "").startsWith("region:");
|
||||
const pos = isRegion
|
||||
? regionApiPosition(n.position.x, n.position.y)
|
||||
: { x: n.position.x, y: n.position.y };
|
||||
: neApiPosition(n.position.x, n.position.y);
|
||||
return {
|
||||
fabric_node_id: n.id,
|
||||
x: pos.x,
|
||||
|
|
@ -975,7 +1103,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}`;
|
||||
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}`;
|
||||
}
|
||||
|
||||
function applyViewGraph(
|
||||
|
|
@ -1155,6 +1283,60 @@ export function TopologyPage() {
|
|||
}, []);
|
||||
|
||||
const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
|
||||
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||
const worldTransformRef = useRef<TopologyWorldTransform | null>(null);
|
||||
const flatLodFetchGenRef = useRef(0);
|
||||
const flatLodTimerRef = useRef<number | null>(null);
|
||||
const isWorldFlatCanvasRef = useRef(false);
|
||||
const mapIdRef = useRef(mapId);
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
staleTime: liveSync ? 0 : 30_000,
|
||||
refetchOnWindowFocus: liveSync,
|
||||
refetchInterval: liveSync ? 5000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const treeFlatMap = useMemo(() => {
|
||||
if (!mapId) return false;
|
||||
const kids = treeQuery.data?.root?.children || [];
|
||||
return isWorldFlatViewName(findViewInRegion(kids, mapId)?.view?.name);
|
||||
}, [mapId, treeQuery.data?.root?.children]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [treeNeQuery]);
|
||||
|
||||
const treeNeSearchQuery = useQuery({
|
||||
queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1),
|
||||
queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }),
|
||||
enabled: debouncedTreeNeQuery.length >= 1,
|
||||
});
|
||||
|
||||
const graphQuery = useQuery({
|
||||
queryKey: queryKeys.topologyGraph(mapId),
|
||||
queryFn: () =>
|
||||
fetchTopologyGraph(mapId, treeFlatMap ? { lod: "overview" } : undefined),
|
||||
enabled: Boolean(mapId),
|
||||
staleTime: liveSync ? 0 : 30_000,
|
||||
refetchOnWindowFocus: liveSync && !treeFlatMap,
|
||||
refetchInterval: liveSync && mapId && !treeFlatMap ? 3000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const isWorldFlatCanvas =
|
||||
treeFlatMap ||
|
||||
isWorldFlatViewName(graphQuery.data?.view?.name) ||
|
||||
Boolean(graphQuery.data?.view?.filter?.world_flat);
|
||||
|
||||
isWorldFlatCanvasRef.current = isWorldFlatCanvas;
|
||||
mapIdRef.current = mapId;
|
||||
|
||||
const worldVisualLod = isWorldFlatCanvas ? worldVisualLodFromZoom(canvasZoom) : "full";
|
||||
|
||||
const displayOpts = useMemo(
|
||||
() => ({
|
||||
hideIp,
|
||||
|
|
@ -1162,10 +1344,13 @@ export function TopologyPage() {
|
|||
hidePorts,
|
||||
connectMode: toolMode === "connect",
|
||||
showPlaceholderBadge,
|
||||
worldVisualLod,
|
||||
}),
|
||||
[hideIp, hideVendor, hidePorts, toolMode, showPlaceholderBadge],
|
||||
[hideIp, hideVendor, hidePorts, toolMode, showPlaceholderBadge, worldVisualLod],
|
||||
);
|
||||
|
||||
const displayEdges = useMemo(() => {
|
||||
if (isWorldFlatCanvas && worldVisualLod !== "full") return [];
|
||||
const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts, scaleBundleWidth).map((e) =>
|
||||
withEdgeVisual(e, edgeDefaults),
|
||||
);
|
||||
|
|
@ -1182,37 +1367,88 @@ export function TopologyPage() {
|
|||
animated: edgeFlow,
|
||||
};
|
||||
});
|
||||
}, [edges, expandPhysicalLinks, hidePorts, scaleBundleWidth, edgeFlow, edgeDefaults, selectedEdgeId]);
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
staleTime: liveSync ? 0 : 30_000,
|
||||
refetchOnWindowFocus: liveSync,
|
||||
refetchInterval: liveSync ? 5000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}, [
|
||||
edges,
|
||||
expandPhysicalLinks,
|
||||
hidePorts,
|
||||
scaleBundleWidth,
|
||||
edgeFlow,
|
||||
edgeDefaults,
|
||||
selectedEdgeId,
|
||||
isWorldFlatCanvas,
|
||||
worldVisualLod,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [treeNeQuery]);
|
||||
if (graphQuery.data?.world_transform) {
|
||||
worldTransformRef.current = graphQuery.data.world_transform;
|
||||
}
|
||||
}, [graphQuery.data?.world_transform]);
|
||||
|
||||
const treeNeSearchQuery = useQuery({
|
||||
queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1),
|
||||
queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }),
|
||||
enabled: debouncedTreeNeQuery.length >= 1,
|
||||
});
|
||||
const refreshFlatViewport = useCallback(async () => {
|
||||
if (!mapId || !isWorldFlatCanvas || dirtyRef.current) return;
|
||||
const inst = rfRef.current;
|
||||
if (!inst) return;
|
||||
const zoom = inst.getZoom();
|
||||
setCanvasZoom(zoom);
|
||||
const gen = ++flatLodFetchGenRef.current;
|
||||
const commit = (g: TopologyViewGraph, centerX: number, centerY: number) => {
|
||||
if (g.world_transform) worldTransformRef.current = g.world_transform;
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), (prev: TopologyViewGraph | undefined) =>
|
||||
mergeFlatWorldGraph(prev, g, { centerX, centerY }),
|
||||
);
|
||||
};
|
||||
try {
|
||||
// Far out: keep accumulated detail tiles — don't wipe with a sparse overview.
|
||||
if (zoom < 0.025) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const pane = canvasRef.current?.getBoundingClientRect();
|
||||
const w = pane?.width || 1200;
|
||||
const h = pane?.height || 800;
|
||||
const vp = inst.getViewport();
|
||||
const z = Math.max(vp.zoom || 0.01, 0.01);
|
||||
const min_x = -vp.x / z;
|
||||
const min_y = -vp.y / z;
|
||||
const max_x = (w - vp.x) / z;
|
||||
const max_y = (h - vp.y) / z;
|
||||
const g = await fetchTopologyGraph(mapId, {
|
||||
lod: "detail",
|
||||
min_x,
|
||||
max_x,
|
||||
min_y,
|
||||
max_y,
|
||||
});
|
||||
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
|
||||
commit(g, (min_x + max_x) / 2, (min_y + max_y) / 2);
|
||||
} catch {
|
||||
/* keep last good sample */
|
||||
}
|
||||
}, [mapId, isWorldFlatCanvas, queryClient]);
|
||||
|
||||
const graphQuery = useQuery({
|
||||
queryKey: queryKeys.topologyGraph(mapId),
|
||||
queryFn: () => fetchTopologyGraph(mapId),
|
||||
enabled: Boolean(mapId),
|
||||
staleTime: liveSync ? 0 : 30_000,
|
||||
refetchOnWindowFocus: liveSync,
|
||||
refetchInterval: liveSync && mapId ? 3000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
const scheduleFlatViewportRefresh = useCallback(() => {
|
||||
if (!isWorldFlatCanvas) return;
|
||||
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
|
||||
flatLodTimerRef.current = window.setTimeout(() => {
|
||||
flatLodTimerRef.current = null;
|
||||
void refreshFlatViewport();
|
||||
}, 200);
|
||||
}, [isWorldFlatCanvas, refreshFlatViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -1339,11 +1575,6 @@ export function TopologyPage() {
|
|||
return { drill, flatView };
|
||||
}, [activeRegion]);
|
||||
|
||||
const isWorldFlatCanvas =
|
||||
isWorldFlatViewName(activeView?.name) ||
|
||||
isWorldFlatViewName(graphQuery.data?.view?.name) ||
|
||||
Boolean(graphQuery.data?.view?.filter?.world_flat);
|
||||
|
||||
// Resolve World drill view id for shortcuts; do not auto-open canvas.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -1607,13 +1838,15 @@ export function TopologyPage() {
|
|||
if (cancelled) return;
|
||||
const inst = rfRef.current;
|
||||
if (!inst) {
|
||||
if (tries++ < 20) window.setTimeout(run, 50);
|
||||
if (tries++ < 40) window.setTimeout(run, 40);
|
||||
return;
|
||||
}
|
||||
pendingFitRef.current = false;
|
||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 200 });
|
||||
// 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, 40);
|
||||
const t = window.setTimeout(run, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(t);
|
||||
|
|
@ -3294,11 +3527,90 @@ export function TopologyPage() {
|
|||
focusNode,
|
||||
]);
|
||||
|
||||
const focusWorldAround = useCallback(
|
||||
async (nodeId: string, displayX: number, displayY: number) => {
|
||||
const viewId = mapIdRef.current;
|
||||
if (!viewId || !isWorldFlatCanvasRef.current || dirtyRef.current) return;
|
||||
if (flatLodTimerRef.current) {
|
||||
window.clearTimeout(flatLodTimerRef.current);
|
||||
flatLodTimerRef.current = null;
|
||||
}
|
||||
const gen = ++flatLodFetchGenRef.current;
|
||||
// Zoom immediately so the user isn't stuck on overview while detail loads.
|
||||
const inst = rfRef.current;
|
||||
if (inst) {
|
||||
setCanvasZoom(WORLD_LOCATE_ZOOM);
|
||||
inst.setCenter(displayX, displayY, { zoom: WORLD_LOCATE_ZOOM, duration: 200 });
|
||||
}
|
||||
const half = WORLD_LOCATE_HALF;
|
||||
try {
|
||||
const g = await fetchTopologyGraph(viewId, {
|
||||
lod: "detail",
|
||||
min_x: displayX - half,
|
||||
max_x: displayX + half,
|
||||
min_y: displayY - half,
|
||||
max_y: displayY + half,
|
||||
});
|
||||
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
|
||||
if (g.world_transform) worldTransformRef.current = g.world_transform;
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(viewId), (prev: TopologyViewGraph | undefined) =>
|
||||
mergeFlatWorldGraph(prev, g, { centerX: displayX, centerY: displayY }),
|
||||
);
|
||||
} catch {
|
||||
/* keep last sample */
|
||||
}
|
||||
if (gen !== flatLodFetchGenRef.current) return;
|
||||
// Re-center after graph replace (fit/apply can nudge viewport) and re-select.
|
||||
window.requestAnimationFrame(() => {
|
||||
if (flatLodFetchGenRef.current !== gen) return;
|
||||
rfRef.current?.setCenter(displayX, displayY, { zoom: WORLD_LOCATE_ZOOM, duration: 0 });
|
||||
setCanvasZoom(WORLD_LOCATE_ZOOM);
|
||||
focusNode(nodeId, false);
|
||||
setSearchHitIds([nodeId]);
|
||||
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
|
||||
searchHitTimerRef.current = window.setTimeout(() => {
|
||||
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
|
||||
searchHitTimerRef.current = null;
|
||||
}, 8000);
|
||||
});
|
||||
},
|
||||
[queryClient, focusNode],
|
||||
);
|
||||
|
||||
const locateNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
(nodeId: string, opts?: { worldX?: number; worldY?: number }) => {
|
||||
const onCanvas = nodesRef.current.find((n) => n.id === nodeId);
|
||||
const wt = worldTransformRef.current;
|
||||
const originX = Number(wt?.origin_x) || 0;
|
||||
const originY = Number(wt?.origin_y) || 0;
|
||||
const scale = Math.max(Number(wt?.scale) || 1, 1e-9);
|
||||
|
||||
let displayX: number | undefined;
|
||||
let displayY: number | undefined;
|
||||
if (onCanvas) {
|
||||
displayX = onCanvas.position.x + TOPO_HANDLE_X;
|
||||
displayY = onCanvas.position.y + TOPO_HANDLE_Y;
|
||||
} else if (opts?.worldX != null && opts?.worldY != null) {
|
||||
displayX = (Number(opts.worldX) - originX) * scale;
|
||||
displayY = (Number(opts.worldY) - originY) * scale;
|
||||
} else {
|
||||
showError(t("topology.locateNotOnCanvas"));
|
||||
return;
|
||||
}
|
||||
|
||||
focusNode(nodeId, false);
|
||||
setSearchHitIds([nodeId]);
|
||||
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
|
||||
searchHitTimerRef.current = window.setTimeout(() => {
|
||||
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
|
||||
searchHitTimerRef.current = null;
|
||||
}, 8000);
|
||||
|
||||
if (isWorldFlatCanvasRef.current) {
|
||||
void focusWorldAround(nodeId, displayX, displayY);
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
rfRef.current?.fitView({
|
||||
nodes: [{ id: nodeId }],
|
||||
|
|
@ -3307,25 +3619,35 @@ export function TopologyPage() {
|
|||
includeHiddenNodes: true,
|
||||
});
|
||||
}, 30);
|
||||
searchHitTimerRef.current = window.setTimeout(() => {
|
||||
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
|
||||
searchHitTimerRef.current = null;
|
||||
}, 2200);
|
||||
},
|
||||
[focusNode],
|
||||
[focusNode, focusWorldAround, showError, t],
|
||||
);
|
||||
|
||||
const pendingLocateWorldRef = useRef<{
|
||||
id: string;
|
||||
worldX?: number;
|
||||
worldY?: number;
|
||||
} | null>(null);
|
||||
|
||||
const jumpToTreeSearchHit = useCallback(
|
||||
(
|
||||
hit: FabricNodeSearchHit,
|
||||
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
|
||||
) => {
|
||||
const views = hit.views || [];
|
||||
const worldCoords =
|
||||
hit.world_x != null && hit.world_y != null
|
||||
? { worldX: Number(hit.world_x), worldY: Number(hit.world_y) }
|
||||
: undefined;
|
||||
const onCurrent =
|
||||
Boolean(mapId) &&
|
||||
(views.some((v) => v.view_id === mapId) || nodes.some((n) => n.id === hit.id));
|
||||
(views.some((v) => v.view_id === mapId) ||
|
||||
nodes.some((n) => n.id === hit.id) ||
|
||||
(isWorldFlatCanvasRef.current && worldCoords != null));
|
||||
if (onCurrent && mapId && (!view || view.view_id === mapId)) {
|
||||
if (nodes.some((n) => n.id === hit.id)) {
|
||||
if (isWorldFlatCanvasRef.current) {
|
||||
locateNode(hit.id, worldCoords);
|
||||
} else if (nodes.some((n) => n.id === hit.id)) {
|
||||
locateNode(hit.id);
|
||||
} else {
|
||||
setPendingHighlightNe(hit.id);
|
||||
|
|
@ -3337,6 +3659,7 @@ export function TopologyPage() {
|
|||
}
|
||||
const target =
|
||||
view ||
|
||||
views.find((v) => isWorldFlatViewName(v.view_name)) ||
|
||||
views.find((v) => String(v.kind) === "physical") ||
|
||||
views[0] ||
|
||||
null;
|
||||
|
|
@ -3350,6 +3673,11 @@ export function TopologyPage() {
|
|||
setSelectedFolderId(regionId);
|
||||
setExpandedIds((p) => ({ ...p, [regionId]: true }));
|
||||
}
|
||||
if (isWorldFlatViewName(target.view_name) && worldCoords) {
|
||||
pendingLocateWorldRef.current = { id: hit.id, ...worldCoords };
|
||||
} else {
|
||||
pendingLocateWorldRef.current = null;
|
||||
}
|
||||
setMapId(target.view_id);
|
||||
setPendingHighlightNe(hit.id);
|
||||
setTreeNeQuery("");
|
||||
|
|
@ -3380,12 +3708,32 @@ export function TopologyPage() {
|
|||
}, [treeSearchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingHighlightNe || !canvasMode || !nodes.length) return;
|
||||
if (!pendingHighlightNe || !canvasMode) return;
|
||||
const nodeId = pendingHighlightNe;
|
||||
if (!nodes.some((n) => n.id === nodeId)) return;
|
||||
const pendingWorld = pendingLocateWorldRef.current;
|
||||
if (
|
||||
pendingWorld &&
|
||||
pendingWorld.id === nodeId &&
|
||||
isWorldFlatCanvasRef.current &&
|
||||
rfRef.current
|
||||
) {
|
||||
pendingLocateWorldRef.current = null;
|
||||
setPendingHighlightNe("");
|
||||
window.setTimeout(
|
||||
() =>
|
||||
locateNode(nodeId, {
|
||||
worldX: pendingWorld.worldX,
|
||||
worldY: pendingWorld.worldY,
|
||||
}),
|
||||
80,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!nodes.length || !nodes.some((n) => n.id === nodeId)) return;
|
||||
pendingLocateWorldRef.current = null;
|
||||
setPendingHighlightNe("");
|
||||
window.setTimeout(() => locateNode(nodeId), 80);
|
||||
}, [pendingHighlightNe, canvasMode, nodes, locateNode]);
|
||||
}, [pendingHighlightNe, canvasMode, nodes, locateNode, mapId, graphQuery.data]);
|
||||
|
||||
const canvasHits = useMemo(() => {
|
||||
const q = canvasQuery.trim();
|
||||
|
|
@ -4838,23 +5186,41 @@ export function TopologyPage() {
|
|||
{treeRoot ? (
|
||||
<TopoDisplayContext.Provider value={displayOpts}>
|
||||
<ReactFlow
|
||||
nodes={nodes.map((n) =>
|
||||
searchHitIds.includes(n.id)
|
||||
? { ...n, className: "is-search-hit" }
|
||||
: { ...n, className: undefined },
|
||||
)}
|
||||
nodes={nodes.map((n) => {
|
||||
const isRegion = n.data.kind === "region";
|
||||
const lod = isWorldFlatCanvas ? worldVisualLod : "full";
|
||||
const sized =
|
||||
!isRegion && lod === "dot"
|
||||
? { ...n, width: 6, height: 6 }
|
||||
: !isRegion && lod === "pin"
|
||||
? { ...n, width: 14, height: 14 }
|
||||
: n;
|
||||
return searchHitIds.includes(n.id)
|
||||
? {
|
||||
...sized,
|
||||
selected: true,
|
||||
className: "is-search-hit",
|
||||
}
|
||||
: { ...sized, className: undefined };
|
||||
})}
|
||||
edges={displayEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onlyRenderVisibleElements
|
||||
onlyRenderVisibleElements={
|
||||
isWorldFlatCanvas ? worldVisualLod === "dot" : true
|
||||
}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
connectionLineType={ConnectionLineType.Straight}
|
||||
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
|
||||
defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
minZoom={isWorldFlatCanvas ? 0.005 : 0.05}
|
||||
maxZoom={4}
|
||||
nodesDraggable={toolBehavior.nodesDraggable}
|
||||
minZoom={isWorldFlatCanvas ? 0.002 : 0.05}
|
||||
maxZoom={isWorldFlatCanvas ? 12 : 4}
|
||||
nodesDraggable={
|
||||
isWorldFlatCanvas
|
||||
? worldVisualLod === "full" && toolBehavior.nodesDraggable
|
||||
: toolBehavior.nodesDraggable
|
||||
}
|
||||
nodesConnectable={toolBehavior.nodesConnectable}
|
||||
elementsSelectable={toolBehavior.elementsSelectable}
|
||||
panOnDrag={toolBehavior.panOnDrag}
|
||||
|
|
@ -4936,8 +5302,24 @@ export function TopologyPage() {
|
|||
});
|
||||
}}
|
||||
onMoveStart={closeCtxMenu}
|
||||
onMove={(_e, vp) => {
|
||||
if (isWorldFlatCanvas) setCanvasZoom(vp.zoom);
|
||||
}}
|
||||
onMoveEnd={(_e, vp) => {
|
||||
if (!isWorldFlatCanvas) return;
|
||||
setCanvasZoom(vp.zoom);
|
||||
scheduleFlatViewportRefresh();
|
||||
}}
|
||||
onInit={(inst) => {
|
||||
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
|
||||
if (pendingFitRef.current && nodesRef.current.length > 0) {
|
||||
pendingFitRef.current = false;
|
||||
// Defer one frame so RF has committed node internals.
|
||||
window.requestAnimationFrame(() => {
|
||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
||||
setCanvasZoom(inst.getZoom());
|
||||
});
|
||||
}
|
||||
}}
|
||||
fitView={false}
|
||||
deleteKeyCode={null}
|
||||
|
|
@ -4965,7 +5347,7 @@ export function TopologyPage() {
|
|||
<FullscreenIcon exit={fullscreen} />
|
||||
</ControlButton>
|
||||
</Controls>
|
||||
<MiniMap pannable zoomable />
|
||||
{!isWorldFlatCanvas ? <MiniMap pannable zoomable /> : null}
|
||||
</ReactFlow>
|
||||
</TopoDisplayContext.Provider>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1115,7 +1115,7 @@ export const fetchTopologyViewGraph = (
|
|||
max_y?: number;
|
||||
sbn_id?: string;
|
||||
folder_id?: string;
|
||||
lod?: "auto" | "sbn" | "me";
|
||||
lod?: "auto" | "sbn" | "me" | "overview" | "detail";
|
||||
status?: string;
|
||||
},
|
||||
) => {
|
||||
|
|
|
|||
|
|
@ -467,6 +467,8 @@ export type FabricNodeSearchHit = {
|
|||
ume_ne_id?: string;
|
||||
role?: string;
|
||||
region_folder_id?: string | null;
|
||||
world_x?: number | null;
|
||||
world_y?: number | null;
|
||||
link_status?: "managed" | "ume" | "both" | "orphaned" | string;
|
||||
managed_alive?: boolean;
|
||||
ume_alive?: boolean;
|
||||
|
|
@ -540,6 +542,18 @@ export type TopologyOutsidePeer = {
|
|||
via_node_id: string;
|
||||
};
|
||||
|
||||
export type TopologyWorldTransform = {
|
||||
origin_x: number;
|
||||
origin_y: number;
|
||||
scale: number;
|
||||
full_min_x: number;
|
||||
full_max_x: number;
|
||||
full_min_y: number;
|
||||
full_max_y: number;
|
||||
total: number;
|
||||
lod: string;
|
||||
};
|
||||
|
||||
export type TopologyViewGraph = {
|
||||
view: TopologyViewItem;
|
||||
nodes: TopologyViewNodeItem[];
|
||||
|
|
@ -547,6 +561,7 @@ export type TopologyViewGraph = {
|
|||
truncated?: boolean;
|
||||
truncate_reason?: string;
|
||||
outside_peers?: TopologyOutsidePeer[];
|
||||
world_transform?: TopologyWorldTransform | null;
|
||||
};
|
||||
|
||||
export type FabricSummary = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue