<template>
  <div>
    <div class="sb-wrap">
      <div class="sb-hero">
        <h1>One year, one chart</h1>
        <p>
          A scrollytelling story on a single live chart. Scroll the story and
          the same twelve columns recolor, annotate, and finally reshape;
          scrolling back up rewinds the story.
        </p>
      </div>

      <div class="sb-card">
        <div class="sb-layout">
          <div class="sb-scroller" id="sb-scroller">
            <div class="sb-steps">
              <section class="sb-step" id="sb-step-1">
                <div class="card">
                  <h3>One year of revenue</h3>
                  <p>
                    Fiscal 2025, month by month: 341 k$ across twelve columns. A
                    slow start and a strong finish, with two turning points
                    hiding in plain sight.
                  </p>
                </div>
              </section>
              <section class="sb-step" id="sb-step-2">
                <div class="card">
                  <h3>March: the outage</h3>
                  <p>
                    A bad deploy took sign-ups offline for two days, and March
                    is the only month that broke the climb. The spotlight
                    narrows.
                  </p>
                </div>
              </section>
              <section class="sb-step" id="sb-step-3">
                <div class="card">
                  <h3>May: v2 ships</h3>
                  <p>
                    The hinge of the year. From May on, every month tops the
                    last, and the slope never looks back.
                  </p>
                </div>
              </section>
              <section class="sb-step" id="sb-step-4">
                <div class="card">
                  <h3>The quarters take shape</h3>
                  <p>
                    Same columns, new grouping. Each quarter beats the last by
                    half again or more: 35 k$ in Q1 grows to 157 k$ by Q4.
                  </p>
                </div>
              </section>
              <section class="sb-step" id="sb-step-5">
                <div class="card">
                  <h3>The year in one circle</h3>
                  <p>
                    The twelve months curl into a ring, each column becoming its
                    own slice. The quarter colors survive the change of shape.
                  </p>
                </div>
              </section>
            </div>
          </div>

          <div class="sb-graphic">
            <div id="chart">
              <apexchart
                type="bar"
                height="300"
                :options="chartOptions"
                :series="series"
              ></apexchart>
            </div>

            <div class="sb-head">
              <div>
                <b>FY25 revenue, monthly</b>
                <span class="sb-chip" id="sb-chip">Beat 1 of 5</span>
              </div>
              <div class="sb-nav">
                <button class="sb-btn" id="sb-prev" type="button" disabled>
                  Prev
                </button>
                <button
                  class="sb-dot is-active"
                  aria-label="Go to beat 1"
                ></button>
                <button class="sb-dot" aria-label="Go to beat 2"></button>
                <button class="sb-dot" aria-label="Go to beat 3"></button>
                <button class="sb-dot" aria-label="Go to beat 4"></button>
                <button class="sb-dot" aria-label="Go to beat 5"></button>
                <button class="sb-btn" id="sb-next" type="button">Next</button>
              </div>
            </div>
          </div>
        </div>
      </div>

      <p class="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 class="sb-note">
        <code>chart.storyboard.bind(&#123; beats, scroller &#125;)</code> pairs
        prose elements with views; here <code>scroller</code> points the
        observer at the story column, so beats apply as their step crosses the
        middle of that panel (IntersectionObserver, no scroll listeners) and
        re-apply in reverse on the way up. The design rule that keeps every
        transition smooth: all five beats keep the <b>same twelve marks</b>.
        Emphasis moves by color (spotlights, quarter groups) and annotations,
        never by cropping or re-binning, so nothing pops in or out. The finale's
        <code>options</code> payload swaps <code>chart.type</code>, and the
        <code>morph</code> feature curls each column into its own donut slice, a
        one-to-one morph. The dots and Prev/Next call
        <code>chart.storyboard.goTo(i)</code> and scroll the story to match,
        each beat announces itself to the aria-live region, and
        <code>beatChange</code> drives the step highlight. Transitions are cut
        instead of animated under prefers-reduced-motion. Needs the
        <code>storyboard</code> feature (bundles <code>perspectives</code>).
      </div>
    </div>
  </div>
</template>

<script>
import VueApexCharts from 'vue-apexcharts'
import ApexCharts from 'apexcharts'

// The design rule of this story: every beat keeps the SAME twelve marks.
// Emphasis changes via per-column colors and annotations (values, axes and
// mark count never change), so each transition tweens element-for-element
// instead of popping marks in and out. The finale is the one shape change:
// a 1:1 bar-to-donut morph, each column curling into its own slice.
// These definitions are shared by the vanilla-js, React and Vue builds.
var REV = [12, 14, 9, 15, 18, 22, 26, 31, 37, 44, 52, 61]
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']

var ACCENT = '#0EA5E9'
var FADE = '#e3e8f4' // out-of-spotlight months step back to this
var RED = '#f87171'
var QCOLORS = ['#0EA5E9', '#7dd3fc', '#4ade80', '#fbbf24'] // one per quarter

function colors12(fn) {
  var a = []
  for (var i = 0; i < 12; i++) a.push(fn(i))
  return a
}
var C_ALL = colors12(function () { return ACCENT })
var C_OUTAGE = colors12(function (i) {
  if (i === 2) return RED // March
  return i >= 1 && i <= 3 ? ACCENT : FADE
})
var C_V2 = colors12(function (i) { return i >= 4 ? ACCENT : FADE }) // May on
var C_QUARTERS = colors12(function (i) { return QCOLORS[Math.floor(i / 3)] })

// The shared column payload; a beat only swaps the colors array. labels []
// resets the donut's month labels so they never leak into the axis mapping.
var COLUMN_OPTS = {
  chart: { type: 'bar' },
  series: [{ name: 'Revenue (k$)', data: REV }],
  labels: [],
  stroke: { width: 0, colors: undefined },
  plotOptions: { bar: { columnWidth: '58%', borderRadius: 3, distributed: true } },
  legend: { show: false },
}
function columnBeat(colors) {
  return Object.assign({}, COLUMN_OPTS, { colors: colors })
}

// The finale: same twelve values, same quarter colors, new shape. The morph
// feature pairs the 12 columns with the 12 slices.
var DONUT_OPTS = {
  chart: { type: 'donut' },
  series: REV,
  labels: MONTHS,
  colors: C_QUARTERS,
  stroke: { width: 2, colors: ['#fff'] },
  legend: { show: false },
  plotOptions: {
    pie: {
      expandOnClick: false,
      donut: {
        size: '68%',
        labels: {
          show: true,
          total: {
            show: true,
            label: 'FY25 revenue',
            formatter: function () { return '341 k$' },
          },
        },
      },
    },
  },
}

var BEATS = [
  {
    selector: '#sb-step-1',
    view: { window: { xaxis: null, yaxis: [null] }, theme: { mode: 'light' } },
    options: columnBeat(C_ALL),
    announce: 'Overview: fiscal 2025 revenue, month by month',
  },
  {
    selector: '#sb-step-2',
    view: {
      window: { xaxis: null, yaxis: [null] },
      theme: { mode: 'light' },
      annotations: {
        static: {
          xaxis: [
            {
              x: 'Mar',
              strokeDashArray: 4,
              borderColor: '#f87171',
              label: {
                text: 'The outage',
                borderColor: '#fca5a5',
                style: { color: '#7f1d1d', background: '#fee2e2' },
              },
            },
          ],
        },
      },
    },
    options: columnBeat(C_OUTAGE),
    announce: 'Spotlight on March: the outage month',
  },
  {
    selector: '#sb-step-3',
    view: {
      window: { xaxis: null, yaxis: [null] },
      theme: { mode: 'light' },
      annotations: {
        static: {
          xaxis: [
            {
              x: 'May',
              strokeDashArray: 4,
              borderColor: '#4ade80',
              label: {
                text: 'v2 ships',
                borderColor: '#86efac',
                style: { color: '#14532d', background: '#dcfce7' },
              },
            },
          ],
        },
      },
    },
    options: columnBeat(C_V2),
    announce: 'Spotlight from May on: v2 changes the slope',
  },
  {
    selector: '#sb-step-4',
    view: { window: { xaxis: null, yaxis: [null] }, theme: { mode: 'light' } },
    options: columnBeat(C_QUARTERS),
    announce: 'The months grouped into quarters by color',
  },
  {
    selector: '#sb-step-5',
    view: { window: { xaxis: null, yaxis: [null] }, theme: { mode: 'light' } },
    options: DONUT_OPTS,
    announce: 'The twelve months as a donut ring',
  },
]

export default {
components: {
apexchart: VueApexCharts,
},
data: function () {
return {
series: [
  {
    name: 'Revenue (k$)',
    data: [12, 14, 9, 15, 18, 22, 26, 31, 37, 44, 52, 61]
  }
],
chartOptions: {
chart: {
  id: 'storyChart',
  type: 'bar',
  height: 300,
  fontFamily: 'Helvetica, Arial, sans-serif',
  animations: { speed: 700, dynamicAnimation: { speed: 500 } },
  toolbar: { show: false },
  zoom: { enabled: false },
},
colors: ['#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9', '#0EA5E9'],
plotOptions: { bar: { columnWidth: '58%', borderRadius: 3, distributed: true } },
stroke: { width: 0 },
dataLabels: { enabled: false },
legend: { show: false },
grid: { borderColor: '#eef0f6' },
xaxis: {
  categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
},
},
storyChart: null,
}
},
mounted: function () {
  // The vue-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. (REV, MONTHS, the palettes and BEATS live in the shared head
  // script.) The storyboard drives the chart instance directly, so no reactive
  // data changes and the <apexchart> is never re-rendered under it.
  var me = this
  var timer = window.setInterval(function () {
    var chart = ApexCharts.getChartByID('storyChart')
    if (!chart) return
    window.clearInterval(timer)
    me.storyChart = chart

    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 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)
    })

    // 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)
},
beforeDestroy: function () {
  if (this.storyChart && this.storyChart.storyboard) {
    this.storyChart.storyboard.unbind()
  }
},,
}
</script>

