|
// 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(); |
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.