Nothing
# ---- Sequence Pattern Comparison ----
utils::globalVariables(c(
"freq", "p_label", "pattern", "resid", "resid_label",
"side", "x_center", "xmax", "xmin", "yidx", "ymax", "ymin"
))
#' Compare Subsequence Patterns Between Groups
#'
#' @description
#' Extracts all k-gram patterns (subsequences of length k) from sequences
#' in each group, computes standardized residuals against the independence
#' model, and optionally runs a permutation or chi-square test of
#' group differences.
#'
#' @details
#' Standardized residuals are always computed from a 2xG contingency table
#' of (this pattern vs. everything else) using the textbook formula
#' \code{(o - e) / sqrt(e * (1 - r/N) * (1 - c/N))}. They describe how much
#' each group's count for a given pattern deviates from expectation under
#' independence, scaled to be approximately N(0,1) under the null.
#'
#' The optional \code{test} argument chooses an inference method:
#' \describe{
#' \item{\code{"permutation"}}{Shuffles group labels across sequences and
#' recomputes a per-pattern statistic (row-wise Euclidean residual norm).
#' Answers: "is this pattern's distribution associated with group
#' membership at the \emph{actor} level?" Respects the sequence as the
#' unit of analysis; can be underpowered when the number of sequences
#' is small.}
#' \item{\code{"chisq"}}{Runs \code{chisq.test} on the 2xG table per
#' pattern. Answers: "do the group \emph{streams} generate this pattern
#' at different rates?" Treats each k-gram occurrence as an event; fast
#' and powerful even with few sequences, but the iid assumption it makes
#' is optimistic when sequences are strongly autocorrelated.}
#' \item{\code{"none"}}{Skip inference. Only residuals, frequencies, and
#' proportions are returned.}
#' }
#'
#' P-values are adjusted once across all patterns (not per-pattern) using
#' any method supported by \code{\link[stats]{p.adjust}}. The default is
#' \code{"fdr"} (Benjamini-Hochberg).
#'
#' @param x A \code{netobject_group} (from grouped \code{build_network}),
#' a \code{netobject} (requires \code{group}), or a wide-format
#' \code{data.frame} (requires \code{group}).
#' @param group Character or vector. Column name or vector of group labels.
#' Not needed for \code{netobject_group}.
#' @param sub Integer vector. Pattern lengths to analyze. Default: \code{3:5}.
#' @param min_freq Integer. Minimum frequency in each group for a pattern
#' to be included. Default: 5.
#' @param test Character. Inference method: one of \code{"permutation"}
#' (default), \code{"chisq"}, or \code{"none"}. See Details.
#' @param iter Integer. Permutation iterations. Only used when
#' \code{test = "permutation"}. Default: 1000.
#' @param adjust Character. P-value correction method (see
#' \code{\link[stats]{p.adjust}}). Default: \code{"fdr"}.
#'
#' @return An object of class \code{"net_sequence_comparison"} containing:
#' \describe{
#' \item{patterns}{Tidy data.frame. Always present:
#' \code{pattern}, \code{length}, \code{freq_<group>},
#' \code{prop_<group>}, \code{resid_<group>}. If
#' \code{test = "permutation"}: \code{effect_size}, \code{p_value}.
#' If \code{test = "chisq"}: \code{statistic}, \code{p_value}.}
#' \item{groups}{Character vector of group names.}
#' \item{n_patterns}{Integer. Number of patterns passing min_freq.}
#' \item{params}{List of sub, min_freq, test, iter, adjust.}
#' }
#'
#' @examples
#' seqs <- data.frame(
#' V1 = sample(LETTERS[1:4], 60, TRUE),
#' V2 = sample(LETTERS[1:4], 60, TRUE),
#' V3 = sample(LETTERS[1:4], 60, TRUE),
#' V4 = sample(LETTERS[1:4], 60, TRUE)
#' )
#' grp <- rep(c("X", "Y"), 30)
#' net <- build_network(seqs, method = "relative")
#' res <- sequence_compare(net, group = grp, sub = 2:3, test = "chisq")
#'
#' @importFrom data.table data.table rbindlist setDT setorderv
#' @importFrom stats p.adjust sd chisq.test
#' @export
sequence_compare <- function(x, group = NULL, sub = 3:5,
min_freq = 5L,
test = c("permutation", "chisq", "none"),
iter = 1000L, adjust = "fdr") {
test <- match.arg(test)
min_freq <- as.integer(min_freq)
iter <- as.integer(iter)
sub <- as.integer(sub)
stopifnot(
length(sub) >= 1L, all(sub >= 1L),
min_freq >= 1L,
iter >= 1L
)
adjust <- match.arg(adjust, stats::p.adjust.methods)
# ---- Extract sequences and group labels ----
parsed <- .cs_parse_input(x, group)
seq_mat <- parsed$seq_mat
group_vec <- parsed$group
groups <- sort(unique(group_vec))
n_groups <- length(groups)
stopifnot("Need at least 2 groups" = n_groups >= 2L)
n <- nrow(seq_mat)
p <- ncol(seq_mat)
sub <- sub[sub <= p]
stopifnot("No valid pattern lengths (sub > sequence length)" = length(sub) >= 1L)
pat_list <- .cs_extract_patterns(seq_mat, sub)
group_idx <- lapply(groups, function(g) which(group_vec == g))
names(group_idx) <- groups
row_list <- list()
for (si in seq_along(sub)) {
k <- sub[si]
pat_mat <- pat_list[[si]]
unique_pats <- sort(unique(pat_mat[nzchar(pat_mat)]))
if (!length(unique_pats)) next
counts_all <- vapply(groups, function(g) {
vals <- pat_mat[, group_idx[[g]], drop = FALSE]
idx <- match(vals, unique_pats, nomatch = 0L)
tabulate(idx, nbins = length(unique_pats))
}, integer(length(unique_pats)))
if (!is.matrix(counts_all)) counts_all <- matrix(counts_all, ncol = n_groups)
rownames(counts_all) <- unique_pats
colnames(counts_all) <- groups
total_by_group <- colSums(counts_all)
N_grand <- sum(total_by_group)
if (N_grand == 0) next
# min_freq filter: every group must meet threshold
keep <- apply(counts_all, 1, min) >= min_freq
if (!any(keep)) next
counts <- counts_all[keep, , drop = FALSE]
kept_pats <- rownames(counts)
n_pat <- nrow(counts)
props <- sweep(counts, 2, total_by_group, `/`)
props[!is.finite(props)] <- NA_real_
# Standardized residuals: (o - e) / sqrt(e * (1 - r/N) * (1 - c/N))
# 2xG table is (pattern, total - pattern). Group totals come from the
# unfiltered count matrix so "rest" includes rare patterns.
resids <- matrix(0, nrow = n_pat, ncol = n_groups,
dimnames = list(kept_pats, groups))
col_props <- total_by_group / N_grand
row_sums <- rowSums(counts)
row_props <- row_sums / N_grand
for (i in seq_len(n_pat)) {
row1 <- counts[i, ]
exp1 <- row_sums[i] * col_props
denom <- sqrt(exp1 * (1 - row_props[i]) * (1 - col_props))
resids[i, ] <- (row1 - exp1) / denom
}
resids[!is.finite(resids)] <- 0
freq_df <- as.data.frame(counts, stringsAsFactors = FALSE)
names(freq_df) <- paste0("freq_", groups)
prop_df <- as.data.frame(props, stringsAsFactors = FALSE)
names(prop_df) <- paste0("prop_", groups)
resid_df <- as.data.frame(resids, stringsAsFactors = FALSE)
names(resid_df) <- paste0("resid_", groups)
rows <- data.table(pattern = kept_pats, length = k)
rows <- cbind(rows, freq_df, prop_df, resid_df)
# ---- Inference ----
if (test == "chisq") {
stat_vec <- numeric(n_pat)
p_vec <- numeric(n_pat)
for (i in seq_len(n_pat)) {
row1 <- counts[i, ]
row2 <- total_by_group - row1
tab <- rbind(row1, row2)
ch <- suppressWarnings(stats::chisq.test(tab))
stat_vec[i] <- unname(ch$statistic)
p_vec[i] <- ch$p.value
}
rows$statistic <- stat_vec
rows$p_value <- p_vec
} else if (test == "permutation") {
stat_obs <- .cs_pattern_statistic(counts)
stat_perm <- matrix(0, nrow = n_pat, ncol = iter)
stat_ge <- integer(n_pat)
for (it in seq_len(iter)) {
perm_group <- group_vec[sample(n)]
perm_idx <- lapply(groups, function(g) which(perm_group == g))
perm_counts_all <- vapply(seq_along(groups), function(gi) {
vals <- pat_mat[, perm_idx[[gi]], drop = FALSE]
idx <- match(vals, unique_pats, nomatch = 0L)
tabulate(idx, nbins = length(unique_pats))
}, integer(length(unique_pats)))
if (!is.matrix(perm_counts_all)) {
perm_counts_all <- matrix(perm_counts_all, ncol = n_groups)
}
rownames(perm_counts_all) <- unique_pats
perm_counts <- perm_counts_all[kept_pats, , drop = FALSE]
stat_perm[, it] <- .cs_pattern_statistic(perm_counts)
stat_ge <- stat_ge + (stat_perm[, it] >= stat_obs)
}
perm_mean <- rowMeans(stat_perm)
perm_sd <- apply(stat_perm, 1, sd)
perm_sd[perm_sd == 0] <- 1
rows$effect_size <- (stat_obs - perm_mean) / perm_sd
rows$p_value <- (stat_ge + 1) / (iter + 1)
}
# test == "none" adds nothing
row_list[[length(row_list) + 1L]] <- rows
}
if (!length(row_list)) {
stop(sprintf("No patterns with min_freq >= %d in all groups.", min_freq),
call. = FALSE)
}
results <- rbindlist(row_list)
# Adjust p-values ONCE across the full pattern set
if ("p_value" %in% names(results) && adjust != "none") {
results$p_value <- stats::p.adjust(results$p_value, method = adjust)
}
# Sort: by p_value if tested, else by max |residual|
if ("p_value" %in% names(results)) {
if ("effect_size" %in% names(results)) {
results <- results[order(results$p_value, -abs(results$effect_size)), ]
} else {
results <- results[order(results$p_value, -results$statistic), ]
}
} else {
resid_cols <- paste0("resid_", groups)
max_r <- do.call(pmax, lapply(resid_cols, function(cn) abs(results[[cn]])))
results <- results[order(-max_r), ]
}
rownames(results) <- NULL
structure(list(
patterns = as.data.frame(results, stringsAsFactors = FALSE),
groups = groups,
n_patterns = nrow(results),
params = list(sub = sub, min_freq = min_freq,
test = test, iter = iter, adjust = adjust)
), class = "net_sequence_comparison")
}
# ---- Input parsing ----
#' @noRd
.cs_parse_input <- function(x, group) {
# netobject_group: each element has $data
if (inherits(x, "netobject_group")) {
group_names <- names(x)
mats <- lapply(seq_along(x), function(i) {
d <- x[[i]]$data
if (is.null(d)) stop("netobject_group element has no $data.", call. = FALSE)
m <- as.matrix(as.data.frame(d, stringsAsFactors = FALSE))
# Decode integer-encoded data
lbl <- rownames(x[[i]]$weights)
if (!is.null(lbl) && length(lbl) > 0L &&
(is.integer(m[, 1]) || is.numeric(m[, 1]))) {
storage.mode(m) <- "character"
m[] <- lbl[as.integer(m)]
}
m
})
# Align column count
max_cols <- max(vapply(mats, ncol, integer(1)))
mats <- lapply(mats, function(m) {
if (ncol(m) < max_cols) {
pad <- matrix(NA_character_, nrow(m), max_cols - ncol(m))
cbind(m, pad)
} else m
})
seq_mat <- do.call(rbind, mats)
group_vec <- rep(group_names, vapply(mats, nrow, integer(1)))
return(list(seq_mat = seq_mat, group = group_vec))
}
# netobject or tna: extract $data
if (inherits(x, "tna") || inherits(x, "netobject") ||
inherits(x, "cograph_network")) {
if (inherits(x, "cograph_network") && !inherits(x, "netobject")) {
x <- .as_netobject(x)
}
if (inherits(x, "tna")) {
d <- x$data
lbl <- x$labels
} else {
d <- x$data
lbl <- rownames(x$weights)
}
if (is.null(d)) stop("Object has no $data.", call. = FALSE)
seq_mat <- as.matrix(as.data.frame(d, stringsAsFactors = FALSE))
if (!is.null(lbl) && length(lbl) > 0L &&
(is.integer(seq_mat[, 1]) || is.numeric(seq_mat[, 1]))) {
storage.mode(seq_mat) <- "character"
seq_mat[] <- lbl[as.integer(seq_mat)]
}
} else if (is.data.frame(x) || is.matrix(x)) {
seq_mat <- as.matrix(as.data.frame(x, stringsAsFactors = FALSE))
} else {
stop("x must be a netobject_group, netobject, tna, data.frame, or matrix.",
call. = FALSE)
}
# Resolve group
if (is.null(group)) {
stop("'group' is required when x is not a netobject_group.", call. = FALSE)
}
if (length(group) == 1L && is.character(group) && group %in% colnames(seq_mat)) {
group_vec <- seq_mat[, group]
seq_mat <- seq_mat[, colnames(seq_mat) != group, drop = FALSE]
} else {
stopifnot("'group' length must match nrow(x)" = length(group) == nrow(seq_mat))
group_vec <- as.character(group)
}
list(seq_mat = seq_mat, group = group_vec)
}
# ---- Pattern extraction (vectorized) ----
#' @noRd
.cs_extract_patterns <- function(m, sub) {
n <- nrow(m)
p <- ncol(m)
mis <- is.na(m)
lapply(sub, function(k) {
n_pos <- p - k + 1L
if (n_pos < 1L) {
return(matrix("", nrow = 0, ncol = n))
}
# Build pattern strings for each position × sequence
out <- matrix("", nrow = n_pos, ncol = n)
for (i in seq_len(n_pos)) {
idx <- i:(i + k - 1L)
has_na <- rowSums(mis[, idx, drop = FALSE]) > 0L
cols <- m[, idx, drop = FALSE]
pat <- do.call(paste, c(as.data.frame(cols, stringsAsFactors = FALSE),
list(sep = "->")))
pat[has_na] <- ""
out[i, ] <- pat
}
out
})
}
# ---- Test statistic ----
#' @noRd
.cs_pattern_statistic <- function(x) {
n <- sum(x)
nr <- nrow(x)
nc <- ncol(x)
rs <- rowSums(x)
cs <- colSums(x)
expected <- outer(rs, cs) / n
sqrt(rowSums((x - expected)^2))
}
# ---- S3 Methods ----
#' Print Method for net_sequence_comparison
#'
#' @param x A \code{net_sequence_comparison} object.
#' @param ... Additional arguments (ignored).
#' @return The input object, invisibly.
#' @inherit sequence_compare examples
#' @export
print.net_sequence_comparison <- function(x, ...) {
cat(sprintf("Sequence Comparison [%d patterns | %d groups: %s]\n",
x$n_patterns, length(x$groups), paste(x$groups, collapse = ", ")))
cat(sprintf(" Lengths: %s | min_freq: %d",
paste(x$params$sub, collapse = ", "), x$params$min_freq))
test <- x$params$test
if (identical(test, "permutation")) {
cat(sprintf(" | permutation: %d iter (%s)",
x$params$iter, x$params$adjust))
} else if (identical(test, "chisq")) {
cat(sprintf(" | chi-square (%s)", x$params$adjust))
}
cat("\n")
if (x$n_patterns > 0) {
top <- utils::head(x$patterns, 10)
cat("\n")
print(top, row.names = FALSE)
if (x$n_patterns > 10) {
cat(sprintf(" ... and %d more patterns\n", x$n_patterns - 10))
}
}
invisible(x)
}
#' Summary Method for net_sequence_comparison
#'
#' @param object A \code{net_sequence_comparison} object.
#' @param ... Additional arguments (ignored).
#' @return The patterns data.frame, invisibly.
#' @inherit sequence_compare examples
#' @export
summary.net_sequence_comparison <- function(object, ...) {
print(object$patterns, row.names = FALSE)
invisible(object$patterns)
}
#' Plot Method for net_sequence_comparison
#'
#' @description
#' Visualizes pattern-level standardized residuals across groups. Two styles
#' are available:
#' \describe{
#' \item{\code{"pyramid"}}{Back-to-back bars of pattern proportions, shaded
#' by each side's standardized residual. Requires exactly 2 groups.}
#' \item{\code{"heatmap"}}{One tile per (pattern, group) cell, colored by
#' standardized residual. Works for any number of groups.}
#' }
#' Residuals are read directly from the \code{resid_<group>} columns in
#' \code{$patterns}, which are always populated regardless of the inference
#' method chosen in \code{sequence_compare}.
#'
#' @param x A \code{net_sequence_comparison} object.
#' @param top_n Integer. Show top N patterns. Default: 10.
#' @param style Character. \code{"pyramid"} (default) or \code{"heatmap"}.
#' @param sort Character. \code{"statistic"} (default) ranks patterns by test
#' statistic or residual magnitude. \code{"frequency"} ranks by total
#' occurrence count across all groups.
#' @param alpha Numeric. Significance threshold for p-value display in the
#' pyramid. Default: 0.05.
#' @param show_residuals Logical. If \code{TRUE}, print the standardized
#' residual value inside each pyramid bar. Default: \code{FALSE}. Ignored
#' for the heatmap (which always shows residuals).
#' @param ... Additional arguments (ignored).
#' @return A \code{ggplot} object, invisibly.
#' @inherit sequence_compare examples
#' @import ggplot2
#' @export
plot.net_sequence_comparison <- function(x, top_n = 10L,
style = c("pyramid", "heatmap"),
sort = c("statistic", "frequency"),
alpha = 0.05,
show_residuals = FALSE, ...) {
style <- match.arg(style)
sort <- match.arg(sort)
pat <- x$patterns
groups <- x$groups
if (nrow(pat) == 0) {
message("No patterns to plot.")
return(invisible(NULL))
}
resid_cols <- paste0("resid_", groups)
freq_cols <- paste0("freq_", groups)
if (sort == "frequency") {
total_freq <- Reduce("+", lapply(freq_cols, function(cn) pat[[cn]]))
pat <- pat[order(-total_freq), ]
} else {
if ("effect_size" %in% names(pat)) {
pat <- pat[order(-abs(pat$effect_size)), ]
} else if ("statistic" %in% names(pat)) {
pat <- pat[order(-pat$statistic), ]
} else {
max_r <- do.call(pmax, lapply(resid_cols, function(cn) abs(pat[[cn]])))
pat <- pat[order(-max_r), ]
}
}
pat <- utils::head(pat, top_n)
if (style == "heatmap") {
return(.sc_plot_heatmap(pat, groups, resid_cols))
}
.sc_plot_pyramid(pat, groups, alpha, show_residuals)
}
#' @noRd
.sc_plot_pyramid <- function(pat, groups, alpha, show_residuals = FALSE) {
if (length(groups) != 2L) {
stop("style = 'pyramid' requires exactly 2 groups; got ",
length(groups), ". Use style = 'heatmap' instead.",
call. = FALSE)
}
g1 <- groups[1]; g2 <- groups[2]
prop1 <- paste0("prop_", g1); prop2 <- paste0("prop_", g2)
freq1 <- paste0("freq_", g1); freq2 <- paste0("freq_", g2)
rcol1 <- paste0("resid_", g1); rcol2 <- paste0("resid_", g2)
# Preserve the rank order passed in (top-ranked first) so the pyramid
# agrees with the heatmap. Reverse factor levels so row 1 appears at
# the top of the y-axis rather than the bottom.
resid1 <- pat[[rcol1]]
resid2 <- pat[[rcol2]]
pyramid <- data.frame(
pattern = factor(pat$pattern, levels = rev(pat$pattern)),
left = -pat[[prop1]],
right = pat[[prop2]],
freq_left = pat[[freq1]],
freq_right = pat[[freq2]],
resid_left = resid1,
resid_right = resid2,
stringsAsFactors = FALSE
)
r_max <- max(abs(c(resid1, resid2)), na.rm = TRUE)
x_max <- max(abs(c(pyramid$left, pyramid$right)), na.rm = TRUE)
gutter <- x_max * 0.06
has_pval <- "p_value" %in% names(pat)
bar_hw <- 0.42
y_num <- as.numeric(pyramid$pattern)
rect_df <- rbind(
data.frame(yidx = y_num, ymin = y_num - bar_hw, ymax = y_num + bar_hw,
xmin = pyramid$left - gutter, xmax = -gutter,
freq = pyramid$freq_left, resid = pyramid$resid_left,
side = "left", stringsAsFactors = FALSE),
data.frame(yidx = y_num, ymin = y_num - bar_hw, ymax = y_num + bar_hw,
xmin = gutter, xmax = pyramid$right + gutter,
freq = pyramid$freq_right, resid = pyramid$resid_right,
side = "right", stringsAsFactors = FALSE)
)
if (has_pval) {
p_df <- data.frame(
yidx = y_num,
p_label = ifelse(pat$p_value < 0.001, "<.001",
ifelse(pat$p_value < 0.01,
sprintf("%.3f", pat$p_value),
sprintf("%.2f", pat$p_value))),
stringsAsFactors = FALSE
)
}
# Residual labels inside each bar (optional)
if (show_residuals) {
rect_df$x_center <- (rect_df$xmin + rect_df$xmax) / 2
rect_df$resid_label <- sprintf("%.2f", rect_df$resid)
rect_df$text_color <- ifelse(abs(rect_df$resid) > r_max * 0.35,
"white", "grey30")
}
p <- ggplot() +
geom_rect(data = rect_df,
aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax,
fill = resid)) +
geom_text(data = rect_df,
aes(x = ifelse(side == "left", xmin, xmax),
y = yidx, label = freq),
hjust = ifelse(rect_df$side == "left", 1.15, -0.15),
size = 3, fontface = "bold",
color = ifelse(abs(rect_df$resid) > r_max * 0.5, "grey25", "grey45")) +
{if (show_residuals)
geom_text(data = rect_df,
aes(x = x_center, y = yidx, label = resid_label),
size = 2.8, fontface = "bold",
color = rect_df$text_color)
} +
{if (has_pval)
geom_text(data = p_df,
aes(x = 0, y = yidx, label = p_label),
size = 2.5, color = "grey40", fontface = "italic")
} +
scale_fill_gradient2(low = "#CB181D", mid = "white", high = "#2171B5",
midpoint = 0, limits = c(-3, 3),
oob = scales::squish,
name = "Standardized\nresidual") +
scale_y_continuous(breaks = y_num,
labels = as.character(pat$pattern)) +
scale_x_continuous(
labels = function(x) format(abs(x), scientific = FALSE)
) +
labs(x = "Proportion", y = NULL,
title = "Sequence Pattern Comparison",
subtitle = paste0(g1, " \u2190\u2190 \u2192\u2192 ", g2)) +
theme_minimal(base_size = 13) +
theme(
axis.text.y = element_text(family = "mono", size = 8),
axis.ticks.y = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
plot.title = element_text(hjust = 0.5, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, color = "grey40",
size = 11),
legend.position = "right",
plot.margin = margin(5, 10, 5, 5)
)
print(p)
invisible(p)
}
#' @noRd
.sc_plot_heatmap <- function(pat, groups, resid_cols) {
long <- as.data.frame(data.table::rbindlist(lapply(seq_along(groups), function(gi) {
data.frame(
pattern = pat$pattern,
group = groups[gi],
resid = pat[[resid_cols[gi]]],
stringsAsFactors = FALSE
)
})))
long$pattern <- factor(long$pattern, levels = rev(pat$pattern))
long$group <- factor(long$group, levels = groups)
r_max <- max(abs(long$resid), na.rm = TRUE)
# Saturate at ±3 (|z|>3 is the conventional "very strong" threshold).
# Anything beyond clips to full color via scales::squish so mid-range
# residuals (±1 to ±2) pick up visible color instead of fading to white.
r_lim <- 3
p <- ggplot(long, aes(x = group, y = pattern, fill = resid)) +
geom_tile(color = "white") +
geom_text(aes(label = sprintf("%.2f", resid)),
color = "white", size = 5, fontface = "bold") +
scale_fill_gradient2(low = "#CB181D", mid = "white", high = "#2171B5",
midpoint = 0, limits = c(-r_lim, r_lim),
oob = scales::squish,
name = "Standardized\nresidual") +
labs(x = "Groups", y = NULL, title = "Sequence Pattern Comparison") +
theme_minimal(base_size = 11) +
theme(
axis.text.y = element_text(family = "mono", size = 10),
axis.text.x = element_text(angle = 0, size = 11,
face = "bold"),
axis.title.x = element_text(size = 14),
panel.grid = element_blank(),
plot.title = element_text(hjust = 0.5, face = "bold", size = 15),
legend.position = "right",
legend.title = element_text(size = 12),
legend.text = element_text(size = 11),
legend.key.height = unit(1.2, "cm"),
plot.margin = margin(10, 15, 10, 10)
)
print(p)
invisible(p)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.