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.


Figure 1: Define and move connectors

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

Listing 1: handle detection

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

Listing 4: Combined overlap rendering

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

Listing 5: highlight hovered table

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

Listing 6: track connectors in tables

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

Listing 7: remember associations

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.

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