{"spec_id":"diagnostic-regression-panel","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// diagnostic-regression-panel: Regression Diagnostic Panel (Four-Plot Display)\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Updated: 2026-09-05\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst FONT = \"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif\";\n\n// --- Deterministic PRNG (LCG) + Box-Muller normal draws ---------------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return function lcg() {\n    state = (1103515245 * state + 12345) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\nfunction randNormal() {\n  const u1 = Math.max(rand(), 1e-12);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Inverse standard-normal CDF (Acklam's rational approximation) ---------\nfunction probit(p) {\n  const a = [-3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2, -3.066479806614716e1, 2.506628277459239];\n  const b = [-5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1, -1.328068155288572e1];\n  const c = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -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  const phigh = 1 - plow;\n  if (p < plow) {\n    const q = Math.sqrt(-2 * Math.log(p));\n    return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);\n  }\n  if (p <= phigh) {\n    const q = p - 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) / (((((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 - p));\n  return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);\n}\n\n// --- LOWESS smoother: tricube-weighted local linear regression -------------\nfunction lowess(xs, ys, fracSpan, gridSize) {\n  const n = xs.length;\n  const order = xs.map((_, i) => i).sort((i, j) => xs[i] - xs[j]);\n  const sx = order.map((i) => xs[i]);\n  const sy = order.map((i) => ys[i]);\n  const bandwidth = Math.max(2, Math.round(fracSpan * n));\n  const xmin = sx[0];\n  const xmax = sx[n - 1];\n  const grid = [];\n  for (let g = 0; g < gridSize; g++) grid.push(xmin + ((xmax - xmin) * g) / (gridSize - 1));\n  return grid.map((x0) => {\n    const dists = sx.map((xi) => Math.abs(xi - x0));\n    const h = [...dists].sort((p, q) => p - q)[Math.min(bandwidth, n - 1)] || 1e-6;\n    let sw = 0;\n    let swx = 0;\n    let swy = 0;\n    let swxx = 0;\n    let swxy = 0;\n    for (let i = 0; i < n; i++) {\n      const u = dists[i] / h;\n      if (u >= 1) continue;\n      const w = (1 - u * u * u) ** 3;\n      sw += w;\n      swx += w * sx[i];\n      swy += w * sy[i];\n      swxx += w * sx[i] * sx[i];\n      swxy += w * sx[i] * sy[i];\n    }\n    const denom = sw * swxx - swx * swx;\n    let slope = 0;\n    let intercept = swy / sw;\n    if (Math.abs(denom) > 1e-9) {\n      slope = (sw * swxy - swx * swy) / denom;\n      intercept = (swy - slope * swx) / sw;\n    }\n    return { x: x0, y: intercept + slope * x0 };\n  });\n}\n\n// --- Data: simulate a fitted regression with mild non-linearity, -----------\n// heteroscedastic noise, and a few high-leverage / high-influence points ----\nconst n = 120;\nconst x = [];\nfor (let i = 0; i < n; i++) x.push(rand() * 10);\nx[n - 2] = 12.4; // high-leverage points (sparse, far from the predictor mean)\nx[n - 1] = -2.6;\n\nconst y = x.map((xi) => {\n  const trueSignal = 5 + 2.2 * xi + 0.18 * xi * xi; // mild curvature the linear fit misses\n  const noiseScale = 1 + 0.35 * Math.abs(xi); // heteroscedastic spread\n  return trueSignal + randNormal() * noiseScale;\n});\ny[10] += 19; // outliers that become influential once combined with leverage\ny[45] -= 16;\n\n// --- Simple OLS fit: y = b0 + b1 * x ----------------------------------------\nconst xbar = x.reduce((s, v) => s + v, 0) / n;\nconst ybar = y.reduce((s, v) => s + v, 0) / n;\nconst sxx = x.reduce((s, xi) => s + (xi - xbar) ** 2, 0);\nconst sxy = x.reduce((s, xi, i) => s + (xi - xbar) * (y[i] - ybar), 0);\nconst b1 = sxy / sxx;\nconst b0 = ybar - b1 * xbar;\n\nconst fitted = x.map((xi) => b0 + b1 * xi);\nconst residuals = y.map((yi, i) => yi - fitted[i]);\nconst p = 2; // estimated parameters (intercept + slope)\nconst rss = residuals.reduce((s, r) => s + r * r, 0);\nconst sigma = Math.sqrt(rss / (n - p));\n\nconst leverage = x.map((xi) => 1 / n + (xi - xbar) ** 2 / sxx);\nconst stdResiduals = residuals.map((r, i) => r / (sigma * Math.sqrt(1 - leverage[i])));\nconst sqrtAbsStd = stdResiduals.map((r) => Math.sqrt(Math.abs(r)));\nconst cooksD = stdResiduals.map((sr, i) => (sr * sr * leverage[i]) / ((1 - leverage[i]) * p));\n\n// The 3 most influential observations (highest Cook's distance) — labeled in every panel\nconst topInfluential = cooksD\n  .map((d, i) => [d, i])\n  .sort((a, b) => b[0] - a[0])\n  .slice(0, 3)\n  .map(([, i]) => i);\n\n// Q-Q coordinates: sort standardized residuals, pair with theoretical normal quantiles\nconst qqOrder = stdResiduals.map((_, i) => i).sort((i, j) => stdResiduals[i] - stdResiduals[j]);\nconst qqByIndex = new Array(n);\nqqOrder.forEach((origIdx, rank) => {\n  const pval = (rank + 0.5) / n;\n  qqByIndex[origIdx] = { x: probit(pval), y: stdResiduals[origIdx] };\n});\nconst qqTheoretical = qqByIndex.map((pt) => pt.x);\nconst qqMin = Math.min(...qqTheoretical);\nconst qqMax = Math.max(...qqTheoretical);\n\n// Cook's distance contours for the Residuals-vs-Leverage panel\nconst axisYMax = 5; // a little headroom above the data so markers never clip the frame\nconst contourClip = 4.4;\nconst maxLeverage = Math.max(...leverage);\nconst leverageAxisMax = maxLeverage * 1.35;\nfunction cookContour(D) {\n  const hMin = (D * p) / (D * p + contourClip * contourClip);\n  const steps = 40;\n  const pos = [];\n  for (let i = 0; i <= steps; i++) {\n    const h = hMin + ((leverageAxisMax - hMin) * i) / steps;\n    if (h <= 0 || h >= 1) continue;\n    pos.push({ x: h, y: Math.min(Math.sqrt((D * p * (1 - h)) / h), contourClip) });\n  }\n  return { pos, neg: pos.map((pt) => ({ x: pt.x, y: -pt.y })) };\n}\nconst cook05 = cookContour(0.5);\nconst cook10 = cookContour(1.0);\n\n// --- Consistent scatter marker styling across all four subplots ------------\nconst POINT_STYLE = {\n  backgroundColor: t.palette[0],\n  borderColor: t.pageBg,\n  borderWidth: 1.5,\n  radius: 7,\n  hoverRadius: 7,\n};\nconst SMOOTH_COLOR = t.palette[1];\nconst REFERENCE_COLOR = t.ink;\nconst CONTOUR_COLOR = t.amber;\n\n// --- Point-label plugin: draws \"#idx\" next to the top-influence points -----\n// Flips the label to the opposite side of the point whenever the default\n// placement (upper-right) would run past the chart area — into the panel\n// title above, or off the right/left edge of the canvas.\nfunction labelPlugin(getPoints) {\n  return {\n    id: \"anyplotPointLabels\",\n    afterDatasetsDraw(chart) {\n      const { ctx, scales, chartArea } = chart;\n      ctx.save();\n      ctx.font = \"600 20px \" + FONT;\n      ctx.fillStyle = t.ink;\n      ctx.textAlign = \"left\";\n      ctx.textBaseline = \"middle\";\n      getPoints().forEach(({ x, y, label }) => {\n        const px = scales.x.getPixelForValue(x);\n        const py = scales.y.getPixelForValue(y);\n        const w = ctx.measureText(label).width;\n        const nearTop = py - 26 < chartArea.top;\n        const nearRight = px + 12 + w > chartArea.right;\n        const lx = nearRight ? px - 12 - w : px + 12;\n        const ly = nearTop ? py + 22 : py - 16;\n        ctx.fillText(label, lx, ly);\n      });\n      ctx.restore();\n    },\n  };\n}\n\n// --- Shared chrome for every subplot ----------------------------------------\nfunction baseOptions(panelTitle, xLabel, yLabel, extraScales) {\n  return {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 8 },\n    plugins: {\n      legend: { display: false },\n      title: { display: true, text: panelTitle, color: t.ink, font: { size: 24, weight: \"600\", family: FONT }, padding: { bottom: 12 } },\n    },\n    scales: {\n      x: {\n        ticks: { color: t.inkSoft, font: { size: 15, family: FONT } },\n        grid: { color: t.grid },\n        title: { display: true, text: xLabel, color: t.ink, font: { size: 17, family: FONT } },\n        ...(extraScales?.x || {}),\n      },\n      y: {\n        ticks: { color: t.inkSoft, font: { size: 15, family: FONT } },\n        grid: { color: t.grid },\n        title: { display: true, text: yLabel, color: t.ink, font: { size: 17, family: FONT } },\n        ...(extraScales?.y || {}),\n      },\n    },\n  };\n}\n\n// --- Mount: shared title above a 2x2 grid of independent Chart.js charts ---\nconst root = document.createElement(\"div\");\ndocument.getElementById(\"container\").appendChild(root);\nroot.style.display = \"flex\";\nroot.style.flexDirection = \"column\";\nroot.style.width = \"100%\";\nroot.style.height = \"100%\";\nroot.style.backgroundColor = t.pageBg;\n\nconst titleEl = document.createElement(\"div\");\ntitleEl.textContent = \"diagnostic-regression-panel · javascript · chartjs · anyplot.ai\";\ntitleEl.style.textAlign = \"center\";\ntitleEl.style.color = t.ink;\ntitleEl.style.fontFamily = FONT;\ntitleEl.style.fontWeight = \"700\";\ntitleEl.style.fontSize = \"30px\";\ntitleEl.style.padding = \"22px 0 10px 0\";\nroot.appendChild(titleEl);\n\nconst grid = document.createElement(\"div\");\ngrid.style.flex = \"1 1 auto\";\ngrid.style.display = \"grid\";\ngrid.style.gridTemplateColumns = \"1fr 1fr\";\ngrid.style.gridTemplateRows = \"1fr 1fr\";\ngrid.style.columnGap = \"6px\";\ngrid.style.rowGap = \"6px\";\ngrid.style.minHeight = \"0\";\ngrid.style.padding = \"0 18px 18px 18px\";\nroot.appendChild(grid);\n\nfunction addCanvas() {\n  const cell = document.createElement(\"div\");\n  cell.style.position = \"relative\";\n  cell.style.minWidth = \"0\";\n  cell.style.minHeight = \"0\";\n  grid.appendChild(cell);\n  const canvas = document.createElement(\"canvas\");\n  cell.appendChild(canvas);\n  return canvas;\n}\n\n// --- Panel 1: Residuals vs Fitted -------------------------------------------\nconst fittedMin = Math.min(...fitted);\nconst fittedMax = Math.max(...fitted);\nconst lowess1 = lowess(fitted, residuals, 0.6, 40);\nnew Chart(addCanvas(), {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      { data: fitted.map((f, i) => ({ x: f, y: residuals[i] })), ...POINT_STYLE, showLine: false },\n      { data: [{ x: fittedMin, y: 0 }, { x: fittedMax, y: 0 }], showLine: true, borderColor: REFERENCE_COLOR, borderWidth: 2, borderDash: [8, 5], pointRadius: 0 },\n      { data: lowess1, showLine: true, borderColor: SMOOTH_COLOR, borderWidth: 3.5, pointRadius: 0, tension: 0.25 },\n    ],\n  },\n  options: baseOptions(\"Residuals vs Fitted\", \"Fitted values\", \"Residuals\"),\n  plugins: [labelPlugin(() => topInfluential.map((i) => ({ x: fitted[i], y: residuals[i], label: `#${i}` })))],\n});\n\n// --- Panel 2: Normal Q-Q -----------------------------------------------------\nnew Chart(addCanvas(), {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      { data: qqByIndex.map((pt) => ({ x: pt.x, y: pt.y })), ...POINT_STYLE, showLine: false },\n      { data: [{ x: qqMin, y: qqMin }, { x: qqMax, y: qqMax }], showLine: true, borderColor: REFERENCE_COLOR, borderWidth: 2, borderDash: [8, 5], pointRadius: 0 },\n    ],\n  },\n  options: baseOptions(\"Normal Q-Q\", \"Theoretical quantiles\", \"Standardized residuals\"),\n  plugins: [labelPlugin(() => topInfluential.map((i) => ({ x: qqByIndex[i].x, y: qqByIndex[i].y, label: `#${i}` })))],\n});\n\n// --- Panel 3: Scale-Location --------------------------------------------------\nconst lowess3 = lowess(fitted, sqrtAbsStd, 0.6, 40);\nnew Chart(addCanvas(), {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      { data: fitted.map((f, i) => ({ x: f, y: sqrtAbsStd[i] })), ...POINT_STYLE, showLine: false },\n      { data: lowess3, showLine: true, borderColor: SMOOTH_COLOR, borderWidth: 3.5, pointRadius: 0, tension: 0.25 },\n    ],\n  },\n  options: baseOptions(\"Scale-Location\", \"Fitted values\", \"√|Standardized residuals|\", { y: { min: 0 } }),\n  plugins: [labelPlugin(() => topInfluential.map((i) => ({ x: fitted[i], y: sqrtAbsStd[i], label: `#${i}` })))],\n});\n\n// --- Panel 4: Residuals vs Leverage (with Cook's distance contours) --------\nnew Chart(addCanvas(), {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      { data: leverage.map((h, i) => ({ x: h, y: stdResiduals[i] })), ...POINT_STYLE, showLine: false },\n      { data: cook05.pos, showLine: true, borderColor: CONTOUR_COLOR, borderWidth: 2, borderDash: [6, 4], pointRadius: 0, tension: 0.15 },\n      { data: cook05.neg, showLine: true, borderColor: CONTOUR_COLOR, borderWidth: 2, borderDash: [6, 4], pointRadius: 0, tension: 0.15 },\n      { data: cook10.pos, showLine: true, borderColor: CONTOUR_COLOR, borderWidth: 2.5, pointRadius: 0, tension: 0.15 },\n      { data: cook10.neg, showLine: true, borderColor: CONTOUR_COLOR, borderWidth: 2.5, pointRadius: 0, tension: 0.15 },\n    ],\n  },\n  options: baseOptions(\"Residuals vs Leverage\", \"Leverage\", \"Standardized residuals\", {\n    x: { min: 0, max: leverageAxisMax },\n    y: { min: -axisYMax, max: axisYMax },\n  }),\n  plugins: [\n    labelPlugin(() => {\n      const mid05 = cook05.pos[Math.floor(cook05.pos.length * 0.6)];\n      const mid10 = cook10.pos[Math.floor(cook10.pos.length * 0.6)];\n      return [\n        ...topInfluential.map((i) => ({ x: leverage[i], y: stdResiduals[i], label: `#${i}` })),\n        { x: mid05.x, y: mid05.y, label: \"D=0.5\" },\n        { x: mid10.x, y: mid10.y, label: \"D=1.0\" },\n      ];\n    }),\n  ],\n});\n"}