// ============================================================================= // 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 = '
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; } });