/* ============================================================
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 = `
${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}
`;
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)}
`;
}
// ── 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();
}
});
});