Violin with Jitter in JavaScript

Using ApexCharts with JavaScript

This Violin with Jitter 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
// ── Reproducible synthetic data (seeded PRNG, no network) ──────────────
function mulberry32(a) {
  return function () {
    a |= 0
    a = (a + 0x6d2b79f5) | 0
    var t = Math.imul(a ^ (a >>> 15), 1 | a)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}
var rng = mulberry32(20240607)

function gauss(mean, sd) {
  var u = 0,
    v = 0
  while (u === 0) u = rng()
  while (v === 0) v = rng()
  return mean + sd * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v)
}

// Draw n samples from a mixture of normal components — the mixture is what
// gives each violin its characteristic multi-modal (lumpy) shape.
function mixture(n, comps) {
  var out = []
  for (var i = 0; i < n; i++) {
    var r = rng(),
      acc = 0,
      c = comps[0]
    for (var k = 0; k < comps.length; k++) {
      acc += comps[k].w
      if (r <= acc) {
        c = comps[k]
        break
      }
    }
    out.push(Math.max(5, Math.round(gauss(c.mean, c.sd) * 10) / 10))
  }
  return out
}

// Daily commute time (minutes) — each city mixes a couple of "transport
// mode" clusters (e.g. metro vs. road) for a realistic bimodal spread.
var CITIES = [
  {
    name: 'Tokyo',
    data: mixture(260, [
      { w: 0.62, mean: 42, sd: 5 },
      { w: 0.38, mean: 58, sd: 5 },
    ]),
  },
  {
    name: 'London',
    data: mixture(260, [
      { w: 0.7, mean: 48, sd: 7 },
      { w: 0.3, mean: 74, sd: 6 },
    ]),
  },
  {
    name: 'New York',
    data: mixture(260, [{ w: 1, mean: 50, sd: 11 }]),
  },
  {
    name: 'Berlin',
    data: mixture(260, [
      { w: 0.8, mean: 37, sd: 5 },
      { w: 0.2, mean: 54, sd: 5 },
    ]),
  },
]

// ── Gaussian KDE (bandwidth controls smoothness) ───────────────────────
function kde(values, bw) {
  var min = Math.min.apply(null, values)
  var max = Math.max.apply(null, values)
  var N = values.length
  var profile = []
  // Keep the tail short (1 bandwidth) so the violin tapers cleanly without
  // a long near-zero spike that would drag the axis toward 0.
  for (var x = min - bw; x <= max + bw; x += 1) {
    var sum = 0
    for (var k = 0; k < N; k++) {
      var d = (x - values[k]) / bw
      sum += Math.exp(-0.5 * d * d)
    }
    profile.push([x, sum / (N * bw)])
  }
  return profile
}

function quantile(sorted, q) {
  var pos = (sorted.length - 1) * q
  var b = Math.floor(pos)
  var r = pos - b
  return sorted[b + 1] !== undefined
    ? sorted[b] + r * (sorted[b + 1] - sorted[b])
    : sorted[b]
}
function fiveNum(values) {
  var s = values.slice().sort(function (a, b) {
    return a - b
  })
  return {
    min: s[0],
    q1: quantile(s, 0.25),
    median: quantile(s, 0.5),
    q3: quantile(s, 0.75),
    max: s[s.length - 1],
  }
}

var BW = 2.5
var LABELS = CITIES.map(function (c) {
  return c.name
})
var COLORS = ['#14b8a6', '#3B82F6', '#f59e0b', '#ef4444'] // teal, blue, amber, red
var stats = CITIES.map(function (c) {
  return fiveNum(c.data)
})

var options = {
  series: [
    {
      name: 'Commute',
      data: CITIES.map(function (c) {
        return {
          x: c.name,
          y: { density: kde(c.data, BW), points: c.data },
        }
      }),
    },
  ],
  chart: {
    type: 'violin',
    height: 560,
  },
  title: {
    text: 'Daily commute time by city',
    align: 'center',
  },
  colors: COLORS,
  plotOptions: {
    bar: { distributed: true }, // one colour per city
    violin: {
      normalize: 'group', // widths track density across cities
      // dots in a darker shade of each violin's colour, with a white outline
      points: { show: true, size: 3, jitter: 0.6 },
    },
  },
  stroke: { width: 1, colors: ['#888'] },
  legend: { show: false },
  yaxis: {
    labels: {
      formatter: function (v) {
        return Math.round(v) + ' min'
      },
    },
  },
  tooltip: {
    shared: false,
    custom: function (o) {
      var st = stats[o.dataPointIndex]
      if (!st) return ''
      var row = function (label, val) {
        return (
          '<tr><td style="padding:2px 8px">' +
          label +
          ':</td><td style="padding:2px 8px"><b>' +
          val.toFixed(1) +
          ' min</b></td></tr>'
        )
      }
      return (
        '<div class="apexcharts-tooltip-box" style="padding:6px">' +
        '<div style="font-weight:600;margin-bottom:2px">' +
        LABELS[o.dataPointIndex] +
        '</div><table>' +
        row('Max', st.max) +
        row('Q3', st.q3) +
        row('Median', st.median) +
        row('Q1', st.q1) +
        row('Min', st.min) +
        '</table></div>'
      )
    },
  },
}

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