{"spec_id":"bar-tornado-sensitivity","library":"d3","language":"javascript","code":"// anyplot.ai\n// bar-tornado-sensitivity: Tornado Diagram for Sensitivity Analysis\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 88/100 | Created: 2026-06-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\nconst BASE_NPV = 10.0;\n\n// NPV sensitivity data for a capital project — values in $M\nconst rawData = [\n  { parameter: \"Revenue Growth Rate\", lowNpv:  5.5, highNpv: 15.0 },\n  { parameter: \"Discount Rate\",       lowNpv:  7.0, highNpv: 14.0 },\n  { parameter: \"Operating Costs\",     lowNpv:  7.0, highNpv: 13.5 },\n  { parameter: \"Market Size\",         lowNpv:  7.0, highNpv: 13.0 },\n  { parameter: \"Tax Rate\",            lowNpv:  8.5, highNpv: 12.5 },\n  { parameter: \"Material Costs\",      lowNpv:  8.5, highNpv: 12.0 },\n  { parameter: \"Labour Efficiency\",   lowNpv:  8.5, highNpv: 11.5 },\n  { parameter: \"Customer Retention\",  lowNpv:  9.0, highNpv: 11.0 },\n];\n\n// Sort by range descending — widest bar at top (tornado shape)\nconst data = rawData.slice().sort(\n  (a, b) => (b.highNpv - b.lowNpv) - (a.highNpv - a.lowNpv)\n);\n\nconst margin = { top: 90, right: 150, bottom: 100, left: 234 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// SVG mount\nconst svg = d3.select(\"#container\")\n  .append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// Scales\nconst x = d3.scaleLinear().domain([4, 17]).nice().range([0, iw]);\nconst y = d3.scaleBand()\n  .domain(data.map(d => d.parameter))\n  .range([0, ih]).padding(0.38);\nconst bh = y.bandwidth();\n\n// Imprint palette — high scenario = brand green (#009E73), low = semantic red (#AE3030)\nconst COLOR_HIGH = t.palette[0];\nconst COLOR_LOW  = t.palette[4];\n\n// Gridlines (vertical, subtle)\nx.ticks(7).forEach(v => {\n  g.append(\"line\")\n    .attr(\"x1\", x(v)).attr(\"x2\", x(v))\n    .attr(\"y1\", 0).attr(\"y2\", ih)\n    .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n});\n\n// Base case reference line (dashed)\nconst baseX = x(BASE_NPV);\ng.append(\"line\")\n  .attr(\"x1\", baseX).attr(\"x2\", baseX)\n  .attr(\"y1\", -20).attr(\"y2\", ih + 10)\n  .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"10 6\");\n\n// Base case label\ng.append(\"text\")\n  .attr(\"x\", baseX + 8).attr(\"y\", -24)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(`Base: $${BASE_NPV}M`);\n\n// Bars (one row group per parameter)\nconst rows = g.selectAll(\".row\").data(data).join(\"g\")\n  .attr(\"class\", \"row\")\n  .attr(\"transform\", d => `translate(0,${y(d.parameter)})`);\n\n// Low-scenario bars — extend left from base\nrows.append(\"rect\")\n  .attr(\"x\", d => x(d.lowNpv))\n  .attr(\"y\", 0)\n  .attr(\"width\", d => x(BASE_NPV) - x(d.lowNpv))\n  .attr(\"height\", bh)\n  .attr(\"fill\", COLOR_LOW)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"opacity\", 0.88);\n\n// High-scenario bars — extend right from base\nrows.append(\"rect\")\n  .attr(\"x\", x(BASE_NPV))\n  .attr(\"y\", 0)\n  .attr(\"width\", d => x(d.highNpv) - x(BASE_NPV))\n  .attr(\"height\", bh)\n  .attr(\"fill\", COLOR_HIGH)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"opacity\", 0.88);\n\n// Value labels at bar ends\nrows.append(\"text\")\n  .attr(\"x\", d => x(d.lowNpv) - 7)\n  .attr(\"y\", bh / 2).attr(\"dy\", \"0.35em\")\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(d => `$${d.lowNpv}M`);\n\nrows.append(\"text\")\n  .attr(\"x\", d => x(d.highNpv) + 7)\n  .attr(\"y\", bh / 2).attr(\"dy\", \"0.35em\")\n  .attr(\"text-anchor\", \"start\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(d => `$${d.highNpv}M`);\n\n// Callout: annotate the highest-impact parameter (widest bar at top)\nconst topParam = data[0];\nconst topBarTopY = y(topParam.parameter);\nconst topBarMidX = (x(topParam.lowNpv) + x(topParam.highNpv)) / 2;\ng.append(\"line\")\n  .attr(\"x1\", x(topParam.lowNpv) + 6).attr(\"x2\", x(topParam.highNpv) - 6)\n  .attr(\"y1\", topBarTopY - 10).attr(\"y2\", topBarTopY - 10)\n  .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1).attr(\"stroke-dasharray\", \"4 3\");\ng.append(\"text\")\n  .attr(\"x\", topBarMidX).attr(\"y\", topBarTopY - 14)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\").style(\"font-style\", \"italic\")\n  .text(\"highest impact\");\n\n// Y-axis (parameter names)\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).tickSize(0).tickPadding(14));\nyAxis.selectAll(\"text\").attr(\"fill\", t.ink).style(\"font-size\", \"16px\");\nyAxis.select(\".domain\").attr(\"stroke\", \"none\");\n\n// X-axis\nconst xAxis = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(7).tickSize(0).tickPadding(8).tickFormat(d => `$${d}M`));\nxAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nxAxis.select(\".domain\").attr(\"stroke\", \"none\");\n\n// X-axis label\ng.append(\"text\")\n  .attr(\"x\", iw / 2).attr(\"y\", ih + 72)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"17px\")\n  .text(\"Net Present Value ($ Millions)\");\n\n// Legend — in right margin, vertically centered near top of chart\nconst lgX = iw + 20;\nconst lgY = Math.round(ih / 2) - 30;\n[{ label: \"High Scenario\", color: COLOR_HIGH },\n { label: \"Low Scenario\",  color: COLOR_LOW  }].forEach((item, i) => {\n  g.append(\"rect\")\n    .attr(\"x\", lgX).attr(\"y\", lgY + i * 34)\n    .attr(\"width\", 16).attr(\"height\", 16)\n    .attr(\"fill\", item.color).attr(\"opacity\", 0.88);\n  g.append(\"text\")\n    .attr(\"x\", lgX + 22).attr(\"y\", lgY + i * 34 + 12)\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n    .text(item.label);\n});\n\n// Chart title\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"bar-tornado-sensitivity · javascript · d3 · anyplot.ai\");\n"}