【原】网络安全拓扑编辑器(My first vide coding application)
个人使用vide coding方式制作的第一个小程序,当前版本为1.0版,程序很简单,为html格式,浏览器打开就能使用。浏览器加载后的效果如下图

【程序用途】方便快速绘制网络安全拓扑
【程序功能】
1、顶部为工具栏,左侧为设备面板,右侧为画布,最右侧为说明栏/属性设置栏
2、右侧的画布中,默认加载了一个典型的等级保护三级内外网安全防护体系的网络安全拓扑,可以在这个默认图上进行修改
3、左侧的设备面板中包括:网络设备、安全设备、服务器设备 和 信任域,在顶部的选择按钮为选中时,可以通过拖拉拽的方式,把设备面板中的图标拖到画布中,即可看到相应的图标效果
4、点击顶部的连线按钮,即可对画布中的两个元素进行连线,点击选择按钮选中连线,可以在右侧看到连线属性,连线形状中可以设置连线为:直线实线、直线虚线、折线实线、折线虚线、曲线实线、曲线虚线

5、连线属性中,勾选数据流动画复选框,可以看到两个元素之间的数据流动动画;也可以按住shift键,选中两个元素,点击顶端的数据流按钮,在两个元素之间的连线也会呈现数据流动效果

6、按住shift键,可以对多个元素进行选中,选中后,可以点击顶部的左对齐、水平居中、右对齐、顶端对齐、竖直居中、底端对齐等对齐按钮,进行相应的对齐操作
7、按住shift键,点击顶端的横向分布按钮或纵向分布按钮,可以对多于2个的元素,进行横向分布或纵向分布操作
8、点击顶部的PNG按钮,可以选择路径生成画布中的内容为PNG格式图片
9、点击顶部的SVG按钮,可以选择路径生成画布中的内容为SVG格式文件
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>网络安全拓扑图绘制工具 · Network Security Topology Editor</title>
<style>
:root {
--bg: #f4f6f9;
--panel: #ffffff;
--border: #d9dee6;
--text: #1f2733;
--muted: #6b7686;
--accent: #2563eb;
--accent-soft: #e8f0fe;
--danger: #dc2626;
--grid: #e3e8ef;
--shadow: 0 1px 3px rgba(16,24,40,.08), 0 1px 2px rgba(16,24,40,.06);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; color: var(--text); background: var(--bg); }
#app { display: grid; grid-template-columns: 232px 1fr 264px; grid-template-rows: 46px 1fr 30px; height: 100vh; }
header { grid-column: 1 / 4; display: flex; align-items: center; gap: 10px; padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--border); }
header .title { font-weight: 700; font-size: 15px; margin-right: 6px; }
header .grp { display: flex; gap: 4px; align-items: center; padding: 0 8px; border-right: 1px solid var(--border); height: 28px; }
header .grp:last-child { border-right: none; }
.btn { border: 1px solid var(--border); background: #fff; color: var(--text); padding: 5px 10px; border-radius: 6px; cursor: pointer; font-size: 12.5px; line-height: 1; }
.btn:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn.ghost { background: transparent; }
.btn.danger:hover { background: #fdeaea; border-color: var(--danger); color: var(--danger); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn:disabled:hover { background: transparent; border-color: var(--border); }
.glabel { font-size: 12px; color: var(--muted); user-select: none; }
aside.left { background: var(--panel); border-right: 1px solid var(--border); padding: 12px 12px 16px; overflow-y: auto; }
aside.left h3 { font-size: 11px; letter-spacing: .04em; color: var(--muted); text-transform: uppercase; margin: 14px 0 8px; }
aside.left h3:first-child { margin-top: 0; }
.palette { display: block; }
.palette .cat-h { grid-column: 1 / -1; font-size: 10.5px; letter-spacing: .06em; color: var(--muted); text-transform: uppercase; font-weight: 700; margin: 10px 0 4px; }
.palette .cat-h:first-child { margin-top: 0; }
.palette .cat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.dev { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 4px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; background: #fff; user-select: none; }
.dev:hover { border-color: var(--accent); background: var(--accent-soft); }
.dev.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.dev .ico { width: 34px; height: 30px; }
.dev .nm { font-size: 11.5px; color: var(--text); white-space: nowrap; text-align: center; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.domain-chip { display: flex; align-items: center; gap: 10px; padding: 10px; margin-bottom: 10px; border: 1px dashed var(--accent); border-radius: 10px; background: var(--accent-soft); cursor: grab; user-select: none; }
.domain-chip:active { cursor: grabbing; }
.domain-chip .dc-box { width: 34px; height: 26px; flex: 0 0 auto; border: 2px dashed var(--accent); border-radius: 5px; background: rgba(37,99,235,.10); }
.domain-chip span { font-size: 12px; font-weight: 600; color: var(--accent); }
main { position: relative; overflow: hidden; background: var(--bg); }
#canvas { display: block; width: 100%; height: 100%; cursor: default; touch-action: none; }
.zoombar { position: absolute; right: 12px; bottom: 12px; display: flex; gap: 6px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 6px; box-shadow: var(--shadow); }
.zoombar .btn { padding: 4px 9px; }
.zoomval { font-size: 12px; color: var(--muted); min-width: 42px; text-align: center; }
.hint { position: absolute; left: 12px; top: 12px; background: rgba(255,255,255,.92); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; font-size: 12px; color: var(--muted); max-width: 320px; box-shadow: var(--shadow); }
aside.right { background: var(--panel); border-left: 1px solid var(--border); padding: 14px; overflow-y: auto; }
aside.right h3 { font-size: 12px; color: var(--muted); margin: 0 0 10px; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 11.5px; color: var(--muted); margin-bottom: 4px; }
.field input, .field select, .field textarea { width: 100%; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; font-family: inherit; color: var(--text); background: #fff; }
.field textarea { resize: vertical; min-height: 54px; }
.field .row { display: flex; gap: 8px; }
.field .row > * { flex: 1; }
.empty { color: var(--muted); font-size: 12.5px; line-height: 1.7; }
.taginput { display: flex; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--border); border-radius: 6px; min-height: 34px; }
.taginput .chip { background: var(--accent-soft); color: var(--accent); border-radius: 12px; padding: 2px 8px; font-size: 11.5px; display: flex; align-items: center; gap: 4px; }
.taginput .chip b { cursor: pointer; font-weight: 700; }
.taginput input { border: none; outline: none; flex: 1; min-width: 70px; font-size: 12.5px; }
footer { grid-column: 1 / 4; display: flex; align-items: center; gap: 14px; padding: 0 14px; background: var(--panel); border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); }
footer .stat b { color: var(--text); font-weight: 600; }
.risk-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; vertical-align: middle; margin-right: 4px; }
</style>
</head>
<body>
<div id="app">
<header>
<span class="title">🌐 网络安全拓扑编辑器</span>
<div class="grp">
<button class="btn active" id="tool-select" data-tool="select" title="选择/移动 (V)">↖ 选择</button>
<button class="btn" id="tool-connect" data-tool="connect" title="连线 (C)">🔗 连线</button>
<button class="btn" id="tool-pan" data-tool="pan" title="平移画布 (空格拖拽)">✋ 平移</button>
</div>
<div class="grp">
<button class="btn ghost" id="act-undo" title="撤销 (Ctrl+Z)">↶ 撤销</button>
<button class="btn ghost" id="act-redo" title="重做 (Ctrl+Y)">↷ 重做</button>
</div>
<div class="grp">
<span class="glabel">对齐</span>
<button class="btn ghost" id="act-align-l" title="左对齐(按住 Shift 多选元素后)" disabled>⬅ 左对齐</button>
<button class="btn ghost" id="act-align-c" title="水平居中对齐" disabled>⬌ 水平居中</button>
<button class="btn ghost" id="act-align-r" title="右对齐" disabled>➡ 右对齐</button>
<button class="btn ghost" id="act-align-t" title="顶端对齐(按住 Shift 多选元素后)" disabled>⬆ 顶端对齐</button>
<button class="btn ghost" id="act-align-vc" title="竖直居中对齐" disabled>↕ 竖直居中</button>
<button class="btn ghost" id="act-align-b" title="底端对齐" disabled>⬇ 底端对齐</button>
<button class="btn ghost" id="act-distribute-h" title="横向分布:以最左、最右元素为两端,中间元素水平方向平均排列(Shift 多选 ≥3 个)" disabled>⇿ 横向分布</button>
<button class="btn ghost" id="act-distribute-v" title="纵向分布:以最上、最下元素为两端,中间元素竖直方向平均排列(Shift 多选 ≥3 个)" disabled>⬍ 纵向分布</button>
<span class="glabel" style="margin-left:4px">Shift 多选</span>
</div>
<div class="grp">
<button class="btn ghost" id="act-flow" title="给选中的两个元素之间的连线添加数据流动画(再次点击关闭)" disabled>🌊 数据流</button>
</div>
<div class="grp">
<button class="btn ghost" id="act-png" title="导出 PNG 图片(可选择保存位置)">🖼 PNG</button>
<button class="btn ghost" id="act-svg" title="导出 SVG">📐 SVG</button>
<button class="btn ghost danger" id="act-clear" title="清空画布">🗑 清空</button>
</div>
</header>
<aside class="left">
<h3>设备面板</h3>
<div class="palette" id="palette"></div>
<h3>信任域(安全分区)</h3>
<div class="domain-chip" id="domain-chip" draggable="true" title="按住拖到右侧画布即可创建安全分区">
<div class="dc-box"></div>
<span>拖我到画布 →</span>
</div>
<div class="empty" style="margin-top:8px">松开后在画布上选中该域,<br>可拖四角 / 四边控制点缩放范围。</div>
</aside>
<main>
<canvas id="canvas"></canvas>
<div class="hint" id="hint">点击左侧设备 → 在画布单击放置;连线模式拖拽两节点即可连接。信任域:从左侧把虚线框拖到画布,选中后拖四角/四边缩放范围。</div>
<div class="zoombar">
<button class="btn" id="zoom-out">−</button>
<span class="zoomval" id="zoom-val">100%</span>
<button class="btn" id="zoom-in">+</button>
<button class="btn" id="zoom-fit" title="适配视图">⤢</button>
</div>
</main>
<aside class="right">
<h3 id="prop-title">属性</h3>
<div id="prop-body"><div class="empty">未选中对象。<br><br>· 从左侧拖/点设备到画布<br>· 连线模式连接两个节点<br>· 选中节点或连线后可在此编辑属性、风险等级与安全标签</div></div>
</aside>
<footer>
<span class="stat">节点 <b id="st-nodes">0</b></span>
<span class="stat">连线 <b id="st-links">0</b></span>
<span class="stat">信任域 <b id="st-domains">0</b></span>
<span class="stat">Version 1.0 © Temptation(公众号:硅基诱惑)</span>
<span style="margin-left:auto" id="st-mode">模式:选择</span>
</footer>
</div>
<script>
"use strict";
/* ===================== 数据模型 ===================== */
const DEVICE_TYPES = {
/* 网络设备 */
switch: { name: "核心交换机", color: "#0ea5e9", cat: "net", glyph: "switch" },
aswitch: { name: "汇聚交换机", color: "#0284c7", cat: "net", glyph: "switch" },
router: { name: "路由器", color: "#3b82f6", cat: "net", glyph: "router" },
cloud: { name: "云", color: "#06b6d4", cat: "net", glyph: "cloud" },
/* 安全设备 */
lb: { name: "负载均衡", color: "#f59e0b", cat: "sec", glyph: "diamond" },
ddos: { name: "抗DDOS", color: "#f97316", cat: "sec", glyph: "shield-wave" },
gap: { name: "网闸", color: "#ef4444", cat: "sec", glyph: "gap" },
firewall:{ name: "防火墙", color: "#dc2626", cat: "sec", glyph: "firewall" },
ips: { name: "入侵防御", color: "#e11d48", cat: "sec", glyph: "shield-cross" },
behavior:{ name: "上网行为管理", color: "#7c3aed", cat: "sec", glyph: "eye" },
avwall: { name: "防毒墙", color: "#d946ef", cat: "sec", glyph: "avwall" },
vpn: { name: "VPN", color: "#0891b2", cat: "sec", glyph: "lock" },
waf: { name: "WAF", color: "#be123c", cat: "sec", glyph: "shield-globe" },
logaudit:{ name: "日志审计", color: "#0d9488", cat: "sec", glyph: "doc" },
dbaudit: { name: "数据库审计", color: "#0e7490", cat: "sec", glyph: "doc-db" },
bastion: { name: "堡垒机", color: "#b45309", cat: "sec", glyph: "key" },
netaudit:{ name: "网络审计", color: "#475569", cat: "sec", glyph: "doc-mag" },
apiaudit:{ name: "API安全审计", color: "#6366f1", cat: "sec", glyph: "code" },
vuln: { name: "漏洞扫描", color: "#ca8a04", cat: "sec", glyph: "bug" },
ids: { name: "入侵检测", color: "#db2777", cat: "sec", glyph: "shield-dot" },
edr: { name: "EDR", color: "#9333ea", cat: "sec", glyph: "monitor" },
sita: { name: "态势感知平台", color: "#7e22ce", cat: "sec", glyph: "radar" },
/* 服务器设备 */
server: { name: "普通服务器", color: "#10b981", cat: "srv", glyph: "server" },
hci: { name: "超融合服务器", color: "#059669", cat: "srv", glyph: "server-link" },
storage: { name: "存储设备", color: "#14b8a6", cat: "srv", glyph: "storage" },
ai: { name: "智算服务器", color: "#8b5cf6", cat: "srv", glyph: "chip" },
pc: { name: "PC终端", color: "#22c55e", cat: "srv", glyph: "pc" },
mobile: { name: "移动终端", color: "#f472b6", cat: "srv", glyph: "mobile" },
};
const RISK = [
{ key: "none", name: "未评级", color: "#94a3b8" },
{ key: "low", name: "低", color: "#16a34a" },
{ key: "mid", name: "中", color: "#eab308" },
{ key: "high", name: "高", color: "#ea580c" },
{ key: "crit", name: "严重", color: "#dc2626" },
];
const NODE_W = 66, NODE_H = 54;
const state = {
nodes: [], links: [], domains: [],
view: { scale: 1, x: 0, y: 0 },
tool: "select",
pendingType: null,
selected: null, // {kind:'node'|'link'|'domain', id}
sel: [], // 多选集合:[{kind,id}](Shift 点击累加)
seq: 1,
history: [], future: [],
};
/* ===================== 画布与坐标 ===================== */
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let dpr = window.devicePixelRatio || 1;
function resize() {
dpr = window.devicePixelRatio || 1;
const r = canvas.getBoundingClientRect();
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
draw();
}
function screenToWorld(sx, sy) {
return { x: (sx - state.view.x) / state.view.scale, y: (sy - state.view.y) / state.view.scale };
}
function pointerPos(e) {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
/* ===================== 渲染 ===================== */
function nodeCenter(n) { return { x: n.x + NODE_W / 2, y: n.y + NODE_H / 2 }; }
/* ---- 连线几何:直线 / 折线 / 曲线 统一抽象 ---- */
function domainBorderPoint(dm, p) {
const L = dm.x, T = dm.y, R = dm.x + dm.w, B = dm.y + dm.h;
if (p.x >= L && p.x <= R && p.y >= T && p.y <= B) {
const dl = p.x - L, dr = R - p.x, dt = p.y - T, db = B - p.y, m = Math.min(dl, dr, dt, db);
if (m === dt) return { x: p.x, y: T };
if (m === db) return { x: p.x, y: B };
if (m === dl) return { x: L, y: p.y };
return { x: R, y: p.y };
}
return { x: Math.max(L, Math.min(p.x, R)), y: Math.max(T, Math.min(p.y, B)) };
}
function linkEnds(lk) {
const a = state.nodes.find(n => n.id === lk.from);
if (!a) return null;
const ac = nodeCenter(a);
const dom = state.domains.find(d => d.id === lk.to);
if (dom) return { a: ac, b: domainBorderPoint(dom, ac) };
const b = state.nodes.find(n => n.id === lk.to);
if (!b) return null;
return { a: ac, b: nodeCenter(b) };
}
function linkCurve(lk) {
const e = linkEnds(lk); if (!e) return null;
const { a, b } = e;
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len; // 垂直方向
const off = Math.min(len * 0.25, 90); // 弯曲幅度
return {
a, b,
c1: { x: a.x + dx * 0.33 + nx * off, y: a.y + dy * 0.33 + ny * off },
c2: { x: a.x + dx * 0.66 + nx * off, y: a.y + dy * 0.66 + ny * off },
};
}
function linkPoints(lk) {
const e = linkEnds(lk); if (!e) return [];
const { a, b } = e;
const shape = lk.shape || "straight";
if (shape === "curve") {
const cv = linkCurve(lk), pts = [];
for (let i = 0; i <= 24; i++) { const t = i / 24, mt = 1 - t;
pts.push({
x: mt*mt*mt*a.x + 3*mt*mt*t*cv.c1.x + 3*mt*t*t*cv.c2.x + t*t*t*b.x,
y: mt*mt*mt*a.y + 3*mt*mt*t*cv.c1.y + 3*mt*t*t*cv.c2.y + t*t*t*b.y }); }
return pts;
}
if (shape === "polyline") {
const midX = (a.x + b.x) / 2;
return [a, { x: midX, y: a.y }, { x: midX, y: b.y }, b];
}
return [a, b];
}
function linkLabelPoint(lk) {
const e = linkEnds(lk); if (!e) return { x: 0, y: 0 };
const { a, b } = e, shape = lk.shape || "straight";
if (shape === "curve") { const cv = linkCurve(lk);
return { x: 0.125*a.x + 0.375*cv.c1.x + 0.375*cv.c2.x + 0.125*b.x,
y: 0.125*a.y + 0.375*cv.c1.y + 0.375*cv.c2.y + 0.125*b.y }; }
if (shape === "polyline") { const midX = (a.x + b.x) / 2; return { x: midX, y: (a.y + b.y) / 2 }; }
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function linkEndTangent(lk) {
const e = linkEnds(lk); if (!e) return { x: 1, y: 0 };
const { a, b } = e, shape = lk.shape || "straight";
if (shape === "curve") { const cv = linkCurve(lk); return { x: b.x - cv.c2.x, y: b.y - cv.c2.y }; }
if (shape === "polyline") { const midX = (a.x + b.x) / 2; return { x: b.x - midX, y: 0 }; }
return { x: b.x - a.x, y: b.y - a.y };
}
function arrowVerts(lk) {
const e = linkEnds(lk); if (!e) return null;
const { b } = e; const dir = linkEndTangent(lk);
const len = Math.hypot(dir.x, dir.y) || 1, ux = dir.x / len, uy = dir.y / len;
const at = { x: b.x - ux * 24, y: b.y - uy * 24 }; // 退到节点边缘外
const s = 7, px = -uy, py = ux;
return [
{ x: at.x + ux * s, y: at.y + uy * s },
{ x: at.x - ux * s * 0.2 + px * s * 0.55, y: at.y - uy * s * 0.2 + py * s * 0.55 },
{ x: at.x - ux * s * 0.2 - px * s * 0.55, y: at.y - uy * s * 0.2 - py * s * 0.55 },
];
}
function paintArrow(c, lk, color) {
const v = arrowVerts(lk); if (!v) return;
c.beginPath(); c.moveTo(v[0].x, v[0].y); c.lineTo(v[1].x, v[1].y); c.lineTo(v[2].x, v[2].y); c.closePath();
c.fillStyle = color; c.fill();
}
function drawFlow(lk, color) {
const pts = linkPoints(lk);
if (pts.length < 2) return;
const seg = []; let total = 0;
for (let i = 1; i < pts.length; i++) {
const dx = pts[i].x - pts[i - 1].x, dy = pts[i].y - pts[i - 1].y;
const d = Math.hypot(dx, dy); seg.push(d); total += d;
}
if (total <= 0.5) return;
const now = performance.now() / 1000;
const count = 5, speed = 0.32;
ctx.save();
for (let k = 0; k < count; k++) {
let t = (now * speed + k / count) % 1; if (t < 0) t += 1;
let dist = t * total;
let i = 0; while (i < seg.length && dist > seg[i]) { dist -= seg[i]; i++; }
if (i >= seg.length) i = seg.length - 1;
const a = pts[i], b = pts[i + 1];
const f = seg[i] ? dist / seg[i] : 0;
const x = a.x + (b.x - a.x) * f, y = a.y + (b.y - a.y) * f;
ctx.beginPath(); ctx.arc(x, y, 4.5 / state.view.scale, 0, 7);
ctx.fillStyle = "rgba(239,68,68,0.25)"; ctx.fill();
ctx.beginPath(); ctx.arc(x, y, 2.6 / state.view.scale, 0, 7);
ctx.fillStyle = "#ef4444"; ctx.fill();
}
ctx.restore();
}
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
function drawGrid() {
const r = canvas.getBoundingClientRect();
const step = 32;
const tl = screenToWorld(0, 0);
const br = screenToWorld(r.width, r.height);
const startX = Math.floor(tl.x / step) * step;
const startY = Math.floor(tl.y / step) * step;
ctx.strokeStyle = getCSS("--grid");
ctx.lineWidth = 1 / state.view.scale;
ctx.beginPath();
for (let x = startX; x <= br.x; x += step) { ctx.moveTo(x, tl.y); ctx.lineTo(x, br.y); }
for (let y = startY; y <= br.y; y += step) { ctx.moveTo(tl.x, y); ctx.lineTo(br.x, y); }
ctx.stroke();
}
function drawDomain(d) {
ctx.save();
ctx.fillStyle = hexA(d.color, 0.08);
ctx.strokeStyle = hexA(d.color, 0.55);
ctx.lineWidth = 1.5 / state.view.scale;
ctx.setLineDash([8 / state.view.scale, 6 / state.view.scale]);
roundRect(ctx, d.x, d.y, d.w, d.h, 14);
ctx.fill(); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
/* 域名称标签:绘制在节点之上,带不透明底色,确保任何情况下都可见 */
function drawDomainLabel(c, d, s) {
c.save();
c.font = `600 ${13 * s}px "Segoe UI", sans-serif`;
c.textBaseline = "middle";
c.textAlign = "left";
const padX = 6 * s, h = 18 * s;
const tw = c.measureText(d.name).width;
const bx = d.x + 10 * s;
const by = d.y + 9 * s;
c.fillStyle = "rgba(255,255,255,0.95)";
roundRect(c, bx - padX, by - h / 2, tw + padX * 2 + 4 * s, h, 5 * s);
c.fill();
c.fillStyle = d.color;
c.fillText(d.name, bx, by + s);
c.restore();
}
function drawDomainHandles(d) {
const sz = 8 / state.view.scale, r = 2 / state.view.scale;
ctx.save();
ctx.fillStyle = "#fff";
ctx.strokeStyle = getCSS("--accent");
ctx.lineWidth = 1.5 / state.view.scale;
domainHandles(d).forEach(h => {
roundRect(ctx, h.x - sz / 2, h.y - sz / 2, sz, sz, r);
ctx.fill(); ctx.stroke();
});
ctx.restore();
}
function drawLink(lk) {
const e = linkEnds(lk); if (!e) return;
const { a, b } = e;
const sel = state.selected && state.selected.kind === "link" && state.selected.id === lk.id;
const color = lk.kind === "vpn" ? "#0891b2" : (sel ? getCSS("--accent") : "#8a94a6");
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = (sel ? 3 : 2) / state.view.scale;
if (lk.kind === "vpn" || lk.dashed) ctx.setLineDash([6 / state.view.scale, 5 / state.view.scale]);
const shape = lk.shape || "straight";
ctx.beginPath();
if (shape === "curve") {
const cv = linkCurve(lk);
ctx.moveTo(a.x, a.y); ctx.bezierCurveTo(cv.c1.x, cv.c1.y, cv.c2.x, cv.c2.y, b.x, b.y);
} else {
const pts = linkPoints(lk);
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.stroke();
ctx.setLineDash([]);
paintArrow(ctx, lk, color);
if (lk.flow) drawFlow(lk, color);
if (lk.label) {
const lp = linkLabelPoint(lk);
ctx.font = `600 ${11 / state.view.scale}px "Segoe UI", sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
const w = ctx.measureText(lk.label).width + 10 / state.view.scale;
ctx.fillStyle = "#fff";
ctx.fillRect(lp.x - w / 2, lp.y - 8 / state.view.scale, w, 16 / state.view.scale);
ctx.fillStyle = "#475569";
ctx.fillText(lk.label, lp.x, lp.y);
}
ctx.restore();
}
function paintGlyph(c, glyph, s) {
c.save();
c.strokeStyle = "#fff"; c.fillStyle = "#fff";
c.lineWidth = 2 * s; c.lineJoin = "round"; c.lineCap = "round";
const u = 11 * s;
const shield = (w, h) => {
c.beginPath();
c.moveTo(0, -h / 2); c.lineTo(w / 2, -h / 2 + h * 0.12); c.lineTo(w / 2, h * 0.12);
c.quadraticCurveTo(w / 2, h / 2, 0, h / 2); c.quadraticCurveTo(-w / 2, h / 2, -w / 2, h * 0.12);
c.lineTo(-w / 2, -h / 2 + h * 0.12); c.closePath();
};
switch (glyph) {
case "router":
roundRect(c, -u, -u * 0.5, u * 2, u, u * 0.4); c.stroke();
c.beginPath(); c.moveTo(0, -u * 0.5); c.lineTo(0, -u * 1.1);
c.moveTo(-u * 0.5, -u * 0.8); c.lineTo(0, -u * 1.1); c.lineTo(u * 0.5, -u * 0.8);
c.moveTo(0, u * 0.5); c.lineTo(0, u * 1.1); c.moveTo(-u * 0.5, u * 0.8); c.lineTo(0, u * 1.1); c.lineTo(u * 0.5, u * 0.8);
c.stroke(); break;
case "switch":
roundRect(c, -u, -u * 0.6, u * 2, u * 1.2, u * 0.3); c.stroke();
for (let i = -1; i <= 1; i++) { c.beginPath(); c.moveTo(i * u * 0.5, -u * 0.6); c.lineTo(i * u * 0.5, u * 0.6); c.stroke(); }
break;
case "cloud":
c.beginPath();
c.arc(-u * 0.5, u * 0.15, u * 0.55, Math.PI * 0.5, Math.PI * 1.5);
c.arc(0, -u * 0.35, u * 0.62, Math.PI * 1.0, Math.PI * 2.0);
c.arc(u * 0.55, u * 0.1, u * 0.5, Math.PI * 1.5, Math.PI * 0.5);
c.closePath(); c.stroke();
break;
case "firewall": {
const w = u * 1.8, h = u * 1.6, bw = w / 3, bh = h / 3; c.lineWidth = 1.4 * s;
for (let r = 0; r < 3; r++) for (let col = 0; col < 3; col++) { roundRect(c, -w / 2 + col * bw + 1, -h / 2 + r * bh + 1, bw - 2, bh - 2, 2 * s); c.stroke(); }
break;
}
case "gap":
c.lineWidth = 3 * s;
roundRect(c, -u * 0.9, -u, u * 0.5, u * 2, 3 * s); c.stroke();
roundRect(c, u * 0.4, -u, u * 0.5, u * 2, 3 * s); c.stroke(); break;
case "shield-cross": shield(u * 1.7, u * 2); c.stroke(); c.beginPath(); c.moveTo(0, -u * 0.5); c.lineTo(0, u * 0.5); c.moveTo(-u * 0.5, 0); c.lineTo(u * 0.5, 0); c.stroke(); break;
case "shield-dot": shield(u * 1.7, u * 2); c.stroke(); c.beginPath(); c.arc(0, u * 0.05, u * 0.32, 0, 7); c.fill(); break;
case "shield-globe": shield(u * 1.7, u * 2); c.stroke(); c.beginPath(); c.arc(0, 0, u * 0.55, 0, 7); c.stroke(); c.moveTo(-u * 0.55, 0); c.lineTo(u * 0.55, 0); c.moveTo(0, -u * 0.55); c.lineTo(0, u * 0.55); c.stroke(); break;
case "shield-wave": shield(u * 1.7, u * 2); c.stroke(); c.lineWidth = 1.6 * s; for (let i = -1; i <= 1; i++) { const yy = i * u * 0.45; c.beginPath(); c.moveTo(-u * 0.6, yy); c.quadraticCurveTo(-u * 0.2, yy - u * 0.3, u * 0.2, yy); c.quadraticCurveTo(u * 0.6, yy + u * 0.3, u * 0.6, yy); c.stroke(); } break;
case "avwall": shield(u * 1.7, u * 2); c.stroke(); c.beginPath(); c.arc(0, u * 0.05, u * 0.34, 0, 7); c.stroke(); c.lineWidth = 1.4 * s; for (let a = 0; a < 6.29; a += Math.PI / 3) { c.beginPath(); c.moveTo(Math.cos(a) * u * 0.34, u * 0.05 + Math.sin(a) * u * 0.34); c.lineTo(Math.cos(a) * u * 0.6, u * 0.05 + Math.sin(a) * u * 0.6); c.stroke(); } break;
case "lock": { const w = u * 1.3, h = u * 1.0; roundRect(c, -w / 2, u * 0.1, w, h, 3 * s); c.stroke(); c.beginPath(); c.arc(0, u * 0.1, u * 0.5, Math.PI, 0); c.stroke(); c.beginPath(); c.arc(0, u * 0.55, u * 0.18, 0, 7); c.fill(); break; }
case "eye": c.beginPath(); c.moveTo(-u, -u * 0.1); c.quadraticCurveTo(0, -u * 0.9, u, u * 0.1); c.quadraticCurveTo(0, u * 0.7, -u, -u * 0.1); c.closePath(); c.stroke(); c.beginPath(); c.arc(0, -u * 0.1, u * 0.32, 0, 7); c.stroke(); break;
case "diamond": c.beginPath(); c.moveTo(0, -u * 1.1); c.lineTo(u * 0.9, 0); c.lineTo(0, u * 1.1); c.lineTo(-u * 0.9, 0); c.closePath(); c.stroke(); break;
case "doc": { const w = u * 1.4, h = u * 1.8; roundRect(c, -w / 2, -h / 2, w, h, 2 * s); c.stroke(); c.lineWidth = 1.5 * s; for (let i = -1; i <= 1; i++) { c.beginPath(); c.moveTo(-w * 0.25, i * u * 0.4); c.lineTo(w * 0.25, i * u * 0.4); c.stroke(); } break; }
case "doc-db": { const w = u * 1.4, h = u * 1.8; roundRect(c, -w / 2, -h / 2, w, h, 2 * s); c.stroke(); c.beginPath(); c.ellipse(u * 0.15, h * 0.22, u * 0.38, u * 0.16, 0, 0, 7); c.stroke(); c.beginPath(); c.moveTo(u * 0.15 - u * 0.38, h * 0.22); c.lineTo(u * 0.15 - u * 0.38, h * 0.45); c.moveTo(u * 0.15 + u * 0.38, h * 0.22); c.lineTo(u * 0.15 + u * 0.38, h * 0.45); c.stroke(); break; }
case "key": c.beginPath(); c.arc(-u * 0.4, -u * 0.4, u * 0.5, 0, 7); c.stroke(); c.lineWidth = 2.4 * s; c.beginPath(); c.moveTo(0, 0); c.lineTo(u * 0.9, u * 0.9); c.moveTo(u * 0.45, u * 0.45); c.lineTo(u * 0.7, u * 0.2); c.moveTo(u * 0.65, u * 0.65); c.lineTo(u * 0.95, u * 0.4); c.stroke(); break;
case "doc-mag": { const w = u * 1.3, h = u * 1.7; roundRect(c, -w / 2 - 2, -h / 2, w, h, 2 * s); c.stroke(); c.beginPath(); c.arc(u * 0.55, u * 0.55, u * 0.45, 0, 7); c.stroke(); c.beginPath(); c.moveTo(u * 0.9, u * 0.9); c.lineTo(u * 1.3, u * 1.3); c.stroke(); break; }
case "code": c.lineWidth = 2.6 * s; c.beginPath(); c.moveTo(-u * 0.75, -u * 0.65); c.lineTo(-u * 0.15, 0); c.lineTo(-u * 0.75, u * 0.65); c.stroke(); c.beginPath(); c.moveTo(-u * 0.1, -u * 0.7); c.lineTo(u * 0.1, u * 0.7); c.stroke(); c.beginPath(); c.moveTo(u * 0.75, -u * 0.65); c.lineTo(u * 0.15, 0); c.lineTo(u * 0.75, u * 0.65); c.stroke(); break;
case "bug": c.beginPath(); c.arc(0, 0, u * 0.55, 0, 7); c.stroke(); c.lineWidth = 2 * s; for (const sg of [-1, 1]) { c.beginPath(); c.moveTo(sg * u * 0.55, -u * 0.3); c.lineTo(sg * u * 0.95, -u * 0.5); c.moveTo(sg * u * 0.55, 0); c.lineTo(sg * u * 1.0, 0); c.moveTo(sg * u * 0.55, u * 0.3); c.lineTo(sg * u * 0.95, u * 0.5); c.stroke(); } c.beginPath(); c.moveTo(0, -u * 0.55); c.lineTo(0, -u * 0.95); c.moveTo(-u * 0.3, -u * 0.75); c.lineTo(-u * 0.5, -u * 1.05); c.moveTo(u * 0.3, -u * 0.75); c.lineTo(u * 0.5, -u * 1.05); c.stroke(); break;
case "monitor": { const w = u * 1.8, h = u * 1.2; roundRect(c, -w / 2, -h / 2 - u * 0.1, w, h, 3 * s); c.stroke(); c.beginPath(); c.moveTo(-u * 0.3, u * 0.5); c.lineTo(u * 0.3, u * 0.5); c.lineTo(u * 0.15, u * 0.85); c.lineTo(-u * 0.15, u * 0.85); c.closePath(); c.stroke(); c.beginPath(); c.moveTo(-u * 0.1, u * 0.85); c.lineTo(-u * 0.1, u * 1.05); c.lineTo(u * 0.1, u * 1.05); c.lineTo(u * 0.1, u * 0.85); c.stroke(); break; }
case "radar": c.beginPath(); c.arc(0, 0, u * 0.9, 0, 7); c.stroke(); c.beginPath(); c.arc(0, 0, u * 0.5, 0, 7); c.stroke(); c.beginPath(); c.moveTo(0, 0); c.lineTo(u * 0.9 * Math.cos(-0.6), u * 0.9 * Math.sin(-0.6)); c.stroke(); c.fillStyle = "rgba(255,255,255,0.4)"; c.beginPath(); c.arc(0, 0, u * 0.9, -0.6, 0); c.lineTo(0, 0); c.closePath(); c.fill(); break;
case "server": for (let i = 0; i < 3; i++) { const yy = -u * 0.7 + i * u * 0.7; roundRect(c, -u * 0.85, yy, u * 1.7, u * 0.5, 2 * s); c.stroke(); } c.beginPath(); c.arc(u * 0.5, -u * 0.45, u * 0.12, 0, 7); c.fill(); c.beginPath(); c.arc(u * 0.5, u * 0.25, u * 0.12, 0, 7); c.fill(); c.beginPath(); c.arc(u * 0.5, u * 0.95, u * 0.12, 0, 7); c.fill(); break;
case "server-link": for (const sx of [-u * 0.55, u * 0.55]) for (let i = 0; i < 2; i++) { const yy = -u * 0.4 + i * u * 0.6; roundRect(c, sx - u * 0.45, yy, u * 0.9, u * 0.4, 2 * s); c.stroke(); } c.lineWidth = 2 * s; c.beginPath(); c.moveTo(-u * 0.1, -u * 0.1); c.quadraticCurveTo(0, 0, u * 0.1, -u * 0.1); c.stroke(); break;
case "storage": { const w = u * 1.7, h = u * 1.7; roundRect(c, -w / 2, -h / 2, w, h, 3 * s); c.stroke(); c.lineWidth = 1.6 * s; for (let i = -1; i <= 1; i++) { c.beginPath(); c.moveTo(-w * 0.35, i * u * 0.5); c.lineTo(w * 0.35, i * u * 0.5); c.stroke(); } c.beginPath(); c.arc(w * 0.2, h * 0.2, u * 0.12, 0, 7); c.fill(); break; }
case "chip": { const sz = u * 1.1; roundRect(c, -sz / 2, -sz / 2, sz, sz, 3 * s); c.stroke(); c.lineWidth = 1.8 * s; for (let i = -1; i <= 1; i++) { c.beginPath(); c.moveTo(i * u * 0.4, -sz / 2); c.lineTo(i * u * 0.4, -sz / 2 - u * 0.25); c.moveTo(i * u * 0.4, sz / 2); c.lineTo(i * u * 0.4, sz / 2 + u * 0.25); c.moveTo(-sz / 2, i * u * 0.4); c.lineTo(-sz / 2 - u * 0.25, i * u * 0.4); c.moveTo(sz / 2, i * u * 0.4); c.lineTo(sz / 2 + u * 0.25, i * u * 0.4); c.stroke(); } roundRect(c, -u * 0.3, -u * 0.3, u * 0.6, u * 0.6, 2 * s); c.fill(); break; }
case "pc": { /* 显示器 + 底座 */ roundRect(c, -u * 1.05, -u * 0.95, u * 2.1, u * 1.5, 3 * s); c.stroke(); c.beginPath(); c.moveTo(-u * 0.4, u * 0.55); c.lineTo(u * 0.4, u * 0.55); c.moveTo(0, u * 0.55); c.lineTo(0, u * 0.95); c.moveTo(-u * 0.5, u * 0.95); c.lineTo(u * 0.5, u * 0.95); c.stroke(); break; }
case "mobile": { const w = u * 0.95, h = u * 1.8; roundRect(c, -w / 2, -h / 2, w, h, 4 * s); c.stroke(); c.lineWidth = 1.6 * s; c.beginPath(); c.moveTo(-w * 0.3, -h * 0.38); c.lineTo(w * 0.3, -h * 0.38); c.stroke(); c.beginPath(); c.arc(0, h * 0.38, u * 0.12, 0, 7); c.fill(); break; }
default: roundRect(c, -u, -u, u * 2, u * 2, 3 * s); c.stroke();
}
c.restore();
}
function drawNodeIcon(type, cx, cy, s) {
const t = DEVICE_TYPES[type] || DEVICE_TYPES.server;
ctx.save();
ctx.translate(cx, cy);
ctx.fillStyle = t.color;
roundRect(ctx, -16 * s, -13 * s, 32 * s, 26 * s, 6 * s); ctx.fill();
paintGlyph(ctx, t.glyph, s);
ctx.restore();
}
function drawNode(n) {
const c = ctx;
const t = DEVICE_TYPES[n.type] || DEVICE_TYPES.pc;
const isPrimary = state.selected && state.selected.kind === "node" && state.selected.id === n.id;
const inSel = state.sel.some(s => s.kind === "node" && s.id === n.id);
const sel = isPrimary || inSel;
c.save();
// body
roundRect(c, n.x, n.y, NODE_W, NODE_H, 9);
c.fillStyle = "#fff"; c.fill();
c.lineWidth = (isPrimary ? 3 : inSel ? 2.5 : 1.5) / state.view.scale;
c.strokeStyle = inSel ? (isPrimary ? getCSS("--accent") : "#0891b2") : hexA(t.color, 0.6);
c.stroke();
// top color strip
c.save();
roundRect(c, n.x, n.y, NODE_W, NODE_H, 9); c.clip();
c.fillStyle = hexA(t.color, 0.12); c.fillRect(n.x, n.y, NODE_W, 14);
c.restore();
// icon
drawNodeIcon(n.type, n.x + NODE_W / 2, n.y + 22, 0.9);
// label
c.fillStyle = "#1f2733"; c.font = `600 ${11.5 / state.view.scale}px "Segoe UI", sans-serif`;
c.textAlign = "center"; c.textBaseline = "top";
c.fillText(n.label || t.name, n.x + NODE_W / 2, n.y + 36 / state.view.scale);
if (n.ip) {
c.fillStyle = "#6b7686"; c.font = `${10 / state.view.scale}px "Segoe UI", sans-serif`;
c.fillText(n.ip, n.x + NODE_W / 2, n.y + 50 / state.view.scale);
}
// risk badge
if (n.risk && n.risk !== "none") {
const rc = RISK.find(r => r.key === n.risk).color;
c.beginPath(); c.arc(n.x + NODE_W - 4, n.y + 4, 5.5, 0, 7); c.fillStyle = rc; c.fill();
if (n.risk === "crit") { c.strokeStyle = "#fff"; c.lineWidth = 1.5; c.beginPath(); c.moveTo(n.x + NODE_W - 7, n.y + 1); c.lineTo(n.x + NODE_W - 1, n.y + 7); c.stroke(); }
}
// tags chips (vuln)
if (n.tags && n.tags.length) {
c.font = `${9.5 / state.view.scale}px "Segoe UI", sans-serif`;
c.textAlign = "center"; c.textBaseline = "bottom";
let total = 0; const texts = n.tags.slice(0, 3);
texts.forEach(tg => { total += c.measureText(tg).width + 12 / state.view.scale; });
let tx = n.x + NODE_W / 2 - total / 2;
c.fillStyle = "#fdeaea";
texts.forEach(tg => {
const w = c.measureText(tg).width + 12 / state.view.scale;
roundRect(c, tx, n.y - 16 / state.view.scale, w, 13 / state.view.scale, 6 / state.view.scale); c.fill();
c.fillStyle = "#dc2626"; c.fillText(tg, tx + w / 2, n.y - 4 / state.view.scale);
c.fillStyle = "#fdeaea"; tx += w + 4 / state.view.scale;
});
}
c.restore();
}
function drawConnectPreview() {
if (state.tool !== "connect" || !drag.linkFrom) return;
const a = state.nodes.find(n => n.id === drag.linkFrom);
if (!a) return;
const pa = nodeCenter(a);
ctx.save();
ctx.strokeStyle = getCSS("--accent"); ctx.lineWidth = 2 / state.view.scale;
ctx.setLineDash([5 / state.view.scale, 4 / state.view.scale]);
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(drag.cur.x, drag.cur.y); ctx.stroke();
ctx.restore();
}
function draw() {
const r = canvas.getBoundingClientRect();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(state.view.scale * dpr, 0, 0, state.view.scale * dpr, state.view.x * dpr, state.view.y * dpr);
drawGrid();
state.domains.forEach(drawDomain);
state.links.forEach(drawLink);
drawConnectPreview();
state.nodes.forEach(drawNode);
state.domains.forEach(d => drawDomainLabel(ctx, d, 1 / state.view.scale));
if (state.selected && state.selected.kind === "domain") {
const d = state.domains.find(x => x.id === state.selected.id);
if (d) drawDomainHandles(d);
}
updateStats();
}
/* ===================== 工具函数 ===================== */
function getCSS(v) { return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); }
function hexA(hex, a) {
const h = hex.replace("#", "");
const n = parseInt(h.length === 3 ? h.split("").map(c => c + c).join("") : h, 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
}
function uid(p) { return p + (state.seq++); }
function hitNode(wx, wy) {
for (let i = state.nodes.length - 1; i >= 0; i--) {
const n = state.nodes[i];
if (wx >= n.x && wx <= n.x + NODE_W && wy >= n.y && wy <= n.y + NODE_H) return n;
}
return null;
}
function hitLink(wx, wy) {
const thr = 6 / state.view.scale;
for (let i = state.links.length - 1; i >= 0; i--) {
const lk = state.links[i];
const pts = linkPoints(lk);
for (let j = 0; j < pts.length - 1; j++) {
if (distToSeg(wx, wy, pts[j].x, pts[j].y, pts[j + 1].x, pts[j + 1].y) < thr) return lk;
}
}
return null;
}
function distToSeg(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy || 1;
let t = ((px - x1) * dx + (py - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
/* 信任域几何:8 个缩放控制点(四角 + 四边中点) */
function domainHandles(d) {
const { x, y, w, h } = d;
return [
{ k: "nw", x: x, y: y }, { k: "n", x: x + w / 2, y: y },
{ k: "ne", x: x + w, y: y }, { k: "e", x: x + w, y: y + h / 2 },
{ k: "se", x: x + w, y: y + h }, { k: "s", x: x + w / 2, y: y + h },
{ k: "sw", x: x, y: y + h }, { k: "w", x: x, y: y + h / 2 },
];
}
function hitDomainHandle(wx, wy) {
if (!(state.selected && state.selected.kind === "domain")) return null;
const d = state.domains.find(x => x.id === state.selected.id);
if (!d) return null;
const thr = 8 / state.view.scale;
for (const hh of domainHandles(d)) {
if (Math.abs(wx - hh.x) < thr && Math.abs(wy - hh.y) < thr) return hh.k;
}
return null;
}
function topDomainAt(wx, wy) {
for (let i = state.domains.length - 1; i >= 0; i--) {
const d = state.domains[i];
if (wx >= d.x && wx <= d.x + d.w && wy >= d.y && wy <= d.y + d.h) return d;
}
return null;
}
/* 取某域及其所有嵌套子域(被该域框完整包含的域,递归) */
function domainTreeIds(rootId) {
const inside = (d, p) => d.x >= p.x && d.y >= p.y && d.x + d.w <= p.x + p.w && d.y + d.h <= p.y + p.h;
const set = new Set([rootId]);
let changed = true;
while (changed) {
changed = false;
for (const d of state.domains) {
if (set.has(d.id)) continue;
const parent = [...set].map(id => state.domains.find(x => x.id === id)).find(p => p && p.id !== d.id && inside(d, p));
if (parent) { set.add(d.id); changed = true; }
}
}
return set;
}
/* ===================== 历史记录 ===================== */
function snapshot() {
state.history.push(JSON.stringify({ nodes: state.nodes, links: state.links, domains: state.domains, seq: state.seq }));
if (state.history.length > 60) state.history.shift();
state.future = [];
}
function restore(obj) {
const d = JSON.parse(obj);
state.nodes = d.nodes; state.links = d.links; state.domains = d.domains; state.seq = d.seq;
state.selected = null; refreshPalette(); refreshDomainList(); renderProps(); draw();
}
function undo() { if (!state.history.length) return; state.future.push(JSON.stringify({ nodes: state.nodes, links: state.links, domains: state.domains, seq: state.seq })); restore(state.history.pop()); updateAlignButtons(); }
function redo() { if (!state.future.length) return; state.history.push(JSON.stringify({ nodes: state.nodes, links: state.links, domains: state.domains, seq: state.seq })); restore(state.future.pop()); updateAlignButtons(); }
/* ===================== 交互 ===================== */
const drag = { mode: null, node: null, linkFrom: null, panX: 0, panY: 0, cur: { x: 0, y: 0 }, offX: 0, offY: 0, dom: null, handle: null, startGeo: null, start: { x: 0, y: 0 }, _moved: false };
canvas.addEventListener("mousedown", (e) => {
const p = pointerPos(e);
const w = screenToWorld(p.x, p.y);
if (state.tool === "pan" || e.button === 1 || (e.button === 0 && e.spaceKey)) {
drag.mode = "pan"; drag.panX = p.x - state.view.x; drag.panY = p.y - state.view.y; return;
}
if (e.button === 2) return; // context menu
if (state.pendingType) {
addNode(state.pendingType, w.x - NODE_W / 2, w.y - NODE_H / 2);
if (!e.shiftKey) { state.pendingType = null; setActivePalette(null); }
return;
}
const n = hitNode(w.x, w.y);
if (state.tool === "connect") {
if (n) { drag.mode = "link"; drag.linkFrom = n.id; drag.cur = w; }
return;
}
// select tool
if (n) {
const entry = { kind: "node", id: n.id };
if (e.shiftKey) {
const i = state.sel.findIndex(s => s.kind === "node" && s.id === n.id);
if (i >= 0) state.sel.splice(i, 1); else state.sel.push(entry);
} else {
state.sel = [entry];
}
state.selected = state.sel.length ? state.sel[state.sel.length - 1] : null;
drag.mode = "move"; drag.node = n; drag.offX = w.x - n.x; drag.offY = w.y - n.y;
renderProps(); updateAlignButtons();
} else {
// 优先:选中域的缩放控制点(四角/四边)
const hk = hitDomainHandle(w.x, w.y);
if (hk) {
const d = state.domains.find(x => x.id === state.selected.id);
state.selected = { kind: "domain", id: d.id };
drag.mode = "resize"; drag.dom = d; drag.handle = hk;
drag.startGeo = { x: d.x, y: d.y, w: d.w, h: d.h };
drag.start = { x: w.x, y: w.y }; drag._moved = false;
renderProps(); draw(); return;
}
const lk = hitLink(w.x, w.y);
if (lk) { state.sel = []; state.selected = { kind: "link", id: lk.id }; renderProps(); updateAlignButtons(); draw(); return; }
// 域内部:选中并整体拖动
const dt = topDomainAt(w.x, w.y);
if (dt) {
const entry = { kind: "domain", id: dt.id };
if (e.shiftKey) {
const i = state.sel.findIndex(s => s.kind === "domain" && s.id === dt.id);
if (i >= 0) state.sel.splice(i, 1); else state.sel.push(entry);
} else {
state.sel = [entry];
}
state.selected = state.sel.length ? state.sel[state.sel.length - 1] : null;
drag.mode = "domMove"; drag.dom = dt; drag.offX = w.x - dt.x; drag.offY = w.y - dt.y;
drag._moved = false;
/* 记录整棵域树(含嵌套子域)与成员节点的起始坐标,拖动时整体平移 */
drag.domTree = domainTreeIds(dt.id);
drag.domStartX = dt.x; drag.domStartY = dt.y;
drag.domTreeStart = {};
drag.domTree.forEach(id => { const d = state.domains.find(x => x.id === id); drag.domTreeStart[id] = { x: d.x, y: d.y }; });
drag.memberStart = {};
state.nodes.forEach(n => { if (drag.domTree.has(n.domainId)) drag.memberStart[n.id] = { x: n.x, y: n.y }; });
renderProps(); draw(); return;
}
state.sel = []; state.selected = null; renderProps(); updateAlignButtons();
}
draw();
});
canvas.addEventListener("mousemove", (e) => {
const p = pointerPos(e);
const w = screenToWorld(p.x, p.y);
if (drag.mode === "pan") { state.view.x = p.x - drag.panX; state.view.y = p.y - drag.panY; draw(); return; }
if (drag.mode === "move" && drag.node) {
drag.node.x = w.x - drag.offX; drag.node.y = w.y - drag.offY;
if (drag.node.domainId) repositionDomain(drag.node.domainId);
draw(); return;
}
if (drag.mode === "resize" && drag.dom) {
drag._moved = true;
const dx = w.x - drag.start.x, dy = w.y - drag.start.y;
const g = drag.startGeo; const hk = drag.handle; const MIN = 40;
let nx = g.x, ny = g.y, nw = g.w, nh = g.h;
if (hk.includes("w")) { nx = g.x + dx; nw = g.w - dx; }
if (hk.includes("e")) { nw = g.w + dx; }
if (hk.includes("n")) { ny = g.y + dy; nh = g.h - dy; }
if (hk.includes("s")) { nh = g.h + dy; }
if (nw < MIN) { if (hk.includes("w")) nx = g.x + g.w - MIN; nw = MIN; }
if (nh < MIN) { if (hk.includes("n")) ny = g.y + g.h - MIN; nh = MIN; }
drag.dom.x = nx; drag.dom.y = ny; drag.dom.w = nw; drag.dom.h = nh;
draw(); return;
}
if (drag.mode === "domMove" && drag.dom) {
drag._moved = true;
drag.dom.x = w.x - drag.offX; drag.dom.y = w.y - drag.offY;
const dx = drag.dom.x - drag.domStartX, dy = drag.dom.y - drag.domStartY;
/* 嵌套子域跟随整体平移 */
drag.domTree.forEach(id => {
if (id === drag.dom.id) return;
const st = drag.domTreeStart[id]; const d = state.domains.find(x => x.id === id);
if (st && d) { d.x = st.x + dx; d.y = st.y + dy; }
});
/* 域内成员设备跟随整体平移 */
state.nodes.forEach(n => {
const st = drag.memberStart[n.id];
if (st) { n.x = st.x + dx; n.y = st.y + dy; }
});
draw(); return;
}
if (drag.mode === "link") { drag.cur = w; draw(); return; }
});
canvas.addEventListener("mouseup", (e) => {
if (drag.mode === "link" && drag.linkFrom) {
const w = screenToWorld(pointerPos(e).x, pointerPos(e).y);
const tgt = hitNode(w.x, w.y);
if (tgt && tgt.id !== drag.linkFrom) {
snapshot();
state.links.push({ id: uid("l"), from: drag.linkFrom, to: tgt.id, label: "", kind: "wire" });
}
drag.linkFrom = null; drag.mode = null; draw(); updateStats(); return;
}
if (drag.mode === "move" && drag.node) {
if (drag._moved) snapshot();
drag.node = null; drag.mode = null; return;
}
if (drag.mode === "resize" && drag.dom) {
if (drag._moved) snapshot();
drag.dom = null; drag.handle = null; drag.mode = null; updateStats(); return;
}
if (drag.mode === "domMove" && drag.dom) {
if (drag._moved) snapshot();
drag.dom = null; drag.mode = null; updateStats(); return;
}
drag.mode = null; drag.linkFrom = null;
});
canvas.addEventListener("mousemove", (e) => { if (drag.mode === "move" || drag.mode === "resize" || drag.mode === "domMove") drag._moved = true; });
canvas.addEventListener("contextmenu", (e) => {
e.preventDefault();
const w = screenToWorld(pointerPos(e).x, pointerPos(e).y);
const n = hitNode(w.x, w.y);
if (n) { snapshot(); deleteNode(n.id); }
});
window.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
if (e.code === "Space") e.spaceKey = true;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { e.preventDefault(); undo(); }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") { e.preventDefault(); redo(); }
if (e.key === "Delete" || e.key === "Backspace") {
if (state.selected) { snapshot();
if (state.selected.kind === "node") deleteNode(state.selected.id);
else if (state.selected.kind === "link") deleteLink(state.selected.id);
else if (state.selected.kind === "domain") deleteDomain(state.selected.id);
}
}
if (e.key.toLowerCase() === "v") setTool("select");
if (e.key.toLowerCase() === "c") setTool("connect");
});
window.addEventListener("keyup", (e) => { if (e.code === "Space") e.spaceKey = false; });
/* ===================== 增删改 ===================== */
function addNode(type, x, y) {
snapshot();
const t = DEVICE_TYPES[type];
const n = { id: uid("n"), type, label: t.name, ip: "", x: Math.round(x), y: Math.round(y), risk: "none", tags: [], domainId: null };
state.nodes.push(n);
state.selected = { kind: "node", id: n.id };
renderProps(); draw();
}
function deleteNode(id) {
state.nodes = state.nodes.filter(n => n.id !== id);
state.links = state.links.filter(l => l.from !== id && l.to !== id);
state.domains.forEach(d => d.nodeIds = (d.nodeIds || []).filter(x => x !== id));
state.sel = state.sel.filter(s => s.kind !== "node" || s.id !== id);
state.selected = null; renderProps(); updateAlignButtons(); draw();
}
function deleteLink(id) { state.links = state.links.filter(l => l.id !== id); state.sel = state.sel.filter(s => s.kind !== "link" || s.id !== id); state.selected = null; renderProps(); updateAlignButtons(); draw(); }
function deleteDomain(id) {
state.domains = state.domains.filter(d => d.id !== id);
state.nodes.forEach(n => { if (n.domainId === id) n.domainId = null; });
state.sel = state.sel.filter(s => s.kind !== "domain" || s.id !== id);
state.selected = null; refreshDomainList(); renderProps(); updateAlignButtons(); draw();
}
function repositionDomain(did) {
const d = state.domains.find(x => x.id === did); if (!d) return;
const members = state.nodes.filter(n => n.domainId === did);
if (!members.length) return;
const minX = Math.min(...members.map(n => n.x)) - 40;
const minY = Math.min(...members.map(n => n.y)) - 40;
const maxX = Math.max(...members.map(n => n.x + NODE_W)) + 40;
const maxY = Math.max(...members.map(n => n.y + NODE_H)) + 40;
d.x = minX; d.y = minY; d.w = maxX - minX; d.h = maxY - minY;
}
function addDomain() {
snapshot();
const palette = ["#2563eb", "#0ea5e9", "#7c3aed", "#db2777", "#16a34a", "#ea580c"];
const d = { id: uid("d"), name: "安全分区 " + (state.domains.length + 1), color: palette[state.domains.length % palette.length], x: 60, y: 60, w: 360, h: 260, nodeIds: [] };
state.domains.push(d); refreshDomainList(); draw();
}
function assignNodeToDomain(nodeId, did) {
const n = state.nodes.find(x => x.id === nodeId); if (!n) return;
n.domainId = (n.domainId === did) ? null : did;
if (n.domainId) repositionDomain(n.domainId);
draw(); refreshDomainList();
}
/* ===================== UI 渲染 ===================== */
function buildPalette() {
const el = document.getElementById("palette");
el.innerHTML = "";
const cats = [["net", "网络设备"], ["sec", "安全设备"], ["srv", "服务器设备"]];
cats.forEach(([ck, cn]) => {
const h = document.createElement("div");
h.className = "cat-h"; h.textContent = cn;
el.appendChild(h);
const grid = document.createElement("div");
grid.className = "cat-grid";
Object.entries(DEVICE_TYPES).filter(([, t]) => t.cat === ck).forEach(([k, t]) => {
const d = document.createElement("div");
d.className = "dev"; d.dataset.type = k;
d.innerHTML = `<canvas class="ico" width="34" height="30"></canvas><span class="nm">${t.name}</span>`;
const cc = d.querySelector("canvas").getContext("2d");
cc.translate(17, 15); drawIconTo(cc, k, 0.78);
d.addEventListener("click", () => {
state.pendingType = (state.pendingType === k) ? null : k;
setActivePalette(state.pendingType);
if (state.pendingType) setTool("select");
});
grid.appendChild(d);
});
el.appendChild(grid);
});
}
function drawIconTo(c, type, s) {
const t = DEVICE_TYPES[type] || DEVICE_TYPES.server;
c.fillStyle = t.color;
roundRect(c, -15 * s, -12 * s, 30 * s, 24 * s, 5 * s); c.fill();
paintGlyph(c, t.glyph, s);
}
function setActivePalette(type) {
document.querySelectorAll(".dev").forEach(d => d.classList.toggle("active", d.dataset.type === type));
document.getElementById("hint").textContent = type
? `已选「${DEVICE_TYPES[type].name}」:在画布单击放置(按住 Shift 连续放置)。`
: "点击左侧设备 → 在画布单击放置;连线模式拖拽两节点即可连接。右键节点可快速删除。";
}
function refreshPalette() { /* no-op, nodes tracked in state */ }
function refreshDomainList() {
const el = document.getElementById("domain-list");
if (!el) return; // 左侧域列表已移除,仅保留「拖我到画布」创建方式
if (!state.domains.length) { el.className = "empty"; el.innerHTML = '点击「+ 新建域」添加安全分区,<br>用于圈定网络信任边界。'; return; }
el.className = ""; el.innerHTML = "";
state.domains.forEach(d => {
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:6px;font-size:12.5px;cursor:pointer";
row.innerHTML = `<span class="risk-dot" style="background:${d.color}"></span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.name}</span>`;
row.addEventListener("click", () => { state.selected = { kind: "domain", id: d.id }; renderProps(); });
el.appendChild(row);
});
}
function renderProps() {
const body = document.getElementById("prop-body");
const title = document.getElementById("prop-title");
const sel = state.selected;
if (!sel) {
title.textContent = "属性";
body.innerHTML = `<div class="empty">未选中对象。<br><br>· 从左侧拖/点设备到画布<br>· 连线模式连接两个节点<br>· 选中节点或连线后可在此编辑属性、风险等级与安全标签</div>`;
return;
}
if (sel.kind === "node") {
const n = state.nodes.find(x => x.id === sel.id); if (!n) { state.selected = null; return renderProps(); }
title.textContent = "节点属性";
const riskOpts = RISK.map(r => `<option value="${r.key}" ${n.risk === r.key ? "selected" : ""}>${r.name}</option>`).join("");
const domOpts = `<option value="">— 无 —</option>` + state.domains.map(d => `<option value="${d.id}" ${n.domainId === d.id ? "selected" : ""}>${d.name}</option>`).join("");
body.innerHTML = `
<div class="field"><label>设备名称</label><input id="p-label" value="${esc(n.label)}"></div>
<div class="field"><label>类型</label><select id="p-type">${[["net", "网络设备"], ["sec", "安全设备"], ["srv", "服务器设备"]].map(([ck, cn]) => `<optgroup label="${cn}">${Object.entries(DEVICE_TYPES).filter(([, t]) => t.cat === ck).map(([k, t]) => `<option value="${k}" ${n.type === k ? "selected" : ""}>${t.name}</option>`).join("")}</optgroup>`).join("")}</select></div>
<div class="field"><label>IP / 网段</label><input id="p-ip" value="${esc(n.ip)}" placeholder="例 10.0.0.1 / 192.168.1.0/24"></div>
<div class="field"><label>安全风险等级</label><select id="p-risk">${riskOpts}</select></div>
<div class="field"><label>归属信任域</label><select id="p-dom">${domOpts}</select></div>
<div class="field"><label>漏洞 / 安全标签(逗号分隔)</label>
<div class="taginput" id="p-tags"><input id="p-tag-in" placeholder="如 CVE-2024-1234, 弱口令"></div>
</div>
<div class="field"><button class="btn danger" id="p-del" style="width:100%">删除该节点</button></div>`;
// tags
const tagBox = body.querySelector("#p-tags");
n.tags.forEach(tg => tagBox.insertBefore(makeChip(tg, n), body.querySelector("#p-tag-in")));
body.querySelector("#p-tag-in").addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.target.value.trim()) { n.tags.push(e.target.value.trim()); e.target.value = ""; renderProps(); draw(); }
});
bindInput(body, "#p-label", v => n.label = v);
bindInput(body, "#p-ip", v => n.ip = v);
body.querySelector("#p-type").addEventListener("change", e => { n.type = e.target.value; draw(); });
body.querySelector("#p-risk").addEventListener("change", e => { n.risk = e.target.value; draw(); });
body.querySelector("#p-dom").addEventListener("change", e => assignNodeToDomain(n.id, e.target.value || null));
body.querySelector("#p-del").addEventListener("click", () => { snapshot(); deleteNode(n.id); });
} else if (sel.kind === "link") {
const lk = state.links.find(x => x.id === sel.id); if (!lk) { state.selected = null; return renderProps(); }
const a = state.nodes.find(x => x.id === lk.from);
const bNode = state.nodes.find(x => x.id === lk.to);
const bDom = bNode ? null : state.domains.find(x => x.id === lk.to);
const bName = bNode ? bNode.label : (bDom ? bDom.name : "?");
title.textContent = "连线属性";
body.innerHTML = `
<div class="field"><label>连接</label><div class="empty">${esc(a ? a.label : "?")} → ${esc(bName)}</div></div>
<div class="field"><label>标注(带宽/协议)</label><input id="p-ll" value="${esc(lk.label)}" placeholder="如 1Gbps / VLAN10 / IPsec"></div>
<div class="field"><label>链路类型</label><select id="p-lk">
<option value="wire" ${lk.kind === "wire" ? "selected" : ""}>普通链路</option>
<option value="vpn" ${lk.kind === "vpn" ? "selected" : ""}>VPN / 加密隧道</option>
</select></div>
<div class="field"><label>连线形状</label><select id="p-shape">
<option value="straight" ${(lk.shape || "straight") === "straight" && !lk.dashed ? "selected" : ""}>直线实线</option>
<option value="straight-dash" ${lk.shape === "straight" && lk.dashed ? "selected" : ""}>直线虚线</option>
<option value="polyline" ${lk.shape === "polyline" && !lk.dashed ? "selected" : ""}>折线(正交)实线</option>
<option value="polyline-dash" ${lk.shape === "polyline" && lk.dashed ? "selected" : ""}>折线(正交)虚线</option>
<option value="curve" ${lk.shape === "curve" && !lk.dashed ? "selected" : ""}>曲线(贝塞尔)实线</option>
<option value="curve-dash" ${lk.shape === "curve" && lk.dashed ? "selected" : ""}>曲线(贝塞尔)虚线</option>
</select></div>
<div class="field"><label style="display:flex;align-items:center;gap:6px;cursor:pointer"><input type="checkbox" id="p-flow" ${lk.flow ? "checked" : ""}> 🌊 数据流动画</label></div>
<div class="field"><button class="btn danger" id="p-del" style="width:100%">删除该连线</button></div>`;
bindInput(body, "#p-ll", v => lk.label = v);
body.querySelector("#p-lk").addEventListener("change", e => { lk.kind = e.target.value; draw(); });
body.querySelector("#p-shape").addEventListener("change", e => {
const v = e.target.value;
if (v.endsWith("-dash")) { lk.shape = v.replace("-dash", ""); lk.dashed = true; }
else { lk.shape = v; lk.dashed = false; }
draw();
});
const pf = body.querySelector("#p-flow");
if (pf) pf.addEventListener("change", e => { lk.flow = e.target.checked; startFlowLoop(); updateFlowButton(); draw(); });
body.querySelector("#p-del").addEventListener("click", () => { snapshot(); deleteLink(lk.id); });
} else if (sel.kind === "domain") {
const d = state.domains.find(x => x.id === sel.id); if (!d) { state.selected = null; return renderProps(); }
title.textContent = "信任域属性";
body.innerHTML = `
<div class="field"><label>名称</label><input id="p-dn" value="${esc(d.name)}"></div>
<div class="field"><label>颜色</label><input id="p-dc" type="color" value="${d.color}"></div>
<div class="field" style="flex-direction:row;gap:8px"><div style="flex:1"><label>宽 W</label><input id="p-dw" type="number" min="40" value="${Math.round(d.w)}"></div><div style="flex:1"><label>高 H</label><input id="p-dh" type="number" min="40" value="${Math.round(d.h)}"></div></div>
<div class="field"><button class="btn" id="p-fit" style="width:100%">适配大小到成员</button></div>
<div class="field" style="font-size:11.5px;color:var(--muted)">提示:在画布上选中本域后,可拖拽四角 / 四边控制点缩放范围。</div>
<div class="field"><button class="btn danger" id="p-del" style="width:100%">删除该信任域</button></div>`;
bindInput(body, "#p-dn", v => d.name = v);
body.querySelector("#p-dc").addEventListener("input", e => { d.color = e.target.value; draw(); refreshDomainList(); });
body.querySelector("#p-dw").addEventListener("input", e => { const v = Math.max(40, +e.target.value || 40); d.w = v; draw(); });
body.querySelector("#p-dh").addEventListener("input", e => { const v = Math.max(40, +e.target.value || 40); d.h = v; draw(); });
body.querySelector("#p-fit").addEventListener("click", () => { repositionDomain(d.id); draw(); });
body.querySelector("#p-del").addEventListener("click", () => { snapshot(); deleteDomain(d.id); });
}
}
function makeChip(text, n) {
const c = document.createElement("span"); c.className = "chip";
c.innerHTML = `${esc(text)} <b>×</b>`;
c.querySelector("b").addEventListener("click", () => { n.tags = n.tags.filter(t => t !== text); renderProps(); draw(); });
return c;
}
function bindInput(root, sel, fn) {
const el = root.querySelector(sel);
el.addEventListener("input", e => { fn(e.target.value); draw(); });
}
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
function updateStats() {
document.getElementById("st-nodes").textContent = state.nodes.length;
document.getElementById("st-links").textContent = state.links.length;
document.getElementById("st-domains").textContent = state.domains.length;
document.getElementById("zoom-val").textContent = Math.round(state.view.scale * 100) + "%";
}
/* ===================== 工具栏 / 缩放 ===================== */
function setTool(t) {
state.tool = t;
document.querySelectorAll("[data-tool]").forEach(b => b.classList.toggle("active", b.dataset.tool === t));
canvas.style.cursor = t === "pan" ? "grab" : (t === "connect" ? "crosshair" : "default");
document.getElementById("st-mode").textContent = "模式:" + ({ select: "选择", connect: "连线", pan: "平移" }[t]);
}
document.querySelectorAll("[data-tool]").forEach(b => b.addEventListener("click", () => setTool(b.dataset.tool)));
document.getElementById("act-undo").addEventListener("click", undo);
document.getElementById("act-redo").addEventListener("click", redo);
/* ===================== 多元素对齐(左 / 居中 / 右) ===================== */
/* 收集当前多选中的元素盒子:节点用 NODE_W,信任域用自己的 w。 */
function selectedBoxes() {
const nodes = state.sel.filter(s => s.kind === "node").map(s => state.nodes.find(n => n.id === s.id)).filter(Boolean);
const doms = state.sel.filter(s => s.kind === "domain").map(s => state.domains.find(d => d.id === s.id)).filter(Boolean);
return nodes.length >= 2 ? nodes : (doms.length >= 2 ? doms : null);
}
function alignSel(mode) {
const boxes = selectedBoxes();
if (!boxes || boxes.length < 2) { flashHint("请先按住 Shift 点击选择至少 2 个元素,再点对齐"); return; }
snapshot();
const wOf = b => (b.w !== undefined ? b.w : NODE_W);
const isNode = b => b.w === undefined;
const lefts = boxes.map(b => b.x);
const rights = boxes.map(b => b.x + wOf(b));
const minX = Math.min(...lefts), maxR = Math.max(...rights);
const cs = boxes.map(b => b.x + wOf(b) / 2);
const meanC = cs.reduce((a, b) => a + b, 0) / cs.length;
boxes.forEach(b => {
if (mode === "left") b.x = minX;
else if (mode === "center") b.x = meanC - wOf(b) / 2;
else if (mode === "right") b.x = maxR - wOf(b);
if (isNode(b) && b.domainId) repositionDomain(b.domainId);
});
draw(); updateStats(); updateAlignButtons();
}
function alignVSel(mode) {
const boxes = selectedBoxes();
if (!boxes || boxes.length < 2) { flashHint("请先按住 Shift 点击选择至少 2 个元素,再点对齐"); return; }
snapshot();
const hOf = b => (b.h !== undefined ? b.h : NODE_H);
const isNode = b => b.h === undefined;
const tops = boxes.map(b => b.y);
const bots = boxes.map(b => b.y + hOf(b));
const minY = Math.min(...tops), maxB = Math.max(...bots);
const cs = boxes.map(b => b.y + hOf(b) / 2);
const meanC = cs.reduce((a, b) => a + b, 0) / cs.length;
boxes.forEach(b => {
if (mode === "top") b.y = minY;
else if (mode === "vcenter") b.y = meanC - hOf(b) / 2;
else if (mode === "bottom") b.y = maxB - hOf(b);
if (isNode(b) && b.domainId) repositionDomain(b.domainId);
});
draw(); updateStats(); updateAlignButtons();
}
function updateAlignButtons() {
const n = state.sel.filter(s => s.kind === "node").length;
const d = state.sel.filter(s => s.kind === "domain").length;
const en = n >= 2 || d >= 2;
["act-align-l", "act-align-c", "act-align-r", "act-align-t", "act-align-vc", "act-align-b"].forEach(id => {
const b = document.getElementById(id); if (b) b.disabled = !en;
});
const dist = document.getElementById("act-distribute-h");
if (dist) dist.disabled = !(n >= 3 || d >= 3);
const distV = document.getElementById("act-distribute-v");
if (distV) distV.disabled = !(n >= 3 || d >= 3);
updateFlowButton();
}
function updateFlowButton() {
const b = document.getElementById("act-flow"); if (!b) return;
const two = state.sel.length === 2;
b.disabled = !two;
if (two) {
const idA = state.sel[0].id, idB = state.sel[1].id;
const on = state.links.some(l => l.flow && ((l.from === idA && l.to === idB) || (l.from === idB && l.to === idA)));
b.classList.toggle("active", on);
} else b.classList.remove("active");
}
let flowRAF = null;
function anyFlowOn() { return state.links.some(l => l.flow); }
function flowLoop() {
if (!anyFlowOn()) { flowRAF = null; return; }
draw();
flowRAF = requestAnimationFrame(flowLoop);
}
function startFlowLoop() { if (!flowRAF) flowRAF = requestAnimationFrame(flowLoop); }
function toggleFlow() {
const ids = state.sel.map(x => x.id);
if (ids.length !== 2) { flashHint("请先按住 Shift 选中两个元素"); return; }
const [idA, idB] = ids;
const links = state.links.filter(l => (l.from === idA && l.to === idB) || (l.from === idB && l.to === idA));
if (!links.length) { flashHint("两个元素之间没有连线,无法添加数据流"); return; }
const anyOn = links.some(l => l.flow);
links.forEach(l => { l.flow = !anyOn; });
startFlowLoop();
updateFlowButton();
draw();
}
function distributeHSel() {
const boxes = selectedBoxes();
if (!boxes || boxes.length < 3) { flashHint("请先按住 Shift 点击选择至少 3 个元素,再点横向分布"); return; }
snapshot();
const wOf = b => (b.w !== undefined ? b.w : NODE_W);
/* 按水平中心从左到右排序,最左、最右作为两端保持不动,中间元素中心等距分布 */
const order = boxes.slice().sort((a, b) => (a.x + wOf(a) / 2) - (b.x + wOf(b) / 2));
const cFirst = order[0].x + wOf(order[0]) / 2;
const cLast = order[order.length - 1].x + wOf(order[order.length - 1]) / 2;
order.forEach((b, i) => {
if (i === 0 || i === order.length - 1) return; // 两端不动
const cx = cFirst + (cLast - cFirst) * (i / (order.length - 1));
b.x = cx - wOf(b) / 2;
if (b.w === undefined && b.domainId) repositionDomain(b.domainId);
});
draw(); updateStats(); updateAlignButtons();
}
function distributeVSel() {
const boxes = selectedBoxes();
if (!boxes || boxes.length < 3) { flashHint("请先按住 Shift 点击选择至少 3 个元素,再点纵向分布"); return; }
snapshot();
const hOf = b => (b.h !== undefined ? b.h : NODE_H);
/* 按竖直中心从上到下排序,最上、最下作为两端保持不动,中间元素中心等距分布 */
const order = boxes.slice().sort((a, b) => (a.y + hOf(a) / 2) - (b.y + hOf(b) / 2));
const cFirst = order[0].y + hOf(order[0]) / 2;
const cLast = order[order.length - 1].y + hOf(order[order.length - 1]) / 2;
order.forEach((b, i) => {
if (i === 0 || i === order.length - 1) return; // 两端不动
const cy = cFirst + (cLast - cFirst) * (i / (order.length - 1));
b.y = cy - hOf(b) / 2;
if (b.h === undefined && b.domainId) repositionDomain(b.domainId);
});
draw(); updateStats(); updateAlignButtons();
}
function flashHint(msg) {
const t = document.createElement("div");
t.textContent = msg;
t.style.cssText = "position:fixed;left:50%;top:14px;transform:translateX(-50%);background:#1f2733;color:#fff;padding:8px 14px;border-radius:8px;font-size:13px;z-index:9999;box-shadow:0 6px 20px rgba(0,0,0,.25)";
document.body.appendChild(t);
setTimeout(() => t.remove(), 1900);
}
document.getElementById("act-align-l").addEventListener("click", () => alignSel("left"));
document.getElementById("act-align-c").addEventListener("click", () => alignSel("center"));
document.getElementById("act-align-r").addEventListener("click", () => alignSel("right"));
document.getElementById("act-align-t").addEventListener("click", () => alignVSel("top"));
document.getElementById("act-align-vc").addEventListener("click", () => alignVSel("vcenter"));
document.getElementById("act-align-b").addEventListener("click", () => alignVSel("bottom"));
document.getElementById("act-distribute-h").addEventListener("click", distributeHSel);
document.getElementById("act-distribute-v").addEventListener("click", distributeVSel);
document.getElementById("act-flow").addEventListener("click", toggleFlow);
/* 信任域:从左侧拖拽虚线框到画布创建 */
const DOMAIN_PALETTE = ["#2563eb", "#0ea5e9", "#7c3aed", "#db2777", "#16a34a", "#ea580c"];
const domainChip = document.getElementById("domain-chip");
domainChip.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/nt-domain", "1");
e.dataTransfer.effectAllowed = "copy";
});
canvas.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; });
canvas.addEventListener("drop", (e) => {
if (!e.dataTransfer.getData("text/nt-domain")) return;
e.preventDefault();
const r = canvas.getBoundingClientRect();
const w = screenToWorld(e.clientX - r.left, e.clientY - r.top);
snapshot();
const w0 = 320, h0 = 220;
const d = { id: uid("d"), name: "安全分区 " + (state.domains.length + 1), color: DOMAIN_PALETTE[state.domains.length % DOMAIN_PALETTE.length], x: Math.round(w.x - w0 / 2), y: Math.round(w.y - h0 / 2), w: w0, h: h0, nodeIds: [] };
state.domains.push(d);
state.selected = { kind: "domain", id: d.id };
refreshDomainList(); renderProps(); draw();
});
document.getElementById("act-clear").addEventListener("click", () => {
if (!state.nodes.length && !state.links.length) return;
if (confirm("确定清空整个画布?")) { snapshot(); state.nodes = []; state.links = []; state.domains = []; state.sel = []; state.selected = null; refreshDomainList(); renderProps(); updateAlignButtons(); draw(); }
});
function zoomAt(factor, cx, cy) {
const r = canvas.getBoundingClientRect();
cx = cx == null ? r.width / 2 : cx; cy = cy == null ? r.height / 2 : cy;
const wx = (cx - state.view.x) / state.view.scale, wy = (cy - state.view.y) / state.view.scale;
state.view.scale = Math.max(0.2, Math.min(3, state.view.scale * factor));
state.view.x = cx - wx * state.view.scale; state.view.y = cy - wy * state.view.scale;
draw();
}
document.getElementById("zoom-in").addEventListener("click", () => zoomAt(1.2));
document.getElementById("zoom-out").addEventListener("click", () => zoomAt(1 / 1.2));
document.getElementById("zoom-fit").addEventListener("click", fitView);
canvas.addEventListener("wheel", (e) => { e.preventDefault(); const p = pointerPos(e); zoomAt(e.deltaY < 0 ? 1.1 : 1 / 1.1, p.x, p.y); }, { passive: false });
function fitView() {
if (!state.nodes.length) { state.view = { scale: 1, x: 40, y: 40 }; draw(); return; }
let minX = Math.min(...state.nodes.map(n => n.x));
let minY = Math.min(...state.nodes.map(n => n.y));
let maxX = Math.max(...state.nodes.map(n => n.x + NODE_W));
let maxY = Math.max(...state.nodes.map(n => n.y + NODE_H));
for (const d of state.domains) {
minX = Math.min(minX, d.x); minY = Math.min(minY, d.y);
maxX = Math.max(maxX, d.x + d.w); maxY = Math.max(maxY, d.y + d.h);
}
minX -= 60; minY -= 60; maxX += 60; maxY += 60;
const r = canvas.getBoundingClientRect();
const s = Math.min(r.width / (maxX - minX), r.height / (maxY - minY), 2);
state.view.scale = s;
state.view.x = (r.width - (maxX - minX) * s) / 2 - minX * s;
state.view.y = (r.height - (maxY - minY) * s) / 2 - minY * s;
draw();
}
/* ===================== 导出 ===================== */
async function exportPNG() {
const b = contentBounds(40);
if (!isFinite(b.w) || !isFinite(b.h) || b.w <= 0 || b.h <= 0) { flashHint("画布没有可导出的内容"); return; }
const scale = 2;
const off = document.createElement("canvas");
off.width = Math.max(1, Math.round(b.w * scale));
off.height = Math.max(1, Math.round(b.h * scale));
const c = off.getContext("2d");
if (!c) { flashHint("无法创建导出画布"); return; }
c.setTransform(scale, 0, 0, scale, -b.x * scale, -b.y * scale);
c.fillStyle = "#ffffff"; c.fillRect(b.x, b.y, b.w, b.h);
renderTo(c, scale, scale);
// 同步生成 PNG(toDataURL 永不返回 null),再转换为 Blob,避免部分浏览器 toBlob 返回 null 导致 0 字节文件
const dataURL = off.toDataURL("image/png");
if (!dataURL || dataURL.length < 50) { flashHint("PNG 生成失败"); return; }
const blob = dataURLToBlob(dataURL);
const name = "topology-" + Date.now() + ".png";
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: name,
types: [{ description: "PNG 图片", accept: { "image/png": [".png"] } }]
});
const w = await handle.createWritable();
await w.write(blob); await w.close();
flashHint("已导出 PNG:" + handle.name);
return;
} catch (err) {
if (err && err.name === "AbortError") return; // 用户取消保存
// 不支持或出错时回退到浏览器默认下载
}
}
downloadBlob(blob, name);
}
document.getElementById("act-png").addEventListener("click", exportPNG);
document.getElementById("act-svg").addEventListener("click", () => {
const svg = exportSVG();
downloadBlob(new Blob([svg], { type: "image/svg+xml" }), "topology-" + Date.now() + ".svg");
});
function contentBounds(pad) {
let minX = 0, minY = 0, maxX = 400, maxY = 300;
if (state.nodes.length) {
minX = Math.min(...state.nodes.map(n => n.x)); minY = Math.min(...state.nodes.map(n => n.y));
maxX = Math.max(...state.nodes.map(n => n.x + NODE_W)); maxY = Math.max(...state.nodes.map(n => n.y + NODE_H));
}
state.domains.forEach(d => { minX = Math.min(minX, d.x); minY = Math.min(minY, d.y); maxX = Math.max(maxX, d.x + d.w); maxY = Math.max(maxY, d.y + d.h); });
return { x: minX - pad, y: minY - pad, w: (maxX - minX) + pad * 2, h: (maxY - minY) + pad * 2 };
}
function renderTo(c, scale, d) {
// shared renderer reused for PNG offscreen
c.save();
state.domains.forEach(dm => {
c.fillStyle = hexA(dm.color, 0.08); c.strokeStyle = hexA(dm.color, 0.55); c.lineWidth = 1.5;
c.setLineDash([8, 6]); roundRect(c, dm.x, dm.y, dm.w, dm.h, 14); c.fill(); c.stroke(); c.setLineDash([]);
c.fillStyle = dm.color; c.font = "600 13px sans-serif"; c.textBaseline = "top"; c.textAlign = "left"; c.fillText(dm.name, dm.x + 10, dm.y + 8);
});
state.links.forEach(lk => {
const e = linkEnds(lk); if (!e) return;
const { a, b } = e;
const col = lk.kind === "vpn" ? "#0891b2" : "#8a94a6";
c.strokeStyle = col; c.lineWidth = 2;
if (lk.kind === "vpn" || lk.dashed) c.setLineDash([6, 5]);
const shape = lk.shape || "straight";
c.beginPath();
if (shape === "curve") { const cv = linkCurve(lk); c.moveTo(a.x, a.y); c.bezierCurveTo(cv.c1.x, cv.c1.y, cv.c2.x, cv.c2.y, b.x, b.y); }
else { const pts = linkPoints(lk); c.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length; i++) c.lineTo(pts[i].x, pts[i].y); }
c.stroke(); c.setLineDash([]);
paintArrow(c, lk, col);
if (lk.label) { const lp = linkLabelPoint(lk); c.font = "600 11px sans-serif"; c.textAlign = "center"; c.textBaseline = "middle"; const w = c.measureText(lk.label).width + 10; c.fillStyle = "#fff"; c.fillRect(lp.x - w / 2, lp.y - 8, w, 16); c.fillStyle = "#475569"; c.fillText(lk.label, lp.x, lp.y); }
});
state.nodes.forEach(n => {
const t = DEVICE_TYPES[n.type] || DEVICE_TYPES.server;
roundRect(c, n.x, n.y, NODE_W, NODE_H, 9); c.fillStyle = "#fff"; c.fill();
c.lineWidth = 1.5; c.strokeStyle = hexA(t.color, 0.6); c.stroke();
c.save(); roundRect(c, n.x, n.y, NODE_W, NODE_H, 9); c.clip(); c.fillStyle = hexA(t.color, 0.12); c.fillRect(n.x, n.y, NODE_W, 14); c.restore();
c.save(); c.translate(n.x + NODE_W / 2, n.y + 22); c.scale(0.9, 0.9); drawIconTo(c, n.type, 1); c.restore();
c.fillStyle = "#1f2733"; c.font = "600 11.5px sans-serif"; c.textAlign = "center"; c.textBaseline = "top"; c.fillText(n.label || t.name, n.x + NODE_W / 2, n.y + 36);
if (n.ip) { c.fillStyle = "#6b7686"; c.font = "10px sans-serif"; c.fillText(n.ip, n.x + NODE_W / 2, n.y + 50); }
if (n.risk && n.risk !== "none") { const rc = RISK.find(r => r.key === n.risk).color; c.beginPath(); c.arc(n.x + NODE_W - 4, n.y + 4, 5.5, 0, 7); c.fillStyle = rc; c.fill(); }
});
state.domains.forEach(d => drawDomainLabel(c, d, 1));
c.restore();
}
function exportSVG() {
const b = contentBounds(40);
const parts = [`<svg xmlns="http://www.w3.org/2000/svg" width="${b.w}" height="${b.h}" viewBox="${b.x} ${b.y} ${b.w} ${b.h}">`];
parts.push(`<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" fill="#ffffff"/>`);
state.domains.forEach(d => {
parts.push(`<rect x="${d.x}" y="${d.y}" width="${d.w}" height="${d.h}" rx="14" fill="${hexA(d.color, 0.08)}" stroke="${hexA(d.color, 0.55)}" stroke-dasharray="8 6"/>`);
});
state.links.forEach(lk => {
const e = linkEnds(lk); if (!e) return;
const { a, b } = e;
const col = lk.kind === "vpn" ? "#0891b2" : "#8a94a6";
const dash = (lk.kind === "vpn" || lk.dashed) ? ` stroke-dasharray="6 5"` : "";
const shape = lk.shape || "straight";
if (shape === "curve") { const cv = linkCurve(lk);
parts.push(`<path d="M ${a.x} ${a.y} C ${cv.c1.x} ${cv.c1.y} ${cv.c2.x} ${cv.c2.y} ${b.x} ${b.y}" fill="none" stroke="${col}" stroke-width="2"${dash}/>`); }
else if (shape === "polyline") { const pts = linkPoints(lk).map(p => `${p.x} ${p.y}`).join(" ");
parts.push(`<polyline points="${pts}" fill="none" stroke="${col}" stroke-width="2"${dash}/>`); }
else { parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${col}" stroke-width="2"${dash}/>`); }
const av = arrowVerts(lk);
if (av) parts.push(`<polygon points="${av[0].x},${av[0].y} ${av[1].x},${av[1].y} ${av[2].x},${av[2].y}" fill="${col}"/>`);
if (lk.label) { const lp = linkLabelPoint(lk); parts.push(`<rect x="${lp.x - 30}" y="${lp.y - 8}" width="60" height="16" fill="#fff"/><text x="${lp.x}" y="${lp.y + 4}" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#475569">${esc(lk.label)}</text>`); }
});
state.nodes.forEach(n => {
const t = DEVICE_TYPES[n.type] || DEVICE_TYPES.server;
parts.push(`<rect x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="9" fill="#fff" stroke="${hexA(t.color, 0.6)}" stroke-width="1.5"/>`);
parts.push(`<rect x="${n.x}" y="${n.y}" width="${NODE_W}" height="14" rx="9" fill="${hexA(t.color, 0.12)}"/>`);
parts.push(`<text x="${n.x + NODE_W / 2}" y="${n.y + 47}" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#1f2733">${esc(n.label || t.name)}</text>`);
if (n.ip) parts.push(`<text x="${n.x + NODE_W / 2}" y="${n.y + 61}" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#6b7686">${esc(n.ip)}</text>`);
if (n.risk && n.risk !== "none") { const rc = RISK.find(r => r.key === n.risk).color; parts.push(`<circle cx="${n.x + NODE_W - 4}" cy="${n.y + 4}" r="5.5" fill="${rc}"/>`); }
if (n.tags && n.tags.length) parts.push(`<text x="${n.x + NODE_W / 2}" y="${n.y - 6}" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#dc2626">${esc(n.tags.slice(0, 3).join(" / "))}</text>`);
});
state.domains.forEach(d => {
const tw = d.name.length * 13 + 14;
parts.push(`<rect x="${d.x + 4}" y="${d.y}" width="${tw}" height="18" rx="5" fill="rgba(255,255,255,0.95)"/>`);
parts.push(`<text x="${d.x + 10}" y="${d.y + 13}" font-family="sans-serif" font-size="13" font-weight="600" fill="${d.color}">${esc(d.name)}</text>`);
});
parts.push(`</svg>`);
return parts.join("\n");
}
function downloadBlob(blobOrCanvas, name) {
const blob = blobOrCanvas instanceof Blob ? blobOrCanvas : (blobOrCanvas.toBlob ? null : blobOrCanvas);
const url = blob ? URL.createObjectURL(blob) : (blobOrCanvas.toDataURL ? blobOrCanvas.toDataURL("image/png") : null);
const a = document.createElement("a");
a.href = blob ? url : blobOrCanvas.toDataURL("image/png");
a.download = name; a.click();
if (blob) setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function dataURLToBlob(d) { const arr = d.split(","), mime = arr[0].match(/:(.*?);/)[1], bin = atob(arr[1]); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return new Blob([u], { type: mime }); }
/* ===================== 初始化 ===================== */
function seedDemo() {
const mk = (type, label, ip, x, y, risk, tags) => ({ id: uid("n"), type, label, ip, x, y, risk: risk || "none", tags: tags || [], domainId: null });
/* ===== 外网信任域(右侧,竖向安全接入层,共七层) ===== */
/* 框上方:互联网(云)—— 与外网信任域水平居中对齐(中心 x=935) */
const internet = mk("cloud", "互联网","", 1035, 70, "none");
/* 框内自上而下七层,每层两台(左列 x=845 / 右列 x=1025,关于中心 935 对称) */
const Y_LB = 220, Y_DD = 320, Y_FW = 420, Y_IPS = 520, Y_BHV = 620, Y_AVW = 720, Y_SW = 840;
const lbA = mk("lb", "负载均衡A","", 945, Y_LB, "none");
const lbB = mk("lb", "负载均衡B","", 1125, Y_LB, "none");
const ddA = mk("ddos", "抗DDOS-A","", 945, Y_DD, "mid");
const ddB = mk("ddos", "抗DDOS-B","", 1125, Y_DD, "mid");
const fwA = mk("firewall", "防火墙A","", 945, Y_FW, "high");
const fwB = mk("firewall", "防火墙B","", 1125, Y_FW, "high");
/* 防火墙A 左侧的 VPN(框内,x=750) */
const vpn = mk("vpn", "VPN","", 850, Y_FW, "mid", ["远程接入"]);
/* 防火墙B 右侧的 WAF(框内,x=1120) */
const waf = mk("waf", "WAF","", 1220, Y_FW, "high", ["Web防护"]);
const ipsA = mk("ips", "入侵防御A","", 945, Y_IPS, "high");
const ipsB = mk("ips", "入侵防御B","", 1125, Y_IPS, "high");
const bhvA = mk("behavior", "上网行为A","", 945, Y_BHV, "mid");
const bhvB = mk("behavior", "上网行为B","", 1125, Y_BHV, "mid");
const avwA = mk("avwall", "防毒墙A","", 945, Y_AVW, "mid");
const avwB = mk("avwall", "防毒墙B","", 1125, Y_AVW, "mid");
const swOA = mk("switch", "外网核心A","", 945, Y_SW, "none");
const swOB = mk("switch", "外网核心B","", 1125, Y_SW, "none");
/* 外网核心交换机B 右侧的入侵检测(用于旁路监测外网核心流量) */
const idsDev = mk("ids", "入侵检测","", 1221, Y_SW, "mid", ["旁路监测"]);
/* ===== 外网核心交换机下方:三台汇聚交换机(每台同时上联两台外网核心) ===== */
const Y_AGG = 1010;
const agg1 = mk("aswitch", "汇聚交换机1","", 835, Y_AGG, "none");
const agg2 = mk("aswitch", "汇聚交换机2","", 1035, Y_AGG, "none");
const agg3 = mk("aswitch", "汇聚交换机3","", 1235, Y_AGG, "none");
/* ===== 中间:网闸(物理隔离两侧信任域);与左右四台核心交换机同处一条水平线(中心 y=867) ===== */
const gap = mk("gap", "网闸","", 597, 840, "mid", ["物理隔离"]);
/* ===== 内网信任域(左侧,两台水平核心交换机) ===== */
const swIA = mk("switch", "内网核心A","", 250, Y_SW, "none");
const swIB = mk("switch", "内网核心B","", 380, Y_SW, "none");
/* 内网核心交换机B右侧(右上方)接入的入侵检测,归入内网核心信任域 */
const nidsB = mk("ids", "入侵检测","", 462, 786, "mid", ["旁路监测"]);
/* ===== 内网安全链:内网核心交换机 向上 → 入侵防御 → 防火墙 ===== */
const iiA = mk("ips", "内网入侵防御A","", 250, 620, "high");
const iiB = mk("ips", "内网入侵防御B","", 380, 620, "high");
const ifA = mk("firewall", "内网防火墙A","", 250, 420, "high");
const ifB = mk("firewall", "内网防火墙B","", 380, 420, "high");
/* ===== 三个外部接入云(政务外网 / 银行 / 医保):置于内网信任域上方、虚线框之外,顶端与互联网图标对齐,分别连到内网两台防火墙 ===== */
const cyA = mk("cloud", "政务外网","", 204, 70, "none");
const cyB = mk("cloud", "银行","", 337, 70, "none");
const cyC = mk("cloud", "医保","", 470, 70, "none");
/* ===== 核心业务信任域设备:每类 3 台竖直排列为一列(普通 / 超融合 / 智算 / 存储,4 列 ×3 行) ===== */
const srv1 = mk("server", "普通服务器1","", 150, 951, "none");
const srv2 = mk("server", "普通服务器2","", 150, 1071, "none");
const srv3 = mk("server", "普通服务器3","", 150, 1191, "none");
const hci1 = mk("hci", "超融合服务器1","", 262, 951, "none");
const hci2 = mk("hci", "超融合服务器2","", 262, 1071, "none");
const hci3 = mk("hci", "超融合服务器3","", 262, 1191, "none");
const ai1 = mk("ai", "智算服务器1","", 374, 951, "none");
const ai2 = mk("ai", "智算服务器2","", 374, 1071, "none");
const ai3 = mk("ai", "智算服务器3","", 374, 1191, "none");
const sto1 = mk("storage", "存储设备1","", 486, 951, "none");
const sto2 = mk("storage", "存储设备2","", 486, 1071, "none");
const sto3 = mk("storage", "存储设备3","", 486, 1191, "none");
/* ===== 业务科室信任域:每台汇聚下方一个,各含 1 个 PC终端 + 1 个移动终端(左右分居两侧) ===== */
const aggCenters = [835, 1035, 1235];
const Y_DEPT = 1170;
const aggs = [agg1, agg2, agg3];
const depts = aggCenters.map((cx, i) => ({
pc: mk("pc", "PC终端","", cx - 76, Y_DEPT, "none"),
mb: mk("mobile", "移动终端","", cx + 10, Y_DEPT, "none"),
}));
const pc1 = depts[0].pc, mb1 = depts[0].mb, pc2 = depts[1].pc, mb2 = depts[1].mb, pc3 = depts[2].pc, mb3 = depts[2].mb;
state.nodes.push(internet, lbA, lbB, ddA, ddB, fwA, fwB, vpn, waf, ipsA, ipsB, bhvA, bhvB, avwA, avwB, swOA, swOB, idsDev, gap, swIA, swIB, nidsB, iiA, iiB, ifA, ifB, cyA, cyB, cyC, srv1, srv2, srv3, hci1, hci2, hci3, sto1, sto2, sto3, ai1, ai2, ai3, agg1, agg2, agg3, pc1, mb1, pc2, mb2, pc3, mb3);
const L = (a, b, label, kind, shape, dashed) => state.links.push({ id: uid("l"), from: a.id, to: b.id, label: label || "", kind: kind || "wire", shape: shape || "straight", dashed: !!dashed });
/* 互联网 → 2 负载均衡 */
L(internet, lbA); L(internet, lbB);
/* 负载均衡 → 抗DDOS */
L(lbA, ddA); L(lbB, ddB);
/* 抗DDOS → 防火墙 */
L(ddA, fwA); L(ddB, fwB);
/* 防火墙A 左侧接 VPN;防火墙B 右侧接 WAF */
L(vpn, fwA); L(fwB, waf);
/* 防火墙 → 入侵防御 → 上网行为管理 → 防毒墙 → 外网核心交换机 */
L(fwA, ipsA); L(fwB, ipsB);
L(ipsA, bhvA); L(ipsB, bhvB);
L(bhvA, avwA); L(bhvB, avwB);
L(avwA, swOA); L(avwB, swOB);
/* 外网核心交换机B 右侧连接入侵检测(旁路监测) */
L(swOB, idsDev, "", "wire", "straight", false);
/* 内网安全链:两台内网核心交换机 向上连接入侵防御,再向上连接防火墙 */
L(swIA, iiA); L(swIB, iiB);
L(swIB, nidsB, "", "wire", "straight", false);
L(iiA, ifA); L(iiB, ifB);
/* 双机冗余:两台内网防火墙之间、两台内网入侵防御之间用虚线连接 */
L(ifA, ifB, "", "wire", "straight", true);
L(iiA, iiB, "", "wire", "straight", true);
/* 三个外部接入云分别连到内网两台防火墙(政务外网 / 银行 / 医保 → 防火墙A、防火墙B) */
L(cyA, ifA); L(cyA, ifB);
L(cyB, ifA); L(cyB, ifB);
L(cyC, ifA); L(cyC, ifB);
/* 双机冗余:外网接入信任域内同类设备之间用虚线连接(负载均衡 / 抗DDOS / 防火墙 / 入侵防御 / 上网行为管理 / 防毒墙 的 A、B 之间) */
L(lbA, lbB, "", "wire", "straight", true);
L(ddA, ddB, "", "wire", "straight", true);
L(fwA, fwB, "", "wire", "straight", true);
L(ipsA, ipsB, "", "wire", "straight", true);
L(bhvA, bhvB, "", "wire", "straight", true);
L(avwA, avwB, "", "wire", "straight", true);
/* 网闸 ↔ 内网两台核心交换机 */
L(gap, swIA); L(gap, swIB);
/* 网闸 ↔ 外网两台核心交换机 */
L(gap, swOA); L(gap, swOB);
/* 三台汇聚交换机分别上联两台外网核心交换机 */
L(agg1, swOA); L(agg1, swOB);
L(agg2, swOA); L(agg2, swOB);
L(agg3, swOA); L(agg3, swOB);
/* 每台汇聚交换机下联其业务科室信任域中的 PC终端 与 移动终端 */
L(agg1, pc1); L(agg1, mb1);
L(agg2, pc2); L(agg2, mb2);
L(agg3, pc3); L(agg3, mb3);
/* 左侧信任域:内网(含专网接入 / 核心业务 / 安全管理 子信任域 + 两台水平核心交换机) */
const dL = { id: uid("d"), name: "内网", color: "#2563eb", x: -120, y: 170, w: 680, h: 1100, nodeIds: [] };
dL.nodeIds = []; state.domains.push(dL);
/* 内网核心信任域:嵌套在内网中,包住两台内网核心交换机;与专网接入、核心业务两域竖直对齐(同一左缘 x=140,上下堆叠构成右侧一列) */
const dCoreIn = { id: uid("d"), name: "内网核心", color: "#0d9488", x: 140, y: 720, w: 415, h: 192, nodeIds: [] };
dCoreIn.nodeIds = [swIA.id, swIB.id, nidsB.id]; state.domains.push(dCoreIn);
swIA.domainId = dCoreIn.id; swIB.domainId = dCoreIn.id; nidsB.domainId = dCoreIn.id;
/* 专网接入信任域:嵌套在内网中,包住两台内网防火墙与两台内网入侵防御 */
const dSec = { id: uid("d"), name: "专网接入", color: "#d97706", x: 140, y: 397, w: 415, h: 300, nodeIds: [] };
dSec.nodeIds = [ifA.id, ifB.id, iiA.id, iiB.id]; state.domains.push(dSec);
[ifA, ifB, iiA, iiB].forEach(n => n.domainId = dSec.id);
/* 核心业务信任域:嵌套在内网中,包住 12 台业务服务器(普通/超融合/智算/存储 各 3 台,竖直排列为 4 列) */
const dBus = { id: uid("d"), name: "核心业务", color: "#db2777", x: 140, y: 924, w: 415, h: 336, nodeIds: [] };
dBus.nodeIds = [srv1.id, srv2.id, srv3.id, hci1.id, hci2.id, hci3.id, sto1.id, sto2.id, sto3.id, ai1.id, ai2.id, ai3.id]; state.domains.push(dBus);
[srv1, srv2, srv3, hci1, hci2, hci3, sto1, sto2, sto3, ai1, ai2, ai3].forEach(n => n.domainId = dBus.id);
/* 两台内网核心交换机连接到核心业务信任域的虚线框(终点为域边框) */
L(swIA, dBus); L(swIB, dBus);
/* 安全管理信任域:嵌套在内网中,位于内网核心交换机A左侧并与之水平对齐(中心 y 与内网核心一致);含 8 台安全管理设备,按 3 列×3 行网格排列(第 3 行仅 EDR、态势感知平台,右下角留空),并连接到内网核心A */
const sm1 = mk("logaudit", "日志审计","", -110, 730, "mid");
const sm2 = mk("dbaudit", "数据库审计","", -30, 730, "mid");
const sm3 = mk("bastion", "堡垒机","", 50, 730, "mid");
const sm4 = mk("netaudit", "网络审计","", -110, 840, "mid");
const sm5 = mk("apiaudit", "API安全审计","", -30, 840, "mid");
const sm6 = mk("vuln", "漏洞扫描","", 50, 840, "mid");
const sm7 = mk("edr", "EDR","", -110, 950, "mid");
const sm8 = mk("sita", "态势感知平台","", -30, 950, "mid");
const dMgmt = { id: uid("d"), name: "安全管理", color: "#9333ea", x: -116, y: 672, w: 238, h: 390, nodeIds: [] };
dMgmt.nodeIds = [sm1.id, sm2.id, sm3.id, sm4.id, sm5.id, sm6.id, sm7.id, sm8.id]; state.domains.push(dMgmt);
[sm1, sm2, sm3, sm4, sm5, sm6, sm7, sm8].forEach(n => n.domainId = dMgmt.id);
state.nodes.push(sm1, sm2, sm3, sm4, sm5, sm6, sm7, sm8);
/* 内网核心交换机A 连接到安全管理信任域(终点为域边框) */
L(swIA, dMgmt);
/* 中间:内外网隔离交换信任域(包住网闸,位于内网与外网之间) */
const dMid = { id: uid("d"), name: "内外网隔离交换", color: "#ef4444", x: 570, y: 802, w: 120, h: 130, nodeIds: [] };
dMid.nodeIds = [gap.id]; state.domains.push(dMid);
gap.domainId = dMid.id;
/* 右侧外层大框:外网信任域(包住接入层、核心层、汇聚交换机、业务科室);中心 935 与互联网云对齐 */
const dW = { id: uid("d"), name: "外网", color: "#059669", x: 720, y: 170, w: 630, h: 1100, nodeIds: [] };
state.domains.push(dW);
/* 外网接入信任域(框内上方,含接入层设备);与外网核心信任域等宽、左右对齐、居中 */
const dAcc = { id: uid("d"), name: "外网接入", color: "#0891b2", x: 750, y: 190, w: 570, h: 590, nodeIds: [] };
dAcc.nodeIds = [lbA.id, lbB.id, ddA.id, ddB.id, fwA.id, fwB.id, vpn.id, waf.id, ipsA.id, ipsB.id, bhvA.id, bhvB.id, avwA.id, avwB.id]; state.domains.push(dAcc);
[lbA, lbB, ddA, ddB, fwA, fwB, vpn, waf, ipsA, ipsB, bhvA, bhvB, avwA, avwB].forEach(n => n.domainId = dAcc.id);
/* 外网核心信任域(框内下方,含两台外网核心交换机);与接入域等宽、左右对齐 */
const dCore = { id: uid("d"), name: "外网核心", color: "#7c3aed", x: 750, y: 800, w: 570, h: 150, nodeIds: [] };
dCore.nodeIds = [swOA.id, swOB.id, idsDev.id]; state.domains.push(dCore);
[swOA, swOB, idsDev].forEach(n => n.domainId = dCore.id);
/* 业务科室信任域:每台汇聚交换机下方各一个(汇聚交换机居中,PC终端居左、移动终端居右) */
depts.forEach((d, i) => {
const cx = aggCenters[i];
const dBox = { id: uid("d"), name: "业务科室", color: "#d97706", x: cx - 85, y: 975, w: 170, h: 270, nodeIds: [] };
dBox.nodeIds = [aggs[i].id, d.pc.id, d.mb.id]; state.domains.push(dBox);
[aggs[i], d.pc, d.mb].forEach(n => n.domainId = dBox.id);
});
}
buildPalette();
refreshDomainList();
seedDemo();
setTool("select");
updateAlignButtons();
fitView();
window.addEventListener("resize", resize);
resize();
</script>
</body>
</html>
程序后续还会不断迭代更新,如果本程序对您有帮助,或者您有好的建议意见,或者您基于这个程序基础上做了更好的功能,也请不吝赐教

浙公网安备 33010602011771号