Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Gradient Descent Visualization</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/dat.gui.min.js"></script> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%); | |
| color: #e0e0e0; | |
| overflow-x: hidden; | |
| min-height: 100vh; | |
| } | |
| .header { | |
| background: rgba(10, 10, 20, 0.8); | |
| backdrop-filter: blur(10px); | |
| padding: 1.5rem 2rem; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
| position: relative; | |
| z-index: 100; | |
| } | |
| .header-content { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .logo { | |
| font-size: 1.8rem; | |
| font-weight: 700; | |
| background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .anycoder-link { | |
| color: #4facfe; | |
| text-decoration: none; | |
| font-size: 0.9rem; | |
| font-weight: 500; | |
| transition: all 0.3s ease; | |
| padding: 0.5rem 1rem; | |
| border-radius: 20px; | |
| background: rgba(79, 172, 254, 0.1); | |
| } | |
| .anycoder-link:hover { | |
| background: rgba(79, 172, 254, 0.2); | |
| transform: translateY(-2px); | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 2rem; | |
| } | |
| .hero { | |
| text-align: center; | |
| margin-bottom: 3rem; | |
| padding: 2rem 0; | |
| } | |
| .hero h1 { | |
| font-size: 3.5rem; | |
| background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 1rem; | |
| font-weight: 800; | |
| } | |
| .hero p { | |
| font-size: 1.2rem; | |
| max-width: 700px; | |
| margin: 0 auto 2rem; | |
| color: #b0b0b0; | |
| line-height: 1.6; | |
| } | |
| .visualization-container { | |
| display: grid; | |
| grid-template-columns: 1fr 350px; | |
| gap: 2rem; | |
| margin-bottom: 3rem; | |
| } | |
| .canvas-wrapper { | |
| position: relative; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); | |
| background: rgba(15, 15, 25, 0.6); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| height: 600px; | |
| } | |
| #gradientDescentCanvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .controls-panel { | |
| background: rgba(20, 20, 35, 0.7); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); | |
| backdrop-filter: blur(10px); | |
| height: fit-content; | |
| } | |
| .controls-panel h2 { | |
| font-size: 1.5rem; | |
| margin-bottom: 1.5rem; | |
| color: #4facfe; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .control-group { | |
| margin-bottom: 1.5rem; | |
| } | |
| .control-group h3 { | |
| font-size: 1.1rem; | |
| margin-bottom: 0.8rem; | |
| color: #00f2fe; | |
| } | |
| .slider-container { | |
| margin-bottom: 1rem; | |
| } | |
| .slider-label { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 0.3rem; | |
| font-size: 0.9rem; | |
| } | |
| .slider { | |
| width: 100%; | |
| height: 6px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 3px; | |
| outline: none; | |
| -webkit-appearance: none; | |
| } | |
| .slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: #4facfe; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .slider::-webkit-slider-thumb:hover { | |
| background: #00f2fe; | |
| transform: scale(1.2); | |
| } | |
| .button-group { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 0.8rem; | |
| margin-top: 1rem; | |
| } | |
| .btn { | |
| padding: 0.8rem 1rem; | |
| border: none; | |
| border-radius: 6px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.5rem; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%); | |
| color: #0c0c0c; | |
| } | |
| .btn-secondary { | |
| background: rgba(255, 255, 255, 0.1); | |
| color: #e0e0e0; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| } | |
| .btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); | |
| } | |
| .btn:active { | |
| transform: translateY(0); | |
| } | |
| .info-section { | |
| background: rgba(20, 20, 35, 0.7); | |
| border-radius: 12px; | |
| padding: 2rem; | |
| margin-top: 2rem; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .info-section h2 { | |
| font-size: 1.8rem; | |
| margin-bottom: 1.5rem; | |
| color: #4facfe; | |
| } | |
| .info-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 1.5rem; | |
| } | |
| .info-card { | |
| background: rgba(30, 30, 50, 0.5); | |
| border-radius: 8px; | |
| padding: 1.5rem; | |
| border-left: 4px solid #4facfe; | |
| } | |
| .info-card h3 { | |
| font-size: 1.2rem; | |
| margin-bottom: 0.8rem; | |
| color: #00f2fe; | |
| } | |
| .info-card p { | |
| color: #b0b0b0; | |
| line-height: 1.6; | |
| } | |
| .footer { | |
| text-align: center; | |
| padding: 2rem; | |
| margin-top: 3rem; | |
| border-top: 1px solid rgba(255, 255, 255, 0.1); | |
| color: #888; | |
| font-size: 0.9rem; | |
| } | |
| @media (max-width: 968px) { | |
| .visualization-container { | |
| grid-template-columns: 1fr; | |
| } | |
| .hero h1 { | |
| font-size: 2.5rem; | |
| } | |
| .canvas-wrapper { | |
| height: 500px; | |
| } | |
| } | |
| @media (max-width: 600px) { | |
| .container { | |
| padding: 1rem; | |
| } | |
| .hero h1 { | |
| font-size: 2rem; | |
| } | |
| .canvas-wrapper { | |
| height: 400px; | |
| } | |
| .button-group { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .loading { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(10, 10, 20, 0.9); | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 10; | |
| color: #4facfe; | |
| font-size: 1.2rem; | |
| } | |
| .spinner { | |
| width: 50px; | |
| height: 50px; | |
| border: 5px solid rgba(79, 172, 254, 0.3); | |
| border-top: 5px solid #4facfe; | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| margin-bottom: 1rem; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .hidden { | |
| display: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div class="header-content"> | |
| <div class="logo"> | |
| <span>∇ GradientVision</span> | |
| </div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" class="anycoder-link">Built with anycoder</a> | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <div class="hero"> | |
| <h1>3D Gradient Descent Visualization</h1> | |
| <p>Explore the complex optimization landscape of machine learning algorithms in an immersive 3D environment. Watch as gradient descent navigates through intricate mathematical terrains to find optimal solutions.</p> | |
| </div> | |
| <div class="visualization-container"> | |
| <div class="canvas-wrapper"> | |
| <div id="loading" class="loading"> | |
| <div class="spinner"></div> | |
| <p>Initializing 3D Visualization...</p> | |
| </div> | |
| <canvas id="gradientDescentCanvas"></canvas> | |
| </div> | |
| <div class="controls-panel"> | |
| <h2>⚙️ Control Panel</h2> | |
| <div class="control-group"> | |
| <h3>Learning Parameters</h3> | |
| <div class="slider-container"> | |
| <div class="slider-label"> | |
| <span>Learning Rate</span> | |
| <span id="learningRateValue">0.1</span> | |
| </div> | |
| <input type="range" min="0.001" max="0.5" step="0.001" value="0.1" class="slider" id="learningRate"> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label"> | |
| <span>Iterations</span> | |
| <span id="iterationsValue">100</span> | |
| </div> | |
| <input type="range" min="10" max="500" step="10" value="100" class="slider" id="iterations"> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <h3>Visualization</h3> | |
| <div class="slider-container"> | |
| <div class="slider-label"> | |
| <span>Animation Speed</span> | |
| <span id="speedValue">1.0</span> | |
| </div> | |
| <input type="range" min="0.1" max="5" step="0.1" value="1.0" class="slider" id="animationSpeed"> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label"> | |
| <span>Surface Complexity</span> | |
| <span id="complexityValue">3</span> | |
| </div> | |
| <input type="range" min="1" max="5" step="1" value="3" class="slider" id="surfaceComplexity"> | |
| </div> | |
| </div> | |
| <div class="button-group"> | |
| <button id="startBtn" class="btn btn-primary"> | |
| <span>▶️ Start</span> | |
| </button> | |
| <button id="resetBtn" class="btn btn-secondary"> | |
| <span>🔄 Reset</span> | |
| </button> | |
| <button id="pauseBtn" class="btn btn-secondary"> | |
| <span>⏸️ Pause</span> | |
| </button> | |
| <button id="randomizeBtn" class="btn btn-secondary"> | |
| <span>🎲 Randomize</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="info-section"> | |
| <h2>Understanding Gradient Descent</h2> | |
| <div class="info-grid"> | |
| <div class="info-card"> | |
| <h3>What is Gradient Descent?</h3> | |
| <p>Gradient descent is an optimization algorithm used to minimize functions by iteratively moving in the direction of steepest descent as defined by the negative of the gradient.</p> | |
| </div> | |
| <div class="info-card"> | |
| <h3>The Learning Process</h3> | |
| <p>The algorithm calculates the gradient of the loss function and updates parameters in the opposite direction, scaled by the learning rate.</p> | |
| </div> | |
| <div class="info-card"> | |
| <h3>3D Visualization</h3> | |
| <p>This visualization shows a complex 3D loss surface where gradient descent navigates through valleys and peaks to find the global minimum.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="footer"> | |
| <p>Interactive 3D Gradient Descent Visualization | Advanced Machine Learning Concepts</p> | |
| </div> | |
| <script> | |
| // Three.js Gradient Descent Visualization | |
| let scene, camera, renderer; | |
| let lossSurface, gradientPath, currentPoint; | |
| let isAnimating = false; | |
| let animationId; | |
| let currentIteration = 0; | |
| let totalIterations = 100; | |
| let learningRate = 0.1; | |
| let animationSpeed = 1.0; | |
| let surfaceComplexity = 3; | |
| // Initialize the application | |
| function init() { | |
| // Set up the scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x0c0c0c); | |
| // Set up the camera | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.z = 5; | |
| camera.position.y = 3; | |
| camera.lookAt(0, 0, 0); | |
| // Set up the renderer | |
| const canvas = document.getElementById('gradientDescentCanvas'); | |
| renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
| renderer.setSize(canvas.parentElement.clientWidth, canvas.parentElement.clientHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| // Add lighting | |
| const ambientLight = new THREE.AmbientLight(0x404040, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0x4facfe, 0.8); | |
| directionalLight.position.set(5, 5, 5); | |
| scene.add(directionalLight); | |
| const pointLight = new THREE.PointLight(0x00f2fe, 0.5); | |
| pointLight.position.set(-5, -5, 5); | |
| scene.add(pointLight); | |
| // Create the loss surface | |
| createLossSurface(); | |
| // Create gradient descent path | |
| createGradientPath(); | |
| // Create current position indicator | |
| createCurrentPoint(); | |
| // Start animation loop | |
| animate(); | |
| // Hide loading screen | |
| document.getElementById('loading').classList.add('hidden'); | |
| // Add window resize handler | |
| window.addEventListener('resize', onWindowResize); | |
| } | |
| // Create a complex 3D loss surface | |
| function createLossSurface() { | |
| if (lossSurface) scene.remove(lossSurface); | |
| const geometry = new THREE.PlaneGeometry(8, 8, 100, 100); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: 0x1a1a2e, | |
| wireframe: false, | |
| transparent: true, | |
| opacity: 0.8, | |
| side: THREE.DoubleSide, | |
| shininess: 100 | |
| }); | |
| lossSurface = new THREE.Mesh(geometry, material); | |
| lossSurface.rotation.x = -Math.PI / 2; | |
| // Create a complex surface with multiple minima and maxima | |
| const positions = geometry.attributes.position.array; | |
| for (let i = 0; i < positions.length; i += 3) { | |
| const x = positions[i]; | |
| const z = positions[i + 2]; | |
| // Complex function with multiple local minima | |
| let y = 0; | |
| y += Math.sin(x * surfaceComplexity * 0.5) * Math.cos(z * surfaceComplexity * 0.5); | |
| y += 0.5 * Math.sin(x * surfaceComplexity) * Math.sin(z * surfaceComplexity); | |
| y += 0.3 * Math.cos(x * surfaceComplexity * 2) * Math.sin(z * surfaceComplexity * 2); | |
| y += 0.2 * Math.sin(x * surfaceComplexity * 3 + z * surfaceComplexity * 2); | |
| positions[i + 1] = y * 0.8; | |
| } | |
| geometry.computeVertexNormals(); | |
| scene.add(lossSurface); | |
| // Add wireframe | |
| const wireframeGeometry = new THREE.WireframeGeometry(geometry); | |
| const wireframeMaterial = new THREE.LineBasicMaterial({ | |
| color: 0x4facfe, | |
| transparent: true, | |
| opacity: 0.3 | |
| }); | |
| const wireframe = new THREE.LineSegments(wireframeGeometry, wireframeMaterial); | |
| lossSurface.add(wireframe); | |
| } | |
| // Create gradient descent path visualization | |
| function createGradientPath() { | |
| if (gradientPath) scene.remove(gradientPath); | |
| const pathGeometry = new THREE.BufferGeometry(); | |
| const pathMaterial = new THREE.LineBasicMaterial({ | |
| color: 0x00f2fe, | |
| linewidth: 3 | |
| }); | |
| gradientPath = new THREE.Line(pathGeometry, pathMaterial); | |
| scene.add(gradientPath); | |
| } | |
| // Create current point indicator | |
| function createCurrentPoint() { | |
| if (currentPoint) scene.remove(currentPoint); | |
| const pointGeometry = new THREE.SphereGeometry(0.1, 16, 16); | |
| const pointMaterial = new THREE.MeshBasicMaterial({ color: 0xff6b6b }); | |
| currentPoint = new THREE.Mesh(pointGeometry, pointMaterial); | |
| scene.add(currentPoint); | |
| } | |
| // Perform one step of gradient descent | |
| function gradientDescentStep() { | |
| if (currentIteration >= totalIterations) { | |
| isAnimating = false; | |
| return; | |
| } | |
| // Calculate gradient (simplified for visualization) | |
| const x = currentPoint.position.x; | |
| const z = currentPoint.position.z; | |
| // Complex gradient calculation matching our surface | |
| let gradX = 0; | |
| let gradZ = 0; | |
| gradX += surfaceComplexity * 0.5 * Math.cos(x * surfaceComplexity * 0.5) * Math.cos(z * surfaceComplexity * 0.5); | |
| gradX += 0.5 * surfaceComplexity * Math.cos(x * surfaceComplexity) * Math.sin(z * surfaceComplexity); | |
| gradX += 0.3 * surfaceComplexity * 2 * -Math.sin(x * surfaceComplexity * 2) * Math.sin(z * surfaceComplexity * 2); | |
| gradX += 0.2 * surfaceComplexity * 3 * Math.cos(x * surfaceComplexity * 3 + z * surfaceComplexity * 2); | |
| gradZ += surfaceComplexity * 0.5 * Math.sin(x * surfaceComplexity * 0.5) * -Math.sin(z * surfaceComplexity * 0.5); | |
| gradZ += 0.5 * surfaceComplexity * Math.sin(x * surfaceComplexity) * Math.cos(z * surfaceComplexity); | |
| gradZ += 0.3 * surfaceComplexity * 2 * Math.cos(x * surfaceComplexity * 2) * Math.cos(z * surfaceComplexity * 2); | |
| gradZ += 0.2 * surfaceComplexity * 2 * Math.cos(x * surfaceComplexity * 3 + z * surfaceComplexity * 2); | |
| // Update position | |
| currentPoint.position.x -= learningRate * gradX; | |
| currentPoint.position.z -= learningRate * gradZ; | |
| // Calculate y position on surface | |
| const y = calculateSurfaceHeight(currentPoint.position.x, currentPoint.position.z); | |
| currentPoint.position.y = y; | |
| // Update path | |
| updateGradientPath(); | |
| currentIteration++; | |
| } | |
| // Calculate height on the loss surface | |
| function calculateSurfaceHeight(x, z) { | |
| let y = 0; | |
| y += Math.sin(x * surfaceComplexity * 0.5) * Math.cos(z * surfaceComplexity * 0.5); | |
| y += 0.5 * Math.sin(x * surfaceComplexity) * Math.sin(z * surfaceComplexity); | |
| y += 0.3 * Math.cos(x * surfaceComplexity * 2) * Math.sin(z * surfaceComplexity * 2); | |
| y += 0.2 * Math.sin(x * surfaceComplexity * 3 + z * surfaceComplexity * 2); | |
| return y * 0.8; | |
| } | |
| // Update the gradient path visualization | |
| function updateGradientPath() { | |
| const positions = []; | |
| // Start from initial position | |
| positions.push(3, calculateSurfaceHeight(3, 3), 3); | |
| // Add current path points (simplified for demo) | |
| for (let i = 0; i <= currentIteration; i++) { | |
| const t = i / totalIterations; | |
| const pathX = 3 - t * 6; | |
| const pathZ = 3 - t * 6; | |
| positions.push(pathX, calculateSurfaceHeight(pathX, pathZ), pathZ); | |
| } | |
| const pathGeometry = new THREE.BufferGeometry(); | |
| pathGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |
| gradientPath.geometry = pathGeometry; | |
| } | |
| // Animation loop | |
| function animate() { | |
| animationId = requestAnimationFrame(animate); | |
| if (isAnimating) { | |
| for (let i = 0; i < animationSpeed; i++) { | |
| gradientDescentStep(); | |
| } | |
| // Rotate camera slowly for better visualization | |
| camera.position.x = 5 * Math.sin(Date.now() * 0.0001); | |
| camera.position.z = 5 * Math.cos(Date.now() * 0.0001); | |
| camera.lookAt(0, 0, 0); | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| // Handle window resize | |
| function onWindowResize() { | |
| const canvasWrapper = document.querySelector('.canvas-wrapper'); | |
| camera.aspect = canvasWrapper.clientWidth / canvasWrapper.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(canvasWrapper.clientWidth, canvasWrapper.clientHeight); | |
| } | |
| // Reset the visualization | |
| function resetVisualization() { | |
| isAnimating = false; | |
| currentIteration = 0; | |
| // Reset current point to starting position | |
| currentPoint.position.set(3, calculateSurfaceHeight(3, 3), 3); | |
| // Reset path | |
| updateGradientPath(); | |
| } | |
| // Randomize starting position | |
| function randomizeStart() { | |
| isAnimating = false; | |
| currentIteration = 0; | |
| const startX = (Math.random() - 0.5) * 6; | |
| const startZ = (Math.random() - 0.5) * 6; | |
| currentPoint.position.set(startX, calculateSurfaceHeight(startX, startZ), startZ); | |
| // Reset path | |
| updateGradientPath(); | |
| } | |
| // Initialize controls | |
| function initControls() { | |
| // Learning rate slider | |
| document.getElementById('learningRate').addEventListener('input', function(e) { | |
| learningRate = parseFloat(e.target.value); | |
| document.getElementById('learningRateValue').textContent = learningRate.toFixed(3); | |
| resetVisualization(); | |
| createLossSurface(); | |
| }); | |
| // Iterations slider | |
| document.getElementById('iterations').addEventListener('input', function(e) { | |
| totalIterations = parseInt(e.target.value); | |
| document.getElementById('iterationsValue').textContent = totalIterations; | |
| }); | |
| // Animation speed slider | |
| document.getElementById('animationSpeed').addEventListener('input', function(e) { | |
| animationSpeed = parseFloat(e.target.value); | |
| document.getElementById('speedValue').textContent = animationSpeed.toFixed(1); | |
| }); | |
| // Surface complexity slider | |
| document.getElementById('surfaceComplexity').addEventListener('input', function(e) { | |
| surfaceComplexity = parseInt(e.target.value); | |
| document.getElementById('complexityValue').textContent = surfaceComplexity; | |
| resetVisualization(); | |
| createLossSurface(); | |
| }); | |
| // Control buttons | |
| document.getElementById('startBtn').addEventListener('click', function() { | |
| isAnimating = true; | |
| }); | |
| document.getElementById('resetBtn').addEventListener('click', resetVisualization); | |
| document.getElementById('pauseBtn').addEventListener('click', function() { | |
| isAnimating = false; | |
| }); | |
| document.getElementById('randomizeBtn').addEventListener('click', randomizeStart); | |
| } | |
| // Initialize when DOM is loaded | |
| document.addEventListener('DOMContentLoaded', function() { | |
| init(); | |
| initControls(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |