Skip to content

Instantly share code, notes, and snippets.

@weslord
Forked from rkirsling/LICENSE
Created May 12, 2018 20:01
Show Gist options
  • Save weslord/ed155f0928e40014a69bf8469978b82d to your computer and use it in GitHub Desktop.
Save weslord/ed155f0928e40014a69bf8469978b82d 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!


Based on Directed Graph Editor by Ross Kirsling.

The code has been updated slightly to work with D3 versions 4 & 5. Other than that, none of the functionality has been changed.

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
var width = 960,
height = 500,
colors = d3.scaleOrdinal(d3.schemeCategory10);
var svg = d3.select('body')
.append('svg')
.attr('oncontextmenu', 'return false;')
.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'.
var nodes = [
{id: 0, reflexive: false},
{id: 1, reflexive: true },
{id: 2, reflexive: false}
],
lastNodeId = 2,
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
var force = d3.forceSimulation()
.force("link", d3.forceLink().id(function(d) { return d.id; }))
.force("charge", d3.forceManyBody().strength(-500))
.force("x", d3.forceX(width/2))
.force("y", d3.forceY(height/2))
.nodes(nodes)
.on('tick', tick);
// 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
var drag_line = svg.append('svg:path')
.attr('class', 'link dragline hidden')
.attr('d', 'M0,0L0,0');
// handles to link and node element groups
var path = svg.append('svg:g').selectAll('path'),
circle = svg.append('svg:g').selectAll('g');
// mouse event vars
var selected_node = null,
selected_link = null,
mousedown_link = null,
mousedown_node = null,
mouseup_node = null;
function resetMouseVars() {
mousedown_node = null;
mouseup_node = null;
mousedown_link = null;
}
// update force layout (called automatically each iteration)
function tick() {
// draw directed edges with proper padding from node centers
path.attr('d', function(d) {
var deltaX = d.target.x - d.source.x,
deltaY = d.target.y - d.source.y,
dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY),
normX = deltaX / dist,
normY = deltaY / dist,
sourcePadding = d.left ? 17 : 12,
targetPadding = d.right ? 17 : 12,
sourceX = d.source.x + (sourcePadding * normX),
sourceY = d.source.y + (sourcePadding * normY),
targetX = d.target.x - (targetPadding * normX),
targetY = d.target.y - (targetPadding * normY);
return 'M' + sourceX + ',' + sourceY + 'L' + targetX + ',' + targetY;
});
circle.attr('transform', function(d) {
return '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', function(d) { return d === selected_link; })
.style('marker-start', function(d) { return d.left ? 'url(#start-arrow)' : ''; })
.style('marker-end', function(d) { return d.right ? 'url(#end-arrow)' : ''; });
// add new links
var enter = path.enter().append('svg:path')
.attr('class', 'link')
.classed('selected', function(d) { return d === selected_link; })
.style('marker-start', function(d) { return d.left ? 'url(#start-arrow)' : ''; })
.style('marker-end', function(d) { return d.right ? 'url(#end-arrow)' : ''; })
.on('mousedown', function(d) {
if(d3.event.ctrlKey) return;
// select link
mousedown_link = d;
if(mousedown_link === selected_link) selected_link = null;
else selected_link = mousedown_link;
selected_node = null;
restart();
});
// remove old links
path.exit().remove();
// merge update and enter selections
path = enter.merge(path);
// circle (node) group
// NB: the function arg is crucial here! nodes are known by id, not by index!
circle = circle.data(nodes, function(d) { return d.id; });
// update existing nodes (reflexive & selected visual states)
circle.selectAll('circle')
.style('fill', function(d) { return (d === selected_node) ? d3.rgb(colors(d.id)).brighter().toString() : colors(d.id); })
.classed('reflexive', function(d) { return d.reflexive; });
// add new nodes
var g = circle.enter().append('svg:g');
g.append('svg:circle')
.attr('class', 'node')
.attr('r', 12)
.style('fill', function(d) { return (d === selected_node) ? d3.rgb(colors(d.id)).brighter().toString() : colors(d.id); })
.style('stroke', function(d) { return d3.rgb(colors(d.id)).darker().toString(); })
.classed('reflexive', function(d) { return d.reflexive; })
.on('mouseover', function(d) {
if(!mousedown_node || d === mousedown_node) return;
// enlarge target node
d3.select(this).attr('transform', 'scale(1.1)');
})
.on('mouseout', function(d) {
if(!mousedown_node || d === mousedown_node) return;
// unenlarge target node
d3.select(this).attr('transform', '');
})
.on('mousedown', function(d) {
if(d3.event.ctrlKey) return;
// select node
mousedown_node = d;
if(mousedown_node === selected_node) selected_node = null;
else selected_node = mousedown_node;
selected_link = null;
// reposition drag line
drag_line
.style('marker-end', 'url(#end-arrow)')
.classed('hidden', false)
.attr('d', 'M' + mousedown_node.x + ',' + mousedown_node.y + 'L' + mousedown_node.x + ',' + mousedown_node.y);
restart();
})
.on('mouseup', function(d) {
if(!mousedown_node) return;
// needed by FF
drag_line
.classed('hidden', true)
.style('marker-end', '');
// check for drag-to-self
mouseup_node = d;
if(mouseup_node === mousedown_node) { 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
var source, target, direction;
if(mousedown_node.id < mouseup_node.id) {
source = mousedown_node;
target = mouseup_node;
direction = 'right';
} else {
source = mouseup_node;
target = mousedown_node;
direction = 'left';
}
var link;
link = links.filter(function(l) {
return (l.source === source && l.target === target);
})[0];
if(link) {
link[direction] = true;
} else {
link = {source: source, target: target, left: false, right: false};
link[direction] = true;
links.push(link);
}
// select new link
selected_link = link;
selected_node = null;
restart();
});
// show node IDs
g.append('svg:text')
.attr('x', 0)
.attr('y', 4)
.attr('class', 'id')
.text(function(d) { return d.id; });
// remove old nodes
circle.exit().remove();
// merge enter and update selections
circle = circle.merge(g);
// set the graph in motion
force.nodes(nodes);
force.force("link").links(links).distance(150);
force.alpha(0.3).restart();
}
function mousedown() {
// prevent I-bar on drag
//d3.event.preventDefault();
// because :active only works in WebKit?
svg.classed('active', true);
if(d3.event.ctrlKey || mousedown_node || mousedown_link) return;
// insert new node at point
var point = d3.mouse(this),
node = {id: ++lastNodeId, reflexive: false};
node.x = point[0];
node.y = point[1];
nodes.push(node);
restart();
}
function mousemove() {
if(!mousedown_node) return;
// update drag line
drag_line.attr('d', 'M' + mousedown_node.x + ',' + mousedown_node.y + 'L' + d3.mouse(this)[0] + ',' + d3.mouse(this)[1]);
restart();
}
function mouseup() {
if(mousedown_node) {
// hide drag line
drag_line
.classed('hidden', true)
.style('marker-end', '');
}
// because :active only works in WebKit?
svg.classed('active', false);
// clear mouse event vars
resetMouseVars();
}
function spliceLinksForNode(node) {
var toSplice = links.filter(function(l) {
return (l.source === node || l.target === node);
});
toSplice.map(function(l) {
links.splice(links.indexOf(l), 1);
});
}
// only respond once per keydown
var lastKeyDown = -1;
function keydown() {
d3.event.preventDefault();
if(lastKeyDown !== -1) return;
lastKeyDown = d3.event.keyCode;
// ctrl
if(d3.event.keyCode === 17) {
circle.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
svg.classed('ctrl', true);
}
if(!selected_node && !selected_link) return;
switch(d3.event.keyCode) {
case 8: // backspace
case 46: // delete
if(selected_node) {
nodes.splice(nodes.indexOf(selected_node), 1);
spliceLinksForNode(selected_node);
} else if(selected_link) {
links.splice(links.indexOf(selected_link), 1);
}
selected_link = null;
selected_node = null;
restart();
break;
case 66: // B
if(selected_link) {
// set link direction to both left and right
selected_link.left = true;
selected_link.right = true;
}
restart();
break;
case 76: // L
if(selected_link) {
// set link direction to left only
selected_link.left = true;
selected_link.right = false;
}
restart();
break;
case 82: // R
if(selected_node) {
// toggle node reflexivity
selected_node.reflexive = !selected_node.reflexive;
} else if(selected_link) {
// set link direction to right only
selected_link.left = false;
selected_link.right = true;
}
restart();
break;
}
}
function keyup() {
lastKeyDown = -1;
// ctrl
if(d3.event.keyCode === 17) {
circle
.on('mousedown.drag', null)
.on('touchstart.drag', null);
svg.classed('ctrl', false);
}
}
function dragstarted(d) {
if (!d3.event.active) force.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
function dragended(d) {
if (!d3.event.active) force.alphaTarget(0);
d.fx = null;
d.fy = null;
}
// 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-2015 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment