json
{
"name": "dnd-vue-topology",
"version": "1.0.0",
"private": true,
"scripts": {
"serve": "npm run build && node scripts/serve-dist.js",
"build": "webpack --config webpack.config.js"
},
"dependencies": {
"babel-polyfill": "^6.26.0",
"core-js": "^2.6.1",
"d3": "5.16.0",
"vue": "2.7.16",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"babel-loader": "8.2.5",
"css-loader": "0.28.11",
"html-webpack-plugin": "3.2.0",
"vue-loader": "15.11.1",
"vue-style-loader": "^4.1.3",
"vue-template-compiler": "2.7.16",
"webpack": "3.12.0",
"webpack-dev-server": "2.11.5"
}
}
jsconst
const path = require("path");
const { VueLoaderPlugin } = require("vue-loader");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: path.resolve(__dirname, "src/main.js"),
output: {
path: path.resolve(__dirname, "dist"),
filename: "js/[name].[chunkhash:8].js",
},
resolve: {
extensions: [".js", ".vue", ".json"],
alias: {
vue$: "vue/dist/vue.esm.js",
"@": path.resolve(__dirname, "src"),
},
},
module: {
rules: [
{
test: /\.vue$/,
loader: "vue-loader",
},
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: [["@babel/preset-env", {
targets: {
ie: "11",
},
bugfixes: true,
modules: false,
useBuiltIns: false,
}]],
},
},
},
{
test: /\.css$/,
use: ["vue-style-loader", "css-loader"],
},
],
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "public/index.html"),
title: "Vue2 D3 拓扑图",
}),
],
devServer: {
host: "127.0.0.1",
port: 8088,
hot: true,
open: false,
historyApiFallback: true,
contentBase: path.resolve(__dirname, "public"),
},
devtool: "source-map",
};
toppchart
js
<template>
<main ref="page" class="topology-page" :class="{ 'is-fullscreen': isFullscreen, 'is-legacy-ie': isLegacyIe }">
<div class="zoom-control">
<button class="zoom-button" :disabled="zoomPercent <= minZoomPercent" @click="changeZoom(-0.1)">-</button>
<input
v-model="zoomInput"
class="zoom-percent"
type="text"
title="输入缩放比例后回车或离开输入框生效"
@blur="applyZoomInput"
@keydown.enter.prevent="$event.target.blur()"
/>
<button class="zoom-button" :disabled="zoomPercent >= maxZoomPercent" @click="changeZoom(0.1)">+</button>
<button class="zoom-button export-button" @click="resetView">重置</button>
<label class="drag-toggle" title="开启后可以拖拽移动画布">
<input v-model="dragEnabled" type="checkbox" />
拖拽
</label>
<label class="drag-toggle playback-toggle" title="开启后按月份自动播放">
<input
:checked="autoPlayEnabled"
type="checkbox"
@change="$emit('toggle-auto-play', $event.target.checked)"
/>
动态播放
</label>
<button class="zoom-button export-button" @click="expandAllNodes">展开全部</button>
<button class="zoom-button export-button" @click="collapseToRootNodes">收起全部</button>
<button class="zoom-button export-button" @click="toggleFullscreen">
{{ isFullscreen ? "退出全屏" : "全屏" }}
</button>
<button class="zoom-button export-button" @click="exportTopologyExcel">导出Excel</button>
<button class="zoom-button export-button" @click="triggerImportExcel">导入Excel</button>
<button class="zoom-button export-button" @click="exportAsPng" title="导出 PNG">导出</button>
<input
ref="excelInput"
class="file-input"
type="file"
accept=".xlsx,.xls,.html,.htm,.csv,.tsv,text/html,text/csv,text/tab-separated-values"
@change="importTopologyExcel"
/>
</div>
<section v-if="enableNodeEdit && editingNode" class="edit-panel">
<h2 class="edit-title">编辑节点</h2>
<label class="edit-field">
名称
<textarea v-model="editForm.name" class="edit-textarea"></textarea>
</label>
<label class="edit-field">
当前数量
<input v-model="editForm.count" class="edit-input" type="number" />
</label>
<label class="edit-field">
占比
<input v-model="editForm.ratio" class="edit-input" placeholder="例如 76%" />
</label>
<label class="edit-field">
连线文字
<input v-model="editForm.link" class="edit-input" placeholder="没有可留空" />
</label>
<label class="edit-field">
节点颜色
<select v-model="editForm.type" class="edit-select">
<option value="root">灰色</option>
<option value="blue">蓝色</option>
<option value="green">绿色</option>
<option value="red">红色</option>
<option value="orange">橙色</option>
</select>
</label>
<div class="edit-actions">
<button class="edit-button" @click="closeEditor">取消</button>
<button class="edit-button primary" @click="saveNodeEditor">保存</button>
</div>
</section>
<div
v-if="tooltip.visible"
class="text-tooltip"
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
>
{{ tooltip.text }}
</div>
<div ref="scrollContainer" class="topology-scroll">
<svg
ref="svg"
class="topology-svg"
:class="{ 'drag-enabled': dragEnabled }"
role="img"
aria-label="Vue2 D3 拓扑流程图"
></svg>
</div>
<nav v-if="timelineMonths.length" class="month-timeline" aria-label="月份时间轴">
<button
v-for="(month, index) in timelineMonths"
:key="month"
class="month-node"
:class="{ active: month === selectedMonth }"
type="button"
@click="$emit('change-month', month)"
>
<span class="month-dot"></span>
<span class="month-label">{{ formatMonthLabel(month) }}</span>
<span class="month-sub">{{ month === selectedMonth ? "播放中" : month }}</span>
</button>
</nav>
</main>
</template>
<script>
import * as d3 from "d3";
import * as XLSX from "xlsx";
export default {
name: "TopologyChart",
props: {
treeData: {
type: Object,
required: true,
},
storageKey: {
type: String,
default: "vue2-d3-topology-data",
},
metricsStorageKey: {
type: String,
default: "vue2-d3-topology-metrics",
},
metricsData: {
type: Object,
default() {
return {};
},
},
timelineMonths: {
type: Array,
default() {
return [];
},
},
selectedMonth: {
type: String,
default: "",
},
enableHoverHighlight: {
type: Boolean,
default: true,
},
enableNodeEdit: {
type: Boolean,
default: true,
},
autoPlayEnabled: {
type: Boolean,
default: false,
},
},
data() {
return {
nodeWidth: 118,
nodeHeight: 87,
zoom: null,
currentTransform: null,
zoomPercent: 100,
zoomInput: "100%",
minZoom: 0.55,
maxZoom: 1.8,
dragEnabled: false,
isFullscreen: false,
isLegacyIe: false,
resizeTimer: null,
chartData: null,
metricsMap: {},
collapsedNodeIds: [],
editingNode: null,
editForm: {
name: "",
count: "",
ratio: "",
link: "",
type: "green",
},
tooltip: {
visible: false,
text: "",
x: 0,
y: 0,
},
};
},
computed: {
minZoomPercent() {
return Math.round(this.minZoom * 100);
},
maxZoomPercent() {
return Math.round(this.maxZoom * 100);
},
},
mounted() {
this.isLegacyIe = this.detectLegacyIe();
this.chartData = this.loadStoredTree();
this.collapsedNodeIds = this.loadCollapsedNodeIds();
this.prepareChartData();
this.renderChart();
window.addEventListener("resize", this.handleResize);
document.addEventListener("fullscreenchange", this.handleFullscreenChange);
},
beforeDestroy() {
window.removeEventListener("resize", this.handleResize);
document.removeEventListener("fullscreenchange", this.handleFullscreenChange);
window.clearTimeout(this.resizeTimer);
this.destroyChart();
},
watch: {
treeData: {
deep: true,
handler() {
if (!this.chartData) {
this.chartData = this.cloneTree(this.treeData);
this.prepareChartData();
}
this.renderChart();
},
},
metricsData: {
deep: true,
handler() {
if (this.chartData) {
this.prepareChartData();
this.renderChart();
}
},
},
},
methods: {
destroyChart() {
if (!this.$refs.svg) {
return;
}
let svg = d3.select(this.$refs.svg);
svg.on(".zoom", null);
svg.interrupt();
svg.selectAll("*").interrupt();
svg.selectAll("*").remove();
this.zoom = null;
this.currentTransform = null;
this.editingNode = null;
this.hideTooltip();
},
isTextOverflow(element) {
return element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1;
},
detectLegacyIe() {
let userAgent = window.navigator && window.navigator.userAgent ? window.navigator.userAgent : "";
return /MSIE|Trident/.test(userAgent);
},
showTooltip(event, text) {
this.tooltip = {
visible: true,
text: text,
x: event.clientX + 12,
y: event.clientY + 12,
};
},
moveTooltip(event) {
if (!this.tooltip.visible) {
return;
}
this.tooltip.x = event.clientX + 12;
this.tooltip.y = event.clientY + 12;
},
hideTooltip() {
this.tooltip.visible = false;
},
formatMonthLabel(month) {
let parts = String(month || "").split("-");
if (parts.length >= 2) {
return Number(parts[1]) + "月";
}
return month;
},
bindOverflowTooltips() {
if (!this.$refs.svg) {
return;
}
let targets = this.$refs.svg.querySelectorAll(".node-name-text, .node-meta");
for (let index = 0; index < targets.length; index += 1) {
let element = targets[index];
let text = (element.textContent || "").trim();
if (!text) {
continue;
}
element.addEventListener("mouseenter", (event) => {
if (this.isTextOverflow(element)) {
this.showTooltip(event, text);
}
});
element.addEventListener("mousemove", (event) => {
this.moveTooltip(event);
});
element.addEventListener("mouseleave", () => {
this.hideTooltip();
});
}
},
cloneTree(data) {
return JSON.parse(JSON.stringify(data));
},
loadStoredTree() {
try {
let stored = window.localStorage.getItem(this.storageKey);
return stored ? JSON.parse(stored) : this.cloneTree(this.treeData);
} catch (error) {
return this.cloneTree(this.treeData);
}
},
saveLocalTree() {
try {
window.localStorage.setItem(this.storageKey, JSON.stringify(this.chartData));
} catch (error) {
console.warn("topology local save failed", error);
}
},
getCollapsedStorageKey() {
return this.storageKey + "-collapsed";
},
loadCollapsedNodeIds() {
try {
let stored = window.localStorage.getItem(this.getCollapsedStorageKey());
let parsed = stored ? JSON.parse(stored) : [];
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
},
saveCollapsedNodeIds() {
try {
window.localStorage.setItem(this.getCollapsedStorageKey(), JSON.stringify(this.collapsedNodeIds));
} catch (error) {
console.warn("topology collapse save failed", error);
}
},
loadStoredMetrics() {
try {
let stored = window.localStorage.getItem(this.metricsStorageKey);
let parsed = stored ? JSON.parse(stored) : null;
if (!parsed) {
return null;
}
return parsed;
} catch (error) {
return null;
}
},
saveLocalMetrics() {
try {
window.localStorage.setItem(this.metricsStorageKey, JSON.stringify(this.metricsMap));
} catch (error) {
console.warn("topology metrics save failed", error);
}
},
triggerImportExcel() {
if (this.$refs.excelInput) {
this.$refs.excelInput.value = "";
this.$refs.excelInput.click();
}
},
exportTopologyExcel() {
this.prepareChartData();
let tables = this.createTopologyTables();
let workbook = this.createTopologyWorkbook(tables);
let content = XLSX.write(workbook, {
bookType: "xlsx",
type: "array",
});
let blob = new Blob([content], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
this.downloadBlob(blob, "topology-template-" + Date.now() + ".xlsx");
},
createTopologyTables() {
let nodes = [];
let lines = [];
let positionMap = this.createTopologyPositionMap();
let walk = (node, parent, depth = 0) => {
let metrics = this.metricsMap[node.id] || {};
let count = metrics.count === "" || metrics.count == null ? node.count : metrics.count;
let ratio = metrics.ratio || node.ratio || "";
let position = positionMap[node.id] || {};
nodes.push({
id: node.id,
name: node.name || "",
type: node.type || "green",
count: count == null ? "" : count,
ratio: ratio,
x: this.formatOptionalNumber(position.x),
y: this.formatOptionalNumber(position.y),
depth: depth,
});
if (parent) {
lines.push({
fromId: parent.id,
toId: node.id,
label: node.link || "",
route: node.linkRoute || "",
points: this.formatRoutePoints(node.linkPoints),
});
}
(node.children || []).forEach((child) => {
walk(child, node, depth + 1);
});
};
walk(this.chartData || this.treeData, null);
return {
nodes: nodes,
lines: lines,
};
},
createTopologyPositionMap() {
let root = d3.hierarchy(this.chartData || this.treeData);
this.layoutAlignedTree(root);
let map = {};
root.each((node) => {
if (node.data && node.data.id) {
map[node.data.id] = {
x: node.y,
y: node.x,
};
}
});
return map;
},
createTopologyWorkbook(tables) {
let workbook = XLSX.utils.book_new();
let nodeRows = [
["节点ID", "节点名称", "节点类型", "当前数量", "占比", "X坐标", "Y坐标"],
].concat(tables.nodes.map((node) => {
return [
node.id,
node.name,
node.type,
node.count,
node.ratio,
node.x,
node.y,
];
}));
let lineRows = [
["起点ID", "终点ID", "线段文字", "连线方式", "折点"],
].concat(tables.lines.map((line) => {
return [
line.fromId,
line.toId,
line.label,
line.route,
line.points,
];
}));
let nodeSheet = XLSX.utils.aoa_to_sheet(nodeRows);
let lineSheet = XLSX.utils.aoa_to_sheet(lineRows);
let matrixSheet = XLSX.utils.aoa_to_sheet(this.createLevelMatrixRows(tables.nodes));
let guideSheet = XLSX.utils.aoa_to_sheet([
["字段", "说明"],
["层级矩阵", "列是节点名称,行是层级,单元格格式为:当前数量 / 占比,例如 120 / 35%。"],
["节点表.X坐标", "节点中心点的水平坐标,单位像素。留空时使用自动布局。"],
["节点表.Y坐标", "节点中心点的垂直坐标,单位像素。留空时使用自动布局。"],
["线段表.连线方式", "可填:直线 / straight。留空时使用自动折线;填了折点时优先按折点走线。"],
["线段表.折点", "格式:x,y;x,y,例如 380,120;520,120。坐标单位像素。"],
]);
nodeSheet["!cols"] = [
{ wch: 14 },
{ wch: 28 },
{ wch: 12 },
{ wch: 12 },
{ wch: 10 },
{ wch: 10 },
{ wch: 10 },
];
lineSheet["!cols"] = [
{ wch: 14 },
{ wch: 14 },
{ wch: 28 },
{ wch: 12 },
{ wch: 38 },
];
matrixSheet["!cols"] = [{ wch: 12 }].concat(tables.nodes.map(() => {
return { wch: 18 };
}));
XLSX.utils.book_append_sheet(workbook, nodeSheet, "节点表");
XLSX.utils.book_append_sheet(workbook, lineSheet, "线段表");
XLSX.utils.book_append_sheet(workbook, matrixSheet, "层级矩阵");
XLSX.utils.book_append_sheet(workbook, guideSheet, "说明");
return workbook;
},
createLevelMatrixRows(nodes) {
let maxDepth = nodes.reduce((max, node) => {
return Math.max(max, node.depth || 0);
}, 0);
let rows = [
["层级\\名称"].concat(nodes.map((node) => {
return node.name;
})),
];
for (let depth = 0; depth <= maxDepth; depth += 1) {
rows.push(["第" + depth + "层"].concat(nodes.map((node) => {
if (node.depth !== depth) {
return "";
}
return (node.count == null ? "" : node.count) + " / " + (node.ratio || "");
})));
}
return rows;
},
formatOptionalNumber(value) {
return value == null || value === "" ? "" : Number(value);
},
formatRoutePoints(points) {
if (!Array.isArray(points) || !points.length) {
return "";
}
return points.map((point) => {
return point.x + "," + point.y;
}).join(";");
},
createExcelHtml(tables) {
let nodeRows = tables.nodes
.map((node) => {
return [
node.id,
node.name,
node.type,
node.count,
node.ratio,
];
});
let lineRows = tables.lines
.map((line) => {
return [
line.fromId,
line.toId,
line.label,
];
});
return `
<html>
<head>
<meta charset="utf-8" />
<style>
body { font-family: "Microsoft YaHei", Arial, sans-serif; color: #22312e; }
h2 { margin: 18px 0 8px; font-size: 16px; }
p { color: #5d6863; font-size: 12px; }
table { border-collapse: collapse; margin-bottom: 20px; }
th { background: #e9f3ef; color: #1e4f45; font-weight: 700; }
th, td { border: 1px solid #bfc9c3; padding: 6px 8px; mso-number-format: "\\@"; }
</style>
</head>
<body>
<h2>节点表</h2>
<p>节点ID必须唯一;类型可填 root、blue、green、red、orange。</p>
${this.createHtmlTable("topology-nodes", ["节点ID", "节点名称", "节点类型", "当前数量", "占比"], nodeRows)}
<h2>线段表</h2>
<p>起点ID和终点ID对应节点表里的节点ID;线段文字为空则不显示。</p>
${this.createHtmlTable("topology-lines", ["起点ID", "终点ID", "线段文字"], lineRows)}
</body>
</html>
`;
},
createHtmlTable(id, headers, rows) {
let headerHtml = headers
.map((header) => {
return "<th>" + this.escapeHtml(header) + "</th>";
})
.join("");
let rowsHtml = rows
.map((row) => {
return "<tr>" + row.map((cell) => {
return "<td>" + this.escapeHtml(cell) + "</td>";
}).join("") + "</tr>";
})
.join("");
return "<table id=\"" + id + "\"><thead><tr>" + headerHtml + "</tr></thead><tbody>" + rowsHtml + "</tbody></table>";
},
escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
},
importTopologyExcel(event) {
let file = event.target.files && event.target.files[0];
if (!file) {
return;
}
let reader = new FileReader();
reader.onload = () => {
try {
let imported = this.parseTopologyExcelFile(reader.result);
this.chartData = imported.tree;
this.collapsedNodeIds = [];
this.assignPositionIds(this.chartData);
this.metricsMap = this.createMetricsMap(this.chartData);
this.saveLocalTree();
this.saveLocalMetrics();
this.saveCollapsedNodeIds();
this.renderChart();
this.resetView();
} catch (error) {
console.warn("topology excel import failed", error);
window.alert(error.message || "导入失败,请检查 Excel 模板格式");
}
};
reader.onerror = () => {
window.alert("读取文件失败");
};
reader.readAsArrayBuffer(file);
},
parseTopologyExcelFile(buffer) {
try {
let workbook = XLSX.read(buffer, { type: "array" });
return this.parseTopologyWorkbook(workbook);
} catch (error) {
let text = this.decodeArrayBuffer(buffer);
return this.parseTopologyExcelText(text);
}
},
decodeArrayBuffer(buffer) {
if (window.TextDecoder) {
return new TextDecoder("utf-8").decode(buffer);
}
let bytes = new Uint8Array(buffer);
let encoded = "";
for (let index = 0; index < bytes.length; index += 1) {
encoded += "%" + ("0" + bytes[index].toString(16)).slice(-2);
}
try {
return decodeURIComponent(encoded);
} catch (error) {
let text = "";
for (let index = 0; index < bytes.length; index += 1) {
text += String.fromCharCode(bytes[index]);
}
return text;
}
},
parseTopologyWorkbook(workbook) {
let nodeSheetName = workbook.SheetNames.find((name) => {
return name.indexOf("节点") >= 0;
}) || workbook.SheetNames[0];
let lineSheetName = workbook.SheetNames.find((name) => {
return name.indexOf("线段") >= 0 || name.indexOf("连线") >= 0;
}) || workbook.SheetNames[1];
let nodeRows = this.readWorkbookSheetRows(workbook.Sheets[nodeSheetName], "节点ID");
let lineRows = this.readWorkbookSheetRows(workbook.Sheets[lineSheetName], "起点ID");
let nodes = nodeRows.map((row) => {
return {
id: row[0],
name: row[1],
type: row[2],
count: row[3],
ratio: row[4],
x: row[5],
y: row[6],
};
}).filter((row) => {
return row.id;
});
let lines = lineRows.map((row) => {
return {
fromId: row[0],
toId: row[1],
label: row[2],
route: row[3],
points: row[4],
};
}).filter((row) => {
return row.fromId && row.toId;
});
this.applyLevelMatrixValues(nodes, lines, workbook);
return this.buildTreeFromTables(nodes, lines);
},
readWorkbookSheetRows(sheet, headerName) {
if (!sheet) {
return [];
}
let rows = XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: "",
raw: false,
});
let headerIndex = rows.findIndex((row) => {
return String(row[0] || "").trim() === headerName;
});
if (headerIndex < 0) {
headerIndex = 0;
}
return rows.slice(headerIndex + 1).map((row) => {
return row.map((cell) => {
return String(cell == null ? "" : cell).trim();
});
}).filter((row) => {
return row.some((cell) => {
return cell;
});
});
},
applyLevelMatrixValues(nodes, lines, workbook) {
let matrixSheetName = workbook.SheetNames.find((name) => {
return name.indexOf("层级") >= 0 || name.indexOf("矩阵") >= 0;
});
if (!matrixSheetName) {
return;
}
let rows = XLSX.utils.sheet_to_json(workbook.Sheets[matrixSheetName], {
header: 1,
defval: "",
raw: false,
});
if (rows.length < 2) {
return;
}
let depthMap = this.createImportDepthMap(nodes, lines);
let names = rows[0].slice(1).map((name) => {
return String(name || "").trim();
});
rows.slice(1).forEach((row) => {
let depth = this.parseLevelValue(row[0]);
if (depth == null) {
return;
}
names.forEach((name, index) => {
if (!name) {
return;
}
let metrics = this.parseMetricCell(row[index + 1]);
if (!metrics) {
return;
}
nodes.forEach((node) => {
if (String(node.name || "").trim() !== name || depthMap[node.id] !== depth) {
return;
}
if (metrics.count !== "") {
node.count = metrics.count;
}
if (metrics.ratio !== "") {
node.ratio = metrics.ratio;
}
});
});
});
},
createImportDepthMap(nodes, lines) {
let nodeIds = new Set(nodes.map((node) => {
return String(node.id || "").trim();
}));
let childIds = new Set(lines.map((line) => {
return String(line.toId || "").trim();
}));
let rootId = nodeIds.has("0") ? "0" : Array.from(nodeIds).find((id) => {
return !childIds.has(id);
});
let childrenMap = {};
lines.forEach((line) => {
let fromId = String(line.fromId || "").trim();
let toId = String(line.toId || "").trim();
if (!childrenMap[fromId]) {
childrenMap[fromId] = [];
}
childrenMap[fromId].push(toId);
});
let depthMap = {};
let visit = (id, depth) => {
depthMap[id] = depth;
(childrenMap[id] || []).forEach((childId) => {
visit(childId, depth + 1);
});
};
if (rootId) {
visit(rootId, 0);
}
return depthMap;
},
parseLevelValue(value) {
let match = String(value == null ? "" : value).match(/\d+/);
return match ? Number(match[0]) : null;
},
parseMetricCell(value) {
let text = String(value == null ? "" : value).trim();
if (!text) {
return null;
}
let parts = text.split(/[\/,,|]/).map((part) => {
return part.trim();
}).filter(Boolean);
let count = parts[0] || "";
let ratio = parts[1] || "";
if (!ratio) {
let ratioMatch = text.match(/\d+(?:\.\d+)?%/);
ratio = ratioMatch ? ratioMatch[0] : "";
}
count = count.replace(/[^\d.-]/g, "");
return {
count: count === "" || Number.isNaN(Number(count)) ? count : Number(count),
ratio: ratio,
};
},
parseTopologyExcelText(text) {
if (text.slice(0, 2) === "PK") {
throw new Error("无法解析这个 Excel 文件,请使用本页面导出的模板编辑后再导入。");
}
let parsedTables = this.parseHtmlTopologyTables(text);
if (!parsedTables.nodes.length) {
parsedTables = this.parseTextTopologyTables(text);
}
if (!parsedTables.nodes.length) {
throw new Error("没有找到节点表数据");
}
return this.buildTreeFromTables(parsedTables.nodes, parsedTables.lines);
},
parseHtmlTopologyTables(text) {
let doc = new DOMParser().parseFromString(text, "text/html");
let tables = Array.from(doc.querySelectorAll("table"));
let nodeTable = doc.querySelector("#topology-nodes") || tables[0];
let lineTable = doc.querySelector("#topology-lines") || tables[1];
return {
nodes: this.readTableRows(nodeTable).map((row) => {
return {
id: row[0],
name: row[1],
type: row[2],
count: row[3],
ratio: row[4],
x: row[5],
y: row[6],
};
}).filter((row) => {
return row.id;
}),
lines: this.readTableRows(lineTable).map((row) => {
return {
fromId: row[0],
toId: row[1],
label: row[2],
route: row[3],
points: row[4],
};
}).filter((row) => {
return row.fromId && row.toId;
}),
};
},
readTableRows(table) {
if (!table) {
return [];
}
return Array.from(table.querySelectorAll("tr")).slice(1).map((tr) => {
return Array.from(tr.children).map((cell) => {
return (cell.textContent || "").trim();
});
});
},
parseTextTopologyTables(text) {
let lines = text.replace(/\r/g, "").split("\n");
let section = "";
let nodes = [];
let edges = [];
lines.forEach((line) => {
let trimmed = line.trim();
if (!trimmed) {
return;
}
if (trimmed.indexOf("节点表") >= 0) {
section = "nodes";
return;
}
if (trimmed.indexOf("线段表") >= 0) {
section = "lines";
return;
}
let columns = line.split(/\t|,/).map((item) => {
return item.trim();
});
if (columns[0] === "节点ID" || columns[0] === "起点ID") {
return;
}
if (section === "nodes" && columns[0]) {
nodes.push({
id: columns[0],
name: columns[1],
type: columns[2],
count: columns[3],
ratio: columns[4],
x: columns[5],
y: columns[6],
});
}
if (section === "lines" && columns[0] && columns[1]) {
edges.push({
fromId: columns[0],
toId: columns[1],
label: columns[2],
route: columns[3],
points: columns[4],
});
}
});
return {
nodes: nodes,
lines: edges,
};
},
parseOptionalNumber(value) {
let text = String(value == null ? "" : value).trim();
if (!text) {
return null;
}
let number = Number(text);
return Number.isFinite(number) ? number : null;
},
parseRoutePoints(value) {
let text = String(value == null ? "" : value).trim();
if (!text) {
return [];
}
return text.split(";").map((part) => {
let values = part.split(",").map((item) => {
return Number(item.trim());
});
if (values.length < 2 || !Number.isFinite(values[0]) || !Number.isFinite(values[1])) {
return null;
}
return {
x: values[0],
y: values[1],
};
}).filter(Boolean);
},
buildTreeFromTables(nodeRows, lineRows) {
let validTypes = new Set(["root", "blue", "green", "red", "orange"]);
let nodeMap = {};
nodeRows.forEach((row) => {
let id = String(row.id || "").trim();
if (!id) {
return;
}
if (nodeMap[id]) {
throw new Error("节点ID重复:" + id);
}
let type = String(row.type || "green").trim();
let count = String(row.count == null ? "" : row.count).trim();
let ratio = String(row.ratio == null ? "" : row.ratio).trim();
let manualX = this.parseOptionalNumber(row.x);
let manualY = this.parseOptionalNumber(row.y);
nodeMap[id] = {
id: id,
name: String(row.name || "").trim() || "未命名节点",
type: validTypes.has(type) ? type : "green",
count: count === "" || Number.isNaN(Number(count)) ? count : Number(count),
ratio: ratio,
children: [],
};
if (manualX != null && manualY != null) {
nodeMap[id].manualX = manualX;
nodeMap[id].manualY = manualY;
}
});
let childIds = new Set();
lineRows.forEach((line) => {
let fromId = String(line.fromId || "").trim();
let toId = String(line.toId || "").trim();
if (!fromId || !toId) {
return;
}
if (!nodeMap[fromId] || !nodeMap[toId]) {
throw new Error("线段引用了不存在的节点:" + fromId + " -> " + toId);
}
if (fromId === toId) {
throw new Error("线段起点和终点不能相同:" + fromId);
}
if (childIds.has(toId)) {
throw new Error("一个节点只能有一个父节点,重复终点:" + toId);
}
childIds.add(toId);
let child = nodeMap[toId];
let label = String(line.label || "").trim();
if (label) {
child.link = label;
} else {
delete child.link;
}
let route = String(line.route || "").trim();
let points = this.parseRoutePoints(line.points);
if (route) {
child.linkRoute = route;
} else {
delete child.linkRoute;
}
if (points.length) {
child.linkPoints = points;
} else {
delete child.linkPoints;
}
nodeMap[fromId].children.push(child);
});
let rootId = nodeMap["0"] ? "0" : Object.keys(nodeMap).find((id) => {
return !childIds.has(id);
});
if (!rootId) {
throw new Error("没有找到根节点,请保留一个未作为终点的节点");
}
Object.keys(nodeMap).forEach((id) => {
if (id !== rootId && !childIds.has(id)) {
nodeMap[rootId].children.push(nodeMap[id]);
}
});
this.removeImportIds(nodeMap[rootId]);
return {
tree: nodeMap[rootId],
};
},
removeImportIds(node) {
delete node.id;
(node.children || []).forEach((child) => {
this.removeImportIds(child);
});
},
prepareChartData() {
this.assignPositionIds(this.chartData);
this.pruneCollapsedNodeIds();
let generatedMetrics = this.createMetricsMap(this.chartData);
let providedMetrics = this.cloneTree(this.metricsData || {});
let storedMetrics = this.loadStoredMetrics();
this.metricsMap = Object.assign({}, generatedMetrics, providedMetrics, storedMetrics || {});
},
assignPositionIds(node, path = "0") {
this.$set(node, "id", path);
if (node.children && node.children.length) {
node.children.forEach((child, index) => {
this.assignPositionIds(child, path + "-" + index);
});
}
},
createMetricsMap(node, map = {}) {
map[node.id] = {
count: node.count == null ? "" : node.count,
ratio: node.ratio || "",
};
if (node.children && node.children.length) {
node.children.forEach((child) => {
this.createMetricsMap(child, map);
});
}
return map;
},
collectNodeIds(node, ids = new Set()) {
if (!node) {
return ids;
}
ids.add(node.id);
(node.children || []).forEach((child) => {
this.collectNodeIds(child, ids);
});
return ids;
},
pruneCollapsedNodeIds() {
let ids = this.collectNodeIds(this.chartData);
let validCollapsedIds = this.collapsedNodeIds.filter((id) => {
return ids.has(id);
});
if (validCollapsedIds.length !== this.collapsedNodeIds.length) {
this.collapsedNodeIds = validCollapsedIds;
this.saveCollapsedNodeIds();
}
},
hasChildNodes(nodeData) {
return !!(nodeData && nodeData.children && nodeData.children.length);
},
isNodeCollapsed(nodeData) {
return !!(nodeData && this.collapsedNodeIds.indexOf(nodeData.id) >= 0);
},
getVisibleChildren(nodeData) {
if (!this.hasChildNodes(nodeData) || this.isNodeCollapsed(nodeData)) {
return null;
}
return nodeData.children;
},
toggleNodeCollapse(event, node) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
if (!node || !this.hasChildNodes(node.data)) {
return;
}
let nodeId = node.data.id;
let collapsedIndex = this.collapsedNodeIds.indexOf(nodeId);
if (collapsedIndex >= 0) {
this.collapsedNodeIds = this.collapsedNodeIds.filter((id) => {
return id !== nodeId;
});
} else {
this.collapsedNodeIds = this.collapsedNodeIds.concat(nodeId);
}
this.saveCollapsedNodeIds();
this.hideTooltip();
this.renderChart({ preserveView: true });
},
expandAllNodes() {
if (!this.collapsedNodeIds.length) {
return;
}
this.collapsedNodeIds = [];
this.saveCollapsedNodeIds();
this.hideTooltip();
this.renderChart({ preserveView: true });
},
collapseToRootNodes() {
let nextCollapsedIds = [];
let walk = (node, isRoot = false) => {
if (!node) {
return;
}
if (!isRoot && this.hasChildNodes(node)) {
nextCollapsedIds.push(node.id);
}
(node.children || []).forEach((child) => {
walk(child, false);
});
};
walk(this.chartData, true);
this.collapsedNodeIds = nextCollapsedIds;
this.saveCollapsedNodeIds();
this.hideTooltip();
this.renderChart({ preserveView: true });
},
getNodeMetrics(nodeData, index, depth) {
let metrics = this.metricsMap[nodeData.id] || {};
let count = metrics.count === "" || metrics.count == null ? nodeData.count : metrics.count;
let ratio = metrics.ratio || nodeData.ratio;
return {
count: count == null || count === "" ? 28 + index * 6 : count,
ratio: ratio || Math.max(8, 76 - depth * 11 - index).toFixed(0) + "%",
};
},
openNodeEditor(node) {
let metrics = this.getNodeMetrics(node.data, 0, node.depth);
this.editingNode = node.data;
this.editForm = {
name: node.data.name || "",
count: metrics.count,
ratio: metrics.ratio,
link: node.data.link || "",
type: node.data.type || "green",
};
},
closeEditor() {
this.editingNode = null;
},
saveNodeEditor() {
if (!this.editingNode) {
return;
}
this.editingNode.name = this.editForm.name.trim() || "未命名节点";
this.editingNode.type = this.editForm.type;
this.$set(this.metricsMap, this.editingNode.id, {
count: this.editForm.count === "" ? "" : Number(this.editForm.count),
ratio: this.editForm.ratio.trim(),
});
if (this.editForm.link.trim()) {
this.$set(this.editingNode, "link", this.editForm.link.trim());
} else {
this.$delete(this.editingNode, "link");
}
this.saveLocalTree();
this.saveLocalMetrics();
this.closeEditor();
this.renderChart();
},
handleResize() {
window.clearTimeout(this.resizeTimer);
this.resizeTimer = window.setTimeout(() => {
this.renderChart();
}, 120);
},
toggleFullscreen() {
let page = this.$refs.page;
if (!page) {
return;
}
if (document.fullscreenElement) {
document.exitFullscreen();
return;
}
if (page.requestFullscreen) {
page.requestFullscreen();
}
},
handleFullscreenChange() {
this.isFullscreen = document.fullscreenElement === this.$refs.page;
this.$nextTick(() => {
this.handleResize();
});
},
changeZoom(delta) {
if (!this.zoom || !this.currentTransform) {
return;
}
let nextScale = Math.max(this.minZoom, Math.min(this.maxZoom, this.currentTransform.k + delta));
this.setZoomScale(nextScale);
},
applyZoomInput() {
let value = Number(String(this.zoomInput).replace("%", "").trim());
if (!Number.isFinite(value)) {
this.zoomInput = this.zoomPercent + "%";
return;
}
let nextScale = Math.max(this.minZoom, Math.min(this.maxZoom, value / 100));
this.setZoomScale(nextScale);
},
resetView() {
if (!this.zoom || !this.$refs.svg) {
return;
}
let svgNode = this.$refs.svg;
d3.select(svgNode).transition().duration(160).call(this.zoom.transform, d3.zoomIdentity);
let scrollContainer = this.$refs.scrollContainer;
if (scrollContainer) {
scrollContainer.scrollTo({
left: 0,
top: 0,
behavior: "smooth",
});
}
},
setZoomScale(nextScale) {
if (!this.zoom || !this.currentTransform) {
this.zoomInput = this.zoomPercent + "%";
return;
}
let svgNode = this.$refs.svg;
let viewport = this.getZoomViewportCenter();
let nextTransform = d3.zoomIdentity
.translate(this.currentTransform.x, this.currentTransform.y)
.scale(this.currentTransform.k);
nextTransform = nextTransform
.translate(viewport.x, viewport.y)
.scale(nextScale / this.currentTransform.k)
.translate(-viewport.x, -viewport.y);
d3.select(svgNode).transition().duration(160).call(this.zoom.transform, nextTransform);
},
getZoomViewportCenter() {
let scrollContainer = this.$refs.scrollContainer;
if (!scrollContainer) {
let svgNode = this.$refs.svg;
let rect = svgNode.getBoundingClientRect();
return {
x: rect.width / 2,
y: rect.height / 2,
};
}
return {
x: scrollContainer.scrollLeft + scrollContainer.clientWidth / 2,
y: scrollContainer.scrollTop + scrollContainer.clientHeight / 2,
};
},
exportAsPng() {
let svg = this.$refs.svg;
if (!svg) {
return;
}
let exportConfig = this.getExportConfig(svg);
let size = {
width: exportConfig.width,
height: exportConfig.height,
};
let exportSvg = this.createExportSvg(svg, exportConfig);
let svgData = new XMLSerializer().serializeToString(exportSvg);
let svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
let objectUrlApi = this.getObjectUrlApi();
let url = objectUrlApi
? objectUrlApi.createObjectURL(svgBlob)
: "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgData);
let img = new Image();
img.onload = () => {
if (objectUrlApi) {
objectUrlApi.revokeObjectURL(url);
}
let canvas = document.createElement("canvas");
let scale = 2;
canvas.width = size.width * scale;
canvas.height = size.height * scale;
let ctx = canvas.getContext("2d");
ctx.fillStyle = "#fbfcf8";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(scale, scale);
try {
ctx.drawImage(img, 0, 0, size.width, size.height);
if (window.navigator.msSaveOrOpenBlob && canvas.msToBlob) {
this.downloadBlob(canvas.msToBlob(), "topology-" + Date.now() + ".png");
return;
}
if (canvas.toBlob) {
canvas.toBlob((blob) => {
if (!blob) {
this.exportAsDataUrl(img, size, svgData);
return;
}
this.downloadBlob(blob, "topology-" + Date.now() + ".png");
}, "image/png");
return;
}
this.exportAsDataUrl(img, size, svgData);
} catch (e) {
console.warn("Canvas export failed, trying canvas fallback", e);
this.exportCanvasFallbackPng();
}
};
img.onerror = () => {
if (objectUrlApi) {
objectUrlApi.revokeObjectURL(url);
}
this.exportCanvasFallbackPng();
};
img.src = url;
},
exportAsDataUrl(img, size, fallbackSvgData) {
let canvas = document.createElement("canvas");
let scale = 2;
canvas.width = size.width * scale;
canvas.height = size.height * scale;
let ctx = canvas.getContext("2d");
ctx.fillStyle = "#fbfcf8";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0, size.width, size.height);
try {
if (window.navigator.msSaveOrOpenBlob && canvas.msToBlob) {
this.downloadBlob(canvas.msToBlob(), "topology-" + Date.now() + ".png");
return;
}
let dataUrl = canvas.toDataURL("image/png");
this.downloadUrl(dataUrl, "topology-" + Date.now() + ".png");
} catch (error) {
console.warn("PNG data URL export failed", error);
this.handlePngExportFailed();
}
},
handlePngExportFailed() {
window.alert("当前浏览器不支持将该 SVG 画布导出为 PNG,请使用极速模式/Chrome,或改用导出Excel。");
},
exportCanvasFallbackPng() {
try {
let root = d3.hierarchy(this.chartData || this.treeData, (nodeData) => {
return this.getVisibleChildren(nodeData);
});
this.prepareNodeKeys(root);
this.layoutAlignedTree(root);
let nodes = root.descendants();
let links = root.links();
let padding = 48;
let nodeWidth = this.nodeWidth;
let nodeHeight = this.nodeHeight;
let minX = d3.min(nodes, (d) => {
return d.x;
});
let maxX = d3.max(nodes, (d) => {
return d.x;
});
let minY = d3.min(nodes, (d) => {
return d.y;
});
let maxY = d3.max(nodes, (d) => {
return d.y;
});
let width = Math.ceil(maxY - minY + nodeWidth + padding * 2);
let height = Math.ceil(maxX - minX + nodeHeight + padding * 2);
let scale = 2;
let canvas = document.createElement("canvas");
canvas.width = width * scale;
canvas.height = height * scale;
let ctx = canvas.getContext("2d");
ctx.scale(scale, scale);
ctx.fillStyle = "#fbfcf8";
ctx.fillRect(0, 0, width, height);
ctx.translate(padding - minY + nodeWidth / 2, padding - minX + nodeHeight / 2);
links.forEach((link) => {
this.drawCanvasLink(ctx, link);
});
links.forEach((link) => {
this.drawCanvasLinkLabel(ctx, link);
});
nodes.forEach((node, index) => {
this.drawCanvasNode(ctx, node, index);
});
if (window.navigator.msSaveOrOpenBlob && canvas.msToBlob) {
this.downloadBlob(canvas.msToBlob(), "topology-" + Date.now() + ".png");
return;
}
if (canvas.toBlob) {
canvas.toBlob((blob) => {
if (!blob) {
this.downloadUrl(canvas.toDataURL("image/png"), "topology-" + Date.now() + ".png");
return;
}
this.downloadBlob(blob, "topology-" + Date.now() + ".png");
}, "image/png");
return;
}
this.downloadUrl(canvas.toDataURL("image/png"), "topology-" + Date.now() + ".png");
} catch (error) {
console.warn("Canvas fallback export failed", error);
this.handlePngExportFailed();
}
},
drawCanvasLink(ctx, link) {
let nodeWidth = this.nodeWidth;
let sx = link.source.y + nodeWidth / 2;
let sy = link.source.x;
let tx = link.target.y - nodeWidth / 2;
let ty = link.target.x;
let midX = sx + (tx - sx) / 2;
ctx.save();
ctx.strokeStyle = "#c6ccc7";
ctx.lineWidth = 1.35;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(midX, sy);
ctx.lineTo(midX, ty);
ctx.lineTo(tx, ty);
ctx.stroke();
ctx.fillStyle = "#c6ccc7";
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx - 10, ty - 5);
ctx.lineTo(tx - 10, ty + 5);
ctx.closePath();
ctx.fill();
ctx.restore();
},
drawCanvasLinkLabel(ctx, link) {
if (!link.target.data.link) {
return;
}
let sx = link.source.y + this.nodeWidth / 2;
let tx = link.target.y - this.nodeWidth / 2;
let labelX = sx + (tx - sx) / 2;
ctx.save();
ctx.fillStyle = "#5d8ab8";
ctx.font = "600 16px Microsoft YaHei, Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(link.target.data.link, labelX, link.target.x);
ctx.restore();
},
drawCanvasNode(ctx, node, index) {
let nodeWidth = this.nodeWidth;
let nodeHeight = this.nodeHeight;
let colors = this.getNodeTypeColors(node.data.type);
let x = node.y - nodeWidth / 2;
let y = node.x - nodeHeight / 2;
let metrics = this.getNodeMetrics(node.data, index, node.depth);
ctx.save();
ctx.fillStyle = colors.fill;
ctx.strokeStyle = colors.stroke;
ctx.lineWidth = 1.7;
this.drawRoundRect(ctx, x, y, nodeWidth, nodeHeight, 4);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y + 50);
ctx.lineTo(x + nodeWidth, y + 50);
ctx.stroke();
ctx.fillStyle = "#21312e";
ctx.font = "700 12px Microsoft YaHei, Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let lines = this.getNodeNameLines(node.data.name);
let startY = node.x - 19 - (lines.length - 1) * 7;
lines.forEach((line, lineIndex) => {
ctx.fillText(line, node.y, startY + lineIndex * 14);
});
ctx.fillStyle = "#5d6863";
ctx.font = "600 10px Microsoft YaHei, Arial";
ctx.fillText("当前 " + metrics.count, node.y, y + 65);
ctx.fillText("占比 " + metrics.ratio, node.y, y + 78);
ctx.restore();
},
drawRoundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
},
getExportConfig(svg) {
let padding = 48;
let chart = svg.querySelector(".chart");
let viewBox = svg.viewBox && svg.viewBox.baseVal;
let fallbackWidth = Math.round((viewBox && viewBox.width) || svg.clientWidth || 960);
let fallbackHeight = Math.round((viewBox && viewBox.height) || svg.clientHeight || 640);
let bounds = {
x: 0,
y: 0,
width: fallbackWidth,
height: fallbackHeight,
};
if (chart && chart.getBBox) {
try {
let box = chart.getBBox();
if (box.width > 0 && box.height > 0) {
bounds = box;
}
} catch (error) {
console.warn("Read SVG bounds failed, using viewBox size", error);
}
}
return {
x: bounds.x,
y: bounds.y,
width: Math.max(Math.ceil(bounds.width + padding * 2), 1),
height: Math.max(Math.ceil(bounds.height + padding * 2), 1),
padding: padding,
};
},
createExportSvg(svg, exportConfig) {
let clone = svg.cloneNode(true);
let width = exportConfig.width;
let height = exportConfig.height;
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xhtml", "http://www.w3.org/1999/xhtml");
clone.setAttribute("width", width);
clone.setAttribute("height", height);
clone.setAttribute("viewBox", "0 0 " + width + " " + height);
let cloneChart = clone.querySelector(".chart");
if (cloneChart) {
cloneChart.setAttribute(
"transform",
"translate(" + (exportConfig.padding - exportConfig.x) + "," + (exportConfig.padding - exportConfig.y) + ")"
);
}
let style = document.createElementNS("http://www.w3.org/2000/svg", "style");
style.textContent = this.getExportCss();
clone.insertBefore(style, clone.firstChild);
let background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
background.setAttribute("width", width);
background.setAttribute("height", height);
background.setAttribute("fill", "#fbfcf8");
clone.insertBefore(background, style.nextSibling);
return clone;
},
getExportCss() {
return `
.topology-svg { background: #fbfcf8; }
.link { fill: none; stroke: #c6ccc7; stroke-linecap: square; stroke-linejoin: round; stroke-width: 1.35; }
.link.path-active { stroke: #4d8f83; stroke-width: 2.8; stroke-linecap: round; stroke-dasharray: 9 6; }
.chart.has-hover .link:not(.path-active) { stroke: #e7ece8; opacity: 0.5; }
.chart.has-hover .link-label-node:not(.path-active) { opacity: 0.38; }
.link-label { fill: #5d8ab8; font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif; font-size: 16px; font-weight: 600; pointer-events: none; text-anchor: middle; }
.node-html { width: 118px; height: 87px; overflow: visible; }
.node-card { position: relative; width: 118px; height: 87px; border: 1.7px solid #aeb6b1; border-radius: 4px; background: #eef0ed; box-shadow: 0 1px 2px rgba(40, 56, 50, 0.12); display: flex; flex-direction: column; justify-content: stretch; overflow: hidden; box-sizing: border-box; font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif; }
.node-collapse-button { position: absolute; top: 4px; right: 4px; z-index: 1; width: 17px; height: 17px; padding: 0; border: 1px solid rgba(86, 104, 98, 0.38); border-radius: 3px; background: rgba(255, 255, 255, 0.86); color: #31423d; font-size: 12px; font-weight: 800; line-height: 15px; text-align: center; }
.node-card.has-children .node-name { padding-right: 24px; }
.node-card.is-collapsed .node-collapse-button { border-color: rgba(47, 143, 125, 0.7); background: #ffffff; color: #1e4f45; }
.node-name { height: 50px; padding: 4px 8px; color: #21312e; font-size: 12px; font-weight: 700; line-height: 14px; text-align: center; white-space: pre-line; display: flex; align-items: center; justify-content: center; overflow: hidden; box-sizing: border-box; }
.node-name-text { display: -webkit-box; max-width: 100%; max-height: 42px; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 3; text-overflow: ellipsis; white-space: pre-line; word-break: break-all; }
.node-divider { height: 1px; background: #aeb6b1; flex: none; }
.node-meta { height: 36px; padding: 4px 7px 3px; color: #5d6863; font-size: 10px; font-weight: 600; line-height: 13px; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; overflow: hidden; box-sizing: border-box; }
.node-meta-line { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node.path-active .node-card { box-shadow: 0 0 0 3px rgba(47, 143, 125, 0.16), 0 6px 14px rgba(40, 56, 50, 0.2); transform: translateY(-2px) scale(1.03); }
.chart.has-hover .node:not(.path-active) .node-card { border-color: #edf1ee; background: #ffffff; box-shadow: none; opacity: 0.42; filter: grayscale(1); }
.chart.has-hover .node:not(.path-active) .node-name, .chart.has-hover .node:not(.path-active) .node-meta { color: #b7c0ba; }
.chart.has-hover .node:not(.path-active) .node-divider { background: #edf1ee; }
.root .node-card { border-color: #aeb6b1; background: #eef0ed; }
.blue .node-card { border-color: #8c9fcf; background: #eaf0ff; }
.blue .node-divider { background: #8c9fcf; }
.green .node-card { border-color: #76a296; background: #e9f3ef; }
.green .node-divider { background: #76a296; }
.red .node-card { border-color: #c38484; background: #f8eeee; }
.red .node-divider { background: #c38484; }
.orange .node-card { border-color: #c49a72; background: #f7eee4; }
.orange .node-divider { background: #c49a72; }
.watermark { fill: rgba(69, 82, 76, 0.06); font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif; font-size: 54px; font-weight: 700; letter-spacing: 0; }
`;
},
downloadSvgFallback(svgData) {
let svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
this.downloadBlob(svgBlob, "topology-" + Date.now() + ".svg");
},
downloadBlob(blob, filename) {
if (window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveOrOpenBlob(blob, filename);
return;
}
if (window.navigator.msSaveBlob) {
window.navigator.msSaveBlob(blob, filename);
return;
}
let downloadUrl = URL.createObjectURL(blob);
let link = document.createElement("a");
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
},
getObjectUrlApi() {
return window.URL || window.webkitURL || null;
},
downloadUrl(url, filename) {
let link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
try {
link.click();
} finally {
document.body.removeChild(link);
}
},
renderChart(options = {}) {
let svg = d3.select(this.$refs.svg);
let vm = this;
let layout = this.getLayoutConfig();
let margin = layout.margin;
let nodeWidth = this.nodeWidth;
let nodeHeight = this.nodeHeight;
let previousTransform = options.preserveView && this.currentTransform ? this.currentTransform : d3.zoomIdentity;
svg.selectAll("*").remove();
let defs = svg.append("defs");
this.drawArrowMarkers(defs);
let chart = svg.append("g").attr("class", "chart");
let root = d3.hierarchy(this.chartData || this.treeData, (nodeData) => {
return this.getVisibleChildren(nodeData);
});
this.prepareNodeKeys(root);
this.layoutAlignedTree(root);
let nodes = root.descendants();
let links = root.links();
let minX = d3.min(nodes, (d) => {
return d.x;
});
let maxX = d3.max(nodes, (d) => {
return d.x;
});
let minY = d3.min(nodes, (d) => {
return d.y;
});
let maxY = d3.max(nodes, (d) => {
return d.y;
});
links.forEach((link) => {
(link.target.data.linkPoints || []).forEach((point) => {
minX = Math.min(minX, point.y);
maxX = Math.max(maxX, point.y);
minY = Math.min(minY, point.x);
maxY = Math.max(maxY, point.x);
});
});
let graphWidth = maxY - minY + nodeWidth;
let graphHeight = maxX - minX + nodeHeight;
let scale = 1;
let scrollContainer = this.$refs.scrollContainer;
let width = Math.ceil(graphWidth + margin.left + margin.right);
let height = Math.ceil(graphHeight + margin.top + margin.bottom);
if (scrollContainer) {
width = Math.max(width, scrollContainer.clientWidth);
height = Math.max(height, scrollContainer.clientHeight);
}
let offsetX = margin.left - minY * scale + nodeWidth / 2;
let offsetY = margin.top - minX * scale + nodeHeight / 2;
svg
.attr("width", width)
.attr("height", height)
.attr("viewBox", "0 0 " + width + " " + height);
chart.attr("transform", "translate(" + offsetX + "," + offsetY + ") scale(" + scale + ")");
this.drawLinks(chart, links, layout);
this.drawNodes(chart, nodes);
this.drawLinkLabels(chart, links, layout);
this.hideTooltip();
this.$nextTick(() => {
window.requestAnimationFrame(() => {
this.bindOverflowTooltips();
});
});
let zoom = d3
.zoom()
.scaleExtent([this.minZoom, this.maxZoom])
.filter(() => {
let event = d3.event;
if (!vm.dragEnabled) {
return false;
}
return event && !event.ctrlKey && !event.button;
})
.on("zoom", () => {
let transform = d3.event && d3.event.transform ? d3.event.transform : d3.zoomIdentity;
vm.currentTransform = transform;
vm.zoomPercent = Math.round(transform.k * 100);
vm.zoomInput = vm.zoomPercent + "%";
chart.attr(
"transform",
"translate(" +
(transform.x + offsetX) +
"," +
(transform.y + offsetY) +
") scale(" +
transform.k * scale +
")"
);
});
this.zoom = zoom;
this.currentTransform = previousTransform;
this.zoomPercent = Math.round(previousTransform.k * 100);
this.zoomInput = this.zoomPercent + "%";
svg.call(zoom).call(zoom.transform, previousTransform);
},
prepareNodeKeys(root) {
root.each((node) => {
node._nodeKey = getNodeKey(node);
node._linkKey = node.parent ? node.parent._nodeKey + "->" + node._nodeKey : "";
});
},
getLayoutConfig() {
return {
margin: { top: 48, right: 72, bottom: 56, left: 42 },
rowGap: 108,
columnGap: 330,
linkLabelGap: 20,
rootTrunkGap: 18,
businessDropOffset: 12,
linkStepColumns: 1,
sharedBranchGap: 14,
};
},
drawArrowMarkers(defs) {
let markers = [
{ id: "arrow", color: "#bcc3bd" },
{ id: "arrow-root", color: "#aeb6b1" },
{ id: "arrow-blue", color: "#8c9fcf" },
{ id: "arrow-green", color: "#76a296" },
{ id: "arrow-red", color: "#c38484" },
{ id: "arrow-orange", color: "#c49a72" },
];
markers.forEach((marker) => {
defs
.append("marker")
.attr("id", marker.id)
.attr("viewBox", "0 -6 12 12")
.attr("refX", 11)
.attr("refY", 0)
.attr("markerWidth", 10)
.attr("markerHeight", 10)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L11,0L0,5")
.attr("fill", marker.color);
});
},
drawLinks(chart, links, layout) {
let nodeWidth = this.nodeWidth;
let nodeHeight = this.nodeHeight;
let labelGap = layout.linkLabelGap;
let rootTrunkGap = layout.rootTrunkGap;
let getLabelWidth = this.getLinkLabelWidth;
const getPath = (d) => {
let templatePath = this.getTemplateLinkPath(d);
if (templatePath) {
return templatePath;
}
let sx = d.source.y + nodeWidth / 2;
let sy = d.source.x;
let tx = d.target.y - nodeWidth / 2;
let ty = d.target.x;
let siblings = d.source.children || [];
let childIndex = siblings.indexOf(d.target);
let isRootLink = d.source.depth === 0;
let sharedSiblings = d.target.data.link
? siblings.filter((item) => item.data.link === d.target.data.link)
: [];
let sharedIndex = sharedSiblings.indexOf(d.target);
let hasSharedLink = sharedSiblings.length > 1;
let startsFromBottom = d.source.depth > 0 && childIndex > 0;
if (startsFromBottom && !hasSharedLink) {
sx = d.source.y;
sy = d.source.x + nodeHeight / 2;
}
let labelBaseX = sx;
let labelWidth = d.target.data.link ? getLabelWidth(d.target.data.link) : 0;
let firstSharedTarget = hasSharedLink ? sharedSiblings[0] : d.target;
let firstTy = firstSharedTarget.x;
let firstTx = firstSharedTarget.y - nodeWidth / 2;
let path = "M" + sx + "," + sy;
if (hasSharedLink) {
labelBaseX = isRootLink ? sx + rootTrunkGap : sx;
let sharedLabelCenterX = labelBaseX + (firstTx - labelBaseX) / 2;
let sharedLabelRight = sharedLabelCenterX + labelWidth / 2 + labelGap;
let splitX = Math.min(firstTx - layout.sharedBranchGap, sharedLabelRight + layout.sharedBranchGap);
path += "H" + labelBaseX + "V" + firstTy;
if (sharedIndex > 0) {
path += "H" + splitX + "V" + ty;
labelBaseX = splitX;
}
} else if (isRootLink) {
labelBaseX = sx + rootTrunkGap;
path += "H" + labelBaseX + "V" + ty;
} else if (startsFromBottom || childIndex > 0) {
path += "V" + ty;
}
let labelCenterX = labelBaseX + (tx - labelBaseX) / 2;
if (!hasSharedLink && d.source.depth > 0 && childIndex > 0 && d.target.data.link) {
labelCenterX = d.target.y - layout.columnGap;
}
let labelLeft = labelCenterX - labelWidth / 2 - labelGap;
let labelRight = labelCenterX + labelWidth / 2 + labelGap;
if (d.target.data.link && !(hasSharedLink && sharedIndex > 0)) {
path += "H" + labelLeft + "M" + labelRight + "," + ty;
return path + "H" + tx;
}
return path + "H" + tx;
};
chart
.append("g")
.selectAll("path")
.data(links)
.join("path")
.attr("class", "link")
.attr("data-link-key", (d) => {
return d.target._linkKey;
})
.attr("marker-end", "url(#arrow)")
.attr("d", (d) => {
return getPath(d);
});
},
drawLinkLabels(chart, links, layout) {
let nodeWidth = this.nodeWidth;
let labelGap = layout.linkLabelGap;
let rootTrunkGap = layout.rootTrunkGap;
let labelNode = chart
.append("g")
.selectAll("g")
.data(
links.filter((d) => {
if (!d.target.data.link) {
return false;
}
let siblings = d.source.children || [];
let sharedSiblings = siblings.filter((item) => item.data.link === d.target.data.link);
return sharedSiblings.length <= 1 || sharedSiblings[0] === d.target;
})
)
.join("g")
.attr("class", "link-label-node")
.attr("transform", (d) => {
let templatePoint = this.getTemplateLinkLabelPoint(d);
if (templatePoint) {
return "translate(" + templatePoint.x + "," + templatePoint.y + ")";
}
let sx = d.source.y + nodeWidth / 2;
let tx = d.target.y - nodeWidth / 2;
let siblings = d.source.children || [];
let childIndex = siblings.indexOf(d.target);
if (d.source.depth === 0) {
sx += rootTrunkGap;
}
if (d.source.depth > 0 && childIndex > 0) {
sx = d.source.y;
}
let labelX = sx + (tx - sx) / 2;
if (d.source.depth > 0 && childIndex > 0) {
labelX = d.target.y - layout.columnGap;
}
return "translate(" + labelX + "," + d.target.x + ")";
});
labelNode
.append("text")
.attr("class", "link-label")
.attr("dy", "0.35em")
.text((d) => {
return d.target.data.link;
});
},
getTemplateLinkPath(link) {
let route = String(link.target.data.linkRoute || "").trim().toLowerCase();
let points = Array.isArray(link.target.data.linkPoints) ? link.target.data.linkPoints : [];
if (!route && !points.length) {
return "";
}
let start = {
x: link.source.y + this.nodeWidth / 2,
y: link.source.x,
};
let end = {
x: link.target.y - this.nodeWidth / 2,
y: link.target.x,
};
let pathPoints = [start].concat(points).concat([end]);
let command = route === "直线" || route === "straight" || route === "line" ? "L" : "L";
return pathPoints.map((point, index) => {
return (index === 0 ? "M" : command) + point.x + "," + point.y;
}).join("");
},
getTemplateLinkLabelPoint(link) {
let route = String(link.target.data.linkRoute || "").trim();
let points = Array.isArray(link.target.data.linkPoints) ? link.target.data.linkPoints : [];
if (!route && !points.length) {
return null;
}
if (points.length) {
return points[Math.floor(points.length / 2)];
}
return {
x: (link.source.y + link.target.y) / 2,
y: (link.source.x + link.target.x) / 2,
};
},
getLinkLabelWidth(text) {
let value = text || "";
let chineseCount = (value.match(/[\u4e00-\u9fa5]/g) || []).length;
let otherCount = value.length - chineseCount;
return Math.max(24, chineseCount * 12 + otherCount * 7);
},
layoutAlignedTree(root) {
let layout = this.getLayoutConfig();
let rowGap = layout.rowGap;
let columnGap = layout.columnGap;
let linkStepOffset = layout.columnGap * layout.linkStepColumns;
const shiftSubtreeY = (node, offset) => {
node.y += offset;
if (node.children && node.children.length) {
node.children.forEach((child) => {
shiftSubtreeY(child, offset);
});
}
};
const place = (node, depth, startX) => {
node.x = startX;
node.y = depth * columnGap;
if (!node.children || !node.children.length) {
return startX;
}
let nextX = startX;
let bottomX = startX;
node.children.forEach((child, index) => {
let childBottomX = place(child, depth + 1, nextX);
if (node.depth > 0 && index > 0 && child.data.link) {
shiftSubtreeY(child, linkStepOffset);
}
bottomX = Math.max(bottomX, childBottomX);
nextX = childBottomX + rowGap;
});
return bottomX;
};
place(root, 0, 0);
root.each((node) => {
if (node.data.manualX != null && node.data.manualY != null) {
node.y = Number(node.data.manualX);
node.x = Number(node.data.manualY);
}
});
},
supportsForeignObject() {
let userAgent = window.navigator && window.navigator.userAgent ? window.navigator.userAgent : "";
return typeof window.SVGForeignObjectElement !== "undefined" && !/MSIE|Trident/.test(userAgent);
},
getNodeTypeColors(type) {
let colors = {
root: { stroke: "#aeb6b1", fill: "#eef0ed" },
blue: { stroke: "#8c9fcf", fill: "#eaf0ff" },
green: { stroke: "#76a296", fill: "#e9f3ef" },
red: { stroke: "#c38484", fill: "#f8eeee" },
orange: { stroke: "#c49a72", fill: "#f7eee4" },
};
return colors[type] || colors.green;
},
getNodeNameLines(name) {
let lines = String(name || "").split(/\n/);
let result = [];
lines.forEach((line) => {
let text = line || "";
if (text.length <= 8) {
result.push(text);
return;
}
for (let index = 0; index < text.length; index += 8) {
result.push(text.slice(index, index + 8));
}
});
return result.slice(0, 3);
},
bindNodeEvents(node, chart, vm) {
node
.on("mouseover", (d) => {
if (!vm.enableHoverHighlight) {
return;
}
vm.highlightPath(chart, d);
})
.on("mouseout", () => {
if (!vm.enableHoverHighlight) {
return;
}
vm.clearHighlight(chart);
})
.on("click", (d) => {
if (!vm.enableNodeEdit) {
return;
}
let event = d3.event;
event.stopPropagation();
vm.openNodeEditor(d);
});
},
drawSvgNodes(chart, nodes) {
let vm = this;
let nodeWidth = this.nodeWidth;
let nodeHeight = this.nodeHeight;
let node = chart
.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.attr("class", (d) => {
return "node " + d.data.type;
})
.attr("data-node-key", (d) => {
return d._nodeKey;
})
.attr("transform", (d) => {
return "translate(" + d.y + "," + d.x + ")";
});
this.bindNodeEvents(node, chart, vm);
node
.append("rect")
.attr("class", "node-card-svg")
.attr("x", -nodeWidth / 2)
.attr("y", -nodeHeight / 2)
.attr("width", nodeWidth)
.attr("height", nodeHeight)
.attr("rx", 4)
.attr("ry", 4)
.attr("fill", (d) => {
return vm.getNodeTypeColors(d.data.type).fill;
})
.attr("stroke", (d) => {
return vm.getNodeTypeColors(d.data.type).stroke;
});
node
.append("line")
.attr("x1", -nodeWidth / 2)
.attr("x2", nodeWidth / 2)
.attr("y1", 6)
.attr("y2", 6)
.attr("stroke", (d) => {
return vm.getNodeTypeColors(d.data.type).stroke;
});
let nameText = node
.append("text")
.attr("class", "node-name-text svg-node-name")
.attr("x", 0)
.attr("y", -22)
.attr("fill", "#21312e")
.attr("font-size", 12)
.attr("font-weight", 700)
.attr("text-anchor", "middle");
nameText.each(function(d) {
let text = d3.select(this);
let lines = vm.getNodeNameLines(d.data.name);
let startY = lines.length > 1 ? -28 : -20;
lines.forEach((line, index) => {
text
.append("tspan")
.attr("x", 0)
.attr("y", startY + index * 14)
.text(line);
});
});
let metaText = node
.append("text")
.attr("class", "node-meta svg-node-meta")
.attr("x", 0)
.attr("y", 25)
.attr("fill", "#5d6863")
.attr("font-size", 10)
.attr("font-weight", 600)
.attr("text-anchor", "middle");
metaText.each(function(d, index) {
let metrics = vm.getNodeMetrics(d.data, index, d.depth);
d3.select(this)
.append("tspan")
.attr("x", 0)
.attr("y", 24)
.text("当前 " + metrics.count);
d3.select(this)
.append("tspan")
.attr("x", 0)
.attr("y", 38)
.text("占比 " + metrics.ratio);
});
let toggle = node
.filter((d) => {
return vm.hasChildNodes(d.data);
})
.append("g")
.attr("class", "node-collapse-svg-button")
.attr("transform", "translate(" + (nodeWidth / 2 - 13) + "," + (-nodeHeight / 2 + 13) + ")")
.on("click", (d) => {
vm.toggleNodeCollapse(d3.event, d);
});
toggle
.append("rect")
.attr("x", -8)
.attr("y", -8)
.attr("width", 17)
.attr("height", 17)
.attr("rx", 3)
.attr("ry", 3)
.attr("fill", "#ffffff")
.attr("stroke", "#8aa99f");
toggle
.append("text")
.attr("x", 0)
.attr("y", 4)
.attr("fill", "#1e4f45")
.attr("font-size", 12)
.attr("font-weight", 800)
.attr("text-anchor", "middle")
.text((d) => {
return vm.isNodeCollapsed(d.data) ? "+" : "-";
});
},
drawNodes(chart, nodes) {
let vm = this;
let nodeWidth = this.nodeWidth;
let nodeHeight = this.nodeHeight;
if (!this.supportsForeignObject()) {
this.drawSvgNodes(chart, nodes);
return;
}
let node = chart
.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.attr("class", (d) => {
return "node " + d.data.type;
})
.attr("data-node-key", (d) => {
return d._nodeKey;
})
.attr("transform", (d) => {
return "translate(" + d.y + "," + d.x + ")";
});
this.bindNodeEvents(node, chart, vm);
let cards = node
.append("foreignObject")
.attr("class", "node-html")
.attr("x", -nodeWidth / 2)
.attr("y", -nodeHeight / 2)
.attr("width", nodeWidth)
.attr("height", nodeHeight)
.append("xhtml:div")
.attr("class", (d) => {
let classes = ["node-card"];
if (vm.hasChildNodes(d.data)) {
classes.push("has-children");
}
if (vm.isNodeCollapsed(d.data)) {
classes.push("is-collapsed");
}
return classes.join(" ");
});
cards
.filter((d) => {
return vm.hasChildNodes(d.data);
})
.append("xhtml:button")
.attr("class", "node-collapse-button")
.attr("type", "button")
.attr("title", (d) => {
return vm.isNodeCollapsed(d.data) ? "展开子节点" : "收起子节点";
})
.text((d) => {
return vm.isNodeCollapsed(d.data) ? "+" : "-";
})
.on("click", (d) => {
vm.toggleNodeCollapse(d3.event, d);
});
cards
.append("xhtml:div")
.attr("class", "node-name")
.append("xhtml:span")
.attr("class", "node-name-text")
.text((d) => {
return d.data.name;
});
cards.append("xhtml:div").attr("class", "node-divider");
let meta = cards
.append("xhtml:div")
.attr("class", "node-meta");
meta
.append("xhtml:span")
.attr("class", "node-meta-line")
.text((d, index) => {
let metrics = vm.getNodeMetrics(d.data, index, d.depth);
return "当前 " + metrics.count;
});
meta
.append("xhtml:span")
.attr("class", "node-meta-line")
.text((d, index) => {
let metrics = vm.getNodeMetrics(d.data, index, d.depth);
return "占比 " + metrics.ratio;
});
},
highlightPath(chart, node) {
let nodeKeys = new Set();
let linkKeys = new Set();
let current = node;
let activeArrow = "url(#arrow-" + node.data.type + ")";
while (current) {
nodeKeys.add(current._nodeKey);
if (current.parent) {
linkKeys.add(current._linkKey);
}
current = current.parent;
}
chart
.classed("has-hover", true)
.classed("active-root", node.data.type === "root")
.classed("active-blue", node.data.type === "blue")
.classed("active-green", node.data.type === "green")
.classed("active-red", node.data.type === "red")
.classed("active-orange", node.data.type === "orange");
chart.selectAll(".node").classed("path-active", (d) => {
return nodeKeys.has(d._nodeKey);
});
chart
.selectAll(".link")
.classed("path-active", (d) => {
return linkKeys.has(d.target._linkKey);
})
.attr("marker-end", (d) => {
return linkKeys.has(d.target._linkKey) ? activeArrow : "url(#arrow)";
});
chart.selectAll(".link-label-node").classed("path-active", (d) => {
if (linkKeys.has(d.target._linkKey)) {
return true;
}
let siblings = d.source.children || [];
return siblings.some((item) => {
return item.data.link === d.target.data.link && linkKeys.has(item._linkKey);
});
});
},
clearHighlight(chart) {
chart
.classed("has-hover", false)
.classed("active-root", false)
.classed("active-blue", false)
.classed("active-green", false)
.classed("active-red", false)
.classed("active-orange", false);
chart.selectAll(".node").classed("path-active", false);
chart.selectAll(".link").classed("path-active", false).attr("marker-end", "url(#arrow)");
chart.selectAll(".link-label-node").classed("path-active", false);
},
},
};
const getNodeKey = (node) => {
return node
.ancestors()
.reverse()
.map((item) => {
return item.data.name;
})
.join("/");
};
</script>
<style>
:root {
color: #22312e;
background: #f5f7f4;
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
background: linear-gradient(180deg, rgba(226, 232, 226, 0.72), transparent 34%), #f6f7f3;
}
.topology-page {
position: relative;
width: 100vw;
height: 100vh;
padding: 28px 34px 24px;
}
.topology-page:fullscreen,
.topology-page.is-fullscreen {
width: 100vw;
height: 100vh;
padding: 18px 22px 20px;
background: #f6f7f3;
}
.zoom-control {
position: relative;
z-index: 2;
display: block;
height: 38px;
width: 100%;
margin-bottom: 12px;
padding: 6px 8px;
border: 1px solid #d7ddd5;
border-radius: 4px;
background: rgba(251, 252, 248, 0.92);
box-shadow: 0 1px 4px rgba(40, 56, 50, 0.12);
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
}
.zoom-control > * {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
.zoom-control > *:last-child {
margin-right: 0;
}
.topology-page.is-legacy-ie {
padding-top: 28px;
}
.topology-page.is-legacy-ie .zoom-control {
height: 58px;
min-height: 58px;
overflow-x: scroll;
overflow-y: hidden;
padding-bottom: 16px;
-ms-overflow-style: scrollbar;
}
.topology-page.is-legacy-ie .zoom-control > * {
margin-top: 3px;
margin-bottom: 3px;
float: none;
}
.zoom-button {
width: 26px;
height: 24px;
border: 1px solid #c9d0ca;
border-radius: 3px;
background: #ffffff;
color: #22312e;
cursor: pointer;
font-size: 16px;
font-weight: 700;
line-height: 20px;
}
.zoom-button:disabled {
color: #a7b0aa;
cursor: not-allowed;
}
.zoom-button:hover,
.drag-toggle:hover {
border-color: #76a296;
background: #e9f3ef;
color: #1e4f45;
transform: translateY(-1px);
}
.zoom-button:disabled:hover {
border-color: #c9d0ca;
background: #ffffff;
color: #a7b0aa;
transform: none;
}
.export-button {
width: auto;
padding: 0 10px;
font-size: 11px;
}
.zoom-percent {
width: 54px;
height: 24px;
border: 1px solid #c9d0ca;
border-radius: 3px;
background: #ffffff;
color: #35433f;
font-size: 12px;
font-weight: 700;
line-height: 22px;
outline: none;
padding: 0 5px;
text-align: center;
}
.zoom-percent:focus {
border-color: #76a296;
box-shadow: 0 0 0 2px rgba(118, 162, 150, 0.18);
}
.drag-toggle {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 7px;
border: 1px solid #c9d0ca;
border-radius: 3px;
background: #ffffff;
color: #35433f;
cursor: pointer;
font-size: 11px;
font-weight: 700;
line-height: 24px;
white-space: nowrap;
}
.topology-page.is-legacy-ie .drag-toggle {
display: inline-block;
}
.topology-page.is-legacy-ie .drag-toggle input {
vertical-align: middle;
}
.drag-toggle input {
width: 13px;
height: 13px;
margin: 0;
}
.file-input {
display: none;
}
.month-timeline {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 0;
min-height: 64px;
margin-top: 12px;
padding: 13px 18px 11px;
border: 1px solid rgba(188, 199, 192, 0.9);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(247, 250, 247, 0.94)),
#fbfcf8;
box-shadow: 0 8px 24px rgba(40, 56, 50, 0.14);
overflow-x: auto;
overflow-y: hidden;
}
.topology-page.is-legacy-ie .month-timeline {
display: block;
overflow-x: scroll;
overflow-y: hidden;
height: 88px;
padding-bottom: 20px;
white-space: nowrap;
-ms-overflow-style: scrollbar;
}
.month-timeline::before {
position: absolute;
right: 28px;
left: 28px;
top: 27px;
height: 2px;
background: linear-gradient(90deg, #d6ded8, #8fb8ad, #d6ded8);
content: "";
}
.month-node {
position: relative;
z-index: 1;
flex: 1 0 96px;
min-width: 96px;
border: 0;
background: transparent;
color: #52615c;
cursor: pointer;
font: inherit;
text-align: center;
}
.topology-page.is-legacy-ie .month-node {
display: inline-block;
width: 96px;
min-width: 96px;
vertical-align: top;
}
.month-dot {
display: block;
width: 13px;
height: 13px;
margin: 0 auto 7px;
border: 2px solid #9eaaa4;
border-radius: 50%;
background: #fbfcf8;
box-shadow: 0 0 0 5px rgba(251, 252, 248, 0.95);
}
.month-label,
.month-sub {
display: block;
white-space: nowrap;
}
.month-label {
color: #22312e;
font-size: 13px;
font-weight: 800;
line-height: 16px;
}
.month-sub {
margin-top: 2px;
color: #73807b;
font-size: 10px;
font-weight: 700;
line-height: 12px;
}
.month-node:hover .month-dot {
border-color: #76a296;
transform: scale(1.08);
}
.month-node.active .month-dot {
width: 18px;
height: 18px;
margin-top: -2px;
border-color: #2f8f7d;
background: #2f8f7d;
box-shadow:
0 0 0 5px rgba(251, 252, 248, 0.95),
0 0 0 9px rgba(47, 143, 125, 0.16);
}
.month-node.active .month-label {
color: #1e4f45;
}
.month-node.active .month-sub {
color: #2f8f7d;
}
.edit-panel {
position: absolute;
top: 84px;
right: 50px;
z-index: 3;
width: 300px;
padding: 14px;
border: 1px solid #d4ddd7;
border-radius: 6px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 28px rgba(40, 56, 50, 0.18);
}
.edit-title {
margin: 0 0 12px;
color: #22312e;
font-size: 14px;
font-weight: 700;
}
.edit-field {
display: block;
margin-bottom: 10px;
color: #52615c;
font-size: 12px;
font-weight: 600;
}
.edit-input,
.edit-select,
.edit-textarea {
width: 100%;
margin-top: 5px;
border: 1px solid #cfd8d2;
border-radius: 4px;
background: #fbfcf8;
color: #22312e;
font: inherit;
}
.edit-input,
.edit-select {
height: 30px;
padding: 0 8px;
}
.edit-textarea {
min-height: 58px;
padding: 7px 8px;
resize: vertical;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.edit-actions > * {
margin-left: 8px;
}
.edit-button {
height: 30px;
padding: 0 13px;
border: 1px solid #c9d0ca;
border-radius: 4px;
background: #ffffff;
color: #22312e;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.edit-button.primary {
border-color: #76a296;
background: #e9f3ef;
color: #1e4f45;
}
.text-tooltip {
position: fixed;
z-index: 10;
max-width: 320px;
padding: 7px 9px;
border: 1px solid #cfd8d2;
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 18px rgba(40, 56, 50, 0.18);
color: #22312e;
font-size: 12px;
font-weight: 600;
line-height: 18px;
pointer-events: none;
white-space: pre-wrap;
}
.node {
cursor: pointer;
}
.topology-scroll {
width: 100%;
height: calc(100% - 128px);
overflow: auto;
border: 1px solid #d7ddd5;
background: #fbfcf8;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8);
}
.topology-page.is-legacy-ie .topology-scroll {
height: 70%;
overflow: scroll;
-ms-overflow-style: scrollbar;
}
.topology-svg {
display: block;
background: #fbfcf8;
cursor: default;
}
.topology-page.is-legacy-ie .topology-svg {
min-width: 1200px;
min-height: 720px;
}
.topology-svg:active {
cursor: default;
}
.topology-svg.drag-enabled {
cursor: grab;
}
.topology-svg.drag-enabled:active {
cursor: grabbing;
}
.link {
fill: none;
stroke: #c6ccc7;
stroke-linecap: square;
stroke-linejoin: round;
stroke-width: 1.35;
}
.link.path-active {
stroke: #4d8f83;
stroke-width: 2.8;
stroke-linecap: round;
stroke-dasharray: 9 6;
filter: drop-shadow(0 0 3px rgba(47, 143, 125, 0.45));
animation: dash-flow 0.75s linear infinite;
}
.chart.active-root .link.path-active {
stroke: #aeb6b1;
}
.chart.active-blue .link.path-active {
stroke: #8c9fcf;
}
.chart.active-green .link.path-active {
stroke: #76a296;
}
.chart.active-red .link.path-active {
stroke: #c38484;
}
.chart.active-orange .link.path-active {
stroke: #c49a72;
}
.chart.has-hover .link:not(.path-active) {
stroke: #e7ece8;
opacity: 0.5;
}
.chart.has-hover .link-label-node:not(.path-active) {
opacity: 0.38;
}
.link-label {
fill: #5d8ab8;
font-size: 16px;
font-weight: 600;
pointer-events: none;
text-anchor: middle;
}
.node-html {
width: 118px;
height: 87px;
overflow: visible;
}
.node-card {
position: relative;
width: 118px;
height: 87px;
border: 1.7px solid #aeb6b1;
border-radius: 4px;
background: #eef0ed;
box-shadow: 0 1px 2px rgba(40, 56, 50, 0.12);
display: flex;
flex-direction: column;
justify-content: stretch;
overflow: hidden;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease,
background-color 0.16s ease;
}
.node-collapse-button {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
width: 17px;
height: 17px;
padding: 0;
border: 1px solid rgba(86, 104, 98, 0.38);
border-radius: 3px;
background: rgba(255, 255, 255, 0.86);
color: #31423d;
cursor: pointer;
font-size: 12px;
font-weight: 800;
line-height: 15px;
text-align: center;
}
.node-collapse-button:hover {
border-color: rgba(47, 143, 125, 0.76);
background: #ffffff;
color: #1e4f45;
}
.node-card-svg {
stroke-width: 1.7;
}
.node.path-active .node-card-svg {
stroke-width: 2.8;
filter: none;
}
.chart.has-hover .node:not(.path-active) .node-card-svg {
fill: #ffffff;
opacity: 0.42;
}
.node-collapse-svg-button {
cursor: pointer;
}
.node-card.has-children .node-name {
padding-right: 24px;
}
.node-card.is-collapsed .node-collapse-button {
border-color: rgba(47, 143, 125, 0.7);
background: #ffffff;
color: #1e4f45;
}
.node-name {
height: 50px;
padding: 4px 8px;
color: #21312e;
font-size: 12px;
font-weight: 700;
line-height: 14px;
text-align: center;
white-space: pre-line;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.node-name-text {
display: -webkit-box;
max-width: 100%;
max-height: 42px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
text-overflow: ellipsis;
white-space: pre-line;
word-break: break-all;
}
.node-divider {
height: 1px;
background: #aeb6b1;
flex: none;
}
.node-meta {
height: 36px;
padding: 4px 7px 3px;
color: #5d6863;
font-size: 10px;
font-weight: 600;
line-height: 13px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
overflow: hidden;
}
.node-meta-line {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node.path-active .node-card {
box-shadow:
0 0 0 3px rgba(47, 143, 125, 0.16),
0 6px 14px rgba(40, 56, 50, 0.2);
transform: translateY(-2px) scale(1.03);
}
.chart.has-hover .node:not(.path-active) .node-card {
border-color: #edf1ee;
background: #ffffff;
box-shadow: none;
opacity: 0.42;
filter: grayscale(1);
}
.chart.has-hover .node:not(.path-active) .node-name,
.chart.has-hover .node:not(.path-active) .node-meta {
color: #b7c0ba;
}
.chart.has-hover .node:not(.path-active) .node-divider {
background: #edf1ee;
}
.root .node-card {
border-color: #aeb6b1;
background: #eef0ed;
}
.blue .node-card {
border-color: #8c9fcf;
background: #eaf0ff;
}
.blue .node-divider {
background: #8c9fcf;
}
.green .node-card {
border-color: #76a296;
background: #e9f3ef;
}
.green .node-divider {
background: #76a296;
}
.red .node-card {
border-color: #c38484;
background: #f8eeee;
}
.red .node-divider {
background: #c38484;
}
.orange .node-card {
border-color: #c49a72;
background: #f7eee4;
}
.orange .node-divider {
background: #c49a72;
}
.watermark {
fill: rgba(69, 82, 76, 0.06);
font-size: 54px;
font-weight: 700;
letter-spacing: 0;
}
@keyframes dash-flow {
to {
stroke-dashoffset: -15;
}
}
</style>