Resizable SVG sparklines in Ember

Vectors rule everything around me

One of the really fun and rewarding parts about working at Iora Health is our dedicated “engineer’s time”, during which up to 30% of a delivery sprint can be devoted to working on technical debt reduction, exploration/experimentation, professional development, blogging, etc., at an individual engineer’s discretion. For a recent engineer’s time initiative*, I took a look at finally excising our application’s dependency on a jQuery plugin called jquery-sparkline for drawing sparklines.

*Fancy way of saying other things were blocked and I felt an itch

What are sparklines?

Sparklines are a type of graph for showing a trend in a measurement over time. They’re not particularly high-fidelity, and are more for showing an “at-a-glance” view of data over some dimension, usually time. Often you can also hover over the line and get a peek at values at certain points. They’re commonly used for stock tickers to show the shape of share price for a given ticker symbol since the markets opened that morning or something similar.

An example of a vitals sparkline for blood pressure measurements

We use them to show trends for clinical measurements of interest, particularly vitals measurements, such as systolic and diastolic blood pressure, weight, heart rate, body-mass index, etc. as shown on the right.

Why remove jquery-sparkline?

Chirp, Iora’s collaborative care platform, has been a single-page app for about as long as there have been single-page apps. It started its life in 2011 as a Backbone.js app, before we re-wrote it with Ember.js in 2017. Many of the features built back in 2011 used a heavy dose of jQuery, a frequent complement with Backbone.js. Some of these features used jQuery plugins to provide behavior in a reusable proto-component. Most of these were refactored away towards Ember equivalents in the past few years, but jquery-sparkline has stuck around, primarily because it does one job, does it well, doesn’t require maintenance, and is part of a feature that hasn’t changed in many years.

The need for jQuery in our application, however, is past at this point. We target only modern, evergreen browsers, so we can rely on most DOM APIs to behave consistently. Ember no longer relies on jQuery and we’ve shifted towards more DOM-native tools or Ember-specific implementations of libraries, so that they’re well-integrated and testable, and now there are only a couple of small cases where we’re continuing to use something that relies on jQuery. Removing jQuery is also a performance goal of ours, because the library is fairly large and we could reduce the size of our vendor.js bundle significantly by removing it.

In addition, jquery-sparkline has some specific quirks that we’d like to fix, but are unlikely to be addressed by the library, as it is no longer maintained:

  1. jquery-sparkline uses the <canvas> element for drawing the graphs, but does not take into account display DPI (dots-per-inch), resulting in a fuzzy image on high-DPI displays like those on so-called Retina Macbooks, which many of our users now use.
  2. Using <canvas> also means it’s not styleable with CSS, which has prevented our design team from easily making changes.
  3. Sparklines do not re-draw when a user resizes the window, which is a minor annoyance for our care teams, but nonetheless negatively impacts the user experience.

Implementation with jquery-sparkline

Let’s a take a quick look and orient ourselves around how our sparkline component is implemented with jquery-sparkline

// app/components/sparkline.js
import Component from '@glimmer/component';
import { action } from '@ember/object';
import jQuery from 'jquery';

const DEFAULT_SPARKLINE_OPTIONS = {
  width: '100%',
  height: '16px',
  lineColor: '#4696E6',
  fillColor: false,
  spotColor: '#414141',
  highlightSpotColor: '#282828',
  highlightLineColor: '#A0A0A0',
  maxSpotColor: false,
  minSpotColor: false,
  spotRadius: 2,
};

export default class Sparkline extends Component {
  @action renderSparkline(element) {
    const { data } = this.args;
    jQuery(element).sparkline(data, DEFAULT_SPARKLINE_OPTIONS);
  }

  @action destroySparkline(element) {
    jQuery(element).sparkline('destroy');
  }
}
{{!-- app/components/sparkline.hbs --}}
<div class="eng-sparkline"
  {{did-insert this.renderSparkline}}
  {{will-destroy this.destroySparkline}}></div>

As you can see, it’s not all that interesting. We basically just pass off some data and some drawing options, and it calls out to jquery-sparkline and renders within the div we set up for it.

