Skip to content

Instantly share code, notes, and snippets.

@rkirsling rkirsling/LICENSE
Last active Aug 28, 2019

Embed
What would you like to do?
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.
@jkschneider

This comment has been minimized.

Copy link

commented Aug 24, 2013

Outstanding work on this, Ross.

@ganesh-gawande

This comment has been minimized.

Copy link

commented Oct 17, 2015

I am using the library, but I am facing problem. I want to draw nodes named = 7001, 7002 but I cant. This library required the ids from 0 only.Is there any other property to display name on node, instead of ID?

@gwpl

This comment has been minimized.

Copy link

commented Nov 21, 2015

I really appreciate and like this piece of work. It's short and sweet. Is there any way to include some open (like BSD, MIT, Public Domain or Apache) license https://en.wikipedia.org/wiki/License_compatibility ?

@rkirsling

This comment has been minimized.

Copy link
Owner Author

commented Dec 3, 2015

I actually extracted this example from my Modal Logic Playground project for the specific purpose of letting others reuse the code. 😄

There didn't seem to be a precedent for adding LICENSE files to gists (or at least, to D3 examples displayed on bl.ocks.org) at the time that I created this, but the full project is MIT-licensed, and my intention is for you to be able to fork and reuse it freely!

@hrb90

This comment has been minimized.

Copy link

commented Jan 16, 2018

Hey Ross! I'm looking to port this to PureScript for, um, basically the same use case as you except with intuitionistic instead of modal logic. Do you happen to know what version of d3 this uses?

@weslord

This comment has been minimized.

Copy link

commented May 12, 2018

Hi Ross, I've created a fork that's been updated to work with D3 version 5.

The few minor changes I made were compatibility-related. All the functionality should be exactly the same.

Please feel welcome to incorporate my changes back into this gist. This is a great example project that's (deservedly) on the front page of bl.ocks.org, and updating it to the latest version will make it much more accessible to people just starting out in D3.

@rkirsling

This comment has been minimized.

Copy link
Owner Author

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

This comment has been minimized.

Copy link

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

This comment has been minimized.

Copy link
Owner Author

commented Jul 10, 2018

@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

This comment has been minimized.

Copy link

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

This comment has been minimized.

Copy link

commented Nov 22, 2018

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.