From 59e9793eb1bb4b1c8354eb66cc67b55cda1e6e7c Mon Sep 17 00:00:00 2001 From: dobr Date: Mon, 17 Nov 2025 12:23:12 +0800 Subject: [PATCH] Initial commit --- .gitignore | 23 +++++ package.json | 13 +++ public/index.html | 55 +++++++++++ public/script.js | 246 ++++++++++++++++++++++++++++++++++++++++++++++ public/style.css | 114 +++++++++++++++++++++ server.js | 88 +++++++++++++++++ 6 files changed, 539 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 public/index.html create mode 100644 public/script.js create mode 100644 public/style.css create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6299354 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Зависимости +/node_modules + +# Файлы сборки +/dist +/build + +# Логи +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Файлы переменных окружения +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Файлы IDE (WebStorm) +.idea/ \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f8c44ac --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "metar-analyzer", + "version": "1.0.0", + "description": "A METAR data analysis website", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "axios": "^1.6.2", + "express": "^4.18.2" + } +} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0e8a953 --- /dev/null +++ b/public/index.html @@ -0,0 +1,55 @@ + + + + + + Анализатор METAR + + + + + +
+

Анализатор данных METAR

+ +
+ + + +
+ +

+ Источником данных является публичный архив Iowa Environmental Mesonet (IEM), поддерживаемый Университетом штата Айова. + Сервис агрегирует и предоставляет через API метеорологические сводки (METAR) с множества аэропортов по всему миру. +

