R/tabdiag.R

Defines functions .r4vn_viewer_tabdiag as.data.frame.r4vn_diag plot.r4vn_diag print.r4vn_diag .r4vn_diag_contract .r4vn_diag_interpretation .r4vn_diag_performance_display .r4vn_diag_performance_long .r4vn_diag_confusion_view_sections .r4vn_diag_confusion_display .r4vn_diag_comparison_display .r4vn_diag_ci_label .r4vn_diag_var_label tabdiag

Documented in plot.r4vn_diag tabdiag

#' Comprehensive Diagnostic Accuracy and ROC Analysis
#'
#' @description
#' `tabdiag()` is the comprehensive diagnostic-accuracy command in R4VN. It is
#' designed for binary diagnostic tests, continuous or ordinal biomarkers,
#' prediction scores, and comparisons of paired ROC curves. The first variable
#' is the binary reference standard (gold standard); diagnostic tests can be
#' supplied directly in `...` and/or through `vars()`.
#'
#' The default output follows the R4VN philosophy: a compact publication-ready
#' Viewer table is shown, while the returned object retains the complete set of
#' diagnostic measures and ROC information for later inspection or export.
#' Interpretation is opt-in (`interpretation = FALSE` by default).
#'
#' @param outcome Binary reference-standard outcome. Supply an unquoted
#'   variable name or a single character variable name.
#' @param ... One or more diagnostic tests/markers. Binary variables are
#'   analyzed as 2 x 2 tests. Numeric variables, dates/times, and ordered
#'   factors are analyzed by ROC.
#' @param data Optional data frame. If omitted, the active R4VN data selected
#'   by `usedf()` or `opendata(..., active = TRUE)` is used.
#' @param vars Optional diagnostic-marker selection created by `vars()`. This
#'   may be used instead of, or together with, markers supplied in `...`.
#' @param event Positive level of the reference-standard outcome. If omitted,
#'   R4VN recognizes common positive encodings such as 1, TRUE, Yes, Positive,
#'   Case, or Co; otherwise it uses the second factor/observed level and reports
#'   the selected positive outcome explicitly.
#' @param positive Positive level for binary diagnostic tests. It may be a
#'   single value used for all binary tests, an unnamed vector in test order,
#'   or a named vector such as
#'   `positive = c(rapid = "Positive", ct = "Abnormal")`.
#' @param direction ROC direction: `"auto"` (default), `"<"`, or `">"`.
#'   With R4VN conventions, `"<"` means higher marker values indicate the
#'   positive outcome and R4VN reports cutoffs as `>=`; `">"` means lower
#'   values indicate the positive outcome and R4VN reports cutoffs as `<=`.
#' @param best Optimal-cutoff methods. Default `"youden"`. May contain one or
#'   more of `"youden"`, `"closest"`, `"ruleout"`, and `"rulein"`.
#'   `"ruleout"` finds the threshold with the highest specificity while
#'   sensitivity is at least `target`; `"rulein"` finds the threshold with the
#'   highest sensitivity while specificity is at least `target`. Set
#'   `best = NULL` to suppress automatically selected cutoffs.
#' @param cuts Optional numeric cutoff(s) requested by the user. Every cutoff
#'   becomes a separate diagnostic-performance row for each quantitative
#'   marker. This is useful for clinically predefined thresholds.
#' @param target Target sensitivity/specificity for `"ruleout"`/`"rulein"`,
#'   default 0.95.
#' @param ci `TRUE` (default), `FALSE`, or a character vector of measures for
#'   which confidence intervals are required. Examples include
#'   `c("sens", "spec")`, `c("sens","spec","ppv","npv")`, and
#'   `c("auc","lr+","lr-","dor")`. `TRUE` requests every currently
#'   implemented stable interval.
#' @param ci_level Confidence level, default 0.95.
#' @param ci_method Binomial confidence-interval method for 2 x 2 proportions:
#'   `"auto"`/`"wilson"` or `"exact"`. AUC uses an internally implemented DeLong CI for an ordinary
#'   empirical ROC and stratified bootstrap CI for partial AUC.
#' @param cut_ci Logical; calculate a bootstrap confidence interval for an
#'   automatically selected Youden/closest cutoff. Default FALSE because this
#'   can be computationally expensive.
#' @param boot Number of bootstrap replicates for cutoff CI, partial-AUC CI,
#'   and bootstrap ROC comparison. Default 2000.
#' @param prevalence Optional target disease prevalence, strictly between 0 and
#'   1. When supplied, PPV and NPV are recalculated for that prevalence.
#' @param roc Logical; calculate ROC analysis for eligible non-binary tests.
#'   Default TRUE.
#' @param partial_auc Optional numeric vector of length two defining a partial
#'   AUC range, for example `c(1, 0.80)` for specificity 100%-80% when
#'   `partial_focus = "specificity"`.
#' @param partial_focus `"specificity"` (default) or `"sensitivity"`.
#' @param partial_correct Logical; request corrected partial AUC where
#'   supported by the R4VN internal ROC engine.
#' @param compare Logical; for two or more ROC-eligible markers, calculate
#'   pairwise comparisons of AUC. Default FALSE.
#' @param compare_method ROC comparison method: `"delong"` (default) or
#'   `"bootstrap"`. Both are implemented internally by R4VN.
#' @param adjust Multiplicity adjustment for pairwise comparison p-values,
#'   passed to `stats::p.adjust()`. Examples: `"none"`, `"holm"`, `"BH"`.
#' @param all_cuts Logical; if TRUE, calculate full 2 x 2 diagnostic properties
#'   for every finite empirical ROC threshold and store them in
#'   `$all_cutoffs`. FALSE by default because this table can be very large.
#' @param missing Logical; include the marker-specific analysis sample table in
#'   printed/Viewer output. The sample table is always retained in
#'   `$descriptive`. Default FALSE.
#' @param show Logical; open the formatted result in the Viewer. Default TRUE.
#'   For backward compatibility, a character vector supplied to `show` is
#'   interpreted as the old diagnostic-column selection syntax.
#' @param measures Diagnostic measures displayed in the selected-threshold
#'   table. Default `"core"` gives N, sensitivity, specificity, PPV, NPV,
#'   LR+, LR-, and accuracy. Other convenient profiles are `"minimal"`,
#'   `"publication"`, `"counts"`, and `"all"`; or supply an explicit vector
#'   such as `c("sens","spec","ppv","npv","lr+","lr-","dor")`.
#'   Cell counts are presented as a conventional 2 x 2 diagnostic
#'   classification table rather than as separate TP/FP/FN/TN measures.
#'   The returned object always retains all calculated measures.
#' @param hide Optional diagnostic columns to remove from the displayed cutoff
#'   table, for example `hide = c("n","accuracy")`.
#' @param plot `FALSE` (default), `TRUE`, `"roc"`, `"cutoff"`, or `"both"`.
#'   `TRUE` is equivalent to `"roc"`. Requested figures are saved internally,
#'   embedded in the Viewer report, and also sent to the RStudio Plot pane when
#'   running interactively.
#' @param plot_args Named list of graphical arguments passed to
#'   `plot.r4vn_diag()`. Useful options include `color`, `lty`, `line_width`,
#'   `legend`, `legend_position`, `auc`, `diagonal`, `grid`, `xlim`, `ylim`,
#'   `xlab`, `ylab`, `main`, and cutoff-plot controls. ROC axes are constrained
#'   to 0-1 by default.
#' @param interpretation Logical; add a cautious deterministic interpretation
#'   table. Default FALSE. Interpretation describes discrimination and selected
#'   thresholds but does not claim clinical utility or causality.
#' @param digit Number of digits displayed for estimates. Default 2.
#' @param p_digit Number of digits displayed for p-values. Default 3.
#' @param zero_correction Continuity correction used only for a requested DOR
#'   confidence interval when a zero cell is present. Default 0.5.
#' @param title Optional title printed above the output.
#' @param console Logical; also print the traditional Console result. Default
#'   FALSE.
#' @param print Deprecated compatibility argument. When supplied, it overrides
#'   `console`.
#' @param export Optional export format accepted by `tabexport()`, such as
#'   `"html"`, `"docx"`, `"xlsx"`, `"pdf"`, or `"png"`. Multiple formats
#'   may be requested in one call.
#' @param file Optional export filename. Its extension may also determine the
#'   export format.
#' @param open Logical; open the exported file when supported.
#'
#' @details
#' ## Binary diagnostic tests
#'
#' A test with exactly two observed non-missing values is analyzed directly as
#' a 2 x 2 table. `tabdiag()` and `tabdiagi()` use the same internal engine, so
#' identical TP/FP/FN/TN counts give identical sensitivity, specificity,
#' predictive values, likelihood ratios, DOR, accuracy, balanced accuracy,
#' Youden index, F1 score, MCC, kappa, FPR, FNR, FDR, FOR, prevalence,
#' detection rate, and detection prevalence.
#'
#' ## Quantitative and ordered diagnostic markers
#'
#' ROC analysis is implemented inside R4VN using base R. `tabdiag()` therefore
#' does not require `pROC`, `ggplot2`, or another ROC package for routine ROC,
#' AUC, cutoff selection, DeLong inference, partial AUC, or paired AUC comparison.
#' This keeps installation light while preserving a complete analysis object.
#'
#' Numeric markers, Date/POSIX values, and ordered factors with more than two
#' levels are analyzed by ROC. Unordered factors with more than two levels are
#' rejected because a clinically meaningful ordering cannot safely be inferred.
#' A marker that becomes constant after removal of missing values is retained
#' in the sample description and omitted from ROC analysis with an explanatory
#' note instead of crashing the whole report.
#'
#' ## Cutoffs
#'
#' `best = "youden"` maximizes sensitivity + specificity. `"closest"`
#' minimizes distance to the upper-left ROC corner. `"ruleout"` and
#' `"rulein"` choose thresholds meeting a target sensitivity or specificity.
#' Tied optimal thresholds are deliberately retained rather than silently choosing one.
#' User-defined `cuts` are added as separate rows rather than replacing the
#' automatic cutoff.
#'
#' ## Confidence intervals
#'
#' Sensitivity, specificity, PPV, NPV, and accuracy use Wilson or exact
#' binomial intervals. LR+/LR- and DOR use conventional log-scale intervals.
#' Ordinary AUC uses DeLong CI. Partial-AUC CI and optimal-cutoff CI use
#' stratified bootstrap. If prevalence-adjusted PPV/NPV are requested, simple
#' binomial CIs are not attached to those adjusted predictive values because
#' they would not correspond to the target-prevalence estimates.
#'
#' ## AUC inference and paired ROC comparison
#'
#' For each ordinary empirical ROC, R4VN stores DeLong AUC variance, standard
#' error, z statistic, and a two-sided large-sample p-value for H0: AUC = 0.5.
#' When `compare = TRUE`, every pair of eligible markers is compared on its own
#' pairwise complete-case sample. This keeps the comparison paired and makes the
#' reported N explicit when marker missingness differs.
#'
#' ## Missing values
#'
#' Each marker is analyzed on its own complete cases with the outcome. Pairwise
#' ROC comparison uses complete cases for both markers and the outcome. Therefore
#' N may differ between marker-specific AUCs and pairwise comparisons. Use
#' `missing = TRUE` to show the sample accounting table; it is always available
#' as `$descriptive`.
#'
#' ## Returned result contract
#'
#' Backward-compatible components (`$summary`, `$thresholds`, `$coordinates`,
#' `$comparison`, `$all_cutoffs`, `$roc`) are retained. In addition, the object
#' follows the common R4VN reporting contract with `$descriptive`, `$estimates`,
#' `$tests`, `$diagnostics`, `$interpretation`, `$tables`, `$plots`, `$models`,
#' `$metadata`, and `$call`.
#'
#' ## Publication-oriented display
#'
#' The default `measures = "core"` keeps the Viewer readable. Use
#' `measures = "publication"` to add DOR, `measures = "all"` for the full
#' diagnostic panel, or `measures = "counts"` for the 2 x 2 diagnostic
#' classification table only. Cell counts are never shown as a vertical
#' TP/FP/FN/TN measure list. Calculations are never discarded by this display
#' choice. When a full performance table contains only a few rows but many
#' columns, the Viewer automatically presents measures vertically for easier
#' reading.
#'
#' ## ROC plotting
#'
#' ROC plots are drawn from false-positive rate (1-specificity) and sensitivity
#' coordinates using ordinary base graphics. The default axes are exactly 0 to
#' 1 (`xaxs = "i"`, `yaxs = "i"`), preventing the small negative/>1 axis
#' extensions that can otherwise appear in automatic plotting systems.
#'
#' @return
#' An object of class `r4vn_diag`. Important components include:
#' 
#' * `$summary`: marker-specific AUC results.
#' * `$thresholds`: full diagnostic metrics at binary tests and selected cutoffs.
#' * `$comparison`: pairwise AUC comparisons.
#' * `$descriptive`: marker-specific N/missing/case/control accounting.
#' * `$estimates`: structured AUC and threshold estimates.
#' * `$tests`: AUC-vs-0.5 and pairwise ROC tests.
#' * `$diagnostics`: ROC coordinates and optional all-cutoff table.
#' * `$tables`: publication-ready display tables used by Viewer/export, including
#'   `$tables$Confusion_matrix` for selected tests/cutoffs.
#' * `$interpretation`: NULL by default, or an interpretation table when requested.
#' * `$roc` / `$models$roc`: lightweight internal R4VN ROC objects.
#'
#' @section Examples - 1. Simplest binary test:
#' ```
#' d_bin <- data.frame(
#'   disease = c(1,1,1,1,0,0,0,0),
#'   rapid   = c(1,1,1,0,1,0,0,0)
#' )
#' tabdiag(disease, rapid, data = d_bin)
#' ```
#'
#' @section Examples - 2. Explicit positive outcome/test level:
#' ```
#' d_txt <- data.frame(
#'   truth = factor(c("No","Yes","Yes","No","Yes","No")),
#'   test  = factor(c("Neg","Pos","Pos","Neg","Neg","Pos"))
#' )
#' tabdiag(truth, test, data = d_txt, event = "Yes", positive = "Pos")
#' ```
#'
#' @section Examples - 3. Several binary tests with named positive levels:
#' ```
#' d_multi_bin <- data.frame(
#'   disease = c(1,1,1,0,0,0,1,0),
#'   rapid = c("Positive","Positive","Negative","Negative","Positive","Negative","Positive","Negative"),
#'   ct = c("Abnormal","Normal","Abnormal","Normal","Normal","Normal","Abnormal","Normal")
#' )
#' tabdiag(
#'   disease, rapid, ct, data = d_multi_bin,
#'   positive = c(rapid = "Positive", ct = "Abnormal")
#' )
#' ```
#'
#' @section Examples - 4. One continuous biomarker:
#' ```
#'   set.seed(101)
#'   d <- data.frame(
#'     disease = rep(c(0,1), each = 100),
#'     crp = c(rnorm(100, 5, 2), rnorm(100, 10, 3))
#'   )
#'   z <- tabdiag(disease, crp, data = d, show = FALSE)
#'   z$summary
#'   z$thresholds
#' ```
#'
#' @section Examples - 5. Select markers with vars():
#' ```
#'   tabdiag(disease, data = d, vars = vars(crp), show = FALSE)
#' ```
#'
#' @section Examples - 6. Automatic and clinically specified cutoffs:
#' ```
#'   tabdiag(
#'     disease, crp, data = d,
#'     best = "youden", cuts = c(5, 7.5, 10), show = FALSE
#'   )
#' ```
#'
#' @section Examples - 7. Several optimal-cutoff definitions:
#' ```
#'   tabdiag(
#'     disease, crp, data = d,
#'     best = c("youden", "closest", "ruleout", "rulein"),
#'     target = 0.90, show = FALSE
#'   )
#' ```
#'
#' @section Examples - 8. Disable automatic cutoffs and use only clinical cutoffs:
#' ```
#'   tabdiag(disease, crp, data = d, best = NULL, cuts = c(6, 8, 10), show = FALSE)
#' ```
#'
#' @section Examples - 9. Confidence-interval control:
#' ```
#'   tabdiag(disease, crp, data = d, ci = c("auc", "sens", "spec"), show = FALSE)
#'   tabdiag(disease, crp, data = d, ci = TRUE, show = FALSE)
#'   tabdiag(disease, crp, data = d, ci = FALSE, show = FALSE)
#' ```
#'
#' @section Examples - 10. Exact binomial CI for diagnostic proportions:
#' ```
#' tabdiag(disease, rapid, data = d_bin, ci = TRUE, ci_method = "exact", show = FALSE)
#' ```
#'
#' @section Examples - 11. Bootstrap CI for the optimal cutoff:
#' ```
#'   tabdiag(
#'     disease, crp, data = d, cut_ci = TRUE,
#'     boot = 200, show = FALSE
#'   )
#' ```
#'
#' @section Examples - 12. Predictive values at a target prevalence:
#' ```
#'   tabdiag(disease, crp, data = d, prevalence = 0.10, show = FALSE)
#' ```
#'
#' @section Examples - 13. Pairwise DeLong comparison:
#' ```
#'   set.seed(102)
#'   d$pct <- c(rnorm(100, 1, 0.8), rnorm(100, 3, 1.2))
#'   tabdiag(disease, crp, pct, data = d, compare = TRUE, show = FALSE)
#' ```
#'
#' @section Examples - 14. Multiple-comparison adjustment:
#' ```
#'   d$score3 <- d$crp + rnorm(nrow(d), 0, 2)
#'   tabdiag(
#'     disease, crp, pct, score3, data = d,
#'     compare = TRUE, adjust = "holm", show = FALSE
#'   )
#' ```
#'
#' @section Examples - 15. Bootstrap ROC comparison:
#' ```
#'   tabdiag(
#'     disease, crp, pct, data = d,
#'     compare = TRUE, compare_method = "bootstrap", boot = 200,
#'     show = FALSE
#'   )
#' ```
#'
#' @section Examples - 16. Partial AUC:
#' ```
#'   tabdiag(
#'     disease, crp, data = d,
#'     partial_auc = c(1, 0.80), partial_focus = "specificity",
#'     partial_correct = TRUE, show = FALSE
#'   )
#' ```
#'
#' @section Examples - 17. Ordered diagnostic score:
#' ```
#'   d_ord <- data.frame(
#'     disease = c(0,0,0,0,1,1,1,1,1,0),
#'     score = ordered(c("Low","Low","Medium","Low","Medium","High","High","Medium","High","Medium"),
#'                     levels = c("Low","Medium","High"))
#'   )
#'   tabdiag(disease, score, data = d_ord, show = FALSE)
#' ```
#'
#' @section Examples - 18. Lower values indicate disease:
#' ```
#'   d$low_marker <- -d$crp
#'   tabdiag(disease, low_marker, data = d, direction = ">", show = FALSE)
#' ```
#'
#' @section Examples - 19. Marker-specific missing values:
#' ```
#'   d_missing <- d
#'   d_missing$crp[1:12] <- NA
#'   d_missing$pct[21:35] <- NA
#'   zmis <- tabdiag(
#'     disease, crp, pct, data = d_missing,
#'     compare = TRUE, missing = TRUE, show = FALSE
#'   )
#'   zmis$descriptive
#'   zmis$comparison
#' ```
#'
#' @section Examples - 20. Compact, publication, counts, and full displays:
#' ```
#'   tabdiag(disease, crp, data = d, measures = "minimal", show = FALSE)
#'   tabdiag(disease, crp, data = d, measures = "publication", show = FALSE)
#'   tabdiag(disease, crp, data = d, measures = "counts", show = FALSE)
#'   tabdiag(disease, crp, data = d, measures = "all", show = FALSE)
#' ```
#'
#' @section Examples - 21. Explicit measure selection and hiding columns:
#' ```
#'   tabdiag(
#'     disease, crp, data = d,
#'     measures = c("sens","spec","ppv","npv","lr+","lr-","dor"),
#'     hide = "npv", show = FALSE
#'   )
#' ```
#'
#' @section Examples - 22. Full metrics at every empirical ROC threshold:
#' ```
#'   zall <- tabdiag(disease, crp, data = d, all_cuts = TRUE, show = FALSE)
#'   head(zall$all_cutoffs)
#' ```
#'
#' @section Examples - 23. Interpretation is explicit opt-in:
#' ```
#'   zint <- tabdiag(disease, crp, data = d, interpretation = TRUE, show = FALSE)
#'   zint$interpretation
#' ```
#'
#' @section Examples - 24. ROC graph with publication controls:
#' ```
#'   tabdiag(
#'     disease, crp, pct, data = d, plot = "roc",
#'     plot_args = list(
#'       line_width = 2, diagonal = TRUE, grid = TRUE,
#'       legend_position = "bottomright",
#'       xlab = "1 - Specificity", ylab = "Sensitivity",
#'       main = "ROC curves"
#'     )
#'   )
#' ```
#'
#' @section Examples - 25. Sensitivity/specificity across cutoffs:
#' ```
#'   tabdiag(
#'     disease, crp, data = d, plot = "cutoff",
#'     plot_args = list(cutoff_mark = TRUE)
#'   )
#' ```
#'
#' @section Examples - 26. Draw both graph families from a saved object:
#' ```
#'   z <- tabdiag(disease, crp, pct, data = d, plot = FALSE, show = FALSE)
#'   plot(z, what = "roc")
#'   plot(z, what = "cutoff")
#' ```
#'
#' @section Examples - 27. Active R4VN data:
#' ```
#' \donttest{
#'   usedf(d)
#'   tabdiag(disease, crp, show = FALSE)
#' }
#' ```
#'
#' @section Examples - 28. Export publication tables:
#' ```
#' \donttest{
#'   tabdiag(
#'     disease, crp, data = d, show = FALSE,
#'     export = "xlsx", file = tempfile(fileext = ".xlsx")
#'   )
#' }
#' ```
#'
#' @section Examples - 29. Inspect the stable R4VN result contract:
#' ```
#'   z <- tabdiag(disease, crp, data = d, show = FALSE)
#'   names(z$tables)
#'   z$estimates$auc
#'   z$tests$auc_vs_0.5
#'   z$diagnostics$coordinates
#'   z$tables$Confusion_matrix
#'   z$metadata
#' ```
#'
#' @section Examples - 30. Full confusion matrix and full performance panel:
#' ```
#'   zfull <- tabdiag(
#'     disease, crp, data = d,
#'     measures = "all", missing = TRUE, show = FALSE
#'   )
#'   zfull$tables$Diagnostic_performance
#'   zfull$tables$Confusion_matrix
#' ```
#'
#' @section Examples - 31. Save publication-ready ROC and cutoff plots:
#' ```
#' \donttest{
#'   zplot <- tabdiag(disease, crp, data = d, show = FALSE)
#'   plot(
#'     zplot, what = "roc",
#'     file = tempfile(fileext = ".png"),
#'     width = 7, height = 7, res = 300,
#'     line_width = 2, grid = TRUE
#'   )
#'   plot(
#'     zplot, what = "cutoff",
#'     file = tempfile(fileext = ".pdf"),
#'     width = 7, height = 7, cutoff_mark = TRUE
#'   )
#' }
#' ```
#'
#' @examples
#'   set.seed(123)
#'   dd <- data.frame(
#'     disease = rep(c(0, 1), each = 40),
#'     marker = c(rnorm(40, 0, 1), rnorm(40, 1.5, 1))
#'   )
#'   z <- tabdiag(disease, marker, data = dd, show = FALSE)
#'   z$summary
#'
#' @export
tabdiag <- function(
  outcome,
  ...,
  data = NULL,
  vars = NULL,
  event = NULL,
  positive = NULL,
  direction = c("auto", "<", ">"),
  best = "youden",
  cuts = NULL,
  target = 0.95,
  ci = TRUE,
  ci_level = 0.95,
  ci_method = c("auto", "wilson", "exact"),
  cut_ci = FALSE,
  boot = 2000,
  prevalence = NULL,
  roc = TRUE,
  partial_auc = NULL,
  partial_focus = c("specificity", "sensitivity"),
  partial_correct = FALSE,
  compare = FALSE,
  compare_method = c("delong", "bootstrap"),
  adjust = "none",
  all_cuts = FALSE,
  missing = FALSE,
  show = TRUE,
  measures = "core",
  hide = NULL,
  plot = FALSE,
  plot_args = list(),
  interpretation = FALSE,
  digit = 2,
  p_digit = 3,
  zero_correction = 0.5,
  title = NULL,
  console = FALSE,
  print = NULL,
  export = NULL,
  file = NULL,
  open = FALSE
) {
  call <- match.call()
  direction <- match.arg(direction)
  ci_method <- match.arg(ci_method)
  partial_focus <- match.arg(partial_focus)
  compare_method <- match.arg(compare_method)

  viewer_show <- TRUE
  if (is.logical(show) && length(show) == 1L && !is.na(show)) {
    viewer_show <- isTRUE(show)
  } else {
    measures <- show
    viewer_show <- TRUE
  }
  if (!is.null(print)) console <- isTRUE(print)

  if (!is.logical(roc) || length(roc) != 1L || is.na(roc)) {
    stop("`roc` must be TRUE or FALSE.", call. = FALSE)
  }
  if (!is.logical(compare) || length(compare) != 1L || is.na(compare)) {
    stop("`compare` must be TRUE or FALSE.", call. = FALSE)
  }
  if (!is.logical(all_cuts) || length(all_cuts) != 1L || is.na(all_cuts)) {
    stop("`all_cuts` must be TRUE or FALSE.", call. = FALSE)
  }
  if (!is.logical(missing) || length(missing) != 1L || is.na(missing)) {
    stop("`missing` must be TRUE or FALSE.", call. = FALSE)
  }
  if (!is.logical(interpretation) || length(interpretation) != 1L || is.na(interpretation)) {
    stop("`interpretation` must be TRUE or FALSE.", call. = FALSE)
  }
  if (!is.logical(open) || length(open) != 1L || is.na(open)) {
    stop("`open` must be TRUE or FALSE.", call. = FALSE)
  }
  if (!is.list(plot_args) ||
      (length(plot_args) && (is.null(names(plot_args)) || any(!nzchar(names(plot_args)))))) {
    stop("`plot_args` must be a named list.", call. = FALSE)
  }
  if (!is.numeric(ci_level) || length(ci_level) != 1L || is.na(ci_level) ||
      ci_level <= 0 || ci_level >= 1) {
    stop("`ci_level` must be strictly between 0 and 1.", call. = FALSE)
  }
  if (!is.numeric(target) || length(target) != 1L || is.na(target) || target <= 0 || target > 1) {
    stop("`target` must be in (0, 1].", call. = FALSE)
  }
  if (!is.numeric(boot) || length(boot) != 1L || is.na(boot) || boot < 10 || boot != floor(boot)) {
    stop("`boot` must be an integer value of at least 10.", call. = FALSE)
  }
  if (!is.numeric(digit) || length(digit) != 1L || is.na(digit) || digit < 0) {
    stop("`digit` must be a non-negative number.", call. = FALSE)
  }
  if (!is.numeric(p_digit) || length(p_digit) != 1L || is.na(p_digit) || p_digit < 1) {
    stop("`p_digit` must be a positive number.", call. = FALSE)
  }
  if (!is.null(cuts) && (!is.numeric(cuts) || any(!is.finite(cuts)))) {
    stop("`cuts` must be NULL or contain only finite numeric values.", call. = FALSE)
  }
  if (!is.null(partial_auc) &&
      (!is.numeric(partial_auc) || length(partial_auc) != 2L || any(!is.finite(partial_auc)) ||
       any(partial_auc < 0 | partial_auc > 1))) {
    stop("`partial_auc` must be NULL or a finite numeric vector of length two within 0-1.", call. = FALSE)
  }
  if (!is.numeric(zero_correction) || length(zero_correction) != 1L || is.na(zero_correction) ||
      zero_correction <= 0) {
    stop("`zero_correction` must be a positive number.", call. = FALSE)
  }

  dat <- .r4vn_diag_resolve_data(data)

  outcome_expr <- substitute(outcome)
  marker_exprs <- as.list(substitute(list(...)))[-1L]
  outcome_name <- .r4vn_diag_name(outcome_expr, "outcome")
  marker_names <- .r4vn_diag_names(marker_exprs)

  if (!is.null(vars)) {
    vr <- .r4vn_resolve_vars_input(vars, data = dat, arg = "vars", default_type = "auto", strict = TRUE)
    marker_names <- c(marker_names, vr$variable)
  }
  marker_names <- unique(marker_names)
  marker_names <- marker_names[marker_names != outcome_name]

  if (!length(marker_names)) {
    stop("At least one diagnostic test/marker must be supplied in `...` or `vars`.", call. = FALSE)
  }

  missing_names <- setdiff(c(outcome_name, marker_names), names(dat))
  if (length(missing_names)) {
    stop("Variables not found in `data`: ", paste(missing_names, collapse = ", "), call. = FALSE)
  }

  out_bin <- .r4vn_diag_binary(dat[[outcome_name]], positive = event, what = outcome_name)
  y_all <- out_bin$value
  outcome_label <- .r4vn_diag_var_label(dat[[outcome_name]], outcome_name)
  marker_labels <- stats::setNames(
    vapply(marker_names, function(nm) .r4vn_diag_var_label(dat[[nm]], nm), character(1)),
    marker_names
  )

  threshold_rows <- list()
  summary_rows <- list()
  descriptive_rows <- list()
  roc_objects <- list()
  coord_objects <- list()
  all_cut_rows <- list()
  notes <- character()

  tr_i <- sr_i <- ac_i <- dr_i <- 0L

  for (j in seq_along(marker_names)) {
    marker <- marker_names[j]
    marker_label <- unname(marker_labels[[marker]])
    x0 <- dat[[marker]]
    desc_ok <- !is.na(y_all) & !is.na(x0)
    binary_test <- .r4vn_diag_is_binary(x0)
    analysis_type <- if (binary_test) "Binary 2 x 2" else if (isTRUE(roc)) "ROC" else "Not analyzed"

    dr_i <- dr_i + 1L
    descriptive_rows[[dr_i]] <- data.frame(
      marker = marker,
      marker_label = marker_label,
      analysis = analysis_type,
      total = nrow(dat),
      complete = sum(desc_ok),
      missing = nrow(dat) - sum(desc_ok),
      cases = sum(y_all[desc_ok] == 1L),
      controls = sum(y_all[desc_ok] == 0L),
      event_prevalence = if (sum(desc_ok) > 0L) mean(y_all[desc_ok] == 1L) else NA_real_,
      stringsAsFactors = FALSE
    )

    if (binary_test) {
      pos_j <- .r4vn_diag_test_positive_for(positive, marker, j)
      tr_i <- tr_i + 1L
      rr <- .r4vn_diag_binary_row(
        marker = marker,
        y = y_all,
        test = x0,
        test_positive = pos_j,
        prevalence = prevalence,
        ci = ci,
        ci_level = ci_level,
        ci_method = ci_method,
        zero_correction = zero_correction
      )
      rr$marker_label <- marker_label
      rr$cutoff_low <- NA_real_
      rr$cutoff_high <- NA_real_
      threshold_rows[[tr_i]] <- rr
      next
    }

    if (!isTRUE(roc)) next

    x <- .r4vn_diag_prepare_predictor(x0, marker)
    ok <- !is.na(y_all) & !is.na(x)
    yy <- y_all[ok]
    xx <- x[ok]

    if (length(unique(yy)) != 2L) {
      notes <- c(notes, paste0(marker_label, ": ROC omitted because complete cases did not contain both outcome levels."))
      descriptive_rows[[dr_i]]$analysis <- "ROC omitted: one outcome level"
      next
    }
    if (length(unique(xx)) < 2L) {
      notes <- c(notes, paste0(marker_label, ": ROC omitted because the marker was constant among complete cases."))
      descriptive_rows[[dr_i]]$analysis <- "ROC omitted: constant marker"
      next
    }

    roc_obj <- tryCatch(
      .r4vn_diag_roc(yy, xx, direction = direction),
      error = function(e) e
    )
    if (inherits(roc_obj, "error")) {
      notes <- c(notes, paste0(marker_label, ": ROC omitted: ", conditionMessage(roc_obj)))
      descriptive_rows[[dr_i]]$analysis <- "ROC omitted"
      next
    }
    roc_objects[[marker]] <- roc_obj

    auc_val <- roc_obj$auc
    auc_var <- roc_obj$variance
    auc_se <- if (is.finite(auc_var) && auc_var >= 0) sqrt(auc_var) else NA_real_
    auc_z <- if (is.finite(auc_se) && auc_se > 0) (auc_val - 0.5) / auc_se else NA_real_
    auc_p <- if (is.finite(auc_z)) 2 * stats::pnorm(-abs(auc_z)) else NA_real_

    auc_low <- auc_high <- NA_real_
    if (.r4vn_diag_ci_requested(ci, "auc")) {
      auc_ci <- .r4vn_diag_auc_ci(roc_obj, level = ci_level)
      auc_low <- unname(auc_ci[1L])
      auc_high <- unname(auc_ci[2L])
    }

    p_auc <- p_auc_low <- p_auc_high <- NA_real_
    if (!is.null(partial_auc)) {
      p_auc <- .r4vn_diag_partial_auc(
        roc_obj,
        range = partial_auc,
        focus = partial_focus,
        correct = partial_correct
      )
      if (.r4vn_diag_ci_requested(ci, "auc")) {
        p_ci <- .r4vn_diag_partial_auc_ci(
          roc_obj,
          range = partial_auc,
          focus = partial_focus,
          correct = partial_correct,
          level = ci_level,
          boot = boot
        )
        p_auc_low <- unname(p_ci[1L])
        p_auc_high <- unname(p_ci[2L])
      }
    }

    sr_i <- sr_i + 1L
    summary_rows[[sr_i]] <- data.frame(
      marker = marker,
      marker_label = marker_label,
      n = length(yy),
      cases = sum(yy == 1L),
      controls = sum(yy == 0L),
      auc = auc_val,
      auc_se = auc_se,
      auc_z = auc_z,
      auc_p = auc_p,
      auc_low = auc_low,
      auc_high = auc_high,
      partial_auc = p_auc,
      partial_auc_low = p_auc_low,
      partial_auc_high = p_auc_high,
      direction = if (roc_obj$direction == "<") "higher = positive" else "lower = positive",
      stringsAsFactors = FALSE
    )

    coords <- roc_obj$coordinates
    coords$marker <- marker
    coords$marker_label <- marker_label
    coord_objects[[marker]] <- coords[, c("marker", "marker_label", "threshold", "sensitivity", "specificity"), drop = FALSE]

    bt <- .r4vn_diag_best_thresholds(roc_obj, best = best, target = target)

    if (nrow(bt)) {
      for (r in seq_len(nrow(bt))) {
        tr_i <- tr_i + 1L
        rr <- .r4vn_diag_threshold_row(
          marker = marker,
          criterion = bt$criterion[r],
          cutoff = bt$cutoff[r],
          direction = roc_obj$direction,
          y = yy,
          x = xx,
          prevalence = prevalence,
          ci = ci,
          ci_level = ci_level,
          ci_method = ci_method,
          zero_correction = zero_correction
        )
        rr$marker_label <- marker_label
        rr$cutoff_low <- NA_real_
        rr$cutoff_high <- NA_real_

        if (isTRUE(cut_ci)) {
          crit_low <- tolower(bt$criterion[r])
          method_key <- if (grepl("^youden", crit_low)) {
            "youden"
          } else if (grepl("^closest", crit_low)) {
            "closest"
          } else {
            NA_character_
          }
          if (!is.na(method_key)) {
            cci <- .r4vn_diag_cut_ci(
              roc_obj, method = method_key,
              level = ci_level, boot = boot
            )
            rr$cutoff_low <- unname(cci[1L])
            rr$cutoff_high <- unname(cci[2L])
          }
        }
        threshold_rows[[tr_i]] <- rr
      }
    }

    if (!is.null(cuts)) {
      for (cu in cuts) {
        if (!is.finite(cu)) next
        tr_i <- tr_i + 1L
        rr <- .r4vn_diag_threshold_row(
          marker = marker,
          criterion = "User specified",
          cutoff = cu,
          direction = roc_obj$direction,
          y = yy,
          x = xx,
          prevalence = prevalence,
          ci = ci,
          ci_level = ci_level,
          ci_method = ci_method,
          zero_correction = zero_correction
        )
        rr$marker_label <- marker_label
        rr$cutoff_low <- NA_real_
        rr$cutoff_high <- NA_real_
        threshold_rows[[tr_i]] <- rr
      }
    }

    if (isTRUE(all_cuts)) {
      finite_cuts <- unique(coords$threshold[is.finite(coords$threshold)])
      if (length(finite_cuts)) {
        rows <- vector("list", length(finite_cuts))
        for (r in seq_along(finite_cuts)) {
          rows[[r]] <- .r4vn_diag_threshold_row(
            marker = marker,
            criterion = "All ROC thresholds",
            cutoff = finite_cuts[r],
            direction = roc_obj$direction,
            y = yy,
            x = xx,
            prevalence = prevalence,
            ci = ci,
            ci_level = ci_level,
            ci_method = ci_method,
            zero_correction = zero_correction
          )
          rows[[r]]$marker_label <- marker_label
        }
        ac_i <- ac_i + 1L
        all_cut_rows[[ac_i]] <- do.call(rbind, rows)
      }
    }
  }

  thresholds <- if (length(threshold_rows)) do.call(rbind, threshold_rows) else data.frame()
  summary_df <- if (length(summary_rows)) do.call(rbind, summary_rows) else data.frame()
  descriptive_df <- if (length(descriptive_rows)) do.call(rbind, descriptive_rows) else data.frame()
  coordinates <- if (length(coord_objects)) do.call(rbind, coord_objects) else data.frame()
  all_cutoffs <- if (length(all_cut_rows)) do.call(rbind, all_cut_rows) else data.frame()

  comparison <- data.frame()
  roc_marker_names <- names(roc_objects)
  if (isTRUE(compare) && length(roc_marker_names) >= 2L) {
    cmb <- utils::combn(roc_marker_names, 2L, simplify = FALSE)
    comp_rows <- list()
    ci_comp <- 0L

    for (i in seq_along(cmb)) {
      m1 <- cmb[[i]][1L]
      m2 <- cmb[[i]][2L]
      x1 <- .r4vn_diag_prepare_predictor(dat[[m1]], m1)
      x2 <- .r4vn_diag_prepare_predictor(dat[[m2]], m2)
      ok <- !is.na(y_all) & !is.na(x1) & !is.na(x2)
      yy <- y_all[ok]
      xx1 <- x1[ok]
      xx2 <- x2[ok]

      if (length(unique(yy)) != 2L || length(unique(xx1)) < 2L || length(unique(xx2)) < 2L) {
        notes <- c(
          notes,
          paste0(marker_labels[[m1]], " vs ", marker_labels[[m2]],
                 ": ROC comparison omitted because the paired complete-case sample was not estimable.")
        )
        next
      }

      dir1 <- if (identical(direction, "auto")) roc_objects[[m1]]$direction else direction
      dir2 <- if (identical(direction, "auto")) roc_objects[[m2]]$direction else direction
      score1 <- if (dir1 == "<") xx1 else -xx1
      score2 <- if (dir2 == "<") xx2 else -xx2

      pair <- tryCatch(
        if (identical(compare_method, "bootstrap")) {
          .r4vn_diag_compare_bootstrap(
            yy, score1, score2,
            level = ci_level, boot = boot
          )
        } else {
          .r4vn_diag_compare_delong(
            yy, score1, score2,
            level = ci_level
          )
        },
        error = function(e) e
      )

      if (inherits(pair, "error")) {
        notes <- c(
          notes,
          paste0(marker_labels[[m1]], " vs ", marker_labels[[m2]],
                 ": ROC comparison omitted: ", conditionMessage(pair))
        )
        next
      }

      a1 <- pair$auc1
      a2 <- pair$auc2
      lo <- pair$lower
      hi <- pair$upper

      ci_comp <- ci_comp + 1L
      comp_rows[[ci_comp]] <- data.frame(
        marker1 = m1,
        marker1_label = unname(marker_labels[[m1]]),
        marker2 = m2,
        marker2_label = unname(marker_labels[[m2]]),
        n = sum(ok),
        auc1 = a1,
        auc2 = a2,
        difference = a1 - a2,
        lower = lo,
        upper = hi,
        p = as.numeric(pair$p),
        stringsAsFactors = FALSE
      )
    }

    if (length(comp_rows)) {
      comparison <- do.call(rbind, comp_rows)
      comparison$p_adjusted <- stats::p.adjust(comparison$p, method = adjust)
    }
  } else if (isTRUE(compare) && length(roc_marker_names) < 2L) {
    notes <- c(notes, "Pairwise ROC comparison was requested, but fewer than two estimable ROC markers were available.")
  }

  if (!is.null(prevalence)) {
    notes <- c(
      notes,
      paste0(
        "PPV and NPV were adjusted to a disease prevalence of ",
        formatC(prevalence * 100, format = "f", digits = 1),
        "%; observed predictive values remain available in the internal 2 x 2 calculations."
      )
    )
  }
  if (isTRUE(cut_ci)) {
    notes <- c(
      notes,
      paste0("Optimal-cutoff confidence intervals used stratified bootstrap with ", boot, " replicates.")
    )
  }
  if (isTRUE(compare) && nrow(comparison)) {
    notes <- c(
      notes,
      paste0(
        "Pairwise ROC comparisons used ", compare_method,
        " tests on pair-specific complete cases; adjusted p-values use `", adjust, "`."
      )
    )
  }

  settings <- list(
    outcome = outcome_name,
    outcome_label = outcome_label,
    event = out_bin$positive,
    negative = out_bin$negative,
    markers = marker_names,
    marker_labels = marker_labels,
    direction = direction,
    best = best,
    cuts = cuts,
    target = target,
    ci = ci,
    ci_level = ci_level,
    ci_method = ci_method,
    cut_ci = cut_ci,
    boot = boot,
    prevalence = prevalence,
    roc = roc,
    partial_auc = partial_auc,
    partial_focus = partial_focus,
    partial_correct = partial_correct,
    compare = compare,
    compare_method = compare_method,
    adjust = adjust,
    all_cuts = all_cuts,
    missing = missing,
    show = measures,
    viewer = viewer_show,
    console = console,
    hide = hide,
    plot = plot,
    plot_args = plot_args,
    interpretation = interpretation,
    digit = as.integer(digit),
    p_digit = as.integer(p_digit),
    title = title
  )

  out <- list(
    summary = summary_df,
    thresholds = thresholds,
    coordinates = coordinates,
    comparison = comparison,
    all_cutoffs = all_cutoffs,
    roc = roc_objects,
    descriptive = descriptive_df,
    settings = settings,
    notes = unique(notes),
    call = call
  )
  class(out) <- "r4vn_diag"
  out <- .r4vn_diag_contract(out, interpretation = interpretation)

  out$plot_titles <- list()
  if (!identical(plot, FALSE)) {
    what <- if (isTRUE(plot)) "roc" else as.character(plot)[1L]
    plot_types <- if (identical(what, "both")) c("roc", "cutoff") else what
    plot_types <- intersect(plot_types, c("roc", "cutoff"))

    if (length(out$roc) && length(plot_types)) {
      plot_dir <- tempfile(pattern = "r4vn-tabdiag-plots-")
      dir.create(plot_dir, recursive = TRUE, showWarnings = FALSE)
      save_args <- plot_args
      save_args[c("x", "what", "file", "width", "height", "res")] <- NULL

      for (tp in plot_types) {
        path <- file.path(plot_dir, paste0(tp, ".png"))
        ok_plot <- tryCatch({
          do.call(
            plot.r4vn_diag,
            c(
              list(x = out, what = tp, file = path, width = 7, height = 7, res = 144),
              save_args
            )
          )
          file.exists(path) && isTRUE(file.info(path)$size > 0)
        }, error = function(e) {
          warning(
            "Diagnostic plot `", tp, "` could not be added to the Viewer: ",
            conditionMessage(e), call. = FALSE
          )
          FALSE
        })
        if (isTRUE(ok_plot)) {
          out$plots[[tp]] <- path
          out$plot_titles[[tp]] <- if (tp == "roc") {
            "ROC curve"
          } else {
            "Sensitivity and specificity across thresholds"
          }
        }
      }

      if (interactive()) {
        pane_args <- plot_args
        pane_args[c("x", "what", "file")] <- NULL
        tryCatch(
          do.call(plot.r4vn_diag, c(list(x = out, what = what), pane_args)),
          error = function(e) warning(
            "Diagnostic Plot-pane figure was unavailable: ", conditionMessage(e),
            call. = FALSE
          )
        )
      }
    } else if (!length(out$roc)) {
      out$notes <- unique(c(out$notes, "Plot requested, but no estimable ROC curve was available."))
    }
  }

  if (!is.null(export) || !is.null(file)) {
    if (!exists("tabexport", mode = "function", inherits = TRUE)) {
      stop("`tabexport()` is required for direct export from `tabdiag()`.", call. = FALSE)
    }
    export_tables <- out$tables[vapply(out$tables, function(z) is.data.frame(z) && nrow(z) > 0L, logical(1))]
    if (!length(export_tables)) {
      stop("No diagnostic tables are available to export.", call. = FALSE)
    }
    out$export <- tabexport(
      export_tables, export = export, file = file, open = open,
      title = title %||% "R4VN diagnostic accuracy analysis"
    )
  }

  .r4vn_show(out, show = viewer_show, console = console)
}

