R/PCA.R

Defines functions screeplot.princomp.BData print.summary.princomp.BData summary.princomp.BData predict.princomp.BData biplot.princomp.BData .plotBoxplotBData .plotReconstructionBData .plotCovarianceBData .plotLoadingsBData .plotScoresBData .plotVarianceBData .plotScreeBData plot.princomp.BData print.princomp.BData plot.princomp.BData.grouped plot.princomp.BData.multivariate print.princomp.BData.multivariate print.princomp.BData.grouped .princompMultivariate .princompGrouped princomp.BData

Documented in biplot.princomp.BData plot.princomp.BData plot.princomp.BData.grouped plot.princomp.BData.multivariate predict.princomp.BData princomp.BData print.princomp.BData print.princomp.BData.grouped print.princomp.BData.multivariate screeplot.princomp.BData summary.princomp.BData

## File for code on Principal Component Analysis

#' Principal Component Analysis for Distributional Data
#'
#' @description
#' Performs Simplicial Functional Principal Component Analysis (SFPCA) on
#' distributional data where each observation is an entire probability distribution.
#' This function extends the classical `princomp` to work with BData objects,
#' enabling dimensionality reduction and exploratory analysis of datasets of
#' distributions.
#'
#' @param x A BData object containing the distributional data. Can be any subclass
#'     such as BDataSplines, BDataDensities, BDataPDFs, BDataHistograms, or
#'     BDataSamples. The data will be internally converted to spline coefficients
#'     for the analysis.
#' @param cor Logical. If `TRUE`, the analysis uses the correlation matrix instead
#'     of the covariance matrix. This standardizes each coefficient to unit variance
#'     before analysis, which can be useful when coefficients have very different
#'     scales. Default is `FALSE`.
#' @param scores Logical. If `TRUE` (default), the principal component scores are
#'     computed for each observation. Scores represent the coordinates of each
#'     distribution in the reduced PC space.
#' @param covmat An optional pre-computed covariance matrix. If provided, this
#'     matrix is used instead of computing the covariance from `x`. Useful for
#'     robust or regularized covariance estimates. **Note:** When provided with a
#'     non-orthonormal basis, this should be the covariance in the transformed
#'     (orthonormalized) space.
#' @param center An optional centering vector. If `NULL` (default), the column
#'     means of the coefficient matrix are used. Providing a custom center allows
#'     for centering around a specific reference distribution.
#' @param group An optional factor or vector specifying group membership for each
#'     observation. If provided, PCA is performed separately for each group level
#'     (unless `multivariate = TRUE`), and the result is an object of class
#'     `"princomp.BData.grouped"` containing a list of PCA results. This is useful
#'     for comparing principal components across different subgroups (e.g., males
#'     vs females, treatment vs control).
#' @param multivariate Logical. Only relevant when `group` is provided. If `FALSE`
#'     (default), PCA is performed separately for each group. If `TRUE`, performs
#'     **multivariate SFPCA** (2D-SFPCA per Hron et al. 2016) where clr-representations
#'     are stacked across groups and a single joint PCA is performed. This captures
#'     the covariation between groups and yields common eigenfunctions spanning all
#'     groups. Requires that observations are **paired** across groups (same number
#'     of observations per group, same observation index = same subject).
#' @param ... Additional arguments passed to methods.
#'
#' @return An object of class `c("princomp.BData", "princomp")` containing:
#' \describe{
#'   \item{sdev}{Numeric vector of standard deviations of the principal components,
#'       i.e., the square roots of the eigenvalues of the covariance matrix.
#'       These quantify the amount of variation captured by each component.}
#'   \item{loadings}{Matrix of eigenfunctions in coefficient form (class `"loadings"`).
#'       Each column is an eigenvector representing a principal component direction
#'       in the space of spline coefficients. Rows correspond to basis coefficients.
#'       **Note:** For non-orthonormal bases, these are back-transformed to the
#'       original coefficient space. Kept for compatibility with `stats::princomp`.}
#'   \item{eigenfunctions}{Alias for `loadings`. This is the proper SFPCA terminology
#'       (Hron et al. 2016).}
#'   \item{center}{Numeric vector containing the mean of each spline coefficient,
#'       used for centering the data before PCA.
#'       This represents the "average distribution" in coefficient space.}
#'   \item{scale}{Numeric vector of scalings applied to each coefficient. All ones
#'       unless `cor=TRUE`, in which case these are the standard deviations used
#'       for standardization.}
#'   \item{n.obs}{Integer giving the number of observations (distributions) in
#'       the dataset.}
#'   \item{scores}{Matrix of principal component scores (if `scores=TRUE`).
#'       Each row corresponds to an observation, each column to a principal
#'       component. The score indicates how far each distribution deviates from
#'       the mean along that PC direction.}
#'   \item{call}{The matched function call.}
#'   \item{Center}{The mean distribution as a BDataSplines object. This can be
#'       plotted or converted to other representations (PDF, CDF, etc.).}
#'   \item{Loadings}{The eigenfunctions as a BDataSplines object. Each row
#'       represents one principal component as a functional perturbation.
#'       Kept for compatibility.}
#'   \item{Eigenfunctions}{Alias for `Loadings`. This is the proper SFPCA terminology.}
#'   \item{basis}{The BayesSpaceBasis used for the spline representation.}
#'   \item{OrigClass}{Character string recording the original class of the input
#'       data, useful for back-conversion.}
#'   \item{orthonormal}{Logical indicating whether the basis was orthonormal
#'       (`TRUE`) or required Gram matrix correction (`FALSE`).}
#'   \item{GramMatrix}{The Gram matrix S from the basis. Identity matrix if
#'       the basis is orthonormal.}
#' }
#'
#' @details
#' ## Conceptual Background
#'
#' In distributional data analysis, each observation is not a single number but
#' an entire probability distribution. Examples include:
#' \itemize{
#'   \item Particle size distributions from different samples
#'   \item Income distributions across different regions
#'   \item Age distributions of populations over time
#'   \item Spectral distributions in chemistry or physics
#' }
#'
#' PCA for such data aims to find the main "modes of variation" - the principal
#' ways in which distributions in the dataset differ from each other.
#'
#' ## Method
#'
#' The function works by:
#' \enumerate{
#'   \item Converting all input distributions to a common spline basis representation
#'       using \code{\link{as.BDataSplines}}. This yields a matrix where each row
#'       is an observation and each column is a spline basis coefficient.
#'   \item **Handling non-orthonormal basis:** If the basis has a non-identity Gram
#'       matrix S, the coefficients are transformed via \eqn{C' = C \cdot S^{1/2}}{C' = C * S^(1/2)}
#'       to orthonormalize the representation before PCA.
#'   \item Computing the covariance (or correlation) matrix of these (transformed)
#'       coefficients.
#'   \item Performing eigendecomposition to find the principal directions.
#'   \item Projecting the centered data onto these directions to obtain scores.
#'   \item **Back-transforming loadings:** If transformation was applied, loadings
#'       are back-transformed via \eqn{L = S^{-1/2} \cdot L'}{L = S^(-1/2) * L'} so they
#'       remain in the original coefficient space.
#' }
#'
#' ## Non-Orthonormal Basis Handling
#'
#' **Important:** Many spline bases, including ZB-splines (zero-integral B-splines),
#' are **not orthonormal**. For such bases, standard PCA on raw coefficients gives
#' incorrect results because the Euclidean inner product in coefficient space does
#' not correspond to the L2 inner product in function space.
#'
#' This function automatically detects non-orthonormal bases by checking the Gram
#' matrix S stored in the \code{\link{BayesSpaceBasis}}. If \eqn{S \neq I}{S != I},
#' the function applies the correction described in Hron et al. (2016, Eq. 6):
#'
#' \deqn{N^{-1} C' C M b = \rho b}{N^(-1) C'CM b = rho * b}
#'

#' where M is the Gram matrix. This is implemented via Cholesky transformation:
#' \enumerate{
#'   \item Compute \eqn{S^{1/2}}{S^(1/2)} via Cholesky decomposition: \eqn{S = L L^T}{S = L * L^T}
#'   \item Transform coefficients: \eqn{\tilde{C} = C \cdot L}{C_tilde = C * L}
#'   \item Perform standard PCA on \eqn{\tilde{C}}{C_tilde}
#'   \item Back-transform loadings: \eqn{V = L^{-1} \cdot \tilde{V}}{V = L^(-1) * V_tilde}
#' }
#'
#' The scores remain unchanged since they are computed with the orthonormalized
#' representation. The loadings are transformed back to the original coefficient
#' space for interpretability.
#'
#' **For users:** If you want to use an orthonormal basis, you can either:
#' \itemize{
#'   \item Use an explicitly orthonormal spline basis
#'   \item Provide the correct Gram matrix S when constructing the
#'       \code{\link{BayesSpaceBasis}} (the function will handle the correction)
#' }
#'
#' The Gram matrix S defines the inner product in coefficient space:
#' \eqn{\langle c_1, c_2 \rangle_S = c_1^T S c_2}{<c1, c2>_S = c1^T * S * c2}.
#' If S = I (identity), the basis is orthonormal and no correction is needed.
#'
#' This approach is analogous to \code{\link[compositions]{princomp.acomp}} from

