Source: modules/Target.js

/** @module modules/Target */

import { Communicator } from "./Communicator.js";
import { Timer } from "./Timer.js";

/**
 * A series of accepted click times.
 * @typedef {Array<module:modules/Timer~Timestamp>} ClickTimes
 */

/**
 * The difference between two click times.
 * @typedef {number} ClickInterval
 */

/**
 * A series of click intervals recorded in a session.
 * @typedef {Array.<ClickInterval>} ClickIntervals
 */

/**
 * Manages the randomized target and interacts and interacts with DOM elements related to it.
 * @extends Communicator
 */
export class Target extends Communicator {
  /**
   * @property {HTMLDivElement} target - Can be clicked to score points.
   * @property {HTMLInputElement} targetVisibility - Can be clicked to enable or disable the target.
   * @property {HTMLAnchorElement} averageClick - Displays the average time, in milliseconds, that a
   * target click was accepted following the target was displayed.
   * @property {HTMLAnchorElement} start - Can be clicked to reset the target position and dimensions.
   * @property {HTMLAnchorElement} end - Can be clicked to reset the target position and dimensions, and
   * the average click time.
   * @type {Object.<string, HTMLElement>}
   */
  domElements = {
    target: document.getElementById("target"),
    targetVisibility: document.getElementById("targetVisibility"),
    averageClick: document.getElementById("averageClick"),
    start: document.getElementById("start"),
    end: document.getElementById("end"),
  };

  /**
   * Contains default target DOM element configuration.
   * @property {number} left - The left position.
   * @property {number} top - The top position.
   * @property {number} side - The width and height dimension.
   * @property {string} visibility - The visibility.
   */
  defaults = {
    left: 50,
    top: 50,
    side: 25,
    visibility: "visible",
  };

  /**
   * The points a target click is worth. This value is multiplied by the timescale multiplier when
   * points are scored.
   * @type {number}
   */
  pointsValue = 1;
  static name = "Target";
  name = "Target";

  /** @see {@link Communicator} */
  constructor(messageProxy) {
    super(messageProxy);
  }

  /**
   * Sets the initial state.
   * @method
   */
  initialize = () => {
    this.targetElementLeft = this.defaults.left;
    this.targetElementTop = this.defaults.top;
    this.targetElementWidth = this.defaults.side;
    this.targetElementHeight = this.defaults.side;
    this.targetElementVisibility = this.defaults.visibility;
    this.clickTimes = [];
    this.clickIntervals = [];
  }

  /**
   * Sets the target DOM element left position.
   * @method
   * @param {number} left - The target left position.
   * @variation 1
   */
  set targetElementLeft(left) {
    this.domElements.target.style.left = `${left}px`;
  }

  /**
   * Sets the target DOM element top position.
   * @method
   * @param {number} top - The number to set the top position to.
   * @variation 1
   */
  set targetElementTop(top) {
    this.domElements.target.style.top = `${top}px`;
  }

  /**
   * Gets the target DOM element width.
   * @member
   * @type {number}
   * @variation 0
   */
  get targetElementWidth() {
    return parseInt(this.domElements.target.style.width, 10);
  }

  /**
   * Sets the target DOM element width.
   * @method
   * @param {number} width - The number to set the width dimension to.
   * @variation 1
   */
  set targetElementWidth(width) {
    this.domElements.target.style.width = `${width}px`;
  }

  /**
   * Sets the target DOM element height.
   * @method
   * @param {number} height - The number to set the height dimension to.
   * @variation 1
   */
  set targetElementHeight(height) {
    this.domElements.target.style.height = `${height}px`;
  }

  /**
   * Gets the target DOM element visibility.
   * @member
   * @type {boolean}
   * @variation 0
   */
  get targetElementVisibility() {
    return this.domElements.targetVisibility.checked;
  }

  /**
   * Sets the target DOM element visibility.
   * @method
   * @param {boolean} visible - True if visible, else false.
   * @variation 1
   */
  set targetElementVisibility(visible) {
    this.domElements.targetVisibility.checked = visible
    this.domElements.target.style.visibility = this.targetElementVisibility ? "visible" : "hidden";
    this.send("targetElementVisibility", visible);
  }

  /**
   * Sets random target positions within the browser window.
   * @method
   */
  setRandomTargetElementPositions() {
    const maxLeft = window.innerWidth - this.targetElementWidth * 2;
    const maxTop = window.innerHeight - this.targetElementWidth * 2;
    this.targetElementLeft = Math.floor((Math.random() * maxLeft) + this.targetElementWidth);
    this.targetElementTop = Math.floor((Math.random() * maxTop) + this.targetElementWidth);
  }

  /**
   * Sets random target dimensions to a minimum of half the default target side value and a maximum
   * of triple the side value plus half the side value.
   */
  setRandomTargetElementDimensions() {
    const value = Math.floor(
      Math.random() * (this.defaults.side * 3) + (this.defaults.side / 2)
    );
    this.targetElementWidth = value;
    this.targetElementHeight = value;
  }

  #clickTimes;

  /**
   * A first-in, first-out queue of session click times. The queue has a maximum length of 2.
   * @member
   * @type {Array.<Timer:Timestamp>}
   * @variation 0
   */
  get clickTimes() {
    return this.#clickTimes;
  }

