Skip to content

Instantly share code, notes, and snippets.

@magjac
Last active July 9, 2021 20:48
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save magjac/b2bf6da945e725a605e0d077781457b2 to your computer and use it in GitHub Desktop.
Save magjac/b2bf6da945e725a605e0d077781457b2 to your computer and use it in GitHub Desktop.
D3 Graphviz GUI editor proof-of-concept

This is a proof-of-concept of a graphical editor that allows you to to edit a Graphviz graph graphically, by drawing edges between nodes and inserting new nodes by using the mouse only. It also allows you to edit the textual DOT language description and directly see the graph transform itself into a new layout in an animated transition.

The GUI editor is perfectly usable as it is, but it has some simplifications that needs to be addressed in a production quality application in order not to hamper productivity:

  • Draws only the standard type edge with alternating colors, although you can edit all aspects of it with the text editor afterwards and see the changes directly.
  • Draws only two kinds of node shapes, alternating between ellipse and polygon and different colors and using predefined names. Also this can of course be changed with the text editor.

Help is available by clicking the question mark in the top right corner.

This small proof-of-concept application is built with d3-graphviz and has been created to demonstrate the capabilities of its Draw API that allows you to build your own application around a Graphviz graph.

If you are interested in discussing making this proof-of-concept into a production quality application, please join the d3-graphviz Slack.

