Files
oskarvitaliiandClaude Opus 4.8 98e8170b8b feat(web): RU/EN language toggle (Russian default)
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>
2026-07-25 11:15:26 +07:00

614 lines
32 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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&deg;N, 82.87&deg;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&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>
<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">&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 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">&mdash;</span><span id="axT1">&mdash;</span></div>
<p class="hint" id="hint">Collecting data&hellip;</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 &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" data-i18n="save">Save settings</button>
<span class="saved" id="saved"></span>
</div>
</div>
</details>
<footer data-i18n="footer">Served from the station &middot; 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)+"&deg;N, "+Number(s.lon).toFixed(2)+"&deg;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";