mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 03:00:46 +08:00
Polish topology chrome for denser dark UI and canvas-aware overlays.
Flatten nested toolbar boxes, add a resizable sidebar and selective off-map NE add, and make fullscreen floats/minimap follow canvas background. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
75d03f5f9b
commit
52cd4d1298
5 changed files with 727 additions and 211 deletions
|
|
@ -1468,6 +1468,12 @@ const en = {
|
||||||
folderHint: "Creating a root auto-creates its unique root map. Add sub-regions on that canvas.",
|
folderHint: "Creating a root auto-creates its unique root map. Add sub-regions on that canvas.",
|
||||||
folderHintShort: "Root folder",
|
folderHintShort: "Root folder",
|
||||||
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
||||||
|
outsidePeersView: "Off-map NEs {{count}}",
|
||||||
|
outsidePeersTitle: "Outside peers",
|
||||||
|
outsidePeersHint: "Linked to this map but not placed. Select and add only what you need.",
|
||||||
|
outsidePeersVia: "via {{name}}",
|
||||||
|
outsidePeersFilterPh: "Filter: name / IP / via",
|
||||||
|
outsidePeersEmpty: "No matching outside peers",
|
||||||
projectNeighbors: "Project neighbors",
|
projectNeighbors: "Project neighbors",
|
||||||
projectedNeighbors: "Projected {{count}} neighbor(s) onto the map",
|
projectedNeighbors: "Projected {{count}} neighbor(s) onto the map",
|
||||||
localBrowserOnly: "this browser only",
|
localBrowserOnly: "this browser only",
|
||||||
|
|
@ -1602,7 +1608,7 @@ const en = {
|
||||||
fit: "Fit view",
|
fit: "Fit view",
|
||||||
liveSync: "Live sync",
|
liveSync: "Live sync",
|
||||||
liveSyncOn: "Syncing…",
|
liveSyncOn: "Syncing…",
|
||||||
liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default. No need to open a map first",
|
liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default",
|
||||||
fullscreen: "Fullscreen",
|
fullscreen: "Fullscreen",
|
||||||
exitFullscreen: "Exit fullscreen",
|
exitFullscreen: "Exit fullscreen",
|
||||||
display: "Display",
|
display: "Display",
|
||||||
|
|
@ -1634,6 +1640,7 @@ const en = {
|
||||||
edgeFlow: "Edge flow",
|
edgeFlow: "Edge flow",
|
||||||
collapseSidebar: "Collapse sidebar",
|
collapseSidebar: "Collapse sidebar",
|
||||||
expandSidebar: "Expand sidebar",
|
expandSidebar: "Expand sidebar",
|
||||||
|
resizeSidebar: "Drag to resize sidebar",
|
||||||
hideAdded: "Hide added",
|
hideAdded: "Hide added",
|
||||||
paletteEmpty: "No matching NEs.",
|
paletteEmpty: "No matching NEs.",
|
||||||
paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.",
|
paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.",
|
||||||
|
|
|
||||||
|
|
@ -1457,6 +1457,12 @@ const zh = {
|
||||||
folderHint: "新建根时自动创建唯一「根图」。子区域请在根图画布上创建。",
|
folderHint: "新建根时自动创建唯一「根图」。子区域请在根图画布上创建。",
|
||||||
folderHintShort: "根目录",
|
folderHintShort: "根目录",
|
||||||
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
||||||
|
outsidePeersView: "未上图网元 {{count}}",
|
||||||
|
outsidePeersTitle: "图外对端",
|
||||||
|
outsidePeersHint: "与画布有链路但未上图。勾选后按需添加,不必一次全部投影。",
|
||||||
|
outsidePeersVia: "经 {{name}}",
|
||||||
|
outsidePeersFilterPh: "筛选:名称 / IP / 经谁",
|
||||||
|
outsidePeersEmpty: "没有匹配的图外对端",
|
||||||
projectNeighbors: "投影邻居上图",
|
projectNeighbors: "投影邻居上图",
|
||||||
projectedNeighbors: "已投影 {{count}} 个邻居上图",
|
projectedNeighbors: "已投影 {{count}} 个邻居上图",
|
||||||
localBrowserOnly: "仅本浏览器",
|
localBrowserOnly: "仅本浏览器",
|
||||||
|
|
@ -1587,7 +1593,7 @@ const zh = {
|
||||||
fit: "适应画布",
|
fit: "适应画布",
|
||||||
liveSync: "实时同步",
|
liveSync: "实时同步",
|
||||||
liveSyncOn: "同步中…",
|
liveSyncOn: "同步中…",
|
||||||
liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新已打开的图(观看 MCP 建图/画拓扑);默认关闭。不需要先打开画布",
|
liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新当前画布(观看 MCP 建图/画拓扑);默认关闭",
|
||||||
fullscreen: "全屏显示",
|
fullscreen: "全屏显示",
|
||||||
exitFullscreen: "退出全屏",
|
exitFullscreen: "退出全屏",
|
||||||
display: "显示",
|
display: "显示",
|
||||||
|
|
@ -1619,6 +1625,7 @@ const zh = {
|
||||||
edgeFlow: "链路流动",
|
edgeFlow: "链路流动",
|
||||||
collapseSidebar: "折叠侧栏",
|
collapseSidebar: "折叠侧栏",
|
||||||
expandSidebar: "展开侧栏",
|
expandSidebar: "展开侧栏",
|
||||||
|
resizeSidebar: "拖动调整侧栏宽度",
|
||||||
hideAdded: "隐藏已添加",
|
hideAdded: "隐藏已添加",
|
||||||
paletteEmpty: "没有匹配的网元。",
|
paletteEmpty: "没有匹配的网元。",
|
||||||
paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。",
|
paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,35 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
scrollbar-color: #475569 #0f172a;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: #475569;
|
||||||
|
border: 2px solid #0f172a;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
|
@ -4305,8 +4334,9 @@ pre {
|
||||||
|
|
||||||
/* ---- Topology — dark sidebar + canvas (shared chrome tokens) ---- */
|
/* ---- Topology — dark sidebar + canvas (shared chrome tokens) ---- */
|
||||||
.topo-page {
|
.topo-page {
|
||||||
|
--topo-sidebar-w: 260px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: var(--topo-sidebar-w) 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
height: calc(100vh - 48px);
|
height: calc(100vh - 48px);
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
|
|
@ -4320,7 +4350,13 @@ pre {
|
||||||
grid-template-columns: 36px 1fr;
|
grid-template-columns: 36px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-page.is-sidebar-resizing {
|
||||||
|
transition: none;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-sidebar {
|
.topo-sidebar {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
@ -4334,6 +4370,35 @@ pre {
|
||||||
box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06);
|
box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__resizer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -8px;
|
||||||
|
z-index: 6;
|
||||||
|
width: 12px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__resizer::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 12%;
|
||||||
|
bottom: 12%;
|
||||||
|
left: 50%;
|
||||||
|
width: 2px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 1px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__resizer:hover::after,
|
||||||
|
.topo-sidebar__resizer.is-dragging::after {
|
||||||
|
background: rgba(59, 130, 246, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
.topo-page.is-sidebar-collapsed .topo-sidebar {
|
.topo-page.is-sidebar-collapsed .topo-sidebar {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|
@ -4366,29 +4431,6 @@ pre {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-sidebar__rail--live {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-sidebar__rail--live.is-on {
|
|
||||||
color: #93c5fd;
|
|
||||||
background: rgba(37, 99, 235, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-sidebar__rail-live-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-sidebar__rail--live.is-on .topo-sidebar__rail-live-dot {
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-sidebar__rail .topo-sidebar__rail-icon {
|
.topo-sidebar__rail .topo-sidebar__rail-icon {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
|
@ -4479,17 +4521,6 @@ pre {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-sidebar__live {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-sidebar__live .btn {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-tree-search__bar > .input {
|
.topo-tree-search__bar > .input {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -6028,36 +6059,29 @@ pre {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-top: 6px;
|
padding-top: 4px;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.16);
|
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-toolbar__group + .topo-toolbar__group,
|
.topo-toolbar__group + .topo-toolbar__group,
|
||||||
.topo-toolbar__panel + .topo-toolbar__group,
|
.topo-toolbar__cluster + .topo-toolbar__group,
|
||||||
.topo-toolbar__group + .topo-toolbar__group--find,
|
.topo-toolbar__group + .topo-toolbar__group--find,
|
||||||
.topo-toolbar__panel + .topo-toolbar__group--find {
|
.topo-toolbar__cluster + .topo-toolbar__group--find {
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
border-left: 1px solid rgba(148, 163, 184, 0.2);
|
border-left: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-toolbar__panel {
|
/* Flat cluster: no nested chrome — only a separator from tool modes. */
|
||||||
|
.topo-toolbar__cluster {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 3px 6px;
|
min-width: 0;
|
||||||
background: rgba(2, 8, 23, 0.55);
|
margin-left: 2px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
padding-left: 10px;
|
||||||
border-radius: 6px;
|
border-left: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
}
|
|
||||||
|
|
||||||
.topo-toolbar__panel .topo-tools {
|
|
||||||
background: rgba(15, 23, 42, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-toolbar__panel .topo-toolbar__display > summary {
|
|
||||||
background: #0b1220;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-toolbar__group--find {
|
.topo-toolbar__group--find {
|
||||||
|
|
@ -6196,11 +6220,8 @@ pre {
|
||||||
|
|
||||||
.topo-tools {
|
.topo-tools {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 2px;
|
|
||||||
background: rgba(2, 8, 23, 0.55);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
|
||||||
border-radius: 6px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6213,8 +6234,8 @@ pre {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 4px 8px;
|
padding: 5px 9px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
@ -6881,6 +6902,24 @@ pre {
|
||||||
background: var(--topo-canvas-bg, #0f172a);
|
background: var(--topo-canvas-bg, #0f172a);
|
||||||
--xy-edge-label-background-color: transparent;
|
--xy-edge-label-background-color: transparent;
|
||||||
--xy-edge-label-color: var(--topo-edge-label-color, #e2e8f0);
|
--xy-edge-label-color: var(--topo-edge-label-color, #e2e8f0);
|
||||||
|
/* Defaults = dark canvas; TopologyPage overrides via canvasFloatChromeVars. */
|
||||||
|
--topo-float-bg: rgba(15, 23, 42, 0.92);
|
||||||
|
--topo-float-border: rgba(148, 163, 184, 0.22);
|
||||||
|
--topo-float-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
--topo-float-fg: #e2e8f0;
|
||||||
|
--topo-float-muted: #94a3b8;
|
||||||
|
--topo-float-btn-bg: #0b1220;
|
||||||
|
--topo-float-btn-border: rgba(148, 163, 184, 0.28);
|
||||||
|
--topo-float-btn-hover: #152033;
|
||||||
|
--topo-float-active-bg: rgba(37, 99, 235, 0.28);
|
||||||
|
--topo-float-active-fg: #93c5fd;
|
||||||
|
--topo-float-active-border: #3b82f6;
|
||||||
|
--topo-float-control-bg: #0f172a;
|
||||||
|
--topo-float-control-hover: #1e293b;
|
||||||
|
--topo-float-control-fill: #cbd5e1;
|
||||||
|
--topo-float-input-bg: #0b1220;
|
||||||
|
--topo-float-input-fg: #e2e8f0;
|
||||||
|
--topo-float-input-border: rgba(148, 163, 184, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-canvas:fullscreen,
|
.topo-canvas:fullscreen,
|
||||||
|
|
@ -6894,31 +6933,39 @@ pre {
|
||||||
.topo-canvas .react-flow__controls {
|
.topo-canvas .react-flow__controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
box-shadow: var(--topo-float-shadow);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--topo-float-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-canvas .react-flow__controls-button {
|
.topo-canvas .react-flow__controls-button {
|
||||||
background: #0f172a;
|
background: transparent;
|
||||||
border-bottom-color: rgba(148, 163, 184, 0.22);
|
border: 0;
|
||||||
fill: #cbd5e1;
|
border-bottom: 1px solid var(--topo-float-border);
|
||||||
|
color: var(--topo-float-control-fill);
|
||||||
|
fill: var(--topo-float-control-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas .react-flow__controls-button:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-canvas .react-flow__controls-button:hover {
|
.topo-canvas .react-flow__controls-button:hover {
|
||||||
background: #1e293b;
|
background: var(--topo-float-control-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-canvas .react-flow__controls-button svg {
|
.topo-canvas .react-flow__controls-button svg,
|
||||||
fill: #cbd5e1;
|
.topo-canvas .react-flow__controls-button svg path {
|
||||||
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-canvas .react-flow__minimap {
|
.topo-canvas .react-flow__minimap {
|
||||||
background: #0b1220;
|
border: 0;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
border-radius: 8px;
|
||||||
border-radius: 6px;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
box-shadow: var(--topo-float-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-canvas .react-flow__controls-button.topo-fs-control {
|
.topo-canvas .react-flow__controls-button.topo-fs-control {
|
||||||
|
|
@ -6928,16 +6975,13 @@ pre {
|
||||||
.topo-canvas .react-flow__controls-button.topo-fs-control svg {
|
.topo-canvas .react-flow__controls-button.topo-fs-control svg {
|
||||||
max-width: 14px;
|
max-width: 14px;
|
||||||
max-height: 14px;
|
max-height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-toolbar__panel--view {
|
.topo-toolbar__cluster .topo-toolbar__group--find {
|
||||||
margin-left: 2px;
|
|
||||||
padding-left: 10px;
|
|
||||||
border-left: 1px solid rgba(148, 163, 184, 0.2);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-toolbar__panel--view .topo-toolbar__group--find {
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
|
|
@ -6953,9 +6997,10 @@ pre {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: var(--topo-float-bg);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
border: 1px solid var(--topo-float-border);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
box-shadow: var(--topo-float-shadow);
|
||||||
|
color: var(--topo-float-fg);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6970,9 +7015,10 @@ pre {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: var(--topo-float-bg);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
border: 1px solid var(--topo-float-border);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
box-shadow: var(--topo-float-shadow);
|
||||||
|
color: var(--topo-float-fg);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
max-width: min(520px, calc(100% - 24px));
|
max-width: min(520px, calc(100% - 24px));
|
||||||
}
|
}
|
||||||
|
|
@ -7006,32 +7052,71 @@ pre {
|
||||||
right: 12px;
|
right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Beat `.app-main button { background:#0b1220 }` so inactive chips stay ghost. */
|
||||||
|
.app-main .topo-fs-toolbar__btn,
|
||||||
|
.topo-canvas .topo-fs-toolbar__btn,
|
||||||
.topo-fs-toolbar__btn {
|
.topo-fs-toolbar__btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 10px;
|
height: auto;
|
||||||
border-radius: 8px;
|
padding: 6px 10px !important;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
border-radius: 8px !important;
|
||||||
background: #0b1220;
|
border: 1px solid transparent !important;
|
||||||
color: #e2e8f0;
|
background: transparent !important;
|
||||||
|
color: var(--topo-float-muted) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main .topo-fs-toolbar__btn:hover,
|
||||||
|
.topo-canvas .topo-fs-toolbar__btn:hover,
|
||||||
.topo-fs-toolbar__btn:hover {
|
.topo-fs-toolbar__btn:hover {
|
||||||
background: #152033;
|
background: var(--topo-float-btn-hover) !important;
|
||||||
|
color: var(--topo-float-fg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main .topo-fs-toolbar__btn.is-active,
|
||||||
|
.topo-canvas .topo-fs-toolbar__btn.is-active,
|
||||||
.topo-fs-toolbar__btn.is-active {
|
.topo-fs-toolbar__btn.is-active {
|
||||||
border-color: #3b82f6;
|
border-color: var(--topo-float-active-border) !important;
|
||||||
background: rgba(37, 99, 235, 0.28);
|
background: var(--topo-float-active-bg) !important;
|
||||||
color: #93c5fd;
|
color: var(--topo-float-active-fg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main .topo-fs-toolbar__btn kbd,
|
||||||
|
.topo-canvas .topo-fs-toolbar__btn kbd,
|
||||||
.topo-fs-toolbar__btn kbd {
|
.topo-fs-toolbar__btn kbd {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
border-color: currentColor;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beat global dark toolbar overrides when canvas bg is light. */
|
||||||
|
.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__find,
|
||||||
|
.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__select,
|
||||||
|
.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__display > summary {
|
||||||
|
background: var(--topo-float-input-bg) !important;
|
||||||
|
border-color: var(--topo-float-input-border) !important;
|
||||||
|
color: var(--topo-float-input-fg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas.is-canvas-light .topo-view-tools .topo-toolbar__find::placeholder {
|
||||||
|
color: var(--topo-float-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas .topo-view-tools .topo-toolbar__find,
|
||||||
|
.topo-canvas .topo-view-tools .topo-toolbar__select,
|
||||||
|
.topo-canvas .topo-view-tools .topo-toolbar__display > summary {
|
||||||
|
background: var(--topo-float-input-bg);
|
||||||
|
border-color: var(--topo-float-input-border);
|
||||||
|
color: var(--topo-float-input-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-canvas__empty {
|
.topo-canvas__empty {
|
||||||
|
|
@ -7330,15 +7415,36 @@ pre {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-outside-peers {
|
.app-main .topo-toolbar__outside-peers,
|
||||||
display: flex;
|
.topo-toolbar__outside-peers {
|
||||||
flex-direction: column;
|
margin-left: auto;
|
||||||
gap: 6px;
|
flex-shrink: 0;
|
||||||
padding: 0 10px 10px;
|
white-space: nowrap;
|
||||||
|
width: auto;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
background: #0b1220;
|
||||||
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-outside-peers .panel__hint {
|
.app-main .topo-toolbar__outside-peers:hover,
|
||||||
margin: 0;
|
.topo-toolbar__outside-peers:hover {
|
||||||
|
background: #152033;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-modal__panel--wide {
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-modal__count {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-node--cisco {
|
.topo-node--cisco {
|
||||||
|
|
@ -7659,6 +7765,10 @@ pre {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-sidebar__resizer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-sidebar {
|
.topo-sidebar {
|
||||||
max-height: 40vh;
|
max-height: 40vh;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
@ -89,6 +90,39 @@ function isPlaceholderSource(source: string | undefined, neIp: string): boolean
|
||||||
|
|
||||||
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
|
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
|
||||||
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
|
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
|
||||||
|
const SIDEBAR_WIDTH_KEY = "netx.topology.sidebarWidth";
|
||||||
|
const SIDEBAR_WIDTH_DEFAULT = 260;
|
||||||
|
const SIDEBAR_WIDTH_MIN = 180;
|
||||||
|
const SIDEBAR_WIDTH_MAX = 560;
|
||||||
|
|
||||||
|
function loadSidebarWidth(): number {
|
||||||
|
try {
|
||||||
|
const n = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY));
|
||||||
|
if (Number.isFinite(n) && n >= SIDEBAR_WIDTH_MIN) {
|
||||||
|
return Math.min(Math.round(n), SIDEBAR_WIDTH_MAX);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return SIDEBAR_WIDTH_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSidebarWidth(width: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(width)));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampSidebarWidth(width: number, pageWidth?: number): number {
|
||||||
|
const maxByPage =
|
||||||
|
pageWidth && pageWidth > 0
|
||||||
|
? Math.max(SIDEBAR_WIDTH_MIN, Math.floor(pageWidth * 0.55))
|
||||||
|
: SIDEBAR_WIDTH_MAX;
|
||||||
|
const maxW = Math.min(SIDEBAR_WIDTH_MAX, maxByPage);
|
||||||
|
return Math.max(SIDEBAR_WIDTH_MIN, Math.min(maxW, Math.round(width)));
|
||||||
|
}
|
||||||
|
|
||||||
function findViewInRegion(
|
function findViewInRegion(
|
||||||
regions: TopologyTreeFolderItem[],
|
regions: TopologyTreeFolderItem[],
|
||||||
|
|
@ -677,6 +711,61 @@ function isHexColor(value: string): boolean {
|
||||||
return /^#[0-9a-fA-F]{6}$/.test(value);
|
return /^#[0-9a-fA-F]{6}$/.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canvasBgLuminance(bg: string): number {
|
||||||
|
const hex = String(bg || "").replace("#", "").trim();
|
||||||
|
if (hex.length !== 6) return 0.1;
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16);
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16);
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16);
|
||||||
|
if (![r, g, b].every((n) => Number.isFinite(n))) return 0.1;
|
||||||
|
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Floating chrome (fs toolbar / view tools / controls) follows canvas bg like MiniMap. */
|
||||||
|
function canvasFloatChromeVars(bg: string): CSSProperties {
|
||||||
|
const light = canvasBgLuminance(bg) > 0.55;
|
||||||
|
if (light) {
|
||||||
|
return {
|
||||||
|
"--topo-float-bg": "rgba(255, 255, 255, 0.92)",
|
||||||
|
"--topo-float-border": "rgba(15, 23, 42, 0.08)",
|
||||||
|
"--topo-float-shadow": "0 8px 24px rgba(15, 23, 42, 0.12)",
|
||||||
|
"--topo-float-fg": "#0f172a",
|
||||||
|
"--topo-float-muted": "#64748b",
|
||||||
|
"--topo-float-btn-bg": "transparent",
|
||||||
|
"--topo-float-btn-border": "transparent",
|
||||||
|
"--topo-float-btn-hover": "rgba(15, 23, 42, 0.06)",
|
||||||
|
"--topo-float-active-bg": "rgba(37, 99, 235, 0.16)",
|
||||||
|
"--topo-float-active-fg": "#1d4ed8",
|
||||||
|
"--topo-float-active-border": "#3b82f6",
|
||||||
|
"--topo-float-control-bg": "transparent",
|
||||||
|
"--topo-float-control-hover": "rgba(15, 23, 42, 0.06)",
|
||||||
|
"--topo-float-control-fill": "#334155",
|
||||||
|
"--topo-float-input-bg": "#ffffff",
|
||||||
|
"--topo-float-input-fg": "#0f172a",
|
||||||
|
"--topo-float-input-border": "rgba(15, 23, 42, 0.16)",
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"--topo-float-bg": "rgba(15, 23, 42, 0.92)",
|
||||||
|
"--topo-float-border": "rgba(148, 163, 184, 0.18)",
|
||||||
|
"--topo-float-shadow": "0 8px 24px rgba(0, 0, 0, 0.35)",
|
||||||
|
"--topo-float-fg": "#e2e8f0",
|
||||||
|
"--topo-float-muted": "#94a3b8",
|
||||||
|
"--topo-float-btn-bg": "transparent",
|
||||||
|
"--topo-float-btn-border": "transparent",
|
||||||
|
"--topo-float-btn-hover": "rgba(30, 41, 59, 0.9)",
|
||||||
|
"--topo-float-active-bg": "rgba(37, 99, 235, 0.28)",
|
||||||
|
"--topo-float-active-fg": "#93c5fd",
|
||||||
|
"--topo-float-active-border": "#3b82f6",
|
||||||
|
"--topo-float-control-bg": "transparent",
|
||||||
|
"--topo-float-control-hover": "rgba(30, 41, 59, 0.9)",
|
||||||
|
"--topo-float-control-fill": "#cbd5e1",
|
||||||
|
"--topo-float-input-bg": "#0b1220",
|
||||||
|
"--topo-float-input-fg": "#e2e8f0",
|
||||||
|
"--topo-float-input-border": "rgba(148, 163, 184, 0.28)",
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
function loadCanvasBg(): string {
|
function loadCanvasBg(): string {
|
||||||
try {
|
try {
|
||||||
const raw = String(localStorage.getItem(CANVAS_BG_KEY) || "").trim().toLowerCase();
|
const raw = String(localStorage.getItem(CANVAS_BG_KEY) || "").trim().toLowerCase();
|
||||||
|
|
@ -1054,7 +1143,15 @@ export function TopologyPage() {
|
||||||
loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, true),
|
loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, true),
|
||||||
);
|
);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(() => loadSidebarWidth());
|
||||||
|
const [sidebarResizing, setSidebarResizing] = useState(false);
|
||||||
|
const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null);
|
||||||
|
const topoPageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [addNeOpen, setAddNeOpen] = useState(false);
|
const [addNeOpen, setAddNeOpen] = useState(false);
|
||||||
|
const [outsidePeersOpen, setOutsidePeersOpen] = useState(false);
|
||||||
|
const [outsidePeerSelectedIds, setOutsidePeerSelectedIds] = useState<string[]>([]);
|
||||||
|
const [outsidePeerQuery, setOutsidePeerQuery] = useState("");
|
||||||
|
const [outsidePeersAdding, setOutsidePeersAdding] = useState(false);
|
||||||
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||||
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
||||||
const [paletteAdding, setPaletteAdding] = useState(false);
|
const [paletteAdding, setPaletteAdding] = useState(false);
|
||||||
|
|
@ -3066,38 +3163,88 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
}, [staleEdgeIds, queueDeleteEdges]);
|
}, [staleEdgeIds, queueDeleteEdges]);
|
||||||
|
|
||||||
const projectOutsidePeers = useCallback(async () => {
|
const addOutsidePeers = useCallback(
|
||||||
if (!mapId) return;
|
async (fabricNodeIds: string[]): Promise<boolean> => {
|
||||||
try {
|
if (!mapId) return false;
|
||||||
const before = nodes.length;
|
const ids = [...new Set(fabricNodeIds.map((x) => String(x || "").trim()).filter(Boolean))];
|
||||||
const projected = await projectTopologyNeighbors(mapId, { dry_run: true });
|
if (!ids.length || outsidePeersAdding) return false;
|
||||||
if (projected.truncate_reason === "membership_frozen") {
|
setOutsidePeersAdding(true);
|
||||||
showError(t("topology.truncatedFrozen"));
|
try {
|
||||||
return;
|
const onCanvas = new Set(nodes.map((n) => n.id));
|
||||||
|
const toAdd = ids.filter((id) => !onCanvas.has(id));
|
||||||
|
if (!toAdd.length) {
|
||||||
|
setOutsidePeersOpen(false);
|
||||||
|
setOutsidePeerSelectedIds([]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const graph = await addTopologyViewNodes(mapId, {
|
||||||
|
fabric_node_ids: toAdd,
|
||||||
|
layout: "grid",
|
||||||
|
});
|
||||||
|
if (graph.truncate_reason === "membership_frozen") {
|
||||||
|
showError(t("topology.truncatedFrozen"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const addedSet = new Set(toAdd);
|
||||||
|
const added = (graph.nodes || []).filter((n) => addedSet.has(n.fabric_node_id));
|
||||||
|
if (added.length > 0) {
|
||||||
|
const peerList = graphQuery.data?.outside_peers || [];
|
||||||
|
const viaAnchors = new Map(
|
||||||
|
peerList
|
||||||
|
.filter((p) => addedSet.has(p.fabric_node_id))
|
||||||
|
.map((p) => [p.fabric_node_id, p.via_node_id] as const),
|
||||||
|
);
|
||||||
|
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
|
||||||
|
await patchTopologyPositions(
|
||||||
|
mapId,
|
||||||
|
added.map((n, i) => {
|
||||||
|
const viaId = viaAnchors.get(n.fabric_node_id);
|
||||||
|
const viaNode = viaId ? nodes.find((x) => x.id === viaId) : undefined;
|
||||||
|
const base = viaNode
|
||||||
|
? { x: viaNode.position.x + 200, y: viaNode.position.y }
|
||||||
|
: { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
|
||||||
|
return {
|
||||||
|
fabric_node_id: n.fabric_node_id,
|
||||||
|
x: base.x + (i % cols) * 180,
|
||||||
|
y: base.y + Math.floor(i / cols) * 120,
|
||||||
|
label: n.label || n.name || "",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const refreshed = await fetchTopologyGraph(mapId);
|
||||||
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||||
|
historyLockRef.current = true;
|
||||||
|
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
|
||||||
|
historyLockRef.current = false;
|
||||||
|
clearDirty();
|
||||||
|
const count = added.length || toAdd.length;
|
||||||
|
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(count)));
|
||||||
|
setOutsidePeerSelectedIds([]);
|
||||||
|
setOutsidePeersOpen(false);
|
||||||
|
return count > 0;
|
||||||
|
} catch (err) {
|
||||||
|
showError(String(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setOutsidePeersAdding(false);
|
||||||
}
|
}
|
||||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
},
|
||||||
historyLockRef.current = true;
|
[
|
||||||
applyViewGraph(projected, edgeDefaults, setNodes, setEdges, localPos);
|
mapId,
|
||||||
historyLockRef.current = false;
|
nodes,
|
||||||
const added = Math.max(0, projected.nodes.length - before);
|
graphQuery.data?.outside_peers,
|
||||||
if (added > 0) {
|
outsidePeersAdding,
|
||||||
markDirty();
|
edgeDefaults,
|
||||||
}
|
setNodes,
|
||||||
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(added)));
|
setEdges,
|
||||||
} catch (err) {
|
clearDirty,
|
||||||
showError(String(err));
|
queryClient,
|
||||||
}
|
showOk,
|
||||||
}, [
|
showError,
|
||||||
mapId,
|
t,
|
||||||
nodes,
|
],
|
||||||
edgeDefaults,
|
);
|
||||||
setNodes,
|
|
||||||
setEdges,
|
|
||||||
markDirty,
|
|
||||||
showOk,
|
|
||||||
showError,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
|
@ -3816,6 +3963,23 @@ export function TopologyPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const outsidePeers = graphQuery.data?.outside_peers || [];
|
const outsidePeers = graphQuery.data?.outside_peers || [];
|
||||||
|
const outsidePeerNameById = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
const label = String(n.data?.label || n.data?.subtitle || "").trim();
|
||||||
|
m.set(n.id, label || n.id.slice(0, 8));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [nodes]);
|
||||||
|
const outsidePeersVisible = useMemo(() => {
|
||||||
|
const q = outsidePeerQuery.trim().toLowerCase();
|
||||||
|
if (!q) return outsidePeers;
|
||||||
|
return outsidePeers.filter((p) => {
|
||||||
|
const viaName = outsidePeerNameById.get(p.via_node_id) || p.via_node_id;
|
||||||
|
const bits = [p.name, p.ip, viaName, p.fabric_node_id];
|
||||||
|
return bits.some((b) => String(b || "").toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
}, [outsidePeers, outsidePeerQuery, outsidePeerNameById]);
|
||||||
const graphTruncated = Boolean(graphQuery.data?.truncated);
|
const graphTruncated = Boolean(graphQuery.data?.truncated);
|
||||||
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
|
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
|
||||||
const worldScatter = graphQuery.data?.scatter || [];
|
const worldScatter = graphQuery.data?.scatter || [];
|
||||||
|
|
@ -3924,8 +4088,53 @@ export function TopologyPage() {
|
||||||
(paletteSource === "managed" && neQuery.isLoading) ||
|
(paletteSource === "managed" && neQuery.isLoading) ||
|
||||||
(paletteSource === "ume" && umeQuery.isLoading);
|
(paletteSource === "ume" && umeQuery.isLoading);
|
||||||
|
|
||||||
|
const onSidebarResizeDown = useCallback(
|
||||||
|
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (sidebarCollapsed) return;
|
||||||
|
e.preventDefault();
|
||||||
|
sidebarDragRef.current = { startX: e.clientX, startW: sidebarWidth };
|
||||||
|
setSidebarResizing(true);
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const drag = sidebarDragRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
const pageW = topoPageRef.current?.clientWidth || window.innerWidth;
|
||||||
|
const next = clampSidebarWidth(drag.startW + (ev.clientX - drag.startX), pageW);
|
||||||
|
setSidebarWidth(next);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
sidebarDragRef.current = null;
|
||||||
|
setSidebarResizing(false);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
setSidebarWidth((w) => {
|
||||||
|
const pageW = topoPageRef.current?.clientWidth || window.innerWidth;
|
||||||
|
const next = clampSidebarWidth(w, pageW);
|
||||||
|
saveSidebarWidth(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
},
|
||||||
|
[sidebarCollapsed, sidebarWidth],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
|
<div
|
||||||
|
ref={topoPageRef}
|
||||||
|
className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}${
|
||||||
|
sidebarResizing ? " is-sidebar-resizing" : ""
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
sidebarCollapsed
|
||||||
|
? undefined
|
||||||
|
: ({ "--topo-sidebar-w": `${sidebarWidth}px` } as CSSProperties)
|
||||||
|
}
|
||||||
|
>
|
||||||
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
|
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
|
||||||
{sidebarCollapsed ? (
|
{sidebarCollapsed ? (
|
||||||
<div className="topo-sidebar__rail-wrap">
|
<div className="topo-sidebar__rail-wrap">
|
||||||
|
|
@ -3940,16 +4149,6 @@ export function TopologyPage() {
|
||||||
<SidebarFoldIcon expand />
|
<SidebarFoldIcon expand />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`topo-sidebar__rail topo-sidebar__rail--live${liveSync ? " is-on" : ""}`}
|
|
||||||
aria-pressed={liveSync}
|
|
||||||
title={t("topology.liveSyncHint")}
|
|
||||||
aria-label={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
|
||||||
onClick={() => setLiveSync((v) => !v)}
|
|
||||||
>
|
|
||||||
<span className="topo-sidebar__rail-live-dot" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -3988,17 +4187,6 @@ export function TopologyPage() {
|
||||||
<SidebarFoldIcon />
|
<SidebarFoldIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="topo-sidebar__live">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
|
|
||||||
aria-pressed={liveSync}
|
|
||||||
title={t("topology.liveSyncHint")}
|
|
||||||
onClick={() => setLiveSync((v) => !v)}
|
|
||||||
>
|
|
||||||
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{treeSearchOpen && treeNeQuery.trim() ? (
|
{treeSearchOpen && treeNeQuery.trim() ? (
|
||||||
<div className="topo-tree-search__panel" role="listbox">
|
<div className="topo-tree-search__panel" role="listbox">
|
||||||
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
|
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
|
||||||
|
|
@ -4102,23 +4290,19 @@ export function TopologyPage() {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mapId && outsidePeers.length > 0 && (
|
|
||||||
<div className="topo-outside-peers">
|
|
||||||
<p className="panel__hint">
|
|
||||||
{t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn--sm"
|
|
||||||
onClick={() => void projectOutsidePeers()}
|
|
||||||
>
|
|
||||||
{t("topology.projectNeighbors")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!sidebarCollapsed ? (
|
||||||
|
<div
|
||||||
|
className={`topo-sidebar__resizer${sidebarResizing ? " is-dragging" : ""}`}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label={t("topology.resizeSidebar")}
|
||||||
|
title={t("topology.resizeSidebar")}
|
||||||
|
onMouseDown={onSidebarResizeDown}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="topo-main">
|
<main className="topo-main">
|
||||||
|
|
@ -4190,6 +4374,15 @@ export function TopologyPage() {
|
||||||
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
|
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="topo-toolbar__actions">
|
<div className="topo-toolbar__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
|
||||||
|
aria-pressed={liveSync}
|
||||||
|
title={t("topology.liveSyncHint")}
|
||||||
|
onClick={() => setLiveSync((v) => !v)}
|
||||||
|
>
|
||||||
|
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
|
|
@ -4294,40 +4487,52 @@ export function TopologyPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="topo-toolbar__row topo-toolbar__row--tools">
|
<div className="topo-toolbar__row topo-toolbar__row--tools">
|
||||||
<div className="topo-toolbar__panel" aria-label={t("topology.toolModes")}>
|
<div className="topo-tools" role="toolbar" aria-label={t("topology.toolModes")}>
|
||||||
<div className="topo-tools" role="toolbar" aria-label={t("topology.toolModes")}>
|
{(
|
||||||
{(
|
[
|
||||||
[
|
["select", t("topology.toolSelect"), "V"],
|
||||||
["select", t("topology.toolSelect"), "V"],
|
["pan", t("topology.toolPan"), "H"],
|
||||||
["pan", t("topology.toolPan"), "H"],
|
["connect", t("topology.toolConnect"), "C"],
|
||||||
["connect", t("topology.toolConnect"), "C"],
|
] as const
|
||||||
] as const
|
).map(([mode, label, key]) => (
|
||||||
).map(([mode, label, key]) => (
|
<button
|
||||||
<button
|
key={mode}
|
||||||
key={mode}
|
type="button"
|
||||||
type="button"
|
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
|
||||||
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
|
title={`${label} (${key})`}
|
||||||
title={`${label} (${key})`}
|
aria-pressed={toolMode === mode}
|
||||||
aria-pressed={toolMode === mode}
|
onClick={() => {
|
||||||
onClick={() => {
|
setToolMode(mode);
|
||||||
setToolMode(mode);
|
connectClickRef.current = null;
|
||||||
connectClickRef.current = null;
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<span className="topo-tools__label">{label}</span>
|
||||||
<span className="topo-tools__label">{label}</span>
|
<kbd className="topo-tools__key">{key}</kbd>
|
||||||
<kbd className="topo-tools__key">{key}</kbd>
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{!fullscreen ? (
|
{!fullscreen ? (
|
||||||
<div
|
<div
|
||||||
className="topo-toolbar__panel topo-toolbar__panel--view"
|
className="topo-toolbar__cluster"
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label={t("topology.display")}
|
aria-label={t("topology.display")}
|
||||||
ref={setViewToolsToolbarSlot}
|
ref={setViewToolsToolbarSlot}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{outsidePeers.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost topo-toolbar__outside-peers"
|
||||||
|
title={t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))}
|
||||||
|
onClick={() => {
|
||||||
|
setOutsidePeerQuery("");
|
||||||
|
setOutsidePeerSelectedIds([]);
|
||||||
|
setOutsidePeersOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topology.outsidePeersView").replace("{{count}}", String(outsidePeers.length))}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -4465,15 +4670,6 @@ export function TopologyPage() {
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
|
|
||||||
aria-pressed={liveSync}
|
|
||||||
title={t("topology.liveSyncHint")}
|
|
||||||
onClick={() => setLiveSync((v) => !v)}
|
|
||||||
>
|
|
||||||
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{!hexBrowseRegion ? (
|
{!hexBrowseRegion ? (
|
||||||
treeLoading ? (
|
treeLoading ? (
|
||||||
|
|
@ -4699,7 +4895,9 @@ export function TopologyPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}${toolMode === "pan" ? " is-pan-mode" : ""}`}
|
className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}${
|
||||||
|
toolMode === "pan" ? " is-pan-mode" : ""
|
||||||
|
}${canvasBgLuminance(canvasBg) > 0.55 ? " is-canvas-light" : ""}`}
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
|
|
@ -4716,6 +4914,7 @@ export function TopologyPage() {
|
||||||
"--topo-vendor-ruijie": vendorColors.ruijie,
|
"--topo-vendor-ruijie": vendorColors.ruijie,
|
||||||
"--topo-vendor-mikrotik": vendorColors.mikrotik,
|
"--topo-vendor-mikrotik": vendorColors.mikrotik,
|
||||||
"--topo-vendor-gray": vendorColors.gray,
|
"--topo-vendor-gray": vendorColors.gray,
|
||||||
|
...canvasFloatChromeVars(canvasBg),
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
onDragOver={onCanvasDragOver}
|
onDragOver={onCanvasDragOver}
|
||||||
|
|
@ -5502,6 +5701,164 @@ export function TopologyPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{outsidePeersOpen && canvasMode ? (
|
||||||
|
<div
|
||||||
|
className="topo-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("topology.outsidePeersTitle")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="topo-modal__backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
if (outsidePeersAdding) return;
|
||||||
|
setOutsidePeersOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="topo-modal__panel topo-modal__panel--wide">
|
||||||
|
<div className="topo-modal__head">
|
||||||
|
<strong>
|
||||||
|
{t("topology.outsidePeersTitle")}
|
||||||
|
<span className="topo-modal__count"> · {outsidePeers.length}</span>
|
||||||
|
</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={outsidePeersAdding}
|
||||||
|
onClick={() => setOutsidePeersOpen(false)}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="panel__hint topo-modal__hint">{t("topology.outsidePeersHint")}</p>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={outsidePeerQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOutsidePeerQuery(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder={t("topology.outsidePeersFilterPh")}
|
||||||
|
disabled={outsidePeersAdding}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{outsidePeersVisible.length > 0 ? (
|
||||||
|
<div className="topo-modal__selectbar">
|
||||||
|
<label className="topo-modal__selectall">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={
|
||||||
|
outsidePeersVisible.length > 0 &&
|
||||||
|
outsidePeersVisible.every((p) =>
|
||||||
|
outsidePeerSelectedIds.includes(p.fabric_node_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={outsidePeersAdding}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setOutsidePeerSelectedIds((prev) => [
|
||||||
|
...new Set([
|
||||||
|
...prev,
|
||||||
|
...outsidePeersVisible.map((p) => p.fabric_node_id),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const drop = new Set(outsidePeersVisible.map((p) => p.fabric_node_id));
|
||||||
|
setOutsidePeerSelectedIds((prev) => prev.filter((id) => !drop.has(id)));
|
||||||
|
}}
|
||||||
|
aria-label={t("topology.selectAllVisible")}
|
||||||
|
/>
|
||||||
|
<span>{t("topology.selectAllVisible")}</span>
|
||||||
|
</label>
|
||||||
|
<span className="panel__hint">
|
||||||
|
{t("topology.selectedCount").replace(
|
||||||
|
"{{count}}",
|
||||||
|
String(outsidePeerSelectedIds.length),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ul className="topo-palette topo-modal__list">
|
||||||
|
{outsidePeersVisible.length === 0 ? (
|
||||||
|
<li className="topo-palette__empty">
|
||||||
|
<span className="panel__hint">{t("topology.outsidePeersEmpty")}</span>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
outsidePeersVisible.map((peer) => {
|
||||||
|
const checked = outsidePeerSelectedIds.includes(peer.fabric_node_id);
|
||||||
|
const viaName =
|
||||||
|
outsidePeerNameById.get(peer.via_node_id) || peer.via_node_id.slice(0, 8);
|
||||||
|
const title = peer.name || peer.ip || peer.fabric_node_id.slice(0, 8);
|
||||||
|
return (
|
||||||
|
<li key={peer.fabric_node_id}>
|
||||||
|
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
|
||||||
|
<label className="topo-palette__check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={outsidePeersAdding}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOutsidePeerSelectedIds((prev) =>
|
||||||
|
e.target.checked
|
||||||
|
? [...new Set([...prev, peer.fabric_node_id])]
|
||||||
|
: prev.filter((id) => id !== peer.fabric_node_id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
aria-label={title}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-palette__item"
|
||||||
|
disabled={outsidePeersAdding}
|
||||||
|
onClick={() => {
|
||||||
|
setOutsidePeerSelectedIds((prev) =>
|
||||||
|
prev.includes(peer.fabric_node_id)
|
||||||
|
? prev.filter((id) => id !== peer.fabric_node_id)
|
||||||
|
: [...prev, peer.fabric_node_id],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="topo-palette__name">{title}</span>
|
||||||
|
<span className="topo-palette__meta">
|
||||||
|
{[peer.ip, t("topology.outsidePeersVia").replace("{{name}}", viaName)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="topo-modal__foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={outsidePeersAdding}
|
||||||
|
onClick={() => setOutsidePeersOpen(false)}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
|
||||||
|
onClick={() => void addOutsidePeers(outsidePeerSelectedIds)}
|
||||||
|
>
|
||||||
|
{outsidePeersAdding
|
||||||
|
? t("topology.addingNe")
|
||||||
|
: t("topology.addSelected").replace(
|
||||||
|
"{{count}}",
|
||||||
|
String(outsidePeerSelectedIds.length),
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{addNeOpen && canvasMode ? (
|
{addNeOpen && canvasMode ? (
|
||||||
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
|
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -213,16 +213,43 @@ const edgeTypes = { topoParallel: ParallelEdge };
|
||||||
const SNAP_GRID: [number, number] = [16, 16];
|
const SNAP_GRID: [number, number] = [16, 16];
|
||||||
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
|
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
|
||||||
|
|
||||||
function canvasDotColor(bg: string): string {
|
function canvasBgRgb(bg: string): { r: number; g: number; b: number } | null {
|
||||||
const hex = String(bg || "").replace("#", "");
|
const hex = String(bg || "").replace("#", "").trim();
|
||||||
if (hex.length !== 6) return "#93c5fd";
|
if (hex.length !== 6) return null;
|
||||||
const r = parseInt(hex.slice(0, 2), 16);
|
const r = parseInt(hex.slice(0, 2), 16);
|
||||||
const g = parseInt(hex.slice(2, 4), 16);
|
const g = parseInt(hex.slice(2, 4), 16);
|
||||||
const b = parseInt(hex.slice(4, 6), 16);
|
const b = parseInt(hex.slice(4, 6), 16);
|
||||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
if (![r, g, b].every((n) => Number.isFinite(n))) return null;
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasDotColor(bg: string): string {
|
||||||
|
const rgb = canvasBgRgb(bg);
|
||||||
|
if (!rgb) return "#93c5fd";
|
||||||
|
const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
||||||
return lum > 0.55 ? "#93c5fd" : "#64748b";
|
return lum > 0.55 ? "#93c5fd" : "#64748b";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MiniMap colors follow the canvas bg so a light map doesn't get a dark inset. */
|
||||||
|
function minimapTheme(bg: string): {
|
||||||
|
bgColor: string;
|
||||||
|
nodeColor: string;
|
||||||
|
nodeStrokeColor: string;
|
||||||
|
maskColor: string;
|
||||||
|
maskStrokeColor: string;
|
||||||
|
} {
|
||||||
|
const rgb = canvasBgRgb(bg) || { r: 11, g: 18, b: 32 };
|
||||||
|
const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
||||||
|
const light = lum > 0.55;
|
||||||
|
return {
|
||||||
|
bgColor: bg || (light ? "#e8eef6" : "#0b1220"),
|
||||||
|
nodeColor: "#3b82f6",
|
||||||
|
nodeStrokeColor: light ? "#93c5fd" : "#1e3a5f",
|
||||||
|
maskColor: light ? "rgba(15, 23, 42, 0.14)" : "rgba(2, 8, 23, 0.62)",
|
||||||
|
maskStrokeColor: light ? "rgba(59, 130, 246, 0.55)" : "rgba(148, 163, 184, 0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): {
|
function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|
@ -497,7 +524,15 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
||||||
<FullscreenIcon exit={fullscreen} />
|
<FullscreenIcon exit={fullscreen} />
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
</Controls>
|
</Controls>
|
||||||
{!isWorldFlatCanvas ? <MiniMap pannable zoomable /> : null}
|
{!isWorldFlatCanvas ? (
|
||||||
|
<MiniMap
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
{...minimapTheme(canvasBg)}
|
||||||
|
nodeStrokeWidth={1}
|
||||||
|
maskStrokeWidth={1}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</TopoDisplayContext.Provider>
|
</TopoDisplayContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue