Skip to content

Commit bb66b9f

Browse files
feat(ggplot2): implement pie-portfolio-interactive (#7774)
## Implementation: `pie-portfolio-interactive` - r/ggplot2 Implements the **r/ggplot2** version of `pie-portfolio-interactive`. **File:** `plots/pie-portfolio-interactive/implementations/r/ggplot2.R` **Parent Issue:** #3754 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26527356861)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 0f8562e commit bb66b9f

2 files changed

Lines changed: 436 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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

Comments
 (0)