# Chargement des bibliothèques library(shiny) library(shinydashboard) library(leaflet) library(plotly) library(dplyr) library(sf) library(RColorBrewer) library(DT) library(rnaturalearth) library(rnaturalearthdata) # Chargement des données load("data/TB_analysis_ready.RData") # Définition des labels pour les clusters labels <- c("1. Faible Impact", "2. Impact Modéré", "3. Impact Critique") # Application des labels aux données tb_clustered$label <- factor(tb_clustered$label) levels(tb_clustered$label) <- labels # Création de la carte du monde world_map <- ne_countries(scale = "medium", returnclass = "sf") # Définition des couleurs pour les clusters green <- "#66bd63" orange <- "#f48a43" red <- "#d73027" # Interface utilisateur ui <- shinydashboard::dashboardPage( skin = "black", # Header dashboardHeader(title = "Tuberculose"), # Sidebar dashboardSidebar( sidebarMenu( menuItem( "Méthodologie & Définitions", tabName = "methodo", icon = icon("info-circle") ), menuItem( "Vue d'Ensemble", tabName = "dashboard", icon = icon("dashboard") ), menuItem("Données Brutes", tabName = "data", icon = icon("table")), # Footer - Informations et crédits div( style = "position: absolute; bottom: 10px; width: 100%; text-align: center; font-size: 12px; color: #b8c7ce;", p("© 2026 Arthur Danjou"), p("M2 ISF - Dauphine PSL"), p( a( "Code Source", href = "https://go.arthurdanjou.fr/datavis", target = "_blank", style = "color: #3c8dbc;" ) ) ), hr(), # Filtre par Région selectInput( "region_select", "Filtrer par Région :", choices = c("Toutes", unique(tb_clustered$g_whoregion)), selected = "Toutes" ), # Sélecteur d'année sliderInput( "year_select", "Année :", min = min(tb_clustered$year), max = max(tb_clustered$year), value = max(tb_clustered$year), step = 1, sep = "", animate = animationOptions(interval = 5000, loop = FALSE) ) ) ), # Body dashboardBody( tabItems( # Page 1 - Vue d'Ensemble tabItem( tabName = "dashboard", # KPI - Total des cas fluidRow( valueBoxOutput("kpi_total_cases", width = 4), valueBoxOutput("kpi_worst_country", width = 4), valueBoxOutput("kpi_critical_count", width = 4) ), # Carte Épidémiologique fluidRow( box( width = 7, title = "Carte Épidémiologique", status = "primary", solidHeader = TRUE, radioButtons( "metric_select", "Indicateur :", choices = c( "Incidence" = "e_inc_100k", "Mortalité" = "e_mort_exc_tbhiv_100k", "Clusters K-Means" = "label" ), inline = TRUE ), p( class = "text-muted", "Cliquer sur un point pour filtrer par pays." ), leafletOutput("map_plot", height = "500px") ), # Scatter Plot des Clusters box( width = 5, title = "Analyse des Clusters (Incidence vs Mortalité)", status = "success", solidHeader = TRUE, p( class = "text-muted", style = "font-size:0.9em", "Chaque point est un pays. Les couleurs correspondent aux groupes de risque identifiés par l'algorithme K-Means." ), p( class = "text-muted", "Cliquer sur un point pour filtrer par pays." ), plotlyOutput("cluster_scatter", height = "530px") ) ), fluidRow( # Plot des tendances box( width = 7, title = "Comparaison : Pays vs Moyenne Régionale vs Moyenne Mondiale", status = "warning", solidHeader = TRUE, plotlyOutput("trend_plot", height = "400px") ), # Distribution des Clusters box( width = 5, title = "Distribution des Clusters", status = "info", solidHeader = TRUE, p( class = "text-muted", "Cliquer sur un point du rug pour filtrer par pays." ), plotlyOutput("density_plot", height = "400px") ) ) ), # Page 2 - Données Brutes tabItem( tabName = "data", fluidRow( box( width = 12, title = "Explorateur de Données", status = "primary", p("Tableau filtrable et exportable des données utilisées."), DTOutput("raw_table") ) ) ), # Page 3 - Méthodologie tabItem( tabName = "methodo", fluidRow( # Indicateurs OMS box( width = 12, title = "Définitions des Indicateurs OMS", status = "info", solidHeader = TRUE, column( width = 6, h4(icon("lungs"), "Incidence de la Tuberculose"), p( "Correspond à la variable ", code("e_inc_100k"), " dans le jeu de données de l'OMS." ), p( "Il s'agit du nombre estimé de ", strong("nouveaux cas"), " de tuberculose (toutes formes confondues) survenus au cours d'une année donnée, rapporté pour 100 000 habitants." ), p( "Cet indicateur mesure la ", em("propagation"), " de la maladie dans la population." ), ), column( width = 6, h4(icon("skull"), "Mortalité (hors VIH)"), p( "Correspond à la variable ", code("e_mort_exc_tbhiv_100k"), "." ), p( "Il s'agit du nombre estimé de décès dus à la tuberculose chez les personnes non infectées par le VIH, rapporté pour 100 000 habitants." ), p( "Cet indicateur mesure la ", em("sévérité"), " et l'efficacité de l'accès aux soins (un taux élevé signale souvent un système de santé défaillant)." ) ), ), ), # Choix des Variables fluidRow( column( width = 6, box( width = 12, title = "Pourquoi seulement 2 variables actives ?", status = "warning", solidHeader = TRUE, p( "Le modèle de clustering repose uniquement sur l'Incidence et la Mortalité. Ce choix de parcimonie est justifié par 4 contraintes techniques :" ), br(), column( width = 6, h4( icon("ruler-combined"), "1. Robustesse Mathématique", class = "text-warning" ), p( "Évite le 'fléau de la dimension'. Avec trop de variables, les distances euclidiennes perdent leur sens et les groupes deviennent flous." ), br(), h4( icon("project-diagram"), "2. Non-Colinéarité", class = "text-warning" ), p( "Évite de compter deux fois la même information (ex: Incidence vs Nombre de cas) qui fausserait le poids des indicateurs." ), ), column( width = 6, h4( icon("filter"), "3. Qualité des Données", class = "text-warning" ), p( "Le K-Means ne tolère pas les données manquantes. Ajouter des variables socio-économiques aurait réduit la taille de l'échantillon de 30% à 50%." ), br(), h4(icon("eye"), "4. Lisibilité", class = "text-warning"), p( "Permet une visualisation directe en 2D (Scatterplot) sans déformation, rendant l'outil accessible aux non-statisticiens." ) ) ), # Source des Données box( width = 12, title = "Source des Données", status = "danger", solidHeader = TRUE, p( icon("database"), "Les données sont issues du ", a( "Global Tuberculosis Report", href = "https://www.who.int/teams/global-programme-on-tuberculosis-and-lung-health/data", target = "_blank" ), " de l'Organisation Mondiale de la Santé (OMS)." ), p("Dernière mise à jour du dataset : Octobre 2024.") ), ), column( width = 6, box( width = 12, title = "Algorithme de Classification (Clustering)", status = "success", solidHeader = TRUE, h4("Pourquoi un Clustering K-Means ?"), p( "Afin de synthétiser l'information et de faciliter la prise de décision, j'ai appliqué un algorithme d'apprentissage non supervisé (K-Means) pour regrouper les pays ayant des profils épidémiques similaires sous 4 clusters." ), h4("Méthodologie"), tags$ul( tags$li( strong("Variables :"), " Incidence et Mortalité (centrées et réduites pour assurer un poids équivalent)." ), tags$li( strong("Nombre de Clusters (k) :"), " Fixé à 3 pour obtenir une segmentation tricolore lisible (Faible, Modéré, Critique)." ), tags$li( strong("Stabilité :"), "Utilisation de `set.seed(123)` pour garantir la reproductibilité des résultats." ) ), h4("Interprétation des 3 Groupes"), # Tableau des Groupes tags$table( class = "table table-striped", tags$thead( tags$tr( tags$th("Cluster"), tags$th("Description"), tags$th("Profil Type") ) ), tags$tbody( tags$tr( tags$td(span( style = paste0( "background-color:", green, "; color: black; font-weight: bold; padding: 5px; border-radius: 5px;" ), labels[1] )), tags$td("Incidence et mortalité très basses."), tags$td("Europe de l'Ouest, Amérique du Nord") ), tags$tr( tags$td(span( style = paste0( "background-color:", orange, "; color: black; font-weight: bold; padding: 5px; border-radius: 5px;" ), labels[2] )), tags$td("Incidence significative mais mortalité contenue."), tags$td("Amérique Latine, Maghreb, Europe de l'Est") ), tags$tr( tags$td(span( style = paste0( "background-color:", red, "; color: black; font-weight: bold; padding: 5px; border-radius: 5px;" ), labels[3] )), tags$td("Incidence massive et forte létalité."), tags$td("Afrique Subsaharienne, Zones de conflit") ) ) ) ), # Code & Documentation box( title = "Code & Documentation", status = "primary", solidHeader = TRUE, width = 12, tags$p( "Ce projet suit une approche Open Science.", style = "font-style: italic;" ), tags$p( "L'intégralité du code source (Rmd, App) ainsi que la notice technique (PDF) sont disponibles en libre accès sur le dépôt GitHub." ), tags$a( href = "https://go.arthurdanjou.fr/datavis-code", target = "_blank", class = "btn btn-block btn-social btn-github", style = "color: white; background-color: #333; border-color: #333;", icon("github"), " Accéder au Code" ) ) ), ), ) ) ) ) # Serveur server <- function(input, output, session) { # Filtrage des données filtered_data <- shiny::reactive({ req(input$year_select) data <- tb_clustered |> filter(year == input$year_select) if (input$region_select != "Toutes") { data <- data |> filter(g_whoregion == input$region_select) } return(data) }) # Données pour la carte map_data_reactive <- shiny::reactive({ req(filtered_data()) world_map |> inner_join(filtered_data(), by = c("adm0_a3" = "iso3")) }) # Définition des couleurs pour les clusters cluster_colors <- setNames( c(green, orange, red), labels ) # Fonction pour dessiner les polygones sur la carte dessiner_polygones <- function(map_object, data, metric) { if (metric == "label") { values_vec <- data$label pal_fun <- leaflet::colorFactor( as.character(cluster_colors), domain = names(cluster_colors) ) fill_vals <- pal_fun(values_vec) legend_title <- "Cluster" label_txt <- paste0(data$name, " - ", data$label) legend_vals <- names(cluster_colors) } else { values_vec <- data[[metric]] pal_fun <- leaflet::colorNumeric( palette = c(green, orange, red), domain = values_vec ) fill_vals <- pal_fun(values_vec) legend_title <- "Taux / 100k" label_txt <- paste0(data$name, ": ", round(values_vec, 1)) legend_vals <- values_vec } map_object |> leaflet::addPolygons( data = data, fillColor = ~fill_vals, weight = 1, color = ifelse(metric == "label", "gray", "black"), fillOpacity = 0.7, layerId = ~adm0_a3, label = ~label_txt, highlightOptions = highlightOptions( weight = 3, color = "#666", bringToFront = TRUE ) ) |> leaflet::addLegend( pal = pal_fun, values = legend_vals, title = legend_title, position = "bottomright" ) } # Carte output$map_plot <- leaflet::renderLeaflet({ isolate({ data <- map_data_reactive() metric <- input$metric_select leaflet(options = leafletOptions(minZoom = 2, maxZoom = 6)) |> addProviderTiles( providers$CartoDB.Positron, options = providerTileOptions(noWrap = TRUE) ) |> setMaxBounds(lng1 = -180, lat1 = -90, lng2 = 180, lat2 = 90) |> setView(lat = 20, lng = 0, zoom = 2) |> dessiner_polygones(data, metric) }) }) # KPI - Total des cas output$kpi_total_cases <- shinydashboard::renderValueBox({ data <- filtered_data() val <- round(mean(data$e_inc_100k, na.rm = TRUE)) valueBox( val, "Incidence Moyenne (cas/100k)", icon = icon("chart-area"), color = "green" ) }) # KPI - Pire pays output$kpi_worst_country <- shinydashboard::renderValueBox({ data <- filtered_data() worst <- data |> arrange(desc(e_inc_100k)) |> slice(1) if (nrow(worst) > 0) { valueBox( worst$country, paste("Max Incidence :", round(worst$e_inc_100k)), icon = icon("exclamation-triangle"), color = "red" ) } else { valueBox("N/A", "Pas de données", icon = icon("ban"), color = "red") } }) # KPI - Pays en phase 'Critique' output$kpi_critical_count <- shinydashboard::renderValueBox({ data <- filtered_data() count <- sum(data$label == labels[3], na.rm = TRUE) valueBox( count, "Pays en phase 'Critique'", icon = icon("hospital"), color = "orange" ) }) # Plot des tendances output$trend_plot <- plotly::renderPlotly({ req(selected_country()) country_hist <- tb_clustered |> filter(iso3 == selected_country()) if (nrow(country_hist) == 0) { return(NULL) } nom_pays <- unique(country_hist$country)[1] region_du_pays <- unique(country_hist$g_whoregion)[1] region_benchmark <- tb_clustered |> filter(g_whoregion == region_du_pays) |> group_by(year) |> summarise(mean_inc = mean(e_inc_100k, na.rm = TRUE)) global_benchmark <- tb_clustered |> group_by(year) |> summarise(mean_inc = mean(e_inc_100k, na.rm = TRUE)) p <- ggplot() + geom_area( data = country_hist, aes( x = year, y = e_inc_100k, group = 1, text = paste0("Pays : ", nom_pays, "") ), fill = red, alpha = 0.1 ) + geom_line( data = region_benchmark, aes( x = year, y = mean_inc, group = 1, color = "Moyenne Régionale", text = paste0( "Moyenne ", region_du_pays, "
Année : ", year, "
Incidence : ", round(mean_inc, 1) ) ), size = 0.5, linetype = "dashed" ) + geom_line( data = global_benchmark, aes( x = year, y = mean_inc, group = 1, color = "Moyenne Mondiale", text = paste0( "Moyenne Mondiale
Année : ", year, "
Incidence : ", round(mean_inc, 1) ) ), size = 0.75, linetype = "dashed" ) + geom_line( data = country_hist, aes( x = year, y = e_inc_100k, group = 1, color = "Pays Sélectionné", text = paste0( "Pays : ", nom_pays, "
Incidence : ", round(e_inc_100k, 1), "
Mortalité : ", round(e_mort_exc_tbhiv_100k, 1) ) ), size = 1 ) + geom_vline( xintercept = as.numeric(input$year_select), linetype = "dotted", color = "black", alpha = 0.6 ) + scale_color_manual( name = "", values = c( "Moyenne Régionale" = "grey30", "Pays Sélectionné" = red, "Moyenne Mondiale" = "orange" ) ) + labs( title = paste( "Trajectoire :", nom_pays, "vs", region_du_pays, "vs Monde" ), x = "Année", y = "Incidence (pour 100k)" ) + theme_minimal() + theme(legend.position = "bottom") ggplotly(p, tooltip = "text") |> layout( legend = list(orientation = "h", x = 0.1, y = -0.2), hovermode = "x unified" ) }) # Densité des cas output$density_plot <- plotly::renderPlotly({ data <- filtered_data() sel_iso <- selected_country() highlight_data <- data |> filter(iso3 == sel_iso) p <- ggplot(data, aes(x = e_inc_100k, fill = label)) + geom_density( aes(text = paste0("Cluster : ", label)), alpha = 0.6, color = NA ) + geom_rug( aes( color = label, customdata = iso3, text = paste0( "Pays : ", country, "
Incidence : ", round(e_inc_100k), " (cas/100k)
Cluster : ", label ) ), sides = "b", length = unit(0.2, "npc"), size = 1.2, alpha = 0.9 ) + geom_point( data = highlight_data, aes( x = e_inc_100k, y = 0, text = paste0( "PAYS SÉLECTIONNÉ
", country, "
Incidence : ", round(e_inc_100k) ) ), color = "black", fill = "white", shape = 21, size = 4 ) + scale_fill_manual(values = cluster_colors) + scale_color_manual(values = cluster_colors) + scale_x_log10() + labs( title = "Distribution des Risques", x = "Incidence (Échelle Log)", y = NULL ) + theme_minimal() + theme( legend.position = "none", axis.text.y = element_blank(), axis.ticks.y = element_blank(), panel.grid.major.y = element_blank(), panel.grid.minor.y = element_blank() ) ggplotly(p, tooltip = "text", source = "density_click") |> layout(hovermode = "closest") }) # Nuage de points des clusters output$cluster_scatter <- plotly::renderPlotly({ data <- filtered_data() sel_iso <- selected_country() highlight_data <- data %>% filter(iso3 == sel_iso) p <- ggplot(data, aes(x = e_inc_100k, y = e_mort_exc_tbhiv_100k)) + geom_point( aes( color = label, customdata = iso3, text = paste( "Pays:", country, "
Cluster:", label, "
Pop:", round(e_pop_num / 1e6, 1), "M", "
Incidence:", round(e_inc_100k), "
Mortalité:", round(e_mort_exc_tbhiv_100k) ) ), size = 3, alpha = 0.6 ) + geom_point( data = highlight_data, aes( fill = label, text = paste( "PAYS SÉLECTIONNÉ", "
Pays:", country, "
Cluster:", label, "
Incidence:", round(e_inc_100k), "
Mortalité:", round(e_mort_exc_tbhiv_100k) ) ), shape = 21, color = "black", stroke = 1, size = 5, alpha = 1, show.legend = FALSE ) + scale_x_log10() + scale_y_log10() + scale_color_manual(values = cluster_colors) + scale_fill_manual(values = cluster_colors) + labs(title = "Incidence vs Mortalité", x = "Incidence", y = "Mortalité") + theme_minimal() + theme(legend.position = "bottom") ggplotly(p, tooltip = "text", source = "scatter_click") }) # Tableau des données brutes output$raw_table <- DT::renderDT({ data <- filtered_data() |> select( country, year, g_whoregion, e_inc_100k, e_mort_exc_tbhiv_100k, label ) datatable( data, rownames = FALSE, options = list(pageLength = 15, scrollX = TRUE), colnames = c( "Pays", "Année", "Région", "Incidence", "Mortalité", "Cluster" ) ) }) # Mise à jour de la carte shiny::observe({ data <- map_data_reactive() metric <- input$metric_select leafletProxy("map_plot", data = data) |> clearShapes() |> clearControls() |> dessiner_polygones(data, metric) }) # Sélection du pays selected_country <- shiny::reactiveVal("FRA") # Sélection du pays sur la carte shiny::observeEvent(input$map_plot_shape_click, { click <- input$map_plot_shape_click if (!is.null(click$id)) { selected_country(click$id) } }) # Sélection du pays dans le nuage de points shiny::observeEvent(event_data("plotly_click", source = "scatter_click"), { click_info <- event_data("plotly_click", source = "scatter_click") if (!is.null(click_info$customdata)) { selected_country(click_info$customdata) } }) # Sélection du pays dans la densité shiny::observeEvent(event_data("plotly_click", source = "density_click"), { click_info <- event_data("plotly_click", source = "density_click") if (!is.null(click_info$customdata)) { selected_country(click_info$customdata) } }) } # Lancement de l'application Shiny shiny::shinyApp(ui, server)