diff --git a/app.R b/app.R index f9b2ad2..1c3e159 100644 --- a/app.R +++ b/app.R @@ -83,11 +83,12 @@ filters <- sidebar( ) # ---- Tarxeta KPI auxiliar --------------------------------------------------- -kpi_box <- function(outputId, title, subtitle = NULL, accent = "primary") { +kpi_box <- function(outputId, title, subtitle = NULL, accent = "primary", trendId = NULL) { value_box( title = tags$span(class = "kpi-title", title, if (!is.null(subtitle)) tags$span(class = "kpi-sub", subtitle)), value = textOutput(outputId, inline = TRUE), + if (!is.null(trendId)) tags$span(class = "kpi-trend", textOutput(trendId, inline = TRUE)), class = paste0("kpi-card kpi-", accent) ) } @@ -104,7 +105,6 @@ ui <- page_navbar( 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")) @@ -116,10 +116,13 @@ ui <- page_navbar( "Mapa en tempo real" ), value = "map", - card( - full_screen = TRUE, - class = "map-card", - leafletOutput("mymap", height = "calc(100vh - 140px)") + layout_sidebar( + sidebar = filters, + card( + full_screen = TRUE, + class = "map-card", + leafletOutput("mymap", height = "calc(100vh - 140px)") + ) ) ), @@ -132,30 +135,29 @@ ui <- page_navbar( 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.")) + "Resumo sobre todos os datos rexistrados. Recóllense de xeito colaborativo a través deste ", + "formulario ", + "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") + kpi_box("n_concellos", "Concellos", "con actualizacións", trendId = "n_concellos_trend"), + kpi_box("n_praias", "Praias", "con actualizacións", trendId = "n_praias_trend"), + kpi_box("n_update", "Actualizacións", "recibidas no formulario", trendId = "n_update_trend"), + kpi_box("n_112", "Notificacións 112", "total recibidas", accent = "danger", trendId = "n_112_trend") ), 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_limpas", "Praias limpas", 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"), + card(card_header("Evolución por tipo de residuo (últimos 3 meses)"), withSpinner(plotOutput("cum_residuos"))), - card(card_header("Evolución diaria — actualizacións recibidas"), + card(card_header("Actualizacións recibidas por día (histórico)"), withSpinner(plotOutput("cum_praias"))) ) ) @@ -184,8 +186,11 @@ server <- function(input, output, session) { isFirstRun <- reactiveVal(TRUE) - data <- reactive({ + # Datos base (lectura + transformacións), SEN filtros. Compártense entre o + # mapa (filtrado polo panel) e as estatísticas (datos completos). + all_data_r <- reactive({ invalidateLater(350000) + all_data <- NULL tryCatch({ all_data <- read_csv("./data/praias.csv", show_col_types = FALSE) all_data <- all_data %>% @@ -212,6 +217,12 @@ server <- function(input, output, session) { }, error = function(e){ message("Error loading data...") }) + all_data + }) + + # Datos filtrados polo panel lateral (só para o mapa). + data <- reactive({ + all_data <- all_data_r() # Filter data based on selected date range max <- max(as.Date(all_data$Data.Norm)) min <- min(as.Date(all_data$Data.Norm)) @@ -252,116 +263,90 @@ server <- function(input, output, session) { 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) + ###### Estatísticas (datos completos, sen filtros) ################ + fulldata <- reactive(all_data_r()) + + residuo_tipos <- c("Hai pellets na praia", "Hai chapapote", "Hai biosoportes") + praia_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)") + limpa_tipos <- c("A praia está limpa", "Non hai pellets na praia", + "Xa non hai (a praia quedaba limpa cando se encheu o formulario)") + evento_tipos <- c("Convocatoria de xornada de limpeza", "Outras Convocatorias") + + # Data de referencia = última con datos (en produción ≈ hoxe). As xanelas + # (semana, 3 meses) áncoranse a ela para que sempre amosen actividade recente. + ref_date <- reactive({ + d <- fulldata() + if (is.null(d) || !nrow(d)) Sys.Date() else max(d$Data.Norm, na.rm = TRUE) }) + last_week <- reactive(ref_date() - 7) - 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)) - }) + # Tendencia: actividade na última semana con datos + trend_chip <- function(n) { + if (is.na(n) || n == 0) "sen novidades esta semana" + else paste0("▲ ", format(n, big.mark = " ", trim = TRUE), " esta semana") + } - 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$n_concellos <- renderText(box_value(length(unique(fulldata()$Concello)))) + output$n_concellos_trend <- renderText( + trend_chip(length(unique((fulldata() %>% filter(Data.Norm >= last_week()))$Concello)))) + + output$n_praias <- renderText( + box_value(length(unique((fulldata() %>% + filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% praia_tipos))$Nome.da.praia..Concello)))) + output$n_praias_trend <- renderText( + trend_chip(length(unique((fulldata() %>% + filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% praia_tipos, + Data.Norm >= last_week()))$Nome.da.praia..Concello)))) + + output$n_update <- renderText(box_value(nrow(fulldata()))) + output$n_update_trend <- renderText( + trend_chip(nrow(fulldata() %>% filter(Data.Norm >= last_week())))) + + output$n_112 <- renderText( + box_value(sum(fulldata()$Está.avisado.o.112. == "Si", na.rm = TRUE))) + output$n_112_trend <- renderText( + trend_chip(sum((fulldata() %>% filter(Data.Norm >= last_week()))$Está.avisado.o.112. == "Si", na.rm = TRUE))) + + output$n_pellets <- renderText( + box_value(sum(fulldata()$Tipo.de.actualización.que.nos.queres.facer.chegar %in% residuo_tipos))) + output$n_limpas <- renderText( + box_value(sum(fulldata()$Tipo.de.actualización.que.nos.queres.facer.chegar %in% limpa_tipos))) + output$n_limpezas <- renderText( + box_value(sum(fulldata()$Tipo.de.actualización.que.nos.queres.facer.chegar %in% evento_tipos))) + + # Evolución por tipo de residuo — últimos 3 meses + output$cum_residuos <- renderPlot({ + d <- fulldata() %>% + filter(Tipo.de.actualización.que.nos.queres.facer.chegar %in% residuo_tipos, + Data.Norm >= ref_date() - 90) %>% + count(Residuo, Data.Norm) %>% + group_by(Data.Norm, Residuo) %>% + summarize(sum_n = sum(n), .groups = "drop") + ggplot(d, 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 = NULL, y = "Actualizacións", color = NULL) + + theme_minimal(base_size = 13) + + theme(legend.position = "bottom", + plot.background = element_rect(fill = "transparent", colour = NA)) }) + # Actualizacións recibidas por día — histórico completo output$cum_praias <- renderPlot({ - distinct_counts <- data() %>% - count(Nome.da.praia..Concello, Concello, Data.Norm) - - summarized_data <- distinct_counts %>% + d <- fulldata() %>% + count(Nome.da.praia..Concello, Concello, Data.Norm) %>% group_by(Data.Norm) %>% - summarize(sum_n = sum(n)) - - ggplot(summarized_data, aes(x = Data.Norm, y = sum_n)) + + summarize(sum_n = sum(n), .groups = "drop") + ggplot(d, 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") + + labs(x = NULL, y = "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({ diff --git a/www/styles.css b/www/styles.css index ef3d720..b12505e 100644 --- a/www/styles.css +++ b/www/styles.css @@ -47,6 +47,7 @@ body{ border-top:3px solid #136f6f; } .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; } +.kpi-trend{ display:block; margin-top:7px; font-size:.72rem; font-weight:560; color:var(--accent, #136f6f); } /* tarxetas de gráficas */ .card-header{ font-weight:620; font-size:.95rem; }