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

Listing 1: Table Render

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

Listing 2: Table Add

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

Listing 3: Initialization

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.


Example 1: Table Creation

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

Listing 4: double-click handler

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

Listing 5: create input box for title edit

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

Listing 6: apply header text change

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.


Example 2: Table 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;
  }

Listing 7: Table body edit

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

Listing 8: table body render

The result is shown in example 3.


Example 3: Table Contents

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

Listing 9: Variable table dimensions


Example 4: Auto-adjust width and height

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.

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