R/char_plot_results.R

Defines functions `%||%` char_plot_all char_plot_sni char_plot_thresh_diag char_plot_raw char_plot_zones char_plot_fri char_plot_cumulative char_plot_fire_history char_plot_peaks .combine_panels .require_ggplot2 .zone_labels .zone_lines .char_xscale .title_el .title .char_theme .has_ggtext

Documented in char_plot_all char_plot_cumulative char_plot_fire_history char_plot_fri char_plot_peaks char_plot_raw char_plot_sni char_plot_thresh_diag char_plot_zones

#' CharAnalysis output figures
#'
#' Nine output figures mirroring the MATLAB CharAnalysis v2.0 plots.
#' Function names follow R snake_case conventions.
#'
#' \describe{
#'   \item{[char_plot_raw()]}{Figure 1 (allFigures only):
#'     C_raw, C_interpolated, and C_background smoothing options.}
#'   \item{[char_plot_thresh_diag()]}{Figure 2 (allFigures only):
#'     Local threshold determination diagnostics (5x5 window grid).}
#'   \item{[char_plot_peaks()]}{Figure 3: Resampled CHAR with
#'     background trend (top) and C_peak with thresholds and peak markers (bottom).}
#'   \item{[char_plot_sni()]}{Figure 4: Sensitivity to alternative
#'     thresholds and signal-to-noise index.}
#'   \item{[char_plot_cumulative()]}{Figure 5: Cumulative peaks through time.}
#'   \item{[char_plot_fri()]}{Figure 6: FRI distributions by
#'     zone with Weibull model fits.}
#'   \item{[char_plot_fire_history()]}{Figure 7: Peak magnitude (top),
#'     FRIs through time with smoothed FRI and CI ribbon (middle), and smoothed
#'     fire frequency (bottom).}
#'   \item{[char_plot_zones()]}{Figure 8: Between-zone comparisons
#'     of raw CHAR distributions (CDF and box plots).}
#'   \item{[char_plot_all()]}{Convenience wrapper: produces all figures and
#'     optionally saves them as PDF files.}
#' }
#'
#' @name char_plot
#' @aliases char_plot_raw char_plot_thresh_diag
#'   char_plot_peaks char_plot_sni
#'   char_plot_cumulative char_plot_fri
#'   char_plot_fire_history char_plot_zones char_plot_all
#'
#' @param out Named list returned by [CharAnalysis()].  Must contain
#'   \code{charcoal}, \code{pretreatment}, \code{peak_analysis},
#'   \code{char_thresh}, \code{post}, and \code{site}.
#' @param save   Logical.  If \code{TRUE}, each figure is saved as a PDF in
#'   \code{out_dir}.  Default \code{FALSE}.
#' @param out_dir Directory for saved PDFs.  Required when \code{save = TRUE};
#'   no default.  Use \code{tempdir()} for a transient location, or supply
#'   a path of your choosing.  Ignored when \code{save = FALSE}.
#' @param width,height PDF dimensions in inches.  Defaults: 11 x 8.5.
#'
#' @return
#'   Individual figure functions each return a \pkg{patchwork} / \pkg{ggplot2}
#'   object.  \code{char_plot_all()} returns a named list of all figure objects.
#'
#' @details
#'   Requires the \pkg{ggplot2} package.  Multi-panel layout uses
#'   \pkg{patchwork} if available; otherwise panels are printed separately
#'   with a message.
#'
#' @seealso [CharAnalysis()], [char_post_process()]
#'
#' @examples
#' \donttest{
#'   # Run pipeline on the bundled example dataset, then plot:
#'   params_file <- system.file("validation", "CO_charParams.csv",
#'                              package = "CharAnalysis")
#'   out <- CharAnalysis(params_file)
#'   char_plot_peaks(out)
#'   char_plot_fire_history(out)
#'   # Individual figures can also be called directly:
#'   char_plot_sni(out)
#'   char_plot_fri(out)
#'   # Save all figures to PDF in a temporary directory:
#'   char_plot_all(out, save = TRUE, out_dir = tempdir())
#' }
NULL

# Suppress R CMD check NOTEs for ggplot2 aes() column names used via NSE.
utils::globalVariables(c(
  "x", "y", "y_lo", "y_hi",           # generic aesthetics
  "accI", "bkg", "peak", "pos", "neg", # char_plot_peaks
  "age", "acc", "smooth", "method",    # char_plot_raw
  "mag",                               # char_plot_fire_history
  "ff",                                # char_plot_fire_history (fire freq)
  "lbl",                               # char_plot_fri (zone labels)
  "xpos", "lo", "hi", "mfri", "sni",  # char_plot_sni
  "zone"                               # char_plot_zones
))

# =============================================================================
# Internal helpers
# =============================================================================

# Use ggtext::element_markdown() for titles when available so that
# HTML <sub>...</sub> tags render as proper subscripts.  Falls back to
# plain element_text() if ggtext is not installed (tags are stripped first).
.has_ggtext <- function() requireNamespace("ggtext", quietly = TRUE)

.char_theme <- function(base_size = 10) {
  title_el <- if (.has_ggtext()) {
    ggtext::element_markdown(size = base_size, face = "bold")
  } else {
    ggplot2::element_text(size = base_size, face = "bold")
  }
  # Axis-title element: use element_markdown() when ggtext is available so
  # that HTML <sup>/<sub> tags render as proper super/subscripts.  Plain text
  # passes through unchanged, so this is safe for all axis labels.
  axis_title_el <- if (.has_ggtext()) {
    ggtext::element_markdown(size = base_size)
  } else {
    ggplot2::element_text(size = base_size)
  }
  ggplot2::theme_classic(base_size = base_size) +
  ggplot2::theme(
    axis.line         = ggplot2::element_line(colour = "black"),
    panel.grid        = ggplot2::element_blank(),
    plot.title        = title_el,
    axis.title.x      = axis_title_el,
    axis.title.y      = axis_title_el,
    axis.ticks        = ggplot2::element_line(colour = "black"),
    axis.ticks.length = ggplot2::unit(3, "pt")
  )
}

# Format a title string: keep HTML when ggtext is available, strip tags otherwise.
.title <- function(html) {
  if (.has_ggtext()) html else gsub("<[^>]+>", "", html)
}

# Return a title theme element of the correct class (must match .char_theme).
# Always call this instead of element_text() when overriding plot.title so
# that patchwork can merge elements across panels without a class conflict.
.title_el <- function(size = 10, face = "bold", hjust = 0) {
  if (.has_ggtext()) {
    ggtext::element_markdown(size = size, face = face, hjust = hjust)
  } else {
    ggplot2::element_text(size = size, face = face, hjust = hjust)
  }
}

.char_xscale <- function(zone_div) {
  x_max   <- max(zone_div)
  x_break <- seq(0, x_max, by = 1000)
  x_label <- x_break / 1000
  list(
    ggplot2::scale_x_reverse(
      limits = c(x_max, zone_div[1L]),
      breaks = x_break,
      labels = x_label
    ),
    ggplot2::xlab("cal. yr BP (x 1000)")
  )
}

.zone_lines <- function(zone_div, y_lo, y_hi) {
  # Vertical dashed lines at interior zone boundaries
  inner <- zone_div[-c(1L, length(zone_div))]
  if (length(inner) == 0L) return(NULL)
  lapply(inner, function(z) {
    ggplot2::annotate("segment",
                      x = z, xend = z,
                      y = y_lo, yend = y_hi,
                      colour = "grey50", linewidth = 0.8, linetype = "dashed")
  })
}

.zone_labels <- function(zone_div, y_pos, base_size = 10) {
  # Text labels centred in each zone (e.g. "Zone 1", "Zone 2")
  n_zones <- length(zone_div) - 1L
  if (n_zones <= 1L) return(NULL)
  mids   <- (zone_div[-length(zone_div)] + zone_div[-1L]) / 2
  labels <- paste0("Zone ", seq_len(n_zones))
  lapply(seq_len(n_zones), function(z) {
    ggplot2::annotate("text",
                      x = mids[z], y = y_pos,
                      label = labels[z],
                      size  = base_size / ggplot2::.pt,
                      hjust = 0.5)
  })
}

.require_ggplot2 <- function() {
  if (!requireNamespace("ggplot2", quietly = TRUE))
    stop("Package 'ggplot2' is required: install.packages('ggplot2')")
}

.combine_panels <- function(panels, ncol = 1L, heights = NULL) {
  if (requireNamespace("patchwork", quietly = TRUE)) {
    p <- Reduce(`/`, panels)
    if (!is.null(heights))
      p <- p + patchwork::plot_layout(heights = heights)
    return(p)
  }
  # Fallback: print each panel in sequence
  message("Install 'patchwork' for combined multi-panel layout: ",
          "install.packages('patchwork')")
  invisible(panels[[length(panels)]])
}