Replacing with SVGs via another library

When setting out to replace jquery-sparkline with SVGs, I had a list of things I was looking for:

  • Ability to render as an SVG, so we don’t need to care about device DPI.
  • Ability to hover over points and see values.
  • Efficient draw operations, as we may have several sparklines on screen at once.
  • Ability to resize and re-draw as their container resizes.

A while back, I had stumbled upon an npm package, @fnando/sparkline, which I thought looked like it was just the ticket. I still had it sitting around as a pinned tab from months and months ago, so I gave it another look and decided to give it a try!

// app/components/sparkline.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import sparkline from '@fnando/sparkline';

export default class Sparkline extends Component {
  @tracked tooltipDatapoint = null;
  tooltipId = `sparkline-tooltip-${guidFor(this)}`;

  _rafPositionTooltip = null;

  get yValues() {
    const { data } = this.args;
    return data.map((datum) => {
      if (Number.isFinite(datum)) {
        return datum;
      } else if (typeof datum === 'string') {
        const [x, y] = datum.split(':');
        return y ? Number(y) : Number(x);
      } else {
        return 0;
      }
    });
  }

  @action renderSparkline(element) {
    sparkline(element, this.yValues, {
      spotRadius: 2,
      onmousemove: this.onMouseMove,
      onmouseout: this.onMouseOut,
    });
  }

  @action destroySparkline(element) {
    this.onMouseOut();

    // This'll clear out existing nodes.
    sparkline(element, [], { interactive: false });
  }

  @action onMouseMove(event, datapoint) {
    cancelAnimationFrame(this._rafPositionTooltip);
    this.tooltipDatapoint = datapoint?.value;
    this._rafPositionTooltip = requestAnimationFrame(() => {
      const tooltip = document.getElementById(this.tooltipId);
      tooltip.style.top = `${event.offsetY}px`;
      tooltip.style.left = `${event.offsetX + 20}px`;
    });
  }

  @action onMouseOut() {
    cancelAnimationFrame(this._rafPositionTooltip);
    this.tooltipDatapoint = null;
  }
}
{{!-- app/components/sparkline.hbs --}}
<div class="eng-sparkline" local-class="container">
  <svg local-class="sparkline"
    width="80px"
    height="24px"
    stroke-width="2"
    aria-describedby={{this.tooltipId}}
    {{did-insert this.renderSparkline}}
    {{did-update this.renderSparkline}}
    {{will-destroy this.destroySparkline}}></svg>

  <div local-class={{if this.tooltipDatapoint 'tooltip' 'visually-hidden'}}
       id={{this.tooltipId}}>
    {{this.tooltipDatapoint}}
  </div>
</div>

It was a pretty easy lift! There was a little massaging to get the data in the right format, but otherwise it was fairly similar to how jquery-sparkline functions.

However, there are a few things that didn’t quite meet the requirements I had for a complete replacement:

  • While you can add mousemove and mouseout event handlers, it does not seem to handle cleanup. Unfortunately, this can cause subtle memory leaks. We’re passing in actions that live on the component, but these functions retain a this binding to the component. These bindings carry references to the Ember application container and may leak memory in our test suite, as containers are being created and torn down all the time. We don’t want old containers hanging around in memory because they’re connected to dangling element references and cannot be garbage collected.
  • Upon inspecting the code, I noticed that all updates are done by tearing down the SVG children and re-creating them. This isn’t particularly efficient, especially because there’s a fixed set of nodes, which could be updated in place instead. Ember’s Glimmer VM is particularly efficient at DOM updates, so this seemed like a missed opportunity.
  • The sparklines drawn don’t respond to container/window resizing.
  • The tooltips on mouseover / mouseout are cool, but are susceptible to layout thrashing, because there’s no automatic debouncing or use of requestAnimationFrame, so I added that to our implementation.

All this being said, the @fnando/sparkline library is still pretty nice. It just doesn’t happen to fulfill all of our requirements here.

Replacing with SVGs rendered by Ember directly

