Explorateur de données Shiny : construire une app interactive en R

Le projet de synthèse : assemblez la série interactivité en une seule application — une barre latérale de filtres pilote un unique jeu de données réactif qui alimente un graphique en direct, une table cherchable et des statistiques descriptives, avec un téléversement CSV optionnel.

Programming
Shiny

Construisez un explorateur de données Shiny complet et exécutable qui relie toute la série interactivité : une barre latérale bslib avec un sélecteur de jeu de données, des sélecteurs de colonnes et un filtre de plage pilote UN SEUL jeu de données filtré réactif qui alimente un nuage de points ggplot2 en direct, une table DT cherchable et un panneau de statistiques descriptives — le tout se mettant à jour ensemble. Comprend le app.R complet, un chemin de téléversement CSV fileInput optionnel, et les concepts clés de mise en œuvre qui le sous-tendent.

Date de publication

17 juillet 2026

Modifié

17 juillet 2026

AstucePoints clés
  • C’est le projet de synthèse — vous assemblez, vous n’apprenez pas de nouvelle syntaxe. L’explorateur de données, ce sont les graphiques interactifs, les tables, les téléversements de fichiers et la mise en page de tableau de bord que vous avez déjà construits, câblés dans une seule application.
  • Un seul jeu de données filtré réactif alimente tout. Calculez les données filtrées une seule fois dans un reactive() ; le graphique, la table, le résumé et les boxes KPI lisent tous ce même résultat. C’est toute la conception — ne filtrez pas les données séparément dans chaque sortie.
  • req() et validate() gardent le tout serein. req(input$xvar) fait attendre les sorties jusqu’à ce que les sélecteurs soient réglés ; validate(need(nrow(...) > 0, "…")) affiche un message convivial au lieu d’une erreur rouge quand un filtre ne correspond à aucune ligne.
  • bslib lui donne l’habillage de tableau de bord. page_sidebar() pour la barre latérale de filtres, value_box() pour les tuiles KPI et card() pour les panneaux graphique et table — la même mise en page que vous avez rencontrée dans les tableaux de bord.
  • Un fileInput() optionnel laisse les utilisateurs apporter leurs propres données. L’application tourne d’emblée sur les jeux intégrés mtcars/iris ; ajoutez un contrôle de téléversement et read.csv() et elle explore n’importe quel CSV.
  • Copiez tout le app.R et exécutez-le en local — un fichier, quatre packages, et vous avez un outil d’exploration réel et réutilisable.

Introduction

Vous avez construit chaque pièce interactive isolément : un graphique qui réagit aux entrées, une table triable et cherchable, un contrôle de téléversement de fichier et une mise en page de tableau de bord. Un explorateur de données, c’est ce qui arrive quand vous les réunissez dans une seule application : l’utilisateur choisit un jeu de données (ou téléverse un CSV), le restreint avec un filtre, et regarde un graphique, une table et un résumé se mettre à jour ensemble. C’est l’outil vers lequel les analystes se tournent en permanence — une manière rapide et sans code de regarder les données sous plusieurs angles à la fois.

C’est une leçon de projet, sa forme diffère donc des leçons de référence qui la précèdent. Il n’y a aucune nouvelle fonction à apprendre — vous les connaissez déjà toutes. La leçon qui vaut d’être apprise ici, c’est l’assemblage : comment câbler les pièces pour qu’elles coopèrent au lieu de se contrarier. L’idée maîtresse est un unique jeu de données filtré réactif que chaque sortie lit, de sorte que le graphique, la table et le résumé s’accordent toujours. Nous regarderons d’abord l’application finie, puis nous parcourrons la poignée de concepts qui la font tenir ensemble.

NoteCopiez toute l’application dans app.R et exécutez-la en local

Le code de cette page n’est pas exécuté ici — une application Shiny a besoin d’une session R active, elle ne peut donc pas tourner dans une page web statique. Copiez le app.R complet ci-dessous dans un fichier et exécutez-le en local avec shiny::runApp() (ou cliquez sur Run App dans RStudio). Installez une fois les quatre packages qu’elle utilise avec install.packages(c("shiny", "bslib", "DT", "ggplot2")) ; elle tourne sur les jeux intégrés mtcars et iris de R, vous n’avez donc besoin d’aucune donnée à vous pour l’essayer.

Ce que vous allez construire

