Files
SignageHTML/public/js/components/WeatherManager.js
kyle 60e41c2cc4 Kiosk UI/UX overhaul: dark landscape mode with hero countdowns and full-width layout
Redesign the landscape orientation for kiosk readability at 3-10m distance:

- Add dark kiosk background (#1a1a2e) with high-contrast light text
- Replace 2-column grid with 5-row full-width stacking layout
- Add compact weather bar (temp + sunrise/sunset) replacing full widget
- Enlarge countdown to 2em hero size in landscape
- Replace time ranges with next 2-3 absolute departure times
- Add 3-tier urgency colors: Nu (green), 1-2min (red), 3-5min (orange)
- Make site headers full-width blue gradient bars in landscape
- Tighten card spacing (65px min-height, 8px gap) for 4-stop visibility
- Add scrolling news ticker with /api/ticker fallback messages
- Fix daylight bar from position:fixed to relative in landscape grid
- Hide background overlay in landscape for maximum contrast
- Fix weather-section HTML missing closing div tags

All changes scoped behind body.landscape CSS selectors; other orientations unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:12:08 +01:00

738 lines
28 KiB
JavaScript

/**
* weather.js - A module for weather-related functionality
* Provides real-time weather data and sunset/sunrise information
* Uses OpenWeatherMap API for weather data
*/
class WeatherManager {
constructor(options = {}) {
// Default options
// Get API key from options, window (injected by server from .env), or fallback
const apiKey = options.apiKey || window.OPENWEATHERMAP_API_KEY || '';
this.options = {
latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude
longitude: options.longitude || (window.DEFAULT_LOCATION?.longitude) || 18.0686, // Stockholm longitude
apiKey: apiKey, // OpenWeatherMap API key (from .env via server injection, or fallback)
refreshInterval: options.refreshInterval || 30 * 60 * 1000, // 30 minutes in milliseconds
...options
};
// State
this.weatherData = null;
this.forecastData = null;
this.sunTimes = null;
this.isDarkMode = false;
this.lastUpdated = null;
this.daylightBarUpdateInterval = null;
// Initialize
this.init();
}
/**
* Initialize the weather manager
*/
async init() {
try {
// Check for API key
if (!this.options.apiKey) {
console.warn('WeatherManager: No OpenWeatherMap API key configured. Set OPENWEATHERMAP_API_KEY in your .env file.');
const weatherContainer = document.getElementById('custom-weather');
if (weatherContainer) {
const warningEl = document.createElement('div');
warningEl.style.cssText = 'padding: 10px; color: #c41e3a; font-size: 0.9em; text-align: center;';
warningEl.textContent = 'Weather unavailable: No API key configured. Set OPENWEATHERMAP_API_KEY in .env';
weatherContainer.prepend(warningEl);
}
// Still set up sun times from calculation so dark mode works
await this.updateSunTimesFromCalculation();
this.updateDarkModeBasedOnTime();
this.dispatchDarkModeEvent();
return;
}
// Fetch weather data
await this.fetchWeatherData();
// Check if it's dark outside (only affects auto mode)
this.updateDarkModeBasedOnTime();
// Set up interval to check dark mode every minute (only affects auto mode)
this.darkModeCheckInterval = setInterval(() => {
// Only update dark mode based on time if ConfigManager has dark mode set to 'auto'
if (this.shouldUseAutoDarkMode()) {
this.updateDarkModeBasedOnTime();
}
}, 60000);
// Set up interval to refresh weather data
setInterval(() => this.fetchWeatherData(), this.options.refreshInterval);
// Dispatch initial dark mode state
this.dispatchDarkModeEvent();
console.log('WeatherManager initialized');
} catch (error) {
console.error('Error initializing WeatherManager:', error);
// Fallback to calculated sun times if API fails
await this.updateSunTimesFromCalculation();
this.updateDarkModeBasedOnTime();
this.dispatchDarkModeEvent();
}
}
/**
* Check if we should use automatic dark mode based on ConfigManager settings
*/
shouldUseAutoDarkMode() {
// If there's a ConfigManager instance with a config
if (window.configManager && window.configManager.config) {
// Only use auto dark mode if the setting is 'auto'
return window.configManager.config.darkMode === 'auto';
}
// Default to true if no ConfigManager is available
return true;
}
/**
* Fetch weather data from OpenWeatherMap API
*/
async fetchWeatherData() {
try {
// Fetch current weather (lang=se for Swedish descriptions)
const currentWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&lang=se&appid=${this.options.apiKey}`;
const currentWeatherResponse = await fetch(currentWeatherUrl);
const currentWeatherData = await currentWeatherResponse.json();
if (currentWeatherData.cod !== 200) {
throw new Error(`API Error: ${currentWeatherData.message}`);
}
// Fetch 3-hour interval forecast (cnt=8 limits to ~24h of data)
const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&lang=se&cnt=8&appid=${this.options.apiKey}`;
const forecastResponse = await fetch(forecastUrl);
const forecastData = await forecastResponse.json();
if (forecastData.cod !== "200") {
throw new Error(`API Error: ${forecastData.message}`);
}
// Process and store the data
this.weatherData = this.processCurrentWeather(currentWeatherData);
this.forecastData = this.processForecast(forecastData);
this.lastUpdated = new Date();
// Extract sunrise and sunset times from the API response
await this.updateSunTimesFromApi(currentWeatherData);
// Update the UI with the new data
this.updateWeatherUI();
console.log('Weather data updated:', this.weatherData);
return this.weatherData;
} catch (error) {
console.error('Error fetching weather data:', error);
// If we don't have any weather data yet, create some default data
if (!this.weatherData) {
this.weatherData = this.createDefaultWeatherData();
this.forecastData = this.createDefaultForecastData();
}
// Fallback to calculated sun times
await this.updateSunTimesFromCalculation();
return this.weatherData;
}
}
/**
* Process current weather data from API response
*/
processCurrentWeather(data) {
const iconCode = data.weather[0].icon;
return {
temperature: Math.round(data.main.temp * 10) / 10, // Round to 1 decimal place
condition: data.weather[0].main,
description: data.weather[0].description,
icon: this.getWeatherIconUrl(iconCode),
iconCode: iconCode, // Store icon code for classification
wind: {
speed: Math.round(data.wind.speed * 3.6), // Convert m/s to km/h
direction: data.wind.deg
},
humidity: data.main.humidity,
pressure: data.main.pressure,
precipitation: data.rain ? (data.rain['1h'] || 0) : 0,
location: data.name,
country: data.sys.country,
timestamp: new Date(data.dt * 1000)
};
}
/**
* Process forecast data from API response
*/
processForecast(data) {
// Get the next 7 forecast periods (3-hour intervals, covering ~21 hours)
return data.list.slice(0, 7).map(item => {
const iconCode = item.weather[0].icon;
return {
temperature: Math.round(item.main.temp * 10) / 10,
condition: item.weather[0].main,
description: item.weather[0].description,
icon: this.getWeatherIconUrl(iconCode),
iconCode: iconCode, // Store icon code for classification
timestamp: new Date(item.dt * 1000),
precipitation: item.rain ? (item.rain['3h'] || 0) : 0
};
});
}
/**
* Get weather icon URL from icon code
*/
getWeatherIconUrl(iconCode) {
return `https://openweathermap.org/img/wn/${iconCode}@4x.png`;
}
/**
* Classify a weather icon and return the appropriate CSS classes
* @param {string} iconCode - OWM icon code (e.g. '01d', '13n')
* @param {string} condition - Weather condition text (e.g. 'Clear', 'Clouds')
* @returns {string[]} Array of CSS class names to apply
*/
classifyWeatherIcon(iconCode, condition) {
const code = iconCode ? iconCode.replace(/[dn]$/, '') : '';
// Snow: icon 13x or condition contains 'Snow'
if (code === '13' || condition.includes('Snow')) {
return ['weather-snow'];
}
// Clear sun: icon 01x or condition is exactly 'Clear'
if (code === '01' || condition === 'Clear') {
return ['weather-sun', 'weather-clear-sun'];
}
// Sun behind clouds: icon 02-04x or cloudy condition
if (['02', '03', '04'].includes(code) || (condition.includes('Clouds') && !condition.includes('Clear'))) {
return ['weather-sun', 'weather-clouds-sun'];
}
return [];
}
/**
* Apply weather icon CSS classes to an element
*/
applyWeatherIconClasses(element, iconCode, condition) {
element.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun');
const classes = this.classifyWeatherIcon(iconCode, condition);
if (classes.length > 0) {
element.classList.add(...classes);
}
}
/**
* Create default weather data for fallback
*/
createDefaultWeatherData() {
return {
temperature: 7.1,
condition: 'Clear',
description: 'clear sky',
icon: 'https://openweathermap.org/img/wn/01d@4x.png',
iconCode: '01d',
wind: {
speed: 14.8,
direction: 270
},
humidity: 65,
pressure: 1012.0,
precipitation: 0.00,
location: 'Stockholm',
country: 'SE',
timestamp: new Date()
};
}
/**
* Create default forecast data for fallback
*/
createDefaultForecastData() {
const now = new Date();
const forecasts = [];
// Create 7 forecast entries
for (let i = 0; i < 7; i++) {
const forecastTime = new Date(now);
forecastTime.setHours(now.getHours() + i);
forecasts.push({
temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour
condition: i < 2 ? 'Clear' : 'Clouds',
description: i < 2 ? 'clear sky' : 'few clouds',
icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@4x.png' : 'https://openweathermap.org/img/wn/02n@4x.png',
iconCode: i < 2 ? '01n' : '02n',
timestamp: forecastTime,
precipitation: 0
});
}
return forecasts;
}
/**
* Update the weather UI with current data
*/
updateWeatherUI() {
if (!this.weatherData || !this.forecastData) return;
try {
// Update current weather
const locationElement = document.querySelector('#custom-weather h3');
if (locationElement) {
locationElement.textContent = this.weatherData.location;
}
const conditionElement = document.querySelector('#custom-weather .weather-icon div');
if (conditionElement) {
conditionElement.textContent = this.weatherData.condition;
}
const iconElement = document.querySelector('#custom-weather .weather-icon img');
if (iconElement) {
iconElement.src = this.weatherData.icon;
iconElement.alt = this.weatherData.description;
iconElement.setAttribute('data-condition', this.weatherData.condition);
this.applyWeatherIconClasses(iconElement, this.weatherData.iconCode, this.weatherData.condition);
}
const temperatureElement = document.querySelector('#custom-weather .temperature');
if (temperatureElement) {
temperatureElement.textContent = `${this.weatherData.temperature} °C`;
}
// Update forecast
const forecastContainer = document.querySelector('#custom-weather .forecast');
if (forecastContainer) {
// Clear existing forecast
forecastContainer.innerHTML = '';
// Add current weather as "Nu" (Swedish for "Now")
const nowElement = document.createElement('div');
nowElement.className = 'forecast-hour';
const nowIcon = document.createElement('img');
nowIcon.src = this.weatherData.icon;
nowIcon.alt = this.weatherData.description;
nowIcon.width = 56;
nowIcon.setAttribute('data-condition', this.weatherData.condition);
this.applyWeatherIconClasses(nowIcon, this.weatherData.iconCode, this.weatherData.condition);
nowElement.innerHTML = `
<div class="time">Nu</div>
<div class="icon"></div>
<div class="temp">${this.weatherData.temperature} °C</div>
`;
nowElement.querySelector('.icon').appendChild(nowIcon);
forecastContainer.appendChild(nowElement);
// Add hourly forecasts
this.forecastData.forEach(forecast => {
const forecastTime = forecast.timestamp;
const timeString = forecastTime.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
const forecastElement = document.createElement('div');
forecastElement.className = 'forecast-hour';
const forecastIcon = document.createElement('img');
forecastIcon.src = forecast.icon;
forecastIcon.alt = forecast.description;
forecastIcon.width = 56;
forecastIcon.setAttribute('data-condition', forecast.condition);
this.applyWeatherIconClasses(forecastIcon, forecast.iconCode, forecast.condition);
forecastElement.innerHTML = `
<div class="time">${timeString}</div>
<div class="icon"></div>
<div class="temp">${forecast.temperature} °C</div>
`;
forecastElement.querySelector('.icon').appendChild(forecastIcon);
forecastContainer.appendChild(forecastElement);
});
}
// Update sun times
const sunTimesElement = document.querySelector('#custom-weather .sun-times');
if (sunTimesElement && this.sunTimes) {
const sunriseTime = this.formatTime(this.sunTimes.today.sunrise);
const sunsetTime = this.formatTime(this.sunTimes.today.sunset);
sunTimesElement.textContent = `☀️ Sunrise: ${sunriseTime} | 🌙 Sunset: ${sunsetTime}`;
}
// Update daylight hours bar
if (this.sunTimes) {
this.updateDaylightHoursBar();
}
// Update compact weather bar (landscape mode)
this.renderCompactWeatherBar();
} catch (error) {
console.error('Error updating weather UI:', error);
}
}
/**
* Render compact weather bar for landscape mode
* Shows: [icon] temp condition | Sunrise HH:MM | Sunset HH:MM
*/
renderCompactWeatherBar() {
const bar = document.getElementById('compact-weather-bar');
if (!bar || !this.weatherData) return;
bar.textContent = '';
const icon = document.createElement('img');
icon.className = 'weather-bar-icon';
icon.src = this.weatherData.icon || '';
icon.alt = this.weatherData.condition || '';
bar.appendChild(icon);
const tempSpan = document.createElement('span');
const strong = document.createElement('strong');
strong.textContent = `${this.weatherData.temperature}\u00B0C`;
tempSpan.appendChild(strong);
tempSpan.appendChild(document.createTextNode(` ${this.weatherData.condition || ''}`));
bar.appendChild(tempSpan);
const sep1 = document.createElement('span');
sep1.className = 'weather-bar-sep';
sep1.textContent = '|';
bar.appendChild(sep1);
let sunriseStr = '--:--';
let sunsetStr = '--:--';
if (this.sunTimes) {
sunriseStr = this.formatTime(this.sunTimes.today.sunrise);
sunsetStr = this.formatTime(this.sunTimes.today.sunset);
}
const sunriseSpan = document.createElement('span');
sunriseSpan.textContent = `\u2600\uFE0F Sunrise ${sunriseStr}`;
bar.appendChild(sunriseSpan);
const sep2 = document.createElement('span');
sep2.className = 'weather-bar-sep';
sep2.textContent = '|';
bar.appendChild(sep2);
const sunsetSpan = document.createElement('span');
sunsetSpan.textContent = `\uD83C\uDF19 Sunset ${sunsetStr}`;
bar.appendChild(sunsetSpan);
}
/**
* Update sunrise and sunset times from API data
*/
async updateSunTimesFromApi(data) {
if (!data || !data.sys || !data.sys.sunrise || !data.sys.sunset) {
console.warn('No sunrise/sunset data in API response, using calculated times');
await this.updateSunTimesFromCalculation();
return;
}
try {
// Create Date objects from Unix timestamps for today
const sunrise = new Date(data.sys.sunrise * 1000);
const sunset = new Date(data.sys.sunset * 1000);
// Fetch tomorrow's times from sunrise-sunset.org API
const tomorrowTimes = await this.fetchSunTimes('tomorrow');
this.sunTimes = {
today: { sunrise, sunset },
tomorrow: tomorrowTimes
};
console.log('Sun times updated from API:', this.sunTimes);
return this.sunTimes;
} catch (error) {
console.error('Error updating sun times from API:', error);
await this.updateSunTimesFromCalculation();
}
}
/**
* Update sunrise and sunset times using sunrise-sunset.org API
* Falls back to hardcoded defaults if the API is unreachable
*/
async updateSunTimesFromCalculation() {
try {
const [todayData, tomorrowData] = await Promise.all([
this.fetchSunTimes('today'),
this.fetchSunTimes('tomorrow')
]);
this.sunTimes = {
today: todayData,
tomorrow: tomorrowData
};
console.log('Sun times updated from sunrise-sunset.org:', this.sunTimes);
return this.sunTimes;
} catch (error) {
console.error('Error fetching sun times from API, using defaults:', error);
const defaultSunrise = new Date();
defaultSunrise.setHours(7, 0, 0, 0);
const defaultSunset = new Date();
defaultSunset.setHours(16, 0, 0, 0);
this.sunTimes = {
today: { sunrise: defaultSunrise, sunset: defaultSunset },
tomorrow: { sunrise: defaultSunrise, sunset: defaultSunset }
};
return this.sunTimes;
}
}
/**
* Fetch sunrise/sunset times from sunrise-sunset.org API
* @param {string} date - 'today', 'tomorrow', or YYYY-MM-DD
* @returns {Object} { sunrise: Date, sunset: Date }
*/
async fetchSunTimes(date) {
const url = `https://api.sunrise-sunset.org/json?lat=${this.options.latitude}&lng=${this.options.longitude}&date=${date}&formatted=0`;
const response = await fetch(url);
const data = await response.json();
if (data.status !== 'OK') {
throw new Error(`Sunrise-sunset API returned status: ${data.status}`);
}
return {
sunrise: new Date(data.results.sunrise),
sunset: new Date(data.results.sunset)
};
}
/**
* Check if it's currently dark outside based on sun times
*/
isDark() {
if (!this.sunTimes) return false;
const now = new Date();
const today = this.sunTimes.today;
// Check if current time is after today's sunset or before today's sunrise
return now > today.sunset || now < today.sunrise;
}
/**
* Update dark mode state based on current time
*/
updateDarkModeBasedOnTime() {
const wasDarkMode = this.isDarkMode;
this.isDarkMode = this.isDark();
// If dark mode state changed, dispatch event
if (wasDarkMode !== this.isDarkMode) {
this.dispatchDarkModeEvent();
}
}
/**
* Set dark mode state manually
*/
setDarkMode(isDarkMode) {
if (this.isDarkMode !== isDarkMode) {
this.isDarkMode = isDarkMode;
this.dispatchDarkModeEvent();
}
}
/**
* Toggle dark mode
*/
toggleDarkMode() {
this.isDarkMode = !this.isDarkMode;
this.dispatchDarkModeEvent();
return this.isDarkMode;
}
/**
* Dispatch dark mode change event
*/
dispatchDarkModeEvent() {
const event = new CustomEvent('darkModeChanged', {
detail: {
isDarkMode: this.isDarkMode,
automatic: true
}
});
document.dispatchEvent(event);
console.log('Dark mode ' + (this.isDarkMode ? 'enabled' : 'disabled'));
}
/**
* Get formatted sunrise time
*/
getSunriseTime() {
if (!this.sunTimes) return '06:45';
return this.formatTime(this.sunTimes.today.sunrise);
}
/**
* Get formatted sunset time
*/
getSunsetTime() {
if (!this.sunTimes) return '17:32';
return this.formatTime(this.sunTimes.today.sunset);
}
/**
* Format time as HH:MM
*/
formatTime(date) {
return date.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
}
/**
* Get the last updated time
*/
getLastUpdatedTime() {
if (!this.lastUpdated) return 'Never';
return this.formatTime(this.lastUpdated);
}
/**
* Render the daylight hours bar with gradient and current hour indicator
*/
renderDaylightHoursBar() {
if (!this.sunTimes) return;
const barElement = document.getElementById('daylight-hours-bar');
const backgroundElement = barElement?.querySelector('.daylight-bar-background');
const indicatorElement = barElement?.querySelector('.daylight-bar-indicator');
if (!barElement || !backgroundElement || !indicatorElement) return;
const today = this.sunTimes.today;
// Normalize sunrise and sunset to today's date for consistent calculation
const now = new Date();
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const sunrise = new Date(todayDate);
sunrise.setHours(today.sunrise.getHours(), today.sunrise.getMinutes(), 0, 0);
const sunset = new Date(todayDate);
sunset.setHours(today.sunset.getHours(), today.sunset.getMinutes(), 0, 0);
// Calculate positions as percentage of 24 hours (1440 minutes)
// Extract hours and minutes from the date objects
const getTimePosition = (date) => {
const hours = date.getHours();
const minutes = date.getMinutes();
const totalMinutes = hours * 60 + minutes;
return (totalMinutes / 1440) * 100;
};
const sunrisePosition = getTimePosition(sunrise);
const sunsetPosition = getTimePosition(sunset);
const currentPosition = getTimePosition(now);
// Ensure positions are valid (0-100)
const clampPosition = (pos) => Math.max(0, Math.min(100, pos));
const sunrisePos = clampPosition(sunrisePosition);
const sunsetPos = clampPosition(sunsetPosition);
const currentPos = clampPosition(currentPosition);
// Create modern gradient for daylight hours with smooth transitions
// Multiple color stops for a more sophisticated gradient effect
let gradient = '';
// Handle case where sunrise is before sunset (normal day)
if (sunrisePos < sunsetPos) {
// Create gradient with smooth transitions:
// - Midnight blue (night) -> dark blue -> orange/red (dawn) -> yellow (day) -> orange/red (dusk) -> dark blue -> midnight blue (night)
const dawnStart = Math.max(0, sunrisePos - 2);
const dawnEnd = Math.min(100, sunrisePos + 1);
const duskStart = Math.max(0, sunsetPos - 1);
const duskEnd = Math.min(100, sunsetPos + 2);
gradient = `linear-gradient(to right,
#191970 0%,
#191970 ${dawnStart}%,
#2E3A87 ${dawnStart}%,
#FF6B35 ${dawnEnd}%,
#FFD93D ${Math.min(100, dawnEnd + 1)}%,
#FFEB3B ${Math.min(100, dawnEnd + 1)}%,
#FFEB3B ${duskStart}%,
#FFD93D ${duskStart}%,
#FF6B35 ${Math.max(0, duskEnd - 1)}%,
#2E3A87 ${duskEnd}%,
#191970 ${duskEnd}%,
#191970 100%)`;
} else {
// Handle edge cases (polar day/night or sunrise after sunset near midnight)
// For simplicity, show all as night (midnight blue)
gradient = 'linear-gradient(to right, #191970 0%, #191970 100%)';
}
// Apply gradient to background
backgroundElement.style.backgroundImage = gradient;
// Determine if it's day or night for icon
const isDaytime = currentPos >= sunrisePos && currentPos <= sunsetPos;
const iconElement = indicatorElement.querySelector('.sun-icon, .moon-icon');
if (iconElement) {
iconElement.textContent = isDaytime ? '☀️' : '🌙';
// Update classes to match the icon for proper styling
if (isDaytime) {
iconElement.classList.remove('moon-icon');
iconElement.classList.add('sun-icon');
} else {
iconElement.classList.remove('sun-icon');
iconElement.classList.add('moon-icon');
}
}
// Position current hour indicator
indicatorElement.style.left = `${currentPos}%`;
// Debug logging
console.log('Daylight bar positions:', {
sunrise: `${today.sunrise.getHours()}:${today.sunrise.getMinutes().toString().padStart(2, '0')}`,
sunset: `${today.sunset.getHours()}:${today.sunset.getMinutes().toString().padStart(2, '0')}`,
current: `${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`,
sunrisePos: `${sunrisePos.toFixed(1)}%`,
sunsetPos: `${sunsetPos.toFixed(1)}%`,
currentPos: `${currentPos.toFixed(1)}%`
});
}
/**
* Update daylight hours bar and set up interval for current hour updates
*/
updateDaylightHoursBar() {
// Render the bar immediately
this.renderDaylightHoursBar();
// Clear existing interval if any
if (this.daylightBarUpdateInterval) {
clearInterval(this.daylightBarUpdateInterval);
}
// Update current hour position every minute
this.daylightBarUpdateInterval = setInterval(() => {
this.renderDaylightHoursBar();
}, 60000); // Update every minute
}
}
// ES module export
export { WeatherManager };
// Keep window reference for backward compatibility
window.WeatherManager = WeatherManager;