D3 v7 merge模式删除节点后链路文本绑定错误问题求助
D3 v7力导向图删除非末尾节点后链路文本显示异常问题
问题描述
删除非末尾节点后,链路与节点的绑定关系断裂,链路文本显示错误或重叠。例如删除节点3(数组索引2)及对应链路3---1(数组索引1)后,重绘的链路文本误显示为节点5被删除。
操作流程:点击节点获取id,通过对比source.id找到对应链路和节点的数组索引,使用splice删除后调用restart()重绘。
原始代码
HTML
<!DOCTYPE html> <html lang="de"> <head> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0"> <meta charset="utf-8"> <!-- jQuery --> <script src="https://code.jquery.com/jquery-3.6.3.js"></script> <!-- D3 --> <script src="https://d3js.org/d3.v7.min.js"></script> <!-- fontawesome stylesheet https://fontawesome.com/ --> <script src="https://kit.fontawesome.com/98a5e27706.js" crossorigin="anonymous"></script> </head> <body> </body> </html>
CSS
.link { stroke: #000; stroke-width: 1.5px; } .nodes { fill: whitesmoke; } .buttons { margin: 0 1em 0 0; }
JavaScript
var data = { "nodes": [{ "id": 1 }, { "id": 2, }, { "id": 3, }, { "id": 4, }, { "id": 5, } ], "links": [{ "source": 2, "target": 1, "text": "2 --- 1" }, { "source": 3, "target": 1, "text": "3 --- 1" }, { "source": 4, "target": 1, "text": "4 --- 1" }, { "source": 5, "target": 1, "text": "5 --- 1" } ] }; let nodes = data.nodes let links = data.links //Helper let nodeToDelete var width = window.innerWidth, height = window.innerHeight; var buttons = d3.select("body").selectAll("button") .data(["add node", "remove node"]) .enter() .append("button") .attr("class", "buttons") .text(function (d) { return d; }) var svg = d3.select("body").append("svg") .attr("width", width) .attr("height", height) .call(d3.zoom().on("zoom", function (event) { svg.attr("transform", event.transform) })) .append("g") var simulation = d3.forceSimulation() .force("size", d3.forceCenter(width / 2, height / 2)) .force("charge", d3.forceManyBody().strength(-5000)) .force("link", d3.forceLink().id(function (d) { return d.id }).distance(250)) linksContainer = svg.append("g").attr("class", "linkscontainer") nodesContainer = svg.append("g").attr("class", "nodesContainer") console.log("links_on_init", links) console.log("nodes_on_init", nodes) restart() simulation .nodes(nodes) .on("tick", tick) simulation .force("link").links(links) function tick() { linkLine.attr("d", function (d) { var dx = (d.target.x - d.source.x), dy = (d.target.y - d.source.y), dr = Math.sqrt(dx * dx + dy * dy) return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y; }) node .attr("transform", d => `translate(${d.x}, ${d.y})`); } function dragStarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragEnded(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; } buttons.on("click", function (_, d) { if (d === "add node") { const newObj = { "id": nodes.length + 1,} const newLink = {"source": nodes.length + 1, "target": 1, "text": nodes.length + 1 + " --- " + "1"} nodes.push(newObj) links.push(newLink) } else if (d === "remove node") { if (nodeToDelete != undefined) { let linkToDeleteIndex = links.findIndex(obj => obj.source.id === nodeToDelete.id ) let nodeToDeleteIndex = nodes.findIndex(obj => obj.id === nodeToDelete.id) links.splice(linkToDeleteIndex, 1) nodes.splice(nodeToDeleteIndex, 1) console.log("links_after_removal", links) console.log("nodes_after_removal", nodes) } } restart() }) function restart() { // Update linkLines linkLine = linksContainer.selectAll(".linkPath") .data(links) linkLine.exit().remove() const linkLineEnter = linkLine.enter() .append("path") .attr("class", "linkPath") .attr("stroke", "red") .attr("fill", "transparent") .attr("stroke-width", 3) .attr("id", function (_, i) { return "path" + i }) linkLine = linkLineEnter.merge(linkLine) // Update linkText linkText = linksContainer.selectAll("linkLabel") .data(links) linkText.exit().remove() const linkTextEnter = linkText.enter() .append("text") .attr("dy", -10) .attr("class", "linkLabel") .attr("id", function (d, i) { return "linkLabel" + i }) .attr("text-anchor", "middle") .text("") linkTextEnter.append("textPath") .attr("xlink:href", function (_, i) { return "#path" + i }) .attr("startOffset", "50%") .attr("opacity", 0.75) .attr("cursor", "default") .attr("class", "linkText") .attr("color", "black") .text(function (d) { return d.text }) linkText = linkTextEnter.merge(linkText) // Update nodes node = nodesContainer.selectAll(".nodes") .data(nodes) node.exit().remove() const nodesEnter = node.enter() .append("g") .attr("class", "nodes") .call(d3.drag() .on("start", dragStarted) .on("drag", dragged) .on("end", dragEnded) ) nodesEnter.selectAll("circle") .data(d => [d]) .enter() .append("circle") .style("stroke", "blue") .attr("r", 40) .on("click", function(_, d) { d3.selectAll("circle") .attr("fill", "whitesmoke") d3.select(this) .attr("fill", "red") nodeToDelete = d }) nodesEnter.append("text") .attr("dominant-baseline", "central") .attr("text-anchor", "middle") .attr("font-size", 20) .attr("fill", "black") .attr("pointer-events", "none") .text(function (d) { return d.id }) node = nodesEnter.merge(node) // Update and restart the simulation. simulation .nodes(nodes); simulation .force("link") .links(links) simulation.restart().alpha(1) }
问题根源
- 数据绑定依赖数组索引:D3默认用数组索引匹配数据与DOM元素,删除非末尾元素后,剩余元素的索引重新排列,导致数据与旧DOM元素错误绑定。
- 文本路径绑定依赖索引生成的ID:链路文本的
xlink:href绑定的是#path+i,索引变化后,文本会绑定到错误的路径上,造成显示异常。 - 删除链路的逻辑有漏洞:初始链路的
source是数字ID,模拟运行后D3会将其替换为节点对象,原代码只判断obj.source.id,会匹配不到初始状态的链路。
修复方案
1. 为数据绑定添加唯一Key函数
修改节点、链路路径、链路文本的数据绑定,使用唯一标识代替数组索引:
// 节点绑定 node = nodesContainer.selectAll(".nodes") .data(nodes, d => d.id) // 用节点ID作为唯一Key // 链路路径绑定 linkLine = linksContainer.selectAll(".linkPath") .data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`) // 用source+target的ID组合作为唯一Key // 链路文本绑定 linkText = linksContainer.selectAll(".linkLabel") .data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`)
2. 重构路径ID生成逻辑
不再用数组索引生成路径ID,改用链路的source和target ID组合,确保ID唯一且不随索引变化:
// 生成链路路径ID .attr("id", function (d) { const sourceId = typeof d.source === 'object' ? d.source.id : d.source; const targetId = typeof d.target === 'object' ? d.target.id : d.target; return `path-${sourceId}-${targetId}` }) // 文本路径绑定href .attr("xlink:href", function (d) { const sourceId = typeof d.source === 'object' ? d.source.id : d.source; const targetId = typeof d.target === 'object' ? d.target.id : d.target; return `#path-${sourceId}-${targetId}` })
3. 修复删除链路的判断逻辑
兼容source为数字ID和节点对象两种情况:
let linkToDeleteIndex = links.findIndex(obj => { const sourceId = typeof obj.source === 'object' ? obj.source.id : obj.source; return sourceId === nodeToDelete.id; })
修复后的完整JavaScript代码
var data = { "nodes": [{ "id": 1 }, { "id": 2, }, { "id": 3, }, { "id": 4, }, { "id": 5, } ], "links": [{ "source": 2, "target": 1, "text": "2 --- 1" }, { "source": 3, "target": 1, "text": "3 --- 1" }, { "source": 4, "target": 1, "text": "4 --- 1" }, { "source": 5, "target": 1, "text": "5 --- 1" } ] }; let nodes = data.nodes let links = data.links //Helper let nodeToDelete var width = window.innerWidth, height = window.innerHeight; var buttons = d3.select("body").selectAll("button") .data(["add node", "remove node"]) .enter() .append("button") .attr("class", "buttons") .text(function (d) { return d; }) var svg = d3.select("body").append("svg") .attr("width", width) .attr("height", height) .call(d3.zoom().on("zoom", function (event) { svg.attr("transform", event.transform) })) .append("g") var simulation = d3.forceSimulation() .force("size", d3.forceCenter(width / 2, height / 2)) .force("charge", d3.forceManyBody().strength(-5000)) .force("link", d3.forceLink().id(function (d) { return d.id }).distance(250)) linksContainer = svg.append("g").attr("class", "linkscontainer") nodesContainer = svg.append("g").attr("class", "nodesContainer") console.log("links_on_init", links) console.log("nodes_on_init", nodes) restart() simulation .nodes(nodes) .on("tick", tick) simulation .force("link").links(links) function tick() { linkLine.attr("d", function (d) { var dx = (d.target.x - d.source.x), dy = (d.target.y - d.source.y), dr = Math.sqrt(dx * dx + dy * dy) return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y; }) node .attr("transform", d => `translate(${d.x}, ${d.y})`); } function dragStarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragEnded(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; } buttons.on("click", function (_, d) { if (d === "add node") { const newObj = { "id": nodes.length + 1,} const newLink = {"source": nodes.length + 1, "target": 1, "text": nodes.length + 1 + " --- " + "1"} nodes.push(newObj) links.push(newLink) } else if (d === "remove node") { if (nodeToDelete != undefined) { // 修复链路查找逻辑 let linkToDeleteIndex = links.findIndex(obj => { const sourceId = typeof obj.source === 'object' ? obj.source.id : obj.source; return sourceId === nodeToDelete.id; }) let nodeToDeleteIndex = nodes.findIndex(obj => obj.id === nodeToDelete.id) links.splice(linkToDeleteIndex, 1) nodes.splice(nodeToDeleteIndex, 1) console.log("links_after_removal", links) console.log("nodes_after_removal", nodes) } } restart() }) function restart() { // Update linkLines - 添加key函数 linkLine = linksContainer.selectAll(".linkPath") .data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`) linkLine.exit().remove() const linkLineEnter = linkLine.enter() .append("path") .attr("class", "linkPath") .attr("stroke", "red") .attr("fill", "transparent") .attr("stroke-width", 3) // 修复路径ID生成逻辑 .attr("id", function (d) { const sourceId = typeof d.source === 'object' ? d.source.id : d.source; const targetId = typeof d.target === 'object' ? d.target.id : d.target; return `path-${sourceId}-${targetId}` }) linkLine = linkLineEnter.merge(linkLine) // Update linkText - 添加key函数 linkText = linksContainer.selectAll(".linkLabel") .data(links, d => `${d.source.id || d.source}-${d.target.id ||
相关产品推荐
相关产品推荐

