Spaces:
Build error
Build error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>๐ช๏ธ Wind Particle Visualization - Earth.nullschool.net Style</title> | |
| <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" /> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 20px; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| } | |
| h1 { | |
| text-align: center; | |
| margin-bottom: 10px; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| } | |
| .subtitle { | |
| text-align: center; | |
| margin-bottom: 20px; | |
| opacity: 0.9; | |
| } | |
| #map { | |
| height: 600px; | |
| width: 100%; | |
| border-radius: 10px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); | |
| margin-bottom: 20px; | |
| } | |
| .controls { | |
| display: flex; | |
| justify-content: center; | |
| gap: 15px; | |
| margin-bottom: 20px; | |
| } | |
| button { | |
| padding: 12px 24px; | |
| border: none; | |
| border-radius: 25px; | |
| background: rgba(255,255,255,0.2); | |
| color: white; | |
| font-weight: bold; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| backdrop-filter: blur(10px); | |
| } | |
| button:hover { | |
| background: rgba(255,255,255,0.3); | |
| transform: translateY(-2px); | |
| } | |
| .info { | |
| text-align: center; | |
| background: rgba(255,255,255,0.1); | |
| padding: 15px; | |
| border-radius: 10px; | |
| backdrop-filter: blur(10px); | |
| } | |
| .legend { | |
| position: absolute; | |
| bottom: 20px; | |
| right: 20px; | |
| background: rgba(255,255,255,0.9); | |
| color: #333; | |
| padding: 15px; | |
| border-radius: 10px; | |
| font-size: 12px; | |
| z-index: 1000; | |
| box-shadow: 0 5px 15px rgba(0,0,0,0.2); | |
| } | |
| .legend h4 { | |
| margin: 0 0 10px 0; | |
| color: #333; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| margin: 5px 0; | |
| } | |
| .legend-color { | |
| width: 20px; | |
| height: 3px; | |
| margin-right: 8px; | |
| border-radius: 2px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>๐ช๏ธ Wind Particle Visualization</h1> | |
| <p class="subtitle">Earth.nullschool.net style particle animation with synthetic wind data</p> | |
| <div class="controls"> | |
| <button onclick="startParticles()">๐ช๏ธ Start Wind Particles</button> | |
| <button onclick="resetParticles()">๐ Reset</button> | |
| <button onclick="toggleSpeed()">โก Toggle Speed</button> | |
| </div> | |
| <div id="map"></div> | |
| <div class="info"> | |
| <strong>๐ฏ Demo Features:</strong> Earth.nullschool.net style particle system โข Bilinear interpolation โข 1000 animated particles โข Zoom-responsive visualization โข Synthetic global wind patterns | |
| </div> | |
| </div> | |
| <div class="legend"> | |
| <h4>๐ช๏ธ Wind Speed</h4> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background: rgba(110,159,190,0.9);"></div> | |
| <span>< 5 m/s</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background: rgba(65,120,190,0.9);"></div> | |
| <span>5-10 m/s</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background: rgba(25,80,170,0.9);"></div> | |
| <span>10-15 m/s</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background: rgba(85,160,35,0.9);"></div> | |
| <span>15-20 m/s</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background: rgba(255,215,0,0.9);"></div> | |
| <span>20-25 m/s</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-color" style="background: rgba(255,120,0,0.9);"></div> | |
| <span>> 25 m/s</span> | |
| </div> | |
| </div> | |
| <script> | |
| console.log('๐ช๏ธ Initializing wind particle system...'); | |
| // Initialize map | |
| const map = L.map('map').setView([30, 0], 3); | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| attribution: 'ยฉ OpenStreetMap contributors' | |
| }).addTo(map); | |
| // Global variables | |
| let particleSystem = null; | |
| let animationSpeed = 1; | |
| let windField = null; | |
| // Generate synthetic wind data (earth.nullschool.net style) | |
| function generateWindField() { | |
| console.log('Generating synthetic wind field...'); | |
| const lats = []; | |
| const lons = []; | |
| const winds = []; | |
| // Create global grid | |
| for (let lat = 60; lat >= -60; lat -= 3) { | |
| for (let lon = -180; lon <= 180; lon += 6) { | |
| lats.push(lat); | |
| lons.push(lon); | |
| // Realistic wind patterns | |
| const latRad = lat * Math.PI / 180; | |
| const lonRad = lon * Math.PI / 180; | |
| // Jet stream + trade winds + random turbulence | |
| const u = 15 * Math.sin(latRad) + 8 * Math.cos(lonRad/2) + (Math.random() - 0.5) * 6; | |
| const v = 5 * Math.cos(latRad) + 3 * Math.sin(lonRad/3) + (Math.random() - 0.5) * 4; | |
| winds.push({ lat, lon, u, v, speed: Math.sqrt(u*u + v*v) }); | |
| } | |
| } | |
| console.log(`Generated ${winds.length} wind points`); | |
| return winds; | |
| } | |
| // Wind particle system (earth.nullschool.net inspired) | |
| class WindParticleSystem { | |
| constructor(map, windData) { | |
| this.map = map; | |
| this.windData = windData; | |
| this.particles = []; | |
| this.numParticles = 1000; | |
| this.particleAge = 80; | |
| this.canvas = null; | |
| this.ctx = null; | |
| this.animationId = null; | |
| this.setup(); | |
| } | |
| setup() { | |
| const mapContainer = this.map.getContainer(); | |
| // 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.canvas.width = mapContainer.offsetWidth; | |
| this.canvas.height = mapContainer.offsetHeight; | |
| mapContainer.style.position = 'relative'; | |
| mapContainer.appendChild(this.canvas); | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.ctx.lineCap = 'round'; | |
| this.ctx.lineJoin = 'round'; | |
| // Initialize particles | |
| this.initializeParticles(); | |
| this.animate(); | |
| // Handle map events | |
| this.map.on('moveend zoomend resize', () => this.updateCanvas()); | |
| console.log('โ Wind particle system initialized'); | |
| } | |
| updateCanvas() { | |
| const mapContainer = this.map.getContainer(); | |
| this.canvas.width = mapContainer.offsetWidth; | |
| this.canvas.height = mapContainer.offsetHeight; | |
| } | |
| initializeParticles() { | |
| this.particles = []; | |
| const bounds = this.map.getBounds(); | |
| for (let i = 0; i < this.numParticles; i++) { | |
| this.particles.push({ | |
| lat: bounds.getSouth() + Math.random() * (bounds.getNorth() - bounds.getSouth()), | |
| lon: bounds.getWest() + Math.random() * (bounds.getEast() - bounds.getWest()), | |
| age: Math.floor(Math.random() * this.particleAge), | |
| trail: [] | |
| }); | |
| } | |
| } | |
| getWindAtPosition(lat, lon) { | |
| // Find nearest wind point (simple approach) | |
| let minDist = Infinity; | |
| let nearestWind = { u: 0, v: 0, speed: 0 }; | |
| for (const wind of this.windData) { | |
| const dist = Math.sqrt((wind.lat - lat) ** 2 + (wind.lon - lon) ** 2); | |
| if (dist < minDist) { | |
| minDist = dist; | |
| nearestWind = wind; | |
| } | |
| } | |
| return nearestWind; | |
| } | |
| getWindColor(speed) { | |
| // Earth.nullschool.net color scheme | |
| if (speed < 5) return 'rgba(110, 159, 190, 0.8)'; | |
| if (speed < 10) return 'rgba(65, 120, 190, 0.8)'; | |
| if (speed < 15) return 'rgba(25, 80, 170, 0.8)'; | |
| if (speed < 20) return 'rgba(85, 160, 35, 0.8)'; | |
| if (speed < 25) return 'rgba(255, 215, 0, 0.8)'; | |
| return 'rgba(255, 120, 0, 0.8)'; | |
| } | |
| evolveParticle(particle) { | |
| const wind = this.getWindAtPosition(particle.lat, particle.lon); | |
| // Move particle based on wind | |
| const scale = 0.01 * animationSpeed * Math.pow(2, this.map.getZoom() - 5); | |
| particle.lat += wind.v * scale; | |
| particle.lon += wind.u * scale; | |
| particle.age++; | |
| // Reset if too old or out of bounds | |
| const bounds = this.map.getBounds(); | |
| if (particle.age > this.particleAge || | |
| particle.lat < bounds.getSouth() || particle.lat > bounds.getNorth() || | |
| particle.lon < bounds.getWest() || particle.lon > bounds.getEast()) { | |
| particle.lat = bounds.getSouth() + Math.random() * (bounds.getNorth() - bounds.getSouth()); | |
| particle.lon = bounds.getWest() + Math.random() * (bounds.getEast() - bounds.getWest()); | |
| particle.age = 0; | |
| } | |
| return wind; | |
| } | |
| render() { | |
| // Fade effect | |
| this.ctx.globalCompositeOperation = 'destination-in'; | |
| this.ctx.globalAlpha = 0.95; | |
| this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); | |
| // Draw particles | |
| this.ctx.globalCompositeOperation = 'lighter'; | |
| this.ctx.globalAlpha = 0.9; | |
| this.ctx.lineWidth = 1.5; | |
| for (const particle of this.particles) { | |
| const wind = this.evolveParticle(particle); | |
| if (wind.speed > 0.5) { | |
| const point = this.map.latLngToContainerPoint([particle.lat, particle.lon]); | |
| if (point.x >= 0 && point.x < this.canvas.width && | |
| point.y >= 0 && point.y < this.canvas.height) { | |
| this.ctx.strokeStyle = this.getWindColor(wind.speed); | |
| this.ctx.beginPath(); | |
| this.ctx.arc(point.x, point.y, 1, 0, 2 * Math.PI); | |
| this.ctx.stroke(); | |
| } | |
| } | |
| } | |
| } | |
| animate() { | |
| this.render(); | |
| this.animationId = requestAnimationFrame(() => this.animate()); | |
| } | |
| destroy() { | |
| if (this.animationId) { | |
| cancelAnimationFrame(this.animationId); | |
| } | |
| if (this.canvas) { | |
| this.canvas.remove(); | |
| } | |
| } | |
| } | |
| // Control functions | |
| function startParticles() { | |
| if (!windField) { | |
| windField = generateWindField(); | |
| } | |
| if (particleSystem) { | |
| particleSystem.destroy(); | |
| } | |
| particleSystem = new WindParticleSystem(map, windField); | |
| console.log('๐ช๏ธ Wind particles started!'); | |
| } | |
| function resetParticles() { | |
| if (particleSystem) { | |
| particleSystem.destroy(); | |
| particleSystem = null; | |
| } | |
| windField = null; | |
| console.log('๐ Particles reset'); | |
| } | |
| function toggleSpeed() { | |
| animationSpeed = animationSpeed === 1 ? 2 : animationSpeed === 2 ? 0.5 : 1; | |
| console.log('โก Animation speed:', animationSpeed); | |
| } | |
| // Auto-start after page load | |
| setTimeout(() => { | |
| console.log('๐ Auto-starting wind particles...'); | |
| startParticles(); | |
| }, 1000); | |
| </script> | |
| </body> | |
| </html> |