{"spec_id":"bubble-basic","library":"makie","language":"julia","code":"# anyplot.ai\n# bubble-basic: Basic Bubble Chart\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 93/100 | Updated: 2026-09-27\n\nusing CairoMakie\nusing Colors\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\"\nconst IMPRINT_PALETTE = [colorant\"#009E73\", colorant\"#C475FD\", colorant\"#4467A3\"]\n\n# Data — film portfolio with a visible narrative:\n#   production budget mildly correlates with critic rating, but box-office\n#   revenue peaks disproportionately for mid-to-high budget films (~$80-150M\n#   \"franchise sweet spot\") before diminishing returns set in on ultra-high\n#   budget tentpoles.\nn           = 90\nbudget_norm = rand(n) .^ 1.4                       # skewed toward low end — more indie\n                                                    # films than tentpoles, as in real slates\nbudget      = 5.0 .+ 245.0 .* budget_norm          # $5M-$250M production budget\n\n# Rating rises mildly with budget (r ≈ 0.5): bigger productions afford more polish.\n# Slightly wider jitter than a plain 0.9 stddev spreads out the high-budget\n# cluster so blockbuster-tier bubbles don't stack as tightly.\nrating = clamp.(5.0 .+ 2.5 .* budget_norm .+ 1.15 .* randn(n), 1.5, 9.8)\n\n# Box office scales with budget but gets an ROI boost near the $80-150M zone\nsweet_spot = (budget .- 115.0) ./ 70.0\nbox_office = clamp.(\n    0.8 .* budget .* (1.0 .+ 1.8 .* exp.(-0.5 .* sweet_spot .^ 2)) .+ 25.0 .* randn(n),\n    10.0, 900.0,\n)\n\n# Scale marker sizes proportional to area (visual area ∝ data value). Floor\n# raised and range narrowed vs. earlier drafts so the smallest reference\n# bubble stays legible and dense clusters overlap less.\ns_min, s_max = extrema(box_office)\ns_norm       = (box_office .- s_min) ./ (s_max - s_min)\nmarker_sizes = 15.0 .+ 42.0 .* sqrt.(s_norm)\n\n# Production scale — a distinct categorical variable (not derived from box\n# office) so color and size each carry their own signal instead of duplicating one.\ntier_idx    = ifelse.(budget .< 50.0, 1, ifelse.(budget .< 150.0, 2, 3))\ntier_names  = [\"Indie (<\\$50M)\", \"Studio (\\$50–150M)\", \"Blockbuster (>\\$150M)\"]\npoint_color = IMPRINT_PALETTE[tier_idx]\n\n# Plot\ntitle_str = \"Film Portfolio Analysis · bubble-basic · julia · makie · anyplot.ai\"\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         = 29,\n    titlecolor        = INK,\n    xlabel            = \"Production Budget (USD Millions)\",\n    ylabel            = \"Critic Rating (out of 10)\",\n    xlabelsize        = 14,\n    ylabelsize        = 14,\n    xlabelcolor       = INK,\n    ylabelcolor       = INK,\n    xticklabelsize    = 12,\n    yticklabelsize    = 12,\n    xticklabelcolor   = INK_SOFT,\n    yticklabelcolor   = INK_SOFT,\n    xtickcolor        = INK_SOFT,\n    ytickcolor        = INK_SOFT,\n    backgroundcolor   = PAGE_BG,\n    topspinevisible   = false,\n    rightspinevisible = false,\n    leftspinecolor    = INK_SOFT,\n    bottomspinecolor  = INK_SOFT,\n    xgridcolor        = RGBAf(INK.r, INK.g, INK.b, 0.15),\n    ygridcolor        = RGBAf(INK.r, INK.g, INK.b, 0.15),\n)\n\n# Highlight the franchise sweet spot where box-office ROI peaks — makes the\n# DE-03 narrative explicit instead of leaving it implicit in the data alone.\n# Heavier band opacity + bold ink-colored text (vs. a previous low-contrast\n# gray) so the insight reads at a glance instead of blending into the grid.\nvspan!(ax, 80.0, 150.0; color = RGBAf(INK.r, INK.g, INK.b, 0.10))\ntext!(ax, 115.0, 9.6;\n    text     = \"franchise sweet spot\",\n    align    = (:center, :bottom),\n    fontsize = 17,\n    font     = :bold,\n    color    = INK,\n)\n\n# Bubble area (sqrt-scaled) encodes box-office revenue; fill color encodes\n# production scale — two independent variables, each with its own legend\n# group below. Alpha raised to 0.5 (spec calls for 0.5-0.7 to handle\n# overlap) so individual bubbles read more solidly while dense low-budget\n# indie and mid-budget studio clusters stay distinguishable.\nscatter!(ax, budget, rating;\n    color       = point_color,\n    markersize  = marker_sizes,\n    alpha       = 0.5,\n    strokewidth = 1.0,\n    strokecolor = RGBAf(INK.r, INK.g, INK.b, 0.4),\n)\n\n# Tier legend — fixed-size swatches, one per product tier\ntier_elems = [\n    MarkerElement(\n        color       = IMPRINT_PALETTE[i],\n        marker      = :circle,\n        markersize  = 18,\n        strokewidth = 1.0,\n        strokecolor = RGBAf(INK.r, INK.g, INK.b, 0.35),\n    )\n    for i in eachindex(IMPRINT_PALETTE)\n]\n\n# Size legend — neutral-colored reference bubbles show the area scale only.\n# Values chosen within the actual box_office range so no two clamp to the\n# same normalized size (this domain's revenue tops out well under $400M).\nref_vals = [30, 110, 200, 300]\nref_norm = clamp.((Float64.(ref_vals) .- s_min) ./ (s_max - s_min), 0.0, 1.0)\nref_ms   = 15.0 .+ 45.0 .* sqrt.(ref_norm)\n\nsize_elems = [\n    MarkerElement(\n        color       = RGBAf(INK_MUTED.r, INK_MUTED.g, INK_MUTED.b, 0.65),\n        marker      = :circle,\n        markersize  = ref_ms[i],\n        strokewidth = 1.0,\n        strokecolor = RGBAf(INK.r, INK.g, INK.b, 0.35),\n    )\n    for i in eachindex(ref_ms)\n]\n\nLegend(fig[1, 2],\n    [tier_elems, size_elems],\n    [tier_names, \"\\$\" .* string.(ref_vals) .* \"M\"],\n    [\"Production Scale\", \"Box Office Revenue\"];\n    backgroundcolor = ELEVATED_BG,\n    framecolor      = INK_SOFT,\n    labelcolor      = INK_SOFT,\n    titlecolor      = INK,\n    patchsize       = (64, 44),\n    labelsize       = 16,\n)\n\n# Tighten the gap between the plot panel and the legend column — a small,\n# layout-aware polish that Makie's GridLayout makes trivial.\ncolgap!(fig.layout, 1, 18)\n\n# Save\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}