# =============================================================================
# char_plot_peaks  --  Figure 3: C_interp / C_background / C_peak
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_peaks <- function(out) {

  .require_ggplot2()

  charcoal     <- out$charcoal
  pretreatment <- out$pretreatment
  peak_analysis <- out$peak_analysis
  char_thresh  <- out$char_thresh
  post         <- out$post
  site         <- out$site

  zone_div  <- pretreatment$zoneDiv
  yr_interp <- pretreatment$yrInterp
  x         <- charcoal$ybpI
  r         <- yr_interp

  # ---------- Panel (a): C_interpolated and C_background -------------------
  df_a <- data.frame(
    x    = x,
    accI = charcoal$accI,
    bkg  = charcoal$accIS
  )

  y_max_a <- max(df_a$accI, na.rm = TRUE)
  y_lim_a <- c(0, 1.1 * y_max_a)

  y_label_a <- if (!is.null(pretreatment$transform) &&
                   pretreatment$transform > 0L) {
    if (.has_ggtext()) {
      "C<sub>interp</sub> (transformed pieces cm<sup>-2</sup> yr<sup>-1</sup>)"
    } else {
      "C_interp (transformed pieces cm^-2 yr^-1)"
    }
  } else {
    if (.has_ggtext()) {
      "C<sub>interp</sub> (pieces cm<sup>-2</sup> yr<sup>-1</sup>)"
    } else {
      "C_interp (pieces cm^-2 yr^-1)"
    }
  }

  # Smoothing window label (yr): take last element of smoothing$yr vector
  smooth_yr <- out$smoothing$yr
  smooth_yr_label <- if (!is.null(smooth_yr)) {
    paste0(smooth_yr[length(smooth_yr)], "-yr")
  } else {
    "low-frequency"
  }

  pa <- ggplot2::ggplot(df_a) +
    ggplot2::geom_col(ggplot2::aes(x = x, y = accI),
                      fill = "black", colour = "black", width = r) +
    ggplot2::geom_line(ggplot2::aes(x = x, y = bkg),
                       colour = "grey50", linewidth = 1.2) +
    .zone_lines(zone_div, y_max_a * 1.01, y_max_a * 1.09) +
    .zone_labels(zone_div, y_max_a * 1.05) +
    .char_xscale(zone_div) +
    ggplot2::scale_y_continuous(limits = y_lim_a, expand = c(0, 0)) +
    ggplot2::ylab(y_label_a) +
    ggplot2::labs(
      title = .title(paste0(
        "(a) ", site,
        ": C<sub>interpolated</sub> (", yr_interp, " yr) and",
        " C<sub>background</sub> defined by ", smooth_yr_label, "-yr trends"))
    ) +
    ggplot2::theme(axis.title.x = ggplot2::element_blank(),
                   axis.text.x  = ggplot2::element_blank(),
                   axis.ticks.x = ggplot2::element_blank()) +
    .char_theme()

  # ---------- Panel (b): C_peak and thresholds ------------------------------
  df_b <- data.frame(
    x     = x,
    peak  = charcoal$peak,
    pos   = char_thresh$pos[, ncol(char_thresh$pos)],
    neg   = char_thresh$neg[, ncol(char_thresh$neg)]
  )

  base_val <- if (!is.null(peak_analysis$cPeak) &&
                  peak_analysis$cPeak == 2L) 1 else 0

  peak_rows   <- x[post$peakIn]
  screen_rows <- x[post$peakScreenIn]
  y_max_b <- max(df_b$peak, na.rm = TRUE)
  y_min_b <- min(df_b$neg,  na.rm = TRUE)

  y_label_b <- if (!is.null(peak_analysis$cPeak) &&
                   peak_analysis$cPeak == 2L) {
    if (.has_ggtext()) {
      "C<sub>peak</sub> (C<sub>interp</sub> / C<sub>bkg</sub>)"
    } else {
      "C_peak (C_interp / C_bkg)"
    }
  } else {
    if (.has_ggtext()) {
      "C<sub>peak</sub> (pieces cm<sup>-2</sup> yr<sup>-1</sup>)"
    } else {
      "C_peak (pieces cm^-2 yr^-1)"
    }
  }

  pb <- ggplot2::ggplot(df_b) +
    ggplot2::geom_col(ggplot2::aes(x = x, y = peak),
                      fill = "black", colour = "black", width = r) +
    ggplot2::geom_line(ggplot2::aes(x = x, y = pos), colour = "red") +
    ggplot2::geom_line(ggplot2::aes(x = x, y = neg), colour = "red") +
    ggplot2::geom_hline(yintercept = base_val, colour = "black", linewidth = 0.4)

  if (length(screen_rows) > 0L) {
    df_screen <- data.frame(x = screen_rows,
                             y = rep(0.8 * y_max_b, length(screen_rows)))
    pb <- pb + ggplot2::geom_point(data = df_screen,
                                    ggplot2::aes(x = x, y = y),
                                    colour = "grey60", size = 1.5, shape = 16)
  }
  if (length(peak_rows) > 0L) {
    df_peaks <- data.frame(x = peak_rows,
                            y = rep(0.8 * y_max_b, length(peak_rows)))
    pb <- pb + ggplot2::geom_point(data = df_peaks,
                                    ggplot2::aes(x = x, y = y),
                                    colour = "black", size = 2, shape = 3,
                                    stroke = 1)
  }

  peak_type <- if (!is.null(peak_analysis$cPeak) &&
                   peak_analysis$cPeak == 2L) "ratio" else "residual"
  # Two-line title matching MATLAB: line break after "peaks"
  cpeak_label <- if (.has_ggtext()) {
    if (peak_type == "ratio") {
      .title("(b) C<sub>peak</sub> (C<sub>interpolated</sub> / C<sub>background</sub>), thresholds defining C<sub>noise</sub>, and peaks<br>identified (gray peaks fail to pass peak-magnitude test)")
    } else {
      .title("(b) C<sub>peak</sub> (C<sub>interpolated</sub> - C<sub>background</sub>), thresholds defining C<sub>noise</sub>, and peaks<br>identified (gray dots fail to pass peak-magnitude test)")
    }
  } else {
    if (peak_type == "ratio") {
      "(b) C_peak (C_interpolated / C_background), thresholds defining C_noise, and peaks\nidentified (gray peaks fail to pass peak-magnitude test)"
    } else {
      "(b) C_peak (C_interpolated - C_background), thresholds defining C_noise, and peaks\nidentified (gray dots fail to pass peak-magnitude test)"
    }
  }

  pb <- pb +
    .zone_lines(zone_div, y_min_b, y_max_b * 1.0) +
    .char_xscale(zone_div) +
    ggplot2::scale_y_continuous(limits = c(y_min_b, 1.1 * y_max_b),
                                 expand = c(0, 0)) +
    ggplot2::ylab(y_label_b) +
    ggplot2::labs(title = cpeak_label) +
    .char_theme()

  .combine_panels(list(pa, pb), heights = c(1.2, 1))
}

