Skip to content

Commit b103cdf

Browse files
authored
Add se to annotate_lineplot_df() summary table (#308)
## What changed * `annotate_lineplot_df()` gains `"se"` as a `summary_stats` option, reporting the standard error (`sd / sqrt(n)`) in the summary table below the plot. (#307) ## Why `gg_lineplot()` already draws Mean +/- 1xSE error bars via `variability = "se"`, but the summary table under the plot could only report `n`, `mean`, `sd`, `median`, and `iqr`. This adds the missing SE row so the table can match the error bars. SE is computed through the existing cards glue engine (`{se}`), no new dependency. Verified numerically against `sd / sqrt(n)` in a new test. Closes #307 --------- Co-authored-by: Davide Garolini <11279768+Melkiades@users.noreply.github.com>
1 parent ca694d2 commit b103cdf

7 files changed

Lines changed: 239 additions & 6 deletions

File tree

‎NEWS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
* `tbl_null_report()` now centers its message, so with no body content it reads as a "no data" panel spanning the table instead of text hugging the left edge. (#305)
44

5+
* `annotate_lineplot_df()` gains `"se"` and `"ci"` as `summary_stats` options, reporting the standard error and the confidence interval of the mean (at a new `conf_level` argument) in the summary table below the plot. (#307)
6+
7+
* `gg_lineplot()` gains a `show_n` argument to append group sizes to the legend (e.g. `"Placebo (N=42)"`) and a `jitter` argument to horizontally separate overlapping groups while keeping points, lines, and error bars aligned. (#307)
8+
59
* Added `modify_split_caption()` to subtitle each page of a split `{gtsummary}` table (e.g. from `tbl_listing()`, `tbl_baseline_chg()`, or `tbl_shift()`) from its split level via a glue `pattern` (default `"Parameter: {spl_level}"`) and hide the now-redundant split column. (#282)
610

711
* `annotate_lineplot_df()` and `annotate_pkc_df()` gain a `font_size` argument to control the summary table font size, matching `annotate_riskdf()`. The `text_size` argument of `annotate_pkc_df()` is soft-deprecated in favor of `font_size`. (#295)

‎R/annotate_gg.R‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
#' Optional column names as strings. If `NULL` (default), the function
1414
#' automatically extracts these from the `gg_plt` mapping.
1515
#' @param summary_stats (`character`)\cr
16-
#' Vector of statistics to include. Defaults to `c("n", "mean", "sd")`.
16+
#' Vector of statistics to include. One or more of `"n"`, `"mean"`, `"sd"`,
17+
#' `"se"`, `"ci"`, `"median"`, and `"iqr"`. Defaults to `c("n", "mean", "sd")`.
18+
#' `"ci"` reports the confidence interval of the mean at `conf_level`.
19+
#' @param conf_level (`numeric`)\cr
20+
#' Confidence level for the `"ci"` statistic. Defaults to `0.95`.
1721
#' @param digits (`numeric`, `list`, or `formula`)\cr
1822
#' Optional specification for the number of decimal places for the summary statistics.
1923
#' Can be a single integer (e.g., `2`), a vector of integers matching the statistics
@@ -63,6 +67,7 @@ annotate_lineplot_df <- function(gg_plt,
6367
y = NULL,
6468
group = NULL,
6569
summary_stats = c("n", "mean", "sd"),
70+
conf_level = 0.95,
6671
digits = NULL,
6772
font_size = 10,
6873
rel_height_plot = 0.75) {
@@ -72,6 +77,17 @@ annotate_lineplot_df <- function(gg_plt,
7277
))
7378
}
7479

80+
# Confidence interval bounds for the table, delegated to the same helper that
81+
# draws the error bars in `gg_lineplot()` so the table can never disagree with
82+
# the plotted interval. They are picked up by name when referenced in the
83+
# `{conf.low}` / `{conf.high}` glue of the `statistic` argument below.
84+
conf.low <- function(x) {
85+
.calc_stats(x, stat = "mean", variability = "ci", conf_level = conf_level)$ymin
86+
}
87+
conf.high <- function(x) {
88+
.calc_stats(x, stat = "mean", variability = "ci", conf_level = conf_level)$ymax
89+
}
90+
7591
# Auto-extract variable names from the ggplot mapping if not explicitly provided
7692
if (is.null(x)) x <- gg_varname_extraction(gg_plt$mapping$x)
7793
if (is.null(y)) y <- gg_varname_extraction(gg_plt$mapping$y)
@@ -90,14 +106,19 @@ annotate_lineplot_df <- function(gg_plt,
90106
"n" = "{N_nonmiss}",
91107
"mean" = "{mean}",
92108
"sd" = "{sd}",
109+
"se" = "{se}",
110+
"ci" = "{conf.low}, {conf.high}",
93111
"median" = "{median}",
94112
"iqr" = "{p25}, {p75}"
95113
)
96114

115+
ci_label <- paste0(round(conf_level * 100), "% CI")
97116
stat_labels <- c(
98117
"n" = "n",
99118
"mean" = "Mean",
100119
"sd" = "SD",
120+
"se" = "SE",
121+
"ci" = ci_label,
101122
"median" = "Median",
102123
"iqr" = "IQR"
103124
)
@@ -151,7 +172,7 @@ annotate_lineplot_df <- function(gg_plt,
151172
dplyr::select(-remove)
152173

153174
raw_labels <- as.character(formatted_df[[1]])
154-
is_stat <- trimws(raw_labels) %in% c("n", "Mean", "SD", "Median", "IQR")
175+
is_stat <- trimws(raw_labels) %in% trimws(gts_stat_labels)
155176

156177
# Apply Plotmath styling (bold for headers, indent for stats)
157178
y_labels <- parse(text = ifelse(

‎R/gg_lineplot.R‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
#' Default is `"ci"`.
2020
#' @param conf_level (`numeric`)\cr
2121
#' Confidence level for error bars when `variability = "ci"` (default: `0.95`).
22+
#' @param show_n (`flag`)\cr
23+
#' When `TRUE`, append the group size to each legend key (e.g.
24+
#' `"Placebo (N=42)"`). Only applies when `group` is supplied. Default `FALSE`.
25+
#' @param jitter (`numeric`)\cr
26+
#' Horizontal jitter width applied to points, lines and error bars together
27+
#' so they stay aligned. `0` (default) disables jittering and uses dodging
28+
#' only. A small value such as `0.1` helps separate overlapping groups.
2229
#'
2330
#' @return A `ggplot` object of class `crane_gg_line`.
2431
#'
@@ -66,17 +73,34 @@
6673
#' linetype = "dashed",
6774
#' color = "gray50"
6875
#' )
76+
#'
77+
#' # 4. Show group sizes in the legend and jitter overlapping groups
78+
#' gg_lineplot(
79+
#' data = mock_adlb,
80+
#' x = AVISIT,
81+
#' y = AVAL,
82+
#' group = ARM,
83+
#' show_n = TRUE,
84+
#' jitter = 0.1
85+
#' )
6986
#' @export
7087
gg_lineplot <- function(data,
7188
x,
7289
y,
7390
group = NULL,
7491
stat = c("mean", "median"),
7592
variability = c("ci", "sd", "se", "iqr", "none"),
76-
conf_level = 0.95) {
93+
conf_level = 0.95,
94+
show_n = FALSE,
95+
jitter = 0) {
7796
# 1. Argument Matching and Validation
7897
stat <- match.arg(stat)
7998
variability <- match.arg(variability)
99+
check_scalar_logical(show_n)
100+
check_scalar(jitter)
101+
if (!is.numeric(jitter) || jitter < 0) {
102+
cli::cli_abort("{.arg jitter} must be a non-negative number.")
103+
}
80104

81105
# Prevent mathematically invalid combinations
82106
if (stat == "mean" && variability == "iqr") {
@@ -137,7 +161,19 @@ gg_lineplot <- function(data,
137161
dplyr::left_join(data, by = x)
138162
}
139163

140-
pd <- ggplot2::position_dodge(width = 0.4)
164+
# A single shared position object keeps the points, connecting lines and
165+
# error bars aligned. When `jitter > 0` a fixed seed makes every layer apply
166+
# the same horizontal jitter, so they never drift apart.
167+
pd <- if (jitter > 0) {
168+
ggplot2::position_jitterdodge(
169+
jitter.width = jitter,
170+
jitter.height = 0,
171+
dodge.width = 0.4,
172+
seed = 123
173+
)
174+
} else {
175+
ggplot2::position_dodge(width = 0.4)
176+
}
141177

142178
# 3. Build Plot: Use clean aes() mapping so gg_varname_extraction succeeds
143179
if (length(group) > 0) {
@@ -201,6 +237,23 @@ gg_lineplot <- function(data,
201237
# Dynamically label the legends to overwrite the ugly ".data[[group]]" default
202238
if (length(group) > 0) {
203239
p <- p + ggplot2::labs(color = group, linetype = group, shape = group)
240+
241+
# Optionally append the group size to each legend key, e.g. "Placebo (N=42)"
242+
if (show_n) {
243+
grp_n <- table(data[[group]])
244+
grp_labels <- stats::setNames(
245+
paste0(names(grp_n), " (N=", as.integer(grp_n), ")"),
246+
names(grp_n)
247+
)
248+
p <- p +
249+
ggplot2::scale_color_discrete(labels = grp_labels) +
250+
ggplot2::scale_shape_discrete(labels = grp_labels) +
251+
ggplot2::scale_linetype_discrete(labels = grp_labels)
252+
}
253+
} else if (show_n) {
254+
cli::cli_warn(
255+
"{.arg show_n} has no effect when {.arg group} is not supplied."
256+
)
204257
}
205258

206259

‎man/annotate_lineplot_df.Rd‎

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎man/gg_lineplot.Rd‎

Lines changed: 22 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎tests/testthat/test-annotate_gg.R‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,87 @@ test_that("annotate_lineplot_df formats numeric digits correctly", {
136136
expect_true(grepl("\\.[0-9]{2}$", val_sd))
137137
})
138138

139+
test_that("annotate_lineplot_df computes se and matches sd / sqrt(n)", {
140+
mock_df2gg <- function(df, ...) df
141+
142+
res_df <- testthat::with_mocked_bindings(
143+
{
144+
annotate_lineplot_df(
145+
gg_plt = p_valid,
146+
data = mock_adlb,
147+
summary_stats = c("n", "mean", "se"),
148+
digits = c(0, 4, 4)
149+
)
150+
},
151+
df2gg_aligned = mock_df2gg
152+
)
153+
154+
expect_s3_class(res_df, "data.frame")
155+
156+
# SE row is present and labelled
157+
expect_true(any(grepl("SE$", trimws(res_df$Group))))
158+
159+
# Verify Visit '0' SE equals sd / sqrt(n) for the first treatment group
160+
x <- mock_adlb$AVAL[mock_adlb$AVISIT == 0 & mock_adlb$ARM == "Trt A"]
161+
expected_se <- stats::sd(x) / sqrt(length(x))
162+
val_se <- trimws(res_df[grepl("SE$", trimws(res_df$Group)), "0"][1])
163+
164+
expect_equal(as.numeric(val_se), expected_se, tolerance = 1e-3)
165+
})
166+
167+
test_that("annotate_lineplot_df computes ci and matches a t-based interval", {
168+
mock_df2gg <- function(df, ...) df
169+
170+
res_df <- testthat::with_mocked_bindings(
171+
{
172+
annotate_lineplot_df(
173+
gg_plt = p_valid,
174+
data = mock_adlb,
175+
summary_stats = c("n", "mean", "ci"),
176+
conf_level = 0.95
177+
)
178+
},
179+
df2gg_aligned = mock_df2gg
180+
)
181+
182+
expect_s3_class(res_df, "data.frame")
183+
184+
# The CI row is labelled with the confidence level
185+
expect_true(any(grepl("95% CI", trimws(res_df$Group))))
186+
187+
# Verify Visit '0' CI equals the t interval for the first treatment group
188+
x <- mock_adlb$AVAL[mock_adlb$AVISIT == 0 & mock_adlb$ARM == "Trt A"]
189+
n <- length(x)
190+
err <- stats::qt(0.975, df = n - 1) * stats::sd(x) / sqrt(n)
191+
expected <- c(mean(x) - err, mean(x) + err)
192+
193+
val_ci <- res_df[grepl("95% CI$", trimws(res_df$Group)), "0"][1]
194+
# gtsummary separates the bounds with a non-breaking space; strip all
195+
# non-numeric characters (except the separator and sign) before parsing
196+
nums <- regmatches(val_ci, gregexpr("-?[0-9.]+", val_ci))[[1]]
197+
got <- as.numeric(nums)
198+
199+
expect_equal(got, expected, tolerance = 1e-2)
200+
})
201+
202+
test_that("annotate_lineplot_df ci label follows conf_level", {
203+
mock_df2gg <- function(df, ...) df
204+
205+
res_df <- testthat::with_mocked_bindings(
206+
{
207+
annotate_lineplot_df(
208+
gg_plt = p_valid,
209+
data = mock_adlb,
210+
summary_stats = c("mean", "ci"),
211+
conf_level = 0.90
212+
)
213+
},
214+
df2gg_aligned = mock_df2gg
215+
)
216+
217+
expect_true(any(grepl("90% CI", trimws(res_df$Group))))
218+
})
219+
139220
test_that("annotate_lineplot_df forwards font_size to df2gg_aligned", {
140221
captured <- NULL
141222
mock_df2gg <- function(df, ..., text_size, label_size) {

‎tests/testthat/test-gg_lineplot.R‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,53 @@ test_that("gg_lineplot converts character y to numeric automatically", {
149149
expect_s3_class(p_factor, "crane_gg_line")
150150
})
151151

152+
test_that("gg_lineplot show_n appends group sizes to the legend", {
153+
p <- gg_lineplot(
154+
data = mock_adlb, x = AVISIT, y = AVAL, group = ARM,
155+
stat = "mean", variability = "ci", show_n = TRUE
156+
)
157+
158+
labels <- ggplot2::ggplot_build(p)$plot$scales$get_scales("colour")$get_labels()
159+
160+
# Treatment B has 30 rows, Treatment A has 20 (one visit removed above)
161+
expect_true(any(grepl("Treatment A \\(N=20\\)", labels)))
162+
expect_true(any(grepl("Treatment B \\(N=30\\)", labels)))
163+
})
164+
165+
test_that("gg_lineplot show_n warns when no group is supplied", {
166+
expect_warning(
167+
gg_lineplot(
168+
data = mock_adlb, x = AVISIT, y = AVAL,
169+
stat = "mean", variability = "ci", show_n = TRUE
170+
),
171+
regexp = "no effect when"
172+
)
173+
})
174+
175+
test_that("gg_lineplot jitter keeps points and error bars aligned", {
176+
p <- gg_lineplot(
177+
data = mock_adlb, x = AVISIT, y = AVAL, group = ARM,
178+
stat = "mean", variability = "ci", jitter = 0.1
179+
)
180+
built <- ggplot2::ggplot_build(p)
181+
182+
# First layer is the points, last is the error bars; a shared seeded
183+
# position must give them identical x coordinates
184+
point_x <- sort(unique(round(built$data[[1]]$x, 4)))
185+
ebar_x <- sort(unique(round(built$data[[length(built$data)]]$x, 4)))
186+
expect_equal(point_x, ebar_x)
187+
})
188+
189+
test_that("gg_lineplot validates jitter", {
190+
expect_error(
191+
gg_lineplot(
192+
data = mock_adlb, x = AVISIT, y = AVAL, group = ARM,
193+
variability = "ci", jitter = -1
194+
),
195+
regexp = "must be a non-negative number"
196+
)
197+
})
198+
152199
test_that("gg_lineplot informs users about numeric vs categorical x-axis", {
153200
# 1. Numeric x-axis hits the final `else` branch (encourages using factor)
154201
expect_message(

0 commit comments

Comments
 (0)