Merge dev into master: RU/EN web interface + scrollable forecast log

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 11:27:09 +07:00
co-authored by Claude Opus 4.8
+135 -38
View File
@@ -39,6 +39,11 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
}
.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);
@@ -153,9 +158,10 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<div class="wrap">
<header>
<p class="eyebrow">Barometric Forecaster</p>
<h1>Weather&nbsp;Predictor</h1>
<p class="lede">Reading the sky by pressure alone, the <b>Zambretti</b> way station at
<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&deg;N, 82.87&deg;E</span>.</p>
</header>
@@ -164,7 +170,7 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<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">hPa&nbsp;·&nbsp;sea level</span>
<span class="unit" data-i18n="unit_msl">hPa&nbsp;·&nbsp;sea level</span>
<span class="chip" id="chip" style="color:var(--muted)">&mdash;</span>
</div>
<p class="verdict" id="verdict">Reading the sky&hellip;</p>
@@ -173,18 +179,18 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<!-- Stats -->
<section class="panel stats">
<div class="stat"><div class="k">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">&mdash;</div></div>
<div class="stat"><div class="k">Temperature</div><div class="v" id="temp">--</div><div class="s">degrees celsius</div></div>
<div class="stat"><div class="k">3-hour move</div><div class="v" id="delta">&mdash;</div><div class="s" id="deltas">&mdash;</div></div>
<div class="stat"><div class="k" data-i18n="k_time">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">&mdash;</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">&mdash;</div><div class="s" id="deltas">&mdash;</div></div>
</section>
<!-- Barograph -->
<section class="panel panel-pad">
<div class="head">
<h2>Barograph</h2>
<h2 data-i18n="barograph">Barograph</h2>
<div class="legend">
<span><i style="background:var(--change)"></i>Pressure</span>
<span><i style="background:var(--fair)"></i>Temperature</span>
<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">
@@ -199,7 +205,7 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<!-- Forecast log -->
<section class="panel panel-pad">
<div class="head">
<h2>Forecast log</h2>
<h2 data-i18n="flog">Forecast log</h2>
<span class="cap" id="log-cap"></span>
</div>
<ol class="log" id="log"></ol>
@@ -207,27 +213,27 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<!-- Settings -->
<details class="panel set">
<summary>Station settings <span class="caret">Adjust</span></summary>
<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>City preset</label>
<label data-i18n="l_city">City preset</label>
<select id="s-city"></select>
</div>
<div class="grid2" style="margin-top:14px">
<div><label>Altitude &middot; metres</label><input id="s-alt" type="number" step="1"></div>
<div><label>Time zone</label><select id="s-tz"></select></div>
<div><label>Latitude</label><input id="s-lat" type="number" step="0.0001"></div>
<div><label>Longitude</label><input id="s-lon" type="number" step="0.0001"></div>
<div><label data-i18n="l_alt">Altitude &middot; 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">Save settings</button>
<button id="save" data-i18n="save">Save settings</button>
<span class="saved" id="saved"></span>
</div>
</div>
</details>
<footer>Served from the station &middot; works without internet</footer>
<footer data-i18n="footer">Served from the station &middot; works without internet</footer>
</div>
<script>
@@ -242,6 +248,93 @@ var ZONES=[
];
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;}
@@ -329,15 +422,15 @@ async function refresh(){
// verdict
var v=document.getElementById("verdict"), vs=document.getElementById("verdict-sub"), chip=document.getElementById("chip");
if(c.haveTrend){
v.textContent=c.forecast;
v.textContent=trPhrase(c.forecast);
var col=CATCOLOR[c.category]||"#93A3BE";
chip.textContent=c.category; chip.style.color=col;
var word=c.trend>0?"pressure rising":(c.trend<0?"pressure falling":"pressure steady");
vs.textContent="Zambretti reading — "+word+" over the last three hours.";
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="Reading the sky…";
chip.textContent="warming up"; chip.style.color="#93A3BE";
vs.textContent="The forecaster needs about three hours of pressure history before its first call.";
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
@@ -356,7 +449,7 @@ async function drawLog(){
ol.innerHTML="";
if(!data.length){
var li=document.createElement("li"); li.className="empty";
li.textContent="No forecasts yet — the first call appears after about three hours.";
li.textContent=T("log_empty");
ol.appendChild(li); cap.textContent=""; return;
}
data.forEach(function(e){
@@ -364,13 +457,13 @@ async function drawLog(){
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=e.text; lt.appendChild(b);
var lc=document.createElement("div"); lc.className="lc"; lc.textContent=e.cat; lt.appendChild(lc);
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=data.length+(data.length>1?" changes":" change");
cap.textContent=T("log_cap")(data.length);
}
function fillAxis(id,mx,mn,deg){
var box=document.getElementById(id); box.innerHTML="";
@@ -387,7 +480,7 @@ async function drawChart(){
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="Collecting data…";
document.getElementById("hint").textContent=T("collecting");
document.getElementById("axL").innerHTML=""; document.getElementById("axR").innerHTML="";
document.getElementById("axT0").textContent=""; document.getElementById("axT1").textContent="";
updateSetHand(); return;
@@ -412,7 +505,7 @@ async function drawChart(){
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=n+" readings over "+hrs.toFixed(1)+" h · pressure in hPa, temperature in °C";
document.getElementById("hint").textContent=T("hint")(n, hrs.toFixed(1));
updateSetHand();
}
@@ -425,13 +518,13 @@ function updateSetHand(){
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="need 3 h of data"; return; }
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?"rising":(diff<-1.6?"falling":"steady");
document.getElementById("deltas").textContent="hPa · "+word;
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):"");}
@@ -457,7 +550,7 @@ function setTz(min){
}
function buildCities(){
var sel=document.getElementById("s-city");
var o0=document.createElement("option"); o0.value="-1"; o0.textContent="Custom / manual entry"; sel.appendChild(o0);
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);
@@ -497,16 +590,20 @@ document.getElementById("save").addEventListener("click", async function(){
var msg=document.getElementById("saved");
try{
var r=await (await fetch("/api/settings",{method:"POST",body:JSON.stringify(body)})).json();
msg.textContent=r.ok?"Saved.":"Could not save.";
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="Could not reach the station."; msg.style.color="var(--storm)"; }
}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);