Violin to Jitter Morph in JavaScript
Using ApexCharts with JavaScript
This Violin to Jitter Morph 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().
// Shared by the vanilla-js, React and Vue builds.
//
// Daily practice minutes for three plans, generated from a seeded RNG so the
// page renders identically on every load. Each lane hides something a violin
// cannot show: Free has a hard 30-minute cap (a wall of readings the estimate
// smooths into a bulge), Pro is the healthy case the violin was made for, and
// Trial has so few readings that its confident-looking curve stands on
// almost nothing.
var seed = 11
function rand() {
seed = (seed * 16807) % 2147483647
return (seed - 1) / 2147483646
}
function gauss() {
var u1 = Math.max(rand(), 1e-9)
var u2 = rand()
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
}
function logNormal(n, median, sigma, lo, hi) {
var out = []
for (var i = 0; i < n; i++) {
var v = Math.exp(Math.log(median) + sigma * gauss())
out.push(Math.round(Math.min(hi, Math.max(lo, v))))
}
return out
}
var PLANS = [
// The cap: anything the distribution puts past 30 lands ON 30 exactly.
{ name: 'Free', color: '#12b3a8', values: logNormal(150, 24, 0.5, 3, 30) },
{ name: 'Pro', color: '#5a67d8', values: logNormal(170, 34, 0.45, 6, 105) },
{ name: 'Trial', color: '#e8890c', values: logNormal(14, 26, 0.55, 4, 95) },
]
var COLORS = PLANS.map(function (p) {
return p.color
})
var VIOLIN_SERIES = [
{
name: 'Minutes',
data: PLANS.map(function (p) {
// Raw observations only: the library runs the density estimate.
return { x: p.name, points: p.values }
}),
},
]
function setActive(exploded) {
var buttons = [].slice.call(document.querySelectorAll('[data-explode]'))
buttons.forEach(function (b) {
b.className =
(b.getAttribute('data-explode') === 'true') === exploded ? 'on' : ''
})
}
function median(values) {
var s = values.slice().sort(function (a, b) {
return a - b
})
var m = (s.length - 1) / 2
return (s[Math.floor(m)] + s[Math.ceil(m)]) / 2
}
function renderSummary() {
var el = document.querySelector('#summary')
if (!el) return
var rows = PLANS.map(function (p) {
var pinned = p.values.filter(function (v) {
return v === 30
}).length
return (
'<tr><td>' +
p.name +
'</td>' +
'<td>' +
p.values.length +
'</td>' +
'<td>' +
median(p.values) +
' min</td>' +
'<td>' +
(p.name === 'Free' ? pinned : '-') +
'</td></tr>'
)
})
el.innerHTML =
'<table><thead><tr><th>Plan</th><th>Readings</th><th>Median</th>' +
'<th>Pinned at the 30 min cap</th></tr></thead><tbody>' +
rows.join('') +
'</tbody></table>'
}
// 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 violins were estimated from the observations, so the chart can
// hand each violin's own readings back: every dot leaves from the
// curve it was smoothed into.
var rows = chart.rowSeries()
// rowSeries() colours by series, and this violin is ONE series split
// across three lanes (distributed). Re-key the colour by lane so each
// violin's ink keeps its own colour on the way out.
rows.forEach(function (cluster, k) {
cluster.data.forEach(function (d) {
d.fillColor = COLORS[k]
})
})
chart.updateOptions({
chart: { type: 'unit' },
series: rows,
plotOptions: {
unit: {
layout: 'scatter',
unitValue: 1,
size: 3.5,
scatter: {
// Value stays on Y, one lane per plan across X, matching the
// violins. The value-axis keys keep their x* names in either
// orientation.
orientation: 'vertical',
spread: 'jitter',
xTitle: 'Minutes per day',
// The same 0..120 window the violin state pins its yaxis to;
// 7 ticks puts a line every 20 minutes, matching its grid.
xMin: 0,
xMax: 120,
tickAmount: 7,
},
},
},
legend: { show: false },
})
} else {
chart.updateOptions({
chart: { type: 'violin' },
series: VIOLIN_SERIES,
legend: { show: false },
})
}
})
})
setActive(false)
renderSummary()
}
var options = {
series: VIOLIN_SERIES,
chart: {
id: 'violinJitter',
type: 'violin',
height: 430,
toolbar: {
show: false,
},
animations: {
chartTypeMorph: {
speed: 900,
},
},
},
colors: COLORS,
plotOptions: {
bar: { distributed: true }, // one colour per plan
violin: {
normalize: 'group',
// The toggle is the reveal here; the built-in overlay would spoil it.
points: { show: false },
},
},
stroke: {
width: 1,
colors: ['#8a97a3'],
},
legend: {
show: false,
},
yaxis: {
// Same domain and ticks as the jitter view, so the two states share one
// grid and the morph never re-scales the room. Minutes cannot be negative,
// which the auto-domain's padding would otherwise imply.
min: 0,
max: 120,
tickAmount: 6,
labels: {
formatter: function (v) {
return Math.round(v) + ' min'
},
},
},
}
var chart = new ApexCharts(document.querySelector('#chart'), options)
chart.render()
// PLANS, VIOLIN_SERIES and wireExplode live in the shared head script.
wireExplode(chart)