# =============================================================================
# char_plot_fire_history  --  Figure 7: peak magnitude / FRIs / fire frequency
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_fire_history <- function(out) {

  .require_ggplot2()

  charcoal      <- out$charcoal
  pretreatment  <- out$pretreatment
  peak_analysis <- out$peak_analysis
  post          <- out$post
  site          <- out$site

  zone_div  <- pretreatment$zoneDiv
  yr_interp <- pretreatment$yrInterp
  x         <- charcoal$ybpI
  r         <- yr_interp

  peak_rows   <- x[post$peakIn]
  screen_rows <- x[post$peakScreenIn]

  # ---------- Panel 1: Peak magnitude ---------------------------------------
  df_mag <- data.frame(x   = x,
                        mag = charcoal$peakMagnitude)
  y_max_mag <- max(df_mag$mag, na.rm = TRUE)
  if (y_max_mag == 0) y_max_mag <- 1  # avoid degenerate limits

  p1 <- ggplot2::ggplot(df_mag) +
    ggplot2::geom_col(ggplot2::aes(x = x, y = mag),
                      fill = "black", colour = "black", width = r)

  if (length(screen_rows) > 0L) {
    df_s <- data.frame(x = screen_rows,
                        y = rep(0.8 * y_max_mag, length(screen_rows)))
    p1 <- p1 + ggplot2::geom_point(data = df_s, ggplot2::aes(x = x, y = y),
                                    colour = "grey60", size = 1.5, shape = 16)
  }
  if (length(peak_rows) > 0L) {
    df_pk <- data.frame(x = peak_rows,
                         y = rep(0.8 * y_max_mag, length(peak_rows)))
    p1 <- p1 + ggplot2::geom_point(data = df_pk, ggplot2::aes(x = x, y = y),
                                    colour = "red", size = 2, shape = 3,
                                    stroke = 1.2)
  }

  p1 <- p1 +
    .zone_lines(zone_div, y_max_mag * 0.90, y_max_mag * 1.0) +
    .zone_labels(zone_div, y_max_mag * 0.95) +
    .char_xscale(zone_div) +
    ggplot2::scale_y_continuous(limits = c(0, 1.1 * y_max_mag), expand = c(0, 0)) +
    ggplot2::ylab(
      if (.has_ggtext()) {
        "peak mag. (pieces cm<sup>-2</sup> peak<sup>-1</sup>)"
      } else {
        "peak mag. (pieces cm^-2 peak^-1)"
      }) +
    ggplot2::labs(title = .title(paste0(
        "Peak magnitude, FRIs, and fire frequ.\n\n", site))) +
    ggplot2::theme(axis.title.x = ggplot2::element_blank(),
                   axis.text.x  = ggplot2::element_blank(),
                   axis.ticks.x = ggplot2::element_blank()) +
    .char_theme()

  # ---------- Panel 2: FRIs through time + smoothed FRI ---------------------
  FRIyr  <- post$FRIyr
  FRI    <- post$FRI
  smFRIyr <- post$smFRIyr
  smFRI   <- post$smFRI
  smFRIci <- post$smFRIci   # [upper, lower] columns (MATLAB convention: [2.5%, 97.5%])
  alpha   <- post$alpha %||% 0.05

  has_fri <- !is.null(FRIyr) && length(FRIyr) > 1L &&
             !all(is.na(FRIyr)) && length(smFRI) > 2L

  if (has_fri) {
    y_max_fri <- 1.1 * max(FRI, na.rm = TRUE)
    df_fri  <- data.frame(x = FRIyr, y = FRI)
    df_sm   <- data.frame(x = smFRIyr, y = smFRI)
    # CI: MATLAB smFRIci column 1 = 2.5th quantile (lower), column 2 = 97.5th (upper)
    df_ci   <- data.frame(x    = smFRIyr,
                           y_lo = smFRIci[, 1L],
                           y_hi = smFRIci[, 2L])

    p2 <- ggplot2::ggplot() +
      ggplot2::geom_ribbon(data = df_ci,
                            ggplot2::aes(x = x, ymin = y_lo, ymax = y_hi),
                            fill = "grey80", alpha = 0.8) +
      ggplot2::geom_point(data = df_fri, ggplot2::aes(x = x, y = y),
                           shape = 22, fill = "grey75", colour = "black",
                           size  = 2) +
      ggplot2::geom_line(data = df_sm, ggplot2::aes(x = x, y = y),
                          colour = "black", linewidth = 1) +
      .zone_lines(zone_div, 0, y_max_fri) +
      .char_xscale(zone_div) +
      ggplot2::scale_y_continuous(limits = c(0, y_max_fri), expand = c(0, 0)) +
      ggplot2::ylab(
        if (.has_ggtext()) {
          paste0("FRI (yr fire<sup>-1</sup>)<br>",
                 peak_analysis$peakFrequ, "-yr mean<br>",
                 round((1 - alpha) * 100), "% CI")
        } else {
          paste0("FRI (yr fire^-1)\n",
                 peak_analysis$peakFrequ, "-yr mean\n",
                 round((1 - alpha) * 100), "% CI")
        }) +
      ggplot2::theme(axis.title.x = ggplot2::element_blank(),
                     axis.text.x  = ggplot2::element_blank(),
                     axis.ticks.x = ggplot2::element_blank()) +
      .char_theme()
  } else {
    p2 <- ggplot2::ggplot() +
      ggplot2::annotate("text", x = 0.5, y = 0.5,
                         label = "Fewer than 3 FRIs - FRI plot not available",
                         hjust = 0.5) +
      ggplot2::theme_void()
  }

  # ---------- Panel 3: Smoothed fire frequency ------------------------------
  df_ff <- data.frame(x  = x,
                       ff = charcoal$smoothedFireFrequ)
  y_max_ff <- max(1.1 * max(df_ff$ff, na.rm = TRUE), .Machine$double.eps)

  p3 <- ggplot2::ggplot(df_ff) +
    ggplot2::geom_line(ggplot2::aes(x = x, y = ff),
                        colour = "black", linewidth = 0.8) +
    .zone_lines(zone_div, 0, y_max_ff) +
    .char_xscale(zone_div) +
    ggplot2::scale_y_continuous(limits = c(0, y_max_ff), expand = c(0, 0)) +
    ggplot2::ylab(
      if (.has_ggtext()) {
        paste0("fire freq.<br>(fires ",
               peak_analysis$peakFrequ, " yr<sup>-1</sup>)")
      } else {
        paste0("fire freq.\n(fires ",
               peak_analysis$peakFrequ, " yr^-1)")
      }) +
    .char_theme()

  .combine_panels(list(p1, p2, p3), heights = c(1.2, 1.2, 1))
}

# =============================================================================
# char_plot_cumulative  --  Figure 5: cumulative peaks through time
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_cumulative <- function(out) {

  .require_ggplot2()

  charcoal     <- out$charcoal
  pretreatment <- out$pretreatment
  post         <- out$post
  site         <- out$site

  zone_div <- pretreatment$zoneDiv
  x_all    <- charcoal$ybpI
  peaks_col <- post$CharcoalCharPeaks[, ncol(post$CharcoalCharPeaks)]

  peak_idx <- which(peaks_col > 0)

  if (length(peak_idx) == 0L) {
    message("char_plot_cumulative: no peaks to plot.")
    return(invisible(NULL))
  }

  # Cumulative count ordered young-to-old (x reversed), so the youngest
  # peak gets the highest cumulative number -- matching MATLAB's flipud(cumsum).
  x_plot <- x_all[peak_idx]
  y_plot <- rev(seq_along(peak_idx))   # equals flipud(cumsum(1:n))

  df <- data.frame(x = x_plot, y = y_plot)

  y_max <- max(y_plot)

  ggplot2::ggplot(df, ggplot2::aes(x = x, y = y)) +
    ggplot2::geom_point(colour = "black", size = 1.5) +
    .zone_lines(zone_div, 0, y_max) +
    .char_xscale(zone_div) +
    ggplot2::scale_y_continuous(limits = c(0, y_max * 1.05), expand = c(0, 0)) +
    ggplot2::ylab("cumulative number of peaks") +
    ggplot2::labs(
      title = .title(paste0(site, ": Cumulative fires as a function of time"))
    ) +
    .char_theme() +
    ggplot2::theme(panel.grid.major = ggplot2::element_line(colour = "grey90",
                                                             linewidth = 0.4))
}

# =============================================================================
# char_plot_fri  --  Figure 6: FRI histograms with Weibull fits by zone
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_fri <- function(out) {

  .require_ggplot2()

  pretreatment <- out$pretreatment
  post         <- out$post
  site         <- out$site

  zone_div <- pretreatment$zoneDiv
  n_zones  <- length(zone_div) - 1L
  zones    <- post$zone   # list of per-zone results from char_post_process

  zone_labels <- paste0("Zone ", seq_len(n_zones))

  # KS pass criterion: p > 0.10 if n < 30, p > 0.05 if n >= 30
  .ks_pass <- function(p, n) {
    if (is.na(p) || is.null(p)) return(FALSE)
    if (n < 30L) p > 0.10 else p > 0.05
  }

  x_lim <- c(0, 800)

  panel_list <- vector("list", n_zones)

  for (z in seq_len(n_zones)) {
    zd <- zones[[z]]

    # ---- Empty / insufficient data ----------------------------------------
    if (is.null(zd) || length(zd$FRI) <= 1L) {
      panel_list[[z]] <- ggplot2::ggplot() +
        ggplot2::annotate("text", x = 0.5, y = 0.5,
                           label = "insufficient data",
                           hjust = 0.5, vjust = 0.5) +
        ggplot2::labs(title = zone_labels[z]) +
        ggplot2::theme_void() +
        ggplot2::theme(plot.title = .title_el(face = "bold", hjust = 0.5))
      next
    }

    fri_z    <- zd$FRI
    bin_c    <- zd$bin_centers               # 30, 50, ..., 990
    freq     <- zd$freq                      # counts per bin (length 49)
    prop     <- freq / sum(freq)             # normalise to proportions
    p_ks     <- zd$p_ks
    n_fri    <- length(fri_z)
    m_fri    <- zd$mean_mFRI
    m_ci     <- zd$mean_mFRI_ci              # [2.5%, 97.5%]
    wbl_b    <- zd$wbl_scale                 # scale
    wbl_c    <- zd$wbl_shape                 # shape
    wbl_b_ci <- zd$wbl_scale_ci             # [2.5%, 97.5%]
    wbl_c_ci <- zd$wbl_shape_ci

    df_hist <- data.frame(x = bin_c, prop = prop)

    p <- ggplot2::ggplot(df_hist) +
      ggplot2::geom_col(ggplot2::aes(x = x, y = prop),
                         fill = "grey75", colour = "grey50", width = 20)

    # ---- Weibull overlay (when fit converged and n > 4) --------------------
    # The KS goodness-of-fit test is computed and shown, but the Weibull curve
    # is displayed whenever the model converged (n > 4 and wbl parameters are
    # available).  MATLAB gates on passKS; here we always draw the curve and
    # report the KS p-value in the annotation so the user has full information.
    # Rationale: floating-point differences in the GMM between R and MATLAB
    # can shift the local threshold slightly, changing the peak count and
    # therefore the KS p-value for borderline datasets (e.g. CH10).  Hiding
    # the Weibull in those cases obscures a legitimate and visually good fit.
    pass_ks     <- n_fri > 4L && .ks_pass(p_ks, n_fri)
    show_weibull <- n_fri > 4L && !is.null(wbl_b) && !is.null(wbl_c)
    annot_lines <- character(0L)

    if (show_weibull) {
      wbl_x   <- seq(1, 1000, by = 1)
      wbl_pdf <- stats::dweibull(wbl_x, shape = wbl_c, scale = wbl_b)
      # Scale PDF to match histogram proportions (bin width 20)
      wbl_pdf_scaled <- wbl_pdf * 20
      df_wbl <- data.frame(x = wbl_x, y = wbl_pdf_scaled)
      p <- p + ggplot2::geom_line(data = df_wbl,
                                    ggplot2::aes(x = x, y = y),
                                    colour = "black", linewidth = 1)

      # KS p-value line: show passing result normally; flag a borderline/failing
      # result with "(n.s.)" so the user can see the statistical quality.
      ks_label <- if (is.na(p_ks)) {
        "KS p = NA"
      } else if (pass_ks) {
        sprintf("KS p = %.3f", p_ks)
      } else {
        sprintf("KS p = %.3f (n.s.)", p_ks)
      }

      annot_lines <- c(
        sprintf("Wbl *b* = %d (%d-%d)",
                round(wbl_b), round(wbl_b_ci[1L]), round(wbl_b_ci[2L])),
        sprintf("Wbl *c* = %.2f (%.2f-%.2f)",
                wbl_c, wbl_c_ci[1L], wbl_c_ci[2L]),
        sprintf("mFRI = %d (%d-%d)",
                round(m_fri), round(m_ci[1L]), round(m_ci[2L])),
        sprintf("N = %d", n_fri),
        ks_label
      )
    } else {
      annot_lines <- sprintf("N = %d", n_fri)
    }

    # ---- Text annotation --------------------------------------------------
    # geom_richtext renders HTML: use <br> for line breaks.
    # annotate("text") renders plain text: use \n for line breaks.
    y_max_hist <- max(prop, na.rm = TRUE)

    if (.has_ggtext()) {
      annot_html <- paste(annot_lines, collapse = "<br>")
      # annot_html must live in the data frame, NOT referenced via aes().
      # If it is referenced by name in aes(), ggplot2 lazy-evaluates it and
      # picks up whatever value the variable holds when the panel is finally
      # rendered (after the loop finishes) -- i.e. always the last zone.
      # Putting it in the data frame captures the value immediately.
      p <- p + ggtext::geom_richtext(
        data  = data.frame(x = x_lim[2L], y = y_max_hist, lbl = annot_html),
        ggplot2::aes(x = x, y = y, label = lbl),
        hjust = 1, vjust = 1, size = 2.8,
        fill  = NA, label.colour = NA, lineheight = 1.3
      )
    } else {
      annot_plain <- paste(gsub("\\*", "", annot_lines), collapse = "\n")
      p <- p + ggplot2::annotate(
        "text",
        x = x_lim[2L], y = y_max_hist,
        label = annot_plain,
        hjust = 1, vjust = 1, size = 2.8, lineheight = 1.3
      )
    }

    # ---- Axis and theme ---------------------------------------------------
    p <- p +
      ggplot2::scale_x_continuous(limits = x_lim,
                                   breaks = seq(0, x_lim[2L], by = 200)) +
      ggplot2::scale_y_continuous(limits = c(0, max(y_max_hist * 1.3, 0.35)),
                                   expand = c(0, 0)) +
      ggplot2::xlab("FRI (yr)") +
      ggplot2::ylab(if (z == n_zones) "proportion" else NULL) +
      ggplot2::labs(title = zone_labels[z]) +
      .char_theme() +
      ggplot2::theme(plot.title = .title_el(face = "bold", hjust = 0.5))

    panel_list[[z]] <- p
  }

  # Combine panels left-to-right (patchwork), or print individually.
  # Panels are reversed so Zone 1 is always on the far right, matching
  # MATLAB's inPlot = fliplr(1:nZones) subplot ordering.
  if (requireNamespace("patchwork", quietly = TRUE)) {
    combined <- Reduce(`+`, rev(panel_list)) +
      patchwork::plot_annotation(
        title = .title(paste0(site,
                               ": FRI distributions by zone with Weibull models")),
        theme = ggplot2::theme(plot.title = .title_el(face = "bold"))
      ) +
      patchwork::plot_layout(nrow = 1L)
    return(combined)
  }

  message("Install 'patchwork' for combined multi-panel layout.")
  invisible(panel_list[[n_zones]])
}

# =============================================================================
# char_plot_zones  --  Figure 8: between-zone raw CHAR comparisons
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_zones <- function(out) {

  .require_ggplot2()

  charcoal     <- out$charcoal
  pretreatment <- out$pretreatment
  site         <- out$site

  zone_div    <- pretreatment$zoneDiv
  n_zones     <- length(zone_div) - 1L
  zone_labels <- paste0("Zone ", seq_len(n_zones))

  if (is.null(charcoal$acc) || is.null(charcoal$ybp)) {
    message("char_plot_zones: raw CHAR (charcoal$acc / charcoal$ybp) not available.")
    return(invisible(NULL))
  }

  # Assign each raw sample to a zone
  zone_id <- cut(charcoal$ybp,
                  breaks = zone_div,
                  labels = zone_labels,
                  include.lowest = TRUE, right = FALSE)
  df_raw <- data.frame(
    acc  = charcoal$acc,
    ybp  = charcoal$ybp,
    zone = zone_id
  )
  df_raw <- df_raw[!is.na(df_raw$zone), ]

  # Zone colours: black + grey scale cycling for additional zones
  zone_cols <- c("black", "grey50", "grey70",
                  "#1b7837", "#762a83", "#d6604d",
                  "#f46d43", "#006837")[seq_len(n_zones)]

  # ---- Left panel: empirical CDFs ------------------------------------------
  x_max_cdf <- max(df_raw$acc, na.rm = TRUE)

  p_cdf <- ggplot2::ggplot(df_raw,
                             ggplot2::aes(x = acc, colour = zone,
                                          group = zone)) +
    ggplot2::stat_ecdf(linewidth = 1) +
    ggplot2::scale_colour_manual(values = zone_cols,
                                  name   = NULL,
                                  labels = zone_labels) +
    ggplot2::scale_x_continuous(limits = c(0, x_max_cdf), expand = c(0, 0)) +
    ggplot2::xlab(
      if (.has_ggtext()) {
        "CHAR (pieces cm<sup>-2</sup> yr<sup>-1</sup>)"
      } else {
        "CHAR (pieces cm^-2 yr^-1)"
      }) +
    ggplot2::ylab("cumulative proportion") +
    ggplot2::labs(title = .title("CDFs of zone-specific raw CHAR, with KS test results")) +
    .char_theme() +
    ggplot2::theme(legend.position        = c(0.85, 0.15),
                   legend.justification   = c("right", "bottom"),
                   legend.background      = ggplot2::element_rect(fill  = "white",
                                                                    colour = "grey80",
                                                                    linewidth = 0.3))

  # ---- KS test table (annotated on CDF plot) --------------------------------
  if (n_zones > 1L) {
    zone_levels <- levels(df_raw$zone)
    ks_mat <- matrix(NA_real_, n_zones, n_zones,
                      dimnames = list(zone_levels, zone_levels))
    for (i in seq_len(n_zones - 1L)) {
      for (j in (i + 1L):n_zones) {
        xi <- df_raw$acc[df_raw$zone == zone_levels[i]]
        xj <- df_raw$acc[df_raw$zone == zone_levels[j]]
        if (length(xi) > 1L && length(xj) > 1L) {
          res <- suppressWarnings(stats::ks.test(xi, xj))
          ks_mat[i, j] <- round(res$p.value, 3L)
        }
      }
    }
    # Build annotation string
    ks_lines <- "KS p-values:"
    for (i in seq_len(n_zones - 1L)) {
      for (j in (i + 1L):n_zones) {
        if (!is.na(ks_mat[i, j])) {
          ks_lines <- c(ks_lines,
                         sprintf("  %s vs %s: p = %.3f",
                                 zone_levels[i], zone_levels[j],
                                 ks_mat[i, j]))
        }
      }
    }
    p_cdf <- p_cdf +
      ggplot2::annotate("text",
                         x    = max(df_raw$acc, na.rm = TRUE) * 0.5,
                         y    = 0.45,
                         label = paste(ks_lines, collapse = "\n"),
                         hjust = 0, vjust = 1, size = 3)
  }

  # ---- Right panel: box plots ----------------------------------------------
  p_box <- ggplot2::ggplot(df_raw,
                             ggplot2::aes(x = zone, y = acc,
                                          fill = zone, colour = zone)) +
    ggplot2::geom_boxplot(
      outlier.shape  = 16, outlier.size = 1,
      coef           = 1.5   # standard whiskers at 1.5 * IQR
    ) +
    ggplot2::scale_fill_manual(
      values = ggplot2::alpha(zone_cols, 0.4),
      guide  = "none") +
    ggplot2::scale_colour_manual(values = zone_cols, guide = "none") +
    ggplot2::xlab("zone") +
    ggplot2::ylab(
      if (.has_ggtext()) {
        "CHAR (pieces cm<sup>-2</sup> yr<sup>-1</sup>)"
      } else {
        "CHAR (pieces cm^-2 yr^-1)"
      }) +
    ggplot2::labs(title = .title("Box plots of raw CHAR per zone")) +
    .char_theme()

  if (requireNamespace("patchwork", quietly = TRUE)) {
    combined <- (p_cdf | p_box) +
      patchwork::plot_annotation(
        title = .title(paste0(site,
                               ": Between-zone comparisons of raw CHAR distributions")),
        theme = ggplot2::theme(plot.title = .title_el(face = "bold"))
      ) +
      patchwork::plot_layout(widths = c(2, 1))
    return(combined)
  }

  message("Install 'patchwork' for combined multi-panel layout.")
  print(p_cdf)
  invisible(p_box)
}

