{"spec_id":"parallel-categories-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// parallel-categories-basic: Basic Parallel Categories Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-05\nimport { useState } from \"react\";\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: Titanic passengers by class, sex and outcome (in-memory, deterministic) ---\n// Class -> Sex -> Outcome, one row per unique combination (Kaggle \"train.csv\" tallies).\nconst CLASS_ORDER = [\"1st\", \"2nd\", \"3rd\"];\nconst SEX_ORDER = [\"Female\", \"Male\"];\nconst OUTCOME_ORDER = [\"Survived\", \"Did not survive\"];\n\nconst ROWS = [\n  { cls: \"1st\", sex: \"Female\", outcome: \"Survived\", n: 91 },\n  { cls: \"1st\", sex: \"Female\", outcome: \"Did not survive\", n: 3 },\n  { cls: \"1st\", sex: \"Male\", outcome: \"Survived\", n: 45 },\n  { cls: \"1st\", sex: \"Male\", outcome: \"Did not survive\", n: 77 },\n  { cls: \"2nd\", sex: \"Female\", outcome: \"Survived\", n: 70 },\n  { cls: \"2nd\", sex: \"Female\", outcome: \"Did not survive\", n: 6 },\n  { cls: \"2nd\", sex: \"Male\", outcome: \"Survived\", n: 17 },\n  { cls: \"2nd\", sex: \"Male\", outcome: \"Did not survive\", n: 91 },\n  { cls: \"3rd\", sex: \"Female\", outcome: \"Survived\", n: 72 },\n  { cls: \"3rd\", sex: \"Female\", outcome: \"Did not survive\", n: 72 },\n  { cls: \"3rd\", sex: \"Male\", outcome: \"Survived\", n: 47 },\n  { cls: \"3rd\", sex: \"Male\", outcome: \"Did not survive\", n: 300 },\n];\n\nconst TOTAL = ROWS.reduce((sum, r) => sum + r.n, 0);\nconst GAP = 34; // px between stacked node segments within a column\nconst NODE_HALF = 13; // half-width of a node rectangle\nconst MIN_SEG = 8; // px floor per ribbon segment so near-zero counts (e.g. n=3) stay a visible sliver\n\n// Outcome carries the semantic color: Survived -> brand green, Did not survive -> matte red.\nconst outcomeColor = (outcome) => (outcome === \"Survived\" ? t.palette[0] : t.palette[4]);\n\n// Sum row counts grouped by the given key (\"cls\" | \"sex\" | \"outcome\") - real totals, used for node labels.\nfunction nodeTotals(key) {\n  const totals = new Map();\n  ROWS.forEach((r) => totals.set(r[key], (totals.get(r[key]) || 0) + r.n));\n  return totals;\n}\n\n// Floor-applied stacking height per node: sums each row's max(count*k, MIN_SEG), so a node's\n// rectangle exactly matches the space its (possibly floor-boosted) row segments occupy.\nfunction effectiveHeights(key, k) {\n  const heights = new Map();\n  ROWS.forEach((r) => {\n    const h = Math.max(r.n * k, MIN_SEG);\n    heights.set(r[key], (heights.get(r[key]) || 0) + h);\n  });\n  return heights;\n}\n\n// Stack a column's nodes top-to-bottom in `order`, vertically centered in the drawing area.\nfunction layoutColumn(order, totals, heights, area) {\n  const contentHeight = order.reduce((s, name) => s + heights.get(name), 0) + GAP * (order.length - 1);\n  let y = area.top + (area.height - contentHeight) / 2;\n  const nodes = {};\n  order.forEach((name) => {\n    const h = heights.get(name);\n    nodes[name] = { y0: y, y1: y + h, total: totals.get(name) };\n    y += h + GAP;\n  });\n  return nodes;\n}\n\n// For every row, find its stacked sub-segment [y0, y1] within its column's node (floor-applied height).\nfunction rowSegments(key, nodes, k) {\n  const cursor = {};\n  return ROWS.map((r) => {\n    const name = r[key];\n    if (cursor[name] === undefined) cursor[name] = nodes[name].y0;\n    const y0 = cursor[name];\n    const y1 = y0 + Math.max(r.n * k, MIN_SEG);\n    cursor[name] = y1;\n    return { ...r, y0, y1 };\n  });\n}\n\n// A ribbon between two vertical segments at x0 and x1, bulging via mirrored bezier curves.\nfunction ribbonPath(x0, y0a, y1a, x1, y0b, y1b) {\n  const xm = (x0 + x1) / 2;\n  return `M${x0},${y0a} C${xm},${y0a} ${xm},${y0b} ${x1},${y0b} L${x1},${y1b} C${xm},${y1b} ${xm},${y1a} ${x0},${y1a} Z`;\n}\n\nfunction ParallelCategories() {\n  const area = useDrawingArea();\n  const [hoveredRow, setHoveredRow] = useState(null);\n  const maxNodes = Math.max(CLASS_ORDER.length, SEX_ORDER.length, OUTCOME_ORDER.length);\n  const k = (area.height - GAP * (maxNodes - 1)) / TOTAL;\n\n  const colX = [area.left, area.left + area.width / 2, area.left + area.width];\n  const clsTotals = nodeTotals(\"cls\");\n  const sexTotals = nodeTotals(\"sex\");\n  const outcomeTotals = nodeTotals(\"outcome\");\n  const clsHeights = effectiveHeights(\"cls\", k);\n  const sexHeights = effectiveHeights(\"sex\", k);\n  const outcomeHeights = effectiveHeights(\"outcome\", k);\n  const clsNodes = layoutColumn(CLASS_ORDER, clsTotals, clsHeights, area);\n  const sexNodes = layoutColumn(SEX_ORDER, sexTotals, sexHeights, area);\n  const outcomeNodes = layoutColumn(OUTCOME_ORDER, outcomeTotals, outcomeHeights, area);\n\n  const clsSegs = rowSegments(\"cls\", clsNodes, k);\n  const sexSegs = rowSegments(\"sex\", sexNodes, k);\n  const outcomeSegs = rowSegments(\"outcome\", outcomeNodes, k);\n\n  // Ribbon fill/stroke by hover state: the hovered row's full class->sex->outcome path\n  // brightens while every other ribbon dims, tracing one flow across all three columns.\n  function ribbonStyle(i) {\n    const isHovered = hoveredRow === i;\n    const isDimmed = hoveredRow !== null && !isHovered;\n    return {\n      fillOpacity: isDimmed ? 0.12 : isHovered ? 0.92 : 0.78,\n      strokeOpacity: isDimmed ? 0.06 : isHovered ? 0.35 : 0.12,\n      strokeWidth: isHovered ? 1.5 : 1,\n    };\n  }\n\n  return (\n    <g>\n      <defs>\n        {/* Secondary, color-independent cue for \"Did not survive\" ribbons (diagonal hatch),\n            so red/green stay distinguishable for deuteranope/protanope viewers. */}\n        <pattern id=\"outcome-hatch\" patternUnits=\"userSpaceOnUse\" width={6} height={6} patternTransform=\"rotate(45)\">\n          <line x1={0} y1={0} x2={0} y2={6} stroke={t.ink} strokeOpacity={0.45} strokeWidth={1.5} />\n        </pattern>\n      </defs>\n\n      {[\"Class\", \"Sex\", \"Outcome\"].map((label, i) => (\n        <text\n          key={label}\n          x={colX[i]}\n          y={area.top - 22}\n          textAnchor=\"middle\"\n          fontSize={15}\n          fontWeight={600}\n          fill={t.inkSoft}\n        >\n          {label}\n        </text>\n      ))}\n\n      {ROWS.map((r, i) => {\n        const d = ribbonPath(\n          colX[0] + NODE_HALF,\n          clsSegs[i].y0,\n          clsSegs[i].y1,\n          colX[1] - NODE_HALF,\n          sexSegs[i].y0,\n          sexSegs[i].y1\n        );\n        const style = ribbonStyle(i);\n        return (\n          <g key={`link1-${i}`}>\n            <path\n              d={d}\n              fill={outcomeColor(r.outcome)}\n              stroke={t.ink}\n              cursor=\"pointer\"\n              {...style}\n              onMouseEnter={() => setHoveredRow(i)}\n              onMouseLeave={() => setHoveredRow(null)}\n            />\n            {r.outcome === \"Did not survive\" && (\n              <path d={d} fill=\"url(#outcome-hatch)\" fillOpacity={style.fillOpacity} pointerEvents=\"none\" />\n            )}\n          </g>\n        );\n      })}\n      {ROWS.map((r, i) => {\n        const d = ribbonPath(\n          colX[1] + NODE_HALF,\n          sexSegs[i].y0,\n          sexSegs[i].y1,\n          colX[2] - NODE_HALF,\n          outcomeSegs[i].y0,\n          outcomeSegs[i].y1\n        );\n        const style = ribbonStyle(i);\n        return (\n          <g key={`link2-${i}`}>\n            <path\n              d={d}\n              fill={outcomeColor(r.outcome)}\n              stroke={t.ink}\n              cursor=\"pointer\"\n              {...style}\n              onMouseEnter={() => setHoveredRow(i)}\n              onMouseLeave={() => setHoveredRow(null)}\n            />\n            {r.outcome === \"Did not survive\" && (\n              <path d={d} fill=\"url(#outcome-hatch)\" fillOpacity={style.fillOpacity} pointerEvents=\"none\" />\n            )}\n          </g>\n        );\n      })}\n\n      {[clsNodes, sexNodes, outcomeNodes].map((nodes, ci) =>\n        Object.entries(nodes).map(([name, node]) => (\n          <rect\n            key={`node-${ci}-${name}`}\n            x={colX[ci] - NODE_HALF}\n            y={node.y0}\n            width={NODE_HALF * 2}\n            height={node.y1 - node.y0}\n            rx={2}\n            fill={t.ink}\n            fillOpacity={0.88}\n          />\n        ))\n      )}\n\n      {Object.entries(clsNodes).map(([name, node]) => (\n        <text\n          key={`label-cls-${name}`}\n          x={colX[0] - NODE_HALF - 12}\n          y={(node.y0 + node.y1) / 2}\n          textAnchor=\"end\"\n          dominantBaseline=\"middle\"\n          fontSize={14}\n          fill={t.ink}\n        >\n          {`${name} · ${node.total}`}\n        </text>\n      ))}\n      {Object.entries(sexNodes).map(([name, node]) => (\n        <text\n          key={`label-sex-${name}`}\n          x={colX[1]}\n          y={node.y0 - 12}\n          textAnchor=\"middle\"\n          fontSize={14}\n          fill={t.ink}\n        >\n          {`${name} · ${node.total}`}\n        </text>\n      ))}\n      {Object.entries(outcomeNodes).map(([name, node]) => (\n        <text\n          key={`label-outcome-${name}`}\n          x={colX[2] + NODE_HALF + 12}\n          y={(node.y0 + node.y1) / 2}\n          textAnchor=\"start\"\n          dominantBaseline=\"middle\"\n          fontSize={14}\n          fill={t.ink}\n        >\n          {`${name} · ${node.total}`}\n        </text>\n      ))}\n    </g>\n  );\n}\n\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n  const title = \"Titanic Passengers · parallel-categories-basic · javascript · muix · anyplot.ai\";\n  const titleFontSize = Math.round(22 * (title.length > 67 ? 67 / title.length : 1));\n  const headerH = 118;\n  const pad = 40;\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        backgroundColor: t.pageBg,\n        position: \"relative\",\n        fontFamily: '\"Helvetica Neue\", Arial, sans-serif',\n        boxSizing: \"border-box\",\n      }}\n    >\n      <div style={{ position: \"absolute\", top: 26, left: pad, right: pad }}>\n        <div style={{ color: t.ink, fontSize: titleFontSize, fontWeight: 600 }}>{title}</div>\n        <div style={{ color: t.inkSoft, fontSize: 15, marginTop: 6 }}>\n          Class, sex and survival outcome for 891 Titanic passengers — ribbon width is\n          proportional to passenger count.\n        </div>\n        <div style={{ display: \"flex\", gap: 24, marginTop: 12, alignItems: \"center\" }}>\n          {OUTCOME_ORDER.map((name) => (\n            <div key={name} style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n              <span\n                style={{\n                  width: 14,\n                  height: 14,\n                  borderRadius: 3,\n                  backgroundColor: outcomeColor(name),\n                  // \"Did not survive\" repeats the ribbons' diagonal-hatch cue, so the two\n                  // outcomes stay distinguishable by texture alone, not just green vs. red.\n                  backgroundImage:\n                    name === \"Did not survive\"\n                      ? \"repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(0,0,0,0.4) 2px, rgba(0,0,0,0.4) 3px)\"\n                      : undefined,\n                  display: \"inline-block\",\n                }}\n              />\n              <span style={{ color: t.inkSoft, fontSize: 14 }}>{name}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n      <div style={{ position: \"absolute\", top: headerH, left: pad }}>\n        <ChartContainer\n          width={width - pad * 2}\n          height={height - headerH - pad / 2}\n          series={[]}\n          margin={{ top: 46, bottom: 12, left: 120, right: 170 }}\n        >\n          <ParallelCategories />\n        </ChartContainer>\n      </div>\n    </div>\n  );\n}\n"}