.r4vn_diag_var_label <- function(x, fallback) {
  lab <- attr(x, "label", exact = TRUE)
  if (!is.null(lab) && length(lab)) {
    lab <- as.character(lab[1L])
    if (!is.na(lab) && nzchar(trimws(lab))) return(lab)
  }
  fallback
}

.r4vn_diag_ci_label <- function(level = 0.95) {
  paste0(formatC(100 * level, format = "fg", digits = 6), "% CI")
}

.r4vn_diag_comparison_display <- function(z, digit = 2, p_digit = 3, ci_level = 0.95) {
  if (is.null(z) || !nrow(z)) return(data.frame())
  lab1 <- if ("marker1_label" %in% names(z)) z$marker1_label else z$marker1
  lab2 <- if ("marker2_label" %in% names(z)) z$marker2_label else z$marker2
  out <- data.frame(
    Comparison = paste(lab1, "vs", lab2),
    N = z$n,
    `AUC 1` = .r4vn_diag_format_num(z$auc1, digit),
    `AUC 2` = .r4vn_diag_format_num(z$auc2, digit),
    `AUC difference` = .r4vn_diag_format_est_ci(
      z$difference, z$lower, z$upper, digit = digit, percent = FALSE
    ),
    `p-value` = .r4vn_diag_format_p(z$p, p_digit),
    `Adjusted p` = .r4vn_diag_format_p(z$p_adjusted, p_digit),
    stringsAsFactors = FALSE,
    check.names = FALSE
  )
  attr(out, "ci_label") <- .r4vn_diag_ci_label(ci_level)
  out
}

