D3.js突出显示相关节点/链接



>问题:我想根据链接类型淡化/突出显示整个依赖链。

为此,我利用 mouseEnter 事件,该事件当前存储所有链接和节点。此外,我淡化了所有节点和链接,只突出显示那些过滤为相关节点和链接的节点。它需要再次检查所有相关节点和链接,如果它们也有来自类型need的连接。只要找到依赖关系连接,就必须执行此操作。我无法找出一个合适的算法。

例子:

为了更好地理解,我创建了一个啤酒成分依赖关系,它看起来像一个星星。出于这些目的,我的版本很好。但是第二个链条,关于汽车 ->车轮 ->轮胎 ->橡胶和收音机让我头疼。无线电是一个"use"依赖关系,意味着它对链不是强制性的,不应该被突出显示。

预期成果:

如果光标超过car则应突出显示所有具有"需要"依赖项的连接节点,其余节点应淡出。

对于那些想帮助我的人,如果有什么不清楚的地方,请不要犹豫。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- d3.js framework -->
<script src="https://d3js.org/d3.v6.js"></script>
<!-- fontawesome stylesheet https://fontawesome.com/ -->
<script src="https://kit.fontawesome.com/39094309d6.js" crossorigin="anonymous"></script>
</head>
<style>
body {
height: 100%;
background: #e6e7ee;
overflow: hidden;
margin: 0px;
}
.faded {
opacity: 0.1;
transition: 0.3s opacity;
}
.highlight {
opacity: 1;
}
</style>
<body>
<svg id="svg"></svg>
<script>
var graph = {
"nodes": [
{
"id": 0,
"name": "beer",
},
{
"id": 1,
"name": "water",
},
{
"id": 2,
"name": "hop",
},
{
"id": 3,
"name": "malt",
},
{
"id": 4,
"name": "yeast",
},
{
"id": 10,
"name": "car",
},
{
"id": 11,
"name": "wheels",
},
{
"id": 12,
"name": "tires",
},
{
"id": 13,
"name": "rubber",
},
{
"id": 14,
"name": "radio",
}
],
"links": [
{
"source": 0,
"target": 1,
"type": "need"
},
{
"source": 0,
"target": 2,
"type": "need"
},
{
"source": 0,
"target": 3,
"type": "need"
},
{
"source": 0,
"target": 4,
"type": "need"
},
{
"source": 10,
"target": 11,
"type": "need"
},
{
"source": 11,
"target": 12,
"type": "need"
},
{
"source": 12,
"target": 13,
"type": "need"
},
{
"source": 10,
"target": 14,
"type": "use"
}
]
}
var svg = d3.select("svg")
.attr("class", "canvas")
.attr("width", window.innerWidth)
.attr("height", window.innerHeight)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
// append markers to svg
svg.append("defs").append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "-0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("orient", "auto")
.attr("markerWidth", 50)
.attr("markerHeight", 50)
.attr("xoverflow", "visible")
.append("svg:path")
.attr("d", "M 0,-1 L 2 ,0 L 0,1")
.attr("fill", "black")
.style("stroke", "none")
var linksContainer = svg.append("g").attr("class", linksContainer)
var nodesContainer = svg.append("g").attr("class", nodesContainer)
var force = d3.forceSimulation()
.force("link", d3.forceLink().id(function (d) {
return d.id
}).distance(80))
.force("charge", d3.forceManyBody().strength(-100))
.force("center", d3.forceCenter(window.innerWidth / 2, window.innerHeight / 2))
.force("collision", d3.forceCollide().radius(90))
initialize()
function initialize() {
link = linksContainer.selectAll(".link")
.data(graph.links)
.join("line")
.attr("class", "link")
.attr('marker-end', 'url(#arrowhead)')
.style("display", "block")
.style("stroke", "black")
.style("stroke-width", 1)
linkPaths = linksContainer.selectAll(".linkPath")
.data(graph.links)
.join("path")
.style("pointer-events", "none")
.attr("class", "linkPath")
.attr("fill-opacity", 1)
.attr("stroke-opacity", 1)
.attr("id", function (d, i) { return "linkPath" + i })
.style("display", "block")
linkLabels = linksContainer.selectAll(".linkLabel")
.data(graph.links)
.join("text")
.style("pointer-events", "none")
.attr("class", "linkLabel")
.attr("id", function (d, i) { return "linkLabel" + i })
.attr("font-size", 16)
.attr("fill", "black")
.text("")
linkLabels
.append("textPath")
.attr('xlink:href', function (d, i) { return '#linkPath' + i })
.style("text-anchor", "middle")
.style("pointer-events", "none")
.attr("startOffset", "50%")
.text(function (d) { return d.type })
node = nodesContainer.selectAll(".node")
.data(graph.nodes, d => d.id)
.join("g")
.attr("class", "node")
.call(d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
node.selectAll("circle")
.data(d => [d])
.join("circle")
.attr("r", 30)
.style("fill", "whitesmoke")
.on("mouseenter", mouseEnter)
.on("mouseleave", mouseLeave)
node.selectAll("text")
.data(d => [d])
.join("text")
.style("class", "icon")
.attr("font-family", "FontAwesome")
.attr("dominant-baseline", "central")
.attr("text-anchor", "middle")
.attr("font-size", 20)
.attr("fill", "black")
.attr("pointer-events", "none")
.attr("dy", "-1em")
.text(function (d) {
return d.name
})
node.append("text")
.attr("dominant-baseline", "central")
.attr("text-anchor", "middle")
.attr("font-size", 13)
.attr("fill", "black")
.attr("pointer-events", "none")
.attr("dy", "0.5em")
.text(function (d) {
return d.id
})
force
.nodes(graph.nodes)
.on("tick", ticked);
force
.force("link")
.links(graph.links)
}
function mouseEnter(event, d) {
const selNodes = node.selectAll("circle")
const selLink = link
const selLinkLabel = linkLabels
const selText = node.selectAll("text")
const related = []
const relatedLinks = []
related.push(d)
force.force('link').links().forEach((link) => {
if (link.source === d || link.target === d) {
relatedLinks.push(link)
if (related.indexOf(link.source) === -1) { related.push(link.source) }
if (related.indexOf(link.target) === -1) { related.push(link.target) }
}
})
selNodes.classed('faded', true)
selNodes.filter((dNodes) => related.indexOf(dNodes) > -1)
.classed('highlight', true)
selLink.classed('faded', true)
selLink.filter((dLink) => dLink.source === d || dLink.target === d)
.classed('highlight', true)
selLinkLabel.classed('faded', true)
selLinkLabel.filter((dLinkLabel) => dLinkLabel.source === d || dLinkLabel.target === d)
.classed('highlight', true)
selText.classed('faded', true)
selText.filter((dText) => related.indexOf(dText) > -1)
.classed('highlight', true)

force.alphaTarget(0.0001).restart()
}
function mouseLeave(event, d) {
const selNodes = node.selectAll("circle")
const selLink = link
const selLinkLabel = linkLabels
const selText = node.selectAll("text")
selNodes.classed('faded', false)
selNodes.classed('highlight', false)
selLink.classed('faded', false)
selLink.classed('highlight', false)
selLinkLabel.classed('faded', false)
selLinkLabel.classed('highlight', false)
selText.classed('faded', false)
selText.classed('highlight', false)

force.restart()
}
function ticked() {
// update link positions
link
.attr("x1", function (d) {
return d.source.x;
})
.attr("y1", function (d) {
return d.source.y;
})
.attr("x2", function (d) {
return d.target.x;
})
.attr("y2", function (d) {
return d.target.y;
});
// update node positions
node
.attr("transform", function (d) {
return "translate(" + d.x + ", " + d.y + ")";
});
linkPaths.attr('d', function (d) {
return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y;
});
linkLabels.attr('transform', function (d) {
if (d.target.x < d.source.x) {
var bbox = this.getBBox();
rx = bbox.x + bbox.width / 2;
ry = bbox.y + bbox.height / 2;
return 'rotate(180 ' + rx + ' ' + ry + ')';
}
else {
return 'rotate(0)';
}
});
}
function dragStarted(event, d) {
if (!event.active) force.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
PosX = d.x
PosY = d.y
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragEnded(event, d) {
if (!event.active) force.alphaTarget(0);
d.fx = undefined;
d.fy = undefined;
}
</script>
</body>
</html>

您可以查看几个问题来运行淡入淡出/突出显示:


首先,请注意force方法在graph中改变了links数组。

例如,您的第一个链接是这样开始的:

{
"source": 0,
"target": 1,
"type": "need"
}

但变成这样:

{
"index": 0
"source": {
"id": 0
"index": 0
"name": "beer"
"vx": 0.036971029563580046
"vy": 0.04369386654517388
"x": 394.1514674087123
"y": 220.18458726626062
},
"target": {
"id": 1
"index": 1
"name": "water"
"vx": -0.021212609689083086
"vy": 0.01105162589441528
"x": 568.911363724937
"y": 177.07991527420614
},
"type": "need"
}

所以你需要一个递归函数,但如果你引用link.source,你会得到空数组 - 相反,你需要引用link.source.id因为这就是force根据上面的例子更新你的图形对象的方式。

这是一个相当冗长的递归函数,它返回给定节点 id 的所有节点和链接,这些节点和链接由 givetype的链接链接:

function nodesByTypeAfterForce(nodeId, sieved, type) {
// get the links for the node per the type
const newLinks = graph.links
.filter(link => link.type === type && link.source.id === nodeId);
// get the linked nodes to nodeId from the links 
const newNodes = newLinks
.map(link => graph.nodes.find(newNode => newNode.id === link.target.id));
// concatenate new nodes and links
(sieved.links = sieved.links || []).push(...newLinks);
(sieved.nodes = sieved.nodes || []).push(...newNodes);
// recursively visit linked nodes until exhausted options
newNodes.forEach(node => nodesByTypeAfterForce(node.id, sieved, type));
// return indices relevant nodes and links
return {
nodes: sieved.nodes.map(node => node.index),
links: sieved.links.map(link => link.index)
};
}

请注意,该函数返回一个index数组,该数组是分配给每个节点和链接force属性。这使得淡入淡出/高亮的过滤在以后更加明确。


现在,在mouseEnter中,你可以从调用这个函数开始,只返回通过某个type的链接链接在一起的节点,并传递d来初始化搜索:

function mouseEnter(event, d) {
// sub graph for the hovered node
const sieved = nodesByTypeAfterForce(d.id, {nodes: [d]}, "need");
//...
}

这取代了related数组的构造,该数组在您的 OP 中与type无关。虽然这很容易解决,但您当前mouseEnter中的另一个问题是您有这些行。

selLink
.filter((dLink) => dLink.source === d || dLink.target === d)

selLinkLabel
.filter((dLinkLabel) => dLinkLabel.source === d || dLinkLabel.target === d)
.classed('highlight', true)

这会导致链接(和链接标签)突出显示到任何链接的节点,而不仅仅是通过type链接的节点(例如need)。

因此,我建议您用此代码块替换(我移动了所有行以将所有内容淡入自己的部分):

// only highlight from sieved
node.selectAll("circle")
.filter(node => sieved.nodes.indexOf(node.index) > -1)
.classed('highlight', true)
link
.filter(link => sieved.links.indexOf(link.index) > -1)
.classed('highlight', true)
linkLabels
.filter(link => sieved.links.indexOf(link.index) > -1)
.classed('highlight', true)
node.selectAll("text")
.filter(node => sieved.nodes.indexOf(node.index) > -1)
.classed('highlight', true)

现在仅根据从上面的nodeByTypeAfterForce函数返回的index突出显示节点和链接。


下面是工作示例,其中nodeByTypeAfterForce只是在定义graph之后放入,唯一的其他编辑是mouseEnter

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- d3.js framework -->
<script src="https://d3js.org/d3.v6.js"></script>
<!-- fontawesome stylesheet https://fontawesome.com/ -->
<script src="https://kit.fontawesome.com/39094309d6.js" crossorigin="anonymous"></script>
</head>
<style>
body {
height: 100%;
background: #e6e7ee;
overflow: hidden;
margin: 0px;
}
.faded {
opacity: 0.1;
transition: 0.3s opacity;
}
.highlight {
opacity: 1;
}
</style>
<body>
<svg id="svg"></svg>
<script>
var graph = {
"nodes": [
{
"id": 0,
"name": "beer",
},
{
"id": 1,
"name": "water",
},
{
"id": 2,
"name": "hop",
},
{
"id": 3,
"name": "malt",
},
{
"id": 4,
"name": "yeast",
},
{
"id": 10,
"name": "car",
},
{
"id": 11,
"name": "wheels",
},
{
"id": 12,
"name": "tires",
},
{
"id": 13,
"name": "rubber",
},
{
"id": 14,
"name": "radio",
}
],
"links": [
{
"source": 0,
"target": 1,
"type": "need"
},
{
"source": 0,
"target": 2,
"type": "need"
},
{
"source": 0,
"target": 3,
"type": "need"
},
{
"source": 0,
"target": 4,
"type": "need"
},
{
"source": 10,
"target": 11,
"type": "need"
},
{
"source": 11,
"target": 12,
"type": "need"
},
{
"source": 12,
"target": 13,
"type": "need"
},
{
"source": 10,
"target": 14,
"type": "use"
}
]
}

function nodesByTypeAfterForce(nodeId, sieved, type) {
// get the links for the node per the type
const newLinks = graph.links
.filter(link => link.type === type && link.source.id === nodeId);
// get the linked nodes to nodeId from the links 
const newNodes = newLinks
.map(link => graph.nodes.find(newNode => newNode.id === link.target.id));
// concatenate new nodes and links
(sieved.links = sieved.links || []).push(...newLinks);
(sieved.nodes = sieved.nodes || []).push(...newNodes);
// recursively visit linked nodes until exhausted options
newNodes.forEach(node => nodesByTypeAfterForce(node.id, sieved, type));
// return indices relevant nodes and links
return {
nodes: sieved.nodes.map(node => node.index),
links: sieved.links.map(link => link.index)
};
}
var svg = d3.select("svg")
.attr("class", "canvas")
.attr("width", window.innerWidth)
.attr("height", window.innerHeight)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
// append markers to svg
svg.append("defs").append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "-0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("orient", "auto")
.attr("markerWidth", 50)
.attr("markerHeight", 50)
.attr("xoverflow", "visible")
.append("svg:path")
.attr("d", "M 0,-1 L 2 ,0 L 0,1")
.attr("fill", "black")
.style("stroke", "none")
var linksContainer = svg.append("g").attr("class", linksContainer)
var nodesContainer = svg.append("g").attr("class", nodesContainer)
var force = d3.forceSimulation()
.force("link", d3.forceLink().id(function (d) {
return d.id
}).distance(80))
.force("charge", d3.forceManyBody().strength(-100))
.force("center", d3.forceCenter(window.innerWidth / 2, window.innerHeight / 2))
.force("collision", d3.forceCollide().radius(90))
initialize()
function initialize() {
link = linksContainer.selectAll(".link")
.data(graph.links)
.join("line")
.attr("class", "link")
.attr('marker-end', 'url(#arrowhead)')
.style("display", "block")
.style("stroke", "black")
.style("stroke-width", 1)
linkPaths = linksContainer.selectAll(".linkPath")
.data(graph.links)
.join("path")
.style("pointer-events", "none")
.attr("class", "linkPath")
.attr("fill-opacity", 1)
.attr("stroke-opacity", 1)
.attr("id", function (d, i) { return "linkPath" + i })
.style("display", "block")
linkLabels = linksContainer.selectAll(".linkLabel")
.data(graph.links)
.join("text")
.style("pointer-events", "none")
.attr("class", "linkLabel")
.attr("id", function (d, i) { return "linkLabel" + i })
.attr("font-size", 16)
.attr("fill", "black")
.text("")
linkLabels
.append("textPath")
.attr('xlink:href', function (d, i) { return '#linkPath' + i })
.style("text-anchor", "middle")
.style("pointer-events", "none")
.attr("startOffset", "50%")
.text(function (d) { return d.type })
node = nodesContainer.selectAll(".node")
.data(graph.nodes, d => d.id)
.join("g")
.attr("class", "node")
.call(d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
node.selectAll("circle")
.data(d => [d])
.join("circle")
.attr("r", 30)
.style("fill", "whitesmoke")
.on("mouseenter", mouseEnter)
.on("mouseleave", mouseLeave)
node.selectAll("text")
.data(d => [d])
.join("text")
.style("class", "icon")
.attr("font-family", "FontAwesome")
.attr("dominant-baseline", "central")
.attr("text-anchor", "middle")
.attr("font-size", 20)
.attr("fill", "black")
.attr("pointer-events", "none")
.attr("dy", "-1em")
.text(function (d) {
return d.name
})
node.append("text")
.attr("dominant-baseline", "central")
.attr("text-anchor", "middle")
.attr("font-size", 13)
.attr("fill", "black")
.attr("pointer-events", "none")
.attr("dy", "0.5em")
.text(function (d) {
return d.id
})
force
.nodes(graph.nodes)
.on("tick", ticked);
force
.force("link")
.links(graph.links)
}
function mouseEnter(event, d) {
// sub graph for the hovered node
const sieved = nodesByTypeAfterForce(d.id, {nodes: [d]}, "need");

// fade everything
node.selectAll("circle").classed('faded', true)
node.selectAll("circle").classed('highlight', false)
link.classed('faded', true)
link.classed('highlight', false)
linkLabels.classed('faded', true)
linkLabels.classed('highlight', false)
node.selectAll("text").classed('faded', true)
node.selectAll("text").classed('highlight', false)

// only highlight from sieved
node.selectAll("circle")
.filter(node => sieved.nodes.indexOf(node.index) > -1)
.classed('highlight', true)
link
.filter(link => sieved.links.indexOf(link.index) > -1)
.classed('highlight', true)
linkLabels
.filter(link => sieved.links.indexOf(link.index) > -1)
.classed('highlight', true)
node.selectAll("text")
.filter(node => sieved.nodes.indexOf(node.index) > -1)
.classed('highlight', true)

force.alphaTarget(0.0001).restart()
}
function mouseLeave(event, d) {
const selNodes = node.selectAll("circle")
const selLink = link
const selLinkLabel = linkLabels
const selText = node.selectAll("text")
selNodes.classed('faded', false)
selNodes.classed('highlight', false)
selLink.classed('faded', false)
selLink.classed('highlight', false)
selLinkLabel.classed('faded', false)
selLinkLabel.classed('highlight', false)
selText.classed('faded', false)
selText.classed('highlight', false)

force.restart()
}
function ticked() {
// update link positions
link
.attr("x1", function (d) {
return d.source.x;
})
.attr("y1", function (d) {
return d.source.y;
})
.attr("x2", function (d) {
return d.target.x;
})
.attr("y2", function (d) {
return d.target.y;
});
// update node positions
node
.attr("transform", function (d) {
return "translate(" + d.x + ", " + d.y + ")";
});
linkPaths.attr('d', function (d) {
return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y;
});
linkLabels.attr('transform', function (d) {
if (d.target.x < d.source.x) {
var bbox = this.getBBox();
rx = bbox.x + bbox.width / 2;
ry = bbox.y + bbox.height / 2;
return 'rotate(180 ' + rx + ' ' + ry + ')';
}
else {
return 'rotate(0)';
}
});
}
function dragStarted(event, d) {
if (!event.active) force.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
PosX = d.x
PosY = d.y
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragEnded(event, d) {
if (!event.active) force.alphaTarget(0);
d.fx = undefined;
d.fy = undefined;
}
</script>
</body>
</html>

如果您定义这样的链接,例如使用"酵母",则会遇到堆栈溢出:

"links": [ // Yeast need yeast ??
{
"source": 4,
"target": 4,
"type": "need"
},
...
]

因此,nodesByTypeAfterForce中需要一些额外的逻辑来容纳自引用链接。

使用递归(getNeedChain调用自己直到完成):

const getNeedChain = id => {
const links = graph.links.filter(l => l.source === id && l.type === "need");
const nodes = links.map(l => graph.nodes.find(n => n.id === l.target));
const linked = nodes.reduce((c, n) => [...c, ...getNeedChain(n.id)], []);
return [...nodes, ...linked];
}
console.log('Beer needs: ', getNeedChain(0));  // Returns 4 nodes
console.log('Car needs: ', getNeedChain(10));  // Returns 3 nodes

最新更新