{"spec_id":"scatter-regression-polynomial","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nscatter-regression-polynomial: Scatter Plot with Polynomial Regression\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-08-11\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\nfrom scipy import stats\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# Imprint palette\nBRAND = \"#009E73\"  # First series\nACCENT = \"#C475FD\"  # Second series\n\n# Data\nnp.random.seed(42)\nn_points = 80\nx = np.linspace(2, 14, n_points)\ny_true = -2.5 * x**2 + 45 * x - 80\ny = y_true + np.random.randn(n_points) * 12\n\n# Fit polynomial (degree 2), with covariance for the confidence band below\ncoeffs, cov = np.polyfit(x, y, 2, cov=True)\npoly = np.poly1d(coeffs)\n\n# Calculate R²\ny_pred = poly(x)\nss_res = np.sum((y - y_pred) ** 2)\nss_tot = np.sum((y - np.mean(y)) ** 2)\nr_squared = 1 - (ss_res / ss_tot)\n\n# Fitted curve + 95% confidence band around the mean prediction. Per-point\n# variance is v @ cov @ v, where v = [x^2, x, 1] is a row of the Vandermonde\n# design matrix (matches np.polyfit's highest-power-first coefficient order).\nx_curve = np.linspace(x.min(), x.max(), 200)\ny_curve = poly(x_curve)\n\ndof = len(x) - len(coeffs)\nt_val = stats.t.ppf(0.975, dof)\ndesign = np.vander(x_curve, N=len(coeffs))\npred_var = np.einsum(\"ij,jk,ik->i\", design, cov, design)\nse_curve = np.sqrt(pred_var)\nci_upper = y_curve + t_val * se_curve\nci_lower = y_curve - t_val * se_curve\n\n# Polynomial equation, folded into the curve's own legend label so it always\n# renders (a prior attempt added it as a separate empty series, which pygal\n# never draws since it has no data points)\na, b, c = coeffs\nb_sign = \"+\" if b >= 0 else \"-\"\nc_sign = \"+\" if c >= 0 else \"-\"\nequation = f\"y = {a:.2f}x² {b_sign} {abs(b):.2f}x {c_sign} {abs(c):.2f}\"\n\n# pygal has no free-text annotation API, so the R² the spec asks to display\n# \"prominently\" is surfaced in the title itself - the most prominent element\n# pygal offers.\ntitle = (\n    f\"Plant Growth vs. Sunlight Exposure (R² = {r_squared:.3f}) · \"\n    \"scatter-regression-polynomial · python · pygal · anyplot.ai\"\n)\n# Scale the title font linearly off the 67-char mandated-title baseline so the\n# longer descriptive prefix never overflows the canvas (see plot-generator.md).\ntitle_font_size = round(66 * min(1.0, 67 / len(title)))\n\n# Custom style. Series order is [CI band, CI erase layer, Data Points, Fit\n# curve] - see the two chart.add() calls that build the band for why the\n# erase layer must sit between the band and the data points.\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=(INK_MUTED, PAGE_BG, BRAND, ACCENT),\n    title_font_size=title_font_size,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=40,\n    stroke_width=2.5,\n    opacity_hover=\".9\",\n    transition=\"200ms ease-in\",\n)\n\n# Create chart\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    x_title=\"Sunlight Exposure (hours)\",\n    y_title=\"Plant Growth (cm)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=3,\n    legend_box_size=28,\n    dots_size=6,\n    show_x_guides=True,\n    show_y_guides=True,\n    x_label_rotation=0,\n    truncate_legend=-1,\n    margin_bottom=40,\n    # pygal's own `.reactive{fill-opacity/stroke-width}` rule is emitted\n    # scoped to the chart's `#chart-<uuid>` id, which outweighs a plain\n    # `.serie-N .reactive` selector on specificity - `!important` (the same\n    # escape hatch pygal's own stylesheets use, e.g. `.always_show .guide.line`)\n    # is required for a per-series override to actually win. Soften the CI\n    # band (index 0) into translucent shading with a thin edge, and make the\n    # erase layer (index 1) fully opaque so it cleanly carves the band's\n    # lower bound out with no visible seam of its own.\n    css=(\n        \"file://style.css\",\n        \"file://graph.css\",\n        \"inline:.serie-0 .reactive { fill-opacity: 0.25 !important; stroke-width: 1.5 !important; stroke-opacity: 0.4 !important; }\"\n        \" .serie-1 .reactive { fill-opacity: 1 !important; stroke-width: 0 !important; }\",\n    ),\n)\n\n# 95% CI band, built from two ordinary single-curve fills instead of one\n# hand-closed upper+lower polygon: pygal's fill always splices its own\n# baseline-connector segment onto the *first and last vertex* of whatever\n# path it's given, so a closed polygon whose start/end vertex sits at the\n# same x gets that connector added twice at the same x - a stray vertical\n# bar. A plain open curve doesn't have this problem, since its first/last\n# vertices sit at different x values, which is exactly pygal's supported\n# fill shape.\n#\n# So: fill under the upper bound (translucent - the visible \"95% CI Band\"),\n# then fill under the lower bound in the page-background color (title=None -\n# a helper layer, not its own legend entry) to erase everything below it.\n# What's left visible is exactly the band between the two curves.\nchart.add(\n    \"95% CI Band\",\n    [(float(xi), float(yi)) for xi, yi in zip(x_curve, ci_upper, strict=True)],\n    stroke=True,\n    fill=True,\n    show_dots=False,\n)\nchart.add(\n    None,\n    [(float(xi), float(yi)) for xi, yi in zip(x_curve, ci_lower, strict=True)],\n    stroke=True,\n    fill=True,\n    show_dots=False,\n)\n\n# Scatter points - added after the CI band layers so dots stay visible even\n# where the opaque erase layer covers the plot area below the band.\nscatter_data = [(float(x[i]), float(y[i])) for i in range(len(x))]\nchart.add(\"Data Points\", scatter_data, stroke=False, dots_size=6, opacity=0.7)\n\n# Fit curve - solid stroke, clearly distinct from the scatter, equation in the legend label\ncurve_data = [(float(x_curve[i]), float(y_curve[i])) for i in range(len(x_curve))]\nchart.add(f\"Fit: {equation}\", curve_data, stroke=True, show_dots=False, dots_size=0)\n\n# Save\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}