Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Drag-and-drop markers question definition class.
  19   *
  20   * @package    qtype_ddmarker
  21   * @copyright  2012 The Open University
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  require_once($CFG->dirroot . '/question/type/ddimageortext/questionbase.php');
  29  require_once($CFG->dirroot . '/question/type/ddmarker/shapes.php');
  30  
  31  
  32  /**
  33   * Represents a drag-and-drop markers question.
  34   *
  35   * @copyright  2009 The Open University
  36   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  class qtype_ddmarker_question extends qtype_ddtoimage_question_base {
  39  
  40      public $showmisplaced;
  41  
  42      public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
  43          if ($filearea == 'bgimage') {
  44              $validfilearea = true;
  45          } else {
  46              $validfilearea = false;
  47          }
  48          if ($component == 'qtype_ddmarker' && $validfilearea) {
  49              $question = $qa->get_question(false);
  50              $itemid = reset($args);
  51              return $itemid == $question->id;
  52          } else {
  53              return parent::check_file_access($qa, $options, $component,
  54                                                                  $filearea, $args, $forcedownload);
  55          }
  56      }
  57      /**
  58       * Get a choice identifier
  59       *
  60       * @param int $choice stem number
  61       * @return string the question-type variable name.
  62       */
  63      public function choice($choice) {
  64          return 'c' . $choice;
  65      }
  66  
  67      public function get_expected_data() {
  68          $vars = array();
  69          foreach ($this->choices[1] as $choice => $notused) {
  70              $vars[$this->choice($choice)] = PARAM_NOTAGS;
  71          }
  72          return $vars;
  73      }
  74      public function is_complete_response(array $response) {
  75          foreach ($this->choices[1] as $choiceno => $notused) {
  76              if (isset($response[$this->choice($choiceno)])
  77                                              && '' != trim($response[$this->choice($choiceno)])) {
  78                  return true;
  79              }
  80          }
  81          return false;
  82      }
  83      public function is_gradable_response(array $response) {
  84          return $this->is_complete_response($response);
  85      }
  86      public function is_same_response(array $prevresponse, array $newresponse) {
  87          foreach ($this->choices[1] as $choice => $notused) {
  88              $fieldname = $this->choice($choice);
  89              if (!$this->arrays_same_at_key_integer(
  90                      $prevresponse, $newresponse, $fieldname)) {
  91                  return false;
  92              }
  93          }
  94          return true;
  95      }
  96      /**
  97       * Tests to see whether two arrays have the same set of coords at a particular key. Coords
  98       * can be in any order.
  99       * @param array $array1 the first array.
 100       * @param array $array2 the second array.
 101       * @param string $key an array key.
 102       * @return bool whether the two arrays have the same set of coords (or lack of them)
 103       * for a given key.
 104       */
 105      public function arrays_same_at_key_integer(
 106              array $array1, array $array2, $key) {
 107          if (array_key_exists($key, $array1)) {
 108              $value1 = $array1[$key];
 109          } else {
 110              $value1 = '';
 111          }
 112          if (array_key_exists($key, $array2)) {
 113              $value2 = $array2[$key];
 114          } else {
 115              $value2 = '';
 116          }
 117          $coords1 = explode(';', $value1);
 118          $coords2 = explode(';', $value2);
 119          if (count($coords1) !== count($coords2)) {
 120              return false;
 121          } else if (count($coords1) === 0) {
 122              return true;
 123          } else {
 124              $valuesinbotharrays = $this->array_intersect_fixed($coords1, $coords2);
 125              return (count($valuesinbotharrays) == count($coords1));
 126          }
 127      }
 128  
 129      /**
 130       *
 131       * This function is a variation of array_intersect that checks for the existence of duplicate
 132       * array values too.
 133       * @author dml at nm dot ru (taken from comments on php manual)
 134       * @param array $array1
 135       * @param array $array2
 136       * @return bool whether array1 and array2 contain the same values including duplicate values
 137       */
 138      protected function array_intersect_fixed($array1, $array2) {
 139          $result = array();
 140          foreach ($array1 as $val) {
 141              if (($key = array_search($val, $array2, true)) !== false) {
 142                   $result[] = $val;
 143                   unset($array2[$key]);
 144              }
 145          }
 146          return $result;
 147      }
 148  
 149  
 150      public function get_validation_error(array $response) {
 151          if ($this->is_complete_response($response)) {
 152              return '';
 153          }
 154          return get_string('pleasedragatleastonemarker', 'qtype_ddmarker');
 155      }
 156  
 157      public function get_num_parts_right(array $response) {
 158          $chosenhits = $this->choose_hits($response);
 159          $divisor = max(count($this->rightchoices), $this->total_number_of_items_dragged($response));
 160          return array(count($chosenhits), $divisor);
 161      }
 162  
 163      /**
 164       * Choose hits to maximize grade where drop targets may have more than one hit and drop targets
 165       * can overlap.
 166       * @param array $response
 167       * @return array chosen hits
 168       */
 169      protected function choose_hits(array $response) {
 170          $allhits = $this->get_all_hits($response);
 171          $chosenhits = array();
 172          foreach ($allhits as $placeno => $hits) {
 173              foreach ($hits as $itemno => $hit) {
 174                  $choice = $this->get_right_choice_for($placeno);
 175                  $choiceitem = "$choice $itemno";
 176                  if (!in_array($choiceitem, $chosenhits)) {
 177                      $chosenhits[$placeno] = $choiceitem;
 178                      break;
 179                  }
 180              }
 181          }
 182          return $chosenhits;
 183      }
 184      public function total_number_of_items_dragged(array $response) {
 185          $total = 0;
 186          foreach ($this->choiceorder[1] as $choice) {
 187              $choicekey = $this->choice($choice);
 188              if (array_key_exists($choicekey, $response) && trim($response[$choicekey] !== '')) {
 189                  $total += count(explode(';', $response[$choicekey]));
 190              }
 191          }
 192          return $total;
 193      }
 194  
 195      /**
 196       * Get's an array of all hits on drop targets. Needs further processing to find which hits
 197       * to select in the general case that drop targets may have more than one hit and drop targets
 198       * can overlap.
 199       * @param array $response
 200       * @return array all hits
 201       */
 202      protected function get_all_hits(array $response) {
 203          $hits = array();
 204          foreach ($this->places as $placeno => $place) {
 205              $rightchoice = $this->get_right_choice_for($placeno);
 206              $rightchoicekey = $this->choice($rightchoice);
 207              if (!array_key_exists($rightchoicekey, $response)) {
 208                  continue;
 209              }
 210              $choicecoords = $response[$rightchoicekey];
 211              $coords = explode(';', $choicecoords);
 212              foreach ($coords as $itemno => $coord) {
 213                  if (trim($coord) === '') {
 214                      continue;
 215                  }
 216                  $pointxy = explode(',', $coord);
 217                  $pointxy[0] = round($pointxy[0]);
 218                  $pointxy[1] = round($pointxy[1]);
 219                  if ($place->drop_hit($pointxy)) {
 220                      if (!isset($hits[$placeno])) {
 221                          $hits[$placeno] = array();
 222                      }
 223                      $hits[$placeno][$itemno] = $coord;
 224                  }
 225              }
 226          }
 227          // Reverse sort in order of number of hits per place (if two or more
 228          // hits per place then we want to make sure hits do not hit elsewhere).
 229          $sortcomparison = function ($a1, $a2){
 230              return (count($a1) - count($a2));
 231          };
 232          uasort($hits, $sortcomparison);
 233          return $hits;
 234      }
 235  
 236      public function get_right_choice_for($place) {
 237          $group = $this->places[$place]->group;
 238          foreach ($this->choiceorder[$group] as $choicekey => $choiceid) {
 239              if ($this->rightchoices[$place] == $choiceid) {
 240                  return $choicekey;
 241              }
 242          }
 243          return null;
 244      }
 245      public function grade_response(array $response) {
 246          list($right, $total) = $this->get_num_parts_right($response);
 247          $fraction = $right / $total;
 248          return array($fraction, question_state::graded_state_for_fraction($fraction));
 249      }
 250  
 251      public function compute_final_grade($responses, $totaltries) {
 252          $maxitemsdragged = 0;
 253          $wrongtries = array();
 254          foreach ($responses as $i => $response) {
 255              $maxitemsdragged = max($maxitemsdragged,
 256                                                  $this->total_number_of_items_dragged($response));
 257              $hits = $this->choose_hits($response);
 258              foreach ($hits as $place => $choiceitem) {
 259                  if (!isset($wrongtries[$place])) {
 260                      $wrongtries[$place] = $i;
 261                  }
 262              }
 263              foreach ($wrongtries as $place => $notused) {
 264                  if (!isset($hits[$place])) {
 265                      unset($wrongtries[$place]);
 266                  }
 267              }
 268          }
 269          $numtries = count($responses);
 270          $numright = count($wrongtries);
 271          $penalty = array_sum($wrongtries) * $this->penalty;
 272          $grade = ($numright - $penalty) / (max($maxitemsdragged, count($this->places)));
 273          return $grade;
 274      }
 275      public function clear_wrong_from_response(array $response) {
 276          $hits = $this->choose_hits($response);
 277  
 278          $cleanedresponse = array();
 279          foreach ($response as $choicekey => $coords) {
 280              $choice = (int)substr($choicekey, 1);
 281              $choiceresponse = array();
 282              $coordparts = explode(';', $coords);
 283              foreach ($coordparts as $itemno => $coord) {
 284                  if (in_array("$choice $itemno", $hits)) {
 285                      $choiceresponse[] = $coord;
 286                  }
 287              }
 288              $cleanedresponse[$choicekey] = join(';', $choiceresponse);
 289          }
 290          return $cleanedresponse;
 291      }
 292      public function get_wrong_drags(array $response) {
 293          $hits = $this->choose_hits($response);
 294          $wrong = array();
 295          foreach ($response as $choicekey => $coords) {
 296              $choice = (int)substr($choicekey, 1);
 297              if ($coords != '') {
 298                  $coordparts = explode(';', $coords);
 299                  foreach ($coordparts as $itemno => $coord) {
 300                      if (!in_array("$choice $itemno", $hits)) {
 301                          $wrong[] = $this->get_selected_choice(1, $choice)->text;
 302                      }
 303                  }
 304              }
 305          }
 306          return $wrong;
 307      }
 308  
 309  
 310      public function get_drop_zones_without_hit(array $response) {
 311          $hits = $this->choose_hits($response);
 312  
 313          $nohits = array();
 314          foreach ($this->places as $placeno => $place) {
 315              $choice = $this->get_right_choice_for($placeno);
 316              if (!isset($hits[$placeno])) {
 317                  $nohit = new stdClass();
 318                  $nohit->coords = $place->coords;
 319                  $nohit->shape = $place->shape->name();
 320                  $nohit->markertext = $this->choices[1][$this->choiceorder[1][$choice]]->text;
 321                  $nohits[] = $nohit;
 322              }
 323          }
 324          return $nohits;
 325      }
 326  
 327      public function classify_response(array $response) {
 328          $parts = array();
 329          $hits = $this->choose_hits($response);
 330          foreach ($this->places as $placeno => $place) {
 331              if (isset($hits[$placeno])) {
 332                  $shuffledchoiceno = $this->get_right_choice_for($placeno);
 333                  $choice = $this->get_selected_choice(1, $shuffledchoiceno);
 334                  $parts[$placeno] = new question_classified_response(
 335                                                      $choice->no,
 336                                                      $choice->summarise(),
 337                                                      1 / count($this->places));
 338              } else {
 339                  $parts[$placeno] = question_classified_response::no_response();
 340              }
 341          }
 342          return $parts;
 343      }
 344  
 345      public function get_correct_response() {
 346          $responsecoords = array();
 347          foreach ($this->places as $placeno => $place) {
 348              $rightchoice = $this->get_right_choice_for($placeno);
 349              if ($rightchoice !== null) {
 350                  $rightchoicekey = $this->choice($rightchoice);
 351                  $correctcoords = $place->correct_coords();
 352                  if ($correctcoords !== null) {
 353                      if (!isset($responsecoords[$rightchoicekey])) {
 354                          $responsecoords[$rightchoicekey] = array();
 355                      }
 356                      $responsecoords[$rightchoicekey][] = join(',', $correctcoords);
 357                  }
 358              }
 359          }
 360          $response = array();
 361          foreach ($responsecoords as $choicekey => $coords) {
 362              $response[$choicekey] = join(';', $coords);
 363          }
 364          return $response;
 365      }
 366  
 367      public function get_right_answer_summary() {
 368          $placesummaries = array();
 369          foreach ($this->places as $placeno => $place) {
 370              $shuffledchoiceno = $this->get_right_choice_for($placeno);
 371              $choice = $this->get_selected_choice(1, $shuffledchoiceno);
 372              $placesummaries[] = '{'.$place->summarise().' -> '.$choice->summarise().'}';
 373          }
 374          return join(', ', $placesummaries);
 375      }
 376  
 377      public function summarise_response(array $response) {
 378          $hits = $this->choose_hits($response);
 379          $goodhits = array();
 380          foreach ($this->places as $placeno => $place) {
 381              if (isset($hits[$placeno])) {
 382                  $shuffledchoiceno = $this->get_right_choice_for($placeno);
 383                  $choice = $this->get_selected_choice(1, $shuffledchoiceno);
 384                  $goodhits[] = "{".$place->summarise()." -> ". $choice->summarise(). "}";
 385              }
 386          }
 387          if (count($goodhits) == 0) {
 388              return null;
 389          }
 390          return implode(', ', $goodhits);
 391      }
 392  
 393      public function get_random_guess_score() {
 394          return null;
 395      }
 396  }
 397  
 398  /**
 399   * Represents one of the choices (draggable markers).
 400   *
 401   * @copyright  2009 The Open University
 402   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 403   */
 404  class qtype_ddmarker_drag_item {
 405      /** @var string Label for the drag item */
 406      public $text;
 407  
 408      /** @var int Number of the item */
 409      public $no;
 410  
 411      /** @var int Group of the item */
 412      public $infinite;
 413  
 414      /** @var int Number of drags */
 415      public $noofdrags;
 416  
 417      /**
 418       * Drag item object setup.
 419       *
 420       * @param string $label The label text of the drag item
 421       * @param int $no Which number drag item this is
 422       * @param bool $infinite True if the item can be used an unlimited number of times
 423       * @param int $noofdrags
 424       */
 425      public function __construct($label, $no, $infinite, $noofdrags) {
 426          $this->text = $label;
 427          $this->infinite = $infinite;
 428          $this->no = $no;
 429          $this->noofdrags = $noofdrags;
 430      }
 431  
 432      /**
 433       * Returns the group of this item.
 434       *
 435       * @return int
 436       */
 437      public function choice_group() {
 438          return 1;
 439      }
 440  
 441      /**
 442       * Creates summary text of for the drag item.
 443       *
 444       * @return string
 445       */
 446      public function summarise() {
 447          return $this->text;
 448      }
 449  }
 450  /**
 451   * Represents one of the places (drop zones).
 452   *
 453   * @copyright  2009 The Open University
 454   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 455   */
 456  class qtype_ddmarker_drop_zone {
 457      /** @var int Group of the item */
 458      public $group = 1;
 459  
 460      /** @var int Number of the item */
 461      public $no;
 462  
 463      /** @var object Shape of the item */
 464      public $shape;
 465  
 466      /** @var array Location of the item */
 467      public $coords;
 468  
 469      /**
 470       * Setup a drop zone object.
 471       *
 472       * @param int $no Which number drop zone this is
 473       * @param int $shape Shape of the drop zone
 474       * @param array $coords Coordinates of the zone
 475       */
 476      public function __construct($no, $shape, $coords) {
 477          $this->no = $no;
 478          $this->shape = qtype_ddmarker_shape::create($shape, $coords);
 479          $this->coords = $coords;
 480      }
 481  
 482      /**
 483       * Creates summary text of for the drop zone
 484       *
 485       * @return string
 486       */
 487      public function summarise() {
 488          return get_string('summariseplaceno', 'qtype_ddmarker', $this->no);
 489      }
 490  
 491      /**
 492       * Indicates if the it coordinates are in this drop zone.
 493       *
 494       * @param array $xy Array of X and Y location
 495       * @return bool
 496       */
 497      public function drop_hit($xy) {
 498          return $this->shape->is_point_in_shape($xy);
 499      }
 500  
 501      /**
 502       * Gets the center point of this zone
 503       *
 504       * @return array X and Y location
 505       */
 506      public function correct_coords() {
 507          return $this->shape->center_point();
 508      }
 509  }