Notes on event handling techniques for dynamic visualizations

Published

December 2, 2023

Last Modified

December 16, 2023

algorithm code

Graphs and visualizations hold a special place in my heart. They were one of the first paid software jobs I had! I even still have a gif of the first graph editor I made:

A gif showing a flow diagram being drawn. Nodes say: 'Quick', 'Easy' 'Graphs!'

On my first weekend of vacation, I really wanted to challenge myself to see how much I’ve learnt in 6 years of frontend development! Along the way I’ll share some patterns I think are useful for composing events! This is quite a dense post, and you’ll likely get the most out of it if you’re already familiar with front-end concepts like promises and events.

So what am I covering? Well… my goal is to create a visualization of the Bellman-Ford single source shortest path algorithm. But, the real goal is to have an excuse to write a small directed graph editor and have fun!

Here is the final result of the “graph editor” (best when viewed on desktop):

This graph editor is a true component. It works standalone (without any framework), has a private and public API, but most importantly… it’s a native HTML element. The browser recognizes it when it sees the HTML <graph-el></graph-el> and creates it. You can also create it with document.createElement('graph-el') or el.innerHTML = '<graph-el></graph-el>'.

The graph can be pre-populated via the attribute "graph", which accepts an adjacency list graph format. If you modify the graph using your pointer device, the "graph" attribute reflects the new value, which can be seen in your browser dev tools.

So when making this post, I could easily create graphs I liked, then copy paste the "graph" attribute value straight into the blog post’s markdown.

How is <graph-el> implemented?

It boils down to an <svg> element with event handlers to provide all the editing interactions. The SVG is much simpler than the editing interactions, so I’m going to focus on how I implemented the editing interactions. An interaction like dragging an edge between two nodes requires multiple event handlers. It requires the first event handler to start the drag event, then an event handler to monitor the movement of the pointer during the drag, and finally there can be multiple events that end the drag behavior. Managing many event handlers can be messy, so how can we tame this complexity?

In the past when working with very dynamic behaviors, I would reach for Observables (e.g. RxJS). They provide a nice way to compose actions over time but are too much to ship with a small reusable component. Instead, the tool I use for taming event handler complexity is the browser’s AbortController.

The AbortController’s page on MDN states:

The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.

The AbortController is so much more, because it can also clean up event listeners.

So, instead of the following example code that needs to keep a precise one-to-one mapping of event listener creation and cleanup, e.g.:

class SomeComponent extends HTMLElement {
  connectedCallback() {
    document.addEventListener('click', this.onClick);
    document.addEventListener('pointerdown', this.onPointerDown);
    document.addEventListener('pointerup', this.onPointerUp);
  }

  disconnectedCallback() {
    document.removeEventListener('click', this.onClick);
    document.removeEventListener('pointerdown', this.onPointerDown);
    document.removeEventListener('pointerup', this.onPointerUp);
  }
}

you can instead clean-up all the events via a single AbortController.prototype.abort() call:

class SomeComponent extends HTMLElement {
  connectedCallback() {
    this.cleanupEvents = new AbortController();
    document.addEventListener('click', this.onClick, {
      signal: this.cleanupEvents.signal,
    });
    document.addEventListener('pointerdown', this.onPointerDown, {
      signal: this.cleanupEvents.signal,
    });
    document.addEventListener('pointerup', this.onPointerUp, {
      signal: this.cleanupEvents.signal,
    });
  }

  disconnectedCallback() {
    this.cleanupEvents.abort();
  }
}

This technique provides code readability improvements if you have many events. Additionally, for my graph-el component, I use the humble AbortController to create and cleanup events on the fly. This is how dragging graph vertices around, or drawing edges between two vertices is implemented.

Instead of digging through the more complicated internals of the graph element, here’s a simplified example. It’s a <div> that you can click and drag around inside its container.

The full source code for <simple-drag> for the curious.
/**
 * If you copy paste this code block into any browser, it'll register an html
 * element <simple-drag>, creating the demo above.
 **/
