---
title: "Plotting risk estimates"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Plotting risk estimates}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)

library(preventr)
```

## Introduction

`plot_risk()`
creates horizontal bar charts from risk estimates produced by
`estimate_risk()`
/
`est_risk()` (the vignette will hereafter use `est_risk()`).
It can also plot manually constructed data, but the manual input still
needs to match the output format of `est_risk()`.

This vignette focuses on four things:

- what
  `plot_risk()`
  expects for `risk_dat`
- what it returns under different input patterns
- how the default data frame behavior works
- how to control the appearance of the plots

The examples deliberately start by showing the default behavior when
`risk_dat` is a data frame. After that, most examples in the vignette
would benefit from `add_to_dat = FALSE` so the vignette renders the plot
output directly.

Additionally, the vignette will want to make heavy use of the argument
`progress = FALSE` in calls to
`plot_risk()`,
which suppresses the progress bar. This is because the progress bar does
not print well in a knitted document, but it does not affect the data
requirements, return structure, or plot appearance. In ordinary use,
`progress` defaults to `TRUE`, and as the name implies, it gives a
visual indication of progress; this can be especially helpful when
`risk_dat` is a large data frame.

As such, the vignette will often use a minor variant of
`plot_risk()`
that defaults to `add_to_dat = FALSE` and `progress = FALSE` to make the
examples more concise and visually clear.

```{r plot-risk-helper}
plot_risk_no_add_no_prog <- function(..., add_to_dat = FALSE, progress = FALSE) {
  plot_risk(..., add_to_dat = add_to_dat, progress = progress)
}
```

## What `plot_risk()` expects

For its argument `risk_dat`, the function
`plot_risk()`
accepts either a data frame or a list of data frames. In either case,
the input needs to match the risk-estimate output schema used by `est_risk()`.
In practical terms, this means the following:

- The data frame(s) within `risk_dat` (whether passed directly or as a
  list of data frames) must contain `model`, `over_years`, and at least
  one risk-estimate column among `total_cvd`, `ascvd`, `heart_failure`,
  `chd`, and `stroke`.
- If the data represent multiple people or instances in one data frame,
  `preventr_id` is required.
- If passing a list of data frames, this implies `risk_dat` is for a
  single person, because `est_risk()`
  only outputs a list of data frames when estimating risk for a single
  person (when estimating over both 10- and 30-year time horizons with
  `collapse = FALSE`). In addition to the aforementioned required
  columns, the structure of the list of data frames must also match the
  output of `est_risk()`,
  meaning the names of the list elements must be `"risk_est_10yr"` and
  `"risk_est_30yr"`, with the maximum number of rows for 10-year
  estimates being 3 and the maximum number of rows for the 30-year
  estimates being 1 and the column `preventr_id` not being present.
- `input_problems` is optional, but if it contains the specific 30-year
  age warning used by `est_risk()`,
  that warning is displayed as a subtitle

The safest way to obtain valid input is to start from `est_risk()`.

## Example data used in this vignette

```{r example-data}
risk_10_year <- est_risk(
  age = 55,
  sex = "female",
  sbp = 140,
  bp_tx = TRUE,
  total_c = 210,
  hdl_c = 50,
  statin = FALSE,
  dm = TRUE,
  smoking = FALSE,
  egfr = 90,
  bmi = 31,
  time = "10yr"
)

risk_30_year <- est_risk(
  age = 55,
  sex = "female",
  sbp = 140,
  bp_tx = TRUE,
  total_c = 210,
  hdl_c = 50,
  statin = FALSE,
  dm = TRUE,
  smoking = FALSE,
  egfr = 90,
  bmi = 31,
  time = "30yr"
)

risk_both <- rbind(risk_10_year, risk_30_year)
# Identical to a call to `est_risk()` with the arguments used for either
# `risk_10_year` or `risk_30_year`, other than setting `time = "both"` and
# `collapse = TRUE`.

