S7 Class Architecture

Internal class system design using R’s S7 object-oriented framework

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 NULLNA conversion
  • 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 componentsColorPalette could 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_col

5. 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 name

This 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 assignment

ColumnSpec 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

  1. Start with the helper function API, then design the class to support it
  2. Use NA_* for optional scalars, NULL for optional objects
  3. Add validators for invariants that can’t be expressed as types
  4. Document the helper, not the class directly

Modifying Existing Classes

  1. Add new properties with defaults to maintain backward compatibility
  2. Update serialization in utils-serialize.R
  3. Update TypeScript types in srcjs/src/types/index.ts
  4. 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")