mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 21:03:26 +08:00
Improve frontend load performance and fix UME/login UX.
Defer topology xyflow and WebCRT xterm until needed, calm idle polling, bake login earth brightness, and let AI alert rules scroll as one natural panel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a0170907e4
commit
9bcb8ee0e3
11 changed files with 906 additions and 663 deletions
|
|
@ -1,9 +1,8 @@
|
|||
import {
|
||||
Fragment,
|
||||
createContext,
|
||||
memo,
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
|
|
@ -14,28 +13,13 @@ import {
|
|||
import { createPortal } from "react-dom";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
ControlButton,
|
||||
MiniMap,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
Handle,
|
||||
import type {
|
||||
Connection,
|
||||
Edge,
|
||||
Node,
|
||||
Position,
|
||||
ConnectionLineType,
|
||||
ConnectionMode,
|
||||
SelectionMode,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
type ReactFlowInstance,
|
||||
ReactFlowInstance,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
addTopologyViewNodes,
|
||||
createFabricManualEdge,
|
||||
|
|
@ -70,24 +54,19 @@ 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,
|
||||
TopologyDiscoverNeResult,
|
||||
TopologyDiscoverOut,
|
||||
TopologyEdgeItem,
|
||||
TopologyNodeItem,
|
||||
TopologyTreeFolderItem,
|
||||
TopologyTreeViewItem,
|
||||
TopologyViewEdgeItem,
|
||||
TopologyViewGraph,
|
||||
TopologyViewNodeItem,
|
||||
TopologyViewRole,
|
||||
TopologyWorldTransform,
|
||||
} from "../types";
|
||||
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
|
||||
import { ParallelEdge } from "./topology/ParallelEdge";
|
||||
import {
|
||||
buildLinkDisplayEdges,
|
||||
formatPortPairLabel,
|
||||
|
|
@ -97,6 +76,16 @@ import {
|
|||
type LinkMember,
|
||||
} from "./topology/linkDisplay";
|
||||
import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode";
|
||||
import type { NeNodeData } from "./topology/TopologyReactFlowView";
|
||||
|
||||
/** xyflow + canvas nodes — only loaded when a map canvas is open. */
|
||||
const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowView"));
|
||||
|
||||
function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
|
||||
const src = String(source || "").trim().toLowerCase();
|
||||
if (src === "lldp" || src === "topology") return true;
|
||||
return !String(neIp || "").trim() && Boolean(src);
|
||||
}
|
||||
|
||||
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
|
||||
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
|
||||
|
|
@ -308,17 +297,6 @@ function formatNeCount(count: number): string {
|
|||
return `${n}N`;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value?: string | null): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const SNAP_GRID: [number, number] = [16, 16];
|
||||
|
||||
/**
|
||||
* onlyRenderVisibleElements leaves off-screen nodes unmeasured; default fitView
|
||||
* skips those. includeHiddenNodes uses declared width/height so fit still works.
|
||||
|
|
@ -402,27 +380,6 @@ function mergeFlatWorldGraph(
|
|||
const UNDO_MAX = 40;
|
||||
const PALETTE_DND = "application/x-netx-topo-palette";
|
||||
|
||||
function FullscreenIcon({ exit }: { exit?: boolean }) {
|
||||
if (exit) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 14H5v5h5v-2H7v-3zm12 0h-2v3h-3v2h5v-5zM7 5h3V3H5v5h2V5zm10 0v3h2V3h-5v2h3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
|
||||
const SEP = " / ";
|
||||
|
||||
|
|
@ -526,23 +483,6 @@ function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
type NeNodeData = {
|
||||
label: string;
|
||||
managed_ne_id: string;
|
||||
ume_ne_id: string;
|
||||
ne_ip: string;
|
||||
vendor: string;
|
||||
connect_status: string;
|
||||
managed_source?: string;
|
||||
/** Canvas navigation node (not a fabric NE). */
|
||||
kind?: "ne" | "region" | "layer";
|
||||
folder_id?: string;
|
||||
view_id?: string;
|
||||
role?: TopologyViewRole | string;
|
||||
subtitle?: string;
|
||||
node_count?: number;
|
||||
};
|
||||
|
||||
type HistorySnap = {
|
||||
nodes: Node<NeNodeData>[];
|
||||
edges: Edge[];
|
||||
|
|
@ -564,32 +504,12 @@ type PaletteItem = {
|
|||
connect_status: string;
|
||||
};
|
||||
|
||||
type TopoDisplayOpts = {
|
||||
hideIp: boolean;
|
||||
hideVendor: boolean;
|
||||
hidePorts: boolean;
|
||||
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 =
|
||||
| { kind: "node"; id: string; x: number; y: number }
|
||||
| { kind: "edge"; id: string; x: number; y: number }
|
||||
| { kind: "selection"; x: number; y: number }
|
||||
| { kind: "pane"; x: number; y: number; flowX: number; flowY: number };
|
||||
|
||||
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||
hideIp: true,
|
||||
hideVendor: true,
|
||||
hidePorts: true,
|
||||
connectMode: false,
|
||||
showPlaceholderBadge: false,
|
||||
worldVisualLod: "full",
|
||||
});
|
||||
|
||||
function newId(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID().replace(/-/g, "");
|
||||
|
|
@ -606,31 +526,6 @@ function isLocalPendingEdgeId(id: string): boolean {
|
|||
return String(id || "").startsWith("local:");
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon tone for /topo/ne-router.png (base = blue).
|
||||
* Unmanaged (no managed/ume) and unknown vendors → gray.
|
||||
* UME-linked NEs default to ZTE when vendor is empty/unknown.
|
||||
*/
|
||||
function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string {
|
||||
const hasManaged = Boolean(String(managedNeId || "").trim());
|
||||
const hasUme = Boolean(String(umeNeId || "").trim());
|
||||
if (!hasManaged && !hasUme) return "gray";
|
||||
const v = String(vendor || "").trim().toLowerCase();
|
||||
if (!v || v === "other" || v === "unknown" || v === "generic") {
|
||||
return hasUme ? "zte" : "gray";
|
||||
}
|
||||
if (v.includes("cisco")) return "cisco";
|
||||
if (v.includes("huawei")) return "huawei";
|
||||
if (v.includes("zte")) return "zte";
|
||||
if (v.includes("juniper")) return "juniper";
|
||||
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
|
||||
if (v.includes("ericsson")) return "ericsson";
|
||||
if (v.includes("h3c") || v.includes("comware")) return "h3c";
|
||||
if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie";
|
||||
if (v.includes("mikrotik")) return "mikrotik";
|
||||
return hasUme ? "zte" : "gray";
|
||||
}
|
||||
|
||||
function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
|
||||
if (!r.ok) return "fail";
|
||||
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
|
||||
|
|
@ -670,33 +565,6 @@ function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
|
|||
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the PNG’s white texture/highlights; tint brand color via mix-blend-mode.
|
||||
* Color comes from --topo-icon-color (vendor palette in Display settings).
|
||||
*/
|
||||
function RouterIcon() {
|
||||
return (
|
||||
<span className="topo-node__icon" aria-hidden="true">
|
||||
<img className="topo-node__icon-art" src="/topo/ne-router.png" alt="" draggable={false} />
|
||||
<span className="topo-node__icon-tint" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Canvas sub-region building art (UME-style); no vendor tint. */
|
||||
function RegionCanvasIcon() {
|
||||
return (
|
||||
<span className="topo-node__icon topo-node__icon--region" aria-hidden="true">
|
||||
<img
|
||||
className="topo-node__icon-art"
|
||||
src="/topo/region-building.png"
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
|
@ -745,99 +613,6 @@ 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 {
|
||||
const src = String(source || "").trim().toLowerCase();
|
||||
if (src === "lldp" || src === "topology") return true;
|
||||
return !String(neIp || "").trim() && Boolean(src);
|
||||
}
|
||||
|
||||
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||
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);
|
||||
const showBadge = placeholder && showPlaceholderBadge;
|
||||
const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
|
||||
// Region count stays in the title "(N)"; do not repeat under the icon.
|
||||
const secondary = isRegion
|
||||
? []
|
||||
: [
|
||||
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" : ""}${
|
||||
connectMode && !isRegion ? " is-connect-mode" : ""
|
||||
}${showBadge ? " is-placeholder" : ""}${isRegion ? " is-region" : ""}`}
|
||||
title={
|
||||
isRegion
|
||||
? name
|
||||
: showBadge
|
||||
? data.managed_source || "placeholder"
|
||||
: name
|
||||
}
|
||||
>
|
||||
<div className="topo-node__glyph">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="topo-node__handle topo-node__handle--center"
|
||||
isConnectable={connectMode && !isRegion}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="topo-node__handle topo-node__handle--center"
|
||||
isConnectable={connectMode && !isRegion}
|
||||
/>
|
||||
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
|
||||
{showBadge ? (
|
||||
<span className="topo-node__badge" aria-hidden>
|
||||
{String(data.managed_source || "ph").slice(0, 4)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="topo-node__caption">
|
||||
<span className="topo-node__caption-name">{name}</span>
|
||||
{secondary.length ? (
|
||||
<span className="topo-node__caption-meta">{secondary.join(SEP)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const nodeTypes = { neNode: NeNode };
|
||||
const edgeTypes = { topoParallel: ParallelEdge };
|
||||
|
||||
type EdgeStyleData = LinkEdgeData;
|
||||
|
||||
type EdgeLineStyle = "solid" | "dashed" | "dotted";
|
||||
|
|
@ -972,16 +747,6 @@ function persistVendorColors(value: VendorColors) {
|
|||
}
|
||||
}
|
||||
|
||||
function canvasDotColor(bg: string): string {
|
||||
const hex = String(bg || "").replace("#", "");
|
||||
if (hex.length !== 6) return "#93c5fd";
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return lum > 0.55 ? "#93c5fd" : "#64748b";
|
||||
}
|
||||
|
||||
function loadBoolFlag(key: string, defaultValue: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
|
|
@ -1138,8 +903,8 @@ function graphToFlow(
|
|||
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 },
|
||||
{ type: "target", position: "left" as Position, x: hx, y: hy },
|
||||
{ type: "source", position: "right" as Position, x: hx, y: hy },
|
||||
],
|
||||
data: {
|
||||
label: n.label || n.name || n.ip || n.fabric_node_id,
|
||||
|
|
@ -1328,8 +1093,8 @@ export function TopologyPage() {
|
|||
const displayMenuRef = useRef<HTMLDetailsElement | null>(null);
|
||||
const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState<HTMLDivElement | null>(null);
|
||||
const findJustLocatedRef = useRef(false);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [nodes, setNodes] = useState<Node<NeNodeData>[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
|
|
@ -1672,6 +1437,12 @@ export function TopologyPage() {
|
|||
|
||||
const canvasMode = Boolean(mapId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasMode) return;
|
||||
void import("./topology/TopologyReactFlowView");
|
||||
}, [canvasMode]);
|
||||
|
||||
|
||||
const activeRegion = useMemo(() => {
|
||||
if (!selectedFolderId) return null;
|
||||
return findFolderInTree(regions, selectedFolderId);
|
||||
|
|
@ -2102,7 +1873,7 @@ export function TopologyPage() {
|
|||
return;
|
||||
}
|
||||
pushHistory();
|
||||
const next = layoutGraph(nodes, edges, kind, { onlyIds });
|
||||
const next = await layoutGraph(nodes, edges, kind, { onlyIds });
|
||||
setNodes(next);
|
||||
markDirty();
|
||||
if (opts?.persist && mapId) {
|
||||
|
|
@ -2765,7 +2536,7 @@ export function TopologyPage() {
|
|||
});
|
||||
let didAutoLayout = false;
|
||||
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||
rfNodes = await layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||
didAutoLayout = true;
|
||||
}
|
||||
historyLockRef.current = true;
|
||||
|
|
@ -5430,7 +5201,7 @@ export function TopologyPage() {
|
|||
</div>
|
||||
) : null}
|
||||
{treeRoot ? (
|
||||
<TopoDisplayContext.Provider value={displayOpts}>
|
||||
<>
|
||||
{isWorldFlatCanvas ? (
|
||||
<div className="topo-world-hud" role="status">
|
||||
<span>
|
||||
|
|
@ -5445,186 +5216,57 @@ export function TopologyPage() {
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ReactFlow
|
||||
nodes={
|
||||
isWorldFlatCanvas && worldVisualLod !== "full"
|
||||
? []
|
||||
: 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={isWorldFlatCanvas && worldVisualLod !== "full" ? [] : displayEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
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.002 : 0.05}
|
||||
maxZoom={isWorldFlatCanvas ? 12 : 4}
|
||||
nodesDraggable={
|
||||
isWorldFlatCanvas
|
||||
? worldVisualLod === "full" && toolBehavior.nodesDraggable
|
||||
: toolBehavior.nodesDraggable
|
||||
}
|
||||
nodesConnectable={toolBehavior.nodesConnectable}
|
||||
elementsSelectable={toolBehavior.elementsSelectable}
|
||||
panOnDrag={toolBehavior.panOnDrag}
|
||||
selectionOnDrag={toolBehavior.selectionOnDrag}
|
||||
panOnScroll={toolBehavior.panOnScroll}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
multiSelectionKeyCode="Shift"
|
||||
snapToGrid={snapToGrid}
|
||||
snapGrid={SNAP_GRID}
|
||||
onNodeDragStart={() => {
|
||||
pushHistory();
|
||||
}}
|
||||
onNodesChange={(changes) => {
|
||||
if (
|
||||
changes.some(
|
||||
(c) => c.type === "position" || c.type === "remove" || c.type === "add",
|
||||
)
|
||||
) {
|
||||
markDirty();
|
||||
}
|
||||
onNodesChange(changes);
|
||||
}}
|
||||
onEdgesChange={(changes) => {
|
||||
const physical = changes.filter((c) => {
|
||||
if (c.type === "select") return false;
|
||||
if ("id" in c && isAggregateEdgeId(String(c.id))) return false;
|
||||
return true;
|
||||
});
|
||||
if (physical.some((c) => c.type !== "select")) {
|
||||
markDirty();
|
||||
}
|
||||
if (physical.length) onEdgesChange(physical);
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onEdgeClick={(_e, edge) => {
|
||||
setCtxMenu(null);
|
||||
focusEdge(edge.id);
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setCtxMenu(null);
|
||||
clearSelection();
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
const multi = selectedNodeIds.length > 1 && selectedNodeIds.includes(node.id);
|
||||
if (!multi) focusNode(node.id, false);
|
||||
const pos = placeCtxMenu(e.clientX, e.clientY);
|
||||
setCtxMenu(
|
||||
multi ? { kind: "selection", ...pos } : { kind: "node", id: node.id, ...pos },
|
||||
);
|
||||
}}
|
||||
onEdgeContextMenu={(e, edge) => {
|
||||
e.preventDefault();
|
||||
focusEdge(edge.id);
|
||||
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 260, h: 280 });
|
||||
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
|
||||
}}
|
||||
onSelectionContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const pos = placeCtxMenu(e.clientX, e.clientY);
|
||||
setCtxMenu({ kind: "selection", ...pos });
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (!rfRef.current) return;
|
||||
const flow = rfRef.current.screenToFlowPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 180, h: 100 });
|
||||
setCtxMenu({
|
||||
kind: "pane",
|
||||
...pos,
|
||||
flowX: flow.x,
|
||||
flowY: flow.y,
|
||||
});
|
||||
}}
|
||||
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) {
|
||||
pendingFitRef.current = false;
|
||||
window.requestAnimationFrame(() => {
|
||||
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());
|
||||
});
|
||||
}
|
||||
}}
|
||||
fitView={false}
|
||||
deleteKeyCode={null}
|
||||
edgesFocusable
|
||||
>
|
||||
<WorldScatterLayer
|
||||
points={worldScatter}
|
||||
mode={worldVisualLod === "pin" ? "pin" : "dot"}
|
||||
visible={showWorldScatter}
|
||||
/>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={16}
|
||||
size={1}
|
||||
color={canvasDotColor(canvasBg)}
|
||||
bgColor={canvasBg}
|
||||
/>
|
||||
{toolMode === "connect" ? (
|
||||
<div className="topo-mode-hint" role="status">
|
||||
{t("topology.connectHint")}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="topo-canvas__empty topo-canvas__status" role="status">
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
<p>{t("topology.treeLoading")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<Controls showInteractive>
|
||||
<ControlButton
|
||||
className="topo-fs-control"
|
||||
onClick={() => void toggleFullscreen()}
|
||||
title={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
|
||||
aria-label={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
|
||||
>
|
||||
<FullscreenIcon exit={fullscreen} />
|
||||
</ControlButton>
|
||||
</Controls>
|
||||
{!isWorldFlatCanvas ? <MiniMap pannable zoomable /> : null}
|
||||
</ReactFlow>
|
||||
</TopoDisplayContext.Provider>
|
||||
}
|
||||
>
|
||||
<TopologyReactFlowView
|
||||
displayOpts={displayOpts}
|
||||
nodes={nodes}
|
||||
setNodes={setNodes}
|
||||
edges={edges}
|
||||
setEdges={setEdges}
|
||||
displayEdges={displayEdges}
|
||||
searchHitIds={searchHitIds}
|
||||
isWorldFlatCanvas={isWorldFlatCanvas}
|
||||
worldVisualLod={worldVisualLod}
|
||||
toolBehavior={toolBehavior}
|
||||
toolMode={toolMode}
|
||||
snapToGrid={snapToGrid}
|
||||
canvasBg={canvasBg}
|
||||
worldScatter={worldScatter}
|
||||
showWorldScatter={showWorldScatter}
|
||||
fullscreen={fullscreen}
|
||||
connectHint={t("topology.connectHint")}
|
||||
fullscreenLabel={t("topology.fullscreen")}
|
||||
exitFullscreenLabel={t("topology.exitFullscreen")}
|
||||
worldTransform={worldTransformRef.current || graphQuery.data?.world_transform}
|
||||
nodesRef={nodesRef}
|
||||
rfRef={rfRef}
|
||||
pendingFitRef={pendingFitRef}
|
||||
markDirty={markDirty}
|
||||
pushHistory={pushHistory}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
focusEdge={focusEdge}
|
||||
focusNode={focusNode}
|
||||
clearSelection={clearSelection}
|
||||
setCtxMenu={setCtxMenu}
|
||||
placeCtxMenu={placeCtxMenu}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
closeCtxMenu={closeCtxMenu}
|
||||
setCanvasZoom={setCanvasZoom}
|
||||
scheduleFlatViewportRefresh={scheduleFlatViewportRefresh}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
) : treeLoading ? (
|
||||
<div className="topo-canvas__empty topo-canvas__status" role="status">
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue