{"spec_id":"sankey-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// sankey-basic: Basic Sankey Diagram\n// Library: muix 7.29.1 | JavaScript 22.23.1\n// Quality: 90/100 | Created: 2026-07-25\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst SIZE = window.ANYPLOT_SIZE;\nconst TITLE = \"sankey-basic · javascript · muix · anyplot.ai\";\nconst MUTED = t.theme === \"light\" ? \"#6B6A63\" : \"#A8A79F\"; // Imprint muted anchor\n\nconst FONT =\n  '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif';\n\n// --- Data: national energy flow, sources → carriers → end-use sectors (TWh) --\n// Deterministic, in-memory. No link has source === target, and the graph is a\n// strict left-to-right DAG (3 stages), so there are no circular flows. Wind\n// and Solar are combined into one \"Renewables\" source so the 4 source\n// categories stay within canonical palette positions 1-4 — position 5\n// (#AE3030) is the reserved bad/loss/error anchor, not a free ordinal slot.\nconst NODES_RAW = [\n  { id: \"coal\", label: \"Coal\", col: 0, color: t.palette[0] },\n  { id: \"gas\", label: \"Gas\", col: 0, color: t.palette[1] },\n  { id: \"nuclear\", label: \"Nuclear\", col: 0, color: t.palette[2] },\n  { id: \"renewables\", label: \"Renewables\", col: 0, color: t.palette[3] },\n  { id: \"electricity\", label: \"Electricity\", col: 1, color: t.palette[5] },\n  { id: \"heat\", label: \"Heat\", col: 1, color: t.palette[6] },\n  { id: \"residential\", label: \"Residential\", col: 2, color: MUTED },\n  { id: \"industrial\", label: \"Industrial\", col: 2, color: MUTED },\n  { id: \"commercial\", label: \"Commercial\", col: 2, color: MUTED },\n  { id: \"transport\", label: \"Transport\", col: 2, color: MUTED },\n];\n\nconst LINKS_RAW = [\n  { source: \"coal\", target: \"electricity\", value: 42 },\n  { source: \"gas\", target: \"electricity\", value: 28 },\n  { source: \"gas\", target: \"heat\", value: 18 },\n  { source: \"nuclear\", target: \"electricity\", value: 22 },\n  { source: \"renewables\", target: \"electricity\", value: 24 }, // wind 15 + solar 9\n  { source: \"renewables\", target: \"heat\", value: 3 }, // solar 3\n  { source: \"electricity\", target: \"residential\", value: 34 },\n  { source: \"electricity\", target: \"industrial\", value: 40 },\n  { source: \"electricity\", target: \"commercial\", value: 30 },\n  { source: \"electricity\", target: \"transport\", value: 12 },\n  { source: \"heat\", target: \"residential\", value: 14 },\n  { source: \"heat\", target: \"industrial\", value: 7 },\n];\n\nconst COL_HEADERS = [\"PRIMARY SOURCES\", \"ENERGY CARRIERS\", \"END-USE SECTORS\"];\nconst COL_X = [170, 760, 1440]; // left edge of the node bar per column; left-most gives \"Renewables\" room to the left\nconst NODE_W = 26;\nconst GAP = 18; // vertical gap between stacked nodes in the same column\nconst PLOT_TOP = 150;\nconst PLOT_BOTTOM = 838;\nconst PLOT_H = PLOT_BOTTOM - PLOT_TOP;\nconst H = SIZE.height; // used to flip top-down pixel y into the chart's data y\n\n// --- Layout: node sizes, per-column vertical stacking, link attach points ----\nconst nodes = NODES_RAW.map((n) => ({ ...n, out: [], in: [] }));\nconst byId = Object.fromEntries(nodes.map((n) => [n.id, n]));\nfor (const l of LINKS_RAW) {\n  byId[l.source].out.push(l);\n  byId[l.target].in.push(l);\n}\nfor (const n of nodes) {\n  const outSum = n.out.reduce((s, l) => s + l.value, 0);\n  const inSum = n.in.reduce((s, l) => s + l.value, 0);\n  n.value = Math.max(outSum, inSum);\n}\n\nconst columns = [0, 1, 2].map((c) => nodes.filter((n) => n.col === c));\n\n// Reorder nodes within each column by the value-weighted average rank of\n// their linked counterparts (Sugiyama-style barycenter heuristic), sweeping\n// left-to-right then right-to-left until it converges. This keeps nodes with\n// shared flow paths adjacent, which cuts down ribbon crossings between\n// columns instead of relying on the arbitrary NODES_RAW order.\nconst assignRanks = (col) => col.forEach((n, i) => (n.rank = i));\nconst barycenter = (links, counterpartId) => {\n  const total = links.reduce((s, l) => s + l.value, 0);\n  if (!total) return null;\n  return links.reduce((s, l) => s + byId[counterpartId(l)].rank * l.value, 0) / total;\n};\nconst reorder = (col, links, counterpartId) =>\n  col\n    .map((n, i) => ({ n, key: barycenter(links(n), counterpartId) ?? n.rank ?? i }))\n    .sort((a, b) => a.key - b.key)\n    .map((s) => s.n);\n\nassignRanks(columns[0]);\nfor (let pass = 0; pass < 4; pass++) {\n  if (pass % 2 === 0) {\n    for (let c = 1; c < columns.length; c++) {\n      columns[c] = reorder(columns[c], (n) => n.in, (l) => l.source);\n      assignRanks(columns[c]);\n    }\n  } else {\n    for (let c = columns.length - 2; c >= 0; c--) {\n      columns[c] = reorder(columns[c], (n) => n.out, (l) => l.target);\n      assignRanks(columns[c]);\n    }\n  }\n}\n\n// One shared px/unit scale (from the tightest-fitting column) keeps a given\n// flow value the same thickness everywhere it appears in the diagram.\nconst scale = Math.min(\n  ...columns.map((col) => {\n    const total = col.reduce((s, n) => s + n.value, 0);\n    return (PLOT_H - GAP * (col.length - 1)) / total;\n  }),\n);\n\nfor (const col of columns) {\n  const stackH = col.reduce((s, n) => s + n.value * scale, 0) + GAP * (col.length - 1);\n  let cursor = PLOT_TOP + (PLOT_H - stackH) / 2;\n  for (const n of col) {\n    n.x0 = COL_X[n.col];\n    n.x1 = n.x0 + NODE_W;\n    n.y0 = cursor; // top edge, top-down px\n    n.h = n.value * scale;\n    n.y1 = n.y0 + n.h; // bottom edge, top-down px\n    cursor = n.y1 + GAP;\n  }\n}\n\n// Stack each node's links along its edge, ordered by the counterpart's\n// position, so ribbons fan out with minimal crossing near the node.\nfor (const n of nodes) {\n  n.out.sort((a, b) => byId[a.target].y0 - byId[b.target].y0);\n  let oc = n.y0;\n  for (const l of n.out) {\n    l.sy0 = oc;\n    l.sy1 = oc + l.value * scale;\n    l.sx = n.x1;\n    oc = l.sy1;\n  }\n  n.in.sort((a, b) => byId[a.source].y0 - byId[b.source].y0);\n  let ic = n.y0;\n  for (const l of n.in) {\n    l.ty0 = ic;\n    l.ty1 = ic + l.value * scale;\n    l.tx = n.x0;\n    ic = l.ty1;\n  }\n}\n\nconst fmt = (v) => `${v} TWh`;\n\nfunction ribbonPath(xs, ys, l) {\n  const cx = (l.sx + l.tx) / 2;\n  const P = (x, yPix) => `${xs(x).toFixed(1)} ${ys(H - yPix).toFixed(1)}`;\n  const C = (yPix) => `${xs(cx).toFixed(1)} ${ys(H - yPix).toFixed(1)}`;\n  return (\n    `M ${P(l.sx, l.sy0)} ` +\n    `C ${C(l.sy0)}, ${C(l.ty0)}, ${P(l.tx, l.ty0)} ` +\n    `L ${P(l.tx, l.ty1)} ` +\n    `C ${C(l.ty1)}, ${C(l.sy1)}, ${P(l.sx, l.sy1)} Z`\n  );\n}\n\n// --- Overlay layers -----------------------------------------------------------\nfunction Links() {\n  const xs = useXScale();\n  const ys = useYScale();\n  return (\n    <g>\n      {LINKS_RAW.map((l, k) => (\n        <path key={k} d={ribbonPath(xs, ys, l)} fill={byId[l.source].color} fillOpacity={0.42}>\n          <title>{`${byId[l.source].label} → ${byId[l.target].label}: ${fmt(l.value)}`}</title>\n        </path>\n      ))}\n    </g>\n  );\n}\n\nfunction Nodes() {\n  const xs = useXScale();\n  const ys = useYScale();\n  return (\n    <g>\n      {nodes.map((n) => (\n        <rect\n          key={n.id}\n          x={xs(n.x0).toFixed(1)}\n          y={ys(H - n.y0).toFixed(1)}\n          width={(xs(n.x1) - xs(n.x0)).toFixed(1)}\n          height={(ys(H - n.y1) - ys(H - n.y0)).toFixed(1)}\n          fill={n.color}\n          rx={3}\n        >\n          <title>{`${n.label}: ${fmt(n.value)}`}</title>\n        </rect>\n      ))}\n    </g>\n  );\n}\n\nfunction Labels() {\n  const xs = useXScale();\n  const ys = useYScale();\n  return (\n    <g fontFamily={FONT}>\n      {COL_HEADERS.map((label, c) => (\n        <text\n          key={label}\n          x={xs(COL_X[c] + NODE_W / 2)}\n          y={ys(H - (PLOT_TOP - 26))}\n          textAnchor=\"middle\"\n          fontSize={13}\n          fontWeight={600}\n          letterSpacing={1}\n          fill={t.inkSoft}\n        >\n          {label}\n        </text>\n      ))}\n      {nodes.map((n) => {\n        const cy = ys(H - (n.y0 + n.h / 2));\n        const anchor = n.col === 0 ? \"end\" : n.col === 2 ? \"start\" : \"middle\";\n        const lx = n.col === 0 ? xs(n.x0) - 12 : n.col === 2 ? xs(n.x1) + 12 : xs((n.x0 + n.x1) / 2);\n        const midAbove = n.col === 1;\n        return (\n          <g key={n.id}>\n            <text\n              x={lx}\n              y={midAbove ? ys(H - n.y0) - 22 : cy - 6}\n              textAnchor={anchor}\n              dominantBaseline=\"middle\"\n              fontSize={16}\n              fontWeight={600}\n              fill={t.ink}\n            >\n              {n.label}\n            </text>\n            <text\n              x={lx}\n              y={midAbove ? ys(H - n.y0) - 4 : cy + 14}\n              textAnchor={anchor}\n              dominantBaseline=\"middle\"\n              fontSize={13}\n              fill={t.inkSoft}\n            >\n              {fmt(n.value)}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\nfunction Frame() {\n  const xs = useXScale();\n  const ys = useYScale();\n  return (\n    <g fontFamily={FONT}>\n      <text x={xs(SIZE.width / 2)} y={ys(H - 46)} textAnchor=\"middle\" fontSize={27} fontWeight={600} fill={t.ink}>\n        {TITLE}\n      </text>\n      <text x={xs(SIZE.width / 2)} y={ys(H - 78)} textAnchor=\"middle\" fontSize={16} fill={t.inkSoft}>\n        National energy flow: primary sources → carriers → end-use sectors\n      </text>\n      <text x={xs(SIZE.width / 2)} y={ys(H - 872)} textAnchor=\"middle\" fontSize={14} fill={t.inkSoft}>\n        Link width and node height ∝ flow value (TWh/yr)\n      </text>\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={SIZE.width}\n      height={SIZE.height}\n      margin={{ top: 0, right: 0, bottom: 0, left: 0 }}\n      series={[]}\n      xAxis={[{ id: \"x\", scaleType: \"linear\", min: 0, max: SIZE.width }]}\n      yAxis={[{ id: \"y\", scaleType: \"linear\", min: 0, max: SIZE.height }]}\n      skipAnimation\n    >\n      <Links />\n      <Nodes />\n      <Labels />\n      <Frame />\n    </ChartContainer>\n  );\n}\n"}