{"spec_id":"tree-decision","library":"d3","language":"javascript","code":"// anyplot.ai\n// tree-decision: Decision Tree Visualization with Probabilities\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 120, right: 250, bottom: 60, left: 140 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: two-stage product-launch decision (in $ millions) ---------------\n// Rollback EMVs are pre-computed: C3 = 0.7*12.0 + 0.3*1.0 = 8.7,\n// D1 = max(Expand=8.7, Hold=6.0) = 8.7, C1 = 0.6*8.7 + 0.4*(-2.0) = 4.42,\n// C2 = 0.6*3.5 + 0.4*1.0 = 2.5, D0 = max(Invest=4.42, License=2.5, DoNothing=0) = 4.42.\nconst treeData = {\n  id: \"D0\",\n  type: \"decision\",\n  label: \"Launch Decision\",\n  emv: 4.42,\n  children: [\n    {\n      id: \"C1\",\n      type: \"chance\",\n      label: \"Market Demand\",\n      branchLabel: \"Invest\",\n      emv: 4.42,\n      pruned: false,\n      children: [\n        {\n          id: \"D1\",\n          type: \"decision\",\n          label: \"Expand Capacity?\",\n          branchLabel: \"High Demand\",\n          probability: 0.6,\n          emv: 8.7,\n          pruned: false,\n          children: [\n            {\n              id: \"C3\",\n              type: \"chance\",\n              label: \"Competitor Response\",\n              branchLabel: \"Expand\",\n              emv: 8.7,\n              pruned: false,\n              children: [\n                { id: \"T5\", type: \"terminal\", branchLabel: \"No Entry\", probability: 0.7, payoff: 12.0, pruned: false },\n                { id: \"T6\", type: \"terminal\", branchLabel: \"Entry\", probability: 0.3, payoff: 1.0, pruned: false },\n              ],\n            },\n            { id: \"T4\", type: \"terminal\", branchLabel: \"Hold\", payoff: 6.0, pruned: true },\n          ],\n        },\n        { id: \"T3\", type: \"terminal\", branchLabel: \"Low Demand\", probability: 0.4, payoff: -2.0, pruned: false },\n      ],\n    },\n    {\n      id: \"C2\",\n      type: \"chance\",\n      label: \"Market Demand\",\n      branchLabel: \"License\",\n      emv: 2.5,\n      pruned: true,\n      children: [\n        { id: \"T1\", type: \"terminal\", branchLabel: \"High Demand\", probability: 0.6, payoff: 3.5, pruned: true },\n        { id: \"T2\", type: \"terminal\", branchLabel: \"Low Demand\", probability: 0.4, payoff: 1.0, pruned: true },\n      ],\n    },\n    { id: \"T0\", type: \"terminal\", branchLabel: \"Do Nothing\", payoff: 0.0, pruned: true },\n  ],\n};\n\n// --- Layout -------------------------------------------------------------\nconst root = d3.hierarchy(treeData);\nd3.tree()\n  .size([ih, iw])\n  .separation((a, b) => (a.parent === b.parent ? 1.3 : 2.2))(root);\nfor (const d of root.descendants()) {\n  d.px = margin.left + d.y;\n  d.py = margin.top + d.x;\n}\n// Pruned branches that terminate early (License -> High/Low Demand, Do Nothing) stop\n// 1-3 levels short of the deepest live path (Invest -> Expand -> Competitor Response),\n// leaving the lower-right quadrant of the canvas empty. Stretch those terminals to the\n// same right edge as the deepest branch so pruned and live paths both use the full width.\nfor (const d of root.descendants()) {\n  if (d.data.type === \"terminal\" && d.data.pruned) d.px = margin.left + iw;\n}\n\n// --- Shape geometry -------------------------------------------------------\nconst SQ = 30; // decision square half-side\nconst CR = 32; // chance circle radius\nconst trianglePath = (cx, cy) => `M ${cx - 23},${cy - 22} L ${cx - 23},${cy + 22} L ${cx + 27},${cy} Z`;\n\nconst fmt = (v) => `${v < 0 ? \"-$\" : \"$\"}${Math.abs(v).toFixed(2)}M`;\n\n// --- SVG mount --------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Links (branches) --------------------------------------------------------\nconst linkGen = d3.linkHorizontal().x((d) => d.px).y((d) => d.py);\nconst linkLayer = svg.append(\"g\");\n\nlinkLayer\n  .selectAll(\"path.branch\")\n  .data(root.links())\n  .join(\"path\")\n  .attr(\"class\", \"branch\")\n  .attr(\"fill\", \"none\")\n  .attr(\"d\", linkGen)\n  .attr(\"stroke\", (l) => (l.target.data.pruned ? t.inkSoft : t.palette[0]))\n  .attr(\"stroke-width\", (l) => (l.target.data.pruned ? 2 : 3))\n  .attr(\"stroke-dasharray\", (l) => (l.target.data.pruned ? \"6,5\" : null))\n  .attr(\"opacity\", (l) => (l.target.data.pruned ? 0.4 : 0.9));\n\n// Branch labels (option name for decision branches, probability for chance branches).\n// A page-bg halo keeps the label legible where the link curve bows steeply near the\n// midpoint — a fixed vertical offset alone isn't enough clearance on those branches.\nlinkLayer\n  .selectAll(\"text.branch-label\")\n  .data(root.links())\n  .join(\"text\")\n  .attr(\"class\", \"branch-label\")\n  .attr(\"x\", (l) => (l.source.px + l.target.px) / 2)\n  .attr(\"y\", (l) => (l.source.py + l.target.py) / 2 - 12)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"paint-order\", \"stroke\")\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 5)\n  .attr(\"stroke-linejoin\", \"round\")\n  .style(\"font-size\", \"13px\")\n  .style(\"font-weight\", \"500\")\n  .attr(\"fill\", t.inkSoft)\n  .text((l) => {\n    const d = l.target.data;\n    return d.probability != null ? `${d.branchLabel} (p=${d.probability.toFixed(2)})` : d.branchLabel;\n  });\n\n// Cross mark on pruned branches\nlinkLayer\n  .selectAll(\"text.prune-mark\")\n  .data(root.links().filter((l) => l.target.data.pruned))\n  .join(\"text\")\n  .attr(\"class\", \"prune-mark\")\n  .attr(\"x\", (l) => (l.source.px + l.target.px) / 2)\n  .attr(\"y\", (l) => (l.source.py + l.target.py) / 2 + 20)\n  .attr(\"text-anchor\", \"middle\")\n  .style(\"font-size\", \"18px\")\n  .style(\"font-weight\", \"700\")\n  .attr(\"fill\", t.inkSoft)\n  .attr(\"opacity\", 0.7)\n  .text(\"✖\");\n\n// --- Nodes -------------------------------------------------------------\nconst nodeLayer = svg.append(\"g\");\nconst node = nodeLayer\n  .selectAll(\"g.node\")\n  .data(root.descendants())\n  .join(\"g\")\n  .attr(\"class\", \"node\")\n  .attr(\"opacity\", (d) => (d.data.pruned ? 0.45 : 1));\n\n// Decision nodes: squares (Imprint position 1 — always brand green)\nnode\n  .filter((d) => d.data.type === \"decision\")\n  .append(\"rect\")\n  .attr(\"x\", (d) => d.px - SQ)\n  .attr(\"y\", (d) => d.py - SQ)\n  .attr(\"width\", SQ * 2)\n  .attr(\"height\", SQ * 2)\n  .attr(\"rx\", 6)\n  .attr(\"fill\", t.palette[0]);\n\n// Chance nodes: circles (Imprint position 2), with an ink stroke for contrast\nnode\n  .filter((d) => d.data.type === \"chance\")\n  .append(\"circle\")\n  .attr(\"cx\", (d) => d.px)\n  .attr(\"cy\", (d) => d.py)\n  .attr(\"r\", CR)\n  .attr(\"fill\", t.palette[1])\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 1.5);\n\n// Terminal nodes: right-pointing triangles (Imprint position 3)\nnode\n  .filter((d) => d.data.type === \"terminal\")\n  .append(\"path\")\n  .attr(\"d\", (d) => trianglePath(d.px, d.py))\n  .attr(\"fill\", t.palette[2]);\n\n// Titles above decision/chance nodes\nnode\n  .filter((d) => d.data.type !== \"terminal\")\n  .append(\"text\")\n  .attr(\"x\", (d) => d.px)\n  .attr(\"y\", (d) => d.py - CR - 14)\n  .attr(\"text-anchor\", \"middle\")\n  .style(\"font-size\", \"13px\")\n  .style(\"font-weight\", \"600\")\n  .attr(\"fill\", t.ink)\n  .text((d) => d.data.label);\n\n// EMV adjacent below decision/chance nodes (spec: \"inside or adjacent\").\n// Sized up from 13px so the rollback value — the single most information-dense\n// label in the diagram — still reads at the 400px mobile thumbnail scale.\nnode\n  .filter((d) => d.data.type !== \"terminal\")\n  .append(\"text\")\n  .attr(\"x\", (d) => d.px)\n  .attr(\"y\", (d) => d.py + CR + 20)\n  .attr(\"text-anchor\", \"middle\")\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"700\")\n  .attr(\"fill\", t.inkSoft)\n  .text((d) => `EMV ${fmt(d.data.emv)}`);\n\n// Terminal payoff label to the right of the triangle tip\nnode\n  .filter((d) => d.data.type === \"terminal\")\n  .append(\"text\")\n  .attr(\"x\", (d) => d.px + 36)\n  .attr(\"y\", (d) => d.py)\n  .attr(\"dominant-baseline\", \"middle\")\n  .style(\"font-size\", \"17px\")\n  .style(\"font-weight\", \"700\")\n  .attr(\"fill\", (d) => (d.data.payoff < 0 ? t.palette[4] : t.ink))\n  .text((d) => fmt(d.data.payoff));\n\n// --- Legend ---------------------------------------------------------------\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${margin.left}, ${height - 42})`);\nconst legendItems = [\n  { shape: \"square\", fill: t.palette[0], label: \"Decision\" },\n  { shape: \"circle\", fill: t.palette[1], label: \"Chance\" },\n  { shape: \"triangle\", fill: t.palette[2], label: \"Terminal\" },\n];\nlet lx = 0;\nfor (const item of legendItems) {\n  const g = legend.append(\"g\").attr(\"transform\", `translate(${lx}, 0)`);\n  if (item.shape === \"square\") {\n    g.append(\"rect\").attr(\"x\", -10).attr(\"y\", -10).attr(\"width\", 20).attr(\"height\", 20).attr(\"rx\", 3).attr(\"fill\", item.fill);\n  } else if (item.shape === \"circle\") {\n    g.append(\"circle\").attr(\"r\", 11).attr(\"fill\", item.fill).attr(\"stroke\", t.ink).attr(\"stroke-width\", 1.2);\n  } else {\n    g.append(\"path\").attr(\"d\", trianglePath(0, 0)).attr(\"transform\", \"scale(0.45)\").attr(\"fill\", item.fill);\n  }\n  g.append(\"text\").attr(\"x\", 22).attr(\"y\", 5).style(\"font-size\", \"13px\").attr(\"fill\", t.inkSoft).text(item.label);\n  lx += 140;\n}\nlegend\n  .append(\"line\")\n  .attr(\"x1\", lx)\n  .attr(\"x2\", lx + 32)\n  .attr(\"y1\", 0)\n  .attr(\"y2\", 0)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"6,5\")\n  .attr(\"opacity\", 0.6);\nlegend.append(\"text\").attr(\"x\", lx + 42).attr(\"y\", 5).style(\"font-size\", \"13px\").attr(\"fill\", t.inkSoft).text(\"Pruned branch\");\n\n// --- Title ------------------------------------------------------------------\nconst TITLE = \"Product Launch Decision · tree-decision · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / TITLE.length)));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(TITLE);\n"}