# =============================================================================
# char_plot_raw  --  Figure 1: C_raw / C_resampled / C_background options
# Only produced when out$results$allFigures == 1.
# Mirrors MATLAB CharPretreatment.m (subplot 1) + CharSmooth.m (subplot 2).
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_raw <- function(out) {

  .require_ggplot2()

  charcoal  <- out$charcoal
  smoothing <- out$smoothing
  pretreat  <- out$pretreatment
  site      <- out$site %||% ""
  yr_interp <- pretreat$yrInterp
  has_gt    <- .has_ggtext()

  # Helper: subscript label, rendered in HTML when ggtext is available
  sub_lbl <- function(base, sub)
    if (has_gt) paste0(base, "<sub>", sub, "</sub>") else paste0(base, "_", sub)
  # Helper: axis title element that renders markdown/HTML
  md_axis <- function()
    if (has_gt) ggtext::element_markdown() else ggplot2::element_text()

  char_lbl <- sub_lbl("CHAR (# cm", "-2")
  char_lbl <- if (has_gt)
    "CHAR (# cm<sup>-2</sup> yr<sup>-1</sup>)" else "CHAR (# cm^-2 yr^-1)"

  x_lim <- c(max(charcoal$ybp, na.rm = TRUE) + 100,
              min(charcoal$ybp, na.rm = TRUE))  # reversed (old -> young right)

  # Legend labels
  lbl_raw  <- sub_lbl("C", "raw")
  lbl_int  <- paste0(sub_lbl("C", "interpolated"), ": ", yr_interp, " yr")

  # -- Subplot 1: C_raw (bars) + C_interpolated (step) + legend --------------
  df_raw <- data.frame(age = charcoal$ybp, acc = charcoal$acc)
  df_int <- data.frame(age = charcoal$ybpI - 0.5 * yr_interp,
                       acc = charcoal$accI)

  p1 <- ggplot2::ggplot() +
    ggplot2::geom_bar(data = df_raw,
                      ggplot2::aes(x = age, y = acc, fill = "raw"),
                      stat = "identity",
                      width = diff(range(charcoal$ybp)) / length(charcoal$ybp),
                      colour = NA) +
    ggplot2::geom_step(data = df_int,
                       ggplot2::aes(x = age, y = acc, colour = "int"),
                       linewidth = 0.8) +
    ggplot2::scale_fill_manual(
      name   = NULL,
      values = c("raw" = "grey50"),
      labels = c("raw" = lbl_raw),
      guide  = ggplot2::guide_legend(
        override.aes = list(colour = NA, fill = "grey50",
                            linetype = 0, size = 4))
    ) +
    ggplot2::scale_colour_manual(
      name   = NULL,
      values = c("int" = "black"),
      labels = c("int" = lbl_int),
      guide  = ggplot2::guide_legend(
        override.aes = list(fill = NA, linetype = 1, linewidth = 0.8))
    ) +
    ggplot2::scale_x_reverse(limits = x_lim) +
    ggplot2::coord_cartesian(
      ylim = c(0, stats::quantile(charcoal$acc, 0.99, na.rm = TRUE))) +
    .char_theme() +
    ggplot2::theme(
      axis.title.y = md_axis(),
      legend.text  = md_axis()
    ) +
    ggplot2::labs(
      title = .title(paste0(site, " (a) ", sub_lbl("C", "raw"), " and ",
                            sub_lbl("C", "interpolated"), ": ", yr_interp, " yr")),
      x = "time (cal. yr BP)",
      y = char_lbl
    )

  # -- Subplot 2: C_interpolated (bars) + all 5 smoothing curves -------------
  smooth_names <- c("Lowess", "Robust Lowess",
                    "Moving Average", "Moving Median", "Moving Mode")
  all_curves   <- charcoal$accIS_all   # N x 5 matrix

  df_int2 <- data.frame(age = charcoal$ybpI, acc = charcoal$accI)

  df_smooth <- do.call(rbind, lapply(seq_len(5L), function(k) {
    data.frame(age    = charcoal$ybpI,
               smooth = all_curves[, k],
               method = smooth_names[k])
  }))
  df_smooth$method <- factor(df_smooth$method, levels = smooth_names)

  selected_name <- smooth_names[smoothing$method]
  df_sel  <- df_smooth[df_smooth$method == selected_name, ]
  df_rest <- df_smooth[df_smooth$method != selected_name, ]

  # Build a named colour vector: selected method = black, others = Set1 palette
  set1_4 <- c("#E41A1C", "#377EB8", "#4DAF4A", "#984EA3")
  non_sel_names <- smooth_names[smooth_names != selected_name]
  col_map <- stats::setNames(
    c("black", set1_4[seq_along(non_sel_names)]),
    c(selected_name, non_sel_names)
  )
  # Legend linewidth override in factor-level order (selected = thick)
  lw_override <- ifelse(smooth_names == selected_name, 1.2, 0.6)

  p2_title <- paste0(
    "(b) ", sub_lbl("C", "interpolated"),
    " and options for a ", smoothing$yr, " yr ",
    sub_lbl("C", "background"),
    " (selected: ", selected_name, ")"
  )

  p2 <- ggplot2::ggplot() +
    ggplot2::geom_bar(data = df_int2,
                      ggplot2::aes(x = age, y = acc),
                      stat = "identity",
                      width = diff(range(charcoal$ybpI, na.rm = TRUE)) /
                        length(charcoal$ybpI),
                      fill = "grey50", colour = NA) +
    ggplot2::geom_line(data = df_rest,
                       ggplot2::aes(x = age, y = smooth, colour = method),
                       linewidth = 0.6, alpha = 0.7) +
    ggplot2::geom_line(data = df_sel,
                       ggplot2::aes(x = age, y = smooth, colour = method),
                       linewidth = 1.2) +
    ggplot2::scale_x_reverse(limits = x_lim) +
    ggplot2::coord_cartesian(ylim = c(0, max(charcoal$accI, na.rm = TRUE))) +
    ggplot2::scale_colour_manual(
      values = col_map,
      name   = "Method",
      guide  = ggplot2::guide_legend(
        override.aes = list(linewidth = lw_override)
      )
    ) +
    .char_theme() +
    ggplot2::theme(axis.title.y = md_axis()) +
    ggplot2::labs(
      title = .title(p2_title),
      x     = "time (cal. yr BP)",
      y     = char_lbl
    )

  fig <- patchwork::wrap_plots(p1, p2, ncol = 1L)
  print(fig)
  invisible(fig)
}


# =============================================================================
# char_plot_thresh_diag  --  Figure 2: threshold determination diagnostics
# Only produced when out$results$allFigures == 1.
# Global threshold: single panel histogram + noise PDF + threshold lines.
# Local threshold:  5x5 grid of per-sample window distributions.
# Mirrors MATLAB CharThreshGlobal.m (lines 201-268) and
#         CharThreshLocal.m (lines 225-291).
#
# NOTE -- R vs. MATLAB difference (intentional, documented):
#   When threshMethod == 3 (Gaussian mixture model), the MATLAB version draws
#   both the noise and signal Gaussian components plus their weighted mixture
#   on each subplot.  The R version plots only the noise component (the
#   component with the smaller mean), which is the distribution used to define
#   the threshold.  This is an intentional simplification: the noise component
#   is the only part of the GMM that directly determines the threshold, and
#   plotting only it avoids visual confusion in windows where the two components
#   have very similar means.  This difference is noted in the package
#   documentation and release notes.
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_thresh_diag <- function(out) {

  .require_ggplot2()

  charcoal     <- out$charcoal
  char_thresh  <- out$char_thresh
  peak_analysis <- out$peak_analysis
  pretreat     <- out$pretreatment
  site         <- out$site %||% ""

  thresh_type <- peak_analysis$threshType  # 1 = global, 2 = local

  # ============================================================
  # GLOBAL THRESHOLD (Figure 2, single panel)
  # ============================================================
  if (thresh_type == 1L) {

    possible  <- char_thresh$possible
    noise_pdf <- char_thresh$noise_pdf
    thresh_pos <- char_thresh$pos[1L, ]   # 4 candidate levels

    # Histogram of C_peak proportions at the candidate bins
    bin_w  <- mean(diff(possible))
    counts <- graphics::hist(charcoal$peak, breaks = possible,
                             plot = FALSE)$counts
    prop   <- counts / sum(counts)
    df_hist <- data.frame(x = possible[-length(possible)] + bin_w / 2,
                          y = prop)

    p <- ggplot2::ggplot(df_hist, ggplot2::aes(x = x, y = y)) +
      ggplot2::geom_col(fill = "grey50", colour = "grey50", width = bin_w)

    # Noise PDF overlay (when data-defined threshold)
    if (!is.null(noise_pdf) && length(noise_pdf) == length(possible) &&
        !identical(noise_pdf, -99)) {
      df_pdf <- data.frame(x = possible,
                           y = noise_pdf * bin_w)
      p <- p + ggplot2::geom_line(data = df_pdf,
                                   ggplot2::aes(x = x, y = y),
                                   colour = "black", linewidth = 1.2)
    }

    y_max <- max(prop, na.rm = TRUE)

    # Dashed lines for all 4 candidate thresholds
    for (tv in thresh_pos) {
      p <- p + ggplot2::geom_vline(xintercept = tv,
                                   linetype = "dashed", colour = "black")
    }
    # Solid red line for selected (4th) threshold
    p <- p + ggplot2::geom_vline(xintercept = thresh_pos[4L],
                                 colour = "red", linewidth = 1.0)

    # Annotation: threshold value and mean SNI
    xannot <- thresh_pos[4L]
    sni_mean <- mean(char_thresh$SNI, na.rm = TRUE)
    p <- p +
      ggplot2::annotate("text", x = xannot * 1.5, y = y_max * 0.75,
                        label = paste0("Threshold = ",
                                       round(xannot, 4)),
                        hjust = 0, size = 3) +
      ggplot2::annotate("text", x = xannot * 1.5, y = y_max * 0.60,
                        label = paste0("SNI = ", round(sni_mean, 2)),
                        hjust = 0, size = 3)

    p <- p +
      .char_theme() +
      ggplot2::labs(
        title = paste0(site, ": ", pretreat$zoneDiv[1L], " to ",
                       pretreat$zoneDiv[length(pretreat$zoneDiv)],
                       " cal. yr BP"),
        x     = "peak CHAR (# cm^-2 yr^-1)",
        y     = "proportion or scaled density"
      )

    print(p)
    return(invisible(p))
  }

  # ============================================================
  # LOCAL THRESHOLD (Figure 2, 5x5 diagnostic grid)
  # ============================================================
  diag <- char_thresh$diag
  if (is.null(diag) || length(diag) == 0L) {
    message("char_plot_thresh_diag: no local threshold diagnostic data available.")
    return(invisible(NULL))
  }

  n_panels <- length(diag)
  n_col    <- 5L
  n_row    <- ceiling(n_panels / n_col)

  panels <- lapply(seq_len(n_panels), function(k) {
    d   <- diag[[k]]
    X   <- d$X
    bin_w_k <- diff(range(X, na.rm = TRUE)) / 50
    if (bin_w_k <= 0) bin_w_k <- 0.01
    breaks_k <- seq(min(X, na.rm = TRUE),
                    max(X, na.rm = TRUE) + bin_w_k,
                    by = bin_w_k)
    h <- graphics::hist(X, breaks = breaks_k, plot = FALSE)
    df_h <- data.frame(x = h$mids, y = h$counts / sum(h$counts))

    x_seq <- seq(min(X, na.rm = TRUE), max(X, na.rm = TRUE),
                 length.out = 200L)

    # Base panel
    pk <- ggplot2::ggplot(df_h, ggplot2::aes(x = x, y = y)) +
      ggplot2::geom_col(fill = "grey75", colour = "grey75",
                        width = bin_w_k)

    # PDF overlay: two-component GMM or single Gaussian
    if (peak_analysis$threshMethod == 3L && d$prop2 > 0) {
      df_pdf1 <- data.frame(
        x = x_seq,
        y = stats::dnorm(x_seq, d$mu1, d$sig1) * bin_w_k * d$prop1)
      df_pdf2 <- data.frame(
        x = x_seq,
        y = stats::dnorm(x_seq, d$mu2, d$sig2) * bin_w_k * d$prop2)
      df_mix  <- data.frame(
        x = x_seq,
        y = df_pdf1$y + df_pdf2$y)
      pk <- pk +
        ggplot2::geom_line(data = df_pdf1,
                           ggplot2::aes(x = x, y = y),
                           colour = "black", linewidth = 0.6) +
        ggplot2::geom_line(data = df_pdf2,
                           ggplot2::aes(x = x, y = y),
                           colour = "black", linewidth = 0.6) +
        ggplot2::geom_line(data = df_mix,
                           ggplot2::aes(x = x, y = y),
                           colour = "blue", linewidth = 0.8)
    } else {
      df_pdf <- data.frame(
        x = x_seq,
        y = stats::dnorm(x_seq, d$mu1, d$sig1) * bin_w_k)
      pk <- pk +
        ggplot2::geom_line(data = df_pdf,
                           ggplot2::aes(x = x, y = y),
                           colour = "black", linewidth = 0.9)
    }

    # Threshold line
    pk <- pk +
      ggplot2::geom_vline(xintercept = d$t_pos,
                          colour = "red", linewidth = 0.6) +
      ggplot2::coord_cartesian(ylim = c(0, 0.25)) +
      .char_theme() +
      ggplot2::theme(
        axis.text    = ggplot2::element_text(size = 6),
        axis.title.x = ggplot2::element_blank(),
        axis.title.y = ggplot2::element_blank(),
        plot.title   = .title_el(size = 7, face = "plain", hjust = 0)
      )

    # Shared axis labels: mirror MATLAB CharThreshLocal.m lines 281-286,
    # which add ylabel to plotIn==11 (middle-left) and xlabel to plotIn==23
    # (bottom-center) only.  Fall back to k==n_panels-2 for xlabel when
    # fewer than 25 panels are plotted (record shorter than 25 windows).
    if (k == 11L) {
      pk <- pk +
        ggplot2::labs(y = "proportion or density (scaled)") +
        ggplot2::theme(axis.title.y = ggplot2::element_text(size = 7, angle = 90,
                                                            vjust = 0.5))
    }
    x_lbl_k <- if (n_panels >= 23L) 23L else max(n_panels - 2L, 1L)
    if (k == x_lbl_k) {
      x_txt <- if (.has_ggtext()) {
        "CHAR (# cm<sup>-2</sup> yr<sup>-1</sup>)"
      } else {
        "CHAR (# cm^-2 yr^-1)"
      }
      pk <- pk +
        ggplot2::labs(x = x_txt) +
        ggplot2::theme(
          axis.title.x = if (.has_ggtext())
            ggtext::element_markdown(size = 7)
          else
            ggplot2::element_text(size = 7)
        )
    }

    # Title and annotation
    yr_label <- round(d$yr_bp)
    panel_title <- if (k == 1L) paste0(site, ": ", yr_label, " yr BP")
                   else paste0(yr_label, " yr BP")
    sni_label <- paste0("SNI = ",  round(d$sni, 2), "\n",
                        "KS p = ", round(d$gof, 2), "\n",
                        "t = ",    round(d$t_pos, 3))
    pk <- pk +
      ggplot2::labs(title = panel_title) +
      ggplot2::annotate("text",
                        x    = max(X, na.rm = TRUE),
                        y    = 0.24,
                        label = sni_label,
                        hjust = 1, vjust = 1, size = 2.5)

    pk
  })

  fig <- patchwork::wrap_plots(panels, ncol = n_col)
  print(fig)
  invisible(fig)
}


# =============================================================================
# char_plot_sni  --  Figure 4: Sensitivity to alternative thresholds and SNI
# Mirrors MATLAB char_plot_sni_ThresholdSNI.m
#
# Layout (mimics MATLAB 3x5 subplot grid):
#   Panel (a): C_interp bar chart with C_back and final threshold; peaks at
#              each threshold marked as dots, selected threshold as +.
#   Panel (b): Global -- C_peak histogram, noise PDF, peak-count curve.
#              Local  -- mean FRI +/- 95% CI by zone for each threshold.
#   Panel (c): SNI time series with dashed reference at SNI = 3.
#   Panel (d): SNI distribution boxplot.
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_sni <- function(out) {

  .require_ggplot2()

  charcoal     <- out$charcoal
  char_thresh  <- out$char_thresh
  peak_analysis <- out$peak_analysis
  pretreatment <- out$pretreatment
  post         <- out$post
  site         <- out$site

  zone_div  <- pretreatment$zoneDiv
  transform <- pretreatment$transform
  cPeak     <- peak_analysis$cPeak
  thresh_type <- peak_analysis$threshType   # 1 = global, 2 = local

  x   <- charcoal$ybpI
  y   <- charcoal$accI
  y2  <- charcoal$accIS
  y3  <- char_thresh$pos[, ncol(char_thresh$pos)]   # final threshold (+)
  y4  <- if (is.matrix(char_thresh$neg)) char_thresh$neg[, 1L]
         else rep(char_thresh$neg[1L], length(x))

  # Positive and negative threshold lines on the C_interp scale
  t_pos_line <- if (cPeak == 1L) y2 + y3 else y2 * y3
  t_neg_line <- if (cPeak == 1L) y2 + y4 else y2 * y4

  # CharcoalCharPeaks: N x T matrix (T = number of thresholds)
  ccp        <- post$CharcoalCharPeaks
  T_thresh   <- ncol(ccp)
  y_max      <- max(y, na.rm = TRUE)

  # y-levels for peak markers (mirroring MATLAB's 0.78 / 0.85 / 0.92)
  lev <- c(0.78, 0.85, 0.92)
  if (T_thresh < 3L) lev <- lev[(4L - T_thresh):3L]

  # Identify which column is the "selected" (Final) threshold (always last column)
  sel_col <- T_thresh

  # y-axis label: mirrors charYLabel(transform)
  y_lbl <- if (.has_ggtext()) {
    switch(as.character(transform),
           "1" = "log CHAR (# cm<sup>-2</sup> yr<sup>-1</sup>)",
           "2" = "ln CHAR (# cm<sup>-2</sup> yr<sup>-1</sup>)",
               "CHAR (# cm<sup>-2</sup> yr<sup>-1</sup>)")
  } else {
    switch(as.character(transform),
           "1" = "log CHAR (# cm^-2 yr^-1)",
           "2" = "ln CHAR (# cm^-2 yr^-1)",
               "CHAR (# cm^-2 yr^-1)")
  }
  y_lbl_axis <- if (.has_ggtext()) ggtext::element_markdown() else ggplot2::element_text()

  # Helper: x-axis ticks in 1000s of years
  x_ticks <- seq(0, max(zone_div, na.rm = TRUE), by = 1000)

  # ============================================================
  # PANEL (a): C_interp + C_back + threshold + peak markers
  # ============================================================
  df_bar  <- data.frame(x = x, y = y)
  df_back <- data.frame(x = x, y2 = y2)
  df_tpos <- data.frame(x = x, t = t_pos_line)
  df_tneg <- data.frame(x = x, t = t_neg_line)

  pa <- ggplot2::ggplot(df_bar, ggplot2::aes(x = x, y = y)) +
    ggplot2::geom_col(fill = "black", colour = "black", width = mean(diff(x), na.rm = TRUE)) +
    ggplot2::geom_line(data = df_back, ggplot2::aes(x = x, y = y2),
                       colour = "grey50", linewidth = 1.2) +
    ggplot2::geom_line(data = df_tpos, ggplot2::aes(x = x, y = t),
                       colour = "red", linewidth = 0.6) +
    ggplot2::geom_line(data = df_tneg, ggplot2::aes(x = x, y = t),
                       colour = "red", linewidth = 0.6)

  # Zone dividers
  if (length(zone_div) > 2L) {
    for (zd in zone_div[-c(1L, length(zone_div))]) {
      pa <- pa + ggplot2::annotate("segment",
                                   x = zd, xend = zd,
                                   y = y_max * 1.01, yend = y_max * 1.10,
                                   colour = "grey50", linewidth = 1.0)
    }
    for (z in seq_along(zone_div[-1L])) {
      mid_x <- mean(zone_div[z:(z + 1L)])
      pa <- pa + ggplot2::annotate("text",
                                   x = mid_x, y = y_max * 1.05,
                                   label = paste0("Zone ", z),
                                   hjust = 0.5, size = 2.5)
    }
  }

  # Peak markers: plot the first (T_thresh - 1) threshold columns as grey dots,
  # sorted by threshold value lowest → highest, mapped to y-levels 0.78 → 0.92
  # (bottom to top).  Then overlay black + at whichever y-level corresponds to
  # the Final threshold value, mirroring MATLAB char_plot_sni_ThresholdSNI.m.
  #
  # The 4th threshValue always duplicates one of the first three; the matching
  # y-level is found by comparing threshold values, not peak vectors.
  tv          <- peak_analysis$threshValues          # all T_thresh values
  non_sel_idx <- setdiff(seq_len(T_thresh), sel_col) # columns 1:(T_thresh-1)
  non_sel_tv  <- tv[non_sel_idx]                     # their threshold values
  show_cols   <- utils::head(non_sel_idx[order(non_sel_tv)], 3L) # sorted asc, ≤3

  for (rank in seq_along(show_cols)) {
    j      <- show_cols[rank]
    lev_j  <- lev[rank]
    pk_idx <- which(ccp[, j] > 0)
    if (length(pk_idx) > 0L) {
      df_pk <- data.frame(x = x[pk_idx], y = y_max * lev_j)
      pa <- pa +
        ggplot2::geom_point(data = df_pk, ggplot2::aes(x = x, y = y),
                            shape = 16L, size = 1, colour = "grey50")
    }
  }

  # Determine mIndex: find the rank (in the sorted non-selected columns) of the
  # column whose threshValue matches the Final threshValue, then read its y-level.
  sel_tv        <- tv[sel_col]
  sorted_tv     <- non_sel_tv[order(non_sel_tv)][seq_along(show_cols)]
  match_rank    <- which(sorted_tv == sel_tv)
  if (length(match_rank) == 0L) match_rank <- ceiling(length(show_cols) / 2L)
  mIndex        <- lev[match_rank[1L]]

  # Selected (final) peaks: white layer erases the grey dot, then black +
  pk_idx_sel <- which(ccp[, sel_col] > 0)
  if (length(pk_idx_sel) > 0L) {
    df_pk_sel <- data.frame(x = x[pk_idx_sel], y = y_max * mIndex)
    pa <- pa +
      ggplot2::geom_point(data = df_pk_sel, ggplot2::aes(x = x, y = y),
                          shape = 3L, size = 2.5, colour = "white", stroke = 1.5) +
      ggplot2::geom_point(data = df_pk_sel, ggplot2::aes(x = x, y = y),
                          shape = 3L, size = 2.5, colour = "black", stroke = 0.9)
  }

  pa <- pa +
    ggplot2::scale_x_reverse(limits = c(max(zone_div), min(zone_div)),
                              breaks = x_ticks,
                              labels = x_ticks / 1000,
                              expand = ggplot2::expansion(0)) +
    ggplot2::scale_y_continuous(limits = c(0, y_max * 1.15),
                                expand = ggplot2::expansion(0)) +
    .char_theme() +
    ggplot2::theme(
      axis.title.y = y_lbl_axis,
      axis.title.x = ggplot2::element_blank(),
      axis.text.x  = ggplot2::element_blank(),
      axis.ticks.x = ggplot2::element_blank()
    ) +
    ggplot2::labs(
      title = if (.has_ggtext())
        paste0(site, " (a) C<sub>interpolated</sub>, C<sub>background</sub>, and peak ID (+)")
      else
        paste0(site, " (a) C_interpolated, C_background, and peak ID (+)"),
      y = y_lbl
    )

  if (.has_ggtext()) {
    pa <- pa + ggplot2::theme(plot.title = ggtext::element_markdown())
  }

  # ============================================================
  # PANEL (b): Threshold sensitivity
  # ============================================================
  if (thresh_type == 1L) {
    # ---- Global: C_peak histogram + noise PDF + peak-count curve ------------
    cpk      <- charcoal$peak
    possible <- char_thresh$possible
    noise_pdf <- char_thresh$noise_pdf

    # Histogram (probability)
    bk   <- possible
    cnts <- graphics::hist(cpk, breaks = bk, plot = FALSE)
    n    <- cnts$counts / sum(cnts$counts)
    xh_c <- cnts$mids

    # Noise PDF scaled to histogram bin width (method > 1 only)
    bw <- mean(diff(bk), na.rm = TRUE)
    has_noise <- !is.null(noise_pdf) && length(noise_pdf) > 1L &&
                   !all(noise_pdf == -99)

    # Right axis: number of peaks as function of threshold
    y3tot <- vapply(possible, function(tv) sum(cpk > tv, na.rm = TRUE),
                    integer(1L))

    # Filter x range for peak-count curve (mirrors MATLAB cPeak logic)
    if (cPeak == 1L) {
      xplot_thresh <- xh_c[xh_c > 0]
    } else {
      xplot_thresh <- xh_c[xh_c >= 1]
    }
    yplot_thresh <- stats::approx(possible, y3tot, xout = xplot_thresh,
                                   method = "linear", rule = 2)$y

    # Scale factor to overlay right axis on left axis
    scale_fac <- max(n, na.rm = TRUE) / max(y3tot[y3tot > 0], na.rm = TRUE)

    df_hist <- data.frame(x = xh_c, n = n)
    df_cnt  <- data.frame(x = xplot_thresh, y = yplot_thresh * scale_fac)

    pb <- ggplot2::ggplot(df_hist, ggplot2::aes(x = x, y = n)) +
      ggplot2::geom_col(fill = "grey75", colour = "grey75",
                        width = bw * 0.98)

    if (has_noise) {
      noise_pdf_sc <- stats::approx(possible, noise_pdf, xout = xh_c,
                                     method = "linear", rule = 2)$y * bw
      df_pdf <- data.frame(x = xh_c, y = noise_pdf_sc)
      pb <- pb + ggplot2::geom_line(data = df_pdf,
                                    ggplot2::aes(x = x, y = y),
                                    linetype = "dashed", linewidth = 1.2)
    }

    pb <- pb +
      ggplot2::geom_line(data = df_cnt, ggplot2::aes(x = x, y = y),
                         linewidth = 1.2)

    # Threshold vertical lines (grey dashed for all but last, solid for last)
    n_tv    <- ncol(char_thresh$pos)
    t_vals  <- char_thresh$pos[1L, ]   # first row (scalar for global)
    n_max   <- max(n, na.rm = TRUE)
    for (ti in seq_len(n_tv)) {
      if (ti < n_tv) {
        pb <- pb + ggplot2::geom_vline(xintercept = t_vals[ti],
                                       linetype = "dashed",
                                       colour = "grey75", linewidth = 0.8)
      } else {
        pb <- pb +
          ggplot2::geom_vline(xintercept = t_vals[ti],
                              colour = "black", linewidth = 0.8) +
          ggplot2::annotate("text", x = t_vals[ti], y = 0,
                            label = "<", angle = 90,
                            fontface = "bold", size = 3.5, vjust = 0.5)
        if (has_noise && !is.null(peak_analysis$threshValues)) {
          tv_pct <- peak_analysis$threshValues[n_tv]
          tv_val <- round(t_vals[ti], 4)
          ann_x  <- t_vals[ti] * 1.25
          ann_y  <- 0.9 * n_max
          ann_lbl <- paste0(tv_pct * 100, "th percentile\n= ", tv_val)
          pb <- pb + ggplot2::annotate("label", x = ann_x, y = ann_y,
                                       label = ann_lbl,
                                       hjust = 0, size = 2.5, fill = "white",
                                       label.size = 0)
        }
      }
    }

    x_lim_b <- c(min(cpk, na.rm = TRUE),
                  0.75 * max(cpk, na.rm = TRUE))
    pb <- pb +
      ggplot2::scale_x_continuous(limits = x_lim_b,
                                   expand = ggplot2::expansion(0)) +
      ggplot2::scale_y_continuous(
        name  = "relative frequency",
        limits = c(0, n_max * 1.01),
        expand = ggplot2::expansion(0),
        sec.axis = ggplot2::sec_axis(
          transform = ~ . / scale_fac,
          name  = "# of peaks identified"
        )
      ) +
      ggplot2::labs(
        x     = if (cPeak == 1L)
          "residual CHAR value"
        else
          "CHAR ratio",
        title = if (has_noise)
          "(b) C_peak dist., noise dist., and threshold"
        else
          "(b) C_peak distribution and threshold"
      ) +
      .char_theme()

  } else {
    # ---- Local: mean FRI +/- 95% CI per zone per threshold ------------------
    # Mirrors MATLAB: always plot columns 1-3 of CharcoalCharPeaks, then
    # find in2 = which of those columns shares its threshValue with the Final
    # (last) threshold.  in2 is the column to mark in black with "+".
    n_zones  <- length(zone_div) - 1L
    ccp_cols <- seq_len(min(T_thresh - 1L, 3L))   # columns 1-3 (non-Final)

    # in2: which plotted column matches the Final threshValue (mirrors MATLAB)
    sel_tv_local <- tv[sel_col]
    in2 <- which(tv[ccp_cols] == sel_tv_local)[1L]
    if (is.na(in2)) in2 <- length(ccp_cols)       # fallback: last plotted col

    zone_data <- lapply(seq_len(n_zones), function(z) {
      lapply(ccp_cols, function(j) {
        pk_yrs <- x[ccp[, j] > 0]
        pk_yrs <- pk_yrs[pk_yrs >= zone_div[z] & pk_yrs < zone_div[z + 1L]]
        fri_vals <- if (length(pk_yrs) > 1L) diff(pk_yrs) else NA_real_
        fri_vals <- fri_vals[fri_vals > 0 & !is.na(fri_vals)]
        mfri <- mean(fri_vals, na.rm = TRUE)
        ci   <- if (length(fri_vals) > 1L) {
          bm <- replicate(1000L, mean(sample(fri_vals, length(fri_vals),
                                             replace = TRUE)))
          stats::quantile(bm, c(0.025, 0.975))
        } else {
          c(NA_real_, NA_real_)
        }
        data.frame(zone = z, thresh = j,
                   mfri = mfri, lo = ci[1L], hi = ci[2L])
      })
    })

    df_zfri <- do.call(rbind, do.call(c, zone_data))
    df_zfri <- df_zfri[!is.na(df_zfri$mfri), ]

    # x positions: fliplr(1:n_zones) with offsets for threshold columns
    offsets <- c(-0.25, 0, 0.25)
    df_zfri$xpos <- (n_zones + 1L - df_zfri$zone) +
                    offsets[df_zfri$thresh]

    # Split on in2 (column index within the plotted 1-3 cols matching Final)
    df_nosel <- df_zfri[df_zfri$thresh != in2, ]
    df_sel   <- df_zfri[df_zfri$thresh == in2, ]

    pb <- ggplot2::ggplot() +
      # Non-selected thresholds: grey dots + grey error bars
      ggplot2::geom_errorbar(data = df_nosel,
                             ggplot2::aes(x = xpos, ymin = lo, ymax = hi),
                             colour = "grey50", width = 0.08) +
      ggplot2::geom_point(data = df_nosel,
                          ggplot2::aes(x = xpos, y = mfri),
                          shape = 16L, size = 2, colour = "grey50") +
      # Selected (final) threshold: black + + black error bars
      ggplot2::geom_errorbar(data = df_sel,
                             ggplot2::aes(x = xpos, ymin = lo, ymax = hi),
                             colour = "black", width = 0.08) +
      ggplot2::geom_point(data = df_sel,
                          ggplot2::aes(x = xpos, y = mfri),
                          shape = 16L, size = 2, colour = "black") +
      ggplot2::scale_x_continuous(
        breaks = seq_len(n_zones),
        labels = rev(seq_len(n_zones))
      ) +
      .char_theme() +
      ggplot2::labs(
        x     = "zone",
        y     = "zone-specific\nmean FRI (years)",
        title = "(b) Sensitivity to\nalternative thresholds"
      )
  }

  # ============================================================
  # PANEL (c): SNI time series
  # ============================================================
  sni_val <- char_thresh$SNI
  if (length(sni_val) == 1L) {
    sni_series <- rep(sni_val, length(x))
  } else {
    sni_series <- sni_val
  }
  df_sni <- data.frame(x = x, sni = sni_series)

  y_lim_c <- c(0, 10)
  y_tick_c <- if (y_lim_c[2L] < 20) seq(0, y_lim_c[2L], by = 2)
              else if (y_lim_c[2L] < 50) seq(0, y_lim_c[2L], by = 5)
              else seq(0, y_lim_c[2L], by = 10)

  pc <- ggplot2::ggplot(df_sni, ggplot2::aes(x = x, y = sni)) +
    ggplot2::geom_line(colour = "black", linewidth = 0.7) +
    ggplot2::geom_hline(yintercept = 3, linetype = "dashed") +
    ggplot2::scale_x_reverse(limits = c(max(zone_div), min(zone_div)),
                              breaks = x_ticks,
                              labels = x_ticks / 1000,
                              expand = ggplot2::expansion(0)) +
    ggplot2::scale_y_continuous(limits = y_lim_c,
                                breaks = y_tick_c,
                                expand = ggplot2::expansion(0)) +
    .char_theme() +
    ggplot2::labs(
      x     = "time (cal. yr BP x 1000)",
      y     = "signal-to-noise index",
      title = "(c) Local signal-to-noise index"
    )

  # ============================================================
  # PANEL (d): Global SNI boxplot / distribution
  # ============================================================
  df_sni_d <- data.frame(x = "SNI", y = sni_series[!is.na(sni_series)])

  pd <- ggplot2::ggplot(df_sni_d, ggplot2::aes(x = x, y = y)) +
    ggplot2::geom_boxplot(outlier.shape = 16L, outlier.size = 1.2,
                          width = 0.5) +
    ggplot2::geom_hline(yintercept = 3, linetype = "dashed") +
    ggplot2::scale_y_continuous(limits = y_lim_c,
                                breaks = y_tick_c,
                                expand = ggplot2::expansion(0)) +
    ggplot2::annotate("text",
                      x = 1.35, y = stats::median(df_sni_d$y, na.rm = TRUE),
                      label = round(stats::median(df_sni_d$y, na.rm = TRUE), 2),
                      hjust = 0, size = 3, fontface = "plain") +
    .char_theme() +
    ggplot2::theme(axis.title.y  = ggplot2::element_blank(),
                   axis.text.y   = ggplot2::element_blank(),
                   axis.ticks.y  = ggplot2::element_blank()) +
    ggplot2::labs(
      x     = "global signal-to-\nnoise distribution",
      title = "(d) Global signal-to-noise index"
    )

  # ============================================================
  # Compose with patchwork (mirrors MATLAB 3x5 layout)
  # Wide panels (a, c) take 4 units; narrow panels (b, d) take 1 unit.
  # ============================================================
  design <- "AAAAB\nCCCCD"
  fig <- patchwork::wrap_plots(pa, pb, pc, pd) +
    patchwork::plot_layout(design = design)
  print(fig)
  invisible(fig)
}


