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
This commit is contained in:
200
js/components/exercise-browser.js
Normal file
200
js/components/exercise-browser.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user