Skip to content

Commit 070b0ad

Browse files
authored
Merge pull request #5 from macromeer/dashboard-app
Add a dashboard app for the figure
2 parents 2f73bc3 + 4d8157c commit 070b0ad

4 files changed

Lines changed: 355 additions & 5 deletions

File tree

‎DESCRIPTION‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
Package: scifigexamples
22
Title: Scientific Publication Figure Plotting Examples with R
33
Version: 0.1.0
4-
Description: Example script for a multi-panel scientific figure. This is not
5-
an R package: the file only lists the dependencies, so they can be
6-
installed with pak::local_install_deps() and in CI.
4+
Description: Example script for a multi-panel scientific figure, and a
5+
dashboard app for it (app/, needs the suggested packages bslib, ragg
6+
and shiny). This is not an R package: the file only lists the
7+
dependencies, so they can be installed with pak::local_install_deps()
8+
and in CI.
79
License: GPL-3
810
Encoding: UTF-8
911
Depends:
@@ -19,6 +21,9 @@ Imports:
1921
svglite,
2022
systemfonts
2123
Suggests:
24+
bslib,
25+
ragg,
26+
shiny,
2227
testthat (>= 3.0.0),
2328
vdiffr,
2429
withr

‎README.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,32 @@ This writes `figure_example.png`, `figure_example.svg` and `figure_example.pdf`.
2121

2222
To change font size, colours or model parameters, edit `opts` in `figure_example.R` (all settings and their defaults are listed in `figure_defaults()` in `R/figure.R`).
2323

24+
## Dashboard
25+
26+
`app/` is a Shiny app for the figure: change font size, colours, model parameters and which panels to show, and download the result as PNG or SVG. It uses the same plotting code (`R/figure.R`). To run it locally:
27+
28+
```r
29+
install.packages(c("shiny", "bslib", "ragg"))
30+
shiny::runApp("app")
31+
```
32+
2433
## Layout
2534

2635
| Path | Contents |
2736
|---|---|
2837
| `figure_example.R` | Script that builds and saves the figure |
2938
| `R/figure.R` | Functions for the figure: data loading, one function per panel, saving |
39+
| `app/` | Dashboard app (Shiny) |
3040
| `data/` | CSV data for panels B–F |
3141
| `images/` | Microscopy images for panel A |
3242
| `tests/` | Tests (see below) |
3343

3444
## Tests
3545

36-
The tests check the data loading, that every panel renders without warnings (also with non-default settings), the exported files, and how each panel looks (visual snapshots with vdiffr). They run on GitHub Actions for every push and pull request. To run them locally:
46+
The tests check the data loading, that every panel renders without warnings (also with non-default settings), the exported files, the dashboard's server logic, and how each panel looks (visual snapshots with vdiffr). They run on GitHub Actions for every push and pull request. To run them locally:
3747

3848
```r
39-
install.packages(c("testthat", "vdiffr", "withr"))
49+
install.packages(c("testthat", "vdiffr", "withr", "shiny", "bslib", "ragg"))
4050
```
4151

4252
```sh

‎app/app.R‎

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
# Dashboard for the figure: change its settings and download the result.
2+
# Run locally from the repository root with shiny::runApp("app").
3+
# The plots come from R/figure.R, the same code that figure_example.R runs.
4+
5+
library(shiny)
6+
library(bslib)
7+
8+
`%||%` <- function(x, y) if (is.null(x)) y else x # base R has it only from 4.4
9+
10+
# R/figure.R, data/ and images/ sit next to this file in the Shinylive bundle,
11+
# and one level up in the repository
12+
root <- if (file.exists("R/figure.R")) "." else ".."
13+
source(file.path(root, "R", "figure.R"), local = TRUE)
14+
15+
# ggplotGrob() in the panel builders draws on the current device. Use a null pdf device:
16+
# no Rplots.pdf, and no cairo device (cairo crashes in webR once magick is loaded).
17+
pdf(NULL)
18+
19+
fig_data <- load_figure_data(file.path(root, "data"), file.path(root, "images"))
20+
defaults <- figure_defaults()
21+
22+
# Settings each panel uses: a panel is rebuilt only when one of these changes
23+
panel_settings <- list(
24+
A = character(),
25+
B = c("base_size", "group_colours", "sigma", "show_inset", "show_polygons"),
26+
C = c("base_size", "group_colours", "jitter_seed", "point_alpha"),
27+
D = c("base_size", "shear_constant", "width_line", "velocity_line"),
28+
E = c("base_size", "decay", "ribbon_width"),
29+
F = c("base_size", "viridis_option")
30+
)
31+
panel_builders <- list(A = panel_a, B = panel_b, C = panel_c, D = panel_d, E = panel_e, F = panel_f)
32+
# Built panels, shared by all sessions. An explicit cache: Shinylive sets up no default app cache.
33+
panel_cache <- cachem::cache_mem(max_size = 100 * 1024^2)
34+
35+
viridis_options <- c("magma (A)" = "A", "inferno (B)" = "B", "plasma (C)" = "C", "viridis (D)" = "D",
36+
"cividis (E)" = "E", "rocket (F)" = "F", "mako (G)" = "G", "turbo (H)" = "H")
37+
38+
save_png <- function(plot, file, width, height, dpi) {
39+
ggsave(file, plot, width = width, height = height, dpi = dpi, bg = "white", device = ragg::agg_png)
40+
}
41+
42+
# Draws one panel in its place in the 3 x 2 figure, with empty cells around it, and trims the
43+
# white space. Drawn alone, the panel would not match the figure: patchwork aligns the panels,
44+
# and the insets in B and D are placed for the layout of the whole figure.
45+
save_panel_png <- function(panel, id, file, dpi) {
46+
spacer <- plot_spacer() + theme(aspect.ratio = 1) # all panels have aspect ratio 1
47+
cells <- rep(list(spacer), 6)
48+
cells[[match(id, LETTERS[1:6])]] <- panel + labs(tag = id)
49+
save_png(wrap_plots(cells, ncol = 3, nrow = 2), file, fig_width, fig_height, dpi)
50+
51+
image <- magick::image_trim(magick::image_read(file))
52+
image <- magick::image_border(image, "white", geometry = paste0(dpi %/% 8, "x", dpi %/% 8))
53+
magick::image_write(image, file, format = "png")
54+
}
55+
56+
# Colour input ----
57+
58+
# A native <input type="color"> with a small Shiny input binding (no extra package)
59+
colour_input <- function(id, label, value) {
60+
div(class = "form-group shiny-input-container",
61+
tags$label(`for` = id, class = "control-label", label),
62+
tags$input(id = id, type = "color", value = value, class = "scifig-colour form-control form-control-color"))
63+
}
64+
65+
colour_binding_js <- "
66+
const colourBinding = new Shiny.InputBinding();
67+
$.extend(colourBinding, {
68+
find: (scope) => $(scope).find('input.scifig-colour'),
69+
getValue: (el) => el.value,
70+
setValue: (el, value) => { el.value = value; },
71+
receiveMessage: (el, data) => { el.value = data.value; $(el).trigger('change'); },
72+
subscribe: (el, callback) => $(el).on('input.scifig change.scifig', () => callback(true)),
73+
unsubscribe: (el) => $(el).off('.scifig'),
74+
getRatePolicy: () => ({ policy: 'debounce', delay: 250 })
75+
});
76+
Shiny.inputBindings.register(colourBinding, 'scifig.colour');
77+
"
78+
79+
# UI ----
80+
81+
settings_panel <- accordion(
82+
open = "Figure",
83+
accordion_panel(
84+
"Figure",
85+
sliderInput("base_size", "Base font size", min = 8, max = 16, step = 0.5, value = defaults$base_size),
86+
helpText("The insets in panels B and D are placed for the default size; other sizes can shift labels."),
87+
checkboxGroupInput("panels", "Panels", choices = LETTERS[1:6], selected = defaults$panels, inline = TRUE),
88+
colour_input("colour1", "Colour 1 (B: apical side, C: mutant)", defaults$group_colours[1]),
89+
colour_input("colour2", "Colour 2 (B: basal side, C: wildtype)", defaults$group_colours[2])
90+
),
91+
accordion_panel(
92+
"B: neighbor numbers",
93+
sliderInput("sigma", "Lognormal sigma", min = 0.05, max = 0.5, step = 0.01, value = defaults$sigma),
94+
input_switch("show_inset", "Interior angle inset", value = defaults$show_inset),
95+
input_switch("show_polygons", "Polygons below the axis", value = defaults$show_polygons)
96+
),
97+
accordion_panel(
98+
"C: tracheal length",
99+
numericInput("jitter_seed", "Jitter seed", value = defaults$jitter_seed, min = 1, step = 1),
100+
sliderInput("point_alpha", "Point opacity", min = 0.05, max = 1, step = 0.05, value = defaults$point_alpha)
101+
),
102+
accordion_panel(
103+
"D: shear stress",
104+
numericInput("shear_constant", "Shear stress constant", value = defaults$shear_constant, min = 0, step = 0.5),
105+
helpText("shear stress = constant / (π · 18 · width²)"),
106+
numericInput("width_line", "Dashed line: lumen width b (µm)", value = defaults$width_line,
107+
min = 0.5, max = 50, step = 0.1),
108+
numericInput("velocity_line", "Dashed line: relative velocity", value = defaults$velocity_line,
109+
min = 0, max = 1, step = 0.05)
110+
),
111+
accordion_panel(
112+
"E: concentration",
113+
numericInput("decay1", "Decay constant, dashed curve (h)", value = defaults$decay[1], min = 1, step = 1),
114+
numericInput("decay2", "Decay constant, solid curve (h)", value = defaults$decay[2], min = 1, step = 1),
115+
numericInput("decay3", "Decay constant, dotted curve (h)", value = defaults$decay[3], min = 1, step = 1),
116+
sliderInput("ribbon_width", "Band around the solid curve (± %)", min = 0, max = 50, step = 1,
117+
value = defaults$ribbon_width * 100)
118+
),
119+
accordion_panel(
120+
"F: Hill coefficients",
121+
selectInput("viridis_option", "Colour palette", choices = viridis_options, selected = defaults$viridis_option)
122+
)
123+
)
124+
125+
ui <- page_sidebar(
126+
title = "Scientific figure dashboard",
127+
tags$head(tags$script(HTML(colour_binding_js))),
128+
sidebar = sidebar(
129+
width = 320,
130+
settings_panel,
131+
actionButton("reset", "Reset to defaults", icon = icon("rotate-left"))
132+
),
133+
navset_card_underline(
134+
nav_panel(
135+
"Figure",
136+
imageOutput("figure_preview", height = "auto"),
137+
card_footer(
138+
div(class = "d-flex flex-wrap gap-2 align-items-center",
139+
selectInput("png_dpi", NULL, width = "180px",
140+
choices = c("PNG, 96 dpi" = 96, "PNG, 150 dpi" = 150, "PNG, 300 dpi" = 300)),
141+
downloadButton("download_png", "Download PNG", class = "mb-3"),
142+
downloadButton("download_svg", "Download SVG", class = "mb-3"))
143+
)
144+
),
145+
nav_panel(
146+
"Panel",
147+
radioButtons("single_panel", NULL, choices = LETTERS[1:6], selected = "B", inline = TRUE),
148+
imageOutput("panel_preview", height = "auto")
149+
),
150+
nav_panel(
151+
"About",
152+
div(class = "p-2", style = "max-width: 700px;",
153+
p("This page shows the example figure from",
154+
a("scifig_plot_examples_R", href = "https://github.com/macromeer/scifig_plot_examples_R",
155+
target = "_blank"),
156+
"and lets you change its settings and download the result as PNG or SVG."),
157+
p("The plots are made by the same R code as", code("figure_example.R"),
158+
"(", code("R/figure.R"), "). The app runs R in your browser (webR), so nothing is sent to a server."),
159+
p("The figure is drawn at a fixed size (1148 × 686 px at 96 dpi), because the insets in panels B",
160+
"and D are positioned for that size. The downloads use the same size; higher dpi only adds pixels.",
161+
"Fonts can differ slightly from the PNG/SVG/PDF that the script writes.")
162+
)
163+
)
164+
)
165+
)
166+
167+
# Server ----
168+
169+
# Settings from the inputs. Inputs that are not set (yet) keep their default.
170+
read_opts <- function(input) {
171+
opts <- defaults
172+
set <- function(name, value) if (!is.null(value)) opts[[name]] <<- value
173+
set_element <- function(name, i, value) if (!is.null(value)) opts[[name]][i] <<- value
174+
for (name in c("base_size", "sigma", "show_inset", "show_polygons", "jitter_seed", "point_alpha",
175+
"shear_constant", "width_line", "velocity_line", "viridis_option")) {
176+
set(name, input[[name]])
177+
}
178+
for (i in 1:2) set_element("group_colours", i, input[[paste0("colour", i)]])
179+
for (i in 1:3) set_element("decay", i, input[[paste0("decay", i)]])
180+
if (!is.null(input$ribbon_width)) opts$ribbon_width <- input$ribbon_width / 100
181+
opts$panels <- input$panels %||% character() # unticking every box gives NULL
182+
opts
183+
}
184+
185+
# Messages for settings that can't be drawn
186+
check_opts <- function(opts) {
187+
numbers <- c(opts$jitter_seed, opts$shear_constant, opts$width_line, opts$velocity_line, opts$decay)
188+
c(
189+
if (anyNA(numbers)) "Fill in every number field.",
190+
if (length(opts$panels) == 0) "Select at least one panel.",
191+
if (isTRUE(opts$shear_constant <= 0)) "The shear stress constant must be positive.",
192+
if (isTRUE(opts$width_line < 0.5 || opts$width_line > 50)) "The lumen width line must be between 0.5 and 50 µm.",
193+
if (isTRUE(opts$velocity_line < 0 || opts$velocity_line > 1)) "The velocity line must be between 0 and 1.",
194+
if (isTRUE(any(opts$decay <= 0))) "Decay constants must be positive."
195+
)
196+
}
197+
198+
server <- function(input, output, session) {
199+
opts <- reactive({
200+
opts <- read_opts(input)
201+
problems <- check_opts(opts)
202+
validate(need(length(problems) == 0, paste(problems, collapse = "\n")))
203+
opts
204+
}) |> debounce(500)
205+
206+
# One cached reactive per panel, keyed by the settings that panel uses
207+
panels <- lapply(setNames(nm = names(panel_builders)), function(id) {
208+
reactive(panel_builders[[id]](fig_data, opts())) |>
209+
bindCache(opts()[panel_settings[[id]]], id, cache = panel_cache)
210+
})
211+
212+
figure <- reactive({
213+
selected <- opts()$panels
214+
build_figure(lapply(setNames(nm = selected), function(id) panels[[id]]()))
215+
})
216+
217+
output$figure_preview <- renderImage({
218+
file <- tempfile(fileext = ".png")
219+
save_png(figure(), file, fig_width, fig_height, dpi = 96)
220+
list(src = file, contentType = "image/png", alt = "Figure preview",
221+
style = "width: 100%; max-width: 1148px; height: auto;")
222+
}, deleteFile = TRUE)
223+
224+
output$panel_preview <- renderImage({
225+
file <- tempfile(fileext = ".png")
226+
# same size in inches as in the figure, more pixels: same layout, just bigger
227+
save_panel_png(panels[[input$single_panel]](), input$single_panel, file, dpi = 240)
228+
list(src = file, contentType = "image/png", alt = paste("Panel", input$single_panel),
229+
style = "width: 100%; max-width: 760px; height: auto;")
230+
}, deleteFile = TRUE)
231+
232+
output$download_png <- downloadHandler(
233+
filename = "figure.png",
234+
content = function(file) save_png(figure(), file, fig_width, fig_height, dpi = as.numeric(input$png_dpi %||% 96))
235+
)
236+
output$download_svg <- downloadHandler(
237+
filename = "figure.svg",
238+
content = function(file) {
239+
ggsave(file, figure(), width = fig_width, height = fig_height, bg = "white", device = svglite::svglite)
240+
}
241+
)
242+
243+
observeEvent(input$reset, {
244+
for (id in c("base_size", "sigma", "point_alpha")) updateSliderInput(session, id, value = defaults[[id]])
245+
updateSliderInput(session, "ribbon_width", value = defaults$ribbon_width * 100)
246+
for (id in c("jitter_seed", "shear_constant", "width_line", "velocity_line")) {
247+
updateNumericInput(session, id, value = defaults[[id]])
248+
}
249+
for (i in 1:3) updateNumericInput(session, paste0("decay", i), value = defaults$decay[i])
250+
for (id in c("show_inset", "show_polygons")) update_switch(id, value = defaults[[id]], session = session)
251+
updateCheckboxGroupInput(session, "panels", selected = defaults$panels)
252+
updateSelectInput(session, "viridis_option", selected = defaults$viridis_option)
253+
session$sendInputMessage("colour1", list(value = defaults$group_colours[1]))
254+
session$sendInputMessage("colour2", list(value = defaults$group_colours[2]))
255+
})
256+
}
257+
258+
shinyApp(ui, server)

‎tests/testthat/test-app.R‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Server logic of the dashboard (app/app.R), without a browser
2+
3+
skip_if_not_installed("shiny")
4+
skip_if_not_installed("bslib")
5+
skip_if_not_installed("ragg")
6+
7+
app <- shiny::shinyAppDir(file.path(repo_root, "app"))
8+
9+
# testServer() doesn't run the UI, so inputs start unset (= defaults), except the
10+
# panel checkboxes: unset is the same as none ticked
11+
start <- function(session, ...) {
12+
session$setInputs(panels = LETTERS[1:6], ...)
13+
session$elapse(600) # settings are debounced
14+
}
15+
16+
test_that("the app starts from the script's default settings", {
17+
shiny::testServer(app, {
18+
start(session)
19+
expect_equal(opts(), fig$figure_defaults())
20+
expect_s3_class(figure(), "patchwork")
21+
expect_match(output$figure_preview$src, "^data:image/png;base64,")
22+
})
23+
})
24+
25+
test_that("settings reach the panels", {
26+
shiny::testServer(app, {
27+
start(session, viridis_option = "B", ribbon_width = 30, colour1 = "#1b9e77", decay1 = 24,
28+
show_inset = FALSE)
29+
expect_equal(opts()$viridis_option, "B")
30+
expect_equal(opts()$ribbon_width, 0.3)
31+
expect_equal(opts()$group_colours, c("#1b9e77", fig$figure_defaults()$group_colours[2]))
32+
expect_equal(opts()$decay, c(24, 60, 72))
33+
expect_false(opts()$show_inset)
34+
for (id in names(panels)) expect_no_warning(ggplotGrob(panels[[id]]()), message = id)
35+
})
36+
})
37+
38+
test_that("only the selected panels are in the figure", {
39+
shiny::testServer(app, {
40+
session$setInputs(panels = c("B", "E"))
41+
session$elapse(600)
42+
expect_equal(opts()$panels, c("B", "E"))
43+
expect_no_warning(ggplotGrob(figure()))
44+
})
45+
})
46+
47+
test_that("settings that can't be drawn give a message instead of a figure", {
48+
shiny::testServer(app, {
49+
start(session, shear_constant = -1)
50+
expect_error(opts(), "shear stress constant must be positive")
51+
session$setInputs(shear_constant = 33.28, decay2 = NA)
52+
session$elapse(600)
53+
expect_error(opts(), "Fill in every number field")
54+
session$setInputs(decay2 = 60, panels = NULL)
55+
session$elapse(600)
56+
expect_error(opts(), "Select at least one panel")
57+
})
58+
})
59+
60+
test_that("the panel tab shows the chosen panel", {
61+
shiny::testServer(app, {
62+
start(session, single_panel = "D")
63+
expect_match(output$panel_preview$src, "^data:image/png;base64,")
64+
})
65+
})
66+
67+
test_that("the downloads write a PNG at the chosen resolution and an SVG", {
68+
shiny::testServer(app, {
69+
start(session, png_dpi = "150")
70+
png <- magick::image_info(magick::image_read(output$download_png))
71+
expect_equal(c(png$width, png$height), round(c(1148, 686) * 150 / 96), tolerance = 1)
72+
73+
svg <- readLines(output$download_svg, warn = FALSE)
74+
expect_match(svg[1], "^<\\?xml")
75+
expect_true(any(grepl("<svg", svg)))
76+
})
77+
})

0 commit comments

Comments
 (0)