<style>
.sb-wrap {
  max-width: 960px;
  margin: 0 auto;
  padding: 8px;
  font-family: Helvetica, Arial, sans-serif;
  color: #1f2937;
}
.sb-hero {
  padding: 18px 4px 14px;
}
.sb-hero h1 {
  font-size: 22px;
  margin: 0 0 8px;
  letter-spacing: -0.4px;
}
.sb-hero p {
  font-size: 14px;
  line-height: 1.6;
  color: #4b5563;
  margin: 0;
}

/* One self-contained card holding a two-column scrollytelling layout: the
     story scrolls in the left column while the chart stays put on the right.
     The card has a FIXED pixel height (never vh), so the whole sample stays
     bounded and the demo page's auto-resizing iframe cannot feed a
     viewport-relative layout back into a runaway height. The story column is
     its own scroll container (#sb-scroller), so the storyboard observes that
     panel's scroll rather than the page/iframe viewport. */
.sb-card {
  background: #fff;
  border: 1px solid #e4e7f2;
  border-radius: 12px;
  padding: 16px;
  box-shadow: 0 2px 10px rgba(30, 41, 59, 0.06);
  height: 500px;
  box-sizing: border-box;
}
.sb-layout {
  display: grid;
  grid-template-columns: minmax(200px, 240px) 1fr;
  gap: 24px;
  height: 100%;
}

