{"spec_id":"heatmap-cohort-retention","library":"makie","language":"julia","code":"# anyplot.ai\n# heatmap-cohort-retention: Cohort Retention Heatmap\n# Library: makie 0.22.10 | Julia 1.11.9\n# Quality: 87/100 | Created: 2026-06-20\n\nusing CairoMakie\nusing Colors\nusing ColorSchemes\nusing Random\n\nRandom.seed!(42)\n\n# Theme tokens\nconst THEME       = get(ENV, \"ANYPLOT_THEME\", \"light\")\nconst PAGE_BG     = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nconst ELEVATED_BG = THEME == \"light\" ? colorant\"#FFFDF6\" : colorant\"#242420\"\nconst INK         = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nconst INK_SOFT    = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\nconst INK_MUTED   = THEME == \"light\" ? colorant\"#6B6A63\" : colorant\"#A8A79F\"\n\n# Imprint sequential colormap for single-polarity continuous data (low → high retention)\nconst ANYPLOT_SEQ = cgrad([colorant\"#009E73\", colorant\"#4467A3\"])\n\n# Data — monthly SaaS cohort retention, Jan–Dec 2024\nconst cohort_months = [\"Jan 2024\", \"Feb 2024\", \"Mar 2024\", \"Apr 2024\",\n                       \"May 2024\", \"Jun 2024\", \"Jul 2024\", \"Aug 2024\",\n                       \"Sep 2024\", \"Oct 2024\", \"Nov 2024\", \"Dec 2024\"]\nconst n_cohorts = 12\nconst n_periods = 12\nconst cohort_sizes  = [2840, 3120, 2950, 3350, 3180, 2760, 2990, 3410, 3050, 2820, 3200, 3680]\nconst base_rates    = [1.00, 0.68, 0.52, 0.42, 0.35, 0.30, 0.26, 0.23, 0.20, 0.18, 0.16, 0.15]\nconst cohort_quality = [1.00, 0.97, 1.03, 0.98, 1.05, 0.99, 1.02, 0.96, 1.04, 1.01, 0.98, 1.03]\n\n# retention_data[period_idx, cohort_idx]; NaN for future periods (triangular shape)\nretention_data = fill(NaN32, n_periods, n_cohorts)\nfor c in 1:n_cohorts\n    for p in 0:(n_cohorts - c)\n        pi = p + 1\n        if p == 0\n            retention_data[pi, c] = 100.0f0\n        else\n            v = base_rates[pi] * cohort_quality[c] + randn() * 0.015\n            retention_data[pi, c] = Float32(clamp(v * 100.0, 5.0, 99.9))\n        end\n    end\nend\n\n# Tick labels\ny_labels = [\"$(cohort_months[c]) ($(cohort_sizes[c]))\" for c in 1:n_cohorts]\nx_labels = [\"Month $(p-1)\" for p in 1:n_periods]\n\n# Plot — square canvas → 2400 × 2400 px output\nfig = Figure(\n    size            = (1200, 1200),\n    fontsize        = 14,\n    backgroundcolor = PAGE_BG,\n)\n\nax = Axis(\n    fig[1, 1];\n    title               = \"heatmap-cohort-retention · julia · makie · anyplot.ai\",\n    titlesize           = 20,\n    titlecolor          = INK,\n    xlabel              = \"Months Since Signup\",\n    xlabelcolor         = INK,\n    xlabelsize          = 14,\n    ylabel              = \"Signup Cohort\",\n    ylabelcolor         = INK,\n    ylabelsize          = 14,\n    xticklabelsize      = 10,\n    yticklabelsize      = 10,\n    xticklabelcolor     = INK_SOFT,\n    yticklabelcolor     = INK_SOFT,\n    xtickcolor          = PAGE_BG,\n    ytickcolor          = PAGE_BG,\n    xticklabelrotation  = π / 4,\n    backgroundcolor     = PAGE_BG,\n    topspinevisible     = true,\n    rightspinevisible   = true,\n    topspinecolor       = INK_SOFT,\n    rightspinecolor     = INK_SOFT,\n    leftspinecolor      = INK_SOFT,\n    bottomspinecolor    = INK_SOFT,\n    xgridvisible        = false,\n    ygridvisible        = false,\n    yreversed           = true,\n    xticks              = (1:n_periods, x_labels),\n    yticks              = (1:n_cohorts, y_labels),\n)\n\n# Heatmap — NaN cells rendered in PAGE_BG (triangular cutout)\nhm = heatmap!(ax, 1:n_periods, 1:n_cohorts, retention_data;\n    colormap   = ANYPLOT_SEQ,\n    colorrange = (0.0f0, 100.0f0),\n    nan_color  = PAGE_BG,\n)\n\n# Text annotations — batch all valid cells into one text! call\n# Use luminance-adaptive text colors: dark ink on lighter cells, white on darker cells\nfunction cell_text_color(v)\n    t = clamp(v / 100.0, 0.0, 1.0)\n    bg = get(ANYPLOT_SEQ, t)\n    to_linear(c) = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055)^2.4\n    lum = 0.2126 * to_linear(red(bg)) + 0.7152 * to_linear(green(bg)) + 0.0722 * to_linear(blue(bg))\n    return lum > 0.179 ? INK : colorant\"#FFFFFF\"\nend\n\ntext_xs     = Float64[]\ntext_ys     = Float64[]\ntext_strs   = String[]\ntext_colors = RGBAf[]\nfor c in 1:n_cohorts, p in 1:n_periods\n    v = retention_data[p, c]\n    isnan(v) && continue\n    push!(text_xs,     Float64(p))\n    push!(text_ys,     Float64(c))\n    push!(text_strs,   \"$(round(Int, v))%\")\n    push!(text_colors, RGBAf(cell_text_color(v)))\nend\n\ntext!(ax, text_xs, text_ys;\n    text     = text_strs,\n    color    = text_colors,\n    align    = (:center, :center),\n    fontsize = 10,\n)\n\n# Colorbar\nColorbar(fig[1, 2], hm;\n    label          = \"Retention Rate (%)\",\n    labelcolor     = INK,\n    labelsize      = 12,\n    ticklabelcolor = INK_SOFT,\n    ticklabelsize  = 10,\n    tickcolor      = INK_SOFT,\n    width          = 22,\n)\n\ncolgap!(fig.layout, 8)\n\n# Save\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}