/** @namespace H5P */ var H5P = H5P || {}; H5P.PersonalityQuiz = (function ($, EventDispatcher) { /** A personality quiz. @memberof PersonalityQuiz @param {Object} params @param {number} id @constructor */ function PersonalityQuiz(params, id) { var self = this; self.classPrefix = 'h5p-personality-quiz-'; self.resultAnimation = params.resultScreen.animation; self.resultTitle = params.resultScreen.displayTitle; self.resultDescription = params.resultScreen.displayDescription; self.resultImagePosition = params.resultScreen.imagePosition; self.progressText = params.progressText; self.personalities = params.personalities; self.numQuestions = params.questions.length; self.slidePercentage = 100 / self.numQuestions; var loadingImages = []; // NOTE (Emil): These constants are defined in pixels. var responsiveColumnThreshold = 450; var minimumHeight = 400; var canvas = { classname: classes('wheel'), width: 300, height: 300, }; var $content = $('.h5p-content'); var body = document.querySelector('body'); var animation = ((body.style.animationName !== undefined) && params.animation); var resizeEventHandler = null; EventDispatcher.call(self); /** Wrapper around H5P.getPath so as not to need id everywhere. @param {string} path @return {string} Full path for the resource. */ function _getPath(path) { return H5P.getPath(path, id); } /** Prefix a classname with self.classPrefix. @param {string} className @param {boolean} addDot If true adds '.' to the start of the start of the class name. @return {string} */ function prefix(className, addDot) { var prefixed = self.classPrefix + className; if (addDot) { prefixed = '.' + prefixed; } return prefixed; } /** Prefixes all arguments with self.classPrefix. @return {string} A string with all prefixed class names passed to the function separated by spaces. */ function classes() { var args = Array.prototype.slice.call(arguments); var classNames = 'h5p-personality-quiz'; args.forEach(function (argument) { classNames += ' ' + prefix(argument); }); return classNames; } /** Interpolates a string, looping over the properties of variables and replaces instances of '@' + property name with the value of the property. @param {string} str @param {Object} variables @return {string} */ function interpolate(str, variables) { var keys = Object.keys(variables); keys.forEach(function (key) { str = str.replace('@' + key, variables[key]); }); return str; } /** Creates an element of 'type' and adds the attributes in the object 'attributes'. In addition some general styles are added to the element. @param {string} type - name of an element type. @param {Object} attributes @returns {jQuery} - The new button element. */ function createButton(type, attributes) { var $button = $('<' + type + '>', attributes); $button.css({ 'border-left': '5px solid #' + params.buttonColor, 'background': 'linear-gradient(to right, #' + params.buttonColor + ' 50%, rgb(233, 239, 247) 50%)', 'background-size': '200% 100%', 'background-position': '100%' }); return $button; } /** Takes a callback and creates a listener for on the button for the click event. The logic for calling the callback is determined by the avilability of animation. @param {jQuery} $element @param {listenerCallback} */ function addButtonListener($element, callback) { if (animation) { $element.click(function () { $(this).addClass(prefix('button-animate')); }); $element.on('animationend', function () { $(this).removeClass(prefix('button-animate')); callback(); }); } else { $element.click(function () { callback(); }); } } /** Creates a canvas element and returns a canvas element. @return {jQuery} */ function createCanvas() { var $wrapper = $('
', { 'class': classes('wheel-container slide') }); var $canvas = $('', { 'class': canvas.classname, }); self.$canvas = $canvas; $wrapper.append($canvas); return $wrapper; } /** Entry point for creating the entire personality quiz ui. @param {PersonalityQuiz} quiz A personality quiz instance @param {Object[]} data The params received from H5P @return {jQuery} */ function createQuiz(quiz, data) { var $container, $slides, $bar, $title, $question, $canvas, $result; $container = $('
', { 'class': classes('container') }); $slides = $('
', { 'class': classes('slides') }); $bar = createProgressbar(); if (!data.titleScreen.skip) { $title = createTitleCard(quiz, data.titleScreen, data.startText); $slides.append($title); } data.questions.forEach(function (question) { $question = createQuestion(quiz, question); $slides.append($question); }); if (animation && self.resultAnimation === 'wheel') { $canvas = createCanvas(); $slides.append($canvas); } $result = createResult(quiz, data.resultScreen, data.retakeText); $slides.append($result); $container.append($bar, $slides); quiz.$progressbar = $bar; if (data.titleScreen.skip) { quiz.$progressbar.show(); } quiz.$progressText = $bar.children(prefix('progress-text', true)); quiz.$wrapper = $container; quiz.$slides = $slides.children(); quiz.$result = $result; return $container; } /** Create a progress bar for the quiz @return {jQuery} */ function createProgressbar() { var $bar, $text, text; $bar = $('
', { 'class': classes('progressbar') }); $bar.css({ 'background': 'linear-gradient(to right, #' + params.progressbarColor + ' 50%, rgb(60, 62, 64) 50%)', 'background-size': '200% 100%', 'background-position': '100%' }); $bar.hide(); $text = $('

', { 'class': classes('progress-text') }); text = interpolate(self.progressText, { 'question': self.answered + 1, 'total': self.numQuestions }); $text.html(text); if (animation) { $bar.css('transition', 'background-position 1s'); } $bar.append($text); return $bar; } /** Create the title screen. @param {PersonalityQuiz} quiz A personality quiz instance @param {Object} data The params received from H5P @param {string} startText The UI text for the start button @return {jQuery} */ function createTitleCard(quiz, data, startText) { var $card, $content, $title, $wrapper, $startButton, path, hasImage; hasImage = data.image.file ? true : false; $card = $('

', { 'class': classes('title-card', 'slide', 'background') }); $content = $('
', { 'class': classes('title-card-wrapper') }); $title = $('

', { html: data.title.text, 'class': classes('title') }); if (hasImage) { path = _getPath(data.image.file.path); $card.css('background-image', 'url(' + path + ')'); } $wrapper = $('
', { 'class': classes('start-button-wrapper') }); $startButton = createButton('button', { 'class': classes('start-button', 'button'), 'html': startText, 'type': 'button' }); addButtonListener($startButton, function () { quiz.trigger('personality-quiz-start'); }); $wrapper.append($startButton); $content.append($title, $wrapper); $card.append($content); return $card; } /** Creates a question for the quiz. @param {PersonalityQuiz} quiz @param {Object} question A question instance from params @return {jQuery} */ function createQuestion(quiz, question) { var $slide, $text, $image, $answer; var path, deferred, images, createAnswerButton; $slide = $('
', { 'class': classes('question', 'slide') }); $text = $('

', { 'class': classes('question-text'), 'html': question.text }); if (question.image.file) { path = _getPath(question.image.file.path); $image = $('', { 'class': classes('question-image'), 'src': path }); deferred = $.Deferred(); $image.on('load', function () { deferred.resolve(); }); loadingImages.push(deferred.promise()); $slide.append($image); } $slide.append($text); // NOTE (Emil): We only make the answers show images if all the alternatives have images. images = true; question.answers.forEach(function (answer) { images = images && answer.image.file !== undefined; }); createAnswerButton = images ? createImageAnswer : createAnswer; $answer = createAnswerButton(question.answers, quiz.answerListener); $slide.append($answer); return $slide; } /** Get the number of columns per row for image answers. @return {number} The number of columns based on $container width and the responseColumnThreshold. */ function getNumColumns() { return (self.$container.width() < responsiveColumnThreshold) ? 2 : 3; } /** Add or remove rows if there are respectively too few or too many rows. @param {jQuery} $container @param {jQuery} $rows @param {number} rowCount */ function checkRows($container, $rows, rowCount) { var $row, $extra; $extra = $rows.slice(rowCount); $extra.remove(); while ($rows.length < rowCount) { $row = createRow(); $container.append($row); $rows = $container.children(); } } /** Attaches $elements to the $rows in the $container. @param {jQuery} $container @param {jQuery} $elements @param {number} columns decides how many elements go in each row. @param {number} [height] An optional height value for the row. */ function attachRows($container, $elements, columns) { var $rows = $container.children(prefix('row', true)); $rows.each(function (index) { var $row = $(this); var start = (index) * columns; var end = start + columns; $row.append($elements.slice(start, end)); }); } /** Creates a list element with the 'h5p-personality-quiz-row' class. @return {jQuery} */ function createRow() { return $('
  • ', { 'class': classes('row') }); } /** Creates an answer with an image attached. @param {Object} answer @param {listenerCallback} listener @return {jQuery} */ function createImageAnswer(answers, listener) { var $wrapper, $answers, $row, $elements; var columns = getNumColumns(); var rowCount = answers.length / columns; var row = 0; $wrapper = $('
    ', { 'class': classes('answers-wrapper') }); $answers = $('
      ', { 'class': classes('image-answers') }); $elements = answers.map(function (answer) { var $answer, $button, $image; var path = _getPath(answer.image.file.path); $answer = $('
      ', { 'class': classes('column', 'columns-' + String(columns)), 'data-personality': answer.personality }); $button = createButton('div', { 'class': classes('button', 'image-answer-button'), 'html': answer.text }); $image = $('
      ', { 'class': classes('image-answer-image') }); $image.css('background-image', 'url(' + path + ')'); $answer.append($image, $button); $answer.click(listener); return $answer; }); for (row = 0; row < rowCount; row++) { $row = createRow(); $answers.append($row); } attachRows($answers, $elements, columns); $wrapper.append($answers); return $wrapper; } /** Creates a button for the answer element. @param {Object} answer @param {listenerCallback} listener @return {jQuery} */ function createAnswer(answers, listener) { var $wrapper = $('
      ', { 'class': classes('answers-wrapper') }); var $answers = $('
        ', { 'class': classes('answers') }); $answers.click(listener); answers.forEach(function (answer) { var $answer = createButton('li', { 'data-personality': answer.personality, 'class': classes('button', 'answer'), 'html': answer.text }); $answers.append($answer); }); $wrapper.append($answers); return $wrapper; } /** Creates the slide for showing the result at the end of the quiz. @param {PersonalityQuiz} quiz A PersonalityQuiz instance @param {Object} data The params received from H5P @param {string} retakeText The UI text for the button to retake the quiz @return {jQuery} */ function createResult(quiz, data, retakeText) { var $result = $('
        ', { 'class': classes('result', 'slide') }); var $wrapper = $('
        ', { 'class': classes('personality-wrapper') }); var $container = $('
        ', { 'class': classes('retake-button-wrapper') }); var $button = createButton('button', { 'html': retakeText, 'class': classes('button', 'retake-button'), 'type': 'button' }); addButtonListener($button, function () { quiz.trigger('personality-quiz-restart'); }); $container.append($button); quiz.$resultWrapper = $wrapper; $result.append($wrapper); $result.append($container); return $result; } /** Sets the background image of the personality slide the the image associated with the personality. @param {jQuery} $result The result slide to set the background on @param {Object} personality */ function setPersonalityBackgroundImage($result, $personality, personality) { var path = _getPath(personality.image.file.path); var classNames = [ prefix('background'), prefix('center-personality-wrapper'), ]; $result.css('background-image', 'url(' + path + ')'); $result.addClass(classNames.join(' ')); $personality.addClass(prefix('center-personality')); } /** Create an element only if the passed expression evaluates to 'true'. @param {boolean} expression @param {string} element The tag for the element to be created. @param {Object} attributes Attributes to set on the created element. @return {jQuery} */ function createIf(expression, element, attributes) { var $element = null; if (expression) { $element = $(element, attributes); } return $element; } /** Sets the height of the inline personality image. @param {jQuery} $wrapper @param {jQuery} $personality @param {jQuery} $image */ function setInlineImageHeight ($image, $personality, $resultWrapper) { var height; if (!$image) { return; } $image.hide(); height = $resultWrapper.outerHeight() - $personality.outerHeight(true); $image.css({'height': 'calc(' + height + 'px - 4 * 1em)'}); $image.show(); } /** Appends the personality information to the result slide. @param {PeronalityQuiz} quiz @param {Object} personality @param {boolean} hasTitle @param {boolean} hasImage @param {boolean} hasDescription */ function appendPersonality(quiz, personality, hasTitle, hasImage, hasDescription) { var $personality, $title, $description, $image; $title = createIf(hasTitle, '

        ', { 'html': personality.name }); if (personality.image.file) { $image = createIf(hasImage, '', { 'class': classes('result-image'), 'src': _getPath(personality.image.file.path), 'alt': personality.image.alt }); } $description = createIf(hasDescription, '

        ', { 'html': personality.description }); // NOTE (Emil): We only create $personality element if it has at least // one child element. if (hasTitle || hasImage || hasDescription) { $personality = $('

        ', { 'class': classes('personality') }); $personality.append($title); $personality.append($image); $personality.append($description); } quiz.$resultWrapper.append($personality); setInlineImageHeight($image, $personality, quiz.$resultWrapper); return $personality; } /** The click event listener if animations are enabled. @param {jQuery} $button @param {Object[]} personalities The list of personalities associated with the $button */ function animatedButtonListener($button, personalities) { var animationClass = prefix('button-animate'); $button.addClass(animationClass); $button.on('animationend', function () { $(this).removeClass(animationClass); $(this).off('animationend'); self.trigger('personality-quiz-answer', personalities); }); } /** Click event handler for disabled animation option. @param {jQuery} @param {Object[]} personalities The personalities associated with the $button */ function nonAnimatedButtonListener($button, personalities) { self.trigger('personality-quiz-answer', personalities); } /** Resize the questions with image answers. */ function resizeColumns($quiz) { var rowCount, columns; var $answers, $rows; columns = getNumColumns(); $answers = $quiz.find(prefix('image-answers', true)); $answers.each(function () { var $answer, $slide, $alternatives; $answer = $(this); $rows = $answer.children(prefix('row', true)); $alternatives = $rows.children(prefix('column', true)); // NOTE (Emil): Remove the answer-images from the DOM so we can // calculate the new column width. $alternatives = $alternatives.detach(); rowCount = Math.ceil($alternatives.length / columns); checkRows($answer, $rows, rowCount); if (!$alternatives.hasClass(prefix('columns-' + columns))) { $alternatives.toggleClass(classes('columns-2', 'columns-3')); } attachRows($answer, $alternatives, columns); // NOTE (Emil): Update the selection after changes. $rows = $answer.children(prefix('row', true)); $slide = $answer.parent().parent(); var titleHeight = $slide.children(prefix('question-text', true)).outerHeight(true) || 0; var imageHeight = $slide.children(prefix('question-image', true)).outerHeight(true) || 0; var height = $slide.height() - (titleHeight + imageHeight); setAnswerImageHeight($rows, height / $rows.length); }); } /** Resize the result screen. */ function resizeResult($quiz) { var $wrapper, $personality, $image; $wrapper = self.$resultWrapper; $personality = $quiz.find(prefix('personality')); $image = $quiz.find(prefix('result-image')); setInlineImageHeight($image, $personality, self.$resultWrapper); } /** Resize event handler. @param {Object} event The resize event object. */ function resize() { resizeColumns(self.$wrapper); resizeResult(self.$wrapper); } /** Calculate and set the height of the slides in the quiz. @param {Object} self The quiz object @param {jQuery} $quiz The container for the entire quiz */ function setQuizHeight(self, $quiz) { var height = 0; self.$slides.each(function (index, element) { var $slide, $image; var h = 0; $slide = $(element); $image = $slide.children(prefix('question-image', true)); $image.hide(); if (this.clientHeight > height) { h = $(this).height(); if ($image) { h = h * 1.3; } height = h; } $image.show(); }); height = Math.max(height, minimumHeight); $quiz.height(height); self.height = height; } /** Set the height of all images attached to answer alternatives. @param {jQuery} $quiz The root of the quiz */ function setAnswerImageHeight($rows, maxRowHeight) { var ratio = 9.0 / 16.0; var numColumns = getNumColumns(); $rows.each(function () { var imageHeight, heights, width; var $row, $columns, $buttons, $images; $row = $(this); $columns = $row.children(); $buttons = $columns.children(prefix('image-answer-button', true)); $images = $columns.children(prefix('image-answer-image', true)); width = (self.$wrapper.width() / numColumns); imageHeight = Math.floor(width * ratio); heights = $buttons.map(function (i, e) { var $e = $(e); $e.css('height', ''); // NOTE (Emil): Unset previous height calculations. return $e.height(); }); $buttons.height(Math.max.apply(null, heights)); $images.height(imageHeight); // If the size of the containing box is larger than the limit set by // maxRowHeight we subtract the difference from the height of the image. if (maxRowHeight && $columns.outerHeight() > maxRowHeight) { imageHeight -= ($columns.outerHeight() - maxRowHeight); $images.height(imageHeight); } }); } /** Internal attach function. Creates the quiz, calculates the height the quiz needs to be and starts canvas rendering if the wheel of fortune animation is enabled. @param {jQuery} $container */ function attach($container) { loadingImages = []; self.reset(); var $quiz = createQuiz(self, params); $container.append($quiz); // NOTE (Emil): We only want to do the work for a resize event once. // Only the resize event call that survives 100 ms is called. $(window).resize(function () { clearTimeout(resizeEventHandler); resizeEventHandler = setTimeout(resize, 100); }); // NOTE (Emil): Wait for images to load, if there are any. // If there aren't any images to wait for this function is called immediately. $.when.apply(null, loadingImages).done(function () { setAnswerImageHeight($quiz.find(prefix('row', true))); setQuizHeight(self, $quiz); if (animation && params.resultScreen.animation === 'wheel') { canvas.width = $container.width() * 0.8; canvas.height = $container.height(); self.wheel = new PersonalityQuiz.WheelAnimation( self, self.personalities, canvas.width, canvas.height, _getPath ); } self.trigger('resize'); }); } /** Required function for interacting with H5P. @param {jQuery} $container The parent element for the entire quiz. */ self.attach = function ($container) { if (self.$container === undefined) { self.$container = $container; attach(self.$container); } }; /** Sets the result of the personality quiz. Creates the missing elements for the result screen and sets the result on the wheel of fortune animation if it is enabled. @param {Object} personality */ self.setResult = function (personality) { var $personality; var backgroundImage = (personality.image.file) && self.resultImagePosition === 'background'; var inlineImage = (personality.image.file) && self.resultImagePosition === 'inline'; if (self.$canvas) { self.wheel.attach(self.$canvas[0]); self.wheel.setTarget(personality); self.wheel.animate(); } $personality = appendPersonality( self, personality, self.resultTitle, inlineImage, self.resultDescription ); if (backgroundImage) { setPersonalityBackgroundImage(self.$result, $personality, personality); } }; /** Searches the personalities for the one with the highest 'count' property. In the case of a tie the first element with the shared highest 'count' is selected. @return {Object} The result personality of the quiz */ self.calculatePersonality = function () { var max = self.personalities[0].count; var index = 0; self.personalities.forEach(function (personality, i) { if (max < personality.count) { max = personality.count; index = i; } }); return self.personalities[index]; }; /** Updates the progressbar. Moves the background gradient based on the number of questions answered and updates the text with the current question number and the question total. */ self.updateProgress = function () { var percentage = 100 - (self.answered) * self.slidePercentage; var text = interpolate(self.progressText, { 'question': self.answered + 1, 'total': self.numQuestions }); self.$progressbar.css('background-position', String(percentage) + '%'); self.$progressText.html(text); }; /** Moves to the next slide. Toggles visiblity of slides and triggers 'personality-quiz-completed' event upon completion. */ self.next = function () { var answeredAllQuestions = (self.answered === self.numQuestions); var $prev = self.$slides.eq(self.index); var $curr = self.$slides.eq(self.index + 1); $prev.hide(); $curr.show(); self.index = self.index + 1; if ($curr.hasClass(prefix('question'))) { self.updateProgress(self.index); } if (!self.completed && answeredAllQuestions) { self.trigger('personality-quiz-completed'); } }; /** The click event listener used for all buttons associated with an answer to a question in the personality quiz. @param {Object} event */ self.answerListener = function (event) { var $target, $button; var isImage, isButton, buttonListener, personalities; $target = $(event.target); $button = $target; isImage = $target.hasClass(prefix('image-answer-image')); isButton = $target.hasClass(prefix('image-answer-button')); buttonListener = animatedButtonListener; $button = isImage ? $target.siblings().eq(0) : $button; $target = (isButton || isImage) ? $target.parent() : $target; personalities = $target.attr('data-personality'); if (personalities) { buttonListener = animation ? animatedButtonListener : nonAnimatedButtonListener; buttonListener($button, personalities); $target.parent(prefix('answers')).off('click'); } }; /** Zeros out all personality quiz state variables. */ self.reset = function () { self.personalities.map(function (e) { e.count = 0; }); self.index = 0; self.answered = 0; self.completed = false; }; /** Event handler for the personality quiz start event. Makes the progressbar visible and goes to the next slide. */ self.on('personality-quiz-start', function () { self.$progressbar.show(); self.next(); }); /** Event handler for the personality quiz answer event. Counts up all personalities in the answer matching the given personalities. */ self.on('personality-quiz-answer', function (event) { var answers; if (event !== undefined && event.data !== undefined) { answers = event.data.split(', '); answers.forEach(function (answer) { self.personalities.forEach(function (personality) { if (personality.name === answer) { personality.count++; } }); }); self.answered += 1; } self.next(); }); /** Event handler for the personality quiz completed event. Hides the progressbar, since it is no longer needed. Sets the quiz as completed, calculates the personality and sets the result. */ self.on('personality-quiz-completed', function () { var personality = self.calculatePersonality(); self.$progressbar.hide(); self.completed = true; self.setResult(personality); if (animation && self.resultAnimation === 'fade-in') { self.$result.addClass(prefix('fade-in')); } }); /** Event handler for the animation end event for the wheel of fortune animation. Sets a fade-out animation and moves the quiz on to the next slide. */ self.on('wheel-animation-end', function () { setTimeout(function () { self.$canvas.addClass(prefix('fade-out')); }, 500); self.$canvas.on('animationend', self.next); }); /** Event handler for the quiz restart event. Empties the root container for the quiz and rebuilds it. */ self.on('personality-quiz-restart', function () { self.$container.empty(); attach(self.$container); }); } PersonalityQuiz.prototype = Object.create(EventDispatcher); PersonalityQuiz.prototype.constructor = PersonalityQuiz; return PersonalityQuiz; })(H5P.jQuery, H5P.EventDispatcher); ; var H5P = H5P || {}; (function ($, PersonalityQuiz) { /** A wheel of fortune animation. @param {Object} quiz @param {Object} personalities @param {number} width @param {number} height @param {PathFunction} _getPath @constructor */ PersonalityQuiz.WheelAnimation = function (quiz, personalities, width, height, _getPath) { var self = this; // TODO (Emil): Some of these variables should probably be private. // NOTE (Emil): Choose the smallest if the dimensions vary, for simplicity. var side = Math.min(width, height); var min = 320; var max = 800; var hasImages = true; self.width = clamp(side, min, max); self.height = clamp(side, min, max); self.offscreen = document.createElement('canvas'); self.offscreen.width = Math.max(self.width - 25, 500); self.offscreen.height = Math.max(self.height - 25, 500); self.offscreen.context = self.offscreen.getContext('2d'); self.nubArrowSize = self.width * 0.1; self.nubRadius = self.width * 0.06; self.segmentAngle = (Math.PI * 2) / (personalities.length * 2); self.targetRotation = 6 * (Math.PI * 2) + ((3 * Math.PI) / 2) - (self.segmentAngle / 2); self.rotation = 0; self.rotationSpeed = Math.PI / 32; self.center = { x: self.width / 2, y: self.height / 2 }; self.personalities = personalities; self.colors = { even: 'rgb(77, 93, 170)', odd: 'rgb(56, 183, 85)', text: 'rgb(233, 239, 247)', nub: 'rgb(233, 239, 247)', overlay: 'rgba(60, 62, 64, 0.5)', frame: 'rgb(60, 62, 64)' }; self.images = []; self.personalities.forEach(function (personality) { hasImages = (hasImages && personality.image.file); }); // NOTE (Emil): Prerender the wheel. if (hasImages) { load(); } else { // NOTE (Emil): If there aren't any images we use a slightly different colors. self.colors = { even: 'rgb(233, 239, 247)', odd: 'rgb(203, 209, 217)', text: 'rgb(60, 62, 64)', nub: 'rgb(77, 93, 170)', overlay: 'rgba(60, 62, 64, 0.4)', frame: 'rgb(255, 255, 255)' }; drawOffscreen(self.offscreen.context, self.personalities); } /** Clamp a value between low and high @param {number} value @param {number} low @param {number} high @return {number} */ function clamp(value, low, high) { return Math.min(Math.max(low, value), high); } /** Zero out the current rotation and set the initial targetRotation. */ function reset() { self.rotation = 0; self.targetRotation = 6 * (Math.PI * 2) + ((3 * Math.PI) / 2) - (self.segmentAngle / 2); } /** Returns the image pattern associated with the personality, if there is one, or it returns the background color for the segment. @param {Object} personality @param {number} index @return {Object|string} */ function getPattern(personality, index) { if (personality.image.pattern) { return personality.image.pattern; } if (index % 2 === 0) { return self.colors.even; } else { return self.colors.odd; } } /** Load personality images and when all images are loaded draw the wheel. */ function load() { self.loadingImages = []; self.personalities.forEach(function (personality) { var image = new Image(); var deferred = $.Deferred(); image.addEventListener('load', function () { personality.image.pattern = self.offscreen.context.createPattern(this, 'no-repeat'); deferred.resolve(); }); image.src = _getPath(personality.image.file.path); self.images.push({ name: personality.name, data: image }); self.loadingImages.push(deferred); }); // NOTE(Emil): When all the images are loaded we can prerender the offscreen buffer. $.when.apply(null, self.loadingImages).done(function () { drawOffscreen(self.offscreen.context, self.personalities); }); } /** Draw a single segment of the wheel. There are two segments per personality. @param {CanvasRenderingContext2D} context @param {Object} center @param {number} radius @param {number} fromAngle @param {number} toAngle @param {string|CanvasGradient|CanvasPattern} fillStyle */ function drawSegment(context, center, radius, fromAngle, toAngle, fillStyle) { context.beginPath(); context.fillStyle = fillStyle; context.moveTo(center.x, center.y); context.arc(center.x, center.y, radius, fromAngle, toAngle, false); context.lineTo(center.x, center.y); context.fill(); // Fill the segment with the background image. context.stroke(); // Draw the outline of the segment. context.closePath(); } /** Draw the personality name if there is not image associated with the personality. @param {CanvasRenderingContext2D} context @param {string} text @param {number} x @param {number} y @param {number} maxWidth */ function drawText(context, text, x, y, maxWidth) { context.fillStyle = self.colors.text; context.font = '24px Arial'; context.fillText(text, x, y, maxWidth); } /** Draw the prerendered wheel, which is stored in the offscreen canvas. @param {CanvasRenderingContext2D} context @param {number} rotation @param {HTMLElement} canvas */ function drawWheel(context, rotation, canvas) { var scale = 1; context.save(); scale = { x: self.width / self.offscreen.width, y: self.width / self.offscreen.height }; context.translate(self.center.x, self.center.y); context.rotate(rotation); context.scale(scale.x, scale.y); context.translate(-self.offscreen.width / 2, -self.offscreen.height / 2); context.drawImage(canvas, 0, 0); context.restore(); } /** Draw the center arrow which will point to the result personality. @param {CanvasRenderingContext2D} context @param {Object} center @param {number} radius @param {string} color */ function drawNub(context, center, radius, color) { context.fillStyle = color; context.beginPath(); context.moveTo(self.center.x - radius, self.center.y); context.lineTo(self.center.x, self.center.y - self.nubArrowSize); context.lineTo(self.center.x + radius, self.center.y); context.arc(self.center.x, self.center.y, radius, 0, Math.PI * 2, false); context.fill(); context.closePath(); } /** Draw the wheel in the offscreen canvas and save it for later. @param {CanvasRenderingContext2D} context @param {Object[]} personalities - a list of personalities from H5P.PersonalityQuiz */ function drawOffscreen(context, personalities) { var center = { x: self.offscreen.width / 2, y: self.offscreen.height / 2 }; var radius = self.offscreen.width / 2 - 2; // NOTE (Emil): We draw two segments for each personality, // this way the number of segments is always even. var length = personalities.length * 2; var angle = (Math.PI * 2) / length; var halfAngle = angle / 2; var i, personality, pattern, open, offset; context.textBaseline = 'middle'; context.strokeStyle = self.colors.frame; for (i = 0; i < length; i++) { personality = personalities[i % personalities.length]; pattern = getPattern(personality, i); open = i * angle; offset = { x: 0, y: 0 }; if (personality.image.file) { offset.x = personality.image.file.width / 2; offset.y = (personality.image.file.height - radius) / 2; } // NOTE (Emil): Assumes that the center of the image is the most interesting. context.save(); // NOTE (Emil): Center the circle segment over the center of the background image. context.translate(center.x, center.y); context.rotate(open + halfAngle - (Math.PI / 2)); context.translate(-offset.x, -offset.y); drawSegment( context, {x: offset.x, y: offset.y}, radius, (Math.PI / 2) - halfAngle, (Math.PI / 2) + halfAngle, pattern ); context.restore(); // NOTE (Emil): Draw the personality name if there are no images. if (self.images.length < self.personalities.length) { context.save(); context.translate(center.x, center.y); context.rotate(open + halfAngle); context.translate(-center.x, -center.y); drawText( context, personality.name, center.x + (self.nubRadius * 2), center.y, radius - (self.nubRadius * 2.5) ); context.restore(); } } } /** Attach the wheel animation to the provided canvas element. @param {HTMLElement} canvasElement */ self.attach = function (canvasElement) { self.onscreen = canvasElement; self.onscreen.width = self.width; self.onscreen.height = self.height; self.onscreen.context = self.onscreen.getContext('2d'); }; /** Set the target personality and calculates the target rotation. @param {Object} targetPersonality */ self.setTarget = function (targetPersonality) { var deviation, round; reset(); deviation = self.segmentAngle * 0.4; // NOTE (Emil): Randomly choose one of the two segments associated // with the personality. round = Math.floor(Math.random() + 0.5); self.personalities.forEach(function (personality, index) { if (targetPersonality.name === personality.name) { var angle = index * self.segmentAngle + (round * Math.PI); var min = angle + deviation; var max = angle - deviation; var deviated = Math.random() * (max - min) + min; self.targetRotation = self.targetRotation - deviated; return; } }); }; /** Draw the wheel of fortune @param {CanvasRenderingContext2D} context @param {number} rotation @param {HTMLElement} canvas */ self.draw = function (context, rotation, canvas) { context.clearRect(0, 0, self.onscreen.width, self.onscreen.height); drawWheel(context, rotation, canvas); drawNub(context, self.center, self.nubRadius, self.colors.nub); }; /** The main animation loop. Starts the callback loop to requestAnimationFrame. A call to 'setTarget' is required before calling this function. */ self.animate = function () { var end, start; self.rotation = 0; function _animate (timestamp) { var dt, scale, rotation; end = end ? end : timestamp; start = timestamp; dt = (start - end) / 1000; scale = 1 - (self.rotation / self.targetRotation); rotation = Math.max(scale * dt * self.rotationSpeed, 0.01); if (self.rotation < self.targetRotation) { // NOTE (Emil): Always move atleast a little until the targetRotation is achieved. self.rotation += Math.min(rotation, self.targetRotation - self.rotation); self.draw(self.onscreen.context, self.rotation, self.offscreen); window.requestAnimationFrame(_animate); } else { quiz.trigger('wheel-animation-end'); } } window.requestAnimationFrame(_animate); }; }; })(H5P.jQuery, H5P.PersonalityQuiz); ;