.r4vn_diag_confusion_display <- function(x) {
  if (is.null(x$thresholds) || !nrow(x$thresholds)) return(data.frame())
  z <- x$thresholds
  rows <- vector("list", 3L * nrow(z))
  k <- 0L
  for (i in seq_len(nrow(z))) {
    marker <- if ("marker_label" %in% names(z)) z$marker_label[i] else z$marker[i]
    cutoff <- if (is.na(z$cutoff[i])) "" else paste0(z$direction[i], .r4vn_diag_format_num(z$cutoff[i], x$settings$digit))
    tp <- as.integer(z$tp[i]); fp <- as.integer(z$fp[i])
    fn <- as.integer(z$fn[i]); tn <- as.integer(z$tn[i])

    k <- k + 1L
    rows[[k]] <- data.frame(
      Marker = marker,
      Criterion = z$criterion[i],
      Cutoff = cutoff,
      `Test result` = "Positive",
      `Reference positive` = tp,
      `Reference negative` = fp,
      Total = tp + fp,
      stringsAsFactors = FALSE, check.names = FALSE
    )
    k <- k + 1L
    rows[[k]] <- data.frame(
      Marker = marker,
      Criterion = z$criterion[i],
      Cutoff = cutoff,
      `Test result` = "Negative",
      `Reference positive` = fn,
      `Reference negative` = tn,
      Total = fn + tn,
      stringsAsFactors = FALSE, check.names = FALSE
    )
    k <- k + 1L
    rows[[k]] <- data.frame(
      Marker = marker,
      Criterion = z$criterion[i],
      Cutoff = cutoff,
      `Test result` = "Total",
      `Reference positive` = tp + fn,
      `Reference negative` = fp + tn,
      Total = tp + fp + fn + tn,
      stringsAsFactors = FALSE, check.names = FALSE
    )
  }
  do.call(rbind, rows)
}

