diff --git a/.gitignore b/.gitignore
index 7c5fc96..734def0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ token-*.json
# Artefactos de build de Docker
*.tar
*.tar.gz
+.positai
diff --git a/PelletMap.Rproj b/PelletMap.Rproj
index cd14864..c30d55e 100644
--- a/PelletMap.Rproj
+++ b/PelletMap.Rproj
@@ -1,17 +1,17 @@
-Version: 1.0
-ProjectId: b8faca93-f9c8-44db-8c21-c65405149399
-
-RestoreWorkspace: Default
-SaveWorkspace: Default
-AlwaysSaveHistory: Default
-
-EnableCodeIndexing: Yes
-UseSpacesForTab: Yes
-NumSpacesForTab: 2
-Encoding: UTF-8
-
-RnwWeave: knitr
-LaTeX: pdfLaTeX
-
-AutoAppendNewline: Yes
-StripTrailingWhitespace: Yes
+Version: 1.0
+ProjectId: b8faca93-f9c8-44db-8c21-c65405149399
+
+RestoreWorkspace: Default
+SaveWorkspace: Default
+AlwaysSaveHistory: Default
+
+EnableCodeIndexing: Yes
+UseSpacesForTab: Yes
+NumSpacesForTab: 2
+Encoding: UTF-8
+
+RnwWeave: knitr
+LaTeX: pdfLaTeX
+
+AutoAppendNewline: Yes
+StripTrailingWhitespace: Yes
diff --git a/app.R b/app.R
index be48901..f9b2ad2 100644
--- a/app.R
+++ b/app.R
@@ -1,630 +1,594 @@
-# load required packages
-library(magrittr)
-library(dplyr)
-library(tibble)
-if(!require("leaflet")) install.packages("leaflet")
-library(shiny)
-library(shinyWidgets)
-library(shinydashboard)
-library(shinythemes)
-library(shinyjs)
-library(htmltools)
-library(readr)
-library(ggplot2)
-library(shinycssloaders)
-
-source("data_loader.R")
-
-sidebar <- dashboardSidebar(
- width = 400,
- tags$head(tags$style(HTML(".sidebar { height: auto; overflow-y}" ))),
- tags$style(HTML(".sidebar-menu { margin-bottom: 20px; }")), # Add margin to sidebar-menu
- sidebarMenu(id = "sidebarID",
- menuItem("Mapa en tempo real", tabName = "map"),
- menuItem("Datos e estadísticas", tabName = "info", badgeLabel = "Novo!", badgeColor = "green"),
- #menuItem("Formulario de recollida de datos", icon = icon("info"), newtab = TRUE, href = "https://docs.google.com/forms/u/1/d/e/1FAIpQLScHqNH3yxk5yBKhOMZ0mVk0Wl-bNLCowqW9UFr0mo2Hj7klGA/formResponse"),
- menuItem("Filtrado de información", tabName = "filter",
- tags$style(HTML(".sidebar-menu .treeview-menu { padding-top: 5px; padding-bottom: 5px }")), # Add margin to shiny-input-container
- sliderInput(
- inputId = "dateRange",
- label = "Filtrado por data:",
- min = as.Date("2024-01-01"),
- max = Sys.Date() + 2,
- value = c(Sys.Date() - 30, Sys.Date() + 1)
- ),
- pickerInput("legendFilter",
- "Filtrar por tipo de actualización:",
- choices = c(
- "Pellets" = "Hai pellets na praia",
- "Biosoportes" = "Hai biosoportes",
- "Chapapote" = "Hai chapapote",
- "Praia limpa" = "A praia está limpa",
- "Xa non hai (a praia quedaba limpa)" = "Xa non hai (a praia quedaba limpa cando se encheu o formulario)",
- "Convocatoria de limpeza" = "Convocatoria de xornada de limpeza",
- "Outras Convocatorias" = "Outras Convocatorias"
- ), multiple = TRUE,
- selected = c(
- "Hai pellets na praia",
- "Hai biosoportes",
- "Hai chapapote",
- "A praia está limpa",
- "Xa non hai (a praia quedaba limpa cando se encheu o formulario)",
- "Convocatoria de xornada de limpeza",
- "Outras Convocatorias"
- )),
- selectInput(inputId = "select_provincia", label = "Provincia", multiple = TRUE, choices=c(), selected=""),
- selectInput(inputId = "select_concello", label = "Concello", multiple = TRUE, choices=c(), selected="")
- ),
- menuItem("Información adicional", tabName = "info_add", startExpanded = TRUE,
- uiOutput("sidebarContent")),
- menuItem("Agradecementos", tabName = "ack")
- )
-)
-body <- dashboardBody(
- tags$head(tags$style(HTML('.skin-blue .main-header .logo {background-color: #3c8dbc;} .skin-blue .main-header .logo:hover {background-color: #3c8dbc;}'))),
- tags$style(HTML(".leaflet { height: calc(100vh - 250px) !important; }")),
- tags$style(HTML(".treeview-menu>li>a { padding-left: 10px; }")), # Add padding to sidebarMenu icons
- tags$style(HTML(".box-header { font-size: 15px; }")),
- tabItems(
- tabItem(tabName = "ack",
- column(12,
- HTML(paste("Este proxecto é posible grazas a colaboración e recollida de datos de voluntarias; de Ana e Miguel das Chas; das voluntarias de Noia Limpa; e a cesión de recursos por parte de ", "Gradiant"))
- )
- ),
- tabItem(tabName = "map",
- column(12, h4("Mapa en tempo real da situación das praias galegas")),
- #column(12, paste("Seguemento cidadán do estado das praias galegas despois do vertido de pellets.")),
- column(12, paste("O mapa reflexa as actualizacións do estado das praias enviadas polos voluntarios. Podes atopar información estadística sobre as praias afectadas no menú Datos e estadísticas do panel lateral.")),
- column(12, paste("Emprega o panel lateral para filtrar a información por tipo de vertido, concello, provincia ou data de actualización. Por defecto, amósase a información da última semana. ")),
- column(12, HTML("
")),
- column(12, leafletOutput("mymap"))
- ),
- tabItem(tabName = "info",
- tags$style(HTML(".box-header { font-size: 10px; }")), # Adjust the font size as needed
- column(12, h4("Datos e estadísticas")),
- column(12, HTML(paste("Todos os datos recóllense de xeito colaborativo por voluntarios e entidades colaboradoras a través deste ", "formulario."))),
- column(12, HTML(paste("Os seguintes datos e estadísticas calcúlanse de forma automática - para os filtros seleccionados no panel lateral - e poden contar erros debido ao reporte non estandarizado do nome da praia por parte dos usuarios do formulario."))),
- column(12, HTML("
")),
- fluidRow(
- column(12,
- box(status = "primary", HTML("
Num. Concellos
"), HTML("con actualizacións recibidas
"),
- withSpinner(valueBoxOutput("n_concellos")),
- width = 3),
- box(status = "primary", HTML("Num. Praias
"), HTML("con actualizacións recibidas
"),
- withSpinner(valueBoxOutput("n_praias")), width = 3),
- box(status = "primary", HTML("Total actualizacións
"), HTML("recibidas no formulario
"),
- withSpinner(valueBoxOutput("n_update")),
- width = 3),
- box(status = "primary", HTML("Notificacións 112
"), HTML("Total actualizacións recibidas
"),
- withSpinner(valueBoxOutput("n_112")),
- width = 3)
- )
- ),
- #column(12, h4("Reconto por tipo de actualización:")),
- fluidRow(
- column(12,
- box(status = "primary", HTML("Praias con Residuos
"), HTML("Total actualizacións recibidas
"),
- withSpinner(valueBoxOutput("n_pellets")), width = 4),
- box(status = "primary", HTML("Praia limpa
"), HTML("Total actualizacións recibidas
"),
- withSpinner(valueBoxOutput("n_limpas")), width = 4),
- box(status = "primary", HTML("Outros eventos
"), HTML("Total actualizacións recibidas
"),
- withSpinner(valueBoxOutput("n_limpezas")), width = 4)
- )
- ),
- fluidRow(
- column(12,
- box(status = "primary",
- HTML("Evolución diaria - Actualizacións recibidas por tipo de residuo
"),
- withSpinner(plotOutput("cum_residuos")), width = 12)
- #box(status = "primary", HTML("Top 5 Concellos
"), withSpinner(plotOutput("top5_concellos")), width = 6)
- )
- ),
- fluidRow(
- column(12,
- box(status = "primary",
- HTML("Evolución diaria - Actualizacións Recibidas
"),
- withSpinner(plotOutput("cum_praias")), width = 12)
- #box(status = "primary", HTML("Top 5 Concellos
"), withSpinner(plotOutput("top5_concellos")), width = 6)
- )
- )
- )
- ),
- useShinyjs()
-)
-
-ui <- tagList(dashboardPage(skin = "black",
- dashboardHeader(
- title = "Unha Vez Máis",
- titleWidth = 400
- ),
- sidebar = sidebar,
- body = body
- ),
- tags$footer(
- style = "text-align: center; padding: 10px; background-color: #fff; height:auto",
- "Author: ", tags$a("Inés Ortega Fernández", href = "https://inesortega.github.io/"),
- " | Contact: ", tags$a("datospellets@gmail.com", href = "datospellets@gmail.com")
- )
-)
-server <- function(input, output, session) {
-
- updateTimestamp <- reactiveValues(time = Sys.time())
- last_run_time <- reactiveValues(time = Sys.time())
- isFirstRun <- reactiveVal(TRUE)
-
-
- ########### REACTIVE ELEMENTS ##############
- # Create a reactiveValu object to store the clicked marker information
- clickedMarker <- reactiveVal(NULL)
-
- data <- reactive({
- invalidateLater(350000)
- tryCatch({
- all_data <- read_csv("./data/praias.csv", show_col_types = FALSE)
- all_data <- all_data %>%
- mutate(
- Data.Norm = case_when(
- Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Convocatoria de xornada de limpeza", "Outras Convocatorias") ~ as.Date(Data.da.Convocatoria, "%Y-%m-%dd", tz="UTC"),
- TRUE ~ as.Date(Marca.temporal, "%Y-%m-%dd", tz="UTC")) # Default case
- ) %>%
- mutate(
- Residuo = case_when(
- Tipo.de.actualización.que.nos.queres.facer.chegar == "Hai pellets na praia" ~ "Pellets",
- Tipo.de.actualización.que.nos.queres.facer.chegar == "Hai biosoportes" ~ "Biosoportes",
- Tipo.de.actualización.que.nos.queres.facer.chegar == "Hai chapapote" ~ "Chapapote") # Default case
- )
-
- all_data <- all_data %>%
- mutate(Nome.da.praia..Concello = ifelse(str_detect(Nome.da.praia..Concello, ","),
- Nome.da.praia..Concello,
- paste(Nome.da.praia..Concello, Concello, sep = ', ')))
-
-
- all_data$Data.Norm <- as.Date(all_data$Data.Norm)
- all_data$id <- seq.int(nrow(all_data))
- }, error = function(e){
- message("Error loading data...")
- })
- # Filter data based on selected date range
- max <- max(as.Date(all_data$Data.Norm))
- min <- min(as.Date(all_data$Data.Norm))
- updateSliderInput(session, inputId = "dateRange", min=min, max=max)
-
- if (!is.null(input$dateRange)) {
- start_date <- input$dateRange[1]
- end_date <- input$dateRange[2]
- filtered_data <- all_data %>%
- filter(Data.Norm >= start_date & Data.Norm <= end_date)
- } else {
- filtered_data <- all_data
- }
-
- # Filter data based on selected provincia
- updateSelectInput(session,"select_provincia",choices=setdiff(unique(filtered_data$Provincia), "FALSE"), select = input$select_provincia)
-
- if(!is.null(input$select_provincia)){
- filtered_data <- filtered_data %>% filter(Provincia %in% input$select_provincia)
- }
-
- # Filter data based on selected concello
- updateSelectInput(session,"select_concello",choices=unique(filtered_data$Concello), select = input$select_concello)
-
- if(!is.null(input$select_concello)){
- filtered_data <- filtered_data %>% filter(Concello %in% input$select_concello)
- }
-
- filtered_data <- filtered_data %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% input$legendFilter)
-
- filtered_data
- })
-
- # Update data and sidebar when date filter is changed
- observeEvent(input$dateRange, {
- # Check if the clicked marker is still valid based on the filtered data
- if (!is.null(clickedMarker())){
- if(nrow(subset(data(), id == clickedMarker()$id)) == 0){
- # not valid
- clickedMarker(NULL)
- output$sidebarContent <- NULL
- }
- }
- })
-
- # Update the clicked marker information when a marker is clicked
- observeEvent(input$mymap_marker_click, {
- marker_info <- input$mymap_marker_click
- clickedMarker(marker_info)
- })
-
- # Update sidebar content if valid marker is clicked
- observe({
- if(!is.null(clickedMarker())){
- marker_id <- clickedMarker()$id
- info <- data() %>%
- filter(id == clickedMarker()$id)
- #### add content to sidebar
- if(nrow(info) > 0){
- tipo <- info$Tipo.de.actualización.que.nos.queres.facer.chegar
- if(!(tipo %in% c("Convocatoria de xornada de limpeza", "Outras Convocatorias"))){
-
- links <- c()
-
- if(tipo %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes")){
- if(!is.na(info$Imaxe.dos.residuos.no.lugar.ou.da.xornada.de.limpeza)){
- links <- sapply(info$Imaxe.dos.residuos.no.lugar.ou.da.xornada.de.limpeza, function(x) strsplit(x, ", "), USE.NAMES=FALSE)[[1]]
- }
- }
- else if(tipo %in% c("Non hai pellets", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)")){
- if(!is.na(info$Imaxes.adicionais)){
- links <- sapply(info$Imaxes.adicionais, function(x) strsplit(x, ", "), USE.NAMES=FALSE)[[1]]
- }
- }
-
- output$sidebarContent <- renderUI({
- sidebar <- fluidRow(
- tags$style(HTML(".skin-black .sidebar-menu>li>.treeview-menu {padding-left: 15px;}")), # Add margin to shiny-input-container
- column(12, HTML(paste(
- "
Praia: ", htmlEscape(info$Nome.da.praia..Concello), "
",
- "Data da limpeza: ", htmlEscape(info$Marca.temporal), "
",
- "Concello: ", htmlEscape(info$Concello), "
",
- "Información adicional: ", htmlEscape(info$Información.adicional), "
"
- ))),
- column(12, h5("Imaxes dispoñibles:")),
- column(12, HTML(paste(
- lapply(1:length(links), function(i) {
- if(!is.null(links[i])){
- HTML(paste(
- "Imaxe ", i, "
"
- ))
- }else{
- paste("")
- }
- }),
- collapse = ""
- ))),
- if (!is.null(updateTimestamp$time)) {
- column(12, h5(paste("Última Actualización: ", updateTimestamp$time)))
- }
- )
- return(sidebar)
- })
- }
- else{
- output$sidebarContent <- renderUI({
- sidebar <- fluidRow(
- column(12, HTML(paste(
- "
Tipo: ", htmlEscape(info$Tipo.de.actualización.que.nos.queres.facer.chegar), "
",
- "Data e hora: ", htmlEscape(info$Data.da.Convocatoria), " ", htmlEscape(info$Hora), "
",
- "Quen organiza a iniciativa: ", htmlEscape(info$Quen.organiza.a.iniciativa), "
",
- "Contacto: ", htmlEscape(info$Contacto), "
",
- tags$a("Cartaz", href = htmlEscape(url_encode_vector(info$Cartaz))), "
",
- tags$a("Ligazón", href = htmlEscape(url_encode_vector(info$Ligazón))), "
"))),
- if (!is.null(updateTimestamp$time)) {
- column(12, h5(paste("Última Actualización: ", updateTimestamp$time)))
- }
- )
- return(sidebar)
- })
-
- }
- }
-
- }
- })
-
- ###### RENDER Statisticsc ################
- output$n_concellos <- renderValueBox({
- distinct_counts <- data() %>%
- count(Concello)
- n_concellos <- length(unique(distinct_counts$Concello))
- customValueBox(n_concellos)
- })
-
- output$n_praias <- renderValueBox({
- praias_tipos <- c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes", "Non hai pellets na praia", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)")
- distinct_counts <- data() %>%
- count(Nome.da.praia..Concello, Concello, Tipo.de.actualización.que.nos.queres.facer.chegar) %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% praias_tipos)
- customValueBox(length(unique(distinct_counts$Nome.da.praia..Concello)))
-
- })
-
- output$n_update <- renderValueBox({
- customValueBox(nrow(data()))
- })
-
- # output$n_animais <- renderValueBox({
- # distinct_counts <- data() %>%
- # count(Atopaches.animáis.mortos.) %>% filter(Atopaches.animáis.mortos. == "Si")
- # customValueBox(distinct_counts)
- # })
-
- output$n_112 <- renderValueBox({
- distinct_counts <- data() %>%
- count(Está.avisado.o.112.) %>% filter(Está.avisado.o.112. == "Si")
- customValueBox(distinct_counts)
- })
-
- reconto <- reactive({
- distinct_counts <- data() %>% count(Nome.da.praia..Concello, Concello, Tipo.de.actualización.que.nos.queres.facer.chegar)
- })
-
- output$n_pellets <- renderValueBox({
- count <- reconto() %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes"))
- customValueBox(sum(count$n))
- })
-
- output$n_limpas <- renderValueBox({
- count <- reconto() %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("A praia está limpa", "Non hai pellets na praia", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)"))
- customValueBox(sum(count$n))
- })
-
- output$n_limpezas <- renderValueBox({
- count <- reconto() %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Convocatoria de xornada de limpeza", "Outras Convocatorias"))
- customValueBox(sum(count$n))
- })
-
- output$top5 <- renderPlot({
- top5 <- data() %>%
- count(Concello) %>%
- arrange(desc(n)) %>%
- head(5)
- ggplot(top5, aes(x = factor(Concello), y = n, fill = n)) +
- geom_col(stat = "n", show.legend = FALSE) +
- geom_text(aes(label = Concello, y = n), position = position_stack(vjust = 0.5), color = "white") +
- scale_fill_gradient(low = "#428BCA", high = "#428BCA") +
- labs(x = NULL, y = NULL) + coord_flip() +
- theme(
- panel.background = element_rect(fill = "transparent", colour = NA),
- plot.background = element_rect(fill = "transparent", colour = NA),
- #panel.grid = element_blank(),
- panel.border = element_blank(),
- plot.margin = unit(c(0, 0, 0, 0), "null"),
- panel.margin = unit(c(0, 0, 0, 0), "null"),
- #axis.ticks = element_blank(),
- axis.text = element_blank(),
- #axis.title = element_blank(),
- axis.line = element_blank(),
- axis.line.x = element_line(color = "black", size = 1),
- axis.text.x = element_text(color = "black", size = 10),
- legend.position = "none",
- axis.ticks.length = unit(0, "null"),
- axis.ticks.margin = unit(0, "null"),
- legend.margin = unit(0, "null")
- )
- })
-
- output$cum_praias <- renderPlot({
- distinct_counts <- data() %>%
- count(Nome.da.praia..Concello, Concello, Data.Norm)
-
- summarized_data <- distinct_counts %>%
- group_by(Data.Norm) %>%
- summarize(sum_n = sum(n))
-
- ggplot(summarized_data, aes(x = Data.Norm, y = sum_n)) +
- geom_line(linewidth = 1, lineend = "round", linejoin = "mitre", colour = "#428BCA") +
- labs(x = "Data", y = "Número de Actualizacións") +
- theme(
- axis.text.x = element_text(color = "black", size = 10)
- )
- })
-
-
-
- output$cum_residuos <- renderPlot({
- distinct_counts <- data() %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes")) %>%
- count(Residuo, Data.Norm)
-
- # Group by both Data.Norm and Residuo to calculate sums per Residuo type
- summarized_data <- distinct_counts %>%
- group_by(Data.Norm, Residuo) %>%
- summarize(sum_n = sum(n), .groups = 'drop')
-
- # Plot each Residuo as a separate line
- ggplot(summarized_data, aes(x = Data.Norm, y = sum_n, color = Residuo, group = Residuo)) +
- geom_line(linewidth = 1, lineend = "round", linejoin = "mitre") +
- labs(x = "Data", y = "Número de Actualizacións", color = "Residuo") +
- theme(
- axis.text.x = element_text(color = "black", size = 10),
- legend.position = "bottom" # Optional: adjust the legend position
- )
- })
-
- ###### RENDER MAP ################
- # Create a reactive object for each type
- data_hai_pellets <- reactive({
- data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes"))
- })
-
- data_non_hai_pellets <- reactive({
- data() %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Non hai pellets na praia", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)"))
- })
-
- data_convocatoria <- reactive({
- data() %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar == "Convocatoria de xornada de limpeza")
- })
-
- data_evento <- reactive({
- data() %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar == "Outras Convocatorias")
- })
-
- data_xa_non_hai <- reactive({
- data() %>%
- filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)"))
- })
-
- color_factor <- reactive({
- colorFactor(palette = color_palette, na.color = "grey",
- levels = c("Hai pellets na praia", "Non hai pellets na praia", "Convocatoria de xornada de limpeza", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)", "Outras Convocatorias"))
- })
-
- # Render map
- output$mymap <- renderLeaflet({
- leaflet() %>%
- addTiles() %>%
- setView(lng = -8.1, lat = 42.5, zoom = 7) %>%
- addLegend(
- position = "bottomright",
- colors = c("#e31a1c", "#33a02c", "#F5A618", "#490a73"), # Colors for the four categories
- labels = c(paste("Hai residuos na praia (", nrow(data_hai_pellets()), ")", sep = ""),
- paste("Non hai residuos na praia (", nrow(data_non_hai_pellets()), ")", sep = ""),
- paste("Convocatoria de xornada de limpeza (", nrow(data_convocatoria()), ")", sep = ""),
- paste("Outras Convocatorias (", nrow(data_evento()), ")", sep = "")),
- title = "Tipo de actualización e reconto por tipo"
- )
- })
-
- observe({
- req(input$sidebarID == "map") # Only display if tab is 'map'
- proxy_map <- leafletProxy("mymap")
- # Check if the data is not empty
- if (!is.null(data()) && nrow(data()) > 0) {
- proxy_map %>% clearMarkers()
- # Determine the selected filters
- selected_filters <- input$legendFilter
-
- # Add markers based on the selected filters
- for (selected_filter in selected_filters) {
- if ((selected_filter %in% c("Hai pellets na praia", "Hai biosoportes", "Hai chapapote")) & nrow(data_hai_pellets()) > 0) {
- proxy_map %>%
- addCircleMarkers(
- data = data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes")),
- lng = ~lon,
- lat = ~lat,
- label = ~paste(Residuo, " - ", Nome.da.praia..Concello),
- layerId = ~id,
- radius = 10,
- color = "#e31a1c",
- popup = ~paste(
- paste("Praia: ", htmlEscape(Nome.da.praia..Concello), "
"),
- paste("Tipo de residuo: ", htmlEscape(Residuo), "
"),
- paste("Data da actualización: ", htmlEscape(Marca.temporal), "
"),
- paste("Está avisado o 112?", htmlEscape(Está.avisado.o.112.), "
"),
- paste("Hai animais mortos", htmlEscape(Atopaches.animáis.mortos.), "
"),
- paste("Por onde están espallados os residuos: ", htmlEscape(Por.onde.están.espallados.os.residuos), "
"),
- paste("Quen está recollendo os residuos?", htmlEscape(Quen.está.recollendo.os.residuos.), "
"),
- paste("Como se están a recoller?", htmlEscape(Como.se.están.a.recoller.os.residuos.), "
"),
- paste("Onde se depositan?", htmlEscape(Onde.se.depositan.os.residuos.), "
"),
- paste("Hai sacos de pellets ou outro tipo de residuos?", htmlEscape(Hai.sacos.de.pellets.ou.outro.tipo.de.residuos.), "
"),
- paste("Cantidade de residuos: ", htmlEscape(Cantidade.de.residuos), "
"),
- paste("Concello: ", htmlEscape(Concello), "
")
- ),
- stroke = FALSE, fillOpacity = 0.5
- )
-
- }
- else if (selected_filter == "Convocatoria de xornada de limpeza" & nrow(data_convocatoria()) > 0) {
- proxy_map %>%
- addCircleMarkers(
- data = data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar == "Convocatoria de xornada de limpeza"),
- lng = ~lon,
- lat = ~lat,
- label = ~Nome.da.praia..Concello,
- layerId = ~id,
- radius = 10,
- color = "#F5A618",
- popup = ~paste(
- paste("Data da convocatoria: ", htmlEscape(as.Date(Data.da.Convocatoria)), " ", htmlEscape(Hora), "
"),
- paste("Praia: ", htmlEscape(Nome.da.praia..Concello), "
"),
- paste("Lugar de encontro: ", htmlEscape(Lugar.de.encontro), "
"),
- paste("Quen organiza a iniciativa? ", htmlEscape(Quen.organiza.a.iniciativa), "
"),
- paste(tags$a("Ligazón", href = htmlEscape(Ligazón)), "
")
- ),
- stroke = FALSE, fillOpacity = 0.5
- )
- }
- else if (selected_filter == "Outras Convocatorias" & nrow(data_evento()) > 0) {
- proxy_map %>%
- addCircleMarkers(
- data = data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar == "Outras Convocatorias"),
- lng = ~lon,
- lat = ~lat,
- label = ~Nome.da.praia..Concello,
- layerId = ~id,
- radius = 10,
- color = "#490a73",
- popup = ~paste(
- paste("Data da convocatoria: ", htmlEscape(as.Date(Data.da.Convocatoria)), " ", htmlEscape(Hora), "
"),
- paste("Lugar de encontro: ", htmlEscape(Lugar.de.encontro), "
"),
- paste("Quen organiza a iniciativa? ", htmlEscape(Quen.organiza.a.iniciativa), "
"),
- paste(tags$a("Ligazón", href = htmlEscape(Ligazón)), "
"),
- paste(tags$a("Cartaz", href = htmlEscape(Cartaz)), "
")
- ),
- stroke = FALSE, fillOpacity = 0.5
- )
- }
- else if (selected_filter %in% c("Non hai pellets na praia", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)") & nrow(data_xa_non_hai()) > 0) {
- proxy_map %>%
- addCircleMarkers(
- data = data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Non hai pellets na praia", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)")),
- lng = ~lon,
- lat = ~lat,
- label = ~paste(Tipo.de.actualización.que.nos.queres.facer.chegar, " - ", Nome.da.praia..Concello),
- layerId = ~id,
- radius = 10,
- popup = ~paste(
- paste("Praia: ", htmlEscape(Nome.da.praia..Concello), "
"),
- paste("Estado da praia: ", htmlEscape(Tipo.de.actualización.que.nos.queres.facer.chegar), "
"),
- paste("Data da actualización: ", htmlEscape(Marca.temporal), "
"),
- paste("Está avisado o 112?", htmlEscape(Está.avisado.o.112.), "
"),
- paste("Hai animais mortos", htmlEscape(Atopaches.animáis.mortos.), "
"),
- paste("Por onde están espallados os residuos: ", htmlEscape(Por.onde.están.espallados.os.residuos), "
"),
- paste("Quen está recollendo os residuos?", htmlEscape(Quen.está.recollendo.os.residuos.), "
"),
- paste("Como se están a recoller?", htmlEscape(Como.se.están.a.recoller.os.residuos.), "
"),
- paste("Onde se depositan?", htmlEscape(Onde.se.depositan.os.residuos.), "
"),
- paste("Hai sacos de pellets ou outro tipo de residuos?", htmlEscape(Hai.sacos.de.pellets.ou.outro.tipo.de.residuos.), "
"),
- paste("Cantidade de residuos: ", htmlEscape(Cantidade.de.residuos), "
"),
- paste("Concello: ", htmlEscape(Concello), "
")
- ),
- color = "#33a02c",
- stroke = FALSE, fillOpacity = 0.5
- )
- }
- }
- } else {
- # Clear any existing markers and popups
- proxy_map %>%
- clearMarkers() %>%
- clearPopups()
- proxy_map %>%
- addPopups(lng = -8.1, lat = 42.5, popup = "Non hai datos para mostrar con estes filtros.")
- }
- })
-}
-
-# Custom valueBox function
-customValueBox <- function(value) {
- if(class(value)[1] == "tbl_df"){
- if(nrow(value) == 0){
- value = 0
- }
- else{
- value = value$n
- }
- }
- tags$div(
- style = "background-color: #f5f5f5; padding: 10px",
- tags$p(
- style = "margin: 0;",
- tags$strong(
- style = "font-size: 35px; color: black; text-align: center;",
- value
- )
- )
- )
-}
-
-if(Sys.getenv("ENV") == "docker"){
- options(shiny.host = '0.0.0.0')
- options(shiny.port = 3838)
-}
-
-shinyApp(ui, server)
+# load required packages
+library(magrittr)
+library(dplyr)
+library(tibble)
+if(!require("leaflet")) install.packages("leaflet")
+library(shiny)
+library(bslib)
+library(shinyWidgets)
+library(shinyjs)
+library(htmltools)
+library(readr)
+library(ggplot2)
+library(shinycssloaders)
+
+source("data_loader.R")
+
+# ---- Tema visual (identidade de inesortega.github.io) -----------------------
+app_theme <- bs_theme(
+ version = 5,
+ bg = "#f7fbfb",
+ fg = "#11201f",
+ primary = "#136f6f",
+ secondary = "#4f5f5e",
+ success = "#2f9e44",
+ warning = "#e8920c",
+ danger = "#d6342a",
+ base_font = font_collection("Inter", "system-ui", "-apple-system",
+ "Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"),
+ "border-color" = "#dcebeb",
+ "font-size-base" = "0.9rem" # texto base ~14px, como o mockup
+)
+
+# ---- Panel lateral de filtros (mesmos inputId que a versión orixinal) -------
+filters <- sidebar(
+ width = 330,
+ dateRangeInput(
+ inputId = "dateRange",
+ label = "Filtrado por data",
+ start = Sys.Date() - 30,
+ end = Sys.Date() + 1,
+ min = as.Date("2024-01-01"),
+ max = Sys.Date() + 2,
+ separator = " — ",
+ format = "yyyy-mm-dd",
+ language = "es",
+ weekstart = 1
+ ),
+ checkboxGroupButtons(
+ inputId = "legendFilter",
+ label = "Tipo de actualización",
+ individual = TRUE,
+ size = "sm",
+ choiceNames = list(
+ HTML("Pellets"),
+ HTML("Biosoportes"),
+ HTML("Chapapote"),
+ HTML("Praia limpa"),
+ HTML("Xa non hai"),
+ HTML("Limpeza"),
+ HTML("Outras")
+ ),
+ choiceValues = c(
+ "Hai pellets na praia",
+ "Hai biosoportes",
+ "Hai chapapote",
+ "A praia está limpa",
+ "Xa non hai (a praia quedaba limpa cando se encheu o formulario)",
+ "Convocatoria de xornada de limpeza",
+ "Outras Convocatorias"
+ ),
+ selected = c(
+ "Hai pellets na praia",
+ "Hai biosoportes",
+ "Hai chapapote",
+ "A praia está limpa",
+ "Xa non hai (a praia quedaba limpa cando se encheu o formulario)",
+ "Convocatoria de xornada de limpeza",
+ "Outras Convocatorias"
+ )
+ ),
+ selectInput(inputId = "select_provincia", label = "Provincia", multiple = TRUE, choices = c(), selected = ""),
+ selectInput(inputId = "select_concello", label = "Concello", multiple = TRUE, choices = c(), selected = "")
+)
+
+# ---- Tarxeta KPI auxiliar ---------------------------------------------------
+kpi_box <- function(outputId, title, subtitle = NULL, accent = "primary") {
+ value_box(
+ title = tags$span(class = "kpi-title", title,
+ if (!is.null(subtitle)) tags$span(class = "kpi-sub", subtitle)),
+ value = textOutput(outputId, inline = TRUE),
+ class = paste0("kpi-card kpi-", accent)
+ )
+}
+
+# ---- UI ---------------------------------------------------------------------
+ui <- page_navbar(
+ title = tags$span(class = "brand",
+ tags$span(class = "brand-mark", HTML('')),
+ tags$span(class = "brand-text",
+ tags$span(class = "brand-title", "Unha Vez Máis"),
+ tags$span(class = "brand-sub", "Monitorización cidadá das praias de Galicia")
+ )
+ ),
+ id = "sidebarID", # conserva input$sidebarID (== "map") que usa o server
+ theme = app_theme,
+ fillable = FALSE,
+ sidebar = filters,
+ header = tagList(
+ useShinyjs(),
+ tags$head(tags$link(rel = "stylesheet", type = "text/css", href = "styles.css"))
+ ),
+
+ nav_panel(
+ title = tagList(
+ HTML(''),
+ "Mapa en tempo real"
+ ),
+ value = "map",
+ card(
+ full_screen = TRUE,
+ class = "map-card",
+ leafletOutput("mymap", height = "calc(100vh - 140px)")
+ )
+ ),
+
+ nav_panel(
+ title = tagList(
+ HTML(''),
+ "Datos e estatísticas"
+ ),
+ div(class = "stats-wrap",
+ div(class = "lead",
+ h4("Datos e estatísticas"),
+ p(HTML(paste0(
+ "Os datos recóllense de xeito colaborativo a través deste ",
+ "formulario. ",
+ "As estatísticas calcúlanse automaticamente para os filtros seleccionados e poden ",
+ "conter erros polo reporte non estandarizado do nome da praia."))
+ )
+ ),
+ layout_columns(
+ col_widths = c(3, 3, 3, 3),
+ kpi_box("n_concellos", "Concellos", "con actualizacións"),
+ kpi_box("n_praias", "Praias", "con actualizacións"),
+ kpi_box("n_update", "Actualizacións", "recibidas no formulario"),
+ kpi_box("n_112", "Notificacións 112", "total recibidas", accent = "danger")
+ ),
+ layout_columns(
+ col_widths = c(4, 4, 4),
+ kpi_box("n_pellets", "Praias con residuos", accent = "danger"),
+ kpi_box("n_limpas", "Praia limpa", accent = "success"),
+ kpi_box("n_limpezas", "Outros eventos", accent = "warning")
+ ),
+ layout_columns(
+ col_widths = c(6, 6),
+ card(card_header("Evolución diaria — por tipo de residuo"),
+ withSpinner(plotOutput("cum_residuos"))),
+ card(card_header("Evolución diaria — actualizacións recibidas"),
+ withSpinner(plotOutput("cum_praias")))
+ )
+ )
+ ),
+
+ nav_panel(
+ title = tagList(
+ HTML(''),
+ "Agradecementos"
+ ),
+ div(class = "ack",
+ HTML(paste("Este proxecto é posible grazas á colaboración e recollida de datos de voluntarias; de Ana e Miguel das Chas; das voluntarias de Noia Limpa; e á cesión de recursos por parte de ", "Gradiant"))
+ )
+ ),
+
+ nav_spacer(),
+ nav_item(tags$span(class = "live-pill", tags$span(class = "live-dot"), "En directo")),
+
+ footer = tags$footer(class = "app-footer",
+ "Autoría: ", tags$a("Inés Ortega-Fernández", href = "https://inesortega.github.io/"),
+ " · Contacto: ", tags$a("datospellets@gmail.com", href = "mailto:datospellets@gmail.com")
+ )
+)
+
+server <- function(input, output, session) {
+
+ isFirstRun <- reactiveVal(TRUE)
+
+ data <- reactive({
+ invalidateLater(350000)
+ tryCatch({
+ all_data <- read_csv("./data/praias.csv", show_col_types = FALSE)
+ all_data <- all_data %>%
+ mutate(
+ Data.Norm = case_when(
+ Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Convocatoria de xornada de limpeza", "Outras Convocatorias") ~ as.Date(Data.da.Convocatoria, "%Y-%m-%dd", tz="UTC"),
+ TRUE ~ as.Date(Marca.temporal, "%Y-%m-%dd", tz="UTC")) # Default case
+ ) %>%
+ mutate(
+ Residuo = case_when(
+ Tipo.de.actualización.que.nos.queres.facer.chegar == "Hai pellets na praia" ~ "Pellets",
+ Tipo.de.actualización.que.nos.queres.facer.chegar == "Hai biosoportes" ~ "Biosoportes",
+ Tipo.de.actualización.que.nos.queres.facer.chegar == "Hai chapapote" ~ "Chapapote") # Default case
+ )
+
+ all_data <- all_data %>%
+ mutate(Nome.da.praia..Concello = ifelse(str_detect(Nome.da.praia..Concello, ","),
+ Nome.da.praia..Concello,
+ paste(Nome.da.praia..Concello, Concello, sep = ', ')))
+
+
+ all_data$Data.Norm <- as.Date(all_data$Data.Norm)
+ all_data$id <- seq.int(nrow(all_data))
+ }, error = function(e){
+ message("Error loading data...")
+ })
+ # Filter data based on selected date range
+ max <- max(as.Date(all_data$Data.Norm))
+ min <- min(as.Date(all_data$Data.Norm))
+ if (isFirstRun()) {
+ # Por defecto: último mes con datos dispoñibles
+ updateDateRangeInput(session, inputId = "dateRange",
+ start = max - 30, end = max, min = min, max = max)
+ isFirstRun(FALSE)
+ } else {
+ updateDateRangeInput(session, inputId = "dateRange", min = min, max = max)
+ }
+
+ if (!is.null(input$dateRange)) {
+ start_date <- input$dateRange[1]
+ end_date <- input$dateRange[2]
+ filtered_data <- all_data %>%
+ filter(Data.Norm >= start_date & Data.Norm <= end_date)
+ } else {
+ filtered_data <- all_data
+ }
+
+ # Filter data based on selected provincia
+ updateSelectInput(session,"select_provincia",choices=setdiff(unique(filtered_data$Provincia), "FALSE"), select = input$select_provincia)
+
+ if(!is.null(input$select_provincia)){
+ filtered_data <- filtered_data %>% filter(Provincia %in% input$select_provincia)
+ }
+
+ # Filter data based on selected concello
+ updateSelectInput(session,"select_concello",choices=unique(filtered_data$Concello), select = input$select_concello)
+
+ if(!is.null(input$select_concello)){
+ filtered_data <- filtered_data %>% filter(Concello %in% input$select_concello)
+ }
+
+ filtered_data <- filtered_data %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% input$legendFilter)
+
+ filtered_data
+ })
+
+ ###### RENDER Statisticsc ################
+ output$n_concellos <- renderText({
+ distinct_counts <- data() %>%
+ count(Concello)
+ n_concellos <- length(unique(distinct_counts$Concello))
+ box_value(n_concellos)
+ })
+
+ output$n_praias <- renderText({
+ praias_tipos <- c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes", "Non hai pellets na praia", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)")
+ distinct_counts <- data() %>%
+ count(Nome.da.praia..Concello, Concello, Tipo.de.actualización.que.nos.queres.facer.chegar) %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% praias_tipos)
+ box_value(length(unique(distinct_counts$Nome.da.praia..Concello)))
+
+ })
+
+ output$n_update <- renderText({
+ box_value(nrow(data()))
+ })
+
+ output$n_112 <- renderText({
+ distinct_counts <- data() %>%
+ count(Está.avisado.o.112.) %>% filter(Está.avisado.o.112. == "Si")
+ box_value(distinct_counts)
+ })
+
+ reconto <- reactive({
+ distinct_counts <- data() %>% count(Nome.da.praia..Concello, Concello, Tipo.de.actualización.que.nos.queres.facer.chegar)
+ })
+
+ output$n_pellets <- renderText({
+ count <- reconto() %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes"))
+ box_value(sum(count$n))
+ })
+
+ output$n_limpas <- renderText({
+ count <- reconto() %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("A praia está limpa", "Non hai pellets na praia", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)"))
+ box_value(sum(count$n))
+ })
+
+ output$n_limpezas <- renderText({
+ count <- reconto() %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Convocatoria de xornada de limpeza", "Outras Convocatorias"))
+ box_value(sum(count$n))
+ })
+
+ output$top5 <- renderPlot({
+ top5 <- data() %>%
+ count(Concello) %>%
+ arrange(desc(n)) %>%
+ head(5)
+ ggplot(top5, aes(x = factor(Concello), y = n, fill = n)) +
+ geom_col(stat = "n", show.legend = FALSE) +
+ geom_text(aes(label = Concello, y = n), position = position_stack(vjust = 0.5), color = "white") +
+ scale_fill_gradient(low = "#136f6f", high = "#136f6f") +
+ labs(x = NULL, y = NULL) + coord_flip() +
+ theme(
+ panel.background = element_rect(fill = "transparent", colour = NA),
+ plot.background = element_rect(fill = "transparent", colour = NA),
+ panel.border = element_blank(),
+ plot.margin = unit(c(0, 0, 0, 0), "null"),
+ axis.text = element_blank(),
+ axis.line = element_blank(),
+ axis.line.x = element_line(color = "black", size = 1),
+ axis.text.x = element_text(color = "black", size = 10),
+ legend.position = "none"
+ )
+ })
+
+ output$cum_praias <- renderPlot({
+ distinct_counts <- data() %>%
+ count(Nome.da.praia..Concello, Concello, Data.Norm)
+
+ summarized_data <- distinct_counts %>%
+ group_by(Data.Norm) %>%
+ summarize(sum_n = sum(n))
+
+ ggplot(summarized_data, aes(x = Data.Norm, y = sum_n)) +
+ geom_area(fill = "#136f6f", alpha = 0.15) +
+ geom_line(linewidth = 1, lineend = "round", linejoin = "mitre", colour = "#136f6f") +
+ labs(x = "Data", y = "Número de Actualizacións") +
+ theme_minimal(base_size = 13) +
+ theme(plot.background = element_rect(fill = "transparent", colour = NA))
+ })
+
+
+
+ output$cum_residuos <- renderPlot({
+ distinct_counts <- data() %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes")) %>%
+ count(Residuo, Data.Norm)
+
+ # Group by both Data.Norm and Residuo to calculate sums per Residuo type
+ summarized_data <- distinct_counts %>%
+ group_by(Data.Norm, Residuo) %>%
+ summarize(sum_n = sum(n), .groups = 'drop')
+
+ # Plot each Residuo as a separate line
+ ggplot(summarized_data, aes(x = Data.Norm, y = sum_n, color = Residuo, group = Residuo)) +
+ geom_line(linewidth = 1, lineend = "round", linejoin = "mitre") +
+ scale_color_manual(values = c("Pellets" = "#d6342a", "Biosoportes" = "#e8920c", "Chapapote" = "#6b3fa0")) +
+ labs(x = "Data", y = "Número de Actualizacións", color = "Residuo") +
+ theme_minimal(base_size = 13) +
+ theme(legend.position = "bottom",
+ plot.background = element_rect(fill = "transparent", colour = NA))
+ })
+
+ ###### RENDER MAP ################
+ # Create a reactive object for each type
+ data_hai_pellets <- reactive({
+ data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes"))
+ })
+
+ data_non_hai_pellets <- reactive({
+ data() %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Non hai pellets na praia", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)"))
+ })
+
+ data_convocatoria <- reactive({
+ data() %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar == "Convocatoria de xornada de limpeza")
+ })
+
+ data_evento <- reactive({
+ data() %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar == "Outras Convocatorias")
+ })
+
+ data_xa_non_hai <- reactive({
+ data() %>%
+ filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)"))
+ })
+
+ color_factor <- reactive({
+ colorFactor(palette = color_palette, na.color = "grey",
+ levels = c("Hai pellets na praia", "Non hai pellets na praia", "Convocatoria de xornada de limpeza", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)", "Outras Convocatorias"))
+ })
+
+ # Render map
+ output$mymap <- renderLeaflet({
+ leaflet() %>%
+ addProviderTiles(providers$CartoDB.Positron) %>%
+ setView(lng = -8.1, lat = 42.5, zoom = 7) %>%
+ addLegend(
+ position = "bottomright",
+ colors = c("#e31a1c", "#33a02c", "#F5A618", "#490a73"), # Colors for the four categories
+ labels = c(paste("Hai residuos na praia (", nrow(data_hai_pellets()), ")", sep = ""),
+ paste("Non hai residuos na praia (", nrow(data_non_hai_pellets()), ")", sep = ""),
+ paste("Convocatoria de xornada de limpeza (", nrow(data_convocatoria()), ")", sep = ""),
+ paste("Outras Convocatorias (", nrow(data_evento()), ")", sep = "")),
+ title = "Tipo de actualización e reconto por tipo"
+ )
+ })
+
+ observe({
+ req(input$sidebarID == "map") # Only display if tab is 'map'
+ proxy_map <- leafletProxy("mymap")
+ # Check if the data is not empty
+ if (!is.null(data()) && nrow(data()) > 0) {
+ proxy_map %>% clearMarkers()
+ # Determine the selected filters
+ selected_filters <- input$legendFilter
+
+ # Add markers based on the selected filters
+ for (selected_filter in selected_filters) {
+ if ((selected_filter %in% c("Hai pellets na praia", "Hai biosoportes", "Hai chapapote")) & nrow(data_hai_pellets()) > 0) {
+ proxy_map %>%
+ addCircleMarkers(
+ data = data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes")) %>% filter(is.finite(lon), is.finite(lat)),
+ lng = ~lon,
+ lat = ~lat,
+ label = ~paste(Residuo, " - ", Nome.da.praia..Concello),
+ layerId = ~id,
+ radius = 10,
+ color = "#e31a1c",
+ popup = ~ popup_card(
+ state = "Hai residuos na praia", accent = "#d6342a",
+ title = Nome.da.praia..Concello, subtitle = Concello,
+ fields = list(
+ "Tipo de residuo" = Residuo,
+ "Data" = Marca.temporal,
+ "Avisado o 112" = Está.avisado.o.112.,
+ "Animais mortos" = Atopaches.animáis.mortos.,
+ "Espallados por" = Por.onde.están.espallados.os.residuos,
+ "Recollendo" = Quen.está.recollendo.os.residuos.,
+ "Como recoller" = Como.se.están.a.recoller.os.residuos.,
+ "Onde depositan" = Onde.se.depositan.os.residuos.,
+ "Sacos" = Hai.sacos.de.pellets.ou.outro.tipo.de.residuos.,
+ "Cantidade" = Cantidade.de.residuos
+ ),
+ foot = img_links(Imaxe.dos.residuos.no.lugar.ou.da.xornada.de.limpeza)
+ ),
+ stroke = FALSE, fillOpacity = 0.5
+ )
+
+ }
+ else if (selected_filter == "Convocatoria de xornada de limpeza" & nrow(data_convocatoria()) > 0) {
+ proxy_map %>%
+ addCircleMarkers(
+ data = data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar == "Convocatoria de xornada de limpeza") %>% filter(is.finite(lon), is.finite(lat)),
+ lng = ~lon,
+ lat = ~lat,
+ label = ~Nome.da.praia..Concello,
+ layerId = ~id,
+ radius = 10,
+ color = "#F5A618",
+ popup = ~ popup_card(
+ state = "Convocatoria de limpeza", accent = "#e8920c",
+ title = Nome.da.praia..Concello, subtitle = Concello,
+ fields = list(
+ "Data e hora" = paste(as.Date(Data.da.Convocatoria), Hora),
+ "Lugar de encontro" = Lugar.de.encontro,
+ "Organiza" = Quen.organiza.a.iniciativa
+ ),
+ foot = ifelse(is.na(Ligazón) | Ligazón == "", "",
+ paste0("Ligazón →"))
+ ),
+ stroke = FALSE, fillOpacity = 0.5
+ )
+ }
+ else if (selected_filter == "Outras Convocatorias" & nrow(data_evento()) > 0) {
+ proxy_map %>%
+ addCircleMarkers(
+ data = data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar == "Outras Convocatorias") %>% filter(is.finite(lon), is.finite(lat)),
+ lng = ~lon,
+ lat = ~lat,
+ label = ~Nome.da.praia..Concello,
+ layerId = ~id,
+ radius = 10,
+ color = "#490a73",
+ popup = ~ popup_card(
+ state = "Outras convocatorias", accent = "#6b3fa0",
+ title = Nome.da.praia..Concello, subtitle = Concello,
+ fields = list(
+ "Data e hora" = paste(as.Date(Data.da.Convocatoria), Hora),
+ "Lugar de encontro" = Lugar.de.encontro,
+ "Organiza" = Quen.organiza.a.iniciativa
+ ),
+ foot = paste0(
+ ifelse(is.na(Ligazón) | Ligazón == "", "",
+ paste0("Ligazón → ")),
+ ifelse(is.na(Cartaz) | Cartaz == "", "",
+ paste0("Cartaz →"))
+ )
+ ),
+ stroke = FALSE, fillOpacity = 0.5
+ )
+ }
+ else if (selected_filter %in% c("Non hai pellets na praia", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)") & nrow(data_xa_non_hai()) > 0) {
+ proxy_map %>%
+ addCircleMarkers(
+ data = data() %>% filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% c("Non hai pellets na praia", "A praia está limpa", "Xa non hai (a praia quedaba limpa cando se encheu o formulario)")) %>% filter(is.finite(lon), is.finite(lat)),
+ lng = ~lon,
+ lat = ~lat,
+ label = ~paste(Tipo.de.actualización.que.nos.queres.facer.chegar, " - ", Nome.da.praia..Concello),
+ layerId = ~id,
+ radius = 10,
+ popup = ~ popup_card(
+ state = "Praia limpa / sen residuos", accent = "#2f9e44",
+ title = Nome.da.praia..Concello, subtitle = Concello,
+ fields = list(
+ "Estado" = Tipo.de.actualización.que.nos.queres.facer.chegar,
+ "Data" = Marca.temporal,
+ "Avisado o 112" = Está.avisado.o.112.,
+ "Animais mortos" = Atopaches.animáis.mortos.,
+ "Cantidade" = Cantidade.de.residuos
+ ),
+ foot = img_links(Imaxes.adicionais)
+ ),
+ color = "#33a02c",
+ stroke = FALSE, fillOpacity = 0.5
+ )
+ }
+ }
+ } else {
+ # Clear any existing markers and popups
+ proxy_map %>%
+ clearMarkers() %>%
+ clearPopups()
+ proxy_map %>%
+ addPopups(lng = -8.1, lat = 42.5, popup = "Non hai datos para mostrar con estes filtros.")
+ }
+ })
+}
+
+# Constrúe os enlaces ás imaxes (campo cunha lista de URLs separadas por comas).
+# Vectorizado: devolve un vector de HTML, un por fila.
+img_links <- function(x) {
+ vapply(as.character(x), function(s) {
+ if (is.na(s) || !nzchar(s)) return("")
+ urls <- trimws(strsplit(s, ",\\s*")[[1]])
+ urls <- urls[nzchar(urls)]
+ if (!length(urls)) return("")
+ enc <- vapply(urls, function(u) utils::URLencode(u), character(1), USE.NAMES = FALSE)
+ paste0("Imaxes: ",
+ paste(paste0("", seq_along(urls), ""),
+ collapse = " · "))
+ }, character(1), USE.NAMES = FALSE)
+}
+
+# Tarxeta de popup do mapa ao estilo do mockup (vectorizada para leaflet).
+# 'fields' = lista nomeada de vectores (etiqueta -> valor); 'foot' = HTML opcional.
+popup_card <- function(state, accent, title, subtitle, fields = list(), foot = NULL) {
+ rows <- ""
+ for (lab in names(fields)) {
+ v <- as.character(fields[[lab]])
+ v <- ifelse(is.na(v) | v == "" | v == "NA", "—", htmlEscape(v))
+ rows <- paste0(rows, "", htmlEscape(lab), "", v, "")
+ }
+ foot_html <- if (is.null(foot)) "" else
+ ifelse(foot == "", "", paste0(""))
+ paste0(
+ "",
+ "
", htmlEscape(state), "",
+ "
", htmlEscape(title), "
",
+ "
", htmlEscape(subtitle), "
",
+ "
", rows, "
",
+ foot_html,
+ "
"
+ )
+}
+
+# Formatea o valor dunha tarxeta KPI (admite un número ou un tibble de count)
+box_value <- function(value) {
+ if (inherits(value, "tbl_df")) {
+ value <- if (nrow(value) == 0) 0 else value$n
+ }
+ format(value, big.mark = " ", trim = TRUE)
+}
+
+if(Sys.getenv("ENV") == "docker"){
+ options(shiny.host = '0.0.0.0')
+ options(shiny.port = 3838)
+}
+
+shinyApp(ui, server)
diff --git a/www/styles.css b/www/styles.css
new file mode 100644
index 0000000..ef3d720
--- /dev/null
+++ b/www/styles.css
@@ -0,0 +1,165 @@
+/* MapaPellets — identidade visual (inesortega.github.io) */
+:root{
+ --c-residuos:#d6342a; --c-limpa:#2f9e44; --c-convocatoria:#e8920c; --c-evento:#6b3fa0;
+ --muted:#4f5f5e; --faint:#7c8c8b; --border-light:#dcebeb;
+}
+
+/* franxa superior teal, sinatura da web persoal */
+body{ border-top:3px solid #136f6f; }
+
+/* barra de navegación */
+.navbar{ background:#e6f0f0 !important; border-bottom:1px solid #c5dada;
+ padding-top:.2rem !important; padding-bottom:.2rem !important; --bs-navbar-padding-y:.2rem; }
+.navbar .brand-title{ font-weight:680; letter-spacing:-.01em; font-size:1.05rem; }
+/* pestanas como pills teal con icona (estilo mockup) */
+.navbar .nav-underline{ --bs-nav-underline-border-width:0; }
+/* centrar verticalmente as pestanas no banner */
+.navbar .navbar-collapse, .navbar .navbar-nav{ align-items:center; }
+.navbar .nav-link{ display:inline-flex; align-items:center; gap:7px;
+ font-weight:560; font-size:.82rem; line-height:1.2; color:var(--muted);
+ border:0 !important; border-radius:7px; padding:.22rem .7rem; margin:0 1px; }
+.navbar .nav-link:hover{ background:#dceaea; color:#11201f; }
+.navbar .nav-link.active{ background:#136f6f !important; color:#fff !important; }
+.navbar .nav-ico{ width:15px; height:15px; flex:none; }
+.nav-badge{ font-size:.6rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em;
+ background:var(--c-limpa); color:#fff; padding:1px 6px; border-radius:99px; line-height:1.45; }
+.navbar .nav-link.active .nav-badge{ background:rgba(255,255,255,.25); }
+
+/* textos introdutorios */
+.intro{ margin:.25rem 0 .75rem; }
+.intro h4, .lead h4{ font-weight:680; letter-spacing:-.01em; }
+.intro p, .lead p{ color:var(--muted); max-width:72ch; }
+
+/* mapa */
+.map-card{ padding:0; overflow:hidden; }
+.map-card .leaflet-container{ border-radius:12px; background:#e3f0f1; }
+
+/* tarxetas KPI */
+.stats-wrap{ display:flex; flex-direction:column; gap:1rem; padding-top:.25rem; }
+.kpi-card{ background:#fff; color:#11201f; border:1px solid var(--border-light);
+ border-left:4px solid #136f6f;
+ box-shadow:0 1px 2px rgba(17,32,31,.04), 0 6px 20px rgba(17,32,31,.06); }
+.kpi-card.kpi-danger{ border-left-color:var(--c-residuos); }
+.kpi-card.kpi-success{ border-left-color:var(--c-limpa); }
+.kpi-card.kpi-warning{ border-left-color:var(--c-convocatoria); }
+.kpi-card .value-box-value{ font-size:2.2rem; font-weight:680; font-variant-numeric:tabular-nums;
+ line-height:1; color:#11201f; }
+.kpi-title{ display:flex; flex-direction:column; gap:1px;
+ font-size:.72rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--muted); }
+.kpi-sub{ font-weight:500; letter-spacing:0; text-transform:none; color:var(--faint); font-size:.7rem; }
+
+/* tarxetas de gráficas */
+.card-header{ font-weight:620; font-size:.95rem; }
+
+/* indicador "en directo" */
+.live-pill{ display:inline-flex; align-items:center; gap:7px; font-size:.78rem; color:var(--muted);
+ background:#fff; border:1px solid var(--border-light); padding:5px 11px; border-radius:99px; }
+.live-dot{ width:8px; height:8px; border-radius:50%; background:var(--c-limpa); }
+@media (prefers-reduced-motion: no-preference){
+ .live-dot{ animation:livepulse 2s ease-out infinite; }
+ @keyframes livepulse{ 0%{box-shadow:0 0 0 0 rgba(47,158,68,.5)} 100%{box-shadow:0 0 0 8px rgba(47,158,68,0)} }
+}
+
+/* pé de páxina */
+.app-footer{ text-align:center; padding:12px; color:var(--muted); font-size:.8rem;
+ border-top:1px solid var(--border-light); }
+
+/* agradecementos */
+.ack{ max-width:72ch; color:#11201f; line-height:1.6; }
+
+/* logotipo + título e subtítulo na cabeceira */
+.navbar .navbar-brand{ padding-top:.2rem; padding-bottom:.2rem; }
+.navbar .brand{ display:inline-flex; align-items:center; gap:11px; }
+.brand-mark{ width:36px; height:36px; border-radius:10px; flex:none; display:inline-grid; place-items:center;
+ background:linear-gradient(150deg,#136f6f,#0e5757); box-shadow:0 1px 2px rgba(17,32,31,.15); }
+.brand-mark svg{ display:block; }
+.brand-text{ display:flex; flex-direction:column; line-height:1.12; }
+.brand-title{ font-weight:680; letter-spacing:-.01em; font-size:1.05rem; color:#11201f; }
+.brand-sub{ font-size:.72rem; font-weight:400; color:var(--muted); }
+
+/* panel de filtros */
+.bslib-sidebar-layout > .sidebar, .sidebar > .sidebar-content{ background:#fff; }
+.sidebar .control-label,
+.sidebar .shiny-input-container > label,
+.sidebar .shiny-input-container .control-label{
+ font-size:.7rem; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
+ color:var(--faint); margin-bottom:.45rem;
+}
+
+/* chips do tipo de actualización (checkboxGroupButtons #legendFilter, btn-check de BS5) */
+#legendFilter .btn-group-container-sw{ display:flex; flex-wrap:wrap; gap:6px; }
+#legendFilter .btn{
+ border:1px solid #c5dada !important; background:#fff !important; color:#11201f !important;
+ border-radius:99px !important; font-weight:540; font-size:.78rem; padding:5px 11px;
+ box-shadow:none !important; margin:0 !important;
+}
+/* marcado -> destacado */
+#legendFilter .btn-check:checked + .btn{
+ opacity:1; background:#fff !important; border-color:#136f6f !important; color:#11201f !important;
+}
+/* non marcado -> atenuado */
+#legendFilter .btn-check:not(:checked) + .btn{
+ opacity:.4; background:#eef5f5 !important; border-color:#dcebeb !important; color:#7c8c8b !important;
+}
+#legendFilter .btn-check:focus-visible + .btn{ outline:2px solid #136f6f; outline-offset:2px; }
+#legendFilter .sw{ display:inline-block; width:9px; height:9px; border-radius:50%;
+ margin-right:7px; vertical-align:middle; }
+
+/* selects máis limpos */
+.sidebar .selectize-input, .sidebar .form-control{ border-color:#c5dada; border-radius:8px; }
+
+/* rango de datas en dúas chips compactas que collen no panel */
+.sidebar .shiny-date-range-input .input-daterange{ display:flex; align-items:center; gap:6px; flex-wrap:nowrap; }
+.sidebar .shiny-date-range-input .input-daterange input{
+ border:1px solid #c5dada !important; border-radius:8px !important; background:#fff;
+ font-variant-numeric:tabular-nums; text-align:center; min-width:0; width:100%;
+ font-size:.82rem; padding:.35rem .4rem; }
+.sidebar .shiny-date-range-input .input-group-addon,
+.sidebar .shiny-date-range-input .input-group-text{
+ border:0 !important; background:transparent !important; color:var(--faint); padding:0 2px; flex:0 0 auto; }
+.sidebar .shiny-date-range-input input:focus{
+ border-color:#136f6f !important; box-shadow:0 0 0 2px rgba(19,111,111,.15) !important; outline:0; }
+
+/* footer sempre ao fondo (non colapsa cara arriba en pestanas curtas).
+ O footer é fillo directo de , así que é main
+ quen ten que ser columna flex e encher o alto; o footer empúrrase co auto. */
+body.bslib-page-navbar{ display:flex; flex-direction:column; min-height:100vh; }
+main.bslib-page-main{ display:flex; flex-direction:column; flex:1 1 auto; min-height:0; }
+main.bslib-page-main > .app-footer{ margin-top:auto; }
+
+/* lenda do mapa (leaflet addLegend) — tarxeta ao estilo do mockup */
+.leaflet-control.legend, .leaflet .info.legend{
+ background:#fff !important; border:1px solid #dcebeb !important; border-radius:12px !important;
+ box-shadow:0 1px 2px rgba(17,32,31,.05), 0 6px 20px rgba(17,32,31,.08) !important;
+ padding:12px 14px !important; color:#11201f !important; line-height:1.7 !important; font-size:.81rem;
+}
+.leaflet-control.legend i{
+ width:11px !important; height:11px !important; border-radius:50% !important;
+ margin-right:8px !important; margin-top:4px !important; opacity:1 !important; border:0 !important;
+}
+.leaflet-control.legend strong{
+ display:block; margin-bottom:8px; text-transform:uppercase; letter-spacing:.07em;
+ font-size:.7rem; color:#7c8c8b; font-weight:700;
+}
+
+/* popup do mapa ao estilo do mockup */
+.leaflet-popup-content-wrapper{ border-radius:12px !important; padding:0 !important;
+ border:1px solid var(--border-light);
+ box-shadow:0 4px 24px rgba(17,32,31,.18) !important; }
+.leaflet-popup-content{ margin:0 !important; width:270px !important;
+ max-height:340px; overflow-y:auto; line-height:1.45; }
+.leaflet-popup-close-button{ top:8px !important; right:8px !important; color:#7c8c8b !important; }
+.pc{ color:#11201f; }
+.pc-state{ display:inline-flex; align-items:center; gap:6px; margin:14px 15px 0;
+ font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--pc,#136f6f); }
+.pc-state::before{ content:""; width:8px; height:8px; border-radius:50%; background:var(--pc,#136f6f); }
+.pc-title{ margin:7px 15px 0; font-size:1rem; font-weight:660; line-height:1.2; }
+.pc-sub{ margin:1px 15px 12px; font-size:.78rem; color:var(--muted); }
+.pc-dl{ margin:0; padding:11px 15px; border-top:1px solid var(--border-light);
+ display:grid; grid-template-columns:auto 1fr; gap:6px 12px; font-size:.79rem; }
+.pc-row{ display:contents; }
+.pc-row dt{ color:var(--faint); font-weight:500; }
+.pc-row dd{ margin:0; text-align:right; font-weight:400; word-break:break-word; }
+.pc-foot{ padding:10px 15px; border-top:1px solid var(--border-light); display:flex; flex-wrap:wrap; gap:14px; }
+.pc-foot a{ font-weight:580; font-size:.82rem; color:#136f6f; }
+.pc-imgs-label{ color:var(--faint); font-weight:500; font-size:.82rem; }