Data and setup

Have you already ran the the script ‘prepare-for-the-workshop!.R’? If no, do it now before proceeding further!

Loading packages

library(logLime)
library(dplyr, warn.conflicts = FALSE)
library(ggplot2)
library(ggdensity)

Reading the data

surveyResults <- readRDS("dataExp5.RDS")

Code below performs preprocessing, filters out respondent-screens for which some common problems were identified, computes and adds columns with relative positions and cursor move distances, and finally merges some respondent’s characteristics to the data regarding actions (events).

# creating log-data object
logData <-
  separate_logdata_types(surveyResults |>
                           select(respid, starts_with("log")),
                         surveyStructure = c("limesurvey_survey_745195.txt",
                                             "limesurvey_survey_817417.txt"),
                         questionNamesPrefix = "log")
#> Processing log-data streams with:
#> - respondent's id(s) stored in columns: respid;
#> - log-data streams stored in columns: logvac, logimmig, logread1, logread2, logread3, logitrust1, logitrust2, loggtrust, logbis11a, logbis11b, logbis11c, logdis19a, logdis19b, lognc01a, lognc01b, lognc01c, logipip20a, logipip20b, logmcatt, logmcint, logmcburd.
#> 
#> Preprocessing log-data streams:
#>   Pivoting data with log-streams in many columns to put it into one column...
#>   Separating log-streams into rows...
#>   Separating log-streams into columns...
#>   Marking broken records...
#> Done.
#> Separating returns to survey screens.
#> Warning in separate_logdata_types(select(surveyResults, respid,
#> starts_with("log")), : Separating returns to survey screens is not possible for
#> log-data collected using 'logdataLimeSurvey' applet in versions earlier than
#> 1.1. Argument `separateReturns` was set to 'no' automatically.
#> Processing input positions.
#> Processing system information.
#> Processing actions:
#>   Computing scroll lengths...
#>   Transforming mousemove events into actual moves...
#>   Labeling clicks and hovers...
#>   Labelling types of elements...
#> Done.
#> 
#> Separated data consists of:
#> - 326 respondents on 21 screens,
#> - 6'524 respondent-screen-entries,
#> - 1'814'090 actions,
#>   out of which data about 153 is somehow broken.
# filtering out problematic records (respondent-screens)
logData <- lapply(logData, semi_join,
                  y = logData$systemInfo |>
                    filter(problemsAnyBroken != 1,
                           problemsLeftBrowser != 1,
                           problemsResized != 1,
                           problemsTimeStamps != 1,
                           inputsWidth > 0),
                  by = c("respid", "screen"))
# computing relative positions and distances
logData$actions <- compute_relative_positions(logData$actions,
                                              logData$systemInfo)
# merging some respondent's characteristics
logData$actions <- logData$actions |>
  left_join(surveyResults |>
              select(respid, variant, gender),
            by = "respid")

Now we may proceed to drawing.

Drawing heatmaps

One of the commonly used forms of visualizing cursor moves data is the preparation of so-called heatmaps. On this type of graph areas of the survey screen that the cursor was pointing at for a long time and areas that were hardly visited (pointed at) are marked with different colors. An interesting feature of this form of visualization is that it can be used to illustrate both the actions of a single respondent and aggregated tendencies among a group of respondents.

Data preparation

One may draw heatmaps using function geom_density2d_filled() from the ggplot2 package. However, data prepared by the separate log data types() above can not (or, more specifically, should not) be used to create such plots! That’s because the actions element of a log-data objects contain mousemove events of different duration. When estimating the time the indicator has been in a given position, this durations should be treated as a weight. However, there is no way to provide different weights for observations while computing 2-dimensional densities in ggplot2. For this reason, the data must be transformed into another form in which cursor positions are reported at evenly spaced points in time. You may get the data of this form by using function compute_cursor_positions():

(cursorPositions <- compute_cursor_positions(logData$actions,
                                             timeSpan = 250) |>
   # also, let's join this data with some respondent's characteristics
   left_join(surveyResults |>
               select(respid, variant, gender),
             by = "respid"))
