arframe TFL Gallery
  1. Tables
  2. Laboratory Shift Table
  • Getting Started
    • Installation

  • Tables
    • Study Conduct
    • Enrollment by Country and Site

    • Study Population
    • Demographics Summary
    • Medical History
    • Prior Medication
    • Disposition Summary
    • Analysis Populations

    • Extent of Exposure
    • Concomitant Medications
    • Extent of Exposure

    • Safety
    • Adverse Events by System Organ Class and Preferred Term
    • AEs Related to Study Drug
    • Common Adverse Events
    • Adverse Events by Grade / Intensity
    • Overall Safety Summary
    • Adverse Events with Event Counts
    • Exposure-Adjusted Adverse Events
    • Adverse Events by Subgroup
    • Serious Adverse Events by SOC and PT
    • AEs Leading to Study Drug Discontinuation
    • Death Summary
    • Vital Signs
    • Laboratory Results - Chemistry
    • Laboratory Shift Table
    • Laboratory Worst Toxicity Grade
    • Laboratory Marked Abnormalities
    • Electrocardiogram Summary

    • Efficacy
    • Time to Event Summary
    • Best Overall Response

  • Listings
    • Adverse Event Listing
    • Demographic Characteristics Listing
    • Medical History Listing
    • Vital Signs Listing
    • Laboratory Test Results Listing
    • Concomitant Medications Listing

  • Figures
    • Kaplan-Meier Plot
    • Swimmer Plot
    • Waterfall Plot

On this page

  • Setup
  • Data Preparation
  • arframe Pipeline
  • Rendered Table
  1. Tables
  2. Laboratory Shift Table

Laboratory Shift Table

Shift from Baseline to Worst Post-Baseline

Setup

See Prerequisites for installation instructions.

library(arframe)
library(pharmaverseadam)
library(dplyr, warn.conflicts = FALSE)
library(tidyr)

# Chemistry panel — 4 hepatic/renal parameters
param_subset <- c("ALT", "AST", "BILI", "CREAT")

adlb_saf <- pharmaverseadam::adlb |>
  blank_to_na() |>
  filter(
    SAFFL   == "Y",
    TRT01A  != "Screen Failure",
    PARCAT1 == "CHEMISTRY",
    PARAMCD %in% param_subset
  )

adsl_saf <- pharmaverseadam::adsl |>
  blank_to_na() |>
  filter(SAFFL == "Y", TRT01A != "Screen Failure")

arm_levels <- c("Placebo", "Xanomeline Low Dose", "Xanomeline High Dose")
arm_n      <- adsl_saf |> count(TRT01A) |> pull(n, name = TRT01A)
arm_n      <- arm_n[arm_levels]

# Reference categories as factors (ensures 0-count levels appear)
bnrind_levels <- c("LOW", "NORMAL", "HIGH")
anrind_levels <- c("Low", "Normal", "High")

adlb_saf <- adlb_saf |>
  mutate(
    BNRIND = factor(BNRIND, levels = bnrind_levels),
    ANRIND = factor(ANRIND, levels = c("LOW", "NORMAL", "HIGH"))
  )

Data Preparation

  • dplyr
  • cards
# ── Baseline reference indicator (one record per subject per param) ──
baseline <- adlb_saf |>
  filter(ABLFL == "Y") |>
  select(USUBJID, PARAMCD, PARAM, TRT01A, BNRIND)

# ── Worst post-baseline: rank LOW/HIGH above NORMAL, pick most extreme ──
# Severity rank: HIGH=2, LOW=2, NORMAL=1 (tie-break: take HIGH over LOW)
post_bl <- adlb_saf |>
  filter(is.na(ABLFL) | ABLFL != "Y", !is.na(ANRIND), ANRIND != "") |>
  mutate(
    anrind_rank = case_when(
      ANRIND == "HIGH"   ~ 3L,
      ANRIND == "LOW"    ~ 2L,
      ANRIND == "NORMAL" ~ 1L,
      TRUE               ~ 0L
    )
  ) |>
  group_by(USUBJID, PARAMCD) |>
  slice_max(anrind_rank, n = 1, with_ties = FALSE) |>
  ungroup() |>
  select(USUBJID, PARAMCD, worst_ANRIND = ANRIND)

# ── Join baseline indicator to worst post-baseline ──
shift_subj <- baseline |>
  inner_join(post_bl, by = c("USUBJID", "PARAMCD")) |>
  filter(!is.na(BNRIND), BNRIND != "", BNRIND %in% c("LOW", "NORMAL", "HIGH"))

# ── n (%) helper — denominator is subjects with that baseline category per arm ──
n_pct <- function(n, denom) {
  ifelse(denom == 0, "0 (0.0)", sprintf("%d (%.1f)", n, n / denom * 100))
}

# ── Cross-tabulate BNRIND x worst_ANRIND per arm, then pivot wide ──
# Column naming: <arm_no_spaces>_<post_category>
shift_counts <- shift_subj |>
  count(PARAMCD, PARAM, TRT01A, BNRIND, worst_ANRIND) |>
  filter(TRT01A %in% arm_levels)

# Denominators: subjects with each baseline category per arm per param
denom_df <- shift_subj |>
  filter(TRT01A %in% arm_levels) |>
  count(PARAMCD, TRT01A, BNRIND, name = "denom")

shift_pct <- shift_counts |>
  left_join(denom_df, by = c("PARAMCD", "TRT01A", "BNRIND")) |>
  mutate(
    value   = n_pct(n, denom),
    col_key = paste0(gsub(" ", "_", TRT01A), "_", worst_ANRIND)
  ) |>
  select(PARAMCD, PARAM, BNRIND, col_key, value) |>
  pivot_wider(names_from = col_key, values_from = value)

# ── Ensure all arm×post-category columns exist ──
all_col_keys <- unlist(lapply(arm_levels, function(a) {
  paste0(gsub(" ", "_", a), "_", c("LOW", "NORMAL", "HIGH"))
}))
for (col in all_col_keys) {
  if (!col %in% names(shift_pct)) shift_pct[[col]] <- NA_character_
}

# ── Final layout: one row per PARAM × BNRIND ──
shift_wide <- shift_pct |>
  mutate(
    BNRIND = factor(BNRIND, levels = c("LOW", "NORMAL", "HIGH")),
    PARAMCD = factor(PARAMCD, levels = param_subset)
  ) |>
  arrange(PARAMCD, BNRIND) |>
  mutate(
    BNRIND = as.character(BNRIND),
    PARAMCD = as.character(PARAMCD),
    bnrind_label = paste0(BNRIND, " (Baseline)")
  ) |>
  mutate(across(all_of(all_col_keys), ~ replace_na(.x, "0 (0.0)"))) |>
  select(PARAMCD, PARAM, bnrind_label, all_of(all_col_keys))

The shift table cross-tabulates a baseline reference indicator against the worst post-baseline reference indicator. This is a derived analysis not directly supported by cards ARD functions, so the dplyr path is the primary approach for this table type.

# ── Same derivation logic, expressed more compactly ──
baseline_c <- adlb_saf |>
  filter(ABLFL == "Y", !is.na(BNRIND), BNRIND != "") |>
  select(USUBJID, PARAMCD, PARAM, TRT01A, BNRIND)

worst_post_c <- adlb_saf |>
  filter(is.na(ABLFL) | ABLFL != "Y", !is.na(ANRIND), ANRIND != "") |>
  mutate(
    anrind_rank = case_when(
      ANRIND == "HIGH"   ~ 3L,
      ANRIND == "LOW"    ~ 2L,
      ANRIND == "NORMAL" ~ 1L,
      TRUE               ~ 0L
    )
  ) |>
  group_by(USUBJID, PARAMCD) |>
  slice_max(anrind_rank, n = 1, with_ties = FALSE) |>
  ungroup() |>
  select(USUBJID, PARAMCD, worst_ANRIND = ANRIND)

