{"spec_id":"violin-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// violin-basic: Basic Violin Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-26\n\n// --- Reproducible RNG (LCG + Box-Muller) ------------------------------------\nlet seed = 20260826;\nconst rng = () => {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n};\nconst randomNormal = (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};\n\n// --- Data: laptop battery life (hours) by product line ----------------------\n// Each line has a distinct distribution shape to show what a violin reveals\n// beyond a box plot: a tight cluster, a wide spread, a right-skewed tail, and\n// a genuinely bimodal usage pattern.\nconst groups = [\n  { category: \"UltraLight\", values: Array.from({ length: 160 }, () => randomNormal(17.5, 1.3)) },\n  { category: \"ProSeries\", values: Array.from({ length: 160 }, () => randomNormal(11, 2) ) },\n  {\n    category: \"Workstation\",\n    values: [\n      ...Array.from({ length: 80 }, () => randomNormal(5, 0.8)),\n      ...Array.from({ length: 80 }, () => randomNormal(9.5, 0.9)),\n    ],\n  },\n  {\n    category: \"Budget\",\n    values: Array.from({ length: 160 }, () => 4 + -Math.log(Math.max(rng(), 1e-9)) * 2.2),\n  },\n  {\n    category: \"Gaming\",\n    values: Array.from({ length: 160 }, () => Math.max(1.5, randomNormal(6, 2.6))),\n  },\n];\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 60, bottom: 90, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst allValues = groups.flatMap((grp) => grp.values);\nconst x = d3.scaleBand()\n  .domain(groups.map((grp) => grp.category))\n  .range([0, iw])\n  .paddingInner(0.35)\n  .paddingOuter(0.25);\n\nconst y = d3.scaleLinear()\n  .domain([0, d3.max(allValues) + 1.5])\n  .nice()\n  .range([ih, 0]);\n\nconst color = d3.scaleOrdinal()\n  .domain(groups.map((grp) => grp.category))\n  .range(t.palette);\n\n// --- Kernel density estimation ----------------------------------------------\nconst kernelEpanechnikov = (bandwidth) => (v) =>\n  Math.abs((v /= bandwidth)) <= 1 ? (0.75 * (1 - v * v)) / bandwidth : 0;\n\nconst kernelDensityEstimator = (kernel, thresholds) => (sample) =>\n  thresholds.map((v) => [v, d3.mean(sample, (s) => kernel(v - s))]);\n\nconst thresholds = y.ticks(90);\nconst halfWidth = x.bandwidth() / 2;\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// --- Horizontal gridlines (value axis) -----------------------------------------\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// --- Draw each category's mirrored violin ---------------------------------------\nfor (const grp of groups) {\n  const cat = grp.category;\n  const values = grp.values.slice().sort(d3.ascending);\n  const fill = color(cat);\n  const center = x(cat) + halfWidth;\n\n  // 1.35x the Silverman rule-of-thumb factor: smooths sparse-tail bumpiness\n  // (e.g. ProSeries) without over-flattening the bimodal Workstation shape.\n  const bandwidth = 1.35 * d3.deviation(values) * Math.pow(values.length, -0.2);\n  const kernel = kernelEpanechnikov(bandwidth);\n  const kde = kernelDensityEstimator(kernel, thresholds);\n  const density = kde(values).filter((d) => d[0] >= values[0] && d[0] <= values[values.length - 1]);\n  const maxDensity = d3.max(density, (d) => d[1]) || 1;\n  const widthScale = d3.scaleLinear().domain([0, maxDensity]).range([0, halfWidth * 0.88]);\n\n  const violin = d3.area()\n    .y((d) => y(d[0]))\n    .x0((d) => center - widthScale(d[1]))\n    .x1((d) => center + widthScale(d[1]))\n    .curve(d3.curveBasis);\n\n  g.append(\"path\")\n    .datum(density)\n    .attr(\"d\", violin)\n    .attr(\"fill\", fill)\n    .attr(\"fill-opacity\", 0.72)\n    .attr(\"stroke\", fill)\n    .attr(\"stroke-width\", 1.5);\n\n  // Quartile markers inside the violin, matching its local width\n  const densityAt = (v) => d3.mean(values, (s) => kernel(v - s));\n  const q1 = d3.quantileSorted(values, 0.25);\n  const median = d3.quantileSorted(values, 0.5);\n  const q3 = d3.quantileSorted(values, 0.75);\n\n  for (const [q, strokeWidth] of [[q1, 2], [q3, 2]]) {\n    const w = widthScale(densityAt(q));\n    g.append(\"line\")\n      .attr(\"x1\", center - w)\n      .attr(\"x2\", center + w)\n      .attr(\"y1\", y(q))\n      .attr(\"y2\", y(q))\n      .attr(\"stroke\", t.ink)\n      .attr(\"stroke-opacity\", 0.55)\n      .attr(\"stroke-width\", strokeWidth);\n  }\n\n  const medianWidth = widthScale(densityAt(median));\n  g.append(\"line\")\n    .attr(\"x1\", center - medianWidth)\n    .attr(\"x2\", center + medianWidth)\n    .attr(\"y1\", y(median))\n    .attr(\"y2\", y(median))\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 3.5);\n}\n\n// --- Focal-point annotation: call out the bimodal Workstation shape -------------\n// Storytelling touch: a violin's advantage over a box plot is revealing shapes\n// like this waisted, two-cluster distribution — worth a direct callout.\n{\n  const wsCenter = x(\"Workstation\") + halfWidth;\n  const waistValue = 7.25; // midpoint between the two generating means (5 and 9.5)\n  const labelValue = 17; // clear of the violin's peak (~12h) and off the 15/20 gridlines\n\n  g.append(\"line\")\n    .attr(\"x1\", wsCenter)\n    .attr(\"x2\", wsCenter)\n    .attr(\"y1\", y(labelValue) + 10)\n    .attr(\"y2\", y(waistValue))\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1)\n    .attr(\"stroke-dasharray\", \"2,3\");\n\n  g.append(\"text\")\n    .attr(\"x\", wsCenter)\n    .attr(\"y\", y(labelValue))\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"12px\")\n    .style(\"font-style\", \"italic\")\n    .text(\"bimodal — two usage clusters\");\n}\n\n// --- Axes -----------------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x).tickSize(0));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y));\n\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.grid);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\nxAxis.select(\".domain\").remove();\n\ng.append(\"text\")\n  .attr(\"transform\", `translate(${-margin.left + 34},${ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"15px\")\n  .text(\"Battery Life (hours)\");\n\n// --- Title ------------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Laptop Battery Life · violin-basic · javascript · d3 · anyplot.ai\");\n"}