// ============================================================================= // NoAdsWeather - app.js // ============================================================================= // --- Units system ------------------------------------------------------------ const IMPERIAL_COUNTRIES = ['United States', 'Liberia', 'Myanmar']; let units = { temp: 'fahrenheit', wind: 'mph', precip: 'inch', pressure: 'inHg', time24h: false, }; function isImperial() { return units.temp === 'fahrenheit'; } function saveUnitsPref() { localStorage.setItem('unitsPref', JSON.stringify({ temp: units.temp, time24h: units.time24h })); } function loadUnitsPref() { return JSON.parse(localStorage.getItem('unitsPref') || 'null'); } function applyUnitsFromTemp(temp) { const imperial = temp === 'fahrenheit'; units.temp = temp; units.wind = imperial ? 'mph' : 'kmh'; units.precip = imperial ? 'inch' : 'mm'; units.pressure = imperial ? 'inHg' : 'hPa'; } function setUnitsForCountry(country) { const stored = loadUnitsPref(); if (stored) { // User has a stored preference — use it applyUnitsFromTemp(stored.temp); units.time24h = stored.time24h; } else { // No stored preference — auto-detect from country if (IMPERIAL_COUNTRIES.includes(country)) { units = { temp: 'fahrenheit', wind: 'mph', precip: 'inch', pressure: 'inHg', time24h: false }; } else { units = { temp: 'celsius', wind: 'kmh', precip: 'mm', pressure: 'hPa', time24h: true }; } } updateUnitsToggleLabel(); } function toggleUnits() { applyUnitsFromTemp(isImperial() ? 'celsius' : 'fahrenheit'); updateUnitsToggleLabel(); saveUnitsPref(); } function updateUnitsToggleLabel() { const btn = document.getElementById('units-toggle'); if (btn) btn.textContent = isImperial() ? '°C' : '°F'; const timeBtn = document.getElementById('time-toggle'); if (timeBtn) timeBtn.textContent = units.time24h ? '12H' : '24H'; } function tempUnit() { return isImperial() ? '°F' : '°C'; } function windUnit() { return isImperial() ? 'mph' : 'km/h'; } function precipUnit() { return isImperial() ? '"' : 'mm'; } function fmtTimeUnit(date) { if (!date || isNaN(date)) return '—'; if (units.time24h) { return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); } return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } function fmtPrecip(val) { if (isImperial()) return val.toFixed(2) + '"'; return val.toFixed(1) + 'mm'; } // --- Section Preferences System ----------------------------------------------- const DEFAULT_SECTION_ORDER = [ 'current-section', 'details-section', 'hourly-section', 'daily-section', 'radar-section', 'sun-section', 'moon-section' ]; // Default layout: ordered list with column assignments // 'left', 'right', or 'wide' const DEFAULT_LAYOUT_LIST = [ { id: 'current-section', col: 'left' }, { id: 'details-section', col: 'right' }, { id: 'hourly-section', col: 'wide' }, { id: 'daily-section', col: 'wide' }, { id: 'radar-section', col: 'left' }, { id: 'sun-section', col: 'right' }, { id: 'moon-section', col: 'right' }, ]; const DEFAULT_CHART_ORDER = ['chart-temp', 'chart-atmos', 'chart-precip', 'chart-wind']; const DEFAULT_WIDE_SECTIONS = ['daily-section', 'hourly-section']; const SECTION_NAMES = { 'current-section': 'Current Conditions', 'details-section': 'Pollen', 'hourly-section': 'Hourly Forecast', 'daily-section': '10-Day Forecast', 'radar-section': 'Radar', 'sun-section': 'Sun', 'moon-section': 'Moon', }; // Sections that always span 2 columns function loadSectionPrefs() { const stored = JSON.parse(localStorage.getItem('sectionPrefs') || 'null'); // Validate — if missing layoutList, reset if (stored && !stored.layoutList) { localStorage.removeItem('sectionPrefs'); return { layoutList: JSON.parse(JSON.stringify(DEFAULT_LAYOUT_LIST)), hidden: [], minimized: [], chartOrder: [...DEFAULT_CHART_ORDER], hiddenCharts: [] }; } const prefs = stored || { order: [...DEFAULT_SECTION_ORDER], hidden: [], minimized: [], chartOrder: [...DEFAULT_CHART_ORDER], hiddenCharts: [], }; if (!prefs.hiddenCharts) prefs.hiddenCharts = []; if (!prefs.layoutList) prefs.layoutList = JSON.parse(JSON.stringify(DEFAULT_LAYOUT_LIST)); return prefs; } function saveSectionPrefs(prefs) { localStorage.setItem('sectionPrefs', JSON.stringify(prefs)); } function applySectionPreferences() { const prefs = loadSectionPrefs(); const container = document.getElementById('weather-content'); if (!container) return; // Reset all sections for (const id of DEFAULT_SECTION_ORDER) { const el = document.getElementById(id); if (el) { el.style.display = ''; el.classList.remove('section-minimized'); el.classList.remove('section-wide'); // Move back to container temporarily container.appendChild(el); } } // Remove old layout rows container.querySelectorAll('.columns-row').forEach(r => r.remove()); // Build layout from prefs.layoutList // Walk through the list and group consecutive left/right items into columns-rows // Wide items break the row const spacer = container.querySelector('.bottom-spacer'); let currentLeft = []; let currentRight = []; function flushColumns(force) { if (!force && currentLeft.length === 0 && currentRight.length === 0) return; const row = document.createElement('div'); row.className = 'columns-row'; const left = document.createElement('div'); left.className = 'weather-col'; const right = document.createElement('div'); right.className = 'weather-col'; for (const el of currentLeft) left.appendChild(el); for (const el of currentRight) right.appendChild(el); row.appendChild(left); row.appendChild(right); container.insertBefore(row, spacer); currentLeft = []; currentRight = []; } for (const item of prefs.layoutList) { const el = document.getElementById(item.id); if (!el) continue; if (item.col === 'wide') { flushColumns(); el.classList.add('section-wide'); container.insertBefore(el, spacer); } else if (item.col === 'left') { currentLeft.push(el); } else { currentRight.push(el); } } flushColumns(); // Always add an empty drop-target row at the end flushColumns(true); // Apply hidden for (const id of prefs.hidden) { const el = document.getElementById(id); if (el) el.style.display = 'none'; } // Apply minimized for (const id of prefs.minimized) { const el = document.getElementById(id); if (el) el.classList.add('section-minimized'); } // Reorder chart rows within the daily forecast applyChartOrder(prefs.chartOrder || DEFAULT_CHART_ORDER); // Inject controls on each draggable section injectSectionControls(); renderHiddenSectionsBar(); } function injectSectionControls() { for (const id of DEFAULT_SECTION_ORDER) { const el = document.getElementById(id); if (!el || el.style.display === 'none') continue; el.setAttribute('data-section-name', SECTION_NAMES[id] || id); const old = el.querySelector('.section-controls'); if (old) old.remove(); const isMin = el.classList.contains('section-minimized'); const isWide = el.classList.contains('section-wide'); const controls = document.createElement('div'); controls.className = 'section-controls'; controls.innerHTML = ` `; el.prepend(controls); // Width toggle controls.querySelector('.section-width-btn').addEventListener('click', () => { const p = loadSectionPrefs(); const item = p.layoutList.find(x => x.id === id); if (!item) return; if (item.col === 'wide') { item.col = 'left'; } else { item.col = 'wide'; } saveSectionPrefs(p); applySectionPreferences(); }); // Minimize/hide controls.querySelector('.section-min-btn').addEventListener('click', () => { const p = loadSectionPrefs(); if (el.classList.contains('section-minimized')) { el.style.display = 'none'; p.minimized = p.minimized.filter(x => x !== id); if (!p.hidden.includes(id)) p.hidden.push(id); saveSectionPrefs(p); renderHiddenSectionsBar(); } else { el.classList.add('section-minimized'); if (!p.minimized.includes(id)) p.minimized.push(id); saveSectionPrefs(p); controls.querySelector('.section-min-btn').textContent = '✕'; controls.querySelector('.section-min-btn').title = 'Remove section'; } }); // Click minimized section to expand el.addEventListener('click', (e) => { if (!el.classList.contains('section-minimized')) return; if (e.target.closest('.section-controls')) return; const p = loadSectionPrefs(); el.classList.remove('section-minimized'); p.minimized = p.minimized.filter(x => x !== id); saveSectionPrefs(p); controls.querySelector('.section-min-btn').textContent = '−'; controls.querySelector('.section-min-btn').title = 'Minimize section'; }); } } function renderHiddenSectionsBar() { let bar = document.getElementById('hidden-sections-bar'); const prefs = loadSectionPrefs(); if (prefs.hidden.length === 0) { if (bar) bar.remove(); return; } if (!bar) { bar = document.createElement('div'); bar.id = 'hidden-sections-bar'; const summary = document.getElementById('weather-summary'); if (summary) summary.parentNode.insertBefore(bar, summary.nextSibling); } bar.innerHTML = prefs.hidden.map(id => `` ).join(' '); bar.querySelectorAll('.show-section-btn').forEach(btn => { btn.addEventListener('click', () => { const id = btn.dataset.id; const p = loadSectionPrefs(); p.hidden = p.hidden.filter(h => h !== id); saveSectionPrefs(p); const el = document.getElementById(id); if (el) { el.style.display = ''; el.classList.remove('section-minimized'); } applySectionPreferences(); }); }); } // --- Drag-to-Reorder --------------------------------------------------------- function initSectionDrag() { const container = document.getElementById('weather-content'); if (!container) return; let dragEl = null; let placeholder = null; let offsetY = 0; let offsetX = 0; let dragActive = false; container.addEventListener('pointerdown', (e) => { const handle = e.target.closest('.section-drag-handle'); if (!handle) return; dragEl = handle.closest('section'); if (!dragEl || !DEFAULT_SECTION_ORDER.includes(dragEl.id)) return; e.preventDefault(); handle.setPointerCapture(e.pointerId); const rect = dragEl.getBoundingClientRect(); offsetY = e.clientY - rect.top; offsetX = e.clientX - rect.left; placeholder = document.createElement('div'); placeholder.className = 'drag-placeholder'; placeholder.style.height = rect.height + 'px'; dragEl.parentNode.insertBefore(placeholder, dragEl); dragEl.classList.add('section-dragging'); dragEl.style.position = 'fixed'; dragEl.style.top = (e.clientY - offsetY) + 'px'; dragEl.style.left = (e.clientX - offsetX) + 'px'; dragEl.style.width = rect.width + 'px'; dragEl.style.zIndex = '999'; dragActive = true; document.body.classList.add('is-dragging'); }); container.addEventListener('pointermove', (e) => { if (!dragActive || !dragEl) return; e.preventDefault(); dragEl.style.top = (e.clientY - offsetY) + 'px'; dragEl.style.left = (e.clientX - offsetX) + 'px'; // Find the nearest column to the cursor const cols = [...container.querySelectorAll('.weather-col')]; let targetCol = null; let minDist = Infinity; for (const col of cols) { const r = col.getBoundingClientRect(); // Distance: 0 if inside, otherwise distance to nearest edge const dx = e.clientX < r.left ? r.left - e.clientX : e.clientX > r.right ? e.clientX - r.right : 0; const dy = e.clientY < r.top ? r.top - e.clientY : e.clientY > r.bottom ? e.clientY - r.bottom : 0; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < minDist) { minDist = dist; targetCol = col; } } if (targetCol && minDist < 200) { if (placeholder.parentNode !== targetCol) targetCol.appendChild(placeholder); const siblings = [...targetCol.querySelectorAll('section:not(.section-dragging)')]; let inserted = false; for (const sib of siblings) { const r = sib.getBoundingClientRect(); if (e.clientY < r.top + r.height / 2) { targetCol.insertBefore(placeholder, sib); inserted = true; break; } } if (!inserted) targetCol.appendChild(placeholder); } }); const endDrag = () => { if (!dragActive || !dragEl) return; placeholder.parentNode.insertBefore(dragEl, placeholder); placeholder.remove(); dragEl.classList.remove('section-dragging'); dragEl.style.position = ''; dragEl.style.top = ''; dragEl.style.left = ''; dragEl.style.width = ''; dragEl.style.zIndex = ''; // Rebuild layoutList from current DOM state const prefs = loadSectionPrefs(); const newList = []; // Walk through all columns-rows and wide sections in order for (const child of container.children) { if (child.classList && child.classList.contains('columns-row')) { const left = child.querySelector('.weather-col:first-child'); const right = child.querySelector('.weather-col:last-child'); const leftSections = left ? [...left.querySelectorAll('section')].map(s => s.id) : []; const rightSections = right ? [...right.querySelectorAll('section')].map(s => s.id) : []; // Interleave left and right to maintain relative order const maxLen = Math.max(leftSections.length, rightSections.length); for (let i = 0; i < maxLen; i++) { if (i < leftSections.length) newList.push({ id: leftSections[i], col: 'left' }); if (i < rightSections.length) newList.push({ id: rightSections[i], col: 'right' }); } } else if (child.tagName === 'SECTION' && DEFAULT_SECTION_ORDER.includes(child.id)) { newList.push({ id: child.id, col: 'wide' }); } } if (newList.length > 0) prefs.layoutList = newList; saveSectionPrefs(prefs); dragEl = null; placeholder = null; dragActive = false; document.body.classList.remove('is-dragging'); }; container.addEventListener('pointerup', endDrag); container.addEventListener('pointercancel', endDrag); } function applyChartOrder(chartOrder) { requestAnimationFrame(() => { const scroll = document.querySelector('.forecast-scroll'); if (!scroll) return; const prefs = loadSectionPrefs(); const footer = scroll.querySelector('.forecast-footer'); for (const chartId of chartOrder) { const row = scroll.querySelector(`[data-chart-id="${chartId}"]`); if (row && footer) { scroll.insertBefore(row, footer); // Apply hidden state if (prefs.hiddenCharts.includes(chartId)) { row.style.display = 'none'; } else { row.style.display = ''; } } } // Add click handlers for chart hide buttons scroll.querySelectorAll('.chart-min-btn').forEach(btn => { btn.onclick = () => { const chartId = btn.dataset.chartId; const p = loadSectionPrefs(); if (!p.hiddenCharts.includes(chartId)) p.hiddenCharts.push(chartId); saveSectionPrefs(p); const row = btn.closest('.chart-row'); if (row) row.style.display = 'none'; renderHiddenChartsBar(); }; }); renderHiddenChartsBar(); }); } const CHART_NAMES = { 'chart-temp': 'Temperature', 'chart-atmos': 'Cloud/Humidity/Pressure', 'chart-precip': 'Precipitation', 'chart-wind': 'Wind', }; function renderHiddenChartsBar() { const section = document.getElementById('daily-section'); if (!section) return; let bar = document.getElementById('hidden-charts-bar'); const prefs = loadSectionPrefs(); if (prefs.hiddenCharts.length === 0) { if (bar) bar.remove(); return; } if (!bar) { bar = document.createElement('div'); bar.id = 'hidden-charts-bar'; // Insert after the h2 const h2 = section.querySelector('h2'); if (h2) h2.parentNode.insertBefore(bar, h2.nextSibling); else section.prepend(bar); } bar.innerHTML = prefs.hiddenCharts.map(id => `` ).join(' '); bar.querySelectorAll('.show-section-btn').forEach(btn => { btn.addEventListener('click', () => { const id = btn.dataset.id; const p = loadSectionPrefs(); p.hiddenCharts = p.hiddenCharts.filter(h => h !== id); saveSectionPrefs(p); const row = document.querySelector(`[data-chart-id="${id}"]`); if (row) row.style.display = ''; renderHiddenChartsBar(); }); }); } function initChartDrag() { // Delegate on #daily-section for chart row reordering document.addEventListener('pointerdown', (e) => { const handle = e.target.closest('.chart-drag-handle'); if (!handle) return; const chartRow = handle.closest('.chart-row'); const scroll = chartRow ? chartRow.closest('.forecast-scroll') : null; if (!chartRow || !scroll) return; e.preventDefault(); handle.setPointerCapture(e.pointerId); const rect = chartRow.getBoundingClientRect(); const scrollRect = scroll.getBoundingClientRect(); const offsetY = e.clientY - rect.top; const placeholder = document.createElement('div'); placeholder.className = 'drag-placeholder'; placeholder.style.height = rect.height + 'px'; scroll.insertBefore(placeholder, chartRow); chartRow.classList.add('section-dragging'); chartRow.style.position = 'fixed'; chartRow.style.top = (e.clientY - offsetY) + 'px'; chartRow.style.left = scrollRect.left + 'px'; chartRow.style.width = scrollRect.width + 'px'; chartRow.style.zIndex = '999'; const onMove = (e2) => { e2.preventDefault(); chartRow.style.top = (e2.clientY - offsetY) + 'px'; const rows = [...scroll.querySelectorAll('.chart-row:not(.section-dragging)')]; for (const row of rows) { const r = row.getBoundingClientRect(); if (e2.clientY < r.top + r.height / 2) { scroll.insertBefore(placeholder, row); return; } } const footer = scroll.querySelector('.forecast-footer'); if (footer) scroll.insertBefore(placeholder, footer); }; const onUp = () => { scroll.insertBefore(chartRow, placeholder); placeholder.remove(); chartRow.classList.remove('section-dragging'); chartRow.style.position = ''; chartRow.style.top = ''; chartRow.style.left = ''; chartRow.style.width = ''; chartRow.style.zIndex = ''; // Save new chart order const newOrder = [...scroll.querySelectorAll('.chart-row')] .map(r => r.dataset.chartId) .filter(Boolean); const prefs = loadSectionPrefs(); prefs.chartOrder = newOrder; saveSectionPrefs(prefs); document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); }); } // --- Constants --------------------------------------------------------------- const WEATHER_DESCRIPTIONS = { 0: { text: 'Clear sky', icon: '☀️' }, 1: { text: 'Mainly clear', icon: '🌤️' }, 2: { text: 'Partly cloudy', icon: '⛅' }, 3: { text: 'Overcast', icon: '☁️' }, 45: { text: 'Foggy', icon: '🌫️' }, 48: { text: 'Depositing rime fog', icon: '🌫️' }, 51: { text: 'Light drizzle', icon: '🌦️' }, 53: { text: 'Moderate drizzle', icon: '🌦️' }, 55: { text: 'Dense drizzle', icon: '🌦️' }, 61: { text: 'Slight rain', icon: '🌧️' }, 63: { text: 'Moderate rain', icon: '🌧️' }, 65: { text: 'Heavy rain', icon: '🌧️' }, 71: { text: 'Slight snow', icon: '🌨️' }, 73: { text: 'Moderate snow', icon: '🌨️' }, 75: { text: 'Heavy snow', icon: '🌨️' }, 77: { text: 'Snow grains', icon: '🌨️' }, 80: { text: 'Slight rain showers', icon: '🌦️' }, 81: { text: 'Moderate rain showers', icon: '🌦️' }, 82: { text: 'Violent rain showers', icon: '🌦️' }, 85: { text: 'Slight snow showers', icon: '🌨️' }, 86: { text: 'Heavy snow showers', icon: '🌨️' }, 95: { text: 'Thunderstorm', icon: '⛈️' }, 96: { text: 'Thunderstorm with slight hail', icon: '⛈️' }, 99: { text: 'Thunderstorm with heavy hail', icon: '⛈️' }, }; // --- DOM Refs ---------------------------------------------------------------- const homeView = document.getElementById('home-view'); const weatherView = document.getElementById('weather-view'); const searchForm = document.getElementById('search-form'); const searchInput = document.getElementById('search-input'); const searchError = document.getElementById('search-error'); const locationName = document.getElementById('location-name'); const backBtn = document.getElementById('back-btn'); // --- Utility Functions ------------------------------------------------------- function weatherInfo(code) { return WEATHER_DESCRIPTIONS[code] || { text: 'Unknown', icon: '❓' }; } function windDirection(degrees) { const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; return dirs[Math.round(degrees / 45) % 8]; } function lonToTile(lon, zoom) { return Math.floor((lon + 180) / 360 * Math.pow(2, zoom)); } function latToTile(lat, zoom) { return Math.floor( (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, zoom) ); } function getMoonPhase(date) { const knownNew = new Date(2000, 0, 6, 18, 14); const synodicMonth = 29.53058867; const diff = (date - knownNew) / (1000 * 60 * 60 * 24); const phase = ((diff % synodicMonth) + synodicMonth) % synodicMonth; const phaseFraction = phase / synodicMonth; let name, icon; if (phaseFraction < 0.0625) { name = 'New Moon'; icon = '🌑'; } else if (phaseFraction < 0.1875) { name = 'Waxing Crescent'; icon = '🌒'; } else if (phaseFraction < 0.3125) { name = 'First Quarter'; icon = '🌓'; } else if (phaseFraction < 0.4375) { name = 'Waxing Gibbous'; icon = '🌔'; } else if (phaseFraction < 0.5625) { name = 'Full Moon'; icon = '🌕'; } else if (phaseFraction < 0.6875) { name = 'Waning Gibbous'; icon = '🌖'; } else if (phaseFraction < 0.8125) { name = 'Last Quarter'; icon = '🌗'; } else if (phaseFraction < 0.9375) { name = 'Waning Crescent'; icon = '🌘'; } else { name = 'New Moon'; icon = '🌑'; } return { name, icon }; } const TEMP_COLOR_THRESHOLD = 5; // °F — don't colorize if range is less than this function tempBackground(avg, minAvg, avgRange) { if (avgRange < TEMP_COLOR_THRESHOLD) return 'transparent'; const t = (avg - minAvg) / avgRange; if (isDarkMode()) { const r = Math.round(20 + t * 40); const g = Math.round(50 - t * 15); const b = Math.round(50 - t * 35); return `rgb(${r}, ${g}, ${b})`; } const r = Math.round(214 + t * 39); const g = Math.round(228 - t * 14); const b = Math.round(253 - t * 39); return `rgb(${r}, ${g}, ${b})`; } function updateDayBackgrounds() { const avgTemps = window._forecastAvgTemps; if (!avgTemps) return; const minAvg = Math.min(...avgTemps); const avgRange = (Math.max(...avgTemps) - minAvg) || 1; document.querySelectorAll('.forecast-day').forEach((el, i) => { if (i < avgTemps.length) { el.style.background = tempBackground(avgTemps[i], minAvg, avgRange); } }); } // --- API Functions ----------------------------------------------------------- async function geocodeFetch(name) { const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=10&language=en&format=json`; const res = await fetch(url); if (!res.ok) throw new Error('Geocoding request failed'); return res.json(); } // Postal code patterns by country (Zippopotam supports 60+ countries) const POSTAL_PATTERNS = [ { regex: /^(\d{5})$/, country: 'us', name: 'United States' }, // US: 90210 { regex: /^(\d{5})$/, country: 'de', name: 'Germany' }, // DE: 10115 (same format as US, tried after US) { regex: /^([A-Z]\d[A-Z]\s?\d[A-Z]\d)$/i, country: 'ca', name: 'Canada' }, // CA: K1A 0B1 { regex: /^([A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2})$/i, country: 'gb', name: 'United Kingdom' }, // UK: SW1A 1AA { regex: /^(\d{4})$/, country: 'au', name: 'Australia' }, // AU: 2000 { regex: /^(\d{5}-\d{3})$/, country: 'br', name: 'Brazil' }, // BR: 01001-000 { regex: /^(\d{3}-\d{4})$/, country: 'jp', name: 'Japan' }, // JP: 100-0001 { regex: /^(\d{5})$/, country: 'fr', name: 'France' }, // FR: 75001 { regex: /^(\d{5})$/, country: 'es', name: 'Spain' }, // ES: 28001 { regex: /^(\d{5})$/, country: 'it', name: 'Italy' }, // IT: 00100 { regex: /^(\d{4}\s?[A-Z]{2})$/i, country: 'nl', name: 'Netherlands' }, // NL: 1012 AB { regex: /^(\d{4})$/, country: 'nz', name: 'New Zealand' }, // NZ: 6011 { regex: /^(\d{2}-\d{3})$/, country: 'pl', name: 'Poland' }, // PL: 00-001 { regex: /^(\d{4})$/, country: 'za', name: 'South Africa' }, // ZA: 2000 { regex: /^(\d{6})$/, country: 'in', name: 'India' }, // IN: 110001 { regex: /^(\d{5})$/, country: 'mx', name: 'Mexico' }, // MX: 06600 ]; async function geocodePostal(code, countryCode, countryName) { // UK postcodes: Zippopotam only accepts the outcode (first part, e.g. "OX1" not "OX1 1AB") let lookupCode = code; if (countryCode === 'gb') { lookupCode = code.trim().split(/\s+/)[0]; } // NL postcodes: strip space (e.g. "1012 AB" → "1012AB") if (countryCode === 'nl') { lookupCode = code.replace(/\s/g, ''); } const res = await fetch(`https://api.zippopotam.us/${countryCode}/${encodeURIComponent(lookupCode)}`); if (!res.ok) return null; const data = await res.json(); if (!data.places || data.places.length === 0) return null; // Pick the best place name — prefer a larger/recognizable city if multiple places returned // Use the last place (Zippopotam often puts the main city last) or the one matching the postcode area const places = data.places; const place = places.length > 1 ? places[places.length - 1] : places[0]; return { name: place['place name'], region: place['state abbreviation'] || place['state'] || '', country: countryName, lat: parseFloat(place.latitude), lon: parseFloat(place.longitude), }; } async function geocodeZip(query) { const trimmed = query.trim(); // Check if user prefixed with country code, e.g. "DE 10115" or "UK SW1A 1AA" const prefixMatch = trimmed.match(/^([A-Z]{2})\s+(.+)$/i); if (prefixMatch) { const cc = prefixMatch[1].toLowerCase(); const code = prefixMatch[2]; const pattern = POSTAL_PATTERNS.find(p => p.country === cc); if (pattern) { const result = await geocodePostal(code, cc, pattern.name); if (result) return result; } } // Find all matching country patterns for this postal code const matchingPatterns = []; for (const p of POSTAL_PATTERNS) { if (p.regex.test(trimmed)) { matchingPatterns.push(p); } } if (matchingPatterns.length === 0) return null; // If only one country matches the format, just try it if (matchingPatterns.length === 1) { return await geocodePostal(trimmed, matchingPatterns[0].country, matchingPatterns[0].name); } // Multiple countries match — fetch all in parallel and show picker const results = await Promise.all( matchingPatterns.map(p => geocodePostal(trimmed, p.country, p.name)) ); const validResults = results.filter(r => r !== null); if (validResults.length === 0) return null; if (validResults.length === 1) return validResults[0]; // Multiple valid results — show picker return showLocationPicker(validResults); } async function geocode(query) { // Check if input looks like a postal code const postal = await geocodeZip(query); if (postal) return postal; // Parse city and region filter from input // Supports: "Austin, TX", "Austin,TX", "Austin TX", "Austin" let searchName, filterRegion; if (query.includes(',')) { const parts = query.split(',').map(s => s.trim()); searchName = parts[0]; filterRegion = parts[1] || ''; } else { // Try splitting on last space: "Austin TX" -> search "Austin", filter "TX" const words = query.trim().split(/\s+/); const lastWord = words[words.length - 1]; // If last word looks like a state abbreviation (2 letters) or short state name if (words.length >= 2 && (lastWord.length <= 3 || STATE_ABBRS[lastWord.toLowerCase()])) { searchName = words.slice(0, -1).join(' '); filterRegion = lastWord; } else { searchName = query; filterRegion = ''; } } // Try searching with the parsed city name let data = await geocodeFetch(searchName); // If no results and we split on space, try the full query as-is if ((!data.results || data.results.length === 0) && filterRegion) { data = await geocodeFetch(query); filterRegion = ''; // Don't filter since we searched the full string } if (!data.results || data.results.length === 0) { throw new Error('Location not found. Try a different city or zip code.'); } let results = data.results.map(r => ({ name: r.name, region: r.admin1 || '', country: r.country || '', lat: r.latitude, lon: r.longitude, })); // Filter by region if provided if (filterRegion) { const filter = filterRegion.toLowerCase(); const filtered = results.filter(r => { const region = r.region.toLowerCase(); const country = r.country.toLowerCase(); return region.startsWith(filter) || region.includes(filter) || country.startsWith(filter) || country.includes(filter) || matchesStateAbbr(filter, region); }); if (filtered.length > 0) results = filtered; } // If only one result or user already filtered, return it if (results.length === 1 || filterRegion) { return results[0]; } // Multiple results — show picker return showLocationPicker(results); } const STATE_ABBRS = { al:'alabama',ak:'alaska',az:'arizona',ar:'arkansas',ca:'california', co:'colorado',ct:'connecticut',de:'delaware',fl:'florida',ga:'georgia', hi:'hawaii',id:'idaho',il:'illinois',in:'indiana',ia:'iowa',ks:'kansas', ky:'kentucky',la:'louisiana',me:'maine',md:'maryland',ma:'massachusetts', mi:'michigan',mn:'minnesota',ms:'mississippi',mo:'missouri',mt:'montana', ne:'nebraska',nv:'nevada',nh:'new hampshire',nj:'new jersey',nm:'new mexico', ny:'new york',nc:'north carolina',nd:'north dakota',oh:'ohio',ok:'oklahoma', or:'oregon',pa:'pennsylvania',ri:'rhode island',sc:'south carolina', sd:'south dakota',tn:'tennessee',tx:'texas',ut:'utah',vt:'vermont', va:'virginia',wa:'washington',wv:'west virginia',wi:'wisconsin',wy:'wyoming', }; function matchesStateAbbr(abbr, fullName) { const expanded = STATE_ABBRS[abbr.toLowerCase()]; return expanded && fullName.toLowerCase().includes(expanded); } function showLocationPicker(results) { // Reset search button while picker is shown const btn = document.querySelector('#search-form button'); if (btn) { btn.disabled = false; btn.textContent = 'Search'; } return new Promise((resolve) => { const container = document.getElementById('search-error'); container.hidden = false; container.style.color = '#1a1a1a'; let html = '
Did you mean:
'; html += '
'; results.forEach((r, i) => { html += ``; }); html += '
'; container.innerHTML = html; container.addEventListener('click', function handler(e) { const btn = e.target.closest('.location-pick'); if (!btn) return; container.removeEventListener('click', handler); container.hidden = true; container.innerHTML = ''; container.style.color = ''; resolve(results[parseInt(btn.dataset.idx)]); }); }); } async function fetchOpenMeteo(lat, lon) { const params = new URLSearchParams({ latitude: lat, longitude: lon, current: 'temperature_2m,apparent_temperature,dew_point_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m,uv_index', hourly: 'temperature_2m,apparent_temperature,dew_point_2m,relative_humidity_2m,weather_code,cloud_cover,precipitation_probability,precipitation,wind_speed_10m,wind_direction_10m,surface_pressure', daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,sunrise,sunset', temperature_unit: units.temp, wind_speed_unit: units.wind, precipitation_unit: units.precip, pressure_unit: units.pressure, timezone: 'auto', forecast_days: 10, }); const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); if (!res.ok) throw new Error('Weather data request failed'); return res.json(); } async function fetchAirQuality(lat, lon) { try { const params = new URLSearchParams({ latitude: lat, longitude: lon, current: 'us_aqi,grass_pollen,birch_pollen,ragweed_pollen,alder_pollen,olive_pollen,mugwort_pollen', }); const res = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`); if (!res.ok) return null; return (await res.json()).current; } catch { return null; } } function aqiLabel(aqi) { if (aqi <= 50) return { text: 'Good', color: '#16a34a' }; if (aqi <= 100) return { text: 'Moderate', color: '#ca8a04' }; if (aqi <= 150) return { text: 'Unhealthy (Sensitive)', color: '#ea580c' }; if (aqi <= 200) return { text: 'Unhealthy', color: '#dc2626' }; if (aqi <= 300) return { text: 'Very Unhealthy', color: '#7c3aed' }; return { text: 'Hazardous', color: '#7f1d1d' }; } function pollenSummary(aq) { if (!aq) return null; const types = [ { name: 'Grass', val: aq.grass_pollen }, { name: 'Birch', val: aq.birch_pollen }, { name: 'Ragweed', val: aq.ragweed_pollen }, { name: 'Alder', val: aq.alder_pollen }, { name: 'Olive', val: aq.olive_pollen }, { name: 'Mugwort', val: aq.mugwort_pollen }, ].filter(t => t.val !== null && t.val !== undefined); if (types.length === 0) return null; function level(v) { if (v <= 10) return 'Low'; if (v <= 50) return 'Moderate'; if (v <= 100) return 'High'; return 'Very High'; } function levelColor(v) { if (v <= 10) return '#16a34a'; if (v <= 50) return '#ca8a04'; if (v <= 100) return '#ea580c'; return '#dc2626'; } return types.map(t => ({ name: t.name, level: level(t.val), color: levelColor(t.val), value: Math.round(t.val), })); } async function fetchAlerts(lat, lon) { try { const res = await fetch( `https://api.weather.gov/alerts/active?point=${lat},${lon}`, { headers: { 'User-Agent': 'NoAdsWeather (noadsweather.com)' } } ); if (!res.ok) return []; const data = await res.json(); return data.features || []; } catch { return []; } } // --- Render Functions -------------------------------------------------------- function generateSummary(current, hourly, daily) { const now = new Date(); const currentTemp = Math.round(current.temperature_2m); const feelsLike = Math.round(current.apparent_temperature); const info = weatherInfo(current.weather_code); // Find next rain in the next 24 hours const startIdx = hourly.time.findIndex(t => new Date(t) >= now); let rainStartHour = null; let rainEndHour = null; let currentlyRaining = current.weather_code >= 51 && current.weather_code <= 99; if (startIdx !== -1) { for (let i = startIdx; i < startIdx + 24 && i < hourly.time.length; i++) { const prob = hourly.precipitation_probability[i]; if (!rainStartHour && prob >= 40) { rainStartHour = new Date(hourly.time[i]); } if (rainStartHour && !rainEndHour && prob < 30) { rainEndHour = new Date(hourly.time[i]); } } } // Today's high and precip const todayHigh = Math.round(daily.temperature_2m_max[0]); const todayLow = Math.round(daily.temperature_2m_min[0]); const todayPrecip = daily.precipitation_sum[0]; // Tomorrow's conditions const tomorrowInfo = daily.time.length > 1 ? weatherInfo(daily.weather_code[1]) : null; const tomorrowHigh = daily.time.length > 1 ? Math.round(daily.temperature_2m_max[1]) : null; const tomorrowPrecip = daily.time.length > 1 ? daily.precipitation_sum[1] : 0; const tomorrowCode = daily.time.length > 1 ? daily.weather_code[1] : 0; // Build opening sentence (temp + condition combined) // Thresholds adapt to F or C const freezing = isImperial() ? 32 : 0; const cold = isImperial() ? 50 : 10; const cool = isImperial() ? 65 : 18; const warm = isImperial() ? 80 : 27; const hot = isImperial() ? 95 : 35; let opening; if (currentTemp <= freezing) opening = `It's freezing at ${currentTemp}${tempUnit()}`; else if (currentTemp <= cold) opening = `It's cold at ${currentTemp}${tempUnit()}`; else if (currentTemp <= cool) opening = `It's cool at ${currentTemp}${tempUnit()}`; else if (currentTemp <= warm) opening = `It's ${currentTemp}${tempUnit()}`; else if (currentTemp <= hot) opening = `It's warm at ${currentTemp}${tempUnit()}`; else opening = `It's hot at ${currentTemp}${tempUnit()}`; if (Math.abs(feelsLike - currentTemp) >= 5) { opening += ` (feels like ${feelsLike}${tempUnit()})`; } // Add condition to the opening sentence const isSnow = (code) => code >= 71 && code <= 77 || code === 85 || code === 86; if (currentlyRaining) { const code = current.weather_code; if (code >= 95) opening += ' with thunderstorms'; else if (isSnow(code)) opening += ' and snowing'; else opening += ' and raining'; if (todayPrecip > 0) opening += ` (${fmtPrecip(todayPrecip)} expected today)`; if (rainEndHour) opening += `, clearing around ${fmtHour(rainEndHour)}`; } else if (rainStartHour) { const hoursUntil = (rainStartHour - now) / (1000 * 60 * 60); if (hoursUntil <= 1) opening += ' with rain expected very soon'; else opening += ` with rain likely around ${fmtHour(rainStartHour)}`; } else { if (info.text.toLowerCase().includes('clear') || info.text.toLowerCase().includes('sunny')) { opening += ' with clear skies'; } else if (info.text.toLowerCase().includes('cloud') || info.text.toLowerCase().includes('overcast')) { opening += ' and cloudy'; } } // Follow-up sentences let follow = []; follow.push(`High of ${todayHigh}${tempUnit()} today`); if (tomorrowInfo && tomorrowHigh !== null) { const tomorrowRain = tomorrowCode >= 51 && tomorrowCode <= 99; const tomorrowSnow = isSnow(tomorrowCode); if (tomorrowSnow && tomorrowPrecip > 0) { follow.push(`Snow expected tomorrow (${fmtPrecip(tomorrowPrecip)})`); } else if (tomorrowRain && tomorrowPrecip > 0) { follow.push(`Rain expected tomorrow (${fmtPrecip(tomorrowPrecip)})`); } else if (tomorrowRain) { follow.push(`Rain expected tomorrow`); } else if (tomorrowHigh - todayHigh >= 8) { follow.push(`warming up to ${tomorrowHigh}${tempUnit()} tomorrow`); } else if (todayHigh - tomorrowHigh >= 8) { follow.push(`cooling to ${tomorrowHigh}${tempUnit()} tomorrow`); } } return opening + '. ' + follow.join('. ') + '.'; } function fmtHour(date) { if (units.time24h) { return date.getHours().toString().padStart(2, '0') + ':00'; } const h = date.getHours(); if (h === 0) return '12am'; if (h < 12) return h + 'am'; if (h === 12) return '12pm'; return (h - 12) + 'pm'; } function renderCurrent(current, airQuality) { const info = weatherInfo(current.weather_code); const section = document.getElementById('current-section'); const uvVal = Math.round(current.uv_index); const aqi = airQuality ? airQuality.us_aqi : null; const aqiInfo = aqi !== null ? aqiLabel(aqi) : null; section.innerHTML = `

Current Conditions

${info.icon}
${info.text}
${Math.round(current.temperature_2m)}${tempUnit()}
Feels like ${Math.round(current.apparent_temperature)}${tempUnit()}
Humidity ${current.relative_humidity_2m}%
Dew Point ${Math.round(current.dew_point_2m)}${tempUnit()}
Wind ${Math.round(current.wind_speed_10m)} ${windUnit()} ${windDirection(current.wind_direction_10m)}
Gusts ${Math.round(current.wind_gusts_10m)} ${windUnit()}
${aqiInfo ? `
Air Quality ${aqi} (${aqiInfo.text})
` : ''}
UV Index ${uvVal} ${uvVal <= 2 ? '(Low)' : uvVal <= 5 ? '(Moderate)' : uvVal <= 7 ? '(High)' : uvVal <= 10 ? '(Very High)' : '(Extreme)'}
`; } const POLLEN_PROXY_URL = 'https://pollen-proxy-15838356607.us-central1.run.app'; function renderPollen(airQuality, lat, lon) { const section = document.getElementById('details-section'); const openMeteoPollen = pollenSummary(airQuality); const hasFreePollen = openMeteoPollen && openMeteoPollen.length > 0; if (hasFreePollen) { // European locations — show Open-Meteo pollen directly section.innerHTML = `

Pollen

${openMeteoPollen.map(p => `
${p.name}
${p.level}
`).join('')}
`; initDragScroll(section.querySelector('.pollen-scroll')); } else { // Non-European — check cache first, auto-show if available const cacheKey = `pollen_${lat.toFixed(2)}_${lon.toFixed(2)}_${new Date().toISOString().slice(0, 10)}`; const cached = localStorage.getItem(cacheKey); if (cached) { // Auto-show cached data section.innerHTML = `

Pollen (${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' })})

`; displayPollenData(JSON.parse(cached)); } else { section.innerHTML = `

Pollen

`; document.getElementById('pollen-btn').addEventListener('click', () => { loadPollenData(lat, lon); }); } } } function displayPollenData(data) { const content = document.getElementById('pollen-content'); const section = document.getElementById('details-section'); if (!data || data.error || !data.dailyInfo || data.dailyInfo.length === 0) { content.innerHTML = 'Pollen data unavailable for this location'; return; } const day = data.dailyInfo[0]; const types = day.pollenTypeInfo || []; const plants = day.plantInfo || []; let items = []; for (const t of types) { const idx = t.indexInfo; if (!idx) continue; items.push({ name: t.displayName, category: idx.category || 'None', value: idx.value }); } for (const p of plants) { const idx = p.indexInfo; if (!idx || idx.value === 0) continue; items.push({ name: p.displayName, category: idx.category, value: idx.value }); } if (items.length === 0) { content.innerHTML = 'No significant pollen detected'; return; } // Update header with date const h2 = section.querySelector('h2'); if (h2) { h2.innerHTML = `Pollen (${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' })})`; } const fewClass = items.length <= 3 ? ' pollen-few' : ''; content.innerHTML = `
${items.map(p => `
${p.name}
${p.category}
${p.value}/5
`).join('')}
`; initDragScroll(content.querySelector('.pollen-scroll')); } async function loadPollenData(lat, lon) { const btn = document.getElementById('pollen-btn'); btn.textContent = 'Loading...'; btn.disabled = true; const cacheKey = `pollen_${lat.toFixed(2)}_${lon.toFixed(2)}_${new Date().toISOString().slice(0, 10)}`; try { const res = await fetch(`${POLLEN_PROXY_URL}?lat=${lat}&lon=${lon}`); const data = await res.json(); localStorage.setItem(cacheKey, JSON.stringify(data)); displayPollenData(data); } catch (e) { const content = document.getElementById('pollen-content'); content.innerHTML = ''; document.getElementById('pollen-btn').addEventListener('click', () => loadPollenData(lat, lon)); } } function pollenIndexColor(value) { if (value <= 1) return '#16a34a'; // Low - green if (value <= 2) return '#84cc16'; // Low-Medium - lime if (value <= 3) return '#ca8a04'; // Medium - yellow if (value <= 4) return '#ea580c'; // High - orange return '#dc2626'; // Very High - red } function renderHourly(hourly) { const section = document.getElementById('hourly-section'); const now = new Date(); const startIdx = hourly.time.findIndex(t => new Date(t) >= now); if (startIdx === -1) { section.innerHTML = ''; return; } let html = '

Hourly Forecast

'; for (let i = startIdx; i < startIdx + 24 && i < hourly.time.length; i++) { const time = new Date(hourly.time[i]); const hour = time.getHours(); const label = units.time24h ? hour.toString().padStart(2, '0') + ':00' : (hour === 0 ? '12am' : hour < 12 ? `${hour}am` : hour === 12 ? '12pm' : `${hour - 12}pm`); const info = weatherInfo(hourly.weather_code[i]); html += `
${label}
${info.icon}
${Math.round(hourly.temperature_2m[i])}°
`; } html += '
'; section.innerHTML = html; initDragScroll(section.querySelector('.hourly-scroll')); } // --- 10-Day Forecast: unified scroll with sticky labels --- const DAY_WIDTH = 100; // px per day column — shared by header + charts function renderDaily(daily, hourly) { const section = document.getElementById('daily-section'); const days = daily.time.length; const innerW = days * DAY_WIDTH; // Pre-compute chart ranges so we can build sticky labels const totalHours = Math.min(days * 24, hourly.time.length); const chartRanges = computeChartRanges(hourly, totalHours); // --- Compute temperature color per day --- const avgTemps = daily.time.map((_, i) => (daily.temperature_2m_max[i] + daily.temperature_2m_min[i]) / 2 ); // Store globally so theme toggle can recompute window._forecastAvgTemps = avgTemps; const tempRange = Math.max(...avgTemps) - Math.min(...avgTemps); const showTempColors = tempRange >= TEMP_COLOR_THRESHOLD; // --- Day column header (inside scroll) --- let dayHeaderHtml = ''; for (let i = 0; i < days; i++) { const date = new Date(daily.time[i] + 'T00:00:00'); const dayLabel = date.toLocaleDateString('en-US', { weekday: 'short' }); const dateLabel = date.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); const info = weatherInfo(daily.weather_code[i]); const precip = daily.precipitation_sum[i]; const minA = Math.min(...avgTemps); const rangeA = (Math.max(...avgTemps) - minA) || 1; const bg = tempBackground(avgTemps[i], minA, rangeA); dayHeaderHtml += `
${dayLabel} ${dateLabel}
${Math.round(daily.temperature_2m_max[i])}° ${Math.round(daily.temperature_2m_min[i])}°
${info.icon}
${info.text}
${precip > 0 ? '💧 ' + fmtPrecip(precip) : ''}
`; } // Build chart rows — each has: sticky left labels | canvas | sticky right labels function chartRow(id, height, legendHtml, leftLabels, rightLabels) { return `
${legendHtml}
${leftLabels}
${rightLabels}
`; } function makeLabels(min, max, steps, suffix, color) { let html = ''; for (let i = steps; i >= 0; i--) { const val = min + ((max - min) / steps) * i; const label = Number.isInteger(val) ? val : val.toFixed(1); html += `${label}${suffix}`; } return html; } const r = chartRanges; const tempLegend = ` Temperature (${tempUnit()}) Feels Like (${tempUnit()}) Dew Point (${tempUnit()})`; const atmosLegend = ' Cloud Cover (%) Precip Chance (%) Humidity (%) Pressure (inHg)'; const precipLegend = ` Precip Accum. (${isImperial() ? 'in' : 'mm'}) Hourly Precip (${isImperial() ? 'in' : 'mm'})`; const windLegend = ` Wind Speed (${windUnit()})`; // Axis width must match CSS .chart-axis width const AXIS_W = 40; const totalScrollW = innerW + AXIS_W * 2; section.innerHTML = `

10-Day Forecast ${showTempColors ? '— colors show relative temps: red = warmest, blue = coolest' : ''}

${dayHeaderHtml}
${chartRow('chart-temp', 160, tempLegend, makeLabels(r.temp.min, r.temp.max, 4, '°', '#dc2626'), makeLabels(r.temp.min, r.temp.max, 4, '°', '#dc2626'))} ${chartRow('chart-atmos', 160, atmosLegend, makeLabels(0, 100, 4, '%', '#84cc16'), makeLabels(0, 100, 4, '%', '#84cc16'))} ${chartRow('chart-precip', 100, precipLegend, makeLabels(0, r.precip.maxAccum, 3, precipUnit(), '#3b82f6'), makeLabels(0, r.precip.maxAccum, 3, precipUnit(), '#3b82f6'))} ${chartRow('chart-wind', 100, windLegend, makeLabels(0, r.wind.max, 3, '', '#2563eb'), makeLabels(0, r.wind.max, 3, '', '#2563eb'))}
`; requestAnimationFrame(() => drawAllCharts(hourly, totalHours, chartRanges)); // Drag-to-scroll initDragScroll(document.querySelector('.forecast-scroll-outer')); } function initDragScroll(el) { if (!el) return; let isDown = false; let startX, scrollLeft; el.style.cursor = 'grab'; el.addEventListener('mousedown', (e) => { isDown = true; el.style.cursor = 'grabbing'; startX = e.pageX - el.offsetLeft; scrollLeft = el.scrollLeft; }); el.addEventListener('mouseleave', () => { isDown = false; el.style.cursor = 'grab'; }); el.addEventListener('mouseup', () => { isDown = false; el.style.cursor = 'grab'; }); el.addEventListener('mousemove', (e) => { if (!isDown) return; e.preventDefault(); const x = e.pageX - el.offsetLeft; el.scrollLeft = scrollLeft - (x - startX); }); // Touch support (mobile) let touchStartX, touchScrollLeft; el.addEventListener('touchstart', (e) => { touchStartX = e.touches[0].pageX; touchScrollLeft = el.scrollLeft; }, { passive: true }); el.addEventListener('touchmove', (e) => { const x = e.touches[0].pageX; el.scrollLeft = touchScrollLeft - (x - touchStartX); }, { passive: true }); } function computeChartRanges(hourly, hours) { const temp = hourly.temperature_2m.slice(0, hours); const feels = hourly.apparent_temperature.slice(0, hours); const dew = hourly.dew_point_2m.slice(0, hours); const allTemps = [...temp, ...feels, ...dew]; const precip = hourly.precipitation.slice(0, hours); let accumTotal = 0; for (let i = 0; i < hours; i++) accumTotal += precip[i] || 0; const wind = hourly.wind_speed_10m.slice(0, hours); return { temp: { min: Math.floor(Math.min(...allTemps) - 5), max: Math.ceil(Math.max(...allTemps) + 5) }, precip: { maxAccum: Math.max(accumTotal, 0.1), maxHourly: Math.max(...precip, 0.01) }, wind: { max: Math.max(...wind, 5) }, }; } // --- Chart drawing helpers --- function getChartContext(id) { const canvas = document.getElementById(id); if (!canvas) return null; return { ctx: canvas.getContext('2d'), w: canvas.width, h: canvas.height }; } function drawLine(ctx, data, count, color, minVal, maxVal, w, h, pad) { const drawH = h - pad * 2; const range = maxVal - minVal || 1; ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < count; i++) { const x = (i / (count - 1)) * w; const y = pad + drawH - ((data[i] - minVal) / range) * drawH; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } function drawArea(ctx, data, count, color, alpha, minVal, maxVal, w, h, pad) { const drawH = h - pad * 2; const range = maxVal - minVal || 1; ctx.fillStyle = color; ctx.globalAlpha = alpha; ctx.beginPath(); ctx.moveTo(0, h - pad); for (let i = 0; i < count; i++) { const x = (i / (count - 1)) * w; const y = pad + drawH - ((data[i] - minVal) / range) * drawH; ctx.lineTo(x, y); } ctx.lineTo(w, h - pad); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1; } function isDarkMode() { return document.documentElement.getAttribute('data-theme') === 'dark'; } function drawDayDividers(ctx, count, w, h) { ctx.strokeStyle = isDarkMode() ? '#374151' : '#e5e7eb'; ctx.lineWidth = 1; for (let i = 24; i < count; i += 24) { const x = (i / (count - 1)) * w; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } } function drawNowLine(ctx, hourly, count, w, h) { const now = new Date(); const startTime = new Date(hourly.time[0]); const hoursElapsed = (now - startTime) / (1000 * 60 * 60); if (hoursElapsed < 0 || hoursElapsed > count) return; const x = (hoursElapsed / (count - 1)) * w; ctx.strokeStyle = isDarkMode() ? '#9ca3af' : '#1a1a1a'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); ctx.setLineDash([]); } // --- Draw all charts --- function drawAllCharts(hourly, hours, r) { // Temperature const c1 = getChartContext('chart-temp'); if (c1) { const { ctx, w, h } = c1; const pad = 10; const temp = hourly.temperature_2m.slice(0, hours); const feels = hourly.apparent_temperature.slice(0, hours); const dew = hourly.dew_point_2m.slice(0, hours); drawDayDividers(ctx, hours, w, h); drawNowLine(ctx, hourly, hours, w, h); drawLine(ctx, dew, hours, '#16a34a', r.temp.min, r.temp.max, w, h, pad); drawLine(ctx, feels, hours, '#9333ea', r.temp.min, r.temp.max, w, h, pad); drawLine(ctx, temp, hours, '#dc2626', r.temp.min, r.temp.max, w, h, pad); } // Atmospheric const c2 = getChartContext('chart-atmos'); if (c2) { const { ctx, w, h } = c2; const pad = 10; const cloud = hourly.cloud_cover.slice(0, hours); const precipChance = hourly.precipitation_probability.slice(0, hours); const humidity = hourly.relative_humidity_2m.slice(0, hours); const pressure = hourly.surface_pressure.slice(0, hours); drawDayDividers(ctx, hours, w, h); drawNowLine(ctx, hourly, hours, w, h); drawArea(ctx, cloud, hours, '#9ca3af', 0.3, 0, 100, w, h, pad); drawArea(ctx, precipChance, hours, '#3b82f6', 0.3, 0, 100, w, h, pad); drawLine(ctx, humidity, hours, '#84cc16', 0, 100, w, h, pad); drawLine(ctx, cloud, hours, '#9ca3af', 0, 100, w, h, pad); drawLine(ctx, precipChance, hours, '#3b82f6', 0, 100, w, h, pad); const pMin = Math.min(...pressure) - 0.1; const pMax = Math.max(...pressure) + 0.1; drawLine(ctx, pressure, hours, '#1a1a1a', pMin, pMax, w, h, pad); } // Precipitation const c3 = getChartContext('chart-precip'); if (c3) { const { ctx, w, h } = c3; const pad = 8; const precip = hourly.precipitation.slice(0, hours); const accum = []; let total = 0; for (let i = 0; i < hours; i++) { total += precip[i] || 0; accum.push(total); } drawDayDividers(ctx, hours, w, h); drawNowLine(ctx, hourly, hours, w, h); const barW = w / hours; ctx.fillStyle = '#16a34a'; for (let i = 0; i < hours; i++) { if (precip[i] > 0) { const barH = (precip[i] / r.precip.maxHourly) * (h - pad * 2) * 0.4; const x = (i / (hours - 1)) * w; ctx.fillRect(x - barW / 2, h - pad - barH, barW, barH); } } drawLine(ctx, accum, hours, '#3b82f6', 0, r.precip.maxAccum, w, h, pad); } // Wind const c4 = getChartContext('chart-wind'); if (c4) { const { ctx, w, h } = c4; const pad = 8; const wind = hourly.wind_speed_10m.slice(0, hours); const dirs = hourly.wind_direction_10m.slice(0, hours); drawDayDividers(ctx, hours, w, h); drawNowLine(ctx, hourly, hours, w, h); drawArea(ctx, wind, hours, '#2563eb', 0.15, 0, r.wind.max, w, h, pad); drawLine(ctx, wind, hours, '#2563eb', 0, r.wind.max, w, h, pad); ctx.fillStyle = '#2563eb'; for (let i = 0; i < hours; i += 6) { const x = (i / (hours - 1)) * w; const y = pad + (h - pad * 2) - (wind[i] / r.wind.max) * (h - pad * 2) - 12; const angle = (dirs[i] + 180) * Math.PI / 180; ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(0, -5); ctx.lineTo(-3, 5); ctx.lineTo(3, 5); ctx.closePath(); ctx.fill(); ctx.restore(); } } } function renderAlerts(alerts) { const section = document.getElementById('alerts-section'); if (!alerts || alerts.length === 0) { section.hidden = true; return; } section.hidden = false; let html = '

⚠️ Weather Alerts

'; for (const alert of alerts) { const p = alert.properties; html += `
${p.event}
${p.headline || ''}
`; } section.innerHTML = html; } let radarInterval = null; function renderRadar(lat, lon) { if (radarInterval) { clearInterval(radarInterval); radarInterval = null; } const section = document.getElementById('radar-section'); section.innerHTML = `

Radar

Loading radar...
`; loadRadar(lat, lon); } async function loadRadar(lat, lon) { try { const res = await fetch('https://api.rainviewer.com/public/weather-maps.json'); const data = await res.json(); const frames = data.radar.past; const container = document.getElementById('radar-container'); const zoom = 7; const n = Math.pow(2, zoom); // Exact fractional tile position for the city const exactX = (lon + 180) / 360 * n; const exactY = (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n; // Center tile const centerTileX = Math.floor(exactX); const centerTileY = Math.floor(exactY); // Fraction within center tile (0-1) const fracX = exactX - centerTileX; const fracY = exactY - centerTileY; // Use a 5x5 grid so there's always enough tile coverage after centering. // The city is at tile (centerTileX + fracX, centerTileY + fracY). // In the 5x5 grid, the center tile starts at index 2 (0-indexed), so // the city is at grid position (2 + fracX, 2 + fracY) out of 5 tiles. // As a percentage of the grid: (2 + frac) / 5 * 100. // We want that at 50% of the container, so: // left = 50% - (2 + fracX) / 5 * gridWidth // where gridWidth = 500% of container. const gridSize = 5; const offsetX = 50 - (2 + fracX) / gridSize * 500; const offsetY = 50 - (2 + fracY) / gridSize * 500; function buildTileGrid(tileSrcFn, extraStyle) { let html = `
`; for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { html += ``; } } return html + '
'; } // Map base layer const mapHtml = buildTileGrid( (tx, ty) => { const style = isDarkMode() ? 'dark_all' : 'rastertiles/voyager'; return `https://a.basemaps.cartocdn.com/${style}/${zoom}/${tx}/${ty}@2x.png`; }, 'opacity:0.7;' ); // Radar layers — one per frame, all hidden except latest let radarHtml = ''; frames.forEach((frame, i) => { radarHtml += buildTileGrid( (tx, ty) => `https://tilecache.rainviewer.com${frame.path}/256/${zoom}/${tx}/${ty}/2/1_0.png`, `opacity:${i === frames.length - 1 ? 1 : 0};transition:opacity 0.3s;` ).replace('
`; container.innerHTML = mapHtml + radarHtml + markerHtml; // Show timestamp for latest frame const timeEl = document.getElementById('radar-time'); const showFrameTime = (frame) => { const d = new Date(frame.time * 1000); timeEl.textContent = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); }; showFrameTime(frames[frames.length - 1]); // Animate through frames let currentFrame = frames.length - 1; const allFrameEls = container.querySelectorAll('.radar-frame'); radarInterval = setInterval(() => { allFrameEls[currentFrame].style.opacity = '0'; currentFrame = (currentFrame + 1) % frames.length; allFrameEls[currentFrame].style.opacity = '1'; showFrameTime(frames[currentFrame]); }, 500); } catch { document.getElementById('radar-container').innerHTML = '
Radar unavailable
'; } } function renderSunMoon(daily, lat, lon) { const fmtTime = fmtTimeUnit; const fmtDate = (d) => { if (!d || isNaN(d)) return ''; return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' }); }; // Sun const sunrise = new Date(daily.sunrise[0]); const sunset = new Date(daily.sunset[0]); const solarNoon = new Date((sunrise.getTime() + sunset.getTime()) / 2); const sunDateLabel = fmtDate(sunrise); const sunSection = document.getElementById('sun-section'); sunSection.innerHTML = `

Sun (${sunDateLabel})

🌅
Sunrise
${fmtTime(sunrise)}
☀️
Solar Noon
${fmtTime(solarNoon)}
🌇
Sunset
${fmtTime(sunset)}
`; // Moon const now = new Date(); const moon = getMoonPhase(now); const moonTimes = getMoonTimes(now, lat, lon); const riseDate = moonTimes.rise ? fmtDate(moonTimes.rise) : ''; const setDate = moonTimes.set ? fmtDate(moonTimes.set) : ''; const moonSection = document.getElementById('moon-section'); moonSection.innerHTML = `

Moon

🌔
Moonrise
${fmtTime(moonTimes.rise)}
${riseDate}
${moon.icon}
Phase
${moon.name}
🌘
Moonset
${fmtTime(moonTimes.set)}
${setDate}
`; } // --- Moonrise/Moonset calculation --- // Simplified algorithm based on Jean Meeus "Astronomical Algorithms" function getMoonTimes(date, lat, lon) { // Find first moonrise from start of day, then first moonset after that rise const rise = findMoonEvent(date, lat, lon, 'rise', 1440); const searchStart = rise || date; const set = findMoonEvent(searchStart, lat, lon, 'set', 1440); return { rise, set }; } function findMoonEvent(date, lat, lon, type, maxMinutes) { const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const startTime = (type === 'set' && date > startOfDay) ? date : startOfDay; let prevAlt = moonAltitude(startTime, lat, lon); for (let m = 10; m <= maxMinutes; m += 10) { const t = new Date(startTime.getTime() + m * 60000); const alt = moonAltitude(t, lat, lon); if (type === 'rise' && prevAlt < -0.833 && alt >= -0.833) { const frac = (0 - prevAlt) / (alt - prevAlt); return new Date(startTime.getTime() + (m - 10 + frac * 10) * 60000); } if (type === 'set' && prevAlt >= -0.833 && alt < -0.833) { const frac = (0 - prevAlt) / (alt - prevAlt); return new Date(startTime.getTime() + (m - 10 + frac * 10) * 60000); } prevAlt = alt; } return null; // Moon doesn't rise/set today } function moonAltitude(date, lat, lon) { const RAD = Math.PI / 180; // Julian date const JD = dateToJD(date); const T = (JD - 2451545.0) / 36525.0; // Moon ecliptic longitude (simplified) const L0 = 218.3165 + 481267.8813 * T; const M = 134.9634 + 477198.8676 * T; // Moon mean anomaly const D = 297.8502 + 445267.1115 * T; // Mean elongation const F = 93.2720 + 483202.0175 * T; // Argument of latitude const Lm = L0 + 6.289 * Math.sin(M * RAD); const Bm = 5.128 * Math.sin(F * RAD); // Ecliptic to equatorial (simplified obliquity) const obliq = 23.439 - 0.0000004 * (JD - 2451545.0); const cosObl = Math.cos(obliq * RAD); const sinObl = Math.sin(obliq * RAD); const lRad = Lm * RAD; const bRad = Bm * RAD; const RA = Math.atan2( Math.sin(lRad) * cosObl - Math.tan(bRad) * sinObl, Math.cos(lRad) ); const Dec = Math.asin( Math.sin(bRad) * cosObl + Math.cos(bRad) * sinObl * Math.sin(lRad) ); // Hour angle const GMST = 280.46061837 + 360.98564736629 * (JD - 2451545.0); const LST = (GMST + lon) * RAD; const HA = LST - RA; // Altitude const sinAlt = Math.sin(lat * RAD) * Math.sin(Dec) + Math.cos(lat * RAD) * Math.cos(Dec) * Math.cos(HA); return Math.asin(sinAlt) / RAD; } function dateToJD(date) { const Y = date.getUTCFullYear(); const M = date.getUTCMonth() + 1; const D = date.getUTCDate() + date.getUTCHours() / 24 + date.getUTCMinutes() / 1440 + date.getUTCSeconds() / 86400; let y = Y, m = M; if (m <= 2) { y--; m += 12; } const A = Math.floor(y / 100); const B = 2 - A + Math.floor(A / 4); return Math.floor(365.25 * (y + 4716)) + Math.floor(30.6001 * (m + 1)) + D + B - 1524.5; } function getDayOfYear(date) { const start = new Date(date.getFullYear(), 0, 0); return Math.floor((date - start) / 86400000); } // --- Orchestrator ------------------------------------------------------------ let _lastLat = null, _lastLon = null; async function fetchAllWeatherData(lat, lon) { _lastLat = lat; _lastLon = lon; document.getElementById('alerts-section').hidden = true; document.getElementById('weather-summary').textContent = ''; document.getElementById('current-section').innerHTML = '
Loading...
'; document.getElementById('details-section').innerHTML = ''; document.getElementById('hourly-section').innerHTML = ''; document.getElementById('daily-section').innerHTML = ''; document.getElementById('radar-section').innerHTML = ''; document.getElementById('sun-section').innerHTML = ''; document.getElementById('moon-section').innerHTML = ''; try { const [meteo, alerts, airQuality] = await Promise.all([ fetchOpenMeteo(lat, lon), fetchAlerts(lat, lon), fetchAirQuality(lat, lon), ]); document.getElementById('weather-summary').textContent = generateSummary(meteo.current, meteo.hourly, meteo.daily); renderCurrent(meteo.current, airQuality); renderPollen(airQuality, lat, lon); renderHourly(meteo.hourly); renderDaily(meteo.daily, meteo.hourly); renderAlerts(alerts); renderRadar(lat, lon); renderSunMoon(meteo.daily, lat, lon); applySectionPreferences(); } catch (err) { document.getElementById('current-section').innerHTML = `

Failed to load weather data. Please try again.

`; } } // --- Navigation & Event Listeners -------------------------------------------- function showHome() { weatherView.hidden = true; homeView.hidden = false; searchInput.value = ''; searchError.hidden = true; } function showWeather(location, query) { homeView.hidden = true; weatherView.hidden = false; const zipMatch = query && query.trim().match(/^(\d{5})$/); if (zipMatch) { locationName.textContent = `${location.name}, ${location.region} (${zipMatch[1]})`; } else { locationName.textContent = `${location.name}, ${location.region}`; } } searchForm.addEventListener('submit', async (e) => { e.preventDefault(); const query = searchInput.value.trim(); if (!query) return; searchError.hidden = true; searchForm.querySelector('button').disabled = true; searchForm.querySelector('button').textContent = 'Searching...'; try { const location = await geocode(query); setUnitsForCountry(location.country); updateURL(query); showWeather(location, query); fetchAllWeatherData(location.lat, location.lon); } catch (err) { searchError.textContent = err.message; searchError.hidden = false; } finally { searchForm.querySelector('button').disabled = false; searchForm.querySelector('button').textContent = 'Search'; } }); backBtn.addEventListener('click', () => { showHome(); history.pushState(null, '', location.pathname); }); document.getElementById('units-toggle').addEventListener('click', () => { toggleUnits(); if (_lastLat !== null) { fetchAllWeatherData(_lastLat, _lastLon); } }); document.getElementById('time-toggle').addEventListener('click', () => { units.time24h = !units.time24h; updateUnitsToggleLabel(); saveUnitsPref(); if (_lastLat !== null) { fetchAllWeatherData(_lastLat, _lastLon); } }); // --- URL State --------------------------------------------------------------- function updateURL(query) { history.pushState(null, '', `?q=${encodeURIComponent(query)}`); } function getQueryFromURL() { // Support ?q=78258, #78258, and legacy hash const params = new URLSearchParams(window.location.search); if (params.get('q')) return params.get('q'); if (window.location.hash.length > 1) return decodeURIComponent(window.location.hash.slice(1)); return ''; } window.addEventListener('popstate', () => { const query = getQueryFromURL(); if (query) { searchInput.value = query; searchForm.dispatchEvent(new Event('submit')); } else { showHome(); } }); // Load from URL on page load (function () { const query = getQueryFromURL(); if (query) { searchInput.value = query; searchForm.dispatchEvent(new Event('submit')); } })(); // Init drag-to-reorder (event delegation, works across re-renders) initSectionDrag(); initChartDrag(); // --- Dark Mode --------------------------------------------------------------- (function () { const toggle = document.getElementById('theme-toggle'); const stored = localStorage.getItem('theme'); function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); toggle.textContent = theme === 'dark' ? '☀️' : '🌙'; localStorage.setItem('theme', theme); updateDayBackgrounds(); if (_lastLat !== null) renderRadar(_lastLat, _lastLon); } // Initialize: use stored preference, fall back to OS preference if (stored) { setTheme(stored); } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { setTheme('dark'); } toggle.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme'); setTheme(current === 'dark' ? 'light' : 'dark'); }); })(); // --- Restore Defaults -------------------------------------------------------- document.getElementById('restore-defaults').addEventListener('click', () => { localStorage.removeItem('sectionPrefs'); if (_lastLat !== null) { fetchAllWeatherData(_lastLat, _lastLon); } }); // --- Privacy Panel ----------------------------------------------------------- function togglePrivacy() { const panel = document.getElementById('privacy-panel'); panel.hidden = !panel.hidden; } document.getElementById('privacy-toggle-home').addEventListener('click', togglePrivacy); document.getElementById('privacy-toggle-weather').addEventListener('click', togglePrivacy); document.getElementById('privacy-close').addEventListener('click', () => { document.getElementById('privacy-panel').hidden = true; }); document.addEventListener('click', (e) => { const panel = document.getElementById('privacy-panel'); if (!panel.hidden && !panel.contains(e.target) && e.target.id !== 'privacy-toggle-home' && e.target.id !== 'privacy-toggle-weather') { panel.hidden = true; } });