Skip to content

Commit 23ba53e

Browse files
feat(highcharts): implement survival-kaplan-meier (#2491)
## Implementation: `survival-kaplan-meier` - highcharts Implements the **highcharts** version of `survival-kaplan-meier`. **File:** `plots/survival-kaplan-meier/implementations/highcharts.py` --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20584330823)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 6c01f58 commit 23ba53e

2 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
""" pyplots.ai
2+
survival-kaplan-meier: Kaplan-Meier Survival Plot
3+
Library: highcharts unknown | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-29
5+
"""
6+
7+
import tempfile
8+
import time
9+
import urllib.request
10+
from pathlib import Path
11+
12+
import numpy as np
13+
from highcharts_core.chart import Chart
14+
from highcharts_core.options import HighchartsOptions
15+
from highcharts_core.options.series.area import AreaRangeSeries, LineSeries
16+
from highcharts_core.options.series.scatter import ScatterSeries
17+
from selenium import webdriver
18+
from selenium.webdriver.chrome.options import Options
19+
20+
21+
# Generate synthetic clinical trial survival data
22+
np.random.seed(42)
23+
n_patients = 120
24+
25+
# Group A: Standard treatment - Weibull distribution
26+
times_a = np.random.weibull(1.5, n_patients // 2) * 24
27+
events_a = np.random.binomial(1, 0.7, n_patients // 2) # 70% event rate
28+
max_time = 36
29+
times_a = np.minimum(times_a, max_time) # Censored at study end
30+
31+
# Group B: New treatment - better survival
32+
times_b = np.random.weibull(1.8, n_patients // 2) * 30
33+
events_b = np.random.binomial(1, 0.55, n_patients // 2) # 55% event rate
34+
times_b = np.minimum(times_b, max_time)
35+
36+
# Kaplan-Meier calculation for Group A (inline, no function)
37+
sorted_idx_a = np.argsort(times_a)
38+
times_a_sorted = times_a[sorted_idx_a]
39+
events_a_sorted = events_a[sorted_idx_a]
40+
unique_times_a = np.unique(times_a_sorted)
41+
n_at_risk_a = len(times_a_sorted)
42+
survival_a = 1.0
43+
var_sum_a = 0.0
44+
km_times_a = [0.0]
45+
km_survival_a = [1.0]
46+
km_lower_a = [1.0]
47+
km_upper_a = [1.0]
48+
censored_times_a = []
49+
censored_survival_a = []
50+
51+
for t in unique_times_a:
52+
mask = times_a_sorted == t
53+
d = events_a_sorted[mask].sum()
54+
c = (~events_a_sorted[mask].astype(bool)).sum()
55+
if d > 0:
56+
survival_a *= 1 - d / n_at_risk_a
57+
if n_at_risk_a > d:
58+
var_sum_a += d / (n_at_risk_a * (n_at_risk_a - d))
59+
se = survival_a * np.sqrt(var_sum_a) if var_sum_a > 0 else 0
60+
lower = max(0, survival_a - 1.96 * se)
61+
upper = min(1, survival_a + 1.96 * se)
62+
km_times_a.append(float(t))
63+
km_survival_a.append(float(survival_a))
64+
km_lower_a.append(float(lower))
65+
km_upper_a.append(float(upper))
66+
if c > 0:
67+
censored_times_a.append(float(t))
68+
censored_survival_a.append(float(survival_a))
69+
n_at_risk_a -= d + c
70+
71+
# Kaplan-Meier calculation for Group B (inline, no function)
72+
sorted_idx_b = np.argsort(times_b)
73+
times_b_sorted = times_b[sorted_idx_b]
74+
events_b_sorted = events_b[sorted_idx_b]
75+
unique_times_b = np.unique(times_b_sorted)
76+
n_at_risk_b = len(times_b_sorted)
77+
survival_b = 1.0
78+
var_sum_b = 0.0
79+
km_times_b = [0.0]
80+
km_survival_b = [1.0]
81+
km_lower_b = [1.0]
82+
km_upper_b = [1.0]
83+
censored_times_b = []
84+
censored_survival_b = []
85+
86+
for t in unique_times_b:
87+
mask = times_b_sorted == t
88+
d = events_b_sorted[mask].sum()
89+
c = (~events_b_sorted[mask].astype(bool)).sum()
90+
if d > 0:
91+
survival_b *= 1 - d / n_at_risk_b
92+
if n_at_risk_b > d:
93+
var_sum_b += d / (n_at_risk_b * (n_at_risk_b - d))
94+
se = survival_b * np.sqrt(var_sum_b) if var_sum_b > 0 else 0
95+
lower = max(0, survival_b - 1.96 * se)
96+
upper = min(1, survival_b + 1.96 * se)
97+
km_times_b.append(float(t))
98+
km_survival_b.append(float(survival_b))
99+
km_lower_b.append(float(lower))
100+
km_upper_b.append(float(upper))
101+
if c > 0:
102+
censored_times_b.append(float(t))
103+
censored_survival_b.append(float(survival_b))
104+
n_at_risk_b -= d + c
105+
106+
# Create chart
107+
chart = Chart(container="container")
108+
chart.options = HighchartsOptions()
109+
110+
# Chart configuration
111+
chart.options.chart = {
112+
"width": 4800,
113+
"height": 2700,
114+
"backgroundColor": "#ffffff",
115+
"marginBottom": 250,
116+
"marginLeft": 220,
117+
"marginRight": 150,
118+
}
119+
120+
# Title
121+
chart.options.title = {
122+
"text": "survival-kaplan-meier · highcharts · pyplots.ai",
123+
"style": {"fontSize": "72px", "fontWeight": "bold"},
124+
}
125+
126+
# Subtitle
127+
chart.options.subtitle = {
128+
"text": "Clinical Trial: Survival Probability Over Time",
129+
"style": {"fontSize": "42px", "color": "#666666"},
130+
}
131+
132+
# X-axis configuration
133+
chart.options.x_axis = {
134+
"title": {"text": "Time (Months)", "style": {"fontSize": "48px"}, "margin": 30},
135+
"labels": {"style": {"fontSize": "36px"}, "y": 40},
136+
"min": 0,
137+
"max": 40,
138+
"tickInterval": 6,
139+
"gridLineWidth": 1,
140+
"gridLineColor": "#e0e0e0",
141+
"gridLineDashStyle": "Dash",
142+
"lineWidth": 2,
143+
"lineColor": "#333333",
144+
}
145+
146+
# Y-axis configuration
147+
chart.options.y_axis = {
148+
"title": {"text": "Survival Probability", "style": {"fontSize": "48px"}},
149+
"labels": {"style": {"fontSize": "36px"}},
150+
"min": 0,
151+
"max": 1.0,
152+
"tickInterval": 0.2,
153+
"gridLineWidth": 1,
154+
"gridLineColor": "#e0e0e0",
155+
"gridLineDashStyle": "Dash",
156+
}
157+
158+
# Legend configuration - positioned closer to the data (left side, middle)
159+
chart.options.legend = {
160+
"enabled": True,
161+
"align": "left",
162+
"verticalAlign": "middle",
163+
"layout": "vertical",
164+
"x": 250,
165+
"y": -200,
166+
"itemStyle": {"fontSize": "36px"},
167+
"itemMarginBottom": 20,
168+
"backgroundColor": "rgba(255, 255, 255, 0.85)",
169+
"borderRadius": 5,
170+
"padding": 15,
171+
}
172+
173+
# Colors
174+
color_a = "#306998" # Python Blue
175+
color_b = "#FFD43B" # Python Yellow
176+
177+
# Plot options for step function
178+
chart.options.plot_options = {
179+
"series": {"animation": False},
180+
"line": {"step": "left", "lineWidth": 5, "marker": {"enabled": False}},
181+
"arearange": {"step": "left", "fillOpacity": 0.2, "lineWidth": 0, "marker": {"enabled": False}},
182+
"scatter": {"marker": {"symbol": "diamond", "radius": 8, "enabled": True}, "enableMouseTracking": True},
183+
}
184+
185+
# --- Group A: Confidence Interval ---
186+
ci_data_a = [[km_times_a[i], km_lower_a[i], km_upper_a[i]] for i in range(len(km_times_a))]
187+
ci_series_a = AreaRangeSeries()
188+
ci_series_a.data = ci_data_a
189+
ci_series_a.name = "95% CI (Standard)"
190+
ci_series_a.color = color_a
191+
ci_series_a.show_in_legend = False
192+
chart.add_series(ci_series_a)
193+
194+
# --- Group A: Survival Curve ---
195+
curve_data_a = [[km_times_a[i], km_survival_a[i]] for i in range(len(km_times_a))]
196+
curve_series_a = LineSeries()
197+
curve_series_a.data = curve_data_a
198+
curve_series_a.name = "Standard Treatment"
199+
curve_series_a.color = color_a
200+
chart.add_series(curve_series_a)
201+
202+
# --- Group A: Censored Marks (tick marks on curve) ---
203+
if censored_times_a:
204+
censor_data_a = [{"x": censored_times_a[i], "y": censored_survival_a[i]} for i in range(len(censored_times_a))]
205+
censor_series_a = ScatterSeries()
206+
censor_series_a.data = censor_data_a
207+
censor_series_a.name = "Censored (Standard)"
208+
censor_series_a.color = color_a
209+
censor_series_a.marker = {
210+
"symbol": "diamond",
211+
"lineWidth": 3,
212+
"lineColor": color_a,
213+
"fillColor": "#ffffff",
214+
"radius": 8,
215+
}
216+
censor_series_a.show_in_legend = False
217+
chart.add_series(censor_series_a)
218+
219+
# --- Group B: Confidence Interval ---
220+
ci_data_b = [[km_times_b[i], km_lower_b[i], km_upper_b[i]] for i in range(len(km_times_b))]
221+
ci_series_b = AreaRangeSeries()
222+
ci_series_b.data = ci_data_b
223+
ci_series_b.name = "95% CI (New)"
224+
ci_series_b.color = color_b
225+
ci_series_b.show_in_legend = False
226+
chart.add_series(ci_series_b)
227+
228+
# --- Group B: Survival Curve ---
229+
curve_data_b = [[km_times_b[i], km_survival_b[i]] for i in range(len(km_times_b))]
230+
curve_series_b = LineSeries()
231+
curve_series_b.data = curve_data_b
232+
curve_series_b.name = "New Treatment"
233+
curve_series_b.color = color_b
234+
chart.add_series(curve_series_b)
235+
236+
# --- Group B: Censored Marks (tick marks on curve) ---
237+
if censored_times_b:
238+
censor_data_b = [{"x": censored_times_b[i], "y": censored_survival_b[i]} for i in range(len(censored_times_b))]
239+
censor_series_b = ScatterSeries()
240+
censor_series_b.data = censor_data_b
241+
censor_series_b.name = "Censored (New)"
242+
censor_series_b.color = color_b
243+
censor_series_b.marker = {
244+
"symbol": "diamond",
245+
"lineWidth": 3,
246+
"lineColor": color_b,
247+
"fillColor": "#ffffff",
248+
"radius": 8,
249+
}
250+
censor_series_b.show_in_legend = False
251+
chart.add_series(censor_series_b)
252+
253+
# Download Highcharts JS modules
254+
highcharts_url = "https://code.highcharts.com/highcharts.js"
255+
with urllib.request.urlopen(highcharts_url, timeout=30) as response:
256+
highcharts_js = response.read().decode("utf-8")
257+
258+
highcharts_more_url = "https://code.highcharts.com/highcharts-more.js"
259+
with urllib.request.urlopen(highcharts_more_url, timeout=30) as response:
260+
highcharts_more_js = response.read().decode("utf-8")
261+
262+
# Generate HTML with inline scripts
263+
html_str = chart.to_js_literal()
264+
html_content = f"""<!DOCTYPE html>
265+
<html>
266+
<head>
267+
<meta charset="utf-8">
268+
<script>{highcharts_js}</script>
269+
<script>{highcharts_more_js}</script>
270+
</head>
271+
<body style="margin:0;">
272+
<div id="container" style="width: 4800px; height: 2700px;"></div>
273+
<script>{html_str}</script>
274+
</body>
275+
</html>"""
276+
277+
# Save HTML file for interactive viewing
278+
with open("plot.html", "w", encoding="utf-8") as f:
279+
f.write(html_content)
280+
281+
# Write temp HTML and take screenshot
282+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
283+
f.write(html_content)
284+
temp_path = f.name
285+
286+
# Configure Chrome for headless screenshot
287+
chrome_options = Options()
288+
chrome_options.add_argument("--headless")
289+
chrome_options.add_argument("--no-sandbox")
290+
chrome_options.add_argument("--disable-dev-shm-usage")
291+
chrome_options.add_argument("--disable-gpu")
292+
chrome_options.add_argument("--window-size=4800,2900")
293+
294+
driver = webdriver.Chrome(options=chrome_options)
295+
driver.get(f"file://{temp_path}")
296+
time.sleep(5) # Wait for chart to render
297+
driver.save_screenshot("plot.png")
298+
driver.quit()
299+
300+
Path(temp_path).unlink() # Clean up temp file
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
library: highcharts
2+
specification_id: survival-kaplan-meier
3+
created: '2025-12-29T22:57:27Z'
4+
updated: '2025-12-29T23:12:17Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20584330823
7+
issue: 0
8+
python_version: 3.13.11
9+
library_version: unknown
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/survival-kaplan-meier/highcharts/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/survival-kaplan-meier/highcharts/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/survival-kaplan-meier/highcharts/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Inline Kaplan-Meier calculation with Greenwood formula for CI (KISS principle
17+
followed)
18+
- Excellent colorblind-safe blue/yellow color scheme with good contrast
19+
- 'Proper step function implementation using Highcharts step: left option'
20+
- Confidence interval bands correctly implemented with AreaRangeSeries and appropriate
21+
transparency
22+
- Censored observation markers now visible as diamond shapes on the curves
23+
- Realistic clinical trial data with meaningful difference between treatment groups
24+
weaknesses:
25+
- Legend positioned in middle-left area potentially overlapping with data visualization
26+
region
27+
- Censored markers could be slightly larger for better visibility at the high resolution

0 commit comments

Comments
 (0)