This checklist walks through how to add a new plotting module to VizModules so it matches the package’s organization, documentation, and testing standards.
VizModules modules are designed to be a joy to use, which requires some discipline to implement in a consistent way. This checklist may seem daunting at first glance, but most of the items already have helpers to implement them.
And a fair few are to keep you from shooting yourself in the foot and avoiding most of the common module pitfalls.
dittoViz_<PlotName> (e.g.,
dittoViz_scatterPlot for
dittoViz::scatterPlot)plotthis_<PlotName> (e.g.,
plotthis_AreaPlot for plotthis::AreaPlot)linePlot, piePlot)@section Plot parameters not implemented or with altered functionality:
- List inputs not exposed and why@section Plot parameters and defaults: - Document all
exposed parameters with UI labels and defaults@section Plot parameters implementing new functionality:
- Document any new or plotly-specific controls (axes, ticks, reference
lines, etc)dittoViz_<PlotName>_module_ui.R (e.g.,
dittoViz_scatterPlot_module_ui.R)plotthis_<PlotName>_module_ui.R (e.g.,
plotthis_AreaPlot_module_ui.R)<plotName>_module_ui.R (e.g.,
linePlot_module_ui.R)plotthis_AreaPlotInputsUI(),
dittoViz_scatterPlotServer(),
linePlotApp()@section Plot parameters not implemented or with altered functionality:List all parameters from the base plot function that are not exposed via UI inputs, with explanations:
#' @section Plot parameters not implemented or with altered functionality:
#' The following [plotthis::AreaPlot()] parameters are not available via UI inputs:
#' \itemize{
#' \item \code{xlab} - X-axis label (plotly allows interactive editing)
#' \item \code{ylab} - Y-axis label (plotly allows interactive editing)
#' \item \code{title} - Plot title (plotly allows interactive editing)
#' \item \code{subtitle} - Plot subtitle (not supported in plotly)
#' \item \code{legend.position} - Legend positioning (plotly allows interactive repositioning)
#' \item \code{split_by} - Split variable (returns a patchwork object, not supported in plotly)
#' \item \code{palette} - Managed internally via the palette selection UI
#' }@section Plot parameters and defaults:Document all parameters that are exposed, listing their UI label and default value:
#' @section Plot parameters and defaults:
#' The following [plotthis::AreaPlot()] parameters can be accessed via UI inputs and/or the \code{defaults} argument:
#' \itemize{
#' \item \code{x} - X-axis variable (UI: "X values", default: 2nd categorical variable)
#' \item \code{y} - Y-axis variable (UI: "Y values", default: 2nd numeric variable)
#' \item \code{group_by} - Grouping variable (UI: "Group by", default: 3rd categorical variable or "")
#' \item \code{facet_by} - Faceting variable (UI: "Facet by", default: "")
#' \item \code{theme} - ggplot2 theme (UI: "Theme", default: "theme_this")
#' \item \code{alpha} - Area fill transparency (UI: "Alpha", default: 1)
#' }@section Plot parameters implementing new functionality:Document all module-specific parameters (plotly controls, reference lines, etc.):
#' The following parameters implementing new functionality or controlling plotly-specific features are also available:
#' \itemize{
#' \item \code{axis.font.size} - Axis title font size (UI: "Axis font size", default: 18)
#' \item \code{axis.showline} - Show axis border lines (UI: "Show axis lines", default: TRUE)
#' \item \code{axis.tickfont.size} - Size of tick labels (UI: "Tick label size", default: 12)
#' \item \code{hline.intercepts} - Y-coordinates for horizontal reference lines (UI: "Y-intercepts", default: "")
#' \item \code{hline.colors} - Colors for horizontal lines, comma-separated (UI: "Colors", default: "#000000")
#' \item \code{hline.linetypes} - Line types for horizontal lines, comma-separated (UI: "Line types", default: "dashed")
#' \item \code{vline.intercepts} - X-coordinates for vertical reference lines (UI: "X-intercepts", default: "")
#' \item \code{abline.slopes} - Slopes for diagonal reference lines (UI: "Slopes", default: "")
#' }Note: Reference line parameters
(hline.*, vline.*, abline.*)
accept comma-separated values to control each line individually.
myPlotApp <- function(data_list = NULL) {
if (is.null(data_list)) {
data_list <- list("example" = my_default_data)
}
createModuleApp(
inputs_ui_fn = myPlotInputsUI,
output_ui_fn = myPlotOutputUI,
server_fn = myPlotServer,
data_list = data_list,
title = "Modular myPlots"
)
}piePlot)An entry in defaults may be a reactive()
rather than a fixed value, so a parent app can make an input follow its
state without the double render that update*Input() causes
(see
vignette("defaults-and-hiding", package = "VizModules")).
Two lines wire this up, and they are required in every new module.
Easy. setup_reactive_defaults() returns
NULL when no entry is reactive, in which case
isolate_fn is the plain
identity()/isolate() it has always been. When
a store is present, isolate_fn recognises direct
input$<key> reads and resolves them from the store
instead, so your existing read sites need no edits, as long as they stay
in the isolate_fn(input$<key>) form. A wrapped read
such as isolate_fn(as.numeric(input$size)) cannot be
recognised and will not support a reactive default; do the conversion
outside the call instead.
Your *InputsUI() and reset observer need no special
handling: both go through get_default(), which resolves
reactive entries on its own. Reset therefore restores the reactive’s
current value.
Modules often derive a value on the server and push it back into one
of their own controls, e.g. an auto-calculated y-axis range, a
regenerated list of stat comparison pairs, a rebuilt colour picker.
update*Input() is an asynchronous round-trip to the
browser, so the plot renders twice: once immediately
with the stale value, then again when the client echoes the new one.
This is annoying as hell and can be avoided by freezing the input before
you update it:
Freezing pauses everything that reads that input until the real value arrives, so the intermediate render never happens. Three rules:
if branch
as the update*Input() call.tryCatch() to your
renderPlotly(). The pause is delivered as a silent
condition; swallowing it turns a clean pause into a blank plot. Guard
with req() and explicit if branches
instead.This does not apply to the reset observer, where a burst of updates is expected.
renderUI()Freezing does not cover an input your module
rebuilds with renderUI(), such as a [multiColorPicker()]
whose groups follow the data. A freeze pauses only the readers that run
after it in that flush, and at startup the plot output runs
first. The freeze lands too late to pause anything, and the value the
freshly built input reports then rebuilds the plot for a mapping it was
already drawing.
In short, this results in the plot being re-rendered one or more times in a way that can be annoying, particularly for large data sets.
Give the plot a server-side value to read instead, so the client’s
echo is compared against what is already in use rather than against
NULL. For a colour picker,
setup_group_colors() does this for you — it resolves the
mapping as soon as the group set is known and holds it in a
reactiveVal(), which only invalidates on a real change:
The same shape works for any renderUI()-rebuilt input:
resolve the value on the server, hold it in a
reactiveVal(), and have the plot read that.
Limits behave the same way, and for the same reason: the module
derives them on the server, pushes them into the
y.min/y.max controls, and the echo of that
push rebuilds the plot. setup_axis_range() is the store for
them.
y_range_store <- setup_axis_range(input, session, params = params)
observeEvent(input$y.data, {
y_range <- .calculate_range(df = data(), data_col_y = input$y.data,
axis_scale_factor = .y_axis_scale_factor)
if (!is.null(y_range)) {
y_range_store(list(min = y_range$min, max = y_range$max))
updateNumericInput(session, "y.min", value = y_range$min)
updateNumericInput(session, "y.max", value = y_range$max)
}
})If your module draws significance brackets, pass
headroom as well. The brackets are stacked above the data,
so the limits have to clear them or they are drawn clipped;
stat_bracket_y_max() works out how high they will reach,
and the store raises the maximum to meet it and updates the control to
match. It only ever raises, so a larger limit the user chose is left
alone:
y_range_store <- setup_axis_range(
input, session, params = params,
headroom = function() {
if (!isTRUE(input$stats.enabled)) {
return(NULL)
}
.stat_bracket_headroom(
df = data(), x = input$x.data, y = input$y.data,
group.by = .blank_to_null(input$group.by),
facet.by = .blank_to_null(input$facet.by),
per.facet = isTRUE(input$stat.per.facet),
input = input
)
}
)Pass the resolved limits on to apply_stat_annotations()
too, as y.min and y.max. It has the last word
on the drawn range, and knowing what you asked for is what lets it leave
a large maximum alone rather than shrinking the axis onto the
brackets.
Every VizModules plot is interactively editable: users can drag the legend, reposition or re-text annotations, drag the (draggable) axis titles to their heart’s content.
Because each module rebuilds its figure from scratch on every input change, those hand-made tweaks would be lost on the next re-render unless they are captured and re-applied.
Two exported helpers handle this for you. Use them in every new module so the behaviour is available from the start.
Pretty simple.
setup_manual_edits() registers the observers that
capture plotly_relayout events (legend, annotation, and
axis-title drags) plus the JavaScript-forwarded colorbar drag;
finalize_manual_edits() tags the figure with the event
source, restores any captured edits, records the figure for stable
annotation keying, and re-attaches the colorbar listener.
Edits are matched to annotations by a content-derived key, so they survive even when annotations are added, removed, or reordered between rebuilds (e.g. when statistical brackets or reference labels appear).
R/plot_helpers.R)| Function | Purpose |
|---|---|
setup_manual_edits() |
Create the edit store and register the relayout/colorbar capture observers (call once, near the top of the server) |
finalize_manual_edits() |
Tag the event source, restore captured edits, record the figure, and
attach the colorbar listener (call in renderPlotly(), just
before returning) |
Both functions are exported, so the same two-step pattern works from
a custom module in your own package. The supporting internals
(.capture_manual_edits(),
.reapply_manual_edits(),
.add_colorbar_listener()) are not exported and shouldn’t
need to be used directly (though you can always access them via
VizModules::: if really necessary.
See any module server (e.g. dittoViz_scatterPlotServer)
for a complete example.
Modules for categorical-vs-numeric plots (box, violin, etc.) can include an optional Stats tab that provides pairwise statistical testing with plotly bracket annotations. If your new module supports grouped comparisons along a categorical x-axis, follow this pattern:
R/stat_helper.R)| Function | Purpose |
|---|---|
compute_pairwise_stats() |
Run pairwise or omnibus tests with p-value adjustment |
create_stat_annotations() |
Convert stats to plotly shapes/annotations with bracket packing |
apply_stat_annotations() |
Append shapes/annotations to the plotly figure and adjust y-axes |
generate_pair_strings() |
Build "A vs B" strings for the comparison selector |
parse_pair_strings() |
Convert selected pair strings back to list of length-2 vectors |
See the plotthis_BoxPlotServer,
plotthis_ViolinPlotServer, or
dittoViz_yPlotServer implementations for complete
integration examples.
Following a consistent style makes the package easier to read, maintain, and extend. Apply these conventions to every new module.
"Group By", not "group by".tipify tooltip
instead."Color" is
better than "Select a Color".group_by input "Group By".Use viz_select_input() rather than
shiny::selectInput() or
shiny::selectizeInput(), and
update_viz_select() in place of their
update*() counterparts. It takes the same
inputId/label/choices/selected/multiple
arguments, but renders a virtualised dropdown, so an input backed by a
column with tens of thousands of distinct values stays usable. A search
box appears automatically once there are more than ten choices.
An empty-string choice still means “no selection”; it is displayed as
(none) so users can see and pick it.
tipifyWrap any non-obvious input in shinyBS::tipify() to show
a tooltip on hover. This keeps labels concise while still informing the
user.
Apply tipify when:
Standard pattern — always use placement = "top" and
options = list(container = "body") so tooltips render
correctly inside sidebar panels:
tipify(
textInput(ns("hline.intercepts"), "Y-intercepts",
placeholder = "e.g. 2, -2",
value = get_default(defaults, "hline.intercepts", "")
),
paste(
"For categorical or factor axes, enter the index (position) of the",
"category rather than its name."
),
placement = "top", options = list(container = "body")
)Inputs that are self-explanatory from their label (e.g.,
"Plot Title", "X-axis Variable") do not need a
tooltip.
In time, these helpers will be further formalized and exported, but
they can be used with the VizModules::: prefix in the
meantime.
Before writing custom inputs, check whether a uniform helper already covers your needs:
| Helper | Provides |
|---|---|
uniform_lines_inputs_ui() |
Horizontal, vertical, and diagonal reference line controls |
uniform_axes_inputs_ui() |
Font, axis border, gridline, tick, and facet styling |
.uniform_stats_inputs_ui() |
Pairwise statistical testing and bracket annotation controls |
uniform_plotly_inputs_ui() |
Download buttons, margins, subplot spacing, and draw-shape styling |
uniform_legend_inputs_ui() |
Legend title and entry label font sizes |
uniform_annotation_inputs_ui() |
Highlighting and labelling of individual data points |
Each UI helper has a matching reset_*_inputs() function
to call from the module’s observeEvent(input$reset, ...)
block.
Modules that draw individual points can adopt
uniform_annotation_inputs_ui() to let users highlight and
label points by the values of a chosen column. The server side needs the
chosen column carried in the plot’s hover text (that is where the values
are read back from), then .apply_highlight_styling() to
restyle matching markers and
.create_highlight_annotations()/.create_selected_annotations()
to build the labels. Set require.markers = TRUE when other
scatter traces are drawn from the same data (box or violin outlines,
say) so only the point markers are matched. Append the resulting
annotations to fig$x$layout$annotations rather than
replacing them, or facet strip labels and statistical brackets will be
lost.
Pass ns and a defaults list to each helper.
Use the include.* arguments to opt in to optional groups
(e.g., include.fit.lines = TRUE for scatter plots,
include.rotate = TRUE for bar plots).
Using the uniform helpers ensures that shared inputs behave identically across every module and that future changes to those helpers propagate automatically.
@importFrom Over ::@importFrom pkg fun in the
roxygen header of any file that calls an external function, then call
the function directly (fun()) in the body.pkg::fun() calls in module
code.The only exception is a one-off call in an @examples
block or vignette where the full qualified name aids readability.
.lintr).sapply() — use vapply() or
lapply() with explicit types instead.NAMESPACE manually; always regenerate with
devtools::document().Never use eval(str2expression()) or
eval(parse()) on raw user input. If a Shiny app is
deployed publicly, this allows arbitrary code execution on the server
(e.g., system("rm -rf /")). VizModules provides three
exported helper functions for safely handling user-typed expressions.
Use them whenever your module accepts free-text input that will be
evaluated or passed to a plotting function.
safe_eval_filter(expr_text, data)Use when a module evaluates a user-typed filter expression directly to produce a logical vector for row subsetting. The expression is parsed, its AST is walked to ensure only allowed operations are present (comparisons, logical operators, column references, and literals), and then it is evaluated in a restricted environment containing only the data frame’s columns.
# In a module server — filtering rows by a textInput:
rows.use = safe_eval_filter(isolate_fn(input$rows.use), data())Returns a logical vector (same length as nrow(data)), or
NULL if the input is empty, unparseable, or contains
disallowed operations.
validate_expression(expr_text, col_names)Use when a module passes a user-typed expression
string through to a downstream plotting function that will evaluate it
internally (e.g., plotthis::BoxPlot(highlight = ...)). The
string is validated but not executed.
# In a module server — passing a highlight expression to plotthis:
highlight <- validate_expression(isolate_fn(input$highlight), names(data()))Returns the original string if safe, or NULL.
safe_resolve_adj_fxn(fn_name)Use when a module resolves a function name from a dropdown or text
input into an actual function reference (e.g., for
x.adj.fxn, y.adj.fxn). Only function names in
the allowed list ("log2", "log",
"log10", "neg_log10", "log1p",
"as.factor", "abs", "sqrt") are
accepted.
# In a module server — resolving an adjustment function:
x.adj.fxn = safe_resolve_adj_fxn(isolate_fn(input$x.adj.fxn))Returns the function, or NULL if the name is empty or
not in the allowed list.
All three helpers share the same whitelist of safe AST nodes:
<, >,
<=, >=, ==,
!=&,
&&, |, ||,
!%in%, c(),
is.na(), is.null()-, +,
*, /, :, %%()TRUE,
FALSE, NA, NULL,
Inf, NaNAnything outside this list (including function calls like
system(), file.remove(),
library(), etc.) is rejected and a warning is issued.