- 68 exercises from UBA FCE chapters 1-3 - Step-by-step solutions with KaTeX rendering - Theory panels (26 topics) expandable per exercise - Matrix builder (2x2/3x3/4x4) with 7 operations - System solver (Gauss, Gauss-Jordan, Cramer, Rouché-Frobenius) - Glassmorphism UI with dark mode - Canvas particle background - ARIA accessibility (keyboard nav, screen reader) - Zero build step - open index.html directly
331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
/**
|
|
* ExerciseViewer — Problem display with KaTeX, step-by-step solution reveal, interactive attempt
|
|
*/
|
|
const ExerciseViewer = (() => {
|
|
let container = null;
|
|
let currentExercise = null;
|
|
let revealedSteps = 0;
|
|
let ac = null; // AbortController for event listener cleanup
|
|
|
|
function escapeHtml(str) {
|
|
return (str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
async function findExercise(id) {
|
|
await ExerciseBrowser.loadExerciseData();
|
|
const exercises = ExerciseBrowser.getExercises();
|
|
return exercises.find(ex => ex.id === id);
|
|
}
|
|
|
|
function renderProblem(ex) {
|
|
const diffLabel = { basic: 'Básico', intermediate: 'Intermedio', advanced: 'Avanzado' };
|
|
const topicLabels = {
|
|
'vector-ops': 'Vectores',
|
|
'line-eq': 'Rectas',
|
|
'plane-eq': 'Planos',
|
|
'matrix-ops': 'Matrices',
|
|
'determinants': 'Determinantes',
|
|
'matrix-inverse': 'Inversa',
|
|
'systems': 'Sistemas'
|
|
};
|
|
return `
|
|
<div class="exercise-viewer__problem">
|
|
<div class="exercise-viewer__problem-header">
|
|
<span class="exercise-viewer__problem-id">${ex.id}</span>
|
|
<span class="exercise-viewer__problem-topic">${topicLabels[ex.topic] || ex.topic} — ${diffLabel[ex.difficulty] || ex.difficulty}</span>
|
|
</div>
|
|
<div class="exercise-viewer__statement" id="exercise-statement"></div>
|
|
${ex.hint ? `<button class="exercise-viewer__hint-btn" id="hintBtn">💡 Ver pista</button>
|
|
<div class="exercise-viewer__hint" id="hintBox">${escapeHtml(ex.hint)}</div>` : ''}
|
|
${ex.theoryKey ? `<button class="exercise-viewer__theory-btn" id="theoryBtn">📖 Ver teoría</button>
|
|
<div class="exercise-viewer__theory theory-panel" id="theoryBox"></div>` : ''}
|
|
</div>
|
|
|
|
<div class="exercise-viewer__attempt">
|
|
<h3>Tu respuesta</h3>
|
|
<div class="attempt-input">
|
|
<input type="text" class="attempt-input__field" id="attemptInput" placeholder="Ingresá tu respuesta...">
|
|
<button class="attempt-input__submit" id="attemptSubmit">Verificar</button>
|
|
</div>
|
|
<div class="attempt-feedback" id="attemptFeedback" role="status" aria-live="polite"></div>
|
|
</div>
|
|
|
|
<div class="exercise-viewer__solution">
|
|
<h3>Solución paso a paso</h3>
|
|
<ol class="solution-steps" id="solutionSteps"></ol>
|
|
<button class="reveal-btn" id="revealBtn">Mostrar siguiente paso ▼</button>
|
|
<div class="solution-answer" id="solutionAnswer">
|
|
<div class="solution-answer__label">Respuesta final</div>
|
|
<div id="answerContent"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
let theoryCache = {};
|
|
|
|
function renderStatement(ex) {
|
|
const el = document.getElementById('exercise-statement');
|
|
if (!el) return;
|
|
const latex = ex.statement;
|
|
if (!latex) {
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
if (typeof KatexRenderer !== 'undefined' && KatexRenderer.renderInline) {
|
|
try {
|
|
KatexRenderer.renderInline(el, latex);
|
|
} catch (e) {
|
|
console.warn('[ExerciseViewer] KaTeX render error, falling back to text:', e);
|
|
el.textContent = latex;
|
|
}
|
|
} else {
|
|
el.textContent = latex;
|
|
}
|
|
}
|
|
|
|
async function loadTheory(chapter, theoryKey) {
|
|
const cacheKey = `${chapter}-${theoryKey}`;
|
|
if (theoryCache[cacheKey]) return theoryCache[cacheKey];
|
|
|
|
try {
|
|
const resp = await fetch(`data/theory-cap0${chapter}.json`);
|
|
const data = await resp.json();
|
|
theoryCache[cacheKey] = data[theoryKey] || null;
|
|
return theoryCache[cacheKey];
|
|
} catch (e) {
|
|
console.warn('[ExerciseViewer] Failed to load theory:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function renderTheoryPanel(theory) {
|
|
const box = document.getElementById('theoryBox');
|
|
if (!box) return;
|
|
const title = escapeHtml(theory.title || '');
|
|
const content = theory.content || '';
|
|
box.innerHTML = `<div class="theory-panel"><h4>${title}</h4><div class="theory-content" id="theoryContent"></div></div>`;
|
|
const contentEl = document.getElementById('theoryContent');
|
|
if (contentEl && typeof KatexRenderer !== 'undefined') {
|
|
contentEl.textContent = content;
|
|
KatexRenderer.renderAll(contentEl);
|
|
} else if (contentEl) {
|
|
contentEl.textContent = content;
|
|
}
|
|
}
|
|
|
|
function checkAnswer(userInput, exercise) {
|
|
if (!exercise.answer) return { correct: false, message: 'No hay respuesta definida para este ejercicio.' };
|
|
const ans = exercise.answer;
|
|
const input = userInput.trim();
|
|
|
|
if (exercise.answerType === 'numeric') {
|
|
const num = parseFloat(input);
|
|
if (isNaN(num)) return { correct: false, message: 'Ingresá un número válido.' };
|
|
const correct = Math.abs(num - ans.value) < 0.01;
|
|
return { correct, message: correct ? '¡Correcto! 🎉' : 'Incorrecto. Intentá de nuevo.' };
|
|
}
|
|
|
|
if (exercise.answerType === 'vector') {
|
|
// Parse vector format: (1; 2; 3) or (1,2,3)
|
|
const vecMatch = input.replace(/\s/g, '').match(/[\(-]?([-\d.]+)[;,]([-\d.]+)(?:[;,]([-\d.]+))?[;\)]?/);
|
|
if (!vecMatch) return { correct: false, message: 'Formato: (1; 2; 3) o (1,2,3)' };
|
|
const vals = vecMatch.slice(1).filter(v => v !== undefined).map(Number);
|
|
const answerVals = ans.value;
|
|
if (vals.length !== answerVals.length) return { correct: false, message: `Se esperan ${answerVals.length} componentes.` };
|
|
const correct = vals.every((v, i) => Math.abs(v - answerVals[i]) < 0.01);
|
|
return { correct, message: correct ? '¡Correcto! 🎉' : 'Incorrecto. Intentá de nuevo.' };
|
|
}
|
|
|
|
if (exercise.answerType === 'expression') {
|
|
// Normalize and compare expression answers
|
|
// Handle both text answers and LaTeX answers
|
|
const normalizeText = (s) => {
|
|
if (!s) return '';
|
|
return s
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, ' ')
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, ''); // Remove diacritics for accent-insensitive comparison
|
|
};
|
|
const normalizeLatex = (s) => {
|
|
if (!s) return '';
|
|
return s
|
|
.trim()
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/\\vec\{([^}]*)\}/g, '\\vec{$1}')
|
|
.replace(/\\overrightarrow\{([^}]*)\}/g, '\\overrightarrow{$1}')
|
|
.replace(/\\cdot/g, '\\cdot')
|
|
.replace(/\\times/g, '\\times');
|
|
};
|
|
const normInput = normalizeText(input);
|
|
const normAnswer = normalizeText(ans.value);
|
|
if (!normAnswer) return { correct: false, message: 'Este ejercicio requiere verificación manual. Mirá la solución.' };
|
|
const correct = normInput === normAnswer;
|
|
return { correct, message: correct ? '¡Correcto! 🎉' : 'Incorrecto. Intentá de nuevo.' };
|
|
}
|
|
|
|
return { correct: false, message: 'Tipo de respuesta no soportado.' };
|
|
}
|
|
|
|
function revealNextStep() {
|
|
if (!currentExercise || !currentExercise.solutionSteps) return;
|
|
const steps = currentExercise.solutionSteps;
|
|
const stepsEl = document.getElementById('solutionSteps');
|
|
const revealBtn = document.getElementById('revealBtn');
|
|
|
|
if (revealedSteps < steps.length) {
|
|
const step = steps[revealedSteps];
|
|
const li = document.createElement('li');
|
|
li.className = 'solution-step';
|
|
li.innerHTML = `
|
|
<div class="solution-step__desc">${escapeHtml(step.desc)}</div>
|
|
<div class="solution-step__expression" data-step="${revealedSteps}"></div>
|
|
`;
|
|
stepsEl.appendChild(li);
|
|
|
|
// Render math
|
|
const mathEl = li.querySelector('.solution-step__expression');
|
|
if (mathEl && typeof KatexRenderer !== 'undefined') {
|
|
KatexRenderer.renderDisplay(mathEl, step.expression);
|
|
}
|
|
|
|
revealedSteps++;
|
|
|
|
// Hide button if all steps revealed
|
|
if (revealedSteps >= steps.length) {
|
|
revealBtn.textContent = 'Mostrar respuesta final ▼';
|
|
revealBtn.onclick = revealAnswer;
|
|
}
|
|
}
|
|
}
|
|
|
|
function revealAnswer() {
|
|
if (!currentExercise || !currentExercise.answer) return;
|
|
const answerEl = document.getElementById('solutionAnswer');
|
|
const contentEl = document.getElementById('answerContent');
|
|
const revealBtn = document.getElementById('revealBtn');
|
|
|
|
if (contentEl && typeof KatexRenderer !== 'undefined') {
|
|
KatexRenderer.renderDisplay(contentEl, currentExercise.answer.latex);
|
|
} else if (contentEl) {
|
|
contentEl.textContent = currentExercise.answer.latex;
|
|
}
|
|
|
|
answerEl.classList.add('solution-answer--visible');
|
|
revealBtn.style.display = 'none';
|
|
}
|
|
|
|
function setupEvents() {
|
|
ac = new AbortController();
|
|
|
|
// Back button
|
|
const backBtn = container.querySelector('.exercise-viewer__back');
|
|
if (backBtn) {
|
|
backBtn.addEventListener('click', () => {
|
|
if (typeof App !== 'undefined') App.navigate('/exercises');
|
|
}, { signal: ac.signal });
|
|
}
|
|
|
|
// Hint button
|
|
const hintBtn = document.getElementById('hintBtn');
|
|
if (hintBtn) {
|
|
hintBtn.addEventListener('click', () => {
|
|
const hintBox = document.getElementById('hintBox');
|
|
hintBox.classList.toggle('exercise-viewer__hint--visible');
|
|
hintBtn.textContent = hintBox.classList.contains('exercise-viewer__hint--visible') ? '🙈 Ocultar pista' : '💡 Ver pista';
|
|
}, { signal: ac.signal });
|
|
}
|
|
|
|
// Theory button
|
|
const theoryBtn = document.getElementById('theoryBtn');
|
|
if (theoryBtn && currentExercise && currentExercise.theoryKey) {
|
|
theoryBtn.addEventListener('click', async () => {
|
|
const theoryBox = document.getElementById('theoryBox');
|
|
const isVisible = theoryBox.classList.contains('exercise-viewer__theory--visible');
|
|
if (isVisible) {
|
|
theoryBox.classList.remove('exercise-viewer__theory--visible');
|
|
theoryBtn.textContent = '📖 Ver teoría';
|
|
} else {
|
|
const chapter = currentExercise.chapter || 1;
|
|
const theory = await loadTheory(chapter, currentExercise.theoryKey);
|
|
if (theory) {
|
|
renderTheoryPanel(theory);
|
|
theoryBox.classList.add('exercise-viewer__theory--visible');
|
|
theoryBtn.textContent = '📕 Ocultar teoría';
|
|
} else {
|
|
theoryBox.innerHTML = '<p>Teoría no disponible.</p>';
|
|
theoryBox.classList.add('exercise-viewer__theory--visible');
|
|
}
|
|
}
|
|
}, { signal: ac.signal });
|
|
}
|
|
|
|
// Attempt
|
|
const submitBtn = document.getElementById('attemptSubmit');
|
|
const attemptInput = document.getElementById('attemptInput');
|
|
const feedback = document.getElementById('attemptFeedback');
|
|
|
|
if (submitBtn && currentExercise) {
|
|
const doCheck = () => {
|
|
const result = checkAnswer(attemptInput.value, currentExercise);
|
|
feedback.className = 'attempt-feedback';
|
|
feedback.classList.add(result.correct ? 'attempt-feedback--correct' : 'attempt-feedback--incorrect');
|
|
feedback.textContent = result.message;
|
|
};
|
|
submitBtn.addEventListener('click', doCheck, { signal: ac.signal });
|
|
attemptInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') doCheck();
|
|
}, { signal: ac.signal });
|
|
}
|
|
|
|
// Reveal steps
|
|
const revealBtn = document.getElementById('revealBtn');
|
|
if (revealBtn) {
|
|
revealBtn.addEventListener('click', revealNextStep, { signal: ac.signal });
|
|
}
|
|
}
|
|
|
|
async function init(el, params) {
|
|
container = el;
|
|
revealedSteps = 0;
|
|
currentExercise = null;
|
|
|
|
if (!params || !params.id) {
|
|
container.innerHTML = '<p>Ejercicio no especificado.</p>';
|
|
return;
|
|
}
|
|
|
|
const ex = await findExercise(params.id);
|
|
if (!ex) {
|
|
container.innerHTML = `<p>Ejercicio "${escapeHtml(params.id)}" no encontrado.</p>`;
|
|
return;
|
|
}
|
|
|
|
currentExercise = ex;
|
|
container.innerHTML = `
|
|
<div class="exercise-viewer">
|
|
<button class="exercise-viewer__back">← Volver a ejercicios</button>
|
|
${renderProblem(ex)}
|
|
</div>
|
|
`;
|
|
|
|
renderStatement(ex);
|
|
setupEvents();
|
|
}
|
|
|
|
function destroy() {
|
|
if (ac) {
|
|
ac.abort();
|
|
ac = null;
|
|
}
|
|
if (container) container.innerHTML = '';
|
|
container = null;
|
|
currentExercise = null;
|
|
revealedSteps = 0;
|
|
}
|
|
|
|
return { init, destroy };
|
|
})();
|