{"spec_id":"sankey-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// sankey-basic: Basic Sankey Diagram\n// Library: d3 7.9.0 | JavaScript 22.23.1\n// Quality: 89/100 | Created: 2026-07-25\n//# anyplot-orientation: landscape\n\n// NOTE: the pinned bundle exposes only core d3 (d3-selection, d3-scale,\n// d3-shape, d3-array, ...) — the `d3-sankey` layout plugin is not part of it.\n// The node/link layout below (columns, value-proportional stacking, link\n// centreline routing) is a small hand-rolled Sankey layout using only core d3.\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 130, right: 200, bottom: 60, left: 200 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: national energy flow, source -> carrier -> end use (TWh) --------\nconst nodeDefs = [\n  { id: \"Coal\", column: 0 },\n  { id: \"Gas\", column: 0 },\n  { id: \"Nuclear\", column: 0 },\n  { id: \"Wind\", column: 0 },\n  { id: \"Solar\", column: 0 },\n  { id: \"Electricity\", column: 1 },\n  { id: \"Direct Heat\", column: 1 },\n  { id: \"Residential\", column: 2 },\n  { id: \"Commercial\", column: 2 },\n  { id: \"Industrial\", column: 2 },\n  { id: \"Transport\", column: 2 },\n];\n\nconst linkDefs = [\n  { source: \"Coal\", target: \"Electricity\", value: 40 },\n  { source: \"Coal\", target: \"Direct Heat\", value: 10 },\n  { source: \"Gas\", target: \"Electricity\", value: 25 },\n  { source: \"Gas\", target: \"Direct Heat\", value: 35 },\n  { source: \"Nuclear\", target: \"Electricity\", value: 30 },\n  { source: \"Wind\", target: \"Electricity\", value: 20 },\n  { source: \"Solar\", target: \"Electricity\", value: 15 },\n  { source: \"Electricity\", target: \"Residential\", value: 45 },\n  { source: \"Electricity\", target: \"Commercial\", value: 35 },\n  { source: \"Electricity\", target: \"Industrial\", value: 30 },\n  { source: \"Electricity\", target: \"Transport\", value: 20 },\n  { source: \"Direct Heat\", target: \"Residential\", value: 25 },\n  { source: \"Direct Heat\", target: \"Industrial\", value: 20 },\n];\n\n// --- Build node/link graph ---------------------------------------------------\nconst numColumns = 1 + d3.max(nodeDefs, (d) => d.column);\nconst nodeById = new Map(\n  nodeDefs.map((d) => [d.id, { ...d, sourceLinks: [], targetLinks: [] }])\n);\n\nconst links = linkDefs.map((d) => {\n  const link = { source: nodeById.get(d.source), target: nodeById.get(d.target), value: d.value };\n  link.source.sourceLinks.push(link);\n  link.target.targetLinks.push(link);\n  return link;\n});\nconst nodes = Array.from(nodeById.values());\n\nfor (const n of nodes) {\n  const out = d3.sum(n.sourceLinks, (l) => l.value);\n  const inn = d3.sum(n.targetLinks, (l) => l.value);\n  n.value = Math.max(out, inn);\n}\n\n// --- Column x-extents ---------------------------------------------------------\nconst nodeWidth = 22;\nconst columnStep = numColumns > 1 ? (iw - nodeWidth) / (numColumns - 1) : 0;\nfor (const n of nodes) {\n  n.x0 = margin.left + n.column * columnStep;\n  n.x1 = n.x0 + nodeWidth;\n}\n\n// --- Crossing reduction: reorder nodes within each column by the value-\n// weighted average rank of the nodes they connect to in the adjacent column\n// (barycenter heuristic), sweeping forward then backward until it converges.\n// This is a lightweight, d3-sankey-free stand-in for the iterative relaxation\n// idiomatic Sankey layouts use, and it only reorders nodes within a column --\n// it never touches the `nodes` array that drives palette-index assignment.\nconst columns = d3.groups(nodes, (d) => d.column).sort((a, b) => a[0] - b[0]);\nfor (const [, colNodes] of columns) colNodes.forEach((n, i) => (n.rank = i));\n\nconst barycenter = (n, links, neighborOf) => {\n  const weight = d3.sum(links, (l) => l.value);\n  return weight > 0 ? d3.sum(links, (l) => neighborOf(l).rank * l.value) / weight : n.rank;\n};\n\nfor (let sweep = 0; sweep < 4; sweep++) {\n  const forward = sweep % 2 === 0;\n  const order = forward ? d3.range(1, numColumns) : d3.range(numColumns - 2, -1, -1);\n  for (const c of order) {\n    const [, colNodes] = columns[c];\n    const links = forward ? \"targetLinks\" : \"sourceLinks\";\n    const neighborOf = forward ? (l) => l.source : (l) => l.target;\n    colNodes\n      .map((n) => ({ n, key: barycenter(n, n[links], neighborOf) }))\n      .sort((a, b) => a.key - b.key)\n      .forEach(({ n }, i) => {\n        colNodes[i] = n;\n        n.rank = i;\n      });\n  }\n}\n\n// --- Vertical (value-proportional) layout ------------------------------------\n// Interior (non-first/last) columns label above each node, which needs ~41px\n// of clearance to the sibling node stacked above it -- outer columns label\n// beside the node at its own mid-height instead, so they don't need extra\n// room. Give interior columns a taller gap so a stacked sibling's rect never\n// sits under another node's label (independent of which node the\n// crossing-reduction pass above happens to rank first).\nconst outerPadding = 26;\nconst innerPadding = 50;\nconst paddingFor = (c) => (c === 0 || c === numColumns - 1 ? outerPadding : innerPadding);\nconst maxPadding = d3.max(columns, ([c, ns]) => (ns.length - 1) * paddingFor(c));\nconst maxColumnValue = d3.max(columns, ([, ns]) => d3.sum(ns, (n) => n.value));\nconst ky = (ih - maxPadding) / maxColumnValue;\n\nfor (const [c, colNodes] of columns) {\n  for (const n of colNodes) n.h = n.value * ky;\n  const pad = paddingFor(c);\n  const total = d3.sum(colNodes, (n) => n.h) + (colNodes.length - 1) * pad;\n  let cursor = margin.top + (ih - total) / 2;\n  for (const n of colNodes) {\n    n.y0 = cursor;\n    n.y1 = cursor + n.h;\n    cursor = n.y1 + pad;\n  }\n}\n\n// Stack each node's source/target links along its height, in link-array order,\n// and record the vertical centre of each link's allocated segment.\nfor (const n of nodes) {\n  let sy = n.y0;\n  for (const l of n.sourceLinks) {\n    const h = l.value * ky;\n    l.sy = sy + h / 2;\n    l.width = Math.max(1.25, h);\n    sy += h;\n  }\n  let ty = n.y0;\n  for (const l of n.targetLinks) {\n    const h = l.value * ky;\n    l.ty = ty + h / 2;\n    ty += h;\n  }\n}\n\n// --- Color: source and carrier nodes each get a distinct Imprint hue (links\n// inherit their immediate source node's color); the first encountered node\n// (a true source, \"Coal\") is #009E73 — the mandated first categorical series.\n// End-use nodes (the last column) each aggregate multiple unrelated flows, so\n// they get the theme-adaptive \"muted\" semantic anchor instead of continuing\n// the palette cycle, which would otherwise recycle hues onto categories that\n// have no relationship to the source node that first used that color. ------\nconst isLight = window.ANYPLOT_THEME === \"light\";\nconst muted = isLight ? \"#6B6A63\" : \"#A8A79F\";\nconst colorOf = new Map();\nlet paletteIdx = 0;\nfor (const n of nodes) {\n  if (n.column === numColumns - 1) {\n    colorOf.set(n.id, muted);\n  } else {\n    colorOf.set(n.id, t.palette[paletteIdx % t.palette.length]);\n    paletteIdx++;\n  }\n}\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Links ----------------------------------------------------------------\nconst linkPath = d3\n  .linkHorizontal()\n  .x((d) => d.x)\n  .y((d) => d.y);\n\nsvg\n  .append(\"g\")\n  .attr(\"fill\", \"none\")\n  .selectAll(\"path\")\n  .data(links)\n  .join(\"path\")\n  .attr(\"d\", (l) =>\n    linkPath({\n      source: { x: l.source.x1, y: l.sy },\n      target: { x: l.target.x0, y: l.ty },\n    })\n  )\n  .attr(\"stroke\", (l) => colorOf.get(l.source.id))\n  .attr(\"stroke-width\", (l) => l.width)\n  .attr(\"stroke-opacity\", 0.42);\n\n// --- Nodes ----------------------------------------------------------------\nconst nodeG = svg.append(\"g\").selectAll(\"g\").data(nodes).join(\"g\");\n\nnodeG\n  .append(\"rect\")\n  .attr(\"x\", (n) => n.x0)\n  .attr(\"y\", (n) => n.y0)\n  .attr(\"width\", (n) => n.x1 - n.x0)\n  .attr(\"height\", (n) => Math.max(1, n.y1 - n.y0))\n  .attr(\"rx\", 3)\n  .attr(\"fill\", (n) => colorOf.get(n.id))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Node labels: side columns get flush left/right labels outside the\n// diagram body; the middle column (fed from both sides) labels above. ---------\nconst fmt = d3.format(\",\");\nnodeG.each(function (n) {\n  const g = d3.select(this);\n  const isFirst = n.column === 0;\n  const isLast = n.column === numColumns - 1;\n  const midY = (n.y0 + n.y1) / 2;\n\n  let anchor, tx, ty1, ty2;\n  if (isFirst) {\n    anchor = \"end\";\n    tx = n.x0 - 12;\n    ty1 = midY - 5;\n    ty2 = midY + 15;\n  } else if (isLast) {\n    anchor = \"start\";\n    tx = n.x1 + 12;\n    ty1 = midY - 5;\n    ty2 = midY + 15;\n  } else {\n    anchor = \"middle\";\n    tx = (n.x0 + n.x1) / 2;\n    ty1 = n.y0 - 28;\n    ty2 = n.y0 - 10;\n  }\n\n  // A page-bg halo keeps labels legible where a link ribbon's curve happens\n  // to sweep behind them (e.g. the middle carrier column, whose incoming\n  // ribbons fan out across the full source-column height).\n  g.append(\"text\")\n    .attr(\"x\", tx)\n    .attr(\"y\", ty1)\n    .attr(\"text-anchor\", anchor)\n    .attr(\"fill\", t.ink)\n    .attr(\"paint-order\", \"stroke\")\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 5)\n    .attr(\"stroke-linejoin\", \"round\")\n    .style(\"font-size\", \"17px\")\n    .style(\"font-weight\", \"600\")\n    .text(n.id);\n\n  g.append(\"text\")\n    .attr(\"x\", tx)\n    .attr(\"y\", ty2)\n    .attr(\"text-anchor\", anchor)\n    .attr(\"fill\", t.inkSoft)\n    .attr(\"paint-order\", \"stroke\")\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 5)\n    .attr(\"stroke-linejoin\", \"round\")\n    .style(\"font-size\", \"14px\")\n    .style(\"font-weight\", \"600\")\n    .text(`${fmt(n.value)} TWh`);\n});\n\n// --- Title + subtitle -----------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"28px\")\n  .style(\"font-weight\", \"700\")\n  .text(\"National Energy Flow: Sources to End Use\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 84)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"17px\")\n  .text(\"Generation sources route through carriers to residential, commercial, industrial, and transport demand (TWh)\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", height - 22)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"sankey-basic · javascript · d3 · anyplot.ai\");\n"}