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"; // Keep SSR search form in sync so non-JS round-trips preserve units const uHidden=document.getElementById('unitsHidden'); if(uHidden) uHidden.value = state.units === 'metric' ? 'metric' : 'imperial'; 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("/api/weather", location.origin); 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${t}
${weatherIcon(hourly.weather_code[i],1)}
${fmt(hourly.temperature_2m[i],'°')}
${(hourly.precipitation_probability?.[i]??0)}%
`; 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
${weatherIcon(daily.weather_code[i],1)}
${dt}
${hi}
${lo}
${weatherText(daily.weather_code[i])}
`; 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{ctx.beginPath();ctx.arc(pad+i*xstep, ymap(v), 2.5, 0, Math.PI*2);ctx.fill()}); } // Keep units in sync for SSR form and client 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(', ')); } // Intercept form submit in JS mode and route smartly. const form = document.getElementById('searchForm'); form.addEventListener('submit',(e)=>{ e.preventDefault(); goSearch(); }); $('#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 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; } // Prefer SSR round-trip when available 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 static 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(){ // Sync hidden units field at boot const uHidden=document.getElementById('unitsHidden'); if(uHidden) uHidden.value = state.units; // 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 { localStorage.setItem('pokeweather:last', JSON.stringify({ when: Date.now(), state: { lat: window.__SSR__.lat, lon: window.__SSR__.lon, name: window.__SSR__.name, units: state.units }, data: { current: window.__SSR__.current || {}, daily: window.__SSR__.daily || {}, hourly: window.__SSR__.hourly || {} } })); } catch {} 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"; if(uHidden) uHidden.value=state.units; 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))}, ()=>{} )} })();