fake_dat <- data.frame(
    age = c(45L, 55L),
    sex = c("female", "male"),
    sbp = c(140, 144),
    bp_tx = c(TRUE, FALSE),
    total_c = c(210, 240),
    hdl_c = c(50, 40),
    statin = c(FALSE, TRUE),
    dm = c(TRUE, FALSE),
    smoking = c(FALSE, TRUE),
    egfr = c(90, 60),
    bmi = c(31, 28)
)

risk_multi <- est_risk(use_dat = fake_dat, progress = FALSE)
# Setting `progress = FALSE` here to avoid showing the progress bar in the
# vignette, as it does not print well in a knitted document.

fake_dat_warning <- fake_dat
fake_dat_warning$age[[2]] <- 65

risk_warning <- est_risk(use_dat = fake_dat_warning, time = 30, progress = FALSE)

manual_single <- data.frame(
  total_cvd = 0.152,
  ascvd = 0.101,
  heart_failure = 0.051,
  chd = 0.062,
  stroke = 0.039,
  model = "base",
  over_years = 10,
  input_problems = NA_character_
)

manual_multi <- data.frame(
  preventr_id = c(1L, 2L),
  total_cvd = c(0.152, 0.280),
  ascvd = c(0.101, 0.210),
  heart_failure = c(0.051, 0.070),
  chd = c(0.062, 0.135),
  stroke = c(0.039, 0.075),
  model = c("base", "base"),
  over_years = c(10L, 10L),
  input_problems = c(NA_character_, NA_character_)
)

manual_multi_with_pce <- data.frame(
  preventr_id = c(1L, rep(2L, 3)),
  total_cvd = c(0.152, 0.175, NA_real_, 0.280),
  ascvd = c(0.101, 0.105, 0.2, 0.210),
  heart_failure = c(0.051, 0.07, NA_real_, 0.070),
  chd = c(0.062, 0.075, NA_real_, 0.135),
  stroke = c(0.039, 0.03, NA_real_, 0.075),
  model = c("base", "sdi", "pce_orig", "sdi"),
  over_years = c(rep(10L, 3), 30L),
  input_problems = rep(NA_character_, 4)
)

manual_list <- list(
  risk_est_10yr = data.frame(
    total_cvd = 0.152,
    ascvd = 0.101,
    heart_failure = 0.051,
    chd = 0.062,
    stroke = 0.039,
    model = "base",
    over_years = 10L,
    input_problems = NA_character_
  ),
  risk_est_30yr = data.frame(
    total_cvd = 0.430,
    ascvd = 0.280,
    heart_failure = 0.150,
    chd = 0.160,
    stroke = 0.120,
    model = "base",
    over_years = 30L,
    input_problems = NA_character_
  )
)
```

## The default behavior for data-frame input

When `risk_dat` is a data frame, `add_to_dat = TRUE` by default, so the
plot is added back onto the data frame as the list-column `plot`. This
is a convenient way to keep the plot objects attached to the data frame
while still being able to render them when needed.

```{r default-return}
# Note this first example uses the real `plot_risk()` with the default behavior of
# `add_to_dat = TRUE` to show the data frame with the plot attached as a list-column.
# It still uses `progress = FALSE` to avoid showing the progress bar in the vignette,
# as it does not print well in a knitted document.
default_plot_df <- plot_risk(risk_multi, progress = FALSE)

names(default_plot_df)

str(default_plot_df, max.level = 1)

