Custom Model Lines in Scatter Plots

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).

How It Works

The custom model line system has three layers:

  1. Model backend registry — a named storage of “recipe cards” that tell the pipeline how to fit and predict for each model type.
  2. UI — a multiDynamicInput widget where users add rows (model type, formula, line color and width, plus any backend-specific fields).
  3. Server pipeline — validates the formula, fits via the backend, predicts over an x-grid, and draws the line on the plot.

The Pipeline

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.

Using Custom Models in the App

  1. Open a scatter plot app (e.g. dittoViz_scatterPlotApp()).
  2. In the Lines tab, toggle Custom Model Lines on.
  3. Click + Add to create a model row.
  4. Select a model type, enter a formula using your data’s column names (e.g. 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).
  5. The fitted line appears on the plot.

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.

Setting Defaults

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.

Adding a Custom Model Backend

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.

Backend Spec

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).

Example: drc (Dose-Response Curves)

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.

Example: mgcv (GAMs)

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.

Where to Register Backends

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

How Extra Fields Flow Through

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")

API Reference

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)

Security

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.