lvmPlot

lvmPlot draws publication-ready diagrams for latent variable models. lavaan is the main workflow, but the package now uses a common lvm_graph grammar so blavaan/lavaan.mi, semPlot, mirt, eRm, OpenMx, psych, poLCA, mclust, flexmix, lcmm, tidyLPA, and MplusAutomation-style outputs can share the same RStudio, SVG, PDF, PNG, and TikZ rendering system. The supported model families include SEM/CFA, multilevel SEM, bifactor and higher-order models, latent class and profile models, IRT/MIRT, Rasch, OpenMx RAM models, and Mplus-style parameter output.

Design target

The package is meant to cover the everyday strengths of common SEM drawing tools while reducing the amount of manual cleanup needed for publication:

lvmPlot adds a common LVM graph grammar, TikZ-first export, publication and presentation themes, orientation-aware label placement, multilevel layer bands, and adapters for SEM, multilevel SEM, LCA/LPA, IRT/MIRT, Rasch, OpenMx, and Mplus-style parameter tables.

Automatic diagrams prioritize a clean publication view. When a model contains geometry that cannot be shown well as a straight-edge path diagram, such as a 60-item single-factor battery, dense latent structural regressions, dense LCA probability matrices, or a covariance edge parallel to a directed path, diagram = "auto" summarizes the display. Use diagram = "all" when you need the complete parameter graph for audit or manual editing.

Install locally

From the package directory:

install.packages("devtools")
devtools::install()

or without devtools:

R CMD INSTALL .

Quick start: lavaan SEM

library(lavaan)
library(lvmPlot)

model <- '
  visual  =~ x1 + x2 + x3
  textual =~ x4 + x5 + x6
  speed   =~ x7 + x8 + x9
  textual ~ visual
  speed   ~ visual + textual
'

fit <- sem(model, data = HolzingerSwineford1939)

plot_lvm(fit, label = "std")
save_lvm_svg(fit, "holzinger-swineford.svg", width = 9.2, height = 5.4)
write_lvm_tikz(fit, "holzinger-swineford.tex", label = "std")

The older SEM-specific helpers still work:

plot_sem(fit, label = "std")
write_sem_tikz(fit, "holzinger-swineford.tex")

RStudio preview and vector export

For day-to-day work in RStudio, draw directly into the Plots pane:

plot_lvm(fit, label = "std")

In RStudio, the package also installs Addins. Select a model object name or an expression in the editor, then use Preview lvmPlot Diagram to draw it in the Plots pane or Export lvmPlot TikZ to write a .tex file.

Export the same diagram as vector artwork:

save_lvm_svg(fit, "holzinger-swineford.svg")
save_lvm_pdf(fit, "holzinger-swineford.pdf")
save_lvm_png(fit, "holzinger-swineford.png", res = 300)

The LVM save helpers use width = "auto" and height = "auto" by default. lvm_canvas_size() gives the recommended inches before export, and explicit numeric width/height values still override it.

When an automatic layout needs final human judgment, open the browser editor, drag nodes and coefficient labels into place, and download the final figure or reusable layout:

lvmPlot_editor(fit, label = "std", theme = "journal")

The editor supports click/shift-click node selection, selected-node label editing, grid snapping, arrow-key nudging, locked nodes, node dragging, draggable coefficient labels, undo/redo, multi-node alignment, horizontal or vertical distribution, smart polishing for selected nodes, one-click layout repair, X/Y compact/expand controls, and live preview updates when edge labels, themes, style presets, colors, font sizes, node sizes, and line widths change. Double-click a coefficient label to return it to automatic placement. Style edits are exported through the same lvm_style() system used by plot_lvm() and the save helpers, so the browser preview is not a separate cosmetic layer. It is meant for the final publishing pass after the automatic layout has done the heavy structural work.

For reproducible editing sessions, download State JSON to save the full editor state, including edited node labels, manual coefficient-label positions, and locked/selected nodes, and load it later to continue editing. Download Figure R for a script that reconstructs the final layout, node labels, style, theme, edge labels, edge-label positions, and export calls inside an analysis project.

The editor keeps publication export inside lvmPlot: SVG/PDF/PNG downloads use the same renderer as save_lvm_svg(), save_lvm_pdf(), and save_lvm_png(), and layout downloads can be reused with plot_lvm(object, layout = layout).

If a TeX engine is installed, compile the file:

write_lvm_tikz(fit, "holzinger-swineford.tex", compile = TRUE)

Publication bundle

