Skip to content

Commit fd724d1

Browse files
feat(highcharts): implement heatmap-cohort-retention (#4941)
## Implementation: `heatmap-cohort-retention` - highcharts Implements the **highcharts** version of `heatmap-cohort-retention`. **File:** `plots/heatmap-cohort-retention/implementations/highcharts.py` **Parent Issue:** #4570 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23165008487)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 57d2daa commit fd724d1

2 files changed

Lines changed: 491 additions & 0 deletions

File tree

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
""" pyplots.ai
2+
heatmap-cohort-retention: Cohort Retention Heatmap
3+
Library: highcharts unknown | Python 3.14.3
4+
Quality: 90/100 | Created: 2026-03-16
5+
"""
6+
7+
import json
8+
import tempfile
9+
import time
10+
import urllib.request
11+
from pathlib import Path
12+
13+
import numpy as np
14+
from selenium import webdriver
15+
from selenium.webdriver.chrome.options import Options
16+
17+
18+
# Data - Monthly signup cohorts with realistic retention variation
19+
np.random.seed(42)
20+
cohorts = [
21+
"Jan 2024",
22+
"Feb 2024",
23+
"Mar 2024",
24+
"Apr 2024",
25+
"May 2024",
26+
"Jun 2024",
27+
"Jul 2024",
28+
"Aug 2024",
29+
"Sep 2024",
30+
"Oct 2024",
31+
]
32+
num_cohorts = len(cohorts)
33+
num_periods = 10
34+
35+
# Cohort sizes - varied to reflect marketing pushes
36+
cohort_sizes = [1240, 1385, 1520, 1190, 1450, 1680, 1310, 1575, 1420, 1290]
37+
38+
# Generate differentiated retention curves to tell a story:
39+
# - Jun 2024 (idx 5): best cohort — new onboarding flow launched
40+
# - Apr 2024 (idx 3): worst cohort — buggy release hurt retention
41+
# - Others vary moderately
42+
retention = np.zeros((num_cohorts, num_periods))
43+
retention[:, 0] = 100.0
44+
45+
base_decay = np.array([1.0, 0.58, 0.45, 0.38, 0.33, 0.30, 0.27, 0.25, 0.23, 0.21])
46+
47+
# Per-cohort quality multipliers for storytelling
48+
cohort_multipliers = [1.0, 0.98, 1.04, 0.82, 0.95, 1.18, 1.06, 1.02, 1.10, 1.05]
49+
50+
for i in range(num_cohorts):
51+
noise = np.random.normal(0, 0.015, num_periods)
52+
curve = base_decay * cohort_multipliers[i] + noise
53+
curve[0] = 1.0
54+
curve = np.clip(curve, 0.05, 1.0)
55+
retention[i, :] = np.round(curve * 100, 1)
56+
57+
# Triangular shape: recent cohorts have fewer periods
58+
heatmap_data = []
59+
for row in range(num_cohorts):
60+
max_periods = num_periods - row
61+
for col in range(max_periods):
62+
heatmap_data.append([col, row, float(retention[row, col])])
63+
64+
# Y-axis labels with cohort sizes
65+
y_labels = [f"{cohort} ({size:,})" for cohort, size in zip(cohorts, cohort_sizes, strict=True)]
66+
x_labels = [f"Month {i}" for i in range(num_periods)]
67+
68+
# Find best and worst cohorts for storytelling emphasis
69+
best_cohort_idx = 5 # Jun 2024 - new onboarding
70+
worst_cohort_idx = 3 # Apr 2024 - buggy release
71+
72+
# Chart configuration
73+
chart_options = {
74+
"chart": {
75+
"type": "heatmap",
76+
"width": 4800,
77+
"height": 2700,
78+
"backgroundColor": "#fafafa",
79+
"marginTop": 260,
80+
"marginBottom": 100,
81+
"marginLeft": 400,
82+
"marginRight": 280,
83+
"style": {"fontFamily": "'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"},
84+
},
85+
"title": {
86+
"text": "heatmap-cohort-retention \u00b7 highcharts \u00b7 pyplots.ai",
87+
"style": {"fontSize": "48px", "fontWeight": "700", "color": "#1a2634"},
88+
"y": 28,
89+
},
90+
"subtitle": {
91+
"text": "Monthly cohort retention rates \u2014 percentage of users returning each month after signup<br/>"
92+
'<span style="font-size:22px;color:#084594;">\u2605 Best: Jun 2024 (new onboarding)</span>'
93+
"&nbsp;&nbsp;&nbsp;"
94+
'<span style="font-size:22px;color:#c0392b;">Apr 2024: lowest retention (buggy release)</span>',
95+
"style": {"fontSize": "26px", "fontWeight": "normal", "color": "#7f8c8d"},
96+
"useHTML": True,
97+
"y": 78,
98+
},
99+
"xAxis": {
100+
"categories": x_labels,
101+
"title": {
102+
"text": "Months Since Signup",
103+
"style": {"fontSize": "28px", "fontWeight": "600", "color": "#34495e"},
104+
"margin": 20,
105+
"y": -8,
106+
},
107+
"labels": {"style": {"fontSize": "26px", "color": "#34495e"}, "y": 32},
108+
"lineWidth": 0,
109+
"tickLength": 0,
110+
"opposite": True,
111+
"offset": 30,
112+
},
113+
"yAxis": {
114+
"categories": y_labels,
115+
"title": {
116+
"text": "Signup Cohort (Users)",
117+
"style": {"fontSize": "28px", "fontWeight": "600", "color": "#34495e"},
118+
"margin": 20,
119+
},
120+
"labels": {"style": {"fontSize": "24px", "color": "#34495e"}},
121+
"reversed": False,
122+
"lineWidth": 0,
123+
"gridLineWidth": 0,
124+
},
125+
"colorAxis": {
126+
"min": 0,
127+
"max": 100,
128+
"stops": [
129+
[0, "#f7fbff"],
130+
[0.12, "#deebf7"],
131+
[0.25, "#9ecae1"],
132+
[0.40, "#4292c6"],
133+
[0.55, "#2171b5"],
134+
[0.70, "#08519c"],
135+
[0.85, "#084594"],
136+
[1, "#042a5e"],
137+
],
138+
"labels": {"style": {"fontSize": "22px", "color": "#34495e"}, "format": "{value}%"},
139+
},
140+
"legend": {
141+
"title": {"text": "Retention %", "style": {"fontSize": "24px", "fontWeight": "600", "color": "#34495e"}},
142+
"align": "right",
143+
"layout": "vertical",
144+
"verticalAlign": "middle",
145+
"symbolHeight": 700,
146+
"symbolWidth": 28,
147+
"itemStyle": {"fontSize": "20px", "color": "#34495e"},
148+
"x": -20,
149+
"margin": 20,
150+
},
151+
"tooltip": {
152+
"style": {"fontSize": "26px"},
153+
"headerFormat": "",
154+
"pointFormat": (
155+
"<b>{series.yAxis.categories.(point.y)}</b><br>{series.xAxis.categories.(point.x)}: <b>{point.value}%</b>"
156+
),
157+
},
158+
"credits": {"enabled": False},
159+
"series": [
160+
{
161+
"type": "heatmap",
162+
"name": "Retention",
163+
"data": heatmap_data,
164+
"borderWidth": 3,
165+
"borderColor": "#fafafa",
166+
"dataLabels": {"enabled": True, "style": {"fontSize": "24px", "fontWeight": "bold", "textOutline": "none"}},
167+
"nullColor": "transparent",
168+
}
169+
],
170+
}
171+
172+
# Download Highcharts JS and heatmap module
173+
js_urls = [
174+
("https://code.highcharts.com/highcharts.js", "https://cdn.jsdelivr.net/npm/highcharts@11/highcharts.js"),
175+
("https://code.highcharts.com/modules/heatmap.js", "https://cdn.jsdelivr.net/npm/highcharts@11/modules/heatmap.js"),
176+
]
177+
js_parts = []
178+
for primary, fallback in js_urls:
179+
for url in (primary, fallback):
180+
try:
181+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
182+
with urllib.request.urlopen(req, timeout=30) as response:
183+
js_parts.append(response.read().decode("utf-8"))
184+
break
185+
except Exception:
186+
continue
187+
all_js = "\n".join(js_parts)
188+
189+
# Convert options to JSON
190+
options_json = json.dumps(chart_options)
191+
192+
# Generate HTML with inline scripts and adaptive label colors
193+
html_content = f"""<!DOCTYPE html>
194+
<html>
195+
<head>
196+
<meta charset="utf-8">
197+
<script>{all_js}</script>
198+
</head>
199+
<body style="margin:0; padding:0; overflow:hidden; background:#fafafa;">
200+
<div id="container" style="width:4800px; height:2700px;"></div>
201+
<script>
202+
var opts = {options_json};
203+
// Adaptive data label colors based on cell value
204+
opts.series[0].dataLabels.formatter = function() {{
205+
var v = this.point.value;
206+
var color = v > 45 ? '#ffffff' : '#1a1a1a';
207+
return '<span style="color:' + color + ';font-size:24px;font-weight:bold">' + v.toFixed(1) + '%</span>';
208+
}};
209+
opts.series[0].dataLabels.useHTML = true;
210+
// Highlight best/worst cohort y-axis labels
211+
opts.yAxis.labels.formatter = function() {{
212+
var val = this.value;
213+
if (this.pos === {best_cohort_idx}) {{
214+
return '<span style="color:#084594;font-weight:bold;font-size:26px">\u2605 ' + val + '</span>';
215+
}} else if (this.pos === {worst_cohort_idx}) {{
216+
return '<span style="color:#c0392b;font-size:24px">' + val + '</span>';
217+
}}
218+
return '<span style="font-size:24px">' + val + '</span>';
219+
}};
220+
opts.yAxis.labels.useHTML = true;
221+
// Add plotBands for best/worst cohort rows
222+
opts.yAxis.plotBands = [
223+
{{from: {best_cohort_idx} - 0.5, to: {best_cohort_idx} + 0.5, color: 'rgba(8,69,148,0.06)'}},
224+
{{from: {worst_cohort_idx} - 0.5, to: {worst_cohort_idx} + 0.5, color: 'rgba(192,57,43,0.06)'}}
225+
];
226+
Highcharts.chart('container', opts);
227+
</script>
228+
</body>
229+
</html>"""
230+
231+
# Save HTML for interactive version
232+
with open("plot.html", "w", encoding="utf-8") as f:
233+
f.write(html_content)
234+
235+
# Take screenshot using headless Chrome
236+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
237+
f.write(html_content)
238+
temp_path = f.name
239+
240+
chrome_options = Options()
241+
chrome_options.add_argument("--headless=new")
242+
chrome_options.add_argument("--no-sandbox")
243+
chrome_options.add_argument("--disable-dev-shm-usage")
244+
chrome_options.add_argument("--disable-gpu")
245+
chrome_options.add_argument("--window-size=4800,2840")
246+
chrome_options.add_argument("--force-device-scale-factor=1")
247+
chrome_options.add_argument("--hide-scrollbars")
248+
249+
driver = webdriver.Chrome(options=chrome_options)
250+
driver.set_window_size(4800, 2840)
251+
driver.get(f"file://{temp_path}")
252+
time.sleep(5)
253+
driver.save_screenshot("plot.png")
254+
driver.quit()
255+
256+
Path(temp_path).unlink()

0 commit comments

Comments
 (0)