panelforest: Build Forest Plots with a Declarative Pipeline

Why I Built panelforest

Forest plots are a staple of clinical research, meta-analyses, and NMA. R has no shortage of options — forestplot, forestploter, forester, meta::forest() — but after using them across several projects, I kept running into the same frustrations:

  • Most packages use a configuration-style API: one long function call with dozens of parameters, where adjusting layout means hunting through docs for the right argument name
  • The panel combinations I actually want — text on the left, CI in the middle, a bar chart on the right — are rarely built-in, so I end up hand-assembling things
  • Grouped rows, summary rows, stripes, dividers: every package handles these differently
  • Changing the color or shape of specific rows usually requires hacking the underlying data

So I wrote panelforest.

The core idea is simple: a forest plot is just several panels placed side by side, each with its own rendering logic. Instead of designing it as a “forest plot function,” let users declare which columns they want and what each one should look like — then let the engine handle the rest.

Project: https://github.com/lenardar/panelforest

What It Looks Like

Here’s the result:

This is a forest plot from an NMA safety analysis. It includes:

  • A text column on the left (subgroup labels)
  • A text column in the middle (OR values)
  • A CI panel on the right (log scale, reference line, truncation arrows, favors annotation)
  • Bold group rows, row dividers, striped background
  • Different colors and shapes per comparison

The code to produce this is about 30 lines, all pipeline composition.

How to Use It

Basic Structure

1
2
3
4
5
6
7
8
9
library(panelforest)

forest_plot(df) |> # pass in data
add_stripe(...) |> # striped background
add_group(...) |> # grouped rows
add_hline(...) |> # divider lines
add_text(...) |> # text column
add_ci(...) |> # CI panel
fp_render() # render

Each add_*() appends a panel column to the layout. Order determines left-to-right arrangement. To change the layout, move the calls around.

Panel Types

Currently built in:

  • add_text() / fp_text() — plain text column, supports alignment, indentation, format functions
  • add_text_ci() / fp_text_ci() — formats est/lower/upper into “0.45 (0.32, 0.61)”
  • add_ci() / fp_ci() — CI visualization: log scale, truncation arrows, diamond summaries, favors annotation
  • add_bar() / fp_bar() — horizontal bar chart
  • add_dot() / fp_dot() — dot + error bar
  • add_gap() / fp_gap() — fixed-width gap
  • add_spacer() / fp_spacer() — absolute-unit spacer (mm)
  • fp_custom() — custom panel: pass in a function that returns a ggplot

When the built-ins aren’t enough, fp_custom() combined with fp_register() lets you register a custom panel type that the engine will treat like a native one.

Column-Driven Aesthetic Mapping

Similar to ggplot2’s aes(), panelforest uses fp_aes() to map data columns to visual attributes:

1
2
3
4
5
6
# df has columns ci_colour and ci_shape
forest_plot(df) |>
add_ci("OR", "LCI", "UCI",
mapping = fp_aes(colour = "ci_colour", shape = "ci_shape")
) |>
fp_render()

This lets each row have a different color and shape without setting them row by row.

A Unified edit() Layer

Need special treatment for specific rows? One edit() function handles row-level, cell-level, and row-height edits:

1
2
3
4
5
6
7
8
9
10
forest_plot(df) |>
add_text("label", header = "Subgroup") |>
add_ci("HR", "LCI", "UCI", header = "HR") |>
# row 1: diamond glyph
edit(row = 1, panel = "HR", glyph = "diamond", fill = "#dbeafe") |>
# rows 2-4: italic
edit(row = 2:4, fontface = "italic") |>
# row 5: taller
edit(row = 5, height = 1.5) |>
fp_render()

The panel argument accepts an index, header string, or column name — no need to remember panel numbers.

Spanning Header Groups

Multiple panels can share a parent label with add_header_group(), and nesting levels are inferred automatically:

1
2
3
4
5
6
forest_plot(df) |>
add_text("label", header = "Drug A") |>
add_text("n_events", header = "Drug B") |>
add_ci("HR", "LCI", "UCI", header = "HR") |>
add_header_group("Treatment", panels = 1:2, border = TRUE) |>
fp_render()

