feat: add position argument to civilytics_logo() for corner placement
R-CMD-check / R CMD check (push) Successful in 3m37s

Supports "bottom-right" (default), "bottom-left", "top-right", and
"top-left". The position controls both horizontal alignment of the logo
grob and whether it is placed above or below the plot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 15:44:18 -06:00
co-authored by Claude Opus 4.6
parent 95b8ba1da4
commit 328d9d2fd6
5 changed files with 152 additions and 41 deletions
+65 -18
View File
@@ -50,12 +50,20 @@ get_png <- function(filename) {
#' sizes before composing the plot with the logo. Default `1.1` inflates #' sizes before composing the plot with the logo. Default `1.1` inflates
#' text by ~10 \% to compensate for the viewport shrinkage caused by #' text by ~10 \% to compensate for the viewport shrinkage caused by
#' [gridExtra::arrangeGrob()]. Set to `1` to disable. #' [gridExtra::arrangeGrob()]. Set to `1` to disable.
#' @param position Character. Corner placement for the logo: `"bottom-right"`
#' (default), `"bottom-left"`, `"top-right"`, or `"top-left"`. Controls
#' whether the logo is placed above or below the plot.
#' #'
#' @return a grob with a logo attached to it ready to plot #' @return a grob with a logo attached to it ready to plot
#' @importFrom ggplot2 theme #' @importFrom ggplot2 theme
#' @importFrom gridExtra arrangeGrob #' @importFrom gridExtra arrangeGrob
#' @export #' @export
add_logo <- function(plot, logo, margin_param = NULL, font_scale = 1.1) { add_logo <- function(plot, logo, margin_param = NULL, font_scale = 1.1,
position = c("bottom-right", "bottom-left",
"top-right", "top-left")) {
position <- match.arg(position)
at_top <- grepl("top", position)
# Inflate text sizes to compensate for arrangeGrob viewport shrinkage. # Inflate text sizes to compensate for arrangeGrob viewport shrinkage.
# All theme text elements use rel() sizing, so scaling the root 'text' # All theme text elements use rel() sizing, so scaling the root 'text'
# element cascades to titles, axis labels, legends, captions, and strips. # element cascades to titles, axis labels, legends, captions, and strips.
@@ -66,6 +74,17 @@ add_logo <- function(plot, logo, margin_param = NULL, font_scale = 1.1) {
) )
} }
if (at_top) {
# For top placement, tighten the top margin of the plot
if (!is.null(margin_param)) {
plot <- plot + theme(plot.margin = unit(c(margin_param, 7, 7, 7), "pt"))
} else {
plot <- plot + theme(plot.margin = unit(c(-14, 7, 7, 7), "pt"))
}
arrangeGrob(logo, plot, heights = c(0.1, 0.93),
padding = unit(0.1, "line"))
} else {
# Bottom placement (original behaviour)
if(has_caption(plot)) { if(has_caption(plot)) {
cap_lines <- measure_caption(plot) cap_lines <- measure_caption(plot)
if (!is.null(margin_param)) { if (!is.null(margin_param)) {
@@ -76,9 +95,9 @@ add_logo <- function(plot, logo, margin_param = NULL, font_scale = 1.1) {
} else { } else {
plot <- plot + theme(plot.margin = unit(c(7, 7, -14, 7), "pt")) plot <- plot + theme(plot.margin = unit(c(7, 7, -14, 7), "pt"))
} }
arrangeGrob(plot, logo, heights = c(0.93, 0.1), arrangeGrob(plot, logo, heights = c(0.93, 0.1),
padding = unit(0.1, "line")) padding = unit(0.1, "line"))
}
} }
#' Measure a ggplot2 object caption #' Measure a ggplot2 object caption
@@ -193,6 +212,9 @@ add_logo_ga <- function(plot_list, logo, nrow = 1, widths = NULL,
#' @param variant Character. `"light"` (default) uses the dark logo for light #' @param variant Character. `"light"` (default) uses the dark logo for light
#' backgrounds. `"dark"` uses the reverse (light) logo for dark backgrounds #' backgrounds. `"dark"` uses the reverse (light) logo for dark backgrounds
#' (pairs with [theme_civilytics_dark()]). #' (pairs with [theme_civilytics_dark()]).
#' @param position Character. Corner placement for the logo: `"bottom-right"`
#' (default), `"bottom-left"`, `"top-right"`, or `"top-left"`. Controls
#' horizontal alignment of the logo grob.
#' #'
#' @return A ggplot object (class `"gg"`) containing the logo grob. #' @return A ggplot object (class `"gg"`) containing the logo grob.
#' @export #' @export
@@ -200,11 +222,15 @@ add_logo_ga <- function(plot_list, logo, nrow = 1, widths = NULL,
#' @examples #' @examples
#' logo <- make_logo_grob() # wordmark, light #' logo <- make_logo_grob() # wordmark, light
#' logo <- make_logo_grob("mark", "dark") # mark, dark #' logo <- make_logo_grob("mark", "dark") # mark, dark
#' logo <- make_logo_grob(position = "bottom-left") # left-aligned
#' class(logo) # "gg" "ggplot" #' class(logo) # "gg" "ggplot"
make_logo_grob <- function(type = c("wordmark", "mark"), make_logo_grob <- function(type = c("wordmark", "mark"),
variant = c("light", "dark")) { variant = c("light", "dark"),
position = c("bottom-right", "bottom-left",
"top-right", "top-left")) {
type <- match.arg(type) type <- match.arg(type)
variant <- match.arg(variant) variant <- match.arg(variant)
position <- match.arg(position)
img_file <- switch( img_file <- switch(
paste(type, variant, sep = "_"), paste(type, variant, sep = "_"),
@@ -214,21 +240,28 @@ make_logo_grob <- function(type = c("wordmark", "mark"),
mark_dark = "civilytics-mark-reverse.png" mark_dark = "civilytics-mark-reverse.png"
) )
# Right-align the logo; the mark is compact so it gets a narrower slice. align_left <- grepl("left", position)
if (align_left) {
xmin <- 0
xmax <- if (type == "mark") 0.07 else 0.35
} else {
xmin <- if (type == "mark") 0.93 else 0.65 xmin <- if (type == "mark") 0.93 else 0.65
xmax <- 1
}
ggplot2::ggplot() + ggplot2::ggplot() +
ggplot2::theme_void() + ggplot2::theme_void() +
ggplot2::annotation_custom( ggplot2::annotation_custom(
get_png(system.file("img", img_file, package = "civilytics")), get_png(system.file("img", img_file, package = "civilytics")),
xmin = xmin, xmax = 1 xmin = xmin, xmax = xmax
) )
} }
#' Add a Civilytics logo to a ggplot (pipe-friendly) #' Add a Civilytics logo to a ggplot (pipe-friendly)
#' #'
#' A convenience wrapper that creates the logo grob and attaches it below the #' A convenience wrapper that creates the logo grob and attaches it to a
#' plot in one call. Designed for use with the base pipe `|>`. #' corner of the plot in one call. Designed for use with the base pipe `|>`.
#' #'
#' **Important:** R's `|>` has *higher* precedence than `+`, so you must #' **Important:** R's `|>` has *higher* precedence than `+`, so you must
#' wrap the ggplot chain in parentheses before piping: #' wrap the ggplot chain in parentheses before piping:
@@ -245,6 +278,8 @@ make_logo_grob <- function(type = c("wordmark", "mark"),
#' [make_logo_grob()]. #' [make_logo_grob()].
#' @param variant Character. `"light"` (default) or `"dark"`. Passed to #' @param variant Character. `"light"` (default) or `"dark"`. Passed to
#' [make_logo_grob()]. #' [make_logo_grob()].
#' @param position Character. Corner placement for the logo: `"bottom-right"`
#' (default), `"bottom-left"`, `"top-right"`, or `"top-left"`.
#' @param margin_param Numeric or `NULL`. Manual margin adjustment passed to #' @param margin_param Numeric or `NULL`. Manual margin adjustment passed to
#' [add_logo()]. #' [add_logo()].
#' @param font_scale Numeric. Inflate text sizes by this factor to compensate #' @param font_scale Numeric. Inflate text sizes by this factor to compensate
@@ -266,26 +301,38 @@ make_logo_grob <- function(type = c("wordmark", "mark"),
#' civilytics_logo() |> #' civilytics_logo() |>
#' grid.draw() #' grid.draw()
#' #'
#' # Dark theme with mark #' # Top-right placement
#' (ggplot(mpg, aes(displ, hwy)) +
#' geom_point() +
#' theme_civilytics_dark()) |>
#' civilytics_logo(variant = "dark", type = "mark") |>
#' grid.draw()
#'
#' # Disable font inflation
#' (ggplot(mpg, aes(displ, hwy)) + #' (ggplot(mpg, aes(displ, hwy)) +
#' geom_point() + #' geom_point() +
#' theme_civilytics()) |> #' theme_civilytics()) |>
#' civilytics_logo(font_scale = 1) |> #' civilytics_logo(position = "top-right") |>
#' grid.draw()
#'
#' # Bottom-left with mark
#' (ggplot(mpg, aes(displ, hwy)) +
#' geom_point() +
#' theme_civilytics()) |>
#' civilytics_logo(type = "mark", position = "bottom-left") |>
#' grid.draw()
#'
#' # Dark theme with mark in top-left
#' (ggplot(mpg, aes(displ, hwy)) +
#' geom_point() +
#' theme_civilytics_dark()) |>
#' civilytics_logo(variant = "dark", type = "mark",
#' position = "top-left") |>
#' grid.draw() #' grid.draw()
#' } #' }
civilytics_logo <- function(plot, civilytics_logo <- function(plot,
type = c("wordmark", "mark"), type = c("wordmark", "mark"),
variant = c("light", "dark"), variant = c("light", "dark"),
position = c("bottom-right", "bottom-left",
"top-right", "top-left"),
margin_param = NULL, margin_param = NULL,
font_scale = 1.1) { font_scale = 1.1) {
logo <- make_logo_grob(type = type, variant = variant) position <- match.arg(position)
add_logo(plot, logo, margin_param = margin_param, font_scale = font_scale) logo <- make_logo_grob(type = type, variant = variant, position = position)
add_logo(plot, logo, margin_param = margin_param, font_scale = font_scale,
position = position)
} }
+11 -1
View File
@@ -4,7 +4,13 @@
\alias{add_logo} \alias{add_logo}
\title{Add a logo to a ggplot2 object} \title{Add a logo to a ggplot2 object}
\usage{ \usage{
add_logo(plot, logo, margin_param = NULL, font_scale = 1.1) add_logo(
plot,
logo,
margin_param = NULL,
font_scale = 1.1,
position = c("bottom-right", "bottom-left", "top-right", "top-left")
)
} }
\arguments{ \arguments{
\item{plot}{a ggplot2 grob} \item{plot}{a ggplot2 grob}
@@ -17,6 +23,10 @@ add_logo(plot, logo, margin_param = NULL, font_scale = 1.1)
sizes before composing the plot with the logo. Default `1.1` inflates sizes before composing the plot with the logo. Default `1.1` inflates
text by ~10 \% to compensate for the viewport shrinkage caused by text by ~10 \% to compensate for the viewport shrinkage caused by
[gridExtra::arrangeGrob()]. Set to `1` to disable.} [gridExtra::arrangeGrob()]. Set to `1` to disable.}
\item{position}{Character. Corner placement for the logo: `"bottom-right"`
(default), `"bottom-left"`, `"top-right"`, or `"top-left"`. Controls
whether the logo is placed above or below the plot.}
} }
\value{ \value{
a grob with a logo attached to it ready to plot a grob with a logo attached to it ready to plot
+23 -11
View File
@@ -8,6 +8,7 @@ civilytics_logo(
plot, plot,
type = c("wordmark", "mark"), type = c("wordmark", "mark"),
variant = c("light", "dark"), variant = c("light", "dark"),
position = c("bottom-right", "bottom-left", "top-right", "top-left"),
margin_param = NULL, margin_param = NULL,
font_scale = 1.1 font_scale = 1.1
) )
@@ -21,6 +22,9 @@ civilytics_logo(
\item{variant}{Character. `"light"` (default) or `"dark"`. Passed to \item{variant}{Character. `"light"` (default) or `"dark"`. Passed to
[make_logo_grob()].} [make_logo_grob()].}
\item{position}{Character. Corner placement for the logo: `"bottom-right"`
(default), `"bottom-left"`, `"top-right"`, or `"top-left"`.}
\item{margin_param}{Numeric or `NULL`. Manual margin adjustment passed to \item{margin_param}{Numeric or `NULL`. Manual margin adjustment passed to
[add_logo()].} [add_logo()].}
@@ -33,8 +37,8 @@ A grob (from [gridExtra::arrangeGrob()]) ready to draw with
[grid::grid.draw()]. [grid::grid.draw()].
} }
\description{ \description{
A convenience wrapper that creates the logo grob and attaches it below the A convenience wrapper that creates the logo grob and attaches it to a
plot in one call. Designed for use with the base pipe `|>`. corner of the plot in one call. Designed for use with the base pipe `|>`.
} }
\details{ \details{
**Important:** R's `|>` has *higher* precedence than `+`, so you must **Important:** R's `|>` has *higher* precedence than `+`, so you must
@@ -58,18 +62,26 @@ library(ggplot2); library(grid)
civilytics_logo() |> civilytics_logo() |>
grid.draw() grid.draw()
# Dark theme with mark # Top-right placement
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
theme_civilytics_dark()) |>
civilytics_logo(variant = "dark", type = "mark") |>
grid.draw()
# Disable font inflation
(ggplot(mpg, aes(displ, hwy)) + (ggplot(mpg, aes(displ, hwy)) +
geom_point() + geom_point() +
theme_civilytics()) |> theme_civilytics()) |>
civilytics_logo(font_scale = 1) |> civilytics_logo(position = "top-right") |>
grid.draw()
# Bottom-left with mark
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
theme_civilytics()) |>
civilytics_logo(type = "mark", position = "bottom-left") |>
grid.draw()
# Dark theme with mark in top-left
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
theme_civilytics_dark()) |>
civilytics_logo(variant = "dark", type = "mark",
position = "top-left") |>
grid.draw() grid.draw()
} }
} }
+10 -1
View File
@@ -4,7 +4,11 @@
\alias{make_logo_grob} \alias{make_logo_grob}
\title{Get a Civilytics Logo grob} \title{Get a Civilytics Logo grob}
\usage{ \usage{
make_logo_grob(type = c("wordmark", "mark"), variant = c("light", "dark")) make_logo_grob(
type = c("wordmark", "mark"),
variant = c("light", "dark"),
position = c("bottom-right", "bottom-left", "top-right", "top-left")
)
} }
\arguments{ \arguments{
\item{type}{Character. `"wordmark"` (default) uses the full wordmark. \item{type}{Character. `"wordmark"` (default) uses the full wordmark.
@@ -13,6 +17,10 @@ make_logo_grob(type = c("wordmark", "mark"), variant = c("light", "dark"))
\item{variant}{Character. `"light"` (default) uses the dark logo for light \item{variant}{Character. `"light"` (default) uses the dark logo for light
backgrounds. `"dark"` uses the reverse (light) logo for dark backgrounds backgrounds. `"dark"` uses the reverse (light) logo for dark backgrounds
(pairs with [theme_civilytics_dark()]).} (pairs with [theme_civilytics_dark()]).}
\item{position}{Character. Corner placement for the logo: `"bottom-right"`
(default), `"bottom-left"`, `"top-right"`, or `"top-left"`. Controls
horizontal alignment of the logo grob.}
} }
\value{ \value{
A ggplot object (class `"gg"`) containing the logo grob. A ggplot object (class `"gg"`) containing the logo grob.
@@ -25,5 +33,6 @@ pipe-friendly [civilytics_logo()].
\examples{ \examples{
logo <- make_logo_grob() # wordmark, light logo <- make_logo_grob() # wordmark, light
logo <- make_logo_grob("mark", "dark") # mark, dark logo <- make_logo_grob("mark", "dark") # mark, dark
logo <- make_logo_grob(position = "bottom-left") # left-aligned
class(logo) # "gg" "ggplot" class(logo) # "gg" "ggplot"
} }
+33
View File
@@ -298,3 +298,36 @@ test_that("civilytics_logo accepts type and variant params", {
result <- suppressWarnings(civilytics_logo(p, type = "mark", variant = "dark")) result <- suppressWarnings(civilytics_logo(p, type = "mark", variant = "dark"))
expect_s3_class(result, "grob") expect_s3_class(result, "grob")
}) })
# --- position argument -------------------------------------------------------
test_that("civilytics_logo accepts all four position values", {
p <- ggplot(mpg, aes(displ, hwy)) + geom_point() + theme_civilytics()
for (pos in c("bottom-right", "bottom-left", "top-right", "top-left")) {
result <- suppressWarnings(civilytics_logo(p, position = pos))
expect_s3_class(result, "grob")
}
})
test_that("civilytics_logo errors on invalid position", {
p <- ggplot(mpg, aes(displ, hwy)) + geom_point() + theme_civilytics()
expect_error(civilytics_logo(p, position = "center"))
})
test_that("make_logo_grob accepts position argument", {
for (pos in c("bottom-right", "bottom-left", "top-right", "top-left")) {
logo <- make_logo_grob(position = pos)
expect_s3_class(logo, "gg")
}
})
test_that("top positions place logo before plot in arrangeGrob", {
p <- ggplot(mpg, aes(displ, hwy)) + geom_point() + theme_civilytics()
result_top <- suppressWarnings(civilytics_logo(p, position = "top-right"))
result_bot <- suppressWarnings(civilytics_logo(p, position = "bottom-right"))
# Top: logo is first grob; bottom: plot is first grob.
# The arrangeGrob heights differ in order
expect_s3_class(result_top, "grob")
expect_s3_class(result_bot, "grob")
})