{"spec_id":"violin-swarm","library":"muix","language":"javascript","code":"// anyplot.ai\n// violin-swarm: Violin Plot with Overlaid Swarm Points\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsGrid } from \"@mui/x-charts/ChartsGrid\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst TITLE = \"violin-swarm · javascript · muix · anyplot.ai\";\nconst TITLE_HEIGHT = 56;\n\n// --- Data (in-memory, deterministic LCG — no seeded RNG in the browser) -----\nfunction lcg(seed) {\n  let state = seed;\n  return () => {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\nfunction randomNormal(rand, mean, stdDev) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * stdDev;\n}\nfunction clamp(v, lo, hi) {\n  return Math.min(hi, Math.max(lo, v));\n}\n\nconst rand = lcg(42);\n\n// Reaction time (ms) across 4 experimental conditions, 50 trials each — each\n// condition shaped differently so the swarm points reveal structure (a bimodal\n// split, a skewed tail) that summary stats alone would hide.\nconst N_PER_CONDITION = 50;\nconst conditions = [\n  {\n    name: \"Baseline\",\n    sample: () => clamp(randomNormal(rand, 420, 35), 300, 650),\n  },\n  {\n    name: \"Caffeine\",\n    sample: () => clamp(randomNormal(rand, 360, 28), 300, 650),\n  },\n  {\n    // Most trials slow down, but a subset of attention lapses spike much higher.\n    name: \"Sleep-deprived\",\n    sample: () =>\n      clamp(\n        rand() < 0.75\n          ? randomNormal(rand, 470, 30)\n          : randomNormal(rand, 590, 25),\n        300,\n        650,\n      ),\n  },\n  {\n    // Right-skewed: most trials cluster near a floor, with a long slow tail.\n    name: \"Dual-task\",\n    sample: () =>\n      clamp(\n        430 + 140 * Math.pow(rand(), 2.2) + 8 * randomNormal(rand, 0, 1),\n        300,\n        650,\n      ),\n  },\n];\nconst categories = conditions.map((c) => c.name);\nconst valuesByCategory = conditions.map((c) =>\n  Array.from({ length: N_PER_CONDITION }, c.sample),\n);\n\nconst allValues = valuesByCategory.flat();\nconst dataMin = Math.min(...allValues);\nconst dataMax = Math.max(...allValues);\nconst yPad = (dataMax - dataMin) * 0.1;\nconst Y_MIN = dataMin - yPad;\nconst Y_MAX = dataMax + yPad;\n\n// --- Gaussian KDE per condition, Silverman bandwidth, normalized to its own\n// peak so violin width encodes shape, not sample count. Each violin's grid is\n// clipped to its own local support (data range ± 3 bandwidths) rather than the\n// shared Y_MIN..Y_MAX span — otherwise the closed path pinches to near-zero\n// width far from the data and the stroke still draws a hairline all the way to\n// the axis edges.\nconst GRID_N = 140;\nfunction stdOf(values) {\n  const m = values.reduce((a, b) => a + b, 0) / values.length;\n  const variance =\n    values.reduce((a, b) => a + (b - m) ** 2, 0) / (values.length - 1);\n  return Math.sqrt(variance);\n}\nfunction bandwidthOf(values) {\n  return 0.9 * stdOf(values) * Math.pow(values.length, -0.2);\n}\nfunction kdeAt(values, bandwidth, y) {\n  return values.reduce(\n    (sum, v) => sum + Math.exp(-0.5 * ((y - v) / bandwidth) ** 2),\n    0,\n  );\n}\nconst bandwidthByCategory = valuesByCategory.map(bandwidthOf);\nconst gridByCategory = valuesByCategory.map((values, i) => {\n  const bandwidth = bandwidthByCategory[i];\n  const lo = Math.max(Y_MIN, Math.min(...values) - 3 * bandwidth);\n  const hi = Math.min(Y_MAX, Math.max(...values) + 3 * bandwidth);\n  return Array.from(\n    { length: GRID_N },\n    (_, k) => lo + (k * (hi - lo)) / (GRID_N - 1),\n  );\n});\nconst rawDensityByCategory = valuesByCategory.map((values, i) =>\n  gridByCategory[i].map((gy) => kdeAt(values, bandwidthByCategory[i], gy)),\n);\nconst peakByCategory = rawDensityByCategory.map((raw) => Math.max(...raw));\nconst densityByCategory = rawDensityByCategory.map((raw, i) =>\n  raw.map((v) => v / peakByCategory[i]),\n);\nfunction densityFraction(categoryIndex, value) {\n  return (\n    kdeAt(\n      valuesByCategory[categoryIndex],\n      bandwidthByCategory[categoryIndex],\n      value,\n    ) / peakByCategory[categoryIndex]\n  );\n}\n\n// --- Median tick (per-violin embellishment) and bimodality detection (data\n// storytelling) — both derived straight from the already-computed density\n// grid, no extra passes over the raw samples.\nfunction medianOf(values) {\n  const sorted = [...values].sort((a, b) => a - b);\n  const mid = Math.floor(sorted.length / 2);\n  return sorted.length % 2 === 0\n    ? (sorted[mid - 1] + sorted[mid]) / 2\n    : sorted[mid];\n}\nconst medianByCategory = valuesByCategory.map(medianOf);\n\n// Local maxima of the normalized density curve. Two nearby local maxima are\n// only a real second mode if the valley between them dips well below both —\n// a shallow-valley shoulder (e.g. plain sampling noise on an otherwise\n// unimodal normal) is merged away instead of flagged. That valley-depth\n// check is what separates the genuinely bimodal Sleep-deprived condition\n// from the merely lumpy KDE of the other, unimodal conditions.\nfunction findPeaks(grid, density, minHeight, minSeparation, valleyRatio) {\n  const raw = [];\n  for (let k = 1; k < density.length - 1; k += 1) {\n    if (\n      density[k] >= minHeight &&\n      density[k] >= density[k - 1] &&\n      density[k] >= density[k + 1]\n    ) {\n      raw.push({ idx: k, value: grid[k], height: density[k] });\n    }\n  }\n  const merged = [];\n  raw.forEach((p) => {\n    const close = merged.find(\n      (m) => Math.abs(m.value - p.value) < minSeparation,\n    );\n    if (!close) merged.push(p);\n    else if (p.height > close.height) Object.assign(close, p);\n  });\n  merged.sort((a, b) => a.idx - b.idx);\n\n  let changed = true;\n  while (changed && merged.length > 1) {\n    changed = false;\n    for (let i = 0; i < merged.length - 1; i += 1) {\n      const a = merged[i];\n      const b = merged[i + 1];\n      let valley = Infinity;\n      for (let k = a.idx; k <= b.idx; k += 1) {\n        valley = Math.min(valley, density[k]);\n      }\n      if (valley / Math.min(a.height, b.height) > valleyRatio) {\n        merged.splice(a.height >= b.height ? i + 1 : i, 1);\n        changed = true;\n        break;\n      }\n    }\n  }\n  return merged;\n}\nconst peaksByCategory = gridByCategory.map((grid, i) =>\n  findPeaks(grid, densityByCategory[i], 0.25, 2 * bandwidthByCategory[i], 0.85),\n);\n\n// --- Beeswarm packing, width-limited to the violin's own density envelope at\n// that value — points spread horizontally but never cross the violin boundary.\n// Collisions are resolved in on-screen pixels for even spacing regardless of\n// the value axis' scale.\nconst MARKER_RADIUS = 5;\nconst MARKER_DIAMETER_PX = MARKER_RADIUS * 2 + 1.5;\n\nfunction layoutSwarm(categoryIndex, pxPerValue, violinHalfWidthPx) {\n  const sorted = [...valuesByCategory[categoryIndex]].sort((a, b) => a - b);\n  const placed = [];\n\n  sorted.forEach((value) => {\n    const maxOffset = Math.max(\n      0,\n      densityFraction(categoryIndex, value) * violinHalfWidthPx - MARKER_RADIUS,\n    );\n    const nearby = placed.filter(\n      (p) => Math.abs((value - p.value) * pxPerValue) < MARKER_DIAMETER_PX,\n    );\n\n    let offsetPx = 0;\n    if (nearby.length > 0) {\n      const step = MARKER_DIAMETER_PX * 0.92;\n      let found = false;\n      for (let k = 0; k < 200 && !found; k += 1) {\n        const candidate =\n          k === 0\n            ? 0\n            : (k % 2 === 1 ? Math.ceil(k / 2) : -Math.ceil(k / 2)) * step;\n        if (Math.abs(candidate) > maxOffset) continue;\n        const clear = nearby.every(\n          (p) =>\n            Math.hypot(\n              candidate - p.offsetPx,\n              (value - p.value) * pxPerValue,\n            ) >=\n            MARKER_DIAMETER_PX * 0.95,\n        );\n        if (clear) {\n          offsetPx = candidate;\n          found = true;\n        }\n      }\n      if (!found) offsetPx = clamp(offsetPx, -maxOffset, maxOffset);\n    }\n    placed.push({ value, offsetPx });\n  });\n\n  return placed;\n}\n\n// The community package (7.29.1) has no violin/swarm component. A custom SVG\n// layer positioned via the chart's own band/linear scale hooks reproduces one\n// while staying entirely within the community ChartContainer surface — the\n// documented \"composition\" technique for chart types MUI X doesn't ship.\nfunction ViolinSwarm() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const bandwidth = xScale.bandwidth();\n  const violinHalfWidthPx = bandwidth * 0.4;\n  const pxPerValue = Math.abs(yScale(Y_MAX) - yScale(Y_MIN)) / (Y_MAX - Y_MIN);\n\n  return (\n    <g>\n      {categories.map((cat, i) => {\n        const color = t.palette[i % t.palette.length];\n        const center = xScale(cat) + bandwidth / 2;\n        const density = densityByCategory[i];\n        const catGrid = gridByCategory[i];\n        const leftSide = catGrid.map(\n          (gy, k) => `${center - density[k] * violinHalfWidthPx},${yScale(gy)}`,\n        );\n        const rightSide = catGrid\n          .map(\n            (gy, k) =>\n              `${center + density[k] * violinHalfWidthPx},${yScale(gy)}`,\n          )\n          .reverse();\n        const violinPath = `M${leftSide.join(\" L\")} L${rightSide.join(\" L\")} Z`;\n        const swarm = layoutSwarm(i, pxPerValue, violinHalfWidthPx);\n\n        // Median tick: a short contrasting bar spanning most of the local\n        // density envelope, the standard violin-plot embellishment for\n        // reading off central tendency without the swarm's raw noise.\n        const median = medianByCategory[i];\n        const medianHalfWidth =\n          densityFraction(i, median) * violinHalfWidthPx * 0.85;\n        const medianY = yScale(median);\n\n        // Bimodal callout: when the density curve has a second surviving\n        // peak, tag the smaller mode with a short leader + label so the\n        // shape the swarm already shows gets called out explicitly.\n        const peaks = peaksByCategory[i];\n        const isBimodal = peaks.length > 1;\n        const minorPeak = isBimodal\n          ? [...peaks].sort((a, b) => a.height - b.height)[0]\n          : null;\n\n        return (\n          <g key={cat}>\n            <path\n              d={violinPath}\n              fill={color}\n              fillOpacity={0.38}\n              stroke={color}\n              strokeWidth={1.75}\n              strokeLinejoin=\"round\"\n            />\n            {swarm.map((p) => (\n              <circle\n                key={`${cat}-${p.value.toFixed(3)}`}\n                cx={center + p.offsetPx}\n                cy={yScale(p.value)}\n                r={MARKER_RADIUS}\n                fill={t.ink}\n                fillOpacity={0.85}\n                stroke={t.pageBg}\n                strokeWidth={0.75}\n              />\n            ))}\n            <line\n              x1={center - medianHalfWidth}\n              x2={center + medianHalfWidth}\n              y1={medianY}\n              y2={medianY}\n              stroke={t.pageBg}\n              strokeWidth={2.5}\n              strokeLinecap=\"round\"\n            />\n            {minorPeak && (\n              <g>\n                <line\n                  x1={center + violinHalfWidthPx * minorPeak.height + 3}\n                  x2={center + violinHalfWidthPx + 20}\n                  y1={yScale(minorPeak.value)}\n                  y2={yScale(minorPeak.value)}\n                  stroke={t.inkSoft}\n                  strokeWidth={1}\n                  strokeDasharray=\"2,2\"\n                  opacity={0.7}\n                />\n                <text\n                  x={center + violinHalfWidthPx + 24}\n                  y={yScale(minorPeak.value)}\n                  dy=\"0.32em\"\n                  fontSize={11}\n                  fill={t.inkSoft}\n                  opacity={0.85}\n                >\n                  second mode\n                </text>\n              </g>\n            )}\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\nexport default function Chart() {\n  const chartHeight = window.ANYPLOT_SIZE.height - TITLE_HEIGHT;\n\n  return (\n    <div\n      style={{\n        width: window.ANYPLOT_SIZE.width,\n        height: window.ANYPLOT_SIZE.height,\n      }}\n    >\n      <div\n        style={{\n          height: TITLE_HEIGHT,\n          lineHeight: `${TITLE_HEIGHT}px`,\n          paddingLeft: 24,\n          fontSize: 22,\n          fontWeight: 500,\n          color: t.ink,\n        }}\n      >\n        {TITLE}\n      </div>\n      <ChartContainer\n        width={window.ANYPLOT_SIZE.width}\n        height={chartHeight}\n        series={[]}\n        skipAnimation\n        margin={{ top: 40, right: 50, bottom: 64, left: 90 }}\n        xAxis={[\n          {\n            id: \"conditions\",\n            data: categories,\n            scaleType: \"band\",\n            tickLabelStyle: { fontSize: 14 },\n          },\n        ]}\n        yAxis={[\n          {\n            id: \"reactionTime\",\n            min: Y_MIN,\n            max: Y_MAX,\n            label: \"Reaction Time (ms)\",\n            labelStyle: { fontSize: 16 },\n            tickLabelStyle: { fontSize: 14 },\n            // tickFontSize sizes only the axis-label clearance gap (legacy prop,\n            // overridden visually by tickLabelStyle.fontSize above) — bumped so\n            // the rotated axis label doesn't collide with the tick numbers.\n            tickFontSize: 22,\n          },\n        ]}\n      >\n        <ChartsGrid\n          horizontal\n          sx={{\n            \"& .MuiChartsGrid-line\": {\n              stroke: t.grid,\n              opacity: 0.2,\n            },\n          }}\n        />\n        <ViolinSwarm />\n        <ChartsXAxis axisId=\"conditions\" disableTicks />\n        <ChartsYAxis axisId=\"reactionTime\" />\n      </ChartContainer>\n    </div>\n  );\n}\n"}