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();