---
title: "Using logLime (UseR! 2026 workshop)"
author: "Tomasz Żółtak"
date: "`r Sys.Date()`"
output:
  html_document:
    toc: yes
    toc_float: yes
editor_options:
  chunk_output_type: console
---

```{r include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)
```

# 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

```{r include = FALSE}
library(logLime)
library(dplyr)
```

## Reading the data

For the purpose of this workshop, you can read the data in two ways:

1.  **The harder way**: reading from a huge CSV file (in the form in which the data is exported from the web-surveying platform), which will take a little more time and perhaps may cause some technical issues;
2.  **The easier way**: simply opening the .RData with the data already imported to R data frames.

Please, choose the one you prefer, or try the first and switch to the second if you encounter problems.

The data comes from the second wave of the survey experiment conducted as a part of the research grant *Understanding response styles in self-report data: consequences, remedies and sources* conducted in the Institute of Philosophy and Sociology of the Polish Academy of Sciences by the team of prof. Artur Pokropek.

### The hard way

The main data file contains survey results exported from the *Lime Survey* platform to a CSV file (encoded in UTF-8 with BOM):

- In fact, this file was created by combining two such files from technically two different surveys - each with a differerent version of the order of items in the questionnaire (counterbalance).
- We need to read this file to R by ourselves.
- Such a huge text files (hundreds of MBs) should not be read using base R import functions, but rather specialized packages, like [*arrow*](https://cran.r-project.org/web/packages/arrow) or [*vroom*](https://cran.r-project.org/web/packages/vroom/).
- Below this file is imported into a data frame named `surveyResults`.

In the folllowing sections it is shown how to read the data using either the *arrow* or the *vroom* package. Please, choose the one you prefer or try booth (and compare execution time, if you want).

#### Using the *arrow* package

With the *Apache* *arrow* library you can read the data this way (in my experience the most efficient, although with the data about the size we use, it does not make much difference):

```{r}
surveyResults <-
  arrow::read_csv_arrow("dataExp5BOM.csv",
                        read_options =
                          arrow::CsvReadOptions$create(block_size = 10^8))
```

**Caution!** The non-default settings are necessary to enable reading data containing such long records, as those storing the collected log-data using `read_csv_arrow()` (set above using tha argument `read_options`).

#### Using the *vroom* package

```{r}
surveyResults <- vroom::vroom("dataExp5BOM.csv")
```

### The easy way

If something goes wrong or you don't want to deal with large text files by yourself, simply run:

```{r}
surveyResults <- readRDS("dataExp5.RDS")
```

## Structure of the data

Survey results include different types of variables:

```{r}
names(surveyResults)
```

- `respid` is **respondent id**.
- `condition:status` are respondent's characteristics got from the Internet panel provider and variables (paradata) describing the interview status.
- `vac.SQ001:IPIP20b.SQ020` are responses to survey questions; variable names are created in such a way that the part before the dot describes question (which is - in most cases - the equivalent of a survey screen) and the part after the dot describes the subquestion (item).
  - On these survey screens log-data has been collected.
  - Variables `vac.SQ001:gtrust2.SQ001` contain opinion scales while variables `BIS11a.SQ001:IPIP20b.SQ020` contain personality scales - depending on the version of the survey these two blocks of question may be presented in a different order (compare values of the variable `variant`).
  - Exported from the *Lime Survey* platform in the form of answer labels and simply read from a CSV file these responses are not analytically useful unless further transformed to factors (specyfing the order of levels in a reasonable way).
- `device01:device03` are responses to questions about pointing device respondent used while answering the survey.
  - Log-data was not collected on this survey screen.
- `multi01.SQ001:multi01.other` are responses to the question about multitasking during the interview.
  - Log-data was not collected on this survey screen.
- `mcatt.SQ001:mcburd.SQ006` are responses to questions that were aimed to measure respondent's involvement and burden.
  - During these screens log-data has been collected.
  - Exported from the *Lime Survey* platform in the form of answer labels and simply read from a CSV file these responses are not analytically useful unless further transformed to factors (specyfing the order of levels in a reasonable way).
- `lag:comments` are responses to the questions on the last survey screen that regarded technical difficulties during the survey.
  - Log-data was not collected on this survey screen.
- `interviewtime:grTime_debriefing` are time of the whole interview along with screen times recorded by the *Lime Survey* platform (itself).
- `logvac:logmcburd` are **variables storing recorded log-streams**.

At the moment log-data is stored in the form of very long character strings, compare:

```{r}
substr(surveyResults$logvac[1], 1, 1000)
nchar(surveyResults$logvac[1])
```

(However, a single log-data record typically wouldn't exceed 524,288 characters, as this is the limit of the HTML TEXTAREA field used to store it on the web-surveying platform.)

# Preprocessing log-data

## Transforming log-data streams into data frames

To transform log-data into analytically useful form, you need to use function `separate_logdata_types()` providing it with a data frame containing respondent id along with columns storing recorded log-data streams. The additional argument `questionNamesPrefix` enables to remove a prefix ("log" in our case) from names of columns storing log-data streams, when placing them into the variable identifying survey screen in the output data.

```{r eval=FALSE}
logData <- separate_logdata_types(surveyResults |>
                                    select(respid, starts_with("log")),
                                  questionNamesPrefix = "log")
```

However, you will get much more useful data, if you provide `separate_logdata_types()` also with information about survey structure, using the `surveyStructure` argument. Often the simplest way to do so is to provide names of files storing exported survey structure using this argument (as below), but you can also read such a file (or files) yourself and provide a resulting data frame using the same argument.

In the case of our data, there are two survey structure files exported to TXT files (tab-separated) from the *Lime Survey* platform. - There are two such files because results come from technically two different surveys - each with a different variant of order of items.

```{r}
logData <-
  separate_logdata_types(surveyResults |>
                           select(respid, starts_with("log")),
                         surveyStructure = c("limesurvey_survey_745195.txt",
                                             "limesurvey_survey_817417.txt"),
                         questionNamesPrefix = "log")
```

Function `separate_logdata_types()` returns a list of three data frames:

1.  ***systemInfo*** - storing *access-related* paradata regarding each **respondent-screen** (i.e. a single row describes a specific survey screen for a specific respondent):

    ```{r}
    logData$systemInfo
    ```

    - respondent's web browser self-identification string,
    - respondent's screen and web browser window width and height,
    - data on position and size of a rectangular area spanned by INPUT elements that are used to mark answers in table-format questions,
      - this information is useful to standardize cursor moves indicators between different respondents; it may be also used to identify respondents who have been shown nominally table-format questions in a list layout because of too narrow browser window (`inputsWidth` is 0 in list layout),
    - *timestamp* of the last recorded event,
    - columns identifying some common problems that were diagnosed during preprocessing of log-data:
      - `problemsAnyBroken` - whether any log-data regarding a given respondent-screen has been broken - if so, this typically means that problems were encountered during saving log-data stream to the *Lime Survey* database and *actions* element is probably incomplete, with some events not being recorded,
      - `problemsLeftBrowser` - whether respondent left browser window (card) while answering a given screen,
      - `problemsResized` - whether respondent changed the size of a browser window while answering a given screen,
      - `problemsNoPageLoaded` - whether log-data for a given respondent-screen does not contain a *pageLoaded* event (if so, it is most probably because an old version of the JavaScript applet - that not recorded this type of events - was used to collect the log-data),
      - `problemsNoSubmit` - whether log-data for a given respondent-screen does not contain a *submit* event (if so, it is most probably because an old version of the JavaScript applet - that not recorded this type of events - was used to collect the log-data),
      - `problemsTimeStamps` - whether there is a discontinuity in values of *timestamps* for a given respondent-screen that results in a negative duration of some *mousemove* events (if so, it is most probably because an old version of the JavaScript applet - that used an unreliable method of getting *timestamps* - was used to collect the log-data),
      - `problemsNoActions` - whether there are no *actions* at all in the recorded log-data for a given respondent-screen.

2.  ***inputPositions*** - storing position of the INPUT elements which are used to mark answers to questions on the survey screen; this data is useful to draw backgrounds of plots visualizing cursor traces; also, some information included in *systemInfo* were computed using this data.

    ```{r}
    logData$inputPositions
    ```

    - If survey structure files were provided to `separate_logdata_types()` INPUT element are matched with question code, subquestion code (if applies) and answer code, as well as with [question format code](https://manual.limesurvey.org/Question_object_types) (used internally by the *Lime Survey* platform).
    - This data may be used to compute number of questions and *items* on a given respondent-screen by calling `count_number_of_items()`.

3.  ***actions*** - storing *response-related* paradata, i.e. information about actual events triggered by respondent's actions:

    ```{r}
    logData$actions
    ```

    - There are quite many variables in this data frame but some of them relate only to specific types of events.
      - See [*logDataLimeSurvey* applet README](https://github.com/tzoltak/logdataLimeSurvey#actions) for more specific information.
    - If survey structure files were provided to `separate_logdata_types()`, most events are matched with question code, subquestion code and answer code (if apply).
    - *Mousemove* events reported here were already transformed to describe *moves* (i.e. vectors) instead of only cursor positions. Moreover, distance of move is reported in two versions: with and without correction for scrolling.

### Why it is important to provide survey structure?

Providing survey structure information is important, as it enables `separate_logdata_types()` to map actions (events) onto question, subquestion and answer codes of the elements that triggered them. **Without these information your ability to compute editing and hovering indices, and even answering speed indices will be considerably limited.**

### Non-standard respondent id

In the calls to `separate_logdata_types()` above there were no indicated which variable(s) in the input data constitute respondent's id. It is possible unless name of respondent's id is one (or a combination of several) of the listed below:

- *id*,
- *token*,
- *respid*.

Two first are default columns used to identify respondents in data exported from the *Lime Survey* platform. The last is my personal choice.

If in your data another column is respondent's id, you will need to indicate this in your call to `separate_logdata_types()` by using additional `respId` argument. It works in a [*tidy-selection*](https://dplyr.tidyverse.org/articles/programming.html#tidy-selection) way, so you may give name of the variable unquoted in a call, for example:

```{r eval=FALSE}
anotherLogData <-
  separate_logdata_types(anotherSurveyResults,
                         respId = someAnotherRespondentId,
                         surveyStructure = anotherSurveyStructurFiles)
```

Be aware, that **ability to identify respondent's id is necessary for most of the package's functions.** So if you use non-standard variable as respondent's id, you will need to provide it in the same way as above also in calls to another functions of the package.

## Separating *stagnations*

The *mousemove* Java Script event reports cursor position, but is triggered by the cursor being moved. As a consequence, a respondent who does not move the cursor will be recorded in log-data returned by `separate_logdata_types()` as a (relatively) long-lasting but (probably) rather short-distanced *mousemove*. However, you may want to separate such *actions* into two distinct ones: one representing only a *stagnation* (not-moving) period and another representing actual move (as it already started). You can do so by using function `separate_stagnations()`.

Unfortunately, this can only be done by dividing *action* using an arbitrarily chosen duration threshold, i.e. dividing all the *mousemove* events lasting longer than a specified value, assuming that they cover an initial period of *stagnation* followed by the actual move lasting for a duration equal to the chosen threshold. It is reasonable to set this threshold to the value of frequency of collecting *mousemove* events that was used in the *Lime Survey* log-data collecting applet (typically 100 ms), or perhaps to a slightly higher value.

Below we will create a copy of our log-data object and perform separation of *stagnations* on it using a threshold of 120 ms (the frequency of collecting *mousemove* events on the surveying platform was 100 ms):

```{r}
logDataStag <- logData
logDataStag$actions <- separate_stagnations(logDataStag$actions, 120)
# checking how the number of mousemove actions has changed:
sum(logData$actions$type %in% "mousemove")
sum(logDataStag$actions$type %in% "mousemove")
```

Let's compare the data for some specific respondent-screen:

```{r}
logData$actions |>
  filter(type %in% "mousemove", respid == 58172, screen == "vac") |>
  slice(1:5) |>
  select(respid:type, pageX:duration)
logDataStag$actions |>
  filter(type %in% "mousemove", respid == 58172, screen == "vac") |>
  slice(1:7) |>
  select(respid:type, pageX:duration)
```

Be aware that **separating *stagnations* affects values of the cursor move average absolute accelerations computed afterwards!** With *stagnations* separated values of these indices are higher. However, **other cursor indices remain unaffected** by whether *stagnations* were separated or not.

You will need `separate_logdata_types()` if you are interested in computing process indicators like *the longest period of stagnation* - it is not implemented in the package (at least at the moment), but its computation is straightforward after separating *stagnations*.

## Removing problematic data

As it was discussed above, *systemInfo* element of a list returned by `separate_logdata_types()` includes some variables that identify common problems found in the processed log-data. The purpose of the `remove_problems()` function is to assist you in deciding whether to keep or to remove this problematic records from a given log-data object. For each variable identifying problems it gives you a short description of the problem, provides suggestions whether to remove or to keep the problematic records (given what process indicators you are going to compute further), and allows you to decide what to do with these records.

```{r eval=FALSE}
logData <- remove_problems(logData)
```

Be aware that **the problems identified by variables created by `separate_logdata_types()` may not be the only ones you should consider!**

### Removing data *manually*

Whether you identified some other type of problem in the data or want to perform further analysis uing only a subgroup of respondents and/or screens, the preferred workflow looks as follows:

- If you want to analyze only some subset of respondent-screens, for example only respondents who used a mouse as a pointing device:

  1.  Create an object identifying the subgroup you want to analyze:

  ```{r}
  mouseOnly <- surveyResults |>
    filter(device02 %in% "Mouse") |>
    select(respid, device02) # this is not necessary but lowers memory footprint
  ```

  2.  Perform `semi_join()` with this object on each element of your log-data object:

  ```{r}
  logDataMouseOnly <- lapply(logData, semi_join, y = mouseOnly, by = "respid")
  ```

- If you identified some problematic group and want to remove it from the analysis, for example respondents who did not successfully complete the survey:

  1.  Create an object identifying problematic records:

  ```{r}
  dropouts <- surveyResults |>
    filter(status != "passed") |> # those, who did not complete the questionnaire
    select(respid, status) # this is not necessary but lowers memory footprint
  ```

  2.  Perform `anti_join()` with this object on each element of your log-data object:

  ```{r}
  logDataPassOnly <- lapply(logData, anti_join, y = dropouts, by = "respid")
  ```

You may identify subgroups/problems also on the screen or respondent-screen level - then you should simply accordingly modify the set of columns you select and provide using the `by` argument.

For example, below we will manually remove some data using information stored in the *systemInfo* element of the `logData` object:

```{r}
(noProblems <- logData$systemInfo |>
   filter(problemsAnyBroken != 1,
          problemsLeftBrowser != 1,
          problemsResized != 1,
          problemsTimeStamps != 1) |>
   select(respid, screen, starts_with("problems")))
logData <- lapply(logData, semi_join, y = noProblems, by = c("respid", "screen"))
```

## Other log-data transforming functions

There are two other functions included in the package that perform some transformation of log-data in order to prepare it for further analysis:

- `compute_relative_positions()` - it enhances data on *actions* (events), specifically *mosemoves* and *clicks*, by computing positions and move distances *relative* to the position of a given survey screen (extremely located) form INPUT elements. Positions and distances recalculated this way are more comparable between respondents with different browser window sizes. See section on cursor moves indices for examples of using this function.
- `compute_cursor_positions()` - it enables to compute cursor position at evenly spaced time points. Data in this form is necessary, for example, to prepare heat-map graphs illustrating the amount of time spent by the respondent over different regions of the survey screen. Example of using this function you will find in the vignette regarding drawing graphs using log-data.

# Storing log-data objects

Log-data objects are large and potentially very large. To assure efficient storage and analysis, please consider the following solutions:

- Filter out and keep only these types of *actions* (events) you are interested in. Especially if you are not interested in cursor moves or hovering, removing *mousemove* or *mouseover* and *mouseout* events will greatly reduce the amount of data.
- Do not store results in CSV files. Use either .RData format (it enables to store log-data list as a single object) or Apache Arrow's [*feather*](https://arrow.apache.org/docs/python/feather.html) or [*parquet*](https://parquet.apache.org/docs/) format to save the space and speed up reading the data.

# Computing process indicators

With the log-data prepared as described in the previous section, you can easily calculate the process indicators using the package functions. In most functions you will provide *actions* element of a log-data object as the first argument.

| Type of process indicators | Functions | Types of events used |
|--------------------|------------------------|----------------------------|
| Response editing | `compute_editing()` | *change* |
| Hovering time | `compute_hovering()` | *mouseover*, *mouseout* |
| Answering time | `compute_aat()`, `compute_aht()` | *change* or (*mouseover*, *mouseout*) |
| Cursor moves | `compute_cursor_indices()` | *mousemove* |

: Functions computing process indicators implemented in the package *logLime*

## Response editing

Use function `compute_editing()` to compute number of response edits:

```{r}
(edits <- compute_editing(logData$actions))
```

If you prefer to get results in a *wide* format with only one row for each respondent, you should use `returnFormat = "wide"` argument (be aware that because both survey screen and question code are used to construct column names, in one-question-per-screen survey designs column names may look somewhat redundant).

```{r}
compute_editing(logData$actions, returnFormat = "wide")
```

Please note, that marking answer for the first times is also counted as *edit* by `compute_editing()`, so **changes of respondent's answer is indicated by number of reported edits greater than 1**.

Also, be aware that computing number of edits is possible only if you have provided survey structure files while constructing log-data object using `separate_logdata_types()` (and consequently if there are columns *questionCode* and *subquestionCode* available in your *actions*).

### Example analysis

Now, we may check, whether the average number of edits depends on whether a given item was presented a given respondent in the first or in the second part of the questionnaire:

```{r}
edits |>
  inner_join(surveyResults |>
               select(respid, variant),
             by = "respid") |>
  summarise(meanEdits = mean(edits),
            .by = c(variant, screen, questionCode)) |>
  mutate(typeOfScale = recode_values(screen,
                                     c("bis11a", "bis11b", "bis11c",
                                       "dis19a", "dis19b",
                                       "nc01a", "nc01b", "nc01c",
                                       "ipip20a", "ipip20b") ~ "personality",
                                     c("gtrust", "immig", "itrust1", "itrust2",
                                       "read1", "read2", "read3",
                                       "vac") ~ "opinion",
                                     c("mcatt", "mcburd",
                                       "mcint") ~ "engagement")) |>
  select(typeOfScale, screen, questionCode, variant, meanEdits) |>
  arrange(typeOfScale, questionCode) |>
  tidyr::pivot_wider(names_from = "variant", values_from = "meanEdits") |>
  print(n = Inf)
```

## Hovering time

Hovering indices describe how much time the cursor spent over specific elements of a given survey screen. If only *questionCode*, *subquestionCode* and *answerCode* columns are available in the input data, they will be used to define the structure of the returned data frame.

Please note, that computing hovering time is lengthy (here it takes ofer one minute). By default, the function shows a progress bar, but to avoid a mess in a static output of this vignette, the following call uses the argument `showPB=interactive()`, so the progress bar is shown only during interactive R session (and not while the .Rmd file is compiled).

```{r}
(hovering <- compute_hovering(logData$actions, showPB = interactive()))
```

Hovering times are reported in seconds.

Results returned by `compute_hovering()` will be typically used for further transformations - most notably for an aggregation. Please note, that if you are interested in the results computed on the *higher level* of aggregation, you may get them directly from `compute_hovering()` by removing some columns from the input data. For example if you are interested only in total time spent over different type of the survey interface elements, you may exclude columns *questionCode*, *subquestionCode* and *answerCode* from the input data:

```{r}
compute_hovering(select(logData$actions,
                        -c(questionCode, subquestionCode, answerCode)),
                 showPB = interactive())
```

There is no possibility to get hovering results in a *wide* format - if you want it, you must pivot the results by yourself.

### Example analysis

We may check, whether there is a relationship between the length of the *question content* (stem) - i.e. how many characters there are in the instructions shown to the respondent at the beginning of the question - and mean hovering time over the *question content* (length of the *question content* is coded by hand in the code below):

```{r fig.height=6}
hoveringResults <- hovering |>
  filter(elementType %in% "question content") |>
  inner_join(surveyResults |>
               select(respid, variant),
             by = "respid") |>
  summarise(meanHoverTime = mean(hoverTime),
            .by = c(screen, questionCode)) |>
  mutate(typeOfScale = recode_values(screen,
                                     c("bis11a", "bis11b", "bis11c",
                                       "dis19a", "dis19b",
                                       "nc01a", "nc01b", "nc01c",
                                       "ipip20a", "ipip20b") ~ "personality",
                                     c("gtrust", "immig", "itrust1", "itrust2",
                                       "read1", "read2", "read3",
                                       "vac") ~ "opinion",
                                     c("mcatt", "mcburd",
                                       "mcint") ~ "engagement"),
         lengthOfQHeader = case_when(screen %in% c("bis11a", "bis11b", "bis11c") ~ 346,
                                     screen %in% c("dis19a", "dis19b") ~ 381,
                                     screen %in% c("nc01a", "nc01b", "nc01c") ~ 297,
                                     screen %in% c("ipip20a", "ipip20b") ~ 545,
                                     questionCode %in% "ctr1"  ~ 135,
                                     questionCode %in% "ctr2"  ~ 148,
                                     questionCode %in% "ctr3"  ~ 137,
                                     screen %in% "immig" ~ 626,
                                     screen %in%  c("itrust1", "itrust2") ~ 111,
                                     screen %in% "read1" ~ 87,
                                     screen %in% "read2" ~ 87,
                                     screen %in% "read3" ~ 189,
                                     screen %in% "vac" ~ 87,
                                     screen %in% "mcatt" ~ 82,
                                     screen %in% "mcburd" ~ 70,
                                     screen %in% "mcint" ~ 82)) |>
  select(typeOfScale, screen, questionCode, meanHoverTime, lengthOfQHeader) |>
  arrange(desc(lengthOfQHeader), screen, questionCode)
print(hoveringResults, n = Inf)
hoveringResults |>
  select(meanHoverTime, lengthOfQHeader) |>
  cor()
with(hoveringResults,
     {
       plot(lengthOfQHeader, meanHoverTime, type = "p", cex = 2, pch = 19,
            xlim = c(0, max(lengthOfQHeader)), ylim = c(0, max(meanHoverTime)))
       grid()
       text(lengthOfQHeader, meanHoverTime,
            labels = ifelse(is.na(questionCode), screen, questionCode),
            cex = 1.3, pos = 1)
       abline(lm(meanHoverTime ~ lengthOfQHeader))
     })
```

## Answering time

### Indices computed using changes

Several indices describing respondent's answering speed can be computed by calling function `compute_aat()` (aat stands for Average Answering Time):

```{r}
(answeringTime <- compute_aat(logData$actions))
```

The indices differ with respect to whether they:

- Include all the time spent on the survey screen - *pageTimeIndex*, *pageTimeIndexAll*;
- Estimate only the time spent on answering the *items*, ruling out the time spent on reading the question stem - *averageAnsweringTime*, *averageAnsweringTimeAll*;
  - The idea of the index is illustrated on the [Ulf Kröhne's website](https://kroehne.github.io/aat/).

Morevoer, the indices differ with respect to whether they:

- Include only the *items* (subquestions) that the respondent answered (ignoring those that were omitted without providing an answer) - *pageTimeIndex*, *averageAnsweringTime*;
- Include all the *items* (subquestions), irrespective they were answered or not - *pageTimeIndexAll*, *averageAnsweringTimeAll*.
  - Please note, that by default the number of all the *items* (subquestions) is estimated from the input data, what means that if all the respondents omitted some *item* it won't be taken into account. To solve this problem you may use additional argument `numberOfItems` along with results of a call to `count_number_of_items(logData$inputPositions)`.

By default if there were several questions on the same survey screen, `NA` are returned as values of the aforementioned indices (that is because they were designed for tabular-format questions). If you want to change this behavior and get values of indices also for screens containing several questions, you should set argument `multipleQuestionsAction="keep"` (compare values in the 6th row of the results with the call above):

```{r}
(answeringTime <- compute_aat(logData$actions, multipleQuestionsAction = "keep"))
```

To get results in a *wide* format with only one row for each respondent, you may use `returnFormat="wide"` argument:

```{r}
compute_aat(logData$actions, returnFormat = "wide")
```

### Example analysis

Let's check whether respondents (who got to the end of the questionnaire) of different age differ in the speed of answering:

```{r}
answeringTime |>
  inner_join(surveyResults |>
               filter(!(status %in% "not completed")) |>
               select(respid, age),
             by = "respid") |>
  # we have to substitute infinite values with NAs first
  mutate(averageAnsweringTime = ifelse(is.infinite(averageAnsweringTime),
                                       NA_real_, averageAnsweringTime),
         averageAnsweringTimeAll = ifelse(is.infinite(averageAnsweringTimeAll),
                                          NA_real_, averageAnsweringTimeAll)) |>
  # and only then aggregate
  summarise(across(timeToFirstAnswer:pageTimeIndexAll, ~mean(., na.rm = TRUE)),
            .by = c(age))
```

### Indices compute using hovering times

Alternatively, indices describing answering speed can be computed using previously prepared data on hovering times:

```{r}
(answeringTimeHover <- compute_aht(hovering))
```

This function returns alternative estimate of the *pageTimeIndexAll* along with the indicator *averageHoverTimeAll* reporting average time of mouse cursor hovering over a single *item* (either over a content or answers), taking into account all the *items* on a given survey screen irrespective they were answered or omitted. Also, survey screen (*totalTime*) is returned.

As in the case of `compute_aat()` you may use arguments `multipleQuestionsAction = "keep")` and `returnFormat = "wide"` while calling `compute_aht()`:

```{r}
compute_aht(hovering, multipleQuestionsAction = "keep")
compute_aht(hovering, returnFormat = "wide")
```

### Example analysis

It is interesting to check, how the *aat* and *aht* results match:

```{r}
answeringTimeATTvsAHT <-
  inner_join(answeringTimeHover |>
               rename(pageTimeIndexAllAHT = pageTimeIndexAll),
             answeringTime |>
               select(respid, screen, averageAnsweringTimeAll,
                      pageTimeIndexAllATT = pageTimeIndexAll),
             by = c("respid", "screen")) |>
  # we have to substitute infinite values with NAs first
  mutate(averageHoverTimeAll = ifelse(is.infinite(averageHoverTimeAll),
                                      NA_real_, averageHoverTimeAll),
         averageAnsweringTimeAll = ifelse(is.infinite(averageAnsweringTimeAll),
                                          NA_real_, averageAnsweringTimeAll))
cor(answeringTimeATTvsAHT |>
      select(averageHoverTimeAll, pageTimeIndexAllAHT),
    answeringTimeATTvsAHT |>
      select(averageAnsweringTimeAll, pageTimeIndexAllATT),
    use = "complete.obs")
with(answeringTimeATTvsAHT,
     {
       plot(averageAnsweringTimeAll, averageHoverTimeAll,
            type = "p", cex = 1.5, pch = 19)
       grid()
       abline(a = 0, b = 1, lty = 2, col = "darkblue")
       abline(lm(averageHoverTimeAll ~ averageAnsweringTimeAll), col = "red")
     })
with(answeringTimeATTvsAHT,
     {
       plot(pageTimeIndexAllATT, pageTimeIndexAllAHT,
            type = "p", cex = 1.5, pch = 19)
       grid()
       abline(a = 0, b = 1, lty = 2, col = "darkblue")
       abline(lm(pageTimeIndexAllAHT ~ pageTimeIndexAllATT), col = "red")
     })
```

## Cursor moves indices

To compute several indices describing the way the respondent moves a cursor on a survey screen you may run:

```{r}
(mouseMoves <- compute_cursor_indices(logData$actions))
```

There are 4 main types of cursor moves indices:

- *dX*, *dY* - total distance traveled on the horizontal and vertical axis, respectively [px];
- *vX*, *vY* - average vertical speed on the horizontal and vertical axis, respectively [px/s];
- *aX*, *aY* - average absolute vertical acceleration on the horizontal and vertical axis, respectively [px/s\^2];
- *flipsX*, *flipsY* - number of flips (changing direction of move) on the horizontal and vertical axis, respectively.

Along with *regular* versions, there are also *scrolling corrected* versions returned:

- These indices are identified by the suffix \*\_sc\*.
- These indices are computed ruling out cursor moves over the survey screen that occurred because of the other actions than moving a pointing device (in particular: because of scrolling - either using mouse wheel, touchpad gestures or scrolling bar - using arrows to scroll the page or using TAB key to switch between survey form INPUT fields).

Moreover, you can get *relative* versions of the indices:

- Values of this indices are computing by dividing a *regular* and/or *scrolling corrected* ones by the width or height of a rectangle spanned by the most upper-left and the most bottom-right survey form INPUT element's position on a given survey screen. Values of these indices ones are better comparable between respondents with different browser window size.

To get also this versions, you need to call `compute_relative_positions()` on the *actions* element (you need to provide also the *systemInfo* element of a log-data object as the second argument) before passing it into `compute_cursor_indices()`:

```{r}
(logData$actions <- compute_relative_positions(logData$actions,
                                               logData$systemInfo))
(mouseMoves <- compute_cursor_indices(logData$actions))
```

Also mouse move indices can be returned in a *wide* format:

```{r}
compute_cursor_indices(logData$actions, returnFormat = "wide")
```

### Example analysis

Let's compare the values of the relative, scroll-corrected cursor move indices between respondents who were among 25% most active web-panel members and among 25% less active panel members, and in between this thresholds:

```{r}
mouseMoves |>
  inner_join(surveyResults |>
               select(respid, n_interviews_last_year) |>
               mutate(webPanelActivity =
                        cut(n_interviews_last_year,
                            c(-Inf,
                              quantile(n_interviews_last_year, c(0.25, 0.75)),
                              Inf),
                            labels = c("Lowest 25%", "Mediocre", "Highest 25%"))),
             by = "respid") |>
  summarise(across(c(ends_with("_screl"), "flipsX", "flipsY"),
                   ~mean(ifelse(is.infinite(.), NA_real_, .), na.rm = TRUE)),
            .by = c(screen, webPanelActivity)) |>
  arrange(screen, webPanelActivity)
```
