loading...

A new technique for making responsive, JavaScript-free charts

richharris profile image Rich Harris ・8 min read

There are countless libraries for generating charts on the web. Each serves a slightly different niche, but all of them have one thing in common: they require JavaScript.

That makes sense, of course — often your charts will depend on data that must be fetched over the network with JS, or will be rendered to a <canvas> element. But it's not ideal. Not everyone has JS, and in any case relying on it means that you'll be left with a chart-shaped hole in the page until it loads, which you can only really get away with if all your dataviz is tucked away below the fold.

Another more subtle problem is that fluid charts — those that adapt to the width of their containers — must be redrawn upon resize to avoid potentially breaking. That can mean more work for the developer (particularly if the developer is using a low-level library like D3), and certainly more work for the browser.

For a recent New York Times article, I wanted to see if it was possible to create SVG charts that would work without JS.

CyberTipline reports vs funding

Well, it is. I haven't seen the same combination of techniques used elsewhere, so I figured I'd write up the process. I've also created an experimental Svelte component library called Pancake to make these techniques easier to use.

The problem

Creating an SVG line chart (we'll come to other chart types later) is actually rather simple. Suppose we have a series like this...

const data = [
  { x: 0,  y: 0 },
  { x: 1,  y: 1 },
  { x: 2,  y: 4 },
  { x: 3,  y: 9 },
  { x: 4,  y: 16 },
  { x: 5,  y: 25 },
  { x: 6,  y: 36 },
  { x: 7,  y: 49 },
  { x: 8,  y: 64 },
  { x: 9,  y: 81 },
  { x: 10, y: 100 }
];

...and a 300px by 100px chart. If we multiply the x values by 30, and subtract the y values from 100, we'll get coordinates that fill the space:

<polyline points="
  0,0
  30,99
  60,96
  90,91
  120,84
  150,75
  180,64
  210,51
  240,36
  270,19
  300,0
"></polyline>

Typically, of course, you'd use a scaling function rather than calculating the coordinates manually:

function scale(domain, range) {
  const m = (range[1] - range[0]) / (domain[1] - domain[0]);
  return num => range[0] + m * (num - domain[0]);
}

const x = scale([0, Math.max(...data.map(d => d.x))], [0, 300]);
const y = scale([0, Math.max(...data.map(d => d.y))], [100, 0]);

const points = data.map(d => `${x(d.x)},${y(d.y)}`).join(' ');

const chart = `
<svg width="300" height="100">
  <polyline points="${points}"></polyline>
</svg>
`;

Throw in some axes and some styling, and we have a chart:

Simple line chart

That logic could all live inside a Node.js script, meaning this chart could easily be created without any client-side JS.

But it won't adapt to the size of its container — it will always be a 300px by 100px chart. On most websites, that's a problem.

The solution (part one)

SVG has an attribute called viewBox that defines a coordinate system that is independent of the size of the <svg> element itself. Ordinarily the aspect ratio of the viewBox is preserved regardless of the aspect ratio of the <svg> element, but we can disable that with preserveAspectRatio="none".

We can pick a simple coordinate system, like this...

<svg viewBox="0 0 100 100" preserveAspectRatio="none">

...and project our data into it. Now, our chart adapts fluidly to its environment:

Fluid-but-broken charts

But it's obviously broken in two important ways. Firstly, the text is horribly scaled, to the point of being illegible in some cases. Secondly, the line strokes are stretched along with the lines themselves, which looks dreadful.

The second of these issues is straightforward enough to solve with a little-known CSS property — vector-effect: non-scaling-strokeapplied to every element:

Fluid-and-slightly-less-broken charts

But the first issue can't, to my knowledge, be solved within SVG.

The solution (part two)

Instead of using SVG elements for the axes, we can use HTML elements and position them with CSS. Because we're using a percentage-based coordinate system, it's very easy to keep the HTML layer and the SVG layer glued together.

Recreating the axes above with HTML is as simple as this:

<!-- x axis -->
<div class="x axis" style="top: 100%; width: 100%; border-top: 1px solid black;">
  <span style="left: 0">0</span>
  <span style="left: 20%">2</span>
  <span style="left: 40%">4</span>
  <span style="left: 60%">6</span>
  <span style="left: 80%">8</span>
  <span style="left: 100%">10</span>
</div>

<!-- y axis -->
<div class="y axis" style="height: 100%; border-left: 1px solid black;">
  <span style="top: 100%">0</span>
  <span style="top: 50%">50</span>
  <span style="top: 0%">100</span>
</div>

<style>
  .axis {
    position: absolute;
  }

  .axis span {
    position: absolute;
    line-height: 1;
  }

  .x.axis span {
    top: 0.5em;
    transform: translate(-50%,0);
  }

  .y.axis span {
    left: -0.5em;
    transform: translate(-100%,-50%);
  }
</style>

Our charts are no longer borked:

Non-borked fluid line charts

Another benefit of using HTML elements is that they automatically snap to the nearest pixel, meaning you don't get the 'fuzzy' effect that tends to happen with SVG elements.

Packaging it up

This solves the problem, but there's a lot of manual busywork involved, hence Pancake. With Pancake, the chart above would look something like this:

<script>
  import * as Pancake from '@sveltejs/pancake';

  const points = [
    { x: 0,  y: 0 },
    { x: 1,  y: 1 },
    { x: 2,  y: 4 },
    { x: 3,  y: 9 },
    { x: 4,  y: 16 },
    { x: 5,  y: 25 },
    { x: 6,  y: 36 },
    { x: 7,  y: 49 },
    { x: 8,  y: 64 },
    { x: 9,  y: 81 },
    { x: 10, y: 100 }
  ];
</script>

<div class="chart">
  <Pancake.Chart x1={0} x2={10} y1={0} y2={100}>
    <Pancake.Box x2={10} y2={100}>
      <div class="axes"></div>
    </Pancake.Box>

    <Pancake.Grid vertical count={5} let:value>
      <span class="x label">{value}</span>
    </Pancake.Grid>

    <Pancake.Grid horizontal count={3} let:value>
      <span class="y label">{value}</span>
    </Pancake.Grid>

    <Pancake.Svg>
      <Pancake.SvgLine data={points} let:d>
        <path class="data" {d}/>
      </Pancake.SvgLine>
    </Pancake.Svg>
  </Pancake.Chart>
</div>

<style>
  .chart {
    height: 100%;
    padding: 3em 2em 2em 3em;
    box-sizing: border-box;
  }

  .axes {
    width: 100%;
    height: 100%;
    border-left: 1px solid black;
    border-bottom: 1px solid black;
  }

  .y.label {
    position: absolute;
    left: -2.5em;
    width: 2em;
    text-align: right;
    bottom: -0.5em;
  }

  .x.label {
    position: absolute;
    width: 4em;
    left: -2em;
    bottom: -22px;
    font-family: sans-serif;
    text-align: center;
  }

  path.data {
    stroke: red;
    stroke-linejoin: round;
    stroke-linecap: round;
    stroke-width: 2px;
    fill: none;
  }
</style>

Because we're using Svelte, this chart can easily be rendered at build time with Node.js, or be injected into the DOM using client-side JS. For charts that have some interactivity (such as the big example chart on the Pancake homepage), you might want to do both — serve the basic chart with your HTML, then progressively enhance it with interactivity by hydrating the initial DOM. This is something that's rather difficult to do without a component framework like Svelte.

Notice that Pancake isn't actually creating the <span> and <path> nodes that comprise the chart. Rather, the components are primarily logical — you bring the markup, meaning you have fine-grained control over the appearance of chart elements.

Taking it further

We can do much more than simple line charts:

Different Pancake chart types

Scatterplots are particularly interesting. Because we can't use <circle> elements — they would stretch, like the line and text elements earlier — we have to get slightly creative. The <Pancake.Scatterplot> component generates a path of disconnected arcs with a radius of zero. By rendering that path with a stroke width, we can make it look as though we're plotting circles.

Because we're in a Svelte component, we can easily introduce motion into our charts, as in this small multiples example. We can also add things like declarative transitions with a minimum of fuss.

Interactivity can also be handled declaratively within a Pancake chart. For example, we can create a quadtree (borrowing heavily from D3) that lets you find the nearest point to the mouse:

<Pancake.SvgScatterplot data={points} let:d>
  <path class="data" {d}/>
</Pancake.SvgScatterplot>

<Pancake.Quadtree data={points} let:closest>
  {#if closest}
    <Pancake.SvgPoint x={closest.x} y={closest.y} let:d>
      <path class="highlight" {d}/>
    </Pancake.SvgPoint>
  {/if}
</Pancake.Quadtree>

At the New York Times we're using a very similar technique to create JS-less maps tracking the coronavirus outbreak. There's a bit more to do, but it's likely that this work will be folded into Pancake eventually.

In future, the library will likely add support for rendering to a canvas layer (both 2D and WebGL). Charts that use <canvas> will have a hard dependency on JS, but it's necessary in cases where you have more data than can be rendered with SVG in a performant way.

Caveats

This is still somewhat experimental; it hasn't been battle-tested to anything like the degree that existing charting libraries have.

Its focus is on managing the coordinate system for two dimensional charts. That's enough for line charts and bar charts and scatterplots and stacked area charts and what-have-you, but if you need to make pie charts you will have to look elsewhere.

For now, there's no documentation, but the homepage has examples you can crib from. It's possible that APIs will change as we encounter more real world problems.

Acknowledgments

The name 'Pancake' comes from the fact that charts are built by stacking layers on top of each other. I'm deeply indebted to Michael Keller for creating Layer Cake, which Pancake draws a lot of inspiration from, and from where I ripped off some of the example charts linked above. Michael also reported the story linked above, giving me a reason to create Pancake in the first place.

I'm also indebted to Mike Bostock, of D3 and Observable fame, for sharing the insights, examples and code that make projects like this one possible. The handful of examples on the Pancake homepage are shamelessly copied from the D3 examples page, which is a goldmine for anyone looking to test out a new charting library.

Posted on by:

Discussion

markdown guide
 

Hi Rich,

thanks for the article, that's a very cool idea!

Did you think about making it a bit more accessible by using a table or dl for the data? A little bit more data points would be necessary, but I think something like this would improve accessibility:


(don't look too detailed at the code, I just put together real quick)

I was also wondering if using aria-describedby would be appropriate here to make the connection between the svg and the data element, but I'm not sure about that.

Cheers,
Michael

 

Yes, absolutely — thank you for mentioning this. It's something that really should be a priority of any library that tries to implement these techniques. My initial focus was on figuring out whether or not this was a viable solution at all, but I've just opened an issue to address a11y github.com/Rich-Harris/pancake/iss...

 

There is in fact a way to solve the text scaling issue: nest your svg with the polyline inside another svg that contains the text elements and use percentages to position those. It's important that the top level svg doesn't have a viewBox in that case.

An example:

 

JS God. Now CSS God. 🙇🏻‍♂️🙇🏻‍♀️

 

Daily Svelte developer and long time SVG enthusiast here, I literally just got done making an immensely immature twerk demo in the REPL to experiment with tweened SVG path data svelte.dev/repl/b682e0fda28f40dc99... and was pleased as usual with how Svelte can manipulate SVG so seamlessly, now this? Incredible.

I'm excited to see where this can lead for data viz in Svelte but I'm wondering about your note on pie charts, is there no plan for integrating pie charts in Pancake or is it just something that hasn't been tackled yet?

Personally I'd love to see some Pancake.Pie and Pancake.Donut components if for nothing else than the delicious names.

 

Charts that use will have a hard dependency on JS, but it's necessary in cases where you have more data than can be rendered with SVG in a performant way.

I'm curious to what point we can push things with SVG. I recently needed to embed multiple animated SVGs into components in an Angular project. (A single canvas wasn't a straightforward option; unless I treated it as a spritesheet perhaps?) There was a noticeable performance impact on load; and significant CPU load caused by simple animations in the SVGs themselves.

TBH I'm not sure how much of the load slowdown was the data processing + angular rendering the dynamic SVGs and how much was the browser having to parse/render the generated SVG markup; but it felt like both contributed to the delay (I suppose this should actually be obvious from the performance data). From what I could find, the CPU load caused by animation seems to be a known issue: not all browsers optimise for animation in SVG (whether it's SMIL or CSS-based) - Chrome being a notable culprit...

I'm now looking at D3 for another project and much prefer the approach you've taken here: passing attributes into a dynamically created SVG via JS feels really verbose in comparison to writing them directly in the markup.

Anyway - thanks for the article and thanks for Svelte!

 

Thanks for this article! Great tips on making responsive axis. I will check pancake in my next svelte project :)

 
 

"..but the homepage has examples you can climb from" I mean, it is like climbing, every time there is a goal higher than you can achieve right now