Industrialize topology editor and add customizable edge styles.

Add tool modes, layouts, and per-edge/default style overrides with a cleaner toolbar and context menus.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 12:31:06 +08:00
parent e4a365fa16
commit 99fce837f0
17 changed files with 1902 additions and 269 deletions

View file

@ -888,7 +888,7 @@ const en = {
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
emptyMaps: "No maps yet. Create one to start.",
palette: "NE library",
paletteHint: "Click to add to canvas",
paletteHint: "Click or drag onto the canvas",
filterPh: "Filter name / IP",
save: "Save",
saving: "Saving…",
@ -900,13 +900,56 @@ const en = {
discoverFail: "Discovery failed: {{detail}}",
discoverOneNeedNe: "This node is not linked to a scannable NE",
selectMap: "Select or create a topology map",
canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable); right-click a node for actions",
canvasHint:
"Use toolbar modes (select/pan/drag/connect), box-select, and layout. Blue dashed=discovered, red=missing. Shortcuts V/H/A/C, Ctrl+Z undo.",
selected: "Selected",
selectedEdge: "Selected edge",
selectedCount: "{{count}} selected",
toolModes: "Edit modes",
toolSelect: "Select",
toolPan: "Pan",
toolDrag: "Drag",
toolConnect: "Connect",
layout: "Layout",
layoutHierarchicalTb: "Hierarchical · top-bottom",
layoutHierarchicalLr: "Hierarchical · left-right",
layoutForce: "Force-directed",
layoutGrid: "Grid",
layoutRadial: "Radial",
layoutSelected: "Selected only (hierarchical)",
layoutNeedSelection: "Select nodes to lay out first",
align: "Align",
alignLeft: "Align left",
alignRight: "Align right",
alignTop: "Align top",
alignBottom: "Align bottom",
alignHCenter: "Align center X",
alignVCenter: "Align center Y",
alignHDistribute: "Distribute horizontally",
alignVDistribute: "Distribute vertically",
alignNeedSelection: "Select at least two nodes",
snapGrid: "Snap to grid",
autoLayoutDiscover: "Auto-layout after discover",
undo: "Undo",
redo: "Redo",
discoverSelected: "Discover selected ({{count}})",
removeSelected: "Remove selected ({{count}})",
openWebcrt: "Open terminal",
openNe: "NE details",
removeNode: "Remove from canvas",
removeEdge: "Delete edge",
nodeMenu: "Node",
selectionMenu: "Selection",
edgeStyle: "Edge style",
edgeColor: "Color",
edgeLineStyle: "Line",
edgeLineSolid: "Solid",
edgeLineDashed: "Dashed",
edgeLineDotted: "Dotted",
edgeWidth: "Width",
edgeStyleReset: "Reset style",
edgeDefaults: "Default edge styles",
edgeDefaultsReset: "Factory reset",
noNeLink: "Not linked to a managed NE",
edgeManual: "Manual",
edgeDiscovered: "Discovered",

View file

@ -885,7 +885,7 @@ const zh = {
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
emptyMaps: "还没有拓扑图,先新建一张。",
palette: "网元库",
paletteHint: "点击添加到画布",
paletteHint: "点击或拖到画布添加",
filterPh: "筛选名称 / IP",
save: "保存",
saving: "保存中…",
@ -897,13 +897,56 @@ const zh = {
discoverFail: "发现失败:{{detail}}",
discoverOneNeedNe: "该节点未关联可扫描网元",
selectMap: "请选择或新建一张拓扑图",
canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删);右键节点可操作",
canvasHint:
"工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。",
selected: "已选节点",
selectedEdge: "已选链路",
selectedCount: "已选 {{count}}",
toolModes: "编辑模式",
toolSelect: "选择",
toolPan: "平移",
toolDrag: "拖动",
toolConnect: "连线",
layout: "布局",
layoutHierarchicalTb: "层次 · 自上而下",
layoutHierarchicalLr: "层次 · 从左到右",
layoutForce: "力导向",
layoutGrid: "网格",
layoutRadial: "环形",
layoutSelected: "仅布局选中(层次)",
layoutNeedSelection: "请先选中要布局的节点",
align: "对齐",
alignLeft: "左对齐",
alignRight: "右对齐",
alignTop: "顶对齐",
alignBottom: "底对齐",
alignHCenter: "水平居中",
alignVCenter: "垂直居中",
alignHDistribute: "水平分布",
alignVDistribute: "垂直分布",
alignNeedSelection: "请至少选中两个节点",
snapGrid: "网格吸附",
autoLayoutDiscover: "发现后自动布局",
undo: "撤销",
redo: "重做",
discoverSelected: "发现选中 ({{count}})",
removeSelected: "移除选中 ({{count}})",
openWebcrt: "打开终端",
openNe: "网元详情",
removeNode: "从画布移除",
removeEdge: "删除链路",
nodeMenu: "网元",
selectionMenu: "多选",
edgeStyle: "链路样式",
edgeColor: "颜色",
edgeLineStyle: "线型",
edgeLineSolid: "实线",
edgeLineDashed: "虚线",
edgeLineDotted: "点线",
edgeWidth: "粗细",
edgeStyleReset: "恢复默认",
edgeDefaults: "默认链路样式",
edgeDefaultsReset: "恢复出厂",
noNeLink: "未关联托管网元",
edgeManual: "人工",
edgeDiscovered: "发现",
@ -911,7 +954,7 @@ const zh = {
fit: "适应画布",
fullscreen: "全屏显示",
exitFullscreen: "退出全屏",
display: "显示选项",
display: "显示",
hideIp: "隐藏 IP",
hideVendor: "隐藏厂商",
hidePorts: "隐藏端口",

View file

@ -2232,22 +2232,37 @@ pre {
}
.topo-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.topo-toolbar__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: nowrap;
min-width: 0;
}
.topo-toolbar__left {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
.topo-toolbar__row--tools {
justify-content: flex-start;
flex-wrap: wrap;
gap: 10px;
padding-top: 2px;
border-top: 1px solid #e2e8f0;
}
.topo-toolbar__group + .topo-toolbar__group,
.topo-tools + .topo-toolbar__group,
.topo-toolbar__group + .topo-toolbar__display,
.topo-tools + .topo-toolbar__display {
margin-left: 2px;
padding-left: 10px;
border-left: 1px solid #e2e8f0;
}
.topo-toolbar__title {
@ -2261,33 +2276,221 @@ pre {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.topo-toolbar__meta {
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.topo-toolbar__actions {
display: flex;
flex-wrap: nowrap;
flex-wrap: wrap;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.topo-toolbar__group {
display: inline-flex;
align-items: center;
gap: 6px;
}
.topo-tools {
display: inline-flex;
gap: 2px;
padding: 2px;
background: #e2e8f0;
border-radius: 8px;
flex-shrink: 0;
}
.topo-tools__btn {
border: 0;
background: transparent;
color: #475569;
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.topo-tools__btn:hover {
background: #cbd5e1;
color: #0f172a;
}
.topo-tools__btn.is-active {
background: #0f172a;
color: #f8fafc;
}
.topo-toolbar__select {
min-width: 88px;
height: 30px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #0f172a;
font-size: 12px;
padding: 0 8px;
}
.topo-toolbar__display {
position: relative;
}
.topo-toolbar__display > summary {
list-style: none;
cursor: pointer;
user-select: none;
height: 30px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #0f172a;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
}
.topo-toolbar__display > summary::-webkit-details-marker {
display: none;
}
.topo-toolbar__display[open] > summary {
border-color: #94a3b8;
background: #f1f5f9;
}
.topo-toolbar__display .topo-display-toggles {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 20;
display: grid;
gap: 6px;
min-width: 260px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.topo-display-defaults {
display: grid;
gap: 8px;
flex: 0 0 auto;
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid #e2e8f0;
}
.topo-display-defaults__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #334155;
}
.topo-display-defaults__row {
display: grid;
grid-template-columns: 48px 28px minmax(72px, 1fr) 64px;
align-items: center;
gap: 6px;
}
.topo-display-defaults__name {
font-size: 12px;
color: #475569;
}
.topo-display-defaults__row input[type="color"] {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
.topo-display-defaults__row .topo-toolbar__select {
min-width: 0;
width: 100%;
}
.topo-canvas.is-pan-mode .react-flow__pane {
cursor: grab;
}
.topo-legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 5;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
padding: 6px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e2e8f0;
font-size: 11px;
color: #475569;
pointer-events: none;
}
.topo-legend__item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.topo-legend__swatch {
display: inline-block;
width: 16px;
height: 0;
border-top: 2px solid #64748b;
vertical-align: middle;
}
.topo-node.is-connect-mode .topo-node__handle {
width: 12px !important;
height: 12px !important;
opacity: 1 !important;
background: #0ea5e9 !important;
border: 2px solid #fff !important;
}
.react-flow__selection {
background: rgba(37, 99, 235, 0.08);
border: 1px solid rgba(37, 99, 235, 0.55);
}
.topo-display-toggles {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 10px;
margin-right: 4px;
font-size: 12px;
color: #475569;
display: grid;
gap: 6px;
}
.topo-display-toggles__item {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
font-size: 12px;
color: #334155;
white-space: nowrap;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.topo-ctx {
@ -2302,6 +2505,90 @@ pre {
border: 1px solid #334155;
border-radius: 8px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28);
font-size: 12px;
line-height: 1.3;
}
.topo-ctx--edge {
min-width: 196px;
}
.topo-ctx__head {
margin: 0;
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: #94a3b8;
}
.topo-ctx__style {
padding: 4px 12px 6px;
}
.topo-ctx__style-title {
margin: 0 0 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: #94a3b8;
}
.topo-ctx__style-grid {
display: grid;
gap: 6px;
}
.topo-ctx__style-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 0;
font-size: 12px;
font-weight: 400;
color: #e2e8f0;
line-height: 1.2;
}
.topo-ctx__style-row > span {
flex: 0 0 auto;
min-width: 2.5em;
color: #cbd5e1;
}
.topo-ctx__style-row input[type="color"] {
flex: 0 0 auto;
box-sizing: border-box;
width: 28px;
height: 22px;
padding: 0;
border: 1px solid #475569;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.topo-ctx__style-row input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.topo-ctx__style-row input[type="color"]::-webkit-color-swatch {
border: 0;
border-radius: 2px;
}
.topo-ctx__style-row select {
flex: 1 1 auto;
max-width: 108px;
height: 24px;
border: 1px solid #475569;
border-radius: 4px;
background: #1e293b;
color: #e2e8f0;
font-size: 12px;
line-height: 1.2;
padding: 0 6px;
}
/* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */
@ -2309,13 +2596,16 @@ pre {
.topo-ctx__item {
display: block;
width: 100%;
padding: 8px 14px;
box-sizing: border-box;
padding: 7px 12px;
border: 0;
border-radius: 0;
background: transparent;
color: #e2e8f0;
text-align: left;
font: inherit;
font-size: 12px;
font-weight: 400;
line-height: 1.3;
cursor: pointer;
}
@ -2481,40 +2771,31 @@ pre {
font-size: 11px;
text-align: center;
/* Default asset is blue (= ZTE). */
--topo-vendor: #0277bd;
--topo-icon-filter: none;
}
.topo-node--cisco {
--topo-vendor: #1d4ed8;
--topo-icon-filter: hue-rotate(-8deg) saturate(1.15);
}
.topo-node--huawei {
--topo-vendor: #c62828;
--topo-icon-filter: hue-rotate(155deg) saturate(1.35);
}
.topo-node--zte {
--topo-vendor: #0277bd;
--topo-icon-filter: none;
}
.topo-node--juniper {
--topo-vendor: #2e7d32;
--topo-icon-filter: hue-rotate(95deg) saturate(1.2);
}
.topo-node--nokia {
--topo-vendor: #00838f;
--topo-icon-filter: hue-rotate(-28deg) saturate(1.1) brightness(0.98);
}
.topo-node--ericsson {
--topo-vendor: #f9a825;
--topo-icon-filter: hue-rotate(175deg) saturate(1.45) brightness(1.05);
}
.topo-node--h3c {
--topo-vendor: #ef6c00;
--topo-icon-filter: hue-rotate(165deg) saturate(1.4);
}
.topo-node--other {
--topo-vendor: #546e7a;
--topo-icon-filter: grayscale(0.85) brightness(0.95);
}
@ -2526,13 +2807,18 @@ pre {
}
.topo-node__handle {
width: 12px !important;
height: 12px !important;
min-width: 12px !important;
min-height: 12px !important;
width: 10px !important;
height: 10px !important;
min-width: 10px !important;
min-height: 10px !important;
background: transparent !important;
border: 0 !important;
opacity: 0;
opacity: 0 !important;
pointer-events: none;
}
.topo-node.is-connect-mode .topo-node__handle {
pointer-events: all;
}
.topo-node__handle--center {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import type { Node } from "@xyflow/react";
export type AlignKind = "left" | "right" | "top" | "bottom" | "hDistribute" | "vDistribute";
export function alignNodes<T extends Record<string, unknown>>(
nodes: Node<T>[],
kind: AlignKind,
selectedIds: Set<string>,
): Node<T>[] {
const selected = nodes.filter((n) => selectedIds.has(n.id));
if (selected.length < 2) return nodes;
const xs = selected.map((n) => n.position.x);
const ys = selected.map((n) => n.position.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const byX = [...selected].sort((a, b) => a.position.x - b.position.x);
const byY = [...selected].sort((a, b) => a.position.y - b.position.y);
const next = new Map<string, { x: number; y: number }>();
if (kind === "left") {
for (const n of selected) next.set(n.id, { x: minX, y: n.position.y });
} else if (kind === "right") {
for (const n of selected) next.set(n.id, { x: maxX, y: n.position.y });
} else if (kind === "top") {
for (const n of selected) next.set(n.id, { x: n.position.x, y: minY });
} else if (kind === "bottom") {
for (const n of selected) next.set(n.id, { x: n.position.x, y: maxY });
} else if (kind === "hDistribute") {
if (byX.length === 2) {
next.set(byX[0].id, { ...byX[0].position });
next.set(byX[1].id, { ...byX[1].position });
} else {
const span = maxX - minX;
byX.forEach((n, i) => {
next.set(n.id, {
x: minX + (span * i) / (byX.length - 1),
y: n.position.y,
});
});
}
} else if (kind === "vDistribute") {
if (byY.length === 2) {
next.set(byY[0].id, { ...byY[0].position });
next.set(byY[1].id, { ...byY[1].position });
} else {
const span = maxY - minY;
byY.forEach((n, i) => {
next.set(n.id, {
x: n.position.x,
y: minY + (span * i) / (byY.length - 1),
});
});
}
}
return nodes.map((n) => {
const p = next.get(n.id);
return p ? { ...n, position: p } : n;
});
}

View file

@ -0,0 +1,234 @@
import dagre from "dagre";
import type { Edge, Node } from "@xyflow/react";
export type LayoutKind = "hierarchical-tb" | "hierarchical-lr" | "force" | "grid" | "radial";
const NODE_W = 140;
const NODE_H = 72;
type XY = { x: number; y: number };
function applyPositions<T extends Record<string, unknown>>(
nodes: Node<T>[],
positions: Map<string, XY>,
): Node<T>[] {
return nodes.map((n) => {
const p = positions.get(n.id);
if (!p) return n;
return { ...n, position: { x: p.x, y: p.y } };
});
}
function layoutDagre<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
rankdir: "TB" | "LR",
): Node<T>[] {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir, nodesep: 48, ranksep: 72, marginx: 24, marginy: 24 });
for (const n of nodes) {
g.setNode(n.id, { width: NODE_W, height: NODE_H });
}
for (const e of edges) {
if (g.hasNode(e.source) && g.hasNode(e.target)) {
g.setEdge(e.source, e.target);
}
}
dagre.layout(g);
const positions = new Map<string, XY>();
for (const n of nodes) {
const dn = g.node(n.id);
if (!dn) continue;
positions.set(n.id, { x: dn.x - NODE_W / 2, y: dn.y - NODE_H / 2 });
}
return applyPositions(nodes, positions);
}
/** Lightweight spring layout (no d3 dependency). */
function layoutForce<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
iterations = 120,
): Node<T>[] {
if (nodes.length === 0) return nodes;
const pos = new Map<string, XY>();
const vel = new Map<string, XY>();
const cx = 400;
const cy = 300;
nodes.forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length;
const r = 80 + nodes.length * 4;
pos.set(n.id, {
x: n.position?.x || cx + Math.cos(angle) * r,
y: n.position?.y || cy + Math.sin(angle) * r,
});
vel.set(n.id, { x: 0, y: 0 });
});
const ids = nodes.map((n) => n.id);
const links = edges
.filter((e) => pos.has(e.source) && pos.has(e.target))
.map((e) => ({ s: e.source, t: e.target }));
const repulsion = 2800;
const attraction = 0.04;
const ideal = 160;
const damping = 0.85;
for (let iter = 0; iter < iterations; iter++) {
for (const id of ids) {
const p = pos.get(id)!;
let fx = 0;
let fy = 0;
for (const other of ids) {
if (other === id) continue;
const q = pos.get(other)!;
let dx = p.x - q.x;
let dy = p.y - q.y;
let dist2 = dx * dx + dy * dy;
if (dist2 < 1) {
dx = Math.random() - 0.5;
dy = Math.random() - 0.5;
dist2 = dx * dx + dy * dy;
}
const dist = Math.sqrt(dist2);
const force = repulsion / dist2;
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
for (const link of links) {
if (link.s !== id && link.t !== id) continue;
const other = link.s === id ? link.t : link.s;
const q = pos.get(other)!;
const dx = q.x - p.x;
const dy = q.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - ideal) * attraction;
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
const v = vel.get(id)!;
v.x = (v.x + fx) * damping;
v.y = (v.y + fy) * damping;
p.x += v.x;
p.y += v.y;
}
}
return applyPositions(nodes, pos);
}
function layoutGrid<T extends Record<string, unknown>>(nodes: Node<T>[]): Node<T>[] {
const cols = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
const gapX = NODE_W + 40;
const gapY = NODE_H + 48;
return nodes.map((n, i) => ({
...n,
position: {
x: (i % cols) * gapX + 40,
y: Math.floor(i / cols) * gapY + 40,
},
}));
}
function layoutRadial<T extends Record<string, unknown>>(nodes: Node<T>[]): Node<T>[] {
if (nodes.length === 0) return nodes;
if (nodes.length === 1) {
return [{ ...nodes[0], position: { x: 320, y: 240 } }];
}
const cx = 400;
const cy = 300;
const r = 60 + nodes.length * 18;
return nodes.map((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
return {
...n,
position: {
x: cx + Math.cos(angle) * r - NODE_W / 2,
y: cy + Math.sin(angle) * r - NODE_H / 2,
},
};
});
}
export function layoutGraph<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
kind: LayoutKind,
opts?: { onlyIds?: Set<string> },
): Node<T>[] {
const only = opts?.onlyIds;
const targets = only && only.size > 0 ? nodes.filter((n) => only.has(n.id)) : nodes;
const targetIds = new Set(targets.map((n) => n.id));
const subEdges = edges.filter((e) => targetIds.has(e.source) && targetIds.has(e.target));
let laid: Node<T>[];
switch (kind) {
case "hierarchical-lr":
laid = layoutDagre(targets, subEdges, "LR");
break;
case "force":
laid = layoutForce(targets, subEdges);
break;
case "grid":
laid = layoutGrid(targets);
break;
case "radial":
laid = layoutRadial(targets);
break;
case "hierarchical-tb":
default:
laid = layoutDagre(targets, subEdges, "TB");
break;
}
if (!only || only.size === 0) return laid;
const byId = new Map(laid.map((n) => [n.id, n]));
return nodes.map((n) => byId.get(n.id) || n);
}
export function alignNodes<T extends Record<string, unknown>>(
nodes: Node<T>[],
ids: string[],
kind: "left" | "right" | "top" | "bottom" | "h-center" | "v-center" | "h-distribute" | "v-distribute",
): Node<T>[] {
const selected = nodes.filter((n) => ids.includes(n.id));
if (selected.length < 2) return nodes;
const xs = selected.map((n) => n.position.x);
const ys = selected.map((n) => n.position.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const pos = new Map<string, XY>();
if (kind === "h-distribute") {
const sorted = [...selected].sort((a, b) => a.position.x - b.position.x);
const span = maxX - minX;
sorted.forEach((n, i) => {
const x = sorted.length === 1 ? minX : minX + (span * i) / (sorted.length - 1);
pos.set(n.id, { x, y: n.position.y });
});
} else if (kind === "v-distribute") {
const sorted = [...selected].sort((a, b) => a.position.y - b.position.y);
const span = maxY - minY;
sorted.forEach((n, i) => {
const y = sorted.length === 1 ? minY : minY + (span * i) / (sorted.length - 1);
pos.set(n.id, { x: n.position.x, y });
});
} else {
for (const n of selected) {
let x = n.position.x;
let y = n.position.y;
if (kind === "left") x = minX;
if (kind === "right") x = maxX;
if (kind === "top") y = minY;
if (kind === "bottom") y = maxY;
if (kind === "h-center") x = midX;
if (kind === "v-center") y = midY;
pos.set(n.id, { x, y });
}
}
return applyPositions(nodes, pos);
}

View file

@ -0,0 +1,64 @@
/** Topology editor interaction modes (NMS-style). */
export type ToolMode = "select" | "pan" | "drag" | "connect";
export type ToolModeBehavior = {
nodesDraggable: boolean;
nodesConnectable: boolean;
elementsSelectable: boolean;
/** false = no pane pan on drag; true = left-drag pans; array = button indices */
panOnDrag: boolean | number[];
selectionOnDrag: boolean;
panOnScroll: boolean;
};
export function behaviorForMode(mode: ToolMode): ToolModeBehavior {
switch (mode) {
case "pan":
return {
nodesDraggable: false,
nodesConnectable: false,
elementsSelectable: false,
panOnDrag: true,
selectionOnDrag: false,
panOnScroll: true,
};
case "drag":
return {
nodesDraggable: true,
nodesConnectable: false,
elementsSelectable: true,
panOnDrag: [1, 2],
selectionOnDrag: false,
panOnScroll: true,
};
case "connect":
return {
nodesDraggable: false,
nodesConnectable: true,
elementsSelectable: true,
panOnDrag: [1, 2],
selectionOnDrag: false,
panOnScroll: true,
};
case "select":
default:
return {
nodesDraggable: true,
nodesConnectable: false,
elementsSelectable: true,
panOnDrag: [1, 2],
selectionOnDrag: true,
panOnScroll: true,
};
}
}
export function toolModeFromKey(key: string): ToolMode | null {
const k = key.toLowerCase();
if (k === "v") return "select";
if (k === "h") return "pan";
if (k === "a") return "drag";
if (k === "c") return "connect";
return null;
}

View file

@ -34,6 +34,7 @@ import type {
PortTrafficDiscoverResponse,
PortTrafficSamples,
PortTrafficTarget,
PortTrafficTargetIn,
PortTrafficTask,
} from "../types";
@ -568,6 +569,9 @@ export const putTopologyGraph = (
source_port?: string;
target_port?: string;
source?: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
}>;
},
) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);

View file

@ -377,6 +377,9 @@ export type TopologyEdgeItem = {
source_port: string;
target_port: string;
source: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
discovered_at?: string | null;
};