diff --git a/api/gfs.php b/api/gfs.php deleted file mode 100644 index 64c57b1..0000000 --- a/api/gfs.php +++ /dev/null @@ -1,91 +0,0 @@ - 'Failed to download GFS file', 'http_code' => $http_code]); - exit; - } -} - -// --- Convert GRIB to XYZ using gdal_translate --- -// We'll use band 1 for this example. You can use gdalinfo to see available bands. -$gdal_command = 'gdal_translate -b 1 -of XYZ ' . escapeshellarg($grib_file) . ' ' . escapeshellarg($xyz_file); -$gdal_output = shell_exec($gdal_command); - -if (!file_exists($xyz_file)) { - echo json_encode(['error' => 'Failed to convert GRIB to XYZ', 'gdal_output' => $gdal_output]); - unlink($grib_file); - exit; -} - -// --- Read the XYZ file and create GeoJSON features --- -$features = []; -$handle = fopen($xyz_file, 'r'); -$line_count = 0; -$sample_rate = 100; // Process 1 in every 100 lines - -if ($handle) { - while (($line = fgets($handle)) !== false) { - $line_count++; - if ($line_count % $sample_rate !== 0) { - continue; - } - - $parts = preg_split('/\s+/', trim($line)); - if (count($parts) === 3) { - $lon = floatval($parts[0]); - $lat = floatval($parts[1]); - $value = floatval($parts[2]); - - // Skip points that are exactly zero, often represent no data - if ($value === 0.0) { - continue; - } - - $features[] = [ - 'type' => 'Feature', - 'properties' => ['value' => $value], - 'geometry' => [ - 'type' => 'Point', - 'coordinates' => [$lon, $lat] - ] - ]; - } - } - fclose($handle); -} - -// --- Clean up temporary files --- -unlink($grib_file); -unlink($xyz_file); - -// --- Output the GeoJSON --- -echo json_encode([ - 'type' => 'FeatureCollection', - 'features' => $features -]); - -?> \ No newline at end of file diff --git a/api/wind.php b/api/wind.php new file mode 100644 index 0000000..b6e9440 --- /dev/null +++ b/api/wind.php @@ -0,0 +1,203 @@ + float, 'v' => float] + */ +function interpolatePoint($gridLat, $gridLon, &$cities, $numNearest = 4, $power = 2) { + // Find nearest cities + usort($cities, function($a, $b) use ($gridLat, $gridLon) { + $distA = haversineDistance($gridLat, $gridLon, $a['coord']['Lat'], $a['coord']['Lon']); + $distB = haversineDistance($gridLat, $gridLon, $b['coord']['Lat'], $b['coord']['Lon']); + return $distA - $distB; + }); + + $nearestCities = array_slice($cities, 0, $numNearest); + + $totalWeight = 0; + $weightedU = 0; + $weightedV = 0; + + foreach ($nearestCities as $city) { + $dist = haversineDistance($gridLat, $gridLon, $city['coord']['Lat'], $city['coord']['Lon']); + if ($dist == 0) { // If the grid point is exactly at a city, use its data directly + $windSpeed = $city['wind']['speed']; // m/s + $windDeg = $city['wind']['deg']; + $windRad = deg2rad($windDeg); + return [ + 'u' => -$windSpeed * sin($windRad), + 'v' => -$windSpeed * cos($windRad) + ]; + } + + $weight = 1 / pow($dist, $power); + + $windSpeed = $city['wind']['speed']; // m/s + $windDeg = $city['wind']['deg']; + $windRad = deg2rad($windDeg); + + // Convert to U/V components (meteorological convention) + $u = -$windSpeed * sin($windRad); + $v = -$windSpeed * cos($windRad); + + $weightedU += $u * $weight; + $weightedV += $v * $weight; + $totalWeight += $weight; + } + + if ($totalWeight == 0) return ['u' => 0, 'v' => 0]; + + return [ + 'u' => $weightedU / $totalWeight, + 'v' => $weightedV / $totalWeight + ]; +} + + +// --- Data Fetching and Processing --- + +$bulkFileName = 'weather_14.json.gz'; +$bulkUrl = "https://bulk.openweathermap.org/snapshot/{$bulkFileName}?appid=" . OWM_API_KEY; + +$gzData = @file_get_contents($bulkUrl); +if ($gzData === false) { + http_response_code(500); + echo json_encode(['error' => 'Failed to download bulk weather data. Check API key and URL.']); + exit; +} + +$jsonData = gzdecode($gzData); +if ($jsonData === false) { + http_response_code(500); + echo json_encode(['error' => 'Failed to decompress weather data.']); + exit; +} + +// The file contains JSON objects separated by newlines +$lines = explode("\n", trim($jsonData)); +$cities = []; +foreach ($lines as $line) { + if (!empty($line)) { + $cities[] = json_decode($line, true); + } +} + +if (empty($cities)) { + http_response_code(500); + echo json_encode(['error' => 'Failed to parse weather data JSON or file is empty.']); + exit; +} + +// --- Interpolation --- + +$nx = 72; // Grid points in longitude (every 5 degrees) +$ny = 37; // Grid points in latitude (every 5 degrees) +$lo1 = -180; +$la1 = 90; +$dx = 5; +$dy = 5; + +$uData = []; +$vData = []; + +for ($j = 0; $j < $ny; $j++) { + $lat = $la1 - $j * $dy; + for ($i = 0; $i < $nx; $i++) { + $lon = $lo1 + $i * $dx; + $wind = interpolatePoint($lat, $lon, $cities); + $uData[] = $wind['u']; + $vData[] = $wind['v']; + } +} + +// --- Format Data --- + +$refTime = gmdate("Y-m-d\\TH:i:s.v\\Z"); + +$formattedData = [ + [ + "header" => [ + "nx" => $nx, + "ny" => $ny, + "lo1" => $lo1, + "la1" => $la1, + "dx" => $dx, + "dy" => $dy, + "parameterCategory" => 2, + "parameterNumber" => 2, + "forecastTime" => 0, + "refTime" => $refTime, + ], + "data" => $uData + ], + [ + "header" => [ + "nx" => $nx, + "ny" => $ny, + "lo1" => $lo1, + "la1" => $la1, + "dx" => $dx, + "dy" => $dy, + "parameterCategory" => 2, + "parameterNumber" => 3, + "forecastTime" => 0, + "refTime" => $refTime, + ], + "data" => $vData + ] +]; + +// --- Caching and Output --- +$cacheDir = dirname($cacheFile); +if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0755, true); +} +file_put_contents($cacheFile, json_encode($formattedData)); + +echo json_encode($formattedData); + +?> \ No newline at end of file diff --git a/assets/css/custom.css b/assets/css/custom.css index 1d9343c..a27b749 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -135,4 +135,16 @@ html, body { display: block; margin-bottom: 5px; cursor: pointer; +} + +.control-group input[type="range"] { + width: 100%; + margin-top: 5px; +} + +.control-group #windAltitudeLabel { + display: block; + text-align: center; + margin-top: 5px; + font-size: 14px; } \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index b3a1ded..5708527 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -60,6 +60,27 @@ function initializeGlobe() { weatherAlertsDataSource.show = this.checked; }); + // Wind Layer + const windLayer = new WindLayer(viewer, { + particleHeight: 10000, + particleCount: 10000, + maxAge: 120, + particleSpeed: 5 + }); + + const windCheckbox = document.getElementById('windLayerCheckbox'); + windCheckbox.addEventListener('change', function() { + windLayer.setVisible(this.checked); + }); + + const windAltitudeSlider = document.getElementById('windAltitudeSlider'); + const windAltitudeLabel = document.getElementById('windAltitudeLabel'); + windAltitudeSlider.addEventListener('input', function() { + const altitude = parseInt(this.value, 10); + windAltitudeLabel.textContent = `${altitude} m`; + windLayer.setOptions({ particleHeight: altitude }); + }); + // Function to load wildfire data const loadWildfireData = async () => { try { diff --git a/assets/js/wind.js b/assets/js/wind.js new file mode 100644 index 0000000..43123f6 --- /dev/null +++ b/assets/js/wind.js @@ -0,0 +1,224 @@ + +/* + * Original code from https://github.com/RaymanNg/3D-Wind-Field + * under the MIT license. + * + * MIT License + * + * Copyright (c) 2019 Rayman Ng + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +class WindLayer { + constructor(viewer, options = {}) { + this.viewer = viewer; + this.scene = viewer.scene; + this.camera = viewer.camera; + this.ellipsoid = viewer.scene.globe.ellipsoid; + this.options = options; + this.windData = null; + this.primitive = null; + this.visible = true; + + this.init(); + } + + async init() { + await this.loadWindData(); + if (this.windData) { + this.particleSystem = new ParticleSystem(this.scene, { + windData: this.windData, + ...this.options.particleSystem + }); + this.primitive = this.particleSystem.primitive; + this.scene.primitives.add(this.primitive); + } + } + + async loadWindData() { + try { + const response = await fetch(this.options.windDataUrl || 'api/wind.php'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + this.windData = this.processWindData(data); + console.log('Wind data loaded and processed.'); + } catch (error) { + console.error('Error loading or processing wind data:', error); + } + } + + processWindData(data) { + let uComponent = null, vComponent = null; + data.forEach(record => { + const type = record.header.parameterCategory + ',' + record.header.parameterNumber; + if (type === '2,2') uComponent = record; + if (type === '2,3') vComponent = record; + }); + + if (!uComponent || !vComponent) { + console.error("Wind data components not found."); + return null; + } + + const header = uComponent.header; + const windData = { + nx: header.nx, + ny: header.ny, + lo1: header.lo1, + la1: header.la1, + dx: header.dx, + dy: header.dy, + u: uComponent.data, + v: vComponent.data + }; + return windData; + } + + setVisible(visible) { + this.visible = visible; + if (this.primitive) { + this.primitive.show = visible; + } + } + + setOptions(options) { + if (this.particleSystem) { + this.particleSystem.applyOptions(options); + } + } +} + +class ParticleSystem { + constructor(scene, options) { + this.scene = scene; + this.options = options; + this.windData = options.windData; + this.particles = []; + this.primitive = null; + + this.createParticles(); + this.createPrimitive(); + + this.scene.preRender.addEventListener(this.update, this); + } + + createParticles() { + const options = this.options; + const particleCount = options.particleCount || 10000; + for (let i = 0; i < particleCount; i++) { + this.particles.push(this.createParticle()); + } + } + + createParticle() { + const lon = Math.random() * 360 - 180; + const lat = Math.random() * 180 - 90; + const altitude = this.options.particleHeight || 10000; + const position = Cesium.Cartesian3.fromDegrees(lon, lat, altitude); + + return { + position: position, + age: Math.floor(Math.random() * (this.options.maxAge || 120)), + maxAge: this.options.maxAge || 120, + speed: Math.random() * (this.options.particleSpeed || 5) + }; + } + + getWind(position) { + const cartographic = Cesium.Cartographic.fromCartesian(position); + const lon = Cesium.Math.toDegrees(cartographic.longitude); + const lat = Cesium.Math.toDegrees(cartographic.latitude); + + const { nx, ny, lo1, la1, dx, dy, u, v } = this.windData; + + const i = Math.floor((lon - lo1) / dx); + const j = Math.floor((la1 - lat) / dy); + + if (i >= 0 && i < nx && j >= 0 && j < ny) { + const index = j * nx + i; + return { u: u[index], v: v[index] }; + } + return { u: 0, v: 0 }; + } + + update() { + if (!this.primitive.show) return; + + this.particles.forEach(particle => { + if (particle.age >= particle.maxAge) { + Object.assign(particle, this.createParticle()); + } + + const wind = this.getWind(particle.position); + const speed = particle.speed; + + const metersPerDegree = 111320; + const vx = wind.u * speed / metersPerDegree; + const vy = wind.v * speed / metersPerDegree; + + const cartographic = Cesium.Cartographic.fromCartesian(particle.position); + cartographic.longitude += Cesium.Math.toRadians(vx); + cartographic.latitude += Cesium.Math.toRadians(vy); + + particle.position = Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + cartographic.height + ); + particle.age++; + }); + + this.updatePrimitive(); + } + + updatePrimitive() { + const instances = this.particles.map(particle => { + return new Cesium.GeometryInstance({ + geometry: new Cesium.SimplePolylineGeometry({ + positions: [particle.position, particle.position] // Simplified for a dot + }), + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + Cesium.Color.WHITE.withAlpha(particle.age / particle.maxAge) + ) + } + }); + }); + + if (this.primitive) { + this.scene.primitives.remove(this.primitive); + } + + this.primitive = new Cesium.Primitive({ + geometryInstances: instances, + appearance: new Cesium.PolylineColorAppearance(), + asynchronous: false + }); + + this.scene.primitives.add(this.primitive); + } + + applyOptions(options) { + this.options = Object.assign(this.options, options); + // Re-create particles or update properties as needed + } +} diff --git a/index.php b/index.php index d375c25..2bc5ff2 100644 --- a/index.php +++ b/index.php @@ -25,9 +25,18 @@ + + +
+

Wind Altitude

+ + 10000 m
+ \ No newline at end of file