{"spec_id":"violin-swarm","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nviolin-swarm: Violin Plot with Overlaid Swarm Points\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-18\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Okabe-Ito palette - first series is always #009E73\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\")\n\n# Data - Reaction times (ms) across 4 experimental conditions\nnp.random.seed(42)\ndata = {\n    \"Control\": np.random.normal(320, 60, 50),\n    \"Treatment A\": np.random.normal(280, 45, 50),\n    \"Treatment B\": np.random.normal(250, 55, 50),\n    \"Treatment C\": np.random.normal(290, 70, 50),\n}\n\n# Clip to realistic range (100-600ms)\nfor key in data:\n    data[key] = np.clip(data[key], 100, 600)\n\n# Custom style for theme-adaptive rendering\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT,\n    title_font_size=28,\n    label_font_size=22,\n    major_label_font_size=18,\n    legend_font_size=16,\n    value_font_size=14,\n    opacity=0.35,\n    stroke_width=3,\n)\n\n# Create XY chart for violin plot with swarm overlay\nchart = pygal.XY(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"violin-swarm · Python · pygal · anyplot.ai\",\n    x_title=\"Experimental Condition\",\n    y_title=\"Reaction Time (ms)\",\n    show_legend=False,\n    stroke=True,\n    fill=True,\n    dots_size=0,\n    show_x_guides=False,\n    show_y_guides=True,\n    range=(50, 550),\n    xrange=(0, 6),\n    margin=80,\n)\n\n# Parameters for violin shapes\nviolin_width = 0.4\nn_points = 80\n\n# Process each category\nall_violins = []\nall_swarms = []\n\nfor i, (_category, values) in enumerate(data.items()):\n    center_x = i + 1.5\n\n    # Create range of y values for density\n    y_min, y_max = values.min(), values.max()\n    padding = (y_max - y_min) * 0.2\n    y_range = np.linspace(y_min - padding, y_max + padding, n_points)\n\n    # Compute KDE using Silverman's rule\n    n = len(values)\n    std = np.std(values)\n    iqr = np.percentile(values, 75) - np.percentile(values, 25)\n    bandwidth = 0.9 * min(std, iqr / 1.34) * n ** (-0.2)\n\n    density = np.zeros_like(y_range)\n    for v in values:\n        density += np.exp(-0.5 * ((y_range - v) / bandwidth) ** 2)\n    density /= n * bandwidth * np.sqrt(2 * np.pi)\n\n    # Normalize density to desired width\n    density = density / density.max() * violin_width\n\n    # Create violin shape (mirrored density)\n    left_points = [(center_x - d, y) for y, d in zip(y_range, density, strict=False)]\n    right_points = [(center_x + d, y) for y, d in zip(y_range[::-1], density[::-1], strict=False)]\n    violin_points = left_points + right_points + [left_points[0]]\n\n    all_violins.append(violin_points)\n\n    # Compute swarm positions - arrange points to minimize overlap\n    sorted_indices = np.argsort(values)\n    positions = np.zeros(len(values))\n\n    # Bin values and offset within bins\n    y_sorted = values[sorted_indices]\n    y_range_data = y_sorted.max() - y_sorted.min()\n    bin_height = y_range_data / 12 if y_range_data > 0 else 1\n\n    current_bin = []\n    current_bin_y = y_sorted[0] if len(y_sorted) > 0 else 0\n\n    for idx, y in enumerate(y_sorted):\n        if y - current_bin_y > bin_height:\n            # Process current bin - spread points horizontally\n            n_in_bin = len(current_bin)\n            if n_in_bin > 0:\n                if n_in_bin > 1:\n                    offsets = np.linspace(-violin_width / 2.5, violin_width / 2.5, n_in_bin)\n                else:\n                    offsets = [0]\n                for j, bin_idx in enumerate(current_bin):\n                    positions[bin_idx] = center_x + offsets[j]\n            current_bin = [sorted_indices[idx]]\n            current_bin_y = y\n        else:\n            current_bin.append(sorted_indices[idx])\n\n    # Process last bin\n    n_in_bin = len(current_bin)\n    if n_in_bin > 0:\n        if n_in_bin > 1:\n            offsets = np.linspace(-violin_width / 2.5, violin_width / 2.5, n_in_bin)\n        else:\n            offsets = [0]\n        for j, bin_idx in enumerate(current_bin):\n            positions[bin_idx] = center_x + offsets[j]\n\n    swarm_points = list(zip(positions, values, strict=False))\n    all_swarms.extend(swarm_points)\n\n# Add violins first (filled, semi-transparent)\nfor violin in all_violins:\n    chart.add(None, violin, show_dots=False)\n\n# Add swarm points as chunked series with visible dots\nchunk_size = 8\nswarm_chunks = [all_swarms[i : i + chunk_size] for i in range(0, len(all_swarms), chunk_size)]\n\nfor chunk in swarm_chunks:\n    chart.add(None, chunk, stroke=False, fill=False, show_dots=True, dots_size=12)\n\n# X-axis labels at violin positions\nchart.x_labels = [\n    {\"value\": 0, \"label\": \"\"},\n    {\"value\": 1.5, \"label\": \"Control\"},\n    {\"value\": 2.5, \"label\": \"Treatment A\"},\n    {\"value\": 3.5, \"label\": \"Treatment B\"},\n    {\"value\": 4.5, \"label\": \"Treatment C\"},\n    {\"value\": 6, \"label\": \"\"},\n]\n\n# Save outputs with theme-suffixed filenames\nchart.render_to_file(f\"plot-{THEME}.html\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n"}