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