- 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
201 lines
6.8 KiB
JavaScript
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, '"').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 };
|
|
})();
|