I like the approach of @fnando/sparkline in general, so I decided to take a look at what was going on within the library, and see if there was a more native Ember approach we could take. To my delight, the library is a pretty small amount of code, and quite straightforward. Most of it is the manual DOM operations to build the SVG and event handlers. Therefore, it seemed feasible to replicate the behavior in Ember natively with relative ease.

Replicating the SVG creation

Translating the DOM API calls to build out the SVG was pretty easy to translate into HTMLBars:

{{!-- app/components/sparkline.hbs --}}
<div class="eng-sparkline" local-class="container">
  <svg local-class="sparkline"
    viewBox="0 0 80 30"
    aria-describedby={{this.tooltipId}}
    stroke-width={{this.strokeWidth}}>
    <path class="eng-sparkline--line"
      local-class="sparkline--line"
      d={{this.pathCoords}}
      fill="none" />
    <path class="eng-sparkline--fill"
      local-class="sparkline--fill"
      d={{this.fillCoords}}
      stroke="none" />
    <line
      class="eng-sparkline--cursor"
      local-class="sparkline--cursor"
      id={{this.cursorId}}
      x1={{this.offscreen}}
      x2={{this.offscreen}}
      y1="0"
      y2="30"
      stroke-width={{this.strokeWidth}} />
    <circle
      class="eng-sparkline--spot"
      local-class="sparkline--spot"
      id={{this.spotId}}
      cx={{this.offscreen}}
      cy={{this.offscreen}}
      r={{this.spotRadius}} />

    <circle local-class="sparkline--spot--end"
      cx={{this.lastDatapoint.x}}
      cy={{this.lastDatapoint.y}}
      r="1" />
  </svg>

  {{!-- ... tooltip stuff etc .. --}}
</div>
// app/components/sparkline.js
function getY(max, height, diff, value) {
  return parseFloat((height - (value * height) / max + diff).toFixed(2));
}

export default class Sparkline extends Component {
  cursorId = `sparkline-cursor-${guidFor(this)}`;
  cursorWidth = 2; // px
  offscreen = -1000; // Some arbitrary value to remove the cursor and spot out of the viewing canvas.
  spotId = `sparkline-spot-${guidFor(this)}`;
  spotRadius = 2; // px
  strokeWidth = 2; // px
  tooltipId = `sparkline-tooltip-${guidFor(this)}`;
  viewBoxHeight = 30; // px
  viewBoxWidth = 80; // px

  @cached
  get datapoints() {
    const values = this.yValues;
    const lastItemIndex = values.length - 1;
    const offset = this.width / lastItemIndex;

    return values.map((value, index) => {
      const x = index * offset + this.spotDiameter;
      const y = getY(
        this.maxValue,
        this.height,
        this.strokeWidth + this.spotRadius,
        value
      );

      return {
        index,
        value,
        x,
        y,
      };
    });
  }

  get fillCoords() {
    return `${this.pathCoords} V ${this.viewBoxHeight} L ${this.spotDiameter} ${this.viewBoxHeight} Z`;
  }

  get height() {
    return this.viewBoxHeight - this.strokeWidth * 2 - this.spotDiameter;
  }

  get maxValue() {
    return Math.max(...this.yValues);
  }

  get pathCoords() {
    const { datapoints } = this;
    const pathY = getY(
      this.maxValue,
      this.height,
      this.strokeWidth + this.spotRadius,
      datapoints[0]?.value
    );

    let pathCoords = `M${this.spotDiameter} ${pathY}`;

    datapoints.forEach((datapoint) => {
      pathCoords += ` L ${datapoint.x} ${datapoint.y}`;
    });

    return pathCoords;
  }

  get spotDiameter() {
    return this.spotRadius * 2;
  }

  get width() {
    return this.viewBoxWidth - this.spotDiameter * 2;
  }
}

This was a fun exercise, because I got to learn a little bit about how SVGs are drawn, and brought some clarity to the previously mysterious runes I saw in the d attribute of an SVG <path> element. (Aside: the MDN SVG tutorial is fantastic for getting started)

