UNPKG

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

Version:
1,136 lines (1,001 loc) 43.8 kB
define(['jquery', 'lodash', 'raphael', 'scale.raphael'], function ($, _, raphael, scaleRaphael) { 'use strict'; $ = $ && Object.prototype.hasOwnProperty.call($, 'default') ? $['default'] : $; _ = _ && Object.prototype.hasOwnProperty.call(_, 'default') ? _['default'] : _; raphael = raphael && Object.prototype.hasOwnProperty.call(raphael, 'default') ? raphael['default'] : raphael; scaleRaphael = scaleRaphael && Object.prototype.hasOwnProperty.call(scaleRaphael, 'default') ? scaleRaphael['default'] : scaleRaphael; var basic = { stroke: "#8D949E", "stroke-width": 2, "stroke-dasharray": "", "stroke-linejoin": "round", fill: "#cccccc", "fill-opacity": 0.5, cursor: "pointer" }; var hover = { stroke: "#3E7DA7", fill: "#0E5D91", "fill-opacity": 0.3 }; var selectable = { "stroke-dasharray": "-", stroke: "#3E7DA7", fill: "#cccccc", "fill-opacity": 0.5 }; var active = { stroke: "#3E7DA7", "stroke-dasharray": "", fill: "#0E5D91", "fill-opacity": 0.5 }; var error = { stroke: "#C74155", "stroke-dasharray": "", "fill-opacity": 0.5, fill: "#661728" }; var success = { stroke: "#C74155", "stroke-dasharray": "", fill: "#0E914B", "fill-opacity": 0.5 }; var layer = { fill: "#ffffff", opacity: 0, cursor: "pointer" }; var creator = { "fill-opacity": 0.5, stroke: "#3E7DA7", "stroke-dasharray": "", fill: "#0E5D91", cursor: "pointer" }; var target = { path: "m 18,8.4143672 -1.882582,0 C 15.801891,4.9747852 13.071059,2.2344961 9.63508,1.9026738 L 9.63508,0 8.2305176,0 l 0,1.9026387 C 4.7947148,2.2343027 2.0637246,4.9746621 1.7481973,8.4143672 l -1.7481973,0 0,1.4045625 1.754877,0 c 0.3460429,3.4066753 3.0632871,6.1119843 6.4756406,6.4413813 l 0,1.739689 1.4045624,0 0,-1.739725 c 3.412547,-0.329537 6.129633,-3.034793 6.475641,-6.4413453 l 1.889279,0 z m -8.36492,6.5188648 0,-4.064673 -1.4045624,0 0,4.063882 C 5.5511016,14.612555 3.4232695,12.494619 3.0864551,9.8189297 l 4.0449512,0 0,-1.4045625 -4.0546368,0 C 3.3788672,5.6984941 5.5228887,3.5393379 8.2305176,3.2161113 l 0,3.9153125 1.4045624,0 0,-3.9160859 c 2.711162,0.3203965 4.858576,2.4808887 5.160955,5.1990293 l -3.927441,0 0,1.4045625 3.917773,0 C 14.449289,12.496957 12.318363,14.616158 9.63508,14.933232 z", fill: "#0E914B", width: 1, "stroke-width": 0, cursor: "pointer" }; var close = { path: "M9.71875 8.20312L13.5938 12.0625C13.6875 12.1562 13.7344 12.2708 13.7344 12.4062C13.7344 12.5312 13.6875 12.6354 13.5938 12.7188L12.5625 13.75C12.4688 13.8438 12.3594 13.8906 12.2344 13.8906C12.1094 13.8906 12 13.8438 11.9062 13.75L8.03125 9.875L4.17188 13.75C4.07812 13.8438 3.96354 13.8906 3.82812 13.8906C3.70312 13.8906 3.59896 13.8438 3.51562 13.75L2.48438 12.7188C2.39062 12.6354 2.34375 12.5312 2.34375 12.4062C2.34375 12.2708 2.39062 12.1562 2.48438 12.0625L6.35938 8.20312L2.48438 4.32812C2.39062 4.23438 2.34375 4.125 2.34375 4C2.34375 3.86458 2.39062 3.75521 2.48438 3.67188L3.51562 2.64062C3.59896 2.54688 3.70312 2.5 3.82812 2.5C3.96354 2.5 4.07812 2.54688 4.17188 2.64062L8.03125 6.51562L11.9062 2.64062C12 2.54688 12.1094 2.5 12.2344 2.5C12.3594 2.5 12.4688 2.54688 12.5625 2.64062L13.5938 3.67188C13.6875 3.75521 13.7344 3.86458 13.7344 4C13.7344 4.125 13.6875 4.23438 13.5938 4.32812L9.71875 8.20312Z" }; var gstyle = { basic: basic, hover: hover, selectable: selectable, active: active, error: error, success: success, layer: layer, creator: creator, "imageset-rect-stroke": { fill: "#ffffff", stroke: "#666666", "stroke-width": 1, "stroke-linejoin": "round", cursor: "pointer" }, "imageset-rect-no-stroke": { fill: "#ffffff", stroke: "#ffffff", "stroke-width": 2, "stroke-linejoin": "round", cursor: "pointer" }, "imageset-img": { cursor: "pointer" }, "order-text": { fill: "#ffffff", stroke: "#000000", "stroke-width": 0.7, "font-family": "sans-serif", "font-weight": "bold", "font-size": 22, cursor: "pointer" }, "score-text-default": { stroke: "#444444", "stroke-width": 0.5, "font-family": "sans-serif", "font-weight": "normal", "font-size": 20, cursor: "pointer" }, "score-text": { stroke: "#000000", "stroke-width": 0.5, "font-family": "sans-serif", "font-weight": "normal", "font-size": 20, cursor: "pointer" }, "small-text": { stroke: "#000000", "stroke-width": 0.5, "font-family": "sans-serif", "font-weight": "normal", "font-size": 16, cursor: "pointer" }, "layer-pos-text": { stroke: "#333", "stroke-width": 0.5, "font-family": "sans-serif", "font-weight": "normal", "font-size": 14 }, target: target, "target-hover": { fill: "#3E7DA7", "fill-opacity": 1 }, "target-success": { fill: "#0E914B", "fill-opacity": 1 }, close: close, "touch-circle": { fill: "none", stroke: "#3E7DA7", "stroke-width": 2 } }; /** * 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) 2015-2023 (original work) Open Assessment Technologies SA ; */ const ELEMENT_CLASS_NAME = 'hotspot'; const ELEMENT_CLASS_NAME2 = 'hotspot2'; //maps the QTI shapes to Raphael shapes const shapeMap = { default: 'rect', poly: 'path' }; //length constraints to validate coords const coordsValidator = { rect: 4, ellipse: 4, circle: 3, poly: 6, default: 0 }; raphael.fn.elFromTagName = function (tagName, attrs, raphType) { const node = document.createElementNS('http://www.w3.org/2000/svg', tagName); const paper = this; // eslint-disable-line consistent-this paper.canvas && paper.canvas.appendChild(node); const raphEl = new raphael.el.constructor(node, paper); raphEl.type = raphType || tagName; //to get bbox(); maybe smth else if (attrs && Object.keys(attrs).length) { raphEl.attr(attrs); } return raphEl; }; /** * NB! Please create child elements *before* group, or el.next/el.prev and paper.top/paper.bottom might get messed up * @param {*} attrs * @returns */ raphael.fn.group = function (attrs) { const raphEl = this.elFromTagName('g', attrs, 'set'); const paper = this; // eslint-disable-line consistent-this raphEl.childrenSet = paper.set(); const originalRemove = raphEl.remove; raphEl.remove = function () { this.childrenSet.forEach(childRaphEl => childRaphEl.remove()); this.childrenSet.clear(); originalRemove.apply(this); }; return raphEl; }; raphael.el.appendChild = function (childRaphEl) { this.node.appendChild(childRaphEl.node); if (this.childrenSet) { this.childrenSet.push(childRaphEl); } }; //transform the coords from the QTI system to Raphael system const qti2raphCoordsMapper = { /** * Rectangle coordinate mapper: from left-x,top-y,right-x-bottom-y to x,y,w,h * @param {Array} coords - QTI coords * @returns {Array} raphael coords */ rect: function (coords) { return [coords[0], coords[1], coords[2] - coords[0], coords[3] - coords[1]]; }, /** * Creates the coords for a default shape (a rectangle that covers all the paper) * @param {Raphael.Paper} paper - the paper * @returns {Array} raphael coords */ default: function (paper) { return [0, 0, paper.width, paper.height]; }, /** * polygone coordinate mapper: from x1,y1,...,xn,yn to SVG path format * @param {Array} coords - QTI coords * @returns {Array} path desc */ poly: function (coords) { let a; const size = coords.length; // autoClose if needed if (coords[0] !== coords[size - 2] && coords[1] !== coords[size - 1]) { coords.push(coords[0]); coords.push(coords[1]); } // move to first point coords[0] = 'M' + coords[0]; for (a = 1; a < size; a++) { if (a % 2 === 0) { coords[a] = 'L' + coords[a]; } } return [coords.join(' ')]; } }; //transform the coords from a raphael shape to the QTI system const raph2qtiCoordsMapper = { /** * Rectangle coordinate mapper: from x,y,w,h to left-x,top-y,right-x-bottom-y * @param {Object} attr - Raphael Element's attributes * @returns {Array} qti based coords */ rect: function (attr) { return [attr.x, attr.y, attr.x + attr.width, attr.y + attr.height]; }, /** * Circle coordinate mapper * @param {Object} attr - Raphael Element's attributes * @returns {Array} qti based coords */ circle: function (attr) { return [attr.cx, attr.cy, attr.r]; }, /** * Ellispe coordinate mapper * @param {Object} attr - Raphael Element's attributes * @returns {Array} qti based coords */ ellipse: function (attr) { return [attr.cx, attr.cy, attr.rx, attr.ry]; }, /** * Get the coords for a default shape (a rectangle that covers all the paper) * @param {Object} attr - Raphael Element's attributes * @returns {Array} qti based coords */ default: function (attr) { return this.rect(attr); }, /** * polygone coordinate mapper: from SVG path (available as segments) to x1,y1,...,xn,yn format * @param {Raphael.Paper} paper - the paper * @returns {Array} raphael coords */ path: function (attr) { const poly = []; let i; if (_.isArray(attr.path)) { for (i = 1; i < attr.path.length; i++) { if (attr.path[i].length === 3) { poly.push(attr.path[i][1]); poly.push(attr.path[i][2]); } } } return poly; } }; const clipPathSetter = { // raphael coords rect: function (raphEl) { raphEl.node.style.clipPath = `fill-box`; }, circle: function (raphEl, coords) { raphEl.node.style.clipPath = `circle(${coords[2]}px)`; }, ellipse: function (raphEl, coords) { raphEl.node.style.clipPath = `ellipse(${coords[2]}px ${coords[3]}px)`; }, default: function (raphEl, coords) { return this.rect(raphEl, coords); }, poly: function (raphEl, coords, clipPathDefId) { raphEl.node.setAttribute('clip-path', `url(#${clipPathDefId})`); } }; const clipPathDefSetter = { // raphael coords poly: function (paper, coords) { return prepareClipPathDef(paper, `<path d="${coords[0]}" />`); } }; function prepareClipPathDef(paper, clipPathHtml) { const defsElId = 'defs-el'; let defsEl = paper.getById(defsElId); if (!defsEl) { defsEl = paper.elFromTagName('defs'); defsEl.id = defsElId; defsEl.toBack(); } const id = `clip-${defsEl.node.children.length}`; const clipPathEl = paper.elFromTagName('clipPath'); clipPathEl.node.innerHTML = clipPathHtml; clipPathEl.node.id = id; defsEl.appendChild(clipPathEl); return id; } function getSelectableStateTransform(raphEl) { const diff = 8 / raphEl.paper.scale; const bbox = raphEl.getBBox(); const scaleX = (bbox.width + diff) / bbox.width; const scaleY = (bbox.height + diff) / bbox.height; return Math.min(scaleX, scaleY); } function removeDefaultStyle(raphEl) { const node = raphEl.node; node.removeAttribute('stroke'); node.removeAttribute('stroke-width'); node.removeAttribute('fill'); node.removeAttribute('stroke-dasharray'); } /** * Graphic interaction helper * @exports qtiCommonRenderer/helpers/Graphic */ const GraphicHelper = { /** * Raw access to the styles * @type {Object} */ _style: gstyle, /** * Apply the style defined by name to the element * @param {Raphael.Element} element - the element to change the state * @param {String} state - the name of the state (from states) to switch to */ setStyle: function (element, name) { if (element && gstyle[name]) { element.attr(gstyle[name]); } }, /** * Create a Raphael paper with a bg image, that is width responsive * @param {String} id - the id of the DOM element that will contain the paper * @param {String} serial - the interaction unique indentifier * @param {Object} options - the paper parameters * @param {String} options.img - the url of the background image * @param {jQueryElement} [options.container] - the parent of the paper element (got the closest parent by default) * @param {Boolean} [options.responsive] - scale to container * @param {Number} [options.width] - the paper width * @param {Number} [options.height] - the paper height * @param {String} [options.imgId] - an identifier for the image element * @param {Function} [options.done] - executed once the image is loaded * @returns {Raphael.Paper} the paper */ responsivePaper: function (id, serial, options) { const $container = options.container || $('#' + id).parent(); const $editor = $('.image-editor', $container); const $body = $container.closest('.qti-itemBody'); const resizer = _.throttle(resizePaper, 10); const imgWidth = options.width || $container.innerWidth(); const imgHeight = options.height || $container.innerHeight(); const paper = scaleRaphael(id, imgWidth, imgHeight); const image = paper.image(options.img, 0, 0, imgWidth, imgHeight); image.id = options.imgId || image.id; paper.setViewBox(0, 0, imgWidth, imgHeight); paper.scale = 1; resizer(); //retry to resize once the SVG is loaded $(image.node).attr('externalResourcesRequired', 'true').on('load', resizer); if (raphael.type === 'SVG') { // TODO: move listeners somewhere they can be easily turned off $(window).on('resize.qti-widget.' + serial, resizer); // TODO: favor window resize event and deprecate $container resive event (or don't allow $container to be destroyed and rebuilt $container.on('resize.qti-widget.' + serial, resizer); $(document).on('customcssloaded.styleeditor', resizer); } else { $container.find('.main-image-box').width(imgWidth); if (typeof options.resize === 'function') { options.resize(imgWidth, 1); } } /** * scale the raphael paper * @private */ function resizePaper(e, givenWidth) { let containerWidth; if (e) { e.stopPropagation(); } const diff = $editor.outerWidth() - $editor.width() + ($container.outerWidth() - $container.width()) + 1; const maxWidth = $body.width(); if (options.responsive) { containerWidth = $container.innerWidth(); } else { containerWidth = $editor.innerWidth(); } if ((options.responsive && containerWidth > 0) || givenWidth > 0 || containerWidth > maxWidth) { if (options.responsive) { if (givenWidth < containerWidth && givenWidth < maxWidth) { containerWidth = givenWidth - diff; } else if (containerWidth > maxWidth) { containerWidth = maxWidth - diff; } else { containerWidth -= diff; } } else { if (givenWidth > 0 && givenWidth < maxWidth) { containerWidth = givenWidth; } else if (containerWidth > maxWidth) { containerWidth = maxWidth; } } const factor = containerWidth / imgWidth; const containerHeight = imgHeight * factor; if (containerWidth > 0) { paper.changeSize(containerWidth, containerHeight, false, false); paper.scale = paper.width / paper.canvas.viewBox.baseVal.width; paper.canvas.style.setProperty('--paper-scale', paper.scale); } if (typeof options.resize === 'function') { options.resize(containerWidth, factor, containerHeight); } } $container.trigger('resized.qti-widget'); } return paper; }, /** * Create a new Element into a raphael paper * @param {Raphael.Paper} paper - the interaction paper * @param {String} type - the shape type * @param {String|Array.<Number>} coords - qti coords as a string or an array of number * @param {Object} [options] - additional creation options * @param {String} [options.id] - to set the new element id * @param {String} [options.title] - to set the new element title * @param {String} [options.style = basic] - to default style * @param {Boolean} [options.hover = true] - to disable the default hover state * @param {Boolean} [options.touchEffect = true] - a circle appears on touch * @param {Boolean} [options.qtiCoords = true] - if the coords are in QTI format * @param {Boolean} [options.useCssClass = true] - use css class instead of `gstyle` * @param {Boolean} [options.useClipPath = false] - clip element so that stroke doesn't go outside element size * @returns {Raphael.Element} the created element */ createElement: function (paper, type, coords, options) { const self = this; let element; const shaper = shapeMap[type] ? paper[shapeMap[type]] : paper[type]; const shapeCoords = options.qtiCoords !== false ? self.raphaelCoords(paper, type, coords) : coords; if (typeof shaper === 'function') { let clipPathDefId; if (options.useClipPath && clipPathDefSetter[type]) { clipPathDefId = clipPathDefSetter[type](paper, shapeCoords); } element = shaper.apply(paper, shapeCoords); if (element) { if (options.id) { element.id = options.id; } if (options.title) { element.attr('title', options.title); } const styleName = options.style || 'basic'; if (options.useCssClass) { element.attr({ class: `${ELEMENT_CLASS_NAME} ${styleName}` }); } else { element.attr(gstyle[styleName]); } element.toFront(); //prevent issue in firefox 37 $(element.node).removeAttr('stroke-dasharray'); if (options.hover !== false) { element.hover( function () { if (!element.flashing) { self.updateElementState(this, 'hover'); } }, function () { if (!element.flashing) { self.updateElementState( this, this.active ? 'active' : this.selectable ? 'selectable' : 'basic' ); } } ); } if (options.touchEffect !== false) { element.touchstart(function () { self.createTouchCircle(paper, element.getBBox()); }); } if (options.useClipPath) { clipPathSetter[type](element, shapeCoords, clipPathDefId); } } } else { throw new Error('Unable to find method ' + type + ' on paper'); } return element; }, /** * Create a new Element with another design (double border - black and white) * @param {Raphael.Paper} paper - the interaction paper * @param {String} type - the shape type * @param {String|Array.<Number>} coords - qti coords as a string or an array of number * @param {Object} [options] - additional creation options * @param {String} [options.id] - to set the new element id * @param {String} [options.style = basic] - to default style * @param {Boolean} [options.qtiCoords = true] - if the coords are in QTI format * @returns {Raphael.Element} the created element */ createElement2: function (paper, type, coords, options) { const self = this; let groupEl; const shaper = shapeMap[type] ? paper[shapeMap[type]] : paper[type]; const shapeCoords = options.qtiCoords !== false ? self.raphaelCoords(paper, type, coords) : coords; const stateCls = options.style || 'basic'; if (typeof shaper === 'function') { try { let clipPathDefId; if (clipPathDefSetter[type]) { clipPathDefId = clipPathDefSetter[type](paper, shapeCoords); } groupEl = paper.group({ class: `${ELEMENT_CLASS_NAME2} ${stateCls}` }); if (options.id) { groupEl.id = options.id; } const shadowEl = shaper.apply(paper, shapeCoords).attr({ class: 'hotspot2-shadow' }); removeDefaultStyle(shadowEl); groupEl.appendChild(shadowEl); const innerEl = shaper.apply(paper, shapeCoords).attr({ class: 'hotspot2-inner' }); removeDefaultStyle(innerEl); groupEl.appendChild(innerEl); clipPathSetter[type](innerEl, shapeCoords, clipPathDefId); const outerEl = shaper.apply(paper, shapeCoords).attr({ class: 'hotspot2-outer' }); removeDefaultStyle(outerEl); groupEl.appendChild(outerEl); clipPathSetter[type](outerEl, shapeCoords, clipPathDefId); } catch (err) { console.error(err); throw err; } } else { throw new Error('Unable to find method ' + type + ' on paper'); } return groupEl; }, /** * Create target point * @param {Raphael.Paper} paper - the paper * @param {Object} [options] * @param {Object} [options.id] - and id to identify the target * @param {Object} [options.point] - the point to add to the paper * @param {Number} [options.point.x = 0] - point's x coord * @param {Number} [options.point.y = 0] - point's y coord * @param {Boolean} [options.hover] = true - the target has an hover effect * @param {Function} [options.create] - call once created * @param {Function} [options.remove] - call once removed */ createTarget: function createTarget(paper, options) { options = options || {}; const point = options.point || { x: 0, y: 0 }; const factor = paper.w !== 0 ? paper.width / paper.w : 1; const hover = typeof options.hover === 'undefined' ? true : !!options.hover; const baseSize = 18; // this is the base size of the path element to be placed on svg (i.e. the path element crosshair is created to have a size of 18) const half = baseSize / 2; const x = point.x - half; const y = point.y - half; const targetSize = factor !== 0 ? 2 / factor : 2; //create the target from a path const target = paper .path(gstyle.target.path) .transform('t' + x + ',' + y + 's' + targetSize) .attr(gstyle.target) .attr('title', _('Click again to remove')); //generate an id if not set in options if (options.id) { target.id = options.id; } else { let count = 0; paper.forEach(function (element) { if (element.data('target')) { count++; } }); target.id = 'target-' + count; } const tBBox = target.getBBox(); //create an invisible rect over the target to ensure path selection const layer = paper .rect(tBBox.x, tBBox.y, tBBox.width, tBBox.height) .attr(gstyle.layer) .click(function () { const id = target.id; const p = this.data('point'); if (_.isFunction(options.select)) { options.select(target, p, this); } if (_.isFunction(options.remove)) { this.remove(); target.remove(); options.remove(id, p); } }); if (hover) { layer.hover( () => { if (!target.flashing) { this.setStyle(target, 'target-hover'); } }, () => { if (!target.flashing) { this.setStyle(target, 'target-success'); } } ); } layer.id = 'layer-' + target.id; layer.data('point', point); target.data('target', point); if (_.isFunction(options.create)) { options.create(target); } return target; }, /** * Get the Raphael coordinate from QTI coordinate * @param {Raphael.Paper} paper - the interaction paper * @param {String} type - the shape type * @param {String|Array.<Number>} coords - qti coords as a string or an array of number * @returns {Array} the arguments array of coordinate to give to the approriate raphael shapre creator */ raphaelCoords: function raphaelCoords(paper, type, coords) { let shapeCoords; if (_.isString(coords)) { coords = _.map(coords.split(','), function (coord) { return parseInt(coord, 10); }); } if (!_.isArray(coords) || coords.length < coordsValidator[type]) { throw new Error('Invalid coords ' + JSON.stringify(coords) + ' for type ' + type); } switch (type) { case 'rect': shapeCoords = qti2raphCoordsMapper.rect(coords); break; case 'default': shapeCoords = qti2raphCoordsMapper['default'].call(null, paper); break; case 'poly': shapeCoords = qti2raphCoordsMapper.poly(coords); break; default: shapeCoords = coords; break; } return shapeCoords; }, /** * Get the QTI coordinates from a Raphael Element * @param {Raphael.Element} element - the shape to get the coords from * @param {Raphael.Element} paper - the interaction paper * @param {number} width - width of background image * @returns {String} the QTI coords */ qtiCoords: function qtiCoords(element, paper, width) { const mapper = raph2qtiCoordsMapper[element.type]; let result = ''; const factor = paper && width ? width / paper.w : 1; if (_.isFunction(mapper)) { result = _.map(mapper.call(raph2qtiCoordsMapper, element.attr()), function (coord) { return Math.round(coord * factor); }).join(','); } return result; }, /** * Create a circle that animate and disapear from a shape. * * @param {Raphael.Paper} paper - the paper * @param {Raphael.Element} element - used to get the bbox from */ createTouchCircle: function (paper, bbox) { const radius = bbox.width > bbox.height ? bbox.width : bbox.height; const tCircle = paper.circle(bbox.x + bbox.width / 2, bbox.y + bbox.height / 2, radius); tCircle.attr(gstyle['touch-circle']); _.defer(function () { tCircle.animate({ r: radius + 5, opacity: 0.7 }, 300, function () { tCircle.remove(); }); }); }, /** * Create a text, that scales. * * @param {Raphael.Paper} paper - the paper * @param {Object} options - the text options * @param {Number} options.left - x coord * @param {Number} options.top - y coord * @param {String} [options.content] - the text content * @param {String} [options.id] - the element identifier * @param {String} [options.style = 'small-text'] - the style name according to the graphic-style.json keys * @param {String} [options.title] - the text tooltip content * @param {String} [options.disableEvents] - ignore events for the node * @param {Boolean} [options.hide = false] - if the text starts hidden * @returns {Raphael.Element} the created text */ createText: function (paper, options) { const top = options.top || 0; const left = options.left || 0; const content = options.content || ''; const style = options.style || 'small-text'; const title = options.title || ''; const disableEvents = options.disableEvents || false; let factor = 1; if (paper.width && paper.w) { factor = paper.width / paper.w; } const text = paper.text(left, top, content).toFront(); if (options.id) { text.id = options.id; } if (options.hide) { text.hide(); } text.attr(gstyle[style]); if (disableEvents) { text.node.setAttribute('pointer-events', 'none'); } if (typeof factor !== 'undefined' && factor !== 1) { const fontSize = parseInt(text.attr('font-size'), 10); const scaledFontSize = Math.floor(fontSize / factor) + 1; text.attr('font-size', scaledFontSize); } if (title) { this.updateTitle(text, title); } return text; }, /** * Create a text in the middle of the related shape. * * @param {Raphael.Paper} paper - the paper * @param {Raphael.Element} shape - the shape to add the text to * @param {Object} options - the text options * @param {String} [options.content] - the text content * @param {String} [options.id] - the element identifier * @param {String} [options.style = 'small-text'] - the style name according to the graphic-style.json keys * @param {String} [options.title] - the text tooltip content * @param {Boolean} [options.hide = false] - if the text starts hidden * @param {Boolean} [options.shapeClick = false] - clicking the text delegates to the shape * @returns {Raphael.Element} the created text */ createShapeText: function (paper, shape, options) { const bbox = shape.getBBox(); const text = this.createText( paper, _.merge( { left: bbox.x + bbox.width / 2, top: bbox.y + bbox.height / 2 }, options ) ); if (options.shapeClick) { text.click(() => { this.trigger(shape, 'click'); }); } return text; }, /** * Create an image with a padding and a border, using a set. * * @param {Raphael.Paper} paper - the paper * @param {Object} options - image options * @param {Number} options.left - x coord * @param {Number} options.top - y coord * @param {Number} options.width - image width * @param {Number} options.height - image height * @param {Number} options.url - image ulr * @param {Number} [options.padding = 6] - a multiple of 2 is welcomed * @param {Boolean} [options.border = false] - add a border around the image * @param {Boolean} [options.shadow = false] - add a shadow back the image * @returns {Raphael.Element} the created set, augmented of a move(x,y) method */ createBorderedImage: function (paper, options) { const padding = options.padding >= 0 ? options.padding : 6; const halfPad = padding / 2; const rx = options.left, ry = options.top, rw = options.width + padding, rh = options.height + padding; const ix = options.left + halfPad, iy = options.top + halfPad, iw = options.width, ih = options.height; const set = paper.set(); //create a rectangle with a padding and a border. const rect = paper .rect(rx, ry, rw, rh) .attr(options.border ? gstyle['imageset-rect-stroke'] : gstyle['imageset-rect-no-stroke']); //and an image centered into the rectangle. const image = paper.image(options.url, ix, iy, iw, ih).attr(gstyle['imageset-img']); if (options.shadow) { set.push( rect.glow({ width: 2, offsetx: 1, offsety: 1 }) ); } set.push(rect, image); /** * Add a move method to set that keep the given coords during an animation * @private * @param {Number} x - destination * @param {Number} y - destination * @param {Number} [duration = 400] - the animation duration * @returns {Raphael.Element} the set for chaining */ set.move = function move(x, y, duration) { const animation = raphael.animation({ x: x, y: y }, duration || 400); const elt = rect.animate(animation); image.animateWith(elt, animation, { x: x + halfPad, y: y + halfPad }, duration || 400); return set; }; return set; }, /** * Update the visual state of an Element * @param {Raphael.Element} element - the element to change the state * @param {String} state - the name of the state (from states) to switch to * @param {String} [title] - a title linked to this step */ updateElementState: function (element, state, title) { if (element) { if (element.node.classList.contains(ELEMENT_CLASS_NAME)) { element.attr({ class: `${ELEMENT_CLASS_NAME} ${state}` }); } else if (element.node.classList.contains(ELEMENT_CLASS_NAME2)) { if (state === 'active') { const scale = getSelectableStateTransform(element); element.animate({ transform: `s${scale}` }, 100, 'linear'); } else if (element.node.classList.contains('active')) { element.animate({ transform: '' }, 100, 'linear'); } element.attr({ class: `${ELEMENT_CLASS_NAME2} ${state}` }); } else if (element.animate) { element.animate(gstyle[state], 200, 'linear', function () { element.attr(gstyle[state]); //for attr that don't animate //preven issue in firefox 37 $(element.node).removeAttr('stroke-dasharray'); }); } if (title) { this.updateTitle(element, title); } } }, /** * Update the title of an element (the attr method of Raphael adds only new node instead of updating exisitings). * @param {Raphael.Element} element - the element to update the title * @param {String} [title] - the new title */ updateTitle: function (element, title) { if (element && element.node) { //removes all remaining titles nodes _.forEach(element.node.children, function (child) { if (child.nodeName.toLowerCase() === 'title') { element.node.removeChild(child); } }); //then set the new title element.attr('title', title); } }, /** * Highlight an element with the error style * @param {Raphael.Element} element - the element to hightlight * @param {String} [restorState = 'basic'] - the state to restore the elt into after flash */ highlightError: function (element, restoredState) { if (element) { element.flashing = true; this.updateElementState(element, 'error'); _.delay(() => { this.updateElementState(element, restoredState || 'basic'); element.flashing = false; }, 800); } }, /** * Trigger an event already bound to a raphael element * @param {Raphael.Element} element * @param {String} event - the event name * */ trigger: function (element, event) { const evt = element.events.filter(el => el.name === event); if (evt.length && evt[0] && typeof evt[0].f === 'function') { evt[0].f.apply(element, Array.prototype.slice.call(arguments, 2)); } }, /** * Get an x/y point from a MouseEvent * @param {MouseEvent} event - the source event * @param {Raphael.Paper} paper - the interaction paper * @param {jQueryElement} $container - the paper container * @param {Boolean} isResponsive - if the paper is scaling * @returns {Object} x,y point */ getPoint: function getPoint(event, paper, $container) { const point = this.clickPoint($container, event); const rect = $container.get(0).getBoundingClientRect(); const factor = paper.w / rect.width; point.x = Math.round(point.x * factor); point.y = Math.round(point.y * factor); return point; }, /** * Get paper position relative to the container * @param {jQueryElement} $container - the paper container * @param {Raphael.Paper} paper - the interaction paper * @returns {Object} position with top and left */ position: function ($container, paper) { const pw = parseInt(paper.w || paper.width, 10); const cw = parseInt($container.width(), 10); const ph = parseInt(paper.w || paper.width, 10); const ch = parseInt($container.height(), 10); return { left: (cw - pw) / 2, top: (ch - ph) / 2 }; }, /** * Get a point from a click event * @param {jQueryElement} $container - the element that contains the paper * @param {MouseEvent} event - the event triggered by the click * @returns {Object} the x,y point */ clickPoint: function ($container, event) { let x, y; const offset = $container.offset(); if (event.pageX || event.pageY) { x = event.pageX - offset.left; y = event.pageY - offset.top; } else if (event.clientX || event.clientY) { x = event.clientX + document.body.scrollLeft + document.documentElement.scrollLeft - offset.left; y = event.clientY + document.body.scrollTop + document.documentElement.scrollTop - offset.top; } return { x, y }; } }; return GraphicHelper; });