{"spec_id":"survival-kaplan-meier","library":"muix","language":"javascript","code":"// anyplot.ai\n// survival-kaplan-meier: Kaplan-Meier Survival Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-09-09\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// --- Data (in-memory, deterministic) ----------------------------------------\n// Reliability engineering: time-to-failure of a machine bearing, comparing a\n// standard design against a reinforced design under the same test protocol.\nconst GROUP_SIZE = 50;\nconst OBSERVATION_WINDOW = 36; // months — study ends here (administrative censoring)\n\n// Small fixed-seed LCG — the browser has no seeded RNG.\nfunction createRng(seed: number) {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = createRng(424242);\n\nfunction sampleExponential(rate: number) {\n  return -Math.log(1 - rng()) / rate;\n}\n\n// Simulates a cohort under two competing risks: mechanical failure (the event\n// of interest) and early dropout (e.g. unit pulled from service for an\n// unrelated reason). Anyone still running at OBSERVATION_WINDOW is censored.\nfunction generateGroup(failureRatePerMonth: number, dropoutRatePerMonth: number) {\n  const observations = [];\n  for (let i = 0; i < GROUP_SIZE; i += 1) {\n    const failureTime = sampleExponential(failureRatePerMonth);\n    const dropoutTime = sampleExponential(dropoutRatePerMonth);\n    const time = Math.min(failureTime, dropoutTime, OBSERVATION_WINDOW);\n    const event = failureTime <= dropoutTime && failureTime <= OBSERVATION_WINDOW ? 1 : 0;\n    observations.push({ time, event });\n  }\n  return observations;\n}\n\nconst standardObservations = generateGroup(0.045, 0.01);\nconst reinforcedObservations = generateGroup(0.022, 0.01);\n\n// Kaplan-Meier estimator with Greenwood's formula for the 95% CI. Returns the\n// step points: survival holds at each value from its own time onward.\nfunction kaplanMeier(observations: { time: number; event: number }[]) {\n  const sorted = [...observations].sort((a, b) => a.time - b.time);\n  const eventTimes = Array.from(new Set(sorted.map((o) => o.time))).sort((a, b) => a - b);\n\n  let atRisk = sorted.length;\n  let survival = 1;\n  let varianceSum = 0;\n  const points = [{ time: 0, survival: 1, lower: 1, upper: 1, censored: false }];\n\n  eventTimes.forEach((time) => {\n    const atThisTime = sorted.filter((o) => o.time === time);\n    const deaths = atThisTime.filter((o) => o.event === 1).length;\n\n    if (deaths > 0) {\n      survival *= 1 - deaths / atRisk;\n      const denom = atRisk * (atRisk - deaths);\n      if (denom > 0) varianceSum += deaths / denom;\n    }\n\n    const standardError = survival * Math.sqrt(varianceSum);\n    points.push({\n      time,\n      survival,\n      lower: Math.max(0, survival - 1.96 * standardError),\n      upper: Math.min(1, survival + 1.96 * standardError),\n      censored: deaths === 0,\n    });\n\n    atRisk -= atThisTime.length;\n  });\n\n  return points;\n}\n\nconst standardCurve = kaplanMeier(standardObservations);\nconst reinforcedCurve = kaplanMeier(reinforcedObservations);\n\n// Both curves are re-sampled onto one shared, sorted time grid (forward-fill,\n// matching step-function semantics) so they can share a single x-axis.\nconst timeGrid = Array.from(\n  new Set([...standardCurve, ...reinforcedCurve].map((p) => p.time)),\n).sort((a, b) => a - b);\n\nfunction alignToGrid(points: typeof standardCurve, grid: number[]) {\n  let cursor = 0;\n  return grid.map((time) => {\n    while (cursor + 1 < points.length && points[cursor + 1].time <= time) cursor += 1;\n    const current = points[cursor];\n    return {\n      time,\n      survival: current.survival,\n      lower: current.lower,\n      upper: current.upper,\n      censored: current.censored && current.time === time,\n    };\n  });\n}\n\nconst standardAligned = alignToGrid(standardCurve, timeGrid);\nconst reinforcedAligned = alignToGrid(reinforcedCurve, timeGrid);\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 is knocked out via the `& .MuiAreaElement-series-{id}` sx rules below\n// (MUI X always applies its own opaque \"brighter\" tint to an area fill, so a\n// transparent/rgba `color` on the series itself is not enough to hide it).\nfunction buildGroupSeries(key: string, label: string, color: string, aligned: typeof standardAligned) {\n  return [\n    {\n      id: `${key}-ci-base`,\n      data: aligned.map((p) => p.lower),\n      stack: `ci-${key}`,\n      area: true,\n      curve: \"stepAfter\" as const,\n      color,\n      showMark: false,\n      disableHighlight: true,\n    },\n    {\n      id: `${key}-ci-band`,\n      data: aligned.map((p) => Math.max(0, p.upper - p.lower)),\n      stack: `ci-${key}`,\n      area: true,\n      curve: \"stepAfter\" as const,\n      color,\n      showMark: false,\n      disableHighlight: true,\n    },\n    {\n      id: `${key}-survival`,\n      data: aligned.map((p) => p.survival),\n      curve: \"stepAfter\" as const,\n      color,\n      label,\n      showMark: ({ index }: { index: number }) => aligned[index].censored,\n    },\n  ];\n}\n\nconst series = [\n  ...buildGroupSeries(\"standard\", `Standard design (n=${GROUP_SIZE})`, t.palette[0], standardAligned),\n  ...buildGroupSeries(\"reinforced\", `Reinforced design (n=${GROUP_SIZE})`, t.palette[1], reinforcedAligned),\n];\n\nconst TITLE = \"survival-kaplan-meier · javascript · muix · anyplot.ai\";\n\n// --- Chart (default-exported component — the harness mounts it) -------------\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}\n        series={series}\n        xAxis={[\n          {\n            data: timeGrid,\n            scaleType: \"linear\",\n            min: 0,\n            max: OBSERVATION_WINDOW,\n            label: \"Time in service (months)\",\n            labelStyle: { fontSize: 15 },\n            tickLabelStyle: { fontSize: 13 },\n          },\n        ]}\n        yAxis={[\n          {\n            min: 0,\n            max: 1,\n            label: \"Survival probability\",\n            labelStyle: { fontSize: 15 },\n            tickLabelStyle: { fontSize: 13 },\n            // The axis-title offset is driven by `tickFontSize` (not\n            // `tickLabelStyle.fontSize`) in MUI X's layout formula, so bump\n            // this well past the rendered \"100%\" tick-label width to clear\n            // the rotated title from the tick text without enlarging the\n            // ticks themselves.\n            tickFontSize: 42,\n            valueFormatter: (v: number) => `${Math.round(v * 100)}%`,\n          },\n        ]}\n        grid={{ horizontal: true }}\n        margin={{ top: 8, right: 24, bottom: 56, left: 112 }}\n        skipAnimation\n        slotProps={{\n          legend: {\n            position: { vertical: \"top\", horizontal: \"right\" },\n            labelStyle: { fontSize: 14 },\n          },\n        }}\n        sx={{\n          \"& .MuiLineElement-root\": { strokeWidth: 3 },\n          \"& .MuiMarkElement-root\": { strokeWidth: 3 },\n          \"& .MuiAreaElement-series-standard-ci-base\": { fill: \"none\" },\n          \"& .MuiAreaElement-series-reinforced-ci-base\": { fill: \"none\" },\n          \"& .MuiAreaElement-series-standard-ci-band\": { fillOpacity: 0.16 },\n          \"& .MuiAreaElement-series-reinforced-ci-band\": { fillOpacity: 0.16 },\n        }}\n      />\n    </Box>\n  );\n}\n"}