Appendix B — Sample Size Calculator
This online sample size calculator lets users adjust assumptions about project structure to estimate the sample size their study requires.
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 600
library(shiny)
library(ggplot2)
library(tibble)
library(scales)
library(ggthemes)
library(dplyr)
# Helps the Shinylive bundler include ggplot2 ecosystem deps that sometimes get missed
if (FALSE) {
library(munsell)
library(farver)
library(RColorBrewer)
library(viridisLite)
library(gtable)
}
# Fixed axis limits covering the full slider ranges
N_XMIN <- 20
N_XMAX <- 40000
MOE_YMIN <- 0
MOE_YMAX <- 15
COST_YMIN <- 0
COST_YMAX <- 900000
info_icon <- function(title, body) {
tags$details(
class = "moe-info",
tags$summary("i"),
tags$div(
class = "moe-info-box",
tags$div(class = "moe-info-title", title),
body
)
)
}
ui <- fluidPage(
tags$style(HTML("
.moe-wrap {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px;
}
.moe-left { flex: 0 0 240px; }
.moe-right { flex: 1 1 auto; min-width: 0; }
.moe-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px 12px;
}
.moe-n {
font-size: 28px;
font-weight: 700;
margin-top: 6px;
}
.moe-cost {
font-size: 24px;
font-weight: 700;
margin-top: 6px;
color: #2ca02c;
}
.moe-note {
color: #666;
font-size: 12px;
margin-top: 6px;
}
.form-group { margin-bottom: 8px; }
/* Tab styling */
.nav-tabs {
border-bottom: 1px solid #ddd;
margin-bottom: 12px;
}
.nav-tabs > li > a {
padding: 6px 12px;
font-size: 13px;
}
/* Plain HTML info disclosure (details/summary) */
details.moe-info {
display: inline-block;
position: relative;
margin-left: 6px;
vertical-align: middle;
}
details.moe-info > summary {
list-style: none;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid #bbb;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: #444;
background: #fff;
line-height: 1;
}
details.moe-info > summary::-webkit-details-marker { display: none; }
details.moe-info[open] > summary {
background: #f3f3f3;
border-color: #aaa;
}
.moe-info-box {
position: absolute;
left: 0;
top: calc(100% + 6px);
width: 230px;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
color: #222;
font-size: 12px;
line-height: 1.25;
box-shadow: 0 6px 18px rgba(0,0,0,0.12);
z-index: 9999;
}
.moe-info-title {
font-weight: 700;
margin-bottom: 4px;
}
/* Equation block */
.moe-eq {
color: #444;
margin-top: 6px;
}
.moe-eq .MathJax {
font-size: 110% !important;
}
/* Cost breakdown text */
.cost-breakdown {
color: #555;
font-size: 12px;
margin-top: 10px;
line-height: 1.4;
}
@media (max-width: 700px) {
.moe-wrap { flex-direction: column; }
.moe-left { flex: 1 1 auto; width: 100%; }
.moe-info-box { width: 100%; }
}
")),
# Load MathJax inside the app context (important for Shinylive)
shiny::withMathJax(),
div(
class = "moe-wrap",
div(
class = "moe-left",
tabsetPanel(
id = "main_tabs",
# Sample Size Tab
tabPanel(
"Sample Size",
div(
class = "moe-card",
tags$strong("Required sample size"),
div(class = "moe-n", textOutput("n_needed", inline = TRUE)),
div(class = "moe-note", textOutput("assumptions", inline = TRUE))
),
sliderInput(
"moe",
label = tagList(
"Margin of error (%)",
info_icon(
"Margin of error",
tagList(
"Half-width of an approximate confidence interval for a proportion. ",
"MOE = 3% means ±3 percentage points at the chosen confidence level."
)
)
),
min = 1, max = 15, value = 3, step = 0.5, post = "%"
),
sliderInput(
"deff",
label = tagList(
"Design effect (DEFF)",
info_icon(
"Design effect (DEFF)",
tagList(
"Inflates variance due to clustering and/or unequal weights. ",
"DEFF = 2.5 is consistent with a clustered and/or unequally weighted design that is less statistically efficient than SRS, with variance 2.5x larger."
)
)
),
min = 1, max = 4, value = 2.5, step = 0.1
),
sliderInput(
"prop",
label = tagList(
"Proportion (p)",
info_icon(
"Proportion (p)",
tagList(
"Anticipated proportion. Required sample size is proportional to p(1-p). ",
"Worst-case is near p = 50%."
)
)
),
min = 1, max = 99, value = 50, step = 1, post = "%"
),
radioButtons(
"conf",
label = tagList(
"Confidence level",
info_icon(
"Confidence level",
tagList(
"Controls the z value (normal quantile) used in the MOE formula. ",
"Higher confidence means larger z and a larger required sample size."
)
)
),
choices = c("90%" = 0.90, "95%" = 0.95, "99%" = 0.99),
selected = 0.95,
inline = TRUE
)
),
# Cost Tab
tabPanel(
"Cost",
div(
class = "moe-card",
tags$strong("Total study cost"),
div(class = "moe-cost", textOutput("total_cost", inline = TRUE)),
div(class = "moe-note", textOutput("cost_note", inline = TRUE))
),
sliderInput(
"cost_planning",
label = "Planning & Pilot ($)",
min = 0, max = 500000, value = 246069, step = 1000, pre = "$"
),
sliderInput(
"cost_dissemination",
label = "Dissemination ($)",
min = 0, max = 200000, value = 102742, step = 500, pre = "$"
),
sliderInput(
"cost_analysis",
label = "Analysis & Reporting ($)",
min = 0, max = 400000, value = 241755, step = 1000, pre = "$"
),
sliderInput(
"cost_qa",
label = "Monitoring & QA ($)",
min = 0, max = 150000, value = 59768, step = 500, pre = "$"
),
sliderInput(
"cost_per_child",
label = "Variable cost per child ($)",
min = 1, max = 50, value = 20, step = 0.5, pre = "$"
)
)
)
),
div(
class = "moe-right",
# Sample Size plot and formula
conditionalPanel(
condition = "input.main_tabs == 'Sample Size'",
plotOutput("curve", height = "360px", width = "100%"),
div(class = "moe-note", "Formula:"),
div(class = "moe-note", textOutput("formula_note", inline = TRUE)),
div(
class = "moe-eq",
shiny::withMathJax(HTML(
"$$\\mathrm{MOE}(n) = z\\,\\sqrt{\\frac{\\mathrm{D}_{eff}\\times\\,p(1-p)}{n}}$$"
))
)
),
# Cost plot and breakdown
conditionalPanel(
condition = "input.main_tabs == 'Cost'",
plotOutput("cost_curve", height = "360px", width = "100%"),
div(class = "cost-breakdown", textOutput("cost_breakdown", inline = TRUE))
)
)
)
)
server <- function(input, output, session) {
p_target <- reactive(input$prop / 100)
moe_target <- reactive(input$moe / 100)
z_value <- reactive({
conf <- as.numeric(input$conf)
stats::qnorm(0.5 + conf / 2)
})
n_required <- reactive({
p <- p_target()
z <- z_value()
ceiling(input$deff * (z^2 * p * (1 - p)) / (moe_target()^2))
})
# Cost calculations
fixed_cost <- reactive({
input$cost_planning + input$cost_dissemination + input$cost_analysis + input$cost_qa
})
total_cost_at_n <- reactive({
n <- n_required()
fixed_cost() + (input$cost_per_child * n)
})
# MOE helper function
moe_for_n <- function(n, deff, p, z) {
z * sqrt(deff * p * (1 - p) / n) * 100
}
# Cost helper function
cost_for_n <- function(n, fixed, per_child) {
fixed + (per_child * n)
}
# Sample Size outputs
output$n_needed <- renderText({
format(n_required(), big.mark = ",")
})
output$assumptions <- renderText({
paste0(
"Uses p = ", input$prop, "%, DEFF = ", format(input$deff, trim = TRUE),
", and z = ", format(round(z_value(), 3), nsmall = 3), "."
)
})
output$formula_note <- renderText({
paste0("Dot marks your chosen MOE (", input$moe, "%).")
})
# Sample Size plot
output$curve <- renderPlot({
n0 <- n_required()
p0 <- p_target()
z0 <- z_value()
n_seq <- unique(round(exp(seq(log(N_XMIN), log(N_XMAX), length.out = 250))))
moe_seq <- moe_for_n(n_seq, input$deff, p0, z0)
tibble(n = n_seq, moe = moe_seq) |>
ggplot(aes(n, moe)) +
geom_line() +
labs(
title = "Sample Size Required",
x = "Sample size (n)",
y = "Margin of error (%)"
) +
geom_point(
data = tibble(n = n0, moe = input$moe),
size = 3,
color = "red"
) +
scale_x_log10(
limits = c(N_XMIN, N_XMAX),
breaks = c(30, 100, 500, 1000, 3000, 10000)
) +
scale_y_continuous(limits = c(MOE_YMIN, MOE_YMAX)) +
ggthemes::theme_fivethirtyeight(base_family = "sans") +
theme(axis.title = element_text())
}, res = 96)
# Cost outputs
output$total_cost <- renderText({
scales::dollar(total_cost_at_n())
})
output$cost_note <- renderText({
n <- n_required()
var_total <- input$cost_per_child * n
paste0(
"Fixed: ", scales::dollar(fixed_cost()),
" | Variable: ", scales::dollar(var_total)
)
})
output$cost_breakdown <- renderText({
n <- n_required()
fixed <- fixed_cost()
var_total <- input$cost_per_child * n
total <- fixed + var_total
paste0(
"Fixed: ", scales::dollar(fixed),
" | Variable (", scales::dollar(input$cost_per_child), " × ", format(n, big.mark = ","), "): ",
scales::dollar(var_total),
" | Total: ", scales::dollar(total)
)
})
# Cost plot
output$cost_curve <- renderPlot({
n0 <- n_required()
fixed <- fixed_cost()
per_child <- input$cost_per_child
n_seq <- unique(round(exp(seq(log(N_XMIN), log(N_XMAX), length.out = 250))))
fixed_seq <- rep(fixed, length(n_seq))
total_seq <- cost_for_n(n_seq, fixed, per_child)
tibble(
n = n_seq,
cost = total_seq,
type = "Total"
) |>
bind_rows(
tibble(n = n_seq, cost = fixed_seq, type = "Fixed")
) |>
ggplot(aes(n, cost, color = type, linetype = type)) +
geom_line() +
labs(
title = "Study Cost Estimation",
x = "Sample size (n)",
y = "Cost ($)",
color = NULL,
linetype = NULL
) +
geom_point(
data = tibble(
n = n0,
cost = fixed + (per_child * n0),
type = "Total"
),
size = 3,
color = I("red"),
show.legend = FALSE
) +
scale_x_log10(
limits = c(N_XMIN, N_XMAX),
breaks = c(30, 100, 500, 1000, 3000, 10000)
) +
scale_y_continuous(
limits = c(COST_YMIN, COST_YMAX),
labels = scales::dollar
) +
scale_color_manual(values = c("Total" = "#1f77b4", "Fixed" = "#aec7e8")) +
scale_linetype_manual(values = c("Total" = "solid", "Fixed" = "dashed")) +
ggthemes::theme_fivethirtyeight(base_family = "sans") +
theme(
axis.title = element_text(),
legend.position = "bottom"
)
}, res = 96)
}
shinyApp(ui, server)