📄 web/charts.html

⬅️ Назад ⬇️ Скачать
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>🌤 Метеостанция — Графики</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');

:root {
--bg: #0b1622;
--surface: #121f30;
--card: #172436;
--border: #1e3050;
--sky: #4da6ff;
--sky-dim: rgba(77,166,255,0.12);
--amber: #f5a623;
--amber-dim:rgba(245,166,35,0.12);
--teal: #34d9c3;
--teal-dim: rgba(52,217,195,0.10);
--muted: #4a6580;
--text: #c8daf0;
--text-dim: #5a7a9a;
--red: #ff5f6d;
--green: #43e97b;
--mono: 'JetBrains Mono', monospace;
--sans: 'Inter', sans-serif;
--r: 12px;
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

html { scroll-behavior: smooth; }

body {
font-family: var(--sans);
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}

/* ── Atmospheric header ── */
.header {
position: relative;
padding: 32px 24px 0;
text-align: center;
overflow: hidden;
}

.header-sky {
position: absolute; inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(30,60,120,0.55) 0%, transparent 70%);
pointer-events: none;
}

/* Subtle star field */
.stars {
position: absolute; inset: 0;
background-image:
radial-gradient(1px 1px at 10% 20%, rgba(255,255,255,0.4) 0%, transparent 100%),
radial-gradient(1px 1px at 30% 10%, rgba(255,255,255,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 55% 25%, rgba(255,255,255,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 80% 8%, rgba(255,255,255,0.35) 0%, transparent 100%),
radial-gradient(1px 1px at 92% 30%, rgba(255,255,255,0.4) 0%, transparent 100%),
radial-gradient(1px 1px at 68% 15%, rgba(255,255,255,0.25) 0%, transparent 100%),
radial-gradient(1px 1px at 42% 5%, rgba(255,255,255,0.45) 0%, transparent 100%),
radial-gradient(1px 1px at 20% 35%, rgba(255,255,255,0.3) 0%, transparent 100%);
pointer-events: none;
animation: twinkle 4s ease-in-out infinite alternate;
}
@keyframes twinkle {
0% { opacity: 0.6; }
100% { opacity: 1; }
}

.header-inner {
position: relative;
z-index: 1;
}

.station-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--sky);
opacity: 0.7;
margin-bottom: 8px;
}

h1 {
font-size: clamp(1.5rem, 4vw, 2.4rem);
font-weight: 700;
letter-spacing: -0.5px;
line-height: 1.1;
color: #e8f4ff;
}

h1 span { color: var(--sky); }

.subtitle {
margin-top: 8px;
font-size: 13px;
color: var(--text-dim);
}

/* ── Live badge ── */
.live-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 14px;
padding: 5px 12px;
background: rgba(67,233,123,0.08);
border: 1px solid rgba(67,233,123,0.2);
border-radius: 99px;
font-size: 11px;
color: var(--green);
font-weight: 500;
}
.live-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--green);
animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}

/* ── Now cards ── */
.now-row {
display: flex;
gap: 12px;
padding: 24px;
justify-content: center;
flex-wrap: wrap;
}

.now-card {
flex: 1 1 130px;
max-width: 170px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 16px 18px;
position: relative;
overflow: hidden;
transition: transform 0.2s, border-color 0.2s;
}
.now-card:hover { transform: translateY(-2px); }

.now-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
}
.now-card.temp::before { background: linear-gradient(90deg, var(--sky), transparent); }
.now-card.hum::before { background: linear-gradient(90deg, var(--teal), transparent); }
.now-card.press::before { background: linear-gradient(90deg, var(--amber), transparent); }

.now-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 6px;
}
.now-val {
font-family: var(--mono);
font-size: 2rem;
font-weight: 500;
line-height: 1;
}
.now-card.temp .now-val { color: var(--sky); }
.now-card.hum .now-val { color: var(--teal); }
.now-card.press .now-val { color: var(--amber); }

.now-unit {
font-size: 11px;
color: var(--text-dim);
margin-top: 3px;
}

/* ── Layout ── */
.main {
max-width: 1100px;
margin: 0 auto;
padding: 0 16px 60px;
}

/* ── Section title ── */
.section-head {
display: flex;
align-items: baseline;
gap: 10px;
margin: 32px 0 14px;
}
.section-head h2 {
font-size: 14px;
font-weight: 600;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-dim);
}
.section-head hr {
flex: 1;
border: none;
border-top: 1px solid var(--border);
}

/* ── Chart card ── */
.chart-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 20px 20px 12px;
margin-bottom: 16px;
position: relative;
}

.chart-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.chart-title .icon { font-size: 14px; }

svg.chart {
width: 100%;
height: 180px;
overflow: visible;
display: block;
}

/* ── Animated path ── */
.chart-path {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke-dashoffset 1.2s cubic-bezier(0.4,0,0.2,1);
}

.chart-area {
opacity: 0.12;
}

/* ── Stats bar ── */
.stats-bar {
display: flex;
gap: 18px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.stat-item { font-size: 11px; }
.stat-lbl { color: var(--text-dim); }
.stat-val { font-family: var(--mono); font-weight: 500; color: var(--text); }

/* ── Period stats ── */
.period-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 16px;
}

.period-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 16px;
}

.period-head {
font-size: 11px;
font-weight: 600;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 12px;
}

.period-metrics {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}

.p-metric { text-align: center; }
.p-lbl {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.p-num {
font-family: var(--mono);
font-size: 1.1rem;
font-weight: 500;
display: block;
margin-top: 2px;
}
.p-metric.t .p-num { color: var(--sky); }
.p-metric.h .p-num { color: var(--teal); }
.p-metric.pr .p-num { color: var(--amber); }

.p-minmax {
font-size: 10px;
color: var(--text-dim);
margin-top: 2px;
font-family: var(--mono);
}

/* ── Status bar ── */
.status-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 0 24px 20px;
justify-content: center;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 99px;
font-size: 11px;
font-weight: 500;
border: 1px solid transparent;
}
.status-pill.ok { background: rgba(67,233,123,0.07); border-color: rgba(67,233,123,0.2); color: var(--green); }
.status-pill.err { background: rgba(255,95,109,0.07); border-color: rgba(255,95,109,0.2); color: var(--red); }
.status-pill.info{ background: rgba(77,166,255,0.07); border-color: rgba(77,166,255,0.2); color: var(--sky); }

/* ── Nav ── */
.nav {
display: flex;
gap: 8px;
justify-content: center;
padding: 0 16px 24px;
flex-wrap: wrap;
}
.nav a {
display: inline-block;
padding: 7px 16px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
text-decoration: none;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.nav a:hover {
background: var(--card);
border-color: var(--sky);
color: var(--sky);
}

/* ── Refresh control ── */
.ctrl-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 16px 16px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-dim);
}
.ctrl-bar select, .ctrl-bar button {
font-family: var(--sans);
font-size: 12px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
transition: border-color 0.15s;
}
.ctrl-bar select:hover, .ctrl-bar button:hover { border-color: var(--sky); }
.ctrl-bar button { color: var(--sky); }

/* ── Tooltip ── */
.tooltip-box {
position: fixed;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
pointer-events: none;
z-index: 999;
display: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
min-width: 130px;
}
.tooltip-box .tt-time { color: var(--text-dim); font-size: 10px; margin-bottom: 4px; }
.tooltip-box .tt-row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
.tooltip-box .tt-key { color: var(--text-dim); }
.tooltip-box .tt-v { font-family: var(--mono); font-weight: 500; }

/* ── Loading ── */
.loading-overlay {
position: fixed; inset: 0;
background: var(--bg);
z-index: 9000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
transition: opacity 0.4s;
}
.loading-overlay.gone { opacity: 0; pointer-events: none; }
.spin {
width: 36px; height: 36px;
border: 3px solid var(--border);
border-top-color: var(--sky);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-size: 13px; color: var(--text-dim); }

/* ── Error ── */
.error-msg {
background: rgba(255,95,109,0.08);
border: 1px solid rgba(255,95,109,0.25);
border-radius: 8px;
padding: 12px 16px;
color: var(--red);
font-size: 13px;
margin: 20px 0;
display: none;
}

/* ── Grid lines text ── */
.grid-label { font-family: var(--mono); font-size: 9px; fill: var(--text-dim); }