.r4vn_diag_confusion_view_sections <- function(x, tab) {
  if (!is.data.frame(tab) || !nrow(tab)) return(character())
  ids <- intersect(c("Marker", "Criterion", "Cutoff"), names(tab))
  if (!length(ids)) return(.r4vn_view_section("2 x 2 diagnostic classification", tab))

  key <- interaction(tab[ids], drop = TRUE, lex.order = TRUE)
  groups <- split(seq_len(nrow(tab)), key)
  blocks <- character()

  for (ii in groups) {
    z <- tab[ii, , drop = FALSE]
    marker <- as.character(z$Marker[1L])
    criterion <- as.character(z$Criterion[1L])
    cutoff <- as.character(z$Cutoff[1L])

    body <- z[, c("Test result", "Reference positive", "Reference negative", "Total"), drop = FALSE]
    title <- paste0("2 x 2 diagnostic classification - ", marker)

    details <- character()
    if (nzchar(criterion)) {
      if (grepl("^Positive:", criterion, ignore.case = TRUE)) {
        details <- c(details, paste0("Test positive = ", trimws(sub("^Positive:", "", criterion, ignore.case = TRUE))))
      } else {
        details <- c(details, paste0("Criterion: ", criterion))
      }
    }
    if (nzchar(cutoff)) details <- c(details, paste0("Cutoff: ", cutoff))
    description <- if (length(details)) paste(details, collapse = "; ") else NULL

    blocks <- c(blocks, .r4vn_view_section(title, body, description = description))
  }
  blocks
}