#' the compositions package, which performs PCA on compositional data using
#' log-ratio coordinates.
#'
#' ## Interpretation
#'
#' \itemize{
#'   \item \strong{Eigenvalues/sdev}: The variance explained by each component.
#'       Large values indicate important modes of variation.
#'   \item \strong{Eigenfunctions}: Directions in coefficient space representing
#'       functional perturbations. When added to/subtracted from the mean, they
#'       show how distributions change along each PC.
#'   \item \strong{Scores}: Coordinates of each observation in the PC space.
#'       Similar scores indicate similar distribution shapes.
#' }
#'
#' Use \code{\link{plot.princomp.BData}} with `type="eigenfunctions"` to visualize
#' how the distribution shape changes along each principal component direction.
#'
#' ## Important Considerations
#'
#' \itemize{
#'   \item The number of meaningful components is limited by both the number of
#'       observations and the basis dimension.
#'   \item If your data shows that PC1 explains nearly 100\% of variance, the
#'       distributions may vary primarily in one characteristic (e.g., only in
#'       scale or only in location). This is not a bug but a property of your data.
#'   \item For meaningful PCA, ensure your dataset has genuine multivariate
#'       variation. Use \code{\link{B2GaussianDistribution}} with an appropriate
#'       covariance structure for simulation studies.
#' }
#'
#' @seealso
#' \code{\link{plot.princomp.BData}} for visualization options including
#'     screeplots, score plots, biplots, and functional loadings plots.
#'
#' \code{\link{biplot.princomp.BData}} for creating biplots showing both
#'     observations and loadings.
#'
#' \code{\link{predict.princomp.BData}} for projecting new distributions onto
#'     the principal components.
#'
#' \code{\link{summary.princomp.BData}} for a summary of variance explained.
#'
#' \code{\link{as.BDataSplines}} for the conversion to spline representation.
#'
#' \code{\link[compositions]{princomp.acomp}} for the analogous method for
#'     compositional data in the compositions package.
#'
#' @references
#' Hron, K., Menafoglio, A., Templ, M., Hruzova, K., & Filzmoser, P. (2016).
#' Simplicial principal component analysis for density functions in Bayes spaces.
#' \emph{Computational Statistics & Data Analysis}, 94, 330-350.
#'
#' @author Matthias Templ
#' @family PCA
#' @export
#' @importFrom stats princomp
#' @importFrom Matrix bdiag
#'
#' @examples
#' ## Example 1: Basic PCA on simulated distributional data
#' ## -------------------------------------------------------
#' ## We use B2GaussianDistribution to generate distributions with
#' ## genuine multivariate variation (not just scaling differences).
#'
#' # Define a Gaussian process on Bayes space with exponential covariance
#' meanDist <- BDataPDFs(list(dunif), basis = B2Unif01Basis7)
#' covFunction <- function(x, y) exp(-4 * abs(x - y)^1.7)
#' gaussProc <- B2GaussianDistribution(meanDist, covFunction)
#'
#' # Simulate 30 distributions from this process
#' simData <- simulate(gaussProc, nsim = 30, seed = 42)
#' splineData <- as.BDataSplines(simData)
#'
#' # Perform PCA
#' pcaResult <- princomp(splineData)
#'
#' # View results
#' pcaResult
#' summary(pcaResult)
#'
#' ## Example 2: Visualizing and interpreting the PCA results
#' ## ---------------------------------------------------------
#'
#' # Screeplot: How much variance does each PC explain?
#' plot(pcaResult, type = "screeplot")
#' # Interpretation: The first two components (PC1 and PC2) capture most of the
#' # variance (around 70%), with a clear "elbow" after PC3. This suggests that
#' # the main patterns in the distributional data can be described by 2-3 modes
#' # of variation.
#'
#' # Score plot: How do observations cluster in PC space?
#' plot(pcaResult, type = "scores")
#' # Interpretation: Each point represents one distribution. Distributions that
#' # are close together in this plot have similar shapes. Look for clusters
#' # (groups of similar distributions), outliers (isolated points), or gradients
#' # (continuous variation along a direction).
#'
#' # To identify individual distributions, use labels:
#' plot(pcaResult, type = "scores", labels = TRUE)
#' # Now each distribution is labeled with its observation number, making it
#' # easy to trace outliers or interesting cases back to the original data.
#'
#' # Loadings plot: How do distributions change along each PC?
#' # This is THE KEY PLOT for interpretation!
#' plot(pcaResult, type = "loadings", npcs = 2)
#' # Interpretation: The black line shows the mean distribution. For each PC,
#' # the solid colored line shows what a distribution looks like when it has a
#' # HIGH score on that PC (positive direction), while the dashed line shows a
#' # LOW score (negative direction). For example, if PC1+ is shifted right and
#' # PC1- is shifted left compared to the mean, then PC1 captures location
#' # variation. If PC1+ is wider and PC1- is narrower, then PC1 captures spread.
#'
#' # Biplot: Combine scores and loadings in one plot
#' biplot(pcaResult)
#' # Interpretation: Numbers show observations, red arrows show how spline
#' # coefficients contribute to the PCs. Observations in similar positions have
#' # similar distributions. The arrows are harder to interpret directly since
#' # they represent abstract basis coefficients - use the loadings plot above
#' # for functional interpretation.
#'
#' ## Example 3: Predicting scores for new data
#' ## -------------------------------------------
#' # Generate new distributions from the same process
#' newData <- as.BDataSplines(simulate(gaussProc, nsim = 5, seed = 123))
#'
#' # Project onto the existing PC space
#' newScores <- predict(pcaResult, newData)
#' print(newScores)
#'
#' ## Example 4: PCA on real age distribution data
#' ## ----------------------------------------------
#' # Load age distribution data from Upper Austrian communes
#' data("age")
#'
#' # Create a basis for age distributions (support 0-100 years)
#' rmAge <- ReferenceMeasureUniform(0, 100)
#' B2Age <- BayesSpace(rmAge)
#' basisAge <- BayesSpaceBasis(B2Age, nbasis = 7)
#'
#' # Convert proportions to BDataDensityHistograms
#' # The data are proportions in 5-year age bins (last bin is 10 years: 90-100)
#' breaks <- c(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 100)
#' ageHist <- BDataDensityHistograms(age$distributions, breaks = breaks, basis = basisAge)
#'
#' # Convert: histograms -> densities -> splines
#' ageDensities <- as.BDataDensities(ageHist)
#' ageSplines <- as.BDataSplines(ageDensities)
#'
#' # Perform PCA
#' pcaAge <- princomp(ageSplines)
#' summary(pcaAge)
#'
#' # Screeplot: Most variance is in PC1 and PC2
#' plot(pcaAge, type = "screeplot")
#'
#' # Score plot colored by sex
#' sexColors <- ifelse(age$data$sex == "male", "blue", "red")
#' plot(pcaAge, type = "scores", col = sexColors)
#' legend("topright", c("Male", "Female"), col = c("blue", "red"), pch = 19)
#' # Interpretation: Males and females separate along PC1, indicating that the
#' # primary mode of variation captures sex differences in age structure
#' # (e.g., females tend to live longer, creating different elderly proportions).
#'
#' # Loadings plot: What do the PCs represent?
#' plot(pcaAge, type = "loadings", npcs = 2)
#' # Interpretation: PC1 captures the balance between younger and older ages.
#' # PC2 may capture differences in the working-age population bulge.
#'
#' ## Example 5: Grouped PCA - compare males vs females
#' ## ---------------------------------------------------
#' # Perform PCA separately for each sex group
#' pcaGrouped <- princomp(ageSplines, group = age$data$sex)
#' pcaGrouped
#'
#' # Compare screeplots: Do males and females have similar dimensionality?
#' plot(pcaGrouped, type = "screeplot")
#'
#' # Compare loadings: Are the modes of variation the same?
#' plot(pcaGrouped, type = "loadings", npcs = 2)
#' # Interpretation: If the loadings look similar across groups, the same
#' # types of variation exist in both. If they differ, each group has
#' # distinct patterns of age distribution variation.
#'
#' # Compare score distributions with boxplots
#' plot(pcaGrouped, type = "boxplot", npcs = 3)
#'
#' ## Example 6: Multivariate SFPCA (2D-SFPCA) - joint analysis across groups
#' ## -------------------------------------------------------------------------
#' # Unlike grouped PCA which analyzes groups separately, multivariate SFPCA
#' # stacks the representations and finds COMMON principal directions across groups.
#' # This requires paired data: same observation index = same subject across groups.
#'
#' pcaMulti <- princomp(ageSplines, group = age$data$sex, multivariate = TRUE)
#' pcaMulti
#'
#' # Screeplot: Total variance across both male and female distributions
#' plot(pcaMulti, type = "screeplot")
#'
#' # Score plot: Each point is a commune, scored on joint PCs
#' plot(pcaMulti, type = "scores")
#'
#' # Eigenfunctions: Shows how each group component of the joint eigenfunction
#' # perturbs the mean. Left column = male, right column = female.
#' plot(pcaMulti, type = "eigenfunctions", npcs = 2)
#' # Interpretation: If PC1 shows similar patterns for both groups, they vary
#' # together. If patterns differ, the PC captures group-specific variation
#' # or interactions between groups.
#'
princomp.BData <- function(x, cor = FALSE, scores = TRUE, covmat = NULL,
                           center = NULL, group = NULL, multivariate = FALSE, ...) {
    cl <- match.call()
    cl[[1L]] <- as.name("princomp")

    # Handle grouped PCA
    if (!is.null(group)) {
        return(.princompGrouped(x, group = group, cor = cor, scores = scores,
                                covmat = covmat, center = center,
                                multivariate = multivariate, ...))
    }

    # Store original class for later conversion back
    origClass <- class(x)[1] # TODO: exploit common interface gsi.orig(x)

    # Get basis
    basis <- getBayesSpaceBasis(x)

    # Convert to spline representation
    xSplines <- as.BDataSplines(x, basis = basis)
    xMat <- unclass(xSplines)

    n <- nrow(xMat)
    p <- ncol(xMat)

    # Get the Gram matrix S from the basis
    # S defines the inner product: <c1, c2>_S = c1^T S c2
    # If S = I (identity), the basis is orthonormal
    S <- basis$S
    if (is.null(S)) {
        S <- diag(p)
    }

    # Check if basis is orthonormal (S is identity matrix)
    isOrthonormal <- isTRUE(all.equal(S, diag(nrow(S)),
                                       check.attributes = FALSE,
                                       tolerance = .Machine$double.eps^0.5))

    # For non-orthonormal basis, we need to transform coefficients
    # to account for the Gram matrix (Hron et al. 2016, Eq. 6)
    # Transform: C_tilde = C %*% S^(1/2)
    # Then standard PCA on C_tilde, then back-transform loadings
    if (!isOrthonormal) {
        # Compute S^(1/2) via Cholesky decomposition: S = L %*% t(L)
        # So S^(1/2) = L (lower triangular Cholesky factor)
        cholS <- tryCatch(
            chol(S),
            error = function(e) {
                # If Cholesky fails, S might not be positive definite
                # Fall back to eigendecomposition
                eigS <- eigen(S, symmetric = TRUE)
                if (any(eigS$values <= 0)) {
                    stop("Gram matrix S is not positive definite. ",
                         "Cannot perform PCA with this basis.")
                }
                eigS$vectors %*% diag(sqrt(eigS$values)) %*% t(eigS$vectors)
            }
        )
        # cholS is upper triangular from R's chol(), so S = t(cholS) %*% cholS
        # We use L = t(cholS) as the "square root"
        sqrtS <- t(cholS)
        sqrtS_inv <- solve(sqrtS)

        # Transform coefficient matrix: each row c_i -> c_i %*% sqrtS
        xMatTransformed <- xMat %*% sqrtS

        message("Note: Basis is non-orthonormal (Gram matrix S != I). ",
                "Applying transformation for correct SFPCA (see Hron et al. 2016).")
    } else {
        xMatTransformed <- xMat
        sqrtS <- diag(p)
        sqrtS_inv <- diag(p)
    }

    # Compute center if not provided (in original space)
    if (is.null(center)) {
        center <- colMeans(xMat)
    }

    # Center the transformed data
    if (!isOrthonormal) {
        centerTransformed <- as.vector(center %*% sqrtS)
        xCenteredTransformed <- sweep(xMatTransformed, 2, centerTransformed, "-")
    } else {
        xCenteredTransformed <- sweep(xMatTransformed, 2, center, "-")
    }

    # Compute covariance matrix if not provided (on transformed data)
    if (is.null(covmat)) {
        if (cor) {
            covmat <- stats::cor(xMatTransformed)
        } else {
            covmat <- stats::cov(xMatTransformed)
        }
    }

    # Eigendecomposition
    eig <- eigen(covmat, symmetric = TRUE)

    # Remove components with near-zero eigenvalues
    tol <- max(dim(covmat)) * max(abs(eig$values)) * .Machine$double.eps
    keep <- eig$values > tol
    if (!any(keep)) {
        stop("All eigenvalues are effectively zero")
    }

    # Standard deviations (sqrt of eigenvalues)
    sdev <- sqrt(eig$values[keep])
    names(sdev) <- paste0("Comp.", seq_along(sdev))

    # Loadings matrix (in transformed space)
    loadingsTransformed <- eig$vectors[, keep, drop = FALSE]

    # Back-transform loadings to original coefficient space with the
    # INVERSE TRANSPOSE of the whitening factor: with S = M %*% t(M) and
    # whitened coefficients C %*% M, a whitened direction v_tilde
    # corresponds to the original-coordinate loading v = t(M)^{-1} v_tilde,
    # which satisfies <c, v>_S = c_tilde %*% v_tilde and
    # t(v) %*% S %*% v = 1 (S-orthonormal eigenfunctions).
    # M^{-1} v_tilde is wrong for the non-symmetric Cholesky factor.
    if (!isOrthonormal) {
        loadings <- t(sqrtS_inv) %*% loadingsTransformed
    } else {
        loadings <- loadingsTransformed
    }
    dimnames(loadings) <- list(paste0("Coef.", seq_len(p)),
                               paste0("Comp.", seq_along(sdev)))
    class(loadings) <- "loadings"

    # Compute scores if requested
    # Scores are computed in transformed space: scores = C_centered_transformed %*% V_transformed
    # This is equivalent to scores in original space with Gram matrix correction
    if (scores) {
        sc <- xCenteredTransformed %*% loadingsTransformed
        dimnames(sc) <- list(rownames(xMat), colnames(loadings))
    } else {
        sc <- NULL
    }

    # Create the mean as a BData object (Center) - in original space
    Center <- BDataSplines(matrix(center, nrow = 1), basis = basis)

    # Create loadings as BDataSplines (each column is a loading)
    # Loadings are in original coefficient space
    LoadingsBData <- BDataSplines(t(loadings), basis = basis)

    # Scale if correlation was used
    if (cor) {
        scale <- apply(xMatTransformed, 2, stats::sd)
    } else {
        scale <- rep(1, p)
    }
    names(scale) <- paste0("Coef.", seq_len(p))

    # Construct result
    # Note: "loadings" kept for compatibility with stats::princomp
    # "eigenfunctions" is the proper SFPCA terminology (Hron et al. 2016)
    result <- list(
        sdev = sdev,
        loadings = loadings,
        eigenfunctions = loadings,
        center = center,
        scale = scale,
        n.obs = n,
        scores = sc,
        call = cl,
        Center = Center,
        Loadings = LoadingsBData,
        Eigenfunctions = LoadingsBData,
        basis = basis,
        OrigClass = origClass,
        orthonormal = isOrthonormal,
        GramMatrix = S
    )
    attr(result, "orig") = x
    class(result) <- c("princomp.BData", "princomp")
    return(result)
}