One thing to point out as well is that we’re using the @cached decorator for datapoints to avoid re-calculating them. The datapoints are looked up in a few places and don’t need to change unless the data or the width changes (which we’ll see more of later.)

Implementing tooltip animations, smoothly

With the drawing complete, we need to replicate the tooltip behavior. Again, this was relatively easy to do, and doesn’t require a lot in terms of changes. Ember makes the addition of the event handlers really easy, without needing to worry about cleanup, by using the {{on}} modifier.

{{!-- app/components/sparkline.hbs --}}
<div class="eng-sparkline" local-class="container">
  <svg local-class="sparkline"
    viewBox="0 0 80 30"
    aria-describedby={{this.tooltipId}}
    stroke-width={{this.strokeWidth}}
    {{will-destroy this.destroySparkline}}>

    {{! -- other svg stuff, omitted for brevity ... }}

    <rect
      local-class="sparkline--interaction-layer"
      width="80px"
      height="30px"
      {{on "mousemove" this.onMouseMove}}
      {{on "mouseout" this.onMouseOut}} />
  </svg>

  <div local-class={{if this.mouseoverDatapoint 'tooltip' 'visually-hidden'}}
       id={{this.tooltipId}}>
    {{this.mouseoverDatapoint.value}}
  </div>
</div>
// app/components/sparkline.js
class Sparkline extends Component {
  @tracked mouseoverDatapoint = null;

  // ... omitted for brevity

  cancelScheduledFrames() {
    cancelAnimationFrame(this._mouseOverEffect);
    cancelAnimationFrame(this._mouseOutEffect);
  }

  @action destroySparkline() {
    this.cancelScheduledFrames();
    this.mouseoverDatapoint = null;
  }

  @action onMouseMove(event) {
    const mouseX = event.offsetX;
    const { datapoints } = this;

    let nextDataPoint = datapoints.find((entry) => {
      return entry.x >= mouseX;
    });

    if (!nextDataPoint) {
      nextDataPoint = datapoints[datapoints.length - 1];
    }

    const previousDataPoint = datapoints[datapoints.indexOf(nextDataPoint) - 1];
    let currentDataPoint, halfway;

    if (previousDataPoint) {
      halfway =
        previousDataPoint.x + (nextDataPoint.x - previousDataPoint.x) / 2;
      currentDataPoint = mouseX >= halfway ? nextDataPoint : previousDataPoint;
    } else {
      currentDataPoint = nextDataPoint;
    }

    this.mouseoverDatapoint = currentDataPoint;

    this.cancelScheduledFrames();
    this._mouseOverEffect = requestAnimationFrame(() => {
      const cursor = document.getElementById(this.cursorId);
      const spot = document.getElementById(this.spotId);
      const tooltip = document.getElementById(this.tooltipId);

      const { x, y } = currentDataPoint;

      spot.setAttribute('cx', x);
      spot.setAttribute('cy', y);

      cursor.setAttribute('x1', x);
      cursor.setAttribute('x2', x);

      tooltip.style.top = `${event.offsetY}px`;
      tooltip.style.left = `${event.offsetX + 20}px`;
    });
  }

  @action onMouseOut() {
    this.mouseoverDatapoint = null;

    this.cancelScheduledFrames();
    this._mouseoutEffect = requestAnimationFrame(() => {
      const cursor = document.getElementById(this.cursorId);
      const spot = document.getElementById(this.spotId);
      const tooltip = document.getElementById(this.tooltipId);

      cursor.setAttribute('x1', this.offscreen);
      cursor.setAttribute('x2', this.offscreen);

      spot.setAttribute('cx', this.offscreen);

      tooltip.style.top = null;
      tooltip.style.left = null;
    });
  }
}

Most of this is directly cribbed from the base mouseover and mouseout implementation from @fnando/sparkline, but note the use of requestAnimationFrame/cancelAnimationFrame to ensure that updates are scheduled so that the positioning of the tooltip and cursor are handled at a smooth 60 FPS with no layout thrashing.

Implementing resize handling

