Files
WEATHER/app.v6.4.js
T
2026-06-25 23:17:45 +00:00

596 lines
30 KiB
JavaScript
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.
/* Weather Consensus — app.v6.4.js */
(function () {
const API_BASE = "/api";
// DOM refs
const form = document.getElementById('searchForm');
const qInput = document.getElementById('q');
const daysEl = document.getElementById('days');
const locationTitle = document.getElementById('locationTitle');
const generatedAt = document.getElementById('generatedAt');
const yearEl = document.getElementById('year');
const themeSelect = document.getElementById('themeMode');
const rainLayer = document.querySelector('.rain');
const snowLayer = document.querySelector('.snow');
const radarPanel = document.getElementById('radarPanel');
if (yearEl) yearEl.textContent = new Date().getFullYear();
// Units + utils
const unitTemp = (u) => u === 'metric' ? '°C' : '°F';
const unitWind = (u) => u === 'metric' ? 'km/h' : 'mph';
const isNum = (v) => Number.isFinite(v);
const el = (tag, cls, html) => { const d=document.createElement(tag); if (cls) d.className=cls; if (html!==undefined) d.innerHTML=html; return d; };
// Dedupe location label
function dedupeLocationLabel(label){
if (!label || typeof label!=='string') return label;
const parts = label.split(',').map(s=>s.trim()).filter(Boolean);
const seen=new Set(), out=[];
for (const p of parts){ const k=p.toLowerCase(); if(!seen.has(k)){ seen.add(k); out.push(p); } }
return out.join(', ');
}
// Deviceagnostic date label (we always format the YMD as UTC so its stable)
function labelFromYMD(ymd){
if (typeof ymd!=='string') return String(ymd ?? '');
const m=ymd.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if(!m) return ymd;
const y=+m[1], mo=+m[2]-1, d=+m[3];
const dt=new Date(Date.UTC(y,mo,d));
return dt.toLocaleDateString(undefined,{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'});
}
// ---- Regionlocal “today” helpers ----
function ymdNowInTZ(tz) {
const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year:'numeric', month:'2-digit', day:'2-digit' });
const parts = Object.fromEntries(fmt.formatToParts(new Date()).map(p => [p.type, p.value]));
return `${parts.year}-${parts.month}-${parts.day}`;
}
function formatInTZ(dateISO, tz) {
try { return new Date(dateISO).toLocaleString(undefined, { timeZone: tz }); }
catch { return new Date(dateISO).toLocaleString(); }
}
// Visual sanity checks
function saneTemp(v,u){ if(!isNum(v)) return null; return (u==='metric') ? ((v>=-73.3&&v<=60.0)?v:null) : ((v>=-100&&v<=140)?v:null); }
function sanePct(v){ if(!isNum(v)) return null; const n=Math.round(v); return (n>=0&&n<=100)?n:null; }
function saneWind(v,u){ if(!isNum(v)) return null; const cap=(u==='metric')?320:200; return (v>=0&&v<=cap)?v:null; }
// Icons
function svgBase(pathD,opts={}){ const ns='http://www.w3.org/2000/svg'; const s=document.createElementNS(ns,'svg'); s.setAttribute('viewBox','0 0 24 24'); s.setAttribute('aria-hidden','true'); const p=document.createElementNS(ns,'path'); for(const [k,v] of Object.entries(opts)){ if(['fill','stroke','stroke-width','stroke-linecap','stroke-linejoin'].includes(k)) p.setAttribute(k,v);} p.setAttribute('d',pathD); s.appendChild(p); return s; }
const iconDroplet=()=>svgBase("M12 2C8.2 7.1 6.2 10.2 6.2 13.1a5.8 5.8 0 0011.6 0c0-2.9-2-6-5.8-11.1z",{fill:'currentColor'});
function iconWind(){ const ns='http://www.w3.org/2000/svg'; const s=document.createElementNS(ns,'svg'); s.setAttribute('viewBox','0 0 24 24'); s.setAttribute('aria-hidden','true'); const p1=document.createElementNS(ns,'path'); p1.setAttribute('fill','none'); p1.setAttribute('stroke','currentColor'); p1.setAttribute('stroke-width','2'); p1.setAttribute('stroke-linecap','round'); p1.setAttribute('stroke-linejoin','round'); p1.setAttribute('d',"M3 8h10a3 3 0 100-6"); const p2=document.createElementNS(ns,'path'); p2.setAttribute('fill','none'); p2.setAttribute('stroke','currentColor'); p2.setAttribute('stroke-width','2'); p2.setAttribute('stroke-linecap','round'); p2.setAttribute('stroke-linejoin','round'); p2.setAttribute('d',"M3 14h14a3 3 0 110 6"); s.append(p1,p2); return s; }
function iconHumidity(){ const ns='http://www.w3.org/2000/svg'; const s=document.createElementNS(ns,'svg'); s.setAttribute('viewBox','0 0 24 24'); s.setAttribute('aria-hidden','true'); const p1=document.createElementNS(ns,'path'); p1.setAttribute('fill','none'); p1.setAttribute('stroke','currentColor'); p1.setAttribute('stroke-width','2'); p1.setAttribute('stroke-linecap','round'); p1.setAttribute('stroke-linejoin','round'); p1.setAttribute('d',"M12 2C7.5 8 5 11 5 14a7 7 0 0014 0c0-3-2.5-6-7-12z"); const p2=document.createElementNS(ns,'path'); p2.setAttribute('fill','none'); p2.setAttribute('stroke','currentColor'); p2.setAttribute('stroke-width','2'); p2.setAttribute('stroke-linecap','round'); p2.setAttribute('d',"M8 16c1.5 1 3 1 4.5 0s3-1 4.5 0"); s.append(p1,p2); return s; }
// API
async function queryForecast(q){
const url=new URL(`${API_BASE}/forecast.py`, window.location.origin);
if(q) url.searchParams.set('q',q);
const res=await fetch(url.toString(),{cache:'no-store'});
const txt=await res.text().catch(()=> '');
if(!res.ok) throw new Error(`API error ${res.status}: ${txt.slice(0,200)}`);
return JSON.parse(txt);
}
// Theme helpers (unchanged)
async function getSunTimes(lat,lon,ymd){
try{
const url=new URL('https://api.open-meteo.com/v1/forecast');
url.searchParams.set('latitude',lat); url.searchParams.set('longitude',lon);
url.searchParams.set('daily','sunrise,sunset'); url.searchParams.set('timezone','auto');
url.searchParams.set('start_date',ymd); url.searchParams.set('end_date',ymd);
const r=await fetch(url.toString(),{cache:'no-store'}); const j=await r.json();
const tz=j.timezone||'UTC'; const daily=j.daily||{};
return { tz, sunrise:(daily.sunrise&&daily.sunrise[0])||null, sunset:(daily.sunset&&daily.sunset[0])||null };
}catch(e){ console.warn('sun times fetch failed',e); return { tz:'UTC', sunrise:null, sunset:null }; }
}
function nowPartsInTZ(tz){
const fmt=new Intl.DateTimeFormat('en-CA',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false});
const parts=fmt.formatToParts(new Date()); const map=Object.fromEntries(parts.map(p=>[p.type,p.value]));
return { ymd:`${map.year}-${map.month}-${map.day}`, hm:`${map.hour}:${map.minute}` };
}
function extractHM(iso){ if(!iso) return null; const m=/T(\d{2}):(\d{2})/.exec(iso); return m ? `${m[1]}:${m[2]}` : null; }
function applyTheme(mode,ctx){ const body=document.body; if(mode==='day') body.setAttribute('data-theme','day'); else if(mode==='night') body.setAttribute('data-theme','night'); else body.setAttribute('data-theme',(ctx&&ctx.isDay)?'day':'night'); }
async function refreshTheme(data){
const mode=(localStorage.getItem('themeMode')||'auto'); if(themeSelect) themeSelect.value=mode;
if(mode==='day'||mode==='night'){ applyTheme(mode); return; }
const today=(data.days&&data.days[0])?data.days[0].date:null; const {lat,lon}=data;
if(!today||!Number.isFinite(lat)||!Number.isFinite(lon)){ applyTheme('auto',{isDay:true}); return; }
const {tz,sunrise,sunset}=await getSunTimes(lat,lon,today); const now=nowPartsInTZ(tz);
const sr=extractHM(sunrise), ss=extractHM(sunset); let isDay=true;
if(now.ymd===today && sr && ss) isDay=(now.hm>=sr && now.hm<ss);
applyTheme('auto',{isDay});
}
// Rain overlay
function ensureRainDrops(){
if(!rainLayer) return; if(rainLayer.childElementCount>0) return;
const COUNT=36; const w=rainLayer.clientWidth||window.innerWidth||1200;
for(let i=0;i<COUNT;i++){ const s=document.createElement('span'); const left=Math.random()*w;
const delay=(Math.random()*1.2).toFixed(2); const dur=(0.9+Math.random()*0.8).toFixed(2);
s.style.left=`${left}px`; s.style.animationDelay=`${delay}s`; s.style.animationDuration=`${dur}s`; rainLayer.appendChild(s); }
}
function updateRainFlag(data){
const body=document.body; const today=(data.days&&data.days[0])?data.days[0]:null;
const pop=today&&today.consensus ? today.consensus.precip_chance_mean : null;
if(isNum(pop) && pop>=40){ body.classList.add('rainy'); ensureRainDrops(); } else { body.classList.remove('rainy'); }
}
// Snow overlay
const SNOW_MIN_POP=40, SNOW_MAX_FLAKES=180;
const SNOW_BASE_PER_400PX={far:18, mid:24, near:32};
let __snowLastWidthBucket=null;
function ensureSnowFlakes(){
if(!snowLayer) return;
const w=snowLayer.clientWidth||window.innerWidth||1200;
const widthBucket=Math.max(400, Math.round(w/200)*200);
if(snowLayer.childElementCount>0 && __snowLastWidthBucket===widthBucket) return;
__snowLastWidthBucket=widthBucket;
snowLayer.innerHTML='';
const scale=widthBucket/400;
let far=Math.round(SNOW_BASE_PER_400PX.far*scale), mid=Math.round(SNOW_BASE_PER_400PX.mid*scale), near=Math.round(SNOW_BASE_PER_400PX.near*scale);
const total=far+mid+near; if(total>SNOW_MAX_FLAKES){ const k=SNOW_MAX_FLAKES/total; far=Math.max(8,Math.floor(far*k)); mid=Math.max(8,Math.floor(mid*k)); near=Math.max(8,Math.floor(near*k)); }
const make=(count,opts)=>{ for(let i=0;i<count;i++){ const s=document.createElement('span');
s.style.left=`${Math.random()*w}px`;
const delay=(Math.random()*opts.delayRange+opts.delayBase).toFixed(2);
const dur=(opts.durBase+Math.random()*opts.durRange).toFixed(2);
const size=(opts.sizeMin+Math.random()*opts.sizeRange).toFixed(1);
const drift=((Math.random()*opts.driftRange)-(opts.driftRange/2)).toFixed(0);
const alpha=(opts.alphaMin+Math.random()*opts.alphaRange).toFixed(2);
s.style.animationDelay=`${delay}s`;
s.style.setProperty('--dur',`${dur}s`);
s.style.setProperty('--size',`${size}px`);
s.style.setProperty('--drift',`${drift}px`);
s.style.setProperty('--alpha',alpha);
snowLayer.appendChild(s); } };
make(far,{delayBase:0,delayRange:6,durBase:10,durRange:6,sizeMin:1.5,sizeRange:2.5,driftRange:40,alphaMin:0.35,alphaRange:0.25});
make(mid,{delayBase:0,delayRange:5,durBase:8, durRange:6,sizeMin:2.5,sizeRange:3.5,driftRange:80,alphaMin:0.45,alphaRange:0.35});
make(near,{delayBase:0,delayRange:4,durBase:6, durRange:5,sizeMin:4, sizeRange:5, driftRange:120,alphaMin:0.55,alphaRange:0.35});
}
function shouldSnow(c,u){
if(!c) return false; const pop=c.precip_chance_mean;
if(!isNum(pop)||pop<SNOW_MIN_POP) return false;
const hi=c.mean_high, lo=c.mean_low;
return (u==='imperial') ? ((isNum(lo)&&lo<=32) || (isNum(hi)&&hi<=36 && isNum(lo)&&lo<=34))
: ((isNum(lo)&&lo<=0) || (isNum(hi)&&hi<=2 && isNum(lo)&&lo<=1));
}
function updateSnowFlag(data){
const body=document.body; const today=(data.days&&data.days[0])?data.days[0]:null; const c=today&&today.consensus;
const show=shouldSnow(c, data.units);
if(show){ body.classList.add('snowy'); body.classList.remove('rainy'); ensureSnowFlakes(); } else { body.classList.remove('snowy'); }
}
let __snowResizeTid=null;
window.addEventListener('resize',()=>{ if(!document.body.classList.contains('snowy')) return; if(__snowResizeTid) clearTimeout(__snowResizeTid); __snowResizeTid=setTimeout(()=>ensureSnowFlakes(),200); });
// Rendering helpers
function metricRow(svg,label,value){ const row=el('div','metric'); const i=el('span','i'); i.appendChild(svg); row.append(i, el('span','name',label), el('span','value',value)); return row; }
// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
// CHANGED: header markup place date and Today badge in a flex row
function renderDay(d,u, todayYMD){
const uT=unitTemp(u), uW=unitWind(u), c=d.consensus||{};
const hi=isNum(c.mean_high)?`${c.mean_high.toFixed(1)}${uT}`:'—';
const lo=isNum(c.mean_low)?`${c.mean_low.toFixed(1)}${uT}`:'—';
const pc=isNum(c.precip_chance_mean)?`${Math.round(c.precip_chance_mean)}%`:'—';
const wm=isNum(c.wind_max_mean)?`${c.wind_max_mean.toFixed(1)} ${uW}`:'—';
const rh=isNum(c.humidity_avg_mean)?`${Math.round(c.humidity_avg_mean)}%`:'—';
const card=el('article','card');
// Build card head with a single-row date line + optional Today badge
const top=el('div','card-head');
const dateRow = el('div','date-row');
const dateText = el('span','date-text', labelFromYMD(d.date));
dateRow.appendChild(dateText);
if (d.date === todayYMD) {
dateRow.appendChild(el('span','today-badge','Today'));
}
top.appendChild(dateRow);
const hiLo=el('div','hi-lo', `<strong>High</strong> ${hi} <strong>Low</strong> ${lo}`);
top.appendChild(hiLo);
card.appendChild(top);
const metrics=el('div','metrics'); metrics.append(
metricRow(iconDroplet(),'Precip chance',pc),
metricRow(iconWind(),'Max wind',wm),
metricRow(iconHumidity(),'Humidity (avg)',rh)
); card.appendChild(metrics);
const providers=el('details','providers'); providers.appendChild(el('summary','', 'Provider breakdown'));
const pre=el('pre','prov-pre',(d.providers||[]).map(p=>{
const bits=[]; const h=isNum(p.high)?p.high:null; const l=isNum(p.low)?p.low:null; const pop=isNum(p.precip_chance)?Math.round(p.precip_chance):null; const w=isNum(p.wind_max)?p.wind_max:null; const hum=isNum(p.humidity_avg)?Math.round(p.humidity_avg):null;
if(h!==null) bits.push(`high=${h}${uT}`); if(l!==null) bits.push(`low=${l}${uT}`); if(pop!==null) bits.push(`precip=${pop}%`); if(w!==null) bits.push(`wind=${w} ${uW}`); if(hum!==null) bits.push(`rh=${hum}%`);
return bits.length ? `${p.provider}: ${bits.join(' · ')}` : `${p.provider}: —`;
}).join('\n')); providers.appendChild(pre); card.appendChild(providers);
return card;
}
// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
// Reversegeocode prettifier for "lat,lon"
function parseLatLonLabel(label){
if (typeof label !== 'string') return null;
const m = label.trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/);
if (!m) return null;
const lat = parseFloat(m[1]), lon = parseFloat(m[2]);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat, lon };
}
function labelFromNominatim(j){
if (!j) return null;
const a = j.address || {};
const locality = a.city || a.town || a.village || a.hamlet || a.municipality || a.county;
const state = a.state || a.region;
const country = a.country || (a.country_code ? a.country_code.toUpperCase() : null);
const parts = [locality, state, (country && country !== 'US') ? country : null].filter(Boolean);
return parts.length ? parts.join(', ') : (j.display_name || null);
}
const revGeoSessionKey = (lat, lon) => `wc:revgeo:${lat.toFixed(4)},${lon.toFixed(4)}`;
async function reverseGeocodeNominatim(lat, lon){
try{
const key = revGeoSessionKey(lat, lon);
const cached = sessionStorage.getItem(key);
if (cached) return cached;
const url = new URL('https://nominatim.openstreetmap.org/reverse');
url.searchParams.set('lat', String(lat));
url.searchParams.set('lon', String(lon));
url.searchParams.set('format', 'json');
url.searchParams.set('addressdetails', '1');
url.searchParams.set('accept-language', 'en');
const r = await fetch(url.toString(), { cache: 'no-store' });
if (!r.ok) throw new Error(`Nominatim ${r.status}`);
const j = await r.json();
const label = labelFromNominatim(j);
if (label) sessionStorage.setItem(key, label);
return label;
}catch(e){
console.warn('reverseGeocode failed', e);
return null;
}
}
// Rendering root
function render(data){
const tz = data.timezone || 'UTC';
const todayYMD = ymdNowInTZ(tz);
locationTitle.textContent=`Forecast · ${dedupeLocationLabel(data.location)}`;
if (generatedAt) generatedAt.textContent=`Updated (local to forecast): ${formatInTZ(data.generated_at_utc, tz)}`;
daysEl.innerHTML='';
(data.days||[]).slice(0,7).forEach(d=>daysEl.appendChild(renderDay(d, data.units, todayYMD)));
refreshTheme(data); updateRainFlag(data); updateSnowFlag(data);
if (radarPanel && radarPanel.style.display === 'none') {
radarPanel.style.display = 'block';
initRadar();
}
if (Number.isFinite(data.lat) && Number.isFinite(data.lon) && window.__WC_centerRadar) {
window.__WC_centerRadar(data.lat, data.lon);
}
const coords = parseLatLonLabel(data.location);
if (coords){
reverseGeocodeNominatim(coords.lat, coords.lon).then(label=>{
if (label && locationTitle) {
locationTitle.textContent = `Forecast · ${dedupeLocationLabel(label)}`;
if (qInput && (qInput.value.trim() === '' || qInput.value === 'Your location')) qInput.value = label;
}
});
}
}
// Submit handler
async function handleSubmit(e){
e.preventDefault();
const q=(qInput.value||'').trim(); if(!q) return;
closeSuggest();
daysEl.innerHTML='\nLoading…\n';
try{
const data=await queryForecast(q);
render(data);
history.replaceState({}, "", `?q=${encodeURIComponent(q)}`);
saveLastLocation({ q, label: data.location });
}catch(err){
console.error(err);
daysEl.innerHTML=`\nUnable to load forecast. ${err.message || err}\n`;
}
}
if(form) form.addEventListener('submit', handleSubmit);
// Theme control
if(themeSelect){
const saved=(localStorage.getItem('themeMode')||'auto');
themeSelect.value=saved; applyTheme(saved);
themeSelect.addEventListener('change',()=>{ const mode=themeSelect.value||'auto'; localStorage.setItem('themeMode',mode); applyTheme(mode); });
}else applyTheme(localStorage.getItem('themeMode')||'auto');
// --- Autocomplete (unchanged from v6.3) ---
const suggBox=document.createElement('div');
suggBox.className='sugg-box';
suggBox.setAttribute('role','listbox');
suggBox.id='suggestions';
suggBox.style.position='fixed';
suggBox.style.display='none';
suggBox.style.zIndex='10000';
document.body.appendChild(suggBox);
let tId=null; let suppressSuggestOnce=false; let committing=false;
function positionSuggest(anchorEl){
if(!anchorEl || !suggBox || suggBox.style.display!=='block') return;
const rect=anchorEl.getBoundingClientRect();
const vw=window.innerWidth || document.documentElement.clientWidth || 360;
const vh=window.innerHeight|| document.documentElement.clientHeight|| 640;
const isNarrow=vw<=640;
const width=Math.min(520, Math.round(vw * (isNarrow ? 0.96 : 0.86)));
const left = isNarrow ? Math.round((vw - width)/2) : Math.round(rect.left + (rect.width/2) - (width/2));
const top = Math.round(rect.bottom) + 6;
const margin=6;
const clampedLeft=Math.max(margin, Math.min(left, vw - width - margin));
const maxH=Math.max(120, vh - top - margin - 10);
suggBox.style.transform='none';
suggBox.style.left = `${clampedLeft}px`;
suggBox.style.top = `${Math.max(margin, top)}px`;
suggBox.style.width= `${width}px`;
suggBox.style.maxWidth= `${Math.round(vw*0.98)}px`;
suggBox.style.maxHeight= `${maxH}px`;
}
function closeSuggest(){ suggBox.style.display='none'; suggBox.innerHTML=''; }
qInput?.addEventListener('input', ()=>{
const q=qInput.value.trim();
if(tId) clearTimeout(tId);
if(suppressSuggestOnce){ suppressSuggestOnce=false; return; }
if(q.length<2 || committing || document.activeElement!==qInput){ closeSuggest(); return; }
tId=setTimeout(()=>fetchSuggest(q), 200);
});
document.addEventListener('click',(e)=>{ if(!suggBox.contains(e.target) && e.target!==qInput) closeSuggest(); });
window.addEventListener('resize', ()=>{ if(suggBox.style.display==='block') positionSuggest(qInput); });
window.addEventListener('scroll', ()=>{ if(suggBox.style.display==='block') positionSuggest(qInput); }, {passive:true});
async function fetchSuggest(q){
try{
if(committing) return;
const url=new URL('https://geocoding-api.open-meteo.com/v1/search');
url.searchParams.set('name', q);
url.searchParams.set('count','8');
url.searchParams.set('language','en');
url.searchParams.set('format','json');
const r=await fetch(url.toString(),{cache:'no-store'});
const j=await r.json();
const list=(j.results||[]).map(r=>({ label:[r.name, r.admin1, r.country].filter(Boolean).join(', '),
query:[r.name, r.admin1, r.country].filter(Boolean).join(', ') }));
showSuggest(list);
positionSuggest(qInput);
}catch(e){ console.warn('suggest error',e); closeSuggest(); }
}
function showSuggest(items){
suggBox.innerHTML='';
if(!items.length){ closeSuggest(); return; }
items.forEach((it)=>{
const opt=document.createElement('div');
opt.className='sugg-item';
opt.textContent=it.label;
opt.setAttribute('role','option');
opt.setAttribute('tabindex','0');
const choose=(e)=>{
if(e) e.preventDefault();
committing=true;
suppressSuggestOnce=true;
qInput.value=it.query;
qInput.blur();
closeSuggest();
if(form && typeof form.requestSubmit==='function'){ form.requestSubmit(); }
else{ form.dispatchEvent(new Event('submit',{bubbles:true, cancelable:true})); }
setTimeout(()=>{ committing=false; }, 300);
};
opt.addEventListener('click', choose);
opt.addEventListener('keydown', (e)=>{ if(e.key==='Enter'||e.key===' ') choose(e); });
suggBox.appendChild(opt);
});
suggBox.style.display='block';
positionSuggest(qInput);
}
// Coord fetch helper
async function queryForecastByCoords(lat, lon) {
const url = new URL(`${API_BASE}/forecast.py`, window.location.origin);
url.searchParams.set('lat', String(lat));
url.searchParams.set('lon', String(lon));
const res = await fetch(url.toString(), { cache: 'no-store' });
const txt = await res.text().catch(() => '');
if (!res.ok) throw new Error(`API error ${res.status}: ${txt.slice(0,200)}`);
return JSON.parse(txt);
}
// Use current location
const useMyLocationBtn = document.getElementById('useMyLocation');
function showInlineMessage(msg) { if (!daysEl) return; daysEl.innerHTML = `\n${msg}\n`; }
async function handleUseMyLocation() {
if (!('geolocation' in navigator)) { showInlineMessage('Geolocation isnt available in this browser.'); return; }
const prevHTML = daysEl.innerHTML;
showInlineMessage('Getting your location…');
const options = { enableHighAccuracy: false, timeout: 10000, maximumAge: 60_000 };
let settled = false;
const done = () => { settled = true; };
try {
await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, options); })
.then(async (pos) => {
const { latitude, longitude } = pos.coords || {};
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { throw new Error('Got invalid coordinates.'); }
showInlineMessage('Loading forecast for your location…');
const data = await queryForecastByCoords(latitude, longitude);
render(data);
history.replaceState({}, '', `?lat=${latitude.toFixed(4)}&lon=${longitude.toFixed(4)}`);
const pretty = await reverseGeocodeNominatim(latitude, longitude);
if (pretty && qInput) qInput.value = pretty; else if (qInput) qInput.value = 'Your location';
saveLastLocation({ lat: latitude, lon: longitude, label: pretty || `${latitude.toFixed(4)},${longitude.toFixed(4)}` });
}).finally(done);
} catch (err) {
console.error(err);
if (!settled) showInlineMessage('Could not get your location. You can type a city or ZIP instead.');
else showInlineMessage('Could not get your location. You can type a city or ZIP instead.');
setTimeout(() => { if (daysEl && daysEl.innerHTML.includes('Could not get')) daysEl.innerHTML = prevHTML; }, 3000);
}
}
if (useMyLocationBtn) { useMyLocationBtn.addEventListener('click', handleUseMyLocation); }
// Persist/restore last location
const lastLocKey = 'wc:lastLocation';
function saveLastLocation(obj){
try{ const payload = { ...obj, t: Date.now() }; localStorage.setItem(lastLocKey, JSON.stringify(payload)); }catch{}
}
function loadLastLocation(){
try{
const raw = localStorage.getItem(lastLocKey);
if (!raw) return null;
const j = JSON.parse(raw);
if (j && (typeof j.q === 'string' || (Number.isFinite(j.lat) && Number.isFinite(j.lon)))) return j;
return null;
}catch{ return null; }
}
// Bootstrap
const params=new URLSearchParams(window.location.search);
const qParam=params.get('q');
const latParam=params.get('lat'), lonParam=params.get('lon');
if(qParam){ qInput.value=qParam; }
else if(latParam && lonParam){ /* user will submit or use current location */ }
else{
const last = loadLastLocation();
if (last){
(async ()=>{
try{
if (typeof last.q === 'string'){
qInput.value = last.label || last.q;
const data = await queryForecast(last.q);
render(data);
history.replaceState({}, '', `?q=${encodeURIComponent(last.q)}`);
}else if (Number.isFinite(last.lat) && Number.isFinite(last.lon)){
if (qInput) qInput.value = last.label || 'Your last location';
const data = await queryForecastByCoords(last.lat, last.lon);
render(data);
history.replaceState({}, '', `?lat=${last.lat.toFixed(4)}&lon=${last.lon.toFixed(4)}`);
}
}catch(e){
console.warn('restore last location failed', e);
daysEl.innerHTML='\nEnter a city/state or ZIP (e.g., "76084") and tap Get Forecast.\n';
}
})();
}else{
daysEl.innerHTML='\nEnter a city/state or ZIP (e.g., "76084") and tap Get Forecast.\n';
}
}
// ===============================
// Time-enabled Radar (unchanged)
// ===============================
const RADAR_IMG_URL =
'https://mapservices.weather.noaa.gov/eventdriven/rest/services/radar/radar_base_reflectivity_time/ImageServer';
const FRAME_RATE = 1;
const AUTO_PLAY_ON_LOAD = true;
const mapEl = document.getElementById('radarMap');
const playBtn = document.getElementById('radarPlayPause');
const slider = document.getElementById('radarSlider');
const tsLabel = document.getElementById('radarTs');
let dbg = document.getElementById('radarDbg');
if (!dbg) { dbg = document.createElement('div'); dbg.id = 'radarDbg'; dbg.style.cssText = 'margin-top:6px;font-size:12px;color:#9ba3af'; tsLabel?.parentNode?.insertAdjacentElement('afterend', dbg); }
const setDbg = (t) => { if (dbg) dbg.textContent = t; };
let radarMap, baseTiles, imageLayer;
let frames = [], idx = 0, playing = false, animTimer = null, radarInitialized = false;
let stepMs = 10 * 60 * 1000;
function fmtLocal(d){ return d.toLocaleString(); }
async function buildSnappedFrames() {
let startMs, endMs;
try {
const r = await fetch(`${RADAR_IMG_URL}?f=pjson`, { cache: 'no-store' });
const j = await r.json();
if (Array.isArray(j?.timeInfo?.timeExtent) && j.timeInfo.timeExtent.length === 2) {
startMs = j.timeInfo.timeExtent[0];
endMs = j.timeInfo.timeExtent[1];
} else {
endMs = Date.now(); startMs = endMs - 4 * 3600 * 1000;
}
stepMs = (typeof j?.timeInfo?.defaultTimeInterval === 'number' && j.timeInfo.defaultTimeInterval > 0)
? j.timeInfo.defaultTimeInterval : 10 * 60 * 1000;
} catch {
endMs = Date.now(); startMs = endMs - 4 * 3600 * 1000; stepMs = 10 * 60 * 1000;
}
const end = Math.floor(endMs / stepMs) * stepMs;
const out = [];
for (let t = end - 8 * stepMs; t <= end; t += stepMs) { if (t >= startMs) out.push(new Date(t)); }
if (out.length < 2) out.push(new Date(end - stepMs), new Date(end));
return out;
}
function ensureRadarMap() {
if (!mapEl || typeof L === 'undefined' || !L.esri) return;
if (radarMap) return;
radarMap = L.map('radarMap', { preferCanvas:true, zoomControl:true })
.setView([37.5, -96.5], 5);
baseTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap · Radar © NOAA/NWS',
maxZoom: 12
}).addTo(radarMap);
imageLayer = L.esri.imageMapLayer({
url: RADAR_IMG_URL,
f: 'image',
format: 'png32',
opacity: 0.85,
useCors: true
}).addTo(radarMap);
imageLayer.on('load', () => {});
imageLayer.on('error', (e) => { console.warn('ImageServer exportImage error', e); });
}
function showFrame() {
if (!frames.length || !imageLayer) return;
const d = frames[idx];
imageLayer.setTimeRange(d, d);
tsLabel && (tsLabel.textContent = `Showing: ${fmtLocal(d)} (local)`);
slider && (slider.value = String(idx));
setDbg(`Mode: ImageServer (REST time) · frames=${frames.length} · step=${Math.round(stepMs/60000)}min · ${d.toISOString()}`);
}
function play() {
if (playing || frames.length < 2) return;
playing = true; if (playBtn) playBtn.textContent = '❚❚ Pause';
animTimer = setInterval(() => { idx = (idx + 1) % frames.length; showFrame(); }, 1000 / FRAME_RATE);
}
function pause() {
playing = false; if (playBtn) playBtn.textContent = '▶︎ Play';
if (animTimer) { clearInterval(animTimer); animTimer = null; }
}
function toggle(){ playing ? pause() : play(); }
async function initRadar(){
if (radarInitialized) return;
radarInitialized = true;
ensureRadarMap();
if (!radarMap || !imageLayer) return;
frames = await buildSnappedFrames();
idx = frames.length - 1;
if (slider){
slider.min = '0'; slider.max = String(frames.length - 1); slider.value = String(idx);
slider.disabled = false;
slider.addEventListener('input', () => { idx = +slider.value; showFrame(); });
}
if (playBtn){
playBtn.disabled = false;
playBtn.addEventListener('click', toggle);
}
showFrame();
if (AUTO_PLAY_ON_LOAD) play();
setInterval(async ()=>{
const fresh = await buildSnappedFrames();
if (fresh.length){
const wasAtEnd = (idx >= frames.length - 2);
frames = fresh;
if (slider) slider.max = String(frames.length - 1);
if (wasAtEnd) idx = frames.length - 1;
showFrame();
}
}, 300000);
}
window.__WC_centerRadar = function(lat, lon){
try{
ensureRadarMap();
if (!radarMap || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
const z = (lon > -170 && lon < -50 && lat > 15 && lat < 60) ? 7 : 5;
radarMap.setView([lat, lon], z, { animate: true });
}catch(e){}
};
})();