{"spec_id":"diagnostic-regression-panel","library":"d3","language":"javascript","code":"// anyplot.ai\n// diagnostic-regression-panel: Regression Diagnostic Panel (Four-Plot Display)\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 88/100 | Updated: 2026-09-05\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (LCG) + Box-Muller normal samples -------------------\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction randNormal() {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Data: simple linear regression of home price on square footage --------\nconst n = 60;\nconst sqft = [];\nconst price = [];\nfor (let i = 0; i < n; i++) {\n  const s = 800 + (i / (n - 1)) * 2700 + randNormal() * 40;\n  const heteroNoise = randNormal() * (8 + s * 0.02); // variance grows with size\n  sqft.push(s);\n  price.push(50 + 0.12 * s + heteroNoise);\n}\n// inject a high-leverage point and two large-residual outliers\nsqft[5] = 4200;\nprice[5] = 560;\nprice[30] += 140;\nprice[45] -= 130;\n\n// --- OLS fit (simple linear regression) -------------------------------------\nconst xbar = d3.mean(sqft);\nconst ybar = d3.mean(price);\nconst Sxx = d3.sum(sqft.map((x) => (x - xbar) ** 2));\nconst Sxy = d3.sum(sqft.map((x, i) => (x - xbar) * (price[i] - ybar)));\nconst b1 = Sxy / Sxx;\nconst b0 = ybar - b1 * xbar;\n\nconst fitted = sqft.map((x) => b0 + b1 * x);\nconst residuals = price.map((y, i) => y - fitted[i]);\nconst p = 2; // parameters: intercept + slope\nconst rss = d3.sum(residuals.map((r) => r ** 2));\nconst sigma2 = rss / (n - p);\nconst leverage = sqft.map((x) => 1 / n + (x - xbar) ** 2 / Sxx);\nconst stdResid = residuals.map((r, i) => r / Math.sqrt(sigma2 * (1 - leverage[i])));\nconst sqrtAbsStdResid = stdResid.map((r) => Math.sqrt(Math.abs(r)));\nconst cooksD = stdResid.map((r, i) => (r ** 2 * leverage[i]) / (p * (1 - leverage[i])));\n\n// three most influential observations by Cook's distance\nconst topInfluential = d3.range(n).sort((a, b) => cooksD[b] - cooksD[a]).slice(0, 3);\n\n// --- Inverse normal CDF (Acklam's rational approximation) ------------------\nfunction probit(pr) {\n  const a = [-3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2,\n    1.38357751867269e2, -3.066479806614716e1, 2.506628277459239];\n  const b = [-5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2,\n    6.680131188771972e1, -1.328068155288572e1];\n  const c = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838,\n    -2.549732539343734, 4.374664141464968, 2.938163982698783];\n  const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];\n  const pLow = 0.02425;\n  if (pr < pLow) {\n    const q = Math.sqrt(-2 * Math.log(pr));\n    return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /\n      ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);\n  }\n  if (pr <= 1 - pLow) {\n    const q = pr - 0.5;\n    const r = q * q;\n    return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /\n      (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);\n  }\n  const q = Math.sqrt(-2 * Math.log(1 - pr));\n  return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /\n    ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);\n}\n\nconst qqOrder = d3.range(n).sort((i, j) => stdResid[i] - stdResid[j]);\nconst qqData = qqOrder.map((idx, rank) => ({\n  theoretical: probit((rank + 0.5) / n),\n  sample: stdResid[idx],\n  idx,\n}));\n\n// --- LOWESS smoother (local linear regression, tricube weights) ------------\nfunction lowess(xs, ys, frac) {\n  const m = xs.length;\n  const k = Math.max(2, Math.round(frac * m));\n  const order = d3.range(m).sort((i, j) => xs[i] - xs[j]);\n  const sx = order.map((i) => xs[i]);\n  const sy = order.map((i) => ys[i]);\n  return order.map((_, i) => {\n    const dists = sx.map((x) => Math.abs(x - sx[i]));\n    const bw = [...dists].sort((a, b) => a - b)[k - 1] || 1e-9;\n    const weights = dists.map((dd) => (dd < bw ? (1 - (dd / bw) ** 3) ** 3 : 0));\n    let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0;\n    for (let j = 0; j < m; j++) {\n      const w = weights[j];\n      sw += w; swx += w * sx[j]; swy += w * sy[j];\n      swxx += w * sx[j] * sx[j]; swxy += w * sx[j] * sy[j];\n    }\n    const denom = sw * swxx - swx * swx;\n    const intercept = Math.abs(denom) < 1e-9 ? swy / sw : (swy - ((sw * swxy - swx * swy) / denom) * swx) / sw;\n    const slope = Math.abs(denom) < 1e-9 ? 0 : (sw * swxy - swx * swy) / denom;\n    return { x: sx[i], y: intercept + slope * sx[i] };\n  });\n}\n\n// --- Layout -------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nsvg.append(\"rect\").attr(\"width\", width).attr(\"height\", height).attr(\"fill\", t.pageBg);\n\nconst titleH = 70;\nconst gutterX = 75;\nconst gutterY = 75;\nconst outer = 30;\nconst gridW = width - 2 * outer;\nconst gridH = height - titleH - 2 * outer;\nconst panelW = (gridW - gutterX) / 2;\nconst panelH = (gridH - gutterY) / 2;\nconst panelMargin = { top: 46, right: 24, bottom: 56, left: 70 };\n\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", titleH / 2 + 10)\n  .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.ink)\n  .style(\"font-size\", \"26px\").style(\"font-weight\", \"600\")\n  .text(\"diagnostic-regression-panel · javascript · d3 · anyplot.ai\");\n\nconst panelPositions = [\n  { x0: outer, y0: outer + titleH },\n  { x0: outer + panelW + gutterX, y0: outer + titleH },\n  { x0: outer, y0: outer + titleH + panelH + gutterY },\n  { x0: outer + panelW + gutterX, y0: outer + titleH + panelH + gutterY },\n];\n\nfunction styleAxis(sel) {\n  sel.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  sel.selectAll(\"line\").attr(\"stroke\", t.grid);\n  sel.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\nfunction panelChrome(g, iw, ih, title, xLabel, yLabel) {\n  g.append(\"text\").attr(\"x\", iw / 2).attr(\"y\", -18).attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.ink).style(\"font-size\", \"16px\").style(\"font-weight\", \"600\").text(title);\n  g.append(\"text\").attr(\"x\", iw / 2).attr(\"y\", ih + 42).attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\").text(xLabel);\n  g.append(\"text\").attr(\"x\", -ih / 2).attr(\"y\", -50).attr(\"transform\", \"rotate(-90)\")\n    .attr(\"text-anchor\", \"middle\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\").text(yLabel);\n}\n\nfunction addGrid(g, x, y, iw, ih) {\n  g.append(\"g\").selectAll(\"line.grid-y\").data(y.ticks(5)).join(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw).attr(\"y1\", (d) => y(d)).attr(\"y2\", (d) => y(d))\n    .attr(\"stroke\", t.grid).attr(\"stroke-opacity\", 0.15);\n  g.append(\"g\").selectAll(\"line.grid-x\").data(x.ticks(5)).join(\"line\")\n    .attr(\"y1\", 0).attr(\"y2\", ih).attr(\"x1\", (d) => x(d)).attr(\"x2\", (d) => x(d))\n    .attr(\"stroke\", t.grid).attr(\"stroke-opacity\", 0.15);\n}\n\nfunction labelInfluential(g, xs, ys, indices, offsetFn) {\n  indices.forEach((i) => {\n    const [dx, dy] = offsetFn ? offsetFn(i) : [8, -8];\n    g.append(\"text\").attr(\"x\", xs(i) + dx).attr(\"y\", ys(i) + dy)\n      .attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\").text(i);\n  });\n}\n\n// --- Panel 1: Residuals vs Fitted -------------------------------------------\n{\n  const pos = panelPositions[0];\n  const g = svg.append(\"g\").attr(\"transform\", `translate(${pos.x0 + panelMargin.left},${pos.y0 + panelMargin.top})`);\n  const iw = panelW - panelMargin.left - panelMargin.right;\n  const ih = panelH - panelMargin.top - panelMargin.bottom;\n\n  const x = d3.scaleLinear().domain(d3.extent(fitted)).nice().range([0, iw]);\n  const y = d3.scaleLinear().domain(d3.extent(residuals)).nice().range([ih, 0]);\n\n  addGrid(g, x, y, iw, ih);\n  styleAxis(g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(5)));\n  styleAxis(g.append(\"g\").call(d3.axisLeft(y).ticks(5)));\n\n  g.append(\"line\").attr(\"x1\", 0).attr(\"x2\", iw).attr(\"y1\", y(0)).attr(\"y2\", y(0))\n    .attr(\"stroke\", t.inkSoft).attr(\"stroke-dasharray\", \"4,4\").attr(\"stroke-width\", 1.5);\n\n  const smooth = lowess(fitted, residuals, 0.6);\n  const line = d3.line().x((d) => x(d.x)).y((d) => y(d.y));\n  g.append(\"path\").datum(smooth).attr(\"d\", line).attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.palette[2]).attr(\"stroke-width\", 3);\n\n  g.selectAll(\"circle\").data(d3.range(n)).join(\"circle\")\n    .attr(\"cx\", (i) => x(fitted[i])).attr(\"cy\", (i) => y(residuals[i]))\n    .attr(\"r\", (i) => (topInfluential.includes(i) ? 7 : 5))\n    .attr(\"fill\", t.palette[0]).attr(\"fill-opacity\", 0.75)\n    .attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1);\n\n  labelInfluential(g, (i) => x(fitted[i]), (i) => y(residuals[i]), topInfluential);\n  panelChrome(g, iw, ih, \"Residuals vs Fitted\", \"Fitted values\", \"Residuals\");\n}\n\n// --- Panel 2: Normal Q-Q -----------------------------------------------------\n{\n  const pos = panelPositions[1];\n  const g = svg.append(\"g\").attr(\"transform\", `translate(${pos.x0 + panelMargin.left},${pos.y0 + panelMargin.top})`);\n  const iw = panelW - panelMargin.left - panelMargin.right;\n  const ih = panelH - panelMargin.top - panelMargin.bottom;\n\n  const domain = d3.extent([...qqData.map((d) => d.theoretical), ...qqData.map((d) => d.sample)]);\n  const x = d3.scaleLinear().domain(domain).nice().range([0, iw]);\n  const y = d3.scaleLinear().domain(domain).nice().range([ih, 0]);\n\n  addGrid(g, x, y, iw, ih);\n  styleAxis(g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(5)));\n  styleAxis(g.append(\"g\").call(d3.axisLeft(y).ticks(5)));\n\n  const refDomain = x.domain();\n  g.append(\"line\")\n    .attr(\"x1\", x(refDomain[0])).attr(\"y1\", y(refDomain[0]))\n    .attr(\"x2\", x(refDomain[1])).attr(\"y2\", y(refDomain[1]))\n    .attr(\"stroke\", t.inkSoft).attr(\"stroke-dasharray\", \"4,4\").attr(\"stroke-width\", 1.5);\n\n  g.selectAll(\"circle\").data(qqData).join(\"circle\")\n    .attr(\"cx\", (d) => x(d.theoretical)).attr(\"cy\", (d) => y(d.sample))\n    .attr(\"r\", (d) => (topInfluential.includes(d.idx) ? 7 : 5))\n    .attr(\"fill\", t.palette[0]).attr(\"fill-opacity\", 0.75)\n    .attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1);\n\n  const byIdx = new Map(qqData.map((d) => [d.idx, d]));\n  // The 45-degree reference line runs bottom-left to top-right on screen; a\n  // (+8,-8) offset moves roughly parallel to it, which is why labels used to\n  // merge with the line. Offset perpendicular instead, direction chosen by\n  // which side of the line the point actually falls on.\n  labelInfluential(\n    g,\n    (i) => x(byIdx.get(i).theoretical),\n    (i) => y(byIdx.get(i).sample),\n    topInfluential,\n    (i) => {\n      const d = byIdx.get(i);\n      const aboveLine = y(d.sample) < y(d.theoretical);\n      return aboveLine ? [-10, -10] : [10, 12];\n    },\n  );\n  panelChrome(g, iw, ih, \"Normal Q-Q\", \"Theoretical Quantiles\", \"Standardized Residuals\");\n}\n\n// --- Panel 3: Scale-Location --------------------------------------------------\n{\n  const pos = panelPositions[2];\n  const g = svg.append(\"g\").attr(\"transform\", `translate(${pos.x0 + panelMargin.left},${pos.y0 + panelMargin.top})`);\n  const iw = panelW - panelMargin.left - panelMargin.right;\n  const ih = panelH - panelMargin.top - panelMargin.bottom;\n\n  const x = d3.scaleLinear().domain(d3.extent(fitted)).nice().range([0, iw]);\n  const y = d3.scaleLinear().domain([0, d3.max(sqrtAbsStdResid) * 1.1]).nice().range([ih, 0]);\n\n  addGrid(g, x, y, iw, ih);\n  styleAxis(g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(5)));\n  styleAxis(g.append(\"g\").call(d3.axisLeft(y).ticks(5)));\n\n  const smooth = lowess(fitted, sqrtAbsStdResid, 0.6);\n  const line = d3.line().x((d) => x(d.x)).y((d) => y(d.y));\n  g.append(\"path\").datum(smooth).attr(\"d\", line).attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.palette[2]).attr(\"stroke-width\", 3);\n\n  g.selectAll(\"circle\").data(d3.range(n)).join(\"circle\")\n    .attr(\"cx\", (i) => x(fitted[i])).attr(\"cy\", (i) => y(sqrtAbsStdResid[i]))\n    .attr(\"r\", (i) => (topInfluential.includes(i) ? 7 : 5))\n    .attr(\"fill\", t.palette[0]).attr(\"fill-opacity\", 0.75)\n    .attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1);\n\n  labelInfluential(g, (i) => x(fitted[i]), (i) => y(sqrtAbsStdResid[i]), topInfluential);\n  panelChrome(g, iw, ih, \"Scale-Location\", \"Fitted values\", \"Sqrt(|Standardized Residuals|)\");\n}\n\n// --- Panel 4: Residuals vs Leverage (with Cook's distance contours) --------\n{\n  const pos = panelPositions[3];\n  const g = svg.append(\"g\").attr(\"transform\", `translate(${pos.x0 + panelMargin.left},${pos.y0 + panelMargin.top})`);\n  const iw = panelW - panelMargin.left - panelMargin.right;\n  const ih = panelH - panelMargin.top - panelMargin.bottom;\n\n  const x = d3.scaleLinear().domain([0, d3.max(leverage) * 1.1]).nice().range([0, iw]);\n  const y = d3.scaleLinear().domain(d3.extent(stdResid)).nice().range([ih, 0]);\n\n  addGrid(g, x, y, iw, ih);\n  styleAxis(g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(5)));\n  styleAxis(g.append(\"g\").call(d3.axisLeft(y).ticks(5)));\n\n  g.append(\"clipPath\").attr(\"id\", \"clip-leverage\").append(\"rect\").attr(\"width\", iw).attr(\"height\", ih);\n\n  const hMax = x.domain()[1];\n  const hGrid = d3.range(1, 200).map((i) => (i / 200) * hMax);\n  const contourLine = d3.line().x((d) => x(d.h)).y((d) => y(d.val));\n  [\n    { D: 0.5, width: 1.5 },\n    { D: 1.0, width: 2 },\n  ].forEach(({ D, width: lw }) => {\n    const upper = hGrid.map((h) => ({ h, val: Math.sqrt((D * p * (1 - h)) / h) }));\n    const lower = upper.map((d) => ({ h: d.h, val: -d.val }));\n    [upper, lower].forEach((series) => {\n      g.append(\"path\").attr(\"clip-path\", \"url(#clip-leverage)\").datum(series).attr(\"d\", contourLine)\n        .attr(\"fill\", \"none\").attr(\"stroke\", t.amber).attr(\"stroke-width\", lw).attr(\"stroke-dasharray\", \"6,4\");\n    });\n  });\n\n  g.selectAll(\"circle\").data(d3.range(n)).join(\"circle\")\n    .attr(\"cx\", (i) => x(leverage[i])).attr(\"cy\", (i) => y(stdResid[i]))\n    .attr(\"r\", (i) => (topInfluential.includes(i) ? 7 : 5))\n    .attr(\"fill\", t.palette[0]).attr(\"fill-opacity\", 0.75)\n    .attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1);\n\n  labelInfluential(g, (i) => x(leverage[i]), (i) => y(stdResid[i]), topInfluential);\n  panelChrome(g, iw, ih, \"Residuals vs Leverage\", \"Leverage\", \"Standardized Residuals\");\n\n  g.append(\"text\").attr(\"x\", iw - 6).attr(\"y\", 14).attr(\"text-anchor\", \"end\")\n    .attr(\"fill\", t.amber).style(\"font-size\", \"12px\")\n    .style(\"paint-order\", \"stroke\").attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 5).attr(\"stroke-linejoin\", \"round\")\n    .text(\"Cook's D = 0.5 / 1.0\");\n}\n"}