R/plotting.R

Defines functions circular_qqplot position_center_spoke axes stress_colors tectonicr.colors

Documented in axes circular_qqplot stress_colors tectonicr.colors

#' Colors for input variables
#'
#' assigns colors to continuous or categorical values for plotting
#'
#' @param x values for color assignment
#' @param n integer. number of colors for continuous colors (i.e.
#' `categorical = FALSE``).
#' @param pal either a named vector specifying the colors for categorical
#' values, or a color function. If `NULL`, default colors are
#' `RColorBrewer::brewer.pal()`
#' (`categorical = TRUE`) and `viridis::viridis()` (`categorical = FALSE`).
#' @param categorical logical.
#' @param na.value color for `NA` values (categorical).
#' @param ... optional arguments passed to palette function
#'
#' @return named color vector
#'
#' @importFrom viridis viridis
#' @importFrom RColorBrewer brewer.pal
#' @importFrom dplyr left_join mutate
#' @export
#'
#' @examples
#' val1 <- c("N", "S", "T", "T", NA)
#' tectonicr.colors(val1, categorical = TRUE)
#' tectonicr.colors(val1, pal = stress_colors(), categorical = TRUE)
#'
#' val2 <- runif(10)
#' tectonicr.colors(val2, n = 5)
tectonicr.colors <- function(x, n = 10, pal = NULL, categorical = FALSE, na.value = "grey", ...) {
  code <- val <- NULL
  if (categorical) {
    # dat <- data.frame(val = x) # |> mutate(val = ifelse(is.na(val), "NA", val))
    if (is.null(pal)) {
      val <- unique(x)
      n <- length(val)
      if (n < 9) {
        pal <- structure(RColorBrewer::brewer.pal(n, name = "Set2"), names = val)
      } else {
        pal <- structure(RColorBrewer::brewer.pal(n, name = "Set3"), names = val)
      }
    }
    cols <- pal[x]
    cols[is.na(cols)] <- na.value
    return(cols)
  } else {
    breaks <- pretty(x, n = n + 1)
    n2 <- length(breaks) - 1
    # order = findInterval(x, sort(x))
    if (is.null(pal)) {
      cols <- viridis::viridis(n = n2, ...) # [order]
    } else {
      cols <- pal(n = n2, ...) # [order]
    }

    ret <- cut(x, breaks = breaks, labels = cols, include.lowest = TRUE) |>
      as.character()
    names(ret) <- cut(x, breaks = breaks, include.lowest = TRUE)
    return(ret)
  }
}


#' Color palette for stress regime
#'
#' @return function
#' @export
#'
#' @examples
#' stress_colors()
stress_colors <- function() {
  structure(
    c("#D55E00", "#E69F00", "#009E73", "#56B4E9", "#0072B2", "grey60"),
    names = c("N", "NS", "S", "TS", "T", "U")
  )
}


#' Plot axes
#'
#' @param x,y coordinates of points
#' @param angle Azimuth in degrees
#' @param radius length of axis
#' @param arrow.code integer. Kind of arrow head. The default is `1`, i.e. no
#' arrow head. See [graphics::arrows()] for details
#' @param arrow.length numeric Length of the edges of the arrow head (in
#' inches). (Ignored if `arrow.code = 1`)
#' @param add logical. add to existing plot?
#' @param ... optional arguments passed to [graphics::arrows()]
#'
#' @returns No return value, called for side effects
#'
#' @export
#'
#' @examples
#' data("san_andreas")
#' axes(san_andreas$lon, san_andreas$lat, san_andreas$azi, add = FALSE)
axes <- function(x, y, angle, radius = .5, arrow.code = 1, arrow.length = 0, add = FALSE, ...) {
  if (!add) plot(x, y, cex = 0, ...)
  graphics::arrows(x, y, x1 = x + radius / 2 * cosd(270 - angle), y1 = y + radius / 2 * sind(270 - angle), length = arrow.length, code = arrow.code, ...)
  graphics::arrows(x, y, x1 = x + radius / 2 * cosd(90 - angle), y1 = y + radius / 2 * sind(90 - angle), length = arrow.length, code = arrow.code, ...)
}

#' Class for Central Position of Spoke Marker
#'
#' position subclass \code{"center_spoke"} to center \code{ggplot::geom_spoke()}
#' marker at its origin
#'
#' @noRd
position_center_spoke <- function() PositionCenterSpoke #

#' @title  Centrically aligned geom_spoke marker
#'
#' @description \code{"position"} subclass "center_spoke" to center
#' \code{ggplot::geom_spoke()} marker at its origin
#'
#' @export
#' @keywords internal
#' @source \url{https://stackoverflow.com/questions/55474143/how-to-center-geom-spoke-around-their-origin}
#'
#' @importFrom ggplot2 ggproto Position
PositionCenterSpoke <- ggplot2::ggproto("PositionCenterSpoke", ggplot2::Position,
  compute_panel = function(self, data, params, scales) {
    data$x <- 2 * data$x - data$xend
    data$y <- 2 * data$y - data$yend
    data$radius <- 2 * data$radius
    data
  }
)

