ggtwotone
is an R package that extends ggplot2
with dual-stroke and contrast-aware geoms. It helps you create clear, high-contrast annotations and lines that remain visible across complex or variable backgrounds.
geom_segment_dual(): Dual-stroke line segments with vertical offset
geom_lm_dual(): Dual-tone regression line with contrast-aware strokes
geom_curve_dual(): Dual-stroke curved line annotations
geom_curve_dual_function(): Plot mathematical or statistical functions as smooth dual-stroke curves
geom_text_contrast(): Automatically switches between light/dark text color based on background
adjust_contrast_pair(): Helper function to find contrast-boosted color pairs using WCAG/APCA
You can install the development version of ggtwotone from GitHub with:
# install.packages("pak")
pak::pak("bwanniarachchige2/ggtwotone")
These are some examples which show you how to use the package:
library(ggtwotone) # automatically loads ggplot2 if it is not active
#> Loading required package: ggplot2
library(magick)
#> Linking to ImageMagick 6.9.12.93
#> Enabled features: cairo, fontconfig, freetype, heic, lcms, pango, raw, rsvg, webp
#> Disabled features: fftw, ghostscript, x11
library(grid)
img <- magick::image_read("man/figures/background_image.jpg")
# Convert image to a rasterGrob
bg_grob <- grid::rasterGrob(img, width = unit(1,"npc"), height = unit(1,"npc"))
# Plot
ggplot() +
annotation_custom(bg_grob, xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf) +
geom_curve_dual_function(
fun = dnorm,
xlim = c(-3, 6),
color1 = "#FFFFFF",
color2 = "#000000",
offset = 0.003,
linewidth = 1.2,
smooth = TRUE
) +
geom_curve_dual_function(
fun = function(x) 0.5 * exp(-abs(x - 2)),
xlim = c(-3, 6),
color1 = "#FFFFCC",
color2 = "#4B0000",
offset = 0.003,
linewidth = 1.2,
smooth = TRUE
) +
coord_cartesian(ylim = c(0, 0.5)) +
theme_void() +
theme(
plot.background = element_rect(fill = "transparent", color = NA),
panel.background = element_rect(fill = "transparent", color = NA)
) +
labs(
title = "Curves over Image Background",
subtitle = "Dual-stroke rendering stays visible over image background"
)
library(dplyr)
#>
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#>
#> filter, lag
#> The following objects are masked from 'package:base':
#>
#> intersect, setdiff, setequal, union
# Zone-colored background
set.seed(42)
tile_df <- expand.grid(x = -7:7, y = -7:7)
zones <- c("Desert", "Forest", "Sea", "Urban")
zone_colors <- c(
"Desert" = "#EDC9AF",
"Forest" = "#14532d",
"Sea" = "#0F3556",
"Urban" = "#eeeeee"
)
tile_df$zone <- sample(
zones,
size = nrow(tile_df),
replace = TRUE,
prob = c(0.2, 0.2, 0.4, 0.2)
)
# Realistic wind vectors
set.seed(42)
n <- 25
wind_df <- data.frame(
x = sample(-4:4, n, replace = TRUE),
y = sample(-4:4, n, replace = TRUE),
angle = runif(n, 180, 270), # Southwest quadrant
speed = runif(n, 1.5, 4) # Speed in m/s
) |>
mutate(
xend = x + speed * cos(angle * pi / 180),
yend = y + speed * sin(angle * pi / 180)
)
# Plot
ggplot() +
geom_tile(data = tile_df, aes(x = x, y = y, fill = zone)) +
scale_fill_manual(values = zone_colors, name = "Zone Type") +
geom_segment_dual(
data = wind_df,
aes(x = x, y = y, xend = xend, yend = yend),
# colour1 = "#FFFFFF", colour2 = "#111111",
linewidth = 1.2,
arrow = arrow(length = unit(0.15, "inches"), type = "open"),
alpha = 0.9
) +
coord_fixed(xlim = range(c(wind_df$x, wind_df$xend)),
ylim = range(c(wind_df$y, wind_df$yend))) +
theme_minimal(base_size = 14) +
theme(
panel.background = element_rect(fill = "white", color = NA)
) +
labs(
title = "Wind Directions Across Zones",
subtitle = "Arrow direction and length represent wind flow and speed (m/s);\ndual-stroke improves visibility",
x = "Longitude",
y = "Latitude"
)
#> Warning in geom_segment_dual(data = wind_df, aes(x = x, y = y, xend = xend, :
#> Ignoring empty aesthetics: `colour1` and `colour2`.
This example visualizes wind directions and speeds over a zone-classified terrain map using geom_segment_dual()
.
Arrow length is scaled by wind speed (in m/s), and dual-stroke styling ensures clear visibility across contrasting terrain types such as desert, forest, sea, and urban zones.
library(ggplot2)
library(ggtwotone)
df <- mpg
# plot
ggplot(df, aes(x = displ, y = hwy)) +
geom_point(color = "darkgreen", size = 3, alpha = 0.7) +
geom_lm_dual(
data = df,
mapping = aes(x = displ, y = hwy),
method = "lm",
formula = hwy ~ displ,
base_color = "#555555",
contrast = 4.5,
method_contrast = "auto",
linewidth = 1.2
) +
theme_minimal(base_size = 14) +
labs(
title = "Engine Displacement vs. Highway MPG",
subtitle = "Regression line with dual-stroke contrast for visibility",
x = "Displacement (L)",
y = "Highway MPG"
)
#> Warning: Duplicated aesthetics after name standardisation: colour1 and colour2
#> Warning in geom_segment_dual(data = reg_segment, mapping = aes(x = x, y = y, :
#> Ignoring empty aesthetics: `colour1` and `colour2`.
library(dplyr)
# Sample from real storm data
data("storms", package = "dplyr")
storm_subset <- storms %>%
filter(name == "Katrina", year == 2005) %>%
mutate(
x = lag(long), y = lag(lat),
xend = long, yend = lat
) %>%
filter(!is.na(x), !is.na(y)) # remove first row with NA lag
# Plot
ggplot(storm_subset) +
geom_segment_dual(
aes(x = x, y = y, xend = xend, yend = yend, group = 1),
color1 = "white", color2 = "black",
linewidth = 1.2,
arrow = arrow(length = unit(0.08, "inches"), type = "open")
) +
geom_point(aes(x = xend, y = yend, color = wind), size = 2) +
scale_color_viridis_c(option = "C", name = "Wind Speed") +
coord_fixed() +
labs(
title = "Storm Track of Hurricane Katrina (2005)",
subtitle = "Arrow direction shows storm movement; \nstroke ensures visibility on top of wind-colored dots",
x = "Longitude", y = "Latitude"
) +
theme_dark()
#> Warning: Duplicated aesthetics after name standardisation: colour1 and colour2
#> Warning in geom_segment_dual(aes(x = x, y = y, xend = xend, yend = yend, :
#> Ignoring empty aesthetics: `colour1` and `colour2`.
The basic idea of drawing line segments with the segment_dual
geom is to replace any line segment from A to B by two line segments drawn side by sidein different color hues chosen such that at least one of the coior hues has a sufficiently large (color) contrast to any background colors.
dframe <- data.frame(x =c(1,3,5), xmax = c(2, 4, 6), y = c(3,2,1), ymax=c(3,4,5), group = 4:6)
dframe |>
ggplot(aes(x = x, xend=xmax, y = y, yend=ymax)) +
geom_point(size = 5) +
geom_point(aes(x = xmax, y = ymax), size = 5) +
geom_segment(aes(group = group), linewidth = 20, alpha = 0.5) +
geom_segment_dual(aes(group = group, color1 = factor(group)), linewidth = 20, alpha = 0.5) +
theme_bw() + theme(aspect.ratio = 1/3) #+
#> Warning in geom_segment_dual(aes(group = group, color1 = factor(group)), :
#> Ignoring empty aesthetics: `colour1` and `colour2`.
# geom_point(size = 5) +
# geom_point(aes(x = xmax, y = ymax), size = 5)
In real-world plots, especially on mixed backgrounds (grayscale tiles, images, or map layers), default ggplot2 annotations can disappear. ggtwotone solves this with:
Dual-stroke visibility: top and bottom layers ensure readability
Contrast checking: uses APCA/WCAG to optimize color pairing
Fallback safety: gracefully assigns black/white when needed
You can explore all functions in the Reference Manual, or see them in the R help tab after loading the package.