{"spec_id":"survival-kaplan-meier","library":"d3","language":"javascript","code":"// anyplot.ai\n// survival-kaplan-meier: Kaplan-Meier Survival Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-09\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 70, bottom: 90, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Simple LCG — the browser has no seeded Math.random.\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\n\nconst FOLLOW_UP_MONTHS = 36;\n\nfunction simulatePatients(n, hazardRate, dropoutProb) {\n  const patients = [];\n  for (let i = 0; i < n; i++) {\n    const eventTime = -Math.log(rand()) / hazardRate;\n    const dropoutTime =\n      rand() < dropoutProb ? rand() * FOLLOW_UP_MONTHS : Infinity;\n    const censorTime = Math.min(dropoutTime, FOLLOW_UP_MONTHS);\n    const time = Math.min(eventTime, censorTime);\n    patients.push({ time, event: eventTime <= censorTime ? 1 : 0 });\n  }\n  return patients;\n}\n\nfunction medianSurvivalTime(steps) {\n  for (const s of steps) if (s.survival <= 0.5) return s.time;\n  return null;\n}\n\nfunction normalCdf(z) {\n  // Abramowitz-Stegun erf approximation\n  const sign = z < 0 ? -1 : 1;\n  const x = Math.abs(z) / Math.SQRT2;\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 tt = 1 / (1 + p * x);\n  const erf =\n    1 -\n    ((((a5 * tt + a4) * tt + a3) * tt + a2) * tt + a1) * tt * Math.exp(-x * x);\n  return 0.5 * (1 + sign * erf);\n}\n\nfunction logRankTest(group1, group2) {\n  const eventTimes = Array.from(\n    new Set(\n      [...group1, ...group2].filter((d) => d.event === 1).map((d) => d.time),\n    ),\n  ).sort((a, b) => a - b);\n\n  let observed1 = 0;\n  let expected1 = 0;\n  let variance = 0;\n  for (const time of eventTimes) {\n    const n1 = group1.filter((d) => d.time >= time).length;\n    const n2 = group2.filter((d) => d.time >= time).length;\n    const d1 = group1.filter((d) => d.time === time && d.event === 1).length;\n    const d2 = group2.filter((d) => d.time === time && d.event === 1).length;\n    const n = n1 + n2;\n    const d = d1 + d2;\n    if (n <= 1) continue;\n    observed1 += d1;\n    expected1 += (d * n1) / n;\n    variance += (d * (n - d) * n1 * n2) / (n * n * (n - 1));\n  }\n  const chiSquare = variance > 0 ? (observed1 - expected1) ** 2 / variance : 0;\n  const pValue = 2 * (1 - normalCdf(Math.sqrt(chiSquare)));\n  return { chiSquare, pValue };\n}\n\nfunction kaplanMeier(patients) {\n  const eventTimes = Array.from(\n    new Set(patients.filter((d) => d.event === 1).map((d) => d.time)),\n  ).sort((a, b) => a - b);\n\n  let survival = 1;\n  let varianceSum = 0;\n  const steps = [{ time: 0, survival: 1, lower: 1, upper: 1 }];\n\n  for (const time of eventTimes) {\n    const atRisk = patients.filter((d) => d.time >= time).length;\n    const deaths = patients.filter(\n      (d) => d.time === time && d.event === 1,\n    ).length;\n    survival *= 1 - deaths / atRisk;\n    if (atRisk > deaths) varianceSum += deaths / (atRisk * (atRisk - deaths));\n    const se = survival * Math.sqrt(varianceSum);\n    steps.push({\n      time,\n      survival,\n      lower: Math.max(0, survival - 1.96 * se),\n      upper: Math.min(1, survival + 1.96 * se),\n    });\n  }\n  steps.push({ ...steps[steps.length - 1], time: FOLLOW_UP_MONTHS });\n\n  const censored = patients\n    .filter((d) => d.event === 0 && d.time > 0)\n    .map((d) => {\n      let level = steps[0];\n      for (const s of steps) if (s.time <= d.time) level = s;\n      return { time: d.time, survival: level.survival };\n    });\n\n  return { steps, censored };\n}\n\nconst groups = [\n  { name: \"Standard Therapy\", n: 95, hazardRate: 0.05, dropoutProb: 0.18 },\n  { name: \"Novel Therapy\", n: 95, hazardRate: 0.027, dropoutProb: 0.18 },\n];\nconst patientSets = groups.map((group) =>\n  simulatePatients(group.n, group.hazardRate, group.dropoutProb),\n);\nconst curves = groups.map((group, i) => ({\n  ...group,\n  ...kaplanMeier(patientSets[i]),\n}));\nconst logRank = logRankTest(patientSets[0], patientSets[1]);\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3\n  .select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\nconst g = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales ---------------------------------------------------------------\nconst x = d3.scaleLinear().domain([0, FOLLOW_UP_MONTHS]).range([0, iw]);\nconst y = d3.scaleLinear().domain([0, 1]).range([ih, 0]);\n\n// --- Gridlines (y-axis only) -----------------------------------------------\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(y.ticks(5))\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d))\n  .attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// --- 95% confidence bands ----------------------------------------------------\nconst band = d3\n  .area()\n  .x((d) => x(d.time))\n  .y0((d) => y(d.lower))\n  .y1((d) => y(d.upper))\n  .curve(d3.curveStepAfter);\n\ncurves.forEach((c, i) => {\n  g.append(\"path\")\n    .datum(c.steps)\n    .attr(\"d\", band)\n    .attr(\"fill\", t.palette[i])\n    .attr(\"fill-opacity\", 0.12)\n    .attr(\"stroke\", t.palette[i])\n    .attr(\"stroke-width\", 1)\n    .attr(\"stroke-opacity\", 0.45);\n});\n\n// --- Survival step curves -----------------------------------------------------\nconst stepLine = d3\n  .line()\n  .x((d) => x(d.time))\n  .y((d) => y(d.survival))\n  .curve(d3.curveStepAfter);\n\ncurves.forEach((c, i) => {\n  g.append(\"path\")\n    .datum(c.steps)\n    .attr(\"d\", stepLine)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.palette[i])\n    .attr(\"stroke-width\", 3.5);\n});\n\n// --- Censoring tick marks -------------------------------------------------\nconst tickHalf = 10;\ncurves.forEach((c, i) => {\n  g.append(\"g\")\n    .selectAll(\"line\")\n    .data(c.censored)\n    .join(\"line\")\n    .attr(\"x1\", (d) => x(d.time))\n    .attr(\"x2\", (d) => x(d.time))\n    .attr(\"y1\", (d) => y(d.survival) - tickHalf)\n    .attr(\"y2\", (d) => y(d.survival) + tickHalf)\n    .attr(\"stroke\", t.palette[i])\n    .attr(\"stroke-width\", 2.75);\n});\n\n// --- Median survival annotations --------------------------------------------\nconst y50 = y(0.5);\ng.append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", y50)\n  .attr(\"y2\", y50)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"6,5\");\n\ncurves.forEach((c, i) => {\n  const medianTime = medianSurvivalTime(c.steps);\n  if (medianTime == null) return;\n  const mx = x(medianTime);\n  g.append(\"line\")\n    .attr(\"x1\", mx)\n    .attr(\"x2\", mx)\n    .attr(\"y1\", y50)\n    .attr(\"y2\", ih)\n    .attr(\"stroke\", t.palette[i])\n    .attr(\"stroke-width\", 1.5)\n    .attr(\"stroke-dasharray\", \"6,5\");\n  g.append(\"text\")\n    .attr(\"x\", mx)\n    .attr(\"y\", y50 - 20)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.palette[i])\n    .style(\"font-size\", \"15px\")\n    .style(\"font-weight\", \"600\")\n    .style(\"paint-order\", \"stroke\")\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 5)\n    .attr(\"stroke-linejoin\", \"round\")\n    .text(`Median: ${medianTime.toFixed(1)}mo`);\n});\n\n// --- Axes -------------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(9).tickSize(0).tickPadding(14));\nconst yAxis = g\n  .append(\"g\")\n  .call(\n    d3\n      .axisLeft(y)\n      .ticks(5)\n      .tickFormat(d3.format(\".0%\"))\n      .tickSize(0)\n      .tickPadding(14),\n  );\n\nfor (const axisG of [xAxis, yAxis]) {\n  axisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\");\n}\nxAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\nyAxis.select(\".domain\").attr(\"stroke\", \"none\");\n\n// --- Axis labels --------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"20px\")\n  .text(\"Time Since Enrollment (months)\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -72)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"20px\")\n  .text(\"Survival Probability\");\n\n// --- Legend -----------------------------------------------------------------\nconst legend = g.append(\"g\").attr(\"transform\", `translate(${iw - 300}, 6)`);\ncurves.forEach((c, i) => {\n  const row = legend.append(\"g\").attr(\"transform\", `translate(0, ${i * 34})`);\n  row\n    .append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", 28)\n    .attr(\"y1\", 0)\n    .attr(\"y2\", 0)\n    .attr(\"stroke\", t.palette[i])\n    .attr(\"stroke-width\", 4);\n  row\n    .append(\"text\")\n    .attr(\"x\", 38)\n    .attr(\"y\", 5)\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"16px\")\n    .text(`${c.name} (n=${c.n})`);\n});\n\nconst pValueText =\n  logRank.pValue < 0.001 ? \"p < 0.001\" : `p = ${logRank.pValue.toFixed(3)}`;\nlegend\n  .append(\"text\")\n  .attr(\"x\", 0)\n  .attr(\"y\", groups.length * 34 + 18)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-style\", \"italic\")\n  .text(`Log-rank test: ${pValueText}`);\n\n// --- Title --------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"24px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"survival-kaplan-meier · javascript · d3 · anyplot.ai\");\n"}