|
| 1 | +#' anyplot.ai |
| 2 | +#' pie-portfolio-interactive: Interactive Portfolio Allocation Chart |
| 3 | +#' Library: ggplot2 3.5.1 | R 4.4.1 |
| 4 | +#' Quality: 86/100 | Created: 2026-05-27 |
| 5 | + |
| 6 | +library(ggplot2) |
| 7 | +library(dplyr) |
| 8 | +library(ragg) |
| 9 | + |
| 10 | +# Theme tokens |
| 11 | +THEME <- Sys.getenv("ANYPLOT_THEME", "light") |
| 12 | +PAGE_BG <- if (THEME == "light") "#FAF8F1" else "#1A1A17" |
| 13 | +ELEVATED_BG <- if (THEME == "light") "#FFFDF6" else "#242420" |
| 14 | +INK <- if (THEME == "light") "#1A1A17" else "#F0EFE8" |
| 15 | +INK_SOFT <- if (THEME == "light") "#4A4A44" else "#B8B7B0" |
| 16 | +INK_MUTED <- if (THEME == "light") "#6B6A63" else "#A8A79F" |
| 17 | + |
| 18 | +ANYPLOT_PALETTE <- c( |
| 19 | + "#009E73", # 1 — Equities (brand green) |
| 20 | + "#C475FD", # 2 — Fixed Income (lavender) |
| 21 | + "#4467A3", # 3 — Alternatives (blue) |
| 22 | + "#BD8233" # 4 — Cash (ochre) |
| 23 | +) |
| 24 | + |
| 25 | +# Portfolio holdings ordered by asset class |
| 26 | +cat_levels <- c("Equities", "Fixed Income", "Alternatives", "Cash") |
| 27 | +cat_colors <- setNames(ANYPLOT_PALETTE, cat_levels) |
| 28 | + |
| 29 | +holdings <- data.frame( |
| 30 | + holding = c( |
| 31 | + "US Large Cap", "Intl Developed", "Emerging Mkts", "US Small Cap", |
| 32 | + "US Treasuries", "Inv Grade Corp", "High Yield", |
| 33 | + "Real Estate", "Commodities", |
| 34 | + "Money Market" |
| 35 | + ), |
| 36 | + category = factor(c( |
| 37 | + rep("Equities", 4), |
| 38 | + rep("Fixed Income", 3), |
| 39 | + rep("Alternatives", 2), |
| 40 | + "Cash" |
| 41 | + ), levels = cat_levels), |
| 42 | + weight = c(20, 18, 12, 5, 12, 10, 8, 6, 4, 5), |
| 43 | + stringsAsFactors = FALSE |
| 44 | +) |
| 45 | + |
| 46 | +# Pre-compute angular midpoints and absolute dollar values (assumes $1M portfolio) |
| 47 | +holdings <- holdings |> |
| 48 | + arrange(category) |> |
| 49 | + mutate( |
| 50 | + cum_end = cumsum(weight), |
| 51 | + cum_start = lag(cum_end, default = 0), |
| 52 | + midpoint = (cum_start + cum_end) / 2, |
| 53 | + dollar = paste0("$", weight * 10, "k") |
| 54 | + ) |
| 55 | + |
| 56 | +# Asset class summary for the inner ring |
| 57 | +categories <- holdings |> |
| 58 | + group_by(category) |> |
| 59 | + summarise(weight = sum(weight), .groups = "drop") |> |
| 60 | + mutate( |
| 61 | + cum_end = cumsum(weight), |
| 62 | + cum_start = lag(cum_end, default = 0), |
| 63 | + midpoint = (cum_start + cum_end) / 2 |
| 64 | + ) |
| 65 | + |
| 66 | +# Title sizing (scale down only if longer than 67-char baseline) |
| 67 | +title_str <- "pie-portfolio-interactive · r · ggplot2 · anyplot.ai" |
| 68 | +n_chars <- nchar(title_str) |
| 69 | +ratio <- if (n_chars > 67) 67 / n_chars else 1.0 |
| 70 | +title_size <- max(8, round(12 * ratio)) |
| 71 | + |
| 72 | +# Double-donut: inner ring = asset classes, outer ring = individual holdings |
| 73 | +p <- ggplot() + |
| 74 | + # Outer ring: individual holdings |
| 75 | + geom_col( |
| 76 | + data = holdings, |
| 77 | + aes(x = 3.2, y = weight, fill = category), |
| 78 | + width = 1.0, |
| 79 | + color = "white", |
| 80 | + linewidth = 0.5 |
| 81 | + ) + |
| 82 | + # Inner ring: asset class summary |
| 83 | + geom_col( |
| 84 | + data = categories, |
| 85 | + aes(x = 2.0, y = weight, fill = category), |
| 86 | + width = 0.9, |
| 87 | + color = "white", |
| 88 | + linewidth = 1.0 |
| 89 | + ) + |
| 90 | + # Inner ring labels (skip Cash at 5% — too small) |
| 91 | + geom_text( |
| 92 | + data = filter(categories, weight >= 8), |
| 93 | + aes(x = 2.0, y = midpoint, |
| 94 | + label = paste0(gsub(" ", "\n", as.character(category)), "\n", weight, "%")), |
| 95 | + color = "white", |
| 96 | + size = 3.5, |
| 97 | + fontface = "bold", |
| 98 | + lineheight = 0.75 |
| 99 | + ) + |
| 100 | + # Outer ring labels: placed outside the ring at x=4.0 using INK so text |
| 101 | + # always lands on PAGE_BG and remains readable in both light and dark themes. |
| 102 | + # Shows holding name, percentage, and absolute value (assumes $1M portfolio). |
| 103 | + # Only segments >= 10% are labeled to avoid crowding. |
| 104 | + geom_text( |
| 105 | + data = filter(holdings, weight >= 10), |
| 106 | + aes(x = 4.0, y = midpoint, |
| 107 | + label = paste0(holding, "\n", weight, "% · ", dollar)), |
| 108 | + color = INK, |
| 109 | + size = 2.6, |
| 110 | + fontface = "bold", |
| 111 | + lineheight = 0.85 |
| 112 | + ) + |
| 113 | + coord_polar(theta = "y", start = 0, clip = "off") + |
| 114 | + xlim(c(0.7, 5.0)) + |
| 115 | + scale_fill_manual( |
| 116 | + values = cat_colors, |
| 117 | + name = "Asset Class" |
| 118 | + ) + |
| 119 | + labs( |
| 120 | + title = title_str, |
| 121 | + subtitle = "Model Portfolio ($1M) · 10 Holdings · Inner ring: asset class | Outer ring: individual holding" |
| 122 | + ) + |
| 123 | + theme_void(base_size = 8) + |
| 124 | + theme( |
| 125 | + plot.background = element_rect(fill = PAGE_BG, color = PAGE_BG), |
| 126 | + panel.background = element_rect(fill = PAGE_BG, color = NA), |
| 127 | + plot.title = element_text( |
| 128 | + color = INK, |
| 129 | + size = title_size, |
| 130 | + hjust = 0.5, |
| 131 | + face = "bold", |
| 132 | + margin = margin(t = 16, b = 4) |
| 133 | + ), |
| 134 | + plot.subtitle = element_text( |
| 135 | + color = INK_MUTED, |
| 136 | + size = 7, |
| 137 | + hjust = 0.5, |
| 138 | + margin = margin(b = 8) |
| 139 | + ), |
| 140 | + legend.text = element_text(color = INK_SOFT, size = 8), |
| 141 | + legend.title = element_text(color = INK, size = 10, face = "bold"), |
| 142 | + legend.background = element_rect(fill = ELEVATED_BG, color = INK_SOFT, linewidth = 0.3), |
| 143 | + legend.key.size = unit(0.45, "cm"), |
| 144 | + legend.position = "bottom", |
| 145 | + legend.direction = "horizontal", |
| 146 | + plot.margin = margin(10, 40, 20, 40) |
| 147 | + ) |
| 148 | + |
| 149 | +# Save — square canvas: 2400 x 2400 px (6 in x 400 dpi) |
| 150 | +ggsave( |
| 151 | + filename = sprintf("plot-%s.png", THEME), |
| 152 | + plot = p, |
| 153 | + device = ragg::agg_png, |
| 154 | + width = 6, |
| 155 | + height = 6, |
| 156 | + units = "in", |
| 157 | + dpi = 400 |
| 158 | +) |
0 commit comments