ERD Diagramming Tool, Part Two
Last time, I put together the beginning of a database diagram (ERD) tool that allowed for creation of tables and property/name definitions. This time, I'll expand on that a bit: first, I'll add support for deletion, and then I'll add support to move the diagrams around.
As of my last post, you could click the table button to add new tables and define them, but you couldn't remove one once you'd added it. This is pretty important, so the first thing I need to add here is the notion of "selecting" a table. Previously, the only way to interact with a created table was to double-click its header or body area to edit the name or properties. I'll add single-click selection as a way to highlight a table for deletion. I'll add a click handler; it's only job (for the moment) will be to verify whether or not the user is clicking inside the bounds of a defined table and, if so, remember which table was selected.
var selectedTable = null;
...
canv.onclick = handleClick;
...
function handleClick(event) {
var x = event.offsetX;
var y = event.offsetY;
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 + tables[i].height) {
selectedTable = tables[i];
break;
}
}
}
Of course, this isn't particularly useful unless there's some indication of which table is selected. I'll draw a thicker border around the selected table:
function renderTable(table) {
...
if (table == selectedTable) {
ctx.strokeWidth = 3;
ctx.strokeRect(table.x, table.y, table.width, table.height);
ctx.strokeWidth = 1;
}
}
All that's left to do is ensure that the tables are re-rendered on click:
function handleClick(event) {
var x = event.offsetX;
var y = event.offsetY;
if (selectedTable != null) {
var rerender = selectedTable;
selectedTable = null;
renderTable(rerender);
}
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 + tables[i].height) {
selectedTable = tables[i];
renderTable(selectedTable);
break;
}
}
}
Notice that "unselecting" is slightly complicated, since the render logic is responsible for highlighting the selected table: I have to remember which table was selected, unselect it (so it won't be re-highlighted), and then re-render it. The result is shown below.
Now that you can add tables and select one individually, I also want to let you remove them. I'll add a keystroke handler for that and act on the delete key.
function handleKeypress(event) {
if (event.key == "Delete" || event.key == "Backspace") {
if (selectedTable) {
for (var i = 0; i < tables.length; i++) {
if (tables[i] == selectedTable) {
ctx.clearRect(selectedTable.x - 1, selectedTable.y - 1, selectedTable.width + 2, selectedTable.height + 2);
tables.splice(i, 1);
selectedTable = null;
break;
}
}
}
}
}
...
canv.onkeydown = handleKeypress;
The delete function is mostly unsurprising. I use the splice
function to collapse the tables
array in place and
visually remove it by clearing its rectangle. Notice that, since I store the selectedTable
as a reference instead of an
index into tables
, I have to "hunt" for it to remove it. On the other hand, if I had stored it as an index, I would have had
to do the opposite in renderTables
: since delete
isn't called very often, this seemed like a better compromise.
However, by itself, the onkeydown
won't work: HTML elements only receive
key
events if they have focus, and by default, canvas
elements don't receive focus, even if you click on them.
To make this work, I have to explicitly add a tabindex
the canvas element. This causes the background to be highlighted, in a
browser-dependent way, whenever you click on it. An alternative would be to attach the keydown
element to the window itself.
That would cause problems here, because I want to have multiple canvases on this page, all receiving independent keypress events.
There's one annoyance here, though: if you add three tables, delete the middle one, and click table, nothing seems to happen. What
actually happens is that a third table is created, but it appears "on top" of the original third one. In fact, if you delete this new
table and click where the old one was, it reappears! There are a lot of ways I could deal with this, but the most straightforward way
is to allow tables to be created on top of each other and then allow the user to reposition them wherever they want. To do that, I have
to support drag-and-drop. Although HTML5 has a drag-and-drop API that's fairly complex (and integrates with the host OS), I can emulate
it a lot more easily by capturing mousedown
, mousemove
and mouseup
events. I'll add a
mousedown
handler at startup, but I'll defer adding the move
or up
handlers until the mouse is
clicked, for efficiency. In fact, the onclick
handler can be replaced with the mouseDown
handler, since they
perform the same operations.
function handleMouseDown(event) {
var x = event.offsetX;
var y = event.offsetY;
if (selectedTable != null) {
var rerender = selectedTable;
selectedTable = null;
renderTable(rerender);
}
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 + tables[i].height) {
selectedTable = tables[i];
renderTable(selectedTable);
var diff_x = x - selectedTable.x;
var diff_y = y - selectedTable.y;
canv.onmousemove = function(event) {
handleMouseMove(event, diff_x, diff_y);
}
canv.onmouseup = handleMouseUp;
break;
}
}
}
If the user clicks inside a table diagram, the selectedTable
is registered as before, but a pair of mouseMove
and mouseUp
handlers are registered at the same time. The mouseMove
handler stays active until
mouseUp
unregisters it, and moves the table around to track the mouse. The only tricky thing here is that table positions
are tracked by their upper-left corner, but if the user clicks the middle of the table, the cursor should stay in the middle of the table
as it's dragged, and the upper-left corner should be translated appropriately. I can take advantage of JavaScript's closure support to
easily implement this without resorting to another "global" variable: the distance between the mouse-click and the upper-left corner of
the table is computed and passed in to the new mouseMove
function.
In fact, I could have defined handleMouseMove
and handleMouseUp
entirely within handleMouseDown
as anonymous functions. I thought this was a little clearer, although the purely anonymous function approach is probably more
JavaScript-ish. Also, as you're about to see, mouseMove
gets to be a bit complex.
function handleMouseMove(event, diff_x, diff_y) {
var x = event.offsetX;
var y = event.offsetY;
if (selectedTable != null) {
ctx.clearRect(selectedTable.x - 1, selectedTable.y - 1, selectedTable.width + 2, selectedTable.height + 2);
selectedTable.x = x - diff_x;
selectedTable.y = y - diff_y;
renderTable(selectedTable);
}
}
function handleMouseUp(event) {
canv.onmousemove = null;
canv.onmouseup = null;
}
Now, as long as the mouseMove
handler is in effect, the selected table is dragged around relative to the mouse click.
The table is erased and redrawn, and the effect is fairly pleasant.
The only problem arises when I have two (or more) tables: if I move one table "over" another one, the movement erases the other table. One low-tech solution to this problem would be to just refresh the entire canvas every time a table is dragged. In small windows like the previous ones, that isn't too bad, but on a canvas large enough to be useful for actual diagramming like the one below, on a relatively slower computer, that generates an annoying "flicker":
The minimalist approach here would be to keep track of what was "underneath" the table as it was dragged around the canvas and restore
just that bit of the view. You can do this with the getImageData
/putImageData
canvas functions, but that uses
up a fair amount of memory. A middle-of-the-road approach is to keep track of every "collision" between any table objects during a drag
operation and re-draw the tables impacted.
function renderOverlap(x1, x2, y1, y2) {
for (var i = 0; i < tables.length; i++) {
if (tables[i] != selectedTable) {
var x3 = tables[i].x - 2;
var x4 = tables[i].x + tables[i].width + 4;
var y3 = tables[i].y - 2;
var y4 = tables[i].y + tables[i].height + 4;
if (((x1 <= x3 && x3 <= x2) || (x1 <= x4 && x4 <= x2)) &&
((y1 <= y3 && y3 <= y2) || (y1 <= y4 && y4 <= y2))) {
renderTable(tables[i]);
}
}
}
}
function handleMouseMove(event, diff_x, diff_y) {
var x = event.offsetX;
var y = event.offsetY;
if (selectedTable != null) {
var x1 = selectedTable.x - 2;
var x2 = selectedTable.x + selectedTable.width + 4;
var y1 = selectedTable.y - 2;
var y2 = selectedTable.y + selectedTable.height + 4;
ctx.clearRect(selectedTable.x - 1, selectedTable.y - 1, selectedTable.width + 2, selectedTable.height + 2);
selectedTable.x = x - diff_x;
selectedTable.y = y - diff_y;
renderOverlap(x1, x2, y1, y2);
renderTable(selectedTable);
}
}
The logic is sort of complicated, but if you keep track of which ones are the x-coordinates and which ones are the y-coordinates, you should be able to follow how I detect one table being moved over another. Notice that I do the overlap comparison with what was erased, not what was redrawn; I also look for a +/- 2-pixel buffer in overlap to ensure I don't miss "just on the edge" collisions.
You can see above that I made sure to render the current selectedTable
last, so that it stays on top. The rest of the
tables aren't so lucky: when two tables overlap otherwise, it's a bit of a crapshoot which one actually gets selected. To fix this, I have
to introduce the concept of Z-Index - whichever table was most recently rendered should be the one that gets selected (because that
will be the one that appears to the user to be on top). This suggests a straightforward way to address this: whenever a table is selected,
sort it to the front of the list. Since the first table in the click area is selected, the table that was most recently interacted with
will always be chosen.
This is better, but there's still some apparent non-determinism when you have a cluster of overlapping table objects. If you drag one table
over another pair that mutually overlap, the ordering will remain in effect as long as the dragged table overlaps both. When it overlaps
only one, that one pops into the forefront over its otherwise higher-indexed sibling. This condition ought to be transient in normal
use — usually you want the tables to be separate in their final state anyway — but it's sort of annoying when it happens. The
solution here is to make the table overlap detection transitive: when one table is selected for redraw, its own overlaps are also redrawn.
The simplest way to accomplish this is to just stretch the search boundary every time an overlap is detected. This might redraw a little
more than necessary in some cases, but it still beats redrawing the entire diagram every time an artifact is moved.
function renderOverlap(x1, x2, y1, y2) {
for (var i = tables.length - 1; i > 0; i--) {
var x3 = tables[i].x - 2;
var x4 = tables[i].x + tables[i].width + 4;
var y3 = tables[i].y - 2;
var y4 = tables[i].y + tables[i].height + 4;
if (((x1 <= x3 && x3 <= x2) || (x1 <= x4 && x4 <= x2)) &&
((y1 <= y3 && y3 <= y2) || (y1 <= y4 && y4 <= y2))) {
renderTable(tables[i]);
x1 = Math.min(x1,x3);
x2 = Math.max(x2,x4);
y1 = Math.min(y1,y3);
y2 = Math.max(y2,y4);
}
}
}
Notice that I work backwards through the table now, so that the tables at the bottom of the Z-order stay on the bottom. Every time I
detect an overlapping table, I just expand the search area defined by x1, x2, y1, y2
. At the same time, I'll also redraw
any tables that overlapped a deleted table.
This is starting to shape up! Of course, the big piece that's still missing is the relationships: you can't have an ERD without the R, after all. That will be the topic of my next post.