{"spec_id":"alluvial-opinion-flow","library":"makie","language":"julia","code":"# anyplot.ai\n# alluvial-opinion-flow: Opinion Flow Diagram\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 90/100 | Created: 2026-08-24\n\nusing CairoMakie\nusing Colors\nusing Random\n\n# Theme tokens (see prompts/default-style-guide.md \"Background\" + \"Theme-adaptive Chrome\")\nTHEME    = get(ENV, \"ANYPLOT_THEME\", \"light\")\nPAGE_BG  = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nINK      = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nINK_SOFT = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\n\n# Imprint categorical palette — canonical order, position 1 always brand green\nIMPRINT_PALETTE = [\n    colorant\"#009E73\",  # 1 — brand green\n    colorant\"#C475FD\",  # 2 — lavender\n    colorant\"#4467A3\",  # 3 — blue\n    colorant\"#BD8233\",  # 4 — ochre\n    colorant\"#AE3030\",  # 5 — matte red\n]\n\n# Data — quarterly political opinion survey, 1000 respondents tracked across 4 waves\nRandom.seed!(42)\n\nwaves      = [\"Q1 2025\", \"Q2 2025\", \"Q3 2025\", \"Q4 2025\"]\ncategories = [\"Strongly Agree\", \"Agree\", \"Neutral\", \"Disagree\", \"Strongly Disagree\"]\nn_wave     = length(waves)\nn_cat      = length(categories)\n\nwave_counts    = Vector{Vector{Int}}(undef, n_wave)\nwave_counts[1] = [230, 260, 220, 180, 110]\n\n# Diagonal-heavy transition matrix: most respondents stay put, some drift to\n# neighboring categories, few make large jumps — a realistic opinion-survey pattern.\nbase_transition = [\n    0.78 0.14 0.05 0.02 0.01\n    0.10 0.70 0.14 0.05 0.01\n    0.03 0.12 0.68 0.12 0.05\n    0.01 0.05 0.14 0.68 0.12\n    0.01 0.02 0.05 0.14 0.78\n]\n\nflow_counts = Vector{Matrix{Int}}(undef, n_wave - 1)\n\nfor w in 1:(n_wave - 1)\n    noise      = 0.03 .* (rand(n_cat, n_cat) .- 0.5)\n    transition = max.(base_transition .+ noise, 0.0)\n    transition = transition ./ sum(transition; dims = 2)\n\n    flows = zeros(Int, n_cat, n_cat)\n    for s in 1:n_cat\n        remaining = wave_counts[w][s]\n        for t in 1:(n_cat - 1)\n            take        = min(round(Int, wave_counts[w][s] * transition[s, t]), remaining)\n            flows[s, t] = take\n            remaining  -= take\n        end\n        flows[s, n_cat] = remaining\n    end\n    flow_counts[w] = flows\n\n    next_counts = [sum(flows[:, t]) for t in 1:n_cat]\n    wave_counts[w + 1] = next_counts\nend\n\n# Layout — stack category nodes per wave column; node_gap reserves whitespace\n# above each node for its respondent-count label.\nnode_gap    = 70.0\nx_positions = collect(1.0:n_wave)\nhalf_width  = 0.09\n\nnode_tops    = Matrix{Float64}(undef, n_wave, n_cat)\nnode_bottoms = Matrix{Float64}(undef, n_wave, n_cat)\n\nfor w in 1:n_wave\n    y_cursor = sum(wave_counts[w]) + node_gap * (n_cat - 1)\n    for c in 1:n_cat\n        node_tops[w, c]    = y_cursor\n        node_bottoms[w, c] = y_cursor - wave_counts[w][c]\n        y_cursor          -= wave_counts[w][c] + node_gap\n    end\nend\n\nmax_top  = maximum(node_tops)\nheader_y = max_top + 65.0\n\n# Plot — see default-style-guide.md \"Visual Sizing Defaults\" for the canvas + sizing values\ntitle_str      = \"Political Opinion Survey · alluvial-opinion-flow · julia · makie · anyplot.ai\"\ntitle_default  = 20\ntitle_floor    = 14\ntitle_ratio    = length(title_str) > 67 ? 67 / length(title_str) : 1.0\ntitle_fontsize = max(title_floor, round(Int, title_default * title_ratio))\n\nfig = Figure(\n    size            = (1600, 900),\n    fontsize        = 14,\n    backgroundcolor = PAGE_BG,\n)\n\nax = Axis(\n    fig[1, 1];\n    title               = title_str,\n    titlesize           = title_fontsize,\n    titlecolor          = INK,\n    backgroundcolor     = PAGE_BG,\n    xticksvisible       = false,\n    xticklabelsvisible  = false,\n    yticksvisible       = false,\n    yticklabelsvisible  = false,\n    topspinevisible     = false,\n    rightspinevisible   = false,\n    leftspinevisible    = false,\n    bottomspinevisible  = false,\n    xgridvisible        = false,\n    ygridvisible        = false,\n)\n\nxlims!(ax, x_positions[1] - 0.85, x_positions[end] + 0.85)\nylims!(ax, -25.0, max_top + 110.0)\n\n# Flows — smoothstep ribbons; opacity distinguishes stable respondents (same\n# category both waves) from changers, per spec.\nfor w in 1:(n_wave - 1)\n    left_cursor  = copy(node_tops[w, :])\n    right_cursor = copy(node_tops[w + 1, :])\n\n    for s in 1:n_cat, t in 1:n_cat\n        count = flow_counts[w][s, t]\n        count == 0 && continue\n\n        y0_top = left_cursor[s]\n        y0_bot = y0_top - count\n        left_cursor[s] = y0_bot\n\n        y1_top = right_cursor[t]\n        y1_bot = y1_top - count\n        right_cursor[t] = y1_bot\n\n        x0   = x_positions[w] + half_width\n        x1   = x_positions[w + 1] - half_width\n        xs   = range(x0, x1; length = 40)\n        ts   = range(0.0, 1.0; length = 40)\n        ease = @. ts^2 * (3 - 2 * ts)\n\n        top_curve = y0_top .+ (y1_top - y0_top) .* ease\n        bot_curve = y0_bot .+ (y1_bot - y0_bot) .* ease\n\n        flow_alpha = s == t ? 0.80 : 0.30\n        band!(ax, xs, bot_curve, top_curve; color = (IMPRINT_PALETTE[s], flow_alpha))\n    end\nend\n\n# Nodes — solid bars sized by respondent count, drawn above the flow ends.\nfor w in 1:n_wave, c in 1:n_cat\n    x0 = x_positions[w] - half_width\n    x1 = x_positions[w] + half_width\n    poly!(\n        ax,\n        Rect2f(x0, node_bottoms[w, c], x1 - x0, node_tops[w, c] - node_bottoms[w, c]);\n        color = IMPRINT_PALETTE[c],\n        strokewidth = 0,\n    )\n    text!(\n        ax, x_positions[w], node_tops[w, c] + 8.0;\n        text = string(wave_counts[w][c]),\n        align = (:center, :bottom),\n        fontsize = 12,\n        color = INK_SOFT,\n    )\nend\n\n# Column headers — one per wave, above the tallest column.\nfor w in 1:n_wave\n    text!(\n        ax, x_positions[w], header_y;\n        text = waves[w],\n        align = (:center, :bottom),\n        fontsize = 16,\n        color = INK,\n        font = :bold,\n    )\nend\n\n# Category names — labeled once on each outer column to avoid mid-chart clutter;\n# the Imprint color already carries the identity across the interior waves.\nfor c in 1:n_cat\n    y_mid_left = (node_tops[1, c] + node_bottoms[1, c]) / 2\n    text!(\n        ax, x_positions[1] - half_width - 0.06, y_mid_left;\n        text = categories[c],\n        align = (:right, :center),\n        fontsize = 13,\n        color = INK,\n    )\n\n    y_mid_right = (node_tops[n_wave, c] + node_bottoms[n_wave, c]) / 2\n    text!(\n        ax, x_positions[n_wave] + half_width + 0.06, y_mid_right;\n        text = categories[c],\n        align = (:left, :center),\n        fontsize = 13,\n        color = INK,\n    )\nend\n\n# Save\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}