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