1060 lines
No EOL
36 KiB
JavaScript
1060 lines
No EOL
36 KiB
JavaScript
Numbas.queueScript('part-display',['display-util', 'display-base','util','jme'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
var util = Numbas.util;
|
|
|
|
class PartDisplay {
|
|
constructor(p) {
|
|
this.part = p;
|
|
|
|
let parentPart = p;
|
|
while(parentPart.parentPart) {
|
|
parentPart = parentPart.parentPart;
|
|
}
|
|
this.parentPart = parentPart;
|
|
|
|
this.message_handlers = {
|
|
'part_answer': ({answer}) => this.part.storeAnswer(answer),
|
|
'submit': () => {
|
|
this.parentPart.submit();
|
|
}
|
|
};
|
|
|
|
let prompt_string = this.part.json.prompt;
|
|
if(this.part.type == 'gapfill') {
|
|
prompt_string = prompt_string.replace(
|
|
/\[\[(\d+)\]\]/g,
|
|
(m,n) => `<numbas-part mode="part_answer" exam="${this.part.question.exam.display_id}" question="${this.part.question.number}" part="${this.part.path}g${n}"></numbas-part>`
|
|
);
|
|
}
|
|
this.prompt = display.makeHTMLFromString(prompt_string, this.part.getScope());
|
|
}
|
|
|
|
update_app(type, arg) {
|
|
this.part.events.trigger('update app',{type, arg, partPath: this.part.path});
|
|
}
|
|
|
|
handle_message(data) {
|
|
const {msgtype} = data;
|
|
const fn = this.message_handlers[msgtype];
|
|
if(fn) {
|
|
fn(data);
|
|
}
|
|
}
|
|
|
|
isDirty(dirty) {
|
|
this.update_app('isDirty', dirty);
|
|
}
|
|
|
|
show() {
|
|
this.update_app('show');
|
|
}
|
|
|
|
updateNextParts() {
|
|
this.update_app('updateNextParts');
|
|
}
|
|
|
|
setName(name) {
|
|
this.update_app('setName', name);
|
|
}
|
|
|
|
warning(warning) {
|
|
this.update_app('warning', warning);
|
|
}
|
|
|
|
setWarnings(warnings) {
|
|
this.update_app('setWarnings', warnings);
|
|
}
|
|
|
|
waiting_for_pre_submit(waiting) {
|
|
this.update_app('waiting_for_pre_submit', waiting);
|
|
}
|
|
|
|
showScore(answered) {
|
|
this.update_app('showScore', answered);
|
|
}
|
|
|
|
updateCorrectAnswer(answer) {
|
|
this.update_app('updateCorrectAnswer', answer);
|
|
}
|
|
|
|
showSteps() {
|
|
this.update_app('showSteps');
|
|
}
|
|
|
|
hideSteps() {
|
|
this.update_app('hideSteps');
|
|
}
|
|
|
|
revealAnswer() {
|
|
this.update_app('revealAnswer');
|
|
}
|
|
|
|
lock() {
|
|
this.update_app('lock');
|
|
}
|
|
|
|
end() {
|
|
this.update_app('end');
|
|
}
|
|
|
|
restoreAnswer() {
|
|
this.update_app('restoreAnswer');
|
|
}
|
|
}
|
|
|
|
display.PartDisplay = PartDisplay;
|
|
});
|
|
Numbas.queueScript('question-display',['display-util', 'display-base','jme-variables','xml','schedule','jme','util'],function() {
|
|
var display = Numbas.display;
|
|
|
|
class QuestionDisplay {
|
|
constructor(question) {
|
|
this.question = question;
|
|
|
|
this.message_handlers = {
|
|
'part': ({partPath, msg}) => {
|
|
const part = this.question.getPart(partPath);
|
|
part.display.handle_message(msg);
|
|
}
|
|
}
|
|
|
|
this.allParts = this.question.allParts();
|
|
}
|
|
|
|
update_app(type, arg) {
|
|
//console.log('question update app',this.question.number, {type,arg});
|
|
this.question.events.trigger('update app', {type, arg, questionNumber: this.question.number});
|
|
}
|
|
|
|
handle_message(data) {
|
|
const {msgtype} = data;
|
|
const fn = this.message_handlers[msgtype];
|
|
if(fn) {
|
|
fn(data);
|
|
}
|
|
}
|
|
|
|
isDirty(dirty) {
|
|
this.update_app('isDirty',dirty);
|
|
}
|
|
|
|
addPart(part) {
|
|
part.events.on('update app', evt => this.update_app('part', evt));
|
|
this.allParts = this.question.allParts();
|
|
}
|
|
|
|
removePart(part) {
|
|
}
|
|
|
|
resume() {
|
|
}
|
|
|
|
updateParts() {
|
|
}
|
|
|
|
end() {
|
|
}
|
|
|
|
getPart(path) {
|
|
}
|
|
|
|
init() {
|
|
}
|
|
|
|
leave() {
|
|
}
|
|
|
|
makeHTML() {
|
|
this.statement = display.makeHTMLFromString(this.question.json.statement, this.question.getScope());
|
|
|
|
this.question.signals.trigger('mainHTMLAttached');
|
|
}
|
|
|
|
revealAnswer() {
|
|
}
|
|
|
|
review() {
|
|
}
|
|
|
|
show() {
|
|
}
|
|
|
|
showAdvice() {
|
|
}
|
|
|
|
showScore() {
|
|
this.update_app('showScore');
|
|
}
|
|
}
|
|
|
|
display.QuestionDisplay = QuestionDisplay;
|
|
})
|
|
;
|
|
Numbas.queueScript('exam-display',['display-util', 'display-base','math','util','timing'],function() {
|
|
var display = Numbas.display;
|
|
var util = Numbas.util;
|
|
|
|
class ExamDisplay {
|
|
/**
|
|
* @param {Numbas.Exam} exam
|
|
*/
|
|
constructor(exam) {
|
|
this.exam = exam;
|
|
Numbas.signals.trigger('display ready');
|
|
}
|
|
|
|
update_app(type, arg) {
|
|
//console.log('exam update app', {type,arg});
|
|
this.exam.events.trigger('update app', {type, arg});
|
|
}
|
|
|
|
/** Update the timer.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
showTiming() {
|
|
//this.update_app('showTiming');
|
|
}
|
|
|
|
/** Initialise the question list display.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
initQuestionList() {
|
|
this.update_app('initQuestionList');
|
|
}
|
|
|
|
addQuestion(question) {
|
|
question.events.on('update app', (msg) => this.update_app('question',msg));
|
|
this.updateQuestionList();
|
|
}
|
|
|
|
/** Update the list of questions after a change.
|
|
*/
|
|
updateQuestionList() {
|
|
this.update_app('updateQuestionList');
|
|
}
|
|
|
|
/** Hide the timer.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
hideTiming() {
|
|
this.update_app('hideTiming');
|
|
}
|
|
|
|
/** Show/update the student's total score.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
showScore() {
|
|
this.update_app('showScore');
|
|
}
|
|
|
|
/** Show an info page (one of the front page, pause, or results).
|
|
*
|
|
* @param {string} page - Name of the page to show.
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
showInfoPage(page) {
|
|
this.update_app('showInfoPage',page);
|
|
}
|
|
|
|
/** Show the modal dialog with actions the student can take to move on from the current question.
|
|
*/
|
|
showDiagnosticActions() {
|
|
this.update_app('showDiagnosticActions');
|
|
}
|
|
|
|
/** Show the current question.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
showQuestion() {
|
|
this.update_app('showQuestion');
|
|
}
|
|
|
|
/** Called just before the current question is regenerated.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
startRegen() {
|
|
this.update_app('startRegen');
|
|
}
|
|
/** Called after the current question has been regenerated.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
endRegen() {
|
|
this.update_app('endRegen');
|
|
}
|
|
|
|
/** Reveal the answers to every question in the exam.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
revealAnswers() {
|
|
this.update_app('revealAnswers');
|
|
}
|
|
|
|
/** Called when the exam ends.
|
|
*
|
|
* @memberof Numbas.display.ExamDisplay
|
|
*/
|
|
end() {
|
|
this.update_app('end');
|
|
}
|
|
}
|
|
|
|
display.ExamDisplay = ExamDisplay;
|
|
});
|
|
;
|
|
Numbas.queueScript('mathjax-hooks',['display-base','jme','jme-display'],function() {
|
|
/*
|
|
if(typeof MathJax=='undefined') {
|
|
return;
|
|
}
|
|
var jme = Numbas.jme;
|
|
MathJax.Hub.Register.StartupHook("TeX Jax Ready",function () {
|
|
var TEX = MathJax.InputJax.TeX;
|
|
var currentScope = null;
|
|
TEX.prefilterHooks.Add(function(data) {
|
|
currentScope = Numbas.display.find_jme_scope(data.script);
|
|
});
|
|
TEX.Definitions.Add({macros: {
|
|
'var': 'JMEvar',
|
|
'simplify': 'JMEsimplify'
|
|
}});
|
|
TEX.Parse.Augment({
|
|
JMEvar: function(name) {
|
|
var settings_string = this.GetBrackets(name);
|
|
var settings = {};
|
|
if(settings_string!==undefined) {
|
|
settings_string.split(/\s*,\s* /g).forEach(function(v) {
|
|
var setting = jme.normaliseRulesetName(v.trim());
|
|
settings[setting] = true;
|
|
});
|
|
}
|
|
var expr = this.GetArgument(name);
|
|
var scope = currentScope;
|
|
try {
|
|
var v = jme.evaluate(jme.compile(expr,scope),scope);
|
|
var tex = jme.display.texify({tok: v},settings,scope);
|
|
}catch(e) {
|
|
throw(new Numbas.Error('mathjax.math processing error',{message:e.message,expression:expr}));
|
|
}
|
|
var mml = TEX.Parse(tex,this.stack.env).mml();
|
|
this.Push(mml);
|
|
},
|
|
JMEsimplify: function(name) {
|
|
var ruleset = this.GetBrackets(name);
|
|
if(ruleset === undefined) {
|
|
ruleset = 'all';
|
|
}
|
|
var expr = this.GetArgument(name);
|
|
var scope = currentScope;
|
|
|
|
var subbed_tree = Numbas.jme.display.subvars(expr, scope);
|
|
|
|
try {
|
|
var tex = Numbas.jme.display.treeToLaTeX(subbed_tree, ruleset, scope);
|
|
} catch(e) {
|
|
throw(new Numbas.Error('mathjax.math processing error',{message:e.message,expression:expr}));
|
|
}
|
|
|
|
var mml = TEX.Parse(tex,this.stack.env).mml();
|
|
this.Push(mml);
|
|
}
|
|
})
|
|
});
|
|
*/
|
|
});
|
|
Numbas.queueScript('display/parts/custom',['display-base','part-display','util','jme'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
|
|
class CustomPartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.CustomPartDisplay = CustomPartDisplay;
|
|
|
|
});
|
|
Numbas.queueScript('display/parts/extension',['display-base','part-display','util'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
|
|
class ExtensionPartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.ExtensionPartDisplay = ExtensionPartDisplay;
|
|
|
|
});
|
|
Numbas.queueScript('display/parts/gapfill',['display-base','part-display','util'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
|
|
class GapFillPartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.GapFillPartDisplay = GapFillPartDisplay;
|
|
|
|
});
|
|
Numbas.queueScript('display/parts/information',['display-base','part-display','util'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
|
|
class InformationPartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.InformationPartDisplay = InformationPartDisplay;
|
|
|
|
});
|
|
;
|
|
Numbas.queueScript('display/parts/jme',['display-base','part-display','util','jme-display','jme'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
var jme = Numbas.jme;
|
|
|
|
class JMEPartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.JMEPartDisplay = JMEPartDisplay;
|
|
})
|
|
Numbas.queueScript('display/parts/matrix',['display-base','part-display','util','jme','jme-display'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
var util = Numbas.util;
|
|
|
|
class MatrixEntryPartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.MatrixEntryPartDisplay = MatrixEntryPartDisplay;
|
|
|
|
});
|
|
Numbas.queueScript('display/parts/multipleresponse',['display-base','part-display','util'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
var util = Numbas.util;
|
|
|
|
class MultipleResponsePartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.MultipleResponsePartDisplay = MultipleResponsePartDisplay;
|
|
|
|
});
|
|
Numbas.queueScript('display/parts/numberentry',['display-base','part-display','util'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
var util = Numbas.util;
|
|
|
|
class NumberEntryPartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.NumberEntryPartDisplay = NumberEntryPartDisplay;
|
|
});
|
|
Numbas.queueScript('display/parts/patternmatch',['display-base','part-display','util'],function() {
|
|
var display = Numbas.display;
|
|
var extend = Numbas.util.extend;
|
|
|
|
class PatternMatchPartDisplay extends Numbas.display.PartDisplay {
|
|
}
|
|
|
|
Numbas.display.PatternMatchPartDisplay = PatternMatchPartDisplay;
|
|
});
|
|
Numbas.queueScript('display-base',['display-util', 'controls', 'math', 'xml', 'util', 'timing', 'jme', 'jme-display'],function() {
|
|
var util = Numbas.util;
|
|
var jme = Numbas.jme;
|
|
var display_util = Numbas.display_util;
|
|
/** @namespace Numbas.display */
|
|
|
|
var display = Numbas.display = /** @lends Numbas.display */ {
|
|
/** Update the progress bar when loading.
|
|
*/
|
|
showLoadProgress: function() {
|
|
},
|
|
|
|
makeHTMLFromString(html, scope, contextDescription) {
|
|
try {
|
|
|
|
const element = document.createElement('div');
|
|
element.innerHTML = html;
|
|
Numbas.xml.localise(element);
|
|
display.setJMEScope(element, scope);
|
|
if(!element.getAttribute('data-jme-context-description')) {
|
|
element.setAttribute('data-jme-context-description',contextDescription);
|
|
}
|
|
|
|
const subbed_element = Numbas.jme.variables.DOMcontentsubvars(element,scope);
|
|
|
|
Numbas.display.register_lightbox(subbed_element);
|
|
Numbas.display.typeset(subbed_element);
|
|
return subbed_element;
|
|
|
|
} catch(error) {
|
|
console.error(error);
|
|
throw(error);
|
|
|
|
var errorContextDescriptionBits = [];
|
|
var errorContextDescription;
|
|
if(error.element) {
|
|
var elem = error.element;
|
|
while(elem) {
|
|
if(elem.nodeType==1) {
|
|
var desc = Numbas.display.getLocalisedAttribute(elem,'data-jme-context-description');
|
|
if(desc) {
|
|
errorContextDescriptionBits.splice(0,0,desc);
|
|
}
|
|
}
|
|
elem = elem.parentElement;
|
|
}
|
|
errorContextDescription = errorContextDescriptionBits.join(' ');
|
|
} else {
|
|
errorContextDescription = contextDescription;
|
|
}
|
|
Numbas.schedule.halt(new Numbas.Error('display.error making html',{contextDescription: errorContextDescription, message: error.message},error));
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Find the element's top ancestor node. For elements in the document, this will be the document object itself.
|
|
*
|
|
* @param {Element} element
|
|
* @returns {Node}
|
|
*/
|
|
find_root_ancestor: function(element) {
|
|
while(element.parentNode) {
|
|
element = element.parentNode;
|
|
}
|
|
return element;
|
|
},
|
|
|
|
|
|
typeset: function(element,callback) {
|
|
if(!element) {
|
|
element = document.body;
|
|
}
|
|
let elements = [element];
|
|
|
|
var tries = 0;
|
|
var delay = 10;
|
|
|
|
const all_elements = elements.slice();
|
|
/**
|
|
* Try to typeset the given elements.
|
|
* An element is typeset if it is attached to the main document, and has a parent which specifies a JME scope to use.
|
|
*
|
|
* After each attempt, if there are any elements still waiting to be typeset, there's an exponentially growing delay before trying again.
|
|
*
|
|
* Once all elements have been typeset, the callback is called.
|
|
*/
|
|
async function try_to_typeset() {
|
|
try {
|
|
elements = elements.filter(element => {
|
|
var root = display.find_root_ancestor(element);
|
|
if(root.ownerDocument !== document) {
|
|
return true;
|
|
}
|
|
|
|
var scope = display.find_jme_scope(element);
|
|
if(!scope) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
if(elements.length) {
|
|
delay *= 1.1;
|
|
setTimeout(try_to_typeset, delay);
|
|
} else {
|
|
await MathJax.typesetPromise(all_elements);
|
|
all_elements.forEach(e => {
|
|
const root = e.getRootNode();
|
|
const s = document.getElementById('MJX-SVG-styles');
|
|
if(!s) {
|
|
return;
|
|
}
|
|
let css_tag = root.querySelector('#MJX-SVG-styles');
|
|
if(!css_tag) {
|
|
css_tag = s.cloneNode(true);
|
|
root.append(css_tag);
|
|
}
|
|
css_tag.textContent = s.textContent;
|
|
})
|
|
if(callback) {
|
|
callback(elements);
|
|
}
|
|
}
|
|
} catch(e) {
|
|
if(window.MathJax===undefined && !display.failedMathJax) {
|
|
display.failedMathJax = true;
|
|
display.showAlert("Failed to load MathJax. Maths will not be typeset properly.\n\nIf you are the exam author, please check that you are connected to the internet, or modify the theme to load a local copy of MathJax. Instructions for doing this are given in the manual.");
|
|
} else {
|
|
Numbas.schedule.halt(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
setTimeout(try_to_typeset, 100);
|
|
},
|
|
|
|
register_lightbox() {
|
|
|
|
},
|
|
|
|
/** Initialise the display. Called as soon as the page loads.
|
|
*/
|
|
init: function() {
|
|
},
|
|
|
|
//alert / confirm boxes
|
|
//
|
|
/** Callback functions for the modals.
|
|
*
|
|
* @type {Object<Function>}
|
|
*/
|
|
modal: {
|
|
ok: function() {},
|
|
cancel: function() {}
|
|
},
|
|
|
|
/** Show an alert dialog.
|
|
*
|
|
* @param {string} msg - message to show the user
|
|
* @param {Function} fnOK - callback when OK is clicked
|
|
*/
|
|
showAlert: function(msg,fnOK) {
|
|
},
|
|
|
|
/** Show a confirmation dialog box.
|
|
*
|
|
* @param {string} msg - message to show the user
|
|
* @param {Function} fnOK - callback if OK is clicked
|
|
* @param {Function} fnCancel - callback if cancelled
|
|
*/
|
|
showConfirm: function(msg,fnOK,fnCancel) {
|
|
},
|
|
|
|
/** Show the end exam confirmation dialog box.
|
|
*
|
|
* @param {string} msg - message to show the user
|
|
* @param {Function} fnEnd - callback to end the exam
|
|
* @param {Function} fnCancel - callback if cancelled
|
|
*/
|
|
showConfirmEndExam: function(msg,fnEnd,fnCancel) {
|
|
},
|
|
|
|
/**
|
|
* Find the JME scope that applies to this element.
|
|
* Looks for an element with a `'jme_scope'` attribute.
|
|
*
|
|
* @param {Element} element
|
|
* @returns {Numbas.jme.Scope}
|
|
*/
|
|
find_jme_scope: function(element) {
|
|
while(element && !element.jme_scope) {
|
|
if(!element.parentElement) {
|
|
const root = element.getRootNode();
|
|
if(root !== document) {
|
|
element = root.host;
|
|
}
|
|
} else {
|
|
element = element.parentElement;
|
|
}
|
|
}
|
|
return element ? element.jme_scope : null;
|
|
},
|
|
|
|
/** Associate a JME scope with the given element.
|
|
*
|
|
* @param {Element} element
|
|
* @param {Numbas.jme.Scope} scope
|
|
*/
|
|
setJMEScope: function(element, scope) {
|
|
element.classList.add('jme-scope');
|
|
element.jme_scope = scope;
|
|
},
|
|
|
|
/** The Numbas exam has failed so much it can't continue - show an error message and the error.
|
|
*
|
|
* @param {Error} e
|
|
*/
|
|
die: function(e) {
|
|
},
|
|
};
|
|
|
|
});
|
|
;
|
|
|
|
Numbas.queueScript('display-util', ['math'], function() {
|
|
/** Parse a colour in hexadecimal RGB format into separate red, green and blue components.
|
|
*
|
|
* @param {string} hex - The hex string representing the colour, in the form `#000000`.
|
|
* @returns {Array.<number>} - An array of the form `[r,g,b]`.
|
|
*/
|
|
function parseRGB(hex) {
|
|
var r = parseInt(hex.slice(1,3));
|
|
var g = parseInt(hex.slice(3,5));
|
|
var b = parseInt(hex.slice(5,7));
|
|
return [r,g,b];
|
|
}
|
|
|
|
/** Convert a colour given in red, green, blue components to hue, saturation, lightness.
|
|
* From https://css-tricks.com/converting-color-spaces-in-javascript/.
|
|
*
|
|
* @param {number} r - The red component.
|
|
* @param {number} g - The green component.
|
|
* @param {number} b - The blue component.
|
|
* @returns {Array.<number>} - The colour in HSL format, an array of the form `[h,s,l]`.
|
|
* */
|
|
function RGBToHSL(r,g,b) {
|
|
r /= 255;
|
|
g /= 255;
|
|
b /= 255;
|
|
|
|
var cmin = Math.min(r,g,b);
|
|
var cmax = Math.max(r,g,b);
|
|
var delta = cmax - cmin;
|
|
|
|
var h,s,l;
|
|
|
|
if (delta == 0) {
|
|
h = 0;
|
|
} else if (cmax == r) {
|
|
h = ((g - b) / delta) % 6;
|
|
} else if (cmax == g) {
|
|
h = (b - r) / delta + 2;
|
|
} else {
|
|
h = (r - g) / delta + 4;
|
|
}
|
|
|
|
h = (h*60) % 360;
|
|
|
|
if (h < 0) {
|
|
h += 360;
|
|
}
|
|
|
|
l = (cmax + cmin) / 2;
|
|
|
|
s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
|
|
|
return [h,s,l];
|
|
}
|
|
|
|
/** Convert a colour in hue, saturation, lightness format to red, green, blue.
|
|
* From https://css-tricks.com/converting-color-spaces-in-javascript/.
|
|
*
|
|
* @param {number} h - The hue component.
|
|
* @param {number} s - The saturation component.
|
|
* @param {number} l - The lightness component.
|
|
* @returns {Array.<number>} - An array of the form `[r,g,b]`.
|
|
*/
|
|
function HSLToRGB(h,s,l) {
|
|
var c = (1 - Math.abs(2 * l - 1)) * s;
|
|
var x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
|
var m = l - c/2;
|
|
|
|
var r,g,b;
|
|
|
|
if (0 <= h && h < 60) {
|
|
r = c; g = x; b = 0;
|
|
} else if (60 <= h && h < 120) {
|
|
r = x; g = c; b = 0;
|
|
} else if (120 <= h && h < 180) {
|
|
r = 0; g = c; b = x;
|
|
} else if (180 <= h && h < 240) {
|
|
r = 0; g = x; b = c;
|
|
} else if (240 <= h && h < 300) {
|
|
r = x; g = 0; b = c;
|
|
} else if (300 <= h && h < 360) {
|
|
r = c; g = 0; b = x;
|
|
}
|
|
r = (r + m) * 255;
|
|
g = (g + m) * 255;
|
|
b = (b + m) * 255;
|
|
|
|
return [r,g,b];
|
|
}
|
|
|
|
var measurer;
|
|
var measureText_cache = {};
|
|
function measureText(element) {
|
|
var styles = window.getComputedStyle(element);
|
|
|
|
if(!measurer) {
|
|
measurer = document.createElement('div');
|
|
measurer.style['position'] = 'absolute';
|
|
measurer.style['left'] = '-10000';
|
|
measurer.style['top'] = '-10000';
|
|
measurer.style['visibility'] = 'hidden';
|
|
}
|
|
|
|
var keys = ['font-size','font-style', 'font-weight', 'font-family', 'line-height', 'text-transform', 'letter-spacing'];
|
|
var id = element.value+';'+keys.map(function(key) { return styles[key]; }).join(';');
|
|
if(measureText_cache[id]) {
|
|
return measureText_cache[id];
|
|
}
|
|
keys.forEach(function(key) {
|
|
measurer.style[key] = styles[key];
|
|
});
|
|
measurer.textContent = element.value;
|
|
document.body.appendChild(measurer);
|
|
var box = measurer.getBoundingClientRect();
|
|
measureText_cache[id] = box;
|
|
document.body.removeChild(measurer);
|
|
return box;
|
|
}
|
|
|
|
/** An object which can produce feedback: {@link Numbas.Question} or {@link Numbas.parts.Part}.
|
|
*
|
|
* @typedef {object} Numbas.display_util.feedbackable
|
|
* @property {observable.<boolean>} answered - Has the object been answered?
|
|
* @property {observable.<boolean>} isDirty - Has the student's answer changed?
|
|
* @property {observable.<number>} score - Number of marks awarded
|
|
* @property {observable.<number>} marks - Number of marks available
|
|
* @property {observable.<number>} credit - Proportion of available marks awarded
|
|
* @property {observable.<boolean>} doesMarking - Does the object do any marking?
|
|
* @property {observable.<boolean>} revealed - Have the correct answers been revealed?
|
|
* @property {boolean} plainScore - Show the score without the "Score: " prefix?
|
|
*/
|
|
/** Settings for {@link Numbas.display_util.showScoreFeedback}
|
|
*
|
|
* @typedef {object} Numbas.display_util.showScoreFeedback_settings
|
|
* @property {boolean} showTotalMark - Show the total marks available?
|
|
* @property {boolean} showActualMark - Show the student's current score?
|
|
* @property {boolean} showAnswerState - Show the correct/incorrect state after marking?
|
|
* @property {boolean} reviewShowScore - Show the score once answers have been revealed?
|
|
* @property {boolean} [reveal_answers_for_instructor=true] - When `Numbas.is_instructor` is true, always act as if the object has been revealed?
|
|
*/
|
|
/** Feedback states for a question or part: "wrong", "correct", "partial" or "none".
|
|
*
|
|
* @typedef {string} Numbas.display_util.feedback_state
|
|
*/
|
|
/** A model representing feedback on an item which is marked - a question or a part.
|
|
*
|
|
* @typedef {object} Numbas.display_util.scoreFeedback
|
|
* @property {observable.<boolean>} update - Call `update(true)` when the score changes. Used to trigger animations.
|
|
* @property {observable.<Numbas.display_util.feedback_state>} state - The current state of the item, to be shown to the student.
|
|
* @property {observable.<boolean>} answered - Has the item been answered? False if the student has changed their answer since submitting.
|
|
* @property {observable.<string>} answeredString - Translated text describing how much of the item has been answered: 'unanswered', 'partially answered' or 'answered'
|
|
* @property {observable.<string>} message - Text summarising the state of the item.
|
|
* @property {observable.<string>} iconClass - CSS class for the feedback icon.
|
|
* @property {observable.<object>} iconAttr - A dictionary of attributes for the feedback icon.
|
|
*/
|
|
/** Update a score feedback box.
|
|
*
|
|
* @param {Numbas.display_util.feedbackable} obj - Object to show feedback about.
|
|
* @param {Numbas.display_util.showScoreFeedback_settings} settings
|
|
* @memberof Numbas.display
|
|
* @returns {Numbas.display_util.scoreFeedback}
|
|
*/
|
|
function showScoreFeedback(obj,settings)
|
|
{
|
|
var niceNumber = Numbas.math.niceNumber;
|
|
var scoreDisplay = '';
|
|
var newScore = Knockout.observable(false);
|
|
var answered = Knockout.computed(function() {
|
|
return obj.answered();
|
|
});
|
|
var attempted = Knockout.computed(function() {
|
|
return obj.visited!==undefined && obj.visited();
|
|
});
|
|
var showFeedbackIcon = settings.showFeedbackIcon === undefined ? settings.showAnswerState : settings.showFeedbackIcon;
|
|
var anyAnswered = Knockout.computed(function() {
|
|
if(obj.anyAnswered===undefined) {
|
|
return answered();
|
|
} else {
|
|
return obj.anyAnswered();
|
|
}
|
|
});
|
|
var partiallyAnswered = Knockout.computed(function() {
|
|
return anyAnswered() && !answered();
|
|
},this);
|
|
var revealed = Knockout.computed(function() {
|
|
return (obj.revealed() && settings.reviewShowScore) || (Numbas.is_instructor && settings.reveal_answers_for_instructor!==false);
|
|
});
|
|
var state = Knockout.computed(function() {
|
|
var score = obj.score();
|
|
var marks = obj.marks();
|
|
var credit = obj.credit();
|
|
if( obj.doesMarking() && showFeedbackIcon && (revealed() || (settings.showAnswerState && anyAnswered())) ) {
|
|
if(credit<=0) {
|
|
return 'wrong';
|
|
} else if(Numbas.math.precround(credit,10)>=1) {
|
|
return 'correct';
|
|
} else {
|
|
return 'partial';
|
|
}
|
|
}
|
|
else {
|
|
return 'none';
|
|
}
|
|
});
|
|
var messageIngredients = ko.computed(function() {
|
|
var score = obj.score();
|
|
var marks = obj.marks();
|
|
var scoreobj = {
|
|
marks: marks,
|
|
score: score,
|
|
marksString: niceNumber(marks)+' '+R('mark',{count:marks}),
|
|
scoreString: niceNumber(score)+' '+R('mark',{count:score}),
|
|
};
|
|
var messageKey;
|
|
if(marks==0) {
|
|
messageKey = 'question.score feedback.not marked';
|
|
} else if(!revealed()) {
|
|
if(settings.showActualMark) {
|
|
if(settings.showTotalMark) {
|
|
messageKey = 'question.score feedback.score total actual';
|
|
} else {
|
|
messageKey = 'question.score feedback.score actual';
|
|
}
|
|
} else if(settings.showTotalMark) {
|
|
messageKey = 'question.score feedback.score total';
|
|
} else {
|
|
var key = answered () ? 'answered' : anyAnswered() ? 'partially answered' : 'unanswered';
|
|
messageKey = 'question.score feedback.'+key;
|
|
}
|
|
} else {
|
|
messageKey = 'question.score feedback.score total actual';
|
|
}
|
|
return {key: messageKey, scoreobj: scoreobj};
|
|
});
|
|
return {
|
|
update: Knockout.computed({
|
|
read: function() {
|
|
return newScore();
|
|
},
|
|
write: function() {
|
|
newScore(true);
|
|
newScore(false);
|
|
}
|
|
}),
|
|
revealed: revealed,
|
|
state: state,
|
|
answered: answered,
|
|
answeredString: Knockout.computed(function() {
|
|
if((obj.marks()==0 && !obj.doesMarking()) || !(revealed() || settings.showActualMark || settings.showTotalMark)) {
|
|
return '';
|
|
}
|
|
var key = answered() ? 'answered' : partiallyAnswered() ? 'partially answered' : 'unanswered';
|
|
return R('question.score feedback.'+key);
|
|
},this),
|
|
attemptedString: Knockout.computed(function() {
|
|
var key = attempted() ? 'attempted' : 'unattempted';
|
|
return R('question.score feedback.'+key);
|
|
},this),
|
|
message: Knockout.computed(function() {
|
|
var ingredients = messageIngredients();
|
|
return R(ingredients.key,ingredients.scoreobj);
|
|
}),
|
|
plainMessage: Knockout.computed(function() {
|
|
var ingredients = messageIngredients();
|
|
var key = ingredients.key;
|
|
if(key=='question.score feedback.score total actual' || key=='question.score feedback.score actual') {
|
|
key += '.plain';
|
|
}
|
|
return R(key,ingredients.scoreobj);
|
|
}),
|
|
iconClass: Knockout.computed(function() {
|
|
if (!showFeedbackIcon) {
|
|
return 'invisible';
|
|
}
|
|
switch(state()) {
|
|
case 'wrong':
|
|
return 'icon-remove';
|
|
case 'correct':
|
|
return 'icon-ok';
|
|
case 'partial':
|
|
return 'icon-ok partial';
|
|
default:
|
|
return '';
|
|
}
|
|
}),
|
|
iconAttr: Knockout.computed(function() {
|
|
return {title:state()=='none' ? '' : R('question.score feedback.'+state())};
|
|
})
|
|
}
|
|
};
|
|
|
|
function passwordHandler(settings) {
|
|
var value = Knockout.observable('');
|
|
|
|
var valid = Knockout.computed(function() {
|
|
return settings.accept(value());
|
|
});
|
|
|
|
return {
|
|
value: value,
|
|
valid: valid,
|
|
feedback: Knockout.computed(function() {
|
|
if(valid()) {
|
|
return {iconClass: 'icon-ok', title: settings.correct_message, buttonClass: 'btn-success'};
|
|
} else if(value()=='') {
|
|
return {iconClass: '', title: '', buttonClass: 'btn-primary'}
|
|
} else {
|
|
return {iconClass: 'icon-remove', title: settings.incorrect_message, buttonClass: 'btn-danger'};
|
|
}
|
|
})
|
|
};
|
|
}
|
|
|
|
/** Localise strings in page HTML - for tags with an attribute `data-localise`, run that attribute through R.js to localise it, and replace the tag's HTML with the result.
|
|
*/
|
|
function localisePage() {
|
|
for(let e of document.querySelectorAll('[data-localise]')) {
|
|
const localString = R(e.getAttribute('data-localise'));
|
|
e.innerHTML = localString;
|
|
}
|
|
for(let e of document.querySelectorAll('[localise-aria-label]')) {
|
|
const localString = R(e.getAttribute('localise-aria-label'));
|
|
e.setAttribute('aria-label', localString);
|
|
}
|
|
}
|
|
|
|
/** Get the attribute with the given name or, if it doesn't exist, look for localise-<name>.
|
|
* If that exists, localise its value and set the desired attribute, then return it.
|
|
*
|
|
* @param {Element} elem
|
|
* @param {string} name
|
|
* @returns {string}
|
|
*/
|
|
function getLocalisedAttribute(elem, name) {
|
|
var attr_localise;
|
|
var attr = elem.getAttribute(name);
|
|
if(!attr && (attr_localise = elem.getAttribute('localise-'+name))) {
|
|
attr = R(attr_localise);
|
|
elem.setAttribute(name,attr);
|
|
}
|
|
return attr;
|
|
}
|
|
|
|
var display_util = Numbas.display_util = {
|
|
parseRGB,
|
|
RGBToHSL,
|
|
HSLToRGB,
|
|
measureText,
|
|
showScoreFeedback,
|
|
passwordHandler,
|
|
localisePage,
|
|
getLocalisedAttribute,
|
|
};
|
|
});
|
|
|
|
/*
|
|
Copyright 2011-16 Newcastle University
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
/** @file Display code. Provides {@link Numbas.display} */
|
|
Numbas.queueScript('display',[],function() {
|
|
}); |