This is an R Markdown document. Markdown is a simple formatting syntax for authoring HTML, PDF, and MS Word documents. For more details on using R Markdown see http://rmarkdown.rstudio.com.
When you click the Knit button a document will be generated that includes both content as well as the output of any embedded R code chunks within the document. You can embed an R code chunk like this:
library(tidyverse)
## Warning: package 'ggplot2' was built under R version 4.5.3
## Warning: package 'readr' was built under R version 4.5.3
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.2.0 ✔ readr 2.2.0
## ✔ forcats 1.0.1 ✔ stringr 1.6.0
## ✔ ggplot2 4.0.2 ✔ tibble 3.3.1
## ✔ lubridate 1.9.5 ✔ tidyr 1.3.2
## ✔ purrr 1.2.1
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(shiny)
## Warning: package 'shiny' was built under R version 4.5.3
library(leaflet)
library(dplyr)
library(ggplot2)
library(scales)
##
## Attaching package: 'scales'
##
## The following object is masked from 'package:purrr':
##
## discard
##
## The following object is masked from 'package:readr':
##
## col_factor
library(tidyr)
library(stringr)
library(plotly)
## Warning: package 'plotly' was built under R version 4.5.3
##
## Attaching package: 'plotly'
##
## The following object is masked from 'package:ggplot2':
##
## last_plot
##
## The following object is masked from 'package:stats':
##
## filter
##
## The following object is masked from 'package:graphics':
##
## layout
branch_info <- read_csv("tpl-branch-general-information - 4326.csv")
## Rows: 112 Columns: 28
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (12): BranchCode, BranchName, Address, PostalCode, Website, Telephone, P...
## dbl (16): _id, PhysicalBranch, SquareFootage, KidsStop, LeadingReading, CLC,...
##
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
visits <- read_csv("tpl-visits-annual-by-branch.csv")
## Rows: 1233 Columns: 4
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (1): BranchCode
## dbl (3): _id, Year, Visits
##
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
events <- read_csv("tpl-events-feed.csv")
## Rows: 8512 Columns: 17
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (9): EventID, Title, LocationName, Audiences, Languages, EventTypes, St...
## dbl (1): _id
## lgl (3): IsRecurring, IsFull, RegistrationClosed
## dttm (3): StartTime, EndTime, LastUpdatedOn
## date (1): StartDateLocal
##
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
population <- read_csv("population.csv")
## New names:
## • `` -> `...3`
## • `` -> `...4`
## Warning: One or more parsing issues, call `problems()` on your data frame for details,
## e.g.:
## dat <- vroom(...)
## problems(dat)
## Rows: 1658 Columns: 4
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (1): area_code
## num (1): Population, 2021
## lgl (2): ...3, ...4
##
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
events <- events %>%
filter(year(as.Date(StartDateLocal)) == 2026)
branch_lookup <- branch_info %>%
transmute(
BranchCode = as.character(BranchCode),
BranchName = trimws(as.character(BranchName)),
Address = as.character(Address),
WardName = as.character(WardName),
NBHDName = as.character(NBHDName),
ServiceTier = as.character(`Service Tier`),
Lat = as.numeric(Lat),
Long = as.numeric(Long),
PostalCode = str_to_upper(str_trim(as.character(PostalCode))),
area_code = str_sub(str_replace_all(PostalCode, " ", ""), 1, 3)
)
population_clean <- population %>%
transmute(
area_code = str_to_upper(str_trim(as.character(area_code))),
Population2021 = as.numeric(str_replace_all(`Population, 2021`, ",", ""))
)
branch_lookup <- branch_lookup %>%
left_join(population_clean, by = "area_code") %>%
distinct(BranchCode, .keep_all = TRUE)
## Warning in left_join(., population_clean, by = "area_code"): Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 6 of `x` matches multiple rows in `y`.
## ℹ Row 1647 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
## "many-to-many"` to silence this warning.
visits2 <- visits %>%
transmute(
VisitYear = as.integer(Year),
BranchCode = as.character(BranchCode),
Visits = as.numeric(Visits)
)
annual_data <- visits2 %>%
left_join(branch_lookup, by = "BranchCode") %>%
filter(!is.na(Lat), !is.na(Long), !is.na(Visits), !is.na(VisitYear))
map_points <- annual_data %>%
distinct(BranchCode, BranchName, Long, Lat) %>%
filter(!is.na(Long), !is.na(Lat))
lng_range <- range(map_points$Long, na.rm = TRUE)
lat_range <- range(map_points$Lat, na.rm = TRUE)
lng_pad <- diff(lng_range) * 0.08
lat_pad <- diff(lat_range) * 0.08
visit_years <- sort(unique(annual_data$VisitYear), decreasing = TRUE)
events_raw <- events
if ("Status" %in% names(events_raw)) {
events_raw <- events_raw %>%
filter(is.na(Status) | Status == "ACTIVE")
}
events_base <- events_raw %>%
transmute(
LocationName = trimws(as.character(LocationName)),
StartDateLocal = as.character(StartDateLocal),
EventTypes = as.character(EventTypes),
Audiences = as.character(Audiences)
) %>%
mutate(
LocationName = recode(
LocationName,
"Daniel G. Hill" = "Daniel G. Hill (formely Jane/Dundas)",
"Dufferin/St.Clair" = "Dufferin/St. Clair",
"Ethennonnhawahstihnen'" = "Ethennonnhawahstihnen' (formely Bayview)",
"Junction Triangle" = "Junction Triangle (formely Perth/Dupont)",
"Junction Triangle - Closed" = "Junction Triangle (formely Perth/Dupont)"
),
EventTypes = ifelse(is.na(EventTypes) | EventTypes == "", "Unknown", EventTypes),
Audiences = ifelse(is.na(Audiences) | Audiences == "", "Unknown", Audiences),
EventYear = suppressWarnings(as.integer(stringr::str_extract(StartDateLocal, "(19|20)\\d{2}")))
) %>%
left_join(
branch_lookup %>% select(BranchCode, BranchName),
by = c("LocationName" = "BranchName")
) %>%
filter(!is.na(BranchCode), !is.na(EventYear))
events_type_long <- events_base %>%
separate_rows(EventTypes, sep = ",\\s*") %>%
mutate(EventTypes = str_squish(EventTypes)) %>%
filter(!is.na(EventTypes), EventTypes != "")
events_audience_long <- events_base %>%
separate_rows(Audiences, sep = ",\\s*") %>%
mutate(Audiences = str_squish(Audiences)) %>%
filter(!is.na(Audiences), Audiences != "")
event_years <- sort(unique(stats::na.omit(events_base$EventYear)), decreasing = TRUE)
safe_rescale <- function(x, to = c(6, 16)) {
x <- as.numeric(x)
if (length(x) == 0) return(numeric(0))
if (all(is.na(x))) return(rep(mean(to), length(x)))
rng <- range(x, na.rm = TRUE)
if (rng[1] == rng[2]) return(rep(mean(to), length(x)))
scales::rescale(x, to = to, from = rng)
}
ui <- fluidPage(
tags$head(
tags$style(HTML("
body {
background: #f5f7fb;
color: #1f2937;
font-family: Arial, Helvetica, sans-serif;
}
.container-fluid {
max-width: 1380px;
margin: 0 auto;
padding: 24px 24px 10px 24px;
}
.app-title {
font-size: 34px;
font-weight: 700;
margin-bottom: 6px;
color: #0f172a;
}
.app-subtitle {
font-size: 15px;
color: #475569;
margin-bottom: 22px;
max-width: 900px;
}
.panel-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 18px;
padding: 18px 18px 10px 18px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
margin-bottom: 18px;
}
.section-title {
font-size: 19px;
font-weight: 700;
margin-bottom: 4px;
color: #0f172a;
}
.section-note {
font-size: 13px;
color: #64748b;
margin-bottom: 12px;
}
.metric-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 12px 14px;
margin-bottom: 12px;
}
.metric-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 4px;
}
.metric-value {
font-size: 24px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
}
.metric-sub {
font-size: 13px;
color: #475569;
margin-top: 3px;
}
.leaflet-container {
border-radius: 14px;
}
.control-label {
color: #334155;
font-weight: 600;
}
"))
),
div(class = "app-title", "Public Libraries as Civic Hubs"),
div(
class = "app-subtitle",
"Explore the public libraries across Toronto. The map shows annual visits, and the charts summarize the selected branch's event."
),
fluidRow(
column(
width = 3,
div(
class = "panel-card",
div(class = "section-title", "Filters"),
div(class = "section-note", "Use one year for visits on the map and another for event programming."),
selectInput(
"visit_year",
"Visits year",
choices = visit_years,
selected = max(visit_years)
),
selectInput(
"event_year",
"Events year",
choices = event_years,
selected = if (length(event_years) > 0) max(event_years) else NULL
),
hr(),
htmlOutput("branch_info")
)
),
column(
width = 9,
div(
class = "panel-card",
div(class = "section-title", "Branch Visits Map"),
div(class = "section-note", "Larger and darker points represent more visits. Click a branch to update the detail panels and comparison charts."),
leafletOutput("map", height = 450)
)
)
),
fluidRow(
column(width = 4, uiOutput("summary_cards")),
column(
width = 8,
div(
class = "panel-card",
div(class = "section-title", "Top Event Types"),
div(class = "section-note", "Sorted bars make branch-level differences easier to compare than unordered categories."),
plotlyOutput("bar_chart", height = 420)
)
)
),
fluidRow(
column(
width = 7,
div(
class = "panel-card",
div(class = "section-title", "Audience Mix"),
div(class = "section-note", "Audience categories are shown as a ranked bar chart so size comparisons are immediate and labels remain readable."),
plotlyOutput("audience_chart", height = 380)
)
),
column(
width = 5,
div(
class = "panel-card",
div(class = "section-title", "Audience Distribution"),
plotlyOutput("pie_chart", height = 420)
)
)
)
)
server <- function(input, output, session) {
selected_branch <- reactiveVal(NULL)
filtered_map <- reactive({
annual_data %>%
filter(VisitYear == as.integer(input$visit_year))
})
selected_branch_meta <- reactive({
req(selected_branch())
branch_lookup %>%
filter(BranchCode == selected_branch()) %>%
slice(1)
})
selected_branch_visits <- reactive({
req(selected_branch(), input$visit_year)
annual_data %>%
filter(
BranchCode == selected_branch(),
VisitYear == as.integer(input$visit_year)
) %>%
slice(1)
})
selected_branch_events <- reactive({
req(selected_branch(), input$event_year)
events_base %>%
filter(
BranchCode == selected_branch(),
EventYear == as.integer(input$event_year)
)
})
bar_data <- reactive({
req(selected_branch(), input$event_year)
df <- events_type_long %>%
filter(
BranchCode == selected_branch(),
EventYear == as.integer(input$event_year)
) %>%
count(EventTypes, name = "count", sort = TRUE)
if (nrow(df) == 0) return(df)
if (nrow(df) > 8) {
top_names <- df$EventTypes[1:8]
df <- df %>%
mutate(EventTypes = ifelse(EventTypes %in% top_names, EventTypes, "Other")) %>%
group_by(EventTypes) %>%
summarise(count = sum(count), .groups = "drop") %>%
arrange(desc(count))
}
df %>%
mutate(
share = count / sum(count),
label = paste0(comma(count), " (", percent(share, accuracy = 0.1), ")")
)
})
pie_data <- reactive({
req(selected_branch(), input$event_year)
events_audience_long %>%
filter(
BranchCode == selected_branch(),
EventYear == as.integer(input$event_year)
) %>%
count(Audiences, name = "count", sort = TRUE)
})
audience_data <- reactive({
req(selected_branch(), input$event_year)
df <- events_audience_long %>%
filter(
BranchCode == selected_branch(),
EventYear == as.integer(input$event_year)
) %>%
count(Audiences, name = "count", sort = TRUE)
if (nrow(df) == 0) return(df)
if (nrow(df) > 8) {
top_names <- df$Audiences[1:8]
df <- df %>%
mutate(Audiences = ifelse(Audiences %in% top_names, Audiences, "Other")) %>%
group_by(Audiences) %>%
summarise(count = sum(count), .groups = "drop") %>%
arrange(desc(count))
}
df %>%
mutate(
share = count / sum(count),
label = paste0(comma(count), " (", percent(share, accuracy = 0.1), ")")
)
})
output$map <- renderLeaflet({
leaflet() %>%
addProviderTiles(providers$CartoDB.PositronNoLabels) %>%
fitBounds(
lng1 = lng_range[1] - lng_pad,
lat1 = lat_range[1] - lat_pad,
lng2 = lng_range[2] + lng_pad,
lat2 = lat_range[2] + lat_pad
)
})
observe({
df <- filtered_map()
sel_code <- selected_branch()
if (nrow(df) == 0) {
leafletProxy("map") %>% clearMarkers() %>% clearControls()
return()
}
radii <- safe_rescale(df$Visits, to = c(7, 18))
pal <- colorNumeric(
palette = "Blues",
domain = log1p(annual_data$Visits),
na.color = "#cbd5e1"
)
df <- df %>%
mutate(
is_selected = if (is.null(sel_code)) FALSE else BranchCode == sel_code,
border_col = ifelse(is_selected, "#0f172a", "white"),
border_wt = ifelse(is_selected, 3.5, 1.3),
point_radius = ifelse(is_selected, radii + 3, radii)
)
leafletProxy("map", data = df) %>%
clearMarkers() %>%
clearControls() %>%
addCircleMarkers(
lng = ~Long,
lat = ~Lat,
layerId = ~BranchCode,
radius = ~point_radius,
fillColor = ~pal(log1p(Visits)),
fillOpacity = 0.9,
color = ~border_col,
weight = ~border_wt,
stroke = TRUE,
opacity = 1,
label = ~paste0(BranchName, " — ", comma(Visits), " visits"),
popup = ~paste0(
"<strong>", BranchName, "</strong><br>",
"Visits year: ", VisitYear, "<br>",
"Visits: ", comma(Visits), "<br>"
)
) %>%
addLegend(
position = "bottomright",
pal = pal,
values = log1p(annual_data$Visits),
title = "Annual visits",
opacity = 1,
labFormat = function(type, cuts, p) {
format(round(expm1(cuts)), big.mark = ",")
}
)
})
observeEvent(input$map_marker_click, {
selected_branch(input$map_marker_click$id)
}, ignoreInit = TRUE)
output$branch_info <- renderUI({
if (is.null(selected_branch())) {
HTML("<b>Selected branch:</b> None<br><span style='color:#64748b;'>Click a point on the map to see branch details.</span>")
} else {
branch_meta <- selected_branch_meta()
visit_row <- selected_branch_visits()
total_events <- nrow(selected_branch_events())
visit_text <- if (nrow(visit_row) == 0) "No visits data" else comma(visit_row$Visits)
pop_text <- if (is.na(branch_meta$Population2021)) "Not available" else comma(branch_meta$Population2021)
HTML(paste0(
"<b>Selected branch:</b> ", branch_meta$BranchName, "<br>",
"<b>Address:</b> ", branch_meta$Address, "<br>",
"<b>Neighbourhood:</b> ", branch_meta$NBHDName, "<br>",
"<b>Ward:</b> ", branch_meta$WardName, "<br>",
"<b>Service tier:</b> ", branch_meta$ServiceTier, "<br>",
"<b>Area population (FSA):</b> ", pop_text, "<br>",
"<b>", input$visit_year, " visits:</b> ", visit_text, "<br>",
"<b>", input$event_year, " events:</b> ", comma(total_events)
))
}
})
output$summary_cards <- renderUI({
if (is.null(selected_branch())) {
return(
div(
class = "panel-card",
div(class = "section-title", "Branch summary"),
div(class = "section-note", "Select a branch on the map to populate the summary cards.")
)
)
}
branch_meta <- selected_branch_meta()
visit_row <- selected_branch_visits()
event_rows <- selected_branch_events()
visits_value <- if (nrow(visit_row) == 0) NA_real_ else visit_row$Visits[[1]]
area_pop <- branch_meta$Population2021[[1]]
visits_per_capita <- if (is.na(visits_value) || is.na(area_pop) || area_pop == 0) NA_real_ else visits_value / area_pop
top_type <- bar_data()
top_type_label <- if (nrow(top_type) == 0) "No data" else as.character(top_type$EventTypes[[1]])
top_type_count <- if (nrow(top_type) == 0) "" else paste0(comma(top_type$count[[1]]), " events")
audience_top <- audience_data()
audience_label <- if (nrow(audience_top) == 0) "No data" else as.character(audience_top$Audiences[[1]])
audience_count <- if (nrow(audience_top) == 0) "" else paste0(comma(audience_top$count[[1]]), " tags")
tagList(
div(
class = "panel-card",
div(class = "section-title", paste0(selected_branch_meta()$BranchName, " summary")),
div(class = "metric-card",
div(class = "metric-label", paste(input$visit_year, "visits")),
div(class = "metric-value", ifelse(is.na(visits_value), "N/A", comma(visits_value))),
div(class = "metric-sub", "Annual branch visits shown on the map")
),
div(class = "metric-card",
div(class = "metric-label", paste(input$event_year, "events")),
div(class = "metric-value", comma(nrow(event_rows))),
div(class = "metric-sub", "Total scheduled programs matched to this branch")
),
div(class = "metric-card",
div(class = "metric-label", "Visits per resident"),
div(class = "metric-value", ifelse(is.na(visits_per_capita), "N/A", number(visits_per_capita, accuracy = 0.01))),
div(class = "metric-sub", "Visits divided by local FSA population")
),
div(class = "metric-card",
div(class = "metric-label", "Most common event type"),
div(class = "metric-value", top_type_label),
div(class = "metric-sub", top_type_count)
),
div(class = "metric-card",
div(class = "metric-label", "Largest audience group"),
div(class = "metric-value", audience_label),
div(class = "metric-sub", audience_count)
)
)
)
})
output$bar_chart <- plotly::renderPlotly({
if (is.null(selected_branch())) {
return(plot_ly() %>%
layout(
annotations = list(list(
x = 0.5, y = 0.5,
text = "Click a branch on the map",
showarrow = FALSE,
xref = "paper", yref = "paper",
font = list(size = 18, color = "#64748b")
)),
xaxis = list(visible = FALSE),
yaxis = list(visible = FALSE),
paper_bgcolor = "white",
plot_bgcolor = "white"
) %>%
config(displayModeBar = FALSE))
}
df <- bar_data()
if (nrow(df) == 0) {
return(plot_ly() %>%
layout(
annotations = list(list(
x = 0.5, y = 0.5,
text = "No event type data for this branch and year",
showarrow = FALSE,
xref = "paper", yref = "paper",
font = list(size = 18, color = "#64748b")
)),
xaxis = list(visible = FALSE),
yaxis = list(visible = FALSE),
paper_bgcolor = "white",
plot_bgcolor = "white"
) %>%
config(displayModeBar = FALSE))
}
df$EventTypes <- factor(df$EventTypes, levels = rev(df$EventTypes))
plot_ly(
data = df,
x = ~count,
y = ~EventTypes,
type = "bar",
orientation = "h",
marker = list(color = "#2563eb"),
text = ~label,
textposition = "outside",
cliponaxis = FALSE,
hovertemplate = paste0(
"<b>%{y}</b><br>",
"Count: %{x:,}<br>",
"Share: %{customdata}<extra></extra>"
),
customdata = ~percent(share, accuracy = 0.1)
) %>%
layout(
title = list(
text = paste0(selected_branch_meta()$BranchName, " — Event types in ", input$event_year),
x = 0.02,
xanchor = "left",
font = list(size = 18)
),
xaxis = list(
title = "Number of event tags",
showgrid = TRUE,
gridcolor = "#e2e8f0",
zeroline = FALSE,
rangemode = "tozero"
),
yaxis = list(
title = "",
automargin = TRUE,
categoryorder = "array",
showgrid = FALSE
),
margin = list(l = 210, r = 130, t = 60, b = 55),
paper_bgcolor = "white",
plot_bgcolor = "white",
showlegend = FALSE
) %>%
config(displayModeBar = FALSE)
})
output$audience_chart <- plotly::renderPlotly({
if (is.null(selected_branch())) {
return(plot_ly() %>%
layout(
annotations = list(list(
x = 0.5, y = 0.5,
text = "Click a branch on the map",
showarrow = FALSE,
xref = "paper", yref = "paper",
font = list(size = 18, color = "#64748b")
)),
xaxis = list(visible = FALSE),
yaxis = list(visible = FALSE),
paper_bgcolor = "white",
plot_bgcolor = "white"
) %>%
config(displayModeBar = FALSE))
}
df <- audience_data()
if (nrow(df) == 0) {
return(plot_ly() %>%
layout(
annotations = list(list(
x = 0.5, y = 0.5,
text = "No audience data for this branch and year",
showarrow = FALSE,
xref = "paper", yref = "paper",
font = list(size = 18, color = "#64748b")
)),
xaxis = list(visible = FALSE),
yaxis = list(visible = FALSE),
paper_bgcolor = "white",
plot_bgcolor = "white"
) %>%
config(displayModeBar = FALSE))
}
df$Audiences <- factor(df$Audiences, levels = rev(df$Audiences))
plot_ly(
data = df,
x = ~count,
y = ~Audiences,
type = "bar",
orientation = "h",
marker = list(color = "#0f766e"),
text = ~label,
textposition = "outside",
cliponaxis = FALSE,
hovertemplate = paste0(
"<b>%{y}</b><br>",
"Count: %{x:,}<br>",
"Share: %{customdata}<extra></extra>"
),
customdata = ~percent(share, accuracy = 0.1)
) %>%
layout(
title = list(
text = paste0(selected_branch_meta()$BranchName, " — Audience mix in ", input$event_year),
x = 0.02,
xanchor = "left",
font = list(size = 18)
),
xaxis = list(
title = "Number of audience tags",
showgrid = TRUE,
gridcolor = "#e2e8f0",
zeroline = FALSE,
rangemode = "tozero"
),
yaxis = list(
title = "",
automargin = TRUE,
showgrid = FALSE
),
margin = list(l = 190, r = 130, t = 60, b = 55),
paper_bgcolor = "white",
plot_bgcolor = "white",
showlegend = FALSE
) %>%
config(displayModeBar = FALSE)
})
output$pie_chart <- plotly::renderPlotly({
if (is.null(selected_branch())) {
return(NULL)
}
df <- pie_data()
if (nrow(df) == 0) {
return(NULL)
}
df <- df %>%
mutate(
percent = count / sum(count),
hover_text = paste0(
"Event type: ", Audiences,
"<br>Count: ", scales::comma(count),
"<br>Percentage: ", scales::percent(percent, accuracy = 0.1)
)
)
plotly::plot_ly(
data = df,
labels = ~Audiences,
values = ~count,
type = "pie",
hole = 0.6,
textinfo = "label",
hoverinfo = "text",
text = ~hover_text
) %>%
plotly::layout(
title = paste(
"Audience Distribution:",
selected_branch_meta()$BranchName, "-", input$event_year
),
legend = list(title = list(text = "Audience Group"))
)
})
}
shinyApp(ui = ui, server = server)