Nothing
utils::globalVariables(c(
"xmin", "xmax", "ymin", "ymax", "x", "y", "label",
"a", "b", "xend", "yend", "linetype", "mid_x", "mid_y",
"label_text", ".data"
))
#' Create a template for use in diyPaths
#'
#' @description
#'
#' Takes your fitted model and prints a ready-to-run starting template (node list, path list, title list, note list, and
#' `diyPaths()` call) to the console. Copy the output into your script, then fine-tune the diagram by adjusting values in the lines marked `#EDIT ME!`
#'
#' The template is also returned invisibly as a character string, so it can be stored as an object then viewed
#' with `writeLines()` (see examples).
#'
#' @param fit A fitted model object of class `lavaan`.
#' @param include_variance_paths Logical. Whether to include variance/residual paths in the path list. Default is `TRUE`.
#' @param diyPaths_arguments Use either "max" (the printed call shows every [diyPaths()] argument) or "min" (only the required arguments). Default is "max".
#' @param quiet Logical. If `TRUE`, the template is not printed to the console and is only returned
#' invisibly. Default is `FALSE`.
#' @return Invisibly returns the template as a single character string. Unless `quiet = TRUE`,
#' it is also printed to the console.
#' @seealso [diyPaths()] to render the diagram from the template;
#' [node()], [path()], [panel_title()], and [panel_note()] for the helper functions the template uses.
#' @export
#'
#' @examples
#'
#' library(lavaan)
#'
#' data(HolzingerSwineford1939, package = "lavaan")
#'
#'
#' #specify the model
#'
#' sem_model <- '
#' visual =~ x1 + x2 + x3
#' textual =~ x4 + x5 + x6
#' speed =~ x7 + x8 + x9
#'
#' speed ~ visual + textual
#' visual ~~ textual
#' '
#'
#' #fit the model
#'
#' fit <- sem(sem_model, data = HolzingerSwineford1939)
#'
#' #print the maximal template to the console
#'
#' # template(fit = fit, include_variance_paths = TRUE, diyPaths_arguments = "max", quiet = FALSE)
#'
#' #store the template without printing it, then view it
#'
#' tmpl <- template(fit = fit, diyPaths_arguments = "min", quiet = TRUE)
#' writeLines(tmpl)
template <- function(fit, include_variance_paths = TRUE, diyPaths_arguments = c("max", "min"), quiet = FALSE) {
temp_pe_full <- lavaan::parameterestimates(fit)
temp_pe_full <- temp_pe_full[temp_pe_full$op %in% c("=~", "~", "~~"), ]
temp_pe_full <- unique(temp_pe_full[, colnames(temp_pe_full) %in% c("lhs", "op", "rhs")])
fit_name <- deparse(substitute(fit))
diyPaths_arguments <- match.arg(diyPaths_arguments)
temp_is_loading <- temp_pe_full$op == "=~"
temp_is_reg <- temp_pe_full$op == "~"
temp_is_cov <- temp_pe_full$op == "~~" & temp_pe_full$lhs != temp_pe_full$rhs
temp_is_var <- temp_pe_full$op == "~~" & temp_pe_full$lhs == temp_pe_full$rhs
temp_latent_var <- unique(temp_pe_full$lhs[temp_pe_full$op == "=~"])
temp_nodes <- data.frame(node = unique(temp_pe_full$lhs))
temp_nodes$is_latent <- ifelse(temp_nodes$node %in% temp_latent_var, "Latent", "Obs")
temp_nodes$type <- NA_character_
if (any(temp_nodes$is_latent == "Latent")){
for (i in seq_len(nrow(temp_nodes))){
current_node <- temp_nodes$node[i]
if(current_node %in% temp_latent_var) {
temp_nodes$type[i] <- paste("#node(s) for latent variable(s)")
} else{
loads_onto <- temp_pe_full$lhs[temp_pe_full$op == "=~" & temp_pe_full$rhs == current_node]
temp_nodes$type[i] <- if(length(loads_onto) > 0) {paste("#node(s) for observed variables loading onto latent variable", paste(loads_onto, collapse = ","))
} else{ paste("#node(s) for observed variable(s) ")}
}
}
} else {
temp_nodes$type <- c("#node(s) for observed variable(s)")
}
temp_rank <- ifelse(temp_nodes$is_latent == "Latent", 1,
ifelse(grepl("#node(s) for observed variables loading onto",
temp_nodes$type, fixed = TRUE), 3, 2))
temp_nodes <- temp_nodes[order(temp_rank, match(temp_nodes$type, unique(temp_nodes$type))), ]
temp_node_paste <- data.frame(output = paste0(
'\t',
'node(',
'name = "', temp_nodes$node, '"', ', ',
'x = 0, ',
'y = 0, ',
'label = "', temp_nodes$node, '"', ')',
ifelse(seq_len(nrow(temp_nodes)) < nrow(temp_nodes), ',', ''),
' #EDIT ME!'))
if (any(temp_nodes$is_latent == "Latent")){
temp_type_grouped <- unique(temp_nodes$type)
temp_paste_group <- unlist(lapply(temp_type_grouped, function(g){
c(paste0("\t", g), temp_node_paste$output[temp_nodes$type == g], "")}))
temp_node_string <- paste0(
"#### BEGIN diyPaths CODE ####\n\n",
"node_list <- list(\n",
paste(temp_paste_group, collapse = "\n"),
"\n)")
} else{
temp_node_string <- paste0(
"#### BEGIN diyPaths CODE ####\n\n",
"node_list <- list(\n",
"\t", temp_nodes$type[1], "\n",
paste(temp_node_paste$output, collapse = "\n"),
"\n)")
}
temp_reg_paths <- data.frame(
from = rep(NA, sum(temp_is_reg)),
to = rep(NA, sum(temp_is_reg))
)
temp_reg_paths$from <- temp_pe_full$rhs[temp_is_reg]
temp_reg_paths$to <- temp_pe_full$lhs[temp_is_reg]
temp_reg_paste <- data.frame(output = paste0('\t',
'path(', 'from = "', temp_reg_paths$from, '"', ', ', 'to = "', temp_reg_paths$to, '"', ', ',
'side_from = "right"', ', ', 'side_to = "left"', ', ', "nudge_text_x = 0", ', ', "nudge_text_y = 0", ')',
',',
' #EDIT ME!'
))
temp_loading_paths <- data.frame(
from = rep(NA, sum(temp_is_loading)),
to = rep(NA, sum(temp_is_loading))
)
temp_loading_paths$from <- temp_pe_full$lhs[temp_is_loading]
temp_loading_paths$to <- temp_pe_full$rhs[temp_is_loading]
temp_loading_paste <- data.frame(output = paste0('\t',
'path(', 'from = "', temp_loading_paths$from, '"', ', ', 'to = "', temp_loading_paths$to, '"', ', ',
'side_from = "right"', ', ', 'side_to = "left"', ', ', "nudge_text_x = 0", ', ', "nudge_text_y = 0", ')',
',',
' #EDIT ME!'
))
temp_cov_paths <- data.frame(
from = rep(NA, sum(temp_is_cov)),
to = rep(NA, sum(temp_is_cov))
)
temp_cov_paths$from <- temp_pe_full$rhs[temp_is_cov]
temp_cov_paths$to <- temp_pe_full$lhs[temp_is_cov]
temp_cov_paste <- data.frame(output = paste0('\t',
'path(', 'from = "', temp_cov_paths$from, '"', ', ', 'to = "', temp_cov_paths$to, '"', ', ',
'side_from = "right"', ', ', 'side_to = "left"', ', ', "nudge_text_x = 0", ', ', "nudge_text_y = 0", ', ', 'cov_curve = 0.4', ')',
',',
' #EDIT ME!'
))
temp_variance_paths <- data.frame(
from = rep(NA, sum(temp_is_var)),
to = rep(NA, sum(temp_is_var))
)
temp_variance_paths$from <- temp_pe_full$rhs[temp_is_var]
temp_variance_paths$to <- temp_pe_full$lhs[temp_is_var]
temp_variance_paste <- data.frame(output = paste0('\t',
'path(', 'from = "', temp_variance_paths$from, '"', ', ', 'to = "', temp_variance_paths$to, '"', ', ',
"nudge_text_x = 0", ', ', "nudge_text_y = 0", ', ', 'variance_position = "top"', ')',
',',
' #EDIT ME!'
))
temp_reg_section <- if (nrow(temp_reg_paths) > 0) paste0('\t',"# regression path(s)\n", paste(temp_reg_paste$output, collapse = "\n"), "\n") else ""
temp_loading_section <- if (nrow(temp_loading_paths) > 0) {
temp_grouped_loadings <- unlist(lapply(unique(temp_loading_paths$from), function(glp){
c(paste0("\t #loading path(s) from ", glp),
temp_loading_paste$output[temp_loading_paths$from == glp], "")}))
paste0(paste(temp_grouped_loadings, collapse = "\n"))
} else {
""
}
temp_cov_section <- if (nrow(temp_cov_paths) > 0) paste0('\t',"# covariance/correlation path(s)\n", paste(temp_cov_paste$output, collapse = "\n"), "\n") else ""
temp_variance_section <- if (include_variance_paths && nrow(temp_variance_paths) > 0) paste0('\t',"# variance path(s) \n","\t", "# REMEMBER TO SET show_variances IN diyPaths CALL = TRUE TO INCLUDE IN DIAGRAM!\n", paste(temp_variance_paste$output, collapse = "\n"), "\n") else ""
temp_path_string <- paste0("\n",
"path_list <- list(\n",
temp_reg_section, "\n",
temp_loading_section, "\n",
temp_cov_section, "\n",
temp_variance_section,
"\n)"
)
temp_path_string <- sub(", #EDIT ME!\\s+\\)", " #EDIT ME!\n\\)", temp_path_string)
temp_n_groups <- lavaan::lavInspect(fit, "ngroups")
temp_title_lines <- paste0(
'\t', 'panel_title(panel_num = ', seq_len(temp_n_groups), ', title = "My SEM Plot")',
ifelse(seq_len(temp_n_groups) < temp_n_groups, ',', ''),
' #EDIT ME!'
)
temp_title_string <- paste0(
"title_list <- list(\n",
paste(temp_title_lines, collapse = "\n"),
"\n)"
)
temp_note_lines <- paste0(
'\t', 'panel_note(panel_num = ', seq_len(temp_n_groups), ', note_text = NULL)',
ifelse(seq_len(temp_n_groups) < temp_n_groups, ',', ''),
' #EDIT ME!'
)
temp_note_string <- paste0(
"note_list <- list(\n",
paste(temp_note_lines, collapse = "\n"),
"\n)"
)
diyPaths_arguments_all_args <- paste0(
"my_path_diagram <- diyPaths(fit = ", fit_name, ", ", "node_positions = node_list, path_positions = path_list,
standardised = FALSE,
digits = 3,
est_stars = FALSE,
est_p = FALSE,
est_ci = FALSE,
show_variances = FALSE,
variance_stars = FALSE,
variance_p = FALSE,
variance_ci = FALSE,
sig_linetype = FALSE,
p_threshold = 0.05,
node_width = 1.5, node_height = 1,
latent_node_size_adjust = 1,
observed_node_size_adjust = 1,
latent_node_text_size = 4,
observed_node_text_size = 4,
non_transparent_text = TRUE,
path_text_size = 3.5,
line_thickness = 0.6,
arrow_size = 0.2,
panel_cols = NULL,
show_group_labels = FALSE,
title_text_size = NULL,
panel_titles = title_list,
panel_notes = note_list,
note_text_size = NULL,
font_family = \"\",
title_note_font_family = NULL,
show_grid = FALSE,
grid_axis_scale = 1,
margin_x = 0.5,
margin_y = 0.5,
look_up_table = FALSE)
my_path_diagram
#### END diyPaths CODE :) ####"
)
diyPaths_arguments_minimal_args <- paste0(
"my_path_diagram <- diyPaths(fit = ", fit_name, ",
node_positions = node_list,
path_positions = path_list,
panel_titles = title_list,
panel_notes = note_list)
my_path_diagram
#### END diyPaths CODE :) ####")
which_template <- if (diyPaths_arguments == "max") diyPaths_arguments_all_args else diyPaths_arguments_minimal_args
template_text <- paste(temp_node_string, temp_path_string, temp_title_string, temp_note_string, which_template, sep = "\n\n")
if(!quiet) {
writeLines(template_text)
}
invisible(template_text)
}
#' Create a node position list
#'
#' @description
#' Helper function that creates a list of arguments which specify a given node's position and its label in the diagram,
#' designed for use within the `node_positions` argument of [diyPaths()].
#'
#' @param name The name of the variable within the `lavaan` model.
#' @param x Numeric value for the x-coordinate of the node's centre. Default is `0`.
#' @param y Numeric value for the y-coordinate of the node's centre. Default is `0`.
#' @param label Custom label to display instead of `lavaan` variable name. Defaults to the `lavaan` variable name.
#'
#' @seealso [path()] to specify the paths between nodes;
#' [diyPaths()] to render the diagram; [template()] to generate a starting `node_positions` list.
#'
#' @return A list containing arguments that specify a node's position and label, for use within the `node_positions` argument of [diyPaths()].
#' @export
#'
#' @examples
#'
#' #a list that specifies a node positioned on x = 1, y = 2,
#' #and relabelled from its `lavaan` model name.
#'
#' node(name = "bpm", x = 1, y = 2, label = "Beats per Minute")
#'
node <- function(name, x = 0, y = 0, label = NULL) {
list(
name = as.character(name),
x = as.numeric(x),
y = as.numeric(y),
label = if (is.null(label)) as.character(name) else as.character(label)
)
}
#' Create a path position list
#'
#' @description
#' Helper function that creates a list of arguments which specify a given path's position and fine-tuning adjustments,
#' designed for use within the `path_positions` argument of [diyPaths()].
#'
#' @param from The source variable name within the `lavaan` model.
#' @param to The target variable name within the `lavaan` model.
#' @param side_from Side of source node where path starts ("top", "bottom", "left", "right"). Default is "right".
#' @param side_to Side of target node where path ends ("top", "bottom", "left", "right"). Default is "left".
#' @param cov_curve Numeric value for curvature of covariance/correlation paths. Default is NULL.
#' @param nudge_text_x Numeric fine tuning adjustment for path estimate text along the x-axis. Default is `0`.
#' @param nudge_text_y Numeric fine tuning adjustment for path estimate text along the y-axis. Default is `0`.
#' @param variance_position Placement of variance/residual paths ("top", "bottom", "left", or "right"). Default is "top".
#'
#' @details
#'
#' `from`/`to` in `path()` must exactly match the variable name used in the `lavaan` model syntax.
#' Furthermore, the order must also be correct for regression or loading paths. A misspelled or mismatched
#' `path()` entry will not raise an error, instead, it will use default attachment points and values, not applying
#' specific customisations. This can be completely avoided using [template()].
#'
#' `cov_curve` controls the curvature of covariance/correlation paths: its size sets how strongly the
#' path bends, and its sign sets which way. If a path bends the wrong way, flip the sign. Best results
#' typically range from -1 to 1.
#'
#' @seealso [node()] to specify node positions;
#' [diyPaths()] to render the diagram; [template()] to generate a starting `path_positions` list.
#'
#' @examples
#'
#' #a list that specifies a regression path of node "anxiety" predicting node "depression".
#'
#' path(from = "anx", to = "dep", side_from = "right", side_to = "left")
#'
#' #a list that specifies a covariance/correlation path of node "anxiety" and node "depression".
#' #Order of "from" and "to" does not matter for covariance/correlation.
#'
#'
#' path(from = "dep", to = "anx", side_from = "left", side_to = "left", cov_curve = -0.6)
#'
#' @return A list containing arguments that specify a path's position and fine-tuning adjustments, for use within the `path_positions` argument of [diyPaths()].
#' @export
path <- function(from, to, side_from = "right", side_to = "left", cov_curve = NULL,
nudge_text_x = 0, nudge_text_y = 0, variance_position = "top") {
list(
from = as.character(from),
to = as.character(to),
side_from = side_from,
side_to = side_to,
nudge_text_x = nudge_text_x,
nudge_text_y = nudge_text_y,
cov_curve = cov_curve,
variance_position = variance_position
)
}
#' Create a note for a diyPaths panel
#'
#' @description
#' Helper function that creates a list of arguments which specify a custom note, displayed as a caption beneath its target panel,
#' designed for use within the `panel_notes` argument of [diyPaths()]. Use the
#' `show_group_labels` argument in [diyPaths()] to view each panel's number and
#' which group it refers to.
#'
#' @param panel_num Integer value for the panel number this note applies to.
#' @param note_text Text to display in the note. If `NULL`, no note is drawn.
#'
#' @seealso [diyPaths()], where the list of notes is passed to `panel_notes`;
#' [panel_title()] for adding titles to panels; [template()], whose output includes a starting note list.
#'
#' @return A list containing arguments that specify a panel's note text, for use within the `panel_notes` argument of [diyPaths()].
#' @export
#'
#'
#' @examples
#' # a note on the first panel noting sample size was 100.
#' panel_note(panel_num = 1, note_text = "n = 100")
#'
#' # a note on the second panel of a multi-group model noting sample size for Group 2 was 50
#' panel_note(panel_num = 2, note_text = "Group 2: n = 50")
#'
panel_note <- function(panel_num, note_text = NULL) {
list(panel_num = as.integer(panel_num),
note_text = if (is.null(note_text)) NA_character_ else as.character(note_text))
}
#' Create a title for a diyPaths panel
#'
#' @description
#' Helper function that creates a list of arguments which specify a custom title and its target panel,
#' designed for use within the `panel_titles` argument of [diyPaths()]. Use the
#' `show_group_labels` argument in [diyPaths()] to view each panel's number and
#' which group it refers to.
#'
#' @param panel_num Integer value for the panel number this title applies to. Default is `1`.
#' @param title The title text to display.
#'
#' @seealso [diyPaths()], where the list of titles is passed to `panel_titles`;
#' [panel_note()] for adding notes to panels; [template()], whose output includes a starting title list.
#'
#' @return A list containing arguments that specify a panel's number and title, for use within the `panel_titles` argument of [diyPaths()].
#' @export
#'
#' @examples
#' # titling a single, non-grouped model
#' panel_title(title = "My SEM Model")
#'
#' # titling the second panel of a multi-group model
#' panel_title(panel_num = 2, title = "Female Participants")
panel_title <- function(panel_num = 1, title = NULL) {
list(panel_num = as.integer(panel_num),
title = if (is.null(title)) NA_character_ else as.character(title))
}
#' Manually plot path diagrams for structural equation models
#'
#' @description
#'
#' Plot fully customisable path diagrams for structural equation models fitted with \pkg{lavaan}, rendered using \pkg{ggplot2}.
#'
#' To render the `ggplot` object of the diagram, the function only requires users to supply a fitted `lavaan` model, specify node positions using x-y coordinates, and detail where on
#' the perimeter of each node (top, bottom, left, or right) each path should begin and end. The render automatically inserts estimates centred on the midpoint and adjusts each node's
#' shape to match its variable type. A range of optional fine-tuning arguments are included, facilitating the creation of a path diagram exactly as you envision it, entirely within R.
#'
#'
#' @param fit A fitted model object of class `lavaan`.
#' @param node_positions Specify a list of node position objects. Use the [node()] helper function to assist with this.
#' @param path_positions Specify a list of path configuration objects. Use the [path()] helper function to assist with this.
#' @param standardised Logical. If `TRUE`, uses standardised parameter estimates (`est.std`). Default is `FALSE`.
#' @param digits Number of decimal places to display for estimates. Default is `3`.
#' @param est_stars Logical. Whether to display significance stars on path estimates. Default is `FALSE`.
#' @param est_p Logical. Whether to display p-values on path estimates. Default is `FALSE`.
#' @param est_ci Logical. Whether to display confidence intervals on path estimates. Default is `FALSE`.
#' @param show_variances Logical. Whether to display variance and residual paths. Default is `FALSE`.
#' @param variance_stars Logical. Whether to display significance stars on variance paths. Default is `FALSE`.
#' @param variance_p Logical. Whether to display p-values on variance paths. Default is `FALSE`.
#' @param variance_ci Logical. Whether to display confidence intervals on variance paths. Default is `FALSE`.
#' @param sig_linetype Logical. If `TRUE`, renders non-significant paths with dashed lines. Default is `FALSE`.
#' @param p_threshold Statistical significance threshold used to determine non-significant paths when `sig_linetype = TRUE`. Default is `0.05`.
#' @param node_width Base width for nodes. Default is `1.5`.
#' @param node_height Base height for node shapes. Default is `1`.
#' @param latent_node_size_adjust Numeric multiplier scaling latent node ellipses. Default is `1`.
#' @param observed_node_size_adjust Numeric multiplier scaling observed node rectangles. Default is `1`.
#' @param latent_node_text_size Text font size for latent node labels. Default is `4`.
#' @param observed_node_text_size Text font size for observed node labels. Default is `4`.
#' @param non_transparent_text Logical. If `TRUE`, path estimates receive a white background mask, if `FALSE`, they have a transparent background. Default is `TRUE`.
#' @param path_text_size Text font size for path estimate labels. Default is `3.5`.
#' @param line_thickness Sets thickness of paths. Default is `0.6`.
#' @param arrow_size Sets the size of arrow heads. Default is `0.2`.
#' @param panel_cols Integer for number of columns to use when arranging multi-group panels. Default is `NULL`.
#' @param show_group_labels Logical. For multi-group models, whether to annotate each panel
#' with its panel number and the raw group value it represents (e.g. "Panel 1: Group = male").
#' Needed to identify which diagram represents each group and its internal panel number when creating panel titles and notes.
#' Default is `FALSE`.
#' @param title_text_size Font size for panel titles. Default is NULL (ggplot2's default title size).
#' @param panel_titles Specify a list of titles for panels. Use the [panel_title()] helper function to assist with this. Any panel not referenced is
#' left untitled. Default is `NULL`.
#' @param panel_notes Specify a list of notes, displayed as captions beneath each panel. Use the [panel_note()] helper function to assist with this. Any panel not referenced is
#' left without a note. Default is `NULL`.
#' @param note_text_size Font size for panel notes (captions). Default is NULL (ggplot2's default caption size).
#' @param font_family Font family used for all text in the diagram. Default is `""`, the graphics device's default font.
#' @param title_note_font_family Font family for panel titles and notes. Default is `NULL`, which
#' uses `font_family`.
#' @param show_grid Logical. Whether to overlay a coordinate grid. Default is `FALSE`.
#' @param grid_axis_scale Sets the spacing of grid-lines when `show_grid = TRUE`. Default is `1`.
#' @param margin_x Padding for plot limits along the x-axis. Default is `0.5`.
#' @param margin_y Padding for plot limits along the y-axis. Default is `0.5`.
#' @param look_up_table Logical. If `TRUE`, also returns a look-up table detailing the width (x scale) and height (y scale) of latent and observed nodes,
#' along with the text sizes used for latent, observed, and path labels. Default is `FALSE`.
#'
#' @details
#'
#' The easiest way to start is with [template()], which prints ready-to-run code for your fitted model.
#' Edit the node positions and path sides it generates, render with `diyPaths()`, then fine-tune and
#' re-render until the layout looks right. See `vignette("diy.sem.plot")` for worked examples.
#'
#' @return A `ggplot` (or `patchwork`, for multi-group models) object representing the SEM
#' path diagram(s). If `look_up_table = TRUE`, a list containing the diagram(s) (`$plot`) and a look-up
#' `data.frame` of node width/height and text sizes for latent, observed, and path labels
#' (`$look_up_table`).
#'
#' @seealso [template()] to generate starting code for `node_positions`, `path_positions`, `panel_titles` and `panel_notes`;
#' [node()], [path()], [panel_title()], and [panel_note()] for the helper functions used within them.
#'
#' @export
#'
#' @examples
#'
#' #full SEM example, this model may not make theoretical sense.
#'
#' library(lavaan)
#' library(ggplot2)
#'
#' data(HolzingerSwineford1939, package = "lavaan")
#'
#' #specify the model
#'
#' sem_model <- '
#' visual =~ x1 + x2 + x3
#' textual =~ x4 + x5 + x6
#' speed =~ x7 + x8 + x9
#'
#' speed ~ visual + textual
#' visual ~~ textual
#' '
#'
#' #fit the model
#'
#' fit <- sem(sem_model, data = HolzingerSwineford1939)
#'
#' #generate a starting template
#'
#' temp <- template(fit = fit, diyPaths_arguments = "max", quiet = TRUE)
#'
#' #### edited template ####
#'
#' node_list <- list(
#' node(name = "visual", x = 1, y = 1, label = "Visual"),
#' node(name = "textual", x = 1, y = 2, label = "Textual"),
#' node(name = "speed", x = 4, y = 1.5, label = "Speed"),
#' node(name = "x1", x = -0.06, y = -0.5, label = "Visual\nPerception"),
#' node(name = "x2", x = 1, y = -0.5, label = "Cubes"),
#' node(name = "x3", x = 2.06, y = -0.5, label = "Lozenges"),
#' node(name = "x4", x = -0.06, y = 3.5, label = "Paragraph\nComprehension"),
#' node(name = "x5", x = 1, y = 3.5, label = "Sentence\nCompletion"),
#' node(name = "x6", x = 2.06, y = 3.5, label = "Word\nMeaning"),
#' node(name = "x7", x = 6, y = 0.5, label = "Speeded\nAddition"),
#' node(name = "x8", x = 6, y = 1.5, label = "Speeded\nCounting"),
#' node(name = "x9", x = 6, y = 2.5, label = "Speeded\nDiscrimination")
#' )
#'
#' path_list <- list(
#' # regression paths
#' path(from = "visual", to = "speed", side_from = "right", side_to = "left"),
#' path(from = "textual", to = "speed", side_from = "right", side_to = "left"),
#'
#' # loading paths
#' path(from = "visual", to = "x1", side_from = "bottom", side_to = "top", nudge_text_x = -0.1),
#' path(from = "visual", to = "x2", side_from = "bottom", side_to = "top"),
#' path(from = "visual", to = "x3", side_from = "bottom", side_to = "top", nudge_text_x = 0.1),
#' path(from = "textual", to = "x4", side_from = "top", side_to = "bottom", nudge_text_x = -0.1),
#' path(from = "textual", to = "x5", side_from = "top", side_to = "bottom"),
#' path(from = "textual", to = "x6", side_from = "top", side_to = "bottom", nudge_text_x = 0.1),
#' path(from = "speed", to = "x7", side_from = "right", side_to = "left"),
#' path(from = "speed", to = "x8", side_from = "right", side_to = "left"),
#' path(from = "speed", to = "x9", side_from = "right", side_to = "left"),
#'
#' # covariance/correlation paths
#' path(from = "visual", to = "textual", side_from = "left", side_to = "left", cov_curve = -0.6)
#' )
#'
#' title_list <- list(
#' panel_title(panel_num = 1, title = "My SEM Plot")
#' )
#'
#' note_list <- list(
#' panel_note(panel_num = 1, note_text = "Standardised estimates shown.")
#' )
#'
#' p <- diyPaths(fit = fit, node_positions = node_list, path_positions = path_list,
#' standardised = TRUE,
#' digits = 3,
#' est_stars = TRUE,
#' est_p = FALSE,
#' est_ci = FALSE,
#' show_variances = FALSE,
#' variance_stars = FALSE,
#' variance_p = FALSE,
#' variance_ci = FALSE,
#' sig_linetype = FALSE,
#' p_threshold = 0.05,
#' panel_titles = title_list,
#' panel_notes = note_list,
#' show_grid = TRUE,
#' grid_axis_scale = 0.5,
#' non_transparent_text = TRUE,
#' latent_node_text_size = 4,
#' observed_node_text_size = 3,
#' path_text_size = 3.5,
#' line_thickness = 0.6,
#' arrow_size = 0.2,
#' node_width = 1.5, node_height = 1,
#' latent_node_size_adjust = 0.8,
#' observed_node_size_adjust = 0.55,
#' show_group_labels = FALSE,
#' panel_cols = NULL,
#' font_family = "sans",
#' title_note_font_family = "serif",
#' margin_x = 0.5,
#' margin_y = 0.5,
#' look_up_table = TRUE)
#'
#' print(p)
diyPaths <- function(fit, node_positions, path_positions, standardised = FALSE, digits = 3,
est_stars = FALSE, est_p = FALSE, est_ci = FALSE,
variance_stars = FALSE, variance_p = FALSE, variance_ci = FALSE,
sig_linetype = FALSE, p_threshold = 0.05,
show_variances = FALSE,
show_grid = FALSE,
grid_axis_scale = 1,
non_transparent_text = TRUE,
latent_node_text_size = 4,
observed_node_text_size = 4,
path_text_size = 3.5,
line_thickness = 0.6,
arrow_size = 0.2,
node_width = 1.5, node_height = 1,
latent_node_size_adjust = 1,
observed_node_size_adjust = 1,
show_group_labels = FALSE,
panel_titles = NULL,
panel_cols = NULL,
margin_x = 0.5,
margin_y = 0.5,
look_up_table = FALSE,
panel_notes = NULL,
note_text_size = NULL,
title_text_size = NULL,
font_family = "",
title_note_font_family = NULL){
pos_df <- do.call(rbind, lapply(node_positions, function(v) {
data.frame(
name = as.character(v$name),
x = as.numeric(v$x),
y = as.numeric(v$y),
label = if (!is.null(v$label)) as.character(v$label) else as.character(v$name),
stringsAsFactors = FALSE
)
}))
notes_df <- do.call(rbind, lapply(panel_notes, as.data.frame))
titles_df <- do.call(rbind, lapply(panel_titles, as.data.frame))
pe_full <- if (standardised){
stan <- lavaan::standardizedsolution(fit)
names(stan)[names(stan) == "est.std"] <- "est"
stan
} else {
lavaan::parameterestimates(fit)
}
size_tbl <- data.frame(
type = c("latent", "observed", "path"),
width = c(node_width * latent_node_size_adjust,
node_width * observed_node_size_adjust,
NA),
height = c(node_height * latent_node_size_adjust,
node_height * observed_node_size_adjust,
NA),
text_size = c(latent_node_text_size,
observed_node_text_size,
path_text_size)
)
latent_vars_all <- unique(pe_full$lhs[pe_full$op == "=~"])
pos_df$is_latent <- pos_df$name %in% latent_vars_all
pos_df$w <- ifelse(pos_df$is_latent, node_width * latent_node_size_adjust, node_width * observed_node_size_adjust)
pos_df$h <- ifelse(pos_df$is_latent, node_height * latent_node_size_adjust, node_height * observed_node_size_adjust)
pos_df$a <- pos_df$w / 2
pos_df$b <- pos_df$h / 2
pos_df$xmin <- pos_df$x - pos_df$w / 2
pos_df$xmax <- pos_df$x + pos_df$w / 2
pos_df$ymin <- pos_df$y - pos_df$h / 2
pos_df$ymax <- pos_df$y + pos_df$h / 2
sub_text_size <- path_text_size * 2
group_ids <- if ("group" %in% names(pe_full)) sort(unique(pe_full$group)) else 1
raw_group_values <- if (length(group_ids) > 1) lavaan::lavInspect(fit, "group.label") else NULL
plots <- vector("list", length(group_ids))
for (g in seq_along(group_ids)) {
pe <- if (length(group_ids) > 1) pe_full[pe_full$group == group_ids[g], ] else pe_full
allowed_ops <- c("=~", "~~", "~")
paths <- pe[pe$op %in% allowed_ops, c("lhs", "op", "rhs", "est", "ci.lower", "ci.upper", "pvalue")]
colnames(paths) <- c("lhs", "type", "rhs", "est", "ci.lower", "ci.upper", "pvalue")
is_loading <- paths$type == "=~"
is_reg <- paths$type == "~"
is_cov <- paths$type == "~~" & paths$lhs != paths$rhs
is_var <- paths$type == "~~" & paths$lhs == paths$rhs
paths$from <- NA
paths$to <- NA
paths$from[is_loading] <- paths$lhs[is_loading]
paths$to[is_loading] <- paths$rhs[is_loading]
paths$from[is_reg] <- paths$rhs[is_reg]
paths$to[is_reg] <- paths$lhs[is_reg]
paths$from[is_cov | is_var] <- paths$lhs[is_cov | is_var]
paths$to[is_cov | is_var] <- paths$rhs[is_cov | is_var]
if (!show_variances) paths <- paths[!(paths$type == "~~" & paths$from == paths$to), ]
digs <- paste0("%.", digits, "f")
paths$value <- sprintf(digs, paths$est)
paths$ci.lower <- sprintf(digs, paths$ci.lower)
paths$ci.upper <- sprintf(digs, paths$ci.upper)
stars <- character(nrow(paths))
stars[!is.na(paths$pvalue) & paths$pvalue <= 0.001] <- "***"
stars[!is.na(paths$pvalue) & paths$pvalue > 0.001 & paths$pvalue <= 0.01] <- "**"
stars[!is.na(paths$pvalue) & paths$pvalue > 0.01 & paths$pvalue <= 0.05] <- "*"
use_stars <- ifelse(paths$type == "~~" & paths$from == paths$to, variance_stars, est_stars)
use_p <- ifelse(paths$type == "~~" & paths$from == paths$to, variance_p, est_p)
use_ci <- ifelse(paths$type == "~~" & paths$from == paths$to, variance_ci, est_ci)
paths$label_text <- as.character(paths$value)
paths$label_text[use_stars] <- paste0(paths$label_text[use_stars], stars[use_stars])
sub_text <- character(nrow(paths))
ci_valid <- use_ci & !is.na(paths$ci.lower) & !is.na(paths$ci.upper)
sub_text[ci_valid] <- paste0("[", paths$ci.lower[ci_valid], ", ", paths$ci.upper[ci_valid], "]")
p_valid <- use_p & !is.na(paths$pvalue)
ps <- character(nrow(paths))
ps[p_valid] <- ifelse(paths$pvalue[p_valid] < 0.001, "p < .001", paste0("p = ", sprintf(digs, paths$pvalue[p_valid])))
both_sub <- nchar(sub_text) > 0 & nchar(ps) > 0
sub_text[both_sub] <- paste0(sub_text[both_sub], ", ", ps[both_sub])
sub_text[!both_sub & nchar(ps) > 0] <- ps[!both_sub & nchar(ps) > 0]
has_sub <- nchar(sub_text) > 0
if (any(has_sub)) {
paths$label_text[has_sub] <- paste0(paths$label_text[has_sub],
"<br><span style='font-size:", sub_text_size, "pt;'>",
sub_text[has_sub], "</span>")
}
paths$linetype <- if (sig_linetype) ifelse(!is.na(paths$pvalue) & paths$pvalue > p_threshold, "dashed", "solid") else "solid"
paths$side_from <- "right"
paths$side_to <- "left"
paths$nudge_x <- 0
paths$nudge_y <- 0
paths$variance_position <- "top"
paths$curvature <- ifelse(
paths$type == "~~" & paths$from != paths$to, 0.4,
ifelse(paths$type == "~~" & paths$from == paths$to,
ifelse(paths$variance_position == "bottom", 1.5, -1.5),
0)
)
for (pp in path_positions) {
match <- ifelse(
paths$type == "~~",
(paths$from == pp$from & paths$to == pp$to) | (paths$from == pp$to & paths$to == pp$from),
paths$from == pp$from & paths$to == pp$to
)
if (any(match)) {
flipped <- match & paths$from != pp$from
paths$from[match] <- pp$from
paths$to[match] <- pp$to
paths$side_from[match] <- pp$side_from
paths$side_to[match] <- pp$side_to
if (!is.null(pp$cov_curve)) {
paths$curvature[match] <- pp$cov_curve
paths$curvature[flipped] <- -pp$cov_curve
}
if (!is.null(pp$nudge_text_x)) paths$nudge_x[match] <- pp$nudge_text_x
if (!is.null(pp$nudge_text_y)) paths$nudge_y[match] <- pp$nudge_text_y
if (!is.null(pp$variance_position)) paths$variance_position[match] <- pp$variance_position
}
}
is_var_type <- paths$type == "~~" & paths$from == paths$to
paths$curvature[is_var_type] <- ifelse(
paths$variance_position[is_var_type] %in% c("bottom", "right"), 1.5, -1.5
)
pos_map <- split(pos_df, pos_df$name)
get_pt <- function(node, side) {
switch(side,
"right" = c(node$xmax, node$y),
"left" = c(node$xmin, node$y),
"top" = c(node$x, node$ymax),
"bottom" = c(node$x, node$ymin),
c(node$x, node$y))
}
n <- nrow(paths)
start_x <- numeric(n); start_y <- numeric(n)
end_x <- numeric(n); end_y <- numeric(n)
for (i in 1:n) {
start_pt <- get_pt(pos_map[[paths$from[i]]], paths$side_from[i])
end_pt <- get_pt(pos_map[[paths$to[i]]], paths$side_to[i])
start_x[i] <- start_pt[1]
start_y[i] <- start_pt[2]
end_x[i] <- end_pt[1]
end_y[i] <- end_pt[2]
}
paths$x <- start_x; paths$y <- start_y
paths$xend <- end_x; paths$yend <- end_y
paths$mid_x <- (paths$x + paths$xend) / 2 + paths$nudge_x
paths$mid_y <- (paths$y + paths$yend) / 2 + paths$nudge_y
is_cov_path <- paths$type == "~~" & paths$from != paths$to
if (any(is_cov_path)) {
for (i in which(is_cov_path)) {
x1 <- paths$x[i]; y1 <- paths$y[i]
x2 <- paths$xend[i]; y2 <- paths$yend[i]
curv <- paths$curvature[i]
dx <- x2 - x1
dy <- y2 - y1
d <- sqrt(dx^2 + dy^2)
perp_x <- dy / d
perp_y <- -dx / d
bump <- curv * d / 2
m_x <- (x1 + x2) / 2
m_y <- (y1 + y2) / 2
paths$mid_x[i] <- m_x + perp_x * bump + paths$nudge_x[i]
paths$mid_y[i] <- m_y + perp_y * bump + paths$nudge_y[i]
}
}
is_var_path <- paths$type == "~~" & paths$from == paths$to
if (any(is_var_path)) {
for (i in which(is_var_path)) {
variance_map <- pos_map[[paths$from[i]]]
variance_position <- paths$variance_position[i]
if (variance_position == "bottom") {
paths$x[i] <- variance_map$x - 0.2
paths$y[i] <- variance_map$ymin
paths$xend[i] <- variance_map$x + 0.2
paths$yend[i] <- variance_map$ymin
paths$mid_x[i] <- variance_map$x + paths$nudge_x[i]
paths$mid_y[i] <- variance_map$ymin - 0.4 + paths$nudge_y[i]
} else if (variance_position == "top") {
paths$x[i] <- variance_map$x - 0.2
paths$y[i] <- variance_map$ymax
paths$xend[i] <- variance_map$x + 0.2
paths$yend[i] <- variance_map$ymax
paths$mid_x[i] <- variance_map$x + paths$nudge_x[i]
paths$mid_y[i] <- variance_map$ymax + 0.4 + paths$nudge_y[i]
} else if (variance_position == "left") {
paths$x[i] <- variance_map$xmin
paths$y[i] <- variance_map$y -0.2
paths$xend[i] <- variance_map$xmin
paths$yend[i] <- variance_map$y + 0.2
paths$mid_x[i] <- variance_map$xmin - 0.4 + paths$nudge_x[i]
paths$mid_y[i] <- variance_map$y + paths$nudge_y[i]
} else if (variance_position == "right") {
paths$x[i] <- variance_map$xmax
paths$y[i] <- variance_map$y - 0.2
paths$xend[i] <- variance_map$xmax
paths$yend[i] <- variance_map$y + 0.2
paths$mid_x[i] <- variance_map$xmax + 0.4 + paths$nudge_x[i]
paths$mid_y[i] <- variance_map$y + paths$nudge_y[i]
}
}
}
measured_df <- pos_df[!pos_df$is_latent, ]
latent_df <- pos_df[pos_df$is_latent, ]
directional_paths <- paths[paths$type %in% c("=~", "~"), ]
cov_paths <- paths[paths$type == "~~" & paths$from != paths$to, ]
variance_paths <- paths[paths$type == "~~" & paths$from == paths$to, ]
p <- ggplot2::ggplot()
if (nrow(measured_df) > 0) {
p <- p + ggplot2::geom_rect(data = measured_df,
ggplot2::aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax),
fill = "white", color = "black") +
ggplot2::geom_text(data = measured_df,
ggplot2::aes(x = x, y = y, label = label),
size = observed_node_text_size,
family = font_family)
}
if (nrow(latent_df) > 0) {
p <- p + ggforce::geom_ellipse(data = latent_df,
ggplot2::aes(x0 = x, y0 = y, a = a, b = b, angle = 0),
fill = "white", color = "black", linewidth = 0.5) +
ggplot2::geom_text(data = latent_df,
ggplot2::aes(x = x, y = y, label = label),
size = latent_node_text_size,
family = font_family)
}
if (nrow(directional_paths) > 0) {
p <- p +
ggplot2::geom_segment(data = directional_paths,
ggplot2::aes(x = x, y = y, xend = xend, yend = yend, linetype = linetype),
linewidth = line_thickness,
arrow = ggplot2::arrow(length = grid::unit(arrow_size, "cm"), type = "closed")) +
ggtext::geom_richtext(data = directional_paths, ggplot2::aes(x = mid_x, y = mid_y, label = label_text),
fill = if (non_transparent_text) "white" else NA,
label.padding = grid::unit(rep(2, 4), "pt"), label.color = NA, size = path_text_size,
family = font_family)
}
if (nrow(cov_paths) > 0) {
for (i in seq_len(nrow(cov_paths))) {
p <- p + ggplot2::geom_curve(data = cov_paths[i, ],
ggplot2::aes(x = x, y = y, xend = xend, yend = yend, linetype = linetype),
linewidth = line_thickness,
curvature = cov_paths$curvature[i],
arrow = ggplot2::arrow(length = grid::unit(arrow_size, "cm"), ends = "both"))
}
p <- p + ggtext::geom_richtext(data = cov_paths, ggplot2::aes(x = mid_x, y = mid_y, label = label_text),
fill = if (non_transparent_text) "white" else NA,
label.padding = grid::unit(rep(2, 4), "pt"), label.color = NA, size = path_text_size,
family = font_family)
}
if (nrow(variance_paths) > 0) {
for (i in seq_len(nrow(variance_paths))) {
p <- p + ggplot2::geom_curve(data = variance_paths[i, ],
ggplot2::aes(x = x, y = y, xend = xend, yend = yend, linetype = linetype),
linewidth = line_thickness,
curvature = variance_paths$curvature[i],
arrow = ggplot2::arrow(length = grid::unit(arrow_size, "cm"), type = "closed"))
}
p <- p + ggtext::geom_richtext(data = variance_paths, ggplot2::aes(x = mid_x, y = mid_y, label = label_text),
fill = if (non_transparent_text) "white" else NA,
label.padding = grid::unit(rep(2, 4), "pt"), label.color = NA, size = path_text_size,
family = font_family)
}
x_limits <- c(min(pos_df$xmin) - margin_x, max(pos_df$xmax) + margin_x)
y_limits <- c(min(pos_df$ymin) - margin_y, max(pos_df$ymax) + margin_y)
p <- p + ggplot2::scale_linetype_identity() +
ggplot2::scale_x_continuous(breaks = seq(floor(x_limits[1]), ceiling(x_limits[2]), by = grid_axis_scale)) +
ggplot2::scale_y_continuous(breaks = seq(floor(y_limits[1]), ceiling(y_limits[2]), by = grid_axis_scale)) +
ggplot2::coord_fixed(xlim = x_limits, ylim = y_limits)
if (!is.null(titles_df)) {
match_id_title <- titles_df[titles_df$panel_num == g & !is.na(titles_df$title), ]
if (nrow(match_id_title) > 0)
p <- p + ggplot2::labs(title = match_id_title$title[1])
}
if (!is.null(notes_df)) {
match_id_note <- notes_df[notes_df$panel_num == g & !is.na(notes_df$note_text), ]
if (nrow(match_id_note) > 0)
p <- p + ggplot2::labs(caption = match_id_note$note_text[1])
}
if (show_group_labels && length(group_ids) > 1) {
p <- p + ggplot2::annotate(
"text", x = Inf, y = -Inf,
label = paste0("Panel ", g, ": Group = ", raw_group_values[g]),
hjust = 1.05, vjust = -0.5, size = 3, colour = "grey40",
family = font_family
)
}
grid_or_no <- if (show_grid) {
ggplot2::theme_bw(base_family = font_family) +
ggplot2::theme(
panel.grid.major = ggplot2::element_line(color = "grey", linetype = "dashed"),
panel.grid.minor = ggplot2::element_line(color = "grey", linetype = "dotted"),
axis.text = ggplot2::element_text(size = 9, color = "black"),
axis.title = ggplot2::element_text(size = 10),
axis.ticks = ggplot2::element_line(color = "black"),
panel.background = ggplot2::element_rect(fill = "white"),
plot.title = ggtext::element_markdown(size = title_text_size, family = title_note_font_family),
plot.caption = ggtext::element_markdown(size = note_text_size, hjust = 0, family = title_note_font_family))
} else {
ggplot2::theme_void(base_family = font_family) +
ggplot2::theme(
plot.title = ggtext::element_markdown(size = title_text_size, family = title_note_font_family),
plot.caption = ggtext::element_markdown(size = note_text_size, hjust = 0, family = title_note_font_family))
}
p <- p + grid_or_no
plots[[g]] <- p
}
final <- if (length(plots) == 1) {
plots[[1]]
} else {
cols <- if (!is.null(panel_cols)) panel_cols else min(length(plots), 2)
patchwork::wrap_plots(plots, ncol = cols)
}
if (look_up_table) {
return(list(plot = final, look_up_table = size_tbl))
}
final
}
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.