|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | heatmap-cohort-retention: Cohort Retention Heatmap |
3 | 3 | Library: altair 6.0.0 | Python 3.14.3 |
4 | 4 | Quality: 86/100 | Created: 2026-03-16 |
|
32 | 32 | rows = [] |
33 | 33 | for i, cohort in enumerate(cohort_labels): |
34 | 34 | max_periods = n_cohorts - i |
35 | | - base_retention = 100.0 |
36 | 35 | for period in range(max_periods): |
37 | 36 | if period == 0: |
38 | 37 | retention = 100.0 |
|
59 | 58 | cohort_order = [f"{c} (n={s:,})" for c, s in zip(cohort_labels, cohort_sizes, strict=True)] |
60 | 59 | period_order = [f"Month {p}" for p in range(n_periods)] |
61 | 60 |
|
| 61 | +# Custom dark teal-to-gold diverging-inspired sequential palette for sophistication |
| 62 | +color_domain = [0, 20, 40, 60, 80, 100] |
| 63 | +color_range = ["#f7f7f7", "#d4e8e0", "#7bc8b5", "#2a9d8f", "#264653", "#1d3557"] |
| 64 | + |
62 | 65 | # Heatmap rectangles |
63 | 66 | heatmap = ( |
64 | 67 | alt.Chart(df) |
65 | | - .mark_rect(stroke="white", strokeWidth=2.5) |
| 68 | + .mark_rect(stroke="#e8e8e8", strokeWidth=1.5, cornerRadius=3) |
66 | 69 | .encode( |
67 | 70 | x=alt.X( |
68 | 71 | "period_label:O", |
69 | 72 | title="Months Since Signup", |
70 | 73 | sort=period_order, |
71 | | - axis=alt.Axis(labelFontSize=17, titleFontSize=22, labelAngle=0), |
| 74 | + axis=alt.Axis( |
| 75 | + labelFontSize=17, |
| 76 | + titleFontSize=22, |
| 77 | + titleFontWeight="bold", |
| 78 | + labelAngle=0, |
| 79 | + domainWidth=0, |
| 80 | + tickWidth=0, |
| 81 | + titlePadding=16, |
| 82 | + labelPadding=8, |
| 83 | + ), |
72 | 84 | ), |
73 | 85 | y=alt.Y( |
74 | 86 | "cohort_label:O", |
75 | 87 | title="Signup Cohort", |
76 | 88 | sort=cohort_order, |
77 | | - axis=alt.Axis(labelFontSize=15, titleFontSize=22), |
| 89 | + axis=alt.Axis( |
| 90 | + labelFontSize=17, |
| 91 | + titleFontSize=22, |
| 92 | + titleFontWeight="bold", |
| 93 | + domainWidth=0, |
| 94 | + tickWidth=0, |
| 95 | + titlePadding=16, |
| 96 | + labelPadding=8, |
| 97 | + ), |
78 | 98 | ), |
79 | 99 | color=alt.Color( |
80 | 100 | "retention_rate:Q", |
81 | | - scale=alt.Scale(scheme="blues", domain=[0, 100]), |
82 | | - legend=alt.Legend(title="Retention %", titleFontSize=18, labelFontSize=16, gradientLength=400), |
| 101 | + scale=alt.Scale(domain=color_domain, range=color_range), |
| 102 | + legend=alt.Legend( |
| 103 | + title="Retention %", |
| 104 | + titleFontSize=18, |
| 105 | + titleFontWeight="bold", |
| 106 | + labelFontSize=16, |
| 107 | + gradientLength=400, |
| 108 | + gradientThickness=18, |
| 109 | + orient="right", |
| 110 | + offset=12, |
| 111 | + ), |
83 | 112 | ), |
| 113 | + tooltip=[ |
| 114 | + alt.Tooltip("cohort:N", title="Cohort"), |
| 115 | + alt.Tooltip("period_label:O", title="Period"), |
| 116 | + alt.Tooltip("retention_rate:Q", title="Retention %", format=".1f"), |
| 117 | + ], |
84 | 118 | ) |
85 | 119 | ) |
86 | 120 |
|
87 | | -# Text annotations inside cells |
| 121 | +# Text annotations with suffix |
88 | 122 | text = ( |
89 | 123 | alt.Chart(df) |
90 | | - .mark_text(fontSize=16, fontWeight="bold") |
| 124 | + .mark_text(fontSize=15, fontWeight="bold") |
91 | 125 | .encode( |
92 | 126 | x=alt.X("period_label:O", sort=period_order), |
93 | 127 | y=alt.Y("cohort_label:O", sort=cohort_order), |
94 | 128 | text=alt.Text("retention_rate:Q", format=".1f"), |
95 | | - color=alt.condition(alt.datum.retention_rate > 55, alt.value("white"), alt.value("#333333")), |
| 129 | + color=alt.condition(alt.datum.retention_rate > 50, alt.value("white"), alt.value("#333333")), |
| 130 | + ) |
| 131 | +) |
| 132 | + |
| 133 | +# Percent symbol as separate smaller text layer for polish |
| 134 | +pct = ( |
| 135 | + alt.Chart(df) |
| 136 | + .mark_text(fontSize=10, fontWeight="normal", dx=20) |
| 137 | + .encode( |
| 138 | + x=alt.X("period_label:O", sort=period_order), |
| 139 | + y=alt.Y("cohort_label:O", sort=cohort_order), |
| 140 | + text=alt.value("%"), |
| 141 | + color=alt.condition( |
| 142 | + alt.datum.retention_rate > 50, alt.value("rgba(255,255,255,0.7)"), alt.value("rgba(51,51,51,0.5)") |
| 143 | + ), |
96 | 144 | ) |
97 | 145 | ) |
98 | 146 |
|
99 | 147 | # Combine |
100 | 148 | chart = ( |
101 | | - (heatmap + text) |
| 149 | + alt.layer(heatmap, text, pct) |
102 | 150 | .properties( |
103 | 151 | width=1400, |
104 | 152 | height=900, |
105 | | - title=alt.Title("heatmap-cohort-retention · altair · pyplots.ai", fontSize=28, anchor="middle"), |
| 153 | + title=alt.Title( |
| 154 | + "heatmap-cohort-retention · altair · pyplots.ai", |
| 155 | + fontSize=28, |
| 156 | + fontWeight="bold", |
| 157 | + anchor="middle", |
| 158 | + subtitle="Monthly SaaS user retention — earliest cohorts show strongest long-term engagement", |
| 159 | + subtitleFontSize=18, |
| 160 | + subtitleColor="#666666", |
| 161 | + subtitlePadding=8, |
| 162 | + ), |
106 | 163 | ) |
107 | 164 | .configure_view(strokeWidth=0) |
| 165 | + .configure(padding={"left": 20, "right": 20, "top": 20, "bottom": 20}, background="#ffffff") |
| 166 | + .configure_axis(labelColor="#444444", titleColor="#333333") |
108 | 167 | ) |
109 | 168 |
|
110 | 169 | # Save |
|
0 commit comments