{"spec_id":"scatter-regression-linear","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nscatter-regression-linear: Scatter Plot with Linear Regression\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-08-05\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\"  # Imprint 'muted' anchor: confidence-band fill\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# Data - advertising spend vs revenue\nnp.random.seed(42)\nn_points = 90\nx = np.random.uniform(5, 60, n_points)  # ad spend, $K\ny = 35 + 4.6 * x + np.random.normal(0, 18, n_points)  # revenue, $K\n\n# Linear regression + 95% confidence band\nslope, intercept, r_value, p_value, std_err = stats.linregress(x, y)\nr_squared = r_value**2\n\nx_line = np.linspace(x.min(), x.max(), 100)\ny_line = slope * x_line + intercept\n\nn = len(x)\nx_mean = x.mean()\nresidual_std = np.sqrt(np.sum((y - (slope * x + intercept)) ** 2) / (n - 2))\nt_val = stats.t.ppf(0.975, n - 2)\nse_line = residual_std * np.sqrt(1 / n + (x_line - x_mean) ** 2 / np.sum((x - x_mean) ** 2))\nci_upper = y_line + t_val * se_line\nci_lower = y_line - t_val * se_line\n\nequation = f\"y = {slope:.2f}x + {intercept:.1f}\"\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 - and the fit equation is folded into the regression line's\n# legend label.\ntitle = (\n    f\"Advertising Spend vs. Revenue (R² = {r_squared:.3f}) · scatter-regression-linear · 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\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    # Series order below is [CI band, CI erase layer, Data Points, Regression\n    # Line] - 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.\n    colors=(INK_MUTED, PAGE_BG, IMPRINT[0], IMPRINT[1]),\n    title_font_size=title_font_size,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=38,\n    dot_opacity=0.65,  # spec: scatter points at moderate transparency (~0.6-0.7)\n    # pygal derives every line's rendered stroke-width from this single style\n    # token (per-series `stroke_style={\"width\": ...}` only styles the series'\n    # <g> wrapper, which the line path's own class overrides) - set it bold\n    # enough that the regression line reads as clearly thicker than the dots.\n    stroke_width=6,\n)\n\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    x_title=\"Advertising Spend ($K)\",\n    y_title=\"Revenue ($K)\",\n    show_legend=True,\n    legend_at_bottom=True,  # top-left legend reserved a tall dead-space column; a bottom row lets the plot use the full canvas width\n    legend_at_bottom_columns=3,\n    legend_box_size=28,\n    dots_size=14,\n    stroke=False,\n    show_x_guides=True,\n    show_y_guides=True,\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, see chart.add() calls) fully opaque so it cleanly\n    # carves the band's 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.3 !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# plot's leftmost x (as the manual-loop version did) gets that connector\n# added twice at the same x - the stray vertical bar from attempt 2. A plain\n# open curve doesn't have this problem, since its first/last vertices are at\n# different x values, which is exactly pygal's supported 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_line, 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_line, 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's lower bound.\nchart.add(\"Data Points\", [{\"value\": (float(xi), float(yi))} for xi, yi in zip(x, y, strict=True)])\n\n# Regression line - solid stroke in a contrasting color, equation in the label\nchart.add(\n    f\"Regression Line ({equation})\",\n    [(float(xi), float(yi)) for xi, yi in zip(x_line, y_line, strict=True)],\n    stroke=True,\n    show_dots=False,\n)\n\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}