<template>
<div>
<div class="wrap">
<h1>Drilldown against a real backend</h1>
<p>
The root shows three years. Each year's quarters are fetched on click,
with a spinner while the request is in flight. Levels are cached, so
drilling back down a branch you have already visited is instant. Tick
"make the next request fail" to see that a failed fetch leaves the chart
exactly where it was and reports the error, rather than stranding the
view.
</p>
<div class="actions">
<button id="up">Back</button>
<button id="clear-cache">Clear cache</button>
<label>
<input type="checkbox" id="fail" />
Make the next request fail
</label>
</div>
<div class="chart-wrap">
<div id="chart">
<apexchart
type="bar"
height="400"
:options="chartOptions"
:series="series"
></apexchart>
</div>
</div>
<div class="status" id="status"></div>
</div>
</div>
</template>
<script>
import VueApexCharts from 'vue-apexcharts'
import ApexCharts from 'apexcharts'
// Shared by the vanilla-js, React and Vue builds.
//
// Levels are FETCHED, not inlined, which is how a real dashboard works: the
// root is small, and each child is a request. A fake backend stands in for the
// API so the demo is self-contained and deterministic.
var BACKEND = {
'2023-q': [
{ x: 'Q1', y: 21 }, { x: 'Q2', y: 28 },
{ x: 'Q3', y: 24 }, { x: 'Q4', y: 27 }
],
'2024-q': [
{ x: 'Q1', y: 33 }, { x: 'Q2', y: 41 },
{ x: 'Q3', y: 36 }, { x: 'Q4', y: 40 }
],
'2025-q': [
{ x: 'Q1', y: 44 }, { x: 'Q2', y: 52 },
{ x: 'Q3', y: 48 }, { x: 'Q4', y: 56 }
]
}
// Flipped by the checkbox, so the failure path is demonstrable rather than
// described. A failed fetch must leave the chart exactly where it was.
var failNext = false
function fakeFetch(id) {
return new Promise(function (resolve, reject) {
window.setTimeout(function () {
if (failNext) {
reject(new Error('Request failed (simulated)'))
return
}
var rows = BACKEND[id]
if (!rows) {
reject(new Error('No level "' + id + '"'))
return
}
resolve({ id: id, name: id.replace('-q', ' by quarter'), data: rows })
}, 700)
})
}
function setStatus(text, isError) {
var el = document.querySelector('#status')
if (!el) return
el.textContent = text || ''
el.className = isError ? 'status error' : 'status'
}
export default {
components: {
apexchart: VueApexCharts,
},
data: function () {
return {
series: [{
name: 'Revenue',
data: [
{ x: '2023', y: 100, drilldown: '2023-q' },
{ x: '2024', y: 150, drilldown: '2024-q' },
{ x: '2025', y: 200, drilldown: '2025-q' }
]
}],
chartOptions: {
chart: {
id: 'asyncDrill',
type: 'bar',
height: 400,
toolbar: {
show: false
},
},
plotOptions: {
bar: {
distributed: true,
columnWidth: '50%',
borderRadius: 6,
borderRadiusApplication: 'end',
},
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
drilldown: {
enabled: true,
// Deliberately empty: every level below the root comes from onDrillDown.
series: [],
breadcrumb: {
show: true,
rootLabel: 'All years',
},
loading: {
show: true,
},
cache: true,
},
},
drillTimer: null,
}
},
mounted: function () {
// The vue-apexcharts wrapper owns the render, so reach the live instance by
// its chart.id, then wire the resolver and controls. (BACKEND/fakeFetch/
// setStatus live in the shared head script.)
var me = this
me.drillTimer = window.setInterval(function () {
var chart = ApexCharts.getChartByID('asyncDrill')
if (!chart) return
window.clearInterval(me.drillTimer)
chart.updateOptions({
drilldown: {
onDrillDown: function (ctx) {
setStatus('Fetching ' + ctx.id + '...')
return fakeFetch(ctx.id)
},
},
})
chart.addEventListener('drillDownEnd', function () {
setStatus('')
})
chart.addEventListener('drillDownError', function (info) {
setStatus(String(info.error && info.error.message), true)
})
document.querySelector('#up').addEventListener('click', function () {
chart.drillUp()
})
document.querySelector('#clear-cache').addEventListener('click', function () {
chart.clearDrilldownCache()
setStatus('Cache cleared - the next drill will refetch.')
})
document.querySelector('#fail').addEventListener('change', function (e) {
failNext = e.target.checked
})
}, 50)
},
beforeDestroy: function () {
window.clearInterval(this.drillTimer)
},,
}
</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 24px;
}
.chart-wrap {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
position: relative;
}
.actions {
margin: 20px auto 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
align-items: center;
}
.actions button {
color: #fff;
background: #008ffb;
border: none;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
}
.actions label {
font-size: 13px;
color: #5b6b78;
display: inline-flex;
align-items: center;
gap: 6px;
}
.status {
min-height: 20px;
text-align: center;
font-size: 13px;
color: #5b6b78;
margin-top: 12px;
}
.status.error {
color: #d7263d;
font-weight: 600;
}
</style>