From 6df627b93d5ea2af0fad146eb374a050dfd9f41a Mon Sep 17 00:00:00 2001 From: Andreas Schwenk Date: Tue, 24 Sep 2024 18:00:27 +0200 Subject: [PATCH] v1.2.6 --- web/src/eval.js | 16 ++-- web/src/icons.js | 4 +- web/src/index.js | 212 ++++++++++++++++++++++++++++++++++++-------- web/src/input.js | 12 +++ web/src/lang.js | 40 ++++++++- web/src/math.js | 3 +- web/src/question.js | 50 ++++++----- 7 files changed, 264 insertions(+), 73 deletions(-) diff --git a/web/src/eval.js b/web/src/eval.js index ed057cc..926fdbd 100644 --- a/web/src/eval.js +++ b/web/src/eval.js @@ -209,19 +209,23 @@ export function evalQuestion(question) { let text = choices[Math.floor(Math.random() * choices.length)]; question.feedbackPopupDiv.innerHTML = text; question.feedbackPopupDiv.style.color = - question.state === QuestionState.passed ? "green" : "maroon"; - question.feedbackPopupDiv.style.display = "block"; + question.state === QuestionState.passed ? "var(--green)" : "var(--red)"; + question.feedbackPopupDiv.style.display = "flex"; setTimeout(() => { question.feedbackPopupDiv.style.display = "none"; - }, 500); + }, 1000); // change the question button + question.editingEnabled = true; if (question.state === QuestionState.passed) { - if (question.src.instances.length > 0) { + question.editingEnabled = false; + if (question.src.instances.length > 1) { // if the student passed and there are other question instances, // provide the ability to repeat the question question.checkAndRepeatBtn.innerHTML = iconRepeat; - } else question.checkAndRepeatBtn.style.display = "none"; - } else { + } else { + question.checkAndRepeatBtn.style.visibility = "hidden"; + } + } else if (question.checkAndRepeatBtn != null) { // in case of non-passing, the check button must be provided (kept) question.checkAndRepeatBtn.innerHTML = iconCheck; } diff --git a/web/src/icons.js b/web/src/icons.js index c6d9e0f..a1c1cb9 100644 --- a/web/src/icons.js +++ b/web/src/icons.js @@ -27,7 +27,7 @@ export const iconCircleChecked = // icon at the bottom of a question to check the question export const iconCheck = - ''; + ''; // icon at the bottom of a question to repeat the question -export const iconRepeat = ``; +export const iconRepeat = ``; diff --git a/web/src/index.js b/web/src/index.js index 493f900..17b8c7b 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -5,8 +5,17 @@ ******************************************************************************/ import { genDiv } from "./dom.js"; -import { courseInfo1, courseInfo2, courseInfo3 } from "./lang.js"; -import { Question } from "./question.js"; +import { evalQuestion } from "./eval.js"; +import { + courseInfo1, + courseInfo2, + courseInfo3, + dataPolicy, + evalNowText, + pointsText, + timerInfoText, +} from "./lang.js"; +import { Question, QuestionState } from "./question.js"; /** * This file is the entry point of the quiz website and populates the DOM. @@ -17,42 +26,169 @@ import { Question } from "./question.js"; * @param {boolean} debug -- true for enabling extensive debugging features */ export function init(quizSrc, debug) { - // default to English, if the provided language abbreviation is unknown - if (["en", "de", "es", "it", "fr"].includes(quizSrc.lang) == false) - quizSrc.lang = "en"; - // if debugging is enabled, show a DEBUG info at the start of the page - if (debug) document.getElementById("debug").style.display = "block"; - // show the quiz' meta data - document.getElementById("date").innerHTML = quizSrc.date; - document.getElementById("title").innerHTML = quizSrc.title; - document.getElementById("author").innerHTML = quizSrc.author; - document.getElementById("courseInfo1").innerHTML = courseInfo1[quizSrc.lang]; - let reload = - '' + - courseInfo3[quizSrc.lang] + - ""; - document.getElementById("courseInfo2").innerHTML = courseInfo2[ - quizSrc.lang - ].replace("*", reload); - - // generate questions - /** @type {Question[]} */ - let questions = []; - /** @type {HTMLElement} */ - let questionsDiv = document.getElementById("questions"); - let idx = 1; // question index 1, 2, ... - for (let questionSrc of quizSrc.questions) { - questionSrc.title = "" + idx + ". " + questionSrc.title; - let div = genDiv(); - questionsDiv.appendChild(div); - let question = new Question(div, questionSrc, quizSrc.lang, debug); - question.showSolution = debug; - questions.push(question); - question.populateDom(); - if (debug && questionSrc.error.length == 0) { - // if the debug version is active, evaluate the question immediately - if (question.hasCheckButton) question.checkAndRepeatBtn.click(); + new Quiz(quizSrc, debug); +} + +/** + * Question management. + */ +export class Quiz { + /** + * + * @param {Object.} quizSrc -- JSON object as output from sell.py + * @param {boolean} debug -- true for enabling extensive debugging features + */ + constructor(quizSrc, debug) { + /** @type {Object.} */ + this.quizSrc = quizSrc; + + // default to English, if the provided language abbreviation is unknown + if (["en", "de", "es", "it", "fr"].includes(this.quizSrc.lang) == false) + this.quizSrc.lang = "en"; + + /** @type {boolean} */ + this.debug = debug; + + // if debugging is enabled, show a DEBUG info at the start of the page + if (this.debug) document.getElementById("debug").style.display = "block"; + + /** @type {Question[]} -- the questions */ + this.questions = []; + + /** @type {number} -- positive value := limited time */ + this.timeLeft = quizSrc.timer; + /** @type {boolean} -- whether the quiz is time limited */ + this.timeLimited = quizSrc.timer > 0; + + this.fillPageMetadata(); + + if (this.timeLimited) { + document.getElementById("timer-info").style.display = "block"; + document.getElementById("timer-info-text").innerHTML = + timerInfoText[this.quizSrc.lang]; + document.getElementById("start-btn").addEventListener("click", () => { + document.getElementById("timer-info").style.display = "none"; + this.generateQuestions(); + this.runTimer(); + }); + } else { + this.generateQuestions(); + } + } + + /** + * Shows the quiz' meta data. + */ + fillPageMetadata() { + document.getElementById("date").innerHTML = this.quizSrc.date; + document.getElementById("title").innerHTML = this.quizSrc.title; + document.getElementById("author").innerHTML = this.quizSrc.author; + document.getElementById("courseInfo1").innerHTML = + courseInfo1[this.quizSrc.lang]; + let reload = + '' + + courseInfo3[this.quizSrc.lang] + + ""; + document.getElementById("courseInfo2").innerHTML = courseInfo2[ + this.quizSrc.lang + ].replace("*", reload); + + document.getElementById("data-policy").innerHTML = + dataPolicy[this.quizSrc.lang]; + } + + /** + * Generates the questions. + */ + generateQuestions() { + /** @type {HTMLElement} */ + let questionsDiv = document.getElementById("questions"); + let idx = 1; // question index 1, 2, ... + for (let questionSrc of this.quizSrc.questions) { + questionSrc.title = "" + idx + ". " + questionSrc.title; + let div = genDiv(); + questionsDiv.appendChild(div); + let question = new Question( + div, + questionSrc, + this.quizSrc.lang, + this.debug + ); + question.showSolution = this.debug; + this.questions.push(question); + question.populateDom(this.timeLimited); + if (this.debug && questionSrc.error.length == 0) { + // if the debug version is active, evaluate the question immediately + if (question.hasCheckButton) question.checkAndRepeatBtn.click(); + } + idx++; } - idx++; } + + /** + * Runs the timer countdown. + */ + runTimer() { + // button to evaluate quiz immediately + document.getElementById("stop-now").style.display = "block"; + document.getElementById("stop-now-btn").innerHTML = + evalNowText[this.quizSrc.lang]; + document.getElementById("stop-now-btn").addEventListener("click", () => { + this.timeLeft = 1; + }); + // create and show timer + let timerDiv = document.getElementById("timer"); + timerDiv.style.display = "block"; + timerDiv.innerHTML = formatTime(this.timeLeft); + // tick every second + let interval = setInterval(() => { + this.timeLeft--; + timerDiv.innerHTML = formatTime(this.timeLeft); + // stop, if no time is left + if (this.timeLeft <= 0) { + this.stopTimer(interval); + } + }, 1000); + } + + stopTimer(interval) { + document.getElementById("stop-now").style.display = "none"; + clearInterval(interval); + let score = 0; + let maxScore = 0; + for (let question of this.questions) { + let pts = question.src["points"]; + maxScore += pts; + evalQuestion(question); + if (question.state === QuestionState.passed) score += pts; + question.editingEnabled = false; + } + document.getElementById("questions-eval").style.display = "block"; + //document.getElementById("questions-eval-text").innerHTML = + // evalText[quizSrc.lang] + ":"; + let p = document.getElementById("questions-eval-percentage"); + p.innerHTML = + maxScore == 0 + ? "" + : "" + + score + + " / " + + maxScore + + " " + + pointsText[this.quizSrc.lang] + + " " + + "

