{"spec_id":"line-retention-cohort","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nline-retention-cohort: User Retention Curve by Cohort\nLibrary: altair 6.2.1 | Python 3.13.14\nQuality: 93/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file from shadowing the installed altair package\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _this_dir]\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens\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 for five cohorts\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data — monthly signup cohorts tracked weekly for 12 weeks\nnp.random.seed(42)\n\ncohorts = {\n    \"Jan 2025\": {\"size\": 1245, \"half_life\": 3.5},\n    \"Feb 2025\": {\"size\": 1102, \"half_life\": 4.0},\n    \"Mar 2025\": {\"size\": 1380, \"half_life\": 4.8},\n    \"Apr 2025\": {\"size\": 1510, \"half_life\": 5.5},\n    \"May 2025\": {\"size\": 1423, \"half_life\": 6.2},\n}\n\nweeks = np.arange(0, 13)\nrows = []\nfor i, (cohort_label, info) in enumerate(cohorts.items()):\n    retention = 100 * np.exp(-weeks / info[\"half_life\"])\n    noise = np.concatenate([[0], np.cumsum(np.random.randn(12) * 1.5)])\n    retention = np.clip(retention + noise, 5, 100)\n    retention[0] = 100.0\n    legend_label = f\"{cohort_label} (n={info['size']:,})\"\n    for w, r in zip(weeks, retention, strict=True):\n        rows.append({\"Week\": w, \"Retention (%)\": round(r, 1), \"Cohort\": legend_label, \"order\": i})\n\ndf = pd.DataFrame(rows)\n\ncohort_labels = list(df[\"Cohort\"].unique())\norder_domain = list(range(5))\nopacity_range = [0.60, 0.70, 0.80, 0.90, 1.0]\nwidth_range = [1.8, 2.4, 3.0, 3.6, 4.2]\nsize_range = [60, 90, 120, 150, 180]\n\n# Interactive hover highlight\nhighlight = alt.selection_point(fields=[\"Cohort\"], on=\"pointerover\", empty=False)\n\n# Reference line at 20% retention threshold\nthreshold_df = pd.DataFrame({\"y\": [20]})\nthreshold = alt.Chart(threshold_df).mark_rule(strokeDash=[8, 6], strokeWidth=2, color=INK_MUTED).encode(y=\"y:Q\")\nthreshold_label = (\n    alt.Chart(threshold_df)\n    .mark_text(text=\"20% Target\", align=\"left\", dx=5, dy=-12, fontSize=13, fontWeight=\"bold\", color=INK_MUTED)\n    .encode(x=alt.value(20), y=\"y:Q\")\n)\n\n# Axis encodings\nx_enc = alt.X(\"Week:Q\", title=\"Weeks Since Signup\", scale=alt.Scale(domain=[0, 12]), axis=alt.Axis(tickMinStep=1))\ny_enc = alt.Y(\"Retention (%):Q\", title=\"Retention (%)\", scale=alt.Scale(domain=[0, 100]), axis=alt.Axis(format=\".0f\"))\ncolor_enc = alt.Color(\n    \"Cohort:N\",\n    scale=alt.Scale(domain=cohort_labels, range=IMPRINT_PALETTE),\n    sort=cohort_labels,\n    legend=alt.Legend(title=\"Cohort\", symbolStrokeWidth=3, symbolSize=150),\n)\n\n# Lines with graduated width and opacity — newer cohorts thicker and more opaque\nlines = (\n    alt.Chart(df)\n    .mark_line()\n    .encode(\n        x=x_enc,\n        y=y_enc,\n        color=color_enc,\n        strokeWidth=alt.condition(\n            highlight,\n            alt.value(6),\n            alt.StrokeWidth(\"order:O\", scale=alt.Scale(domain=order_domain, range=width_range), legend=None),\n        ),\n        opacity=alt.condition(\n            highlight,\n            alt.value(1.0),\n            alt.Opacity(\"order:O\", scale=alt.Scale(domain=order_domain, range=opacity_range), legend=None),\n        ),\n        detail=\"Cohort:N\",\n        tooltip=[\"Cohort:N\", \"Week:Q\", \"Retention (%):Q\"],\n    )\n    .add_params(highlight)\n)\n\n# Points with graduated size + distinct shapes for CVD accessibility\nshape_range = [\"circle\", \"square\", \"cross\", \"diamond\", \"triangle-up\"]\npoints = (\n    alt.Chart(df)\n    .mark_point(filled=True)\n    .encode(\n        x=\"Week:Q\",\n        y=\"Retention (%):Q\",\n        color=alt.Color(\"Cohort:N\", scale=alt.Scale(domain=cohort_labels, range=IMPRINT_PALETTE), legend=None),\n        shape=alt.Shape(\"Cohort:N\", scale=alt.Scale(domain=cohort_labels, range=shape_range), legend=None),\n        opacity=alt.condition(\n            highlight,\n            alt.value(1.0),\n            alt.Opacity(\"order:O\", scale=alt.Scale(domain=order_domain, range=opacity_range), legend=None),\n        ),\n        size=alt.condition(\n            highlight,\n            alt.value(200),\n            alt.Size(\"order:O\", scale=alt.Scale(domain=order_domain, range=size_range), legend=None),\n        ),\n        tooltip=[\"Cohort:N\", \"Week:Q\", \"Retention (%):Q\"],\n    )\n)\n\ntitle_str = \"line-retention-cohort · python · altair · anyplot.ai\"\nchart = (\n    alt.layer(threshold, threshold_label, lines, points)\n    .properties(\n        width=607,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            title_str,\n            fontSize=16,\n            fontWeight=\"bold\",\n            color=INK,\n            subtitle=\"Newer cohorts retain better — product improvements are working\",\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(\n        domain=False,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=12,\n    )\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=10,\n    )\n    .configure_title(color=INK)\n)\n\n# Save — landscape canvas target: 3200 × 1800\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}