import React from 'react'
import ReactApexChart from 'react-apexcharts'
import ApexCharts from 'apexcharts'
import './styles.css'
// 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', '#9B51E0']
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',
},
]
const ApexChart = () => {
const [state, setState] = React.useState({
series: [100, 170, 150, 140, 40],
options: {
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', '#9B51E0'],
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,
},
},
},
})
React.useEffect(() => {
// The react-apexcharts wrapper owns the render, so reach the live instance by
// its chart.id. Poll until it exists, then wire the same story the vanilla
// build does. (The labels, colors, counts and BEATS live in the shared head
// script.) The storyboard drives the chart instance directly, so no React
// state changes and the <ReactApexChart> is never re-rendered under it.
let chart
const timer = window.setInterval(() => {
chart = ApexCharts.getChartByID('marathonStory')
if (!chart) return
window.clearInterval(timer)
const steps = document.querySelectorAll('.sb-step')
const dots = document.querySelectorAll('.sb-dot')
const chip = document.getElementById('sb-chip')
const prevBtn = document.getElementById('sb-prev')
const nextBtn = document.getElementById('sb-next')
const scroller = document.getElementById('sb-scroller')
let 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 controls and the scroll stay in sync.
const scrollToBeat = (i) => {
const step = document.getElementById('sb-step-' + (i + 1))
if (!step || !scroller) return
const sRect = step.getBoundingClientRect()
const cRect = scroller.getBoundingClientRect()
scroller.scrollTop +=
sRect.top -
cRect.top -
(scroller.clientHeight - step.clientHeight) / 2
}
const goToBeat = (i) => {
i = Math.max(0, Math.min(BEATS.length - 1, i))
chart.storyboard.goTo(i)
scrollToBeat(i)
}
chart.addEventListener('beatChange', (c, info) => {
current = info.index
steps.forEach((el, i) =>
el.classList.toggle('is-active', i === info.index),
)
dots.forEach((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((dot, i) => {
dot.addEventListener('click', () => goToBeat(i))
})
prevBtn.addEventListener('click', () => goToBeat(current - 1))
nextBtn.addEventListener('click', () => goToBeat(current + 1))
// 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' })
}, 50)
return () => {
window.clearInterval(timer)
if (chart && chart.storyboard) chart.storyboard.unbind()
}
}, [])
return (
<div>
<div className="sb-wrap">
<div className="sb-hero">
<h1>600 finishers, one chart</h1>
<p>
A scroll-driven story on a single unit chart. The same dots start as
five regional clusters, gather into one field, then flow apart into
six finish-time bars. Scroll back up to rewind. Not one dot is
destroyed or created; they only rearrange.
</p>
</div>
<div className="sb-card">
<div className="sb-layout">
<div className="sb-scroller" id="sb-scroller">
<div className="sb-steps">
<section className="sb-step" id="sb-step-1">
<div className="card">
<h3>Five regions</h3>
<p>
The 600 finishers split into five clusters by home region:
Africa, Europe, the Americas, Asia and Oceania. One dot is
one runner.
</p>
</div>
</section>
<section className="sb-step" id="sb-step-2">
<div className="card">
<h3>One field</h3>
<p>
The five clusters gather into a single packed field. The
small Oceania group nests in the centre, the larger
regions wrap around it.
</p>
</div>
</section>
<section className="sb-step" id="sb-step-3">
<div className="card">
<h3>By finish time</h3>
<p>
The same finishers flow apart into six bars by finishing
time, each dot travelling from its old spot and
recolouring on the way. The four-hour pack towers over the
fast and the slow.
</p>
</div>
</section>
</div>
</div>
<div className="sb-graphic">
<div id="chart">
<ReactApexChart
options={state.options}
series={state.series}
type="unit"
height={420}
/>
</div>
<div className="sb-head">
<div>
<b>Marathon field</b>
<span className="sb-chip" id="sb-chip">
Beat 1 of 3
</span>
</div>
<div className="sb-nav">
<button
className="sb-btn"
id="sb-prev"
type="button"
disabled
>
Prev
</button>
<button
className="sb-dot is-active"
aria-label="Go to beat 1"
></button>
<button className="sb-dot" aria-label="Go to beat 2"></button>
<button className="sb-dot" aria-label="Go to beat 3"></button>
<button className="sb-btn" id="sb-next" type="button">
Next
</button>
</div>
</div>
</div>
</div>
</div>
<p className="sb-hint">
Scroll the story, or use the beat dots and Prev/Next. Each beat is a
saved view; scrolling back up reverses it.
</p>
<div className="sb-note">
<code>chart.storyboard.bind({ beats, scroller })</code>{' '}
pairs prose elements with views; here <code>scroller</code> points the
observer at the story column, so a beat applies as its step crosses
the middle of that panel (IntersectionObserver, no scroll listeners)
and re-applies in reverse on the way up. Every beat's{' '}
<code>options</code> payload sets
<code>plotOptions.unit.transition: 'flow'</code>, which keys the dots
by global order: the SAME dot migrates (and recolours) from a regional
cluster to the packed field to a finish-time bar rather than fading
out and back in. Transitions are cut instead of animated under
prefers-reduced-motion. The unit chart is a premium type; without a
license it renders with a trial watermark. Needs the{' '}
<code>storyboard</code> feature (bundles
<code>perspectives</code>).
</div>
</div>
</div>
)
}
export default ApexChart