{"spec_id":"tree-decision","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ntree-decision: Decision Tree Visualization with Probabilities\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport pandas as pd\nfrom PIL import Image\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\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 — canonical order for node types\nDECISION_COLOR = \"#009E73\"  # position 1 (green) — first categorical series\nCHANCE_COLOR = \"#C475FD\"  # position 2 (lavender)\nTERMINAL_COLOR = \"#4467A3\"  # position 3 (blue)\nPRUNE_RED = \"#AE3030\"  # semantic anchor — bad/loss/error\n\n# Data - Two-stage investment decision tree\nnodes_df = pd.DataFrame(\n    [\n        {\n            \"id\": \"D1\",\n            \"type\": \"decision\",\n            \"x\": 50,\n            \"y\": 300,\n            \"emv\": \"$152K\",\n            \"label\": \"\",\n            \"payoff\": \"\",\n            \"detail\": \"Root decision | EMV=$152K | Optimal: Invest Large\",\n            \"pruned\": False,\n        },\n        {\n            \"id\": \"C1\",\n            \"type\": \"chance\",\n            \"x\": 300,\n            \"y\": 150,\n            \"emv\": \"$152K\",\n            \"label\": \"Invest\\nLarge\",\n            \"payoff\": \"\",\n            \"detail\": \"Chance | EMV=$152K | 0.40×300+0.35×120+0.25×(-40)\",\n            \"pruned\": False,\n        },\n        {\n            \"id\": \"C2\",\n            \"type\": \"chance\",\n            \"x\": 300,\n            \"y\": 470,\n            \"emv\": \"$108K\",\n            \"label\": \"Invest\\nSmall\",\n            \"payoff\": \"\",\n            \"detail\": \"Chance (pruned) | EMV=$108K | 0.40×180+0.35×90+0.25×20\",\n            \"pruned\": True,\n        },\n        {\n            \"id\": \"T1\",\n            \"type\": \"terminal\",\n            \"x\": 530,\n            \"y\": 55,\n            \"emv\": \"\",\n            \"label\": \"High Demand\",\n            \"payoff\": \"$300K\",\n            \"detail\": \"High Demand | Payoff=$300K | Prob=0.40\",\n            \"pruned\": False,\n        },\n        {\n            \"id\": \"T2\",\n            \"type\": \"terminal\",\n            \"x\": 530,\n            \"y\": 160,\n            \"emv\": \"\",\n            \"label\": \"Moderate\",\n            \"payoff\": \"$120K\",\n            \"detail\": \"Moderate Demand | Payoff=$120K | Prob=0.35\",\n            \"pruned\": False,\n        },\n        {\n            \"id\": \"T3\",\n            \"type\": \"terminal\",\n            \"x\": 530,\n            \"y\": 255,\n            \"emv\": \"\",\n            \"label\": \"Low Demand\",\n            \"payoff\": \"$-40K\",\n            \"detail\": \"Low Demand | Payoff=-$40K | Prob=0.25\",\n            \"pruned\": False,\n        },\n        {\n            \"id\": \"T4\",\n            \"type\": \"terminal\",\n            \"x\": 530,\n            \"y\": 380,\n            \"emv\": \"\",\n            \"label\": \"High Demand\",\n            \"payoff\": \"$180K\",\n            \"detail\": \"High Demand | Payoff=$180K | Prob=0.40\",\n            \"pruned\": True,\n        },\n        {\n            \"id\": \"T5\",\n            \"type\": \"terminal\",\n            \"x\": 530,\n            \"y\": 470,\n            \"emv\": \"\",\n            \"label\": \"Moderate\",\n            \"payoff\": \"$90K\",\n            \"detail\": \"Moderate Demand | Payoff=$90K | Prob=0.35\",\n            \"pruned\": True,\n        },\n        {\n            \"id\": \"T6\",\n            \"type\": \"terminal\",\n            \"x\": 530,\n            \"y\": 555,\n            \"emv\": \"\",\n            \"label\": \"Low Demand\",\n            \"payoff\": \"$20K\",\n            \"detail\": \"Low Demand | Payoff=$20K | Prob=0.25\",\n            \"pruned\": True,\n        },\n    ]\n)\n\nedges_df = pd.DataFrame(\n    [\n        {\"x\": 50, \"y\": 300, \"x2\": 300, \"y2\": 150, \"label\": \"Invest Large\", \"prob\": \"\", \"pruned\": False},\n        {\"x\": 50, \"y\": 300, \"x2\": 300, \"y2\": 470, \"label\": \"Invest Small\", \"prob\": \"\", \"pruned\": True},\n        {\"x\": 300, \"y\": 150, \"x2\": 530, \"y2\": 55, \"label\": \"\", \"prob\": \"0.40\", \"pruned\": False},\n        {\"x\": 300, \"y\": 150, \"x2\": 530, \"y2\": 160, \"label\": \"\", \"prob\": \"0.35\", \"pruned\": False},\n        {\"x\": 300, \"y\": 150, \"x2\": 530, \"y2\": 255, \"label\": \"\", \"prob\": \"0.25\", \"pruned\": False},\n        {\"x\": 300, \"y\": 470, \"x2\": 530, \"y2\": 380, \"label\": \"\", \"prob\": \"0.40\", \"pruned\": True},\n        {\"x\": 300, \"y\": 470, \"x2\": 530, \"y2\": 470, \"label\": \"\", \"prob\": \"0.35\", \"pruned\": True},\n        {\"x\": 300, \"y\": 470, \"x2\": 530, \"y2\": 555, \"label\": \"\", \"prob\": \"0.25\", \"pruned\": True},\n    ]\n)\n\n# Shared scales — inverted y so tree grows downward\nx_scale = alt.Scale(domain=[-30, 680])\ny_scale = alt.Scale(domain=[620, -30])\nx_enc = alt.X(\"x:Q\", scale=x_scale, axis=None)\ny_enc = alt.Y(\"y:Q\", scale=y_scale, axis=None)\n\nhover = alt.selection_point(on=\"pointerover\", fields=[\"id\"], empty=False)\nnode_tooltip = [\n    alt.Tooltip(\"id:N\", title=\"Node\"),\n    alt.Tooltip(\"type:N\", title=\"Type\"),\n    alt.Tooltip(\"detail:N\", title=\"Info\"),\n]\n\n# Subtle background panel behind optimal path (storytelling)\noptimal_path_bg = pd.DataFrame([{\"x\": 20, \"y\": -10, \"x2\": 670, \"y2\": 300}])\noptimal_bg = (\n    alt.Chart(optimal_path_bg)\n    .mark_rect(cornerRadius=14, color=ELEVATED_BG, opacity=0.85, stroke=INK_SOFT, strokeWidth=1.0, strokeDash=[5, 4])\n    .encode(x=alt.X(\"x:Q\", scale=x_scale, axis=None), y=alt.Y(\"y:Q\", scale=y_scale, axis=None), x2=\"x2:Q\", y2=\"y2:Q\")\n)\n\noptimal_label = (\n    alt.Chart(pd.DataFrame([{\"x\": 640, \"y\": 18}]))\n    .mark_text(fontSize=13, fontStyle=\"italic\", color=INK_MUTED, align=\"right\", fontWeight=\"bold\")\n    .encode(\n        x=alt.X(\"x:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"y:Q\", scale=y_scale, axis=None),\n        text=alt.value(\"optimal path\"),\n    )\n)\n\n# Edges\nactive_edges = edges_df[~edges_df[\"pruned\"]]\npruned_edges = edges_df[edges_df[\"pruned\"]]\n\nactive_lines = (\n    alt.Chart(active_edges).mark_rule(strokeWidth=3.0, color=INK).encode(x=x_enc, y=y_enc, x2=\"x2:Q\", y2=\"y2:Q\")\n)\n\npruned_lines = (\n    alt.Chart(pruned_edges)\n    .mark_rule(strokeWidth=1.8, strokeDash=[7, 5], opacity=0.40, color=INK_SOFT)\n    .encode(x=x_enc, y=y_enc, x2=\"x2:Q\", y2=\"y2:Q\")\n)\n\n# Pruned cross mark — uses Imprint semantic-red anchor\npruned_cross = (\n    alt.Chart(pd.DataFrame([{\"cx\": 140, \"cy\": 405}]))\n    .mark_text(fontSize=26, fontWeight=\"bold\", color=PRUNE_RED, text=\"✕\")\n    .encode(x=alt.X(\"cx:Q\", scale=x_scale, axis=None), y=alt.Y(\"cy:Q\", scale=y_scale, axis=None))\n)\n\n# Node subsets\nchance_df = nodes_df[nodes_df[\"type\"] == \"chance\"]\nterminal_df = nodes_df[nodes_df[\"type\"] == \"terminal\"]\ndecision_df = nodes_df[nodes_df[\"type\"] == \"decision\"]\n\ndecision_nodes = (\n    alt.Chart(decision_df)\n    .mark_square(size=900, color=DECISION_COLOR, stroke=INK, strokeWidth=2.0)\n    .encode(x=x_enc, y=y_enc, size=alt.condition(hover, alt.value(1100), alt.value(900)), tooltip=node_tooltip)\n    .add_params(hover)\n)\n\nchance_nodes = (\n    alt.Chart(chance_df)\n    .mark_circle(size=900, color=CHANCE_COLOR, stroke=INK, strokeWidth=2.0)\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        opacity=alt.condition(alt.datum.pruned == True, alt.value(0.40), alt.value(1.0)),\n        size=alt.condition(hover, alt.value(1100), alt.value(900)),\n        tooltip=node_tooltip,\n    )\n    .add_params(hover)\n)\n\nterminal_nodes = (\n    alt.Chart(terminal_df)\n    .mark_point(shape=\"triangle-right\", size=700, filled=True, color=TERMINAL_COLOR, stroke=INK, strokeWidth=2.0)\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        opacity=alt.condition(alt.datum.pruned == True, alt.value(0.40), alt.value(1.0)),\n        size=alt.condition(hover, alt.value(880), alt.value(700)),\n        tooltip=node_tooltip,\n    )\n    .add_params(hover)\n)\n\n# Text labels\nemv_labels = (\n    alt.Chart(nodes_df)\n    .transform_filter(alt.datum.emv != \"\")\n    .mark_text(fontSize=14, fontWeight=\"bold\", dy=-28, color=INK)\n    .encode(x=x_enc, y=y_enc, text=\"emv:N\")\n)\n\npayoff_labels = (\n    alt.Chart(nodes_df)\n    .transform_filter(alt.datum.payoff != \"\")\n    .mark_text(fontSize=14, fontWeight=\"bold\", dx=44, align=\"left\", color=INK)\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        text=\"payoff:N\",\n        opacity=alt.condition(alt.datum.pruned == True, alt.value(0.40), alt.value(1.0)),\n    )\n)\n\nterminal_desc = (\n    alt.Chart(nodes_df)\n    .transform_filter(alt.datum.type == \"terminal\")\n    .mark_text(fontSize=11, dx=44, dy=16, align=\"left\", color=INK_MUTED)\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        text=\"label:N\",\n        opacity=alt.condition(alt.datum.pruned == True, alt.value(0.40), alt.value(1.0)),\n    )\n)\n\n# Edge midpoint labels\nedges_df[\"mx\"] = (edges_df[\"x\"] + edges_df[\"x2\"]) / 2\nedges_df[\"my\"] = (edges_df[\"y\"] + edges_df[\"y2\"]) / 2\n\nbranch_label_df = edges_df[edges_df[\"label\"] != \"\"]\nprob_label_df = edges_df[edges_df[\"prob\"] != \"\"]\n\nbranch_labels = (\n    alt.Chart(branch_label_df)\n    .mark_text(fontSize=12, fontWeight=\"bold\", dy=-13, color=INK)\n    .encode(\n        x=alt.X(\"mx:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"my:Q\", scale=y_scale, axis=None),\n        text=\"label:N\",\n        opacity=alt.condition(alt.datum.pruned == True, alt.value(0.40), alt.value(1.0)),\n    )\n)\n\nprob_labels = (\n    alt.Chart(prob_label_df)\n    .mark_text(fontSize=12, dy=-11, color=INK_SOFT, fontWeight=\"bold\")\n    .encode(\n        x=alt.X(\"mx:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"my:Q\", scale=y_scale, axis=None),\n        text=\"prob:N\",\n        opacity=alt.condition(alt.datum.pruned == True, alt.value(0.40), alt.value(1.0)),\n    )\n)\n\n# Legend\nlegend_data = pd.DataFrame(\n    [\n        {\"lx\": 100, \"ly\": 590, \"label\": \"Decision Node\", \"shape\": \"square\"},\n        {\"lx\": 280, \"ly\": 590, \"label\": \"Chance Node\", \"shape\": \"circle\"},\n        {\"lx\": 460, \"ly\": 590, \"label\": \"Terminal Node\", \"shape\": \"triangle\"},\n    ]\n)\n\nlegend_sq = (\n    alt.Chart(legend_data.query(\"shape == 'square'\"))\n    .mark_square(size=320, color=DECISION_COLOR, stroke=INK, strokeWidth=1.2)\n    .encode(x=alt.X(\"lx:Q\", scale=x_scale, axis=None), y=alt.Y(\"ly:Q\", scale=y_scale, axis=None))\n)\nlegend_ci = (\n    alt.Chart(legend_data.query(\"shape == 'circle'\"))\n    .mark_circle(size=320, color=CHANCE_COLOR, stroke=INK, strokeWidth=1.2)\n    .encode(x=alt.X(\"lx:Q\", scale=x_scale, axis=None), y=alt.Y(\"ly:Q\", scale=y_scale, axis=None))\n)\nlegend_tr = (\n    alt.Chart(legend_data.query(\"shape == 'triangle'\"))\n    .mark_point(shape=\"triangle-right\", size=260, filled=True, color=TERMINAL_COLOR, stroke=INK, strokeWidth=1.2)\n    .encode(x=alt.X(\"lx:Q\", scale=x_scale, axis=None), y=alt.Y(\"ly:Q\", scale=y_scale, axis=None))\n)\nlegend_txt = (\n    alt.Chart(legend_data)\n    .mark_text(fontSize=10, dx=17, align=\"left\", color=INK_SOFT)\n    .encode(x=alt.X(\"lx:Q\", scale=x_scale, axis=None), y=alt.Y(\"ly:Q\", scale=y_scale, axis=None), text=\"label:N\")\n)\n\n# Combine all layers — landscape inner view 620×320 → PIL-padded to 3200×1800\nchart = (\n    alt.layer(\n        optimal_bg,\n        active_lines,\n        pruned_lines,\n        pruned_cross,\n        decision_nodes,\n        chance_nodes,\n        terminal_nodes,\n        emv_labels,\n        payoff_labels,\n        terminal_desc,\n        branch_labels,\n        prob_labels,\n        optimal_label,\n        legend_sq,\n        legend_ci,\n        legend_tr,\n        legend_txt,\n    )\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"tree-decision · python · altair · anyplot.ai\", fontSize=16, color=INK, anchor=\"start\", offset=12\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_title(color=INK)\n)\n\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\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\nchart.save(f\"plot-{THEME}.html\")\n"}