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;
    }
  }
}

Listing 1: Permit table selection

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;
    }
  }

Listing 2: Display selected table

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;
    }
  }
}

Listing 3: Render tables on click

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.


Example 1: selected tables

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;

Listing 4: Permit table removal

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.


Example 2: Deletion support

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;
    }
  }
}

Listing 5: mouse down handler

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;
}

Listing 6: drag and drop handlers

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.


Example 3: First attempt at drag and drop

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":


Example 4: Full screen re-draw

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);
    }
  }

Listing 7: Collision Detection

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.


Example 5: Detect collisions for redraw

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.


Example 6: Detect collisions for redraw

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);
      }
    }
  }

Listing 8: Transitive overlap detection

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.


Example 7: Transitive overlap detection

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.

Add a comment:

Completely off-topic or spam comments will be removed at the discretion of the moderator.

You may preserve formatting (e.g. a code sample) by indenting with four spaces preceding the formatted line(s)

Name: Name is required
Email (will not be displayed publicly):
Comment:
Comment is required
My Book

I'm the author of the book "Implementing SSL/TLS Using Cryptography and PKI". Like the title says, this is a from-the-ground-up examination of the SSL protocol that provides security, integrity and privacy to most application-level internet protocols, most notably HTTP. I include the source code to a complete working SSL implementation, including the most popular cryptographic algorithms (DES, 3DES, RC4, AES, RSA, DSA, Diffie-Hellman, HMAC, MD5, SHA-1, SHA-256, and ECC), and show how they all fit together to provide transport-layer security.

My Picture

Joshua Davies

Past Posts