From ad9d0e4e4cfd7a46edda8ff920c4ec83e9dda5f0 Mon Sep 17 00:00:00 2001 From: Carson Richardson Date: Sat, 5 Sep 2026 13:56:43 -0400 Subject: [PATCH 1/2] Misc doc changes --- NEWS.md | 86 ++++++++++++++++++++++++++++++++---------------- cran-comments.md | 2 +- revdep/README.md | 70 +++++++++++++++++++-------------------- 3 files changed, 93 insertions(+), 65 deletions(-) diff --git a/NEWS.md b/NEWS.md index db88d49..35f88ac 100644 --- a/NEWS.md +++ b/NEWS.md @@ -14,7 +14,7 @@ - Added explicit `subgroup` mappings for mixed hierarchical displays, allowing standalone covariates and contiguous categorical blocks to share a forest plot. Subgroup p-values can be delegated as "overall" or "level" via `p_method` - in `tidy_forest_model()` + in `tidy_forest_model()` (#11) - Added covariance-aware subgroup effects for fitted interaction models. `tidy_forest_model()` and fitted-model `as_forest_data()` methods now use `marginaleffects` to derive average slopes or comparisons from the original @@ -40,10 +40,10 @@ - Added the `forest_data` S3 class as the package's stable interchange format. It stores validated effect-scale, axis, confidence-level, reference-value, - source-model, source-package, and source-column metadata. + source-model, source-package, and source-column metadata. (#17) - Converted `as_forest_data()` into an S3 generic with methods for data frames, existing `forest_data` objects, linear and generalized linear models, Cox - models, and supported mixed-model classes. + models, and supported mixed-model classes. (#19) - Refactored `ggforestplot()` to consume the `forest_data` contract instead of determining plotting behavior from the original model class. - Added `forest_metadata()` for inspecting the semantic and provenance @@ -95,12 +95,12 @@ - Added mixed-model support for `tidy_forest_model()` and `ggforestplot()` when `broom.mixed` is installed. Mixed-model tidiers are restricted to fixed - effects so random-effect parameter rows are not plotted as terms. + effects so random-effect parameter rows are not plotted as terms. (#21) - Added confidence interval truncation support to `ggforestplot()` via `ci_limits`, with optional arrowheads for intervals extending beyond the - displayed range. + displayed range. (#18) - Added `bind_forest_models()` to stack multiple fitted models into one - grouped forest-plot data frame. + grouped forest-plot data frame. (#16) ## Minor Changes @@ -115,59 +115,87 @@ - Faceted `ggforestplot()` output now respects factor level order in the `facet` column, allowing users to control facet ordering directly from their data. -- Fixed faceted `ggforestplot()` output when users add `scale_y_discrete(limits = ...)`, preserving per-facet row filtering while accepting visible term labels. +- Fixed faceted `ggforestplot()` output when users add + `scale_y_discrete(limits = ...)`, preserving per-facet row filtering while + accepting visible term labels. (#20) # ggforestplotR 0.2.1 ## Breaking Changes -- Simplified the reference-line API in `ggforestplot()`: `ref_line` now takes the numeric reference value directly, and `NULL` hides the line. -- Replaced the older reference-line arguments with `ref_label`, `ref_linetype`, and `ref_color`. +- Simplified the reference-line API in `ggforestplot()`: `ref_line` now takes + the numeric reference value directly, and `NULL` hides the line. +- Replaced the older reference-line arguments with `ref_label`, `ref_linetype`, + and `ref_color`. ## Major Changes -- Added `facet` and `facet_strip_position` as clearer names for grouped plot panels. The older `grouping` and `grouping_strip_position` arguments now warn and continue to work. -- Added `linewidth` for confidence interval widths. The older `line_size` argument now warns and continues to work. -- Added deprecation warnings for table-display shortcuts in favor of explicit `columns`, `left_columns`, and `right_columns` selections. +- Added `facet` and `facet_strip_position` as clearer names for grouped plot + panels. The older `grouping` and `grouping_strip_position` arguments now warn + and continue to work. +- Added `linewidth` for confidence interval widths. The older `line_size` + argument now warns and continues to work. +- Added deprecation warnings for table-display shortcuts in favor of explicit + `columns`, `left_columns`, and `right_columns` selections. ## Minor Changes -- Added `stripe_alpha` to control row stripe transparency across plots, side tables, and split tables. -- Updated table helpers so they inherit custom y-axis ordering and filtering from the trained forest plot scale. -- Updated vignettes and tests to use the clearer faceting and geometry argument names. +- Added `stripe_alpha` to control row stripe transparency across plots, + side tables, and split tables. (#7) +- Updated table helpers so they inherit custom y-axis ordering and filtering + from the trained forest plot scale. (#9) +- Updated vignettes and tests to use the clearer faceting and geometry argument + names. # ggforestplotR 0.2.0 ## Major Changes -- Added separate table formatting controls for point estimates, confidence interval bounds, and p-values via `estimate_digits`, `interval_digits`, and `p_digits`. -- Added `ref_line`, `ref_line_value`, and `ref_line_label` to standardize reference-line naming while keeping `zero_line` arguments backward compatible. +- Added separate table formatting controls for point estimates, + confidence interval bounds, and p-values via `estimate_digits`, + `interval_digits`, and `p_digits`. +- Added `ref_line`, `ref_line_value`, and `ref_line_label` to standardize + reference-line naming while keeping `zero_line` arguments backward compatible. - Added `term_labels`, a named-vector relabeling helper for displayed model terms. -- Expanded `add_forest_table(columns = ...)` and split-table column selection to support arbitrary preserved data columns in addition to built-in columns. -- Added `column_labels`, a named-vector helper for relabeling displayed table column headers. -- Added a built-in `ci` table column and `ci_fmt` so users can display point estimates and confidence intervals in separate columns. `conf.low` and `conf.high` are accepted as aliases for `ci`. +- Expanded `add_forest_table(columns = ...)` and split-table column selection to + support arbitrary preserved data columns in addition to built-in columns. +- Added `column_labels`, a named-vector helper for relabeling displayed table + column headers. +- Added a built-in `ci` table column and `ci_fmt` so users can display point + estimates and confidence intervals in separate columns. `conf.low` and + `conf.high` are accepted as aliases for `ci`. ## Minor Changes -- Added model-aware default effect labels and scales, including odds ratios for binomial logit models and hazard ratios for Cox models. +- Added model-aware default effect labels and scales, including odds ratios for + binomial logit models and hazard ratios for Cox models. # ggforestplotR 0.1.1 ## Minor Changes -- Added `events` column support across `ggforestplot()`, `as_forest_data()`, `add_forest_table()`, and `add_split_table()`. (Thanks @sritchie73 for the suggestion) -- Added table header styling controls for font size, font face, and font family in both table helpers. (Thanks @sritchie73 for the suggestion) -- Split table composition code into dedicated source files to simplify maintenance of the public API. -- Refined split-table examples and documentation to match the expanded numeric column mapping. +- Added `events` column support across `ggforestplot()`, `as_forest_data()`, + `add_forest_table()`, and `add_split_table()`. (#2) (#3) + (Thanks @sritchie73 for the suggestion) +- Added table header styling controls for font size, font face, and font family + in both table helpers. (Thanks @sritchie73 for the suggestion) +- Split table composition code into dedicated source files to simplify + maintenance of the public API. +- Refined split-table examples and documentation to match the expanded numeric + column mapping. # ggforestplotR 0.1.0 ## Major Changes -- Refined split-table layout logic, including dynamic panel widths from column counts, alignment controls, and cleaner stripe/background handling. -- `add_forest_table()` now allows for custom column ordering via the `columns` argument. -- Converted package documentation to roxygen-generated `NAMESPACE` and `.Rd` files. -- Reworked package documentation, including pkgdown configuration, README installation guidance, and the vignette set. +- Refined split-table layout logic, including dynamic panel widths from column + counts, alignment controls, and cleaner stripe/background handling. +- `add_forest_table()` now allows for custom column ordering via the `columns` + argument. +- Converted package documentation to roxygen-generated `NAMESPACE` and `.Rd` + files. +- Reworked package documentation, including pkgdown configuration, README + installation guidance, and the vignette set. # ggforestplotR 0.0.0.9000 diff --git a/cran-comments.md b/cran-comments.md index eaf65e5..023b49b 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -4,7 +4,7 @@ ## R CMD check results -- `R CMD check ggforestplotR_0.3.1.tar.gz --as-cran` +- `R CMD check ggforestplotR_0.5.0.tar.gz --as-cran` - Status: OK 0 errors | 0 warnings | 0 notes diff --git a/revdep/README.md b/revdep/README.md index e212009..61a88a6 100644 --- a/revdep/README.md +++ b/revdep/README.md @@ -1,43 +1,43 @@ # Platform -|field |value | -|:--------|:-------------------------------------------------------------------------------------------------------------------------------------------------------| -|version |R version 4.6.0 (2026-04-24 ucrt) | -|os |Windows 11 x64 (build 26200) | -|system |x86_64, mingw32 | -|ui |RStudio | -|language |(EN) | -|collate |English_United States.utf8 | -|ctype |English_United States.utf8 | -|tz |America/New_York | -|date |2026-05-19 | -|rstudio |2026.04.0+526 Globemaster Allium (desktop) | -|pandoc |3.9 @ D:\pandoc\pandoc-3.9\pandoc.exe | -|quarto |ERROR: Unknown command "TMPDIR=C:/Users/Carso/AppData/Local/Temp/RtmpoFpwMI/file19fc4e91f46". Did you mean command "remove"? @ D:\Quarto\bin\quarto.exe | +|field |value | +|:--------|:------------------------------------------| +|version |R version 4.6.1 (2026-06-24 ucrt) | +|os |Windows 11 x64 (build 26200) | +|system |x86_64, mingw32 | +|ui |RStudio | +|language |(EN) | +|collate |English_United States.utf8 | +|ctype |English_United States.utf8 | +|tz |America/New_York | +|date |2026-09-02 | +|rstudio |2026.04.0+526 Globemaster Allium (desktop) | +|pandoc |3.9 @ D:\pandoc\pandoc-3.9\pandoc.exe | +|quarto |1.9.35 @ D:\Quarto\bin\quarto.exe | # Dependencies -|package |old |new |Δ | -|:-------------|:-----|:-----|:--| -|ggforestplotR |0.1.1 |0.2.0 |* | -|cli |3.6.6 |3.6.6 | | -|cpp11 |0.5.5 |0.5.5 | | -|farver |2.1.2 |2.1.2 | | -|ggplot2 |4.0.3 |4.0.3 | | -|glue |1.8.1 |1.8.1 | | -|gtable |0.3.6 |0.3.6 | | -|isoband |0.3.0 |0.3.0 | | -|labeling |0.4.3 |0.4.3 | | -|lifecycle |1.0.5 |1.0.5 | | -|patchwork |1.3.2 |1.3.2 | | -|R6 |2.6.1 |2.6.1 | | -|RColorBrewer |1.1-3 |1.1-3 | | -|rlang |1.2.0 |1.2.0 | | -|S7 |0.2.2 |0.2.2 | | -|scales |1.4.0 |1.4.0 | | -|vctrs |0.7.3 |0.7.3 | | -|viridisLite |0.4.3 |0.4.3 | | -|withr |3.0.2 |3.0.2 | | +|package |old |new |Δ | +|:-------------|:-----|:---|:--| +|ggforestplotR |0.3.1 |NA |* | +|cli |3.6.6 |NA |* | +|cpp11 |0.5.5 |NA |* | +|farver |2.1.2 |NA |* | +|ggplot2 |4.0.3 |NA |* | +|glue |1.8.1 |NA |* | +|gtable |0.3.6 |NA |* | +|isoband |0.3.0 |NA |* | +|labeling |0.4.3 |NA |* | +|lifecycle |1.0.5 |NA |* | +|patchwork |1.3.2 |NA |* | +|R6 |2.6.1 |NA |* | +|RColorBrewer |1.1-3 |NA |* | +|rlang |1.3.0 |NA |* | +|S7 |0.2.2 |NA |* | +|scales |1.4.0 |NA |* | +|vctrs |0.7.3 |NA |* | +|viridisLite |0.4.3 |NA |* | +|withr |3.0.3 |NA |* | # Revdeps From b2072fcfd88685e1059c1a2679e419c8d8244b6f Mon Sep 17 00:00:00 2001 From: Carson Richardson Date: Sun, 27 Sep 2026 11:07:33 -0400 Subject: [PATCH 2/2] Implement structured forest rows and table formatters --- DESCRIPTION | 4 +- NEWS.md | 10 + R/add_forest_table.R | 17 ++ R/add_split_table.R | 18 ++ R/as_forest_data.R | 17 ++ R/forest_data.R | 18 +- R/ggforestplot.R | 31 ++- R/ggplot_add_scales.R | 2 +- R/row_types.R | 24 ++ R/table_formatters.R | 62 ++++++ R/utils.R | 205 +++++++++++++----- man/add_forest_table.Rd | 11 + man/add_split_table.Rd | 10 + man/as_forest_data.Rd | 7 + man/ggforestplot.Rd | 8 +- tests/testthat/helper-structured-rows.R | 27 +++ tests/testthat/test-as_forest_data.R | 5 +- tests/testthat/test-row-types.R | 116 ++++++++++ tests/testthat/test-subgroup-effects.R | 10 +- tests/testthat/test-subgroup.R | 26 +-- tests/testthat/test-table-formatters.R | 106 +++++++++ vignettes/ggforestplotR-data-helpers.Rmd | 34 +++ .../ggforestplotR-plot-customization.Rmd | 35 +++ 23 files changed, 707 insertions(+), 96 deletions(-) create mode 100644 R/row_types.R create mode 100644 R/table_formatters.R create mode 100644 tests/testthat/helper-structured-rows.R create mode 100644 tests/testthat/test-row-types.R create mode 100644 tests/testthat/test-table-formatters.R diff --git a/DESCRIPTION b/DESCRIPTION index 2a50bbb..8e1de6d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: ggforestplotR Title: Publication-Ready Forest Plots with 'ggplot2' -Version: 0.5.0 +Version: 0.5.0.9000 Authors@R: person(given = "Carson", family = "Richardson", @@ -35,4 +35,4 @@ Config/testthat/edition: 3 Config/Needs/website: pkgdown URL: https://thatoneguy006.github.io/ggforestplotR/, https://github.com/thatoneguy006/ggforestplotR BugReports: https://github.com/thatoneguy006/ggforestplotR/issues -Config/roxygen2/version: 8.0.0 +Config/roxygen2/version: 8.1.0 diff --git a/NEWS.md b/NEWS.md index 5af9112..aa7b4c0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,13 @@ +# ggforestplotR (development version) + +- Added semantic forest row types (`estimate`, `reference`, `header`, `summary`, + and `spacer`) so tables can contain reference categories and structural rows + without fabricated plot geometry. +- Added vectorized `formatters` to both table helpers for estimates, confidence + bounds, p-values, and preserved source columns. Digit settings and text + templates remain available. +- Added `reference_text` to both table helpers to customize reference cells. + # ggforestplotR 0.5.0 ## Breaking Changes diff --git a/R/add_forest_table.R b/R/add_forest_table.R index d311368..6427cad 100644 --- a/R/add_forest_table.R +++ b/R/add_forest_table.R @@ -13,6 +13,8 @@ p_digits = NULL, estimate_fmt = NULL, ci_fmt = NULL, + formatters = NULL, + reference_text = "Reference", text_size = NULL, header_text_size = NULL, header_fontface = "bold", @@ -140,6 +142,8 @@ p_digits = digits$p_digits, estimate_fmt = estimate_fmt, ci_fmt = ci_fmt, + formatters = formatters, + reference_text = reference_text, columns = table_columns, display_data = display_data ) @@ -251,6 +255,13 @@ #' `columns` includes `"ci"`. Use `{conf.low}` and `{conf.high}` as #' placeholders. The shorthand `{conf.low, conf.high}` is also supported. #' Defaults to `"({conf.low}, {conf.high})"`. +#' @param formatters Named list of vectorized functions for scalar table +#' values. Keys include `estimate`, `ci`, `conf.low`, `conf.high`, `p` +#' (or `p.value`), `n`, `events`, `group`, and preserved source columns. +#' Each function receives a vector and returns one value per input. +#' Functions take precedence over digit settings; `estimate_fmt` and +#' `ci_fmt` compose the resulting strings. +#' @param reference_text Text shown in estimate cells for reference rows. #' @param text_size Text size for table contents. Defaults to `3.2`. #' @param header_text_size Header text size for table column labels. Defaults #' to `11`. @@ -317,6 +328,8 @@ add_forest_table <- function(plot = NULL, p_digits = NULL, estimate_fmt = NULL, ci_fmt = NULL, + formatters = NULL, + reference_text = "Reference", text_size = NULL, header_text_size = NULL, header_fontface = "bold", @@ -361,6 +374,8 @@ add_forest_table <- function(plot = NULL, p_digits = p_digits, estimate_fmt = estimate_fmt, ci_fmt = ci_fmt, + formatters = formatters, + reference_text = reference_text, text_size = text_size, header_text_size = header_text_size, header_fontface = header_fontface, @@ -396,6 +411,8 @@ add_forest_table <- function(plot = NULL, p_digits = p_digits, estimate_fmt = estimate_fmt, ci_fmt = ci_fmt, + formatters = formatters, + reference_text = reference_text, text_size = text_size, header_text_size = header_text_size, header_fontface = header_fontface, diff --git a/R/add_split_table.R b/R/add_split_table.R index 4e1b61b..5a73367 100644 --- a/R/add_split_table.R +++ b/R/add_split_table.R @@ -13,6 +13,8 @@ p_digits = NULL, estimate_fmt = NULL, ci_fmt = NULL, + formatters = NULL, + reference_text = "Reference", text_size = NULL, header_text_size = NULL, header_fontface = "bold", @@ -144,6 +146,8 @@ p_digits = digits$p_digits, estimate_fmt = estimate_fmt, ci_fmt = ci_fmt, + formatters = formatters, + reference_text = reference_text, columns = resolved_left, display_data = display_data ) @@ -161,6 +165,8 @@ p_digits = digits$p_digits, estimate_fmt = estimate_fmt, ci_fmt = ci_fmt, + formatters = formatters, + reference_text = reference_text, columns = resolved_right, display_data = display_data ) @@ -329,6 +335,12 @@ #' table columns include `"ci"`. Use `{conf.low}` and `{conf.high}` as #' placeholders. The shorthand `{conf.low, conf.high}` is also supported. #' Defaults to `"({conf.low}, {conf.high})"`. +#' @param formatters Named list of vectorized functions for scalar table +#' values. Keys include `estimate`, `ci`, `conf.low`, `conf.high`, `p` +#' (or `p.value`), `n`, `events`, `group`, and preserved source columns. +#' Functions take precedence over digit settings; `estimate_fmt` and +#' `ci_fmt` compose the resulting strings. +#' @param reference_text Text shown in estimate cells for reference rows. #' @param text_size Text size for table contents. Defaults to `3.2`. #' @param header_text_size Header text size for table column labels. Defaults #' to `11`. @@ -396,6 +408,8 @@ add_split_table <- function(plot = NULL, p_digits = NULL, estimate_fmt = NULL, ci_fmt = NULL, + formatters = NULL, + reference_text = "Reference", text_size = NULL, header_text_size = NULL, header_fontface = "bold", @@ -435,6 +449,8 @@ add_split_table <- function(plot = NULL, p_digits = p_digits, estimate_fmt = estimate_fmt, ci_fmt = ci_fmt, + formatters = formatters, + reference_text = reference_text, text_size = text_size, header_text_size = header_text_size, header_fontface = header_fontface, @@ -467,6 +483,8 @@ add_split_table <- function(plot = NULL, p_digits = p_digits, estimate_fmt = estimate_fmt, ci_fmt = ci_fmt, + formatters = formatters, + reference_text = reference_text, text_size = text_size, header_text_size = header_text_size, header_fontface = header_fontface, diff --git a/R/as_forest_data.R b/R/as_forest_data.R index 833ebd0..c8ab578 100644 --- a/R/as_forest_data.R +++ b/R/as_forest_data.R @@ -13,6 +13,11 @@ #' @param term_labels Optional named vector used to relabel displayed terms. #' Names should match values in the term column and values are the labels to #' display. +#' @param row_type Optional column name defining semantic rows: `"estimate"`, +#' `"reference"`, `"header"`, `"summary"`, or `"spacer"`. Estimate and +#' summary rows require finite estimate and CI values; other rows require +#' missing geometry. Structured rows retain source order, so use +#' `sort_terms = "none"`. If omitted, all rows are estimates. #' @param group Optional column name used for color-grouping multiple #' estimates per row. If this column is a factor, its levels control the #' group legend and vertical dodge order. @@ -122,6 +127,9 @@ as_forest_data.forest_data <- function(data, if (!"label" %in% names(out)) { out$label <- out$term } + if (!"row_type" %in% names(out)) { + out$row_type <- rep("estimate", nrow(out)) + } if (!is.null(p_method)) { stop( "`p_method` is already defined by the `forest_data` metadata.", @@ -188,6 +196,7 @@ as_forest_data.data.frame <- function(data, sort_terms = c("none", "descending", "ascending"), subgroup = NULL, p_method = c("overall", "level"), + row_type = NULL, ...) { if (!inherits(data, "data.frame")) { stop( @@ -239,6 +248,7 @@ as_forest_data.data.frame <- function(data, conf.low = resolve_column(data, conf.low, "conf.low"), conf.high = resolve_column(data, conf.high, "conf.high"), label = resolve_column(data, label, "label", required = FALSE), + row_type = resolve_column(data, row_type, "row_type", required = FALSE), group = resolve_column(data, group, "group", required = FALSE), subgroup = resolve_column(data, subgroup, "subgroup", required = FALSE), grouping = resolve_column(data, grouping, "grouping", required = FALSE), @@ -269,6 +279,13 @@ as_forest_data.data.frame <- function(data, as.character(data[[cols$label]]) } out$label <- apply_term_labels(out$term, out$label, term_labels) + out$row_type <- if (is.null(cols$row_type)) { + rep("estimate", nrow(out)) + } else { + normalize_forest_row_type(data[[cols$row_type]]) + } + out$term[out$row_type == "spacer" & is.na(out$term)] <- "" + out$label[out$row_type == "spacer" & is.na(out$label)] <- "" out$group <- if (is.null(cols$group)) { NA_character_ diff --git a/R/forest_data.R b/R/forest_data.R index 1b691a4..a2e7d49 100644 --- a/R/forest_data.R +++ b/R/forest_data.R @@ -170,17 +170,23 @@ validate_forest_metadata <- function(data, metadata) { identical(metadata$axis_transform, "log10") ) + geometry <- if ("row_type" %in% names(data)) { + data$row_type %in% forest_geometry_row_types() + } else { + rep(TRUE, nrow(data)) + } + bounded_columns <- c("estimate", "conf.low", "conf.high") if (identical(scale, "probability") && any(vapply(bounded_columns, function(column) { - any(data[[column]] < 0 | data[[column]] > 1) + any(data[[column]][geometry] < 0 | data[[column]][geometry] > 1) }, logical(1)))) { stop("Probability estimates and intervals must be between 0 and 1.", call. = FALSE) } if (identical(scale, "risk_difference") && any(vapply(bounded_columns, function(column) { - any(data[[column]] < -1 | data[[column]] > 1) + any(data[[column]][geometry] < -1 | data[[column]][geometry] > 1) }, logical(1)))) { stop("Risk-difference estimates and intervals must be between -1 and 1.", call. = FALSE) } @@ -200,11 +206,13 @@ forest_axis_label <- function(metadata) { } } -set_forest_metadata <- function(data, metadata) { +set_forest_metadata <- function(data, metadata, validate = TRUE) { if (is.null(metadata$p_method)) { metadata$p_method <- "overall" } - validate_forest_metadata(data, metadata) + if (isTRUE(validate)) { + validate_forest_metadata(data, metadata) + } attr(data, "forest_meta") <- metadata # Transitional mirrors for code written against ggforestplotR <= 0.3.1. @@ -283,7 +291,7 @@ strip_forest_data_class <- function(x) { return(strip_forest_data_class(out)) } - set_forest_metadata(out, metadata) + set_forest_metadata(out, metadata, validate = nrow(out) > 0L) } #' @export diff --git a/R/ggforestplot.R b/R/ggforestplot.R index 848de81..ae183aa 100644 --- a/R/ggforestplot.R +++ b/R/ggforestplot.R @@ -16,6 +16,10 @@ #' @param group Optional column name used for color-grouping estimates. If this #' column is a factor, its levels control the group legend and vertical dodge #' order. +#' @param row_type Optional data-frame column mapping for semantic row types. +#' `"estimate"` and `"summary"` rows draw points and intervals; +#' `"reference"`, `"header"`, and `"spacer"` rows occupy display space +#' without geometry. Existing `forest_data` objects define this internally. #' @param subgroup Optional column name defining hierarchical subgroup blocks. #' Missing or empty values identify ordinary standalone estimates. Each #' non-empty subgroup must form one contiguous block within a facet. This is @@ -139,7 +143,8 @@ ggforestplot <- function(data, ref_linetype = 2, ref_color = "grey60", subgroup = NULL, - p_method = c("overall", "level")) { + p_method = c("overall", "level"), + row_type = NULL) { ref_line_missing <- missing(ref_line) conf_level_missing <- missing(conf.level) p_method_missing <- missing(p_method) @@ -167,6 +172,12 @@ ggforestplot <- function(data, if (inherits(data, "forest_data") && !p_method_missing) { stop("`p_method` is already defined by the `forest_data` metadata.", call. = FALSE) } + if (!is.null(row_type) && !is.data.frame(data)) { + stop("`row_type` is only a mapping for data-frame input.", call. = FALSE) + } + if (!is.null(row_type) && inherits(data, "forest_data")) { + stop("`row_type` is already defined by the `forest_data` object.", call. = FALSE) + } forest_data <- if (inherits(data, "forest_data")) { as_forest_data( @@ -186,6 +197,7 @@ ggforestplot <- function(data, term_labels = term_labels, group = group, subgroup = subgroup, + row_type = row_type, grouping = facet, separate_groups = separate_groups, n = n, @@ -251,7 +263,9 @@ ggforestplot <- function(data, display_data <- build_forest_plot_data(forest_data) forest_data <- display_data$forest_data plot_data <- display_data$plot_data - estimate_data <- plot_data[plot_data$row_type == "estimate", , drop = FALSE] + geometry_data <- plot_data[ + plot_data$row_type %in% forest_geometry_row_types(), , drop = FALSE + ] stripe_data <- display_data$stripe_data separator_data <- display_data$separator_data plot_stripe_data <- stripe_data @@ -263,7 +277,7 @@ ggforestplot <- function(data, plot_x_limits <- ci_limits } else if (isTRUE(plot_exponentiate)) { plot_x_limits <- default_plot_background_limits( - estimate_data, + geometry_data, exponentiate = plot_exponentiate, include_zero = draw_ref_line, ref_line = ref_line @@ -276,11 +290,11 @@ ggforestplot <- function(data, } ci_plot_data <- build_ci_plot_data( - estimate_data, + geometry_data, ci_limits = ci_limits, exponentiate = plot_exponentiate ) - has_groups <- has_table_values(estimate_data, "group") + has_groups <- has_table_values(geometry_data, "group") dodge <- ggplot2::position_dodge(width = dodge_width) point_mapping <- if (has_groups) { ggplot2::aes( @@ -312,12 +326,7 @@ ggforestplot <- function(data, } p <- ggplot2::ggplot(ci_plot_data, point_mapping) - header_data <- plot_data[ - plot_data$row_type == "subgroup_header", - , - drop = FALSE - ] - if (nrow(header_data) > 0L) { + if (any(plot_data$row_type != "estimate")) { p <- p + ggplot2::geom_blank( data = plot_data, mapping = ggplot2::aes(y = .data$row_key), diff --git a/R/ggplot_add_scales.R b/R/ggplot_add_scales.R index ec9bb74..6f2beed 100644 --- a/R/ggplot_add_scales.R +++ b/R/ggplot_add_scales.R @@ -212,7 +212,7 @@ is_ggforestplot_faceted_y_scale <- function(scale, plot) { display_data <- forest_full_display_data(state) has_subgroup_headers <- !is.null(display_data) && "row_type" %in% names(display_data) && - any(display_data$row_type == "subgroup_header") + any(display_data$row_type != "estimate") if (!isTRUE(state$has_groupings) && !isTRUE(has_subgroup_headers)) { return(FALSE) diff --git a/R/row_types.R b/R/row_types.R new file mode 100644 index 0000000..1753984 --- /dev/null +++ b/R/row_types.R @@ -0,0 +1,24 @@ +forest_row_types <- function() { + c("estimate", "reference", "header", "summary", "spacer") +} + +forest_geometry_row_types <- function() { + c("estimate", "summary") +} + +normalize_forest_row_type <- function(x) { + if (!is.character(x) && !is.factor(x)) { + stop("`row_type` must contain character or factor values.", call. = FALSE) + } + values <- tolower(trimws(as.character(x))) + bad <- unique(values[is.na(values) | !values %in% forest_row_types()]) + if (length(bad) > 0L) { + quoted <- ifelse(is.na(bad), "NA", sprintf('"%s"', bad)) + stop(sprintf( + "Unsupported `row_type` value(s): %s. Allowed values are: %s.", + paste(quoted, collapse = ", "), + paste(sprintf('"%s"', forest_row_types()), collapse = ", ") + ), call. = FALSE) + } + values +} diff --git a/R/table_formatters.R b/R/table_formatters.R new file mode 100644 index 0000000..d67264f --- /dev/null +++ b/R/table_formatters.R @@ -0,0 +1,62 @@ +normalize_table_formatters <- function(formatters, data = NULL) { + if (is.null(formatters)) { + return(list()) + } + if (!is.list(formatters) || is.null(names(formatters)) || + anyNA(names(formatters)) || any(!nzchar(names(formatters)))) { + stop("`formatters` must be a named list of functions with non-empty names.", call. = FALSE) + } + keys <- names(formatters) + keys[keys == "p.value"] <- "p" + if (anyDuplicated(keys)) { + stop("`formatters` names must be unique after alias normalization.", call. = FALSE) + } + source_names <- if (inherits(data, "forest_data")) { + names(forest_source_columns(data)) + } else if (!is.null(data)) { + names(data) + } else { + character() + } + allowed <- unique(c( + "estimate", "ci", "conf.low", "conf.high", "p", "n", "events", "group", + setdiff(source_names, c("term", "label")) + )) + unknown <- setdiff(keys, allowed) + if (length(unknown) > 0L) { + stop(sprintf("Unknown formatter target(s): %s.", paste(unknown, collapse = ", ")), call. = FALSE) + } + if (!all(vapply(formatters, is.function, logical(1)))) { + stop("Every `formatters` entry must be a function.", call. = FALSE) + } + names(formatters) <- keys + formatters +} + +resolve_table_formatter <- function(formatters, key) { + formatter <- formatters[[key]] + if (is.null(formatter) && key %in% c("conf.low", "conf.high")) { + formatter <- formatters[["ci"]] + } + formatter +} + +apply_table_formatter <- function(values, formatter = NULL, fallback = as.character, + key = "value") { + result <- tryCatch( + if (is.null(formatter)) fallback(values) else formatter(values), + error = function(e) { + stop(sprintf("Formatter for `%s` failed: %s", key, conditionMessage(e)), + call. = FALSE) + } + ) + if (!is.atomic(result)) { + stop(sprintf("Formatter for `%s` must return an atomic vector.", key), call. = FALSE) + } + if (length(result) != length(values)) { + stop(sprintf("Formatter for `%s` must return one value per input value.", key), call. = FALSE) + } + result <- as.character(result) + result[is.na(result)] <- "" + result +} diff --git a/R/utils.R b/R/utils.R index c7699c5..ab6dd5a 100644 --- a/R/utils.R +++ b/R/utils.R @@ -22,7 +22,8 @@ resolve_column <- function(data, column, arg, required = TRUE) { forest_display_reserved_columns <- function() { c( "row_key", "grouping_panel", "row_type", "display_label", - ".forest_source_row", ".display_identity" + ".forest_source_row", ".display_identity", + ".forest_generated_subgroup_header" ) } @@ -49,14 +50,26 @@ validate_forest_data <- function(data, exponentiate = FALSE) { ) } + row_type <- if ("row_type" %in% names(data)) { + normalize_forest_row_type(data$row_type) + } else { + rep("estimate", nrow(data)) + } + geometry <- row_type %in% forest_geometry_row_types() numeric_cols <- c("estimate", "conf.low", "conf.high") + if (!any(geometry)) { + stop("Forest data must contain at least one `estimate` or `summary` row.", call. = FALSE) + } for (col in numeric_cols) { - if (anyNA(data[[col]])) { - stop(sprintf("Column `%s` cannot contain missing values.", col), call. = FALSE) + if (!is.numeric(data[[col]]) || + any(!is.finite(data[[col]][geometry]))) { + stop(sprintf("Column `%s` must contain finite numeric values for estimate and summary rows.", col), call. = FALSE) } - - if (isTRUE(exponentiate) && any(data[[col]] <= 0)) { + if (any(!is.na(data[[col]][!geometry]))) { + stop("Reference, header, and spacer rows cannot contain estimate or confidence-interval values.", call. = FALSE) + } + if (isTRUE(exponentiate) && any(data[[col]][geometry] <= 0)) { stop( "Ratio estimates and logarithmic axes require strictly positive `estimate`, `conf.low`, and `conf.high` values.", call. = FALSE @@ -64,10 +77,18 @@ validate_forest_data <- function(data, exponentiate = FALSE) { } } - if (any(data$conf.low > data$conf.high)) { + if (any(data$conf.low[geometry] > data$conf.high[geometry])) { stop("`conf.low` cannot be greater than `conf.high`.", call. = FALSE) } + labels <- if ("label" %in% names(data)) data$label else data$term + for (values in list(data$term, labels)) { + if (any(!is.na(row_type) & row_type != "spacer" & + (is.na(values) | !nzchar(trimws(as.character(values)))))) { + stop("Non-spacer rows require non-empty term and label values.", call. = FALSE) + } + } + invisible(data) } @@ -353,6 +374,13 @@ sort_forest_data <- function(data, sort_terms = c("none", "descending", "ascendi has_subgroups <- "subgroup" %in% names(data) && any(!is.na(data$subgroup) & nzchar(data$subgroup)) + has_structured_rows <- "row_type" %in% names(data) && + any(data$row_type != "estimate") + + if (isTRUE(has_structured_rows)) { + stop("`sort_terms` must be \"none\" when structured `row_type` values are used so row order is preserved.", call. = FALSE) + } + if (isTRUE(has_subgroups)) { stop( "`sort_terms` must be \"none\" when `subgroup` is used so source row order is preserved.", @@ -573,17 +601,19 @@ collapse_grouped_values <- function(formatted, } format_forest_p_values <- function(values, group = NULL, digits = 2, p_digits = digits, - force_group_labels = FALSE, align_groups = FALSE) { + force_group_labels = FALSE, align_groups = FALSE, + formatter = NULL) { p_digits <- resolve_table_digits(digits = digits, p_digits = p_digits)$p_digits values <- as.numeric(values) eps <- 10^(-p_digits) - formatted <- ifelse( + fallback <- function(values) ifelse( is.na(values), "", ifelse(values < eps, paste0("<", sprintf(paste0("%.", p_digits, "f"), eps)), sprintf(paste0("%.", p_digits, "f"), values) ) ) + formatted <- apply_table_formatter(values, formatter, fallback, key = "p") collapse_grouped_values( formatted, group, @@ -598,7 +628,10 @@ format_forest_estimates <- function(estimate, conf.low, conf.high, interval_digits = digits, estimate_fmt = NULL, force_group_labels = FALSE, - align_groups = FALSE) { + align_groups = FALSE, + estimate_formatter = NULL, + conf_low_formatter = NULL, + conf_high_formatter = NULL) { digits <- resolve_table_digits( digits = digits, estimate_digits = estimate_digits, @@ -611,9 +644,12 @@ format_forest_estimates <- function(estimate, conf.low, conf.high, stop("`estimate_fmt` must be a single character string.", call. = FALSE) } - estimate_text <- sprintf(paste0("%.", digits$estimate_digits, "f"), estimate) - conf_low_text <- sprintf(paste0("%.", digits$interval_digits, "f"), conf.low) - conf_high_text <- sprintf(paste0("%.", digits$interval_digits, "f"), conf.high) + estimate_text <- apply_table_formatter(estimate, estimate_formatter, + function(x) sprintf(paste0("%.", digits$estimate_digits, "f"), x), "estimate") + conf_low_text <- apply_table_formatter(conf.low, conf_low_formatter, + function(x) sprintf(paste0("%.", digits$interval_digits, "f"), x), "conf.low") + conf_high_text <- apply_table_formatter(conf.high, conf_high_formatter, + function(x) sprintf(paste0("%.", digits$interval_digits, "f"), x), "conf.high") formatted <- vapply( seq_along(estimate_text), @@ -645,7 +681,9 @@ format_forest_intervals <- function(conf.low, conf.high, interval_digits = digits, ci_fmt = NULL, force_group_labels = FALSE, - align_groups = FALSE) { + align_groups = FALSE, + conf_low_formatter = NULL, + conf_high_formatter = NULL) { digits <- resolve_table_digits( digits = digits, interval_digits = interval_digits @@ -657,8 +695,10 @@ format_forest_intervals <- function(conf.low, conf.high, stop("`ci_fmt` must be a single character string.", call. = FALSE) } - conf_low_text <- sprintf(paste0("%.", digits$interval_digits, "f"), conf.low) - conf_high_text <- sprintf(paste0("%.", digits$interval_digits, "f"), conf.high) + conf_low_text <- apply_table_formatter(conf.low, conf_low_formatter, + function(x) sprintf(paste0("%.", digits$interval_digits, "f"), x), "conf.low") + conf_high_text <- apply_table_formatter(conf.high, conf_high_formatter, + function(x) sprintf(paste0("%.", digits$interval_digits, "f"), x), "conf.high") formatted <- vapply( seq_along(conf_low_text), @@ -687,9 +727,10 @@ format_forest_intervals <- function(conf.low, conf.high, format_forest_table_values <- function(values, group = NULL, force_group_labels = FALSE, - align_groups = FALSE) { - formatted <- as.character(values) - formatted[is.na(formatted)] <- "" + align_groups = FALSE, + formatter = NULL, + key = "value") { + formatted <- apply_table_formatter(values, formatter, as.character, key) collapse_grouped_values( formatted, group, @@ -856,7 +897,7 @@ assign_hierarchical_row_keys <- function(data, has_groupings) { factor_levels <- if ( "row_type" %in% names(data) && - any(data$row_type == "subgroup_header") + any(data$row_type != "estimate") ) { rev(all_levels) } else { @@ -939,8 +980,12 @@ expand_subgroup_display_rows <- function(data, has_groupings) { display_data <- as_forest_display_frame(data) p_method <- forest_p_method(display_data) display_data$.forest_source_row <- seq_len(nrow(display_data)) - display_data$row_type <- "estimate" + if (!"row_type" %in% names(display_data)) { + display_data$row_type <- "estimate" + } + display_data$.forest_generated_subgroup_header <- FALSE display_data$display_label <- display_data$label + display_data$display_label[display_data$row_type == "spacer"] <- "" has_subgroups <- "subgroup" %in% names(display_data) && any(!is.na(display_data$subgroup) & nzchar(display_data$subgroup)) @@ -975,7 +1020,8 @@ expand_subgroup_display_rows <- function(data, has_groupings) { header$label <- subgroup header$subgroup <- subgroup header$grouping_panel <- display_data$grouping_panel[row_index] - header$row_type <- "subgroup_header" + header$row_type <- "header" + header$.forest_generated_subgroup_header <- TRUE header$display_label <- subgroup block_end <- position @@ -1043,7 +1089,8 @@ subgroup_header_rows <- function(data, header_row) { format_subgroup_header_p_values <- function(data, header_row, p_digits, - align_groups) { + align_groups, + formatter = NULL) { block <- subgroup_header_rows(data, header_row) if (nrow(block) == 0L) { @@ -1055,7 +1102,7 @@ format_subgroup_header_p_values <- function(data, length(unique(block_values)) == 1L) { return(format_forest_p_values( block_values[[1L]], - p_digits = p_digits + p_digits = p_digits, formatter = formatter )) } @@ -1064,7 +1111,7 @@ format_subgroup_header_p_values <- function(data, if (!isTRUE(has_groups)) { values <- block$p.value[!is.na(block$p.value)] value <- if (length(values) == 0L) NA_real_ else values[[1L]] - return(format_forest_p_values(value, p_digits = p_digits)) + return(format_forest_p_values(value, p_digits = p_digits, formatter = formatter)) } group_key <- ifelse( @@ -1082,7 +1129,8 @@ format_subgroup_header_p_values <- function(data, values, group = group_levels, p_digits = p_digits, - align_groups = align_groups + align_groups = align_groups, + formatter = formatter ) } @@ -1214,14 +1262,23 @@ build_forest_plot_data <- function(data) { plot_data <- expand_subgroup_display_rows(forest_data, has_groupings) plot_data$.display_identity <- encode_display_identity( plot_data$row_type, - ifelse(plot_data$row_type == "estimate", plot_data$subgroup, NA_character_), + ifelse(plot_data$.forest_generated_subgroup_header | + plot_data$row_type %in% c("estimate", "reference", "summary"), + plot_data$subgroup, NA_character_), plot_data$label ) + unique_source_rows <- plot_data$row_type == "spacer" | + (plot_data$row_type == "header" & + !plot_data$.forest_generated_subgroup_header) + plot_data$.display_identity[unique_source_rows] <- paste0( + plot_data$.display_identity[unique_source_rows], + "|source:", plot_data$.forest_source_row[unique_source_rows] + ) plot_data <- assign_row_keys(plot_data, has_groupings) - estimate_rows <- plot_data$row_type == "estimate" - source_rows <- plot_data$.forest_source_row[estimate_rows] - source_keys <- as.character(plot_data$row_key[estimate_rows]) + original_rows <- !is.na(plot_data$.forest_source_row) + source_rows <- plot_data$.forest_source_row[original_rows] + source_keys <- as.character(plot_data$row_key[original_rows]) forest_data$row_key <- factor( source_keys[match(seq_len(nrow(forest_data)), source_rows)], levels = levels(plot_data$row_key) @@ -1386,6 +1443,8 @@ build_forest_table_data <- function(data, p_digits = NULL, estimate_fmt = NULL, ci_fmt = NULL, + formatters = NULL, + reference_text = "Reference", column_labels = NULL, columns = NULL, display_data = data) { @@ -1395,6 +1454,11 @@ build_forest_table_data <- function(data, interval_digits = interval_digits, p_digits = p_digits ) + if (!is.character(reference_text) || length(reference_text) != 1L || + is.na(reference_text)) { + stop("`reference_text` must be a single non-missing character string.", call. = FALSE) + } + formatters <- normalize_table_formatters(formatters, data) if (is.null(conf.level) && inherits(data, "forest_data")) { conf.level <- forest_metadata(data)$conf_level } @@ -1437,8 +1501,8 @@ build_forest_table_data <- function(data, row_label_data_columns <- unique(c( "term", "label", mapped_row_label_columns )) - has_subgroup_headers <- "row_type" %in% names(display_data) && - any(display_data$row_type == "subgroup_header") + has_subgroup_headers <- ".forest_generated_subgroup_header" %in% + names(display_data) && any(display_data$.forest_generated_subgroup_header) visible_data_columns <- names(display_data)[ !startsWith(names(display_data), "..source..") & !names(display_data) %in% forest_display_reserved_columns() @@ -1476,9 +1540,15 @@ build_forest_table_data <- function(data, } else { "estimate" } - row_types[[row_key]] <- row_type - is_header <- identical(row_type, "subgroup_header") - is_child <- !is_header && + row_types[[i]] <- row_type + is_header <- identical(row_type, "header") + is_generated_header <- is_header && + ".forest_generated_subgroup_header" %in% names(rd) && + isTRUE(rd$.forest_generated_subgroup_header[[1L]]) + is_reference <- identical(row_type, "reference") + is_spacer <- identical(row_type, "spacer") + is_geometry <- row_type %in% forest_geometry_row_types() + is_child <- !is_header && !is_spacer && any(!is.na(rd$subgroup) & nzchar(rd$subgroup)) term_text <- if ("display_label" %in% names(rd)) { rd$display_label[[1L]] @@ -1490,7 +1560,7 @@ build_forest_table_data <- function(data, } else { rd$group } - row_group_values[[i]] <- if (is_header && identical(p_method, "overall")) { + row_group_values[[i]] <- if (is_generated_header && identical(p_method, "overall")) { subgroup_header_group_values(data, rd) } else { as.character(rd$group) @@ -1499,50 +1569,67 @@ build_forest_table_data <- function(data, row_key = row_key, grouping_panel = rd$grouping_panel[1L], term_text = term_text, - group_text = if (is_header) "" else format_forest_table_values( - group_values, rd$group, align_groups = align_groups + group_text = if (is_header || is_spacer) "" else format_forest_table_values( + group_values, rd$group, align_groups = align_groups, + formatter = formatters[["group"]], key = "group" ), - n_text = if (is_header) "" else format_forest_table_values( - rd$n, rd$group, align_groups = align_groups + n_text = if (is_header || is_spacer) "" else format_forest_table_values( + rd$n, rd$group, align_groups = align_groups, + formatter = formatters[["n"]], key = "n" ), - events_text = if (is_header) "" else format_forest_table_values( - rd$events, rd$group, align_groups = align_groups + events_text = if (is_header || is_spacer) "" else format_forest_table_values( + rd$events, rd$group, align_groups = align_groups, + formatter = formatters[["events"]], key = "events" ), - estimate_text = if (is_header) "" else format_forest_estimates( + estimate_text = if (is_reference) collapse_grouped_values( + rep(reference_text, nrow(rd)), rd$group, align_groups = align_groups + ) else if (!is_geometry) "" else format_forest_estimates( rd$estimate, rd$conf.low, rd$conf.high, rd$group, estimate_digits = digits$estimate_digits, interval_digits = digits$interval_digits, estimate_fmt = estimate_fmt, - align_groups = align_groups + align_groups = align_groups, + estimate_formatter = formatters[["estimate"]], + conf_low_formatter = resolve_table_formatter(formatters, "conf.low"), + conf_high_formatter = resolve_table_formatter(formatters, "conf.high") ), - estimate_value_text = if (is_header) "" else format_forest_estimates( + estimate_value_text = if (is_reference) collapse_grouped_values( + rep(reference_text, nrow(rd)), rd$group, align_groups = align_groups + ) else if (!is_geometry) "" else format_forest_estimates( rd$estimate, rd$conf.low, rd$conf.high, rd$group, estimate_digits = digits$estimate_digits, interval_digits = digits$interval_digits, estimate_fmt = if (is.null(estimate_fmt)) "{estimate}" else estimate_fmt, - align_groups = align_groups + align_groups = align_groups, + estimate_formatter = formatters[["estimate"]], + conf_low_formatter = resolve_table_formatter(formatters, "conf.low"), + conf_high_formatter = resolve_table_formatter(formatters, "conf.high") ), - ci_text = if (is_header) "" else format_forest_intervals( + ci_text = if (!is_geometry) "" else format_forest_intervals( rd$conf.low, rd$conf.high, rd$group, interval_digits = digits$interval_digits, ci_fmt = ci_fmt, - align_groups = align_groups + align_groups = align_groups, + conf_low_formatter = resolve_table_formatter(formatters, "conf.low"), + conf_high_formatter = resolve_table_formatter(formatters, "conf.high") ), - p_text = if (is_header && identical(p_method, "overall")) { + p_text = if (is_generated_header && identical(p_method, "overall")) { format_subgroup_header_p_values( data, rd, p_digits = digits$p_digits, - align_groups = align_groups + align_groups = align_groups, + formatter = formatters[["p"]] ) - } else if (is_header || + } else if (is_reference || is_spacer || is_generated_header || (is_child && identical(p_method, "overall"))) { "" } else { format_forest_p_values( rd$p.value, rd$group, p_digits = digits$p_digits, - align_groups = align_groups + align_groups = align_groups, + formatter = formatters[["p"]] ) }, stringsAsFactors = FALSE @@ -1550,8 +1637,12 @@ build_forest_table_data <- function(data, for (extra in extra_columns) { storage_field <- extra_storage_lookup[[extra]] + if (is_spacer) { + row_parts[[i]][[storage_field]] <- "" + next + } if (isTRUE(has_subgroup_headers) && extra %in% row_label_data_columns) { - if (is_header) { + if (is_generated_header) { row_parts[[i]][[storage_field]] <- term_text next } @@ -1564,7 +1655,8 @@ build_forest_table_data <- function(data, label_text <- format_forest_table_values( label_values, rd$group, - align_groups = FALSE + align_groups = FALSE, + formatter = formatters[[extra]], key = extra ) if (is_child) { label_text <- paste0( @@ -1576,7 +1668,7 @@ build_forest_table_data <- function(data, next } - if (is_header) { + if (is_generated_header) { row_parts[[i]][[storage_field]] <- "" next } @@ -1589,7 +1681,8 @@ build_forest_table_data <- function(data, row_parts[[i]][[storage_field]] <- format_forest_table_values( values, rd$group, - align_groups = align_groups + align_groups = align_groups, + formatter = formatters[[extra]], key = extra ) } } @@ -1660,7 +1753,7 @@ build_forest_table_data <- function(data, long_part <- data.frame( row_key = table_rows$row_key, grouping_panel = table_rows$grouping_panel, - row_type = unname(row_types[as.character(table_rows$row_key)]), + row_type = unname(row_types[match(as.character(table_rows$row_key), row_levels)]), column_key = key, column_position = NA_real_, text = table_rows[[column_field_lookup[[key]]]], diff --git a/man/add_forest_table.Rd b/man/add_forest_table.Rd index f572a35..3e05166 100644 --- a/man/add_forest_table.Rd +++ b/man/add_forest_table.Rd @@ -20,6 +20,8 @@ add_forest_table( p_digits = NULL, estimate_fmt = NULL, ci_fmt = NULL, + formatters = NULL, + reference_text = "Reference", text_size = NULL, header_text_size = NULL, header_fontface = "bold", @@ -95,6 +97,15 @@ shorthand \verb{\{conf.low, conf.high\}} is also supported. Defaults to placeholders. The shorthand \verb{\{conf.low, conf.high\}} is also supported. Defaults to \code{"({conf.low}, {conf.high})"}.} +\item{formatters}{Named list of vectorized functions for scalar table +values. Keys include \code{estimate}, \code{ci}, \code{conf.low}, \code{conf.high}, \code{p} +(or \code{p.value}), \code{n}, \code{events}, \code{group}, and preserved source columns. +Each function receives a vector and returns one value per input. +Functions take precedence over digit settings; \code{estimate_fmt} and +\code{ci_fmt} compose the resulting strings.} + +\item{reference_text}{Text shown in estimate cells for reference rows.} + \item{text_size}{Text size for table contents. Defaults to \code{3.2}.} \item{header_text_size}{Header text size for table column labels. Defaults diff --git a/man/add_split_table.Rd b/man/add_split_table.Rd index 85c2dfa..08f6509 100644 --- a/man/add_split_table.Rd +++ b/man/add_split_table.Rd @@ -20,6 +20,8 @@ add_split_table( p_digits = NULL, estimate_fmt = NULL, ci_fmt = NULL, + formatters = NULL, + reference_text = "Reference", text_size = NULL, header_text_size = NULL, header_fontface = "bold", @@ -96,6 +98,14 @@ table columns include \code{"ci"}. Use \code{{conf.low}} and \code{{conf.high}} placeholders. The shorthand \verb{\{conf.low, conf.high\}} is also supported. Defaults to \code{"({conf.low}, {conf.high})"}.} +\item{formatters}{Named list of vectorized functions for scalar table +values. Keys include \code{estimate}, \code{ci}, \code{conf.low}, \code{conf.high}, \code{p} +(or \code{p.value}), \code{n}, \code{events}, \code{group}, and preserved source columns. +Functions take precedence over digit settings; \code{estimate_fmt} and +\code{ci_fmt} compose the resulting strings.} + +\item{reference_text}{Text shown in estimate cells for reference rows.} + \item{text_size}{Text size for table contents. Defaults to \code{3.2}.} \item{header_text_size}{Header text size for table column labels. Defaults diff --git a/man/as_forest_data.Rd b/man/as_forest_data.Rd index 4183692..e30dbd0 100644 --- a/man/as_forest_data.Rd +++ b/man/as_forest_data.Rd @@ -49,6 +49,7 @@ as_forest_data(data, ...) sort_terms = c("none", "descending", "ascending"), subgroup = NULL, p_method = c("overall", "level"), + row_type = NULL, ... ) @@ -238,6 +239,12 @@ the canonical \code{p.value} column for either the omnibus interaction test or row-level effect tests, according to \code{p_method}, so they can share a table column with ordinary coefficient p-values.} +\item{row_type}{Optional column name defining semantic rows: \code{"estimate"}, +\code{"reference"}, \code{"header"}, \code{"summary"}, or \code{"spacer"}. Estimate and +summary rows require finite estimate and CI values; other rows require +missing geometry. Structured rows retain source order, so use +\code{sort_terms = "none"}. If omitted, all rows are estimates.} + \item{conf.int}{Logical; model methods require \code{TRUE} because forest data include confidence-interval columns.} diff --git a/man/ggforestplot.Rd b/man/ggforestplot.Rd index 19ea8e3..eda0c91 100644 --- a/man/ggforestplot.Rd +++ b/man/ggforestplot.Rd @@ -45,7 +45,8 @@ ggforestplot( ref_linetype = 2, ref_color = "grey60", subgroup = NULL, - p_method = c("overall", "level") + p_method = c("overall", "level"), + row_type = NULL ) } \arguments{ @@ -168,6 +169,11 @@ inference inside the plotting layer.} \code{"overall"} displays one p-value on each subgroup header; \code{"level"} displays p-values on the subgroup estimate rows. Fitted-model subgroup data inherit this choice from \code{\link[=tidy_forest_model]{tidy_forest_model()}}.} + +\item{row_type}{Optional data-frame column mapping for semantic row types. +\code{"estimate"} and \code{"summary"} rows draw points and intervals; +\code{"reference"}, \code{"header"}, and \code{"spacer"} rows occupy display space +without geometry. Existing \code{forest_data} objects define this internally.} } \value{ A \code{ggplot} object. Use standard \code{ggplot2} functions such as diff --git a/tests/testthat/helper-structured-rows.R b/tests/testthat/helper-structured-rows.R new file mode 100644 index 0000000..b9072a5 --- /dev/null +++ b/tests/testthat/helper-structured-rows.R @@ -0,0 +1,27 @@ +structured_rows <- function() { + data.frame( + term = c("Section", "Female", "Male", NA, NA, "Overall"), + estimate = c(NA, NA, 1.42, NA, NA, 1.18), + conf.low = c(NA, NA, 1.10, NA, NA, 1.04), + conf.high = c(NA, NA, 1.84, NA, NA, 1.34), + row_type = c("header", "reference", "estimate", "spacer", "spacer", "summary"), + p.value = c(0.03, NA, 0.008, NA, NA, 0.01), + n = c(NA, 60, 55, NA, NA, 115), + extra = c("Head", "Ref", "Male", NA, NA, "All") + ) +} + +make_structured_plot <- function(raw = structured_rows(), ...) { + ggforestplot(raw, row_type = "row_type", p.value = "p.value", + n = "n", ref_line = NULL, ...) +} + +as_structured_data <- function(raw, ...) { + as_forest_data(raw, term = "term", estimate = "estimate", + conf.low = "conf.low", conf.high = "conf.high", ...) +} + +table_cell <- function(spec, key, type) { + cells <- spec$table_data + cells$text[cells$column_key == key & cells$row_type == type] +} diff --git a/tests/testthat/test-as_forest_data.R b/tests/testthat/test-as_forest_data.R index 114cba4..2cb98a4 100644 --- a/tests/testthat/test-as_forest_data.R +++ b/tests/testthat/test-as_forest_data.R @@ -25,14 +25,15 @@ test_that("as_forest_data standardizes coefficient columns", { ) expect_equal( - names(out)[seq_len(12)], + names(out)[seq_len(13)], c( - "term", "estimate", "conf.low", "conf.high", "label", "group", + "term", "estimate", "conf.low", "conf.high", "label", "row_type", "group", "subgroup", "grouping", "separate_groups", "n", "events", "p.value" ) ) expect_true(all(c("variable", "beta", "lower", "upper", "cohort", "section", "block", "sample_size", "event_count") %in% names(out))) expect_equal(as.character(out$term), c("Age", "BMI")) + expect_true(all(out$row_type == "estimate")) expect_equal(as.character(out$group), c("A", "A")) expect_equal(as.character(out$grouping), c("Clinical", "Clinical")) expect_equal(as.character(out$separate_groups), c("Anthropometrics", "Anthropometrics")) diff --git a/tests/testthat/test-row-types.R b/tests/testthat/test-row-types.R new file mode 100644 index 0000000..646ed97 --- /dev/null +++ b/tests/testthat/test-row-types.R @@ -0,0 +1,116 @@ +test_that("row types normalize and require appropriate geometry", { + raw <- structured_rows() + raw$row_type[2] <- " Reference " + out <- as_structured_data(raw, row_type = "row_type") + expect_equal(out$row_type[2], "reference") + expect_equal(out$label[4:5], c("", "")) + expect_equal(unname(forest_metadata(out)$column_mapping[["row_type"]]), "row_type") + + raw$row_type[2] <- "ref" + expect_error(as_structured_data(raw, row_type = "row_type"), "Unsupported.*ref") + raw$row_type[2] <- NA_character_ + expect_error(as_structured_data(raw, row_type = "row_type"), "Unsupported.*NA") + raw$row_type[2] <- "reference" + raw$estimate[2] <- 1 + expect_error(as_structured_data(raw, row_type = "row_type"), "cannot contain estimate") + raw$estimate[2] <- NA_real_ + raw$conf.low[3] <- NA_real_ + expect_error(as_structured_data(raw, row_type = "row_type"), "conf.low.*finite") + raw$conf.low[3] <- 1.1 + raw$estimate[6] <- NA_real_ + expect_error(as_structured_data(raw, row_type = "row_type"), "estimate.*finite") + raw <- structured_rows() + expect_error(as_structured_data(raw[1:2, ], row_type = "row_type"), + "at least one") + expect_error(as_structured_data(raw, row_type = "row_type", + sort_terms = "ascending"), "sort_terms.*none") + raw$term[2] <- "" + expect_error(as_structured_data(raw, row_type = "row_type"), "non-empty") +}) + +test_that("structured rows preserve order and draw geometry only where required", { + p <- make_structured_plot() + display <- p$ggforestplotR_state$display_data + expect_equal(display$row_type, structured_rows()$row_type) + expect_equal(length(unique(as.character(display$row_key))), 6L) + expect_equal(rev(levels(display$row_key)), as.character(display$row_key)) + expect_equal(display$display_label[4:5], c("", "")) + expect_true(all(!is.na(p$ggforestplotR_state$forest_data$row_key))) + built <- ggplot2::ggplot_build(p) + point_index <- which(vapply(p$layers, function(x) inherits(x$geom, "GeomPoint"), logical(1))) + expect_equal(nrow(built$data[[point_index]]), 2L) + expect_true(any(vapply(p$layers, function(x) inherits(x$geom, "GeomBlank"), logical(1)))) +}) + +test_that("reference, header, summary, and spacer table cells follow semantics", { + p <- make_structured_plot() + spec <- build_forest_table_data( + p$ggforestplotR_state$forest_data, + display_data = p$ggforestplotR_state$display_data, + columns = c("term", "n", "estimate", "ci", "p", "extra") + ) + expect_equal(table_cell(spec, "estimate", "reference"), "Reference") + expect_equal(table_cell(spec, "ci", "reference"), "") + expect_equal(table_cell(spec, "p", "reference"), "") + expect_equal(table_cell(spec, "n", "reference"), "60") + expect_equal(table_cell(spec, "extra", "reference"), "Ref") + expect_equal(table_cell(spec, "estimate", "summary"), "1.18") + expect_equal(table_cell(spec, "p", "header"), "0.030") + expect_equal(table_cell(spec, "extra", "header"), "Head") + expect_true(all(spec$table_data$text[spec$table_data$row_type == "spacer"] == "")) + spec <- build_forest_table_data( + p$ggforestplotR_state$forest_data, + display_data = p$ggforestplotR_state$display_data, + columns = "estimate", reference_text = "Ref." + ) + expect_equal(table_cell(spec, "estimate", "reference"), "Ref.") +}) + +test_that("subgroup reference children are indented and generated headers stay marked", { + raw <- structured_rows()[2:3, ] + raw$subgroup <- "Sex" + p <- ggforestplot(raw, row_type = "row_type", subgroup = "subgroup", ref_line = NULL) + display <- p$ggforestplotR_state$display_data + expect_equal(display$row_type, c("header", "reference", "estimate")) + expect_equal(display$.forest_generated_subgroup_header, c(TRUE, FALSE, FALSE)) + expect_equal(display$display_label[2], " Female") + expect_equal(display$display_label[3], " Male") +}) + +test_that("semantic scale checks apply to geometry rows only", { + raw <- structured_rows() + expect_s3_class(as_structured_data(raw, row_type = "row_type", + estimate_scale = "ratio"), "forest_data") + raw$estimate[3] <- 0 + expect_error(as_structured_data(raw, row_type = "row_type", + estimate_scale = "ratio"), "strictly positive") + raw <- structured_rows() + raw$estimate[3] <- 1.2 + expect_error(as_structured_data(raw, row_type = "row_type", + estimate_scale = "probability"), "between 0 and 1") + raw <- structured_rows() + raw$estimate[3] <- 1.2 + expect_error(as_structured_data(raw, row_type = "row_type", + estimate_scale = "risk_difference"), "between -1 and 1") +}) + +test_that("grouped estimates still share rows while reference rows remain separate", { + raw <- data.frame( + term = c("Female", "Female", "Female", "Female"), + estimate = c(NA, NA, 1.2, 1.4), + conf.low = c(NA, NA, 1.0, 1.2), + conf.high = c(NA, NA, 1.4, 1.6), + type = c("reference", "reference", "estimate", "estimate"), + model = c("A", "B", "A", "B") + ) + p <- ggforestplot(raw, row_type = "type", group = "model", ref_line = NULL) + display <- p$ggforestplotR_state$display_data + expect_equal(length(unique(as.character(display$row_key))), 2L) + expect_equal(length(unique(as.character(display$row_key[1:2]))), 1L) + expect_equal(length(unique(as.character(display$row_key[3:4]))), 1L) + spec <- build_forest_table_data( + p$ggforestplotR_state$forest_data, + display_data = display, columns = "estimate" + ) + expect_equal(table_cell(spec, "estimate", "reference"), "Reference\nReference") +}) diff --git a/tests/testthat/test-subgroup-effects.R b/tests/testthat/test-subgroup-effects.R index 7e06522..5d402ac 100644 --- a/tests/testthat/test-subgroup-effects.R +++ b/tests/testthat/test-subgroup-effects.R @@ -199,7 +199,7 @@ test_that("interaction and covariate p-values share the canonical column", { plot <- ggforestplot(out) display_data <- plot$ggforestplotR_state$display_data - header <- display_data$row_type == "subgroup_header" + header <- display_data$row_type == "header" children <- display_data$row_type == "estimate" & !is.na(display_data$subgroup) standalone <- display_data$row_type == "estimate" & @@ -232,7 +232,7 @@ test_that("level p-values stay on subgroup estimate rows", { plot <- ggforestplot(out) display_data <- plot$ggforestplotR_state$display_data - header <- display_data$row_type == "subgroup_header" + header <- display_data$row_type == "header" children <- display_data$row_type == "estimate" & !is.na(display_data$subgroup) standalone <- display_data$row_type == "estimate" & @@ -297,12 +297,12 @@ test_that("model-derived rows stay aligned with plots and tables", { expect_equal( display_data$row_type, - c("subgroup_header", rep("estimate", 3L)) + c("header", rep("estimate", 3L)) ) expect_equal(display_data$display_label[[1L]], "cyl") expect_equal(trimws(display_data$display_label[-1L]), c("4", "6", "8")) expect_true(all(!is.na( - display_data$p.value[display_data$row_type == "subgroup_header"] + display_data$p.value[display_data$row_type == "header"] ))) table_spec <- build_forest_table_data( @@ -312,7 +312,7 @@ test_that("model-derived rows stay aligned with plots and tables", { ) table_data <- table_spec$table_data header_key <- as.character( - display_data$row_key[display_data$row_type == "subgroup_header"] + display_data$row_key[display_data$row_type == "header"] ) header_cells <- table_data[ as.character(table_data$row_key) == header_key, diff --git a/tests/testthat/test-subgroup.R b/tests/testthat/test-subgroup.R index acc2ef4..d1a5543 100644 --- a/tests/testthat/test-subgroup.R +++ b/tests/testthat/test-subgroup.R @@ -59,7 +59,7 @@ subgroup_is_indented <- function(display_label, original_label) { } expect_subgroup_header_blanks <- function(table_data, display_data) { - header_rows <- display_data$row_type == "subgroup_header" + header_rows <- display_data$row_type == "header" header_keys <- as.character(display_data$row_key[header_rows]) expected_labels <- stats::setNames( display_data$display_label[header_rows], @@ -107,7 +107,7 @@ test_that("subgroup is an explicit canonical mapping and is not inferred", { expect_equal(as.character(explicit$subgroup), raw$subgroup_name) expect_equal(nrow(explicit), nrow(raw)) - expect_false("row_type" %in% names(explicit)) + expect_true(all(explicit$row_type == "estimate")) expect_equal( unname(forest_metadata(explicit)$column_mapping[["subgroup"]]), "subgroup_name" @@ -130,8 +130,8 @@ test_that("mixed standalone and subgroup rows preserve source order", { expect_equal( display_data$row_type, c( - "estimate", "subgroup_header", "estimate", "estimate", - "estimate", "subgroup_header", "estimate", "estimate" + "estimate", "header", "estimate", "estimate", + "estimate", "header", "estimate", "estimate" ) ) expect_equal( @@ -173,7 +173,7 @@ test_that("p_method level keeps p-values on subgroup children", { p_method = "level" ) display_data <- p$ggforestplotR_state$display_data - header_rows <- display_data$row_type == "subgroup_header" + header_rows <- display_data$row_type == "header" child_rows <- display_data$row_type == "estimate" & !is.na(display_data$subgroup) & nzchar(display_data$subgroup) standalone_rows <- display_data$row_type == "estimate" & !child_rows @@ -205,13 +205,13 @@ test_that("subgroup hierarchy handles boundary compositions", { expect_equal( grouped_display$row_type, c( - "subgroup_header", "estimate", "estimate", - "subgroup_header", "estimate", "estimate" + "header", "estimate", "estimate", + "header", "estimate", "estimate" ) ) expect_equal( grouped_display$display_label[ - grouped_display$row_type == "subgroup_header" + grouped_display$row_type == "header" ], c("Race", "Sex") ) @@ -251,7 +251,7 @@ test_that("point and confidence interval geoms exclude subgroup headers", { p <- subgroup_plot() display_data <- p$ggforestplotR_state$display_data header_keys <- as.character( - display_data$row_key[display_data$row_type == "subgroup_header"] + display_data$row_key[display_data$row_type == "header"] ) point_indices <- subgroup_layer_indices(p, "GeomPoint") interval_indices <- subgroup_layer_indices(p, "GeomErrorbar") @@ -297,7 +297,7 @@ test_that("subgroup forest table preserves hierarchy and row alignment", { display_data$display_label ) - header_rows <- display_data$row_type == "subgroup_header" + header_rows <- display_data$row_type == "header" child_rows <- display_data$row_type == "estimate" & !is.na(display_data$subgroup) & nzchar(display_data$subgroup) standalone_rows <- display_data$row_type == "estimate" & !child_rows @@ -472,7 +472,7 @@ test_that("subgroup parent p-values respect facets and estimate groups", { display_data = p$ggforestplotR_state$display_data ) parent_p <- spec$table_data[ - spec$table_data$row_type == "subgroup_header" & + spec$table_data$row_type == "header" & spec$table_data$column_key == "p", , drop = FALSE @@ -578,7 +578,7 @@ test_that("source columns are not shadowed by subgroup display internals", { display_data$row_key[display_data$row_type == "estimate"] ) header_keys <- as.character( - display_data$row_key[display_data$row_type == "subgroup_header"] + display_data$row_key[display_data$row_type == "header"] ) for (column in internal_names) { @@ -641,7 +641,7 @@ test_that("custom y limits support header-only subgroup views", { expect_length(split_tables, 2L) expect_true(all(vapply( split_tables, - function(table) all(table$data$row_type == "subgroup_header"), + function(table) all(table$data$row_type == "header"), logical(1) ))) }) diff --git a/tests/testthat/test-table-formatters.R b/tests/testthat/test-table-formatters.R new file mode 100644 index 0000000..d7af4dd --- /dev/null +++ b/tests/testthat/test-table-formatters.R @@ -0,0 +1,106 @@ +test_that("formatter specifications and results are validated", { + raw <- structured_rows() + p <- make_structured_plot(raw) + data <- p$ggforestplotR_state$forest_data + display <- p$ggforestplotR_state$display_data + build <- function(formatters) build_forest_table_data( + data, display_data = display, columns = c("estimate", "p"), + formatters = formatters + ) + expect_error(build(list(function(x) x)), "named list") + expect_error(build("bad"), "named list") + expect_error(build(stats::setNames(list(identity), "")), "non-empty") + expect_error(build(list(p = identity, p.value = identity)), "unique") + expect_error(build(list(p = "bad")), "function") + expect_error(build(list(unknown = identity)), "Unknown formatter") + expect_error(build(list(estimate = function(x) stop("boom"))), + "Formatter for `estimate` failed: boom") + expect_error(apply_table_formatter(1:2, function(x) "fixed", key = "estimate"), + "one value per input") + expect_error(apply_table_formatter(1:2, function(x) list("a", "b"), key = "n"), + "atomic vector") + expect_error(build_forest_table_data(data, display_data = display, + reference_text = NA_character_), "reference_text") +}) + +test_that("estimate, CI, and p formatters precede templates and digits", { + p <- make_structured_plot() + args <- list( + data = p$ggforestplotR_state$forest_data, + display_data = p$ggforestplotR_state$display_data, + columns = c("estimate", "ci", "p"), + p_digits = 1, + estimate_fmt = "{estimate} [{conf.low}, {conf.high}]", + ci_fmt = "<{conf.low}|{conf.high}>", + formatters = list( + estimate = function(x) sprintf("%.1f", x), + ci = function(x) sprintf("%.3f", x), + conf.low = function(x) paste0("L", sprintf("%.2f", x)), + p.value = function(x) sprintf("P%.3f", x) + ) + ) + spec <- do.call(build_forest_table_data, args) + expect_equal(table_cell(spec, "estimate", "estimate"), "1.4 [L1.10, 1.840]") + expect_equal(table_cell(spec, "ci", "estimate"), "") + expect_equal(table_cell(spec, "p", "estimate"), "P0.008") + expect_equal(table_cell(spec, "p", "header"), "P0.030") + expect_equal(table_cell(spec, "estimate", "reference"), "Reference") + args$columns <- "estimate" + spec <- do.call(build_forest_table_data, args) + expect_equal(table_cell(spec, "estimate", "estimate"), "1.4 [L1.10, 1.840]") +}) + +test_that("custom source columns and non-geometric values are formatted", { + raw <- structured_rows() + raw$prevalence <- c(NA, 0.2, 0.33, NA, NA, 0.4) + raw$date <- as.Date("2026-01-01") + seq_len(nrow(raw)) + p <- make_structured_plot(raw) + spec <- build_forest_table_data( + p$ggforestplotR_state$forest_data, + display_data = p$ggforestplotR_state$display_data, + columns = c("term", "n", "prevalence", "date"), + formatters = list( + n = function(x) paste0("N=", x), + prevalence = function(x) ifelse(is.na(x), NA_character_, + paste0(round(100 * x), "%")), + date = function(x) format(x, "%Y/%m/%d") + ) + ) + expect_equal(table_cell(spec, "n", "reference"), "N=60") + expect_equal(table_cell(spec, "prevalence", "reference"), "20%") + expect_match(table_cell(spec, "date", "reference"), "^2026/") + expect_true(all(spec$table_data$text[spec$table_data$row_type == "spacer"] == "")) +}) + +test_that("both public table adders accept formatters and reference text", { + skip_if_not_installed("patchwork") + p <- make_structured_plot() + formatter <- list(estimate = function(x) sprintf("%.1f", x)) + expect_s3_class( + p + add_forest_table(columns = c("term", "estimate"), + formatters = formatter, reference_text = "Ref."), + "patchwork" + ) + expect_s3_class( + p + add_split_table(left_columns = "term", right_columns = "estimate", + formatters = formatter, reference_text = "Ref."), + "patchwork" + ) +}) + +test_that("generated subgroup p-values use the custom formatter", { + raw <- structured_rows()[2:3, ] + raw$subgroup <- "Sex" + raw$p.value[2] <- 0.007 + p <- ggforestplot(raw, row_type = "row_type", subgroup = "subgroup", + p.value = "p.value", ref_line = NULL) + spec <- build_forest_table_data( + p$ggforestplotR_state$forest_data, + display_data = p$ggforestplotR_state$display_data, + columns = c("estimate", "p"), + formatters = list(p = function(x) sprintf("P%.4f", x)) + ) + expect_equal(table_cell(spec, "p", "header"), "P0.0070") + expect_equal(table_cell(spec, "p", "reference"), "") + expect_equal(table_cell(spec, "p", "estimate"), "") +}) diff --git a/vignettes/ggforestplotR-data-helpers.Rmd b/vignettes/ggforestplotR-data-helpers.Rmd index 382e348..21086a7 100644 --- a/vignettes/ggforestplotR-data-helpers.Rmd +++ b/vignettes/ggforestplotR-data-helpers.Rmd @@ -26,6 +26,40 @@ library(ggplot2) This short article covers the `forest_data` interchange object and the helper functions that prepare it before a plot is drawn. +## Structured forest data and reference rows + +Map a source column with `row_type =` to place reference categories and +structural rows alongside estimates. `estimate` and `summary` rows need finite +estimates and confidence bounds. `reference`, `header`, and `spacer` rows use +`NA` for all three geometry values. Structured rows keep their source order; +`sort_terms` must remain `"none"`. + +```{r structured-rows} +structured <- data.frame( + term = c("Female", "Male", "Clinical", "Age", "", "Overall"), + estimate = c(NA, 1.42, NA, 1.03, NA, 1.18), + conf.low = c(NA, 1.10, NA, 1.01, NA, 1.04), + conf.high = c(NA, 1.84, NA, 1.05, NA, 1.34), + subgroup = c("Sex", "Sex", NA, NA, NA, NA), + row_type = c("reference", "estimate", "header", "estimate", "spacer", "summary") +) + +ggforestplot( + structured, + subgroup = "subgroup", + row_type = "row_type", + exponentiate = TRUE +) + add_split_table( + left_columns = "term", + right_columns = c("estimate", "ci"), + reference_text = "Reference" +) +``` + +You can also supply explicit `"header"` and `"spacer"` rows in the source +table. A mapped `subgroup` creates its own header; reference rows inside the +subgroup appear as indented children. + ## Use `as_forest_data()` to standardize a coefficient table `as_forest_data()` converts your column names into the internal structure used diff --git a/vignettes/ggforestplotR-plot-customization.Rmd b/vignettes/ggforestplotR-plot-customization.Rmd index a7fa95d..5a18848 100644 --- a/vignettes/ggforestplotR-plot-customization.Rmd +++ b/vignettes/ggforestplotR-plot-customization.Rmd @@ -25,6 +25,41 @@ library(ggplot2) This article focuses on utility and enhanced customization of forest plots and accompanied tables. +## Formatting table values with functions + +`formatters` accepts a named list of vectorized functions. Each function +receives a source vector and returns one value per input. You can format built +in statistics and any preserved source column. A `ci` formatter applies to +both confidence bounds unless `conf.low` or `conf.high` overrides it. + +```{r function-formatters} +format_data <- data.frame( + term = c("Age", "BMI"), + estimate = c(0.31, -0.24), + conf.low = c(0.12, -0.40), + conf.high = c(0.56, -0.08), + p.value = c(0.0004, 0.023), + prevalence = c(0.1543, 0.2871) +) + +ggforestplot(format_data, p.value = "p.value") + + add_forest_table( + columns = c("term", "prevalence", "estimate", "p"), + formatters = list( + estimate = function(x) sprintf("%.1f", x), + ci = function(x) sprintf("%.3f", x), + p = function(x) ifelse(x < 0.001, "<0.001", sprintf("%.3f", x)), + prevalence = function(x) sprintf("%.1f%%", 100 * x) + ), + estimate_fmt = "{estimate} [{conf.low}, {conf.high}]" + ) +``` + +Formatter functions act on scalar source values before `estimate_fmt` and +`ci_fmt` compose text, and before grouped cells are combined. Functions from +other packages work when they follow the same vector input and output +contract. + ## Group rows and control strip placement `facet` creates section panels, and `facet_strip_position` controls which