D3生成topo 结点连线 webpack 配置兼容ie 11

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, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;");
    },
    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>
相关推荐
tangdou3690986551 小时前
前端Skill全家桶:React+Vue+TypeScript开发实战
前端
大大杰哥1 小时前
Vue2学习(3)--组件中的通信方式/组件之间的交互
java·前端·javascript
阿猫的故乡1 小时前
Vue3自定义插件:封装一个全局消息提示插件,所有组件都能直接用
前端·javascript·vue.js
橘子星1 小时前
树与二叉树:从概念到 JavaScript 实现
前端·javascript·面试
小小高不懂写代码1 小时前
Transformer与注意力机制
前端·人工智能
AiClaw1 小时前
AIClaw 的 Skills 机制:先注入索引,再按需读取完整说明
前端
YHHLAI1 小时前
HTML5 Canvas 从入门到实战:画布绘图 · 帧动画 · 小游戏 · 数据可视化
前端·信息可视化·html5
前端 贾公子1 小时前
npx skills:AI Agent Skill 的 npm,50+ 工具统一的 Skill 管理工具
前端
触底反弹1 小时前
面试官问"Ajax原理",我从XHR讲到async/await,他直接懵了!
前端·面试·架构