S7 Class Architecture
Overview
tabviz uses S7, R’s modern object-oriented system, for its internal data structures. S7 provides:
- Type safety — Properties are validated at construction time
- Composition — Complex objects built from simpler components
- Method dispatch — Generic functions that work across types
- Immutability by default — Modifications create new objects
┌─────────────────────────────────────────────────────────────────┐
│ WebSpec │
│ The root specification object passed to tabviz() │
├─────────────────────────────────────────────────────────────────┤
│ data │ data.frame with point/interval values │
│ columns │ List<ColumnSpec | ColumnGroup> │
│ groups │ List<GroupSpec> │
│ theme │ WebTheme │
│ interaction │ InteractionSpec │
│ annotations │ List<Annotation> │
│ ... │ Row styling column mappings │
└─────────────────────────────────────────────────────────────────┘
│
├──────────────────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────────────────┐
│ WebTheme │ │ ColumnSpec/Group │
├─────────────────┤ ├─────────────────────────────┤
│ colors │ │ id, header, field, type │
│ typography │ │ width, align, position │
│ spacing │ │ style mappings │
│ shapes │ └─────────────────────────────┘
│ axis │
│ layout │
└─────────────────┘
Class Hierarchy
Core Classes (R/classes-core.R)
| Class | Purpose |
|---|---|
WebSpec |
Root specification containing all data and configuration |
GroupSpec |
Row grouping definition with optional nesting |
EffectSpec |
Multi-effect plot configuration (multiple intervals per row) |
GroupSummary |
Aggregate statistics for a group |
PlotLabels |
Title, subtitle, caption, footnote text |
Heterogeneity |
Meta-analysis heterogeneity statistics (optional) |
Theme Classes (R/classes-theme.R)
| Class | Purpose |
|---|---|
WebTheme |
Complete theme specification (composes all below) |
ColorPalette |
All color definitions (background, foreground, intervals, etc.) |
Typography |
Font family, sizes, weights |
Spacing |
Row height, padding, gaps |
Shapes |
Point size, line width, border radius |
AxisConfig |
Axis range, ticks, gridlines |
LayoutConfig |
Plot position, cell padding, borders |
Component Classes (R/classes-components.R)
| Class | Purpose |
|---|---|
ColumnSpec |
Single column definition |
ColumnGroup |
Hierarchical column header grouping |
InteractionSpec |
UI interaction toggles (sort, collapse, select, etc.) |
Annotation Classes (R/classes-annotations.R)
| Class | Purpose |
|---|---|
ReferenceLine |
Vertical reference line at specific x value |
CustomAnnotation |
Shape annotation on specific rows |
RiskOfBias |
Cochrane-style risk of bias traffic lights |
Design Patterns
1. Class + Helper Function Pattern
Every S7 class has a corresponding helper function with a user-friendly API:
# S7 class (internal)
GroupSpec <- new_class("GroupSpec", properties = list(
id = class_character,
label = class_character,
collapsed = new_property(class_logical, default = FALSE),
parent_id = new_property(class_character, default = NA_character_)
))
# Helper function (user-facing)
web_group <- function(id, label = id, parent = NULL, collapsed = FALSE) {
GroupSpec(
id = as.character(id),
label = as.character(label),
collapsed = collapsed,
parent_id = if (is.null(parent)) NA_character_ else as.character(parent)
)
}This pattern provides:
- Type coercion — Helper handles
NULL→NAconversion - Defaults — Sensible defaults without cluttering class definition
- Documentation — roxygen docs on the helper, not the class
- Validation — Additional business logic before construction
2. Composition Over Inheritance
WebTheme composes six sub-classes rather than inheriting or flattening:
WebTheme <- new_class("WebTheme", properties = list(
name = new_property(class_character, default = "default"),
colors = new_property(ColorPalette, default = ColorPalette()),
typography = new_property(Typography, default = Typography()),
spacing = new_property(Spacing, default = Spacing()),
shapes = new_property(Shapes, default = Shapes()),
axis = new_property(AxisConfig, default = AxisConfig()),
layout = new_property(LayoutConfig, default = LayoutConfig())
))Benefits:
- Targeted modification — Change only what you need:
theme@colors@primary - Reusable components —
ColorPalettecould be used elsewhere - Cleaner API — 6 grouped settings vs 30+ flat properties
3. Fluent Modifiers
Theme modification uses pipe-friendly functions that return modified copies:
web_theme_jama() |>
set_colors(primary = "#0066cc", border = "#999999") |>
set_spacing(row_height = 24) |>
set_axis(gridlines = TRUE)Implementation pattern:
set_colors <- function(theme, ...) {
stopifnot(S7_inherits(theme, WebTheme))
args <- list(...)
current <- theme@colors
for (prop in names(args)) {
if (prop %in% S7::prop_names(current)) {
S7::prop(current, prop) <- args[[prop]]
} else {
cli_warn("Unknown color property: {.field {prop}}")
}
}
theme@colors <- current
theme
}4. NA vs NULL Convention
The package follows a consistent pattern for optional values:
| Type | Default | Meaning |
|---|---|---|
| Scalar optional (string, number) | NA_character_ / NA_real_ |
“Not specified” |
| Complex object optional | NULL |
“Not present” |
Examples:
# Scalar optional — use NA
label_col = new_property(class_character, default = NA_character_)
weight_col = new_property(class_character, default = NA_character_)
# Complex object optional — use NULL
overall_summary = new_property(
new_union(GroupSummary, class_missing),
default = NULL
)
theme = new_property(class_any, default = NULL)This is checked during serialization:
# NA → NULL for JSON
label = if (is.na(spec@label_col)) NULL else spec@label_col5. Validators for Business Logic
S7 validators enforce invariants that can’t be expressed through types alone:
WebSpec <- new_class("WebSpec",
properties = list(...),
validator = function(self) {
# Required columns must exist
cols <- names(self@data)
if (!self@point_col %in% cols) {
return(paste0("Column '", self@point_col, "' not found in data"))
}
# Interval constraints
if (any(self@data[[self@lower_col]] > self@data[[self@upper_col]], na.rm = TRUE)) {
return("lower values must be <= upper values")
}
# Log scale requires positive values
if (self@scale == "log") {
if (any(self@data[[self@point_col]] <= 0, na.rm = TRUE)) {
return("All values must be positive for log scale")
}
}
NULL # Return NULL if valid
}
)WebSpec Deep Dive
WebSpec is the central data structure. Understanding it is key to understanding the package.
Core Data Properties
WebSpec <- new_class("WebSpec", properties = list(
# Source data
data = class_data.frame,
# Column mappings (required)
point_col = class_character, # "hr", "effect", etc.
lower_col = class_character, # "lo", "ci_low", etc.
upper_col = class_character, # "hi", "ci_high", etc.
# Column mappings (optional)
label_col = new_property(class_character, default = NA_character_),
group_col = new_property(class_character, default = NA_character_),
weight_col = new_property(class_character, default = NA_character_),
# Scale configuration
scale = new_property(class_character, default = "linear"), # or "log"
null_value = new_property(class_numeric, default = 0), # 0 for linear, 1 for log
axis_label = new_property(class_character, default = "Estimate"),
...
))Row Styling Column Mappings
Row-level styling is controlled via column references, not inline values:
# Instead of storing style values directly:
# row_bold = c(TRUE, FALSE, TRUE) ← NOT this
# We store the column name containing the values:
row_bold_col = new_property(class_character, default = NA_character_)
row_italic_col = new_property(class_character, default = NA_character_)
row_color_col = new_property(class_character, default = NA_character_)
row_bg_col = new_property(class_character, default = NA_character_)
row_badge_col = new_property(class_character, default = NA_character_)
row_icon_col = new_property(class_character, default = NA_character_)
row_indent_col = new_property(class_character, default = NA_character_)
row_type_col = new_property(class_character, default = NA_character_)
weight_col = new_property(class_character, default = NA_character_)Usage in web_spec():
web_spec(data, point = "hr", lower = "lo", upper = "hi",
row_bold = "is_summary", # Column name, not values
row_badge = "significance", # Column name
weight = "study_weight") # Column nameThis pattern:
- Keeps data and configuration separate
- Allows dynamic styling from data columns
- Avoids duplicating data in the spec
Hierarchical Groups
Groups support nesting via parent_id:
GroupSpec(id = "north_america", label = "North America", parent_id = NA_character_)
GroupSpec(id = "usa", label = "United States", parent_id = "north_america")
GroupSpec(id = "canada", label = "Canada", parent_id = "north_america")The group_cols property tracks hierarchy for composite ID generation:
# For data grouped by region > country
group_cols = c("region", "country") # Outer to inner
group_col = "country" # Deepest level for row assignmentColumnSpec Design
Columns support multiple types with type-specific options:
ColumnSpec <- new_class("ColumnSpec", properties = list(
id = class_character,
header = class_character,
field = class_character,
type = new_property(class_character, default = "text"),
width = new_property(class_any, default = NA_real_), # numeric or "auto"
align = new_property(class_character, default = "left"),
options = new_property(class_list, default = list()),
# Per-cell style mappings
style_bold = new_property(class_any, default = NULL),
style_italic = new_property(class_any, default = NULL),
style_color = new_property(class_any, default = NULL),
style_bg = new_property(class_any, default = NULL),
style_badge = new_property(class_any, default = NULL),
style_icon = new_property(class_any, default = NULL)
))Type-Specific Options
The options property holds type-specific configuration:
# Bar column
col_bar("weight", max_value = 100, show_label = TRUE, color = "#3b82f6")
# Creates: options = list(bar = list(maxValue = 100, showLabel = TRUE, color = "#3b82f6"))
# P-value column
col_pvalue("pval", stars = TRUE, thresholds = c(0.05, 0.01, 0.001))
# Creates: options = list(pvalue = list(stars = TRUE, thresholds = c(0.05, 0.01, 0.001)))
# Sparkline column
col_sparkline("trend", type = "area", height = 24)
# Creates: options = list(sparkline = list(type = "area", height = 24))ColumnGroup for Hierarchical Headers
ColumnGroup <- new_class("ColumnGroup", properties = list(
id = class_character,
header = class_character,
columns = new_property(class_list, default = list())
))Usage:
col_group("Results",
col_interval("OR (95% CI)", point = "or", lower = "lo", upper = "hi"),
col_pvalue("pvalue", "P")
)Serialization to JavaScript
S7 objects are converted to JSON for the JavaScript frontend via serialize_spec():
R (S7 objects) JSON TypeScript
──────────────────────────────────────────────────────────────────────
WebSpec → { data: {...}, → WebSpec interface
@data columns: [...],
@columns theme: {...},
@theme ... }
...
WebTheme → { name: "...", → WebTheme interface
@colors colors: {...},
@typography typography: {...},
... ... }
Key transformations:
| R Pattern | JSON Pattern |
|---|---|
snake_case property |
camelCase key |
NA_character_ |
null |
NA_real_ |
null |
| S7 nested object | Nested JSON object |
class_list of S7 |
Array of JSON objects |
Example serialization:
serialize_theme <- function(theme) {
list(
name = theme@name,
colors = list(
background = theme@colors@background,
foreground = theme@colors@foreground,
# snake_case → camelCase
intervalPositive = theme@colors@interval_positive,
intervalNegative = theme@colors@interval_negative,
...
),
...
)
}Type Checking Patterns
S7_inherits for Runtime Checks
# In modifiers.R
set_row_style <- function(x, ...) {
spec <- if (inherits(x, "htmlwidget")) {
attr(x, "webspec")
} else if (S7_inherits(x, WebSpec)) {
x
} else {
cli_abort("x must be a WebSpec or htmlwidget")
}
...
}
# In columns.R
col_group <- function(header, ...) {
columns <- list(...)
for (i in seq_along(columns)) {
if (!S7_inherits(columns[[i]], ColumnSpec)) {
cli_abort("All arguments must be ColumnSpec objects")
}
}
...
}class_any Escape Hatch
Some properties use class_any for flexibility:
# Allows NULL or WebTheme
theme = new_property(class_any, default = NULL)
# Allows numeric or "auto" string
width = new_property(class_any, default = NA_real_)
# Allows NULL or numeric vector
tick_values = new_property(class_any, default = NULL)A more type-safe approach would use unions:
theme = new_property(
new_union(WebTheme, class_missing),
default = NULL
)Method Dispatch
S7 methods are registered for generic functions:
# Print method for WebSpec
method(print, WebSpec) <- function(x, ...) {
cli_inform(c(
"A {.cls WebSpec} with {nrow(x@data)} row{?s}",
"*" = "Point: {.field {x@point_col}}",
"*" = "Interval: {.field {x@lower_col}} to {.field {x@upper_col}}",
"*" = "Scale: {.val {x@scale}} (null = {x@null_value})",
"*" = "Columns: {length(x@columns)}",
"*" = "Groups: {length(x@groups)}"
))
invisible(x)
}
# Plot method (renders as tabviz with forest column)
method(plot, WebSpec) <- function(x, ...) {
tabviz(x@data, columns = list(viz_forest(point = x@point_col, lower = x@lower_col, upper = x@upper_col)), ...)
}
# as.data.frame method
method(as.data.frame, WebSpec) <- function(x, ...) {
x@data
}Best Practices
Creating New Classes
- Start with the helper function API, then design the class to support it
- Use
NA_*for optional scalars,NULLfor optional objects - Add validators for invariants that can’t be expressed as types
- Document the helper, not the class directly
Modifying Existing Classes
- Add new properties with defaults to maintain backward compatibility
- Update serialization in
utils-serialize.R - Update TypeScript types in
srcjs/src/types/index.ts - Update fluent modifiers if the property should be user-configurable
Testing Classes
# Test construction
spec <- web_spec(df, point = "hr", lower = "lo", upper = "hi")
expect_s7_class(spec, WebSpec)
# Test validation
expect_error(
WebSpec(data = df, point_col = "nonexistent", ...),
"not found in data"
)
# Test serialization round-trip
json <- serialize_spec(spec)
expect_equal(json$data$pointCol, "hr")