{"spec_id":"parallel-categories-basic","library":"makie","language":"julia","code":"# anyplot.ai\n# parallel-categories-basic: Basic Parallel Categories Plot\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 85/100 | Created: 2026-09-05\n\nusing CairoMakie\nusing Colors\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 IMPRINT_PALETTE = [\n    colorant\"#009E73\", colorant\"#C475FD\", colorant\"#4467A3\", colorant\"#BD8233\",\n    colorant\"#AE3030\", colorant\"#2ABCCD\", colorant\"#954477\", colorant\"#99B314\",\n]\n\n# --- Data: customer journey from acquisition channel to product to outcome --\nchannels = [\"Organic Search\", \"Paid Ads\", \"Referral\"]\nproducts = [\"Electronics\", \"Apparel\", \"Home Decor\"]\noutcomes = [\"Purchased\", \"Abandoned\"]\n\n# (channel_idx, product_idx, outcome_idx, session_count)\ncombos = [\n    (1, 1, 1, 58), (1, 1, 2, 142),\n    (1, 2, 1, 46), (1, 2, 2, 104),\n    (1, 3, 1, 39), (1, 3, 2, 91),\n    (2, 1, 1, 102), (2, 1, 2, 78),\n    (2, 2, 1, 61), (2, 2, 2, 89),\n    (2, 3, 1, 34), (2, 3, 2, 96),\n    (3, 1, 1, 151), (3, 1, 2, 39),\n    (3, 2, 1, 88), (3, 2, 2, 52),\n    (3, 3, 1, 63), (3, 3, 2, 47),\n]\n\nchannel_totals = [sum(c[4] for c in combos if c[1] == i) for i in 1:length(channels)]\nproduct_totals = [sum(c[4] for c in combos if c[2] == i) for i in 1:length(products)]\noutcome_totals = [sum(c[4] for c in combos if c[3] == i) for i in 1:length(outcomes)]\n\n# --- Layout: stack each dimension's categories top-to-bottom, centered ------\nconst GAP = 40.0\n\nfunction node_tops(totals)\n    total_height = sum(totals) + GAP * (length(totals) - 1)\n    tops = Float64[]\n    y = total_height / 2\n    for cnt in totals\n        push!(tops, y)\n        y -= cnt + GAP\n    end\n    return tops\nend\n\nchannel_tops = node_tops(channel_totals)\nproduct_tops = node_tops(product_totals)\noutcome_tops = node_tops(outcome_totals)\n\n# Stacks the combos touching a node top-to-bottom by `order_key`, so each\n# node edge can use an order tailored to the ribbons it feeds. A node's\n# rectangle is solid (no internal ribbon path is drawn), so its incoming\n# edge (grouped by source category) and outgoing edge (grouped by target\n# category) are free to use independent stacking orders — this is what\n# keeps same-outcome ribbons contiguous and minimizes crossings, per the\n# spec's guidance to order categories to reduce crossings.\nfunction stack_positions(n_categories, dim_idx, tops, order_key)\n    offsets = Dict{NTuple{3,Int},Tuple{Float64,Float64}}()\n    for ci in 1:n_categories\n        cat_combos = sort(filter(c -> c[dim_idx] == ci, combos); by=order_key)\n        current_top = tops[ci]\n        for c in cat_combos\n            y_bottom = current_top - c[4]\n            offsets[(c[1], c[2], c[3])] = (current_top, y_bottom)\n            current_top = y_bottom\n        end\n    end\n    return offsets\nend\n\n# Channel's only edge feeds product nodes -> order by (product, outcome).\nchannel_pos = stack_positions(length(channels), 1, channel_tops, c -> (c[2], c[3]))\n# Product's incoming edge meets the channel edge -> order by (channel, outcome).\nproduct_pos_in = stack_positions(length(products), 2, product_tops, c -> (c[1], c[3]))\n# Product's outgoing edge feeds outcome nodes -> order by (outcome, channel),\n# so purchased- and abandoned-bound ribbons leave each product as two\n# contiguous bands instead of interleaved by channel.\nproduct_pos_out = stack_positions(length(products), 2, product_tops, c -> (c[3], c[1]))\n# Outcome's only edge meets the product edge -> order by (product, channel).\noutcome_pos = stack_positions(length(outcomes), 3, outcome_tops, c -> (c[2], c[1]))\n\n# --- Figure -------------------------------------------------------------------\nfig = Figure(\n    resolution = (1600, 900),\n    fontsize = 14,\n    backgroundcolor = PAGE_BG,\n)\n\nconst X1, X2, X3 = 0.0, 1.6, 3.2\n\nax = Axis(\n    fig[1, 1];\n    title = \"parallel-categories-basic · julia · makie · anyplot.ai\",\n    titlesize = 20,\n    titlecolor = INK,\n    backgroundcolor = PAGE_BG,\n    xgridvisible = false,\n    ygridvisible = false,\n)\nhidespines!(ax)\nhidexdecorations!(ax)\nhideydecorations!(ax)\nxlims!(ax, X1 - 1.6, X3 + 1.6)\n\n# --- Ribbons: smooth bands colored by acquisition channel (source dim) ------\nconst NODE_HW = 0.09\nconst N_SAMPLES = 40\n\nsmoothstep(t) = t * t * (3 - 2t)\n\nfunction draw_ribbon!(ax, x_left, x_right, y_top_l, y_bot_l, y_top_r, y_bot_r, color)\n    xs = range(x_left, x_right; length=N_SAMPLES)\n    t = smoothstep.((xs .- x_left) ./ (x_right - x_left))\n    y_top = y_top_l .+ (y_top_r - y_top_l) .* t\n    y_bot = y_bot_l .+ (y_bot_r - y_bot_l) .* t\n    ribbon_color = RGBAf(color.r, color.g, color.b, 0.45)\n    band!(ax, xs, y_bot, y_top; color=ribbon_color)\nend\n\n# Draw the widest ribbons first so the remaining crossings — thinner ribbons\n# layered on top — stay individually readable instead of disappearing under\n# a wide band's translucent fill.\nfor c in sort(combos; by=x -> -x[4])\n    key = (c[1], c[2], c[3])\n    channel_color = IMPRINT_PALETTE[c[1]]\n\n    yt_l, yb_l = channel_pos[key]\n    yt_r, yb_r = product_pos_in[key]\n    draw_ribbon!(ax, X1 + NODE_HW, X2 - NODE_HW, yt_l, yb_l, yt_r, yb_r, channel_color)\n\n    yt_l2, yb_l2 = product_pos_out[key]\n    yt_r2, yb_r2 = outcome_pos[key]\n    draw_ribbon!(ax, X2 + NODE_HW, X3 - NODE_HW, yt_l2, yb_l2, yt_r2, yb_r2, channel_color)\nend\n\n# --- Nodes: rectangles per category, labeled with name + total count -------\nfunction draw_node!(ax, x, y_top, y_bottom, color, label, label_side)\n    poly!(\n        ax,\n        Point2f[\n            (x - NODE_HW, y_bottom), (x + NODE_HW, y_bottom),\n            (x + NODE_HW, y_top), (x - NODE_HW, y_top),\n        ];\n        color=color, strokecolor=INK_SOFT, strokewidth=1,\n    )\n    y_mid = (y_top + y_bottom) / 2\n    if label_side == :left\n        text!(ax, x - NODE_HW - 0.04, y_mid; text=label, align=(:right, :center),\n            color=INK, fontsize=14)\n    elseif label_side == :right\n        text!(ax, x + NODE_HW + 0.04, y_mid; text=label, align=(:left, :center),\n            color=INK, fontsize=14)\n    else\n        text!(ax, x, y_top + 18; text=label, align=(:center, :bottom),\n            color=INK, fontsize=14)\n    end\nend\n\nfor (i, name) in enumerate(channels)\n    y_top = channel_tops[i]\n    draw_node!(ax, X1, y_top, y_top - channel_totals[i], IMPRINT_PALETTE[i],\n        \"$(name) ($(channel_totals[i]))\", :left)\nend\n\nfor (i, name) in enumerate(products)\n    y_top = product_tops[i]\n    draw_node!(ax, X2, y_top, y_top - product_totals[i], ELEVATED_BG,\n        \"$(name) ($(product_totals[i]))\", :top)\nend\n\nfor (i, name) in enumerate(outcomes)\n    y_top = outcome_tops[i]\n    draw_node!(ax, X3, y_top, y_top - outcome_totals[i], ELEVATED_BG,\n        \"$(name) ($(outcome_totals[i]))\", :right)\nend\n\n# --- Dimension headers --------------------------------------------------------\nheader_y = maximum([channel_tops[1], product_tops[1], outcome_tops[1]]) + 70\ntext!(ax, X1, header_y; text=\"Acquisition Channel\", align=(:center, :bottom),\n    color=INK, fontsize=16)\ntext!(ax, X2, header_y; text=\"Product Category\", align=(:center, :bottom),\n    color=INK, fontsize=16)\ntext!(ax, X3, header_y; text=\"Outcome\", align=(:center, :bottom),\n    color=INK, fontsize=16)\n\n# --- Save ----------------------------------------------------------------------\nsave(\"plot-$(THEME).png\", fig; px_per_unit=2)\n"}