{"spec_id":"raincloud-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nraincloud-basic: Basic Raincloud Plot\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-05-26\n\"\"\"\n\nimport os\nimport re\n\nimport cairosvg\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_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Box-interior fill: in light mode the off-white reads as a hollow box; in dark\n# mode PAGE_BG blends into the canvas, so blend toward INK to keep the box\n# distinct from the surrounding plot background.\nBOX_FILL = \"#FAF8F1\" if THEME == \"light\" else \"#2E2E29\"\n\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# Data — reaction times (ms) for three treatment groups\nnp.random.seed(42)\ndata = {\n    \"Control\": np.random.normal(450, 80, 90),\n    \"Treatment A\": np.random.normal(380, 60, 90),\n    \"Treatment B\": np.random.normal(320, 50, 90),\n}\ngroup_colors = IMPRINT_PALETTE[: len(data)]\n\n# X-axis bounds with small padding\nall_vals = np.concatenate(list(data.values()))\nx_lo = float(np.floor((all_vals.min() - 25) / 50) * 50)\nx_hi = float(np.ceil((all_vals.max() + 25) / 50) * 50)\n\n# pygal renders one color per series; build the per-series color list so each\n# layer (rain, box outline, median line) matches the group color.\nseries_colors = []\nfor gc in group_colors:\n    series_colors.extend([gc, gc, gc])\n\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=tuple(series_colors),\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n)\n\nCANVAS_W, CANVAS_H = 3200, 1800\nMARGIN_LEFT = 340\nMARGIN_RIGHT = 90\nMARGIN_TOP = 150\nMARGIN_BOTTOM = 160\n\nchart = pygal.XY(\n    width=CANVAS_W,\n    height=CANVAS_H,\n    style=custom_style,\n    title=\"raincloud-basic · python · pygal · anyplot.ai\",\n    x_title=\"Reaction Time (ms)\",\n    y_title=\"Treatment Group\",\n    show_legend=False,\n    stroke=True,\n    fill=False,\n    dots_size=0,\n    show_x_guides=True,\n    show_y_guides=False,\n    xrange=(x_lo, x_hi),\n    range=(0.0, 4.0),\n    margin=40,\n    margin_left=MARGIN_LEFT,\n    margin_right=MARGIN_RIGHT,\n    margin_top=MARGIN_TOP,\n    margin_bottom=MARGIN_BOTTOM,\n    explicit_size=True,\n)\n\n# Raincloud layout\ncloud_height = 0.36\nrain_offset = -0.36\nrain_spread = 0.10\nn_kde_points = 96\nbox_hw = 0.13\n\ncloud_polygons = []\nbox_specs = []\nmedians = {}\n\nfor i, (category, values) in enumerate(data.items()):\n    center_y = i + 1\n    values = np.array(values)\n\n    # Silverman bandwidth for half-violin KDE\n    n = len(values)\n    std = float(np.std(values))\n    iqr_val = float(np.percentile(values, 75) - np.percentile(values, 25))\n    bandwidth = 0.9 * min(std, iqr_val / 1.34) * n ** (-0.2)\n\n    pad = (values.max() - values.min()) * 0.08\n    x_kde = np.linspace(values.min() - pad, values.max() + pad, n_kde_points)\n    density = np.zeros_like(x_kde)\n    for v in values:\n        density += np.exp(-0.5 * ((x_kde - v) / bandwidth) ** 2)\n    density /= n * bandwidth * np.sqrt(2 * np.pi)\n\n    # Trim KDE tails at 5% of peak for cleaner cloud edges\n    peak = density.max()\n    keep = np.where(density > peak * 0.05)[0]\n    x_kde = x_kde[keep[0] : keep[-1] + 1]\n    density = density[keep[0] : keep[-1] + 1]\n    density_scaled = density / density.max() * cloud_height\n\n    # Cloud polygon: baseline → top curve → baseline\n    poly = [(float(x_kde[0]), float(center_y))]\n    poly += [(float(x), float(center_y + d)) for x, d in zip(x_kde, density_scaled, strict=True)]\n    poly.append((float(x_kde[-1]), float(center_y)))\n    cloud_polygons.append((group_colors[i], poly))\n\n    # Rain: jittered points below baseline\n    rng = np.random.default_rng(42 + i)\n    jitter = rng.uniform(-rain_spread, rain_spread, len(values))\n    rain_points = [\n        {\"value\": (float(v), center_y + rain_offset + float(j)), \"label\": f\"{category}: {v:.0f} ms\"}\n        for j, v in zip(jitter, values, strict=True)\n    ]\n    chart.add(f\"{category} rain\", rain_points, stroke=False, fill=False, dots_size=22)\n\n    # Box plot statistics\n    median = float(np.median(values))\n    q1 = float(np.percentile(values, 25))\n    q3 = float(np.percentile(values, 75))\n    iqr = q3 - q1\n    w_lo = float(max(values.min(), q1 - 1.5 * iqr))\n    w_hi = float(min(values.max(), q3 + 1.5 * iqr))\n    medians[category] = median\n    box_specs.append((group_colors[i], center_y, q1, q3, median, w_lo, w_hi))\n\n    # Whisker line (added via pygal so the box has a native-rendered backbone)\n    chart.add(\n        \"\", [(w_lo, center_y), (w_hi, center_y)], stroke=True, fill=False, show_dots=False, stroke_style={\"width\": 4}\n    )\n    # Median rule (drawn as a vertical line inside the box via two XY points)\n    chart.add(\n        \"\",\n        [(median, center_y - box_hw * 1.05), (median, center_y + box_hw * 1.05)],\n        stroke=True,\n        fill=False,\n        show_dots=False,\n        stroke_style={\"width\": 10},\n    )\n\nchart.y_labels = [\n    {\"value\": 0.0, \"label\": \"\"},\n    {\"value\": 1.0, \"label\": \"Control\"},\n    {\"value\": 2.0, \"label\": \"Treatment A\"},\n    {\"value\": 3.0, \"label\": \"Treatment B\"},\n    {\"value\": 4.0, \"label\": \"\"},\n]\n\n# Render base SVG\nbase_svg = chart.render().decode(\"utf-8\")\n\n# Extract the actual plot-area transform + dimensions from the rendered SVG\n# (pygal computes them from title/axis-label space, so reading them back beats\n# trying to predict them).\nplot_g = re.search(r'<g\\s+transform=\"translate\\(([0-9.]+)[,\\s]+([0-9.]+)\\)\"\\s+class=\"plot\">', base_svg)\nplot_tx, plot_ty = float(plot_g.group(1)), float(plot_g.group(2))\n\nplot_rect = re.search(\n    r'class=\"plot\">.*?<rect[^>]*width=\"([0-9.]+)\"[^>]*height=\"([0-9.]+)\"'\n    r'[^>]*class=\"background\"',\n    base_svg,\n    re.DOTALL,\n)\nplot_w, plot_h = float(plot_rect.group(1)), float(plot_rect.group(2))\n\n# Data → plot-local SVG pixel mapping (SVG y is inverted relative to data y)\nx_scale = plot_w / (x_hi - x_lo)\nx_offset = -x_lo * x_scale\ny_data_lo, y_data_hi = 0.0, 4.0\ny_scale = -plot_h / (y_data_hi - y_data_lo)\ny_offset = -y_data_hi * y_scale\n\n\ndef to_svg(px, py):\n    return px * x_scale + x_offset, py * y_scale + y_offset\n\n\n# Cloud polygons — injected inside the plot group (uses plot-local coords).\n# Fill is the full polygon (closed back to baseline); the stroke is drawn\n# separately as a polyline along only the curved top edge so the baseline\n# closure doesn't show as a visible horizontal line.\nclouds_svg = '<g class=\"raincloud-clouds\">'\nfor color, poly in cloud_polygons:\n    pts = \" \".join(f\"{x:.1f},{y:.1f}\" for x, y in (to_svg(px, py) for px, py in poly))\n    # Curve-only stroke: skip the first and last vertices (the baseline endpoints).\n    curve_pts = \" \".join(f\"{x:.1f},{y:.1f}\" for x, y in (to_svg(px, py) for px, py in poly[1:-1]))\n    clouds_svg += (\n        f'<polygon points=\"{pts}\" fill=\"{color}\" fill-opacity=\"0.55\" stroke=\"none\"/>'\n        f'<polyline points=\"{curve_pts}\" fill=\"none\" '\n        f'stroke=\"{color}\" stroke-width=\"2.5\" stroke-opacity=\"0.9\"/>'\n    )\nclouds_svg += \"</g>\"\n\nbg_marker = 'class=\"background\"'\nfirst_bg = base_svg.find(bg_marker)\nsecond_bg = base_svg.find(bg_marker, first_bg + 1)\nbg_end = base_svg.find(\"/>\", second_bg) + 2\nbase_svg = base_svg[:bg_end] + clouds_svg + base_svg[bg_end:]\n\n# Box-plot rectangles — drawn in absolute SVG coords (outside plot group)\nboxes_svg = '<g class=\"raincloud-boxes\">'\nfor color, cy, q1, q3, median, _w_lo, _w_hi in box_specs:\n    bx1, by1 = to_svg(q1, cy + box_hw)\n    bx2, by2 = to_svg(q3, cy - box_hw)\n    bx, by = bx1 + plot_tx, by1 + plot_ty\n    bw, bh = bx2 - bx1, by2 - by1\n    mx, _ = to_svg(median, cy)\n    mx += plot_tx\n    box_top_y = by\n    box_bot_y = by + bh\n    boxes_svg += (\n        f'<rect x=\"{bx:.1f}\" y=\"{by:.1f}\" width=\"{bw:.1f}\" height=\"{bh:.1f}\" '\n        f'fill=\"{BOX_FILL}\" fill-opacity=\"0.92\" '\n        f'stroke=\"{color}\" stroke-width=\"5\"/>'\n        f'<line x1=\"{mx:.1f}\" y1=\"{box_top_y:.1f}\" '\n        f'x2=\"{mx:.1f}\" y2=\"{box_bot_y:.1f}\" '\n        f'stroke=\"{color}\" stroke-width=\"8\" stroke-linecap=\"round\"/>'\n    )\nboxes_svg += \"</g>\"\n\nsvg_out = base_svg.replace(\"</svg>\", f\"{boxes_svg}\\n</svg>\")\n\n# Save outputs\nwith open(f\"plot-{THEME}.svg\", \"w\") as f:\n    f.write(svg_out)\n\ncairosvg.svg2png(\n    bytestring=svg_out.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", output_width=CANVAS_W, output_height=CANVAS_H\n)\n\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(svg_out.encode(\"utf-8\"))\n"}