# Internal function: Grouped PCA
# Routes to either separate PCA per group or multivariate (joint) SFPCA
.princompGrouped <- function(x, group, cor = FALSE, scores = TRUE,
                             covmat = NULL, center = NULL,
                             multivariate = FALSE, ...) {
    if (multivariate) {
        return(.princompMultivariate(x, group = group, cor = cor,
                                     scores = scores, covmat = covmat,
                                     center = center, ...))
    }

    # Get coefficient matrix and basis
    xMat <- unclass(x)
    basis <- getBayesSpaceBasis(x)

    if (length(group) != nrow(xMat)) {
        stop("Length of 'group' must match number of observations in 'x'")
    }

    group <- as.factor(group)
    levels <- levels(group)

    # Perform PCA for each group separately
    results <- lapply(levels, function(lvl) {
        idx <- which(group == lvl)
        # Subset the matrix and recreate BDataSplines
        xSubMat <- xMat[idx, , drop = FALSE]
        xSub <- BDataSplines(xSubMat, basis = basis)
        princomp.BData(xSub, cor = cor, scores = scores,
                       covmat = covmat, center = center, group = NULL, ...)
    })
    names(results) <- levels

    # Store group information
    structure(
        results,
        class = c("princomp.BData.grouped", "list"),
        group = group,
        levels = levels,
        call = match.call()
    )
}


# Internal function: Multivariate SFPCA (2D-SFPCA)
# Performs joint PCA on stacked clr-representations across groups
# Following the approach in Hron et al. (2016) and Menafoglio's implementation
#
# Key difference from separate grouped PCA:
# - Stacks coefficients: for observation i, [coefs_group1, coefs_group2, ...]
# - Single covariance on stacked (n_groups * n_coefs) dimensional space
# - Eigenfunctions span all groups jointly
# - Captures covariation BETWEEN groups
#
# Requires paired observations (same indices = same subject across groups)
.princompMultivariate <- function(x, group, cor = FALSE, scores = TRUE,
                                   covmat = NULL, center = NULL, ...) {
    cl <- match.call()
    cl[[1L]] <- as.name("princomp")

    # Get coefficient matrix and basis
    xMat <- unclass(x)
    basis <- getBayesSpaceBasis(x)
    origClass <- class(x)[1]  # TODO: exploit common interface gsi.orig(x)
    p <- ncol(xMat)  # Number of coefficients per distribution

    if (length(group) != nrow(xMat)) {
        stop("Length of 'group' must match number of observations in 'x'")
    }

    group <- as.factor(group)
    levels <- levels(group)
    nGroups <- length(levels)

    # Get indices for each group
    groupIndices <- lapply(levels, function(lvl) which(group == lvl))
    groupSizes <- sapply(groupIndices, length)

    # Check that all groups have the same number of observations (paired data)
    if (length(unique(groupSizes)) != 1) {
        stop("Multivariate SFPCA requires paired observations: ",
             "all groups must have the same number of observations.\n",
             "Group sizes: ", paste(levels, "=", groupSizes, collapse = ", "))
    }
    nObs <- groupSizes[1]

    # Get the Gram matrix S from the basis
    S <- basis$S
    if (is.null(S)) {
        S <- diag(p)
    }

    # Check if basis is orthonormal
    isOrthonormal <- isTRUE(all.equal(S, diag(nrow(S)),
                                       check.attributes = FALSE,
                                       tolerance = .Machine$double.eps^0.5))

    # For non-orthonormal basis, compute transformation matrices
    if (!isOrthonormal) {
        cholS <- tryCatch(
            chol(S),
            error = function(e) {
                eigS <- eigen(S, symmetric = TRUE)
                if (any(eigS$values <= 0)) {
                    stop("Gram matrix S is not positive definite.")
                }
                eigS$vectors %*% diag(sqrt(eigS$values)) %*% t(eigS$vectors)
            }
        )
        sqrtS <- t(cholS)
        sqrtS_inv <- solve(sqrtS)

        # For stacked coefficients, the Gram matrix is block-diagonal
        # S_stacked = diag(S, S, ..., S) with nGroups blocks
        sqrtS_stacked <- as.matrix(Matrix::bdiag(rep(list(sqrtS), nGroups)))
        sqrtS_inv_stacked <- as.matrix(Matrix::bdiag(rep(list(sqrtS_inv), nGroups)))

        message("Note: Basis is non-orthonormal (Gram matrix S != I). ",
                "Applying transformation for correct multivariate SFPCA.")
    } else {
        sqrtS_stacked <- diag(p * nGroups)
        sqrtS_inv_stacked <- diag(p * nGroups)
    }

    # Stack coefficients: for each observation i, concatenate coefficients
    # from all groups [coefs_group1_i, coefs_group2_i, ...]
    # Result: nObs x (nGroups * p) matrix
    xStacked <- matrix(NA, nrow = nObs, ncol = nGroups * p)
    colnames(xStacked) <- paste0(rep(levels, each = p), ".Coef.", rep(1:p, nGroups))

    for (g in seq_along(levels)) {
        idx <- groupIndices[[g]]
        cols <- ((g - 1) * p + 1):(g * p)
        xStacked[, cols] <- xMat[idx, , drop = FALSE]
    }

    # Apply Gram matrix transformation if needed
    if (!isOrthonormal) {
        xStackedTransformed <- xStacked %*% sqrtS_stacked
    } else {
        xStackedTransformed <- xStacked
    }

    # Compute center if not provided
    if (is.null(center)) {
        centerStacked <- colMeans(xStacked)
    } else {
        # center should be provided as stacked vector
        centerStacked <- center
    }

    # Center the transformed data
    if (!isOrthonormal) {
        centerTransformed <- as.vector(centerStacked %*% sqrtS_stacked)
        xCenteredTransformed <- sweep(xStackedTransformed, 2, centerTransformed, "-")
    } else {
        xCenteredTransformed <- sweep(xStackedTransformed, 2, centerStacked, "-")
    }

    # Compute covariance matrix if not provided
    if (is.null(covmat)) {
        if (cor) {
            covmat <- stats::cor(xStackedTransformed)
        } else {
            covmat <- stats::cov(xStackedTransformed)
        }
    }

    # Eigendecomposition
    eig <- eigen(covmat, symmetric = TRUE)

    # Remove components with near-zero eigenvalues
    tol <- max(dim(covmat)) * max(abs(eig$values)) * .Machine$double.eps
    keep <- eig$values > tol
    if (!any(keep)) {
        stop("All eigenvalues are effectively zero")
    }

    # Standard deviations
    sdev <- sqrt(eig$values[keep])
    names(sdev) <- paste0("Comp.", seq_along(sdev))

    # Loadings (in transformed space)
    loadingsTransformed <- eig$vectors[, keep, drop = FALSE]

    # Normalize eigenfunctions so that L2 norm = 1
    # In transformed (orthonormal) space, this is just Euclidean norm
    # But we normalize per-group to match Alessandra's approach
    for (i in seq_len(ncol(loadingsTransformed))) {
        # L2 norm using trapezoidal approximation would require evaluation
        # For coefficient space, we use the Gram-weighted norm
        # In transformed space, it's just Euclidean
        norm_i <- sqrt(sum(loadingsTransformed[, i]^2))
        loadingsTransformed[, i] <- loadingsTransformed[, i] / norm_i
    }

    # Back-transform loadings to original coefficient space with the
    # inverse TRANSPOSE (see the comment in princomp.BData)
    if (!isOrthonormal) {
        loadings <- t(sqrtS_inv_stacked) %*% loadingsTransformed
    } else {
        loadings <- loadingsTransformed
    }
    dimnames(loadings) <- list(colnames(xStacked),
                               paste0("Comp.", seq_along(sdev)))
    class(loadings) <- "loadings"

    # Compute scores: project centered data onto eigenvectors
    if (scores) {
        sc <- xCenteredTransformed %*% loadingsTransformed
        dimnames(sc) <- list(paste0("Obs.", seq_len(nObs)), colnames(loadings))
    } else {
        sc <- NULL
    }

    # Create the mean as a list of BDataSplines objects (one per group)
    CenterList <- lapply(seq_along(levels), function(g) {
        cols <- ((g - 1) * p + 1):(g * p)
        BDataSplines(matrix(centerStacked[cols], nrow = 1), basis = basis)
    })
    names(CenterList) <- levels

    # Create loadings as list of BDataSplines objects (one per group)
    # Each eigenfunction has components for each group
    LoadingsList <- lapply(seq_along(levels), function(g) {
        cols <- ((g - 1) * p + 1):(g * p)
        BDataSplines(t(loadings[cols, , drop = FALSE]), basis = basis)
    })
    names(LoadingsList) <- levels

    # Scale if correlation was used
    if (cor) {
        scale <- apply(xStackedTransformed, 2, stats::sd)
    } else {
        scale <- rep(1, p * nGroups)
    }
    names(scale) <- colnames(xStacked)

    # Construct result
    result <- list(
        sdev = sdev,
        loadings = loadings,
        eigenfunctions = loadings,
        center = centerStacked,
        scale = scale,
        n.obs = nObs,
        scores = sc,
        call = cl,
        Center = CenterList,
        Loadings = LoadingsList,
        Eigenfunctions = LoadingsList,
        basis = basis,
        OrigClass = origClass,
        orthonormal = isOrthonormal,
        GramMatrix = S,
        # Multivariate-specific fields
        levels = levels,
        nGroups = nGroups,
        nCoefs = p
    )

    class(result) <- c("princomp.BData.multivariate", "princomp.BData", "princomp")
    return(result)
}


#' Print a grouped distributional PCA
#'
#' @param x a `princomp.BData.grouped` object (from [princomp.BData] with a `group`).
#' @param ... further arguments, currently unused.
#' @returns `x`, invisibly. Prints, per group, the number of observations and the
#'     share of variance explained by the first principal component.
#' @seealso [princomp.BData]
#' @export
#' @method print princomp.BData.grouped
#' @examples
#' \donttest{
#' data("age")
#' basisAge <- BayesSpaceBasis(BayesSpace(ReferenceMeasureUniform(0, 100)), nbasis = 7)
#' breaks <- c(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 100)
#' ageHist <- BDataDensityHistograms(age$distributions, breaks = breaks, basis = basisAge)
#' ageSplines <- as.BDataSplines(as.BDataDensities(ageHist))
#' pcaGrouped <- princomp(ageSplines, group = age$data$sex)
#' print(pcaGrouped)
#' }
print.princomp.BData.grouped <- function(x, ...) {
    levels <- attr(x, "levels")
    cat("Grouped PCA on distributional data\n")
    cat("Groups:", paste(levels, collapse = ", "), "\n\n")

    for (lvl in levels) {
        cat("--- Group:", lvl, "---\n")
        cat("  Observations:", x[[lvl]]$n.obs, "\n")
        vars <- x[[lvl]]$sdev^2
        pct1 <- round(100 * vars[1] / sum(vars), 1)
        cat("  PC1 explains:", pct1, "% of variance\n")
    }
    invisible(x)
}


#' Print a multivariate (2D-)SFPCA on distributional data
#'
#' @param x a `princomp.BData.multivariate` object (from [princomp.BData] with
#'     `multivariate = TRUE`).
#' @param ... further arguments, currently unused.
#' @returns `x`, invisibly. Prints the groups, observations and dimensions of the
#'     joint analysis together with the variance explained.
#' @seealso [princomp.BData]
#' @export
#' @method print princomp.BData.multivariate
#' @examples
#' \donttest{
#' data("age")
#' basisAge <- BayesSpaceBasis(BayesSpace(ReferenceMeasureUniform(0, 100)), nbasis = 7)
#' breaks <- c(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 100)
#' ageHist <- BDataDensityHistograms(age$distributions, breaks = breaks, basis = basisAge)
#' ageSplines <- as.BDataSplines(as.BDataDensities(ageHist))
#' pcaMulti <- princomp(ageSplines, group = age$data$sex, multivariate = TRUE)
#' print(pcaMulti)
#' }
print.princomp.BData.multivariate <- function(x, ...) {
    cat("Multivariate SFPCA (2D-SFPCA) on distributional data\n")
    cat("Groups:", paste(x$levels, collapse = ", "), "\n")
    cat("Observations per group:", x$n.obs, "\n")
    cat("Coefficients per group:", x$nCoefs, "\n")
    cat("Total dimensions:", x$nCoefs * x$nGroups, "\n")
    if (!x$orthonormal) {
        cat("Note: Non-orthonormal basis, Gram matrix correction applied\n")
    }
    cat("\n")

    # Variance explained
    vars <- x$sdev^2
    propVar <- vars / sum(vars)
    cumProp <- cumsum(propVar)

    nshow <- min(5, length(vars))
    cat("Importance of components:\n")
    cat(sprintf("  %-12s", ""), paste(sprintf("%-10s", paste0("PC", 1:nshow)), collapse = ""), "\n")
    cat(sprintf("  %-12s", "Std. Dev."),
        paste(sprintf("%-10.4f", x$sdev[1:nshow]), collapse = ""), "\n")
    cat(sprintf("  %-12s", "Prop. Var."),
        paste(sprintf("%-10.4f", propVar[1:nshow]), collapse = ""), "\n")
    cat(sprintf("  %-12s", "Cum. Prop."),
        paste(sprintf("%-10.4f", cumProp[1:nshow]), collapse = ""), "\n")

    invisible(x)
}


#' Plot Method for Multivariate SFPCA of Distributional Data
#'
#' @description
#' Creates visualization plots for multivariate SFPCA (2D-SFPCA) results.
#' Eigenfunctions span multiple groups and are shown with one panel per group.
#'
#' @param x An object of class `princomp.BData.multivariate` as returned by
#'     \code{\link{princomp.BData}} with `group` and `multivariate = TRUE`.
#' @param type Character string specifying the type of plot:
#'     \describe{
#'       \item{"screeplot"}{Variance explained by each component}
#'       \item{"scores"}{Scatter plot of PC scores}
#'       \item{"eigenfunctions"}{Eigenfunctions shown per group (default)}
#'       \item{"loadings"}{Alias for eigenfunctions}
#'     }
#' @param npcs Number of principal components to display. Default is 2.
#' @param pcs Which PCs to use for scores plot. Default is c(1, 2).
#' @param as Representation for eigenfunction plots: "PDF", "clr", "density", etc.
#' @param ... Additional graphical arguments.
#'
#' @return Invisibly returns the multivariate PCA object.
#'
#' @details
#' For multivariate SFPCA, eigenfunctions are joint across all groups. The
#' eigenfunctions plot shows how each group's component of the eigenfunction
#' affects the distribution shape.
#'
#' @seealso
#' \code{\link{princomp.BData}} for performing multivariate SFPCA.
#'
#' @author Matthias Templ
#' @family PCA
#' @export
#' @method plot princomp.BData.multivariate
#' @importFrom graphics par mtext layout
#'
#' @examples
#' \donttest{
#' data("age")
#' basisAge <- BayesSpaceBasis(BayesSpace(ReferenceMeasureUniform(0, 100)), nbasis = 7)
#' breaks <- c(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 100)
#' ageHist <- BDataDensityHistograms(age$distributions, breaks = breaks, basis = basisAge)
#' ageSplines <- as.BDataSplines(as.BDataDensities(ageHist))
#' pcaMulti <- princomp(ageSplines, group = age$data$sex, multivariate = TRUE)
#' plot(pcaMulti, type = "eigenfunctions", npcs = 2)
#' }
plot.princomp.BData.multivariate <- function(x,
                                              type = c("screeplot", "variance",
                                                       "scores", "eigenfunctions",
                                                       "loadings"),
                                              npcs = 2, pcs = c(1, 2),
                                              as = "PDF", ...) {
    type <- match.arg(type)

    # Alias loadings -> eigenfunctions
    if (type == "loadings") type <- "eigenfunctions"
    if (type == "variance") type <- "screeplot"

    levels <- x$levels
    nGroups <- x$nGroups

    if (type == "screeplot") {
        # Standard screeplot
        vars <- x$sdev^2
        propVar <- 100 * vars / sum(vars)
        nshow <- min(10, length(vars))

        graphics::barplot(propVar[1:nshow],
                          names.arg = paste0("PC", 1:nshow),
                          ylab = "% Variance Explained",
                          main = "Multivariate SFPCA Screeplot",
                          col = "steelblue", ...)
    } else if (type == "scores") {
        # Score scatter plot
        if (is.null(x$scores)) {
            stop("Scores not available. Rerun princomp with scores = TRUE.")
        }

        sc <- x$scores
        graphics::plot(sc[, pcs[1]], sc[, pcs[2]],
                       xlab = paste0("PC", pcs[1]),
                       ylab = paste0("PC", pcs[2]),
                       main = "Multivariate SFPCA Scores",
                       pch = 19, ...)
        graphics::abline(h = 0, v = 0, col = "gray", lty = 2)
    } else if (type == "eigenfunctions") {
        # Plot eigenfunctions for each group
        # Following the approach in .plotLoadingsBData
        oldpar <- graphics::par(no.readonly = TRUE)
        on.exit(graphics::par(oldpar))

        # Layout: npcs rows, nGroups columns
        graphics::par(mfrow = c(npcs, nGroups), mar = c(4, 4, 3, 1))

        basis <- x$basis

        for (pc in 1:npcs) {
            for (g in seq_along(levels)) {
                lvl <- levels[g]

                # Get mean coefficients for this group
                meanCoefs <- unclass(x$Center[[lvl]])[1, ]

                # Get eigenfunction coefficients for this group
                eigCoefs <- unclass(x$Eigenfunctions[[lvl]])[pc, ]

                # Scale by standard deviation
                sdPC <- x$sdev[pc]
                scaledEig <- sdPC * eigCoefs

                # Create BDataSplines for mean, plus, and minus
                meanBData <- BDataSplines(matrix(meanCoefs, nrow = 1), basis = basis)
                posCoefs <- meanCoefs + scaledEig
                negCoefs <- meanCoefs - scaledEig
                posBData <- BDataSplines(matrix(posCoefs, nrow = 1), basis = basis)
                negBData <- BDataSplines(matrix(negCoefs, nrow = 1), basis = basis)

                # Variance explained
                pct <- round(100 * x$sdev[pc]^2 / sum(x$sdev^2), 1)
                mainTitle <- paste0(lvl, ": PC", pc, " (", pct, "%)")

                # Plot using BDataSplines plot method; ... lets callers set
                # e.g. xlab/ylab on the panels
                if (as == "clr") {
                    plot(clr(meanBData), col = "black", lwd = 2, lty = 1,
                         main = mainTitle, ...)
                    plot(clr(posBData), add = TRUE, col = "blue", lwd = 1.5, lty = 1)
                    plot(clr(negBData), add = TRUE, col = "red", lwd = 1.5, lty = 2)
                } else {
                    plot(meanBData, as = as, close = TRUE, col = "black", lwd = 2,
                         lty = 1, main = mainTitle, ...)
                    plot(posBData, as = as, close = TRUE, add = TRUE, col = "blue",
                         lwd = 1.5, lty = 1)
                    plot(negBData, as = as, close = TRUE, add = TRUE, col = "red",
                         lwd = 1.5, lty = 2)
                }
            }
        }
    }

    invisible(x)
}


#' Plot Method for Grouped PCA of Distributional Data
#'
#' @description
#' Creates multi-panel comparison plots for grouped PCA results. Each group
#' is shown in a separate panel, allowing visual comparison of PCA results
#' across different subgroups of the data.
#'
#' @param x An object of class `princomp.BData.grouped` as returned by
#'     \code{\link{princomp.BData}} with a `group` argument.
#' @param type Character string specifying the type of plot. Same options as
#'     \code{\link{plot.princomp.BData}}: `"screeplot"`, `"variance"`,
#'     `"scores"`, `"loadings"`, `"covariance"`, or `"reconstruction"`.
#' @param mfrow Optional vector of c(nrow, ncol) for panel layout. If `NULL`,
#'     panels are arranged automatically in a single row.
#' @param main Character string for overall title. If `NULL`, group names
#'     are used as individual panel titles.
#' @param ... Additional arguments passed to \code{\link{plot.princomp.BData}}.
#'
#' @return Invisibly returns the grouped PCA object.
#'
#' @details
#' This function sets up a multi-panel plotting layout and calls
#' \code{\link{plot.princomp.BData}} for each group. The layout is automatically
#' determined based on the number of groups, but can be overridden using `mfrow`.
#'
#' Common use cases:
#' \itemize{
#'   \item Compare loadings across groups to see if different subgroups have
#'       different modes of variation.
#'   \item Compare screeplots to assess if dimensionality differs by group.
#'   \item Compare score plots to see internal structure within each group.
#' }
#'
#' @seealso
#' \code{\link{princomp.BData}} for performing grouped PCA.
#' \code{\link{plot.princomp.BData}} for single-group plot options.
#'
#' @author Matthias Templ
#' @family PCA
#' @export
#' @method plot princomp.BData.grouped
#' @importFrom graphics par mtext
#'
#' @examples
#' # Create grouped PCA using age distribution data
#' data("age")
#'
#' # Set up basis
#' rmAge <- ReferenceMeasureUniform(0, 100)
#' B2Age <- BayesSpace(rmAge)
#' basisAge <- BayesSpaceBasis(B2Age, nbasis = 7)
#'
#' # Create histograms and convert to splines
#' breaks <- c(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 100)
#' ageHist <- BDataDensityHistograms(age$distributions, breaks = breaks, basis = basisAge)
#' ageSplines <- as.BDataSplines(as.BDataDensities(ageHist))
#'
#' # Perform grouped PCA by sex
#' pcaGrouped <- princomp(ageSplines, group = age$data$sex)
#' pcaGrouped
#'
#' # Compare screeplots by group
#' plot(pcaGrouped, type = "screeplot")
#'
#' # Compare loadings by group
#' plot(pcaGrouped, type = "loadings", npcs = 2)
#'
#' # Custom layout (2 rows)
#' plot(pcaGrouped, type = "scores", mfrow = c(2, 1))
#'
plot.princomp.BData.grouped <- function(x, type = c("screeplot", "variance",
                                                     "scores", "eigenfunctions",
                                                     "loadings", "covariance",
                                                     "reconstruction", "boxplot"),
                                         mfrow = NULL, main = NULL, ...) {
    type <- match.arg(type)

    # "loadings" is kept as alias for "eigenfunctions" for backwards compatibility
    if (type == "loadings") type <- "eigenfunctions"

    levels <- attr(x, "levels")

    # Validate input
    if (is.null(levels) || length(levels) == 0) {
        stop("Invalid grouped PCA object: no 'levels' attribute found. ",
             "Use princomp() with 'group' argument to create grouped PCA.")
    }

    nGroups <- length(levels)

    # Set up panel layout
    oldpar <- par(no.readonly = TRUE)
    on.exit(par(oldpar))

    if (is.null(mfrow)) {
        # Automatic layout: single row
        mfrow <- c(1, nGroups)
    }
    par(mfrow = mfrow)

    # Plot each group
    for (i in seq_along(levels)) {
        lvl <- levels[i]
        groupMain <- if (is.null(main)) lvl else paste(main, "-", lvl)

        plot(x[[lvl]], type = type, main = groupMain, ...)
    }

    invisible(x)
}


#' @describeIn princomp.BData Print method displaying key PCA results
#' @param x A princomp.BData object to print.
#' @param ... Additional arguments (currently unused).
#' @export
#' @method print princomp.BData
print.princomp.BData <- function(x, ...) {
    cat("Call:\n")
    print(x$call)
    cat("\nStandard deviations:\n")
    print(x$sdev)
    vars <- x$sdev^2
    pct1 <- round(100 * vars[1] / sum(vars), 1)
    pct2 <- if (length(vars) > 1) round(100 * sum(vars[1:min(2, length(vars))]) / sum(vars), 1) else pct1
    cat("\nPC1 explains", pct1, "% of the variance")
    if (length(vars) > 1) {
        cat(", PC1-2 explain", pct2, "%")
    }
    cat("\nBasis dimension:", ncol(x$loadings), "\n")
    cat("Number of observations:", x$n.obs, "\n")

    # Report on orthonormality
    if (!is.null(x$orthonormal)) {
        if (x$orthonormal) {
            cat("Basis: orthonormal (no Gram matrix correction needed)\n")
        } else {
            cat("Basis: non-orthonormal (Gram matrix correction applied)\n")
        }
    }

    invisible(x)
}


#' Plot Method for PCA of Distributional Data
#'
#' @description
#' A versatile plotting function for visualizing principal component analysis
#' results on distributional data. Provides multiple plot types for comprehensive
#' exploration of the PCA results, including the unique ability to visualize
#' loadings as functional perturbations of the mean distribution.
#'
#' @param x An object of class `princomp.BData` as returned by
#'     \code{\link{princomp.BData}}.
#' @param type Character string specifying the type of plot to produce:
#'   \describe{
#'     \item{`"screeplot"`}{Bar chart showing the percentage of variance explained
#'         by each principal component. Useful for deciding how many components
#'         to retain.}
#'     \item{`"variance"`}{Line plot showing cumulative variance explained as
#'         more components are included. Horizontal reference lines at 80\%, 90\%,
#'         and 95\% help identify cutoff points.}
#'     \item{`"scores"`}{Scatter plot of observations in the space of two
#'         principal components. Useful for identifying clusters, outliers, or
#'         patterns in the data.}
#'     \item{`"eigenfunctions"`}{\strong{Key plot for interpretation.} Shows how the
#'         distribution shape changes along each principal component direction.
#'         The mean distribution is shown along with perturbations in the positive
#'         and negative PC directions. This is the standard SFPCA terminology
#'         (Hron et al. 2016).}
#'     \item{`"loadings"`}{Alias for `"eigenfunctions"` (kept for backwards
#'         compatibility).}
#'     \item{`"biplot"`}{Combines scores and eigenfunctions in a single plot. Calls
#'         \code{\link{biplot.princomp.BData}}.}
#'     \item{`"covariance"`}{Heatmap showing the functional covariance surface.
#'         This visualizes which parts of the domain co-vary: high values indicate
#'         that when one part of the distribution is above average, the other part
#'         tends to be as well. Useful for understanding the correlation structure
#'         across the support of the distributions.}
#'     \item{`"reconstruction"`}{Shows the reconstructed distributions using only
#'         the first `npcs` principal components. Useful for assessing how well
#'         a reduced number of components approximates the original data.}
#'     \item{`"boxplot"`}{Boxplots showing the distribution of scores for each
#'         principal component. Useful for visualizing the spread of observations
#'         along each PC direction and identifying potential outliers.}
#'   }
#' @param choices Integer vector of length 2 specifying which principal components
#'     to plot (for `type="scores"` and `type="biplot"`). Default is `c(1, 2)`.
#' @param scale Scaling factor for loadings in biplot (passed to
#'     \code{\link{biplot.princomp.BData}}). Default is 1.
#' @param pc.biplot Logical. If `TRUE`, use Gabriel's biplot scaling. Default
#'     is `FALSE`.
#' @param main Character string for plot title. If `NULL`, an appropriate default
#'     is generated based on the plot type.
#' @param col Colors for plotting. For loadings plot: first color is for the mean,
#'     subsequent colors for each PC. For scores plot: color for points/labels.
#' @param labels For `type="scores"`: Controls labeling of observations:
#'   \describe{
#'     \item{`FALSE`}{(Default) Show only points, no labels.}
#'     \item{`TRUE`}{Show observation numbers (1, 2, 3, ...) instead of points.}
#'     \item{character vector}{Use custom labels for each observation.}
#'   }
#'   This helps identify which distribution each point represents.
#' @param lwd Line width for functional plots (loadings). Default is 1.
#' @param lty Line type (currently not directly used; solid for positive direction,
#'     dashed for negative).
#' @param legend Logical. If `TRUE` (default), add a legend to the loadings plot.
#' @param ... Additional graphical parameters passed to underlying plot functions.
#' @param npcs Number of principal components to display in screeplot, variance
#'     plot, or loadings plot. Default is `min(10, length(x$sdev))`.
#' @param as For loadings plot: how to display the distributions. One of:
#'   \describe{
#'     \item{`"PDF"`}{Probability density function (default). Shows distributions
#'         as proper densities that integrate to 1.}
#'     \item{`"density"`}{Density with respect to the reference measure.}
#'     \item{`"logPDF"`}{Log of the probability density function.}
#'     \item{`"logDensity"`}{Log density with respect to reference measure.}
#'     \item{`"clr"`}{Centered log-ratio transformation. Shows the additive
#'         structure in Bayes space. The clr is centered around zero, making
#'         it easy to see positive and negative deviations from the geometric
#'         mean. This is the natural representation for Bayes space PCA.}
#'     \item{`"coef"`}{Raw spline coefficients (parallel coordinates plot).
#'         Useful for seeing which coefficients drive each PC.}
#'   }
#' @param scale.sdev Numeric scaling factor for the loadings plot. Controls how
#'     far from the mean the perturbed distributions are shown:
#'   \describe{
#'     \item{`1`}{Show distributions at ±1 standard deviation from mean (default).}
#'     \item{`2`}{Show distributions at ±2 standard deviations (more extreme).}
#'     \item{`NA`}{Show unit loadings without scaling by standard deviation.}
#'   }
#'
#' @return Invisibly returns the data used for plotting (varies by plot type).
#'
#' @details
#' ## Understanding the Eigenfunctions Plot
#'
#' The `type="eigenfunctions"` plot is the most important visualization for
#' interpreting SFPCA results on distributional data. It answers the question:
#' "How does the distribution shape change as we move along each principal component?"
#'
#' The plot shows:
#' \itemize{
#'   \item \strong{Black thick line}: The mean distribution (center of the data).
#'   \item \strong{Colored solid lines}: Distributions at +`scale.sdev` standard
#'       deviations from the mean along each PC direction (positive perturbation).
#'   \item \strong{Colored dashed lines}: Distributions at -`scale.sdev` standard
#'       deviations from the mean along each PC direction (negative perturbation).
#' }
#'
#' For example, if PC1 captures variation in the "location" of distributions, the
#' PC1+ line will be shifted right and PC1- will be shifted left compared to the
#' mean. If PC1 captures variation in "spread", PC1+ will be wider and PC1- will
#' be narrower.
#'
#' ## Choosing the Number of Components
#'
#' Use the screeplot and variance plot to decide how many components are meaningful:
#' \itemize{
#'   \item \strong{Screeplot}: Look for an "elbow" where the variance drops off.
#'       Components before the elbow are typically retained.
#'   \item \strong{Variance plot}: Choose enough components to explain a target
#'       percentage (e.g., 80\% or 90\%) of total variance.
#' }
#'
#' @seealso
#' \code{\link{princomp.BData}} for performing the PCA.
#'
#' \code{\link{biplot.princomp.BData}} for detailed control over biplots.
#'
#' \code{\link{screeplot.princomp.BData}} for the standalone screeplot function.
#'
#' @author Matthias Templ
#' @family PCA
#' @export
#' @method plot princomp.BData
#' @importFrom graphics par barplot lines legend axis abline arrows text points
#'
#' @examples
#' ## Setup: Create PCA result for examples
#' meanDist <- BDataPDFs(list(dunif), basis = B2Unif01Basis7)
#' gaussProc <- B2GaussianDistribution(meanDist, function(x,y) exp(-4*abs(x-y)^1.7))
#' pcaResult <- princomp(as.BDataSplines(simulate(gaussProc, 30, seed = 42)))
#'
#' ## Screeplot: Decide how many components to keep
#' plot(pcaResult, type = "screeplot")
#' # Interpretation: The bars show the percentage of total variance explained by
#' # each PC. Here, PC1 and PC2 dominate, suggesting that the main structure in
#' # the data can be captured by just two components. The "elbow" after PC2 or
#' # PC3 indicates where additional components add little explanatory power.
#'
#' ## Cumulative variance: What percentage is explained?
#' plot(pcaResult, type = "variance")
#' # Interpretation: This shows how total explained variance accumulates as more
#' # components are included. The dashed reference lines help identify how many
#' # components are needed to reach common thresholds (80%, 90%, 95%). Here,
#' # approximately 3 components explain over 90% of the variance.
#'
#' ## Score plot: Explore structure in the data
#' plot(pcaResult, type = "scores")
#' # Interpretation: Each point is one distribution projected onto PC1 and PC2.
#' # The spread along PC1 (x-axis) shows variation in the first mode; spread
#' # along PC2 (y-axis) shows the second mode. Clusters indicate groups of
#' # similar distributions; outliers are distributions with unusual shapes.
#'
#' ## Score plot with labels: Identify individual distributions
#' plot(pcaResult, type = "scores", labels = TRUE)
#' # Using labels=TRUE replaces points with observation numbers (1, 2, 3, ...),
#' # allowing you to identify which specific distribution each position
#' # represents. This is useful for tracking outliers or interesting cases
#' # back to the original data.
#'
#' ## Eigenfunctions plot: THE KEY INTERPRETIVE PLOT
#' plot(pcaResult, type = "eigenfunctions", npcs = 2)
#' # Interpretation: The black curve is the mean (average) distribution. For
#' # each PC, solid lines show how distributions look when they score HIGH on
#' # that component, dashed lines show LOW scores. Compare the colored curves
#' # to the black mean to see what "high PC1" or "low PC2" means in terms of
#' # actual distribution shape (e.g., shifted location, changed spread, altered
#' # skewness, or different modality).
#'
#' ## More extreme perturbations (2 standard deviations)
#' plot(pcaResult, type = "eigenfunctions", npcs = 2, scale.sdev = 2)
#' # Using scale.sdev=2 shows more extreme deviations from the mean, making
#' # the differences between PC directions more visible.
#'
#' ## Show loadings as log-PDFs (useful for seeing tail behavior)
#' plot(pcaResult, type = "loadings", npcs = 2, as = "logPDF")
#' # The log-PDF representation emphasizes differences in the tails of the
#' # distributions, which can be important for detecting outlier behavior.
#'
#' ## Show loadings in CLR (centered log-ratio) representation
#' plot(pcaResult, type = "loadings", npcs = 2, as = "clr")
#' # The CLR representation shows the additive structure in Bayes space.
#' # Values are centered around zero, making it easy to see which parts of the
#' # distribution are above or below the geometric mean. This is the natural
#' # representation for PCA in Bayes space.
#'
#' ## Covariance surface: See which parts of the domain co-vary
#' plot(pcaResult, type = "covariance")
#' # Interpretation: This heatmap shows functional covariance Cov(f(s), f(t))
#' # across the domain. High values (red) indicate that when one part of the
#' # distribution is above average, the other tends to be as well. The diagonal
#' # shows variance at each point. Off-diagonal patterns reveal correlation
#' # structure (e.g., if left and right tails move together or oppositely).
#'
#' ## Reconstruction: How well do k PCs approximate the data?
#' plot(pcaResult, type = "reconstruction", npcs = 2)
#' # Shows all distributions reconstructed using only the first 2 PCs.
#' # Compare to the original data to assess information loss.
#' # If reconstructions look similar to originals, 2 PCs are sufficient.
#'
#' plot(pcaResult, type = "reconstruction", npcs = 1)
#' # Using only 1 PC shows more loss of detail - useful for understanding
#' # what information each additional PC captures.
#'
#' ## Boxplot: Distribution of scores across PCs
#' plot(pcaResult, type = "boxplot", npcs = 4)
#' # Shows the spread of observations along each PC direction.
#' # Useful for identifying outliers (points beyond whiskers) and comparing
#' # the amount of variation captured by each component. The percentage
#' # labels show variance explained by each PC.
#'
plot.princomp.BData <- function(x, type = c("screeplot", "variance", "scores",
                                            "eigenfunctions", "loadings", "biplot",
                                            "covariance", "reconstruction", "boxplot"),
                                choices = 1:2, scale = 1, pc.biplot = FALSE,
                                main = NULL, col = NULL, lwd = 1, lty = 1,
                                legend = TRUE, labels = FALSE, ...,
                                npcs = min(10, length(x$sdev)),
                                as = c("PDF", "density", "logPDF", "logDensity", "clr", "coef"),
                                scale.sdev = 1) {
    type <- match.arg(type)
    as <- match.arg(as)

    # "loadings" is kept as alias for "eigenfunctions" for backwards compatibility
    if (type == "loadings") type <- "eigenfunctions"

    switch(type,
           screeplot = .plotScreeBData(x, npcs = npcs, main = main, ...),
           variance = .plotVarianceBData(x, npcs = npcs, main = main, ...),
           scores = .plotScoresBData(x, choices = choices, main = main,
                                     col = col, labels = labels, ...),
           eigenfunctions = .plotLoadingsBData(x, npcs = npcs, main = main,
                                         col = col, lwd = lwd, lty = lty,
                                         legend = legend, as = as,
                                         scale.sdev = scale.sdev, ...),
           biplot = biplot.princomp.BData(x, choices = choices, scale = scale,
                                          pc.biplot = pc.biplot, main = main,
                                          col = col, ...),
           covariance = .plotCovarianceBData(x, main = main, col = col, ...),
           reconstruction = .plotReconstructionBData(x, npcs = npcs, main = main,
                                                     col = col, as = as, ...),
           boxplot = .plotBoxplotBData(x, npcs = npcs, main = main, col = col, ...)
    )
}


# Internal function: Screeplot
.plotScreeBData <- function(x, npcs, main = NULL, ...) {
    if (is.null(main)) main <- "Screeplot"
    vars <- x$sdev^2
    vars <- vars[1:min(npcs, length(vars))]
    pct <- 100 * vars / sum(x$sdev^2)

    bp <- barplot(pct, names.arg = paste0("PC", seq_along(pct)),
                  main = main, ylab = "Percentage of Variance",
                  xlab = "Principal Component", ...)
    lines(bp, pct, type = "b", pch = 19, col = "blue")
    invisible(list(variance = vars, percent = pct))
}


# Internal function: Cumulative variance plot
.plotVarianceBData <- function(x, npcs, main = NULL, ...) {
    if (is.null(main)) main <- "Cumulative Variance Explained"
    vars <- x$sdev^2
    vars <- vars[1:min(npcs, length(vars))]
    cumvar <- cumsum(vars) / sum(x$sdev^2) * 100

    plot(seq_along(cumvar), cumvar, type = "b", pch = 19,
         main = main, xlab = "Number of Components",
         ylab = "Cumulative Variance (%)",
         xaxt = "n", ylim = c(0, 100), ...)
    axis(1, at = seq_along(cumvar))
    abline(h = c(80, 90, 95), lty = 2, col = "gray60")
    invisible(cumvar)
}


# Internal function: Score plot
# labels: FALSE = points only, TRUE = row numbers, character vector = custom labels
.plotScoresBData <- function(x, choices, main = NULL, col = NULL,
                             labels = FALSE, cex = NULL, pch = 19, ...) {
    if (is.null(x$scores)) {
        stop("No scores available. Re-run princomp with scores=TRUE.")
    }
    if (is.null(main)) {
        main <- paste("Scores on PC", choices[1], "vs PC", choices[2])
    }
    if (is.null(col)) col <- 1

    scores <- x$scores[, choices, drop = FALSE]
    varexp <- round(100 * x$sdev[choices]^2 / sum(x$sdev^2), 1)

    # Determine labeling mode
    n <- nrow(scores)
    useLabels <- !isFALSE(labels)

    # Set up plot
    plot(scores[, 1], scores[, 2], type = "n",
         main = main,
         xlab = paste0("PC", choices[1], " (", varexp[1], "%)"),
         ylab = paste0("PC", choices[2], " (", varexp[2], "%)"))
    abline(h = 0, v = 0, lty = 2, col = "gray60")

    if (useLabels) {
        # Determine what labels to use
        if (isTRUE(labels)) {
            # Use row numbers or rownames if available
            lbls <- rownames(scores)
            if (is.null(lbls)) lbls <- seq_len(n)
        } else {
            # Custom labels provided
            lbls <- labels
            if (length(lbls) != n) {
                warning("Number of labels does not match number of observations. ",
                        "Recycling labels.")
                lbls <- rep_len(lbls, n)
            }
        }

        # Add text labels
        if (is.null(cex)) cex <- 0.8
        text(scores[, 1], scores[, 2], labels = lbls, col = col, cex = cex)
    } else {
        # Original behavior: points only (pch is recycled across observations,
        # so a vector pch adds a redundant, color-vision-safe group encoding)
        points(scores[, 1], scores[, 2], pch = pch, col = col, cex = cex)
    }

    invisible(scores)
}


# Internal function: Loadings as functional plot
# Shows how the distribution changes along each principal component direction.
# The mean distribution is shown in black (thick line), and for each PC:
# - solid colored line: mean + scale.sdev * sdev * loading (positive direction)
# - dashed colored line: mean - scale.sdev * sdev * loading (negative direction)
# This represents the distribution of an observation that is scale.sdev standard
# deviations away from the mean along that PC direction.
.plotLoadingsBData <- function(x, npcs, main = NULL, col = NULL,
                               lwd = 1, lty = 1, legend = TRUE,
                               as = "PDF", scale.sdev = 1, ...) {
    if (is.null(main)) main <- paste("Eigenfunctions: Mean \u00b1 PC perturbations as", as)

    npcs <- min(npcs, length(x$sdev))
    basis <- x$basis

    # Default colors: black for mean, then standard colors for PCs
    if (is.null(col)) {
        col <- c(1, 2:(npcs + 1))
    }
    # Recycle colors if needed
    if (length(col) < npcs + 1) {
        col <- rep(col, length.out = npcs + 1)
    }

    # Get the mean coefficients
    meanCoefs <- x$center

    # Get the loadings (eigenvectors, unit length in coefficient space)
    loadingsMat <- x$loadings[, 1:npcs, drop = FALSE]

    # Scale loadings by standard deviation if requested
    # scale.sdev = 1 means show +/- 1 standard deviation from mean
    # scale.sdev = 2 means show +/- 2 standard deviations from mean
    if (!is.na(scale.sdev) && scale.sdev != 0) {
        loadingsMat <- sweep(loadingsMat, 2, x$sdev[1:npcs] * scale.sdev, "*")
    }

    # Create the mean as BDataSplines
    meanBData <- BDataSplines(matrix(meanCoefs, nrow = 1), basis = basis)

    # Collect all distributions to determine plot range
    allBData <- list(meanBData)
    for (i in seq_len(npcs)) {
        posCoefs <- meanCoefs + loadingsMat[, i]
        negCoefs <- meanCoefs - loadingsMat[, i]
        allBData[[length(allBData) + 1]] <- BDataSplines(matrix(posCoefs, nrow = 1), basis = basis)
        allBData[[length(allBData) + 1]] <- BDataSplines(matrix(negCoefs, nrow = 1), basis = basis)
    }

    # Plot mean first (thick black line)
    # Handle "clr" separately since it's not a standard plot.BDataSplines option
    if (as == "clr") {
        plotArgs <- list(col = col[1], lwd = lwd * 2, lty = 1, main = main, ...)
        do.call(plot, c(list(x = clr(meanBData)), plotArgs))
    } else {
        plotArgs <- list(close = TRUE, col = col[1], lwd = lwd * 2, lty = 1,
                         main = main, ...)
        do.call(plot, c(list(x = meanBData, as = as), plotArgs))
    }

    # Build legend
    legendLabels <- "Mean"
    legendCols <- col[1]
    legendLty <- 1
    legendLwd <- lwd * 2

    # Add perturbations for each PC
    for (i in seq_len(npcs)) {
        # Positive direction: mean + loading
        posCoefs <- meanCoefs + loadingsMat[, i]
        posBData <- BDataSplines(matrix(posCoefs, nrow = 1), basis = basis)

        # Negative direction: mean - loading
        negCoefs <- meanCoefs - loadingsMat[, i]
        negBData <- BDataSplines(matrix(negCoefs, nrow = 1), basis = basis)

        # Plot positive direction (solid line)
        if (as == "clr") {
            plot(clr(posBData), add = TRUE, col = col[i + 1], lwd = lwd, lty = 1)
        } else {
            plot(posBData, as = as, close = TRUE, add = TRUE,
                 col = col[i + 1], lwd = lwd, lty = 1)
        }

        # Plot negative direction (dashed line)
        if (as == "clr") {
            plot(clr(negBData), add = TRUE, col = col[i + 1], lwd = lwd, lty = 2)
        } else {
            plot(negBData, as = as, close = TRUE, add = TRUE,
                 col = col[i + 1], lwd = lwd, lty = 2)
        }

        # Update legend
        pct <- round(100 * x$sdev[i]^2 / sum(x$sdev^2), 1)
        legendLabels <- c(legendLabels,
                          paste0("PC", i, "+ (", pct, "%)"),
                          paste0("PC", i, "-"))
        legendCols <- c(legendCols, col[i + 1], col[i + 1])
        legendLty <- c(legendLty, 1, 2)
        legendLwd <- c(legendLwd, lwd, lwd)
    }

    if (legend) {
        legend("topright", legend = legendLabels, col = legendCols,
               lty = legendLty, lwd = legendLwd, bty = "n", cex = 0.7)
    }

    invisible(list(mean = meanBData, loadings = loadingsMat))
}