#> Preprocessing log-data streams.
#> Calculating cursor positions.
#> # A tibble: 668,583 × 9
#>    respid screen timeStampRel pageX pageY pageX_rel pageY_rel variant     gender
#>     <int> <chr>         <dbl> <dbl> <dbl>     <dbl>     <dbl> <chr>       <chr> 
#>  1  58172 bis11a            0  1201   431     0.589     0.165 Personalit… Male  
#>  2  58172 bis11a          250  1192   322     0.573    -0.128 Personalit… Male  
#>  3  58172 bis11a          500  1165   300     0.524    -0.187 Personalit… Male  
#>  4  58172 bis11a          750  1165   300     0.524    -0.187 Personalit… Male  
#>  5  58172 bis11a         1000  1165   300     0.524    -0.187 Personalit… Male  
#>  6  58172 bis11a         1250  1162   291     0.519    -0.211 Personalit… Male  
#>  7  58172 bis11a         1500  1186   280     0.562    -0.241 Personalit… Male  
#>  8  58172 bis11a         1750  1186   280     0.562    -0.241 Personalit… Male  
#>  9  58172 bis11a         2000  1186   280     0.562    -0.241 Personalit… Male  
#> 10  58172 bis11a         2250  1186   280     0.562    -0.241 Personalit… Male  
#> # ℹ 668,573 more rows

Now we are ready to prepare heatmaps.

Heatmap for a single respondent-screen

Let’s start with drawing a heatmap for a single respondent-screen. In the code chunk below the screen containing a set of question regarding immigrants is selected and a single respondent is sampled.

respCursorPositions <- cursorPositions |>
  filter(screen == "immig") |>
  filter(respid %in% sample(unique(respid), 1))

Having the data selected you may use ggplot2 functions to plot a heatmap. Please note, that relative positions are used instead of regular (absolute) ones.

# declaring data and mapping of variables onto axis
ggplot(respCursorPositions,
       aes(x = pageX_rel, y = pageY_rel)) +
  # heatmap itself (using the ggdensity package)
  geom_hdr() +
  # reversing the Y axis because of the difference in coordinates system
  # between web browsers and plotting packages
  scale_y_reverse() +
  # a decoration - marking the area within which INPUT elements were shown
  geom_vline(xintercept = c(0, 1), color = "red", linetype = "dashed") +
  geom_hline(yintercept = c(0, 1), color = "red", linetype = "dashed")

The lightest areas indicate no hovering and the darker it gets, the more time was spent by the cursor over a given area. Please note, that values in the legend indicate density, not simply the time (i.e. density is proportional to time, but given in a different unit).

Several heatmaps on one graph

Let’s suppose we want to compare heatmaps for several respondents on one graph. In the code chunk below 4 respondents with explicitly specified ids are selected.

respsCursorPositions <- logData$actions |>
  filter(screen == "immig") |>
  filter(respid %in% c(988464343, 987844729, 988421695, 988555104))

To put heatmaps describing different respondents on separate panels of the graph, you need to add facet_wrap() call - see the last line of the code chunk below.

# declaring data and mapping of variables onto axis
ggplot(respsCursorPositions,
       aes(x = pageX_rel, y = pageY_rel)) +
  # heatmap itself (using the ggdensity package)
  geom_hdr() +
  # reversing the Y axis because of the difference in coordinates system
  # between web browsers and plotting packages
  scale_y_reverse(limits = c(NA, -1)) +
  # a decoration - marking the area within which INPUT elements were shown
  geom_vline(xintercept = c(0, 1), color = "red", linetype = "dashed") +
  geom_hline(yintercept = c(0, 1), color = "red", linetype = "dashed") +
  # placing each respondent on a separate panel
  facet_wrap(vars(respid))
#> Warning: Removed 58 rows containing non-finite outside the scale range
#> (`stat_hdr()`).

Please note, that the results visualized using the geom_hdr() do not show potential differences regarding the total time spent on the screen by different respondents, but only differences in the average share of time spent over the different regions! If you’re interested in the mean absolute time spent over the regions of the survey screen, you have to turn to ggplot2’s geom_density_2d_filled(contour_var = "density", breaks = vectorOfReasonablyChosenValues).

Heatmaps comparing groups of respondents

Comparing groups of respondents using heatmaps is also simple. You need to provide the ggplot() with a data frame including all the members of the groups you want to compare, and then use the variable(s) defining the grouping as the faceting variable(s). In the code chunk below function facet_grid() is used instead of facet_wrap() to arrange results in a grid constructed from crossing values of variables gender and variant (this second variable describes whether question about immigration was presented in the first or in the second block of the questionnaire).

# density
# declaring data and mapping of variables onto axis
ggplot(cursorPositions |>
         filter(screen == "immig"),
       aes(x = pageX_rel, y = pageY_rel)) +
  # heatmap itself
  geom_hdr() +
  # reversing the Y axis because of the difference in coordinates system
  # between web browsers and plotting packages
  scale_y_reverse(limits = c(NA, -1)) +
  # a decoration - marking the area within which INPUT elements were shown
  geom_vline(xintercept = c(0, 1), color = "red", linetype = "dashed") +
  geom_hline(yintercept = c(0, 1), color = "red", linetype = "dashed") +
  # defining that graphs should summarize results
  # grouped by the crossing of variant and gender
  facet_grid(vars(variant), vars(gender))
#> Warning: Removed 1137 rows containing non-finite outside the scale range
#> (`stat_hdr()`).

Please note, that the results visualized using the geom_hdr() do not show potential differences regarding the mean absolute time spent on the screen by the members of different groups, but only differences in the average share of time spent over the different regions! As with comparing individual respondents, you will have to turn to ggplot2’s geom_density_2d_filled(contour_var = "density", breaks = vectorOfReasonablyChosenValues) if you’re interested in comparing mean absolute times.

Drawing cursor traces and clicks

Sometimes you want to illustrate the cursor trace for a specific respondent-screen. Such a graph will look more informative if you enrich with the position of clicks and place the position of the survey form INPUT elements in the background. However, to achieve this, you first need to prepare several separate data sets:

# some single respondent's id
respondent <- 988543469
# data on cursor trace
respCursorMoves <- logData$actions |>
  filter(screen == "immig",
         type %in% "mousemove") |>
  filter(respid %in% respondent)
# data on clicks
respCursorClicks <- logData$actions |>
  filter(type %in% "click") |>
  semi_join(respCursorMoves,
            by = c("respid", "screen"))
# data on INPUT elements' positions
respInputPositions <- logData$inputPositions |>
  semi_join(respCursorMoves,
            by = c("respid", "screen"))

Having the data prepared you may use ggplot2 to prepare a graph:

# no data specified, as it will be defined separately for each 'layer' of the plot
ggplot(mapping = aes(x = pageX_rel, y = pageY_rel)) +
  # putting INPUT elements into background
  geom_point(data = respInputPositions,
             size = 4, color = "darkgrey") +
  # drawing mouse trace
  geom_path(data = respCursorMoves,
            linewidth = 1.2) +
  # drawing clicks
  geom_point(data = respCursorClicks,
             size = 5) +
  # labeling start and end point
  geom_label(data = respCursorMoves[c(1, nrow(respCursorMoves)), ] |>
               mutate(label = c("start", "end")),
             mapping = aes(label = label), alpha = 0.7) +
  # reversing the Y axis because of the difference in coordinates system
  # between web browsers and plotting packages
  scale_y_reverse()

Animating cursor traces and clicks

If you want to animate cursor trace, it is possible by using package gganimate (you will also need a package responsible for rendering an animation - here this will be the gifski package, that prepares a GIF file, although this is not the only possible choice).

library(gifski)
library(gganimate)

To prepare animation you need to assign a ggplot graph to an object:

g1 <- ggplot(NULL,
       aes(x = pageX_rel, y = pageY_rel)) +
  # putting INPUT elements into background
  geom_point(data = respInputPositions,
             size = 4, color = "darkgrey") +
  # drawing mouse trace
  geom_path(data = respCursorMoves,
            linewidth = 1.2) +
  # drawing clicks
  geom_point(data = respCursorClicks, mapping = aes(group = timeStampRel),
             size = 5) +
  # labeling start and end point
  geom_label(data = respCursorMoves |> #[c(1, nrow(respCursorMoves)), ] |>
               mutate(label = c("start", rep(NA_character_, n() - 2), "end")),
             mapping = aes(label = label, group = timeStampRel), alpha = 0.7) +
  # reversing the Y axis because of the difference in coordinates system
  # between web browsers and plotting packages
  scale_y_reverse()

Please note, that for the animated geom_point and geom_label layers you have to explicitely set the group aesthetics so that each time point will be assigned to a different group (by using group = timeStampRel within the call to aes()).

Now you may turn this graph into an animation by specyfing an animation type in the same way as if you will add another element to a graph. When drawing a cursor trace, the transition_reveal() function will provide the appropriate type of animation. Also, you need to specify that the timeStampRel variable describes the time:

a1 <- g1 + transition_reveal(timeStampRel)

Finally, you need to render animation by calling function animate():

animate(a1, renderer = gifski_renderer())
#> `geom_path()`: Each group consists of only one observation.
#> ℹ Do you need to adjust the group aesthetic?
#> `geom_path()`: Each group consists of only one observation.
#> ℹ Do you need to adjust the group aesthetic?