Basic Word Cloud — ggplot2

A word cloud displays text data where word size represents frequency or importance. Words are arranged to fill available space, creating a visual summary of text content that highlights prominent terms and patterns. This visualization is ideal for quickly identifying the most common themes or keywords in a body of text.

Basic Word Cloud rendered with ggplot2

R source (ggplot2)

#' anyplot.ai
#' wordcloud-basic: Basic Word Cloud
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 91/100 | Created: 2026-08-04

library(ggplot2)
library(tibble)
library(ragg)

set.seed(42)

# --- Theme tokens -------------------------------------------------------------
THEME       <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG     <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
INK         <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
IMPRINT_PALETTE <- c("#009E73", "#C475FD", "#4467A3", "#BD8233",
                     "#AE3030", "#2ABCCD", "#954477", "#99B314")
BRAND <- IMPRINT_PALETTE[1]

# --- Data: term frequencies from a renewable-energy report scan ---------------
terms <- c(
  "solar", "wind", "energy", "renewable", "grid", "battery", "storage",
  "efficiency", "carbon", "emissions", "turbine", "hydropower", "biomass",
  "geothermal", "investment", "innovation", "climate", "capacity",
  "generation", "transition", "policy", "technology", "electricity",
  "consumption", "demand", "supply", "market", "subsidy", "reliability",
  "recycling"
)
rank_order <- seq_along(terms)
base_freq  <- 150 / rank_order^0.65
frequency  <- pmax(round(base_freq + rnorm(length(terms), mean = 0, sd = 4)), 5)

word_freq <- tibble(word = terms, frequency = frequency)
word_freq <- word_freq[order(-word_freq$frequency), ]

# Font size scales with sqrt(frequency) so rendered AREA (not height) tracks
# term frequency, matching how the eye compares word-cloud prominence.
size_min <- 3.2
size_max <- 18
freq_sqrt <- sqrt(word_freq$frequency)
word_freq$text_size <- size_min + (freq_sqrt - min(freq_sqrt)) /
  diff(range(freq_sqrt)) * (size_max - size_min)

# --- Layout: box-bounded spiral placement with rectangle collision ------------
# geom_text's `size` aesthetic is in millimetres, so the layout below works
# directly in millimetres too (1 coordinate unit = 1 mm) and the panel is
# rendered at that exact physical scale via coord_fixed(ratio = 1) below —
# this keeps the hand-measured bounding boxes true to the rendered glyphs.
# Bounding-box multipliers are calibrated from grid::grobWidth/grobHeight on
# the bold sans face actually used for geom_text (see dev notes: width per
# char ranges ~0.55-0.68x fontsize, height ~0.735x fontsize; padded up here
# for a safety margin against overlap).
half_w_per_char <- 0.35
half_h_factor   <- 0.42
box_half_w <- 86   # mm — half-width of the placement box (172mm total)
box_half_h <- 44   # mm — half-height of the placement box (88mm total)
x_stretch  <- box_half_w / box_half_h

n_words  <- nrow(word_freq)
placed_x <- numeric(n_words)
placed_y <- numeric(n_words)
placed_hw <- numeric(n_words)
placed_hh <- numeric(n_words)
max_radius <- sqrt(box_half_w^2 + box_half_h^2)

# Fallback grid, closest-to-center first, for words the spiral walk can't
# place within its step budget (a spiral path only samples one point per
# radius, so it can miss small pockets of free space that a grid catches).
grid_x <- seq(-box_half_w, box_half_w, by = 1)
grid_y <- seq(-box_half_h, box_half_h, by = 1)
fallback_grid <- expand.grid(x = grid_x, y = grid_y)
fallback_grid <- fallback_grid[order(sqrt((fallback_grid$x / x_stretch)^2 + fallback_grid$y^2)), ]

for (i in seq_len(n_words)) {
  fs <- word_freq$text_size[i]
  half_w <- nchar(word_freq$word[i]) * fs * half_w_per_char
  half_h <- fs * half_h_factor
  placed <- FALSE

  if (i == 1) {
    cx <- 0
    cy <- 0
    placed <- TRUE
  } else {
    theta <- 0
    radius <- 0
    step <- 0
    repeat {
      cx <- radius * cos(theta) * x_stretch
      cy <- radius * sin(theta)

      in_box <- (abs(cx) + half_w <= box_half_w) && (abs(cy) + half_h <= box_half_h)
      overlap <- !in_box
      if (in_box) {
        for (j in seq_len(i - 1)) {
          dx <- abs(cx - placed_x[j])
          dy <- abs(cy - placed_y[j])
          if (dx < (half_w + placed_hw[j]) * 1.06 && dy < (half_h + placed_hh[j]) * 1.15) {
            overlap <- TRUE
            break
          }
        }
      }
      if (!overlap) {
        placed <- TRUE
        break
      }

      theta <- theta + 0.12
      radius <- min(radius + 0.18, max_radius)
      step <- step + 1
      if (step > 8000) break
    }
  }

  if (!placed) {
    for (k in seq_len(nrow(fallback_grid))) {
      gx <- fallback_grid$x[k]
      gy <- fallback_grid$y[k]
      if (abs(gx) + half_w > box_half_w || abs(gy) + half_h > box_half_h) next
      overlap <- FALSE
      for (j in seq_len(i - 1)) {
        dx <- abs(gx - placed_x[j])
        dy <- abs(gy - placed_y[j])
        if (dx < (half_w + placed_hw[j]) * 1.06 && dy < (half_h + placed_hh[j]) * 1.15) {
          overlap <- TRUE
          break
        }
      }
      if (!overlap) {
        cx <- gx
        cy <- gy
        placed <- TRUE
        break
      }
    }
  }

  placed_x[i] <- cx
  placed_y[i] <- cy
  placed_hw[i] <- half_w
  placed_hh[i] <- half_h
}

word_freq$x <- placed_x
word_freq$y <- placed_y

# --- Plot -----------------------------------------------------------------
plot_title <- "wordcloud-basic · r · ggplot2 · anyplot.ai"
title_ratio <- if (nchar(plot_title) > 67) 67 / nchar(plot_title) else 1.0
title_size <- round(12 * title_ratio)

p <- ggplot(word_freq, aes(x = x, y = y, label = word,
                            size = text_size, color = frequency)) +
  geom_text(fontface = "bold", family = "sans") +
  scale_size_identity() +
  scale_color_gradient(low = BRAND, high = "#4467A3", guide = "none") +
  scale_x_continuous(limits = c(-box_half_w, box_half_w), expand = c(0, 0)) +
  scale_y_continuous(limits = c(-box_half_h, box_half_h), expand = c(0, 0)) +
  coord_fixed(ratio = 1, clip = "off") +
  labs(title = plot_title) +
  theme_void(base_size = 8) +
  theme(
    plot.background = element_rect(fill = PAGE_BG, color = PAGE_BG),
    plot.title = element_text(color = INK, size = title_size, hjust = 0.5,
                               margin = margin(b = 10)),
    plot.margin = margin(t = 16, r = 24, b = 12, l = 24)
  )

# --- Save -------------------------------------------------------------------
ggsave(
  filename = sprintf("plot-%s.png", THEME),
  plot     = p,
  device   = ragg::agg_png,
  width    = 8,
  height   = 4.5,
  units    = "in",
  dpi      = 400
)

Part of Basic Word Cloud on anyplot.ai.

Other implementations