/* 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.hm0) return; const COUNT=36; const w=rainLayer.clientWidth||window.innerWidth||1200; for(let i=0;i=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{ 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', `High ${hi} Low ${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){} }; })();