diff --git a/.Rbuildignore b/.Rbuildignore index ae7e12a9..4deffee4 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -10,3 +10,4 @@ ^vignettes/articles$ ^\.gitlab-ci\.yml$ LICENSE +.lintr diff --git a/.lintr b/.lintr new file mode 100644 index 00000000..c514f393 --- /dev/null +++ b/.lintr @@ -0,0 +1,6 @@ +linters: linters_with_defaults( + line_length_linter = line_length_linter(120), + cyclocomp_linter = NULL, + object_usage_linter = NULL, + object_length_linter = NULL + ) diff --git a/DESCRIPTION b/DESCRIPTION index 556be636..422c6878 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: crane Title: Supplements the 'gtsummary' Package for Pharmaceutical Reporting -Version: 0.2.0.9014 +Version: 0.2.0.9015 Authors@R: c( person("Daniel D.", "Sjoberg", , "danield.sjoberg@gmail.com", role = c("aut", "cre"), comment = c(ORCID = "0000-0003-0862-2018")), @@ -22,15 +22,20 @@ BugReports: https://github.com/insightsengineering/crane/issues Depends: gtsummary (>= 2.4.0.9009), R (>= 4.2) -Imports: +Imports: + assertthat (>= 0.2.1), broom (>= 1.0.8), cards (>= 0.7.0), cardx (>= 0.3.0), + cowplot (>= 1.2.0), + checkmate (>= 2.3.2), cli (>= 3.6.4), dplyr (>= 1.1.4), flextable (>= 0.9.7), + ggplot2 (>= 4.0.0), glue (>= 1.8.0), gt (>= 0.11.1), + labeling, lifecycle, rlang (>= 1.1.5), survival (>= 3.6-4), diff --git a/NAMESPACE b/NAMESPACE index 124285cd..7d04acf9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -10,7 +10,13 @@ export("%>%") export(add_blank_rows) export(add_hierarchical_count_row) export(add_overall) +export(annot_at_risk) +export(annot_cox_ph) +export(annot_surv_med) export(filter_hierarchical) +export(g_km) +export(get_cox_pairwise_tbl) +export(h_data_plot) export(label_roche_number) export(label_roche_percent) export(label_roche_pvalue) @@ -36,6 +42,11 @@ export(tbl_survfit_times) export(theme_gtsummary_roche) import(glue) import(rlang) +importFrom(assertthat,assert_that) +importFrom(broom,tidy) +importFrom(cowplot,draw_plot) +importFrom(cowplot,ggdraw) +importFrom(cowplot,plot_grid) importFrom(dplyr,"%>%") importFrom(dplyr,across) importFrom(dplyr,all_of) @@ -48,6 +59,40 @@ importFrom(dplyr,matches) importFrom(dplyr,num_range) importFrom(dplyr,starts_with) importFrom(dplyr,where) +importFrom(ggplot2,.pt) +importFrom(ggplot2,aes) +importFrom(ggplot2,annotate) +importFrom(ggplot2,coord_cartesian) +importFrom(ggplot2,element_blank) +importFrom(ggplot2,element_line) +importFrom(ggplot2,element_rect) +importFrom(ggplot2,element_text) +importFrom(ggplot2,geom_point) +importFrom(ggplot2,geom_ribbon) +importFrom(ggplot2,geom_step) +importFrom(ggplot2,ggplot) +importFrom(ggplot2,guide_legend) +importFrom(ggplot2,guides) +importFrom(ggplot2,labs) +importFrom(ggplot2,margin) +importFrom(ggplot2,scale_color_manual) +importFrom(ggplot2,scale_fill_manual) +importFrom(ggplot2,scale_linetype_manual) +importFrom(ggplot2,scale_shape_manual) +importFrom(ggplot2,scale_x_continuous) +importFrom(ggplot2,scale_y_continuous) +importFrom(ggplot2,theme) +importFrom(ggplot2,theme_bw) +importFrom(ggplot2,theme_void) importFrom(gtsummary,add_overall) importFrom(gtsummary,filter_hierarchical) importFrom(gtsummary,sort_hierarchical) +importFrom(labeling,extended) +importFrom(rlang,.data) +importFrom(rlang,ensym) +importFrom(stats,pchisq) +importFrom(survival,Surv) +importFrom(survival,coxph) +importFrom(survival,survdiff) +importFrom(tidyr,pivot_wider) +importFrom(utils,tail) diff --git a/NEWS.md b/NEWS.md index 17a76e1d..a5922ec3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,6 @@ -# crane 0.2.0.9014 +# crane 0.2.0.9015 + +* Added `g_km()` function for creating Kaplan-Meier plots. * Added `list("assign_summary_type-arg:cat_threshold" = 0L)` to `theme_gtsummary_roche()`. Numeric variables with few levels will no longer default to summary type `'categorical'` in `gtsummary::tbl_summary()` and `tbl_roche_summary()`. (#79) diff --git a/R/crane-package.R b/R/crane-package.R index 9d4c0225..d295f062 100644 --- a/R/crane-package.R +++ b/R/crane-package.R @@ -1,6 +1,7 @@ #' @keywords internal #' @import rlang #' @import glue glue +#' @importFrom utils tail #' @importFrom dplyr across starts_with ends_with contains matches num_range #' all_of any_of everything last_col where "_PACKAGE" @@ -9,7 +10,7 @@ ## usethis namespace: end NULL -utils::globalVariables(c(".")) +utils::globalVariables(c(".", "obj")) # using pkgs to silence NOTE .silence <- function() { diff --git a/R/gkm.R b/R/gkm.R new file mode 100644 index 00000000..e5fb67f5 --- /dev/null +++ b/R/gkm.R @@ -0,0 +1,631 @@ +f_conf_level <- function(conf_level) { + # assert_proportion_value(conf_level) # Assuming assert_proportion_value is defined elsewhere + paste0(conf_level * 100, "% CI") +} + +control_surv_med_annot <- function(x = 0.8, y = 0.85, w = 0.32, h = 0.16, fill = TRUE) { + # assert_proportion_value(x) + # assert_proportion_value(y) + # assert_proportion_value(w) + # assert_proportion_value(h) + + list(x = x, y = y, w = w, h = h, fill = fill) +} + +control_coxph_annot <- function(x = 0.29, y = 0.51, w = 0.4, h = 0.125, fill = TRUE, ref_lbls = FALSE) { + checkmate::assert_logical(ref_lbls, any.missing = FALSE) + + res <- c(control_surv_med_annot(x = x, y = y, w = w, h = h), list(ref_lbls = ref_lbls)) + res +} + + +#' @title Convert Data Frame to ggplot2 Table Graphic +#' +#' @description Creates a \code{ggplot2} object that renders a data frame as a table graphic. +#' +#' @param df The data frame to render. +#' @param colwidths Numeric vector of relative column widths. If \code{NULL}, determined by max character length. +#' @param font_size Numeric base font size. +#' @param col_labels Logical, whether to display column labels (header). +#' @param col_lab_fontface Character string for the font face of column labels (e.g., "bold"). +#' @param hline Logical, whether to draw a horizontal line below the column labels. +#' @param bg_fill Optional color string for the plot background. +#' @keywords internal +#' @return A \code{ggplot2} object representing the table. +#' @importFrom ggplot2 ggplot theme_void scale_x_continuous scale_y_continuous theme element_rect annotate element_text .pt +df2gg <- function(df, colwidths = NULL, font_size = 10, col_labels = TRUE, + col_lab_fontface = "bold", hline = TRUE, bg_fill = NULL) { + # ... (function body remains the same) + df <- as.data.frame(apply(df, 1:2, function(x) { + if (is.na(x)) { + "NA" + } else { + as.character(x) + } + })) + if (col_labels) { + df <- as.matrix(df) + df <- rbind(colnames(df), df) + } + if (is.null(colwidths)) { + colwidths <- apply(df, 2, function(x) max(nchar(x), na.rm = TRUE)) + } + tot_width <- sum(colwidths) + res <- ggplot2::ggplot(data = df) + + ggplot2::theme_void() + + ggplot2::scale_x_continuous(limits = c( + 0, + tot_width + )) + + ggplot2::scale_y_continuous(limits = c(1, nrow(df))) + if (!is.null(bg_fill)) { + res <- res + ggplot2::theme(plot.background = ggplot2::element_rect(fill = bg_fill)) + } + if (hline) { + res <- res + ggplot2::annotate("segment", + x = 0 + 0.2 * colwidths[2], + xend = tot_width - 0.1 * tail(colwidths, 1), y = nrow(df) - + 0.5, yend = nrow(df) - 0.5 + ) + } + for (i in seq_len(ncol(df))) { + line_pos <- c(if (i == 1) { + 0 + } else { + sum(colwidths[1:(i - + 1)]) + }, sum(colwidths[1:i])) + res <- res + ggplot2::annotate("text", + x = mean(line_pos), y = rev(seq_len(nrow(df))), + label = df[, i], size = font_size / .pt, fontface = if (col_labels) { + c(col_lab_fontface, rep("plain", nrow(df) - 1)) + } else { + rep("plain", nrow(df)) + } + ) + } + res +} + +#' @title Calculate X-axis Ticks +#' +#' @description Determines the positions for x-axis ticks based on the data and user input. +#' +#' @param data A data frame containing a \code{time} column. +#' @param xticks A numeric vector of specific tick positions, a single number for the interval, or \code{NULL} for auto-calculation. +#' @param max_time Optional numeric value specifying the maximum time to consider for tick range. +#' @keywords internal +#' @importFrom labeling extended +#' @return A numeric vector of x-axis tick positions. +h_xticks <- function(data, xticks = NULL, max_time = NULL) { + # ... (function body remains the same) + if (is.null(xticks)) { + if (is.null(max_time)) { + labeling::extended(range(data$time)[1], range(data$time)[2], m = 5) + } else { + labeling::extended(range(data$time)[1], max(range(data$time)[2], max_time), m = 5) + } + } else if (checkmate::test_number(xticks)) { + if (is.null(max_time)) { + seq(0, max(data$time), xticks) + } else { + seq(0, max(data$time, max_time), xticks) + } + } else if (is.numeric(xticks)) { + xticks + } else { + stop( + paste( + "xticks should be either `NULL`", + "or a single number (interval between x ticks)", + "or a numeric vector (position of ticks on the x axis)" + ) + ) + } +} + +#' @title Median Survival Summary Table +#' +#' @description Extracts and formats the median survival time and its confidence interval +#' from a fitted Kaplan-Meier object. +#' +#' @param fit_km A fitted Kaplan-Meier object of class \code{survfit}. +#' @param armval Character string to use as the row name if \code{fit_km} has no strata (e.g., "All"). +#' @keywords internal +#' @return A data frame with columns "N", "Median", and the confidence interval label. +h_tbl_median_surv <- function(fit_km, armval = "All") { + # ... (function body remains the same) + y <- if (is.null(fit_km$strata)) { + as.data.frame(t(summary(fit_km)$table), row.names = armval) + } else { + tbl <- summary(fit_km)$table + rownames_lst <- strsplit(sub("=", "equals", rownames(tbl)), "equals") + rownames(tbl) <- matrix(unlist(rownames_lst), ncol = 2, byrow = TRUE)[, 2] + as.data.frame(tbl) + } + conf.int <- summary(fit_km)$conf.int # nolint + y$records <- round(y$records) + y$median <- signif(y$median, 4) + y$`CI` <- paste0( + "(", signif(y[[paste0(conf.int, "LCL")]], 4), ", ", signif(y[[paste0(conf.int, "UCL")]], 4), ")" + ) + stats::setNames( + y[c("records", "median", "CI")], + c("N", "Median", f_conf_level(conf.int)) + ) +} + +#' Perform Pairwise Cox Proportional Hazards Regression +#' +#' This function performs a pairwise comparison of treatment arms using the **Cox proportional hazards model** and calculates the corresponding **log-rank p-value**. Each comparison is made between a specified reference group and all other comparison groups in the dataset. +#' +#' @param model_formula A \code{\link[stats]{formula}} object specifying the survival model, typically in the form \code{Surv(time, status) ~ arm + covariates}. +#' @param data A \code{\link[base]{data.frame}} containing the survival data, including time, status, and the arm variable. +#' @param arm A character string specifying the name of the column in \code{data} that contains the grouping/treatment arm variable (must be a factor-like variable). +#' @param ref_group A character string specifying the level of the \code{arm} variable to be used as the **reference group** for all pairwise comparisons. If \code{NULL} (the default), the **first unique level** of the \code{arm} column is used as the reference group. +#' +#' @return A \code{\link[base]{data.frame}} with the results of the pairwise comparisons. The columns include: +#' \itemize{ +#' \item \code{arm}: The comparison arm being tested against the reference group. +#' \item \code{hr}: The Hazard Ratio (HR) for the comparison arm vs. the reference arm, formatted to two decimal places. +#' \item \code{ci}: The 95\% confidence interval for the HR, presented as a string in the format "(lower, upper)", with values formatted to two decimal places. +#' \item \code{pval}: The log-rank p-value for the comparison. +#' } +#' +#' @details The function iterates through each unique arm (excluding the reference group), filters the data to include only the current comparison arm and the reference arm, and then fits a Cox model (\code{\link[survival]{coxph}}) and performs a log-rank test (\code{\link[survival]{survdiff}}). The Hazard Ratio and its 95\% confidence interval are extracted from the Cox model summary, and the p-value is calculated from the log-rank test. +#' +#' @importFrom survival coxph Surv survdiff +#' @importFrom stats pchisq +#' @export +#' @importFrom rlang ensym +#' @importFrom assertthat assert_that +#' @examples +#' # Example data setup (assuming 'time' is event time, 'status' is event indicator (1=event), +#' # and 'arm' is the treatment group) +#' library(survival) +#' use_lung <- lung +#' use_lung$arm <- factor(sample(c("A", "B", "C"), nrow(use_lung), replace = TRUE)) +#' use_lung$status <- use_lung$status - 1 # Convert status to 0/1 +#' use_lung <- na.omit(use_lung) +#' +#' formula <- Surv(time, status) ~ arm +#' results_tbl <- get_cox_pairwise_tbl( +#' model_formula = formula, +#' data = use_lung, +#' arm = "arm", +#' ref_group = "A" +#' ) +#' print(results_tbl) +get_cox_pairwise_tbl <- function(model_formula, data, arm, ref_group = NULL) { + msg <- paste0(rlang::ensym(data), "[['", rlang::ensym(arm), "']] is not a factor") + assertthat::assert_that(is.factor(data[[arm]]), msg = msg) + ref_group <- if (!is.null(ref_group)) { + ref_group + } else { + levels(data[[arm]])[1] + } + comp_group <- setdiff(levels(data[[arm]]), ref_group) + + ret <- c() + for (current_arm in comp_group) { + subset_arm <- c(ref_group, current_arm) + assertthat::assert_that(length(subset_arm) == 2, msg = "Make sure 2 arms") + comp_df <- data[as.character(data[[arm]]) %in% subset_arm, ] + suppressWarnings( + coxph_ans <- coxph(formula = model_formula, data = comp_df) %>% summary() + ) + orginal_survdiff <- survdiff(formula = model_formula, data = comp_df) + log_rank_pvalue <- 1 - stats::pchisq(orginal_survdiff$chisq, length(orginal_survdiff$n) - + 1) + current_row <- data.frame( + hr = sprintf("%.2f", coxph_ans$conf.int[1, 1]), + ci = paste0( + "(", + sprintf("%.2f", coxph_ans$conf.int[1, 3]), + ", ", + sprintf("%.2f", coxph_ans$conf.int[1, 4]), + ")" + ), + pval = log_rank_pvalue + ) + rownames(current_row) <- current_arm + ret <- rbind(ret, current_row) + } + + return(ret) +} + + +#' @title Prepare Kaplan-Meier Data for Plotting +#' +#' @description Takes a fitted \code{survfit} object and processes it into a data frame +#' suitable for plotting a Kaplan-Meier curve with \code{ggplot2}, including extending +#' the curve to time zero. +#' +#' @param fit_km A fitted Kaplan-Meier object of class \code{survfit}. +#' @param armval Character string for the strata level if \code{fit_km} has no strata (e.g., "All"). +#' @param max_time Numeric, the maximum time point to include in the data, or \code{NULL} for no limit. +#' +#' @return A data frame containing the survival curve steps, confidence intervals, and censoring info. +#' @importFrom broom tidy +#' @export +h_data_plot <- function(fit_km, + armval = "All", + max_time = NULL) { + # ... (function body remains the same) + y <- broom::tidy(fit_km) + + if (!is.null(fit_km$strata)) { + fit_km_var_level <- strsplit(sub("=", "equals", names(fit_km$strata)), "equals") + strata_levels <- vapply(fit_km_var_level, FUN = "[", FUN.VALUE = "a", i = 2) + strata_var_level <- strsplit(sub("=", "equals", y$strata), "equals") + y$strata <- factor( + vapply(strata_var_level, FUN = "[", FUN.VALUE = "a", i = 2), + levels = strata_levels + ) + } else { + y$strata <- armval + } + + y_by_strata <- split(y, y$strata) + y_by_strata_extended <- lapply( + y_by_strata, + FUN = function(tbl) { + first_row <- tbl[1L, ] + first_row$time <- 0 + first_row$n.risk <- sum(first_row[, c("n.risk", "n.event", "n.censor")]) + first_row$n.event <- first_row$n.censor <- 0 + first_row$estimate <- first_row$conf.high <- first_row$conf.low <- 1 + first_row$std.error <- 0 + rbind( + first_row, + tbl + ) + } + ) + y <- do.call(rbind, y_by_strata_extended) + + y$censor <- ifelse(y$n.censor > 0, y$estimate, NA) + if (!is.null(max_time)) { + y <- y[y$time <= max(max_time), ] + } + y +} + + +## Core Plotting and Annotation Functions + +#' @title Generate a Kaplan-Meier Plot +#' +#' @description This function creates a comprehensive \code{ggplot2} object for a Kaplan-Meier +#' survival curve, with support for various customizations like censoring marks, CIs, and axis control. +#' +#' @param surv_plot_data A data frame containing the pre-processed survival data, ready for plotting. +#' This data should be equivalent to the output of \code{\link{h_data_plot}}. +#' @param col A character vector of colors for the survival curves. Length should match number of arms. +#' @param lty A vector of line types for the survival curves, or \code{NULL} for default. +#' @param lwd Numeric value specifying line width for the survival curves. +#' @param censor_show Logical, whether to display censoring marks on the plot. +#' @param pch Plotting character for censoring marks. +#' @param size Size of the censoring marks. +#' @param max_time Numeric, the maximum time point to display on the x-axis. +#' @param xticks Numeric vector of x-axis tick positions, or a single number for the interval, or \code{NULL} for auto. +#' @param xlab Character string for the x-axis label. +#' @param yval Character string, either \code{"Survival"} or \code{"Failure"} to plot Survival or Failure probability. +#' @param ylab Character string for the y-axis label. +#' @param ylim Numeric vector of length 2 for y-axis limits. +#' @param title Character string for the plot title. +#' @param footnotes Character string for plot footnotes/caption. +#' @param font_size Numeric, base font size for the plot theme. +#' @param ci_ribbon Logical, whether to display confidence intervals as a ribbon (area). +#' @param legend_pos Numeric vector of length 2 for legend position (x, y) relative to the plot area (0 to 1), or \code{NULL} for auto-placement. +#' @param ggtheme An optional \code{ggplot2} theme to apply. +#' +#' @return A \code{ggplot2} object of the KM plot. +#' @importFrom ggplot2 ggplot aes theme_bw scale_y_continuous labs theme element_text element_blank element_rect element_line geom_step geom_point scale_shape_manual guides guide_legend geom_ribbon scale_color_manual scale_fill_manual scale_linetype_manual coord_cartesian +#' @importFrom rlang .data +#' @export +g_km <- function( + surv_plot_data, + col = NULL, + lty = NULL, + lwd = 0.5, + censor_show = TRUE, + pch = 3, + size = 2, + max_time = NULL, + xticks = NULL, + xlab = "Days", + yval = c("Survival", "Failure"), + ylab = paste(yval, "Probability"), + ylim = NULL, + title = NULL, + footnotes = NULL, + font_size = 10, + ci_ribbon = FALSE, + legend_pos = NULL, + ggtheme = NULL +) { + # ... (function body remains the same) + checkmate::assert_data_frame(surv_plot_data, min.cols = 7, min.rows = 1) + data <- surv_plot_data + + armval <- levels(data$strata) + checkmate::assert_vector(col, len = length(armval), null.ok = TRUE) + + yval <- match.arg(yval) + + xticks <- h_xticks(data = data, xticks = xticks, max_time = max_time) + + if (yval == "Failure") { + data[c("estimate", "conf.low", "conf.high", "censor")] <- list( + 1 - data$estimate, 1 - data$conf.low, 1 - data$conf.high, 1 - data$censor + ) + } + + if (is.null(ylim)) { + if (!is.null(max_time)) { + y_lwr <- min(data[data$time < max_time, ][["estimate"]]) + y_upr <- max(data[data$time < max_time, ][["estimate"]]) + } else { + y_lwr <- min(data[["estimate"]]) + y_upr <- max(data[["estimate"]]) + } + ylim <- c(y_lwr, y_upr) + } + + gg_plt <- ggplot2::ggplot( + data = data, + mapping = ggplot2::aes( + x = .data[["time"]], y = .data[["estimate"]], ymin = .data[["conf.low"]], + ymax = .data[["conf.high"]], color = .data[["strata"]], fill = .data[["strata"]] + ) + ) + + ggplot2::theme_bw(base_size = font_size) + + ggplot2::scale_y_continuous(limits = ylim, expand = c(0.025, 0)) + + ggplot2::labs(title = title, x = xlab, y = ylab, caption = footnotes) + + ggplot2::theme( + axis.text = ggplot2::element_text(size = font_size), axis.title = ggplot2::element_text(size = font_size), + legend.title = ggplot2::element_blank(), legend.text = ggplot2::element_text(size = font_size), + legend.box.background = ggplot2::element_rect(fill = "white", linewidth = 0.5), + legend.background = ggplot2::element_blank(), legend.position = "inside", + legend.spacing.y = ggplot2::unit(-0.02, "npc"), panel.grid.major = ggplot2::element_blank(), + panel.grid.minor = ggplot2::element_blank() + ) + + if (!is.null(max_time) && !is.null(xticks)) { + gg_plt <- gg_plt + ggplot2::scale_x_continuous( + breaks = xticks, limits = c(min(0, xticks), max(c(xticks, max_time))), expand = c(0.025, 0) + ) + } else if (!is.null(xticks)) { + if (max(data$time) <= max(xticks)) { + gg_plt <- gg_plt + ggplot2::scale_x_continuous( + breaks = xticks, limits = c(min(0, min(xticks)), max(xticks)), expand = c(0.025, 0) + ) + } else { + gg_plt <- gg_plt + ggplot2::scale_x_continuous(breaks = xticks, expand = c(0.025, 0)) + } + } else if (!is.null(max_time)) { + gg_plt <- gg_plt + ggplot2::scale_x_continuous(limits = c(0, max_time), expand = c(0.025, 0)) + } + + if (!is.null(legend_pos)) { + gg_plt <- gg_plt + ggplot2::theme(legend.position.inside = legend_pos) + } else { + max_time2 <- sort( + data$time, + partial = nrow(data) - length(armval) - 1 + )[nrow(data) - length(armval) - 1] + + y_rng <- ylim[2] - ylim[1] + + if (yval == "Survival" && all(data$estimate[data$time == max_time2] > ylim[1] + 0.09 * y_rng) && + all(data$estimate[data$time == max_time2] < ylim[1] + 0.5 * y_rng)) { # nolint + gg_plt <- gg_plt + + ggplot2::theme( + legend.position.inside = c(1, 0.5), + legend.justification = c(1.1, 0.6) + ) + } else { + gg_plt <- gg_plt + + ggplot2::theme( + legend.position.inside = c(1, 0), + legend.justification = c(1.1, -0.4) + ) + } + } + + gg_plt <- if (is.null(lty)) { + gg_plt + ggplot2::geom_step(linewidth = lwd, na.rm = TRUE) + } else if (length(lty) == 1) { + gg_plt + ggplot2::geom_step(linewidth = lwd, lty = lty, na.rm = TRUE) + } else { + gg_plt + + ggplot2::geom_step(ggplot2::aes(lty = .data[["strata"]]), linewidth = lwd, na.rm = TRUE) + + ggplot2::scale_linetype_manual(values = lty) + } + + if (censor_show) { + gg_plt <- gg_plt + ggplot2::geom_point( + data = data[data$n.censor != 0, ], + ggplot2::aes(x = .data[["time"]], y = .data[["censor"]], shape = "Censored"), + size = size, + na.rm = TRUE + ) + + ggplot2::scale_shape_manual(name = NULL, values = pch) + + ggplot2::guides(fill = ggplot2::guide_legend(override.aes = list(shape = NA))) + } + + if (ci_ribbon) gg_plt <- gg_plt + ggplot2::geom_ribbon(alpha = 0.3, lty = 0, na.rm = TRUE) + + if (!is.null(col)) { + gg_plt <- gg_plt + + ggplot2::scale_color_manual(values = col) + + ggplot2::scale_fill_manual(values = col) + } + if (!is.null(ggtheme)) gg_plt <- gg_plt + ggtheme + + + gg_plt +} + +#' @title Annotate Kaplan-Meier Plot with Median Survival Table +#' +#' @description Adds a median survival time summary table as an annotation box on a +#' Kaplan-Meier plot using \code{cowplot}. +#' +#' @param gg_plt A \code{ggplot2} or \code{cowplot} object of the Kaplan-Meier plot. +#' @param fit_km A fitted Kaplan-Meier object of class \code{survfit}, used to generate the table data. +#' @param control_annot_surv_med A list of control parameters for the annotation box. +#' @param font_size Numeric, base font size for the annotation table. +#' +#' @return A \code{cowplot} object with the median survival table annotation added. +#' @importFrom cowplot ggdraw draw_plot +#' @importFrom ggplot2 theme element_text coord_cartesian scale_x_continuous scale_y_continuous margin +#' @export +annot_surv_med <- function(gg_plt, fit_km, control_annot_surv_med = control_surv_med_annot(), font_size = 10) { + # Determine armval for h_tbl_median_surv, assuming it's available in the calling environment or logic should be updated + # For now, keeping as is, but this typically requires armval or inferring it from fit_km + armval <- if (is.null(fit_km$strata)) "All" else levels(fit_km$strata) # Placeholder for armval + + surv_med_tbl <- h_tbl_median_surv(fit_km = fit_km, armval = armval) + bg_fill <- if (isTRUE(control_annot_surv_med[["fill"]])) "#00000020" else control_annot_surv_med[["fill"]] + + gg_surv_med <- df2gg(surv_med_tbl, font_size = font_size, colwidths = c(1, 1, 2), bg_fill = bg_fill) + + ggplot2::theme( + axis.text.y = ggplot2::element_text(size = font_size, face = "italic", hjust = 1), + plot.margin = ggplot2::margin(0, 2, 0, 5) + ) + + ggplot2::coord_cartesian(clip = "off", ylim = c(0.5, nrow(surv_med_tbl) + 1.5)) + gg_surv_med <- suppressMessages( + gg_surv_med + + ggplot2::scale_x_continuous(expand = c(0.025, 0)) + + ggplot2::scale_y_continuous(labels = rev(rownames(surv_med_tbl)), breaks = seq_len(nrow(surv_med_tbl))) + ) + + gg_plt <- cowplot::ggdraw(gg_plt) + + cowplot::draw_plot( + gg_surv_med, control_annot_surv_med[["x"]], control_annot_surv_med[["y"]], + width = control_annot_surv_med[["w"]], height = control_annot_surv_med[["h"]], + vjust = 0.5, hjust = 0.5 + ) + gg_plt +} + +#' @title Annotate Kaplan-Meier Plot with Cox-PH Table +#' +#' @description Adds a Cox Proportional Hazards summary table as an annotation box on a +#' Kaplan-Meier plot using \code{cowplot}. +#' +#' @param gg_plt A \code{ggplot2} or \code{cowplot} object of the Kaplan-Meier plot. +#' @param coxph_tbl A data frame containing pre-calculated Cox-PH results. +#' @param control_annot_coxph A list of control parameters for the annotation box. +#' @param font_size Numeric, base font size for the annotation table. +#' +#' @return A \code{cowplot} object with the Cox-PH table annotation added. +#' @importFrom cowplot ggdraw draw_plot +#' @importFrom ggplot2 theme element_text coord_cartesian scale_x_continuous scale_y_continuous margin +#' @export +annot_cox_ph <- function(gg_plt, coxph_tbl, control_annot_coxph = control_coxph_annot(), font_size = 10) { + # ... (function body remains the same) + bg_fill <- if (isTRUE(control_annot_coxph[["fill"]])) "#00000020" else control_annot_coxph[["fill"]] + + gg_coxph <- df2gg(coxph_tbl, font_size = font_size, colwidths = c(1.1, 1, 3), bg_fill = bg_fill) + + ggplot2::theme( + axis.text.y = ggplot2::element_text(size = font_size, face = "italic", hjust = 1), + plot.margin = ggplot2::margin(0, 2, 0, 5) + ) + + ggplot2::coord_cartesian(clip = "off", ylim = c(0.5, nrow(coxph_tbl) + 1.5)) + gg_coxph <- suppressMessages( + gg_coxph + + ggplot2::scale_x_continuous(expand = c(0.025, 0)) + + ggplot2::scale_y_continuous(labels = rev(rownames(coxph_tbl)), breaks = seq_len(nrow(coxph_tbl))) + ) + + gg_plt <- cowplot::ggdraw(gg_plt) + + cowplot::draw_plot( + gg_coxph, control_annot_coxph[["x"]], control_annot_coxph[["y"]], + width = control_annot_coxph[["w"]], height = control_annot_coxph[["h"]], + vjust = 0.5, hjust = 0.5 + ) + gg_plt +} + +#' @title Annotate Plot with Numbers at Risk Table +#' +#' @description Adds a "Numbers at Risk" table below a Kaplan-Meier plot using \code{cowplot::plot_grid}. +#' +#' @param gg_plt A \code{ggplot2} or \code{cowplot} object of the Kaplan-Meier plot. +#' @param fit_km A fitted Kaplan-Meier object of class \code{survfit}, used to generate the table data. +#' @param font_size Numeric, base font size for the table. +#' @param annot_at_risk_title Logical, whether to include the title "Patients at Risk:". +#' @param rel_height_plot Numeric, relative height of the main plot area compared to the 'at-risk' table (0 to 1). +#' @param xlab Character string for the x-axis label on the 'at-risk' table (typically time). +#' +#' @return A \code{cowplot} object combining the KM plot and the 'Numbers at Risk' table. +#' @importFrom broom tidy +#' @importFrom tidyr pivot_wider +#' @importFrom cowplot plot_grid +#' @importFrom ggplot2 labs theme_bw theme element_text element_blank element_line coord_cartesian scale_x_continuous scale_y_continuous +#' @export +annot_at_risk <- function(gg_plt, fit_km, font_size = 10, annot_at_risk_title = TRUE, rel_height_plot = 0.75, xlab = "Days") { + # ... (function body remains the same) + data <- broom::tidy(fit_km) + xticks <- h_xticks(data = data) + annot_tbl <- summary(fit_km, times = xticks, extend = TRUE) + + # Placeholder for armval, should be retrieved from fit_km or passed as argument + armval <- if (is.null(fit_km$strata)) "All" else levels(fit_km$strata) + + annot_tbl <- if (is.null(fit_km$strata)) { + data.frame( + n.risk = annot_tbl$n.risk, + time = annot_tbl$time, + strata = armval + ) + } else { + strata_lst <- strsplit(sub("=", "equals", levels(annot_tbl$strata)), "equals") + levels(annot_tbl$strata) <- matrix(unlist(strata_lst), ncol = 2, byrow = TRUE)[, 2] + data.frame( + n.risk = annot_tbl$n.risk, + time = annot_tbl$time, + strata = annot_tbl$strata + ) + } + + at_risk_tbl <- as.data.frame(tidyr::pivot_wider(annot_tbl, names_from = "time", values_from = "n.risk")[, -1]) + at_risk_tbl[is.na(at_risk_tbl)] <- 0 + rownames(at_risk_tbl) <- levels(annot_tbl$strata) + + gg_at_risk <- df2gg( + at_risk_tbl, + font_size = font_size, col_labels = FALSE, hline = FALSE, + colwidths = rep(1, ncol(at_risk_tbl)) + ) + + ggplot2::labs(title = if (annot_at_risk_title) "Patients at Risk:" else NULL, x = xlab) + + ggplot2::theme_bw(base_size = font_size) + + ggplot2::theme( + plot.title = ggplot2::element_text(size = font_size, vjust = 3, face = "bold"), + panel.border = ggplot2::element_blank(), + panel.grid = ggplot2::element_blank(), + axis.title.y = ggplot2::element_blank(), + axis.ticks.y = ggplot2::element_blank(), + axis.text.y = ggplot2::element_text(size = font_size, face = "italic", hjust = 1), + axis.text.x = ggplot2::element_text(size = font_size), + axis.line.x = ggplot2::element_line() + ) + + ggplot2::coord_cartesian(clip = "off", ylim = c(0.5, nrow(at_risk_tbl))) + gg_at_risk <- suppressMessages( + gg_at_risk + + ggplot2::scale_x_continuous(expand = c(0.1, 0), breaks = seq_along(at_risk_tbl) - 0.5, labels = xticks) + + ggplot2::scale_y_continuous(labels = rev(levels(annot_tbl$strata)), breaks = seq_len(nrow(at_risk_tbl))) + ) + + gg_plt <- cowplot::plot_grid( + gg_plt, gg_at_risk, + align = "v", axis = "tblr", ncol = 1, + rel_heights = c(rel_height_plot, 1 - rel_height_plot) + ) + gg_plt +} diff --git a/R/tbl_null_report.R b/R/tbl_null_report.R index d8adb081..02b7237e 100644 --- a/R/tbl_null_report.R +++ b/R/tbl_null_report.R @@ -11,7 +11,9 @@ #' @export #' @rdname tbl_null_report tbl_null_report <- function( - label = "Null Report: no observations met the reporting criteria for inclusion in this output.") { + label = + "Null Report: no observations met the reporting criteria for inclusion in this output." +) { set_cli_abort_call() # Check input label ---------------------------------------------------------- diff --git a/_pkgdown.yml b/_pkgdown.yml index c850f898..ef2fb0c1 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -41,3 +41,11 @@ reference: - modify_zero_recode - add_blank_rows - label_roche + - title: "g km plot" + contents: + - get_cox_pairwise_tbl + - h_data_plot + - g_km + - annot_surv_med + - annot_cox_ph + - annot_at_risk diff --git a/inst/WORDLIST b/inst/WORDLIST index b95a577a..0db4adb4 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -15,11 +15,14 @@ cardx de flextable funder +ggplot gtsummary pharma pre rlang's survfit tbl +tte tidyselect unstratified +customizations diff --git a/man/annot_at_risk.Rd b/man/annot_at_risk.Rd new file mode 100644 index 00000000..3892dbcc --- /dev/null +++ b/man/annot_at_risk.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{annot_at_risk} +\alias{annot_at_risk} +\title{Annotate Plot with Numbers at Risk Table} +\usage{ +annot_at_risk( + gg_plt, + fit_km, + font_size = 10, + annot_at_risk_title = TRUE, + rel_height_plot = 0.75, + xlab = "Days" +) +} +\arguments{ +\item{gg_plt}{A \code{ggplot2} or \code{cowplot} object of the Kaplan-Meier plot.} + +\item{fit_km}{A fitted Kaplan-Meier object of class \code{survfit}, used to generate the table data.} + +\item{font_size}{Numeric, base font size for the table.} + +\item{annot_at_risk_title}{Logical, whether to include the title "Patients at Risk:".} + +\item{rel_height_plot}{Numeric, relative height of the main plot area compared to the 'at-risk' table (0 to 1).} + +\item{xlab}{Character string for the x-axis label on the 'at-risk' table (typically time).} +} +\value{ +A \code{cowplot} object combining the KM plot and the 'Numbers at Risk' table. +} +\description{ +Adds a "Numbers at Risk" table below a Kaplan-Meier plot using \code{cowplot::plot_grid}. +} diff --git a/man/annot_cox_ph.Rd b/man/annot_cox_ph.Rd new file mode 100644 index 00000000..f4303738 --- /dev/null +++ b/man/annot_cox_ph.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{annot_cox_ph} +\alias{annot_cox_ph} +\title{Annotate Kaplan-Meier Plot with Cox-PH Table} +\usage{ +annot_cox_ph( + gg_plt, + coxph_tbl, + control_annot_coxph = control_coxph_annot(), + font_size = 10 +) +} +\arguments{ +\item{gg_plt}{A \code{ggplot2} or \code{cowplot} object of the Kaplan-Meier plot.} + +\item{coxph_tbl}{A data frame containing pre-calculated Cox-PH results.} + +\item{control_annot_coxph}{A list of control parameters for the annotation box.} + +\item{font_size}{Numeric, base font size for the annotation table.} +} +\value{ +A \code{cowplot} object with the Cox-PH table annotation added. +} +\description{ +Adds a Cox Proportional Hazards summary table as an annotation box on a +Kaplan-Meier plot using \code{cowplot}. +} diff --git a/man/annot_surv_med.Rd b/man/annot_surv_med.Rd new file mode 100644 index 00000000..a86b706e --- /dev/null +++ b/man/annot_surv_med.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{annot_surv_med} +\alias{annot_surv_med} +\title{Annotate Kaplan-Meier Plot with Median Survival Table} +\usage{ +annot_surv_med( + gg_plt, + fit_km, + control_annot_surv_med = control_surv_med_annot(), + font_size = 10 +) +} +\arguments{ +\item{gg_plt}{A \code{ggplot2} or \code{cowplot} object of the Kaplan-Meier plot.} + +\item{fit_km}{A fitted Kaplan-Meier object of class \code{survfit}, used to generate the table data.} + +\item{control_annot_surv_med}{A list of control parameters for the annotation box.} + +\item{font_size}{Numeric, base font size for the annotation table.} +} +\value{ +A \code{cowplot} object with the median survival table annotation added. +} +\description{ +Adds a median survival time summary table as an annotation box on a +Kaplan-Meier plot using \code{cowplot}. +} diff --git a/man/df2gg.Rd b/man/df2gg.Rd new file mode 100644 index 00000000..cc7a2be6 --- /dev/null +++ b/man/df2gg.Rd @@ -0,0 +1,38 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{df2gg} +\alias{df2gg} +\title{Convert Data Frame to ggplot2 Table Graphic} +\usage{ +df2gg( + df, + colwidths = NULL, + font_size = 10, + col_labels = TRUE, + col_lab_fontface = "bold", + hline = TRUE, + bg_fill = NULL +) +} +\arguments{ +\item{df}{The data frame to render.} + +\item{colwidths}{Numeric vector of relative column widths. If \code{NULL}, determined by max character length.} + +\item{font_size}{Numeric base font size.} + +\item{col_labels}{Logical, whether to display column labels (header).} + +\item{col_lab_fontface}{Character string for the font face of column labels (e.g., "bold").} + +\item{hline}{Logical, whether to draw a horizontal line below the column labels.} + +\item{bg_fill}{Optional color string for the plot background.} +} +\value{ +A \code{ggplot2} object representing the table. +} +\description{ +Creates a \code{ggplot2} object that renders a data frame as a table graphic. +} +\keyword{internal} diff --git a/man/g_km.Rd b/man/g_km.Rd new file mode 100644 index 00000000..f0b15d19 --- /dev/null +++ b/man/g_km.Rd @@ -0,0 +1,75 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{g_km} +\alias{g_km} +\title{Generate a Kaplan-Meier Plot} +\usage{ +g_km( + surv_plot_data, + col = NULL, + lty = NULL, + lwd = 0.5, + censor_show = TRUE, + pch = 3, + size = 2, + max_time = NULL, + xticks = NULL, + xlab = "Days", + yval = c("Survival", "Failure"), + ylab = paste(yval, "Probability"), + ylim = NULL, + title = NULL, + footnotes = NULL, + font_size = 10, + ci_ribbon = FALSE, + legend_pos = NULL, + ggtheme = NULL +) +} +\arguments{ +\item{surv_plot_data}{A data frame containing the pre-processed survival data, ready for plotting. +This data should be equivalent to the output of \code{\link{h_data_plot}}.} + +\item{col}{A character vector of colors for the survival curves. Length should match number of arms.} + +\item{lty}{A vector of line types for the survival curves, or \code{NULL} for default.} + +\item{lwd}{Numeric value specifying line width for the survival curves.} + +\item{censor_show}{Logical, whether to display censoring marks on the plot.} + +\item{pch}{Plotting character for censoring marks.} + +\item{size}{Size of the censoring marks.} + +\item{max_time}{Numeric, the maximum time point to display on the x-axis.} + +\item{xticks}{Numeric vector of x-axis tick positions, or a single number for the interval, or \code{NULL} for auto.} + +\item{xlab}{Character string for the x-axis label.} + +\item{yval}{Character string, either \code{"Survival"} or \code{"Failure"} to plot Survival or Failure probability.} + +\item{ylab}{Character string for the y-axis label.} + +\item{ylim}{Numeric vector of length 2 for y-axis limits.} + +\item{title}{Character string for the plot title.} + +\item{footnotes}{Character string for plot footnotes/caption.} + +\item{font_size}{Numeric, base font size for the plot theme.} + +\item{ci_ribbon}{Logical, whether to display confidence intervals as a ribbon (area).} + +\item{legend_pos}{Numeric vector of length 2 for legend position (x, y) relative to the plot area (0 to 1), or \code{NULL} for auto-placement.} + +\item{ggtheme}{An optional \code{ggplot2} theme to apply.} +} +\value{ +A \code{ggplot2} object of the KM plot. +} +\description{ +This function creates a comprehensive \code{ggplot2} object for a Kaplan-Meier +survival curve, with support for various customizations like censoring marks, CIs, and axis control. +} diff --git a/man/get_cox_pairwise_tbl.Rd b/man/get_cox_pairwise_tbl.Rd new file mode 100644 index 00000000..7ebe55ab --- /dev/null +++ b/man/get_cox_pairwise_tbl.Rd @@ -0,0 +1,50 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{get_cox_pairwise_tbl} +\alias{get_cox_pairwise_tbl} +\title{Perform Pairwise Cox Proportional Hazards Regression} +\usage{ +get_cox_pairwise_tbl(model_formula, data, arm, ref_group = NULL) +} +\arguments{ +\item{model_formula}{A \code{\link[stats]{formula}} object specifying the survival model, typically in the form \code{Surv(time, status) ~ arm + covariates}.} + +\item{data}{A \code{\link[base]{data.frame}} containing the survival data, including time, status, and the arm variable.} + +\item{arm}{A character string specifying the name of the column in \code{data} that contains the grouping/treatment arm variable (must be a factor-like variable).} + +\item{ref_group}{A character string specifying the level of the \code{arm} variable to be used as the \strong{reference group} for all pairwise comparisons. If \code{NULL} (the default), the \strong{first unique level} of the \code{arm} column is used as the reference group.} +} +\value{ +A \code{\link[base]{data.frame}} with the results of the pairwise comparisons. The columns include: +\itemize{ +\item \code{arm}: The comparison arm being tested against the reference group. +\item \code{hr}: The Hazard Ratio (HR) for the comparison arm vs. the reference arm, formatted to two decimal places. +\item \code{ci}: The 95\\% confidence interval for the HR, presented as a string in the format "(lower, upper)", with values formatted to two decimal places. +\item \code{pval}: The log-rank p-value for the comparison. +} +} +\description{ +This function performs a pairwise comparison of treatment arms using the \strong{Cox proportional hazards model} and calculates the corresponding \strong{log-rank p-value}. Each comparison is made between a specified reference group and all other comparison groups in the dataset. +} +\details{ +The function iterates through each unique arm (excluding the reference group), filters the data to include only the current comparison arm and the reference arm, and then fits a Cox model (\code{\link[survival]{coxph}}) and performs a log-rank test (\code{\link[survival]{survdiff}}). The Hazard Ratio and its 95\\% confidence interval are extracted from the Cox model summary, and the p-value is calculated from the log-rank test. +} +\examples{ +# Example data setup (assuming 'time' is event time, 'status' is event indicator (1=event), +# and 'arm' is the treatment group) +library(survival) +use_lung <- lung +use_lung$arm <- factor(sample(c("A", "B", "C"), nrow(use_lung), replace = TRUE)) +use_lung$status <- use_lung$status - 1 # Convert status to 0/1 +use_lung <- na.omit(use_lung) + +formula <- Surv(time, status) ~ arm +results_tbl <- get_cox_pairwise_tbl( + model_formula = formula, + data = use_lung, + arm = "arm", + ref_group = "A" +) +print(results_tbl) +} diff --git a/man/h_data_plot.Rd b/man/h_data_plot.Rd new file mode 100644 index 00000000..159bc6c8 --- /dev/null +++ b/man/h_data_plot.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{h_data_plot} +\alias{h_data_plot} +\title{Prepare Kaplan-Meier Data for Plotting} +\usage{ +h_data_plot(fit_km, armval = "All", max_time = NULL) +} +\arguments{ +\item{fit_km}{A fitted Kaplan-Meier object of class \code{survfit}.} + +\item{armval}{Character string for the strata level if \code{fit_km} has no strata (e.g., "All").} + +\item{max_time}{Numeric, the maximum time point to include in the data, or \code{NULL} for no limit.} +} +\value{ +A data frame containing the survival curve steps, confidence intervals, and censoring info. +} +\description{ +Takes a fitted \code{survfit} object and processes it into a data frame +suitable for plotting a Kaplan-Meier curve with \code{ggplot2}, including extending +the curve to time zero. +} diff --git a/man/h_tbl_median_surv.Rd b/man/h_tbl_median_surv.Rd new file mode 100644 index 00000000..8d181629 --- /dev/null +++ b/man/h_tbl_median_surv.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{h_tbl_median_surv} +\alias{h_tbl_median_surv} +\title{Median Survival Summary Table} +\usage{ +h_tbl_median_surv(fit_km, armval = "All") +} +\arguments{ +\item{fit_km}{A fitted Kaplan-Meier object of class \code{survfit}.} + +\item{armval}{Character string to use as the row name if \code{fit_km} has no strata (e.g., "All").} +} +\value{ +A data frame with columns "N", "Median", and the confidence interval label. +} +\description{ +Extracts and formats the median survival time and its confidence interval +from a fitted Kaplan-Meier object. +} +\keyword{internal} diff --git a/man/h_xticks.Rd b/man/h_xticks.Rd new file mode 100644 index 00000000..2f912508 --- /dev/null +++ b/man/h_xticks.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gkm.R +\name{h_xticks} +\alias{h_xticks} +\title{Calculate X-axis Ticks} +\usage{ +h_xticks(data, xticks = NULL, max_time = NULL) +} +\arguments{ +\item{data}{A data frame containing a \code{time} column.} + +\item{xticks}{A numeric vector of specific tick positions, a single number for the interval, or \code{NULL} for auto-calculation.} + +\item{max_time}{Optional numeric value specifying the maximum time to consider for tick range.} +} +\value{ +A numeric vector of x-axis tick positions. +} +\description{ +Determines the positions for x-axis ticks based on the data and user input. +} +\keyword{internal} diff --git a/tests/testthat/test-gkm.R b/tests/testthat/test-gkm.R new file mode 100644 index 00000000..94671a23 --- /dev/null +++ b/tests/testthat/test-gkm.R @@ -0,0 +1,43 @@ +skip_on_cran() + +anl <- cards::ADTTE |> + dplyr::mutate(is_event = CNSR == 0) +by <- "TRTP" +anl[[by]] <- factor(anl[[by]], levels = c( + "Placebo", + "Xanomeline Low Dose", + "Xanomeline High Dose" +)) + +test_that("test gkm() works", { + GROUP_SYM <- rlang::sym(by) + model_formula <- rlang::new_formula( + lhs = rlang::expr(Surv(AVAL, is_event)), + rhs = rlang::expr(!!GROUP_SYM) + ) + + fit_kmg01 <- survival::survfit(model_formula, anl) + + + expect_no_error(surv_plot_data <- h_data_plot(fit_kmg01)) + + expect_no_error( + suppressWarnings( + coxph_tbl <- get_cox_pairwise_tbl( + model_formula, + data = anl, + arm = by + ) + ) + ) + + expect_no_error( + plt_kmg01 <- g_km(surv_plot_data, + xlab = "Time (Days)", + ylim = c(0.9, 1) + ) %>% + annot_surv_med(fit_kmg01) %>% + annot_cox_ph(coxph_tbl) %>% + annot_at_risk(fit_kmg01) + ) +})