{"spec_id":"tree-decision","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// tree-decision: Decision Tree Visualization with Probabilities\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Custom marker symbol: terminal nodes are right-pointing triangles ------\n// Highcharts core only ships an upward triangle; register a right-pointing\n// variant so terminal (payoff) nodes read as \"flow exits here\".\nHighcharts.SVGRenderer.prototype.symbols.triangleright = (x, y, w, h) => [\n  \"M\",\n  x,\n  y,\n  \"L\",\n  x,\n  y + h,\n  \"L\",\n  x + w,\n  y + h / 2,\n  \"Z\",\n];\n\n// --- Data: two-stage R&D investment decision (in-memory, deterministic) ----\n// Coordinates are hand-laid-out tree positions: x = decision stage (0-4,\n// left-to-right), y = vertical slot (rollback average of each node's\n// children), matching how a decision-tree rollback is drawn by hand.\nconst NODES = [\n  {\n    id: \"root\",\n    type: \"decision\",\n    parentId: null,\n    name: \"Invest in R&D?\",\n    branchLabel: null,\n    probability: null,\n    payoff: null,\n    emv: 421,\n    pruned: false,\n    x: 0,\n    y: 1,\n  },\n  {\n    id: \"c1\",\n    type: \"chance\",\n    parentId: \"root\",\n    name: \"Technical Success?\",\n    branchLabel: \"Invest in R&D\",\n    probability: null,\n    payoff: null,\n    emv: 421,\n    pruned: false,\n    x: 1,\n    y: 2,\n  },\n  {\n    id: \"t_noinvest\",\n    type: \"terminal\",\n    parentId: \"root\",\n    name: \"No Investment\",\n    branchLabel: \"Don't Invest\",\n    probability: null,\n    payoff: 0,\n    emv: 0,\n    pruned: true,\n    x: 1,\n    y: 0,\n  },\n  {\n    id: \"d1\",\n    type: \"decision\",\n    parentId: \"c1\",\n    name: \"Launch Product?\",\n    branchLabel: \"Success\",\n    probability: 0.7,\n    payoff: null,\n    emv: 730,\n    pruned: false,\n    x: 2,\n    y: 3,\n  },\n  {\n    id: \"t_fail\",\n    type: \"terminal\",\n    parentId: \"c1\",\n    name: \"R&D Failure\",\n    branchLabel: \"Failure\",\n    probability: 0.3,\n    payoff: -300,\n    emv: -300,\n    pruned: false,\n    x: 2,\n    y: 1,\n  },\n  {\n    id: \"c2\",\n    type: \"chance\",\n    parentId: \"d1\",\n    name: \"Market Demand\",\n    branchLabel: \"Launch\",\n    probability: null,\n    payoff: null,\n    emv: 730,\n    pruned: false,\n    x: 3,\n    y: 4,\n  },\n  {\n    id: \"t_nolaunch\",\n    type: \"terminal\",\n    parentId: \"d1\",\n    name: \"Shelve Product\",\n    branchLabel: \"Don't Launch\",\n    probability: null,\n    payoff: -50,\n    emv: -50,\n    pruned: true,\n    x: 3,\n    y: 2,\n  },\n  {\n    id: \"t_high\",\n    type: \"terminal\",\n    parentId: \"c2\",\n    name: \"High Demand\",\n    branchLabel: \"High Demand\",\n    probability: 0.5,\n    payoff: 1200,\n    emv: 1200,\n    pruned: false,\n    x: 4,\n    y: 5,\n  },\n  {\n    id: \"t_med\",\n    type: \"terminal\",\n    parentId: \"c2\",\n    name: \"Medium Demand\",\n    branchLabel: \"Medium Demand\",\n    probability: 0.3,\n    payoff: 500,\n    emv: 500,\n    pruned: false,\n    x: 4,\n    y: 4,\n  },\n  {\n    id: \"t_low\",\n    type: \"terminal\",\n    parentId: \"c2\",\n    name: \"Low Demand\",\n    branchLabel: \"Low Demand\",\n    probability: 0.2,\n    payoff: -100,\n    emv: -100,\n    pruned: false,\n    x: 4,\n    y: 3,\n  },\n];\nconst nodeById = Object.fromEntries(NODES.map((n) => [n.id, n]));\nconst edges = NODES.filter((n) => n.parentId !== null);\n\nconst fmtMoney = (v) => `${v < 0 ? \"-\" : \"\"}$${Math.abs(v)}k`;\nconst hexToRgba = (hex, alpha) => {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r},${g},${b},${alpha})`;\n};\n\nconst DECISION_COLOR = t.palette[0]; // brand green — always first series\nconst CHANCE_COLOR = t.palette[1]; // lavender — second Imprint position\nconst TERMINAL_COLOR = t.ink; // neutral anchor: terminal nodes are reference endpoints\nconst PRUNE_COLOR = t.palette[4]; // matte red — semantic anchor for rejected/bad outcomes\n\nconst toPoint = (n, fillColor) => ({\n  x: n.x,\n  y: n.y,\n  marker: { fillColor, lineColor: t.pageBg },\n  custom: {\n    label: n.name,\n    valueLabel:\n      n.type === \"terminal\"\n        ? `Payoff: ${fmtMoney(n.payoff)}`\n        : `EMV: ${fmtMoney(n.emv)}`,\n  },\n});\n\nconst decisionData = NODES.filter((n) => n.type === \"decision\").map((n) =>\n  toPoint(n, DECISION_COLOR),\n);\nconst chanceData = NODES.filter((n) => n.type === \"chance\").map((n) =>\n  toPoint(n, CHANCE_COLOR),\n);\nconst terminalData = NODES.filter((n) => n.type === \"terminal\").map((n) =>\n  toPoint(n, n.pruned ? hexToRgba(TERMINAL_COLOR, 0.4) : TERMINAL_COLOR),\n);\n\n// --- Connector lines + branch labels ----------------------------------------\n// Highcharts core has no tree/link series (networkgraph is an add-on module,\n// not loaded) — branches are drawn as SVG paths in the render event, mapped\n// through the axes so they stay aligned with the scatter node markers.\nlet customEls = [];\n\nfunction drawBranches(chart) {\n  customEls.forEach((el) => el.destroy());\n  customEls = [];\n\n  const xAxis = chart.xAxis[0];\n  const yAxis = chart.yAxis[0];\n\n  edges.forEach((child) => {\n    const parent = nodeById[child.parentId];\n    const x1 = xAxis.toPixels(parent.x);\n    const y1 = yAxis.toPixels(parent.y);\n    const x2 = xAxis.toPixels(child.x);\n    const y2 = yAxis.toPixels(child.y);\n\n    const line = chart.renderer\n      .path([\"M\", x1, y1, \"L\", x2, y2])\n      .attr({\n        stroke: child.pruned ? PRUNE_COLOR : t.inkSoft,\n        \"stroke-width\": child.pruned ? 2 : 2.5,\n        dashstyle: child.pruned ? \"Dash\" : \"Solid\",\n        opacity: child.pruned ? 0.55 : 0.9,\n        zIndex: 1,\n      })\n      .add();\n    customEls.push(line);\n\n    const midX = (x1 + x2) / 2;\n    const midY = (y1 + y2) / 2;\n    const labelText =\n      child.branchLabel +\n      (child.probability != null ? ` (p=${child.probability.toFixed(2)})` : \"\");\n    const label = chart.renderer\n      .text(labelText, midX, midY - 12)\n      .css({ color: t.inkSoft, fontSize: \"13px\" })\n      .attr({ zIndex: 3 })\n      .add();\n    label.translate(-label.getBBox().width / 2, 0);\n    customEls.push(label);\n\n    if (child.pruned) {\n      const cross = chart.renderer\n        .text(\"✖\", midX, midY + 22)\n        .css({ color: PRUNE_COLOR, fontSize: \"20px\", fontWeight: \"700\" })\n        .attr({ zIndex: 3 })\n        .add();\n      cross.translate(-cross.getBBox().width / 2, 0);\n      customEls.push(cross);\n    }\n  });\n}\n\n// --- Chart -------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    events: {\n      render() {\n        drawBranches(this);\n      },\n    },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"tree-decision · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Two-stage R&D investment decision · values in $ thousands · dashed red = pruned (rejected) branch\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: {\n    min: -0.6,\n    max: 4.6,\n    startOnTick: false,\n    endOnTick: false,\n    visible: false,\n  },\n  yAxis: {\n    min: -0.9,\n    max: 5.9,\n    startOnTick: false,\n    endOnTick: false,\n    visible: false,\n    title: { text: null },\n  },\n  legend: {\n    enabled: true,\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  tooltip: { enabled: false },\n  plotOptions: {\n    series: { animation: false },\n    scatter: {\n      marker: { radius: 19, lineWidth: 2, symbol: \"circle\" },\n      dataLabels: [\n        {\n          enabled: true,\n          y: -32,\n          formatter() {\n            return this.point.custom.label;\n          },\n          style: {\n            color: t.ink,\n            fontSize: \"14px\",\n            fontWeight: \"600\",\n            textOutline: \"none\",\n          },\n        },\n        {\n          enabled: true,\n          y: 34,\n          formatter() {\n            return this.point.custom.valueLabel;\n          },\n          style: {\n            color: t.inkSoft,\n            fontSize: \"13px\",\n            fontWeight: \"400\",\n            textOutline: \"none\",\n          },\n        },\n      ],\n    },\n  },\n  series: [\n    {\n      name: \"Decision node (square)\",\n      type: \"scatter\",\n      color: DECISION_COLOR,\n      marker: { symbol: \"square\" },\n      data: decisionData,\n    },\n    {\n      name: \"Chance node (circle)\",\n      type: \"scatter\",\n      color: CHANCE_COLOR,\n      marker: { symbol: \"circle\" },\n      data: chanceData,\n    },\n    {\n      name: \"Terminal node (triangle)\",\n      type: \"scatter\",\n      color: TERMINAL_COLOR,\n      marker: { symbol: \"triangleright\", radius: 21 },\n      data: terminalData,\n    },\n  ],\n});\n"}