{"spec_id":"shap-summary","library":"muix","language":"javascript","code":"// anyplot.ai\n// shap-summary: SHAP Summary Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-09-09\nimport { ScatterChart } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsReferenceLine } from \"@mui/x-charts/ChartsReferenceLine\";\nimport { ContinuousColorLegend } from \"@mui/x-charts/ChartsLegend\";\nimport { ChartsText } from \"@mui/x-charts/ChartsText\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (LCG) + Box-Muller for approx-normal noise ----------\nlet seed = 42;\nfunction nextUniform() {\n  seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction nextNormal(mean, stdDev) {\n  const u1 = Math.max(nextUniform(), 1e-9);\n  const u2 = nextUniform();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * stdDev;\n}\n\n// --- Data: XGBoost churn-risk model explained via SHAP (TreeExplainer) -----\n// Each feature's SHAP values are driven by its own (normalized) feature value\n// plus noise, so a feature with real signal shows a clean color split — e.g.\n// red (high feature value) clustered on the side that increases churn risk.\nconst FEATURE_DEFS = [\n  { name: \"Support Tickets (30d)\", amplitude: 0.48, direction: 1 },\n  { name: \"Days Since Last Login\", amplitude: 0.4, direction: 1 },\n  { name: \"Monthly Active Hours\", amplitude: 0.34, direction: -1 },\n  { name: \"NPS Score\", amplitude: 0.27, direction: -1 },\n  { name: \"Contract Length (months)\", amplitude: 0.22, direction: -1 },\n  { name: \"Discount Applied (%)\", amplitude: 0.17, direction: 1 },\n  { name: \"Integrations Enabled\", amplitude: 0.13, direction: -1 },\n  { name: \"Team Size (seats)\", amplitude: 0.09, direction: -1 },\n  { name: \"Onboarding Sessions\", amplitude: 0.06, direction: -1 },\n];\n\nconst SAMPLES_PER_FEATURE = 80;\n\nconst rawFeatures = FEATURE_DEFS.map((feature) => {\n  const points = [];\n  for (let s = 0; s < SAMPLES_PER_FEATURE; s += 1) {\n    const featureValue = nextUniform(); // normalized 0 (low) - 1 (high)\n    const noiseScale = Math.max(0.05 + (0.5 - feature.amplitude) * 0.08, 0.02);\n    const shapValue =\n      feature.direction * feature.amplitude * (featureValue - 0.5) * 2 + nextNormal(0, noiseScale);\n    points.push({ featureValue, shapValue });\n  }\n  return { name: feature.name, points };\n});\n\n// Sort by mean |SHAP value| — most influential feature first (top row).\nconst meanAbsShap = (points) => points.reduce((sum, p) => sum + Math.abs(p.shapValue), 0) / points.length;\nconst rankedFeatures = [...rawFeatures].sort((a, b) => meanAbsShap(b.points) - meanAbsShap(a.points));\n\nconst FEATURE_COUNT = rankedFeatures.length;\n// Row 0 sits at the bottom of the y-axis; the most important feature gets the\n// highest row index so it renders at the top.\nconst NAMES_BOTTOM_TO_TOP = [...rankedFeatures].reverse().map((f) => f.name);\n\nconst allShapValues = rankedFeatures.flatMap((f) => f.points.map((p) => p.shapValue));\nconst dataMin = Math.min(...allShapValues);\nconst dataMax = Math.max(...allShapValues);\nconst X_PAD = (dataMax - dataMin) * 0.08;\nconst X_MIN = dataMin - X_PAD;\nconst X_MAX = dataMax + X_PAD;\nconst ROW_MIN = -0.62;\nconst ROW_MAX = FEATURE_COUNT - 1 + 0.62;\n\nconst MARGIN = { top: 100, right: 190, bottom: 110, left: 250 };\nconst MARKER_SIZE = 5.5;\nconst MARKER_DIAMETER_PX = MARKER_SIZE * 2 + 1.5;\n\n// --- Beeswarm packing: each point keeps its exact SHAP value on the x-axis;\n// only its y-offset within the feature's row is adjusted so overlapping\n// samples fan out instead of stacking. Collisions are resolved in on-screen\n// pixels so the spread looks even regardless of the x-axis range.\nfunction layoutBeeswarm(plotWidthPx, plotHeightPx) {\n  const pxPerX = plotWidthPx / (X_MAX - X_MIN);\n  const pxPerRow = plotHeightPx / (ROW_MAX - ROW_MIN);\n  const maxOffsetPx = pxPerRow * 0.42;\n\n  return rankedFeatures.flatMap((feature, idx) => {\n    const rowPosition = FEATURE_COUNT - 1 - idx;\n    const sorted = [...feature.points].sort((a, b) => a.shapValue - b.shapValue);\n\n    const placed = [];\n    sorted.forEach((point) => {\n      const nearby = placed.filter(\n        (p) => Math.abs((point.shapValue - p.shapValue) * pxPerX) < MARKER_DIAMETER_PX,\n      );\n      let offsetPx = 0;\n      if (nearby.length > 0) {\n        const step = MARKER_DIAMETER_PX * 0.9;\n        let k = 0;\n        let candidate = 0;\n        let resolved = false;\n        while (!resolved && k < 200) {\n          const raw = k === 0 ? 0 : (k % 2 === 1 ? Math.ceil(k / 2) : -Math.ceil(k / 2)) * step;\n          candidate = Math.max(-maxOffsetPx, Math.min(maxOffsetPx, raw));\n          resolved = nearby.every(\n            (p) =>\n              Math.hypot(candidate - p.offsetPx, (point.shapValue - p.shapValue) * pxPerX) >=\n              MARKER_DIAMETER_PX * 0.95,\n          );\n          k += 1;\n        }\n        offsetPx = candidate;\n      }\n      placed.push({ ...point, offsetPx });\n    });\n\n    return placed.map((p, i) => ({\n      id: `${feature.name}-${i}`,\n      x: p.shapValue,\n      y: rowPosition + p.offsetPx / pxPerRow,\n      z: p.featureValue,\n    }));\n  });\n}\n\n// --- Title (fontsize scales with title length, see plot-generator.md) -------\nconst TITLE = \"shap-summary · javascript · muix · anyplot.ai\";\nconst TITLE_FONTSIZE = Math.round(22 * (TITLE.length > 67 ? 67 / TITLE.length : 1));\n\n// --- Chart (default-exported component — the harness mounts it) ------------\nexport default function Chart() {\n  const plotWidthPx = width - MARGIN.left - MARGIN.right;\n  const plotHeightPx = height - MARGIN.top - MARGIN.bottom;\n  const points = layoutBeeswarm(plotWidthPx, plotHeightPx);\n\n  return (\n    <ScatterChart\n      width={width}\n      height={height}\n      skipAnimation\n      legend={{ hidden: true }}\n      grid={{ vertical: true }}\n      margin={MARGIN}\n      xAxis={[\n        {\n          id: \"shapValue\",\n          min: X_MIN,\n          max: X_MAX,\n          label: \"SHAP value (impact on predicted churn risk)\",\n          labelStyle: { fontSize: 17, fill: t.ink },\n          tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n          valueFormatter: (value) => `${value > 0 ? \"+\" : \"\"}${value.toFixed(2)}`,\n        },\n      ]}\n      yAxis={[\n        {\n          id: \"features\",\n          min: ROW_MIN,\n          max: ROW_MAX,\n          tickMinStep: 1,\n          valueFormatter: (value) => NAMES_BOTTOM_TO_TOP[Math.round(value)] ?? \"\",\n          tickLabelStyle: { fontSize: 15, fill: t.inkSoft },\n        },\n      ]}\n      zAxis={[\n        {\n          id: \"featureValue\",\n          min: 0,\n          max: 1,\n          colorMap: {\n            type: \"continuous\",\n            min: 0,\n            max: 1,\n            color: [t.div[2], t.div[0]],\n          },\n          valueFormatter: (value) => `${Math.round(value * 100)}th pct`,\n        },\n      ]}\n      series={[\n        {\n          id: \"shapSamples\",\n          label: \"Sample SHAP values\",\n          data: points,\n          markerSize: MARKER_SIZE,\n          color: t.palette[0],\n        },\n      ]}\n    >\n      <ChartsReferenceLine\n        x={0}\n        lineStyle={{ stroke: t.inkSoft, strokeDasharray: \"6 4\", strokeWidth: 1.5 }}\n      />\n      <ChartsText\n        text={TITLE}\n        x={width / 2}\n        y={40}\n        style={{\n          fontSize: TITLE_FONTSIZE,\n          fontWeight: 600,\n          fill: t.ink,\n          textAnchor: \"middle\",\n          dominantBaseline: \"hanging\",\n        }}\n      />\n      <ChartsText\n        text=\"Feature value\"\n        x={width - 105}\n        y={64}\n        style={{\n          fontSize: 14,\n          fill: t.inkSoft,\n          textAnchor: \"middle\",\n          dominantBaseline: \"hanging\",\n        }}\n      />\n      <ContinuousColorLegend\n        axisDirection=\"z\"\n        direction=\"column\"\n        position={{ horizontal: \"right\", vertical: \"middle\" }}\n        length=\"62%\"\n        thickness={20}\n        spacing={10}\n        align=\"middle\"\n        minLabel=\"Low\"\n        maxLabel=\"High\"\n        labelStyle={{ fontSize: 15, fill: t.inkSoft }}\n      />\n    </ScatterChart>\n  );\n}\n"}