document.onload = (function(d3, saveAs, Blob, undefined){ "use strict"; // define graphcreator object var GraphCreator = function(svg, nodes, edges){ var thisGraph = this; thisGraph.idct = 0; thisGraph.nodes = nodes || []; thisGraph.edges = edges || []; thisGraph.state = { selectedNode: null, selectedEdge: null, mouseDownNode: null, mouseDownLink: null, justDragged: false, justScaleTransGraph: false, lastKeyDown: -1, shiftNodeDrag: false, selectedText: null }; // define arrow markers for graph links var defs = svg.append('svg:defs'); defs.append('svg:marker') .attr('id', 'end-arrow') .attr('viewBox', '0 -5 10 10') .attr('refX', "32") .attr('markerWidth', 3.5) .attr('markerHeight', 3.5) .attr('orient', 'auto') .append('svg:path') .attr('d', 'M0,-5L10,0L0,5'); // define arrow markers for leading arrow defs.append('svg:marker') .attr('id', 'mark-end-arrow') .attr('viewBox', '0 -5 10 10') .attr('refX', 7) .attr('markerWidth', 3.5) .attr('markerHeight', 3.5) .attr('orient', 'auto') .append('svg:path') .attr('d', 'M0,-5L10,0L0,5'); thisGraph.svg = svg; thisGraph.svgG = svg.append("g") .classed(thisGraph.consts.graphClass, true); var svgG = thisGraph.svgG; // displayed when dragging between nodes thisGraph.dragLine = svgG.append('svg:path') .attr('class', 'link dragline hidden') .attr('d', 'M0,0L0,0') .style('marker-end', 'url(#mark-end-arrow)'); // svg nodes and edges thisGraph.paths = svgG.append("g").selectAll("g"); thisGraph.circles = svgG.append("g").selectAll("g"); thisGraph.drag = d3.behavior.drag() .origin(function(d){ return {x: d.x, y: d.y}; }) .on("drag", function(args){ thisGraph.state.justDragged = true; thisGraph.dragmove.call(thisGraph, args); }) .on("dragend", function() { // todo check if edge-mode is selected }); // listen for key events d3.select(window).on("keydown", function(){ thisGraph.svgKeyDown.call(thisGraph); }) .on("keyup", function(){ thisGraph.svgKeyUp.call(thisGraph); }); svg.on("mousedown", function(d){thisGraph.svgMouseDown.call(thisGraph, d);}); svg.on("mouseup", function(d){thisGraph.svgMouseUp.call(thisGraph, d);}); // listen for dragging var dragSvg = d3.behavior.zoom() .on("zoom", function(){ if (d3.event.sourceEvent.shiftKey){ // TODO the internal d3 state is still changing return false; } else{ thisGraph.zoomed.call(thisGraph); } return true; }) .on("zoomstart", function(){ var ael = d3.select("#" + thisGraph.consts.activeEditId).node(); if (ael){ ael.blur(); } if (!d3.event.sourceEvent.shiftKey) d3.select('body').style("cursor", "move"); }) .on("zoomend", function(){ d3.select('body').style("cursor", "auto"); }); svg.call(dragSvg).on("dblclick.zoom", null); // listen for resize window.onresize = function(){thisGraph.updateWindow(svg);}; // handle download data d3.select("#download-input").on("click", function(){ var saveEdges = []; thisGraph.edges.forEach(function(val, i){ saveEdges.push({source: val.source.id, target: val.target.id}); }); var blob = new Blob([window.JSON.stringify({"nodes": thisGraph.nodes, "edges": saveEdges})], {type: "text/plain;charset=utf-8"}); saveAs(blob, "mydag.json"); }); // handle uploaded data d3.select("#upload-input").on("click", function(){ document.getElementById("hidden-file-upload").click(); }); d3.select("#hidden-file-upload").on("change", function(){ if (window.File && window.FileReader && window.FileList && window.Blob) { var uploadFile = this.files[0]; var filereader = new window.FileReader(); filereader.onload = function(){ var txtRes = filereader.result; // TODO better error handling try{ var jsonObj = JSON.parse(txtRes); thisGraph.deleteGraph(true); thisGraph.nodes = jsonObj.nodes; thisGraph.setIdCt(jsonObj.nodes.length + 1); var newEdges = jsonObj.edges; newEdges.forEach(function(e, i){ newEdges[i] = {source: thisGraph.nodes.filter(function(n){return n.id == e.source;})[0], target: thisGraph.nodes.filter(function(n){return n.id == e.target;})[0]}; }); thisGraph.edges = newEdges; thisGraph.updateGraph(); }catch(err){ window.alert("Error parsing uploaded file\nerror message: " + err.message); return; } }; filereader.readAsText(uploadFile); } else { alert("Your browser won't let you save this graph -- try upgrading your browser to IE 10+ or Chrome or Firefox."); } }); // handle delete graph d3.select("#delete-graph").on("click", function(){ thisGraph.deleteGraph(false); }); }; GraphCreator.prototype.setIdCt = function(idct){ this.idct = idct; }; GraphCreator.prototype.consts = { selectedClass: "selected", connectClass: "connect-node", circleGClass: "conceptG", graphClass: "graph", activeEditId: "active-editing", BACKSPACE_KEY: 8, DELETE_KEY: 46, ENTER_KEY: 13, nodeRadius: 50 }; /* PROTOTYPE FUNCTIONS */ GraphCreator.prototype.dragmove = function(d) { var thisGraph = this; if (thisGraph.state.shiftNodeDrag){ thisGraph.dragLine.attr('d', 'M' + d.x + ',' + d.y + 'L' + d3.mouse(thisGraph.svgG.node())[0] + ',' + d3.mouse(this.svgG.node())[1]); } else{ d.x += d3.event.dx; d.y += d3.event.dy; thisGraph.updateGraph(); } }; GraphCreator.prototype.deleteGraph = function(skipPrompt){ var thisGraph = this, doDelete = true; if (!skipPrompt){ doDelete = window.confirm("Press OK to delete this graph"); } if(doDelete){ thisGraph.nodes = []; thisGraph.edges = []; thisGraph.updateGraph(); } }; /* select all text in element: taken from http://stackoverflow.com/questions/6139107/programatically-select-text-in-a-contenteditable-html-element */ GraphCreator.prototype.selectElementContents = function(el) { var range = document.createRange(); range.selectNodeContents(el); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); }; /* insert svg line breaks: taken from http://stackoverflow.com/questions/13241475/how-do-i-include-newlines-in-labels-in-d3-charts */ GraphCreator.prototype.insertTitleLinebreaks = function (gEl, title) { var words = title.split(/\s+/g), nwords = words.length; var el = gEl.append("text") .attr("text-anchor","middle") .attr("dy", "-" + (nwords-1)*7.5); for (var i = 0; i < words.length; i++) { var tspan = el.append('tspan').text(words[i]); if (i > 0) tspan.attr('x', 0).attr('dy', '15'); } }; // remove edges associated with a node GraphCreator.prototype.spliceLinksForNode = function(node) { var thisGraph = this, toSplice = thisGraph.edges.filter(function(l) { return (l.source === node || l.target === node); }); toSplice.map(function(l) { thisGraph.edges.splice(thisGraph.edges.indexOf(l), 1); }); }; GraphCreator.prototype.replaceSelectEdge = function(d3Path, edgeData){ var thisGraph = this; d3Path.classed(thisGraph.consts.selectedClass, true); if (thisGraph.state.selectedEdge){ thisGraph.removeSelectFromEdge(); } thisGraph.state.selectedEdge = edgeData; }; GraphCreator.prototype.replaceSelectNode = function(d3Node, nodeData){ var thisGraph = this; d3Node.classed(this.consts.selectedClass, true); if (thisGraph.state.selectedNode){ thisGraph.removeSelectFromNode(); } thisGraph.state.selectedNode = nodeData; }; GraphCreator.prototype.removeSelectFromNode = function(){ var thisGraph = this; thisGraph.circles.filter(function(cd){ return cd.id === thisGraph.state.selectedNode.id; }).classed(thisGraph.consts.selectedClass, false); thisGraph.state.selectedNode = null; }; GraphCreator.prototype.removeSelectFromEdge = function(){ var thisGraph = this; thisGraph.paths.filter(function(cd){ return cd === thisGraph.state.selectedEdge; }).classed(thisGraph.consts.selectedClass, false); thisGraph.state.selectedEdge = null; }; GraphCreator.prototype.pathMouseDown = function(d3path, d){ var thisGraph = this, state = thisGraph.state; d3.event.stopPropagation(); state.mouseDownLink = d; if (state.selectedNode){ thisGraph.removeSelectFromNode(); } var prevEdge = state.selectedEdge; if (!prevEdge || prevEdge !== d){ thisGraph.replaceSelectEdge(d3path, d); } else{ thisGraph.removeSelectFromEdge(); } }; // mousedown on node GraphCreator.prototype.circleMouseDown = function(d3node, d){ var thisGraph = this, state = thisGraph.state; d3.event.stopPropagation(); state.mouseDownNode = d; if (d3.event.shiftKey){ state.shiftNodeDrag = d3.event.shiftKey; // reposition dragged directed edge thisGraph.dragLine.classed('hidden', false) .attr('d', 'M' + d.x + ',' + d.y + 'L' + d.x + ',' + d.y); return; } }; /* place editable text on node in place of svg text */ GraphCreator.prototype.changeTextOfNode = function(d3node, d){ var thisGraph= this, consts = thisGraph.consts, htmlEl = d3node.node(); d3node.selectAll("text").remove(); var nodeBCR = htmlEl.getBoundingClientRect(), curScale = nodeBCR.width/consts.nodeRadius, placePad = 5*curScale, useHW = curScale > 1 ? nodeBCR.width*0.71 : consts.nodeRadius*1.42; // replace with editableconent text var d3txt = thisGraph.svg.selectAll("foreignObject") .data([d]) .enter() .append("foreignObject") .attr("x", nodeBCR.left + placePad ) .attr("y", nodeBCR.top + placePad) .attr("height", 2*useHW) .attr("width", useHW) .append("xhtml:p") .attr("id", consts.activeEditId) .attr("contentEditable", "true") .text(d.title) .on("mousedown", function(d){ d3.event.stopPropagation(); }) .on("keydown", function(d){ d3.event.stopPropagation(); if (d3.event.keyCode == consts.ENTER_KEY && !d3.event.shiftKey){ this.blur(); } }) .on("blur", function(d){ d.title = this.textContent; thisGraph.insertTitleLinebreaks(d3node, d.title); d3.select(this.parentElement).remove(); }); return d3txt; }; // mouseup on nodes GraphCreator.prototype.circleMouseUp = function(d3node, d){ var thisGraph = this, state = thisGraph.state, consts = thisGraph.consts; // reset the states state.shiftNodeDrag = false; d3node.classed(consts.connectClass, false); var mouseDownNode = state.mouseDownNode; if (!mouseDownNode) return; thisGraph.dragLine.classed("hidden", true); if (mouseDownNode !== d){ // we're in a different node: create new edge for mousedown edge and add to graph var newEdge = {source: mouseDownNode, target: d}; var filtRes = thisGraph.paths.filter(function(d){ if (d.source === newEdge.target && d.target === newEdge.source){ thisGraph.edges.splice(thisGraph.edges.indexOf(d), 1); } return d.source === newEdge.source && d.target === newEdge.target; }); if (!filtRes[0].length){ thisGraph.edges.push(newEdge); thisGraph.updateGraph(); } } else{ // we're in the same node if (state.justDragged) { // dragged, not clicked state.justDragged = false; } else{ // clicked, not dragged if (d3.event.shiftKey){ // shift-clicked node: edit text content var d3txt = thisGraph.changeTextOfNode(d3node, d); var txtNode = d3txt.node(); thisGraph.selectElementContents(txtNode); txtNode.focus(); } else{ if (state.selectedEdge){ thisGraph.removeSelectFromEdge(); } var prevNode = state.selectedNode; if (!prevNode || prevNode.id !== d.id){ thisGraph.replaceSelectNode(d3node, d); } else{ thisGraph.removeSelectFromNode(); } } } } state.mouseDownNode = null; return; }; // end of circles mouseup // mousedown on main svg GraphCreator.prototype.svgMouseDown = function(){ this.state.graphMouseDown = true; }; // mouseup on main svg GraphCreator.prototype.svgMouseUp = function(){ var thisGraph = this, state = thisGraph.state; if (state.justScaleTransGraph) { // dragged not clicked state.justScaleTransGraph = false; } else if (state.graphMouseDown && d3.event.shiftKey){ // clicked not dragged from svg var xycoords = d3.mouse(thisGraph.svgG.node()), d = {id: thisGraph.idct++, title: "new concept", x: xycoords[0], y: xycoords[1]}; thisGraph.nodes.push(d); thisGraph.updateGraph(); // make title of text immediently editable var d3txt = thisGraph.changeTextOfNode(thisGraph.circles.filter(function(dval){ return dval.id === d.id; }), d), txtNode = d3txt.node(); thisGraph.selectElementContents(txtNode); txtNode.focus(); } else if (state.shiftNodeDrag){ // dragged from node state.shiftNodeDrag = false; thisGraph.dragLine.classed("hidden", true); } state.graphMouseDown = false; }; // keydown on main svg GraphCreator.prototype.svgKeyDown = function() { var thisGraph = this, state = thisGraph.state, consts = thisGraph.consts; // make sure repeated key presses don't register for each keydown if(state.lastKeyDown !== -1) return; state.lastKeyDown = d3.event.keyCode; var selectedNode = state.selectedNode, selectedEdge = state.selectedEdge; switch(d3.event.keyCode) { case consts.BACKSPACE_KEY: case consts.DELETE_KEY: d3.event.preventDefault(); if (selectedNode){ thisGraph.nodes.splice(thisGraph.nodes.indexOf(selectedNode), 1); thisGraph.spliceLinksForNode(selectedNode); state.selectedNode = null; thisGraph.updateGraph(); } else if (selectedEdge){ thisGraph.edges.splice(thisGraph.edges.indexOf(selectedEdge), 1); state.selectedEdge = null; thisGraph.updateGraph(); } break; } }; GraphCreator.prototype.svgKeyUp = function() { this.state.lastKeyDown = -1; }; // call to propagate changes to graph GraphCreator.prototype.updateGraph = function(){ var thisGraph = this, consts = thisGraph.consts, state = thisGraph.state; thisGraph.paths = thisGraph.paths.data(thisGraph.edges, function(d){ return String(d.source.id) + "+" + String(d.target.id); }); var paths = thisGraph.paths; // update existing paths paths.style('marker-end', 'url(#end-arrow)') .classed(consts.selectedClass, function(d){ return d === state.selectedEdge; }) .attr("d", function(d){ return "M" + d.source.x + "," + d.source.y + "L" + d.target.x + "," + d.target.y; }); // add new paths paths.enter() .append("path") .style('marker-end','url(#end-arrow)') .classed("link", true) .attr("d", function(d){ return "M" + d.source.x + "," + d.source.y + "L" + d.target.x + "," + d.target.y; }) .on("mousedown", function(d){ thisGraph.pathMouseDown.call(thisGraph, d3.select(this), d); } ) .on("mouseup", function(d){ state.mouseDownLink = null; }); // remove old links paths.exit().remove(); // update existing nodes thisGraph.circles = thisGraph.circles.data(thisGraph.nodes, function(d){ return d.id;}); thisGraph.circles.attr("transform", function(d){return "translate(" + d.x + "," + d.y + ")";}); // add new nodes var newGs= thisGraph.circles.enter() .append("g"); newGs.classed(consts.circleGClass, true) .attr("transform", function(d){return "translate(" + d.x + "," + d.y + ")";}) .on("mouseover", function(d){ if (state.shiftNodeDrag){ d3.select(this).classed(consts.connectClass, true); } }) .on("mouseout", function(d){ d3.select(this).classed(consts.connectClass, false); }) .on("mousedown", function(d){ thisGraph.circleMouseDown.call(thisGraph, d3.select(this), d); }) .on("mouseup", function(d){ thisGraph.circleMouseUp.call(thisGraph, d3.select(this), d); }) .call(thisGraph.drag); newGs.append("circle") .attr("r", String(consts.nodeRadius)); newGs.each(function(d){ thisGraph.insertTitleLinebreaks(d3.select(this), d.title); }); // remove old nodes thisGraph.circles.exit().remove(); }; GraphCreator.prototype.zoomed = function(){ this.state.justScaleTransGraph = true; d3.select("." + this.consts.graphClass) .attr("transform", "translate(" + d3.event.translate + ") scale(" + d3.event.scale + ")"); }; GraphCreator.prototype.updateWindow = function(svg){ var docEl = document.documentElement, bodyEl = document.getElementsByTagName('body')[0]; var x = window.innerWidth || docEl.clientWidth || bodyEl.clientWidth; var y = window.innerHeight|| docEl.clientHeight|| bodyEl.clientHeight; svg.attr("width", x).attr("height", y); }; /**** MAIN ****/ // warn the user when leaving window.onbeforeunload = function(){ return "Make sure to save your graph locally before leaving :-)"; }; var docEl = document.documentElement, bodyEl = document.getElementsByTagName('body')[0]; var width = window.innerWidth || docEl.clientWidth || bodyEl.clientWidth, height = window.innerHeight|| docEl.clientHeight|| bodyEl.clientHeight; var xLoc = width/2 - 25, yLoc = 100; // initial node data var nodes = [{title: "new concept", id: 0, x: xLoc, y: yLoc}, {title: "new concept", id: 1, x: xLoc, y: yLoc + 200}]; var edges = [{source: nodes[1], target: nodes[0]}]; /** MAIN SVG **/ var svg = d3.select("body").append("svg") .attr("width", width) .attr("height", height); var graph = new GraphCreator(svg, nodes, edges); graph.setIdCt(2); graph.updateGraph(); })(window.d3, window.saveAs, window.Blob);