{"spec_id":"parallel-categories-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// parallel-categories-basic: Basic Parallel Categories Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 86/100 | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Support-ticket routing: priority -> department -> outcome.\nconst dims = [\"Priority\", \"Department\", \"Outcome\"];\nconst cats = [\n  [\"High\", \"Medium\", \"Low\"],\n  [\"Technical\", \"Billing\", \"Account\"],\n  [\"Resolved\", \"Escalated\"],\n];\nconst paths = [\n  { v: [\"High\", \"Technical\", \"Escalated\"], count: 22 },\n  { v: [\"High\", \"Technical\", \"Resolved\"], count: 8 },\n  { v: [\"High\", \"Billing\", \"Escalated\"], count: 10 },\n  { v: [\"High\", \"Billing\", \"Resolved\"], count: 5 },\n  { v: [\"Medium\", \"Technical\", \"Escalated\"], count: 9 },\n  { v: [\"Medium\", \"Technical\", \"Resolved\"], count: 26 },\n  { v: [\"Medium\", \"Billing\", \"Escalated\"], count: 5 },\n  { v: [\"Medium\", \"Billing\", \"Resolved\"], count: 21 },\n  { v: [\"Medium\", \"Account\", \"Resolved\"], count: 14 },\n  { v: [\"Low\", \"Technical\", \"Resolved\"], count: 30 },\n  { v: [\"Low\", \"Billing\", \"Resolved\"], count: 25 },\n  { v: [\"Low\", \"Account\", \"Resolved\"], count: 18 },\n  { v: [\"Low\", \"Account\", \"Escalated\"], count: 2 },\n];\nconst priorityColor = { High: t.palette[0], Medium: t.palette[1], Low: t.palette[2] };\n\n// --- Layout: stack nodes per dimension, then size ribbons between them ------\nfunction hexToRgba(hex, alpha) {\n  const n = parseInt(hex.slice(1), 16);\n  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;\n}\n\nfunction splitSegments(range, ordered) {\n  const total = ordered.reduce((s, o) => s + o.value, 0) || 1;\n  const height = range[1] - range[0];\n  let cursor = range[1];\n  const segs = {};\n  ordered.forEach(({ key, value }) => {\n    const h = (height * value) / total;\n    segs[key] = [cursor - h, cursor];\n    cursor -= h;\n  });\n  return segs;\n}\n\nconst totals = dims.map((_, d) => {\n  const byCat = {};\n  cats[d].forEach((c) => (byCat[c] = 0));\n  paths.forEach((p) => (byCat[p.v[d]] += p.count));\n  return byCat;\n});\n\nconst GAP = 7;\nconst extents = dims.map(\n  (_, d) => cats[d].reduce((s, c) => s + totals[d][c], 0) + GAP * (cats[d].length - 1),\n);\nconst maxExtent = Math.max(...extents);\n\nconst nodePos = dims.map((_, d) => {\n  const topPad = (maxExtent - extents[d]) / 2;\n  let cursor = maxExtent - topPad;\n  const pos = {};\n  cats[d].forEach((c, i) => {\n    const h = totals[d][c];\n    pos[c] = [cursor - h, cursor];\n    cursor -= h;\n    if (i < cats[d].length - 1) cursor -= GAP;\n  });\n  return pos;\n});\n\n// Priority -> Department (transition 0), grouped by priority for contiguous color blocks.\n// t0[department][priority] = ticket count flowing along that priority->department edge.\nconst t0 = {};\ncats[1].forEach((dept) => {\n  t0[dept] = {};\n  cats[0].forEach((pr) => (t0[dept][pr] = 0));\n});\npaths.forEach((p) => (t0[p.v[1]][p.v[0]] += p.count));\n\nconst priorityRightSeg = {};\ncats[0].forEach((pr) => {\n  const ordered = cats[1].map((dept) => ({ key: dept, value: t0[dept][pr] })).filter((o) => o.value > 0);\n  priorityRightSeg[pr] = splitSegments(nodePos[0][pr], ordered);\n});\nconst deptLeftSeg = {};\ncats[1].forEach((dept) => {\n  const ordered = cats[0].map((pr) => ({ key: pr, value: t0[dept][pr] })).filter((o) => o.value > 0);\n  deptLeftSeg[dept] = splitSegments(nodePos[1][dept], ordered);\n});\n\n// Department -> Outcome (transition 1), still split by priority first so a\n// priority's color stays a contiguous block all the way through.\n// t1[department][priority][outcome] = ticket count for that full 3-hop path.\nconst t1 = {};\ncats[1].forEach((dept) => {\n  t1[dept] = {};\n  cats[0].forEach((pr) => {\n    t1[dept][pr] = {};\n    cats[2].forEach((o) => (t1[dept][pr][o] = 0));\n  });\n});\npaths.forEach((p) => (t1[p.v[1]][p.v[0]][p.v[2]] += p.count));\n\nconst deptRightSeg = {};\ncats[1].forEach((dept) => {\n  deptRightSeg[dept] = {};\n  cats[0].forEach((pr) => {\n    const range = deptLeftSeg[dept][pr];\n    if (!range) return;\n    const ordered = cats[2].map((o) => ({ key: o, value: t1[dept][pr][o] })).filter((x) => x.value > 0);\n    deptRightSeg[dept][pr] = splitSegments(range, ordered);\n  });\n});\nconst outcomeLeftSeg = {};\ncats[2].forEach((o) => {\n  const ordered = [];\n  cats[0].forEach((pr) => {\n    cats[1].forEach((dept) => {\n      const v = t1[dept][pr][o];\n      if (v > 0) ordered.push({ key: `${pr}|${dept}`, value: v });\n    });\n  });\n  outcomeLeftSeg[o] = splitSegments(nodePos[2][o], ordered);\n});\n\n// --- Ribbons: smoothstep-eased bands filled between a top and bottom curve --\nconst STEPS = 14;\nfunction curvePoints(x0, y0, x1, y1) {\n  const pts = [];\n  for (let i = 0; i <= STEPS; i++) {\n    const tt = i / STEPS;\n    const s = tt * tt * (3 - 2 * tt);\n    pts.push({ x: x0 + (x1 - x0) * tt, y: y0 + (y1 - y0) * s });\n  }\n  return pts;\n}\n\n// Each ribbon is two line datasets (top edge, bottom edge) with the bottom one\n// filled up to the top (\"fill: -1\"). Giving both edges a thin matching-color\n// stroke keeps adjacent/overlapping ribbons visually separated instead of\n// blurring into one blob, and `highlight` bumps a path's opacity + stroke\n// weight to call out the diagram's key pattern (see the transition-1 loop).\nconst datasets = [];\nfunction addRibbon(x0, x1, startRange, endRange, color, flowLabel, highlight = false) {\n  const fillAlpha = highlight ? 0.75 : 0.55;\n  const strokeAlpha = highlight ? 1 : 0.85;\n  const strokeWidth = highlight ? 1.5 : 1;\n  datasets.push({\n    data: curvePoints(x0, startRange[1], x1, endRange[1]),\n    borderWidth: strokeWidth,\n    borderColor: hexToRgba(color, strokeAlpha),\n    pointRadius: 0,\n    fill: false,\n    tension: 0,\n  });\n  datasets.push({\n    data: curvePoints(x0, startRange[0], x1, endRange[0]),\n    borderWidth: strokeWidth,\n    borderColor: hexToRgba(color, strokeAlpha),\n    pointRadius: 0,\n    pointHitRadius: 10,\n    fill: \"-1\",\n    backgroundColor: hexToRgba(color, fillAlpha),\n    tension: 0,\n    flowLabel,\n  });\n}\n\ncats[0].forEach((pr) => {\n  cats[1].forEach((dept) => {\n    const start = priorityRightSeg[pr][dept];\n    const end = deptLeftSeg[dept][pr];\n    if (start && end) addRibbon(0, 1, start, end, priorityColor[pr], `${pr} → ${dept}: ${t0[dept][pr]} tickets`);\n  });\n});\n// High-priority tickets that end up Escalated are the standout pattern in this\n// data (71% of High tickets escalate, vs. 19% Medium and 3% Low) - highlight\n// those two paths so the diagram surfaces that insight instead of treating\n// every flow equally.\ncats[0].forEach((pr) => {\n  cats[1].forEach((dept) => {\n    cats[2].forEach((o) => {\n      const start = deptRightSeg[dept]?.[pr]?.[o];\n      const end = outcomeLeftSeg[o]?.[`${pr}|${dept}`];\n      const count = t1[dept][pr][o];\n      if (start && end) {\n        const highlight = pr === \"High\" && o === \"Escalated\";\n        addRibbon(1, 2, start, end, priorityColor[pr], `${pr} → ${dept} → ${o}: ${count} tickets`, highlight);\n      }\n    });\n  });\n});\n\n// --- Nodes: thick vertical strokes act as the category \"blocks\" ------------\nconst NODE_WIDTH = 30;\ndims.forEach((_, d) => {\n  cats[d].forEach((c) => {\n    const [y0, y1] = nodePos[d][c];\n    datasets.push({\n      data: [\n        { x: d, y: y0 },\n        { x: d, y: y1 },\n      ],\n      borderColor: t.inkSoft,\n      borderWidth: NODE_WIDTH,\n      borderCapStyle: \"butt\",\n      pointRadius: 0,\n      pointHitRadius: Math.max(15, (y1 - y0) / 2),\n      fill: false,\n      tension: 0,\n      nodeLabel: `${c}: ${totals[d][c]} tickets`,\n    });\n  });\n});\n\n// --- Legend proxies: one swatch per priority, ribbons/nodes stay hidden ----\ncats[0].forEach((pr) => {\n  datasets.push({\n    label: `${pr} priority`,\n    data: [],\n    backgroundColor: priorityColor[pr],\n    borderColor: priorityColor[pr],\n    isLegend: true,\n  });\n});\n\n// --- Category labels above each node ----------------------------------------\nconst nodeLabelPlugin = {\n  id: \"nodeLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    ctx.fillStyle = t.ink;\n    ctx.font = \"600 15px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"bottom\";\n    dims.forEach((_, d) => {\n      cats[d].forEach((c) => {\n        const [, y1] = nodePos[d][c];\n        const px = scales.x.getPixelForValue(d);\n        const py = scales.y.getPixelForValue(y1);\n        ctx.fillText(c, px, py - 10);\n      });\n    });\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"line\",\n  data: { datasets },\n  plugins: [nodeLabelPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 40, bottom: 10, left: 12, right: 12 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"parallel-categories-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n      },\n      legend: {\n        position: \"top\",\n        title: { display: true, text: \"Ticket priority\", color: t.inkSoft, font: { size: 13 } },\n        labels: {\n          color: t.ink,\n          font: { size: 14 },\n          usePointStyle: true,\n          filter: (item, data) => data.datasets[item.datasetIndex].isLegend === true,\n        },\n      },\n      tooltip: {\n        backgroundColor: t.elevatedBg,\n        titleColor: t.ink,\n        bodyColor: t.ink,\n        borderColor: t.grid,\n        borderWidth: 1,\n        displayColors: false,\n        filter: (item) => Boolean(item.dataset.flowLabel || item.dataset.nodeLabel),\n        callbacks: {\n          title: () => \"\",\n          label: (item) => item.dataset.flowLabel || item.dataset.nodeLabel,\n        },\n      },\n    },\n    interaction: { mode: \"nearest\", intersect: true },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: -0.25,\n        max: 2.25,\n        grid: { display: false },\n        border: { display: false },\n        afterBuildTicks: (axis) => {\n          axis.ticks = [0, 1, 2].map((v) => ({ value: v }));\n        },\n        ticks: {\n          color: t.ink,\n          font: { size: 16, weight: \"500\" },\n          callback: (v) => dims[v] ?? \"\",\n        },\n      },\n      y: {\n        display: false,\n        min: -maxExtent * 0.05,\n        max: maxExtent * 1.08,\n      },\n    },\n  },\n});\n"}