You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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)
}

问题根源

  1. 数据绑定依赖数组索引:D3默认用数组索引匹配数据与DOM元素,删除非末尾元素后,剩余元素的索引重新排列,导致数据与旧DOM元素错误绑定。
  2. 文本路径绑定依赖索引生成的ID:链路文本的xlink:href绑定的是#path+i,索引变化后,文本会绑定到错误的路径上,造成显示异常。
  3. 删除链路的逻辑有漏洞:初始链路的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 ||
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.02 09:36:10