32  Tables that accompany figures

Many manuscript figures are paired with a small numeric table: a numbers-at-risk strip below a Kaplan-Meier curve, or the count panel beneath a longitudinal participation bar chart. The hvtiPlotR plot objects carry these companion tables alongside the plot data, so we can pull the numbers straight out and render them with gt (Iannone et al. 2026). As with the publication tables chapter, these tables will migrate to the planned hvtiRtables package once it is available.

32.1 When to use it

A figure shows the shape of a result; the companion table gives the reader the numbers the shape rests on. The two go together when a curve hides how much data backs each region of it. The clearest case is the numbers-at-risk strip beneath a Kaplan-Meier curve: the curve might look confident out at ten years, but if only eight patients remain at risk there, the at-risk row is what tells the reader to read that tail with caution. Reach for a companion table whenever the figure invites a question the plot cannot answer on its own (“based on how many?”) and you want the answer on the same page rather than buried in the text.

We work through two cases the package already supports: the at-risk strip that a survival curve needs beside it, and the participation counts that sit beneath a longitudinal bar chart. In both, the numbers already live on the plot object, so we never recompute them and risk a table that disagrees with its own figure.

32.2 Numbers at risk

hv_survival() stores its companion tables in the object’s $tables slot. The numbers-at-risk strip lives in $tables$risk (one row per strata × report-time) and the per-time-point summary in $tables$report.

km <- hv_survival(sample_survival_data(n = 500, seed = 42))
names(km$tables)
[1] "risk"   "report"
str(km$tables$risk)
'data.frame':   6 obs. of  3 variables:
 $ strata     : chr  "All" "All" "All" "All" ...
 $ report_time: num  1 5 10 15 20 25
 $ n.risk     : num  478 412 322 260 207 207

We render the at-risk table directly with gt, relabelling the columns for presentation.

km$tables$risk |>
  gt() |>
  tab_header(
    title = "Numbers at Risk",
    subtitle = "Patients remaining under follow-up at each report time"
  ) |>
  cols_label(
    strata      = "Stratum",
    report_time = "Time (years)",
    n.risk      = "At risk (n)"
  )
Numbers at Risk
Patients remaining under follow-up at each report time
Stratum Time (years) At risk (n)
All 1 478
All 5 412
All 10 322
All 15 260
All 20 207
All 25 207

The companion report table carries the survival estimates and confidence limits at the same report times — useful as the figure caption’s numeric backing.

km$tables$report |>
  gt() |>
  tab_header(title = "Survival Estimates at Report Times") |>
  cols_label(
    strata      = "Stratum",
    report_time = "Time (years)",
    surv        = "S(t)",
    lower       = "95% LCL",
    upper       = "95% UCL",
    n.risk      = "At risk",
    n.event     = "Events"
  ) |>
  fmt_number(columns = c(surv, lower, upper), decimals = 3)
Survival Estimates at Report Times
Stratum Time (years) S(t) 95% LCL 95% UCL At risk Events
All 1 0.954 0.932 0.969 478 1
All 5 0.822 0.786 0.853 412 1
All 10 0.642 0.599 0.683 322 1
All 15 0.518 0.474 0.562 260 1
All 20 0.414 0.372 0.458 207 0
All 25 0.414 0.372 0.458 207 0

32.3 Longitudinal count table

hv_longitudinal() produces a two-panel figure: a grouped bar chart of participation counts and a numeric table panel below it. The underlying counts live in the object’s $data slot in long format (one row per time-window × series), with the column roles recorded in $meta.

lc <- hv_longitudinal(sample_longitudinal_counts_data(n_patients = 300, seed = 42L))
names(lc)
[1] "data"   "meta"   "tables"
lc$meta[c("x_col", "group_col", "count_col")]
$x_col
[1] "time_label"

$group_col
[1] "series"

$count_col
[1] "count"
str(lc$data)
'data.frame':   14 obs. of  3 variables:
 $ time_label: Factor w/ 7 levels "≥0 Days","≥1 Month",..: 1 2 3 4 5 6 7 1 2 3 ...
 $ series    : chr  "Patients" "Patients" "Patients" "Patients" ...
 $ count     : int  19 47 49 100 159 101 276 19 50 56 ...

We reshape the long counts into a wide table (one row per follow-up window, one column per series) and render it with gt. This is the tabular companion to the bar chart’s table panel.

lc_wide <- lc$data |>
  tidyr::pivot_wider(
    names_from  = series,
    values_from = count
  )
lc_wide
# A tibble: 7 × 3
  time_label Patients Measurements
  <fct>         <int>        <int>
1 ≥0 Days          19           19
2 ≥1 Month         47           50
3 ≥3 Months        49           56
4 ≥6 Months       100          118
5 ≥1 Year         159          217
6 ≥2 Years        101          113
7 ≥2.5 Years      276          620
lc_wide |>
  gt(rowname_col = "time_label") |>
  tab_header(
    title    = "Longitudinal Participation Counts",
    subtitle = "Patients and measurements at each follow-up window"
  ) |>
  fmt_number(columns = where(is.numeric), decimals = 0) |>
  tab_stubhead(label = "Follow-up window")
Longitudinal Participation Counts
Patients and measurements at each follow-up window
Follow-up window Patients Measurements
≥0 Days 19 19
≥1 Month 47 50
≥3 Months 49 56
≥6 Months 100 118
≥1 Year 159 217
≥2 Years 101 113
≥2.5 Years 276 620

The gt version above is for a standalone table, say in the manuscript body or a supplement. When the numbers belong inside the figure, the package draws them as a table panel you stack under the chart. For reference, the same counts rendered as the in-figure table panel appear below the bar chart when the two are composed with patchwork. Building both from the same lc$data keeps the standalone table and the in-figure panel telling one story.

p_bar <- plot(lc) +
  scale_fill_manual(
    values = c(Patients = "steelblue", Measurements = "firebrick"),
    name   = NULL
  ) +
  labs(x = NULL, y = "Count (n)")

p_tbl <- plot(lc, type = "table") +
  scale_colour_manual(
    values = c(Patients = "steelblue", Measurements = "firebrick"),
    guide  = "none"
  )

p_bar / p_tbl + plot_layout(heights = c(3, 1))

32.4 Pitfalls

  • At-risk counts must match the curve’s time axis. The risk strip is only useful if its columns sit under the same time points the curve uses. If the plot runs 0 to 20 years on a five-year grid, the at-risk table needs the same grid, or the reader cannot line a count up with the part of the curve it describes. Because $tables$risk is computed by the same constructor that drew the curve, the report times already agree; the trap appears when you hand-edit one axis or crop the plot with coord_cartesian() and forget the table still shows the full range. Change the axis in one place and check the other followed.
  • Keeping the table and figure in sync. The reason we pull numbers off the plot object rather than recomputing them is to keep a single source of truth. The moment you summarise the cohort separately for the table, the two can drift: a filter applied to one and not the other, a different handling of missing follow-up, a stale data load. If you must build a companion table by hand, derive it from the exact frame the figure was built from, and re-derive both whenever the data change. A figure and a table that disagree are worse than either alone, because a reader cannot tell which to believe.