Skip to content

Instantly share code, notes, and snippets.

@capicue
Last active February 25, 2026 17:24
Show Gist options
  • Select an option

  • Save capicue/7cef1dbbadbc106492ee7372a0b690cb to your computer and use it in GitHub Desktop.

Select an option

Save capicue/7cef1dbbadbc106492ee7372a0b690cb to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>D3 Force Graph (from Graphviz)</title>
<style>
html, body { height: 100%; margin: 0; }
svg { width: 100%; height: 100%; display: block; background: #fff; }
.link { stroke-width: 2.5px; stroke-opacity: 0.85; }
.node circle { stroke: #111; stroke-width: 1.5px; fill: #fff; }
.label { font: 16px system-ui, -apple-system, Segoe UI, Roboto, sans-serif; pointer-events: none; }
</style>
</head>
<body>
<svg id="viz"></svg>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const nodes = [
{ id: "A" }, { id: "B" }, { id: "C" }, { id: "D" }, { id: "E" }, { id: "F" },
{ id: "G" }, { id: "H" }, { id: "I" }, { id: "J" }, { id: "K" }, { id: "L" },
];
// Preserve duplicates. "Top" edges are black and 2x stronger attraction.
const links = [
// top edges: black + 2x stronger attraction
{ source: "A", target: "B", strong: true, color: "black" },
{ source: "D", target: "G", strong: true, color: "black" },
{ source: "C", target: "F", strong: true, color: "black" },
{ source: "F", target: "C", strong: true, color: "black" },
{ source: "E", target: "I", strong: true, color: "black" },
{ source: "K", target: "L", strong: true, color: "black" },
{ source: "H", target: "J", strong: true, color: "black" },
{ source: "J", target: "H", strong: true, color: "black" },
// colored edges
{ source: "E", target: "A", color: "red" },
{ source: "E", target: "B", color: "red" },
{ source: "E", target: "C", color: "red" },
{ source: "E", target: "D", color: "red" },
{ source: "E", target: "F", color: "red" },
{ source: "I", target: "C", color: "orange" },
{ source: "I", target: "D", color: "orange" },
{ source: "I", target: "F", color: "orange" },
{ source: "I", target: "G", color: "orange" },
{ source: "I", target: "H", color: "orange" },
{ source: "D", target: "B", color: "yellow" },
{ source: "D", target: "E", color: "yellow" },
{ source: "D", target: "F", color: "yellow" },
{ source: "D", target: "I", color: "yellow" },
{ source: "D", target: "J", color: "yellow" },
{ source: "G", target: "A", color: "green" },
{ source: "G", target: "E", color: "green" },
{ source: "G", target: "H", color: "green" },
{ source: "G", target: "I", color: "green" },
{ source: "G", target: "J", color: "green" },
{ source: "A", target: "C", color: "blue" },
{ source: "A", target: "E", color: "blue" },
{ source: "A", target: "F", color: "blue" },
{ source: "A", target: "H", color: "blue" },
{ source: "A", target: "I", color: "blue" },
{ source: "L", target: "E", color: "purple" },
{ source: "L", target: "H", color: "purple" },
{ source: "L", target: "I", color: "purple" },
{ source: "L", target: "J", color: "purple" },
{ source: "L", target: "K", color: "purple" },
{ source: "B", target: "E", color: "pink" },
{ source: "B", target: "F", color: "pink" },
{ source: "B", target: "G", color: "pink" },
{ source: "B", target: "I", color: "pink" },
{ source: "B", target: "J", color: "pink" },
{ source: "K", target: "D", color: "brown" },
{ source: "K", target: "E", color: "brown" },
{ source: "K", target: "I", color: "brown" },
{ source: "K", target: "J", color: "brown" },
{ source: "K", target: "L", color: "brown" },
];
const svg = d3.select("#viz");
const width = () => svg.node().clientWidth;
const height = () => svg.node().clientHeight;
// Zoom + pan
const g = svg.append("g");
const zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on("zoom", (event) => g.attr("transform", event.transform));
svg.call(zoom);
// Force parameters
// (Bigger footprint: more distance + more repulsion)
const baseDistance = 160;
const baseStrength = 0.8;
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links)
.id(d => d.id)
.distance(l => l.strong ? baseDistance / 2 : baseDistance) // strong edges tighter
.strength(l => l.strong ? baseStrength * 2 : baseStrength) // strong edges stiffer
)
.force("charge", d3.forceManyBody().strength(-900))
// We'll set center to (0,0) so the zoom transform determines on-screen centering
.force("center", d3.forceCenter(0, 0))
.force("collide", d3.forceCollide(26));
// Set initial zoom centered + zoomed in immediately (no animation, no later re-centering)
const initialScale = 1.8; // tweak to taste
svg.call(
zoom.transform,
d3.zoomIdentity
.translate(width() / 2, height() / 2)
.scale(initialScale)
);
// Links
const link = g.append("g")
.attr("stroke-linecap", "round")
.selectAll("line")
.data(links)
.join("line")
.attr("class", "link")
.attr("stroke", d => d.color || "#999");
// Nodes
const node = g.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.attr("class", "node")
.call(drag(simulation));
node.append("circle").attr("r", 18);
node.append("text")
.attr("class", "label")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.text(d => d.id);
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
window.addEventListener("resize", () => {
// Keep whatever zoom/pan the user has; just keep the sim moving a bit.
simulation.alpha(0.3).restart();
});
function drag(sim) {
function dragstarted(event) {
if (!event.active) sim.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) sim.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment