UNPKG

@oat-sa/tao-item-runner-qti

Version:
626 lines (569 loc) 22.5 kB
/* * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; under version 2 * of the License (non-upgradable). * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * Copyright (c) 2014-2023 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); * */ /** * @author Bertrand Chevrier <bertrand@taotesting.com> */ import $ from 'jquery'; import _ from 'lodash'; import __ from 'i18n'; import tpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/graphicAssociateInteraction'; import graphic from 'taoQtiItem/qtiCommonRenderer/helpers/Graphic'; import pciResponse from 'taoQtiItem/qtiCommonRenderer/helpers/PciResponse'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; import gstyle from 'taoQtiItem/qtiCommonRenderer/renderers/graphic-style'; const titles = { get hotspotBasic() { return __('Select this area to start an association'); }, get hotspotSelectable() { return __('Select this area to finish an association'); }, get line() { return __('Select line to remove'); }, get closeBtn() { return __('Click to remove'); } }; /** * Get the element that has the active state * @private * @param {Object} interaction * @returns {Raphael.Element} the active element */ const _getActiveElement = function _getActiveElement(interaction) { let active; _.forEach(interaction.getChoices(), function (choice) { const element = interaction.paper.getById(choice.serial); if (element && element.active === true) { active = element; return false; } }); return active; }; const _getImage = function (interaction) { return interaction.paper.getById('bg-image-' + interaction.serial); }; /** * @param {Raphael.Element} element */ const _toggleHotspotAssociatedStyle = function (interaction, element) { let associated = false; const choiceId = element.data('choiceId'); _.forEach(interaction.getChoices(), function (choice) { const otherEl = interaction.paper.getById(choice.serial); const assocs = otherEl.data('assocs'); if (assocs && assocs.length && (otherEl === element || assocs.includes(choiceId))) { associated = true; return false; } }); if (associated) { element.node.setAttribute('data-associated', 'true'); } else { element.node.removeAttribute('data-associated'); } }; /** * Check if a shape can accept matches * @private * @param {Raphael.Element} element - the shape * @returns {Boolean} true if the element is matchable */ const _isMatchable = function (element) { let matchable = false; if (element) { const matchMax = element.data('max') || 0; const matching = element.data('matching') || 0; matchable = matchMax === 0 || matchMax > matching; } return matchable; }; /** * Makes the shapes selectable * @private * @param {Object} interaction * @param {Raphael.Element} active - the active shape */ const _shapesSelectable = function _shapesSelectable(interaction, active) { const assocs = active.data('assocs') || []; const choices = interaction.getChoices(); const activeChoice = choices[active.id]; //update the shape state _.forEach(choices, function (choice) { const element = interaction.paper.getById(choice.serial); if (!assocs.includes(choice.id())) { const assocsElement = element.data('assocs') || []; if (!element.active && element.id !== active.id) { if (_isMatchable(element) && !assocsElement.includes(activeChoice.id())) { element.selectable = true; graphic.updateElementState(element, 'selectable'); element.attr('title', titles.hotspotSelectable); } else { element.attr('title', ''); } } } else { element.attr('title', ''); } }); }; /** * Makes all the shapes UNselectable * @private * @param {Object} interaction */ const _shapesUnSelectable = function _shapesUnSelectable(interaction) { _.forEach(interaction.getChoices(), function (choice) { const element = interaction.paper.getById(choice.serial); if (element) { element.selectable = false; element.active = false; graphic.updateElementState(element, 'basic'); if (_isMatchable(element)) { element.attr('title', titles.hotspotBasic); element.node.setAttribute('data-matchable', 'true'); } else { element.attr('title', ''); element.node.removeAttribute('data-matchable'); } } }); }; /** * Create a path from a src element to a destination. * The path is selectable and can be removed by itself * @private * @param {Object} interaction * @param {Raphael.Element} srcElement - the path starts from this shape * @param {Raphael.Element} destElement - the path ends to this shape * @param {Function} onRemove - called back on path remove */ const _createPath = function _createPath(interaction, srcElement, destElement, onRemove) { const $container = containerHelper.get(interaction); const paper = interaction.paper; //get the middle point of the source shape const src = srcElement.getBBox(); const sx = src.x + src.width / 2; const sy = src.y + src.height / 2; //get the middle point of the source shape const dest = destElement.getBBox(); const dx = dest.x + dest.width / 2; const dy = dest.y + dest.height / 2; const pathStr = 'M' + sx + ',' + sy + 'L' + dx + ',' + dy; const pathStartStr = 'M' + sx + ',' + sy + 'L' + sx + ',' + sy; const lineGroup = paper.group({ class: 'assoc-line' }).attr('title', titles.line).click(onLineClick); const lineShadow = paper.path(pathStartStr).animate({ path: pathStr }, 200).attr({ class: 'assoc-line-shadow' }); const lineOuter = paper.path(pathStartStr).animate({ path: pathStr }, 200).attr({ class: 'assoc-line-outer' }); const lineInner = paper.path(pathStartStr).animate({ path: pathStr }, 200).attr({ class: 'assoc-line-inner' }); const lineHitbox = paper.path(pathStr).attr({ class: 'assoc-line-hitbox' }); lineGroup.appendChild(lineShadow); lineGroup.appendChild(lineOuter); lineGroup.appendChild(lineInner); lineGroup.appendChild(lineHitbox); //get the middle of the path const midPath = lineHitbox.getPointAtLength(lineHitbox.getTotalLength() / 2); const closerRad = 14; const closerHitboxRad = 20; const closerPathHalfSize = 8; const closerPathScale = 0.8; const closerHitbox = paper.circle(midPath.x, midPath.y, closerHitboxRad).attr({ class: 'close-btn-hitbox' }); const closerBg = paper.circle(midPath.x, midPath.y, closerRad).attr({ class: 'close-btn-bg' }); const closerPath = paper .path(gstyle.close.path) .attr({ class: 'close-btn-path' }) .transform(`t${midPath.x - closerPathHalfSize},${midPath.y - closerPathHalfSize}s${closerPathScale}`); const closerGroup = paper .group({ class: 'close-btn' }) .attr('title', titles.closeBtn) .click(function () { removeLine(onRemove); }); closerGroup.appendChild(closerHitbox); closerGroup.appendChild(closerBg); closerGroup.appendChild(closerPath); _toggleHotspotAssociatedStyle(interaction, srcElement); _toggleHotspotAssociatedStyle(interaction, destElement); $container.on(`unselect.graphicassociate.${lineGroup.id}`, unselectLine); $container.on(`resetresponse.graphicassociate.${lineGroup.id}`, removeLine); function onLineClick() { $container.trigger('unselect-active-hotspot.graphicassociate'); if (lineGroup.node.classList.contains('selected')) { unselectLine(); } else { selectLine(); } } function createGlassLayer() { const glassLayer = paper .rect(0, 0, paper.w, paper.h) .attr('class', 'glass-layer') .click(function () { _shapesUnSelectable(interaction); $container.trigger('unselect.graphicassociate'); }); glassLayer.id = 'glassLayer'; } function removeGlassLayer() { const glassLayer = paper.getById('glassLayer'); if (glassLayer) { glassLayer.remove(); } } function selectLine() { createGlassLayer(); lineGroup.node.classList.add('selected'); [srcElement, destElement].forEach(raphEl => { raphEl.node.setAttribute('data-for-selected-line', 'true'); }); lineGroup.data({ prevSibling: lineGroup.node.previousElementSibling }); //not 'raphEl.toFront()' to not mess with el.prev/el.next/paper.top/paper.bottom' lineGroup.node.parentElement.insertBefore(lineGroup.node, null); lineGroup.node.after(closerGroup.node); lineGroup.attr('title', ''); } function unselectLine() { lineGroup.node.classList.remove('selected'); [srcElement, destElement].forEach(raphEl => { raphEl.node.removeAttribute('data-for-selected-line'); }); if (lineGroup.data('prevSibling')) { lineGroup.data('prevSibling').after(lineGroup.node); lineGroup.node.after(closerGroup.node); } lineGroup.removeData('prevSibling'); lineGroup.attr('title', titles.line); removeGlassLayer(); } function removeLine(removeCallback) { $container.off(`unselect.graphicassociate.${lineGroup.id}`); $container.off(`resetresponse.graphicassociate.${lineGroup.id}`); closerGroup.remove(); lineGroup.remove(); removeGlassLayer(); if (typeof removeCallback === 'function') { removeCallback(); } [srcElement, destElement].forEach(raphEl => { _toggleHotspotAssociatedStyle(interaction, raphEl); raphEl.node.removeAttribute('data-for-selected-line'); }); _shapesUnSelectable(interaction); } }; /** * Get the response from the interaction * @private * @param {Object} interaction * @returns {Array} the response in raw format */ const _getRawResponse = function _getRawResponse(interaction) { let responses = []; _.forEach(interaction.getChoices(), function (choice) { const element = interaction.paper.getById(choice.serial); const assocs = element.data('assocs'); if (element && assocs) { responses = responses.concat( _.map(assocs, function (id) { return [choice.id(), id]; }) ); } }); return responses; }; /** * By clicking the paper image the shapes are restored to their default state * @private * @param {Object} interaction */ const _paperUnSelect = function _paperUnSelect(interaction) { const $container = containerHelper.get(interaction); const image = _getImage(interaction); if (image) { image.click(function () { _shapesUnSelectable(interaction); $container.trigger('unselect.graphicassociate'); }); } }; /** * Render a choice inside the paper. * Please note that the choice renderer isn't implemented separately because it relies on the Raphael paper instead of the DOM. * @param {Paper} paper - the raphael paper to add the choices to * @param {Object} interaction * @param {Object} choice - the hotspot choice to add to the interaction */ const _renderChoice = function _renderChoice(interaction, choice) { const shape = choice.attr('shape'); const coords = choice.attr('coords'); const maxAssociations = interaction.attr('maxAssociations'); graphic .createElement2(interaction.paper, shape, coords, { id: choice.serial }) .data('choiceId', choice.id()) //same as used in 'assocs' data .data('max', choice.attr('matchMax')) .data('matching', 0) .removeData('assocs') .click(function () { //can't create more associations than the maxAssociations attr if (maxAssociations > 0 && _getRawResponse(interaction).length >= maxAssociations) { _shapesUnSelectable(interaction); instructionMgr.validateInstructions(interaction, { choice: choice, target: this }); return; } const active = _getActiveElement(interaction); if (this.selectable) { if (active) { //increment the matching counter active.data('matching', active.data('matching') + 1); this.data('matching', this.data('matching') + 1); //attach the response to the active (not the dest) const assocs = active.data('assocs') || []; assocs.push(choice.id()); active.data('assocs', assocs); //and create the path _createPath(interaction, active, this, () => { //decrement the matching counter active.data('matching', active.data('matching') - 1); this.data('matching', this.data('matching') - 1); //detach the response from the active active.data('assocs', _.pull(active.data('assocs') || [], choice.id())); containerHelper.triggerResponseChangeEvent(interaction); instructionMgr.validateInstructions(interaction, { choice: choice, target: this }); }); } _shapesUnSelectable(interaction); } else if (this.active) { _shapesUnSelectable(interaction); } else if (_isMatchable(this)) { if (active) { _shapesUnSelectable(interaction); } graphic.updateElementState(this, 'active'); this.attr('title', ''); this.active = true; _shapesSelectable(interaction, this); } containerHelper.triggerResponseChangeEvent(interaction); instructionMgr.validateInstructions(interaction, { choice: choice, target: this }); }); }; /** * Init rendering, called after template injected into the DOM * All options are listed in the QTI v2.1 information model: * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 * * @param {object} interaction * @returns {Promise} */ const render = function render(interaction) { const self = this; return new Promise(function (resolve) { const $container = containerHelper.get(interaction); const background = interaction.object.attributes; $container.off('resized.qti-widget.resolve').one('resized.qti-widget.resolve', resolve); interaction.paper = graphic.responsivePaper('graphic-paper-' + interaction.serial, interaction.serial, { width: background.width, height: background.height, img: self.resolveUrl(background.data), imgId: 'bg-image-' + interaction.serial, container: $container, responsive: $container.hasClass('responsive') }); //call render choice for each interaction's choices _.forEach(interaction.getChoices(), _.partial(_renderChoice, interaction)); _shapesUnSelectable(interaction); //make the paper clear the selection by clicking it _paperUnSelect(interaction); $container.on('unselect-active-hotspot.graphicassociate', function () { _shapesUnSelectable(interaction); }); //set up the constraints instructions instructionMgr.minMaxChoiceInstructions(interaction, { min: interaction.attr('minAssociations'), max: interaction.attr('maxAssociations'), getResponse: _getRawResponse, onError: function (data) { if (data && data.target) { graphic.highlightError(data.target); } } }); }); }; /** * Set the response to the rendered interaction. * * The response format follows the IMS PCI recommendation : * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 * * Available base types are defined in the QTI v2.1 information model: * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 * * Special value: the empty object value {} resets the interaction responses * * @param {object} interaction * @param {object} response */ const setResponse = function (interaction, response) { let responseValues; if (response && interaction.paper) { try { responseValues = pciResponse.unserialize(response, interaction); if (responseValues.length === 2 && !Array.isArray(responseValues[0]) && !Array.isArray(responseValues[1])) { responseValues = [responseValues]; } } catch (e) { console.error(e); } if (_.isArray(responseValues)) { //create an object with choiceId => shapeElement const map = _.transform(interaction.getChoices(), function (res, choice) { res[choice.id()] = interaction.paper.getById(choice.serial); }); _.forEach(responseValues, function (responseValue) { if (_.isArray(responseValue) && responseValue.length === 2) { const el1 = map[responseValue[0]]; const el2 = map[responseValue[1]]; if (el1 && el2) { graphic.trigger(el1, 'click'); graphic.trigger(el2, 'click'); } } }); } } }; /** * Reset the current responses of the rendered interaction. * * The response format follows the IMS PCI recommendation : * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 * * Available base types are defined in the QTI v2.1 information model: * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 * * Special value: the empty object value {} resets the interaction responses * * @param {object} interaction * @param {object} response */ const resetResponse = function resetResponse(interaction) { const toRemove = []; //reset response and state bound to shapes _.forEach(interaction.getChoices(), function (choice) { const element = interaction.paper.getById(choice.serial); if (element) { element.data({ max: choice.attr('matchMax'), matching: 0, assocs: [] }); } }); if (interaction && interaction.paper) { const $container = containerHelper.get(interaction); $container.trigger('resetresponse.graphicassociate'); } toRemove.forEach(el => el.remove()); }; /** * Return the response of the rendered interaction * * The response format follows the IMS PCI recommendation : * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 * * Available base types are defined in the QTI v2.1 information model: * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10321 * * @param {object} interaction * @returns {object} */ const getResponse = function (interaction) { const raw = _getRawResponse(interaction); const response = pciResponse.serialize(raw, interaction); return response; }; /** * Clean interaction destroy * @param {Object} interaction */ const destroy = function destroy(interaction) { if (interaction.paper) { const $container = containerHelper.get(interaction); $(window).off('resize.qti-widget.' + interaction.serial); $container.off('resize.qti-widget.' + interaction.serial); interaction.paper.clear(); instructionMgr.removeInstructions(interaction); $container.off('.graphicassociate'); $('.main-image-box', $container).empty().removeAttr('style'); $('.image-editor', $container).removeAttr('style'); $('ul', $container).empty(); } //remove all references to a cache container containerHelper.reset(interaction); }; /** * Set the interaction state. It could be done anytime with any state. * * @param {Object} interaction - the interaction instance * @param {Object} state - the interaction state */ const setState = function setState(interaction, state) { if (_.isObject(state)) { if (state.response) { interaction.resetResponse(); interaction.setResponse(state.response); } } }; /** * Get the interaction state. * * @param {Object} interaction - the interaction instance * @returns {Object} the interaction current state */ const getState = function getState(interaction) { const state = {}; const response = interaction.getResponse(); if (response) { state.response = response; } return state; }; /** * Expose the common renderer for the hotspot interaction * @exports qtiCommonRenderer/renderers/interactions/GraphicAssociateInteraction */ export default { qtiClass: 'graphicAssociateInteraction', template: tpl, render: render, getContainer: containerHelper.get, setResponse: setResponse, getResponse: getResponse, resetResponse: resetResponse, destroy: destroy, setState: setState, getState: getState };