Design · Requirement #9 · Project P005
Build the figures the open-source ecosystem is missing, borrow the table logic that already exists, and share the FDA's normative rules between the static and interactive renderers rather than duplicating them.
The FDA Standard Safety Tables and Figures: Integrated Guide v2.0 (April 2025, governed by MAPP 6025.9) specifies 60 tables and 22 figures across three delivery tiers. This requirement implements a deliberately scoped subset in R, as static displays that can be reviewed and generated alongside the safety.viz interactive graphics.
The full inventory — every display with its tier, section, chart engine, ADaM domains and the guide's cross-cutting rules — is published as fda_stf_inventory.json alongside the assessment. It is the specification input to this design.
Open-source coverage of the guide is sharply lopsided. pharmaverse cardinal has templates for 26 of 60 tables but only 4 of 22 figures. Fifteen figures have no static-R implementation in any open-source package, and twelve of those already exist as shipped safety.viz interactive renderers, collapsing into four reusable chart engines. The gap in the ecosystem and the assets already on our shelf are very nearly the same set — so the plan builds figures and borrows tables.
The framing question was whether to run parallel R and JS libraries or integrate them. Both pure options are rejected:
Instead, three layers, with the sharing in the middle. This mirrors gsm.kri, which pairs Visualize_Scatter (ggplot2) with Widget_ScatterPlot (htmlwidget) over one shared data interface.
| Layer | Language | Contents |
|---|---|---|
| L1 · Derivation | R shared | Appendix Tables 56–60 as package datasets, plus Derive_* functions applying the guide's normative rules: TEAE flagging, OCMQ narrow/broad assignment, abnormality grading, extreme-value exclusion, ULN multiples, DILI quadrant assignment, last-on-treatment windows, risk difference with CI, incidence rates per 100 person-years. |
| L2a · Static | R | Visualize_* returning ggplot objects; Table_* / Listing_* returning gt or gtsummary objects. Exports PNG, PDF, SVG. |
| L2b · Interactive | JS | Existing safety.viz Chart.js modules and Widget_* bindings, unchanged in kind, reading L1-enriched data. |
L1 emits enriched participant-level data — the input frame plus derived columns — not a pre-aggregated result set. safety.viz's value is client-side interactivity (brushing, filtering, drill-down), which needs participant-level data in the browser. Adding derived columns single-sources the FDA's rules while leaving the interactive renderers fully capable. Presentational shaping — binning, sorting, layout — stays in each renderer.
safety.viz currently derives flags in JS inside each module's structureData.js. Moving normative derivations upstream to R is a genuine change to its data contract, not a free alignment. Only rules the FDA specifies move to L1. Prove it on the DILI pair — hep-explorer already computes ULN multiples and quadrant membership client-side — before committing the other three engines.
All of it lands in gsm.safety, alongside the seven Widget_* bindings delivered by v1.0.0. This mirrors gsm.kri, which already carries ggplot2, gt, DT, htmlwidgets and rmarkdown in one package, and it is what makes "review the static and interactive versions side by side" possible in a single pkgdown gallery.
gsm.safety/
R/
Derive_*.R # L1 — FDA normative rules (shared)
Visualize_*.R # L2a — ggplot2 static figures
Table_*.R # L2a — gt / gtsummary tables
Listing_*.R # L2a — subject-level listings
Widget_*.R # L2b — existing htmlwidget bindings (v1.0.0)
data/
FDA_AbnormalityLevels # Appendix Tables 56–57
FDA_ExtremeValues # Appendix Tables 58–60
inst/workflow/3_reports/
fda_*.yaml # workr workflows, one per display
Naming follows the guide's own identifiers so traceability is mechanical: Visualize_HepatocellularDILI() implements Figure 7, Table_DILIQuadrants() implements Tables 21 and 22. Each carries the guide version it was written against.
The question raised was whether we can skip porting table logic entirely and instead add YAML workflows that render the cardinal templates directly. It is the right instinct — cardinal is Apache-2, pharmaverse-hosted, and covers 26 of the guide's tables — so it was investigated against the actual sources rather than assumed.
Cardinal's catalog is 27 entries at quarto/catalog/fda-table_NN/index.qmd (26 FDA tables plus one Roche display). Inspecting fda-table_07, the FDA Overview of Adverse Events:
Rendering the templates as-is does not work for production analysis. With no parameters and a hard-coded demo dataset, a workflow that rendered them would faithfully reproduce cardinal's catalog page — against pharmaverseadam, with a fabricated AEACN column — not analyse a study. The blocker is structural, not cosmetic.
It also fits the workflow model poorly. gsm.safety's workr steps name R functions and pass R objects:
steps:
- output: lWidget
name: gsm.safety::Widget_AeTimelines
params:
dfResults: dfResults
A Quarto render is a document build, not a step returning a chainable object, and it would add a Quarto CLI dependency to the runtime.
| Option | What it means | Assessment |
|---|---|---|
| A · Render as-is | YAML workflow shells out to quarto render on the vendored template | Rejected No params, hard-coded demo data, fabricated columns. Renders a catalog page, not an analysis. |
| B · Fork and parameterise | Vendor the .qmd files, add a params: block, replace hard-coded loads | Viable, not preferred Cheaper than porting, but adds a Quarto CLI dependency, sits awkwardly beside workr's R-function steps, and the upstream diffability it buys is worth little while cardinal's development is dormant. |
| C · Port the logic | Extract the table logic into Table_* functions over gtsummary and cards; YAML workflows call those | Recommended Matches the existing step model exactly, accepts gsm-mapped column parameters, and is testable with testthat. |
The porting cost is smaller than it first appears. Stripping the demo scaffolding from Table 07 leaves roughly forty lines of genuine logic — a tbl_hierarchical() composition stacked with tbl_stack() and indented with modify_indent(). Cardinal remains extremely valuable as the reference implementation for that logic, and Apache-2 permits reuse with attribution.
Each template ends by emitting an Analysis Results Dataset via gather_ard(tbl). An ARD is a machine-readable representation of the computed results, it aligns with CDISC's Analysis Results Standard, and it would slot naturally into both the gsm pipeline and our requirement-traceable evidence practice — a table that ships its own numbers in structured form is far easier to test and to audit than one that only renders. Recommend adopting the ARD-first pattern in Table_* outputs.
Phases 0 and 1 begin after gsm.safety v1.0.0 is released — PR #39 merged and tagged. Until then main is the stale v0.1.0 safetyCharts bridge, and static work would be built on a moving foundation.
Phase 0 · gated on v1.0.0
Phase 1 · gated on v1.0.0
Every one has a shipped safety.viz twin and no static-R implementation anywhere else. In order of leverage:
| Engine | FDA figures | Companion tables | safety.viz twin |
|---|---|---|---|
| DILI quadrant scatter | F7, F8 | T21, T22 | hep-explorer |
| Shift scatter | F15, F22 | — | shift-plot |
| Box plot over time | F10, F16–F20 | T20 family | outlier-explorer |
| Dot + risk-difference forest | F2, F3 | T14, T17 | ae-explorer |
Start with the DILI pair as a vertical slice — requirement, L1 derivation, ggplot2 renderer, vdiffr evidence, published gallery entry beside the existing hep-explorer demo. It exercises the whole chain and is where our asset is most clearly differentiated: nothing in cardinal or tern implements an eDISH plot.
Phase 1b · cheap completions
Paired-bar retention plots (F5, F21) and the incidence-rate point-range plot (F12). Simple engines, no interactive counterpart to reconcile; completes the "exists nowhere else" set at fifteen.
Phase 2 · borrow
F1, F4, F11, F13, F14. Wrap ggsurvfit or tern::g_km and adapt cardinal's fig_01 rather than writing new code — but supply the guide's mandated furniture, which existing templates do not fully provide: at-risk and cumulative-event strip tables beneath every plot, pointwise 95% CI bars, and earliest-qualifying-event semantics.
Phase 3 · tables and listings
Per option C above. Prioritise tables that pair with Phase 1 figures — T21/T22 with the DILI plots, T52/T53 with the lab thresholds, the T20 family with abnormality grading — rather than working through the guide in numerical order. Each Table_* emits both a rendered object and its ARD.
Static displays use the more traditional testthat path that gsm.safety #27 already anticipates, rather than the Playwright browser evidence the JS renderers need:
Displays reach "done" the same way renderers do — visible on the published site with a demo, evidence, and requirement traceability — extended to a static gallery in the gsm.safety pkgdown site so the two renderings sit side by side.
A new Visualize_* / Table_* family inside gsm.safety, or a separate ST&F package. Single-package mirrors gsm.kri and keeps one requirement matrix and one gallery; separate keeps gsm.safety lean but fragments the review surface.
Full L1 — safety.viz stops deriving normative flags and consumes R-enriched data — versus loose coupling sharing only the requirement matrix and demo data.
The differentiated twelve first, or tier-complete coverage of everything in Core (28 tables and 12 figures, including the KM family and the whole open-label-extension section).
PNG and vector PDF/SVG from ggplot2 is trivial. Submission-grade RTF and DOCX is a larger commitment, reachable via gt::as_rtf() or by adopting junco on rtables.
Option A (render templates directly via YAML), B (fork and parameterise the .qmd), or C (port the logic into Table_* functions). See section 4 for the evidence.
This design reads the "after it goes live" gate as gsm.safety v1.0.0 — PR #39 merged and tagged — which is the dependency the assessment flagged, since that release is what makes the Widget_* bindings and the vendored safety.viz bundle real on main.
Sub-issues to file in gsm.safety once this design is signed off and the v1.0.0 gate clears — via the requirement-tasks skill, as cross-repo sub-issues of #9.
| # | Sub-issue | Phase |
|---|---|---|
| 1 | FDA reference criteria as package data (Appendix Tables 56–60) | 0 |
| 2 | Requirement matrix for FDA ST&F displays (obot.agent) | 0 |
| 3 | Derive_* — abnormality grading, extreme-value exclusion, ULN multiples | 0 |
| 4 | ADaM ↔ Mapped_* alignment for the guide's domains | 0 |
| 5 | DILI quadrant scatter — F7, F8 + T21, T22 (vertical slice, proves L1) | 1 |
| 6 | Shift scatter — F15, F22 | 1 |
| 7 | Box plot over time — F10, F16–F20 | 1 |
| 8 | Dot + risk-difference forest — F2, F3 | 1 |
| 9 | Static gallery in the gsm.safety pkgdown site | 1 |
| 10 | Retention and incidence-rate figures — F5, F21, F12 | 1b |