{"spec_id":"violin-grouped-swarm","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nviolin-grouped-swarm: Grouped Violin Plot with Swarm Overlay\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 80/100 | Updated: 2026-05-18\n\"\"\"\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Data - Response times across task types and expertise levels\nnp.random.seed(42)\ncategories = [\"Simple\", \"Medium\", \"Complex\"]\ngroups = [\"Novice\", \"Expert\"]\n\n# Generate realistic response time data (in milliseconds)\ndata = {}\nfor cat in categories:\n    data[cat] = {}\n    if cat == \"Simple\":\n        data[cat][\"Novice\"] = np.random.normal(450, 80, 40)\n        data[cat][\"Expert\"] = np.random.normal(280, 50, 40)\n    elif cat == \"Medium\":\n        data[cat][\"Novice\"] = np.random.normal(850, 150, 40)\n        data[cat][\"Expert\"] = np.random.normal(520, 90, 40)\n    else:  # Complex\n        data[cat][\"Novice\"] = np.random.normal(1400, 250, 40)\n        data[cat][\"Expert\"] = np.random.normal(780, 120, 40)\n\n# Clip to realistic range\nfor cat in categories:\n    for group in groups:\n        data[cat][group] = np.clip(data[cat][group], 100, 2000)\n\n# Colors for groups\nnovice_color = \"#306998\"  # Python Blue\nexpert_color = \"#FFD43B\"  # Python Yellow\nswarm_novice = \"#1a4d75\"  # Darker blue for swarm\nswarm_expert = \"#c9a82c\"  # Darker yellow for swarm\n\n# Custom style for 4800x2700 px canvas\n# Color order: 3 novice violins, 3 expert violins, 12 novice swarm chunks, 12 expert swarm chunks\ncustom_style = Style(\n    background=\"white\",\n    plot_background=\"white\",\n    foreground=\"#333333\",\n    foreground_strong=\"#333333\",\n    foreground_subtle=\"#666666\",\n    guide_stroke_color=\"#e0e0e0\",\n    colors=(novice_color,) * 3 + (expert_color,) * 3 + (swarm_novice,) * 15 + (swarm_expert,) * 15,\n    title_font_size=84,\n    label_font_size=54,\n    major_label_font_size=48,\n    legend_font_size=48,\n    value_font_size=36,\n    opacity=0.4,  # Semi-transparent violins so swarm points show through\n    opacity_hover=0.6,\n)\n\n# Create XY chart for grouped violin plot with swarm\nchart = pygal.XY(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"violin-grouped-swarm · pygal · pyplots.ai\",\n    x_title=\"Task Type\",\n    y_title=\"Response Time (ms)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=2,\n    stroke=True,\n    fill=True,\n    dots_size=0,\n    show_x_guides=False,\n    show_y_guides=True,\n    range=(0, 2100),\n    xrange=(0, 4.5),\n    margin=60,\n)\n\n# Parameters for violin shapes\nviolin_width = 0.25\nn_points = 60\ngroup_offset = 0.35  # Offset between grouped violins\n\n\n# KDE helper function\ndef compute_kde(values, y_range):\n    \"\"\"Compute Gaussian 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    return density\n\n\n# Swarm layout helper - arranges points to avoid overlap\ndef compute_swarm_positions(values, center_x, width=0.15):\n    \"\"\"Compute swarm positions 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 = y_sorted.max() - y_sorted.min()\n    bin_height = y_range / 15 if y_range > 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                offsets = np.linspace(-width / 2, width / 2, n_in_bin) if n_in_bin > 1 else [0]\n                for i, bin_idx in enumerate(current_bin):\n                    positions[bin_idx] = center_x + offsets[i]\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        offsets = np.linspace(-width / 2, width / 2, n_in_bin) if n_in_bin > 1 else [0]\n        for i, bin_idx in enumerate(current_bin):\n            positions[bin_idx] = center_x + offsets[i]\n\n    return positions\n\n\n# Pre-compute all shapes\nnovice_violins = []\nexpert_violins = []\nnovice_swarms = []\nexpert_swarms = []\n\nfor i, category in enumerate(categories):\n    base_x = i + 1.25\n\n    for group in groups:\n        values = data[category][group]\n        offset = -group_offset if group == \"Novice\" else group_offset\n        center_x = base_x + offset\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.15\n        y_range = np.linspace(y_min - padding, y_max + padding, n_points)\n\n        # Compute KDE\n        density = compute_kde(values, y_range)\n\n        # Normalize density to desired width\n        density = density / density.max() * violin_width\n\n        # Create full violin shape (mirrored)\n        left_points = [(center_x - d, y) for y, d in zip(y_range, density, strict=True)]\n        right_points = [(center_x + d, y) for y, d in zip(y_range[::-1], density[::-1], strict=True)]\n        violin_points = left_points + right_points + [left_points[0]]\n\n        # Compute swarm positions\n        swarm_x = compute_swarm_positions(values, center_x, width=violin_width * 0.7)\n        swarm_points = list(zip(swarm_x, values, strict=True))\n\n        if group == \"Novice\":\n            novice_violins.append(violin_points)\n            novice_swarms.extend(swarm_points)\n        else:\n            expert_violins.append(violin_points)\n            expert_swarms.extend(swarm_points)\n\n# Add violins with legend entries for first of each group\nfor i, violin in enumerate(novice_violins):\n    label = \"Novice\" if i == 0 else None\n    chart.add(label, violin, show_dots=False)\n\nfor i, violin in enumerate(expert_violins):\n    label = \"Expert\" if i == 0 else None\n    chart.add(label, violin, show_dots=False)\n\n# Add swarm points as individual series with dots\n# Group swarm points into chunks to reduce number of series\nchunk_size = 10\nnovice_chunks = [novice_swarms[i : i + chunk_size] for i in range(0, len(novice_swarms), chunk_size)]\nexpert_chunks = [expert_swarms[i : i + chunk_size] for i in range(0, len(expert_swarms), chunk_size)]\n\nfor chunk in novice_chunks:\n    chart.add(None, chunk, stroke=False, fill=False, show_dots=True, dots_size=8)\n\nfor chunk in expert_chunks:\n    chart.add(None, chunk, stroke=False, fill=False, show_dots=True, dots_size=8)\n\n# X-axis labels for categories\nchart.x_labels = [\n    {\"value\": 0, \"label\": \"\"},\n    {\"value\": 1.25, \"label\": \"Simple\"},\n    {\"value\": 2.25, \"label\": \"Medium\"},\n    {\"value\": 3.25, \"label\": \"Complex\"},\n    {\"value\": 4.5, \"label\": \"\"},\n]\n\n# Save outputs\nchart.render_to_file(\"plot.html\")\nchart.render_to_png(\"plot.png\")\n"}