{"spec_id":"survival-kaplan-meier","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// survival-kaplan-meier: Kaplan-Meier Survival Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: two-arm clinical trial, 36-month follow-up ----------------------\n// Tiny fixed-seed LCG — the browser has no seeded RNG.\nconst HORIZON = 36;\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\n\nfunction generateArm(hazardRate, n) {\n  const times = [];\n  const events = [];\n  for (let i = 0; i < n; i++) {\n    const trueEventTime = -Math.log(1 - rand()) / hazardRate;\n    const dropoutTime = 5 + rand() * 45;\n    const observed = Math.min(trueEventTime, dropoutTime, HORIZON);\n    const event = trueEventTime <= dropoutTime && trueEventTime <= HORIZON ? 1 : 0;\n    times.push(Math.round(observed * 10) / 10);\n    events.push(event);\n  }\n  return { times, events };\n}\n\nconst standardCare = generateArm(0.0385, 50); // median ~18 months\nconst newTherapy = generateArm(0.0231, 50); // median ~30 months\n\n// --- Kaplan-Meier estimator with Greenwood confidence intervals ------------\nfunction kaplanMeier(times, events, horizon) {\n  const n = times.length;\n  const paired = times.map((time, i) => ({ time, event: events[i] })).sort((a, b) => a.time - b.time);\n  const uniqueTimes = [...new Set(paired.map((p) => p.time))].sort((a, b) => a - b);\n\n  let atRisk = n;\n  let survival = 1;\n  let cumVarTerm = 0;\n\n  const stepTimes = [0];\n  const stepSurvival = [1];\n  const ciLower = [1];\n  const ciUpper = [1];\n  const censorPoints = [];\n\n  for (const time of uniqueTimes) {\n    const atT = paired.filter((p) => p.time === time);\n    const deaths = atT.filter((p) => p.event === 1).length;\n    const censored = atT.filter((p) => p.event === 0).length;\n\n    if (deaths > 0) {\n      survival *= 1 - deaths / atRisk;\n      cumVarTerm += deaths / (atRisk * (atRisk - deaths));\n      const se = survival * Math.sqrt(cumVarTerm);\n      stepTimes.push(time);\n      stepSurvival.push(survival);\n      ciLower.push(Math.max(0, survival - 1.96 * se));\n      ciUpper.push(Math.min(1, survival + 1.96 * se));\n    }\n    if (censored > 0) censorPoints.push({ time, survival });\n    atRisk -= atT.length;\n  }\n\n  if (stepTimes[stepTimes.length - 1] < horizon) {\n    stepTimes.push(horizon);\n    stepSurvival.push(survival);\n    ciLower.push(ciLower[ciLower.length - 1]);\n    ciUpper.push(ciUpper[ciUpper.length - 1]);\n  }\n\n  return { stepTimes, stepSurvival, ciLower, ciUpper, censorPoints };\n}\n\n// --- Log-rank test (Mantel-Haenszel), reported in the subtitle -------------\nfunction erf(x) {\n  const sign = x < 0 ? -1 : 1;\n  const ax = Math.abs(x);\n  const a1 = 0.254829592,\n    a2 = -0.284496736,\n    a3 = 1.421413741,\n    a4 = -1.453152027,\n    a5 = 1.061405429,\n    p = 0.3275911;\n  const s = 1 / (1 + p * ax);\n  const y = 1 - (((((a5 * s + a4) * s + a3) * s + a2) * s + a1) * s) * Math.exp(-ax * ax);\n  return sign * y;\n}\n\nfunction logRankTest(timesA, eventsA, timesB, eventsB) {\n  const records = timesA\n    .map((time, i) => ({ time, event: eventsA[i], arm: 0 }))\n    .concat(timesB.map((time, i) => ({ time, event: eventsB[i], arm: 1 })));\n  const uniqueTimes = [...new Set(records.map((r) => r.time))].sort((a, b) => a - b);\n\n  let nA = timesA.length;\n  let nB = timesB.length;\n  let observedA = 0;\n  let expectedA = 0;\n  let variance = 0;\n\n  for (const time of uniqueTimes) {\n    const atT = records.filter((r) => r.time === time);\n    const dA = atT.filter((r) => r.arm === 0 && r.event === 1).length;\n    const dB = atT.filter((r) => r.arm === 1 && r.event === 1).length;\n    const d = dA + dB;\n    const nTotal = nA + nB;\n    if (d > 0 && nTotal > 1) {\n      observedA += dA;\n      expectedA += (d * nA) / nTotal;\n      variance += (d * (nA / nTotal) * (nB / nTotal) * (nTotal - d)) / (nTotal - 1);\n    }\n    nA -= atT.filter((r) => r.arm === 0).length;\n    nB -= atT.filter((r) => r.arm === 1).length;\n  }\n\n  const z = (observedA - expectedA) / Math.sqrt(variance);\n  return 1 - erf(Math.abs(z) / Math.SQRT2);\n}\n\nconst controlKM = kaplanMeier(standardCare.times, standardCare.events, HORIZON);\nconst treatmentKM = kaplanMeier(newTherapy.times, newTherapy.events, HORIZON);\nconst pValue = logRankTest(standardCare.times, standardCare.events, newTherapy.times, newTherapy.events);\nconst pLabel = pValue < 0.001 ? \"p < 0.001\" : `p = ${pValue.toFixed(3)}`;\n\n// --- Datasets ----------------------------------------------------------\n// Each arm contributes 4 datasets: a hidden CI floor, a filled CI ceiling\n// (shaded band between the two), the visible step curve, and a tick-mark\n// layer at censoring times. Only the step-curve datasets carry a label so\n// the legend shows just the two arms, not the CI/censor plumbing.\nfunction armDatasets(km, colorHex, label) {\n  const bandFill = `${colorHex}33`; // ~20% opacity\n  const stepPoints = km.stepTimes.map((time, i) => ({ x: time, y: km.stepSurvival[i] }));\n  const lowerPoints = km.stepTimes.map((time, i) => ({ x: time, y: km.ciLower[i] }));\n  const upperPoints = km.stepTimes.map((time, i) => ({ x: time, y: km.ciUpper[i] }));\n  const censorPoints = km.censorPoints.map((c) => ({ x: c.time, y: c.survival }));\n\n  return [\n    {\n      label: \"\",\n      data: lowerPoints,\n      stepped: true,\n      borderWidth: 0,\n      pointRadius: 0,\n      fill: false,\n    },\n    {\n      label: \"\",\n      data: upperPoints,\n      stepped: true,\n      borderWidth: 0,\n      pointRadius: 0,\n      backgroundColor: bandFill,\n      fill: \"-1\",\n    },\n    {\n      label,\n      data: stepPoints,\n      stepped: true,\n      borderColor: colorHex,\n      backgroundColor: colorHex,\n      borderWidth: 3.5,\n      pointRadius: 0,\n      fill: false,\n    },\n    {\n      label: \"\",\n      data: censorPoints,\n      showLine: false,\n      pointStyle: \"line\",\n      pointRotation: 90,\n      pointRadius: 9,\n      pointBorderWidth: 2,\n      borderColor: colorHex,\n      backgroundColor: colorHex,\n    },\n  ];\n}\n\nconst datasets = [\n  ...armDatasets(controlKM, t.palette[0], \"Standard Care\"),\n  ...armDatasets(treatmentKM, t.palette[1], \"New Therapy\"),\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  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"survival-kaplan-meier · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      subtitle: {\n        display: true,\n        text: `Log-rank test: ${pLabel} · tick marks show censored patients`,\n        color: t.inkSoft,\n        font: { size: 15 },\n        padding: { bottom: 12 },\n      },\n      legend: {\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          filter: (item, data) => data.datasets[item.datasetIndex].label !== \"\",\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: 0,\n        max: HORIZON,\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 6 },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Time Since Enrollment (Months)\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        min: 0,\n        max: 1.02,\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          stepSize: 0.2,\n          callback: (value) => `${Math.round(value * 100)}%`,\n        },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Survival Probability\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n});\n"}