<!DOCTYPE html>
<html style="height: calc(100% - 16px);">
<meta charset="utf-8">
<style type="text/css" media="screen">
#editor {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
border: 1px solid lightgray;
}
</style>
<body style="height: 100%">
<script src="https://d3js.org/d3.v5.js"></script>
<script src="https://unpkg.com/viz.js@1.8.0/viz.js" type="javascript/worker"></script>
<script src="https://unpkg.com/d3-graphviz@1.5.1/build/d3-graphviz.min.js"></script>
<script src="https://unpkg.com/ace-builds@1.3.3/src-noconflict/ace.js" type="text/javascript" charset="utf-8"></script>
<div id="editor" style="width: 50%; float: left"></div>
<div style="width: 50%; height: 0%">
<div id="help-text" style="position: absolute; top: 3px; display: none; font: 16px/1.5em 'Helvetica Neue', Helvetica, sans-serif;">
<b>Draw an edge</b>:<br>
Right-click on the node to draw the edge from<br>
Double-click on the node to draw the edge to<br>
Press the ESC key to abort<br>
<br>
<b>Draw a node</b>:<br>
Middle-click the background<br>
<br>
<b>Delete a node or an edge</b>:<br>
Select a node or an edge by clicking it<br>
Press the DEL key
<br>
</div>
</div>
<div id="graph" style="width: 50%; height: 100%; margin-left: 50%; text-align: center">
<div id="error-message" style="position: absolute; top: 1%; left: 51%; color: red"></div>
</div>
<div id="graph2" style="width: 50%; height: 0%; margin-left: 50%">
<div id="help" title="Show help" style="position: absolute; top: 3px; left: calc(100% - 28px)">
<svg width="25" height="25" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
<path d="M11 3A8 8 0 0 0 3 11 8 8 0 0 0 11 19 8 8 0 0 0 19 11 8 8 0 0 0 11 3M10.994 6.5C11.758 6.5 12.379 6.719 12.857 7.158 13.336 7.589 13.576 8.142 13.576 8.816 13.576 9.109 13.495 9.406 13.336 9.707 13.176 10.01 13.03 10.223 12.908 10.354 12.791 10.475 12.623 10.635 12.404 10.83L12.342 10.891C11.83 11.338 11.572 11.785 11.572 12.232V12.719H10.389V12.146C10.389 11.781 10.469 11.467 10.629 11.207 10.788 10.939 11.07 10.625 11.473 10.268 11.699 10.06 11.859 9.914 11.951 9.816 12.05 9.711 12.148 9.569 12.24 9.391 12.341 9.204 12.393 9.01 12.393 8.816 12.393 8.442 12.266 8.142 12.01 7.914 11.77 7.686 11.431 7.572 10.994 7.572 10.272 7.572 9.776 7.964 9.508 8.744L8.424 8.305C8.6 7.841 8.904 7.426 9.332 7.06 9.769 6.687 10.322 6.5 10.994 6.5M10.98 13.842C11.224 13.842 11.426 13.923 11.586 14.09 11.754 14.249 11.838 14.442 11.838 14.67 11.838 14.898 11.754 15.09 11.586 15.256 11.426 15.418 11.224 15.5 10.98 15.5 10.737 15.5 10.531 15.418 10.363 15.256 10.204 15.09 10.125 14.898 10.125 14.67 10.125 14.442 10.204 14.249 10.363 14.09 10.531 13.923 10.737 13.842 10.98 13.842" transform="translate(0-.002)" fill="#4d4d4d" fill-rule="evenodd"/>
</svg>
</div>
</div>
<script>
'use strict';
var isDrawingEdge = false;
var isDrawingNode = false;
var startNode;
var selectedEdge = d3.select(null);
var selectedEdgeFill;
var selectedEdgeStroke;
var selectedNode = d3.select(null);
var selectedNodeStroke;
var selectedNodeFill;
var nodeIndex;
var edgeIndex;
var editor = ace.edit("editor");
var pendingUpdate = false
var rendering = false;
var showHelp = false;
var dotSrc = `strict digraph {
node [style="filled"]
a [shape="ellipse" fillcolor="` + d3.schemeCategory10[0] + `"]
b [shape="polygon" fillcolor="` + d3.schemeCategory10[1] + `"]
a -> b [fillcolor="` + d3.schemePaired[0] + `" color="` + d3.schemePaired[1] + `"]
}`;
var graphviz = d3.select("#graph").graphviz()
.attributer(attributer)
.transition(function() {
return d3.transition().duration(1000);
});
editor.getSession().on('change', render);
editor.setValue(dotSrc);
function attributer(datum, index, nodes) {
var selection = d3.select(this);
if (datum.tag == "svg") {
var width = d3.select('#graph').node().clientWidth;
var height = d3.select('#graph').node().clientHeight;
var x = "10";
var y = "10";
var unit = 'px';
selection
.attr("width", width + unit)
.attr("height", height + unit);
datum.attributes.width = width + unit;
datum.attributes.height = height + unit;
}
}
function render() {
dotSrc = editor.getValue();
if (!dotSrc) {
return;
}
if (rendering) {
pendingUpdate = true;
return;
}
rendering = true;
d3.select("#error-message").text("");
graphviz
.onerror(handleError)
.renderDot(dotSrc, startApp);
}
function handleError(errorMessage) {
d3.select("#error-message").text(errorMessage);
rendering = false;
if (pendingUpdate) {
pendingUpdate = false;
render();
}
}
function startApp() {
var nodes = d3.selectAll(".node");
var edges = d3.selectAll(".edge");
// click outside of nodes
d3.select(document).on("click", function() {
var event = d3.event;
event.preventDefault();
event.stopPropagation();
unSelectEdge();
unSelectNode();
if (event.which == 2) {
var graph0 = d3.select("svg").selectWithoutDataPropagation("g");
var width = 54;
var height = 36;
var [x0, y0] = d3.mouse(graph0.node());
var x1 = x0 - width / 2;
var x2 = x0 + width / 2;
var y1 = y0 - height / 2;
var y2 = y0 + height / 2;
if (nodeIndex == null) {
nodeIndex = d3.selectAll('.node').size();
} else {
nodeIndex += 1;
}
var shapes = ['ellipse', 'polygon'];
var shape = shapes[nodeIndex % shapes.length];
var numLetters = 'z'.charCodeAt(0) - 'a'.charCodeAt(0) + 1;
var letter = String.fromCharCode('a'.charCodeAt(0) + (nodeIndex % numLetters));
var digit = Math.floor(nodeIndex / numLetters) || '';
var nodeName = letter + digit;
var fillcolor = d3.schemeCategory10[nodeIndex % 10];
var color = 'black';
graphviz
.drawNode(x1, y1, width, height, shape, nodeName, {text: nodeName, fillcolor: fillcolor, color: color, id: 'drawn-node', labeljust: 'l'})
.insertDrawnNode(nodeName);
var dotSrcLines = dotSrc.split('\n');
var newNodeString = ' ' + nodeName + ' [shape="' + shape + '" color="' + color + '"; fillcolor="' + fillcolor + '"; penwidth="1"]';
dotSrcLines.splice(-1, 0, newNodeString);
dotSrc = dotSrcLines.join('\n');
editor.setValue(dotSrc);
}
});
// keyup outside of nodes
d3.select(document).on("keyup", function() {
var event = d3.event;
event.preventDefault();
if (event.keyCode == 27) {
graphviz.removeDrawnEdge();
unSelectEdge();
unSelectNode();
}
if (event.keyCode == 46) {
deleteSelectedEdge();
deleteSelectedNode();
graphviz.removeDrawnEdge();
editor.setValue(dotSrc);
}
isDrawingEdge = false;
});
// move
d3.select(document).on("mousemove", function() {
var event = d3.event;
event.preventDefault();
event.stopPropagation();
var graph0 = d3.select("svg").selectWithoutDataPropagation("g");
var [x0, y0] = d3.mouse(graph0.node());
var shortening = 2; // avoid mouse pointing on edge
if (isDrawingEdge) {
graphviz
.moveDrawnEdgeEndPoint(x0, y0, {shortening: shortening})
}
});
// click and mousedown on nodes
nodes.on("click mousedown", function() {
var event = d3.event;
event.preventDefault();
event.stopPropagation();
if (!isDrawingEdge && event.which == 1) {
unSelectEdge();
selectNode(d3.select(this));
}
});
// double-click on nodes
nodes.on("dblclick", function() {
var event = d3.event;
event.preventDefault();
event.stopPropagation();
unSelectEdge();
if (isDrawingEdge) {
var endNode = d3.select(this);
var startNodeName = startNode.selectWithoutDataPropagation("title").text();
var endNodeName = endNode.selectWithoutDataPropagation("title").text();
graphviz
.insertDrawnEdge(startNodeName + '->' + endNodeName);
var fillcolor = d3.schemePaired[(edgeIndex * 2) % 12];
var color = d3.schemePaired[(edgeIndex * 2 + 1) % 12];
var dotSrcLines = dotSrc.split('\n');
var newEdgeString = ' ' + startNodeName + ' -> ' + endNodeName + ' [color="' + color + '"; fillcolor="' + fillcolor + '"; penwidth="1"]';
dotSrcLines.splice(-1, 0, newEdgeString);
dotSrc = dotSrcLines.join('\n');
editor.setValue(dotSrc);
}
isDrawingEdge = false;
});
// right-click on nodes
nodes.on("contextmenu", function() {
var event = d3.event;
event.preventDefault();
event.stopPropagation();
unSelectEdge();
unSelectNode();
graphviz.removeDrawnEdge();
startNode = d3.select(this);
var graph0 = d3.select("svg").selectWithoutDataPropagation("g");
var [x0, y0] = d3.mouse(graph0.node());
if (edgeIndex == null) {
edgeIndex = d3.selectAll('.edge').size();
} else {
edgeIndex += 1;
}
var fillcolor = d3.schemePaired[(edgeIndex * 2) % 12];
var color = d3.schemePaired[(edgeIndex * 2 + 1) % 12];
graphviz
.drawEdge(x0, y0, x0, y0, {fillcolor: fillcolor, color: color});
isDrawingEdge = true;
});
// click and mousedown on edges
edges.on("click mousedown", function() {
var event = d3.event;
event.preventDefault();
event.stopPropagation();
unSelectNode();
selectEdge(d3.select(this));
});
// right-click outside of nodes
d3.select(document).on("contextmenu", function() {
var event = d3.event;
event.preventDefault();
event.stopPropagation();
unSelectEdge();
unSelectNode();
});
// click on help
d3.select("#help").on("click", function() {
var event = d3.event;
event.preventDefault();
event.stopPropagation();
toggleShowHelp();
});
rendering = false;
if (pendingUpdate) {
pendingUpdate = false;
render();
}
}
function selectEdge(edge) {
unSelectEdge();
selectedEdge = edge;
selectedEdgeFill = selectedEdge.selectAll('polygon').attr("fill");
selectedEdgeStroke = selectedEdge.selectAll('polygon').attr("stroke");
selectedEdge.selectAll('path, polygon').attr("stroke", "red");
selectedEdge.selectAll('polygon').attr("fill", "red");
}
function unSelectEdge() {
selectedEdge.selectAll('path, polygon').attr("stroke", selectedEdgeStroke);
selectedEdge.selectAll('polygon').attr("fill", selectedEdgeFill);
selectedEdge = d3.select(null);
}
function deleteSelectedEdge() {
selectedEdge.style("display", "none");
if (selectedEdge.size() != 0) {
var edgeName = selectedEdge.selectWithoutDataPropagation("title").text();
edgeName = edgeName.replace('->', ' -> ');
var dotSrcLines = dotSrc.split('\n');
while (true) {
var i = dotSrcLines.findIndex(function (element, index) {
return element.indexOf(edgeName) >= 0;
});
if (i < 0)
break;
dotSrcLines.splice(i, 1);
}
dotSrc = dotSrcLines.join('\n');
}
}
function selectNode(node) {
unSelectNode();
selectedNode = node;
selectedNodeFill = selectedNode.selectAll('polygon, ellipse').attr("fill");
selectedNodeStroke = selectedNode.selectAll('polygon, ellipse').attr("stroke");
selectedNode.selectAll('polygon, ellipse').attr("stroke", "red");
selectedNode.selectAll('polygon, ellipse').attr("fill", "red");
}
function unSelectNode() {
selectedNode.selectAll('polygon, ellipse').attr("stroke", selectedNodeStroke);
selectedNode.selectAll('polygon, ellipse').attr("fill", selectedNodeFill);
selectedNode = d3.select(null);
}
function deleteSelectedNode() {
selectedNode.style("display", "none");
if (selectedNode.size() != 0) {
var nodeName = selectedNode.selectWithoutDataPropagation("title").text();
var dotSrcLines = dotSrc.split('\n');
while (true) {
var i = dotSrcLines.findIndex(function (element, index) {
var trimmedElement = element.trim();
if (trimmedElement == nodeName) {
return true;
}
if (trimmedElement.indexOf(nodeName + ' ') == 0) {
return true;
}
if (trimmedElement.indexOf(' ' + nodeName + ' ') >= 0) {
return true;
}
return false;
});
if (i < 0)
break;
dotSrcLines.splice(i, 1);
}
dotSrc = dotSrcLines.join('\n');
}
}
function toggleShowHelp() {
showHelp = !showHelp;
if (showHelp) {
d3.select("#editor").style("display", "none");
d3.select("#help-text").style("display", "block");
} else {
d3.select("#editor").style("display", "block");
d3.select("#help-text").style("display", "none");
}
}
d3.select(window).on("resize", resizeSVG);
function resizeSVG() {
var width = d3.select('#graph').node().clientWidth;
var height = d3.select('#graph').node().clientHeight;
d3.select("#graph").selectWithoutDataPropagation("svg")
.transition()
.duration(700)
.attr("width", width)
.attr("height", height);
};
</script>
</html>
@ErChulo
Copy link

ErChulo commented Jul 9, 2021

genius

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment