{"spec_id":"violin-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// violin-basic: Basic Violin Plot\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-26\n\n// The core bundle (no highcharts-more) has no \"arearange\"/\"boxplot\" series, so\n// there is no native way to fill the area between two curves at each y-level —\n// which is exactly what a violin silhouette needs. Instead the silhouette,\n// quartile ticks, and median bar are drawn directly with chart.renderer.path()\n// (the same public SVG-renderer API Highcharts uses internally for its own\n// series shapes), converting KDE/quantile values to pixels via axis.toPixels()\n// once the chart has laid out its axes (chart.events.load).\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic LCG) ------------------------------------\nlet seed = 42;\nfunction lcg() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction randNormal(mean, std) {\n  const u1 = Math.max(lcg(), 1e-9);\n  const u2 = lcg();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\nfunction quantile(sorted, q) {\n  const pos = (sorted.length - 1) * q;\n  const base = Math.floor(pos);\n  const rest = pos - base;\n  return sorted[base + 1] !== undefined\n    ? sorted[base] + rest * (sorted[base + 1] - sorted[base])\n    : sorted[base];\n}\nfunction stdDev(values) {\n  const mean = values.reduce((a, b) => a + b, 0) / values.length;\n  const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;\n  return Math.sqrt(variance);\n}\n// Silverman's rule of thumb for a Gaussian KDE bandwidth.\nfunction bandwidthOf(values, sorted) {\n  const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25);\n  const spread = iqr > 0 ? Math.min(stdDev(values), iqr / 1.34) : stdDev(values);\n  return 0.9 * spread * Math.pow(values.length, -0.2);\n}\nfunction densityAt(y, values, bandwidth) {\n  let sum = 0;\n  for (const v of values) {\n    const u = (y - v) / bandwidth;\n    sum += Math.exp(-0.5 * u * u);\n  }\n  return sum / (values.length * bandwidth * Math.sqrt(2 * Math.PI));\n}\n\n// Marathon finish times (minutes) by age group — the 40-49 bracket mixes a\n// competitive-masters cluster with a recreational cluster, giving it a visibly\n// bimodal shape a box plot would flatten into one unremarkable box.\nconst AGE_GROUP_DEFS = [\n  { name: \"20-29\", n: 220, mixture: [{ mean: 245, std: 28, weight: 1 }] },\n  { name: \"30-39\", n: 260, mixture: [{ mean: 235, std: 24, weight: 1 }] },\n  {\n    name: \"40-49\",\n    n: 300,\n    mixture: [\n      { mean: 224, std: 16, weight: 0.4 },\n      { mean: 278, std: 26, weight: 0.6 },\n    ],\n  },\n  { name: \"50-59\", n: 200, mixture: [{ mean: 262, std: 30, weight: 1 }] },\n  { name: \"60+\", n: 140, mixture: [{ mean: 288, std: 34, weight: 1 }] },\n];\nconst KDE_POINTS = 140;\nconst ageGroups = AGE_GROUP_DEFS.map((def) => {\n  const cumWeights = [];\n  let runningWeight = 0;\n  def.mixture.forEach((m) => {\n    runningWeight += m.weight;\n    cumWeights.push(runningWeight);\n  });\n  const samples = Array.from({ length: def.n }, () => {\n    const r = lcg() * runningWeight;\n    const part = def.mixture[cumWeights.findIndex((c) => r <= c)] || def.mixture[def.mixture.length - 1];\n    return Math.min(420, Math.max(140, randNormal(part.mean, part.std)));\n  });\n  const sorted = [...samples].sort((a, b) => a - b);\n  return {\n    name: def.name,\n    samples,\n    q1: quantile(sorted, 0.25),\n    median: quantile(sorted, 0.5),\n    q3: quantile(sorted, 0.75),\n    bandwidth: bandwidthOf(samples, sorted),\n  };\n});\nconst categories = ageGroups.map((g) => g.name);\n\nconst allValues = ageGroups.flatMap((g) => g.samples);\nconst yMin = Math.floor((Math.min(...allValues) - 8) / 10) * 10;\nconst yMax = Math.ceil((Math.max(...allValues) + 8) / 10) * 10;\nageGroups.forEach((g) => {\n  g.evalYs = Array.from({ length: KDE_POINTS }, (_, k) => yMin + (k / (KDE_POINTS - 1)) * (yMax - yMin));\n  g.densities = g.evalYs.map((y) => densityAt(y, g.samples, g.bandwidth));\n  g.maxDensity = Math.max(...g.densities);\n});\n\n// Draws the mirrored KDE silhouette plus quartile/median ticks for every\n// category, once the axes are laid out and axis.toPixels() is meaningful.\nfunction drawViolins(chart) {\n  const xAxis = chart.xAxis[0];\n  const yAxis = chart.yAxis[0];\n  const unitPx = Math.abs(xAxis.toPixels(1, false) - xAxis.toPixels(0, false));\n  const maxHalfWidthPx = unitPx * 0.4;\n\n  ageGroups.forEach((g, i) => {\n    const centerX = xAxis.toPixels(i, false);\n    const color = t.palette[i % t.palette.length];\n\n    // Highcharts 11+ renderer.path() takes an array of [\"command\", ...coords]\n    // segment tuples, not a flat command/coordinate list.\n    const path = [];\n    g.evalYs.forEach((y, k) => {\n      const px = centerX + (g.densities[k] / g.maxDensity) * maxHalfWidthPx;\n      path.push([k === 0 ? \"M\" : \"L\", px, yAxis.toPixels(y, false)]);\n    });\n    for (let k = g.evalYs.length - 1; k >= 0; k--) {\n      const px = centerX - (g.densities[k] / g.maxDensity) * maxHalfWidthPx;\n      path.push([\"L\", px, yAxis.toPixels(g.evalYs[k], false)]);\n    }\n    path.push([\"Z\"]);\n    chart.renderer\n      .path(path)\n      .attr({ fill: color, \"fill-opacity\": 0.82, stroke: t.inkSoft, \"stroke-width\": 1.25, zIndex: 5 })\n      .add();\n\n    // Quartile ticks (dashed, ink-soft) and median bar (solid, page-bg cut\n    // for contrast against the fill) — each sized to the violin's local\n    // half-width at that exact value, not the violin's overall max width.\n    [\n      { value: g.q1, widthFrac: 0.55, stroke: t.inkSoft, strokeWidth: 1.5, dash: \"4,3\" },\n      { value: g.q3, widthFrac: 0.55, stroke: t.inkSoft, strokeWidth: 1.5, dash: \"4,3\" },\n      { value: g.median, widthFrac: 0.75, stroke: t.pageBg, strokeWidth: 3 },\n    ].forEach((mark) => {\n      const localDensity = densityAt(mark.value, g.samples, g.bandwidth);\n      const halfPx = Math.min(1, localDensity / g.maxDensity) * maxHalfWidthPx * mark.widthFrac;\n      const py = yAxis.toPixels(mark.value, false);\n      const attrs = { stroke: mark.stroke, \"stroke-width\": mark.strokeWidth, zIndex: 6, linecap: \"round\" };\n      if (mark.dash) attrs[\"stroke-dasharray\"] = mark.dash;\n      chart.renderer\n        .path([[\"M\", centerX - halfPx, py], [\"L\", centerX + halfPx, py]])\n        .attr(attrs)\n        .add();\n    });\n  });\n}\n\n// --- Chart -------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    events: { load: function () { drawViolins(this); } },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"violin-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  xAxis: {\n    categories,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n    title: { text: \"Runner Age Group\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n  },\n  yAxis: {\n    min: yMin,\n    max: yMax,\n    title: { text: \"Marathon Finish Time (minutes)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    gridLineColor: t.grid,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n  },\n  legend: { enabled: false },\n  tooltip: { enabled: false },\n  plotOptions: { series: { animation: false } },\n  // A real (if invisible) cartesian series — Highcharts only lays out axes\n  // when at least one cartesian series is present; the violin bodies\n  // themselves are drawn later via chart.renderer, not as series data.\n  series: [\n    {\n      type: \"scatter\",\n      data: ageGroups.map((g, i) => ({ x: i, y: g.median })),\n      marker: { enabled: false },\n      enableMouseTracking: false,\n      showInLegend: false,\n    },\n  ],\n});\n"}