{"spec_id":"line-retention-cohort","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nline-retention-cohort: User Retention Curve by Cohort\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-06-20\n\"\"\"\n\nimport io\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, Legend, Span\nfrom bokeh.plotting import figure\nfrom PIL import Image\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette — positions 1-5\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data — wider decay spread (0.22 → 0.06) so curves diverge clearly at week 12\nnp.random.seed(42)\nweeks = np.arange(0, 13)\n\ncohorts = {\n    \"Jan 2025\": {\"size\": 1245, \"decay\": 0.22},\n    \"Feb 2025\": {\"size\": 1380, \"decay\": 0.17},\n    \"Mar 2025\": {\"size\": 1520, \"decay\": 0.13},\n    \"Apr 2025\": {\"size\": 1410, \"decay\": 0.09},\n    \"May 2025\": {\"size\": 1680, \"decay\": 0.06},\n}\n\nretention_data = {}\nfor cohort, params in cohorts.items():\n    base = 100 * np.exp(-params[\"decay\"] * weeks)\n    noise = np.random.normal(0, 1.2, len(weeks))\n    retention = np.clip(base + noise, 0, 100)\n    retention[0] = 100.0\n    retention_data[cohort] = retention\n\n# Title — 52 chars, under the 67-char baseline; no scaling needed\ntitle = \"line-retention-cohort · python · bokeh · anyplot.ai\"\n\n# Plot — 3200×1800 landscape canvas, toolbar hidden for static PNG\np = figure(\n    width=3200,\n    height=1800,\n    title=title,\n    x_axis_label=\"Weeks Since Signup\",\n    y_axis_label=\"Retention Rate (%)\",\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=130,\n    min_border_right=60,\n)\n\n# Progressive visual weight: older cohorts are thinner and more transparent\nline_widths = [2.5, 3.0, 3.5, 4.5, 5.5]\nalphas = [0.55, 0.65, 0.75, 0.88, 1.0]\ndash_patterns = [\"dashed\", \"dashed\", \"solid\", \"solid\", \"solid\"]\n\nlegend_items = []\nfor i, (cohort, params) in enumerate(cohorts.items()):\n    source = ColumnDataSource(\n        data={\n            \"week\": weeks,\n            \"retention\": retention_data[cohort],\n            \"cohort\": [cohort] * len(weeks),\n            \"size\": [params[\"size\"]] * len(weeks),\n            \"retention_fmt\": [f\"{r:.1f}\" for r in retention_data[cohort]],\n        }\n    )\n    label = f\"{cohort} (n={params['size']:,})\"\n    color = IMPRINT_PALETTE[i]\n\n    line = p.line(\n        x=\"week\",\n        y=\"retention\",\n        source=source,\n        line_width=line_widths[i],\n        line_color=color,\n        line_alpha=alphas[i],\n        line_dash=dash_patterns[i],\n    )\n    scatter = p.scatter(\n        x=\"week\",\n        y=\"retention\",\n        source=source,\n        size=9 + i * 2,\n        fill_color=color,\n        fill_alpha=alphas[i],\n        line_color=PAGE_BG,\n        line_width=2,\n    )\n    legend_items.append((label, [line, scatter]))\n\n# HoverTool for interactive HTML\nhover = HoverTool(\n    tooltips=[(\"Cohort\", \"@cohort\"), (\"Week\", \"@week\"), (\"Retention\", \"@retention_fmt%\"), (\"Cohort Size\", \"@size{,}\")],\n    mode=\"mouse\",\n)\np.add_tools(hover)\n\n# Reference line at 20% retention threshold\nthreshold = Span(\n    location=20, dimension=\"width\", line_color=INK_MUTED, line_dash=\"dashed\", line_width=2.5, line_alpha=0.75\n)\np.add_layout(threshold)\n\nthreshold_label = Label(\n    x=11.8, y=20, text=\"20% threshold\", text_font_size=\"26pt\", text_color=INK_MUTED, y_offset=10, text_align=\"right\"\n)\np.add_layout(threshold_label)\n\n# Legend — larger text for canvas readability\nlegend = Legend(items=legend_items, location=\"top_right\")\nlegend.label_text_font_size = \"34pt\"\nlegend.label_text_color = INK_SOFT\nlegend.glyph_height = 36\nlegend.glyph_width = 36\nlegend.spacing = 14\nlegend.padding = 24\nlegend.background_fill_color = ELEVATED_BG\nlegend.background_fill_alpha = 0.92\nlegend.border_line_color = INK_SOFT\nlegend.border_line_alpha = 0.3\np.add_layout(legend)\n\n# Axis ranges\np.y_range.start = 0\np.y_range.end = 105\np.x_range.start = -0.3\np.x_range.end = 12.3\n\n# Theme-adaptive chrome\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_font_style = \"normal\"\np.yaxis.axis_label_text_font_style = \"normal\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\n\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\np.xgrid.grid_line_alpha = 0\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.12\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None  # remove box frame — L-shape via axes only\n\np.axis.axis_line_width = 2\n\n# Save HTML (no toolbar) for screenshot\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot via headless Chrome — use oversized viewport then crop to exact canvas\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H + 200}\",  # extra height absorbs any browser chrome overhead\n    \"--hide-scrollbars\",\n    \"--force-device-scale-factor=1\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H + 200)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\nraw_png = driver.get_screenshot_as_png()\ndriver.quit()\nimg = Image.open(io.BytesIO(raw_png)).crop((0, 0, W, H))\nimg.save(f\"plot-{THEME}.png\")\n\n# Re-save HTML with toolbar for the interactive catalog artifact\np.toolbar_location = \"above\"\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n"}