Добавление информативного альтернативного текста (замещающего текста) к изображениям - фундаментальный принцип веб-доступности. В настоящее время Shiny не имеет возможности добавлять замещающий текст к динамическому графику, созданному с помощью функции renderPlot(). В этой статье демонстрируется метод достижения этой цели.

Общая проблема

Обнаружив, что не существует эквивалента параметра renderImage() alt для renderPlot(), я начал поиск решений и нашел ветку на эту тему ​​в репозитории RStudio Shiny на GitHub ». В этом потоке решение упоминается leonawicz с использованием наблюдателей для добавления альтернативного текста к динамическим графикам с использованием их идентификатора при отсутствии функции, реализованной в Shiny. Следующее - мое решение, основанное на этом принципе.

Пример приложения

Этот пример основан на гистограмме по умолчанию приложения данных Old Faithful Geyser, созданной в RStudio, когда вы используете Файл ›Новый файл› Shiny Web App… для создания нового приложения Shiny. Я внес и другие изменения для дальнейшего улучшения доступности, помимо тех, которые касаются альтернативного текста, о которых я расскажу в конце статьи.

#
# Based on RStudio example Shiny web application.
# Find out more about building applications with Shiny here:
#
#    http://shiny.rstudio.com/
#
library(shiny)
# Define UI for application that draws a histogram
ui <- fluidPage(title = "Adding dynamic alt text to plots",
  # Set the language of the page - important for accessibility
  tags$html(lang = "en"),
  # CSS to improve contrast and size of slider widget numbers.
  # Placed in the <head> tag for HTML conformity
  tags$head(
    tags$style("
      /* slider scale, start and end numbers */
      .irs-grid-text, .irs-min, .irs-max {
        color: #333;
        font-size: 0.8em;
      }
      /* value of slider appearing above the 'thumb' control */
      .irs-from, .irs-to, .irs-single {
        background-color: #333;
      }
    ")
  ),
  # Application title
  h1("Old Faithful Geyser Data"),
  # Set up the main landmark for the page - important for
  # accessibility
  HTML("<main>"),
  # Sidebar with a slider input for number of bins
  sidebarLayout(
    sidebarPanel(
      sliderInput("bins",
                  "Number of bins:",
                  min = 1,
                  max = 50,
                  value = 30)
    ),
    # Show a plot of the generated distribution
    mainPanel(
      plotOutput("distPlot")
    )
  ),
  # End the main landmark and insert the script to add the alt text
  # to the plot image
  HTML("</main>
        <script>
          // Receive call from Shiny server with the alt text for
          // the dynamic plot <img>.
          Shiny.addCustomMessageHandler('altTextHandler', function(altText) {
            // Setup a call to the update function every 500
            // milliseconds in case the plot does not exist yet
            var altTextCallback = setInterval(function() {
              try {
                // Get reference to <div> containing the plot as the
                // <img> element does not have an id itself
                var plotContainer = document.getElementById('distPlot');
                // Add the alt attribute to the plot <img>
                plotContainer.firstChild.setAttribute('alt', altText);
                // Cancel the callback as we have updated the alt
                // text
                clearInterval(altTextCallback);
              }
              catch(e) {
                // An error occurred, likely the <img> hasn't been
                // created yet. Function will run again in 500ms
              }
            }, 500);
          });
        </script>
  ")
)
# Define server logic required to draw a histogram
server <- function(input, output, session) {
  # create a title for the plot which can also serve as the
  # beginning of the alt text
  plotTitle <- "Histogram of eruption waiting times (min)"
  output$distPlot <- renderPlot({
    # extract the eruption waiting times from the dataset and
    # generate bins based on input$bins from ui.R
    x    <- faithful[, 2]
    bins <- seq(min(x), max(x), length.out = input$bins + 1)
    # draw the histogram with the specified number of bins
    hist(x, breaks = bins, col = 'darkgray', border = 'white', main = plotTitle, xlab = "Eruption waiting time (min)", ylab = "Frequency of eruptions")
  })
  observe({
    session$sendCustomMessage("altTextHandler", paste0(plotTitle, ", bins = ", input$bins, "."))
  })
}
# Run the application
shinyApp(ui = ui, server = server)

Два основных отличия между этим примером и примером приложения Shiny по умолчанию, которые необходимы для обновления замещающего текста:

  • observe() в объекте server
  • сценарий обработчика событий Shiny.addCustomMessageHandler() в объекте ui

Наблюдатель

Как и в случае приложения RStudio «по умолчанию», изменение количества ячеек, сделанное пользователем через виджет ползунка, приводит к повторной оценке renderPlot() и перерисовке графика. Роль наблюдателя также состоит в том, чтобы сообщить об этом изменении за пределами кода сервера R:

observe({
  session$sendCustomMessage("altTextHandler", paste0(plotTitle, ", bins = ", input$bins, "."))
})

Здесь мы создаем строку текста для использования в качестве альтернативного текста для графика, взяв статический заголовок графика и добавив к нему количество ячеек, взятых из значения ползунка. (Это также демонстрирует, как альтернативный текст может быть динамическим, чтобы он мог осмысленно передавать то, что показывает сюжет.) Затем текст передается altTextHandler, обработчику событий, объявленному в блоке сценария, чья задача состоит в том, чтобы «прислушиваться» к звонок от наблюдателя.

Обработчик событий

// Receive call from Shiny server with the alt text for
// the dynamic plot <img>.
Shiny.addCustomMessageHandler('altTextHandler', function(altText) {
  // Setup a call to the update function every 500
  // milliseconds in case the plot does not exist yet
  var altTextCallback = setInterval(function() {
    try {
      // Get reference to <div> containing the plot as the
      // <img> element does not have an id itself
      var plotContainer = document.getElementById('distPlot');
      // Add the alt attribute to the plot <img>
      plotContainer.firstChild.setAttribute('alt', altText);
      // Cancel the callback as we have updated the alt
      // text
      clearInterval(altTextCallback);
    }
    catch(e) {
      // An error occurred, likely the <img> hasn't been
      // created yet. Function will run again in 500ms
    }
  }, 500);
});

Когда наблюдатель вызывает обработчик событий, строка, которую мы создали для использования в качестве альтернативного текста, передается ему в качестве параметра altText, готового к использованию в функции. Отсюда вы можете ожидать, что сможете просто добавить его в сюжет, но есть еще одна проблема, которую необходимо решить. Когда Shiny вызывает обработчик событий, чтобы сообщить об изменении, график еще не был перерисован. Фактически, он фактически не обновляется на веб-странице до тех пор, пока не будет выполнена функция обработчика событий, а это означает, что любые изменения, внесенные в график, по существу перезаписываются после того, как мы вернемся. Чтобы противостоять этому, мы используем метод Window setInterval() javascript, чтобы существенно отделить изменения, которые мы хотим выполнить на графике, от текущего потока событий.

Метод setInterval будет запускать код внутри function() { … } каждые 500 миллисекунд, пока ему не будет сказано остановиться. Причина этого повторения состоит в том, чтобы позволить Shiny удалить текущий график со страницы и заменить его новым на основе измененных значений - в противном случае мы могли бы пытаться добавить замещающий текст к чему-то, чего больше не существует, вызывая ошибку. ! Код try { … } catch(e) { } обрабатывает эту возможность; попробуйте добавить замещающий текст к сюжету, но если его нет, не волнуйтесь и повторите попытку через 500 миллисекунд. Как только новый сюжет появится на странице, мы наконец сможем добавить замещающий текст.

Чтобы получить доступ к элементу <img>, представляющему сюжет, нам нужен способ ссылки на него. К сожалению, уникальных идентификаторов нет. Вместо этого мы должны сделать это через его родительский элемент, <div>, содержащий идентификатор, который вы передали функции plotOutput() в коде ui.R. В этом примере это «distPlot»:

var plotContainer = document.getElementById('distPlot');

График - это первый элемент в <div>, поэтому следующая ссылка на элемент <img>, представляющий график, добавляет к нему альтернативный текст с помощью атрибута alt:

plotContainer.firstChild.setAttribute('alt', altText);

Если к этому моменту мы не столкнулись с какими-либо проблемами, последняя строка отменяет все дальнейшие вызовы этой функции:

clearInterval(altTextCallback);

Если вы проверяете элементы с помощью инструментов разработчика в своем браузере (Инструменты ›Веб-разработчик› Inspector для Firefox, Просмотр ›Разработчик› Проверять элементы для Chrome и Разработка › Показать веб-инспектор для Safari), вы увидите, что альтернативный текст добавляется после изменения графика с небольшой задержкой.

Дальнейшие улучшения

Пользователи с нарушениями зрения могут не видеть обновление графика при изменении входных значений, поэтому, объявив контейнер графика как динамическую область, то есть содержимое может изменяться, программы чтения с экрана могут объявлять замещающий текст каждый раз, когда график изменяется. Мы делаем это, добавляя атрибут aria-live к контейнеру графика в нашем коде обработчика событий:

try {
  // Get reference to <div> containing the plot as the
  // <img> element does not have an id itself
  var plotContainer = document.getElementById('distPlot');
  // Screen readers can announce the alt text when plot updates
  if (plotContainer['aria-live'] === undefined) plotContainer.setAttribute('aria-live', 'polite');
  // Add the alt attribute to the plot <img>
  plotContainer.firstChild.setAttribute('alt', altText);
  // Cancel the callback as we have updated the alt
  // text
  clearInterval(altTextCallback);
}
catch(e) {
  // An error occurred, likely the <img> hasn't been
  // created yet. Function will run again in 500ms
}

Это нужно сделать только один раз, поэтому мы сначала проверяем, существует ли атрибут aria-live, а затем добавляем его, если нет. Значение 'polite’ просто означает, что объявление замещающего текста будет отложено до тех пор, пока программа чтения с экрана не закончит считывание любой другой информации, чтобы предотвратить прерывание.

Другие улучшения доступности, которые я добавил в этот пример:

  • Установка языка страницы tags$html(lang = “en”), чтобы контент лучше понимал программное обеспечение, в том числе программы чтения с экрана.
  • Определение основного содержания страницы с помощью элемента ориентира HTML(“<main>”) для облегчения навигации.
  • Использование CSS для улучшения контрастности и размера чисел, используемых в виджете ползунка:
tags$head(
  tags$style("
    /* slider scale, start and end numbers */
    .irs-grid-text, .irs-min, .irs-max {
      color: #333;
      font-size: 0.8em;
    }
    /* value of slider appearing above the 'thumb' control */
    .irs-from, .irs-to, .irs-single {
      background-color: #333;
    }
  ")
),

Надеюсь, эти методы помогут улучшить доступность ваших приложений Shiny и любого другого создаваемого вами веб-контента.

Рекомендуемая литература для дальнейшего чтения