# Internal function: Covariance surface plot
# Shows the covariance structure of the functional data across the domain.
# This helps identify which parts of the support co-vary.
.plotCovarianceBData <- function(x, main = NULL, col = NULL, nGrid = 101, ...) {
    if (is.null(main)) main <- "Functional Covariance Surface"

    basis <- x$basis
    support <- getSupport(basis)
    lims=getSupport1DPlottingLimits(support)

    # Create evaluation grid (Support uses $a and $b for continuous range)
    grid <- seq(lims[1], lims[2], length.out = nGrid)

    # Evaluate orthonormalized basis functions at grid points
    # basis$basis returns raw B-splines (nGrid x nRawBasis)
    # We need to apply the transformation matrix A to get orthonormal basis
    # B_ortho = B_raw %*% t(A), giving nGrid x nCoef
    B_raw <- basis$basis(grid)
    B <- B_raw %*% t(basis$A)

    # Reconstruct coefficient covariance from PCA
    # Cov_coef = Loadings %*% diag(sdev^2) %*% t(Loadings)
    loadings <- x$loadings
    vars <- x$sdev^2
    CovCoef <- loadings %*% diag(vars) %*% t(loadings)

    # Functional covariance: Cov_func = B %*% Cov_coef %*% t(B)
    CovFunc <- B %*% CovCoef %*% t(B)

    # Default color palette (blue-white-red)
    if (is.null(col)) {
        col <- grDevices::hcl.colors(100, "Blue-Red 3")
    }

    # Plot as image
    graphics::image(grid, grid, CovFunc,
                    col = col,
                    main = main,
                    xlab = "Domain",
                    ylab = "Domain",
                    ...)

    # Add contour lines
    graphics::contour(grid, grid, CovFunc, add = TRUE, col = "gray30", lwd = 0.5)

    invisible(list(grid = grid, covariance = CovFunc))
}


# Internal function: Reconstruction plot
# Shows the reconstructed distributions using a reduced number of PCs.
# Reconstruction: center + sum(loadings[k] * scores[i,k]) for k = 1 to npcs
.plotReconstructionBData <- function(x, npcs, main = NULL, col = NULL,
                                     as = "PDF", ...) {
    if (is.null(x$scores)) {
        stop("No scores available. Re-run princomp with scores=TRUE.")
    }

    npcs <- min(npcs, length(x$sdev))
    if (is.null(main)) {
        main <- paste("Reconstruction using", npcs, "PCs")
    }

    basis <- x$basis
    n <- nrow(x$scores)

    # Default colors
    if (is.null(col)) {
        col <- grDevices::hcl.colors(n, "Set2")
    }
    if (length(col) < n) {
        col <- rep(col, length.out = n)
    }

    # Reconstruct coefficients: center + scores %*% t(loadings)
    # Using only first npcs components
    reconstructedCoefs <- matrix(x$center, nrow = n, ncol = length(x$center),
                                  byrow = TRUE)
    reconstructedCoefs <- reconstructedCoefs +
        x$scores[, 1:npcs, drop = FALSE] %*% t(x$loadings[, 1:npcs, drop = FALSE])

    # Create BDataSplines from reconstructed coefficients
    reconstructedBData <- BDataSplines(reconstructedCoefs, basis = basis)

    # Plot the reconstructed distributions
    if (as == "clr") {
        plot(clr(reconstructedBData), col = col, main = main, ...)
    } else {
        plot(reconstructedBData, as = as, close = TRUE, col = col, main = main, ...)
    }

    invisible(reconstructedBData)
}


# Internal function: Boxplot of score distributions
# Shows the distribution of scores for each principal component as boxplots.
# This helps visualize the spread and identify outliers in PC space.
.plotBoxplotBData <- function(x, npcs, main = NULL, col = NULL, ...) {
    if (is.null(x$scores)) {
        stop("No scores available. Re-run princomp with scores=TRUE.")
    }

    npcs <- min(npcs, length(x$sdev))
    if (is.null(main)) {
        main <- "Distribution of PC Scores"
    }

    # Get scores for the first npcs components
    scores <- x$scores[, 1:npcs, drop = FALSE]

    # Compute variance explained for labels
    varexp <- round(100 * x$sdev[1:npcs]^2 / sum(x$sdev^2), 1)
    labels <- paste0("PC", 1:npcs, "\n(", varexp, "%)")

    # Default colors
    if (is.null(col)) {
        col <- grDevices::hcl.colors(npcs, "Set2")
    }
    if (length(col) < npcs) {
        col <- rep(col, length.out = npcs)
    }

    # Create boxplot
    graphics::boxplot(as.data.frame(scores), names = labels, main = main,
                      col = col, ylab = "Score", ...)

    # Add horizontal line at zero
    graphics::abline(h = 0, lty = 2, col = "gray60")

    invisible(scores)
}


#' Biplot for PCA of Distributional Data
#'
#' @description
#' Creates a biplot that simultaneously displays the principal component scores
#' (observations) and loadings (variables/coefficients) in the same plot. This
#' visualization helps understand relationships between observations and the
#' underlying coefficient structure.
#'
#' @param x An object of class `princomp.BData` as returned by
#'     \code{\link{princomp.BData}}.
#' @param choices Integer vector of length 2 specifying which principal components
#'     to display. Default is `c(1, 2)` for the first two components.
#' @param scale Numeric scaling factor controlling the relative scaling of scores
#'     and loadings:
#'   \describe{
#'     \item{`0`}{(Default) Distance biplot: Loadings unscaled, scores scaled.
#'         Distances between observation labels approximate Euclidean distances
#'         in the full space.
#'         Best for seeing relationships between observations.}
#'     \item{`1`}{Correlation biplot: Loadings scaled by standard deviations.
#'         Inner products between loading arrows approximate covariances.
#'         Best for seeing relationships between variables.}
#'   }
#' @param pc.biplot Logical. If `TRUE`, use Gabriel's (1971) biplot scaling,
#'     which sets `scale=1`. Default is `FALSE`.
#' @param main Character string for plot title. Default is `"Biplot"`.
#' @param col Vector of two colors: first for observation labels, second for
#'     loading arrows. Default is `c("black", "red")`.
#' @param cex Vector of two character expansion factors: first for observation
#'     labels, second for loading labels. Default is `c(0.8, 0.8)`.
#' @param xlabs Character vector of labels for observations. Default uses
#'     observation numbers (1, 2, 3, ...).
#' @param ylabs Character vector of labels for loading arrows (coefficients).
#'     Default uses `"C1"`, `"C2"`, etc.
#' @param expand Numeric expansion factor for loading arrows. Increase this
#'     if the arrows are too short to see clearly. Default is 1.
#' @param arrow.len Length of arrow heads. Default is 0.1.
#' @param ... Additional graphical parameters passed to the underlying plot.
#'
#' @return Invisibly returns a list containing the scaled `scores` and `loadings`
#'     matrices as plotted.
#'
#' @details
#' ## Biplot Interpretation
#'
#' In a biplot:
#' \itemize{
#'   \item \strong{Observations} (numbers/text) represent individual distributions.
#'       Observations close together have similar distribution shapes.
#'   \item \strong{Loading arrows} (red arrows labeled C1, C2, etc.) represent
#'       spline basis coefficients. The direction and length indicate how each
#'       coefficient contributes to the principal components.
#'   \item \strong{Projecting} an observation onto a loading arrow approximates
#'       that observation's value on that coefficient (relative to the mean).
#'   \item \strong{Arrow angles}: Arrows pointing in similar directions indicate
#'       positively correlated coefficients; opposite directions indicate negative
#'       correlation; perpendicular arrows indicate uncorrelated coefficients.
#' }
#'
#' ## Note on Coefficient Interpretation
#'
#' Unlike PCA on raw data where variables have direct meaning, the "variables"
#' here are spline basis coefficients. These are abstract and their individual
#' interpretation may not be intuitive. For understanding how distributions
#' vary, the \code{\link{plot.princomp.BData}} function with `type="eigenfunctions"`
#' provides a more interpretable visualization showing how distribution shapes
#' change along each PC direction.
#'
#' ## Why Biplot Works for ZB-Splines
#'
#' The biplot is meaningful for the default ZB-spline basis because ZB-splines
#' have **local support**: each basis function is non-zero only in a specific
#' region of the domain. This means coefficient i primarily reflects the behavior
#' of the distribution near knot region i. When a loading arrow for coefficient
#' Ci points strongly in a PC direction, it indicates that variation in that
#' region of the domain is associated with that principal component.
#'
#' **Important:** If orthonormal splines were used instead, the biplot would
#' lose this interpretability because orthonormal basis functions have global
#' support (they are non-zero across the entire domain), so each coefficient
#' would mix information from everywhere rather than representing a local region.
#'
#' @seealso
#' \code{\link{princomp.BData}} for performing the PCA.
#'
#' \code{\link{plot.princomp.BData}} for other visualization options, especially
#'     `type="loadings"` for functional interpretation.
#'
#' \code{\link[stats]{biplot}} for the generic biplot function.
#'
#' @references
#' Gabriel, K. R. (1971). The biplot graphic display of matrices with application
#' to principal component analysis. \emph{Biometrika}, 58(3), 453-467.
#'
#' @author Matthias Templ
#' @family PCA
#' @export
#' @method biplot princomp.BData
#' @importFrom graphics arrows text points
#'
#' @examples
#' ## Create example PCA result
#' meanDist <- BDataPDFs(list(dunif), basis = B2Unif01Basis7)
#' gaussProc <- B2GaussianDistribution(meanDist, function(x,y) exp(-4*abs(x-y)^1.7))
#' pcaResult <- princomp(as.BDataSplines(simulate(gaussProc, 25, seed = 42)))
#'
#' ## Basic biplot
#' biplot(pcaResult)
#'
#' ## Biplot of PC2 vs PC3
#' biplot(pcaResult, choices = c(2, 3))
#'
#' ## Correlation biplot (scale=1)
#' biplot(pcaResult, scale = 1)
#'
#' ## Customized biplot
#' biplot(pcaResult,
#'        col = c("blue", "darkgreen"),
#'        cex = c(1, 0.7),
#'        expand = 1.5,
#'        main = "PCA Biplot of Distributional Data")
#'
biplot.princomp.BData <- function(x, choices = 1:2, scale = 0, pc.biplot = FALSE,
                                  main = NULL, col = c("black", "red"),
                                  cex = c(0.8, 0.8),
                                  xlabs = NULL, ylabs = NULL,
                                  expand = 1, arrow.len = 0.1, ...) {
    if (length(choices) != 2) {
        stop("choices must be a vector of length 2")
    }
    if (is.null(x$scores)) {
        stop("No scores available. Re-run princomp with scores=TRUE.")
    }

    if (is.null(main)) {
        main <- "Biplot"
    }

    # Get scores and loadings for chosen components
    scores <- x$scores[, choices, drop = FALSE]
    loadings <- x$loadings[, choices, drop = FALSE]

    # Apply scaling (following standard biplot convention)
    # scale=0: loadings unscaled, scores scaled by 1/sdev (distance biplot)
    # scale=1: loadings scaled by sdev, scores unscaled (correlation biplot)
    lam <- x$sdev[choices]
    if (pc.biplot) scale <- 1

    n <- x$n.obs
    lam_scale <- lam^scale

    # Scale scores and loadings
    scores <- t(t(scores) / lam^(1 - scale)) / sqrt(n)
    loadings <- t(t(loadings) * lam_scale)

    # Auto-scale loadings to be comparable to scores
    # This makes the biplot more readable
    score_range <- max(abs(scores))
    loading_range <- max(abs(loadings))
    if (loading_range > 0) {
        auto_scale <- score_range / loading_range * 0.8
    } else {
        auto_scale <- 1
    }
    loadings <- loadings * auto_scale * expand

    # Labels
    if (is.null(xlabs)) {
        xlabs <- seq_len(nrow(scores))
    }
    if (is.null(ylabs)) {
        ylabs <- rownames(loadings)
        if (is.null(ylabs)) ylabs <- paste0("C", seq_len(nrow(loadings)))
    }

    # Compute axis limits
    rangx <- range(scores[, 1], loadings[, 1])
    rangy <- range(scores[, 2], loadings[, 2])

    # Variance explained
    varexp <- round(100 * x$sdev[choices]^2 / sum(x$sdev^2), 1)

    # Plot
    plot(rangx, rangy, type = "n",
         main = main,
         xlab = paste0("PC", choices[1], " (", varexp[1], "%)"),
         ylab = paste0("PC", choices[2], " (", varexp[2], "%)"),
         asp = 1, ...)

    # Add grid lines
    abline(h = 0, v = 0, lty = 2, col = "gray70")

    # Plot observation scores
    if (length(col) < 2) col <- rep(col, 2)
    if (length(cex) < 2) cex <- rep(cex, 2)

    text(scores[, 1], scores[, 2], labels = xlabs, col = col[1], cex = cex[1])

    # Plot loadings as arrows
    for (i in seq_len(nrow(loadings))) {
        arrows(0, 0, loadings[i, 1], loadings[i, 2],
               col = col[2], length = arrow.len)
        text(loadings[i, 1] * 1.1, loadings[i, 2] * 1.1,
             labels = ylabs[i], col = col[2], cex = cex[2])
    }

    invisible(list(scores = scores, loadings = loadings))
}