#' Quantile-Quantile Linearised Plot for Circular Distributions
#'
#'
#' Uniformly distributed orientations should yield a straight line through the
#' origin. Systematic departures from linearity will indicate preferred
#' orientation in some manner.
#'
#' @param x numeric. Angles in degrees
#' @param axial Logical. Whether data are uniaxial (`axial=FALSE`)
#' @param xlab,ylab,main plot labels.
#' @param ... graphical parameters
#'
#' @return plot
#' @importFrom graphics plot abline lines points
#' @export
#'
#' @references Borradaile, G. J. (2003). Statistics of earth
#' science data: their distribution in time, space, and orientation (Vol. 351,
#' p. 329). Berlin: Springer.
#'
#' @examples
#' x_vm <- rvm(100, mean = 0, kappa = 2)
#' circular_qqplot(x_vm, pch = 20)
#'
#' x_norm <- rnorm(100, mean = 0, sd = 25)
#' circular_qqplot(x_norm, pch = 20)
#'
#' x_unif <- runif(100, 0, 360)
#' circular_qqplot(x_unif, pch = 20)
circular_qqplot <- function(x, axial = TRUE,
                            xlab = paste("i/(n+1)"),
                            ylab = NULL, main = "Circular Quantile-Quantile Plot", ...) {
  if (axial) {
    f <- 2
  } else {
    f <- 1
  }
  if (is.null(ylab)) {
    ylab <- paste0(deparse1(substitute(x)), "/", 360 / f)
  }

  x <- (x %% (360 / f)) / (360 / f)
  x <- sort(x)
  n <- length(x)
  xin <- seq_along(x) / (n + 1)

  graphics::plot(xin, x,
    type = "p", xlim = c(0, 1), ylim = c(0, 1), asp = 1,
    xlab = xlab, ylab = ylab, main = main,
    sub = bquote("N" == .(n)),
    ...
  )
  graphics::abline(a = 0, b = 1, col = "slategrey")
  graphics::lines(xin, x)
  invisible(xin)
  # graphics::points(xin, x, col = "slategrey")
}

#' von Mises Quantile-Quantile Plot
#'
#' Produces a Q-Q plot of the data against a specified von Mises distribution
#' to graphically assess the goodness of fit of the model.
#'
#' @param x numeric. Angles in degrees
#' @param w numeric. optional weightings for `x` to estimate `mean` and `kappa`.
#' @param axial Logical. Whether data are uniaxial (`axial=FALSE`)
#' @param xlab,ylab,main plot labels.
#' @param mean numeric. Circular mean of the von Mises distribution. If `NULL`,
#' it will be estimated from `x`.
#' @param kappa numeric. Concentration parameter of the von Mises distribution.
#' If `NULL`, it will be estimated from `x`.
#' @param ... graphical parameters
#'
#' @return plot
#' @importFrom stats ecdf
#' @importFrom graphics plot lines
#'
#' @export
#'
#' @examples
#' x_vm <- rvm(100, mean = 0, kappa = 4)
#' vm_qqplot(x_vm, axial = FALSE, pch = 20)
#'
#' x_unif <- runif(100, 0, 360)
#' vm_qqplot(x_unif, axial = FALSE, pch = 20)
vm_qqplot <- function(x, w = NULL, axial = TRUE, mean = NULL, kappa = NULL,
                      xlab = "von Mises quantile function",
                      ylab = "Empirical quantile function",
                      main = "von Mises Q-Q Plot", ...) {
  if (axial) {
    f <- 2
  } else {
    f <- 1
  }

  n <- length(x)

  # if (stretch) {
  #   k <- 4
  # } else {
  #   k <- 2
  # }

  if (is.null(mean)) mean <- circular_mean(x, w = w, axial = axial)
  if (is.null(kappa)) kappa <- est.kappa(x, w = w, axial = axial)

  caption <- bquote(
    bar(alpha) == .(round(mean, 1)) * degree ~ "|" ~ kappa == .(round(kappa, 1))
  )

  xf <- (x * f) %% 360
  xf <- sort(xf)

  edf <- stats::ecdf(xf)


  # z <- sind((xf - mean*f) / k)
  #
  # z_sort <- sort(z) #|> scales::rescale(c(-1, 1))
  #
  #
  # probs <- seq(0, 1, length.out = n)
  # quantiles <- qvm(edf(xf), mean = 0, kappa = kappa, from = 0)
  # quantiles <- qvm(probs, mean = 0, kappa = kappa, from = 0)

  #
  # sin_q <- sind(quantiles / k)

  # graphics::plot(0, 0, type = "n", xlim = c(-1, 1), ylim = c(-1, 1), asp = 1, xlab = xlab, ylab = ylab, main = main, ...)
  # graphics::abline(a = 0, b = 1, col = "slategrey")
  # graphics::points(sin_q, z_sort, pch=20)

  tqf <- qvm(edf(xf), mean * f, kappa, from = 0)
  #
  graphics::plot(tqf / f, xf / f, xlim = c(0, 360 / f), ylim = c(0, 360 / f), asp = 1, xlab = xlab, ylab = ylab, main = main, sub = bquote("N" == .(n)), ...)
  graphics::abline(a = 0, b = 1, col = "slategrey")
  mtext(caption)
  invisible(tqf)
}


# Circular diagram -----------------------------------------------------------------


#' Circular plot
#'
#' @param main Character string specifying the title of the plot.
#' @param at Optional vector of angles at which tick marks should be plotted.
#' Set `at=numeric(0)` to suppress tick marks.
#' @param labels Either a logical value indicating whether to plot labels
#' next to the tick marks, or a vector of labels for the tick marks.
#' @param cborder logical. Border of rose plot.
#' @param ... optional arguments passed to `plot.default()`
#' @importFrom spatstat.geom disc plot.owin
#' @import spatstat.utils
#' @import spatstat.explore
#' @importFrom spatstat.univar whist
#'
#' @note Polar diagram where angles increase clockwise.
#'
#' @export
#'
#' @return none
#'
#' @keywords internal
circular_plot <- function(main = NULL, labels = TRUE,
                          at = seq(0, 360 - 45, 45), cborder = TRUE, ...) {
  unit <- "degree"
  ymax <- 1
  insideclearance <- 0.1
  outsidespace <- if (!is.null(at) && length(at) == 0) {
    0
  } else if (identical(labels, FALSE)) {
    0.1
  } else {
    0.25
  }
  R <- (1 + insideclearance) * ymax
  DD <- spatstat.geom::disc(R)
  Rout <- (1 + outsidespace) * R
  disco <- spatstat.geom::disc(Rout)
  spatstat.utils::dont.complain.about(DD, disco)
  result <- spatstat.utils::do.call.matched(
    spatstat.geom::plot.owin, spatstat.utils::resolve.defaults(list(
      x = quote(disco),
      main = main, type = "n", border = cborder
    ), list(...))
  )
  spatstat.utils::do.call.matched(
    spatstat.geom::plot.owin, spatstat.utils::resolve.defaults(
      list(
        x = quote(DD),
        hatch = FALSE, add = TRUE, border = cborder
      ), list(...)
    ),
    extrargs = spatstat.utils::graphicsPars("owin"), skipargs = "col"
  )
  spatstat.explore::circticks(
    R,
    at = at, unit = unit, start = -90, clockwise = TRUE, labels = labels
  )
  return(invisible(result))
}