Imaginez l’application finie. Sur la gauche, une barre latérale de filtres : un menu déroulant pour choisir un jeu de données intégré (mtcars ou iris), un bouton « …or upload a CSV » en dessous, puis trois sélecteurs de colonnes — un axe X, un axe Y et un regroupement « Colour by » — et un curseur de plage qui filtre les données sur la colonne X. En haut de la zone principale s’alignent trois tuiles KPI : combien de lignes passent actuellement le filtre, la moyenne de la colonne X et le nombre de colonnes des données. En dessous, côte à côte, un nuage de points et une table cherchable et triable ; et sous celles-ci, un panneau de statistiques descriptives.

La récompense, c’est que tout bouge en même temps. Faites glisser le curseur de plage et le nuage de points se redessine, la table se réduit aux lignes correspondantes, le résumé se recalcule et la tuile « Rows shown » décroît — parce que chacune de ces sorties lit le même jeu de données filtré. Passez le jeu de données de mtcars à iris et les sélecteurs de colonnes se repeuplent avec les nouvelles colonnes ; choisissez une colonne de regroupement et les points se colorent par groupe. Téléversez un CSV et le tout explore votre fichier à la place. Un seul écran, plusieurs vues coordonnées — voilà un explorateur de données.

L’application complète

Voici le tout dans un seul app.R — copiez-le dans un fichier et exécutez-le. Il fait environ 90 lignes, et chaque ligne est quelque chose que vous avez vu dans cette série ; la présentation qui suit explique les parties qui comptent.

# app.R — Interactive Data Explorer
library(shiny)
library(bslib)
library(DT)
library(ggplot2)

# Datasets you can explore out of the box (no upload needed)
builtin <- list(mtcars = mtcars, iris = iris)

ui <- page_sidebar(
  title = "Data Explorer",
  sidebar = sidebar(
    width = 320,
    selectInput("dataset", "Built-in dataset", choices = names(builtin)),
    fileInput("file", "…or upload a CSV", accept = ".csv"),
    hr(),
    selectInput("xvar",  "X axis (numeric)", choices = NULL),
    selectInput("yvar",  "Y axis (numeric)", choices = NULL),
    selectInput("group", "Colour by",        choices = NULL),
    sliderInput("xrange", "Filter X range", min = 0, max = 1, value = c(0, 1))
  ),

  # KPI tiles across the top
  layout_columns(
    fill = FALSE,
    value_box("Rows shown", textOutput("n_rows"), theme = "primary"),
    value_box("Mean of X",  textOutput("mean_x"), theme = "text-success"),
    value_box("Columns",    textOutput("n_cols"), theme = "text-warning")
  ),

  # plot and table, side by side
  layout_columns(
    card(card_header("Scatter plot"),  plotOutput("plot")),
    card(card_header("Filtered data"), DTOutput("table"))
  ),

  card(card_header("Summary statistics"), verbatimTextOutput("summary"))
)

server <- function(input, output, session) {

  # 1. THE SOURCE: an uploaded CSV if the user gave one, else the picked built-in dataset
  raw <- reactive({
    if (is.null(input$file)) {
      builtin[[input$dataset]]
    } else {
      read.csv(input$file$datapath, stringsAsFactors = TRUE)
    }
  })

  # 2. when the data changes, refresh the column pickers to match its columns
  observeEvent(raw(), {
    df      <- raw()
    numeric <- names(df)[vapply(df, is.numeric, logical(1))]
    cats    <- names(df)[vapply(df, function(x) is.factor(x) || is.character(x), logical(1))]

    updateSelectInput(session, "xvar",  choices = numeric, selected = numeric[1])
    updateSelectInput(session, "yvar",  choices = numeric,
                      selected = numeric[min(2, length(numeric))])
    updateSelectInput(session, "group", choices = c("None", cats), selected = "None")
  })

  # 3. when the X column changes, set the filter slider to that column's range
  observeEvent(input$xvar, {
    req(input$xvar)
    x <- raw()[[input$xvar]]
    updateSliderInput(session, "xrange",
                      min = floor(min(x, na.rm = TRUE)),
                      max = ceiling(max(x, na.rm = TRUE)),
                      value = range(x, na.rm = TRUE))
  })

  # 4. THE ONE REACTIVE: filter the data once; plot, table, summary, KPIs all read it
  filtered <- reactive({
    req(input$xvar, input$yvar)
    df <- raw()
    x  <- df[[input$xvar]]
    df[x >= input$xrange[1] & x <= input$xrange[2], , drop = FALSE]
  })

  # 5. guard an empty filter with a friendly message instead of an error
  plot_data <- reactive({
    validate(need(nrow(filtered()) > 0, "No rows match the current filter — widen the range."))
    filtered()
  })

  # KPI value boxes
  output$n_rows <- renderText(nrow(filtered()))
  output$n_cols <- renderText(ncol(raw()))
  output$mean_x <- renderText({
    x <- filtered()[[input$xvar]]
    if (length(x) && is.numeric(x)) round(mean(x, na.rm = TRUE), 1) else "—"
  })

  # scatter plot
  output$plot <- renderPlot({
    d <- plot_data()
    p <- ggplot(d, aes(x = .data[[input$xvar]], y = .data[[input$yvar]]))
    if (!is.null(input$group) && input$group != "None") {
      p <- p +
        geom_point(aes(colour = .data[[input$group]]), size = 2.5, alpha = 0.85) +
        scale_colour_viridis_d()
    } else {
      p <- p + geom_point(colour = "#3a86d4", size = 2.5, alpha = 0.85)
    }
    p + labs(x = input$xvar, y = input$yvar) + theme_minimal(base_size = 14)
  })

  # searchable, sortable table
  output$table <- renderDT({
    datatable(filtered(), filter = "top", rownames = FALSE,
              options = list(pageLength = 8))
  })

  # summary statistics
  output$summary <- renderPrint({
    summary(filtered())
  })
}

shinyApp(ui, server)

Exécutez-le et vous avez l’application décrite plus haut — une barre latérale de filtres, trois tuiles KPI, un graphique et une table coordonnés, et un panneau de résumé, tous réagissant aux mêmes contrôles.

R n’est pas installé ? Exécutez cet explorateur de données en direct dans votre navigateur — il démarre R avec WebAssembly, rien à configurer. Ouvrir dans le Playground ▸

Les concepts clés de mise en œuvre

L’application est courte, mais quatre idées la portent. Aucune n’est nouvelle ; l’habileté consiste à voir comment elles s’emboîtent.

Un seul jeu de données filtré réactif alimente tout

C’est le cœur de toute l’application. Regardez ce que le graphique, la table, le résumé et la tuile « Rows shown » ont en commun : ils affichent tous le même sous-ensemble des données — les lignes qui passent le filtre courant. La version naïve filtre les données séparément à l’intérieur de chaque sortie. Cela fait quatre copies de la même logique de filtrage, et le jour où vous changez le fonctionnement du filtrage vous devez le changer à quatre endroits — et si vous vous trompez sur l’un, le graphique et la table se contredisent en silence.

La solution est de filtrer une seule fois et de partager le résultat :

filtered <- reactive({
  req(input$xvar, input$yvar)
  df <- raw()
  x  <- df[[input$xvar]]
  df[x >= input$xrange[1] & x <= input$xrange[2], , drop = FALSE]
})

Un reactive() calcule sa valeur une seule fois, la met en cache et remet le même résultat à chaque appelant jusqu’à ce que ses entrées changent. Ainsi filtered() exécute le filtre une seule fois ; le graphique lit filtered(), la table lit filtered(), le résumé lit filtered() et le KPI lit nrow(filtered()). Déplacez le curseur et Shiny réexécute le filtre une seule fois, puis pousse le nouveau résultat vers les quatre sorties — c’est pourquoi elles s’accordent toujours et pourquoi l’application reste rapide. C’est l’habitude la plus importante pour construire n’importe quelle application Shiny multi-vues : dérivez les données partagées dans un réactif, et laissez les sorties les consommer. (La leçon sur le traitement réactif des données couvre en profondeur cette mise en couches.)

req() et validate() pour les moments délicats

Deux moments provoqueraient sinon des erreurs. Au démarrage, les sélecteurs de colonnes sont vides (choices = NULL), donc input$xvar vaut NULL et df[[NULL]] n’a aucun sens. req(input$xvar, input$yvar) en tête de filtered() s’en charge : req() interrompt le réactif silencieusement jusqu’à ce que chaque argument soit vrai, de sorte que les sorties restent simplement vides la fraction de seconde avant que les sélecteurs se peuplent, au lieu de faire clignoter une erreur rouge.