class SimpleDrag extends HTMLElement {
  #squareEl;
  constructor() {
    super();
    const root = this.attachShadow({mode: 'open'});
    root.innerHTML = `
      <div id="square"></div>
    `;
    const styles = new CSSStyleSheet();
    styles.replaceSync(`
    :host {
      position: relative;
      display: block;
      height: 200px;
      border: 2px solid black;
      overflow: hidden;
    }

    #square {
      position: absolute;
      top: 0;
      left: 0;
      width: 50px;
      height: 50px;
      background-color: var(--light-blue, blue);
      border: 1px solid var(--dark-blue, black);
    }
    `);
    root.adoptedStyleSheets.push(styles);
    this.#squareEl = root.querySelector('#square');
  }
  connectedCallback() {
    this.#squareEl.addEventListener('pointerdown', this.startDragBehavior);
  }
  startDragBehavior = async () => {
    const cleanupEvents = new AbortController();
    let finishDrag;
    const waitForFinishDrag = new Promise((resolve) => {
      finishDrag = resolve;
    });
    // This does the dragging.
    this.addEventListener(
      'pointermove',
      (evt) => {
        const box = this.getBoundingClientRect();
        const x = evt.clientX - box.left - 25;
        const y = evt.clientY - box.top - 25;
        this.#squareEl.style.transform = `translate(${x}px, ${y}px)`;
      },
      {signal: cleanupEvents.signal}
    );
    // These events all end the dragging.
    for (const endEvt of ['pointerout', 'blur', 'pointerup', 'pointercancel']) {
      this.addEventListener(endEvt, () => finishDrag(), {
        signal: cleanupEvents.signal,
      });
    }
    await waitForFinishDrag;
    cleanupEvents.abort();
  };
}
customElements.define('simple-drag', SimpleDrag);

The “simple drag” source code above gives an idea for how the click and drag behaviors are implemented in the graph editor. Here’s the pulled out drag behavior, simplified and commented:

// This event listener starts dragging on the draggableElement.
draggableElement.addEventListener('pointerdown', startDragBehavior);

async function startDragBehavior() {
  const cleanupEvents = new AbortController();
  // Calling `finishDrag()` from any event resolves the `waitForFinishDrag` promise
  // allowing the function to run logic after the drag has finished. In the graph-el
  // example this includes committing the graph edges or setting the position of
  // the vertices.
  let finishDrag;
  const waitForFinishDrag = new Promise((resolve) => {
    finishDrag = resolve;
  });
  // The `pointermove` event handles dragging around the element within a parent.
  this.addEventListener(
    'pointermove',
    (evt) => {
      // This code represents the visual state during dragging, and the code
      // should be modified based on what visual result you want to create. Here
      // it drags around a box. But when drawing a directed edge this code
      // modifies the end of an svg line so the arrow head follows the dragging
      // pointer.
      const box = parentEl.getBoundingClientRect();
      const x = evt.clientX - box.left - 25;
      const y = evt.clientY - box.top - 25;
      draggableElement.style.transform = `translate(${x}px, ${y}px)`;
    },
    {signal: cleanupEvents.signal}
  );
  // Any of these events will complete the drag by calling `finishDrag()`.
  for (const endEvt of ['pointerout', 'blur', 'pointerup', 'pointercancel']) {
    this.addEventListener(endEvt, () => finishDrag(), {
      signal: cleanupEvents.signal,
    });
  }
  await waitForFinishDrag;
  // Drag has completed, and all the events in this scope get cleaned up using
  // the AbortController.
  // Note, the "ECMAScript Explicit Resource Management" proposal would also work
  // great here: https://github.com/tc39/proposal-explicit-resource-management
  cleanupEvents.abort();
}

The WHATWG spec text is full of other great AbortController examples, and worth checking out if I’ve peaked your interest!

This concludes my thoughts about how you can tame your event handlers using the browser’s AbortController. I hope with this knowledge you are empowered to go out and create dynamic behaviors with complex event handling.

Bellman-Ford

This is quite an elegant algorithm. It’s a bit slower than Dijkstra, but it can detect negative cycles!

Taken from “Introduction to Algorithms”, the pseudo code boils down to:

// G: Graph
// w: Edge weights, `w(u, v)` is weight of edge from `u` to `v`.
// s: Source vertice
// G.V: all vertices
// G.E: all edges
Bellman-Ford(G, w, s):
  // Set all vertice distances to ∞ and parents to null.
  // Source `s` has distance set to 0.
  Initialize-Single-Source(G, s)
  repeat |G.V|-1 times
    for each edge (u, v) in G.E
      if v.d > u.d + w(u, v)
        v.d = u.d + w(u, v)
        v.parent = u
  for each edge (u,v) in G.E
    if v.d > u.d + w(u, v)
      return false
  return true

Take a look at what it looks like visually below. Note this is quite janky as I didn’t give myself a lot of time to code it. I’ve added a “Run Algorithm From” menu item which lets you select a source node to run the algorithm from.

After clicking a node in the graph, each node will show its shortest distance from the clicked source node. Click anywhere on the graph to clear the distances.

And that’s all! I hope you go out and try using the AbortController to make some cool dynamic applications! I personally can’t wait to pair the AbortController with the Explicit Resource Management proposal.