3v324v23 commited on
Commit
00a07a4
·
1 Parent(s): 5a734e0

updated docker

Browse files
Files changed (2) hide show
  1. Dockerfile +13 -2
  2. app.R +658 -44
Dockerfile CHANGED
@@ -7,8 +7,19 @@ RUN install2.r --error \
7
  dplyr \
8
  ggplot2 \
9
  readr \
10
- ggExtra
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(bslib)
3
- library(dplyr)
4
- library(ggplot2)
5
-
6
- df <- readr::read_csv("penguins.csv")
7
- # Find subset of columns that are suitable for scatter plot
8
- df_num <- df |> select(where(is.numeric), -Year)
9
-
10
- ui <- page_sidebar(
11
- theme = bs_theme(bootswatch = "minty"),
12
- title = "Penguins explorer",
13
- sidebar = sidebar(
14
- varSelectInput("xvar", "X variable", df_num, selected = "Bill Length (mm)"),
15
- varSelectInput("yvar", "Y variable", df_num, selected = "Bill Depth (mm)"),
16
- checkboxGroupInput("species", "Filter by species",
17
- choices = unique(df$Species), selected = unique(df$Species)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  ),
19
- hr(), # Add a horizontal rule
20
- checkboxInput("by_species", "Show species", TRUE),
21
- checkboxInput("show_margins", "Show marginal plots", TRUE),
22
- checkboxInput("smooth", "Add smoother"),
23
  ),
24
- plotOutput("scatter")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if (input$show_margins) {
45
- margin_type <- if (input$by_species) "density" else "histogram"
46
- p <- p |> ggExtra::ggMarginal(
47
- type = margin_type, margins = "both",
48
- size = 8, groupColour = input$by_species, groupFill = input$by_species
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
- p
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  },
54
- res = 100
 
 
 
55
  )
56
  }
57
 
58
- shinyApp(ui, server)
 
 
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)