{"spec_id":"violin-split","library":"d3","language":"javascript","code":"// anyplot.ai\n// violin-split: Split Violin Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-09\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 100, right: 220, bottom: 90, left: 90 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Deterministic PRNG (LCG) + Box-Muller normal sampler -------------------\nfunction makeRng(seed) {\n  let state = seed >>> 0;\n  return function () {\n    state = (1103515245 * state + 12345) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = makeRng(42);\nfunction randNormal(mean, std) {\n  const u1 = Math.max(rng(), 1e-9);\n  const u2 = rng();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\nfunction clamp(v, lo, hi) {\n  return Math.max(lo, Math.min(hi, v));\n}\n\n// --- Data: midterm vs final exam scores across five courses -----------------\nconst COURSES = [\n  { name: \"Algebra\", midterm: [68, 12], final: [76, 10] },\n  { name: \"Biology\", midterm: [74, 9], final: [79, 8] },\n  { name: \"Chemistry\", midterm: [63, 14], final: [70, 13] },\n  { name: \"Physics\", midterm: [58, 15], final: [67, 14] },\n  { name: \"Statistics\", midterm: [71, 10], final: [75, 9] },\n];\nconst N = 220;\nconst courses = COURSES.map((c) => ({\n  name: c.name,\n  midterm: Array.from({ length: N }, () => clamp(randNormal(c.midterm[0], c.midterm[1]), 0, 100)),\n  final: Array.from({ length: N }, () => clamp(randNormal(c.final[0], c.final[1]), 0, 100)),\n}));\n\n// --- Kernel density estimate (Gaussian kernel, Silverman bandwidth) ---------\n// Boundary-corrected via the reflection method: samples are mirrored across\n// the [0, 100] data bounds so the estimate does not taper artificially near\n// the edges (courses with means close to 0 or 100 would otherwise show a\n// biased, abruptly-cut density there).\nconst GRID = d3.range(0, 100.001, 100 / 140);\nfunction kde(sample) {\n  const std = d3.deviation(sample);\n  const bw = 1.06 * std * Math.pow(sample.length, -0.2);\n  const gauss = (u) => Math.exp(-0.5 * u * u) / Math.sqrt(2 * Math.PI);\n  return GRID.map((x) => {\n    let sum = 0;\n    for (const v of sample) {\n      sum += gauss((x - v) / bw) + gauss((x + v) / bw) + gauss((x - (200 - v)) / bw);\n    }\n    return { x, density: sum / (sample.length * bw) };\n  });\n}\nfor (const c of courses) {\n  c.midtermKde = kde(c.midterm);\n  c.finalKde = kde(c.final);\n}\nconst maxDensity = d3.max(courses, (c) =>\n  Math.max(d3.max(c.midtermKde, (d) => d.density), d3.max(c.finalKde, (d) => d.density))\n);\n\nfunction quartiles(sample) {\n  const sorted = [...sample].sort(d3.ascending);\n  return {\n    q1: d3.quantileSorted(sorted, 0.25),\n    median: d3.quantileSorted(sorted, 0.5),\n    q3: d3.quantileSorted(sorted, 0.75),\n  };\n}\nfor (const c of courses) {\n  c.midtermStats = quartiles(c.midterm);\n  c.finalStats = quartiles(c.final);\n}\n\n// --- Scales -------------------------------------------------------------\nconst x = d3.scaleBand().domain(courses.map((c) => c.name)).range([0, iw]).padding(0.38);\nconst y = d3.scaleLinear().domain([0, 100]).nice().range([ih, 0]);\nconst halfWidth = (x.bandwidth() / 2) * 0.88;\nconst widthScale = d3.scaleLinear().domain([0, maxDensity]).range([0, halfWidth]);\n\n// --- SVG mount --------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Y gridlines (drawn first, below the violins) ---------------------------\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(y.ticks(6))\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d))\n  .attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// --- Axes -------------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\".tick line\").remove();\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Split violin halves ------------------------------------------------\nconst areaLeft = d3.area().curve(d3.curveBasis).y((d) => y(d.x)).x0(0).x1((d) => -widthScale(d.density));\nconst areaRight = d3.area().curve(d3.curveBasis).y((d) => y(d.x)).x0(0).x1((d) => widthScale(d.density));\n\nconst violin = g\n  .selectAll(\".violin\")\n  .data(courses)\n  .join(\"g\")\n  .attr(\"class\", \"violin\")\n  .attr(\"transform\", (d) => `translate(${x(d.name) + x.bandwidth() / 2},0)`);\n\nviolin\n  .append(\"path\")\n  .attr(\"d\", (d) => areaLeft(d.midtermKde))\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.85)\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 1.5);\n\nviolin\n  .append(\"path\")\n  .attr(\"d\", (d) => areaRight(d.finalKde))\n  .attr(\"fill\", t.palette[1])\n  .attr(\"fill-opacity\", 0.85)\n  .attr(\"stroke\", t.palette[1])\n  .attr(\"stroke-width\", 1.5);\n\n// center seam where the two halves meet\nviolin\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", 0)\n  .attr(\"y1\", 0)\n  .attr(\"y2\", ih)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2);\n\n// --- Inner quartile markers (median tick + IQR ticks) per half --------------\nfunction drawQuartiles(sel, stats, sign) {\n  const { q1, median, q3 } = stats;\n  sel\n    .append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", sign * halfWidth * 0.42)\n    .attr(\"y1\", y(q1))\n    .attr(\"y2\", y(q1))\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 2);\n  sel\n    .append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", sign * halfWidth * 0.42)\n    .attr(\"y1\", y(q3))\n    .attr(\"y2\", y(q3))\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 2);\n  sel\n    .append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", sign * halfWidth * 0.6)\n    .attr(\"y1\", y(median))\n    .attr(\"y2\", y(median))\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 3);\n}\nviolin.each(function (d) {\n  const sel = d3.select(this);\n  drawQuartiles(sel, d.midtermStats, -1);\n  drawQuartiles(sel, d.finalStats, 1);\n});\n\n// --- Storytelling annotation: call out the course with the largest --------\n// midterm -> final median gain\nlet bestCourse = courses[0];\nlet bestGain = -Infinity;\nfor (const c of courses) {\n  const gain = c.finalStats.median - c.midtermStats.median;\n  if (gain > bestGain) {\n    bestGain = gain;\n    bestCourse = c;\n  }\n}\nconst calloutX = x(bestCourse.name) + x.bandwidth() / 2;\nconst calloutTopY = Math.max(\n  20,\n  Math.min(y(bestCourse.midtermStats.q3), y(bestCourse.finalStats.q3)) - 34\n);\nconst callout = g.append(\"g\").attr(\"class\", \"callout\");\ncallout\n  .append(\"line\")\n  .attr(\"x1\", calloutX)\n  .attr(\"x2\", calloutX)\n  .attr(\"y1\", calloutTopY + 18)\n  .attr(\"y2\", calloutTopY + 32)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"2,2\");\ncallout\n  .append(\"text\")\n  .attr(\"x\", calloutX)\n  .attr(\"y\", calloutTopY)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(`Largest gain: ${bestCourse.name} +${bestGain.toFixed(1)} pts`);\n\n// --- Legend -------------------------------------------------------------\nconst legendData = [\n  { label: \"Midterm\", color: t.palette[0] },\n  { label: \"Final\", color: t.palette[1] },\n];\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${margin.left + iw + 40}, ${margin.top + 10})`);\nconst legendRows = legend\n  .selectAll(\"g\")\n  .data(legendData)\n  .join(\"g\")\n  .attr(\"transform\", (d, i) => `translate(0, ${i * 34})`);\nlegendRows.append(\"rect\").attr(\"width\", 20).attr(\"height\", 20).attr(\"rx\", 3).attr(\"fill\", (d) => d.color);\nlegendRows\n  .append(\"text\")\n  .attr(\"x\", 28)\n  .attr(\"y\", 15)\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text((d) => d.label);\n\n// --- Axis labels --------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left + iw / 2)\n  .attr(\"y\", height - 24)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Course\");\n\nsvg\n  .append(\"text\")\n  .attr(\"transform\", `translate(28, ${margin.top + ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Exam Score (%)\");\n\n// --- Title --------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"violin-split · javascript · d3 · anyplot.ai\");\n"}