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.
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>
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>
Join our Discord to share your charts, ask questions, or collaborate with fellow developers!
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.