Kaplan-Meier Survival Plot — Highcharts

A Kaplan-Meier survival plot visualizes the probability of survival (or event-free time) over a time period using a step function. It is the standard method for estimating survival functions from time-to-event data, handling censored observations where the event has not yet occurred. The plot shows how survival probability decreases over time, with optional confidence intervals and comparison between groups.

Kaplan-Meier Survival Plot rendered with Highcharts

Renders

JavaScript source (Highcharts)

// anyplot.ai
// survival-kaplan-meier: Kaplan-Meier Survival Plot
// Library: highcharts 12.6.0 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-09-09

//# anyplot-orientation: landscape
const t = window.ANYPLOT_TOKENS;

// --- Reproducible RNG (fixed-seed LCG — the browser has no seeded Math.random) --
const makeLcg = (seed) => {
  let state = seed >>> 0;
  return () => {
    state = (1103515245 * state + 12345) & 0x7fffffff;
    return state / 0x7fffffff;
  };
};
const rand = makeLcg(42);
const exponential = (rate) => -Math.log(1 - rand()) / rate;

// --- Data: time-to-failure (months in service) for two bearing designs -----
const STUDY_END = 60;
const DROPOUT_RATE = 0.01;
const GROUPS = [
  { name: "Standard Bearing", failureRate: 0.035, n: 70 },
  { name: "Reinforced Bearing", failureRate: 0.018, n: 70 },
];

const records = GROUPS.map((group) => {
  const units = [];
  for (let i = 0; i < group.n; i++) {
    const failureTime = exponential(group.failureRate);
    const dropoutTime = exponential(DROPOUT_RATE);
    const time = Math.min(failureTime, dropoutTime, STUDY_END);
    const event = failureTime <= dropoutTime && failureTime <= STUDY_END ? 1 : 0;
    units.push({ time, event });
  }
  return units.sort((a, b) => a.time - b.time);
});

// --- Kaplan-Meier estimator with Greenwood confidence intervals ------------
const kaplanMeier = (units) => {
  const eventTimes = [...new Set(units.filter((u) => u.event === 1).map((u) => u.time))].sort(
    (a, b) => a - b
  );
  let survival = 1;
  let greenwoodSum = 0;
  const steps = [{ time: 0, survival: 1, lower: 1, upper: 1 }];
  eventTimes.forEach((time) => {
    const atRisk = units.filter((u) => u.time >= time).length;
    const deaths = units.filter((u) => u.time === time && u.event === 1).length;
    survival *= 1 - deaths / atRisk;
    if (atRisk > deaths) greenwoodSum += deaths / (atRisk * (atRisk - deaths));
    const se = survival * Math.sqrt(greenwoodSum);
    steps.push({
      time,
      survival,
      lower: Math.max(0, survival - 1.96 * se),
      upper: Math.min(1, survival + 1.96 * se),
    });
  });
  const censoredTimes = units.filter((u) => u.event === 0).map((u) => u.time);
  return { steps, censoredTimes };
};

const curves = records.map(kaplanMeier);

// Step-held survival value at an arbitrary time (for placing censoring ticks).
const survivalAt = (steps, time) => {
  let value = 1;
  for (const step of steps) {
    if (step.time > time) break;
    value = step.survival;
  }
  return value;
};

// First time the curve reaches 50% survival, or null if never reached.
const medianSurvival = (steps) => {
  const hit = steps.find((s) => s.survival <= 0.5);
  return hit ? hit.time : null;
};

// --- Log-rank test (Mantel-Cox) comparing the two groups --------------------
const erf = (x) => {
  const sign = x < 0 ? -1 : 1;
  x = Math.abs(x);
  const a1 = 0.254829592;
  const a2 = -0.284496736;
  const a3 = 1.421413741;
  const a4 = -1.453152027;
  const a5 = 1.061405429;
  const p = 0.3275911;
  const tt = 1 / (1 + p * x);
  const y = 1 - ((((a5 * tt + a4) * tt + a3) * tt + a2) * tt + a1) * tt * Math.exp(-x * x);
  return sign * y;
};

const logRankTest = (unitsA, unitsB) => {
  const eventTimes = [...new Set([...unitsA, ...unitsB].filter((u) => u.event === 1).map((u) => u.time))].sort(
    (a, b) => a - b
  );
  let observedA = 0;
  let expectedA = 0;
  let variance = 0;
  eventTimes.forEach((time) => {
    const atRiskA = unitsA.filter((u) => u.time >= time).length;
    const atRiskB = unitsB.filter((u) => u.time >= time).length;
    const deathsA = unitsA.filter((u) => u.time === time && u.event === 1).length;
    const deathsB = unitsB.filter((u) => u.time === time && u.event === 1).length;
    const atRisk = atRiskA + atRiskB;
    const deaths = deathsA + deathsB;
    if (atRisk < 2) return;
    observedA += deathsA;
    expectedA += (deaths * atRiskA) / atRisk;
    variance += (deaths * (atRiskA / atRisk) * (atRiskB / atRisk) * (atRisk - deaths)) / (atRisk - 1);
  });
  const chiSquare = variance > 0 ? (observedA - expectedA) ** 2 / variance : 0;
  const pValue = 1 - erf(Math.sqrt(chiSquare / 2));
  return { chiSquare, pValue };
};

const { chiSquare, pValue } = logRankTest(records[0], records[1]);

