machinelearningalgorithms / templates /gradient-descent-three.html
deedrop1140's picture
Upload 182 files
d0a6b4f verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gradient Descent Simulator</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--background: #05080a;
--primary: #00ffff;
--secondary: #a855f7;
--accent: #ec4899;
--surface: #0a1014;
--border: #1e293b;
}
body {
background-color: var(--background);
color: #f8fafc;
font-family: 'Space Grotesk', sans-serif;
margin: 0;
overflow-x: hidden;
}
.gradient-text {
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.glass {
background: rgba(10, 16, 20, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(30, 41, 59, 0.5);
}
.glow-cyan {
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
}
.canvas-container {
width: 100%;
height: 350px; /* Default for mobile */
position: relative;
background: #000;
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--border);
}
/* Desktop override */
@media (min-width: 768px) {
.canvas-container {
height: 500px;
}
}
input[type=range] {
-webkit-appearance: none;
width: 100%;
background: transparent;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
cursor: pointer;
background: #1e293b;
border-radius: 3px;
}
input[type=range]::-webkit-slider-thumb {
height: 18px;
width: 18px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
-webkit-appearance: none;
margin-top: -6px;
box-shadow: 0 0 10px rgba(0, 255, 255, 0.5);
}
.hidden { display: none; }
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-float { animation: float 3s ease-in-out infinite; }
</style>
</head>
<body class="p-4 md:p-8">
<div class="max-w-7xl mx-auto grid lg:grid-cols-[1fr_350px] gap-8">
<!-- Left Side: Header & Viewport -->
<div class="space-y-6">
<header class="relative flex flex-col md:block">
<h1 class="text-4xl md:text-5xl font-bold gradient-text text-center md:text-left">Gradient Descent</h1>
<!-- Centered Button (Responsive Position) -->
<div class="order-last md:order-none mt-4 md:mt-0 md:absolute md:left-1/2 md:-translate-x-1/2 md:top-1 flex items-center justify-center">
<audio id="clickSound" src="https://www.soundjay.com/buttons/sounds/button-3.mp3"></audio>
<a href="/gradient-descent" onclick="playSound(); return false;" class="inline-flex items-center justify-center text-center leading-none bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 px-6 rounded-xl text-sm transition-all duration-150 shadow-[0_4px_0_rgb(29,78,216)] active:shadow-none active:translate-y-[4px] uppercase tracking-wider">
Back to Core
</a>
</div>
<p class="text-slate-400 text-sm max-w-md mt-2 text-center md:text-left mx-auto md:mx-0">
Optimize parameters by descending the loss landscape. Now with <b>Adaptive Learning Rate</b> logic!
</p>
</header>
<div class="canvas-container" id="container">
<div id="three-canvas"></div>
<!-- Viewport Overlays -->
<div class="absolute top-4 left-4 pointer-events-none">
<div class="glass px-3 py-2 rounded-lg">
<div class="text-[10px] text-slate-500 font-mono uppercase tracking-widest">Status</div>
<div id="status-text" class="text-xs font-mono text-cyan-400 mt-1 uppercase">Ready</div>
</div>
</div>
<div class="absolute top-4 right-4 pointer-events-none text-right">
<div class="glass px-3 py-2 rounded-lg">
<div class="text-[10px] text-slate-500 font-mono uppercase tracking-widest">Position</div>
<div id="pos-display" class="text-xs font-mono text-cyan-400 mt-1">X: 3.00 Y: 3.00</div>
</div>
</div>
<div class="absolute bottom-4 left-4 glass px-3 py-1 rounded-full text-[10px] text-slate-400 font-bold border border-slate-800">
GOAL AT <span id="goal-coords" class="text-green-400">0, 0</span>
</div>
</div>
<!-- Educational Info -->
<div class="glass rounded-xl overflow-hidden">
<button onclick="toggleInfo()" class="w-full flex items-center justify-between p-4 hover:bg-slate-800/30 transition-colors">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
<span class="font-bold">Optimizer Secrets</span>
</div>
<svg id="info-arrow" class="w-5 h-5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button>
<div id="info-content" class="p-4 pt-0 text-sm text-slate-400 leading-relaxed grid md:grid-cols-2 gap-6 border-t border-slate-800 hidden">
<div class="space-y-2 pt-4">
<p><strong class="text-white">Momentum (β):</strong> Acts like a physical ball with weight. It keeps moving in the same direction, helping it "roll" through flat valleys and over small local pits.</p>
</div>
<div class="space-y-2 pt-4">
<p><strong class="text-white">The Challenge:</strong> Rosenbrock and Rastrigin are "non-convex" or have "vanishing gradients." Vanilla GD is often too weak to reach the center without help!</p>
</div>
<!-- Pro Tip Note -->
<div class="col-span-full pt-4 border-t border-slate-800/50 mt-2">
<p class="text-yellow-400 font-medium">
<span class="mr-2">💡</span> <b>Pro Tip:</b> If the ball gets stuck or vibrates wildly, you must <b>adjust the learning rate</b> or <b>momentum</b>. Or turn on <b>Adaptive Rate</b> to let the algorithm handle it!
</p>
</div>
</div>
</div>
</div>
<!-- Right Side: Controls -->
<aside class="space-y-6">
<div class="glass rounded-xl p-6 space-y-6">
<!-- Learning Rate Slider -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider">Learning Rate (α)</label>
<span id="lr-value" class="text-cyan-400 font-mono bg-cyan-400/10 px-2 py-0.5 rounded border border-cyan-400/20 text-sm">0.050</span>
</div>
<input type="range" id="lr-slider" min="0.001" max="0.5" step="0.001" value="0.05">
</div>
<!-- Adaptive Toggle -->
<div onclick="toggleAdaptive()" class="flex items-center justify-between p-3 rounded-lg bg-slate-800/40 border border-slate-700/50 cursor-pointer hover:bg-slate-800 transition-colors">
<div class="flex flex-col">
<span class="text-xs font-bold text-slate-300">Adaptive Rate</span>
<span class="text-[10px] text-slate-500">Auto-tune α during descent</span>
</div>
<div id="adaptive-toggle-bg" class="w-10 h-5 rounded-full bg-slate-700 relative transition-colors">
<div id="adaptive-toggle-dot" class="absolute left-1 top-1 w-3 h-3 rounded-full bg-white transition-all"></div>
</div>
</div>
<!-- Momentum Slider -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider">Momentum (β)</label>
<span id="mom-value" class="text-purple-400 font-mono bg-purple-400/10 px-2 py-0.5 rounded border border-purple-400/20 text-sm">0.10</span>
</div>
<input type="range" id="mom-slider" min="0" max="0.99" step="0.01" value="0.1">
</div>
<!-- Playback Buttons -->
<div class="grid grid-cols-2 gap-3">
<button id="btn-toggle" class="flex items-center justify-center gap-2 py-3 rounded-lg font-bold transition-all bg-cyan-400 text-black glow-cyan hover:brightness-110">
<span id="toggle-icon"></span> <span id="toggle-text">Start</span>
</button>
<button id="btn-step" class="flex items-center justify-center gap-2 py-3 rounded-lg border border-slate-700 hover:bg-slate-800 transition-all font-bold">
Step ➜
</button>
</div>
<button id="btn-reset" class="w-full flex items-center justify-center gap-2 py-2 text-slate-400 hover:text-white text-sm transition-colors font-medium">
Reset Position ↺
</button>
<!-- Stats -->
<div class="grid grid-cols-2 gap-4 pt-4 border-t border-slate-800">
<div class="text-center">
<div id="steps-val" class="text-2xl font-mono font-bold text-cyan-400">0</div>
<div class="text-[10px] text-slate-500 uppercase tracking-tighter">Steps</div>
</div>
<div class="text-center">
<div id="loss-val" class="text-2xl font-mono font-bold text-pink-500">9.00</div>
<div class="text-[10px] text-slate-500 uppercase tracking-tighter">Loss</div>
</div>
</div>
<!-- Progress -->
<div class="p-4 bg-slate-900/50 rounded-lg border border-slate-800">
<div class="flex justify-between text-xs mb-2">
<span class="text-slate-500">Distance to Target</span>
<span id="dist-val" class="font-mono text-slate-300">4.242</span>
</div>
<div class="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div id="progress-bar" class="h-full bg-cyan-400 transition-all duration-300" style="width: 10%"></div>
</div>
</div>
</div>
<!-- Level Selector -->
<div class="glass rounded-xl p-4 space-y-3">
<h3 class="text-xs font-bold text-slate-500 uppercase tracking-widest px-2">Function Landscape</h3>
<div id="level-list" class="space-y-1">
<!-- Levels injected here -->
</div>
</div>
</aside>
</div>
<!-- Victory Modal -->
<div id="victory-modal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm hidden">
<div class="glass relative max-w-sm w-full rounded-2xl p-8 text-center animate-float">
<!-- Close Button (Cross) -->
<button onclick="closeModal()" class="absolute top-4 right-4 text-slate-500 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
<div class="w-16 h-16 bg-cyan-400/20 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-3xl text-cyan-400">🏆</span>
</div>
<h2 class="text-2xl font-bold gradient-text mb-1">Convergence!</h2>
<p id="modal-level-name" class="text-slate-400 text-sm mb-6">Level Complete</p>
<div class="bg-slate-900/80 rounded-xl p-4 mb-6">
<div id="modal-steps" class="text-3xl font-mono text-cyan-400 font-bold">0</div>
<div class="text-xs text-slate-500 uppercase tracking-widest">Total Steps</div>
</div>
<div class="flex gap-3">
<button onclick="closeModal()" class="flex-1 py-2 rounded-lg border border-slate-700 hover:bg-slate-800 transition-colors text-sm font-semibold">Retry</button>
<button onclick="nextLevel()" class="flex-1 py-2 rounded-lg bg-cyan-400 text-black font-bold text-sm">Next Level</button>
</div>
</div>
</div>
<script>
// --- LEVEL DEFINITIONS ---
// Added 'presets' to automatically tune parameters for each landscape
const LEVELS = [
{
id: 1,
name: 'The Bowl',
difficulty: 'easy',
loss: (x, y) => x*x + y*y,
min: {x: 0, y: 0},
winRadius: 0.15,
presets: { lr: 0.05, mom: 0.1 } // Slow & Steady
},
{
id: 2,
name: 'The Ellipse',
difficulty: 'easy',
loss: (x, y) => x*x + 10*y*y,
min: {x: 0, y: 0},
winRadius: 0.2,
presets: { lr: 0.05, mom: 0.1 }
},
{
id: 3,
name: 'Rosenbrock Valley',
difficulty: 'medium',
loss: (x, y) => Math.pow(1-x, 2) + 100*Math.pow(y-x*x, 2),
min: {x: 1, y: 1},
winRadius: 0.25,
presets: { lr: 0.002, mom: 0.85 } // Needs momentum to traverse the valley
},
{
id: 4,
name: 'Beale Function',
difficulty: 'medium',
loss: (x, y) => Math.pow(1.5-x+x*y, 2) + Math.pow(2.25-x+x*y*y, 2) + Math.pow(2.625-x+x*y*y*y, 2),
min: {x: 3, y: 0.5},
winRadius: 0.25,
presets: { lr: 0.01, mom: 0.5 }
},
{
id: 5,
name: 'Rastrigin',
difficulty: 'hard',
loss: (x, y) => 20 + x*x - 10*Math.cos(2*Math.PI*x) + y*y - 10*Math.cos(2*Math.PI*y),
min: {x: 0, y: 0},
winRadius: 0.3,
presets: { lr: 0.005, mom: 0.95 } // HIGH MOMENTUM to escape local minima
}
];
// Constants for 3D Visuals
const SURFACE_OFFSET = -0.5;
const Z_SCALE = 0.8;
const BALL_FLOAT = 0.1;
// --- APP STATE ---
let currentLevel = LEVELS[0];
let pos = { x: 3, y: 3 };
let velocity = { x: 0, y: 0 };
let path = [];
let learningRate = 0.05;
let momentum = 0.1; // Default starting momentum
let isAdaptive = false;
let isRunning = false;
let steps = 0;
let loopId = null;
let prevLoss = Infinity;
// --- THREE.JS SETUP ---
const container = document.getElementById('container');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05080a);
const camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 1000);
camera.position.set(8, 8, 8);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.getElementById('three-canvas').appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(0x00ffff, 1);
pointLight.position.set(10, 10, 10);
scene.add(pointLight);
function calculateVizHeight(x, y) {
let z = currentLevel.loss(x, y);
z = Math.min(z, 50);
return (Math.log(z + 1) * Z_SCALE) + SURFACE_OFFSET;
}
// Surface
let surfaceMesh;
function updateSurface() {
if (surfaceMesh) scene.remove(surfaceMesh);
const size = 64;
const geo = new THREE.PlaneBufferGeometry(10, 10, size, size);
const posAttr = geo.attributes.position;
const colorAttr = new THREE.BufferAttribute(new Float32Array(posAttr.count * 3), 3);
let minVal = Infinity, maxVal = -Infinity;
const vals = [];
for (let i = 0; i < posAttr.count; i++) {
const x = posAttr.getX(i);
const y = posAttr.getY(i);
let z = currentLevel.loss(x, y);
z = Math.min(z, 50);
z = Math.log(z + 1) * Z_SCALE;
vals.push(z);
minVal = Math.min(minVal, z);
maxVal = Math.max(maxVal, z);
}
for (let i = 0; i < posAttr.count; i++) {
const z = vals[i];
posAttr.setZ(i, z);
const t = (z - minVal) / (maxVal - minVal || 1);
colorAttr.setXYZ(i, 0.1 + t * 0.8, 0.8 - t * 0.6, 0.8 + t * 0.2);
}
geo.setAttribute('color', colorAttr);
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, transparent: true, opacity: 0.8, roughness: 0.5 });
surfaceMesh = new THREE.Mesh(geo, mat);
surfaceMesh.rotation.x = -Math.PI / 2;
surfaceMesh.position.y = SURFACE_OFFSET;
scene.add(surfaceMesh);
}
const ball = new THREE.Mesh(
new THREE.SphereGeometry(0.18, 24, 24),
new THREE.MeshStandardMaterial({ color: 0xff66cc, emissive: 0xff0088, emissiveIntensity: 0.5 })
);
scene.add(ball);
let pathLine;
function updatePath() {
if (pathLine) scene.remove(pathLine);
if (path.length < 2) return;
const points = path.map(p => new THREE.Vector3(p.x, calculateVizHeight(p.x, p.y) + 0.05, p.y));
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.8 });
pathLine = new THREE.Line(geo, mat);
scene.add(pathLine);
}
const goalMarker = new THREE.Group();
const torus = new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.02, 16, 32), new THREE.MeshBasicMaterial({ color: 0x00ff66, transparent: true, opacity: 0.5 }));
torus.rotation.x = Math.PI/2;
const star = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8), new THREE.MeshBasicMaterial({ color: 0x00ff66 }));
goalMarker.add(torus);
goalMarker.add(star);
scene.add(goalMarker);
function updateGoalMarker() {
const gx = currentLevel.min.x;
const gy = currentLevel.min.y;
const gz = calculateVizHeight(gx, gy);
goalMarker.position.set(gx, gz + 0.05, gy);
document.getElementById('goal-coords').innerText = `[${gx}, ${gy}]`;
}
scene.add(new THREE.GridHelper(10, 20, 0x1a3a4a, 0x0a1a2a));
// --- OPTIMIZER LOGIC ---
function calculateGradient(x, y) {
const h = 0.0001;
const dx = (currentLevel.loss(x + h, y) - currentLevel.loss(x - h, y)) / (2 * h);
const dy = (currentLevel.loss(x, y + h) - currentLevel.loss(x, y - h)) / (2 * h);
const magnitude = Math.sqrt(dx*dx + dy*dy);
const limit = 50;
if (magnitude > limit) {
return { x: (dx / magnitude) * limit, y: (dy / magnitude) * limit, magnitude: limit };
}
return { x: dx, y: dy, magnitude };
}
function step() {
const currentLoss = currentLevel.loss(pos.x, pos.y);
const grad = calculateGradient(pos.x, pos.y);
// --- ADAPTIVE LOGIC ---
if (isAdaptive) {
if (currentLoss > prevLoss * 1.05) {
learningRate *= 0.5;
}
else if (Math.abs(currentLoss - prevLoss) < 0.00001 && grad.magnitude < 0.01) {
learningRate *= 1.2;
}
learningRate = Math.max(0.001, Math.min(0.5, learningRate));
updateLRElement();
}
// Momentum Logic
velocity.x = momentum * velocity.x - learningRate * grad.x;
velocity.y = momentum * velocity.y - learningRate * grad.y;
pos.x = Math.max(-5, Math.min(5, pos.x + velocity.x));
pos.y = Math.max(-5, Math.min(5, pos.y + velocity.y));
path.push({...pos});
steps++;
prevLoss = currentLoss;
updateUI();
const dist = Math.sqrt(Math.pow(pos.x - currentLevel.min.x, 2) + Math.pow(pos.y - currentLevel.min.y, 2));
if (dist < currentLevel.winRadius) {
stopDescent();
showVictory();
} else if (steps > 3000) {
stopDescent();
}
}
function updateLRElement() {
document.getElementById('lr-slider').value = learningRate;
document.getElementById('lr-value').innerText = learningRate.toFixed(3);
}
function updateUI() {
document.getElementById('pos-display').innerText = `X: ${pos.x.toFixed(2)} Y: ${pos.y.toFixed(2)}`;
document.getElementById('steps-val').innerText = steps;
const lossVal = currentLevel.loss(pos.x, pos.y);
document.getElementById('loss-val').innerText = lossVal.toFixed(2);
const dist = Math.sqrt(Math.pow(pos.x - currentLevel.min.x, 2) + Math.pow(pos.y - currentLevel.min.y, 2));
document.getElementById('dist-val').innerText = dist.toFixed(3);
document.getElementById('progress-bar').style.width = `${Math.max(5, Math.min(100, (1 - dist / 7) * 100))}%`;
const ballZ = calculateVizHeight(pos.x, pos.y);
ball.position.set(pos.x, ballZ + BALL_FLOAT, pos.y);
updatePath();
}
function toggleAdaptive() {
isAdaptive = !isAdaptive;
const bg = document.getElementById('adaptive-toggle-bg');
const dot = document.getElementById('adaptive-toggle-dot');
if (isAdaptive) {
bg.classList.replace('bg-slate-700', 'bg-cyan-400');
dot.style.left = '22px';
} else {
bg.classList.replace('bg-cyan-400', 'bg-slate-700');
dot.style.left = '4px';
}
}
function startDescent() {
isRunning = true;
document.getElementById('toggle-text').innerText = 'Pause';
document.getElementById('status-text').innerText = 'Descending...';
document.getElementById('status-text').className = 'text-xs font-mono text-yellow-400 mt-1 uppercase';
prevLoss = currentLevel.loss(pos.x, pos.y);
loopId = setInterval(step, 50);
}
function stopDescent() {
isRunning = false;
document.getElementById('toggle-text').innerText = 'Start';
document.getElementById('status-text').innerText = 'Paused';
document.getElementById('status-text').className = 'text-xs font-mono text-cyan-400 mt-1 uppercase';
clearInterval(loopId);
}
function showVictory() {
document.getElementById('modal-level-name').innerText = currentLevel.name;
document.getElementById('modal-steps').innerText = steps;
document.getElementById('victory-modal').classList.remove('hidden');
}
function reset() {
stopDescent();
if(currentLevel.id === 3) pos = { x: -3, y: -3 };
else if(currentLevel.id === 4) pos = { x: 1, y: 1 };
else pos = { x: (Math.random() - 0.5) * 8, y: (Math.random() - 0.5) * 8 };
velocity = { x: 0, y: 0 };
path = [{...pos}];
steps = 0;
updateUI();
}
// --- EVENT HANDLERS ---
document.getElementById('btn-toggle').onclick = () => isRunning ? stopDescent() : startDescent();
document.getElementById('btn-step').onclick = step;
document.getElementById('btn-reset').onclick = reset;
document.getElementById('lr-slider').oninput = (e) => {
learningRate = parseFloat(e.target.value);
document.getElementById('lr-value').innerText = learningRate.toFixed(3);
};
document.getElementById('mom-slider').oninput = (e) => {
momentum = parseFloat(e.target.value);
document.getElementById('mom-value').innerText = momentum.toFixed(2);
};
function toggleInfo() {
const content = document.getElementById('info-content');
content.classList.toggle('hidden');
}
function closeModal() {
document.getElementById('victory-modal').classList.add('hidden');
reset();
}
function nextLevel() {
const idx = LEVELS.findIndex(l => l.id === currentLevel.id);
const next = LEVELS[(idx + 1) % LEVELS.length];
selectLevel(next.id);
document.getElementById('victory-modal').classList.add('hidden');
}
function selectLevel(id) {
currentLevel = LEVELS.find(l => l.id === id);
// Apply Presets if they exist
if (currentLevel.presets) {
learningRate = currentLevel.presets.lr;
momentum = currentLevel.presets.mom;
// Update UI Controls
document.getElementById('lr-slider').value = learningRate;
document.getElementById('lr-value').innerText = learningRate.toFixed(3);
document.getElementById('mom-slider').value = momentum;
document.getElementById('mom-value').innerText = momentum.toFixed(2);
}
renderLevels();
updateSurface();
updateGoalMarker();
reset();
}
function renderLevels() {
const list = document.getElementById('level-list');
list.innerHTML = LEVELS.map(l => `
<button onclick="selectLevel(${l.id})" class="w-full text-left p-3 rounded-lg transition-all border ${currentLevel.id === l.id ? 'bg-cyan-400/10 border-cyan-400/50' : 'border-transparent hover:bg-slate-800'}">
<div class="flex justify-between items-center mb-1">
<span class="font-bold text-sm ${currentLevel.id === l.id ? 'text-cyan-400' : ''}">${l.name}</span>
<span class="text-[9px] px-1.5 py-0.5 rounded border ${l.difficulty === 'easy' ? 'border-green-500/30 text-green-400' : l.difficulty === 'medium' ? 'border-yellow-500/30 text-yellow-400' : 'border-red-500/30 text-red-400'} uppercase">${l.difficulty}</span>
</div>
</button>
`).join('');
}
function animate() {
requestAnimationFrame(animate);
if (isRunning) {
const s = 1 + Math.sin(Date.now() * 0.01) * 0.15;
ball.scale.setScalar(s);
}
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
renderLevels();
updateSurface();
updateGoalMarker();
updateUI();
animate();
// --- MOUSE & TOUCH CONTROLS ---
let isMouseDown = false;
let prevMouse = { x: 0, y: 0 };
// Mouse Events
container.addEventListener('mousedown', (e) => {
isMouseDown = true;
prevMouse = { x: e.clientX, y: e.clientY };
});
window.addEventListener('mouseup', () => isMouseDown = false);
window.addEventListener('mousemove', (e) => handleCameraRotate(e.clientX, e.clientY));
// Touch Events (Mobile)
container.addEventListener('touchstart', (e) => {
if(e.touches.length === 1) {
isMouseDown = true;
prevMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
}, { passive: false });
window.addEventListener('touchend', () => isMouseDown = false);
window.addEventListener('touchmove', (e) => {
if(e.touches.length === 1 && isMouseDown) {
handleCameraRotate(e.touches[0].clientX, e.touches[0].clientY);
}
}, { passive: false });
// Common Rotation Logic
function handleCameraRotate(clientX, clientY) {
if (!isMouseDown) return;
const dx = (clientX - prevMouse.x) * 0.005;
const dy = (clientY - prevMouse.y) * 0.005;
const radius = camera.position.length();
let phi = Math.atan2(camera.position.x, camera.position.z);
let theta = Math.acos(camera.position.y / radius);
phi -= dx;
theta = Math.max(0.1, Math.min(Math.PI / 2.1, theta - dy));
camera.position.x = radius * Math.sin(theta) * Math.sin(phi);
camera.position.y = radius * Math.cos(theta);
camera.position.z = radius * Math.sin(theta) * Math.cos(phi);
camera.lookAt(0, 0, 0);
prevMouse = { x: clientX, y: clientY };
}
</script>
</body>
</html>