diff --git a/assets/exam-simulator.css b/assets/exam-simulator.css index 0eac88d..18336b9 100644 --- a/assets/exam-simulator.css +++ b/assets/exam-simulator.css @@ -2,267 +2,62 @@ display: none; } -.exam-simulator { - --exam-blue: #47abe8; - --exam-coral: #fe756c; - --exam-green: #3bb583; - --exam-ink: #20252a; - color: var(--exam-ink); +.exam-start, +.exam-summary { + max-width: 900px; } -.exam-loading { - min-height: 45vh; - display: flex; - align-items: center; - justify-content: center; - gap: 1rem; - font-size: 1.1rem; +.exam-simulator-page .fiftyohm-layout-outer-head { + width: 100%; + position: fixed; + top: 0; + z-index: 1030; } -.exam-kicker { - margin: 0 0 0.25rem; - color: #0068a5; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.exam-start { - max-width: 1050px; - margin: 1.5rem auto 4rem; -} - -.exam-choice-groups { - display: grid; - gap: 2.5rem; - margin-top: 3rem; -} - -.exam-choice-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1rem; -} - -.exam-choice { - min-height: 155px; - padding: 1.4rem; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - color: #fff; - text-align: left; - background: var(--exam-choice-color); - border: 3px solid var(--exam-choice-color); - border-radius: 0.65rem; - box-shadow: 0 0.35rem 1rem rgb(0 0 0 / 10%); - transition: transform 120ms ease, box-shadow 120ms ease; -} - -.exam-choice:hover, -.exam-choice:focus-visible { - color: #fff; - transform: translateY(-3px); - box-shadow: 0 0.55rem 1.4rem rgb(0 0 0 / 18%); -} - -.exam-choice:focus-visible { - outline: 3px solid #20252a; - outline-offset: 3px; -} - -.exam-choice strong { - font-size: 2rem; - line-height: 1; -} - -.exam-choice span { - margin-top: 0.55rem; - font-size: 1.05rem; - font-weight: 700; -} - -.exam-choice small { - margin-top: 0.3rem; - opacity: 0.9; -} - -.exam-start-note { - margin-top: 2.5rem; - padding: 1.2rem 1.4rem; - background: #eef8fd; - border-left: 5px solid #00adef; -} - -.exam-session { - width: min(1560px, calc(100vw - 2rem)); - position: relative; - left: 50%; - transform: translateX(-50%); +.exam-simulator-page .fiftyohm-layout-outer-content { + margin-top: calc(50px + var(--exam-header-height, 100px)); } .exam-toolbar { - margin: 1rem 0 1.25rem; - padding: 1rem 1.25rem; + position: sticky; + top: var(--exam-header-height, 100px); + z-index: 1020; display: flex; align-items: center; justify-content: space-between; - gap: 1.5rem; - background: #fff; - border: 1px solid #c9d1d7; - border-top: 6px solid #00adef; - box-shadow: 0 0.25rem 0.8rem rgb(0 0 0 / 7%); -} - -.exam-toolbar h1 { - margin: 0; - font-size: clamp(1.35rem, 2vw, 2rem); -} - -.exam-progress-label { - margin-top: 0.25rem; - color: #606a73; -} - -.exam-toolbar-actions { - display: flex; - align-items: center; gap: 1rem; } -.exam-timer { - min-width: 135px; - padding: 0.55rem 0.8rem; - display: flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - color: #fff; - background: #20252a; - border-radius: 0.35rem; - font-size: 1.35rem; - font-variant-numeric: tabular-nums; - font-weight: 700; -} - -.exam-timer-warning { - background: #b42d25; - animation: exam-timer-pulse 1.5s ease-in-out infinite; -} - -.exam-timer-finished { - background: #5c636a; - font-size: 1rem; -} - -@keyframes exam-timer-pulse { - 50% { box-shadow: 0 0 0 5px rgb(254 117 108 / 30%); } -} - -.exam-part-result, -.exam-overall { - margin: 0 0 1.25rem; - padding: 1rem 1.25rem; - display: flex; - align-items: center; - gap: 0.8rem; - border: 2px solid; - border-radius: 0.4rem; -} - -.exam-part-result > .material-icons, -.exam-overall > .material-icons { - font-size: 2rem; -} - -.result-passed { - background: #e2f5ed; - border-color: #3bb583; -} - -.result-oral { - background: #fff4d5; - border-color: #e6a700; -} - -.result-failed, -.result-incomplete { - background: #ffe5e3; - border-color: #fe756c; -} - .exam-paper-layout { display: grid; - grid-template-columns: 360px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) 300px; align-items: start; - gap: 1.5rem; + gap: 4ex; } .exam-answer-sheet { position: sticky; - top: 0.75rem; -} - -.answer-sheet-paper { - padding: 1rem; - background: #fff; - border: 2px solid #20252a; - box-shadow: 0 0.35rem 1rem rgb(0 0 0 / 10%); -} - -.answer-sheet-paper h2 { - margin: 0 0 0.8rem; - text-align: center; - font-size: 1.45rem; -} - -.answer-sheet-part { - padding: 0.6rem 0; - display: grid; - border-top: 2px solid #20252a; - border-bottom: 2px solid #20252a; -} - -.answer-sheet-part span, -.answer-sheet-part small { - font-size: 0.78rem; -} - -.answer-sheet-part strong { - line-height: 1.2; -} - -.answer-sheet-hint { - margin: 0.6rem 0; - font-size: 0.76rem; - line-height: 1.25; - text-align: left; + top: calc(var(--exam-header-height, 100px) + 85px); } .answer-table { width: 100%; - border-collapse: collapse; table-layout: fixed; - font-size: 0.82rem; + border-collapse: collapse; + font-size: 0.78rem; } .answer-table th, .answer-table td { - height: 27px; + height: 22px; padding: 0; - border: 1px solid #20252a; + border: 1px solid #212529; text-align: center; } -.answer-table thead th, -.answer-table tbody th { - font-weight: 800; -} - .answer-table tr > :first-child { - width: 18%; - border-right-width: 3px; + width: 20%; + border-right-width: 2px; } .answer-number, @@ -270,220 +65,103 @@ width: 100%; height: 100%; padding: 0; - color: #20252a; + color: #212529; background: transparent; border: 0; } .answer-number:hover, -.answer-number:focus-visible, -.answer-mark:not(:disabled):hover, -.answer-mark:not(:disabled):focus-visible { +.answer-mark:not(:disabled):hover { background: #d2f3ff; - outline: 2px solid #0068a5; - outline-offset: -2px; } .answer-mark:disabled { - color: #20252a; + color: #212529; opacity: 1; } .answer-mark-symbol { display: inline-block; - min-width: 1rem; - font-size: 1.05rem; - font-weight: 900; + font-size: 1rem; + font-weight: 700; line-height: 1; } .answer-mark-restored { - padding-bottom: 1px; border-bottom: 3px double currentColor; } -.answer-cell-correct { - background: #bfe8d6; +.answer-table td.answer-cell-correct, +.answer-table td.answer-cell-solution, +.answer-table td.answer-cell-correct .answer-mark, +.answer-table td.answer-cell-solution .answer-mark { + background-color: #3bb583; } -.answer-cell-wrong { - background: #ffc9c5; +.answer-table td.answer-cell-wrong, +.answer-table td.answer-cell-wrong .answer-mark { + background-color: #fe756c; } -.answer-cell-solution { - box-shadow: inset 0 0 0 2px #168a5d; +.exam-question { + scroll-margin-top: calc(var(--exam-header-height, 100px) + 85px); } -.answer-sheet-count { - margin-top: 0.6rem; - text-align: right; - font-size: 0.8rem; +.exam-question:first-child { + margin-top: 0; } -.exam-questions { - min-width: 0; -} - -.exam-question.question { - scroll-margin-top: 1rem; - margin: 0 0 1.25rem; - padding: 1rem; - border-left: 5px solid #7c8790; -} - -.exam-question .answers button { - display: block; - min-height: 48px; - border: 2px solid transparent; +.exam-questions.course .question, +.exam-questions.course .solution, +.exam-questions.course .katex-display { + width: 100%; } .exam-question .answers button::before { - content: none; + margin-right: 0.25em; +} + +.exam-toolbar .btn-sm { + min-height: 34px; +} + +.exam-toolbar .btn-secondary { + --bs-btn-color: #212529; + --bs-btn-hover-color: #212529; + --bs-btn-active-color: #212529; + --bs-btn-disabled-color: #212529; } -.exam-question .answers button:not(:disabled):hover, -.exam-question .answers button:not(:disabled):focus-visible, .exam-question .answers button.exam-answer-selected { - border-color: #0068a5; background: #d2f3ff; } .exam-question .answers button:disabled { + color: #212529; cursor: default; opacity: 1; - color: #20252a; } -.exam-question .answers button.correct { - background: #3bb583; - border-color: #168a5d; -} - -.exam-question .answers button.wrong { - background: #fe756c; - border-color: #c5423a; -} - -.exam-answer-label { - margin-right: 0.35rem; -} - -.exam-solution-icon { - margin-right: 0.3rem; - font-size: 1.15rem; -} - -.exam-submit-panel { - margin: 2rem 0 4rem; - padding: 1.5rem; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 1rem; - background: #eef2f5; - border: 1px solid #c9d1d7; -} - -.exam-submit-panel p { - flex-basis: 100%; - margin: 0; -} - -.exam-summary { - max-width: 920px; - margin: 2rem auto 4rem; -} - -.exam-result-list { - margin: 2rem 0; - display: grid; - gap: 0.75rem; -} - -.exam-result-card { - padding: 0.9rem 1rem; - display: grid; - grid-template-columns: minmax(220px, 1fr) auto minmax(150px, auto) auto; - align-items: center; - gap: 1rem; - border: 1px solid #c9d1d7; - border-left: 5px solid #00adef; -} - -.exam-result-card > div { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.exam-result-code { - width: 2.2rem; - height: 2.2rem; - display: inline-grid; - place-items: center; - color: #fff; - background: #20252a; - border-radius: 50%; - font-weight: 800; -} - -.exam-score { - font-variant-numeric: tabular-nums; -} - -.exam-status { - padding: 0.2rem 0.5rem; - border-radius: 0.25rem; - font-size: 0.82rem; - font-weight: 700; -} - -@media (max-width: 1050px) { - .exam-paper-layout { - grid-template-columns: 310px minmax(0, 1fr); - gap: 1rem; - } -} - -@media (max-width: 800px) { - .exam-choice-grid { - grid-template-columns: 1fr; - } - - .exam-choice { - min-height: 120px; - } - - .exam-toolbar, - .exam-toolbar-actions { - align-items: stretch; +@media (max-width: 940px) { + .exam-toolbar { + top: var(--exam-header-height, 64px); + align-items: flex-start; flex-direction: column; } - .exam-timer { - align-self: flex-start; - } - .exam-paper-layout { display: flex; flex-direction: column; } .exam-answer-sheet { - width: 100%; + width: min(100%, 400px); position: static; + order: -1; + align-self: center; } - .answer-sheet-paper { - max-width: 430px; - margin: 0 auto; - } - - .exam-result-card { - grid-template-columns: 1fr auto; - } - - .exam-result-card .exam-status { - grid-column: 1; + .exam-question { + scroll-margin-top: calc(var(--exam-header-height, 64px) + 130px); } } diff --git a/assets/exam-simulator.js b/assets/exam-simulator.js index aca9cd5..3fe6019 100644 --- a/assets/exam-simulator.js +++ b/assets/exam-simulator.js @@ -2,7 +2,7 @@ "use strict"; var STORAGE_KEY = "50ohm-exam-simulator-v1"; - var STORAGE_VERSION = 1; + var STORAGE_VERSION = 3; var QUESTION_COUNT = 25; var ANSWER_LABELS = ["A", "B", "C", "D"]; @@ -104,9 +104,8 @@ function renderMathSoon(VueApi) { VueApi.nextTick(function () { - if (typeof window.renderFiftyOhmMath === "function") { - window.renderFiftyOhmMath(document.getElementById("exam-simulator")); - } + if (typeof window.renderFiftyOhmMath !== "function") return; + window.renderFiftyOhmMath(document.getElementById("exam-simulator")); }); } @@ -126,6 +125,8 @@ var reviewPartIndex = ref(null); var now = ref(Date.now()); var timerId = null; + var catalogLoadHandler = null; + var headerObserver = null; var primaryChoices = computed(function () { return EXAM_CHOICES.filter(function (choice) { return choice.group === "primary"; }); @@ -178,7 +179,9 @@ if (failed.length === 1 && failed[0].score >= 17) return "oral"; return "failed"; }); - var overallClass = computed(function () { return "result-" + overallState.value; }); + var overallAlertClass = computed(function () { + return { passed: "alert-success", oral: "alert-warning", failed: "alert-danger", incomplete: "alert-danger" }[overallState.value]; + }); var overallIcon = computed(function () { return { passed: "verified", oral: "record_voice_over", failed: "cancel", incomplete: "stop_circle" }[overallState.value]; }); @@ -249,8 +252,12 @@ if (currentPart.value.status !== "running") return; var question = currentPart.value.questions[questionIndex]; if (question.selected === answerIndex) return; + var firstAnswer = question.selected === null; question.selected = answerIndex; question.history.push(answerIndex); + if (firstAnswer && questionIndex < currentPart.value.questions.length - 1) { + window.setTimeout(function () { scrollToQuestion(questionIndex + 1); }, 150); + } } function answerMark(questionIndex, answerIndex) { @@ -308,10 +315,16 @@ } function startNextPart() { - if (!hasNextPart.value) return; - session.value.currentPart += 1; - activatePart(session.value.parts[session.value.currentPart]); + var nextIndex = session.value.currentPart + 1; + var nextPart = session.value.parts[nextIndex]; + if (!nextPart) { + showSummary(); + return; + } + session.value.currentPart = nextIndex; reviewPartIndex.value = null; + view.value = "exam"; + activatePart(nextPart); renderMathSoon(Vue); window.scrollTo({ top: 0, behavior: "smooth" }); } @@ -339,7 +352,7 @@ } function abortExam() { - if (!window.confirm("Möchtest du diese Probeprüfung abbrechen? Der gespeicherte Prüfungsstand wird gelöscht.")) return; + if (!window.confirm("Möchtest du diese Simulation abbrechen? Der gespeicherte Prüfungsstand wird gelöscht.")) return; clearStoredSession(); session.value = null; view.value = "exam"; @@ -366,10 +379,16 @@ return "Nicht bestanden"; } - function resultClass(part) { - if (part.score >= 19) return "result-passed"; - if (part.score >= 17) return "result-oral"; - return "result-failed"; + function resultAlertClass(part) { + if (part.score >= 19) return "alert-success"; + if (part.score >= 17) return "alert-warning"; + return "alert-danger"; + } + + function resultBadgeClass(part) { + if (part.score >= 19) return "text-bg-success"; + if (part.score >= 17) return "text-bg-warning"; + return "text-bg-danger"; } function resultIcon(part) { @@ -390,7 +409,10 @@ var raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return; var stored = JSON.parse(raw); - if (!stored || stored.version !== STORAGE_VERSION || !stored.session) return; + if (!stored || stored.version !== STORAGE_VERSION || !stored.session) { + clearStoredSession(); + return; + } var choice = EXAM_CHOICES.find(function (item) { return item.id === stored.session.choiceId; }); var valid = choice && stored.session.parts.every(function (part) { return Array.isArray(part.questions) && part.questions.length === QUESTION_COUNT && part.questions.every(function (question) { @@ -427,7 +449,7 @@ if (session.value && currentPart.value && currentPart.value.status === "running" && seconds <= 0) submitPart(true); }); - onMounted(function () { + function loadCatalog() { fetch("assets/exam-questions.json") .then(function (response) { if (!response.ok) throw new Error("Fragenkatalog nicht gefunden (HTTP " + response.status + ")."); @@ -444,11 +466,36 @@ loadError.value = error.message || "Unbekannter Ladefehler"; loading.value = false; }); + } + + function updateHeaderHeight() { + var header = document.querySelector(".fiftyohm-layout-outer-head"); + if (!header) return; + document.documentElement.style.setProperty("--exam-header-height", header.offsetHeight + "px"); + } + + onMounted(function () { + document.body.classList.add("exam-simulator-page"); + updateHeaderHeight(); + if (typeof window.ResizeObserver === "function") { + headerObserver = new window.ResizeObserver(updateHeaderHeight); + headerObserver.observe(document.querySelector(".fiftyohm-layout-outer-head")); + } + if (document.readyState === "loading") { + catalogLoadHandler = function () { window.setTimeout(loadCatalog, 0); }; + document.addEventListener("DOMContentLoaded", catalogLoadHandler, { once: true }); + } else { + loadCatalog(); + } timerId = window.setInterval(function () { now.value = Date.now(); }, 1000); }); onBeforeUnmount(function () { if (timerId !== null) window.clearInterval(timerId); + if (catalogLoadHandler !== null) document.removeEventListener("DOMContentLoaded", catalogLoadHandler); + if (headerObserver !== null) headerObserver.disconnect(); + document.documentElement.style.removeProperty("--exam-header-height"); + document.body.classList.remove("exam-simulator-page"); }); return { @@ -469,7 +516,7 @@ answeredCount: answeredCount, hasNextPart: hasNextPart, isReviewing: isReviewing, - overallClass: overallClass, + overallAlertClass: overallAlertClass, overallIcon: overallIcon, overallTitle: overallTitle, overallText: overallText, @@ -490,7 +537,8 @@ newExam: newExam, scrollToQuestion: scrollToQuestion, resultLabel: resultLabel, - resultClass: resultClass, + resultAlertClass: resultAlertClass, + resultBadgeClass: resultBadgeClass, resultIcon: resultIcon, resultDescription: resultDescription }; diff --git a/assets/script.js b/assets/script.js index 2c07254..68b1870 100644 --- a/assets/script.js +++ b/assets/script.js @@ -5,7 +5,7 @@ function shuffleChildren(el) { } document.addEventListener("DOMContentLoaded", function() { - var els = document.querySelectorAll(".answers:not([data-no-shuffle])"); + var els = document.querySelectorAll(".answers"); for (var i = 0; i < els.length; i++) { shuffleChildren(els[i]); } diff --git a/assets/style-fiftyohm.css b/assets/style-fiftyohm.css index 344664e..d5aa4c6 100644 --- a/assets/style-fiftyohm.css +++ b/assets/style-fiftyohm.css @@ -190,6 +190,12 @@ ul.fiftyohm-navbar { display: flex; } +@media (min-width: 940px) { + ul.fiftyohm-navbar { + gap: 1rem; + } +} + .fiftyohm-navbar li { list-style-type: none; margin: 2ex 0; diff --git a/src/build.py b/src/build.py index 4bbf350..57a0a61 100644 --- a/src/build.py +++ b/src/build.py @@ -131,9 +131,102 @@ class Build: def __build_question_slide(self, input): return self.__build_question(input, template_file="slide/question.html") - def __build_page(self, content, course_wrapper=False, sidebar=None): + def __build_page( + self, + content, + course_wrapper=False, + sidebar=None, + page_stylesheets=None, + page_scripts=None, + ): page_template = self.env.get_template("html/page.html") - return page_template.render(content=content, course_wrapper=course_wrapper, sidebar=sidebar) + return page_template.render( + content=content, + course_wrapper=course_wrapper, + sidebar=sidebar, + page_stylesheets=page_stylesheets or [], + page_scripts=page_scripts or [], + ) + + @staticmethod + def __walk_exam_questions(section, category=()): + for question in section.get("questions", []): + yield category, question + for index, child in enumerate(section.get("sections", [])): + yield from Build.__walk_exam_questions(child, (*category, index)) + + def __build_exam_catalog(self): + """Create the compact client-side catalog used by the exam simulator.""" + with ( + self.config.p_data_fragenkatalog.open(encoding="utf-8") as catalog_file, + self.config.p_data_metadata.open(encoding="utf-8") as metadata_file, + ): + catalog = json.load(catalog_file) + metadata = json.load(metadata_file) + + part_for_section = {1: "B", 2: "V"} + part_for_class = {"1": "N", "2": "E", "3": "A"} + questions = {} + + def picture_data(picture_id): + if not picture_id: + return {"picture": "", "pictureAlt": ""} + return { + "picture": picture_id, + "pictureAlt": self.__picture_handler(picture_id) or "Bildbeschreibung noch nicht verfügbar", + } + + for section_index, section in enumerate(catalog["sections"]): + for category, question in self.__walk_exam_questions(section): + if section_index == 0: + part = part_for_class.get(str(question.get("class"))) + else: + part = part_for_section.get(section_index) + if part is None: + continue + + question_metadata = metadata.get(question["number"], {}) + question_picture = picture_data(question_metadata.get("picture_question", "")) + answers = [] + for answer_letter in "abcd": + answer_picture = picture_data(question_metadata.get(f"picture_{answer_letter}", "")) + answers.append( + { + "text": question.get(f"answer_{answer_letter}", ""), + **answer_picture, + } + ) + + questions[question["number"]] = { + "id": question["number"], + "part": part, + "category": list(category), + "question": question["question"], + "layout": question_metadata.get("layout", "") or "default", + "answers": answers, + "hasSolution": self.__has_solution(question["number"]), + **question_picture, + } + + self.config.p_build_assets.mkdir(parents=True, exist_ok=True) + with (self.config.p_build_assets / "exam-questions.json").open("w", encoding="utf-8") as file: + json.dump({"version": 1, "questions": questions}, file, ensure_ascii=False, separators=(",", ":")) + file.write("\n") + + def __build_exam_simulator(self): + self.__build_exam_catalog() + exam_template = self.env.get_template("html/exam-simulator.html") + content = exam_template.render() + page = self.__build_page( + content, + page_stylesheets=["assets/exam-simulator.css"], + page_scripts=["assets/vue.global.prod.js", "assets/exam-simulator.js"], + ) + legacy_path = self.config.p_build / "probeprüfung.html" + if legacy_path.exists(): + legacy_path.unlink() + with (self.config.p_build / "simulation.html").open("w", encoding="utf-8") as file: + file.write(page) def __copy_picture(self, id): file = f"{id}.svg" @@ -509,6 +602,7 @@ class Build: self.__build_html_page(contents, "pruefung") self.__build_html_page(contents, "infos") self.__build_html_page(contents, "suche") + self.__build_exam_simulator() def build_solutions(self): for solution_file in self.config.p_data_solutions.glob("*.md"): diff --git a/templates/html/exam-simulator.html b/templates/html/exam-simulator.html index 975a391..9c7cb0a 100644 --- a/templates/html/exam-simulator.html +++ b/templates/html/exam-simulator.html @@ -1,118 +1,170 @@
Probeprüfung
-Wähle die Prüfung, die du unter möglichst realistischen Bedingungen üben möchtest.
-Gesamtergebnis
- -| Teil | Ergebnis | Status | ||||
|---|---|---|---|---|---|---|
| + + | /25 | ++ | + + + | Nicht bearbeitet | + +||