R version 4.6.1 (2026-06-24)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.5 LTS
Matrix products: default
BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
locale:
[1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
[4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
[7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
[10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
time zone: UTC
tzcode source: system (glibc)
attached base packages:
[1] stats graphics grDevices utils datasets methods base
loaded via a namespace (and not attached):
[1] compiler_4.6.1 fastmap_1.2.0 cli_3.6.6 tools_4.6.1
[5] htmltools_0.5.9 otel_0.2.0 yaml_2.3.12 rmarkdown_2.32
[9] knitr_1.52 jsonlite_2.0.0 xfun_0.61 digest_0.6.39
[13] rlang_1.3.0 evaluate_1.0.5
If you are a biostatistician porting a SAS template to R, start at Track A. If you are here for testing, CI, or package infrastructure, start at Track B.
Development environment setup
Prerequisites
- R ≥ 4.1
- RStudio (recommended) or any R-capable editor
- Git
Installing development dependencies
Clone the repository, then install the development dependencies declared in DESCRIPTION. The install_deps() call picks up both Imports (needed at runtime) and Suggests (needed to run tests and build vignettes), so your local environment matches what CI sees.
# Clone (first time only)
# git clone https://github.com/ehrlinger/hvtiPlotR.git
# Install devtools if needed
install.packages("devtools")
# Install all Imports + Suggests from DESCRIPTION
devtools::install_deps(dependencies = TRUE)The development workflow loop
These four calls cover the full cycle for any code change: reload, redocument, test, then check. You will run load_all() and test() constantly; document() whenever you edit roxygen comments; check() before opening a PR.
# Load the package into the current session without installing
devtools::load_all()
# Regenerate NAMESPACE and .Rd files from roxygen comments
devtools::document()
# Run all tests
devtools::test()
# Full R CMD CHECK (must pass before merging)
devtools::check()
# Build vignettes only
devtools::build_vignettes()devtools::load_all() re-sources all R/*.R files and makes every exported function available immediately, much faster than install.packages() during development.
Track A: Porting a SAS template
This track walks through adding a brand-new plot by porting an existing SAS template. We use a fictional template tp.np.bmi.avrg_curv.binary.sas as the running example, and port it the way every new plot family in the package is built (hazard_plot(), survival_difference_plot() and nnt_plot() are legacy single-call functions kept for compatibility, not a pattern to copy): a constructor, hv_bmi_curve(), that validates the data and returns an hv_data object, and a plot.hv_bmi_curve() method that turns that object into a bare ggplot. R/spaghetti-plot.R is the real file to keep open beside you; the example below follows it step for step.
Step 1: Understand the SAS template output
Before writing any R code, identify:
What datasets does the template produce? Most
tp.np.*templates export apredictdataset (the fitted curve) and ameansdataset (binned patient-level summaries). Export these to CSV from SAS to use as your development data.-
What are the SAS column names? Document the mapping in the constructor’s roxygen
@descriptionblock. For example:SAS column R column Meaning iv_bmitimex-axis variable (BMI) mean_curvestimatepredicted probability cll_p68lower68 % CI lower bound clu_p68upper68 % CI upper bound Which existing R function is closest? Check
_pkgdown.ymland the plot-functions vignette. You may only need to extend an existing function rather than create a new one. For this template,hv_nonparametric()already covers most of the ground, so in real life you would extend it. We build a new pair here only to show the pattern.
Step 2: Create the R source file
Create R/bmi-curve-plot.R. Filenames are kebab-case and end in -plot.R where the concept is a plot (R/spaghetti-plot.R, R/nonparametric-curve-plot.R). One concept lives in one file: the sample-data generator, the hv_<concept>() constructor, and its print and plot methods together.
The work splits in two, and the split is the point. The constructor checks the data and records which columns play which role; it draws nothing. The plot method reads that record back and draws; it never re-validates column names. A caller can print the object, inspect $data, or plot it several ways without repeating the checks.
The constructor
# File: R/bmi-curve-plot.R
#' Prepare average BMI curve data for plotting
#'
#' Validates the fitted curve output from a nonparametric analysis of a
#' binary outcome against BMI (or any continuous covariate) and returns an
#' `hv_bmi_curve` object. Call [plot.hv_bmi_curve()] on the result to draw
#' it. Ports `tp.np.bmi.avrg_curv.binary.sas`.
#'
#' SAS column mapping:
#' - `time` is `iv_bmi` (BMI on the x-axis)
#' - `estimate` is `mean_curv` (predicted probability)
#' - `lower` is `cll_p68` (68 % CI lower)
#' - `upper` is `clu_p68` (68 % CI upper)
#'
#' @param data Data frame of fitted curve output, one row per x value.
#' @param x_col Name of the x-axis column. Default `"time"`.
#' @param estimate_col Name of the predicted value column. Default `"estimate"`.
#' @param lower_col Name of the lower CI column, or `NULL` for no ribbon.
#' Default `NULL`.
#' @param upper_col Name of the upper CI column, or `NULL`. Default `NULL`.
#'
#' @return An object of class `c("hv_bmi_curve", "hv_data")`; call `plot()`
#' on it to draw the figure. The list contains:
#' - `$data`: the validated input data frame.
#' - `$meta`: named list of the column names above, plus `n_points` and
#' `n_missing`.
#' - `$tables`: empty list.
#'
#' @seealso [plot.hv_bmi_curve()] to draw the figure,
#' [sample_bmi_curve_data()] for example data,
#' [theme_hv_manuscript()] for the publication theme.
#'
#' @references SAS template: `tp.np.bmi.avrg_curv.binary.sas`.
#'
#' @family BMI curve
#'
#' @examples
#' dat <- sample_bmi_curve_data(n = 500)
#' bc <- hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")
#' bc # prints the column mapping
#'
#' @export
hv_bmi_curve <- function(data,
x_col = "time",
estimate_col = "estimate",
lower_col = NULL,
upper_col = NULL) {
.check_df(data)
.check_cols(data, c(x_col, estimate_col, lower_col, upper_col))
incomplete <- .count_incomplete(data, c(x_col, estimate_col))
new_hv_data(
data = as.data.frame(data),
meta = list(
x_col = x_col,
estimate_col = estimate_col,
lower_col = lower_col,
upper_col = upper_col,
n_points = nrow(data),
n_missing = incomplete$n_missing
),
tables = list(),
subclass = "hv_bmi_curve"
)
}new_hv_data() in R/hvti-data.R is the only way to build the return value. It guarantees the three slots every hv_data object carries ($data, $meta, $tables) and sets the class to c("hv_bmi_curve", "hv_data"), so the base methods in that file act as the fallback for anything your subclass does not define. .check_df(), .check_cols() and .count_incomplete() live in R/validators.R; use them rather than writing your own stop() calls, so the error messages match the rest of the package.
The print and plot methods
#' Print an hv_bmi_curve object
#'
#' @param x An `hv_bmi_curve` object from [hv_bmi_curve()].
#' @param ... Ignored.
#' @return `x`, invisibly.
#' @export
print.hv_bmi_curve <- function(x, ...) {
m <- x$meta
cat("<hv_bmi_curve>\n")
cat(sprintf(" N points : %d\n", m$n_points))
cat(sprintf(" x / estimate: %s / %s\n", m$x_col, m$estimate_col))
if (!is.null(m$lower_col))
cat(sprintf(" CI columns : %s / %s\n", m$lower_col, m$upper_col))
invisible(x)
}
#' Plot an hv_bmi_curve object
#'
#' Draws the average curve, with a confidence ribbon when the constructor
#' was given CI columns.
#'
#' @param x An `hv_bmi_curve` object.
#' @param line_width Width of the curve line. Default `1.0`.
#' @param alpha Transparency of the ribbon in \eqn{[0,1]}. Default `0.2`.
#' @param ... Ignored; present for S3 consistency.
#'
#' @return A bare [ggplot2::ggplot()] object; compose with `+` to add
#' scales, labels, and [theme_hv_manuscript()].
#'
#' @seealso [hv_bmi_curve()] to build the data object.
#'
#' @family BMI curve
#'
#' @examples
#' dat <- sample_bmi_curve_data(n = 500)
#' bc <- hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")
#' plot(bc) +
#' ggplot2::scale_y_continuous(labels = scales::percent) +
#' ggplot2::labs(x = "BMI (kg/m2)", y = "Prevalence of AF") +
#' theme_hv_manuscript()
#'
#' @importFrom ggplot2 ggplot aes geom_line geom_ribbon
#' @importFrom rlang .data
#' @export
plot.hv_bmi_curve <- function(x, line_width = 1.0, alpha = 0.2, ...) {
.check_alpha(alpha)
m <- x$meta
p <- ggplot2::ggplot(
x$data,
ggplot2::aes(x = .data[[m$x_col]], y = .data[[m$estimate_col]])
)
if (!is.null(m$lower_col) && !is.null(m$upper_col)) {
p <- p + ggplot2::geom_ribbon(
ggplot2::aes(ymin = .data[[m$lower_col]], ymax = .data[[m$upper_col]]),
alpha = alpha
)
}
p + ggplot2::geom_line(linewidth = line_width)
}The conventions these two functions follow are the ones in the conventions table in CONTRIBUTING.md:
-
Column names are strings, passed as
x_col = "time", and stored in$metafor the plot method to read back. Never useenquo()or{ }; they make column names opaque to the caller. -
.data[[col]]does the tidy evaluation. It needs@importFrom rlang .data. -
No colors or themes are applied inside either function. The caller adds
scale_color_*(),labs()andtheme_hv_manuscript()afterwards, as the examples show. -
The plot method returns
p, notprint(p)orp + theme(...).
S3 registration comes from the plain @export tag on print.hv_bmi_curve() and plot.hv_bmi_curve(). roxygen2 recognizes the generic.class name and writes S3method(plot,hv_bmi_curve) into NAMESPACE, next to S3method(plot,hv_spaghetti). You do not need @method.
Step 3: Add a sample-data generator
Add sample_bmi_curve_data() to the top of the same file, as sample_spaghetti_data() sits at the top of R/spaghetti-plot.R. The generator should:
- Accept
n(patient count), the x range, andseed. - Return a data frame whose column names match the constructor defaults (
time,estimate,lower,upper), not the SAS column names. - Produce realistic-looking data at a plausible scale.
#' Sample BMI Curve Data
#'
#' Simulates the fitted curve output from a nonparametric BMI analysis,
#' matching the column layout expected by [hv_bmi_curve()].
#'
#' @param n Number of simulated patients (controls CI width).
#' Default `500`.
#' @param bmi_min Lower end of the BMI range. Default `18`.
#' @param bmi_max Upper end of the BMI range. Default `50`.
#' @param n_points Number of points on the prediction grid. Default `200`.
#' @param seed Random seed. Default `42`.
#'
#' @return A data frame with columns `time` (BMI grid), `estimate`,
#' `lower`, `upper`.
#'
#' @seealso [hv_bmi_curve()]
#'
#' @examples
#' dat <- sample_bmi_curve_data(n = 300)
#' head(dat)
#'
#' @importFrom stats plogis qnorm
#' @export
sample_bmi_curve_data <- function(n = 500,
bmi_min = 18,
bmi_max = 50,
n_points = 200,
seed = 42L) {
set.seed(seed)
z <- stats::qnorm(0.84) # 68 % CI is about 1 SD
bmi <- seq(bmi_min, bmi_max, length.out = n_points)
est <- stats::plogis(-2 + 0.05 * (bmi - 30))
se <- sqrt(est * (1 - est) / (n * 0.02))
data.frame(
time = bmi,
estimate = est,
lower = pmax(est - z * se, 0),
upper = pmin(est + z * se, 1)
)
}Step 4: Write roxygen documentation
Roxygen markdown is enabled in this package (Roxygen: list(markdown = TRUE) in DESCRIPTION), so backticks and [fn()] links render as written. Each exported function needs:
| Tag | Constructor |
plot method |
Notes |
|---|---|---|---|
| Description | Yes | Yes | Paragraph after the title, or @description; names the SAS template and column mapping |
@param |
Yes | Yes | One per argument; include the default |
@return |
Yes | Yes | Constructor: the class and its three slots. Method: a bare ggplot |
@seealso |
Yes | Yes | Link the pair to each other and to the sample_*() generator |
@references |
Yes | No | The exact SAS template filename(s) |
@family |
Yes | Yes | Same family name on both, so their help pages cross-link |
@examples |
Yes | Yes | Must run; use \donttest{} for slow examples |
@importFrom |
As needed | As needed | Declare every function used from other packages |
@export |
Yes | Yes | On the method it also registers the S3 method |
Then regenerate NAMESPACE and man/:
devtools::document()Check that NAMESPACE now carries export(hv_bmi_curve), export(sample_bmi_curve_data), S3method(plot,hv_bmi_curve) and S3method(print,hv_bmi_curve). Commit NAMESPACE and man/ with the source change.
Step 5: Register in _pkgdown.yml
The reference: index is explicit, and pkgdown errors on an exported topic missing from it. List the constructor, both methods and the generator, in the same order the existing sections use:
- title: "Nonparametric Covariate Curves"
desc: >
Average curves plotted against a continuous covariate (BMI, age, etc.)
rather than against time. Ports `tp.np.bmi.avrg_curv.binary.sas`.
contents:
- hv_bmi_curve
- plot.hv_bmi_curve
- print.hv_bmi_curve
- sample_bmi_curve_dataIf an existing section fits, add the four lines there instead of starting a new one.
Step 6: Add a worked example to the plot-functions vignette
Open vignettes/plot-functions.qmd and add a new top-level section before the “Draft Footnotes” section. Show both steps, so a reader sees the object before the figure:
# BMI Curve Plot
`hv_bmi_curve()` prepares a fitted nonparametric average curve of a binary
outcome against BMI; `plot()` draws it ...
::: {.cell}
```{.r .cell-code}
dat <- sample_bmi_curve_data(n = 500)
bc <- hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")
bc
plot(bc) + ggplot2::labs(x = "BMI (kg/m2)", y = "Prevalence of AF") +
theme_hv_manuscript()
```
:::Step 7: Add a row to the SAS migration guide
In vignettes/sas-migration-guide.qmd, add a row to the template lookup table. The third column names the constructor:
| `tp.np.bmi.avrg_curv.binary.sas` | np | `hv_bmi_curve()` | [BMI curve](#np-bmi) |Then add the corresponding section with a runnable example further down in the # Nonparametric temporal trends family.
Step 8: Write tests
Create tests/testthat/test_bmi_curve_plot.R. Test files use an underscore, test_*.R, and the name follows the source file.
A plot test has to prove the plot carries data. A ggplot whose every layer holds zero rows still has class "ggplot" and still renders an empty panel, so expect_s3_class(p, "ggplot") on its own is a smoke test, not coverage. tests/testthat/helper-plot-data.R provides expect_plot_has_data(), which runs ggplot2::ggplot_build() and fails when a data layer is empty. testthat loads helper-*.R files before the tests, so the helper is available without a source() call. Its arguments tighten the check:
-
min_rows: every data layer must hold at least this many rows. -
geoms: each geom class named here must appear in the plot, for example"GeomRibbon". -
min_groups: at least one data layer must split into this many groups, which catches a stratified plot that collapsed to one line.
Reference lines (GeomHline, GeomVline, GeomAbline) do not count as data layers, though each must still draw at least one row.
library(testthat)
library(hvtiPlotR)
dat <- sample_bmi_curve_data(n = 100, n_points = 50, seed = 1L)
test_that("sample_bmi_curve_data returns the constructor's default columns", {
expect_s3_class(dat, "data.frame")
expect_named(dat, c("time", "estimate", "lower", "upper"))
expect_equal(nrow(dat), 50)
})
test_that("hv_bmi_curve returns an hv_data object with the column mapping", {
bc <- hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")
expect_s3_class(bc, c("hv_bmi_curve", "hv_data"))
expect_equal(bc$meta$estimate_col, "estimate")
expect_identical(bc$tables, list())
})
test_that("hv_bmi_curve errors on a missing column", {
expect_error(hv_bmi_curve(dat, x_col = "no_such_col"), "column")
})
test_that("plot.hv_bmi_curve draws the curve from every data row", {
p <- plot(hv_bmi_curve(dat))
expect_plot_has_data(p, min_rows = nrow(dat), geoms = "GeomLine")
})
test_that("plot.hv_bmi_curve adds a ribbon carrying data when CI columns are given", {
p <- plot(hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper"))
expect_plot_has_data(p, min_rows = nrow(dat),
geoms = c("GeomRibbon", "GeomLine"))
})
test_that("print.hv_bmi_curve output is stable", {
expect_snapshot(print(hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")))
})At minimum, a new plot needs these tests:
| Test | What to check |
|---|---|
| Sample data shape | Class, column names, and number of rows |
| Constructor object |
expect_s3_class(obj, "hv_data") and the $meta entries the plot method reads |
| Error on bad input | expect_error(hv_<concept>(dat, x_col = "no_such_col")) |
| Plot carries data |
expect_plot_has_data(plot(obj), min_rows = ...); a bare class check does not count |
| Optional layers |
expect_plot_has_data(..., geoms = "GeomRibbon") for each layer an argument switches on |
The package uses testthat edition 3. The first devtools::test() run writes the expect_snapshot() baseline to tests/testthat/_snaps/bmi_curve_plot.md; commit that file with the test. When a later change alters the output on purpose, review and accept the new snapshot rather than deleting the file:
devtools::test(filter = "bmi_curve")
testthat::snapshot_review() # inspect the diff
testthat::snapshot_accept() # accept the intended changeStep 9: Update NEWS.md
Add a bullet under the # hvtiPlotR (unreleased) heading at the top of NEWS.md, adding that heading if it is not already there:
* Added `hv_bmi_curve()`, its `plot()` and `print()` methods, and
`sample_bmi_curve_data()`: a nonparametric average curve of a binary
outcome against a continuous covariate (BMI).
Ports `tp.np.bmi.avrg_curv.binary.sas`.Step 10: Final checklist before opening a PR
Run these commands in order before pushing your branch. All must complete cleanly: every test passing, and zero errors, zero warnings and zero notes from check(). Then open a pull request against main on GitHub.
devtools::document() # regenerate NAMESPACE + .Rd without errors
devtools::test() # all tests pass
lintr::lint_package() # zero lints; CI fails on any
devtools::check() # 0 errors, 0 warnings, 0 notesTrack B: Package infrastructure
Package structure overview
The tree below shows where each piece of the package lives. The two directories you will touch most are R/ (one file per plot family) and tests/testthat/ (one test file per source file). Everything under man/ and NAMESPACE is auto-generated; do not edit those by hand.
hvtiPlotR/
├── R/ # Source: one file per plot family
├── man/ # Auto-generated .Rd files — do not edit manually
├── tests/
│ └── testthat/ # One test_*.R per source file
├── vignettes/ # Quarto (.qmd) vignettes
├── inst/ # Bundled files (extdata/, incl. hv_ppt_template.pptx)
├── DESCRIPTION # Package metadata and dependency declarations
├── NAMESPACE # Auto-generated — do not edit manually
├── _pkgdown.yml # Website reference and articles layout
└── NEWS.md # User-visible changelog
NAMESPACE and man/*.Rd are both auto-generated by devtools::document() from the roxygen comments in R/*.R. Never edit these files by hand.
Adding a dependency
-
Runtime dependency (used inside a plot function): add to
ImportsinDESCRIPTIONand declare with@importFrom pkg fnin the roxygen block. -
Vignette/test only: add to
Suggests. Wrap any code that uses it inif (requireNamespace("pkg", quietly = TRUE)) { ... }or use#| eval: falsein the vignette chunk.
# Check before adding — is it already available in base R or an existing Import?
# Keep Imports lean; every new dependency adds installation weight.Code style and conventions
File and function naming
| Item | Convention | Example |
|---|---|---|
| Source files | kebab-case.R |
bmi-curve-plot.R |
| Constructors | hv_<concept>() |
hv_bmi_curve() |
| Plot methods | plot.hv_<concept>() |
plot.hv_bmi_curve() |
| Sample generators |
sample_ prefix |
sample_bmi_curve_data() |
| Internal helpers |
. prefix |
.bmi_compute_ci() |
Internal helpers (prefixed with .) should have @keywords internal and no @export, so they will not appear in NAMESPACE or generate .Rd files.
The bare-ggplot pattern
Every plot.hv_<concept>() method follows five rules:
- Draw model-output data held in the
hv_dataobject (not raw patient data). - Take every column reference from a string argument (
x_col =, etc.) stored in$meta. - Use
.data[[col]]for tidy evaluation insideaes(). - Return an unstyled ggplot: no
scale_*(), nolabs(), no theme. - Not call
print()orinvisible().
Return the bare ggplot and callers can layer scales, labels, and a theme on top with the + composition grammar covered in vignettes/plot-decorators.qmd.
Tidy evaluation
We pass column names as strings (e.g., x_col = "time") and reference them inside aes() with .data[[col]]. This keeps the caller’s interface explicit and avoids the non-standard evaluation pitfalls that come with enquo() or bare symbols. The .data pronoun must be declared in the roxygen block with @importFrom rlang .data.
# Correct — column name is a string; .data masks the data frame
ggplot2::aes(x = .data[[x_col]], y = .data[[estimate_col]])
# Wrong — bare symbol; fails when x_col is a variable
ggplot2::aes(x = x_col, y = estimate_col)
# Wrong — enquo; column name is opaque to the caller
ggplot2::aes(x = !!rlang::enquo(x_col))Always declare .data in the roxygen block:
#' @importFrom rlang .dataTesting
Test file layout
Each source file R/my-plot.R should have a corresponding tests/testthat/test_my_plot.R. The minimum set of tests for a new plot is the table in Step 8 of Track A; the one that matters most is a data-carrying assertion. expect_plot_has_data() in tests/testthat/helper-plot-data.R builds the plot with ggplot_build() and fails when a layer holds no rows, which expect_s3_class(p, "ggplot") cannot catch.
Snapshot tests
The tests/testthat/_snaps/ directory stores snapshot outputs for expect_snapshot() tests. To update snapshots after an intentional change:
testthat::snapshot_review() # review diffs interactively
testthat::snapshot_accept() # accept all pending diffsRunning checks
Use devtools::test() for fast, interactive feedback during development. Switch to devtools::check() (or the more verbose rcmdcheck::rcmdcheck()) when you want the full R CMD CHECK sweep, including example execution and vignette builds.
What R CMD CHECK validates
- All examples in
@examplesblocks run without error. - All exported functions have documentation.
-
NAMESPACEmatches the actual exports. - No undefined global variables (lintr /
R CMD CHECKNOTE). - Vignette chunks marked
eval: truerun successfully.
Vignette conventions
Vignettes live in vignettes/ as .qmd files; Quarto builds them (VignetteBuilder: quarto). A few things to keep in mind:
- All code chunks that read files, write files, or access a network must have
#| eval: false. - Use
here::here()for file paths in eval-false chunks (not hard-coded absolute paths). - Use
system.file("extdata", "hv_ppt_template.pptx", package = "hvtiPlotR")for the bundled PPT template, never a hard-coded path. - After adding a new vignette, register it in
_pkgdown.ymlunderarticles.
Releasing a new version
- Update the version in
DESCRIPTION(follow semantic versioning:MAJOR.MINOR.PATCH). - Rename the
# hvtiPlotR (unreleased)heading inNEWS.mdto# hvtiPlotR X.Y.Z. - Run
devtools::check(); you need zero errors, zero warnings, and zero notes before tagging. - Tag the release commit:
git tag -a vX.Y.Z -m "Release X.Y.Z". - Push the tag:
git push origin vX.Y.Z. - The pkgdown GitHub Action picks up the tag and rebuilds the documentation site.