{"spec_id":"line-retention-cohort","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nline-retention-cohort: User Retention Curve by Cohort\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 86/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\nimport sys\n\n\n# Script filename shadows the installed `pygal` package when run as `python pygal.py`;\n# dropping the script directory from sys.path lets the real package resolve.\nsys.path.pop(0)\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens — Imprint palette chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette — canonical order, position 1 = brand green\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n# 5 cohort series use positions 1-5; threshold reference line gets muted neutral\nSERIES_COLORS = IMPRINT_PALETTE[:5] + (INK_MUTED,)\n\n# Data — Monthly signup cohorts tracked weekly for 12 weeks\nnp.random.seed(42)\n\ncohorts = {\n    \"Jan 2025\": {\"size\": 1245, \"decay\": 0.18},\n    \"Feb 2025\": {\"size\": 1102, \"decay\": 0.16},\n    \"Mar 2025\": {\"size\": 1380, \"decay\": 0.14},\n    \"Apr 2025\": {\"size\": 1467, \"decay\": 0.12},\n    \"May 2025\": {\"size\": 1590, \"decay\": 0.11},\n}\n\nweeks = list(range(13))\nretention_data = {}\nfor cohort, params in cohorts.items():\n    retention = [100.0]\n    for week in range(1, 13):\n        noise = np.random.normal(0, 1.5)\n        prev = retention[-1]\n        drop = prev * params[\"decay\"] * (1 / (1 + 0.1 * week)) + noise\n        retention.append(max(round(prev - max(drop, 0.5), 1), 5.0))\n    retention_data[cohort] = retention\n\n# Title — 51 chars, within 67-char baseline so no font scaling needed\ntitle = \"line-retention-cohort · python · pygal · anyplot.ai\"\n\n# Style — theme-adaptive Imprint palette tokens\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=SERIES_COLORS,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    opacity=\".95\",\n    opacity_hover=\"1\",\n    stroke_width=3,\n    font_family=\"'Segoe UI', 'Helvetica Neue', Arial, sans-serif\",\n    title_font_family=\"'Segoe UI', 'Helvetica Neue', Arial, sans-serif\",\n    legend_font_family=\"'Segoe UI', 'Helvetica Neue', Arial, sans-serif\",\n    label_font_family=\"'Segoe UI', 'Helvetica Neue', Arial, sans-serif\",\n    major_label_font_family=\"'Segoe UI', 'Helvetica Neue', Arial, sans-serif\",\n    value_font_family=\"'Segoe UI', 'Helvetica Neue', Arial, sans-serif\",\n)\n\n# Plot\nchart = pygal.Line(\n    width=3200,\n    height=1800,\n    title=title,\n    x_title=\"Weeks Since Signup\",\n    y_title=\"Retained Users (%)\",\n    style=custom_style,\n    show_dots=True,\n    dots_size=6,\n    stroke_style={\"width\": 4},\n    show_y_guides=True,\n    show_x_guides=False,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=3,\n    legend_box_size=28,\n    truncate_legend=-1,\n    range=(0, 102),\n    x_label_rotation=0,\n    value_formatter=lambda x: f\"{x:.0f}%\" if x is not None else \"\",\n    tooltip_fancy_mode=True,\n    tooltip_border_radius=8,\n    interpolate=\"cubic\",\n    show_minor_x_labels=False,\n    y_labels=[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],\n    margin_top=50,\n    margin_bottom=50,\n    margin_left=30,\n    margin_right=30,\n    spacing=25,\n    print_values=False,\n    dynamic_print_values=True,\n    no_data_text=\"No data available\",\n)\n\nchart.x_labels = [str(w) for w in weeks]\nchart.x_labels_major = [\"0\", \"3\", \"6\", \"9\", \"12\"]\n\n# Add cohorts: oldest=thinnest/smaller dots, newest=thickest/larger for visual emphasis\nstroke_widths = [4, 5, 6, 7, 8.5]\ndot_sizes = [5, 6, 7, 8, 10]\ncohort_list = list(cohorts.items())\n\nfor i, (cohort, params) in enumerate(cohort_list):\n    label = f\"{cohort} (n={params['size']:,})\"\n    values = retention_data[cohort]\n    chart.add(\n        label,\n        [\n            {\"value\": v, \"label\": f\"Week {w}: {v:.1f}% retained ({int(params['size'] * v / 100):,} users)\"}\n            for w, v in zip(weeks, values, strict=True)\n        ],\n        stroke_style={\"width\": stroke_widths[i], \"linecap\": \"round\", \"linejoin\": \"round\"},\n        dots_size=dot_sizes[i],\n        allow_interruptions=False,\n    )\n\n# Reference threshold at 20% retention (gets muted neutral — 6th color in SERIES_COLORS)\n# Dash-dot pattern \"28, 8, 4, 8\" is visually distinct from pygal's Y-guide dashes in light mode\nchart.add(\n    \"─ 20% Retention Threshold\",\n    [{\"value\": 20.0, \"label\": \"Target: 20% retention benchmark\"}] * len(weeks),\n    stroke_style={\"width\": 5.5, \"dasharray\": \"28, 8, 4, 8\", \"linecap\": \"round\"},\n    show_dots=False,\n    dots_size=0,\n    allow_interruptions=False,\n)\n\n# Save PNG and interactive HTML\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}