mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 08:10:47 +08:00
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:
parent
e4a365fa16
commit
99fce837f0
17 changed files with 1902 additions and 269 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "隐藏端口",
|
||||
|
|
|
|||
|
|
@ -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
64
web/src/pages/topology/alignNodes.ts
Normal file
64
web/src/pages/topology/alignNodes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
234
web/src/pages/topology/layoutGraph.ts
Normal file
234
web/src/pages/topology/layoutGraph.ts
Normal 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);
|
||||
}
|
||||
64
web/src/pages/topology/toolMode.ts
Normal file
64
web/src/pages/topology/toolMode.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue