--- title: "Custom Model Lines in Scatter Plots" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Custom Model Lines in Scatter Plots} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = FALSE ) ``` The `dittoViz_scatterPlot` module lets users overlay fitted model lines on their data. Out of the box it supports `lm`, `glm`, `loess`, and `nls`. 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. 5. The fitted line appears on the plot. 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: ```{r} library(VizModules) dittoViz_scatterPlotApp( data_list = list("sales" = example_sales), defaults = list( x.by = "revenue", y.by = "units", 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. ## Adding a Custom Model Backend The built-in types (`lm`, `glm`, `loess`, `nls`) 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) ```{r} 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) ```{r} 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 in `.safe_build_model()` 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/model_backends.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.