Skip to content

Dev Log 02: Making a Line Chart

Since the last dev log, a few new features have shipped:

  1. Axes
  2. Gridlines
  3. Fluid container

To celebrate, I thought blogging in a tutorial format might be fun. So we're making a Line Chart today.

Step 1: Gather your data

A line chart is typically used to encode a continuous dataset across two dimensions, with the independent variable encoded on x (often this is time) and the dependent variable encoded on y.

For simplicity we will be using a sine wave with a linearly increasing magnitude for funsies. This is implemented as a helper just because as soon as a component example requires a backing class, the component gets shoved into an examples section instead of inline.

So here's our data for 10 values.

Show code
gjs
import generateSine from '~docs/helpers/generate-sine';

<template>
  <table>
    <thead>
      <tr>
        <th>x</th>
        <th>sin(x) * x/5</th>
      </tr>
    </thead>
    <tbody>
      {{#each (generateSine 10) as |row|}}
        <tr>
          <td>{{row.x}}</td>
          <td>{{row.y}}</td>
        </tr>
      {{/each}}
    </tbody>
  </table>
</template>

Step 2: Create a line

Alright, now let's plot it. This should look familiar if you read the last dev log.

Show code
gjs
import { scaleLinear } from '@lineal-viz/lineal/helpers';
import { Line, Fluid } from '@lineal-viz/lineal/components';
import { sub } from '~docs/helpers/math';
import { array } from '@ember/helper';
import generateSine from '~docs/helpers/generate-sine';

<template>
  <div class='demo-chart'>
    <Fluid as |width|>
      <svg width='100%' height='200'>
        {{#let
          (scaleLinear range=(array 15 (sub width 15)))
          (scaleLinear range='190..10')
          (generateSine 50)
          as |xScale yScale data|
        }}
          <Line
            @data={{data}}
            @xScale={{xScale}}
            @yScale={{yScale}}
            @x='x'
            @y='y'
            @curve='natural'
            fill='transparent'
            stroke='currentColor'
            stroke-width='2'
          />
        {{/let}}
      </svg>
    </Fluid>
  </div>
</template>

Oh yeah, that's the stuff. Immediately this is more useful than a table of data, but still, we want to understand specific values in addition to the general shape. So let's add some axes.

Step 3: Add axes

The new Axis component is a Glimmer rewrite of d3-axis. If you aren't familiar with d3-axis, you can think of it is a miniature data visualization that uses a scale as a data source. Scales in d3 are responsible for their own default tick generation and formatting based on the scale type and domain.

Since we already have scales in the template context, we can pass them along to our axis components, which will treat them as tracked properties that SVG elements can be derived from.

Show code
gjs
import { scaleLinear } from '@lineal-viz/lineal/helpers';
import { Line, Axis, Fluid } from '@lineal-viz/lineal/components';
import { array } from '@ember/helper';
import { and } from '~docs/helpers/truth-helpers';
import generateSine from '~docs/helpers/generate-sine';

<template>
  <div class='demo-chart' style='padding: 25px;'>
    <Fluid as |width|>
      <svg width='100%' height='200' style='overflow: visible;'>
        {{#let
          (scaleLinear range=(array 0 width))
          (scaleLinear range='200..0')
          (generateSine 51)
          as |xScale yScale data|
        }}
          <Line
            @data={{data}}
            @xScale={{xScale}}
            @yScale={{yScale}}
            @x='x'
            @y='y'
            @curve='natural'
            fill='transparent'
            stroke='currentColor'
            stroke-width='2'
          />
          {{#if (and xScale.isValid yScale.isValid)}}
            <Axis
              @scale={{yScale}}
              @orientation='left'
              fill='transparent'
            />
            <Axis
              @scale={{xScale}}
              @orientation='bottom'
              @tickValues={{array 2 8 14 20.5 27 33 39 45.5 50}}
              transform='translate(0,{{yScale.compute 0}})'
              fill='transparent'
            />
          {{/if}}
        {{/let}}
      </svg>
    </Fluid>
  </div>
</template>

Just like with d3-axis, @tickValues can be specified to override the default ticks generated by a scale. Here the x-axis ticks are chosen to always miss the plotted line. We don't always have the luxury of tailoring a visualization to a dataset, but it's always nice to do it when we can!

Step 4: Add gridlines

For the full graph paper experience, we want gridlines too. Traditionally with d3, gridlines are implemented using d3-axis. By tweaking the tick size and imperatively removing the tick text elements, we get lines that stretch across our plot area.

Since this is such a common pattern and since we aren't in the business of imperatively tweaking things, Lineal has a GridLines component.

Show code
gjs
import { scaleLinear } from '@lineal-viz/lineal/helpers';
import { Line, Axis, GridLines, Fluid } from '@lineal-viz/lineal/components';
import { array } from '@ember/helper';
import { and } from '~docs/helpers/truth-helpers';
import generateSine from '~docs/helpers/generate-sine';

<template>
  <div class='demo-chart' style='padding: 25px;'>
    <Fluid as |width|>
      <svg width='100%' height='200' style='overflow: visible;'>
        {{#let
          (scaleLinear range=(array 0 width))
          (scaleLinear range='200..0')
          (generateSine 51)
          as |xScale yScale data|
        }}
          <Line
            @data={{data}}
            @xScale={{xScale}}
            @yScale={{yScale}}
            @x='x'
            @y='y'
            @curve='natural'
            fill='transparent'
            stroke='currentColor'
            stroke-width='2'
          />
          {{#if (and xScale.isValid yScale.isValid)}}
            <GridLines
              @scale={{yScale}}
              @direction='horizontal'
              @length={{width}}
              stroke-dasharray='5 5'
            />
            <GridLines
              @scale={{xScale}}
              @direction='vertical'
              @length={{200}}
              @lineValues={{array 5 11 17 23.5 30 36 42 48.5}}
              stroke-dasharray='5 5'
            />
            <Axis
              @scale={{yScale}}
              @orientation='left'
              fill='transparent'
            />
            <Axis
              @scale={{xScale}}
              @orientation='bottom'
              @tickValues={{array 2 8 14 20.5 27 33 39 45.5 50}}
              transform='translate(0,{{yScale.compute 0}})'
              fill='transparent'
            />
          {{/if}}
        {{/let}}
      </svg>
    </Fluid>
  </div>
</template>

Step 5: Style everything

We have a few presentational attributes on our SVG elements that we can move to CSS. And while we're at it, we can do a lot to make this chart look better. Lineal isn't in the business of styling things for you, but it is designed to encourage styling with CSS rather than setting styles or attributes with JS.

css
/* This uses postcss for nesting */
.demo-two-line-chart {
  margin: 50px;
  overflow: visible;

  .line {
    stroke: var(--c-red-line);
    stroke-width: 4;
    stroke-linecap: round;
    fill: none;
  }

  .axis {
    fill: none;

    line,
    path {
      stroke: var(--c-base);
      stroke-width: 2;
    }

    text {
      fill: var(--c-line-0);
    }
  }

  .gridlines line {
    stroke-dasharray: 5 5;
    stroke: var(--c-base);
    opacity: 0.9;
  }
}
Show code
gjs
import { scaleLinear } from '@lineal-viz/lineal/helpers';
import { Line, Axis, GridLines, Fluid } from '@lineal-viz/lineal/components';
import { array } from '@ember/helper';
import { and } from '~docs/helpers/truth-helpers';
import generateSine from '~docs/helpers/generate-sine';

<template>
  <div class='demo-two-line-chart' style='padding: 25px;'>
    <Fluid as |width|>
      <svg width='100%' height='200' style='overflow: visible;'>
        {{#let
          (scaleLinear range=(array 0 width))
          (scaleLinear range='200..0')
          (generateSine 51)
          as |xScale yScale data|
        }}
          {{#if (and xScale.isValid yScale.isValid)}}
            <GridLines
              @scale={{yScale}}
              @direction='horizontal'
              @length={{width}}
            />
            <GridLines
              @scale={{xScale}}
              @direction='vertical'
              @length={{200}}
              @lineValues={{array 5 11 17 23.5 30 36 42 48.5}}
            />
            <Axis
              @scale={{yScale}}
              @orientation='left'
            />
            <Axis
              @scale={{xScale}}
              @orientation='bottom'
              @tickValues={{array 2 8 14 20.5 27 33 39 45.5 50}}
              transform='translate(0,{{yScale.compute 0}})'
            />
          {{/if}}
          <Line
            @data={{data}}
            @xScale={{xScale}}
            @yScale={{yScale}}
            @x='x'
            @y='y'
            @curve='natural'
            class='line'
          />
        {{/let}}
      </svg>
    </Fluid>
  </div>
</template>

Step 6: Accessibility

As the name would suggest, data visualizations are optimized for sighted users. However, this doesn't mean we can't do something to help screen reader users, and we definitely shouldn't make the experience for screen reader users even worse.

First, let's make the chart no longer actively antagonistic to screen reader users by hiding the axes. The rule of thumb is that all text should be read aloud, but tick labels are only useful in the context of visual comparison with marks. Having a screen reader say "eight six four ..." without that context is meaningless and makes the page harder to traverse.

Next, let's try to make the chart somewhat useful for the screen reader experience by providing a title and description. These aren't exactly replacements for the process of reading a chart to gain insights and knowledge, but it at least makes for a cohesive experience rather than trying to pretend the primary content of the page simply isn't there. Standard alt text rules apply here: describe the essence and purpose of the graphic, not just the visual details. For instance, it's not useful to know the line is a shade of red, but it is useful to know that the magnitude of the sine wave is increasing.

Finally, to prevent the loss of granular information, we'll provide a table of the underlying data. The entire premise of data visualization is that visually encoding data makes patterns, trends, and anomolies immediately apparent where they wouldn't be in a table. Given this premise, providing a table might seem counterproductive, or even a forfeit of the ideals of the data viz. I wouldn't argue that it's a forfeit of something, but consider the alternative: no information for users who with low or no vision.

Show code
gjs
import { scaleLinear } from '@lineal-viz/lineal/helpers';
import { Line, Axis, GridLines, Fluid } from '@lineal-viz/lineal/components';
import { array } from '@ember/helper';
import { and } from '~docs/helpers/truth-helpers';
import generateSine from '~docs/helpers/generate-sine';

<template>
  {{#let (generateSine 51) as |data|}}
    <div class='demo-two-line-chart' style='padding: 25px;'>
      <Fluid as |width|>
        <svg width='100%' height='200' style='overflow: visible;'>
          <title>A sine wave with an increasing magnitude</title>
          <desc>
            A sine wave with the function f(x) = sin(x) * x/5 plotted with x values
            ranging from 0 to 50.
          </desc>
          {{#let
            (scaleLinear range=(array 0 width))
            (scaleLinear range='200..0')
            as |xScale yScale|
          }}
            {{#if (and xScale.isValid yScale.isValid)}}
              <GridLines
                @scale={{yScale}}
                @direction='horizontal'
                @length={{width}}
              />
              <GridLines
                @scale={{xScale}}
                @direction='vertical'
                @length={{200}}
                @lineValues={{array 5 11 17 23.5 30 36 42 48.5}}
              />
              <Axis
                @scale={{yScale}}
                @orientation='left'
                aria-hidden='true'
              />
              <Axis
                @scale={{xScale}}
                @orientation='bottom'
                @tickValues={{array 2 8 14 20.5 27 33 39 45.5 50}}
                transform='translate(0,{{yScale.compute 0}})'
                aria-hidden='true'
              />
            {{/if}}
            <Line
              @data={{data}}
              @xScale={{xScale}}
              @yScale={{yScale}}
              @x='x'
              @y='y'
              @curve='natural'
              class='line'
            />
          {{/let}}
        </svg>
      </Fluid>
    </div>

    <details>
      <summary>Plotted sine curve dataset</summary>
      <table>
        <thead>
          <tr>
            <th>x</th>
            <th>sin(x) * x/5</th>
          </tr>
        </thead>
        <tbody>
          {{#each data as |row|}}
            <tr>
              <td>{{row.x}}</td>
              <td>{{row.y}}</td>
            </tr>
          {{/each}}
        </tbody>
      </table>
    </details>
  {{/let}}
</template>

Step 7: Rethink dimensions

At this point we have a very respectable chart, but is it Web Scale™? As users of the web, we expect charts to adapt to our screen dimensions. And as developers of the web, we expect systems of components to encapsulate common problems such as resizing and stretching to fill the contents of a containing element.

With Lineal, that's where Fluid comes in. It uses a ResizeObserver to get pixel dimensions of a wrapping element and then yields those dimensions to its children. This way we can style layout components using whatever we want (%, or vw, or fr or whatever) while still getting pixel values to use in our scales and visual encodings.

The tricky gotcha here is that our ranges cover the plottable area of a chart, which doesn't include our axes. Furthermore, we don't know the dimensions of our axes until they have been rendered. In the future Lineal will provide utilities for doing a two-pass render that dynamically updates a chart's scale's ranges based on the axes dimensions, but for now we can do something that's "good enough" with best effort CSS.

We will construct a few wrapping elements like so:

  1. The outermost div will provide padding that makes way for our axes that are rendered outside of the SVG's dimensions.
  2. An inner div will use Fluid to get dimensions for use in plotting.
  3. An SVG will use these dimensions to set up our scales and marks.
css
.demo-two-fluid-chart {
  padding: 25px;

  &__plot {
    width: 100%;
    height: 25vh;
  }

  &__svg {
    width: 100%;
    height: 100%;
    overflow: visible;
    margin: 0;
  }
}
Show code
gjs
import { scaleLinear } from '@lineal-viz/lineal/helpers';
import { Line, Axis, GridLines, Fluid } from '@lineal-viz/lineal/components';
import { array } from '@ember/helper';
import { and } from '~docs/helpers/truth-helpers';
import generateSine from '~docs/helpers/generate-sine';

<template>
  <div class='flex'>
    <div class='min-col'>
      {{#let (generateSine 51) as |data|}}
        <div class='demo-two-fluid-chart'>
          <Fluid class='demo-two-fluid-chart__plot' as |width height|>
            <svg class='demo-two-line-chart demo-two-fluid-chart__svg'>
              <title>A sine wave with an increasing magnitude</title>
              <desc>
                A sine wave with the function f(x) = sin(x) * x/5 plotted with x values
                ranging from 0 to 50.
              </desc>
              {{#let
                (scaleLinear range=(array 0 width))
                (scaleLinear range=(array height 0))
                as |xScale yScale|
              }}
                {{#if (and xScale.isValid yScale.isValid)}}
                  <GridLines
                    @scale={{yScale}}
                    @direction='horizontal'
                    @length={{width}}
                  />
                  <GridLines
                    @scale={{xScale}}
                    @direction='vertical'
                    @length={{height}}
                    @lineValues={{array 5 11 17 23.5 30 36 42 48.5}}
                  />
                  <Axis
                    @scale={{yScale}}
                    @orientation='left'
                    aria-hidden='true'
                  />
                  <Axis
                    @scale={{xScale}}
                    @orientation='bottom'
                    @tickValues={{array 2 8 14 20.5 27 33 39 45.5 50}}
                    transform='translate(0,{{yScale.compute 0}})'
                    aria-hidden='true'
                  />
                {{/if}}
                <Line
                  @data={{data}}
                  @xScale={{xScale}}
                  @yScale={{yScale}}
                  @x='x'
                  @y='y'
                  @curve='natural'
                  class='line'
                />
              {{/let}}
            </svg>
          </Fluid>
        </div>

        <details>
          <summary>Plotted sine curve dataset</summary>
          <table>
            <thead>
              <tr>
                <th>x</th>
                <th>sin(x) * x/5</th>
              </tr>
            </thead>
            <tbody>
              {{#each data as |row|}}
                <tr>
                  <td>{{row.x}}</td>
                  <td>{{row.y}}</td>
                </tr>
              {{/each}}
            </tbody>
          </table>
        </details>
      {{/let}}
    </div>
    <div class='animated-gutter'></div>
  </div>
</template>

Next Steps

This is a big milestone for creating charts that look like charts, but there is plenty more to do. Next up is introducing interactivity!