Nothing
# =============================================================================
# R4VN::tabscale()
# Comprehensive scale analysis with minimal external dependencies.
#
# Base/recommended R only:
# - item descriptive statistics
# - corrected item-total correlations
# - Cronbach alpha and standardized alpha
# - alpha if item deleted
# - split-half/Spearman-Brown, Guttman lambda 6, omega total
# - KMO, Bartlett test, parallel analysis
# - principal-axis or maximum-likelihood EFA
# - subscale and total scores
# - criterion correlations and binary gold-standard ROC analysis
#
# Optional package:
# - lavaan, only when cfa = TRUE
#
# The returned object inherits from r4vn_tab, so the current tabexport()
# can export it without a separate parser.
# =============================================================================
.r4vn_ts_or <- function(x, y) if (is.null(x) || !length(x)) y else x
.r4vn_ts_stop <- function(...) stop(..., call. = FALSE)
.r4vn_ts_warn <- function(...) warning(..., call. = FALSE, immediate. = TRUE)
.r4vn_ts_escape <- function(x) {
x <- as.character(x)
x[is.na(x)] <- ""
x <- gsub("&", "&", x, fixed = TRUE)
x <- gsub("<", "<", x, fixed = TRUE)
x <- gsub(">", ">", x, fixed = TRUE)
x <- gsub('"', """, x, fixed = TRUE)
x
}
.r4vn_ts_num <- function(x, digit = 2L) {
out <- rep("", length(x))
ok <- is.finite(x)
out[ok] <- formatC(x[ok], format = "f", digits = digit)
out[is.infinite(x) & x > 0] <- "Inf"
out[is.infinite(x) & x < 0] <- "-Inf"
out
}
.r4vn_ts_safe <- function(x, fun, ...) {
x <- x[is.finite(x)]
if (!length(x)) return(NA_real_)
fun(x, ...)
}
.r4vn_ts_p <- function(x, digit = 3L) {
out <- rep("", length(x))
ok <- is.finite(x)
cut <- 10^(-digit)
out[ok & x < cut] <- paste0("<", formatC(cut, format = "f", digits = digit))
out[ok & x >= cut] <- formatC(x[ok & x >= cut], format = "f", digits = digit)
out
}
.r4vn_ts_label <- function(x, fallback) {
lab <- attr(x, "label", exact = TRUE)
if (is.null(lab) || !length(lab) || is.na(lab[1L]) || !nzchar(as.character(lab[1L]))) fallback else as.character(lab[1L])
}
.r4vn_ts_get_data <- function(data = NULL) {
if (!is.null(data)) {
if (!is.data.frame(data)) data <- as.data.frame(data)
return(data)
}
resolver <- get0(".r4vn_resolve_analysis_data", mode = "function", inherits = TRUE)
if (!is.null(resolver)) {
z <- try(resolver(NULL), silent = TRUE)
if (is.data.frame(z)) return(z)
}
use <- get0("usedf", mode = "function", inherits = TRUE)
if (!is.null(use)) {
z <- try(use(quiet = TRUE), silent = TRUE)
if (is.data.frame(z)) return(z)
}
for (op in c("R4VN.active_data", "r4vn.active_data")) {
z <- getOption(op)
if (is.data.frame(z)) return(z)
}
for (en in c(".r4vn_env", ".R4VN_env", ".r4vn_state")) {
e <- get0(en, inherits = TRUE)
if (is.environment(e)) {
for (nm in c("active_data", "active", "data")) {
if (exists(nm, envir = e, inherits = FALSE)) {
z <- get(nm, envir = e, inherits = FALSE)
if (is.data.frame(z)) return(z)
}
}
}
}
.r4vn_ts_stop("No active data frame. Supply `data` or run `usedf(data)` first.")
}
.r4vn_ts_commit_data <- function(data, data_expr, data_supplied, env) {
commit <- get0(".r4vn_commit_context", mode = "function", inherits = TRUE)
if (!is.null(commit)) {
target <- NULL
if (!isTRUE(data_supplied)) {
active_target <- get0(".r4vn_active_target", mode = "function", inherits = TRUE)
if (!is.null(active_target)) target <- try(active_target(), silent = TRUE)
} else if (is.symbol(data_expr)) {
object_target <- get0(".r4vn_object_target", mode = "function", inherits = TRUE)
if (!is.null(object_target)) target <- try(object_target(data_expr, env), silent = TRUE)
}
if (!is.null(target) && !inherits(target, "try-error")) {
done <- try(commit(target, data), silent = TRUE)
if (!inherits(done, "try-error")) return(invisible(TRUE))
}
}
if (isTRUE(data_supplied) && is.symbol(data_expr)) {
assign(as.character(data_expr), data, envir = env)
return(invisible(TRUE))
}
set_active <- get0(".r4vn_set_active", mode = "function", inherits = TRUE)
if (!is.null(set_active)) {
done <- try(set_active(data, name = "tabscale", quiet = TRUE), silent = TRUE)
if (!inherits(done, "try-error")) return(invisible(TRUE))
}
use <- get0("usedf", mode = "function", inherits = TRUE)
if (!is.null(use)) {
done <- try(use(data, quiet = TRUE), silent = TRUE)
if (!inherits(done, "try-error")) return(invisible(TRUE))
}
invisible(FALSE)
}
.r4vn_ts_names <- function(x, arg = "vars", allow_null = FALSE) {
if (is.null(x)) {
if (allow_null) return(character())
.r4vn_ts_stop(sprintf("`%s` is required.", arg))
}
if (inherits(x, "r4vn_vars")) return(unique(as.character(x$variable)))
if (is.data.frame(x) && "variable" %in% names(x)) return(unique(as.character(x$variable)))
if (inherits(x, "formula")) return(unique(all.vars(x)))
if (is.character(x)) return(unique(x[nzchar(x)]))
if (is.symbol(x)) return(as.character(x))
.r4vn_ts_stop(sprintf("`%s` must be created by vars(), a character vector, or a one-sided formula.", arg))
}
.r4vn_ts_expr_name <- function(expr, value, data_names, arg) {
if (identical(expr, quote(NULL))) return(NULL)
if (is.symbol(expr)) {
nm <- as.character(expr)
if (nm %in% data_names) return(nm)
}
if (inherits(value, "r4vn_vars")) {
z <- unique(as.character(value$variable))
if (length(z) == 1L) return(z)
}
if (is.character(value) && length(value) == 1L && value %in% data_names) return(value)
.r4vn_ts_stop(sprintf("`%s` must identify one variable in data.", arg))
}
.r4vn_ts_factor_map <- function(factor, all_items = NULL) {
if (is.null(factor)) return(list())
if (!is.list(factor) || inherits(factor, "r4vn_vars")) {
.r4vn_ts_stop("`factor` must be a named list, for example list(Physical = vars(q1, q2), Mental = vars(q3, q4)).")
}
if (!length(factor)) return(list())
nms <- names(factor)
if (is.null(nms)) nms <- rep("", length(factor))
blank <- !nzchar(nms)
nms[blank] <- paste0("Factor", which(blank))
nms <- make.unique(nms)
if ("Total" %in% nms) .r4vn_ts_stop("`Total` is reserved for the full scale; use another factor name.")
out <- stats::setNames(vector("list", length(factor)), nms)
for (i in seq_along(factor)) {
out[[i]] <- .r4vn_ts_names(factor[[i]], sprintf("factor[[%s]]", nms[i]))
if (length(out[[i]]) < 2L) .r4vn_ts_stop(sprintf("Factor `%s` must contain at least two items.", nms[i]))
}
if (!is.null(all_items)) {
bad <- setdiff(unique(unlist(out, use.names = FALSE)), all_items)
if (length(bad)) .r4vn_ts_stop("Factor items not found in `vars`: ", paste(bad, collapse = ", "))
}
out
}
.r4vn_ts_numeric_item <- function(x, variable) {
labels <- NULL
original <- x
if (is.logical(x)) {
value <- as.numeric(x)
labels <- c("0" = "No", "1" = "Yes")
} else if (is.factor(x)) {
lv <- levels(x)
suppressWarnings(num_lv <- as.numeric(lv))
if (all(is.finite(num_lv))) {
value <- num_lv[as.integer(x)]
labels <- stats::setNames(lv, as.character(num_lv))
} else {
value <- as.numeric(x)
labels <- stats::setNames(lv, as.character(seq_along(lv)))
}
} else if (is.character(x)) {
suppressWarnings(num <- as.numeric(x))
if (all(is.na(x) | is.finite(num))) {
value <- num
} else {
lv <- unique(x[!is.na(x)])
value <- match(x, lv)
labels <- stats::setNames(lv, as.character(seq_along(lv)))
.r4vn_ts_warn(sprintf("Character item `%s` was scored in first-observed order; use factor levels to define the intended order.", variable))
}
} else if (is.numeric(x) || is.integer(x)) {
value <- as.numeric(x)
lab_attr <- attr(original, "labels", exact = TRUE)
if (!is.null(lab_attr) && length(lab_attr)) {
nm <- names(lab_attr)
if (is.null(nm)) nm <- as.character(lab_attr)
labels <- stats::setNames(nm, as.character(as.numeric(lab_attr)))
}
} else {
.r4vn_ts_stop(sprintf("Item `%s` must be numeric, logical, factor, ordered factor, or character.", variable))
}
list(value = value, labels = labels)
}
.r4vn_ts_near_pd <- function(R, eps = 1e-8) {
dn <- dimnames(R)
R <- (R + t(R)) / 2
diag(R) <- 1
e <- eigen(R, symmetric = TRUE)
if (min(e$values) < eps) {
vals <- pmax(e$values, eps)
R <- e$vectors %*% diag(vals, nrow = length(vals)) %*% t(e$vectors)
d <- sqrt(diag(R))
R <- sweep(sweep(R, 1L, d, "/"), 2L, d, "/")
diag(R) <- 1
}
dimnames(R) <- dn
R
}
.r4vn_ts_cor <- function(X, method = c("pearson", "spearman"), use = c("pairwise", "complete")) {
method <- match.arg(method)
use <- match.arg(use)
use_r <- if (use == "complete") "complete.obs" else "pairwise.complete.obs"
R <- suppressWarnings(stats::cor(X, use = use_r, method = method))
if (any(!is.finite(R))) .r4vn_ts_stop("The item correlation matrix contains undefined values. Check items with no variation or excessive missingness.")
.r4vn_ts_near_pd(R)
}
.r4vn_ts_cov <- function(X, use = c("pairwise", "complete")) {
use <- match.arg(use)
use_r <- if (use == "complete") "complete.obs" else "pairwise.complete.obs"
S <- suppressWarnings(stats::cov(X, use = use_r))
if (any(!is.finite(S))) .r4vn_ts_stop("The item covariance matrix contains undefined values.")
S
}
.r4vn_ts_alpha_from_cov <- function(S) {
k <- ncol(S)
if (k < 2L) return(NA_real_)
den <- sum(S)
if (!is.finite(den) || den <= 0) return(NA_real_)
k / (k - 1) * (1 - sum(diag(S)) / den)
}
.r4vn_ts_alpha <- function(X, use = c("pairwise", "complete"), cor_method = "pearson", bootstrap = 0L, conf = 0.95) {
use <- match.arg(use)
k <- ncol(X)
S <- .r4vn_ts_cov(X, use)
R <- .r4vn_ts_cor(X, cor_method, use)
alpha <- .r4vn_ts_alpha_from_cov(S)
alpha_std <- .r4vn_ts_alpha_from_cov(R)
item_total <- vapply(seq_len(k), function(j) {
rest <- rowSums(X[, -j, drop = FALSE], na.rm = TRUE)
nrest <- rowSums(!is.na(X[, -j, drop = FALSE]))
rest[nrest == 0L] <- NA_real_
suppressWarnings(stats::cor(X[, j], rest, use = "pairwise.complete.obs", method = cor_method))
}, numeric(1))
alpha_deleted <- vapply(seq_len(k), function(j) {
if (k <= 2L) return(NA_real_)
.r4vn_ts_alpha_from_cov(.r4vn_ts_cov(X[, -j, drop = FALSE], use))
}, numeric(1))
off <- R[lower.tri(R)]
mean_inter <- if (length(off)) mean(off, na.rm = TRUE) else NA_real_
median_inter <- if (length(off)) stats::median(off, na.rm = TRUE) else NA_real_
split_coefficient <- function(left) {
right <- setdiff(seq_len(k), left)
if (!length(left) || !length(right)) return(c(r = NA_real_, sb = NA_real_))
a <- rowMeans(X[, left, drop = FALSE], na.rm = TRUE)
b <- rowMeans(X[, right, drop = FALSE], na.rm = TRUE)
a[rowSums(!is.na(X[, left, drop = FALSE])) == 0L] <- NA_real_
b[rowSums(!is.na(X[, right, drop = FALSE])) == 0L] <- NA_real_
r <- suppressWarnings(stats::cor(a, b, use = "pairwise.complete.obs", method = cor_method))
sb <- if (is.finite(r) && r > -1) 2 * r / (1 + r) else NA_real_
c(r = r, sb = sb)
}
odd <- seq(1L, k, by = 2L)
split_odd_even <- split_coefficient(odd)
split_values <- split_odd_even["sb"]
if (k >= 4L) {
size <- floor(k / 2L)
if (choose(k, size) <= 500L) {
candidates <- utils::combn(k, size, simplify = FALSE)
} else {
candidates <- replicate(500L, sort(sample.int(k, size)), simplify = FALSE)
}
split_values <- vapply(candidates, function(left) split_coefficient(left)["sb"], numeric(1))
}
split_values <- split_values[is.finite(split_values)]
split_mean <- if (length(split_values)) mean(split_values) else NA_real_
split_best <- if (length(split_values)) max(split_values) else NA_real_
invR <- try(solve(R), silent = TRUE)
lambda6 <- NA_real_
if (!inherits(invR, "try-error")) {
smc <- pmax(0, pmin(1, 1 - 1 / diag(invR)))
error_var <- diag(S) * (1 - smc)
total_var <- sum(S)
if (is.finite(total_var) && total_var > 0) lambda6 <- 1 - sum(error_var) / total_var
}
total_var <- sum(S)
lambda1 <- if (is.finite(total_var) && total_var > 0) 1 - sum(diag(S)) / total_var else NA_real_
off_sq <- S^2
diag(off_sq) <- 0
lambda2 <- if (is.finite(lambda1) && total_var > 0) {
lambda1 + sqrt(k / (k - 1) * sum(off_sq)) / total_var
} else NA_real_
lambda3 <- alpha
lambda4 <- split_best
row_off_sq <- rowSums(off_sq)
lambda5 <- if (is.finite(lambda1) && total_var > 0) {
lambda1 + 2 * sqrt(k / (k - 1)) * sqrt(max(row_off_sq)) / total_var
} else NA_real_
unique_values <- lapply(X, function(z) unique(z[is.finite(z)]))
binary_items <- all(vapply(unique_values, length, integer(1)) <= 2L)
kr20 <- if (binary_items) alpha else NA_real_
omega <- NA_real_
if (k >= 3L) {
pa <- try(.r4vn_ts_paf(R, 1L, rotation = "none"), silent = TRUE)
if (!inherits(pa, "try-error")) {
lam <- as.numeric(pa$loadings[, 1L])
if (sum(lam, na.rm = TRUE) < 0) lam <- -lam
uniq <- pmax(0, 1 - lam^2)
den <- sum(lam)^2 + sum(uniq)
if (is.finite(den) && den > 0) omega <- sum(lam)^2 / den
}
}
n_complete <- sum(stats::complete.cases(X))
n_ci <- if (n_complete >= 4L) n_complete else nrow(X)
ci <- c(lower = NA_real_, upper = NA_real_)
omega_ci <- c(lower = NA_real_, upper = NA_real_)
ci_method <- "Feldt"
if (is.finite(alpha) && n_ci >= 4L && k >= 2L) {
a <- 1 - conf
ci <- c(
lower = 1 - (1 - alpha) * stats::qf(1 - a / 2, n_ci - 1, (n_ci - 1) * (k - 1)),
upper = 1 - (1 - alpha) * stats::qf(a / 2, n_ci - 1, (n_ci - 1) * (k - 1))
)
ci <- pmax(-1, pmin(1, ci))
}
bootstrap <- as.integer(bootstrap)
if (bootstrap > 0L && nrow(X) >= 20L) {
vals <- replicate(bootstrap, {
id <- sample.int(nrow(X), nrow(X), replace = TRUE)
tryCatch(.r4vn_ts_alpha_from_cov(.r4vn_ts_cov(X[id, , drop = FALSE], use)), error = function(e) NA_real_)
})
vals <- vals[is.finite(vals)]
if (length(vals) >= max(20L, floor(bootstrap * 0.5))) {
ci <- stats::quantile(vals, probs = c((1 - conf) / 2, 1 - (1 - conf) / 2), na.rm = TRUE, names = FALSE)
names(ci) <- c("lower", "upper")
ci_method <- "Bootstrap percentile"
}
omega_vals <- replicate(bootstrap, {
id <- sample.int(nrow(X), nrow(X), replace = TRUE)
tryCatch({
rb <- .r4vn_ts_cor(X[id, , drop = FALSE], cor_method, use)
pa <- .r4vn_ts_paf(rb, 1L, rotation = "none")
lam <- as.numeric(pa$loadings[,1L]); if (sum(lam,na.rm=TRUE)<0) lam <- -lam
uniq <- pmax(0,1-lam^2); sum(lam)^2/(sum(lam)^2+sum(uniq))
}, error=function(e) NA_real_)
})
omega_vals <- omega_vals[is.finite(omega_vals)]
if (length(omega_vals) >= max(20L, floor(bootstrap*.5))) {
omega_ci <- stats::quantile(omega_vals, probs=c((1-conf)/2,1-(1-conf)/2),na.rm=TRUE,names=FALSE)
names(omega_ci) <- c("lower","upper")
}
}
ordinal_alpha <- omega_hierarchical <- glb <- NA_real_
polychoric <- NULL
if (requireNamespace("psych", quietly = TRUE) && k >= 3L &&
all(vapply(X, function(z) length(unique(z[is.finite(z)])) <= 10L, logical(1)))) {
pc <- try(psych::polychoric(X)$rho, silent = TRUE)
if (!inherits(pc, "try-error") && is.matrix(pc) && all(is.finite(pc))) {
polychoric <- .r4vn_ts_near_pd(pc)
ordinal_alpha <- .r4vn_ts_alpha_from_cov(polychoric)
om <- try(suppressMessages(psych::omega(polychoric, nfactors = min(3L, k - 1L),
n.obs = n_ci, plot = FALSE)), silent = TRUE)
if (!inherits(om, "try-error")) {
if (length(om$omega_h) && is.finite(om$omega_h[1L])) omega_hierarchical <- om$omega_h[1L]
}
}
}
if (requireNamespace("psych", quietly = TRUE)) {
glb_fit <- try(psych::glb.algebraic(S), silent = TRUE)
if (!inherits(glb_fit, "try-error") && length(glb_fit$glb) && is.finite(glb_fit$glb[1L])) glb <- glb_fit$glb[1L]
}
list(
alpha = alpha,
alpha_std = alpha_std,
alpha_ci = ci,
alpha_ci_method = ci_method,
ordinal_alpha = ordinal_alpha,
item_total = item_total,
alpha_deleted = alpha_deleted,
mean_interitem = mean_inter,
median_interitem = median_inter,
split_half_r = unname(split_odd_even["r"]),
spearman_brown = unname(split_odd_even["sb"]),
split_half_mean = split_mean,
split_half_best = split_best,
lambda1 = lambda1,
lambda2 = lambda2,
lambda3 = lambda3,
lambda4 = lambda4,
lambda5 = lambda5,
lambda6 = lambda6,
glb = glb,
kr20 = kr20,
omega_total = omega,
omega_ci = omega_ci,
omega_hierarchical = omega_hierarchical,
polychoric = polychoric,
covariance = S,
correlation = R
)
}
.r4vn_ts_paf <- function(R, factors = 1L, rotation = c("varimax", "promax", "none"), maxit = 100L, tol = 1e-5) {
rotation <- match.arg(rotation)
p <- ncol(R)
factors <- max(1L, min(as.integer(factors), p - 1L))
invR <- try(solve(R), silent = TRUE)
h2 <- if (inherits(invR, "try-error")) rep(0.5, p) else pmax(0.05, pmin(0.99, 1 - 1 / diag(invR)))
L <- matrix(0, p, factors)
for (i in seq_len(maxit)) {
reduced <- R
diag(reduced) <- h2
e <- eigen(reduced, symmetric = TRUE)
vals <- pmax(e$values[seq_len(factors)], 0)
Lnew <- e$vectors[, seq_len(factors), drop = FALSE] %*% diag(sqrt(vals), nrow = factors)
hnew <- rowSums(Lnew^2)
if (max(abs(hnew - h2), na.rm = TRUE) < tol) {
L <- Lnew
h2 <- hnew
break
}
L <- Lnew
h2 <- pmin(0.999, pmax(0.001, hnew))
}
phi <- diag(factors)
rotmat <- diag(factors)
if (factors > 1L && rotation == "varimax") {
vr <- stats::varimax(L)
L <- unclass(vr$loadings)
rotmat <- vr$rotmat
} else if (factors > 1L && rotation == "promax") {
pr <- stats::promax(L)
L <- unclass(pr$loadings)
rotmat <- pr$rotmat
phi_try <- try(solve(t(rotmat) %*% rotmat), silent = TRUE)
if (!inherits(phi_try, "try-error")) phi <- phi_try
}
colnames(L) <- paste0("F", seq_len(factors))
rownames(L) <- rownames(R)
dimnames(phi) <- list(colnames(L), colnames(L))
list(loadings = L, communality = h2, uniqueness = pmax(0, 1 - h2), phi = phi, rotmat = rotmat, method = "Principal axis")
}
.r4vn_ts_kmo_bartlett <- function(R, n) {
p <- ncol(R)
invR <- try(solve(R), silent = TRUE)
if (inherits(invR, "try-error")) {
msa <- stats::setNames(rep(NA_real_, p), colnames(R))
return(list(kmo = NA_real_, msa = msa, bartlett = c(chisq = NA, df = p * (p - 1) / 2, p = NA), determinant = NA_real_))
}
partial <- -stats::cov2cor(invR)
diag(partial) <- 0
r2 <- R^2
diag(r2) <- 0
p2 <- partial^2
sum_r2 <- sum(r2) / 2
sum_p2 <- sum(p2) / 2
kmo <- sum_r2 / (sum_r2 + sum_p2)
msa <- rowSums(r2) / (rowSums(r2) + rowSums(p2))
names(msa) <- colnames(R)
detR <- determinant(R, logarithm = TRUE)
logdet <- as.numeric(detR$modulus)
chi <- -(n - 1 - (2 * p + 5) / 6) * logdet
df <- p * (p - 1) / 2
pv <- stats::pchisq(chi, df = df, lower.tail = FALSE)
list(kmo = kmo, msa = msa, bartlett = c(chisq = chi, df = df, p = pv), determinant = exp(logdet))
}
.r4vn_ts_parallel <- function(X, R, iter = 100L, quantile = 0.95, seed = NULL) {
iter <- max(20L, as.integer(iter))
n <- sum(stats::complete.cases(X))
if (n < 20L) n <- nrow(X)
p <- ncol(X)
obs <- eigen(R, symmetric = TRUE, only.values = TRUE)$values
if (!is.null(seed)) {
old <- if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) get(".Random.seed", envir = .GlobalEnv) else NULL
on.exit({
if (is.null(old)) {
if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) rm(".Random.seed", envir = .GlobalEnv)
} else assign(".Random.seed", old, envir = .GlobalEnv)
}, add = TRUE)
set.seed(seed)
}
sim <- replicate(iter, {
z <- matrix(stats::rnorm(n * p), nrow = n, ncol = p)
eigen(stats::cor(z), symmetric = TRUE, only.values = TRUE)$values
})
random_q <- apply(sim, 1L, stats::quantile, probs = quantile, na.rm = TRUE)
suggested <- sum(obs > random_q)
if (suggested < 1L) suggested <- 1L
tab <- data.frame(Component = seq_len(p), Observed = obs, Random95 = random_q, Retain = obs > random_q, stringsAsFactors = FALSE)
list(table = tab, suggested = suggested)
}
.r4vn_ts_complexity <- function(L) {
sq <- L^2
den <- rowSums(sq^2)
num <- rowSums(sq)^2
ifelse(den > 0, num / den, NA_real_)
}
.r4vn_ts_efa <- function(X, cor_method, use, method, rotation, nfactor, parallel_iter, seed) {
R <- .r4vn_ts_cor(X, cor_method, use)
complete_n <- sum(stats::complete.cases(X))
n_for_tests <- if (complete_n >= 20L) complete_n else nrow(X)
diagnostics <- .r4vn_ts_kmo_bartlett(R, n_for_tests)
parallel <- .r4vn_ts_parallel(X, R, parallel_iter, seed = seed)
nf <- .r4vn_ts_or(nfactor, parallel$suggested)
nf <- max(1L, min(as.integer(nf), ncol(X) - 1L))
fit <- NULL
if (method == "ml") {
fa <- try(stats::factanal(covmat = R, factors = nf, n.obs = n_for_tests, rotation = rotation), silent = TRUE)
if (!inherits(fa, "try-error")) {
L <- unclass(fa$loadings)
colnames(L) <- paste0("F", seq_len(ncol(L)))
rownames(L) <- colnames(X)
phi <- diag(ncol(L))
if (rotation == "promax" && !is.null(fa$rotmat)) {
phi_try <- try(solve(t(fa$rotmat) %*% fa$rotmat), silent = TRUE)
if (!inherits(phi_try, "try-error")) phi <- phi_try
}
dimnames(phi) <- list(colnames(L), colnames(L))
fit <- list(
loadings = L,
communality = 1 - fa$uniquenesses,
uniqueness = fa$uniquenesses,
phi = phi,
method = "Maximum likelihood",
statistic = unname(.r4vn_ts_or(fa$STATISTIC, NA_real_)),
df = unname(.r4vn_ts_or(fa$dof, NA_real_)),
p = unname(.r4vn_ts_or(fa$PVAL, NA_real_))
)
} else {
.r4vn_ts_warn("Maximum-likelihood EFA did not converge; principal-axis factoring was used instead.")
}
}
if (is.null(fit)) {
fit <- .r4vn_ts_paf(R, nf, rotation)
fit$statistic <- fit$df <- fit$p <- NA_real_
}
L <- fit$loadings
dominant <- apply(abs(L), 1L, function(z) colnames(L)[which.max(z)])
dominant_loading <- apply(L, 1L, function(z) z[which.max(abs(z))])
loading_table <- data.frame(
Variable = rownames(L),
L,
DominantFactor = dominant,
DominantLoading = dominant_loading,
Communality = fit$communality,
Uniqueness = fit$uniqueness,
Complexity = .r4vn_ts_complexity(L),
check.names = FALSE,
stringsAsFactors = FALSE
)
list(
R = R,
diagnostics = diagnostics,
parallel = parallel,
nfactor = nf,
fit = fit,
loadings = loading_table
)
}
.r4vn_ts_score_one <- function(X, method = c("mean", "sum"), min_valid = NULL) {
method <- match.arg(method)
k <- ncol(X)
if (is.null(min_valid)) min_valid <- ceiling(0.8 * k)
if (length(min_valid) != 1L || !is.finite(min_valid)) .r4vn_ts_stop("`min_valid` must be one finite number.")
if (min_valid > 0 && min_valid <= 1) min_valid <- ceiling(min_valid * k)
min_valid <- max(1L, min(k, as.integer(min_valid)))
nvalid <- rowSums(!is.na(X))
score <- if (method == "mean") rowMeans(X, na.rm = TRUE) else rowSums(X, na.rm = TRUE)
score[nvalid < min_valid] <- NA_real_
list(score = score, nvalid = nvalid, min_valid = min_valid)
}
.r4vn_ts_subscale_scores <- function(X, factor_map, method, min_valid) {
if (length(min_valid) > 1L && (is.null(names(min_valid)) || any(!nzchar(names(min_valid))))) {
.r4vn_ts_stop("When `min_valid` has several values, it must be named by factor and/or `Total`.")
}
choose_min <- function(nm) {
if (is.null(min_valid)) return(NULL)
if (length(min_valid) == 1L) return(min_valid)
if (nm %in% names(min_valid)) return(unname(min_valid[[nm]]))
NULL
}
out <- list()
if (length(factor_map)) {
for (nm in names(factor_map)) out[[nm]] <- .r4vn_ts_score_one(X[, factor_map[[nm]], drop = FALSE], method, choose_min(nm))
}
out[["Total"]] <- .r4vn_ts_score_one(X, method, choose_min("Total"))
out
}
.r4vn_ts_roc <- function(y, score, event = NULL, direction = c("auto", "higher", "lower"), conf = 0.95) {
direction <- match.arg(direction)
ok <- !is.na(y) & is.finite(score)
y <- y[ok]
score <- score[ok]
if (is.factor(y)) y <- droplevels(y)
if (is.factor(y)) {
lv <- levels(y)
if (length(lv) != 2L) .r4vn_ts_stop("`gold` must have exactly two levels for ROC analysis.")
event <- .r4vn_ts_or(event, lv[2L])
if (!event %in% lv) .r4vn_ts_stop("`event` was not found among gold-standard levels.")
yy <- as.integer(y == event)
nonevent <- lv[lv != event][1L]
} else {
vals <- sort(unique(y[!is.na(y)]))
if (length(vals) != 2L) .r4vn_ts_stop("`gold` must have exactly two values for ROC analysis.")
event <- .r4vn_ts_or(event, vals[2L])
yy <- as.integer(y == event)
nonevent <- vals[vals != event][1L]
}
n1 <- sum(yy == 1L)
n0 <- sum(yy == 0L)
if (n1 < 2L || n0 < 2L) .r4vn_ts_stop("ROC analysis requires at least two event and two non-event observations.")
auc_higher <- (sum(rank(score, ties.method = "average")[yy == 1L]) - n1 * (n1 + 1) / 2) / (n1 * n0)
if (direction == "auto") direction <- if (auc_higher >= 0.5) "higher" else "lower"
auc <- if (direction == "higher") auc_higher else 1 - auc_higher
u <- sort(unique(score))
cuts <- if (length(u) == 1L) u else c(u[1L] - .Machine$double.eps, (u[-1L] + u[-length(u)]) / 2, u[length(u)] + .Machine$double.eps)
rows <- lapply(cuts, function(cut) {
pred <- if (direction == "higher") score >= cut else score <= cut
tp <- sum(pred & yy == 1L)
fn <- sum(!pred & yy == 1L)
tn <- sum(!pred & yy == 0L)
fp <- sum(pred & yy == 0L)
sens <- tp / (tp + fn)
spec <- tn / (tn + fp)
ppv <- if (tp + fp > 0) tp / (tp + fp) else NA_real_
npv <- if (tn + fn > 0) tn / (tn + fn) else NA_real_
lrpos <- if (1 - spec > 0) sens / (1 - spec) else Inf
lrneg <- if (spec > 0) (1 - sens) / spec else Inf
accuracy <- (tp + tn) / (tp + tn + fp + fn)
dor <- if (is.finite(lrpos) && is.finite(lrneg) && lrneg > 0) lrpos / lrneg else Inf
c(cutoff = cut, sensitivity = sens, specificity = spec, ppv = ppv, npv = npv, lr_pos = lrpos, lr_neg = lrneg, accuracy = accuracy, dor = dor, youden = sens + spec - 1, tp = tp, fp = fp, tn = tn, fn = fn)
})
curve <- as.data.frame(do.call(rbind, rows), stringsAsFactors = FALSE)
best <- curve[which.max(curve$youden), , drop = FALSE]
q1 <- auc / (2 - auc)
q2 <- 2 * auc^2 / (1 + auc)
se <- sqrt((auc * (1 - auc) + (n1 - 1) * (q1 - auc^2) + (n0 - 1) * (q2 - auc^2)) / (n1 * n0))
z <- stats::qnorm(1 - (1 - conf) / 2)
ci <- pmax(0, pmin(1, auc + c(-1, 1) * z * se))
auc_z <- if (is.finite(se) && se > 0) (auc - 0.5) / se else NA_real_
auc_p <- if (is.finite(auc_z)) 2 * stats::pnorm(abs(auc_z), lower.tail = FALSE) else NA_real_
list(
event = as.character(event),
nonevent = as.character(nonevent),
direction = direction,
n_event = n1,
n_nonevent = n0,
auc = auc,
se = se,
p = auc_p,
ci = stats::setNames(ci, c("lower", "upper")),
best = best,
curve = curve[order(1 - curve$specificity, curve$sensitivity), , drop = FALSE]
)
}
.r4vn_ts_htmt <- function(R, factor_map) {
if (length(factor_map) < 2L) return(data.frame())
out <- list()
nms <- names(factor_map)
z <- 0L
for (i in seq_len(length(nms) - 1L)) {
for (j in (i + 1L):length(nms)) {
a <- factor_map[[i]]
b <- factor_map[[j]]
cross <- mean(abs(R[a, b, drop = FALSE]), na.rm = TRUE)
wa <- if (length(a) > 1L) mean(abs(R[a, a, drop = FALSE][lower.tri(R[a, a, drop = FALSE])]), na.rm = TRUE) else NA_real_
wb <- if (length(b) > 1L) mean(abs(R[b, b, drop = FALSE][lower.tri(R[b, b, drop = FALSE])]), na.rm = TRUE) else NA_real_
htmt <- cross / sqrt(wa * wb)
z <- z + 1L
out[[z]] <- data.frame(Factor1 = nms[i], Factor2 = nms[j], HTMT = htmt, stringsAsFactors = FALSE)
}
}
do.call(rbind, out)
}
.r4vn_ts_cfa <- function(X, original_data, factor_map, ordered, estimator, missing, cfa_group_name, modification, modification_min) {
if (!requireNamespace("lavaan", quietly = TRUE)) {
.r4vn_ts_stop("CFA requires the optional package `lavaan`. Install it with install.packages(\"lavaan\") and add lavaan to Suggests in DESCRIPTION.")
}
if (!length(factor_map)) .r4vn_ts_stop("`factor` must be declared when `cfa = TRUE`.")
item_labels <- colnames(X)
item_model_names <- make.names(item_labels, unique = TRUE)
item_to_model <- stats::setNames(item_model_names, item_labels)
model_to_item <- stats::setNames(item_labels, item_model_names)
factor_labels <- names(factor_map)
factor_model_names <- make.names(factor_labels, unique = TRUE)
factor_to_model <- stats::setNames(factor_model_names, factor_labels)
model_to_factor <- stats::setNames(factor_labels, factor_model_names)
factor_map_model <- lapply(factor_map, function(items) unname(item_to_model[items]))
names(factor_map_model) <- factor_model_names
model <- paste(vapply(names(factor_map_model), function(nm) paste0(nm, " =~ ", paste(factor_map_model[[nm]], collapse = " + ")), character(1)), collapse = "\n")
item_display <- stats::setNames(vapply(item_labels, function(v) .r4vn_ts_label(original_data[[v]], v), character(1)), item_labels)
display_model <- paste(vapply(factor_labels, function(nm) paste0(nm, " =~ ", paste(item_display[factor_map[[nm]]], collapse = " + ")), character(1)), collapse = "\n")
cfa_data <- as.data.frame(X, check.names = FALSE)
names(cfa_data) <- item_model_names
group_model_name <- NULL
if (!is.null(cfa_group_name)) {
group_model_name <- make.unique(c(names(cfa_data), ".r4vn_cfa_group"), sep = "_")
group_model_name <- tail(group_model_name, 1L)
cfa_data[[group_model_name]] <- original_data[[cfa_group_name]]
}
ordered_labels <- character()
if (isTRUE(ordered)) ordered_labels <- item_labels
else if (inherits(ordered, "r4vn_vars")) ordered_labels <- intersect(as.character(ordered$variable), item_labels)
else if (is.character(ordered)) ordered_labels <- intersect(ordered, item_labels)
ordered_names <- unname(item_to_model[ordered_labels])
estimator_use <- estimator
if (identical(estimator, "auto")) estimator_use <- if (length(ordered_names)) "WLSMV" else "MLR"
args <- list(model = model, data = cfa_data, estimator = estimator_use, std.lv = TRUE)
if (length(ordered_names)) args$ordered <- ordered_names
if (!is.null(group_model_name)) args$group <- group_model_name
if (!length(ordered_names) && !is.null(missing) && nzchar(missing)) args$missing <- missing
fit <- try(do.call(lavaan::cfa, args), silent = TRUE)
if (inherits(fit, "try-error")) .r4vn_ts_stop("CFA failed: ", as.character(fit))
measures_wanted <- c("chisq", "df", "pvalue", "cfi", "tli", "rmsea", "rmsea.ci.lower", "rmsea.ci.upper", "srmr", "aic", "bic")
measures <- try(lavaan::fitMeasures(fit, measures_wanted), silent = TRUE)
if (inherits(measures, "try-error")) measures <- stats::setNames(rep(NA_real_, length(measures_wanted)), measures_wanted)
std <- lavaan::standardizedSolution(fit)
loading_index <- std$op == "=~"
loading_columns <- c("lhs", "rhs", "est.std", "se", "z", "pvalue", "ci.lower", "ci.upper")
if ("group" %in% names(std)) loading_columns <- c(loading_columns, "group")
load <- std[loading_index, loading_columns, drop = FALSE]
names(load)[seq_len(8L)] <- c("Factor", "Item", "Loading", "SE", "z", "p", "CI_lower", "CI_upper")
group_labels <- NULL
if ("group" %in% names(load)) {
group_labels <- try(lavaan::lavInspect(fit, "group.label"), silent = TRUE)
if (inherits(group_labels, "try-error") || !length(group_labels)) group_labels <- sort(unique(load$group))
load$Group <- as.character(group_labels[load$group])
load$group <- NULL
}
r2 <- try(lavaan::inspect(fit, "r2"), silent = TRUE)
if (!inherits(r2, "try-error")) {
if (is.list(r2) && "Group" %in% names(load)) {
group_index <- match(load$Group, as.character(group_labels))
load$R2 <- vapply(seq_len(nrow(load)), function(i) unname(r2[[group_index[i]]][load$Item[i]]), numeric(1))
} else {
if (is.list(r2)) r2 <- r2[[1L]]
load$R2 <- unname(r2[load$Item])
}
} else load$R2 <- NA_real_
load$Factor <- unname(model_to_factor[load$Factor])
load$Item <- unname(model_to_item[load$Item])
load$`Item label` <- vapply(load$Item, function(v) .r4vn_ts_label(original_data[[v]], v), character(1))
load <- load[, c(if ("Group" %in% names(load)) "Group", "Factor", "Item label", "Item",
setdiff(names(load), c("Group", "Factor", "Item label", "Item"))), drop = FALSE]
if ("Group" %in% names(load)) load <- load[, c("Group", setdiff(names(load), "Group")), drop = FALSE]
reliability_groups <- if ("Group" %in% names(load)) unique(load$Group) else NA_character_
reliability <- do.call(rbind, lapply(reliability_groups, function(gr) {
zgroup <- if (is.na(gr)) load else load[load$Group == gr, , drop = FALSE]
do.call(rbind, lapply(factor_labels, function(nm) {
z <- zgroup[zgroup$Factor == nm, , drop = FALSE]
lam <- z$Loading
theta <- pmax(0, 1 - lam^2)
cr <- sum(lam)^2 / (sum(lam)^2 + sum(theta))
ave <- sum(lam^2) / (sum(lam^2) + sum(theta))
out <- data.frame(Factor = nm, CompositeReliability = cr, AVE = ave, SqrtAVE = sqrt(ave), stringsAsFactors = FALSE)
if (!is.na(gr)) out <- data.frame(Group = gr, out, check.names = FALSE, stringsAsFactors = FALSE)
out
}))
}))
factor_cor_columns <- c("lhs", "rhs", "est.std", "pvalue")
if ("group" %in% names(std)) factor_cor_columns <- c(factor_cor_columns, "group")
factor_cor <- std[std$op == "~~" & std$lhs != std$rhs & std$lhs %in% factor_model_names & std$rhs %in% factor_model_names, factor_cor_columns, drop = FALSE]
if (nrow(factor_cor)) {
names(factor_cor)[seq_len(4L)] <- c("Factor1", "Factor2", "Correlation", "p")
factor_cor$Factor1 <- unname(model_to_factor[factor_cor$Factor1])
factor_cor$Factor2 <- unname(model_to_factor[factor_cor$Factor2])
if ("group" %in% names(factor_cor)) {
factor_cor$Group <- as.character(group_labels[factor_cor$group])
factor_cor$group <- NULL
factor_cor <- factor_cor[, c("Group", setdiff(names(factor_cor), "Group")), drop = FALSE]
}
}
R <- .r4vn_ts_cor(X, "pearson", "pairwise")
htmt <- .r4vn_ts_htmt(R, factor_map)
mi <- data.frame()
if (isTRUE(modification)) {
mi0 <- try(lavaan::modindices(fit, sort. = TRUE), silent = TRUE)
if (!inherits(mi0, "try-error")) {
mi0 <- mi0[is.finite(mi0$mi) & mi0$mi >= modification_min, c("lhs", "op", "rhs", "mi", "epc", "sepc.all"), drop = FALSE]
if (nrow(mi0) > 30L) mi0 <- mi0[seq_len(30L), , drop = FALSE]
names(mi0) <- c("Left", "Operator", "Right", "MI", "EPC", "StandardizedEPC")
mi0$Left <- ifelse(mi0$Left %in% names(model_to_factor), unname(model_to_factor[mi0$Left]),
ifelse(mi0$Left %in% names(model_to_item), unname(model_to_item[mi0$Left]), mi0$Left))
mi0$Right <- ifelse(mi0$Right %in% names(model_to_factor), unname(model_to_factor[mi0$Right]),
ifelse(mi0$Right %in% names(model_to_item), unname(model_to_item[mi0$Right]), mi0$Right))
mi <- mi0
}
}
list(
fit = fit,
model = display_model,
model_syntax = model,
estimator = estimator_use,
ordered = ordered_labels,
group = cfa_group_name,
measures = measures,
loadings = load,
reliability = reliability,
factor_correlations = factor_cor,
htmt = htmt,
modification = mi
)
}
.r4vn_ts_bind_fill <- function(tables) {
tables <- tables[vapply(tables, function(x) is.data.frame(x) && nrow(x) > 0L, logical(1))]
if (!length(tables)) return(data.frame())
all_names <- unique(unlist(lapply(tables, names), use.names = FALSE))
out <- lapply(names(tables), function(section) {
x <- tables[[section]]
miss <- setdiff(all_names, names(x))
for (nm in miss) x[[nm]] <- ""
x <- x[, all_names, drop = FALSE]
data.frame(Section = section, x, check.names = FALSE, stringsAsFactors = FALSE)
})
do.call(rbind, out)
}
.r4vn_ts_transpose_display <- function(x) {
if (!is.data.frame(x) || !nrow(x) || !ncol(x)) return(x)
# A single result is clearest as Statistic | Value.
if (nrow(x) == 1L) {
return(data.frame(
Statistic = names(x),
Value = unlist(x[1L, , drop = FALSE], use.names = FALSE),
check.names = FALSE, stringsAsFactors = FALSE
))
}
# Prefer a meaningful row identifier so scales/models become columns.
identifiers <- c("Scale", "Model", "Factor", "Group", "Outcome", "Item", "Variable")
id <- identifiers[identifiers %in% names(x)]
id <- id[vapply(id, function(nm) {
z <- as.character(x[[nm]])
all(!is.na(z) & nzchar(z)) && !anyDuplicated(z)
}, logical(1))]
id <- if (length(id)) id[1L] else NULL
if (is.null(id)) {
labels <- paste("Result", seq_len(nrow(x)))
value_names <- names(x)
} else {
labels <- make.unique(as.character(x[[id]]))
# `Analysis` repeats the section caption and need not consume a table row.
value_names <- setdiff(names(x), c(id, "Analysis"))
}
out <- data.frame(Statistic = value_names, check.names = FALSE,
stringsAsFactors = FALSE)
for (i in seq_len(nrow(x))) {
out[[labels[i]]] <- unlist(x[i, value_names, drop = FALSE], use.names = FALSE)
}
out
}
.r4vn_ts_html_table <- function(df, caption = NULL, digits = 3L,
p_digits = digits, transpose = FALSE) {
if (!is.data.frame(df) || !nrow(df)) return("")
x <- df
for (j in seq_along(x)) {
if (is.numeric(x[[j]])) {
is_p <- grepl("(^p$|(^|[ _.])p($|[ _.])|pvalue|p-value|p value)", tolower(names(x)[j]))
x[[j]] <- if (is_p) .r4vn_ts_p(x[[j]], p_digits) else .r4vn_ts_num(x[[j]], digits)
} else x[[j]] <- as.character(x[[j]])
}
if (isTRUE(transpose)) x <- .r4vn_ts_transpose_display(x)
head <- paste0("<tr>", paste0("<th>", .r4vn_ts_escape(names(x)), "</th>", collapse = ""), "</tr>")
body <- vapply(seq_len(nrow(x)), function(i) {
paste0("<tr>", paste0("<td>", .r4vn_ts_escape(unlist(x[i, , drop = FALSE], use.names = FALSE)), "</td>", collapse = ""), "</tr>")
}, character(1))
cap <- if (!is.null(caption) && nzchar(caption)) paste0("<h3>", .r4vn_ts_escape(caption), "</h3>") else ""
paste0("<div class=\"ts-section\">", cap, "<table><thead>", head, "</thead><tbody>", paste(body, collapse = ""), "</tbody></table></div>")
}
.r4vn_ts_svg_line <- function(x, ys, labels, width = 720, height = 330, xlab = "", ylab = "") {
if (!length(x) || !length(ys)) return("")
ys <- lapply(ys, as.numeric)
xr <- range(x, finite = TRUE)
yr <- range(unlist(ys), finite = TRUE)
if (!all(is.finite(c(xr, yr)))) return("")
if (diff(xr) == 0) xr <- xr + c(-0.5, 0.5)
if (diff(yr) == 0) yr <- yr + c(-0.5, 0.5)
px <- function(z) 55 + (z - xr[1]) / diff(xr) * (width - 80)
py <- function(z) height - 40 - (z - yr[1]) / diff(yr) * (height - 70)
paths <- vapply(seq_along(ys), function(i) {
y <- ys[[i]]
ok <- is.finite(x) & is.finite(y)
pts <- paste0(round(px(x[ok]), 1), ",", round(py(y[ok]), 1), collapse = " ")
dash <- if (i == 1L) "" else " stroke-dasharray=\"6,4\""
paste0("<polyline fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"", dash, " points=\"", pts, "\"/>")
}, character(1))
legend <- paste0("<text x=\"", 60 + (seq_along(labels) - 1) * 160, "\" y=\"20\" font-size=\"12\">", .r4vn_ts_escape(labels), "</text>", collapse = "")
paste0(
"<svg class=\"ts-svg\" viewBox=\"0 0 ", width, " ", height, "\" role=\"img\">",
"<line x1=\"55\" y1=\"", height - 40, "\" x2=\"", width - 25, "\" y2=\"", height - 40, "\" stroke=\"currentColor\"/>",
"<line x1=\"55\" y1=\"30\" x2=\"55\" y2=\"", height - 40, "\" stroke=\"currentColor\"/>",
paste(paths, collapse = ""), legend,
"<text x=\"", width / 2, "\" y=\"", height - 8, "\" text-anchor=\"middle\" font-size=\"12\">", .r4vn_ts_escape(xlab), "</text>",
"<text x=\"15\" y=\"", height / 2, "\" transform=\"rotate(-90 15 ", height / 2, ")\" text-anchor=\"middle\" font-size=\"12\">", .r4vn_ts_escape(ylab), "</text>",
"</svg>"
)
}
.r4vn_ts_clean_factor_name <- function(x) {
z <- gsub("[^[:alnum:]_]+", "_", x)
z <- gsub("^_+|_+$", "", z)
ifelse(nzchar(z), z, "subscale")
}
.r4vn_ts_fisher_cor <- function(x, y, method = "pearson", conf = 0.95) {
ok <- is.finite(x) & is.finite(y)
n <- sum(ok)
if (n < 4L) return(c(N = n, r = NA_real_, lower = NA_real_, upper = NA_real_, p = NA_real_))
test <- try(suppressWarnings(stats::cor.test(x[ok], y[ok], method = method,
exact = FALSE)), silent = TRUE)
if (inherits(test, "try-error")) return(c(N = n, r = NA_real_, lower = NA_real_, upper = NA_real_, p = NA_real_))
r <- unname(test$estimate)
ci <- c(NA_real_, NA_real_)
if (is.finite(r) && abs(r) < 1 && n > 3L) {
z <- atanh(r)
q <- stats::qnorm(1 - (1 - conf) / 2)
ci <- tanh(z + c(-1, 1) * q / sqrt(n - 3))
} else if (is.finite(r)) ci <- rep(r, 2L)
c(N = n, r = r, lower = ci[1L], upper = ci[2L], p = test$p.value)
}
.r4vn_ts_external_validity <- function(scores, data, variables, hypothesis,
method = "pearson", conf = 0.95,
type = "Convergent") {
if (!length(variables)) return(data.frame())
bad <- setdiff(variables, names(data))
if (length(bad)) .r4vn_ts_stop(type, " validity variables not found in data: ", paste(bad, collapse = ", "))
out <- list()
z <- 0L
for (sn in names(scores)) {
for (v in variables) {
y <- .r4vn_ts_numeric_item(data[[v]], v)$value
est <- .r4vn_ts_fisher_cor(scores[[sn]], y, method, conf)
z <- z + 1L
met <- if (is.null(hypothesis)) NA else {
h <- hypothesis
if (length(h) > 1L && !is.null(names(h)) && v %in% names(h)) h <- h[[v]]
h <- as.numeric(h[1L])
if (!is.finite(h)) NA else if (type == "Convergent") abs(est["r"]) >= abs(h) else abs(est["r"]) <= abs(h)
}
out[[z]] <- data.frame(
Scale = sn,
Variable = .r4vn_ts_label(data[[v]], v),
`Variable name` = v,
Type = type,
N = unname(est["N"]),
Method = tools::toTitleCase(method),
Correlation = unname(est["r"]),
`CI lower` = unname(est["lower"]),
`CI upper` = unname(est["upper"]),
p = unname(est["p"]),
`Hypothesis threshold` = if (is.null(hypothesis)) NA_real_ else suppressWarnings(as.numeric(hypothesis[1L])),
`Hypothesis met` = if (is.na(met)) "Not specified" else if (met) "Yes" else "No",
check.names = FALSE, stringsAsFactors = FALSE
)
}
}
do.call(rbind, out)
}
.r4vn_ts_icc <- function(M, conf = 0.95) {
M <- as.matrix(M)
storage.mode(M) <- "double"
M <- M[stats::complete.cases(M), , drop = FALSE]
n <- nrow(M); k <- ncol(M)
empty <- c(N = n, Raters = k, ICC_A1 = NA, ICC_Ak = NA, ICC_C1 = NA, ICC_Ck = NA,
lower = NA, upper = NA, SEM = NA, MDC95 = NA)
if (n < 3L || k < 2L) return(empty)
grand <- mean(M)
row_mean <- rowMeans(M)
col_mean <- colMeans(M)
ss_row <- k * sum((row_mean - grand)^2)
ss_col <- n * sum((col_mean - grand)^2)
ss_error <- sum((M - row_mean - rep(col_mean, each = n) + grand)^2)
msr <- ss_row / (n - 1)
msc <- ss_col / (k - 1)
mse <- ss_error / ((n - 1) * (k - 1))
icc_a1 <- (msr - mse) / (msr + (k - 1) * mse + k * (msc - mse) / n)
icc_ak <- (msr - mse) / (msr + (msc - mse) / n)
icc_c1 <- (msr - mse) / (msr + (k - 1) * mse)
icc_ck <- (msr - mse) / msr
f <- if (mse > 0) msr / mse else Inf
a <- 1 - conf
lower <- if (is.finite(f) && f > 0) (f / stats::qf(1 - a / 2, n - 1, (n - 1) * (k - 1)) - 1) /
(f / stats::qf(1 - a / 2, n - 1, (n - 1) * (k - 1)) + k - 1) else NA_real_
upper <- if (is.finite(f) && f > 0) (f / stats::qf(a / 2, n - 1, (n - 1) * (k - 1)) - 1) /
(f / stats::qf(a / 2, n - 1, (n - 1) * (k - 1)) + k - 1) else NA_real_
sem <- sqrt(max(mse, 0))
c(N = n, Raters = k, ICC_A1 = icc_a1, ICC_Ak = icc_ak,
ICC_C1 = icc_c1, ICC_Ck = icc_ck, lower = lower, upper = upper,
SEM = sem, MDC95 = 1.96 * sqrt(2) * sem)
}
.r4vn_ts_kappa <- function(M, weighted = TRUE, conf = 0.95) {
M <- as.data.frame(M, check.names = FALSE)
M <- M[stats::complete.cases(M), , drop = FALSE]
n <- nrow(M); k <- ncol(M)
if (n < 3L || k < 2L) return(c(N = n, Raters = k, Agreement = NA, Kappa = NA, lower = NA, upper = NA))
lev <- sort(unique(unlist(lapply(M, as.character), use.names = FALSE)))
Z <- sapply(M, function(x) match(as.character(x), lev))
if (k == 2L) {
tab <- table(factor(Z[, 1], levels = seq_along(lev)), factor(Z[, 2], levels = seq_along(lev)))
p <- tab / sum(tab)
if (weighted && length(lev) > 2L) {
w <- 1 - outer(seq_along(lev), seq_along(lev), function(a, b) (a - b)^2 / (length(lev) - 1)^2)
} else w <- diag(length(lev))
po <- sum(w * p)
pe <- sum(w * outer(rowSums(p), colSums(p)))
kap <- if (pe < 1) (po - pe) / (1 - pe) else NA_real_
agreement <- sum(diag(tab)) / sum(tab)
} else {
counts <- t(apply(Z, 1L, function(z) tabulate(z, nbins = length(lev))))
pi <- colSums(counts) / (n * k)
pj <- (rowSums(counts^2) - k) / (k * (k - 1))
po <- mean(pj); pe <- sum(pi^2)
kap <- if (pe < 1) (po - pe) / (1 - pe) else NA_real_
agreement <- mean(apply(counts, 1L, max) == k)
}
se <- if (is.finite(kap)) sqrt(max(po * (1 - po), 0) / (n * max((1 - pe)^2, .Machine$double.eps))) else NA_real_
q <- stats::qnorm(1 - (1 - conf) / 2)
ci <- if (is.finite(se)) pmax(-1, pmin(1, kap + c(-1, 1) * q * se)) else c(NA, NA)
c(N = n, Raters = k, Agreement = agreement, Kappa = kap, lower = ci[1L], upper = ci[2L])
}
.r4vn_ts_agreement <- function(first, second, scale_name, type, conf = 0.95) {
ok <- is.finite(first) & is.finite(second)
x <- first[ok]; y <- second[ok]
icc <- .r4vn_ts_icc(cbind(x, y), conf)
cor_p <- .r4vn_ts_fisher_cor(x, y, "pearson", conf)
cor_s <- .r4vn_ts_fisher_cor(x, y, "spearman", conf)
d <- y - x
md <- if (length(d)) mean(d) else NA_real_
sdd <- if (length(d) > 1L) stats::sd(d) else NA_real_
se_md <- sdd / sqrt(length(d))
q <- if (length(d) > 1L) stats::qt(1 - (1 - conf) / 2, length(d) - 1L) else NA_real_
tt <- if (length(d) > 1L) try(stats::t.test(y, x, paired = TRUE, conf.level = conf), silent = TRUE) else NULL
wp <- if (length(d) > 1L) try(suppressWarnings(stats::wilcox.test(y, x, paired = TRUE, exact = FALSE)), silent = TRUE) else NULL
pooled <- sqrt((stats::var(x) + stats::var(y)) / 2)
grand_mean <- mean(c(x,y))
cv_within <- if (is.finite(grand_mean) && abs(grand_mean) > .Machine$double.eps)
100*sqrt(mean(d^2)/2)/abs(grand_mean) else NA_real_
tab <- data.frame(
Analysis = type, Scale = scale_name, N = length(d),
`Time/form 1 mean` = mean(x), `Time/form 2 mean` = mean(y),
`Mean difference` = md, `Difference CI lower` = md - q * se_md,
`Difference CI upper` = md + q * se_md,
`Paired t p` = if (!is.null(tt) && !inherits(tt, "try-error")) tt$p.value else NA_real_,
`Wilcoxon p` = if (!is.null(wp) && !inherits(wp, "try-error")) wp$p.value else NA_real_,
`Pearson r` = unname(cor_p["r"]), `Spearman rho` = unname(cor_s["r"]),
`ICC absolute single` = unname(icc["ICC_A1"]),
`ICC CI lower` = unname(icc["lower"]), `ICC CI upper` = unname(icc["upper"]),
SEM = unname(icc["SEM"]), MDC95 = unname(icc["MDC95"]),
`Within-subject CV %` = cv_within,
`Cohen dz` = if (is.finite(sdd) && sdd > 0) md / sdd else NA_real_,
`Standardized mean difference` = if (is.finite(pooled) && pooled > 0) md / pooled else NA_real_,
`Bland-Altman lower` = md - 1.96 * sdd, `Bland-Altman upper` = md + 1.96 * sdd,
check.names = FALSE, stringsAsFactors = FALSE
)
list(table = tab, first = x, second = y, difference = d)
}
.r4vn_ts_content_validity <- function(content, cutoff = 3, max_score = 4, conf = 0.95) {
if (is.null(content)) return(NULL)
if (!is.data.frame(content) && !is.matrix(content)) .r4vn_ts_stop("`content` must be an expert-by-item data frame or numeric matrix.")
labs <- if (is.data.frame(content)) vapply(seq_along(content), function(j) .r4vn_ts_label(content[[j]], names(content)[j]), character(1)) else colnames(content)
M <- as.matrix(content)
suppressWarnings(storage.mode(M) <- "double")
if (!ncol(M) || nrow(M) < 2L || !any(is.finite(M)) || any(!is.na(M) & !is.finite(M))) .r4vn_ts_stop("`content` must contain numeric expert relevance ratings.")
if (!is.finite(cutoff) || !is.finite(max_score) || cutoff > max_score) .r4vn_ts_stop("`content_cutoff` and `content_max` must be finite, with cutoff not exceeding the maximum.")
if (any(M > max_score, na.rm=TRUE)) .r4vn_ts_stop("Content-validity ratings exceed `content_max`.")
if (is.null(colnames(M))) colnames(M) <- paste0("Item", seq_len(ncol(M)))
if (is.null(labs) || length(labs) != ncol(M)) labs <- colnames(M)
rows <- lapply(seq_len(ncol(M)), function(j) {
z <- M[, j]; z <- z[is.finite(z)]
n <- length(z); agree <- sum(z >= cutoff)
icvi <- if (n) agree / n else NA_real_
ci <- if (n) stats::binom.test(agree,n,conf.level=conf)$conf.int else c(NA_real_,NA_real_)
pc <- if (n) choose(n, agree) * 0.5^n else NA_real_
kstar <- if (is.finite(icvi) && is.finite(pc) && pc < 1) (icvi - pc) / (1 - pc) else NA_real_
data.frame(Item = labs[j], Variable = colnames(M)[j], Experts = n,
`Relevant ratings` = agree, `I-CVI` = icvi,
`I-CVI CI lower` = ci[1L], `I-CVI CI upper` = ci[2L],
`Chance agreement` = pc, `Modified kappa` = kstar,
Interpretation = if (!is.finite(kstar)) "" else if (kstar >= .74) "Excellent" else if (kstar >= .60) "Good" else if (kstar >= .40) "Fair" else "Poor",
check.names = FALSE, stringsAsFactors = FALSE)
})
item <- do.call(rbind, rows)
summary <- data.frame(
Statistic = c("S-CVI/Ave", "S-CVI/UA", "Mean expert relevance score", "Rating cutoff", "Maximum rating"),
Value = c(mean(item$`I-CVI`, na.rm = TRUE), mean(item$`I-CVI` == 1, na.rm = TRUE),
mean(M, na.rm = TRUE), cutoff, max_score), stringsAsFactors = FALSE
)
list(item = item, summary = summary, data = M)
}
.r4vn_ts_known_groups <- function(scores, group, group_label, conf = 0.95) {
g <- droplevels(as.factor(group))
if (nlevels(g) < 2L) .r4vn_ts_stop("`known_groups` must contain at least two observed groups.")
desc <- list(); tests <- list()
for (sn in names(scores)) {
x <- scores[[sn]]
desc[[sn]] <- do.call(rbind, lapply(levels(g), function(lv) {
z <- x[g == lv & is.finite(x)]
data.frame(Scale = sn, Group = lv, N = length(z), Mean = mean(z), SD = stats::sd(z),
Median = stats::median(z), Q1 = stats::quantile(z, .25), Q3 = stats::quantile(z, .75),
check.names = FALSE, stringsAsFactors = FALSE)
}))
ok <- is.finite(x) & !is.na(g)
if (nlevels(g) == 2L) {
tt <- try(stats::t.test(x[ok] ~ g[ok], conf.level = conf), silent = TRUE)
wt <- try(suppressWarnings(stats::wilcox.test(x[ok] ~ g[ok], exact = FALSE)), silent = TRUE)
lev <- levels(g); a <- x[ok & g == lev[1L]]; b <- x[ok & g == lev[2L]]
sp <- sqrt(((length(a)-1)*stats::var(a)+(length(b)-1)*stats::var(b))/(length(a)+length(b)-2))
d <- (mean(b)-mean(a))/sp
j <- 1 - 3/(4*(length(a)+length(b))-9)
se_d <- sqrt((length(a)+length(b))/(length(a)*length(b)) + d^2/(2*(length(a)+length(b)-2)))
qn <- stats::qnorm(1-(1-conf)/2)
d_ci <- d+c(-1,1)*qn*se_d
md_ci <- if (!inherits(tt,"try-error")) -rev(unname(tt$conf.int)) else c(NA_real_,NA_real_)
tests[[sn]] <- data.frame(Scale = sn, Variable = group_label,
`Group 1`=lev[1L], `Group 2`=lev[2L], Test = "Two-group comparison",
`Parametric p` = if (!inherits(tt, "try-error")) tt$p.value else NA_real_,
`Nonparametric p` = if (!inherits(wt, "try-error")) wt$p.value else NA_real_,
`Mean difference (second-first)` = mean(b)-mean(a),
`Mean difference CI lower`=md_ci[1L], `Mean difference CI upper`=md_ci[2L],
`Cohen d` = d, `Cohen d CI lower`=d_ci[1L], `Cohen d CI upper`=d_ci[2L], `Hedges g` = j*d,
`Eta squared` = NA_real_, check.names = FALSE, stringsAsFactors = FALSE)
} else {
fit <- stats::lm(x[ok] ~ g[ok]); av <- stats::anova(fit)
kw <- stats::kruskal.test(x[ok] ~ g[ok])
eta <- av$`Sum Sq`[1L]/sum(av$`Sum Sq`)
tests[[sn]] <- data.frame(Scale = sn, Variable = group_label,
`Group 1`=NA_character_, `Group 2`=NA_character_, Test = "Multiple-group comparison",
`Parametric p` = av$`Pr(>F)`[1L], `Nonparametric p` = kw$p.value,
`Mean difference (second-first)` = NA_real_,
`Mean difference CI lower`=NA_real_, `Mean difference CI upper`=NA_real_,
`Cohen d` = NA_real_, `Cohen d CI lower`=NA_real_, `Cohen d CI upper`=NA_real_, `Hedges g` = NA_real_,
`Eta squared` = eta, check.names = FALSE, stringsAsFactors = FALSE)
}
}
list(descriptive = do.call(rbind, desc), tests = do.call(rbind, tests), group = g, scores = scores)
}
.r4vn_ts_responsiveness <- function(before, after, conf = 0.95) {
out <- list(); details <- list()
for (sn in intersect(names(before), names(after))) {
a <- before[[sn]]; b <- after[[sn]]
z <- .r4vn_ts_agreement(a, b, sn, "Responsiveness", conf)
d <- z$difference
base_sd <- stats::sd(z$first)
change_sd <- stats::sd(d)
z$table$`Effect size` <- if (is.finite(base_sd) && base_sd > 0) mean(d)/base_sd else NA_real_
z$table$SRM <- if (is.finite(change_sd) && change_sd > 0) mean(d)/change_sd else NA_real_
out[[sn]] <- z$table
details[[sn]] <- z
}
list(table = do.call(rbind, out), details = details)
}
.r4vn_ts_invariance <- function(X, original_data, factor_map, group_name,
ordered, estimator, missing, levels = c("configural", "metric", "scalar", "strict")) {
if (is.null(group_name) || !nzchar(group_name)) .r4vn_ts_stop("Measurement invariance requires `cfa_group`.")
if (!requireNamespace("lavaan", quietly = TRUE)) .r4vn_ts_stop("Measurement invariance requires the optional package `lavaan`.")
levels <- match.arg(levels, several.ok = TRUE)
levels <- c("configural", intersect(c("metric", "scalar", "strict"), levels))
item_names <- colnames(X); model_items <- make.names(item_names, unique = TRUE)
item_map <- stats::setNames(model_items, item_names)
factor_names <- names(factor_map); model_factors <- make.names(factor_names, unique = TRUE)
model <- paste(vapply(seq_along(factor_map), function(i) paste0(model_factors[i], " =~ ", paste(item_map[factor_map[[i]]], collapse = " + ")), character(1)), collapse = "\n")
d <- as.data.frame(X, check.names = FALSE); names(d) <- model_items
group_var <- ".r4vn_group"; d[[group_var]] <- original_data[[group_name]]
ord <- if (isTRUE(ordered)) model_items else if (is.character(ordered)) unname(item_map[intersect(ordered, item_names)]) else character()
est <- if (identical(estimator, "auto")) if (length(ord)) "WLSMV" else "MLR" else estimator
scalar_equal <- if (length(ord)) c("loadings", "thresholds") else c("loadings", "intercepts")
equality <- list(configural = NULL, metric = "loadings", scalar = scalar_equal,
strict = c(scalar_equal, "residuals"))
fits <- list(); rows <- list()
for (lv in levels) {
args <- list(model = model, data = d, group = group_var, estimator = est, std.lv = TRUE)
if (length(ord)) args$ordered <- ord else if (!is.null(missing) && nzchar(missing)) args$missing <- missing
if (!is.null(equality[[lv]])) args$group.equal <- equality[[lv]]
fit <- try(do.call(lavaan::cfa, args), silent = TRUE)
if (inherits(fit, "try-error")) next
fits[[lv]] <- fit
fm <- lavaan::fitMeasures(fit, c("chisq", "df", "pvalue", "cfi", "tli", "rmsea", "srmr"))
rows[[lv]] <- data.frame(Model = tools::toTitleCase(lv), ChiSquare = fm["chisq"], df = fm["df"], p = fm["pvalue"],
CFI = fm["cfi"], TLI = fm["tli"], RMSEA = fm["rmsea"], SRMR = fm["srmr"], stringsAsFactors = FALSE)
}
if (!length(rows)) .r4vn_ts_stop("None of the requested measurement-invariance models converged.")
tab <- do.call(rbind, rows); rownames(tab) <- NULL
if (nrow(tab)) {
tab$`Delta CFI` <- c(NA, diff(tab$CFI)); tab$`Delta RMSEA` <- c(NA, diff(tab$RMSEA)); tab$`Delta SRMR` <- c(NA, diff(tab$SRMR))
tab$Supported <- ifelse(is.na(tab$`Delta CFI`), "Reference", ifelse(abs(tab$`Delta CFI`) <= .010 & tab$`Delta RMSEA` <= .015, "Yes", "No"))
}
list(table = tab, fits = fits, group = group_name, estimator = est)
}
.r4vn_ts_dif <- function(X, group, item_labels) {
g <- droplevels(as.factor(group))
if (nlevels(g) != 2L) .r4vn_ts_stop("Current DIF screening requires a two-level `cfa_group`.")
total <- rowMeans(X, na.rm = TRUE)
rows <- lapply(seq_along(X), function(j) {
y <- X[[j]]; ok <- is.finite(y) & is.finite(total) & !is.na(g)
if (sum(ok) < 20L) return(data.frame())
fit0 <- stats::lm(y[ok] ~ total[ok])
fit1 <- stats::lm(y[ok] ~ total[ok] + g[ok])
fit2 <- stats::lm(y[ok] ~ total[ok] * g[ok])
a01 <- stats::anova(fit0, fit1); a12 <- stats::anova(fit1, fit2)
r20 <- summary(fit0)$r.squared; r21 <- summary(fit1)$r.squared; r22 <- summary(fit2)$r.squared
data.frame(Item = item_labels[j], Variable = names(X)[j],
`Uniform DIF p` = a01$`Pr(>F)`[2L], `Nonuniform DIF p` = a12$`Pr(>F)`[2L],
`Uniform delta R2` = r21-r20, `Nonuniform delta R2` = r22-r21,
Flag = ifelse(a01$`Pr(>F)`[2L] < .01 || a12$`Pr(>F)`[2L] < .01, "Review", "No signal"),
check.names = FALSE, stringsAsFactors = FALSE)
})
do.call(rbind, rows)
}
.r4vn_ts_plot_draw <- function(spec) {
old <- graphics::par(no.readonly = TRUE); on.exit(graphics::par(old), add = TRUE)
# Use the platform sans-serif family consistently in the Plots pane and
# raster output. This also preserves Vietnamese/Unicode labels where the
# operating system provides the glyphs.
graphics::par(family = "sans")
tp <- spec$type
if (tp == "item_diagnostics") {
graphics::par(mar = c(5, max(7, min(16, max(nchar(spec$labels))*.55)), 3, 1))
m <- rbind(spec$missing, spec$floor, spec$ceiling)
graphics::barplot(m, beside = TRUE, horiz = TRUE, names.arg = spec$labels,
col = c("#6BAED6", "#74C476", "#FD8D3C"), border = NA, las = 1,
xlab = "Percent", main = spec$title)
graphics::legend("bottomright", legend = c("Missing", "Floor", "Ceiling"),
fill = c("#6BAED6", "#74C476", "#FD8D3C"), bty = "n")
} else if (tp == "item_distribution") {
graphics::par(mar = c(max(7, min(15, max(nchar(spec$labels))*.55)), 4, 3, 1))
cols <- grDevices::hcl.colors(nrow(spec$proportions), "YlOrRd", rev = TRUE)
graphics::barplot(spec$proportions, names.arg=spec$labels, las=2, col=cols,
border=NA, ylab="Proportion", main=spec$title)
graphics::legend("topright", legend=rownames(spec$proportions), fill=cols,
title="Response", bty="n", cex=.8)
} else if (tp == "reliability") {
graphics::par(mar = c(max(7, min(15, max(nchar(spec$labels))*.55)), 4, 3, 1))
ylim <- range(c(spec$item_total, spec$alpha_deleted, spec$alpha), finite = TRUE)
graphics::plot(seq_along(spec$labels), spec$item_total, type = "b", pch = 16, col = "#2C7FB8",
xaxt = "n", xlab = "", ylab = "Coefficient", ylim = ylim, main = spec$title)
graphics::axis(1, at = seq_along(spec$labels), labels = spec$labels, las = 2, cex.axis = .75)
graphics::lines(seq_along(spec$labels), spec$alpha_deleted, type = "b", pch = 17, col = "#D95F0E")
graphics::abline(h = spec$alpha, lty = 2, col = "#555555")
graphics::legend("bottomright", legend = c("Corrected item-total r", "Alpha if deleted", "Total alpha"),
col = c("#2C7FB8", "#D95F0E", "#555555"), pch = c(16,17,NA), lty = c(1,1,2), bty = "n")
} else if (tp == "correlation") {
R <- spec$matrix; nr <- nrow(R)
graphics::par(mar = c(max(7, min(15, max(nchar(spec$labels))*.55)), max(7, min(15, max(nchar(spec$labels))*.55)), 3, 2))
graphics::image(seq_len(nr), seq_len(nr), t(R[nr:1, , drop=FALSE]), zlim=c(-1,1),
col=grDevices::colorRampPalette(c("#B2182B","white","#2166AC"))(100), axes=FALSE, xlab="", ylab="", main=spec$title)
graphics::axis(1, seq_len(nr), spec$labels, las=2, cex.axis=.7)
graphics::axis(2, seq_len(nr), rev(spec$labels), las=2, cex.axis=.7)
} else if (tp == "scores") {
n <- length(spec$values); graphics::par(mfrow=c(ceiling(n/2), min(2,n)), mar=c(4,4,3,1))
for (i in seq_along(spec$values)) graphics::hist(spec$values[[i]], col="#9ECAE1", border="white", main=names(spec$values)[i], xlab="Score")
} else if (tp == "scree") {
graphics::plot(spec$x, spec$observed, type="b", pch=16, col="#1F78B4", xlab="Component", ylab="Eigenvalue", main=spec$title)
graphics::lines(spec$x, spec$random, type="b", pch=17, lty=2, col="#E31A1C")
graphics::abline(h=1, lty=3, col="#666666"); graphics::legend("topright", c("Observed","Random 95th percentile"), col=c("#1F78B4","#E31A1C"), pch=c(16,17), lty=c(1,2), bty="n")
} else if (tp == "loadings") {
L <- spec$loadings; graphics::par(mar=c(max(7,min(15,max(nchar(rownames(L)))*.55)),4,3,1))
graphics::matplot(seq_len(nrow(L)), L, type="b", pch=seq_len(ncol(L))+14, lty=1, xaxt="n", xlab="", ylab="Standardized loading", ylim=c(-1,1), main=spec$title)
graphics::axis(1, seq_len(nrow(L)), rownames(L), las=2, cex.axis=.7); graphics::abline(h=0, col="#777777")
graphics::legend("bottomright", colnames(L), col=seq_len(ncol(L)), pch=seq_len(ncol(L))+14, lty=1, bty="n")
} else if (tp == "roc") {
graphics::plot(c(0,1), c(0,1), type="n", xlab="1 - Specificity",
ylab="Sensitivity", xlim=c(0,1), ylim=c(0,1), xaxs="i", yaxs="i",
asp=1, main=spec$title)
graphics::abline(0,1,lty=2,col="#888888")
cols <- grDevices::hcl.colors(length(spec$curves), "Dark 3")
for (i in seq_along(spec$curves)) {
fpr <- pmax(0, pmin(1, 1-spec$curves[[i]]$specificity))
tpr <- pmax(0, pmin(1, spec$curves[[i]]$sensitivity))
graphics::lines(fpr, tpr, col=cols[i], lwd=2)
}
graphics::legend("bottomright", names(spec$curves), col=cols, lwd=2, bty="n")
} else if (tp == "agreement") {
graphics::par(mfrow=c(1,2), mar=c(4,4,3,1)); x<-spec$first; y<-spec$second
graphics::plot(x,y,pch=16,col=grDevices::adjustcolor("#2C7FB8",.55),xlab="First measurement",ylab="Second measurement",main=paste(spec$title,"agreement")); graphics::abline(0,1,lty=2)
avg<-(x+y)/2; d<-y-x; md<-mean(d); sdv<-stats::sd(d)
graphics::plot(avg,d,pch=16,col=grDevices::adjustcolor("#D95F0E",.55),xlab="Mean of measurements",ylab="Difference",main="Bland-Altman plot"); graphics::abline(h=c(md,md-1.96*sdv,md+1.96*sdv),lty=c(1,2,2))
} else if (tp == "known_groups") {
graphics::boxplot(spec$score ~ spec$group, col="#A1D99B", xlab=spec$group_label, ylab="Score", main=spec$title)
} else if (tp == "responsiveness") {
graphics::plot(c(1,2), range(c(spec$first,spec$second),finite=TRUE), type="n", xaxt="n", xlab="", ylab="Score", main=spec$title)
graphics::axis(1,c(1,2),c("Before","After")); graphics::segments(1,spec$first,2,spec$second,col=grDevices::adjustcolor("#3182BD",.2)); graphics::points(c(1,2),c(mean(spec$first),mean(spec$second)),pch=19,col="#CB181D",cex=1.4); graphics::lines(c(1,2),c(mean(spec$first),mean(spec$second)),col="#CB181D",lwd=3)
} else if (tp == "coefficients") {
graphics::par(mar=c(max(6,min(14,max(nchar(spec$labels))*.55)),4,3,1))
graphics::barplot(spec$values, names.arg=spec$labels, las=2, ylim=c(min(0,min(spec$values,na.rm=TRUE)),1),
col="#756BB1", border=NA, ylab="Coefficient", main=spec$title); graphics::abline(h=c(.5,.7,.9),lty=3,col="#999999")
} else if (tp == "validity_forest") {
y <- rev(seq_along(spec$estimate)); graphics::par(mar=c(4,max(8,min(18,max(nchar(spec$labels))*.5)),3,1))
xr <- range(c(spec$lower,spec$upper,0),finite=TRUE); graphics::plot(spec$estimate,y,xlim=xr,ylim=c(.5,length(y)+.5),yaxt="n",pch=19,col="#238B45",xlab="Correlation (95% CI)",ylab="",main=spec$title)
graphics::segments(spec$lower,y,spec$upper,y,col="#238B45",lwd=2); graphics::axis(2,y,spec$labels,las=2,cex.axis=.75); graphics::abline(v=0,lty=2,col="#777777")
} else if (tp == "invariance") {
d <- spec$data; keep <- is.finite(d$`Delta CFI`) | is.finite(d$`Delta RMSEA`) | is.finite(d$`Delta SRMR`)
if (!any(keep)) { graphics::plot.new(); graphics::title(main=spec$title) } else {
m <- rbind(abs(d$`Delta CFI`[keep]),abs(d$`Delta RMSEA`[keep]),abs(d$`Delta SRMR`[keep]))
graphics::barplot(m,beside=TRUE,names.arg=d$Model[keep],col=c("#3182BD","#31A354","#E6550D"),border=NA,ylab="Absolute change",main=spec$title)
graphics::abline(h=.01,lty=2,col="#555555"); graphics::legend("topleft",c("Delta CFI","Delta RMSEA","Delta SRMR"),fill=c("#3182BD","#31A354","#E6550D"),bty="n")
}
}
invisible(spec)
}
.r4vn_ts_plot_svg <- function(spec, width = 8, height = 5.5) {
path <- tempfile(fileext = ".svg")
grDevices::svg(path, width = width, height = height, onefile = TRUE,
bg = "white", family = "sans")
tryCatch(.r4vn_ts_plot_draw(spec), finally = grDevices::dev.off())
txt <- paste(readLines(path, warn = FALSE, encoding = "UTF-8"), collapse = "\n")
unlink(path)
sub("^[\\s\\S]*?(<svg)", "\\1", txt, perl = TRUE)
}
.r4vn_ts_base64 <- function(x) {
bytes <- as.integer(x)
if (!length(bytes)) return("")
alphabet <- strsplit(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
"", fixed = TRUE
)[[1L]]
padding <- (3L - length(bytes) %% 3L) %% 3L
if (padding) bytes <- c(bytes, rep.int(0L, padding))
z <- matrix(bytes, ncol = 3L, byrow = TRUE)
code <- cbind(
bitwShiftR(z[, 1L], 2L),
bitwOr(bitwShiftL(bitwAnd(z[, 1L], 3L), 4L), bitwShiftR(z[, 2L], 4L)),
bitwOr(bitwShiftL(bitwAnd(z[, 2L], 15L), 2L), bitwShiftR(z[, 3L], 6L)),
bitwAnd(z[, 3L], 63L)
)
encoded <- as.vector(t(matrix(alphabet[code + 1L], ncol = 4L)))
if (padding) encoded[(length(encoded) - padding + 1L):length(encoded)] <- "="
paste0(encoded, collapse = "")
}
.r4vn_ts_plot_png <- function(spec, width = 1152L, height = 792L,
res = 144L) {
path <- tempfile(fileext = ".png")
on.exit(unlink(path), add = TRUE)
args <- list(filename = path, width = width, height = height, units = "px",
res = res, bg = "white")
if (isTRUE(capabilities("cairo"))) args$type <- "cairo-png"
do.call(grDevices::png, args)
tryCatch(.r4vn_ts_plot_draw(spec), finally = grDevices::dev.off())
size <- file.info(path)$size
if (!is.finite(size) || size <= 0) .r4vn_ts_stop("Could not render the Viewer plot as PNG.")
bytes <- readBin(path, what = "raw", n = size)
paste0(
"<img class=\"ts-plot-image\" alt=\"",
.r4vn_ts_escape(.r4vn_ts_or(spec$title, "tabscale plot")),
"\" src=\"data:image/png;base64,", .r4vn_ts_base64(bytes), "\">"
)
}
#' Comprehensive Scale Analysis
#'
#' @description
#' `tabscale()` provides a one-command, publication-ready psychometric report.
#' It covers internal consistency, stability, equivalence, inter-rater
#' reliability, measurement error, content validity, structural validity,
#' convergent/discriminant and known-groups validity, criterion validity,
#' measurement invariance, DIF screening, and responsiveness when the required
#' data are supplied. Variable labels are used throughout whenever available.
#'
#' @usage
#' tabscale(data = NULL, vars = NULL, factor = NULL, reverse = NULL,
#' report = c("auto", "brief", "full", "custom"),
#' range = NULL, score = c("mean", "sum"), min_valid = NULL,
#' missing = c("pairwise", "complete"), cor_method = c("pearson", "spearman"),
#' reliability = TRUE, bootstrap = 0, conf = 0.95,
#' retest = NULL, parallel_form = NULL, raters = NULL,
#' rater_type = c("auto", "continuous", "categorical"),
#' content = NULL, content_cutoff = 3, content_max = 4,
#' validity = TRUE,
#' efa = NULL, efa_method = c("pa", "ml"), nfactor = NULL,
#' rotation = c("varimax", "promax", "none"), parallel_iter = 100,
#' cfa = NULL, ordered = FALSE, estimator = "auto",
#' cfa_missing = "fiml", cfa_group = NULL,
#' modification = FALSE, modification_min = 10,
#' invariance = FALSE,
#' invariance_levels = c("configural", "metric", "scalar", "strict"),
#' dif = FALSE, convergent = NULL, discriminant = NULL,
#' convergent_min = 0.50, discriminant_max = 0.30,
#' known_groups = NULL,
#' gold = NULL, event = NULL, direction = c("auto", "higher", "lower"),
#' post = NULL,
#' name = FALSE, digit = 2, p_digit = 3,
#' template = c("journal", "clean", "minimal"),
#' plot = TRUE, plot_types = "auto",
#' viewer_plot_format = c("png", "svg"),
#' append = NULL, file = NULL,
#' title = NULL, interpretation = FALSE,
#' raw = TRUE, show = TRUE, seed = NULL)
#'
#' @param data Optional data frame. When omitted, the active R4VN data frame is used.
#' @param vars Items created by `vars()`, a character vector, or a one-sided formula.
#' @param factor Optional named list defining subscales/CFA factors.
#' @param reverse Optional items to reverse-score.
#' @param report Output profile. `"auto"` runs reliability plus EFA and any
#' data-dependent modules requested by their arguments; `"brief"` omits
#' factor models; `"full"` also runs CFA when a factor map and lavaan are
#' available; `"custom"` follows the module switches exactly.
#' @param range Two numeric values giving the minimum and maximum item score.
#' @param score Calculate scale scores as the item `"mean"` or `"sum"`.
#' @param min_valid Minimum valid items. A value in `(0,1]` is treated as a proportion.
#' A named vector can define separate minima for factors and `Total`.
#' @param missing Correlation/covariance handling: pairwise or complete observations.
#' @param cor_method Pearson or Spearman item correlations.
#' @param reliability Logical; calculate reliability statistics.
#' @param bootstrap Number of nonparametric bootstrap replicates for alpha and
#' omega-total confidence intervals. Zero uses a Feldt interval for alpha.
#' @param conf Confidence level for alpha and AUC intervals.
#' @param retest Items measured again, in the same order as `vars()`, for
#' test-retest reliability, ICC, SEM, MDC, and Bland-Altman analysis.
#' @param parallel_form Items from an equivalent form, in the same order as
#' `vars()`, for parallel-form reliability.
#' @param raters Two or more variables containing ratings of the same subjects.
#' @param rater_type Treat ratings as continuous or categorical; `"auto"`
#' chooses categorical for variables with at most 10 observed levels.
#' @param content Expert-by-item matrix/data frame of content-relevance ratings.
#' @param content_cutoff Minimum rating counted as content-relevant.
#' @param content_max Maximum possible content rating, retained in the report.
#' @param validity Logical master switch for validity modules.
#' @param efa Logical or `NULL`; run EFA, KMO, Bartlett, and parallel analysis.
#' @param efa_method Principal-axis (`"pa"`) or maximum-likelihood (`"ml"`) EFA.
#' @param nfactor Number of EFA factors. When `NULL`, parallel analysis is used.
#' @param rotation EFA rotation.
#' @param parallel_iter Number of Monte Carlo samples for parallel analysis.
#' @param cfa Logical or `NULL`; run CFA using the optional `lavaan` package.
#' @param ordered Logical or character item names treated as ordinal in CFA.
#' @param estimator CFA estimator. `"auto"` uses WLSMV for ordered items and MLR otherwise.
#' @param cfa_missing Missing-data option passed to lavaan for non-ordinal CFA.
#' @param cfa_group Optional grouping variable name for multiple-group CFA.
#' @param modification Logical; include large CFA modification indices.
#' @param modification_min Minimum modification index displayed.
#' @param invariance Logical; test configural, metric, scalar, and strict
#' measurement invariance across `cfa_group`.
#' @param invariance_levels Invariance levels to fit.
#' @param dif Logical; screen uniform and non-uniform differential item
#' functioning across a two-level `cfa_group`.
#' @param convergent External variables used for convergent validity.
#' @param discriminant External variables used for discriminant validity.
#' @param convergent_min Prespecified minimum absolute convergent correlation.
#' @param discriminant_max Prespecified maximum absolute discriminant correlation.
#' @param known_groups Grouping variable for known-groups validity.
#' @param gold Optional criterion or binary gold-standard variable.
#' @param event Event level for binary gold-standard ROC analysis.
#' @param direction Whether higher or lower scores predict the event; `"auto"` chooses the direction with AUC at least 0.5.
#' @param post Post-intervention/follow-up items, in the same order as `vars()`,
#' for responsiveness (effect size and standardized response mean).
#' @param name `FALSE` does not modify data. `TRUE` creates `scale_total` and
#' subscale variables; a character value supplies the score prefix.
#' @param digit Decimal places for estimates.
#' @param p_digit Decimal places for p-values.
#' @param template HTML style.
#' @param plot Logical; create all applicable graphics in both the R Plots pane
#' and the HTML Viewer.
#' @param plot_types `"auto"`, `"all"`, or any of `"items"`, `"distributions"`,
#' `"correlation"`, `"reliability"`, `"scores"`, `"scree"`,
#' `"loadings"`, `"cfa_loadings"`, `"roc"`, `"test_retest"`,
#' `"parallel_form"`, `"inter_rater"`, `"content"`,
#' `"external_validity"`, `"known_groups"`, `"invariance"`, and
#' `"responsiveness"`. The automatic set omits secondary plots that often
#' add visual clutter: item completeness, item-response distributions,
#' item-reliability diagnostics, external-validity correlations,
#' known-groups boxplots, and individual responsiveness trajectories.
#' Their statistical tables remain in the report. Request any of these names
#' explicitly, or use `plot_types = "all"`, when the graphic is needed.
#' @param viewer_plot_format Format used to embed plots in the HTML Viewer.
#' The default `"png"` uses high-resolution, self-contained images and gives
#' consistent fonts in RStudio Viewer, Chrome, and saved HTML files. `"svg"`
#' retains vector graphics but may render text differently across browsers
#' because SVG font substitution is controlled by the local system.
#' @param append Optional previous R4VN table object or HTML file.
#' @param file Optional HTML output path.
#' @param title Optional table title.
#' @param interpretation Logical; add cautious automatic interpretation. The
#' default is `FALSE`.
#' @param raw Logical; retain numerical result components.
#' @param show Logical; open the HTML result.
#' @param seed Optional random seed used by parallel analysis. The default `NULL` does not set a seed.
#'
#' @details
#' The default `report = "auto"` produces descriptive item distributions,
#' missing/floor/ceiling effects, corrected item-total correlations, alpha with
#' 95% CI, standardized alpha, omega total, split-half coefficients, all six
#' Guttman lambdas, KMO, Bartlett's test, parallel analysis, EFA, score
#' distributions, and matching graphics. KR-20 is added for binary items.
#' Ordinal alpha, omega hierarchical, and the greatest lower bound are added
#' when the optional package `psych` is installed.
#'
#' Additional data activate stability/test-retest, parallel-form, inter-rater,
#' content, convergent, discriminant, known-groups, criterion, responsiveness,
#' measurement-invariance, and DIF sections. CFA and invariance use `lavaan`.
#' Face validity is inherently qualitative and is therefore identified in the
#' coverage table rather than assigned a spurious numeric coefficient.
#'
#' The same plot specifications are rendered in the R graphics device and in
#' the HTML Viewer. In RStudio, use the Plots pane arrows to review every graph,
#' or rerun selected graphs with `plot(result, which = "roc")`.
#'
#' @return Invisibly returns an object inheriting from `r4vn_tabscale` and
#' `r4vn_tab`. Components include `tables`, `coverage`, `descriptive`,
#' `reliability`, `scores`, `test_retest`, `parallel_form`, `inter_rater`,
#' `content_validity`, `efa`, `cfa`, `convergent_validity`,
#' `discriminant_validity`, `known_groups`, `criterion_validity`, `invariance`,
#' `dif`, `responsiveness`, `plots`, `interpretation`, and `file`.
#'
#' @seealso `vars`, `tab`, `tabmulti`, `tabexport`
#' @family R4VN tables
#'
#' @examples
#' set.seed(2026)
#' n <- 120
#' f1 <- rnorm(n)
#' f2 <- 0.35 * f1 + rnorm(n, sd = 0.94)
#' make_item <- function(z) as.integer(cut(z, quantile(z, 0:5/5),
#' include.lowest = TRUE, labels = FALSE))
#' latent <- list(0.8*f1, 0.7*f1, 0.9*f1, -0.7*f1,
#' 0.8*f2, 0.7*f2, 0.9*f2, 0.6*f2)
#' base_items <- lapply(latent, function(z) make_item(z + rnorm(n)))
#' dat <- as.data.frame(base_items)
#' names(dat) <- paste0("q", 1:8)
#' for (j in 1:8) {
#' dat[[paste0("q", j, "_retest")]] <- pmax(
#' 1,
#' pmin(
#' 5,
#' dat[[paste0("q", j)]] + sample(-1:1, n, TRUE, c(.1, .8, .1))
#' )
#' )
#' dat[[paste0("q", j, "_formb")]] <- make_item(latent[[j]] + rnorm(n))
#' dat[[paste0("q", j, "_post")]] <- pmax(1, pmin(5, dat[[paste0("q",j)]] + rbinom(n,1,.35)))
#' attr(dat[[paste0("q",j)]], "label") <- paste("Well-being item", j)
#' }
#' dat$convergent_measure <- f1 + f2 + rnorm(n, sd=.6)
#' dat$unrelated_measure <- rnorm(n)
#' dat$known_group <- factor(ifelse(f1+f2>0,"Higher expected score","Lower expected score"))
#' dat$gold <- factor(ifelse(f1 + f2 + rnorm(n) > 0, "Yes", "No"),
#' levels = c("No", "Yes"))
#' dat$rater1 <- sample(1:4,n,TRUE); dat$rater2 <- dat$rater1
#' dat$rater3 <- dat$rater1
#' dat$rater2[sample(n,30)] <- sample(1:4,30,TRUE)
#' dat$rater3[sample(n,35)] <- sample(1:4,35,TRUE)
#' attr(dat$known_group,"label") <- "Prespecified clinical group"
#' attr(dat$gold,"label") <- "Clinical gold standard"
#'
#' # 1. One-command automatic report: reliability, factorability, EFA, plots.
#' tb <- tabscale(
#' dat,
#' vars = vars(q1, q2, q3, q4, q5, q6, q7, q8),
#' factor = list(Domain1 = vars(q1, q2, q3, q4),
#' Domain2 = vars(q5, q6, q7, q8)),
#' reverse = vars(q4), range = c(1, 5),
#' nfactor = 2, parallel_iter = 10,
#' plot = FALSE, show = FALSE
#' )
#' tb$reliability_summary
#' tb$efa$loadings
#' if (interactive()) {
#' plot(tb) # all plots in the Plots pane
#' plot(tb, which = "reliability") # one selected plot
#' }
#'
#' # 2. Stability, parallel forms, inter-rater reliability, and measurement error.
#' rel <- tabscale(
#' dat, vars=vars(q1,q2,q3,q4,q5,q6,q7,q8), reverse=vars(q4), range=c(1,5),
#' retest=vars(q1_retest,q2_retest,q3_retest,q4_retest,
#' q5_retest,q6_retest,q7_retest,q8_retest),
#' parallel_form=vars(q1_formb,q2_formb,q3_formb,q4_formb,
#' q5_formb,q6_formb,q7_formb,q8_formb),
#' raters=vars(rater1,rater2,rater3),
#' plot=FALSE, show=FALSE)
#' rel$test_retest$table
#' rel$parallel_form$table
#' rel$inter_rater$table
#'
#' # 3. Convergent, discriminant, known-groups, criterion validity,
#' # responsiveness, and automatic ROC curves.
#' val <- tabscale(
#' dat, vars=vars(q1,q2,q3,q4,q5,q6,q7,q8), reverse=vars(q4), range=c(1,5),
#' convergent=vars(convergent_measure), discriminant=vars(unrelated_measure),
#' known_groups=known_group, gold=gold, event="Yes",
#' post=vars(q1_post,q2_post,q3_post,q4_post,q5_post,q6_post,q7_post,q8_post),
#' interpretation=TRUE, plot=FALSE, show=FALSE)
#' val$convergent_validity
#' val$discriminant_validity
#' val$known_groups$tests
#' val$criterion_validity$table
#' val$responsiveness$table
#'
#' # 4. Content validity: experts in rows and items in columns.
#' expert_ratings <- as.data.frame(matrix(sample(2:4, 6*8, TRUE,
#' prob=c(.10,.30,.60)), nrow=6, dimnames=list(NULL,paste0("q",1:8))))
#' content_result <- tabscale(
#' dat, vars=vars(q1,q2,q3,q4,q5,q6,q7,q8), range=c(1,5),
#' content=expert_ratings, content_cutoff=3,
#' plot=FALSE, show=FALSE)
#' content_result$content_validity$summary
#' content_result$content_validity$item
#'
#' # 5. CFA, composite reliability, AVE, HTMT, Fornell-Larcker,
#' # measurement invariance, and DIF screening.
#' if (interactive() && requireNamespace("lavaan", quietly = TRUE)) {
#' cfa_result <- tabscale(
#' dat, vars=vars(q1,q2,q3,q4,q5,q6,q7,q8),
#' factor=list(Domain1=vars(q1,q2,q3,q4), Domain2=vars(q5,q6,q7,q8)),
#' reverse=vars(q4), range=c(1,5), cfa=TRUE, ordered=TRUE,
#' cfa_group=known_group, invariance=TRUE, dif=TRUE,
#' modification=TRUE, plot=FALSE, show=FALSE)
#' cfa_result$cfa$reliability
#' cfa_result$cfa$htmt
#' cfa_result$invariance$table
#' cfa_result$dif
#' }
#' @export
tabscale <- function(data = NULL, vars = NULL, factor = NULL, reverse = NULL,
report = c("auto", "brief", "full", "custom"),
range = NULL, score = c("mean", "sum"), min_valid = NULL,
missing = c("pairwise", "complete"), cor_method = c("pearson", "spearman"),
reliability = TRUE, bootstrap = 0L, conf = 0.95,
retest = NULL, parallel_form = NULL, raters = NULL,
rater_type = c("auto", "continuous", "categorical"),
content = NULL, content_cutoff = 3, content_max = 4,
validity = TRUE,
efa = NULL, efa_method = c("pa", "ml"), nfactor = NULL,
rotation = c("varimax", "promax", "none"), parallel_iter = 100L,
cfa = NULL, ordered = FALSE, estimator = "auto",
cfa_missing = "fiml", cfa_group = NULL,
modification = FALSE, modification_min = 10,
invariance = FALSE,
invariance_levels = c("configural", "metric", "scalar", "strict"),
dif = FALSE,
convergent = NULL, discriminant = NULL,
convergent_min = 0.50, discriminant_max = 0.30,
known_groups = NULL,
gold = NULL, event = NULL, direction = c("auto", "higher", "lower"),
post = NULL,
name = FALSE, digit = 2L, p_digit = 3L,
template = c("journal", "clean", "minimal"),
plot = TRUE, plot_types = "auto",
viewer_plot_format = c("png", "svg"),
append = NULL, file = NULL, title = NULL,
interpretation = FALSE, raw = TRUE, show = TRUE, seed = NULL) {
env <- parent.frame()
data_supplied <- !missing(data)
data_expr <- substitute(data)
gold_expr <- substitute(gold)
cfa_group_expr <- substitute(cfa_group)
known_groups_expr <- substitute(known_groups)
# Allow tabscale(vars(...)) in active-data mode while preserving
# tabscale(data, vars = vars(...)) for explicit data.
if (!is.null(data) && !is.data.frame(data) && is.null(vars) && (inherits(data, "r4vn_vars") || is.character(data) || inherits(data, "formula"))) {
vars <- data
data <- NULL
data_expr <- quote(NULL)
data_supplied <- FALSE
}
data <- .r4vn_ts_get_data(data)
report <- match.arg(report)
score <- match.arg(score)
missing <- match.arg(missing)
cor_method <- match.arg(cor_method)
efa_method <- match.arg(efa_method)
rotation <- match.arg(rotation)
direction <- match.arg(direction)
rater_type <- match.arg(rater_type)
template <- match.arg(template)
viewer_plot_format <- match.arg(viewer_plot_format)
valid_plot_types <- c("items", "distributions", "correlation", "reliability", "scores", "scree",
"loadings", "cfa_loadings", "roc", "test_retest", "parallel_form", "inter_rater",
"content", "external_validity", "known_groups", "invariance", "responsiveness")
if (!identical(plot_types, "auto") && !identical(plot_types, "all")) {
plot_types <- unique(as.character(plot_types))
bad_plot_types <- setdiff(plot_types, valid_plot_types)
if (length(bad_plot_types)) .r4vn_ts_stop("Unknown `plot_types`: ", paste(bad_plot_types, collapse = ", "))
}
digit <- max(0L, as.integer(digit))
p_digit <- max(0L, as.integer(p_digit))
if (!is.finite(conf) || conf <= 0 || conf >= 1) .r4vn_ts_stop("`conf` must be between 0 and 1.")
if (is.null(efa)) efa <- report %in% c("auto", "full")
if (is.null(cfa)) {
wants_auto_cfa <- isTRUE(report == "full") && !is.null(factor)
cfa <- wants_auto_cfa && requireNamespace("lavaan", quietly = TRUE)
if (wants_auto_cfa && !isTRUE(cfa)) .r4vn_ts_warn("`report = \"full\"` could not run CFA because the optional package `lavaan` is not installed.")
}
if (report == "brief") efa <- cfa <- FALSE
validity_enabled <- isTRUE(validity)
factor_map0 <- .r4vn_ts_factor_map(factor)
item_names <- if (is.null(vars)) unique(unlist(factor_map0, use.names = FALSE)) else .r4vn_ts_names(vars, "vars")
if (!length(item_names)) .r4vn_ts_stop("No scale items were supplied.")
if (anyDuplicated(item_names)) item_names <- unique(item_names)
bad <- setdiff(item_names, names(data))
if (length(bad)) .r4vn_ts_stop("Items not found in data: ", paste(bad, collapse = ", "))
if (length(item_names) < 2L) .r4vn_ts_stop("At least two scale items are required.")
factor_map <- .r4vn_ts_factor_map(factor, item_names)
reverse_names <- .r4vn_ts_names(reverse, "reverse", allow_null = TRUE)
bad_reverse <- setdiff(reverse_names, item_names)
if (length(bad_reverse)) .r4vn_ts_stop("Reverse-coded items not found in `vars`: ", paste(bad_reverse, collapse = ", "))
if (length(reverse_names) && (is.null(range) || length(range) != 2L || any(!is.finite(range)))) {
.r4vn_ts_stop("Supply `range = c(minimum, maximum)` when reverse-scored items are used.")
}
if (!is.null(range)) {
range <- sort(as.numeric(range))
if (length(range) != 2L || any(!is.finite(range)) || range[1L] >= range[2L]) .r4vn_ts_stop("`range` must contain two increasing finite values.")
}
converted <- lapply(item_names, function(v) .r4vn_ts_numeric_item(data[[v]], v))
names(converted) <- item_names
X_raw <- as.data.frame(stats::setNames(lapply(converted, `[[`, "value"), item_names), check.names = FALSE)
X <- X_raw
if (!is.null(range)) {
outside <- vapply(X, function(z) any(!is.na(z) & (z < range[1L] | z > range[2L])), logical(1))
if (any(outside)) .r4vn_ts_warn("Values outside `range` were set to missing for: ", paste(names(outside)[outside], collapse = ", "))
for (v in names(outside)[outside]) {
X_raw[[v]][!is.na(X_raw[[v]]) & (X_raw[[v]] < range[1L] | X_raw[[v]] > range[2L])] <- NA_real_
X[[v]][!is.na(X[[v]]) & (X[[v]] < range[1L] | X[[v]] > range[2L])] <- NA_real_
}
}
if (length(reverse_names)) {
lo <- range[1L]
hi <- range[2L]
for (v in reverse_names) X[[v]] <- lo + hi - X[[v]]
}
variances <- vapply(X, stats::var, numeric(1), na.rm = TRUE)
no_variation <- names(variances)[!is.finite(variances) | variances <= 0]
if (length(no_variation)) .r4vn_ts_stop("Items with no usable variation: ", paste(no_variation, collapse = ", "))
# Item descriptive table --------------------------------------------------
response_values <- sort(unique(unlist(X_raw, use.names = FALSE)))
response_values <- response_values[is.finite(response_values)]
if (length(response_values) > 15L) .r4vn_ts_warn("More than 15 distinct response values were found; the item distribution table will be wide.")
response_columns <- vapply(response_values, function(value) {
labs <- unique(unlist(lapply(converted, function(z) {
if (is.null(z$labels) || !as.character(value) %in% names(z$labels)) return(character())
as.character(z$labels[[as.character(value)]])
}), use.names = FALSE))
labs <- labs[!is.na(labs) & nzchar(labs)]
if (length(labs) == 1L && labs != as.character(value)) paste0(value, ": ", labs) else paste0("Score ", value)
}, character(1))
response_columns <- make.unique(response_columns)
descriptive_rows <- lapply(seq_along(item_names), function(i) {
v <- item_names[i]
x0 <- X_raw[[v]]
xs <- X[[v]]
n <- sum(!is.na(xs))
miss <- sum(is.na(xs))
q <- if (n) stats::quantile(xs, c(.25, .5, .75), na.rm = TRUE, names = FALSE) else rep(NA_real_, 3L)
minv <- if (n) min(xs, na.rm = TRUE) else NA_real_
maxv <- if (n) max(xs, na.rm = TRUE) else NA_real_
floor_value <- if (!is.null(range)) range[1L] else minv
ceiling_value <- if (!is.null(range)) range[2L] else maxv
base <- data.frame(
Item = .r4vn_ts_label(data[[v]], v),
Variable = v,
Subscale = {
z <- names(factor_map)[vapply(factor_map, function(set) v %in% set, logical(1))]
if (length(z)) paste(z, collapse = ", ") else ""
},
Reversed = if (v %in% reverse_names) "Yes" else "No",
N = n,
Missing = sprintf("%d (%.1f%%)", miss, 100 * miss / nrow(data)),
`Mean (SD)` = if (n) sprintf(paste0("%.", digit, "f (%.", digit, "f)"), mean(xs, na.rm = TRUE), stats::sd(xs, na.rm = TRUE)) else "",
`Median (IQR)` = if (n) sprintf(paste0("%.", digit, "f (%.", digit, "f-%.", digit, "f)"), q[2L], q[1L], q[3L]) else "",
`Range` = if (n) sprintf(paste0("%.", digit, "f-%.", digit, "f"), minv, maxv) else "",
`Floor %` = if (n) 100 * mean(xs == floor_value, na.rm = TRUE) else NA_real_,
`Ceiling %` = if (n) 100 * mean(xs == ceiling_value, na.rm = TRUE) else NA_real_,
check.names = FALSE,
stringsAsFactors = FALSE
)
for (j in seq_along(response_values)) {
val <- response_values[j]
nn <- sum(x0 == val, na.rm = TRUE)
den <- sum(!is.na(x0))
base[[response_columns[j]]] <- if (den) sprintf("%d (%.1f%%)", nn, 100 * nn / den) else ""
}
base
})
descriptive_table <- do.call(rbind, descriptive_rows)
# Scores and reliability --------------------------------------------------
score_objects <- .r4vn_ts_subscale_scores(X, factor_map, score, min_valid)
score_df <- as.data.frame(lapply(score_objects, `[[`, "score"), check.names = FALSE)
names(score_df) <- names(score_objects)
item_labels <- vapply(item_names, function(v) .r4vn_ts_label(data[[v]], v), character(1))
alternate_scores <- function(specification, argument) {
nm <- .r4vn_ts_names(specification, argument, allow_null = TRUE)
if (!length(nm)) return(NULL)
bad <- setdiff(nm, names(data))
if (length(bad)) .r4vn_ts_stop("Variables in `", argument, "` not found in data: ", paste(bad, collapse = ", "))
if (length(nm) != length(item_names)) {
.r4vn_ts_stop("`", argument, "` must contain ", length(item_names), " items in the same order as `vars`.")
}
conv <- lapply(nm, function(v) .r4vn_ts_numeric_item(data[[v]], v)$value)
Z <- as.data.frame(stats::setNames(conv, item_names), check.names = FALSE)
if (!is.null(range)) {
for (v in item_names) Z[[v]][!is.na(Z[[v]]) & (Z[[v]] < range[1L] | Z[[v]] > range[2L])] <- NA_real_
}
if (length(reverse_names)) {
for (v in reverse_names) Z[[v]] <- range[1L] + range[2L] - Z[[v]]
}
obj <- .r4vn_ts_subscale_scores(Z, factor_map, score, min_valid)
list(items = nm, matrix = Z, scores = as.data.frame(lapply(obj, `[[`, "score"), check.names = FALSE))
}
retest_result <- alternate_scores(retest, "retest")
parallel_result <- alternate_scores(parallel_form, "parallel_form")
post_result <- if (validity_enabled) alternate_scores(post, "post") else NULL
stability <- equivalence <- responsiveness <- NULL
if (!is.null(retest_result)) {
details <- lapply(names(score_df), function(nm) .r4vn_ts_agreement(score_df[[nm]], retest_result$scores[[nm]], nm, "Test-retest reliability", conf))
names(details) <- names(score_df)
stability <- list(table = do.call(rbind, lapply(details, `[[`, "table")), details = details,
items = retest_result$items, scores = retest_result$scores)
}
if (!is.null(parallel_result)) {
details <- lapply(names(score_df), function(nm) .r4vn_ts_agreement(score_df[[nm]], parallel_result$scores[[nm]], nm, "Parallel-form reliability", conf))
names(details) <- names(score_df)
equivalence <- list(table = do.call(rbind, lapply(details, `[[`, "table")), details = details,
items = parallel_result$items, scores = parallel_result$scores)
}
if (!is.null(post_result)) responsiveness <- .r4vn_ts_responsiveness(score_df, post_result$scores, conf)
content_result <- if (validity_enabled) .r4vn_ts_content_validity(content, content_cutoff, content_max, conf) else NULL
rater_result <- NULL
rater_names <- .r4vn_ts_names(raters, "raters", allow_null = TRUE)
if (length(rater_names)) {
bad <- setdiff(rater_names, names(data))
if (length(bad)) .r4vn_ts_stop("Rater variables not found in data: ", paste(bad, collapse = ", "))
if (length(rater_names) < 2L) .r4vn_ts_stop("`raters` must contain at least two rating variables.")
rater_data <- data[, rater_names, drop = FALSE]
numeric_raters <- as.data.frame(lapply(rater_data, function(z) .r4vn_ts_numeric_item(z, "rater")$value), check.names = FALSE)
names(numeric_raters) <- rater_names
type_use <- rater_type
if (type_use == "auto") type_use <- if (all(vapply(rater_data, function(z) length(unique(z[!is.na(z)])) <= 10L, logical(1)))) "categorical" else "continuous"
icc <- .r4vn_ts_icc(numeric_raters, conf)
kap <- if (type_use == "categorical") .r4vn_ts_kappa(rater_data, weighted = FALSE, conf = conf) else
c(N=unname(icc["N"]), Raters=length(rater_names), Agreement=NA, Kappa=NA, lower=NA, upper=NA)
kap_weighted <- if (type_use == "categorical" && length(rater_names) == 2L)
.r4vn_ts_kappa(rater_data, weighted = TRUE, conf = conf) else kap
rater_table <- data.frame(
Type = type_use, N = unname(icc["N"]), Raters = length(rater_names),
`Exact agreement` = unname(kap["Agreement"]),
`Unweighted/Fleiss kappa` = unname(kap["Kappa"]),
`Kappa CI lower` = unname(kap["lower"]), `Kappa CI upper` = unname(kap["upper"]),
`Weighted kappa (two ordinal raters)` = unname(kap_weighted["Kappa"]),
`Weighted kappa CI lower` = unname(kap_weighted["lower"]),
`Weighted kappa CI upper` = unname(kap_weighted["upper"]),
`ICC absolute single` = unname(icc["ICC_A1"]), `ICC absolute average` = unname(icc["ICC_Ak"]),
`ICC consistency single` = unname(icc["ICC_C1"]), `ICC consistency average` = unname(icc["ICC_Ck"]),
`ICC CI lower` = unname(icc["lower"]), `ICC CI upper` = unname(icc["upper"]),
SEM = unname(icc["SEM"]), MDC95 = unname(icc["MDC95"]),
check.names = FALSE, stringsAsFactors = FALSE
)
rater_result <- list(type = type_use, variables = rater_names,
labels = vapply(rater_names, function(v) .r4vn_ts_label(data[[v]], v), character(1)),
table = rater_table, data = rater_data, icc = icc,
kappa = kap, weighted_kappa = kap_weighted)
}
reliability_results <- list()
reliability_summary <- data.frame()
item_analysis <- data.frame()
if (isTRUE(reliability)) {
scale_sets <- c(factor_map, list(Total = item_names))
reliability_results <- lapply(scale_sets, function(set) .r4vn_ts_alpha(X[, set, drop = FALSE], missing, cor_method, bootstrap, conf))
reliability_summary <- do.call(rbind, lapply(names(scale_sets), function(nm) {
set <- scale_sets[[nm]]
rr <- reliability_results[[nm]]
sc <- score_objects[[nm]]$score
data.frame(
Scale = nm,
Items = length(set),
ValidScores = sum(is.finite(sc)),
`Score mean` = .r4vn_ts_safe(sc, mean),
`Score SD` = .r4vn_ts_safe(sc, stats::sd),
`Score min` = .r4vn_ts_safe(sc, min),
`Score max` = .r4vn_ts_safe(sc, max),
Alpha = rr$alpha,
`Alpha CI lower` = rr$alpha_ci[1L],
`Alpha CI upper` = rr$alpha_ci[2L],
`Alpha CI method` = rr$alpha_ci_method,
`Standardized alpha` = rr$alpha_std,
`Ordinal alpha` = rr$ordinal_alpha,
`Omega total` = rr$omega_total,
`Omega CI lower` = rr$omega_ci[1L],
`Omega CI upper` = rr$omega_ci[2L],
`Omega hierarchical` = rr$omega_hierarchical,
`Greatest lower bound` = rr$glb,
KR20 = rr$kr20,
`Mean inter-item r` = rr$mean_interitem,
`Median inter-item r` = rr$median_interitem,
`Split-half r` = rr$split_half_r,
`Spearman-Brown` = rr$spearman_brown,
`Mean split-half reliability` = rr$split_half_mean,
`Best split-half reliability (lambda 4)` = rr$split_half_best,
`Guttman lambda 1` = rr$lambda1,
`Guttman lambda 2` = rr$lambda2,
`Guttman lambda 3` = rr$lambda3,
`Guttman lambda 4` = rr$lambda4,
`Guttman lambda 5` = rr$lambda5,
`Guttman lambda 6` = rr$lambda6,
`Minimum valid items` = score_objects[[nm]]$min_valid,
check.names = FALSE,
stringsAsFactors = FALSE
)
}))
rr_total <- reliability_results[["Total"]]
memberships <- lapply(item_names, function(v) names(factor_map)[vapply(factor_map, function(s) v %in% s, logical(1))])
sub_item_total <- vapply(seq_along(item_names), function(i) {
z <- memberships[[i]]
if (length(z) != 1L) return(NA_real_)
pos <- match(item_names[i], factor_map[[z]])
reliability_results[[z]]$item_total[pos]
}, numeric(1))
sub_alpha_deleted <- vapply(seq_along(item_names), function(i) {
z <- memberships[[i]]
if (length(z) != 1L) return(NA_real_)
pos <- match(item_names[i], factor_map[[z]])
reliability_results[[z]]$alpha_deleted[pos]
}, numeric(1))
item_analysis <- data.frame(
Item = item_labels,
Variable = item_names,
Subscale = vapply(memberships, function(z) if (length(z)) paste(z, collapse = ", ") else "", character(1)),
`Corrected item-total r` = rr_total$item_total,
`Alpha if deleted` = rr_total$alpha_deleted,
`Corrected item-subscale r` = sub_item_total,
`Subscale alpha if deleted` = sub_alpha_deleted,
`Item mean` = vapply(X, mean, numeric(1), na.rm = TRUE),
`Item SD` = vapply(X, stats::sd, numeric(1), na.rm = TRUE),
check.names = FALSE,
stringsAsFactors = FALSE
)
}
# EFA ---------------------------------------------------------------------
efa_result <- NULL
if (isTRUE(efa)) {
efa_result <- .r4vn_ts_efa(X, cor_method, missing, efa_method, rotation, nfactor, parallel_iter, seed)
efa_result$loadings$Item <- item_labels[match(efa_result$loadings$Variable, item_names)]
efa_result$loadings <- efa_result$loadings[, c("Item", "Variable", setdiff(names(efa_result$loadings), c("Item", "Variable"))), drop = FALSE]
if (nrow(item_analysis)) {
match_i <- match(item_analysis$Variable, efa_result$loadings$Variable)
item_analysis$`EFA dominant factor` <- efa_result$loadings$DominantFactor[match_i]
item_analysis$`EFA loading` <- efa_result$loadings$DominantLoading[match_i]
item_analysis$Communality <- efa_result$loadings$Communality[match_i]
}
}
# CFA ---------------------------------------------------------------------
cfa_group_value <- NULL
cfa_group_name <- NULL
if (!identical(cfa_group_expr, quote(NULL))) {
cfa_group_value <- try(eval(cfa_group_expr, envir = env), silent = TRUE)
if (inherits(cfa_group_value, "try-error")) cfa_group_value <- NULL
cfa_group_name <- .r4vn_ts_expr_name(cfa_group_expr, cfa_group_value, names(data), "cfa_group")
}
cfa_result <- NULL
if (isTRUE(cfa)) {
if (!is.null(cfa_group_name) && cfa_group_name %in% item_names) .r4vn_ts_stop("`cfa_group` cannot also be a scale item.")
cfa_result <- .r4vn_ts_cfa(X, data, factor_map, ordered, estimator, cfa_missing, cfa_group_name, modification, modification_min)
if (nrow(item_analysis)) {
cfa_load <- cfa_result$loadings
match_i <- match(item_analysis$Variable, cfa_load$Item)
item_analysis$`CFA factor` <- cfa_load$Factor[match_i]
item_analysis$`CFA loading` <- cfa_load$Loading[match_i]
item_analysis$`CFA R2` <- cfa_load$R2[match_i]
}
}
invariance_result <- NULL
if (validity_enabled && isTRUE(invariance)) {
if (!isTRUE(cfa)) .r4vn_ts_stop("Set `cfa = TRUE` when requesting measurement invariance.")
invariance_result <- .r4vn_ts_invariance(X, data, factor_map, cfa_group_name,
ordered, estimator, cfa_missing, invariance_levels)
}
dif_result <- data.frame()
if (validity_enabled && isTRUE(dif)) {
if (is.null(cfa_group_name)) .r4vn_ts_stop("DIF screening requires `cfa_group`.")
dif_result <- .r4vn_ts_dif(X, data[[cfa_group_name]], item_labels)
}
# Gold-standard/criterion validity ---------------------------------------
criterion_validity <- NULL
gold_name <- NULL
if (validity_enabled && !identical(gold_expr, quote(NULL))) {
gold_value <- try(eval(gold_expr, envir = env), silent = TRUE)
if (inherits(gold_value, "try-error")) gold_value <- NULL
gold_name <- .r4vn_ts_expr_name(gold_expr, gold_value, names(data), "gold")
y <- data[[gold_name]]
nlevels_gold <- if (is.factor(y)) nlevels(droplevels(y)) else length(unique(y[!is.na(y)]))
if (nlevels_gold == 2L) {
rocs <- lapply(names(score_df), function(nm) .r4vn_ts_roc(y, score_df[[nm]], event, direction, conf))
names(rocs) <- names(score_df)
roc_table <- do.call(rbind, lapply(names(rocs), function(nm) {
z <- rocs[[nm]]
b <- z$best[1L, ]
data.frame(
Scale = nm,
`Gold standard` = .r4vn_ts_label(data[[gold_name]], gold_name),
`Variable name` = gold_name,
Event = z$event,
Direction = z$direction,
`N event` = z$n_event,
`N non-event` = z$n_nonevent,
AUC = z$auc,
`AUC p` = z$p,
`AUC CI lower` = z$ci[1L],
`AUC CI upper` = z$ci[2L],
Cutoff = b$cutoff,
Sensitivity = b$sensitivity,
Specificity = b$specificity,
PPV = b$ppv,
NPV = b$npv,
`LR+` = b$lr_pos,
`LR-` = b$lr_neg,
Accuracy = b$accuracy,
DOR = b$dor,
Youden = b$youden,
check.names = FALSE,
stringsAsFactors = FALSE
)
}))
criterion_validity <- list(type = "roc", gold = gold_name,
gold_label = .r4vn_ts_label(data[[gold_name]], gold_name), roc = rocs, table = roc_table)
} else if (is.numeric(y)) {
criterion <- do.call(rbind, lapply(names(score_df), function(nm) {
ok <- is.finite(score_df[[nm]]) & is.finite(y)
pearson_test <- if (sum(ok) >= 4L) try(stats::cor.test(score_df[[nm]][ok], y[ok], method = "pearson"), silent = TRUE) else NULL
spearman_test <- if (sum(ok) >= 4L) try(suppressWarnings(stats::cor.test(score_df[[nm]][ok], y[ok], method = "spearman", exact = FALSE)), silent = TRUE) else NULL
data.frame(
Scale = nm,
Criterion = .r4vn_ts_label(data[[gold_name]], gold_name),
`Variable name` = gold_name,
N = sum(ok),
Pearson = if (!is.null(pearson_test) && !inherits(pearson_test, "try-error")) unname(pearson_test$estimate) else NA_real_,
`Pearson p` = if (!is.null(pearson_test) && !inherits(pearson_test, "try-error")) pearson_test$p.value else NA_real_,
Spearman = if (!is.null(spearman_test) && !inherits(spearman_test, "try-error")) unname(spearman_test$estimate) else NA_real_,
`Spearman p` = if (!is.null(spearman_test) && !inherits(spearman_test, "try-error")) spearman_test$p.value else NA_real_,
check.names = FALSE,
stringsAsFactors = FALSE
)
}))
criterion_validity <- list(type = "criterion", gold = gold_name,
gold_label = .r4vn_ts_label(data[[gold_name]], gold_name), table = criterion)
} else .r4vn_ts_stop("`gold` must be binary for ROC analysis or numeric for criterion correlations.")
}
convergent_names <- if (validity_enabled) .r4vn_ts_names(convergent, "convergent", allow_null = TRUE) else character()
discriminant_names <- if (validity_enabled) .r4vn_ts_names(discriminant, "discriminant", allow_null = TRUE) else character()
convergent_table <- .r4vn_ts_external_validity(score_df, data, convergent_names,
convergent_min, cor_method, conf, "Convergent")
discriminant_table <- .r4vn_ts_external_validity(score_df, data, discriminant_names,
discriminant_max, cor_method, conf, "Discriminant")
known_groups_result <- NULL
known_groups_name <- NULL
if (validity_enabled && !identical(known_groups_expr, quote(NULL))) {
kg_value <- try(eval(known_groups_expr, envir = env), silent = TRUE)
if (inherits(kg_value, "try-error")) kg_value <- NULL
known_groups_name <- .r4vn_ts_expr_name(known_groups_expr, kg_value, names(data), "known_groups")
known_groups_result <- .r4vn_ts_known_groups(score_df, data[[known_groups_name]],
.r4vn_ts_label(data[[known_groups_name]], known_groups_name), conf)
}
# Optionally create score variables --------------------------------------
scored_data <- data
created <- character()
if (!identical(name, FALSE) && !is.null(name)) {
prefix <- if (isTRUE(name)) "scale" else as.character(name)[1L]
if (!nzchar(prefix)) prefix <- "scale"
for (nm in names(score_df)) {
suffix <- if (nm == "Total") "total" else .r4vn_ts_clean_factor_name(nm)
candidate <- make.names(paste0(prefix, "_", suffix))
new_name <- tail(make.unique(c(names(scored_data), candidate), sep = "_"), 1L)
scored_data[[new_name]] <- score_df[[nm]]
attr(scored_data[[new_name]], "label") <- paste0(if (nm == "Total") "Total scale" else nm, " score (", score, ")")
created <- c(created, new_name)
}
committed <- .r4vn_ts_commit_data(scored_data, data_expr, data_supplied, env)
if (!isTRUE(committed)) .r4vn_ts_warn("Score variables were returned in `$scored_data` but could not be written back to the supplied or active data.")
}
# Tables for output -------------------------------------------------------
efa_diag_table <- efa_msa_table <- efa_parallel_table <- efa_loading_table <- efa_fit_table <- efa_phi_table <- data.frame()
if (!is.null(efa_result)) {
d <- efa_result$diagnostics
efa_diag_table <- data.frame(
Statistic = c("KMO overall", "Bartlett chi-square", "Bartlett df", "Bartlett p", "Correlation determinant", "Parallel-analysis factors", "Retained factors"),
Value = c(d$kmo, d$bartlett["chisq"], d$bartlett["df"], d$bartlett["p"], d$determinant, efa_result$parallel$suggested, efa_result$nfactor),
stringsAsFactors = FALSE
)
efa_msa_table <- data.frame(Item = item_labels, Variable = item_names,
ItemMSA = unname(d$msa[item_names]), stringsAsFactors = FALSE)
efa_parallel_table <- efa_result$parallel$table
efa_loading_table <- efa_result$loadings
efa_fit_table <- data.frame(Method = efa_result$fit$method, Rotation = rotation, ChiSquare = efa_result$fit$statistic, df = efa_result$fit$df, p = efa_result$fit$p, stringsAsFactors = FALSE)
if (!is.null(efa_result$fit$phi) && nrow(efa_result$fit$phi) > 1L) {
phi <- efa_result$fit$phi
efa_phi_table <- data.frame(Factor = if (is.null(rownames(phi))) paste0("F", seq_len(nrow(phi))) else rownames(phi), phi, check.names = FALSE, stringsAsFactors = FALSE)
}
}
cfa_fit_table <- cfa_loading_table <- cfa_rel_table <- cfa_cor_table <- htmt_table <- mi_table <- fornell_table <- data.frame()
if (!is.null(cfa_result)) {
cfa_fit_table <- data.frame(Statistic = names(cfa_result$measures), Value = as.numeric(cfa_result$measures), stringsAsFactors = FALSE)
cfa_loading_table <- cfa_result$loadings
cfa_rel_table <- cfa_result$reliability
cfa_cor_table <- cfa_result$factor_correlations
htmt_table <- cfa_result$htmt
mi_table <- cfa_result$modification
if (nrow(cfa_rel_table) && nrow(cfa_cor_table) && !"Group" %in% names(cfa_rel_table)) {
fornell_table <- do.call(rbind, lapply(seq_len(nrow(cfa_cor_table)), function(i) {
f1 <- cfa_cor_table$Factor1[i]; f2 <- cfa_cor_table$Factor2[i]
s1 <- cfa_rel_table$SqrtAVE[match(f1, cfa_rel_table$Factor)]
s2 <- cfa_rel_table$SqrtAVE[match(f2, cfa_rel_table$Factor)]
r <- abs(cfa_cor_table$Correlation[i])
data.frame(Factor1=f1, Factor2=f2, `Absolute factor correlation`=r,
`Square root AVE factor 1`=s1, `Square root AVE factor 2`=s2,
`Fornell-Larcker supported`=ifelse(is.finite(r) & is.finite(s1) & is.finite(s2) & s1>r & s2>r,"Yes","No"),
check.names=FALSE, stringsAsFactors=FALSE)
}))
}
}
criterion_table <- if (!is.null(criterion_validity)) criterion_validity$table else data.frame()
reliability_score_table <- if (nrow(reliability_summary)) reliability_summary[, c("Scale","Items","ValidScores","Score mean","Score SD","Score min","Score max","Minimum valid items"),drop=FALSE] else data.frame()
internal_consistency_table <- if (nrow(reliability_summary)) reliability_summary[, c("Scale","Alpha","Alpha CI lower","Alpha CI upper","Alpha CI method","Standardized alpha","Ordinal alpha","Omega total","Omega CI lower","Omega CI upper","Omega hierarchical","KR20","Mean inter-item r","Median inter-item r"),drop=FALSE] else data.frame()
reliability_bounds_table <- if (nrow(reliability_summary)) reliability_summary[, c("Scale","Split-half r","Spearman-Brown","Mean split-half reliability","Best split-half reliability (lambda 4)","Guttman lambda 1","Guttman lambda 2","Guttman lambda 3","Guttman lambda 4","Guttman lambda 5","Guttman lambda 6","Greatest lower bound"),drop=FALSE] else data.frame()
stability_table <- if (!is.null(stability)) stability$table else data.frame()
equivalence_table <- if (!is.null(equivalence)) equivalence$table else data.frame()
rater_table <- if (!is.null(rater_result)) rater_result$table else data.frame()
content_item_table <- if (!is.null(content_result)) content_result$item else data.frame()
content_summary_table <- if (!is.null(content_result)) content_result$summary else data.frame()
known_desc_table <- if (!is.null(known_groups_result)) known_groups_result$descriptive else data.frame()
known_test_table <- if (!is.null(known_groups_result)) known_groups_result$tests else data.frame()
responsiveness_table <- if (!is.null(responsiveness)) responsiveness$table else data.frame()
invariance_table <- if (!is.null(invariance_result)) invariance_result$table else data.frame()
coverage_table <- data.frame(
Domain = c("Internal consistency", "Stability/test-retest", "Parallel-form equivalence",
"Inter-rater reliability", "Measurement error", "Content validity", "Face validity",
"Structural validity (EFA)", "Structural validity (CFA)", "Convergent validity",
"Discriminant validity", "Known-groups validity", "Criterion validity",
"Measurement invariance", "Differential item functioning", "Responsiveness"),
Status = c(if (isTRUE(reliability)) "Reported" else "Not requested",
if (!is.null(stability)) "Reported" else "Not supplied",
if (!is.null(equivalence)) "Reported" else "Not supplied",
if (!is.null(rater_result)) "Reported" else "Not supplied",
if (!is.null(stability) || !is.null(equivalence) || !is.null(rater_result)) "Reported" else "Requires repeated/rater data",
if (!is.null(content_result)) "Reported" else "Not supplied",
"Qualitative assessment; not calculated",
if (!is.null(efa_result)) "Reported" else "Not requested",
if (!is.null(cfa_result)) "Reported" else "Not requested",
if (nrow(convergent_table) || nrow(cfa_rel_table)) "Reported" else "Not supplied",
if (nrow(discriminant_table) || nrow(htmt_table) || nrow(fornell_table)) "Reported" else "Not supplied",
if (!is.null(known_groups_result)) "Reported" else "Not supplied",
if (!is.null(criterion_validity)) "Reported" else "Not supplied",
if (!is.null(invariance_result)) "Reported" else "Not requested",
if (nrow(dif_result)) "Reported" else "Not requested",
if (!is.null(responsiveness)) "Reported" else "Not supplied"),
stringsAsFactors = FALSE)
tables <- list(
`Analysis coverage` = coverage_table,
`Item descriptive statistics` = descriptive_table,
`Item reliability diagnostics` = item_analysis,
`Scale score summary` = reliability_score_table,
`Internal consistency` = internal_consistency_table,
`Split-half and reliability lower bounds` = reliability_bounds_table,
`Test-retest reliability and measurement error` = stability_table,
`Parallel-form reliability` = equivalence_table,
`Inter-rater reliability` = rater_table,
`Content validity summary` = content_summary_table,
`Content validity by item` = content_item_table,
`EFA factorability` = efa_diag_table,
`EFA item MSA` = efa_msa_table,
`EFA model fit` = efa_fit_table,
`EFA factor correlations` = efa_phi_table,
`Parallel analysis` = efa_parallel_table,
`EFA loadings` = efa_loading_table,
`CFA fit` = cfa_fit_table,
`CFA standardized loadings` = cfa_loading_table,
`CFA composite reliability and AVE` = cfa_rel_table,
`CFA factor correlations` = cfa_cor_table,
`Fornell-Larcker discriminant validity` = fornell_table,
`HTMT discriminant validity` = htmt_table,
`CFA modification indices` = mi_table,
`Measurement invariance` = invariance_table,
`Differential item functioning screening` = dif_result,
`Convergent validity` = convergent_table,
`Discriminant validity` = discriminant_table,
`Known-groups descriptive statistics` = known_desc_table,
`Known-groups validity` = known_test_table,
`Gold-standard or criterion validity` = criterion_table,
Responsiveness = responsiveness_table
)
flat_data <- .r4vn_ts_bind_fill(tables)
# Plot specifications are shared by the R graphics device and the Viewer.
plot_specs <- list()
default_plot_types <- c("correlation", "scores", "scree", "loadings",
"cfa_loadings", "roc", "test_retest", "parallel_form", "inter_rater",
"content", "invariance")
add_plot <- function(name, spec) {
if (!isTRUE(plot)) return(invisible(NULL))
allowed <- (identical(plot_types, "auto") && name %in% default_plot_types) ||
identical(plot_types, "all") || name %in% as.character(plot_types)
if (allowed) plot_specs[[make.unique(c(names(plot_specs), name))[length(plot_specs)+1L]]] <<- spec
invisible(NULL)
}
missing_pct <- 100 * vapply(X, function(z) mean(is.na(z)), numeric(1))
floor_pct <- if (nrow(descriptive_table)) descriptive_table$`Floor %` else numeric()
ceiling_pct <- if (nrow(descriptive_table)) descriptive_table$`Ceiling %` else numeric()
add_plot("items", list(type="item_diagnostics", labels=item_labels, missing=missing_pct,
floor=floor_pct, ceiling=ceiling_pct, title="Item completeness and floor/ceiling effects"))
response_levels <- sort(unique(unlist(X_raw, use.names=FALSE)))
response_levels <- response_levels[is.finite(response_levels)]
response_prop <- sapply(X_raw, function(z) {
tab <- table(factor(z, levels=response_levels)); if (sum(tab)) as.numeric(tab)/sum(tab) else rep(NA_real_,length(tab))
})
rownames(response_prop) <- paste0("Score ",response_levels)
add_plot("distributions", list(type="item_distribution", labels=item_labels,
proportions=response_prop, title="Item response distributions"))
R_plot <- if (length(reliability_results)) reliability_results$Total$correlation else .r4vn_ts_cor(X, cor_method, missing)
add_plot("correlation", list(type="correlation", matrix=R_plot, labels=item_labels, title="Item correlation matrix"))
if (nrow(item_analysis)) add_plot("reliability", list(type="reliability", labels=item_labels,
item_total=item_analysis$`Corrected item-total r`, alpha_deleted=item_analysis$`Alpha if deleted`,
alpha=reliability_results$Total$alpha, title="Item reliability diagnostics"))
add_plot("scores", list(type="scores", values=score_df, title="Scale score distributions"))
if (!is.null(efa_result)) {
add_plot("scree", list(type="scree", x=efa_parallel_table$Component,
observed=efa_parallel_table$Observed, random=efa_parallel_table$Random95,
title="Parallel-analysis scree plot"))
L <- efa_result$fit$loadings; rownames(L) <- item_labels[match(rownames(L), item_names)]
add_plot("loadings", list(type="loadings", loadings=L, title="Exploratory factor loadings"))
}
if (!is.null(cfa_result) && nrow(cfa_result$loadings)) {
ld <- cfa_result$loadings
ld$.factor_plot <- if ("Group" %in% names(ld)) paste(ld$Group,ld$Factor,sep=" \u2014 ") else ld$Factor
factors_cfa <- unique(ld$.factor_plot); items_cfa <- unique(ld$`Item label`)
Lc <- matrix(NA_real_,nrow=length(items_cfa),ncol=length(factors_cfa),dimnames=list(items_cfa,factors_cfa))
for (i in seq_len(nrow(ld))) Lc[ld$`Item label`[i],ld$.factor_plot[i]] <- ld$Loading[i]
add_plot("cfa_loadings",list(type="loadings",loadings=Lc,title="Confirmatory factor loadings"))
}
if (!is.null(criterion_validity) && criterion_validity$type == "roc") {
curves <- lapply(criterion_validity$roc, `[[`, "curve")
names(curves) <- paste0(names(curves), " (AUC=", .r4vn_ts_num(vapply(criterion_validity$roc, `[[`, numeric(1), "auc"), 2), ")")
add_plot("roc", list(type="roc", curves=curves, xlim=c(0,1), ylim=c(0,1),
title=paste0("ROC curves: ", criterion_validity$gold_label)))
}
if (!is.null(stability)) for (nm in names(stability$details)) {
z <- stability$details[[nm]]; add_plot("test_retest", list(type="agreement", first=z$first, second=z$second, title=paste0("Test-retest: ", nm)))
}
if (!is.null(equivalence)) for (nm in names(equivalence$details)) {
z <- equivalence$details[[nm]]; add_plot("parallel_form", list(type="agreement", first=z$first, second=z$second, title=paste0("Parallel forms: ", nm)))
}
if (!is.null(rater_result)) {
vals <- c(Kappa=rater_result$table$`Unweighted/Fleiss kappa`,
`ICC absolute`=rater_result$table$`ICC absolute single`,
`ICC consistency`=rater_result$table$`ICC consistency single`)
keep <- is.finite(vals); if (any(keep)) add_plot("inter_rater",
list(type="coefficients",values=vals[keep],labels=names(vals)[keep],title="Inter-rater reliability"))
}
if (!is.null(content_result)) add_plot("content",
list(type="coefficients",values=content_result$item$`I-CVI`,labels=content_result$item$Item,title="Item-level content validity"))
external_plot <- rbind(convergent_table,discriminant_table)
if (nrow(external_plot)) add_plot("external_validity",list(type="validity_forest",
estimate=external_plot$Correlation,lower=external_plot$`CI lower`,upper=external_plot$`CI upper`,
labels=paste(external_plot$Scale,external_plot$Variable,sep=" \u2014 "),title="Convergent and discriminant validity"))
if (!is.null(known_groups_result)) for (nm in names(score_df)) add_plot("known_groups",
list(type="known_groups", score=score_df[[nm]], group=known_groups_result$group,
group_label=.r4vn_ts_label(data[[known_groups_name]], known_groups_name), title=paste0("Known-groups validity: ", nm)))
if (!is.null(responsiveness)) for (nm in names(responsiveness$details)) {
z <- responsiveness$details[[nm]]; add_plot("responsiveness",
list(type="responsiveness", first=z$first, second=z$second, title=paste0("Responsiveness: ", nm)))
}
if (!is.null(invariance_result) && nrow(invariance_result$table)) add_plot("invariance",
list(type="invariance",data=invariance_result$table,title="Measurement invariance: change in fit"))
plot_html <- if (length(plot_specs)) paste(vapply(seq_along(plot_specs), function(i) {
render_plot <- if (viewer_plot_format == "png") .r4vn_ts_plot_png else .r4vn_ts_plot_svg
z <- try(render_plot(plot_specs[[i]]), silent=TRUE)
# Keep the report usable on unusual headless systems whose PNG device is
# unavailable; SVG remains a self-contained fallback.
if (inherits(z, "try-error") && viewer_plot_format == "png")
z <- try(.r4vn_ts_plot_svg(plot_specs[[i]]), silent = TRUE)
if (inherits(z,"try-error")) "" else paste0("<div class=\"ts-chart\"><h3>",
.r4vn_ts_escape(.r4vn_ts_or(plot_specs[[i]]$title, names(plot_specs)[i])), "</h3>", z, "</div>")
}, character(1)), collapse="") else ""
section_number <- 0L
section_table <- function(df, caption) {
if (!is.data.frame(df) || !nrow(df)) return("")
section_number <<- section_number + 1L
transpose <- nrow(df) <= 5L && ncol(df) >= 9L && ncol(df) > nrow(df) + 4L
.r4vn_ts_html_table(df, paste0(section_number, ". ", caption), digit,
p_digit, transpose = transpose)
}
html_sections <- c(
section_table(coverage_table, "Analysis coverage"),
section_table(descriptive_table, "Item descriptive statistics"),
section_table(item_analysis, "Item reliability diagnostics"),
section_table(reliability_score_table, "Scale and subscale score summary"),
section_table(internal_consistency_table, "Internal consistency"),
section_table(reliability_bounds_table, "Split-half reliability and lower-bound coefficients")
)
if (!is.null(efa_result)) {
html_sections <- c(html_sections,
section_table(efa_diag_table, "EFA factorability and retained factors"),
section_table(efa_msa_table, "Item-level sampling adequacy"),
section_table(efa_fit_table, "EFA model"),
section_table(efa_phi_table, "EFA factor correlations"),
section_table(efa_parallel_table, "Parallel analysis"),
section_table(efa_loading_table, "Exploratory factor loadings")
)
}
if (!is.null(cfa_result)) {
html_sections <- c(html_sections,
section_table(cfa_fit_table, "Confirmatory factor analysis fit"),
paste0("<div class=\"ts-model\"><strong>CFA model</strong><pre>", .r4vn_ts_escape(cfa_result$model), "</pre><div>Estimator: ", .r4vn_ts_escape(cfa_result$estimator), "</div></div>"),
section_table(cfa_loading_table, "CFA standardized loadings"),
section_table(cfa_rel_table, "Composite reliability and convergent validity"),
section_table(cfa_cor_table, "Factor correlations"),
section_table(fornell_table, "Fornell-Larcker discriminant validity"),
section_table(htmt_table, "HTMT discriminant validity"),
section_table(mi_table, "Modification indices")
)
}
html_sections <- c(html_sections,
section_table(stability_table, "Test-retest reliability, agreement, and measurement error"),
section_table(equivalence_table, "Parallel-form reliability and agreement"),
section_table(rater_table, "Inter-rater reliability and agreement"),
section_table(content_summary_table, "Content validity summary"),
section_table(content_item_table, "Content validity by item"),
section_table(invariance_table, "Measurement invariance"),
section_table(dif_result, "Differential item functioning screening"),
section_table(convergent_table, "Convergent validity"),
section_table(discriminant_table, "Discriminant validity"),
section_table(known_desc_table, "Known-groups descriptive statistics"),
section_table(known_test_table, "Known-groups validity"),
section_table(criterion_table, if (!is.null(criterion_validity) && criterion_validity$type == "roc") "Gold-standard ROC analysis" else "Criterion validity"),
section_table(responsiveness_table, "Responsiveness"),
plot_html)
interpretation_text <- character()
if (isTRUE(interpretation)) {
alpha_total <- if (nrow(reliability_summary)) reliability_summary$Alpha[reliability_summary$Scale == "Total"][1L] else NA_real_
if (is.finite(alpha_total)) interpretation_text <- c(interpretation_text,
sprintf("The total-scale Cronbach alpha was %.2f; interpret this together with dimensionality, item content, and the intended use of the score.", alpha_total))
if (!is.null(efa_result)) interpretation_text <- c(interpretation_text,
sprintf("The overall KMO was %.2f, Bartlett's test p was %s, and parallel analysis suggested %d factor(s).",
efa_result$diagnostics$kmo, .r4vn_ts_p(efa_result$diagnostics$bartlett["p"], p_digit), efa_result$parallel$suggested))
if (!is.null(cfa_result)) interpretation_text <- c(interpretation_text,
sprintf("CFA fit: CFI %.3f, TLI %.3f, RMSEA %.3f, and SRMR %.3f; prespecified criteria and substantive plausibility should guide conclusions.",
cfa_result$measures["cfi"], cfa_result$measures["tli"], cfa_result$measures["rmsea"], cfa_result$measures["srmr"]))
if (nrow(known_test_table)) interpretation_text <- c(interpretation_text,
paste0("Known-groups comparisons were produced for ", nrow(known_test_table), " scale score(s); assess whether effect direction and magnitude match prespecified hypotheses."))
if (!is.null(criterion_validity) && criterion_validity$type == "roc") interpretation_text <- c(interpretation_text,
"ROC estimates describe discrimination in this sample and should be externally validated before a cutoff is used clinically.")
}
if (length(interpretation_text)) html_sections <- c(html_sections,
paste0("<div class=\"ts-interpretation\"><h3>Interpretation</h3>",
paste0("<p>", .r4vn_ts_escape(interpretation_text), "</p>", collapse=""), "</div>"))
negative_items <- if (nrow(item_analysis)) item_analysis$Variable[is.finite(item_analysis$`Corrected item-total r`) & item_analysis$`Corrected item-total r` < 0] else character()
notes <- c(
"Categorical responses are shown as n (%). Item means, reliability, EFA, CFA, subscale scores, and total scores use reverse-scored values where specified.",
"Corrected item-total correlation excludes the item from its comparison total. Omega total is estimated from a one-factor principal-axis solution.",
if (length(reverse_names)) paste0("Reverse-scored items: ", paste(reverse_names, collapse = ", "), ".") else NULL,
if (length(negative_items)) paste0("Items with negative corrected item-total correlation: ", paste(negative_items, collapse = ", "), ". Check coding and construct direction.") else NULL,
if (length(created)) paste0("Created score variables: ", paste(created, collapse = ", "), ".") else NULL,
if (!is.null(criterion_validity) && criterion_validity$type == "roc") "AUC confidence intervals use the Hanley-McNeil large-sample approximation; the displayed cutoff maximizes Youden's index." else NULL,
"Face validity is a qualitative judgement by target users and experts; tabscale records it in the coverage table but does not manufacture a numeric coefficient.",
"Ordinal alpha, omega hierarchical, and the greatest lower bound are reported when the optional psych package is available; blank values are not interpreted as zero.",
if (nrow(dif_result)) "DIF results are screening analyses based on nested linear models and should be confirmed with an ordinal-logistic or item-response model when items are ordinal." else NULL,
if (isTRUE(cfa) && length(cfa_result$ordered)) "Ordinal CFA was estimated with the declared ordered items." else NULL
)
note_html <- paste0("<div class=\"ts-notes\">", paste0("<div>", .r4vn_ts_escape(notes), "</div>", collapse = ""), "</div>")
title <- .r4vn_ts_or(title, "Scale analysis")
title_html <- paste0("<h2>", .r4vn_ts_escape(title), "</h2>")
table_block <- paste0("<section class=\"r4vn-table r4vn-tabscale\">", title_html, paste(html_sections[nzchar(html_sections)], collapse = ""), note_html, "</section>")
css <- switch(template,
journal = "body{font-family:Arial,sans-serif;color:#111;margin:20px}.r4vn-tabscale{max-width:100%}h2{font-size:20px;margin:0 0 14px}h3{font-size:15px;margin:20px 0 7px}.ts-section{max-width:100%;overflow-x:auto}table{border-collapse:collapse;font-size:12px;min-width:620px}th{border-top:2px solid #222;border-bottom:1px solid #555;padding:6px 8px;text-align:right;white-space:nowrap}th:first-child,td:first-child{text-align:left}td{border-bottom:1px solid #ddd;padding:5px 8px;text-align:right;vertical-align:top;white-space:nowrap}.ts-notes{font-size:11px;line-height:1.4;margin-top:14px}.ts-chart{max-width:900px;overflow:hidden;margin:18px 0}.ts-chart img.ts-plot-image,.ts-chart svg{display:block;width:100%;height:auto;max-width:900px}.ts-chart svg text{font-family:Arial,sans-serif!important;letter-spacing:normal!important;word-spacing:normal!important}.ts-interpretation{border-left:4px solid #555;padding:2px 12px;margin:18px 0;font-size:12px}.ts-svg{width:100%;height:auto;color:#222;background:#fff}.ts-model{font-size:12px;margin:12px 0}.ts-model pre{background:#f6f6f6;padding:8px;white-space:pre-wrap}.table-separator{height:26px}",
clean = "body{font-family:system-ui,Arial,sans-serif;color:#222;margin:20px}.r4vn-tabscale{max-width:100%}h2{font-size:21px}h3{font-size:15px;margin-top:20px}.ts-section{overflow-x:auto}table{border-collapse:separate;border-spacing:0;font-size:12px;min-width:620px;border:1px solid #ddd;border-radius:5px}th{background:#f5f5f5;font-weight:600}th,td{padding:6px 8px;border-bottom:1px solid #e4e4e4;text-align:right;white-space:nowrap}th:first-child,td:first-child{text-align:left}.ts-notes{font-size:11px;line-height:1.45;margin-top:14px}.ts-chart{max-width:900px;overflow:hidden;margin:18px 0}.ts-chart img.ts-plot-image,.ts-chart svg{display:block;width:100%;height:auto;max-width:900px}.ts-chart svg text{font-family:Arial,sans-serif!important;letter-spacing:normal!important;word-spacing:normal!important}.ts-svg{width:100%;max-width:900px;color:#333}.ts-model pre{background:#f5f5f5;padding:8px}.table-separator{height:26px}",
minimal = "body{font-family:Arial,sans-serif;margin:16px}h2{font-size:18px}h3{font-size:14px}.ts-section{overflow-x:auto}table{border-collapse:collapse;font-size:11px}th,td{padding:4px 6px;border-bottom:1px solid #ccc;text-align:right;white-space:nowrap}th:first-child,td:first-child{text-align:left}.ts-notes{font-size:10px;margin-top:10px}.ts-chart{max-width:900px;overflow:hidden;margin:16px 0}.ts-chart img.ts-plot-image,.ts-chart svg{display:block;width:100%;height:auto;max-width:900px}.ts-chart svg text{font-family:Arial,sans-serif!important;letter-spacing:normal!important;word-spacing:normal!important}.ts-svg{width:100%;max-width:900px;color:#111}.table-separator{height:20px}"
)
blocks <- table_block
if (inherits(append, "r4vn_tab") && !is.null(append$blocks)) blocks <- c(append$blocks, table_block)
document <- paste0("<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>", css, "</style></head><body>", paste(blocks, collapse = "<div class=\"table-separator\"></div>"), "</body></html>")
if (is.null(file)) file <- tempfile(pattern = "r4vn-tabscale-", fileext = ".html")
if (!is.character(file) || length(file) != 1L || !nzchar(file)) .r4vn_ts_stop("`file` must be one valid path.")
if (!grepl("[.]html?$", file, ignore.case = TRUE)) file <- paste0(file, ".html")
if (is.character(append) && length(append) == 1L && file.exists(append)) {
old <- paste(readLines(append, warn = FALSE, encoding = "UTF-8"), collapse = "\n")
if (grepl("</body>", old, fixed = TRUE)) {
document <- sub("</body>", paste0("<div class=\"table-separator\"></div>", table_block, "</body>"), old, fixed = TRUE)
file <- append
}
}
writeLines(enc2utf8(document), file, useBytes = TRUE)
output <- list(
report = report,
data = flat_data,
tables = tables,
coverage = coverage_table,
descriptive = descriptive_table,
item_analysis = item_analysis,
reliability = if (isTRUE(raw)) reliability_results else reliability_summary,
reliability_summary = reliability_summary,
scores = score_df,
score_details = score_objects,
scored_data = scored_data,
created = created,
items = item_names,
reverse = reverse_names,
factor = factor_map,
efa = efa_result,
cfa = cfa_result,
stability = stability,
test_retest = stability,
parallel_form = equivalence,
inter_rater = rater_result,
content_validity = content_result,
convergent_validity = convergent_table,
discriminant_validity = discriminant_table,
known_groups = known_groups_result,
invariance = invariance_result,
dif = dif_result,
responsiveness = responsiveness,
validity = criterion_validity,
criterion_validity = criterion_validity,
validity_all = list(content=content_result, structural=list(efa=efa_result,cfa=cfa_result),
convergent=convergent_table, discriminant=list(external=discriminant_table,htmt=htmt_table,fornell_larcker=fornell_table),
known_groups=known_groups_result, criterion=criterion_validity, invariance=invariance_result,
dif=dif_result, responsiveness=responsiveness),
gold = gold_name,
plots = plot_specs,
interpretation = interpretation_text,
html = document,
table_html = table_block,
blocks = blocks,
file = normalizePath(file, winslash = "/", mustWork = TRUE),
call = match.call()
)
class(output) <- c("r4vn_tabscale", "r4vn_tab")
if (isTRUE(plot) && length(plot_specs) && interactive()) graphics::plot(output)
if (isTRUE(show) && interactive()) {
viewer <- getOption("viewer")
if (is.function(viewer)) viewer(output$file) else utils::browseURL(output$file)
}
invisible(output)
}
#' Print or reopen a scale analysis
#'
#' @param x An object created by `tabscale()`.
#' @param ... Additional arguments currently ignored.
#' @return The input object, invisibly.
#' @method print r4vn_tabscale
#' @export
print.r4vn_tabscale <- function(x, ...) {
if (!inherits(x, "r4vn_tabscale")) .r4vn_ts_stop("`x` must be created by tabscale().")
viewer <- getOption("viewer")
if (is.function(viewer)) viewer(x$file) else utils::browseURL(x$file)
invisible(x)
}
#' Plot diagnostics from a scale analysis
#'
#' Displays every available tabscale graphic in the R graphics device. In
#' RStudio, use the back/forward arrows in the Plots pane to review them.
#'
#' @param x An object created by `tabscale()`.
#' @param which Optional plot names or indices. The default displays all plots.
#' @param ... Additional arguments currently ignored.
#' @return The input object, invisibly.
#' @method plot r4vn_tabscale
#' @export
plot.r4vn_tabscale <- function(x, which = NULL, ...) {
if (!inherits(x, "r4vn_tabscale")) .r4vn_ts_stop("`x` must be created by tabscale().")
specs <- x$plots
if (!length(specs)) {
.r4vn_ts_warn("No plots are stored in this result. Run tabscale(..., plot = TRUE).")
return(invisible(x))
}
if (!is.null(which)) {
if (is.numeric(which)) specs <- specs[intersect(as.integer(which), seq_along(specs))]
else specs <- specs[names(specs) %in% as.character(which) | sub("[.][0-9]+$", "", names(specs)) %in% as.character(which)]
}
for (spec in specs) .r4vn_ts_plot_draw(spec)
invisible(x)
}
attr(tabscale, "r4vn_version") <- "tabscale-2.0.2-2026-09-02"
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.