rose_histogram <- function(x, ..., main, labels = TRUE, at = NULL,
                           cborder = TRUE, axial = FALSE, add = FALSE) {
  if (missing(main) || is.null(main)) {
    main <- spatstat.utils::short.deparse(substitute(x))
  }

  bks <- x$mids
  bw <- x$binwidth
  y <- x$density

  if (!add) circular_plot(smain = main, labels, at = at, cborder = cborder)

  for (i in seq_along(y)) {
    rose_fan(bks[i], d = bw, radius = y[i], axial = axial, add = TRUE, ...)
  }
}

graphicsAargh <- c(
  "density", "angle", "col", "border",
  "xlim", "ylim", "xlab", "ylab", "axes"
)

rose_freq <- function(x, bins = NULL, ..., weights = NULL, binwidth = NULL,
                      round_binwidth = 0, equal_area = TRUE,
                      main = NULL, axial = TRUE) {
  if (missing(main) || is.null(main)) {
    main <- spatstat.utils::short.deparse(substitute(x))
  }

  stopifnot(is.numeric(x))
  if (!is.null(weights)) {
    spatstat.utils::check.nvector(weights, length(x),
      things = "observations",
      vname = "weights"
    )
  }

  if (axial) {
    if (!is.null(bins) && is.null(binwidth)) {
      stopifnot(bins > 0)
      binwidth <- 180 / bins # bin width
    } else if (is.null(bins) && is.null(binwidth)) {
      binwidth <- rose_binwidth(length(stats::na.omit(x)), axial = axial)
    }

    binwidth <- round(binwidth)
    stopifnot(binwidth > 0)

    binwidth <- symmetric_bw(binwidth)

    x <- x %% 180
    breaks <- seq(0, 180, binwidth) |> add_end(180)
  } else {
    if (!is.null(bins) && is.null(binwidth)) {
      stopifnot(bins > 0)
      binwidth <- 360 / bins # bin width
    } else if (is.null(bins) && is.null(binwidth)) {
      binwidth <- rose_binwidth(length(stats::na.omit(x)), axial = axial)
    }

    binwidth <- round(binwidth)
    stopifnot(binwidth > 0)

    breaks <- seq(0, 360, binwidth) |> add_end(360)
  }

  h <- spatstat.utils::do.call.matched(graphics::hist.default, list(
    x = x, breaks = breaks, ..., plot = FALSE
  ),
  skipargs = graphicsAargh, sieve = TRUE
  )

  result <- numeric()
  result <- h$result
  result$otherargs <- h$otherargs

  freqs <- spatstat.univar::whist(
    x = x, breaks = breaks, weights = weights
  )

  result$count <- freqs
  result$density <- freqs / binwidth

  if (equal_area) {
    result$density <- sqrt(result$density)
  }
  result$xname <- main
  result$density <- result$density / max(result$density)
  result$binwidth <- binwidth

  result
}



#' @title Selecting optimal number of bins and width for rose diagrams
#'
#' @param n Integer. number of data
#' @param round Logical. Whether bin width is round to zero digits
#' (`round=TRUE`, the default)
#' or as is (`FALSE`).
#' @param axial Logical. Whether data are uniaxial (`axial=FALSE`)
#' or biaxial (`TRUE`, the default).
#' @param ... Additional arguments passed to [rose_bw()].
#'
#' @keywords internal
#' @name rose_bw
NULL

#' @rdname rose_bw
rose_bins <- function(n, round = FALSE) {
  b <- 2 * n^(1 / 3) # bins
  if (round) {
    round(b)
  } else {
    b
  }
}

#' @rdname rose_bw
rose_binwidth <- function(n, axial = TRUE, ...) {
  if (axial) {
    r <- 180
  } else {
    r <- 360
  }
  r / rose_bins(n)
}

is.naturalnumber <- function(x, tol = .Machine$double.eps^0.5) x > tol & abs(x - round(x)) < tol

symmetric_bw <- function(x) {
  div <- 180 / seq(1, 180, 1)
  cond <- is.naturalnumber(div) & div < 180
  allowed <- div[cond]
  target.index <- which(abs(allowed - x) == min(abs(allowed - x)))
  min(allowed[target.index])
}

add_end <- function(x, end) {
  check <- end %in% x
  if (check) {
    x
  } else {
    x <- c(x, end)
  }
}

#' @title Rose Diagram
#'
#' @description Plots a rose diagram (rose of directions), the analogue of a
#' histogram or density plot for angular data.
#'
#' @param x Data to be plotted. A numeric vector containing angles (in degrees).
#' @param weights Optional vector of numeric weights associated with x.
#' @param binwidth The width of the bins (in degrees).
#' @param bins number of arcs to partition the circle width.
#' Overridden by `binwidth`.
#' @param axial Logical. Whether data are uniaxial (`axial=FALSE`)
#' or biaxial (`TRUE`, the default).
#' @param equal_area Logical. Whether the radii of the bins are proportional to
#' the frequencies (`equal_area=FALSE`, i.e. equal-angle) or proportional to the
#' square-root of the frequencies (`equal_area=TRUE`, the default).
#' @param muci logical. Whether the mean and its 95% CI are added to the plot
#' or not.
#' @param round_binwidth integer. Number of decimal places of bin width (0 by
#' default).
#' @param mtext character. String to be drawn at the top margin of the plot
#' (`"N"` by default)
#' @param main,sub Character string specifying the title and subtitle of the
#' plot. If `sub = NULL`, it will show the bin width.
#' @inheritParams circular_plot
#' @param col fill color of bins
#' @param dots logical. Whether a circular dot plot should be added
#' (`FALSE` is the default).
#' @param jitter_factor Add a small amount of noise to the angles' radius that
#' is added to `scale`. Jitter is ignored when `stack==TRUE`).
#' If `0`, no jitter is added (by default); if negative, the points fall into
#' the circle.
#' @param stack logical. Groups and stacks the dots if `TRUE`. Default is `FALSE`.
#' @param dot_cex,dot_pch,dot_col Plotting arguments for circular dot plot
#' @param add logical.
#' @param ... Additional arguments passed to [spatstat.explore::rose()].
#'
#' @note If `bins` and `binwidth` are `NULL`, an optimal bin width will be
#' calculated using Scott (1979):
#' \deqn{ w_b = \frac{R}{n^{\frac{1}{3}}}
#' }
#' with n being the length of `x`, and the range R being either 180 or 360
#' degree for axial or directional data, respectively.
#'
#' If `"axial" == TRUE`, the binwidth is adjusted to guarantee symmetrical fans.
#'
#' @return A window (class `"owin"`) containing the plotted region or a `list`
#' of the calculated frequencies.
#'
#' @importFrom spatstat.explore rose
#' @importFrom spatstat.utils short.deparse
#' @importFrom graphics hist title points mtext
#' @importFrom stats na.omit
#'
#' @export
#'
#' @examples
#' x <- rvm(100, mean = 90, k = 5)
#' rose(x, axial = FALSE, border = TRUE)
#'
#' data("san_andreas") #'
#' rose(san_andreas$azi, main = "equal area")
#' rose(san_andreas$azi, equal_area = FALSE, main = "equal angle")
#'
#' # weighted frequencies:
#' rose(san_andreas$azi, weights = 1 / san_andreas$unc, main = "weighted")
#'
#' # add dots
#' rose(san_andreas$azi, dots = TRUE, main = "dot plot", jitter = .2)
#' rose(san_andreas$azi,
#'   dots = TRUE, stack = TRUE, dot_cex = 0.5, dot_pch = 21,
#'   main = "stacked dot plot"
#' )
rose <- function(x, weights = NULL, binwidth = NULL, bins = NULL, axial = TRUE,
                 equal_area = TRUE, muci = TRUE,
                 round_binwidth = 0, mtext = "N", main = NULL, sub = NULL,
                 at = seq(0, 360 - 45, 45), cborder = TRUE, labels = TRUE,
                 col = "grey", dots = FALSE, dot_pch = 1, dot_cex = 1,
                 dot_col = "slategrey", stack = FALSE, jitter_factor = 0,
                 add = FALSE, ...) {
  if (!add) {
    if (missing(main) || is.null(main)) {
      main <- spatstat.utils::short.deparse(substitute(x))
    }
    circular_plot(main = main, labels = labels, at = at, cborder = cborder)
  }

  if (axial) {
    x <- x %% 180
    x[x >= 180] <- 180 - 2 * .Machine$double.eps
  } else {
    x <- x %% 360
    x[x >= 360] <- 360 - 4 * .Machine$double.eps
  }

  freqs <- rose_freq(
    x,
    bins = bins, ..., weights = weights, binwidth = binwidth,
    round_binwidth = round_binwidth, equal_area = equal_area,
    axial = axial
  )

  rose_histogram(freqs, ...,
    col = col, axial = axial,
    main = main, labels = TRUE, at = at, cborder = TRUE, add = TRUE
  )

  if (dots) {
    plot_points(x,
      axial = axial, stack = stack, cex = dot_cex, pch = dot_pch,
      col = dot_col, jitter_factor = jitter_factor, add = TRUE
    )
  }

  if (is.null(sub)) sub <- paste("Bin width:", freqs$binwidth)
  graphics::title(main = NULL, sub = sub, ylab = NULL)
  graphics::mtext(mtext)

  if (muci) rose_stats(x, weights = weights, axial = axial)
  invisible(freqs)
}


#' Direction Lines and Fans in Circular Diagram
#'
#' @param x angles in degrees
#' @param d width of a fan (in degrees)
#' @param radius of the plotted circle
#' @param axial Logical. Whether `x` are uniaxial (`axial=FALSE`)
#' or biaxial (`TRUE`, the default).
#' @param add logical. Add to existing plot?
#' @param ... optional arguments passed to [graphics::segments()] or
#' [graphics::polygon()]
#'
#' @returns No return value, called for side effects
#'
#' @importFrom graphics segments polygon
#' @name rose_geom
#' @examples
#' angles <- c(0, 10, 45)
#' radius <- c(.7, 1, .2)
#' lwd <- c(2, 1, .75)
#' col <- c(1, 2, 3)
#' rose_line(angles, radius = radius, axial = FALSE, add = FALSE, lwd = lwd, col = col)
NULL

#' @rdname rose_geom
#' @export
rose_line <- function(x, radius = 1, axial = TRUE, add = TRUE, ...) {
  xrad <- deg2rad(90 - x)
  tx <- radius * cos(xrad)
  ty <- radius * sin(xrad)

  if (!add) circular_plot()
  graphics::segments(0, 0, tx, ty, ...)
  if (axial) {
    graphics::segments(0, 0, -tx, -ty, ...)
  }
  invisible()
}

#' @rdname rose_geom
#' @export
rose_fan <- function(x, d, radius = 1, axial = TRUE, add = TRUE, ...) {
  xrad <- deg2rad(x)
  drad <- deg2rad(d) / 2

  eps <- (pi / 128) / 2
  aa <- (pi / 2) - seq(xrad - drad, xrad + drad, by = eps)

  tx <- radius * cos(aa)
  ty <- radius * sin(aa)
  xx <- c(0, tx, 0)
  yy <- c(0, ty, 0)

  if (!add) circular_plot()

  graphics::polygon(x = xx, y = yy, ...)
  if (axial) {
    graphics::polygon(x = -xx, y = -yy, ...)
  }
}

#' Show Average Direction and Spread in Rose Diagram
#'
#' Adds the average direction (and its spread) to an existing rose diagram.
#'
#' @param x Data to be plotted. A numeric vector containing angles (in degrees).
#' @param weights Optional vector of numeric weights associated with x.
#' @param axial Logical. Whether data are uniaxial (`axial=FALSE`)
#' or biaxial (`TRUE`, the default).
#' @param avg character. The average estimate for x. Either the circular mean
#' (`"mean"`, the default), the circular Quasi Median (`"median"`), or the
#' sample median (`"sample_median"`).
#' @param spread character. The measure of spread to be plotted as a fan.
#' Either 95% confidence interval (`"CI"`, the default), Fishers confidence interval (`"fisher"`), the circular
#' standard deviation (`"sd"`), the Quasi interquartile range on the circle
#' (`"IQR"`), or the sampke median deviation (`"mdev"`). `NULL` if no fan should be drawn.
#' @param avg.col color for the average line
#' @param avg.lty line type of the average line
#' @param avg.lwd  line width of the average line
#' @param spread.col color of the spread fan
#' @param spread.border logical. Whether to draw a border of the fan or not.
#' @param spread.lty line type of the spread fan's border
#' @param spread.lwd line width of the spread fan's border
#' @param add logical.
#' @param ... optional arguments to `circular_plot()` if add is `FALSE`.
#' @importFrom ggplot2 alpha
#'
#' @seealso [rose()] for plotting the rose diagram, and
#' [circular_mean()], [circular_median()], [circular_sample_median()],
#' [confidence_interval()], [confidence_interval_fisher()],
#' [circular_sd()], [circular_IQR()], [circular_sample_median_deviation()]
#' for statistical parameters.
#'
#' @returns No return value, called for side effects
#' @export
#'
#' @examples
#' data("san_andreas")
#' rose(san_andreas$azi, weights = 1 / san_andreas$unc, muci = FALSE)
#' rose_stats(san_andreas$azi, weights = 1 / san_andreas$unc, avg = "sample_median", spread = "mdev")
rose_stats <- function(x, weights = NULL, axial = TRUE, avg = c("mean", "median", "sample_median"), spread = c("CI", "fisher", "sd", "IQR", "mdev"),
                       avg.col = "#85112AFF", avg.lty = 2, avg.lwd = 1.5,
                       spread.col = ggplot2::alpha("#85112AFF", .2), spread.border = FALSE, spread.lty = NULL, spread.lwd = NULL, add = TRUE, ...) {
  avg <- match.arg(avg)
  mu <- switch(avg,
    mean = circular_mean(x, weights, axial),
    median = circular_median(x, weights, axial),
    sample_median = circular_sample_median(x, axial)
  )
  # mu_text <- switch(avg,
  #   mean = "Mean: ",
  #   median = "Median: "
  # )

  if (!is.null(spread)) {
    spread <- match.arg(spread)
    ci <- switch(spread,
      CI = confidence_interval(x, w = weights, axial = axial)$conf.angle,
      fisher = confidence_interval_fisher(x, w = weights, axial = axial, quiet = TRUE)$conf.angle,
      sd = circular_sd(x, weights, axial),
      IQR = circular_IQR(x, weights, axial),
      mdev = circular_sample_median_deviation(x, axial)
    )
    rose_fan(mu, 2 * ci,
      radius = 1.1, axial = axial, col = spread.col,
      border = spread.border, lty = spread.lty, lwd = spread.lwd,
      add = add, ...
    )
  }

  rose_line(mu,
    radius = 1.1, axial = axial, col = avg.col, lty = avg.lty,
    lwd = avg.lwd, add = add, ...
  )
  invisible(c(mu, ci))
}

# Dot plot ---------------------------------------------------------------------


#' Add Points to a Circular Plot
#'
#' Add points to a plot of circular data points on the current graphics device.
#'
#' @param x Data to be plotted. A numeric vector containing angles (in degrees).
#' @param axial Logical. Whether data are uniaxial (`axial=FALSE`)
#' or biaxial (`TRUE`, the default).
#' @param stack logical: if `TRUE`, points are stacked on the perimeter of the
#' circle.
#' Otherwise, all points are plotted on the perimeter of the circle. Default is
#' `FALSE`.
#' @param binwidth numeric. Bin width (in degrees) for the stacked dot plots.
#' ignored when `stack==FALSE`. Is set to `1` degree by default.
#' @param cex character (or symbol) expansion: a numerical vector. This works as
#' a multiple of `par("cex")`.
#' @param sep constant used to specify the distance between stacked points, if
#' `stack==TRUE` or in the case of more than one dataset. Default is `0.025`;
#' smaller values will create smaller spaces.
#' @param jitter_factor numeric. Adds a small amount of random variation to the
#' location of each points along radius that is added to `scale`. Jitter is
#' ignored when `stack==TRUE`). If `0`, no jitter is added (by default); if
#' negative, the points fall into the circle.
#' @param ... Further graphical parameters may also be supplied as arguments.
#' @param scale radius of plotted circle. Default is `1.1`.
#' Larger values shrink the circle, while smaller values enlarge the circle.
#' @param add logical
#' @inheritParams circular_plot
#'
#' @importFrom graphics points
#'
#' @return A list with information on the plot
#' @export
#'
#' @examples
#' x <- rvm(100, mean = 90, k = 5)
#' plot_points(x, add = FALSE)
#' plot_points(x, jitter_factor = .2, add = FALSE) # jittered plot
#' plot_points(x, stack = TRUE, binwidth = 3, add = FALSE) # stacked plot
plot_points <- function(x, axial = TRUE, stack = FALSE, binwidth = 1, cex = 1, sep = 0.025, jitter_factor = 0, ..., scale = 1.1, add = TRUE,
                        main = NULL, labels = TRUE,
                        at = seq(0, 360 - 45, 45), cborder = TRUE) {
  if (!add) {
    if (missing(main) || is.null(main)) {
      main <- spatstat.utils::short.deparse(substitute(x))
    }
    circular_plot(main = main, labels = labels, at = at, cborder = cborder)
  }

  f <- as.numeric(axial) + 1

  if (!stack) {
    if (axial) {
      x_shift <- (x + 180) %% 360
      x <- c(x, x_shift)
    }
    xr <- deg2rad(x)
    u <- pi / 2 - xr
    n <- length(x)
    r <- scale + runif(n, min(0, jitter_factor), max(0, jitter_factor))
    z <- cos(u) * r
    y <- sin(u) * r
    graphics::points(z, y, cex = cex, ...)
  } else {
    freqs <- rose_freq(x, axial = axial, binwidth = binwidth)
    if (axial) {
      freqs$mids <- freqs$mids %% 180
      freqs$count <- rep(freqs$count, 2)
      freqs$mids <- c(freqs$mids, freqs$mids + 180)
    }

    bins <- f * 180 / freqs$binwidth
    bins.count <- freqs$count
    mids <- deg2rad(-90 - freqs$mids)
    index <- cex * sep
    # index <- cex * freqs$binwidth

    for (i in 1:bins) {
      if (bins.count[i] > 0) {
        for (j in 0:(bins.count[i] - 1)) {
          r <- scale + j * index
          z <- r * cos(mids[i])
          y <- r * sin(mids[i])
          graphics::points(z, y, cex = cex, ...)
        }
      }
    }
  }
}

