diff --git a/01_KTO_Interactive_Map/index.html b/01_KTO_Interactive_Map/index.html index 2beaf15..e005dc8 100644 --- a/01_KTO_Interactive_Map/index.html +++ b/01_KTO_Interactive_Map/index.html @@ -8,6 +8,7 @@ + diff --git a/02_Balkans_Interactive_Map/index.html b/02_Balkans_Interactive_Map/index.html index e0dc2ae..4a3413f 100644 --- a/02_Balkans_Interactive_Map/index.html +++ b/02_Balkans_Interactive_Map/index.html @@ -8,6 +8,7 @@ + diff --git a/03_ITO_Interactive_Map/index.html b/03_ITO_Interactive_Map/index.html index cc7fb18..5938010 100644 --- a/03_ITO_Interactive_Map/index.html +++ b/03_ITO_Interactive_Map/index.html @@ -8,6 +8,7 @@ + diff --git a/04_KTO_Interactive_Map/index.html b/04_KTO_Interactive_Map/index.html index 4b8c754..42c5d0f 100644 --- a/04_KTO_Interactive_Map/index.html +++ b/04_KTO_Interactive_Map/index.html @@ -8,6 +8,7 @@ + diff --git a/common/scripts/map_actions.js b/common/scripts/map_actions.js index 8f75aa2..1508f05 100644 --- a/common/scripts/map_actions.js +++ b/common/scripts/map_actions.js @@ -26,9 +26,16 @@ const Mode = { var layer = { mission: {canvas: document.createElement("canvas"), ctx: null, used: false}, whitebrd: {canvas: document.createElement("canvas"), ctx: null, used: false}, - weather: {canvas: document.createElement("canvas"), ctx: null, used: false} + weather: {canvas: document.createElement("canvas"), ctx: null, used: false}, + wind_particles: {canvas: document.createElement("canvas"), ctx: null, used: false} } +// Wind Particles System +var windParticles = null; +var animationLoopId = null; +var lastAnimationFrameTime = 0; +var animationFrameInterval = 1000 / 60; + // Global Variables var modal; var textEntry; @@ -280,6 +287,12 @@ function changedIMCS(list) { function selectAltitude(list) { properties.settings.altitude = list.options[list.selectedIndex].value; + + // Update wind particles altitude if active + if (windParticles && layer.wind_particles.used) { + windParticles.setAltitude(properties.settings.altitude); + } + chart_changed = true; saveSettings(); refreshCanvas(); @@ -369,6 +382,7 @@ function selectSymbols(list) { // Update the selected Weather Chart Type function selectChart(list) { properties.settings.weather = parseInt(list.options[list.selectedIndex].value); + properties.settings.visibility.weather = true; chart_changed = true; saveSettings(); refreshCanvas(); @@ -1312,28 +1326,60 @@ function refreshCanvas(){ chart_changed = false; layer.weather.used = true; layer.weather.ctx.clearRect(0,0,layer.weather.canvas.width,layer.weather.canvas.height); + + // Stop wind particles when switching charts + if (windParticles && windParticles.isRunning) { + windParticles.stop(); + layer.wind_particles.used = false; + } + + // Clear wind particles canvas when not in use + if (properties.settings.weather !== 1) { + if (windParticles) { + windParticles.clear(); + } + layer.wind_particles.used = false; + } + switch (properties.settings.weather) { case 0: drawDopplerRadar(layer.weather.ctx); break; case 1: - drawWinds(layer.weather.ctx); + // Use particle system for wind visualization only + layer.weather.used = false; + layer.wind_particles.used = true; + if (windParticles) { + windParticles.setWeatherData(fmap); + windParticles.setAltitude(properties.settings.altitude); + windParticles.clear(); + windParticles.start(); + updateAnimationLoop(); // Start animation loop when wind particles are enabled + } else { + console.error('WindParticles not initialized!'); + } break; case 2: drawTemperatures(layer.weather.ctx); + updateAnimationLoop(); // Stop animation loop for other weather modes break; case 3: drawIsoBars(layer.weather.ctx); + updateAnimationLoop(); break; case 4: drawClouds(layer.weather.ctx); + updateAnimationLoop(); break; } } + + // Call updateAnimationLoop to ensure consistent state + updateAnimationLoop(); // Render the layers on the Main Canvas var img_size = 3840 * properties.zoom; @@ -1343,6 +1389,12 @@ function refreshCanvas(){ // Don't draw weather over the legend clearLegend(context); } + + // Render wind particles layer + if (layer.wind_particles.used && properties.settings.visibility.weather) { + context.drawImage(layer.wind_particles.canvas,0, 0,img_size,img_size); + clearLegend(context); + } // Draw Bullseye annotation if (properties.settings.visibility.bullseye) { @@ -1360,10 +1412,65 @@ function refreshCanvas(){ } } +// Animation loop for continuous rendering of wind particles +function animationLoop(timestamp) { + // Only continue if wind particles are active + if (layer.wind_particles.used && windParticles && windParticles.isRunning) { + if (!timestamp) timestamp = 0; + + // Throttle rendering to reduce CPU usage on large maps + if (!lastAnimationFrameTime || (timestamp - lastAnimationFrameTime) >= animationFrameInterval) { + lastAnimationFrameTime = timestamp; + // Update viewport using bounding rect to get exact canvas-space coordinates, + // regardless of zoom or CSS scaling + const _cvs = layer.wind_particles.canvas; + const _rect = _cvs.getBoundingClientRect(); + if (_rect.width > 0 && _rect.height > 0) { + const _ratioX = _cvs.width / _rect.width; + const _ratioY = _cvs.height / _rect.height; + const _visX = Math.max(0, -_rect.left) * _ratioX; + const _visY = Math.max(0, -_rect.top) * _ratioY; + const _visW = (Math.min(window.innerWidth, _rect.right) - Math.max(0, _rect.left)) * _ratioX; + const _visH = (Math.min(window.innerHeight, _rect.bottom) - Math.max(0, _rect.top)) * _ratioY; + windParticles.setViewport(_visX, _visY, _visW, _visH); + } + windParticles.animate(); + refreshCanvas(); + } + + // Continue the animation loop + animationLoopId = requestAnimationFrame(animationLoop); + } else { + // Stop animation loop if wind particles are not active + if (animationLoopId) { + cancelAnimationFrame(animationLoopId); + animationLoopId = null; + } + lastAnimationFrameTime = 0; + } +} + +// Start or stop animation loop based on wind particles state +function updateAnimationLoop() { + if (layer.wind_particles.used && windParticles && windParticles.isRunning) { + // Start animation loop if not already running + if (!animationLoopId) { + animationLoopId = requestAnimationFrame(animationLoop); + } + } else { + // Stop animation loop + if (animationLoopId) { + cancelAnimationFrame(animationLoopId); + animationLoopId = null; + } + lastAnimationFrameTime = 0; + } +} + function setupLayer(layer, width, height) { layer.canvas.width = width; layer.canvas.height = height; - layer.ctx = layer.canvas.getContext("2d"); + layer.ctx = layer.canvas.getContext("2d", { alpha: true }); } var last_zoom = 1; @@ -1372,6 +1479,56 @@ function scaleView(zoom, event) { // Add event parameter to capture mouse positi var dim_str = dimension.toString() + "px"; var scale = properties.zoom / last_zoom; + if (windParticles) { + windParticles.setZoom(properties.zoom); + } + + var scroll_element = document.scrollingElement; + var scroll_top = scroll_element.scrollTop; + var scroll_left = scroll_element.scrollLeft; + var scroll_height = scroll_element.scrollHeight; + var scroll_width = scroll_element.scrollWidth; + var client_height = scroll_element.clientHeight; + var client_width = scroll_element.clientWidth; + var scroll_left_ratio = scroll_left / scroll_width; + var scroll_top_ratio = scroll_top / scroll_height; + var offset_right = scroll_width - (scroll_left + client_width); + var offset_bottom = scroll_height - (scroll_top + client_height); + + scroll_element.scrollTop = scroll_height * scroll_top_ratio * scale; + scroll_element.scrollLeft = scroll_width * scroll_left_ratio * scale; + + var new_offset_right = scroll_element.scrollWidth * scale - (scroll_element.scrollLeft + client_width); + var new_offset_bottom = scroll_element.scrollHeight * scale - (scroll_element.scrollTop + client_height); + var diff_right = offset_right - new_offset_right; + var diff_bottom = offset_bottom - new_offset_bottom; + + scroll_element.scrollTop = scroll_element.scrollTop - (diff_bottom / 2) / 2; + scroll_element.scrollLeft = scroll_element.scrollLeft - (diff_right / 2) / 2; + + var scroll_element = document.scrollingElement; + var scroll_top = scroll_element.scrollTop; + var scroll_left = scroll_element.scrollLeft; + var scroll_height = scroll_element.scrollHeight; + var scroll_width = scroll_element.scrollWidth; + var client_height = scroll_element.clientHeight; + var client_width = scroll_element.clientWidth; + var scroll_left_ratio = scroll_left / scroll_width; + var scroll_top_ratio = scroll_top / scroll_height; + var offset_right = scroll_width - (scroll_left + client_width); + var offset_bottom = scroll_height - (scroll_top + client_height); + + setTimeout(function() { + scroll_element.scrollTop = scroll_element.scrollHeight * scroll_top_ratio; + scroll_element.scrollLeft = scroll_element.scrollWidth * scroll_left_ratio; + var new_offset_right = scroll_element.scrollWidth - (scroll_element.scrollLeft + client_width); + var new_offset_bottom = scroll_element.scrollHeight - (scroll_element.scrollTop + client_height); + var diff_right = offset_right - new_offset_right; + var diff_bottom = offset_bottom - new_offset_bottom; + scroll_element.scrollLeft = scroll_element.scrollLeft - (diff_right / 2) / 2; + scroll_element.scrollTop = scroll_element.scrollTop - (diff_bottom / 2) / 2; + }, 0); + var scroll_element = document.scrollingElement; var client_width = scroll_element.clientWidth; var client_height = scroll_element.clientHeight; @@ -1418,6 +1575,13 @@ function resetLayers() { layer.whitebrd.used = false; layer.weather.ctx.clearRect(0,0,canvas.width,canvas.height); layer.weather.used = false; + + // Reset wind particles via the WindParticles system + if (windParticles) { + windParticles.clear(); + windParticles.stop(); + } + layer.wind_particles.used = false; } // Set the default bullseye based on the bullseye defined in the image map. @@ -1483,7 +1647,7 @@ function loadSettings() { properties.settings.metric = (window.localStorage.getItem("metric") === "true"); properties.settings.altitude = Number(window.localStorage.getItem("altitude")); properties.settings.weather = Number(window.localStorage.getItem("chart")); - properties.settings.visibility.bullseye = (window.localStorage.getItem("bullseye") === "true"); + properties.settings.windStyle = window.localStorage.getItem("windStyle") || 'barbs'; properties.settings.visibility.mission = (window.localStorage.getItem("mission") === "true"); properties.settings.visibility.weather = (window.localStorage.getItem("weather") === "true"); properties.settings.visibility.whitebrd = (window.localStorage.getItem("whitebrd") === "true"); @@ -1668,6 +1832,18 @@ window.onload = function(e) { setupLayer(layer.mission, canvas.width, canvas.height ); setupLayer(layer.whitebrd, canvas.width, canvas.height ); setupLayer(layer.weather, canvas.width, canvas.height ); + + // Setup wind particles canvas (don't create context - WindParticles manages its own) + layer.wind_particles.canvas.width = canvas.width; + layer.wind_particles.canvas.height = canvas.height; + + // Initialize Wind Particles System + if (typeof WindParticles !== 'undefined') { + windParticles = new WindParticles(layer.wind_particles.canvas, fmap); + console.log('Wind Particles system initialized successfully'); + } else { + console.error('WindParticles class not found! Check if map_wind_particles.js is loaded.'); + } // Determine Map Properties map.pixels = canvas.height; diff --git a/common/scripts/map_wind_particles.js b/common/scripts/map_wind_particles.js new file mode 100644 index 0000000..448e635 --- /dev/null +++ b/common/scripts/map_wind_particles.js @@ -0,0 +1,488 @@ +// +// Wind Particle System - Windy/Leaflet Style Visualization +// Creates animated wind particles that follow the wind field +// + +class WindParticles { + constructor(canvas, weatherData) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d', { alpha: true }); + this.weatherData = weatherData; + + // Particle configuration + this.config = { + particleCount: 5000, // Active particle count after zoom adjustment + baseParticleCount: 5000, // Reference count used at zoom 1 + minParticleCount: 500, // Prevent the layer from becoming too sparse + maxParticleCount: 9000, // Cap CPU usage when zoomed out + particleCountZoomPower: 2.2, // >2 reduces particle count faster as zoom increases + particleLifetime: 90, // Frames before regeneration + particleSpeed: 0.05, // Speed multiplier + particleWidth: 2, // Thicker trail width + particleLength: 1.15, // Slightly longer trail multiplier + particleOpacity: 1, // Base opacity + particleColor: '#ffffff', // Particle color (overridden by speed) + fadeOpacity: 0.95, // Trail fade rate + minWindSpeed: 0.1, // Minimum wind speed to show particles (very low) + altitudeIndex: 0, // Which altitude layer to use (0 = surface) + velocitySmoothing: 0.92, // Higher values produce smoother directional transitions + zoomMotionCompensation: 0.8, // Reduces per-frame jump when map is visually enlarged + zoom: 1 // Current map zoom used to tune density + }; + + this.particles = []; + this.isRunning = false; + this.windField = null; // Precomputed Float32Array [u0,v0, u1,v1, ...] + this.windFieldDimX = 0; + this.windFieldDimY = 0; + this.colorCache = {}; // Cache for color strings + this.viewport = null; // {x, y, width, height} in canvas pixels, null = full canvas + + this.initParticles(); + } + + // Initialize particles with random positions + initParticles() { + this.particles = []; + for (let i = 0; i < this.config.particleCount; i++) { + this.particles.push(this.createParticle()); + } + } + + syncParticleCount() { + const targetCount = Math.max(0, Math.round(this.config.particleCount)); + const currentCount = this.particles.length; + + if (targetCount === currentCount) { + return; + } + + if (targetCount > currentCount) { + for (let i = currentCount; i < targetCount; i++) { + this.particles.push(this.createParticle()); + } + return; + } + + this.particles.length = targetCount; + } + + getParticleCountForZoom(zoomLevel) { + const safeZoom = Math.max(Number(zoomLevel) || 1, 0.01); + const zoomPower = Math.max(1, Number(this.config.particleCountZoomPower) || 2); + const zoomRatio = 1 / Math.pow(safeZoom, zoomPower); + const baseCount = this.config.baseParticleCount; + const minCount = this.config.minParticleCount; + const maxCount = this.config.maxParticleCount; + + return Math.max(minCount, Math.min(maxCount, Math.round(baseCount * zoomRatio))); + } + + setZoom(zoomLevel, forceRebuild) { + const safeZoom = Math.max(Number(zoomLevel) || 1, 0.01); + const targetCount = this.getParticleCountForZoom(safeZoom); + const zoomChanged = Math.abs((this.config.zoom || 1) - safeZoom) > 0.001; + const countChanged = targetCount !== this.config.particleCount; + + this.config.zoom = safeZoom; + this.config.particleCount = targetCount; + + if (forceRebuild) { + this.initParticles(); + return; + } + + if (zoomChanged || countChanged) { + this.syncParticleCount(); + } + } + + // Create a single particle + createParticle() { + // Spawn only within the visible viewport (with a small margin to avoid pop-in) + const margin = 50; + const vp = this.viewport; + const spawnX = vp ? vp.x - margin : 0; + const spawnY = vp ? vp.y - margin : 0; + const spawnW = vp ? vp.width + margin * 2 : this.canvas.width; + const spawnH = vp ? vp.height + margin * 2 : this.canvas.height; + + const x = spawnX + Math.random() * spawnW; + const y = spawnY + Math.random() * spawnH; + + return { + x: x, + y: y, + age: Math.random() * this.config.particleLifetime, + xt: x, + yt: y, + vx: 0, + vy: 0, + windSpeed: 0 // wind speed for coloring + }; + } + + // Precompute u/v for every grid cell into a flat Float32Array. + // Called once when weather data changes — O(dimX*dimY) instead of per-particle per-frame. + _buildWindField() { + const fmap = this.weatherData; + if (!fmap || !fmap.wind || !fmap.dimension) { + this.windField = null; + return; + } + const dimX = fmap.dimension.x; + const dimY = fmap.dimension.y; + const alt = this.config.altitudeIndex; + const buf = new Float32Array(dimX * dimY * 2); // [u, v, u, v, ...] + const PI_DIV_180 = Math.PI / 180; + + for (let gy = 0; gy < dimY; gy++) { + const row = fmap.wind[gy]; + for (let gx = 0; gx < dimX; gx++) { + const idx = (gy * dimX + gx) * 2; + const col = row && row[gx]; + const cell = col && col[alt]; + if (cell) { + const dir = cell.direction * PI_DIV_180; + const spd = cell.speed; + buf[idx] = -Math.sin(dir) * spd; // u + buf[idx + 1] = Math.cos(dir) * spd; // v + } + // else remains 0,0 + } + } + this.windField = buf; + this.windFieldDimX = dimX; + this.windFieldDimY = dimY; + } + + // Get wind vector at a specific position (kept for compatibility) + getWindAt(x, y) { + const res = { u: 0, v: 0, speed: 0 }; + if (!this.windField) return res; + const dimX = this.windFieldDimX; + const dimY = this.windFieldDimY; + const gx = Math.min(dimX - 1, Math.max(0, Math.floor(x / this.canvas.width * dimX))); + const gy = Math.min(dimY - 1, Math.max(0, Math.floor(y / this.canvas.height * dimY))); + const idx = (gy * dimX + gx) * 2; + res.u = this.windField[idx]; + res.v = this.windField[idx + 1]; + res.speed = Math.sqrt(res.u * res.u + res.v * res.v); + return res; + } + + // Bilinear interpolation directly on the precomputed Float32Array — no object allocations + getInterpolatedWind(x, y) { + if (!this.windField) return { u: 0, v: 0, speed: 0 }; + + const dimX = this.windFieldDimX; + const dimY = this.windFieldDimY; + const buf = this.windField; + + const fx = (x / this.canvas.width) * (dimX - 1); + const fy = (y / this.canvas.height) * (dimY - 1); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const x1 = x0 + 1 < dimX ? x0 + 1 : x0; + const y1 = y0 + 1 < dimY ? y0 + 1 : y0; + const sx = fx - x0; + const sy = fy - y0; + const _sx = 1 - sx; + const _sy = 1 - sy; + + const i00 = (y0 * dimX + x0) * 2; + const i10 = (y0 * dimX + x1) * 2; + const i01 = (y1 * dimX + x0) * 2; + const i11 = (y1 * dimX + x1) * 2; + + const u = _sx * _sy * buf[i00] + sx * _sy * buf[i10] + _sx * sy * buf[i01] + sx * sy * buf[i11]; + const v = _sx * _sy * buf[i00+1] + sx * _sy * buf[i10+1] + _sx * sy * buf[i01+1] + sx * sy * buf[i11+1]; + return { u, v, speed: Math.sqrt(u * u + v * v) }; + } + + // Convert wind speed to a continuous color ramp (with caching) + getColorFromWindSpeed(speed, alpha) { + // Round speed for better cache hits + const roundedSpeed = Math.round(speed); + const cacheKey = `${roundedSpeed}_${alpha.toFixed(2)}`; + + if (this.colorCache[cacheKey]) { + return this.colorCache[cacheKey]; + } + + const normalizedSpeed = Math.max(0, Math.min(1, speed / 60)); + const hue = 220 - (220 * normalizedSpeed); + const saturation = 85; + const lightness = 58; + const color = `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; + + this.colorCache[cacheKey] = color; + return color; + } + + // Update particle position + updateParticle(particle) { + // Kept for API compatibility — actual update is done inline in animate() + } + + // Advance one animation step + animate() { + if (!this.isRunning) return; + + // Hoist all per-frame constants outside the particle loop + const vp = this.viewport; + const margin = 50; + const minX = vp ? vp.x - margin : 0; + const minY = vp ? vp.y - margin : 0; + const maxX = vp ? vp.x + vp.width + margin : this.canvas.width; + const maxY = vp ? vp.y + vp.height + margin : this.canvas.height; + const lifetime = this.config.particleLifetime; + const smoothing = this.config.velocitySmoothing; + const iSmoothing = 1 - smoothing; + const scaleFactor = this.weatherData + ? this.canvas.width / Math.max(this.windFieldDimX * 12, 1) + : 0; + const zoomCompensation = this.config.zoom > 1 + ? 1 / Math.pow(this.config.zoom, this.config.zoomMotionCompensation) + : 1; + const speedMul = this.config.particleSpeed * scaleFactor * zoomCompensation; + const particles = this.particles; + const len = particles.length; + const buf = this.windField; + const dimX = this.windFieldDimX; + const dimY = this.windFieldDimY; + const cw = this.canvas.width; + const ch = this.canvas.height; + const dimX1 = dimX - 1; + const dimY1 = dimY - 1; + + for (let i = 0; i < len; i++) { + const p = particles[i]; + p.age++; + + if (p.age > lifetime || p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) { + // Inline createParticle to avoid function call + object allocation + const spawnX = vp ? vp.x - margin : 0; + const spawnY = vp ? vp.y - margin : 0; + const spawnW = vp ? vp.width + margin * 2 : cw; + const spawnH = vp ? vp.height + margin * 2 : ch; + p.x = spawnX + Math.random() * spawnW; + p.y = spawnY + Math.random() * spawnH; + p.xt = p.x; + p.yt = p.y; + p.vx = 0; + p.vy = 0; + p.age = Math.random() * lifetime; + p.windSpeed = 0; + continue; + } + + // Bilinear wind interpolation directly on Float32Array — zero allocations + let u = 0, v = 0, speed = 0; + if (buf) { + const fx = (p.x / cw) * dimX1; + const fy = (p.y / ch) * dimY1; + const x0 = fx | 0; + const y0 = fy | 0; + const x1 = x0 < dimX1 ? x0 + 1 : x0; + const y1 = y0 < dimY1 ? y0 + 1 : y0; + const sx = fx - x0; + const sy = fy - y0; + const _sx = 1 - sx; + const _sy = 1 - sy; + const i00 = (y0 * dimX + x0) * 2; + const i10 = (y0 * dimX + x1) * 2; + const i01 = (y1 * dimX + x0) * 2; + const i11 = (y1 * dimX + x1) * 2; + u = _sx * _sy * buf[i00] + sx * _sy * buf[i10] + _sx * sy * buf[i01] + sx * sy * buf[i11]; + v = _sx * _sy * buf[i00+1] + sx * _sy * buf[i10+1] + _sx * sy * buf[i01+1] + sx * sy * buf[i11+1]; + speed = Math.sqrt(u * u + v * v); + } + + p.windSpeed = speed; + p.xt = p.x; + p.yt = p.y; + + const tvx = u * speedMul; + const tvy = v * speedMul; + p.vx = p.vx * smoothing + tvx * iSmoothing; + p.vy = p.vy * smoothing + tvy * iSmoothing; + p.x += p.vx; + p.y += p.vy; + } + + this.draw(); + } + + // Draw all particles + draw() { + const vp = this.viewport; + + this.ctx.save(); + + // Clip to viewport so fade and drawing don't touch offscreen regions + if (vp) { + this.ctx.beginPath(); + this.ctx.rect(vp.x, vp.y, vp.width, vp.height); + this.ctx.clip(); + } + + // Fade effect - erodes trails without darkening background + this.ctx.globalCompositeOperation = 'destination-out'; + this.ctx.globalAlpha = 1 - this.config.fadeOpacity; + this.ctx.fillStyle = '#ffffff'; + if (vp) { + this.ctx.fillRect(vp.x, vp.y, vp.width, vp.height); + } else { + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + this.ctx.globalAlpha = 1; + this.ctx.globalCompositeOperation = 'source-over'; + + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + + // Draw particles as short rounded trail segments + const particles = this.particles; + const len = particles.length; + for (let i = 0; i < len; i++) { + const particle = particles[i]; + // Calculate opacity based on age + const ageRatio = Math.min(particle.age / 10, 1); + + if (ageRatio > 0.1) { + // Set color based on wind speed + // Set alpha based on age for smooth fade-in + const alpha = Math.min(this.config.particleOpacity, ageRatio * this.config.particleOpacity); + + this.ctx.strokeStyle = this.getColorFromWindSpeed(particle.windSpeed, alpha); + this.ctx.lineWidth = this.config.particleWidth; + + const dx = particle.x - particle.xt; + const dy = particle.y - particle.yt; + const controlX = particle.xt + dx * 0.5 + particle.vx * 0.35; + const controlY = particle.yt + dy * 0.5 + particle.vy * 0.35; + const endX = particle.xt + dx * this.config.particleLength; + const endY = particle.yt + dy * this.config.particleLength; + + this.ctx.beginPath(); + this.ctx.moveTo(particle.xt, particle.yt); + this.ctx.quadraticCurveTo(controlX, controlY, endX, endY); + this.ctx.stroke(); + } + } + + this.ctx.restore(); + } + + // Advance one animation step — the real implementation is the new animate() above + // This old stub is replaced; keeping hexToRgb below for compatibility if needed. + + // Convert hex color to RGB + hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : { r: 255, g: 255, b: 255 }; + } + + // Start animation + start() { + if (this.isRunning) return; + this.isRunning = true; + } + + // Stop animation + stop() { + this.isRunning = false; + } + + // Clear canvas + clear() { + // Fully clear the canvas with proper transparency + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + // Fill with transparent black initially for better fade effect + this.ctx.fillStyle = 'rgba(0, 0, 0, 0)'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + // Update configuration + setConfig(newConfig) { + Object.assign(this.config, newConfig); + + // Clear color cache on config changes + this.colorCache = {}; + + // Rebuild wind field if altitude changed + if (newConfig.altitudeIndex !== undefined) { + this._buildWindField(); + } + + if (newConfig.baseParticleCount !== undefined || + newConfig.minParticleCount !== undefined || + newConfig.maxParticleCount !== undefined || + newConfig.particleCountZoomPower !== undefined || + newConfig.zoomMotionCompensation !== undefined || + newConfig.zoom !== undefined) { + this.setZoom(this.config.zoom, false); + return; + } + + // Reinitialize particles if count changed + if (newConfig.particleCount && newConfig.particleCount !== this.particles.length) { + this.syncParticleCount(); + } + } + + // Update weather data + setWeatherData(weatherData) { + this.weatherData = weatherData; + this._buildWindField(); + this.initParticles(); + } + + // Resize canvas + resize(width, height) { + this.canvas.width = width; + this.canvas.height = height; + this.setZoom(this.config.zoom, true); + } + + // Update the visible viewport so particles are only rendered/spawned in the visible area + // x, y, width, height are in canvas pixel coordinates + setViewport(x, y, width, height) { + if (width <= 0 || height <= 0) return; + + const prev = this.viewport; + // Only respawn particles if viewport changed significantly (> 10px) + if (!prev || Math.abs(prev.x - x) > 10 || Math.abs(prev.y - y) > 10 || + Math.abs(prev.width - width) > 10 || Math.abs(prev.height - height) > 10) { + this.viewport = { x, y, width, height }; + // Respawn out-of-viewport particles immediately so coverage is dense + const margin = 50; + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + if (p.x < x - margin || p.x > x + width + margin || + p.y < y - margin || p.y > y + height + margin) { + Object.assign(p, this.createParticle()); + } + } + } + } + + // Update altitude level to display + setAltitude(altitudeIndex) { + const nextAltitude = Number(altitudeIndex); + this.config.altitudeIndex = Math.max(0, Math.min(9, Number.isNaN(nextAltitude) ? 0 : nextAltitude)); + this.colorCache = {}; + this._buildWindField(); // Rebuild field for new altitude layer + this.initParticles(); + } +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = WindParticles; +}