# =============================================================================
# char_plot_all  --  produce and optionally save all figures
# =============================================================================

#' @rdname char_plot
#' @export
char_plot_all <- function(out, save = FALSE, out_dir = NULL, width = 11, height = 8.5) {

  .require_ggplot2()

  if (isTRUE(save)) {
    if (is.null(out_dir))
      stop("When save = TRUE, please supply 'out_dir' for the saved PDFs. ",
           "Use tempdir() for a transient location, or a path of your choosing.",
           call. = FALSE)
    if (!is.character(out_dir) || length(out_dir) != 1L || nchar(out_dir) == 0L)
      stop("'out_dir' must be a non-empty character string.", call. = FALSE)
  }

  all_figs <- isTRUE(out$results$allFigures == 1L)

  fig1 <- if (all_figs) char_plot_raw(out) else NULL
  fig2 <- if (all_figs) char_plot_thresh_diag(out) else NULL
  fig3 <- char_plot_peaks(out)
  fig4 <- char_plot_sni(out)
  fig5 <- char_plot_cumulative(out)
  fig6 <- char_plot_fri(out)
  fig7 <- char_plot_fire_history(out)
  fig8 <- char_plot_zones(out)

  for (fig in list(fig3, fig4, fig5, fig6, fig7, fig8)) {
    if (!is.null(fig)) print(fig)
  }

  if (save) {
    if (!dir.exists(out_dir)) dir.create(out_dir, recursive = TRUE)
    site <- out$site
    figs <- list(
      list(fig = fig1, name = "01_pretreatment"),
      list(fig = fig2, name = "02_threshold_determination"),
      list(fig = fig3, name = "03_CHAR_analysis"),
      list(fig = fig4, name = "04_threshold_sensitivity_SNI"),
      list(fig = fig5, name = "05_cumulative_peaks"),
      list(fig = fig6, name = "06_FRI_distributions"),
      list(fig = fig7, name = "07_continuous_fire_hx"),
      list(fig = fig8, name = "08_zone_comparisons")
    )
    for (f in figs) {
      if (!is.null(f$fig)) {
        path <- file.path(out_dir, paste0(site, "_", f$name, ".pdf"))
        ggplot2::ggsave(path, plot = f$fig, width = width, height = height,
                        units = "in", device = "pdf")
        message("Saved: ", path)
      }
    }
  }

  invisible(list(
    fig_pretreatment       = fig1,
    fig_threshold          = fig2,
    fig_char               = fig3,
    fig_threshold_sni      = fig4,
    fig_cumulative         = fig5,
    fig_fri_dist           = fig6,
    fig_fire_history       = fig7,
    fig_zones              = fig8
  ))
}

# =============================================================================
# Null-coalescing operator (avoid importing rlang)
# =============================================================================
`%||%` <- function(a, b) if (!is.null(a)) a else b

Try the CharAnalysis package in your browser

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

CharAnalysis documentation built on May 3, 2026, 5:06 p.m.