.r4vn_diag_performance_long <- function(tab) {
  if (!is.data.frame(tab) || !nrow(tab)) return(data.frame())
  id <- intersect(c("Marker", "Criterion", "Cutoff"), names(tab))
  metrics <- setdiff(names(tab), id)
  if (!length(metrics)) return(tab)

  out <- list()
  k <- 0L
  for (i in seq_len(nrow(tab))) {
    vals <- as.character(tab[i, id, drop = TRUE])
    vals <- vals[!is.na(vals) & nzchar(trimws(vals))]
    label <- paste(vals, collapse = " | ")
    for (m in metrics) {
      k <- k + 1L
      out[[k]] <- data.frame(
        Result = label,
        Measure = m,
        Value = as.character(tab[[m]][i]),
        stringsAsFactors = FALSE
      )
    }
  }
  do.call(rbind, out)
}

.r4vn_diag_performance_display <- function(x) {
  s <- x$settings
  if (!nrow(x$thresholds)) return(data.frame())
  disp <- .r4vn_diag_threshold_display(
    x$thresholds,
    ci = s$ci,
    show = s$show,
    hide = s$hide,
    digit = s$digit
  )
  if (isTRUE(s$cut_ci) && any(!is.na(x$thresholds$cutoff_low))) {
    cutci <- rep("", nrow(x$thresholds))
    ok <- !is.na(x$thresholds$cutoff_low) & !is.na(x$thresholds$cutoff_high)
    cutci[ok] <- paste0(
      .r4vn_diag_format_num(x$thresholds$cutoff_low[ok], s$digit),
      "\u2013",
      .r4vn_diag_format_num(x$thresholds$cutoff_high[ok], s$digit)
    )
    loc <- match("Cutoff", names(disp))
    nm <- paste0("Cutoff ", .r4vn_diag_ci_label(s$ci_level))
    tmp <- list(cutci)
    names(tmp) <- nm
    disp <- append(disp, tmp, after = loc)
    disp <- as.data.frame(disp, stringsAsFactors = FALSE, check.names = FALSE)
  }
  disp
}

.r4vn_diag_interpretation <- function(x) {
  out <- list()
  k <- 0L
  add <- function(section, marker, text) {
    k <<- k + 1L
    out[[k]] <<- data.frame(
      section = section,
      marker = marker,
      interpretation = text,
      stringsAsFactors = FALSE
    )
  }

  if (nrow(x$summary)) {
    for (i in seq_len(nrow(x$summary))) {
      z <- x$summary[i, , drop = FALSE]
      auc <- z$auc[1L]
      quality <- if (!is.finite(auc)) {
        "could not be classified"
      } else if (auc < 0.60) {
        "showed little discrimination"
      } else if (auc < 0.70) {
        "showed limited discrimination"
      } else if (auc < 0.80) {
        "showed acceptable discrimination"
      } else if (auc < 0.90) {
        "showed good discrimination"
      } else {
        "showed excellent discrimination"
      }
      ci_txt <- ""
      if (is.finite(z$auc_low[1L]) && is.finite(z$auc_high[1L])) {
        ci_txt <- paste0(
          " (", .r4vn_diag_ci_label(x$settings$ci_level), " ",
          .r4vn_diag_format_num(z$auc_low[1L], x$settings$digit), "\u2013",
          .r4vn_diag_format_num(z$auc_high[1L], x$settings$digit), ")"
        )
      }
      add(
        "Discrimination", z$marker_label[1L],
        paste0(
          z$marker_label[1L], " ", quality, " with AUC ",
          .r4vn_diag_format_num(auc, x$settings$digit), ci_txt,
          ". This describes discrimination in the analyzed sample, not clinical utility by itself."
        )
      )
    }
  }

  if (nrow(x$thresholds)) {
    for (m in unique(x$thresholds$marker)) {
      zz_all <- x$thresholds[x$thresholds$marker == m, , drop = FALSE]
      zz <- zz_all[!is.na(zz_all$cutoff), , drop = FALSE]
      if (!nrow(zz)) {
        z <- zz_all[1L, , drop = FALSE]
        add(
          "Binary test", z$marker_label[1L],
          paste0(
            z$marker_label[1L], " had sensitivity ",
            .r4vn_diag_format_num(100 * z$sensitivity[1L], x$settings$digit),
            "% and specificity ",
            .r4vn_diag_format_num(100 * z$specificity[1L], x$settings$digit),
            "%. Predictive values depend on disease prevalence, and these results require validation in the intended population."
          )
        )
        next
      }
      pick <- which(grepl("^Youden", zz$criterion))
      if (!length(pick)) pick <- which(grepl("^Closest", zz$criterion))
      if (!length(pick)) pick <- 1L
      z <- zz[pick[1L], , drop = FALSE]
      add(
        "Selected cutoff", z$marker_label[1L],
        paste0(
          "At the ", z$criterion[1L], " cutoff (", z$direction[1L],
          .r4vn_diag_format_num(z$cutoff[1L], x$settings$digit), "), sensitivity was ",
          .r4vn_diag_format_num(100 * z$sensitivity[1L], x$settings$digit),
          "% and specificity was ",
          .r4vn_diag_format_num(100 * z$specificity[1L], x$settings$digit),
          "%. Cutoff performance should be externally validated before clinical adoption."
        )
      )
    }
  }

  if (nrow(x$comparison)) {
    for (i in seq_len(nrow(x$comparison))) {
      z <- x$comparison[i, , drop = FALSE]
      p <- z$p_adjusted[1L]
      conclusion <- if (is.finite(p) && p < 0.05) {
        "The paired AUCs differed statistically after the requested multiplicity adjustment."
      } else {
        "The paired analysis did not show a statistically detectable AUC difference after the requested multiplicity adjustment."
      }
      add(
        "ROC comparison",
        paste(z$marker1_label[1L], "vs", z$marker2_label[1L]),
        paste0(
          z$marker1_label[1L], " vs ", z$marker2_label[1L], ": ", conclusion,
          " Adjusted p = ", .r4vn_diag_format_p(p, x$settings$p_digit), "."
        )
      )
    }
  }

  if (!length(out)) return(data.frame())
  do.call(rbind, out)
}

.r4vn_diag_contract <- function(x, interpretation = FALSE) {
  s <- x$settings
  x$estimates <- list(
    auc = x$summary,
    performance = x$thresholds
  )
  x$tests <- list(
    auc_vs_0.5 = if (nrow(x$summary)) {
      x$summary[, intersect(
        c("marker", "marker_label", "n", "auc", "auc_se", "auc_z", "auc_p", "auc_low", "auc_high"),
        names(x$summary)
      ), drop = FALSE]
    } else data.frame(),
    roc_comparison = x$comparison
  )
  x$diagnostics <- list(
    coordinates = x$coordinates,
    all_cutoffs = x$all_cutoffs
  )
  x$models <- list(roc = x$roc)
  x["interpretation"] <- list(if (isTRUE(interpretation)) .r4vn_diag_interpretation(x) else NULL)

  sample_disp <- x$descriptive
  if (nrow(sample_disp)) {
    sample_disp <- data.frame(
      Marker = sample_disp$marker_label,
      Analysis = sample_disp$analysis,
      Total = sample_disp$total,
      Complete = sample_disp$complete,
      Missing = sample_disp$missing,
      Cases = sample_disp$cases,
      Controls = sample_disp$controls,
      `Outcome prevalence` = ifelse(
        is.na(sample_disp$event_prevalence), "",
        paste0(.r4vn_diag_format_num(100 * sample_disp$event_prevalence, s$digit), "%")
      ),
      stringsAsFactors = FALSE,
      check.names = FALSE
    )
  }

  roc_disp <- if (nrow(x$summary)) {
    .r4vn_diag_summary_display(x$summary, ci = s$ci, digit = s$digit, p_digit = s$p_digit)
  } else data.frame()
  perf_disp <- .r4vn_diag_performance_display(x)
  confusion_disp <- .r4vn_diag_confusion_display(x)
  comp_disp <- .r4vn_diag_comparison_display(
    x$comparison, digit = s$digit, p_digit = s$p_digit, ci_level = s$ci_level
  )

  x$tables <- list(
    Sample = sample_disp,
    ROC_summary = roc_disp,
    Diagnostic_performance = perf_disp,
    Confusion_matrix = confusion_disp,
    ROC_comparison = comp_disp
  )
  if (!is.null(x$interpretation) && nrow(x$interpretation)) {
    x$tables$Interpretation <- x$interpretation
  }
  x$plots <- list()
  x$metadata <- list(
    outcome = s$outcome,
    outcome_label = s$outcome_label,
    positive = s$event,
    negative = s$negative,
    markers = s$markers,
    marker_labels = s$marker_labels,
    ci_level = s$ci_level,
    prevalence = s$prevalence,
    pairwise_complete_case_comparison = isTRUE(s$compare),
    missing_table_shown = isTRUE(s$missing),
    interpretation = isTRUE(interpretation)
  )
  x
}

