updated docker
Browse files- Dockerfile +13 -2
- app.R +658 -44
Dockerfile
CHANGED
@@ -7,8 +7,19 @@ RUN install2.r --error \
|
|
7 |
dplyr \
|
8 |
ggplot2 \
|
9 |
readr \
|
10 |
-
|
11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
COPY . .
|
13 |
|
14 |
CMD ["R", "--quiet", "-e", "shiny::runApp(host='0.0.0.0', port=7860)"]
|
|
|
7 |
dplyr \
|
8 |
ggplot2 \
|
9 |
readr \
|
10 |
+
httr \
|
11 |
+
jsonlite \
|
12 |
+
tidyverse \
|
13 |
+
viridis \
|
14 |
+
shinycssloaders \
|
15 |
+
DT \
|
16 |
+
maps \
|
17 |
+
mapdata \
|
18 |
+
leaflet \
|
19 |
+
leaflet.extras \
|
20 |
+
leaflet.extras \
|
21 |
+
shinythemes
|
22 |
+
|
23 |
COPY . .
|
24 |
|
25 |
CMD ["R", "--quiet", "-e", "shiny::runApp(host='0.0.0.0', port=7860)"]
|
app.R
CHANGED
@@ -1,58 +1,672 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
library(shiny)
|
2 |
-
library(
|
3 |
-
library(
|
4 |
-
library(
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
),
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
),
|
24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
)
|
26 |
|
27 |
server <- function(input, output, session) {
|
28 |
-
subsetted <- reactive({
|
29 |
-
req(input$species)
|
30 |
-
df |> filter(Species %in% input$species)
|
31 |
-
})
|
32 |
-
|
33 |
-
output$scatter <- renderPlot(
|
34 |
-
{
|
35 |
-
p <- ggplot(subsetted(), aes(!!input$xvar, !!input$yvar)) +
|
36 |
-
theme_light() +
|
37 |
-
list(
|
38 |
-
theme(legend.position = "bottom"),
|
39 |
-
if (input$by_species) aes(color = Species),
|
40 |
-
geom_point(),
|
41 |
-
if (input$smooth) geom_smooth()
|
42 |
-
)
|
43 |
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
}
|
51 |
|
52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
},
|
54 |
-
|
|
|
|
|
|
|
55 |
)
|
56 |
}
|
57 |
|
58 |
-
|
|
|
|
1 |
+
##################################################################
|
2 |
+
# Single R Script: Mortality Analysis + Shiny - Two-Click BBox
|
3 |
+
##################################################################
|
4 |
+
|
5 |
+
### 1) Install/Load Required Packages ####
|
6 |
+
required_packages <- c(
|
7 |
+
"httr", "jsonlite", "tidyverse", "glue", "lubridate",
|
8 |
+
"wesanderson", "viridis", "shiny", "shinycssloaders",
|
9 |
+
"DT", "maps", "mapdata", "leaflet", "leaflet.extras",
|
10 |
+
"shinythemes"
|
11 |
+
)
|
12 |
+
|
13 |
+
installed_packages <- rownames(installed.packages())
|
14 |
+
for (pkg in required_packages) {
|
15 |
+
if (!pkg %in% installed_packages) {
|
16 |
+
install.packages(pkg, dependencies = TRUE)
|
17 |
+
}
|
18 |
+
}
|
19 |
+
|
20 |
+
library(httr)
|
21 |
+
library(jsonlite)
|
22 |
+
library(tidyverse)
|
23 |
+
library(glue)
|
24 |
+
library(lubridate)
|
25 |
+
library(wesanderson)
|
26 |
+
library(viridis)
|
27 |
library(shiny)
|
28 |
+
library(shinycssloaders)
|
29 |
+
library(DT)
|
30 |
+
library(maps)
|
31 |
+
library(mapdata)
|
32 |
+
library(leaflet)
|
33 |
+
library(leaflet.extras)
|
34 |
+
library(shinythemes)
|
35 |
+
|
36 |
+
|
37 |
+
##################################################################
|
38 |
+
# 2) Mortality-Analysis Functions
|
39 |
+
##################################################################
|
40 |
+
|
41 |
+
fetch_dead_data_once <- function(
|
42 |
+
place_id = NULL,
|
43 |
+
swlat = NULL,
|
44 |
+
swlng = NULL,
|
45 |
+
nelat = NULL,
|
46 |
+
nelng = NULL,
|
47 |
+
start_date,
|
48 |
+
end_date,
|
49 |
+
iconic_taxa = "Aves",
|
50 |
+
per_page = 1000,
|
51 |
+
max_pages = 50
|
52 |
+
) {
|
53 |
+
base_url <- "https://api.inaturalist.org/v1/observations"
|
54 |
+
|
55 |
+
query_params <- glue(
|
56 |
+
"iconic_taxa={iconic_taxa}&",
|
57 |
+
"term_id=17&",
|
58 |
+
"term_value_id=19&",
|
59 |
+
"verifiable=true&",
|
60 |
+
"d1={start_date}&d2={end_date}&",
|
61 |
+
"order=desc&order_by=created_at&",
|
62 |
+
"per_page={per_page}"
|
63 |
+
)
|
64 |
+
|
65 |
+
loc_part <- ""
|
66 |
+
if (!is.null(place_id)) {
|
67 |
+
loc_part <- glue("&place_id={place_id}")
|
68 |
+
} else if (!is.null(swlat) && !is.null(swlng) &&
|
69 |
+
!is.null(nelat) && !is.null(nelng)) {
|
70 |
+
loc_part <- glue("&nelat={nelat}&nelng={nelng}&swlat={swlat}&swlng={swlng}")
|
71 |
+
} else {
|
72 |
+
stop("Must provide either 'place_id' OR bounding box (swlat, swlng, nelat, nelng).")
|
73 |
+
}
|
74 |
+
|
75 |
+
observations_list <- list()
|
76 |
+
current_page <- 1
|
77 |
+
|
78 |
+
while (current_page <= max_pages) {
|
79 |
+
query_url <- paste0(base_url, "?", query_params,
|
80 |
+
"&page=", current_page, loc_part)
|
81 |
+
|
82 |
+
message("Fetching page ", current_page,
|
83 |
+
" [", start_date, " to ", end_date, "]:\n", query_url)
|
84 |
+
|
85 |
+
resp <- GET(query_url)
|
86 |
+
if (http_error(resp)) {
|
87 |
+
warning("HTTP error on page ", current_page, ": ", status_code(resp))
|
88 |
+
break
|
89 |
+
}
|
90 |
+
|
91 |
+
parsed <- content(resp, as = "text", encoding = "UTF-8") %>%
|
92 |
+
fromJSON(flatten = TRUE)
|
93 |
+
|
94 |
+
if (length(parsed$results) == 0) {
|
95 |
+
message("No more results at page ", current_page)
|
96 |
+
break
|
97 |
+
}
|
98 |
+
|
99 |
+
obs_page_df <- as_tibble(parsed$results)
|
100 |
+
observations_list[[current_page]] <- obs_page_df
|
101 |
+
|
102 |
+
if (nrow(obs_page_df) < per_page) {
|
103 |
+
message("Reached last page of results at page ", current_page)
|
104 |
+
break
|
105 |
+
}
|
106 |
+
|
107 |
+
current_page <- current_page + 1
|
108 |
+
Sys.sleep(1) # polite pause for the API
|
109 |
+
}
|
110 |
+
|
111 |
+
observations_all <- bind_rows(observations_list)
|
112 |
+
return(observations_all)
|
113 |
+
}
|
114 |
+
|
115 |
+
|
116 |
+
fetch_dead_data_monthly <- function(
|
117 |
+
year,
|
118 |
+
place_id = NULL,
|
119 |
+
swlat = NULL,
|
120 |
+
swlng = NULL,
|
121 |
+
nelat = NULL,
|
122 |
+
nelng = NULL,
|
123 |
+
iconic_taxa = "Aves"
|
124 |
+
) {
|
125 |
+
monthly_list <- list()
|
126 |
+
|
127 |
+
for (month_i in 1:12) {
|
128 |
+
start_date <- as.Date(glue("{year}-{sprintf('%02d', month_i)}-01"))
|
129 |
+
end_date <- start_date %m+% months(1) %m-% days(1)
|
130 |
+
|
131 |
+
if (year(start_date) != year) break
|
132 |
+
|
133 |
+
message("\n--- Querying ", year, ", month ", month_i, " ---")
|
134 |
+
df_month <- fetch_dead_data_once(
|
135 |
+
place_id = place_id,
|
136 |
+
swlat = swlat,
|
137 |
+
swlng = swlng,
|
138 |
+
nelat = nelat,
|
139 |
+
nelng = nelng,
|
140 |
+
start_date = start_date,
|
141 |
+
end_date = end_date,
|
142 |
+
iconic_taxa= iconic_taxa
|
143 |
+
)
|
144 |
+
monthly_list[[month_i]] <- df_month
|
145 |
+
}
|
146 |
+
|
147 |
+
year_df <- bind_rows(monthly_list)
|
148 |
+
return(year_df)
|
149 |
+
}
|
150 |
+
|
151 |
+
|
152 |
+
getDeadVertebrates_monthlyLoop <- function(
|
153 |
+
years = c(2022, 2023),
|
154 |
+
place_id = NULL,
|
155 |
+
swlat = NULL,
|
156 |
+
swlng = NULL,
|
157 |
+
nelat = NULL,
|
158 |
+
nelng = NULL,
|
159 |
+
iconic_taxa = "Aves",
|
160 |
+
per_page = 1000,
|
161 |
+
max_pages = 50,
|
162 |
+
outdir = NULL
|
163 |
+
) {
|
164 |
+
all_years_list <- list()
|
165 |
+
for (yr in years) {
|
166 |
+
message("\n========= YEAR: ", yr, " ==========\n")
|
167 |
+
yr_df <- fetch_dead_data_monthly(
|
168 |
+
year = yr,
|
169 |
+
place_id = place_id,
|
170 |
+
swlat = swlat,
|
171 |
+
swlng = swlng,
|
172 |
+
nelat = nelat,
|
173 |
+
nelng = nelng,
|
174 |
+
iconic_taxa= iconic_taxa
|
175 |
+
) %>%
|
176 |
+
mutate(Window = as.character(yr))
|
177 |
+
|
178 |
+
all_years_list[[as.character(yr)]] <- yr_df
|
179 |
+
}
|
180 |
+
|
181 |
+
merged_df_all <- bind_rows(all_years_list)
|
182 |
+
|
183 |
+
if (!"created_at_details.date" %in% names(merged_df_all) ||
|
184 |
+
nrow(merged_df_all) == 0) {
|
185 |
+
daily_plot <- ggplot() +
|
186 |
+
labs(title = "No 'Dead' Observations Found", x = NULL, y = NULL) +
|
187 |
+
theme_void()
|
188 |
+
|
189 |
+
top_species_plot <- ggplot() +
|
190 |
+
labs(title = "No species data", x = NULL, y = NULL) +
|
191 |
+
theme_void()
|
192 |
+
|
193 |
+
map_hotspots_gg <- ggplot() +
|
194 |
+
labs(title = "No data for hotspots map") +
|
195 |
+
theme_void()
|
196 |
+
|
197 |
+
return(list(
|
198 |
+
merged_df = merged_df_all,
|
199 |
+
daily_plot = daily_plot,
|
200 |
+
top_species_plot = top_species_plot,
|
201 |
+
map_hotspots_gg = map_hotspots_gg
|
202 |
+
))
|
203 |
+
}
|
204 |
+
|
205 |
+
if (!is.null(outdir)) {
|
206 |
+
if (!dir.exists(outdir)) {
|
207 |
+
dir.create(outdir, recursive = TRUE)
|
208 |
+
}
|
209 |
+
readr::write_csv(merged_df_all, file.path(outdir, "merged_df_top_all_data.csv"))
|
210 |
+
}
|
211 |
+
|
212 |
+
counts_by_day <- merged_df_all %>%
|
213 |
+
mutate(obs_date = as.Date(`created_at_details.date`)) %>%
|
214 |
+
group_by(Window, obs_date) %>%
|
215 |
+
summarise(n = n(), .groups = "drop")
|
216 |
+
|
217 |
+
n_windows <- length(unique(counts_by_day$Window))
|
218 |
+
wes_colors <- wes_palette("Zissou1", n_windows, type = "discrete")
|
219 |
+
|
220 |
+
daily_plot <- ggplot(counts_by_day, aes(x = obs_date, y = n, color = Window)) +
|
221 |
+
geom_line(size = 1.2) +
|
222 |
+
geom_point(size = 2) +
|
223 |
+
scale_color_manual(values = wes_colors) +
|
224 |
+
scale_x_date(date_labels = "%b", date_breaks = "1 month") +
|
225 |
+
labs(
|
226 |
+
title = glue("Daily 'Dead' Observations (Years {paste(years, collapse=', ')})"),
|
227 |
+
x = "Month",
|
228 |
+
y = "Number of Observations",
|
229 |
+
color = "Year"
|
230 |
+
) +
|
231 |
+
theme_minimal(base_size = 14) +
|
232 |
+
theme(axis.text.x = element_text(angle = 45, hjust = 1))
|
233 |
+
|
234 |
+
if ("taxon.name" %in% names(merged_df_all)) {
|
235 |
+
species_counts <- merged_df_all %>%
|
236 |
+
filter(!is.na(taxon.name)) %>%
|
237 |
+
group_by(Window, taxon.name) %>%
|
238 |
+
summarise(dead_count = n(), .groups = "drop")
|
239 |
+
|
240 |
+
top_species_overall <- species_counts %>%
|
241 |
+
group_by(taxon.name) %>%
|
242 |
+
summarise(total_dead = sum(dead_count)) %>%
|
243 |
+
arrange(desc(total_dead)) %>%
|
244 |
+
slice_head(n = 20)
|
245 |
+
|
246 |
+
species_top20 <- species_counts %>%
|
247 |
+
filter(taxon.name %in% top_species_overall$taxon.name)
|
248 |
+
|
249 |
+
top_species_plot <- ggplot(species_top20, aes(
|
250 |
+
x = reorder(taxon.name, -dead_count),
|
251 |
+
y = dead_count,
|
252 |
+
fill= Window
|
253 |
+
)) +
|
254 |
+
geom_col(position = position_dodge(width = 0.7)) +
|
255 |
+
coord_flip() +
|
256 |
+
scale_fill_manual(values = wes_colors) +
|
257 |
+
labs(
|
258 |
+
title = "Top 20 Species with 'Dead' Observations",
|
259 |
+
x = "Species",
|
260 |
+
y = "Number of Dead Observations",
|
261 |
+
fill = "Year"
|
262 |
+
) +
|
263 |
+
theme_minimal(base_size = 14)
|
264 |
+
} else {
|
265 |
+
top_species_plot <- ggplot() +
|
266 |
+
labs(title = "No 'taxon.name' column found", x = NULL, y = NULL) +
|
267 |
+
theme_void()
|
268 |
+
}
|
269 |
+
|
270 |
+
daily_quantile <- quantile(counts_by_day$n, probs = 0.90, na.rm = TRUE)
|
271 |
+
high_mortality_days <- counts_by_day %>%
|
272 |
+
filter(n >= daily_quantile) %>%
|
273 |
+
pull(obs_date)
|
274 |
+
|
275 |
+
merged_high <- merged_df_all %>%
|
276 |
+
mutate(obs_date = as.Date(`created_at_details.date`)) %>%
|
277 |
+
filter(obs_date %in% high_mortality_days)
|
278 |
+
|
279 |
+
if ("location" %in% names(merged_high)) {
|
280 |
+
location_df <- merged_high %>%
|
281 |
+
filter(!is.na(location) & location != "") %>%
|
282 |
+
separate(location, into = c("lat_str", "lon_str"), sep = ",", remove = FALSE) %>%
|
283 |
+
mutate(
|
284 |
+
latitude = as.numeric(lat_str),
|
285 |
+
longitude = as.numeric(lon_str)
|
286 |
+
)
|
287 |
+
|
288 |
+
if (nrow(location_df) == 0) {
|
289 |
+
map_hotspots_gg <- ggplot() +
|
290 |
+
labs(title = "No data in top 90th percentile days with valid location") +
|
291 |
+
theme_void()
|
292 |
+
} else {
|
293 |
+
min_lon <- min(location_df$longitude, na.rm = TRUE)
|
294 |
+
max_lon <- max(location_df$longitude, na.rm = TRUE)
|
295 |
+
min_lat <- min(location_df$latitude, na.rm = TRUE)
|
296 |
+
max_lat <- max(location_df$latitude, na.rm = TRUE)
|
297 |
+
|
298 |
+
map_hotspots_gg <- ggplot(location_df, aes(x = longitude, y = latitude, color = Window)) +
|
299 |
+
borders("world", fill = "gray80", colour = "white") +
|
300 |
+
geom_point(alpha = 0.6, size = 2) +
|
301 |
+
scale_color_manual(values = wes_colors) +
|
302 |
+
coord_quickmap(xlim = c(min_lon, max_lon),
|
303 |
+
ylim = c(min_lat, max_lat),
|
304 |
+
expand = TRUE) +
|
305 |
+
labs(
|
306 |
+
title = glue("Top 90th percentile mortality days ({paste(years, collapse=', ')})"),
|
307 |
+
x = "Longitude",
|
308 |
+
y = "Latitude",
|
309 |
+
color = "Year"
|
310 |
+
) +
|
311 |
+
theme_minimal(base_size = 14)
|
312 |
+
}
|
313 |
+
} else {
|
314 |
+
map_hotspots_gg <- ggplot() +
|
315 |
+
labs(title = "No 'location' column for top 90% days map") +
|
316 |
+
theme_void()
|
317 |
+
}
|
318 |
+
|
319 |
+
if (!is.null(outdir)) {
|
320 |
+
if (!dir.exists(outdir)) {
|
321 |
+
dir.create(outdir, recursive = TRUE)
|
322 |
+
}
|
323 |
+
|
324 |
+
readr::write_csv(merged_high, file.path(outdir, "merged_df_top90.csv"))
|
325 |
+
ggsave(file.path(outdir, "daily_plot.png"),
|
326 |
+
daily_plot, width = 8, height = 5, dpi = 300)
|
327 |
+
ggsave(file.path(outdir, "top_species_plot.png"),
|
328 |
+
top_species_plot, width = 7, height = 7, dpi = 300)
|
329 |
+
ggsave(file.path(outdir, "map_hotspots.png"),
|
330 |
+
map_hotspots_gg, width = 8, height = 5, dpi = 300)
|
331 |
+
}
|
332 |
+
|
333 |
+
return(list(
|
334 |
+
merged_df = merged_high,
|
335 |
+
daily_plot = daily_plot,
|
336 |
+
top_species_plot = top_species_plot,
|
337 |
+
map_hotspots_gg = map_hotspots_gg,
|
338 |
+
daily_90th_quant = daily_quantile
|
339 |
+
))
|
340 |
+
}
|
341 |
+
|
342 |
+
|
343 |
+
##################################################################
|
344 |
+
# 3) Shiny App: UI + Server
|
345 |
+
##################################################################
|
346 |
+
|
347 |
+
ui <- fluidPage(
|
348 |
+
theme = shinytheme("flatly"),
|
349 |
+
|
350 |
+
# -- Logo and Title at the top --
|
351 |
+
fluidRow(
|
352 |
+
column(
|
353 |
+
width = 2,
|
354 |
+
# .all_logos.png in www folder
|
355 |
+
tags$img(src = "www/all_logos.png", height = "400px")
|
356 |
),
|
357 |
+
column(
|
358 |
+
width = 10,
|
359 |
+
titlePanel("Dead Wildlife Observations from iNaturalist")
|
360 |
+
)
|
361 |
),
|
362 |
+
hr(),
|
363 |
+
|
364 |
+
# Layout with a sidebar containing TABS: Query, About, Participatory, How to Use
|
365 |
+
sidebarLayout(
|
366 |
+
sidebarPanel(
|
367 |
+
tabsetPanel(
|
368 |
+
id = "sidebar_tabs",
|
369 |
+
|
370 |
+
# == Query Panel ==
|
371 |
+
tabPanel(
|
372 |
+
title = "Query",
|
373 |
+
br(),
|
374 |
+
radioButtons("query_mode", "Choose Query Mode:",
|
375 |
+
choices = c("Use place_id" = "place",
|
376 |
+
"Two clicks to define bounding box" = "twoclick")),
|
377 |
+
|
378 |
+
# If place ID chosen, show numeric input
|
379 |
+
conditionalPanel(
|
380 |
+
condition = "input.query_mode == 'place'",
|
381 |
+
numericInput("place_id", "place_id (e.g. 1 for USA, 6712 for Canada)",
|
382 |
+
value = 1, min = 1, max = 999999, step = 1)
|
383 |
+
),
|
384 |
+
|
385 |
+
# If two-click BBox chosen, show Leaflet & bounding box info
|
386 |
+
conditionalPanel(
|
387 |
+
condition = "input.query_mode == 'twoclick'",
|
388 |
+
helpText("Left-click once for the first corner, and once more for the opposite corner.
|
389 |
+
The SW and NE corners will be automatically computed."),
|
390 |
+
leafletOutput("map_two_click", height = "300px"),
|
391 |
+
br(),
|
392 |
+
actionButton("clear_bbox", "Clear bounding box"),
|
393 |
+
br(), br(),
|
394 |
+
verbatimTextOutput("bbox_coords")
|
395 |
+
),
|
396 |
+
|
397 |
+
checkboxGroupInput("years", "Select Year(s):",
|
398 |
+
choices = 2021:2025,
|
399 |
+
selected = c(2021, 2022, 2023)),
|
400 |
+
|
401 |
+
selectInput("iconic_taxon", "Select Taxonomic Group:",
|
402 |
+
choices = c("Aves", "Mammalia", "Reptilia", "Amphibia", "Actinopterygii", "Mollusca", "Animalia"),
|
403 |
+
selected = "Aves"),
|
404 |
+
|
405 |
+
actionButton("run_query", "Run Query", icon = icon("play")),
|
406 |
+
hr(),
|
407 |
+
downloadButton("downloadData", "Download Top-90% CSV", icon = icon("download"))
|
408 |
+
),
|
409 |
+
|
410 |
+
# == About Panel ==
|
411 |
+
tabPanel(
|
412 |
+
title = "About",
|
413 |
+
br(),
|
414 |
+
p("This Shiny application was created by Diego Ellis Soto (UC Berkeley).
|
415 |
+
It queries iNaturalist for observations that have been annotated as 'Dead' wildlife (term_id=17, term_value_id=19).
|
416 |
+
The data is fetched via the iNaturalist API and summarized here for scientific or conservation purposes.")
|
417 |
+
),
|
418 |
+
|
419 |
+
# == Participatory Science Panel ==
|
420 |
+
tabPanel(
|
421 |
+
title = "Participatory Science",
|
422 |
+
br(),
|
423 |
+
p("Citizen science platforms like iNaturalist allow everyday people to collect and share data about local biodiversity.
|
424 |
+
Recording observations of dead wildlife can help track mortality events, disease spread, and other factors affecting animal populations."),
|
425 |
+
p("We encourage everyone to contribute their sightings responsibly, ensuring that any data on roadkill or other mortalities can help conservation efforts and
|
426 |
+
raise public awareness.")
|
427 |
+
),
|
428 |
+
|
429 |
+
# == How To Use Panel ==
|
430 |
+
tabPanel(
|
431 |
+
title = "How to Use",
|
432 |
+
br(),
|
433 |
+
p("This application lets you retrieve data about dead wildlife observations from iNaturalist.
|
434 |
+
You can either specify a place_id (e.g., country or region) or define a custom bounding box with two clicks on the map.
|
435 |
+
After choosing which years and taxonomic group to query, press 'Run Query.'"),
|
436 |
+
p("These data are critical for understanding patterns of wildlife mortality, identifying hotspots of roadkill or disease, and informing conservation actions.
|
437 |
+
By systematically collecting and analyzing these records, conservation biologists and policymakers can make evidence-based decisions to protect wildlife populations.")
|
438 |
+
)
|
439 |
+
)
|
440 |
+
),
|
441 |
+
|
442 |
+
# Main panel with tabbed outputs
|
443 |
+
mainPanel(
|
444 |
+
tabsetPanel(
|
445 |
+
tabPanel("Daily Time Series", withSpinner(plotOutput("dailyPlot"), type = 6)),
|
446 |
+
tabPanel("Top Species", withSpinner(plotOutput("speciesPlot"), type = 6)),
|
447 |
+
tabPanel("Hotspots Map (90th%)", withSpinner(plotOutput("hotspotMap"), type = 6)),
|
448 |
+
tabPanel("Data Table (Top-90%)", withSpinner(DT::dataTableOutput("dataTable"), type = 6))
|
449 |
+
)
|
450 |
+
)
|
451 |
+
)
|
452 |
)
|
453 |
|
454 |
server <- function(input, output, session) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
455 |
|
456 |
+
# Reactive values to store bounding box corners from two clicks
|
457 |
+
rv <- reactiveValues(
|
458 |
+
corner1 = NULL, # first click: (lat, lng)
|
459 |
+
corner2 = NULL, # second click: (lat, lng)
|
460 |
+
bbox = NULL # computed bounding box: c(swlat, swlng, nelat, nelng)
|
461 |
+
)
|
462 |
+
|
463 |
+
# Render a simple Leaflet map (no draw toolbar).
|
464 |
+
output$map_two_click <- renderLeaflet({
|
465 |
+
leaflet() %>%
|
466 |
+
addTiles() %>%
|
467 |
+
setView(lng = -100, lat = 40, zoom = 4)
|
468 |
+
})
|
469 |
+
|
470 |
+
# Observe map clicks when "Two clicks to define bounding box" is selected
|
471 |
+
observeEvent(input$map_two_click_click, {
|
472 |
+
req(input$query_mode == "twoclick")
|
473 |
+
|
474 |
+
click <- input$map_two_click_click
|
475 |
+
if (is.null(click)) return()
|
476 |
+
|
477 |
+
lat_clicked <- click$lat
|
478 |
+
lng_clicked <- click$lng
|
479 |
+
|
480 |
+
# If corner1 is NULL, store the first corner
|
481 |
+
if (is.null(rv$corner1)) {
|
482 |
+
rv$corner1 <- c(lat_clicked, lng_clicked)
|
483 |
+
showNotification("First corner set. Now click for the opposite corner.")
|
484 |
+
|
485 |
+
# Add marker for the first corner
|
486 |
+
leafletProxy("map_two_click") %>%
|
487 |
+
clearMarkers() %>% # remove any old markers
|
488 |
+
addMarkers(lng = lng_clicked, lat = lat_clicked,
|
489 |
+
popup = "Corner 1")
|
490 |
+
|
491 |
+
rv$corner2 <- NULL
|
492 |
+
rv$bbox <- NULL
|
493 |
+
|
494 |
+
} else {
|
495 |
+
# This is the second corner
|
496 |
+
rv$corner2 <- c(lat_clicked, lng_clicked)
|
497 |
+
|
498 |
+
# We'll compute bounding box from the two corners
|
499 |
+
lat_min <- min(rv$corner1[1], rv$corner2[1])
|
500 |
+
lat_max <- max(rv$corner1[1], rv$corner2[1])
|
501 |
+
lng_min <- min(rv$corner1[2], rv$corner2[2])
|
502 |
+
lng_max <- max(rv$corner1[2], rv$corner2[2])
|
503 |
+
|
504 |
+
rv$bbox <- c(lat_min, lng_min, lat_max, lng_max)
|
505 |
+
|
506 |
+
showNotification("Second corner set. Bounding box defined!", duration = 2)
|
507 |
+
|
508 |
+
# Add marker for the second corner & a rectangle to visualize the bounding box
|
509 |
+
leafletProxy("map_two_click") %>%
|
510 |
+
clearMarkers() %>%
|
511 |
+
addMarkers(lng = rv$corner1[2], lat = rv$corner1[1],
|
512 |
+
popup = "Corner 1") %>%
|
513 |
+
addMarkers(lng = rv$corner2[2], lat = rv$corner2[1],
|
514 |
+
popup = "Corner 2") %>%
|
515 |
+
# draw rectangle
|
516 |
+
clearShapes() %>%
|
517 |
+
addRectangles(
|
518 |
+
lng1 = lng_min, lat1 = lat_min,
|
519 |
+
lng2 = lng_max, lat2 = lat_max,
|
520 |
+
fillColor = "red", fillOpacity = 0.2,
|
521 |
+
color = "red"
|
522 |
)
|
523 |
+
}
|
524 |
+
})
|
525 |
+
|
526 |
+
# Button to clear bounding box and reset corners
|
527 |
+
observeEvent(input$clear_bbox, {
|
528 |
+
rv$corner1 <- NULL
|
529 |
+
rv$corner2 <- NULL
|
530 |
+
rv$bbox <- NULL
|
531 |
+
|
532 |
+
leafletProxy("map_two_click") %>%
|
533 |
+
clearMarkers() %>%
|
534 |
+
clearShapes()
|
535 |
+
})
|
536 |
+
|
537 |
+
# Show the user the bounding box
|
538 |
+
output$bbox_coords <- renderText({
|
539 |
+
req(input$query_mode == "twoclick")
|
540 |
+
|
541 |
+
if (is.null(rv$bbox)) {
|
542 |
+
"No bounding box defined yet."
|
543 |
+
} else {
|
544 |
+
paste0(
|
545 |
+
"Bounding box:\n",
|
546 |
+
"SW corner: (", rv$bbox[1], ", ", rv$bbox[2], ")\n",
|
547 |
+
"NE corner: (", rv$bbox[3], ", ", rv$bbox[4], ")"
|
548 |
+
)
|
549 |
+
}
|
550 |
+
})
|
551 |
+
|
552 |
+
# Reactive value to store final query results
|
553 |
+
result_data <- reactiveVal(NULL)
|
554 |
+
|
555 |
+
# "Run Query" button
|
556 |
+
observeEvent(input$run_query, {
|
557 |
+
req(input$years)
|
558 |
+
validate(need(length(input$years) > 0, "Please select at least one year"))
|
559 |
+
|
560 |
+
yrs <- as.numeric(input$years)
|
561 |
+
|
562 |
+
withProgress(message = 'Fetching data from iNaturalist...', value = 0, {
|
563 |
+
incProgress(0.3)
|
564 |
+
|
565 |
+
if (input$query_mode == "place") {
|
566 |
+
# place_id mode
|
567 |
+
place_id_val <- input$place_id
|
568 |
+
swlat_val <- NULL
|
569 |
+
swlng_val <- NULL
|
570 |
+
nelat_val <- NULL
|
571 |
+
nelng_val <- NULL
|
572 |
+
|
573 |
+
} else {
|
574 |
+
# two-click bounding box
|
575 |
+
validate(need(!is.null(rv$bbox), "Please click twice on the map to define bounding box."))
|
576 |
+
|
577 |
+
place_id_val <- NULL
|
578 |
+
swlat_val <- rv$bbox[1]
|
579 |
+
swlng_val <- rv$bbox[2]
|
580 |
+
nelat_val <- rv$bbox[3]
|
581 |
+
nelng_val <- rv$bbox[4]
|
582 |
}
|
583 |
|
584 |
+
query_res <- getDeadVertebrates_monthlyLoop(
|
585 |
+
years = yrs,
|
586 |
+
place_id = place_id_val,
|
587 |
+
swlat = swlat_val,
|
588 |
+
swlng = swlng_val,
|
589 |
+
nelat = nelat_val,
|
590 |
+
nelng = nelng_val,
|
591 |
+
iconic_taxa = input$iconic_taxon,
|
592 |
+
outdir = NULL
|
593 |
+
)
|
594 |
+
|
595 |
+
result_data(query_res)
|
596 |
+
incProgress(1)
|
597 |
+
})
|
598 |
+
})
|
599 |
+
|
600 |
+
# (a) Daily Plot
|
601 |
+
output$dailyPlot <- renderPlot({
|
602 |
+
req(result_data())
|
603 |
+
result_data()$daily_plot
|
604 |
+
})
|
605 |
+
|
606 |
+
# (b) Top Species Plot
|
607 |
+
output$speciesPlot <- renderPlot({
|
608 |
+
req(result_data())
|
609 |
+
result_data()$top_species_plot
|
610 |
+
})
|
611 |
+
|
612 |
+
# (c) Hotspots Map
|
613 |
+
output$hotspotMap <- renderPlot({
|
614 |
+
req(result_data())
|
615 |
+
result_data()$map_hotspots_gg
|
616 |
+
})
|
617 |
+
|
618 |
+
# (d) Data Table (top-90% days)
|
619 |
+
output$dataTable <- DT::renderDataTable({
|
620 |
+
req(result_data())
|
621 |
+
df <- result_data()$merged_df
|
622 |
+
|
623 |
+
if (nrow(df) == 0) {
|
624 |
+
return(DT::datatable(data.frame(Message = "No records found"), options = list(pageLength = 5)))
|
625 |
+
}
|
626 |
+
|
627 |
+
df <- df %>%
|
628 |
+
mutate(
|
629 |
+
inat_link = paste0(
|
630 |
+
"<a href='https://www.inaturalist.org/observations/",
|
631 |
+
id, "' target='_blank'>", id, "</a>"
|
632 |
+
)
|
633 |
+
)
|
634 |
+
|
635 |
+
# If photos exist
|
636 |
+
photo_col <- "taxon.default_photo.square_url"
|
637 |
+
if (photo_col %in% names(df)) {
|
638 |
+
df$image_thumb <- ifelse(
|
639 |
+
!is.na(df[[photo_col]]) & df[[photo_col]] != "",
|
640 |
+
paste0("<img src='", df[[photo_col]], "' width='50'/>"),
|
641 |
+
"No Img"
|
642 |
+
)
|
643 |
+
} else {
|
644 |
+
df$image_thumb <- "No Img"
|
645 |
+
}
|
646 |
+
|
647 |
+
show_cols <- c(
|
648 |
+
"inat_link", "image_thumb", "taxon.name", "created_at_details.date",
|
649 |
+
setdiff(names(df), c("inat_link", "image_thumb", "taxon.name", "created_at_details.date"))
|
650 |
+
)
|
651 |
+
|
652 |
+
DT::datatable(
|
653 |
+
df[ , show_cols, drop = FALSE],
|
654 |
+
escape = FALSE,
|
655 |
+
options = list(pageLength = 10, autoWidth = TRUE)
|
656 |
+
)
|
657 |
+
})
|
658 |
+
|
659 |
+
# Download CSV handler
|
660 |
+
output$downloadData <- downloadHandler(
|
661 |
+
filename = function() {
|
662 |
+
paste0("inat_dead_top90_", Sys.Date(), ".csv")
|
663 |
},
|
664 |
+
content = function(file) {
|
665 |
+
req(result_data())
|
666 |
+
readr::write_csv(result_data()$merged_df, file)
|
667 |
+
}
|
668 |
)
|
669 |
}
|
670 |
|
671 |
+
# Run the Shiny app
|
672 |
+
shinyApp(ui = ui, server = server)
|