Le second moment est un filtre qui ne correspond à aucune ligne — faites glisser le curseur jusqu’à une plage minuscule et le sous-ensemble est vide. Fournir un data frame à zéro ligne à ggplot() est une erreur qui ne demande qu’à survenir. validate(need(...)) transforme cette erreur en message :

plot_data <- reactive({
  validate(need(nrow(filtered()) > 0, "No rows match the current filter — widen the range."))
  filtered()
})

need(condition, message) vérifie la condition ; si elle échoue, validate() arrête la sortie et affiche le message à la place du graphique. L’utilisateur voit « No rows match the current filter — widen the range, » et non une trace de pile. Utilisez req() pour attendre les entrées, validate()/need() pour expliquer un état que l’utilisateur peut corriger.

La mise en page bslib est l’habillage du tableau de bord

La forme de l’application vient entièrement de bslib, la boîte à outils de mise en page moderne — la même que derrière la leçon sur les tableaux de bord. Quatre fonctions font tout :

  • page_sidebar() est le cadre de la page entière : un title, une sidebar pour les filtres, et la zone principale est tout le reste que vous passez.
  • sidebar() contient les contrôles de filtre — c’est juste un conteneur pour les selectInput(), le fileInput() et le sliderInput().
  • value_box() est une tuile KPI : un titre et une valeur (ici un textOutput() que le server remplit), avec une couleur de theme. Trois d’entre elles enveloppées dans layout_columns() donnent la rangée de chiffres vedettes.
  • card() encadre chaque panneau — le graphique, la table, le résumé — avec un card_header() optionnel.

layout_columns() dispose ses enfants sur une grille responsive, de sorte que le graphique et la table se placent côte à côte sur un écran large et s’empilent sur un écran étroit, sans aucune media query à écrire. La documentation des tableaux de bord bslib est la référence complète pour ces briques de construction. L’essentiel : la mise en page est déclarative — vous décrivez l’agencement et bslib le rend responsive.

Comment le chemin de téléversement l’étend

L’application tourne sur des données intégrées, mais c’est le fileInput() qui la transforme en outil pour les propres données de l’utilisateur. Toute l’extension tient dans le réactif raw() en tête du server :

raw <- reactive({
  if (is.null(input$file)) {
    builtin[[input$dataset]]                    # no upload yet -> the picked built-in
  } else {
    read.csv(input$file$datapath, stringsAsFactors = TRUE)  # a file was uploaded -> read it
  }
})

Avant tout téléversement, input$file vaut NULL, donc raw() renvoie le jeu de données intégré que le menu déroulant a sélectionné. À l’instant où l’utilisateur téléverse un CSV, input$file devient le data frame de téléversement et raw() le lit depuis input$file$datapath — le chemin temporaire où Shiny l’a enregistré, jamais le nom de fichier original. Tout ce qui suit est écrit par rapport à raw(), donc peu importe d’où viennent les données : les sélecteurs de colonnes se repeuplent à partir des colonnes téléversées, et le graphique, la table et le résumé explorent les nouvelles données sans changement. C’est la puissance de faire transiter toutes les données par un seul réactif — vous changez la source à un seul endroit et l’application entière suit.

Comment l’étendre

Cette application est délibérément un noyau solide que vous pouvez faire grandir. Chaque direction est une leçon que vous avez déjà rencontrée :

  • Plus de types de graphiques. Ajoutez un selectInput() pour le type de graphique et ramifiez dans renderPlot() — un histogramme pour une variable, une boîte à moustaches par groupe. C’est la trousse à outils des graphiques et diagrammes interactifs.
  • Une table plus riche. Activez la sélection de lignes et le formatage de la leçon sur les tables de données interactives — laissez une ligne cliquée piloter un panneau de détail, ou colorez les cellules selon la valeur.
  • Excel et fichiers multiples. Élargissez le téléversement avec une ramification tools::file_ext() et readxl, tout droit issue des téléversements de fichiers.
  • Des contrôles de filtre qui se construisent d’eux-mêmes. Générez un filtre par colonne avec renderUI() de la leçon sur l’interface dynamique, pour que la barre latérale s’adapte à toutes les données chargées.
  • Finition et vitesse de tableau de bord. Ajoutez des onglets et des info boxes des tableaux de bord ; si un gros téléversement le rend lent, mettez le filtre en cache et profilez-le avec les techniques de performance.

