/* ============================================================ SkyLens — Main Application Global weather intelligence, Singapore-first APIs: data.gov.sg (NEA), Open-Meteo, OpenWeatherMap, WeatherAPI ============================================================ */ 'use strict'; // ── State ────────────────────────────────────────────────────── const STATE = { lat: 1.3521, lon: 103.8198, locationName: 'Singapore', country: 'SG', unit: 'C', // 'C' | 'F' theme: 'dark', activeLayer: 'temperature', keys: { owm: '', wapi: '', tio: '' }, weather: null, hourly: [], daily: [], stations: [], neaForecast: [], hourlyChart: null, map: null, stationMarkers: [], clockInterval: null, }; // Returns true when the current location is within Singapore's bounding box function isSingapore() { return STATE.lat >= 1.15 && STATE.lat <= 1.48 && STATE.lon >= 103.6 && STATE.lon <= 104.1; } // ── Country code → flag emoji ────────────────────────────────── function countryFlag(cc) { if (!cc || cc.length !== 2) return '🌍'; return String.fromCodePoint(...[...cc.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); } // ── Country name → ISO2 code lookup ─────────────────────────── const COUNTRY_MAP = { 'japan':'JP','china':'CN','korea':'KR','south korea':'KR','north korea':'KP', 'usa':'US','united states':'US','america':'US','uk':'GB','united kingdom':'GB', 'england':'GB','australia':'AU','canada':'CA','france':'FR','germany':'DE', 'italy':'IT','spain':'ES','portugal':'PT','netherlands':'NL','belgium':'BE', 'switzerland':'CH','austria':'AT','sweden':'SE','norway':'NO','denmark':'DK', 'finland':'FI','poland':'PL','russia':'RU','india':'IN','pakistan':'PK', 'bangladesh':'BD','sri lanka':'LK','thailand':'TH','vietnam':'VN','cambodia':'KH', 'myanmar':'MM','laos':'LA','philippines':'PH','indonesia':'ID','malaysia':'MY', 'singapore':'SG','brunei':'BN','taiwan':'TW','hong kong':'HK','macau':'MO', 'new zealand':'NZ','brazil':'BR','argentina':'AR','chile':'CL','colombia':'CO', 'peru':'PE','mexico':'MX','egypt':'EG','nigeria':'NG','south africa':'ZA', 'kenya':'KE','ethiopia':'ET','ghana':'GH','morocco':'MA','turkey':'TR', 'iran':'IR','iraq':'IQ','saudi arabia':'SA','uae':'AE','israel':'IL', 'greece':'GR','czech republic':'CZ','hungary':'HU','romania':'RO','ukraine':'UA', }; // Major cities per country (lat/lon) for country-name searches const COUNTRY_CITIES = { JP:[{name:'Tokyo',lat:35.6762,lon:139.6503},{name:'Osaka',lat:34.6937,lon:135.5023},{name:'Kyoto',lat:35.0116,lon:135.7681},{name:'Sapporo',lat:43.0618,lon:141.3545},{name:'Fukuoka',lat:33.5904,lon:130.4017},{name:'Nagoya',lat:35.1815,lon:136.9066},{name:'Hiroshima',lat:34.3853,lon:132.4553},{name:'Sendai',lat:38.2682,lon:140.8694}], CN:[{name:'Beijing',lat:39.9042,lon:116.4074},{name:'Shanghai',lat:31.2304,lon:121.4737},{name:'Guangzhou',lat:23.1291,lon:113.2644},{name:'Shenzhen',lat:22.5431,lon:114.0579},{name:'Chengdu',lat:30.5728,lon:104.0668},{name:'Hangzhou',lat:30.2741,lon:120.1551},{name:'Wuhan',lat:30.5928,lon:114.3055},{name:'Xi\'an',lat:34.3416,lon:108.9398}], US:[{name:'New York',lat:40.7128,lon:-74.0060},{name:'Los Angeles',lat:34.0522,lon:-118.2437},{name:'Chicago',lat:41.8781,lon:-87.6298},{name:'Houston',lat:29.7604,lon:-95.3698},{name:'Phoenix',lat:33.4484,lon:-112.0740},{name:'Philadelphia',lat:39.9526,lon:-75.1652},{name:'San Antonio',lat:29.4241,lon:-98.4936},{name:'San Diego',lat:32.7157,lon:-117.1611}], GB:[{name:'London',lat:51.5074,lon:-0.1278},{name:'Manchester',lat:53.4808,lon:-2.2426},{name:'Birmingham',lat:52.4862,lon:-1.8904},{name:'Edinburgh',lat:55.9533,lon:-3.1883},{name:'Glasgow',lat:55.8642,lon:-4.2518},{name:'Liverpool',lat:53.4084,lon:-2.9916},{name:'Bristol',lat:51.4545,lon:-2.5879},{name:'Leeds',lat:53.8008,lon:-1.5491}], AU:[{name:'Sydney',lat:-33.8688,lon:151.2093},{name:'Melbourne',lat:-37.8136,lon:144.9631},{name:'Brisbane',lat:-27.4698,lon:153.0251},{name:'Perth',lat:-31.9505,lon:115.8605},{name:'Adelaide',lat:-34.9285,lon:138.6007},{name:'Canberra',lat:-35.2809,lon:149.1300},{name:'Darwin',lat:-12.4634,lon:130.8456},{name:'Hobart',lat:-42.8821,lon:147.3272}], MY:[{name:'Kuala Lumpur',lat:3.1390,lon:101.6869},{name:'George Town',lat:5.4141,lon:100.3288},{name:'Johor Bahru',lat:1.4927,lon:103.7414},{name:'Ipoh',lat:4.5975,lon:101.0901},{name:'Kota Kinabalu',lat:5.9804,lon:116.0735},{name:'Kuching',lat:1.5497,lon:110.3592},{name:'Shah Alam',lat:3.0733,lon:101.5185},{name:'Petaling Jaya',lat:3.1073,lon:101.6067}], ID:[{name:'Jakarta',lat:-6.2088,lon:106.8456},{name:'Surabaya',lat:-7.2575,lon:112.7521},{name:'Bandung',lat:-6.9175,lon:107.6191},{name:'Medan',lat:3.5952,lon:98.6722},{name:'Semarang',lat:-6.9932,lon:110.4203},{name:'Makassar',lat:-5.1477,lon:119.4327},{name:'Palembang',lat:-2.9761,lon:104.7754},{name:'Bali',lat:-8.3405,lon:115.0920}], TH:[{name:'Bangkok',lat:13.7563,lon:100.5018},{name:'Chiang Mai',lat:18.7883,lon:98.9853},{name:'Phuket',lat:7.8804,lon:98.3923},{name:'Pattaya',lat:12.9236,lon:100.8825},{name:'Hat Yai',lat:7.0086,lon:100.4747},{name:'Nakhon Ratchasima',lat:14.9799,lon:102.0978},{name:'Udon Thani',lat:17.4138,lon:102.7872},{name:'Khon Kaen',lat:16.4419,lon:102.8360}], IN:[{name:'Mumbai',lat:19.0760,lon:72.8777},{name:'Delhi',lat:28.7041,lon:77.1025},{name:'Bangalore',lat:12.9716,lon:77.5946},{name:'Hyderabad',lat:17.3850,lon:78.4867},{name:'Chennai',lat:13.0827,lon:80.2707},{name:'Kolkata',lat:22.5726,lon:88.3639},{name:'Pune',lat:18.5204,lon:73.8567},{name:'Ahmedabad',lat:23.0225,lon:72.5714}], KR:[{name:'Seoul',lat:37.5665,lon:126.9780},{name:'Busan',lat:35.1796,lon:129.0756},{name:'Incheon',lat:37.4563,lon:126.7052},{name:'Daegu',lat:35.8714,lon:128.6014},{name:'Daejeon',lat:36.3504,lon:127.3845},{name:'Gwangju',lat:35.1595,lon:126.8526},{name:'Suwon',lat:37.2636,lon:127.0286},{name:'Ulsan',lat:35.5384,lon:129.3114}], FR:[{name:'Paris',lat:48.8566,lon:2.3522},{name:'Marseille',lat:43.2965,lon:5.3698},{name:'Lyon',lat:45.7640,lon:4.8357},{name:'Toulouse',lat:43.6047,lon:1.4442},{name:'Nice',lat:43.7102,lon:7.2620},{name:'Nantes',lat:47.2184,lon:-1.5536},{name:'Strasbourg',lat:48.5734,lon:7.7521},{name:'Bordeaux',lat:44.8378,lon:-0.5792}], DE:[{name:'Berlin',lat:52.5200,lon:13.4050},{name:'Hamburg',lat:53.5753,lon:10.0153},{name:'Munich',lat:48.1351,lon:11.5820},{name:'Cologne',lat:50.9333,lon:6.9500},{name:'Frankfurt',lat:50.1109,lon:8.6821},{name:'Stuttgart',lat:48.7758,lon:9.1829},{name:'Düsseldorf',lat:51.2217,lon:6.7762},{name:'Dortmund',lat:51.5136,lon:7.4653}], PH:[{name:'Manila',lat:14.5995,lon:120.9842},{name:'Quezon City',lat:14.6760,lon:121.0437},{name:'Cebu',lat:10.3157,lon:123.8854},{name:'Davao',lat:7.1907,lon:125.4553},{name:'Zamboanga',lat:6.9214,lon:122.0790},{name:'Cagayan de Oro',lat:8.4542,lon:124.6319},{name:'Iloilo',lat:10.7202,lon:122.5621},{name:'Bacolod',lat:10.6407,lon:122.9457}], VN:[{name:'Ho Chi Minh City',lat:10.8231,lon:106.6297},{name:'Hanoi',lat:21.0285,lon:105.8542},{name:'Da Nang',lat:16.0544,lon:108.2022},{name:'Hai Phong',lat:20.8449,lon:106.6881},{name:'Can Tho',lat:10.0452,lon:105.7469},{name:'Nha Trang',lat:12.2388,lon:109.1967},{name:'Hue',lat:16.4637,lon:107.5909},{name:'Da Lat',lat:11.9404,lon:108.4583}], RU:[{name:'Moscow',lat:55.7558,lon:37.6173},{name:'Saint Petersburg',lat:59.9311,lon:30.3609},{name:'Novosibirsk',lat:55.0084,lon:82.9357},{name:'Yekaterinburg',lat:56.8389,lon:60.6057},{name:'Kazan',lat:55.8304,lon:49.0661},{name:'Nizhny Novgorod',lat:56.2965,lon:43.9361},{name:'Chelyabinsk',lat:55.1644,lon:61.4368},{name:'Vladivostok',lat:43.1332,lon:131.9113}], TR:[{name:'Istanbul',lat:41.0082,lon:28.9784},{name:'Ankara',lat:39.9334,lon:32.8597},{name:'Izmir',lat:38.4192,lon:27.1287},{name:'Bursa',lat:40.1885,lon:29.0610},{name:'Antalya',lat:36.8969,lon:30.7133},{name:'Adana',lat:37.0000,lon:35.3213},{name:'Gaziantep',lat:37.0662,lon:37.3833},{name:'Konya',lat:37.8714,lon:32.4846}], SA:[{name:'Riyadh',lat:24.7136,lon:46.6753},{name:'Jeddah',lat:21.4858,lon:39.1925},{name:'Mecca',lat:21.3891,lon:39.8579},{name:'Medina',lat:24.5247,lon:39.5692},{name:'Dammam',lat:26.4207,lon:50.0888},{name:'Khobar',lat:26.2172,lon:50.1971},{name:'Tabuk',lat:28.3998,lon:36.5715},{name:'Abha',lat:18.2164,lon:42.5053}], AE:[{name:'Dubai',lat:25.2048,lon:55.2708},{name:'Abu Dhabi',lat:24.4539,lon:54.3773},{name:'Sharjah',lat:25.3463,lon:55.4209},{name:'Ajman',lat:25.4052,lon:55.5136},{name:'Ras Al Khaimah',lat:25.7895,lon:55.9432},{name:'Fujairah',lat:25.1288,lon:56.3265},{name:'Umm Al Quwain',lat:25.5647,lon:55.5554},{name:'Al Ain',lat:24.2075,lon:55.7447}], EG:[{name:'Cairo',lat:30.0444,lon:31.2357},{name:'Alexandria',lat:31.2001,lon:29.9187},{name:'Giza',lat:30.0131,lon:31.2089},{name:'Sharm El Sheikh',lat:27.9158,lon:34.3300},{name:'Luxor',lat:25.6872,lon:32.6396},{name:'Aswan',lat:24.0889,lon:32.8998},{name:'Hurghada',lat:27.2579,lon:33.8116},{name:'Port Said',lat:31.2653,lon:32.3019}], ZA:[{name:'Johannesburg',lat:-26.2041,lon:28.0473},{name:'Cape Town',lat:-33.9249,lon:18.4241},{name:'Durban',lat:-29.8587,lon:31.0218},{name:'Pretoria',lat:-25.7479,lon:28.2293},{name:'Port Elizabeth',lat:-33.9608,lon:25.6022},{name:'Bloemfontein',lat:-29.0852,lon:26.1596},{name:'East London',lat:-33.0153,lon:27.9116},{name:'Nelspruit',lat:-25.4753,lon:30.9694}], BR:[{name:'São Paulo',lat:-23.5505,lon:-46.6333},{name:'Rio de Janeiro',lat:-22.9068,lon:-43.1729},{name:'Brasília',lat:-15.7942,lon:-47.8822},{name:'Salvador',lat:-12.9714,lon:-38.5014},{name:'Fortaleza',lat:-3.7172,lon:-38.5433},{name:'Belo Horizonte',lat:-19.9167,lon:-43.9345},{name:'Manaus',lat:-3.1190,lon:-60.0217},{name:'Curitiba',lat:-25.4284,lon:-49.2733}], MX:[{name:'Mexico City',lat:19.4326,lon:-99.1332},{name:'Guadalajara',lat:20.6597,lon:-103.3496},{name:'Monterrey',lat:25.6866,lon:-100.3161},{name:'Puebla',lat:19.0414,lon:-98.2063},{name:'Tijuana',lat:32.5149,lon:-117.0382},{name:'León',lat:21.1221,lon:-101.6824},{name:'Cancún',lat:21.1619,lon:-86.8515},{name:'Mérida',lat:20.9674,lon:-89.5926}], }; // ── Geocoding ────────────────────────────────────────────────── async function geocodeSearch(query) { const q = query.trim().toLowerCase(); // Exact country name match → return that country's major cities only const cc = COUNTRY_MAP[q]; if (cc && COUNTRY_CITIES[cc]) { const label = query.charAt(0).toUpperCase() + query.slice(1); return COUNTRY_CITIES[cc].map(c => ({ name: c.name, country: cc, countryName: label, admin: label, population: 0, lat: c.lat, lon: c.lon, importance: 1, })); } // City/partial search via Open-Meteo, but filter out results whose // name exactly matches a known country (e.g. "Russia, Ohio") const results = await geocodeOpenMeteo(query); return results.filter(r => { const nameLower = r.name.toLowerCase(); // Drop if the place name IS a country name but the country code doesn't match const mappedCc = COUNTRY_MAP[nameLower]; if (mappedCc && r.country !== mappedCc) return false; return true; }); } // Open-Meteo geocoding as fallback (city names only, sorted by population) async function geocodeOpenMeteo(query) { try { const r = await fetchWithTimeout( `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=15&language=en&format=json` ); if (!r.ok) return []; const data = await r.json(); return (data.results || []) .map(loc => ({ name: loc.name, country: loc.country_code || '', countryName: loc.country || '', admin: loc.admin1 || '', population: loc.population || 0, lat: loc.latitude, lon: loc.longitude, importance: loc.population || 0, })) .sort((a, b) => b.population - a.population) .slice(0, 8); } catch { return []; } } async function reverseGeocode(lat, lon) { // Try OneMap first for Singapore coordinates (returns proper SG neighbourhood names) if (lat >= 1.15 && lat <= 1.48 && lon >= 103.6 && lon <= 104.1) { try { const r = await fetchWithTimeout( `https://www.onemap.gov.sg/api/common/elastic/search?searchVal=${lat},${lon}&returnGeom=Y&getAddrDetails=Y&pageNum=1` ); // OneMap search by coords doesn't work directly — use reverse geocode endpoint } catch {} // Use Nominatim with zoom=16 for Singapore to get street/neighbourhood level try { const r = await fetchWithTimeout( `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=16&addressdetails=1`, { headers: { 'Accept-Language': 'en' } } ); if (r.ok) { const d = await r.json(); const addr = d.address || {}; // For SG, use neighbourhood/suburb as the primary name const local = addr.neighbourhood || addr.suburb || addr.quarter || addr.residential || addr.village || addr.town || ''; const cc = 'SG'; const display = local ? `${local}, Singapore` : 'Singapore'; return { name: local || 'Singapore', country: cc, display }; } } catch {} } // Global fallback — Nominatim zoom=10 try { const r = await fetchWithTimeout( `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`, { headers: { 'Accept-Language': 'en' } } ); if (!r.ok) return null; const d = await r.json(); const addr = d.address || {}; const local = addr.neighbourhood || addr.suburb || addr.quarter || addr.village || ''; const city = addr.city || addr.town || addr.county || addr.state || ''; const country = addr.country || ''; const cc = (addr.country_code || '').toUpperCase(); let display; if (local && local.toLowerCase() !== city.toLowerCase() && local.toLowerCase() !== country.toLowerCase()) { display = `${local}, ${country}`; } else if (city && city.toLowerCase() !== country.toLowerCase()) { display = `${city}, ${country}`; } else { display = country; } return { name: local || city || country, country: cc, display }; } catch { return null; } } // ── Weather condition mappings ───────────────────────────────── const WMO_CODES = { 0: { desc: 'Clear sky', icon: '☀️', condition: 'sunny' }, 1: { desc: 'Mainly clear', icon: '🌤', condition: 'sunny' }, 2: { desc: 'Partly cloudy', icon: '⛅', condition: 'cloudy' }, 3: { desc: 'Overcast', icon: '☁️', condition: 'cloudy' }, 45: { desc: 'Foggy', icon: '🌫', condition: 'cloudy' }, 48: { desc: 'Icy fog', icon: '🌫', condition: 'cloudy' }, 51: { desc: 'Light drizzle', icon: '🌦', condition: 'rainy' }, 53: { desc: 'Drizzle', icon: '🌦', condition: 'rainy' }, 55: { desc: 'Heavy drizzle', icon: '🌧', condition: 'rainy' }, 61: { desc: 'Light rain', icon: '🌧', condition: 'rainy' }, 63: { desc: 'Rain', icon: '🌧', condition: 'rainy' }, 65: { desc: 'Heavy rain', icon: '🌧', condition: 'rainy' }, 80: { desc: 'Light showers', icon: '🌦', condition: 'rainy' }, 81: { desc: 'Showers', icon: '🌧', condition: 'rainy' }, 82: { desc: 'Heavy showers', icon: '⛈', condition: 'rainy' }, 95: { desc: 'Thunderstorm', icon: '⛈', condition: 'rainy' }, 96: { desc: 'Thunderstorm + hail', icon: '⛈', condition: 'rainy' }, 99: { desc: 'Severe thunderstorm', icon: '🌩', condition: 'rainy' }, }; const NEA_FORECAST_ICONS = { 'Fair': '☀️', 'Fair (Day)': '☀️', 'Fair (Night)': '🌙', 'Fair and Warm': '🌤', 'Partly Cloudy': '⛅', 'Partly Cloudy (Day)': '⛅', 'Partly Cloudy (Night)': '🌙', 'Cloudy': '☁️', 'Hazy': '🌫', 'Slightly Hazy': '🌫', 'Windy': '💨', 'Mist': '🌫', 'Fog': '🌫', 'Light Rain': '🌦', 'Moderate Rain': '🌧', 'Heavy Rain': '🌧', 'Passing Showers': '🌦', 'Light Showers': '🌦', 'Showers': '🌧', 'Heavy Showers': '⛈', 'Thundery Showers': '⛈', 'Heavy Thundery Showers': '⛈', 'Heavy Thundery Showers with Gusty Winds': '🌩', }; // ── Utility helpers ──────────────────────────────────────────── const $ = id => document.getElementById(id); const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; }; function toF(c) { return Math.round(c * 9/5 + 32); } function displayTemp(c) { const val = STATE.unit === 'F' ? toF(c) : Math.round(c); return val; } function tempUnit() { return STATE.unit === 'F' ? '°F' : '°C'; } function formatTime(isoOrDate, opts = {}) { const d = typeof isoOrDate === 'string' ? new Date(isoOrDate) : isoOrDate; return d.toLocaleTimeString('en-SG', { hour: '2-digit', minute: '2-digit', hour12: true, ...opts }); } function formatDay(isoDate) { const d = new Date(isoDate + 'T00:00:00+08:00'); const today = new Date(); const diff = Math.round((d - today) / 86400000); if (diff === 0) return 'Today'; if (diff === 1) return 'Tomorrow'; return d.toLocaleDateString('en-SG', { weekday: 'short', month: 'short', day: 'numeric' }); } function windDir(deg) { const dirs = ['N','NE','E','SE','S','SW','W','NW']; return dirs[Math.round(deg / 45) % 8]; } function formatPop(n) { if (n >= 1_000_000) return `${(n/1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n/1_000).toFixed(0)}K`; return `${n}`; } function showToast(msg, type = 'info', duration = 4000) { const t = el('div', `toast ${type}`, msg); $('toastContainer').appendChild(t); setTimeout(() => t.remove(), duration); } function uviCategory(uvi) { if (uvi <= 2) return { label: 'Low', color: '#3fb950' }; if (uvi <= 5) return { label: 'Moderate', color: '#d29922' }; if (uvi <= 7) return { label: 'High', color: '#f0883e' }; if (uvi <= 10) return { label: 'Very High', color: '#f85149' }; return { label: 'Extreme', color: '#bc8cff' }; } function aqiCategory(aqi) { if (aqi <= 50) return { label: 'Good', cls: 'aqi-good', advice: 'Air quality is satisfactory.' }; if (aqi <= 100) return { label: 'Moderate', cls: 'aqi-moderate', advice: 'Acceptable; some pollutants may affect sensitive groups.' }; if (aqi <= 150) return { label: 'Unhealthy (SG)', cls: 'aqi-unhealthy', advice: 'Sensitive groups should reduce outdoor activity.' }; return { label: 'Unhealthy', cls: 'aqi-unhealthy', advice: 'Everyone should reduce prolonged outdoor exertion.' }; } // ── Clock ────────────────────────────────────────────────────── function startClock() { if (STATE.clockInterval) clearInterval(STATE.clockInterval); const update = () => { const now = new Date(); const timeStr = now.toLocaleTimeString('en-SG', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const dateStr = now.toLocaleDateString('en-SG', { weekday: 'short', day: 'numeric', month: 'short' }); const el = $('heroTime'); if (el) el.textContent = `${dateStr} · ${timeStr} SGT`; }; update(); STATE.clockInterval = setInterval(update, 1000); } // ── Sky Animation ────────────────────────────────────────────── function updateSkyAnimation(condition) { document.body.setAttribute('data-condition', condition); const cloudLayer = $('cloudLayer'); const rainLayer = $('rainLayer'); const starLayer = $('starLayer'); const sunGlow = $('sunGlow'); cloudLayer.innerHTML = ''; rainLayer.innerHTML = ''; starLayer.innerHTML = ''; const isNight = condition === 'night'; const isRainy = condition === 'rainy'; const isSunny = condition === 'sunny'; // Clouds const cloudCount = isRainy ? 8 : (condition === 'cloudy' ? 6 : 3); for (let i = 0; i < cloudCount; i++) { const c = el('div', 'cloud-puff'); const w = 80 + Math.random() * 160; const h = 30 + Math.random() * 40; c.style.cssText = ` width:${w}px; height:${h}px; top:${5 + Math.random() * 30}%; left:${Math.random() * 100}%; opacity:${0.1 + Math.random() * 0.2}; animation-duration:${40 + Math.random() * 60}s; animation-delay:-${Math.random() * 60}s; `; cloudLayer.appendChild(c); } // Rain if (isRainy) { for (let i = 0; i < 80; i++) { const r = el('div', 'rain-drop'); const h = 15 + Math.random() * 25; r.style.cssText = ` left:${Math.random() * 100}%; top:-${h}px; height:${h}px; animation-duration:${0.6 + Math.random() * 0.8}s; animation-delay:-${Math.random() * 2}s; opacity:${0.4 + Math.random() * 0.4}; `; rainLayer.appendChild(r); } } // Stars if (isNight) { for (let i = 0; i < 120; i++) { const s = el('div', 'star'); const sz = 1 + Math.random() * 2.5; s.style.cssText = ` width:${sz}px; height:${sz}px; left:${Math.random() * 100}%; top:${Math.random() * 70}%; animation-duration:${2 + Math.random() * 4}s; animation-delay:-${Math.random() * 4}s; `; starLayer.appendChild(s); } } sunGlow.style.opacity = isSunny ? '1' : '0'; } // ── Fetch with timeout (8 seconds) ──────────────────────────── function fetchWithTimeout(url, options = {}, ms = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ms); return fetch(url, { ...options, signal: controller.signal }) .finally(() => clearTimeout(timer)); } // ── API Fetchers ─────────────────────────────────────────────── async function fetchOpenMeteo(lat, lon) { const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` + `¤t=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,` + `weather_code,wind_speed_10m,wind_direction_10m,surface_pressure,visibility,uv_index` + `&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index` + `&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,` + `precipitation_probability_max,sunrise,sunset,uv_index_max,wind_speed_10m_max` + `&timezone=auto&forecast_days=7`; const r = await fetchWithTimeout(url); if (!r.ok) throw new Error('Open-Meteo fetch failed'); return r.json(); } async function fetchNEA2Hour() { try { const r = await fetchWithTimeout('https://api.data.gov.sg/v1/environment/2-hour-weather-forecast'); if (!r.ok) return null; return r.json(); } catch { return null; } } async function fetchNEATemperature() { try { const r = await fetchWithTimeout('https://api.data.gov.sg/v1/environment/air-temperature'); if (!r.ok) return null; return r.json(); } catch { return null; } } async function fetchNEARainfall() { try { const r = await fetchWithTimeout('https://api.data.gov.sg/v1/environment/rainfall'); if (!r.ok) return null; return r.json(); } catch { return null; } } async function fetchNEAHumidity() { try { const r = await fetchWithTimeout('https://api.data.gov.sg/v1/environment/relative-humidity'); if (!r.ok) return null; return r.json(); } catch { return null; } } async function fetchNEAWind() { try { const r = await fetchWithTimeout('https://api.data.gov.sg/v1/environment/wind-speed'); if (!r.ok) return null; return r.json(); } catch { return null; } } async function fetchOWM(lat, lon) { const key = STATE.keys.owm; if (!key) return null; try { const r = await fetchWithTimeout( `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${key}&units=metric` ); if (!r.ok) return null; return r.json(); } catch { return null; } } async function fetchWeatherAPI(lat, lon) { const key = STATE.keys.wapi; if (!key) return null; try { const r = await fetchWithTimeout( `https://api.weatherapi.com/v1/current.json?key=${key}&q=${lat},${lon}&aqi=yes` ); if (!r.ok) return null; return r.json(); } catch { return null; } } async function fetchAirQuality(lat, lon) { try { const r = await fetchWithTimeout( `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}` + `¤t=us_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,ozone` ); if (!r.ok) return null; return r.json(); } catch { return null; } } // ── Render: Hero Card ────────────────────────────────────────── function renderHero(data) { const c = data.current; const wmo = WMO_CODES[c.weather_code] || { desc: 'Unknown', icon: '🌡', condition: 'default' }; $('heroLocation').textContent = STATE.locationName; $('heroIcon').textContent = wmo.icon; $('heroTemp').textContent = displayTemp(c.temperature_2m); $('heroDesc').textContent = wmo.desc; $('heroFeels').textContent = displayTemp(c.apparent_temperature); $('heroHumidity').textContent = `${c.relative_humidity_2m}%`; $('heroWind').textContent = `${Math.round(c.wind_speed_10m)} km/h ${windDir(c.wind_direction_10m)}`; $('heroRain').textContent = `${c.precipitation} mm`; $('heroPressure').textContent = `${Math.round(c.surface_pressure)} hPa`; $('heroVisibility').textContent = c.visibility ? `${(c.visibility/1000).toFixed(1)} km` : 'N/A'; const uvi = uviCategory(c.uv_index || 0); $('heroUV').innerHTML = `${Math.round(c.uv_index || 0)} ${uvi.label}`; // Night detection const hour = new Date().getHours(); const condition = (hour >= 19 || hour < 6) ? 'night' : wmo.condition; updateSkyAnimation(condition); // Update unit display document.querySelectorAll('.hero-unit').forEach(u => u.textContent = tempUnit()); } // ── Render: Hourly ───────────────────────────────────────────── function renderHourly(data) { const scroll = $('hourlyScroll'); scroll.innerHTML = ''; const now = new Date(); const hours = data.hourly; const temps = [], labels = [], rainProbs = [], rainMm = []; let count = 0; for (let i = 0; i < hours.time.length && count < 24; i++) { const t = new Date(hours.time[i]); if (t < now) continue; count++; const wmo = WMO_CODES[hours.weather_code[i]] || { icon: '🌡' }; const temp = displayTemp(hours.temperature_2m[i]); const prob = hours.precipitation_probability[i] || 0; const mm = hours.precipitation[i] || 0; const item = el('div', `hour-item${count === 1 ? ' active' : ''}`); // Show mm if > 0, otherwise show probability if > 0 let rainLabel = ''; if (mm > 0) rainLabel = `☔${mm.toFixed(1)}mm`; else if (prob > 0) rainLabel = `💧${prob}%`; item.innerHTML = ` ${formatTime(t, { hour: '2-digit', minute: '2-digit', hour12: true })} ${wmo.icon} ${temp}° ${rainLabel} `; scroll.appendChild(item); temps.push(temp); labels.push(formatTime(t, { hour: '2-digit', minute: '2-digit', hour12: false })); rainProbs.push(prob); rainMm.push(mm); } // Chart — temp line + rain mm bars const ctx = $('hourlyChart').getContext('2d'); if (STATE.hourlyChart) STATE.hourlyChart.destroy(); STATE.hourlyChart = new Chart(ctx, { data: { labels, datasets: [ { type: 'line', label: `Temp (${tempUnit()})`, data: temps, borderColor: '#58a6ff', backgroundColor: 'rgba(88,166,255,0.08)', fill: true, tension: 0.4, pointRadius: 2, borderWidth: 2, yAxisID: 'yTemp', order: 1, }, { type: 'bar', label: 'Rain (mm)', data: rainMm, backgroundColor: rainMm.map(v => v === 0 ? 'rgba(88,166,255,0.0)' : v < 1 ? 'rgba(88,166,255,0.4)' : v < 5 ? 'rgba(88,166,255,0.7)' : 'rgba(88,166,255,1.0)' ), borderColor: 'rgba(88,166,255,0.6)', borderWidth: 0, borderRadius: 3, yAxisID: 'yRain', order: 2, }, { type: 'line', label: 'Rain prob (%)', data: rainProbs, borderColor: 'rgba(88,166,255,0.35)', backgroundColor: 'transparent', fill: false, tension: 0.4, pointRadius: 0, borderWidth: 1.5, borderDash: [4, 4], yAxisID: 'yProb', order: 3, }, ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(22,27,34,0.95)', titleColor: '#8b949e', bodyColor: '#e6edf3', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, callbacks: { label: ctx => { if (ctx.dataset.label.startsWith('Temp')) return ` Temp: ${ctx.parsed.y}°`; if (ctx.dataset.label.startsWith('Rain (mm')) return ` Rain: ${ctx.parsed.y.toFixed(1)} mm`; if (ctx.dataset.label.startsWith('Rain prob')) return ` Prob: ${ctx.parsed.y}%`; return ctx.dataset.label; } } } }, scales: { x: { display: false }, yTemp: { display: true, position: 'left', grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#8b949e', font: { size: 10 }, maxTicksLimit: 4 }, }, yRain: { display: true, position: 'right', min: 0, grid: { display: false }, ticks: { color: '#58a6ff', font: { size: 9 }, maxTicksLimit: 3, callback: v => v > 0 ? `${v}mm` : '', }, }, yProb: { display: false, min: 0, max: 100, }, } } }); } // ── Render: Daily ────────────────────────────────────────────── function renderDaily(data) { const list = $('dailyList'); list.innerHTML = ''; const d = data.daily; for (let i = 0; i < d.time.length; i++) { const wmo = WMO_CODES[d.weather_code[i]] || { icon: '🌡', desc: '' }; const hi = displayTemp(d.temperature_2m_max[i]); const lo = displayTemp(d.temperature_2m_min[i]); const prob = d.precipitation_probability_max[i] || 0; const mm = d.precipitation_sum[i] || 0; const row = el('div', 'day-row'); row.innerHTML = ` ${formatDay(d.time[i])} ${wmo.icon} ${wmo.desc} ${prob > 0 ? `💧${prob}%` : ''} ${mm > 0 ? `${mm.toFixed(1)}mm` : ''}
${hi}° ${lo}°
`; list.appendChild(row); } } // ── Render: Rain Forecast ────────────────────────────────────── let rainChart = null; function renderRain(data) { const now = new Date(); const hours = data.hourly; const daily = data.daily; // ── Find next rain event ─────────────────────────────────── let nextRainIdx = -1; let nextRainMm = 0; let hoursUntil = 0; for (let i = 0; i < hours.time.length; i++) { const t = new Date(hours.time[i]); if (t < now) continue; const mm = hours.precipitation[i] || 0; if (mm > 0) { nextRainIdx = i; nextRainMm = mm; hoursUntil = Math.round((t - now) / 3600000); break; } } // ── Badge & banner ───────────────────────────────────────── const badge = $('nextRainBadge'); const banner = $('rainNextBanner'); if (nextRainIdx === -1) { badge.textContent = 'No rain expected'; badge.style.cssText = 'background:rgba(63,185,80,0.15);color:#3fb950;border-color:rgba(63,185,80,0.3)'; banner.innerHTML = `
☀️
No rain in the next 7 days
Enjoy the dry weather!
`; } else if (hoursUntil === 0) { badge.textContent = 'Raining now'; badge.style.cssText = 'background:rgba(88,166,255,0.2);color:#58a6ff;border-color:rgba(88,166,255,0.4)'; banner.innerHTML = `
🌧
Rain is falling now
${nextRainMm.toFixed(1)} mm this hour · ${hours.precipitation_probability[nextRainIdx]}% probability
`; } else { const label = hoursUntil === 1 ? 'in ~1 hour' : `in ~${hoursUntil} hours`; badge.textContent = `Rain ${label}`; badge.style.cssText = 'background:rgba(210,153,34,0.15);color:#d29922;border-color:rgba(210,153,34,0.3)'; const rainTime = new Date(hours.time[nextRainIdx]); banner.innerHTML = `
🌦
Rain expected ${label} · ${formatTime(rainTime)}
${nextRainMm.toFixed(1)} mm forecast · ${hours.precipitation_probability[nextRainIdx]}% probability
`; } // ── Hourly rain chart (next 24h) ─────────────────────────── const chartLabels = [], chartMm = [], chartProb = []; let count = 0; for (let i = 0; i < hours.time.length && count < 24; i++) { const t = new Date(hours.time[i]); if (t < now) continue; count++; chartLabels.push(formatTime(t, { hour: '2-digit', minute: '2-digit', hour12: false })); chartMm.push(hours.precipitation[i] || 0); chartProb.push(hours.precipitation_probability[i] || 0); } // Running accumulation const accumulated = []; let sum = 0; chartMm.forEach(v => { sum += v; accumulated.push(+sum.toFixed(2)); }); const ctx = $('rainChart').getContext('2d'); if (rainChart) rainChart.destroy(); rainChart = new Chart(ctx, { data: { labels: chartLabels, datasets: [ { type: 'bar', label: 'Rain (mm/hr)', data: chartMm, backgroundColor: chartMm.map(v => v === 0 ? 'rgba(88,166,255,0.06)' : v < 1 ? 'rgba(88,166,255,0.45)' : v < 5 ? 'rgba(88,166,255,0.75)' : 'rgba(88,166,255,1.0)' ), borderRadius: 3, yAxisID: 'yMm', order: 2, }, { type: 'line', label: 'Accumulated (mm)', data: accumulated, borderColor: '#f7c948', backgroundColor: 'transparent', tension: 0.4, pointRadius: 0, borderWidth: 2, yAxisID: 'yAcc', order: 1, }, { type: 'line', label: 'Probability (%)', data: chartProb, borderColor: 'rgba(255,255,255,0.2)', backgroundColor: 'rgba(255,255,255,0.03)', fill: true, tension: 0.4, pointRadius: 0, borderWidth: 1, borderDash: [3, 3], yAxisID: 'yProb', order: 3, }, ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(22,27,34,0.95)', titleColor: '#8b949e', bodyColor: '#e6edf3', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, callbacks: { label: ctx => { if (ctx.dataset.label === 'Rain (mm/hr)') return ` ${ctx.parsed.y.toFixed(1)} mm/hr`; if (ctx.dataset.label === 'Accumulated (mm)') return ` ${ctx.parsed.y.toFixed(1)} mm total`; if (ctx.dataset.label === 'Probability (%)') return ` ${ctx.parsed.y}% chance`; return ''; } } } }, scales: { x: { display: true, ticks: { color: '#8b949e', font: { size: 9 }, maxTicksLimit: 8, maxRotation: 0 }, grid: { display: false }, }, yMm: { display: true, position: 'left', min: 0, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#58a6ff', font: { size: 9 }, maxTicksLimit: 4, callback: v => `${v}mm` }, title: { display: true, text: 'mm/hr', color: '#58a6ff', font: { size: 9 } }, }, yAcc: { display: true, position: 'right', min: 0, grid: { display: false }, ticks: { color: '#f7c948', font: { size: 9 }, maxTicksLimit: 4, callback: v => `${v}mm` }, title: { display: true, text: 'Total', color: '#f7c948', font: { size: 9 } }, }, yProb: { display: false, min: 0, max: 100 }, } } }); // ── 7-day daily rain totals ──────────────────────────────── const totalsEl = $('rainDailyTotals'); totalsEl.innerHTML = ''; const maxMm = Math.max(...daily.precipitation_sum.map(v => v || 0), 1); daily.time.forEach((date, i) => { const mm = daily.precipitation_sum[i] || 0; const prob = daily.precipitation_probability_max[i] || 0; const pct = Math.min(100, (mm / maxMm) * 100); const wmo = WMO_CODES[daily.weather_code[i]] || { icon: '🌡' }; const row = el('div', 'rain-total-row'); row.innerHTML = ` ${formatDay(date)} ${wmo.icon}
${mm > 0 ? `${mm.toFixed(1)}mm` : '—'} ${prob > 0 ? `${prob}%` : ''} `; totalsEl.appendChild(row); }); } function renderSunMoon(data) { const d = data.daily; const sunrise = new Date(d.sunrise[0]); const sunset = new Date(d.sunset[0]); const now = new Date(); $('sunrise').textContent = formatTime(sunrise); $('sunset').textContent = formatTime(sunset); const dayMs = sunset - sunrise; const dayHrs = (dayMs / 3600000).toFixed(1); $('dayLength').textContent = `${dayHrs} hrs`; // Moon phase (simple calculation) const moonPhases = ['🌑 New','🌒 Waxing Crescent','🌓 First Quarter','🌔 Waxing Gibbous', '🌕 Full','🌖 Waning Gibbous','🌗 Last Quarter','🌘 Waning Crescent']; const daysSinceNew = (now - new Date('2000-01-06')) / 86400000; const phase = Math.floor((daysSinceNew % 29.53) / 29.53 * 8) % 8; $('moonPhase').textContent = moonPhases[phase]; // Draw sun arc const canvas = $('sunArcCanvas'); const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); const cx = W / 2, cy = H - 10, r = H - 20; // Arc track ctx.beginPath(); ctx.arc(cx, cy, r, Math.PI, 0); ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 3; ctx.stroke(); // Progress arc const progress = Math.max(0, Math.min(1, (now - sunrise) / (sunset - sunrise))); const angle = Math.PI + progress * Math.PI; ctx.beginPath(); ctx.arc(cx, cy, r, Math.PI, angle); ctx.strokeStyle = '#f7c948'; ctx.lineWidth = 3; ctx.stroke(); // Sun dot const sx = cx + r * Math.cos(angle); const sy = cy + r * Math.sin(angle); ctx.beginPath(); ctx.arc(sx, sy, 8, 0, Math.PI * 2); ctx.fillStyle = '#f7c948'; ctx.shadowColor = '#f7c948'; ctx.shadowBlur = 12; ctx.fill(); ctx.shadowBlur = 0; // Labels ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '11px Inter, sans-serif'; ctx.textAlign = 'left'; ctx.fillText(formatTime(sunrise), 4, H - 2); ctx.textAlign = 'right'; ctx.fillText(formatTime(sunset), W - 4, H - 2); } // ── Render: Activity Suitability ────────────────────────────── function renderActivity(data) { const c = data.current; const temp = c.temperature_2m; const humid = c.relative_humidity_2m; const wind = c.wind_speed_10m; const rain = c.precipitation; const uvi = c.uv_index || 0; function score(activity) { switch (activity) { case 'run': { let s = 100; if (temp > 32) s -= 30; else if (temp > 28) s -= 15; if (humid > 85) s -= 20; else if (humid > 75) s -= 10; if (rain > 0) s -= 40; if (uvi > 8) s -= 15; return Math.max(0, s); } case 'cycle': { let s = 100; if (temp > 33) s -= 25; else if (temp > 29) s -= 10; if (wind > 30) s -= 20; else if (wind > 20) s -= 10; if (rain > 0) s -= 50; return Math.max(0, s); } case 'hike': { let s = 100; if (temp > 34) s -= 30; else if (temp > 30) s -= 15; if (humid > 90) s -= 20; if (rain > 2) s -= 40; else if (rain > 0) s -= 20; if (uvi > 9) s -= 20; return Math.max(0, s); } case 'bbq': { let s = 100; if (rain > 0) s -= 60; if (wind > 25) s -= 20; if (temp < 22) s -= 10; return Math.max(0, s); } case 'swim': { let s = 100; if (temp < 26) s -= 20; if (rain > 2) s -= 30; if (wind > 30) s -= 15; return Math.max(0, s); } case 'golf': { let s = 100; if (rain > 0) s -= 50; if (wind > 25) s -= 20; if (uvi > 9) s -= 15; if (temp > 34) s -= 15; return Math.max(0, s); } default: return 50; } } const activities = [ { key: 'run', icon: '🏃', label: 'Running' }, { key: 'cycle', icon: '🚴', label: 'Cycling' }, { key: 'hike', icon: '🥾', label: 'Hiking' }, { key: 'bbq', icon: '🍖', label: 'BBQ' }, { key: 'swim', icon: '🏊', label: 'Swimming' }, { key: 'golf', icon: '⛳', label: 'Golf' }, ]; const grid = $('activityGrid'); grid.innerHTML = ''; activities.forEach(a => { const s = score(a.key); const cls = s >= 70 ? 'great' : s >= 40 ? 'good' : 'poor'; const lbl = s >= 70 ? 'Great' : s >= 40 ? 'Okay' : 'Poor'; const item = el('div', 'activity-item'); item.innerHTML = `
${a.icon} ${a.label}
${lbl} · ${s}% `; grid.appendChild(item); }); } // ── Render: NEA 2-Hour Forecast ──────────────────────────────── function renderNEA2Hour(data) { if (!data || !data.items || !data.items[0]) { $('neaRegions').innerHTML = '

NEA data unavailable

'; return; } const forecasts = data.items[0].forecasts; const container = $('neaRegions'); container.innerHTML = ''; forecasts.forEach(f => { const icon = NEA_FORECAST_ICONS[f.forecast] || '🌡'; const item = el('div', 'nea-region-item'); item.innerHTML = ` ${icon}
${f.area}
${f.forecast}
`; container.appendChild(item); }); } // ── Render: Consensus ────────────────────────────────────────── // NOTE: "NEA (est.)" uses the actual NEA 2-hour forecast text if available, // otherwise falls back to Open-Meteo. It is NOT a fabricated value. function renderConsensus(openMeteo, owm, wapi, nea2) { const body = $('consensusBody'); body.innerHTML = ''; const sources = []; // Open-Meteo (always available) const omTemp = openMeteo.current.temperature_2m; const omWmo = WMO_CODES[openMeteo.current.weather_code] || { desc: 'Unknown', icon: '🌡' }; sources.push({ name: 'Open-Meteo', temp: omTemp, desc: omWmo.desc, icon: omWmo.icon }); if (owm) { sources.push({ name: 'OWM', temp: owm.main.temp, desc: owm.weather[0].description, icon: owmIconToEmoji(owm.weather[0].icon), }); } if (wapi) { sources.push({ name: 'WeatherAPI', temp: wapi.current.temp_c, desc: wapi.current.condition.text, icon: '🌡', }); } // NEA 2-hour forecast — use actual forecast text, no fabricated temperature if (nea2 && nea2.items && nea2.items[0]) { const forecasts = nea2.items[0].forecasts; // Pick Central region as representative, fallback to first entry const central = forecasts.find(f => f.area.toLowerCase().includes('central') || f.area.toLowerCase().includes('city') || f.area.toLowerCase().includes('bishan') ) || forecasts[0]; if (central) { const neaIcon = NEA_FORECAST_ICONS[central.forecast] || '🌡'; sources.push({ name: 'NEA', temp: null, // NEA 2-hour forecast has no temperature value desc: `${central.area}: ${central.forecast}`, icon: neaIcon, isNea: true, noTemp: true, }); } } sources.forEach(s => { const row = el('div', 'source-row'); row.innerHTML = ` ${s.name} ${s.icon} ${s.noTemp ? '—' : `${displayTemp(s.temp)}°`} ${s.desc} `; body.appendChild(row); }); // Confidence: based on temp spread across numeric sources only const numericTemps = sources.filter(s => !s.noTemp).map(s => s.temp); if (numericTemps.length >= 2) { const spread = Math.max(...numericTemps) - Math.min(...numericTemps); const confidence = Math.max(20, Math.round(100 - spread * 15)); $('confidenceFill').style.width = `${confidence}%`; $('confidencePct').textContent = `${confidence}%`; } else { $('confidenceFill').style.width = '70%'; $('confidencePct').textContent = '70%'; } } function owmIconToEmoji(icon) { const map = { '01d': '☀️', '01n': '🌙', '02d': '🌤', '02n': '🌤', '03d': '☁️', '03n': '☁️', '04d': '☁️', '04n': '☁️', '09d': '🌧', '09n': '🌧', '10d': '🌦', '10n': '🌦', '11d': '⛈', '11n': '⛈', '13d': '❄️', '13n': '❄️', '50d': '🌫', '50n': '🌫', }; return map[icon] || '🌡'; } // ── Render: Air Quality ──────────────────────────────────────── function renderAirQuality(data) { const body = $('aqBody'); if (!data || !data.current) { body.innerHTML = '

AQ data unavailable

'; return; } const aqi = Math.round(data.current.us_aqi || 0); const cat = aqiCategory(aqi); $('aqiBadge').textContent = `AQI ${aqi}`; $('aqiBadge').style.background = aqi <= 50 ? 'rgba(63,185,80,0.15)' : aqi <= 100 ? 'rgba(210,153,34,0.15)' : 'rgba(248,81,73,0.15)'; body.innerHTML = `
${aqi} AQI
${cat.label}
${cat.advice}
PM2.5
${(data.current.pm2_5 || 0).toFixed(1)}
PM10
${(data.current.pm10 || 0).toFixed(1)}
O₃
${(data.current.ozone || 0).toFixed(1)}
NO₂
${(data.current.nitrogen_dioxide || 0).toFixed(1)}
CO
${((data.current.carbon_monoxide || 0)/1000).toFixed(2)}
Units
μg/m³
`; } // ── Render: Historical Comparison ───────────────────────────── function renderHistorical(data, inSG = true) { const card = document.getElementById('historyCard'); if (card) card.style.display = inSG ? '' : 'none'; if (!inSG) return; const body = $('historyBody'); const month = new Date().getMonth(); const sgAvg = { temp: [26.5,27.1,27.5,28.0,28.3,28.2,27.9,27.8,27.5,27.3,26.8,26.4][month], rain: [167,112,170,167,173,130,155,155,167,194,254,288][month], humid: [84,83,83,84,83,83,82,82,82,83,85,85][month], }; const cur = data.current; const rows = [ { label: 'Temperature', cur: cur.temperature_2m, avg: sgAvg.temp, unit: '°C', max: 40 }, { label: 'Humidity', cur: cur.relative_humidity_2m, avg: sgAvg.humid, unit: '%', max: 100 }, ]; body.innerHTML = ''; rows.forEach(r => { const pctNow = Math.min(100, (r.cur / r.max) * 100); const pctAvg = Math.min(100, (r.avg / r.max) * 100); const diff = (r.cur - r.avg).toFixed(1); const diffCls = diff > 0 ? 'above' : 'below'; const diffStr = diff > 0 ? `+${diff}` : diff; const row = el('div', 'hist-row'); row.innerHTML = ` ${r.label}
${Math.round(r.cur)}${r.unit} ${diffStr} `; body.appendChild(row); }); // Legend const legend = el('div', '', `
Current Monthly avg
`); body.appendChild(legend); } // ── Map: Tile URL helper ─────────────────────────────────────── function mapTileUrl(theme) { return theme === 'light' ? 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' : 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; } // ── Map: Initialize ──────────────────────────────────────────── function initMap() { if (STATE.map) return; const map = L.map('stationMap', { center: [1.3521, 103.8198], zoom: 11, zoomControl: true, attributionControl: false, }); // Use the already-applied theme so tiles match on first load L.tileLayer(mapTileUrl(STATE.theme), { maxZoom: 19, }).addTo(map); // Attribution L.control.attribution({ prefix: false }) .addAttribution('© CARTO | NEA Singapore') .addTo(map); STATE.map = map; // Click to set location map.on('click', e => { STATE.lat = e.latlng.lat; STATE.lon = e.latlng.lng; STATE.locationName = `${e.latlng.lat.toFixed(4)}, ${e.latlng.lng.toFixed(4)}`; loadWeather(); }); } // ── Map: Update station markers ──────────────────────────────── function updateMapMarkers(stationData, layer) { if (!STATE.map) return; // Clear old markers STATE.stationMarkers.forEach(m => STATE.map.removeLayer(m)); STATE.stationMarkers = []; if (!stationData || !stationData.metadata || !stationData.items) return; const stations = stationData.metadata.stations; // Real NEA API: items[0].readings is an array of { station_id, value } const readings = stationData.items[0]?.readings || []; const readMap = {}; readings.forEach(r => { readMap[r.station_id] = r.value; }); // Color scale — thresholds tuned to actual NEA data ranges const colorScale = { temperature: v => v < 26 ? '#58a6ff' : v < 29 ? '#3fb950' : v < 32 ? '#d29922' : '#f85149', rainfall: v => v === 0 ? '#3fb950' : v < 1 ? '#d29922' : '#f85149', humidity: v => v < 70 ? '#3fb950' : v < 85 ? '#d29922' : '#f85149', wind: v => v < 5 ? '#3fb950' : v < 15 ? '#d29922' : '#f85149', // knots }; const unitLabel = { temperature: '°C', rainfall: 'mm', humidity: '%', wind: 'kt', // NEA wind-speed API returns knots }; const legend = $('mapLegend'); legend.innerHTML = ''; const legendItems = { temperature: [{ color: '#58a6ff', label: '<26°C' }, { color: '#3fb950', label: '26-29°C' }, { color: '#d29922', label: '29-32°C' }, { color: '#f85149', label: '>32°C' }], rainfall: [{ color: '#3fb950', label: 'Dry (0mm)' }, { color: '#d29922', label: 'Light (<1mm)' }, { color: '#f85149', label: 'Heavy (≥1mm)' }], humidity: [{ color: '#3fb950', label: '<70%' }, { color: '#d29922', label: '70-85%' }, { color: '#f85149', label: '>85%' }], wind: [{ color: '#3fb950', label: '<5 kt' }, { color: '#d29922', label: '5-15 kt' }, { color: '#f85149', label: '>15 kt' }], }; (legendItems[layer] || []).forEach(item => { const li = el('div', 'legend-item'); li.innerHTML = `${item.label}`; legend.appendChild(li); }); stations.forEach(st => { const val = readMap[st.id]; if (val === undefined) return; const color = (colorScale[layer] || colorScale.temperature)(val); // Format display value compactly let display; if (layer === 'temperature') display = `${val.toFixed(1)}°`; else if (layer === 'humidity') display = `${Math.round(val)}%`; else if (layer === 'rainfall') display = val === 0 ? '0' : `${val.toFixed(1)}`; else display = `${val.toFixed(1)}`; // wind in knots const size = 36; const icon = L.divIcon({ className: '', html: `
${display}
`, iconSize: [size, size], iconAnchor: [size/2, size/2], }); const unitFull = { temperature: '°C', rainfall: 'mm/5min', humidity: '%', wind: 'knots' }; const marker = L.marker([st.location.latitude, st.location.longitude], { icon }) .addTo(STATE.map) .bindPopup(` ${st.name}
${layer.charAt(0).toUpperCase() + layer.slice(1)}: ${val} ${unitFull[layer] || ''}
Station: ${st.id} `); STATE.stationMarkers.push(marker); }); } // ── Main Load ────────────────────────────────────────────────── let _loading = false; async function loadWeather() { if (_loading) return; _loading = true; const { lat, lon } = STATE; showToast('Fetching weather…', 'info', 2000); try { // Parallel fetch — NEA APIs only when location is within Singapore const inSG = isSingapore(); const neaPromise = inSG ? Promise.allSettled([ fetchNEA2Hour(), fetchNEATemperature(), fetchNEARainfall(), fetchNEAHumidity(), fetchNEAWind(), ]) : Promise.resolve([ { status: 'fulfilled', value: null }, { status: 'fulfilled', value: null }, { status: 'fulfilled', value: null }, { status: 'fulfilled', value: null }, { status: 'fulfilled', value: null }, ]); const [omData, aqData, owmData, wapiData, neaResults] = await Promise.all([ fetchOpenMeteo(lat, lon).then(v => ({ status: 'fulfilled', value: v })).catch(e => ({ status: 'rejected', reason: e })), fetchAirQuality(lat, lon).then(v => ({ status: 'fulfilled', value: v })).catch(() => ({ status: 'fulfilled', value: null })), fetchOWM(lat, lon).then(v => ({ status: 'fulfilled', value: v })).catch(() => ({ status: 'fulfilled', value: null })), fetchWeatherAPI(lat, lon).then(v => ({ status: 'fulfilled', value: v })).catch(() => ({ status: 'fulfilled', value: null })), neaPromise, ]); const [neaForecastRes, neaTempRes, neaRainRes, neaHumidRes, neaWindRes] = neaResults; const om = omData.status === 'fulfilled' ? omData.value : null; const aq = aqData.value; const owm = owmData.value; const wapi = wapiData.value; const nea2 = neaForecastRes?.value ?? null; const nt = neaTempRes?.value ?? null; const nr = neaRainRes?.value ?? null; const nh = neaHumidRes?.value ?? null; const nw = neaWindRes?.value ?? null; if (!om) throw new Error('Primary weather data unavailable'); STATE.weather = om; // Show/hide Singapore-specific panels const sgOnlyIds = ['neaCard', 'mapCard', 'activityCard', 'historyCard']; sgOnlyIds.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = inSG ? '' : 'none'; }); // Render all panels renderHero(om); renderHourly(om); renderRain(om); renderDaily(om); renderSunMoon(om); renderActivity(om); if (inSG) renderNEA2Hour(nea2); renderConsensus(om, owm, wapi, inSG ? nea2 : null); renderAirQuality(aq); renderHistorical(om, inSG); // Show last-updated time on hero card const updatedEl = document.getElementById('lastUpdated'); if (updatedEl) { const t = new Date().toLocaleTimeString('en-SG', { hour: '2-digit', minute: '2-digit', hour12: false }); updatedEl.textContent = `Updated ${t}`; } // Map: load the correct dataset for the active layer const layerDataMap = { temperature: nt, rainfall: nr, humidity: nh, wind: nw, }; const mapData = layerDataMap[STATE.activeLayer] || nt || nr || nh || nw; if (mapData) { updateMapMarkers(mapData, STATE.activeLayer); } showToast('Weather updated ✓', 'success', 2000); } catch (err) { console.error(err); showToast(`Error: ${err.message}`, 'error', 5000); } finally { _loading = false; } } // ── Map layer switching ──────────────────────────────────────── async function switchMapLayer(layer) { STATE.activeLayer = layer; document.querySelectorAll('.layer-btn').forEach(b => { b.classList.toggle('active', b.dataset.layer === layer); }); const fetchers = { temperature: fetchNEATemperature, rainfall: fetchNEARainfall, humidity: fetchNEAHumidity, wind: fetchNEAWind, }; try { const data = await (fetchers[layer] || fetchers.temperature)(); updateMapMarkers(data, layer); } catch (e) { showToast('Could not load station data', 'error'); } } // ── Search ───────────────────────────────────────────────────── function setupSearch() { const input = $('locationSearch'); const dropdown = $('searchDropdown'); let debounceTimer = null; input.addEventListener('input', () => { const q = input.value.trim(); clearTimeout(debounceTimer); if (q.length < 2) { dropdown.classList.remove('open'); return; } // Show loading state dropdown.innerHTML = '
🔍 Searching…
'; dropdown.classList.add('open'); debounceTimer = setTimeout(async () => { const results = await geocodeSearch(q); dropdown.innerHTML = ''; if (!results.length) { dropdown.innerHTML = '
No results found
'; return; } results.forEach(loc => { const flag = countryFlag(loc.country); const sub = [loc.admin, loc.countryName].filter(s => s && s !== loc.name).join(', '); const item = el('div', 'search-item'); item.innerHTML = ` ${flag}
${loc.name}
${sub || loc.countryName}
${loc.population > 0 ? `${formatPop(loc.population)}` : ''} `; item.addEventListener('click', () => { STATE.lat = loc.lat; STATE.lon = loc.lon; STATE.locationName = loc.admin ? `${loc.name}, ${loc.countryName}` : loc.name; STATE.country = loc.country; input.value = STATE.locationName; dropdown.classList.remove('open'); loadWeather(); if (STATE.map) STATE.map.setView([loc.lat, loc.lon], isSG ? 12 : 10); }); dropdown.appendChild(item); }); }, 350); // debounce 350ms }); document.addEventListener('click', e => { if (!e.target.closest('.search-wrap')) dropdown.classList.remove('open'); }); // Enter key triggers first result input.addEventListener('keydown', async e => { if (e.key !== 'Enter') return; const q = input.value.trim(); if (!q) return; const results = await geocodeSearch(q); if (results.length) { const loc = results[0]; STATE.lat = loc.lat; STATE.lon = loc.lon; STATE.locationName = loc.admin ? `${loc.name}, ${loc.countryName}` : loc.name; STATE.country = loc.country; input.value = STATE.locationName; dropdown.classList.remove('open'); loadWeather(); if (STATE.map) STATE.map.setView([loc.lat, loc.lon], isSingapore() ? 12 : 10); } }); $('searchBtn').addEventListener('click', async () => { const q = input.value.trim(); if (!q) return; const results = await geocodeSearch(q); if (results.length) { const loc = results[0]; STATE.lat = loc.lat; STATE.lon = loc.lon; STATE.locationName = loc.admin ? `${loc.name}, ${loc.countryName}` : loc.name; STATE.country = loc.country; input.value = STATE.locationName; dropdown.classList.remove('open'); loadWeather(); } }); } // ── Geolocation ──────────────────────────────────────────────── function setupGeo() { $('geoBtn').addEventListener('click', () => { if (!navigator.geolocation) { showToast('Geolocation not supported', 'error'); return; } showToast('Getting your location…', 'info', 2000); navigator.geolocation.getCurrentPosition( async pos => { STATE.lat = pos.coords.latitude; STATE.lon = pos.coords.longitude; // Reverse geocode to get a human-readable name const geo = await reverseGeocode(STATE.lat, STATE.lon); STATE.locationName = geo ? geo.display : `${STATE.lat.toFixed(3)}, ${STATE.lon.toFixed(3)}`; STATE.country = geo ? geo.country : ''; $('locationSearch').value = STATE.locationName; loadWeather(); if (STATE.map) STATE.map.setView([STATE.lat, STATE.lon], isSingapore() ? 13 : 10); }, () => showToast('Location access denied', 'error') ); }); } // ── Unit Toggle ──────────────────────────────────────────────── function setupUnitToggle() { $('unitToggle').addEventListener('click', () => { STATE.unit = STATE.unit === 'C' ? 'F' : 'C'; $('unitToggle').textContent = `°${STATE.unit}`; if (STATE.weather) { renderHero(STATE.weather); renderHourly(STATE.weather); renderDaily(STATE.weather); } }); } // ── Theme Toggle ─────────────────────────────────────────────── function setupTheme() { const saved = localStorage.getItem('skylens-theme') || 'dark'; applyTheme(saved); $('themeToggle').addEventListener('click', () => { const next = STATE.theme === 'dark' ? 'light' : 'dark'; applyTheme(next); localStorage.setItem('skylens-theme', next); }); } function applyTheme(theme) { STATE.theme = theme; document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : ''); // Update PWA theme-color meta const meta = document.getElementById('themeColorMeta'); if (meta) meta.content = theme === 'light' ? '#f0f4f8' : '#0d1117'; const icon = $('themeIcon'); if (theme === 'light') { icon.innerHTML = ''; } else { icon.innerHTML = ''; } // Swap map tiles if the map is already initialized if (STATE.map) { // Remove all tile layers STATE.map.eachLayer(l => { if (l instanceof L.TileLayer) STATE.map.removeLayer(l); }); // Add the correct tile for the new theme L.tileLayer(mapTileUrl(theme), { maxZoom: 19 }).addTo(STATE.map); } } // ── API Key Modal ────────────────────────────────────────────── function setupModal() { // Load saved keys silently — no blocking modal on first visit STATE.keys.owm = localStorage.getItem('skylens-owm') || ''; STATE.keys.wapi = localStorage.getItem('skylens-wapi') || ''; STATE.keys.tio = localStorage.getItem('skylens-tio') || ''; // Pre-fill inputs if modal is opened manually via settings button if ($('owmKey')) $('owmKey').value = STATE.keys.owm; if ($('wapiKey')) $('wapiKey').value = STATE.keys.wapi; if ($('tioKey')) $('tioKey').value = STATE.keys.tio; const modal = $('apiModal'); // Always hidden on load — user opens it via ⚙ button modal.classList.add('hidden'); $('saveKeys').addEventListener('click', () => { STATE.keys.owm = $('owmKey').value.trim(); STATE.keys.wapi = $('wapiKey').value.trim(); STATE.keys.tio = $('tioKey').value.trim(); if (STATE.keys.owm) localStorage.setItem('skylens-owm', STATE.keys.owm); if (STATE.keys.wapi) localStorage.setItem('skylens-wapi', STATE.keys.wapi); if (STATE.keys.tio) localStorage.setItem('skylens-tio', STATE.keys.tio); modal.classList.add('hidden'); showToast('API keys saved ✓', 'success'); loadWeather(); }); $('skipKeys').addEventListener('click', () => { modal.classList.add('hidden'); }); // Settings button opens the modal const settingsBtn = $('settingsBtn'); if (settingsBtn) { settingsBtn.addEventListener('click', () => { modal.classList.remove('hidden'); }); } // Click outside modal to close modal.addEventListener('click', e => { if (e.target === modal) modal.classList.add('hidden'); }); } // ── Auto-refresh ─────────────────────────────────────────────── // Full weather refresh every 5 minutes // NEA station map refresh every 60 seconds (NEA updates ~1 min) function setupAutoRefresh() { // Full data refresh every 5 min setInterval(() => { loadWeather(); }, 5 * 60 * 1000); // Station map refresh every 60 seconds — SG only (NEA updates ~1 min) setInterval(async () => { if (!isSingapore()) return; const fetchers = { temperature: fetchNEATemperature, rainfall: fetchNEARainfall, humidity: fetchNEAHumidity, wind: fetchNEAWind, }; try { const data = await (fetchers[STATE.activeLayer] || fetchers.temperature)(); if (data) { updateMapMarkers(data, STATE.activeLayer); // Update last-refreshed timestamp on map card const badge = document.querySelector('.map-card .card-badge'); if (badge) { const t = new Date().toLocaleTimeString('en-SG', { hour: '2-digit', minute: '2-digit', hour12: false }); badge.textContent = `Live · ${t}`; } } } catch { /* silent fail — don't interrupt UX */ } }, 60 * 1000); } // ── Init ─────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Warn if opened as file:// — CORS will block most APIs if (location.protocol === 'file:') { const w = document.getElementById('corsWarning'); if (w) w.style.display = 'block'; } // ── Register Service Worker ────────────────────────────────── // First unregister any old SW that may be serving stale cached files if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations().then(regs => { regs.forEach(reg => { // Only unregister old cache versions reg.active?.scriptURL && console.log('[SW] Found:', reg.active.scriptURL); }); }); const canUseSW = location.protocol === 'https:' || location.hostname === 'localhost'; if (canUseSW) { navigator.serviceWorker.register('./sw.js?v=2', { scope: './' }) .then(reg => { console.log('[SW] Registered v2, scope:', reg.scope); navigator.serviceWorker.addEventListener('message', event => { if (event.data?.type === 'REFRESH_WEATHER') loadWeather(); }); }) .catch(err => console.warn('[SW] Registration failed:', err)); } } // ── PWA Install Prompt ─────────────────────────────────────── // Android/Chrome: use beforeinstallprompt // iOS Safari: show manual instructions (no programmatic prompt available) let deferredInstallPrompt = null; const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent); const isInStandaloneMode = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; if (!isInStandaloneMode) { if (isIOS) { // Show iOS-specific install instructions after 4 seconds setTimeout(() => showIOSInstallBanner(), 4000); } else { window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredInstallPrompt = e; setTimeout(() => showAndroidInstallBanner(), 3000); }); } } window.addEventListener('appinstalled', () => { deferredInstallPrompt = null; const banner = document.getElementById('installBanner'); if (banner) banner.remove(); showToast('SkyLens installed ✓ — find it on your home screen', 'success', 4000); }); function showIOSInstallBanner() { if (document.getElementById('installBanner')) return; const banner = document.createElement('div'); banner.id = 'installBanner'; banner.style.cssText = ` position:fixed;bottom:20px;left:50%;transform:translateX(-50%); z-index:998;background:var(--bg2);border:1px solid var(--accent); border-radius:14px;padding:14px 18px;box-shadow:var(--shadow); font-size:0.85rem;max-width:340px;width:90%;text-align:center; `; banner.innerHTML = `
📲
Install SkyLens on iPhone
Tap Share then "Add to Home Screen"
`; document.body.appendChild(banner); document.getElementById('dismissInstall').addEventListener('click', () => banner.remove()); // Auto-dismiss after 12 seconds setTimeout(() => banner?.remove(), 12000); } function showAndroidInstallBanner() { if (document.getElementById('installBanner')) return; const banner = document.createElement('div'); banner.id = 'installBanner'; banner.style.cssText = ` position:fixed;bottom:20px;left:50%;transform:translateX(-50%); z-index:998;display:flex;align-items:center;gap:12px; background:var(--bg2);border:1px solid var(--accent); border-radius:12px;padding:12px 18px;box-shadow:var(--shadow); font-size:0.88rem;max-width:360px;width:90%; `; banner.innerHTML = ` 📲
Install SkyLens
Add to home screen for the best experience
`; document.body.appendChild(banner); document.getElementById('installBtn').addEventListener('click', async () => { if (!deferredInstallPrompt) return; deferredInstallPrompt.prompt(); const { outcome } = await deferredInstallPrompt.userChoice; deferredInstallPrompt = null; banner.remove(); }); document.getElementById('dismissInstall').addEventListener('click', () => banner.remove()); } // ── Mobile Tab Bar ─────────────────────────────────────────── function setupMobileTabs() { const tabs = document.querySelectorAll('.tab-btn[data-tab]'); const cols = { left: document.querySelector('.col-left'), center: document.querySelector('.col-center'), right: document.querySelector('.col-right'), }; tabs.forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.tab; // Geo tab — trigger geolocation, don't switch panel if (tab === 'geo') { $('geoBtn').click(); return; } // Switch active tab button tabs.forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Show correct column, hide others Object.entries(cols).forEach(([key, col]) => { if (!col) return; col.classList.toggle('tab-active', key === tab); }); // Init or invalidate map when switching to map tab if (tab === 'center') { setTimeout(() => { if (!STATE.map) { initMap(); // first time — initialize now that div is visible loadWeather(); // load station data } else { STATE.map.invalidateSize(); // already initialized — just fix dimensions } }, 50); } }); }); } // ── Mobile Search ───────────────────────────────────────────── function setupMobileSearch() { const input = document.getElementById('mobileSearch'); const btn = document.getElementById('mobileSearchBtn'); if (!input || !btn) return; const doSearch = async () => { const q = input.value.trim(); if (!q) return; const results = await geocodeSearch(q); if (results.length) { const loc = results[0]; STATE.lat = loc.lat; STATE.lon = loc.lon; STATE.locationName = loc.admin ? `${loc.name}, ${loc.countryName}` : loc.name; STATE.country = loc.country; input.value = STATE.locationName; // Also sync desktop search const desktopInput = $('locationSearch'); if (desktopInput) desktopInput.value = STATE.locationName; loadWeather(); } else { showToast('Location not found', 'error'); } }; btn.addEventListener('click', doSearch); input.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); } setupMobileTabs(); setupMobileSearch(); startClock(); setupSearch(); setupGeo(); setupUnitToggle(); setupTheme(); setupModal(); setupAutoRefresh(); // Map layer buttons document.querySelectorAll('.layer-btn').forEach(btn => { btn.addEventListener('click', () => switchMapLayer(btn.dataset.layer)); }); // Init map after DOM is ready // On mobile the map tab handles init lazily (map div is hidden at startup) // On desktop init immediately setTimeout(() => { const isMobile = window.innerWidth <= 768; if (!isMobile) initMap(); }, 100); // If window resizes from mobile to desktop, init map if not yet done window.addEventListener('resize', () => { if (window.innerWidth > 768 && !STATE.map) { initMap(); } if (STATE.map) STATE.map.invalidateSize(); }); // 🇸🇬 Singapore quick-switch button const sgBtn = $('sgBtn'); if (sgBtn) { sgBtn.addEventListener('click', () => { STATE.lat = 1.3521; STATE.lon = 103.8198; STATE.locationName = 'Singapore'; STATE.country = 'SG'; $('locationSearch').value = 'Singapore'; if (STATE.map) STATE.map.setView([1.3521, 103.8198], 11); loadWeather(); }); } // ── Startup: get location then load weather ───────────────── // Simple approach: try GPS first (up to 8s), then fall back to Singapore const SG_DEFAULT_LAT = 1.3521; const SG_DEFAULT_LON = 103.8198; function startWithLocation(lat, lon, name, country) { STATE.lat = lat; STATE.lon = lon; STATE.locationName = name; STATE.country = country; _loading = false; const si = $('locationSearch'); const mi = document.getElementById('mobileSearch'); if (si) si.value = name; if (mi) mi.value = name; loadWeather(); } if (navigator.geolocation) { $('heroLocation').textContent = 'Locating…'; $('heroDesc').textContent = 'Getting your location…'; navigator.geolocation.getCurrentPosition( async pos => { const lat = pos.coords.latitude; const lon = pos.coords.longitude; const geo = await reverseGeocode(lat, lon); const name = geo ? geo.display : `${lat.toFixed(2)}, ${lon.toFixed(2)}`; const country = geo ? geo.country : ''; try { localStorage.setItem('skylens-last-loc', JSON.stringify({ lat, lon, name, country })); } catch {} startWithLocation(lat, lon, name, country); }, () => { startWithLocation(SG_DEFAULT_LAT, SG_DEFAULT_LON, 'Singapore', 'SG'); }, { timeout: 8000, maximumAge: 0, enableHighAccuracy: false } ); } else { startWithLocation(SG_DEFAULT_LAT, SG_DEFAULT_LON, 'Singapore', 'SG'); } // Keyboard shortcut: R to refresh document.addEventListener('keydown', e => { if (e.key === 'r' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') { loadWeather(); } }); });