Initial commit

master
Иван Добринец 2025-11-17 12:23:12 +08:00
commit 59e9793eb1
6 changed files with 539 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -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/

13
package.json Normal file
View File

@ -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"
}
}

55
public/index.html Normal file
View File

@ -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>

246
public/script.js Normal file
View File

@ -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();
});

114
public/style.css Normal file
View File

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

88
server.js Normal file
View File

@ -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}`);
});