<template>
<div>
<div class="wrap">
<h1>What is actually inside a bar?</h1>
<p>
A histogram bar is a count, and a count is a thing that has already
thrown away the rows it counted. Except here it has not: the series was
handed the raw delivery times and the chart did the binning itself, so
it can still name the observations behind every bar. Press the button
and each bar comes apart into exactly the deliveries it was standing
for, every dot leaving from the slice of the bar that was counting it.
</p>
<div class="actions">
<button data-explode="false" class="on">Histogram</button>
<button data-explode="true">Show every delivery</button>
</div>
<div class="chart-wrap">
<div id="chart">
<apexchart
type="histogram"
height="420"
:options="chartOptions"
:series="series"
></apexchart>
</div>
</div>
<div class="readout" id="readout"></div>
<div class="note">
The exploded view is built by <code>chart.rowSeries()</code>, which
returns one cluster per bar holding that bar's own observations. No data
is passed back in: a histogram is one of the few marks that can name its
rows, because it was given them in the first place. Requires
<code>apexcharts/features/stats</code> for the rows and
<code>apexcharts/features/morph</code> for the transition, both of which
the full bundle already includes.
</div>
</div>
</div>
</template>
<script>
import VueApexCharts from 'vue-apexcharts'
import ApexCharts from 'apexcharts'
// Shared by the vanilla-js, React and Vue builds.
//
// 640 delivery times, seeded so the page is deterministic. The series carries
// the RAW OBSERVATIONS: the chart does the binning, which is what lets it hand
// them back again later.
var DELIVERIES = (function () {
var seed = 20260814
function rand() {
seed = (seed * 16807) % 2147483647
return (seed - 1) / 2147483646
}
var out = []
for (var i = 0; i < 640; i++) {
var u1 = Math.max(rand(), 1e-9)
var u2 = rand()
var z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
// Log-normal: a delivery can run very late but never finish early.
out.push(Math.round(Math.exp(3.15 + z * 0.42)))
}
return out
})()
// The histogram's own series, kept so the chart can be put back together.
var HISTOGRAM_SERIES = [{ name: 'Deliveries', data: DELIVERIES }]
// Quick (blue) to punctual (grey-green) to very late (amber, then red).
var RAMP = ['#4e8cff', '#59c2b0', '#e8c14a', '#ef7d3a', '#d94040']
function rampColor(t) {
var scaled = Math.max(0, Math.min(1, t)) * (RAMP.length - 1)
var i = Math.min(RAMP.length - 2, Math.floor(scaled))
var f = scaled - i
var a = RAMP[i]
var b = RAMP[i + 1]
var mix = function (o) {
var av = parseInt(a.substr(o, 2), 16)
var bv = parseInt(b.substr(o, 2), 16)
var v = Math.round(av + (bv - av) * f)
return (v < 16 ? '0' : '') + v.toString(16)
}
return '#' + mix(1) + mix(3) + mix(5)
}
function setReadout(text) {
var el = document.querySelector('#readout')
if (el) el.innerHTML = text
}
function setActive(exploded) {
var buttons = [].slice.call(document.querySelectorAll('[data-explode]'))
buttons.forEach(function (b) {
b.className = (b.getAttribute('data-explode') === 'true') === exploded ? 'on' : ''
})
}
// Wires the two buttons to a live chart. Shared by all three builds.
function wireExplode(chart) {
var buttons = [].slice.call(document.querySelectorAll('[data-explode]'))
buttons.forEach(function (b) {
b.addEventListener('click', function () {
// The active view's button is a no-op: re-requesting the readings while
// already exploded would ask rowSeries() of a unit chart, which has no
// rows to hand back.
if (b.className === 'on') return
var explode = b.getAttribute('data-explode') === 'true'
setActive(explode)
if (explode) {
// The whole point: nothing about the sample is passed in here. The
// chart already knows which observations it counted into each bar, so
// rowSeries() returns one cluster per bar holding exactly those rows.
var rows = chart.rowSeries()
var total = rows.reduce(function (n, c) {
return n + c.data.length
}, 0)
// What comes back is ordinary series data, so it can be decorated.
// Colouring each bar's dots by how late that bar was keeps the
// distribution readable once the bars are gone: without this the blob
// is 640 identical dots and you cannot tell a quick delivery from a
// disastrous one.
rows.forEach(function (cluster, k) {
var color = rampColor(rows.length > 1 ? k / (rows.length - 1) : 0)
cluster.data.forEach(function (d) {
d.fillColor = color
})
})
chart.updateOptions({
chart: { type: 'unit' },
series: rows,
plotOptions: {
unit: {
// NOT 'columns'. That layout would stack each bin's dots back
// into a column the same height and place as the bar they left,
// so the objects would travel a few pixels and the whole thing
// would look like a redraw. 'packed' gathers them into one blob,
// which is a real journey.
layout: 'packed',
unitValue: 1,
size: 3,
},
},
legend: { show: false },
})
setReadout(
'One dot per delivery: <b>' + total + '</b> of them, ' +
'gathered out of <b>' + rows.length + '</b> bars.',
)
} else {
chart.updateOptions({
chart: { type: 'histogram' },
series: HISTOGRAM_SERIES,
legend: { show: false },
})
setReadout(
'Every bar is a count of the deliveries that landed in its range.',
)
}
})
})
setActive(false)
setReadout('Every bar is a count of the deliveries that landed in its range.')
}
export default {
components: {
apexchart: VueApexCharts,
},
data: function () {
return {
series: HISTOGRAM_SERIES,
chartOptions: {
chart: {
id: 'explodeHist',
type: 'histogram',
height: 420,
toolbar: {
show: false
},
animations: {
chartTypeMorph: {
speed: 900
}
},
},
plotOptions: {
histogram: {
bins: 22,
},
},
colors: ['#4e8cff'],
legend: {
show: false,
},
xaxis: {
title: {
text: 'Delivery time (minutes)'
},
labels: {
formatter: function (val) {
return Math.round(val)
}
}
},
yaxis: {
title: {
text: 'Deliveries'
},
},
},
explodeTimer: null,
}
},
mounted: function () {
// The vue-apexcharts wrapper owns the render, so reach the live instance by
// its chart.id before wiring the controls.
var me = this
me.explodeTimer = window.setInterval(function () {
var chart = ApexCharts.getChartByID('explodeHist')
if (!chart) return
window.clearInterval(me.explodeTimer)
wireExplode(chart)
}, 50)
},
beforeDestroy: function () {
window.clearInterval(this.explodeTimer)
},,
}
</script>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafbfc;
color: #2c3e50;
margin: 0;
padding: 32px 16px;
}
.wrap {
max-width: 820px;
margin: 0 auto;
}
h1 {
font-size: 22px;
margin: 0 0 8px;
}
p {
color: #5b6b78;
line-height: 1.55;
margin: 0 0 24px;
}
.chart-wrap {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
}
.actions {
margin: 20px auto 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
align-items: center;
}
.actions button {
color: #5b6b78;
background: #fff;
border: 1px solid #dfe6ec;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
}
.actions button.on {
color: #fff;
background: #4e8cff;
border-color: #4e8cff;
}
.readout {
text-align: center;
font-size: 13px;
color: #5b6b78;
margin-top: 14px;
min-height: 20px;
}
.readout b {
color: #2c3e50;
font-variant-numeric: tabular-nums;
}
.note {
margin-top: 22px;
font-size: 13px;
line-height: 1.6;
color: #5b6b78;
background: #fff;
border-left: 3px solid #4e8cff;
border-radius: 0 6px 6px 0;
padding: 12px 16px;
}
code {
background: #eef2f7;
border-radius: 3px;
padding: 1px 5px;
font-size: 12px;
}
</style>