{"spec_id":"tree-decision","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\ntree-decision: Decision Tree Visualization with Probabilities\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 84/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens (Imprint palette)\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 — node types in canonical order\nCOLOR_DECISION = \"#009E73\"  # position 1, brand green\nCOLOR_CHANCE = \"#C475FD\"  # position 2, lavender\nCOLOR_TERMINAL = \"#4467A3\"  # position 3, blue\nCOLOR_PRUNE = \"#AE3030\"  # position 5, semantic: error/rejected\n\n# Data — Two-stage investment decision tree\n# EMV rollback:\n#   C3: 0.7*500 + 0.3*100 = 380\n#   D2: max(380, 300) = 380  → Don't Expand pruned\n#   C1: 0.6*380 + 0.4*50  = 248\n#   C2: 0.5*250 + 0.5*120 = 185\n#   D1: max(248, 185, 0)  = 248 → License & Do Nothing pruned\n#\n# Layout designed for 3200×1800 canvas (data units ≈ pixels at 1:1 scale).\n# Columns at x ∈ {220, 850, 1500, 2150, 2800}; rows spread across y ∈ [100, 1700].\nnodes = {\n    \"D1\": {\"type\": \"decision\", \"x\": 220, \"y\": 750, \"value\": 248},\n    \"C1\": {\"type\": \"chance\", \"x\": 850, \"y\": 1050, \"value\": 248},\n    \"C2\": {\"type\": \"chance\", \"x\": 850, \"y\": 475, \"value\": 185},\n    \"T1\": {\"type\": \"terminal\", \"x\": 850, \"y\": 125, \"value\": 0},\n    \"D2\": {\"type\": \"decision\", \"x\": 1500, \"y\": 1300, \"value\": 380},\n    \"T2\": {\"type\": \"terminal\", \"x\": 1500, \"y\": 750, \"value\": 50},\n    \"T3\": {\"type\": \"terminal\", \"x\": 1500, \"y\": 600, \"value\": 250},\n    \"T4\": {\"type\": \"terminal\", \"x\": 1500, \"y\": 350, \"value\": 120},\n    \"C3\": {\"type\": \"chance\", \"x\": 2150, \"y\": 1475, \"value\": 380},\n    \"T5\": {\"type\": \"terminal\", \"x\": 2150, \"y\": 1100, \"value\": 300},\n    \"T6\": {\"type\": \"terminal\", \"x\": 2800, \"y\": 1620, \"value\": 500},\n    \"T7\": {\"type\": \"terminal\", \"x\": 2800, \"y\": 1350, \"value\": 100},\n}\n\nedges = [\n    (\"D1\", \"C1\", \"Launch Product\", None, False),\n    (\"D1\", \"C2\", \"License Tech\", None, True),\n    (\"D1\", \"T1\", \"Do Nothing\", None, True),\n    (\"C1\", \"D2\", \"High Demand\", 0.6, False),\n    (\"C1\", \"T2\", \"Low Demand\", 0.4, False),\n    (\"C2\", \"T3\", \"Strong Partner\", 0.5, True),\n    (\"C2\", \"T4\", \"Weak Partner\", 0.5, True),\n    (\"D2\", \"C3\", \"Expand\", None, False),\n    (\"D2\", \"T5\", \"Don't Expand\", None, True),\n    (\"C3\", \"T6\", \"Success\", 0.7, False),\n    (\"C3\", \"T7\", \"Failure\", 0.3, False),\n]\n\n# Canvas: 3200×1800 landscape (Imprint catalog standard)\nW, H = 3200, 1800\n\np = figure(\n    width=W,\n    height=H,\n    title=\"tree-decision · python · bokeh · anyplot.ai\",\n    x_range=(-280, 3250),\n    y_range=(-100, 1880),\n    toolbar_location=None,  # omit toolbar so exported PNG height == H\n    min_border_bottom=60,\n    min_border_left=60,\n    min_border_top=110,\n    min_border_right=60,\n)\n\n# Subtle fill highlighting the optimal-path region (upper portion)\np.add_layout(BoxAnnotation(bottom=850, top=1700, fill_color=COLOR_DECISION, fill_alpha=0.04))\n\n# Draw edges: right-angle connectors (horizontal → vertical → horizontal)\nfor src, dst, label, prob, pruned in edges:\n    sx, sy = nodes[src][\"x\"], nodes[src][\"y\"]\n    dx, dy = nodes[dst][\"x\"], nodes[dst][\"y\"]\n    mid_x = (sx + dx) / 2\n\n    lw = 4 if pruned else 7\n    alpha = 0.35 if pruned else 0.80\n    dash = [14, 8] if pruned else \"solid\"\n\n    p.line(\n        [sx, mid_x, mid_x, dx], [sy, sy, dy, dy], line_width=lw, line_alpha=alpha, line_color=INK_SOFT, line_dash=dash\n    )\n\n    # Branch label — placed on the vertical connector segment\n    branch_text = f\"{label} (p={prob})\" if prob is not None else label\n    label_y = (sy + dy) / 2\n    p.add_layout(\n        Label(\n            x=mid_x,\n            y=label_y,\n            text=branch_text,\n            text_font_size=\"21pt\",\n            text_color=INK_MUTED if pruned else INK_SOFT,\n            text_align=\"right\",\n            text_baseline=\"middle\",\n            x_offset=-10,\n        )\n    )\n\n    # Red cross mark on pruned branches\n    if pruned:\n        cx, cy, cs = mid_x + 48, label_y, 18\n        p.multi_line(\n            [[cx - cs, cx + cs], [cx - cs, cx + cs]],\n            [[cy - cs, cy + cs], [cy + cs, cy - cs]],\n            line_width=5,\n            line_color=COLOR_PRUNE,\n            line_alpha=0.75,\n        )\n\n# Build ColumnDataSources for each node type\ndecision_nodes = {k: v for k, v in nodes.items() if v[\"type\"] == \"decision\"}\nchance_nodes = {k: v for k, v in nodes.items() if v[\"type\"] == \"chance\"}\nterminal_nodes = {k: v for k, v in nodes.items() if v[\"type\"] == \"terminal\"}\n\n\ndecision_src = ColumnDataSource(\n    data={\n        \"x\": [n[\"x\"] for n in decision_nodes.values()],\n        \"y\": [n[\"y\"] for n in decision_nodes.values()],\n        \"name\": list(decision_nodes.keys()),\n        \"emv\": [f\"${n['value']}K\" for n in decision_nodes.values()],\n        \"node_type\": [\"Decision\"] * len(decision_nodes),\n    }\n)\nchance_src = ColumnDataSource(\n    data={\n        \"x\": [n[\"x\"] for n in chance_nodes.values()],\n        \"y\": [n[\"y\"] for n in chance_nodes.values()],\n        \"name\": list(chance_nodes.keys()),\n        \"emv\": [f\"${n['value']}K\" for n in chance_nodes.values()],\n        \"node_type\": [\"Chance\"] * len(chance_nodes),\n    }\n)\nterminal_src = ColumnDataSource(\n    data={\n        \"x\": [n[\"x\"] for n in terminal_nodes.values()],\n        \"y\": [n[\"y\"] for n in terminal_nodes.values()],\n        \"name\": list(terminal_nodes.keys()),\n        \"emv\": [f\"${n['value']}K\" for n in terminal_nodes.values()],\n        \"node_type\": [\"Terminal\"] * len(terminal_nodes),\n    }\n)\n\n# Decision nodes — squares (width/height in data units ≈ pixels)\nr_dec = p.rect(\n    \"x\",\n    \"y\",\n    width=100,\n    height=100,\n    source=decision_src,\n    fill_color=COLOR_DECISION,\n    fill_alpha=0.90,\n    line_color=INK,\n    line_width=3,\n)\n\n# Chance nodes — circles (size in screen px)\nr_ch = p.scatter(\n    \"x\",\n    \"y\",\n    source=chance_src,\n    size=80,\n    marker=\"circle\",\n    fill_color=COLOR_CHANCE,\n    fill_alpha=0.90,\n    line_color=INK,\n    line_width=3,\n)\n\n# Terminal nodes — right-pointing triangles (larger than predecessor)\nr_term = p.scatter(\n    \"x\",\n    \"y\",\n    source=terminal_src,\n    size=78,\n    marker=\"triangle\",\n    fill_color=COLOR_TERMINAL,\n    fill_alpha=0.90,\n    line_color=INK,\n    line_width=3,\n    angle=np.pi / 2,\n)\n\n# Interactive hover\np.add_tools(\n    HoverTool(\n        renderers=[r_dec, r_ch, r_term],\n        tooltips=f\"\"\"\n        <div style=\"font-size:18px;padding:8px;background:{ELEVATED_BG};\n                    color:{INK};border-radius:4px;border:1px solid {INK_SOFT};\">\n            <b>@name</b> (@node_type)<br/>Value: <b>@emv</b>\n        </div>\n        \"\"\",\n        point_policy=\"snap_to_data\",\n    )\n)\n\n# Node labels — placed in data coordinates to avoid overlapping node shapes.\n# Decision/chance: label bottom 70–75 data units above node centre (clears shape top).\n# Terminal: label top 65 data units below node centre (clears triangle bottom).\nfor _nid, nd in nodes.items():\n    if nd[\"type\"] == \"terminal\":\n        p.add_layout(\n            Label(\n                x=nd[\"x\"],\n                y=nd[\"y\"] - 65,  # below triangle\n                text=f\"${nd['value']}K\",\n                text_font_size=\"20pt\",\n                text_font_style=\"bold\",\n                text_align=\"center\",\n                text_baseline=\"top\",\n                text_color=INK,\n            )\n        )\n    else:\n        # Square half-height = 50 data units → label bottom at y+75 clears top edge\n        offset = 75 if nd[\"type\"] == \"decision\" else 60\n        p.add_layout(\n            Label(\n                x=nd[\"x\"],\n                y=nd[\"y\"] + offset,\n                text=f\"EMV ${nd['value']}K\",\n                text_font_size=\"19pt\",\n                text_font_style=\"bold\",\n                text_align=\"center\",\n                text_baseline=\"bottom\",\n                text_color=INK,\n            )\n        )\n\n# Legend — top-left corner\nleg_x = 30\nleg_y0 = 1720\nleg_gap = 62\n\nfor i, (ntype, color, marker, label) in enumerate(\n    [\n        (\"decision\", COLOR_DECISION, \"square\", \"Decision Node\"),\n        (\"chance\", COLOR_CHANCE, \"circle\", \"Chance Node\"),\n        (\"terminal\", COLOR_TERMINAL, \"triangle\", \"Terminal Node\"),\n    ]\n):\n    ly = leg_y0 - i * leg_gap\n    angle = np.pi / 2 if ntype == \"terminal\" else 0\n    p.scatter([leg_x + 18], [ly], size=24, marker=marker, fill_color=color, line_color=INK, line_width=2, angle=angle)\n    p.add_layout(\n        Label(\n            x=leg_x + 18,\n            y=ly,\n            text=label,\n            text_font_size=\"20pt\",\n            text_color=INK_SOFT,\n            x_offset=22,\n            text_baseline=\"middle\",\n        )\n    )\n\n# Pruned legend entry\nly = leg_y0 - 3 * leg_gap\np.line([leg_x + 5, leg_x + 32], [ly, ly], line_width=4, line_dash=[10, 6], line_color=INK_SOFT, line_alpha=0.55)\ncs = 9\np.multi_line(\n    [[leg_x + 18 - cs, leg_x + 18 + cs], [leg_x + 18 - cs, leg_x + 18 + cs]],\n    [[ly - cs, ly + cs], [ly + cs, ly - cs]],\n    line_width=4,\n    line_color=COLOR_PRUNE,\n    line_alpha=0.75,\n)\np.add_layout(\n    Label(\n        x=leg_x + 18,\n        y=ly,\n        text=\"Pruned (suboptimal)\",\n        text_font_size=\"20pt\",\n        text_color=INK_SOFT,\n        x_offset=22,\n        text_baseline=\"middle\",\n    )\n)\n\n# Theme-adaptive chrome\np.title.text_font_size = \"50pt\"\np.title.text_font_style = \"normal\"\np.title.text_color = INK\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.visible = False\np.ygrid.visible = False\np.outline_line_color = INK_SOFT\np.outline_line_alpha = 0.25\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\n# Save interactive HTML (required catalog artifact)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot via headless Chrome — same pattern as highcharts.py.\n# window-size must match figure width/height exactly so the bokeh canvas fills\n# the viewport without white space.\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\n\ndriver = webdriver.Chrome(options=opts)\n# Force exact viewport dimensions via CDP — avoids the ~139 px browser-chrome\n# overhead that headless Chrome subtracts from --window-size height.\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)  # let bokeh JS render\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}