{"spec_id":"circlepacking-basic","library":"makie","language":"julia","code":"# anyplot.ai\n# circlepacking-basic: Circle Packing Chart\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 79/100 | Created: 2026-09-02\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 INK      = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nconst INK_SOFT = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\nconst IMPRINT_PALETTE = [\n    colorant\"#009E73\",  # 1 — brand green\n    colorant\"#C475FD\",  # 2 — lavender\n    colorant\"#4467A3\",  # 3 — blue\n    colorant\"#BD8233\",  # 4 — ochre\n]\n\n# --- Data: disk storage broken down into folders and files --------------------\nstruct LeafSpec\n    label::String\n    size_mb::Float64\nend\n\nstruct SubcatSpec\n    label::String\n    leaves::Vector{LeafSpec}\nend\n\nstruct CategorySpec\n    label::String\n    subcats::Vector{SubcatSpec}\nend\n\nfunction random_leaves(names, lo, hi)\n    return [LeafSpec(n, lo + rand() * (hi - lo)) for n in names]\nend\n\ncategories = [\n    CategorySpec(\"Documents\", [\n        SubcatSpec(\"Reports\", random_leaves([\"Q1\", \"Q2\", \"Q3\", \"Q4\"], 4.0, 60.0)),\n        SubcatSpec(\"Spreadsheets\", random_leaves([\"Budget\", \"Forecast\", \"Payroll\"], 2.0, 40.0)),\n        SubcatSpec(\"Presentations\", random_leaves([\"Kickoff\", \"Roadmap\"], 8.0, 90.0)),\n    ]),\n    CategorySpec(\"Media\", [\n        SubcatSpec(\"Photos\", random_leaves([\"Trip\", \"Family\", \"Events\", \"Pets\"], 20.0, 320.0)),\n        SubcatSpec(\"Videos\", random_leaves([\"Vacation\", \"Tutorial\"], 200.0, 1400.0)),\n        SubcatSpec(\"Audio\", random_leaves([\"Podcasts\", \"Music\", \"Voice Memos\"], 15.0, 260.0)),\n        SubcatSpec(\"Design Files\", random_leaves([\"Logos\", \"Mockups\"], 10.0, 150.0)),\n    ]),\n    CategorySpec(\"Code\", [\n        SubcatSpec(\"Frontend\", random_leaves([\"Components\", \"Styles\", \"Assets\"], 3.0, 55.0)),\n        SubcatSpec(\"Backend\", random_leaves([\"API\", \"Services\", \"Migrations\"], 3.0, 50.0)),\n        SubcatSpec(\"Scripts\", random_leaves([\"Automation\", \"CI\"], 1.0, 20.0)),\n        SubcatSpec(\"Tests\", random_leaves([\"Unit\", \"Integration\", \"Fixtures\"], 1.0, 30.0)),\n    ]),\n    CategorySpec(\"System\", [\n        SubcatSpec(\"Cache\", random_leaves([\"Browser\", \"Build\", \"Package\"], 10.0, 200.0)),\n        SubcatSpec(\"Logs\", random_leaves([\"App\", \"Access\", \"Crash\"], 2.0, 45.0)),\n        SubcatSpec(\"Config\", random_leaves([\"User\", \"Network\"], 0.5, 6.0)),\n        SubcatSpec(\"Temp\", random_leaves([\"Downloads\", \"Swap\", \"Recovery\"], 5.0, 90.0)),\n    ]),\n]\n\n# --- Hierarchy node + recursive circle packing ---------------------------------\nmutable struct PackNode\n    label::String\n    depth::Int\n    value::Float64\n    category_idx::Int\n    children::Vector{PackNode}\n    rel_x::Float64\n    rel_y::Float64\n    abs_x::Float64\n    abs_y::Float64\n    r::Float64\nend\n\nPackNode(label, depth, category_idx) =\n    PackNode(label, depth, 0.0, category_idx, PackNode[], 0.0, 0.0, 0.0, 0.0, 0.0)\n\n# Position of a circle with radius r3, externally tangent to two placed circles.\nfunction tangent_points(x1, y1, r1, x2, y2, r2, r3)\n    d = hypot(x2 - x1, y2 - y1)\n    R1, R2 = r1 + r3, r2 + r3\n    if d < 1e-9 || d > R1 + R2 || d < abs(R1 - R2)\n        return Tuple{Float64,Float64}[]\n    end\n    a = (R1^2 - R2^2 + d^2) / (2d)\n    h2 = R1^2 - a^2\n    h2 < 0 && return Tuple{Float64,Float64}[]\n    h = sqrt(h2)\n    xm = x1 + a * (x2 - x1) / d\n    ym = y1 + a * (y2 - y1) / d\n    ux, uy = -(y2 - y1) / d, (x2 - x1) / d\n    return [(xm + h * ux, ym + h * uy), (xm - h * ux, ym - h * uy)]\nend\n\n# Greedy sibling packer: places circles (by descending radius) tangent to two\n# already-placed neighbors, minimizing distance from the current centroid.\nfunction pack_siblings(radii::Vector{Float64})\n    n = length(radii)\n    n == 0 && return Float64[], Float64[]\n    xs, ys = zeros(n), zeros(n)\n    order = sortperm(radii, rev = true)\n    placed = Int[order[1]]\n    if n >= 2\n        i2 = order[2]\n        xs[i2] = radii[order[1]] + radii[i2]\n        push!(placed, i2)\n    end\n    for k in 3:n\n        i = order[k]\n        r = radii[i]\n        best, best_dist = nothing, Inf\n        for ai in 1:length(placed), bi in (ai + 1):length(placed)\n            a, b = placed[ai], placed[bi]\n            for p in tangent_points(xs[a], ys[a], radii[a], xs[b], ys[b], radii[b], r)\n                ok = true\n                for c in placed\n                    if hypot(p[1] - xs[c], p[2] - ys[c]) < radii[c] + r - 1e-6\n                        ok = false\n                        break\n                    end\n                end\n                if ok\n                    dist = hypot(p[1], p[2]) + r\n                    if dist < best_dist\n                        best_dist, best = dist, p\n                    end\n                end\n            end\n        end\n        if best === nothing\n            angle = 2pi * k / n\n            reach = sum(radii) + r\n            best = (reach * cos(angle), reach * sin(angle))\n        end\n        xs[i], ys[i] = best\n        push!(placed, i)\n    end\n    return xs, ys\nend\n\n# Bottom-up: pack each node's children, then set node.r to their enclosing\n# circle (plus padding) and store each child's offset relative to this node.\nfunction pack!(node::PackNode; padding_ratio = 0.10)\n    if isempty(node.children)\n        node.r = sqrt(node.value)\n        return\n    end\n    for c in node.children\n        pack!(c; padding_ratio = padding_ratio)\n    end\n    radii = [c.r for c in node.children]\n    xs, ys = pack_siblings(radii)\n    lefts = xs .- radii\n    rights = xs .+ radii\n    tops = ys .- radii\n    bottoms = ys .+ radii\n    cx = (minimum(lefts) + maximum(rights)) / 2\n    cy = (minimum(tops) + maximum(bottoms)) / 2\n    xs .-= cx\n    ys .-= cy\n    enclosing_r = maximum(hypot.(xs, ys) .+ radii)\n    node.r = enclosing_r * (1 + padding_ratio)\n    for (c, x, y) in zip(node.children, xs, ys)\n        c.rel_x, c.rel_y = x, y\n    end\nend\n\nfunction locate!(node::PackNode, parent_x, parent_y)\n    node.abs_x = parent_x + node.rel_x\n    node.abs_y = parent_y + node.rel_y\n    for c in node.children\n        locate!(c, node.abs_x, node.abs_y)\n    end\nend\n\nfunction collect_nodes!(node::PackNode, acc::Vector{PackNode})\n    push!(acc, node)\n    for c in node.children\n        collect_nodes!(c, acc)\n    end\nend\n\n# --- Build the tree -------------------------------------------------------------\nroot = PackNode(\"Storage\", 0, 0)\nfor (ci, cat) in enumerate(categories)\n    cat_node = PackNode(cat.label, 1, ci)\n    for sub in cat.subcats\n        sub_node = PackNode(sub.label, 2, ci)\n        for leaf in sub.leaves\n            leaf_node = PackNode(leaf.label, 3, ci)\n            leaf_node.value = leaf.size_mb\n            push!(sub_node.children, leaf_node)\n        end\n        push!(cat_node.children, sub_node)\n    end\n    push!(root.children, cat_node)\nend\n\npack!(root)\nroot.rel_x, root.rel_y = 0.0, 0.0\nlocate!(root, 0.0, 0.0)\n\n# Rescale so the root circle lands on a fixed size in figure data units.\nconst TARGET_ROOT_R = 540.0\nscale = TARGET_ROOT_R / root.r\nall_nodes = PackNode[]\ncollect_nodes!(root, all_nodes)\nfor node in all_nodes\n    node.abs_x *= scale\n    node.abs_y *= scale\n    node.r *= scale\nend\n\n# --- Plot ------------------------------------------------------------------------\nfig = Figure(\n    size = (1200, 1200),\n    backgroundcolor = PAGE_BG,\n)\n\nax = Axis(\n    fig[1, 1];\n    title = \"circlepacking-basic · julia · makie · anyplot.ai\",\n    titlesize = 30,\n    titlecolor = INK,\n    aspect = DataAspect(),\n    backgroundcolor = PAGE_BG,\n)\nhidedecorations!(ax)\nhidespines!(ax)\n\n# Root: faint container circle showing the encompassing boundary.\npoly!(ax, Circle(Point2f(root.abs_x, root.abs_y), root.r);\n    color = (PAGE_BG, 0.0), strokecolor = INK_SOFT, strokewidth = 1.5)\n\nfill_alpha = Dict(1 => 0.16, 2 => 0.38, 3 => 0.88)\nfor depth in 1:3\n    for node in all_nodes\n        node.depth == depth || continue\n        base = IMPRINT_PALETTE[node.category_idx]\n        poly!(ax, Circle(Point2f(node.abs_x, node.abs_y), node.r);\n            color = (base, fill_alpha[depth]), strokecolor = PAGE_BG, strokewidth = 2.0)\n    end\nend\n\n# Labels: categories placed just below the actual bottom of their own child\n# cluster (not a fixed fraction of the category radius, which can collide\n# with a child that happens to sit near the category's edge); subcategories\n# at their own center, only when large relative to their own category (not\n# the global root) so every category gets comparable coverage.\n#\n# Each tier is drawn with a single vectorized text!() call (positions/text as\n# arrays) rather than one text!() per node: a per-node loop of individual\n# text!() calls was silently dropping a subset of glyphs in CairoMakie even\n# though their positions and the labeling threshold were correct.\ncategory_r = Dict(node.category_idx => node.r for node in all_nodes if node.depth == 1)\nlabel_margin = 0.05 * root.r\n\ncat_nodes = filter(n -> n.depth == 1, all_nodes)\ncat_positions = [\n    Point2f(n.abs_x, minimum(c.abs_y - c.r for c in n.children) - label_margin) for\n    n in cat_nodes\n]\ntext!(ax, cat_positions; text = [n.label for n in cat_nodes],\n    align = (:center, :center), fontsize = 20, color = INK, font = :bold)\n\nsub_nodes = filter(\n    n -> n.depth == 2 && n.r >= 0.18 * category_r[n.category_idx], all_nodes,\n)\nsub_positions = [Point2f(n.abs_x, n.abs_y) for n in sub_nodes]\ntext!(ax, sub_positions; text = [n.label for n in sub_nodes],\n    align = (:center, :center), fontsize = 13, color = INK)\n\n# --- Save --------------------------------------------------------------------\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}