884 lines
39 KiB
JavaScript
884 lines
39 KiB
JavaScript
/**
|
||
* @file app.js
|
||
* @brief LPIC-Prüfungssimulator: Sitzungen, Bewertung, Speichern/Import und Darstellung mit reveal.js
|
||
* @author Björn Runden <br@runden.de>
|
||
* @owner Björn Runden <br@runden.de>
|
||
* @assist KI-unterstützt (Claude Opus 5.5), geprüft von Björn Runden <br@runden.de>
|
||
* @version 0.2.0 (29.10.2026)
|
||
*
|
||
* Bewusst klassisches Script (kein ES-Modul, kein fetch): läuft so auch direkt von file://.
|
||
* Die Fragenbank kommt verschlüsselt aus js/bank.js (window.LPIC_BANK, ChaCha20), weitere Pakete per Import.
|
||
* Reine Logik liegt in window.LPICSim.core und wird von tests/selftest.html geprüft.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
const STORE_SESSION = 'lpic_sim.session';
|
||
const STORE_PACKS = 'lpic_sim.packs';
|
||
const SESSION_FORMAT = 'lpic-sim-session';
|
||
const PACK_FORMAT = 'lpic-sim-questions';
|
||
const OPTION_IDS = ['a', 'b', 'c', 'd', 'e'];
|
||
const LETTERS = 'ABCDE';
|
||
const COUNT_WORDS = { 2: 'ZWEI', 3: 'DREI', 4: 'VIER' };
|
||
const SAVE_EVERY_S = 15;
|
||
|
||
/* ------------------------------------------------------------------ Speicher */
|
||
|
||
/** localStorage mit Fehlertoleranz (privater Modus, gesperrter Speicher). */
|
||
const store = {
|
||
get(key) {
|
||
try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : null; }
|
||
catch (e) { console.warn('localStorage nicht lesbar:', e); return null; }
|
||
},
|
||
set(key, value) {
|
||
try { localStorage.setItem(key, JSON.stringify(value)); return true; }
|
||
catch (e) { console.warn('localStorage nicht schreibbar:', e); return false; }
|
||
},
|
||
del(key) {
|
||
try { localStorage.removeItem(key); } catch (e) { console.warn('localStorage:', e); }
|
||
},
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ Hilfen */
|
||
|
||
/** Mischt eine Kopie des Arrays (Fisher-Yates). */
|
||
function shuffled(arr) {
|
||
const a = arr.slice();
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
/** Vergleicht Objective-IDs numerisch („103.10“ nach „103.9“). */
|
||
function cmpObjective(a, b) {
|
||
const [a1, a2] = a.split('.').map(Number);
|
||
const [b1, b2] = b.split('.').map(Number);
|
||
return a1 - b1 || a2 - b2;
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
/** Mini-Markup für Fragetexte: `Code`, **fett**, Zeilenumbruch. Returns: sicheres HTML. */
|
||
function fmt(text) {
|
||
return String(text ?? '').split(/(`[^`]+`)/).map((part) =>
|
||
/^`[^`]+`$/.test(part)
|
||
? `<code>${esc(part.slice(1, -1))}</code>`
|
||
: esc(part).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>')
|
||
).join('');
|
||
}
|
||
|
||
function fmtTime(sec) {
|
||
const s = Math.max(0, Math.floor(sec));
|
||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
|
||
const mm = String(m).padStart(2, '0'), ss = String(r).padStart(2, '0');
|
||
return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
||
}
|
||
|
||
/** Normalisiert Freitext: Leerraum an den Rändern weg, innen auf ein Leerzeichen. */
|
||
function normalize(s, caseSensitive) {
|
||
const n = String(s ?? '').trim().replace(/\s+/g, ' ');
|
||
return caseSensitive === false ? n.toLowerCase() : n;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ Fragenbank entschlüsseln */
|
||
|
||
/**
|
||
* ChaCha20 nach RFC 8439 (Gegenstück zu tools/build_bank.py). Eigene Implementierung, weil
|
||
* WebCrypto kein ChaCha20 kennt und über http://<LAN-IP> (kein Secure Context) ganz fehlt.
|
||
* @param {Uint8Array} key 32 Byte · @param {Uint8Array} nonce 12 Byte · @param {Uint8Array} data
|
||
* @returns {Uint8Array} ver-/entschlüsselte Daten
|
||
*/
|
||
function chacha20(key, nonce, data, counter = 1) {
|
||
const kv = new DataView(key.buffer, key.byteOffset, 32);
|
||
const nv = new DataView(nonce.buffer, nonce.byteOffset, 12);
|
||
const st = new Uint32Array(16);
|
||
st.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
|
||
for (let i = 0; i < 8; i++) st[4 + i] = kv.getUint32(i * 4, true);
|
||
for (let i = 0; i < 3; i++) st[13 + i] = nv.getUint32(i * 4, true);
|
||
const x = new Uint32Array(16);
|
||
const block = new Uint8Array(64);
|
||
const bv = new DataView(block.buffer);
|
||
const out = new Uint8Array(data.length);
|
||
const qr = (a, b, c, d) => {
|
||
x[a] += x[b]; x[d] ^= x[a]; x[d] = (x[d] << 16) | (x[d] >>> 16);
|
||
x[c] += x[d]; x[b] ^= x[c]; x[b] = (x[b] << 12) | (x[b] >>> 20);
|
||
x[a] += x[b]; x[d] ^= x[a]; x[d] = (x[d] << 8) | (x[d] >>> 24);
|
||
x[c] += x[d]; x[b] ^= x[c]; x[b] = (x[b] << 7) | (x[b] >>> 25);
|
||
};
|
||
for (let off = 0; off < data.length; off += 64) {
|
||
st[12] = counter + off / 64;
|
||
x.set(st);
|
||
for (let r = 0; r < 10; r++) {
|
||
qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15);
|
||
qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14);
|
||
}
|
||
for (let i = 0; i < 16; i++) bv.setUint32(i * 4, (x[i] + st[i]) >>> 0, true);
|
||
const n = Math.min(64, data.length - off);
|
||
for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ block[i];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const hexBytes = (h) => new Uint8Array(String(h).match(/../g).map((b) => parseInt(b, 16)));
|
||
|
||
/** Entschlüsselt window.LPIC_BANK (Felder build, rev, payload). Returns: {exams} oder wirft Error. */
|
||
function decodeBank(raw) {
|
||
if (!raw) return { exams: [] };
|
||
if (!raw.payload) return raw; // unverschlüsselte Bank (ältere Builds)
|
||
const bin = atob(raw.payload);
|
||
const data = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) data[i] = bin.charCodeAt(i);
|
||
const plain = chacha20(hexBytes(raw.build), hexBytes(raw.rev), data);
|
||
try { return JSON.parse(new TextDecoder().decode(plain)); }
|
||
catch (e) { throw new Error('Fragenbank nicht lesbar (bank.js beschädigt oder unvollständig)'); }
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ Kernlogik */
|
||
|
||
/** Prüft eine Frage gegen die Erstellungsregeln. Returns: Fehlertext oder null. */
|
||
function validateQuestion(q, objectives) {
|
||
if (!q || typeof q !== 'object') return 'keine Frage';
|
||
if (!/^\d{3}\.\d+-\d{3}$/.test(q.id || '')) return `ungültige id ${q.id}`;
|
||
if (!objectives.has(q.objective)) return `${q.id}: unbekanntes Objective ${q.objective}`;
|
||
if (!String(q.question || '').trim() || !String(q.explanation || '').trim()) return `${q.id}: question/explanation fehlt`;
|
||
if (q.type === 'fill') {
|
||
return Array.isArray(q.answers) && q.answers.length ? null : `${q.id}: answers fehlt`;
|
||
}
|
||
if (q.type !== 'single' && q.type !== 'multi') return `${q.id}: unbekannter type ${q.type}`;
|
||
const opts = q.options || [];
|
||
if (opts.map((o) => o.id).join() !== OPTION_IDS.join()) return `${q.id}: genau 5 Optionen a–e nötig`;
|
||
if (opts.some((o) => !String(o.text || '').trim() || !String(o.explanation || '').trim())) return `${q.id}: Option ohne Text/Erläuterung`;
|
||
const nOk = opts.filter((o) => o.correct === true).length;
|
||
if (q.type === 'single' && nOk !== 1) return `${q.id}: single braucht genau 1 richtige Option`;
|
||
if (q.type === 'multi' && (nOk < 2 || nOk > 4)) return `${q.id}: multi braucht 2–4 richtige Optionen`;
|
||
return null;
|
||
}
|
||
|
||
/** Alle Objective-IDs einer Prüfung. */
|
||
function objectiveIds(exam) {
|
||
return new Set(exam.topics.flatMap((t) => t.objectives.map((o) => o.id)));
|
||
}
|
||
|
||
/** Objective-Metadaten (Titel, Gewicht, Topic) zu einer ID. */
|
||
function objectiveInfo(exam, oid) {
|
||
for (const t of exam.topics) {
|
||
const o = t.objectives.find((x) => x.id === oid);
|
||
if (o) return { ...o, topic: t.id, topicTitle: t.title };
|
||
}
|
||
return { id: oid, title: '', weight: 0, topic: oid.split('.')[0], topicTitle: '' };
|
||
}
|
||
|
||
/**
|
||
* Fügt ein Fragenpaket in die Bank ein (gleiche id ersetzt).
|
||
* Ein Paket mit "definition" (Inhalt einer exam.json) legt eine neue Prüfung an.
|
||
* Returns: {added, replaced, errors}
|
||
*/
|
||
function mergePack(bankExams, pack) {
|
||
const res = { added: 0, replaced: 0, errors: [] };
|
||
if (!pack || pack.format !== PACK_FORMAT) { res.errors.push(`format muss '${PACK_FORMAT}' sein`); return res; }
|
||
let exam = bankExams.find((e) => e.id === pack.exam);
|
||
if (!exam && pack.definition && pack.definition.exam && pack.definition.exam.id === pack.exam) {
|
||
exam = { ...pack.definition.exam, topics: pack.definition.topics || [], bank: [] };
|
||
bankExams.push(exam);
|
||
}
|
||
if (!exam) { res.errors.push(`Prüfung '${pack.exam}' unbekannt (Paket ohne "definition")`); return res; }
|
||
const objectives = objectiveIds(exam);
|
||
for (const q of pack.questions || []) {
|
||
if (q && q.review && q.review.status === 'rejected') continue; // von Prüfern abgelehnt
|
||
const err = validateQuestion(q, objectives);
|
||
if (err) { res.errors.push(err); continue; }
|
||
const idx = exam.bank.findIndex((x) => x.id === q.id);
|
||
if (idx >= 0) { exam.bank[idx] = q; res.replaced++; } else { exam.bank.push(q); res.added++; }
|
||
}
|
||
exam.bank.sort((a, b) => cmpObjective(a.objective, b.objective) || a.id.localeCompare(b.id));
|
||
return res;
|
||
}
|
||
|
||
/**
|
||
* Wählt n Fragen gewichtet nach Objective (wie die echte Prüfung: Gewicht = Fragenzahl).
|
||
* Fehlende Fragen eines Objectives werden zufällig aus dem Rest aufgefüllt.
|
||
*/
|
||
function pickWeighted(exam, pool, n) {
|
||
const byObj = new Map();
|
||
for (const q of shuffled(pool)) {
|
||
if (!byObj.has(q.objective)) byObj.set(q.objective, []);
|
||
byObj.get(q.objective).push(q);
|
||
}
|
||
const objs = [...byObj.keys()].map((id) => ({ id, w: objectiveInfo(exam, id).weight || 1 }));
|
||
const total = objs.reduce((s, o) => s + o.w, 0) || 1;
|
||
const target = Math.min(n, pool.length);
|
||
for (const o of objs) { o.exact = (target * o.w) / total; o.quota = Math.min(Math.floor(o.exact), byObj.get(o.id).length); }
|
||
let left = target - objs.reduce((s, o) => s + o.quota, 0);
|
||
for (const o of objs.slice().sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))) {
|
||
if (left <= 0) break;
|
||
if (o.quota < byObj.get(o.id).length) { o.quota++; left--; }
|
||
}
|
||
const picked = objs.flatMap((o) => byObj.get(o.id).slice(0, o.quota));
|
||
if (picked.length < target) {
|
||
const ids = new Set(picked.map((q) => q.id));
|
||
picked.push(...shuffled(pool.filter((q) => !ids.has(q.id))).slice(0, target - picked.length));
|
||
}
|
||
return picked;
|
||
}
|
||
|
||
/**
|
||
* Legt eine neue Sitzung an. Die Fragen werden als Kopie in die Sitzung übernommen,
|
||
* damit Export/Import unabhängig vom späteren Stand der Bank funktioniert.
|
||
* @param {object} exam Prüfung aus der Bank
|
||
* @param {object} o {mode:'learn'|'exam', topics?:string[], count?:number, order?:'random'|'objective', shuffleOptions?:bool, questionIds?:string[]}
|
||
*/
|
||
function createSession(exam, o) {
|
||
const mode = o.mode === 'exam' ? 'exam' : 'learn';
|
||
let pool = exam.bank;
|
||
if (o.questionIds) pool = pool.filter((q) => o.questionIds.includes(q.id));
|
||
else if (mode === 'learn' && o.topics && o.topics.length) pool = pool.filter((q) => o.topics.includes(q.objective.split('.')[0]));
|
||
let list;
|
||
if (mode === 'exam') {
|
||
list = shuffled(pickWeighted(exam, pool, exam.questions || 60));
|
||
} else {
|
||
list = o.order === 'objective'
|
||
? pool.slice().sort((a, b) => cmpObjective(a.objective, b.objective) || a.id.localeCompare(b.id))
|
||
: shuffled(pool);
|
||
if (o.count > 0) list = list.slice(0, o.count);
|
||
}
|
||
const questions = list.map((q) => {
|
||
const c = JSON.parse(JSON.stringify(q));
|
||
if (c.options && o.shuffleOptions !== false) c.options = shuffled(c.options);
|
||
return c;
|
||
});
|
||
const now = new Date().toISOString();
|
||
return {
|
||
format: SESSION_FORMAT,
|
||
version: 1,
|
||
id: Date.now().toString(36),
|
||
exam_id: exam.id,
|
||
exam_title: exam.title,
|
||
exam_code: exam.code,
|
||
topics: exam.topics,
|
||
mode,
|
||
created: now,
|
||
updated: now,
|
||
elapsed_s: 0,
|
||
time_limit_s: mode === 'exam' ? (exam.duration_min || 90) * 60 : 0,
|
||
pass_percent: exam.pass_percent || 65,
|
||
current: 0,
|
||
submitted: false,
|
||
questions,
|
||
answers: {},
|
||
};
|
||
}
|
||
|
||
/** Anzahl richtiger Optionen einer Choice-Frage. */
|
||
function correctCount(q) {
|
||
return (q.options || []).filter((o) => o.correct).length;
|
||
}
|
||
|
||
/** Bewertet eine Antwort (alles oder nichts, wie LPI). Returns: true/false. */
|
||
function isCorrect(q, value) {
|
||
if (q.type === 'fill') {
|
||
const v = normalize(value, q.case_sensitive);
|
||
return v !== '' && q.answers.some((a) => normalize(a, q.case_sensitive) === v);
|
||
}
|
||
const sel = new Set(Array.isArray(value) ? value : value ? [value] : []);
|
||
const ok = q.options.filter((o) => o.correct).map((o) => o.id);
|
||
return sel.size === ok.length && ok.every((id) => sel.has(id));
|
||
}
|
||
|
||
/** Hat die Frage eine (nicht leere) Antwort? */
|
||
function isAnswered(a) {
|
||
if (!a) return false;
|
||
return Array.isArray(a.value) ? a.value.length > 0 : normalize(a.value) !== '';
|
||
}
|
||
|
||
/** Prüft eine importierte Sitzung grob. Returns: Fehlertext oder null. */
|
||
function validateSession(s) {
|
||
if (!s || s.format !== SESSION_FORMAT) return `format muss '${SESSION_FORMAT}' sein`;
|
||
if (s.version !== 1) return `Sitzungsversion ${s.version} wird nicht unterstützt`;
|
||
if (!Array.isArray(s.questions) || !s.questions.length) return 'Sitzung ohne Fragen';
|
||
if (typeof s.answers !== 'object' || s.answers === null) return 'Sitzung ohne answers';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Auswertung einer Sitzung, gesamt und je Topic.
|
||
* @param {boolean} onlyRevealed im Lernmodus nur bereits aufgelöste Fragen zählen
|
||
*/
|
||
function score(session, onlyRevealed) {
|
||
const res = { total: 0, correct: 0, answered: 0, wrong: [], topics: {} };
|
||
session.questions.forEach((q, i) => {
|
||
const a = session.answers[q.id];
|
||
if (onlyRevealed && !(a && a.revealed)) return;
|
||
const t = q.objective.split('.')[0];
|
||
const ts = res.topics[t] || (res.topics[t] = { total: 0, correct: 0 });
|
||
const ok = !!a && isCorrect(q, a.value);
|
||
res.total++; ts.total++;
|
||
if (isAnswered(a)) res.answered++;
|
||
if (ok) { res.correct++; ts.correct++; } else res.wrong.push(i);
|
||
});
|
||
res.percent = res.total ? Math.round((res.correct / res.total) * 100) : 0;
|
||
res.passed = res.percent >= session.pass_percent;
|
||
return res;
|
||
}
|
||
|
||
const core = { chacha20, decodeBank, validateQuestion, mergePack, pickWeighted, createSession, isCorrect, isAnswered, validateSession, score, fmt, normalize, shuffled };
|
||
|
||
/* ------------------------------------------------------------------ Oberfläche */
|
||
|
||
const ui = {
|
||
exams: [], // Bank inkl. importierter Pakete
|
||
session: null, // laufende Sitzung oder null (Startseite)
|
||
tickCount: 0,
|
||
};
|
||
|
||
const $ = (sel, root = document) => root.querySelector(sel);
|
||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||
|
||
/** Lädt die Bank aus bank.js und gespeicherte Pakete aus localStorage. */
|
||
function loadBank() {
|
||
try { ui.exams = decodeBank(window.LPIC_BANK).exams || []; }
|
||
catch (e) { ui.exams = []; console.error(e); setTimeout(() => toast(e.message, 'error'), 0); }
|
||
for (const pack of store.get(STORE_PACKS) || []) mergePack(ui.exams, pack);
|
||
ui.exams.sort((a, b) => String(a.level).localeCompare(String(b.level)) || (a.order || 0) - (b.order || 0));
|
||
}
|
||
|
||
function save() {
|
||
if (!ui.session) return;
|
||
ui.session.updated = new Date().toISOString();
|
||
if (!store.set(STORE_SESSION, ui.session)) toast('Speichern im Browser nicht möglich – bitte regelmäßig exportieren.', 'warn');
|
||
}
|
||
|
||
function toast(msg, kind = 'info') {
|
||
const box = $('#toast');
|
||
const el = document.createElement('div');
|
||
el.className = `toast ${kind}`;
|
||
el.textContent = msg;
|
||
box.appendChild(el);
|
||
setTimeout(() => el.remove(), kind === 'error' ? 9000 : 4500);
|
||
}
|
||
|
||
/** Bietet Text als Datei zum Speichern an. */
|
||
function download(name, text) {
|
||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = name;
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
|
||
/** Setzt die Folien neu und synchronisiert reveal.js. */
|
||
function setSlides(html, index) {
|
||
$('.reveal .slides').innerHTML = html;
|
||
Reveal.sync();
|
||
Reveal.slide(index || 0, 0);
|
||
updateToolbar();
|
||
}
|
||
|
||
/* ----------------------------- Startseite */
|
||
|
||
function renderStart() {
|
||
ui.session = null;
|
||
const saved = store.get(STORE_SESSION);
|
||
const levels = [...new Set(ui.exams.map((e) => e.level))];
|
||
const examChoices = levels.map((lvl) => `
|
||
<div class="exam-group"><span class="exam-level">${esc(lvl)}</span>
|
||
${ui.exams.filter((e) => e.level === lvl).map((e, i) => `
|
||
<label class="choice"><input type="radio" name="exam" value="${esc(e.id)}"${ui.exams.indexOf(e) === 0 && i === 0 ? ' checked' : ''}>
|
||
<span>${esc(e.title)} <small>${esc(e.code || '')} · ${e.bank.length} Fragen</small></span></label>`).join('')}
|
||
</div>`).join('');
|
||
let resume = '';
|
||
if (saved && !validateSession(saved)) {
|
||
const done = saved.questions.filter((q) => isAnswered(saved.answers[q.id])).length;
|
||
resume = `
|
||
<div class="card resume">
|
||
<h3>Gespeicherte Sitzung</h3>
|
||
<p>${esc(saved.exam_title)} · ${saved.mode === 'exam' ? 'Prüfungsmodus' : 'Lernmodus'}${saved.submitted ? ' · abgegeben' : ''}
|
||
· ${done}/${saved.questions.length} beantwortet · Stand ${esc(new Date(saved.updated).toLocaleString('de-DE'))}</p>
|
||
<button class="btn primary" data-act="resume">Fortsetzen</button>
|
||
<button class="btn" data-act="discard">Verwerfen</button>
|
||
</div>`;
|
||
}
|
||
const html = `
|
||
<section class="start">
|
||
<h1>LPIC-Prüfungssimulator</h1>
|
||
${ui.exams.length ? '' : '<p class="warn-text">Keine Fragenbank gefunden – <code>tools/build_bank.py</code> ausführen oder ein Fragenpaket importieren.</p>'}
|
||
${resume}
|
||
<form class="card setup" id="setup">
|
||
<h3>Neue Sitzung</h3>
|
||
<fieldset><legend>Prüfung</legend>${examChoices}</fieldset>
|
||
<fieldset><legend>Modus</legend>
|
||
<label class="choice"><input type="radio" name="mode" value="learn" checked>
|
||
<span>Lernmodus <small>Weiter (→ / Leertaste) zeigt die Auflösung mit Erläuterung</small></span></label>
|
||
<label class="choice"><input type="radio" name="mode" value="exam">
|
||
<span>Prüfungsmodus <small id="exam-info"></small></span></label>
|
||
</fieldset>
|
||
<fieldset class="learn-only"><legend>Themen</legend><div id="topic-list"></div></fieldset>
|
||
<fieldset class="learn-only"><legend>Ablauf</legend>
|
||
<label class="inline">Anzahl Fragen <input type="number" name="count" min="0" value="0" title="0 = alle"> <small>(0 = alle)</small></label>
|
||
<label class="inline"><select name="order"><option value="random">zufällige Reihenfolge</option>
|
||
<option value="objective">nach Objective sortiert</option></select></label>
|
||
</fieldset>
|
||
<label class="choice"><input type="checkbox" name="shuffle" checked><span>Antwortoptionen mischen</span></label>
|
||
<p><button class="btn primary" type="submit">Starten</button></p>
|
||
</form>
|
||
<p class="footnote">Objectives laut LPI, Quelle und Version je Prüfung in <code>data/<prüfung>/exam.json</code>.
|
||
Stand der Fragenbank: ${esc(window.LPIC_BANK ? window.LPIC_BANK.generated : '–')}.
|
||
Tasten: → / Leertaste weiter · ← zurück · 1–5 Antwort wählen · Enter im Textfeld = weiter.</p>
|
||
</section>`;
|
||
setSlides(html, 0);
|
||
updateStartForm();
|
||
}
|
||
|
||
/** Aktualisiert Themenliste und Hinweise je gewählter Prüfung/Modus. */
|
||
function updateStartForm() {
|
||
const form = $('#setup');
|
||
if (!form) return;
|
||
const exam = ui.exams.find((e) => e.id === (form.exam && form.exam.value));
|
||
const learn = form.mode.value === 'learn';
|
||
$$('.learn-only', form).forEach((el) => { el.hidden = !learn; });
|
||
if (!exam) return;
|
||
$('#exam-info').textContent = `${exam.questions} Fragen, ${exam.duration_min} Minuten, gewichtet nach Objectives, Auflösung erst nach Abgabe`;
|
||
const counts = {};
|
||
exam.bank.forEach((q) => { const t = q.objective.split('.')[0]; counts[t] = (counts[t] || 0) + 1; });
|
||
const list = $('#topic-list');
|
||
if (list.dataset.exam !== exam.id) {
|
||
list.dataset.exam = exam.id;
|
||
list.innerHTML = exam.topics.map((t) => `
|
||
<label class="choice"><input type="checkbox" name="topic" value="${esc(t.id)}" checked>
|
||
<span>${esc(t.id)} ${esc(t.title)} <small>${counts[t.id] || 0} Fragen</small></span></label>`).join('');
|
||
}
|
||
}
|
||
|
||
function startFromForm(form) {
|
||
const exam = ui.exams.find((e) => e.id === form.exam.value);
|
||
if (!exam) { toast('Bitte eine Prüfung wählen.', 'warn'); return; }
|
||
const topics = $$('input[name=topic]:checked', form).map((i) => i.value);
|
||
const s = createSession(exam, {
|
||
mode: form.mode.value,
|
||
topics,
|
||
count: parseInt(form.count.value, 10) || 0,
|
||
order: form.order.value,
|
||
shuffleOptions: form.shuffle.checked,
|
||
});
|
||
if (!s.questions.length) { toast('Keine Fragen für diese Auswahl.', 'warn'); return; }
|
||
startSession(s, 0);
|
||
}
|
||
|
||
function startSession(session, index) {
|
||
ui.session = session;
|
||
save();
|
||
renderSession(index);
|
||
}
|
||
|
||
/* ----------------------------- Fragen-Folien */
|
||
|
||
function renderSession(index) {
|
||
const s = ui.session;
|
||
const slides = s.questions.map((q, i) => questionSlide(q, i)).join('');
|
||
const last = s.mode === 'exam' && !s.submitted ? submitSlide() : resultSlide();
|
||
setSlides(slides + last, Math.min(index ?? s.current ?? 0, s.questions.length));
|
||
}
|
||
|
||
/** Ist die Frage gesperrt (aufgelöst bzw. Prüfung abgegeben)? */
|
||
function isLocked(q) {
|
||
const a = ui.session.answers[q.id];
|
||
return ui.session.submitted || !!(a && a.revealed);
|
||
}
|
||
|
||
function questionSlide(q, i) {
|
||
const s = ui.session;
|
||
const a = s.answers[q.id] || {};
|
||
const locked = isLocked(q);
|
||
const info = objectiveInfo({ topics: s.topics }, q.objective);
|
||
const showSolution = s.mode === 'learn' || s.submitted;
|
||
const nOk = correctCount(q);
|
||
let hint = '';
|
||
let body;
|
||
if (q.type === 'fill') {
|
||
hint = `Antwort eintippen${q.case_sensitive === false ? '' : ' – Groß-/Kleinschreibung beachten'}.`;
|
||
body = `<input class="fill" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
|
||
value="${esc(a.value || '')}"${locked ? ' disabled' : ''} aria-label="Antwort">`;
|
||
} else {
|
||
const multi = q.type === 'multi';
|
||
if (multi) hint = `Wählen Sie ${COUNT_WORDS[nOk] || nOk}.`;
|
||
const sel = new Set(Array.isArray(a.value) ? a.value : []);
|
||
body = `<ol class="options">${q.options.map((o, k) => `
|
||
<li class="option" data-oid="${o.id}"><label>
|
||
<input type="${multi ? 'checkbox' : 'radio'}" name="q_${esc(q.id)}" value="${o.id}"${sel.has(o.id) ? ' checked' : ''}${locked ? ' disabled' : ''}>
|
||
<span class="letter">${LETTERS[k]}</span><span class="text">${fmt(o.text)}</span></label></li>`).join('')}
|
||
</ol>`;
|
||
}
|
||
const flag = s.mode === 'exam' && !s.submitted
|
||
? `<label class="flag"><input type="checkbox" data-flag${a.flagged ? ' checked' : ''}> zur Überprüfung markieren</label>` : '';
|
||
const solution = showSolution ? solutionBlock(q, locked) : '';
|
||
const learnBtn = s.mode === 'learn' && !locked ? '<p class="actions"><button class="btn" data-act="next">Auflösen ▸</button></p>' : '';
|
||
return `
|
||
<section class="question${locked ? ' locked' : ''}" data-qid="${esc(q.id)}" data-index="${i}">
|
||
<div class="q-head"><span class="q-no">Frage ${i + 1} von ${s.questions.length}</span>
|
||
<span class="q-obj">${esc(q.objective)} ${esc(info.title)}${info.weight ? ` · Gewicht ${info.weight}` : ''}</span>${flag}</div>
|
||
<div class="q-text">${fmt(q.question)}</div>
|
||
${q.code ? `<pre class="q-code"><code>${esc(q.code)}</code></pre>` : ''}
|
||
${hint ? `<p class="q-hint">${hint}</p>` : ''}
|
||
${body}
|
||
${learnBtn}
|
||
${solution}
|
||
</section>`;
|
||
}
|
||
|
||
/** Auflösung: Ergebnis, Erläuterung je Option, Gesamterläuterung. Ungesperrt als reveal-Fragment. */
|
||
function solutionBlock(q, locked) {
|
||
const a = ui.session.answers[q.id];
|
||
let res = '';
|
||
if (locked) {
|
||
const ok = !!a && isCorrect(q, a.value);
|
||
res = !isAnswered(a) ? '<p class="verdict skipped">Nicht beantwortet</p>'
|
||
: ok ? '<p class="verdict right">Richtig</p>' : '<p class="verdict wrong">Falsch</p>';
|
||
}
|
||
let details;
|
||
if (q.type === 'fill') {
|
||
const alt = q.answers.slice(1);
|
||
details = `<p><strong>Musterlösung:</strong> <code>${esc(q.answers[0])}</code>${alt.length
|
||
? ` · ebenfalls akzeptiert: ${alt.map((x) => `<code>${esc(x)}</code>`).join(', ')}` : ''}</p>`
|
||
+ (a && isAnswered(a) ? `<p>Ihre Antwort: <code>${esc(a.value)}</code></p>` : '');
|
||
} else {
|
||
details = `<ul class="explain">${q.options.map((o, k) => `
|
||
<li class="${o.correct ? 'ok' : 'no'}"><span class="letter">${LETTERS[k]}</span>
|
||
<span class="mark">${o.correct ? '✔ richtig' : '✘ falsch'}</span> ${fmt(o.explanation)}</li>`).join('')}</ul>`;
|
||
}
|
||
const refs = q.references && q.references.length
|
||
? `<p class="refs">Nachschlagen: ${q.references.map((r) => `<code>${esc(r)}</code>`).join(' · ')}</p>` : '';
|
||
return `
|
||
<div class="solution${locked ? '' : ' fragment'}">
|
||
<h3>Auflösung</h3>${res}${details}
|
||
<div class="explanation">${fmt(q.explanation)}</div>${refs}
|
||
</div>`;
|
||
}
|
||
|
||
/** Markiert Optionen nach der Auflösung (richtig/falsch gewählt). */
|
||
function markOptions(section, q) {
|
||
const a = ui.session.answers[q.id];
|
||
const sel = new Set(a && Array.isArray(a.value) ? a.value : []);
|
||
$$('.option', section).forEach((li) => {
|
||
const o = q.options.find((x) => x.id === li.dataset.oid);
|
||
li.classList.toggle('is-correct', o.correct);
|
||
li.classList.toggle('is-wrong', !o.correct && sel.has(o.id));
|
||
li.classList.toggle('is-missed', o.correct && !sel.has(o.id));
|
||
});
|
||
const fill = $('.fill', section);
|
||
if (fill) fill.classList.add(a && isCorrect(q, a.value) ? 'is-correct' : 'is-wrong');
|
||
}
|
||
|
||
/** Lernmodus: Frage auflösen, sperren und Auflösung dauerhaft sichtbar machen. */
|
||
function revealQuestion(section) {
|
||
const s = ui.session;
|
||
const q = s.questions[Number(section.dataset.index)];
|
||
const a = s.answers[q.id] || (s.answers[q.id] = { value: q.type === 'fill' ? '' : [] });
|
||
if (a.revealed) return;
|
||
a.revealed = true;
|
||
save();
|
||
const fresh = document.createElement('div');
|
||
fresh.innerHTML = solutionBlock(q, true);
|
||
$('.solution', section).replaceWith(fresh.firstElementChild);
|
||
$$('input', section).forEach((i) => { i.disabled = true; });
|
||
const btn = $('.actions', section);
|
||
if (btn) btn.remove();
|
||
section.classList.add('locked');
|
||
markOptions(section, q);
|
||
Reveal.syncFragments();
|
||
$('.solution', section).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
updateToolbar();
|
||
}
|
||
|
||
/* ----------------------------- Abgabe & Ergebnis */
|
||
|
||
function submitSlide() {
|
||
const s = ui.session;
|
||
const open = [], flagged = [];
|
||
s.questions.forEach((q, i) => {
|
||
const a = s.answers[q.id];
|
||
if (!isAnswered(a)) open.push(i);
|
||
if (a && a.flagged) flagged.push(i);
|
||
});
|
||
const jump = (list) => list.length
|
||
? list.map((i) => `<button class="btn small" data-goto="${i}">${i + 1}</button>`).join(' ') : '–';
|
||
return `
|
||
<section class="final submit">
|
||
<h2>Prüfung abgeben</h2>
|
||
<p>Beantwortet: ${s.questions.length - open.length} von ${s.questions.length}</p>
|
||
<p>Offen: ${jump(open)}</p>
|
||
<p>Markiert: ${jump(flagged)}</p>
|
||
<p><button class="btn primary" data-act="submit">Prüfung abgeben und auswerten</button></p>
|
||
</section>`;
|
||
}
|
||
|
||
function resultSlide() {
|
||
const s = ui.session;
|
||
const learn = s.mode === 'learn';
|
||
const r = score(s, learn);
|
||
const topicTitle = (id) => (s.topics.find((t) => t.id === id) || {}).title || '';
|
||
const rows = Object.keys(r.topics).sort().map((t) => {
|
||
const ts = r.topics[t];
|
||
const p = Math.round((ts.correct / ts.total) * 100);
|
||
return `<tr><td>${esc(t)}</td><td>${esc(topicTitle(t))}</td><td>${ts.correct}/${ts.total}</td>
|
||
<td><span class="bar"><span style="width:${p}%"></span></span> ${p} %</td></tr>`;
|
||
}).join('');
|
||
const wrong = r.wrong.length
|
||
? r.wrong.map((i) => `<button class="btn small" data-goto="${i}">${i + 1}</button>`).join(' ') : '–';
|
||
const verdict = r.total === 0 ? ''
|
||
: `<p class="verdict ${r.passed ? 'right' : 'wrong'}">${r.passed ? 'Bestanden' : 'Nicht bestanden'} (Richtwert ${s.pass_percent} %)</p>`;
|
||
return `
|
||
<section class="final result">
|
||
<h2>Auswertung${learn ? ' (bisher aufgelöste Fragen)' : ''}</h2>
|
||
<p class="big">${r.correct} von ${r.total} richtig · ${r.percent} %</p>
|
||
${verdict}
|
||
<p class="footnote">LPI bewertet auf einer Skala von 200–800 (bestanden ab 500) und veröffentlicht keine Prozentgrenze.
|
||
Der Richtwert ist eine Annahme und in <code>exam.json</code> (<code>pass_percent</code>) einstellbar.</p>
|
||
<div class="table-wrap"><table><thead><tr><th>Topic</th><th></th><th>richtig</th><th>Quote</th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>
|
||
<p>Falsch/offen (anklicken zum Ansehen): ${wrong}</p>
|
||
<p class="actions">
|
||
${r.wrong.length ? '<button class="btn primary" data-act="retry">Falsche im Lernmodus wiederholen</button>' : ''}
|
||
<button class="btn" data-act="export">Sitzung exportieren</button>
|
||
<button class="btn" data-act="menu">Neue Sitzung</button>
|
||
</p>
|
||
</section>`;
|
||
}
|
||
|
||
function submitExam(auto) {
|
||
const s = ui.session;
|
||
s.submitted = true;
|
||
save();
|
||
renderSession(s.questions.length);
|
||
toast(auto ? 'Zeit abgelaufen – Prüfung automatisch abgegeben.' : 'Prüfung abgegeben.', auto ? 'warn' : 'info');
|
||
}
|
||
|
||
/** Neue Lernsitzung mit den falsch beantworteten Fragen der aktuellen Sitzung. */
|
||
function retryWrong() {
|
||
const s = ui.session;
|
||
const r = score(s, s.mode === 'learn');
|
||
const qs = r.wrong.map((i) => s.questions[i]);
|
||
const exam = { ...(ui.exams.find((e) => e.id === s.exam_id) || {}), id: s.exam_id, title: s.exam_title,
|
||
code: s.exam_code, topics: s.topics, pass_percent: s.pass_percent, bank: qs };
|
||
startSession(createSession(exam, { mode: 'learn', order: 'random', shuffleOptions: true }), 0);
|
||
}
|
||
|
||
/* ----------------------------- Werkzeugleiste, Timer */
|
||
|
||
/** Überträgt die tatsächliche Höhe der (ggf. umbrochenen) Werkzeugleiste nach CSS --tb-h. */
|
||
function syncToolbarHeight() {
|
||
document.documentElement.style.setProperty('--tb-h', `${$('.toolbar').offsetHeight}px`);
|
||
}
|
||
|
||
function updateToolbar() {
|
||
const s = ui.session;
|
||
$('#tb-title').textContent = s ? `${s.exam_title} · ${s.mode === 'exam' ? 'Prüfung' : 'Lernen'}` : 'LPIC-Prüfungssimulator';
|
||
$('#tb-export').disabled = !s;
|
||
if (!s) { $('#tb-progress').textContent = ''; $('#tb-timer').textContent = ''; return; }
|
||
const done = s.questions.filter((q) => isAnswered(s.answers[q.id])).length;
|
||
$('#tb-progress').textContent = `${done}/${s.questions.length} beantwortet`;
|
||
const timer = $('#tb-timer');
|
||
if (s.mode === 'exam' && !s.submitted) {
|
||
const rest = s.time_limit_s - s.elapsed_s;
|
||
timer.textContent = `⏱ ${fmtTime(rest)}`;
|
||
timer.classList.toggle('low', rest < 600);
|
||
} else {
|
||
timer.textContent = `⏱ ${fmtTime(s.elapsed_s)}`;
|
||
timer.classList.remove('low');
|
||
}
|
||
}
|
||
|
||
function tick() {
|
||
const s = ui.session;
|
||
if (!s || s.submitted) return;
|
||
s.elapsed_s++;
|
||
if (++ui.tickCount % SAVE_EVERY_S === 0) save();
|
||
if (s.mode === 'exam' && s.elapsed_s >= s.time_limit_s) { submitExam(true); return; }
|
||
updateToolbar();
|
||
}
|
||
|
||
/* ----------------------------- Export / Import */
|
||
|
||
function exportSession() {
|
||
const s = ui.session;
|
||
if (!s) return;
|
||
save();
|
||
const d = new Date();
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||
download(`lpic_sim_${s.exam_id}_${s.mode}_${stamp}.json`, JSON.stringify(s, null, 1));
|
||
}
|
||
|
||
/** Import: erkennt Sitzung (fortsetzen) oder Fragenpaket (in Bank übernehmen). */
|
||
function importFile(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
let doc;
|
||
try { doc = JSON.parse(reader.result); }
|
||
catch (e) { toast(`${file.name}: kein gültiges JSON (${e.message})`, 'error'); return; }
|
||
if (doc.format === SESSION_FORMAT) {
|
||
const err = validateSession(doc);
|
||
if (err) { toast(`${file.name}: ${err}`, 'error'); return; }
|
||
startSession(doc, doc.current || 0);
|
||
toast(`Sitzung „${doc.exam_title}“ geladen – weiter bei Frage ${Math.min((doc.current || 0) + 1, doc.questions.length)}.`);
|
||
} else if (doc.format === PACK_FORMAT) {
|
||
const res = mergePack(ui.exams, doc);
|
||
if (res.added + res.replaced > 0) {
|
||
const packs = (store.get(STORE_PACKS) || []).filter((p) => p.name !== file.name);
|
||
packs.push({ ...doc, name: file.name });
|
||
store.set(STORE_PACKS, packs);
|
||
}
|
||
res.errors.slice(0, 5).forEach((e) => toast(e, 'error'));
|
||
toast(`${file.name}: ${res.added} neu, ${res.replaced} ersetzt${res.errors.length ? `, ${res.errors.length} abgelehnt` : ''}.`,
|
||
res.errors.length ? 'warn' : 'info');
|
||
if (!ui.session) renderStart();
|
||
} else {
|
||
toast(`${file.name}: unbekanntes Format (erwartet ${SESSION_FORMAT} oder ${PACK_FORMAT})`, 'error');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
/* ----------------------------- Ereignisse */
|
||
|
||
function onChange(ev) {
|
||
const t = ev.target;
|
||
if (t.closest('#setup')) { updateStartForm(); return; }
|
||
const section = t.closest('section.question');
|
||
if (!section || !ui.session) return;
|
||
const s = ui.session;
|
||
const q = s.questions[Number(section.dataset.index)];
|
||
const a = s.answers[q.id] || (s.answers[q.id] = { value: q.type === 'fill' ? '' : [] });
|
||
if (t.hasAttribute('data-flag')) { a.flagged = t.checked; save(); return; }
|
||
if (isLocked(q)) return;
|
||
if (q.type === 'single') a.value = [t.value];
|
||
else if (q.type === 'multi') {
|
||
let sel = $$('.options input:checked', section).map((i) => i.value);
|
||
const max = correctCount(q);
|
||
if (sel.length > max) {
|
||
t.checked = false;
|
||
sel = sel.filter((v) => v !== t.value);
|
||
toast(`Höchstens ${max} Antworten wählbar.`, 'warn');
|
||
}
|
||
a.value = sel;
|
||
} else a.value = t.value;
|
||
save();
|
||
updateToolbar();
|
||
}
|
||
|
||
function onClick(ev) {
|
||
const btn = ev.target.closest('[data-act],[data-goto]');
|
||
if (!btn) return;
|
||
if (btn.dataset.goto !== undefined) { Reveal.slide(Number(btn.dataset.goto)); return; }
|
||
switch (btn.dataset.act) {
|
||
case 'next': Reveal.next(); break;
|
||
case 'prev': Reveal.prev(); break;
|
||
case 'resume': { const s = store.get(STORE_SESSION); if (s) { ui.session = s; renderSession(s.current); } break; }
|
||
case 'discard':
|
||
if (window.confirm('Gespeicherte Sitzung verwerfen? (Vorher exportieren, falls sie noch gebraucht wird.)')) {
|
||
store.del(STORE_SESSION); renderStart();
|
||
}
|
||
break;
|
||
case 'submit': {
|
||
const open = ui.session.questions.filter((q) => !isAnswered(ui.session.answers[q.id])).length;
|
||
if (!open || window.confirm(`${open} Fragen sind offen. Trotzdem abgeben?`)) submitExam(false);
|
||
break;
|
||
}
|
||
case 'retry': retryWrong(); break;
|
||
case 'export': exportSession(); break;
|
||
case 'import': $('#import-file').click(); break;
|
||
case 'menu': save(); renderStart(); break;
|
||
default: break;
|
||
}
|
||
}
|
||
|
||
function onKey(ev) {
|
||
const t = ev.target;
|
||
if (t.matches && t.matches('input.fill')) {
|
||
if (ev.key === 'Enter') { ev.preventDefault(); t.blur(); Reveal.next(); }
|
||
return;
|
||
}
|
||
if (t.matches && t.matches('input[type=text],input[type=number],select,textarea')) return;
|
||
if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
|
||
const n = Number(ev.key);
|
||
if (n >= 1 && n <= 5) {
|
||
const input = $$('.present .options input')[n - 1];
|
||
if (input && !input.disabled) { input.click(); ev.preventDefault(); }
|
||
}
|
||
}
|
||
|
||
function onSlideChanged(ev) {
|
||
const s = ui.session;
|
||
if (!s) return;
|
||
s.current = ev.indexh;
|
||
const sec = ev.currentSlide;
|
||
if (sec.classList.contains('result')) {
|
||
// Lernmodus: Auswertung bei jedem Aufruf neu berechnen
|
||
const fresh = document.createElement('div');
|
||
fresh.innerHTML = resultSlide();
|
||
sec.innerHTML = fresh.firstElementChild.innerHTML;
|
||
} else if (sec.classList.contains('submit')) {
|
||
const fresh = document.createElement('div');
|
||
fresh.innerHTML = submitSlide();
|
||
sec.innerHTML = fresh.firstElementChild.innerHTML;
|
||
} else if (sec.classList.contains('question')) {
|
||
const q = s.questions[Number(sec.dataset.index)];
|
||
if (isLocked(q) && q.options) markOptions(sec, q);
|
||
sec.scrollTop = 0;
|
||
}
|
||
save();
|
||
updateToolbar();
|
||
}
|
||
|
||
function onFragmentShown(ev) {
|
||
const frag = ev.fragment;
|
||
if (frag && frag.classList.contains('solution')) revealQuestion(frag.closest('section.question'));
|
||
}
|
||
|
||
function init() {
|
||
loadBank();
|
||
// Erst eine Platzhalter-Folie, dann reveal.js starten, dann die echte Startseite setzen
|
||
$('.reveal .slides').innerHTML = '<section></section>';
|
||
Reveal.initialize({
|
||
hash: false,
|
||
history: false,
|
||
controls: true,
|
||
controlsTutorial: false,
|
||
progress: true,
|
||
center: false,
|
||
disableLayout: true,
|
||
scrollActivationWidth: null,
|
||
overview: false,
|
||
touch: false,
|
||
pause: false,
|
||
transition: 'slide',
|
||
transitionSpeed: 'fast',
|
||
keyboard: { 66: null, 190: null, 79: null }, // B, Punkt, O: Pause/Übersicht aus
|
||
}).then(() => {
|
||
Reveal.on('slidechanged', onSlideChanged);
|
||
Reveal.on('fragmentshown', onFragmentShown);
|
||
const slides = $('.reveal .slides');
|
||
slides.addEventListener('change', onChange);
|
||
slides.addEventListener('input', (ev) => { if (ev.target.matches('input.fill')) onChange(ev); });
|
||
slides.addEventListener('submit', (ev) => { ev.preventDefault(); startFromForm(ev.target); });
|
||
document.addEventListener('click', onClick);
|
||
document.addEventListener('keydown', onKey);
|
||
$('#import-file').addEventListener('change', (ev) => {
|
||
const f = ev.target.files && ev.target.files[0];
|
||
if (f) importFile(f);
|
||
ev.target.value = '';
|
||
});
|
||
$('#tb-export').addEventListener('click', exportSession);
|
||
window.addEventListener('beforeunload', save);
|
||
new ResizeObserver(syncToolbarHeight).observe($('.toolbar'));
|
||
setInterval(tick, 1000);
|
||
renderStart();
|
||
});
|
||
}
|
||
|
||
window.LPICSim = { core, ui };
|
||
if (window.Reveal && !window.LPICSIM_NO_UI) document.addEventListener('DOMContentLoaded', init);
|
||
})();
|