{"spec_id":"tree-decision","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\ntree-decision: Decision Tree Visualization with Probabilities\nLibrary: plotly 6.7.0 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\n\nimport plotly.graph_objects as go\n\n\n# Theme tokens — Imprint palette / chrome (see prompts/default-style-guide.md)\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 categorical palette — node type colors (positions 1→3)\nC_DECISION = \"#009E73\"  # brand green — decision nodes (first series)\nC_CHANCE = \"#4467A3\"  # blue — chance nodes\nC_TERMINAL = \"#BD8233\"  # ochre — terminal/payoff nodes\nC_PRUNED = INK_MUTED  # theme-adaptive muted — pruned elements\n\n# Data — two-stage product launch investment decision\nnodes = {\n    \"D1\": {\n        \"type\": \"decision\",\n        \"parent\": None,\n        \"label\": None,\n        \"prob\": None,\n        \"payoff\": None,\n        \"emv\": 280,\n        \"pruned\": False,\n    },\n    \"C1\": {\n        \"type\": \"chance\",\n        \"parent\": \"D1\",\n        \"label\": \"Launch Product\",\n        \"prob\": None,\n        \"payoff\": None,\n        \"emv\": 280,\n        \"pruned\": False,\n    },\n    \"T1\": {\n        \"type\": \"terminal\",\n        \"parent\": \"C1\",\n        \"label\": \"High Demand\",\n        \"prob\": 0.6,\n        \"payoff\": 500,\n        \"emv\": None,\n        \"pruned\": False,\n    },\n    \"T2\": {\n        \"type\": \"terminal\",\n        \"parent\": \"C1\",\n        \"label\": \"Low Demand\",\n        \"prob\": 0.4,\n        \"payoff\": -50,\n        \"emv\": None,\n        \"pruned\": False,\n    },\n    \"C2\": {\n        \"type\": \"chance\",\n        \"parent\": \"D1\",\n        \"label\": \"License Tech\",\n        \"prob\": None,\n        \"payoff\": None,\n        \"emv\": 170,\n        \"pruned\": True,\n    },\n    \"T3\": {\n        \"type\": \"terminal\",\n        \"parent\": \"C2\",\n        \"label\": \"Accepted\",\n        \"prob\": 0.7,\n        \"payoff\": 200,\n        \"emv\": None,\n        \"pruned\": True,\n    },\n    \"T4\": {\n        \"type\": \"terminal\",\n        \"parent\": \"C2\",\n        \"label\": \"Rejected\",\n        \"prob\": 0.3,\n        \"payoff\": 100,\n        \"emv\": None,\n        \"pruned\": True,\n    },\n    \"T5\": {\n        \"type\": \"terminal\",\n        \"parent\": \"D1\",\n        \"label\": \"Do Nothing\",\n        \"prob\": None,\n        \"payoff\": 0,\n        \"emv\": None,\n        \"pruned\": True,\n    },\n}\n\n# Layout positions (x, y) — left-to-right tree; distributed to reduce upper-right gap\npositions = {\n    \"D1\": (0.0, 0.50),\n    \"C1\": (0.35, 0.79),\n    \"T1\": (0.72, 0.88),\n    \"T2\": (0.72, 0.68),\n    \"C2\": (0.35, 0.33),\n    \"T3\": (0.72, 0.46),\n    \"T4\": (0.72, 0.20),\n    \"T5\": (0.35, 0.06),\n}\n\n# Plot\nfig = go.Figure()\n\n# Draw edges (branches)\nfor nid, n in nodes.items():\n    if n[\"parent\"] is None:\n        continue\n    p_x, p_y = positions[n[\"parent\"]]\n    c_x, c_y = positions[nid]\n    edge_color = C_PRUNED if n[\"pruned\"] else C_DECISION\n    dash = \"dash\" if n[\"pruned\"] else \"solid\"\n    line_width = 2.5 if n[\"pruned\"] else 4\n    opacity = 0.5 if n[\"pruned\"] else 1.0\n\n    hover_parts = [f\"<b>{n['label'] or ''}</b>\"]\n    if n[\"prob\"] is not None:\n        hover_parts.append(f\"Probability: {n['prob']:.0%}\")\n    if n[\"payoff\"] is not None:\n        hover_parts.append(f\"Payoff: ${n['payoff']:+,}\")\n    if n[\"pruned\"]:\n        hover_parts.append(\"<i>Pruned (suboptimal)</i>\")\n    edge_hover = \"<br>\".join(hover_parts)\n\n    fig.add_trace(\n        go.Scatter(\n            x=[p_x, c_x],\n            y=[p_y, c_y],\n            mode=\"lines\",\n            line={\"color\": edge_color, \"width\": line_width, \"dash\": dash},\n            opacity=opacity,\n            hoverinfo=\"text\",\n            hovertext=[edge_hover, edge_hover],\n            showlegend=False,\n        )\n    )\n\n    # Branch label at midpoint\n    t = 0.42 if n[\"parent\"] == \"D1\" else 0.5\n    mid_x = p_x + (c_x - p_x) * t\n    mid_y = p_y + (c_y - p_y) * t\n    label_text = n[\"label\"] or \"\"\n    if n[\"prob\"] is not None:\n        label_text = f\"{n['label']} (p={n['prob']})\"\n    text_color = C_PRUNED if n[\"pruned\"] else INK\n\n    yshift = 22\n    if nid in (\"T5\", \"C2\"):\n        yshift = -18\n\n    fig.add_annotation(\n        x=mid_x,\n        y=mid_y,\n        text=f\"<b>{label_text}</b>\",\n        showarrow=False,\n        font={\"size\": 14, \"color\": text_color, \"family\": \"Arial, sans-serif\"},\n        yshift=yshift,\n    )\n\n# Pruned double-strike marks\nfor nid, n in nodes.items():\n    if not n[\"pruned\"] or n[\"parent\"] is None:\n        continue\n    p_x, p_y = positions[n[\"parent\"]]\n    c_x, c_y = positions[nid]\n    mark_x = p_x + (c_x - p_x) * 0.18\n    mark_y = p_y + (c_y - p_y) * 0.18\n    fig.add_annotation(\n        x=mark_x, y=mark_y, text=\"//\", showarrow=False, font={\"size\": 18, \"color\": \"#AE3030\", \"family\": \"Arial Black\"}\n    )\n\n# Draw nodes — decision (rect), chance (circle), terminal (triangle)\nbase_size_x = 0.032\nbase_size_y = 0.05\nmax_emv = 280  # D1=280, C1=280, C2=170\n\nfor nid, n in nodes.items():\n    n_x, n_y = positions[nid]\n    if n[\"pruned\"]:\n        node_color = C_PRUNED\n    elif n[\"type\"] == \"decision\":\n        node_color = C_DECISION\n    elif n[\"type\"] == \"chance\":\n        node_color = C_CHANCE\n    else:\n        node_color = C_TERMINAL\n    node_opacity = 0.65 if n[\"pruned\"] else 1.0\n\n    # Scale node size by EMV magnitude (min 0.6×, max 1.4× base)\n    emv_scale = max(0.6, min(1.4, n[\"emv\"] / max_emv)) if n[\"emv\"] is not None else 1.0\n    shape_size_x = base_size_x * emv_scale\n    shape_size_y = base_size_y * emv_scale\n\n    hover_parts = [f\"<b>{nid}</b> — {n['type'].title()} Node\"]\n    if n[\"emv\"] is not None:\n        hover_parts.append(f\"EMV: <b>${n['emv']:,}</b>\")\n    if n[\"payoff\"] is not None:\n        hover_parts.append(f\"Payoff: <b>${n['payoff']:+,}</b>\")\n    if n[\"prob\"] is not None:\n        hover_parts.append(f\"Probability: {n['prob']:.0%}\")\n    if n[\"label\"]:\n        hover_parts.append(f\"Branch: {n['label']}\")\n    if n[\"pruned\"]:\n        hover_parts.append(\"<i>Pruned (suboptimal path)</i>\")\n    elif n[\"type\"] != \"terminal\":\n        hover_parts.append(\"<i>Optimal path</i>\")\n    node_hover = \"<br>\".join(hover_parts)\n\n    if n[\"type\"] == \"decision\":\n        fig.add_shape(\n            type=\"rect\",\n            x0=n_x - shape_size_x,\n            y0=n_y - shape_size_y,\n            x1=n_x + shape_size_x,\n            y1=n_y + shape_size_y,\n            fillcolor=node_color,\n            opacity=node_opacity,\n            line={\"color\": PAGE_BG, \"width\": 2.5},\n            xref=\"x\",\n            yref=\"y\",\n        )\n    elif n[\"type\"] == \"chance\":\n        fig.add_shape(\n            type=\"circle\",\n            x0=n_x - shape_size_x,\n            y0=n_y - shape_size_y,\n            x1=n_x + shape_size_x,\n            y1=n_y + shape_size_y,\n            fillcolor=node_color,\n            opacity=node_opacity,\n            line={\"color\": PAGE_BG, \"width\": 2.5},\n            xref=\"x\",\n            yref=\"y\",\n        )\n    else:\n        # Terminal node: right-pointing triangle\n        fig.add_trace(\n            go.Scatter(\n                x=[n_x],\n                y=[n_y],\n                mode=\"markers\",\n                marker={\n                    \"size\": 52,\n                    \"color\": node_color,\n                    \"symbol\": \"triangle-right\",\n                    \"line\": {\"color\": PAGE_BG, \"width\": 2.5},\n                    \"opacity\": node_opacity,\n                },\n                hoverinfo=\"text\",\n                hovertext=[node_hover],\n                hoverlabel={\"bgcolor\": node_color, \"font_size\": 13, \"font_color\": PAGE_BG},\n                showlegend=False,\n            )\n        )\n\n    # Invisible scatter for hover on decision/chance nodes (shapes don't support hover)\n    if n[\"type\"] in (\"decision\", \"chance\"):\n        fig.add_trace(\n            go.Scatter(\n                x=[n_x],\n                y=[n_y],\n                mode=\"markers\",\n                marker={\"size\": 50, \"color\": \"rgba(0,0,0,0)\", \"symbol\": \"square\"},\n                hoverinfo=\"text\",\n                hovertext=[node_hover],\n                hoverlabel={\"bgcolor\": node_color, \"font_size\": 13, \"font_color\": PAGE_BG},\n                showlegend=False,\n            )\n        )\n\n    # EMV label inside decision/chance nodes\n    if n[\"emv\"] is not None:\n        emv_text_color = PAGE_BG\n        fig.add_annotation(\n            x=n_x,\n            y=n_y,\n            text=f\"<b>${n['emv']}</b>\",\n            showarrow=False,\n            font={\"size\": 14, \"color\": emv_text_color, \"family\": \"Arial, sans-serif\"},\n        )\n\n    # Payoff label to the right of terminal nodes\n    if n[\"payoff\"] is not None:\n        payoff_color = C_PRUNED if n[\"pruned\"] else INK\n        fig.add_annotation(\n            x=n_x,\n            y=n_y,\n            text=f\"<b>${n['payoff']:+,}</b>\",\n            showarrow=False,\n            font={\"size\": 14, \"color\": payoff_color, \"family\": \"Arial, sans-serif\"},\n            xshift=58,\n        )\n\n# Legend entries\nfor legend_name, legend_color, legend_symbol in [\n    (\"Decision Node\", C_DECISION, \"square\"),\n    (\"Chance Node\", C_CHANCE, \"circle\"),\n    (\"Terminal Node\", C_TERMINAL, \"triangle-right\"),\n]:\n    fig.add_trace(\n        go.Scatter(\n            x=[None],\n            y=[None],\n            mode=\"markers\",\n            marker={\"size\": 14, \"color\": legend_color, \"symbol\": legend_symbol, \"line\": {\"color\": PAGE_BG, \"width\": 1}},\n            name=legend_name,\n            showlegend=True,\n        )\n    )\n\nfig.add_trace(\n    go.Scatter(\n        x=[None],\n        y=[None],\n        mode=\"lines\",\n        line={\"color\": C_PRUNED, \"width\": 3, \"dash\": \"dash\"},\n        name=\"Pruned Branch\",\n        showlegend=True,\n    )\n)\n\n# Title — scaled font for length > 67 chars\ntitle_main = \"Product Launch Decision · tree-decision · python · plotly · anyplot.ai\"\nn_chars = len(title_main)\nratio = 67 / n_chars if n_chars > 67 else 1.0\ntitle_fontsize = max(11, round(16 * ratio))\n\nsubtitle_color = INK_SOFT\ntitle_full = (\n    f\"{title_main}\"\n    f\"<br><sup style='color:{subtitle_color}; font-size:12px'>\"\n    f\"Optimal path: Launch Product · EMV $280K · Pruned branches marked with //</sup>\"\n)\n\n# Style\nfig.update_layout(\n    autosize=False,\n    width=800,\n    height=450,\n    margin={\"l\": 80, \"r\": 40, \"t\": 100, \"b\": 60},\n    title={\n        \"text\": title_full,\n        \"font\": {\"size\": title_fontsize, \"color\": INK, \"family\": \"Arial, sans-serif\"},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n        \"y\": 0.97,\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    xaxis={\"showgrid\": False, \"zeroline\": False, \"showticklabels\": False, \"showline\": False, \"range\": [-0.08, 0.95]},\n    yaxis={\"showgrid\": False, \"zeroline\": False, \"showticklabels\": False, \"showline\": False, \"range\": [-0.05, 1.00]},\n    legend={\n        \"font\": {\"size\": 10, \"family\": \"Arial, sans-serif\", \"color\": INK_SOFT},\n        \"x\": 0.01,\n        \"y\": 0.01,\n        \"xanchor\": \"left\",\n        \"yanchor\": \"bottom\",\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"itemsizing\": \"constant\",\n    },\n    hoverlabel={\"font_size\": 13},\n    hovermode=\"closest\",\n)\n\n# Save\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}