{"spec_id":"violin-swarm","library":"echarts","language":"javascript","code":"// anyplot.ai\n// violin-swarm: Violin Plot with Overlaid Swarm Points\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG) + Box-Muller for reproducible normal samples --\nlet seed = 42;\nfunction lcgRandom() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction randomNormal(mean, stdDev) {\n  const u1 = lcgRandom() || 1e-9;\n  const u2 = lcgRandom();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * stdDev;\n}\n\n// --- Data: reaction times (ms) across 4 caffeine dosage conditions ---------\nconst categories = [\"Placebo\", \"Low Dose\", \"Medium Dose\", \"High Dose\"];\nconst samplesByCategory = [\n  Array.from({ length: 55 }, () => randomNormal(340, 40)),\n  // bimodal: a subset of subjects respond strongly to the low dose\n  Array.from({ length: 60 }, (_, i) =>\n    i % 3 === 0 ? randomNormal(320, 28) : randomNormal(255, 24)\n  ),\n  Array.from({ length: 48 }, () => randomNormal(252, 32)),\n  Array.from({ length: 65 }, () => randomNormal(205, 22)),\n];\n\n// --- Kernel density estimation (Gaussian kernel, Silverman bandwidth) ------\nfunction meanAndStdDev(values) {\n  const m = values.reduce((sum, v) => sum + v, 0) / values.length;\n  const variance = values.reduce((sum, v) => sum + (v - m) ** 2, 0) / values.length;\n  return { mean: m, stdDev: Math.sqrt(variance) };\n}\nfunction quantile(sortedValues, q) {\n  const pos = (sortedValues.length - 1) * q;\n  const base = Math.floor(pos);\n  const rest = pos - base;\n  return sortedValues[base + 1] !== undefined\n    ? sortedValues[base] + rest * (sortedValues[base + 1] - sortedValues[base])\n    : sortedValues[base];\n}\nfunction silvermanBandwidth(values) {\n  const sorted = [...values].sort((a, b) => a - b);\n  const { stdDev } = meanAndStdDev(values);\n  const spread = Math.min(stdDev, (quantile(sorted, 0.75) - quantile(sorted, 0.25)) / 1.34);\n  return 0.9 * (spread || stdDev) * Math.pow(values.length, -0.2);\n}\nfunction gaussianKde(values, bandwidth) {\n  return (y) => {\n    const sum = values.reduce((acc, v) => {\n      const u = (y - v) / bandwidth;\n      return acc + Math.exp(-0.5 * u * u);\n    }, 0);\n    return sum / (values.length * bandwidth * Math.sqrt(2 * Math.PI));\n  };\n}\n\n// --- Violin geometry: one density profile per category, normalized width --\nconst GRID_POINTS = 80;\nconst MAX_HALF_WIDTH = 0.38;\n\nconst violins = categories.map((category, index) => {\n  const values = samplesByCategory[index];\n  const bandwidth = silvermanBandwidth(values);\n  const yMin = Math.min(...values) - 2.5 * bandwidth;\n  const yMax = Math.max(...values) + 2.5 * bandwidth;\n  const density = gaussianKde(values, bandwidth);\n  const grid = Array.from(\n    { length: GRID_POINTS },\n    (_, i) => yMin + (i / (GRID_POINTS - 1)) * (yMax - yMin)\n  );\n  const densities = grid.map(density);\n  const maxDensity = Math.max(...densities);\n  const halfWidths = densities.map((d) => (d / maxDensity) * MAX_HALF_WIDTH);\n  return { category, index, values, grid, halfWidths };\n});\n\nfunction halfWidthAt(violin, y) {\n  const { grid, halfWidths } = violin;\n  if (y <= grid[0]) return halfWidths[0];\n  if (y >= grid[grid.length - 1]) return halfWidths[halfWidths.length - 1];\n  for (let i = 0; i < grid.length - 1; i++) {\n    if (y >= grid[i] && y <= grid[i + 1]) {\n      const frac = (y - grid[i]) / (grid[i + 1] - grid[i]);\n      return halfWidths[i] + frac * (halfWidths[i + 1] - halfWidths[i]);\n    }\n  }\n  return 0;\n}\n\n// --- Swarm layout: bin observations along y, spread them within the local -\n// --- violin half-width so points never spill past the density outline -----\nconst SWARM_BINS = 28;\nconst MAX_POINT_SPACING = 0.045;\n\nfunction beeswarmOffsets(violin) {\n  const { values, grid } = violin;\n  const yMin = grid[0];\n  const yMax = grid[grid.length - 1];\n  const binWidth = (yMax - yMin) / SWARM_BINS;\n  const bins = Array.from({ length: SWARM_BINS }, () => []);\n  values\n    .map((_, i) => i)\n    .sort((a, b) => values[a] - values[b])\n    .forEach((i) => {\n      const binIndex = Math.min(\n        SWARM_BINS - 1,\n        Math.max(0, Math.floor((values[i] - yMin) / binWidth))\n      );\n      bins[binIndex].push(i);\n    });\n  const offsets = new Array(values.length).fill(0);\n  // Density-scaled marker size: bins forced below MAX_POINT_SPACING (crowded)\n  // get a ratio < 1 so renderer can shrink/lighten points to stay distinguishable.\n  const sizeRatios = new Array(values.length).fill(1);\n  bins.forEach((indices, binIndex) => {\n    const count = indices.length;\n    if (count === 0) return;\n    const binCenterY = yMin + (binIndex + 0.5) * binWidth;\n    const spacing = Math.min(MAX_POINT_SPACING, (2 * halfWidthAt(violin, binCenterY)) / count);\n    const ratio = Math.max(0.4, Math.min(1, spacing / MAX_POINT_SPACING));\n    indices.forEach((valueIndex, k) => {\n      offsets[valueIndex] = (k - (count - 1) / 2) * spacing;\n      sizeRatios[valueIndex] = ratio;\n    });\n  });\n  return { offsets, sizeRatios };\n}\n\nviolins.forEach((violin) => {\n  const { offsets, sizeRatios } = beeswarmOffsets(violin);\n  violin.offsets = offsets;\n  violin.sizeRatios = sizeRatios;\n});\n\n// --- Series data --------------------------------------------------------\nconst violinOutlines = violins.map((violin) => {\n  const points = [];\n  for (let i = 0; i < violin.grid.length; i++) {\n    points.push([violin.index + violin.halfWidths[i], violin.grid[i]]);\n  }\n  for (let i = violin.grid.length - 1; i >= 0; i--) {\n    points.push([violin.index - violin.halfWidths[i], violin.grid[i]]);\n  }\n  return points;\n});\n\n// Low Dose is the deliberately bimodal category (see data generation above);\n// give it a subtle accent so the storytelling point isn't purely implicit.\nconst BIMODAL_INDEX = 1;\n\nconst swarmPoints = violins.flatMap((violin) =>\n  violin.values.map((value, i) => {\n    const ratio = violin.sizeRatios[i];\n    return {\n      value: [violin.index + violin.offsets[i], value],\n      symbolSize: 6 + ratio * 4,\n      itemStyle: { opacity: 0.55 + ratio * 0.3 },\n    };\n  })\n);\n\nfunction hexToRgba(hex, alpha) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\nfunction renderViolin(params, api) {\n  const points = violinOutlines[params.dataIndex].map((p) => api.coord(p));\n  const isBimodal = params.dataIndex === BIMODAL_INDEX;\n  return {\n    type: \"polygon\",\n    shape: { points },\n    style: {\n      fill: hexToRgba(t.palette[0], 0.4),\n      stroke: isBimodal ? t.amber : t.palette[0],\n      lineWidth: isBimodal ? 2.5 : 1.5,\n    },\n  };\n}\n\n// --- Chart ----------------------------------------------------------------\nconst titleText = \"Reaction Time by Caffeine Dose · violin-swarm · javascript · echarts · anyplot.ai\";\n// Moderately-long descriptive titles (up to 110 chars) hold a higher floor so\n// they keep visual presence; only titles beyond that shrink proportionally.\nconst titleFontSize =\n  titleText.length > 110\n    ? Math.max(16, Math.round(24 * (110 / titleText.length)))\n    : titleText.length > 67\n      ? 24\n      : 22;\n\nconst chart = echarts.init(document.getElementById(\"container\"));\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  color: t.palette,\n  title: {\n    text: titleText,\n    left: \"center\",\n    top: 18,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n  },\n  legend: {\n    top: 60,\n    left: \"center\",\n    itemWidth: 18,\n    itemHeight: 12,\n    textStyle: { color: t.inkSoft, fontSize: 15 },\n    data: [\"Density\", \"Observations\"],\n  },\n  grid: { left: 100, right: 60, top: 130, bottom: 90 },\n  xAxis: {\n    type: \"value\",\n    min: -0.65,\n    max: categories.length - 1 + 0.65,\n    axisLabel: {\n      customValues: categories.map((_, i) => i),\n      formatter: (val) => categories[val],\n      color: t.inkSoft,\n      fontSize: 16,\n    },\n    axisTick: { customValues: categories.map((_, i) => i) },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    splitLine: { show: false },\n    name: \"Experimental Condition\",\n    nameLocation: \"middle\",\n    nameGap: 45,\n    nameTextStyle: { color: t.ink, fontSize: 18 },\n  },\n  yAxis: {\n    type: \"value\",\n    name: \"Reaction Time (ms)\",\n    nameLocation: \"middle\",\n    nameGap: 65,\n    nameTextStyle: { color: t.ink, fontSize: 18 },\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { onZero: false, lineStyle: { color: t.inkSoft } },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n  series: [\n    {\n      name: \"Density\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      xAxisIndex: 0,\n      yAxisIndex: 0,\n      renderItem: renderViolin,\n      data: categories.map((_, i) => i),\n      itemStyle: { color: hexToRgba(t.palette[0], 0.6) },\n      clip: true,\n      silent: true,\n      z: 2,\n    },\n    {\n      name: \"Observations\",\n      type: \"scatter\",\n      data: swarmPoints,\n      itemStyle: {\n        color: t.palette[1],\n        borderColor: t.pageBg,\n        borderWidth: 1,\n      },\n      z: 3,\n    },\n  ],\n});\n\n// --- Storytelling callout: label the deliberately bimodal Low Dose group ---\nconst bimodalViolin = violins[BIMODAL_INDEX];\nconst bimodalTopY = bimodalViolin.grid[bimodalViolin.grid.length - 1];\nconst [labelX, labelY] = chart.convertToPixel(\n  { xAxisIndex: 0, yAxisIndex: 0 },\n  [bimodalViolin.index, bimodalTopY]\n);\nchart.setOption({\n  graphic: [\n    {\n      type: \"text\",\n      left: labelX - 52,\n      top: labelY - 30,\n      z: 10,\n      style: {\n        text: \"Bimodal response\",\n        fill: t.amber,\n        fontSize: 13,\n        fontWeight: 600,\n      },\n    },\n  ],\n});\n"}