R/signif_trailing.R

Defines functions within wrap seq_range signif_pvalue signif_whole count_sigfigs count_decimals signif_trailing

Documented in count_decimals count_sigfigs seq_range signif_pvalue signif_trailing signif_whole within wrap

#' Format numbers for display as character strings
#'
#' `signif_trailing()` converts numeric values to character strings to
#' preserve trailing zeroes
#'
#' @param x A numeric vector.
#' @param digits An integer specifying the number of *decimal places* or
#'   *significant figures* to preserve. Negative `digits` values will round to
#'   the nearest whole value of `10^(digits)`.
#' @param format Indicates how to treat `digits`. Either the desired number of
#'   decimal places (`format = "digits"`, the *default*) or significant figures
#'   after the decimal place (`format = "signif"`).
#' @param trim Logical; if `TRUE` (the *default*), caps `digits` at the
#'   number of decimal places or significant figures observed in `x`. If
#'   `FALSE`, uses the exact `digits` value.
#'
#' @details
#' `signif_trailing()`
#'
#' - Negative `digits` round to the respective integer place, e.g.
#'   `signif_trailing(123, digits = -1)` returns `"120"`.
#'
#' @returns `signif_trailing()` returns a character vector of formatted numbers
#'   the same length as `x`.
#'
#' @seealso [formatC()], [round()], [signif()]
#'
#' @rdname signif_trailing
#' @order 1
#' @keywords internal
signif_trailing <- function(
    x,
    digits = 2L,
    format = c("digits", "signif"),
    trim = TRUE
) {
    format <- match.arg(format)

    if (format == "digits") {
        validate_numeric(x, allow_na = TRUE)
        validate_numeric(digits, 1, c(-Inf, Inf), FALSE, TRUE)
        if (trim) {
            digits <- min(digits, count_decimals(x))
        }
        formatC_x <- round(x, digits)
        formatC_format <- "f"
    } else {
        ## if whole digits >= sig figs, return rounded whole number
        ## x & digits validated by `signif_whole`
        if (trim) {
            digits <- min(digits, count_sigfigs(x))
        }
        formatC_x <- signif_whole(x, digits)
        formatC_format <- "fg"
    }

    result <- formatC(
        x = formatC_x,
        digits = max(0, digits),
        format = formatC_format,
        flag = "#"
    )

    ## remove trailing `.` or "NA"
    return(gsub("\\.$|NA| ", "", result))
}


#' Count maximum decimal places across a numeric vector
#'
#' Returns the largest number of decimal places present in any finite,
#' non-NA element of `x`. Used internally by `signif_trailing()` for
#' `format = "digits"`.
#'
#' @param x A numeric vector.
#' @returns A single non-negative integer.
#'
#' @keywords internal
count_decimals <- function(x) {
    ## handle non-finite elements where nchar shouldn't apply
    x <- x[is.finite(x)]
    if (length(x) == 0L) {
        return(0L)
    }

    ## format with enough precision to capture true decimal places
    txt <- format(x, scientific = FALSE, trim = TRUE)
    decimals <- vapply(strsplit(txt, ".", fixed = TRUE), \(.s) {
        if (length(.s) < 2L) 0L else nchar(.s[[2L]])
    }, integer(1))

    return(max(decimals))
}


#' Count maximum significant figures across a numeric vector
#'
#' Returns the largest number of significant figures present in any finite,
#' non-NA element of `x`. Used internally by `signif_trailing()` for
#' `format = "signif"`.
#'
#' @param x A numeric vector.
#' @returns A single positive integer (minimum 1).
#'
#' @keywords internal
count_sigfigs <- function(x) {
    ## handle non-finite elements where nchar shouldn't apply
    x <- x[is.finite(x) & x != 0]
    if (length(x) == 0L) {
        return(1L)
    }

    ## format with enough precision, strip sign and leading/trailing zeros
    txt <- format(abs(x), scientific = FALSE, trim = TRUE)
    sigfigs <- vapply(txt, \(.s) {
        ## remove decimal point
        .s <- gsub(".", "", .s, fixed = TRUE)
        ## remove leading zeros
        .s <- sub("^0+", "", .s)
        ## remove trailing zeros only for values without a decimal
        ## (integers represented exactly)
        nchar(.s)
    }, integer(1), USE.NAMES = FALSE)

    return(max(sigfigs))
}


