Residual Plot in D3.js (JavaScript)

The same plot in 14 other libraries — JavaScript: Chart.js, Apache ECharts, Highcharts, MUI X Charts; Python: Altair, Bokeh, lets-plot, Matplotlib, Plotly, plotnine, Pygal, Seaborn; R: ggplot2; Julia: Makie.jl. Compare all 15 side by side: Residual Plot in Python, R, Julia and JavaScript.

A residual plot displays the difference between observed and predicted values (residuals) against fitted values in regression analysis. This diagnostic visualization helps identify violations of regression assumptions including non-linearity, heteroscedasticity (non-constant variance), and outliers. A well-fitting model shows residuals randomly scattered around zero with no discernible pattern.

Residual Plot rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// residual-plot: Residual Plot
// Library: d3 7.9.0 | JavaScript 22.23.2
// Quality: 90/100 | Created: 2026-09-05

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

// --- Data (deterministic LCG seed=42, building energy-consumption model) ---
let seed = 42;
function lcgRand() {
  seed = (1664525 * seed + 1013904223) >>> 0;
  return seed / 4294967296;
}
function lcgRandn() {
  const u1 = lcgRand() + 1e-10;
  const u2 = lcgRand();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// Predicted monthly energy consumption (kWh) from a regression model, with a
// deliberately heteroscedastic error term — the variance of the residual
// grows with the fitted value, a classic diagnostic finding.
const data = Array.from({ length: 400 }, () => {
  const fitted = 400 + lcgRand() * 4200;
  const noiseScale = 55 + fitted * 0.085;
  const residual = lcgRandn() * noiseScale;
  return { fitted, residual };
});

// --- Reference statistics ---------------------------------------------------
const rmsResidual = Math.sqrt(d3.mean(data, (d) => d.residual ** 2));
const outlierThreshold = 2 * rmsResidual;
data.forEach((d) => {
  d.isOutlier = Math.abs(d.residual) > outlierThreshold;
});

// --- Binned local-mean smoother (LOWESS-style trend of the residual mean) --
const N_BINS = 20;
const fittedMax = d3.max(data, (d) => d.fitted);
const binWidth = fittedMax / N_BINS;
const bins = Array.from({ length: N_BINS }, () => []);
data.forEach((d) => {
  const idx = Math.min(N_BINS - 1, Math.floor(d.fitted / binWidth));
  bins[idx].push(d.residual);
});
const smoothed = bins
  .map((vals, i) => (vals.length ? { fitted: (i + 0.5) * binWidth, residual: d3.mean(vals) } : null))
  .filter((d) => d !== null);

// --- 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, fittedMax * 1.03]).nice().range([0, iw]);

const maxAbsResidual = Math.max(d3.max(data, (d) => Math.abs(d.residual)), outlierThreshold) * 1.15;
const y = d3.scaleLinear().domain([-maxAbsResidual, maxAbsResidual]).nice().range([ih, 0]);

// --- Grid (both axes, floating — no domain line, matches scatter convention)
g.append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(d3.axisBottom(x).ticks(8).tickSize(-ih).tickFormat(""))
  .call((ax) => ax.select(".domain").remove())
  .call((ax) => ax.selectAll("line").attr("stroke", t.grid));

g.append("g")
  .call(d3.axisLeft(y).ticks(7).tickSize(-iw).tickFormat(""))
  .call((ax) => ax.select(".domain").remove())
  .call((ax) => ax.selectAll("line").attr("stroke", t.grid));

// --- +/-2 sigma band (muted fill, identifies the potential-outlier zone) ---
g.append("rect")
  .attr("x", 0)
  .attr("y", y(outlierThreshold))
  .attr("width", iw)
  .attr("height", y(-outlierThreshold) - y(outlierThreshold))
  .attr("fill", t.muted)
  .attr("fill-opacity", 0.1);

g.selectAll(".band-line")
  .data([outlierThreshold, -outlierThreshold])
  .join("line")
  .attr("x1", 0).attr("x2", iw)
  .attr("y1", (d) => y(d)).attr("y2", (d) => y(d))
  .attr("stroke", t.muted)
  .attr("stroke-width", 1.5)
  .attr("stroke-dasharray", "3,4");

g.append("text")
  .attr("x", iw).attr("y", y(outlierThreshold) - 8)
  .attr("text-anchor", "end").attr("fill", t.muted)
  .style("font-size", "14px")
  .text("+2σ");

g.append("text")
  .attr("x", iw).attr("y", y(-outlierThreshold) + 20)
  .attr("text-anchor", "end").attr("fill", t.muted)
  .style("font-size", "14px")
  .text("−2σ");

// --- Zero reference line (perfect-prediction baseline) ----------------------
g.append("line")
  .attr("x1", 0).attr("x2", iw)
  .attr("y1", y(0)).attr("y2", y(0))
  .attr("stroke", t.ink)
  .attr("stroke-width", 2)
  .attr("stroke-dasharray", "7,5")
  .attr("stroke-opacity", 0.6);

g.append("text")
  .attr("x", iw).attr("y", y(0) - 10)
  .attr("text-anchor", "end").attr("fill", t.inkSoft)
  .style("font-size", "14px").style("font-weight", "600")
  .text("y = 0");

// --- Smoother trend line -----------------------------------------------------
g.append("path")
  .datum(smoothed)
  .attr("fill", "none")
  .attr("stroke", t.palette[2])
  .attr("stroke-width", 3)
  .attr("stroke-opacity", 0.85)
  .attr("d", d3.line().x((d) => x(d.fitted)).y((d) => y(d.residual)).curve(d3.curveMonotoneX));

// --- Scatter markers: normal vs. outlier -------------------------------------
g.selectAll("circle")
  .data(data)
  .join("circle")
  .attr("cx", (d) => x(d.fitted))
  .attr("cy", (d) => y(d.residual))
  .attr("r", (d) => (d.isOutlier ? 8 : 6))
  .attr("fill", (d) => (d.isOutlier ? t.palette[4] : t.palette[0]))
  .attr("fill-opacity", (d) => (d.isOutlier ? 0.85 : 0.55))
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 1.2);

// --- Point-color legend (upper-left, low-variance corner stays uncluttered)
const legend = g.append("g").attr("transform", "translate(14,10)");
const legendRows = [
  { label: "Residual", color: t.palette[0] },
  { label: "Outlier (|residual| > 2σ)", color: t.palette[4] },
];
legendRows.forEach((row, i) => {
  const ly = i * 26;
  legend.append("circle").attr("cx", 8).attr("cy", ly).attr("r", 7)
    .attr("fill", row.color).attr("fill-opacity", 0.75);
  legend.append("text").attr("x", 22).attr("y", ly + 5)
    .attr("fill", t.inkSoft).style("font-size", "15px")
    .text(row.label);
});
legend.append("line")
  .attr("x1", 0).attr("x2", 16).attr("y1", 2 * 26).attr("y2", 2 * 26)
  .attr("stroke", t.palette[2]).attr("stroke-width", 3);
legend.append("text").attr("x", 22).attr("y", 2 * 26 + 5)
  .attr("fill", t.inkSoft).style("font-size", "15px")
  .text("Local mean (binned)");

// --- Axes ---------------------------------------------------------------------
const xAxis = g.append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(d3.axisBottom(x).ticks(8).tickFormat((d) => d3.format(",")(d)));
xAxis.select(".domain").attr("stroke", t.inkSoft);
xAxis.selectAll(".tick text").attr("fill", t.inkSoft).style("font-size", "15px");
xAxis.selectAll(".tick line").remove();

const yAxis = g.append("g")
  .call(d3.axisLeft(y).ticks(7).tickFormat((d) => d3.format(",")(d)));
yAxis.select(".domain").attr("stroke", t.inkSoft);
yAxis.selectAll(".tick text").attr("fill", t.inkSoft).style("font-size", "15px");
yAxis.selectAll(".tick line").remove();

// --- Axis labels ---------------------------------------------------------------
svg.append("text")
  .attr("x", margin.left + iw / 2).attr("y", height - 18)
  .attr("text-anchor", "middle").attr("fill", t.inkSoft)
  .style("font-size", "20px")
  .text("Fitted Value — Predicted Energy Consumption (kWh)");

svg.append("text")
  .attr("transform", `translate(36,${margin.top + ih / 2}) rotate(-90)`)
  .attr("text-anchor", "middle").attr("fill", t.inkSoft)
  .style("font-size", "20px")
  .text("Residual — Actual − Predicted (kWh)");

// --- Title -----------------------------------------------------------------
svg.append("text")
  .attr("x", width / 2).attr("y", 48)
  .attr("text-anchor", "middle").attr("fill", t.ink)
  .style("font-size", "26px").style("font-weight", "600")
  .text("residual-plot · javascript · d3 · anyplot.ai");

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/residual-plot/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": "residual-plot",
  "language": "javascript",
  "library": "d3",
  "page": "https://anyplot.ai/residual-plot/javascript/d3",
  "hub": "https://anyplot.ai/residual-plot",
  "code_json": "https://api.anyplot.ai/specs/residual-plot/d3/code",
  "spec_json": "https://api.anyplot.ai/specs/residual-plot",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/residual-plot/javascript/d3/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/residual-plot/javascript/d3/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/residual-plot/javascript/d3/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/residual-plot/javascript/d3/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Residual Plot on anyplot.ai.

Other implementations