var docEl = document.documentElement, bodyEl = document.getElementsByTagName('body')[0]; var width = window.innerWidth || docEl.clientWidth || bodyEl.clientWidth, height = window.innerHeight || docEl.clientHeight || bodyEl.clientHeight; function fitscreen(nodes, width, height) { var minX = nodes[0].x; var maxX = nodes[0].x; var minY = nodes[0].y; var maxY = nodes[0].y; var offsetX = 0, offsetY = 0; nodes.forEach(function (e, i) { if (e.x > maxX) { maxX = e.x } if (e.x < minX) { minX = e.x } if (e.y < minY) { minY = e.y } if (e.y < minY) { minY = e.y } }); if (minX < 0) { offsetX = - minX; minX += offsetX; maxX += offsetX; } if (minY < 0) { offsetY = -minY; minY += offsetY maxY += offsetY; } nodes.forEach(function (e, i) { e.x += offsetX; e.y += offsetY; e.x = (e.x - minX) * width * 0.8 / (maxX - minX) + 0.25 * width; e.y = (e.y - minY) * height * 0.8 / (maxY - minY) + 0.25 * height; }); return nodes; } document.onload = (function (d3, saveAs, Blob, undefined) { // 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', "20") .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); jsonObj.nodes = fitscreen(jsonObj.nodes, width, height) thisGraph.deleteGraph(true); thisGraph.nodes = jsonObj.nodes; thisGraph.setIdCt(jsonObj.nodes.length); 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: 25 }; /* 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: (this.idct).toString(), x: xycoords[0], y: xycoords[1] }; thisGraph.idct++; 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"; // }; // initial node data var obj = JSON.parse('{"nodes": [{"title": "0", "id": 0, "x": 38, "y": 20}, {"title": "1", "id": 1, "x": 39, "y": 26}, {"title": "2", "id": 2, "x": 40, "y": 25}, {"title": "3", "id": 3, "x": 36, "y": 23}, {"title": "4", "id": 4, "x": 33, "y": 10}, {"title": "5", "id": 5, "x": 37, "y": 12}, {"title": "6", "id": 6, "x": 38, "y": 13}, {"title": "7", "id": 7, "x": 37, "y": 20}, {"title": "8", "id": 8, "x": 41, "y": 9}, {"title": "9", "id": 9, "x": 41, "y": 13}, {"title": "10", "id": 10, "x": 36, "y": -5}, {"title": "11", "id": 11, "x": 38, "y": 15}, {"title": "12", "id": 12, "x": 38, "y": 15}, {"title": "13", "id": 13, "x": 37, "y": 15}, {"title": "14", "id": 14, "x": 35, "y": 14}, {"title": "15", "id": 15, "x": 39, "y": 19}], "edges": [{"source": 8, "target": 10}, {"source": 10, "target": 4}, {"source": 4, "target": 14}, {"source": 14, "target": 0}, {"source": 0, "target": 7}, {"source": 7, "target": 3}, {"source": 3, "target": 1}, {"source": 1, "target": 2}, {"source": 2, "target": 15}, {"source": 15, "target": 11}, {"source": 11, "target": 12}, {"source": 12, "target": 13}, {"source": 13, "target": 5}, {"source": 5, "target": 6}, {"source": 6, "target": 9}, {"source": 9, "target": 8}]}') obj.nodes = fitscreen(obj.nodes, width, height); var newEdges = obj.edges; newEdges.forEach(function (e, i) { newEdges[i] = { source: obj.nodes.filter(function (n) { return n.id == e.source; })[0], target: obj.nodes.filter(function (n) { return n.id == e.target; })[0] }; }); obj.edges = newEdges; /** MAIN SVG **/ var svg = d3.select("body").append("svg") .attr("width", width) .attr("height", height); var graph = new GraphCreator(svg, obj.nodes, obj.edges); graph.setIdCt(obj.nodes.length); graph.updateGraph(); })(window.d3, window.saveAs, window.Blob);