#' Round numbers to significant figures or whole value
#'
#' `signif_whole()` rounds numeric values to a specified number of significant
#' figures, or the nearest whole value if the number of digits of `x` are
#' greater than `digits`.
#'
#' @details
#' Decimal rounding is based on the "banker's rounding" default behaviour of
#'   `signif()` and `round()`, where `signif(123.45, 4)` or `round(123.45, 1)`
#'   each return `123.4`.
#'
#' `signif_whole()`
#'
#' - Negative `digits` round to the nearest whole value as if `digits = 0`,
#'   e.g. `signif_whole(123, digits = -5)` still returns `123`.
#'
#' @returns `signif_whole()` returns a numeric vector the same length as `x`.
#'
#' @rdname signif_trailing
#' @order 2
#' @keywords internal
signif_whole <- function(x, digits = 5L) {
    validate_numeric(x, allow_na = TRUE)
    validate_numeric(digits, 1, c(-Inf, Inf), FALSE, TRUE)

    ## if whole digits >= sig figs, return rounded whole number
    whole_digits <- floor(log10(abs(x))) + 1
    whole_digits[!is.finite(whole_digits)] <- 1 ## handle 0, NA, Inf
    should_round <- whole_digits >= digits

    ## vectorised ifelse evaluation
    return(ifelse(should_round, round(x), signif(x, digits)))
}


#' Format p-values for display
#'
#' `signif_pvalue()` displays p-values as either formatted numeric strings
#' or significance symbols.
#'
#' @param format Indicates how to treat `digits`. Either the desired
#'   significance criteria over which to display the absolute p value
#'   (`format = "digits"`, the *default*), or the smallest significance
#'   criteria to print as less than (`format = "signif"`).
#' @param display Specifies output type, either *"value"* (the *default*) for
#'   formatted numbers or *"symbol"* for significance symbols.
#' @param symbol Character string specifying the significance symbol.
#'   *Default* is "*".
#' @param symbol_repeat Logical indicating whether to repeat symbols for
#'   different significance levels. Default is *FALSE*.
#' @param alpha A numeric value specifying significance threshold.
#'   *Default* is `0.05`.
#'
#' @details
#' `signif_pvalue()`
#'
#' - When `format = "digits"` and e.g. `digits = 3`, `x` is rounded to 3
#'   decimal places, or shown as *"p < 0.001"* below a 3-decimal place
#'   significance threshold.
#' - `digits = 1` with `format = "digits"` displays *"p < `alpha`"*,
#'   e.g. *"p < 0.05"*.
#' - When `format = "signif"`, `digits` sets the lowest threshold
#'   (e.g. `digits = 3` gives thresholds `alpha`, `0.01`, `0.001`). Values
#'   below `alpha` show the nearest threshold above them, e.g. `p = 0.04`
#'   gives *"p < 0.05"*; `p = 0.009` gives *"p < 0.01"*.
#' - When `display = "symbol"`, if `symbol_repeat = TRUE`: Uses repeated
#'   symbols based on thresholds
#'   `(0.001 = "***", 0.01 = "**", alpha = "*", ns = "")`.
#' - If `symbol_repeat = FALSE`: Shows one symbol `"*"` for p < alpha,
#'   otherwise empty string.
#'
#' @returns `signif_pvalue()` returns a character vector of formatted p-values
#'   or significance symbols the same length as `x`.
#'
#' @rdname signif_trailing
#' @order 3
#' @keywords internal
signif_pvalue <- function(
    x,
    digits = 3L,
    format = c("digits", "threshold"),
    display = c("value", "symbol"),
    symbol = "*",
    symbol_repeat = FALSE,
    alpha = 0.05
) {
    validate_numeric(x, allow_na = TRUE)
    validate_numeric(digits, 1, c(0, Inf), FALSE, TRUE)
    format <- match.arg(format)
    display <- match.arg(display)
    validate_numeric(
        alpha, 1, c(0, 1), FALSE, 
        msg1 = "one-element", msg2 = "between {col_blue('[0, 1]')}"
    )

    x[is.infinite(x)] <- NA_real_

    if (display == "symbol" && symbol_repeat) {
        return(strrep(symbol, 3L - validate_findInt(x, c(0.001, 0.01, alpha))))
    }
    if (display == "symbol") {
        return(ifelse(x >= alpha, "", symbol))
    }

    if (format == "digits") {
        threshold <- 10^-digits
        if (digits == 1) {
            digits <- -floor(log10(alpha))
            threshold <- alpha
        }
        return(ifelse(
            x < threshold,
            sprintf("p < %.*f", digits, threshold),
            paste0("p = ", signif_trailing(x, digits, format, FALSE))
        ))
    }

    ## format = "threshold": find nearest conventional threshold above x;
    ## digits defines the lowest threshold (e.g. digits = 3 -> 0.001)
    alpha_exp <- -floor(log10(alpha))
    thresholds <- unique(c(alpha, 10^-(alpha_exp:digits)))
    nearest <- vapply(x, \(.p) {
        above <- thresholds[thresholds > .p]
        if (length(above) == 0L) NA_real_ else min(above)
    }, numeric(1))

    # compute formatted_threshold only for non-NA nearest values
    formatted_threshold <- rep(NA_character_, length(nearest))
    valid <- !is.na(nearest)
    if (any(valid)) {
        fd <- as.integer(-floor(log10(nearest[valid])))
        formatted_threshold[valid] <- sprintf("%.*f", fd, nearest[valid])
    }

    return(ifelse(
        is.na(x) | x >= alpha,
        paste0("p = ", signif_trailing(x, digits, "digits", FALSE)),
        paste0("p < ", formatted_threshold)
    ))
}


