'use client' import { useId, useMemo } from 'react' import { cn } from '../../lib/cn' import { formatTick, SERIES_COLORS } from '../../lib/chart' import { ChartTooltip } from '../ChartTooltip' import { Legend } from '../Legend' import { Text } from '../Text' import { VisuallyHidden } from '../VisuallyHidden' import { DRAW_IN_CLASS, PLOT_WIDTH, PlotAnnouncer, PlotTip, TICK_CLASS, linear, niceScale, pointerToView, svgId, useChartCursor, useDrawIn, } from '../internal/plot' export interface KaplanMeierChartSubject { /** Time until the event, or until the subject was last seen. */ time: number /** True when the event happened at `time`; false when the subject was censored — still fine when last seen. */ event: boolean } export interface KaplanMeierChartGroup { id: string label: string subjects: KaplanMeierChartSubject[] /** Any CSS colour. Defaults walk SERIES_COLORS. */ color?: string } /** The estimate just after `time`. */ export interface KaplanMeierChartStep { time: number survival: number lower: number upper: number /** Subjects still followed at `time`, before its events. */ atRisk: number events: number censored: number } export interface KaplanMeierChartProps { /** One curve per group. */ groups: KaplanMeierChartGroup[] /** Accessible name for the chart. */ label: string /** Coverage of the confidence band. */ confidence?: 0.9 | 0.95 | 0.99 /** Shade the confidence band around each curve. */ showConfidence?: boolean /** Tick each censored subject on its curve. */ showCensors?: boolean /** Mark where each curve crosses 50%. */ showMedian?: boolean /** Print the number at risk under each axis tick. */ showAtRisk?: boolean /** Name of the time unit, printed under the axis — days, months. */ timeLabel?: string /** Format times on the axis and in the tooltip. */ formatTime?: (value: number) => string /** Plot height in pixels, not counting the at-risk table. */ height?: number /** Merged last, so it wins. */ className?: string } const Z = { 0.9: 1.6449, 0.95: 1.96, 0.99: 2.5758 } /** * The product-limit estimator. Censored subjects leave the risk set without * counting as events; the band uses Greenwood's variance on the log(−log) * scale, which keeps it inside 0–100% where the plain formula spills over. */ function estimate(subjects: KaplanMeierChartSubject[], z: number) { const sorted = subjects.filter((s) => Number.isFinite(s.time) && s.time >= 0).sort((a, b) => a.time - b.time) const steps: KaplanMeierChartStep[] = [{ time: 0, survival: 1, lower: 1, upper: 1, atRisk: sorted.length, events: 0, censored: 0 }] let survival = 1 let greenwood = 0 let index = 0 while (index < sorted.length) { const time = sorted[index].time const atRisk = sorted.length - index let events = 0 let censored = 0 while (index < sorted.length && sorted[index].time === time) { if (sorted[index].event) events += 1 else censored += 1 index += 1 } if (events > 0) { survival *= 1 - events / atRisk greenwood += atRisk > events ? events / (atRisk * (atRisk - events)) : 0 } let lower = survival let upper = survival if (survival > 0 && survival < 1) { const se = Math.sqrt(greenwood) / Math.abs(Math.log(survival)) lower = survival ** Math.exp(z * se) upper = survival ** Math.exp(-z * se) } const previous = steps[steps.length - 1] if (time === 0) Object.assign(previous, { survival, lower, upper, events, censored }) else steps.push({ time, survival, lower, upper, atRisk, events, censored }) } const median = steps.find((step) => step.survival <= 0.5)?.time ?? null const end = sorted.length ? sorted[sorted.length - 1].time : 0 return { steps, median, end, total: sorted.length, events: sorted.filter((s) => s.event).length } } function logGamma(x: number): number { const c = [76.18009172947146, -86.50532032941677, 24.01409824083091, -1.231739572450155, 0.1208650973866179e-2, -0.5395239384953e-5] let y = x const t = x + 5.5 - (x + 0.5) * Math.log(x + 5.5) let sum = 1.000000000190015 for (const k of c) sum += k / ++y return -t + Math.log((2.5066282746310005 * sum) / x) } /** Upper regularised incomplete gamma Q(a, x): series below a + 1, continued fraction above. */ function gammaQ(a: number, x: number): number { if (x <= 0) return 1 if (x < a + 1) { let term = 1 / a let sum = term for (let n = 1; n < 200; n += 1) { term *= x / (a + n) sum += term if (Math.abs(term) < Math.abs(sum) * 1e-14) break } return 1 - sum * Math.exp(-x + a * Math.log(x) - logGamma(a)) } let b = x + 1 - a let c = 1e300 let d = 1 / b let h = d for (let i = 1; i < 200; i += 1) { const an = -i * (i - a) b += 2 d = an * d + b if (Math.abs(d) < 1e-300) d = 1e-300 c = b + an / c if (Math.abs(c) < 1e-300) c = 1e-300 d = 1 / d const delta = d * c h *= delta if (Math.abs(delta - 1) < 1e-14) break } return Math.exp(-x + a * Math.log(x) - logGamma(a)) * h } /** * The log-rank test across all groups: observed minus expected events at each * event time, weighed against their covariance, compared with χ² on k − 1 * degrees of freedom. */ function logRank(groups: KaplanMeierChartSubject[][]) { const k = groups.length if (k < 2) return null const times = [...new Set(groups.flatMap((g) => g.filter((s) => s.event).map((s) => s.time)))].sort((a, b) => a - b) const observed = new Array(k).fill(0) const expected = new Array(k).fill(0) const v = Array.from({ length: k }, () => new Array(k).fill(0)) for (const t of times) { const at = groups.map((g) => g.filter((s) => s.time >= t).length) const dead = groups.map((g) => g.filter((s) => s.event && s.time === t).length) const n = at.reduce((a, b) => a + b, 0) const d = dead.reduce((a, b) => a + b, 0) if (n === 0) continue for (let j = 0; j < k; j += 1) { observed[j] += dead[j] expected[j] += (d * at[j]) / n if (n > 1) for (let l = 0; l < k; l += 1) v[j][l] += ((d * (n - d)) / (n - 1)) * (at[j] / n) * ((j === l ? 1 : 0) - at[l] / n) } } // Solve V' x = U for the first k − 1 groups by Gaussian elimination. const size = k - 1 const m = v.slice(0, size).map((row, j) => [...row.slice(0, size), observed[j] - expected[j]]) for (let col = 0; col < size; col += 1) { let pivot = col for (let row = col + 1; row < size; row += 1) if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) pivot = row ;[m[col], m[pivot]] = [m[pivot], m[col]] if (Math.abs(m[col][col]) < 1e-12) return null for (let row = 0; row < size; row += 1) { if (row === col) continue const f = m[row][col] / m[col][col] for (let c = col; c <= size; c += 1) m[row][c] -= f * m[col][c] } } let chi = 0 for (let j = 0; j < size; j += 1) chi += (observed[j] - expected[j]) * (m[j][size] / m[j][j]) return { chi, df: size, p: gammaQ(size / 2, chi / 2), observed, expected } } const pct = (value: number) => `${(value * 100).toFixed(1)}%` const pText = (p: number) => (p < 0.001 ? 'p < 0.001' : `p = ${p.toFixed(3)}`) /** * Survival curves: the share of each group still without the event over time * — customers not yet churned, patients not yet relapsed, trials not yet * converted — estimated with Kaplan–Meier. * * Everyone who has not had the event yet is censored rather than dropped, which * is the whole point: an average of "time to churn" over customers who have * churned says nothing about the ones who have not. Bands, censor ticks and the * at-risk table show how much data is behind each part of each curve, so the * thin tail on the right is read as the guess it is. The log-rank p-value says * whether the gap between groups is more than chance. */ export function KaplanMeierChart({ groups, label, confidence = 0.95, showConfidence = true, showCensors = true, showMedian = true, showAtRisk = true, timeLabel, formatTime = formatTick, height = 260, className, }: KaplanMeierChartProps) { const tableId = useId() const clipId = svgId(`${tableId}clip`) const drawn = useDrawIn() const curves = useMemo( () => groups.map((group, index) => ({ ...group, color: group.color ?? SERIES_COLORS[index % SERIES_COLORS.length], ...estimate(group.subjects, Z[confidence]), })), [groups, confidence], ) const test = useMemo(() => logRank(groups.map((group) => group.subjects)), [groups]) const times = [...new Set(curves.flatMap((curve) => curve.steps.map((step) => step.time)))].sort((a, b) => a - b) const { active, setActive, keyProps } = useChartCursor(times.length) const xs = niceScale(0, Math.max(1, ...curves.map((curve) => curve.end)), 6) const left = 78 const plot = { x: left, y: 12, width: PLOT_WIDTH - left - 16, height: height - 12 - 28 - (timeLabel ? 12 : 0) } const toX = linear(0, xs.max, plot.x, plot.x + plot.width) const toY = linear(0, 1, plot.y + plot.height, plot.y) const riskTop = height + 8 const total = height + (showAtRisk ? 22 + curves.length * 15 : 0) const stepAt = (curve: (typeof curves)[number], time: number) => { let found = curve.steps[0] for (const step of curve.steps) if (step.time <= time) found = step return found } const atRisk = (curve: (typeof curves)[number], time: number) => curve.subjects.filter((s) => s.time >= time).length const stepPath = (curve: (typeof curves)[number], key: 'survival' | 'lower' | 'upper') => { let d = `M${toX(0)} ${toY(curve.steps[0][key])}` for (const step of curve.steps.slice(1)) d += `H${toX(step.time).toFixed(1)}V${toY(step[key]).toFixed(1)}` return `${d}H${toX(curve.end).toFixed(1)}` } const bandPath = (curve: (typeof curves)[number]) => { let d = `M${toX(0)} ${toY(1)}` for (const step of curve.steps.slice(1)) d += `H${toX(step.time).toFixed(1)}V${toY(step.upper).toFixed(1)}` d += `H${toX(curve.end).toFixed(1)}V${toY(curve.steps[curve.steps.length - 1].lower).toFixed(1)}` for (let i = curve.steps.length - 1; i >= 1; i -= 1) d += `H${toX(curve.steps[i].time).toFixed(1)}V${toY(curve.steps[i - 1].lower).toFixed(1)}` return `${d}H${toX(0)}Z` } const onPointerMove = (event: React.PointerEvent) => { const { x } = pointerToView(event, PLOT_WIDTH, total) if (x < plot.x - 8 || x > plot.x + plot.width + 8) return setActive(null) const time = linear(plot.x, plot.x + plot.width, 0, xs.max)(x) let best = 0 times.forEach((t, index) => { if (t <= time) best = index }) setActive(best) } const time = active === null ? null : times[active] const summary = `${test ? `Log-rank χ² ${test.chi.toFixed(2)}, ${test.df} df, ${pText(test.p)}. ` : ''}${curves .map((curve) => `${curve.label}: ${curve.total} subjects, ${curve.events} events, median ${curve.median === null ? 'not reached' : formatTime(curve.median)}.`) .join(' ')}` return (
{test && ( {`Log-rank χ² = ${test.chi.toFixed(2)}, ${test.df} df, ${pText(test.p)}${test.p < 0.05 ? ' — the curves differ by more than chance' : ' — no evidence the curves differ'}`} )}
setActive(null)} {...keyProps} > {[0, 0.25, 0.5, 0.75, 1].map((tick) => ( {`${tick * 100}%`} ))} {xs.ticks.map((tick) => ( {formatTime(tick)} ))} {timeLabel && ( {timeLabel} )} {showConfidence && curves.map((curve) => )} {showMedian && curves.map( (curve) => curve.median !== null && ( ), )} {curves.map((curve) => ( ))} {showCensors && curves.flatMap((curve) => curve.steps .filter((step) => step.censored > 0) .map((step) => ( )), )} {time !== null && ( {curves.map((curve) => ( ))} )} {showAtRisk && ( )} {time !== null && ( {curves.flatMap((curve) => curve.steps.map((step) => ( )), )}
{`${label}. ${summary}`}
Group Time At risk Events Censored Survival {`${Math.round(confidence * 100)}% interval`}
{curve.label} {formatTime(step.time)} {step.atRisk} {step.events} {step.censored} {pct(step.survival)} {`${pct(step.lower)} to ${pct(step.upper)}`}
`${curve.label} ${pct(stepAt(curve, time).survival)}, ${atRisk(curve, time)} at risk`).join('; ')}` } />
) }