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