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

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 };
})();