Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Watch Whales Think 🐳</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| overflow: hidden; | |
| background: #000; | |
| font-family: 'Inter', system-ui, sans-serif; | |
| } | |
| canvas { display: block; position: fixed; top: 0; left: 0; } | |
| /* Glassmorphism controls */ | |
| .controls { | |
| position: fixed; | |
| top: 20px; | |
| left: 20px; | |
| padding: 24px; | |
| background: rgba(0, 0, 0, 0.6); | |
| backdrop-filter: blur(20px); | |
| border-radius: 20px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| color: white; | |
| z-index: 100; | |
| min-width: 280px; | |
| transition: opacity 0.3s, transform 0.3s; | |
| } | |
| .controls.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| transform: translateX(-100%); | |
| } | |
| .controls h1 { | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| margin-bottom: 4px; | |
| background: linear-gradient(135deg, #00d9ff, #00ff88); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .controls .tagline { | |
| font-size: 0.85rem; | |
| color: rgba(255, 255, 255, 0.6); | |
| margin-bottom: 20px; | |
| } | |
| .slider-group { margin-bottom: 16px; } | |
| .slider-group label { | |
| display: block; | |
| font-size: 0.75rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| color: rgba(255, 255, 255, 0.7); | |
| margin-bottom: 6px; | |
| } | |
| input[type="range"] { | |
| width: 100%; | |
| height: 6px; | |
| border-radius: 3px; | |
| background: rgba(255, 255, 255, 0.1); | |
| appearance: none; | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #00d9ff, #00ff88); | |
| cursor: pointer; | |
| box-shadow: 0 0 15px rgba(0, 217, 255, 0.5); | |
| } | |
| .btn { | |
| width: 100%; | |
| padding: 12px; | |
| border: none; | |
| border-radius: 12px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| margin-top: 8px; | |
| font-size: 0.9rem; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #00d9ff, #00ff88); | |
| color: #000; | |
| } | |
| .btn-secondary { | |
| background: rgba(255, 255, 255, 0.1); | |
| color: white; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| } | |
| .btn:hover { transform: scale(1.02); } | |
| .btn:active { transform: scale(0.98); } | |
| .btn-audio.active { | |
| background: linear-gradient(135deg, #ff0080, #ff6600); | |
| animation: pulse 1s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 0, 128, 0.5); } | |
| 50% { box-shadow: 0 0 20px 5px rgba(255, 0, 128, 0.3); } | |
| } | |
| /* Floating toggle button */ | |
| .toggle-btn { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| width: 50px; | |
| height: 50px; | |
| border-radius: 50%; | |
| background: rgba(0, 0, 0, 0.6); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| color: white; | |
| font-size: 1.5rem; | |
| cursor: pointer; | |
| z-index: 100; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s; | |
| } | |
| .toggle-btn:hover { | |
| background: rgba(0, 217, 255, 0.3); | |
| transform: scale(1.1); | |
| } | |
| /* Audio visualizer indicator */ | |
| .audio-visualizer { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: flex; | |
| gap: 4px; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .audio-visualizer.active { opacity: 1; } | |
| .audio-bar { | |
| width: 4px; | |
| height: 20px; | |
| background: linear-gradient(to top, #00d9ff, #00ff88); | |
| border-radius: 2px; | |
| transform-origin: bottom; | |
| } | |
| /* Recording indicator */ | |
| .recording-indicator { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 16px; | |
| background: rgba(255, 0, 0, 0.8); | |
| border-radius: 20px; | |
| color: white; | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| z-index: 100; | |
| } | |
| .recording-indicator.active { opacity: 1; } | |
| .recording-dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| background: white; | |
| animation: blink 1s infinite; | |
| } | |
| @keyframes blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.3; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="canvas"></canvas> | |
| <!-- Controls Panel --> | |
| <div class="controls" id="controls"> | |
| <h1>🐳 Hypnotic Flocking</h1> | |
| <p class="tagline">Watch whales think</p> | |
| <div class="slider-group"> | |
| <label>Chaos Level</label> | |
| <input type="range" id="chaos" min="0" max="100" value="30"> | |
| </div> | |
| <div class="slider-group"> | |
| <label>Whale Count</label> | |
| <input type="range" id="count" min="50" max="1000" value="500"> | |
| </div> | |
| <div class="slider-group"> | |
| <label>Trail Glow</label> | |
| <input type="range" id="trail" min="0" max="100" value="70"> | |
| </div> | |
| <button class="btn btn-secondary btn-audio" id="audioBtn"> | |
| 🎤 Audio Reactive | |
| </button> | |
| <button class="btn btn-primary" id="recordBtn"> | |
| 📹 Record MP4 | |
| </button> | |
| <button class="btn btn-secondary" id="resetBtn"> | |
| ↻ Reset | |
| </button> | |
| </div> | |
| <!-- Toggle Button --> | |
| <button class="toggle-btn" id="toggleBtn" title="Toggle Controls">⚙️</button> | |
| <!-- Audio Visualizer --> | |
| <div class="audio-visualizer" id="audioVisualizer"> | |
| <div class="audio-bar"></div> | |
| <div class="audio-bar"></div> | |
| <div class="audio-bar"></div> | |
| <div class="audio-bar"></div> | |
| <div class="audio-bar"></div> | |
| <div class="audio-bar"></div> | |
| <div class="audio-bar"></div> | |
| <div class="audio-bar"></div> | |
| </div> | |
| <!-- Recording Indicator --> | |
| <div class="recording-indicator" id="recordingIndicator"> | |
| <div class="recording-dot"></div> | |
| <span>Recording...</span> | |
| </div> | |
| <script> | |
| // Canvas setup | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let width, height; | |
| function resize() { | |
| width = canvas.width = window.innerWidth; | |
| height = canvas.height = window.innerHeight; | |
| } | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| // State | |
| let particles = []; | |
| let audioContext = null; | |
| let analyser = null; | |
| let audioData = null; | |
| let isAudioActive = false; | |
| let audioLevel = 0; | |
| let mediaRecorder = null; | |
| let recordedChunks = []; | |
| let isRecording = false; | |
| // Controls | |
| const chaosSlider = document.getElementById('chaos'); | |
| const countSlider = document.getElementById('count'); | |
| const trailSlider = document.getElementById('trail'); | |
| const audioBtn = document.getElementById('audioBtn'); | |
| const recordBtn = document.getElementById('recordBtn'); | |
| const resetBtn = document.getElementById('resetBtn'); | |
| const toggleBtn = document.getElementById('toggleBtn'); | |
| const controlsPanel = document.getElementById('controls'); | |
| const audioVisualizer = document.getElementById('audioVisualizer'); | |
| const recordingIndicator = document.getElementById('recordingIndicator'); | |
| const audioBars = audioVisualizer.querySelectorAll('.audio-bar'); | |
| // Particle class | |
| class Particle { | |
| constructor() { | |
| this.x = Math.random() * width; | |
| this.y = Math.random() * height; | |
| this.vx = (Math.random() - 0.5) * 4; | |
| this.vy = (Math.random() - 0.5) * 4; | |
| this.history = []; | |
| this.hue = Math.random() * 60 + 140; // Teal-green range | |
| } | |
| update(particles, chaos) { | |
| // Boids algorithm | |
| let alignX = 0, alignY = 0; | |
| let cohesionX = 0, cohesionY = 0; | |
| let separationX = 0, separationY = 0; | |
| let neighbors = 0; | |
| const perception = 80 + chaos * 0.5; | |
| const chaosMultiplier = 1 + (chaos / 100) * 2; | |
| for (const other of particles) { | |
| const dx = other.x - this.x; | |
| const dy = other.y - this.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist > 0 && dist < perception) { | |
| alignX += other.vx; | |
| alignY += other.vy; | |
| cohesionX += other.x; | |
| cohesionY += other.y; | |
| if (dist < 30) { | |
| separationX -= dx / dist; | |
| separationY -= dy / dist; | |
| } | |
| neighbors++; | |
| } | |
| } | |
| if (neighbors > 0) { | |
| // Alignment | |
| alignX /= neighbors; | |
| alignY /= neighbors; | |
| this.vx += (alignX - this.vx) * 0.05 * chaosMultiplier; | |
| this.vy += (alignY - this.vy) * 0.05 * chaosMultiplier; | |
| // Cohesion | |
| cohesionX /= neighbors; | |
| cohesionY /= neighbors; | |
| this.vx += (cohesionX - this.x) * 0.001; | |
| this.vy += (cohesionY - this.y) * 0.001; | |
| // Separation | |
| this.vx += separationX * 0.5; | |
| this.vy += separationY * 0.5; | |
| } | |
| // Audio reactivity | |
| if (isAudioActive && audioLevel > 0) { | |
| const audioForce = audioLevel * 0.3; | |
| this.vx += (Math.random() - 0.5) * audioForce; | |
| this.vy += (Math.random() - 0.5) * audioForce; | |
| this.hue = 140 + audioLevel * 200; // Shift colors with audio | |
| } | |
| // Speed limit | |
| const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy); | |
| const maxSpeed = 4 + (chaos / 100) * 4; | |
| if (speed > maxSpeed) { | |
| this.vx = (this.vx / speed) * maxSpeed; | |
| this.vy = (this.vy / speed) * maxSpeed; | |
| } | |
| // Move | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| // Wrap edges | |
| if (this.x < 0) this.x = width; | |
| if (this.x > width) this.x = 0; | |
| if (this.y < 0) this.y = height; | |
| if (this.y > height) this.y = 0; | |
| // Store history for trails | |
| this.history.push({ x: this.x, y: this.y }); | |
| const maxHistory = Math.floor(trailSlider.value / 2) + 5; | |
| if (this.history.length > maxHistory) { | |
| this.history.shift(); | |
| } | |
| } | |
| draw() { | |
| // Draw trail | |
| if (this.history.length > 1) { | |
| ctx.beginPath(); | |
| ctx.moveTo(this.history[0].x, this.history[0].y); | |
| for (let i = 1; i < this.history.length; i++) { | |
| ctx.lineTo(this.history[i].x, this.history[i].y); | |
| } | |
| const gradient = ctx.createLinearGradient( | |
| this.history[0].x, this.history[0].y, | |
| this.x, this.y | |
| ); | |
| gradient.addColorStop(0, `hsla(${this.hue}, 100%, 50%, 0)`); | |
| gradient.addColorStop(1, `hsla(${this.hue}, 100%, 60%, 0.8)`); | |
| ctx.strokeStyle = gradient; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| } | |
| // Draw head | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, 3, 0, Math.PI * 2); | |
| ctx.fillStyle = `hsl(${this.hue}, 100%, 70%)`; | |
| ctx.fill(); | |
| } | |
| } | |
| // Initialize particles | |
| function initParticles(count) { | |
| particles = []; | |
| for (let i = 0; i < count; i++) { | |
| particles.push(new Particle()); | |
| } | |
| } | |
| initParticles(500); | |
| // Animation loop | |
| function animate() { | |
| // Fade effect for trails | |
| ctx.fillStyle = `rgba(0, 0, 0, ${1 - trailSlider.value / 100})`; | |
| ctx.fillRect(0, 0, width, height); | |
| const chaos = parseFloat(chaosSlider.value); | |
| // Update audio level | |
| if (isAudioActive && analyser) { | |
| analyser.getByteFrequencyData(audioData); | |
| audioLevel = audioData.reduce((a, b) => a + b) / audioData.length / 255; | |
| // Update visualizer bars | |
| for (let i = 0; i < audioBars.length; i++) { | |
| const value = audioData[i * 4] / 255; | |
| audioBars[i].style.transform = `scaleY(${0.3 + value * 2})`; | |
| } | |
| } | |
| // Update and draw particles | |
| for (const p of particles) { | |
| p.update(particles, chaos); | |
| p.draw(); | |
| } | |
| requestAnimationFrame(animate); | |
| } | |
| animate(); | |
| // Event listeners | |
| countSlider.addEventListener('input', () => { | |
| const targetCount = parseInt(countSlider.value); | |
| if (particles.length < targetCount) { | |
| while (particles.length < targetCount) { | |
| particles.push(new Particle()); | |
| } | |
| } else { | |
| particles.length = targetCount; | |
| } | |
| }); | |
| resetBtn.addEventListener('click', () => { | |
| initParticles(parseInt(countSlider.value)); | |
| }); | |
| toggleBtn.addEventListener('click', () => { | |
| controlsPanel.classList.toggle('hidden'); | |
| }); | |
| // Audio reactive mode | |
| audioBtn.addEventListener('click', async () => { | |
| if (!isAudioActive) { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| audioContext = new AudioContext(); | |
| const source = audioContext.createMediaStreamSource(stream); | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 64; | |
| source.connect(analyser); | |
| audioData = new Uint8Array(analyser.frequencyBinCount); | |
| isAudioActive = true; | |
| audioBtn.classList.add('active'); | |
| audioBtn.textContent = '🎤 Audio ON'; | |
| audioVisualizer.classList.add('active'); | |
| } catch (e) { | |
| console.error('Microphone access denied:', e); | |
| alert('Please allow microphone access for audio-reactive mode'); | |
| } | |
| } else { | |
| isAudioActive = false; | |
| audioLevel = 0; | |
| if (audioContext) { | |
| audioContext.close(); | |
| audioContext = null; | |
| } | |
| audioBtn.classList.remove('active'); | |
| audioBtn.textContent = '🎤 Audio Reactive'; | |
| audioVisualizer.classList.remove('active'); | |
| } | |
| }); | |
| // MP4 Recording | |
| recordBtn.addEventListener('click', async () => { | |
| if (!isRecording) { | |
| try { | |
| const stream = canvas.captureStream(60); | |
| mediaRecorder = new MediaRecorder(stream, { | |
| mimeType: 'video/webm;codecs=vp9', | |
| videoBitsPerSecond: 8000000 | |
| }); | |
| recordedChunks = []; | |
| mediaRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) recordedChunks.push(e.data); | |
| }; | |
| mediaRecorder.onstop = () => { | |
| const blob = new Blob(recordedChunks, { type: 'video/webm' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'whale-dreams.webm'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }; | |
| mediaRecorder.start(); | |
| isRecording = true; | |
| recordBtn.textContent = '⏹️ Stop Recording'; | |
| recordBtn.classList.remove('btn-primary'); | |
| recordBtn.classList.add('btn-secondary'); | |
| recordingIndicator.classList.add('active'); | |
| // Auto-stop after 30 seconds | |
| setTimeout(() => { | |
| if (isRecording) { | |
| recordBtn.click(); | |
| } | |
| }, 30000); | |
| } catch (e) { | |
| console.error('Recording failed:', e); | |
| alert('Recording not supported in this browser'); | |
| } | |
| } else { | |
| mediaRecorder.stop(); | |
| isRecording = false; | |
| recordBtn.textContent = '📹 Record MP4'; | |
| recordBtn.classList.add('btn-primary'); | |
| recordBtn.classList.remove('btn-secondary'); | |
| recordingIndicator.classList.remove('active'); | |
| } | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'f' || e.key === 'F') { | |
| controlsPanel.classList.toggle('hidden'); | |
| } | |
| if (e.key === ' ') { | |
| audioBtn.click(); | |
| } | |
| if (e.key === 'r' || e.key === 'R') { | |
| recordBtn.click(); | |
| } | |
| }); | |
| // Start in fullscreen mode after 5 seconds | |
| setTimeout(() => { | |
| controlsPanel.classList.add('hidden'); | |
| }, 5000); | |
| </script> | |
| </body> | |
| </html> | |