testFlow/ui/page/test_d3.html
Wyle.Gong-巩文昕 7a1aae1e2f ui
2025-04-23 11:21:08 +08:00

1018 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>D3 树形结构可视化</title>
<script src="/Users/zpc01/workspace/v/test_front/ui/assets/d3.js"></script>
<style>
body {
font-family: "Microsoft YaHei", sans-serif;
margin: 0;
padding: 20px;
}
#tree-container {
width: 100%;
height: 800px;
border: 1px solid #eee;
overflow: hidden;
position: relative;
}
/* 卡片式节点样式 */
.node rect {
fill: #fff;
stroke: #1890ff;
stroke-width: 1.5px;
rx: 4;
ry: 4;
transition: all 0.3s;
cursor: pointer;
}
.node text {
font: 14px sans-serif;
dominant-baseline: middle;
pointer-events: none; /* 防止文本拦截点击事件 */
}
.link {
fill: none;
stroke: #cacaca;
stroke-width: 1.5px;
}
.control-panel {
margin-bottom: 20px;
padding: 15px;
background: #f8f8f8;
border-radius: 5px;
}
button {
padding: 6px 15px;
margin-right: 8px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #40a9ff;
}
/* 选中和拖拽状态 */
.node--selected rect {
fill: #e6f7ff;
stroke: #1890ff;
stroke-width: 2.5px;
}
.dragging rect {
fill: #fff1f0;
stroke: #ff4d4f;
}
/* 潜在父节点高亮 */
.potential-parent rect {
fill: rgba(24, 144, 255, 0.15);
stroke: #1890ff;
stroke-width: 2px;
}
/* 根节点高亮区域 */
.root-hint {
fill: rgba(82, 196, 26, 0.15);
stroke: #52c41a;
stroke-width: 2px;
rx: 4;
ry: 4;
pointer-events: none;
}
.tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
}
.zoom-controls {
position: absolute;
right: 20px;
top: 100px;
display: flex;
flex-direction: column;
z-index: 100;
}
.zoom-btn {
width: 36px;
height: 36px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 5px;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.tree-label {
font-size: 14px;
font-weight: bold;
fill: #666;
}
.tree-container {
border-top: 1px dashed #ddd;
padding-top: 30px;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="control-panel">
<button id="add-btn">添加节点</button>
<button id="edit-btn">编辑节点</button>
<button id="delete-btn">删除节点</button>
<button id="refresh-btn">刷新</button>
<button id="fit-btn">适应视图</button>
</div>
<div id="tree-container"></div>
<div class="tooltip"></div>
<div class="zoom-controls">
<div class="zoom-btn" id="zoom-in">+</div>
<div class="zoom-btn" id="zoom-out">-</div>
<div class="zoom-btn" id="zoom-reset"></div>
</div>
<script>
// 等待DOM完全加载后初始化D3
document.addEventListener("DOMContentLoaded", initD3);
function initD3() {
// 全局变量
let treeData = [];
let selectedNodeId = null; // 存储选中节点的ID而非DOM元素
let zoomBehavior;
let lastPotentialParent = null; // 跟踪上一个潜在父节点
let treeGroups = []; // 存储每棵树的组引用
let treeOffsets = []; // 存储每棵树的垂直偏移
// 安全获取容器尺寸
const container = document.getElementById("tree-container");
const containerWidth = container ? container.clientWidth : 800; // 默认值
const containerHeight = container ? container.clientHeight : 600; // 默认值
// 设置图表尺寸和边距 - 使用安全值
const margin = { top: 40, right: 20, bottom: 40, left: 20 };
const width = Math.max(
100,
containerWidth - margin.left - margin.right
);
const height = Math.max(
100,
containerHeight - margin.top - margin.bottom
);
// 创建工具提示
const tooltip = d3.select(".tooltip");
// 创建SVG容器 - 检查选择器是否有效
const svgContainer = d3.select("#tree-container");
if (svgContainer.empty()) {
console.error("Container #tree-container not found");
return;
}
const svg = svgContainer
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.on("click", function (event) {
// 只有点击到SVG背景而非节点时才取消选择
if (event.target.tagName === "svg") {
selectedNodeId = null;
mainGroup.selectAll(".node").classed("node--selected", false);
}
});
// 创建主要图形组
const mainGroup = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
//console.log("original maingroup", mainGroup)
// 根节点高亮指示器
const rootHint = mainGroup
.append("rect")
.attr("class", "root-hint")
.style("opacity", 0);
// 初始化zoom行为
zoomBehavior = d3
.zoom()
.scaleExtent([0.1, 3])
.on("zoom", (event) => {
mainGroup.attr("transform", event.transform);
});
svg.call(zoomBehavior);
// 适应视图函数
function fitView() {
const bounds = mainGroup.node().getBBox();
const fullWidth = isNaN(bounds.width) ? width : bounds.width;
const fullHeight = isNaN(bounds.height) ? height : bounds.height;
// 确保不使用NaN值
if (
isNaN(fullWidth) ||
isNaN(fullHeight) ||
fullWidth <= 0 ||
fullHeight <= 0
) {
console.warn(
"Invalid dimensions for fitView:",
fullWidth,
fullHeight
);
return;
}
const scale = Math.min(width / fullWidth, height / fullHeight) * 0.9;
const translateX = (width - fullWidth * scale) / 2 + margin.left;
const translateY = (height - fullHeight * scale) / 2 + margin.top;
svg
.transition()
.duration(750)
.call(
zoomBehavior.transform,
d3.zoomIdentity.translate(translateX, translateY).scale(scale)
);
}
// 将后端数据转换为D3可用的格式
function convertToD3Format(data) {
// 防御性检查
if (!Array.isArray(data)) {
//conso le.error("Expected array data, got:", data);
return [];
}
const map = {};
const roots = [];
// 首先创建所有节点的映射
data.forEach((item) => {
map[item.id] = {
id: item.id,
name: item.name || "未命名",
level: item.level || 0,
children: [],
};
});
// 构建树形结构
data.forEach((item) => {
const node = map[item.id];
if (!node) return; // 防御性检查
if (
!item.parent_id ||
item.parent_id === "" ||
item.parent_id === "-1"
) {
roots.push(node);
} else {
const parent = map[item.parent_id];
if (parent) {
parent.children.push(node);
} else {
// 如果找不到父节点,将其作为根节点
console.warn(
`找不到父节点 ID:${item.parent_id},将节点 ID:${item.id} 作为根节点`
);
roots.push(node);
}
}
});
return roots;
}
// 更新树形图 - 支持多棵树从上往下排列
function updateTree(rootNodes) {
// 清除节点和连接
mainGroup.selectAll(".tree-container").remove();
treeGroups = [];
treeOffsets = [];
if (!rootNodes || rootNodes.length === 0) {
mainGroup
.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.text("暂无数据");
return;
}
// 计算每棵树的垂直偏移
const treeSpacing = 200; // 树之间的间距
let totalHeight = 0;
// 为每棵树创建独立的布局和渲染
rootNodes.forEach((rootData, index) => {
try {
// 防御性检查
if (!rootData || typeof rootData !== "object") {
console.error(`Invalid root data at index ${index}:`, rootData);
return;
}
treeOffsets.push(totalHeight);
// 创建树容器
const treeContainer = mainGroup
.append("g")
.attr("class", "tree-container")
.attr("transform", `translate(0, ${totalHeight})`);
//console.log(treeContainer);
//console.log("minimap", mainGroup);
treeGroups.push(treeContainer);
// 添加树标签
treeContainer
.append("text")
.attr("class", "tree-label")
.attr("x", width / 2)
.attr("y", -25)
.attr("text-anchor", "middle")
.text(`${rootData.name || "Tree " + (index + 1)}`);
// 转换数据为层次结构
console.log(Map)
const root = d3.hierarchy(rootData);
console.log("rootdata",rootData)
console.log("root", root);
// 创建自定义树布局 - 确保足够的垂直空间防止重叠
const treeLayout = d3
.tree()
.nodeSize([150, 120]) // 增加水平间距[宽度, 高度]
.separation(function (a, b) {
// 根据节点深度动态调整间距
return a.parent === b.parent ? 1.5 : 2.5;
});
// 计算该棵树的节点位置 - 添加错误处理
try {
treeLayout(root);
} catch (e) {
console.error("Error during tree layout calculation:", e);
return;
}
// 水平居中处理 - 添加防御性检查
let minX = Infinity,
maxX = -Infinity;
let validDescendants = root
.descendants()
.filter((d) => !isNaN(d.x) && !isNaN(d.y));
if (validDescendants.length === 0) {
console.warn("No valid descendants for tree layout");
return;
}
validDescendants.forEach((d) => {
if (d.x < minX) minX = d.x;
if (d.x > maxX) maxX = d.x;
});
// 确保minX和maxX是有效数字
if (!isFinite(minX) || !isFinite(maxX)) {
minX = 0;
maxX = width;
}
const centerOffset = width / 2 - (maxX + minX) / 2;
// 应用水平居中偏移 - 确保不使用NaN
validDescendants.forEach((d) => {
d.x = !isNaN(d.x) ? d.x + centerOffset : centerOffset;
});
// 计算树高度
let maxY = 0;
validDescendants.forEach((d) => {
if (d.depth > 0 && d.y > maxY && !isNaN(d.y)) maxY = d.y;
});
// 最小树高度,确保下一棵树有足够空间
const treeHeight = Math.max(300, maxY + 100);
totalHeight += treeHeight + treeSpacing;
// 计算节点和连接线
const nodes = validDescendants;
//console.log(root.links());
//console.log(treeContainer);
const links = root
.links()
.filter(
(link) =>
!isNaN(link.source.x) &&
!isNaN(link.source.y) &&
!isNaN(link.target.x) &&
!isNaN(link.target.y)
);
console.log("links", links);
// 定义垂直连接线生成器 - 添加防御性检查
const diagonal = d3
.linkHorizontal()
.x((d) => (isNaN(d.x) ? 0 : d.x))
.y((d) => (isNaN(d.y) ? 0 : d.y));
// 绘制连接线
treeContainer
.selectAll(".link")
.data(links)
.enter()
.append("path")
.attr("class", "link")
.attr("d", (d) => {
try {
return diagonal(d);
} catch (e) {
console.error("Error generating link path:", e, d);
return "M0,0"; // 返回空路径
}
});
// 创建节点组
const nodeGroups = treeContainer
.selectAll(".node")
.data(nodes)
.enter()
.append("g")
.attr("class", (d) => {
// 如果当前节点ID与selectedNodeId匹配添加选中样式
return d.data.id === selectedNodeId
? "node node--selected"
: "node";
})
.attr("transform", (d) => {
// 确保坐标是有效的数字
const x = isNaN(d.x) ? 0 : d.x;
const y = isNaN(d.y) ? 0 : d.y;
return `translate(${x},${y})`;
})
.attr("data-id", (d) => d.data.id)
.attr("data-tree-index", index); // 记录树索引
// 测量文本宽度
const textWidths = {};
nodes.forEach((d) => {
const name = d.data.name || "未命名";
const textWidth = name.length * 8 + 40; // 估算宽度
textWidths[d.data.id] = textWidth;
});
// 添加卡片矩形
nodeGroups
.append("rect")
.attr("width", (d) =>
Math.max(100, textWidths[d.data.id] || 100)
)
.attr("height", 40)
.attr(
"x",
(d) => -(Math.max(100, textWidths[d.data.id] || 100) / 2)
)
.attr("y", -20);
// 添加节点文本
nodeGroups
.append("text")
.attr("dy", 0)
.attr("text-anchor", "middle")
.text((d) => d.data.name || "未命名");
// 添加悬停事件
nodeGroups
.on("mouseover", (event, d) => {
// 显示工具提示
tooltip.transition().duration(200).style("opacity", 0.9);
tooltip
.html(
`ID: ${d.data.id}<br/>名称: ${
d.data.name || "未命名"
}<br/>Level: ${d.data.level || 0}`
)
.style("left", event.pageX + 10 + "px")
.style("top", event.pageY - 28 + "px");
})
.on("mouseout", () => {
// 隐藏工具提示
tooltip.transition().duration(500).style("opacity", 0);
});
// 添加拖拽功能
setupDrag(nodeGroups);
} catch (e) {
console.error(`Error processing tree at index ${index}:`, e);
}
});
// 自动适应视图
setTimeout(fitView, 300); // 增加延迟确保DOM已更新
}
// 设置拖拽功能
function setupDrag(nodes) {
const dragHandler = d3
.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded);
nodes.call(dragHandler);
}
// 拖拽开始
function dragStarted(event, d) {
if (!event || !event.sourceEvent) return;
event.sourceEvent.stopPropagation();
// 记录初始位置,用于判断是否为点击
d.startX = event.x;
d.startY = event.y;
d3.select(this).raise().classed("dragging", true);
d.dx = 0;
d.dy = 0;
// 记录原始树索引
d.originalTreeIndex = parseInt(
d3.select(this).attr("data-tree-index")
);
if (isNaN(d.originalTreeIndex)) d.originalTreeIndex = 0; // 默认值
// 隐藏根节点提示
rootHint.style("opacity", 0);
lastPotentialParent = null;
}
// 拖拽中
function dragged(event, d) {
if (!event) return;
d.dx = (d.dx || 0) + event.dx;
d.dy = (d.dy || 0) + event.dy;
// 确保坐标有效
const x = isNaN(d.x) ? 0 : d.x;
const y = isNaN(d.y) ? 0 : d.y;
const dx = isNaN(d.dx) ? 0 : d.dx;
const dy = isNaN(d.dy) ? 0 : d.dy;
d3.select(this).attr("transform", `translate(${x + dx}, ${y + dy})`);
// 清除上一个潜在父节点的高亮
if (lastPotentialParent) {
d3.select(lastPotentialParent).classed("potential-parent", false);
lastPotentialParent = null;
}
// 检查是否有潜在的放置目标
const targetNode = findClosestNode(event, d);
if (targetNode && targetNode.element) {
// 直接高亮目标节点,而不是创建虚影
const targetElement = targetNode.element;
d3.select(targetElement).classed("potential-parent", true);
lastPotentialParent = targetElement;
// 隐藏根节点提示
rootHint.style("opacity", 0);
} else {
// 检查是否足够远,表示可能成为根节点
const distanceFromOrigin = Math.sqrt(dx * dx + dy * dy);
if (distanceFromOrigin > 150 && d.parent) {
// 已经是根节点的不处理
// 显示"成为根节点"的提示
try {
const nodeRect = d3
.select(this)
.select("rect")
.node()
.getBBox();
rootHint
.attr("x", x + dx - nodeRect.width / 2)
.attr("y", y + dy - nodeRect.height / 2)
.attr("width", nodeRect.width)
.attr("height", nodeRect.height)
.style("opacity", 0.7);
} catch (e) {
console.warn("Error displaying root hint:", e);
rootHint.style("opacity", 0);
}
} else {
rootHint.style("opacity", 0);
}
}
}
// 拖拽结束
function dragEnded(event, d) {
if (!event) return;
// 计算拖拽距离
const dragDistance = Math.sqrt(
Math.pow(event.x - (d.startX || 0), 2) +
Math.pow(event.y - (d.startY || 0), 2)
);
// 重置样式
d3.select(this).classed("dragging", false);
rootHint.style("opacity", 0);
// 清除潜在父节点高亮
if (lastPotentialParent) {
d3.select(lastPotentialParent).classed("potential-parent", false);
lastPotentialParent = null;
}
// 如果移动距离小于阈值,视为点击
if (dragDistance < 5) {
// 处理点击选中逻辑
mainGroup.selectAll(".node").classed("node--selected", false);
d3.select(this).classed("node--selected", true);
selectedNodeId = d.data.id;
return;
}
// 检查是否拖拽到其他节点附近
const targetNode = findClosestNode(event, d);
if (
targetNode &&
targetNode.node !== d &&
targetNode.node.data.id !== d.data.id
) {
// 避免循环引用 - 不能将节点拖动到其子节点
if (isDescendantOf(d, targetNode.node)) {
alert("不能将节点移动到其子节点下");
updateTree(treeData);
return;
}
// 更新父子关系
updateParentRelationship(d.data.id, targetNode.node.data.id);
} else {
// 检查是否足够远离原点,表示成为根节点
const distanceFromOrigin = Math.sqrt(
(d.dx || 0) * (d.dx || 0) + (d.dy || 0) * (d.dy || 0)
);
if (distanceFromOrigin > 150 && d.parent) {
// 已经是根节点的不处理
// 将节点变为根节点
updateParentRelationship(d.data.id, "-1");
} else {
updateTree(treeData); // 重置位置
}
}
}
// 查找最近的节点 - 重写以更精确地处理跨树拖拽
function findClosestNode(event, sourceNode) {
// 防御性检查
if (
!sourceNode ||
!treeOffsets ||
sourceNode.originalTreeIndex === undefined
) {
return null;
}
// 获取当前拖拽节点的全局坐标
const srcX = isNaN(sourceNode.x) ? 0 : sourceNode.x;
const srcY = isNaN(sourceNode.y) ? 0 : sourceNode.y;
const srcDx = isNaN(sourceNode.dx) ? 0 : sourceNode.dx;
const srcDy = isNaN(sourceNode.dy) ? 0 : sourceNode.dy;
const origTreeIndex = !isNaN(sourceNode.originalTreeIndex)
? sourceNode.originalTreeIndex
: 0;
const treeOffset = treeOffsets[origTreeIndex] || 0;
const draggedX = srcX + srcDx;
const draggedY = srcY + srcDy + treeOffset;
let closestNode = null;
let closestDistance = 80; // 初始距离阈值
// 检查所有树中的所有节点
treeGroups.forEach((treeGroup, treeIndex) => {
if (!treeGroup) return;
const currentTreeOffset = treeOffsets[treeIndex] || 0;
treeGroup.selectAll(".node").each(function (d) {
// 防御性检查
if (!d || !d.data) return;
// 跳过源节点和其子节点
if (d === sourceNode || isDescendantOf(sourceNode, d)) return;
// 获取节点全局坐标
const nodeTransform = d3.select(this).attr("transform");
if (!nodeTransform) return;
const matches = nodeTransform.match(
/translate\(([^,]+),([^)]+)\)/
);
if (!matches) return;
let nodeX = parseFloat(matches[1]);
let nodeY = parseFloat(matches[2]) + currentTreeOffset;
// 确保坐标有效
if (isNaN(nodeX)) nodeX = 0;
if (isNaN(nodeY)) nodeY = 0;
const distance = Math.sqrt(
Math.pow(draggedX - nodeX, 2) + Math.pow(draggedY - nodeY, 2)
);
if (distance < closestDistance) {
closestDistance = distance;
closestNode = {
node: d,
element: this,
distance: distance,
};
}
});
});
return closestNode;
}
// 检查是否是子孙节点
function isDescendantOf(parent, child) {
if (!parent || !child) return false;
if (!parent.children) return false;
for (let i = 0; i < parent.children.length; i++) {
if (parent.children[i] === child) return true;
if (isDescendantOf(parent.children[i], child)) return true;
}
return false;
}
// 更新父子关系
function updateParentRelationship(nodeId, newParentId) {
if (!nodeId) {
console.error("Missing nodeId in updateParentRelationship");
return;
}
fetch(`http://127.0.0.1:5002/api/tree/${nodeId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
parent_id: newParentId || "-1",
}),
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return response.json();
})
.then((data) => {
fetchTreeData();
})
.catch((error) => {
console.error("更新节点关系失败:", error);
updateTree(treeData);
});
}
// 从API获取数据
async function fetchTreeData() {
try {
const response = await fetch("http://127.0.0.1:5002/api/tree/");
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
const result = await response.json();
if (result.code === 0 && Array.isArray(result.data)) {
treeData = convertToD3Format(result.data);
updateTree(treeData);
} else {
console.error("获取数据失败:", result.message || "未知错误");
// 显示错误信息
mainGroup.selectAll("*").remove();
mainGroup
.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.text("数据加载失败: " + (result.message || "请检查网络连接"));
}
} catch (error) {
console.error("获取数据错误:", error);
// 显示错误信息
mainGroup.selectAll("*").remove();
mainGroup
.append("text")
.attr("x", width / 2)
.attr("y", height / 2)
.attr("text-anchor", "middle")
.text("数据加载错误: " + error.message);
}
}
// 添加节点
function addNode() {
let parentId = "-1"; // 默认为根节点
if (selectedNodeId) {
parentId = selectedNodeId;
}
const newNodeName = prompt("请输入新节点名称:");
if (newNodeName) {
fetch("http://127.0.0.1:5002/api/tree/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: newNodeName,
parent_id: parentId,
}),
})
.then((response) => response.json())
.then((data) => {
alert("节点添加成功");
fetchTreeData();
})
.catch((error) => {
console.error("添加节点失败:", error);
alert("添加节点失败: " + error.message);
});
}
}
// 编辑节点
function editNode() {
if (!selectedNodeId) {
alert("请先选择一个节点");
return;
}
// 查找选中节点的数据
let selectedData;
mainGroup.selectAll(".node").each(function (d) {
if (d && d.data && d.data.id === selectedNodeId) {
selectedData = d.data;
}
});
if (!selectedData) {
alert("找不到选中的节点");
return;
}
const newName = prompt("请输入新的节点名称:", selectedData.name);
if (newName) {
fetch(`http://127.0.0.1:5002/api/tree/${selectedNodeId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: newName,
}),
})
.then((response) => response.json())
.then((data) => {
alert("节点更新成功");
fetchTreeData();
})
.catch((error) => {
console.error("更新节点失败:", error);
alert("更新节点失败: " + error.message);
});
}
}
// 删除节点
function deleteNode() {
if (!selectedNodeId) {
alert("请先选择一个节点");
return;
}
// 查找选中节点的数据
let selectedData;
mainGroup.selectAll(".node").each(function (d) {
if (d && d.data && d.data.id === selectedNodeId) {
selectedData = d.data;
}
});
if (!selectedData) {
alert("找不到选中的节点");
return;
}
const confirmDelete = confirm(
`确定删除节点 "${selectedData.name}" 吗? 子节点将提升至父级。`
);
if (confirmDelete) {
fetch(`http://127.0.0.1:5002/api/tree/${selectedNodeId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
cascade: false,
}),
})
.then((response) => response.json())
.then((data) => {
alert("节点删除成功");
selectedNodeId = null;
fetchTreeData();
})
.catch((error) => {
console.error("删除节点失败:", error);
alert("删除节点失败: " + error.message);
});
}
}
// 注册按钮事件
document.getElementById("add-btn").addEventListener("click", addNode);
document.getElementById("edit-btn").addEventListener("click", editNode);
document
.getElementById("delete-btn")
.addEventListener("click", deleteNode);
document
.getElementById("refresh-btn")
.addEventListener("click", fetchTreeData);
document.getElementById("fit-btn").addEventListener("click", fitView);
// 缩放控制
document.getElementById("zoom-in").addEventListener("click", () => {
svg.transition().call(zoomBehavior.scaleBy, 1.2);
});
document.getElementById("zoom-out").addEventListener("click", () => {
svg.transition().call(zoomBehavior.scaleBy, 0.8);
});
document
.getElementById("zoom-reset")
.addEventListener("click", fitView);
// 初始加载数据
fetchTreeData();
}
</script>
</body>
</html>