Created
August 14, 2022 21:42
-
-
Save Tymotex/1d10e3ebcc93657e4baef814cde75969 to your computer and use it in GitHub Desktop.
Graph Visualiser MVP
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
import * as d3 from 'd3'; | |
import { useCallback, useEffect, useRef } from 'react'; | |
let vertices = [ | |
{ id: '0' }, | |
{ id: '1' }, | |
{ id: '2' }, | |
{ id: '3' }, | |
{ id: '4' }, | |
{ id: '5' }, | |
{ id: '6' }, | |
{ id: '7' }, | |
] | |
let edges = [ | |
{ source: '0', target: '1', weight: 5, isBidirectional: true }, | |
{ source: '0', target: '2', weight: 3, isBidirectional: false }, | |
{ source: '2', target: '5', weight: 5, isBidirectional: false }, | |
{ source: '3', target: '5', weight: 3, isBidirectional: false }, | |
{ source: '3', target: '2', weight: 2, isBidirectional: false }, | |
{ source: '1', target: '4', weight: 1, isBidirectional: true }, | |
{ source: '3', target: '4', weight: 8, isBidirectional: false }, | |
{ source: '5', target: '6', weight: 2, isBidirectional: false }, | |
{ source: '7', target: '2', weight: 4, isBidirectional: true }, | |
] | |
function getPrimitiveVal(value) { | |
return value !== null && typeof value === "object" ? value.valueOf() : value; | |
} | |
function renderForceGraph({ | |
vertices, // an iterable of node objects (typically [{id}, …]) | |
edges // an iterable of link objects (typically [{source, target}, …]) | |
}, { | |
nodeId = d => d.id, // given d in nodes, returns a unique identifier (string) | |
nodeStroke = "#fff", // node stroke color | |
nodeStrokeWidth = 1.5, // node stroke width, in pixels | |
nodeStrokeOpacity = 1, // node stroke opacity | |
nodeRadius = 8, // node radius, in pixels | |
getEdgeSource = ({ source }) => source, // given d in links, returns a node identifier string | |
getEdgeDest = ({ target }) => target, // given d in links, returns a node identifier string | |
linkStroke = "#111111", // link stroke color | |
linkStrokeOpacity = 0.4, // link stroke opacity | |
linkStrokeWidth = (d) => d.weight, // given d in links, returns a stroke width in pixels | |
linkStrokeLinecap = "round", // link stroke linecap | |
width = 400, // outer width, in pixels | |
height = 400, // outer height, in pixels | |
} = {}) { | |
// Generating the node & links dataset that D3 uses for simulating the graph. | |
const verticesMap = d3.map(vertices, nodeId).map(getPrimitiveVal); | |
const edgesSourceMap = d3.map(edges, getEdgeSource).map(getPrimitiveVal); | |
const edgesDestMap = d3.map(edges, getEdgeDest).map(getPrimitiveVal); | |
// Replace the input nodes and links with mutable objects for the simulation. | |
// Here, we build the vertex and edge maps that are used to render the graph. | |
vertices = d3.map(vertices, (_, i) => ({ id: verticesMap[i] })); | |
edges = d3.map(edges, (edge, i) => ({ | |
source: edgesSourceMap[i], | |
target: edgesDestMap[i], | |
weight: edge.weight, | |
isBidirectional: edge.isBidirectional | |
})); | |
// Generating styling maps that we use to apply styles. We can do things | |
// like make every edge's width scale proportionally to its weight, ie. | |
// make higher weight edges wider than lower weight edges. | |
const edgeWidths = typeof linkStrokeWidth !== "function" ? null : d3.map(edges, linkStrokeWidth); | |
const edgeColour = typeof linkStroke !== "function" ? null : d3.map(edges, linkStroke); | |
// Clear the graph's existing <g> children, which are the containers for | |
// the graph's vertices, edges, weight labels, etc. | |
d3.select('#graph-visualiser').selectAll("g").remove(); | |
// Setting the dimensions of the graph SVG container. | |
// Expects the <svg id="graph-visualiser" ...> to be mounted on the DOM already. | |
const graph = d3.select("#graph-visualiser") | |
.attr("width", width) | |
.attr("height", height) | |
.attr("width", width) | |
.attr("height", height) | |
.attr("viewBox", [-width / 2, -height / 2, width, height]) // Setting the origin to be at the center of the SVG container. | |
defineArrowheads(); | |
// Construct the forces. | |
const forceNode = d3.forceManyBody() | |
.strength(5) // This sets a close-range repulsive force between vertices. | |
.distanceMax(15); | |
const forceLink = d3.forceLink(edges) | |
.id(({ index: i }) => verticesMap[i]) | |
.strength(0.5); // Magnitude of the attractive force exerted by edges on the vertices. | |
// Set the force directed layout simulation parameters. | |
const simulation = d3.forceSimulation(vertices) | |
.force("link", forceLink) | |
.force("charge", forceNode) | |
.force("center", d3.forceCenter()) | |
.force("manyBody", d3.forceManyBody() | |
.strength(-200) // A really negative force tends to space out nodes better. | |
.distanceMax(100)) // This prevents forces from pushing out isolated subgraphs/vertices to the far edge. | |
.on("tick", ticked); | |
// Add the edges to the graph and set their properties. | |
const edgeGroup = graph.append("g") | |
.attr("stroke", typeof linkStroke !== "function" ? linkStroke : null) | |
.attr("stroke-opacity", linkStrokeOpacity) | |
.attr("stroke-width", typeof linkStrokeWidth !== "function" ? linkStrokeWidth : '2px') | |
.attr("stroke-linecap", linkStrokeLinecap) | |
.attr("id", 'edges') | |
.selectAll("line") | |
.data(edges) | |
.join("line") | |
.attr('class', (link) => `edge-${link.source.id}-${link.target.id} edge-${link.target.id}-${link.source.id}`) | |
.attr('marker-end', 'url(#end-arrowhead)') // Attach the arrowhead defined in <defs> earlier. | |
.attr('marker-start', (link) => link.isBidirectional ? 'url(#start-arrowhead)' : ''); // Add the start arrow IFF the link is bidirectional. | |
// Add the weight labels to the graph and set their properties. | |
const weightLabelGroup = graph.append("g") | |
.attr("id", "weight-labels") | |
.style('user-select', 'none') | |
.selectAll("text") | |
.data(edges) | |
.join("text") | |
.attr("id", (edge) => `weight-${edge.source.id}-${edge.target.id} weight-${edge.target.id}-${edge.source.id}`) | |
.text(edge => `${edge.weight}`) | |
.style('font-size', '8px') | |
.attr('fill', 'white') | |
.attr('stroke', 'brown') | |
.attr('x', (link) => link.x1) | |
.attr('y', (link) => link.y1) | |
.attr('text-anchor', 'middle') | |
.attr('alignment-baseline', 'middle') | |
// Add the vertices to the graph and set their properties. | |
const vertexGroup = graph.append("g") | |
.attr("fill", '#FFFFFF') | |
.attr("stroke", nodeStroke) | |
.attr("stroke-opacity", nodeStrokeOpacity) | |
.attr("stroke-width", nodeStrokeWidth) | |
.attr("id", 'vertices') | |
.selectAll("circle") | |
.data(vertices) | |
.join("circle") | |
.attr("r", nodeRadius) | |
.attr("id", (node) => `vertex-${node.id}`) | |
.attr("stroke", "#000000") | |
.call(handleDrag(simulation)); | |
// Add the vertex text labels to the graph and set their properties. | |
const vertexTextGroup = graph.append("g") | |
.attr("fill", '#000000') | |
.style('user-select', 'none') | |
.attr("id", 'vertex-labels') | |
.selectAll("text") | |
.data(vertices) | |
.join("text") | |
.style("pointer-events", 'none') | |
.attr("id", (node) => `text-${node.id}`) | |
.attr("font-size", '8px') | |
.attr("stroke", 'black') | |
.attr("stroke-width", '0.5') | |
.attr("alignment-baseline", 'middle') // Centering text inside a circle: https://stackoverflow.com/questions/28128491/svg-center-text-in-circle. | |
.attr("text-anchor", 'middle') | |
.text((_, i) => i) | |
// Applying styling maps to the edges. | |
if (edgeWidths) edgeGroup.attr("stroke-width", ({ index: i }) => edgeWidths[i]); | |
if (edgeColour) edgeGroup.attr("stroke", ({ index: i }) => edgeColour[i]); | |
function ticked() { | |
// On each tick of the simulation, update the coordinates of everything. | |
edgeGroup | |
.attr("x1", d => d.source.x) | |
.attr("y1", d => d.source.y) | |
.attr("x2", d => d.target.x) | |
.attr("y2", d => d.target.y); | |
vertexGroup | |
.attr("cx", function (d) { | |
return Math.max(-width / 2 + nodeRadius, Math.min(width / 2 - nodeRadius, d.x)); | |
}) | |
.attr("cy", function (d) { | |
return Math.max(-width / 2 + nodeRadius, Math.min(width / 2 - nodeRadius, d.y)); | |
}) | |
weightLabelGroup | |
.attr("x", function (d) { | |
return (d.source.x + d.target.x) / 2; | |
}) | |
.attr("y", function (d) { | |
return (d.source.y + d.target.y) / 2; | |
}) | |
vertexTextGroup | |
.attr("x", function (d) { | |
return Math.max(-width / 2 + nodeRadius, Math.min(width / 2 - nodeRadius, d.x)); | |
}) | |
.attr("y", function (d) { | |
return Math.max(-width / 2 + nodeRadius, Math.min(width / 2 - nodeRadius, d.y)); | |
}) | |
} | |
function handleDrag(simulation) { | |
function dragstarted(event) { | |
if (!event.active) simulation.alphaTarget(0.3).restart(); | |
event.subject.fx = event.subject.x; | |
event.subject.fy = event.subject.y; | |
} | |
function dragged(event) { | |
event.subject.fx = event.x; | |
event.subject.fy = event.y; | |
} | |
function dragended(event) { | |
if (!event.active) simulation.alphaTarget(0); | |
event.subject.fx = null; | |
event.subject.fy = null; | |
} | |
return d3.drag() | |
.on("start", dragstarted) | |
.on("drag", dragged) | |
.on("end", dragended); | |
} | |
return Object.assign(graph.node()); | |
} | |
// Defining the arrowhead for directed edges. | |
// Sourced the attributes from here: http://bl.ocks.org/fancellu/2c782394602a93921faff74e594d1bb1. | |
// TODO: these could be defined declaratively inside <svg id="graph-visualiser"> | |
function defineArrowheads() { | |
const graph = d3.select('#graph-visualiser'); | |
graph.append('defs').append('marker') | |
.attr('id', 'end-arrowhead') | |
.attr('viewBox', '-0 -5 10 10') | |
.attr('refX', 13) | |
.attr('refY', 0) | |
.attr('orient', 'auto') | |
.attr('markerWidth', 5) | |
.attr('markerHeight', 5) | |
.attr('xoverflow', 'visible') | |
.append('svg:path') | |
.attr('d', 'M 0,-3 L 6 ,0 L 0,3') | |
.attr('fill', '#999') | |
.style('stroke', 'none'); | |
graph.select('defs').append('marker') | |
.attr('id', 'start-arrowhead') | |
.attr('viewBox', '-0 -5 10 10') | |
.attr('refX', 13) | |
.attr('refY', 0) | |
.attr('orient', 'auto-start-reverse') // Reverses the direction of 'end-arrowhead'. See: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/orient. | |
.attr('markerWidth', 5) | |
.attr('markerHeight', 5) | |
.attr('xoverflow', 'visible') | |
.append('svg:path') | |
.attr('d', 'M 0,-3 L 6 ,0 L 0,3') | |
.attr('fill', '#999') | |
.style('stroke', 'none'); | |
// Unfortunately, there is no way for the <marker> element to inherit the | |
// styling of the parent <line>. The workaround is to define these highlighted | |
// variants of the arrowhead which get applied on highlighted edges. | |
graph.select('defs').append('marker') | |
.attr('id', 'highlighted-start-arrowhead') | |
.attr('viewBox', '-0 -5 10 10') | |
.attr('refX', 9) | |
.attr('refY', 0) | |
.attr('orient', 'auto-start-reverse') | |
.attr('markerWidth', 5) | |
.attr('markerHeight', 5) | |
.attr('xoverflow', 'visible') | |
.append('svg:path') | |
.attr('d', 'M 0,-3 L 6 ,0 L 0,3') | |
.attr('fill', 'gold') | |
.style('stroke', 'none'); | |
graph.select('defs').append('marker') | |
.attr('id', 'highlighted-end-arrowhead') | |
.attr('viewBox', '-0 -5 10 10') | |
.attr('refX', 9) | |
.attr('refY', 0) | |
.attr('orient', 'auto') | |
.attr('markerWidth', 5) | |
.attr('markerHeight', 5) | |
.attr('xoverflow', 'visible') | |
.append('svg:path') | |
.attr('d', 'M 0,-3 L 6 ,0 L 0,3') | |
.attr('fill', 'gold') | |
.style('stroke', 'none'); | |
} | |
function App() { | |
const vertexHighlightInput = useRef(); | |
const edgeHighlightInput = useRef(); | |
const addEdgeInput = useRef(); | |
const loadGraph = useCallback(() => { | |
renderForceGraph({ vertices, edges }, { | |
nodeId: d => d.id, | |
nodeGroup: d => d.group, | |
nodeTitle: d => `${d.id}\n${d.group}`, | |
linkStrokeWidth: l => Math.sqrt(l.value), | |
width: 400, | |
height: 400, | |
}); | |
}, [vertices, edges]); | |
useEffect(() => { | |
// Initialise the graph. | |
loadGraph(); | |
}, []); | |
return ( | |
<div> | |
{/* Graph visualiser SVG container */} | |
<svg id="graph-visualiser" style={{ | |
maxWidth: "100%", | |
height: "auto", | |
height: "intrinsic", | |
border: "1px dashed black", | |
margin: '10px 20px', | |
}}> | |
</svg> | |
<div style={{ display: 'flex', flexDirection: 'column' }}> | |
{/* Add a new vertex */} | |
<div | |
style={{ margin: '5px 20px', width: 'fit-content' }} | |
> | |
<button | |
onClick={() => { | |
vertices = [...vertices, { id: `${vertices.length}`, group: "1" }] | |
// Reload the graph. | |
loadGraph(); | |
}} | |
> | |
Add a new vertex | |
</button> | |
</div> | |
{/* Highlight a vertex */} | |
<div | |
style={{ margin: '5px 20px', width: 'fit-content' }} | |
> | |
<input type="text" ref={vertexHighlightInput} placeholder="Eg. 2" /> | |
<button onClick={() => { | |
if (vertexHighlightInput.current) { | |
const vertexVal = parseInt(vertexHighlightInput.current.value); | |
const vertex = document.querySelector(`#vertex-${vertexVal}`); | |
vertex.setAttribute('fill', '#000000'); | |
vertex.setAttribute('r', '9'); | |
const text = document.querySelector(`#text-${vertexVal}`); | |
text.setAttribute('stroke', "white"); | |
text.setAttribute('fill', "white"); | |
} | |
}} | |
>Highlight Vertex</button> | |
</div> | |
{/* Highlight an edge */} | |
<div | |
style={{ margin: '5px 20px', width: 'fit-content' }} | |
> | |
<input type="text" ref={edgeHighlightInput} placeholder="Eg. 1,2" /> | |
<button | |
onClick={() => { | |
if (edgeHighlightInput.current) { | |
const [v, w] = edgeHighlightInput.current.value.split(',').map(vertex => parseInt(vertex)); | |
const link = document.querySelector(`.edge-${v}-${w}`); | |
link.setAttribute('stroke-width', '4'); | |
link.setAttribute('stroke', 'gold'); | |
link.setAttribute('stroke-opacity', '1'); | |
link.setAttribute('marker-end', 'url(#highlighted-end-arrowhead)'); | |
if (link.getAttribute('marker-start')) | |
link.setAttribute('marker-start', 'url(#highlighted-start-arrowhead)'); | |
} | |
}} | |
> | |
Highlight Edge | |
</button> | |
</div> | |
{/* Insert edge */} | |
<div | |
style={{ margin: '5px 20px', width: 'fit-content' }} | |
> | |
<input type="text" ref={addEdgeInput} placeholder="Eg. 1,6" /> | |
<button | |
onClick={() => { | |
if (addEdgeInput.current) { | |
const [v, w] = addEdgeInput.current.value.split(',').map(vertex => parseInt(vertex)); | |
edges = [...edges, { source: `${v}`, target: `${w}`, weight: '5' }]; | |
loadGraph(); | |
} | |
}} | |
> | |
Add edge | |
</button> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
export default App; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment