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:
renato97
2026-05-20 01:26:40 -03:00
commit 45582e5f59
16 changed files with 5116 additions and 0 deletions

250
js/app.js Normal file
View 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
};
})();

View 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, '&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 };
})();

View File

@@ -0,0 +1,330 @@
/**
* 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 };
})();

View File

@@ -0,0 +1,286 @@
/**
* MatrixBuilder — Size selector (2x2/3x3/4x4), input grid, operation buttons
*/
const MatrixBuilder = (() => {
let container = null;
let currentSize = 3;
function render() {
container.innerHTML = `
<div class="matrix-builder">
<div class="matrix-builder__controls">
<label>Tamaño:</label>
<select class="matrix-builder__size-select" id="matrixSize">
<option value="2" ${currentSize === 2 ? 'selected' : ''}>2×2</option>
<option value="3" ${currentSize === 3 ? 'selected' : ''}>3×3</option>
<option value="4" ${currentSize === 4 ? 'selected' : ''}>4×4</option>
</select>
<button class="matrix-ops__btn" data-action="clear">Limpiar</button>
<button class="matrix-ops__btn" data-action="example">Ejemplo</button>
</div>
<div id="matrixGridContainer"></div>
<div class="matrix-ops">
<button class="matrix-ops__btn" data-op="determinant">Determinante</button>
<button class="matrix-ops__btn" data-op="transpose">Traspuesta</button>
<button class="matrix-ops__btn" data-op="trace">Traza</button>
<button class="matrix-ops__btn" data-op="rank">Rango</button>
<button class="matrix-ops__btn" data-op="inverse">Inversa</button>
<button class="matrix-ops__btn" data-op="det-sarrus">Sarrus (3×3)</button>
<button class="matrix-ops__btn" data-op="det-triangular">Triangularización</button>
</div>
<div class="error-message" id="matrixError"></div>
<div class="matrix-result" id="matrixResult">
<div class="matrix-result__title" id="resultTitle"></div>
<div class="matrix-result__value" id="resultValue"></div>
<div id="resultSteps"></div>
</div>
</div>
`;
renderGrid();
}
function renderGrid() {
const gridContainer = document.getElementById('matrixGridContainer');
if (!gridContainer) return;
let html = `<div class="matrix-grid" style="grid-template-columns: repeat(${currentSize}, 1fr); padding-left: 20px; padding-right: 20px;">`;
for (let i = 0; i < currentSize; i++) {
for (let j = 0; j < currentSize; j++) {
html += `<input type="text" class="matrix-grid__cell" id="cell-${i}-${j}" data-row="${i}" data-col="${j}">`;
}
}
html += '</div>';
gridContainer.innerHTML = html;
}
function getMatrix() {
const M = [];
let hasEmpty = false;
for (let i = 0; i < currentSize; i++) {
const row = [];
for (let j = 0; j < currentSize; j++) {
const cell = document.getElementById(`cell-${i}-${j}`);
if (!cell) return null;
const val = cell.value.trim();
if (val === '') {
cell.classList.add('matrix-grid__cell--error');
hasEmpty = true;
} else {
cell.classList.remove('matrix-grid__cell--error');
}
const num = parseFloat(val);
if (isNaN(num)) {
cell.classList.add('matrix-grid__cell--error');
hasEmpty = true;
}
row.push(num || 0);
}
M.push(row);
}
if (hasEmpty) {
showError('Completá todas las celdas con valores numéricos.');
return null;
}
hideError();
return M;
}
function showError(msg) {
const el = document.getElementById('matrixError');
if (el) {
el.textContent = msg;
el.classList.add('error-message--visible');
}
}
function hideError() {
const el = document.getElementById('matrixError');
if (el) el.classList.remove('error-message--visible');
}
function showResult(title, value, steps) {
const resultEl = document.getElementById('matrixResult');
const titleEl = document.getElementById('resultTitle');
const valueEl = document.getElementById('resultValue');
const stepsEl = document.getElementById('resultSteps');
titleEl.textContent = title;
resultEl.classList.add('matrix-result--visible');
// Render value
if (typeof value === 'number') {
if (typeof KatexRenderer !== 'undefined') {
KatexRenderer.renderDisplay(valueEl, `\\text{${title}} = ${MathEngine.fmtNum(value)}`);
} else {
valueEl.textContent = `${title} = ${value}`;
}
} else if (Array.isArray(value) && Array.isArray(value[0])) {
if (typeof KatexRenderer !== 'undefined') {
KatexRenderer.renderDisplay(valueEl, MathEngine.matToLatex(value));
} else {
valueEl.textContent = JSON.stringify(value);
}
} else if (Array.isArray(value)) {
if (typeof KatexRenderer !== 'undefined') {
KatexRenderer.renderDisplay(valueEl, MathEngine.vecToLatex(value));
} else {
valueEl.textContent = JSON.stringify(value);
}
} else if (typeof value === 'object' && value !== null) {
valueEl.textContent = JSON.stringify(value);
} else if (typeof KatexRenderer !== 'undefined') {
KatexRenderer.renderDisplay(valueEl, String(value));
} else {
valueEl.textContent = String(value);
}
// Render steps
stepsEl.innerHTML = '';
if (steps && steps.length > 0) {
const ol = document.createElement('ol');
ol.className = 'matrix-result__steps';
steps.forEach((step, idx) => {
const li = document.createElement('li');
li.className = 'matrix-result__step';
const descDiv = document.createElement('div');
descDiv.textContent = step.desc;
const exprDiv = document.createElement('div');
if (typeof KatexRenderer !== 'undefined') {
KatexRenderer.renderDisplay(exprDiv, step.latex || step.expression || '');
} else {
exprDiv.textContent = step.latex || step.expression || '';
}
li.appendChild(descDiv);
li.appendChild(exprDiv);
ol.appendChild(li);
});
stepsEl.appendChild(ol);
}
}
function executeOperation(op) {
const M = getMatrix();
if (!M) return;
let result;
try {
const ME = MathEngine;
switch (op) {
case 'determinant':
if (currentSize === 2) result = ME.determinant.det2x2(M);
else if (currentSize === 3) result = ME.determinant.det3x3Sarrus(M);
else result = ME.determinant.det(M);
break;
case 'det-sarrus':
if (currentSize !== 3) { showError('Sarrus solo aplica a matrices 3×3.'); return; }
result = ME.determinant.det3x3Sarrus(M);
break;
case 'det-triangular':
result = ME.determinant.detByTriangularization(M);
break;
case 'transpose':
result = ME.matrix.transpose(M);
break;
case 'trace':
result = ME.matrix.trace(M);
break;
case 'rank':
result = ME.system.rank(M);
break;
case 'inverse':
if (currentSize === 2) result = ME.inverse.inverse2x2(M);
else result = ME.inverse.inverse(M);
break;
default:
showError('Operación no reconocida.');
return;
}
if (result.error) {
showError(result.error);
return;
}
const titles = {
'determinant': 'Determinante',
'det-sarrus': 'Determinante (Sarrus)',
'det-triangular': 'Determinante (Triangularización)',
'transpose': 'Traspuesta',
'trace': 'Traza',
'rank': 'Rango',
'inverse': 'Inversa'
};
showResult(titles[op] || op, result.value, result.steps);
} catch (e) {
showError('Error: ' + e.message);
}
}
function clearGrid() {
for (let i = 0; i < currentSize; i++)
for (let j = 0; j < currentSize; j++) {
const cell = document.getElementById(`cell-${i}-${j}`);
if (cell) { cell.value = ''; cell.classList.remove('matrix-grid__cell--error'); }
}
const resultEl = document.getElementById('matrixResult');
if (resultEl) resultEl.classList.remove('matrix-result--visible');
hideError();
}
function fillExample() {
const examples = {
2: [[1, 2], [3, 4]],
3: [[1, 2, 3], [4, 5, 2], [6, 3, 1]],
4: [[1, 2, 0, 1], [3, 0, 1, 2], [1, 1, 2, 0], [2, 3, 1, 1]]
};
const ex = examples[currentSize];
for (let i = 0; i < currentSize; i++)
for (let j = 0; j < currentSize; j++) {
const cell = document.getElementById(`cell-${i}-${j}`);
if (cell) cell.value = ex[i][j];
}
}
function handleSizeChange(e) {
currentSize = parseInt(e.target.value);
renderGrid();
const resultEl = document.getElementById('matrixResult');
if (resultEl) resultEl.classList.remove('matrix-result--visible');
hideError();
}
function handleOpClick(e) {
const btn = e.target.closest('[data-op]');
if (!btn) return;
executeOperation(btn.dataset.op);
}
function handleActionClick(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'clear') clearGrid();
else if (action === 'example') fillExample();
}
function init(el) {
container = el;
render();
container.addEventListener('change', (e) => {
if (e.target.id === 'matrixSize') handleSizeChange(e);
});
container.addEventListener('click', handleOpClick);
container.addEventListener('click', handleActionClick);
}
function destroy() {
if (container) {
container.innerHTML = '';
}
container = null;
}
return { init, destroy, clearGrid, fillExample };
})();

View File

@@ -0,0 +1,310 @@
/**
* SystemSolver — System input, method selector, step-by-step solution display
*/
const SystemSolver = (() => {
let container = null;
let currentSize = 3;
let currentMethod = 'gauss';
function render() {
container.innerHTML = `
<div class="system-solver">
<div class="system-solver__controls">
<label>Incógnitas:</label>
<select class="system-solver__size-select" id="systemSize">
<option value="2" ${currentSize === 2 ? 'selected' : ''}>2</option>
<option value="3" ${currentSize === 3 ? 'selected' : ''}>3</option>
</select>
<label>Método:</label>
<select class="system-solver__method-select" id="systemMethod">
<option value="gauss" ${currentMethod === 'gauss' ? 'selected' : ''}>Gauss</option>
<option value="gauss-jordan" ${currentMethod === 'gauss-jordan' ? 'selected' : ''}>Gauss-Jordan</option>
<option value="cramer" ${currentMethod === 'cramer' ? 'selected' : ''}>Cramer</option>
<option value="rouche-frobenius" ${currentMethod === 'rouche-frobenius' ? 'selected' : ''}>Rouché-Frobenius</option>
</select>
<button class="matrix-ops__btn" data-action="clear">Limpiar</button>
<button class="matrix-ops__btn" data-action="example">Ejemplo</button>
</div>
<p class="system-solver__matrix-label">Matriz de coeficientes | Términos independientes</p>
<div id="systemGridContainer"></div>
<button class="system-solver__solve-btn" id="solveBtn">Resolver</button>
<div class="error-message" id="systemError"></div>
<div class="matrix-result" id="systemResult">
<div class="matrix-result__title" id="resultTitle"></div>
<div class="matrix-result__value" id="resultValue"></div>
<div id="resultSteps"></div>
</div>
</div>
`;
renderGrid();
}
function renderGrid() {
const gridContainer = document.getElementById('systemGridContainer');
if (!gridContainer) return;
const n = currentSize;
// n columns for coefficients + divider + 1 column for constants
let html = `<div class="augmented-grid" style="grid-template-columns: repeat(${n}, 1fr) auto 1fr; padding-left: 24px; padding-right: 24px; gap: 4px;">`;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
html += `<input type="text" class="matrix-grid__cell" id="sys-coeff-${i}-${j}" data-row="${i}" data-col="${j}" placeholder="a${i + 1}${j + 1}">`;
}
// Divider column (one per row, but we only need 1 actual divider)
if (i === 0) {
html += `<div class="augmented-grid__divider" style="grid-row: span ${n};"></div>`;
} else {
// Empty cell for grid alignment — no, the divider spans all rows
}
html += `<input type="text" class="matrix-grid__cell" id="sys-const-${i}" data-row="${i}" placeholder="b${i + 1}" style="border-color: var(--color-accent);">`;
}
html += '</div>';
gridContainer.innerHTML = html;
}
function getSystem() {
const n = currentSize;
const A = [];
const b = [];
let hasError = false;
for (let i = 0; i < n; i++) {
const row = [];
for (let j = 0; j < n; j++) {
const cell = document.getElementById(`sys-coeff-${i}-${j}`);
if (!cell) return null;
const val = cell.value.trim();
if (val === '') {
cell.classList.add('matrix-grid__cell--error');
hasError = true;
} else {
cell.classList.remove('matrix-grid__cell--error');
}
const num = parseFloat(val);
if (isNaN(num)) {
cell.classList.add('matrix-grid__cell--error');
hasError = true;
}
row.push(num || 0);
}
A.push(row);
const bCell = document.getElementById(`sys-const-${i}`);
if (!bCell) return null;
const bVal = bCell.value.trim();
if (bVal === '') {
bCell.classList.add('matrix-grid__cell--error');
hasError = true;
} else {
bCell.classList.remove('matrix-grid__cell--error');
}
b.push(parseFloat(bVal) || 0);
}
if (hasError) {
showError('Completá todas las celdas con valores numéricos.');
return null;
}
hideError();
return { A, b };
}
function showError(msg) {
const el = document.getElementById('systemError');
if (el) {
el.textContent = msg;
el.classList.add('error-message--visible');
}
}
function hideError() {
const el = document.getElementById('systemError');
if (el) el.classList.remove('error-message--visible');
}
function showResult(title, value, steps) {
const resultEl = document.getElementById('systemResult');
const titleEl = document.getElementById('resultTitle');
const valueEl = document.getElementById('resultValue');
const stepsEl = document.getElementById('resultSteps');
titleEl.textContent = title;
resultEl.classList.add('matrix-result--visible');
// Render value
if (typeof value === 'object' && value !== null && value.type) {
// Classification result
let text = '';
if (value.type === 'CD') text = 'Compatible Determinado';
else if (value.type === 'CI') text = `Compatible Indeterminado (${value.freeVars || '?'} parámetros libres)`;
else if (value.type === 'SI') text = 'Sistema Incompatible';
else if (value.type === 'indeterminate') text = 'Compatible Indeterminado';
else if (value.type === 'trivial') text = 'Solución trivial';
valueEl.textContent = text;
if (value.solution) {
const solDiv = document.createElement('div');
solDiv.style.marginTop = '8px';
if (typeof KatexRenderer !== 'undefined') {
KatexRenderer.renderDisplay(solDiv, 'x = ' + MathEngine.vecToLatex(value.solution));
} else {
solDiv.textContent = 'x = ' + JSON.stringify(value.solution);
}
valueEl.appendChild(solDiv);
}
} else if (Array.isArray(value)) {
if (typeof KatexRenderer !== 'undefined') {
KatexRenderer.renderDisplay(valueEl, 'x = ' + MathEngine.vecToLatex(value));
} else {
valueEl.textContent = 'x = ' + JSON.stringify(value);
}
} else {
valueEl.textContent = String(value);
}
// Render steps
stepsEl.innerHTML = '';
if (steps && steps.length > 0) {
const ol = document.createElement('ol');
ol.className = 'matrix-result__steps';
steps.forEach((step) => {
const li = document.createElement('li');
li.className = 'matrix-result__step';
const descDiv = document.createElement('div');
descDiv.textContent = step.desc;
const exprDiv = document.createElement('div');
if (typeof KatexRenderer !== 'undefined' && (step.latex || step.expression)) {
KatexRenderer.renderDisplay(exprDiv, step.latex || step.expression);
} else {
exprDiv.textContent = step.latex || step.expression || '';
}
li.appendChild(descDiv);
li.appendChild(exprDiv);
ol.appendChild(li);
});
stepsEl.appendChild(ol);
}
}
function solve() {
const system = getSystem();
if (!system) return;
const { A, b } = system;
let result;
try {
const ME = MathEngine;
switch (currentMethod) {
case 'gauss':
result = ME.system.gaussElimination(A, b);
break;
case 'gauss-jordan':
result = ME.system.gaussJordan(A, b);
break;
case 'cramer':
result = ME.system.cramer(A, b);
break;
case 'rouche-frobenius':
result = ME.system.roucheFrobenius(A, b);
break;
default:
showError('Método no reconocido.');
return;
}
if (result.error) {
showError(result.error);
// Still show steps
if (result.steps && result.steps.length > 0) {
showResult('Clasificación', null, result.steps);
}
return;
}
const titles = {
'gauss': 'Eliminación de Gauss',
'gauss-jordan': 'Gauss-Jordan',
'cramer': 'Regla de Cramer',
'rouche-frobenius': 'Rouché-Frobenius'
};
showResult(titles[currentMethod] || currentMethod, result.value, result.steps);
} catch (e) {
showError('Error: ' + e.message);
}
}
function clearGrid() {
const n = currentSize;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const cell = document.getElementById(`sys-coeff-${i}-${j}`);
if (cell) { cell.value = ''; cell.classList.remove('matrix-grid__cell--error'); }
}
const bCell = document.getElementById(`sys-const-${i}`);
if (bCell) { bCell.value = ''; bCell.classList.remove('matrix-grid__cell--error'); }
}
const resultEl = document.getElementById('systemResult');
if (resultEl) resultEl.classList.remove('matrix-result--visible');
hideError();
}
function fillExample() {
const examples = {
2: { A: [[2, 1], [1, 1]], b: [7, 4] },
3: { A: [[2, 1, -1], [-3, -1, 2], [-2, 1, 2]], b: [8, -11, -3] }
};
const ex = examples[currentSize];
const n = currentSize;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const cell = document.getElementById(`sys-coeff-${i}-${j}`);
if (cell) cell.value = ex.A[i][j];
}
const bCell = document.getElementById(`sys-const-${i}`);
if (bCell) bCell.value = ex.b[i];
}
}
function init(el) {
container = el;
render();
container.addEventListener('change', (e) => {
if (e.target.id === 'systemSize') {
currentSize = parseInt(e.target.value);
renderGrid();
const resultEl = document.getElementById('systemResult');
if (resultEl) resultEl.classList.remove('matrix-result--visible');
hideError();
}
if (e.target.id === 'systemMethod') {
currentMethod = e.target.value;
}
});
container.addEventListener('click', (e) => {
if (e.target.id === 'solveBtn') solve();
});
container.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'clear') clearGrid();
else if (action === 'example') fillExample();
});
}
function destroy() {
if (container) container.innerHTML = '';
container = null;
}
return { init, destroy, clearGrid, fillExample };
})();

691
js/math-engine.js Normal file
View File

@@ -0,0 +1,691 @@
/**
* MathEngine — Pure JS Linear Algebra Computation Library
* Every method returns { value, steps: [{desc, latex}] }
* Uses namespace pattern (not ES modules) for CDN compatibility.
*/
const MathEngine = (() => {
const EPS = 0.001;
// ── Helpers ──────────────────────────────────────────
function round(x) {
return Math.abs(x) < EPS ? 0 : Math.round(x * 10000) / 10000;
}
function cloneMatrix(M) {
return M.map(row => [...row]);
}
function isMatrix(M) {
return Array.isArray(M) && Array.isArray(M[0]);
}
function rows(M) { return M.length; }
function cols(M) { return M[0].length; }
function makeResult(value, steps) {
return { value, steps: steps || [] };
}
function makeError(msg, steps) {
return { error: msg, steps: steps || [] };
}
function vecToLatex(v) {
return '(' + v.map(x => fmtNum(x)).join(';\\;') + ')';
}
function matToLatex(M) {
const r = M.length;
const c = M[0].length;
let s = '\\begin{pmatrix}';
for (let i = 0; i < r; i++) {
s += M[i].map(x => fmtNum(x)).join(' & ');
if (i < r - 1) s += ' \\\\ ';
}
s += '\\end{pmatrix}';
return s;
}
function fmtNum(n) {
if (n === undefined || n === null) return '0';
const r = round(n);
if (Number.isInteger(r)) return String(r);
return r.toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
}
// ── VECTOR OPERATIONS ───────────────────────────────
const vector = {
components(A, B) {
const steps = [];
const v = A.map((a, i) => round(B[i] - a));
steps.push({ desc: 'Componentes del vector AB = B - A', latex: `\\overrightarrow{AB} = ${vecToLatex(B)} - ${vecToLatex(A)} = ${vecToLatex(v)}` });
return makeResult(v, steps);
},
magnitude(v) {
const steps = [];
const squares = v.map(x => `${fmtNum(x)}^2`).join(' + ');
const sum = v.reduce((s, x) => s + x * x, 0);
const mag = round(Math.sqrt(sum));
steps.push({ desc: 'M\u00f3dulo del vector', latex: `|\\vec{v}| = \\sqrt{${squares}} = \\sqrt{${fmtNum(sum)}} = ${fmtNum(mag)}` });
return makeResult(mag, steps);
},
unitVector(v) {
const steps = [];
const mag = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
if (mag < EPS) return makeError('No se puede obtener vector unitario del vector nulo');
const u = v.map(x => round(x / mag));
steps.push({ desc: 'Vector unitario = v / |v|', latex: `\\hat{u} = \\frac{${vecToLatex(v)}}{${fmtNum(mag)}} = ${vecToLatex(u)}` });
return makeResult(u, steps);
},
add(a, b) {
const steps = [];
const r = a.map((x, i) => round(x + b[i]));
steps.push({ desc: 'Suma de vectores componente a componente', latex: `${vecToLatex(a)} + ${vecToLatex(b)} = ${vecToLatex(r)}` });
return makeResult(r, steps);
},
scale(k, v) {
const steps = [];
const r = v.map(x => round(k * x));
steps.push({ desc: 'Producto escalar', latex: `${fmtNum(k)} \\cdot ${vecToLatex(v)} = ${vecToLatex(r)}` });
return makeResult(r, steps);
},
dotProduct(a, b) {
const steps = [];
const products = a.map((x, i) => `(${fmtNum(x)})(${fmtNum(b[i])})`).join(' + ');
const val = a.reduce((s, x, i) => s + x * b[i], 0);
const rv = round(val);
steps.push({ desc: 'Producto escalar', latex: `\\vec{u} \\cdot \\vec{v} = ${products} = ${fmtNum(rv)}` });
return makeResult(rv, steps);
},
crossProduct(a, b) {
const steps = [];
const r = [
round(a[1] * b[2] - a[2] * b[1]),
round(a[2] * b[0] - a[0] * b[2]),
round(a[0] * b[1] - a[1] * b[0])
];
steps.push({ desc: 'Producto vectorial por determinante', latex: `\\vec{u} \\times \\vec{v} = \\begin{vmatrix} \\mathbf{i} & \\mathbf{j} & \\mathbf{k} \\\\ ${fmtNum(a[0])} & ${fmtNum(a[1])} & ${fmtNum(a[2])} \\\\ ${fmtNum(b[0])} & ${fmtNum(b[1])} & ${fmtNum(b[2])} \\end{vmatrix}` });
steps.push({ desc: 'Resultado', latex: `\\vec{u} \\times \\vec{v} = ${vecToLatex(r)}` });
return makeResult(r, steps);
},
isParallel(a, b) {
const steps = [];
const cross = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
];
const isPar = cross.every(x => Math.abs(x) < EPS);
steps.push({ desc: 'Son paralelos si u \u00d7 v = 0', latex: `\\vec{u} \\times \\vec{v} = ${vecToLatex(cross.map(round))} ${isPar ? '=' : '\\neq'} \\vec{0}` });
steps.push({ desc: isPar ? 'Son paralelos' : 'No son paralelos', latex: `\\text{${isPar ? 'Son paralelos' : 'No son paralelos'}}` });
return makeResult(isPar, steps);
},
isPerpendicular(a, b) {
const steps = [];
const dot = a.reduce((s, x, i) => s + x * b[i], 0);
const isPerp = Math.abs(dot) < EPS;
steps.push({ desc: 'Son perpendiculares si u \u00b7 v = 0', latex: `\\vec{u} \\cdot \\vec{v} = ${fmtNum(round(dot))} ${isPerp ? '=' : '\\neq'} 0` });
steps.push({ desc: isPerp ? 'Son perpendiculares' : 'No son perpendiculares', latex: `\\text{${isPerp ? 'Son perpendiculares' : 'No son perpendiculares'}}` });
return makeResult(isPerp, steps);
},
mixedProduct(a, b, c) {
const steps = [];
const cross = [
b[1] * c[2] - b[2] * c[1],
b[2] * c[0] - b[0] * c[2],
b[0] * c[1] - b[1] * c[0]
];
const val = a.reduce((s, x, i) => s + x * cross[i], 0);
const rv = round(val);
steps.push({ desc: 'Producto mixto [u,v,w] = u \u00b7 (v \u00d7 w)', latex: `\\vec{v} \\times \\vec{w} = ${vecToLatex(cross.map(round))}` });
steps.push({ desc: 'Producto escalar con u', latex: `[\\vec{u},\\vec{v},\\vec{w}] = ${vecToLatex(a)} \\cdot ${vecToLatex(cross.map(round))} = ${fmtNum(rv)}` });
return makeResult(rv, steps);
},
isCoplanar(a, b, c) {
const steps = [];
const mp = vector.mixedProduct(a, b, c);
const isCo = Math.abs(mp.value) < EPS;
steps.push(...mp.steps);
steps.push({ desc: isCo ? 'Son coplanarios (producto mixto = 0)' : 'No son coplanarios (producto mixto \u2260 0)', latex: `[\\vec{u},\\vec{v},\\vec{w}] ${isCo ? '=' : '\\neq'} 0 \\Rightarrow \\text{${isCo ? 'Coplanarios' : 'No coplanarios'}}` });
return makeResult(isCo, steps);
},
angle(a, b) {
const steps = [];
const dot = a.reduce((s, x, i) => s + x * b[i], 0);
const magA = Math.sqrt(a.reduce((s, x) => s + x * x, 0));
const magB = Math.sqrt(b.reduce((s, x) => s + x * x, 0));
if (magA < EPS || magB < EPS) return makeError('Vector nulo no tiene \u00e1ngulo definido');
const cosA = dot / (magA * magB);
const clamped = Math.max(-1, Math.min(1, cosA));
const angleRad = Math.acos(clamped);
const angleDeg = round(angleRad * 180 / Math.PI);
steps.push({ desc: '\u00c1ngulo entre vectores', latex: `\\cos\\alpha = \\frac{\\vec{u} \\cdot \\vec{v}}{|\\vec{u}||\\vec{v}|} = \\frac{${fmtNum(round(dot))}}{${fmtNum(round(magA))} \\cdot ${fmtNum(round(magB))}} = ${fmtNum(round(cosA))}` });
steps.push({ desc: '\u00c1ngulo en grados', latex: `\\alpha = \\arccos(${fmtNum(round(cosA))}) = ${fmtNum(angleDeg)}^\\circ` });
return makeResult(angleDeg, steps);
}
};
// ── MATRIX OPERATIONS ───────────────────────────────
const matrix = {
create(rows, cols, fill = 0) {
return Array.from({ length: rows }, () => Array(cols).fill(fill));
},
identity(n) {
const M = matrix.create(n, n);
for (let i = 0; i < n; i++) M[i][i] = 1;
return M;
},
zeros(r, c) {
if (c === undefined) c = r;
return matrix.create(r, c, 0);
},
add(A, B) {
const steps = [];
const r = rows(A), c = cols(A);
const R = matrix.create(r, c);
for (let i = 0; i < r; i++)
for (let j = 0; j < c; j++)
R[i][j] = round(A[i][j] + B[i][j]);
steps.push({ desc: 'Suma componente a componente', latex: `${matToLatex(A)} + ${matToLatex(B)} = ${matToLatex(R)}` });
return makeResult(R, steps);
},
subtract(A, B) {
const steps = [];
const r = rows(A), c = cols(A);
const R = matrix.create(r, c);
for (let i = 0; i < r; i++)
for (let j = 0; j < c; j++)
R[i][j] = round(A[i][j] - B[i][j]);
steps.push({ desc: 'Resta componente a componente', latex: `${matToLatex(A)} - ${matToLatex(B)} = ${matToLatex(R)}` });
return makeResult(R, steps);
},
scale(k, A) {
const steps = [];
const R = A.map(row => row.map(x => round(k * x)));
steps.push({ desc: 'Producto escalar por la matriz', latex: `${fmtNum(k)} \\cdot ${matToLatex(A)} = ${matToLatex(R)}` });
return makeResult(R, steps);
},
multiply(A, B) {
const steps = [];
const rA = rows(A), cA = cols(A), cB = cols(B);
if (cA !== B.length) return makeError(`Dimensiones incompatibles: ${rA}\u00d7${cA} por ${B.length}\u00d7${cB}`);
const R = matrix.create(rA, cB);
let detail = '';
for (let i = 0; i < rA; i++) {
for (let j = 0; j < cB; j++) {
let sum = 0;
const terms = [];
for (let k = 0; k < cA; k++) {
sum += A[i][k] * B[k][j];
terms.push(`(${fmtNum(A[i][k])})(${fmtNum(B[k][j])})`);
}
R[i][j] = round(sum);
}
}
steps.push({ desc: `Producto de matrices ${rA}\u00d7${cA} \u00b7 ${B.length}\u00d7${cB}`, latex: `${matToLatex(A)} \\cdot ${matToLatex(B)} = ${matToLatex(R)}` });
return makeResult(R, steps);
},
transpose(A) {
const steps = [];
const r = rows(A), c = cols(A);
const R = matrix.create(c, r);
for (let i = 0; i < r; i++)
for (let j = 0; j < c; j++)
R[j][i] = A[i][j];
steps.push({ desc: 'Traspuesta: intercambiar filas por columnas', latex: `(${matToLatex(A)})^T = ${matToLatex(R)}` });
return makeResult(R, steps);
},
isSymmetric(A) {
const n = rows(A);
for (let i = 0; i < n; i++)
for (let j = 0; j < i; j++)
if (Math.abs(A[i][j] - A[j][i]) > EPS) return makeResult(false, [{ desc: 'No es sim\u00e9trica', latex: `a_{${i}${j}} = ${fmtNum(A[i][j])} \\neq a_{${j}${i}} = ${fmtNum(A[j][i])}` }]);
return makeResult(true, [{ desc: 'La matriz es sim\u00e9trica', latex: 'A = A^T' }]);
},
trace(A) {
const steps = [];
const n = rows(A);
const diag = [];
let sum = 0;
for (let i = 0; i < n; i++) {
sum += A[i][i];
diag.push(`a_{${i + 1}${i + 1}} = ${fmtNum(A[i][i])}`);
}
steps.push({ desc: 'Traza = suma de la diagonal', latex: `\\text{tr}(A) = ${diag.join(' + ')} = ${fmtNum(round(sum))}` });
return makeResult(round(sum), steps);
}
};
// ── DETERMINANT OPERATIONS ──────────────────────────
const determinant = {
det2x2(A) {
const steps = [];
const val = A[0][0] * A[1][1] - A[0][1] * A[1][0];
const rv = round(val);
steps.push({ desc: 'Determinante 2\u00d72', latex: `\\det ${matToLatex(A)} = (${fmtNum(A[0][0])})(${fmtNum(A[1][1])}) - (${fmtNum(A[0][1])})(${fmtNum(A[1][0])})` });
steps.push({ desc: 'Resultado', latex: `= ${fmtNum(A[0][0] * A[1][1])} - ${fmtNum(A[0][1] * A[1][0])} = ${fmtNum(rv)}` });
return makeResult(rv, steps);
},
det3x3Sarrus(A) {
const steps = [];
steps.push({ desc: 'Regla de Sarrus', latex: `\\det ${matToLatex(A)}` });
const pos =
A[0][0] * A[1][1] * A[2][2] +
A[0][1] * A[1][2] * A[2][0] +
A[0][2] * A[1][0] * A[2][1];
const neg =
A[0][2] * A[1][1] * A[2][0] +
A[0][0] * A[1][2] * A[2][1] +
A[0][1] * A[1][0] * A[2][2];
const val = pos - neg;
const rv = round(val);
steps.push({ desc: 'Diagonales positivas', latex: `+${fmtNum(A[0][0])} \\cdot ${fmtNum(A[1][1])} \\cdot ${fmtNum(A[2][2])} + ${fmtNum(A[0][1])} \\cdot ${fmtNum(A[1][2])} \\cdot ${fmtNum(A[2][0])} + ${fmtNum(A[0][2])} \\cdot ${fmtNum(A[1][0])} \\cdot ${fmtNum(A[2][1])}` });
steps.push({ desc: 'Suma positivas', latex: `= ${fmtNum(round(pos))}` });
steps.push({ desc: 'Diagonales negativas', latex: `-${fmtNum(A[0][2])} \\cdot ${fmtNum(A[1][1])} \\cdot ${fmtNum(A[2][0])} - ${fmtNum(A[0][0])} \\cdot ${fmtNum(A[1][2])} \\cdot ${fmtNum(A[2][1])} - ${fmtNum(A[0][1])} \\cdot ${fmtNum(A[1][0])} \\cdot ${fmtNum(A[2][2])}` });
steps.push({ desc: 'Suma negativas', latex: `= -${fmtNum(round(neg))}` });
steps.push({ desc: 'Resultado', latex: `${fmtNum(round(pos))} - ${fmtNum(round(neg))} = ${fmtNum(rv)}` });
return makeResult(rv, steps);
},
cofactor(A, row, col) {
const n = rows(A);
const sub = [];
for (let i = 0; i < n; i++) {
if (i === row) continue;
const r = [];
for (let j = 0; j < n; j++) {
if (j === col) continue;
r.push(A[i][j]);
}
sub.push(r);
}
const sign = (row + col) % 2 === 0 ? 1 : -1;
const subDet = determinant.det(sub);
const val = sign * subDet.value;
const rv = round(val);
const fullSteps = [];
fullSteps.push({ desc: `Cofactor C_{${row + 1}${col + 1}}`, latex: `C_{${row + 1}${col + 1}} = ${sign > 0 ? '' : '-'} \\det ${matToLatex(sub)}` });
fullSteps.push(...subDet.steps);
fullSteps.push({ desc: 'Valor del cofactor', latex: `C_{${row + 1}${col + 1}} = ${fmtNum(rv)}` });
return makeResult(rv, fullSteps);
},
det(A) {
const n = rows(A);
if (n === 1) return makeResult(A[0][0], [{ desc: 'Determinante 1\u00d71', latex: `\\det = ${fmtNum(A[0][0])}` }]);
if (n === 2) return determinant.det2x2(A);
if (n === 3) return determinant.det3x3Sarrus(A);
// Generic cofactor expansion along first row
const steps = [];
steps.push({ desc: `Expansi\u00f3n por cofactores (fila 1)`, latex: `\\det ${matToLatex(A)}` });
let total = 0;
const parts = [];
for (let j = 0; j < n; j++) {
const sign = j % 2 === 0 ? 1 : -1;
const subDet = determinant.cofactor(A, 0, j);
const contrib = sign * A[0][j] * subDet.value;
total += contrib;
parts.push(`${sign > 0 ? '' : '-'} ${fmtNum(A[0][j])} \\cdot C_{1${j + 1}}`);
steps.push(...subDet.steps);
}
steps.push({ desc: 'Expansi\u00f3n completa', latex: parts.join(' + ') });
steps.push({ desc: 'Resultado', latex: `= ${fmtNum(round(total))}` });
return makeResult(round(total), steps);
},
detByTriangularization(A) {
const steps = [];
const n = rows(A);
const M = cloneMatrix(A);
let sign = 1;
steps.push({ desc: 'Triangularizaci\u00f3n', latex: `\\det ${matToLatex(M)}` });
for (let col = 0; col < n; col++) {
// Find pivot
let pivotRow = -1;
for (let i = col; i < n; i++) {
if (Math.abs(M[i][col]) > EPS) { pivotRow = i; break; }
}
if (pivotRow === -1) {
steps.push({ desc: 'Columna sin pivote \u2192 determinante 0', latex: `\\det = 0` });
return makeResult(0, steps);
}
if (pivotRow !== col) {
[M[col], M[pivotRow]] = [M[pivotRow], M[col]];
sign *= -1;
steps.push({ desc: `F${col + 1} \u2194 F${pivotRow + 1} (cambia signo)`, latex: matToLatex(M) });
}
for (let i = col + 1; i < n; i++) {
if (Math.abs(M[i][col]) > EPS) {
const factor = M[i][col] / M[col][col];
for (let j = col; j < n; j++) {
M[i][j] = round(M[i][j] - factor * M[col][j]);
}
steps.push({ desc: `F${i + 1} \u2192 F${i + 1} - (${fmtNum(round(factor))})F${col + 1}`, latex: matToLatex(M) });
}
}
}
let detVal = sign;
for (let i = 0; i < n; i++) detVal *= M[i][i];
detVal = round(detVal);
const diagParts = [];
for (let i = 0; i < n; i++) diagParts.push(fmtNum(M[i][i]));
steps.push({ desc: 'Producto de la diagonal', latex: `\\det = ${sign < 0 ? '-' : ''}${diagParts.join(' \\cdot ')} = ${fmtNum(detVal)}` });
return makeResult(detVal, steps);
}
};
// ── INVERSE OPERATIONS ──────────────────────────────
const inverse = {
inverse2x2(A) {
const steps = [];
const detResult = determinant.det2x2(A);
steps.push(...detResult.steps);
const det = detResult.value;
if (Math.abs(det) < EPS) {
steps.push({ desc: 'Matriz no invertible (det = 0)', latex: '\\det = 0 \\Rightarrow A^{-1} \\text{ no existe}' });
return makeError('Matriz no invertible (determinante nulo)', steps);
}
const R = [[A[1][1] / det, -A[0][1] / det], [-A[1][0] / det, A[0][0] / det]].map(r => r.map(x => round(x)));
steps.push({ desc: 'Inversa 2\u00d72: intercambiar diagonal, negar antidiagonal, dividir por det', latex: `A^{-1} = \\frac{1}{${fmtNum(det)}} \\begin{pmatrix} ${fmtNum(A[1][1])} & ${fmtNum(-A[0][1])} \\\\ ${fmtNum(-A[1][0])} & ${fmtNum(A[0][0])} \\end{pmatrix}` });
steps.push({ desc: 'Resultado', latex: `A^{-1} = ${matToLatex(R)}` });
return makeResult(R, steps);
},
inverse(A) {
const n = rows(A);
if (n === 2) return inverse.inverse2x2(A);
const steps = [];
const detResult = determinant.det(A);
steps.push(...detResult.steps);
const det = detResult.value;
if (Math.abs(det) < EPS) {
steps.push({ desc: 'Matriz no invertible (det = 0)', latex: '\\det = 0 \\Rightarrow A^{-1} \\text{ no existe}' });
return makeError('Matriz no invertible (determinante nulo)', steps);
}
// Build adjugate (transpose of cofactor matrix)
steps.push({ desc: 'Construir la adjunta (traspuesta de la matriz de cofactores)', latex: `\\text{Adj}(A)` });
const adj = matrix.create(n, n);
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const cf = determinant.cofactor(A, i, j);
adj[j][i] = cf.value; // note: transposing
}
}
const R = adj.map(row => row.map(x => round(x / det)));
steps.push({ desc: 'Dividir adjunta por determinante', latex: `A^{-1} = \\frac{1}{${fmtNum(det)}} \\cdot ${matToLatex(adj)}` });
steps.push({ desc: 'Resultado', latex: `A^{-1} = ${matToLatex(R)}` });
return makeResult(R, steps);
},
isInvertible(A) {
const det = determinant.det(A);
const inv = Math.abs(det.value) > EPS;
return makeResult(inv, [...det.steps, { desc: inv ? 'Es invertible (det \u2260 0)' : 'No es invertible (det = 0)', latex: inv ? '\\det(A) \\neq 0 \\Rightarrow \\text{Invertible}' : '\\det(A) = 0 \\Rightarrow \\text{No invertible}' }]);
}
};
// ── SYSTEM SOLVERS ──────────────────────────────────
const system = {
rank(A) {
const steps = [];
const M = cloneMatrix(A);
const r = rows(M), c = cols(M);
const n = Math.min(r, c);
let rk = 0;
steps.push({ desc: 'Reducir a forma escalonada', latex: matToLatex(M) });
for (let col = 0; col < c && rk < r; col++) {
let pivotRow = -1;
for (let i = rk; i < r; i++) {
if (Math.abs(M[i][col]) > EPS) { pivotRow = i; break; }
}
if (pivotRow === -1) continue;
if (pivotRow !== rk) {
[M[rk], M[pivotRow]] = [M[pivotRow], M[rk]];
steps.push({ desc: `F${rk + 1} \u2194 F${pivotRow + 1}`, latex: matToLatex(M) });
}
const pivot = M[rk][col];
for (let i = rk + 1; i < r; i++) {
if (Math.abs(M[i][col]) > EPS) {
const factor = M[i][col] / pivot;
for (let j = col; j < c; j++) {
M[i][j] = round(M[i][j] - factor * M[rk][j]);
}
steps.push({ desc: `F${i + 1} \u2192 F${i + 1} - (${fmtNum(round(factor))})F${rk + 1}`, latex: matToLatex(M) });
}
}
rk++;
}
steps.push({ desc: `Rango = ${rk}`, latex: `\\text{rg}(A) = ${rk}` });
return makeResult(rk, steps);
},
gaussElimination(A, b) {
const steps = [];
const n = A.length;
const aug = A.map((row, i) => [...row, b[i]]);
steps.push({ desc: 'Sistema aumentado inicial', latex: matToLatex(aug) });
// Forward elimination
for (let col = 0; col < n; col++) {
let pivotRow = -1;
for (let i = col; i < n; i++) {
if (Math.abs(aug[i][col]) > EPS) { pivotRow = i; break; }
}
if (pivotRow === -1) {
// Check for incompatibility
for (let i = col; i < n; i++) {
if (Math.abs(aug[i][n]) > EPS) {
steps.push({ desc: 'Sistema incompatible', latex: `0 = ${fmtNum(round(aug[i][n]))} \\Rightarrow \\text{Incompatible}` });
return makeError('Sistema incompatible', steps);
}
}
continue;
}
if (pivotRow !== col) {
[aug[col], aug[pivotRow]] = [aug[pivotRow], aug[col]];
steps.push({ desc: `F${col + 1} \u2194 F${pivotRow + 1}`, latex: matToLatex(aug) });
}
for (let i = col + 1; i < n; i++) {
if (Math.abs(aug[i][col]) > EPS) {
const factor = aug[i][col] / aug[col][col];
for (let j = col; j <= n; j++) {
aug[i][j] = round(aug[i][j] - factor * aug[col][j]);
}
steps.push({ desc: `F${i + 1} \u2192 F${i + 1} - (${fmtNum(round(factor))})F${col + 1}`, latex: matToLatex(aug) });
}
}
}
// Check for indeterminate
const rankA = aug.filter(row => row.slice(0, n).some(x => Math.abs(x) > EPS)).length;
if (rankA < n) {
steps.push({ desc: `Sistema compatible indeterminado (rango = ${rankA} < ${n})`, latex: `\\text{rg}(A) = ${rankA} < ${n} \\Rightarrow \\text{CI}` });
return makeResult({ type: 'indeterminate', rank: rankA, augmented: aug }, steps);
}
// Back substitution
const x = new Array(n);
for (let i = n - 1; i >= 0; i--) {
let sum = aug[i][n];
for (let j = i + 1; j < n; j++) {
sum -= aug[i][j] * x[j];
}
x[i] = round(sum / aug[i][i]);
}
steps.push({ desc: 'Sustituci\u00f3n regresiva', latex: `x = ${vecToLatex(x)}` });
return makeResult(x, steps);
},
gaussJordan(A, b) {
const steps = [];
const n = A.length;
const aug = A.map((row, i) => [...row, b[i]]);
steps.push({ desc: 'Matriz aumentada', latex: matToLatex(aug) });
// Forward elimination
let pivotCols = [];
let rowIdx = 0;
for (let col = 0; col < n && rowIdx < n; col++) {
let pivotRow = -1;
for (let i = rowIdx; i < n; i++) {
if (Math.abs(aug[i][col]) > EPS) { pivotRow = i; break; }
}
if (pivotRow === -1) continue;
if (pivotRow !== rowIdx) {
[aug[rowIdx], aug[pivotRow]] = [aug[pivotRow], aug[rowIdx]];
steps.push({ desc: `F${rowIdx + 1} \u2194 F${pivotRow + 1}`, latex: matToLatex(aug) });
}
const pivot = aug[rowIdx][col];
for (let j = col; j <= n; j++) aug[rowIdx][j] = round(aug[rowIdx][j] / pivot);
steps.push({ desc: `F${rowIdx + 1} \u2192 F${rowIdx + 1} / ${fmtNum(round(pivot))}`, latex: matToLatex(aug) });
for (let i = 0; i < n; i++) {
if (i !== rowIdx && Math.abs(aug[i][col]) > EPS) {
const factor = aug[i][col];
for (let j = col; j <= n; j++) {
aug[i][j] = round(aug[i][j] - factor * aug[rowIdx][j]);
}
steps.push({ desc: `F${i + 1} \u2192 F${i + 1} - (${fmtNum(round(factor))})F${rowIdx + 1}`, latex: matToLatex(aug) });
}
}
pivotCols.push(col);
rowIdx++;
}
const rankA = pivotCols.length;
// Check for incompatibility
for (let i = rankA; i < n; i++) {
if (Math.abs(aug[i][n]) > EPS) {
steps.push({ desc: 'Sistema incompatible', latex: `0 = ${fmtNum(round(aug[i][n]))}` });
return makeError('Sistema incompatible (SI)', steps);
}
}
if (rankA < n) {
steps.push({ desc: `Sistema compatible indeterminado (rango = ${rankA})`, latex: `\\text{rg}(A) = ${rankA} < ${n}` });
const x = new Array(n).fill(0);
for (let i = 0; i < rankA; i++) x[pivotCols[i]] = round(aug[i][n]);
return makeResult({ type: 'indeterminate', rank: rankA, solution: x, augmented: aug, pivotCols }, steps);
}
const x = aug.map(row => round(row[n]));
steps.push({ desc: 'Soluci\u00f3n', latex: `x = ${vecToLatex(x)}` });
return makeResult(x, steps);
},
cramer(A, b) {
const steps = [];
const n = A.length;
const detA = determinant.det(A);
steps.push({ desc: 'Determinante del sistema', latex: `\\Delta = \\det(A) = ${fmtNum(detA.value)}` });
steps.push(...detA.steps);
if (Math.abs(detA.value) < EPS) {
steps.push({ desc: 'No se puede aplicar Cramer (det = 0)', latex: '\\Delta = 0 \\Rightarrow \\text{Cramer no aplica}' });
return makeError('Sistema con determinante nulo (Cramer no aplica)', steps);
}
const x = [];
for (let j = 0; j < n; j++) {
const Aj = cloneMatrix(A);
for (let i = 0; i < n; i++) Aj[i][j] = b[i];
const detJ = determinant.det(Aj);
steps.push({ desc: `\\Delta_${j + 1}: reemplazar columna ${j + 1} por t\u00e9rminos independientes`, latex: `\\Delta_${j + 1} = \\det ${matToLatex(Aj)} = ${fmtNum(detJ.value)}` });
x.push(round(detJ.value / detA.value));
}
const vars = x.map((v, i) => `x_{${i + 1}} = \\frac{${fmtNum(round(determinant.det(A.map((row, ii) => { const Aj = cloneMatrix(A); for (let k = 0; k < n; k++) Aj[k][i] = b[k]; return Aj; })).value))}}{${fmtNum(detA.value)}} = ${fmtNum(v)}`).join(', \\; ');
steps.push({ desc: 'Soluci\u00f3n por Cramer', latex: vars });
steps.push({ desc: 'Resultado', latex: `x = ${vecToLatex(x)}` });
return makeResult(x, steps);
},
roucheFrobenius(A, b) {
const steps = [];
const n = A.length;
const augMatrix = A.map((row, i) => [...row, b[i]]);
const rankA = system.rank(cloneMatrix(A));
const rankAug = system.rank(cloneMatrix(augMatrix));
steps.push(...rankA.steps);
steps.push(...rankAug.steps);
const rA = rankA.value;
const rAug = rankAug.value;
let classification;
if (rA !== rAug) {
classification = { type: 'SI', label: 'Sistema Incompatible' };
} else if (rA === rAug && rA === n) {
classification = { type: 'CD', label: 'Compatible Determinado' };
} else {
classification = { type: 'CI', label: 'Compatible Indeterminado', freeVars: n - rA };
}
steps.push({ desc: 'Clasificaci\u00f3n Rouche-Frobenius', latex: `\\text{rg}(A) = ${rA}, \\; \\text{rg}(A|b) = ${rAug}, \\; n = ${n} \\Rightarrow ${classification.label}` });
return makeResult(classification, steps);
},
solveHomogeneous(A) {
const steps = [];
const n = A.length;
const b = new Array(n).fill(0);
steps.push({ desc: 'Sistema homog\u00e9neo Ax = 0', latex: matToLatex(A.map((row, i) => [...row, 0])) });
const rankA = system.rank(cloneMatrix(A));
steps.push(...rankA.steps);
if (rankA.value === n) {
steps.push({ desc: 'Rango = n, soluci\u00f3n \u00fanica trivial', latex: `\\text{rg}(A) = ${n} = n \\Rightarrow x = \\vec{0}` });
return makeResult({ type: 'trivial', solution: new Array(n).fill(0) }, steps);
}
const gj = system.gaussJordan(cloneMatrix(A), [...b]);
steps.push(...gj.steps);
if (gj.error) {
return makeResult({ type: 'trivial', solution: new Array(n).fill(0) }, steps);
}
if (gj.value && gj.value.type === 'indeterminate') {
steps.push({ desc: `Soluci\u00f3n no trivial (${n - rankA.value} par\u00e1metros libres)`, latex: `\\dim(\\ker) = ${n - rankA.value}` });
}
return makeResult(gj.value || { type: 'trivial', solution: new Array(n).fill(0) }, steps);
}
};
return {
EPS,
round,
cloneMatrix,
vecToLatex,
matToLatex,
fmtNum,
vector,
matrix,
determinant,
inverse,
system
};
})();

