Violin plot

바이올린 플롯

Replaces a box plot’s five-number summary with a mirrored kernel density curve, so the distribution’s actual shape shows through instead of being compressed away.

Also known as: Kernel density plot (mirrored)
···
html
<div class="viz">
  <svg id="svg"></svg>
</div>
css
:root{--viz-4:#e8a23a;--viz-5:#4098d7}
body{display:block}
.viz{position:relative;width:100%;height:100%}
svg{display:block;width:100%;height:100%;overflow:visible}
js
var svg = document.getElementById('svg');
var NS = 'http://www.w3.org/2000/svg';
function se(t){ return document.createElementNS(NS,t); }
function sa(e,o){ for (var k in o) e.setAttribute(k,o[k]); }
function R(a,b){ return a + Math.random()*(b-a); }

var CATS = [
  { name:'iOS', color:'var(--accent)' },
  { name:'Android', color:'var(--viz-4)' },
  { name:'Web', color:'var(--accent-3)' },
  { name:'Desktop', color:'var(--viz-5)' }
];
var NSAMP=50, MGRID=32;
var grid=[]; for (var g=0; g<=MGRID; g++) grid.push(g*100/MGRID);
var cur = CATS.map(function(){ return { density: grid.map(function(){ return 0.1; }), median:50 }; });
var W=300, H=200;

function measure(){
  var r = svg.getBoundingClientRect();
  W = Math.max(r.width,10); H = Math.max(r.height,10);
  sa(svg,{ viewBox: '0 0 ' + W + ' ' + H });
}

function samples(mean,spread){
  var out=[];
  for (var i=0;i<NSAMP;i++){
    var s=0; for (var k=0;k<4;k++) s+=Math.random();
    out.push(Math.max(1,Math.min(99, mean+(s/4-0.5)*2*spread)));
  }
  return out;
}

function kde(xs, g, h){
  return g.map(function(gv){
    var s=0;
    xs.forEach(function(x){ var u=(gv-x)/h; s += Math.exp(-0.5*u*u); });
    return s/(xs.length*h*Math.sqrt(2*Math.PI));
  });
}

function regen(){
  return CATS.map(function(){
    var pm = { mean:R(30,72), spread:R(9,22) };
    var xs = samples(pm.mean,pm.spread);
    var h = Math.max(4, pm.spread*0.5);
    var d = kde(xs, grid, h);
    var maxd = Math.max.apply(null, d.concat([0.001]));
    var norm = d.map(function(v){ return v/maxd; });
    xs.sort(function(a,b){ return a-b; });
    var median = xs[Math.floor(xs.length/2)];
    return { density:norm, median:median };
  });
}

function draw(){
  svg.innerHTML = '';
  var padL=14,padR=14,padT=10,padB=18;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n=CATS.length;
  var colW = pw/n;
  var halfMax = colW*0.36;
  var fs = Math.max(8,Math.min(10,W/48));
  CATS.forEach(function(cat,ci){
    var cx = padL+colW*(ci+0.5);
    var dens = cur[ci].density;
    var leftPts=[], rightPts=[];
    dens.forEach(function(v,gi){
      var y = padT+ph-(grid[gi]/100)*ph;
      leftPts.push({ x:cx-v*halfMax, y:y });
      rightPts.push({ x:cx+v*halfMax, y:y });
    });
    var d = 'M'+leftPts[0].x.toFixed(1)+','+leftPts[0].y.toFixed(1);
    leftPts.forEach(function(p){ d += ' L'+p.x.toFixed(1)+','+p.y.toFixed(1); });
    for (var k=rightPts.length-1;k>=0;k--){ d += ' L'+rightPts[k].x.toFixed(1)+','+rightPts[k].y.toFixed(1); }
    d += ' Z';
    var shape = se('path');
    sa(shape,{ d:d, fill:cat.color, 'fill-opacity':0.5, stroke:cat.color, 'stroke-width':1.4 });
    svg.appendChild(shape);
    var my = padT+ph-(cur[ci].median/100)*ph;
    var mtick = se('line');
    sa(mtick,{ x1:cx-halfMax*0.55, y1:my, x2:cx+halfMax*0.55, y2:my, stroke:'var(--fg)', 'stroke-width':2 });
    svg.appendChild(mtick);
    var lab = se('text');
    sa(lab,{ x:cx, y:padT+ph+13, 'text-anchor':'middle', fill:'var(--muted)', 'font-size':fs, 'font-weight':600 });
    lab.textContent = cat.name;
    svg.appendChild(lab);
  });
}

function animate(target){
  var from = cur.map(function(c){ return { density:c.density.slice(), median:c.median }; });
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/850);
    var e = 1-Math.pow(1-p,3);
    cur = from.map(function(c,i){
      return {
        density: c.density.map(function(v,gi){ return v+(target[i].density[gi]-v)*e; }),
        median: c.median+(target[i].median-c.median)*e
      };
    });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

new ResizeObserver(function(){ measure(); draw(); }).observe(svg);
measure();
draw();
animate(regen());
setInterval(function(){ animate(regen()); }, 4000);

It works on the same data a box plot does — comparing distributions across groups — but where a box plot compresses each group to five numbers (min, Q1, median, Q3, max), a violin plot draws the distribution as a smooth curve (a kernel density estimate, or KDE) and mirrors it left-right into a width. A box plot hides a bimodal distribution (two humps) inside one flat rectangle; a violin shows both humps directly.

A KDE works by smearing each individual sample into a bell-shaped (Gaussian) kernel and summing them all into one curve. How much each sample gets smeared — the bandwidth — changes the result substantially: too narrow, and each sample stays a sharp spike that reads as real structure when it's just noise; too wide, and even a genuinely bimodal distribution gets smoothed into one bland hill. The violin's shape is therefore a function of the bandwidth choice as much as the underlying data.

The easily overlooked weakness is that a smooth outline alone can't tell you the exact quartiles or sample count — which is why this demo adds a median tick, and why practice often overlays a thin box plot inside the violin, pairing the shape with the concrete numbers a box plot alone provides.

When to use

Use it to compare group distributions when the shape itself matters — bimodality, skew. With very few samples (under 20), the curve can present noise as structure; a beeswarm works better there.