# Plot density lines on rose ---------------------------------------------------

calc_circular_density <- function(x, z, kappa) {
  nx <- length(x)
  # if (kernel == "vonmises") {
  y <- sapply(z, dvm, mean = x, kappa = kappa)
  # }
  # else if (kernel == "wrappednormal") {
  #   rho <- exp(-bw^2/2)
  #   y <- sapply(z, DwrappednormalRad, mu = x, rho = rho,
  #               K = K, min.k = min.k)
  # }
  # else {
  #   stop("other kernels not implemented yet")
  # }
  apply(y, 2, sum) / nx
}


circular_density <- function(x, z = NULL, kappa, na.rm = TRUE, from = 0, to = 360, n = 512, axial = TRUE) {
  f <- as.numeric(axial) + 1
  x <- x * f

  if (is.null(z)) {
    z <- seq(from = from, to = to, length = n)
  } else {
    if (!is.numeric(z)) {
      stop("argument 'z' must be numeric")
    }
    namez <- deparse(substitute(z))
    z.na <- is.na(z)
    if (any(z.na)) {
      if (na.rm) {
        z <- z[!z.na]
      } else {
        stop("z contains missing values")
      }
    }
    z.finite <- is.finite(z)
    if (any(!z.finite)) {
      z <- z[z.finite]
    }
  }

  calc_circular_density(x, z, kappa)
}

circular_lines <- function(x, y, join = FALSE, nosort = FALSE, offset = 1.1, shrink = 1, axial = TRUE, ...) {
  x <- deg2rad(90 - x)

  if (axial) {
    x <- c(x, x + pi)
    y <- rep(y, 2)
  }


  n <- length(x)
  if (!nosort) {
    xorder <- order(x)
    x <- x[xorder]
    y <- y[xorder]
    spacings <- c(diff(x), x[1] - x[n] + 2 * pi)
    pos <- which.max(spacings)[1]
    if (pos == n) {
      xorder <- 1:n
    } else {
      xorder <- c((pos + 1):n, 1:pos)
    }
  } else {
    xorder <- 1:n
  }
  z <- (y / shrink + offset) * cos(x)
  w <- (y / shrink + offset) * sin(x)
  z <- z[xorder]
  w <- w[xorder]
  if (join) {
    z <- c(z, z[1])
    w <- c(w, w[1])
  }
  graphics::lines(x = z, y = w, ...)
  invisible(list(x = z, y = w))
}


#' Circular density plot
#'
#' Plot the multiples of a von Mises density distribution
#'
#' @param x Data to be plotted. A numeric vector containing angles (in degrees).
#' @param kappa Concentration parameter for the von Mises distribution.
#' Small kappa gives smooth density lines.
#' @param axial Logical. Whether data are uniaxial (`axial=FALSE`)
#' or biaxial (`TRUE`, the default).
#' @param n the number of equally spaced points at which the density is to be estimated.
#' @param norm_density logical. Normalize the density?
#' @param scale radius of plotted circle. Default is `1.1`.
#' @param shrink parameter that controls the size of the plotted function. Default is 1.
#' @param ... Further graphical parameters may also be supplied as arguments.
#' @param add logical. Add to existing plot? (`TRUE` by default).
#' @inheritParams circular_plot
#'
#' @seealso [dvm()]
#' @return plot or calculated densities as numeric vector
#' @export
#'
#' @examples
#' rose(san_andreas$azi, dots = TRUE, stack = TRUE, dot_cex = 0.5, dot_pch = 21)
#' plot_density(san_andreas$azi, kappa = 10, col = "seagreen", shrink = 1.5)
#' plot_density(san_andreas$azi, kappa = 10, col = "seagreen", add = FALSE, scale = .6)
plot_density <- function(x, kappa, axial = TRUE, n = 512, norm_density = TRUE, ...,
                         scale = 1.1, shrink = 1,
                         add = TRUE, main = NULL, labels = TRUE,
                         at = seq(0, 360 - 45, 45), cborder = TRUE) {
  if (!add) {
    if (missing(main) || is.null(main)) {
      main <- spatstat.utils::short.deparse(substitute(x))
    }
    circular_plot(main = main, labels = labels, at = at, cborder = cborder)
  }


  f <- as.numeric(axial) + 1
  d <- circular_density(x, kappa = kappa, n = n, axial = axial)
  if (norm_density) d / max(d)
  circular_lines(seq(0, 360, length = f * n), rep(d, f), axial = FALSE, n, offset = scale, shrink = shrink, ...)
  invisible(d)
}




#' Plotting stress analysis results
#'
#' Creates a set of plots including
#' the azimuth as a function of the distance to the plate boundary,
#' the Norm Chi-squared as a function of the distance to the plate boundary,
#' the circular distance (and dispersion) a function of the distance to the
#' plate boundary, and a rose diagram of the frequency distribution of the
#' azimuths.
#'
#' @param azi numeric. Azimuth of \eqn{\sigma_{Hmax}}{SHmax}
#' @param distance numeric. Distance to plate boundary
#' @param prd numeric. the predicted direction of \eqn{\sigma_{Hmax}}{SHmax}
#' @param unc numeric. Uncertainty of observed \eqn{\sigma_{Hmax}}{SHmax},
#' either a numeric vector or a number
#' @param regime character vector. The stress
#' regime (following the classification of the World Stress Map)
#' @param width integer. window width (in number of observations) for moving
#' average of the azimuths, circular dispersion, and Norm Chi-square statistics.
#' If `NULL`, an optimal width will be estimated.
#'
#' @importFrom dplyr arrange mutate
#'
#' @seealso [PoR_shmax()], [distance_from_pb()], [circular_mean()],
#' [circular_dispersion()], [confidence_interval_fisher()], [norm_chisq()],
#' [weighted_rayleigh()]
#'
#' @details
#' Plot 1 shows the transformed azimuths as a function of the distance to the
#' plate boundary. The red line indicates the rolling circular mean, stippled
#' red lines indicate the 95% confidence interval about the mean.
#'
#' Plot 2 shows the normalized \eqn{\chi^2}{chi-squared} statistics as a
#' function of the distance to the plate boundary. The red line shows the
#' rolling \eqn{\chi^2}{chi-squared} statistic.
#'
#' Plot 3 shows the circular distance of the transformed azimuths to the
#' predicted azimuth, as a function of the distance to the plate boundary. The
#' red line shows the rolling circular dispersion about the prediction.
#'
#' Plot 4 give the rose diagram of the transformed azimuths.
#'
#' @returns four R base plots
#'
#' @export
#'
#' @examples
#' data("nuvel1")
#' na_pa <- subset(nuvel1, nuvel1$plate.rot == "na")
#'
#' data("plates")
#' plate_boundary <- subset(plates, plates$pair == "na-pa")
#'
#' data("san_andreas")
#' res <- PoR_shmax(san_andreas, na_pa, "right")
#' d <- distance_from_pb(san_andreas, na_pa, plate_boundary, tangential = TRUE)
#' quick_plot(res$azi.PoR, d, res$prd, san_andreas$unc, san_andreas$regime)
quick_plot <- function(
    azi,
    distance,
    prd,
    unc = NULL,
    regime,
    width = 51) {
  if (missing(regime)) {
    regime <- rep(NA, length(azi))
  }
  nchisq_i <- numeric()
  regime <- ifelse(is.na(regime), "U", regime)

  t <- data.frame(azi, distance, prd, unc,
    regime = factor(regime, levels = c("U", "N", "NS", "S", "TS", "T"))
  ) |>
    dplyr::filter(!is.na(azi)) |>
    dplyr::arrange(distance) |>
    dplyr::mutate(
      nchisq_i = (deviation_norm(azi, prd) / unc)^2 / (90 / unc)^2,
      cdist = circular_distance(azi, prd),
      roll_mean = roll_circstats(
        azi,
        w = 1 / unc,
        FUN = circular_mean,
        width = width
      ),
      roll_sd = roll_circstats(
        azi,
        w = 1 / unc,
        FUN = circular_sd,
        width = width
      ) / 2,
      roll_nchisq = roll_normchisq(
        azi,
        prd,
        unc,
        width = width
      ),
      roll_disp = roll_dispersion(
        azi,
        prd,
        w = 1 / unc,
        width = width
      )
    )

  # add lower and upper period to data for plotting
  tmin <- dplyr::mutate(t, azi = azi - 180)
  tmax <- dplyr::mutate(t, azi = azi + 180)
  t2 <- rbind(tmin, t, tmax)

  nchisq <- norm_chisq(azi, prd, unc)
  rt <- weighted_rayleigh(azi, mu = prd, w = 1 / unc, quiet = TRUE)
  azi.PoR.mean <- circular_mean(azi, 1 / unc)
  azi.PoR.sd <- circular_sd(azi, 1 / unc)
  disp <- circular_dispersion(azi, prd, 1 / unc)
  CIF <- confidence_interval_fisher(azi, w = 1 / unc, quiet = TRUE)
  CI <- CIF$conf.interval
  CI_ang <- CIF$conf.angle

  subtitle <-
    bquote(95 * "% CI [" * .(round(CI[1])) * degree * "," ~ .(round(CI[2])) * degree * "] | R" == .(signif(rt$statistic, 2)) ~ ("p" == .(signif(rt$p.value, 2))))

  subtitle_rose <- bquote(atop(
    "N" == .(length(azi)),
    bar(alpha) == .(round(azi.PoR.mean, 1)) * degree * "" %+-% "" * .(round(CI_ang, 1)) * ~degree
  ))
  # subtitle_rose <- do.call(expression, subtitle_rose)

  grDevices::palette(c("grey60", "#D55E00", "#E69F00", "#009E73", "#56B4E9", "#0072B2"))

  # distance plot
  ## create empty plot
  graphics::plot(
    0,
    type = "n",
    xlab = "Distance from plate boundary",
    ylab = expression("Azimuth wrt. PoR " ~ alpha ~ "(" * degree * ")"),
    sub = subtitle,
    main = "Distance from plate boundary vs. azimuth",
    xlim = range(distance),
    ylim = c(0, 180),
    yaxp = c(0, 180, 8)
  )

  ## 95% confidence interval
  graphics::polygon(
    x = c(rev(t$distance), t$distance),
    y = c(rev(t$roll_mean + t$roll_sd), t$roll_mean - t$roll_sd),
    col = "grey90", border = FALSE
  )

  ## points
  graphics::arrows(
    y0 = t2$azi - t2$unc, x0 = t2$distance,
    y1 = t2$azi + t2$unc, x1 = t2$distance,
    code = 0, lwd = .25, col = t2$regime
  )
  graphics::points(azi ~ distance, data = t2, col = t2$regime)

  ## roll statistics
  graphics::lines(roll_mean ~ distance, data = t, type = "S", col = "#85112AFF")

  ## predicted az
  graphics::abline(h = unique(prd), col = "black", lty = 2)
  graphics::legend("bottomright",
    inset = .05, cex = .75,
    legend = names(stress_colors()), title = "Stress regime",
    fill = stress_colors()
  )

  # Norm chisq plot
  grDevices::dev.new()
  graphics::plot(nchisq_i ~ distance,
    data = t, col = t$regime,
    xlab = "Distance from plate boundary", ylab = expression("Norm" ~ chi[i]^2),
    main = expression(bold("Deviation from prediction" ~ beta)),
    xlim = range(distance),
    ylim = c(0, 1), yaxp = c(0, 1, 4),
    sub = bquote("Norm" ~ chi^2 == .(round(nchisq, 2)))
  )
  graphics::lines(
    roll_nchisq ~ distance,
    data = t,
    type = "S",
    col = "#85112AFF"
  )
  graphics::abline(h = .15, col = "black", lty = 2)

  # Dispersion plot
  grDevices::dev.new()
  graphics::plot(0,
    type = "n",
    xlab = "Distance from plate boundary", ylab = expression("Circular distance " ~ "d(" * alpha[i] * "," ~ beta * ")"),
    main = expression(bold("Circular dispersion around prediction" ~ beta)),
    xlim = range(distance),
    ylim = c(0, 1), yaxp = c(0, 1, 4),
    sub = bquote("D(" * alpha * "," ~ beta * ")" == .(round(disp, 3)))
  )
  ## 95% confidence interval
  # graphics::polygon(
  #   x = c(rev(t$distance), t$distance),
  #   y = c(rev(t$roll_disp_CI[, 1]), t$roll_disp_CI[, 2]),
  #   col = "grey90", border = FALSE, lty = 3
  # )
  graphics::points(cdist ~ distance,
    data = t, col = t$regime
  )
  graphics::lines(roll_disp ~ distance, data = t, type = "S", col = "#85112AFF")
  # graphics::abline(h = disp, col = "black", lty = 2) # dispersion


  # rose plot
  grDevices::dev.new()
  rose(
    azi,
    weights = 1 / unc,
    sub = subtitle_rose,
    main = "Rose diagram",
    mtext = "PoR"
  )
  # rose_stats(azi, weights = 1 / unc)
  rose_line(prd, radius = 1.1, col = "#009E73") # show the predicted direction
  grDevices::palette("default")
}

