{"spec_id":"bar-tornado-sensitivity","library":"echarts","language":"javascript","code":"// anyplot.ai\n// bar-tornado-sensitivity: Tornado Diagram for Sensitivity Analysis\n// Library: echarts 5.5.1 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-02\n\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// NPV sensitivity analysis for a manufacturing plant investment ($M)\n// Base case NPV = $12.5M; 8 parameters varied one at a time (one-way sensitivity)\n// Sorted ascending by output range so ECharts default category order puts widest bar at top\nconst baseNPV = 12.5;\nconst xMin    = 6.0;\n\nconst params = [\n  { name: \"Salvage Value\",        minOut: 11.7, maxOut: 13.4 },\n  { name: \"Tax Rate\",             minOut: 11.4, maxOut: 13.7 },\n  { name: \"Raw Material Cost\",    minOut: 11.3, maxOut: 14.2 },\n  { name: \"Capital Expenditure\",  minOut: 10.6, maxOut: 14.9 },\n  { name: \"Operating Margin\",     minOut:  9.8, maxOut: 15.2 },\n  { name: \"Discount Rate\",        minOut: 10.1, maxOut: 15.8 },\n  { name: \"Market Share\",         minOut:  9.1, maxOut: 15.6 },\n  { name: \"Revenue Growth Rate\",  minOut:  7.8, maxOut: 18.2 },\n];\n\nconst names = params.map(function (p) { return p.name; });\n\n// Stacked bar technique: transparent offset positions bars at minOut;\n// left wing spans minOut → base (downside); right wing spans base → maxOut (upside)\nconst offsetData = params.map(function (p) { return +p.minOut.toFixed(3); });\nconst leftData   = params.map(function (p) { return +(baseNPV - p.minOut).toFixed(3); });\nconst rightData  = params.map(function (p) { return +(p.maxOut - baseNPV).toFixed(3); });\n\nconst minByName = {};\nconst maxByName = {};\nparams.forEach(function (p) { minByName[p.name] = p.minOut; maxByName[p.name] = p.maxOut; });\n\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// Title font size scaled to title length (baseline 67 chars at 22px)\nconst titleText     = \"NPV Sensitivity Analysis · bar-tornado-sensitivity · javascript · echarts · anyplot.ai\";\nconst titleFontSize = Math.max(16, Math.round(22 * 67 / titleText.length));\n\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n\n  title: {\n    text: titleText,\n    left: \"center\",\n    top: 22,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: \"bold\" },\n  },\n\n  legend: {\n    data: [\"Below Base Case\", \"Above Base Case\"],\n    top: 66,\n    textStyle: { color: t.inkSoft, fontSize: 15 },\n    itemWidth: 22,\n    itemHeight: 14,\n  },\n\n  tooltip: {\n    trigger: \"axis\",\n    axisPointer: { type: \"shadow\" },\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    textStyle: { color: t.ink, fontSize: 13 },\n    formatter: function (seriesParams) {\n      var name = seriesParams[0].name;\n      var lo   = minByName[name];\n      var hi   = maxByName[name];\n      return [\n        \"<b>\" + name + \"</b>\",\n        \"Downside: <b>$\" + lo.toFixed(1) + \"M</b>\",\n        \"Base case: <b>$\" + baseNPV.toFixed(1) + \"M</b>\",\n        \"Upside: <b>$\" + hi.toFixed(1) + \"M</b>\",\n        \"Range: <b>$\" + (hi - lo).toFixed(1) + \"M</b>\",\n      ].join(\"<br/>\");\n    },\n  },\n\n  grid: { containLabel: true, left: 60, right: 110, top: 106, bottom: 84 },\n\n  xAxis: {\n    type: \"value\",\n    min: xMin,\n    max: 20,\n    name: \"Net Present Value ($ Million)\",\n    nameLocation: \"middle\",\n    nameGap: 48,\n    nameTextStyle: { color: t.ink, fontSize: 16 },\n    axisLabel: {\n      color: t.inkSoft,\n      fontSize: 14,\n      formatter: function (v) { return \"$\" + v + \"M\"; },\n    },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    splitLine: { lineStyle: { color: t.grid } },\n    axisTick: { show: false },\n  },\n\n  yAxis: {\n    type: \"category\",\n    data: names,\n    axisLabel: { color: t.inkSoft, fontSize: 14, margin: 12 },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    splitLine: { show: false },\n    axisTick: { show: false },\n  },\n\n  series: [\n    // Transparent offset — shifts each bar rightward to its true start (minOut)\n    {\n      name: \"_offset\",\n      type: \"bar\",\n      stack: \"tornado\",\n      barWidth: \"55%\",\n      data: offsetData,\n      itemStyle: { color: \"transparent\" },\n      emphasis: { disabled: true },\n      silent: true,\n    },\n\n    // Left wing: output is below base case (downside scenarios)\n    // Uses Imprint matte red — semantic anchor for loss/adverse\n    {\n      name: \"Below Base Case\",\n      type: \"bar\",\n      stack: \"tornado\",\n      data: leftData,\n      itemStyle: { color: t.palette[4], borderRadius: [3, 0, 0, 3] },\n      label: {\n        show: true,\n        position: \"insideLeft\",\n        color: \"#ffffff\",\n        fontSize: 11,\n        fontWeight: \"bold\",\n        formatter: function (p) { return \"$\" + minByName[p.name].toFixed(1) + \"M\"; },\n        overflow: \"truncate\",\n      },\n      markLine: {\n        silent: true,\n        symbol: \"none\",\n        lineStyle: { color: t.ink, width: 2, type: \"dashed\", opacity: 0.65 },\n        label: { show: false },\n        data: [{ xAxis: baseNPV }],\n      },\n    },\n\n    // Right wing: output is above base case (upside scenarios)\n    // Uses Imprint brand green — first categorical series, always #009E73\n    {\n      name: \"Above Base Case\",\n      type: \"bar\",\n      stack: \"tornado\",\n      data: rightData,\n      itemStyle: { color: t.palette[0], borderRadius: [0, 3, 3, 0] },\n      label: {\n        show: true,\n        position: \"right\",\n        color: t.inkSoft,\n        fontSize: 11,\n        fontWeight: \"bold\",\n        formatter: function (p) { return \"$\" + maxByName[p.name].toFixed(1) + \"M\"; },\n      },\n    },\n  ],\n});\n"}