poke/html/weather.ejs
2025-08-24 12:40:10 +02:00

452 lines
24 KiB
Plaintext
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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>PokeWeather</title>
<meta name="theme-color" content="#0ea5e9" />
<meta name="color-scheme" content="dark light" />
<style>
:root{--bg:#0b0f16;--fg:#eef3ff;--muted:#a7b4cc;--card:#0f1624;--ring:#ffffff22;--accent:#0ea5e9;--good:#7ee787;--bad:#f97373;--warn:#fbbf24;--br:16px}
@media (prefers-color-scheme:light){:root{--bg:#fbfbfe;--fg:#0b1020;--muted:#5b6b87;--card:#ffffff;--ring:#0b102018}}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:radial-gradient(1200px 800px at 20% 10%,#0c1528,var(--bg));color:var(--fg);font:15px/1.4 ui-sans-serif,system-ui,Segoe UI,Roboto,Inter,Arial}
/* Improved, stable navbar (no weird pill stretching) */
header{position:sticky;top:0;z-index:40}
.navwrap{backdrop-filter:saturate(1.4) blur(10px);background:linear-gradient(180deg,rgba(0,0,0,.35),rgba(0,0,0,.15));border-bottom:1px solid var(--ring)}
.wrap{max-width:1100px;margin:0 auto;padding:12px 14px}
.top{display:flex;align-items:center;gap:12px;justify-content:space-between;min-height:56px}
.brand{display:flex;align-items:center;gap:10px}
.brand .logo{width:36px;height:36px;border-radius:12px;background:linear-gradient(135deg,#00d2ff,#3a7bd5);display:grid;place-items:center;box-shadow:0 6px 20px #00d2ff44;flex:0 0 36px}
/* removed brand text on purpose */
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--ring);border-radius:999px;padding:6px 10px;background:linear-gradient(180deg,transparent,rgba(255,255,255,.03));white-space:nowrap;max-width:58vw;overflow:hidden;text-overflow:ellipsis}
.controls{display:flex;gap:8px;flex-wrap:nowrap}
button,.btn{border:1px solid var(--ring);background:linear-gradient(180deg,transparent,rgba(255,255,255,.03));color:var(--fg);padding:10px 12px;border-radius:12px;cursor:pointer;white-space:nowrap}
button:active{transform:translateY(1px)}
.subnav{border-top:1px solid var(--ring)}
.subrow{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding-top:8px}
.tabs{display:flex;gap:8px}
.tab{padding:8px 12px;border:1px solid var(--ring);border-radius:999px;background:linear-gradient(180deg,transparent,rgba(255,255,255,.03));font-weight:700;text-decoration:none;color:inherit}
.search{display:flex;gap:8px;position:relative;flex:1;min-width:220px}
input[type="search"]{flex:1;min-width:140px;border:1px solid var(--ring);background:var(--card);color:var(--fg);padding:12px 14px;border-radius:12px;outline:none}
main{max-width:1100px;margin:14px auto;padding:0 14px;display:grid;gap:14px}
.grid{display:grid;gap:14px;grid-template-columns:repeat(12,1fr)}
.card{background:var(--card);border:1px solid var(--ring);border-radius:var(--br);padding:14px}
.current{grid-column:span 12;display:grid;gap:12px}
.curr-top{display:flex;gap:14px;align-items:center;justify-content:space-between;flex-wrap:wrap}
.temp{font-size:52px;font-weight:900;letter-spacing:-1px}
.desc{color:var(--muted);font-weight:600}
.details{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
.stat{border:1px dashed var(--ring);border-radius:12px;padding:10px}
.stat h4{margin:0 0 6px 0;font-size:12px;color:var(--muted);font-weight:700}
.stat div{font-weight:800}
.row{display:flex;gap:10px;align-items:center}
.bigicon{font-size:42px}
.section-title{font-size:14px;color:var(--muted);margin:0 0 8px 4px;font-weight:700;text-transform:uppercase;letter-spacing:.1em}
.scrollx{display:flex;gap:10px;overflow:auto;scroll-snap-type:x mandatory;padding-bottom:4px}
.hour{min-width:84px;scroll-snap-align:start;border:1px solid var(--ring);background:linear-gradient(180deg,transparent,rgba(255,255,255,.02));border-radius:12px;padding:10px;text-align:center}
.hour .t{font-weight:800}
.day{display:grid;grid-template-columns:1.2fr 2fr .8fr;gap:10px;align-items:center;border:1px solid var(--ring);border-radius:12px;padding:10px}
.day .hi{font-weight:800}
.day .lo{color:var(--muted)}
.panel{grid-column:span 12}
canvas{width:100%;height:120px;background:linear-gradient(180deg,transparent,rgba(255,255,255,.02));border-radius:12px;border:1px solid var(--ring)}
footer{opacity:.8;text-align:center;padding:18px;color:var(--muted)}
.suggest{position:absolute;background:var(--card);border:1px solid var(--ring);border-radius:12px;margin-top:4px;overflow:hidden;display:none;z-index:30;max-height:300px;overflow:auto}
.suggest button{display:block;width:100%;text-align:left;padding:10px 12px;border:0;border-bottom:1px solid var(--ring);background:none;color:inherit}
.suggest button:last-child{border-bottom:0}
.hidden{display:none}
@media (min-width:780px){.current{grid-column:span 7}.panel{grid-column:span 5}}
.ssr{display:none}
main{display:grid}
</style>
<noscript>
<style>
.ssr{display:block !important;}
main{display:none !important;}
</style>
</noscript>
</head>
<body class="<%= ssr && ssr.forceNoJS ? 'no-js' : '' %>">
<header>
<div class="navwrap">
<div class="wrap top">
<div class="brand">
<div class="logo" aria-hidden="true"><span>☁️</span></div>
<span class="chip" id="locChip"><%= (ssr && ssr.name) || '—' %></span>
</div>
<div class="controls">
<button id="btnUnits" title="Toggle °C/°F">°C</button>
<button id="btnTheme" title="Toggle theme">🌗</button>
<button id="btnInstall" class="hidden" title="Install">⬇️ Install</button>
<a class="btn" href="#" id="btnShare" title="Share">🔗 Share</a>
</div>
</div>
<div class="wrap subrow subnav">
<div class="tabs" role="tablist" aria-label="Sections">
<a class="tab" href="#now">Now</a>
<a class="tab" href="#hourly">Hourly</a>
<a class="tab" href="#daily">7-Day</a>
</div>
<div class="search" role="search" aria-label="Search location">
<input id="q" type="search" placeholder="Search city, place, or paste lat,lon" autocomplete="off" />
<button id="btnSearch" title="Search">Search</button>
<button id="btnGeo" title="Use my location">📍</button>
<div id="suggest" class="suggest" role="listbox" aria-label="Suggestions"></div>
</div>
</div>
</div>
</header>
<section class="ssr">
<div class="wrap" style="padding-top:14px">
<article class="card" aria-live="polite">
<h3 class="section-title">Now — <%= (ssr && ssr.name) || 'Unknown' %></h3>
<% if (ssr && ssr.current) { %>
<div class="row" style="justify-content:space-between;flex-wrap:wrap">
<div class="row">
<div class="bigicon"><%= ssr.icon %></div>
<div>
<div class="temp"><%= Math.round(ssr.current.temperature_2m) %>°</div>
<div class="desc"><%= ssr.desc %></div>
</div>
</div>
<div class="row" style="gap:12px">
<div class="stat"><h4>Feels like</h4><div><%= Math.round(ssr.current.apparent_temperature) %>°</div></div>
<div class="stat"><h4>Wind</h4><div><%= Math.round(ssr.current.wind_speed_10m) %> <%= ssr.windUnit %></div></div>
<div class="stat"><h4>Humidity</h4><div><%= Math.round(ssr.current.relative_humidity_2m) %>%</div></div>
<div class="stat"><h4>Pressure</h4><div><%= Math.round(ssr.current.pressure_msl) %> hPa</div></div>
</div>
</div>
<div class="details" style="margin-top:10px">
<div class="stat"><h4>Sunrise</h4><div><%= ssr.sunriseLocal %></div></div>
<div class="stat"><h4>Sunset</h4><div><%= ssr.sunsetLocal %></div></div>
<div class="stat"><h4>UV max</h4><div><%= ssr.daily.uv_index_max?.[0] ?? '—' %></div></div>
<div class="stat"><h4>Precip prob (next hr)</h4><div><%= ssr.popNext ?? '—' %>%</div></div>
</div>
<% } else { %>
<p style="color:var(--muted)">No data to show.</p>
<% } %>
</article>
<article class="card" style="margin-top:14px">
<h3 class="section-title">7-day forecast</h3>
<% if (ssr && ssr.daily) { %>
<div class="grid" style="grid-template-columns:repeat(1,1fr);gap:10px">
<% for (let i=0;i<ssr.daily.time.length;i++){ %>
<div class="day">
<div class="row" style="gap:10px">
<div style="font-size:22px"><%= ssr.dailyIcons[i] %></div>
<div><%= ssr.dailyLabels[i] %></div>
</div>
<div class="row">
<div class="hi"><%= Math.round(ssr.daily.temperature_2m_max[i]) %>°</div>
<div class="lo"><span style="color:var(--muted)"><%= Math.round(ssr.daily.temperature_2m_min[i]) %>°</span></div>
</div>
<div style="text-align:right;color:var(--muted)"><%= ssr.dailyTexts[i] %></div>
</div>
<% } %>
</div>
<% } %>
</article>
</div>
</section>
<!-- ===== JS APP (kept intact) ===== -->
<main>
<section id="now" class="grid">
<article class="card current" aria-live="polite">
<div class="curr-top">
<div class="row">
<div class="bigicon" id="currIcon">—</div>
<div>
<div class="temp" id="currTemp">—</div>
<div class="desc" id="currDesc">—</div>
</div>
</div>
<div class="row" style="gap:12px">
<div class="stat"><h4>Feels like</h4><div id="currFeels">—</div></div>
<div class="stat"><h4>Wind</h4><div id="currWind">—</div></div>
<div class="stat"><h4>Humidity</h4><div id="currHum">—</div></div>
<div class="stat"><h4>Pressure</h4><div id="currPress">—</div></div>
</div>
</div>
<div class="details">
<div class="stat"><h4>Sunrise</h4><div id="sunrise">—</div></div>
<div class="stat"><h4>Sunset</h4><div id="sunset">—</div></div>
<div class="stat"><h4>UV max</h4><div id="uv">—</div></div>
<div class="stat"><h4>Precip prob (next hr)</h4><div id="pop">—</div></div>
</div>
</article>
<aside id="hourly" class="panel card">
<h3 class="section-title">Next 24 hours</h3>
<div id="hours" class="scrollx" aria-label="Hourly forecast"></div>
<canvas id="chart" height="120" aria-label="Temperature chart" role="img"></canvas>
</aside>
</section>
<section id="daily" class="card">
<h3 class="section-title">7-day forecast</h3>
<div id="days" class="grid" style="grid-template-columns:repeat(1,1fr);gap:10px"></div>
</section>
</main>
<script>
window.__SSR__ = <%- JSON.stringify(ssr || {}) %>;
window.__SSR_ROUTE__ = "/weather";
</script>
<script>
const $=s=>document.querySelector(s);const $$=s=>[...document.querySelectorAll(s)];
const units=JSON.parse(localStorage.getItem('pokeweather:units')||'{}');
let state={lat:null,lon:null,name:null,units:units.units||'metric'};
const btnUnits=$('#btnUnits'); btnUnits.textContent=state.units==='metric'?"°C":"°F";
const weatherText=(c)=>{
if([0].includes(c))return"Clear sky";
if([1].includes(c))return"Mostly clear";
if([2].includes(c))return"Partly cloudy";
if([3].includes(c))return"Overcast";
if([45,48].includes(c))return"Fog";
if([51,53,55].includes(c))return"Drizzle";
if([56,57].includes(c))return"Freezing drizzle";
if([61,63,65].includes(c))return"Rain";
if([66,67].includes(c))return"Freezing rain";
if([71,73,75].includes(c))return"Snow";
if([77].includes(c))return"Snow grains";
if([80,81,82].includes(c))return"Showers";
if([85,86].includes(c))return"Snow showers";
if([95].includes(c))return"Thunderstorm";
if([96,99].includes(c))return"Storm & hail";return"—"}
const weatherIcon=(c,isDay)=>{
if(c===0)return isDay?"☀️":"🌙";
if([1,2].includes(c))return isDay?"🌤️":"☁️";
if([3].includes(c))return"☁️";
if([45,48].includes(c))return"🌫️";
if([51,53,55,80,81,82].includes(c))return"🌦️";
if([61,63,65].includes(c))return"🌧️";
if([66,67].includes(c))return"🌧️❄️";
if([71,73,75,77,85,86].includes(c))return"❄️";
if([95,96,99].includes(c))return"⛈️";return"☁️";
};
const fmt=(n,u='')=>n==null?'—':`${Math.round(n)}${u}`;
const fmtTime=(s)=>new Date(s).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const fmtDay=(s)=>new Date(s).toLocaleDateString([], {weekday:'short',month:'short',day:'numeric'});
async function searchPlaces(q){
if(!q) return [];
const url=`https://nominatim.openstreetmap.org/search?format=json&limit=5&addressdetails=1&q=${encodeURIComponent(q)}`;
const r=await fetch(url,{headers:{'Accept-Language':navigator.language}});
if(!r.ok) return [];
return r.json();
}
function applyUnits(){
btnUnits.textContent=state.units==='metric'?"°C":"°F";
if(state.lat&&state.lon) loadWeather(state.lat,state.lon,state.name);
localStorage.setItem('pokeweather:units',JSON.stringify({units:state.units}))
}
async function loadWeather(lat,lon,name){
state.lat=+lat;state.lon=+lon;state.name=name||state.name||`${lat.toFixed(3)},${lon.toFixed(3)}`;
$('#locChip').textContent=state.name;
const tu=state.units==='metric'?"celsius":"fahrenheit";
const wu=state.units==='metric'?"kmh":"mph";
const url=new URL('https://api.open-meteo.com/v1/forecast');
url.search={}.toString();
url.searchParams.set('latitude',lat);url.searchParams.set('longitude',lon);
url.searchParams.set('current','temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,wind_speed_10m,wind_direction_10m,pressure_msl');
url.searchParams.set('hourly','temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m');
url.searchParams.set('daily','weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,uv_index_max');
url.searchParams.set('timezone','auto');url.searchParams.set('forecast_days','7');
url.searchParams.set('temperature_unit',tu);url.searchParams.set('windspeed_unit',wu);
$('.current').classList.add('loading');
try{
const res=await fetch(url.toString());
if(!res.ok) throw new Error('Weather error');
const data=await res.json();
localStorage.setItem('pokeweather:last',JSON.stringify({when:Date.now(),state,data}));
render(data);
}catch(e){
const cached=JSON.parse(localStorage.getItem('pokeweather:last')||'null');
if(cached){render(cached.data);}
else alert('Could not load weather.');
}finally{$('.current').classList.remove('loading')}
}
function render(d){
const c=d.current; const daily=d.daily; const hourly=d.hourly;
$('#currIcon').textContent=weatherIcon(c.weather_code,c.is_day);
$('#currTemp').textContent=fmt(c.temperature_2m,'°');
$('#currDesc').textContent=weatherText(c.weather_code);
$('#currFeels').textContent=fmt(c.apparent_temperature,'°');
$('#currWind').textContent=`${fmt(c.wind_speed_10m)} ${state.units==='metric'?'km/h':'mph'} ↗ ${c.wind_direction_10m??'—'}°`;
$('#currHum').textContent=fmt(c.relative_humidity_2m,'%');
$('#currPress').textContent=fmt(c.pressure_msl,' hPa');
$('#sunrise').textContent=fmtTime(daily.sunrise[0]);
$('#sunset').textContent=fmtTime(daily.sunset[0]);
$('#uv').textContent=(daily.uv_index_max?.[0]??'—');
const nowIndex=hourly.time.findIndex(t=>Date.parse(t)>Date.now());
const popNext=hourly.precipitation_probability?.[nowIndex]??hourly.precipitation_probability?.[0]??null;
$('#pop').textContent=popNext==null?'—':popNext+"%";
// hourly tiles
const H=$('#hours'); H.innerHTML='';
const start=nowIndex>0?nowIndex-1:0; const end=Math.min(start+24,hourly.time.length);
for(let i=start;i<end;i++){
const el=document.createElement('div');
el.className='hour';
const t=new Date(hourly.time[i]).toLocaleTimeString([], {hour:'2-digit'});
el.innerHTML=`<div>${t}</div><div style="font-size:22px">${weatherIcon(hourly.weather_code[i],1)}</div><div class="t">${fmt(hourly.temperature_2m[i],'°')}</div><div style="color:var(--muted);font-size:12px">${(hourly.precipitation_probability?.[i]??0)}%</div>`;
H.appendChild(el);
}
// chart
drawChart($('#chart'), hourly.time.slice(start,end).map(t=>new Date(t)), hourly.temperature_2m.slice(start,end));
// 7-day
const D=$('#days'); D.innerHTML='';
for(let i=0;i<daily.time.length;i++){
const row=document.createElement('div'); row.className='day';
const dt=fmtDay(daily.time[i]);
const hi=fmt(daily.temperature_2m_max[i],'°');
const lo=fmt(daily.temperature_2m_min[i],'°');
row.innerHTML=`<div class="row" style="gap:10px"><div style="font-size:22px">${weatherIcon(daily.weather_code[i],1)}</div><div>${dt}</div></div><div class="row"><div class="hi">${hi}</div><div class="lo">${lo}</div></div><div style="text-align:right;color:var(--muted)">${weatherText(daily.weather_code[i])}</div>`;
D.appendChild(row);
}
}
function drawChart(canvas, xs, ys){
const ctx=canvas.getContext('2d');
const w=canvas.width=canvas.clientWidth*2; const h=canvas.height=canvas.clientHeight*2; ctx.scale(2,2);
const min=Math.min(...ys), max=Math.max(...ys), pad=8; const xstep=(canvas.clientWidth-2*pad)/(ys.length-1);
const ymap=v=>{if(max===min) return canvas.clientHeight/2; const t=(v-min)/(max-min); return canvas.clientHeight-pad - t*(canvas.clientHeight-2*pad)};
ctx.clearRect(0,0,canvas.clientWidth,canvas.clientHeight);
ctx.lineWidth=2; ctx.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue('--accent');
ctx.beginPath(); ctx.moveTo(pad, ymap(ys[0]));
for(let i=1;i<ys.length;i++){ctx.lineTo(pad+i*xstep, ymap(ys[i]));}
ctx.stroke();
ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue('--accent');
ys.forEach((v,i)=>{ctx.beginPath();ctx.arc(pad+i*xstep, ymap(v), 2.5, 0, Math.PI*2);ctx.fill()});
}
btnUnits.addEventListener('click',()=>{state.units=state.units==='metric'?'imperial':'metric';applyUnits()});
$('#btnTheme').addEventListener('click',()=>{
const dark=document.documentElement.style.getPropertyValue('--bg')==='#0b0f16';
if(dark){document.documentElement.style.setProperty('--bg','#fbfbfe');document.documentElement.style.setProperty('--fg','#0b1020');document.documentElement.style.setProperty('--muted','#5b6b87');document.documentElement.style.setProperty('--card','#ffffff');document.documentElement.style.setProperty('--ring','#0b102018');}
else{document.documentElement.style.setProperty('--bg','#0b0f16');document.documentElement.style.setProperty('--fg','#eef3ff');document.documentElement.style.setProperty('--muted','#a7b4cc');document.documentElement.style.setProperty('--card','#0f1624');document.documentElement.style.setProperty('--ring','#ffffff22');}
});
$('#btnGeo').addEventListener('click',()=>{
if(!navigator.geolocation){alert('Geolocation not supported');return}
navigator.geolocation.getCurrentPosition(p=>{
reverseName(p.coords.latitude,p.coords.longitude).then(n=>loadWeather(p.coords.latitude,p.coords.longitude,n));
},e=>alert('Location denied'))
});
function reverseName(lat,lon){
const u=`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}`;
return fetch(u).then(r=>r.ok?r.json():null).then(j=>j?.display_name?.split(',').slice(0,2).join(', ')||`${lat.toFixed(3)},${lon.toFixed(3)}`);
}
const suggest=$('#suggest');
$('#q').addEventListener('input', debounce(async(e)=>{
const v=e.target.value.trim(); if(!v){suggest.style.display='none'; return}
const list=await searchPlaces(v); suggest.innerHTML='';
list.forEach(item=>{
const b=document.createElement('button'); b.type='button';
const label=item.display_name.split(',').slice(0,3).join(', ');
b.textContent=label; b.addEventListener('click',()=>{selectPlace(item)});
suggest.appendChild(b);
});
if(list.length){const r=$('#q').getBoundingClientRect(); suggest.style.minWidth=r.width+'px'; suggest.style.display='block'} else suggest.style.display='none';
},300));
function selectPlace(item){
suggest.style.display='none';
$('#q').value=item.display_name.split(',').slice(0,2).join(', ');
loadWeather(item.lat,item.lon,item.display_name.split(',').slice(0,2).join(', '));
}
$('#btnSearch').addEventListener('click',()=>goSearch());
$('#q').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();goSearch()}});
function goSearch(){
const v=$('#q').value.trim();
if(!v) return;
// If user typed "lat,lon", stay fully client-side (as before)
const latlon=v.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/);
if(latlon){
const lat=+latlon[1], lon=+latlon[2];
reverseName(lat,lon).then(n=>loadWeather(lat,lon,n));
return;
}
// If were on the SSR page, round-trip to your /weather route (keeps no-JS users happy, too)
if (window.__SSR_ROUTE__) {
const units = (state.units==='metric') ? 'metric' : 'imperial';
const url = new URL(window.__SSR_ROUTE__, location.origin);
url.searchParams.set('q', v);
url.searchParams.set('units', units);
location.href = url.toString();
return;
}
// Fallback: pure client search (for the static single-file build)
searchPlaces(v).then(list=>{
if(list[0]) selectPlace(list[0]);
else alert('No results');
});
}
$('#btnShare').addEventListener('click',async e=>{
e.preventDefault();
const url=new URL(location.href);
if(state.lat&&state.lon){url.searchParams.set('lat',state.lat);url.searchParams.set('lon',state.lon);url.searchParams.set('name',state.name)}
try{ if(navigator.share){await navigator.share({title:'PokeWeather',text:`${state.name||'Weather'}: ${$('#currTemp').textContent} ${$('#currDesc').textContent}`,url:url.toString()});}
else{await navigator.clipboard.writeText(url.toString());alert('Link copied!')} }catch{}
});
function debounce(fn,ms){let t;return (...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
(function init(){
// If server already gave us a place, hydrate immediately so UI & search are “awake”
if (window.__SSR__ && typeof window.__SSR__.lat === "number" && typeof window.__SSR__.lon === "number") {
try {
// Also seed last cache so offline/opening without params still has context
localStorage.setItem('pokeweather:last', JSON.stringify({
when: Date.now(),
state: { lat: window.__SSR__.lat, lon: window.__SSR__.lon, name: window.__SSR__.name, units: (JSON.parse(localStorage.getItem('pokeweather:units')||'{}').units || 'metric') },
data: { current: window.__SSR__.current || {}, daily: window.__SSR__.daily || {}, hourly: window.__SSR__.hourly || {} }
}));
} catch {}
// Re-fetch on the client to fully power the JS UI (chart, hourly, etc.)
loadWeather(window.__SSR__.lat, window.__SSR__.lon, window.__SSR__.name);
return;
}
const sp=new URLSearchParams(location.search);
const lat=sp.get('lat'), lon=sp.get('lon'), name=sp.get('name');
if(lat&&lon){loadWeather(+lat,+lon,name);return}
const cached=JSON.parse(localStorage.getItem('pokeweather:last')||'null');
if(cached){state.units=cached.state?.units||state.units; btnUnits.textContent=state.units==='metric'?"°C":"°F"; loadWeather(cached.state.lat,cached.state.lon,cached.state.name); return}
if(navigator.geolocation){navigator.geolocation.getCurrentPosition(
p=>{reverseName(p.coords.latitude,p.coords.longitude).then(n=>loadWeather(p.coords.latitude,p.coords.longitude,n))},
()=>{}
)}
})();
</script>
</body>
</html>