all(vapply(default_plot_df$plot, ggplot2::is_ggplot, logical(1)))
```

To render a plot stored in that list-column, extract it explicitly.

```{r default-return-plot}
default_plot_df$plot[[1]]
```

When the column `plot` has more than one plot object, calling the column
directly renders all the plots in a list.

```{r default-return-plot-list}
default_plot_df$plot
```

## Return formats and the roles of `add_to_dat` and `collapse`

The return format of
`plot_risk()`
depends on three things:

- whether `risk_dat` is a data frame or a list of data frames,
- whether `add_to_dat` is `TRUE` or `FALSE`, and
- for list input only, whether `collapse` is `TRUE` or `FALSE`.

This table summarizes the return format based on these factors:

| Structure of `risk_dat` | Value of `add_to_dat` | Value of `collapse` | Output format |
|----|---:|---:|----|
| data frame | `TRUE` | not applicable | data frame with `plot` list-column |
| data frame | `FALSE` | not applicable | ggplot object or list of ggplot objects |
| list of data frames | `TRUE` | `TRUE` | single, collapsed data frame with `plot` list-column |
| list of data frames | `TRUE` | `FALSE` | list of data frames, each with `plot` list-column |
| list of data frames | `FALSE` | not applicable | list of ggplot objects |

Two details are worth emphasizing:

- `collapse` is only relevant when `risk_dat` is a list of data frames
  and `add_to_dat = TRUE`.
- If you want to actually *see* the plots (e.g., in your console, a
  knitted document, etc.), `add_to_dat = FALSE` accomplishes that;
  otherwise, you can extract the plot objects from the data frame that
  is returned when `add_to_dat = TRUE`.

## Rendering plots directly

If you want
`plot_risk()`
to return the plot object itself rather than appending it to the input
data, set `add_to_dat = FALSE`.

For a single plotting unit, this yields a single `ggplot` object.

```{r direct-single-plot}
# Again, this example uses the real `plot_risk()` with `add_to_dat = FALSE`
# to show the plot object directly. It still uses `progress = FALSE` to
# avoid showing the progress bar in the vignette, as it does not print well
# in a knitted document.
p_direct <- plot_risk(risk_10_year, add_to_dat = FALSE, progress = FALSE)
class(p_direct)
p_direct
```

After this point, most examples in the vignette are intended to show
plot output directly and all examples use `progress = FALSE` to suppress
the progress bar; thus, the vignette will hereafter make heavy use the
`plot_risk_no_add_no_prog()` variant previously defined to avoid having
to specify `add_to_dat = FALSE` and `progress = FALSE` repeatedly. This
helps the examples be more concise and clear.

## Using a manually constructed data frame

You do not need to start from `est_risk()`,
but your input must still obey the minimum required structure.

```{r manual-single-plot}
plot_risk_no_add_no_prog(manual_single)
```

An important detail to recall is that `model` and `over_years` are part
of the minimum schema. A data frame containing only risk columns is not
sufficient. The manually-created data frame `manual_single` meets these
criteria.

```{r manual-single-str}
str(manual_single)
```

## Reordering or restricting outcomes

By default, `outcomes = "all"` expands to:

- `total_cvd`
- `ascvd`
- `heart_failure`
- `chd`
- `stroke`

You can supply a character vector to change outcome inclusion, outcome
order, or both.

```{r subset-outcomes}
plot_risk_no_add_no_prog(risk_10_year, outcomes = c("stroke", "chd", "ascvd"))
```

## Annotation controls

The `annotation` argument accepts:

- `"all"` (the default)
- `"none"`
- one or more of `"title"`, `"subtitle"`, and `"caption"`

Notice "annotation" here refers only to the title, subtitle, and caption. Other text elements, such as the outcome labels and risk percentages are not controlled by the `annotation` argument. Likewise, `annotation` does not impact elements associated with the legend (when the legend applies); these elements are controlled by the `legend`, `lines`, and `line_text` arguments, which are discussed in the [section herein on legend and threshold line controls](#legend-and-threshold-line-controls).

### Removing annotation

```{r annotation-none}
plot_risk_no_add_no_prog(risk_10_year, annotation = "none")
```

### Keeping only selected annotation components

```{r annotation-selected}
plot_risk_no_add_no_prog(risk_10_year, annotation = c("title", "caption"))
```

### Showing the 30-year age-warning subtitle

If `input_problems` contains the specific warning string used by `est_risk()`
for 30-year estimation in people older than 59 years,
`plot_risk()`
uses that text as a subtitle.

```{r annotation-warning-subtitle}
# Reminder of ages and time horizons for the `risk_warning` data frame,
# remembering that the 30-year age warning applies to people older than
# 59 years when estimating over a 30-year time horizon.
risk_warning[, c("age", "over_years")]

# We thus expect a warning subtitle for the second row of `risk_warning`
# but not the first row.
plot_risk_no_add_no_prog(risk_warning)
```

## Color schemes

`plot_risk()`
supports two color schemes:

- `"single"`
- `"categories"`

### Single-color plots

For `color_scheme = "single"`, `color_dat` should be a single color
value.

```{r color-single}
plot_risk_no_add_no_prog(
  risk_10_year,
  color_scheme = "single",
  color_dat = "#1b9e77"
)
```

You can also specify the color using a named color or call to
[`rgb()`](https://rdrr.io/r/grDevices/rgb.html), as long as the result
is a single color value.

```{r color-single-named}
plot_risk_no_add_no_prog(
  risk_10_year,
  color_scheme = "single",
  color_dat = "mediumorchid4"
)

plot_risk_no_add_no_prog(
  risk_10_year,
  color_scheme = "single",
  color_dat = rgb(0.8, 0.6, 0.7)
)
```

### Category-based plots

For `color_scheme = "categories"`, `color_dat` should be a data frame
with columns `threshold` and `color`.

The rules are:

- you can supply up to three user-defined threshold-color pairs
- thresholds should fall strictly between 0.001 and 0.999
- duplicated, missing, or out-of-range thresholds are discarded
- the remaining threshold-color pairs are sorted by threshold value
- a final catch-all category is always created for values at or above
  the highest valid threshold, using `color_for_last_group`

```{r color-dat}
color_dat <- data.frame(
  threshold = c(0.20, 0.30, 0.40),
  color = c("#1db8b8", "#d70b9a", "#799dfa")
)
```

```{r color-categories}
plot_risk_no_add_no_prog(
  risk_30_year,
  color_scheme = "categories",
  color_dat = color_dat
)
```

The final risk group, meaning values at or above the highest valid
threshold, uses `color_for_last_group`.

```{r color-last-group}
plot_risk_no_add_no_prog(
  risk_30_year,
  color_scheme = "categories",
  color_dat = color_dat,
  color_for_last_group = rgb(25, 25, 112, maxColorValue = 255)
)
```

### Cleaning threshold input

`plot_risk()`
cleans category-threshold input by removing invalid or duplicate
thresholds and sorting the remaining threshold-color pairs.

```{r color-categories-cleaning}
# Note: The "messy" aspect here pertains to the thresholds being
# out of order. The colors are fine, because any valid color value
# is accepted, including a mixture of named colors, hex codes, and
# calls to `rgb()`.
color_dat_messy <- data.frame(
  threshold = c(0.375, 0.175, 0.275),
  color = c(rgb(0.5, 0.3, 0.9), "#1c1c69", "brown4")
)

plot_risk_no_add_no_prog(
  risk_30_year,
  color_scheme = "categories",
  color_dat = color_dat_messy
)
```

## Legend and threshold-line controls

The arguments `legend`, `lines`, and `line_text` are only used when
`color_scheme = "categories"`.

### Removing the legend

```{r categories-no-legend}
plot_risk_no_add_no_prog(
  risk_30_year,
  color_scheme = "categories",
  color_dat = color_dat,
  legend = FALSE
)
```

### Removing the dashed threshold lines

```{r categories-no-lines}
plot_risk_no_add_no_prog(
  risk_30_year,
  color_scheme = "categories",
  color_dat = color_dat,
  lines = FALSE
)
```

### Keeping lines but removing line text

```{r categories-no-line-text}
plot_risk_no_add_no_prog(
  risk_30_year,
  color_scheme = "categories",
  color_dat = color_dat,
  line_text = FALSE
)
```

## Base font size

You can adjust the overall text size with `base_size`.

```{r base-size}
plot_risk_no_add_no_prog(risk_10_year, base_size = 14)
```

## Multiple time horizons in one data frame

If one data frame contains more than one value of `over_years`
`plot_risk()`
splits internally by time horizon before plotting.

With `add_to_dat = FALSE`, this yields plot objects directly. With
`add_to_dat = TRUE`, this simply means the plot objects in the `plot`
list-column correctly correspond to the given row (i.e., the row for the
10-year time horizon contains the plot for the 10-year time horizon, and
the row for the 30-year time horizon contains the plot for the 30-year
time horizon).

```{r multiple-horizons}
plots_by_horizon <- plot_risk_no_add_no_prog(risk_both)

length(plots_by_horizon)
```

```{r multiple-horizons-plot-10}
plots_by_horizon[[1]]
```

```{r multiple-horizons-plot-30}
plots_by_horizon[[2]]
```

## Multiple people in one data frame

If one data frame contains multiple people or instances, `preventr_id`
is required so
`plot_risk()`
can split the data correctly.

```{r multiple-people}
plots_by_person <- plot_risk_no_add_no_prog(manual_multi)
length(plots_by_person)
```

```{r multiple-people-plot-1}
plots_by_person[[1]]
```

```{r multiple-people-plot-2}
plots_by_person[[2]]
```

This works in concert with multiple time horizons in one data frame, as
shown in the `manual_multi_with_pce` example. This data frame contains
risk estimates for two people. The first person has a single row
reflecting the 10-year time horizon from the base model of the PREVENT
equations. The second person has three rows: One row is the 10-year time
horizon from the base model of the PREVENT equations adding social
deprivation index (SDI), one row is the 10-year time horizon from the
original PCEs, and one row is the 30-year time horizon from the base
model of the PREVENT equations adding SDI.

```{r manual-multi-with-pce-table}
knitr::kable(manual_multi_with_pce)
```

Because plotting is separated by individual and time horizon, one would
expect 3 *unique* plots: One for the first person and two for the second
person (one for the 10-year time horizon and one for the 30-year time
horizon). However, to maintain tidy data, the 10-year time horizon plot for
the second person is repeated across their corresponding two rows for
their 10-year time horizon.

```{r multiple-people-multiple-horizons-plot}
plots_by_person_and_horizon <- plot_risk(
  manual_multi_with_pce,
  progress = FALSE
)

# Should be `TRUE` because the 10-year plot for the second person is 
# repeated across their two rows for the 10-year time horizon.
identical(
  plots_by_person_and_horizon$plot[[2]],
  plots_by_person_and_horizon$plot[[3]]
)

# Expect identicality between 2 and 3; expect differences otherwise
plots_by_person_and_horizon$plot
```

## Working with a list of data frames

A list of data frames is also valid input, as long as it adheres to the
output schema of `est_risk()`.

### Returning a list of data frames with plots attached

When `risk_dat` is a list of data frames, `add_to_dat = TRUE`, and
`collapse = FALSE`, the output remains a list.

```{r list-input-uncollapsed-plot}
list_with_plots <- plot_risk_no_add_no_prog(manual_list)
length(list_with_plots)

list_with_plots
```

### Collapsing a list input to one data frame

When `risk_dat` is a list of data frames, `add_to_dat = TRUE`, and
`collapse = TRUE`, the output is collapsed into one data frame.
Remember, `add_to_dat` is `TRUE` by default, so the main thing to note
here is that `collapse` matters for list input when `add_to_dat = TRUE`.
Given the intent of this example, note the use of
`plot_risk()`
and not `plot_risk_no_add_no_prog()`, because the former defaults to
`add_to_dat = TRUE` while the latter defaults to `add_to_dat = FALSE`.

```{r list-input-collapsed}
collapsed_list_with_plots <- plot_risk(
  manual_list,
  collapse = TRUE,
  progress = FALSE
)

collapsed_list_with_plots[, c("model", "over_years")]
```

```{r list-input-collapsed-plot}
collapsed_list_with_plots$plot[[1]]
```

### Returning only the plots from a list input

When `add_to_dat = FALSE`, `collapse` is functionally irrelevant for the
return format and the returned value is a list of plot objects. This
example will again use
`plot_risk()`
instead of `plot_risk_no_add_no_prog()` given its intent.

```{r list-input-plots-direct}
direct_list_plots <- plot_risk(
  manual_list,
  add_to_dat = FALSE,
  progress = FALSE
)

