Marathon Storyboard in JavaScript
Using ApexCharts with JavaScript
This Marathon Storyboard example uses ApexCharts.js directly in JavaScript, with no wrapper component.
Install it with npm install apexcharts, then mount the chart with new ApexCharts(element, options).render().
// One field of 600 marathon finishers, shown three ways on a single unit chart:
// as five regional clusters, as one packed field, then regrouped into six
// finish-time bars. The design rule that makes it a story rather than three
// separate charts: every beat sets plotOptions.unit.transition:'flow', so the
// SAME dots are keyed by global order and GLIDE (and recolour) from one
// arrangement into the next instead of being rebuilt. These definitions are
// shared by the vanilla-js, React and Vue builds.
var REGION_LABELS = ['Africa', 'Europe', 'Americas', 'Asia', 'Oceania']
var REGION_COLORS = ['#F2994A', '#2D9CDB', '#27AE60', '#EB5757', '#8D6E63']
var REGION_COUNT = [100, 170, 150, 140, 40] // 600 finishers
var FINISH_LABELS = [
'Sub-3:00',
'3:00-3:30',
'3:30-4:00',
'4:00-4:30',
'4:30-5:00',
'5:00+',
]
var FINISH_COLORS = [
'#1E8E5A',
'#35B37E',
'#86C82B',
'#F2C037',
'#F2994A',
'#EB5757',
]
var FINISH_COUNT = [45, 120, 165, 140, 85, 45] // also 600
// A bare ViewState shared by every beat: the unit chart has no axes to window,
// so each beat only needs to describe its own data + layout in `options`.
var LIGHT = { theme: { mode: 'light' } }
var BEATS = [
{
selector: '#sb-step-1',
view: LIGHT,
options: {
series: REGION_COUNT,
labels: REGION_LABELS,
colors: REGION_COLORS,
plotOptions: { unit: { layout: 'grouped', transition: 'flow' } },
},
announce: 'The field by region: five clusters of finishers',
},
{
selector: '#sb-step-2',
view: LIGHT,
options: {
series: REGION_COUNT,
labels: REGION_LABELS,
colors: REGION_COLORS,
plotOptions: { unit: { layout: 'packed', transition: 'flow' } },
},
announce:
'One field: the same finishers packed together, smallest region centred',
},
{
selector: '#sb-step-3',
view: LIGHT,
options: {
series: FINISH_COUNT,
labels: FINISH_LABELS,
colors: FINISH_COLORS,
plotOptions: { unit: { layout: 'columns', transition: 'flow' } },
},
announce: 'Regrouped by finish time: the finishers flow into six bars',
},
]
var options = {
series: [100, 170, 150, 140, 40],
chart: {
id: 'marathonStory',
type: 'unit',
height: 420,
fontFamily: 'Helvetica, Arial, sans-serif',
animations: {
enabled: true,
speed: 850,
},
toolbar: { show: false },
},
labels: ['Africa', 'Europe', 'Americas', 'Asia', 'Oceania'],
colors: ['#F2994A', '#2D9CDB', '#27AE60', '#EB5757', '#8D6E63'],
legend: {
position: 'bottom',
},
plotOptions: {
unit: {
layout: 'grouped',
transition: 'flow',
// A fixed dot size keeps every dot the SAME size in all three layouts, so
// dots only move and recolour across the story (they never resize). The
// size is set by the tightest layout: 600 dots must fit the packed field
// and the widest region cluster, so the dots are small on purpose.
size: 2.3,
spacing: 1.15,
},
},
}
var chart = new ApexCharts(document.querySelector('#chart'), options)
chart.render()
// beatChange fires on every activation (scroll or goTo): drive the page UI.
// (The labels, colors, counts and BEATS live in the shared head script.)
var steps = document.querySelectorAll('.sb-step')
var dots = document.querySelectorAll('.sb-dot')
var chip = document.getElementById('sb-chip')
var prevBtn = document.getElementById('sb-prev')
var nextBtn = document.getElementById('sb-next')
var scroller = document.getElementById('sb-scroller')
var current = 0
// Center a beat's step inside the story column WITHOUT scrolling the page:
// adjust only the panel's own scrollTop. The IntersectionObserver then
// activates that beat, so the manual controls and the scroll stay in sync.
function scrollToBeat(i) {
var step = document.getElementById('sb-step-' + (i + 1))
if (!step || !scroller) return
var sRect = step.getBoundingClientRect()
var cRect = scroller.getBoundingClientRect()
scroller.scrollTop +=
sRect.top - cRect.top - (scroller.clientHeight - step.clientHeight) / 2
}
function goToBeat(i) {
i = Math.max(0, Math.min(BEATS.length - 1, i))
chart.storyboard.goTo(i)
scrollToBeat(i)
}
chart.addEventListener('beatChange', function (c, info) {
current = info.index
steps.forEach(function (el, i) {
el.classList.toggle('is-active', i === info.index)
})
dots.forEach(function (dot, i) {
dot.classList.toggle('is-active', i === info.index)
})
chip.textContent = 'Beat ' + (info.index + 1) + ' of ' + BEATS.length
prevBtn.disabled = info.index === 0
nextBtn.disabled = info.index === BEATS.length - 1
})
dots.forEach(function (dot, i) {
dot.addEventListener('click', function () {
goToBeat(i)
})
})
prevBtn.addEventListener('click', function () {
goToBeat(current - 1)
})
nextBtn.addEventListener('click', function () {
goToBeat(current + 1)
})
window.addEventListener('load', function () {
// scroller binds the observer to the story column, so the story is driven by
// that panel's own scroll (not the page/iframe viewport).
chart.storyboard.bind({ beats: BEATS, scroller: '#sb-scroller' })
})