@media (max-width: 500px) {
.now-row { gap: 8px; padding: 16px; }
.now-val { font-size: 1.5rem; }
.chart-card { padding: 14px 12px 10px; }
}
</style>
</head>
<body>

<div class="loading-overlay" id="loader">
<div class="spin"></div>
<div class="loading-text">Получение данных с метеостанции…</div>
</div>

<div class="tooltip-box" id="tooltip">
<div class="tt-time" id="tt-time"></div>
<div class="tt-row"><span class="tt-key">🌡 Темп</span><span class="tt-v" id="tt-t"></span></div>
<div class="tt-row"><span class="tt-key">💧 Влаж</span><span class="tt-v" id="tt-h"></span></div>
<div class="tt-row"><span class="tt-key">📊 Давл</span><span class="tt-v" id="tt-p"></span></div>
</div>

<!-- ── Header ── -->
<div class="header">
<div class="header-sky"></div>
<div class="stars"></div>
<div class="header-inner">
<div class="station-label">Домашняя метеостанция · ESP32-S3</div>
<h1>Мониторинг <span>погоды</span></h1>
<div class="subtitle">Температура · Влажность · Атмосферное давление</div>
<div class="live-badge">
<div class="live-dot"></div>
<span id="live-label">Обновление данных…</span>
</div>
</div>
</div>

<!-- ── Status pills ── -->
<div class="status-bar" id="status-bar">
<span class="status-pill info">AHT20: —</span>
<span class="status-pill info">BMP280: —</span>
<span class="status-pill info">SD: —</span>
<span class="status-pill info">Wi-Fi: —</span>
</div>

<!-- ── Now values ── -->
<div class="now-row">
<div class="now-card temp">
<div class="now-label">Температура</div>
<div class="now-val" id="now-t">—</div>
<div class="now-unit">°C</div>
</div>
<div class="now-card hum">
<div class="now-label">Влажность</div>
<div class="now-val" id="now-h">—</div>
<div class="now-unit">%</div>
</div>
<div class="now-card press">
<div class="now-label">Давление</div>
<div class="now-val" id="now-p">—</div>
<div class="now-unit">мм.рт.ст</div>
</div>
</div>

<!-- ── Controls ── -->
<div class="ctrl-bar">
<span>Обновление каждые</span>
<select id="refresh-sel">
<option value="20">20 сек</option>
<option value="30">30 сек</option>
<option value="40">40 сек</option>
<option value="60" selected>1 мин</option>
<option value="120">2 мин</option>
</select>
<button id="refresh-btn">🔄 Обновить</button>
<span id="next-update"></span>
</div>

<!-- ── Nav ── -->
<div class="nav">
<a href="/">🏠 Главная</a>
<a href="/index_web">🌐 Web UI</a>
<a href="/info">⚙️ Настройки</a>
<a href="/archive">🗃️ Архив</a>
<a href="/files">📁 Файлы</a>
</div>

<!-- ── Main content ── -->
<div class="main">
<div class="error-msg" id="error-msg">⚠️ Не удалось получить данные. Проверьте подключение.</div>

<!-- RAM history charts -->
<div class="section-head">
<h2>📜 История RAM</h2>
<hr>
</div>

<div class="chart-card">
<div class="chart-title"><span class="icon">🌡</span> Температура (°C)</div>
<svg class="chart" id="svg-t" viewBox="0 0 900 180" preserveAspectRatio="none"></svg>
<div class="stats-bar" id="stats-t"></div>
</div>

<div class="chart-card">
<div class="chart-title"><span class="icon">💧</span> Влажность (%)</div>
<svg class="chart" id="svg-h" viewBox="0 0 900 180" preserveAspectRatio="none"></svg>
<div class="stats-bar" id="stats-h"></div>
</div>

<div class="chart-card">
<div class="chart-title"><span class="icon">📊</span> Давление (мм.рт.ст)</div>
<svg class="chart" id="svg-p" viewBox="0 0 900 180" preserveAspectRatio="none"></svg>
<div class="stats-bar" id="stats-p"></div>
</div>

<!-- SD period stats -->
<div class="section-head">
<h2>📊 Статистика по периодам (SD)</h2>
<hr>
</div>

<div class="period-grid" id="period-grid">
<!-- filled by JS -->
</div>
</div>

<script>
// ── Config ──
const BASE = ''; // same origin
const W = 900, H = 180;
const PAD = { top: 18, bot: 24, left: 40, right: 12 };
const PW = W - PAD.left - PAD.right;
const PH = H - PAD.top - PAD.bot;

let history = [];
let chartsData = null;
let refreshTimer = null;
let countdownVal = 0;
let countdownTimer = null;

// ── DOM refs ──
const $ = id => document.getElementById(id);

// ── Helpers ──
const clamp = (v,lo,hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a,b,t) => a + (b-a)*t;
const fmt1 = v => (typeof v === 'number' && !isNaN(v)) ? v.toFixed(1) : '—';
const fmt0 = v => (typeof v === 'number' && !isNaN(v)) ? v.toFixed(0) : '—';

function mkEl(tag, attrs = {}, parent) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
if (parent) parent.appendChild(el);
return el;
}

// ── Draw one SVG line chart ──
function drawChart(svgId, data, key, color, unit, decimals = 1) {
const svg = $(svgId);
svg.innerHTML = '';

if (!data || data.length === 0) {
mkEl('text', { x: W/2, y: H/2, 'text-anchor':'middle', fill:'#4a6580', 'font-size':'13', 'font-family':'Inter,sans-serif' }, svg)
.textContent = 'Нет данных в истории RAM';
return;
}

const vals = data.map(d => parseFloat(d[key])).filter(v => !isNaN(v));
if (vals.length < 2) return;

const mn = Math.min(...vals);
const mx = Math.max(...vals);
const range = mx - mn || 1;
const mnPad = mn - range * 0.08;
const mxPad = mx + range * 0.08;

const xScale = i => PAD.left + (i / (data.length - 1)) * PW;
const yScale = v => PAD.top + (1 - (v - mnPad) / (mxPad - mnPad)) * PH;

// Grid lines & labels
const steps = 4;
for (let i = 0; i <= steps; i++) {
const v = mnPad + (mxPad - mnPad) * (1 - i / steps);
const y = PAD.top + i / steps * PH;
mkEl('line', { x1: PAD.left, x2: W - PAD.right, y1: y, y2: y,
stroke: '#1e3050', 'stroke-width': '1', 'stroke-dasharray': '3,4' }, svg);
const t = mkEl('text', { x: PAD.left - 4, y: y + 3.5,
'text-anchor': 'end', class: 'grid-label' }, svg);
t.textContent = decimals === 0 ? Math.round(v) : v.toFixed(1);
}

// X axis time labels (up to 5)
const labelCount = Math.min(data.length, 5);
for (let li = 0; li < labelCount; li++) {
const idx = Math.round(li / (labelCount - 1) * (data.length - 1));
const x = xScale(idx);
mkEl('line', { x1: x, x2: x, y1: PAD.top, y2: H - PAD.bot + 4,
stroke: '#1e3050', 'stroke-width': '1' }, svg);
const t = mkEl('text', { x, y: H - 4, 'text-anchor': 'middle', class: 'grid-label' }, svg);
t.textContent = data[idx].time || '';
}

// Area fill
let areaD = `M ${xScale(0)} ${H - PAD.bot}`;
areaD += ` L ${xScale(0)} ${yScale(vals[0])}`;
for (let i = 1; i < data.length; i++) {
areaD += ` L ${xScale(i)} ${yScale(parseFloat(data[i][key]))}`;
}
areaD += ` L ${xScale(data.length - 1)} ${H - PAD.bot} Z`;

const grad = mkEl('defs', {}, svg);
const gId = 'g' + svgId;
const lg = mkEl('linearGradient', { id: gId, x1: '0', y1: '0', x2: '0', y2: '1' }, grad);
mkEl('stop', { offset: '0%', 'stop-color': color, 'stop-opacity': '0.35' }, lg);
mkEl('stop', { offset: '100%', 'stop-color': color, 'stop-opacity': '0' }, lg);

mkEl('path', { d: areaD, fill: `url(#${gId})`, class: 'chart-area' }, svg);

// Line path
let d = `M ${xScale(0)} ${yScale(vals[0])}`;
for (let i = 1; i < data.length; i++) {
d += ` L ${xScale(i)} ${yScale(parseFloat(data[i][key]))}`;
}

const path = mkEl('path', {
d, stroke: color, 'stroke-width': '2.2',
fill: 'none', 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
}, svg);

// Animate
const len = path.getTotalLength ? path.getTotalLength() : 2000;
path.setAttribute('stroke-dasharray', len);
path.setAttribute('stroke-dashoffset', len);
requestAnimationFrame(() => {
path.style.transition = 'stroke-dashoffset 1.4s cubic-bezier(0.4,0,0.2,1)';
path.setAttribute('stroke-dashoffset', '0');
});

// Last dot
const lx = xScale(data.length - 1);
const ly = yScale(vals[vals.length - 1]);
mkEl('circle', { cx: lx, cy: ly, r: '4', fill: color, opacity: '0.9' }, svg);
mkEl('circle', { cx: lx, cy: ly, r: '7', fill: 'none', stroke: color, 'stroke-width': '1.5', opacity: '0.4' }, svg);

// Invisible overlay rects for hover
for (let i = 0; i < data.length; i++) {
const x = xScale(i);
const ow = PW / Math.max(data.length, 1);
const rect = mkEl('rect', {
x: x - ow/2, y: PAD.top, width: ow, height: PH,
fill: 'transparent', style: 'cursor:crosshair'
}, svg);
rect.addEventListener('mousemove', e => showTooltip(e, data[i]));
rect.addEventListener('mouseleave', hideTooltip);
rect.addEventListener('touchstart', e => showTooltip(e.touches[0], data[i]), { passive: true });
}
}

// ── Stats bar ──
function renderStats(elId, vals, color, unit, dec = 1) {
const el = $(elId);
if (!vals || vals.length === 0) { el.innerHTML = ''; return; }
const mn = Math.min(...vals).toFixed(dec);
const mx = Math.max(...vals).toFixed(dec);
const av = (vals.reduce((a,b) => a+b, 0) / vals.length).toFixed(dec);
const last = vals[vals.length - 1].toFixed(dec);
el.innerHTML =
`<div class="stat-item"><span class="stat-lbl">Мин&nbsp;</span><span class="stat-val" style="color:${color}">${mn}${unit}</span></div>` +
`<div class="stat-item"><span class="stat-lbl">Макс&nbsp;</span><span class="stat-val" style="color:${color}">${mx}${unit}</span></div>` +
`<div class="stat-item"><span class="stat-lbl">Среднее&nbsp;</span><span class="stat-val" style="color:${color}">${av}${unit}</span></div>` +
`<div class="stat-item"><span class="stat-lbl">Записей&nbsp;</span><span class="stat-val">${vals.length}</span></div>`;
}

// ── Period grid ──
function renderPeriods(cd) {
const periods = [
{ key: '6', label: 'За 6 часов' },
{ key: '12', label: 'За 12 часов' },
{ key: '24', label: 'За 24 часа' },
];
let html = '';
for (const p of periods) {
const s = cd.periods[p.key];
if (!s || s.count === 0) {
html += `<div class="period-card"><div class="period-head">${p.label}</div><div style="color:var(--text-dim);font-size:12px">Нет данных (SD)</div></div>`;
continue;
}
const avgT = (s.sumT / s.count).toFixed(1);
const avgH = (s.sumH / s.count).toFixed(1);
const avgP = (s.sumP / s.count).toFixed(0);
html += `
<div class="period-card">
<div class="period-head">${p.label} &nbsp;·&nbsp; ${s.count} зап.</div>
<div class="period-metrics">
<div class="p-metric t">
<div class="p-lbl">🌡 Темп</div>
<span class="p-num">${avgT}°</span>
<div class="p-minmax">${s.minT.toFixed(1)} / ${s.maxT.toFixed(1)}</div>
</div>
<div class="p-metric h">
<div class="p-lbl">💧 Влаж</div>
<span class="p-num">${avgH}%</span>
<div class="p-minmax">${s.minH.toFixed(1)} / ${s.maxH.toFixed(1)}</div>
</div>
<div class="p-metric pr">
<div class="p-lbl">📊 Давл</div>
<span class="p-num">${avgP}</span>
<div class="p-minmax">${s.minP.toFixed(0)} / ${s.maxP.toFixed(0)}</div>
</div>
</div>
</div>`;
}
$('period-grid').innerHTML = html;
}