" + + Math.round((score / maxScore) * 100) + + " %"; + } +} + +/** + * @param {number} seconds + * @returns {string} + */ +function formatTime(seconds) { + let mins = Math.floor(seconds / 60); + let secs = seconds % 60; + return mins + ":" + ("" + secs).padStart(2, "0"); } diff --git a/web/src/input.js b/web/src/input.js index e9f8e73..6303079 100644 --- a/web/src/input.js +++ b/web/src/input.js @@ -58,6 +58,7 @@ export class GapInput { let input = genInputField(width); question.gapInputs[this.inputId] = input; input.addEventListener("keyup", () => { + if (question.editingEnabled == false) return; this.question.editedQuestion(); input.value = input.value.toUpperCase(); this.question.student[this.inputId] = input.value.trim(); @@ -120,21 +121,32 @@ export class TermInput { this.outerSpan.appendChild(this.equationPreviewDiv); // events this.inputElement.addEventListener("click", () => { + if (question.editingEnabled == false) return; // mark the question as altered this.question.editedQuestion(); this.edited(); }); this.inputElement.addEventListener("keyup", () => { + if (question.editingEnabled == false) return; // mark the question as altered this.question.editedQuestion(); this.edited(); }); + + this.inputElement.addEventListener("focus", () => { + if (question.editingEnabled == false) return; + }); + this.inputElement.addEventListener("focusout", () => { // hide the TeX preview in case that the focus to the input was lost this.equationPreviewDiv.innerHTML = ""; this.equationPreviewDiv.style.display = "none"; }); this.inputElement.addEventListener("keydown", (e) => { + if (question.editingEnabled == false) { + e.preventDefault(); + return; + } // forbid special characters let allowed = "abcdefghijklmnopqrstuvwxyz"; allowed += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; diff --git a/web/src/lang.js b/web/src/lang.js index 8128df2..64dfbaf 100644 --- a/web/src/lang.js +++ b/web/src/lang.js @@ -9,7 +9,7 @@ */ export let courseInfo1 = { - en: "This page runs in your browser and does not store any data on servers.", + en: "This page operates entirely in your browser and does not store any data on external servers.", de: "Diese Seite wird in Ihrem Browser ausgeführt und speichert keine Daten auf Servern.", es: "Esta página se ejecuta en su navegador y no almacena ningún dato en los servidores.", it: "Questa pagina viene eseguita nel browser e non memorizza alcun dato sui server.", @@ -17,7 +17,7 @@ export let courseInfo1 = { }; export let courseInfo2 = { - en: "You can * this page in order to get new randomized tasks.", + en: "* this page to receive a new set of randomized tasks.", de: "Sie können diese Seite *, um neue randomisierte Aufgaben zu erhalten.", es: "Puedes * esta página para obtener nuevas tareas aleatorias.", it: "È possibile * questa pagina per ottenere nuovi compiti randomizzati", @@ -25,7 +25,7 @@ export let courseInfo2 = { }; export let courseInfo3 = { - en: "reload", + en: "Refresh", de: "aktualisieren", es: "recargar", it: "ricaricare", @@ -41,7 +41,7 @@ export let feedbackOK = { }; export let feedbackIncomplete = { - en: ["fill all fields"], + en: ["please complete all fields"], de: ["bitte alles ausfüllen"], es: ["por favor, rellene todo"], it: ["compilare tutto"], @@ -55,3 +55,35 @@ export let feedbackErr = { it: ["riprova", "ancora qualche errore", "risposta sbagliata"], fr: ["réessayer", "encore des erreurs", "mauvaise réponse"], }; + +export let pointsText = { + en: "point(s)", + de: "Punkt(e)", + es: "punto(s)", + it: "punto/i", + fr: "point(s)", +}; + +export let evalNowText = { + en: "Evaluate now", + de: "Jetzt auswerten", + es: "Evaluar ahora", + it: "Valuta ora", + fr: "Évaluer maintenant", +}; + +export let dataPolicy = { + en: "Data Policy: This website does not collect, store, or process any personal data on external servers. All functionality is executed locally in your browser, ensuring complete privacy. No cookies are used, and no data is transmitted to or from the server. Your activity on this site remains entirely private and local to your device.", + de: "Datenschutzrichtlinie: Diese Website sammelt, speichert oder verarbeitet keine personenbezogenen Daten auf externen Servern. Alle Funktionen werden lokal in Ihrem Browser ausgeführt, um vollständige Privatsphäre zu gewährleisten. Es werden keine Cookies verwendet, und es werden keine Daten an den Server gesendet oder von diesem empfangen. Ihre Aktivität auf dieser Seite bleibt vollständig privat und lokal auf Ihrem Gerät.", + es: "Política de datos: Este sitio web no recopila, almacena ni procesa ningún dato personal en servidores externos. Toda la funcionalidad se ejecuta localmente en su navegador, garantizando una privacidad completa. No se utilizan cookies y no se transmiten datos hacia o desde el servidor. Su actividad en este sitio permanece completamente privada y local en su dispositivo.", + it: "Politica sui dati: Questo sito web non raccoglie, memorizza o elabora alcun dato personale su server esterni. Tutte le funzionalità vengono eseguite localmente nel tuo browser, garantendo una privacy completa. Non vengono utilizzati cookie e nessun dato viene trasmesso da o verso il server. La tua attività su questo sito rimane completamente privata e locale sul tuo dispositivo.", + fr: "Politique de confidentialité: Ce site web ne collecte, ne stocke ni ne traite aucune donnée personnelle sur des serveurs externes. Toutes les fonctionnalités sont exécutées localement dans votre navigateur, garantissant une confidentialité totale. Aucun cookie n’est utilisé et aucune donnée n’est transmise vers ou depuis le serveur. Votre activité sur ce site reste entièrement privée et locale sur votre appareil.", +}; + +export let timerInfoText = { + en: "You have a limited time to complete this quiz. The countdown, displayed in minutes, is visible at the top-left of the screen. When you're ready to begin, simply press the Start button.", + de: "Die Zeit für dieses Quiz ist begrenzt. Der Countdown, in Minuten angezeigt, läuft oben links auf dem Bildschirm. Mit dem Start-Button beginnt das Quiz.", + es: "Tienes un tiempo limitado para completar este cuestionario. La cuenta regresiva, mostrada en minutos, se encuentra en la parte superior izquierda de la pantalla. Cuando estés listo, simplemente presiona el botón de inicio.", + it: "Hai un tempo limitato per completare questo quiz. Il conto alla rovescia, visualizzato in minuti, è visibile in alto a sinistra dello schermo. Quando sei pronto, premi semplicemente il pulsante Start.", + fr: "Vous disposez d’un temps limité pour compléter ce quiz. Le compte à rebours, affiché en minutes, est visible en haut à gauche de l’écran. Lorsque vous êtes prêt, appuyez simplement sur le bouton Démarrer.", +}; diff --git a/web/src/math.js b/web/src/math.js index 29b6ecb..085f638 100644 --- a/web/src/math.js +++ b/web/src/math.js @@ -1125,8 +1125,7 @@ export class TermNode { case "sinc": case "sinh": case "tan": - case "tanh": - case "ln": { + case "tanh": { let u = this.c[0].toTexString(true); // operand w/o parentheses! s += "\\" + this.op + "\\left(" + u + "\\right)"; break; diff --git a/web/src/question.js b/web/src/question.js index eeb3851..aa06a00 100644 --- a/web/src/question.js +++ b/web/src/question.js @@ -12,7 +12,6 @@ import { genButton, genDiv, - genInputField, genLi, genMathSpan, genSpan, @@ -98,6 +97,8 @@ export class Question { this.numChecked = 0; /** @type {boolean} -- true, iff the question as a check button */ this.hasCheckButton = true; + /** @type {boolean} */ + this.editingEnabled = true; } /** @@ -141,40 +142,35 @@ export class Question { let color2 = "transparent"; switch (this.state) { case QuestionState.init: - case QuestionState.incomplete: - color1 = "rgb(0,0,0)"; - color2 = "transparent"; + color1 = "black"; break; case QuestionState.passed: - color1 = "rgb(0,150,0)"; - color2 = "rgba(0,150,0, 0.025)"; + color1 = "var(--green)"; + color2 = "rgba(0,150,0, 0.035)"; break; + case QuestionState.incomplete: case QuestionState.errors: - color1 = "rgb(150,0,0)"; - color2 = "rgba(150,0,0, 0.025)"; + color1 = "var(--red)"; + color2 = "rgba(150,0,0, 0.035)"; if (this.includesSingleChoice == false && this.numChecked >= 5) { // TODO: support this feedback, if there are answer fields beyond // single-choice. Currently, each single-choice option increments // this.numChecked; so scoring feedback is turned off. this.feedbackSpan.innerHTML = - "" + this.numCorrect + " / " + this.numChecked; + "  " + this.numCorrect + " / " + this.numChecked; } break; } - this.questionDiv.style.color = - this.feedbackSpan.style.color = - this.titleDiv.style.color = - this.checkAndRepeatBtn.style.backgroundColor = - this.questionDiv.style.borderColor = - color1; this.questionDiv.style.backgroundColor = color2; + this.questionDiv.style.borderColor = color1; } /** * Generate the DOM of the question. + * @param {boolean} [hideCheckBtn=false] * @returns {void} */ - populateDom() { + populateDom(hideCheckBtn = false) { this.parentDiv.innerHTML = ""; // generate question div this.questionDiv = genDiv(); @@ -220,10 +216,13 @@ export class Question { // generate question text for (let c of this.src.text.c) this.questionDiv.appendChild(this.generateText(c)); + // generate button row let buttonDiv = genDiv(); + buttonDiv.innerHTML = ""; + buttonDiv.classList.add("button-group"); this.questionDiv.appendChild(buttonDiv); - buttonDiv.classList.add("buttonRow"); + // (a) check button this.hasCheckButton = Object.keys(this.expected).length > 0; if (this.hasCheckButton) { @@ -231,13 +230,16 @@ export class Question { buttonDiv.appendChild(this.checkAndRepeatBtn); this.checkAndRepeatBtn.innerHTML = iconCheck; this.checkAndRepeatBtn.style.backgroundColor = "black"; + if (hideCheckBtn) { + this.checkAndRepeatBtn.style.height = "0"; + this.checkAndRepeatBtn.style.visibility = "hidden"; + } } - // (c) spacing - let space = genSpan("   "); - buttonDiv.appendChild(space); - // (d) feedback text + // (c) feedback text this.feedbackSpan = genSpan(""); + this.feedbackSpan.style.userSelect = "none"; buttonDiv.appendChild(this.feedbackSpan); + // debug text (variables, python src, text src) if (this.debug) { if (this.src.variables.length > 0) { @@ -269,6 +271,7 @@ export class Question { } varDiv.innerHTML = html; } + // syntax highlighted source code let sources = ["python_src_html", "text_src_html"]; let titles = ["Python Source Code", "Text Source Code"]; @@ -288,11 +291,13 @@ export class Question { } } } + // evaluation if (this.hasCheckButton) { this.checkAndRepeatBtn.addEventListener("click", () => { if (this.state == QuestionState.passed) { this.state = QuestionState.init; + this.editingEnabled = true; this.reset(); this.populateDom(); } else { @@ -390,6 +395,7 @@ export class Question { node.t == "span" || spanInsteadParagraph ? "span" : "p" ); for (let c of node.c) e.appendChild(this.generateText(c)); + e.style.userSelect = "none"; return e; } case "text": { @@ -565,6 +571,7 @@ export class Question { if (mc) { // multi-choice tr.addEventListener("click", () => { + if (this.editingEnabled == false) return; this.editedQuestion(); this.student[answerId] = this.student[answerId] === "true" ? "false" : "true"; @@ -575,6 +582,7 @@ export class Question { } else { // single-choice tr.addEventListener("click", () => { + if (this.editingEnabled == false) return; this.editedQuestion(); for (let id of answerIDs) this.student[id] = "false"; this.student[answerId] = "true";