{"spec_id":"residual-plot","library":"d3","language":"javascript","code":"// anyplot.ai\n// residual-plot: Residual Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 90, right: 70, bottom: 90, left: 120 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (deterministic LCG seed=42, building energy-consumption model) ---\nlet seed = 42;\nfunction lcgRand() {\n  seed = (1664525 * seed + 1013904223) >>> 0;\n  return seed / 4294967296;\n}\nfunction lcgRandn() {\n  const u1 = lcgRand() + 1e-10;\n  const u2 = lcgRand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// Predicted monthly energy consumption (kWh) from a regression model, with a\n// deliberately heteroscedastic error term — the variance of the residual\n// grows with the fitted value, a classic diagnostic finding.\nconst data = Array.from({ length: 400 }, () => {\n  const fitted = 400 + lcgRand() * 4200;\n  const noiseScale = 55 + fitted * 0.085;\n  const residual = lcgRandn() * noiseScale;\n  return { fitted, residual };\n});\n\n// --- Reference statistics ---------------------------------------------------\nconst rmsResidual = Math.sqrt(d3.mean(data, (d) => d.residual ** 2));\nconst outlierThreshold = 2 * rmsResidual;\ndata.forEach((d) => {\n  d.isOutlier = Math.abs(d.residual) > outlierThreshold;\n});\n\n// --- Binned local-mean smoother (LOWESS-style trend of the residual mean) --\nconst N_BINS = 20;\nconst fittedMax = d3.max(data, (d) => d.fitted);\nconst binWidth = fittedMax / N_BINS;\nconst bins = Array.from({ length: N_BINS }, () => []);\ndata.forEach((d) => {\n  const idx = Math.min(N_BINS - 1, Math.floor(d.fitted / binWidth));\n  bins[idx].push(d.residual);\n});\nconst smoothed = bins\n  .map((vals, i) => (vals.length ? { fitted: (i + 0.5) * binWidth, residual: d3.mean(vals) } : null))\n  .filter((d) => d !== null);\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales -------------------------------------------------------------------\nconst x = d3.scaleLinear().domain([0, fittedMax * 1.03]).nice().range([0, iw]);\n\nconst maxAbsResidual = Math.max(d3.max(data, (d) => Math.abs(d.residual)), outlierThreshold) * 1.15;\nconst y = d3.scaleLinear().domain([-maxAbsResidual, maxAbsResidual]).nice().range([ih, 0]);\n\n// --- Grid (both axes, floating — no domain line, matches scatter convention)\ng.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(8).tickSize(-ih).tickFormat(\"\"))\n  .call((ax) => ax.select(\".domain\").remove())\n  .call((ax) => ax.selectAll(\"line\").attr(\"stroke\", t.grid));\n\ng.append(\"g\")\n  .call(d3.axisLeft(y).ticks(7).tickSize(-iw).tickFormat(\"\"))\n  .call((ax) => ax.select(\".domain\").remove())\n  .call((ax) => ax.selectAll(\"line\").attr(\"stroke\", t.grid));\n\n// --- +/-2 sigma band (muted fill, identifies the potential-outlier zone) ---\ng.append(\"rect\")\n  .attr(\"x\", 0)\n  .attr(\"y\", y(outlierThreshold))\n  .attr(\"width\", iw)\n  .attr(\"height\", y(-outlierThreshold) - y(outlierThreshold))\n  .attr(\"fill\", t.muted)\n  .attr(\"fill-opacity\", 0.1);\n\ng.selectAll(\".band-line\")\n  .data([outlierThreshold, -outlierThreshold])\n  .join(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d)).attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.muted)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"3,4\");\n\ng.append(\"text\")\n  .attr(\"x\", iw).attr(\"y\", y(outlierThreshold) - 8)\n  .attr(\"text-anchor\", \"end\").attr(\"fill\", t.muted)\n  .style(\"font-size\", \"14px\")\n  .text(\"+2σ\");\n\ng.append(\"text\")\n  .attr(\"x\", iw).attr(\"y\", y(-outlierThreshold) + 20)\n  .attr(\"text-anchor\", \"end\").attr(\"fill\", t.muted)\n  .style(\"font-size\", \"14px\")\n  .text(\"−2σ\");\n\n// --- Zero reference line (perfect-prediction baseline) ----------------------\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", y(0)).attr(\"y2\", y(0))\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"7,5\")\n  .attr(\"stroke-opacity\", 0.6);\n\ng.append(\"text\")\n  .attr(\"x\", iw).attr(\"y\", y(0) - 10)\n  .attr(\"text-anchor\", \"end\").attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\").style(\"font-weight\", \"600\")\n  .text(\"y = 0\");\n\n// --- Smoother trend line -----------------------------------------------------\ng.append(\"path\")\n  .datum(smoothed)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[2])\n  .attr(\"stroke-width\", 3)\n  .attr(\"stroke-opacity\", 0.85)\n  .attr(\"d\", d3.line().x((d) => x(d.fitted)).y((d) => y(d.residual)).curve(d3.curveMonotoneX));\n\n// --- Scatter markers: normal vs. outlier -------------------------------------\ng.selectAll(\"circle\")\n  .data(data)\n  .join(\"circle\")\n  .attr(\"cx\", (d) => x(d.fitted))\n  .attr(\"cy\", (d) => y(d.residual))\n  .attr(\"r\", (d) => (d.isOutlier ? 8 : 6))\n  .attr(\"fill\", (d) => (d.isOutlier ? t.palette[4] : t.palette[0]))\n  .attr(\"fill-opacity\", (d) => (d.isOutlier ? 0.85 : 0.55))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.2);\n\n// --- Point-color legend (upper-left, low-variance corner stays uncluttered)\nconst legend = g.append(\"g\").attr(\"transform\", \"translate(14,10)\");\nconst legendRows = [\n  { label: \"Residual\", color: t.palette[0] },\n  { label: \"Outlier (|residual| > 2σ)\", color: t.palette[4] },\n];\nlegendRows.forEach((row, i) => {\n  const ly = i * 26;\n  legend.append(\"circle\").attr(\"cx\", 8).attr(\"cy\", ly).attr(\"r\", 7)\n    .attr(\"fill\", row.color).attr(\"fill-opacity\", 0.75);\n  legend.append(\"text\").attr(\"x\", 22).attr(\"y\", ly + 5)\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\")\n    .text(row.label);\n});\nlegend.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", 16).attr(\"y1\", 2 * 26).attr(\"y2\", 2 * 26)\n  .attr(\"stroke\", t.palette[2]).attr(\"stroke-width\", 3);\nlegend.append(\"text\").attr(\"x\", 22).attr(\"y\", 2 * 26 + 5)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\")\n  .text(\"Local mean (binned)\");\n\n// --- Axes ---------------------------------------------------------------------\nconst xAxis = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(8).tickFormat((d) => d3.format(\",\")(d)));\nxAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\nxAxis.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\nxAxis.selectAll(\".tick line\").remove();\n\nconst yAxis = g.append(\"g\")\n  .call(d3.axisLeft(y).ticks(7).tickFormat((d) => d3.format(\",\")(d)));\nyAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\nyAxis.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\nyAxis.selectAll(\".tick line\").remove();\n\n// --- Axis labels ---------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + iw / 2).attr(\"y\", height - 18)\n  .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"20px\")\n  .text(\"Fitted Value — Predicted Energy Consumption (kWh)\");\n\nsvg.append(\"text\")\n  .attr(\"transform\", `translate(36,${margin.top + ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"20px\")\n  .text(\"Residual — Actual − Predicted (kWh)\");\n\n// --- Title -----------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.ink)\n  .style(\"font-size\", \"26px\").style(\"font-weight\", \"600\")\n  .text(\"residual-plot · javascript · d3 · anyplot.ai\");\n"}