  /**
   * Sets the session click times. When the maximum length of click times is reached, a
   * click interval is created from the difference between those two click times, then the 0-index
   * click time is removed. An empty array can be provided in place of a timestamp to clear the
   * click times.
   * @method
   * @param {number|module:modules/Timer:Timestamp|Array} value - A click time or empty array.
   * @variation 1
   */
  set clickTimes(value) {
    // Try to empty the click time queue
    if (Array.isArray(value)) {
      if (value.length > 0) {
        throw new Error(`Invalid array length for value: ${value.length}`);
      }

      this.#clickTimes = value;
    // Process the new click time
    } else {
      this.#clickTimes.push(value);

      if (this.#clickTimes.length === 2) {
        // Generate a click interval from two sequential click times
        this.clickIntervals = this.clickTimes;
        // Remove the older click time to open a slot for the next click time
        this.#clickTimes.splice(0, 1);
      }
    }
  }

  #clickIntervals = [];

  /**
   * The average of the session click intervals in milliseconds
   * @member
   * @type {number}
   * @variation 0
   */
  get clickIntervals() {
    let clickIntervalSum = this.#clickIntervals.reduce(
      (totalTime, recordedTime) => totalTime + recordedTime, 0
    );

    // Don't divide by 0
    if (this.#clickIntervals.length > 0) {
      return Math.round(clickIntervalSum / this.#clickIntervals.length);
    }

    return clickIntervalSum;
  }

  /**
   * Calculates the interval between two sequential click times, calculates the duration for which
   * the session was paused between the clicks times, subtracts the paused duration from the click
   * interval, then adds the resulting click interval to the click intervals.
   * @method
   * @param {(number|ClickTimes)} clickTimes - A full or empty click times queue.
   * @variation 1
   */
  set clickIntervals(clickTimes) {
    if (Array.isArray(clickTimes) && clickTimes.length === 0) {
      // Reset `#clickIntervals` if `clickTimes` is empty
      this.#clickIntervals = [];
    } else if (clickTimes.length < 2 || clickTimes.length > 2) {
      throw new Error(`Invalid array length for clickTimes: ${clickTimes.length}`);
    } else {
      if (clickTimes.filter((time) => isNaN(time)).length > 0) {
        throw new Error(
          `Invalid type(s) for clickTimes: ${typeof(clickTimes[0])}, ${typeof(clickTimes[1])}`
        );
      } else if (clickTimes[1] - clickTimes[0] < 0) {
        throw new Error(`Invalid time sequence for clickTimes: ${clickTimes[1]}, ${clickTimes[2]}`);
      }

      const pauseDuration = this.getPauseDurationBetweenClicks(clickTimes[0], clickTimes[1]);
      this.#clickIntervals.push(clickTimes[1] - clickTimes[0] - pauseDuration);
      this.updateAverageClickElement();
    }
  }

  /**
   * Updates the average click DOM element content.
   */
  updateAverageClickElement() {
    this.domElements.averageClick.textContent = this.clickIntervals.toString();
  }

  /**
  * Sum the duration of time that the session was paused between two sequential click times.
  * @param {module:modules/Timer:Timestamp} startClick - The first accepted click.
  * @param {module:modules/Timer:Timestamp} endClick - The last accepted click.
  * @returns {number} The summed pause durations in milliseconds.
  */
  getPauseDurationBetweenClicks(startClick, endClick) {
    const sessionSegments = this.state[Timer].sessionSegments;

    return sessionSegments.reduce((totalTime, sessionTime, index) => {
      const nextIndex = index + 1;

      if (nextIndex in sessionSegments) {
        if (sessionTime.end > startClick && sessionSegments[nextIndex].start < endClick) {
          return totalTime + sessionSegments[nextIndex].start - sessionTime.end;
        }
      }
      return totalTime;
    }, 0);
  }

  /**
   * An event listener delegatee that adds a click time randomizes the target DOM element  position
   * and dimensions, then adds points to the session score.
   * @method
   */
  targetElementClick = () => {
    if (this.state[Timer].process === "start") {
      this.clickTimes = performance.now();
      this.setRandomTargetElementDimensions();
      this.setRandomTargetElementPositions();
      this.send("points", this.pointsValue);
    }
  }

  /**
   * An event listener delegatee that sets the target DOM element to the target visibility.
   * @method
   */
  targetVisibilityElementClick = () => {
    this.targetElementVisibility = this.targetElementVisibility;

    if (this.state[Timer].process === "start") {
        this.clickTimes = performance.now();
    }
  }

  /**
  * Handles the sesssion start, pause, and end states.
  * @method
  */
  processReceive = (object, property, value) => {
    switch (value) {
      case "start":
        // Add a clickTime when a new session is started
        if (this.clickTimes.length === 0 && this.targetElementVisibility) {
          this.clickTimes = performance.now();
        }

        this.setRandomTargetElementDimensions();
        this.setRandomTargetElementPositions();

        break;
      case "pause":
        this.setRandomTargetElementDimensions();
        this.setRandomTargetElementPositions();

        break;
      case "end":
        this.initialize();
    }
  }
}