{"spec_id":"tree-decision","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ntree-decision: Decision Tree Visualization with Probabilities\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\nimport sys as _sys\n\n\n# Prevent this file (pygal.py) from shadowing the installed pygal package\n_sys.path = [p for p in _sys.path if p not in (\"\", os.path.dirname(os.path.abspath(__file__)))]\ndel _sys\n\nimport cairosvg\nimport pygal\nfrom pygal.style import Style\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens (Imprint palette)\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — semantic assignments for decision tree node roles\nDECISION_CLR = \"#4467A3\"  # blue (Imprint #3)\nCHANCE_CLR = \"#BD8233\"  # ochre (Imprint #4)\nGAIN_CLR = \"#009E73\"  # brand green (Imprint #1, semantic: gain/profit)\nLOSS_CLR = \"#AE3030\"  # matte red (Imprint #5, semantic: loss)\nAMBER = \"#DDCC77\"  # amber anchor — golden border for optimal outcome region\n\nFONT = \"DejaVu Sans, sans-serif\"\nW, H = 3200, 1800  # canvas: landscape 16:9, canonical Imprint size\n\n# Node shape sizes for 3200×1800 canvas\nDEC_HALF = 48  # decision square half-side\nCHC_R = 40  # chance circle radius\nTRI = 50  # terminal triangle half-side (larger for clear visibility)\n\n# SVG overlay font sizes at native 3200×1800 pixels\nFS = {\"branch\": 24, \"prob\": 23, \"payoff\": 28, \"emv_hdr\": 20, \"emv_val\": 23, \"caption\": 23}\n\n# Decision tree — New Product Launch\n# EMV rollback: C1: 0.6×500 + 0.4×(-100) = 260K [OPTIMAL]\n#               C2: 0.7×250 + 0.3×50      = 190K [PRUNED]\n#               D1: max(260, 190, 0)       = 260K\nnodes = {\n    \"D1\": {\"type\": \"decision\", \"x\": 400, \"y\": 870, \"emv\": 260, \"label\": \"Strategy\\nChoice\"},\n    \"C1\": {\"type\": \"chance\", \"x\": 1500, \"y\": 420, \"emv\": 260, \"label\": \"Market\\nOutcome\"},\n    \"C2\": {\"type\": \"chance\", \"x\": 1500, \"y\": 1310, \"emv\": 190, \"label\": \"License\\nResult\"},\n    \"T1\": {\"type\": \"terminal\", \"x\": 2567, \"y\": 240, \"payoff\": 500},\n    \"T2\": {\"type\": \"terminal\", \"x\": 2567, \"y\": 600, \"payoff\": -100},\n    \"T3\": {\"type\": \"terminal\", \"x\": 2567, \"y\": 1120, \"payoff\": 250},\n    \"T4\": {\"type\": \"terminal\", \"x\": 2567, \"y\": 1500, \"payoff\": 50},\n    \"T5\": {\"type\": \"terminal\", \"x\": 1500, \"y\": 1700, \"payoff\": 0},\n}\n\nbranches = [\n    (\"D1\", \"C1\", \"Launch Product\", None, False),\n    (\"D1\", \"C2\", \"Sell License\", None, True),\n    (\"D1\", \"T5\", \"Do Nothing\", None, True),\n    (\"C1\", \"T1\", \"High Demand\", 0.6, False),\n    (\"C1\", \"T2\", \"Low Demand\", 0.4, False),\n    (\"C2\", \"T3\", \"Accepted\", 0.7, True),\n    (\"C2\", \"T4\", \"Rejected\", 0.3, True),\n]\n\n# Imprint palette passed to pygal Style (first series = brand green)\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\nstyle = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=(DECISION_CLR, CHANCE_CLR, GAIN_CLR, LOSS_CLR, INK_MUTED),\n    title_font_size=66,\n    legend_font_size=44,\n    label_font_size=56,\n    major_label_font_size=44,\n    value_font_size=36,\n    font_family=FONT,\n)\n\n\ndef fmt_k(v):\n    return f\"${v:,.0f}K\" if v >= 0 else f\"−${abs(v):,.0f}K\"\n\n\ndef elbow(x1, y1, x2, y2, ratio=0.55):\n    mx = x1 + (x2 - x1) * ratio\n    return f\"M {x1},{y1} L {mx},{y1} L {mx},{y2} L {x2},{y2}\", mx\n\n\ndef tx(x, y, s, size, fill=None, weight=\"normal\", anchor=\"middle\", italic=False):\n    fill = fill or INK\n    fs = ' font-style=\"italic\"' if italic else \"\"\n    return (\n        f'<text x=\"{x}\" y=\"{y}\" text-anchor=\"{anchor}\" font-size=\"{size}\" '\n        f'fill=\"{fill}\" font-weight=\"{weight}\" font-family=\"{FONT}\"{fs}>{s}</text>'\n    )\n\n\n# Build pygal XY chart — provides native legend, tooltips, and themed background\nchart = pygal.XY(\n    width=W,\n    height=H,\n    style=style,\n    title=\"tree-decision · python · pygal · anyplot.ai\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=5,\n    legend_box_size=26,\n    show_x_guides=False,\n    show_y_guides=False,\n    show_x_labels=False,\n    show_y_labels=False,\n    dots_size=0,\n    stroke=False,\n    range=(0, H),\n    xrange=(0, W),\n    margin=10,\n    margin_bottom=5,\n    spacing=8,\n    tooltip_border_radius=8,\n    tooltip_fancy_mode=True,\n    value_formatter=fmt_k,\n)\n\nchart.add(\n    \"Decision Node\",\n    [\n        {\n            \"value\": (n[\"x\"], n[\"y\"]),\n            \"label\": n[\"label\"].replace(\"\\n\", \" \"),\n            \"xlink\": {\"title\": f\"EMV: ${n['emv']}K | Optimal: Launch Product\"},\n        }\n        for n in nodes.values()\n        if n[\"type\"] == \"decision\"\n    ],\n)\nchart.add(\n    \"Chance Node\",\n    [\n        {\"value\": (n[\"x\"], n[\"y\"]), \"label\": n[\"label\"].replace(\"\\n\", \" \"), \"xlink\": {\"title\": f\"EMV: ${n['emv']}K\"}}\n        for n in nodes.values()\n        if n[\"type\"] == \"chance\"\n    ],\n)\nchart.add(\n    \"Terminal (Gain)\",\n    [\n        {\"value\": (n[\"x\"], n[\"y\"]), \"label\": fmt_k(n[\"payoff\"])}\n        for n in nodes.values()\n        if n[\"type\"] == \"terminal\" and n[\"payoff\"] > 0\n    ],\n)\nchart.add(\n    \"Terminal (Loss)\",\n    [\n        {\"value\": (n[\"x\"], n[\"y\"]), \"label\": fmt_k(n[\"payoff\"])}\n        for n in nodes.values()\n        if n[\"type\"] == \"terminal\" and n[\"payoff\"] < 0\n    ],\n)\nchart.add(\n    \"Terminal (Neutral)\",\n    [\n        {\"value\": (n[\"x\"], n[\"y\"]), \"label\": fmt_k(n[\"payoff\"])}\n        for n in nodes.values()\n        if n[\"type\"] == \"terminal\" and n[\"payoff\"] == 0\n    ],\n)\n\nbase_svg = chart.render().decode(\"utf-8\")\n\n# SVG defs: node gradients + drop-shadow filter\ndefs = (\n    \"<defs>\"\n    '<filter id=\"sh\" x=\"-20%\" y=\"-20%\" width=\"150%\" height=\"150%\">'\n    '<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"4\" result=\"b\"/>'\n    '<feOffset dx=\"2\" dy=\"3\" result=\"s\"/>'\n    '<feFlood flood-color=\"#000\" flood-opacity=\"0.18\" result=\"c\"/>'\n    '<feComposite in=\"c\" in2=\"s\" operator=\"in\" result=\"shadow\"/>'\n    '<feMerge><feMergeNode in=\"shadow\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>'\n    \"</filter>\"\n    f'<linearGradient id=\"g_d\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">'\n    f'<stop offset=\"0%\" stop-color=\"#6688C8\"/><stop offset=\"100%\" stop-color=\"{DECISION_CLR}\"/>'\n    \"</linearGradient>\"\n    f'<linearGradient id=\"g_c\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">'\n    f'<stop offset=\"0%\" stop-color=\"#D8A060\"/><stop offset=\"100%\" stop-color=\"{CHANCE_CLR}\"/>'\n    \"</linearGradient>\"\n    \"</defs>\"\n)\n\n# Amber golden border highlights the optimal outcome region (C1 subtree: T1 + T2).\n# Previous version used a light-blue fill matching altair — change request: golden accent instead.\namber_box = (\n    f'<rect x=\"1440\" y=\"155\" width=\"1360\" height=\"510\" '\n    f'fill=\"{AMBER}\" fill-opacity=\"0.05\" stroke=\"{AMBER}\" stroke-width=\"3\" rx=\"14\" opacity=\"0.7\"/>'\n    + tx(2785, 182, \"Optimal\", 22, fill=AMBER, weight=\"bold\", anchor=\"end\")\n)\n\ng = ['<g id=\"dt\">']\ng.append(amber_box)\n\n# Branch connectors\nfor src, dst, label, prob, pruned in branches:\n    p, c = nodes[src], nodes[dst]\n    path_d, mx = elbow(p[\"x\"], p[\"y\"], c[\"x\"], c[\"y\"])\n    if pruned:\n        g.append(\n            f'<path d=\"{path_d}\" fill=\"none\" stroke=\"{INK_MUTED}\" '\n            f'stroke-width=\"2\" opacity=\"0.45\" stroke-dasharray=\"14,8\"/>'\n        )\n    else:\n        g.append(f'<path d=\"{path_d}\" fill=\"none\" stroke=\"{GAIN_CLR}\" stroke-width=\"4\" opacity=\"0.9\"/>')\n\n    vy = p[\"y\"] + (c[\"y\"] - p[\"y\"]) * 0.45\n    lc, fw = (INK_MUTED, \"normal\") if pruned else (INK, \"bold\")\n    g.append(tx(mx - 16, vy, label, FS[\"branch\"], fill=lc, weight=fw, anchor=\"end\"))\n\n    if prob is not None:\n        pc = INK_MUTED if pruned else CHANCE_CLR\n        g.append(tx(mx + 16, vy, f\"p = {prob}\", FS[\"prob\"], fill=pc, anchor=\"start\", italic=True))\n\n    if pruned:\n        # X mark near top of vertical segment — avoids crowding with branch labels\n        sy = p[\"y\"] + (c[\"y\"] - p[\"y\"]) * 0.10\n        g.append(\n            f'<line x1=\"{mx - 10}\" y1=\"{sy - 9}\" x2=\"{mx + 10}\" y2=\"{sy + 9}\" '\n            f'stroke=\"{LOSS_CLR}\" stroke-width=\"3\" opacity=\"0.7\"/>'\n        )\n        g.append(\n            f'<line x1=\"{mx - 10}\" y1=\"{sy + 9}\" x2=\"{mx + 10}\" y2=\"{sy - 9}\" '\n            f'stroke=\"{LOSS_CLR}\" stroke-width=\"3\" opacity=\"0.7\"/>'\n        )\n\n# Nodes\nfor nd in nodes.values():\n    x, y, ntype = nd[\"x\"], nd[\"y\"], nd[\"type\"]\n    if ntype == \"decision\":\n        g.append(\n            f'<rect x=\"{x - DEC_HALF}\" y=\"{y - DEC_HALF}\" width=\"{DEC_HALF * 2}\" '\n            f'height=\"{DEC_HALF * 2}\" fill=\"url(#g_d)\" stroke=\"{PAGE_BG}\" '\n            f'stroke-width=\"3\" rx=\"8\" filter=\"url(#sh)\"/>'\n        )\n        g.append(tx(x, y - 6, \"EMV\", FS[\"emv_hdr\"], fill=\"white\", weight=\"bold\"))\n        g.append(tx(x, y + 18, f\"${nd['emv']}K\", FS[\"emv_val\"], fill=\"white\", weight=\"bold\"))\n        for i, line in enumerate(nd[\"label\"].split(\"\\n\")):\n            g.append(tx(x, y + DEC_HALF + 30 + i * 28, line, FS[\"caption\"], fill=DECISION_CLR, weight=\"bold\"))\n    elif ntype == \"chance\":\n        g.append(\n            f'<circle cx=\"{x}\" cy=\"{y}\" r=\"{CHC_R}\" fill=\"url(#g_c)\" '\n            f'stroke=\"{PAGE_BG}\" stroke-width=\"3\" filter=\"url(#sh)\"/>'\n        )\n        g.append(tx(x, y - 5, \"EMV\", FS[\"emv_hdr\"] - 2, fill=\"white\", weight=\"bold\"))\n        g.append(tx(x, y + 16, f\"${nd['emv']}K\", FS[\"emv_val\"] - 2, fill=\"white\", weight=\"bold\"))\n        for i, line in enumerate(nd[\"label\"].split(\"\\n\")):\n            g.append(tx(x, y + CHC_R + 28 + i * 26, line, FS[\"caption\"], fill=CHANCE_CLR, weight=\"bold\"))\n    elif ntype == \"terminal\":\n        payoff = nd[\"payoff\"]\n        fill = GAIN_CLR if payoff > 0 else (LOSS_CLR if payoff < 0 else INK_MUTED)\n        pts_str = f\"{x - TRI},{y - TRI} {x - TRI},{y + TRI} {x + TRI},{y}\"\n        g.append(f'<polygon points=\"{pts_str}\" fill=\"{fill}\" stroke=\"{PAGE_BG}\" stroke-width=\"2\" filter=\"url(#sh)\"/>')\n        g.append(tx(x + TRI + 18, y + 9, fmt_k(payoff), FS[\"payoff\"], fill=fill, weight=\"bold\", anchor=\"start\"))\n\ng.append(\"</g>\")\n\nsvg_out = base_svg.replace(\"</svg>\", f\"{defs}\\n{chr(10).join(g)}\\n</svg>\")\n\nwith open(f\"plot-{THEME}.svg\", \"w\") as f:\n    f.write(svg_out)\n\ncairosvg.svg2png(bytestring=svg_out.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n\nhtml_content = (\n    \"<!DOCTYPE html>\\n<html>\\n<head>\\n\"\n    f\"    <title>tree-decision &middot; python &middot; pygal &middot; anyplot.ai</title>\\n\"\n    f\"    <style>\\n\"\n    f\"        body {{ margin: 0; padding: 20px; background: {PAGE_BG}; font-family: sans-serif; }}\\n\"\n    f\"        .container {{ max-width: 100%; margin: 0 auto; text-align: center; }}\\n\"\n    f\"        object {{ width: 100%; max-width: {W}px; height: auto; }}\\n\"\n    f\"    </style>\\n</head>\\n<body>\\n\"\n    '    <div class=\"container\">\\n'\n    f'        <object type=\"image/svg+xml\" data=\"plot-{THEME}.svg\">Decision tree</object>\\n'\n    \"    </div>\\n</body>\\n</html>\"\n)\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(html_content)\n"}