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;
+}