+ + + + + +
+ + + + \ No newline at end of file diff --git a/public/script.js b/public/script.js new file mode 100644 index 0000000..6ddfea1 --- /dev/null +++ b/public/script.js @@ -0,0 +1,246 @@ +document.addEventListener('DOMContentLoaded', () => { + const stationInput = document.getElementById('station-input'); + const daysInput = document.getElementById('days-input'); + const fetchButton = document.getElementById('fetch-button'); + const loadingDiv = document.getElementById('loading'); + const errorDiv = document.getElementById('error'); + const resultsDiv = document.getElementById('results'); + const resultsTitle = document.getElementById('results-title'); + const analysisSummary = document.getElementById('analysis-summary'); + const rawDataContainer = document.getElementById('raw-data-container'); + + // Устанавливаем значения по умолчанию + stationInput.value = 'UIII'; // Иркутск + daysInput.value = '14'; + + const charts = {}; + + const fetchAndDrawData = async () => { + const station = stationInput.value.toUpperCase() || 'UIII'; + const days = daysInput.value || '14'; + + loadingDiv.classList.remove('hidden'); + resultsDiv.classList.add('hidden'); + errorDiv.classList.add('hidden'); + + try { + const response = await fetch(`/api/metar?station=${station}&days=${days}`); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + const apiResponse = await response.json(); + + if (!apiResponse.data || apiResponse.data.length === 0) { + throw new Error('Данные для указанной станции или периода не найдены.'); + } + + processData(apiResponse.data, station, days); + + loadingDiv.classList.add('hidden'); + resultsDiv.classList.remove('hidden'); + + } catch (e) { + loadingDiv.classList.add('hidden'); + errorDiv.textContent = `Ошибка: ${e.message}`; + errorDiv.classList.remove('hidden'); + } + }; + + const processData = (data, station, days) => { + resultsTitle.textContent = `Результаты для ${station} за последние ${days} дней`; + + const labels = []; + const temperatures = []; + const dewPoints = []; + const pressures = []; + const windSpeeds = []; + const windDirections = []; + + const fToC = (f) => f !== null ? ((f - 32) * 5 / 9).toFixed(1) : null; + const inHgToHpa = (inHg) => inHg !== null ? (inHg * 33.8639).toFixed(1) : null; + const knotsToMs = (knots) => knots !== null ? (knots * 0.514444).toFixed(1) : null; + + data.forEach(report => { + labels.push(new Date(report.valid).toLocaleString('ru-RU', { timeZone: 'UTC' })); + temperatures.push(fToC(report.tmpf)); + dewPoints.push(fToC(report.dwpf)); + pressures.push(inHgToHpa(report.alti)); + windSpeeds.push(knotsToMs(report.sknt)); + if (report.drct) { + windDirections.push(report.drct); + } + }); + + // графики + drawChart('temp-chart', 'line', { + labels, + datasets: [ + { + label: 'Температура (°C)', + data: temperatures, + borderColor: 'rgba(255, 99, 132, 1)', + backgroundColor: 'rgba(255, 99, 132, 0.2)', + fill: false, + tension: 0.1 + }, + { + label: 'Точка росы (°C)', + data: dewPoints, + borderColor: 'rgba(54, 162, 235, 1)', + backgroundColor: 'rgba(54, 162, 235, 0.2)', + fill: false, + tension: 0.1 + } + ] + }, 'Температура и точка росы'); + + drawChart('pressure-chart', 'line', { + labels, + datasets: [{ + label: 'Давление (гПа)', + data: pressures, + borderColor: 'rgba(75, 192, 192, 1)', + backgroundColor: 'rgba(75, 192, 192, 0.2)', + fill: false, + tension: 0.1 + }] + }, 'Атмосферное давление (QNH)'); + + drawChart('wind-speed-chart', 'line', { + labels, + datasets: [{ + label: 'Скорость ветра (м/с)', + data: windSpeeds, + borderColor: 'rgba(153, 102, 255, 1)', + backgroundColor: 'rgba(153, 102, 255, 0.2)', + fill: false, + tension: 0.1 + }] + }, 'Скорость ветра'); + + // анализ + performAnalysis(temperatures, windSpeeds, windDirections); + + // отображение данных тлько последние 50 + displayRawData(data.slice(-20)); + }; + + const drawChart = (canvasId, type, data, title) => { + const ctx = document.getElementById(canvasId).getContext('2d'); + + if (charts[canvasId]) { + charts[canvasId].destroy(); + } + + charts[canvasId] = new Chart(ctx, { + type: type, + data: data, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: title, + font: { size: 16 } + } + }, + scales: { + x: { + ticks: { + maxTicksLimit: 15 + } + } + } + } + }); + }; + + const performAnalysis = (temps, speeds, directions) => { + const validTemps = temps.filter(t => t !== null).map(Number); + const validSpeeds = speeds.filter(s => s !== null).map(Number); + + const maxTemp = Math.max(...validTemps); + const minTemp = Math.min(...validTemps); + const avgTemp = (validTemps.reduce((a, b) => a + b, 0) / validTemps.length).toFixed(1); + + const maxWind = Math.max(...validSpeeds); + + const getWindDirection = (deg) => { + if (deg > 337.5 || deg <= 22.5) return 'С'; + if (deg > 22.5 && deg <= 67.5) return 'СВ'; + if (deg > 67.5 && deg <= 112.5) return 'В'; + if (deg > 112.5 && deg <= 157.5) return 'ЮВ'; + if (deg > 157.5 && deg <= 202.5) return 'Ю'; + if (deg > 202.5 && deg <= 247.5) return 'ЮЗ'; + if (deg > 247.5 && deg <= 292.5) return 'З'; + if (deg > 292.5 && deg <= 337.5) return 'СЗ'; + return null; + }; + + const dirCounts = directions.map(getWindDirection) + .filter(d => d !== null) + .reduce((acc, dir) => { + acc[dir] = (acc[dir] || 0) + 1; + return acc; + }, {}); + + const prevailingDir = Object.keys(dirCounts).length > 0 ? Object.keys(dirCounts).reduce((a, b) => dirCounts[a] > dirCounts[b] ? a : b) : 'Нет данных'; + + analysisSummary.innerHTML = ` + + `; + }; + + const displayRawData = (data) => { + let tableHTML = ` + + + + + + + + + `; + data.forEach(report => { + const date = new Date(report.valid); + + const formattedDate = date.toLocaleString('ru-RU', { + timeZone: 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + tableHTML += ` + + + + + `; + }); + tableHTML += `
Время (UTC)Сводка METAR
${formattedDate}${report.metar}
`; + rawDataContainer.innerHTML = tableHTML; + }; + + + fetchButton.addEventListener('click', fetchAndDrawData); + + fetchAndDrawData(); +}); \ No newline at end of file diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..85a9c22 --- /dev/null +++ b/public/style.css @@ -0,0 +1,114 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + background-color: #f4f7f9; + color: #333; + margin: 0; + padding: 20px; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background-color: #fff; + padding: 20px 40px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +h1, h2, h3 { + color: #2c3e50; + border-bottom: 2px solid #e0e0e0; + padding-bottom: 10px; +} + +.controls { + display: flex; + gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.controls input { + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 16px; +} + +.controls button { + padding: 10px 20px; + background-color: #3498db; + color: white; + border: none; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +.controls button:hover { + background-color: #2980b9; +} + +.hidden { + display: none; +} + +#loading { + font-size: 18px; + color: #3498db; + text-align: center; + padding: 20px; +} + +#error { + color: #e74c3c; + background-color: #fbecec; + border: 1px solid #e74c3c; + padding: 15px; + border-radius: 4px; + margin-top: 20px; +} + +.charts-grid { + display: grid; + grid-template-columns: 1fr; + gap: 30px; + margin-top: 20px; +} + +@media (min-width: 900px) { + .charts-grid { + grid-template-columns: repeat(2, 1fr); + } +} + + +.chart-container { + position: relative; + height: 40vh; + min-height: 300px; +} + +#analysis-summary { + background-color: #ecf0f1; + padding: 15px; + border-radius: 5px; + line-height: 1.6; +} + +#raw-data-container table { + width: 100%; + border-collapse: collapse; + margin-top: 15px; +} + +#raw-data-container th, #raw-data-container td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} + +#raw-data-container th { + background-color: #f2f2f2; +} \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..ce39b3c --- /dev/null +++ b/server.js @@ -0,0 +1,88 @@ +const express = require('express'); +const axios = require('axios'); + +const app = express(); +const PORT = 3000; + +app.use(express.static('public')); + +// Функция для парсинга CSV-ответа от IEM API +function parseIEMData(csvData) { + const lines = csvData.trim().split('\n'); + if (lines.length < 2) { + return []; + } + + const headerLine = lines.find(line => !line.startsWith('#')); + if (!headerLine) return []; + + const headers = headerLine.split(','); + + const dataLines = lines.filter(line => !line.startsWith('#') && line !== headerLine); + + const jsonData = dataLines.map(line => { + const values = line.split(','); + const report = {}; + headers.forEach((header, index) => { + const value = values[index]; + const numValue = (value === 'M' || value === '') ? null : parseFloat(value); + if (['tmpf', 'dwpf', 'alti', 'sknt', 'drct'].includes(header)) { + report[header] = numValue; + } else { + report[header] = values[index]; + } + }); + return report; + }); + + return jsonData; +} + + +app.get('/api/metar', async (req, res) => { + const { station, days } = req.query; + + if (!station) { + return res.status(400).json({ error: 'Station code is required' }); + } + + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - parseInt(days, 10)); + + const iemApiUrl = `https://mesonet.agron.iastate.edu/cgi-bin/request/asos.py?` + + `station=${station}&` + + `data=metar&` + + `data=tmpf&` + + `data=dwpf&` + + `data=alti&` + + `data=sknt&` + + `data=drct&` + + `year1=${startDate.getUTCFullYear()}&month1=${startDate.getUTCMonth() + 1}&day1=${startDate.getUTCDate()}&` + + `year2=${endDate.getUTCFullYear()}&month2=${endDate.getUTCMonth() + 1}&day2=${endDate.getUTCDate()}&` + + `tz=Etc/UTC&` + + `format=comma&` + + `latlon=no&` + + `missing=M`; + + try { + console.log(`Fetching data from: ${iemApiUrl}`); + const response = await axios.get(iemApiUrl); + + if (!response.data || response.data.trim() === '' || response.data.includes('No data found')) { + return res.status(404).json({ error: 'No data found for the specified station or period.' }); + } + + const jsonData = parseIEMData(response.data); + + res.json({ data: jsonData }); + + } catch (error) { + console.error('Error fetching data from IEM API:', error.message); + res.status(500).json({ error: 'Failed to fetch or process data from IEM API.' }); + } +}); + +app.listen(PORT, () => { + console.log(`Server is running on http://localhost:${PORT}`); +}); \ No newline at end of file