Building a Shiny app
Megumi Oshima & Nicholas Ducharme-Barth
January 2025
Shiny is a package that can be used to build interactive web apps in either R or Python.
Let’s look at an example!
All Shiny apps need three components:
ui.R
): this is the interactive partserver.R
): this is the computation/plotting engineshiny::shinyApp()
(app.R
): this ties everything together and launches the appLet’s look closer at each component.
ui.R
App appearance (GUI & plot window) controlled by ui.R
, specifically pageWithSidebar()
.
Let’s focus on the GUI panel.
vars <- setdiff(names(iris), "Species")
pageWithSidebar(
headerPanel('Iris k-means clustering'),
sidebarPanel(
selectInput('xcol', 'X Variable', vars),
selectInput('ycol', 'Y Variable', vars, selected = vars[[2]]),
numericInput('clusters', 'Cluster count', 3, min = 1, max = 9)
),
mainPanel(
plotOutput('plot1')
)
)
headerPanel()
and sidebarPanel()
.plotOutput()
within mainPanel()
.server.R
reactive()
captures the inputs from the GUI.renderPlot()
produces the output.function(input, output) {
# Combine the selected variables into a new data frame
selectedData <- reactive({
iris[, c(input$xcol, input$ycol)]
})
clusters <- reactive({
kmeans(selectedData(), input$clusters)
})
output$plot1 <- renderPlot({
palette(c("#E41A1C", "#377EB8", "#4DAF4A", "#984EA3",
"#FF7F00", "#FFFF33", "#A65628", "#F781BF", "#999999"))
par(mar = c(5.1, 4.1, 0, 1))
plot(selectedData(),
col = clusters()$cluster,
pch = 20, cex = 3)
points(clusters()$centers, pch = 4, cex = 4, lwd = 4)
})
}
app.R
Running the app is straightforward.
Package all code and data in the same directory, this will make publishing the app easier.
Once your app is built it can hosted online via shinyapps.io (individuals) or Posit Connect (enterprise users).
In both cases, publishing can be done in a couple steps using the rsconnect
R package.
rsconnect::deployApp(appDir = '<project-dir>')
!Questions?
Let’s go back to our Stock Synthesis example and build a Shiny app to explore how changing the steepness impacted recruitment estimates.
#_____________________________________________________________________________________________________________________________
# load packages
library(data.table)
library(magrittr)
library(r4ss)
#_____________________________________________________________________________________________________________________________
# define paths
proj_dir = this.path::this.proj()
dir_model = paste0(proj_dir,"/stock-synthesis-models/")
#_____________________________________________________________________________________________________________________________
# get a vector of directories for models that have produced stock synthesis output
all_dirs = list.files(dir_model,recursive = TRUE)
# only keep model directories that contain a Report.sso file
all_dirs = all_dirs[grep("/Report.sso",all_dirs,fixed=TRUE)]
all_dirs = gsub("Report.sso","",all_dirs,fixed=TRUE)
#_____________________________________________________________________________________________________________________________
# extract output from models
output = SSgetoutput(dirvec=paste0(dir_model,all_dirs))
names(output) = gsub("/","",all_dirs)
# since we ran models with different steepness values lets get the steepness value that corresponds to each model
model_steepness = sapply(output,function(x)x[["parameters"]]["SR_BH_steep","Value"])
# we want to build a shiny app that shows the stock recruit curve for each model so we will need to extract the corresponding data
# expected stock recruit relationship (srr)
expected_srr_dt.list = as.list(rep(NA,length(all_dirs)))
for(i in seq_along(expected_srr_dt.list)){
expected_srr_dt.list[[i]] = as.data.table(output[[i]]$SPAWN_RECR_CURVE) %>%
.[,model_name:=names(output[i])] %>%
.[,.(model_name,SSB,Recruitment)] %>%
setnames(.,c("SSB","Recruitment"),c("ssb","rec_actual"))
}
expected_srr_dt = rbindlist(expected_srr_dt.list)
fwrite(expected_srr_dt,file=paste0(proj_dir,"shiny/shiny-data/expected_srr.csv"))
# annual estimated recruitments and corresponding spawning biomass
est_recruit_dt.list = as.list(rep(NA,length(all_dirs)))
for(i in seq_along(est_recruit_dt.list)){
est_recruit_dt.list[[i]] = as.data.table(output[[i]]$recruit) %>%
.[,model_name:=names(output[i])] %>%
.[era=="Main"] %>%
.[,.(model_name,Yr,SpawnBio,pred_recr)] %>%
setnames(.,c("Yr","SpawnBio","pred_recr"),c("yr","ssb","rec_estimated"))
}
est_recruit_dt = rbindlist(est_recruit_dt.list)
fwrite(est_recruit_dt,file=paste0(proj_dir,"shiny/shiny-data/est_recruit.csv"))
#_____________________________________________________________________________________________________________________________
# make summary files to run the shiny app from
summary_dt = data.table(model_name=names(output),steepness=model_steepness)
fwrite(summary_dt,file=paste0(proj_dir,"shiny/shiny-data/summary.csv"))
ui.R
library(shinydashboard)
library(shinyWidgets)
css <- htmltools::HTML(
"#summarytable > .dataTables_wrapper.no-footer > .dataTables_scroll > .dataTables_scrollBody {
transform:rotateX(180deg);
}
#summarytable > .dataTables_wrapper.no-footer > .dataTables_scroll > .dataTables_scrollBody table{
transform:rotateX(180deg);
}"
)
ui = shinydashboard::dashboardPage(
header = shinydashboard::dashboardHeader(title="Shiny demo"),
sidebar = shinydashboard::dashboardSidebar(
br(),
br(),
sidebarMenu(id="sidebarmenu",
menuItem("Introduction", tabName="introduction"),
menuItem("Summary table", tabName="table"),
menuItem("Stock-recruit relationship (SRR)", tabName="srr_plots")
),
# Only show these on the plotting tabs - not Introduction and Summary table tabs
conditionalPanel(condition="input.sidebarmenu == 'srr_plots'",
# srr-show-est
switchInput(
inputId = "srr_show_est",
label = "Show estimated recruitment",
value=TRUE,
onLabel = "TRUE",
offLabel = "FALSE",
onStatus = "success",
offStatus = "danger"),
# srr-est-type
awesomeRadio(
inputId = "srr_est_type",
label = "Plot estimated recruitment as:",
choices=c("Path","Points","Both"),
selected = "Path")
),
br(),
br(),
tags$footer(
div(style="text-align:center",
tags$p("version 0.0.1"),
tags$p(paste("Copyright", format(Sys.time(),"%Y"), "NOAA Fisheries, PIFSC Stock Assessment Group"))
)
)
), # End of sidebar
body = shinydashboard::dashboardBody(
tags$head(tags$style(HTML('.wrapper {height: auto !important; position:relative; overflow-x:hidden; overflow-y:hidden}') )),
tags$head(tags$style(css)),
# Start of main tab stuff
tabItems(
# **** Introduction ****
tabItem(tabName="introduction", h2("Introduction"),
fluidRow(column(12, includeMarkdown("./introduction_index.md")))
), # End of introduction tab
# **** Summary table ****
tabItem(tabName="table", h2("Summary table"),
fluidRow(box(title="Model metrics", collapsed=FALSE, solidHeader=TRUE, collapsible=TRUE, status="primary", width=12,
DT::dataTableOutput("summarytable")))
), # End of table tab
# **** Stock recruitment plots ****
tabItem(tabName="srr_plots", h2("Stock-recruitment plots"),
fluidRow(
box(title="Stock-recruitment relationship (SRR)", solidHeader=TRUE, collapsible=TRUE, collapsed=FALSE, status="primary", width=12,
p("Select at least one model."),
plotOutput("srr_plots", height="auto"))
)
) # End of srr_plots tab
) # End of tabItems
) # End of dashboardBody
)
server.R
library(data.table)
library(magrittr)
library(ggplot2)
library(viridis)
server = function(input, output){
# pixel height for each panel. i.e row height when plotting by species
height_per_panel = 350
# load data needed for shiny app
summary_dt = fread(file="./shiny-data/summary.csv")
ref_table_reduced = summary_dt %>%
as.data.frame(.)
output$summarytable = DT::renderDataTable({
summary_df = summary_dt %>%
as.data.frame(.,stringsAsFactors=FALSE)
summary_DT = DT::datatable(summary_df, filter = 'top',rownames=FALSE,
options = list(scrollX = TRUE, search = list(regex = TRUE, caseInsensitive = FALSE),pageLength = 25))
return(summary_DT)
})
outputOptions(output, "summarytable", suspendWhenHidden = FALSE)
filtered_id = reactive({
req(input$summarytable_rows_selected)
keep_models = c(ref_table_reduced[input$summarytable_rows_selected, ]$model_name)
return(keep_models)
})
# define plots
output$srr_plots = renderPlot({
input_models = unique(filtered_id())
if(length(input_models) < 1 ){
return(warning("Please select at least one model."))
}
expected_srr_dt = fread(file="./shiny-data/expected_srr.csv") %>%
.[model_name %in% input_models]
est_recruit_dt = fread(file="./shiny-data/est_recruit.csv") %>%
.[model_name %in% input_models]
p = expected_srr_dt %>%
ggplot() +
ylim(0,NA) +
xlab("Spawning biomass (SSB)") +
ylab("Recruitment") +
geom_path(aes(x=ssb,y=rec_actual,group=model_name,color=model_name),linewidth=1.5)
if(input$srr_show_est == "TRUE")
{
if(input$srr_est_type == "Path"){
p = p + geom_line(data=est_recruit_dt,aes(x=ssb,y=rec_estimated,group=model_name,color=model_name),alpha=0.5)
} else if(input$srr_est_type == "Points"){
p = p + geom_point(data=est_recruit_dt,aes(x=ssb,y=rec_estimated,group=model_name,fill=model_name),shape=21,cex=3)
} else {
p = p + geom_line(data=est_recruit_dt,aes(x=ssb,y=rec_estimated,group=model_name,color=model_name),alpha=0.5)
p = p + geom_point(data=est_recruit_dt,aes(x=ssb,y=rec_estimated,group=model_name,fill=model_name),shape=21,cex=3)
}
}
p = p + viridis::scale_color_viridis("Model",begin = 0.1,end = 0.8,direction = 1,option = "H",discrete=TRUE) +
viridis::scale_fill_viridis("Model",begin = 0.1,end = 0.8,direction = 1,option = "H",discrete=TRUE) +
theme(panel.background = element_rect(fill = "white", color = "black", linetype = "solid"),
panel.grid.major = element_line(color = 'gray70',linetype = "dotted"),
panel.grid.minor = element_line(color = 'gray70',linetype = "dotted"),
strip.background =element_rect(fill="white"),
legend.key = element_rect(fill = "white"))
return(p)
},
height=function(){
return((height_per_panel*1.5))
})
}
Let’s check out the app!
Using the Stock Synthesis model runs from the previous activity and building off of the Shiny code provided in the GitHub repo:
ISC Open Science Worflows Workshop 2025