Last active
January 31, 2023 08:55
-
-
Save OctaneInteractive/419739c1e229fbe414e3c110bd8bb0b9 to your computer and use it in GitHub Desktop.
An example of a Force-Directed Graph, via D3.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width,initial-scale=1.0"> | |
<title>D3</title> | |
<style> | |
html { | |
background-color: rgba(62, 62, 62, 0.95); | |
font-family: Calibri, Candara, Segoe, Segoe UI, Optima, Arial, sans-serif; | |
} | |
body { | |
margin: 0 0 0 0; | |
padding: 0 0 0 0; | |
} | |
.nodes circle:active { | |
cursor: grabbing; | |
} | |
.nodes circle { | |
cursor: grab; | |
} | |
.texts text { | |
font-size: 0.85rem; | |
fill: white; | |
text-anchor: middle; | |
text-align: center; | |
} | |
.descriptionFromNode { | |
background-color: rgba(39, 39, 39, 0.75); | |
bottom: 0px; | |
height: 50px; | |
left: 0px; | |
margin: 0 0 0 0; | |
padding: 0 0 0 0; | |
position: fixed; | |
width: calc(100%); | |
} | |
.descriptionFromNode div { | |
color: white; | |
padding: 15px 20px; | |
} | |
.arrowDown { | |
height: 13px; | |
width: 13px; | |
} | |
</style> | |
<script src="https://d3js.org/d3.v4.min.js"></script> | |
</head> | |
<body> | |
<svg width="960" height="600"></svg> | |
<section class="descriptionFromNode"> | |
<div id="descriptionFromNode"> | |
Description | |
</div> | |
</section> | |
<script src="./d3.js"></script> | |
</body> | |
</html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Force-Directed Graph, via D3 ( https://github.com/ninjaconcept/d3-force-directed-graph/blob/master/example/3-user-interaction.html ). | |
*/ | |
const nodes = [{ | |
id: "mammal", | |
group: 0, | |
label: "Mammals", | |
level: 1 | |
}, | |
{ | |
id: "ape", | |
group: 0, | |
label: "Apes", | |
level: 2 | |
}, | |
{ | |
id: "human", | |
group: 0, | |
label: "Humans", | |
level: 2 | |
}, | |
{ | |
id: "chimpanzee", | |
group: 0, | |
label: "Chimpanzees", | |
level: 2 | |
}, | |
{ | |
id: "gorilla", | |
group: 0, | |
label: "Gorilla", | |
level: 2 | |
}, | |
{ | |
id: "orangutan", | |
group: 0, | |
label: "Orangutan", | |
level: 2 | |
}, | |
{ | |
id: "dog", | |
group: 0, | |
label: "Dogs", | |
level: 2 | |
}, | |
{ | |
id: "wolf", | |
group: 0, | |
label: "Wolves", | |
level: 2 | |
}, | |
{ | |
id: "coyote", | |
group: 0, | |
label: "Coyotes", | |
level: 2 | |
}, | |
{ | |
id: "fox", | |
group: 0, | |
label: "Foxes", | |
level: 2 | |
}, | |
{ | |
id: "cat", | |
group: 0, | |
label: "Cats", | |
level: 2 | |
}, | |
{ | |
id: "abyssinian", | |
group: 0, | |
label: "Abyssinian", | |
level: 2 | |
}, | |
{ | |
id: "aegean", | |
group: 0, | |
label: "Aegean", | |
level: 2 | |
}, | |
{ | |
id: "american_bobtail", | |
group: 0, | |
label: "American Bobtail", | |
level: 2 | |
}, | |
{ | |
id: "american_curl", | |
group: 0, | |
label: "American Curl", | |
level: 2 | |
}, | |
{ | |
id: "american_ringtail", | |
group: 0, | |
label: "American Ringtail", | |
level: 2 | |
}, | |
{ | |
id: "american_shorthair", | |
group: 0, | |
label: "American Shorthair", | |
level: 2 | |
}, | |
{ | |
id: "american_wirehair", | |
group: 0, | |
label: "American Wirehair", | |
level: 2 | |
}, | |
{ | |
id: "insect", | |
group: 1, | |
label: "Insects", | |
level: 1 | |
}, | |
{ | |
id: "ant", | |
group: 1, | |
label: "Ants", | |
level: 2 | |
}, | |
{ | |
id: "bee", | |
group: 1, | |
label: "Bees", | |
level: 2 | |
}, | |
{ | |
id: "grasshopper", | |
group: 1, | |
label: "Grasshoppers", | |
level: 2 | |
}, | |
{ | |
id: "fish", | |
group: 2, | |
label: "Fish", | |
level: 1 | |
}, | |
{ | |
id: "carp", | |
group: 2, | |
label: "Carp", | |
level: 2 | |
}, | |
{ | |
id: "sturgeon", | |
group: 2, | |
label: "Sturgeon", | |
level: 2 | |
}, | |
{ | |
id: "pike", | |
group: 2, | |
label: "Pikes", | |
level: 2 | |
} | |
] | |
const links = [{ | |
target: "mammal", | |
source: "dog", | |
strength: 0.2 | |
}, | |
{ | |
target: "mammal", | |
source: "ape", | |
strength: 0.1 | |
}, | |
{ | |
target: "ape", | |
source: "human", | |
strength: 0.1 | |
}, | |
{ | |
target: "ape", | |
source: "chimpanzee", | |
strength: 0.1 | |
}, | |
{ | |
target: "ape", | |
source: "gorilla", | |
strength: 0.1 | |
}, | |
{ | |
target: "ape", | |
source: "orangutan", | |
strength: 0.1 | |
}, | |
{ | |
target: "dog", | |
source: "fox", | |
strength: 0.1 | |
}, | |
{ | |
target: "dog", | |
source: "wolf", | |
strength: 0.1 | |
}, | |
{ | |
target: "dog", | |
source: "coyote", | |
strength: 0.1 | |
}, | |
{ | |
target: "mammal", | |
source: "cat", | |
strength: 0.2 | |
}, | |
{ | |
target: "cat", | |
source: "abyssinian", | |
strength: 0.2 | |
}, | |
{ | |
target: "cat", | |
source: "aegean", | |
strength: 0.2 | |
}, | |
{ | |
target: "cat", | |
source: "american_bobtail", | |
strength: 0.2 | |
}, | |
{ | |
target: "cat", | |
source: "american_curl", | |
strength: 0.2 | |
}, | |
{ | |
target: "cat", | |
source: "american_ringtail", | |
strength: 0.2 | |
}, | |
{ | |
target: "cat", | |
source: "american_shorthair", | |
strength: 0.2 | |
}, | |
{ | |
target: "cat", | |
source: "american_wirehair", | |
strength: 0.2 | |
}, | |
{ | |
target: "insect", | |
source: "ant", | |
strength: 0.2 | |
}, | |
{ | |
target: "insect", | |
source: "bee", | |
strength: 0.2 | |
}, | |
{ | |
target: "insect", | |
source: "grasshopper", | |
strength: 0.2 | |
}, | |
{ | |
target: "fish", | |
source: "carp", | |
strength: 0.2 | |
}, | |
{ | |
target: "fish", | |
source: "pike", | |
strength: 0.2 | |
}, | |
{ | |
target: "fish", | |
source: "sturgeon", | |
strength: 0.2 | |
}, | |
{ | |
target: "fish", | |
source: "mammal", | |
strength: 0.3 | |
}, | |
{ | |
target: "insect", | |
source: "mammal", | |
strength: 0.3 | |
}, | |
{ | |
target: "fish", | |
source: "insect", | |
strength: 0.3 | |
} | |
] | |
function getNeighbors(node) { | |
return links.reduce((neighbors, link) => { | |
if (link.target.id === node.id) { | |
neighbors.push(link.source.id) | |
} else if (link.source.id === node.id) { | |
neighbors.push(link.target.id) | |
} | |
return neighbors | |
}, [node.id]) | |
} | |
const colourOfLine = "rgba(39, 39, 39, 0.75)" | |
const isNeighborLink = (node, link) => link.target.id === node.id || link.source.id === node.id | |
function getColourOfNode(node, neighbors) { | |
if (node.hasOwnProperty('level') && Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1) { | |
return node.level === 1 ? "blue" : "green" | |
} else { | |
return node.level === 1 ? "red" : "purple" | |
} | |
} | |
const getColourOfLink = (node, link) => isNeighborLink(node, link) ? "green" : colourOfLine | |
const getColourOfText = (node, neighbors) => Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1 ? "green" : "black" | |
let width = window.innerWidth | |
let height = window.innerHeight | |
let svg = d3.select("svg") | |
svg.attr("width", width).attr("height", height).attr("class", "assetExplorer") | |
// Configure the simulation with forces. | |
let forceOfLink = d3 | |
.forceLink() | |
.id(link => link.id) | |
.strength(link => link.strength) | |
let simulation = d3 | |
.forceSimulation() | |
.force("link", forceOfLink) | |
.force("charge", d3.forceManyBody().strength(-120)) | |
.force("center", d3.forceCenter(width / 2, height / 2)) | |
let toDragAndDrop = d3.drag() | |
.on("start", node => { | |
node.fx = node.x | |
node.fy = node.y | |
}).on("drag", node => { | |
simulation.alphaTarget(0.7).restart() | |
node.fx = d3.event.x | |
node.fy = d3.event.y | |
}).on("end", node => { | |
if (!d3.event.active) { | |
simulation.alphaTarget(0) | |
} | |
node.fx = null | |
node.fy = null | |
}) | |
function selectNode(selectedNode) { | |
document.getElementById("descriptionFromNode").innerHTML = selectedNode.label | |
const neighbors = getNeighbors(selectedNode) | |
nodeElements | |
.attr("fill", node => getColourOfNode(node, neighbors)) | |
textElements | |
.attr("fill", node => getColourOfText(node, neighbors)) | |
linkElements | |
.attr("stroke", link => getColourOfLink(selectedNode, link)) | |
} | |
let linkElements = svg.append("g") | |
.attr("class", "links") | |
.selectAll("line") | |
.data(links) | |
.enter().append("line") | |
.attr("stroke-width", 2) | |
.attr("stroke", colourOfLine) | |
let nodeElements = svg.append("g") | |
.attr("class", "nodes") | |
.selectAll("circle") | |
.data(nodes) | |
.enter().append("circle") | |
.attr("r", 10) | |
.attr("fill", getColourOfNode) | |
.call(toDragAndDrop) | |
.on("click", event => { | |
d3.event.preventDefault() | |
console.info("Event:click", event) | |
removeMenuForNode() | |
selectNode(event) | |
}) | |
.on("dblclick", event => { | |
d3.event.preventDefault() | |
console.info("Event:dblclick", event) | |
removeMenuForNode() | |
menuForNode(configForMenu({ | |
items: { | |
assetEdit: { | |
id: event.id | |
}, | |
assetView: { | |
id: event.id | |
} | |
}, | |
x: event.x, | |
y: event.y, | |
})) | |
}) | |
let textElements = svg.append("g") | |
.attr("class", "texts") | |
.selectAll("text") | |
.data(nodes) | |
.enter().append("text") | |
.text(node => node.label) | |
.attr("dy", "2rem") | |
simulation.nodes(nodes).on("tick", () => { | |
nodeElements | |
.attr("cx", node => node.x) | |
.attr("cy", node => node.y) | |
textElements | |
.attr("x", node => node.x) | |
.attr("y", node => node.y) | |
linkElements | |
.attr("x1", link => link.source.x) | |
.attr("y1", link => link.source.y) | |
.attr("x2", link => link.target.x) | |
.attr("y2", link => link.target.y) | |
}) | |
simulation.force("link").links(links) | |
const removeMenuForNode = () => svg.selectAll(".menuForNode").remove() | |
const configForMenu = ({ items, x, y }) => { | |
return { | |
width: 120, | |
container: svg, // The menu is a child of the parent SVG. | |
itemsForMenu: (() => [{ | |
label: "Edit Asset", | |
value: items.assetEdit.id | |
}, | |
{ | |
label: "Follow Asset", | |
value: items.assetView.id | |
}])(items), | |
fontSize: 14, | |
color: "#333", | |
fontFamily: "Calibri, Candara, Segoe, Segoe UI, Optima, Arial, sans-serif", | |
x: x, | |
y: y, | |
changeHandler: option => { | |
// Use `this` to access the option group. | |
console.info("configForMenu", option) | |
} | |
} | |
} | |
// Select element, via SVG ( https://gist.github.com/vbiamitr/f39f26dc93d95251912e817d6c266ed6 ). | |
const menuForNode = options => { | |
if (typeof options !== "object" || options === null || !options.container) { | |
console.error(new Error("No container provided")) | |
return | |
} | |
options = { | |
...configForMenu, | |
...options | |
} | |
options.optionHeight = options.fontSize * 2 | |
options.height = options.fontSize + 8 | |
options.padding = 5 | |
options.hoverColor = "#3e3e3ef2" | |
options.hoverTextColor = "#fff" | |
options.bgColor = "#fff" | |
options.width = options.width - 2 | |
const g = options.container | |
.append("svg") | |
.attr("class", "menuForNode") | |
.attr("shape-rendering", "geometricPrecision") | |
.attr("x", options.x) | |
.attr("y", options.y) | |
.append("g") | |
.attr("transform", "translate(1, 1)") | |
.attr("font-family", options.fontFamily) | |
let selectedOption = options.itemsForMenu.length === 0 ? { | |
label: "", | |
value: "" | |
} : options.itemsForMenu[0] | |
const selectField = g.append("g") | |
// Background. | |
selectField | |
.append("rect") | |
.attr("class", "option") | |
.attr("fill", options.bgColor) | |
.attr("height", options.height) | |
.attr("width", options.width) | |
.attr("rx", 3) | |
.style("stroke", "#a0a0a0") | |
.style("stroke-width", 1) | |
// Text. | |
const activeText = selectField | |
.append("text") | |
.text(selectedOption.label) | |
.attr("fill", options.color) | |
.attr("font-size", options.fontSize) | |
.attr("x", options.padding) | |
.attr("y", options.height / 2 + options.fontSize / 3) | |
// Arrow symbol. | |
selectField | |
.append("svg") | |
.attr("x", 90) | |
.attr("y", 3.8) | |
.append("path") | |
.attr("class", "arrowDown") | |
.attr("fill", "#3e3e3ef2") | |
.attr("d", "M12.1,10.8c-0.3,0-0.6-0.1-0.8-0.3L5.3,4.5C5.1,4.3,5,4,5,3.7s0.1-0.6,0.3-0.8c0.5-0.5,1.2-0.5,1.6,0L12.1,8 l5.1-5.1c0.5-0.5,1.3-0.5,1.7,0c0.5,0.5,0.5,1.3,0,1.7L13,10.6C12.7,10.7,12.4,10.8,12.1,10.8z") | |
// A transparent surface to capture actions. | |
selectField | |
.append("rect") | |
.attr("height", options.height) | |
.attr("width", options.width) | |
.style("fill", "transparent") | |
.on("click", handleSelectClick) | |
// Rendering options. | |
const optionGroup = g | |
.append("g") | |
.attr("transform", `translate(0, ${options.height})`) | |
.attr("opacity", 0) // .attr("display", "none") Issue in IE and Firefox: Unable to calculate `textLength` when `display` is `none`. | |
// Rendering options group. | |
const optionEnter = optionGroup | |
.selectAll("g") | |
.data(options.itemsForMenu) | |
.enter() | |
.append("g") | |
.on("click", handleOptionClick) | |
// Rendering background. | |
optionEnter | |
.append("rect") | |
.attr("width", options.width) | |
.attr("height", options.optionHeight) | |
.attr("y", (d, i) => i * options.optionHeight) | |
.attr("class", "option") | |
.style("stroke", options.hoverColor) | |
.style("stroke-dasharray", (d, i) => { | |
let stroke = [ | |
0, | |
options.width, | |
options.optionHeight, | |
options.width, | |
options.optionHeight | |
] | |
if (i === 0) { | |
stroke = [ | |
options.width + options.optionHeight, | |
options.width, | |
options.optionHeight | |
] | |
} else if (i === options.itemsForMenu.length - 1) { | |
stroke = [0, options.width, options.optionHeight * 2 + options.width] | |
} | |
return stroke.join(" ") | |
}) | |
.style("stroke-width", 1) | |
.style("fill", options.bgColor) | |
// Rendering option text. | |
optionEnter | |
.append("text") | |
.attr("x", options.padding) | |
.attr("y", (d, i) => { | |
return (i * options.optionHeight + options.optionHeight / 2 + options.fontSize / 3) | |
}) | |
.text(d => { | |
return d.label | |
}) | |
.attr("font-size", options.fontSize) | |
.attr("fill", options.color) | |
.each(wrapText) | |
// Rendering option surface to handle events. | |
optionEnter | |
.append("rect") | |
.attr("width", options.width) | |
.attr("height", options.optionHeight) | |
.attr("y", (d, i) => i * options.optionHeight) | |
.style("fill", "transparent") | |
.on("mouseover", handleMouseOver) | |
.on("mouseout", handleMouseOut) | |
// Once the `textLength` is calculated... | |
optionGroup.attr("display", "none").attr("opacity", 1) | |
d3.select("body").on("click", () => optionGroup.attr("display", "none")) | |
// Utility Methods | |
function handleMouseOver() { | |
d3.select(d3.event.target.parentNode) | |
.select(".option") | |
.style("fill", options.hoverColor) | |
d3.select(d3.event.target.parentNode) | |
.select("text") | |
.style("fill", options.hoverTextColor) | |
} | |
function handleMouseOut() { | |
d3.select(d3.event.target.parentNode) | |
.select(".option") | |
.style("fill", options.bgColor) | |
d3.select(d3.event.target.parentNode) | |
.select("text") | |
.style("fill", options.color) | |
} | |
function handleOptionClick(d) { | |
d3.event.stopPropagation() | |
selectedOption = d | |
activeText.text(selectedOption.label).each(wrapText) | |
typeof options.changeHandler === 'function' && options.changeHandler.call(this, d) | |
optionGroup.attr("display", "none") | |
} | |
function handleSelectClick() { | |
d3.event.stopPropagation() | |
const isVisible = optionGroup.attr("display") === "block" ? "none" : "block" | |
optionGroup.attr("display", isVisible) | |
} | |
// Wraps words in text. | |
function wrapText() { | |
console.info('wrapText()') | |
const width = options.width | |
const padding = options.padding | |
const self = d3.select(this) | |
let textLength = self.node().getComputedTextLength() | |
let text = self.text() | |
const arrayOfText = text.split(/\s+/) | |
let lastWord = "" | |
while (textLength > width - 2 * padding && text.length > 0) { | |
lastWord = arrayOfText.pop() | |
text = arrayOfText.join(" ") | |
self.text(text) | |
textLength = self.node().getComputedTextLength() | |
} | |
self.text(`${text} ${lastWord}`) | |
// Add ellipsis to the last word in the text. | |
if (lastWord) { | |
textLength = self.node().getComputedTextLength() | |
text = self.text() | |
while (textLength > width - 2 * padding && text.length > 0) { | |
text = text.slice(0, -1) | |
self.text(`${text}...`) | |
textLength = self.node().getComputedTextLength() | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
An example of a Force-Directed Graph, via D3, combined with a select element that becomes visible when double-clicking a node.