Files
algebra-webapp/js/components/exercise-viewer.js
renato97 45582e5f59 feat: interactive linear algebra practice web app
- 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
2026-05-20 01:26:40 -03:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 };
})();