{"spec_id":"bar-tornado-sensitivity","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// bar-tornado-sensitivity: Tornado Diagram for Sensitivity Analysis\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-20\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// One-way sensitivity analysis: NPV of a solar farm project ($ millions) under\n// low/high scenarios for each input assumption, varied one at a time.\nconst BASE_VALUE = 12.4;\nconst parameters = [\n  { name: \"Electricity Price\", low: 8.9, high: 15.7 },\n  { name: \"Discount Rate\", low: 15.8, high: 9.1 },\n  { name: \"Capital Cost\", low: 14.6, high: 10.1 },\n  { name: \"O&M Cost\", low: 13.5, high: 11.2 },\n  { name: \"Panel Degradation Rate\", low: 13.1, high: 11.5 },\n  { name: \"Debt Interest Rate\", low: 13.0, high: 11.7 },\n  { name: \"Construction Delay\", low: 12.9, high: 11.6 },\n  { name: \"Tax Credit Rate\", low: 12.0, high: 12.9 },\n].sort((a, b) => Math.abs(b.high - b.low) - Math.abs(a.high - a.low));\n\n// Sorted descending by impact range so the widest bar lands at the top —\n// Highcharts' \"bar\" (horizontal) type renders category index 0 at the top.\nconst categories = parameters.map((p) => p.name);\nconst topParam = parameters[0];\nconst topRange = Math.abs(topParam.high - topParam.low).toFixed(1);\n\n// --- Round, evenly-spaced value-axis ticks -----------------------------------\n// The stacked series live in \"delta from base\" space, but riders should read\n// whole-dollar NPV values on the axis. Compute the round absolute tick values\n// first, then convert each back into delta space for Highcharts' tickPositions.\nconst allDeltas = parameters.flatMap((p) => [p.high - BASE_VALUE, p.low - BASE_VALUE]);\nconst minAbs = Math.floor(BASE_VALUE + Math.min(...allDeltas));\nconst maxAbs = Math.ceil(BASE_VALUE + Math.max(...allDeltas));\nconst tickPositions = [];\nfor (let v = minAbs; v <= maxAbs; v++) tickPositions.push(+(v - BASE_VALUE).toFixed(4));\n\nfunction pointEffect(value) {\n  return value >= BASE_VALUE ? \"raises\" : \"lowers\";\n}\n\n// --- Chart -------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"bar\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"bar-tornado-sensitivity · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: `${topParam.name} is the leading driver of NPV uncertainty (range: $${topRange}M)`,\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: {\n    categories,\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: \"Project NPV ($ millions)\",\n      style: { color: t.inkSoft, fontSize: \"16px\" },\n    },\n    gridLineColor: t.grid,\n    tickPositions,\n    labels: {\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n      formatter: function () {\n        const v = Math.round((BASE_VALUE + this.value) * 10) / 10;\n        return \"$\" + (Number.isInteger(v) ? v.toFixed(0) : v.toFixed(1)) + \"M\";\n      },\n    },\n    plotLines: [\n      {\n        value: 0,\n        color: t.ink,\n        width: 2,\n        zIndex: 5,\n        label: {\n          text: \"Base case: $\" + BASE_VALUE.toFixed(1) + \"M\",\n          rotation: 0,\n          align: \"left\",\n          verticalAlign: \"top\",\n          x: 8,\n          y: 4,\n          style: { color: t.inkSoft, fontSize: \"13px\" },\n        },\n      },\n    ],\n  },\n  legend: {\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  tooltip: {\n    formatter: function () {\n      const outcome = BASE_VALUE + this.y;\n      const scenario = this.point.custom.scenario;\n      const effect = pointEffect(outcome);\n      return (\n        \"<b>\" +\n        this.point.category +\n        \"</b><br/>\" +\n        scenario +\n        \" input \" +\n        effect +\n        \" NPV to <b>$\" +\n        outcome.toFixed(1) +\n        \"M</b>\"\n      );\n    },\n  },\n  plotOptions: {\n    series: { animation: false, stacking: \"normal\", pointPadding: 0.08, groupPadding: 0.12 },\n    bar: { borderWidth: 0 },\n  },\n  series: [\n    {\n      name: \"High input scenario\",\n      data: parameters.map((p, i) => ({\n        y: +(p.high - BASE_VALUE).toFixed(1),\n        custom: { scenario: \"High\" },\n        // A subtle frame on the single highest-impact parameter draws the eye\n        // to the tornado's key takeaway without adding a separate annotation.\n        borderWidth: i === 0 ? 2 : 0,\n        borderColor: i === 0 ? t.ink : undefined,\n        dataLabels: i === 0 ? { style: { fontWeight: \"800\" } } : undefined,\n      })),\n      color: t.palette[0],\n      dataLabels: {\n        enabled: true,\n        color: t.ink,\n        style: { fontSize: \"13px\", fontWeight: \"500\", textOutline: \"none\" },\n        formatter: function () {\n          return \"$\" + (BASE_VALUE + this.y).toFixed(1) + \"M\";\n        },\n      },\n    },\n    {\n      name: \"Low input scenario\",\n      data: parameters.map((p, i) => ({\n        y: +(p.low - BASE_VALUE).toFixed(1),\n        custom: { scenario: \"Low\" },\n        borderWidth: i === 0 ? 2 : 0,\n        borderColor: i === 0 ? t.ink : undefined,\n        dataLabels: i === 0 ? { style: { fontWeight: \"800\" } } : undefined,\n      })),\n      color: t.palette[1],\n      dataLabels: {\n        enabled: true,\n        color: t.ink,\n        style: { fontSize: \"13px\", fontWeight: \"500\", textOutline: \"none\" },\n        formatter: function () {\n          return \"$\" + (BASE_VALUE + this.y).toFixed(1) + \"M\";\n        },\n      },\n    },\n  ],\n});\n"}