{"spec_id":"violin-split","library":"muix","language":"javascript","code":"// anyplot.ai\n// violin-split: Split Violin Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-09\nimport { Box, Typography } from \"@mui/material\";\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;\n\nconst CAT_AXIS_ID = \"category-axis\";\nconst VAL_AXIS_ID = \"value-axis\";\n\n// --- Data (in-memory, deterministic — tiny fixed-seed LCG) ------------------\nfunction makeLcg(seed: number) {\n  let state = seed >>> 0;\n  return function next() {\n    state = (Math.imul(1664525, state) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = makeLcg(20260909);\n\nfunction gaussian(mean: number, std: number) {\n  let u1 = rng();\n  while (u1 <= 1e-12) u1 = rng();\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\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, v));\n}\n\nconst N_PER_GROUP = 140;\nconst SPLIT_GROUPS = [\"Control group\", \"Experimental group\"];\n\nconst CATEGORY_PARAMS = [\n  {\n    name: \"Mathematics\",\n    control: { mean: 68, std: 11 },\n    experimental: { mean: 74, std: 9 },\n  },\n  {\n    name: \"Science\",\n    control: { mean: 74, std: 9 },\n    experimental: { mean: 80, std: 7 },\n  },\n  {\n    name: \"Language Arts\",\n    control: { mean: 76, std: 8 },\n    experimental: { mean: 81, std: 7 },\n  },\n  {\n    name: \"History\",\n    control: { mean: 70, std: 10 },\n    experimental: { mean: 76, std: 8 },\n  },\n];\nconst CATEGORIES = CATEGORY_PARAMS.map((c) => c.name);\n\nconst DATA: Record<string, Record<string, number[]>> = {};\nCATEGORY_PARAMS.forEach(({ name, control, experimental }) => {\n  DATA[name] = {\n    // Only the physical floor (0) is clamped — a hard clamp at 100 would pile\n    // up upper-tail samples on the boundary and flatten the violin's tip.\n    \"Control group\": Array.from({ length: N_PER_GROUP }, () =>\n      clamp(gaussian(control.mean, control.std), 0, Infinity),\n    ),\n    \"Experimental group\": Array.from({ length: N_PER_GROUP }, () =>\n      clamp(gaussian(experimental.mean, experimental.std), 0, Infinity),\n    ),\n  };\n});\n\nconst ALL_VALUES = CATEGORIES.flatMap((cat) =>\n  SPLIT_GROUPS.flatMap((grp) => DATA[cat][grp]),\n);\nconst RAW_MIN = Math.min(...ALL_VALUES);\nconst RAW_MAX = Math.max(...ALL_VALUES);\nconst PAD = (RAW_MAX - RAW_MIN) * 0.1;\nconst Y_MIN = Math.max(0, Math.floor(RAW_MIN - PAD));\n// Test scores don't exceed 100 — cap the axis window there. The underlying\n// data stays unclamped (see above) so the KDE tail decays smoothly into this\n// boundary instead of piling up on it.\nconst Y_MAX = Math.min(100, Math.ceil(RAW_MAX + PAD));\n\nconst GRID_N = 220;\nconst GRID_Y = Array.from(\n  { length: GRID_N },\n  (_, i) => Y_MIN + ((Y_MAX - Y_MIN) * i) / (GRID_N - 1),\n);\n\nconst GROUP_COLORS = [t.palette[0], t.palette[1]];\n\n// --- KDE (Gaussian kernel, Silverman bandwidth) — draws the violin curves ---\nfunction silvermanBandwidth(values: number[]) {\n  const n = values.length;\n  const mean = values.reduce((s, v) => s + v, 0) / n;\n  const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1);\n  const sigma = Math.sqrt(variance) || 1;\n  return Math.max(0.9 * sigma * Math.pow(n, -0.2), 0.6);\n}\n\nfunction kdeCurve(values: number[], gridY: number[]) {\n  const h = silvermanBandwidth(values);\n  const n = values.length;\n  const norm = 1 / (n * h * Math.sqrt(2 * Math.PI));\n  return gridY.map((y) => {\n    let sum = 0;\n    for (let i = 0; i < n; i++) {\n      const u = (y - values[i]) / h;\n      sum += Math.exp(-0.5 * u * u);\n    }\n    return sum * norm;\n  });\n}\n\n// Trims the near-zero density tails so each half pinches to the center spine\n// instead of drawing a near-invisible sliver across the whole y-range.\nfunction trimToSupport(density: number[], thresholdRatio: number) {\n  const max = Math.max(...density);\n  const threshold = max * thresholdRatio;\n  let lo = 0;\n  let hi = density.length - 1;\n  while (lo < hi && density[lo] < threshold) lo++;\n  while (hi > lo && density[hi] < threshold) hi--;\n  lo = Math.max(0, lo - 1);\n  hi = Math.min(density.length - 1, hi + 1);\n  return { lo, hi, max };\n}\n\nfunction quartileStats(values: number[]) {\n  const sorted = [...values].sort((a, b) => a - b);\n  const quantile = (p: number) => {\n    const pos = (sorted.length - 1) * p;\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  };\n  return { q1: quantile(0.25), median: quantile(0.5), q3: quantile(0.75) };\n}\n\n// Largest control-vs-experimental median gap across categories — drives the\n// callout annotation that anchors the data-storytelling focal point.\nconst GAP_INFO = CATEGORIES.map((cat) => {\n  const controlMedian = quartileStats(DATA[cat][\"Control group\"]).median;\n  const experimentalMedian = quartileStats(\n    DATA[cat][\"Experimental group\"],\n  ).median;\n  return {\n    category: cat,\n    gap: experimentalMedian - controlMedian,\n  };\n}).reduce((best, cur) => (Math.abs(cur.gap) > Math.abs(best.gap) ? cur : best));\n\n// --- Custom-drawn split violins (community @mui/x-charts has no built-in\n// violin mark — composed from ChartContainer's cartesian scales instead) ----\nfunction Violins() {\n  const xScale = useXScale(CAT_AXIS_ID);\n  const yScale = useYScale(VAL_AXIS_ID);\n\n  return (\n    <>\n      {CATEGORIES.map((cat) => {\n        const bandStart = xScale(cat);\n        if (bandStart === undefined) return null;\n        const bandwidth = xScale.bandwidth();\n        const cx = bandStart + bandwidth / 2;\n        const halfWidthMax = bandwidth * 0.42;\n\n        const halves = SPLIT_GROUPS.map((grp, gi) => {\n          const values = DATA[cat][grp];\n          const density = kdeCurve(values, GRID_Y);\n          const { lo, hi, max } = trimToSupport(density, 0.01);\n          const side = gi === 0 ? -1 : 1;\n          const color = GROUP_COLORS[gi];\n\n          const points: [number, number][] = [];\n          for (let i = lo; i <= hi; i++) {\n            const w = (density[i] / max) * halfWidthMax;\n            points.push([cx + side * w, yScale(GRID_Y[i]) as number]);\n          }\n          const d =\n            `M ${cx} ${points[0][1]} ` +\n            points.map(([x, y]) => `L ${x} ${y}`).join(\" \") +\n            ` L ${cx} ${points[points.length - 1][1]} Z`;\n\n          const { q1, median, q3 } = quartileStats(values);\n          const markerX = cx + side * 7;\n          // GRID_Y is ascending, so index `hi` is the higher score (top of\n          // chart, smaller pixel y) and `lo` is the lower score (bottom).\n          const yTop = yScale(GRID_Y[hi]) as number;\n          const yBottom = yScale(GRID_Y[lo]) as number;\n\n          return { grp, d, color, q1, median, q3, markerX, yTop, yBottom };\n        });\n\n        // Thin spine anchoring the two halves where they meet, spanning the\n        // combined vertical extent of both violins for this category.\n        const spineTop = Math.min(...halves.flatMap((h) => [h.yTop, h.yBottom]));\n        const spineBottom = Math.max(\n          ...halves.flatMap((h) => [h.yTop, h.yBottom]),\n        );\n\n        const isMaxGap = cat === GAP_INFO.category;\n\n        return (\n          <g key={cat}>\n            <line\n              x1={cx}\n              x2={cx}\n              y1={spineTop}\n              y2={spineBottom}\n              stroke={t.inkSoft}\n              strokeWidth={1}\n              opacity={0.4}\n            />\n            {halves.map(({ grp, d, color, q1, median, q3, markerX }) => (\n              <g key={grp}>\n                <path\n                  d={d}\n                  fill={color}\n                  fillOpacity={0.75}\n                  stroke={color}\n                  strokeWidth={1.5}\n                  strokeOpacity={0.95}\n                  strokeLinejoin=\"round\"\n                />\n                <line\n                  x1={markerX}\n                  x2={markerX}\n                  y1={yScale(q1)}\n                  y2={yScale(q3)}\n                  stroke={t.pageBg}\n                  strokeWidth={5}\n                  strokeLinecap=\"round\"\n                  opacity={0.85}\n                />\n                <circle\n                  cx={markerX}\n                  cy={yScale(median)}\n                  r={4.5}\n                  fill={t.pageBg}\n                  stroke={color}\n                  strokeWidth={1.5}\n                />\n              </g>\n            ))}\n            {isMaxGap && (\n              <text\n                x={cx}\n                y={spineTop - 12}\n                textAnchor=\"middle\"\n                fontSize={13}\n                fontWeight={700}\n                fill={t.ink}\n              >\n                {`Largest gap: ${GAP_INFO.gap >= 0 ? \"+\" : \"\"}${GAP_INFO.gap.toFixed(1)} pt`}\n              </text>\n            )}\n          </g>\n        );\n      })}\n    </>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -----------\nexport default function Chart() {\n  const W = window.ANYPLOT_SIZE.width;\n  const H = window.ANYPLOT_SIZE.height;\n  const TITLE_H = 46;\n  const LEGEND_H = 34;\n  const CHART_H = H - TITLE_H - LEGEND_H;\n\n  return (\n    <Box sx={{ width: W, height: H, display: \"flex\", flexDirection: \"column\" }}>\n      <Typography\n        sx={{\n          height: TITLE_H,\n          lineHeight: `${TITLE_H}px`,\n          fontSize: 22,\n          fontWeight: 700,\n          textAlign: \"center\",\n        }}\n      >\n        violin-split · javascript · muix · anyplot.ai\n      </Typography>\n      <Box\n        sx={{\n          height: LEGEND_H,\n          display: \"flex\",\n          justifyContent: \"center\",\n          alignItems: \"center\",\n          gap: 4,\n        }}\n      >\n        {SPLIT_GROUPS.map((grp, i) => (\n          <Box key={grp} sx={{ display: \"flex\", alignItems: \"center\", gap: 1 }}>\n            <Box\n              sx={{\n                width: 16,\n                height: 16,\n                borderRadius: \"3px\",\n                backgroundColor: GROUP_COLORS[i],\n              }}\n            />\n            <Typography sx={{ fontSize: 16, color: \"text.secondary\" }}>\n              {grp}\n            </Typography>\n          </Box>\n        ))}\n      </Box>\n      <ChartContainer\n        width={W}\n        height={CHART_H}\n        series={[]}\n        skipAnimation\n        margin={{ top: 36, right: 50, bottom: 74, left: 96 }}\n        xAxis={[\n          {\n            id: CAT_AXIS_ID,\n            scaleType: \"band\",\n            data: CATEGORIES,\n            label: \"Subject\",\n            tickLabelStyle: { fontSize: 14 },\n            labelStyle: { fontSize: 16, fontWeight: 600 },\n          },\n        ]}\n        yAxis={[\n          {\n            id: VAL_AXIS_ID,\n            scaleType: \"linear\",\n            min: Y_MIN,\n            max: Y_MAX,\n            label: \"Test score (points)\",\n            tickLabelStyle: { fontSize: 14 },\n            labelStyle: { fontSize: 16, fontWeight: 600 },\n          },\n        ]}\n      >\n        <ChartsGrid horizontal />\n        <Violins />\n        <ChartsXAxis axisId={CAT_AXIS_ID} position=\"bottom\" />\n        <ChartsYAxis axisId={VAL_AXIS_ID} position=\"left\" />\n      </ChartContainer>\n    </Box>\n  );\n}\n"}