ERD Diagramming Tool, Part 1
I try to do as much as I can on the command line — not just for nostalgic reasons, but also because of the opportunities for automation that it provides. Still, one graphical tool I find myself missing on a pretty regular basis is Visio. I've long since made the switch over to OS/X, and there's really (still!) no decent equivalent in the Mac ecosystem. I've dabbled in trying to create diagrams using MetaUML, but there's something to be said for the real-time experience of dragging and dropping your artifacts as you create a diagram.
It occurred to me to wonder... how hard would something like this be to put together, really? Of course, professional tools like Visio and Gliffy have hundreds
of features that I've never needed, but what about just something to create relatively simple Entity Relationship Diagrams (ERDs)? As it
turns out, using HTML's canvas
, a workable ERD tool is not prohibitively difficult to put together. I'll present my own
approach here in three parts: in this part, I'll add support to create and define tables, in the second I'll add support for moving them
around on the page, and in the final part, I'll add relationship support (this is, the lines that connect the tables).
I'll maintain an array of objects representing the tables themselves: each table will have an upper-left corner, a width, a height, a title and a list of columns. Each table will be rendered individually (this will make dragging and dropping easier):
function renderTable(table) {
ctx.strokeStyle = "black";
ctx.fillStyle = "cyan";
ctx.strokeRect(table.x, table.y, table.width, PADDING);
ctx.strokeRect(table.x, table.y + PADDING, table.width, table.height - PADDING);
ctx.fillRect(table.x, table.y, table.width, PADDING);
ctx.fillRect(table.x, table.y + PADDING + 1, table.width, table.height - PADDING - 1);
ctx.textAlign = "left";
ctx.fillStyle = "#000";
ctx.fillText(table.title, table.x + 5, table.y + PADDING - 5, table.width - 5); // not stroke!
}
This is all pretty standard HTML canvas stuff: the only thing to really notice here is that I call strokeRect
before
calling fillRect
. If I don't do that, I end up with double-thick lines which I don't really want. Also, notice that
I call fillText
instead of strokeText
. That seems counterintuitive, but strokeText
is only used
for really large fonts when you want to see just the outline. Again, strokeText
will give me thicker lines than I want here.
Also notice the final parameter to fillText
: I cap the width at the width of the table (minus the 5 pixels of padding I added
in the first place). A rendered table is just two boxes: one small box at the top for the title and one larger box for the column list.
Of course, rendering tables isn't interesting without some way to create them. I'll attach the create code to an external button that inserts a table at the end of the list and draws it:
var tables = [];
var DEFAULT_WIDTH = 100;
var DEFAULT_HEIGHT = 150;
var PADDING = 20;
var FONT_HEIGHT = 12;
function addTable() {
tables.push({x: PADDING + tables.length * (DEFAULT_WIDTH + PADDING),
y: PADDING,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
title: "",
columns: []
});
renderTable(tables[tables.length - 1]);
}
Notice that the default x
coordinate is based on the length of the table: this causes the next table to appear slightly
to the right of the previous one. I tie this together with some initialization code:
canv = document.getElementById(canv_id);
document.getElementById(button_id).addEventListener("click", addTable);
canv.height = canv.height * window.devicePixelRatio;
canv.width = canv.width * window.devicePixelRatio;
ctx = canv.getContext('2d');
ctx.font = "arial " + FONT_HEIGHT +"px";
ctx.strokeStyle = "#000";
I scale the canvas width and height by the devicePixelRatio
for some high resolution devices but otherwise this is about
what you'd probably expect. The result is illustrated in example 1, below.
So now I can create table icons, but I can't do anything with them. The first thing I'd want to be able to do would be to name them. Now, if I were so inclined, I could probably develop a full text input library for interacting with the titles and columns, but I am in a browser after all, and browsers have a lot of sophisticated support for text input. So instead, what I'll do, is overlay the title area with a text input box. If the user double-clicks in the header area of a table, I'll initialize the input box and when the user is done typing in it, I'll dismiss it and update the table's title. Listing 4 shows the start of the double-click handler attached to the canvas:
var activeEditHeader = null;
var activeEditBody = null;
function handleDoubleClick(event) {
var x = event.offsetX;
var y = event.offsetY;
// Did the user double-click inside a table text area?
for (var i = 0; i < tables.length; i++) {
if (x > tables[i].x && x < tables[i].x + tables[i].width &&
y > tables[i].y && y < tables[i].y + PADDING) {
// Clicked header; edit name
activeEditHeader = tables[i];
activeEditBody = null;
} else if (x > tables[i].x && x < tables[i].x + tables[i].width &&
y > tables[i].y + PADDING && y < tables[i].y + tables[i].height) {
// Clicked body; edit columns
activeEditHeader = null;
activeEditBody = tables[i];
}
}
Here, I check to see if the user double-clicked inside a table header or body (and if so, which one). I have two variables to keep track of which, if any, of the table headers or bodies is being "actively" edited. If a header is active, I add an text input over the header area:
// Find the actual position of the canvas (the event target)
var canvas_abs_x = 0;
var canvas_abs_y = 0;
var element = event.target;
while (element != document.body) {
canvas_abs_x += element.offsetLeft - element.scrollLeft;
canvas_abs_y += element.offsetTop - element.scrollTop;
element = element.offsetParent;
}
if (activeEditHeader != null) {
var headerInput = document.createElement("input");
headerInput.setAttribute("type", "text");
headerInput.value = activeEditHeader.title;
// Positioned relative to the nearest positioned ancestor or the document itself
// Not positioned based on eventX/Y, but on table X/Y
headerInput.style = "position: absolute; " +
"left: " + (canvas_abs_x + activeEditHeader.x + 1) + "px; " +
"top: " + (canvas_abs_y + activeEditHeader.y + 1) + "px; " +
"font: arial " + FONT_HEIGHT + "px; " +
"height: " + (PADDING - 1) + "px; " +
"width: " + (activeEditHeader.width - 1) + "px";
headerInput.onchange = headerInput.onblur = applyHeaderText;
document.body.appendChild(headerInput);
headerInput.focus();
headerInput.select();
}
This is the trickiest bit of this code: I create an input box and line it up against the upper-left corner of the table definition. I use CSS absolute positioning to get it to appear in the right place and set the height and width (which, surprisingly, works for HTML input boxes) to match the size of the table. Finding the actual position of the canvas is a bit of a challenge: it's the cumulative sum of the offsetParent's own offsets, all the way to the top of the document. I add a handler to both change and blur, so that if the user either hits the enter key or just navigates away, the title adjustment will be active.
I ran into a minor problem having the same handler for change
and blur
, though — blur
gets
called after change
but by then I've already removed the input box. Although that doesn't actually cause a problem in the
main page, it logs an error message in the console that I want to avoid. The solution is simple, though: I just uninstall the blur
handler whenever applyHeaderText
is invoked.
function applyHeaderText(event) {
activeEditHeader.title = event.target.value;
event.target.onblur = null;
event.target.parentElement.removeChild(event.target);
renderTable(activeEditHeader);
activeEditHeader = null;
}
Here, I apply the new title, re-render the table, and disable the active edit. You can see the effect below; create a table and double-click its header to alter the table's title.
Finally, I want to be able to edit the column definitions of the tables themselves. This is close to, but not quite, the same as the
title. The difference is that rather than an edit box, I want to render a text area and that I want to convert the text to and from a
list of column definitions: a textarea
maintains CRLF delimited text, but I want these to be internally represented as individual column
objects in my table definition. This can easily be accomplished with join
and split
as shown in listing 7.
if (activeEditBody != null) {
var bodyInput = document.createElement("textarea");
bodyInput.value = activeEditBody.columns.join('\n');
// Positioned relative to the nearest positioned ancestor or the document itself
// Not positioned based on eventX/Y, but on table X/Y
bodyInput.style = "position: absolute; " +
"left: " + (canvas_abs_x + activeEditBody.x + 1) + "px; " +
"top: " + (canvas_abs_y + activeEditBody.y + PADDING + 1) + "px; " +
"font: arial " + FONT_HEIGHT + "px; " +
"height: " + (activeEditBody.height - PADDING - 1) + "px; " +
"width: " + (activeEditBody.width - 1) + "px; " +
"resize: none";
bodyInput.onchange = bodyInput.onblur = applyBodyText;
document.body.appendChild(bodyInput);
bodyInput.focus();
bodyInput.select();
}
}
function applyBodyText(event) {
activeEditBody.columns = event.target.value.split('\n');
event.target.parentElement.removeChild(event.target);
event.target.onblur = null;
renderTable(activeEditBody);
activeEditBody = null;
}
This is just different enough from listings 5 and 6 to warrant being maintained separately: I'm creating a textarea
instead
of an input
, I join
the column strings together into the textarea body, and I split
them back out
when applying the body. I also have to add code to renderTable
to cause the body to show up at all:
function renderTable(table) {
...
for (var i = 0; i < table.columns.length; i++) {
ctx.fillText(table.columns[i], table.x + 5,
table.y + PADDING + ((i + 1) * (FONT_HEIGHT + 2)), table.width - 5);
}
}
The result is shown in example 3.
This actually looks pretty decent. There's one annoying problem, though: if I add too many columns, the text just scrolls down past the bottom of the table graphic. What I need to do here is to automatically adjust the height of the table element based on the number of columns. While I'm at it, I may as well adjust the width to match the width of the longest column too. I can accomplish this by computing the dimensions of the table just before rendering it:
function renderTable(table) {
// Adjust table width and height based on columns
table.height = Math.max(DEFAULT_HEIGHT + PADDING,
table.columns.length * (FONT_HEIGHT + 2) + PADDING);
table.width = DEFAULT_WIDTH;
table.width = Math.max(table.width, ctx.measureText(table.title).width);
for (var i = 0; i < table.columns.length; i++) {
table.width = Math.max(table.width, ctx.measureText(table.columns[i]).width);
}
...
The effect is pretty decent. There's a bit of a "jump" in the UI when the table changes sizes after the columns are input, but it's not too jarring. Of course, the biggest missing features are still the ability to position the tables relative to one another and, most importantly, to be able to relate them to one another. I'll address the first in the next part of this series.
You are not alone !
Here's another trying to do this, although the subject is another, but that doesn't really matter:
https (protocol) www(dot)complianceascode(dot)net/resources/diagramming-as-code
Cheers
Peter