// ── Tooltip ──
function showTooltip(e, point) {
const tt = $('tooltip');
$('tt-time').textContent = point.time || '';
$('tt-t').textContent = fmt1(parseFloat(point.temp)) + ' °C';
$('tt-h').textContent = fmt1(parseFloat(point.hum)) + ' %';
$('tt-p').textContent = fmt0(parseFloat(point.press)) + ' мм';
tt.style.display = 'block';
const x = (e.clientX || e.pageX) + 14;
const y = (e.clientY || e.pageY) - 10;
tt.style.left = Math.min(x, window.innerWidth - 160) + 'px';
tt.style.top = Math.max(y, 8) + 'px';
}
function hideTooltip() { $('tooltip').style.display = 'none'; }

// ── Status pills ──
function renderStatus(d) {
const pills = [
{ label: 'AHT20', ok: d.ahtOk },
{ label: 'BMP280', ok: d.bmpOk },
{ label: 'SD', ok: d.sdOk },
{ label: 'Wi-Fi', ok: d.wifiOk },
];
$('status-bar').innerHTML = pills.map(p =>
`<span class="status-pill ${p.ok ? 'ok' : 'err'}">${p.label}: ${p.ok ? '✓' : '✗'}</span>`
).join('');
}

// ── Main fetch ──
async function fetchAll() {
try {
// Parallel fetch of both endpoints
const [r1, r2] = await Promise.all([
fetch(BASE + '/data?_=' + Date.now()),
fetch(BASE + '/charts_data?_=' + Date.now()),
]);
const d = await r1.json();
const cd = await r2.json();

$('error-msg').style.display = 'none';

// Now values
$('now-t').textContent = fmt1(d.temperature);
$('now-h').textContent = fmt1(d.humidity);
$('now-p').textContent = fmt0(d.pressure);

// Status
renderStatus(d);

// Live label
const now = new Date();
const hm = now.toLocaleTimeString('ru', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
$('live-label').textContent = 'Обновлено в ' + hm;

// History
history = d.history || [];

const tVals = history.map(x => parseFloat(x.temp));
const hVals = history.map(x => parseFloat(x.hum));
const pVals = history.map(x => parseFloat(x.press));

drawChart('svg-t', history, 'temp', '#4da6ff', '°C', 1);
drawChart('svg-h', history, 'hum', '#34d9c3', '%', 1);
drawChart('svg-p', history, 'press', '#f5a623', 'мм', 0);

renderStats('stats-t', tVals, '#4da6ff', ' °C', 1);
renderStats('stats-h', hVals, '#34d9c3', ' %', 1);
renderStats('stats-p', pVals, '#f5a623', ' мм', 0);

// Period stats
chartsData = cd;
renderPeriods(cd);

// Hide loader
const loader = $('loader');
loader.classList.add('gone');
setTimeout(() => loader.style.display = 'none', 500);

} catch (err) {
console.error('Fetch error:', err);
$('error-msg').style.display = 'block';
$('live-label').textContent = 'Ошибка получения данных';
const loader = $('loader');
loader.classList.add('gone');
setTimeout(() => loader.style.display = 'none', 400);
}
}

// ── Countdown ──
function startCountdown(sec) {
countdownVal = sec;
clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
countdownVal--;
$('next-update').textContent = countdownVal > 0 ? `· следующее через ${countdownVal} с` : '';
if (countdownVal <= 0) clearInterval(countdownTimer);
}, 1000);
}

// ── Refresh loop ──
function scheduleRefresh() {
clearTimeout(refreshTimer);
const sec = parseInt($('refresh-sel').value) || 20;
startCountdown(sec);
refreshTimer = setTimeout(async () => {
await fetchAll();
scheduleRefresh();
}, sec * 1000);
}

// ── Event listeners ──
$('refresh-sel').addEventListener('change', () => {
clearTimeout(refreshTimer);
scheduleRefresh();
});

$('refresh-btn').addEventListener('click', async () => {
clearTimeout(refreshTimer);
$('live-label').textContent = 'Обновление…';
await fetchAll();
scheduleRefresh();
});

// ── Init ──
(async () => {
await fetchAll();
scheduleRefresh();
})();
</script>
</body>
</html>

🏠 Главная