Skip to content

Instantly share code, notes, and snippets.

@Tymotex
Created August 14, 2022 21:42
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 Tymotex/1d10e3ebcc93657e4baef814cde75969 to your computer and use it in GitHub Desktop.
Save Tymotex/1d10e3ebcc93657e4baef814cde75969 to your computer and use it in GitHub Desktop.
Graph Visualiser MVP
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