From 9687cc97ac8a0824c6304d602c7acb9e5dbde263 Mon Sep 17 00:00:00 2001 From: Ole Kristian Hoel Date: Thu, 6 Nov 2025 23:56:32 +0100 Subject: [PATCH] Added weather animations and moon phases --- index.html | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 671 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index c7ebc12..dbe7554 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@ } .clock-container { + position: relative; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border-radius: 20px; @@ -337,12 +338,207 @@ 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } + + /* Weather Animation Styles */ + .weather-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; + z-index: 5; + border-radius: 15px; + } + + /* Rain Animation */ + .rain { + position: absolute; + width: 2px; + height: 50px; + background: linear-gradient(transparent, rgba(173, 216, 230, 0.6)); + animation: fall linear infinite; + } + + @keyframes fall { + to { + transform: translateY(600px); + } + } + + /* Snow Animation */ + .snow { + position: absolute; + width: 10px; + height: 10px; + background: white; + border-radius: 50%; + opacity: 0.8; + animation: snowfall linear infinite; + } + + @keyframes snowfall { + to { + transform: translateY(600px) translateX(50px); + } + } + + /* Sun Animation */ + .sun { + position: absolute; + width: 80px; + height: 80px; + background: radial-gradient(circle, #ffeb3b 40%, #ffd700 60%, transparent 70%); + border-radius: 50%; + top: 20px; + right: 20px; + animation: sunshine 3s ease-in-out infinite; + box-shadow: 0 0 40px #ffeb3b, 0 0 80px #ffd700; + } + + @keyframes sunshine { + 0%, 100% { + opacity: 0.8; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.1); + } + } + + /* Moon Animation */ + .moon { + position: absolute; + width: 70px; + height: 70px; + background: radial-gradient(circle, #f0f0f0 40%, #d0d0d0 60%, transparent 70%); + border-radius: 50%; + top: 20px; + right: 20px; + animation: moonshine 4s ease-in-out infinite; + overflow: hidden; + } + + /* Moon phase overlay - creates the shadow effect */ + .moon::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(20, 20, 40, 0.95); + border-radius: 50%; + transform-origin: center; + } + + /* Moon phase - dynamically calculated terminator using CSS variable */ + .moon::before { + clip-path: var(--moon-shadow-clip, circle(50% at 50% 50%)); + } + + @keyframes moonshine { + 0%, 100% { + opacity: 0.7; + box-shadow: 0 0 30px rgba(240, 240, 240, 0.6), 0 0 60px rgba(200, 200, 200, 0.4); + } + 50% { + opacity: 0.9; + box-shadow: 0 0 40px rgba(240, 240, 240, 0.8), 0 0 80px rgba(200, 200, 200, 0.6); + } + } + + /* Cloud Animation */ + .cloud { + position: absolute; + background: rgba(255, 255, 255, 0.8); + border-radius: 100px; + animation: drift 20s linear infinite; + } + + .cloud::before, + .cloud::after { + content: ''; + position: absolute; + background: rgba(255, 255, 255, 0.8); + border-radius: 100px; + } + + .cloud-small { + width: 60px; + height: 20px; + top: 30px; + } + + .cloud-small::before { + width: 30px; + height: 30px; + top: -15px; + left: 10px; + } + + .cloud-small::after { + width: 40px; + height: 25px; + top: -10px; + right: 5px; + } + + @keyframes drift { + from { + left: -100px; + } + to { + left: calc(100% + 100px); + } + } + + /* Lightning Animation */ + .lightning { + position: absolute; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.9); + opacity: 0; + animation: flash 4s ease-in-out infinite; + } + + @keyframes flash { + 0%, 20%, 100% { + opacity: 0; + } + 10% { + opacity: 0.9; + } + } + + /* Fog Animation */ + .fog { + position: absolute; + width: 100%; + height: 50%; + bottom: 0; + background: linear-gradient(to top, rgba(200, 200, 200, 0.6), transparent); + animation: fogmove 8s ease-in-out infinite; + } + + @keyframes fogmove { + 0%, 100% { + opacity: 0.6; + } + 50% { + opacity: 0.3; + } + }
+
@@ -394,6 +590,7 @@
+
@@ -445,6 +642,7 @@
+
@@ -501,17 +699,26 @@ trondheim: { timezone: 'Europe/Oslo', cityName: 'Trondheim', - is24Hour: true + is24Hour: true, + weather: 'clear', // Will be updated from yr.no + latitude: 63.4305, + longitude: 10.3951 }, alesund: { timezone: 'Europe/Oslo', cityName: 'Ålesund', - is24Hour: true + is24Hour: true, + weather: 'clear', // Will be updated from yr.no + latitude: 62.4722, + longitude: 6.1549 }, gjovik: { timezone: 'Europe/Oslo', cityName: 'Gjøvik', - is24Hour: true + is24Hour: true, + weather: 'clear', // Will be updated from yr.no + latitude: 60.7954, + longitude: 10.6918 } }; @@ -568,6 +775,458 @@ }); } + // Weather animation functions + function createWeatherEffect(cityKey, weatherType) { + const container = document.getElementById(`weather-${cityKey}`); + if (!container) return; + container.innerHTML = ''; // Clear existing weather + + // Handle combination weather types (e.g., "sun+cloudy", "rain+lightning") + if (weatherType.includes('+')) { + const types = weatherType.split('+'); + types.forEach(type => { + switch(type) { + case 'sun': + createSun(container); + break; + case 'moon': + createMoon(container); + break; + case 'cloudy': + createClouds(container); + break; + case 'rain': + createRain(container); + break; + case 'snow': + createSnow(container); + break; + case 'lightning': + createLightning(container); + break; + } + }); + return; + } + + // Handle single weather types + switch(weatherType) { + case 'rain': + createRain(container); + break; + case 'snow': + createSnow(container); + break; + case 'sun': + createSun(container); + break; + case 'moon': + createMoon(container); + break; + case 'cloudy': + createClouds(container); + break; + case 'storm': + createStorm(container); + break; + case 'fog': + createFog(container); + break; + case 'clear': + // No weather effect + break; + } + } + + function createRain(container) { + for (let i = 0; i < 50; i++) { + const drop = document.createElement('div'); + drop.className = 'rain'; + drop.style.left = Math.random() * 100 + '%'; + drop.style.animationDuration = (Math.random() * 0.5 + 0.5) + 's'; + drop.style.animationDelay = Math.random() * 2 + 's'; + container.appendChild(drop); + } + } + + function createSnow(container) { + for (let i = 0; i < 30; i++) { + const flake = document.createElement('div'); + flake.className = 'snow'; + flake.style.left = Math.random() * 100 + '%'; + flake.style.animationDuration = (Math.random() * 3 + 2) + 's'; + flake.style.animationDelay = Math.random() * 3 + 's'; + flake.style.width = (Math.random() * 5 + 5) + 'px'; + flake.style.height = flake.style.width; + container.appendChild(flake); + } + } + + function createSun(container) { + const sun = document.createElement('div'); + sun.className = 'sun'; + container.appendChild(sun); + } + + function createMoon(container) { + const moon = document.createElement('div'); + moon.className = 'moon'; + + // Calculate and apply moon phase dynamically + const phase = getMoonPhase(); + applyMoonPhaseStyle(moon, phase); + + // Debug logging + console.log(`Moon phase: ${phase.toFixed(4)}`); + + container.appendChild(moon); + } + + function applyMoonPhaseStyle(moonElement, phase) { + // Calculate terminator shape using ellipse formula + // b = (2n - 1) * R, where n is illuminated fraction (0-1) + + // Convert phase (0-1 cycle) to illumination fraction (0-1) + // Phase 0.0 = new moon (0% lit), Phase 0.5 = full moon (100% lit) + let illumination; + if (phase <= 0.5) { + illumination = phase * 2; // 0 to 1 during waxing + } else { + illumination = (1 - phase) * 2; // 1 to 0 during waning + } + + const R = 50; // radius as percentage + const b = (2 * illumination - 1) * R; // semi-minor axis of terminator ellipse + + console.log(`Phase: ${phase.toFixed(4)}, Illumination: ${illumination.toFixed(4)}, b: ${b.toFixed(2)}`); + + // Generate polygon points for the shadow + const points = []; + const steps = 36; // Number of points for smooth curve + + // The shadow covers different parts depending on phase + if (phase <= 0.5) { + // Waxing: shadow on left, light on right + // Left edge (terminator line - ellipse) + for (let i = 0; i <= steps; i++) { + const angle = (i / steps) * Math.PI; // 0 to π (left semicircle) + const y = 50 - R * Math.cos(angle); // Center at 50%, radius R (flipped for top=0) + const x = 50 - b * Math.sin(angle); // Ellipse with semi-minor axis b (negative for left) + points.push(`${x}% ${y}%`); + } + // Right edge (moon limb - circle) + for (let i = steps; i >= 0; i--) { + const angle = (i / steps) * Math.PI; + const y = 50 - R * Math.cos(angle); + const x = 50 - R * Math.sin(angle); + points.push(`${x}% ${y}%`); + } + } else { + // Waning: shadow on right, light on left + // Right edge (terminator line - ellipse) + for (let i = 0; i <= steps; i++) { + const angle = (i / steps) * Math.PI; + const y = 50 - R * Math.cos(angle); + const x = 50 + b * Math.sin(angle); // Ellipse on right (positive) + points.push(`${x}% ${y}%`); + } + // Left edge (moon limb - circle) + for (let i = steps; i >= 0; i--) { + const angle = (i / steps) * Math.PI; + const y = 50 - R * Math.cos(angle); + const x = 50 + R * Math.sin(angle); + points.push(`${x}% ${y}%`); + } + } + + // Apply clip-path to ::before pseudo-element via style + const clipPath = `polygon(${points.join(', ')})`; + moonElement.style.setProperty('--moon-shadow-clip', clipPath); + } + + // Calculate current moon phase + function getMoonPhase() { + // Use UTC for astronomical calculations + const now = new Date(); + + // Known new moon: Oct 21, 2025 at 12:25 UTC + const knownNewMoon = Date.UTC(2025, 9, 21, 12, 25); + const currentTime = now.getTime(); + + // Calculate days since known new moon + const daysSinceKnownNewMoon = (currentTime - knownNewMoon) / (1000 * 60 * 60 * 24); + + // Lunar cycle is approximately 29.53 days + const lunarCycle = 29.53058867; + const phase = (daysSinceKnownNewMoon % lunarCycle) / lunarCycle; + + console.log(`Days since new moon: ${daysSinceKnownNewMoon.toFixed(2)}, Phase: ${phase.toFixed(4)}`); + + return phase; // Returns 0-1 where 0=new moon, 0.5=full moon + } + + function getMoonPhaseClass() { + const phase = getMoonPhase(); + + // Convert phase to moon phase name + if (phase < 0.0625) return 'moon-new'; + else if (phase < 0.1875) return 'moon-waxing-crescent'; + else if (phase < 0.3125) return 'moon-first-quarter'; + else if (phase < 0.4375) return 'moon-waxing-gibbous'; + else if (phase < 0.5625) return 'moon-full'; + else if (phase < 0.6875) return 'moon-waning-gibbous'; + else if (phase < 0.8125) return 'moon-last-quarter'; + else if (phase < 0.9375) return 'moon-waning-crescent'; + else return 'moon-new'; + } + + function createClouds(container) { + for (let i = 0; i < 3; i++) { + const cloud = document.createElement('div'); + cloud.className = 'cloud cloud-small'; + cloud.style.top = (Math.random() * 50 + 20) + 'px'; + cloud.style.animationDuration = (Math.random() * 10 + 15) + 's'; + cloud.style.animationDelay = (i * 5) + 's'; + container.appendChild(cloud); + } + } + + function createLightning(container) { + const lightning = document.createElement('div'); + lightning.className = 'lightning'; + container.appendChild(lightning); + } + + function createStorm(container) { + // Rain + Lightning (legacy function, kept for compatibility) + createRain(container); + createLightning(container); + } + + function createFog(container) { + const fog = document.createElement('div'); + fog.className = 'fog'; + container.appendChild(fog); + } + + // Initialize weather effects for all clocks + function initializeWeather() { + Object.keys(clocks).forEach(cityKey => { + const weather = clocks[cityKey].weather || 'clear'; + createWeatherEffect(cityKey, weather); + }); + } + + // Fetch weather from yr.no API + async function fetchWeatherFromYr(latitude, longitude) { + try { + const url = `https://api.met.no/weatherapi/nowcast/2.0/complete?lat=${latitude}&lon=${longitude}`; + const response = await fetch(url, { + headers: { + 'User-Agent': 'WebClock/1.0 (Educational Project)' + } + }); + + if (!response.ok) { + throw new Error(`Weather API error: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error('Error fetching weather:', error); + return null; + } + } + + // Map yr.no weather symbols to our weather types + // Reference: https://github.com/metno/weathericons/tree/main/weather + function mapWeatherSymbol(symbolCode) { + if (!symbolCode) return 'clear'; + + // Remove suffix to get base code + const code = symbolCode.toLowerCase(); + const baseCode = code.replace(/_day|_night|_polartwilight/g, ''); + + // Check for day/night/polartwilight suffix + const isNight = code.endsWith('_night') || code.endsWith('_polartwilight'); + + // Map all 41 weather codes to animations + switch(baseCode) { + // Clear sky + case 'clearsky': + return isNight ? 'moon' : 'sun'; + + // Fair weather (sun/moon + clouds) + case 'fair': + return isNight ? 'moon+cloudy' : 'sun+cloudy'; + + // Partly cloudy + case 'partlycloudy': + return isNight ? 'moon+cloudy' : 'sun+cloudy'; + + // Cloudy + case 'cloudy': + return 'cloudy'; + + // Light rain + case 'lightrainshowers': + return isNight ? 'moon+rain' : 'sun+rain'; + case 'lightrainshowersandthunder': + return isNight ? 'moon+rain+lightning' : 'sun+rain+lightning'; + + case 'lightrain': + return 'rain'; + + case 'lightrainandthunder': + return 'rain+lightning'; + + // Rain showers + case 'rainshowers': + return isNight ? 'moon+rain' : 'sun+rain'; + case 'rainshowersandthunder': + return isNight ? 'moon+rain+lightning' : 'sun+rain+lightning'; + + // Rain + case 'rain': + return 'rain'; + + case 'rainandthunder': + return 'rain+lightning'; + + // Heavy rain + case 'heavyrainshowers': + return isNight ? 'moon+rain' : 'sun+rain'; + case 'heavyrainshowersandthunder': + return isNight ? 'moon+rain+lightning' : 'sun+rain+lightning'; + + case 'heavyrain': + return 'rain'; + + case 'heavyrainandthunder': + return 'rain+lightning'; + + // Light sleet + case 'lightsleetshowers': + return isNight ? 'moon+snow' : 'sun+snow'; + case 'lightssleetshowersandthunder': + return isNight ? 'moon+snow+lightning' : 'sun+snow+lightning'; + + case 'lightsleet': + return 'snow'; + + case 'lightsleetandthunder': + return 'snow+lightning'; + + // Sleet showers + case 'sleetshowers': + return isNight ? 'moon+snow' : 'sun+snow'; + case 'sleetshowersandthunder': + return isNight ? 'moon+snow+lightning' : 'sun+snow+lightning'; + + // Sleet + case 'sleet': + return 'snow'; + + case 'sleetandthunder': + return 'snow+lightning'; + + // Heavy sleet + case 'heavysleetshowers': + return isNight ? 'moon+snow' : 'sun+snow'; + case 'heavysleetshowersandthunder': + return isNight ? 'moon+snow+lightning' : 'sun+snow+lightning'; + + case 'heavysleet': + return 'snow'; + + case 'heavysleetandthunder': + return 'snow+lightning'; + + // Light snow + case 'lightsnowshowers': + return isNight ? 'moon+snow' : 'sun+snow'; + case 'lightssnowshowersandthunder': + return isNight ? 'moon+snow+lightning' : 'sun+snow+lightning'; + + case 'lightsnow': + return 'snow'; + + case 'lightsnowandthunder': + return 'snow+lightning'; + + // Snow showers + case 'snowshowers': + return isNight ? 'moon+snow' : 'sun+snow'; + case 'snowshowersandthunder': + return isNight ? 'moon+snow+lightning' : 'sun+snow+lightning'; + + // Snow + case 'snow': + return 'snow'; + + case 'snowandthunder': + return 'snow+lightning'; + + // Heavy snow + case 'heavysnowshowers': + return isNight ? 'moon+snow' : 'sun+snow'; + case 'heavysnowshowersandthunder': + return isNight ? 'moon+snow+lightning' : 'sun+snow+lightning'; + + case 'heavysnow': + return 'snow'; + + case 'heavysnowandthunder': + return 'snow+lightning'; + + // Fog + case 'fog': + return 'fog'; + + // Default fallback + default: + console.log(`Unknown weather code: ${symbolCode}`); + return 'clear'; + } + } + + // Update weather for a specific city + async function updateCityWeather(cityKey) { + const config = clocks[cityKey]; + if (!config.latitude || !config.longitude) { + console.log(`No coordinates for ${cityKey}`); + return; + } + + const weatherData = await fetchWeatherFromYr(config.latitude, config.longitude); + console.log('Raw weather data:', weatherData); + if (weatherData && weatherData.properties && weatherData.properties.timeseries) { + const currentWeather = weatherData.properties.timeseries[0]; + + // Nowcast API provides symbol_code in next_1_hours + const symbolCode = currentWeather.data.next_1_hours?.summary?.symbol_code; + + if (symbolCode) { + const weatherType = mapWeatherSymbol(symbolCode); + console.log(`Weather for ${config.cityName}: ${symbolCode} -> ${weatherType}`); + + // Update the weather type + clocks[cityKey].weather = weatherType; + + // Update the animation + createWeatherEffect(cityKey, weatherType); + } + } + } + + // Update weather for all cities + async function updateAllWeather() { + for (const cityKey of Object.keys(clocks)) { + await updateCityWeather(cityKey); + } + } + // Theme system const themes = { halloween: { @@ -711,6 +1370,15 @@ // Check for theme change every hour setInterval(applyTheme, 1000 * 60 * 60); + // Initialize weather effects with default values first + initializeWeather(); + + // Fetch real weather data from yr.no + updateAllWeather(); + + // Update weather every 10 minutes (yr.no recommends not more frequent than every 10 minutes) + setInterval(updateAllWeather, 1000 * 60 * 10); + // Update all clocks immediately updateAllClocks();