{"spec_id":"swimmer-clinical-timeline","library":"muix","language":"javascript","code":"// anyplot.ai\n// swimmer-clinical-timeline: Swimmer Plot for Clinical Trial Timelines\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-08-24\nimport { BarChart } from \"@mui/x-charts/BarChart\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic): simulated Phase II oncology trial -----\n// 20 patients across two treatment arms, ordered longest-duration-first so\n// the plot reads top-to-bottom like a published clinical swimmer plot.\nconst ARM_A = \"Chemotherapy\";\nconst ARM_B = \"Targeted Therapy\";\n\nconst patients = [\n  { id: \"PT-011\", group: ARM_B, duration: 58, ongoing: true, events: [{ time: 9, type: \"partial_response\" }, { time: 27, type: \"complete_response\" }] },\n  { id: \"PT-004\", group: ARM_B, duration: 54, ongoing: true, events: [{ time: 12, type: \"partial_response\" }, { time: 34, type: \"complete_response\" }] },\n  { id: \"PT-017\", group: ARM_A, duration: 50, ongoing: true, events: [{ time: 14, type: \"partial_response\" }, { time: 40, type: \"adverse_event\" }] },\n  { id: \"PT-002\", group: ARM_B, duration: 47, ongoing: false, events: [{ time: 11, type: \"partial_response\" }, { time: 25, type: \"complete_response\" }, { time: 47, type: \"progressive_disease\" }] },\n  { id: \"PT-019\", group: ARM_A, duration: 44, ongoing: true, events: [{ time: 16, type: \"partial_response\" }] },\n  { id: \"PT-008\", group: ARM_B, duration: 41, ongoing: false, events: [{ time: 10, type: \"partial_response\" }, { time: 41, type: \"progressive_disease\" }] },\n  { id: \"PT-013\", group: ARM_A, duration: 39, ongoing: true, events: [{ time: 20, type: \"adverse_event\" }, { time: 22, type: \"partial_response\" }] },\n  { id: \"PT-005\", group: ARM_B, duration: 36, ongoing: false, events: [{ time: 36, type: \"progressive_disease\" }] },\n  { id: \"PT-016\", group: ARM_A, duration: 34, ongoing: true, events: [{ time: 18, type: \"partial_response\" }] },\n  { id: \"PT-001\", group: ARM_B, duration: 32, ongoing: false, events: [{ time: 9, type: \"partial_response\" }, { time: 32, type: \"progressive_disease\" }] },\n  { id: \"PT-020\", group: ARM_A, duration: 30, ongoing: false, events: [{ time: 12, type: \"adverse_event\" }, { time: 30, type: \"progressive_disease\" }] },\n  { id: \"PT-009\", group: ARM_B, duration: 28, ongoing: true, events: [{ time: 14, type: \"partial_response\" }] },\n  { id: \"PT-003\", group: ARM_A, duration: 26, ongoing: false, events: [{ time: 26, type: \"progressive_disease\" }] },\n  { id: \"PT-018\", group: ARM_B, duration: 24, ongoing: false, events: [{ time: 8, type: \"partial_response\" }, { time: 24, type: \"progressive_disease\" }] },\n  { id: \"PT-006\", group: ARM_A, duration: 22, ongoing: false, events: [{ time: 10, type: \"adverse_event\" }, { time: 22, type: \"progressive_disease\" }] },\n  { id: \"PT-015\", group: ARM_B, duration: 20, ongoing: false, events: [{ time: 20, type: \"progressive_disease\" }] },\n  { id: \"PT-010\", group: ARM_A, duration: 18, ongoing: false, events: [{ time: 18, type: \"progressive_disease\" }] },\n  { id: \"PT-012\", group: ARM_B, duration: 16, ongoing: false, events: [{ time: 8, type: \"adverse_event\" }, { time: 16, type: \"progressive_disease\" }] },\n  { id: \"PT-007\", group: ARM_A, duration: 14, ongoing: false, events: [{ time: 14, type: \"progressive_disease\" }] },\n  { id: \"PT-014\", group: ARM_B, duration: 12, ongoing: false, events: [{ time: 12, type: \"progressive_disease\" }] },\n];\n\nconst ids = patients.map((p) => p.id);\nconst durations = patients.map((p) => p.duration);\nconst barColors = patients.map((p) => (p.group === ARM_A ? t.palette[0] : t.palette[1]));\n\n// Event-type encoding: shape carries the primary meaning (colorblind-safe,\n// works even if two hues get confused); color reinforces clinical semantics\n// — progression uses the matte-red \"bad\" anchor, adverse event uses the\n// amber \"warning\" anchor, response events use two cool, uncommitted hues.\nconst EVENT_COLOR = {\n  partial_response: t.palette[5],\n  complete_response: t.palette[7],\n  progressive_disease: t.palette[4],\n  adverse_event: t.amber,\n};\nconst EVENT_LABEL = {\n  partial_response: \"Partial response\",\n  complete_response: \"Complete response\",\n  progressive_disease: \"Progressive disease\",\n  adverse_event: \"Adverse event\",\n};\n\nfunction trianglePath(cx, cy, r) {\n  return `M ${cx} ${cy - r} L ${cx + r} ${cy + r * 0.85} L ${cx - r} ${cy + r * 0.85} Z`;\n}\nfunction diamondPath(cx, cy, r) {\n  return `M ${cx} ${cy - r} L ${cx + r} ${cy} L ${cx} ${cy + r} L ${cx - r} ${cy} Z`;\n}\nfunction starPath(cx, cy, rOuter, rInner) {\n  const pts = [];\n  for (let i = 0; i < 10; i++) {\n    const r = i % 2 === 0 ? rOuter : rInner;\n    const angle = (Math.PI / 5) * i - Math.PI / 2;\n    pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);\n  }\n  return `M ${pts.join(\" L \")} Z`;\n}\nfunction arrowPath(x, y, r) {\n  return `M ${x} ${y - r} L ${x + r * 1.7} ${y} L ${x} ${y + r} Z`;\n}\n\nfunction EventMarker({ type, cx, cy, size }) {\n  const color = EVENT_COLOR[type];\n  switch (type) {\n    case \"partial_response\":\n      return <path d={trianglePath(cx, cy, size)} fill={color} stroke={t.pageBg} strokeWidth={1.5} />;\n    case \"complete_response\":\n      return <path d={starPath(cx, cy, size * 1.15, size * 0.48)} fill={color} stroke={t.pageBg} strokeWidth={1.2} />;\n    case \"progressive_disease\":\n      return <path d={diamondPath(cx, cy, size)} fill={color} stroke={t.pageBg} strokeWidth={1.5} />;\n    case \"adverse_event\":\n      return <circle cx={cx} cy={cy} r={size * 0.72} fill={color} stroke={t.pageBg} strokeWidth={1.2} />;\n    default:\n      return null;\n  }\n}\n\n// The two longest-surviving patients (both Targeted Therapy) are the chart's\n// clearest insight; called out with a bracket + caption rather than a heavier\n// annotation so sorting/color still do most of the storytelling work.\nconst LONGEST_IDS = new Set([...patients].sort((a, b) => b.duration - a.duration).slice(0, 2).map((p) => p.id));\n\n// Must render inside BarChart's ChartContainer to read its live scales.\nfunction SwimmerOverlay() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  if (!xScale || !yScale || typeof yScale.bandwidth !== \"function\") return null;\n  const bw = yScale.bandwidth();\n  const markerSize = Math.min(bw * 0.42, 13);\n\n  const longestRows = patients\n    .filter((p) => LONGEST_IDS.has(p.id))\n    .map((p) => ({ cy: +yScale(p.id) + bw / 2, xEnd: xScale(p.duration) }))\n    .sort((a, b) => a.cy - b.cy);\n\n  return (\n    <g>\n      {patients.map((p) => {\n        const bandTop = yScale(p.id);\n        if (bandTop == null) return null;\n        const cy = +bandTop + bw / 2;\n        return (\n          <g key={p.id}>\n            {p.events.map((e, i) => (\n              <EventMarker key={i} type={e.type} cx={xScale(e.time)} cy={cy} size={markerSize} />\n            ))}\n            {p.ongoing && <path d={arrowPath(xScale(p.duration), cy, markerSize * 1.05)} fill={t.ink} />}\n          </g>\n        );\n      })}\n      {longestRows.length === 2 && (\n        <g>\n          <path\n            d={`M ${Math.max(longestRows[0].xEnd, longestRows[1].xEnd) + 14} ${longestRows[0].cy} h 5 V ${longestRows[1].cy} h -5`}\n            fill=\"none\"\n            stroke={t.inkSoft}\n            strokeWidth={1.25}\n            opacity={0.6}\n          />\n          <text\n            x={Math.max(longestRows[0].xEnd, longestRows[1].xEnd) + 24}\n            y={(longestRows[0].cy + longestRows[1].cy) / 2}\n            fontSize={11}\n            fontStyle=\"italic\"\n            fill={t.inkSoft}\n            dominantBaseline=\"middle\"\n          >\n            Longest on study\n          </text>\n        </g>\n      )}\n    </g>\n  );\n}\n\nfunction LegendSwatch({ color }) {\n  return (\n    <svg width={18} height={18}>\n      <rect x={1} y={4} width={16} height={10} rx={2} fill={color} />\n    </svg>\n  );\n}\nfunction LegendMarker({ type }) {\n  return (\n    <svg width={18} height={18}>\n      <EventMarker type={type} cx={9} cy={9} size={7} />\n    </svg>\n  );\n}\nfunction LegendArrow() {\n  return (\n    <svg width={18} height={18}>\n      <path d={arrowPath(2, 9, 7.5)} fill={t.ink} />\n    </svg>\n  );\n}\n\nconst LEGEND_ITEMS = [\n  { key: \"arm-a\", render: <LegendSwatch color={t.palette[0]} />, label: ARM_A },\n  { key: \"arm-b\", render: <LegendSwatch color={t.palette[1]} />, label: ARM_B },\n  { key: \"pr\", render: <LegendMarker type=\"partial_response\" />, label: EVENT_LABEL.partial_response },\n  { key: \"cr\", render: <LegendMarker type=\"complete_response\" />, label: EVENT_LABEL.complete_response },\n  { key: \"pd\", render: <LegendMarker type=\"progressive_disease\" />, label: EVENT_LABEL.progressive_disease },\n  { key: \"ae\", render: <LegendMarker type=\"adverse_event\" />, label: EVENT_LABEL.adverse_event },\n  { key: \"ongoing\", render: <LegendArrow />, label: \"Ongoing (censored)\" },\n];\n\nconst TITLE = \"swimmer-clinical-timeline · javascript · muix · anyplot.ai\";\nconst HEADER_HEIGHT = 160;\n\nexport default function Chart() {\n  const { width: W, height: H } = window.ANYPLOT_SIZE;\n  const chartHeight = H - HEADER_HEIGHT;\n\n  return (\n    <Box sx={{ width: W, height: H, bgcolor: t.pageBg }}>\n      <Box sx={{ height: HEADER_HEIGHT, pt: \"20px\", pl: \"56px\", pr: \"56px\", boxSizing: \"border-box\" }}>\n        <Typography sx={{ color: t.ink, fontSize: 22, fontWeight: 500 }}>{TITLE}</Typography>\n        <Typography sx={{ color: t.inkSoft, fontSize: 14, mt: \"4px\" }}>\n          Phase II oncology trial · 20 patients across two treatment arms, sorted by time on study\n        </Typography>\n        <Box sx={{ display: \"flex\", flexWrap: \"wrap\", gap: \"20px\", mt: \"14px\", alignItems: \"center\" }}>\n          {LEGEND_ITEMS.map((item) => (\n            <Box key={item.key} sx={{ display: \"flex\", alignItems: \"center\", gap: \"6px\" }}>\n              {item.render}\n              <Typography sx={{ color: t.inkSoft, fontSize: 13 }}>{item.label}</Typography>\n            </Box>\n          ))}\n        </Box>\n      </Box>\n\n      <BarChart\n        width={W}\n        height={chartHeight}\n        layout=\"horizontal\"\n        skipAnimation\n        borderRadius={3}\n        series={[\n          {\n            id: \"duration\",\n            label: \"Time on study\",\n            data: durations,\n            valueFormatter: (v) => `${v} wk`,\n          },\n        ]}\n        xAxis={[\n          {\n            scaleType: \"linear\",\n            min: 0,\n            max: 64,\n            label: \"Time on Study (weeks)\",\n            labelStyle: { fontSize: 16, fill: t.ink },\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            disableTicks: true,\n            tickNumber: 5,\n          },\n        ]}\n        yAxis={[\n          {\n            scaleType: \"band\",\n            data: ids,\n            colorMap: { type: \"ordinal\", values: ids, colors: barColors },\n            tickLabelStyle: { fontSize: 13, fill: t.inkSoft },\n            disableTicks: true,\n            categoryGapRatio: 0.32,\n          },\n        ]}\n        grid={{ vertical: true }}\n        margin={{ top: 30, right: 70, bottom: 66, left: 92 }}\n        slotProps={{ legend: { hidden: true } }}\n        sx={{\n          \"& .MuiChartsAxis-line\": { stroke: t.inkSoft },\n          \"& .MuiChartsGrid-line\": { stroke: t.grid, opacity: 0.7 },\n        }}\n      >\n        <SwimmerOverlay />\n      </BarChart>\n    </Box>\n  );\n}\n"}