I am trying to create a "Flowchart/Workflow" type of chart/representation. Each node will be a "task" and then we will draw lines to connect each task to the next one so that we can layout the workflow.
This example is very close to what we want and so we've chosen it as a "jumping off point".
You can see the code for this example here.
And here's a snippet of it at work:
/*
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.
*/
// set up SVG for D3
const width = 500;
const height = 250;
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();
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;
}
<!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>
</html>
When you run the demo, each node can be dragged around (using the ctrl key), however doing so makes the entire structure move and self-align all the nodes.
What I want to have happen is that you can drag the nodes, but then that's it. They stay where you put them, and nothing goes spinning/bouncing around.
So far I'm pretty sure that the answer has something to do with the d3.forceSimulation() and/or the tick() function(s). But I'm not sure how to make it do what I want.
Thanks in advance for any info you can offer.
PS - I am working in v5.x.x of D3.js