{"spec_id":"parallel-categories-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// parallel-categories-basic: Basic Parallel Categories Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 81/100 | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 150, right: 190, bottom: 40, left: 190 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\nconst barWidth = 20;\nconst nodePadding = 22;\n\n// --- Data: customer journey from acquisition channel through product\n// category and device type to purchase outcome (in-memory, deterministic).\n// Device counts are a fixed per-channel desktop/mobile split of each\n// channel-category-outcome total (Organic Search skews desktop-heavy research\n// behavior 65/35, Paid Ads skews mobile 40/60, Referral is closer to even\n// 55/45) — no RNG, every count traces back to an explicit ratio. ------------\nconst dims = [\n  { key: \"channel\", label: \"Acquisition Channel\", values: [\"Organic Search\", \"Paid Ads\", \"Referral\"] },\n  { key: \"category\", label: \"Product Category\", values: [\"Electronics\", \"Apparel\", \"Home Goods\"] },\n  { key: \"device\", label: \"Device Type\", values: [\"Desktop\", \"Mobile\"] },\n  { key: \"outcome\", label: \"Purchase Outcome\", values: [\"Purchased\", \"Abandoned\"] },\n];\n\nconst flows = [\n  { channel: \"Organic Search\", category: \"Electronics\", device: \"Desktop\", outcome: \"Purchased\", count: 273 },\n  { channel: \"Organic Search\", category: \"Electronics\", device: \"Desktop\", outcome: \"Abandoned\", count: 117 },\n  { channel: \"Organic Search\", category: \"Electronics\", device: \"Mobile\", outcome: \"Purchased\", count: 147 },\n  { channel: \"Organic Search\", category: \"Electronics\", device: \"Mobile\", outcome: \"Abandoned\", count: 63 },\n  { channel: \"Organic Search\", category: \"Apparel\", device: \"Desktop\", outcome: \"Purchased\", count: 169 },\n  { channel: \"Organic Search\", category: \"Apparel\", device: \"Desktop\", outcome: \"Abandoned\", count: 91 },\n  { channel: \"Organic Search\", category: \"Apparel\", device: \"Mobile\", outcome: \"Purchased\", count: 91 },\n  { channel: \"Organic Search\", category: \"Apparel\", device: \"Mobile\", outcome: \"Abandoned\", count: 49 },\n  { channel: \"Organic Search\", category: \"Home Goods\", device: \"Desktop\", outcome: \"Purchased\", count: 124 },\n  { channel: \"Organic Search\", category: \"Home Goods\", device: \"Desktop\", outcome: \"Abandoned\", count: 72 },\n  { channel: \"Organic Search\", category: \"Home Goods\", device: \"Mobile\", outcome: \"Purchased\", count: 66 },\n  { channel: \"Organic Search\", category: \"Home Goods\", device: \"Mobile\", outcome: \"Abandoned\", count: 38 },\n  { channel: \"Paid Ads\", category: \"Electronics\", device: \"Desktop\", outcome: \"Purchased\", count: 60 },\n  { channel: \"Paid Ads\", category: \"Electronics\", device: \"Desktop\", outcome: \"Abandoned\", count: 84 },\n  { channel: \"Paid Ads\", category: \"Electronics\", device: \"Mobile\", outcome: \"Purchased\", count: 90 },\n  { channel: \"Paid Ads\", category: \"Electronics\", device: \"Mobile\", outcome: \"Abandoned\", count: 126 },\n  { channel: \"Paid Ads\", category: \"Apparel\", device: \"Desktop\", outcome: \"Purchased\", count: 48 },\n  { channel: \"Paid Ads\", category: \"Apparel\", device: \"Desktop\", outcome: \"Abandoned\", count: 76 },\n  { channel: \"Paid Ads\", category: \"Apparel\", device: \"Mobile\", outcome: \"Purchased\", count: 72 },\n  { channel: \"Paid Ads\", category: \"Apparel\", device: \"Mobile\", outcome: \"Abandoned\", count: 114 },\n  { channel: \"Paid Ads\", category: \"Home Goods\", device: \"Desktop\", outcome: \"Purchased\", count: 36 },\n  { channel: \"Paid Ads\", category: \"Home Goods\", device: \"Desktop\", outcome: \"Abandoned\", count: 64 },\n  { channel: \"Paid Ads\", category: \"Home Goods\", device: \"Mobile\", outcome: \"Purchased\", count: 54 },\n  { channel: \"Paid Ads\", category: \"Home Goods\", device: \"Mobile\", outcome: \"Abandoned\", count: 96 },\n  { channel: \"Referral\", category: \"Electronics\", device: \"Desktop\", outcome: \"Purchased\", count: 110 },\n  { channel: \"Referral\", category: \"Electronics\", device: \"Desktop\", outcome: \"Abandoned\", count: 50 },\n  { channel: \"Referral\", category: \"Electronics\", device: \"Mobile\", outcome: \"Purchased\", count: 90 },\n  { channel: \"Referral\", category: \"Electronics\", device: \"Mobile\", outcome: \"Abandoned\", count: 40 },\n  { channel: \"Referral\", category: \"Apparel\", device: \"Desktop\", outcome: \"Purchased\", count: 72 },\n  { channel: \"Referral\", category: \"Apparel\", device: \"Desktop\", outcome: \"Abandoned\", count: 39 },\n  { channel: \"Referral\", category: \"Apparel\", device: \"Mobile\", outcome: \"Purchased\", count: 58 },\n  { channel: \"Referral\", category: \"Apparel\", device: \"Mobile\", outcome: \"Abandoned\", count: 31 },\n  { channel: \"Referral\", category: \"Home Goods\", device: \"Desktop\", outcome: \"Purchased\", count: 55 },\n  { channel: \"Referral\", category: \"Home Goods\", device: \"Desktop\", outcome: \"Abandoned\", count: 33 },\n  { channel: \"Referral\", category: \"Home Goods\", device: \"Mobile\", outcome: \"Purchased\", count: 45 },\n  { channel: \"Referral\", category: \"Home Goods\", device: \"Mobile\", outcome: \"Abandoned\", count: 27 },\n];\n\nconst totalCount = flows.reduce((s, f) => s + f.count, 0);\nconst byIndex = (list) => (v) => list.indexOf(v);\nconst ixOf = dims.map((d) => byIndex(d.values));\n\n// A single global dimension-order sort (channel -> category -> device ->\n// outcome) drives every node's internal stacking below, so a flow keeps the\n// same relative vertical slot on both faces of every interior node instead\n// of twisting inside the node bar — the source of most avoidable crossings.\nconst flowOrder = [...flows].sort((a, b) => {\n  for (let i = 0; i < dims.length; i++) {\n    const diff = ixOf[i](a[dims[i].key]) - ixOf[i](b[dims[i].key]);\n    if (diff !== 0) return diff;\n  }\n  return 0;\n});\n\n// --- Column layout: a shared pixels-per-unit scale keeps a flow's ribbon the\n// same height on both ends it touches, regardless of how many values share\n// that column ----------------------------------------------------------------\nfunction columnTotals(values, key) {\n  return values.map((v) => ({\n    value: v,\n    total: flows.filter((f) => f[key] === v).reduce((s, f) => s + f.count, 0),\n  }));\n}\nfunction rawKy(n) {\n  return (ih - nodePadding * (n - 1)) / totalCount;\n}\nconst ky = Math.min(...dims.map((d) => rawKy(d.values.length)));\n\nfunction layoutColumn(values, key) {\n  const items = columnTotals(values, key);\n  const contentHeight = items.reduce((s, d) => s + d.total * ky, 0) + nodePadding * (items.length - 1);\n  let y = margin.top + (ih - contentHeight) / 2;\n  const nodes = new Map();\n  for (const d of items) {\n    const h = d.total * ky;\n    nodes.set(d.value, { y0: y, y1: y + h, total: d.total });\n    y += h + nodePadding;\n  }\n  return nodes;\n}\n\nconst xs = dims.map((_, i) => margin.left + (iw * i) / (dims.length - 1));\nconst dimNodes = dims.map((d) => layoutColumn(d.values, d.key));\n\n// Stack every flow within each dimension's nodes, in the shared global order,\n// recording the y-band it occupies at that dimension — reused as the link\n// target on its left and the link source on its right.\nfor (let i = 0; i < dims.length; i++) {\n  const key = dims[i].key;\n  const cursor = new Map(dims[i].values.map((v) => [v, dimNodes[i].get(v).y0]));\n  for (const f of flowOrder) {\n    const v = f[key];\n    const y0 = cursor.get(v);\n    const y1 = y0 + f.count * ky;\n    f.bandY0 = f.bandY0 || [];\n    f.bandY1 = f.bandY1 || [];\n    f.bandY0[i] = y0;\n    f.bandY1[i] = y1;\n    cursor.set(v, y1);\n  }\n}\n\n// --- Ribbon shape: two cubic-bezier edges between a source band and a target\n// band, filled as a single closed path ---------------------------------------\nfunction ribbonPath(xa, xb, sy0, sy1, ty0, ty1) {\n  const xm = (xa + xb) / 2;\n  return `M${xa},${sy0}C${xm},${sy0} ${xm},${ty0} ${xb},${ty0}L${xb},${ty1}C${xm},${ty1} ${xm},${sy1} ${xa},${sy1}Z`;\n}\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\nconst color = d3.scaleOrdinal().domain(dims[0].values).range(t.palette.slice(0, dims[0].values.length));\n\n// Ribbons (drawn first, nodes sit on top of their edges). Largest flows first\n// so the smaller, easier-to-lose ribbons draw on top at crossing points —\n// combined with the higher opacity below this keeps multi-channel overlaps\n// readable instead of blending into a muddy composite color.\nconst drawOrder = [...flows].sort((a, b) => b.count - a.count);\nfor (let i = 0; i < dims.length - 1; i++) {\n  const xa = xs[i] + barWidth;\n  const xb = xs[i + 1];\n  svg\n    .selectAll(`.ribbon-${i}`)\n    .data(drawOrder)\n    .join(\"path\")\n    .attr(\"d\", (f) => ribbonPath(xa, xb, f.bandY0[i], f.bandY1[i], f.bandY0[i + 1], f.bandY1[i + 1]))\n    .attr(\"fill\", (f) => color(f.channel))\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 1)\n    .attr(\"opacity\", 0.82);\n}\n\n// Node bars: theme-adaptive neutral anchor — these represent structural\n// totals, not a categorical series, so they stay ink-colored rather than\n// pulling another Imprint hue.\nfor (let i = 0; i < dims.length; i++) {\n  const dim = dims[i];\n  const nodes = dimNodes[i];\n  const isFirst = i === 0;\n  const x = xs[i];\n\n  svg\n    .append(\"g\")\n    .selectAll(\"rect\")\n    .data(dim.values)\n    .join(\"rect\")\n    .attr(\"x\", x)\n    .attr(\"y\", (v) => nodes.get(v).y0)\n    .attr(\"width\", barWidth)\n    .attr(\"height\", (v) => nodes.get(v).y1 - nodes.get(v).y0)\n    .attr(\"fill\", t.ink);\n\n  svg\n    .append(\"g\")\n    .selectAll(\"text\")\n    .data(dim.values)\n    .join(\"text\")\n    .attr(\"x\", isFirst ? x - 14 : x + barWidth + 14)\n    .attr(\"y\", (v) => (nodes.get(v).y0 + nodes.get(v).y1) / 2)\n    .attr(\"dy\", \"0.35em\")\n    .attr(\"text-anchor\", isFirst ? \"end\" : \"start\")\n    .style(\"font-size\", \"17px\")\n    .style(\"font-weight\", isFirst ? \"600\" : \"400\")\n    .style(\"paint-order\", \"stroke\")\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 6)\n    .attr(\"stroke-linejoin\", \"round\")\n    .attr(\"fill\", isFirst ? (v) => color(v) : t.inkSoft)\n    .text((v) => v);\n\n  svg\n    .append(\"text\")\n    .attr(\"x\", x + barWidth / 2)\n    .attr(\"y\", margin.top - 34)\n    .attr(\"text-anchor\", \"middle\")\n    .style(\"font-size\", \"15px\")\n    .style(\"font-weight\", \"600\")\n    .style(\"letter-spacing\", \"0.02em\")\n    .attr(\"fill\", t.inkSoft)\n    .text(dim.label.toUpperCase());\n}\n\n// --- Title --------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 54)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"24px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"parallel-categories-basic · javascript · d3 · anyplot.ai\");\n"}