The dittoViz_scatterPlot module lets users overlay
fitted model lines on their data. Out of the box it supports
lm, glm, and loess. This vignette
explains how the system works, how to set defaults, and how to add
support for any modelling package (e.g. drc,
mgcv, brms).
The custom model line system has three layers:
multiDynamicInput widget where
users add rows (model type, formula, line color and width, plus any
backend-specific fields).User fills a row → input$custom.models → .safe_build_model() → backend$fit()
→ .compute_custom_model_fit() → backend$predict() → plotly::add_lines()
A formula validation (AST whitelist) runs before any model is fitted, blocking arbitrary code execution regardless of backend.
dittoViz_scatterPlotApp()).revenue ~ poly(units, 2)), pick line colour and
width. Write it as <y column> ~ <x column>: the
line is the formula’s prediction across the X axis’s range, drawn
against the Y axis, and any other column in the formula is held at its
median (or first level).The model is fit to the values as plotted. With an X or Y adjustment
on the Adjustments tab (a z-score, log10,
…), revenue ~ units models the adjusted revenue against the
adjusted units, so the line runs through the points you see. Don’t
repeat the adjustment inside the formula:
log10(revenue) ~ units on a log10 Y axis would
take the log twice. No model lines are drawn while
as.factor makes an axis categorical. The same goes for the
Linear model line and Line of best fit
toggles.
You can add multiple rows — each draws its own line with independent settings.
To pre-fill model rows on startup, pass them via the
defaults argument to the app or module:
library(VizModules)
dittoViz_scatterPlotApp(
data_list = list("sales" = example_sales),
defaults = list(
x.by = "units",
y.by = "revenue",
custom.model.enable = TRUE,
custom.models = list(
models1 = list(
model_type = "lm",
formula = "revenue ~ units",
line_colour = "#1F77B4",
line_width = 2
),
models2 = list(
model_type = "loess",
formula = "revenue ~ units",
line_colour = "#E63946",
line_width = 3
)
)
)
)These rows appear pre-filled when the app loads.
Unlike most defaults entries, custom.models
cannot be supplied as a reactive(). It is backed by a
compound multiDynamicInput() widget that the generic
control-sync used by reactive defaults cannot drive; pass a plain
list.
The built-in types (lm, glm,
loess) are registered automatically. To add support for
another modelling package, call register_model_backend()
before the app runs.
A backend is a named list with:
| Element | Type | Description |
|---|---|---|
fit |
function | function(formula, data, ...) — fits the model. Extra UI
fields arrive via .... |
predict |
function | function(model, newdata) — returns numeric y-values for
an x-grid. |
validate_classes |
character | Class(es) the fitted object must inherit (sanity check). |
fields |
list (optional) | Extra UI fields specific to this backend (appear when selected). |
library(VizModules)
library(drc)
register_model_backend("drm", list(
fit = function(formula, data, drc_fct = "LL.4", ...) {
fct_map <- list(
"LL.4" = drc::LL.4, "LL.3" = drc::LL.3, "LL.2" = drc::LL.2,
"W1.4" = drc::W1.4, "W2.4" = drc::W2.4
)
fct_fn <- fct_map[[drc_fct]]
if (is.null(fct_fn)) stop("Unknown drc family: ", drc_fct)
drc::drm(formula, data = data, fct = fct_fn())
},
predict = function(model, newdata) {
as.numeric(predict(model, newdata = newdata))
},
validate_classes = "drc",
fields = list(
drc_fct = list(
type = "select",
args = list(choices = c("LL.4", "LL.3", "LL.2", "W1.4", "W2.4"),
selected = "LL.4")
)
)
))
# Launch — "drm" now appears in the dropdown with a "Drc fct" selector
data(ryegrass, package = "drc")
dittoViz_scatterPlotApp(data_list = list("ryegrass" = ryegrass))When the user selects drm from the Model Type
dropdown, the Drc fct field appears. The selected value
(e.g. "LL.3") is forwarded to your fit
function as the drc_fct argument.
register_model_backend("gam", list(
fit = function(formula, data, ...) {
mgcv::gam(formula, data = data)
},
predict = function(model, newdata) {
as.numeric(predict(model, newdata = newdata))
},
validate_classes = "gam"
# No extra fields needed — formula handles everything (e.g. y ~ s(x))
))No fields needed here because GAM complexity is
expressed in the formula itself (s(), te(),
etc.). Note: you’d need to add "s", "te", etc.
to the formula whitelist, .formula_allowed_calls() in
R/parse_utils.R, for this to work.
| Scenario | Where to call register_model_backend() |
|---|---|
| Built into VizModules | Add to .register_builtin_backends() in
R/plot_fit_lines.R |
| Extension package | Your package’s .onLoad() in R/zzz.R |
| Standalone Shiny app | Before shinyApp() or in global.R |
Any field in the multiDynamicInput row that isn’t one of
the four standard keys (model_type, formula,
line_colour, line_width) is collected by the
server and forwarded as ... to the backend’s
fit function:
Row value: list(model_type="drm", formula="y~x", drc_fct="LL.3", line_colour="#000", line_width=2)
↓
Server extracts: extra_args = list(drc_fct = "LL.3")
↓
Calls: .safe_build_model("y~x", data, "drm", drc_fct = "LL.3")
↓
Backend receives: fit(formula, data, drc_fct = "LL.3")
| Function | Purpose |
|---|---|
register_model_backend(name, backend) |
Add a backend to the registry |
get_model_backend(name) |
Retrieve a backend spec |
list_model_backends() |
List all registered backend names |
build_model_row_spec() |
Build a merged row_spec from all backends (used internally by the scatter UI) |
The formula AST whitelist is enforced for all
backends equally. Only column names, literals, and a fixed set of
math/transform functions (log, sqrt,
poly, exp, etc.) are permitted in formulas.
The backend’s fit function only ever receives a validated
formula object — never raw text that could execute
arbitrary code.