Donut and Polar Area Charts with One Device Highlighted in Vue
Using ApexCharts with Vue
This Donut and Polar Area Charts with One Device Highlighted example wires ApexCharts into Vue through the vue3-apexcharts component.
Install it with npm install vue3-apexcharts apexcharts, then mount the chart with <apexchart type="..." :options="options" :series="series" />.
<template>
<div>
<div class="wrap">
<h1>Which device brings the traffic, and the bigger orders?</h1>
<p>
Pick a device. Each channel stays faded and the device's share is drawn
solid in front.
</p>
<div class="actions">
<span>Device</span>
<button data-device="mobile" class="active">Mobile</button>
<button data-device="desktop">Desktop</button>
<button data-device="tablet">Tablet</button>
<button data-device="">All (clear)</button>
</div>
<h2><small>Sums</small>The device is a share of the channel</h2>
<p>
A channel's sessions and orders are its devices' added up, so the solid
part always fits inside its slice.
</p>
<div class="grid">
<div class="card">
<div id="chart">
<apexchart
type="donut"
height="360"
:options="chartOptions"
:series="series"
></apexchart>
</div>
</div>
<div class="card">
<div id="chart2">
<apexchart
type="polarArea"
height="360"
:options="chartOptions1"
:series="series1"
></apexchart>
</div>
</div>
</div>
<h2><small>Averages</small>A device can spend more than its channel</h2>
<p>
An average is not a sum, so a device can sit above its channel. Pick
Desktop: each solid slice runs past the channel's value, marked by a
dashed line.
</p>
<div class="grid">
<div class="card">
<div id="chart3">
<apexchart
type="polarArea"
height="360"
:options="chartOptions2"
:series="series2"
></apexchart>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import VueApexCharts from 'vue-apexcharts'
import ApexCharts from 'apexcharts'
import 'apexcharts/features/highlight-filter'
// Shared by the vanilla-js, React and Vue builds.
//
// Every channel's figure is the whole and one device's is the part. A pick
// is an ordinary update of highlightFilter.data, one value per slice; null
// clears it.
var CHANNELS = ['Organic', 'Paid search', 'Social', 'Email', 'Referral']
// Sums: a channel's sessions and orders are its devices' added up, so a
// device's part always fits inside the channel's slice.
var SESSIONS = [4200, 2600, 1900, 1100, 700]
var SESSIONS_BY_DEVICE = {
mobile: [2500, 1300, 1500, 400, 300],
desktop: [1400, 1100, 300, 600, 330],
tablet: [300, 200, 100, 100, 70],
}
var ORDERS = [300, 240, 160, 180, 120]
var ORDERS_BY_DEVICE = {
mobile: [140, 100, 100, 60, 45],
desktop: [130, 120, 45, 100, 60],
tablet: [30, 20, 15, 20, 15],
}
// Averages: a channel's average order value is its devices' revenue over
// their orders, so a device that spends more per order sits above it.
var ORDER_VALUE = [66, 85, 49, 76, 95]
var ORDER_VALUE_BY_DEVICE = {
mobile: [52, 70, 41, 60, 76],
desktop: [81, 98, 66, 84, 109],
tablet: [70, 85, 55, 80, 95],
}
function pickDevice(device) {
document.querySelectorAll('[data-device]').forEach(function (b) {
b.classList.toggle('active', b.getAttribute('data-device') === device)
})
// A pick is an ordinary update; null clears it.
ApexCharts.getChartByID('sessions').updateOptions({
highlightFilter: { data: device ? SESSIONS_BY_DEVICE[device] : null },
})
ApexCharts.getChartByID('orders').updateOptions({
highlightFilter: { data: device ? ORDERS_BY_DEVICE[device] : null },
})
ApexCharts.getChartByID('order-value').updateOptions({
highlightFilter: { data: device ? ORDER_VALUE_BY_DEVICE[device] : null },
})
}
function wireDevices() {
document.querySelectorAll('[data-device]').forEach(function (b) {
b.addEventListener('click', function () {
pickDevice(b.getAttribute('data-device'))
})
})
}
export default {
components: {
apexchart: VueApexCharts,
},
data: function () {
return {
series: SESSIONS,
chartOptions: {
chart: {
id: 'sessions',
type: 'donut',
height: 360,
toolbar: { show: false },
},
labels: CHANNELS,
colors: ['#0284c7', '#0f766e', '#f59e0b', '#e11d48', '#64748b'],
plotOptions: {
pie: {
donut: {
size: '58%',
labels: {
show: true,
value: {
formatter: function (v) {
return Number(v).toLocaleString()
},
},
total: {
show: true,
label: 'Sessions',
formatter: function (w) {
return w.globals.seriesTotals
.reduce(function (a, b) {
return a + b
}, 0)
.toLocaleString()
},
},
},
},
},
},
// No slice borders: the faded tints alone keep the slices apart.
stroke: { width: 0 },
dataLabels: { enabled: true },
legend: { position: 'bottom' },
title: { text: 'Sessions by channel', style: { fontSize: '13px' } },
tooltip: {
y: {
formatter: function (v) {
return Number(v).toLocaleString()
},
},
},
highlightFilter: { data: SESSIONS_BY_DEVICE.mobile },
},
series1: ORDERS,
chartOptions1: {
chart: {
id: 'orders',
type: 'polarArea',
height: 360,
toolbar: { show: false },
},
labels: CHANNELS,
colors: ['#0284c7', '#0f766e', '#f59e0b', '#e11d48', '#64748b'],
fill: { opacity: 0.85 },
stroke: { width: 0 },
legend: { position: 'bottom' },
title: { text: 'Orders by channel', style: { fontSize: '13px' } },
highlightFilter: { data: ORDERS_BY_DEVICE.mobile },
},
series2: ORDER_VALUE,
chartOptions2: {
chart: {
id: 'order-value',
type: 'polarArea',
height: 360,
toolbar: { show: false },
},
labels: CHANNELS,
colors: ['#0284c7', '#0f766e', '#f59e0b', '#e11d48', '#64748b'],
fill: { opacity: 0.85 },
stroke: { width: 0 },
yaxis: {
labels: {
formatter: function (v) {
return '$' + v
},
},
},
legend: { position: 'bottom' },
title: {
text: 'Average order value by channel',
style: { fontSize: '13px' },
},
tooltip: {
y: {
formatter: function (v) {
return '$' + v
},
},
},
highlightFilter: { data: ORDER_VALUE_BY_DEVICE.mobile },
},
deviceTimer: null,
}
},
mounted: function () {
// The vue-apexcharts wrappers own the renders, so reach the live instances
// by their chart.id. (pickDevice lives in the shared head script.)
var me = this
me.deviceTimer = window.setInterval(function () {
if (
!ApexCharts.getChartByID('sessions') ||
!ApexCharts.getChartByID('orders') ||
!ApexCharts.getChartByID('order-value')
)
return
window.clearInterval(me.deviceTimer)
wireDevices()
}, 50)
},
beforeDestroy: function () {
window.clearInterval(this.deviceTimer)
},
}
</script>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafbfc;
color: #2c3e50;
margin: 0;
padding: 32px 16px;
}
.wrap {
max-width: 760px;
margin: 0 auto;
}
h1 {
font-size: 22px;
margin: 0 0 8px;
}
p {
color: #5b6b78;
line-height: 1.5;
margin: 0 0 20px;
}
.actions {
margin: 0 0 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.actions span {
font-size: 13px;
color: #5b6b78;
margin-right: 4px;
}
.actions button {
color: #2c3e50;
background: #fff;
border: 1px solid #cfd8e3;
padding: 6px 14px;
font-weight: 600;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
}
.actions button.active {
color: #fff;
background: #0284c7;
border-color: #0284c7;
}
h2 {
font-size: 16px;
margin: 28px 0 6px;
}
h2 small {
display: block;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #0284c7;
margin-bottom: 2px;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.card {
background: #fff;
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
min-width: 0;
}
/* Each chart fills its card up to 340px, centred, and shrinks with it on a
phone. */
.card > div {
max-width: 340px;
margin: 0 auto;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>