| <!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;
|
| position: relative;
|
| background: #000;
|
| border-radius: 1rem;
|
| overflow: hidden;
|
| border: 1px solid var(--border);
|
| }
|
|
|
|
|
| @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">
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
| <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>
|
|
|
|
|
| <aside class="space-y-6">
|
| <div class="glass rounded-xl p-6 space-y-6">
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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">
|
|
|
| </div>
|
| </div>
|
| </aside>
|
| </div>
|
|
|
|
|
| <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">
|
|
|
| <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>
|
|
|
|
|
| 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 }
|
| },
|
| {
|
| 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 }
|
| },
|
| {
|
| 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 }
|
| }
|
| ];
|
|
|
|
|
| const SURFACE_OFFSET = -0.5;
|
| const Z_SCALE = 0.8;
|
| const BALL_FLOAT = 0.1;
|
|
|
|
|
| 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;
|
| let isAdaptive = false;
|
| let isRunning = false;
|
| let steps = 0;
|
| let loopId = null;
|
| let prevLoss = Infinity;
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
| 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));
|
|
|
|
|
| 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);
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
| 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);
|
|
|
|
|
| if (currentLevel.presets) {
|
| learningRate = currentLevel.presets.lr;
|
| momentum = currentLevel.presets.mom;
|
|
|
|
|
| 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();
|
|
|
|
|
| let isMouseDown = false;
|
| let prevMouse = { x: 0, y: 0 };
|
|
|
|
|
| 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));
|
|
|
|
|
| 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 });
|
|
|
|
|
| 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> |