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>
390 lines
17 KiB
C++
390 lines
17 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}
|
||
|
||
.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 Predictor</h1>
|
||
<p class="lede">Reading the sky by pressure alone, the <b>Zambretti</b> way — station at
|
||
<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">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">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">—</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">—</div><div class="s" id="deltas">—</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…</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 · metres</label><input id="s-alt" type="number" step="1"></div>
|
||
<div><label>Time zone · 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 · 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)+"°N, "+Number(s.lon).toFixed(2)+"°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";
|