596 lines
30 KiB
JavaScript
596 lines
30 KiB
JavaScript
/* 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(', ');
|
||
}
|
||
|
||
// Device‑agnostic date label (we always format the YMD as UTC so it’s 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'});
|
||
}
|
||
|
||
// ---- Region‑local “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;
|
||
}
|
||
// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
|
||
|
||
// Reverse‑geocode 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 isn’t 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){}
|
||
};
|
||
})(); |