/** * Wave Particle Animation System for Leaflet * Based on wind particle systems but adapted for ocean wave data */ class WaveParticleSystem { constructor(map, options = {}) { this.map = map; this.particles = []; this.activeParticles = []; this.animationId = null; this.isAnimating = false; // Configuration this.config = { particleCount: options.particleCount || 1500, particleLife: options.particleLife || 50, // frames particleSpeed: options.particleSpeed || 1.0, particleSize: options.particleSize || 1.5, fadeOpacity: options.fadeOpacity || 0.98, colorScheme: options.colorScheme || 'ocean', trailLength: options.trailLength || 10, ...options }; // Canvas setup this.canvas = null; this.ctx = null; this.setupCanvas(); // Wave data this.waveData = null; this.bounds = null; } setupCanvas() { // Create canvas overlay this.canvas = document.createElement('canvas'); this.canvas.style.position = 'absolute'; this.canvas.style.top = '0'; this.canvas.style.left = '0'; this.canvas.style.pointerEvents = 'none'; this.canvas.style.zIndex = '1000'; this.ctx = this.canvas.getContext('2d'); // Add to map this.map.getContainer().appendChild(this.canvas); // Resize handling this.map.on('resize', () => this.resizeCanvas()); this.map.on('zoom', () => this.handleMapChange()); this.map.on('move', () => this.handleMapChange()); this.resizeCanvas(); } resizeCanvas() { const container = this.map.getContainer(); this.canvas.width = container.offsetWidth; this.canvas.height = container.offsetHeight; this.canvas.style.width = container.offsetWidth + 'px'; this.canvas.style.height = container.offsetHeight + 'px'; } handleMapChange() { // Recalculate particle positions when map changes this.updateParticleScreenPositions(); } loadWaveData(waveData) { this.waveData = waveData; this.calculateBounds(); this.initializeParticles(); console.log(`Loaded ${waveData.particles ? waveData.particles.length : 0} wave data points for particle animation`); } calculateBounds() { if (!this.waveData || !this.waveData.particles) return; const lats = this.waveData.particles.map(p => p.lat); const lons = this.waveData.particles.map(p => p.lon); this.bounds = { north: Math.max(...lats), south: Math.min(...lats), east: Math.max(...lons), west: Math.min(...lons) }; } initializeParticles() { this.particles = []; this.activeParticles = []; if (!this.waveData || !this.waveData.particles) return; // Create particle pool from wave data for (let i = 0; i < Math.min(this.config.particleCount, this.waveData.particles.length); i++) { const wavePoint = this.waveData.particles[i]; const particle = this.createParticle(wavePoint); this.particles.push(particle); } // Initialize some active particles const initialActiveCount = Math.min(200, this.particles.length); for (let i = 0; i < initialActiveCount; i++) { this.activateParticle(); } } createParticle(wavePoint) { return { // Geographic position lat: wavePoint.lat, lon: wavePoint.lon, // Screen position (calculated dynamically) x: 0, y: 0, // Wave properties waveHeight: wavePoint.wave_height, waveDirection: wavePoint.wave_direction, wavePeriod: wavePoint.wave_period, // Velocity components (adjusted for screen space) vx: wavePoint.u_velocity || 0, vy: wavePoint.v_velocity || 0, // Particle state life: 0, maxLife: this.config.particleLife + Math.random() * 20, size: wavePoint.particle_size || this.config.particleSize, opacity: 1.0, active: false, // Visual properties colorIntensity: wavePoint.color_intensity || 0.5, trail: [] // For particle trails }; } activateParticle() { // Find inactive particle const inactive = this.particles.find(p => !p.active); if (!inactive) return; // Reset particle properties inactive.active = true; inactive.life = 0; inactive.opacity = 1.0; inactive.trail = []; // Random starting position within bounds const randomWavePoint = this.waveData.particles[Math.floor(Math.random() * this.waveData.particles.length)]; inactive.lat = randomWavePoint.lat + (Math.random() - 0.5) * 2; // Small random offset inactive.lon = randomWavePoint.lon + (Math.random() - 0.5) * 2; // Update screen position this.updateParticleScreenPosition(inactive); this.activeParticles.push(inactive); } updateParticleScreenPosition(particle) { const point = this.map.latLngToContainerPoint([particle.lat, particle.lon]); particle.x = point.x; particle.y = point.y; } updateParticleScreenPositions() { this.activeParticles.forEach(particle => { this.updateParticleScreenPosition(particle); }); } interpolateWaveField(lat, lon) { if (!this.waveData || !this.waveData.particles) return { u: 0, v: 0, height: 0 }; // Simple nearest neighbor for now (could be improved with bilinear interpolation)\n let nearest = null; let minDistance = Infinity; \n for (const point of this.waveData.particles) { const distance = Math.sqrt(\n Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2)\n ); if (distance < minDistance) {\n minDistance = distance;\n nearest = point;\n }\n }\n \n if (nearest && minDistance < 2.0) { // Within 2 degrees\n return {\n u: nearest.u_velocity || 0,\n v: nearest.v_velocity || 0,\n height: nearest.wave_height || 0,\n direction: nearest.wave_direction || 0\n };\n }\n \n return { u: 0, v: 0, height: 0, direction: 0 };\n }\n \n updateParticles() {\n const zoom = this.map.getZoom();\n const speedScale = Math.pow(2, zoom - 2) * this.config.particleSpeed;\n \n this.activeParticles = this.activeParticles.filter(particle => {\n // Age the particle\n particle.life++;\n \n // Check if particle should die\n if (particle.life > particle.maxLife) {\n particle.active = false;\n return false;\n }\n \n // Get wave field at current position\n const field = this.interpolateWaveField(particle.lat, particle.lon);\n \n // Update velocity based on local wave field\n particle.vx = field.u * speedScale * 100; // Scale for screen pixels\n particle.vy = -field.v * speedScale * 100; // Negative because screen Y increases downward\n \n // Update position\n particle.x += particle.vx;\n particle.y += particle.vy;\n \n // Convert back to geographic coordinates\n const latlng = this.map.containerPointToLatLng([particle.x, particle.y]);\n particle.lat = latlng.lat;\n particle.lon = latlng.lng;\n \n // Update trail\n particle.trail.push({ x: particle.x, y: particle.y });\n if (particle.trail.length > this.config.trailLength) {\n particle.trail.shift();\n }\n \n // Update opacity (fade with age)\n particle.opacity = 1.0 - (particle.life / particle.maxLife);\n \n // Check bounds\n const bounds = this.map.getBounds();\n const margin = 100; // pixels\n if (particle.x < -margin || particle.x > this.canvas.width + margin ||\n particle.y < -margin || particle.y > this.canvas.height + margin) {\n // Particle is off screen, deactivate\n particle.active = false;\n return false;\n }\n \n return true;\n });\n \n // Maintain particle count by activating new ones\n const targetActive = Math.min(300, this.particles.length);\n while (this.activeParticles.length < targetActive) {\n this.activateParticle();\n }\n }\n \n getParticleColor(particle) {\n const intensity = particle.colorIntensity;\n const opacity = particle.opacity;\n \n switch (this.config.colorScheme) {\n case 'ocean':\n // Blue to white based on wave height\n const r = Math.floor(intensity * 255);\n const g = Math.floor(100 + intensity * 155);\n const b = 255;\n return `rgba(${r}, ${g}, ${b}, ${opacity})`;\n \n case 'heatmap':\n // Heat map colors\n if (intensity < 0.3) {\n return `rgba(0, 0, ${Math.floor(255 * intensity / 0.3)}, ${opacity})`;\n } else if (intensity < 0.7) {\n const green = Math.floor(255 * (intensity - 0.3) / 0.4);\n return `rgba(0, ${green}, 255, ${opacity})`;\n } else {\n const red = Math.floor(255 * (intensity - 0.7) / 0.3);\n return `rgba(${red}, 255, 255, ${opacity})`;\n }\n \n default:\n return `rgba(255, 255, 255, ${opacity})`;\n }\n }\n \n drawParticles() {\n // Clear canvas with fade effect\n this.ctx.fillStyle = `rgba(0, 0, 0, ${1 - this.config.fadeOpacity})`;\n this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);\n \n // Draw particles\n this.activeParticles.forEach(particle => {\n this.drawParticle(particle);\n });\n }\n \n drawParticle(particle) {\n const color = this.getParticleColor(particle);\n \n // Draw particle trail\n if (particle.trail.length > 1) {\n this.ctx.beginPath();\n this.ctx.strokeStyle = color;\n this.ctx.lineWidth = 1;\n this.ctx.globalAlpha = particle.opacity * 0.3;\n \n this.ctx.moveTo(particle.trail[0].x, particle.trail[0].y);\n for (let i = 1; i < particle.trail.length; i++) {\n this.ctx.lineTo(particle.trail[i].x, particle.trail[i].y);\n }\n this.ctx.stroke();\n }\n \n // Draw particle\n this.ctx.beginPath();\n this.ctx.fillStyle = color;\n this.ctx.globalAlpha = particle.opacity;\n this.ctx.arc(particle.x, particle.y, particle.size, 0, 2 * Math.PI);\n this.ctx.fill();\n \n // Reset alpha\n this.ctx.globalAlpha = 1.0;\n }\n \n animate() {\n if (!this.isAnimating) return;\n \n this.updateParticles();\n this.drawParticles();\n \n this.animationId = requestAnimationFrame(() => this.animate());\n }\n \n start() {\n if (this.isAnimating) return;\n \n this.isAnimating = true;\n console.log('Starting wave particle animation');\n this.animate();\n }\n \n stop() {\n if (!this.isAnimating) return;\n \n this.isAnimating = false;\n if (this.animationId) {\n cancelAnimationFrame(this.animationId);\n this.animationId = null;\n }\n console.log('Stopped wave particle animation');\n }\n \n setColorScheme(scheme) {\n this.config.colorScheme = scheme;\n }\n \n setParticleSpeed(speed) {\n this.config.particleSpeed = speed;\n }\n \n setParticleCount(count) {\n this.config.particleCount = count;\n // Reinitialize if needed\n if (this.waveData) {\n this.initializeParticles();\n }\n }\n \n destroy() {\n this.stop();\n \n if (this.canvas && this.canvas.parentNode) {\n this.canvas.parentNode.removeChild(this.canvas);\n }\n \n this.map.off('resize');\n this.map.off('zoom');\n this.map.off('move');\n \n this.particles = [];\n this.activeParticles = [];\n this.waveData = null;\n }\n}\n\n// Export for use in other modules\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = WaveParticleSystem;\n} else if (typeof window !== 'undefined') {\n window.WaveParticleSystem = WaveParticleSystem;\n}