Spaces:
Runtime error
Runtime error
| /** | |
| * 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} |