Brand Logo Story in JavaScript

Using ApexCharts with JavaScript

This Brand Logo Story 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().

JavaScript installation guide
// A year at Halo, a (fictional) coffee roaster: 2,400 kilos roasted, one dot
// per kilo, told in five beats on ONE unit chart. Halo's logo is not in the
// shape catalog and never will be, because it is Halo's. It does not need to
// be: `shapeFrom` takes an outline and packs it with the same engine the
// built-in shapes use, so any company's mark is a layout the moment someone
// pastes its path data. These definitions are shared by the vanilla-js, React
// and Vue builds.

// The mark: a cup on a saucer under two rising wisps, drawn as eight subpaths
// in a 0-100 box.
//
// This is the DEFAULT nonzero fill rule doing the two things that make a mark
// like this authorable in pieces rather than as one traced contour:
//
//   1. Overlapping subpaths UNION. The cup, its elliptical rim, its foot, the
//      saucer and the handle are five separate shapes that simply overlap, and
//      they merge into one silhouette. Nothing has to be mitred or joined by
//      hand, which is why a logo can be assembled part by part.
//   2. A subpath wound the OTHER WAY cuts a hole. The handle is a solid loop
//      with the finger hole punched back out of it, so the hole is not a
//      separate idea from the fill: it is the same ellipse, wound backwards.
//
// The winding is the thing to get right and the thing nothing in the path data
// reveals. Here every union subpath is written clockwise on screen, and the one
// hole runs anticlockwise (as an arc, `sweep 1` is the positive direction and
// `sweep 0` the negative one). A subpath wound backwards by accident still
// renders, so it looks deliberate: a cup with its body bored through.
var HALO_PATH =
  // the cup: straight tapering sides down to a rounded bottom
  'M 72 42 L 62 72 C 62 76, 55 77, 46 77 C 37 77, 30 76, 30 72 L 20 42 Z ' +
  // the rim, an ellipse overlapping the cup's flat top so it reads as a vessel
  // seen slightly from above
  'M 20 42 A 26 7 0 0 1 72 42 A 26 7 0 0 1 20 42 Z ' +
  // the foot
  'M 38 75 L 54 75 L 57 81.5 L 35 81.5 Z ' +
  // the saucer
  'M 4 86.5 A 42 6.5 0 0 1 88 86.5 A 42 6.5 0 0 1 4 86.5 Z ' +
  // the handle: a solid loop overlapping the cup's right wall...
  'M 64 58 A 14 11 0 0 1 92 58 A 14 11 0 0 1 64 58 Z ' +
  // ...and the finger hole, the SAME kind of ellipse wound the other way
  // (`sweep 0`), which is what turns it from a blob into a handle
  'M 74 58 A 7 5 0 0 0 88 58 A 7 5 0 0 0 74 58 Z ' +
  // two wisps of steam, each tapering to a point at both ends
  'M 48 37 C 36 27, 56 19, 47 5 C 50 4, 52 5, 54 7 C 60 19, 44 26, 54 37 ' +
  'C 52 38, 50 38, 48 37 Z ' +
  'M 30 36 C 21 28, 35 21, 28 9 C 30 8, 32 9, 34 11 C 39 21, 28 27, 36 36 ' +
  'C 34 37, 32 37, 30 36 Z'

var halo = ApexUnitShapes.shapeFrom(HALO_PATH, {
  name: 'halo',
  // Measured, not guessed: at 600 dots the steam has thinned to a single file
  // and the finger hole is one dot from closing, which is the floor. Ask for
  // fewer and the chart warns in the console rather than drawing mush. Every
  // logo has a number like this; finding yours is the one piece of homework a
  // custom shape asks for.
  minUnits: 600,
})

// The same outline, filled from the middle out instead of top down. Nothing is
// re-authored: `.with()` returns a variant and leaves the original alone.
var haloCore = halo.with({ order: 'centerOut' })

