Files
Arduino/WeatherPredictor/web_page.h
T
oskarvitaliiandClaude Opus 4.8 26a5071e86 feat(web): redesign UI as a barometer instrument
Dark dusk-indigo panel with brass/enamel accents, serif almanac
headings + monospace instrument readouts. Signature element: an
antique barometer dial with a live needle and a ghost 'set hand' at
the pressure 3h ago -- the trend made physical. Barograph, stat tiles
and settings kept quiet. Fully self-contained (no external fonts/CDNs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:42:34 +07:00

390 lines
17 KiB
C++
Raw 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}
.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}
#chart{width:100%; height:200px}
.hint{color:var(--muted); font-size:.78rem; margin:10px 0 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{
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;
}
input: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>
<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
<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">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">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>
</section>
<!-- Barograph -->
<section class="panel panel-pad">
<div class="head">
<h2>Barograph</h2>
<div class="legend">
<span><i style="background:var(--change)"></i>Pressure</span>
<span><i style="background:var(--fair)"></i>Temperature</span>
</div>
</div>
<svg id="chart" viewBox="0 0 640 200" preserveAspectRatio="none" aria-label="Pressure and temperature history"></svg>
<p class="hint" id="hint">Collecting data&hellip;</p>
</section>
<!-- Settings -->
<details class="panel set">
<summary>Station settings <span class="caret">Adjust</span></summary>
<hr class="rule">
<div class="form">
<div class="grid2" style="margin-top:18px">
<div><label>Altitude &middot; metres</label><input id="s-alt" type="number" step="1"></div>
<div><label>Time zone &middot; minutes from UTC</label><input id="s-tz" type="number" step="15"></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>
<div class="actions">
<button id="save">Save settings</button>
<span class="saved" id="saved"></span>
</div>
</div>
</details>
<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"};
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=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.";
}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.";
}
document.getElementById("chip").setAttribute("aria-label",c.category||"");
// absolute already implied; keep footer clean
}catch(e){}
await drawChart();
}
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="Collecting data…"; updateSetHand(); return; }
var ps=data.map(function(d){return d.msl;}), ts=data.map(function(d){return d.temp;}), n=data.length;
function line(vals,color,fillId){
var mn=Math.min.apply(null,vals), mx=Math.max.apply(null,vals), 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(fillId){
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"}));
}
// baseline
svg.appendChild(el("line",{x1:0,y1:H-1,x2:W,y2:H-1,stroke:"#2A3A5A","stroke-width":1}));
line(ps,"#79C2D0",true);
line(ts,"#E4B658",false);
var hrs=((data[n-1].t-data[0].t)/3600);
document.getElementById("hint").textContent=n+" readings over "+hrs.toFixed(1)+" h · "+
Math.min.apply(null,ps).toFixed(0)+""+Math.max.apply(null,ps).toFixed(0)+" hPa";
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="need 3 h of data"; 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;
}
async function loadSettings(){
try{
var s=await (await fetch("/api/settings")).json();
document.getElementById("s-alt").value=s.altitude;
document.getElementById("s-tz").value=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";
}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?"Saved.":"Could not save.";
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)"; }
setTimeout(function(){msg.textContent="";},4000);
});
buildGauge();
loadSettings();
refresh();
setInterval(refresh,15000);
</script>
</body>
</html>
)HTML";