{"spec_id":"alluvial-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// alluvial-basic: Basic Alluvial Diagram\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Cohort of 240 students tracked across four semesters, transitioning between\n// declared academic tracks. Categories persist across time points, so each\n// keeps the same Imprint color throughout.\nconst categories = [\"STEM\", \"Business\", \"Humanities\", \"Undeclared\"];\nconst stages = [\"Semester 1\", \"Semester 2\", \"Semester 3\", \"Semester 4\"];\n\nconst initialCounts = { STEM: 70, Business: 60, Humanities: 50, Undeclared: 60 };\n\n// transitions[s] = flows from stages[s] to stages[s + 1]\nconst transitions = [\n  [\n    [\"STEM\", \"STEM\", 55], [\"STEM\", \"Business\", 10], [\"STEM\", \"Undeclared\", 5],\n    [\"Business\", \"STEM\", 5], [\"Business\", \"Business\", 45], [\"Business\", \"Humanities\", 5], [\"Business\", \"Undeclared\", 5],\n    [\"Humanities\", \"Business\", 5], [\"Humanities\", \"Humanities\", 40], [\"Humanities\", \"Undeclared\", 5],\n    [\"Undeclared\", \"STEM\", 10], [\"Undeclared\", \"Business\", 15], [\"Undeclared\", \"Humanities\", 10], [\"Undeclared\", \"Undeclared\", 25],\n  ],\n  [\n    [\"STEM\", \"STEM\", 50], [\"STEM\", \"Business\", 12], [\"STEM\", \"Humanities\", 3], [\"STEM\", \"Undeclared\", 5],\n    [\"Business\", \"STEM\", 8], [\"Business\", \"Business\", 55], [\"Business\", \"Humanities\", 7], [\"Business\", \"Undeclared\", 5],\n    [\"Humanities\", \"STEM\", 2], [\"Humanities\", \"Business\", 8], [\"Humanities\", \"Humanities\", 40], [\"Humanities\", \"Undeclared\", 5],\n    [\"Undeclared\", \"STEM\", 5], [\"Undeclared\", \"Business\", 10], [\"Undeclared\", \"Humanities\", 5], [\"Undeclared\", \"Undeclared\", 20],\n  ],\n  [\n    [\"STEM\", \"STEM\", 48], [\"STEM\", \"Business\", 10], [\"STEM\", \"Humanities\", 2], [\"STEM\", \"Undeclared\", 5],\n    [\"Business\", \"STEM\", 10], [\"Business\", \"Business\", 62], [\"Business\", \"Humanities\", 8], [\"Business\", \"Undeclared\", 5],\n    [\"Humanities\", \"STEM\", 3], [\"Humanities\", \"Business\", 7], [\"Humanities\", \"Humanities\", 40], [\"Humanities\", \"Undeclared\", 5],\n    [\"Undeclared\", \"STEM\", 4], [\"Undeclared\", \"Business\", 8], [\"Undeclared\", \"Humanities\", 3], [\"Undeclared\", \"Undeclared\", 20],\n  ],\n];\n\n// Node totals per stage, derived from the transition matrices (column sums).\nconst counts = [initialCounts];\nfor (const stageTransitions of transitions) {\n  const next = Object.fromEntries(categories.map((c) => [c, 0]));\n  for (const [, target, value] of stageTransitions) next[target] += value;\n  counts.push(next);\n}\n\n// --- Layout -------------------------------------------------------------\nconst margin = { top: 130, right: 190, bottom: 70, left: 190 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\nconst nodePadding = 14;\nconst nodeWidth = 22;\nconst colX = stages.map((_, s) => margin.left + (s * iw) / (stages.length - 1));\n\nconst color = d3.scaleOrdinal().domain(categories).range(t.palette);\n\n// nodes[s][category] = { y0, y1, unit }\nconst nodes = counts.map((stageCounts) => {\n  const total = d3.sum(categories, (c) => stageCounts[c]);\n  const unit = (ih - nodePadding * (categories.length - 1)) / total;\n  let y = margin.top;\n  const stageNodes = {};\n  for (const c of categories) {\n    const h = stageCounts[c] * unit;\n    stageNodes[c] = { y0: y, y1: y + h, unit };\n    y += h + nodePadding;\n  }\n  return stageNodes;\n});\n\n// Sub-stack each node's outgoing/incoming flows in fixed category order so\n// ribbons never cross within the same node.\nconst links = transitions.map((stageTransitions, s) => {\n  const sourceOffset = Object.fromEntries(categories.map((c) => [c, nodes[s][c].y0]));\n  const targetOffset = Object.fromEntries(categories.map((c) => [c, nodes[s + 1][c].y0]));\n  const sorted = [...stageTransitions].sort(\n    (a, b) => categories.indexOf(a[1]) - categories.indexOf(b[1]) || categories.indexOf(a[0]) - categories.indexOf(b[0])\n  );\n  return sorted.map(([source, target, value]) => {\n    const h0 = value * nodes[s][source].unit;\n    const h1 = value * nodes[s + 1][target].unit;\n    const y0a = sourceOffset[source];\n    const y1a = targetOffset[target];\n    sourceOffset[source] += h0;\n    targetOffset[target] += h1;\n    return { source, target, value, x0: colX[s] + nodeWidth / 2, x1: colX[s + 1] - nodeWidth / 2, y0a, y0b: y0a + h0, y1a, y1b: y1a + h1 };\n  });\n});\n\nconst ribbonPath = (x0, y0a, y0b, x1, y1a, y1b) => {\n  const xm = (x0 + x1) / 2;\n  return `M${x0},${y0a}C${xm},${y0a} ${xm},${y1a} ${x1},${y1a}L${x1},${y1b}C${xm},${y1b} ${xm},${y0b} ${x0},${y0b}Z`;\n};\n\n// --- SVG mount ----------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Ribbons --------------------------------------------------------------\n// Positions are baked into each link already, so re-sorting for the join only\n// changes paint (z-)order, not layout: draw the bulkiest flows first so the\n// rarer, thinner ones paint last and stay traceable on top of the crossings.\nfor (const stageLinks of links) {\n  const paintOrder = [...stageLinks].sort((a, b) => b.value - a.value);\n  svg\n    .append(\"g\")\n    .selectAll(\"path\")\n    .data(paintOrder)\n    .join(\"path\")\n    .attr(\"d\", (d) => ribbonPath(d.x0, d.y0a, d.y0b, d.x1, d.y1a, d.y1b))\n    .attr(\"fill\", (d) => color(d.source))\n    .attr(\"fill-opacity\", 0.62)\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 0.75)\n    .attr(\"stroke-opacity\", 0.6);\n}\n\n// --- Nodes ------------------------------------------------------------------\nnodes.forEach((stageNodes, s) => {\n  svg\n    .append(\"g\")\n    .selectAll(\"rect\")\n    .data(categories.map((c) => ({ category: c, ...stageNodes[c] })))\n    .join(\"rect\")\n    .attr(\"x\", colX[s] - nodeWidth / 2)\n    .attr(\"y\", (d) => d.y0)\n    .attr(\"width\", nodeWidth)\n    .attr(\"height\", (d) => d.y1 - d.y0)\n    .attr(\"fill\", (d) => color(d.category));\n});\n\n// --- Compact counts on the middle stages ---------------------------------\n// Semester 1 and Semester 4 already get full \"category + count\" labels\n// outside the diagram; the two interior stages have no room beside the\n// nodes (ribbons touch both edges), so stamp the count directly on each bar\n// with a haloed label instead — skipped only for slivers too short to hold\n// readable text.\nconst MIN_LABEL_HEIGHT = 26;\nfor (let s = 1; s < stages.length - 1; s++) {\n  const g = svg.append(\"g\");\n  for (const c of categories) {\n    const { y0, y1 } = nodes[s][c];\n    if (y1 - y0 < MIN_LABEL_HEIGHT) continue;\n    g.append(\"text\")\n      .attr(\"x\", colX[s])\n      .attr(\"y\", (y0 + y1) / 2)\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"dominant-baseline\", \"central\")\n      .attr(\"fill\", \"#FFFFFF\")\n      .style(\"font-size\", \"11px\")\n      .style(\"font-weight\", \"600\")\n      .style(\"paint-order\", \"stroke\")\n      .style(\"stroke\", \"rgba(0,0,0,0.55)\")\n      .style(\"stroke-width\", \"3px\")\n      .style(\"stroke-linejoin\", \"round\")\n      .text(counts[s][c]);\n  }\n}\n\n// --- Shared legend --------------------------------------------------------\n// Ties each node/ribbon color back to its category name so the two\n// unlabeled interior stages (which only carry counts above) stay\n// self-explanatory without cross-referencing the edge columns.\nconst legend = svg.append(\"g\");\nconst legendItems = categories.map((c) => {\n  const item = legend.append(\"g\");\n  item.append(\"rect\").attr(\"width\", 14).attr(\"height\", 14).attr(\"rx\", 3).attr(\"fill\", color(c));\n  item\n    .append(\"text\")\n    .attr(\"x\", 20)\n    .attr(\"y\", 7)\n    .attr(\"dominant-baseline\", \"central\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"13px\")\n    .style(\"font-weight\", \"500\")\n    .text(c);\n  return item;\n});\nconst legendGap = 28;\nconst itemWidths = legendItems.map((item) => item.node().getBBox().width);\nconst totalWidth = itemWidths.reduce((a, b) => a + b, 0) + legendGap * (legendItems.length - 1);\nlet legendX = (width - totalWidth) / 2;\nconst legendY = height - 30;\nlegendItems.forEach((item, i) => {\n  item.attr(\"transform\", `translate(${legendX},${legendY})`);\n  legendX += itemWidths[i] + legendGap;\n});\n\n// --- Category labels (first and last stage only) -----------------------\nconst labelStage = (s, anchor, dx) => {\n  const g = svg.append(\"g\");\n  for (const c of categories) {\n    const { y0, y1 } = nodes[s][c];\n    const cy = (y0 + y1) / 2;\n    const text = g\n      .append(\"text\")\n      .attr(\"x\", colX[s] + dx)\n      .attr(\"y\", cy)\n      .attr(\"text-anchor\", anchor)\n      .attr(\"fill\", t.ink)\n      .style(\"font-size\", \"15px\")\n      .style(\"font-weight\", \"600\");\n    text.append(\"tspan\").text(c);\n    text\n      .append(\"tspan\")\n      .attr(\"x\", colX[s] + dx)\n      .attr(\"dy\", \"1.3em\")\n      .attr(\"fill\", t.inkSoft)\n      .style(\"font-size\", \"13px\")\n      .style(\"font-weight\", \"400\")\n      .text(`${counts[s][c]} students`);\n  }\n};\nlabelStage(0, \"end\", -nodeWidth / 2 - 12);\nlabelStage(stages.length - 1, \"start\", nodeWidth / 2 + 12);\n\n// --- Stage headers ------------------------------------------------------\nsvg\n  .append(\"g\")\n  .selectAll(\"text\")\n  .data(stages)\n  .join(\"text\")\n  .attr(\"x\", (_, s) => colX[s])\n  .attr(\"y\", margin.top - 32)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => d);\n\n// --- Title --------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"alluvial-basic · javascript · d3 · anyplot.ai\");\n"}