Files
2025-04-23 11:21:08 +08:00

1586 lines
52 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>需求树形结构可视化</title>
<script src="/ui/assets/d3.js"></script>
<!-- <script src="https://d3js.org/d3.v7.min.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; /* 防止文本拦截点击事件 */
}
.node-text-line {
font: 14px sans-serif;
text-anchor: middle;
dominant-baseline: middle;
}
.link {
fill: none;
stroke: #cacaca;
stroke-width: 1.5px;
}
.control-panel {
margin-bottom: 20px;
padding: px;
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: 80px;
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;
}
.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;
max-width: 300px;
line-height: 1.5;
}
/* 添加表格样式 */
.tooltip-table {
width: 100%;
border-collapse: collapse;
}
.tooltip-table td {
padding: 2px 5px;
vertical-align: top;
}
.tooltip-table td:first-child {
font-weight: bold;
white-space: nowrap;
}
/* 添加状态标签样式 */
.status-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
margin-left: 5px;
}
.status-pending {
background-color: #faad14;
color: #fff;
}
.status-done {
background-color: #52c41a;
color: #fff;
}
.status-progress {
background-color: #1890ff;
color: #fff;
}
.priority-high {
background-color: #f5222d;
color: #fff;
}
.priority-medium {
background-color: #fa8c16;
color: #fff;
}
.priority-low {
background-color: #52c41a;
color: #fff;
}
</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>
function initD3() {
// 全局变量
console.log("init");
let treeData = [];
let selectedNodeId = null; // 存储选中节点的ID而非DOM元素
let zoomBehavior;
let lastPotentialParent = null; // 跟踪上一个潜在父节点
let treeGroups = []; // 存储每棵树的组引用
let treeOffsets = []; // 存储每棵树的垂直偏移
let singleNodesOffset = 0;
const urlParams = new URLSearchParams(window.location.search);
let docId = urlParams.get("doc_id");
// 安全获取容器尺寸
const container = $node.children[1];
const containerWidth = container ? container.clientWidth : 800; // 默认值
const containerHeight = container ? container.clientHeight : 600; // 默认值
console.log(
"container width",
containerWidth,
"containerHeight",
containerHeight
);
// 设置图表尺寸和边距 - 使用安全值
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("minG1",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)) {
console.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,
description: item.description || "无描述",
parent_id: item.parent_id || "-1",
parent_req_id: item.parent_req_id || "-1",
req_id: item.req_id || "-1",
priority: item.priority || "无",
status: item.status || "无",
type: item.type || "无",
created_at: item.created_at || "无",
updated_at: item.updated_at || "无",
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();
mainGroup.selectAll("*").remove();
const singleNodeTrees = [];
const multiNodeTrees = [];
rootNodes.forEach((rootData) => {
if (!rootData.children || rootData.children.length === 0) {
singleNodeTrees.push(rootData);
} else {
multiNodeTrees.push(rootData);
}
});
mainGroup.selectAll(".single-nodes-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;
// 先渲染多节点树
multiNodeTrees.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})`);
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)}`);
// 转换数据为层次结构
const root = d3.hierarchy(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;
});
const centerOffset = (width * 5) / 4;
// width / 2 - (maxX + minX) / 2+800;
console.log("centerOffset", centerOffset, width, maxX, minX);
// 应用水平居中偏移 - 确保不使用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;
const links = root
.links()
.filter(
(link) =>
!isNaN(link.source.x) &&
!isNaN(link.source.y) &&
!isNaN(link.target.x) &&
!isNaN(link.target.y)
);
// 定义垂直连接线生成器 - 添加防御性检查
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) // 记录树索引
.attr("data-is-single-node", "false");
// 测量文本宽度
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.each(function (d) {
const node = d3.select(this);
const nodeName = d.data.name || "未命名";
const nodeWidth =
Math.max(100, textWidths[d.data.id] || 100) - 68; // 留出边距
// 将文本按宽度拆分成多行
const lines = splitTextIntoLines(nodeName, nodeWidth / 8); // 估算每个字符宽度约8px
// 根据行数调整矩形高度
const lineHeight = 18;
const rectHeight = Math.max(40, lines.length * lineHeight + 10);
// 更新矩形高度
node
.select("rect")
.attr("height", rectHeight)
.attr("y", -rectHeight / 2);
// 添加每一行文本
lines.forEach((line, i) => {
const yPos = -rectHeight / 2 + 15 + i * lineHeight;
node
.append("text")
.attr("class", "node-text-line")
.attr("dy", yPos)
.text(line);
});
});
// 文本分行函数
function splitTextIntoLines(text, maxCharsPerLine) {
if (!text) return [""];
const words = text.split("");
const lines = [];
let currentLine = "";
words.forEach((char) => {
if (currentLine.length >= maxCharsPerLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine += char;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
// 添加悬停事件
nodeGroups
.on("mouseover", (event, d) => {
// 显示工具提示
console.log("mouseover", d);
tooltip.transition().duration(200).style("opacity", 0.9);
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return "未设置";
try {
const date = new Date(dateStr);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch (e) {
return dateStr;
}
};
// 获取状态样式类
const getStatusClass = (status) => {
if (!status) return "";
if (status.includes("待")) return "status-pending";
if (status.includes("完成")) return "status-done";
if (status.includes("进行")) return "status-progress";
return "";
};
// 获取优先级样式类
const getPriorityClass = (priority) => {
if (!priority) return "";
if (priority.includes("高")) return "priority-high";
if (priority.includes("中")) return "priority-medium";
if (priority.includes("低")) return "priority-low";
return "";
};
// 构建HTML表格
const tooltipContent = `
<table class="tooltip-table">
<tr><td>ID:</td><td>${d.data.id || "未设置"}</td></tr>
<tr><td>需求ID:</td><td>${d.data.req_id || "未设置"}</td></tr>
<tr><td>名称:</td><td>${d.data.name || "未命名"}</td></tr>
<tr><td>描述:</td><td>${d.data.description || "无描述"}</td></tr>
<tr><td>类型:</td><td>${d.data.type || "未设置"}</td></tr>
<tr><td>状态:</td><td>
${
d.data.status
? `<span class="status-tag ${getStatusClass(
d.data.status
)}">${d.data.status}</span>`
: ""
}
</td></tr>
<tr><td>优先级:</td><td>
${
d.data.priority
? `<span class="status-tag ${getPriorityClass(
d.data.priority
)}">${d.data.priority}</span>`
: ""
}
</td></tr>
<tr><td>层级:</td><td>${
d.data.level !== undefined ? d.data.level : "未设置"
}</td></tr>
<tr><td>父需求ID:</td><td>${
d.data.parent_req_id || "无"
}</td></tr>
<tr><td>创建时间:</td><td>${formatDate(
d.data.created_at
)}</td></tr>
<tr><td>更新时间:</td><td>${formatDate(
d.data.updated_at
)}</td></tr>
</table>
`;
tooltip
.html(tooltipContent)
.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);
}
});
// 如果有单节点树,创建单节点树区域
if (singleNodeTrees.length > 0) {
// 添加分隔线和标题
const separatorHeight = 50;
// 添加分隔线
mainGroup
.append("line")
.attr("x1", 0)
.attr("y1", totalHeight)
.attr("x2", width)
.attr("y2", totalHeight)
.attr("stroke", "#ccc")
.attr("stroke-dasharray", "5,5");
// 添加单节点区域标题
mainGroup
.append("text")
.attr("x", width / 2)
.attr("y", totalHeight + 30)
.attr("text-anchor", "middle")
.attr("font-weight", "bold")
.text("单节点需求树");
totalHeight += separatorHeight;
// 创建单节点树容器
const singleNodesContainer = mainGroup
.append("g")
.attr("class", "single-nodes-container")
.attr("transform", `translate(0, ${totalHeight})`);
singleNodesOffset = totalHeight;
// 定义网格布局参数
const nodeWidth = 150;
const nodeHeight = 60;
const nodeMargin = 20;
const nodesPerRow = Math.floor(
(width - 100) / (nodeWidth + nodeMargin)
);
// 创建单节点树网格
singleNodeTrees.forEach((nodeData, index) => {
// 计算网格位置
const row = Math.floor(index / nodesPerRow);
const col = index % nodesPerRow;
const x = 50 + col * (nodeWidth + nodeMargin) + nodeWidth / 2;
const y = row * (nodeHeight + nodeMargin) + nodeHeight / 2;
const hierarchyData = d3.hierarchy(nodeData);
// 创建节点组
const nodeGroup = singleNodesContainer
.append("g")
.datum(hierarchyData)
.attr(
"class",
nodeData.id === selectedNodeId
? "node node--selected"
: "node"
)
.attr("transform", `translate(${x}, ${y})`)
.attr("data-id", nodeData.id)
.attr("data-tree-index", multiNodeTrees.length + index) // 使用不同的索引范围
.attr("data-is-single-node", "true");
// 添加节点矩形
nodeGroup
.append("rect")
.attr("width", nodeWidth)
.attr("height", nodeHeight)
.attr("x", -nodeWidth / 2)
.attr("y", -nodeHeight / 2)
.attr("rx", 5) // 圆角
.attr("ry", 5); // 圆角
// 添加节点文本
nodeGroup.each(function (d) {
const node = d3.select(this);
const nodeName = d.data.name || "未命名";
const maxWidth = nodeWidth - 80; // 留出边距
console.log("max", maxWidth);
// 将文本按宽度拆分成多行
const lines = splitTextIntoLines(nodeName, maxWidth / 8);
// 根据行数调整矩形高度
const lineHeight = 18;
const rectHeight = Math.max(
nodeHeight,
lines.length * lineHeight + 10
);
// 更新矩形高度
node
.select("rect")
.attr("height", rectHeight)
.attr("y", -rectHeight / 2);
// 添加每一行文本
lines.forEach((line, i) => {
const yPos = -rectHeight / 2 + 15 + i * lineHeight;
node
.append("text")
.attr("class", "node-text-line")
.attr("dy", yPos)
.text(line);
});
});
function splitTextIntoLines(text, maxCharsPerLine) {
if (!text) return [""];
const words = text.split("");
const lines = [];
let currentLine = "";
words.forEach((char) => {
if (currentLine.length >= maxCharsPerLine) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine += char;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
// 添加与多节点树相同的悬停事件处理
nodeGroup
.on("mouseover", (event, d) => {
// 显示工具提示
console.log("singled", d);
tooltip.transition().duration(200).style("opacity", 0.9);
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return "未设置";
try {
const date = new Date(dateStr);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch (e) {
return dateStr;
}
};
// 获取状态样式类
const getStatusClass = (status) => {
if (!status) return "";
if (status.includes("待")) return "status-pending";
if (status.includes("完成")) return "status-done";
if (status.includes("进行")) return "status-progress";
return "";
};
// 获取优先级样式类
const getPriorityClass = (priority) => {
if (!priority) return "";
if (priority.includes("高")) return "priority-high";
if (priority.includes("中")) return "priority-medium";
if (priority.includes("低")) return "priority-low";
return "";
};
// 构建HTML表格
const tooltipContent = `
<table class="tooltip-table">
<tr><td>ID:</td><td>${d.data.id || "未设置"}</td></tr>
<tr><td>需求ID:</td><td>${d.data.req_id || "未设置"}</td></tr>
<tr><td>名称:</td><td>${d.data.name || "未命名"}</td></tr>
<tr><td>描述:</td><td>${d.data.description || "无描述"}</td></tr>
<tr><td>类型:</td><td>${d.data.type || "未设置"}</td></tr>
<tr><td>状态:</td><td>
${
d.data.status
? `<span class="status-tag ${getStatusClass(
d.data.status
)}">${d.data.status}</span>`
: ""
}
</td></tr>
<tr><td>优先级:</td><td>
${
d.data.priority
? `<span class="status-tag ${getPriorityClass(
d.data.priority
)}">${d.data.priority}</span>`
: ""
}
</td></tr>
<tr><td>层级:</td><td>${
d.data.level !== undefined ? d.data.level : "未设置"
}</td></tr>
<tr><td>父需求ID:</td><td>${
d.data.parent_req_id || "无"
}</td></tr>
<tr><td>创建时间:</td><td>${formatDate(
d.data.created_at
)}</td></tr>
<tr><td>更新时间:</td><td>${formatDate(
d.data.updated_at
)}</td></tr>
</table>
`;
tooltip
.html(tooltipContent)
.style("left", event.pageX + 10 + "px")
.style("top", event.pageY - 28 + "px");
})
.on("mouseout", () => {
// 隐藏工具提示
tooltip.transition().duration(500).style("opacity", 0);
});
// 添加单节点的拖拽功能
setupDrag(nodeGroup);
});
// 更新总高度,以便正确计算画布大小
const rowCount = Math.ceil(singleNodeTrees.length / nodesPerRow);
const singleNodesHeight =
rowCount * (nodeHeight + nodeMargin) + nodeMargin;
totalHeight += singleNodesHeight;
}
// 自动适应视图
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;
tooltip.transition().duration(100).style("opacity", 0);
}
// 拖拽中
function dragged(event, d) {
if (!event) return;
tooltip.transition().style("opacity", 0);
const isSingleNode =
d3.select(this).attr("data-is-single-node") === "true";
console.log(d3.select(this).attr("transform"));
if (isSingleNode) {
// 修复单节点拖拽坐标计算
// 获取当前节点的变换矩阵
const transform = d3.select(this).attr("transform");
const matches = transform.match(/translate\(([^,]+),([^)]+)\)/);
if (matches) {
// 解析当前位置
const currentX = parseFloat(matches[1]);
const currentY = parseFloat(matches[2]);
// 计算新位置
const newX = currentX + event.dx;
const newY = currentY + event.dy;
// 更新位置
d3.select(this).attr("transform", `translate(${newX}, ${newY})`);
// 存储拖拽偏移量用于后续计算
d.dx = event.dx;
d.dy = event.dy;
// 存储绝对位置用于findClosestNode
d.draggedX = newX;
d.draggedY = newY;
}
} else {
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 isSingleNode =
d3
.select(event.sourceEvent.target.parentNode)
.attr("data-is-single-node") === "true";
console.log(isSingleNode, sourceNode.draggedX, sourceNode.draggedY);
let draggedX, draggedY;
if (
isSingleNode &&
sourceNode.draggedX !== undefined &&
sourceNode.draggedY !== undefined
) {
// 使用存储的绝对位置
console.log("single");
draggedX = sourceNode.draggedX;
draggedY = sourceNode.draggedY;
// 对于单节点,需要考虑单节点容器的偏移
const singleNodesContainer = mainGroup.select(
".single-nodes-container"
);
if (singleNodesContainer.size() > 0) {
const containerTransform = singleNodesContainer.attr("transform");
if (containerTransform) {
const matches = containerTransform.match(
/translate\(([^,]+),([^)]+)\)/
);
if (matches) {
// 从变换矩阵中提取Y偏移
const containerY = parseFloat(matches[2]);
if (!isNaN(containerY)) {
draggedY += containerY;
}
}
}
}
} else {
// 获取当前拖拽节点的全局坐标
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;
draggedX = srcX + srcDx;
draggedY = srcY + srcDy + treeOffset;
}
console.log("draggedX:", draggedX, "draggedY:", draggedY);
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,
};
}
});
});
const singleNodesContainer = mainGroup.select(
".single-nodes-container"
);
// console.log("singleNodesContainer", singleNodesContainer);
if (singleNodesContainer.size() > 0) {
// 获取单节点区域的垂直偏移
// console.log("singlenodecontainer", singleNodesContainer);
singleNodesContainer.selectAll(".node").each(function (d) {
// console.log("d",d)
// 防御性检查
if (!d || !d.data) return;
// 跳过源节点
if (d === sourceNode || d.data.id === sourceNode.data.id) return;
// 获取节点全局坐标
const nodeTransform = d3.select(this).attr("transform");
// console.log("nodeTransform", nodeTransform)
if (!nodeTransform) return;
const matches = nodeTransform.match(
/translate\(([^,]+),([^)]+)\)/
);
if (!matches) return;
let nodeX = parseFloat(matches[1]);
let nodeY = parseFloat(matches[2]) + singleNodesOffset;
// 确保坐标有效
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,
};
}
});
}
console.log("closestNode", closestNode);
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/demand/${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 {
console.log("docId", docId);
const response = await fetch(
`http://127.0.0.1:5002/api/demand/${docId}`
);
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("请输入新节点名称:");
const newNodeDesc = prompt("请输入节点描述:");
if (newNodeName) {
fetch(`http://127.0.0.1:5002/api/demand/${docId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: newNodeName,
description: newNodeDesc || "",
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);
const newDesc = prompt(
"请输入新的节点描述:",
selectedData.description || ""
);
if (newName) {
fetch(`http://127.0.0.1:5002/api/demand/${selectedNodeId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: newName,
description: newDesc,
}),
})
.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/demand/${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);
});
}
}
// 注册按钮事件
$node.children[0].children[0].addEventListener("click", addNode);
$node.children[0].children[1].addEventListener("click", editNode);
$node.children[0].children[2].addEventListener("click", deleteNode);
$node.children[0].children[3].addEventListener("click", fetchTreeData);
$node.children[0].children[4].addEventListener("click", fitView);
// 缩放控制
$node.children[3].children[0].addEventListener("click", () => {
svg.transition().call(zoomBehavior.scaleBy, 1.2);
});
$node.children[3].children[1].addEventListener("click", () => {
svg.transition().call(zoomBehavior.scaleBy, 0.8);
});
$node.children[3].children[2].addEventListener("click", fitView);
// 初始加载数据
fetchTreeData();
}
initD3();
//document.addEventListener("DOMContentLoaded", initD3);
//setTimeout(initD3, 2000);
</script>
</body>
</html>