{"spec_id":"shap-waterfall","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// shap-waterfall: SHAP Waterfall Plot for Feature Attribution\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-09\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Credit-risk model: SHAP contributions to the predicted probability of default\n// for a single loan applicant, ordered by absolute contribution magnitude.\nconst baseValue = 0.18; // E[f(x)] — mean predicted default probability across training data\nconst features = [\n  \"Credit Score\",\n  \"Debt-to-Income Ratio\",\n  \"Payment History\",\n  \"Credit Utilization\",\n  \"Annual Income\",\n  \"Recent Credit Inquiries\",\n  \"Loan Amount\",\n  \"Employment Length\",\n  \"Number of Open Accounts\",\n  \"Age of Credit History\",\n];\nconst shapValues = [-0.086, 0.052, -0.041, 0.033, -0.027, 0.021, 0.018, -0.014, 0.011, -0.009];\n\nlet running = baseValue;\nconst segments = shapValues.map((shap) => {\n  const start = running;\n  const end = start + shap;\n  running = end;\n  return { low: Math.min(start, end), high: Math.max(start, end), start, end, shap };\n});\nconst finalValue = running;\n\nconst positiveColor = t.palette[4]; // matte red — pushes predicted risk up\nconst negativeColor = t.palette[2]; // blue — pushes predicted risk down\n\n// --- Chart -------------------------------------------------------------------\nconst chart = Highcharts.chart(\"container\", {\n  chart: {\n    type: \"bar\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    spacingRight: 40,\n  },\n  credits: { enabled: false },\n  title: {\n    text: \"shap-waterfall · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  xAxis: {\n    categories: features,\n    reversed: true,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    gridLineWidth: 0,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n  },\n  yAxis: {\n    title: {\n      text: \"Contribution to Predicted Default Probability\",\n      style: { color: t.inkSoft, fontSize: \"16px\" },\n    },\n    gridLineColor: t.grid,\n    labels: {\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n      formatter() {\n        return `${Math.round(this.value * 100)}%`;\n      },\n    },\n    plotLines: [\n      {\n        value: baseValue,\n        color: t.inkSoft,\n        width: 1.5,\n        dashStyle: \"Dash\",\n        zIndex: 5,\n        label: {\n          text: `Base value: ${(baseValue * 100).toFixed(1)}%`,\n          style: { color: t.inkSoft, fontSize: \"13px\" },\n          rotation: 0,\n          y: -8,\n        },\n      },\n      {\n        value: finalValue,\n        color: t.ink,\n        width: 2,\n        dashStyle: \"Solid\",\n        zIndex: 5,\n        label: {\n          text: `Prediction: ${(finalValue * 100).toFixed(1)}%`,\n          style: { color: t.ink, fontSize: \"13px\", fontWeight: \"600\" },\n          rotation: 0,\n          y: -8,\n          align: \"right\",\n        },\n      },\n    ],\n  },\n  legend: {\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  plotOptions: {\n    series: { animation: false, pointPadding: 0.15, groupPadding: 0, stacking: \"normal\" },\n    bar: { borderWidth: 0 },\n  },\n  series: [\n    {\n      // Highcharts stacks bar/column series in reverse declaration order (the\n      // first series lands on TOP of the stack) — declare the visible segment\n      // first so it occupies [low, high], and the invisible spacer last so it\n      // occupies [0, low] and pushes the visible segment into position.\n      name: \"SHAP contribution\",\n      data: segments.map((s) => ({\n        y: s.high - s.low,\n        color: s.shap >= 0 ? positiveColor : negativeColor,\n        custom: { shap: s.shap },\n      })),\n      showInLegend: false,\n      dataLabels: {\n        enabled: true,\n        inside: false,\n        crop: false,\n        overflow: \"allow\",\n        style: { color: t.ink, fontSize: \"13px\", fontWeight: \"500\", textOutline: \"none\" },\n        formatter() {\n          const pp = this.point.custom.shap * 100;\n          return `${pp >= 0 ? \"+\" : \"\"}${pp.toFixed(1)} pp`;\n        },\n      },\n    },\n    {\n      name: \"base\",\n      data: segments.map((s) => s.low),\n      color: \"rgba(0,0,0,0)\",\n      enableMouseTracking: false,\n      showInLegend: false,\n      dataLabels: { enabled: false },\n    },\n    {\n      name: \"Increases risk\",\n      data: [],\n      color: positiveColor,\n      showInLegend: true,\n    },\n    {\n      name: \"Decreases risk\",\n      data: [],\n      color: negativeColor,\n      showInLegend: true,\n    },\n  ],\n});\n\n// --- Connector lines between cumulative segments -----------------------------\nconst catAxis = chart.xAxis[0];\nconst valAxis = chart.yAxis[0];\nconst half = 0.5 - 0.15; // matches plotOptions.series.pointPadding\nsegments.slice(0, -1).forEach((seg, i) => {\n  const xPixel = valAxis.toPixels(seg.end, false);\n  const y1 = catAxis.toPixels(i + half, false);\n  const y2 = catAxis.toPixels(i + 1 - half, false);\n  chart.renderer\n    .path([\"M\", xPixel, y1, \"L\", xPixel, y2])\n    .attr({ stroke: t.inkSoft, \"stroke-width\": 1, dashstyle: \"Dash\", zIndex: 4 })\n    .add();\n});\n"}