#' Plot data in PoR map
#'
#' @param x,pb `sf` objects of the data points and the plate
#' boundary geometries in the geographical coordinate system
#' @param PoR Pole of Rotation. \code{"data.frame"} or object of class
#' \code{"euler.pole"}
#' containing the geographical coordinates of the Pole of Rotation
#' @param type Character. Type of plate boundary (optional). Can be
#' \code{"out"}, \code{"in"}, \code{"right"}, or
#' \code{"left"} for outward, inward, right-lateral, or left-lateral
#' moving plate boundaries, respectively. If \code{"none"} (the default), only
#' the PoR-equivalent azimuth is returned.
#'
#' @param show.deviation logical.
#' Whether the data should be color-coded according to the deviation from the
#' prediction, or according to the stress regime? Is ignored if `type=='none'`.
#' @param ... optional arguments passed to [tectonicr.colors()]
#'
#' @returns plot
#'
#' @importFrom sf st_coordinates st_geometry
#' @importFrom dplyr arrange
#' @export
#'
#' @seealso [PoR_shmax()], [axes()], [tectonicr.colors()]
#'
#' @examples
#' data("nuvel1")
#' na_pa <- subset(nuvel1, nuvel1$plate.rot == "na")
#'
#' data("plates")
#' plate_boundary <- subset(plates, plates$pair == "na-pa")
#'
#' data("san_andreas")
#' PoR_map(san_andreas, PoR = na_pa, pb = plate_boundary, type = "right", show.deviation = TRUE)
PoR_map <- function(x, PoR, pb = NULL, type = c("none", "in", "out", "right", "left"),
                    show.deviation = FALSE, ...) {
  val <- val2 <- character()
  type <- match.arg(type)
  x_por_df <- PoR_shmax(x, PoR, type = type)
  if (type == "none") {
    x_por_df <- data.frame(azi.PoR = x_por_df)
  }

  x_por_coords <- geographical_to_PoR_sf(x, PoR) |>
    sf::st_coordinates()
  por_crs <- PoR_crs(PoR)

  pb_por <- geographical_to_PoR_sf(pb, PoR)

  if (show.deviation & type != "none") {
    cols <- tectonicr.colors(abs(x_por_df$cdist), categorical = FALSE, ...)
    legend.title <- "Circular distance"
  } else {
    cols <- tectonicr.colors(
      x$regime,
      pal = stress_colors(),
      categorical = TRUE,
      ...
    )
    legend.title <- "Stress regime"
  }

  col.legend <- data.frame(col = cols, val = names(cols)) |>
    mutate(val2 = gsub("\\(", "", val), val2 = gsub("\\[", "", val2)) |>
    unique() |>
    dplyr::arrange(val2)

  plot(
    x_por_coords[, 1],
    x_por_coords[, 2],
    cex = 0,
    xlab = expression("PoR longitude (" * degree * ")"),
    ylab = expression("PoR latitude (" * degree * ")"),
    asp = 1
  )
  graphics::abline(
    h = seq(-90, 90, 5),
    v = seq(-180, 180, 5),
    col = "grey",
    lty = 2
  )
  axes(
    x_por_coords[, 1],
    x_por_coords[, 2],
    x_por_df$azi.PoR,
    col = cols,
    add = TRUE
  )
  plot(sf::st_geometry(pb_por), add = TRUE)
  graphics::legend("bottomleft",
    inset = .05, cex = .75,
    legend = col.legend$val, title = legend.title, fill = col.legend$col,
    bty = "o", bg = "white"
  )
}

Try the tectonicr package in your browser

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

tectonicr documentation built on Sept. 11, 2024, 6:05 p.m.