Scatter Plot with LOWESS Regression — D3.js

A scatter plot with a LOWESS (Locally Weighted Scatterplot Smoothing) regression curve overlaid. LOWESS is a non-parametric method that fits smooth curves by performing local weighted regressions at each point, adapting to local data patterns without assuming a specific functional form. This makes it ideal for exploring complex relationships where the underlying pattern is unknown or varies across the data range.

Scatter Plot with LOWESS Regression rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// scatter-regression-lowess: Scatter Plot with LOWESS Regression
// Library: d3 7.9.0 | JavaScript 22.23.2
// Quality: 85/100 | Created: 2026-09-09

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 130, right: 50, bottom: 90, left: 100 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Data: nitrogen fertilization rate vs. grain yield ----------------------
// Deterministic LCG so the "random" noise is reproducible across runs.
function makeRng(seed) {
  let state = seed >>> 0;
  return () => {
    state = (Math.imul(1664525, state) + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const rng = makeRng(42);
function randNormal() {
  const u1 = Math.max(rng(), 1e-9);
  const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// True agronomic response: yield rises with applied nitrogen, then plateaus
// and slightly declines past the optimal rate (over-fertilization penalty).
function trueYield(nitrogenRate) {
  return (
    2.0 +
    6.5 * (1 - Math.exp(-nitrogenRate / 70)) -
    0.00004 * nitrogenRate * nitrogenRate
  );
}

const pointCount = 140;
const nitrogenRate = Array.from({ length: pointCount }, () => rng() * 280);
const grainYield = nitrogenRate.map(
  (rate) => Math.max(0.2, trueYield(rate) + randNormal() * 0.65),
);

// --- LOWESS: tricube-weighted local linear regression ------------------------
function lowess(xs, ys, frac) {
  const n = xs.length;
  const k = Math.max(2, Math.round(frac * n));
  return xs.map((xi) => {
    const dists = xs.map((x) => Math.abs(x - xi));
    const bandwidth = [...dists].sort((a, b) => a - b)[k - 1] || 1e-9;
    const weights = dists.map((d) => {
      const u = Math.min(d / bandwidth, 1);
      return Math.pow(1 - Math.pow(u, 3), 3);
    });

    let sw = 0, swx = 0, swy = 0, swxy = 0, swxx = 0;
    for (let j = 0; j < n; j++) {
      const w = weights[j];
      sw += w;
      swx += w * xs[j];
      swy += w * ys[j];
      swxy += w * xs[j] * ys[j];
      swxx += w * xs[j] * xs[j];
    }
    const denom = sw * swxx - swx * swx;
    const slope = Math.abs(denom) < 1e-9 ? 0 : (sw * swxy - swx * swy) / denom;
    const intercept = (swy - slope * swx) / sw;
    return intercept + slope * xi;
  });
}

const smoothingFraction = 0.35;
const lowessFit = lowess(nitrogenRate, grainYield, smoothingFraction);
const fitOrder = d3.range(pointCount).sort((a, b) => nitrogenRate[a] - nitrogenRate[b]);
const fitCurve = fitOrder.map((i) => ({ x: nitrogenRate[i], y: lowessFit[i] }));

// --- SVG mount ----------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Scales ---------------------------------------------------------------
const x = d3.scaleLinear().domain([0, 280]).nice().range([0, iw]);
const y = d3.scaleLinear().domain([0, d3.max(grainYield) * 1.08]).nice().range([ih, 0]);

// --- Gridlines (y-axis only, for a subtler chrome) ---------------------------
g.append("g")
  .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat(""))
  .selectAll("line")
  .attr("stroke", t.grid);
g.selectAll(".domain").remove();

// --- Axes -------------------------------------------------------------------
const xAxis = g.append("g").attr("transform", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(7));
const yAxis = g.append("g").call(d3.axisLeft(y).ticks(6));
for (const axis of [xAxis, yAxis]) {
  axis.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
  axis.selectAll("line").attr("stroke", t.inkSoft);
  axis.select(".domain").attr("stroke", t.inkSoft);
}

// --- Axis labels --------------------------------------------------------------
g.append("text")
  .attr("x", iw / 2).attr("y", ih + 60)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", "18px")
  .text("Nitrogen Applied (kg/ha)");
g.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -ih / 2).attr("y", -70)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", "18px")
  .text("Grain Yield (t/ha)");

// --- Scatter points -----------------------------------------------------------
g.selectAll("circle")
  .data(d3.range(pointCount))
  .join("circle")
  .attr("cx", (i) => x(nitrogenRate[i]))
  .attr("cy", (i) => y(grainYield[i]))
  .attr("r", 6)
  .attr("fill", t.palette[0])
  .attr("fill-opacity", 0.55)
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 1);

// --- LOWESS curve ---------------------------------------------------------
const line = d3.line().x((d) => x(d.x)).y((d) => y(d.y)).curve(d3.curveMonotoneX);
g.append("path")
  .datum(fitCurve)
  .attr("fill", "none")
  .attr("stroke", t.palette[1])
  .attr("stroke-width", 4)
  .attr("stroke-linecap", "round")
  .attr("d", line);

// --- Optimal-rate annotation (data storytelling focal point) ----------------
const peak = fitCurve.reduce((best, d) => (d.y > best.y ? d : best), fitCurve[0]);
g.append("line")
  .attr("x1", x(peak.x)).attr("x2", x(peak.x))
  .attr("y1", y(peak.y)).attr("y2", ih)
  .attr("stroke", t.inkSoft)
  .attr("stroke-width", 1.5)
  .attr("stroke-dasharray", "4,4");
g.append("circle")
  .attr("cx", x(peak.x)).attr("cy", y(peak.y))
  .attr("r", 7)
  .attr("fill", t.pageBg)
  .attr("stroke", t.palette[1])
  .attr("stroke-width", 2.5);
g.append("text")
  .attr("x", x(peak.x)).attr("y", y(peak.y) - 18)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", "14px").style("font-weight", "600")
  .text(`Optimal ≈ ${Math.round(peak.x)} kg/ha`);

// --- Legend -----------------------------------------------------------------
const legend = g.append("g").attr("transform", `translate(${iw - 330},0)`);
legend.append("circle").attr("cx", 8).attr("cy", 0).attr("r", 6)
  .attr("fill", t.palette[0]).attr("fill-opacity", 0.55).attr("stroke", t.pageBg);
legend.append("text").attr("x", 24).attr("y", 5)
  .attr("fill", t.inkSoft).style("font-size", "14px")
  .text("Field observations");
legend.append("line").attr("x1", 0).attr("x2", 16).attr("y1", 28).attr("y2", 28)
  .attr("stroke", t.palette[1]).attr("stroke-width", 4).attr("stroke-linecap", "round");
legend.append("text").attr("x", 24).attr("y", 33)
  .attr("fill", t.inkSoft).style("font-size", "14px")
  .text(`LOWESS fit (frac = ${smoothingFraction})`);

// --- Title --------------------------------------------------------------------
const titleText = "Nitrogen Fertilization Response · scatter-regression-lowess · javascript · d3 · anyplot.ai";
const titleRatio = titleText.length > 67 ? 67 / titleText.length : 1;
const titleFontSize = Math.max(14, Math.round(22 * titleRatio));
svg.append("text")
  .attr("x", width / 2).attr("y", 50)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink).style("font-size", `${titleFontSize}px`).style("font-weight", "600")
  .text(titleText);
svg.append("text")
  .attr("x", width / 2).attr("y", 82)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft).style("font-size", "16px")
  .text("Locally weighted regression smooths a noisy, non-monotonic yield response");

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/scatter-regression-lowess/d3/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "scatter-regression-lowess",
  "language": "javascript",
  "library": "d3",
  "page": "https://anyplot.ai/scatter-regression-lowess/javascript/d3",
  "hub": "https://anyplot.ai/scatter-regression-lowess",
  "code_json": "https://api.anyplot.ai/specs/scatter-regression-lowess/d3/code",
  "spec_json": "https://api.anyplot.ai/specs/scatter-regression-lowess",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/scatter-regression-lowess/javascript/d3/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/scatter-regression-lowess/javascript/d3/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/scatter-regression-lowess/javascript/d3/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/scatter-regression-lowess/javascript/d3/plot-dark.html",
  "quality_score": 85.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations