1- """ pyplots.ai
1+ """pyplots.ai
22heatmap-cohort-retention: Cohort Retention Heatmap
33Library: pygal 3.1.0 | Python 3.14.3
44Quality: 85/100 | Created: 2026-03-16
@@ -57,6 +57,18 @@ def _plot(self):
5757 if not self .matrix_data :
5858 return
5959
60+ # Inject CSS to suppress pygal's default focus/active/hover outlines
61+ defs_node = self .svg .node (self .svg .root , "defs" )
62+ style_node = self .svg .node (defs_node , "style" , type = "text/css" )
63+ style_node .text = (
64+ ".cell rect, .cell { outline: none !important; stroke-opacity: 1; } "
65+ ".cell:focus rect, .cell:hover rect, .cell:active rect, "
66+ ".active .cell rect, .cell .active rect "
67+ "{ outline: none !important; } "
68+ "g.cell:focus, g.cell:active { outline: none; } "
69+ ".activate-serie, .active .reactive { outline: none !important; }"
70+ )
71+
6072 n_rows = len (self .matrix_data )
6173 n_cols = max (len (row ) for row in self .matrix_data )
6274
@@ -110,7 +122,7 @@ def _plot(self):
110122
111123 # Row labels with cohort sizes
112124 row_font_size = min (36 , int (cell_height * 0.50 ))
113- size_font_size = int (row_font_size * 0.78 )
125+ size_font_size = int (row_font_size * 0.88 )
114126 for i , label in enumerate (self .row_labels ):
115127 ry = y_offset + i * (cell_height + gap ) + cell_height / 2
116128 rx = x_offset - 20
@@ -155,10 +167,12 @@ def _plot(self):
155167 cy = y_offset + i * (cell_height + gap )
156168
157169 cell_group = self .svg .node (plot_node , "g" , class_ = "cell" )
170+ cell_group .set ("style" , "outline:none;stroke:none;" )
158171 rect = self .svg .node (cell_group , "rect" , x = cx , y = cy , width = cell_width , height = cell_height , rx = 3 , ry = 3 )
159172 rect .set ("fill" , color )
160173 rect .set ("stroke" , "#ffffff" )
161174 rect .set ("stroke-width" , "2" )
175+ rect .set ("style" , "outline:none;" )
162176
163177 # Tooltip
164178 cohort_label = self .row_labels [i ] if i < len (self .row_labels ) else ""
@@ -253,9 +267,9 @@ def _compute(self):
253267cohort_sizes = [1200 , 1350 , 980 , 1520 , 1100 , 1430 , 1280 , 1050 , 1380 , 1150 ]
254268
255269# Base retention curve that decays over time
256- base_retention = np .array ([100.0 , 68 .0 , 52 .0 , 43 .0 , 37 .0 , 33 .0 , 30 .0 , 28 .0 , 26 .5 , 25 .0 ])
270+ base_retention = np .array ([100.0 , 65 .0 , 48 .0 , 40 .0 , 34 .0 , 30 .0 , 27 .0 , 25 .0 , 23 .5 , 22 .0 ])
257271
258- # Build triangular retention matrix
272+ # Build triangular retention matrix with visible cohort variation
259273matrix = []
260274for i in range (n_cohorts ):
261275 n_periods = n_max_periods - i
@@ -264,9 +278,12 @@ def _compute(self):
264278 if j == 0 :
265279 row .append (100.0 )
266280 else :
267- # Add cohort-specific variation — later cohorts slightly better retention
268- improvement = i * 0.8
269- noise = np .random .uniform (- 2.5 , 2.5 )
281+ # Later cohorts show progressively better retention (product improvements)
282+ improvement = i * 1.8
283+ # Apr 2024 (i=3) had a bad onboarding change — worse retention
284+ if i == 3 :
285+ improvement = - 4.0
286+ noise = np .random .uniform (- 2.0 , 2.0 )
270287 val = base_retention [j ] + improvement + noise
271288 val = max (5.0 , min (100.0 , val ))
272289 row .append (round (val , 1 ))
0 commit comments