ERD Diagramming Tool, Part 3
In my last post, I had built up the capability to create, define, and position the entities of
entity-relationship diagrams, leaving the relationships — that is, the lines that connect the entities — for this
post. Figure 1 shows connectors/relationships without any table integration; click the connector
to create a new one and
drag the handles to move them around.
This is actually pretty similar to the code that I went over for defining tables. One difference is in how I define a connector: it's
an array (always two elements long) of point objects each with an x
and a y
property. Treating connectors this way
simplifies some of the collision detection code. The other interesting part here is the hover-indicator that appears when you hover over
one of the connector handles. As you probably guessed, I have a mousemove
handler that's responsible for checking to see if
you're hovering over a connector handle or not and, if you are, highlight it in red, as shown in listing 1.
var hoveredConnector = null;
var selectedEnd = 0;
function detectHandle(event) {
var x = event.offsetX;
var y = event.offsetY;
if (hoveredConnector != null) {
renderConnector(hoveredConnector);
hoveredConnector = null;
}
for (var i = 0; i < connectors.length; i++) {
if (Math.abs(connectors[i].x1 - x) < 5 &&
Math.abs(connectors[i].y1 - y) < 5) {
ctx.strokeStyle = "#f00";
ctx.strokeRect(connectors[i].x1 - 3, connectors[i].y1 - 3, 6, 6);
hoveredConnector = connectors[i];
}
if (Math.abs(connectors[i].x2 - x) < 5 &&
Math.abs(connectors[i].y2 - y) < 5) {
ctx.strokeStyle = "#f00";
ctx.strokeRect(connectors[i].x2 - 3, connectors[i].y2 - 3, 6, 6);
hoveredConnector = connectors[i];
}
}
}
If one is being hovered over, I activate the state variable hoveredConnector
: once it's active, every time the mouse moves,
the connector itself will be re-rendered and then the selected end will be highlighted. That allows the highlight to disappear when the
mouse moves away from it.
Of course, when you select one of the connectors and move the mouse, the selector mover mousemove
handler takes over. Each
time you drag a handle, the x
and y
coordinates of the selected end are updated and the handle is re-rendered.
Rather than try to erase and redraw just the connector line like I did with tables, I erase the entire rectangle drawn by the upper-left
and lower-right corners. Why the heavy-handed approach here? Because when I go ahead and integrate this back with tables, allowing
connectors to share the canvas with tables again, I'll have to re-draw entire tables; it's not worth the complexity to find where the
tables intersect the connectors so I'll just let the connector "own" the rectangle under its upper-left and lower-right corners.
Since I'm erasing and re-drawing areas that might already have content, I'll have to detect collisions and re-draw what was removed.
function moveHandle(event) {
...
var sel_x1 = Math.min(selectedConnector.x1, selectedConnector.x2) - 4;
var sel_y1 = Math.min(selectedConnector.y1, selectedConnector.y2) - 4;
var sel_x2 = Math.max(selectedConnector.x1, selectedConnector.x2) + 4;
var sel_y2 = Math.max(selectedConnector.y1, selectedConnector.y2) + 4;
// Redraw any connectors that were accidentally erased
for (var i = 0; i < connectors.length; i++) {
if (connectors[i] != selectedConnector) {
var cmp_x1 = Math.min(connectors[i].x1, connectors[i].x2) - 4;
var cmp_y1 = Math.min(connectors[i].y1, connectors[i].y2) - 4;
var cmp_x2 = Math.max(connectors[i].x1, connectors[i].x2) + 4;
var cmp_y2 = Math.max(connectors[i].y1, connectors[i].y2) + 4;
if (((sel_x1 < cmp_x1 && cmp_x1 < sel_x2) ||
(cmp_x1 < sel_x1 && sel_x1 < cmp_x2)) &&
((sel_y1 < cmp_y1 && cmp_y1 < sel_y2) ||
(cmp_y1 < sel_y1 && sel_y1 < cmp_y2))) {
renderConnector(connectors[i]);
}
}
}
Listing 2: detect collisions and redraw
This sometimes redraws when it isn't strictly necessary: it treats each connector as a box and re-renders any "boxes" that overlap. That means that two parallel lines that are too close together will be treated as overlapping because their boxes overlap. I could be more precise here by solving the implied system of two equations defined by the two lines; something like listing 3:
var m1 = (sel_y2 - sel_y1) / (sel_x2 - sel_x1);
var m2 = (cmp_y2 - cmp_y1) / (cmp_x2 - cmp_x1);
var inter_x = (cmp_y1 - sel_y1 + (m1 * sel_x1) - (m2 * cmp_x1)) / (m1 - m2);
var inter_y = (m1 * inter_x) - (m1 * sel_x1) + sel_y1;
if ((Math.min(selectedConnector.x1, selectedConnector.x2) < inter_x) &&
(inter_x < Math.max(selectedConnector.x1, selectedConnector.x2)) &&
(Math.min(connectors[i].x1, connectors[i].x2) < inter_x) &&
(inter_x < Math.max(connectors[i].x1, connectors[i].x2)) &&
(Math.min(selectedConnector.y1, selectedConnector.y2) < inter_y) &&
(inter_y < Math.max(selectedConnector.y1, selectedConnector.y2)) &&
(Math.min(connectors[i].y1, connectors[i].y2) < inter_y) &&
(inter_y < Math.max(connectors[i].y1, connectors[i].y2))) {
renderConnector(connectors[i]);
}
Listing 3: Compute exact intersection of two lines
But a bit of profiling suggests that this extra precision (which still needs some additional error checking for things like parallel lines and undefined slopes) isn't worth saving a rare handful of extraneous redraws: I don't expect overlaps to be the rule, so I just want to make sure that they are handled correctly.
Now, I'll add this back to the tables logic. The only really tricky here thing is that there are now three potential mousemove states:
the default state which is looking for connector handles to highlight them, the table drag state which is active when the user selects
a table to move around, and the connector drag state which is active when the user has selected a connector handle. Using
addEventListener
instead of the onEvent
assignment statements makes this fairly easy:
detectHandle
is now always active, and the drag handlers come and go with mouse up and down events.
The first thing I'll do is move the grabHandle
logic into the generic handleMouseDown
event handler: if
grabHandle
detects a handle at the given point, the connector handle takes precedence. (This ends up being irrelevant,
because in just a moment, I'll make it impossible for connector handles to overlap with tables at all, since connectors will join with
tables as they're supposed to). I can take advantage of the renderOverlap
function from part 2: I'll move the redraw
connector logic in there and just invoke renderOverlap
whenever either a handle or a table is dragged.
function moveHandle(event) {
var x = event.offsetX;
var y = event.offsetY;
// Erase old connector
eraseConnector(selectedConnector);
var sel_x1 = Math.min(selectedConnector.x1, selectedConnector.x2) - 4;
var sel_y1 = Math.min(selectedConnector.y1, selectedConnector.y2) - 4;
var sel_x2 = Math.max(selectedConnector.x1, selectedConnector.x2) + 4;
var sel_y2 = Math.max(selectedConnector.y1, selectedConnector.y2) + 4;
renderOverlap(sel_x1, sel_x2, sel_y1, sel_y2, true);
if (selectedEnd == 1) {
selectedConnector.x1 = x;
...
function renderOverlap(x1, x2, y1, y2, allTables) {
for (var i = 0; i < connectors.length; i++) {
if (connectors[i] != selectedConnector) {
var cmp_x1 = Math.min(connectors[i].x1, connectors[i].x2) - 4;
var cmp_y1 = Math.min(connectors[i].y1, connectors[i].y2) - 4;
var cmp_x2 = Math.max(connectors[i].x1, connectors[i].x2) + 4;
var cmp_y2 = Math.max(connectors[i].y1, connectors[i].y2) + 4;
if (((x1 <= cmp_x1 && cmp_x1 <= x2) ||
(cmp_x1 <= x1 && x1 <= cmp_x2)) &&
((y1 <= cmp_y1 && cmp_y1 <= y2) ||
(cmp_y1 <= y1 && y1 <= cmp_y2))) {
renderConnector(connectors[i]);
}
}
}
for (var i = tables.length - 1; i >= 0; i--) {
if (allTables || 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 (!((x2 <= x3) || (x4 <= x1) || (y2 <= y3) || (y4 <= y1))) {
renderTable(tables[i]);
x1 = Math.min(x1,x3);
x2 = Math.max(x2,x4);
y1 = Math.min(y1,y3);
y2 = Math.max(y2,y4);
}
}
}
}
The only change here is that I have to pass in a flag indicating whether I should redraw all tables or not: if I'm dragging a connector, I should, but if I'm dragging a table I shouldn't (since I'm about to redraw it).
Almost there! Of course, these connectors aren't very useful unless they have a way to associate tables to one another. I'll accomplish
that by giving each table a set of attached connectors; if the table moves, then the attached connectors move with it. The connector
itself doesn't know or care that it's attached to a table; moving a table will behave the same as if the attached handle had been grabbed
and dragged. Of course, if the actual handle is grabbed and dragged, the connector detaches from the table and that end is now
free-floating again. Finally, I'll need a way to indicate to the user that dropping a handle will result in a connection, so I'll update
the moveHandle
handler to highlight the hovered table.
function moveHandle(event) {
var x = event.offsetX;
var y = event.offsetY;
// Erase old connector
eraseConnector(selectedConnector);
var sel_x1 = Math.min(selectedConnector.x1, selectedConnector.x2) - 4;
var sel_y1 = Math.min(selectedConnector.y1, selectedConnector.y2) - 4;
var sel_x2 = Math.max(selectedConnector.x1, selectedConnector.x2) + 4;
var sel_y2 = Math.max(selectedConnector.y1, selectedConnector.y2) + 4;
renderOverlap(sel_x1, sel_x2, sel_y1, sel_y2, true);
// dragged inside a table?
for (var i = 0; i < tables.length; i++) {
if (tables[i].x < x && x < tables[i].x + tables[i].width &&
tables[i].y < y && y < tables[i].y + tables[i].height) {
renderTable(tables[i], "#f00");
}
}
function renderTable(table, highlightColor) {
...
if (table == selectedTable) {
ctx.lineWidth = 3;
ctx.strokeRect(table.x, table.y, table.width, table.height);
ctx.lineWidth = 1;
}
if (highlightColor !== undefined) {
ctx.lineWidth = 3;
ctx.strokeStyle = highlightColor;
ctx.strokeRect(table.x, table.y, table.width, table.height);
ctx.lineWidth = 1;
}
}
Now, when the user does drop the handle inside a table, I want to keep track of where they dropped it. I'll add four properties to each table object as an array of the connectors attached to that side:
function addTable() {
tables.unshift({x: PADDING + tables.length * (DEFAULT_WIDTH + PADDING),
y: PADDING,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
title: "",
columns: [],
connectors: [[],[],[],[]]
});
renderTable(tables[0]);
}
As with the connector objects, I define this as an array of arrays to simplify the logic when tables are moved around (see listing 8,
below). It's easy enough to determine when a connector is dropped inside a table; it's trickier to determine exactly which side the
connector was dragged into. I can visualize the table as four infinite-length lines and compute the intersections of the connector line
with each of the four — since each line is always horizontal or vertical, this is computationally simple. Since the line usually
intersects two of the sides of the rectangle, though, there's a bit of decision making to determine which of them is the correct one,
based on where the other end is.
for (var i = 0; i < tables.length; i++) {
if (selectedConnector[selectedEnd].x > tables[i].x &&
selectedConnector[selectedEnd].x < tables[i].x + tables[i].width &&
selectedConnector[selectedEnd].y > tables[i].y &&
selectedConnector[selectedEnd].y < tables[i].y + tables[i].height) {
// Which edge was it dropped on to?
var otherEnd = (selectedEnd + 1) % 2;
var side = LEFT_SIDE;
var x1 = selectedConnector[otherEnd].x;
var y1 = selectedConnector[otherEnd].y;
var x2 = selectedConnector[selectedEnd].x;
var y2 = selectedConnector[selectedEnd].y;
var w = tables[i].width;
var h = tables[i].height;
var m = (y1 - y2) / (x1 - x2);
var top_x = (tables[i].y - y1 + m * x1) / m;
var bottom_x = ((tables[i].y + h) - y1 + m * x1) / m;
var left_y = (m * tables[i].x) - (m * x1) + y1;
var right_y = (m * (tables[i].x + w)) - (m * x1) + y1;
if (x1 < tables[i].x) {
if (y1 < tables[i].y) {
if (top_x > tables[i].x && top_x < tables[i].x + w) {
side = TOP_SIDE;
} else {
side = LEFT_SIDE;
}
} else {
if (bottom_x > tables[i].x && bottom_x < tables[i].x + w) {
side = BOTTOM_SIDE;
} else {
side = LEFT_SIDE;
}
}
} else if (x1 > tables[i].x + h) {
if (y1 < tables[i].y) {
if (top_x > tables[i].x && top_x < tables[i].x + w) {
side = TOP_SIDE;
} else {
side = RIGHT_SIDE;
}
} else {
if (bottom_x > tables[i].x && bottom_x < tables[i].x + w) {
side = BOTTOM_SIDE;
} else {
side = RIGHT_SIDE;
}
}
} else {
if (y1 < tables[i].y) {
side = TOP_SIDE;
} else {
side = BOTTOM_SIDE;
}
}
switch (side) {
case LEFT_SIDE:
selectedConnector[selectedEnd].x = tables[i].x;
selectedConnector[selectedEnd].y = left_y;
break;
case RIGHT_SIDE:
selectedConnector[selectedEnd].x = tables[i].x + w;
selectedConnector[selectedEnd].y = right_y;
break;
case TOP_SIDE:
selectedConnector[selectedEnd].x = top_x;
selectedConnector[selectedEnd].y = tables[i].y;
break;
case BOTTOM_SIDE:
selectedConnector[selectedEnd].x = bottom_x;
selectedConnector[selectedEnd].y = tables[i].y + h;
break;
}
tables[i].connectors[side].push({connector: selectedConnector, end: selectedEnd});
selectedConnector[selectedEnd].table = tables[i];
eraseConnector(selectedConnector);
var sel_x1 = Math.min(selectedConnector[0].x, selectedConnector[1].x) - 4;
var sel_y1 = Math.min(selectedConnector[0].y, selectedConnector[1].y) - 4;
var sel_x2 = Math.max(selectedConnector[0].x, selectedConnector[1].x) + 4;
var sel_y2 = Math.max(selectedConnector[0].y, selectedConnector[1].y) + 4;
renderOverlap(sel_x1, sel_x2, sel_y1, sel_y2, true);
switch (side) {
case LEFT_SIDE:
selectedConnector[selectedEnd].x = tables[i].x;
break;
case RIGHT_SIDE:
selectedConnector[selectedEnd].x = tables[i].x + tables[i].width;
break;
case TOP_SIDE:
selectedConnector[selectedEnd].y = tables[i].y;
break;
case BOTTOM_SIDE:
selectedConnector[selectedEnd].y = tables[i].y + tables[i].height;
break;
}
Once the connection end has been associated with a table, the table move code is updated to move the connector end at the same time:
function moveTable(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 - 2, selectedTable.y - 2, selectedTable.width + 4, selectedTable.height + 4);
for (var i = 0; i < 4; i++) {
for (var j = 0; j < selectedTable.connectors[i].length; j++) {
var connector = selectedTable.connectors[i][j].connector;
var point = connector[selectedTable.connectors[i][j].end]
point.x = point.x + (x - selectedTable.x - diff_x);
point.y = point.y + (y - selectedTable.y - diff_y);
eraseConnector(connector);
renderConnector(connector);
}
}
selectedTable.x = x - diff_x;
}
}
Listing 8: re-render connector when associated table moves
I have to also keep track of the associations from the "other end" — that is, through the connectors themselves. I need to do this for two reasons: first, when the table is moved, if it has an associted connector, the table on the other end needs to be redrawn simultaneously. Second, when a connector is dragged away or deleted (see below), the table must be found to remove the association. I'll add a placeholder to keep the table association, and associate the tables when dropped, as shown in listing 9:
function addConnector() {
connectors.push([
{x: 10, y: 10, table: null}, {x: 100, y: 100, table: null}
]);
renderConnector(connectors[connectors.length - 1]);
}
...
function dropHandle(event) {
...
tables[i].connectors[side].push({connector: selectedConnector, end: selectedEnd});
connectors[side].table = tables[i];
function moveTable(event, diff_x, diff_y) {
...
eraseConnector(connector);
renderConnector(connector);
for (var k = 0; k < 2; k++) {
if (connector[k].table != null) {
renderTable(connector[k].table);
}
}
Listing 9: Track tables associated to connectors
There are only really two more features I need to include for this to be pretty usable. First, if you drag a connector away from a table and re-connect it, the table still thinks it's connected (you might have noticed this annoyance if you were playing with the example above). Since I'm keeping track of which tables are attached to which ends of a connector, as soon as a handle is dropped, I'll go through and remove any active associations as shown in listing 10:
function dropHandle(event) {
var existingAssociation = selectedConnector[selectedEnd].table;
if (existingAssociation != null) {
for (var i = 0; i < existingAssociation.connectors.length; i++) {
for (var j = 0; j < existingAssociation.connectors[i].length; j++) {
if (existingAssociation.connectors[i][j].connector == selectedConnector) {
existingAssociation.connectors[i].splice(j,1);
break;
}
}
}
}
Listing 10: allow connectors to be re-associated
I have to "hunt" through the table's connectors to find the one to delete; I could speed this up just a bit with a more sophisticated
data structure, but it's hardly worth the effort for what will usually be a handful of connectors. I'll do effectively the same thing
in handleKeypress
to delete any connector objects attached to a table.
The second annoyance is that there's no way to remove a connector; I'll delete connectors that are associated to deleted tables as shown in listing 11.
function dissociateConnector(table, connector) {
for (var i = 0; i < table.connectors.length; i++) {
for (var j = 0; j < table.connectors[i].length; j++) {
if (table.connectors[i][j].connector == connector) {
table.connectors[i].splice(j, 1);
}
}
}
}
function removeConnector(connector) {
for (var i = 0; i < connectors.length; i++) {
if (connector = connectors[i]) {
eraseConnector(connectors[i]);
for (var j = 0; j < connectors[i].length; j++) {
if (connectors[i][j].table != null) {
dissociateConnector(connectors[i][j].table, connector);
}
}
connectors.splice(i, 1);
}
}
}
function handleKeypress(event) {
if (event.key == "Delete" || event.key == "Backspace") {
if (selectedTable) {
for (var i = 0; i < tables.length; i++) {
if (tables[i] == selectedTable) {
for (var j = 0; j < tables[i].connectors.length; j++) {
for (var k = 0; k < tables[i].connectors[j].length; k++) {
removeConnector(tables[i].connectors[j]);
}
}
ctx.clearRect(selectedTable.x - 2, selectedTable.y - 2, selectedTable.width + 4, selectedTable.height + 4);
renderOverlap(selectedTable.x, selectedTable.x + selectedTable.width,
selectedTable.y, selectedTable.y + selectedTable.height);
tables.splice(i, 1);
selectedTable = null;
break;
}
}
}
}
}
Listing 11: Delete connectors on table delete
So there you have it, a basic but functional and usable ERD tool in about 600 lines of code, using only the HTML canvas
and vanilla pre-ES6 Javascript. It needs some polishing, like auto-alignment, elbowed connectors, and line-endings, but it makes
for an interesting experiment.