From 5a7d2e052b84f6f93f85f009fe874490cb4510ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=A9s=20Ortega?= Date: Sat, 27 Jun 2026 16:55:58 -0700 Subject: [PATCH 1/5] =?UTF-8?q?Redise=C3=B1o=20da=20UI=20con=20bslib=20(Bo?= =?UTF-8?q?otstrap=205),=20mantendo=20o=20core=20de=20R=20intacto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrar a UI de shinydashboard -> bslib: page_navbar + sidebar + nav_panel + card() + value_box(); elimínanse shinydashboard/shinythemes do código - bs_theme coa identidade visual de inesortega.github.io (teal #136f6f, fondo #f7fbfb, tipografía Inter) - Consolidar o CSS en www/styles.css (substitúe ~15 tags$style dispersos) - Mapa: addProviderTiles(CartoDB.Positron) no canto de addTiles() - KPIs: value_box + renderText/box_value (mesma computación de sempre) - Gráficas: theme_minimal + paleta de categorías - Mantéñense TODOS os inputId/outputId, reactivos, observers e data_loader.R; o navbar usa id='sidebarID' para conservar input$sidebarID == 'map' Verificado: parse OK, build do obxecto ui OK, smoke test de runtime (HTTP 200, "Listening on 0.0.0.0:3838", sen erros). Co-Authored-By: Claude Opus 4.8 (1M context) --- app.R | 1241 ++++++++++++++++++++++++------------------------ www/styles.css | 55 +++ 2 files changed, 666 insertions(+), 630 deletions(-) create mode 100644 www/styles.css diff --git a/app.R b/app.R index be48901..68b4f88 100644 --- a/app.R +++ b/app.R @@ -1,630 +1,611 @@ -# 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" +) + +# ---- Panel lateral de filtros (mesmos inputId que a versión orixinal) ------- +filters <- sidebar( + width = 340, + 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", + "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 = ""), + hr(), + uiOutput("sidebarContent") +) + +# ---- 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-title", "Unha Vez Máis"), + 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 = "Mapa en tempo real", + value = "map", + div(class = "intro", + h4("Mapa en tempo real da situación das praias galegas"), + p("O mapa reflicte as actualizacións do estado das praias enviadas polo voluntariado. ", + "Atoparás información estatística na pestana «Datos e estatísticas»."), + p("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 última semana.") + ), + card( + full_screen = TRUE, + class = "map-card", + leafletOutput("mymap", height = "calc(100vh - 250px)") + ) + ), + + nav_panel( + title = "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 = "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) { + + 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( + 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 <- 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")), + 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.") + } + }) +} + +# 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..5a79ff0 --- /dev/null +++ b/www/styles.css @@ -0,0 +1,55 @@ +/* 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; } +.navbar .brand-title{ font-weight:680; letter-spacing:-.01em; font-size:1.05rem; } +.navbar .nav-link{ font-weight:560; } + +/* 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.1rem; 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); + padding:0 .35rem; } +.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; } From 5811dcc32f3276a10aac8414c24de44de30e95eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=A9s=20Ortega?= Date: Sat, 27 Jun 2026 17:07:54 -0700 Subject: [PATCH 2/5] =?UTF-8?q?Achegar=20a=20UI=20ao=20mockup:=20chips=20d?= =?UTF-8?q?e=20tipo,=20r=C3=B3tulos=20en=20mai=C3=BAsculas=20e=20logotipo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Tipo de actualización": checkboxGroupButtons como chips con punto de cor por categoría (mesmo inputId 'legendFilter' e mesmos valores -> server igual) - Rótulos do panel como micro-rótulos (eyebrow) en maiúsculas - Logotipo (onda) na marca da cabeceira - Panel lateral en branco; slider e selects coa paleta teal Verificado: parse OK, runtime HTTP 200, chips e logo presentes, sen erros. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.R | 60 ++++++++++++++++++++++++++++++++------------------ www/styles.css | 34 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/app.R b/app.R index 68b4f88..03fdce2 100644 --- a/app.R +++ b/app.R @@ -34,31 +34,44 @@ filters <- sidebar( width = 340, sliderInput( inputId = "dateRange", - label = "Filtrado por data:", + label = "Filtrado por data", min = as.Date("2024-01-01"), max = Sys.Date() + 2, value = c(Sys.Date() - 30, Sys.Date() + 1) ), - pickerInput("legendFilter", - "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" - )), + 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 = ""), hr(), @@ -77,7 +90,10 @@ kpi_box <- function(outputId, title, subtitle = NULL, accent = "primary") { # ---- UI --------------------------------------------------------------------- ui <- page_navbar( - title = tags$span(class = "brand-title", "Unha Vez Máis"), + title = tags$span(class = "brand", + tags$span(class = "brand-mark", HTML('')), + tags$span(class = "brand-title", "Unha Vez Máis") + ), id = "sidebarID", # conserva input$sidebarID (== "map") que usa o server theme = app_theme, fillable = FALSE, diff --git a/www/styles.css b/www/styles.css index 5a79ff0..5d79d04 100644 --- a/www/styles.css +++ b/www/styles.css @@ -53,3 +53,37 @@ body{ border-top:3px solid #136f6f; } /* agradecementos */ .ack{ max-width:72ch; color:#11201f; line-height:1.6; } + +/* logotipo na cabeceira */ +.navbar .brand{ display:inline-flex; align-items:center; gap:10px; } +.brand-mark{ width:32px; height:32px; border-radius:9px; 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; } + +/* 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) */ +#legendFilter .btn-group-container-sw{ display:flex; flex-wrap:wrap; gap:6px; } +#legendFilter .btn-group{ display:inline-flex; } +#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; +} +#legendFilter .btn:not(.active){ opacity:.42; background:#eef5f5 !important; } +#legendFilter .btn.active{ opacity:1; border-color:#136f6f !important; } +#legendFilter .btn:focus-visible{ 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 e slider máis limpos */ +.sidebar .selectize-input, .sidebar .form-control{ border-color:#c5dada; border-radius:8px; } +.irs--shiny .irs-bar, .irs--shiny .irs-handle>i:first-child{ background:#136f6f; } +.irs--shiny .irs-from, .irs--shiny .irs-to, .irs--shiny .irs-single{ background:#136f6f; } From a1f81e857ab738bbc0a485887e6087aa2a305ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=A9s=20Ortega?= Date: Sat, 27 Jun 2026 18:01:33 -0700 Subject: [PATCH 3/5] melloras visuais --- .gitignore | 1 + PelletMap.Rproj | 34 +++++----- app.R | 163 +++++++++++++++++++++++++++++++----------------- www/styles.css | 102 +++++++++++++++++++++++++----- 4 files changed, 212 insertions(+), 88 deletions(-) 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 03fdce2..983f735 100644 --- a/app.R +++ b/app.R @@ -26,18 +26,24 @@ app_theme <- bs_theme( danger = "#d6342a", base_font = font_collection("Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica", "Arial", "sans-serif"), - "border-color" = "#dcebeb" + "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 = 340, - sliderInput( + 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, - value = c(Sys.Date() - 30, Sys.Date() + 1) + separator = " — ", + format = "yyyy-mm-dd", + language = "es", + weekstart = 1 ), checkboxGroupButtons( inputId = "legendFilter", @@ -91,8 +97,11 @@ kpi_box <- function(outputId, title, subtitle = NULL, accent = "primary") { # ---- UI --------------------------------------------------------------------- ui <- page_navbar( title = tags$span(class = "brand", - tags$span(class = "brand-mark", HTML('')), - tags$span(class = "brand-title", "Unha Vez Máis") + 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, @@ -104,24 +113,23 @@ ui <- page_navbar( ), nav_panel( - title = "Mapa en tempo real", - value = "map", - div(class = "intro", - h4("Mapa en tempo real da situación das praias galegas"), - p("O mapa reflicte as actualizacións do estado das praias enviadas polo voluntariado. ", - "Atoparás información estatística na pestana «Datos e estatísticas»."), - p("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 última semana.") + title = tagList( + HTML(''), + "Mapa en tempo real" ), + value = "map", card( full_screen = TRUE, class = "map-card", - leafletOutput("mymap", height = "calc(100vh - 250px)") + leafletOutput("mymap", height = "calc(100vh - 140px)") ) ), nav_panel( - title = "Datos e estatísticas", + title = tagList( + HTML(''), + "Datos e estatísticas" + ), div(class = "stats-wrap", div(class = "lead", h4("Datos e estatísticas"), @@ -156,7 +164,10 @@ ui <- page_navbar( ), nav_panel( - title = "Agradecementos", + 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")) ) @@ -213,7 +224,14 @@ server <- function(input, output, session) { # 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 (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] @@ -514,19 +532,21 @@ server <- function(input, output, session) { 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), "
") + 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 + ) ), stroke = FALSE, fillOpacity = 0.5 ) @@ -542,12 +562,16 @@ server <- function(input, output, session) { 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)), "
") + 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 ) @@ -562,12 +586,20 @@ server <- function(input, output, session) { 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)), "
") + 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 ) @@ -581,19 +613,16 @@ server <- function(input, output, session) { 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), "
") + 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 + ) ), color = "#33a02c", stroke = FALSE, fillOpacity = 0.5 @@ -611,6 +640,28 @@ server <- function(input, output, session) { }) } +# 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("
", foot, "
")) + 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")) { diff --git a/www/styles.css b/www/styles.css index 5d79d04..b2fb295 100644 --- a/www/styles.css +++ b/www/styles.css @@ -8,9 +8,22 @@ body{ border-top:3px solid #136f6f; } /* barra de navegación */ -.navbar{ background:#e6f0f0 !important; border-bottom:1px solid #c5dada; } +.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; } -.navbar .nav-link{ font-weight:560; } +/* 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; } @@ -29,7 +42,7 @@ body{ border-top:3px solid #136f6f; } .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.1rem; font-weight:680; font-variant-numeric:tabular-nums; +.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); } @@ -40,7 +53,7 @@ body{ border-top:3px solid #136f6f; } /* indicador "en directo" */ .live-pill{ display:inline-flex; align-items:center; gap:7px; font-size:.78rem; color:var(--muted); - padding:0 .35rem; } + 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; } @@ -54,11 +67,15 @@ body{ border-top:3px solid #136f6f; } /* agradecementos */ .ack{ max-width:72ch; color:#11201f; line-height:1.6; } -/* logotipo na cabeceira */ -.navbar .brand{ display:inline-flex; align-items:center; gap:10px; } -.brand-mark{ width:32px; height:32px; border-radius:9px; display:inline-grid; place-items:center; +/* 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; } @@ -69,21 +86,76 @@ body{ border-top:3px solid #136f6f; } color:var(--faint); margin-bottom:.45rem; } -/* chips do tipo de actualización (checkboxGroupButtons #legendFilter) */ +/* 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-group{ display:inline-flex; } #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; } -#legendFilter .btn:not(.active){ opacity:.42; background:#eef5f5 !important; } -#legendFilter .btn.active{ opacity:1; border-color:#136f6f !important; } -#legendFilter .btn:focus-visible{ outline:2px solid #136f6f; outline-offset:2px; } +/* 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 e slider máis limpos */ +/* selects máis limpos */ .sidebar .selectize-input, .sidebar .form-control{ border-color:#c5dada; border-radius:8px; } -.irs--shiny .irs-bar, .irs--shiny .irs-handle>i:first-child{ background:#136f6f; } -.irs--shiny .irs-from, .irs--shiny .irs-to, .irs--shiny .irs-single{ background:#136f6f; } + +/* 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) */ +body.bslib-page-navbar{ display:flex; flex-direction:column; min-height:100vh; } +body.bslib-page-navbar > .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:540; 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; } From ac84281820d29b0b59ef1a809fd42fdcaafbd575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=A9s=20Ortega?= Date: Sat, 27 Jun 2026 18:27:49 -0700 Subject: [PATCH 4/5] melloras visuais --- app.R | 30 ++++++++++++++++++++++++------ www/styles.css | 3 ++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/app.R b/app.R index 983f735..107c46f 100644 --- a/app.R +++ b/app.R @@ -525,7 +525,7 @@ server <- function(input, output, session) { 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")), + 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), @@ -546,7 +546,8 @@ server <- function(input, output, session) { "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 ) @@ -555,7 +556,7 @@ server <- function(input, output, session) { 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"), + 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, @@ -579,7 +580,7 @@ server <- function(input, output, session) { 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"), + 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, @@ -607,7 +608,7 @@ server <- function(input, output, session) { 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)")), + 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), @@ -622,7 +623,8 @@ server <- function(input, output, session) { "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 @@ -640,6 +642,22 @@ server <- function(input, output, session) { }) } +# 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) { diff --git a/www/styles.css b/www/styles.css index b2fb295..48052c7 100644 --- a/www/styles.css +++ b/www/styles.css @@ -156,6 +156,7 @@ body.bslib-page-navbar > .app-footer{ margin-top:auto; } 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:540; word-break:break-word; } +.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; } From 6c99c290d08597dea8395f3563fbe89b841a3bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=A9s=20Ortega?= Date: Sat, 27 Jun 2026 18:38:16 -0700 Subject: [PATCH 5/5] melloras visuais --- app.R | 104 +------------------------------------------------ www/styles.css | 7 +++- 2 files changed, 6 insertions(+), 105 deletions(-) diff --git a/app.R b/app.R index 107c46f..f9b2ad2 100644 --- a/app.R +++ b/app.R @@ -79,9 +79,7 @@ filters <- sidebar( ) ), selectInput(inputId = "select_provincia", label = "Provincia", multiple = TRUE, choices = c(), selected = ""), - selectInput(inputId = "select_concello", label = "Concello", multiple = TRUE, choices = c(), selected = ""), - hr(), - uiOutput("sidebarContent") + selectInput(inputId = "select_concello", label = "Concello", multiple = TRUE, choices = c(), selected = "") ) # ---- Tarxeta KPI auxiliar --------------------------------------------------- @@ -184,15 +182,8 @@ ui <- page_navbar( 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({ @@ -261,99 +252,6 @@ server <- function(input, output, session) { 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( - 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 <- renderText({ distinct_counts <- data() %>% diff --git a/www/styles.css b/www/styles.css index 48052c7..ef3d720 100644 --- a/www/styles.css +++ b/www/styles.css @@ -120,9 +120,12 @@ body{ border-top:3px solid #136f6f; } .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) */ +/* 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; } -body.bslib-page-navbar > .app-footer{ margin-top:auto; } +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{