| |
| |
| |
| |
| |
| |
| 'use strict'; |
|
|
| |
| const STATE = { |
| lat: 1.3521, |
| lon: 103.8198, |
| locationName: 'Singapore', |
| country: 'SG', |
| unit: 'C', |
| theme: 'dark', |
| activeLayer: 'temperature', |
| keys: { owm: '', wapi: '', tio: '' }, |
| weather: null, |
| hourly: [], |
| daily: [], |
| stations: [], |
| neaForecast: [], |
| hourlyChart: null, |
| map: null, |
| stationMarkers: [], |
| clockInterval: null, |
| }; |
|
|
| |
| function isSingapore() { |
| return STATE.lat >= 1.15 && STATE.lat <= 1.48 && |
| STATE.lon >= 103.6 && STATE.lon <= 104.1; |
| } |
|
|
| |
| function countryFlag(cc) { |
| if (!cc || cc.length !== 2) return 'π'; |
| return String.fromCodePoint(...[...cc.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); |
| } |
|
|
| |
| 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', |
| }; |
|
|
| |
| 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}], |
| }; |
|
|
| |
| async function geocodeSearch(query) { |
| const q = query.trim().toLowerCase(); |
|
|
| |
| 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, |
| })); |
| } |
|
|
| |
| |
| const results = await geocodeOpenMeteo(query); |
| return results.filter(r => { |
| const nameLower = r.name.toLowerCase(); |
| |
| const mappedCc = COUNTRY_MAP[nameLower]; |
| if (mappedCc && r.country !== mappedCc) return false; |
| return true; |
| }); |
| } |
|
|
| |
| 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) { |
| |
| 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` |
| ); |
| |
| } catch {} |
|
|
| |
| 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 || {}; |
| |
| 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 {} |
| } |
|
|
| |
| 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; } |
| } |
|
|
| |
| 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': 'π©', |
| }; |
|
|
| |
| 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.' }; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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'; |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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'; |
| } |
|
|
| |
| 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)); |
| } |
|
|
| |
| 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; } |
| } |
|
|
| |
| 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 = `<span style="color:${uvi.color}">${Math.round(c.uv_index || 0)} ${uvi.label}</span>`; |
|
|
| |
| const hour = new Date().getHours(); |
| const condition = (hour >= 19 || hour < 6) ? 'night' : wmo.condition; |
| updateSkyAnimation(condition); |
|
|
| |
| document.querySelectorAll('.hero-unit').forEach(u => u.textContent = tempUnit()); |
| } |
|
|
| |
| 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' : ''}`); |
| |
| let rainLabel = ''; |
| if (mm > 0) rainLabel = `<span class="hour-rain rain-mm-label">β${mm.toFixed(1)}mm</span>`; |
| else if (prob > 0) rainLabel = `<span class="hour-rain">π§${prob}%</span>`; |
|
|
| item.innerHTML = ` |
| <span class="hour-time">${formatTime(t, { hour: '2-digit', minute: '2-digit', hour12: true })}</span> |
| <span class="hour-icon">${wmo.icon}</span> |
| <span class="hour-temp">${temp}Β°</span> |
| ${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); |
| } |
|
|
| |
| 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, |
| }, |
| } |
| } |
| }); |
| } |
|
|
| |
| 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 = ` |
| <span class="day-name">${formatDay(d.time[i])}</span> |
| <span class="day-icon">${wmo.icon}</span> |
| <span class="day-desc">${wmo.desc}</span> |
| <span class="day-rain-chance"> |
| ${prob > 0 ? `<span class="rain-prob">π§${prob}%</span>` : ''} |
| ${mm > 0 ? `<span class="rain-mm">${mm.toFixed(1)}mm</span>` : ''} |
| </span> |
| <div class="day-temps"> |
| <span>${hi}Β°</span> |
| <span class="day-temp-lo">${lo}Β°</span> |
| </div> |
| `; |
| list.appendChild(row); |
| } |
| } |
|
|
| |
| let rainChart = null; |
|
|
| function renderRain(data) { |
| const now = new Date(); |
| const hours = data.hourly; |
| const daily = data.daily; |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| 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 = ` |
| <div class="rain-banner-clear"> |
| <span class="rain-banner-icon">βοΈ</span> |
| <div> |
| <div class="rain-banner-title">No rain in the next 7 days</div> |
| <div class="rain-banner-sub">Enjoy the dry weather!</div> |
| </div> |
| </div>`; |
| } 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 = ` |
| <div class="rain-banner-rain"> |
| <span class="rain-banner-icon">π§</span> |
| <div> |
| <div class="rain-banner-title">Rain is falling now</div> |
| <div class="rain-banner-sub">${nextRainMm.toFixed(1)} mm this hour Β· ${hours.precipitation_probability[nextRainIdx]}% probability</div> |
| </div> |
| </div>`; |
| } 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 = ` |
| <div class="rain-banner-soon"> |
| <span class="rain-banner-icon">π¦</span> |
| <div> |
| <div class="rain-banner-title">Rain expected ${label} Β· ${formatTime(rainTime)}</div> |
| <div class="rain-banner-sub">${nextRainMm.toFixed(1)} mm forecast Β· ${hours.precipitation_probability[nextRainIdx]}% probability</div> |
| </div> |
| </div>`; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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 }, |
| } |
| } |
| }); |
|
|
| |
| 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 = ` |
| <span class="rain-total-day">${formatDay(date)}</span> |
| <span class="rain-total-icon">${wmo.icon}</span> |
| <div class="rain-total-bar-wrap"> |
| <div class="rain-total-bar" style="width:${pct}%;background:${ |
| mm === 0 ? 'rgba(255,255,255,0.08)' : |
| mm < 5 ? 'rgba(88,166,255,0.5)' : |
| mm < 20 ? 'rgba(88,166,255,0.8)' : |
| '#58a6ff' |
| }"></div> |
| </div> |
| <span class="rain-total-mm">${mm > 0 ? `${mm.toFixed(1)}mm` : 'β'}</span> |
| <span class="rain-total-prob">${prob > 0 ? `${prob}%` : ''}</span> |
| `; |
| 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`; |
|
|
| |
| 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]; |
|
|
| |
| 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; |
|
|
| |
| ctx.beginPath(); |
| ctx.arc(cx, cy, r, Math.PI, 0); |
| ctx.strokeStyle = 'rgba(255,255,255,0.1)'; |
| ctx.lineWidth = 3; |
| ctx.stroke(); |
|
|
| |
| 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(); |
|
|
| |
| 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; |
|
|
| |
| 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); |
| } |
|
|
| |
| 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 = ` |
| <div class="activity-header"> |
| <span class="activity-icon">${a.icon}</span> |
| <span>${a.label}</span> |
| </div> |
| <div class="activity-score-bar"> |
| <div class="activity-score-fill score-${cls}" style="width:${s}%"></div> |
| </div> |
| <span class="activity-label label-${cls}">${lbl} Β· ${s}%</span> |
| `; |
| grid.appendChild(item); |
| }); |
| } |
|
|
| |
| function renderNEA2Hour(data) { |
| if (!data || !data.items || !data.items[0]) { |
| $('neaRegions').innerHTML = '<p style="color:var(--text-muted);font-size:0.85rem">NEA data unavailable</p>'; |
| 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 = ` |
| <span class="nea-region-icon">${icon}</span> |
| <div> |
| <div class="nea-region-name">${f.area}</div> |
| <div class="nea-region-desc">${f.forecast}</div> |
| </div> |
| `; |
| container.appendChild(item); |
| }); |
| } |
|
|
| |
| |
| |
| function renderConsensus(openMeteo, owm, wapi, nea2) { |
| const body = $('consensusBody'); |
| body.innerHTML = ''; |
|
|
| const sources = []; |
|
|
| |
| 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: 'π‘', |
| }); |
| } |
|
|
| |
| if (nea2 && nea2.items && nea2.items[0]) { |
| const forecasts = nea2.items[0].forecasts; |
| |
| 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, |
| desc: `${central.area}: ${central.forecast}`, |
| icon: neaIcon, |
| isNea: true, |
| noTemp: true, |
| }); |
| } |
| } |
|
|
| sources.forEach(s => { |
| const row = el('div', 'source-row'); |
| row.innerHTML = ` |
| <span class="source-logo${s.isNea ? '" style="background:rgba(63,185,80,0.15);color:#3fb950' : ''}">${s.name}</span> |
| <span class="source-icon">${s.icon}</span> |
| <span class="source-temp">${s.noTemp ? 'β' : `${displayTemp(s.temp)}Β°`}</span> |
| <span class="source-desc">${s.desc}</span> |
| `; |
| body.appendChild(row); |
| }); |
|
|
| |
| 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] || 'π‘'; |
| } |
|
|
| |
| function renderAirQuality(data) { |
| const body = $('aqBody'); |
| if (!data || !data.current) { |
| body.innerHTML = '<p style="color:var(--text-muted);font-size:0.85rem">AQ data unavailable</p>'; |
| 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 = ` |
| <div class="aqi-main"> |
| <div class="aqi-circle ${cat.cls}"> |
| <span class="aqi-number">${aqi}</span> |
| <span class="aqi-label-sm">AQI</span> |
| </div> |
| <div class="aqi-info"> |
| <div class="aqi-category">${cat.label}</div> |
| <div class="aqi-advice">${cat.advice}</div> |
| </div> |
| </div> |
| <div class="pollutant-grid"> |
| <div class="pollutant-item"> |
| <div class="pollutant-name">PM2.5</div> |
| <div class="pollutant-val">${(data.current.pm2_5 || 0).toFixed(1)}</div> |
| </div> |
| <div class="pollutant-item"> |
| <div class="pollutant-name">PM10</div> |
| <div class="pollutant-val">${(data.current.pm10 || 0).toFixed(1)}</div> |
| </div> |
| <div class="pollutant-item"> |
| <div class="pollutant-name">Oβ</div> |
| <div class="pollutant-val">${(data.current.ozone || 0).toFixed(1)}</div> |
| </div> |
| <div class="pollutant-item"> |
| <div class="pollutant-name">NOβ</div> |
| <div class="pollutant-val">${(data.current.nitrogen_dioxide || 0).toFixed(1)}</div> |
| </div> |
| <div class="pollutant-item"> |
| <div class="pollutant-name">CO</div> |
| <div class="pollutant-val">${((data.current.carbon_monoxide || 0)/1000).toFixed(2)}</div> |
| </div> |
| <div class="pollutant-item"> |
| <div class="pollutant-name">Units</div> |
| <div class="pollutant-val" style="font-size:0.65rem;color:var(--text-muted)">ΞΌg/mΒ³</div> |
| </div> |
| </div> |
| `; |
| } |
|
|
| |
| 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 = ` |
| <span class="hist-label">${r.label}</span> |
| <div class="hist-bar-wrap"> |
| <div class="hist-bar-now" style="width:${pctNow}%"></div> |
| <div class="hist-bar-avg" style="left:${pctAvg}%" title="Monthly avg: ${r.avg}${r.unit}"></div> |
| </div> |
| <span class="hist-val">${Math.round(r.cur)}${r.unit}</span> |
| <span class="hist-diff ${diffCls}">${diffStr}</span> |
| `; |
| body.appendChild(row); |
| }); |
|
|
| |
| const legend = el('div', '', ` |
| <div style="display:flex;gap:16px;font-size:0.72rem;color:var(--text-muted);margin-top:8px"> |
| <span style="display:flex;align-items:center;gap:4px"> |
| <span style="width:12px;height:4px;background:linear-gradient(90deg,#58a6ff,#3fb950);border-radius:2px;display:inline-block"></span> Current |
| </span> |
| <span style="display:flex;align-items:center;gap:4px"> |
| <span style="width:2px;height:12px;background:var(--text-muted);display:inline-block"></span> Monthly avg |
| </span> |
| </div> |
| `); |
| body.appendChild(legend); |
| } |
|
|
| |
| 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'; |
| } |
|
|
| |
| function initMap() { |
| if (STATE.map) return; |
|
|
| const map = L.map('stationMap', { |
| center: [1.3521, 103.8198], |
| zoom: 11, |
| zoomControl: true, |
| attributionControl: false, |
| }); |
|
|
| |
| L.tileLayer(mapTileUrl(STATE.theme), { |
| maxZoom: 19, |
| }).addTo(map); |
|
|
| |
| L.control.attribution({ prefix: false }) |
| .addAttribution('Β© <a href="https://carto.com">CARTO</a> | NEA Singapore') |
| .addTo(map); |
|
|
| STATE.map = map; |
|
|
| |
| 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(); |
| }); |
| } |
|
|
| |
| function updateMapMarkers(stationData, layer) { |
| if (!STATE.map) return; |
|
|
| |
| STATE.stationMarkers.forEach(m => STATE.map.removeLayer(m)); |
| STATE.stationMarkers = []; |
|
|
| if (!stationData || !stationData.metadata || !stationData.items) return; |
|
|
| const stations = stationData.metadata.stations; |
| |
| const readings = stationData.items[0]?.readings || []; |
| const readMap = {}; |
| readings.forEach(r => { readMap[r.station_id] = r.value; }); |
|
|
| |
| 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', |
| }; |
|
|
| const unitLabel = { |
| temperature: 'Β°C', |
| rainfall: 'mm', |
| humidity: '%', |
| wind: 'kt', |
| }; |
|
|
| 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 = `<span class="legend-dot" style="background:${item.color}"></span>${item.label}`; |
| legend.appendChild(li); |
| }); |
|
|
| stations.forEach(st => { |
| const val = readMap[st.id]; |
| if (val === undefined) return; |
|
|
| const color = (colorScale[layer] || colorScale.temperature)(val); |
| |
| 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)}`; |
|
|
| const size = 36; |
|
|
| const icon = L.divIcon({ |
| className: '', |
| html: `<div class="station-marker" style="width:${size}px;height:${size}px;background:${color};font-size:0.58rem;line-height:1.1;text-align:center;padding:2px">${display}</div>`, |
| 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(` |
| <b>${st.name}</b><br> |
| ${layer.charAt(0).toUpperCase() + layer.slice(1)}: <b>${val} ${unitFull[layer] || ''}</b><br> |
| <small>Station: ${st.id}</small> |
| `); |
|
|
| STATE.stationMarkers.push(marker); |
| }); |
| } |
|
|
| |
| let _loading = false; |
|
|
| async function loadWeather() { |
| if (_loading) return; |
| _loading = true; |
|
|
| const { lat, lon } = STATE; |
| showToast('Fetching weatherβ¦', 'info', 2000); |
|
|
| try { |
| |
| 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; |
|
|
| |
| const sgOnlyIds = ['neaCard', 'mapCard', 'activityCard', 'historyCard']; |
| sgOnlyIds.forEach(id => { |
| const el = document.getElementById(id); |
| if (el) el.style.display = inSG ? '' : 'none'; |
| }); |
|
|
| |
| 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); |
|
|
| |
| 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}`; |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| 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'); |
| } |
| } |
|
|
| |
| 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; } |
|
|
| |
| dropdown.innerHTML = '<div class="search-item" style="color:var(--text-muted)">π Searchingβ¦</div>'; |
| dropdown.classList.add('open'); |
|
|
| debounceTimer = setTimeout(async () => { |
| const results = await geocodeSearch(q); |
| dropdown.innerHTML = ''; |
|
|
| if (!results.length) { |
| dropdown.innerHTML = '<div class="search-item" style="color:var(--text-muted)">No results found</div>'; |
| 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 = ` |
| <span style="font-size:1.3rem;line-height:1;flex-shrink:0">${flag}</span> |
| <div style="flex:1;min-width:0"> |
| <div style="font-size:0.88rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${loc.name}</div> |
| <div style="font-size:0.72rem;color:var(--text-muted)">${sub || loc.countryName}</div> |
| </div> |
| ${loc.population > 0 ? `<span style="margin-left:8px;font-size:0.68rem;color:var(--text-muted);flex-shrink:0">${formatPop(loc.population)}</span>` : ''} |
| `; |
| 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); |
| }); |
|
|
| document.addEventListener('click', e => { |
| if (!e.target.closest('.search-wrap')) dropdown.classList.remove('open'); |
| }); |
|
|
| |
| 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(); |
| } |
| }); |
| } |
|
|
| |
| 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; |
| |
| 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') |
| ); |
| }); |
| } |
|
|
| |
| 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); |
| } |
| }); |
| } |
|
|
| |
| 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' : ''); |
|
|
| |
| const meta = document.getElementById('themeColorMeta'); |
| if (meta) meta.content = theme === 'light' ? '#f0f4f8' : '#0d1117'; |
|
|
| const icon = $('themeIcon'); |
| if (theme === 'light') { |
| icon.innerHTML = '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>'; |
| } else { |
| icon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>'; |
| } |
|
|
| |
| if (STATE.map) { |
| |
| STATE.map.eachLayer(l => { |
| if (l instanceof L.TileLayer) STATE.map.removeLayer(l); |
| }); |
| |
| L.tileLayer(mapTileUrl(theme), { maxZoom: 19 }).addTo(STATE.map); |
| } |
| } |
|
|
| |
| function setupModal() { |
| |
| STATE.keys.owm = localStorage.getItem('skylens-owm') || ''; |
| STATE.keys.wapi = localStorage.getItem('skylens-wapi') || ''; |
| STATE.keys.tio = localStorage.getItem('skylens-tio') || ''; |
|
|
| |
| if ($('owmKey')) $('owmKey').value = STATE.keys.owm; |
| if ($('wapiKey')) $('wapiKey').value = STATE.keys.wapi; |
| if ($('tioKey')) $('tioKey').value = STATE.keys.tio; |
|
|
| const modal = $('apiModal'); |
| |
| 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'); |
| }); |
|
|
| |
| const settingsBtn = $('settingsBtn'); |
| if (settingsBtn) { |
| settingsBtn.addEventListener('click', () => { |
| modal.classList.remove('hidden'); |
| }); |
| } |
|
|
| |
| modal.addEventListener('click', e => { |
| if (e.target === modal) modal.classList.add('hidden'); |
| }); |
| } |
|
|
| |
| |
| |
| function setupAutoRefresh() { |
| |
| setInterval(() => { |
| loadWeather(); |
| }, 5 * 60 * 1000); |
|
|
| |
| 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); |
| |
| 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 { } |
| }, 60 * 1000); |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| |
| if (location.protocol === 'file:') { |
| const w = document.getElementById('corsWarning'); |
| if (w) w.style.display = 'block'; |
| } |
|
|
| |
| |
| if ('serviceWorker' in navigator) { |
| navigator.serviceWorker.getRegistrations().then(regs => { |
| regs.forEach(reg => { |
| |
| 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)); |
| } |
| } |
|
|
| |
| |
| |
| 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) { |
| |
| 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 = ` |
| <div style="font-size:1.6rem;margin-bottom:8px">π²</div> |
| <div style="font-weight:600;margin-bottom:6px">Install SkyLens on iPhone</div> |
| <div style="color:var(--text-muted);font-size:0.78rem;line-height:1.5;margin-bottom:12px"> |
| Tap <strong style="color:var(--text)">Share</strong> |
| <span style="font-size:1rem"> β </span> |
| then <strong style="color:var(--text)">"Add to Home Screen"</strong> |
| <span style="font-size:1rem"> οΌ </span> |
| </div> |
| <button id="dismissInstall" style="background:var(--surface);border:1px solid var(--border);color:var(--text-muted);border-radius:8px;padding:6px 16px;cursor:pointer;font-size:0.8rem;font-family:var(--font)">Got it</button> |
| `; |
| document.body.appendChild(banner); |
| document.getElementById('dismissInstall').addEventListener('click', () => banner.remove()); |
| |
| 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 = ` |
| <span style="font-size:1.4rem">π²</span> |
| <div style="flex:1"> |
| <div style="font-weight:600;margin-bottom:2px">Install SkyLens</div> |
| <div style="color:var(--text-muted);font-size:0.78rem">Add to home screen for the best experience</div> |
| </div> |
| <button id="installBtn" style="background:var(--accent);color:#0d1117;border:none;border-radius:8px;padding:6px 14px;font-weight:600;cursor:pointer;font-size:0.82rem;white-space:nowrap;font-family:var(--font)">Install</button> |
| <button id="dismissInstall" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.1rem;padding:0 4px">β</button> |
| `; |
| 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()); |
| } |
|
|
| |
| 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; |
|
|
| |
| if (tab === 'geo') { |
| $('geoBtn').click(); |
| return; |
| } |
|
|
| |
| tabs.forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
|
|
| |
| Object.entries(cols).forEach(([key, col]) => { |
| if (!col) return; |
| col.classList.toggle('tab-active', key === tab); |
| }); |
|
|
| |
| if (tab === 'center') { |
| setTimeout(() => { |
| if (!STATE.map) { |
| initMap(); |
| loadWeather(); |
| } else { |
| STATE.map.invalidateSize(); |
| } |
| }, 50); |
| } |
| }); |
| }); |
| } |
|
|
| |
| 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; |
| |
| 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(); |
|
|
| |
| document.querySelectorAll('.layer-btn').forEach(btn => { |
| btn.addEventListener('click', () => switchMapLayer(btn.dataset.layer)); |
| }); |
|
|
| |
| |
| |
| setTimeout(() => { |
| const isMobile = window.innerWidth <= 768; |
| if (!isMobile) initMap(); |
| }, 100); |
|
|
| |
| window.addEventListener('resize', () => { |
| if (window.innerWidth > 768 && !STATE.map) { |
| initMap(); |
| } |
| if (STATE.map) STATE.map.invalidateSize(); |
| }); |
|
|
| |
| 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(); |
| }); |
| } |
|
|
| |
| |
| 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'); |
| } |
|
|
| |
| document.addEventListener('keydown', e => { |
| if (e.key === 'r' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') { |
| loadWeather(); |
| } |
| }); |
| }); |
|
|