For manuscript workflows, export_lvm_bundle() writes the whole figure artifact set in one call: PDF, PNG, SVG, standalone TikZ, node and edge tables, diagnostics, a quality score, session metadata, and a Markdown report.

bundle <- export_lvm_bundle(
  fit,
  dir = "figures/holzinger-swineford",
  name = "figure-cfa",
  label = "std",
  theme = "journal",
  check = TRUE,
  optimize = TRUE,
  optimize_orientation = c("top-down", "left-right")
)

bundle

This makes the diagram auditable: the exported folder contains both the figure and the data/diagnostics needed to reproduce or review it. When optimize = TRUE, the bundle also contains layout-selection.csv, which records each candidate orientation/layout/routing combination and the selected quality score.

You can run the same selection step without exporting files:

selection <- select_lvm_layout(
  fit,
  orientation = c("top-down", "left-right", "bottom-up", "right-left"),
  label = "std"
)

selection
plot_lvm(selection$graph, label = "std")

Other latent variable models

All adapters return the same lvm_graph object:

graph <- as_lvm_graph(fit)
plot_lvm(graph)

Supported adapters include:

You can also build diagrams directly:

nodes <- data.frame(
  name = c("Theta", "i1", "i2", "i3"),
  role = c("trait", "item", "item", "item"),
  type = c("latent", "observed", "observed", "observed")
)

edges <- data.frame(
  from = "Theta",
  to = c("i1", "i2", "i3"),
  type = "loading",
  edge_label = c("a=.80", "a=1.10", "a=.65")
)

plot_lvm(lvm_graph(nodes, edges, model_type = "irt", layout_family = "irt"))

User choices

The main plotting functions expose the same grammar-level choices:

plot_lvm(
  fit,
  layout_family = "sem",          # sem, bifactor, irt, mixture, growth, multilevel, circle
  orientation = "left-right",     # top-down, bottom-up, left-right, right-left
  diagram = "measurement",        # all, measurement, structural, paths, covariances
  theme = "classic",              # see lvm_themes()
  label = "std",
  aspect = "balanced",            # balanced, preserve, fill
  min_abs = .10,
  significant = TRUE
)

Built-in themes are:

lvm_themes()

The defaults remain conservative (journal), with extra presets for minimal, classic, apa, nature, colorblind, poster, compact, slides, and blueprint.

The default aspect = "balanced" keeps diagrams from looking oddly stretched in wide RStudio plot panes or exported PNG/SVG/PDF files. Use aspect = "preserve" when equal x/y coordinate units matter, or aspect = "fill" when you explicitly want the older full-panel stretch. For dense diagrams, the default themes also make small automatic typography and line-width adjustments so labels remain legible; explicit lvm_style() choices always take precedence. The default label = "auto" keeps the diagram itself clean by hiding automatically estimated parameters while preserving explicit custom edge labels on ordinary diagrams. Use label = "std", label = "est", or label = "both" when you want coefficients printed on the paths, and label = "none" to silence all edge labels including custom edge_label values.

Style overrides cover the usual publication tweaks:

plot_lvm(
  fit,
  label = "std",
  style = lvm_style(
    scale = 1.08,          # whole figure polish
    font_scale = 0.95,     # keep labels modest after enlarging nodes/lines
    node_font_size = 12,
    edge_font_size = 9,
    font_family = "Helvetica",
    latent_size = 17,
    observed_width = 22,
    observed_height = 10,
    node_line_width = 1.1,
    edge_line_width = 1.0,
    node_fill = "#F8FAFC",
    edge_color = "#334155",
    label_fill = "#FFFFFF"
  )
)

For publication cleanup, use a layout matrix and local node/edge styling. The same controls work in the RStudio Plots pane and in TikZ output:

params <- data.frame(
  lhs = c("f", "f", "y"),
  op = c("=~", "=~", "~"),
  rhs = c("x1", "x2", "f"),
  est = c(1, .8, .4),
  std.all = c(.7, .6, .35)
)

layout <- matrix(
  c(NA, "y", NA,
    NA, "f", NA,
    "x1", ".", "x2"),
  nrow = 3,
  byrow = TRUE
)

plot_lvm(
  params,
  layout = layout,
  label = "std",
  node_style = data.frame(
    name = c("f", "y"),
    label = c("Factor", "Outcome"),
    shape = c("diamond", "rounded"),
    fill = c("#EEF2FF", "#ECFDF5"),
    color = c("#3730A3", "#047857"),
    font_size = c(12, 10)
  ),
  edge_style = data.frame(
    from = c("f", "f"),
    to = c("x2", "y"),
    label = c("lambda2", "beta"),
    color = c("#B91C1C", "#0F766E"),
    line_width = c(1.6, 1.4),
    linetype = c("dashed", "solid"),
    curvature = c(.20, -.18),
    label_size = c(8, 9),
    label_fill = c("#FFF7ED", "#F0FDFA")
  )
)

