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.
If you are working with an AI coding agent, the package ships an Agent Skill covering exactly this
checklist - vizmodules-new-module - along
with the required roxygen sections, the uniform input helpers, the traps
that have cost real time here, and file templates to copy. Install it
(and its two siblings) into your project with
VizModules::use_vizmodules_skills("."), or pass
client = "copilot" / client = "claude" to
write to .github/skills/ or .claude/skills/
instead of the default .agents/skills/. The skills live in
inst/skills/ in this repository, so keep them in
step with this vignette when you change a convention it
documents.
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, defaults = NULL, hide.inputs = NULL, hide.tabs = NULL) {
# With no data, open on the module's showcase example (its entry in
# .module_showcase()), layering any caller defaults over its own.
if (is.null(data_list)) {
example <- .module_example("myplot")
data_list <- example$data_list
defaults <- utils::modifyList(example$defaults, defaults %||% list())
}
createModuleApp(
inputs_ui_fn = myPlotInputsUI,
output_ui_fn = myPlotOutputUI,
server_fn = myPlotServer,
data_list = data_list,
defaults = defaults,
hide.inputs = hide.inputs,
hide.tabs = hide.tabs,
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.
The re-render problems above come from the server pushing
values at the client. Free text has the opposite problem:
textInput() and textAreaInput() report to the
server on every keystroke, so a plot that reads one
directly is rebuilt once per character.
That matters most when the input is an expression, because the
intermediate states are not just wasted work, they are mostly
invalid work. Typing condition == "Disease" walks
through c, co, con, … and every
one of those is a complete render cycle spent on an expression that
cannot parse.
Wrap the read in shiny::debounce() so a burst of typing
collapses into one render once the user pauses:
# Once, in the module server body -- not inside a reactive.
filter_text <- debounce(reactive(input$row_filter), 700)
filtered <- reactive({
keep <- safe_eval_filter(filter_text(), data())
if (is.null(keep)) data() else data()[keep, , drop = FALSE]
})debounce() emits its initial value immediately and then
holds the previous value while typing, so startup is unaffected and
there is no blank first render to design around.
ComplexHeatmap_HeatmapServer()’s Row/Column Filter
inputs are the worked example — a heatmap is drawn to a device and
measured before it can be shown, so a per-keystroke rebuild is
especially painful there.
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,
dittoViz_yPlotServer, or
dittoViz_freqPlotServer 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 visibility, font family and colour, and 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().Every stylesheet a module loads is injected into the host app’s document. There is no shadow DOM and no automatic scoping: a rule you write for your widget applies to the whole page. Because each plot module pulls in a colour picker, attaching any module to a page brings the package’s stylesheets with it — so a careless selector silently restyles an app that merely embedded one plot.
This has bitten the package three times (#355), and every case was the same mistake: styling a class the package does not own.
Anchor every selector on a package-owned prefix —
.multi-color-picker, .mc-, .mdi-,
.vizmodules-, .viz-, .pb-. Never
write a bare rule against a class that belongs to Bootstrap, Shiny,
selectize, DT or any other library:
/* WRONG — restyles every dropdown, well and tab strip in the host app */
.selectize-dropdown .option { padding: 0 !important; }
.well .btn { padding: 4px 10px; }
.nav-tabs { flex-wrap: wrap; }
/* RIGHT — reaches only markup this package rendered */
.mc-palette-dropdown .option { padding: 0 !important; }
.pb-app .well .btn { padding: 4px 10px; }
.vizmodules-input-tabs > .nav-tabs { flex-wrap: wrap; }The trap is that these rules look scoped.
.well .btn reads like “the buttons in my well” — but
.well is what shiny::sidebarPanel() renders,
so it is every sidebar on the page. If a selector’s leftmost class is
not one you invented, it is not scoped.
tests/testthat/test-ui_utils.R enforces this: it parses
every selector in every bundled and inline stylesheet and fails on
anything not anchored to a package prefix. Add your widget’s prefix
there if you introduce one.
<body> needs
its own marker classAnything that escapes its container to avoid clipping — selectize’s
dropdownParent: "body", a tooltip, a popper — can no longer
be reached by a .my-widget .thing selector, which is
exactly why the leaking rules were written unscoped in the first place.
Give the escaped element a class of its own instead:
.selectize-dropdown .option never matched the colour
picker’s own options at all — its custom render.option
emits .mc-palette-option, and selectize does not add
.option to that. The rule’s only effect was on
other people’s dropdowns: a selector that misses everything you own and
hits everything you don’t. Open the widget, inspect the element, and
confirm the class you are targeting is actually on it.
An inline style= attribute can only be overridden with
!important, which makes a host app fight your widget rather
than theme it. Put layout in a stylesheet and pass only the values that
vary per instance, as CSS custom properties:
The control grid used Bootstrap’s negative-margin row idiom
(margin-left: -15px on the row, matching padding on each
cell), which assumes a parent with at least that much horizontal padding
to absorb it. Dropped into a bslib::sidebar() with less,
the grid overhung on both sides and the sidebar grew a horizontal
scrollbar. Use gap instead — it needs nothing from the
parent.
Give flex children min-width: 0 as well. Without it a
long select or label refuses to shrink below its intrinsic width and
pushes the container wider — the same overflow by a different route.
Put it in inst/src/, serve it through an
htmlDependency(), and attach it to the UI that needs it
rather than to the app as a whole:
.viz_modules_dependency <- function() {
htmlDependency(
name = "viz-modules",
version = as.character(utils::packageVersion("VizModules")),
src = "src", package = "VizModules",
stylesheet = "vizModules.css"
)
}Attaching it with
htmltools::attachDependencies(ui, dep, append = TRUE) means
the styles travel with the markup — including through a runtime
insertUI(), which the Figure Builder relies on when it
injects a panel’s controls.
Automated checks catch an unscoped selector, but not whether
your rule actually changed anything. To see the real effect, load the
widget beside a stock selectInput(),
sidebarPanel() and DT::datatable(), then
disable just your stylesheet in the browser and re-measure:
document.querySelectorAll('link[rel=stylesheet]').forEach(function (l) {
if (l.href.indexOf('yourFile.css') !== -1) { l.disabled = true; }
});If a computed style on the host’s own controls changes, your CSS is leaking. If nothing in your widget changes, your rule was never matching in the first place.
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.
safe_eval_filter() and
validate_expression() share one whitelist of safe AST
nodes:
<, >,
<=, >=, ==,
!=&,
&&, |, ||,
!, xor()%in%, c(),
is.na(), is.null()-, +,
*, /, :, %%,
abs(), round()grepl(),
startsWith(), endsWith(),
substr(), nchar(), toupper(),
tolower(), trimws()()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. So is
a namespaced or extracted call such as base::log(x), which
would otherwise slip past a name-based check.
The expression must also be a single statement:
anything after a ; or a newline is rejected rather than
silently ignored.
safe_resolve_adj_fxn() is the exception — it does not
walk an AST at all. It matches the whole input against its own short
list of numeric transforms (log2, log,
log10, neg_log10, log1p,
as.factor, abs, sqrt) and
resolves it with match.fun().
Model formulas (.safe_build_model(), behind the custom
fit lines) reuse the same walker with a formula-specific vocabulary:
~, +, -, *,
/, ^, (, :,
I(), and the transforms log(),
log2(), log10(), sqrt(),
exp(), poly().