initial commit

This commit is contained in:
2026-06-25 23:17:45 +00:00
commit 780fae7df0
11 changed files with 3502 additions and 0 deletions
+596
View File
@@ -0,0 +1,596 @@
/* 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){}
};
})();