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
26defined('MOODLE_INTERNAL') || die();
27
28require_once($CFG->dirroot . '/question/type/ddimageortext/questionbase.php');
29require_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 */
38class 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 */
404class 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 */
456class 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}
510