#' @export
print.r4vn_diag <- function(x, ...) {
  s <- x$settings
  if (!is.null(s$title) && nzchar(s$title)) {
    cat("\n", s$title, "\n", sep = "")
  } else {
    cat("\nR4VN diagnostic accuracy analysis\n")
  }
  cat(strrep("-", 58), "\n", sep = "")
  cat("Outcome: ", s$outcome_label %||% s$outcome, "\n", sep = "")
  cat("Positive outcome: ", s$event, "\n", sep = "")

  if (isTRUE(s$missing) && !is.null(x$tables$Sample) && nrow(x$tables$Sample)) {
    cat("\nAnalysis sample by diagnostic marker\n")
    .r4vn_diag_print_df(x$tables$Sample)
  }

  if (!is.null(x$tables$ROC_summary) && nrow(x$tables$ROC_summary)) {
    cat("\nROC summary\n")
    .r4vn_diag_print_df(x$tables$ROC_summary)
  }

  binary_markers <- if (!is.null(x$descriptive) && nrow(x$descriptive)) {
    x$descriptive$marker[x$descriptive$analysis == "Binary 2 x 2"]
  } else character()
  counts_profile <- length(s$show) == 1L && is.character(s$show) &&
    tolower(s$show) %in% c("counts", "cells")
  show_all_cells <- length(s$show) == 1L && is.character(s$show) &&
    tolower(s$show) %in% c("all", "counts", "cells")

  if (!is.null(x$tables$Confusion_matrix) && nrow(x$tables$Confusion_matrix) &&
      (isTRUE(show_all_cells) || length(binary_markers))) {
    cm <- x$tables$Confusion_matrix
    if (!isTRUE(show_all_cells) && length(binary_markers)) {
      cm <- cm[cm$Marker %in% unname(x$settings$marker_labels[binary_markers]), , drop = FALSE]
    }
    if (nrow(cm)) {
      cat("\n2 x 2 diagnostic classification\n")
      .r4vn_diag_print_df(cm)
    }
  }

  if (!isTRUE(counts_profile) && !is.null(x$tables$Diagnostic_performance) &&
      nrow(x$tables$Diagnostic_performance)) {
    cat("\nDiagnostic performance at selected thresholds/tests\n")
    .r4vn_diag_print_df(x$tables$Diagnostic_performance)
  }

  if (!is.null(x$tables$ROC_comparison) && nrow(x$tables$ROC_comparison)) {
    cat("\nPairwise ROC comparison\n")
    .r4vn_diag_print_df(x$tables$ROC_comparison)
  }

  if (isTRUE(s$all_cuts) && nrow(x$all_cutoffs)) {
    cat(
      "\nFull metrics for every empirical ROC threshold are stored in ",
      "`$all_cutoffs` (", nrow(x$all_cutoffs), " rows).\n",
      sep = ""
    )
  }

  if (!is.null(x$interpretation) && nrow(x$interpretation)) {
    cat("\nInterpretation\n")
    for (i in seq_len(nrow(x$interpretation))) {
      cat("- ", x$interpretation$section[i], " [", x$interpretation$marker[i], "]: ",
          x$interpretation$interpretation[i], "\n", sep = "")
    }
  }

  if (length(x$notes)) {
    cat("\nNotes:\n")
    for (z in x$notes) cat("- ", z, "\n", sep = "")
  }

  invisible(x)
}

#' Plot an R4VN diagnostic analysis
#'
#' Draw ROC curves and/or sensitivity-specificity curves over empirical
#' thresholds from an object returned by `tabdiag()`.
#'
#' @param x An object returned by `tabdiag()`.
#' @param what `"roc"`, `"cutoff"`, or `"both"`.
#' @param color Optional vector of line colors. Defaults to a distinct base-R
#'   qualitative palette.
#' @param lty ROC line type(s).
#' @param line_width ROC/cutoff line width(s).
#' @param legend Logical; show the ROC legend.
#' @param legend_position Base-graphics legend position, default
#'   `"bottomright"`.
#' @param auc Logical; append AUC to ROC legend labels.
#' @param diagonal Logical; draw the no-discrimination diagonal.
#' @param diagonal_lty Line type for the no-discrimination diagonal.
#' @param grid Logical; draw a light reference grid.
#' @param xlim,ylim ROC axis limits. Values are clamped to `c(0, 1)`. Defaults are
#'   exactly `c(0, 1)` so the ROC axes never extend below 0 or above 1.
#' @param xlab,ylab ROC axis labels.
#' @param main Optional ROC title.
#' @param cutoff_mark Logical; mark selected cutoffs with vertical reference
#'   lines on cutoff plots.
#' @param cutoff_legend Logical; show sensitivity/specificity legend on cutoff
#'   plots.
#' @param cutoff_xlab,cutoff_ylab Cutoff-plot axis labels.
#' @param cutoff_main Optional cutoff-plot title. When multiple markers are
#'   plotted, the marker label is appended automatically.
#' @param file Optional graphics filename. Supported extensions are `.png`,
#'   `.jpg`/`.jpeg`, `.tif`/`.tiff`, `.pdf`, and `.svg`. For `what = "both"`,
#'   save the ROC and cutoff plots separately with two calls to `plot()`.
#' @param width,height Figure width and height in inches when `file` is used.
#'   Defaults are 7 and 7.
#' @param res Raster resolution in dpi for PNG/JPEG/TIFF output. Default 300.
#' @param bg Graphics-device background color. Default `"white"`.
#' @param font_family Optional base-graphics font family, for example
#'   `"Arial"`, when available on the current system.
#' @param cex_axis,cex_lab,cex_main Text-size controls for axes, labels, title.
#' @param bty Box type passed to base graphics.
#' @param ... Additional arguments passed to the initial base `plot()` call.
#'
#' @return Invisibly returns `x`.
#' @export
plot.r4vn_diag <- function(
  x,
  what = c("roc", "cutoff", "both"),
  color = NULL,
  lty = 1,
  line_width = 2,
  legend = TRUE,
  legend_position = "bottomright",
  auc = TRUE,
  diagonal = TRUE,
  diagonal_lty = 2,
  grid = FALSE,
  xlim = c(0, 1),
  ylim = c(0, 1),
  xlab = "1 - Specificity",
  ylab = "Sensitivity",
  main = NULL,
  cutoff_mark = TRUE,
  cutoff_legend = TRUE,
  cutoff_xlab = "Threshold",
  cutoff_ylab = "Probability",
  cutoff_main = NULL,
  file = NULL,
  width = 7,
  height = 7,
  res = 300,
  bg = "white",
  font_family = NULL,
  cex_axis = 1,
  cex_lab = 1,
  cex_main = 1,
  bty = "l",
  ...
) {
  what <- match.arg(what)

  if (!is.null(file)) {
    if (!is.character(file) || length(file) != 1L || is.na(file) || !nzchar(file)) {
      stop("`file` must be NULL or a single non-empty filename.", call. = FALSE)
    }
    if (identical(what, "both")) {
      stop(
        "When `file` is supplied, use separate plot() calls for `what = 'roc'` and `what = 'cutoff'`.",
        call. = FALSE
      )
    }
    if (!is.numeric(width) || length(width) != 1L || !is.finite(width) || width <= 0 ||
        !is.numeric(height) || length(height) != 1L || !is.finite(height) || height <= 0) {
      stop("`width` and `height` must be positive finite numbers in inches.", call. = FALSE)
    }
    if (!is.numeric(res) || length(res) != 1L || !is.finite(res) || res <= 0) {
      stop("`res` must be a positive finite number.", call. = FALSE)
    }
  }

  if (!length(x$roc)) {
    stop("No ROC curves are available in this object.", call. = FALSE)
  }
  if (!is.numeric(xlim) || length(xlim) != 2L || any(!is.finite(xlim)) ||
      !is.numeric(ylim) || length(ylim) != 2L || any(!is.finite(ylim))) {
    stop("`xlim` and `ylim` must each contain two finite numeric values.", call. = FALSE)
  }
  xlim <- pmax(0, pmin(1, xlim))
  ylim <- pmax(0, pmin(1, ylim))
  if (diff(range(xlim)) <= 0 || diff(range(ylim)) <= 0) {
    stop("ROC `xlim` and `ylim` must span a positive range within 0-1.", call. = FALSE)
  }
  xlim <- sort(xlim)
  ylim <- sort(ylim)

  if (!is.null(file)) {
    file <- path.expand(file)
    out_dir <- dirname(file)
    if (!dir.exists(out_dir)) {
      ok_dir <- dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)
      if (!ok_dir && !dir.exists(out_dir)) {
        stop("Could not create graphics output directory: ", out_dir, call. = FALSE)
      }
    }
    ext <- tolower(tools::file_ext(file))
    if (!nzchar(ext)) {
      stop("`file` must include a supported graphics extension.", call. = FALSE)
    }
    if (ext == "png") {
      grDevices::png(file, width = width, height = height, units = "in", res = res, bg = bg)
    } else if (ext %in% c("jpg", "jpeg")) {
      grDevices::jpeg(file, width = width, height = height, units = "in", res = res, bg = bg)
    } else if (ext %in% c("tif", "tiff")) {
      grDevices::tiff(file, width = width, height = height, units = "in", res = res, bg = bg, compression = "lzw")
    } else if (ext == "pdf") {
      grDevices::pdf(file, width = width, height = height, bg = bg, onefile = TRUE)
    } else if (ext == "svg") {
      grDevices::svg(file, width = width, height = height, bg = bg)
    } else {
      stop("Unsupported graphics extension: .", ext, call. = FALSE)
    }
    on.exit(grDevices::dev.off(), add = TRUE)
  }

  if (!is.null(font_family)) {
    if (!is.character(font_family) || length(font_family) != 1L || is.na(font_family)) {
      stop("`font_family` must be NULL or a single character value.", call. = FALSE)
    }
    old_family <- graphics::par("family")
    graphics::par(family = font_family)
    on.exit(graphics::par(family = old_family), add = TRUE)
  }

  nm <- names(x$roc)
  labels <- x$settings$marker_labels[nm]
  labels[is.na(labels) | !nzchar(labels)] <- nm[is.na(labels) | !nzchar(labels)]
  n_curve <- length(nm)
  if (is.null(color)) color <- grDevices::hcl.colors(max(1L, n_curve), "Dark 3")
  color <- rep(color, length.out = n_curve)
  lty <- rep(lty, length.out = n_curve)
  line_width <- rep(line_width, length.out = n_curve)

  draw_roc <- function() {
    graphics::plot(
      NA_real_, NA_real_, type = "n",
      xlim = xlim, ylim = ylim, xaxs = "i", yaxs = "i",
      xlab = xlab, ylab = ylab,
      main = main %||% "ROC curve",
      cex.axis = cex_axis, cex.lab = cex_lab, cex.main = cex_main,
      bty = bty, ...
    )
    if (isTRUE(grid)) graphics::grid()
    if (isTRUE(diagonal)) graphics::abline(0, 1, lty = diagonal_lty)

    for (i in seq_along(nm)) {
      z <- x$coordinates[x$coordinates$marker == nm[i], , drop = FALSE]
      if (!nrow(z)) next
      fpr <- 1 - z$specificity
      ord <- order(fpr, z$sensitivity)
      graphics::lines(
        fpr[ord], z$sensitivity[ord],
        col = color[i], lty = lty[i], lwd = line_width[i]
      )
    }

    if (isTRUE(legend)) {
      leg <- unname(labels)
      if (isTRUE(auc)) {
        auc_lookup <- stats::setNames(x$summary$auc, x$summary$marker)
        leg <- paste0(
          leg, " (AUC=",
          vapply(nm, function(m) {
            a <- auc_lookup[[m]]
            if (is.null(a) || !is.finite(a)) "NA" else .r4vn_diag_format_num(a, x$settings$digit)
          }, character(1)),
          ")"
        )
      }
      graphics::legend(
        legend_position, legend = leg,
        col = color, lty = lty, lwd = line_width,
        bty = "n"
      )
    }
  }

  draw_cutoff <- function() {
    old <- NULL
    if (n_curve > 1L) {
      old <- graphics::par(mfrow = c(n_curve, 1L))
      on.exit(graphics::par(old), add = TRUE)
    }

    for (i in seq_along(nm)) {
      m <- nm[i]
      z <- x$coordinates[x$coordinates$marker == m & is.finite(x$coordinates$threshold), , drop = FALSE]
      if (!nrow(z)) next
      z <- z[order(z$threshold), , drop = FALSE]
      ttl <- cutoff_main
      if (is.null(ttl)) ttl <- paste("Sensitivity and specificity -", labels[[i]])
      else if (n_curve > 1L) ttl <- paste(ttl, "-", labels[[i]])

      graphics::plot(
        z$threshold, z$sensitivity,
        type = "l", ylim = c(0, 1), yaxs = "i",
        xlab = cutoff_xlab, ylab = cutoff_ylab, main = ttl,
        lwd = line_width[i], col = color[i],
        cex.axis = cex_axis, cex.lab = cex_lab, cex.main = cex_main,
        bty = bty, ...
      )
      if (isTRUE(grid)) graphics::grid()
      graphics::lines(
        z$threshold, z$specificity,
        lty = 2, lwd = line_width[i], col = color[i]
      )

      if (isTRUE(cutoff_mark) && nrow(x$thresholds)) {
        sel <- x$thresholds[
          x$thresholds$marker == m & !is.na(x$thresholds$cutoff) &
            x$thresholds$criterion != "All ROC thresholds",
          , drop = FALSE
        ]
        if (nrow(sel)) {
          for (v in unique(sel$cutoff[is.finite(sel$cutoff)])) {
            graphics::abline(v = v, lty = 3)
          }
        }
      }
      if (isTRUE(cutoff_legend)) {
        graphics::legend(
          "bottomright",
          legend = c("Sensitivity", "Specificity"),
          col = rep(color[i], 2L), lty = c(1, 2),
          lwd = rep(line_width[i], 2L), bty = "n"
        )
      }
    }
  }

  if (what == "roc") draw_roc()
  if (what == "cutoff") draw_cutoff()
  if (what == "both") {
    draw_roc()
    draw_cutoff()
  }

  invisible(x)
}

