Files
algebra-webapp/js/components/exercise-browser.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

201 lines
6.8 KiB
JavaScript

/**
* ExerciseBrowser — Topic filter tabs, exercise list, search, navigation to viewer
*/
const ExerciseBrowser = (() => {
let container = null;
let exercises = [];
let filteredExercises = [];
let activeTopic = 'all';
let searchText = '';
let exerciseDataLoaded = {};
let ac = null;
let searchDebounceTimer = null;
const TOPICS = [
{ key: 'all', label: 'Todos', icon: '📚' },
{ key: 'vector-ops', label: 'Vectores', icon: '➡️' },
{ key: 'line-eq', label: 'Rectas', icon: '📏' },
{ key: 'plane-eq', label: 'Planos', icon: '📐' },
{ key: 'matrix-ops', label: 'Matrices', icon: '🔲' },
{ key: 'determinants', label: 'Determinantes', icon: 'det' },
{ key: 'matrix-inverse', label: 'Inversa', icon: '🔄' },
{ key: 'systems', label: 'Sistemas', icon: '⚖️' }
];
const CHAPTER_NAMES = {
1: 'Cap. 1 — Vectores, Recta y Plano',
2: 'Cap. 2 — Matrices y Determinantes',
3: 'Cap. 3 — Sistemas de Ecuaciones'
};
async function loadExerciseData() {
if (Object.keys(exerciseDataLoaded).length > 0) return exerciseDataLoaded;
const chapters = [1, 2, 3];
const promises = chapters.map(async (ch) => {
try {
const resp = await fetch(`data/exercises-cap0${ch}.json`);
exerciseDataLoaded[ch] = await resp.json();
} catch (e) {
console.warn(`[ExerciseBrowser] Failed to load cap${ch}:`, e);
exerciseDataLoaded[ch] = [];
}
});
await Promise.all(promises);
exercises = Object.values(exerciseDataLoaded).flat();
return exerciseDataLoaded;
}
function getTopicCounts() {
const counts = { all: exercises.length };
exercises.forEach(ex => {
counts[ex.topic] = (counts[ex.topic] || 0) + 1;
});
return counts;
}
function filterExercises() {
filteredExercises = exercises.filter(ex => {
const topicMatch = activeTopic === 'all' || ex.topic === activeTopic;
if (!searchText) return topicMatch;
const search = searchText.toLowerCase();
const textMatch =
(ex.statement || '').toLowerCase().includes(search) ||
(ex.id || '').toLowerCase().includes(search) ||
(ex.subtopic || '').toLowerCase().includes(search);
return topicMatch && textMatch;
});
}
function renderTopicTabs() {
const counts = getTopicCounts();
return `<div class="topic-tabs">${TOPICS.map(t => {
const isActive = t.key === activeTopic ? ' topic-tab--active' : '';
const count = counts[t.key] || 0;
return `<button class="topic-tab${isActive}" data-topic="${t.key}">
<span>${t.icon}</span> ${t.label} <span class="topic-tab__count">${count}</span>
</button>`;
}).join('')}</div>`;
}
function renderExerciseCards() {
if (filteredExercises.length === 0) {
return `<div class="exercise-browser__empty"><p>No se encontraron ejercicios</p></div>`;
}
return `<div class="exercise-grid">${filteredExercises.map(ex => {
const topicInfo = TOPICS.find(t => t.key === ex.topic) || { label: ex.topic };
const difficultyClass = `difficulty--${ex.difficulty || 'basic'}`;
const diffLabel = { basic: 'Básico', intermediate: 'Intermedio', advanced: 'Avanzado' };
return `<div class="exercise-card" data-exercise-id="${ex.id}" tabindex="0" role="button" aria-label="Ver ejercicio ${ex.id}">
<div class="exercise-card__id">${ex.id}</div>
<span class="exercise-card__topic">${topicInfo.icon || ''} ${topicInfo.label}</span>
<div class="exercise-card__statement">${ex.statement}</div>
<div class="exercise-card__difficulty ${difficultyClass}">${diffLabel[ex.difficulty] || ex.difficulty}</div>
</div>`;
}).join('')}</div>`;
}
function escapeAttr(str) {
return (str || '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function render() {
if (!container) return;
filterExercises();
container.innerHTML = `
<div class="exercise-browser">
<div class="exercise-browser__header">
<h2>Ejercicios</h2>
<p>Seleccioná un tema para filtrar los ejercicios</p>
</div>
<input type="text" class="exercise-browser__search" placeholder="Buscar ejercicios..." value="${searchText}">
${renderTopicTabs()}
${renderExerciseCards()}
</div>
`;
// Skip KaTeX on cards — text+math mixing causes spacing loss in math mode.
// Full KaTeX rendering happens in exercise-viewer when user opens the exercise.
}
function renderMathInElements() {
// Disabled: KaTeX rendering on cards removed due to text spacing issues.
// The statement is shown as plain text; full rendering in exercise-viewer.
return;
}
function handleTopicClick(e) {
const tab = e.target.closest('.topic-tab');
if (!tab) return;
activeTopic = tab.dataset.topic;
render();
}
function handleCardClick(e) {
const card = e.target.closest('.exercise-card');
if (!card) return;
const id = card.dataset.exerciseId;
if (id && typeof App !== 'undefined') {
App.navigate(`/exercise/${id}`);
}
}
function handleSearch(e) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
searchText = e.target.value;
filterExercises();
// Re-render only the grid, not the tabs
const grid = container.querySelector('.exercise-grid');
const empty = container.querySelector('.exercise-browser__empty');
if (grid || empty) {
const parent = grid ? grid.parentElement : empty.parentElement;
const targetEl = grid || empty;
const temp = document.createElement('div');
temp.innerHTML = renderExerciseCards();
targetEl.replaceWith(temp.firstElementChild);
renderMathInElements();
}
}, 300);
}
function init(el) {
container = el;
ac = new AbortController();
loadExerciseData().then(() => {
render();
container.addEventListener('click', handleTopicClick, { signal: ac.signal });
container.addEventListener('click', handleCardClick, { signal: ac.signal });
container.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
const card = e.target.closest('.exercise-card');
if (card) { e.preventDefault(); card.click(); }
}
}, { signal: ac.signal });
container.addEventListener('input', (e) => {
if (e.target.classList.contains('exercise-browser__search')) {
handleSearch(e);
}
}, { signal: ac.signal });
});
}
function destroy() {
if (ac) {
ac.abort();
ac = null;
}
clearTimeout(searchDebounceTimer);
if (container) {
container.innerHTML = '';
}
container = null;
activeTopic = 'all';
searchText = '';
}
function getExercises() {
return exercises;
}
return { init, destroy, getExercises, loadExerciseData };
})();