Initial commit
commit
59e9793eb1
|
|
@ -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/
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Анализатор METAR</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>Анализатор данных METAR</h1>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" id="station-input" placeholder="Код аэропорта (ICAO)">
|
||||||
|
<input type="number" id="days-input" placeholder="Глубина (дни)">
|
||||||
|
<button id="fetch-button">Получить и проанализировать</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Источником данных является публичный архив Iowa Environmental Mesonet (IEM), поддерживаемый Университетом штата Айова.
|
||||||
|
Сервис агрегирует и предоставляет через API метеорологические сводки (METAR) с множества аэропортов по всему миру.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="loading" class="hidden">Загрузка данных...</div>
|
||||||
|
<div id="error" class="hidden"></div>
|
||||||
|
|
||||||
|
<div id="results" class="hidden">
|
||||||
|
<h2 id="results-title"></h2>
|
||||||
|
|
||||||
|
<h3>Сводный анализ</h3>
|
||||||
|
<div id="analysis-summary"></div>
|
||||||
|
|
||||||
|
<h3>Графики</h3>
|
||||||
|
<div class="charts-grid">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="temp-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="pressure-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="wind-speed-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Исходные данные (последние 20 сводок)</h3>
|
||||||
|
<div id="raw-data-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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 = `
|
||||||
|
<ul>
|
||||||
|
<li><strong>Температура:</strong></li>
|
||||||
|
<ul>
|
||||||
|
<li>Максимальная: <strong>${maxTemp}°C</strong></li>
|
||||||
|
<li>Минимальная: <strong>${minTemp}°C</strong></li>
|
||||||
|
<li>Средняя: <strong>${avgTemp}°C</strong></li>
|
||||||
|
</ul>
|
||||||
|
<li><strong>Ветер:</strong></li>
|
||||||
|
<ul>
|
||||||
|
<li>Максимальная скорость: <strong>${maxWind} м/с</strong></li>
|
||||||
|
<li>Преобладающее направление: <strong>${prevailingDir}</strong></li>
|
||||||
|
</ul>
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayRawData = (data) => {
|
||||||
|
let tableHTML = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время (UTC)</th>
|
||||||
|
<th>Сводка METAR</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
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 += `
|
||||||
|
<tr>
|
||||||
|
<td>${formattedDate}</td>
|
||||||
|
<td><code>${report.metar}</code></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
tableHTML += `</tbody></table>`;
|
||||||
|
rawDataContainer.innerHTML = tableHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
fetchButton.addEventListener('click', fetchAndDrawData);
|
||||||
|
|
||||||
|
fetchAndDrawData();
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue