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");
|
||||
})();
|
||||
+5
-1
@@ -150,7 +150,8 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
"\\sps": "\\text{sps}",
|
||||
};
|
||||
|
||||
renderMathInElement(document.body, {
|
||||
window.renderFiftyOhmMath = function(root) {
|
||||
renderMathInElement(root || document.body, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true }, // Note: $$ has to come before $
|
||||
{ left: '$', right: '$', display: false },
|
||||
@@ -160,5 +161,8 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
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
+61
-28
@@ -24,7 +24,11 @@ class Build:
|
||||
|
||||
self.env = Environment(loader=FileSystemLoader(self.config.p_templates))
|
||||
self.env.filters["shuffle_answers"] = self.__filter_shuffle_answers
|
||||
self.questions = self.__parse_katalog()
|
||||
with self.config.p_data_fragenkatalog.open(encoding="utf-8") as file:
|
||||
self.fragenkatalog = json.load(file)
|
||||
with self.config.p_data_metadata.open(encoding="utf-8") as file:
|
||||
self.question_metadata = json.load(file)
|
||||
self.questions = self.__parse_katalog(self.fragenkatalog)
|
||||
|
||||
self.question_index = {}
|
||||
self.question_token_pattern = re.compile(r"^\s*\[question:([\w\d]+)\]", re.MULTILINE)
|
||||
@@ -32,22 +36,18 @@ class Build:
|
||||
self.index_entries = {}
|
||||
self.index_token_pattern = Index.pattern
|
||||
|
||||
def __parse_katalog(self):
|
||||
with self.config.p_data_fragenkatalog.open(encoding="utf-8") as file:
|
||||
fragenkatalog = json.load(file)
|
||||
|
||||
@staticmethod
|
||||
def __parse_katalog(fragenkatalog):
|
||||
questions = {}
|
||||
|
||||
for exampart in fragenkatalog["sections"]:
|
||||
for chapter in exampart["sections"]:
|
||||
if "questions" in chapter:
|
||||
for question in chapter["questions"]:
|
||||
questions[question["number"]] = question
|
||||
if "sections" in chapter:
|
||||
for section in chapter["sections"]:
|
||||
for question in section["questions"]:
|
||||
def collect(section):
|
||||
for question in section.get("questions", []):
|
||||
questions[question["number"]] = question
|
||||
for child in section.get("sections", []):
|
||||
collect(child)
|
||||
|
||||
for exam_part in fragenkatalog["sections"]:
|
||||
collect(exam_part)
|
||||
return questions
|
||||
|
||||
def __build_question(self, number, template_file="html/question.html"):
|
||||
@@ -55,17 +55,8 @@ class Build:
|
||||
|
||||
question_template = self.env.get_template(template_file)
|
||||
|
||||
with (self.config.p_data_metadata).open(encoding="utf-8") as file:
|
||||
metadata_json = json.load(file)
|
||||
|
||||
question = None
|
||||
metadata = None
|
||||
|
||||
if number in self.questions:
|
||||
question = self.questions[number]
|
||||
|
||||
if number in metadata_json:
|
||||
metadata = metadata_json[number]
|
||||
question = self.questions.get(number)
|
||||
metadata = self.question_metadata.get(number)
|
||||
|
||||
if question is None or metadata is None:
|
||||
tqdm.write(
|
||||
@@ -131,16 +122,57 @@ 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 [],
|
||||
)
|
||||
|
||||
def __build_exam_simulator(self):
|
||||
self.config.p_build_assets.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(self.config.p_data_fragenkatalog, self.config.p_build_assets / "fragenkatalog.json")
|
||||
shutil.copyfile(self.config.p_data_metadata, self.config.p_build_assets / "metadata.json")
|
||||
(self.config.p_build_assets / "exam-questions.json").unlink(missing_ok=True)
|
||||
|
||||
picture_fields = ("picture_question", "picture_a", "picture_b", "picture_c", "picture_d")
|
||||
picture_ids = {
|
||||
metadata[field]
|
||||
for metadata in self.question_metadata.values()
|
||||
for field in picture_fields
|
||||
if metadata.get(field)
|
||||
}
|
||||
for picture_id in picture_ids:
|
||||
self.__picture_handler(picture_id)
|
||||
|
||||
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?ts=202608051415"],
|
||||
page_scripts=["assets/vue.global.prod.js", "assets/exam-simulator.js?ts=202608051500"],
|
||||
)
|
||||
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"
|
||||
try:
|
||||
shutil.copyfile(self.config.p_data_pictures / file, self.config.p_build_pictures / file)
|
||||
if (self.config.p_data_pictures / f"{id}.txt").exists():
|
||||
return (self.config.p_data_pictures / f"{id}.txt").read_text(encoding="utf-8")
|
||||
description_source = self.config.p_data_pictures / f"{id}.txt"
|
||||
if description_source.exists():
|
||||
shutil.copyfile(description_source, self.config.p_build_pictures / description_source.name)
|
||||
return description_source.read_text(encoding="utf-8")
|
||||
else:
|
||||
return "Bildbeschreibung noch nicht verfügbar"
|
||||
except FileNotFoundError:
|
||||
@@ -509,6 +541,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"):
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<div id="exam-simulator" class="exam-simulator" v-cloak>
|
||||
<div v-if="loading" class="d-flex align-items-center justify-content-center gap-3 py-5" role="status">
|
||||
<div class="spinner-border text-primary" aria-hidden="true"></div>
|
||||
<span>Prüfungsfragen werden geladen …</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="alert alert-danger" role="alert">
|
||||
<h1 class="h4">Die Prüfungssimulation konnte nicht geladen werden</h1>
|
||||
<p class="mb-0" v-text="loadError"></p>
|
||||
</div>
|
||||
|
||||
<section v-else-if="!session" class="mx-auto mb-5 exam-start" aria-labelledby="exam-heading">
|
||||
<h1 id="exam-heading">Prüfungssimulation <sup class="exam-beta fs-6">BETA</sup></h1>
|
||||
<p class="lead">Welche Prüfung möchtest du simulieren?</p>
|
||||
|
||||
<h2 class="h4 mt-5">Erstprüfung</h2>
|
||||
<p class="mb-2">Du hast noch keine Amateurfunkprüfung abgelegt? Dann ist das die richtige Simulation für Dich.</p>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-2">
|
||||
<div v-for="choice in primaryChoices" :key="choice.id" class="col">
|
||||
<button type="button" class="btn w-100 h-100 py-3 text-white"
|
||||
:style="{ backgroundColor: choice.color, borderColor: choice.color }" @click="startExam(choice)">
|
||||
<span class="d-block" v-text="choice.label"></span>
|
||||
<small class="d-block" v-text="choice.detail"></small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="h4 mt-4">Aufstockungsprüfung</h2>
|
||||
<p class="mb-2">Du hast bereits eine Zulassung zum Amateurfunkdienst und möchtest in eine höhere Klasse aufstocken? Dann wähle hier die passende Prüfung.</p>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-2">
|
||||
<div v-for="choice in upgradeChoices" :key="choice.id" class="col">
|
||||
<button type="button" class="btn w-100 h-100 py-3 text-white"
|
||||
:style="{ backgroundColor: choice.color, borderColor: choice.color }" @click="startExam(choice)">
|
||||
<span class="d-block" v-text="choice.label"></span>
|
||||
<small class="d-block" v-text="choice.detail"></small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="h4 mt-4">Einzelne Prüfungsteile</h2>
|
||||
<p class="mb-2">Übe gezielt einen Prüfungsteil unter Prüfungsbedingungen.</p>
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-5 g-2">
|
||||
<div v-for="choice in singlePartChoices" :key="choice.id" class="col">
|
||||
<button type="button" class="btn w-100 h-100 py-3 text-white"
|
||||
:style="{ backgroundColor: choice.color, borderColor: choice.color }" @click="startExam(choice)">
|
||||
<span class="d-block" v-text="choice.label"></span>
|
||||
<small class="d-block" v-text="choice.detail"></small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-primary mt-4 mb-0">
|
||||
Jeder Prüfungsteil enthält 25 Fragen. Für B, V, N und E stehen jeweils 45 Minuten zur Verfügung,
|
||||
für Technik A 60 Minuten. Ein Teil ist ab 19 Punkten bestanden.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="view === 'summary'" class="mx-auto mb-5 exam-summary" aria-labelledby="summary-heading">
|
||||
<h1 id="summary-heading">Gesamtergebnis</h1>
|
||||
<p class="lead" v-text="currentChoice.label"></p>
|
||||
<div class="alert d-flex align-items-center gap-2" :class="overallAlertClass">
|
||||
<span class="material-icons" aria-hidden="true" v-text="overallIcon"></span>
|
||||
<div>
|
||||
<strong v-text="overallTitle"></strong>
|
||||
<div v-text="overallText"></div>
|
||||
<p v-if="overallState === 'failed'" class="mt-2 mb-0">
|
||||
Hol dir Unterstützung bei erfahrenen Ausbilderinnen und Ausbildern – zum Beispiel im
|
||||
<a href="https://matrix.to/#/#ausbildung:darc.de" target="_blank" rel="noopener">DARC-Chat</a>, in unseren
|
||||
<a href="https://treff.darc.de/d/#/gastlink/jbogyos6" target="_blank" rel="noopener">Fragestunden auf treff.darc.de</a>
|
||||
oder bei einem Ortsverband in deiner Nähe. Alle Angebote findest du auf der
|
||||
<a href="index.html">Startseite von 50ohm.de</a>.
|
||||
</p>
|
||||
<template v-else-if="overallState === 'passed'">
|
||||
<p class="mt-2 mb-0">
|
||||
Du hast schon viel gelernt. Wenn du dich bereit fühlst, kannst du dich bei der Bundesnetzagentur zur
|
||||
Amateurfunkprüfung anmelden. Alle Informationen dazu findest du unter
|
||||
<a href="pruefung.html">Prüfung</a>.
|
||||
</p>
|
||||
<p class="mt-2 mb-0">
|
||||
Wenn dir 50ohm.de geholfen hat, kannst du unsere Arbeit unterstützen, indem du Mitglied im DARC wirst.
|
||||
Hier geht es zum
|
||||
<a href="https://antrag.darc.de/antrag/schritt-1" target="_blank" rel="noopener">digitalen Mitgliedsantrag</a>.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead><tr><th>Teil</th><th>Ergebnis</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(part, index) in session.parts" :key="part.code">
|
||||
<td><b v-text="part.code + ' – ' + partDefinitions[part.code].label"></b></td>
|
||||
<template v-if="part.status === 'evaluated'">
|
||||
<td><b v-text="part.score"></b>/25</td>
|
||||
<td><span class="badge" :class="resultBadgeClass(part)" v-text="resultLabel(part)"></span></td>
|
||||
<td><button type="button" class="btn btn-outline-dark btn-sm" @click="reviewPart(index)">Auswertung</button></td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td colspan="3" class="text-secondary">Nicht bearbeitet</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" @click="newExam">Neue Simulation</button>
|
||||
</section>
|
||||
|
||||
<section v-else class="exam-session" aria-labelledby="part-heading">
|
||||
<header class="exam-toolbar bg-white border-bottom py-2 mb-3">
|
||||
<div>
|
||||
<small class="text-secondary" v-text="currentChoice.label + ' · Teil ' + (displayedPartIndex + 1) + ' von ' + session.parts.length"></small>
|
||||
<h1 id="part-heading" class="h4 mb-0" v-text="currentPartDefinition.label"></h1>
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<a class="btn btn-secondary btn-sm d-inline-flex align-items-center" target="_blank" href="/hm"
|
||||
title="Hilfsmittel herunterladen" aria-label="Hilfsmittel herunterladen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 640 640" fill="currentColor" aria-hidden="true">
|
||||
<path d="M192 112L304 112L304 200C304 239.8 336.2 272 376 272L464 272L464 512C464 520.8 456.8 528 448 528L192 528C183.2 528 176 520.8 176 512L176 128C176 119.2 183.2 112 192 112zM352 131.9L444.1 224L376 224C362.7 224 352 213.3 352 200L352 131.9zM192 64C156.7 64 128 92.7 128 128L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 250.5C512 233.5 505.3 217.2 493.3 205.2L370.7 82.7C358.7 70.7 342.5 64 325.5 64L192 64zM248 320C234.7 320 224 330.7 224 344C224 357.3 234.7 368 248 368L392 368C405.3 368 416 357.3 416 344C416 330.7 405.3 320 392 320L248 320zM248 416C234.7 416 224 426.7 224 440C224 453.3 234.7 464 248 464L392 464C405.3 464 416 453.3 416 440C416 426.7 405.3 416 392 416L248 416z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button v-if="currentPart.status === 'running'" type="button"
|
||||
class="btn btn-sm d-inline-flex align-items-center"
|
||||
:class="currentPart.paused ? 'btn-warning' : (remainingSeconds <= 300 ? 'btn-danger' : 'btn-secondary')"
|
||||
@click="toggleTimer"
|
||||
:title="currentPart.paused ? 'Timer fortsetzen' : 'Timer pausieren'"
|
||||
:aria-label="(currentPart.paused ? 'Timer fortsetzen, verbleibende Zeit ' : 'Timer pausieren, verbleibende Zeit ') + formattedTime">
|
||||
<span class="material-icons fs-6 me-1" aria-hidden="true"
|
||||
v-text="currentPart.paused ? 'play_arrow' : 'pause'"></span>
|
||||
<span role="timer" v-text="formattedTime"></span>
|
||||
</button>
|
||||
<span v-else class="btn btn-secondary btn-sm d-inline-flex align-items-center">Abgegeben</span>
|
||||
<button v-if="currentPart.status === 'evaluated' && !isReviewing && hasNextPart"
|
||||
type="button" class="btn btn-primary btn-sm" @click="startNextPart">Nächsten Teil starten</button>
|
||||
<button v-else-if="currentPart.status === 'evaluated' && !isReviewing"
|
||||
type="button" class="btn btn-primary btn-sm" @click="showSummary">Gesamtergebnis</button>
|
||||
<button v-else-if="isReviewing" type="button" class="btn btn-primary btn-sm" @click="showSummary">Gesamtergebnis</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm d-inline-flex align-items-center" @click="abortExam">Simulation abbrechen</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="currentPart.status === 'evaluated'" class="alert d-flex align-items-center gap-2"
|
||||
:class="resultAlertClass(currentPart)">
|
||||
<span class="material-icons" aria-hidden="true" v-text="resultIcon(currentPart)"></span>
|
||||
<div>
|
||||
<strong><span v-text="currentPart.score"></span> von 25 Punkten – <span v-text="resultLabel(currentPart)"></span></strong>
|
||||
<div v-text="resultDescription(currentPart)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exam-paper-layout"
|
||||
:key="'exam-part-' + displayedPartIndex + '-' + currentPart.code + '-' + currentPart.status">
|
||||
<main class="exam-questions course">
|
||||
<template v-for="(questionState, questionIndex) in currentPart.questions" :key="questionState.id">
|
||||
<article class="question exam-question" :class="'layout-' + questionData(questionState).layout"
|
||||
:id="'exam-question-' + questionIndex">
|
||||
<div class="question-text">
|
||||
<span v-text="padNumber(questionIndex + 1) + ': '"></span><span v-html="questionData(questionState).question"></span>
|
||||
</div>
|
||||
<div v-if="questionData(questionState).picture" class="question-picture">
|
||||
<img :src="'pictures/' + questionData(questionState).picture + '.svg'"
|
||||
:alt="pictureAlt(questionData(questionState).picture, 'Abbildung zu Frage ' + questionState.id)">
|
||||
</div>
|
||||
<div class="answers">
|
||||
<button v-for="(sourceIndex, answerIndex) in questionState.order" :key="answerIndex" type="button"
|
||||
:disabled="currentPart.status !== 'running' || currentPart.paused"
|
||||
:class="questionAnswerClass(questionIndex, answerIndex)"
|
||||
@click="selectAnswer(questionIndex, answerIndex)">
|
||||
<span v-if="questionData(questionState).answers[sourceIndex].text"
|
||||
v-html="questionData(questionState).answers[sourceIndex].text"></span>
|
||||
<span v-if="questionData(questionState).answers[sourceIndex].picture" class="question-picture">
|
||||
<img :src="'pictures/' + questionData(questionState).answers[sourceIndex].picture + '.svg'"
|
||||
:alt="pictureAlt(questionData(questionState).answers[sourceIndex].picture,
|
||||
'Abbildung zu Antwort ' + answerLabels[answerIndex] + ' bei Frage ' + questionState.id)">
|
||||
</span>
|
||||
<span v-if="currentPart.status === 'evaluated' && sourceIndex === 0" class="result material-icons">check</span>
|
||||
<span v-else-if="currentPart.status === 'evaluated' && questionState.selected === answerIndex" class="result material-icons">clear</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="currentPart.status === 'evaluated' && questionData(questionState).hasSolution" class="solution">
|
||||
<a class="btn btn-warning btn-sm d-inline-flex align-items-center" :href="questionState.id + '.html'" target="_blank"
|
||||
:aria-label="'Lösungshinweis zu Frage ' + questionState.id">
|
||||
<span class="material-icons fs-6 me-1" aria-hidden="true">lightbulb</span> Lösungsweg anzeigen
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="border-top pt-3 mb-5 d-flex flex-wrap gap-2 align-items-center justify-content-end">
|
||||
<template v-if="currentPart.status === 'running'">
|
||||
<span class="me-auto"><b v-text="answeredCount"></b> von 25 Fragen beantwortet.</span>
|
||||
<button type="button" class="btn btn-primary" @click="requestSubmit">Prüfungsteil abgeben</button>
|
||||
</template>
|
||||
<template v-else-if="isReviewing">
|
||||
<button type="button" class="btn btn-primary" @click="showSummary">Zurück zum Gesamtergebnis</button>
|
||||
</template>
|
||||
<template v-else-if="hasNextPart">
|
||||
<button type="button" class="btn btn-primary" @click="startNextPart">Nächsten Prüfungsteil starten</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @click="finishExamEarly">Simulation hier beenden</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" class="btn btn-primary" @click="showSummary">Gesamtergebnis anzeigen</button>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="exam-answer-sheet" aria-label="Antwortbogen">
|
||||
<div class="border border-dark bg-white p-2">
|
||||
<h2 class="h5 text-center">Antwortbogen</h2>
|
||||
<div class="border-top border-bottom border-dark py-1 mb-2">
|
||||
<strong class="d-block" v-text="currentPartDefinition.label"></strong>
|
||||
<small><span v-text="currentPartDefinition.minutes"></span> Minuten · 25 Fragen</small>
|
||||
</div>
|
||||
<table class="answer-table">
|
||||
<thead><tr><th>Nr.</th><th v-for="label in answerLabels" :key="label" v-text="label"></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(question, questionIndex) in currentPart.questions" :key="question.id">
|
||||
<th scope="row">
|
||||
<button type="button" class="answer-number" @click="scrollToQuestion(questionIndex)"
|
||||
:aria-label="'Zu Frage ' + (questionIndex + 1)" v-text="padNumber(questionIndex + 1)"></button>
|
||||
</th>
|
||||
<td v-for="(_, answerIndex) in answerLabels" :key="answerIndex"
|
||||
:class="answerCellClass(questionIndex, answerIndex)">
|
||||
<button type="button" class="answer-mark"
|
||||
:disabled="currentPart.status !== 'running' || currentPart.paused"
|
||||
@click="selectAnswer(questionIndex, answerIndex)"
|
||||
:aria-label="'Frage ' + (questionIndex + 1) + ', Antwort ' + answerLabels[answerIndex]"
|
||||
v-html="answerMark(questionIndex, answerIndex)"></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<small class="d-block text-end mt-1"><b v-text="answeredCount"></b> von 25 beantwortet</small>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -13,9 +13,12 @@
|
||||
<link rel="stylesheet" href="assets/style-fiftyohm.css?ts=20260805">
|
||||
<link rel="stylesheet" href="assets/style-course.css?ts=202312172012">
|
||||
<link rel="stylesheet" href="assets/katex/katex.min.css">
|
||||
{% for stylesheet in page_stylesheets|default([]) %}
|
||||
<link rel="stylesheet" href="{{ stylesheet }}">
|
||||
{% endfor %}
|
||||
<script defer src="assets/katex/katex.min.js"></script>
|
||||
<script defer src="assets/katex/auto-render.min.js"></script>
|
||||
<script defer src="assets/latex.js"></script>
|
||||
<script defer src="assets/latex.js?ts=20260805"></script>
|
||||
<script defer src="assets/script.js"></script>
|
||||
<script defer src="assets/antrag/floating-cta.js"></script>
|
||||
<style>
|
||||
@@ -86,6 +89,12 @@
|
||||
Prüfung
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="simulation.html">
|
||||
<span class="material-icons">quiz</span>
|
||||
Simulation
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li>
|
||||
<a href="login.html">
|
||||
<span class="material-icons">login</span>
|
||||
@@ -188,6 +197,9 @@
|
||||
</div>
|
||||
|
||||
<script src="assets/bootstrap-5.2.0-beta1-dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% for script in page_scripts|default([]) %}
|
||||
<script src="{{ script }}"></script>
|
||||
{% endfor %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user