{"spec_id":"alluvial-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// alluvial-basic: Basic Alluvial Diagram\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\n// The Sankey/alluvial series type lives in a Highcharts add-on module\n// (modules/sankey) that isn't vendored here — only the core bundle is loaded\n// (see prompts/library/highcharts.md, \"Forbidden patterns\"). The core\n// SVGRenderer is not a module, though, so this snippet draws the alluvial\n// bands and category nodes natively with chart.renderer primitives inside a\n// series-less chart, the same technique Highcharts itself used for flow\n// diagrams before the sankey module existed.\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// A 1,000-lead SaaS trial cohort tracked across four quarters. Every lead is\n// in exactly one category per quarter, so column totals stay conserved and\n// each flow's width is exactly the number of leads making that transition.\nconst TIME_LABELS = [\"Q1\", \"Q2\", \"Q3\", \"Q4\"];\nconst CATEGORIES = [\"Lead\", \"Trial\", \"Customer\", \"Churned\"];\nconst STACK_ORDER = [\"Customer\", \"Trial\", \"Lead\", \"Churned\"]; // top → bottom\nconst CATEGORY_COLOR = {\n  Customer: t.palette[0], // brand green — the desired outcome\n  Trial: t.palette[2],\n  Lead: t.palette[1],\n  Churned: t.palette[4], // semantic anchor for loss (Color Philosophy, slot 5)\n};\n\nconst TRANSITIONS = [\n  // Q1 -> Q2\n  [\n    { from: \"Lead\", to: \"Lead\", value: 200 },\n    { from: \"Lead\", to: \"Trial\", value: 500 },\n    { from: \"Lead\", to: \"Churned\", value: 300 },\n  ],\n  // Q2 -> Q3\n  [\n    { from: \"Lead\", to: \"Lead\", value: 40 },\n    { from: \"Lead\", to: \"Trial\", value: 120 },\n    { from: \"Lead\", to: \"Churned\", value: 40 },\n    { from: \"Trial\", to: \"Trial\", value: 180 },\n    { from: \"Trial\", to: \"Customer\", value: 270 },\n    { from: \"Trial\", to: \"Churned\", value: 50 },\n    { from: \"Churned\", to: \"Churned\", value: 300 },\n  ],\n  // Q3 -> Q4\n  [\n    { from: \"Lead\", to: \"Lead\", value: 10 },\n    { from: \"Lead\", to: \"Trial\", value: 20 },\n    { from: \"Lead\", to: \"Churned\", value: 10 },\n    { from: \"Trial\", to: \"Trial\", value: 80 },\n    { from: \"Trial\", to: \"Customer\", value: 190 },\n    { from: \"Trial\", to: \"Churned\", value: 30 },\n    { from: \"Customer\", to: \"Customer\", value: 240 },\n    { from: \"Customer\", to: \"Churned\", value: 30 },\n    { from: \"Churned\", to: \"Churned\", value: 390 },\n  ],\n];\n\n// Column totals per category, derived from the flows so the diagram can never\n// drift out of balance with the data it draws.\nconst nodeValues = [{ Lead: 1000, Trial: 0, Customer: 0, Churned: 0 }];\nTRANSITIONS.forEach((flows) => {\n  const next = { Lead: 0, Trial: 0, Customer: 0, Churned: 0 };\n  flows.forEach((flow) => {\n    next[flow.to] += flow.value;\n  });\n  nodeValues.push(next);\n});\nconst TOTAL = CATEGORIES.reduce((sum, cat) => sum + nodeValues[0][cat], 0);\n\n// --- Chart (series-less; the alluvial is hand-drawn via chart.renderer) ----\nHighcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    spacingTop: 70,\n    spacingBottom: 64,\n    spacingLeft: 190,\n    spacingRight: 190,\n    style: { fontFamily: \"inherit\" },\n    events: {\n      load() {\n        drawAlluvial(this);\n      },\n    },\n  },\n  credits: { enabled: false },\n  title: {\n    text: \"alluvial-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n    // Extra margin below the subtitle so the Q1-Q4 column headers (drawn at\n    // chart.plotTop - 24) clear the subtitle baseline instead of sitting on it.\n    margin: 50,\n  },\n  subtitle: {\n    text: \"SaaS trial funnel · 1,000-lead cohort tracked across four quarters\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: { visible: false },\n  yAxis: { visible: false },\n  legend: { enabled: false },\n  series: [],\n});\n\nfunction drawAlluvial(chart) {\n  const r = chart.renderer;\n  const nodeWidth = 30;\n  const gap = 6;\n  const usableHeight = chart.plotHeight - gap * (STACK_ORDER.length - 1);\n  const scale = usableHeight / TOTAL;\n  const usableWidth = chart.plotWidth - nodeWidth;\n\n  const colX = (i) => chart.plotLeft + nodeWidth / 2 + (usableWidth * i) / (TIME_LABELS.length - 1);\n\n  // Segment top/bottom per column & category, stacked in STACK_ORDER.\n  const segments = nodeValues.map((values) => {\n    const seg = {};\n    let y = chart.plotTop;\n    STACK_ORDER.forEach((cat) => {\n      const h = values[cat] * scale;\n      seg[cat] = { top: y, bottom: y + h, height: h };\n      y += h + gap;\n    });\n    return seg;\n  });\n\n  // Flow bands — drawn first so the node rectangles sit cleanly on top of\n  // their flat edges.\n  TRANSITIONS.forEach((flows, i) => {\n    const srcCursor = {};\n    const tgtCursor = {};\n    STACK_ORDER.forEach((cat) => {\n      srcCursor[cat] = segments[i][cat].top;\n      tgtCursor[cat] = segments[i + 1][cat].top;\n    });\n    const x0 = colX(i) + nodeWidth / 2;\n    const x1 = colX(i + 1) - nodeWidth / 2;\n    const cx = (x0 + x1) / 2;\n\n    flows.forEach((flow) => {\n      const h = flow.value * scale;\n      const y0Top = srcCursor[flow.from];\n      const y0Bottom = y0Top + h;\n      const y1Top = tgtCursor[flow.to];\n      const y1Bottom = y1Top + h;\n      srcCursor[flow.from] = y0Bottom;\n      tgtCursor[flow.to] = y1Bottom;\n\n      r.path([\n        \"M\", x0, y0Top,\n        \"C\", cx, y0Top, cx, y1Top, x1, y1Top,\n        \"L\", x1, y1Bottom,\n        \"C\", cx, y1Bottom, cx, y0Bottom, x0, y0Bottom,\n        \"Z\",\n      ])\n        .attr({ fill: CATEGORY_COLOR[flow.from], \"fill-opacity\": 0.45 })\n        .add();\n    });\n  });\n\n  // Category nodes.\n  segments.forEach((seg, i) => {\n    STACK_ORDER.forEach((cat) => {\n      const s = seg[cat];\n      if (s.height < 1) return;\n      r.rect(colX(i) - nodeWidth / 2, s.top, nodeWidth, s.height, 3)\n        .attr({ fill: CATEGORY_COLOR[cat], stroke: t.pageBg, \"stroke-width\": 1.5 })\n        .add();\n    });\n  });\n\n  // Column headers (time points).\n  TIME_LABELS.forEach((label, i) => {\n    r.text(label, colX(i), chart.plotTop - 24)\n      .attr({ align: \"center\" })\n      .css({ color: t.ink, fontSize: \"16px\", fontWeight: \"600\" })\n      .add();\n  });\n\n  // Endpoint labels — category name + count, only at the first and last\n  // columns (color + column headers carry identity for the middle columns).\n  CATEGORIES.forEach((cat) => {\n    const first = segments[0][cat];\n    if (first.height >= 1) {\n      r.text(`${cat} · ${nodeValues[0][cat].toLocaleString()}`, colX(0) - nodeWidth / 2 - 14, (first.top + first.bottom) / 2 + 5)\n        .attr({ align: \"right\" })\n        .css({ color: t.ink, fontSize: \"14px\" })\n        .add();\n    }\n    const lastIdx = segments.length - 1;\n    const last = segments[lastIdx][cat];\n    if (last.height >= 1) {\n      r.text(`${cat} · ${nodeValues[lastIdx][cat].toLocaleString()}`, colX(lastIdx) + nodeWidth / 2 + 14, (last.top + last.bottom) / 2 + 5)\n        .attr({ align: \"left\" })\n        .css({ color: t.ink, fontSize: \"14px\" })\n        .add();\n    }\n  });\n\n  // Legend — identifies category color for the two middle columns, where\n  // endpoint labels aren't drawn. Ordered to match the visual top-to-bottom\n  // stacking (STACK_ORDER), not category-definition order, so a swatch maps\n  // directly onto its band without hunting.\n  const itemWidth = 210;\n  const legendWidth = itemWidth * STACK_ORDER.length;\n  const legendY = chart.plotTop + chart.plotHeight + 34;\n  const legendStartX = chart.plotLeft + (chart.plotWidth - legendWidth) / 2;\n  STACK_ORDER.forEach((cat, i) => {\n    const itemX = legendStartX + i * itemWidth;\n    r.rect(itemX, legendY - 11, 14, 14, 2).attr({ fill: CATEGORY_COLOR[cat] }).add();\n    r.text(cat, itemX + 22, legendY)\n      .attr({ align: \"left\" })\n      .css({ color: t.inkSoft, fontSize: \"14px\" })\n      .add();\n  });\n}\n"}