Partez de ce app.R et ajoutez une pièce à la fois — exécutez-le après chaque changement, exactement comme vous avez construit votre première application.

Problèmes fréquents

Le graphique, la table et le résumé se contredisent. Presque toujours, cela signifie que vous avez filtré les données séparément dans chaque sortie au lieu de partager un seul réactif. Si renderPlot() filtre raw() d’une façon et renderDT() d’une autre, ils se désynchronisent. Calculez le sous-ensemble une seule fois dans un reactive() (filtered() ici) et faites en sorte que chaque sortie le lise — alors ils ne peuvent pas se contredire, parce qu’il n’y a qu’un seul jeu de données filtré.

L’application échoue au démarrage avec un message d’entrée vide. Avant que les sélecteurs de colonnes se peuplent, input$xvar et input$yvar valent NULL, et tout code qui indexe les données avec eux échoue. Placez req(input$xvar, input$yvar) en tête du réactif qui les utilise, pour qu’il attende que les sélecteurs soient réglés au lieu de tourner avec NULL.

Changer de jeu de données laisse les anciennes colonnes sélectionnées. Si vous ne rafraîchissez pas les sélecteurs quand les données changent, input$xvar peut encore contenir un nom de colonne qui n’existe pas dans le nouveau jeu de données. Surveillez les données avec observeEvent(raw(), { ... }) et appelez updateSelectInput() pour réinitialiser les choix aux nouvelles colonnes — l’application le fait pour qu’un passage de mtcars à iris repeuple les menus X, Y et de regroupement.

Questions fréquentes

Assemblez les pièces interactives autour d’un seul jeu de données filtré réactif. Placez les filtres (un sélecteur de jeu de données, des sélecteurs de colonnes, un curseur de plage) dans un bslib::sidebar(), calculez les données filtrées une seule fois dans un reactive(), et faites lire ce même réactif par un plotOutput(), un DT::DTOutput() et un panneau de résumé pour qu’ils se mettent à jour ensemble. Ajoutez un fileInput() si vous voulez que les utilisateurs téléversent leur propre CSV. Le app.R complet de cette page est un point de départ complet et exécutable.

Filtrez les données une seule fois dans un reactive() et laissez les deux sorties le lire. Ne sous-ensemblez pas les données séparément à l’intérieur de renderPlot() et renderDT() — cela fait deux copies de la logique qui peuvent diverger. Écrivez filtered <- reactive({ ... }), puis renderPlot({ ggplot(filtered(), ...) }) et renderDT({ datatable(filtered()) }). Comme un réactif met son résultat en cache, Shiny exécute le filtre une seule fois et pousse les mêmes lignes vers les deux, de sorte que le graphique et la table correspondent toujours.

Oui — et c’est le motif recommandé. Un reactive() calcule sa valeur une seule fois et remet le résultat identique à chaque appelant jusqu’à ce que ses entrées changent. Ainsi un unique reactive filtered() peut alimenter un graphique, une table, un résumé et une box KPI en même temps ; chacun appelle simplement filtered(). Cela garde les vues synchronisées et évite de répéter le même calcul dans chaque sortie.

Ajoutez fileInput("file", "Upload a CSV", accept = ".csv") à l’interface, puis lisez-le sur le server depuis input$file$datapath — le chemin temporaire où Shiny enregistre le téléversement, pas le nom de fichier original. Faites-le transiter par votre réactif de données pour que l’application utilise le téléversement quand il est présent et un jeu de données intégré sinon : reactive({ if (is.null(input$file)) mtcars else read.csv(input$file$datapath) }). La leçon sur les téléversements de fichiers couvre Excel, les fichiers multiples et la limite de taille.

Quatre : shiny pour l’application, bslib pour la mise en page barre latérale/card/value-box, DT pour la table interactive et ggplot2 pour le graphique. Installez-les une fois avec install.packages(c("shiny", "bslib", "DT", "ggplot2")). L’application de cette page tourne sur les jeux intégrés mtcars et iris de R, vous n’avez donc besoin d’aucune donnée supplémentaire pour l’essayer.

Testez vos connaissances

