{"spec_id":"shap-summary","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// shap-summary: SHAP Summary Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-09\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (no seeded RNG in the browser) ----------------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return function lcg() {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = makeLcg(20260909);\n\n// --- Data: synthetic SHAP output from a gradient-boosted loan-default model -\n// One feature carries `direction` (does a HIGH raw value push risk up or down)\n// and `nonlinear` (a mild non-linear kink, per the spec's \"detect non-linear\n// relationships\" application). `scale` sets the typical |SHAP value| magnitude\n// before mean-abs importance is computed and used to rank + trim to top 10.\nconst FEATURE_DEFS = [\n  { name: \"Credit score\", scale: 0.42, direction: -1, nonlinear: false, noise: 0.35 },\n  { name: \"Debt-to-income ratio\", scale: 0.35, direction: 1, nonlinear: false, noise: 0.4 },\n  { name: \"Credit utilization\", scale: 0.31, direction: 1, nonlinear: true, noise: 0.35 },\n  { name: \"Late payments (12mo)\", scale: 0.27, direction: 1, nonlinear: true, noise: 0.55 },\n  { name: \"Loan amount\", scale: 0.22, direction: 1, nonlinear: false, noise: 0.45 },\n  { name: \"Annual income\", scale: 0.19, direction: -1, nonlinear: false, noise: 0.4 },\n  { name: \"Employment length\", scale: 0.16, direction: -1, nonlinear: false, noise: 0.5 },\n  { name: \"Open credit accounts\", scale: 0.13, direction: 1, nonlinear: false, noise: 0.55 },\n  { name: \"Recent credit inquiries\", scale: 0.11, direction: 1, nonlinear: true, noise: 0.5 },\n  { name: \"Applicant age\", scale: 0.08, direction: -1, nonlinear: false, noise: 0.65 },\n  { name: \"Loan term (months)\", scale: 0.06, direction: 1, nonlinear: false, noise: 0.7 },\n  { name: \"Home ownership score\", scale: 0.05, direction: -1, nonlinear: false, noise: 0.7 },\n];\n\nconst N_SAMPLES = 220;\n\nconst featurePoints = FEATURE_DEFS.map((f) => {\n  const shapValues = [];\n  for (let s = 0; s < N_SAMPLES; s++) {\n    const fv = rng(); // normalized raw feature value in [0, 1], colors the dot\n    const centered = (fv - 0.5) * 2; // [-1, 1]\n    let effect = f.direction * centered * f.scale;\n    if (f.nonlinear) {\n      // Kink near the extremes so high/low both push the same direction —\n      // the \"non-linear relationship\" case called out in the specification.\n      effect += f.direction * Math.sign(centered) * Math.pow(Math.abs(centered), 2) * f.scale * 0.5;\n    }\n    const noise = (rng() - 0.5) * f.scale * f.noise;\n    shapValues.push({ x: effect + noise, v: fv });\n  }\n  const meanAbsShap = shapValues.reduce((sum, p) => sum + Math.abs(p.x), 0) / shapValues.length;\n  return { name: f.name, meanAbsShap, shapValues };\n});\n\n// Rank by mean |SHAP value| (most important first) and keep the top 10.\nconst topFeatures = featurePoints.sort((a, b) => b.meanAbsShap - a.meanAbsShap).slice(0, 10);\nconst numFeatures = topFeatures.length;\n\n// Row 0 (bottom) = least important, row numFeatures-1 (top) = most important,\n// matching the linear y-scale's natural bottom-to-top ordering. Points are\n// jittered vertically around their row to reduce overlap (beeswarm-style).\nconst points = [];\ntopFeatures.forEach((feature, rank) => {\n  const baseY = numFeatures - 1 - rank;\n  feature.shapValues.forEach((p) => {\n    const jitter = (rng() - 0.5) * 0.72;\n    points.push({ x: p.x, y: baseY + jitter, v: p.v });\n  });\n});\n\n// --- Color: Imprint diverging scale (blue = low feature value, red = high) --\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nfunction mixHex(hexA, hexB, frac) {\n  const [r1, g1, b1] = hexToRgb(hexA);\n  const [r2, g2, b2] = hexToRgb(hexB);\n  const r = Math.round(r1 + (r2 - r1) * frac);\n  const g = Math.round(g1 + (g2 - g1) * frac);\n  const b = Math.round(b1 + (b2 - b1) * frac);\n  return `rgba(${r}, ${g}, ${b}, 0.82)`;\n}\n// t.div = [red, midpoint, blue]; low feature value -> blue, high -> red.\nfunction valueToColor(v) {\n  return v <= 0.5 ? mixHex(t.div[2], t.div[1], v / 0.5) : mixHex(t.div[1], t.div[0], (v - 0.5) / 0.5);\n}\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Plugins: zero-impact reference line + feature-value color legend -------\nconst zeroLinePlugin = {\n  id: \"zeroLine\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const xPix = scales.x.getPixelForValue(0);\n    ctx.save();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 2;\n    ctx.beginPath();\n    ctx.moveTo(xPix, chartArea.top);\n    ctx.lineTo(xPix, chartArea.bottom);\n    ctx.stroke();\n    ctx.restore();\n  },\n};\n\nconst colorLegendPlugin = {\n  id: \"colorLegend\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const barW = 240;\n    const barH = 16;\n    const x0 = chartArea.right - barW;\n    const y0 = chartArea.top - 46;\n    const grad = ctx.createLinearGradient(x0, 0, x0 + barW, 0);\n    grad.addColorStop(0, t.div[2]);\n    grad.addColorStop(0.5, t.div[1]);\n    grad.addColorStop(1, t.div[0]);\n    ctx.save();\n    ctx.fillStyle = grad;\n    ctx.fillRect(x0, y0, barW, barH);\n    ctx.font = \"14px -apple-system, BlinkMacSystemFont, sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textBaseline = \"alphabetic\";\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"Low\", x0, y0 - 6);\n    ctx.textAlign = \"right\";\n    ctx.fillText(\"High\", x0 + barW, y0 - 6);\n    ctx.textAlign = \"center\";\n    ctx.fillText(\"Feature value\", x0 + barW / 2, y0 + barH + 18);\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"SHAP value\",\n        data: points,\n        pointBackgroundColor: (ctx) => (ctx.raw ? valueToColor(ctx.raw.v) : t.palette[0]),\n        pointBorderWidth: 0,\n        pointRadius: 4,\n        pointHoverRadius: 4,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 64, right: 10, bottom: 4, left: 4 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"shap-summary · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n        padding: { bottom: 18 },\n      },\n      legend: { display: false },\n      tooltip: {\n        callbacks: {\n          label: (ctx) => `SHAP ${ctx.parsed.x.toFixed(3)} · feature value ${(ctx.raw.v * 100).toFixed(0)}%`,\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        title: { display: true, text: \"SHAP value (impact on model output)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n      },\n      y: {\n        type: \"linear\",\n        min: -0.75,\n        max: numFeatures - 1 + 0.75,\n        afterBuildTicks: (axis) => {\n          axis.ticks = Array.from({ length: numFeatures }, (_, i) => ({ value: i }));\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => {\n            const row = Math.round(value);\n            const feature = topFeatures[numFeatures - 1 - row];\n            return feature ? feature.name : \"\";\n          },\n        },\n        grid: { color: t.grid, drawTicks: false },\n      },\n    },\n  },\n  plugins: [zeroLinePlugin, colorLegendPlugin],\n});\n"}