shift_cards_raw <- baseline_c |>
  inner_join(worst_post_c, by = c("USUBJID", "PARAMCD")) |>
  filter(TRT01A %in% arm_levels, BNRIND %in% c("LOW", "NORMAL", "HIGH")) |>
  count(PARAMCD, PARAM, TRT01A, BNRIND, worst_ANRIND) |>
  left_join(
    baseline_c |>
      filter(TRT01A %in% arm_levels, BNRIND %in% c("LOW", "NORMAL", "HIGH")) |>
      count(PARAMCD, TRT01A, BNRIND, name = "denom"),
    by = c("PARAMCD", "TRT01A", "BNRIND")
  ) |>
  mutate(
    value   = ifelse(denom == 0, "0 (0.0)", sprintf("%d (%.1f)", n, n / denom * 100)),
    col_key = paste0(gsub(" ", "_", TRT01A), "_", worst_ANRIND)
  ) |>
  select(PARAMCD, PARAM, BNRIND, col_key, value) |>
  pivot_wider(names_from = col_key, values_from = value)

# Ensure all columns exist and final layout
for (col in all_col_keys) {
  if (!col %in% names(shift_cards_raw)) shift_cards_raw[[col]] <- NA_character_
}

shift_cards <- shift_cards_raw |>
  mutate(
    BNRIND = factor(BNRIND, levels = c("LOW", "NORMAL", "HIGH")),
    PARAMCD = factor(PARAMCD, levels = param_subset)
  ) |>
  arrange(PARAMCD, BNRIND) |>
  mutate(
    BNRIND  = as.character(BNRIND),
    PARAMCD = as.character(PARAMCD),
    bnrind_label = paste0(BNRIND, " (Baseline)")
  ) |>
  mutate(across(all_of(all_col_keys), ~ replace_na(.x, "0 (0.0)"))) |>
  select(PARAMCD, PARAM, bnrind_label, all_of(all_col_keys))

arframe Pipeline

The rendered table below uses the dplyr data prep (shift_wide). The cards tab produces an equivalent shift_cards — swap it in to use the cards path instead.

Each row is a baseline reference category. Columns are treatment arm × post-baseline reference category, with arm-level spans. One page per parameter:

# Column specs: arm × post-baseline category
shift_cols <- unlist(lapply(arm_levels, function(a) {
  setNames(
    lapply(c("LOW", "NORMAL", "HIGH"), function(cat) {
      fr_col(tools::toTitleCase(tolower(cat)), align = "decimal")
    }),
    paste0(gsub(" ", "_", a), "_", c("LOW", "NORMAL", "HIGH"))
  )
}), recursive = FALSE)

shift_wide |>
  fr_table() |>
  fr_titles(
    "Table 14.3.6",
    "Laboratory Shift Table — Shift from Baseline to Worst Post-Baseline",
    "Safety Population"
  ) |>
  fr_cols(
    PARAMCD      = fr_col(visible = FALSE),
    PARAM        = fr_col(visible = FALSE),
    bnrind_label = fr_col("Baseline\nReference Range", width = 1.8),
    !!!shift_cols,
    .n = arm_n
  ) |>
  fr_spans(
    !!!setNames(
      lapply(arm_levels, function(a) {
        paste0(gsub(" ", "_", a), "_", c("LOW", "NORMAL", "HIGH"))
      }),
      arm_levels
    )
  ) |>
  fr_header(bold = TRUE, align = "center") |>
  fr_rows(page_by = "PARAM") |>
  fr_footnotes(
    "Worst post-baseline is the most extreme reference range indicator (High or Low) recorded after baseline; Normal if no abnormal values observed.",
    "Denominator for percentages is the number of subjects with the specified baseline reference range category.",
    "Reference range indicators derived from laboratory normal ranges (ANRIND, BNRIND).",
    "Safety Population: all randomised subjects who received at least one dose."
  )