Étendez l’application pour qu’une quatrième tuile KPI affiche le pourcentage du jeu de données original qui passe actuellement le filtre (par exemple « 75 % » quand 24 lignes sur 32 correspondent). Ajoutez un value_box() à la rangée du haut et remplissez-le depuis le server, en lisant les mêmes réactifs filtered() et raw() que le reste de l’application utilise.

Ajoutez value_box("Kept", textOutput("pct"), theme = "text-info") à la rangée KPI de layout_columns(). Dans le server, output$pct <- renderText({ ... }) — calculez nrow(filtered()) / nrow(raw()), multipliez par 100, arrondissez, et collez un "%". Vous n’avez besoin d’aucun nouveau réactif ; réutilisez filtered() et raw().

# in the UI, add to the KPI layout_columns():
value_box("Kept", textOutput("pct"), theme = "text-info")

# in the server:
output$pct <- renderText({
  paste0(round(100 * nrow(filtered()) / nrow(raw())), "%")
})

Comme filtered() et raw() sont tous deux des réactifs existants de l’application, la nouvelle tuile se met à jour au même rythme que le graphique, la table et le résumé à l’instant où le filtre change — sans câblage supplémentaire. C’est la conception un-seul-réactif-alimente-tout qui paie de nouveau : une nouvelle vue n’est qu’un consommateur de plus des données partagées.

Dans l’application, le graphique, la table et le résumé appellent tous filtered(). Pourquoi calculer les données filtrées dans un seul reactive() au lieu de filtrer à l’intérieur de chacune des trois sorties ?

A. C’est la seule façon dont Shiny autorise une sortie à lire une entrée.
B. Un reactive() exécute le filtre une seule fois et partage le résultat mis en cache, donc les trois vues restent synchronisées et le travail n’est pas répété.
C. Les sorties ne peuvent pas contenir de code de filtrage, il doit donc vivre dans un réactif.
D. Cela fait exécuter le filtre trois fois, une par sortie, ce qui est plus rapide.

B. Un reactive() calcule sa valeur une seule fois, la met en cache et remet le même résultat à chaque appelant jusqu’à ce que ses entrées changent — donc le filtre s’exécute une seule fois et le graphique, la table et le résumé lisent tous des lignes identiques, ce qui garantit leur accord. A et C sont fausses (les sorties peuvent lire des entrées et peuvent contenir directement du code de filtrage). D est à l’envers : filtrer séparément dans chaque sortie répéterait le travail trois fois, ce qui est exactement ce que le réactif partagé évite.

Conclusion

Un explorateur de données paraît ambitieux et se révèle petit, parce que vous avez déjà construit chaque pièce. L’application est un habillage bslibpage_sidebar(), des KPI value_box() et des panneaux card() — enveloppé autour d’une idée directrice : un seul jeu de données filtré réactif que le graphique, la table, le résumé et les KPI lisent tous, de sorte qu’ils ne se contredisent jamais et que le filtrage s’exécute une seule fois. req() garde les sorties silencieuses jusqu’à ce que les sélecteurs soient réglés, validate()/need() explique un filtre vide au lieu d’échouer, et un fileInput() optionnel change la source des données dans un seul réactif pour que l’application entière suive. C’est la vraie leçon du projet de synthèse — non pas de nouvelles fonctions, mais la discipline de dériver les données partagées une seule fois et de laisser vos sorties les consommer. Partez de ce app.R, ajoutez une fonctionnalité à la fois, et vous avez un outil d’exploration réutilisable pour n’importe quel jeu de données que vous rencontrez.

Leçons connexes

Cette page vous a-t-elle été utile ?

Recevez les nouvelles leçons R & Python par e-mail

Pratique, reproductible, sans spam. Désinscription à tout moment.

Double opt-in. Nous ne partageons jamais votre e-mail.

Partager cette pageXLinkedInRedditHN

Réutilisation

Citation

BibTeX
@online{2026,
  author = {},
  title = {Explorateur de données Shiny : construire une app interactive
    en R},
  date = {2026-07-17},
  url = {https://www.datanovia.com/learn/programming/shiny/interactivity/capstone-data-explorer},
  langid = {fr}
}
Veuillez citer ce travail comme suit :
“Explorateur de données Shiny : construire une app interactive en R.” 2026. July 17. https://www.datanovia.com/learn/programming/shiny/interactivity/capstone-data-explorer.