Client-side i18n: static labels via data-i18n, dial zone words, and the 26 Zambretti phrases + categories + trend words translated in the browser. Choice persists in localStorage. Device firmware unchanged (API stays English; TFT stays Latin). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
614 lines
32 KiB
C++
614 lines
32 KiB
C++
#pragma once
|
||
#include <Arduino.h>
|
||
|
||
static const char INDEX_HTML[] PROGMEM = R"HTML(
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Weather Predictor</title>
|
||
<style>
|
||
:root{
|
||
--ink:#0F1A2E; --ink2:#0B1322; --surface:#182642; --line:#2A3A5A;
|
||
--enamel:#F1ECE0; --brass:#C6A15B; --brass-dim:#8C7538;
|
||
--text:#EAF0F8; --muted:#93A3BE;
|
||
--storm:#E4573B; --rain:#5C86D6; --change:#79C2D0; --fair:#E4B658; --fine:#8FBE8A;
|
||
--serif:"Palatino Linotype",Palatino,"Book Antiqua",Georgia,"Times New Roman",serif;
|
||
--sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||
--mono:ui-monospace,"Cascadia Mono","Segoe UI Mono",Consolas,Menlo,monospace;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{
|
||
margin:0; background:
|
||
radial-gradient(120% 80% at 50% -10%, #1B2B49 0%, var(--ink) 55%, var(--ink2) 100%) fixed;
|
||
color:var(--text); font-family:var(--sans); line-height:1.5;
|
||
-webkit-font-smoothing:antialiased;
|
||
}
|
||
.wrap{max-width:760px; margin:0 auto; padding:28px 20px 48px}
|
||
|
||
/* Masthead */
|
||
.eyebrow{
|
||
font-size:.72rem; letter-spacing:.28em; text-transform:uppercase;
|
||
color:var(--brass); display:flex; align-items:center; gap:10px; margin:0 0 10px;
|
||
}
|
||
.eyebrow::before{content:""; width:26px; height:1px; background:var(--brass-dim)}
|
||
h1{
|
||
font-family:var(--serif); font-weight:600; font-size:clamp(2.1rem,7vw,3.1rem);
|
||
letter-spacing:.01em; margin:0; line-height:1.02;
|
||
}
|
||
.lede{color:var(--muted); font-size:.92rem; margin:8px 0 0}
|
||
.lede b{color:var(--text); font-weight:600}
|
||
header{position:relative}
|
||
.lang{position:absolute; top:0; right:0; display:flex; gap:3px}
|
||
.lang button{padding:4px 9px; font-size:.72rem; font-weight:700; letter-spacing:.06em; font-family:var(--sans);
|
||
background:transparent; color:var(--muted); border:1px solid var(--line); border-radius:7px; cursor:pointer}
|
||
.lang button.on{background:var(--brass); color:#221a06; border-color:var(--brass)}
|
||
|
||
.panel{
|
||
background:linear-gradient(180deg, var(--surface), #14203a);
|
||
border:1px solid var(--line); border-radius:16px; margin-top:22px;
|
||
}
|
||
.panel-pad{padding:22px}
|
||
.rule{height:1px; background:var(--line); border:0; margin:0}
|
||
|
||
/* Dial hero */
|
||
.dial{padding:20px 16px 6px; text-align:center}
|
||
svg{display:block; width:100%; height:auto}
|
||
#gauge{max-width:420px; margin:0 auto}
|
||
.needle{transition:transform .9s cubic-bezier(.2,.8,.2,1)}
|
||
.sethand{transition:transform .9s cubic-bezier(.2,.8,.2,1)}
|
||
|
||
.readout{
|
||
display:flex; align-items:baseline; justify-content:center; gap:12px;
|
||
margin-top:2px; flex-wrap:wrap;
|
||
}
|
||
.pressure{
|
||
font-family:var(--mono); font-variant-numeric:tabular-nums;
|
||
font-size:clamp(2.6rem,11vw,3.7rem); font-weight:600; letter-spacing:-.02em; color:var(--enamel);
|
||
}
|
||
.unit{font-family:var(--sans); font-size:.9rem; letter-spacing:.22em; text-transform:uppercase; color:var(--muted)}
|
||
.chip{
|
||
font-size:.74rem; letter-spacing:.14em; text-transform:uppercase; font-weight:600;
|
||
padding:5px 11px; border-radius:999px; border:1px solid currentColor; white-space:nowrap;
|
||
}
|
||
.verdict{
|
||
font-family:var(--serif); font-size:clamp(1.35rem,4.6vw,1.7rem); font-weight:600;
|
||
margin:16px 0 4px; padding:0 12px; color:var(--enamel);
|
||
}
|
||
.verdict-sub{color:var(--muted); font-size:.86rem; margin:0 0 20px}
|
||
|
||
/* Stats */
|
||
.stats{display:grid; grid-template-columns:repeat(3,1fr)}
|
||
.stat{padding:18px; text-align:left; border-left:1px solid var(--line)}
|
||
.stat:first-child{border-left:0}
|
||
.stat .k{font-size:.68rem; letter-spacing:.2em; text-transform:uppercase; color:var(--muted)}
|
||
.stat .v{font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:1.7rem; font-weight:600; color:var(--enamel); margin-top:6px; line-height:1}
|
||
.stat .s{font-size:.78rem; color:var(--muted); margin-top:5px}
|
||
@media(max-width:520px){
|
||
.stats{grid-template-columns:1fr 1fr}
|
||
.stat:nth-child(3){grid-column:1 / -1; border-left:0; border-top:1px solid var(--line)}
|
||
}
|
||
|
||
/* Barograph */
|
||
.head{display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:14px}
|
||
.head h2{font-family:var(--serif); font-weight:600; font-size:1.15rem; margin:0}
|
||
.legend{display:flex; gap:16px; font-size:.76rem; color:var(--muted)}
|
||
.legend i{display:inline-block; width:20px; height:2px; vertical-align:middle; margin-right:6px}
|
||
.baro{position:relative; padding:0 46px}
|
||
#chart{width:100%; height:200px; display:block}
|
||
.ax{position:absolute; top:0; height:200px; display:flex; flex-direction:column;
|
||
justify-content:space-between; font-family:var(--mono); font-variant-numeric:tabular-nums;
|
||
font-size:.7rem; pointer-events:none}
|
||
.ax-l{left:0; align-items:flex-start; color:var(--change)}
|
||
.ax-r{right:0; align-items:flex-end; color:var(--fair)}
|
||
.axx{display:flex; justify-content:space-between; padding:0 46px; margin-top:6px;
|
||
font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:.7rem; color:var(--muted)}
|
||
.hint{color:var(--muted); font-size:.78rem; margin:10px 0 0}
|
||
.cap{color:var(--muted); font-size:.74rem; letter-spacing:.06em}
|
||
|
||
/* Forecast log */
|
||
.log{list-style:none; margin:2px 0 0; padding:0 8px 0 0; max-height:216px; overflow-y:auto;
|
||
scrollbar-width:thin; scrollbar-color:var(--line) transparent}
|
||
.log::-webkit-scrollbar{width:8px}
|
||
.log::-webkit-scrollbar-thumb{background:var(--line); border-radius:8px}
|
||
.log::-webkit-scrollbar-track{background:transparent}
|
||
.log li{display:flex; align-items:center; gap:13px; padding:12px 0; border-top:1px solid var(--line)}
|
||
.log li:first-child{border-top:0}
|
||
.log .dot{width:9px; height:9px; border-radius:50%; flex:0 0 auto}
|
||
.log .lt{flex:1; min-width:0}
|
||
.log .lt b{display:block; font-family:var(--serif); font-weight:600; color:var(--enamel); font-size:1.02rem; line-height:1.25}
|
||
.log .lc{font-size:.66rem; letter-spacing:.18em; text-transform:uppercase; color:var(--muted); margin-top:2px}
|
||
.log time{font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:.76rem; color:var(--muted); white-space:nowrap; flex:0 0 auto}
|
||
.log .empty{color:var(--muted); padding:6px 0}
|
||
|
||
/* Settings */
|
||
details.set summary{
|
||
cursor:pointer; list-style:none; padding:18px 22px; display:flex; align-items:center;
|
||
justify-content:space-between; font-family:var(--serif); font-size:1.1rem; font-weight:600;
|
||
}
|
||
details.set summary::-webkit-details-marker{display:none}
|
||
details.set summary .caret{color:var(--brass); font-size:.8rem; letter-spacing:.15em; text-transform:uppercase}
|
||
.form{padding:4px 22px 22px}
|
||
.grid2{display:grid; grid-template-columns:1fr 1fr; gap:14px}
|
||
@media(max-width:520px){.grid2{grid-template-columns:1fr}}
|
||
label{display:block; font-size:.72rem; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); margin:0 0 6px}
|
||
input,select{
|
||
width:100%; padding:11px 12px; background:var(--ink2); color:var(--text);
|
||
border:1px solid var(--line); border-radius:10px; font-family:var(--mono); font-size:1rem;
|
||
}
|
||
select{appearance:none; cursor:pointer}
|
||
input:focus,select:focus{outline:none; border-color:var(--brass); box-shadow:0 0 0 3px rgba(198,161,91,.18)}
|
||
.actions{display:flex; align-items:center; gap:16px; margin-top:18px}
|
||
button{
|
||
padding:11px 22px; border:0; border-radius:10px; cursor:pointer;
|
||
background:var(--brass); color:#221a06; font-weight:700; font-size:.95rem; letter-spacing:.02em;
|
||
font-family:var(--sans);
|
||
}
|
||
button:hover{filter:brightness(1.06)}
|
||
button:focus-visible{outline:2px solid var(--enamel); outline-offset:2px}
|
||
.saved{font-size:.82rem; color:var(--fine)}
|
||
|
||
footer{color:var(--muted); font-size:.74rem; text-align:center; margin-top:26px; letter-spacing:.04em}
|
||
|
||
@media(prefers-reduced-motion:reduce){.needle,.sethand{transition:none}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
|
||
<header>
|
||
<div class="lang" id="lang"><button type="button" data-lang="ru">RU</button><button type="button" data-lang="en">EN</button></div>
|
||
<p class="eyebrow" data-i18n="eyebrow">Barometric Forecaster</p>
|
||
<h1 data-i18n="title">Weather Predictor</h1>
|
||
<p class="lede"><span data-i18n="lede">Reading the sky by pressure alone, the Zambretti way — station at</span>
|
||
<span id="coords">54.99°N, 82.87°E</span>.</p>
|
||
</header>
|
||
|
||
<!-- Barometer dial -->
|
||
<section class="panel dial">
|
||
<svg id="gauge" viewBox="0 0 400 232" role="img" aria-label="Barometer dial"></svg>
|
||
<div class="readout">
|
||
<span class="pressure"><span id="msl">----</span></span>
|
||
<span class="unit" data-i18n="unit_msl">hPa · sea level</span>
|
||
<span class="chip" id="chip" style="color:var(--muted)">—</span>
|
||
</div>
|
||
<p class="verdict" id="verdict">Reading the sky…</p>
|
||
<p class="verdict-sub" id="verdict-sub">The forecaster needs about three hours of pressure history before its first call.</p>
|
||
</section>
|
||
|
||
<!-- Stats -->
|
||
<section class="panel stats">
|
||
<div class="stat"><div class="k" data-i18n="k_time">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">—</div></div>
|
||
<div class="stat"><div class="k" data-i18n="k_temp">Temperature</div><div class="v" id="temp">--</div><div class="s" data-i18n="s_celsius">degrees celsius</div></div>
|
||
<div class="stat"><div class="k" data-i18n="k_move">3-hour move</div><div class="v" id="delta">—</div><div class="s" id="deltas">—</div></div>
|
||
</section>
|
||
|
||
<!-- Barograph -->
|
||
<section class="panel panel-pad">
|
||
<div class="head">
|
||
<h2 data-i18n="barograph">Barograph</h2>
|
||
<div class="legend">
|
||
<span><i style="background:var(--change)"></i><span data-i18n="pressure">Pressure</span></span>
|
||
<span><i style="background:var(--fair)"></i><span data-i18n="temperature">Temperature</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="baro">
|
||
<div class="ax ax-l" id="axL"></div>
|
||
<svg id="chart" viewBox="0 0 640 200" preserveAspectRatio="none" aria-label="Pressure and temperature history"></svg>
|
||
<div class="ax ax-r" id="axR"></div>
|
||
</div>
|
||
<div class="axx"><span id="axT0">—</span><span id="axT1">—</span></div>
|
||
<p class="hint" id="hint">Collecting data…</p>
|
||
</section>
|
||
|
||
<!-- Forecast log -->
|
||
<section class="panel panel-pad">
|
||
<div class="head">
|
||
<h2 data-i18n="flog">Forecast log</h2>
|
||
<span class="cap" id="log-cap"></span>
|
||
</div>
|
||
<ol class="log" id="log"></ol>
|
||
</section>
|
||
|
||
<!-- Settings -->
|
||
<details class="panel set">
|
||
<summary><span data-i18n="settings">Station settings</span> <span class="caret" data-i18n="adjust">Adjust</span></summary>
|
||
<hr class="rule">
|
||
<div class="form">
|
||
<div style="margin-top:18px">
|
||
<label data-i18n="l_city">City preset</label>
|
||
<select id="s-city"></select>
|
||
</div>
|
||
<div class="grid2" style="margin-top:14px">
|
||
<div><label data-i18n="l_alt">Altitude · metres</label><input id="s-alt" type="number" step="1"></div>
|
||
<div><label data-i18n="l_tz">Time zone</label><select id="s-tz"></select></div>
|
||
<div><label data-i18n="l_lat">Latitude</label><input id="s-lat" type="number" step="0.0001"></div>
|
||
<div><label data-i18n="l_lon">Longitude</label><input id="s-lon" type="number" step="0.0001"></div>
|
||
</div>
|
||
<div class="actions">
|
||
<button id="save" data-i18n="save">Save settings</button>
|
||
<span class="saved" id="saved"></span>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<footer data-i18n="footer">Served from the station · works without internet</footer>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
var CX=200, CY=200, R=150, PMIN=960, PMAX=1060;
|
||
var ZONES=[
|
||
{from:960, to:985, label:"STORMY", color:"#E4573B"},
|
||
{from:985, to:1000, label:"RAIN", color:"#5C86D6"},
|
||
{from:1000,to:1015, label:"CHANGE", color:"#79C2D0"},
|
||
{from:1015,to:1035, label:"FAIR", color:"#E4B658"},
|
||
{from:1035,to:1060, label:"VERY DRY", color:"#8FBE8A"}
|
||
];
|
||
var CATCOLOR={Fine:"#8FBE8A",Fair:"#E4B658",Changeable:"#79C2D0",Rain:"#5C86D6",Storm:"#E4573B"};
|
||
|
||
// ---------- i18n ----------
|
||
var LANG=(function(){ try{ return localStorage.getItem("wp_lang")||"ru"; }catch(e){ return "ru"; } })();
|
||
var STR={
|
||
ru:{
|
||
eyebrow:"Барометрический предсказатель", title:"Предсказатель погоды",
|
||
lede:"Читаем небо по одному давлению — метод Zambretti. Станция:",
|
||
unit_msl:"гПа · у моря",
|
||
k_time:"Местное время", k_temp:"Температура", k_move:"Изм. за 3 ч", s_celsius:"градусы Цельсия",
|
||
barograph:"Барограф", pressure:"Давление", temperature:"Температура", flog:"Журнал прогнозов",
|
||
settings:"Настройки станции", adjust:"Изменить",
|
||
l_city:"Пресет города", l_alt:"Высота · метры", l_tz:"Часовой пояс", l_lat:"Широта", l_lon:"Долгота",
|
||
custom:"Вручную", save:"Сохранить", footer:"Отдаётся станцией · работает без интернета",
|
||
warm:"Читаем небо…", warm_chip:"прогрев",
|
||
warm_sub:"Прогнозу нужно около трёх часов истории давления до первого вывода.",
|
||
verdict_pre:"Показание Zambretti — ", verdict_post:" за последние три часа.",
|
||
tr_rising:"давление растёт", tr_falling:"давление падает", tr_steady:"давление стабильно",
|
||
collecting:"Сбор данных…",
|
||
hint:function(n,h){return n+" измерений за "+h+" ч · давление в гПа, температура в °C";},
|
||
log_empty:"Пока прогнозов нет — первый появится примерно через три часа.",
|
||
log_cap:function(n){var d1=n%10,d2=n%100; return n+((d1===1&&d2!==11)?" смена":((d1>=2&&d1<=4&&(d2<10||d2>=20))?" смены":" смен"));},
|
||
need3:"нужно 3 ч данных", mv_unit:"гПа · ", mv_rising:"рост", mv_steady:"стабильно", mv_falling:"падение",
|
||
saved_ok:"Сохранено.", saved_err:"Не удалось сохранить.", saved_net:"Станция недоступна."
|
||
},
|
||
en:{
|
||
eyebrow:"Barometric Forecaster", title:"Weather Predictor",
|
||
lede:"Reading the sky by pressure alone, the Zambretti way — station at",
|
||
unit_msl:"hPa · sea level",
|
||
k_time:"Local time", k_temp:"Temperature", k_move:"3-hour move", s_celsius:"degrees celsius",
|
||
barograph:"Barograph", pressure:"Pressure", temperature:"Temperature", flog:"Forecast log",
|
||
settings:"Station settings", adjust:"Adjust",
|
||
l_city:"City preset", l_alt:"Altitude · metres", l_tz:"Time zone", l_lat:"Latitude", l_lon:"Longitude",
|
||
custom:"Custom / manual entry", save:"Save settings", footer:"Served from the station · works without internet",
|
||
warm:"Reading the sky…", warm_chip:"warming up",
|
||
warm_sub:"The forecaster needs about three hours of pressure history before its first call.",
|
||
verdict_pre:"Zambretti reading — ", verdict_post:" over the last three hours.",
|
||
tr_rising:"pressure rising", tr_falling:"pressure falling", tr_steady:"pressure steady",
|
||
collecting:"Collecting data…",
|
||
hint:function(n,h){return n+" readings over "+h+" h · pressure in hPa, temperature in °C";},
|
||
log_empty:"No forecasts yet — the first call appears after about three hours.",
|
||
log_cap:function(n){return n+(n>1?" changes":" change");},
|
||
need3:"need 3 h of data", mv_unit:"hPa · ", mv_rising:"rising", mv_steady:"steady", mv_falling:"falling",
|
||
saved_ok:"Saved.", saved_err:"Could not save.", saved_net:"Could not reach the station."
|
||
}
|
||
};
|
||
function T(k){ var s=STR[LANG]||STR.en; return (k in s)?s[k]:(STR.en[k]!=null?STR.en[k]:k); }
|
||
var ZONE_RU={STORMY:"БУРЯ",RAIN:"ДОЖДЬ",CHANGE:"ПЕРЕМЕННО",FAIR:"ЯСНО","VERY DRY":"СУШЬ"};
|
||
function zoneLabel(z){ return LANG==="ru"?(ZONE_RU[z]||z):z; }
|
||
var CATS={Fine:"Ясно",Fair:"Хорошо",Changeable:"Переменно",Rain:"Дождь",Storm:"Буря"};
|
||
function trCat(s){ return (LANG==="ru"&&CATS[s])?CATS[s]:s; }
|
||
var PHRASES={
|
||
"Settled fine weather":"Устойчиво ясно",
|
||
"Fine weather":"Ясная погода",
|
||
"Becoming fine":"Проясняется",
|
||
"Fine, becoming less settled":"Ясно, погода портится",
|
||
"Fine, possibly showers":"Ясно, возможны ливни",
|
||
"Fairly fine, improving":"Довольно ясно, улучшение",
|
||
"Fairly fine, possibly showers early":"Довольно ясно, ранние ливни",
|
||
"Fairly fine, showers later":"Довольно ясно, ливни позже",
|
||
"Showery early, improving":"Ранние ливни, улучшение",
|
||
"Changeable, improving":"Переменно, улучшение",
|
||
"Fairly fine, showers likely":"Довольно ясно, вероятны ливни",
|
||
"Rather unsettled, clearing later":"Неустойчиво, позже прояснение",
|
||
"Unsettled, probably improving":"Неустойчиво, вероятно улучшение",
|
||
"Showery, bright intervals":"Ливни, прояснения",
|
||
"Showery, becoming unsettled":"Ливни, погода портится",
|
||
"Changeable, some rain":"Переменно, местами дождь",
|
||
"Unsettled, short fine intervals":"Неустойчиво, краткие прояснения",
|
||
"Unsettled, rain later":"Неустойчиво, дождь позже",
|
||
"Unsettled, rain at times":"Неустойчиво, временами дождь",
|
||
"Very unsettled, finer at times":"Очень неустойчиво, временами лучше",
|
||
"Rain at times, worse later":"Временами дождь, позже хуже",
|
||
"Rain at times, becoming very unsettled":"Временами дождь, резко портится",
|
||
"Rain at frequent intervals":"Частые дожди",
|
||
"Very unsettled, rain":"Очень неустойчиво, дождь",
|
||
"Stormy, possibly improving":"Буря, возможно улучшение",
|
||
"Stormy, much rain":"Буря, сильный дождь"
|
||
};
|
||
function trPhrase(s){ return (LANG==="ru"&&PHRASES[s])?PHRASES[s]:s; }
|
||
function applyLang(){
|
||
document.documentElement.lang=LANG;
|
||
document.querySelectorAll("[data-i18n]").forEach(function(el){ el.textContent=T(el.getAttribute("data-i18n")); });
|
||
ZONES.forEach(function(z){ var lab=document.getElementById("lab-"+z.label); if(lab) lab.textContent=zoneLabel(z.label); });
|
||
var city=document.getElementById("s-city"); if(city&&city.options.length) city.options[0].textContent=T("custom");
|
||
document.querySelectorAll("#lang button").forEach(function(b){ b.classList.toggle("on", b.getAttribute("data-lang")===LANG); });
|
||
}
|
||
function setLang(l){ LANG=l; try{localStorage.setItem("wp_lang",l);}catch(e){} applyLang(); refresh(); }
|
||
|
||
function clamp(v,a,b){return v<a?a:(v>b?b:v);}
|
||
// pressure -> screen angle (deg): 960=180 (left), 1060=0 (right)
|
||
function angleOf(p){return 180 - (clamp(p,PMIN,PMAX)-PMIN)/(PMAX-PMIN)*180;}
|
||
function pol(r,deg){var a=deg*Math.PI/180; return [CX+r*Math.cos(a), CY-r*Math.sin(a)];}
|
||
function arcPts(r,pFrom,pTo){
|
||
var a0=angleOf(pFrom), a1=angleOf(pTo), d=(a1<a0?-1:1), s="";
|
||
for(var a=a0; (d<0? a>=a1 : a<=a1); a+=d*2){ var q=pol(r,a); s+=(s?"L":"M")+q[0].toFixed(1)+" "+q[1].toFixed(1); }
|
||
var e=pol(r,a1); s+="L"+e[0].toFixed(1)+" "+e[1].toFixed(1);
|
||
return s;
|
||
}
|
||
function el(tag,attrs,txt){
|
||
var n=document.createElementNS("http://www.w3.org/2000/svg",tag);
|
||
for(var k in attrs) n.setAttribute(k,attrs[k]);
|
||
if(txt!=null) n.textContent=txt;
|
||
return n;
|
||
}
|
||
|
||
// Build the static dial once.
|
||
function buildGauge(){
|
||
var g=document.getElementById("gauge");
|
||
// track
|
||
g.appendChild(el("path",{d:arcPts(R,PMIN,PMAX),fill:"none",stroke:"#2A3A5A","stroke-width":14,"stroke-linecap":"round"}));
|
||
// zone arcs (dim) + boundary ticks + labels
|
||
ZONES.forEach(function(z){
|
||
g.appendChild(el("path",{id:"zone-"+z.label,d:arcPts(R,z.from,z.to),fill:"none",stroke:z.color,"stroke-width":14,opacity:.28}));
|
||
var mid=(z.from+z.to)/2, lp=pol(R+21,angleOf(mid));
|
||
var t=el("text",{id:"lab-"+z.label,x:lp[0].toFixed(1),y:lp[1].toFixed(1),fill:"#93A3BE","font-size":11,"font-family":"var(--sans)","letter-spacing":"1.5","text-anchor":"middle","dominant-baseline":"middle"},z.label);
|
||
g.appendChild(t);
|
||
});
|
||
// numeric ticks
|
||
[960,980,1000,1020,1040,1060].forEach(function(p){
|
||
var o=pol(R-8,angleOf(p)), i=pol(R-18,angleOf(p));
|
||
g.appendChild(el("line",{x1:o[0].toFixed(1),y1:o[1].toFixed(1),x2:i[0].toFixed(1),y2:i[1].toFixed(1),stroke:"#4A5B7C","stroke-width":1.5}));
|
||
var np=pol(R-30,angleOf(p));
|
||
g.appendChild(el("text",{x:np[0].toFixed(1),y:np[1].toFixed(1),fill:"#6B7C9C","font-size":9,"font-family":"var(--mono)","text-anchor":"middle","dominant-baseline":"middle"},p));
|
||
});
|
||
// set-hand (ghost, hidden until data) — points up at rot 0
|
||
var sh=el("g",{id:"sethand",class:"sethand",transform:"rotate(0 "+CX+" "+CY+")",opacity:0});
|
||
sh.appendChild(el("line",{x1:CX,y1:CY,x2:CX,y2:CY-(R-22),stroke:"#93A3BE","stroke-width":2,"stroke-dasharray":"3 4"}));
|
||
sh.appendChild(el("circle",{cx:CX,cy:CY-(R-22),r:3.5,fill:"none",stroke:"#93A3BE","stroke-width":1.5}));
|
||
g.appendChild(sh);
|
||
// live needle — points up at rot 0
|
||
var nd=el("g",{id:"needle",class:"needle",transform:"rotate(0 "+CX+" "+CY+")"});
|
||
nd.appendChild(el("line",{x1:CX,y1:CY+14,x2:CX,y2:CY-(R-12),stroke:"#F1ECE0","stroke-width":3,"stroke-linecap":"round"}));
|
||
nd.appendChild(el("polygon",{points:CX+","+(CY-(R-2))+" "+(CX-5)+","+(CY-(R-16))+" "+(CX+5)+","+(CY-(R-16)),fill:"#F1ECE0"}));
|
||
g.appendChild(nd);
|
||
// hub
|
||
g.appendChild(el("circle",{cx:CX,cy:CY,r:10,fill:"#182642",stroke:"#C6A15B","stroke-width":2}));
|
||
g.appendChild(el("circle",{cx:CX,cy:CY,r:3.5,fill:"#C6A15B"}));
|
||
}
|
||
|
||
function zoneOf(p){for(var i=0;i<ZONES.length;i++){if(p>=ZONES[i].from&&p<ZONES[i].to)return ZONES[i];}return ZONES[p>=PMAX?ZONES.length-1:0];}
|
||
function setNeedle(id,p){ // rotate so up(rot0)=1010
|
||
var rot=90-angleOf(p);
|
||
document.getElementById(id).setAttribute("transform","rotate("+rot.toFixed(1)+" "+CX+" "+CY+")");
|
||
}
|
||
function highlightZone(p){
|
||
ZONES.forEach(function(z){
|
||
var on=(p>=z.from&&p<z.to);
|
||
document.getElementById("zone-"+z.label).setAttribute("opacity",on?1:.28);
|
||
var lab=document.getElementById("lab-"+z.label);
|
||
lab.setAttribute("fill",on?"#F1ECE0":"#93A3BE");
|
||
lab.setAttribute("font-weight",on?"700":"400");
|
||
});
|
||
}
|
||
|
||
function fmtSigned(x,digits){return (x>=0?"+":"")+x.toFixed(digits==null?1:digits);}
|
||
|
||
var lastHistory=[];
|
||
|
||
async function refresh(){
|
||
try{
|
||
var c=await (await fetch("/api/current")).json();
|
||
// pressure + needle + zone
|
||
var msl=c.msl;
|
||
document.getElementById("msl").textContent=msl.toFixed(0);
|
||
setNeedle("needle",msl);
|
||
highlightZone(msl);
|
||
// time
|
||
var parts=(c.time||"").split(" ");
|
||
document.getElementById("date").textContent=parts[0]||"";
|
||
document.getElementById("time").textContent=(parts[1]||"").slice(0,5);
|
||
// temp
|
||
document.getElementById("temp").textContent=c.temp.toFixed(1);
|
||
// verdict
|
||
var v=document.getElementById("verdict"), vs=document.getElementById("verdict-sub"), chip=document.getElementById("chip");
|
||
if(c.haveTrend){
|
||
v.textContent=trPhrase(c.forecast);
|
||
var col=CATCOLOR[c.category]||"#93A3BE";
|
||
chip.textContent=trCat(c.category); chip.style.color=col;
|
||
var word=c.trend>0?T("tr_rising"):(c.trend<0?T("tr_falling"):T("tr_steady"));
|
||
vs.textContent=T("verdict_pre")+word+T("verdict_post");
|
||
}else{
|
||
v.textContent=T("warm");
|
||
chip.textContent=T("warm_chip"); chip.style.color="#93A3BE";
|
||
vs.textContent=T("warm_sub");
|
||
}
|
||
document.getElementById("chip").setAttribute("aria-label",c.category||"");
|
||
// absolute already implied; keep footer clean
|
||
}catch(e){}
|
||
await drawChart();
|
||
await drawLog();
|
||
}
|
||
|
||
function fmtTime(ep){var d=new Date(ep*1000);function p(x){return(x<10?"0":"")+x;}return p(d.getUTCHours())+":"+p(d.getUTCMinutes());}
|
||
function fmtStamp(ep){var d=new Date(ep*1000);function p(x){return(x<10?"0":"")+x;}return p(d.getUTCMonth()+1)+"-"+p(d.getUTCDate())+" "+p(d.getUTCHours())+":"+p(d.getUTCMinutes());}
|
||
|
||
async function drawLog(){
|
||
var data;
|
||
try{ data=await (await fetch("/api/forecasts")).json(); }catch(e){ return; }
|
||
var ol=document.getElementById("log"), cap=document.getElementById("log-cap");
|
||
ol.innerHTML="";
|
||
if(!data.length){
|
||
var li=document.createElement("li"); li.className="empty";
|
||
li.textContent=T("log_empty");
|
||
ol.appendChild(li); cap.textContent=""; return;
|
||
}
|
||
data.forEach(function(e){
|
||
var li=document.createElement("li");
|
||
var dot=document.createElement("span"); dot.className="dot";
|
||
dot.style.background=CATCOLOR[e.cat]||"#93A3BE"; li.appendChild(dot);
|
||
var lt=document.createElement("div"); lt.className="lt";
|
||
var b=document.createElement("b"); b.textContent=trPhrase(e.text); lt.appendChild(b);
|
||
var lc=document.createElement("div"); lc.className="lc"; lc.textContent=trCat(e.cat); lt.appendChild(lc);
|
||
li.appendChild(lt);
|
||
var tm=document.createElement("time"); tm.textContent=fmtStamp(e.t); li.appendChild(tm);
|
||
ol.appendChild(li);
|
||
});
|
||
cap.textContent=T("log_cap")(data.length);
|
||
}
|
||
function fillAxis(id,mx,mn,deg){
|
||
var box=document.getElementById(id); box.innerHTML="";
|
||
[mx,(mx+mn)/2,mn].forEach(function(v){
|
||
var s=document.createElement("span");
|
||
s.textContent=deg?(Math.round(v)+"°"):Math.round(v);
|
||
box.appendChild(s);
|
||
});
|
||
}
|
||
async function drawChart(){
|
||
var data;
|
||
try{ data=await (await fetch("/api/history")).json(); }catch(e){ return; }
|
||
lastHistory=data||[];
|
||
var svg=document.getElementById("chart"), W=640,H=200,pad=6;
|
||
while(svg.firstChild) svg.removeChild(svg.firstChild);
|
||
if(!data.length){
|
||
document.getElementById("hint").textContent=T("collecting");
|
||
document.getElementById("axL").innerHTML=""; document.getElementById("axR").innerHTML="";
|
||
document.getElementById("axT0").textContent="—"; document.getElementById("axT1").textContent="—";
|
||
updateSetHand(); return;
|
||
}
|
||
var ps=data.map(function(d){return d.msl;}), ts=data.map(function(d){return d.temp;}), n=data.length;
|
||
var pmn=Math.min.apply(null,ps), pmx=Math.max.apply(null,ps);
|
||
var tmn=Math.min.apply(null,ts), tmx=Math.max.apply(null,ts);
|
||
[0,0.5,1].forEach(function(f){var y=(pad+(H-2*pad)*f).toFixed(1);
|
||
svg.appendChild(el("line",{x1:0,y1:y,x2:W,y2:y,stroke:"#22314F","stroke-width":1}));});
|
||
function line(vals,mn,mx,color,fill){
|
||
var rng=(mx-mn)||1,d="";
|
||
for(var i=0;i<n;i++){var x=pad+(W-2*pad)*(n>1?i/(n-1):0);
|
||
var y=H-pad-(H-2*pad)*((vals[i]-mn)/rng); d+=(i?"L":"M")+x.toFixed(1)+" "+y.toFixed(1);}
|
||
if(fill){var area=d+"L"+(W-pad)+" "+(H-pad)+"L"+pad+" "+(H-pad)+"Z";
|
||
svg.appendChild(el("path",{d:area,fill:color,opacity:.10}));}
|
||
svg.appendChild(el("path",{d:d,fill:"none",stroke:color,"stroke-width":2,"stroke-linejoin":"round"}));
|
||
}
|
||
line(ps,pmn,pmx,"#79C2D0",true);
|
||
line(ts,tmn,tmx,"#E4B658",false);
|
||
fillAxis("axL",pmx,pmn,false);
|
||
fillAxis("axR",tmx,tmn,true);
|
||
document.getElementById("axT0").textContent=fmtTime(data[0].t);
|
||
document.getElementById("axT1").textContent=fmtTime(data[n-1].t);
|
||
var hrs=((data[n-1].t-data[0].t)/3600);
|
||
document.getElementById("hint").textContent=T("hint")(n, hrs.toFixed(1));
|
||
updateSetHand();
|
||
}
|
||
|
||
// Ghost "set hand": pressure ~3 h ago, and the 3-hour move readout.
|
||
function updateSetHand(){
|
||
var sh=document.getElementById("sethand");
|
||
var d=lastHistory;
|
||
if(d.length<2){ sh.setAttribute("opacity",0); return; }
|
||
var latest=d[d.length-1], target=latest.t-10800, past=null;
|
||
for(var i=0;i<d.length;i++){ if(d[i].t<=target) past=d[i]; }
|
||
if(!past || (latest.t-past.t)<9000){ sh.setAttribute("opacity",0);
|
||
document.getElementById("delta").textContent="—";
|
||
document.getElementById("deltas").textContent=T("need3"); return; }
|
||
setNeedle("sethand",past.msl);
|
||
sh.setAttribute("opacity",.7);
|
||
var diff=latest.msl-past.msl;
|
||
document.getElementById("delta").textContent=fmtSigned(diff,1);
|
||
var word=diff>1.6?T("mv_rising"):(diff<-1.6?T("mv_falling"):T("mv_steady"));
|
||
document.getElementById("deltas").textContent=T("mv_unit")+word;
|
||
}
|
||
|
||
function tzLabel(min){var s=min<0?"-":"+";var a=Math.abs(min);var h=Math.floor(a/60);var m=a%60;return "UTC"+s+h+(m?(":"+(m<10?"0":"")+m):"");}
|
||
function buildTz(){var sel=document.getElementById("s-tz");for(var m=-720;m<=840;m+=30){var o=document.createElement("option");o.value=m;o.textContent=tzLabel(m);sel.appendChild(o);}}
|
||
|
||
// City presets: approximate average elevation (m), coordinates and UTC offset (min).
|
||
var CITIES=[
|
||
{n:"Astana", alt:347, lat:51.1694, lon:71.4491, tz:300},
|
||
{n:"Karaganda", alt:553, lat:49.8047, lon:73.1094, tz:300},
|
||
{n:"Almaty", alt:848, lat:43.2220, lon:76.8512, tz:300},
|
||
{n:"Bishkek", alt:800, lat:42.8746, lon:74.5698, tz:360},
|
||
{n:"Tashkent", alt:455, lat:41.2995, lon:69.2401, tz:300},
|
||
{n:"Yekaterinburg", alt:237, lat:56.8389, lon:60.6057, tz:300},
|
||
{n:"Omsk", alt:87, lat:54.9885, lon:73.3242, tz:360},
|
||
{n:"Novosibirsk", alt:150, lat:55.0084, lon:82.9357, tz:420},
|
||
{n:"Krasnoyarsk", alt:137, lat:56.0153, lon:92.8932, tz:420},
|
||
{n:"Moscow", alt:156, lat:55.7558, lon:37.6173, tz:180},
|
||
{n:"Saint Petersburg",alt:3, lat:59.9311, lon:30.3609, tz:180}
|
||
];
|
||
function setTz(min){
|
||
var tz=document.getElementById("s-tz"); tz.value=String(min);
|
||
if(parseInt(tz.value,10)!==min){var o=document.createElement("option");o.value=min;o.textContent=tzLabel(min);tz.appendChild(o);tz.value=String(min);}
|
||
}
|
||
function buildCities(){
|
||
var sel=document.getElementById("s-city");
|
||
var o0=document.createElement("option"); o0.value="-1"; o0.textContent=T("custom"); sel.appendChild(o0);
|
||
CITIES.forEach(function(c,i){
|
||
var o=document.createElement("option"); o.value=i;
|
||
o.textContent=c.n+" · "+c.alt+" m · "+tzLabel(c.tz); sel.appendChild(o);
|
||
});
|
||
sel.addEventListener("change",function(){
|
||
var i=parseInt(sel.value,10); if(i<0) return;
|
||
var c=CITIES[i];
|
||
document.getElementById("s-alt").value=c.alt;
|
||
document.getElementById("s-lat").value=c.lat;
|
||
document.getElementById("s-lon").value=c.lon;
|
||
setTz(c.tz);
|
||
});
|
||
}
|
||
|
||
async function loadSettings(){
|
||
try{
|
||
var s=await (await fetch("/api/settings")).json();
|
||
document.getElementById("s-alt").value=s.altitude;
|
||
setTz(s.tz);
|
||
document.getElementById("s-lat").value=s.lat;
|
||
document.getElementById("s-lon").value=s.lon;
|
||
document.getElementById("coords").innerHTML=Number(s.lat).toFixed(2)+"°N, "+Number(s.lon).toFixed(2)+"°E";
|
||
// Auto-select a matching city preset, else "Custom".
|
||
var ci=-1;
|
||
for(var k=0;k<CITIES.length;k++){ if(Math.abs(CITIES[k].lat-s.lat)<0.02 && Math.abs(CITIES[k].lon-s.lon)<0.02){ ci=k; break; } }
|
||
document.getElementById("s-city").value=String(ci);
|
||
}catch(e){}
|
||
}
|
||
|
||
document.getElementById("save").addEventListener("click", async function(){
|
||
var body={
|
||
altitude:parseFloat(document.getElementById("s-alt").value),
|
||
tz:parseInt(document.getElementById("s-tz").value),
|
||
lat:parseFloat(document.getElementById("s-lat").value),
|
||
lon:parseFloat(document.getElementById("s-lon").value)
|
||
};
|
||
var msg=document.getElementById("saved");
|
||
try{
|
||
var r=await (await fetch("/api/settings",{method:"POST",body:JSON.stringify(body)})).json();
|
||
msg.textContent=r.ok?T("saved_ok"):T("saved_err");
|
||
msg.style.color=r.ok?"var(--fine)":"var(--storm)";
|
||
if(r.ok){ loadSettings(); refresh(); }
|
||
}catch(e){ msg.textContent=T("saved_net"); msg.style.color="var(--storm)"; }
|
||
setTimeout(function(){msg.textContent="";},4000);
|
||
});
|
||
|
||
document.querySelectorAll("#lang button").forEach(function(b){
|
||
b.addEventListener("click",function(){ setLang(b.getAttribute("data-lang")); });
|
||
});
|
||
buildGauge();
|
||
buildTz();
|
||
buildCities();
|
||
applyLang();
|
||
loadSettings();
|
||
refresh();
|
||
setInterval(refresh,15000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
)HTML";
|