|
<!DOCTYPE html> |
|
<meta charset="utf-8"> |
|
<style> |
|
|
|
.links line { |
|
stroke: #76963e; |
|
stroke-width: 1.0px; |
|
stroke-opacity: 1.5; |
|
} |
|
|
|
.nodes circle { |
|
stroke: green; |
|
stroke-width: 0.5px; |
|
} |
|
|
|
#ib-d3-graph-div { |
|
height: 500px; |
|
width: 960px; |
|
} |
|
|
|
#testSvgId { |
|
height: 500px; |
|
width: 960px; |
|
} |
|
|
|
#backgroundId { |
|
height: 500px; |
|
width: 960px; |
|
} |
|
|
|
text { |
|
font: bold 12px monospace; |
|
} |
|
|
|
</style> |
|
|
|
<div id="ib-d3-graph-div"> |
|
</div> |
|
<!-- <svg id="testSvgId" width="960" height="500"></svg> --> |
|
<script src="https://d3js.org/d3.v4.min.js"></script> |
|
<script> |
|
|
|
class D3ForceGraph { |
|
constructor(graphDiv, svgId) { |
|
let t = this; |
|
|
|
t.graphDiv = graphDiv; |
|
t.rect = t.graphDiv.getBoundingClientRect(); |
|
t.width = t.graphDiv.scrollWidth; |
|
t.height = t.graphDiv.scrollHeight; |
|
t.center = {x: t.width / 2, y: t.height / 2}; |
|
|
|
t.svgId = svgId; |
|
t.updateRefCount = 0; |
|
} |
|
|
|
init() { |
|
let t = this; |
|
|
|
t.graphData = { "nodes": [], "links": [] }; |
|
|
|
// graph area |
|
let svg = d3.select(t.graphDiv) |
|
.append("svg") |
|
.attr('id', t.svgId) |
|
.attr('width', t.width) |
|
.attr('height', t.height); |
|
t.svg = svg; |
|
|
|
// Needs to be second, just after the svg itself. |
|
let background = t.initBackground(t, svg); |
|
// background |
|
|
|
// Holds child components (nodes, links), i.e. all but the background |
|
let svgGroup = svg |
|
.append('svg:g') |
|
.attr("id", "svgGroup"); |
|
t.svgGroup = svgGroup; |
|
|
|
let graphLinksGroup = |
|
svgGroup |
|
.append("g") |
|
.attr("id", `links_${t.svgId}`) |
|
.attr("class", "links"); |
|
t.graphLinksGroup = graphLinksGroup; |
|
|
|
let graphNodesGroup = |
|
svgGroup |
|
.append("g") |
|
.attr("id", `nodes_${t.svgId}`) |
|
.attr("class", "nodes"); |
|
t.graphNodesGroup = graphNodesGroup; |
|
|
|
let zoom = |
|
d3.zoom() |
|
.on("zoom", () => t.handleZoom(svgGroup)); |
|
t.zoom = zoom; |
|
background.call(zoom); |
|
|
|
|
|
let simulation = t.initSimulation(); |
|
t.simulation = simulation; |
|
|
|
// update(); |
|
t.update(t, simulation, graphNodesGroup, graphLinksGroup); |
|
} |
|
|
|
initBackground(t, svg) { |
|
let result = svg |
|
.append("rect") |
|
.attr("id", "backgroundId") |
|
.attr("fill", "#F2F7F0") |
|
.attr("class", "view") |
|
.attr("x", 0.5) |
|
.attr("y", 0.5) |
|
.attr("width", t.width - 1) |
|
.attr("height", t.height - 1) |
|
.on("click", () => t.handleBackgroundClicked()); |
|
|
|
return result; |
|
} |
|
|
|
initSimulation() { |
|
let t = this; |
|
|
|
let result = d3.forceSimulation() |
|
.velocityDecay(0.55) |
|
.force("link", d3.forceLink() |
|
.distance(100) |
|
.id(d => d.id)) |
|
.force("charge", d3.forceManyBody().strength(-100).distanceMin(10000)) |
|
.force("collide", d3.forceCollide(25)) |
|
.force("center", d3.forceCenter(t.center.x, t.center.y)); |
|
|
|
return result; |
|
} |
|
|
|
getRadius(d) { |
|
const min = 5; |
|
const max = 50; |
|
let r = Math.trunc(500 / (d.id || 1)); |
|
if (r < min) r = min; |
|
if (r > max) r = max; |
|
|
|
return r; |
|
} |
|
getColor(d) { return "lightblue"; } |
|
|
|
handleDragStarted(d, simulation) { |
|
if (!d3.event.active) simulation.alphaTarget(0.3).restart(); |
|
|
|
d.fx = d.x; |
|
d.fy = d.y; |
|
} |
|
handleDragged(d) { |
|
d.fx = d3.event.x; |
|
d.fy = d3.event.y; |
|
} |
|
handleDragEnded(d, simulation) { |
|
if (!d3.event.active) simulation.alphaTarget(0); |
|
|
|
d.fx = undefined; |
|
d.fy = undefined; |
|
} |
|
|
|
handleBackgroundClicked() { |
|
console.log(`background clicked in numero 2`); |
|
} |
|
|
|
handleZoom(svgGroup) { |
|
svgGroup |
|
.attr("transform", |
|
`translate(${d3.event.transform.x}, ${d3.event.transform.y})` + " " + |
|
`scale(${d3.event.transform.k})`); |
|
} |
|
|
|
update(t, simulation, graphNodesGroup, graphLinksGroup) { |
|
let nodes = t.graphData.nodes; |
|
let links = t.graphData.links; |
|
|
|
let drag = |
|
d3.drag() |
|
.on("start", d => t.handleDragStarted(d, simulation)) |
|
.on("drag", d => t.handleDragged(d)) |
|
.on("end", d => t.handleDragEnded(d, simulation)); |
|
|
|
// nodes |
|
let graphNodesData = |
|
graphNodesGroup |
|
.selectAll("g") |
|
.data(nodes, d => d.id); |
|
let graphNodesEnter = |
|
graphNodesData |
|
.enter() |
|
.append("g") |
|
.attr("id", d => d.id || null) |
|
//.on("contextmenu", (d, i) => { |
|
// t.remove(d); |
|
// d3.event.preventDefault(); |
|
//}) |
|
.on("mouseover", d => console.log(`d.id: ${d.id}`)) |
|
.on("click", d => t.handleNodeClicked(d)) //focus and zoom |
|
.call(drag); |
|
let graphNodesExit = |
|
graphNodesData |
|
.exit() |
|
// .call((s) => console.log(`selection exiting. s: ${JSON.stringify(s)}`)) |
|
.remove(); |
|
|
|
let graphNodeCircles = |
|
graphNodesEnter |
|
.append("circle") |
|
.classed('node', true) |
|
.attr("cursor", "pointer") |
|
.attr("r", d => t.getRadius(d)) |
|
.attr("fill", d => t.getColor(d)); |
|
|
|
let graphNodeLabels = |
|
graphNodesEnter |
|
.append("text") |
|
.attr("id", d => "label_" + d.id) |
|
.attr("font-size", `10px`) |
|
.attr("text-anchor", "middle") |
|
.text(d => `${d.id}`); |
|
|
|
// merge |
|
graphNodesData = |
|
graphNodesEnter.merge(graphNodesData); |
|
|
|
// links |
|
let graphLinksData = |
|
graphLinksGroup |
|
.selectAll("line") |
|
.data(links); |
|
let graphLinksEnter = |
|
graphLinksData |
|
.enter() |
|
.append("line"); |
|
let graphLinksExit = |
|
graphLinksData |
|
.exit() |
|
.remove(); |
|
// merge |
|
graphLinksData = |
|
graphLinksEnter.merge(graphLinksData); |
|
|
|
simulation |
|
.nodes(nodes) |
|
.on("tick", handleTicked) |
|
.on("end", () => t.handleEnd()); |
|
|
|
simulation |
|
.force("link") |
|
.links(links); |
|
|
|
function handleTicked() { |
|
graphLinksData |
|
.attr("x1", d => d.source.x) |
|
.attr("y1", d => d.source.y) |
|
.attr("x2", d => d.target.x) |
|
.attr("y2", d => d.target.y); |
|
|
|
// Translate the groups |
|
graphNodesData |
|
.attr("transform", d => { |
|
return 'translate(' + [d.x, d.y] + ')'; |
|
}); |
|
} |
|
} |
|
|
|
add(nodesToAdd, linksToAdd) { |
|
let t = this; |
|
|
|
if (nodesToAdd) { |
|
nodesToAdd.forEach(n => t.graphData.nodes.push(n)); |
|
} |
|
if (linksToAdd) { |
|
linksToAdd.forEach(l => t.graphData.links.push(l)); |
|
} |
|
|
|
// update(); |
|
t.update(t, t.simulation, t.graphNodesGroup, t.graphLinksGroup) |
|
t.simulation.restart(); |
|
t.simulation.alpha(1); |
|
} |
|
|
|
remove(dToRemove) { |
|
console.log(`dToRemove: ${JSON.stringify(dToRemove)}`) |
|
|
|
let t = this; |
|
|
|
let currentNodes = t.graphData.nodes; |
|
let currentLinks = t.graphData.links; |
|
let nIndex = currentNodes.indexOf(dToRemove); |
|
if (nIndex > -1) { |
|
currentNodes.splice(nIndex, 1); |
|
} |
|
|
|
let toRemoveLinks = currentLinks.filter(l => { |
|
return l.source.id === dToRemove.id || l.target.id === dToRemove.id; |
|
}); |
|
toRemoveLinks.forEach(l => { |
|
let lIndex = currentLinks.indexOf(l); |
|
currentLinks.splice(lIndex, 1); |
|
}) |
|
|
|
t.update(t, t.simulation, t.graphNodesGroup, t.graphLinksGroup) |
|
t.simulation.restart(); |
|
t.simulation.alpha(1); |
|
} |
|
|
|
handleNodeClicked(d) { |
|
console.log(`node clicked: ${JSON.stringify(d)}`); |
|
|
|
let t = this; |
|
//get a transform to the selected node |
|
let transform = d3.zoomIdentity.translate(d.x, d.y); |
|
//transition |
|
return t.svg.transition().duration(2000).call(t.zoom.transform, transform); |
|
} |
|
|
|
handleEnd() { |
|
console.log("end yo"); |
|
} |
|
} |
|
|
|
let graphDiv = document.querySelector("#ib-d3-graph-div"); |
|
let graph = new D3ForceGraph(graphDiv, "testSvgId"); |
|
graph.init(); |
|
|
|
setTimeout(() => { |
|
let initialCount = 3; |
|
let nodes = [ {"id": 0, "name": "root node"} ]; |
|
let links = []; |
|
for (var i = 1; i < initialCount; i++) { |
|
let randomIndex = Math.trunc(Math.random() * nodes.length); |
|
let randomNode = nodes[randomIndex]; |
|
let newNode = {id: i, name: `node ${i}`}; |
|
let newLink = {source: randomIndex, target: newNode.id}; |
|
|
|
nodes.push(newNode); |
|
links.push(newLink); |
|
} |
|
|
|
graph.add(nodes, links); |
|
|
|
let count = 0; |
|
let interval = setInterval(() => { |
|
let randomIndex = Math.trunc(Math.random() * graph.graphData.nodes.length); |
|
let randomNode = graph.graphData.nodes[randomIndex]; |
|
let randomId = Math.trunc(Math.random() * 10000); |
|
let newNode = {"id": randomId, "name": "server " + randomId}; |
|
if (randomNode.x) { |
|
newNode.x = randomNode.x; |
|
newNode.y = randomNode.y; |
|
} |
|
let newLink = {source: randomNode.id, target: randomId}; |
|
graph.add([newNode], [newLink]); |
|
count ++; |
|
if (count % 10 === 0) { |
|
console.log(`count: ${count}`) |
|
if (count % 40 === 0) { |
|
clearInterval(interval); |
|
} |
|
} |
|
|
|
}, 10) |
|
|
|
}, 500); |
|
|
|
|
|
|
|
</script> |