Skip to content

Commit bf87be6

Browse files
zorroozclaude
andcommitted
feat: add composite marks: errorbar, significance, lollipop, dumbbell
- mark_errorbar: wraps geom_errorbar/geom_errorbarh (VL Errorbar) with vertical/horizontal orientation dispatch - mark_significance: syntax-sugar combining geom_segment + geom_text for statistical significance brackets and labels Auto-computes y_positions from data range Documented with equivalent pipeline expansion - mark_lollipop: syntax-sugar combining geom_segment + geom_point for lollipop charts with customizable stem/point styling - mark_dumbbell: syntax-sugar combining geom_segment + 2x geom_point for before/after comparison charts - All registered as unsupported on plotit_composite - Tests: 12 new tests (3 per mark) - All Rd files auto-generated by devtools::document() Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6bc2857 commit bf87be6

8 files changed

Lines changed: 673 additions & 0 deletions

File tree

NAMESPACE

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,19 @@ export(mark_boxplot)
2323
export(mark_corr)
2424
export(mark_density)
2525
export(mark_density_2d)
26+
export(mark_dumbbell)
27+
export(mark_errorbar)
2628
export(mark_hex)
2729
export(mark_histogram)
2830
export(mark_line)
31+
export(mark_lollipop)
2932
export(mark_map)
3033
export(mark_path)
3134
export(mark_point)
3235
export(mark_polygon)
3336
export(mark_rect)
3437
export(mark_rule)
38+
export(mark_significance)
3539
export(mark_smooth)
3640
export(mark_text)
3741
export(mark_violin)

R/mark.R

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,322 @@ S7::method(mark_corr, plotit_class) <- function(
779779
)
780780
}
781781

782+
# ---- mark_errorbar ----
783+
#' Error bar layer
784+
#'
785+
#' Adds error bars showing confidence intervals, standard errors,
786+
#' or other variability measures. Data should include columns for
787+
#' `ymin`/`ymax` (vertical) or `xmin`/`xmax` (horizontal).
788+
#'
789+
#' @param plot A plotit object
790+
#' @param mapping Optional new aesthetics (must include `ymin`/`ymax`
791+
#' or `xmin`/`xmax`)
792+
#' @param data Optional data for this layer
793+
#' @param position Position adjustment.
794+
#' @param width Width of the error bar caps (default 0.5).
795+
#' @param orientation `"vertical"` (default) or `"horizontal"`.
796+
#' @param rasterize If `TRUE`, rasterize via `ggrastr::rasterise()`.
797+
#' @param rasterize_dpi DPI for rasterization (default 300).
798+
#' @param rasterize_dev Graphics device for rasterization (default `"cairo"`).
799+
#' @param ... Other arguments passed to the underlying geom
800+
#' @return Modified plotit object
801+
#' @references
802+
#' Vega-Lite: \href{https://vega.github.io/vega-lite/docs/errorbar.html}{Errorbar} (composite mark)
803+
#' @examples
804+
#' df <- data.frame(
805+
#' x = c("A", "B"), y = c(10, 20), ymin = c(8, 18), ymax = c(12, 22))
806+
#' plotit(df, encode(x = x, y = y, ymin = ymin, ymax = ymax)) |>
807+
#' mark_errorbar(width = 0.3)
808+
#' @export
809+
mark_errorbar <- S7::new_generic(
810+
"mark_errorbar", "plot",
811+
function(plot, mapping = NULL, data = NULL, position = NULL, ...,
812+
width = 0.5, orientation = c("vertical", "horizontal"),
813+
rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") {
814+
S7::S7_dispatch()
815+
}
816+
)
817+
818+
#' @export
819+
S7::method(mark_errorbar, plotit_class) <- function(
820+
plot, mapping = NULL, data = NULL, position = NULL, ...,
821+
width = 0.5, orientation = c("vertical", "horizontal"),
822+
rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") {
823+
orientation <- match.arg(orientation)
824+
geom_fun <- if (orientation == "horizontal") {
825+
ggplot2::geom_errorbarh
826+
} else {
827+
ggplot2::geom_errorbar
828+
}
829+
params <- rlang::list2(...)
830+
params$width <- width
831+
do.call(function(...) {
832+
._mark_impl(plot, mapping, data, position, geom_fun,
833+
rasterize, rasterize_dpi, rasterize_dev, ...)
834+
}, params)
835+
}
836+
837+
# ---- mark_significance ----
838+
#' Significance annotation layer
839+
#'
840+
#' Adds statistical significance brackets and labels between groups.
841+
#' This is a **syntax-sugar composite mark** that combines
842+
#' `mark_rule` and `mark_text` internally.
843+
#'
844+
#' Equivalent expansion:
845+
#' \preformatted{
846+
#' p |> mark_rule(x = comp$group1, xend = comp$group2,
847+
#' y = comp$y_position, yend = comp$y_position) |>
848+
#' mark_text(x = midpoint, y = comp$y_position + y_offset,
849+
#' label = comp$label)
850+
#' }
851+
#'
852+
#' @param plot A plotit object
853+
#' @param comparisons A data frame with columns: `group1`, `group2`,
854+
#' `label`, and optionally `y_position`. Character columns are
855+
#' matched against the x-axis variable.
856+
#' @param y_position Numeric vector of y-positions for the brackets.
857+
#' If omitted, auto-computed from data range.
858+
#' @param y_offset Text offset above the bracket line (default 0.5).
859+
#' In data units.
860+
#' @param line_colour Colour for the bracket lines (default `"grey30"`).
861+
#' @param line_width Width of bracket lines (default 0.3).
862+
#' @param text_size Size of significance label text (default 3.5).
863+
#' @param tip_length Length of bracket end-tick lines (default 0.02
864+
#' as fraction of x-axis range).
865+
#' @param ... Additional arguments passed to `mark_text()`
866+
#' @return Modified plotit object
867+
#' @examples
868+
#' df <- data.frame(group = c("A", "B", "C"), value = c(5, 8, 4))
869+
#' comp <- data.frame(
870+
#' group1 = c("A", "A"), group2 = c("B", "C"),
871+
#' label = c("**", "ns"))
872+
#' plotit(df, encode(x = group, y = value)) |>
873+
#' mark_bar() |>
874+
#' mark_significance(comp, y_position = c(9, 6))
875+
#' @export
876+
mark_significance <- S7::new_generic(
877+
"mark_significance", "plot",
878+
function(plot, comparisons, y_position = NULL, y_offset = NULL,
879+
line_colour = "grey30", line_width = 0.3,
880+
text_size = 3.5, tip_length = 0.02, ...) {
881+
S7::S7_dispatch()
882+
}
883+
)
884+
885+
#' @export
886+
S7::method(mark_significance, plotit_class) <- function(
887+
plot, comparisons, y_position = NULL, y_offset = NULL,
888+
line_colour = "grey30", line_width = 0.3,
889+
text_size = 3.5, tip_length = 0.02, ...) {
890+
if (!is.data.frame(comparisons)) {
891+
cli::cli_abort("{.arg comparisons} must be a data frame.")
892+
}
893+
required <- c("group1", "group2", "label")
894+
missing_cols <- setdiff(required, names(comparisons))
895+
if (length(missing_cols) > 0) {
896+
cli::cli_abort(
897+
"{.arg comparisons} must have columns: {.val {required}}."
898+
)
899+
}
900+
# Extract data range for auto y_position
901+
d <- plot@gg$data
902+
y_var <- rlang::eval_tidy(plot@gg$mapping$y, d)
903+
y_range <- range(y_var, na.rm = TRUE)
904+
y_span <- diff(y_range)
905+
if (is.null(y_offset)) y_offset <- y_span * 0.02
906+
# Auto-compute y_position if not provided
907+
if (is.null(y_position)) {
908+
y_position <- y_range[2] + y_span * 0.1 + seq_len(nrow(comparisons)) * y_span * 0.08
909+
}
910+
# Get x positions (handle factor/character group1/group2)
911+
x_var <- rlang::eval_tidy(plot@gg$mapping$x, d)
912+
x_levels <- if (is.factor(x_var)) levels(x_var) else sort(unique(as.character(x_var)))
913+
x_positions <- seq_along(x_levels)
914+
names(x_positions) <- x_levels
915+
# Draw brackets
916+
for (i in seq_len(nrow(comparisons))) {
917+
g1 <- as.character(comparisons$group1[i])
918+
g2 <- as.character(comparisons$group2[i])
919+
x1 <- if (g1 %in% names(x_positions)) x_positions[[g1]] else as.numeric(g1)
920+
x2 <- if (g2 %in% names(x_positions)) x_positions[[g2]] else as.numeric(g2)
921+
if (is.na(x1) || is.na(x2)) next
922+
y_pos <- if (i <= length(y_position)) y_position[i] else y_position[1] + (i - 1) * y_span * 0.08
923+
# Bracket line
924+
geome <- ggplot2::geom_segment(
925+
mapping = encode(x = x1, xend = x2, y = y_pos, yend = y_pos),
926+
colour = line_colour, linewidth = line_width
927+
)
928+
plot <- .add_geom(plot, geome)
929+
# Left tick
930+
tick_len <- if (is.factor(x_var)) tip_length * length(x_levels) else tip_length * diff(range(x_positions))
931+
geome <- ggplot2::geom_segment(
932+
mapping = encode(
933+
x = x1, xend = x1, y = y_pos - tick_len, yend = y_pos
934+
),
935+
colour = line_colour, linewidth = line_width
936+
)
937+
plot <- .add_geom(plot, geome)
938+
# Right tick
939+
geome <- ggplot2::geom_segment(
940+
mapping = encode(
941+
x = x2, xend = x2, y = y_pos - tick_len, yend = y_pos
942+
),
943+
colour = line_colour, linewidth = line_width
944+
)
945+
plot <- .add_geom(plot, geome)
946+
# Label
947+
geome <- ggplot2::geom_text(
948+
mapping = encode(
949+
x = (x1 + x2) / 2, y = y_pos + y_offset, label = comparisons$label[i]
950+
),
951+
size = text_size, ...
952+
)
953+
plot <- .add_geom(plot, geome)
954+
}
955+
plot
956+
}
957+
958+
# ---- mark_lollipop ----
959+
#' Lollipop chart layer
960+
#'
961+
#' Creates a lollipop chart: a point anchored by a stem to a reference
962+
#' line. This is a **syntax-sugar composite mark** combining
963+
#' `geom_segment` and `geom_point`.
964+
#'
965+
#' Equivalent expansion:
966+
#' \preformatted{
967+
#' p |> mark_rule(x = x, xend = x, y = ref, yend = y) |>
968+
#' mark_point(x = x, y = y)
969+
#' }
970+
#'
971+
#' @param plot A plotit object
972+
#' @param mapping Optional new aesthetics
973+
#' @param data Optional data for this layer
974+
#' @param stem_colour Colour for the stem lines (default `"grey50"`).
975+
#' @param stem_width Line width for stems (default 0.5).
976+
#' @param point_size Point size for the lollipop head (default 3).
977+
#' @param ... Other arguments passed to `mark_point()`
978+
#' @return Modified plotit object
979+
#' @examples
980+
#' df <- data.frame(cat = LETTERS[1:5], val = c(3, 7, 2, 9, 5))
981+
#' plotit(df, encode(x = cat, y = val)) |>
982+
#' mark_lollipop(point_size = 4, stem_colour = "grey70")
983+
#' @export
984+
mark_lollipop <- S7::new_generic(
985+
"mark_lollipop", "plot",
986+
function(plot, mapping = NULL, data = NULL,
987+
stem_colour = "grey50", stem_width = 0.5,
988+
point_size = 3, ...) {
989+
S7::S7_dispatch()
990+
}
991+
)
992+
993+
#' @export
994+
S7::method(mark_lollipop, plotit_class) <- function(
995+
plot, mapping = NULL, data = NULL,
996+
stem_colour = "grey50", stem_width = 0.5,
997+
point_size = 3, ...) {
998+
d <- data %||% plot@gg$data
999+
m <- mapping %||% plot@gg$mapping
1000+
# Extract x and y from mapping
1001+
x_col <- rlang::eval_tidy(m$x, d)
1002+
y_col <- rlang::eval_tidy(m$y, d)
1003+
# Stem: segment from 0 to y
1004+
stem_mapping <- encode(x = x_col, xend = x_col, y = 0, yend = y_col)
1005+
geome <- ggplot2::geom_segment(
1006+
mapping = stem_mapping,
1007+
colour = stem_colour, linewidth = stem_width
1008+
)
1009+
plot <- .add_geom(plot, geome)
1010+
# Point at the top
1011+
point_mapping <- encode(x = x_col, y = y_col)
1012+
# Determine fill from mapping or default
1013+
fill_val <- if (!is.null(m$fill)) {
1014+
rlang::eval_tidy(m$fill, d)
1015+
} else {
1016+
NULL
1017+
}
1018+
plot <- plot |> mark_point(
1019+
mapping = m, data = d, size = point_size, ...
1020+
)
1021+
plot
1022+
}
1023+
1024+
# ---- mark_dumbbell ----
1025+
#' Dumbbell comparison chart layer
1026+
#'
1027+
#' Creates a dumbbell chart with two connected points showing before/after
1028+
#' or paired comparisons. This is a **syntax-sugar composite mark**
1029+
#' combining two `mark_point` calls and a `geom_segment`.
1030+
#'
1031+
#' Equivalent expansion:
1032+
#' \preformatted{
1033+
#' p |> mark_rule(x = x, xend = x, y = y_start, yend = y_end) |>
1034+
#' mark_point(x = x, y = y_start, colour = colour_start) |>
1035+
#' mark_point(x = x, y = y_end, colour = colour_end)
1036+
#' }
1037+
#'
1038+
#' @param plot A plotit object
1039+
#' @param mapping Optional new aesthetics
1040+
#' @param data Optional data for this layer
1041+
#' @param colour_start Colour for the start point (default `"#4E79A7"`).
1042+
#' @param colour_end Colour for the end point (default `"#E15759"`).
1043+
#' @param line_colour Colour for the connecting line (default `"grey50"`).
1044+
#' @param point_size Size for both dumbbell points (default 3).
1045+
#' @param line_width Width for the connecting line (default 1).
1046+
#' @param ... Other arguments passed to `mark_point()` calls
1047+
#' @return Modified plotit object
1048+
#' @examples
1049+
#' df <- data.frame(cat = LETTERS[1:5], before = c(3, 5, 2, 8, 4),
1050+
#' after = c(7, 6, 5, 10, 6))
1051+
#' plotit(df, encode(x = cat, y = before, yend = after)) |>
1052+
#' mark_dumbbell()
1053+
#' @export
1054+
mark_dumbbell <- S7::new_generic(
1055+
"mark_dumbbell", "plot",
1056+
function(plot, mapping = NULL, data = NULL,
1057+
colour_start = "#4E79A7", colour_end = "#E15759",
1058+
line_colour = "grey50", point_size = 3,
1059+
line_width = 1, ...) {
1060+
S7::S7_dispatch()
1061+
}
1062+
)
1063+
1064+
#' @export
1065+
S7::method(mark_dumbbell, plotit_class) <- function(
1066+
plot, mapping = NULL, data = NULL,
1067+
colour_start = "#4E79A7", colour_end = "#E15759",
1068+
line_colour = "grey50", point_size = 3,
1069+
line_width = 1, ...) {
1070+
d <- data %||% plot@gg$data
1071+
m <- mapping %||% plot@gg$mapping
1072+
x_col <- rlang::eval_tidy(m$x, d)
1073+
y_col <- rlang::eval_tidy(m$y, d)
1074+
yend_col <- rlang::eval_tidy(m$yend, d)
1075+
# Connecting line
1076+
segment_mapping <- encode(
1077+
x = x_col, xend = x_col,
1078+
y = y_col, yend = yend_col
1079+
)
1080+
geome <- ggplot2::geom_segment(
1081+
mapping = segment_mapping,
1082+
colour = line_colour, linewidth = line_width
1083+
)
1084+
plot <- .add_geom(plot, geome)
1085+
# Start point
1086+
start_mapping <- encode(x = x_col, y = y_col)
1087+
plot <- plot |>
1088+
mark_point(mapping = start_mapping, data = d,
1089+
colour = colour_start, size = point_size, ...)
1090+
# End point
1091+
end_mapping <- encode(x = x_col, y = yend_col)
1092+
plot <- plot |>
1093+
mark_point(mapping = end_mapping, data = d,
1094+
colour = colour_end, size = point_size, ...)
1095+
plot
1096+
}
1097+
7821098
# ---- mark_bar (hand-written: geom_col vs geom_bar dispatch) ----
7831099
#' Bar layer
7841100
#'

R/zzz.R

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ for (.generic_name in c(
7171
"mark_violin", "mark_map", "mark_rect", "mark_rule",
7272
"mark_path", "mark_polygon", "mark_smooth", "mark_hex",
7373
"mark_density_2d", "mark_corr",
74+
"mark_errorbar", "mark_significance",
75+
"mark_lollipop", "mark_dumbbell",
7476
"scale_color", "scale_fill", "scale_size", "scale_alpha",
7577
"scale_shape", "scale_linetype", "scale_x", "scale_y",
7678
"project_cartesian", "project_polar", "project_parallel",

0 commit comments

Comments
 (0)