Merge branch 'main' into fix/bottom-position-of-logos-in-responsive-mode
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.exam-start,
|
||||
.exam-summary {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.exam-beta {
|
||||
top: -1.5em;
|
||||
}
|
||||
|
||||
.exam-simulator-page .fiftyohm-layout-outer-head {
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
.exam-simulator-page .fiftyohm-layout-outer-content {
|
||||
margin-top: calc(50px + var(--exam-header-height, 100px));
|
||||
}
|
||||
|
||||
.exam-toolbar {
|
||||
position: sticky;
|
||||
top: var(--exam-header-height, 100px);
|
||||
z-index: 1020;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.exam-paper-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
align-items: start;
|
||||
gap: 4ex;
|
||||
}
|
||||
|
||||
.exam-answer-sheet {
|
||||
position: sticky;
|
||||
top: calc(var(--exam-header-height, 100px) + 85px);
|
||||
}
|
||||
|
||||
.answer-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.answer-table th,
|
||||
.answer-table td {
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid #212529;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.answer-table tr > :first-child {
|
||||
width: 20%;
|
||||
border-right-width: 2px;
|
||||
}
|
||||
|
||||
.answer-number,
|
||||
.answer-mark {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
color: #212529;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.answer-number:hover,
|
||||
.answer-mark:not(:disabled):hover {
|
||||
background: #d2f3ff;
|
||||
}
|
||||
|
||||
.answer-mark:disabled {
|
||||
color: #212529;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.answer-mark-symbol {
|
||||
display: inline-block;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.answer-mark-restored {
|
||||
border-bottom: 3px double currentColor;
|
||||
}
|
||||
|
||||
.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 !important;
|
||||
}
|
||||
|
||||
.answer-table td.answer-cell-wrong,
|
||||
.answer-table td.answer-cell-wrong .answer-mark {
|
||||
background-color: #fe756c !important;
|
||||
}
|
||||
|
||||
.exam-question {
|
||||
scroll-margin-top: calc(var(--exam-header-height, 100px) + 85px);
|
||||
}
|
||||
|
||||
.exam-question:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.exam-questions.course .question,
|
||||
.exam-questions.course .solution,
|
||||
.exam-questions.course .katex-display {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.exam-question .answers button::before {
|
||||
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.exam-answer-selected {
|
||||
background: #d2f3ff;
|
||||
}
|
||||
|
||||
.exam-question .answers button:disabled {
|
||||
color: #212529;
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.exam-toolbar {
|
||||
top: var(--exam-header-height, 64px);
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.exam-paper-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.exam-answer-sheet {
|
||||
width: min(100%, 400px);
|
||||
position: static;
|
||||
order: -1;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.exam-question {
|
||||
scroll-margin-top: calc(var(--exam-header-height, 64px) + 130px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var STORAGE_KEY = "50ohm-exam-simulator-v1";
|
||||
var STORAGE_VERSION = 3;
|
||||
var QUESTION_COUNT = 25;
|
||||
var ANSWER_LABELS = ["A", "B", "C", "D"];
|
||||
|
||||
var PART_DEFINITIONS = {
|
||||
B: { label: "Betriebliche Kenntnisse", minutes: 45 },
|
||||
V: { label: "Kenntnisse von Vorschriften", minutes: 45 },
|
||||
N: { label: "Technische Kenntnisse Klasse N", minutes: 45 },
|
||||
E: { label: "Technische Kenntnisse Klasse E", minutes: 45 },
|
||||
A: { label: "Technische Kenntnisse Klasse A", minutes: 60 }
|
||||
};
|
||||
|
||||
var EXAM_CHOICES = [
|
||||
{ id: "N", group: "primary", label: "Klasse N", detail: "B + V + N", parts: ["B", "V", "N"], color: "#47ABE8" },
|
||||
{ id: "E", group: "primary", label: "Klasse E", detail: "B + V + N + E", parts: ["B", "V", "N", "E"], color: "#FE756C" },
|
||||
{ id: "A", group: "primary", label: "Klasse A", detail: "B + V + N + E + A", parts: ["B", "V", "N", "E", "A"], color: "#3BB583" },
|
||||
{ id: "N-E", group: "upgrade", label: "N → E", detail: "Technik E", parts: ["E"], color: "#FE756C" },
|
||||
{ id: "N-A", group: "upgrade", label: "N → A", detail: "Technik E + A", parts: ["E", "A"], color: "#3BB583" },
|
||||
{ id: "E-A", group: "upgrade", label: "E → A", detail: "Technik A", parts: ["A"], color: "#3BB583" },
|
||||
{ id: "part-B", group: "single", label: "B", detail: "Betriebliche Kenntnisse", parts: ["B"], color: "#47ABE8" },
|
||||
{ id: "part-V", group: "single", label: "V", detail: "Kenntnisse von Vorschriften", parts: ["V"], color: "#47ABE8" },
|
||||
{ id: "part-N", group: "single", label: "N", detail: "Technik Klasse N", parts: ["N"], color: "#47ABE8" },
|
||||
{ id: "part-E", group: "single", label: "E", detail: "Technik Klasse E", parts: ["E"], color: "#FE756C" },
|
||||
{ id: "part-A", group: "single", label: "A", detail: "Technik Klasse A", parts: ["A"], color: "#3BB583" }
|
||||
];
|
||||
|
||||
function normalizeCatalog(rawCatalog, metadata, questionIndex) {
|
||||
var questions = {};
|
||||
var partForSection = { 1: "B", 2: "V" };
|
||||
var partForClass = { "1": "N", "2": "E", "3": "A" };
|
||||
|
||||
function pictureId(value) {
|
||||
return value ? String(value) : "";
|
||||
}
|
||||
|
||||
function walk(section, sectionIndex, category) {
|
||||
(section.questions || []).forEach(function (question) {
|
||||
var part = sectionIndex === 0
|
||||
? partForClass[String(question.class)]
|
||||
: partForSection[sectionIndex];
|
||||
if (!part) return;
|
||||
|
||||
var questionMetadata = metadata[question.number] || {};
|
||||
questions[question.number] = {
|
||||
id: question.number,
|
||||
part: part,
|
||||
category: category.slice(),
|
||||
question: question.question,
|
||||
layout: questionMetadata.layout || "default",
|
||||
picture: pictureId(questionMetadata.picture_question),
|
||||
answers: ["a", "b", "c", "d"].map(function (letter) {
|
||||
return {
|
||||
text: question["answer_" + letter] || "",
|
||||
picture: pictureId(questionMetadata["picture_" + letter])
|
||||
};
|
||||
}),
|
||||
hasSolution: Boolean(questionIndex[question.number] && questionIndex[question.number].has_solution)
|
||||
};
|
||||
});
|
||||
|
||||
(section.sections || []).forEach(function (child, childIndex) {
|
||||
walk(child, sectionIndex, category.concat(childIndex));
|
||||
});
|
||||
}
|
||||
|
||||
(rawCatalog.sections || []).forEach(function (section, sectionIndex) {
|
||||
walk(section, sectionIndex, []);
|
||||
});
|
||||
return questions;
|
||||
}
|
||||
|
||||
function shuffled(values) {
|
||||
var result = values.slice();
|
||||
for (var i = result.length - 1; i > 0; i -= 1) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var tmp = result[i];
|
||||
result[i] = result[j];
|
||||
result[j] = tmp;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildCategoryTree(pool) {
|
||||
var root = { children: new Map(), items: [], size: 0 };
|
||||
pool.forEach(function (item) {
|
||||
var node = root;
|
||||
node.size += 1;
|
||||
(item.category || []).forEach(function (categoryPart) {
|
||||
if (!node.children.has(categoryPart)) {
|
||||
node.children.set(categoryPart, { children: new Map(), items: [], size: 0 });
|
||||
}
|
||||
node = node.children.get(categoryPart);
|
||||
node.size += 1;
|
||||
});
|
||||
node.items.push(item);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function allocateQuotas(children, quota, totalSize) {
|
||||
var allocations = children.map(function (child) {
|
||||
var target = quota * child.size / totalSize;
|
||||
return { child: child, quota: Math.floor(target), fraction: target - Math.floor(target) };
|
||||
});
|
||||
var assigned = allocations.reduce(function (sum, allocation) { return sum + allocation.quota; }, 0);
|
||||
|
||||
while (assigned < quota) {
|
||||
var candidates = allocations.filter(function (allocation) {
|
||||
return allocation.quota < allocation.child.size;
|
||||
});
|
||||
var weight = candidates.reduce(function (sum, allocation) { return sum + allocation.fraction; }, 0);
|
||||
var selected;
|
||||
if (weight <= 0) {
|
||||
selected = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
} else {
|
||||
var draw = Math.random() * weight;
|
||||
selected = candidates[candidates.length - 1];
|
||||
for (var i = 0; i < candidates.length; i += 1) {
|
||||
draw -= candidates[i].fraction;
|
||||
if (draw <= 0) {
|
||||
selected = candidates[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.quota += 1;
|
||||
selected.fraction = 0;
|
||||
assigned += 1;
|
||||
}
|
||||
return allocations;
|
||||
}
|
||||
|
||||
function pickFromTree(node, quota) {
|
||||
if (quota <= 0) return [];
|
||||
if (node.children.size === 0) return shuffled(node.items).slice(0, quota);
|
||||
var allocations = allocateQuotas(Array.from(node.children.values()), quota, node.size);
|
||||
var result = [];
|
||||
allocations.forEach(function (allocation) {
|
||||
result = result.concat(pickFromTree(allocation.child, allocation.quota));
|
||||
});
|
||||
return shuffled(result);
|
||||
}
|
||||
|
||||
function balancedSample(pool, count) {
|
||||
if (!Array.isArray(pool) || pool.length < count) {
|
||||
throw new Error("Für diesen Prüfungsteil stehen nicht genügend Fragen zur Verfügung.");
|
||||
}
|
||||
return pickFromTree(buildCategoryTree(pool), count);
|
||||
}
|
||||
|
||||
function renderMathSoon(VueApi) {
|
||||
VueApi.nextTick(function () {
|
||||
if (typeof window.renderFiftyOhmMath !== "function") return;
|
||||
window.renderFiftyOhmMath(document.getElementById("exam-simulator"));
|
||||
});
|
||||
}
|
||||
|
||||
var app = Vue.createApp({
|
||||
setup: function () {
|
||||
var ref = Vue.ref;
|
||||
var computed = Vue.computed;
|
||||
var watch = Vue.watch;
|
||||
var onMounted = Vue.onMounted;
|
||||
var onBeforeUnmount = Vue.onBeforeUnmount;
|
||||
|
||||
var loading = ref(true);
|
||||
var loadError = ref("");
|
||||
var catalog = ref({});
|
||||
var altTexts = Vue.reactive({});
|
||||
var requestedAltTexts = new Set();
|
||||
var session = ref(null);
|
||||
var view = ref("exam");
|
||||
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"; });
|
||||
});
|
||||
var upgradeChoices = computed(function () {
|
||||
return EXAM_CHOICES.filter(function (choice) { return choice.group === "upgrade"; });
|
||||
});
|
||||
var singlePartChoices = computed(function () {
|
||||
return EXAM_CHOICES.filter(function (choice) { return choice.group === "single"; });
|
||||
});
|
||||
var currentChoice = computed(function () {
|
||||
if (!session.value) return null;
|
||||
return EXAM_CHOICES.find(function (choice) { return choice.id === session.value.choiceId; });
|
||||
});
|
||||
var displayedPartIndex = computed(function () {
|
||||
return reviewPartIndex.value === null ? session.value.currentPart : reviewPartIndex.value;
|
||||
});
|
||||
var currentPart = computed(function () {
|
||||
if (!session.value) return null;
|
||||
return session.value.parts[displayedPartIndex.value];
|
||||
});
|
||||
var currentPartDefinition = computed(function () {
|
||||
if (!currentPart.value) return null;
|
||||
return PART_DEFINITIONS[currentPart.value.code];
|
||||
});
|
||||
var remainingSeconds = computed(function () {
|
||||
if (!currentPart.value || currentPart.value.status !== "running") return 0;
|
||||
if (currentPart.value.paused) return currentPart.value.pausedRemainingSeconds;
|
||||
return Math.max(0, Math.ceil((currentPart.value.deadline - now.value) / 1000));
|
||||
});
|
||||
var formattedTime = computed(function () {
|
||||
var minutes = Math.floor(remainingSeconds.value / 60);
|
||||
var seconds = remainingSeconds.value % 60;
|
||||
return String(minutes).padStart(2, "0") + ":" + String(seconds).padStart(2, "0");
|
||||
});
|
||||
var answeredCount = computed(function () {
|
||||
if (!currentPart.value) return 0;
|
||||
return currentPart.value.questions.filter(function (question) { return question.selected !== null; }).length;
|
||||
});
|
||||
var hasNextPart = computed(function () {
|
||||
if (!session.value) return false;
|
||||
return session.value.currentPart < session.value.parts.length - 1;
|
||||
});
|
||||
var isReviewing = computed(function () { return reviewPartIndex.value !== null; });
|
||||
|
||||
function evaluatedParts() {
|
||||
return session.value.parts.filter(function (part) { return part.status === "evaluated"; });
|
||||
}
|
||||
|
||||
var overallState = computed(function () {
|
||||
if (session.value.parts.some(function (part) { return part.status !== "evaluated"; })) return "incomplete";
|
||||
var failed = session.value.parts.filter(function (part) { return part.score < 19; });
|
||||
if (failed.length === 0) return "passed";
|
||||
if (failed.length === 1 && failed[0].score >= 17) return "oral";
|
||||
return "failed";
|
||||
});
|
||||
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];
|
||||
});
|
||||
var overallTitle = computed(function () {
|
||||
return { passed: "Prüfung bestanden", oral: "Mündliche Nachprüfung möglich", failed: "Prüfung nicht bestanden", incomplete: "Prüfung vorzeitig beendet" }[overallState.value];
|
||||
});
|
||||
var overallText = computed(function () {
|
||||
if (overallState.value === "passed") return "Gratulation! Du hast jeden Prüfungsteil mit mindestens 19 Punkten bestanden.";
|
||||
if (overallState.value === "oral") return "In genau einem Prüfungsteil wurden 17 oder 18 Punkte erreicht; nach den Prüfungsregeln kann eine mündliche Nachprüfung möglich sein.";
|
||||
if (overallState.value === "failed") return "Mindestens ein Prüfungsteil wurde nicht bestanden.";
|
||||
return evaluatedParts().length + " von " + session.value.parts.length + " Prüfungsteilen wurden abgeschlossen.";
|
||||
});
|
||||
|
||||
function questionData(questionState) {
|
||||
return catalog.value[questionState.id];
|
||||
}
|
||||
|
||||
function padNumber(number) {
|
||||
return String(number).padStart(2, "0");
|
||||
}
|
||||
|
||||
function pictureAlt(picture, fallback) {
|
||||
return altTexts[picture] || fallback;
|
||||
}
|
||||
|
||||
function loadAltText(picture) {
|
||||
if (!picture || requestedAltTexts.has(picture)) return;
|
||||
requestedAltTexts.add(picture);
|
||||
fetch("pictures/" + encodeURIComponent(picture) + ".txt")
|
||||
.then(function (response) {
|
||||
if (!response.ok) return "";
|
||||
return response.text();
|
||||
})
|
||||
.then(function (description) {
|
||||
if (description && description.trim()) altTexts[picture] = description.trim();
|
||||
})
|
||||
.catch(function () { /* the generic alternative text remains available */ });
|
||||
}
|
||||
|
||||
function loadPartAltTexts(part) {
|
||||
if (!part) return;
|
||||
part.questions.forEach(function (questionState) {
|
||||
var question = questionData(questionState);
|
||||
if (!question) return;
|
||||
loadAltText(question.picture);
|
||||
question.answers.forEach(function (answer) { loadAltText(answer.picture); });
|
||||
});
|
||||
}
|
||||
|
||||
function createPart(code) {
|
||||
var picked = balancedSample(Object.values(catalog.value).filter(function (question) {
|
||||
return question.part === code;
|
||||
}), QUESTION_COUNT);
|
||||
return {
|
||||
code: code,
|
||||
status: "pending",
|
||||
deadline: null,
|
||||
paused: false,
|
||||
pausedRemainingSeconds: null,
|
||||
score: null,
|
||||
autoSubmitted: false,
|
||||
questions: picked.map(function (question) {
|
||||
return { id: question.id, order: shuffled([0, 1, 2, 3]), selected: null, history: [] };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function activatePart(part) {
|
||||
part.status = "running";
|
||||
part.deadline = Date.now() + PART_DEFINITIONS[part.code].minutes * 60 * 1000;
|
||||
part.paused = false;
|
||||
part.pausedRemainingSeconds = null;
|
||||
now.value = Date.now();
|
||||
}
|
||||
|
||||
function toggleTimer() {
|
||||
var part = currentPart.value;
|
||||
if (!part || part.status !== "running") return;
|
||||
if (part.paused) {
|
||||
part.deadline = Date.now() + part.pausedRemainingSeconds * 1000;
|
||||
part.pausedRemainingSeconds = null;
|
||||
part.paused = false;
|
||||
now.value = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
var seconds = remainingSeconds.value;
|
||||
if (seconds <= 0) {
|
||||
submitPart(true);
|
||||
return;
|
||||
}
|
||||
part.pausedRemainingSeconds = seconds;
|
||||
part.paused = true;
|
||||
part.deadline = null;
|
||||
}
|
||||
|
||||
function startExam(choice) {
|
||||
try {
|
||||
var parts = choice.parts.map(createPart);
|
||||
activatePart(parts[0]);
|
||||
session.value = {
|
||||
version: STORAGE_VERSION,
|
||||
choiceId: choice.id,
|
||||
currentPart: 0,
|
||||
startedAt: Date.now(),
|
||||
parts: parts
|
||||
};
|
||||
view.value = "exam";
|
||||
reviewPartIndex.value = null;
|
||||
renderMathSoon(Vue);
|
||||
} catch (error) {
|
||||
loadError.value = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function selectAnswer(questionIndex, answerIndex) {
|
||||
if (currentPart.value.status !== "running" || currentPart.value.paused) 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) {
|
||||
var question = currentPart.value.questions[questionIndex];
|
||||
if (question.selected === answerIndex) {
|
||||
var restored = question.history.slice(0, -1).indexOf(answerIndex) !== -1;
|
||||
return '<span class="answer-mark-symbol' + (restored ? " answer-mark-restored" : "") + '">×</span>';
|
||||
}
|
||||
if (question.history.indexOf(answerIndex) !== -1) {
|
||||
return '<span class="answer-mark-symbol">=</span>';
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function answerCellClass(questionIndex, answerIndex) {
|
||||
if (currentPart.value.status === "running") return "";
|
||||
var question = currentPart.value.questions[questionIndex];
|
||||
var sourceIndex = question.order[answerIndex];
|
||||
if (question.selected === answerIndex && sourceIndex === 0) return "answer-cell-correct";
|
||||
if (question.selected === answerIndex) return "answer-cell-wrong";
|
||||
if (sourceIndex === 0) return "answer-cell-solution";
|
||||
return "";
|
||||
}
|
||||
|
||||
function questionAnswerClass(questionIndex, answerIndex) {
|
||||
var question = currentPart.value.questions[questionIndex];
|
||||
if (currentPart.value.status === "running") {
|
||||
return question.selected === answerIndex ? "exam-answer-selected" : "";
|
||||
}
|
||||
var sourceIndex = question.order[answerIndex];
|
||||
if (sourceIndex === 0) return "correct";
|
||||
if (question.selected === answerIndex) return "wrong";
|
||||
return "";
|
||||
}
|
||||
|
||||
function submitPart(autoSubmitted) {
|
||||
if (!session.value || currentPart.value.status !== "running") return;
|
||||
var score = currentPart.value.questions.reduce(function (sum, question) {
|
||||
return sum + (question.selected !== null && question.order[question.selected] === 0 ? 1 : 0);
|
||||
}, 0);
|
||||
currentPart.value.score = score;
|
||||
currentPart.value.status = "evaluated";
|
||||
currentPart.value.deadline = null;
|
||||
currentPart.value.paused = false;
|
||||
currentPart.value.pausedRemainingSeconds = null;
|
||||
currentPart.value.autoSubmitted = Boolean(autoSubmitted);
|
||||
renderMathSoon(Vue);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function requestSubmit() {
|
||||
var unanswered = QUESTION_COUNT - answeredCount.value;
|
||||
var text = unanswered > 0
|
||||
? "Du hast " + unanswered + " Fragen nicht beantwortet. Möchtest du den Prüfungsteil trotzdem abgeben?"
|
||||
: "Möchtest du diesen Prüfungsteil jetzt verbindlich abgeben?";
|
||||
if (window.confirm(text)) submitPart(false);
|
||||
}
|
||||
|
||||
function startNextPart() {
|
||||
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" });
|
||||
}
|
||||
|
||||
function finishExamEarly() {
|
||||
if (!window.confirm("Möchtest du die Prüfung wirklich hier beenden? Nicht bearbeitete Teile gelten dann als nicht abgeschlossen.")) return;
|
||||
showSummary();
|
||||
}
|
||||
|
||||
function showSummary() {
|
||||
reviewPartIndex.value = null;
|
||||
view.value = "summary";
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function reviewPart(index) {
|
||||
reviewPartIndex.value = index;
|
||||
view.value = "exam";
|
||||
renderMathSoon(Vue);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function clearStoredSession() {
|
||||
try { window.localStorage.removeItem(STORAGE_KEY); } catch (error) { /* storage can be unavailable */ }
|
||||
}
|
||||
|
||||
function resetExam() {
|
||||
clearStoredSession();
|
||||
session.value = null;
|
||||
view.value = "exam";
|
||||
reviewPartIndex.value = null;
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function abortExam() {
|
||||
if (!window.confirm("Möchtest du diese Simulation abbrechen? Der gespeicherte Prüfungsstand wird gelöscht.")) return;
|
||||
resetExam();
|
||||
}
|
||||
|
||||
function newExam() {
|
||||
resetExam();
|
||||
}
|
||||
|
||||
function scrollToQuestion(index) {
|
||||
var element = document.getElementById("exam-question-" + index);
|
||||
if (element) element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function resultLabel(part) {
|
||||
if (part.score >= 19) return "Bestanden";
|
||||
if (part.score >= 17) return "Nachprüfung möglich";
|
||||
return "Nicht bestanden";
|
||||
}
|
||||
|
||||
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) {
|
||||
if (part.score >= 19) return "check_circle";
|
||||
if (part.score >= 17) return "record_voice_over";
|
||||
return "cancel";
|
||||
}
|
||||
|
||||
function resultDescription(part) {
|
||||
var prefix = part.autoSubmitted ? "Die Zeit ist abgelaufen und der Prüfungsteil wurde automatisch abgegeben. " : "";
|
||||
if (part.score >= 19) return prefix + "Die erforderlichen 19 Punkte wurden erreicht.";
|
||||
if (part.score >= 17) return prefix + "Mit 17 oder 18 Punkten kann nach den Prüfungsregeln eine mündliche Nachprüfung möglich sein.";
|
||||
return prefix + "Die erforderlichen 19 Punkte wurden nicht erreicht.";
|
||||
}
|
||||
|
||||
function restoreSession() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
var stored = JSON.parse(raw);
|
||||
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) {
|
||||
return Boolean(catalog.value[question.id]);
|
||||
});
|
||||
});
|
||||
if (!valid) {
|
||||
clearStoredSession();
|
||||
return;
|
||||
}
|
||||
stored.session.parts.forEach(function (part) {
|
||||
part.paused = Boolean(part.paused);
|
||||
if (!part.paused) part.pausedRemainingSeconds = null;
|
||||
});
|
||||
session.value = stored.session;
|
||||
view.value = stored.view === "summary" ? "summary" : "exam";
|
||||
if (session.value.parts[session.value.currentPart].status === "running" &&
|
||||
!session.value.parts[session.value.currentPart].paused &&
|
||||
session.value.parts[session.value.currentPart].deadline <= Date.now()) {
|
||||
submitPart(true);
|
||||
}
|
||||
} catch (error) {
|
||||
clearStoredSession();
|
||||
}
|
||||
}
|
||||
|
||||
watch([session, view], function () {
|
||||
if (!session.value) return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
version: STORAGE_VERSION,
|
||||
session: session.value,
|
||||
view: view.value
|
||||
}));
|
||||
} catch (error) { /* simulator remains usable without persistence */ }
|
||||
}, { deep: true });
|
||||
|
||||
watch(currentPart, function (part) {
|
||||
loadPartAltTexts(part);
|
||||
});
|
||||
|
||||
watch(remainingSeconds, function (seconds) {
|
||||
if (session.value && currentPart.value && currentPart.value.status === "running" &&
|
||||
!currentPart.value.paused && seconds <= 0) submitPart(true);
|
||||
});
|
||||
|
||||
function loadCatalog() {
|
||||
Promise.all([
|
||||
fetch("assets/fragenkatalog.json"),
|
||||
fetch("assets/metadata.json"),
|
||||
fetch("assets/question_index.json")
|
||||
])
|
||||
.then(function (responses) {
|
||||
var names = ["Fragenkatalog", "Metadaten", "Fragenindex"];
|
||||
responses.forEach(function (response, index) {
|
||||
if (!response.ok) throw new Error(names[index] + " nicht gefunden (HTTP " + response.status + ").");
|
||||
});
|
||||
return Promise.all(responses.map(function (response) { return response.json(); }));
|
||||
})
|
||||
.then(function (data) {
|
||||
catalog.value = normalizeCatalog(data[0], data[1] || {}, data[2] || {});
|
||||
if (Object.keys(catalog.value).length === 0) throw new Error("Der Fragenkatalog ist leer.");
|
||||
restoreSession();
|
||||
loading.value = false;
|
||||
renderMathSoon(Vue);
|
||||
})
|
||||
.catch(function (error) {
|
||||
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 {
|
||||
loading: loading,
|
||||
loadError: loadError,
|
||||
session: session,
|
||||
view: view,
|
||||
primaryChoices: primaryChoices,
|
||||
upgradeChoices: upgradeChoices,
|
||||
singlePartChoices: singlePartChoices,
|
||||
currentChoice: currentChoice,
|
||||
currentPart: currentPart,
|
||||
currentPartDefinition: currentPartDefinition,
|
||||
displayedPartIndex: displayedPartIndex,
|
||||
partDefinitions: PART_DEFINITIONS,
|
||||
answerLabels: ANSWER_LABELS,
|
||||
remainingSeconds: remainingSeconds,
|
||||
formattedTime: formattedTime,
|
||||
toggleTimer: toggleTimer,
|
||||
answeredCount: answeredCount,
|
||||
hasNextPart: hasNextPart,
|
||||
isReviewing: isReviewing,
|
||||
overallAlertClass: overallAlertClass,
|
||||
overallState: overallState,
|
||||
overallIcon: overallIcon,
|
||||
overallTitle: overallTitle,
|
||||
overallText: overallText,
|
||||
padNumber: padNumber,
|
||||
pictureAlt: pictureAlt,
|
||||
questionData: questionData,
|
||||
startExam: startExam,
|
||||
selectAnswer: selectAnswer,
|
||||
answerMark: answerMark,
|
||||
answerCellClass: answerCellClass,
|
||||
questionAnswerClass: questionAnswerClass,
|
||||
requestSubmit: requestSubmit,
|
||||
startNextPart: startNextPart,
|
||||
finishExamEarly: finishExamEarly,
|
||||
showSummary: showSummary,
|
||||
reviewPart: reviewPart,
|
||||
abortExam: abortExam,
|
||||
newExam: newExam,
|
||||
scrollToQuestion: scrollToQuestion,
|
||||
resultLabel: resultLabel,
|
||||
resultAlertClass: resultAlertClass,
|
||||
resultBadgeClass: resultBadgeClass,
|
||||
resultIcon: resultIcon,
|
||||
resultDescription: resultDescription
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.mount("#exam-simulator");
|
||||
})();
|
||||
+14
-10
@@ -150,15 +150,19 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
"\\sps": "\\text{sps}",
|
||||
};
|
||||
|
||||
renderMathInElement(document.body, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true }, // Note: $$ has to come before $
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: "\\(", right: "\\)", display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
macros: macros
|
||||
});
|
||||
window.renderFiftyOhmMath = function(root) {
|
||||
renderMathInElement(root || document.body, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true }, // Note: $$ has to come before $
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: "\\(", right: "\\)", display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
macros: macros
|
||||
});
|
||||
};
|
||||
|
||||
window.renderFiftyOhmMath(document.body);
|
||||
|
||||
});
|
||||
|
||||
@@ -227,6 +227,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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user