activities_associations_ComplexAssociation.js

/**
 *  File    : activities/associations/ComplexAssociation.js
 *  Created : 03/06/2015
 *  By      : Francesc Busquets <francesc@gmail.com>
 *
 *  JClic.js
 *  An HTML5 player of JClic activities
 *  https://projectestac.github.io/jclic.js
 *
 *  @source https://github.com/projectestac/jclic.js
 *
 *  @license EUPL-1.2
 *  @licstart
 *  (c) 2000-2020 Educational Telematic Network of Catalonia (XTEC)
 *
 *  Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
 *  the European Commission- subsequent versions of the EUPL (the "Licence");
 *  You may not use this work except in compliance with the Licence.
 *
 *  You may obtain a copy of the Licence at:
 *  https://joinup.ec.europa.eu/software/page/eupl
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
 *  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 *  Licence for the specific language governing permissions and limitations
 *  under the Licence.
 *  @licend
 *  @module
 */


import Activity from '../../Activity';
import { Point } from '../../AWT';
import { SimpleAssociation, SimpleAssociationPanel } from './SimpleAssociation';

/**
 * This is a special case of {@link module:activities/associations/SimpleAssociation.SimpleAssociation SimpleAssociation} where the elements of the 'secondary' panel
 * can have zero, one or more associated elements in the 'primary' panel.
 * @extends module:activities/associations/SimpleAssociation.SimpleAssociation
 */
export class ComplexAssociation extends SimpleAssociation {
  /**
   * ComplexAssociation constructor
   * @param {module:project/JClicProject.JClicProject} project - The JClic project to which this activity belongs
   */
  constructor(project) {
    super(project);
    this.useIdAss = true;
  }

  /**
   * Loads this object settings from an XML element
   * @override
   * @param {external:jQuery} $xml - The jQuery XML element to parse
   */
  setProperties($xml) {
    super.setProperties($xml);
    this.abc['primary'].avoidAllIdsNull(this.abc['secondary'].getNumCells());
  }

  /**
   * Retrieves the minimum number of actions needed to solve this activity.
   * @override
   * @returns {number}
   */
  getMinNumActions() {
    if (this.invAss)
      return this.abc['secondary'].getNumCells();
    else
      return this.abc['primary'].getNumCells() - this.nonAssignedCells;
  }
}

Object.assign(ComplexAssociation.prototype, {
  /**
   * Number of unassigned cells
   * @name module:activities/associations/ComplexAssociation.ComplexAssociation#nonAssignedCells
   * @type {number} */
  nonAssignedCells: 0,
  /**
   * Uses cell's `idAss` field to check if pairings match
   * @name module:activities/associations/ComplexAssociation.ComplexAssociation#useIdAss
   * @type {boolean} */
  useIdAss: false,
});

/**
 * The {@link module:Activity.ActivityPanel ActivityPanel} where {@link module:activities/associations/ComplexAssociation.ComplexAssociation ComplexAssociation} activities are played.
 * @extends module:activities/associations/SimpleAssociation.SimpleAssociationPanel
 */
export class ComplexAssociationPanel extends SimpleAssociationPanel {
  /**
   * ComplexAssociationPanel prototype
   * @param {module:Activity.Activity} act - The {@link module:Activity.Activity Activity} to which this Panel belongs
   * @param {module:JClicPlayer.JClicPlayer} ps - Any object implementing the methods defined in the
   * [PlayStation](http://projectestac.github.io/jclic/apidoc/edu/xtec/jclic/PlayStation.html) Java interface.
   * @param {external:jQuery} [$div] - The jQuery DOM element where this Panel will deploy
   */
  constructor(act, ps, $div) {
    super(act, ps, $div);
  }

  /**
   * Prepares the visual components of the activity
   * @override
   */
  buildVisualComponents() {
    super.buildVisualComponents();

    const
      abcA = this.act.abc['primary'],
      abcB = this.act.abc['secondary'];

    if (abcA && abcB) {
      if (this.act.invAss)
        this.invAssCheck = Array(abcB.getNumCells()).fill(false);
      this.bgA.setDefaultIdAss();
      this.act.nonAssignedCells = 0;
      this.bgA.cells.forEach(bx => {
        if (bx.idAss === -1) {
          this.act.nonAssignedCells++;
          bx.switchToAlt(this.ps);
        }
      });
    }
  }

  /**
   * Checks if all inverse associations are done
   * @returns {boolean}
   */
  checkInvAss() {
    if (!this.act.invAss || !this.invAssCheck)
      return false;
    return this.invAssCheck.every(chk => chk);
  }

  /**
   * Main handler used to process mouse, touch, keyboard and edit events
   * @override
   * @param {external:Event} event - The HTML event to be processed
   * @returns {boolean} - When this event handler returns `false`, jQuery will stop its
   * propagation through the DOM tree. See: {@link http://api.jquery.com/on}
   */
  processEvent(event) {
    if (this.bc && this.playing) {
      //
      // The [AWT.Point](AWT.html#Point) where the mouse or touch event has been originated
      // and two [ActiveBox](ActiveBox.html) pointers used for the [BoxConnector](BoxConnector.html)
      // `origin` and `dest` points.
      let p = null, bx1, bx2;

      //
      // _touchend_ event don't provide pageX nor pageY information
      if (event.type === 'touchend') {
        p = this.bc.active ? this.bc.dest.clone() : new Point();
      } else {
        // Touch events can have more than one touch, so `pageX` must be obtained from `touches[0]`
        let
          x = event.originalEvent && event.originalEvent.touches ? event.originalEvent.touches[0].pageX : event.pageX,
          y = event.originalEvent && event.originalEvent.touches ? event.originalEvent.touches[0].pageY : event.pageY;
        p = new Point(x - this.$div.offset().left, y - this.$div.offset().top);
      }

      let
        // Flag for tracking `mouseup` events
        up = false,
        // Flag for assuring that only one media plays per event (avoid event sounds overlapping
        // cell's media sounds)
        m = false,
        // Flag for tracking clicks on the background of grid A
        clickOnBg0 = false,
        // Array to be filled with actions to be executed at the end of event processing
        delayedActions = [];

      switch (event.type) {
        case 'touchcancel':
          // Canvel movement
          if (this.bc.active)
            this.bc.end();
          break;

        case 'mouseup':
          // Don't consider drag moves below 3 pixels. Can be a "trembling click"
          if (this.bc.active && p.distanceTo(this.bc.origin) <= 3) {
            break;
          }
          up = true;
        /* falls through */
        case 'touchend':
        case 'touchstart':
        case 'mousedown':
          if (!this.bc.active) {
            // New pairing starts
            //
            // Pairings can never start with a `mouseup` event
            if (up)
              break;

            this.ps.stopMedia(1);
            // Determine if click was done on panel A or panel B
            bx1 = this.bgA ? this.bgA.findActiveBox(p) : null;
            bx2 = this.bgB ? this.bgB.findActiveBox(p) : null;
            if (bx1 && bx1.idAss !== -1 && (!this.act.useOrder || bx1.idOrder === this.currentItem) ||
              !this.act.useOrder && bx2) {
              // Start the [BoxConnector](BoxConnector.html)
              if (this.act.dragCells)
                this.bc.begin(p, bx1 || bx2);
              else
                this.bc.begin(p);
              // Play cell media or event sound
              m = m || (bx1 || bx2).playMedia(this.ps, delayedActions);
              if (!m)
                this.playEvent('click');
            }

            // Move the focus to the opposite accessible group
            let bg = bx1 ? this.bgA : this.bgB;
            if (bg && bg.$accessibleDiv) {
              bg = bx1 ? this.bgB : this.bgA;
              if (bg && bg.$accessibleDiv)
                bg.$accessibleDiv.focus();
            }
          } else {
            this.ps.stopMedia(1);
            // Pairing completed
            //
            // Find the active boxes behind `bc.origin` and `p`
            const origin = this.bc.origin;
            this.bc.end();
            bx1 = this.bgA ? this.bgA.findActiveBox(origin) : null;
            if (bx1) {
              bx2 = this.bgB ? this.bgB.findActiveBox(p) : null;
            } else {
              bx2 = this.bgB ? this.bgB.findActiveBox(origin) : null;
              if (bx2) {
                bx1 = this.bgA ? this.bgA.findActiveBox(p) : null;
                clickOnBg0 = true;
              }
            }
            // Check if the pairing was correct
            if (bx1 && bx2 && bx1.idAss !== -1 && !bx2.isInactive() && this.act.abc['secondary']) {
              const
                src = bx1.getDescription(),
                dest = bx2.getDescription(),
                matchingDest = this.act.abc['secondary'].getActiveBoxContent(bx1.idAss);
              let ok = false;

              if (bx1.idAss === bx2.idOrder || bx2.getContent().isEquivalent(matchingDest, true)) {
                // Pairing was OK. Play media and disable involved cells
                ok = true;
                bx1.idAss = -1;
                if (this.act.abc['solvedPrimary']) {
                  bx1.switchToAlt(this.ps);
                  m = m || bx1.playMedia(this.ps, delayedActions);
                } else {
                  if (clickOnBg0)
                    m = m || bx1.playMedia(this.ps, delayedActions);
                  else
                    m = m || bx2.playMedia(this.ps, delayedActions);
                  bx1.clear();
                }
                if (this.act.invAss) {
                  this.invAssCheck[bx2.idOrder] = true;
                  bx2.clear();
                }
                if (this.act.useOrder && this.bgA)
                  // Load next item
                  this.currentItem = this.bgA.getNextItem(this.currentItem);
              }
              // Check results and notify action
              if (this.bgA) {
                const cellsPlaced = this.bgA.countCellsWithIdAss(-1);
                this.ps.reportNewAction(this.act, 'MATCH', src, dest, ok, cellsPlaced - this.act.nonAssignedCells);
                // End activity or play event sound
                if (ok && (this.checkInvAss() || cellsPlaced === this.bgA.getNumCells()))
                  this.finishActivity(true);
                else if (!m)
                  this.playEvent(ok ? 'actionOk' : 'actionError');
              }
            } else if (this.bgB && (clickOnBg0 && this.bgA && this.bgA.contains(p) || !clickOnBg0 && this.bgB.contains(p))) {
              // click on grid, out of cell
              const srcOut = bx1 ? bx1.getDescription() : bx2 ? bx2.getDescription() : 'null';
              this.ps.reportNewAction(this.act, 'MATCH', srcOut, 'null', false, this.bgB.countCellsWithIdAss(-1));
              this.playEvent('actionError');
            }
            this.update();

            // Move the focus to the `source` accessible group
            if (this.bgA && this.bgA.$accessibleDiv)
              this.bgA.$accessibleDiv.focus();
          }
          break;

        case 'mousemove':
        case 'touchmove':
          this.bc.moveTo(p);
          break;
      }
      delayedActions.forEach(action => action());
      event.preventDefault();
    }
  }
}

Object.assign(ComplexAssociationPanel.prototype, {
  /**
   * Array for storing checked associations
   * @name module:activities/associations/ComplexAssociation.ComplexAssociationPanel#invAssCheck
   * @type {boolean[]} */
  invAssCheck: null,
});

/**
 * Panel class associated to this type of activity: {@link module:activities/associations/ComplexAssociation.ComplexAssociationPanel ComplexAssociationPanel}
 * @type {class} */
ComplexAssociation.Panel = ComplexAssociationPanel;

// Register activity class
export default Activity.registerClass('@associations.ComplexAssociation', ComplexAssociation);