<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Домашняя метеостанция</title>
<style>
:root {
--bg: #0f172a;
--bg-grad-top: #1e293b;
--title: #fbbf24;
--text: #e2e8f0;
--accent: #7dd3fc;
--card-bg: #1e293b;
--card-bg-solid: #1e293b;
--error: #f87171;
--divider: #334155;
--day-header: #fb923c;
--muted: #64748b;
--temp-color: #f87171;
--hum-color: #38bdf8;
--press-color: #a78bfa;
--wind-color: #4ade80;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px 16px 60px;
background: radial-gradient(ellipse at 30% 0%, var(--bg-grad-top) 0%, var(--bg) 65%);
color: var(--text);
font-family: "Segoe UI", "DejaVu Sans", Arial, sans-serif;
min-height: 100vh;
}
.wrap { max-width: 900px; margin: 0 auto; }
header { text-align: center; margin-bottom: 24px; }
header h1 {
color: var(--title);
font-size: 2rem;
margin: 0 0 8px;
letter-spacing: 0.5px;
}
header .updated { color: var(--text); font-size: 0.85rem; opacity: 0.85; }
.cards-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.card {
background: var(--card-bg-solid);
border-radius: 15px;
padding: 12px 16px 16px;
position: relative;
overflow: hidden;
border-left: 8px solid transparent;
}
.card.temp { border-left-color: var(--temp-color); }
.card.hum { border-left-color: var(--hum-color); }
.card.press { border-left-color: var(--press-color); }
.card .label { font-size: 0.85rem; color: var(--text); opacity: 0.9; }
.card .value {
font-size: 1.9rem;
font-weight: bold;
text-align: center;
margin-top: 8px;
}
.card.temp .value { color: var(--temp-color); }
.card.hum .value { color: var(--hum-color); }
.card.press .value { color: var(--press-color); }
.no-data {
text-align: center;
color: var(--error);
padding: 16px;
font-size: 0.9rem;
}
.divider {
border: none;
border-top: 2px solid var(--divider);
margin: 20px 40px;
}
.forecast-title {
text-align: center;
color: var(--title);
font-size: 1.1rem;
margin: 8px 0 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
/* 🌙 Стили для блока луны */
.moon-info {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(251, 191, 36, 0.15);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85rem;
color: var(--title);
cursor: help;
}
.moon-icon {
font-size: 1.2em;
line-height: 1;
}
.moon-percent {
font-weight: 600;
}
.moon-name {
font-size: 0.8rem;
color: var(--title);
opacity: 0.9;
font-style: italic;
max-width: 160px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.forecast-time {
text-align: center;
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 12px;
}
.forecast-time.stale { color: var(--error); }
.forecast-days {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.day-col {
background: var(--card-bg);
border-radius: 10px;
padding: 8px 6px 10px;
}
.day-col h3 {
text-align: center;
color: var(--day-header);
font-size: 0.9rem;
margin: 0 0 8px;
font-weight: 600;
}
.day-col table {
width: auto;
border-collapse: collapse;
font-size: 0.75rem;
line-height: 1.2;
color: var(--accent);
}
.day-col td {
padding: 0 3px;
white-space: nowrap;
}
.day-col td.icon { width: 22px; text-align: center; font-size: 0.9rem; }
.day-col td.time { text-align: left; color: var(--muted); }
.day-col td.temp { text-align: right; color: var(--temp-color); font-weight: 600; }
.day-col td.hum { text-align: right; }
.day-col td.wind { text-align: right; color: var(--wind-color); }
.day-col td.desc {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
max-width: none;
color: var(--muted);
font-size: 0.7rem;
padding-top: 0;
}
.history {
margin-top: 24px;
}
.history h2 {
text-align: center;
color: var(--title);
font-size: 1rem;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.history h2 .badge {
background: var(--card-bg-solid);
border: 1px solid var(--divider);
border-radius: 12px;
padding: 2px 10px;
font-size: 0.75rem;
color: var(--accent);
font-weight: normal;
}
.history .row {
text-align: center;
font-size: 0.85rem;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.history .row:last-child {
border-bottom: none;
}
.history .val-time {
color: var(--muted);
font-family: monospace;
margin-right: 6px;
}
.history .val-temp {
color: var(--temp-color);
font-weight: bold;
margin-right: 6px;
}
.history .val-hum {
color: var(--hum-color);
font-weight: bold;
margin-right: 6px;
}
.history .val-press {
color: var(--press-color);
font-weight: bold;
}
.links {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 24px;
}
.links a {
display: inline-block;
padding: 8px 18px;
border-radius: 10px;
background: var(--card-bg-solid);
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
border: 1px solid var(--divider);
transition: background 0.2s, color 0.2s;
}
.links a:hover {
background: var(--divider);
color: var(--title);
}
footer {
text-align: center;
color: var(--muted);
font-size: 0.7rem;
margin-top: 30px;
}
.loading { text-align: center; opacity: 0.7; padding: 20px; font-size: 0.85rem; }
@media (max-width: 560px) {
.forecast-days { grid-template-columns: 1fr; }
header h1 { font-size: 1.5rem; }
.day-col td { font-size: 0.7rem; }
.history .row { font-size: 0.8rem; }
.moon-info { font-size: 0.75rem; padding: 3px 8px; }
.moon-icon { font-size: 1em; }
.moon-name { max-width: 110px; font-size: 0.7rem; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Домашняя метеостанция</h1>
<div class="updated" id="updated">Обновлено: —</div>
</header>
<div id="conditions"><div class="loading">Загрузка показаний…</div></div>
<hr class="divider">
<div id="forecast"><div class="loading">Загрузка прогноза…</div></div>
<div class="history" id="history">
<div class="loading" style="padding: 10px;">Загрузка настроек истории…</div>
</div>
<div class="links">
<a href="/info" target="_blank">⚙️ Настройки</a>
<a href="/files" target="_blank">📁 Файлы на SD</a>
<a href="/archive" target="_blank"> 📚 Архив</a>
<a href="/index_web" target="_blank"> ☢ WEB </a>
<a href="/charts" target="_blank"> 📊 График</a>
</div>
<footer id="footer"></footer>
</div>
<script>
// ================================
// КОНФИГУРАЦИЯ
// ================================
const CONFIG = {
meteoUrl: "/data",
forecastUrl: "https://api.open-meteo.com/v1/forecast?latitude=42.32&longitude=69.60¤t_weather=true&hourly=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=Asia/Almaty&forecast_days=3",
refreshMinutes: 5,
cacheKey: "meteo_forecast_cache",
historyCacheKey: "meteo_history_settings_cache"
};
const WEATHER_INFO = {
0: { desc: "Ясно", icon: "☀️" },
1: { desc: "Преим. ясно", icon: "🌤️" },
2: { desc: "Малооблачно", icon: "🌤️" },
3: { desc: "Пасмурно", icon: "☁️" },
45: { desc: "Туман", icon: "🌫️" },
48: { desc: "Иней", icon: "🌫️" },
51: { desc: "Слабая морось", icon: "🌦️" },
53: { desc: "Морось", icon: "🌦️" },
55: { desc: "Сильная морось", icon: "🌧️" },
61: { desc: "Слабый дождь", icon: "🌦️" },
63: { desc: "Дождь", icon: "🌧️" },
65: { desc: "Сильный дождь", icon: "🌧️" },
71: { desc: "Слабый снег", icon: "🌨️" },
73: { desc: "Снег", icon: "🌨️" },
75: { desc: "Сильный снег", icon: "🌨️" },
80: { desc: "Ливень", icon: "🌦️" },
81: { desc: "Сильный ливень", icon: "🌧️" },
82: { desc: "Очень сильный ливень", icon: "🌧️" },
95: { desc: "Гроза", icon: "⛈️" },
96: { desc: "Гроза с градом", icon: "⛈️" },
99: { desc: "Сильная гроза с градом", icon: "⛈️" },
};
// 🌙 Фазы луны — с названиями и иконками
const MOON_PHASES = [
{ name: "Новолуние", icon: "🌑" },
{ name: "Молодой месяц", icon: "🌒" },
{ name: "Первая четверть", icon: "🌓" },
{ name: "Растущая луна", icon: "🌔" },
{ name: "Полнолуние", icon: "🌕" },
{ name: "Убывающая луна", icon: "🌖" },
{ name: "Последняя четверть", icon: "🌗" },
{ name: "Старая луна", icon: "🌘" }
];
function weatherInfo(code) {
return WEATHER_INFO[code] || { desc: "Неизвестно", icon: "❓" };
}
function fmtTemp(t) {
const v = Math.round(t);
return (v >= 0 ? "+" : "") + v;
}
// 🌙 Расчёт фазы луны и освещённости
function getMoonPhase(date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate() + date.getHours() / 24 + date.getMinutes() / 1440;
let a = Math.floor((14 - month) / 12);
let y = year + 4800 - a;
let m = month + 12 * a - 3;
let jd = day + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045;
const knownNewMoon = 2451550.1;
const synodicMonth = 29.530588853;
let age = (jd - knownNewMoon) % synodicMonth;
if (age < 0) age += synodicMonth;
let phase = Math.floor((age / synodicMonth) * 8);
if (phase >= 8) phase = 0;
let illumination = Math.round((1 - Math.cos((age / synodicMonth) * 2 * Math.PI)) / 2 * 100);
// Возраст луны в днях (для tooltip)
let ageDays = Math.round(age * 10) / 10;
return {
phase: phase,
phaseName: MOON_PHASES[phase].name,
phaseIcon: MOON_PHASES[phase].icon,
illumination: illumination,
ageDays: ageDays
};
}
// ================================
// ЗАГРУЗКА ТЕКУЩИХ ПОКАЗАНИЙ
// ================================
async function loadConditions() {
const el = document.getElementById("conditions");
try {
const res = await fetch(CONFIG.meteoUrl, { cache: "no-store" });
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
renderConditions(data);
renderHistory(data);
renderFooter(data);
if (data.historyTitle) {
localStorage.setItem(CONFIG.historyCacheKey, data.historyTitle);
}
document.getElementById("updated").textContent =
"Обновлено: " + new Date().toLocaleString("ru-RU");
return data;
} catch (e) {
el.innerHTML = `
<div class="no-data">
⚠️ Нет данных от локальной метеостанции<br>
<span style="font-size:0.7rem; opacity:0.8;">${e.message} — открой консоль (F12) для подробностей</span>
</div>`;
console.error("Ошибка загрузки meteoUrl:", CONFIG.meteoUrl, e);
renderFooter(null);
loadHistoryFromCache();
return null;
}
}
function renderConditions(data) {
const el = document.getElementById("conditions");
el.innerHTML = `
<div class="cards-row">
<div class="card temp">
<div class="label"> 🌡️ Температура</div>
<div class="value">${(data.temperature ?? 0).toFixed(1)}°C</div>
</div>
<div class="card hum">
<div class="label"> 💧 Влажность</div>
<div class="value">${(data.humidity ?? 0).toFixed(1)}%</div>
</div>
</div>
<div class="cards-row" style="grid-template-columns: 1fr;">
<div class="card press">
<div class="label">Атмосферное давление</div>
<div class="value">${(data.pressure ?? 0).toFixed(1)} мм рт.ст.</div>
</div>
</div>
`;
}
// ================================
// ИСТОРИЯ С ЦВЕТАМИ
// ================================
function renderHistory(data) {
const el = document.getElementById("history");
const title = data?.historyTitle || localStorage.getItem(CONFIG.historyCacheKey) || "🕒 История";
const titleMatch = title.match(/шаг\s+(\d+)\s+мин,\s+(\d+)\s+записей/);
let badgeHtml = "";
if (titleMatch) {
const interval = titleMatch[1];
const slots = titleMatch[2];
badgeHtml = `<span class="badge">шаг ${interval} мин, ${slots} записей</span>`;
}
if (!data || !data.history || !data.history.length) {
el.innerHTML = `
<h2>🕒 История ${badgeHtml}</h2>
<div class="row" style="color: var(--muted); opacity: 0.7;">Нет записей в истории</div>
`;
return;
}
const rows = data.history.slice(-20).reverse().map(rec => `
<div class="row">
<span class="val-time">${rec.time ?? "--:--"}</span>
<span class="val-temp">${(rec.temp ?? 0).toFixed(1)}°</span>
<span class="val-hum">${(rec.hum ?? 0).toFixed(1)}%</span>
<span class="val-press">${(rec.press ?? 0).toFixed(1)} мм</span>
</div>
`).join("");
el.innerHTML = `<h2>🕒 История ${badgeHtml}</h2>${rows}`;
}
function loadHistoryFromCache() {
const el = document.getElementById("history");
const cachedTitle = localStorage.getItem(CONFIG.historyCacheKey);
if (cachedTitle) {
const titleMatch = cachedTitle.match(/шаг\s+(\d+)\s+мин,\s+(\d+)\s+записей/);
let badgeHtml = "";
if (titleMatch) {
const interval = titleMatch[1];
const slots = titleMatch[2];
badgeHtml = `<span class="badge">шаг ${interval} мин, ${slots} записей</span>`;
}
el.innerHTML = `
<h2>🕒 История ${badgeHtml}</h2>
<div class="row" style="color: var(--muted); opacity: 0.7;">Данные истории недоступны (кэш)</div>
`;
}
}
function renderFooter(data) {
const el = document.getElementById("footer");
const interval = data && data.refreshInterval ? data.refreshInterval : "N/A";
el.textContent = `Источник: ${CONFIG.meteoUrl} | Интервал: ${interval} сек`;
}
// ================================
// ЗАГРУЗКА ПРОГНОЗА
// ================================
async function loadForecast() {
const el = document.getElementById("forecast");
const now = new Date();
const moonInfo = getMoonPhase(now);
console.log("🌙 Moon info:", moonInfo);
try {
const res = await fetch(CONFIG.forecastUrl, { cache: "no-store" });
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
const byDay = groupForecast(data, now);
const fetchTime = now.toLocaleString("ru-RU");
localStorage.setItem(CONFIG.cacheKey, JSON.stringify({
fetchTime,
currentWeather: data.current_weather,
byDay: serializeDays(byDay),
moonInfo: moonInfo
}));
renderForecast(byDay, data.current_weather, fetchTime, false, moonInfo);
} catch (e) {
const cached = localStorage.getItem(CONFIG.cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
const ageHours = (now - new Date(parsed.fetchTime)) / 3600000;
const stale = isNaN(ageHours) ? false : ageHours > 6;
renderForecast(deserializeDays(parsed.byDay), parsed.currentWeather, parsed.fetchTime, stale, parsed.moonInfo || moonInfo);
} else {
el.innerHTML = `<div class="no-data">⚠️ Прогноз недоступен, кэш отсутствует</div>`;
}
}
}
function groupForecast(data, now) {
const byDay = {};
const times = data.hourly.time;
const temps = data.hourly.temperature_2m;
const hums = data.hourly.relative_humidity_2m;
const codes = data.hourly.weather_code;
const winds = data.hourly.wind_speed_10m;
for (let i = 0; i < times.length; i++) {
const dt = new Date(times[i]);
if (dt.getHours() % 3 !== 0) continue;
if (dt.toDateString() === now.toDateString() && dt.getHours() < now.getHours()) continue;
const dayKey = dt.toISOString().slice(0, 10);
const timeStr = dt.toTimeString().slice(0, 5);
const code = codes[i];
const info = weatherInfo(code);
if (!byDay[dayKey]) byDay[dayKey] = [];
if (byDay[dayKey].length >= 8) continue;
byDay[dayKey].push({
time: timeStr,
temp: fmtTemp(temps[i]),
hum: Math.round(hums[i]),
wind: winds[i] !== undefined ? Number(winds[i]).toFixed(1) : "—",
desc: info.desc,
icon: info.icon
});
}
return byDay;
}
function serializeDays(byDay) { return byDay; }
function deserializeDays(byDay) { return byDay; }
function renderForecast(byDay, currentWeather, fetchTime, stale, moonInfo) {
const el = document.getElementById("forecast");
const sortedDays = Object.keys(byDay).sort();
if (!sortedDays.length) {
el.innerHTML = `<div class="no-data">⚠️ Нет данных прогноза</div>`;
return;
}
const dayNames = ["Сегодня", "Завтра", "Послезавтра"];
let headerIcon = "🌦️";
if (currentWeather) {
headerIcon = weatherInfo(currentWeather.weathercode).icon;
}
// 🌙 Формируем HTML для луны — теперь с названием фазы!
let moonHtml = "";
if (moonInfo) {
const tooltip = `${moonInfo.phaseName}\nОсвещённость: ${moonInfo.illumination}%\nВозраст луны: ${moonInfo.ageDays} дн.`;
moonHtml = `
<div class="moon-info" title="${tooltip}">
<span class="moon-icon">${moonInfo.phaseIcon}</span>
<span class="moon-name">${moonInfo.phaseName}</span>
<span class="moon-percent">${moonInfo.illumination}%</span>
</div>
`;
}
let cols = sortedDays.slice(0, 3).map((day, idx) => {
const dateObj = new Date(day + "T00:00:00");
const dateLabel = dateObj.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
let rows = "";
for (const r of byDay[day]) {
rows += `
<tr>
<td class="icon">${r.icon}</td>
<td class="time">${r.time}</td>
<td class="temp">${r.temp}°</td>
<td class="hum">${r.hum}%</td>
<td class="wind">💨 ${r.wind}</td>
<td class="desc">${r.desc}</td>
</tr>
`;
}
return `
<div class="day-col">
<h3>${dayNames[idx]} (${dateLabel})</h3>
<table>${rows}</table>
</div>
`;
}).join("");
el.innerHTML = `
<div class="forecast-title">
<span style="font-size: 1.3em; line-height: 1;">${headerIcon}</span>
<span>Погода в Шымкенте</span>
${moonHtml}
</div>
<div class="forecast-time ${stale ? "stale" : ""}">
Данные на: ${fetchTime}${stale ? " (устарело!)" : ""}
</div>
<div class="forecast-days">${cols}</div>
`;
}
// ================================
// ЗАПУСК И АВТООБНОВЛЕНИЕ
// ================================
function refreshAll() {
loadConditions();
loadForecast();
}
(function initHistoryTitle() {
const cached = localStorage.getItem(CONFIG.historyCacheKey);
if (cached) {
loadHistoryFromCache();
}
})();
refreshAll();
setInterval(refreshAll, CONFIG.refreshMinutes * 60 * 1000);
</script>
</body>
</html>