Rendered Table

Table 14.3.6
Laboratory Shift Table — Shift from Baseline to Worst Post-Baseline
Safety Population
Alanine Aminotransferase (U/L)
Placebo
(N=86)
Xanomeline Low Dose
(N=96)
Xanomeline High Dose
(N=72)
Baseline
Reference Range
LowNormalHighLowNormalHighLowNormalHigh
LOW (Baseline)0 0 0 0 1 (100.0)0 0 0 0
NORMAL (Baseline)1 (1.2)73 ( 91.2)6 ( 7.5)5 (5.6)75 ( 84.3)9 ( 10.1)0 61 (89.7)7 ( 10.3)
HIGH (Baseline)0 1 ( 25.0)3 ( 75.0)0 0 3 (100.0)0 0 4 (100.0)
Worst post-baseline is the most extreme reference range indicator (High or Low) recorded after baseline; Normal if no abnormal values observed.
Denominator for percentages is the number of subjects with the specified baseline reference range category.
Reference range indicators derived from laboratory normal ranges (ANRIND, BNRIND).
Safety Population: all randomised subjects who received at least one dose.
/opt/quarto/share/rmd/rmd.R 01APR2026 09:54:05
Table 14.3.6
Laboratory Shift Table — Shift from Baseline to Worst Post-Baseline
Safety Population
Aspartate Aminotransferase (U/L)
Placebo
(N=86)
Xanomeline Low Dose
(N=96)
Xanomeline High Dose
(N=72)
Baseline
Reference Range
LowNormalHighLowNormalHighLowNormalHigh
NORMAL (Baseline)0 70 ( 89.7)8 ( 10.3)0 76 ( 89.4)9 ( 10.6)1 ( 1.4)63 (91.3)5 ( 7.2)
HIGH (Baseline)0 2 ( 33.3)4 ( 66.7)0 3 ( 37.5)5 ( 62.5)0 1 (33.3)2 ( 66.7)
Worst post-baseline is the most extreme reference range indicator (High or Low) recorded after baseline; Normal if no abnormal values observed.
Denominator for percentages is the number of subjects with the specified baseline reference range category.
Reference range indicators derived from laboratory normal ranges (ANRIND, BNRIND).
Safety Population: all randomised subjects who received at least one dose.
/opt/quarto/share/rmd/rmd.R 01APR2026 09:54:05
Table 14.3.6
Laboratory Shift Table — Shift from Baseline to Worst Post-Baseline
Safety Population
Bilirubin (umol/L)
Placebo
(N=86)
Xanomeline Low Dose
(N=96)
Xanomeline High Dose
(N=72)
Baseline
Reference Range
LowNormalHighLowNormalHighLowNormalHigh
NORMAL (Baseline)0 78 ( 95.1)4 ( 4.9)0 88 ( 98.9)1 ( 1.1)0 66 (95.7)3 ( 4.3)
HIGH (Baseline)0 0 2 (100.0)0 2 ( 66.7)1 ( 33.3)0 0 3 (100.0)
Worst post-baseline is the most extreme reference range indicator (High or Low) recorded after baseline; Normal if no abnormal values observed.
Denominator for percentages is the number of subjects with the specified baseline reference range category.
Reference range indicators derived from laboratory normal ranges (ANRIND, BNRIND).
Safety Population: all randomised subjects who received at least one dose.
/opt/quarto/share/rmd/rmd.R 01APR2026 09:54:05
Table 14.3.6
Laboratory Shift Table — Shift from Baseline to Worst Post-Baseline
Safety Population
Creatinine (umol/L)
Placebo
(N=86)
Xanomeline Low Dose
(N=96)
Xanomeline High Dose
(N=72)
Baseline
Reference Range
LowNormalHighLowNormalHighLowNormalHigh
LOW (Baseline)0 2 (100.0)0 0 0 0 1 (100.0) 0 0
NORMAL (Baseline)1 (1.2)72 ( 88.9)8 ( 9.9)1 (1.1)82 ( 93.2)5 ( 5.7)2 ( 3.0)56 (84.8)8 ( 12.1)
HIGH (Baseline)0 0 1 (100.0)0 0 5 (100.0)0 0 5 (100.0)
Worst post-baseline is the most extreme reference range indicator (High or Low) recorded after baseline; Normal if no abnormal values observed.
Denominator for percentages is the number of subjects with the specified baseline reference range category.
Reference range indicators derived from laboratory normal ranges (ANRIND, BNRIND).
Safety Population: all randomised subjects who received at least one dose.
/opt/quarto/share/rmd/rmd.R 01APR2026 09:54:05
Source Code
---
title: "Laboratory Shift Table"
subtitle: "Shift from Baseline to Worst Post-Baseline"
execute:
  echo: true
  eval: true
---


```{r}
#| label: prereqs
#| include: false
library(arframe)
fr_theme(hlines = "header", font_family = "Courier New")
blank_to_na <- function(df) {
  df[] <- lapply(df, function(x) {
    if (is.character(x)) x[x == ""] <- NA_character_
    x
  })
  df
}
```

## Setup

See [Prerequisites](../install.qmd) for installation instructions.

```{r}
#| label: setup
library(arframe)
library(pharmaverseadam)
library(dplyr, warn.conflicts = FALSE)
library(tidyr)

# Chemistry panel — 4 hepatic/renal parameters
param_subset <- c("ALT", "AST", "BILI", "CREAT")

adlb_saf <- pharmaverseadam::adlb |>
  blank_to_na() |>
  filter(
    SAFFL   == "Y",
    TRT01A  != "Screen Failure",
    PARCAT1 == "CHEMISTRY",
    PARAMCD %in% param_subset
  )

adsl_saf <- pharmaverseadam::adsl |>
  blank_to_na() |>
  filter(SAFFL == "Y", TRT01A != "Screen Failure")

arm_levels <- c("Placebo", "Xanomeline Low Dose", "Xanomeline High Dose")
arm_n      <- adsl_saf |> count(TRT01A) |> pull(n, name = TRT01A)
arm_n      <- arm_n[arm_levels]

# Reference categories as factors (ensures 0-count levels appear)
bnrind_levels <- c("LOW", "NORMAL", "HIGH")
anrind_levels <- c("Low", "Normal", "High")

adlb_saf <- adlb_saf |>
  mutate(
    BNRIND = factor(BNRIND, levels = bnrind_levels),
    ANRIND = factor(ANRIND, levels = c("LOW", "NORMAL", "HIGH"))
  )
```


## Data Preparation

::: {.panel-tabset}

### dplyr

```{r}
#| label: dplyr-code

# ── Baseline reference indicator (one record per subject per param) ──
baseline <- adlb_saf |>
  filter(ABLFL == "Y") |>
  select(USUBJID, PARAMCD, PARAM, TRT01A, BNRIND)

# ── Worst post-baseline: rank LOW/HIGH above NORMAL, pick most extreme ──
# Severity rank: HIGH=2, LOW=2, NORMAL=1 (tie-break: take HIGH over LOW)
post_bl <- adlb_saf |>
  filter(is.na(ABLFL) | ABLFL != "Y", !is.na(ANRIND), ANRIND != "") |>
  mutate(
    anrind_rank = case_when(
      ANRIND == "HIGH"   ~ 3L,
      ANRIND == "LOW"    ~ 2L,
      ANRIND == "NORMAL" ~ 1L,
      TRUE               ~ 0L
    )
  ) |>
  group_by(USUBJID, PARAMCD) |>
  slice_max(anrind_rank, n = 1, with_ties = FALSE) |>
  ungroup() |>
  select(USUBJID, PARAMCD, worst_ANRIND = ANRIND)

# ── Join baseline indicator to worst post-baseline ──
shift_subj <- baseline |>
  inner_join(post_bl, by = c("USUBJID", "PARAMCD")) |>
  filter(!is.na(BNRIND), BNRIND != "", BNRIND %in% c("LOW", "NORMAL", "HIGH"))

# ── n (%) helper — denominator is subjects with that baseline category per arm ──
n_pct <- function(n, denom) {
  ifelse(denom == 0, "0 (0.0)", sprintf("%d (%.1f)", n, n / denom * 100))
}

# ── Cross-tabulate BNRIND x worst_ANRIND per arm, then pivot wide ──
# Column naming: <arm_no_spaces>_<post_category>
shift_counts <- shift_subj |>
  count(PARAMCD, PARAM, TRT01A, BNRIND, worst_ANRIND) |>
  filter(TRT01A %in% arm_levels)

# Denominators: subjects with each baseline category per arm per param
denom_df <- shift_subj |>
  filter(TRT01A %in% arm_levels) |>
  count(PARAMCD, TRT01A, BNRIND, name = "denom")

shift_pct <- shift_counts |>
  left_join(denom_df, by = c("PARAMCD", "TRT01A", "BNRIND")) |>
  mutate(
    value   = n_pct(n, denom),
    col_key = paste0(gsub(" ", "_", TRT01A), "_", worst_ANRIND)
  ) |>
  select(PARAMCD, PARAM, BNRIND, col_key, value) |>
  pivot_wider(names_from = col_key, values_from = value)

# ── Ensure all arm×post-category columns exist ──
all_col_keys <- unlist(lapply(arm_levels, function(a) {
  paste0(gsub(" ", "_", a), "_", c("LOW", "NORMAL", "HIGH"))
}))
for (col in all_col_keys) {
  if (!col %in% names(shift_pct)) shift_pct[[col]] <- NA_character_
}

# ── Final layout: one row per PARAM × BNRIND ──
shift_wide <- shift_pct |>
  mutate(
    BNRIND = factor(BNRIND, levels = c("LOW", "NORMAL", "HIGH")),
    PARAMCD = factor(PARAMCD, levels = param_subset)
  ) |>
  arrange(PARAMCD, BNRIND) |>
  mutate(
    BNRIND = as.character(BNRIND),
    PARAMCD = as.character(PARAMCD),
    bnrind_label = paste0(BNRIND, " (Baseline)")
  ) |>
  mutate(across(all_of(all_col_keys), ~ replace_na(.x, "0 (0.0)"))) |>
  select(PARAMCD, PARAM, bnrind_label, all_of(all_col_keys))
```

### cards

The shift table cross-tabulates a baseline reference indicator against the worst post-baseline reference indicator. This is a derived analysis not directly supported by `cards` ARD functions, so the dplyr path is the primary approach for this table type.

