{"spec_id":"parallel-categories-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// parallel-categories-basic: Basic Parallel Categories Plot\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-05\n\n// Parallel categories needs width-proportional ribbons connecting categorical\n// axes — visually the same shape as a Sankey diagram. The sankey/dependency-\n// wheel series types live in an add-on module that isn't vendored here (see\n// prompts/library/highcharts.md, \"Forbidden patterns\" — only the core bundle\n// is loaded). The core SVGRenderer is not a module, though, so this snippet\n// hand-draws the category axes and the ribbons connecting them with\n// chart.renderer primitives inside a series-less chart.\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// 1,900 e-commerce sessions classified across three categorical dimensions:\n// how the visitor arrived, which product category they browsed, and whether\n// they purchased. Every session is exactly one (channel, product, outcome)\n// combination, so ribbon widths and node heights stay internally consistent\n// by construction — no separate rollup can drift out of balance.\nconst CHANNEL_ORDER = [\"Paid Search\", \"Organic Search\", \"Social Media\", \"Referral\"];\nconst PRODUCT_ORDER = [\"Electronics\", \"Apparel\", \"Home & Garden\"];\nconst OUTCOME_ORDER = [\"Purchased\", \"Abandoned\"];\nconst COLUMN_LABELS = [\"Acquisition Channel\", \"Product Category\", \"Purchase Outcome\"];\n\n// Color by the first dimension (channel) so a ribbon's hue traces a session's\n// origin all the way through the product and outcome columns. Palette\n// positions follow CHANNEL_ORDER exactly — brand green goes to the first\n// (topmost, largest) channel, not cherry-picked.\nconst CHANNEL_COLOR = {\n  \"Paid Search\": t.palette[0], // brand green — first channel in canonical/visual order\n  \"Organic Search\": t.palette[1],\n  \"Social Media\": t.palette[2],\n  Referral: t.palette[3],\n};\n\nconst SESSIONS = [\n  { channel: \"Organic Search\", product: \"Electronics\", outcome: \"Purchased\", value: 130 },\n  { channel: \"Organic Search\", product: \"Electronics\", outcome: \"Abandoned\", value: 70 },\n  { channel: \"Organic Search\", product: \"Apparel\", outcome: \"Purchased\", value: 90 },\n  { channel: \"Organic Search\", product: \"Apparel\", outcome: \"Abandoned\", value: 60 },\n  { channel: \"Organic Search\", product: \"Home & Garden\", outcome: \"Purchased\", value: 80 },\n  { channel: \"Organic Search\", product: \"Home & Garden\", outcome: \"Abandoned\", value: 70 },\n  { channel: \"Paid Search\", product: \"Electronics\", outcome: \"Purchased\", value: 170 },\n  { channel: \"Paid Search\", product: \"Electronics\", outcome: \"Abandoned\", value: 130 },\n  { channel: \"Paid Search\", product: \"Apparel\", outcome: \"Purchased\", value: 100 },\n  { channel: \"Paid Search\", product: \"Apparel\", outcome: \"Abandoned\", value: 100 },\n  { channel: \"Paid Search\", product: \"Home & Garden\", outcome: \"Purchased\", value: 70 },\n  { channel: \"Paid Search\", product: \"Home & Garden\", outcome: \"Abandoned\", value: 80 },\n  { channel: \"Social Media\", product: \"Electronics\", outcome: \"Purchased\", value: 70 },\n  { channel: \"Social Media\", product: \"Electronics\", outcome: \"Abandoned\", value: 80 },\n  { channel: \"Social Media\", product: \"Apparel\", outcome: \"Purchased\", value: 110 },\n  { channel: \"Social Media\", product: \"Apparel\", outcome: \"Abandoned\", value: 90 },\n  { channel: \"Social Media\", product: \"Home & Garden\", outcome: \"Purchased\", value: 40 },\n  { channel: \"Social Media\", product: \"Home & Garden\", outcome: \"Abandoned\", value: 60 },\n  { channel: \"Referral\", product: \"Electronics\", outcome: \"Purchased\", value: 65 },\n  { channel: \"Referral\", product: \"Electronics\", outcome: \"Abandoned\", value: 35 },\n  { channel: \"Referral\", product: \"Apparel\", outcome: \"Purchased\", value: 55 },\n  { channel: \"Referral\", product: \"Apparel\", outcome: \"Abandoned\", value: 45 },\n  { channel: \"Referral\", product: \"Home & Garden\", outcome: \"Purchased\", value: 50 },\n  { channel: \"Referral\", product: \"Home & Garden\", outcome: \"Abandoned\", value: 50 },\n];\n\nconst sumBy = (key, name) => SESSIONS.filter((s) => s[key] === name).reduce((sum, s) => sum + s.value, 0);\nconst channelTotal = Object.fromEntries(CHANNEL_ORDER.map((c) => [c, sumBy(\"channel\", c)]));\nconst productTotal = Object.fromEntries(PRODUCT_ORDER.map((p) => [p, sumBy(\"product\", p)]));\nconst outcomeTotal = Object.fromEntries(OUTCOME_ORDER.map((o) => [o, sumBy(\"outcome\", o)]));\nconst TOTAL = SESSIONS.reduce((sum, s) => sum + s.value, 0);\n\n// --- Chart (series-less; ribbons hand-drawn via chart.renderer) ------------\nHighcharts.chart(\"container\", {\n  chart: {\n    backgroundColor: \"transparent\",\n    animation: false,\n    spacingTop: 170,\n    spacingBottom: 78,\n    spacingLeft: 212,\n    spacingRight: 206,\n    style: { fontFamily: \"inherit\" },\n    events: {\n      load() {\n        drawParallelCategories(this);\n      },\n    },\n  },\n  credits: { enabled: false },\n  title: {\n    text: \"parallel-categories-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n    margin: 50,\n  },\n  subtitle: {\n    text: \"1,900 e-commerce sessions · acquisition channel → product category → outcome\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: { visible: false },\n  yAxis: { visible: false },\n  legend: { enabled: false },\n  series: [],\n});\n\n// Stacks `categories` top-to-bottom proportional to `totals`, using a single\n// shared `scale` (so a value means the same number of pixels in every column)\n// and centering the stack vertically when it's shorter than the tallest\n// column's full span.\nfunction buildSegments(chart, categories, totals, gap, scale) {\n  const stackHeight = TOTAL * scale + gap * (categories.length - 1);\n  let y = chart.plotTop + (chart.plotHeight - stackHeight) / 2;\n  const segments = {};\n  categories.forEach((cat) => {\n    const h = totals[cat] * scale;\n    segments[cat] = { top: y, bottom: y + h, height: h };\n    y += h + gap;\n  });\n  return segments;\n}\n\nfunction drawParallelCategories(chart) {\n  const r = chart.renderer;\n  const nodeWidth = 30;\n  const gap = 36; // wide enough for a label to sit in the gap above a segment\n  const usableWidth = chart.plotWidth - nodeWidth;\n  const colX = (i) => chart.plotLeft + nodeWidth / 2 + (usableWidth * i) / (COLUMN_LABELS.length - 1);\n\n  // One shared scale (calibrated to the column with the most categories/gaps)\n  // keeps a session worth the same pixel height in every column, so a ribbon\n  // never tapers or flares just because two columns have a different category\n  // count.\n  const maxCategories = Math.max(CHANNEL_ORDER.length, PRODUCT_ORDER.length, OUTCOME_ORDER.length);\n  const scale = (chart.plotHeight - gap * (maxCategories - 1)) / TOTAL;\n\n  const channelSeg = buildSegments(chart, CHANNEL_ORDER, channelTotal, gap, scale);\n  const productSeg = buildSegments(chart, PRODUCT_ORDER, productTotal, gap, scale);\n  const outcomeSeg = buildSegments(chart, OUTCOME_ORDER, outcomeTotal, gap, scale);\n\n  // Ribbons are grouped by originating channel so a hover can highlight one\n  // channel's whole path (both hops) while dimming every other ribbon — the\n  // same thin colored stroke also keeps each ribbon's edge legible where\n  // translucent fills overlap and would otherwise blend into a muddy blob.\n  const ribbonsByChannel = {};\n  CHANNEL_ORDER.forEach((c) => (ribbonsByChannel[c] = []));\n  const REST_FILL_OPACITY = 0.45;\n  const REST_STROKE_OPACITY = 0.65;\n  const HOVER_FILL_OPACITY = 0.85;\n  const DIM_OPACITY = 0.08;\n\n  const ribbon = (x0, y0Top, y0Bottom, x1, y1Top, y1Bottom, color, channel) => {\n    const cx = (x0 + x1) / 2;\n    const el = 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({\n        fill: color,\n        \"fill-opacity\": REST_FILL_OPACITY,\n        stroke: color,\n        \"stroke-width\": 0.75,\n        \"stroke-opacity\": REST_STROKE_OPACITY,\n      })\n      .css({ cursor: \"pointer\" })\n      .add();\n    ribbonsByChannel[channel].push(el);\n    el.on(\"mouseover\", () => highlightChannel(channel));\n    el.on(\"mouseout\", resetRibbons);\n    return el;\n  };\n\n  function highlightChannel(channel) {\n    Object.entries(ribbonsByChannel).forEach(([ch, elements]) => {\n      const active = ch === channel;\n      elements.forEach((el) =>\n        el.attr({\n          \"fill-opacity\": active ? HOVER_FILL_OPACITY : DIM_OPACITY,\n          \"stroke-opacity\": active ? 1 : DIM_OPACITY,\n        }),\n      );\n    });\n  }\n\n  function resetRibbons() {\n    Object.values(ribbonsByChannel)\n      .flat()\n      .forEach((el) => el.attr({ \"fill-opacity\": REST_FILL_OPACITY, \"stroke-opacity\": REST_STROKE_OPACITY }));\n  }\n\n  // Stage 1 — Channel -> Product. Iterating products outer / channels inner\n  // gives every product node a fixed, repeatable top-to-bottom order of\n  // contributing channels, which is exactly the order stage 2 reads back to\n  // keep each channel's ribbon in the same vertical band as it crosses the\n  // product node.\n  const srcCursor = {};\n  const tgtCursor = {};\n  CHANNEL_ORDER.forEach((c) => (srcCursor[c] = channelSeg[c].top));\n  PRODUCT_ORDER.forEach((p) => (tgtCursor[p] = productSeg[p].top));\n  const subBand = {};\n  const x0a = colX(0) + nodeWidth / 2;\n  const x1a = colX(1) - nodeWidth / 2;\n\n  PRODUCT_ORDER.forEach((product) => {\n    CHANNEL_ORDER.forEach((channel) => {\n      const value = sumBy2(\"channel\", channel, \"product\", product);\n      if (value <= 0) return;\n      const h = value * scale;\n      const y0 = srcCursor[channel];\n      const y1 = tgtCursor[product];\n      ribbon(x0a, y0, y0 + h, x1a, y1, y1 + h, CHANNEL_COLOR[channel], channel);\n      subBand[`${product}|${channel}`] = { top: y1, bottom: y1 + h };\n      srcCursor[channel] += h;\n      tgtCursor[product] += h;\n    });\n  });\n\n  // Stage 2 — Product -> Outcome, split by the originating channel so the\n  // color (and thus the traceable path) survives the second hop too.\n  const tgtCursor2 = {};\n  OUTCOME_ORDER.forEach((o) => (tgtCursor2[o] = outcomeSeg[o].top));\n  const x0b = colX(1) + nodeWidth / 2;\n  const x1b = colX(2) - nodeWidth / 2;\n\n  PRODUCT_ORDER.forEach((product) => {\n    CHANNEL_ORDER.forEach((channel) => {\n      const key = `${product}|${channel}`;\n      if (!subBand[key]) return;\n      let localTop = subBand[key].top;\n      OUTCOME_ORDER.forEach((outcome) => {\n        const value = SESSIONS.find((s) => s.channel === channel && s.product === product && s.outcome === outcome)?.value ?? 0;\n        if (value <= 0) return;\n        const h = value * scale;\n        const y1 = tgtCursor2[outcome];\n        ribbon(x0b, localTop, localTop + h, x1b, y1, y1 + h, CHANNEL_COLOR[channel], channel);\n        localTop += h;\n        tgtCursor2[outcome] += h;\n      });\n    });\n  });\n\n  // Nodes on top of the ribbons: channel nodes carry the color key, product\n  // and outcome nodes stay neutral so the ribbon color alone tells the story.\n  const drawNode = (x, seg, fill) => {\n    Object.values(seg).forEach((s) => {\n      if (s.height < 1) return;\n      r.rect(x - nodeWidth / 2, s.top, nodeWidth, s.height, 3)\n        .attr({ fill, stroke: t.pageBg, \"stroke-width\": 1.5 })\n        .add();\n    });\n  };\n  CHANNEL_ORDER.forEach((c) => {\n    const s = channelSeg[c];\n    if (s.height < 1) return;\n    r.rect(colX(0) - nodeWidth / 2, s.top, nodeWidth, s.height, 3)\n      .attr({ fill: CHANNEL_COLOR[c], stroke: t.pageBg, \"stroke-width\": 1.5 })\n      .css({ cursor: \"pointer\" })\n      .on(\"mouseover\", () => highlightChannel(c))\n      .on(\"mouseout\", resetRibbons)\n      .add();\n  });\n  PRODUCT_ORDER.forEach((p) => drawNode(colX(1), { [p]: productSeg[p] }, t.inkSoft));\n  OUTCOME_ORDER.forEach((o) => drawNode(colX(2), { [o]: outcomeSeg[o] }, t.inkSoft));\n\n  // Column (dimension) headers.\n  COLUMN_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 (channel + outcome) sit beside their nodes, where there's\n  // open horizontal space; the middle (product) column labels sit just above\n  // each node instead, since ribbons touch both of its long edges.\n  CHANNEL_ORDER.forEach((c) => {\n    const s = channelSeg[c];\n    if (s.height < 1) return;\n    r.text(`${c} · ${channelTotal[c].toLocaleString()}`, colX(0) - nodeWidth / 2 - 14, (s.top + s.bottom) / 2 + 5)\n      .attr({ align: \"right\" })\n      .css({ color: t.ink, fontSize: \"14px\" })\n      .add();\n  });\n  OUTCOME_ORDER.forEach((o) => {\n    const s = outcomeSeg[o];\n    if (s.height < 1) return;\n    r.text(`${o} · ${outcomeTotal[o].toLocaleString()}`, colX(2) + nodeWidth / 2 + 14, (s.top + s.bottom) / 2 + 5)\n      .attr({ align: \"left\" })\n      .css({ color: t.ink, fontSize: \"14px\" })\n      .add();\n  });\n  PRODUCT_ORDER.forEach((p) => {\n    const s = productSeg[p];\n    if (s.height < 1) return;\n    r.text(`${p} · ${productTotal[p].toLocaleString()}`, colX(1), s.top - gap / 2 + 5)\n      .attr({ align: \"center\" })\n      .css({ color: t.inkSoft, fontSize: \"13px\" })\n      .add();\n  });\n\n  // Legend — identifies the channel colors, which is the dimension the\n  // ribbons are keyed on throughout both hops. Also hoverable, so it doubles\n  // as a discoverable entry point into the same path-highlight as the nodes.\n  const itemWidth = 200;\n  const legendWidth = itemWidth * CHANNEL_ORDER.length;\n  const legendY = chart.plotTop + chart.plotHeight + 40;\n  const legendStartX = chart.plotLeft + (chart.plotWidth - legendWidth) / 2;\n  CHANNEL_ORDER.forEach((c, i) => {\n    const itemX = legendStartX + i * itemWidth;\n    const swatch = r.rect(itemX, legendY - 11, 14, 14, 2).attr({ fill: CHANNEL_COLOR[c] }).css({ cursor: \"pointer\" }).add();\n    const label = r\n      .text(c, itemX + 22, legendY)\n      .attr({ align: \"left\" })\n      .css({ color: t.inkSoft, fontSize: \"14px\", cursor: \"pointer\" })\n      .add();\n    [swatch, label].forEach((el) => {\n      el.on(\"mouseover\", () => highlightChannel(c));\n      el.on(\"mouseout\", resetRibbons);\n    });\n  });\n}\n\n// Small helper for the two-key sum used in stage 1 (kept separate from `sumBy`\n// above, which only filters on one key).\nfunction sumBy2(keyA, valA, keyB, valB) {\n  return SESSIONS.filter((s) => s[keyA] === valA && s[keyB] === valB).reduce((sum, s) => sum + s.value, 0);\n}\n"}