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