// The dots spelling out how many of them there are. `glyphs` builds a
// seven-segment centreline from the text and thickens it, so this is a stroke
// shape and needs no outline at all.
var total = ApexUnitShapes.glyphs('2,400')

var LINES = ['Espresso', 'Filter', 'Decaf', 'Cold brew']
var KILOS = [980, 720, 300, 400] // 2,400
var ROAST = ['#5C3A21', '#C77B30', '#6F9B4A', '#2E8B9A']

// A bare ViewState shared by every beat: the unit chart has no axes to window,
// so each beat only needs to describe its own layout in `options`. The series,
// labels and colours never change across this story: one crowd, five
// arrangements.
var LIGHT = { theme: { mode: 'light' } }

function beat(unit) {
  return {
    series: KILOS,
    labels: LINES,
    colors: ROAST,
    // 'flow' keys the dots by global order, so the SAME dot glides from one
    // arrangement into the next instead of being rebuilt. It is the whole
    // reason this reads as one crowd moving rather than five charts.
    plotOptions: { unit: Object.assign({ transition: 'flow' }, unit) },
  }
}

var BEATS = [
  {
    selector: '#lg-step-1',
    view: LIGHT,
    options: beat({ layout: 'packed' }),
    announce: 'A year of roasting: 2,400 kilos in one packed crowd',
  },
  {
    selector: '#lg-step-2',
    view: LIGHT,
    options: beat({ layout: 'custom', positions: halo }),
    announce:
      'The same 2,400 kilos, gathered into the company mark: a cup of coffee',
  },
  {
    selector: '#lg-step-3',
    view: LIGHT,
    options: beat({ layout: 'custom', positions: haloCore }),
    announce: 'Refilled from the centre out: espresso fills the cup',
  },
  {
    selector: '#lg-step-4',
    view: LIGHT,
    options: beat({ layout: 'columns' }),
    announce: 'The same kilos as four plain columns, in true proportion',
  },
  {
    selector: '#lg-step-5',
    view: LIGHT,
    options: beat({ layout: 'custom', positions: total }),
    announce: 'The dots spell out their own total: 2,400',
  },
]

var options = {
  series: [980, 720, 300, 400],
  chart: {
    id: 'haloStory',
    type: 'unit',
    height: 420,
    fontFamily: 'Helvetica, Arial, sans-serif',
    animations: {
      enabled: true,
      speed: 850,
    },
    toolbar: { show: false },
  },
  labels: ['Espresso', 'Filter', 'Decaf', 'Cold brew'],
  colors: ['#5C3A21', '#C77B30', '#6F9B4A', '#2E8B9A'],
  legend: {
    position: 'bottom',
  },
  plotOptions: {
    unit: {
      layout: 'packed',
      transition: 'flow',
      // A fixed dot size keeps every dot the SAME size in all five layouts, so
      // dots only move across the story and never resize.
      //
      // Which beat sets the number is worth knowing, because it is not the one it
      // looks like. A silhouette fits the dot gap to its OWN area and then clamps
      // the radius to that gap, so the cup ignores anything above ~1.7 and beats
      // 2, 3 and 5 render identically at 1.7 or at 3. The binding constraint is
      // beat 1: the packed blob grows with the size it is given, and at 2 it
      // reaches 17px past the top of the legend.
      size: 1.7,
      spacing: 1.05,
      clusterLabels: {
        show: false,
      },
    },
  },
}

var chart = new ApexCharts(document.querySelector('#chart'), options)
chart.render()

// beatChange fires on every activation (scroll or goTo): drive the page UI.
// (The shape, the beats and the data live in the shared head script.)
var steps = document.querySelectorAll('.lg-step')
var dots = document.querySelectorAll('.lg-dot')
var chip = document.getElementById('lg-chip')
var prevBtn = document.getElementById('lg-prev')
var nextBtn = document.getElementById('lg-next')
var scroller = document.getElementById('lg-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('lg-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: '#lg-scroller' })
})