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:
250
js/app.js
Normal file
250
js/app.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* App — Hash router, component lifecycle, navigation, event bus
|
||||
*/
|
||||
const App = (() => {
|
||||
let currentComponent = null;
|
||||
let appEl = null;
|
||||
|
||||
const routes = [
|
||||
{ pattern: /^\/$/, handler: showHome },
|
||||
{ pattern: /^\/exercises$/, handler: showExercises },
|
||||
{ pattern: /^\/exercise\/(.+)$/, handler: showExercise },
|
||||
{ pattern: /^\/workspace$/, handler: showWorkspace },
|
||||
{ pattern: /^\/workspace\/matrix$/, handler: showMatrixBuilder },
|
||||
{ pattern: /^\/workspace\/system$/, handler: showSystemSolver }
|
||||
];
|
||||
|
||||
// ── Event Bus ──
|
||||
const bus = {};
|
||||
function on(event, fn) {
|
||||
(bus[event] = bus[event] || []).push(fn);
|
||||
}
|
||||
function emit(event, data) {
|
||||
(bus[event] || []).forEach(fn => fn(data));
|
||||
}
|
||||
|
||||
// ── Theme ──
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('algebra-theme');
|
||||
if (saved === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
if (isDark) {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
localStorage.setItem('algebra-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem('algebra-theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
function navigate(path) {
|
||||
window.location.hash = '#' + path;
|
||||
}
|
||||
|
||||
function getHash() {
|
||||
return window.location.hash.slice(1) || '/';
|
||||
}
|
||||
|
||||
function matchRoute(hash) {
|
||||
for (const route of routes) {
|
||||
const match = hash.match(route.pattern);
|
||||
if (match) {
|
||||
return { handler: route.handler, params: match.slice(1) };
|
||||
}
|
||||
}
|
||||
return { handler: showHome, params: [] };
|
||||
}
|
||||
|
||||
function updateActiveNav() {
|
||||
const hash = getHash();
|
||||
document.querySelectorAll('.navbar__link').forEach(link => {
|
||||
const route = link.getAttribute('data-route');
|
||||
link.classList.remove('navbar__link--active');
|
||||
if (route === 'home' && (hash === '/' || hash === '')) {
|
||||
link.classList.add('navbar__link--active');
|
||||
} else if (route === 'exercises' && hash.startsWith('/exercise')) {
|
||||
link.classList.add('navbar__link--active');
|
||||
} else if (route === 'workspace' && hash.startsWith('/workspace')) {
|
||||
link.classList.add('navbar__link--active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Component Lifecycle ──
|
||||
function destroyCurrent() {
|
||||
if (currentComponent && typeof currentComponent.destroy === 'function') {
|
||||
currentComponent.destroy();
|
||||
}
|
||||
currentComponent = null;
|
||||
if (appEl) appEl.innerHTML = '';
|
||||
}
|
||||
|
||||
function mountComponent(componentObj, initArgs) {
|
||||
destroyCurrent();
|
||||
currentComponent = componentObj;
|
||||
componentObj.init(appEl, initArgs);
|
||||
}
|
||||
|
||||
// ── Page Handlers ──
|
||||
function showHome() {
|
||||
destroyCurrent();
|
||||
appEl.innerHTML = `
|
||||
<div class="home-page">
|
||||
<div class="home-page__hero">
|
||||
<h1>Álgebra Lineal Interactivo</h1>
|
||||
<p>Practicá los ejercicios de Vectores, Recta, Plano, Matrices, Determinantes y Sistemas de Ecuaciones. Paso a paso, con verificación automática.</p>
|
||||
</div>
|
||||
<div class="home-page__cards">
|
||||
<div class="home-card" data-nav="/exercises">
|
||||
<div class="home-card__icon">📚</div>
|
||||
<div class="home-card__title">Ejercicios</div>
|
||||
<div class="home-card__desc">68 ejercicios de los capítulos 1 a 3 con solución paso a paso</div>
|
||||
</div>
|
||||
<div class="home-card" data-nav="/workspace">
|
||||
<div class="home-card__icon">🧮</div>
|
||||
<div class="home-card__title">Taller</div>
|
||||
<div class="home-card__desc">Constructor de matrices y resolvedor de sistemas interactivos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
// Click handlers for home cards
|
||||
appEl.querySelectorAll('.home-card[data-nav]').forEach(card => {
|
||||
card.addEventListener('click', () => navigate(card.dataset.nav));
|
||||
});
|
||||
}
|
||||
|
||||
function showExercises() {
|
||||
mountComponent(ExerciseBrowser);
|
||||
}
|
||||
|
||||
function showExercise(params) {
|
||||
mountComponent(ExerciseViewer, { id: params[0] });
|
||||
}
|
||||
|
||||
function showWorkspace() {
|
||||
destroyCurrent();
|
||||
appEl.innerHTML = `
|
||||
<div class="workspace">
|
||||
<div class="workspace__header">
|
||||
<h2>Taller de Cálculo</h2>
|
||||
<p>Operá con matrices y resolvé sistemas de ecuaciones</p>
|
||||
</div>
|
||||
<div class="workspace__tabs">
|
||||
<button class="workspace__tab workspace__tab--active" data-ws="matrix">Constructor de Matrices</button>
|
||||
<button class="workspace__tab" data-ws="system">Resolvedor de Sistemas</button>
|
||||
</div>
|
||||
<div class="workspace__content" id="workspaceContent"></div>
|
||||
</div>
|
||||
`;
|
||||
setupWorkspaceTabs();
|
||||
// Default to matrix
|
||||
const wsContent = document.getElementById('workspaceContent');
|
||||
MatrixBuilder.init(wsContent);
|
||||
currentComponent = MatrixBuilder;
|
||||
}
|
||||
|
||||
function setupWorkspaceTabs() {
|
||||
const tabs = appEl.querySelectorAll('.workspace__tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach(t => t.classList.remove('workspace__tab--active'));
|
||||
tab.classList.add('workspace__tab--active');
|
||||
const ws = tab.dataset.ws;
|
||||
const wsContent = document.getElementById('workspaceContent');
|
||||
if (!wsContent) return;
|
||||
|
||||
if (currentComponent && typeof currentComponent.destroy === 'function') {
|
||||
currentComponent.destroy();
|
||||
}
|
||||
wsContent.innerHTML = '';
|
||||
|
||||
if (ws === 'matrix') {
|
||||
MatrixBuilder.init(wsContent);
|
||||
currentComponent = MatrixBuilder;
|
||||
} else if (ws === 'system') {
|
||||
SystemSolver.init(wsContent);
|
||||
currentComponent = SystemSolver;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showMatrixBuilder() {
|
||||
navigate('/workspace');
|
||||
}
|
||||
|
||||
function showSystemSolver() {
|
||||
navigate('/workspace');
|
||||
}
|
||||
|
||||
// ── Router ──
|
||||
function handleRoute() {
|
||||
const hash = getHash();
|
||||
const { handler, params } = matchRoute(hash);
|
||||
updateActiveNav();
|
||||
|
||||
// Fade out
|
||||
if (appEl) {
|
||||
appEl.style.opacity = '0';
|
||||
appEl.style.transition = 'opacity 150ms ease-out';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
handler(params);
|
||||
emit('routechange', hash);
|
||||
// Fade in
|
||||
if (appEl) {
|
||||
appEl.style.opacity = '1';
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
function init() {
|
||||
appEl = document.getElementById('app');
|
||||
if (!appEl) {
|
||||
console.error('[App] #app element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
initTheme();
|
||||
|
||||
// Init particles background
|
||||
if (typeof Particles !== 'undefined' && Particles.init) {
|
||||
Particles.init();
|
||||
}
|
||||
|
||||
// Theme toggle
|
||||
const themeBtn = document.getElementById('themeToggle');
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', toggleTheme);
|
||||
}
|
||||
|
||||
// Hash change listener
|
||||
window.addEventListener('hashchange', handleRoute);
|
||||
|
||||
// Initial route
|
||||
handleRoute();
|
||||
}
|
||||
|
||||
// Boot
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
return {
|
||||
navigate,
|
||||
on,
|
||||
emit,
|
||||
getCurrentComponent: () => currentComponent
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user