length(direct_list_plots)
```

```{r list-input-plots-direct-plot}
direct_list_plots[[2]]
```

### Malformed list input is not accepted

When `risk_dat` is a list of data frames, the structure of the list and
the data frames within it must match the output schema of `est_risk()`.
The following examples show some ways that malformed list input is not
accepted. These examples will again use
`plot_risk()`
instead of `plot_risk_no_add_no_prog()` given their intent.

```{r malformed-list-names, error = TRUE}
# When `risk_dat` is a list of data frames, the names of the list
# elements must be "risk_est_10yr" and "risk_est_30yr". This input
# violates that requirement.
malformed_list_names <- manual_list

names(malformed_list_names) <- c("ten_year", "thirty_year")

plot_risk(malformed_list_names)
```

```{r malformed-list-too-many-rows, error = TRUE}
# When `risk_dat` is a list of data frames, there must be no more than 3
# rows for the 10-year estimates and no more than 1 row for the 30-year
# estimates. This input violates that requirement.
malformed_list_more_than_one_person <- manual_list

malformed_list_more_than_one_person$risk_est_10yr <- rbind(
  malformed_list_more_than_one_person$risk_est_10yr,
  manual_multi |> dplyr::select(-preventr_id),
  manual_multi |> dplyr::select(-preventr_id)
)

plot_risk(malformed_list_more_than_one_person)
```

```{r malformed-list-preventr-id, error = TRUE}
# When `risk_dat` is a list of data frames, the column `preventr_id` must
# not be present. This input violates that requirement.
malformed_list_preventr_id_preset <- manual_list
malformed_list_preventr_id_preset$risk_est_10yr$preventr_id <- 1L
malformed_list_preventr_id_preset$risk_est_30yr$preventr_id <- 1L

plot_risk(malformed_list_preventr_id_preset)
```

## Strict logical arguments

Several behavior arguments are intentionally strict logicals. For these
arguments, values such as `1` and `0` are not treated as acceptable
stand-ins for `TRUE` and `FALSE`. These arguments include:

- `add_to_dat`
- `collapse`
- `progress`
- `legend`
- `lines`
- `line_text`

## Viewing data frames with plots as a list column

When `ggplot2` 4.0.0 was first released, one of the big changes was
rewriting things "under the hood" to move from S3 to S7 (see here for additional detail if interested: https://tidyverse.org/blog/2025/09/ggplot2-4-0-0/). This originally
resulted in problems with various methods to view data frames depending
on the IDE (see here for additional detail if interested:
<https://github.com/tidyverse/ggplot2/issues/6732>). The good news is the underlying data were never negatively impacted, but as you can imagine, not being able to reliably view data frames with plots as a list column is not ideal. As such, `preventr` tries to warn if it detects this might be an issue with your setup, but this is kind of tricky to do given - among other things - the different view functions are inherently interactive. As such, `preventr` does not attempt to cover every single use case, especially considering this issue should now be fixed if you are using the latest versions of `ggplot2`, your IDE, and R. If you find an exception and confirm it is due to the aforementioned issue, feel free to let me know, but more importantly, let the good folks behind `ggplot2` know.

## Notes on `progress`

The `progress` argument controls whether a progress bar is displayed
during execution. In ordinary interactive use, this is mostly relevant
when `risk_dat` is a data frame and there are multiple plotting units to
iterate over.

This vignette does not focus on the progress bar visually, because it
does not change the data requirements, return structure, or plot
appearance.

## Summary

`plot_risk()`
is easiest to use when you start from
`est_risk()`,
but it is flexible enough to support valid manual input and list-based
workflows.

The main points are:

- if you opt not to start from `est_risk()`, your input still needs to match the output schema of `est_risk()`.
- `model` and `over_years` are part of the minimum schema for manual
  input.
- `preventr_id` is required when one data frame contains multiple people.
- when `risk_dat` is a data frame, the default is to add a `plot`
  list-column.
- `collapse` matters for list input when `add_to_dat = TRUE`.
- when you want to foreground the graphics immediately, `add_to_dat = FALSE` is
  often the clearest choice, but you can always extract the plot objects from the data frame when the data frame was made with a call where `add_to_dat = TRUE`.
- category-based coloring gives control over thresholds,
  legends, and reference lines.
