Skip to content

Instantly share code, notes, and snippets.

@OctaneInteractive
Last active January 31, 2023 08:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save OctaneInteractive/419739c1e229fbe414e3c110bd8bb0b9 to your computer and use it in GitHub Desktop.
Save OctaneInteractive/419739c1e229fbe414e3c110bd8bb0b9 to your computer and use it in GitHub Desktop.
An example of a Force-Directed Graph, via D3.
<!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>
/**
* 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()
}
}
}
}
@OctaneInteractive
Copy link
Author

An example of a Force-Directed Graph, via D3, combined with a select element that becomes visible when double-clicking a node.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment