{"spec_id":"survival-kaplan-meier","library":"muix","language":"javascript","code":"// anyplot.ai\n// survival-kaplan-meier: Kaplan-Meier Survival Plot\n// Library: muix 9.16.0 | JavaScript 22.23.3\n// Quality: 87/100 | Updated: 2026-10-23\nimport { LineChart } from \"@mui/x-charts/LineChart\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Constants\nconst Z_95 = 1.96; // normal quantile for the 95% confidence interval\n\n// Columns: set these to your data's column names\nconst TIME = \"Time in service (months)\"; // role: time\nconst EVENT = \"Failure\"; // role: event\nconst GROUP = \"Bearing design\"; // role: group\n\n// Data: replace this block with your own records, one object per observation\n// Reliability engineering: time-to-failure of a machine bearing, standard vs\n// reinforced design. Each unit either fails, is pulled from service early for\n// an unrelated reason, or is still running when the 36-month study ends.\nlet seed = 424242;\nconst rng = () => {\n  seed = (seed * 1664525 + 1013904223) >>> 0; // fixed-seed LCG — the browser has no seeded RNG\n  return seed / 4294967296;\n};\nconst data: Record<string, string | number>[] = [];\n[\n  { design: \"Standard design\", failureRate: 0.045 },\n  { design: \"Reinforced design\", failureRate: 0.022 },\n].forEach(({ design, failureRate }) => {\n  for (let i = 0; i < 50; i += 1) {\n    const failureTime = -Math.log(1 - rng()) / failureRate;\n    const dropoutTime = -Math.log(1 - rng()) / 0.01;\n    const time = Math.min(failureTime, dropoutTime, 36);\n    const failed = failureTime <= dropoutTime && failureTime <= 36;\n    data.push({ [TIME]: time, [EVENT]: failed ? 1 : 0, [GROUP]: design });\n  }\n});\n\n// Plot: reads only data and the constants above\nconst groups = [...new Set(data.map((d) => d[GROUP] as string))];\nconst timeGrid = [...new Set([0, ...data.map((d) => d[TIME] as number)])].sort((a, b) => a - b);\nconst maxTime = timeGrid[timeGrid.length - 1];\n\n// Kaplan-Meier product-limit estimate with Greenwood's 95% CI, evaluated on the\n// shared time grid. Step-function semantics: a value holds from its time onward.\nconst curves = groups.map((group) => {\n  const rows = data.filter((d) => d[GROUP] === group);\n  let atRisk = rows.length;\n  let survival = 1;\n  let varianceSum = 0;\n  const points = timeGrid.map((time) => {\n    const atThisTime = rows.filter((d) => d[TIME] === time);\n    const deaths = atThisTime.filter((d) => d[EVENT] === 1).length;\n    if (deaths > 0) {\n      survival *= 1 - deaths / atRisk;\n      if (atRisk > deaths) varianceSum += deaths / (atRisk * (atRisk - deaths));\n    }\n    atRisk -= atThisTime.length;\n    const standardError = survival * Math.sqrt(varianceSum);\n    return {\n      survival,\n      lower: Math.max(0, survival - Z_95 * standardError),\n      upper: Math.min(1, survival + Z_95 * standardError),\n      censored: atThisTime.length > deaths,\n    };\n  });\n  return { group, n: rows.length, points };\n});\n\n// Each group contributes 3 series: an invisible base (stacked to the CI lower\n// bound) + a filled delta on top of it (renders as the CI band from lower to\n// upper), then the visible KM step line. Only the step line gets a `label`,\n// so the band helpers are automatically excluded from the legend. The base's\n// fill and both helpers' outlines are knocked out via the\n// `.MuiLineChart-area/line[data-series=…]` sx rules below (MUI X always\n// paints an area fill opaque, so a transparent `color` is not enough).\nconst series = curves.flatMap(({ group, n, points }, i) => [\n  {\n    id: `ci-base-${i}`,\n    data: points.map((p) => p.lower),\n    stack: `ci-${i}`,\n    area: true,\n    curve: \"stepAfter\" as const,\n    color: t.palette[i],\n    showMark: false,\n    disableHighlight: true,\n  },\n  {\n    id: `ci-band-${i}`,\n    data: points.map((p) => Math.max(0, p.upper - p.lower)),\n    stack: `ci-${i}`,\n    area: true,\n    curve: \"stepAfter\" as const,\n    color: t.palette[i],\n    showMark: false,\n    disableHighlight: true,\n  },\n  {\n    id: `survival-${i}`,\n    data: points.map((p) => p.survival),\n    curve: \"stepAfter\" as const,\n    color: t.palette[i],\n    label: `${group} (n=${n})`,\n    shape: \"circle\" as const,\n    showMark: ({ index }: { index: number }) => points[index].censored,\n  },\n]);\n\nconst bandStyles = Object.fromEntries(\n  curves.flatMap((_, i) => [\n    [`& .MuiLineChart-area[data-series=\"ci-base-${i}\"]`, { fill: \"none\" }],\n    [`& .MuiLineChart-area[data-series=\"ci-band-${i}\"]`, { fill: t.palette[i], fillOpacity: 0.16 }],\n    [`& .MuiLineChart-line[data-series=\"ci-base-${i}\"]`, { display: \"none\" }],\n    [`& .MuiLineChart-line[data-series=\"ci-band-${i}\"]`, { display: \"none\" }],\n  ]),\n);\n\nconst TITLE = \"survival-kaplan-meier · javascript · muix · anyplot.ai\";\n\nexport default function Chart() {\n  const size = window.ANYPLOT_SIZE;\n  const paddingX = 40;\n  const paddingY = 28;\n  const headerHeight = 76;\n\n  return (\n    <Box\n      sx={{\n        width: size.width,\n        height: size.height,\n        boxSizing: \"border-box\",\n        padding: `${paddingY}px ${paddingX}px`,\n        display: \"flex\",\n        flexDirection: \"column\",\n      }}\n    >\n      <Box sx={{ height: headerHeight, flexShrink: 0 }}>\n        <Typography sx={{ fontSize: 22, fontWeight: 600, color: \"text.primary\", lineHeight: 1.3 }}>\n          {TITLE}\n        </Typography>\n        <Typography sx={{ fontSize: 14, color: \"text.secondary\", mt: \"4px\" }}>\n          Shaded bands are 95% confidence intervals · open circles mark units censored while still in service\n        </Typography>\n      </Box>\n      <LineChart\n        width={size.width - paddingX * 2}\n        height={size.height - paddingY * 2 - headerHeight - 40}\n        series={series}\n        xAxis={[\n          {\n            data: timeGrid,\n            scaleType: \"linear\",\n            min: 0,\n            max: maxTime,\n            label: TIME,\n            labelStyle: { fontSize: 16 },\n            tickLabelStyle: { fontSize: 14 },\n            height: 52,\n          },\n        ]}\n        yAxis={[\n          {\n            min: 0,\n            max: 1,\n            label: \"Survival probability\",\n            labelStyle: { fontSize: 16 },\n            tickLabelStyle: { fontSize: 14 },\n            width: 84,\n            valueFormatter: (v: number) => `${Math.round(v * 100)}%`,\n          },\n        ]}\n        grid={{ horizontal: true }}\n        margin={{ top: 8, right: 24, bottom: 8, left: 16 }}\n        skipAnimation\n        slotProps={{\n          legend: {\n            position: { vertical: \"top\", horizontal: \"end\" },\n            sx: { fontSize: 15 },\n          },\n        }}\n        sx={{\n          \"& .MuiLineChart-line\": { strokeWidth: 3 },\n          \"& .MuiLineChart-mark\": { strokeWidth: 2.5 },\n          ...bandStyles,\n        }}\n      />\n    </Box>\n  );\n}\n"}