{"spec_id":"survival-kaplan-meier","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// survival-kaplan-meier: Kaplan-Meier Survival Plot\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-09\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Reproducible RNG (fixed-seed LCG — the browser has no seeded Math.random) --\nconst makeLcg = (seed) => {\n  let state = seed >>> 0;\n  return () => {\n    state = (1103515245 * state + 12345) & 0x7fffffff;\n    return state / 0x7fffffff;\n  };\n};\nconst rand = makeLcg(42);\nconst exponential = (rate) => -Math.log(1 - rand()) / rate;\n\n// --- Data: time-to-failure (months in service) for two bearing designs -----\nconst STUDY_END = 60;\nconst DROPOUT_RATE = 0.01;\nconst GROUPS = [\n  { name: \"Standard Bearing\", failureRate: 0.035, n: 70 },\n  { name: \"Reinforced Bearing\", failureRate: 0.018, n: 70 },\n];\n\nconst records = GROUPS.map((group) => {\n  const units = [];\n  for (let i = 0; i < group.n; i++) {\n    const failureTime = exponential(group.failureRate);\n    const dropoutTime = exponential(DROPOUT_RATE);\n    const time = Math.min(failureTime, dropoutTime, STUDY_END);\n    const event = failureTime <= dropoutTime && failureTime <= STUDY_END ? 1 : 0;\n    units.push({ time, event });\n  }\n  return units.sort((a, b) => a.time - b.time);\n});\n\n// --- Kaplan-Meier estimator with Greenwood confidence intervals ------------\nconst kaplanMeier = (units) => {\n  const eventTimes = [...new Set(units.filter((u) => u.event === 1).map((u) => u.time))].sort(\n    (a, b) => a - b\n  );\n  let survival = 1;\n  let greenwoodSum = 0;\n  const steps = [{ time: 0, survival: 1, lower: 1, upper: 1 }];\n  eventTimes.forEach((time) => {\n    const atRisk = units.filter((u) => u.time >= time).length;\n    const deaths = units.filter((u) => u.time === time && u.event === 1).length;\n    survival *= 1 - deaths / atRisk;\n    if (atRisk > deaths) greenwoodSum += deaths / (atRisk * (atRisk - deaths));\n    const se = survival * Math.sqrt(greenwoodSum);\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  const censoredTimes = units.filter((u) => u.event === 0).map((u) => u.time);\n  return { steps, censoredTimes };\n};\n\nconst curves = records.map(kaplanMeier);\n\n// Step-held survival value at an arbitrary time (for placing censoring ticks).\nconst survivalAt = (steps, time) => {\n  let value = 1;\n  for (const step of steps) {\n    if (step.time > time) break;\n    value = step.survival;\n  }\n  return value;\n};\n\n// First time the curve reaches 50% survival, or null if never reached.\nconst medianSurvival = (steps) => {\n  const hit = steps.find((s) => s.survival <= 0.5);\n  return hit ? hit.time : null;\n};\n\n// --- Log-rank test (Mantel-Cox) comparing the two groups --------------------\nconst erf = (x) => {\n  const sign = x < 0 ? -1 : 1;\n  x = 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 tt = 1 / (1 + p * x);\n  const y = 1 - ((((a5 * tt + a4) * tt + a3) * tt + a2) * tt + a1) * tt * Math.exp(-x * x);\n  return sign * y;\n};\n\nconst logRankTest = (unitsA, unitsB) => {\n  const eventTimes = [...new Set([...unitsA, ...unitsB].filter((u) => u.event === 1).map((u) => u.time))].sort(\n    (a, b) => a - b\n  );\n  let observedA = 0;\n  let expectedA = 0;\n  let variance = 0;\n  eventTimes.forEach((time) => {\n    const atRiskA = unitsA.filter((u) => u.time >= time).length;\n    const atRiskB = unitsB.filter((u) => u.time >= time).length;\n    const deathsA = unitsA.filter((u) => u.time === time && u.event === 1).length;\n    const deathsB = unitsB.filter((u) => u.time === time && u.event === 1).length;\n    const atRisk = atRiskA + atRiskB;\n    const deaths = deathsA + deathsB;\n    if (atRisk < 2) return;\n    observedA += deathsA;\n    expectedA += (deaths * atRiskA) / atRisk;\n    variance += (deaths * (atRiskA / atRisk) * (atRiskB / atRisk) * (atRisk - deaths)) / (atRisk - 1);\n  });\n  const chiSquare = variance > 0 ? (observedA - expectedA) ** 2 / variance : 0;\n  const pValue = 1 - erf(Math.sqrt(chiSquare / 2));\n  return { chiSquare, pValue };\n};\n\nconst { chiSquare, pValue } = logRankTest(records[0], records[1]);\n\n// --- Series data: step curve, plus tick markers at censored times ----------\nconst buildLineData = (steps, censoredTimes) => {\n  const points = steps.map((s) => ({ x: s.time, y: s.survival, marker: { enabled: false } }));\n  censoredTimes.forEach((time) => {\n    points.push({ x: time, y: survivalAt(steps, time), marker: { enabled: true } });\n  });\n  points.push({ x: STUDY_END, y: steps[steps.length - 1].survival, marker: { enabled: false } });\n  return points.sort((a, b) => a.x - b.x);\n};\n\n// Custom vertical-tick marker symbol for censored observations (core\n// SVGRenderer API — no add-on module needed).\nHighcharts.SVGRenderer.prototype.symbols.tick = (x, y, w, h) => [\"M\", x + w / 2, y, \"L\", x + w / 2, y + h];\n\n// Confidence-band outline in pixel space, following the same step-after\n// shape as the survival line. `arearange` (the natural fit) lives in the\n// highcharts-more module, which anyplot doesn't vendor — the core\n// SVGRenderer draws the equivalent polygon directly instead.\nconst bandOutline = (steps, xAxis, yAxis, key) => {\n  const extended = steps.concat([{ ...steps[steps.length - 1], time: STUDY_END }]);\n  const pixels = [];\n  extended.forEach((step, i) => {\n    const x = xAxis.toPixels(step.time);\n    if (i > 0) pixels.push([x, pixels[pixels.length - 1][1]]);\n    pixels.push([x, yAxis.toPixels(step[key])]);\n  });\n  return pixels;\n};\n\nHighcharts.chart(\n  \"container\",\n  {\n    chart: {\n      type: \"line\",\n      backgroundColor: \"transparent\",\n      animation: false,\n      style: { fontFamily: \"inherit\" },\n    },\n    credits: { enabled: false },\n    colors: t.palette,\n    title: {\n      text: \"survival-kaplan-meier · javascript · highcharts · anyplot.ai\",\n      style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n    },\n    subtitle: {\n      text: `Log-rank test: χ² = ${chiSquare.toFixed(2)}, p ${pValue < 0.001 ? \"< 0.001\" : `= ${pValue.toFixed(3)}`} (df=1)`,\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n    },\n    xAxis: {\n      title: { text: \"Months in Service\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n      min: 0,\n      max: STUDY_END,\n      tickInterval: 12,\n      lineColor: t.inkSoft,\n      tickColor: t.inkSoft,\n      gridLineColor: t.grid,\n      labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n    },\n    yAxis: {\n      title: { text: \"Survival Probability\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n      min: 0,\n      max: 1,\n      tickInterval: 0.2,\n      gridLineColor: t.grid,\n      labels: {\n        style: { color: t.inkSoft, fontSize: \"14px\" },\n        formatter() {\n          return `${Math.round(this.value * 100)}%`;\n        },\n      },\n      plotLines: [\n        {\n          value: 0.5,\n          color: t.inkSoft,\n          dashStyle: \"Dash\",\n          width: 1.5,\n          zIndex: 4,\n          label: { text: \"Median\", align: \"left\", style: { color: t.inkSoft, fontSize: \"12px\" } },\n        },\n      ],\n    },\n    legend: {\n      itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n      itemHoverStyle: { color: t.ink },\n    },\n    plotOptions: {\n      series: {\n        animation: false,\n        lineWidth: 3,\n        step: \"left\",\n        marker: { enabled: false, symbol: \"tick\", radius: 7, lineWidth: 1.5, fillColor: \"transparent\" },\n      },\n    },\n    series: GROUPS.map((group, i) => ({\n      name: group.name,\n      data: buildLineData(curves[i].steps, curves[i].censoredTimes),\n      color: t.palette[i],\n      zIndex: 3,\n    })),\n  },\n  (chart) => {\n    // Shaded 95% CI bands, drawn behind the survival lines.\n    curves.forEach((curve, i) => {\n      const bandColor = Highcharts.color(t.palette[i]).setOpacity(0.15).get();\n      const upper = bandOutline(curve.steps, chart.xAxis[0], chart.yAxis[0], \"upper\");\n      const lower = bandOutline(curve.steps, chart.xAxis[0], chart.yAxis[0], \"lower\").reverse();\n      const path = [\"M\", upper[0][0], upper[0][1]];\n      upper.slice(1).forEach(([x, y]) => path.push(\"L\", x, y));\n      lower.forEach(([x, y]) => path.push(\"L\", x, y));\n      path.push(\"Z\");\n      chart.renderer.path(path).attr({ fill: bandColor, zIndex: 0 }).add(chart.seriesGroup);\n    });\n\n    // Per-group median survival markers.\n    GROUPS.forEach((group, i) => {\n      const median = medianSurvival(curves[i].steps);\n      if (median !== null) {\n        chart.xAxis[0].addPlotLine({ value: median, color: t.palette[i], dashStyle: \"Dot\", width: 1, zIndex: 2 });\n      }\n    });\n  }\n);\n"}