Multi-level nesting is supported — a group that contains other groups is automatically promoted to a higher tier.

Design Decisions

Why ggplot2 + patchwork

Each panel is an independent ggplot object, assembled horizontally with patchwork. The benefits:

  • Inherits ggplot2’s rendering quality and theme system
  • Independent coordinate systems per panel — CI panels can use log scale while text panels use [0, 1], without interference
  • patchwork’s layout system natively supports mixing proportional and fixed widths
  • The output is a standard ggplot object, so ggsave() just works

The downside is performance — rendering slows when there are many panels or many rows. But forest plots are typically tens to a few hundred rows; at that scale it’s not an issue.

Why Not a ggplot2 Geom

The alternative would be a geom_forest() extension. I didn’t go that route because a forest plot is fundamentally “multiple heterogeneous column panels” — each column has completely different coordinate systems and rendering logic. Forcing that into ggplot2’s facet or annotation systems would be awkward.

patchwork’s multi-panel approach is more natural: each column is an independent ggplot that doesn’t interfere with the others, but they share a common row coordinate.

fp_size(): Let Content Determine Dimensions

Forest plot dimensions should be determined by content, not guessed by the user. fp_size() calculates exact inch dimensions from the number of panels, row count, and row heights:

1
2
3
size <- fp_size(plot_obj)
ggsave("forest.png", fp_render(plot_obj),
width = size["width"], height = size["height"])

No matter how many panel columns you add or how many rows your data has, the output will always have consistent density and proportions.

CI Panel Details

fp_ci() is the most feature-dense panel:

  • Log scale: trans = "log", automatically handles positive-value constraints and axis labels
  • Truncation arrows: when CIs extend beyond the display range, arrows indicate the truncation direction; arrow_type = "open" or "closed"
  • Diamond summaries: rows marked with add_summary() render as diamonds automatically
  • Favors annotation: favors_left / favors_right draw directional arrows and labels below the axis
  • Reference line: ref_line draws a dashed vertical line
  • Aesthetic mapping: per-row colors, shapes, and sizes via fp_aes()

Any of these alone is straightforward. Combined, they cover the vast majority of clinical forest plot requirements.

A Complete Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
library(panelforest)

df <- data.frame(
label = c("Overall", "Age < 65", "Age >= 65", "Male", "Female"),
HR = c(0.72, 0.68, 0.81, 0.75, 0.69),
LCI = c(0.58, 0.49, 0.61, 0.55, 0.48),
UCI = c(0.89, 0.94, 1.07, 1.02, 0.99)
)

plot_obj <- forest_plot(df) |>
add_stripe(c("white", "#f4f7f5")) |>
add_summary(1) |>
add_hline(1) |>
add_text("label", header = "Subgroup", width = 2, align = "left") |>
add_text_ci("HR", "LCI", "UCI", header = "HR (95% CI)", width = 2) |>
add_ci("HR", "LCI", "UCI",
header = "Hazard Ratio",
trans = "log",
width = 3,
show_axis = TRUE,
favors_left = "Favors treatment",
favors_right = "Favors control"
)

size <- fp_size(plot_obj)
ggsave("forest.png", fp_render(plot_obj),
width = size["width"], height = size["height"],
dpi = 300, bg = "white")

The structure is clear: data → decorations → panels → render. To change layout, reorder the add_*() calls. To change style, add edit().

Roadmap

panelforest is currently at v0.2.0, with core features stable. Planned work:

  • forest_plot_from() — model-to-forest-plot: pass in glm, coxph, lm, etc., and get a forest plot automatically. Powered by broom::tidy(), with automatic effect size and scale inference per model type. Planned support for lme4, metafor, brms.
  • add_rule() — conditional styling: declarative rules for batch highlighting, as an alternative to repeated edit() calls
  • More scales: sqrt, logit, etc.
  • Text wrapping, export helpers, footnote system, and other UX improvements

The model-direct direction is the most important — making panelforest not just a plotting tool, but something that connects directly to statistical model output, removing the “run the model, manually wrangle the data, then plot” cycle.

Project: https://github.com/lenardar/panelforest