Scatter Plot with Polynomial Regression — Chart.js

A scatter plot displaying the relationship between two numeric variables with a fitted polynomial regression curve (degree 2-4). This visualization extends beyond linear regression to capture non-linear relationships in data, making it ideal for modeling curved trends, parabolic patterns, and complex data relationships where a straight line would not adequately represent the underlying pattern.

Scatter Plot with Polynomial Regression rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// scatter-regression-polynomial: Scatter Plot with Polynomial Regression
// Library: chartjs 4.4.7 | JavaScript 22.23.1
// Quality: 94/100 | Created: 2026-08-11

const t = window.ANYPLOT_TOKENS;
const THEME = window.ANYPLOT_THEME || "light";
const MUTED = THEME === "light" ? "#6B6A63" : "#A8A79F"; // Imprint muted anchor (theme-adaptive)

// Tiny deterministic LCG + Box-Muller — the browser has no seeded RNG
let seed = 42;
function lcg() {
  seed = (seed * 1664525 + 1013904223) % 4294967296;
  return seed / 4294967296;
}
function gaussian() {
  const u1 = Math.max(lcg(), 1e-9);
  const u2 = lcg();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// Data: advertising spend vs. revenue lift — an inverted-U ("diminishing
// returns then over-saturation") shape that only a degree-2 fit captures.
const N_POINTS = 90;
const points = [];
for (let i = 0; i < N_POINTS; i++) {
  const spend = 2 + 96 * (i / (N_POINTS - 1)) + (lcg() - 0.5) * 3;
  const trueLift = -0.045 * spend * spend + 5.2 * spend + 15;
  const lift = trueLift + gaussian() * 14;
  points.push({ x: spend, y: lift });
}

// Least-squares polynomial fit (degree 2) via normal equations
function polyfit(pts, degree) {
  const cols = degree + 1;
  const XtX = Array.from({ length: cols }, () => new Array(cols).fill(0));
  const Xty = new Array(cols).fill(0);
  for (const { x, y } of pts) {
    const powers = new Array(cols);
    let p = 1;
    for (let k = 0; k < cols; k++) {
      powers[k] = p;
      p *= x;
    }
    for (let i = 0; i < cols; i++) {
      Xty[i] += powers[i] * y;
      for (let j = 0; j < cols; j++) XtX[i][j] += powers[i] * powers[j];
    }
  }
  // Gaussian elimination with partial pivoting
  const M = XtX.map((row, i) => [...row, Xty[i]]);
  for (let col = 0; col < cols; col++) {
    let pivotRow = col;
    for (let r = col + 1; r < cols; r++) {
      if (Math.abs(M[r][col]) > Math.abs(M[pivotRow][col])) pivotRow = r;
    }
    [M[col], M[pivotRow]] = [M[pivotRow], M[col]];
    const pivot = M[col][col];
    for (let c = col; c <= cols; c++) M[col][c] /= pivot;
    for (let r = 0; r < cols; r++) {
      if (r === col) continue;
      const factor = M[r][col];
      for (let c = col; c <= cols; c++) M[r][c] -= factor * M[col][c];
    }
  }
  return M.map((row) => row[cols]);
}

const [c0, c1, c2] = polyfit(points, 2);
const predict = (x) => c0 + c1 * x + c2 * x * x;

// Goodness of fit + residual spread (for the prediction band)
const yMean = points.reduce((s, p) => s + p.y, 0) / points.length;
let ssRes = 0;
let ssTot = 0;
for (const { x, y } of points) {
  ssRes += (y - predict(x)) ** 2;
  ssTot += (y - yMean) ** 2;
}
const r2 = 1 - ssRes / ssTot;
const residualStd = Math.sqrt(ssRes / (points.length - 3));

// Fitted curve + ±1.96σ prediction band, sampled on a fine grid
const xMin = Math.min(...points.map((p) => p.x));
const xMax = Math.max(...points.map((p) => p.x));
const CURVE_STEPS = 60;
const curve = [];
const bandUpper = [];
const bandLower = [];
for (let i = 0; i <= CURVE_STEPS; i++) {
  const x = xMin + ((xMax - xMin) * i) / CURVE_STEPS;
  const yHat = predict(x);
  curve.push({ x, y: yHat });
  bandUpper.push({ x, y: yHat + 1.96 * residualStd });
  bandLower.push({ x, y: yHat - 1.96 * residualStd });
}

function hexToRgba(hex, alpha) {
  const clean = hex.replace("#", "");
  const r = parseInt(clean.substring(0, 2), 16);
  const g = parseInt(clean.substring(2, 4), 16);
  const b = parseInt(clean.substring(4, 6), 16);
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

const signedTerm = (value, symbol) =>
  `${value >= 0 ? "+" : "−"} ${Math.abs(value).toFixed(4)}${symbol}`;
const equation = `y = ${c2.toFixed(4)}x² ${signedTerm(c1, "x")} ${signedTerm(c0, "")}`;
const BAND_SWATCH = hexToRgba(MUTED, 0.55); // opaque enough to read in the legend chip (fill itself stays soft at 0.18)

// Mount
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

// Inline plugin: equation + R² badge, drawn inside the plot area
const fitBadge = {
  id: "fitBadge",
  afterDraw(chart) {
    const { ctx, chartArea } = chart;
    const text1 = equation;
    const text2 = `R² = ${r2.toFixed(3)}`;

    ctx.save();
    ctx.font = "600 20px sans-serif";
    const w1 = ctx.measureText(text1).width;
    ctx.font = "600 22px sans-serif";
    const w2 = ctx.measureText(text2).width;
    const boxW = Math.max(w1, w2) + 40;
    const boxH = 84;
    const boxX = chartArea.right - boxW - 24;
    const boxY = chartArea.top + 24;

    ctx.fillStyle = t.elevatedBg;
    ctx.strokeStyle = t.grid;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.roundRect(boxX, boxY, boxW, boxH, 10);
    ctx.fill();
    ctx.stroke();

    ctx.textBaseline = "middle";
    ctx.fillStyle = t.inkSoft;
    ctx.font = "600 20px sans-serif";
    ctx.fillText(text1, boxX + 20, boxY + 30);
    ctx.fillStyle = t.ink;
    ctx.font = "600 22px sans-serif";
    ctx.fillText(text2, boxX + 20, boxY + 62);
    ctx.restore();
  },
};

// Chart — scatter points + prediction band (behind) + fitted curve (on top)
new Chart(canvas, {
  type: "scatter",
  plugins: [fitBadge],
  data: {
    datasets: [
      {
        label: "_bandUpper",
        type: "line",
        data: bandUpper,
        borderWidth: 0,
        pointRadius: 0,
        fill: false,
      },
      {
        label: "95% prediction band",
        type: "line",
        data: bandLower,
        borderWidth: 0,
        pointRadius: 0,
        backgroundColor: hexToRgba(MUTED, 0.18),
        fill: "-1",
      },
      {
        label: "Ad campaigns (spend vs. revenue lift)",
        data: points,
        backgroundColor: hexToRgba(t.palette[0], 0.65),
        borderColor: t.pageBg,
        borderWidth: 1,
        pointRadius: 7,
        pointHoverRadius: 7,
      },
      {
        label: "Quadratic fit (degree 2)",
        type: "line",
        data: curve,
        borderColor: t.palette[1],
        backgroundColor: "transparent",
        borderWidth: 3.5,
        pointRadius: 0,
        tension: 0.2,
        fill: false,
      },
    ],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: { padding: { top: 8, right: 16 } },
    plugins: {
      title: {
        display: true,
        text: "scatter-regression-polynomial · javascript · chartjs · anyplot.ai",
        color: t.ink,
        font: { size: 26 },
        padding: { top: 10, bottom: 20 },
      },
      // Legend lives below the plot (not stacked under the title) so the top
      // region stays open for just the title + equation badge.
      legend: {
        position: "bottom",
        labels: {
          color: t.ink,
          font: { size: 16 },
          usePointStyle: true,
          pointStyle: "circle",
          boxWidth: 10,
          padding: 24,
          filter: (item) => !item.text.startsWith("_"),
          generateLabels(chart) {
            const items = Chart.defaults.plugins.legend.labels.generateLabels(chart);
            for (const item of items) {
              if (item.text === "95% prediction band") {
                item.fillStyle = BAND_SWATCH;
                item.strokeStyle = BAND_SWATCH;
              }
            }
            return items;
          },
        },
      },
      tooltip: {
        callbacks: {
          label(ctx) {
            if (ctx.dataset.label !== "Ad campaigns (spend vs. revenue lift)") {
              return ctx.dataset.label;
            }
            const { x, y } = ctx.raw;
            const residual = y - predict(x);
            const sign = residual >= 0 ? "+" : "−";
            return `spend $${x.toFixed(1)}k → lift $${y.toFixed(1)}k (${sign}${Math.abs(residual).toFixed(1)} vs. fit)`;
          },
        },
      },
    },
    scales: {
      x: {
        title: {
          display: true,
          text: "Advertising Spend ($ thousands)",
          color: t.ink,
          font: { size: 18 },
        },
        ticks: { color: t.inkSoft, font: { size: 14 } },
        grid: { color: t.grid },
        border: { display: false },
      },
      y: {
        title: {
          display: true,
          text: "Revenue Lift ($ thousands)",
          color: t.ink,
          font: { size: 18 },
        },
        ticks: { color: t.inkSoft, font: { size: 14 } },
        grid: { color: t.grid },
        border: { display: false },
      },
    },
  },
});

Part of Scatter Plot with Polynomial Regression on anyplot.ai.

Other implementations