#' @export
as.data.frame.r4vn_diag <- function(x, row.names = NULL, optional = FALSE, ...) {
  if (nrow(x$thresholds)) return(x$thresholds)
  x$summary
}


# ============================================================================
# Viewer renderer for tabdiag()
# ============================================================================
.r4vn_viewer_tabdiag <- function(x) {
  s <- x$settings
  blocks <- character()

  meta <- paste0(
    '<div class="r4vn-meta">',
    '<span class="r4vn-chip">Outcome: ', .r4vn_view_escape(s$outcome_label %||% s$outcome), '</span>',
    '<span class="r4vn-chip">Positive: ', .r4vn_view_escape(s$event), '</span>',
    '<span class="r4vn-chip">CI: ', .r4vn_view_escape(.r4vn_diag_ci_label(s$ci_level)), '</span>',
    if (!is.null(s$prevalence)) paste0(
      '<span class="r4vn-chip">Target prevalence: ',
      formatC(100 * s$prevalence, format = "f", digits = 1), '%</span>'
    ) else '',
    '</div>'
  )

  if (isTRUE(s$missing) && !is.null(x$tables$Sample) && nrow(x$tables$Sample)) {
    blocks <- c(blocks, .r4vn_view_section("Analysis sample by diagnostic marker", x$tables$Sample))
  }
  if (!is.null(x$tables$ROC_summary) && nrow(x$tables$ROC_summary)) {
    blocks <- c(blocks, .r4vn_view_section("ROC summary", x$tables$ROC_summary))
  }
  binary_markers <- if (!is.null(x$descriptive) && nrow(x$descriptive)) {
    x$descriptive$marker[x$descriptive$analysis == "Binary 2 x 2"]
  } else character()
  counts_profile <- length(s$show) == 1L && is.character(s$show) &&
    tolower(s$show) %in% c("counts", "cells")
  show_all_cells <- length(s$show) == 1L && is.character(s$show) &&
    tolower(s$show) %in% c("all", "counts", "cells")

  # A conventional 2 x 2 table is easier to interpret than listing TP/FP/FN/TN
  # as separate measures. Binary tests receive this table by default. For
  # continuous markers it is added when the user requests all/counts.
  if (!is.null(x$tables$Confusion_matrix) && nrow(x$tables$Confusion_matrix) &&
      (isTRUE(show_all_cells) || length(binary_markers))) {
    cm <- x$tables$Confusion_matrix
    if (!isTRUE(show_all_cells) && length(binary_markers)) {
      binary_labels <- unname(x$settings$marker_labels[binary_markers])
      cm <- cm[cm$Marker %in% binary_labels, , drop = FALSE]
    }
    if (nrow(cm)) blocks <- c(blocks, .r4vn_diag_confusion_view_sections(x, cm))
  }

  if (!isTRUE(counts_profile) && !is.null(x$tables$Diagnostic_performance) &&
      nrow(x$tables$Diagnostic_performance)) {
    perf <- x$tables$Diagnostic_performance
    if (nrow(perf) <= 4L && ncol(perf) > 12L) {
      long_perf <- .r4vn_diag_performance_long(perf)
      for (lab in unique(long_perf$Result)) {
        zz <- long_perf[long_perf$Result == lab, c("Measure", "Value"), drop = FALSE]
        blocks <- c(
          blocks,
          .r4vn_view_section(paste0("Diagnostic performance - ", lab), zz)
        )
      }
    } else {
      blocks <- c(
        blocks,
        .r4vn_view_section(
          "Diagnostic performance at selected thresholds/tests",
          perf
        )
      )
    }
  }
  if (!is.null(x$tables$ROC_comparison) && nrow(x$tables$ROC_comparison)) {
    blocks <- c(blocks, .r4vn_view_section("Pairwise ROC comparison", x$tables$ROC_comparison))
  }
  if (!is.null(x$interpretation) && nrow(x$interpretation)) {
    blocks <- c(blocks, .r4vn_view_section("Interpretation", x$interpretation))
  }

  notes <- x$notes
  if (isTRUE(s$all_cuts) && nrow(x$all_cutoffs)) {
    notes <- c(
      notes,
      paste0(
        "Full metrics for every empirical ROC threshold are stored in `$all_cutoffs` (",
        nrow(x$all_cutoffs), " rows)."
      )
    )
  }

  # Keep every figure requested in tabdiag() inside the same Viewer report.
  # The image files are copied beside index.html, so the Viewer does not depend
  # on browser access to an unrelated temporary directory or on extra packages.
  report_dir <- tempfile(pattern = "r4vn-tabdiag-viewer-")
  dir.create(report_dir, recursive = TRUE, showWarnings = FALSE)
  if (length(x$plots)) {
    for (nm in names(x$plots)) {
      src <- x$plots[[nm]]
      if (!is.character(src) || length(src) != 1L || !file.exists(src)) next
      ext <- tools::file_ext(src)
      dest_name <- paste0("plot-", nm, if (nzchar(ext)) paste0(".", ext) else "")
      dest <- file.path(report_dir, dest_name)
      copied <- isTRUE(file.copy(src, dest, overwrite = TRUE))
      if (!copied || !file.exists(dest)) next
      ttl <- x$plot_titles[[nm]] %||% if (nm == "roc") "ROC curve" else "Diagnostic plot"
      blocks <- c(
        blocks,
        paste0(
          '<section class="r4vn-section"><h2>', .r4vn_view_escape(ttl), '</h2>',
          '<div class="r4vn-diag-figure"><img src="', .r4vn_view_escape(dest_name),
          '" alt="', .r4vn_view_escape(ttl), '"></div></section>'
        )
      )
    }
  }

  title <- s$title %||% "Diagnostic accuracy analysis"
  subtitle <- "ROC and diagnostic performance"
  html <- paste0(
    '<!DOCTYPE html><html><head><meta charset="UTF-8">',
    '<meta name="viewport" content="width=device-width,initial-scale=1">',
    '<style>', .r4vn_view_css(),
    '.r4vn-diag-figure{text-align:center;margin:8px 0 2px;}',
    '.r4vn-diag-figure img{max-width:100%;height:auto;display:inline-block;}',
    '</style></head><body><main class="r4vn-page">',
    '<div class="r4vn-brand">R4VN</div><h1>', .r4vn_view_escape(title), '</h1>',
    '<div class="r4vn-subtitle">', .r4vn_view_escape(subtitle), '</div>',
    meta, paste0(blocks, collapse = ""), .r4vn_view_notes(notes),
    '</main></body></html>'
  )
  file <- file.path(report_dir, "index.html")
  writeLines(enc2utf8(html), file, useBytes = TRUE)
  list(html = html, file = file)
}

Try the R4VN package in your browser

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

R4VN documentation built on Sept. 30, 2026, 5:13 p.m.