```{r}
#| label: cards-code

# ── Same derivation logic, expressed more compactly ──
baseline_c <- adlb_saf |>
  filter(ABLFL == "Y", !is.na(BNRIND), BNRIND != "") |>
  select(USUBJID, PARAMCD, PARAM, TRT01A, BNRIND)

worst_post_c <- adlb_saf |>
  filter(is.na(ABLFL) | ABLFL != "Y", !is.na(ANRIND), ANRIND != "") |>
  mutate(
    anrind_rank = case_when(
      ANRIND == "HIGH"   ~ 3L,
      ANRIND == "LOW"    ~ 2L,
      ANRIND == "NORMAL" ~ 1L,
      TRUE               ~ 0L
    )
  ) |>
  group_by(USUBJID, PARAMCD) |>
  slice_max(anrind_rank, n = 1, with_ties = FALSE) |>
  ungroup() |>
  select(USUBJID, PARAMCD, worst_ANRIND = ANRIND)

shift_cards_raw <- baseline_c |>
  inner_join(worst_post_c, by = c("USUBJID", "PARAMCD")) |>
  filter(TRT01A %in% arm_levels, BNRIND %in% c("LOW", "NORMAL", "HIGH")) |>
  count(PARAMCD, PARAM, TRT01A, BNRIND, worst_ANRIND) |>
  left_join(
    baseline_c |>
      filter(TRT01A %in% arm_levels, BNRIND %in% c("LOW", "NORMAL", "HIGH")) |>
      count(PARAMCD, TRT01A, BNRIND, name = "denom"),
    by = c("PARAMCD", "TRT01A", "BNRIND")
  ) |>
  mutate(
    value   = ifelse(denom == 0, "0 (0.0)", sprintf("%d (%.1f)", n, n / denom * 100)),
    col_key = paste0(gsub(" ", "_", TRT01A), "_", worst_ANRIND)
  ) |>
  select(PARAMCD, PARAM, BNRIND, col_key, value) |>
  pivot_wider(names_from = col_key, values_from = value)

# Ensure all columns exist and final layout
for (col in all_col_keys) {
  if (!col %in% names(shift_cards_raw)) shift_cards_raw[[col]] <- NA_character_
}

shift_cards <- shift_cards_raw |>
  mutate(
    BNRIND = factor(BNRIND, levels = c("LOW", "NORMAL", "HIGH")),
    PARAMCD = factor(PARAMCD, levels = param_subset)
  ) |>
  arrange(PARAMCD, BNRIND) |>
  mutate(
    BNRIND  = as.character(BNRIND),
    PARAMCD = as.character(PARAMCD),
    bnrind_label = paste0(BNRIND, " (Baseline)")
  ) |>
  mutate(across(all_of(all_col_keys), ~ replace_na(.x, "0 (0.0)"))) |>
  select(PARAMCD, PARAM, bnrind_label, all_of(all_col_keys))
```

:::


## arframe Pipeline

The rendered table below uses the **dplyr** data prep (`shift_wide`). The cards tab produces an equivalent `shift_cards` — swap it in to use the cards path instead.

Each row is a baseline reference category. Columns are treatment arm × post-baseline reference category, with arm-level spans. One page per parameter:

```{r}
#| label: pipeline
#| eval: false

# Column specs: arm × post-baseline category
shift_cols <- unlist(lapply(arm_levels, function(a) {
  setNames(
    lapply(c("LOW", "NORMAL", "HIGH"), function(cat) {
      fr_col(tools::toTitleCase(tolower(cat)), align = "decimal")
    }),
    paste0(gsub(" ", "_", a), "_", c("LOW", "NORMAL", "HIGH"))
  )
}), recursive = FALSE)

shift_wide |>
  fr_table() |>
  fr_titles(
    "Table 14.3.6",
    "Laboratory Shift Table — Shift from Baseline to Worst Post-Baseline",
    "Safety Population"
  ) |>
  fr_cols(
    PARAMCD      = fr_col(visible = FALSE),
    PARAM        = fr_col(visible = FALSE),
    bnrind_label = fr_col("Baseline\nReference Range", width = 1.8),
    !!!shift_cols,
    .n = arm_n
  ) |>
  fr_spans(
    !!!setNames(
      lapply(arm_levels, function(a) {
        paste0(gsub(" ", "_", a), "_", c("LOW", "NORMAL", "HIGH"))
      }),
      arm_levels
    )
  ) |>
  fr_header(bold = TRUE, align = "center") |>
  fr_rows(page_by = "PARAM") |>
  fr_footnotes(
    "Worst post-baseline is the most extreme reference range indicator (High or Low) recorded after baseline; Normal if no abnormal values observed.",
    "Denominator for percentages is the number of subjects with the specified baseline reference range category.",
    "Reference range indicators derived from laboratory normal ranges (ANRIND, BNRIND).",
    "Safety Population: all randomised subjects who received at least one dose."
  )
```


## Rendered Table

```{r}
#| label: rendered-table
#| echo: false
#| ref.label: pipeline
```

Open-source TFL reference collection

 

CDISC Pilot Study (CDISCPILOT01) • pharmaverseadam datasets