{"spec_id":"bar-tornado-sensitivity","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nbar-tornado-sensitivity: Tornado Diagram for Sensitivity Analysis\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-06-02\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\nimport pandas as pd\nfrom PIL import Image\n\n\n# Drop script directory from sys.path so `altair` resolves the package, not this file\nsys.path[:] = [p for p in sys.path if os.path.abspath(p or \".\") != os.path.dirname(os.path.abspath(__file__))]\nalt = importlib.import_module(\"altair\")\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — semantic: green=upside gain, red=downside loss\nHIGH_COLOR = \"#009E73\"  # Imprint position 1, brand green — High Scenario (upside)\nLOW_COLOR = \"#AE3030\"  # Imprint position 5, matte red — Low Scenario (downside)\n\n# Data — NPV sensitivity analysis for a capital investment project\nbase_npv = 250.0  # Base case NPV in $M\n\nparameters = [\n    (\"Discount Rate\", 195.0, 320.0),\n    (\"Revenue Growth\", 200.0, 310.0),\n    (\"Operating Costs\", 210.0, 305.0),\n    (\"Terminal Value\", 215.0, 295.0),\n    (\"CapEx Estimate\", 218.0, 288.0),\n    (\"Tax Rate\", 225.0, 280.0),\n    (\"Working Capital\", 232.0, 272.0),\n    (\"Inflation Rate\", 238.0, 265.0),\n]\n\nrecords = []\nfor param, low, high in parameters:\n    span = abs(high - low)\n    records.append({\"parameter\": param, \"value\": low, \"side\": \"Low Scenario\", \"span\": span, \"base\": base_npv})\n    records.append({\"parameter\": param, \"value\": high, \"side\": \"High Scenario\", \"span\": span, \"base\": base_npv})\n\ndf = pd.DataFrame(records)\n\n# Sort by span descending — widest bar at top\ny_sort = alt.EncodingSortField(field=\"span\", order=\"descending\")\n\ntitle_str = \"bar-tornado-sensitivity · python · altair · anyplot.ai\"\n\n# Bars\nbars = (\n    alt.Chart(df)\n    .mark_bar(cornerRadius=2, height=22)\n    .encode(\n        x=alt.X(\n            \"value:Q\",\n            title=\"Net Present Value ($M)\",\n            scale=alt.Scale(domain=[175, 335]),\n            axis=alt.Axis(tickCount=7, grid=False),\n        ),\n        x2=\"base:Q\",\n        y=alt.Y(\"parameter:N\", sort=y_sort, title=None, axis=alt.Axis(grid=False)),\n        color=alt.Color(\n            \"side:N\",\n            scale=alt.Scale(domain=[\"Low Scenario\", \"High Scenario\"], range=[LOW_COLOR, HIGH_COLOR]),\n            title=None,\n        ),\n        tooltip=[\n            alt.Tooltip(\"parameter:N\", title=\"Parameter\"),\n            alt.Tooltip(\"side:N\", title=\"Scenario\"),\n            alt.Tooltip(\"value:Q\", title=\"NPV ($M)\", format=\",.0f\"),\n        ],\n    )\n)\n\n# Value labels — low scenario (left side, outside bar)\nlow_labels = (\n    alt.Chart(df)\n    .transform_filter(alt.datum.side == \"Low Scenario\")\n    .mark_text(fontSize=10, fontWeight=\"bold\", dx=-6, align=\"right\", color=LOW_COLOR)\n    .encode(x=\"value:Q\", y=alt.Y(\"parameter:N\", sort=y_sort), text=alt.Text(\"value:Q\", format=\"$,.0f\"))\n)\n\n# Value labels — high scenario (right side, outside bar)\nhigh_labels = (\n    alt.Chart(df)\n    .transform_filter(alt.datum.side == \"High Scenario\")\n    .mark_text(fontSize=10, fontWeight=\"bold\", dx=6, align=\"left\", color=HIGH_COLOR)\n    .encode(x=\"value:Q\", y=alt.Y(\"parameter:N\", sort=y_sort), text=alt.Text(\"value:Q\", format=\"$,.0f\"))\n)\n\n# Base case reference line\nrule = (\n    alt.Chart(pd.DataFrame({\"x\": [base_npv]}))\n    .mark_rule(strokeDash=[5, 4], strokeWidth=1.5, color=INK_SOFT)\n    .encode(x=\"x:Q\")\n)\n\n# Base case label anchored to top parameter\nsort_order = [p for p, _, _ in sorted(parameters, key=lambda x: abs(x[2] - x[1]), reverse=True)]\nbase_label_df = pd.DataFrame(\n    {\n        \"x\": [base_npv],\n        \"y\": [sort_order[0]],\n        \"label\": [f\"Base: ${base_npv:.0f}M\"],\n        \"span\": [max(abs(high - low) for _, low, high in parameters)],\n    }\n)\nbase_label = (\n    alt.Chart(base_label_df)\n    .mark_text(align=\"left\", dx=6, dy=-14, fontSize=10, fontWeight=\"bold\", color=INK)\n    .encode(x=\"x:Q\", y=alt.Y(\"y:N\", sort=y_sort), text=\"label:N\")\n)\n\n# Interactive: hover highlights, click selects for persistent focus\nhighlight = alt.selection_point(on=\"pointerover\", fields=[\"parameter\"], empty=False)\nclick = alt.selection_point(fields=[\"parameter\"])\n\nbars = bars.add_params(highlight, click).encode(\n    opacity=alt.condition(highlight | click, alt.value(1.0), alt.value(0.65)),\n    strokeWidth=alt.condition(click, alt.value(2), alt.value(0)),\n    stroke=alt.condition(click, alt.value(INK), alt.value(None)),\n)\n\nchart = (\n    (bars + low_labels + high_labels + rule + base_label)\n    .properties(\n        width=620,\n        height=314,\n        title=alt.Title(\n            title_str,\n            subtitle=\"One-at-a-time NPV sensitivity — wider bars indicate stronger parameter influence\",\n            fontSize=16,\n            subtitleFontSize=11,\n            subtitleColor=INK_MUTED,\n            anchor=\"start\",\n            color=INK,\n        ),\n        background=PAGE_BG,\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=620, continuousHeight=314)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        gridColor=INK,\n        gridOpacity=0.15,\n    )\n    .configure_legend(\n        labelFontSize=10,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        symbolSize=200,\n        orient=\"bottom\",\n        direction=\"horizontal\",\n        titleFontSize=0,\n        padding=10,\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n    )\n    .configure_title(color=INK)\n)\n\n# Save PNG then pad to exact 3200×1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# Save HTML (interactive)\nchart.save(f\"plot-{THEME}.html\")\n"}