/* LEFT: the story panel. Scrolling it drives the beats; each step reaches
     the panel's middle trigger line as you go. */
.sb-scroller {
  height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-right: 4px;
}
.sb-steps {
  margin: 0;
  /* Top/bottom room so the first and last steps can reach the panel's middle
       trigger line. */
  padding: 100px 6px 190px;
}
.sb-step {
  min-height: 240px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  opacity: 0.32;
  transition: opacity 0.3s ease;
}
.sb-step.is-active {
  opacity: 1;
}
.sb-step .card {
  border-left: 3px solid #c7d2fe;
  background: #fbfcff;
  border-radius: 0 8px 8px 0;
  padding: 14px 16px;
  box-shadow: 0 1px 3px rgba(30, 41, 59, 0.06);
}
.sb-step.is-active .card {
  border-left-color: #0ea5e9;
}
.sb-step h3 {
  margin: 0 0 6px;
  font-size: 15px;
  color: #101828;
}
.sb-step p {
  margin: 0;
  font-size: 13px;
  line-height: 1.6;
  color: #4b5563;
}

/* RIGHT: the chart pins in place while the story scrolls. Vertically
     centered in its column, with the controls right under it, docs-style. */
.sb-graphic {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-width: 0;
}
/* The shared demo stylesheet gives the first chart container (#chart) its
     own white panel; the card provides it here instead. */
#chart {
  padding: 0;
  background: transparent;
  border: 0;
  box-shadow: none;
  width: 100%;
}
.sb-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-top: 12px;
}
.sb-head b {
  font-size: 14px;
  color: #111827;
}
.sb-chip {
  font-family: monospace;
  font-size: 12px;
  color: #4338ca;
  background: #eef2ff;
  border-radius: 999px;
  padding: 3px 10px;
  margin-left: 10px;
  white-space: nowrap;
}
.sb-nav {
  display: flex;
  align-items: center;
  gap: 8px;
}
.sb-btn {
  padding: 4px 12px;
  border-radius: 8px;
  border: 1px solid #d0d5dd;
  background: #fff;
  color: #344054;
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
}
.sb-btn:disabled {
  opacity: 0.45;
  cursor: default;
}
.sb-dot {
  width: 11px;
  height: 11px;
  padding: 0;
  border-radius: 50%;
  border: 1px solid #7dd3fc;
  background: #fff;
  cursor: pointer;
}
.sb-dot.is-active {
  background: #0ea5e9;
  border-color: #0ea5e9;
}
.sb-hint {
  font-size: 13px;
  color: #667085;
  margin: 12px 4px 0;
  line-height: 1.6;
}
.sb-note {
  background: #eef2ff;
  border-left: 3px solid #0ea5e9;
  padding: 12px 16px;
  font-size: 13px;
  color: #234;
  border-radius: 2px;
  line-height: 1.65;
  margin: 20px 4px 30px;
}
.sb-note code {
  background: #dfe3ff;
  padding: 1px 5px;
  border-radius: 3px;
}

/* Narrow containers: stack to one column with the chart on top and the story
     panel below it, back to a fixed-height internal scroller. This sample runs
     inside the demo page's iframe, so the query matches the IFRAME width (not
     the browser viewport): two columns get the ~560px+ they need to breathe,
     and anything narrower (a squeezed content area or a real phone) stacks. */
@media (max-width: 560px) {
  .sb-card {
    height: auto;
  }
  .sb-layout {
    grid-template-columns: 1fr;
    gap: 14px;
  }
  .sb-graphic {
    order: -1;
  }
  .sb-scroller {
    height: 240px;
    border-top: 1px solid #eef0f6;
    padding-top: 4px;
  }
  .sb-steps {
    padding: 60px 6px 90px;
  }
  .sb-step {
    min-height: 180px;
  }
}
</style>
Scrollytelling (Storyboard) - Vue Narrative & State | ApexCharts.js | ApexCharts.js