Handling re-draw of our SVG sparklines is the last piece, and the most complicated.

SVGs on their own have built-in scaling with lots of ways to handle aspect ratio and things. This is great for many SVG use-cases, but unfortunately there’s some dynamism in our use-case with sparklines that we need to handle. In general, we want height to be fixed and width to be flexible. This means our y values are fixed, but our x values are dynamically computed on width, which we want to make changeable. To address this we need to re-compute path coordinates as our desired width changes, so we’ll need to use some JavaScript.

Initially, I thought “this is a great opportunity to use ResizeObserver”, an API I’ve heard about as a good solution for efficiently handling resizing.

I wrote up a quick case for it, but unfortunately, the values I was receiving from the ResizeObserverEntry were strangely large, and kept increasing with every frame. I also noticed it did not have support in Safari, which we don’t officially support, but I know we have users who use it, so I decided not to delve too much further into it. In the future, I will probably revisit this.

As I was reading MDN, I found reference to SVGResize, or rather, the resize event on <svg> elements specifically. I tried it by adding {{on "resize" this.updateDimensions}} directly on the <svg> element, but it appeared to have no effect. Upon further reading, it sounds like while it exists in the standards, no prominent browsers have implemented it.

I decided to fallback to adding handlers to window for the resize event. This isn’t ideal, as there’s an event listener for every sparkline being rendered, but we’re at least using requestAnimationFrame to add some debouncing. With this, the worst performance implications are at least somewhat mitigated.

// app/components/sparkline.js
class Sparkline extends Component {
  @tracked mouseoverDatapoint = null;
  @tracked viewBoxWidth = 80; // px <-- we added @tracked to this

  // ... omitted for brevity

  get viewBoxHeight() { // <-- this used to be a class field, now is dynamic
    return this.args.height ? Number(this.args.height) : 30; // px
  }

  cancelScheduledFrames() {
    cancelAnimationFrame(this._computeDimensions); // <-- We added this
    cancelAnimationFrame(this._mouseOverEffect);
    cancelAnimationFrame(this._mouseOutEffect);
  }

  @action updateDimensions(element) {
    this.cancelScheduledFrames();

    this._computeDimensions = requestAnimationFrame(() => {
      const boundingBox = element.getBoundingClientRect();
      this.viewBoxWidth = boundingBox.width;
    });

    if (!this._resizeListener) {
      this._resizeListener = this.updateDimensions.bind(this, element);
      window.addEventListener('resize', this._resizeListener);
    }
  }

  @action destroySparkline() {
    this.cancelScheduledFrames();
    this.mouseoverDatapoint = null;

    // \/ this is new
    if (this._resizeListener) {
      window.removeEventListener('resize', this._resizeListener);
    }
  }
{{!-- app/components/sparkline.hbs --}}
<div class="eng-sparkline" local-class="container">
  <svg local-class="sparkline"
    viewBox="0 0 {{this.viewBoxWidth}} {{this.viewBoxHeight}}"
    aria-describedby={{this.tooltipId}}
    stroke-width={{this.strokeWidth}}
    {{did-insert this.updateDimensions}}
    {{did-update this.updateDimensions}}
    {{will-destroy this.destroySparkline}}>

    {{!-- ... svg lines and points. omitted for brevity. --}}

    <rect
      local-class="sparkline--interaction-layer"
      width={{this.viewBoxWidth}}
      height={{this.viewBoxHeight}}
      {{on "mousemove" this.onMouseMove}}
      {{on "mouseout" this.onMouseOut}} />
  </svg>

  {{!-- ... tooltip stuff --}}
</div>
Our now-resizing sparkline

Total picture

An example of a vitals sparkline, now using SVGs

In the end, we have a total re-implementation of sparklines using SVGs and some JavaScript for handling the coordinate building, dynamic resizing, and hover effects. The whole thing clocks in around a little more than 200 lines of JavaScript and a couple dozen lines of HTMLBars. In comparison, jquery-sparkline is a whopping 61.2kB when minified, so this is already a serious size reduction.