// --- Series data: step curve, plus tick markers at censored times ----------
const buildLineData = (steps, censoredTimes) => {
  const points = steps.map((s) => ({ x: s.time, y: s.survival, marker: { enabled: false } }));
  censoredTimes.forEach((time) => {
    points.push({ x: time, y: survivalAt(steps, time), marker: { enabled: true } });
  });
  points.push({ x: STUDY_END, y: steps[steps.length - 1].survival, marker: { enabled: false } });
  return points.sort((a, b) => a.x - b.x);
};

// Custom vertical-tick marker symbol for censored observations (core
// SVGRenderer API — no add-on module needed).
Highcharts.SVGRenderer.prototype.symbols.tick = (x, y, w, h) => ["M", x + w / 2, y, "L", x + w / 2, y + h];

// Confidence-band outline in pixel space, following the same step-after
// shape as the survival line. `arearange` (the natural fit) lives in the
// highcharts-more module, which anyplot doesn't vendor — the core
// SVGRenderer draws the equivalent polygon directly instead.
const bandOutline = (steps, xAxis, yAxis, key) => {
  const extended = steps.concat([{ ...steps[steps.length - 1], time: STUDY_END }]);
  const pixels = [];
  extended.forEach((step, i) => {
    const x = xAxis.toPixels(step.time);
    if (i > 0) pixels.push([x, pixels[pixels.length - 1][1]]);
    pixels.push([x, yAxis.toPixels(step[key])]);
  });
  return pixels;
};

Highcharts.chart(
  "container",
  {
    chart: {
      type: "line",
      backgroundColor: "transparent",
      animation: false,
      style: { fontFamily: "inherit" },
    },
    credits: { enabled: false },
    colors: t.palette,
    title: {
      text: "survival-kaplan-meier · javascript · highcharts · anyplot.ai",
      style: { color: t.ink, fontSize: "22px", fontWeight: "600" },
    },
    subtitle: {
      text: `Log-rank test: χ² = ${chiSquare.toFixed(2)}, p ${pValue < 0.001 ? "< 0.001" : `= ${pValue.toFixed(3)}`} (df=1)`,
      style: { color: t.inkSoft, fontSize: "14px" },
    },
    xAxis: {
      title: { text: "Months in Service", style: { color: t.inkSoft, fontSize: "16px" } },
      min: 0,
      max: STUDY_END,
      tickInterval: 12,
      lineColor: t.inkSoft,
      tickColor: t.inkSoft,
      gridLineColor: t.grid,
      labels: { style: { color: t.inkSoft, fontSize: "14px" } },
    },
    yAxis: {
      title: { text: "Survival Probability", style: { color: t.inkSoft, fontSize: "16px" } },
      min: 0,
      max: 1,
      tickInterval: 0.2,
      gridLineColor: t.grid,
      labels: {
        style: { color: t.inkSoft, fontSize: "14px" },
        formatter() {
          return `${Math.round(this.value * 100)}%`;
        },
      },
      plotLines: [
        {
          value: 0.5,
          color: t.inkSoft,
          dashStyle: "Dash",
          width: 1.5,
          zIndex: 4,
          label: { text: "Median", align: "left", style: { color: t.inkSoft, fontSize: "12px" } },
        },
      ],
    },
    legend: {
      itemStyle: { color: t.inkSoft, fontSize: "14px" },
      itemHoverStyle: { color: t.ink },
    },
    plotOptions: {
      series: {
        animation: false,
        lineWidth: 3,
        step: "left",
        marker: { enabled: false, symbol: "tick", radius: 7, lineWidth: 1.5, fillColor: "transparent" },
      },
    },
    series: GROUPS.map((group, i) => ({
      name: group.name,
      data: buildLineData(curves[i].steps, curves[i].censoredTimes),
      color: t.palette[i],
      zIndex: 3,
    })),
  },
  (chart) => {
    // Shaded 95% CI bands, drawn behind the survival lines.
    curves.forEach((curve, i) => {
      const bandColor = Highcharts.color(t.palette[i]).setOpacity(0.15).get();
      const upper = bandOutline(curve.steps, chart.xAxis[0], chart.yAxis[0], "upper");
      const lower = bandOutline(curve.steps, chart.xAxis[0], chart.yAxis[0], "lower").reverse();
      const path = ["M", upper[0][0], upper[0][1]];
      upper.slice(1).forEach(([x, y]) => path.push("L", x, y));
      lower.forEach(([x, y]) => path.push("L", x, y));
      path.push("Z");
      chart.renderer.path(path).attr({ fill: bandColor, zIndex: 0 }).add(chart.seriesGroup);
    });

    // Per-group median survival markers.
    GROUPS.forEach((group, i) => {
      const median = medianSurvival(curves[i].steps);
      if (median !== null) {
        chart.xAxis[0].addPlotLine({ value: median, color: t.palette[i], dashStyle: "Dot", width: 1, zIndex: 2 });
      }
    });
  }
);

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/survival-kaplan-meier/highcharts/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "survival-kaplan-meier",
  "language": "javascript",
  "library": "highcharts",
  "page": "https://anyplot.ai/survival-kaplan-meier/javascript/highcharts",
  "hub": "https://anyplot.ai/survival-kaplan-meier",
  "code_json": "https://api.anyplot.ai/specs/survival-kaplan-meier/highcharts/code",
  "spec_json": "https://api.anyplot.ai/specs/survival-kaplan-meier",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/survival-kaplan-meier/javascript/highcharts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/survival-kaplan-meier/javascript/highcharts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/survival-kaplan-meier/javascript/highcharts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/survival-kaplan-meier/javascript/highcharts/plot-dark.html",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Kaplan-Meier Survival Plot on anyplot.ai.

Other implementations