atmos / dashboard_frontend /index.html
Edge001's picture
Upload folder using huggingface_hub
c2a61b6 verified
Raw
History Blame Contribute Delete
38.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATMOS β€” ERA5 Climate Downscaling Β· India</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;900&family=Fira+Code:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
/* ── Reset & Root ─────────────────────────────────────────────────────── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080f1a;
--bg2:#0b1422;
--glass:rgba(11,20,34,.72);
--glass2:rgba(15,26,45,.85);
--border:rgba(255,255,255,.06);
--border2:rgba(255,255,255,.1);
--teal:#00e5cc;
--teal2:rgba(0,229,204,.12);
--gold:#f5a623;
--gold2:rgba(245,166,35,.12);
--jade:#00f5a0;
--jade2:rgba(0,245,160,.1);
--rose:#ff5f7e;
--violet:#b57aff;
--txt:#c8daf0;
--txt2:#7a98bc;
--txt3:#3d5470;
--display:'Outfit',sans-serif;
--mono:'Fira Code',monospace;
--r:6px;
}
html,body{height:100%;background:var(--bg);color:var(--txt);font-family:var(--display);overflow:hidden}
/* Noise grain overlay */
body::before{
content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
opacity:.022;mix-blend-mode:overlay;
}
/* Subtle grid bg */
body::after{
content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
background-image:
linear-gradient(rgba(0,229,204,.03) 1px,transparent 1px),
linear-gradient(90deg,rgba(0,229,204,.03) 1px,transparent 1px);
background-size:40px 40px;
}
#app{display:grid;grid-template-rows:58px 1fr 168px;height:100vh;position:relative;z-index:1}
/* ── Header ───────────────────────────────────────────────────────────── */
#hdr{
display:flex;align-items:center;padding:0 24px;gap:0;
background:var(--glass2);
border-bottom:1px solid var(--border);
backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
position:relative;flex-shrink:0;z-index:100;
}
/* Gradient accent line at bottom of header */
#hdr::after{
content:'';position:absolute;bottom:-1px;left:0;right:0;height:1px;
background:linear-gradient(90deg,transparent,var(--teal),var(--gold),var(--jade),transparent);
opacity:.5;
}
/* Logo mark */
#logo{display:flex;align-items:center;gap:10px;margin-right:28px}
#logo-mark{
width:30px;height:30px;border-radius:8px;
background:linear-gradient(135deg,var(--teal),var(--jade));
display:flex;align-items:center;justify-content:center;
font-family:var(--mono);font-size:10px;font-weight:500;color:#000;
letter-spacing:-.02em;flex-shrink:0;
}
#logo-text{display:flex;flex-direction:column;gap:1px}
#logo-name{font-family:var(--display);font-size:15px;font-weight:700;color:#fff;letter-spacing:-.02em;line-height:1}
#logo-sub{font-family:var(--mono);font-size:8px;color:var(--txt3);letter-spacing:.08em;text-transform:uppercase;line-height:1}
/* Divider */
.hdr-div{width:1px;height:28px;background:var(--border2);margin:0 20px;flex-shrink:0}
/* Badges */
.chip{
display:flex;align-items:center;gap:5px;padding:4px 10px;
border-radius:20px;font-family:var(--mono);font-size:8px;font-weight:500;
letter-spacing:.06em;text-transform:uppercase;white-space:nowrap;
border:1px solid;flex-shrink:0;
}
.chip-teal{background:var(--teal2);border-color:rgba(0,229,204,.25);color:var(--teal)}
.chip-gold{background:var(--gold2);border-color:rgba(245,166,35,.25);color:var(--gold)}
.chip-jade{background:var(--jade2);border-color:rgba(0,245,160,.25);color:var(--jade)}
.chip-violet{background:rgba(181,122,255,.1);border-color:rgba(181,122,255,.25);color:var(--violet)}
.chip-rose{background:rgba(255,95,126,.08);border-color:rgba(255,95,126,.22);color:var(--rose)}
.chip-dot{width:5px;height:5px;border-radius:50%;background:currentColor;flex-shrink:0}
/* Status area */
#hdr-status{margin-left:auto;display:flex;align-items:center;gap:16px;flex-shrink:0}
#live-clock{font-family:var(--mono);font-size:10px;color:var(--txt2);
display:flex;flex-direction:column;align-items:flex-end;gap:1px;line-height:1}
#hdr-date{font-size:9px;color:var(--txt3)}
#hdr-season{font-size:9px;letter-spacing:.06em;text-transform:uppercase}
.status-pill{
display:flex;align-items:center;gap:7px;padding:5px 12px;
background:var(--glass);border:1px solid var(--border2);border-radius:20px;
}
.status-light{width:6px;height:6px;border-radius:50%;flex-shrink:0;transition:all .4s}
.status-light.amber{background:#f5a623;box-shadow:0 0 8px #f5a623}
.status-light.green{background:var(--jade);box-shadow:0 0 8px var(--jade);animation:pulse 2.5s infinite}
.status-light.red{background:var(--rose);box-shadow:0 0 8px var(--rose)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
#status-text{font-family:var(--mono);font-size:9px;color:var(--txt2)}
/* ── Cache Progress Bar ───────────────────────────────────────────────── */
#cache-bar-wrap{
display:flex;align-items:center;gap:8px;padding:3px 14px;
border-top:1px solid var(--border);background:rgba(8,15,26,.9);
position:absolute;bottom:0;left:0;right:0;z-index:200;
font-family:var(--mono);font-size:7px;color:var(--txt3);
transition:opacity .3s;
}
#cache-bar-wrap.hidden{opacity:0;pointer-events:none}
#cache-progress-bar{flex:1;height:2px;background:rgba(255,255,255,.06);border-radius:1px}
#cache-progress-fill{height:100%;border-radius:1px;background:linear-gradient(90deg,var(--teal),var(--jade));transition:width .5s;width:0%}
#cache-bar-label{white-space:nowrap;color:var(--txt3)}
#btn-build-cache{
font-family:var(--mono);font-size:7px;font-weight:600;letter-spacing:.08em;
padding:3px 9px;border-radius:10px;cursor:pointer;text-transform:uppercase;
background:rgba(0,229,204,.08);border:1px solid rgba(0,229,204,.25);color:var(--teal);
transition:all .15s;flex-shrink:0;
}
#btn-build-cache:hover{background:rgba(0,229,204,.18)}
#btn-build-cache:disabled{opacity:.35;cursor:not-allowed}
#ram-badge{
font-family:var(--mono);font-size:7px;padding:2px 7px;border-radius:8px;
border:1px solid var(--border);color:var(--txt3);white-space:nowrap;flex-shrink:0;
}
/* ── Maps ─────────────────────────────────────────────────────────────── */
#maps{display:grid;grid-template-columns:1fr 1fr;position:relative;overflow:hidden;padding-bottom:24px}
.map-wrap{position:relative;overflow:hidden}
/* Thin separator between maps */
.map-wrap:first-child{border-right:1px solid var(--border2)}
/* Frosted glass map labels */
.map-lbl{
position:absolute;top:16px;left:50%;transform:translateX(-50%);z-index:900;
font-family:var(--mono);font-size:9px;font-weight:500;letter-spacing:.1em;
padding:5px 14px;pointer-events:none;text-transform:uppercase;
background:rgba(8,15,26,.78);backdrop-filter:blur(12px);
border:1px solid;border-radius:20px;white-space:nowrap;
}
.lbl-lr{border-color:rgba(245,166,35,.3);color:var(--gold)}
.lbl-pred{border-color:rgba(0,229,204,.3);color:var(--teal)}
.map-res{
position:absolute;bottom:14px;left:50%;transform:translateX(-50%);z-index:900;
font-family:var(--mono);font-size:8px;color:var(--txt3);
background:rgba(8,15,26,.78);backdrop-filter:blur(8px);
padding:3px 10px;border-radius:12px;border:1px solid var(--border);white-space:nowrap;
}
#map-lr,#map-pred{width:100%;height:100%}
.leaflet-container{background:#050d18!important}
.leaflet-control-attribution{display:none!important}
/* Uncertainty button β€” elegant toggle */
#unc-btn{
position:absolute;bottom:48px;right:14px;z-index:900;
font-family:var(--mono);font-size:8px;font-weight:500;letter-spacing:.08em;
padding:5px 12px;border-radius:16px;cursor:pointer;text-transform:uppercase;
background:rgba(8,15,26,.78);backdrop-filter:blur(12px);
border:1px solid rgba(181,122,255,.3);color:var(--violet);
transition:all .2s;
}
#unc-btn:hover{background:rgba(181,122,255,.12);border-color:rgba(181,122,255,.5)}
#unc-btn.active{background:rgba(181,122,255,.18);border-color:var(--violet);color:#fff}
#unc-info{
position:absolute;top:52px;right:14px;z-index:901;display:none;
font-family:var(--mono);font-size:8px;
background:rgba(8,15,26,.92);backdrop-filter:blur(16px);
border:1px solid rgba(181,122,255,.3);color:var(--violet);
padding:8px 12px;border-radius:var(--r);line-height:1.9;
}
/* Loading overlay */
#spinner{
position:absolute;inset:0;display:none;align-items:center;justify-content:center;
background:rgba(8,15,26,.65);backdrop-filter:blur(4px);z-index:2000;
flex-direction:column;gap:12px;
}
#spinner.show{display:flex}
.spin-ring{
width:36px;height:36px;border-radius:50%;
border:1.5px solid rgba(0,229,204,.15);
border-top-color:var(--teal);
animation:rot .8s linear infinite;
}
@keyframes rot{to{transform:rotate(360deg)}}
.spin-txt{font-family:var(--mono);font-size:8px;color:var(--teal);letter-spacing:.1em;text-transform:uppercase}
/* Point tooltip */
#pt-tip{
position:fixed;display:none;
background:rgba(8,15,26,.95);backdrop-filter:blur(20px);
border:1px solid var(--border2);border-radius:var(--r);
padding:12px 16px;font-family:var(--mono);font-size:10px;line-height:1.8;
color:var(--txt);z-index:3000;pointer-events:none;max-width:200px;
box-shadow:0 8px 32px rgba(0,0,0,.6);
}
.pt-coord{color:var(--teal);font-weight:500;font-size:9px;letter-spacing:.04em}
.pt-row{display:flex;justify-content:space-between;gap:16px}
.pt-lbl{color:var(--txt3)}
.pt-val-gold{color:var(--gold);font-weight:500}
.pt-val-jade{color:var(--jade);font-weight:500}
/* ── Bottom Panel ─────────────────────────────────────────────────────── */
#bottom{
display:grid;
grid-template-columns:200px 1fr 1fr 1fr 1fr 1fr;
gap:1px;background:var(--border);
border-top:1px solid var(--border);flex-shrink:0;overflow:hidden;
}
.panel-cell{background:var(--bg2);overflow:hidden;min-width:0;display:flex;flex-direction:column;justify-content:center}
/* Controls cell */
#ctrl-cell{padding:12px 16px;gap:10px}
.ctrl-row{display:flex;align-items:center;gap:8px;width:100%}
.ctrl-lbl{font-family:var(--mono);font-size:7px;color:var(--txt3);letter-spacing:.1em;
text-transform:uppercase;white-space:nowrap;width:48px;flex-shrink:0}
/* Play button β€” circle */
#btn-play{
width:32px;height:32px;border-radius:50%;border:1.5px solid var(--teal);
background:rgba(0,229,204,.08);color:var(--teal);cursor:pointer;
display:flex;align-items:center;justify-content:center;flex-shrink:0;
transition:all .2s;font-size:10px;
}
#btn-play:hover{background:rgba(0,229,204,.18);box-shadow:0 0 12px rgba(0,229,204,.2)}
#btn-play.playing{border-color:var(--rose);color:var(--rose);background:rgba(255,95,126,.08)}
#btn-play.playing:hover{background:rgba(255,95,126,.18)}
#speed-sel{
background:rgba(255,255,255,.04);border:1px solid var(--border2);
color:var(--txt2);padding:3px 6px;font-family:var(--mono);font-size:8px;
cursor:pointer;border-radius:4px;outline:none;
}
#t-val{font-family:var(--mono);font-size:9px;color:var(--txt3);margin-left:auto;white-space:nowrap}
/* Custom sliders */
input[type=range]{
-webkit-appearance:none;appearance:none;width:100%;height:2px;
background:rgba(255,255,255,.08);border-radius:1px;outline:none;cursor:pointer;
flex:1;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:12px;height:12px;border-radius:50%;
background:var(--teal);border:none;cursor:pointer;
box-shadow:0 0 8px rgba(0,229,204,.4);transition:transform .15s;
}
input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.3)}
input#op-slider::-webkit-slider-thumb{background:var(--txt2);box-shadow:none}
/* Stat cells */
.stat-cell{padding:14px 18px;justify-content:center}
.stat-label{font-family:var(--mono);font-size:7px;color:var(--txt3);letter-spacing:.12em;
text-transform:uppercase;margin-bottom:4px;line-height:1}
.stat-number{font-family:var(--display);font-size:26px;font-weight:600;line-height:1;
letter-spacing:-.02em;transition:color .3s}
.stat-unit{font-size:11px;font-weight:400;color:var(--txt3);margin-left:2px}
.stat-sub{font-family:var(--mono);font-size:8px;color:var(--txt3);margin-top:3px;line-height:1}
.c-teal{color:var(--teal)}.c-gold{color:var(--gold)}.c-jade{color:var(--jade)}
.c-violet{color:var(--violet)}.c-rose{color:var(--rose)}
/* Thin top accent line on stat cells */
.stat-cell::before{
content:'';display:block;height:1.5px;width:28px;
border-radius:1px;margin-bottom:10px;flex-shrink:0;
}
.stat-cell.acc-gold::before{background:var(--gold)}
.stat-cell.acc-jade::before{background:var(--jade)}
.stat-cell.acc-violet::before{background:var(--violet)}
/* Chart cells */
.chart-cell{padding:0;position:relative;background:var(--bg2)}
.chart-label{
position:absolute;top:8px;left:12px;z-index:2;pointer-events:none;
font-family:var(--mono);font-size:7px;color:var(--txt3);letter-spacing:.1em;text-transform:uppercase;
}
.chart-cell canvas{width:100%;height:100%;display:block}
</style>
</head>
<body>
<div id="app">
<!-- ═══ HEADER ═══════════════════════════════════════════════════════════ -->
<header id="hdr">
<div id="logo">
<div id="logo-mark">AI</div>
<div id="logo-text">
<div id="logo-name">ATMOS</div>
<div id="logo-sub">Climate Downscaling System</div>
</div>
</div>
<div class="hdr-div"></div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span class="chip chip-gold"><span class="chip-dot"></span>ERA5 Β· 0.25Β° Β· 28 km/px</span>
<span class="chip chip-teal"><span class="chip-dot"></span>AI Β· 0.0625Β° Β· 7 km/px Β· 4Γ—SR</span>
<span class="chip chip-jade">Swin2SR Ensemble Β· 24.2M INT8</span>
<span class="chip chip-violet">TTA Uncertainty</span>
<span class="chip chip-rose">PSD Analysis</span>
</div>
<div id="hdr-status">
<div id="live-clock">
<span id="hdr-date" style="color:var(--txt2)">β€”</span>
<span id="hdr-season">β€”</span>
</div>
<div class="status-pill">
<div id="status-dot" class="status-light amber"></div>
<span id="status-text">Connecting</span>
</div>
<div class="status-pill" style="flex-direction:column;gap:4px;padding:6px 12px;min-width:140px">
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span style="font-family:var(--mono);font-size:7px;color:var(--txt3);letter-spacing:.08em;text-transform:uppercase">Cache</span>
<span id="cache-pct" style="font-family:var(--mono);font-size:8px;color:var(--teal);font-weight:500">0%</span>
</div>
<div style="width:100%;height:2px;background:rgba(255,255,255,.06);border-radius:1px">
<div id="cache-bar" style="height:2px;background:var(--teal);border-radius:1px;width:0%;transition:width .5s"></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%">
<span style="font-family:var(--mono);font-size:7px;color:var(--txt3);letter-spacing:.08em;text-transform:uppercase">RAM</span>
<span id="ram-pct" style="font-family:var(--mono);font-size:8px;color:var(--gold)">β€”</span>
</div>
<div style="width:100%;height:2px;background:rgba(255,255,255,.06);border-radius:1px">
<div id="ram-bar" style="height:2px;background:var(--gold);border-radius:1px;width:0%;transition:width .5s"></div>
</div>
</div>
</div>
</header>
<!-- ═══ MAPS ══════════════════════════════════════════════════════════════ -->
<div id="maps">
<div class="map-wrap">
<div class="map-lbl lbl-lr">ERA5 Baseline Β· 0.25Β°</div>
<div id="map-lr"></div>
<div class="map-res" id="res-lr">129 Γ— 121</div>
</div>
<div class="map-wrap">
<div class="map-lbl lbl-pred">Ensemble AI Β· 0.0625Β° Β· Mask+Phys+Elev</div>
<div id="map-pred"></div>
<div class="map-res" id="res-pred">516 Γ— 484</div>
<button id="unc-btn" onclick="toggleUncertainty()">⬑ Uncertainty</button>
<div id="unc-info"></div>
</div>
<div id="spinner">
<div class="spin-ring"></div>
<span class="spin-txt">Computing</span>
</div>
</div>
<!-- ═══ BOTTOM PANEL ══════════════════════════════════════════════════════ -->
<div id="bottom">
<!-- Controls -->
<div class="panel-cell" id="ctrl-cell">
<div class="ctrl-row">
<button id="btn-play" title="Play/Pause">β–Ά</button>
<select id="speed-sel">
<option value="2000">0.5Γ—</option>
<option value="1000" selected>1Γ—</option>
<option value="500">2Γ—</option>
<option value="200">5Γ—</option>
</select>
<span id="t-val">T: 0</span>
</div>
<div class="ctrl-row">
<span class="ctrl-lbl">Time</span>
<input type="range" id="t-slider" min="0" max="8783" value="0">
</div>
<div class="ctrl-row">
<span class="ctrl-lbl">Opacity</span>
<input type="range" id="op-slider" min="20" max="95" value="80">
</div>
</div>
<!-- Mean Temp -->
<div class="panel-cell stat-cell acc-gold">
<div class="stat-label">Mean Temperature</div>
<div class="stat-number c-gold" id="s-mean">β€”<span class="stat-unit">K</span></div>
<div class="stat-sub" id="s-mean-c">β€” Β°C</div>
</div>
<!-- Sharpness -->
<div class="panel-cell stat-cell acc-jade">
<div class="stat-label">AI Sharpness Gain</div>
<div class="stat-number c-jade" id="s-sharp">β€”</div>
<div class="stat-sub">Laplacian βˆ‡Β² ratio</div>
</div>
<!-- Uncertainty -->
<div class="panel-cell stat-cell acc-violet">
<div class="stat-label">Prediction Uncertainty</div>
<div class="stat-number c-violet" id="s-unc">β€”<span class="stat-unit">K</span></div>
<div class="stat-sub" id="s-unc-sub">TTA 8-jitter mean</div>
</div>
<!-- Annual Cycle chart -->
<div class="panel-cell chart-cell">
<div class="chart-label">Annual Cycle Β· click map</div>
<canvas id="ts-canvas"></canvas>
</div>
<!-- PSD chart -->
<div class="panel-cell chart-cell">
<div class="chart-label" style="color:rgba(255,95,126,.6)">Power Spectrum Β· ERA5 vs AI</div>
<canvas id="psd-canvas"></canvas>
</div>
</div><!-- #bottom -->
</div><!-- #app -->
<div id="pt-tip"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
/* ── Config & State ──────────────────────────────────────────────────── */
const API='/api', BOUNDS=[[6,68],[38,98]], CENTER=[22,82];
const CITIES=[
['New Delhi',28.6,77.2],['Mumbai',19.1,72.9],['Hyderabad',17.4,78.5],
['Kukatpally',17.5,78.4],['Bengaluru',12.97,77.6],['Chennai',13.1,80.3],
['Kolkata',22.6,88.4],['Jaipur',26.9,75.8],['Ahmedabad',23.0,72.6],
['Pune',18.5,73.9],['Bhopal',23.3,77.4],['Lucknow',26.8,80.9],
['Patna',25.6,85.1],['Guwahati',26.2,91.7],['Shimla',31.1,77.2],
['Srinagar',34.1,74.8],['Thiruvananthapuram',8.5,76.9],['Bhubaneswar',20.3,85.8],
];
let meta=null,tIdx=0,isPlaying=false,playTimer=null;
let overlayLR=null,overlayPred=null,overlayUnc=null,opacity=0.80,uncActive=false;
/* ── Maps ────────────────────────────────────────────────────────────── */
const TILE='https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const mapLR=L.map('map-lr',{zoomControl:false,attributionControl:false}).setView(CENTER,5);
const mapPred=L.map('map-pred',{zoomControl:false,attributionControl:false}).setView(CENTER,5);
L.tileLayer(TILE,{subdomains:'abcd',maxZoom:18}).addTo(mapLR);
L.tileLayer(TILE,{subdomains:'abcd',maxZoom:18}).addTo(mapPred);
L.control.zoom({position:'bottomright'}).addTo(mapPred);
let syncing=false;
function syncMaps(s,d){if(syncing)return;syncing=true;d.setView(s.getCenter(),s.getZoom(),{animate:false});syncing=false;}
mapLR.on('move',()=>syncMaps(mapLR,mapPred));
mapPred.on('move',()=>syncMaps(mapPred,mapLR));
/* State boundaries */
fetch('https://raw.githubusercontent.com/datameet/maps/master/States/India_States.geojson')
.then(r=>r.json()).then(gj=>{
const s={color:'rgba(255,255,255,.07)',weight:1,fillOpacity:0};
[mapLR,mapPred].forEach(m=>L.geoJSON(gj,{style:s}).addTo(m));
}).catch(()=>{});
/* City markers */
function addCities(m){
CITIES.forEach(([n,lat,lon])=>{
const k=n==='Kukatpally';
const mj=['New Delhi','Mumbai','Hyderabad','Chennai','Kolkata','Bengaluru'].includes(n);
const c=k?'#ff5f7e':mj?'#00e5cc':'#3d5470';
const r=k?5:mj?4:3;
L.circleMarker([lat,lon],{radius:r,color:c,weight:k?2:1.5,fillColor:c,fillOpacity:.9})
.bindTooltip(n,{permanent:false,direction:'top',offset:[0,-4],
className:'leaflet-tooltip-custom'}).addTo(m);
});
}
addCities(mapLR); addCities(mapPred);
/* Custom tooltip style */
const style=document.createElement('style');
style.textContent=`.leaflet-tooltip-custom{background:rgba(8,15,26,.92);border:1px solid rgba(0,229,204,.3);color:#c8daf0;font-family:'Fira Code',monospace;font-size:9px;padding:3px 8px;border-radius:4px;box-shadow:none;}`;
document.head.appendChild(style);
</script>
<script>
/* ── Canvas helpers ──────────────────────────────────────────────────── */
function setupCanvas(id){
const c=document.getElementById(id);
const W=c.offsetWidth||180,H=c.offsetHeight||148;
c.width=W*devicePixelRatio; c.height=H*devicePixelRatio;
const ctx=c.getContext('2d'); ctx.scale(devicePixelRatio,devicePixelRatio);
return {c,ctx,W,H};
}
/* Annual cycle timeseries */
function drawTimeseries(vals_C){
const {ctx,W,H}=setupCanvas('ts-canvas');
ctx.clearRect(0,0,W,H);
if(!vals_C||vals_C.length<2) return;
const pad={t:22,b:18,l:6,r:6};
const cw=W-pad.l-pad.r, ch=H-pad.t-pad.b;
const mn=Math.min(...vals_C), mx=Math.max(...vals_C), rng=mx-mn||1;
const px=i=>pad.l+i/(vals_C.length-1)*cw;
const py=v=>pad.t+ch*(1-(v-mn)/rng);
/* Season bands */
const bands=[
{s:0,e:.17,c:'rgba(0,100,255,.06)',lbl:'WIN'},
{s:.17,e:.42,c:'rgba(245,166,35,.05)',lbl:'PRE'},
{s:.42,e:.75,c:'rgba(0,200,100,.06)',lbl:'MON'},
{s:.75,e:1,c:'rgba(0,150,255,.05)',lbl:'POS'},
];
bands.forEach(({s,e,c,lbl})=>{
ctx.fillStyle=c; ctx.fillRect(pad.l+s*cw,pad.t,(e-s)*cw,ch);
ctx.font=`${6*devicePixelRatio}px Fira Code`; ctx.fillStyle='rgba(61,84,112,.8)';
ctx.fillText(lbl, pad.l+(s+.01)*cw, pad.t+8);
});
/* Grid lines */
ctx.setLineDash([2,4]);
[.25,.5,.75].forEach(f=>{
const y=pad.t+ch*f;
ctx.beginPath(); ctx.moveTo(pad.l,y); ctx.lineTo(pad.l+cw,y);
ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=.5; ctx.stroke();
});
ctx.setLineDash([]);
/* Area fill */
ctx.beginPath();
vals_C.forEach((v,i)=>{const x=px(i),y=py(v);i?ctx.lineTo(x,y):ctx.moveTo(x,y);});
ctx.lineTo(px(vals_C.length-1),pad.t+ch); ctx.lineTo(pad.l,pad.t+ch); ctx.closePath();
const grad=ctx.createLinearGradient(0,pad.t,0,pad.t+ch);
grad.addColorStop(0,'rgba(0,229,204,.15)'); grad.addColorStop(1,'rgba(0,229,204,.01)');
ctx.fillStyle=grad; ctx.fill();
/* Line */
ctx.beginPath();
vals_C.forEach((v,i)=>{const x=px(i),y=py(v);i?ctx.lineTo(x,y):ctx.moveTo(x,y);});
ctx.strokeStyle='rgba(0,229,204,.9)'; ctx.lineWidth=1.3; ctx.stroke();
/* Labels */
ctx.font=`${7*devicePixelRatio}px Fira Code`; ctx.fillStyle='rgba(122,152,188,.7)';
ctx.fillText(mn.toFixed(1)+'Β°C',pad.l,H-3);
ctx.textAlign='right'; ctx.fillText(mx.toFixed(1)+'Β°C',W-pad.r,H-3); ctx.textAlign='left';
}
/* PSD chart */
function drawPSD(data){
const {ctx,W,H}=setupCanvas('psd-canvas');
ctx.clearRect(0,0,W,H);
if(!data||!data.wavelengths_km) return;
const pad={t:22,b:18,l:6,r:6};
const cw=W-pad.l-pad.r, ch=H-pad.t-pad.b;
const wl=data.wavelengths_km,p0=data.psd_era5,p1=data.psd_ai;
const n=wl.length; if(n<2) return;
const allV=[...p0,...p1]; const mn=Math.min(...allV),mx=Math.max(...allV),rng=mx-mn||1;
/* x: larger wavelength on left */
const px=i=>pad.l+(1-i/(n-1))*cw;
const py=v=>pad.t+ch*(1-(v-mn)/rng);
/* Grid */
ctx.setLineDash([2,4]);
[.25,.5,.75].forEach(f=>{
const y=pad.t+ch*f;
ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(pad.l+cw,y);
ctx.strokeStyle='rgba(255,255,255,.04)';ctx.lineWidth=.5;ctx.stroke();
});
ctx.setLineDash([]);
/* Enhancement fill */
ctx.beginPath();
p1.forEach((v,i)=>{i?ctx.lineTo(px(i),py(v)):ctx.moveTo(px(i),py(v));});
for(let i=n-1;i>=0;i--) ctx.lineTo(px(i),py(p0[i]));
ctx.closePath(); ctx.fillStyle='rgba(0,229,204,.07)'; ctx.fill();
/* ERA5 line */
ctx.beginPath();
p0.forEach((v,i)=>{i?ctx.lineTo(px(i),py(v)):ctx.moveTo(px(i),py(v));});
ctx.strokeStyle='rgba(245,166,35,.55)'; ctx.lineWidth=1.2; ctx.stroke();
/* AI line */
ctx.beginPath();
p1.forEach((v,i)=>{i?ctx.lineTo(px(i),py(v)):ctx.moveTo(px(i),py(v));});
ctx.strokeStyle='rgba(0,229,204,.9)'; ctx.lineWidth=1.5; ctx.stroke();
/* Labels */
ctx.font=`${7*devicePixelRatio}px Fira Code`; ctx.fillStyle='rgba(122,152,188,.6)';
ctx.fillText('500km',pad.l,H-3); ctx.textAlign='right';
ctx.fillText('10km',W-pad.r,H-3); ctx.textAlign='left';
ctx.fillStyle='rgba(0,229,204,.75)'; ctx.fillText('AI',W-pad.r-18,pad.t+10);
ctx.fillStyle='rgba(245,166,35,.65)'; ctx.fillText('ERA5',W-pad.r-44,pad.t+10);
}
</script>
<script>
/* ── Frame Loading ───────────────────────────────────────────────────── */
const spinner=document.getElementById('spinner');
async function loadFrame(t){
tIdx=t;
document.getElementById('t-val').textContent='T:'+String(t).padStart(5,' ');
document.getElementById('hdr-date').textContent=getDateStr(t);
document.getElementById('hdr-season').textContent=getSeason(t);
if(!isPlaying) spinner.classList.add('show');
const ts=Date.now();
let done=0;
const finish=()=>{if(++done===2)spinner.classList.remove('show');};
const i1=new Image();
i1.onload=()=>{if(overlayLR)mapLR.removeLayer(overlayLR);overlayLR=L.imageOverlay(`${API}/map/lr/${t}.png?_=${ts}`,BOUNDS,{opacity,interactive:false}).addTo(mapLR);finish();};
i1.onerror=finish; i1.src=`${API}/map/lr/${t}.png?_=${ts}`;
const i2=new Image();
i2.onload=()=>{if(overlayPred)mapPred.removeLayer(overlayPred);overlayPred=L.imageOverlay(`${API}/map/pred/${t}.png?_=${ts}`,BOUNDS,{opacity,interactive:false}).addTo(mapPred);finish();};
i2.onerror=finish; i2.src=`${API}/map/pred/${t}.png?_=${ts}`;
if(uncActive) loadUncertainty(t);
fetch(`${API}/cache/status`).then(r=>r.json()).then(d=>{
const pct=d.progress_pct||0;
document.getElementById('cache-pct').textContent=d.ready?'Ready':pct.toFixed(0)+'%';
document.getElementById('cache-bar').style.width=pct+'%';
document.getElementById('cache-bar').style.background=d.ready?'var(--jade)':'var(--teal)';
document.getElementById('ram-pct').textContent=d.ram_pct+'%';
document.getElementById('ram-bar').style.width=d.ram_pct+'%';
const rbar=document.getElementById('ram-bar');
rbar.style.background=d.ram_pct>90?'var(--rose)':d.ram_pct>75?'var(--gold)':'var(--jade)';
}).catch(()=>{});
fetch(`${API}/stats/${t}`).then(r=>r.json()).then(d=>{
const K=d.mean_temp_K||0;
setNum('s-mean', K.toFixed(1), 'K');
document.getElementById('s-mean-c').textContent=(K-273.15).toFixed(1)+' Β°C';
const sg=d.sharpness_gain_pct;
setNum('s-sharp', (sg>0?'+':'')+sg+'%');
}).catch(()=>{});
fetch(`${API}/psd/${t}`).then(r=>r.json()).then(drawPSD).catch(()=>{});
}
/* Animated number update */
function setNum(id, txt, unit=''){
const el=document.getElementById(id);
el.style.opacity='.4'; el.style.transform='translateY(4px)';
el.style.transition='opacity .25s,transform .25s';
setTimeout(()=>{
el.innerHTML=txt+(unit?`<span class="stat-unit">${unit}</span>`:'');
el.style.opacity='1'; el.style.transform='translateY(0)';
},120);
}
/* ── Uncertainty ─────────────────────────────────────────────────────── */
function toggleUncertainty(){
uncActive=!uncActive;
document.getElementById('unc-btn').classList.toggle('active',uncActive);
if(uncActive) loadUncertainty(tIdx);
else{if(overlayUnc){mapPred.removeLayer(overlayUnc);overlayUnc=null;}document.getElementById('unc-info').style.display='none';}
}
function loadUncertainty(t){
const url=`${API}/uncertainty/${t}.png?_=${Date.now()}`;
const img=new Image();
img.onload=()=>{if(overlayUnc)mapPred.removeLayer(overlayUnc);overlayUnc=L.imageOverlay(url,BOUNDS,{opacity:.65,interactive:false}).addTo(mapPred);};
img.src=url;
fetch(`${API}/uncertainty/${t}/stats`).then(r=>r.json()).then(d=>{
setNum('s-unc',d.mean_uncertainty_K?.toFixed(3)||'β€”','K');
document.getElementById('s-unc-sub').textContent='p95 = '+(d.p95_uncertainty_K?.toFixed(3)||'β€”')+'K';
const info=document.getElementById('unc-info');
info.style.display='block';
info.innerHTML=`Mean ${d.mean_uncertainty_K?.toFixed(3)}K Β· Max ${d.max_uncertainty_K?.toFixed(3)}K Β· ${d.high_uncertainty_pct}% high`;
}).catch(()=>{});
}
/* ── Map click ───────────────────────────────────────────────────────── */
function handleClick(e){
const lat=e.latlng.lat.toFixed(3),lon=e.latlng.lng.toFixed(3);
const tip=document.getElementById('pt-tip');
fetch(`${API}/point/${tIdx}/${lat}/${lon}`).then(r=>r.json()).then(d=>{
tip.style.display='block';
tip.style.left=(e.originalEvent.clientX+14)+'px';
tip.style.top=(e.originalEvent.clientY-10)+'px';
tip.innerHTML=`<div class="pt-coord">${lat}Β°N ${lon}Β°E</div>
<div class="pt-row"><span class="pt-lbl">ERA5</span><span class="pt-val-gold">${d.era5_C} Β°C</span></div>
<div class="pt-row"><span class="pt-lbl">AI</span><span class="pt-val-jade">${d.pred_C} Β°C</span></div>`;
}).catch(()=>{});
fetch(`${API}/timeseries/${lat}/${lon}?step=48`).then(r=>r.json())
.then(d=>drawTimeseries(d.values_C)).catch(()=>{});
}
mapLR.on('click',handleClick);
mapPred.on('click',handleClick);
document.addEventListener('keydown',()=>document.getElementById('pt-tip').style.display='none');
/* ── Controls ────────────────────────────────────────────────────────── */
document.getElementById('t-slider').addEventListener('input',e=>{if(isPlaying)togglePlay();loadFrame(parseInt(e.target.value));});
document.getElementById('op-slider').addEventListener('input',e=>{
opacity=parseInt(e.target.value)/100;
if(overlayLR)overlayLR.setOpacity(opacity);
if(overlayPred)overlayPred.setOpacity(opacity);
});
function togglePlay(){
isPlaying=!isPlaying;
const btn=document.getElementById('btn-play');
if(isPlaying){
btn.textContent='⏸'; btn.classList.add('playing');
const spd=parseInt(document.getElementById('speed-sel').value);
playTimer=setInterval(()=>{const n=(tIdx+4)%(meta?.timesteps||8784);document.getElementById('t-slider').value=n;loadFrame(n);},spd);
} else {
btn.textContent='β–Ά'; btn.classList.remove('playing'); clearInterval(playTimer);
}
}
document.getElementById('btn-play').addEventListener('click',togglePlay);
document.getElementById('speed-sel').addEventListener('change',()=>{if(isPlaying){togglePlay();togglePlay();}});
/* ── Helpers ─────────────────────────────────────────────────────────── */
function getSeason(t){const m=Math.floor((t/8784)*12);if(m<2||m===11)return'Winter';if(m<5)return'Pre-Monsoon';if(m<9)return'Monsoon';return'Post-Monsoon';}
function getDateStr(t){const d=new Date('2020-01-01T00:00:00Z');d.setUTCHours(d.getUTCHours()+t);return d.toUTCString().slice(5,16)+' '+String(d.getUTCHours()).padStart(2,'0')+':00 UTC';}
/* ── Init ────────────────────────────────────────────────────────────── */
let _retryTimer = null;
async function init(){
const dot=document.getElementById('status-dot');
const stxt=document.getElementById('status-text');
try{
const r=await fetch(`${API}/meta`); meta=await r.json();
if(meta.output_shape) document.getElementById('res-pred').textContent=meta.output_shape[0]+'Γ—'+meta.output_shape[1];
document.getElementById('t-slider').max=(meta.timesteps||8784)-1;
mapLR.fitBounds(BOUNDS,{padding:[4,4]});
mapPred.fitBounds(BOUNDS,{padding:[4,4]});
dot.className='status-light green'; stxt.textContent='Live';
if(_retryTimer){clearInterval(_retryTimer);_retryTimer=null;}
loadFrame(0);
startCachePolling();
} catch(e){
dot.className='status-light red'; stxt.textContent='Offline β€” retrying…';
spinner.classList.remove('show');
// Auto-retry every 3 seconds until server is up
if(!_retryTimer) _retryTimer=setInterval(()=>init(), 3000);
}
}
init();
/* ── Cache Status Polling ─────────────────────────────────────────────── */
let cachePoller = null;
let lastInferMs = null;
function updateCacheBar(d) {
const wrap = document.getElementById('cache-bar-wrap');
const fill = document.getElementById('cache-progress-fill');
const label = document.getElementById('cache-bar-label');
const ram = document.getElementById('ram-badge');
const btn = document.getElementById('btn-build-cache');
if (!wrap) return;
const pct = d.progress_pct || 0;
const ready = d.ready;
const running = pct > 0 && !ready;
const idle = pct === 0 && !ready;
fill.style.width = pct + '%';
ram.textContent = 'RAM ' + (d.ram_pct || 'β€”') + '%';
if (ready) {
label.textContent = 'βœ“ Cache complete β€” all ' + d.total_frames + ' frames instant';
fill.style.background = 'var(--jade)';
btn.disabled = true; btn.textContent = 'βœ“ Done';
wrap.classList.remove('hidden');
// fade out after 5s
setTimeout(() => wrap.classList.add('hidden'), 5000);
clearInterval(cachePoller); cachePoller = null;
} else if (running) {
const eta = d.eta_sec;
const etaStr = eta ? (Math.floor(eta/60)+'m '+String(eta%60).padStart(2,'0')+'s') : '…';
label.textContent = 'Building cache: ' + pct.toFixed(1) + '% β€” ETA ' + etaStr;
fill.style.background = 'linear-gradient(90deg,var(--teal),var(--jade))';
btn.disabled = true; btn.textContent = '⏳ Building';
wrap.classList.remove('hidden');
} else {
// idle
const inf = lastInferMs ? (' Β· live inference ~' + Math.round(lastInferMs/1000) + 's/frame') : '';
label.textContent = 'Frame cache: idle' + inf;
wrap.classList.remove('hidden');
btn.disabled = false; btn.textContent = '⚑ Build Cache';
}
}
function startCachePolling() {
if (cachePoller) return;
cachePoller = setInterval(() => {
fetch(API + '/cache/status').then(r => r.json()).then(updateCacheBar).catch(() => {});
}, 6000);
// Immediate first check
fetch(API + '/cache/status').then(r => r.json()).then(updateCacheBar).catch(() => {});
}
async function triggerCacheBuild() {
const btn = document.getElementById('btn-build-cache');
btn.disabled = true; btn.textContent = 'Starting…';
try {
const r = await fetch(API + '/cache/build', {method:'POST'});
const d = await r.json();
if (d.status === 'started') {
btn.textContent = '⏳ Building';
startCachePolling();
} else if (d.status === 'already_running') {
btn.textContent = '⏳ Running';
} else if (d.status === 'already_complete') {
btn.textContent = 'βœ“ Done';
}
} catch(e) {
btn.disabled = false; btn.textContent = '⚑ Build Cache';
}
}
</script>
</body>
</html>