Skip to content

Instantly share code, notes, and snippets.

@rkirsling
Last active September 22, 2024 23:23
Show Gist options
  • Save rkirsling/5001347 to your computer and use it in GitHub Desktop.
Save rkirsling/5001347 to your computer and use it in GitHub Desktop.
Directed Graph Editor

Click in the open space to add a node, drag from one node to another to add an edge.
Ctrl-drag a node to move the graph layout.
Click a node or an edge to select it.

When a node is selected: R toggles reflexivity, Delete removes the node.
When an edge is selected: L(eft), R(ight), B(oth) change direction, Delete removes the edge.

To see this example as part of a larger project, check out Modal Logic Playground!

svg {
background-color: #FFF;
cursor: default;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-o-user-select: none;
user-select: none;
}
svg:not(.active):not(.ctrl) {
cursor: crosshair;
}
path.link {
fill: none;
stroke: #000;
stroke-width: 4px;
cursor: default;
}
svg:not(.active):not(.ctrl) path.link {
cursor: pointer;
}
path.link.selected {
stroke-dasharray: 10,2;
}
path.link.dragline {
pointer-events: none;
}
path.link.hidden {
stroke-width: 0;
}
circle.node {
stroke-width: 1.5px;
cursor: pointer;
}
circle.node.reflexive {
stroke: #000 !important;
stroke-width: 2.5px;
}
text {
font: 12px sans-serif;
pointer-events: none;
}
text.id {
text-anchor: middle;
font-weight: bold;
}
// set up SVG for D3
const width = 960;
const height = 500;
const colors = d3.scaleOrdinal(d3.schemeCategory10);
const svg = d3.select('body')
.append('svg')
.on('contextmenu', () => { d3.event.preventDefault(); })
.attr('width', width)
.attr('height', height);
// set up initial nodes and links
// - nodes are known by 'id', not by index in array.
// - reflexive edges are indicated on the node (as a bold black circle).
// - links are always source < target; edge directions are set by 'left' and 'right'.
const nodes = [
{ id: 0, reflexive: false },
{ id: 1, reflexive: true },
{ id: 2, reflexive: false }
];
let lastNodeId = 2;
const links = [
{ source: nodes[0], target: nodes[1], left: false, right: true },
{ source: nodes[1], target: nodes[2], left: false, right: true }
];
// init D3 force layout
const force = d3.forceSimulation()
.force('link', d3.forceLink().id((d) => d.id).distance(150))
.force('charge', d3.forceManyBody().strength(-500))
.force('x', d3.forceX(width / 2))
.force('y', d3.forceY(height / 2))
.on('tick', tick);
// init D3 drag support
const drag = d3.drag()
// Mac Firefox doesn't distinguish between left/right click when Ctrl is held...
.filter(() => d3.event.button === 0 || d3.event.button === 2)
.on('start', (d) => {
if (!d3.event.active) force.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (d) => {
d.fx = d3.event.x;
d.fy = d3.event.y;
})
.on('end', (d) => {
if (!d3.event.active) force.alphaTarget(0);
d.fx = null;
d.fy = null;
});
// define arrow markers for graph links
svg.append('svg:defs').append('svg:marker')
.attr('id', 'end-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 6)
.attr('markerWidth', 3)
.attr('markerHeight', 3)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#000');
svg.append('svg:defs').append('svg:marker')
.attr('id', 'start-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 4)
.attr('markerWidth', 3)
.attr('markerHeight', 3)
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M10,-5L0,0L10,5')
.attr('fill', '#000');
// line displayed when dragging new nodes
const dragLine = svg.append('svg:path')
.attr('class', 'link dragline hidden')
.attr('d', 'M0,0L0,0');
// handles to link and node element groups
let path = svg.append('svg:g').selectAll('path');
let circle = svg.append('svg:g').selectAll('g');
// mouse event vars
let selectedNode = null;
let selectedLink = null;
let mousedownLink = null;
let mousedownNode = null;
let mouseupNode = null;
function resetMouseVars() {
mousedownNode = null;
mouseupNode = null;
mousedownLink = null;
}
// update force layout (called automatically each iteration)
function tick() {
// draw directed edges with proper padding from node centers
path.attr('d', (d) => {
const deltaX = d.target.x - d.source.x;
const deltaY = d.target.y - d.source.y;
const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const normX = deltaX / dist;
const normY = deltaY / dist;
const sourcePadding = d.left ? 17 : 12;
const targetPadding = d.right ? 17 : 12;
const sourceX = d.source.x + (sourcePadding * normX);
const sourceY = d.source.y + (sourcePadding * normY);
const targetX = d.target.x - (targetPadding * normX);
const targetY = d.target.y - (targetPadding * normY);
return `M${sourceX},${sourceY}L${targetX},${targetY}`;
});
circle.attr('transform', (d) => `translate(${d.x},${d.y})`);
}
// update graph (called when needed)
function restart() {
// path (link) group
path = path.data(links);
// update existing links
path.classed('selected', (d) => d === selectedLink)
.style('marker-start', (d) => d.left ? 'url(#start-arrow)' : '')
.style('marker-end', (d) => d.right ? 'url(#end-arrow)' : '');
// remove old links
path.exit().remove();
// add new links
path = path.enter().append('svg:path')
.attr('class', 'link')
.classed('selected', (d) => d === selectedLink)
.style('marker-start', (d) => d.left ? 'url(#start-arrow)' : '')
.style('marker-end', (d) => d.right ? 'url(#end-arrow)' : '')
.on('mousedown', (d) => {
if (d3.event.ctrlKey) return;
// select link
mousedownLink = d;
selectedLink = (mousedownLink === selectedLink) ? null : mousedownLink;
selectedNode = null;
restart();
})
.merge(path);
// circle (node) group
// NB: the function arg is crucial here! nodes are known by id, not by index!
circle = circle.data(nodes, (d) => d.id);
// update existing nodes (reflexive & selected visual states)
circle.selectAll('circle')
.style('fill', (d) => (d === selectedNode) ? d3.rgb(colors(d.id)).brighter().toString() : colors(d.id))
.classed('reflexive', (d) => d.reflexive);
// remove old nodes
circle.exit().remove();
// add new nodes
const g = circle.enter().append('svg:g');
g.append('svg:circle')
.attr('class', 'node')
.attr('r', 12)
.style('fill', (d) => (d === selectedNode) ? d3.rgb(colors(d.id)).brighter().toString() : colors(d.id))
.style('stroke', (d) => d3.rgb(colors(d.id)).darker().toString())
.classed('reflexive', (d) => d.reflexive)
.on('mouseover', function (d) {
if (!mousedownNode || d === mousedownNode) return;
// enlarge target node
d3.select(this).attr('transform', 'scale(1.1)');
})
.on('mouseout', function (d) {
if (!mousedownNode || d === mousedownNode) return;
// unenlarge target node
d3.select(this).attr('transform', '');
})
.on('mousedown', (d) => {
if (d3.event.ctrlKey) return;
// select node
mousedownNode = d;
selectedNode = (mousedownNode === selectedNode) ? null : mousedownNode;
selectedLink = null;
// reposition drag line
dragLine
.style('marker-end', 'url(#end-arrow)')
.classed('hidden', false)
.attr('d', `M${mousedownNode.x},${mousedownNode.y}L${mousedownNode.x},${mousedownNode.y}`);
restart();
})
.on('mouseup', function (d) {
if (!mousedownNode) return;
// needed by FF
dragLine
.classed('hidden', true)
.style('marker-end', '');
// check for drag-to-self
mouseupNode = d;
if (mouseupNode === mousedownNode) {
resetMouseVars();
return;
}
// unenlarge target node
d3.select(this).attr('transform', '');
// add link to graph (update if exists)
// NB: links are strictly source < target; arrows separately specified by booleans
const isRight = mousedownNode.id < mouseupNode.id;
const source = isRight ? mousedownNode : mouseupNode;
const target = isRight ? mouseupNode : mousedownNode;
const link = links.filter((l) => l.source === source && l.target === target)[0];
if (link) {
link[isRight ? 'right' : 'left'] = true;
} else {
links.push({ source, target, left: !isRight, right: isRight });
}
// select new link
selectedLink = link;
selectedNode = null;
restart();
});
// show node IDs
g.append('svg:text')
.attr('x', 0)
.attr('y', 4)
.attr('class', 'id')
.text((d) => d.id);
circle = g.merge(circle);
// set the graph in motion
force
.nodes(nodes)
.force('link').links(links);
force.alphaTarget(0.3).restart();
}
function mousedown() {
// because :active only works in WebKit?
svg.classed('active', true);
if (d3.event.ctrlKey || mousedownNode || mousedownLink) return;
// insert new node at point
const point = d3.mouse(this);
const node = { id: ++lastNodeId, reflexive: false, x: point[0], y: point[1] };
nodes.push(node);
restart();
}
function mousemove() {
if (!mousedownNode) return;
// update drag line
dragLine.attr('d', `M${mousedownNode.x},${mousedownNode.y}L${d3.mouse(this)[0]},${d3.mouse(this)[1]}`);
}
function mouseup() {
if (mousedownNode) {
// hide drag line
dragLine
.classed('hidden', true)
.style('marker-end', '');
}
// because :active only works in WebKit?
svg.classed('active', false);
// clear mouse event vars
resetMouseVars();
}
function spliceLinksForNode(node) {
const toSplice = links.filter((l) => l.source === node || l.target === node);
for (const l of toSplice) {
links.splice(links.indexOf(l), 1);
}
}
// only respond once per keydown
let lastKeyDown = -1;
function keydown() {
d3.event.preventDefault();
if (lastKeyDown !== -1) return;
lastKeyDown = d3.event.keyCode;
// ctrl
if (d3.event.keyCode === 17) {
circle.call(drag);
svg.classed('ctrl', true);
return;
}
if (!selectedNode && !selectedLink) return;
switch (d3.event.keyCode) {
case 8: // backspace
case 46: // delete
if (selectedNode) {
nodes.splice(nodes.indexOf(selectedNode), 1);
spliceLinksForNode(selectedNode);
} else if (selectedLink) {
links.splice(links.indexOf(selectedLink), 1);
}
selectedLink = null;
selectedNode = null;
restart();
break;
case 66: // B
if (selectedLink) {
// set link direction to both left and right
selectedLink.left = true;
selectedLink.right = true;
}
restart();
break;
case 76: // L
if (selectedLink) {
// set link direction to left only
selectedLink.left = true;
selectedLink.right = false;
}
restart();
break;
case 82: // R
if (selectedNode) {
// toggle node reflexivity
selectedNode.reflexive = !selectedNode.reflexive;
} else if (selectedLink) {
// set link direction to right only
selectedLink.left = false;
selectedLink.right = true;
}
restart();
break;
}
}
function keyup() {
lastKeyDown = -1;
// ctrl
if (d3.event.keyCode === 17) {
circle.on('.drag', null);
svg.classed('ctrl', false);
}
}
// app starts here
svg.on('mousedown', mousedown)
.on('mousemove', mousemove)
.on('mouseup', mouseup);
d3.select(window)
.on('keydown', keydown)
.on('keyup', keyup);
restart();
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Directed Graph Editor</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
</body>
<script src="http://d3js.org/d3.v5.min.js"></script>
<script src="app.js"></script>
</html>
Copyright (c) 2013 Ross Kirsling
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@rkirsling
Copy link
Author

rkirsling commented May 28, 2018

Man do I wish gists triggered email notifications. 😓

@hrb90: It has been using v3 (see index.html line 12), but I've just upgraded it to v5. 🙂

@weslord: Thank you! I haven't looked at D3 in years, so I wanted to go through and understand everything for myself, but your diff was immensely helpful for identifying all the intertwining breakages! I ended up with virtually the same set of changes, but also took the opportunity to modernize the JavaScript while I was at it (2013 was certainly a different time 😛).

@sighmon
Copy link

sighmon commented Jul 10, 2018

@rkirsling thanks so much for the library. I'm wanting to display some text on the links as you've done for showing the id of the nodes here:

// show node IDs
  g.append('svg:text')
    .attr('x', 0)
    .attr('y', 4)
    .attr('class', 'id')
    .text((d) => d.id);

  circle = g.merge(circle);

But couldn't work out where to attach it to the links. Might you be able to help out?

@rkirsling
Copy link
Author

@sighmon: The idea is that nodes are visually a <g> containing <circle> and <text>, but links are currently just a bare <path>, so you'll need to wrap them in a <g> if you want them to have a <text> too. That basically just amounts to making lines 123-149 match lines 151-242 (and also tweaking lines 83 and 102 accordingly). And then you get to have the real fun of positioning that text. 😛

@sighmon
Copy link

sighmon commented Jul 12, 2018

@rkirsling Perfect thanks - managed to get that working nicely - including adding a rect behind it to give the text a background-colour.

@zsl1549
Copy link

zsl1549 commented Nov 22, 2018

@rkirsling Thank you very much. I want to use your D3 on the react. Do you have any good advice?

@john012343210
Copy link

john012343210 commented Nov 20, 2019

nice use of d3. I appreciate your work. But how could we connect to the database so that the database could know what you change about the graph

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