From cf065405ccabf39f63b050d53e9f0f2acd1913d2 Mon Sep 17 00:00:00 2001 From: Adithya KP Date: Fri, 19 Jun 2026 07:56:04 +0530 Subject: [PATCH] Refactor: Migrate monolithic HTML UI to native Web Components --- public/components/current-weather.js | 1050 ++++++++++++++++ public/components/forecast-list.js | 986 +++++++++++++++ public/components/weather-header.js | 305 +++++ public/components/weather-map.js | 285 +++++ public/components/weather-search.js | 839 +++++++++++++ public/index.html | 353 +----- public/script.js | 1683 +++++--------------------- public/style.css | 1119 +---------------- 8 files changed, 3825 insertions(+), 2795 deletions(-) create mode 100644 public/components/current-weather.js create mode 100644 public/components/forecast-list.js create mode 100644 public/components/weather-header.js create mode 100644 public/components/weather-map.js create mode 100644 public/components/weather-search.js diff --git a/public/components/current-weather.js b/public/components/current-weather.js new file mode 100644 index 0000000..5b81238 --- /dev/null +++ b/public/components/current-weather.js @@ -0,0 +1,1050 @@ +class CurrentWeather extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this._weatherData = null; + this._aqiData = null; + this._unit = 'C'; + this._lang = 'en'; + this._isFavorite = false; + this._sunTimer = null; + } + + static get observedAttributes() { + return ['lang', 'unit', 'is-favorite']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) return; + if (name === 'lang') { + this._lang = newValue; + this.updateTranslations(); + } else if (name === 'unit') { + this._unit = newValue; + this.updateUnit(); + } else if (name === 'is-favorite') { + this._isFavorite = this.hasAttribute('is-favorite'); + this.updateFavoriteButton(); + } + } + + get weatherData() { return this._weatherData; } + set weatherData(val) { + this._weatherData = val; + this.render(); + } + + get aqiData() { return this._aqiData; } + set aqiData(val) { + this._aqiData = val; + this.renderAqi(); + } + + connectedCallback() { + this.render(); + this._sunTimer = setInterval(() => this.renderSunPosition(), 60000); + } + + disconnectedCallback() { + if (this._sunTimer) { + clearInterval(this._sunTimer); + } + } + + toUnit(kelvin) { + const DEGREE = '\u00B0'; + if (this._unit === 'C') return `${Math.round(kelvin - 273.15)}${DEGREE}C`; + if (this._unit === 'F') return `${Math.round((kelvin - 273.15) * 9 / 5 + 32)}${DEGREE}F`; + return `${Math.round(kelvin)}K`; + } + + toUnitNum(kelvin) { + if (this._unit === 'C') return Math.round(kelvin - 273.15); + if (this._unit === 'F') return Math.round((kelvin - 273.15) * 9 / 5 + 32); + return Math.round(kelvin); + } + + unitLabel() { + const DEGREE = '\u00B0'; + if (this._unit === 'C') return `${DEGREE}C`; + if (this._unit === 'F') return `${DEGREE}F`; + return 'K'; + } + + getWindDirection(deg) { + if (deg === undefined || deg === null) return ''; + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + const index = Math.round(deg / 45) % 8; + const arrows = ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖']; + return `${directions[index]} ${arrows[index]}`; + } + + getAqiCategory(aqi) { + const value = Number(aqi); + if (!Number.isFinite(value)) { + return { label: 'Unknown', level: 0, badgeClass: '' }; + } + switch (value) { + case 1: return { label: 'Good', level: 1, badgeClass: 'aqi-1' }; + case 2: return { label: 'Fair', level: 2, badgeClass: 'aqi-2' }; + case 3: return { label: 'Moderate', level: 3, badgeClass: 'aqi-3' }; + case 4: return { label: 'Poor', level: 4, badgeClass: 'aqi-4' }; + case 5: return { label: 'Very Poor', level: 5, badgeClass: 'aqi-5' }; + default: + if (value <= 50) return { label: 'Good', level: 1, badgeClass: 'aqi-1' }; + if (value <= 100) return { label: 'Fair', level: 2, badgeClass: 'aqi-2' }; + if (value <= 150) return { label: 'Moderate', level: 3, badgeClass: 'aqi-3' }; + if (value <= 200) return { label: 'Poor', level: 4, badgeClass: 'aqi-4' }; + return { label: 'Very Poor', level: 5, badgeClass: 'aqi-5' }; + } + } + + getAqiHealthRecommendation(categoryLabel) { + switch (categoryLabel) { + case 'Good': return 'Enjoy outdoor activities. Sensitive groups may still consider monitoring.'; + case 'Fair': return 'Unusually sensitive individuals should reduce prolonged outdoor exertion.'; + case 'Moderate': return 'Consider reducing prolonged outdoor activities if you experience symptoms.'; + case 'Poor': return 'Limit outdoor activity; keep windows closed and consider an air purifier.'; + case 'Very Poor': return 'Avoid outdoor activity. Stay indoors and follow local health guidance.'; + default: return 'Air quality information is unavailable right now.'; + } + } + + getShiftedDate(unixSeconds, timezoneOffsetSeconds) { + return new Date((unixSeconds + timezoneOffsetSeconds) * 1000); + } + + formatDateAtOffset(unixSeconds, timezoneOffsetSeconds) { + const locale = this._lang === 'hi' ? 'hi-IN' : this._lang === 'es' ? 'es-ES' : this._lang === 'fr' ? 'fr-FR' : 'en-US'; + return this.getShiftedDate(unixSeconds, timezoneOffsetSeconds).toLocaleDateString(locale, { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' + }); + } + + formatTimeAtOffset(unixSeconds, timezoneOffsetSeconds) { + const locale = this._lang === 'hi' ? 'hi-IN' : this._lang === 'es' ? 'es-ES' : this._lang === 'fr' ? 'fr-FR' : 'en-US'; + return this.getShiftedDate(unixSeconds, timezoneOffsetSeconds).toLocaleTimeString(locale, { + hour: '2-digit', minute: '2-digit', timeZone: 'UTC' + }); + } + + getNextSunriseSeconds(todaySunrise, nowSeconds) { + const daySeconds = 24 * 60 * 60; + const daysAhead = Math.floor((nowSeconds - todaySunrise) / daySeconds) + 1; + return todaySunrise + daysAhead * daySeconds; + } + + formatDuration(seconds) { + const totalMinutes = Math.max(0, Math.round(seconds / 60)); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const hStr = this._lang === 'hi' ? 'घं' : 'h'; + const mStr = this._lang === 'hi' ? 'मि' : 'm'; + if (hours === 0) return `${minutes}${mStr}`; + if (minutes === 0) return `${hours}${hStr}`; + return `${hours}${hStr} ${minutes}${mStr}`; + } + + render() { + if (!this._weatherData) { + this.shadowRoot.innerHTML = ''; + return; + } + + const data = this._weatherData; + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + const ICON_URL = 'https://openweathermap.org/img/wn'; + const iconCode = data.weather[0].icon; + + this.shadowRoot.innerHTML = ` + + +
+
+
+

${data.name}, ${data.sys.country}

+
+ + +
+
+ +

${this.formatDateAtOffset(Math.floor(Date.now() / 1000), data.timezone)}

+
+ +
+
+ ${data.weather[0].description} +
+
+ ${this.toUnitNum(data.main.temp)} + ${this.unitLabel()} +
+

${data.weather[0].description}

+

${t['feelsLikeMain'] ? t['feelsLikeMain'].replace('--°', this.toUnit(data.main.feels_like)) : `Feels like ${this.toUnit(data.main.feels_like)}`}

+
+ +
+
+

${t['sunPosition'] || 'Sun Position'}

+ -- +
+
+
+
+ +
+
+
+ -- + -- +
+
+
+ +
+
+
+ +
+
+ ${t['feelsLike'] || 'Feels Like'} + ${this.toUnit(data.main.feels_like)} +
+
+ +
+
+ +
+
+ ${t['humidity'] || 'Humidity'} + ${data.main.humidity}% +
+
+ +
+
+ +
+
+ ${t['windSpeed'] || 'Wind Speed'} + + ${Math.round(data.wind.speed * 3.6)} km/h ${this.getWindDirection(data.wind.deg)} +
+ + + N + + +
+
+
+
+ +
+
+ +
+
+ ${t['pressure'] || 'Pressure'} + ${data.main.pressure} hPa +
+
+ +
+
+ +
+
+ ${t['visibility'] || 'Visibility'} + ${(data.visibility / 1000).toFixed(1)} km +
+
+ +
+
+ + + + + +
+
+ ${t['aqi'] || 'Air Quality (AQI)'} + + -- + -- + + Pollutants -- + Recommendation -- +
+
+ +
+
+ +
+
+ ${t['sunrise'] || 'Sunrise'} + ${this.formatTimeAtOffset(data.sys.sunrise, data.timezone)} +
+
+ +
+
+ +
+
+ ${t['sunset'] || 'Sunset'} + ${this.formatTimeAtOffset(data.sys.sunset, data.timezone)} +
+
+ +
+
+ +
+
+ ${t['uvIndex'] || 'UV Index'} + -- +
+
+
+ `; + + this.setupShareAndFavorite(); + this.renderSunPosition(); + this.renderAqi(); + } + + setupShareAndFavorite() { + const shareBtn = this.shadowRoot.getElementById('share-btn'); + const shareMenu = this.shadowRoot.getElementById('share-menu'); + const copyLinkBtn = this.shadowRoot.getElementById('copy-link-btn'); + const copySummaryBtn = this.shadowRoot.getElementById('copy-summary-btn'); + const favoriteBtn = this.shadowRoot.getElementById('favorite-btn'); + + if (shareBtn) { + shareBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isHidden = shareMenu.classList.contains('hidden'); + shareMenu.classList.toggle('hidden', !isHidden); + shareBtn.setAttribute('aria-expanded', String(isHidden)); + }); + } + + if (copyLinkBtn) { + copyLinkBtn.addEventListener('click', async () => { + const link = this.getShareableLink(); + const copied = await this.copyTextToClipboard(link); + this.showShareStatus(copied ? 'Link copied!' : 'Failed to copy.'); + if (copied) setTimeout(() => this.toggleShareMenu(false), 900); + }); + } + + if (copySummaryBtn) { + copySummaryBtn.addEventListener('click', async () => { + const summary = this.getShareSummary(); + const copied = await this.copyTextToClipboard(summary); + this.showShareStatus(copied ? 'Summary copied!' : 'Failed to copy.'); + if (copied) setTimeout(() => this.toggleShareMenu(false), 900); + }); + } + + if (favoriteBtn) { + favoriteBtn.addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('toggle-favorite', { + detail: { + query: `${this._weatherData.name},${this._weatherData.sys.country}`, + label: `${this._weatherData.name}, ${this._weatherData.sys.country}` + }, + bubbles: true, + composed: true + })); + }); + } + + document.addEventListener('click', (e) => { + const path = e.composedPath(); + if (!path.includes(shareBtn) && !path.includes(shareMenu)) { + this.toggleShareMenu(false); + } + }); + } + + toggleShareMenu(show) { + const shareMenu = this.shadowRoot.getElementById('share-menu'); + const shareBtn = this.shadowRoot.getElementById('share-btn'); + if (shareMenu && shareBtn) { + shareMenu.classList.toggle('hidden', !show); + shareBtn.setAttribute('aria-expanded', String(show)); + if (!show) this.showShareStatus(''); + } + } + + showShareStatus(msg) { + const status = this.shadowRoot.getElementById('share-status-text'); + if (status) status.textContent = msg; + } + + async copyTextToClipboard(text) { + if (!text) return false; + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + try { + await navigator.clipboard.writeText(text); + return true; + } catch {} + } + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + this.shadowRoot.appendChild(textarea); + textarea.select(); + let success = false; + try { + success = document.execCommand('copy'); + } catch { + success = false; + } + this.shadowRoot.removeChild(textarea); + return success; + } + + getShareableLink() { + const city = `${this._weatherData.name},${this._weatherData.sys.country}`; + const url = new URL(window.location.href); + url.searchParams.set('city', city); + url.searchParams.set('units', this._unit); + return url.toString(); + } + + getShareSummary() { + const description = this._weatherData.weather?.[0]?.description || ''; + const formattedDescription = description + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + const temperature = `${this.toUnitNum(this._weatherData.main.temp)}${this.unitLabel()}`; + const humidityValue = this._weatherData.main.humidity !== undefined ? `${this._weatherData.main.humidity}%` : '--'; + const cityLabel = `${this._weatherData.name}, ${this._weatherData.sys.country}`; + return `${cityLabel}: ${temperature}, Humidity ${humidityValue}, ${formattedDescription}`; + } + + renderSunPosition() { + if (!this._weatherData) return; + + const sunMarker = this.shadowRoot.getElementById('sun-marker'); + const sunPhase = this.shadowRoot.getElementById('sun-phase'); + const sunProgress = this.shadowRoot.getElementById('sun-progress'); + const solarNoon = this.shadowRoot.getElementById('solar-noon'); + if (!sunMarker || !sunPhase || !sunProgress || !solarNoon) return; + + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + + const nowSeconds = Math.floor(Date.now() / 1000); + const timezone = this._weatherData.timezone; + const sunriseTime = this._weatherData.sys.sunrise; + const sunsetTime = this._weatherData.sys.sunset; + const daylight = Math.max(sunsetTime - sunriseTime, 1); + const midpoint = sunriseTime + Math.floor(daylight / 2); + let progress = 0; + let phaseText = ''; + let progressText = ''; + + if (nowSeconds <= sunriseTime) { + phaseText = this._lang === 'hi' ? 'सूर्योदय से पहले' : 'Before sunrise'; + progressText = this._lang === 'hi' ? `सूर्योदय में ${this.formatDuration(sunriseTime - nowSeconds)}` : `${this.formatDuration(sunriseTime - nowSeconds)} until sunrise`; + progress = 0; + } else if (nowSeconds >= sunsetTime) { + phaseText = this._lang === 'hi' ? 'सूर्यास्त के बाद' : 'After sunset'; + progressText = this._lang === 'hi' ? `सूर्योदय में ${this.formatDuration(this.getNextSunriseSeconds(sunriseTime, nowSeconds) - nowSeconds)}` : `${this.formatDuration(this.getNextSunriseSeconds(sunriseTime, nowSeconds) - nowSeconds)} until sunrise`; + progress = 100; + } else { + progress = ((nowSeconds - sunriseTime) / daylight) * 100; + phaseText = t['daylight'] || 'Daylight'; + progressText = this._lang === 'hi' ? `दिन का ${Math.round(progress)}% हिस्सा पूरा हुआ` : `${Math.round(progress)}% of daylight completed`; + } + + sunMarker.style.left = `${Math.min(Math.max(progress, 0), 100)}%`; + sunPhase.textContent = phaseText; + sunProgress.textContent = progressText; + + const midpointLabel = this._lang === 'hi' ? 'सौर मध्यबिंदु' : this._lang === 'es' ? 'Mediodía solar' : this._lang === 'fr' ? 'Midi solaire' : 'Solar midpoint'; + solarNoon.textContent = `${midpointLabel} ${this.formatTimeAtOffset(midpoint, timezone)}`; + } + + renderAqi() { + const aqiCard = this.shadowRoot.getElementById('aqi-card'); + const aqiValueEl = this.shadowRoot.getElementById('aqi-value'); + const aqiBadgeEl = this.shadowRoot.getElementById('aqi-badge'); + const aqiPollutantsEl = this.shadowRoot.getElementById('aqi-pollutants'); + const aqiRecommendationEl = this.shadowRoot.getElementById('aqi-recommendation'); + + if (!aqiCard) return; + + if (!this._aqiData || !Array.isArray(this._aqiData.list) || this._aqiData.list.length === 0) { + aqiCard.classList.add('hidden'); + return; + } + + aqiCard.classList.remove('hidden'); + + const entry = this._aqiData.list[0]; + const aqi = entry?.main?.aqi; + const pollutants = entry?.components || {}; + + const { label, badgeClass } = this.getAqiCategory(aqi); + + if (aqiValueEl) aqiValueEl.textContent = Number.isFinite(Number(aqi)) ? String(aqi) : '--'; + + if (aqiBadgeEl) { + aqiBadgeEl.textContent = label; + aqiBadgeEl.className = 'aqi-badge'; // Reset + if (badgeClass) aqiBadgeEl.classList.add(badgeClass); + } + + if (aqiPollutantsEl) { + const parts = []; + const add = (key, name) => { + const v = pollutants[key]; + if (v !== undefined && v !== null) parts.push(`${name}: ${v}`); + }; + add('pm2_5', 'PM2.5'); + add('pm10', 'PM10'); + add('o3', 'O₃'); + add('no2', 'NO₂'); + add('so2', 'SO₂'); + add('co', 'CO'); + aqiPollutantsEl.textContent = parts.length ? `Pollutants ${parts.join(' • ')}` : 'Pollutants --'; + } + + if (aqiRecommendationEl) { + aqiRecommendationEl.textContent = `Recommendation: ${this.getAqiHealthRecommendation(label)}`; + } + } + + updateFavoriteButton() { + const btn = this.shadowRoot.getElementById('favorite-btn'); + if (btn) { + btn.setAttribute('aria-pressed', String(this._isFavorite)); + const icon = btn.querySelector('.favorite-icon'); + if (icon) icon.textContent = this._isFavorite ? '★' : '☆'; + btn.title = this._isFavorite ? 'Remove favorite' : 'Favorite'; + } + } + + updateUnit() { + // Redraw temperatures + if (!this._weatherData) return; + this.render(); + } + + updateTranslations() { + if (!this._weatherData) return; + this.render(); + } +} + +customElements.define('current-weather', CurrentWeather); diff --git a/public/components/forecast-list.js b/public/components/forecast-list.js new file mode 100644 index 0000000..cc32bff --- /dev/null +++ b/public/components/forecast-list.js @@ -0,0 +1,986 @@ +class ForecastList extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this._forecastData = null; + this._trendData = []; + this._unit = 'C'; + this._lang = 'en'; + this._selectedHourlyMetric = 'humidity'; // humidity or precip + this._selectedTrendMetric = 'avg'; // avg, high, low + this._tempChart = null; + this._humidityPrecipChart = null; + } + + static get observedAttributes() { + return ['lang', 'unit']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) return; + if (name === 'lang') { + this._lang = newValue; + this.updateTranslationsAndRedraw(); + } else if (name === 'unit') { + this._unit = newValue; + this.updateUnitAndRedraw(); + } + } + + get forecastData() { return this._forecastData; } + set forecastData(val) { + this._forecastData = val; + if (val) { + this._trendData = this.buildDailyTrendData(val.list, val.city?.timezone || 0); + this.render(); + } + } + + connectedCallback() { + this.render(); + } + + disconnectedCallback() { + this.destroyCharts(); + } + + destroyCharts() { + if (this._tempChart) { + this._tempChart.destroy(); + this._tempChart = null; + } + if (this._humidityPrecipChart) { + this._humidityPrecipChart.destroy(); + this._humidityPrecipChart = null; + } + } + + toUnit(kelvin) { + const DEGREE = '\u00B0'; + if (this._unit === 'C') return `${Math.round(kelvin - 273.15)}${DEGREE}C`; + if (this._unit === 'F') return `${Math.round((kelvin - 273.15) * 9 / 5 + 32)}${DEGREE}F`; + return `${Math.round(kelvin)}K`; + } + + toUnitNum(kelvin) { + if (this._unit === 'C') return Math.round(kelvin - 273.15); + if (this._unit === 'F') return Math.round((kelvin - 273.15) * 9 / 5 + 32); + return Math.round(kelvin); + } + + unitLabel() { + const DEGREE = '\u00B0'; + if (this._unit === 'C') return `${DEGREE}C`; + if (this._unit === 'F') return `${DEGREE}F`; + return 'K'; + } + + getShiftedDate(unixSeconds, timezoneOffsetSeconds) { + return new Date((unixSeconds + timezoneOffsetSeconds) * 1000); + } + + buildHourlyPoints(forecastList, timezoneOffsetSeconds, hoursAhead) { + if (!Array.isArray(forecastList) || forecastList.length === 0) return []; + const nowSeconds = Math.floor(Date.now() / 1000); + const endSeconds = nowSeconds + hoursAhead * 60 * 60; + const locale = this._lang === 'hi' ? 'hi-IN' : this._lang === 'es' ? 'es-ES' : this._lang === 'fr' ? 'fr-FR' : 'en-US'; + + const points = forecastList + .slice() + .sort((a, b) => a.dt - b.dt) + .filter((item) => item?.dt >= nowSeconds - 3600 && item?.dt <= endSeconds); + + return points.slice(0, 10).map((item) => { + const localDate = this.getShiftedDate(item.dt, timezoneOffsetSeconds); + return { + raw: item, + dt: item.dt, + timeLabel: localDate.toLocaleTimeString(locale, { hour: 'numeric' }), + humidity: item.main?.humidity ?? null, + precipProb: item.pop ?? null, + precipAmount: item.rain?.['3h'] ?? null + }; + }); + } + + buildDailyTrendData(forecastList, timezoneOffsetSeconds) { + const groupedDays = new Map(); + const locale = this._lang === 'hi' ? 'hi-IN' : this._lang === 'es' ? 'es-ES' : this._lang === 'fr' ? 'fr-FR' : 'en-US'; + + forecastList.forEach((item) => { + const localDate = this.getShiftedDate(item.dt, timezoneOffsetSeconds); + const dateKey = localDate.toISOString().slice(0, 10); + + if (!groupedDays.has(dateKey)) { + groupedDays.set(dateKey, { + dateKey, + dayLabel: localDate.toLocaleDateString(locale, { weekday: 'short', timeZone: 'UTC' }), + dateLabel: localDate.toLocaleDateString(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }), + temperatures: [] + }); + } + groupedDays.get(dateKey).temperatures.push(item.main.temp); + }); + + return Array.from(groupedDays.values()).slice(0, 5).map((day) => { + const high = Math.max(...day.temperatures); + const low = Math.min(...day.temperatures); + const avg = day.temperatures.reduce((total, temp) => total + temp, 0) / day.temperatures.length; + return { ...day, high, low, avg }; + }); + } + + render() { + if (!this._forecastData) { + this.shadowRoot.innerHTML = ''; + return; + } + + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + + this.shadowRoot.innerHTML = ` + + +
+ +
+
+ ${t['next24Hours'] || 'Next 24 Hours'} + -- +
+
+ +
+
+ + +
+
+ ${t['humidityPrecip'] || 'Humidity & Precip (Next 24 Hours)'} + -- +
+
+ + +
+
+ +
+
+ + +
+

${t['forecast24h'] || '24-Hour Forecast'}

+

${t['forecastDesc'] || 'Upcoming weather trend will appear here.'}

+
+ +
+
+
+ + + + `; + + this.setupToggles(); + this.renderHourlyForecastCards(); + this.renderCharts(); + this.renderWeatherTrends(); + } + + setupToggles() { + const hourlyToggles = this.shadowRoot.querySelectorAll('.hourly-toggle'); + hourlyToggles.forEach(btn => { + btn.addEventListener('click', () => { + this._selectedHourlyMetric = btn.dataset.hourlyMetric; + hourlyToggles.forEach(b => b.classList.toggle('active', b === btn)); + this.renderHumidityPrecipChart(); + }); + }); + + const trendToggles = this.shadowRoot.querySelectorAll('[data-trend-metric]'); + trendToggles.forEach(btn => { + btn.addEventListener('click', () => { + this._selectedTrendMetric = btn.dataset.trendMetric; + trendToggles.forEach(b => b.classList.toggle('active', b === btn)); + this.renderTrendChart(); + }); + }); + } + + renderHourlyForecastCards() { + const container = this.shadowRoot.getElementById('forecast-container'); + if (!container) return; + + const timezoneOffsetSeconds = this._forecastData.city?.timezone || 0; + const locale = this._lang === 'hi' ? 'hi-IN' : this._lang === 'es' ? 'es-ES' : this._lang === 'fr' ? 'fr-FR' : 'en-US'; + const ICON_URL = 'https://openweathermap.org/img/wn'; + + const hoursAhead = 24; + const nowSeconds = Math.floor(Date.now() / 1000); + const endSeconds = nowSeconds + hoursAhead * 60 * 60; + const points = this._forecastData.list + .slice() + .sort((a, b) => a.dt - b.dt) + .filter((item) => item?.dt >= nowSeconds - 3600 && item?.dt <= endSeconds) + .slice(0, 10); + + container.innerHTML = points.map(item => { + const localDate = this.getShiftedDate(item.dt, timezoneOffsetSeconds); + const timeLabel = localDate.toLocaleTimeString(locale, { hour: 'numeric', hour12: true }); + const iconCode = item.weather[0].icon; + const temp = this.toUnitNum(item.main.temp); + const desc = item.weather[0].description; + + return ` +
+
${timeLabel}
+
+ ${desc} +
+
${temp}${this.unitLabel()}
+
${desc}
+
+ `; + }).join(''); + } + + renderCharts() { + this.destroyCharts(); + this.renderTemperatureChart(); + this.renderHumidityPrecipChart(); + } + + renderTemperatureChart() { + const canvas = this.shadowRoot.getElementById('temperature-chart'); + if (!canvas) return; + + const timezoneOffsetSeconds = this._forecastData.city?.timezone || 0; + const hoursAhead = 24; + const nowSeconds = Math.floor(Date.now() / 1000); + const endSeconds = nowSeconds + hoursAhead * 60 * 60; + const hourlyData = this._forecastData.list + .slice() + .sort((a, b) => a.dt - b.dt) + .filter((item) => item?.dt >= nowSeconds - 3600 && item?.dt <= endSeconds) + .slice(0, 10); + + if (!hourlyData.length) return; + + const locale = this._lang === 'hi' ? 'hi-IN' : this._lang === 'es' ? 'es-ES' : this._lang === 'fr' ? 'fr-FR' : 'en-US'; + const labels = hourlyData.map(item => { + const date = new Date(item.dt * 1000); + return date.toLocaleTimeString(locale, { hour: 'numeric', hour12: true }); + }); + + const temperatures = hourlyData.map(item => this.toUnitNum(item.main.temp)); + + const minTemp = Math.min(...temperatures); + const maxTemp = Math.max(...temperatures); + const graphRange = this.shadowRoot.getElementById('graph-range'); + if (graphRange) graphRange.textContent = `${minTemp}${this.unitLabel()} - ${maxTemp}${this.unitLabel()}`; + + // Set summary + const forecastSummary = this.shadowRoot.getElementById('forecast-summary'); + if (forecastSummary) { + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + const trend = temperatures[temperatures.length - 1] > temperatures[0] + ? (this._lang === 'hi' ? 'तापमान में बढ़ोतरी होगी' : 'Temperatures are expected to rise') + : (this._lang === 'hi' ? 'तापमान में गिरावट होगी' : 'Temperatures are expected to fall'); + forecastSummary.textContent = `${trend} (${minTemp}${this.unitLabel()} - ${maxTemp}${this.unitLabel()}).`; + } + + const data = { + labels: labels, + datasets: [{ + label: `Temperature (${this.unitLabel()})`, + data: temperatures, + borderColor: 'rgba(75, 192, 192, 1)', + backgroundColor: 'rgba(75, 192, 192, 0.2)', + fill: true, + tension: 0.4 + }] + }; + + const config = { + type: 'line', + data: data, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { + beginAtZero: false, + ticks: { callback: (value) => value + this.unitLabel() } + } + } + } + }; + + if (window.Chart) { + this._tempChart = new window.Chart(canvas, config); + } + } + + renderHumidityPrecipChart() { + const canvas = this.shadowRoot.getElementById('humidity-precip-chart'); + const rangeEl = this.shadowRoot.getElementById('humidity-precip-range'); + if (!canvas || !rangeEl) return; + + const timezoneOffsetSeconds = this._forecastData.city?.timezone || 0; + const hourlyPoints = this.buildHourlyPoints(this._forecastData.list, timezoneOffsetSeconds, 24); + + if (hourlyPoints.length === 0) { + rangeEl.textContent = '--'; + return; + } + + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + const metric = this._selectedHourlyMetric; + const labels = hourlyPoints.map(p => p.timeLabel); + const values = hourlyPoints.map(p => { + if (metric === 'humidity') return p.humidity || 0; + return (p.precipProb || 0) * 100; + }); + + const data = { + labels: labels, + datasets: [{ + label: metric === 'humidity' ? `${t['humidity'] || 'Humidity'} (%)` : `${t['precip'] || 'Precipitation'} (%)`, + data: values, + borderColor: metric === 'humidity' ? 'rgba(54, 162, 235, 1)' : 'rgba(255, 99, 132, 1)', + backgroundColor: metric === 'humidity' ? 'rgba(54, 162, 235, 0.2)' : 'rgba(255, 99, 132, 0.2)', + fill: true, + tension: 0.4 + }] + }; + + const config = { + type: 'line', + data: data, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { + beginAtZero: true, + max: 100, + ticks: { callback: (value) => value + '%' } + } + } + } + }; + + if (this._humidityPrecipChart) { + this._humidityPrecipChart.destroy(); + } + + if (window.Chart) { + this._humidityPrecipChart = new window.Chart(canvas, config); + } + + const minV = Math.min(...values); + const maxV = Math.max(...values); + rangeEl.textContent = `${minV}% - ${maxV}%`; + } + + renderWeatherTrends() { + const trendStatsEl = this.shadowRoot.getElementById('trend-stats'); + const trendsSummaryEl = this.shadowRoot.getElementById('trends-summary'); + if (!trendStatsEl || !this._trendData.length) return; + + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + + // Render stats cards + trendStatsEl.innerHTML = this._trendData.map(day => ` +
+
+ ${day.dayLabel} + ${day.dateLabel} +
+
+ ${this.toUnitNum(day.high)}${this.unitLabel()} ${t['high'] ? t['high'].toLowerCase() : 'high'} + ${this.toUnitNum(day.low)}${this.unitLabel()} ${t['low'] ? t['low'].toLowerCase() : 'low'} + ${this.toUnitNum(day.avg)}${this.unitLabel()} ${t['avg'] ? t['avg'].toLowerCase() : 'avg'} +
+
+ `).join(''); + + // Update summary text + const avgs = this._trendData.map(day => this.toUnitNum(day.avg)); + const overallAvg = Math.round(avgs.reduce((sum, v) => sum + v, 0) / avgs.length); + if (trendsSummaryEl) { + trendsSummaryEl.textContent = this._lang === 'hi' + ? `अगले 5 दिनों का औसत तापमान लगभग ${overallAvg}${this.unitLabel()} रहेगा।` + : `Average temperature over the next 5 days will be around ${overallAvg}${this.unitLabel()}.`; + } + + this.renderTrendChart(); + } + + renderTrendChart() { + const trendChart = this.shadowRoot.getElementById('trend-chart'); + const labelEl = this.shadowRoot.getElementById('trend-chart-label'); + const rangeEl = this.shadowRoot.getElementById('trend-chart-range'); + if (!trendChart || !this._trendData.length) return; + + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + const metricLabels = { + high: t['high'] || 'Daily High Temperature', + low: t['low'] || 'Daily Low Temperature', + avg: t['avgTemp'] || 'Daily Average Temperature' + }; + const metricColors = { + high: '#f97316', + low: '#0ea5e9', + avg: '#667eea' + }; + const metric = this._selectedTrendMetric; + const width = 760; + const height = 280; + const padding = { top: 46, right: 42, bottom: 48, left: 54 }; + const innerWidth = width - padding.left - padding.right; + const innerHeight = height - padding.top - padding.bottom; + const values = this._trendData.map((day) => this.toUnitNum(day[metric])); + const lowValues = this._trendData.map((day) => this.toUnitNum(day.low)); + const highValues = this._trendData.map((day) => this.toUnitNum(day.high)); + const minValue = Math.floor(Math.min(...lowValues) - 1); + const maxValue = Math.ceil(Math.max(...highValues) + 1); + const range = Math.max(maxValue - minValue, 1); + const barWidth = Math.min(58, innerWidth / this._trendData.length * 0.45); + + const getY = (value) => padding.top + ((maxValue - value) / range) * innerHeight; + const points = this._trendData.map((day, index) => { + const x = padding.left + (index * innerWidth) / Math.max(this._trendData.length - 1, 1); + const val = this.toUnitNum(day[metric]); + return { ...day, x, y: getY(val), value: val }; + }); + + const baselineY = height - padding.bottom; + const bars = points.map((point) => { + const barHeight = Math.max(baselineY - point.y, 3); + return ` + + + + + + + `; + }).join(''); + const linePoints = points.map((point) => `${point.x},${point.y}`).join(' '); + const gridLines = [0, 0.5, 1].map((step) => { + const y = padding.top + innerHeight * step; + const value = Math.round(maxValue - range * step); + return ` + + ${value}${this.unitLabel()} + `; + }).join(''); + const labels = points.map((point) => ` + + + ${Math.round(point.value)}${this.unitLabel()} + ${point.dayLabel} + + `).join(''); + + trendChart.style.setProperty('--trend-color', metricColors[metric]); + trendChart.innerHTML = ` + ${gridLines} + ${bars} + + ${labels} + `; + + if (labelEl) labelEl.textContent = metricLabels[metric]; + if (rangeEl) rangeEl.textContent = `${Math.round(Math.min(...values))}${this.unitLabel()} - ${Math.round(Math.max(...values))}${this.unitLabel()}`; + } + + updateUnitAndRedraw() { + if (!this._forecastData) return; + this._trendData = this.buildDailyTrendData(this._forecastData.list, this._forecastData.city?.timezone || 0); + this.render(); + } + + updateTranslationsAndRedraw() { + if (!this._forecastData) return; + this._trendData = this.buildDailyTrendData(this._forecastData.list, this._forecastData.city?.timezone || 0); + this.render(); + } +} + +customElements.define('forecast-list', ForecastList); diff --git a/public/components/weather-header.js b/public/components/weather-header.js new file mode 100644 index 0000000..3ab1b60 --- /dev/null +++ b/public/components/weather-header.js @@ -0,0 +1,305 @@ +class WeatherHeader extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this._lang = 'en'; + this._theme = 'light'; + this._unit = 'C'; + } + + static get observedAttributes() { + return ['lang', 'theme', 'unit']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) return; + if (name === 'lang') { + this._lang = newValue; + this.updateTranslations(); + } else if (name === 'theme') { + this._theme = newValue; + this.updateTheme(); + } else if (name === 'unit') { + this._unit = newValue; + this.updateUnit(); + } + } + + connectedCallback() { + this.render(); + this.setupEventListeners(); + } + + render() { + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + + this.shadowRoot.innerHTML = ` + +
+

${t['appTitle'] || 'Weatherify ⛅'}

+ + + + + +
+ + + +
+
+ `; + + this.shadowRoot.getElementById('language-toggle').value = this._lang; + this.updateThemeEffects(); + } + + setupEventListeners() { + const langToggle = this.shadowRoot.getElementById('language-toggle'); + langToggle.addEventListener('change', (e) => { + const lang = e.target.value; + this.dispatchEvent(new CustomEvent('lang-change', { + detail: { lang }, + bubbles: true, + composed: true + })); + }); + + const themeToggle = this.shadowRoot.getElementById('theme-toggle'); + themeToggle.addEventListener('click', () => { + const newTheme = this._theme === 'dark' ? 'light' : 'dark'; + this.dispatchEvent(new CustomEvent('theme-change', { + detail: { theme: newTheme }, + bubbles: true, + composed: true + })); + }); + + const unitButtons = this.shadowRoot.querySelectorAll('.unit-btn'); + unitButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + const unit = btn.dataset.unit; + this.dispatchEvent(new CustomEvent('unit-change', { + detail: { unit }, + bubbles: true, + composed: true + })); + }); + }); + } + + updateTranslations() { + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + const titleEl = this.shadowRoot.getElementById('app-title'); + if (titleEl) titleEl.textContent = t['appTitle'] || 'Weatherify ⛅'; + + const langToggle = this.shadowRoot.getElementById('language-toggle'); + if (langToggle) langToggle.value = this._lang; + + const labelEl = this.shadowRoot.querySelector('.toggle-label'); + if (labelEl) { + labelEl.textContent = this._theme === 'dark' ? (t['themeLight'] || 'Light') : (t['themeDark'] || 'Dark'); + } + } + + updateTheme() { + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + const labelEl = this.shadowRoot.querySelector('.toggle-label'); + if (labelEl) { + labelEl.textContent = this._theme === 'dark' ? (t['themeLight'] || 'Light') : (t['themeDark'] || 'Dark'); + } + this.updateThemeEffects(); + } + + updateThemeEffects() { + const themeToggle = this.shadowRoot.getElementById('theme-toggle'); + if (themeToggle) { + const isDark = this._theme === 'dark'; + themeToggle.setAttribute('aria-pressed', String(isDark)); + themeToggle.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme'); + themeToggle.title = isDark ? 'Switch to light theme' : 'Switch to dark theme'; + const icon = themeToggle.querySelector('.toggle-icon'); + if (icon) icon.textContent = isDark ? '☀️' : '🌙'; + } + } + + updateUnit() { + const buttons = this.shadowRoot.querySelectorAll('.unit-btn'); + buttons.forEach(btn => { + btn.classList.toggle('active', btn.dataset.unit === this._unit); + }); + } +} + +customElements.define('weather-header', WeatherHeader); diff --git a/public/components/weather-map.js b/public/components/weather-map.js new file mode 100644 index 0000000..1bcc199 --- /dev/null +++ b/public/components/weather-map.js @@ -0,0 +1,285 @@ +class WeatherMap extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this._lang = 'en'; + this._lat = null; + this._lon = null; + this._cityName = ''; + this._mapInstance = null; + this._mapMarker = null; + this._activeWeatherLayer = null; + } + + static get observedAttributes() { + return ['lang']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) return; + if (name === 'lang') { + this._lang = newValue; + this.updateTranslations(); + } + } + + setMapCoords(lat, lon, cityName) { + this._lat = lat; + this._lon = lon; + this._cityName = cityName; + this.initOrUpdateMap(); + } + + connectedCallback() { + this.render(); + } + + render() { + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + + this.shadowRoot.innerHTML = ` + + + +
+ + +
+
+
+
+ `; + + this.setupLayerControls(); + this.initOrUpdateMap(); + } + + setupLayerControls() { + const controls = this.shadowRoot.querySelectorAll('.map-layer-controls .trend-toggle'); + controls.forEach(btn => { + btn.addEventListener('click', (e) => { + controls.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + const layerType = btn.dataset.layer; + this.updateWeatherLayer(layerType); + }); + }); + } + + initOrUpdateMap() { + const mapElement = this.shadowRoot.getElementById('weather-map'); + if (!mapElement || this._lat === null || this._lon === null) return; + + const L = window.L; + if (!L) return; + + if (!this._mapInstance) { + this._mapInstance = L.map(mapElement).setView([this._lat, this._lon], 10); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this._mapInstance); + this._mapMarker = L.marker([this._lat, this._lon]).addTo(this._mapInstance); + } else { + this._mapInstance.setView([this._lat, this._lon], 10); + this._mapMarker.setLatLng([this._lat, this._lon]); + } + this._mapMarker.bindPopup(`${this._cityName}`).openPopup(); + } + + updateWeatherLayer(layerType) { + const L = window.L; + if (!L || !this._mapInstance) return; + + if (this._activeWeatherLayer) { + this._mapInstance.removeLayer(this._activeWeatherLayer); + this._activeWeatherLayer = null; + } + + let layerUrl = ''; + if (layerType === 'precipitation') { + layerUrl = 'https://tile.openweathermap.org/map/precipitation_new/{z}/{x}/{y}.png?appid=MOCK_KEY'; + } else if (layerType === 'clouds') { + layerUrl = 'https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=MOCK_KEY'; + } + + if (layerUrl && layerType !== 'base') { + // Keep the code disabled as in original codebase + // this._activeWeatherLayer = L.tileLayer(layerUrl, { opacity: 0.6 }).addTo(this._mapInstance); + } + } + + updateTranslations() { + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + + const titleEl = this.shadowRoot.getElementById('weather-map-title'); + if (titleEl) titleEl.textContent = t['interactiveMap'] || 'Interactive Map'; + + const summaryEl = this.shadowRoot.getElementById('map-summary'); + if (summaryEl) summaryEl.textContent = t['mapDesc'] || 'Live spatial weather patterns.'; + + const controls = this.shadowRoot.querySelectorAll('.map-layer-controls .trend-toggle'); + controls.forEach(btn => { + const layer = btn.dataset.layer; + if (layer === 'base') btn.textContent = t['mapBase'] || 'Base'; + else if (layer === 'precipitation') btn.textContent = t['mapRain'] || 'Rain'; + else if (layer === 'clouds') btn.textContent = t['mapClouds'] || 'Clouds'; + }); + } +} + +customElements.define('weather-map', WeatherMap); diff --git a/public/components/weather-search.js b/public/components/weather-search.js new file mode 100644 index 0000000..0023835 --- /dev/null +++ b/public/components/weather-search.js @@ -0,0 +1,839 @@ +class WeatherSearch extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this._lang = 'en'; + this._loading = false; + this._error = ''; + this._welcome = true; + this._favorites = []; + this._recent = []; + this._debounceTimer = null; + } + + static get observedAttributes() { + return ['lang', 'loading', 'error', 'welcome']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) return; + if (name === 'lang') { + this._lang = newValue; + this.updateTranslations(); + } else if (name === 'loading') { + this._loading = this.hasAttribute('loading'); + this.updateLoadingState(); + } else if (name === 'error') { + this._error = newValue || ''; + this.updateErrorState(); + } else if (name === 'welcome') { + this._welcome = this.hasAttribute('welcome'); + this.updateWelcomeState(); + } + } + + get favorites() { return this._favorites; } + set favorites(val) { + this._favorites = val || []; + this.renderHistory(); + } + + get recent() { return this._recent; } + set recent(val) { + this._recent = val || []; + this.renderHistory(); + } + + connectedCallback() { + this.render(); + this.setupEventListeners(); + } + + render() { + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + this.shadowRoot.innerHTML = ` + + +
+ + + + + + + + +
+

${t['welcomeTitle'] || 'Welcome to Weatherify ⛅'}

+

${t['welcomeDesc'] || 'Search for a city to view its weather forecast'}

+
+ + + +
+ `; + + this.renderHistory(); + this.updateLoadingState(); + this.updateErrorState(); + this.updateWelcomeState(); + } + + setupEventListeners() { + const cityInput = this.shadowRoot.getElementById('city-input'); + const clearBtn = this.shadowRoot.getElementById('clear-btn'); + const searchBtn = this.shadowRoot.getElementById('search-btn'); + const locationBtn = this.shadowRoot.getElementById('location-btn'); + + cityInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this.hideSuggestions(); + this.handleSearchSubmit(); + } + }); + + cityInput.addEventListener('focus', () => { + if (!cityInput.value.trim()) { + this.openHistoryDropdown(); + } + }); + + cityInput.addEventListener('input', (e) => { + this.setAttribute('error', ''); // Clear error state on input + const query = e.target.value.trim(); + searchBtn.disabled = query.length === 0; + clearBtn.classList.toggle('hidden', cityInput.value.length === 0); + + clearTimeout(this._debounceTimer); + + if (query.length < 2) { + this.hideSuggestions(); + if (!query) { + this.openHistoryDropdown(); + } else { + this.closeHistoryDropdown(); + } + return; + } + + this.closeHistoryDropdown(); + this._debounceTimer = setTimeout(() => this.fetchCitySuggestions(query), 300); + }); + + clearBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + cityInput.value = ''; + cityInput.dispatchEvent(new Event('input', { bubbles: true })); + cityInput.focus(); + }); + + searchBtn.addEventListener('click', () => this.handleSearchSubmit()); + + if (locationBtn) { + locationBtn.addEventListener('click', () => { + this.hideSuggestions(); + this.dispatchEvent(new CustomEvent('location-request', { + bubbles: true, + composed: true + })); + }); + } + + // Handle clicking outside suggestions/history + document.addEventListener('click', (e) => { + const path = e.composedPath(); + if (!path.includes(this)) { + this.hideSuggestions(); + this.closeHistoryDropdown(); + } + }); + } + + handleSearchSubmit() { + const cityInput = this.shadowRoot.getElementById('city-input'); + const city = cityInput.value.trim(); + if (city) { + this.dispatchEvent(new CustomEvent('city-select', { + detail: { city }, + bubbles: true, + composed: true + })); + } + } + + async fetchCitySuggestions(query) { + try { + const url = `/api/geo?q=${encodeURIComponent(query)}&limit=5&lang=${this._lang}`; + const response = await fetch(url); + if (!response.ok) return; + + const cities = await response.json(); + this.displaySuggestions(cities); + } catch (error) { + console.error('Error fetching suggestions:', error); + } + } + + displaySuggestions(cities) { + const container = this.shadowRoot.getElementById('suggestions-dropdown'); + if (!container) return; + + if (cities.length === 0) { + this.hideSuggestions(); + return; + } + + container.innerHTML = ''; + + cities.forEach((city) => { + const suggestion = document.createElement('div'); + suggestion.className = 'suggestion-item'; + + const localName = city.local_names && city.local_names[this._lang] ? city.local_names[this._lang] : city.name; + const text = `${localName}, ${city.state ? `${city.state}, ` : ''}${city.country}`; + suggestion.textContent = text; + + suggestion.addEventListener('click', () => { + const cityInput = this.shadowRoot.getElementById('city-input'); + const clearBtn = this.shadowRoot.getElementById('clear-btn'); + + cityInput.value = localName; + clearBtn.classList.remove('hidden'); + this.hideSuggestions(); + + this.dispatchEvent(new CustomEvent('city-select', { + detail: { city: localName }, + bubbles: true, + composed: true + })); + }); + + container.appendChild(suggestion); + }); + + container.classList.remove('hidden'); + } + + hideSuggestions() { + const container = this.shadowRoot.getElementById('suggestions-dropdown'); + if (container) container.classList.add('hidden'); + } + + openHistoryDropdown() { + const dropdown = this.shadowRoot.getElementById('history-dropdown'); + if (dropdown && (this._favorites.length || this._recent.length)) { + dropdown.classList.remove('hidden'); + } + } + + closeHistoryDropdown() { + const dropdown = this.shadowRoot.getElementById('history-dropdown'); + if (dropdown) dropdown.classList.add('hidden'); + } + + renderHistory() { + const dropdown = this.shadowRoot.getElementById('history-dropdown'); + if (!dropdown) return; + + const inner = dropdown.querySelector('.history-dropdown-inner'); + if (!inner) return; + + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + + const favoriteMarkup = this._favorites.map((item) => ` + + `).join(''); + + const recentMarkup = this._recent.map((item) => ` + + `).join(''); + + const hasHistory = this._favorites.length > 0 || this._recent.length > 0; + const noRecentText = t['recent'] ? `No ${t['recent'].toLowerCase()} searches yet.` : 'No recent searches yet.'; + + const emptyState = !hasHistory ? ` +
+

${noRecentText}

+
+ ` : ''; + + inner.innerHTML = ` +
+
${t['favorites'] || 'Favorites'}
+
${favoriteMarkup}
+
+
+
${t['recent'] || 'Recent'}
+
${recentMarkup}
+
+ ${emptyState} +
+ +
+ `; + + // Add event listeners inside history list + inner.querySelectorAll('.history-button').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const query = btn.dataset.query; + this.closeHistoryDropdown(); + + // Also set the input value + const cityInput = this.shadowRoot.getElementById('city-input'); + const clearBtn = this.shadowRoot.getElementById('clear-btn'); + const searchBtn = this.shadowRoot.getElementById('search-btn'); + if (cityInput) { + cityInput.value = btn.querySelector('span').textContent; + if (clearBtn) clearBtn.classList.remove('hidden'); + if (searchBtn) searchBtn.disabled = false; + } + + this.dispatchEvent(new CustomEvent('city-select', { + detail: { city: query }, + bubbles: true, + composed: true + })); + }); + }); + + const clearBtn = inner.querySelector('.clear-history-btn'); + if (clearBtn) { + clearBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent('clear-history', { + bubbles: true, + composed: true + })); + }); + } + } + + updateLoadingState() { + const loadingEl = this.shadowRoot.getElementById('loading'); + if (loadingEl) { + loadingEl.classList.toggle('hidden', !this._loading); + } + } + + updateErrorState() { + const errorEl = this.shadowRoot.getElementById('error-message'); + if (errorEl) { + const p = errorEl.querySelector('p'); + if (this._error) { + p.textContent = this._error; + errorEl.classList.remove('hidden'); + } else { + errorEl.classList.add('hidden'); + } + } + } + + updateWelcomeState() { + const welcomeEl = this.shadowRoot.getElementById('no-data-message'); + if (welcomeEl) { + welcomeEl.classList.toggle('hidden', !this._welcome); + } + } + + updateTranslations() { + const t = window.translations?.[this._lang] || window.translations?.['en'] || {}; + + // placeholder + const cityInput = this.shadowRoot.getElementById('city-input'); + if (cityInput) cityInput.placeholder = t['searchPlaceholder'] || 'Enter city name...'; + + // location label + const locationLabel = this.shadowRoot.querySelector('.location-label'); + if (locationLabel) locationLabel.textContent = t['useMyLocation'] || 'Use My Location'; + + // welcome message + const welcomeEl = this.shadowRoot.getElementById('no-data-message'); + if (welcomeEl) { + const h2 = welcomeEl.querySelector('h2'); + const p = welcomeEl.querySelector('p'); + h2.textContent = t['welcomeTitle'] || 'Welcome to Weatherify ⛅'; + p.textContent = t['welcomeDesc'] || 'Search for a city to view its weather forecast'; + } + + // history headers + this.renderHistory(); + } + + // Public method to set input value + setInputValue(val) { + const cityInput = this.shadowRoot.getElementById('city-input'); + const clearBtn = this.shadowRoot.getElementById('clear-btn'); + const searchBtn = this.shadowRoot.getElementById('search-btn'); + if (cityInput) { + cityInput.value = val; + if (clearBtn) clearBtn.classList.toggle('hidden', !val); + if (searchBtn) searchBtn.disabled = !val; + } + } +} + +customElements.define('weather-search', WeatherSearch); diff --git a/public/index.html b/public/index.html index 5e74865..84fd3f2 100644 --- a/public/index.html +++ b/public/index.html @@ -35,356 +35,31 @@ + + + + + + +
-
-

Weatherify ⛅

- - - - - -
- - - -
-
- - - - -
-
-

Saved Cities

-

Quickly reopen favorite cities or recent searches.

-
-
-
-
Favorites
-
-
-
-
Recent
-
-
-
- -
-
-
- -
-

Welcome to Weatherify ⛅

-

Search for a city to view its weather forecast

-
- - - - -
-
- - + + +
- - -
-
-
-
-

City Name

-
- - -
-
- -

Date

-
-
-
- -- - °C -
-

Weather Description

-

Feels like --°C

-
-
- - -
-
-

Sun Position

- Daylight -
-
-
-
- -
-
-
- Sunrise in -- - Solar midpoint --:-- -
-
- - - -
- -
-
- -
-
- Feels Like - --°C -
-
- -
-
- -
-
- Humidity - --% -
-
- -
-
- -
-
- Wind Speed - -- km/h - Wind Speed - -- km/h -
- - - - - N - - - -
-
-
- -
-
- -
-
- Pressure - -- hPa -
-
- -
-
- -
-
- Visibility - -- km -
-
- -
- -
- Air Quality (AQI) - - -- - -- - - Pollutants -- - Recommendation -- -
-
- -
-
- -
-
- Sunrise - --:-- -
-
- -
-
- -
-
- Sunset - --:-- -
-
- -
-
- -
-
- UV Index - -- -
-
- -
- - -
-
-
- Next 24 Hours - -- -
-
- -
-
- -
-
- Humidity & Precip (Next 24 Hours) - -- -
-
- - -
-
- -
-
- -
-

24-Hour Forecast

-

Upcoming weather trend will appear here.

-
- -
-
-
- - - - - -
- - -
-
-
-
- -
+ + + + -
diff --git a/public/script.js b/public/script.js index d39ed4e..8c939ec 100644 --- a/public/script.js +++ b/public/script.js @@ -1,203 +1,44 @@ // Weather and geocoding go through same-origin `/api/*` proxy (see server.js). No API key in the browser. const API_BASE = '/api'; -const ICON_URL = 'https://openweathermap.org/img/wn'; -const DEGREE = '\u00B0'; const DEFAULT_CITY = 'New Delhi'; let currentUnit = 'C'; let rawData = { current: null, forecast: null, airQuality: null }; - -// ============================================================ -// 🌍 MULTI-LANGUAGE SUPPORT (i18n) INTEGRATION -// ============================================================ let currentLang = localStorage.getItem('weatherify-lang') || 'en'; +let currentTheme = localStorage.getItem('weatherify-theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); -function applyTranslations(lang) { - if (!window.translations || !window.translations[lang]) return; - const t = window.translations[lang]; - - document.querySelectorAll('[data-i18n]').forEach(el => { - const key = el.getAttribute('data-i18n'); - if (t[key]) el.textContent = t[key]; - }); - - document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { - const key = el.getAttribute('data-i18n-placeholder'); - if (t[key]) el.setAttribute('placeholder', t[key]); - }); - - const isDark = document.body.classList.contains('dark-mode'); - const toggleLabel = document.querySelector('.toggle-label'); - if (toggleLabel) toggleLabel.textContent = isDark ? t['themeLight'] : t['themeDark']; - - if (rawData.current) { - const feelsLikeMain = document.getElementById('feels-like-main'); - if (feelsLikeMain) feelsLikeMain.textContent = `${t['feelsLike']} ${toUnit(rawData.current.main.feels_like)}`; - } -} - -const languageToggle = document.getElementById('language-toggle'); -if (languageToggle) { - languageToggle.value = currentLang; - languageToggle.addEventListener('change', (e) => { - currentLang = e.target.value; - localStorage.setItem('weatherify-lang', currentLang); - applyTranslations(currentLang); - if (currentCityQuery) fetchWeatherData(currentCityQuery); - else fetchWeatherData(DEFAULT_CITY); - }); -} -document.addEventListener('DOMContentLoaded', () => applyTranslations(currentLang)); - - -// ============================================================ -// AQI UI elements -// ============================================================ -const aqiCard = document.getElementById('aqi-card'); -const aqiValueEl = document.getElementById('aqi-value'); -const aqiBadgeEl = document.getElementById('aqi-badge'); -const aqiPollutantsEl = document.getElementById('aqi-pollutants'); -const aqiRecommendationEl = document.getElementById('aqi-recommendation'); - -function getAqiCategory(aqi) { - const value = Number(aqi); - if (!Number.isFinite(value)) { - return { label: 'Unknown', level: 0, badgeClass: '' }; - } - - // OpenWeatherMap air pollution uses a 1-5 AQI index. - switch (value) { - case 1: - return { label: 'Good', level: 1, badgeClass: 'aqi-1' }; - case 2: - return { label: 'Fair', level: 2, badgeClass: 'aqi-2' }; - case 3: - return { label: 'Moderate', level: 3, badgeClass: 'aqi-3' }; - case 4: - return { label: 'Poor', level: 4, badgeClass: 'aqi-4' }; - case 5: - return { label: 'Very Poor', level: 5, badgeClass: 'aqi-5' }; - default: - if (value <= 50) return { label: 'Good', level: 1, badgeClass: 'aqi-1' }; - if (value <= 100) return { label: 'Fair', level: 2, badgeClass: 'aqi-2' }; - if (value <= 150) return { label: 'Moderate', level: 3, badgeClass: 'aqi-3' }; - if (value <= 200) return { label: 'Poor', level: 4, badgeClass: 'aqi-4' }; - return { label: 'Very Poor', level: 5, badgeClass: 'aqi-5' }; - } -} - -function getAqiHealthRecommendation(categoryLabel) { - switch (categoryLabel) { - case 'Good': - return 'Enjoy outdoor activities. Sensitive groups may still consider monitoring.'; - case 'Fair': - return 'Unusually sensitive individuals should reduce prolonged outdoor exertion.'; - case 'Moderate': - return 'Consider reducing prolonged outdoor activities if you experience symptoms.'; - case 'Poor': - return 'Limit outdoor activity; keep windows closed and consider an air purifier.'; - case 'Very Poor': - return 'Avoid outdoor activity. Stay indoors and follow local health guidance.'; - default: - return 'Air quality information is unavailable right now.'; - } -} - -function renderAqiUI(airPollution) { - console.log('[AQI] renderAqiUI called with:', airPollution); - - if (!airPollution) { - console.warn('[AQI] No airPollution data provided'); - if (aqiCard) aqiCard.classList.add('hidden'); - return; - } - - if (!Array.isArray(airPollution.list)) { - console.warn('[AQI] airPollution.list is not an array:', typeof airPollution.list, airPollution.list); - if (aqiCard) aqiCard.classList.add('hidden'); - return; - } - - if (airPollution.list.length === 0) { - console.warn('[AQI] airPollution.list is empty'); - if (aqiCard) aqiCard.classList.add('hidden'); - return; - } - - console.log('[AQI] Rendering AQI data'); - if (aqiCard) aqiCard.classList.remove('hidden'); - - const entry = airPollution.list[0]; - console.log('[AQI] Entry:', entry); - const aqi = entry?.main?.aqi; - const pollutants = entry?.components || {}; - console.log('[AQI] AQI value:', aqi, 'Pollutants:', pollutants); - - const { label, badgeClass } = getAqiCategory(aqi); +let currentCityQuery = ''; +let currentCityLabel = ''; +let recentSearches = []; +let favoriteCities = []; - if (aqiValueEl) aqiValueEl.textContent = Number.isFinite(Number(aqi)) ? String(aqi) : '--'; +const STORAGE_RECENT = 'weatherify-recent-cities'; +const STORAGE_FAVORITES = 'weatherify-favorite-cities'; +const MAX_RECENT_SEARCHES = 8; - if (aqiBadgeEl) { - aqiBadgeEl.textContent = label; - aqiBadgeEl.classList.remove('aqi-1', 'aqi-2', 'aqi-3', 'aqi-4', 'aqi-5'); - if (badgeClass) aqiBadgeEl.classList.add(badgeClass); - } +// DOM Components references +let appHeader, appSearch, mainWeather, dailyForecast, weatherMapComponent, weatherContainer, spinnerLoading; - if (aqiPollutantsEl) { - const parts = []; - const pm25 = pollutants.pm2_5; - const pm10 = pollutants.pm10; - const o3 = pollutants.o3; - const no2 = pollutants.no2; - const so2 = pollutants.so2; - const co = pollutants.co; - - const add = (key, label) => { - const v = pollutants[key]; - if (v === undefined || v === null) return; - parts.push(`${label}: ${v}`); - }; - - add('pm2_5', 'PM2.5'); - add('pm10', 'PM10'); - add('o3', 'O₃'); - add('no2', 'NO₂'); - add('so2', 'SO₂'); - add('co', 'CO'); - - aqiPollutantsEl.textContent = parts.length ? `Pollutants ${parts.join(' • ')}` : 'Pollutants --'; - } - - if (aqiRecommendationEl) { - aqiRecommendationEl.textContent = `Recommendation: ${getAqiHealthRecommendation(label)}`; - } +function getShiftedDate(unixSeconds, timezoneOffsetSeconds) { + return new Date((unixSeconds + timezoneOffsetSeconds) * 1000); } -async function fetchAirQualityByCoords(lat, lon) { - const url = `${API_BASE}/air-quality?lat=${lat}&lon=${lon}`; - - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Air quality fetch failed: ${response.status}`); - } - - return response.json(); +// i18n +function applyTheme(theme) { + currentTheme = theme; + const isDark = theme === 'dark'; + document.documentElement.classList.toggle('dark-mode', isDark); + document.body.classList.toggle('dark-mode', isDark); + localStorage.setItem('weatherify-theme', theme); + if (appHeader) appHeader.setAttribute('theme', theme); } function initUnitDisplay() { - const unitElement = document.querySelector('.unit'); - if (unitElement) { - unitElement.textContent = unitLabel(); - } -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initUnitDisplay); -} else { - initUnitDisplay(); + if (appHeader) appHeader.setAttribute('unit', currentUnit); } function toUnit(kelvin) { + const DEGREE = '\u00B0'; if (currentUnit === 'C') return `${Math.round(kelvin - 273.15)}${DEGREE}C`; if (currentUnit === 'F') return `${Math.round((kelvin - 273.15) * 9 / 5 + 32)}${DEGREE}F`; return `${Math.round(kelvin)}K`; @@ -210,94 +51,12 @@ function toUnitNum(kelvin) { } function unitLabel() { + const DEGREE = '\u00B0'; if (currentUnit === 'C') return `${DEGREE}C`; if (currentUnit === 'F') return `${DEGREE}F`; return 'K'; } -async function parseJsonSafe(response) { - try { - return await response.json(); - } catch { - return null; - } -} - -// ============================================================ -// DOM Elements -// ============================================================ -const cityInput = document.getElementById('city-input'); -const clearBtn = document.getElementById('clear-btn'); -const searchBtn = document.getElementById('search-btn'); -const locationBtn = document.getElementById('location-btn'); -const weatherContainer = document.getElementById('weather-container'); -const spinnerLoading = document.getElementById("spinner-loading"); -const errorMessage = document.getElementById('error-message'); - -// Create suggestions dropdown -const suggestionsContainer = document.createElement('div'); -suggestionsContainer.className = 'suggestions-container hidden'; -cityInput.parentNode.insertBefore(suggestionsContainer, cityInput.nextSibling); - -// Weather data elements -const cityName = document.getElementById('city-name'); -const dateElement = document.getElementById('date'); -const tempElement = document.getElementById('temp'); -const weatherDesc = document.getElementById('weather-desc'); -const weatherIcon = document.getElementById('weather-icon'); -const feelsLike = document.getElementById('feels-like'); -const humidity = document.getElementById('humidity'); -const windSpeed = document.getElementById('wind-speed'); -const windCompassIcon = document.getElementById('wind-compass-icon'); -const pressure = document.getElementById('pressure'); -const visibility = document.getElementById('visibility'); -const sunrise = document.getElementById('sunrise'); -const sunset = document.getElementById('sunset'); -const sunPhase = document.getElementById('sun-phase'); -const sunMarker = document.getElementById('sun-marker'); -const sunProgress = document.getElementById('sun-progress'); -const solarNoon = document.getElementById('solar-noon'); -const forecastContainer = document.getElementById('forecast-container'); -const temperatureChartCanvas = document.getElementById('temperature-chart'); -const forecastSummary = document.getElementById('forecast-summary'); -const graphRange = document.getElementById('graph-range'); - -const humidityPrecipChartCanvas = document.getElementById('humidity-precip-chart'); -const humidityPrecipRange = document.getElementById('humidity-precip-range'); -const hourlyMetricControls = document.querySelectorAll('.hourly-toggle'); -let selectedHourlyMetric = 'humidity'; - -const trendsSummary = document.getElementById('trends-summary'); -const trendChart = document.getElementById('trend-chart'); -const trendStats = document.getElementById('trend-stats'); -const trendChartLabel = document.getElementById('trend-chart-label'); -const trendChartRange = document.getElementById('trend-chart-range'); -const trendControls = document.querySelector('.trend-controls'); - -const shareBtn = document.getElementById('share-btn'); -const shareMenu = document.getElementById('share-menu'); -const copyLinkBtn = document.getElementById('copy-link-btn'); -const copySummaryBtn = document.getElementById('copy-summary-btn'); -const shareStatusText = document.getElementById('share-status-text'); - -const historyDropdown = document.getElementById('history-dropdown'); -const favoriteList = document.getElementById('favorite-list'); -const recentList = document.getElementById('recent-list'); -const clearHistoryBtn = document.getElementById('clear-history-btn'); -const favoriteToggleBtn = document.getElementById('favorite-btn'); - -const STORAGE_RECENT = 'weatherify-recent-cities'; -const STORAGE_FAVORITES = 'weatherify-favorite-cities'; -const MAX_RECENT_SEARCHES = 8; - -let sunTimeline = null; -let dailyTrendData = []; -let selectedTrendMetric = 'avg'; -let currentCityQuery = ''; -let currentCityLabel = ''; -let recentSearches = []; -let favoriteCities = []; - function normalizeCityKey(city) { return city.trim().toLowerCase(); } @@ -316,76 +75,24 @@ function saveHistoryArray(key, entries) { localStorage.setItem(key, JSON.stringify(entries)); } -function renderHistory() { - if (!historyDropdown) return; - - const favoriteMarkup = favoriteCities.map((item) => ` - - `).join(''); - - const recentMarkup = recentSearches.map((item) => ` - - `).join(''); - - const hasHistory = favoriteCities.length > 0 || recentSearches.length > 0; - const t = window.translations && window.translations[currentLang] ? window.translations[currentLang] : window.translations['en']; - const noRecentText = t && t['recent'] ? `No ${t['recent'].toLowerCase()} searches yet.` : 'No recent searches yet.'; - - const emptyState = !hasHistory ? ` -
-

${noRecentText}

-
- ` : ''; - - const inner = historyDropdown.querySelector('.history-dropdown-inner'); - if (inner) { - inner.innerHTML = ` -
-
${t ? t['favorites'] : 'Favorites'}
-
${favoriteMarkup}
-
-
-
${t ? t['recent'] : 'Recent'}
-
${recentMarkup}
-
- ${emptyState} -
- -
- `; - } - - const newFavoriteList = document.getElementById('favorite-list'); - const newRecentList = document.getElementById('recent-list'); - const newClearHistoryBtn = document.getElementById('clear-history-btn'); +function initHistory() { + recentSearches = loadHistoryArray(STORAGE_RECENT); + favoriteCities = loadHistoryArray(STORAGE_FAVORITES); - if (newFavoriteList) { - newFavoriteList.addEventListener('click', handleHistoryClick); - } - if (newRecentList) { - newRecentList.addEventListener('click', handleHistoryClick); - } - if (newClearHistoryBtn) { - newClearHistoryBtn.addEventListener('click', (event) => { - event.stopPropagation(); - clearRecentHistory(); - }); + if (appSearch) { + appSearch.favorites = favoriteCities; + appSearch.recent = recentSearches; } } function updateFavoriteButton() { - if (!favoriteToggleBtn) return; - - const active = favoriteCities.some((item) => normalizeCityKey(item.query) === normalizeCityKey(currentCityQuery)); - const icon = favoriteToggleBtn.querySelector('.favorite-icon'); - favoriteToggleBtn.setAttribute('aria-pressed', String(active)); - if (icon) icon.textContent = active ? '★' : '☆'; - favoriteToggleBtn.title = active ? 'Remove favorite' : 'Favorite'; + if (!mainWeather) return; + const isFav = favoriteCities.some((item) => normalizeCityKey(item.query) === normalizeCityKey(currentCityQuery)); + if (isFav) { + mainWeather.setAttribute('is-favorite', ''); + } else { + mainWeather.removeAttribute('is-favorite'); + } } function createCityEntry(data) { @@ -406,100 +113,30 @@ function addRecentSearch(entry) { } saveHistoryArray(STORAGE_RECENT, recentSearches); - renderHistory(); + if (appSearch) appSearch.recent = recentSearches; } function toggleFavoriteCity(entry) { if (!entry || !entry.query) return; const normalized = normalizeCityKey(entry.query); - const existingIndex = favoriteCities.findIndex((item) => normalizeCityKey(item.query) === normalized); + const existsIndex = favoriteCities.findIndex((item) => normalizeCityKey(item.query) === normalized); - if (existingIndex >= 0) { - favoriteCities.splice(existingIndex, 1); + if (existsIndex > -1) { + favoriteCities.splice(existsIndex, 1); } else { - if(favoriteCities.length >= 5) { - alert("You can only save up to 5 favorite cities."); - return; - } - favoriteCities.unshift(entry); + favoriteCities.push(entry); } saveHistoryArray(STORAGE_FAVORITES, favoriteCities); + if (appSearch) appSearch.favorites = favoriteCities; updateFavoriteButton(); - renderHistory(); } function clearRecentHistory() { recentSearches = []; saveHistoryArray(STORAGE_RECENT, recentSearches); - renderHistory(); -} - -function handleHistoryClick(event) { - const button = event.target.closest('button.history-button'); - if (!button) return; - - const query = button.dataset.query; - if (!query) return; - - cityInput.value = query.replace(/,([A-Z]{2})$/, ', $1'); - clearBtn.classList.remove('hidden'); - cityInput.dispatchEvent(new Event('input', { bubbles: true })); - hideSuggestions(); - closeHistoryDropdown(); - fetchWeatherData(query); -} - -function openHistoryDropdown() { - if (!historyDropdown) return; - const hasAny = (favoriteCities && favoriteCities.length) || (recentSearches && recentSearches.length); - if (!hasAny) return; - historyDropdown.classList.remove('hidden'); -} - -function closeHistoryDropdown() { - if (!historyDropdown) return; - historyDropdown.classList.add('hidden'); -} - -function initHistory() { - recentSearches = loadHistoryArray(STORAGE_RECENT); - favoriteCities = loadHistoryArray(STORAGE_FAVORITES); - renderHistory(); - - if (cityInput) { - cityInput.addEventListener('focus', () => { - if (!cityInput.value.trim()) { - openHistoryDropdown(); - } - }); - - cityInput.addEventListener('input', () => { - if (!cityInput.value.trim()) { - if (favoriteCities.length || recentSearches.length) { - openHistoryDropdown(); - } - } else { - closeHistoryDropdown(); - } - }); - } - - if (historyDropdown) { - historyDropdown.addEventListener('click', handleHistoryClick); - } - - if (clearHistoryBtn) { - clearHistoryBtn.classList.toggle('hidden', recentSearches.length === 0); - } - - if (favoriteToggleBtn) { - favoriteToggleBtn.addEventListener('click', () => { - if (!currentCityQuery || !currentCityLabel) return; - toggleFavoriteCity({ query: currentCityQuery, label: currentCityLabel }); - }); - } + if (appSearch) appSearch.recent = recentSearches; } function setCurrentCity(data) { @@ -508,6 +145,9 @@ function setCurrentCity(data) { updateFavoriteButton(); updateUrlParams(currentCityQuery, currentUnit); addRecentSearch({ query: currentCityQuery, label: currentCityLabel }); + + // update search input value + if (appSearch) appSearch.setInputValue(currentCityLabel); } function updateUrlParams(city, units, replaceState = true) { @@ -527,1102 +167,369 @@ function updateUrlParams(city, units, replaceState = true) { window.history.replaceState({}, '', url); } } catch { - // ignore invalid URL updates in older browsers + // ignore } } function setCurrentUnit(unit) { if (!['C', 'F', 'K'].includes(unit)) return; currentUnit = unit; - document.querySelectorAll('.unit-btn').forEach((btn) => { - btn.classList.toggle('active', btn.dataset.unit === unit); - }); - initUnitDisplay(); + if (appHeader) appHeader.setAttribute('unit', unit); updateUrlParams(currentCityQuery || undefined, currentUnit); if (rawData.current) { - updateUI(rawData.current); - updateForecastUI(rawData.forecast); - } -} - -function toggleShareMenu(forceState) { - if (!shareMenu || !shareBtn) return; - const isOpen = shareMenu.classList.contains('hidden'); - const show = typeof forceState === 'boolean' ? forceState : isOpen; - shareMenu.classList.toggle('hidden', !show); - shareBtn.setAttribute('aria-expanded', String(show)); - if (!show) { - showShareStatus(''); - } -} - -function showShareStatus(message) { - if (!shareStatusText) return; - shareStatusText.textContent = message; -} - -async function copyTextToClipboard(text) { - if (!text) return false; - if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { - try { - await navigator.clipboard.writeText(text); - return true; - } catch {} - } - - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'absolute'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - - let success = false; - try { - success = document.execCommand('copy'); - } catch { - success = false; + if (mainWeather) { + mainWeather.setAttribute('unit', unit); + mainWeather.setWeatherData(rawData.current, rawData.airQuality, currentUnit, currentLang, isFavorite(currentCityQuery)); + } + if (dailyForecast) { + dailyForecast.setAttribute('unit', unit); + dailyForecast.forecastData = rawData.forecast; + } } - document.body.removeChild(textarea); - return success; } -function getShareableLink() { - const city = currentCityQuery || cityInput.value.trim(); - if (!city) return window.location.href; - try { - const url = new URL(window.location.href); - url.searchParams.set('city', city); - url.searchParams.set('units', currentUnit); - return url.toString(); - } catch { - return `${window.location.pathname}?city=${encodeURIComponent(city)}&units=${encodeURIComponent(currentUnit)}`; - } +function isFavorite(cityQuery) { + return favoriteCities.some((item) => normalizeCityKey(item.query) === normalizeCityKey(cityQuery)); } -function getShareSummary() { - if (!rawData.current) return ''; - const description = rawData.current.weather?.[0]?.description || ''; - const formattedDescription = description - .split(' ') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - const temperature = `${toUnitNum(rawData.current.main.temp)}${unitLabel()}`; - const humidityValue = rawData.current.main.humidity !== undefined ? `${rawData.current.main.humidity}%` : '--'; - const cityLabel = currentCityLabel || `${rawData.current.name}, ${rawData.current.sys.country}`; - return `${cityLabel}: ${temperature}, Humidity ${humidityValue}, ${formattedDescription}`; -} +// Dynamic backgrounds based on weather type +function updateDynamicBackground(data) { + const body = document.body; + const weatherType = (data.weather?.[0]?.main || '').toLowerCase(); + const isNight = data.weather?.[0]?.icon?.includes('n'); + const themeClasses = [ + 'theme-clear-day', 'theme-clear-night', 'theme-clouds', 'theme-rain', + 'theme-drizzle', 'theme-thunderstorm', 'theme-snow', 'theme-mist', + 'theme-fog', 'theme-haze' + ]; -async function handleCopyLink() { - const link = getShareableLink(); - const copied = await copyTextToClipboard(link); - showShareStatus(copied ? 'Link copied to clipboard!' : 'Unable to copy link.'); - if (copied) setTimeout(() => toggleShareMenu(false), 900); -} + body.classList.remove(...themeClasses); -async function handleCopySummary() { - const summary = getShareSummary(); - if (!summary) { - showShareStatus('Weather unavailable to share.'); - return; - } - const copied = await copyTextToClipboard(summary); - showShareStatus(copied ? 'Summary copied to clipboard!' : 'Unable to copy summary.'); - if (copied) setTimeout(() => toggleShareMenu(false), 900); + if (weatherType === 'clear') body.classList.add(isNight ? 'theme-clear-night' : 'theme-clear-day'); + else if (weatherType === 'clouds') body.classList.add('theme-clouds'); + else if (weatherType === 'rain') body.classList.add('theme-rain'); + else if (weatherType === 'drizzle') body.classList.add('theme-drizzle'); + else if (weatherType === 'thunderstorm') body.classList.add('theme-thunderstorm'); + else if (weatherType === 'snow') body.classList.add('theme-snow'); + else if (['mist', 'fog', 'haze', 'smoke'].includes(weatherType)) body.classList.add('theme-mist'); + else body.classList.add(isNight ? 'theme-clear-night' : 'theme-clear-day'); } -// Hourly metric toggle -hourlyMetricControls.forEach((btn) => { - btn.addEventListener('click', () => { - selectedHourlyMetric = btn.dataset.hourlyMetric; - hourlyMetricControls.forEach((b) => b.classList.toggle('active', b === btn)); - if (rawData.forecast) { - const hoursAhead = 24; - const hourlyPoints = buildHourlyPoints(rawData.forecast.list, rawData.forecast.city?.timezone || 0, hoursAhead); - renderHumidityPrecipChart(hourlyPoints); - } - }); -}); - -// Unit toggle -document.querySelectorAll('.unit-btn').forEach((btn) => { - btn.addEventListener('click', () => { - setCurrentUnit(btn.dataset.unit); - }); -}); - -// Event Listeners -searchBtn.addEventListener('click', handleSearch); -cityInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - hideSuggestions(); - handleSearch(); +function setWeatherBackground(condition) { + const body = document.body; + switch (condition.toLowerCase()) { + case "clear": + body.style.background = "linear-gradient(to right, #f7b267, #f77f00)"; + break; + case "clouds": + body.style.background = "linear-gradient(to right, #bdc3c7, #2c3e50)"; + break; + case "rain": + body.style.background = "linear-gradient(to right, #3a7bd5, #3a6073)"; + break; + case "drizzle": + body.style.background = "linear-gradient(to right, #89f7fe, #66a6ff)"; + break; + case "snow": + body.style.background = "linear-gradient(to right, #e6dada, #274046)"; + break; + case "thunderstorm": + body.style.background = "linear-gradient(to right, #141e30, #243b55)"; + break; + default: + body.style.background = "linear-gradient(to right, #89f7fe, #66a6ff)"; } -}); - -if (locationBtn) { - locationBtn.addEventListener('click', () => { - hideSuggestions(); - requestWeatherFromMyLocation(); - }); } -if (shareBtn) { - shareBtn.addEventListener('click', (event) => { - event.stopPropagation(); - toggleShareMenu(); - }); -} - -if (copyLinkBtn) { - copyLinkBtn.addEventListener('click', handleCopyLink); +function showLoading() { + if (appSearch) appSearch.setAttribute('loading', ''); + if (spinnerLoading) spinnerLoading.classList.remove('hidden'); } -if (copySummaryBtn) { - copySummaryBtn.addEventListener('click', handleCopySummary); +function hideLoading() { + if (appSearch) appSearch.removeAttribute('loading'); + if (spinnerLoading) spinnerLoading.classList.add('hidden'); } -// Autocomplete functionality -let debounceTimer; -cityInput.addEventListener('input', (e) => { - hideError(); - const query = e.target.value.trim(); - searchBtn.disabled = query.length === 0; - clearBtn.classList.toggle('hidden', cityInput.value.length === 0); - - clearTimeout(debounceTimer); - - if (query.length < 2) { - hideSuggestions(); - return; - } - - debounceTimer = setTimeout(() => fetchCitySuggestions(query), 300); -}); - -clearBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - cityInput.value = ''; - cityInput.dispatchEvent(new Event('input', { bubbles: true })); - cityInput.focus(); -}); - -// Hide suggestions, history, and share menu when clicking outside search controls -document.addEventListener('click', (e) => { - if (!e.target.closest('.search-box')) hideSuggestions(); - if (!e.target.closest('.search-container')) closeHistoryDropdown(); - if (!e.target.closest('#share-menu') && !e.target.closest('#share-btn')) toggleShareMenu(false); -}); - -if (trendControls) { - trendControls.addEventListener('click', (event) => { - const button = event.target.closest('[data-trend-metric]'); - if (!button) return; - - selectedTrendMetric = button.dataset.trendMetric; - updateTrendToggleState(); - renderTrendChart(dailyTrendData); - }); +function showWeather() { + if (weatherContainer) weatherContainer.classList.remove('hidden'); + if (appSearch) appSearch.removeAttribute('welcome'); } -async function fetchCitySuggestions(query) { - try { - const url = `${API_BASE}/geo?q=${encodeURIComponent(query)}&limit=5&lang=${currentLang}`; - const response = await fetch(url); - - if (!response.ok) return; - - const cities = await response.json(); - displaySuggestions(cities); - } catch (error) { - console.error('Error fetching suggestions:', error); - } +function hideWeather() { + if (weatherContainer) weatherContainer.classList.add('hidden'); + if (appSearch) appSearch.setAttribute('welcome', ''); } -function displaySuggestions(cities) { - if (cities.length === 0) { - hideSuggestions(); - return; +function showError(message) { + if (appSearch) { + appSearch.setAttribute('error', message || 'City not found. Please check spelling and try again.'); } - - suggestionsContainer.innerHTML = ''; - - cities.forEach((city) => { - const suggestion = document.createElement('div'); - suggestion.className = 'suggestion-item'; - - const localName = city.local_names && city.local_names[currentLang] ? city.local_names[currentLang] : city.name; - suggestion.textContent = `${localName}, ${city.state ? `${city.state}, ` : ''}${city.country}`; - - suggestion.addEventListener('click', () => { - cityInput.value = localName; - clearBtn.classList.remove('hidden'); - hideSuggestions(); - fetchWeatherData(localName); - }); - - suggestionsContainer.appendChild(suggestion); - }); - - suggestionsContainer.classList.remove('hidden'); -} - -function hideSuggestions() { - suggestionsContainer.classList.add('hidden'); } -// Initialize with default city or shared link -window.addEventListener('DOMContentLoaded', () => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js').catch((err) => { - console.error('Service Worker registration failed:', err); - }); - } - initHistory(); - - const params = new URLSearchParams(window.location.search); - const sharedUnits = params.get('units')?.toUpperCase(); - const sharedCity = params.get('city'); - - if (sharedUnits) { - setCurrentUnit(sharedUnits); +function hideError() { + if (appSearch) { + appSearch.removeAttribute('error'); } - - if (sharedCity) { - fetchWeatherData(sharedCity); - } else { - fetchWeatherData(DEFAULT_CITY); - } -}); - -function handleSearch() { - const city = cityInput.value.trim(); - if (city) { - fetchWeatherData(city); - } -} - -function getLocationErrorMessage(error) { - if (!error) return 'Unable to get your location.'; - if (error.code === 1) return 'Location permission denied. Please enable location access in your browser settings.'; - if (error.code === 2) return 'Your location is unavailable right now.'; - if (error.code === 3) return 'Timed out while trying to get your location. Please try again.'; - return 'Unable to get your location.'; } -async function requestWeatherFromMyLocation() { - if (locationBtn) locationBtn.disabled = true; - - if (!navigator.geolocation) { - hideLoading(); - showError('Geolocation is not supported by your browser.'); - if (locationBtn) locationBtn.disabled = false; - return; +async function fetchAirQualityByCoords(lat, lon) { + const url = `${API_BASE}/air-quality?lat=${lat}&lon=${lon}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Air quality fetch failed: ${response.status}`); } - - showLoading(); - hideError(); - - navigator.geolocation.getCurrentPosition( - (position) => { - const { latitude, longitude } = position.coords; - fetchWeatherByCoords(latitude, longitude); - }, - (error) => { - hideLoading(); - showError(getLocationErrorMessage(error)); - - if (!navigator.onLine) { - const cachedData = localStorage.getItem('weatherify-last-data'); - if (cachedData) { - rawData = JSON.parse(cachedData); - updateUI(rawData.current); - updateForecastUI(rawData.forecast); - showWeather(); - showError('You are offline. Showing last known weather data.'); - } - } - - if (locationBtn) locationBtn.disabled = false; - }, - { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } - ); -} - -function detectUserLocation() { - requestWeatherFromMyLocation(); + return response.json(); } async function fetchWeatherByCoords(lat, lon) { showLoading(); hideError(); + hideWeather(); try { - const [currentResponse, forecastResponse] = await Promise.all([ - fetch(`${API_BASE}/weather?lat=${lat}&lon=${lon}&units=standard&lang=${currentLang}`), - fetch(`${API_BASE}/forecast?lat=${lat}&lon=${lon}&units=standard&lang=${currentLang}`) + const currentUrl = `${API_BASE}/weather?lat=${lat}&lon=${lon}&lang=${currentLang}`; + const forecastUrl = `${API_BASE}/forecast?lat=${lat}&lon=${lon}&lang=${currentLang}`; + + const [currentRes, forecastRes] = await Promise.all([ + fetch(currentUrl), + fetch(forecastUrl) ]); - if (!currentResponse.ok || !forecastResponse.ok) { - throw new Error('Unable to fetch location weather'); + if (!currentRes.ok || !forecastRes.ok) { + throw new Error('Coordinate geocoding weather lookup failed'); } - const currentData = await currentResponse.json(); - const forecastData = await forecastResponse.json(); + const currentData = await currentRes.json(); + const forecastData = await forecastRes.json(); - if (!currentData || !currentData.name || !currentData.sys || !forecastData || !forecastData.list) { - throw new Error('Unable to fetch location weather'); + let aqiData = null; + try { + aqiData = await fetchAirQualityByCoords(lat, lon); + } catch (e) { + console.warn('AQI fetch failed:', e); } - rawData.current = currentData; - rawData.forecast = forecastData; - - if (currentData?.coord?.lat !== undefined && currentData?.coord?.lon !== undefined) { - try { - const airQuality = await fetchAirQualityByCoords(currentData.coord.lat, currentData.coord.lon); - rawData.airQuality = airQuality; - renderAqiUI(airQuality); - } catch (error) { - rawData.airQuality = null; - if (aqiCard) aqiCard.classList.add('hidden'); - } - } + rawData = { current: currentData, forecast: forecastData, airQuality: aqiData }; - localStorage.setItem('weatherify-last-data', JSON.stringify(rawData)); - updateUI(currentData); - updateForecastUI(forecastData); - showWeather(); setCurrentCity(currentData); + updateDynamicBackground(currentData); + setWeatherBackground(currentData.weather[0].main); - } catch (error) { - if (!navigator.onLine) { - const cachedData = localStorage.getItem('weatherify-last-data'); - if (cachedData) { - rawData = JSON.parse(cachedData); - updateUI(rawData.current); - updateForecastUI(rawData.forecast); - showWeather(); - showError('You are offline. Showing last known weather data.'); - hideLoading(); - return; - } + if (mainWeather) { + mainWeather.setAttribute('unit', currentUnit); + mainWeather.setWeatherData(currentData, aqiData, currentUnit, currentLang, isFavorite(currentCityQuery)); } - fetchWeatherData(DEFAULT_CITY); + + if (dailyForecast) { + dailyForecast.setAttribute('unit', currentUnit); + dailyForecast.forecastData = forecastData; + } + + if (weatherMapComponent) { + weatherMapComponent.setMapCoords(lat, lon, currentData.name); + } + + showWeather(); + } catch (err) { + console.error(err); + showError(currentLang === 'hi' ? 'मौसम का डेटा प्राप्त करने में विफल।' : 'Failed to retrieve weather data.'); } finally { hideLoading(); - if (locationBtn) locationBtn.disabled = false; } } async function fetchWeatherData(city) { - if (aqiCard) aqiCard.classList.add('hidden'); + if (!city) return; + showLoading(); hideError(); + hideWeather(); try { - const [currentResponse, forecastResponse] = await Promise.all([ - fetch(`${API_BASE}/weather?q=${encodeURIComponent(city)}&units=standard&lang=${currentLang}`), - fetch(`${API_BASE}/forecast?q=${encodeURIComponent(city)}&units=standard&lang=${currentLang}`) + const currentUrl = `${API_BASE}/weather?q=${encodeURIComponent(city)}&lang=${currentLang}`; + const forecastUrl = `${API_BASE}/forecast?q=${encodeURIComponent(city)}&lang=${currentLang}`; + + const [currentRes, forecastRes] = await Promise.all([ + fetch(currentUrl), + fetch(forecastUrl) ]); - if (!currentResponse.ok) { - const errorData = await parseJsonSafe(currentResponse); + if (!currentRes.ok) { + const errorData = await currentRes.json().catch(() => null); throw new Error(errorData?.message || 'City not found'); } - if (!forecastResponse.ok) { - const errorData = await parseJsonSafe(forecastResponse); - throw new Error(errorData?.message || 'Forecast unavailable'); + if (!forecastRes.ok) { + throw new Error('Forecast fetch failed'); } - const currentData = await currentResponse.json(); - const forecastData = await forecastResponse.json(); + const currentData = await currentRes.json(); + const forecastData = await forecastRes.json(); - if (!currentData || !currentData.name || !currentData.sys || !forecastData || !forecastData.list) { - throw new Error('City not found'); + let aqiData = null; + try { + aqiData = await fetchAirQualityByCoords(currentData.coord.lat, currentData.coord.lon); + } catch (e) { + console.warn('AQI fetch failed:', e); } - rawData.current = currentData; - rawData.forecast = forecastData; - - if (currentData?.coord?.lat !== undefined && currentData?.coord?.lon !== undefined) { - try { - const airQuality = await fetchAirQualityByCoords(currentData.coord.lat, currentData.coord.lon); - rawData.airQuality = airQuality; - renderAqiUI(airQuality); - } catch { - rawData.airQuality = null; - if (aqiCard) aqiCard.classList.add('hidden'); - } - } + rawData = { current: currentData, forecast: forecastData, airQuality: aqiData }; - localStorage.setItem('weatherify-last-data', JSON.stringify(rawData)); - updateUI(currentData); - updateForecastUI(forecastData); - showWeather(); setCurrentCity(currentData); - } catch (error) { - console.error('Fetch error:', error); - if (!navigator.onLine) { - const cachedData = localStorage.getItem('weatherify-last-data'); - if (cachedData) { - rawData = JSON.parse(cachedData); - updateUI(rawData.current); - updateForecastUI(rawData.forecast); - showWeather(); - showError('You are offline. Showing last known weather data.'); - hideLoading(); - return; - } + updateDynamicBackground(currentData); + setWeatherBackground(currentData.weather[0].main); + + if (mainWeather) { + mainWeather.setAttribute('unit', currentUnit); + mainWeather.setWeatherData(currentData, aqiData, currentUnit, currentLang, isFavorite(currentCityQuery)); + } + + if (dailyForecast) { + dailyForecast.setAttribute('unit', currentUnit); + dailyForecast.forecastData = forecastData; } - showError('City not found. Please check spelling and try again.'); + + if (weatherMapComponent) { + weatherMapComponent.setMapCoords(currentData.coord.lat, currentData.coord.lon, currentData.name); + } + + showWeather(); + } catch (err) { + console.error(err); + showError(currentLang === 'hi' ? 'शहर नहीं मिला। कृपया पुनः प्रयास करें।' : 'City not found. Please try again.'); } finally { hideLoading(); } } -function setWeatherBackground(condition) { - const body = document.body; - switch (condition.toLowerCase()) { - case "clear": - body.style.background = "linear-gradient(to right, #ff7e5f, #feb47b)"; - break; - case "clouds": - body.style.background = "linear-gradient(to right, #bdc3c7, #2c3e50)"; - break; - case "rain": - body.style.background = "linear-gradient(to right, #4b79a1, #283e51)"; - break; - case "snow": - body.style.background = "linear-gradient(to right, #e6dada, #274046)"; - break; - case "thunderstorm": - body.style.background = "linear-gradient(to right, #141e30, #243b55)"; - break; +function getLocationErrorMessage(error) { + switch (error.code) { + case error.PERMISSION_DENIED: + return currentLang === 'hi' ? 'उपयोगकर्ता ने स्थान का अनुरोध अस्वीकार कर दिया।' : 'User denied the request for Geolocation.'; + case error.POSITION_UNAVAILABLE: + return currentLang === 'hi' ? 'स्थान की जानकारी अनुपलब्ध है।' : 'Location information is unavailable.'; + case error.TIMEOUT: + return currentLang === 'hi' ? 'स्थान प्राप्त करने का समय समाप्त हो गया।' : 'The request to get user location timed out.'; default: - body.style.background = "linear-gradient(to right, #89f7fe, #66a6ff)"; + return currentLang === 'hi' ? 'एक अज्ञात त्रुटि हुई।' : 'An unknown error occurred.'; } } -function updateUI(data) { - cityName.textContent = `${data.name}, ${data.sys.country}`; - const locale = currentLang === 'hi' ? 'hi-IN' : currentLang === 'es' ? 'es-ES' : currentLang === 'fr' ? 'fr-FR' : 'en-US'; - const d = getShiftedDate(Math.floor(Date.now() / 1000), data.timezone); - dateElement.textContent = d.toLocaleDateString(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }); - - tempElement.textContent = toUnitNum(data.main.temp); - weatherDesc.textContent = data.weather[0].description; - - const iconCode = data.weather[0].icon; - weatherIcon.innerHTML = `${data.weather[0].description}`; - - feelsLike.textContent = toUnit(data.main.feels_like); - const feelsLikeMain = document.getElementById('feels-like-main'); - if (feelsLikeMain) { - const t = window.translations && window.translations[currentLang] ? window.translations[currentLang] : window.translations['en']; - feelsLikeMain.textContent = `${t['feelsLike']} ${toUnit(data.main.feels_like)}`; - } - - humidity.textContent = `${data.main.humidity}%`; - const windDir = getWindDirection(data.wind.deg); - windSpeed.textContent = `${Math.round(data.wind.speed * 3.6)} km/h ${windDir}`; - // Dynamic wind direction compass needle rotation logic -if (windCompassIcon && data && data.wind && data.wind.deg !== undefined) { - windCompassIcon.style.transform = `rotate(${data.wind.deg}deg)`; -} - pressure.textContent = `${data.main.pressure} hPa`; - visibility.textContent = `${(data.visibility / 1000).toFixed(1)} km`; - - sunrise.textContent = formatTimeAtOffset(data.sys.sunrise, data.timezone); - sunset.textContent = formatTimeAtOffset(data.sys.sunset, data.timezone); - - updateSunPosition(data); - updateDynamicBackground(data); - setWeatherBackground(data.weather[0].main); - - // Call interactive map - initOrUpdateMap(data.coord.lat, data.coord.lon, data.name); -} - -function updateForecastUI(forecastData) { - if (!forecastContainer) { +async function requestWeatherFromMyLocation() { + if (!navigator.geolocation) { + showError(currentLang === 'hi' ? 'आपके ब्राउज़र द्वारा जियोलोकेशन समर्थित नहीं है।' : 'Geolocation is not supported by your browser.'); return; } - const hourlyData = forecastData.list.slice(0, 8); - dailyTrendData = buildDailyTrendData(forecastData.list, forecastData.city?.timezone || 0); - - forecastContainer.innerHTML = ''; - const locale = currentLang === 'hi' ? 'hi-IN' : currentLang === 'es' ? 'es-ES' : currentLang === 'fr' ? 'fr-FR' : 'en-US'; - - hourlyData.forEach((hour) => { - const date = new Date(hour.dt * 1000); - const timeString = date.toLocaleTimeString(locale, { hour: 'numeric', hour12: true }); - const iconCode = hour.weather[0].icon; - const description = hour.weather[0].description; - - const card = document.createElement('div'); - card.className = 'forecast-card'; - card.innerHTML = ` -
${timeString}
-
- ${description} -
-
${toUnit(hour.main.temp)}
-
${description}
- `; - - forecastContainer.appendChild(card); - }); - - updateForecastSummary(hourlyData); - renderTemperatureChart(hourlyData); - - const hoursAhead = 24; - const hourlyPoints = buildHourlyPoints(forecastData.list, forecastData.city?.timezone || 0, hoursAhead); - renderHumidityPrecipChart(hourlyPoints); - - updateWeatherTrends(dailyTrendData); + navigator.geolocation.getCurrentPosition( + async (position) => { + const { latitude, longitude } = position.coords; + await fetchWeatherByCoords(latitude, longitude); + }, + (error) => { + console.warn('[Geolocation] Error:', error); + showError(getLocationErrorMessage(error)); + }, + { enableHighAccuracy: true, timeout: 8000, maximumAge: 0 } + ); } -function updateForecastSummary(chartData) { - if (!forecastSummary || !graphRange) return; - - if (!chartData.length) { - forecastSummary.textContent = 'Forecast trend is unavailable right now.'; - graphRange.textContent = '--'; - return; - } - - const temperatures = chartData.map((item) => item.main.temp); - const firstTemp = temperatures[0]; - const lastTemp = temperatures[temperatures.length - 1]; - const minTemp = Math.min(...temperatures); - const maxTemp = Math.max(...temperatures); - const delta = lastTemp - firstTemp; - - let trend = 'Temperatures are expected to stay fairly steady'; - if(currentLang === 'hi') trend = delta > 1.5 ? 'तापमान बढ़ने की उम्मीद है' : delta < -1.5 ? 'तापमान कम होने की उम्मीद है' : 'तापमान स्थिर रहने की उम्मीद है'; - else if(currentLang === 'es') trend = delta > 1.5 ? 'Se espera que las temperaturas suban' : delta < -1.5 ? 'Se espera que las temperaturas bajen' : 'Temperaturas estables'; - else if(currentLang === 'fr') trend = delta > 1.5 ? 'Les températures devraient augmenter' : delta < -1.5 ? 'Les températures devraient baisser' : 'Températures stables'; - else trend = delta > 1.5 ? 'Temperatures are expected to rise' : delta < -1.5 ? 'Temperatures are expected to cool down' : 'Temperatures are expected to stay fairly steady'; - - forecastSummary.textContent = `${trend} (${toUnit(minTemp)} - ${toUnit(maxTemp)}).`; - graphRange.textContent = `${toUnit(minTemp)} - ${toUnit(maxTemp)}`; -} +// Wire components together using CustomEvents +function setupComponentListeners() { + // 1. Language change + appHeader.addEventListener('lang-change', (e) => { + currentLang = e.detail.lang; + localStorage.setItem('weatherify-lang', currentLang); + + // Propagate to other components + [appHeader, appSearch, mainWeather, dailyForecast, weatherMapComponent].forEach(el => { + if (el) el.setAttribute('lang', currentLang); + }); -function buildDailyTrendData(forecastList, timezoneOffsetSeconds) { - const groupedDays = new Map(); - const locale = currentLang === 'hi' ? 'hi-IN' : currentLang === 'es' ? 'es-ES' : currentLang === 'fr' ? 'fr-FR' : 'en-US'; - - forecastList.forEach((item) => { - const localDate = getShiftedDate(item.dt, timezoneOffsetSeconds); - const dateKey = localDate.toISOString().slice(0, 10); - - if (!groupedDays.has(dateKey)) { - groupedDays.set(dateKey, { - dateKey, - dayLabel: localDate.toLocaleDateString(locale, { weekday: 'short', timeZone: 'UTC' }), - dateLabel: localDate.toLocaleDateString(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }), - temperatures: [] - }); + if (currentCityQuery) { + fetchWeatherData(currentCityQuery); + } else { + fetchWeatherData(DEFAULT_CITY); } - - groupedDays.get(dateKey).temperatures.push(item.main.temp); }); - return Array.from(groupedDays.values()).slice(0, 5).map((day) => { - const high = Math.max(...day.temperatures); - const low = Math.min(...day.temperatures); - const avg = day.temperatures.reduce((total, temp) => total + temp, 0) / day.temperatures.length; - - return { ...day, high, low, avg }; + // 2. Theme change + appHeader.addEventListener('theme-change', (e) => { + applyTheme(e.detail.theme); }); -} -function updateWeatherTrends(trendData) { - if (!trendsSummary || !trendStats || !trendChart) return; - - if (!trendData.length) { - trendsSummary.textContent = 'Daily trend data is unavailable right now.'; - trendStats.innerHTML = ''; - trendChart.innerHTML = ''; - trendChartRange.textContent = '--'; - return; - } - - const firstAverage = trendData[0].avg; - const lastAverage = trendData[trendData.length - 1].avg; - const averageDelta = toUnitNum(lastAverage) - toUnitNum(firstAverage); - - let trendText = 'Weather remaining stable over the next few days.'; - if(currentLang==='hi') trendText = averageDelta > 1.5 ? 'आने वाले दिनों में तापमान बढ़ेगा।' : averageDelta < -1.5 ? 'आने वाले दिनों में ठंडक की उम्मीद है।' : 'मौसम स्थिर रहेगा।'; - else if(currentLang==='es') trendText = averageDelta > 1.5 ? 'Temperaturas subiendo en los próximos días.' : averageDelta < -1.5 ? 'Tendencia al enfriamiento.' : 'Clima estable.'; - else if(currentLang==='fr') trendText = averageDelta > 1.5 ? 'Températures en hausse dans les jours à venir.' : averageDelta < -1.5 ? 'Tendance au refroidissement.' : 'Météo stable.'; - else trendText = averageDelta > 1.5 ? 'Temperatures rising over the next few days.' : averageDelta < -1.5 ? 'Cooling trend expected over the next few days.' : 'Weather remaining stable over the next few days.'; - - trendsSummary.textContent = trendText; - renderTrendStats(trendData); - renderTrendChart(trendData); -} - -function renderTrendStats(trendData) { - if (!trendStats) return; - const t = window.translations && window.translations[currentLang] ? window.translations[currentLang] : window.translations['en']; - - trendStats.innerHTML = trendData.map((day) => ` -
-
- ${day.dayLabel} - ${day.dateLabel} -
-
- ${toUnitNum(day.high)}${unitLabel()} ${t['high'] ? t['high'].toLowerCase() : 'high'} - ${toUnitNum(day.low)}${unitLabel()} ${t['low'] ? t['low'].toLowerCase() : 'low'} - ${toUnitNum(day.avg)}${unitLabel()} ${t['avg'] ? t['avg'].toLowerCase() : 'avg'} -
-
- `).join(''); -} - -function updateTrendToggleState() { - if (!trendControls) return; - - trendControls.querySelectorAll('[data-trend-metric]').forEach((button) => { - button.classList.toggle('active', button.dataset.trendMetric === selectedTrendMetric); + // 3. Unit change + appHeader.addEventListener('unit-change', (e) => { + setCurrentUnit(e.detail.unit); }); -} - -function renderTrendChart(trendData) { - if (!trendChart) return; - - if (!trendData.length) { - trendChart.innerHTML = ''; - return; - } - - const t = window.translations && window.translations[currentLang] ? window.translations[currentLang] : window.translations['en']; - const metricLabels = { - high: t['high'] || 'Daily High Temperature', - low: t['low'] || 'Daily Low Temperature', - avg: t['avgTemp'] || 'Daily Average Temperature' - }; - const metricColors = { - high: '#f97316', - low: '#0ea5e9', - avg: '#667eea' - }; - const metric = selectedTrendMetric in metricLabels ? selectedTrendMetric : 'avg'; - const width = 760; - const height = 280; - const padding = { top: 46, right: 42, bottom: 48, left: 54 }; - const innerWidth = width - padding.left - padding.right; - const innerHeight = height - padding.top - padding.bottom; - const values = trendData.map((day) => toUnitNum(day[metric])); - const lowValues = trendData.map((day) => toUnitNum(day.low)); - const highValues = trendData.map((day) => toUnitNum(day.high)); - const minValue = Math.floor(Math.min(...lowValues) - 1); - const maxValue = Math.ceil(Math.max(...highValues) + 1); - const range = Math.max(maxValue - minValue, 1); - const barWidth = Math.min(58, innerWidth / trendData.length * 0.45); - - const getY = (value) => padding.top + ((maxValue - value) / range) * innerHeight; - const points = trendData.map((day, index) => { - const x = padding.left + (index * innerWidth) / Math.max(trendData.length - 1, 1); - const val = toUnitNum(day[metric]); - return { ...day, x, y: getY(val), value: val }; - }); - - const baselineY = height - padding.bottom; - const bars = points.map((point) => { - const barHeight = Math.max(baselineY - point.y, 3); - return ` - - - - - - - `; - }).join(''); - const linePoints = points.map((point) => `${point.x},${point.y}`).join(' '); - const gridLines = [0, 0.5, 1].map((step) => { - const y = padding.top + innerHeight * step; - const value = Math.round(maxValue - range * step); - return ` - - ${value}${unitLabel()} - `; - }).join(''); - const labels = points.map((point) => ` - - - ${Math.round(point.value)}${unitLabel()} - ${point.dayLabel} - - `).join(''); - - trendChart.style.setProperty('--trend-color', metricColors[metric]); - trendChart.innerHTML = ` - ${gridLines} - ${bars} - - ${labels} - `; - - if (trendChartLabel) trendChartLabel.textContent = metricLabels[metric]; - if (trendChartRange) trendChartRange.textContent = `${Math.round(Math.min(...values))}${unitLabel()} - ${Math.round(Math.max(...values))}${unitLabel()}`; -} - -function updateSunPosition(data) { - if (!sunMarker || !sunPhase || !sunProgress || !solarNoon) return; - - sunTimeline = { - timezone: data.timezone, - sunrise: data.sys.sunrise, - sunset: data.sys.sunset - }; - - renderSunPosition(); -} - -function updateDynamicBackground(data) { - const body = document.body; - const weatherType = (data.weather?.[0]?.main || '').toLowerCase(); - const isNight = data.weather?.[0]?.icon?.includes('n'); - const themeClasses = [ - 'theme-clear-day', 'theme-clear-night', 'theme-clouds', 'theme-rain', - 'theme-drizzle', 'theme-thunderstorm', 'theme-snow', 'theme-mist', - 'theme-fog', 'theme-haze' - ]; - body.classList.remove(...themeClasses); - - if (weatherType === 'clear') body.classList.add(isNight ? 'theme-clear-night' : 'theme-clear-day'); - else if (weatherType === 'clouds') body.classList.add('theme-clouds'); - else if (weatherType === 'rain') body.classList.add('theme-rain'); - else if (weatherType === 'drizzle') body.classList.add('theme-drizzle'); - else if (weatherType === 'thunderstorm') body.classList.add('theme-thunderstorm'); - else if (weatherType === 'snow') body.classList.add('theme-snow'); - else if (['mist', 'fog', 'haze', 'smoke'].includes(weatherType)) body.classList.add('theme-mist'); - else body.classList.add(isNight ? 'theme-clear-night' : 'theme-clear-day'); -} - -function renderSunPosition() { - if (!sunTimeline || !sunMarker || !sunPhase || !sunProgress || !solarNoon) return; - const t = window.translations && window.translations[currentLang] ? window.translations[currentLang] : window.translations['en']; - - const nowSeconds = Math.floor(Date.now() / 1000); - const { timezone, sunrise, sunset } = sunTimeline; - const daylight = Math.max(sunset - sunrise, 1); - const midpoint = sunrise + Math.floor(daylight / 2); - let progress = 0; - let phaseText = ''; - let progressText = ''; - - if (nowSeconds <= sunrise) { - phaseText = currentLang==='hi'?'सूर्योदय से पहले':'Before sunrise'; - progressText = currentLang==='hi'?`सूर्योदय में ${formatDuration(sunrise - nowSeconds)}`:`${formatDuration(sunrise - nowSeconds)} until sunrise`; - progress = 0; - } else if (nowSeconds >= sunset) { - phaseText = currentLang==='hi'?'सूर्यास्त के बाद':'After sunset'; - progressText = currentLang==='hi'?`सूर्योदय में ${formatDuration(getNextSunriseSeconds(sunrise, nowSeconds) - nowSeconds)}`:`${formatDuration(getNextSunriseSeconds(sunrise, nowSeconds) - nowSeconds)} until sunrise`; - progress = 100; - } else { - progress = ((nowSeconds - sunrise) / daylight) * 100; - phaseText = t['daylight'] || 'Daylight'; - progressText = currentLang==='hi'?`दिन का ${Math.round(progress)}% हिस्सा पूरा हुआ`:`${Math.round(progress)}% of daylight completed`; - } - - sunMarker.style.left = `${Math.min(Math.max(progress, 0), 100)}%`; - sunPhase.textContent = phaseText; - sunProgress.textContent = progressText; - - const midpointLabel = currentLang === 'hi' ? 'सौर मध्यबिंदु' : currentLang === 'es' ? 'Mediodía solar' : currentLang === 'fr' ? 'Midi solaire' : 'Solar midpoint'; - solarNoon.textContent = `${midpointLabel} ${formatTimeAtOffset(midpoint, timezone)}`; -} - -function getShiftedDate(unixSeconds, timezoneOffsetSeconds) { - return new Date((unixSeconds + timezoneOffsetSeconds) * 1000); -} - -function formatDateAtOffset(unixSeconds, timezoneOffsetSeconds) { - const locale = currentLang === 'hi' ? 'hi-IN' : currentLang === 'es' ? 'es-ES' : currentLang === 'fr' ? 'fr-FR' : 'en-US'; - return getShiftedDate(unixSeconds, timezoneOffsetSeconds).toLocaleDateString(locale, { - weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' + // 4. City selection from search suggestion or history + appSearch.addEventListener('city-select', (e) => { + fetchWeatherData(e.detail.city); }); -} -function formatTimeAtOffset(unixSeconds, timezoneOffsetSeconds) { - const locale = currentLang === 'hi' ? 'hi-IN' : currentLang === 'es' ? 'es-ES' : currentLang === 'fr' ? 'fr-FR' : 'en-US'; - return getShiftedDate(unixSeconds, timezoneOffsetSeconds).toLocaleTimeString(locale, { - hour: '2-digit', minute: '2-digit', timeZone: 'UTC' + // 5. Use My Location button clicked + appSearch.addEventListener('location-request', () => { + requestWeatherFromMyLocation(); }); -} -function getNextSunriseSeconds(todaySunrise, nowSeconds) { - const daySeconds = 24 * 60 * 60; - const daysAhead = Math.floor((nowSeconds - todaySunrise) / daySeconds) + 1; - return todaySunrise + daysAhead * daySeconds; -} - -function formatDuration(seconds) { - const totalMinutes = Math.max(0, Math.round(seconds / 60)); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - const hStr = currentLang==='hi'?'घं':'h'; - const mStr = currentLang==='hi'?'मि':'m'; - - if (hours === 0) return `${minutes}${mStr}`; - if (minutes === 0) return `${hours}${hStr}`; - return `${hours}${hStr} ${minutes}${mStr}`; -} - -function renderTemperatureChart(hourlyData) { - if (!temperatureChartCanvas) return; - - if (!hourlyData.length) { - if (window.temperatureChart) window.temperatureChart.destroy(); - return; - } - - const locale = currentLang === 'hi' ? 'hi-IN' : currentLang === 'es' ? 'es-ES' : currentLang === 'fr' ? 'fr-FR' : 'en-US'; - const labels = hourlyData.map(item => { - const date = new Date(item.dt * 1000); - return date.toLocaleTimeString(locale, { hour: 'numeric', hour12: true }); + // 6. Clear history clicked + appSearch.addEventListener('clear-history', () => { + clearRecentHistory(); }); - const temperatures = hourlyData.map(item => toUnitNum(item.main.temp)); - - const data = { - labels: labels, - datasets: [{ - label: `Temperature (${unitLabel()})`, - data: temperatures, - borderColor: 'rgba(75, 192, 192, 1)', - backgroundColor: 'rgba(75, 192, 192, 0.2)', - fill: true, - tension: 0.4 - }] - }; - - const config = { - type: 'line', - data: data, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { - y: { - beginAtZero: false, - ticks: { callback: function(value) { return value + unitLabel(); } } - } - } - } - }; - - if (window.temperatureChart) window.temperatureChart.destroy(); - window.temperatureChart = new Chart(temperatureChartCanvas, config); -} - -function buildHourlyPoints(forecastList, timezoneOffsetSeconds, hoursAhead) { - if (!Array.isArray(forecastList) || forecastList.length === 0) return []; - - const nowSeconds = Math.floor(Date.now() / 1000); - const endSeconds = nowSeconds + hoursAhead * 60 * 60; - const locale = currentLang === 'hi' ? 'hi-IN' : currentLang === 'es' ? 'es-ES' : currentLang === 'fr' ? 'fr-FR' : 'en-US'; - - const points = forecastList - .slice() - .sort((a, b) => a.dt - b.dt) - .filter((item) => item?.dt >= nowSeconds - 3600 && item?.dt <= endSeconds); - - return points.slice(0, 10).map((item) => { - const localDate = getShiftedDate(item.dt, timezoneOffsetSeconds); - return { - raw: item, - dt: item.dt, - timeLabel: localDate.toLocaleTimeString(locale, { hour: 'numeric' }), - humidity: item.main?.humidity ?? null, - precipProb: item.pop ?? null, - precipAmount: item.rain?.['3h'] ?? null - }; + // 7. Favorite toggle inside weather detail card + mainWeather.addEventListener('toggle-favorite', (e) => { + toggleFavoriteCity({ query: e.detail.query, label: e.detail.label }); }); } -function renderHumidityPrecipChart(hourlyPoints) { - if (!humidityPrecipChartCanvas || !humidityPrecipRange) return; - - if (!hourlyPoints || hourlyPoints.length === 0) { - if (window.humidityPrecipChart) window.humidityPrecipChart.destroy(); - humidityPrecipRange.textContent = '--'; - return; +// Initial startup +window.addEventListener('DOMContentLoaded', () => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch((err) => { + console.error('Service Worker registration failed:', err); + }); } - const t = window.translations && window.translations[currentLang] ? window.translations[currentLang] : window.translations['en']; - const metric = selectedHourlyMetric; - const labels = hourlyPoints.map(p => p.timeLabel); - const values = hourlyPoints.map(p => { - if (metric === 'humidity') return p.humidity || 0; - return (p.precipProb || 0) * 100; - }); - - const data = { - labels: labels, - datasets: [{ - label: metric === 'humidity' ? `${t ? t['humidity'] : 'Humidity'} (%)` : `${t ? t['precip'] : 'Precipitation'} (%)`, - data: values, - borderColor: metric === 'humidity' ? 'rgba(54, 162, 235, 1)' : 'rgba(255, 99, 132, 1)', - backgroundColor: metric === 'humidity' ? 'rgba(54, 162, 235, 0.2)' : 'rgba(255, 99, 132, 0.2)', - fill: true, - tension: 0.4 - }] - }; - - const config = { - type: 'line', - data: data, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { - y: { - beginAtZero: true, - max: 100, - ticks: { callback: function(value) { return value + '%'; } } - } - } - } - }; - - if (window.humidityPrecipChart) window.humidityPrecipChart.destroy(); - window.humidityPrecipChart = new Chart(humidityPrecipChartCanvas, config); - - const minV = Math.min(...values); - const maxV = Math.max(...values); - humidityPrecipRange.textContent = `${minV}% - ${maxV}%`; -} - -function getWindDirection(deg) { - if (deg === undefined || deg === null) return ''; - const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - const index = Math.round(deg / 45) % 8; - const arrows = ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖']; - return `${directions[index]} ${arrows[index]}`; -} - -function showLoading() { spinnerLoading.classList.remove("hidden"); } -function hideLoading() { spinnerLoading.classList.add("hidden"); } -function showWeather() { weatherContainer.classList.remove('hidden'); } -function hideWeather() { weatherContainer.classList.add('hidden'); } -function showError(message) { - if (message) errorMessage.querySelector('p').textContent = message; - errorMessage.classList.remove('hidden'); -} -function hideError() { errorMessage.classList.add('hidden'); } - -setInterval(renderSunPosition, 60000); - -// ============================================================ -// ✅ Dark Mode Toggle — Issue #14 -// ============================================================ -(function initDarkMode() { - const STORAGE_KEY = 'weatherify-theme'; - const DARK_CLASS = 'dark-mode'; + // Resolve component elements + appHeader = document.getElementById('app-header'); + appSearch = document.getElementById('app-search'); + mainWeather = document.getElementById('main-weather'); + dailyForecast = document.getElementById('daily-forecast'); + weatherMapComponent = document.getElementById('weather-map-component'); + weatherContainer = document.getElementById('weather-container'); + spinnerLoading = document.getElementById('spinner-loading'); - const toggleBtn = document.getElementById('theme-toggle'); - const icon = toggleBtn ? toggleBtn.querySelector('.toggle-icon') : null; - const label = toggleBtn ? toggleBtn.querySelector('.toggle-label') : null; + // Initialize themes, history, units + applyTheme(currentTheme); + initHistory(); + initUnitDisplay(); - function applyTheme(isDark) { - document.documentElement.classList.toggle(DARK_CLASS, isDark); - if (document.body) document.body.classList.toggle(DARK_CLASS, isDark); - if (icon) icon.textContent = isDark ? '☀️' : '🌙'; - - const t = window.translations && window.translations[currentLang] ? window.translations[currentLang] : window.translations['en']; - if (label) label.textContent = isDark ? (t ? t['themeLight'] : 'Light') : (t ? t['themeDark'] : 'Dark'); - - if (toggleBtn) { - toggleBtn.setAttribute('aria-pressed', String(isDark)); - toggleBtn.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme'); - toggleBtn.title = isDark ? 'Switch to light theme' : 'Switch to dark theme'; - } - } + // Propagate initial states to components + [appHeader, appSearch, mainWeather, dailyForecast, weatherMapComponent].forEach(el => { + if (el) el.setAttribute('lang', currentLang); + }); + if (appHeader) appHeader.setAttribute('theme', currentTheme); + if (appHeader) appHeader.setAttribute('unit', currentUnit); + if (mainWeather) mainWeather.setAttribute('unit', currentUnit); + if (dailyForecast) dailyForecast.setAttribute('unit', currentUnit); - function getInitialPreference() { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved !== null) return saved === 'dark'; - return window.matchMedia('(prefers-color-scheme: dark)').matches; - } + // Setup component observers and listeners + setupComponentListeners(); - applyTheme(getInitialPreference()); + // Load initial weather + const params = new URLSearchParams(window.location.search); + const sharedUnits = params.get('units')?.toUpperCase(); + const sharedCity = params.get('city'); - if (toggleBtn) { - toggleBtn.addEventListener('click', () => { - const isDark = !document.body.classList.contains(DARK_CLASS); - applyTheme(isDark); - localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light'); - }); + if (sharedUnits) { + setCurrentUnit(sharedUnits); } - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { - if (localStorage.getItem(STORAGE_KEY) === null) { - applyTheme(e.matches); - } - }); -})(); - -// ============================================================ -// 🗺️ INTERACTIVE WEATHER MAP INTEGRATION (Leaflet) -// ============================================================ -let mapInstance = null; -let mapMarker = null; -let activeWeatherLayer = null; - -function initOrUpdateMap(lat, lon, cityName) { - const mapElement = document.getElementById('weather-map'); - if (!mapElement) return; - - if (!mapInstance) { - mapInstance = L.map('weather-map').setView([lat, lon], 10); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors' - }).addTo(mapInstance); - mapMarker = L.marker([lat, lon]).addTo(mapInstance); - setupMapLayerControls(); + if (sharedCity) { + fetchWeatherData(sharedCity); } else { - mapInstance.setView([lat, lon], 10); - mapMarker.setLatLng([lat, lon]); + fetchWeatherData(DEFAULT_CITY); } - mapMarker.bindPopup(`${cityName}`).openPopup(); -} - -function setupMapLayerControls() { - const mapControls = document.querySelectorAll('.map-layer-controls .trend-toggle'); - if (!mapControls.length) return; - - mapControls.forEach(btn => { - btn.addEventListener('click', (e) => { - mapControls.forEach(b => b.classList.remove('active')); - e.target.classList.add('active'); - - const layerType = e.target.dataset.layer; - - if (activeWeatherLayer) { - mapInstance.removeLayer(activeWeatherLayer); - activeWeatherLayer = null; - } - - let layerUrl = ''; - if (layerType === 'precipitation') layerUrl = 'https://tile.openweathermap.org/map/precipitation_new/{z}/{x}/{y}.png?appid=MOCK_KEY'; - else if (layerType === 'clouds') layerUrl = 'https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=MOCK_KEY'; - - if (layerUrl && layerType !== 'base') { - /* activeWeatherLayer = L.tileLayer(layerUrl, { opacity: 0.6 }).addTo(mapInstance); */ - } - }); - }); -} +}); diff --git a/public/style.css b/public/style.css index 4cb20eb..c8afb1f 100644 --- a/public/style.css +++ b/public/style.css @@ -1,5 +1,9 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); +:root { + --card-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); +} + * { box-sizing: border-box; margin: 0; @@ -67,6 +71,10 @@ body.theme-thunderstorm { --bg-start: #1f2745; --bg-mid: #40527d; --bg-end: #7f8 body.theme-snow { --bg-start: #bbd3e8; --bg-mid: #eef7ff; --bg-end: #d6e5f2; } body.theme-mist, body.theme-fog, body.theme-haze { --bg-start: #9aa7b5; --bg-mid: #dde5eb; --bg-end: #c8d0d8; } +body.dark-mode { + color: #e2e8f0; +} + .container { width: 100%; max-width: 90rem; @@ -92,262 +100,15 @@ body.theme-mist, body.theme-fog, body.theme-haze { --bg-start: #9aa7b5; --bg-mid isolation: isolate; } -.weather-app, .container, #weather-container { - overflow-x: hidden; -} - -/* ===== HEADER ===== */ -header { - text-align: left; - margin-bottom: 28px; - animation: fadeIn 0.8s ease-out; - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - grid-template-areas: - "title theme units" - "search search search"; - column-gap: 14px; - row-gap: 18px; - align-items: center; -} - -header h1 { grid-area: title; } -.search-container { grid-area: search; display: flex; flex-direction: column; width: 100%; position: relative; } -.theme-toggle { grid-area: theme; } -.unit-toggle { grid-area: units; } - -h1 { - font-size: 36px; - font-weight: 700; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin-bottom: 0; - letter-spacing: -0.5px; - line-height: 1.1; -} - -.search-box { - display: flex; - gap: 12px; - width: 100%; - position: relative; - flex-shrink: 0; -} - -.search-input-wrap { - flex: 1; - position: relative; - min-width: 0; -} - -#city-input { - width: 100%; - box-sizing: border-box; - padding: 14px 40px 14px 20px; - border: 2px solid #e2e8f0; - border-radius: 14px; - font-size: 16px; - font-family: inherit; - outline: none; - transition: all 0.3s ease; - background: rgba(255, 255, 255, 0.8); -} - -#city-input:focus { - border-color: #667eea; - box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.15); - background: white; - transform: translateY(-2px); -} - -#city-input::placeholder { color: #cbd5e0; } - -.clear-btn { - position: absolute; - top: 50%; - right: 10px; - transform: translateY(-50%); - width: 32px; - height: 32px; - padding: 0; - border: none; - border-radius: 10px; - background: transparent; - color: #64748b; - font-size: 22px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: color 0.2s ease, background 0.2s ease; -} - -.clear-btn:hover { color: #475569; background: rgba(100, 116, 139, 0.12); } -.clear-btn:focus-visible { outline: 2px solid #667eea; outline-offset: 2px; } - -#search-btn { - width: 3.5rem; - min-width: 3.5rem; - height: 3.5rem; - flex-shrink: 0; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border: none; - border-radius: 0.875rem; - cursor: pointer; - color: white; - transition: transform 0.3s ease, box-shadow 0.3s ease, opacity 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - font-weight: 600; - box-shadow: 0 0.25rem 0.9375rem rgba(102, 126, 234, 0.3); -} - -#search-btn:hover { transform: translateY(-3px); box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5); } -#search-btn:active { transform: translateY(-1px); } - -.suggestions-container { - position: absolute; - top: calc(100% + 12px); - left: 0; - right: 0; - background: white; - border-radius: 14px; - box-shadow: 0 15px 50px rgba(0, 0, 0, 0.2); - max-height: 300px; - overflow-y: auto; - z-index: 1000; - animation: slideDown 0.3s ease-out; - transition: background 0.4s ease; -} - -.history-dropdown { - position: static; - margin-top: 18px; - background: #eef2ff; - border: 1px solid #c7d2fe; - border-radius: 22px; - box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12); - padding: 20px; - width: 100%; - overflow: hidden; -} - -.history-dropdown.hidden { display: none; } - -.history-panel-header { - margin-bottom: 16px; - padding-bottom: 8px; - border-bottom: 1px solid rgba(99, 102, 241, 0.15); -} - -.history-panel-header h2 { margin: 0; font-size: 1.2rem; font-weight: 700; color: #1e293b; } -.history-panel-header p { margin: 8px 0 0; color: #475569; font-size: 0.95rem; line-height: 1.45; } - -.history-empty { - padding: 18px; - border-radius: 16px; - background: #f8fafc; - color: #475569; - border: 1px dashed #cbd5e0; - text-align: center; -} - -.history-empty p { font-weight: 600; margin-bottom: 6px; } -.history-empty small { color: #64748b; } -.history-actions { display: flex; justify-content: flex-end; padding-top: 8px; } - -.history-toggle-btn { - border: none; - background: #eef2ff; - color: #4f46e5; - border-radius: 14px; - padding: 0 14px; - min-width: 48px; - height: 48px; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - transition: background 0.2s ease, transform 0.2s ease; -} - -.history-toggle-btn:hover { background: #e0e7ff; transform: translateY(-1px); } -.history-dropdown-inner { display: grid; gap: 14px; } -.history-section { display: grid; gap: 10px; } -.history-section-title { font-size: 0.9rem; font-weight: 700; color: #334155; } - -.history-list { - display: grid; - gap: 8px; - max-height: 160px; - overflow-y: auto; - padding-right: 4px; -} - -.history-button { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 12px 14px; - border-radius: 14px; - border: 1px solid #e2e8f0; - background: #f8fafc; - color: #0f172a; - font-size: 0.95rem; - text-align: left; - cursor: pointer; - transition: background 0.2s ease, transform 0.2s ease; -} - -.history-button:hover { transform: translateX(2px); background: #e2e8f0; } -.history-badge { font-size: 0.9rem; color: #ca8a04; } - -.clear-history-btn { - width: fit-content; - border: 1px solid #cbd5e0; - background: #ffffff; - color: #475569; - border-radius: 12px; - padding: 10px 16px; - cursor: pointer; - transition: background 0.2s ease, color 0.2s ease; -} - -.clear-history-btn:hover { background: #f8fafc; } - -.favorite-btn { - border: none; - background: transparent; - color: #f59e0b; - font-size: 1.6rem; - line-height: 1; - cursor: pointer; - transition: transform 0.2s ease; +body.dark-mode .weather-app { + background: rgba(15, 23, 42, 0.92); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.06); } -.favorite-btn:hover { transform: scale(1.1); } - -.suggestion-item { - padding: 14px 20px; - cursor: pointer; - border-bottom: 1px solid #f0f0f0; - transition: all 0.2s ease; - font-size: 15px; - color: #2d3748; +.weather-app, .container, #weather-container { + overflow-x: hidden; } -.suggestion-item:last-child { border-bottom: none; } -.suggestion-item:hover { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding-left: 24px; } -.suggestion-item:first-child { border-radius: 14px 14px 0 0; } -.suggestion-item:last-child { border-radius: 0 0 14px 14px; } - -/* ===== MAIN WEATHER SECTION ===== */ #weather-container { display: flex; flex-direction: column; @@ -355,436 +116,10 @@ h1 { animation: fadeIn 0.8s ease-out 0.2s both; } -/* ===== WEATHER MAIN: two columns — temp left, sun right ===== */ -.weather-main { - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: auto 1fr; - gap: 1.25rem; - width: 100%; - min-width: 0; - align-items: stretch; -} - -/* City/date info spans full width on top */ -.location-info { - grid-column: 1 / -1; - grid-row: 1; - animation: slideInLeft 0.6s ease-out; - width: 100%; - text-align: left; -} - -/* Temp card in left column */ -.temperature-section { - grid-column: 1; - grid-row: 2; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - padding: 28px 26px; - background: linear-gradient(145deg, #f0f4ff 0%, #e8eeff 40%, #f5f0ff 100%); - border-radius: 24px; - animation: slideInLeft 0.6s ease-out 0.2s both; - border: 1px solid rgba(102, 126, 234, 0.15); - position: relative; - transition: background 0.4s ease, border-color 0.4s ease; - min-height: 220px; -} - -/* Sun card in right column, same height as temp */ -.sun-position-card { - grid-column: 2; - grid-row: 2; - background: linear-gradient(135deg, #fff7dd 0%, #fff2c5 100%); - border: 2px solid rgba(217, 119, 6, 0.2); - border-radius: 20px; - padding: 20px 24px; - transition: all 0.3s ease; - box-shadow: 0 10px 30px rgba(217, 119, 6, 0.1); - animation: slideInLeft 0.6s ease-out 0.1s both; - display: flex; - flex-direction: column; - justify-content: center; -} - -.sun-position-card:hover { - transform: translateY(-3px); - box-shadow: 0 15px 40px rgba(217, 119, 6, 0.15); -} - -#city-name { - font-size: 26px; - font-weight: 700; - color: #2d3748; - margin-bottom: 3px; - letter-spacing: -0.5px; - transition: color 0.4s ease; -} - -#date { - font-size: 13px; - color: #718096; - font-weight: 500; - transition: color 0.4s ease; -} - -.weather-icon { - width: 100px; - height: 100px; - animation: bounce 2s ease-in-out infinite; -} - -.weather-icon img { - width: 100%; - height: 100%; - object-fit: contain; - filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.1)); -} - -.temperature { - display: flex; - align-items: flex-start; - gap: 5px; -} - -#temp { - font-size: 64px; - font-weight: 300; - line-height: 1; - color: #2d3748; - letter-spacing: -2px; - transition: color 0.4s ease; -} - -.unit { - font-size: 24px; - font-weight: 400; - color: #718096; - margin-top: 8px; - transition: color 0.4s ease; -} - -#weather-desc { - font-size: 16px; - color: #718096; - text-transform: capitalize; - font-weight: 500; - transition: color 0.4s ease; -} - -/* ===== SUN POSITION INTERNALS ===== */ -.sun-position-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.sun-position-header h3 { - font-size: 14px; - font-weight: 700; - color: #7c4a03; - text-transform: uppercase; - letter-spacing: 1px; -} - -#sun-phase { - font-size: 12px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: #b45309; - background: rgba(180, 83, 9, 0.1); - padding: 4px 10px; - border-radius: 6px; -} - -.sun-track { - position: relative; - height: 50px; - margin-bottom: 15px; -} - -.sun-track-line { - position: absolute; - left: 0; - right: 0; - top: 50%; - transform: translateY(-50%); - height: 6px; - border-radius: 999px; - background: linear-gradient(90deg, #f59e0b 0%, #facc15 50%, #f97316 100%); - opacity: 0.4; -} - -.sun-track-marker { - position: absolute; - left: 0; - top: 50%; - transform: translate(-50%, -50%); - width: 40px; - height: 40px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - background: radial-gradient(circle at 30% 30%, #fff9c4 0%, #fbbf24 55%, #f59e0b 100%); - box-shadow: 0 8px 20px rgba(245, 158, 11, 0.4), inset -2px -2px 5px rgba(0, 0, 0, 0.1); - animation: float 3s ease-in-out infinite; -} - -.sun-icon { font-size: 20px; line-height: 1; } - -.sun-position-meta { - display: flex; - justify-content: space-between; - gap: 15px; - font-size: 12px; - color: #8a5a13; - font-weight: 500; -} - -/* ===== WEATHER DETAILS GRID ===== */ -.weather-details { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); - gap: 1rem; - animation: slideInLeft 0.6s ease-out 0.3s both; +#weather-container.hidden { + display: none !important; } -.detail-card { - background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%); - border-radius: 16px; - padding: 18px 16px; - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; - transition: all 0.3s ease; - border: 1px solid rgba(102, 126, 234, 0.12); - cursor: pointer; - position: relative; - overflow: hidden; -} - -.detail-card::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(135deg, transparent 0%, rgba(102, 126, 234, 0.1) 100%); - opacity: 0; - transition: opacity 0.3s ease; -} - -.detail-card:hover { transform: translateY(-5px); box-shadow: 0 12px 35px rgba(0, 0, 0, 0.1); border-color: #667eea; } -.detail-card:hover::before { opacity: 1; } - -.detail-icon { color: #667eea; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; } -.detail-info { display: flex; flex-direction: column; align-items: center; gap: 4px; text-align: center; } -.aqi-value { font-size: 1.2rem; font-weight: 800; color: #1f2937; } - -.aqi-badge { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 5px 12px; - border-radius: 999px; - color: #ffffff; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - white-space: nowrap; -} - -.aqi-1 { background: #16a34a; } -.aqi-2 { background: #fbbf24; color: #1f2937; } -.aqi-3 { background: #f97316; } -.aqi-4 { background: #dc2626; } -.aqi-5 { background: #9333ea; } - -.aqi-pollutants, -.aqi-recommendation { - font-size: 11px; - line-height: 1.5; - color: #475569; - margin-top: 6px; - text-align: center; - max-width: 160px; - overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; -} - -.detail-label { font-size: 11px; color: #718096; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 600; transition: color 0.4s ease; } -.detail-value { font-size: 16px; font-weight: 700; color: #2d3748; transition: color 0.4s ease; } - -/* ===== FORECAST SECTION ===== */ -.forecast-section { - animation: slideInRight 0.6s ease-out 0.2s both; - display: flex; - flex-direction: column; - gap: 18px; -} - -.hourly-toggle-row { display: flex; gap: 8px; margin: 0 0 12px 0; flex-wrap: wrap; } - -.hourly-toggle { - border: none; - border-radius: 12px; - padding: 9px 14px; - background: #edf2f7; - color: #718096; - cursor: pointer; - font: inherit; - font-size: 13px; - font-weight: 700; - transition: all 0.2s ease; -} - -.hourly-toggle:hover { color: #4c51bf; background: rgba(255, 255, 255, 0.72); } -.hourly-toggle.active { color: white; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 6px 18px rgba(102, 126, 234, 0.25); } - -.forecast-title { font-size: 24px; font-weight: 700; color: #2d3748; letter-spacing: -0.5px; transition: color 0.4s ease; } -.forecast-subtitle { font-size: 13px; line-height: 1.6; color: #718096; transition: color 0.4s ease; } - -.forecast-graph-card { - background: linear-gradient(180deg, #eef4ff 0%, #f8fbff 100%); - border: 2px solid rgba(102, 126, 234, 0.15); - border-radius: 20px; - padding: 22px; - transition: all 0.3s ease; -} - -.forecast-graph-card:hover { border-color: #667eea; box-shadow: 0 15px 40px rgba(102, 126, 234, 0.1); } - -.graph-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - margin-bottom: 15px; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - color: #667eea; -} - -.graph-wrapper { width: 100%; height: 240px; } -#temperature-chart { width: 100%; height: 100%; } - -.graph-grid-line { stroke: rgba(102, 126, 234, 0.15); stroke-width: 1; stroke-dasharray: 5 5; } -.graph-area { fill: rgba(102, 126, 234, 0.12); animation: fillArea 0.8s ease-out forwards; } -.graph-line { fill: none; stroke: #667eea; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; animation: drawLine 1s ease-out forwards; stroke-dasharray: 1000; stroke-dashoffset: 1000; } -.graph-point { fill: #ffffff; stroke: #667eea; stroke-width: 3; animation: popIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; opacity: 0; } -.graph-point-label { fill: #2d3748; font-size: 12px; font-weight: 700; } -.graph-axis-label { fill: #718096; font-size: 11px; font-weight: 600; } - -.forecast-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 14px; -} - -.forecast-card { - background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%); - border-radius: 16px; - padding: 18px 14px; - text-align: center; - transition: all 0.3s ease; - border: 1px solid rgba(102, 126, 234, 0.12); - cursor: pointer; - position: relative; - overflow: hidden; -} - -.forecast-card::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(135deg, transparent 0%, rgba(102, 126, 234, 0.1) 100%); - opacity: 0; - transition: opacity 0.3s ease; -} - -.forecast-card:hover { transform: translateY(-8px); box-shadow: 0 12px 35px rgba(0, 0, 0, 0.1); border-color: #667eea; } -.forecast-card:hover::before { opacity: 1; } -.forecast-day { font-size: 13px; font-weight: 700; color: #667eea; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; } -.forecast-icon { width: 45px; height: 45px; margin: 0 auto 10px; transition: transform 0.3s ease; } -.forecast-card:hover .forecast-icon { transform: scale(1.1) rotate(5deg); } -.forecast-icon img { width: 100%; height: 100%; object-fit: contain; } -.forecast-temp { font-size: 16px; font-weight: 700; color: #2d3748; margin-bottom: 4px; transition: color 0.4s ease; } -.forecast-desc { font-size: 12px; color: #718096; text-transform: capitalize; transition: color 0.4s ease; } - -/* ===== WEATHER TRENDS ===== */ -.weather-trends-section { display: flex; flex-direction: column; gap: 20px; animation: slideUp 0.6s ease-out 0.25s both; } -.trends-header { display: flex; justify-content: space-between; gap: 20px; align-items: center; } - -.trend-controls { - display: inline-flex; - gap: 6px; - padding: 5px; - background: #edf2f7; - border: 1px solid rgba(226, 232, 240, 0.9); - border-radius: 14px; - flex-shrink: 0; -} - -.trend-toggle { border: none; border-radius: 10px; padding: 9px 14px; background: transparent; color: #718096; cursor: pointer; font: inherit; font-size: 13px; font-weight: 700; transition: all 0.2s ease; } -.trend-toggle:hover { color: #4c51bf; background: rgba(255, 255, 255, 0.72); } -.trend-toggle.active { color: white; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 6px 18px rgba(102, 126, 234, 0.25); } - -.trends-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr); gap: 18px; align-items: stretch; } - -.trend-chart-card { - background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%); - border: 2px solid rgba(102, 126, 234, 0.15); - border-radius: 20px; - padding: 22px; - transition: all 0.3s ease; -} - -.trend-chart-card:hover { border-color: #667eea; box-shadow: 0 15px 40px rgba(102, 126, 234, 0.1); } -.trend-chart-wrapper { height: 280px; width: 100%; } -#trend-chart { --trend-color: #667eea; width: 100%; height: 100%; overflow: visible; } - -.trend-bar { fill: rgba(102, 126, 234, 0.18); stroke: rgba(102, 126, 234, 0.45); stroke-width: 1; transition: all 0.25s ease; } -.trend-bar-group:hover .trend-bar { fill: rgba(102, 126, 234, 0.28); } -.trend-range-line { stroke: rgba(45, 55, 72, 0.22); stroke-width: 3; stroke-linecap: round; } -.trend-high-dot { fill: #f97316; stroke: white; stroke-width: 2; opacity: 0.9; } -.trend-low-dot { fill: #0ea5e9; stroke: white; stroke-width: 2; opacity: 0.9; } -.trend-line { fill: none; stroke: var(--trend-color); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; animation: drawLine 1s ease-out forwards; stroke-dasharray: 1000; stroke-dashoffset: 1000; } -.trend-line-point { fill: white; stroke: var(--trend-color); stroke-width: 3; animation: popIn 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } -.trend-value-label { paint-order: stroke; stroke: #f8fbff; stroke-width: 5px; stroke-linejoin: round; } - -.trend-stats { display: grid; grid-template-columns: 1fr; gap: 12px; } - -.trend-stat-card { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - padding: 16px; - border-radius: 16px; - background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%); - border: 1px solid rgba(102, 126, 234, 0.12); - transition: all 0.25s ease; -} - -.trend-stat-card:hover { transform: translateY(-3px); border-color: #667eea; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08); } -.trend-stat-date { display: flex; flex-direction: column; gap: 3px; min-width: 58px; } -.trend-stat-date span { color: #667eea; font-size: 13px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; } -.trend-stat-date small { color: #718096; font-size: 12px; font-weight: 600; } -.trend-stat-values { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px 12px; font-size: 12px; color: #718096; } -.trend-stat-values strong { color: #2d3748; } - /* ===== ANIMATIONS ===== */ @keyframes slideUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @@ -799,415 +134,63 @@ h1 { @keyframes skyShift { 0% { background-position: 0% 35%; } 100% { background-position: 100% 65%; } } @keyframes floatGlow { 0% { transform: translate3d(0, 0, 0) scale(1); } 50% { transform: translate3d(18px, -14px, 0) scale(1.06); } 100% { transform: translate3d(-10px, 12px, 0) scale(0.96); } } -/* Loading State */ -.loading { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 9999; } -.spinner { width: 50px; height: 50px; border: 5px solid rgba(255, 255, 255, 0.2); border-top: 5px solid white; border-radius: 50%; animation: spin 1s linear infinite; } -@keyframes spin { to { transform: rotate(360deg); } } -.loading p { color: #718096; font-size: 16px; font-weight: 500; margin-top: 16px; } - -.error { text-align: center; padding: 40px; background: linear-gradient(135deg, #fed7d7 0%, #fecaca 100%); border: 2px solid #fc8181; border-radius: 14px; margin-top: 20px; animation: slideUp 0.4s ease-out; } -.error p { color: #c53030; font-size: 15px; font-weight: 500; } -.hidden { display: none !important; } - -/* ===== RESPONSIVE ===== */ -@media screen and (max-width: 1024px) { - .weather-app { padding: 1.5rem; } - .weather-details { grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); } - .trends-layout { grid-template-columns: 1fr; } - .trend-stats { grid-template-columns: repeat(2, 1fr); } -} - -@media screen and (max-width: 768px) { - .weather-app { padding: 22px; } - h1 { font-size: 28px; } - header { grid-template-columns: 1fr; grid-template-areas: "title" "theme" "units" "search"; align-items: stretch; } - .theme-toggle, .unit-toggle { width: fit-content; } - #city-name { font-size: 26px; } - #temp { font-size: 52px; } - /* Stack temp and sun vertically on tablet */ - .weather-main { grid-template-columns: 1fr; } - .temperature-section { grid-column: 1; grid-row: 2; } - .sun-position-card { grid-column: 2; grid-row: 2; } - .weather-details { grid-template-columns: repeat(2, 1fr); gap: 12px; } - .detail-card { padding: 15px; } - .forecast-container { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 12px; } - .forecast-card { padding: 14px 12px; } - .graph-wrapper { height: 180px; } - .trends-header { flex-direction: column; align-items: stretch; } - .trend-controls { align-self: flex-start; } - .trend-chart-wrapper { height: 220px; } -} - -@media screen and (max-width: 600px) { - .weather-app { padding: 1.125rem; border-radius: 1.25rem; } - header { margin-bottom: 18px; } - h1 { font-size: 24px; line-height: 1.1; } - .search-box { flex-direction: column; gap: 10px; } - .search-input-wrap { width: 100%; } - #city-input, #search-btn { width: 100%; padding: 12px; font-size: 15px; } - #location-btn.location-btn { width: 100%; justify-content: center; padding: 12px; flex-shrink: 0; } - #city-input { padding-right: 40px; } - #city-name { font-size: 22px; } - #temp { font-size: 48px; } - .weather-icon { width: 80px; height: 80px; } - .temperature-section { padding: 22px 18px; } - .weather-details { grid-template-columns: 1fr; gap: 10px; } - .detail-card { padding: 12px; } - .detail-label { font-size: 10px; } - .detail-value { font-size: 14px; } - .forecast-title { font-size: 20px; } - .forecast-container { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } - .forecast-card { padding: 12px; } - .graph-wrapper { height: 150px; } - .trend-stats { grid-template-columns: 1fr; } - .trend-stat-card { align-items: flex-start; flex-direction: column; } - .trend-stat-values { justify-content: flex-start; } - .trend-chart-wrapper { height: 190px; } - .sun-position-card { padding: 16px; } - .sun-track { height: 40px; } - .sun-position-meta { flex-direction: column; gap: 8px; } - .toggle-label { display: none; } - .theme-toggle { padding: 8px 10px; } - .unit-btn { padding: 8px 10px; font-size: 12px; } - .share-menu { right: 0; left: 0; } -} - -@media screen and (max-width: 450px) { - .container { max-width: 100%; } - .weather-app { padding: 16px; border-radius: 18px; } - h1 { font-size: 22px; } - #city-name { font-size: 20px; } - #temp { font-size: 44px; } - .weather-details { grid-template-columns: 1fr; } - .forecast-container { grid-template-columns: repeat(2, 1fr); } - .weather-main { gap: 15px; } - .forecast-title { font-size: 18px; } - .trend-controls { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; } - .trend-toggle { padding: 9px 8px; } - .detail-card { flex-direction: row; align-items: center; } - .detail-info { align-items: flex-start; text-align: left; } -} - -/* ===== CSS VARIABLES ===== */ -:root { - --page-bg: #f1f5f9; - --app-bg: #ffffff; - --panel-bg: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%); - --text: #0f172a; - --muted: #64748b; - --accent-start: #667eea; - --accent-end: #764ba2; - --border: rgba(226, 232, 240, 0.5); - --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.04); -} - -body, .weather-app, .temperature-section, .detail-card, .forecast-graph-card { - transition: background-color 240ms ease, color 240ms ease, border-color 240ms ease; -} - -body { background-color: var(--page-bg); color: var(--text); } - -body.dark-mode { - --page-bg: #0b1220; - --app-bg: rgba(15, 23, 42, 0.92); - --panel-bg: linear-gradient(135deg, rgba(10, 14, 25, 0.6) 0%, rgba(20, 26, 40, 0.6) 100%); - --text: #e2e8f0; - --muted: #94a3b8; - --accent-start: #f59e0b; - --accent-end: #f97316; - --border: rgba(148, 163, 184, 0.08); - --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); -} - -/* ===== UNIT TOGGLE ===== */ -.unit-toggle { display: flex; border: 1.5px solid rgba(102, 126, 234, 0.3); border-radius: 50px; overflow: hidden; flex-shrink: 0; } -.unit-btn { padding: 8px 14px; border: none; background: transparent; color: #718096; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background 0.2s ease, color 0.2s ease; } -.unit-btn:not(:last-child) { border-right: 1.5px solid rgba(102, 126, 234, 0.2); } -.unit-btn.active { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } -.unit-btn:not(.active):hover { background: rgba(102, 126, 234, 0.1); color: #667eea; } - -body.dark-mode .unit-toggle { border-color: rgba(100, 116, 139, 0.4); } -body.dark-mode .unit-btn { color: #94a3b8; } -body.dark-mode .unit-btn:not(:last-child) { border-right-color: rgba(100, 116, 139, 0.3); } -body.dark-mode .unit-btn:not(.active):hover { background: rgba(102, 126, 234, 0.15); color: #a5b4fc; } - -/* ===== CITY HEADER ROW ===== */ -.city-header-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; position: relative; } -.city-header-actions { display: flex; align-items: center; gap: 8px; } - -/* ===== SHARE BUTTON & MENU ===== */ -.share-btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 8px 14px; - border: 1.5px solid rgba(102, 126, 234, 0.3); - border-radius: 50px; - background: rgba(102, 126, 234, 0.08); - color: #2d3748; - font-family: inherit; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: background 0.3s ease, border-color 0.3s ease, transform 0.15s ease; - white-space: nowrap; -} - -.share-btn:hover { background: rgba(102, 126, 234, 0.15); border-color: #667eea; transform: translateY(-1px); } -body.dark-mode .share-btn { color: #f8fafc; border-color: rgba(148, 163, 184, 0.4); background: rgba(102, 126, 234, 0.22); } - -.share-menu { - position: absolute; - top: calc(100% + 8px); - left: 0; - width: min(220px, 100%); - padding: 12px; - border-radius: 18px; - background: white; - border: 1px solid rgba(148, 163, 184, 0.3); - box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12); - z-index: 10; +/* Global Loading State */ +.loading { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; - gap: 8px; -} - -.share-menu.hidden { display: none; } - -.share-menu-item { - border: none; - background: transparent; - padding: 10px 12px; - border-radius: 10px; - font-family: inherit; - font-size: 14px; - font-weight: 600; - text-align: left; - cursor: pointer; - transition: background 0.2s ease; - color: #2d3748; -} - -.share-menu-item:hover { background: rgba(102, 126, 234, 0.14); } -.share-status-text { font-size: 12px; color: #475569; min-height: 18px; padding: 0 4px; } - -body.dark-mode .share-menu { background: rgba(15, 23, 42, 0.96); border-color: rgba(148, 163, 184, 0.24); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); } -body.dark-mode .share-menu-item { background: rgba(255, 255, 255, 0.05); color: #e2e8f0; } -body.dark-mode .share-menu-item:hover { background: rgba(255, 255, 255, 0.12); } -body.dark-mode .share-status-text { color: #cbd5e1; } - -/* ===== UV INDEX ===== */ -#uv-index { - display: inline-flex; align-items: center; justify-content: center; - min-width: 32px; - height: 32px; - border-radius: 50%; - font-weight: 700; - color: white; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + z-index: 9999; } -#uv-index:empty, #uv-index[data-empty="true"] { - background: transparent; - color: #94a3b8; - font-size: 13px; - font-weight: 600; - min-width: auto; - height: auto; - border-radius: 0; -} - -/* ===== DARK MODE TOGGLE ===== */ -.theme-toggle { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 16px; - border: 1.5px solid rgba(102, 126, 234, 0.3); - border-radius: 50px; - background: rgba(102, 126, 234, 0.08); - color: #2d3748; - font-family: inherit; - font-size: 14px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease, transform 0.15s ease, box-shadow 0.3s ease; - user-select: none; -} - -.theme-toggle:hover { background: rgba(102, 126, 234, 0.15); border-color: #667eea; transform: translateY(-1px); } -.theme-toggle:active { transform: translateY(0); } -.toggle-icon { font-style: normal; font-size: 16px; transition: transform 0.4s ease; line-height: 1; } -body.dark-mode .toggle-icon { transform: rotate(20deg); } -body.dark-mode .theme-toggle { background: rgba(251, 191, 36, 0.08); border-color: rgba(251, 191, 36, 0.3); color: #fbbf24; } -body.dark-mode .theme-toggle:hover { background: rgba(251, 191, 36, 0.15); border-color: #fbbf24; } - -/* ===== DARK MODE OVERRIDES ===== */ -body.dark-mode { color: #e2e8f0; } -body.dark-mode .weather-app { background: rgba(15, 23, 42, 0.92); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.06); } -body.dark-mode #city-input { background: rgba(30, 41, 59, 0.8); border-color: rgba(100, 116, 139, 0.4); color: #e2e8f0; } -body.dark-mode #city-input::placeholder { color: #64748b; } -body.dark-mode #city-input:focus { background: rgba(30, 41, 59, 1); border-color: #667eea; } -body.dark-mode .clear-btn { color: #94a3b8; } -body.dark-mode .clear-btn:hover { color: #e2e8f0; background: rgba(148, 163, 184, 0.15); } -body.dark-mode .suggestions-container { background: #1e293b; box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5); } -body.dark-mode .suggestion-item { color: #cbd5e0; border-bottom-color: rgba(100, 116, 139, 0.2); } -body.dark-mode .suggestion-item:hover { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } -body.dark-mode .temperature-section { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); border-color: rgba(100, 116, 139, 0.2); } -body.dark-mode #city-name, body.dark-mode #temp, body.dark-mode .forecast-title { color: #f1f5f9; } -body.dark-mode #date, body.dark-mode #weather-desc, body.dark-mode .unit, body.dark-mode .forecast-subtitle, body.dark-mode .detail-label { color: #94a3b8; } -body.dark-mode .detail-card { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); border-color: rgba(100, 116, 139, 0.2); } -body.dark-mode .detail-card:hover { border-color: #667eea; box-shadow: 0 12px 35px rgba(0, 0, 0, 0.4); } -body.dark-mode .detail-value { color: #f1f5f9; } -body.dark-mode .aqi-value { color: #f1f5f9; } -body.dark-mode .aqi-pollutants, body.dark-mode .aqi-recommendation { color: #94a3b8; } -body.dark-mode .forecast-graph-card { background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: rgba(102, 126, 234, 0.2); } -body.dark-mode .forecast-card { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); border-color: rgba(100, 116, 139, 0.2); } -body.dark-mode .forecast-card:hover { border-color: #667eea; box-shadow: 0 12px 35px rgba(0, 0, 0, 0.4); } -body.dark-mode .forecast-temp { color: #f1f5f9; } -body.dark-mode .forecast-desc { color: #94a3b8; } -body.dark-mode .graph-point-label { fill: #f1f5f9; } -body.dark-mode .graph-axis-label { fill: #94a3b8; } -body.dark-mode .graph-point { fill: #1e293b; } -body.dark-mode .sun-position-card { background: linear-gradient(135deg, #2a1f0a 0%, #1f1506 100%); border-color: rgba(217, 119, 6, 0.3); } -body.dark-mode .sun-position-header h3 { color: #fbbf24; } -body.dark-mode #sun-phase { color: #fbbf24; background: rgba(251, 191, 36, 0.1); } -body.dark-mode .sun-position-meta { color: #d97706; } -body.dark-mode .location-btn { color: #e2e8f0; border-color: rgba(102, 126, 234, 0.65); background: linear-gradient(135deg, rgba(102, 126, 234, 0.22) 0%, rgba(118, 75, 162, 0.22) 100%); box-shadow: 0 4px 20px rgba(102, 126, 234, 0.22); } -body.dark-mode .location-btn:hover { border-color: rgba(102, 126, 234, 0.95); background: linear-gradient(135deg, rgba(102, 126, 234, 0.3) 0%, rgba(118, 75, 162, 0.3) 100%); } -body.dark-mode .location-label { color: #e2e8f0; } -body.dark-mode .location-icon { color: #a5b4fc; } -body.dark-mode .spinner { border-color: rgba(100, 116, 139, 0.3); border-top-color: white; } -body.dark-mode .loading p { color: #94a3b8; } -body.dark-mode .trend-chart-card { background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: rgba(102, 126, 234, 0.2); } -body.dark-mode .trend-stat-card { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); border-color: rgba(100, 116, 139, 0.2); } -body.dark-mode .trend-stat-card:hover { border-color: #667eea; } -body.dark-mode .trend-stat-values strong { color: #f1f5f9; } -body.dark-mode .trend-stat-date span { color: #a5b4fc; } -body.dark-mode .trend-stat-date small { color: #64748b; } -body.dark-mode .trend-value-label { stroke: #0f172a; } -body.dark-mode .history-dropdown { background: rgba(15, 23, 42, 0.95); border-color: rgba(99, 102, 241, 0.3); } -body.dark-mode .history-panel-header h2 { color: #f1f5f9; } -body.dark-mode .history-panel-header p { color: #94a3b8; } -body.dark-mode .history-button { background: rgba(30, 41, 59, 0.8); border-color: rgba(100, 116, 139, 0.3); color: #e2e8f0; } -body.dark-mode .history-button:hover { background: rgba(51, 65, 85, 0.9); } -body.dark-mode .history-section-title { color: #94a3b8; } -body.dark-mode .clear-history-btn { background: rgba(30, 41, 59, 0.8); border-color: rgba(100, 116, 139, 0.3); color: #94a3b8; } -body.dark-mode .hourly-toggle { background: rgba(30, 41, 59, 0.9); color: #94a3b8; } -body.dark-mode .hourly-toggle.active { color: white; } -body.dark-mode .trend-controls { background: rgba(30, 41, 59, 0.9); border-color: rgba(100, 116, 139, 0.2); } -body.dark-mode .trend-toggle { color: #94a3b8; } -body.dark-mode .trend-toggle:hover { color: #a5b4fc; background: rgba(102, 126, 234, 0.15); } - -/* Hide welcome message when weather data is visible */ -#weather-container:not(.hidden) ~ #no-data-message, -#weather-container:not(.hidden) + #no-data-message { display: none !important; } - -/* ===== UTILITY ===== */ -.text-red-500 { color: #ef4444 !important; } -.text-sm { font-size: 0.875rem !important; } -.mt-2 { margin-top: 0.5rem !important; margin-left: 0.5rem !important; } - -#search-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; background: #cbd5e0; } - -/* ===== LOCATION BUTTON ===== */ -.location-btn { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%); - border: 1.5px solid rgba(102, 126, 234, 0.35); - border-radius: 14px; - padding: 14px 16px; - cursor: pointer; - color: #2d3748; - transition: all 0.3s ease; - display: flex; - align-items: center; - gap: 10px; - font-weight: 700; - white-space: nowrap; - flex-shrink: 0; - box-shadow: 0 4px 15px rgba(102, 126, 234, 0.10); +.spinner { + width: 50px; + height: 50px; + border: 5px solid rgba(255, 255, 255, 0.2); + border-top: 5px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; } -.location-btn:hover { transform: translateY(-3px); box-shadow: 0 10px 25px rgba(102, 126, 234, 0.25); border-color: rgba(102, 126, 234, 0.65); } -.location-btn:active { transform: translateY(-1px); } -.location-btn:focus-visible { outline: 2px solid #667eea; outline-offset: 2px; } -.location-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; } -.location-icon { font-size: 18px; line-height: 1; } -.location-label { font-size: 14px; } - -.feels-like-main { font-size: 14px; color: #94a3b8; font-weight: 500; transition: color 0.4s ease; } -body.dark-mode .feels-like-main { color: #94a3b8; } - -.no-data-message { text-align: center; padding: 40px 20px; color: #718096; } -.no-data-message h2 { font-size: 22px; font-weight: 700; color: #2d3748; margin-bottom: 8px; transition: color 0.4s ease; } -body.dark-mode .no-data-message h2 { color: #f1f5f9; } -body.dark-mode .no-data-message { color: #94a3b8; } - -/* ===== SKELETON LOADER ===== */ -.skeleton-loader { padding: 20px; } -.skeleton-text, .skeleton-card { background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 8px; } -@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } -body.dark-mode .skeleton-text, body.dark-mode .skeleton-card { background: linear-gradient(90deg, #1e293b 25%, #2d3748 50%, #1e293b 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; } - +@keyframes spin { to { transform: rotate(360deg); } } -/* ===== WEATHER MAP SECTION ===== */ -.weather-map-section { - display: flex; - flex-direction: column; - gap: 20px; - animation: slideUp 0.6s ease-out 0.3s both; - margin-top: 10px; +.loading p { + color: #cbd5e1; + font-size: 16px; + font-weight: 500; + margin-top: 16px; } -.map-card { - background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%); - border: 2px solid rgba(102, 126, 234, 0.15); - border-radius: 20px; - padding: 12px; - transition: all 0.3s ease; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); - position: relative; - z-index: 1; /* Keep map controls below header dropdowns */ +body.dark-mode .spinner { + border-color: rgba(100, 116, 139, 0.3); + border-top-color: white; } -.map-card:hover { - border-color: #667eea; - box-shadow: 0 15px 40px rgba(102, 126, 234, 0.15); +body.dark-mode .loading p { + color: #94a3b8; } -/* Dark Mode Overrides for Map */ -body.dark-mode .map-card { - background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); - border-color: rgba(102, 126, 234, 0.2); +.hidden { + display: none !important; } -body.dark-mode .map-card:hover { - border-color: #667eea; +/* ===== RESPONSIVE ===== */ +@media screen and (max-width: 1024px) { + .weather-app { padding: 1.5rem; } } -/* Leaflet Dark Mode UI Adjustments */ -body.dark-mode .leaflet-control-zoom a { - background-color: #1e293b; - color: #e2e8f0; - border-color: #334155; +@media screen and (max-width: 768px) { + .weather-app { padding: 22px; } } -body.dark-mode .leaflet-control-zoom a:hover { - background-color: #334155; +@media screen and (max-width: 600px) { + .weather-app { padding: 1.125rem; border-radius: 1.25rem; } } -body.dark-mode .leaflet-popup-content-wrapper, -body.dark-mode .leaflet-popup-tip { - background-color: #1e293b; - color: #f1f5f9; - box-shadow: 0 3px 14px rgba(0,0,0,0.4); -/* Wind Compass Styling */ -#wind-compass-icon { - transform-origin: center center; - transition: transform 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94); - display: inline-block; +@media screen and (max-width: 450px) { + .container { max-width: 100%; } + .weather-app { padding: 16px; border-radius: 18px; } } \ No newline at end of file