{"spec_id":"tree-decision","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// tree-decision: Decision Tree Visualization with Probabilities\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-08-26\n\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: two-stage product-launch decision (values in $K) ----------------\n// node_type: decision (square) | chance (circle) | terminal (triangle)\n// pruned: true for branches rejected by EMV rollback at each decision node\nconst NODES = [\n  { node_id: \"d0\", node_type: \"decision\", parent_id: null, branch_label: null, probability: null, payoff: null, emv: 197, pruned: false },\n  { node_id: \"c1\", node_type: \"chance\", parent_id: \"d0\", branch_label: \"Launch Full\", probability: null, payoff: null, emv: 197, pruned: false },\n  { node_id: \"c3\", node_type: \"chance\", parent_id: \"d0\", branch_label: \"Launch Regional\", probability: null, payoff: null, emv: 80, pruned: true },\n  { node_id: \"t7\", node_type: \"terminal\", parent_id: \"d0\", branch_label: \"Don't Launch\", probability: null, payoff: 0, emv: null, pruned: true },\n  { node_id: \"d2\", node_type: \"decision\", parent_id: \"c1\", branch_label: \"High Demand\", probability: 0.6, payoff: null, emv: 395, pruned: false },\n  { node_id: \"t4\", node_type: \"terminal\", parent_id: \"c1\", branch_label: \"Low Demand\", probability: 0.4, payoff: -100, emv: null, pruned: false },\n  { node_id: \"c2\", node_type: \"chance\", parent_id: \"d2\", branch_label: \"Expand\", probability: null, payoff: null, emv: 395, pruned: false },\n  { node_id: \"t3\", node_type: \"terminal\", parent_id: \"d2\", branch_label: \"Maintain\", probability: null, payoff: 300, emv: null, pruned: true },\n  { node_id: \"t1\", node_type: \"terminal\", parent_id: \"c2\", branch_label: \"Continues\", probability: 0.7, payoff: 500, emv: null, pruned: false },\n  { node_id: \"t2\", node_type: \"terminal\", parent_id: \"c2\", branch_label: \"Declines\", probability: 0.3, payoff: 150, emv: null, pruned: false },\n  { node_id: \"t5\", node_type: \"terminal\", parent_id: \"c3\", branch_label: \"High Demand\", probability: 0.5, payoff: 120, emv: null, pruned: true },\n  { node_id: \"t6\", node_type: \"terminal\", parent_id: \"c3\", branch_label: \"Low Demand\", probability: 0.5, payoff: 40, emv: null, pruned: true },\n];\nconst nodesById = new Map(NODES.map((n) => [n.node_id, n]));\n\n// --- Left-to-right tree layout: x = depth, y = post-order leaf position ----\nconst childrenOf = new Map();\nNODES.forEach((n) => {\n  if (n.parent_id) {\n    if (!childrenOf.has(n.parent_id)) childrenOf.set(n.parent_id, []);\n    childrenOf.get(n.parent_id).push(n.node_id);\n  }\n});\n\nconst coords = {};\nlet leafCount = 0;\nfunction layout(id, depth) {\n  const kids = childrenOf.get(id) || [];\n  if (kids.length === 0) {\n    coords[id] = { x: depth, y: leafCount };\n    leafCount += 1;\n    return coords[id].y;\n  }\n  const y = kids.reduce((sum, k) => sum + layout(k, depth + 1), 0) / kids.length;\n  coords[id] = { x: depth, y };\n  return y;\n}\nlayout(\"d0\", 0);\nconst maxDepth = Math.max(...Object.values(coords).map((c) => c.x));\n\n// Right-align terminal payoffs into one column (dashed leader line bridges\n// each shallower terminal's structural depth to the aligned column) so the\n// tree's outcomes fill the canvas evenly instead of leaving empty pockets\n// wherever a branch resolves before reaching the deepest stage.\nconst displayCoords = {};\nconst leaders = [];\nNODES.forEach((n) => {\n  const c = coords[n.node_id];\n  if (n.node_type === \"terminal\" && c.x < maxDepth) {\n    displayCoords[n.node_id] = { x: maxDepth, y: c.y };\n    leaders.push({ data: [c, displayCoords[n.node_id]] });\n  } else {\n    displayCoords[n.node_id] = c;\n  }\n});\n\n// --- Helpers -----------------------------------------------------------\nfunction withAlpha(hex, alpha) {\n  const h = hex.replace(\"#\", \"\");\n  const r = parseInt(h.slice(0, 2), 16);\n  const g = parseInt(h.slice(2, 4), 16);\n  const b = parseInt(h.slice(4, 6), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\nconst fmtMoney = (v) => `${v < 0 ? \"-$\" : \"$\"}${Math.abs(v)}K`;\nconst edgeLabel = (child) =>\n  child.probability != null\n    ? `${child.branch_label} (${Math.round(child.probability * 100)}%)`\n    : child.branch_label;\n\n// --- Node styling by type (Imprint palette, canonical order) ---------------\nconst NODE_STYLE = {\n  decision: { pointStyle: \"rect\", color: t.palette[0], radius: 17, rotation: 0, legend: \"Decision\" },\n  chance: { pointStyle: \"circle\", color: t.palette[1], radius: 17, rotation: 0, legend: \"Chance\" },\n  terminal: { pointStyle: \"triangle\", color: t.palette[2], radius: 19, rotation: 90, legend: \"Terminal (payoff)\" },\n};\n\nconst nodesByType = { decision: [], chance: [], terminal: [] };\nNODES.forEach((node) => nodesByType[node.node_type].push({ x: displayCoords[node.node_id].x, y: displayCoords[node.node_id].y, node }));\n\nconst nodeDatasets = Object.entries(nodesByType).map(([type, points]) => {\n  const style = NODE_STYLE[type];\n  return {\n    data: points,\n    showLine: false,\n    pointStyle: style.pointStyle,\n    pointRadius: style.radius,\n    pointRotation: style.rotation,\n    pointBackgroundColor: (ctx) => withAlpha(style.color, ctx.raw.node.pruned ? 0.3 : 1),\n    pointBorderColor: (ctx) => withAlpha(t.ink, ctx.raw.node.pruned ? 0.3 : 1),\n    pointBorderWidth: 2,\n  };\n});\n\n// --- Branch (edge) datasets — drawn first so nodes sit on top --------------\nconst edgeDatasets = NODES.filter((n) => n.parent_id).map((child) => ({\n  data: [coords[child.parent_id], coords[child.node_id]],\n  showLine: true,\n  borderColor: withAlpha(t.inkSoft, child.pruned ? 0.35 : 0.9),\n  borderWidth: child.pruned ? 2 : 2.5,\n  borderDash: child.pruned ? [8, 6] : [],\n  pointRadius: 0,\n  fill: false,\n  tension: 0,\n}));\n\n// --- Leader lines: faint dotted guides from a terminal's true depth out to\n// the aligned outcome column (purely a visual bridge, not a tree edge) -----\nconst leaderDatasets = leaders.map(({ data }) => ({\n  data,\n  showLine: true,\n  borderColor: withAlpha(t.inkSoft, 0.22),\n  borderWidth: 1,\n  borderDash: [2, 3],\n  pointRadius: 0,\n  fill: false,\n  tension: 0,\n}));\n\n// --- Custom draw: branch labels, pruned cross marks, EMV/payoff text -------\nconst annotationsPlugin = {\n  id: \"decisionTreeAnnotations\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n\n    NODES.filter((n) => n.parent_id).forEach((child) => {\n      const parent = coords[child.parent_id];\n      const point = coords[child.node_id];\n      const midX = scales.x.getPixelForValue((parent.x + point.x) / 2);\n      const midY = scales.y.getPixelForValue((parent.y + point.y) / 2);\n      const alpha = child.pruned ? 0.4 : 1;\n\n      ctx.font = \"15px sans-serif\";\n      ctx.fillStyle = withAlpha(t.inkSoft, alpha);\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"bottom\";\n      ctx.fillText(edgeLabel(child), midX, midY - 8);\n\n      if (child.pruned) {\n        ctx.font = \"bold 18px sans-serif\";\n        ctx.fillStyle = \"#AE3030\";\n        ctx.textBaseline = \"middle\";\n        ctx.fillText(\"✕\", midX, midY + 10);\n      }\n    });\n\n    NODES.forEach((node) => {\n      const p = displayCoords[node.node_id];\n      const px = scales.x.getPixelForValue(p.x);\n      const py = scales.y.getPixelForValue(p.y);\n      const alpha = node.pruned ? 0.4 : 1;\n      ctx.fillStyle = withAlpha(t.ink, alpha);\n      ctx.font = \"600 14px sans-serif\";\n\n      if (node.node_type === \"terminal\") {\n        ctx.textAlign = \"left\";\n        ctx.textBaseline = \"middle\";\n        ctx.fillText(fmtMoney(node.payoff), px + 26, py);\n      } else {\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = \"top\";\n        ctx.fillText(`EMV ${fmtMoney(node.emv)}`, px, py + 22);\n      }\n    });\n\n    ctx.font = \"12px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"bottom\";\n    ctx.fillText(\"✕ dashed, faded branch = pruned (rejected) option\", 12, chart.height - 8);\n\n    ctx.restore();\n  },\n};\n\n// --- Mount -----------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart -------------------------------------------------------------\nconst TITLE = \"tree-decision · javascript · chartjs · anyplot.ai\";\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [...edgeDatasets, ...leaderDatasets, ...nodeDatasets] },\n  plugins: [annotationsPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 60, bottom: 30, left: 8 } },\n    plugins: {\n      title: {\n        display: true,\n        text: TITLE,\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n        padding: { top: 12, bottom: 12 },\n      },\n      legend: {\n        position: \"top\",\n        align: \"end\",\n        labels: {\n          color: t.ink,\n          font: { size: 15 },\n          usePointStyle: true,\n          generateLabels: () =>\n            Object.values(NODE_STYLE).map((style) => ({\n              text: style.legend,\n              fillStyle: style.color,\n              strokeStyle: style.color,\n              pointStyle: style.pointStyle,\n              rotation: style.rotation,\n              lineWidth: 0,\n            })),\n        },\n      },\n    },\n    scales: {\n      x: { type: \"linear\", min: -0.5, max: maxDepth + 0.9, display: false },\n      y: { min: -0.8, max: leafCount - 1 + 0.8, reverse: true, display: false },\n    },\n  },\n});\n"}