Create a dual axis chart with Svelte

In this tutorial we will combine a barchart with a line chart to create a dual axis chart like below.

JanAprJulOct0500010000150002000025000$ Sales01000200030004000$ Profits

Let’s first create our axis components, we will need left, bottom and right axis for our chart. And those will be built by using a Svg <line> and <text> elements which will create the main axis line, axis ticks and tick labels.

Left y-axis.

<!-- AxisLeft.svelte -->
<script>
  export let yScale;
  export let margin;
  export let ticksNumber = 5;
</script>

{#if yScale}
  <g transform="translate({margin.left},0)">
    <!-- yScale.domain() is an array with two elements min and max values 
    from the data, so we can use it to create the start and end point
    of our axis line -->
    <line
      stroke="currentColor"
      y1={yScale(0)}
      y2={yScale(yScale.domain()[1])} />
    <!-- Specify the number of ticks here -->
    {#each yScale.ticks(ticksNumber) as tick}
      {#if tick !== 0}
        <line
          stroke="currentColor"
          x1={0}
          x2={-6}
          y1={yScale(tick)}
          y2={yScale(tick)} />
      {/if}

      <text
        fill="currentColor"
        text-anchor="end"
        font-size="10"
        dominant-baseline="middle"
        x={-9}
        y={yScale(tick)}>
        {tick}
      </text>
    {/each}
  </g>
{/if}

Right y-axis.

<!-- AxisRight.svelte -->
<script>
  export let yScale;
  export let width;
  export let margin;
  export let ticksNumber = 5;
</script>

{#if yScale}
  <g transform="translate({width - margin.right},0)">
    <!-- yScale.domain() is an array with two elements of min and max values from the
    data, so we can use it to create the start and end point of our axis line -->
    <line
      stroke="currentColor"
      stroke-width="1px"
      y1={yScale(0)}
      y2={yScale(yScale.domain()[1])} />
    {#each yScale.ticks(ticksNumber) as tick}
      {#if tick !== 0}
        <line
          stroke-width="1px"
          stroke="currentColor"
          x1={0}
          x2={6}
          y1={yScale(tick)}
          y2={yScale(tick)} />
      {/if}

      <text
        fill="currentColor"
        text-anchor="start"
        font-size="10"
        dominant-baseline="middle"
        x={12}
        y={yScale(tick)}>
        {tick}
      </text>
    {/each}
  </g>
{/if}

Bottom axis will be slightly different since scaleBand() does not have the .ticks() method. In this case we will use xScale.domain() to create the axis ticks.

We have two utility functions filterFunc and formatFunc to prevent overlapping text. For example instead of showing each month in the axis like January, February, March etc. we will show every third month and shorten the month name to three characters.

Those utility functions are props that we will pass to our main chart component.

For instance:

// formatFunc to shorten month names
(d) => d.slice(0, 3)

// filterFunc to show each third tick
(d, i) => i % 3 === 0
<script>
  export let xScale;
  export let margin;
  export let height;
  export let width;
  export let filterFunc = (d) => true; // Default filter function that includes all ticks
  export let formatFunc = (d) => d; // Default format function that returns the tick as is

  // Function to filter the ticks based on the provided filter function
  const filterTicks = (domain) => domain.filter(filterFunc);
</script>

{#if xScale}
  <g transform="translate(0,{height - margin.bottom + 1})">
    <line stroke="currentColor" x1={margin.left} x2={width - margin.right} />
    {#each xScale.domain() as tick}
      <line
        stroke="currentColor"
        x1={xScale(tick) + xScale.bandwidth() / 2}
        x2={xScale(tick) + xScale.bandwidth() / 2}
        y2="6" />
    {/each}

    {#each filterTicks(xScale.domain()) as tick}
      <g transform={`translate(${xScale(tick) + xScale.bandwidth() / 2}, 0)`}>
        <text
          font-size="10px"
          fill="currentColor"
          text-anchor="middle"
          dy="0.71em"
          y="9">
          {formatFunc(tick)}
        </text>
      </g>
    {/each}
  </g>
{/if}

Now we have axis components ready and here is the monthly sales and profit data that we are going to use.

// sales_data.json
// copy by clicking the copy icon on the right corner
// or download on https://datavisualizationwithsvelte.com/data/sales_data.json
[
  {
    "month": "January",
    "sales": 5000,
    "profit": 1200
  },
  {
    "month": "February",
    "sales": 7000,
    "profit": 1500
  },
  {
    "month": "March",
    "sales": 8000,
    "profit": 1800
  },
  {
    "month": "April",
    "sales": 12000,
    "profit": 2000
  },
  {
    "month": "May",
    "sales": 15000,
    "profit": 3000
  },
  {
    "month": "June",
    "sales": 20000,
    "profit": 4000
  },
  {
    "month": "July",
    "sales": 22000,
    "profit": 3500
  },
  {
    "month": "August",
    "sales": 18000,
    "profit": 3700
  },
  {
    "month": "September",
    "sales": 16000,
    "profit": 3100
  },
  {
    "month": "October",
    "sales": 19000,
    "profit": 3300
  },
  {
    "month": "November",
    "sales": 23000,
    "profit": 3600
  },
  {
    "month": "December",
    "sales": 25000,
    "profit": 3900
  }
]

If you create a sales_data.json file you can import it directly to your svelte component like import data from '$lib/data/sales_data.json'; if you have "resolveJsonModule": true, in your tsconfig.json file.

Here is the final code to bring together axis components.

<!-- DualAxisChart.svelte -->
<script>
  import data from '$lib/data/sales_data.json';
  import { scaleBand, scaleLinear } from 'd3-scale';
  import { max } from 'd3-array';
  import AxisBottom from './AxisBandBottom.svelte';
  import AxisLeft from './AxisLeft.svelte';
  import AxisRight from './AxisRight.svelte';

  let xScale, yScale1, yScale2;

  let width = 400; // width will be set by the clientWidth
  const height = 350;
  const margin = { top: 40, right: 30, bottom: 20, left: 50 };

  $: if (data && width) {
    xScale = scaleBand()
      .domain(data.map((d) => d.month))
      .range([margin.left, width - margin.right])
      .padding(0.1);

    yScale1 = scaleLinear()
      .domain([0, max(data, (d) => d.sales)])
      .range([height - margin.bottom, margin.top]);

    yScale2 = scaleLinear()
      .domain([0, max(data, (d) => d.profit)])
      .range([height - margin.bottom, margin.top]);
  }
</script>

<div class="wrapper" bind:clientWidth={width}>
  {#if data && width}
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}>
      <AxisBottom
        {width}
        {height}
        {margin}
        {xScale}
        formatFunc={(d) => d.slice(0, 3)}
        filterFunc={(d, i) => i % 3 === 0} />
      <AxisLeft {width} {height} {margin} yScale={yScale1} ticksNumber={5} />
      <AxisRight {width} {height} {margin} yScale={yScale2} />
    </svg>
  {/if}
</div>
JanAprJulOct050001000015000200002500001000200030004000

Now let’s add the bar and line chart and axis labels to our Svg. We are reusing the labels.svelte compoment from the Scatter Chart I tutorial.

<script>
  import data from '$lib/data/sales_data.json';
  import { scaleBand, scaleLinear } from 'd3-scale';
  import { max } from 'd3-array';
  import { line, curveBasis } from 'd3-shape';
  import AxisBottom from './AxisBandBottom.svelte';
  import AxisLeft from './AxisLeft.svelte';
  import AxisRight from './AxisRight.svelte';
  import Labels from './Labels.svelte';

  let xScale, yScale1, yScale2, linePath;

  let width = 400;
  const height = 350;
  const margin = { top: 40, right: 30, bottom: 20, left: 50 };

  $: if (data && width) {
    xScale = scaleBand()
      .domain(data.map((d) => d.month))
      .range([margin.left, width - margin.right])
      .padding(0.1);

    yScale1 = scaleLinear()
      .domain([0, max(data, (d) => d.sales)])
      .range([height - margin.bottom, margin.top]);

    yScale2 = scaleLinear()
      .domain([0, max(data, (d) => d.profit)])
      .range([height - margin.bottom, margin.top]);

    linePath = line()
      .x((d) => xScale(d.month) + xScale.bandwidth() / 2)
      .y((d) => yScale2(d.profit))
      .curve(curveBasis);
  }
</script>

<div class="wrapper" bind:clientWidth={width}>
  {#if data && width}
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}>
      <AxisBottom
        {width}
        {height}
        {margin}
        {xScale}
        formatFunc={(d) => d.slice(0, 3)}
        filterFunc={(d, i) => i % 3 === 0} />
      <AxisLeft {width} {height} {margin} yScale={yScale1} />
      <Labels
        labelfory={true}
        {width}
        {height}
        {margin}
        yoffset={-15}
        xoffset={0}
        label={'$ Sales'}
        fillColor="steelblue" />
      <AxisRight {width} {height} {margin} yScale={yScale2} />
      <Labels
        labelfory={true}
        {width}
        {height}
        {margin}
        yoffset={-15}
        xoffset={width - 28}
        label={'$ Profits'}
        fillColor="red" />

      {#each data as d}
        <rect
          x={xScale(d.month)}
          y={yScale1(d.sales)}
          width={xScale.bandwidth()}
          height={yScale1(0) - yScale1(d.sales)}
          fill="steelblue" />
      {/each}

      <path d={linePath(data)} fill="none" stroke="red" stroke-width="2" />
    </svg>
  {/if}
</div>

Thanks for reading! In case you have a question or comment please join us on Discord!

Please use dual axis charts sparingly and only if you have a compelling reason, as they can be difficult to interpret. Dual axis charts often lead to misinterpretation because they combine two different scales, making it challenging to compare data accurately. Alternatives like side-by-side charts, indexed charts, or connected scatterplots are usually more effective for clear and reliable data representation. For more information, refer here.