139
js/particles.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* Particles — Canvas floating math symbols background
* Full-viewport canvas with slow-drifting math symbols (Σ, π, ∫, ∞, Δ, √, α, β)
* Respects prefers-reduced-motion, disables on viewports < 768px, caps at 30 FPS.
*/
const Particles = (() => {
let canvas = null;
let ctx = null;
let particles = [];
let animId = null;
let lastTime = 0;
const FPS = 30;
const FRAME_TIME = 1000 / FPS;
const SYMBOLS = ['Σ', 'π', '∫', '∞', 'Δ', '√', 'α', 'β', 'λ', 'μ', '∂', '∑'];
function prefersReducedMotion() {
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function randomBetween(a, b) {
return a + Math.random() * (b - a);
}
class Particle {
constructor() {
this.reset();
}
reset() {
this.x = Math.random() * (canvas ? canvas.width : window.innerWidth);
this.y = Math.random() * (canvas ? canvas.height : window.innerHeight);
this.size = randomBetween(16, 32);
this.symbol = SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)];
this.vx = randomBetween(-0.2, 0.2);
this.vy = randomBetween(-0.15, 0.15);
this.rotation = randomBetween(0, Math.PI * 2);
this.rotationSpeed = randomBetween(-0.005, 0.005);
this.opacity = randomBetween(0.4, 0.8);
}
update() {
this.x += this.vx;
this.y += this.vy;
this.rotation += this.rotationSpeed;
const W = canvas ? canvas.width : window.innerWidth;
const H = canvas ? canvas.height : window.innerHeight;
if (this.x < -50) this.x = W + 50;
if (this.x > W + 50) this.x = -50;
if (this.y < -50) this.y = H + 50;
if (this.y > H + 50) this.y = -50;
}
draw(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.rotation);
ctx.font = `${this.size}px "Times New Roman", serif`;
ctx.fillStyle = `rgba(108, 92, 231, ${this.opacity})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.symbol, 0, 0);
ctx.restore();
}
}
function initCanvas() {
if (window.innerWidth < 768) return;
if (prefersReducedMotion()) return;
canvas = document.getElementById('particles-bg');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'particles-bg';
document.body.appendChild(canvas);
}
ctx = canvas.getContext('2d');
resize();
window.addEventListener('resize', resize);
// Create particles
const count = Math.min(40, Math.floor((window.innerWidth * window.innerHeight) / 25000));
particles = [];
for (let i = 0; i < count; i++) {
particles.push(new Particle());
}
lastTime = performance.now();
loop(performance.now());
}
function resize() {
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
function loop(timestamp) {
animId = requestAnimationFrame(loop);
if (timestamp - lastTime < FRAME_TIME) return;
lastTime = timestamp;
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
p.update();
p.draw(ctx);
}
}
function destroy() {
if (animId) {
cancelAnimationFrame(animId);
animId = null;
}
if (canvas && canvas.parentElement) {
canvas.parentElement.removeChild(canvas);
}
canvas = null;
ctx = null;
particles = [];
window.removeEventListener('resize', resize);
}
// Auto-init on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCanvas);
} else {
initCanvas();
}
return { init: initCanvas, destroy };
})();

133
js/utils/katex-render.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* KaTeX Render Helper
* Wraps katex.render() with error handling for both display and inline modes.
* Security-hardened with strict mode and LaTeX sanitization.
*/
const KatexRenderer = {
/**
* Macro whitelist — only macros that KaTeX does NOT natively support.
* Prevents injection via \href, \url, \includegraphics, etc.
* REMOVED: Standard KaTeX commands (\vec, \overrightarrow, \cdot, \times,
* \sqrt, \frac, \pi, \alpha, \beta, \text, \implies, \neq, \infty, \lambda,
* \mu, \sum, \int, \dots, \vdots, \ddots, \hline, \det, \operatorname)
* — KaTeX already supports these natively; redefining them causes infinite
* recursion or strict mode violations.
*/
KATEX_MACROS: {
'\\ran': '\\text{ran}',
'\\nul': '\\text{nul}',
'\\rg': '\\text{rg}',
'\\sen': '\\sin',
'\\tg': '\\tan'
},
KATEX_OPTIONS: {
displayMode: true,
throwOnError: false,
trust: false,
strict: false,
macros: {}
},
/**
* Sanitize LaTeX input by removing potentially dangerous commands.
* @param {string} latex - Raw LaTeX string
* @returns {string} - Sanitized LaTeX safe for KaTeX
*/
sanitizeLatex(latex) {
if (!latex || typeof latex !== 'string') return '';
return latex
.replace(/\\href\{[^}]*\}\{[^}]*\}/g, '')
.replace(/\\url\{[^}]*\}/g, '')
.replace(/\\includegraphics\{[^}]*\}/g, '')
.replace(/\\write\{[^}]*\}/g, '')
.replace(/\\input\{[^}]*\}/g, '')
.replace(/\\include\{[^}]*\}/g, '')
.replace(/\\href\{[^}]*\}/g, '');
},
/**
* Render a LaTeX expression into a DOM element.
* @param {HTMLElement|string} el - Target element or CSS selector
* @param {string} latex - LaTeX string to render
* @param {Object} options - KaTeX options override
* @returns {boolean} true if rendered successfully
*/
render(el, latex, options = {}) {
const target = typeof el === 'string' ? document.querySelector(el) : el;
if (!target) {
console.warn('[KatexRenderer] Element not found:', el);
return false;
}
if (typeof katex === 'undefined') {
target.textContent = latex;
console.warn('[KatexRenderer] KaTeX not loaded');
return false;
}
const mergedOptions = Object.assign({}, this.KATEX_OPTIONS, {
macros: Object.assign({}, this.KATEX_MACROS, options.macros || {})
});
const sanitized = this.sanitizeLatex(latex);
try {
katex.render(sanitized, target, mergedOptions);
return true;
} catch (e) {
target.innerHTML = '<span style="color:#e17055;font-style:italic;">[Error LaTeX]</span>';
console.warn('[KatexRenderer] Parse error:', e.message, '\nLaTeX:', sanitized);
return false;
}
},
/**
* Render LaTeX in display mode (centered block).
*/
renderDisplay(el, latex) {
return this.render(el, latex, { displayMode: true });
},
/**
* Render LaTeX in inline mode.
*/
renderInline(el, latex) {
return this.render(el, latex, { displayMode: false });
},
/**
* Render all math expressions inside a container element.
* Uses KaTeX auto-render extension for $...$ and $$...$$ delimiters.
* @param {HTMLElement} container - DOM element to scan for math
*/
renderAll(container) {
if (typeof renderMathInElement === 'undefined') {
console.warn('[KatexRenderer] auto-render extension not loaded');
return;
}
renderMathInElement(container || document.body, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
throwOnError: false,
trust: false,
strict: false,
macros: this.KATEX_MACROS
});
},
/**
* Create a span with rendered math and return it.
* Useful for building DOM fragments.
*/
createMathSpan(latex, displayMode = false) {
const span = document.createElement('span');
this.render(span, latex, { displayMode });
return span;
}
};