#' Generate numeric sequence from range of a vector
#'
#' Creates a numeric sequence spanning the range of input vector with either a
#' specified step size or a desired output length.
#'
#' @param x A numeric vector.
#' @param by A numeric step size for the output sequence. *Default* is `1`.
#'   Sign determines order of returned vector (negative `by` returns a
#'   descending sequence). `direction` takes precedence over `by` sign.
#' @param length.out A positive integer giving the desired length of the
#'   sequence. *Default* is `NULL`. If supplied, takes precedence over `by`.
#' @param direction Order of returned vector. Either `"up"` for ascending or
#'   `"down"` for descending. If supplied, takes precedence over the `by` sign.
#'
#' @details
#' The output vector will likely be a different length than the input `x`.
#'
#' @returns A numeric vector spanning the range of the input `x`.
#'
#' @seealso [seq()], [range()]
#'
#' @inheritParams validate_mnirs
#' @keywords internal
seq_range <- function(
    x,
    by = 1,
    length.out = NULL,
    direction = c("up", "down"),
    env = rlang::caller_env()
) {
    if (!is.null(length.out) && !missing(by)) {
        cli_inform(
            c("i" = "{.arg length.out} overrides {.arg by}."),
            call = env
        )
    }
    ## explicit `direction` wins; otherwise infer from sign of `by`
    direction <- if (missing(direction)) {
        if (by < 0) "down" else "up"
    } else {
        match.arg(direction)
    }
    x_range <- range(x, na.rm = TRUE)
    if (direction == "down") {
        x_range <- rev(x_range)
    }
    if (!is.null(length.out)) {
        return(seq(x_range[1L], x_range[2L], length.out = length.out))
    }
    by <- if (direction == "down") -abs(by) else abs(by)
    return(seq(x_range[1L], x_range[2L], by = by))
}


#' Wrap vector elements
#'
#' Rotates vector elements by moving the first `n` elements to the end.
#'
#' @param x A vector.
#' @param n An integer specifying number of elements to move from start to end.
#'   *Default* is `0` (no wrapping). Negative values move elements from end
#'   to start.
#'
#' @details
#' The function:
#' - Returns `x` unchanged if `n = 0`.
#' - Moves first `n` elements to the end of the vector.
#' - For negative `n`, effectively moves elements from end to start.
#' - If `n` is larger than `length(x)`, positions wrap around.
#'
#' @returns A vector with all the same elements as `x`.
#'
#' @keywords internal
wrap <- function(x, n = 0L) {
    validate_numeric(n, 1, integer = TRUE)

    if (length(x) == 0L) {
        return(x)
    }

    n <- n %% length(x)
    if (n == 0L) {
        return(x)
    }

    return(c(x[-(1:n)], x[1:n]))
}


#' Detect if numeric values fall within range of a vector
#'
#' Vectorised check for `x %in% vec`, inclusive or exclusive of left and right
#' boundary values, specified independently.
#'
#' @param x A numeric vector.
#' @param vec A numeric vector from which `left` and `right` boundary values
#'   for `x` will be taken.
#' @param inclusive A character vector to specify which of `left` and/or
#'   `right` boundary values should be included in the range, or both (the
#'   default), or excluded if `FALSE`.
#'
#' @details
#' `inclusive = FALSE` can be used to test for positive non-zero values:
#'   `within(x, c(0, Inf), inclusive = FALSE)`.
#'
#' @returns A logical vector the same length as `x`.
#'
#' @seealso [dplyr::between()]
#'
#' @keywords internal
within <- function(x, vec, inclusive = c("left", "right")) {
    if (!is.numeric(x)) {
        abort_validation(substitute(x))
    }
    if (!is.numeric(vec)) {
        abort_validation(substitute(vec))
    }
    inclusive <- match.arg(
        as.character(inclusive), ## force FALSE to character
        choices = c("left", "right", "FALSE"),
        several.ok = TRUE
    )

    ## extract bounds from vec
    left <- min(vec, na.rm = TRUE)
    right <- max(vec, na.rm = TRUE)

    if ("FALSE" %in% inclusive) {
        return(x > left & x < right)
    }

    left_op <- if ("left" %in% inclusive) `>=` else `>`
    right_op <- if ("right" %in% inclusive) `<=` else `<`

    return(left_op(x, left) & right_op(x, right))
}

Try the mnirs package in your browser

Any scripts or data that you put into this service are public.

mnirs documentation built on Sept. 13, 2026, 1:06 a.m.