Violin Plot with Embedded Box Plot — D3.js

A violin plot with an embedded box plot inside, combining the distribution shape visualization (KDE) with traditional quartile statistics. Shows both the probability density and summary statistics in one plot.

Violin Plot with Embedded Box Plot rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// violin-box: Violin Plot with Embedded Box Plot
// Library: d3 7.9.0 | JavaScript 22.23.2
// Quality: 92/100 | Created: 2026-09-09

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 90, right: 60, bottom: 90, left: 100 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Data (in-memory, deterministic) ----------------------------------------
// Test scores across three teaching methods. "Blended Learning" is generated
// as a two-component mixture so its KDE shows two humps that the embedded box
// plot's single median cannot reveal on its own — motivating the combined view.
function makeRng(seed) {
  let state = seed >>> 0;
  return () => {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
function randomNormal(rng, mean, std) {
  const u1 = Math.max(rng(), 1e-9);
  const u2 = rng();
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return mean + z * std;
}
const rng = makeRng(42);
const pointsPerGroup = 150;
const groupSpecs = [
  { label: "Traditional Lecture", sample: () => randomNormal(rng, 68, 11) },
  { label: "Flipped Classroom", sample: () => randomNormal(rng, 76, 9) },
  {
    label: "Blended Learning",
    sample: () => (rng() < 0.6 ? randomNormal(rng, 85, 6) : randomNormal(rng, 65, 9)),
  },
];

const data = [];
for (const spec of groupSpecs) {
  for (let i = 0; i < pointsPerGroup; i++) {
    data.push({ group: spec.label, value: Math.min(100, Math.max(0, spec.sample())) });
  }
}
const groups = groupSpecs.map((g) => g.label);

// --- Kernel density estimation ----------------------------------------------
function kernelEpanechnikov(bandwidth) {
  return (v) => (Math.abs((v /= bandwidth)) <= 1 ? (0.75 * (1 - v * v)) / bandwidth : 0);
}
function kernelDensityEstimator(kernel, thresholds) {
  return (values) => thresholds.map((x) => [x, d3.mean(values, (v) => kernel(x - v))]);
}
const bandwidth = 6;

// Each group's KDE is evaluated only across its own data extent (padded by the
// kernel bandwidth so the curve tapers to zero smoothly) rather than the full
// [0, 100] axis — otherwise the Epanechnikov kernel's hard cutoff produces an
// exact-zero-width (but still stroked) sliver reaching all the way to the axis
// ends wherever a group's data doesn't span the full score range.
const valuesByGroup = d3.group(data, (d) => d.group);
const densityByGroup = new Map();
let maxDensity = 0;
for (const group of groups) {
  const values = valuesByGroup.get(group).map((d) => d.value);
  const lo = Math.max(0, Math.floor(d3.min(values) - bandwidth));
  const hi = Math.min(100, Math.ceil(d3.max(values) + bandwidth));
  const thresholds = d3.range(lo, hi + 1, 1);
  const density = kernelDensityEstimator(kernelEpanechnikov(bandwidth), thresholds)(values);
  densityByGroup.set(group, density);
  maxDensity = Math.max(maxDensity, d3.max(density, (d) => d[1]));
}

// --- Box-plot summary stats (Tukey whiskers, 1.5×IQR) -----------------------
function computeBoxStats(values) {
  const sorted = values.slice().sort(d3.ascending);
  const q1 = d3.quantileSorted(sorted, 0.25);
  const median = d3.quantileSorted(sorted, 0.5);
  const q3 = d3.quantileSorted(sorted, 0.75);
  const iqr = q3 - q1;
  const lowerFence = q1 - 1.5 * iqr;
  const upperFence = q3 + 1.5 * iqr;
  const inRange = sorted.filter((v) => v >= lowerFence && v <= upperFence);
  return {
    q1,
    median,
    q3,
    whiskerLow: d3.min(inRange),
    whiskerHigh: d3.max(inRange),
    outliers: sorted.filter((v) => v < lowerFence || v > upperFence),
  };
}
const statsByGroup = new Map(groups.map((g) => [g, computeBoxStats(valuesByGroup.get(g).map((d) => d.value))]));

// --- SVG mount ----------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Scales -------------------------------------------------------------------
const x = d3.scaleBand().domain(groups).range([0, iw]).padding(0.38);
const y = d3.scaleLinear().domain([0, 100]).nice().range([ih, 0]);
const xNum = d3.scaleLinear().domain([0, maxDensity]).range([0, (x.bandwidth() / 2) * 0.92]);

// --- Y gridlines (drawn first, sit behind the data) ---------------------------
g.append("g")
  .call(d3.axisLeft(y).tickSize(-iw).tickFormat(""))
  .call((sel) => sel.select(".domain").remove())
  .selectAll("line")
  .attr("stroke", t.grid);

// --- Violin + box per group ----------------------------------------------------
const violinArea = d3
  .area()
  .curve(d3.curveCatmullRom.alpha(0.5))
  .y((d) => y(d[0]))
  .x0((d) => -xNum(d[1]))
  .x1((d) => xNum(d[1]));

const groupLayers = g
  .selectAll(".group-layer")
  .data(groups)
  .join("g")
  .attr("class", "group-layer")
  .attr("transform", (group) => `translate(${x(group) + x.bandwidth() / 2},0)`);

groupLayers
  .append("path")
  .attr("d", (group) => violinArea(densityByGroup.get(group)))
  .attr("fill", (group, i) => t.palette[i])
  .attr("fill-opacity", 0.42)
  .attr("stroke", (group, i) => t.palette[i])
  .attr("stroke-width", 2);

const boxWidth = x.bandwidth() * 0.16;

// Whiskers (drawn under the box so the box's fill covers the stem cleanly).
groupLayers.each(function (group, i) {
  const layer = d3.select(this);
  const stats = statsByGroup.get(group);
  const color = t.palette[i];

  layer
    .append("line")
    .attr("x1", 0)
    .attr("x2", 0)
    .attr("y1", y(stats.q3))
    .attr("y2", y(stats.whiskerHigh))
    .attr("stroke", color)
    .attr("stroke-width", 2);
  layer
    .append("line")
    .attr("x1", 0)
    .attr("x2", 0)
    .attr("y1", y(stats.q1))
    .attr("y2", y(stats.whiskerLow))
    .attr("stroke", color)
    .attr("stroke-width", 2);
  for (const whiskerValue of [stats.whiskerHigh, stats.whiskerLow]) {
    layer
      .append("line")
      .attr("x1", -boxWidth * 0.4)
      .attr("x2", boxWidth * 0.4)
      .attr("y1", y(whiskerValue))
      .attr("y2", y(whiskerValue))
      .attr("stroke", color)
      .attr("stroke-width", 2);
  }

  layer
    .append("rect")
    .attr("x", -boxWidth / 2)
    .attr("y", y(stats.q3))
    .attr("width", boxWidth)
    .attr("height", y(stats.q1) - y(stats.q3))
    .attr("fill", t.pageBg)
    .attr("stroke", color)
    .attr("stroke-width", 2.5);
  layer
    .append("line")
    .attr("x1", -boxWidth / 2)
    .attr("x2", boxWidth / 2)
    .attr("y1", y(stats.median))
    .attr("y2", y(stats.median))
    .attr("stroke", t.ink)
    .attr("stroke-width", 3);

  layer
    .selectAll(".outlier")
    .data(stats.outliers)
    .join("circle")
    .attr("class", "outlier")
    .attr("cx", 0)
    .attr("cy", (v) => y(v))
    .attr("r", 4.5)
    .attr("fill", color)
    .attr("fill-opacity", 0.75)
    .attr("stroke", t.pageBg)
    .attr("stroke-width", 1);
});

// --- Axes ----------------------------------------------------------------------
const xAxis = g.append("g").attr("transform", `translate(0,${ih})`).call(d3.axisBottom(x));
const yAxis = g.append("g").call(d3.axisLeft(y).tickFormat((v) => `${v}`));
for (const axis of [xAxis, yAxis]) {
  axis.selectAll("text").attr("fill", t.inkSoft).style("font-size", "18px");
  axis.selectAll("line").attr("stroke", t.inkSoft);
  axis.select(".domain").attr("stroke", t.inkSoft);
}

// --- Axis labels -----------------------------------------------------------------
g.append("text")
  .attr("x", iw / 2)
  .attr("y", ih + 64)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "20px")
  .text("Teaching Method");

g.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -ih / 2)
  .attr("y", -70)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "20px")
  .text("Test Score (%)");

// --- Title -------------------------------------------------------------------
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 48)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "22px")
  .style("font-weight", "600")
  .text("violin-box · javascript · d3 · anyplot.ai");

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/violin-box/d3/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "violin-box",
  "language": "javascript",
  "library": "d3",
  "page": "https://anyplot.ai/violin-box/javascript/d3",
  "hub": "https://anyplot.ai/violin-box",
  "code_json": "https://api.anyplot.ai/specs/violin-box/d3/code",
  "spec_json": "https://api.anyplot.ai/specs/violin-box",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/violin-box/javascript/d3/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/violin-box/javascript/d3/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/violin-box/javascript/d3/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/violin-box/javascript/d3/plot-dark.html",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Violin Plot with Embedded Box Plot on anyplot.ai.

Other implementations