{"spec_id":"survival-kaplan-meier","library":"echarts","language":"javascript","code":"// anyplot.ai\n// survival-kaplan-meier: Kaplan-Meier Survival Plot\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 95/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Simulated two-arm oncology trial: overall survival, months since enrollment.\n// Both arms share an administrative follow-up cutoff plus independent random\n// loss-to-follow-up, so each arm carries its own realistic censoring pattern.\nconst lcgFactory = (seed) => {\n  let state = seed;\n  return () => {\n    state = (state * 1103515245 + 12345) % 2147483648;\n    return state / 2147483648;\n  };\n};\nconst rand = lcgFactory(42);\nconst exponential = (rate) => -Math.log(1 - rand()) / rate;\n\nconst FOLLOW_UP_CUTOFF = 36;\nconst DROPOUT_RATE = 1 / 130;\n\nconst generateArm = (n, medianMonths) => {\n  const eventRate = Math.log(2) / medianMonths;\n  const observations = [];\n  for (let i = 0; i < n; i++) {\n    const trueTime = exponential(eventRate);\n    const dropoutTime = exponential(DROPOUT_RATE);\n    const time = Math.min(trueTime, dropoutTime, FOLLOW_UP_CUTOFF);\n    const event = trueTime <= dropoutTime && trueTime <= FOLLOW_UP_CUTOFF ? 1 : 0;\n    observations.push({ time: Math.round(time * 10) / 10, event });\n  }\n  return observations;\n};\n\nconst newTherapy = generateArm(75, 22);\nconst standardTherapy = generateArm(75, 13);\n\n// --- Kaplan-Meier estimator --------------------------------------------------\n// Greenwood's formula (log-log transform) gives the 95% CI; the transform\n// keeps bounds inside [0, 1] without manual clipping.\nconst kaplanMeier = (observations) => {\n  const sorted = [...observations].sort((a, b) => a.time - b.time);\n  const eventTimes = [...new Set(sorted.filter((o) => o.event === 1).map((o) => o.time))].sort((a, b) => a - b);\n  let survival = 1;\n  let greenwoodSum = 0;\n  const steps = [{ time: 0, survival: 1, lower: 1, upper: 1 }];\n  for (const time of eventTimes) {\n    const atRisk = sorted.filter((o) => o.time >= time).length;\n    const deaths = sorted.filter((o) => o.time === time && o.event === 1).length;\n    survival *= 1 - deaths / atRisk;\n    greenwoodSum += deaths / (atRisk * (atRisk - deaths || 1));\n    let lower = survival;\n    let upper = survival;\n    if (survival > 0 && survival < 1 && greenwoodSum > 0) {\n      const logLogVar = greenwoodSum / Math.log(survival) ** 2;\n      const z = 1.96 * Math.sqrt(logLogVar);\n      lower = survival ** Math.exp(z);\n      upper = survival ** Math.exp(-z);\n    }\n    steps.push({ time, survival, lower, upper });\n  }\n  const censorTimes = sorted.filter((o) => o.event === 0).map((o) => o.time);\n  return { steps, censorTimes, n: sorted.length };\n};\n\nconst survivalAt = (steps, time) => {\n  let value = 1;\n  for (const step of steps) {\n    if (step.time <= time) value = step.survival;\n    else break;\n  }\n  return value;\n};\n\nconst medianSurvival = (steps) => {\n  const hit = steps.find((s) => s.survival <= 0.5);\n  return hit ? hit.time : null;\n};\n\nconst atRiskCounts = (observations, times) => times.map((time) => observations.filter((o) => o.time >= time).length);\n\nconst kmNew = kaplanMeier(newTherapy);\nconst kmStandard = kaplanMeier(standardTherapy);\nconst medianNew = medianSurvival(kmNew.steps);\nconst medianStandard = medianSurvival(kmStandard.steps);\n\n// --- Log-rank test (chi-square, 1 df) ----------------------------------------\nconst logRankPValue = (armA, armB) => {\n  const eventTimes = [...new Set([...armA, ...armB].filter((o) => o.event === 1).map((o) => o.time))].sort(\n    (a, b) => a - b\n  );\n  let observedA = 0;\n  let expectedA = 0;\n  let variance = 0;\n  for (const time of eventTimes) {\n    const atRiskA = armA.filter((o) => o.time >= time).length;\n    const atRiskB = armB.filter((o) => o.time >= time).length;\n    const n = atRiskA + atRiskB;\n    if (n <= 1) continue;\n    const deathsA = armA.filter((o) => o.time === time && o.event === 1).length;\n    const deathsB = armB.filter((o) => o.time === time && o.event === 1).length;\n    const deaths = deathsA + deathsB;\n    observedA += deathsA;\n    expectedA += (deaths * atRiskA) / n;\n    variance += deaths * (atRiskA / n) * (atRiskB / n) * ((n - deaths) / (n - 1));\n  }\n  const chiSquare = variance > 0 ? (observedA - expectedA) ** 2 / variance : 0;\n  // Abramowitz & Stegun 7.1.26 erf approximation; chi-square(1 df) = z^2, so\n  // p = erfc(sqrt(chiSquare / 2)).\n  const erf = (x) => {\n    const sign = x < 0 ? -1 : 1;\n    const ax = Math.abs(x);\n    const a1 = 0.254829592;\n    const a2 = -0.284496736;\n    const a3 = 1.421413741;\n    const a4 = -1.453152027;\n    const a5 = 1.061405429;\n    const p = 0.3275911;\n    const u = 1 / (1 + p * ax);\n    const y = 1 - (((((a5 * u + a4) * u + a3) * u + a2) * u + a1) * u) * Math.exp(-ax * ax);\n    return sign * y;\n  };\n  return 1 - erf(Math.sqrt(chiSquare / 2));\n};\n\nconst pValue = logRankPValue(newTherapy, standardTherapy);\n\n// --- Series construction ------------------------------------------------------\nconst AXIS_MAX = Math.ceil(Math.max(...kmNew.steps.map((s) => s.time), ...kmStandard.steps.map((s) => s.time)) / 5) * 5;\n\nconst extendToAxisMax = (steps) => {\n  const last = steps[steps.length - 1];\n  return last.time < AXIS_MAX ? [...steps, { ...last, time: AXIS_MAX }] : steps;\n};\n\nconst colorNew = t.palette[0];\nconst colorStandard = t.palette[1];\n\nconst buildArmSeries = (label, km, color, medianTime) => {\n  const extended = extendToAxisMax(km.steps);\n  const curveData = extended.map((s) => [s.time, s.survival]);\n  const lowerData = extended.map((s) => [s.time, s.lower]);\n  const widthData = extended.map((s) => [s.time, s.upper - s.lower]);\n  const censorData = km.censorTimes.map((time) => [time, survivalAt(km.steps, time)]);\n  const stackKey = `ci-${label}`;\n  return [\n    {\n      type: \"line\",\n      stack: stackKey,\n      step: \"end\",\n      data: lowerData,\n      lineStyle: { opacity: 0.3, width: 1, color },\n      symbol: \"none\",\n      silent: true,\n      tooltip: { show: false },\n      z: 2,\n    },\n    {\n      type: \"line\",\n      stack: stackKey,\n      step: \"end\",\n      data: widthData,\n      lineStyle: { opacity: 0.3, width: 1, color },\n      symbol: \"none\",\n      areaStyle: { color, opacity: 0.12 },\n      silent: true,\n      tooltip: { show: false },\n      z: 2,\n    },\n    {\n      name: `${label} (n=${km.n})`,\n      type: \"line\",\n      step: \"end\",\n      data: curveData,\n      symbol: \"none\",\n      lineStyle: { width: 3, color },\n      itemStyle: { color },\n      endLabel: { show: true, formatter: label, color, fontSize: 13, fontWeight: 600, distance: 10 },\n      clip: false,\n      z: 3,\n      ...(medianTime == null\n        ? {}\n        : {\n            markPoint: {\n              symbol: \"diamond\",\n              symbolSize: 12,\n              itemStyle: { color, borderColor: t.pageBg, borderWidth: 1.5 },\n              label: { show: false },\n              silent: true,\n              tooltip: { show: false },\n              data: [{ coord: [medianTime, 0.5], name: \"median\" }],\n            },\n          }),\n    },\n    {\n      name: `${label} censored`,\n      type: \"scatter\",\n      data: censorData,\n      symbol: \"rect\",\n      symbolSize: [3, 14],\n      itemStyle: { color },\n      silent: true,\n      tooltip: { show: false },\n      z: 4,\n    },\n  ];\n};\n\nconst medianDropLine = (medianTime, color) =>\n  medianTime == null\n    ? []\n    : [\n        {\n          type: \"line\",\n          data: [\n            [medianTime, 0],\n            [medianTime, 0.5],\n          ],\n          lineStyle: { color, type: \"dashed\", width: 1.5, opacity: 0.6 },\n          symbol: \"none\",\n          silent: true,\n          tooltip: { show: false },\n          z: 1,\n        },\n      ];\n\n// --- At-risk table (below the plot, own grid sharing the time scale) --------\nconst AT_RISK_STEP = 6;\nconst AT_RISK_TIMES = [];\nfor (let time = 0; time <= AXIS_MAX; time += AT_RISK_STEP) AT_RISK_TIMES.push(time);\nconst atRiskNewCounts = atRiskCounts(newTherapy, AT_RISK_TIMES);\nconst atRiskStandardCounts = atRiskCounts(standardTherapy, AT_RISK_TIMES);\n\nconst atRiskRowSeries = (rowIndex, counts, color) => ({\n  type: \"scatter\",\n  xAxisIndex: 1,\n  yAxisIndex: 1,\n  data: AT_RISK_TIMES.map((time, i) => [time, rowIndex, counts[i]]),\n  encode: { x: 0, y: 1, label: 2 },\n  symbolSize: 0,\n  label: { show: true, formatter: (p) => p.value[2], color, fontSize: 14, fontWeight: 600 },\n  silent: true,\n  tooltip: { show: false },\n});\n\nconst series = [\n  ...buildArmSeries(\"New Therapy\", kmNew, colorNew, medianNew),\n  ...buildArmSeries(\"Standard Therapy\", kmStandard, colorStandard, medianStandard),\n  ...medianDropLine(medianNew, colorNew),\n  ...medianDropLine(medianStandard, colorStandard),\n  atRiskRowSeries(0, atRiskNewCounts, colorNew),\n  atRiskRowSeries(1, atRiskStandardCounts, colorStandard),\n];\n\n// 50%-survival reference line, attached to the first real curve series.\nseries[2].markLine = {\n  silent: true,\n  symbol: \"none\",\n  lineStyle: { type: \"dashed\", color: t.inkSoft, opacity: 0.5, width: 1 },\n  label: { show: false },\n  data: [{ yAxis: 0.5 }],\n};\n\n// --- Title sizing (scales down once the string runs past the 78-char baseline) ---\nconst TITLE = \"Overall Survival by Treatment Arm · survival-kaplan-meier · javascript · echarts · anyplot.ai\";\nconst titleFontSize = Math.max(18, Math.round(28 * Math.min(1, 78 / TITLE.length)));\nconst medianLabel = (m) => (m == null ? \"not reached\" : `${m.toFixed(1)} mo`);\nconst pLabel = pValue < 0.0001 ? \"< 0.0001\" : pValue.toFixed(4);\nconst SUBTITLE = `Kaplan-Meier estimate with 95% CI · median OS ${medianLabel(medianNew)} vs ${medianLabel(medianStandard)} · log-rank p = ${pLabel}`;\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option ---------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  title: {\n    text: TITLE,\n    subtext: SUBTITLE,\n    left: \"center\",\n    top: 26,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n    subtextStyle: { color: t.inkSoft, fontSize: 15 },\n  },\n  tooltip: { trigger: \"axis\" },\n  legend: {\n    data: [`New Therapy (n=${kmNew.n})`, `Standard Therapy (n=${kmStandard.n})`],\n    top: 126,\n    textStyle: { color: t.ink, fontSize: 16 },\n  },\n  graphic: [\n    {\n      type: \"text\",\n      left: 140,\n      top: 674,\n      style: { text: \"No. at Risk\", fill: t.inkSoft, fontSize: 13, fontWeight: 600 },\n    },\n  ],\n  grid: [\n    { left: 140, right: 150, top: 186, height: 470 },\n    { left: 140, right: 150, top: 700, height: 90 },\n  ],\n  xAxis: [\n    {\n      type: \"value\",\n      gridIndex: 0,\n      min: 0,\n      max: AXIS_MAX,\n      axisLabel: { show: false },\n      axisLine: { lineStyle: { color: t.inkSoft } },\n      axisTick: { show: false },\n      splitLine: { show: false },\n    },\n    {\n      type: \"value\",\n      gridIndex: 1,\n      name: \"Time (months)\",\n      nameLocation: \"middle\",\n      nameGap: 34,\n      nameTextStyle: { color: t.ink, fontSize: 16 },\n      min: 0,\n      max: AXIS_MAX,\n      interval: AT_RISK_STEP,\n      axisLabel: { color: t.inkSoft, fontSize: 13 },\n      axisLine: { lineStyle: { color: t.inkSoft } },\n      axisTick: { show: false },\n      splitLine: { show: false },\n    },\n  ],\n  yAxis: [\n    {\n      type: \"value\",\n      gridIndex: 0,\n      name: \"Survival Probability\",\n      nameLocation: \"middle\",\n      nameGap: 70,\n      nameTextStyle: { color: t.ink, fontSize: 16 },\n      min: 0,\n      max: 1,\n      axisLabel: { color: t.inkSoft, fontSize: 14, formatter: (value) => `${Math.round(value * 100)}%` },\n      axisLine: { show: false },\n      axisTick: { show: false },\n      splitLine: { lineStyle: { color: t.grid } },\n    },\n    {\n      type: \"category\",\n      gridIndex: 1,\n      data: [\"New Therapy\", \"Standard Therapy\"],\n      inverse: true,\n      axisLabel: {\n        color: (value, index) => (index === 0 ? colorNew : colorStandard),\n        fontSize: 13,\n        fontWeight: 500,\n        margin: 14,\n      },\n      axisLine: { show: false },\n      axisTick: { show: false },\n      splitLine: { show: false },\n    },\n  ],\n  series,\n});\n"}