Straight routing is the default, so model edges follow conventional path-diagram geometry. layout_diagnostics() reports node overlaps, edge/node collisions, and crossings before export. It can also estimate edge label boxes so long labels such as .46*** are checked separately from node layout:

diagnostics <- layout_diagnostics(as_lvm_graph(params))
diagnostics

layout_diagnostics(as_lvm_graph(params), label = "std", stars = "always")
layout_quality(as_lvm_graph(params), label = "std")
check_lvm_layout(params, layout = layout, label = "std")

plot_lvm(params, routing = "smart")
lvm_tikz(params, routing = "smart")

check_lvm_layout() is assertion-style: by default it errors if the diagram is not ready, if the score is below 92, or if node/edge/label collisions remain. Use action = "none" to return the quality object without stopping, or minimum_status = "review" when a script should permit human-review cases.

If a user explicitly wants automatic curved avoidance, routing = "smart" is still available.

For dense LCA/LPA probability matrices, diagram = "auto" uses a compact representative view so the default plot stays readable. Use diagram = "all" when you explicitly want every class-by-item probability edge.

For dense IRT/MIRT item axes, automatic diagrams use representative item blocks and compact display labels such as i12 when long common prefixes would make nodes collide. The original variable names remain in the graph tables, and node_labels can override the display labels when needed.

Default automatic layouts keep ordinary CFA and IRT indicators aligned on clean semantic layers. When a strict row would force straight edges through nearby nodes, dense bifactor, growth, LCA/LPA, and MIRT diagrams use shallow path-diagram layers or arcs to preserve straight edges without node collisions.

Custom layouts can also use a data frame with name, x, and y:

layout <- data.frame(name = c("F", "x1", "x2"), x = c(0, -1, 1), y = c(0, -2, -2))
plot_lvm(graph, layout = layout)

Parameter-table input

lvmPlot also works with a plain lavaan-style table. This keeps it useful in scripts where you want to cache model output first.

params <- data.frame(
  lhs = c("visual", "visual", "visual", "textual", "textual", "textual",
          "textual", "speed", "speed", "speed", "speed"),
  op = c("=~", "=~", "=~", "=~", "=~", "=~", "~", "=~", "=~", "=~", "~"),
  rhs = c("x1", "x2", "x3", "x4", "x5", "x6", "visual",
          "x7", "x8", "x9", "textual"),
  est = c(1, 0.55, 0.73, 1, 1.11, 0.93, 0.41, 1, 1.18, 1.08, 0.36),
  std.all = c(0.77, 0.42, 0.58, 0.85, 0.81, 0.74, 0.39,
              0.62, 0.71, 0.66, 0.34),
  pvalue = c(NA, .001, .001, NA, .001, .001, .002, NA, .001, .001, .004)
)

plot_lvm(params)
lvm_tikz(params, standalone = FALSE)

Multilevel SEM

For lavaan multilevel models, lvmPlot keeps same-named variables separate across levels and draws light Within/Between bands:

model <- '
level: 1
  fw =~ y1 + y2 + y3
  out ~ fw
level: 2
  fb =~ y1 + y2 + y3
  out ~ fb + z
'

fit <- sem(model, data = dat, cluster = "school")
plot_lvm(fit, label = "std")
save_lvm_svg(fit, "multilevel-sem.svg", width = 9.4, height = 6.6)

The same works from cached parameter tables containing a level column.

Custom layouts

The automatic layout is intentionally simple and SEM-shaped: latent variables and structural variables sit on a main row, while indicators are placed below their latent factor. For full control, pass coordinates:

layout <- data.frame(
  name = c("visual", "textual", "speed", "x1", "x2", "x3",
           "x4", "x5", "x6", "x7", "x8", "x9"),
  x = c(-3, 0, 3, -4, -3, -2, -1, 0, 1, 2, 3, 4),
  y = c(0, 0, 0, -2, -2, -2, -2, -2, -2, -2, -2, -2)
)

sem_tikz(params, layout = layout)

Or use the compact matrix form:

sem_tikz(
  params,
  layout = matrix(c("visual", "textual", "speed",
                    "x1", "x4", "x7",
                    "x2", "x5", "x8",
                    "x3", "x6", "x9"),
                  nrow = 4, byrow = TRUE)
)

Current scope

mirror server hosted at Truenetwork, Russian Federation.