#' Predict Scores for New Distributional Data
#'
#' @description
#' Projects new distributional data onto the principal component space defined
#' by a fitted PCA model. This allows new distributions to be positioned relative
#' to the original dataset without re-running the entire PCA.
#'
#' @param object A `princomp.BData` object containing the fitted PCA model.
#' @param newdata A BData object containing the new distributions to project.
#'     Can be any BData subclass; it will be converted to splines using the
#'     same basis as the original PCA.
#' @param ... Additional arguments (currently unused).
#'
#' @return A matrix of principal component scores with observations in rows and
#'     components in columns. Each row contains the coordinates of one new
#'     distribution in the PC space.
#'
#' @details
#' The prediction process:
#' \enumerate{
#'   \item Converts `newdata` to spline coefficients using the same basis as the
#'       original PCA.
#'   \item **For non-orthonormal bases:** Applies the same Gram matrix transformation
#'       that was used during fitting (see \code{\link{princomp.BData}} details).
#'   \item Centers the (transformed) coefficients using the center from the original PCA.
#'   \item Projects the centered coefficients onto the loading vectors to obtain
#'       scores.
#' }
#'
#' The resulting scores can be compared directly to the scores from the original
#' analysis. New observations with scores similar to original observations have
#' similar distribution shapes.
#'
#' **Note:** For non-orthonormal bases, the function automatically applies the
#' same transformation that was used during fitting. This ensures consistency
#' between training and prediction.
#'
#' @seealso
#' \code{\link{princomp.BData}} for fitting the PCA model.
#'
#' \code{\link{plot.princomp.BData}} with `type="scores"` for visualizing scores.
#'
#' @author Matthias Templ
#' @family PCA
#' @export
#' @method predict princomp.BData
#'
#' @examples
#' ## Fit PCA on training data
#' meanDist <- BDataPDFs(list(dunif), basis = B2Unif01Basis7)
#' gaussProc <- B2GaussianDistribution(meanDist, function(x,y) exp(-4*abs(x-y)^1.7))
#' trainData <- as.BDataSplines(simulate(gaussProc, 30, seed = 42))
#' pcaModel <- princomp(trainData)
#'
#' ## Generate new data and predict scores
#' newData <- as.BDataSplines(simulate(gaussProc, 5, seed = 999))
#' newScores <- predict(pcaModel, newData)
#' print(newScores)
#'
#' ## Compare: new observations vs training data
#' trainScores <- pcaModel$scores
#' plot(trainScores[,1], trainScores[,2], pch = 19, col = "gray50",
#'      xlab = "PC1", ylab = "PC2", main = "Training (gray) vs New (red)")
#' points(newScores[,1], newScores[,2], pch = 17, col = "red", cex = 1.5)
#' legend("topright", c("Training", "New"), pch = c(19, 17),
#'        col = c("gray50", "red"))
#'
predict.princomp.BData <- function(object, newdata, ...) {
    if (missing(newdata)) {
        return(object$scores)
    }else{
      stopifnot(hasSameBayesSpace(attr(object,"orig"),newdata)) # TODO_ exploit gsi.orig
    }

    # Convert new data to spline representation
    newSplines <- as.BDataSplines(newdata, basis = getBayesSpaceBasis(attr(object,"orig")))
    newMat <- unclass(newSplines)

    # For non-orthonormal basis, we need to transform the new data
    # the same way as the training data
    if (!isTRUE(object$orthonormal)) {
        S <- object$GramMatrix
        cholS <- chol(S)
        sqrtS <- t(cholS)

        # Transform new data
        newMatTransformed <- newMat %*% sqrtS

        # Center using the transformed center
        centerTransformed <- as.vector(object$center %*% sqrtS)
        newCenteredTransformed <- sweep(newMatTransformed, 2, centerTransformed, "-")

        # For scores, we need the loadings in transformed space
        # loadings (original) = t(sqrtS_inv) %*% loadingsTransformed
        # So: loadingsTransformed = t(sqrtS) %*% loadings
        loadingsTransformed <- t(sqrtS) %*% object$loadings

        # Project onto transformed loadings
        scores <- newCenteredTransformed %*% loadingsTransformed
    } else {
        # Orthonormal case: simple projection
        newCentered <- sweep(newMat, 2, object$center, "-")
        scores <- newCentered %*% object$loadings
    }

    return(scores)
}


#' Summary of PCA for Distributional Data
#'
#' @description
#' Provides a summary of the principal component analysis results, showing the
#' importance of each component in terms of standard deviation, proportion of
#' variance explained, and cumulative proportion.
#'
#' @param object A `princomp.BData` object.
#' @param ... Additional arguments (currently unused).
#'
#' @return An object of class `summary.princomp.BData` containing:
#' \describe{
#'   \item{sdev}{Standard deviations of the principal components.}
#'   \item{importance}{Matrix with three rows: standard deviation, proportion
#'       of variance, and cumulative proportion for each component.}
#' }
#'
#' @seealso \code{\link{princomp.BData}}
#' @author Matthias Templ
#' @family PCA
#' @export
#' @method summary princomp.BData
#'
#' @examples
#' meanDist <- BDataPDFs(list(dunif), basis = B2Unif01Basis7)
#' gaussProc <- B2GaussianDistribution(meanDist, function(x,y) exp(-4*abs(x-y)^1.7))
#' pcaResult <- princomp(as.BDataSplines(simulate(gaussProc, 25, seed = 42)))
#' summary(pcaResult)
#'
summary.princomp.BData <- function(object, ...) {
    vars <- object$sdev^2
    result <- list(
        sdev = object$sdev,
        importance = rbind(
            "Standard deviation" = object$sdev,
            "Proportion of Variance" = vars / sum(vars),
            "Cumulative Proportion" = cumsum(vars) / sum(vars)
        )
    )
    class(result) <- "summary.princomp.BData"
    result
}


#' @export
print.summary.princomp.BData <- function(x, digits = 3, ...) {
    cat("Importance of components:\n")
    print(round(x$importance, digits))
    invisible(x)
}


#' Screeplot for PCA of Distributional Data
#'
#' @description
#' Creates a screeplot showing the variance (or eigenvalue) associated with each
#' principal component. This is a standard tool for deciding how many principal
#' components to retain based on the "elbow" criterion.
#'
#' @param x A `princomp.BData` object.
#' @param npcs Number of principal components to include in the plot. Default
#'     is `min(10, length(x$sdev))`.
#' @param type Character string specifying the plot type:
#'   \describe{
#'     \item{`"barplot"`}{Bar chart of variances (default).}
#'     \item{`"lines"`}{Line plot connecting variance values.}
#'   }
#' @param main Plot title. Default is `"Screeplot"`.
#' @param ... Additional graphical parameters.
#'
#' @return Invisibly returns the variance values for each component.
#'
#' @details
#' The screeplot displays the eigenvalues (variances) of the principal components
#' in decreasing order. The name comes from the plot's resemblance to a scree
#' slope at the base of a mountain.
#'
#' ## How to Use
#'
#' Look for an "elbow" in the plot where the variance drops off sharply.
#' Components before the elbow are typically considered important and retained,
#' while those after the elbow contribute little to the overall structure.
#'
#' Alternative criteria for choosing components include:
#' \itemize{
#'   \item Retain components explaining a target percentage of variance (see
#'       `plot(x, type="variance")`).
#'   \item Kaiser criterion: retain components with variance > 1 (when using
#'       correlation matrix, `cor=TRUE`).
#' }
#'
#' @seealso
#' \code{\link{princomp.BData}} for the PCA analysis.
#'
#' \code{\link{plot.princomp.BData}} for additional plot types including
#'     cumulative variance plots.
#'
#' @author Matthias Templ
#' @family PCA
#' @export
#' @method screeplot princomp.BData
#' @importFrom stats screeplot
#'
#' @examples
#' meanDist <- BDataPDFs(list(dunif), basis = B2Unif01Basis7)
#' gaussProc <- B2GaussianDistribution(meanDist, function(x,y) exp(-4*abs(x-y)^1.7))
#' pcaResult <- princomp(as.BDataSplines(simulate(gaussProc, 25, seed = 42)))
#'
#' # Standard barplot screeplot
#' screeplot(pcaResult)
#'
#' # Line plot version
#' screeplot(pcaResult, type = "lines")
#'
screeplot.princomp.BData <- function(x, npcs = min(10, length(x$sdev)),
                                     type = c("barplot", "lines"),
                                     main = "Screeplot", ...) {
    type <- match.arg(type)

    vars <- x$sdev^2
    vars <- vars[seq_len(min(npcs, length(vars)))]

    if (type == "barplot") {
        barplot(vars, names.arg = paste0("PC", seq_along(vars)),
                main = main, ylab = "Variances", ...)
    } else {
        plot(seq_along(vars), vars, type = "b", pch = 19,
             main = main, xlab = "Component", ylab = "Variances",
             xaxt = "n", ...)
        axis(1, at = seq_along(vars))
    }

    invisible(vars)
}

Try the DistributionalDDA package in your browser

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

DistributionalDDA documentation built on Oct. 20, 2026, 5:06 p.m.