From 60e41c2cc4aa674e2d6ec5612f29ec1c74b2c5cd Mon Sep 17 00:00:00 2001 From: kyle Date: Sun, 15 Feb 2026 19:12:08 +0100 Subject: [PATCH] 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 --- README.md | 45 ++++++-- config/sites.json | 2 +- index.html | 14 ++- public/css/components.css | 120 +++++++++++++++++++++- public/css/main.css | 110 +++++++++++++++++--- public/js/components/DeparturesManager.js | 48 ++++----- public/js/components/NewsTicker.js | 84 +++++++++++++++ public/js/components/WeatherManager.js | 52 ++++++++++ public/js/main.js | 4 + 9 files changed, 421 insertions(+), 58 deletions(-) create mode 100644 public/js/components/NewsTicker.js diff --git a/README.md b/README.md index 4848116..80713a9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A modern digital signage system for displaying real-time transit departures and ![SL Transport Departures Display](screenshots/main-display.png) -> **Note**: Screenshots should be added to the `screenshots/` directory. The application displays real-time transit departures in a modern, optimized layout. +A comprehensive digital signage solution displaying real-time transit departures, weather information, and a visual daylight hours timeline. ## Features @@ -21,6 +21,7 @@ A modern digital signage system for displaying real-time transit departures and - Current weather conditions with icons - Hourly forecast (8-hour outlook) - Sunrise/sunset times +- **Daylight Hours Bar**: Visual 24-hour timeline at the bottom showing sunrise to sunset with animated sun/moon indicator - Automatic dark mode based on time of day ### ⚙️ Flexible Configuration @@ -33,6 +34,8 @@ A modern digital signage system for displaying real-time transit departures and ### 🎨 Modern Design - Swedish color scheme (blue/yellow gradient clock banner) - Compact ribbon-style clock with time and date on one line +- **Daylight Hours Bar**: Modern gradient bar with smooth color transitions from midnight blue (night) through orange/red (dawn/dusk) to yellow (day) +- Animated sun/moon icon with pulsing glow effect - Optimized spacing and typography for readability - Responsive design that adapts to screen size - Smooth animations and visual effects @@ -40,14 +43,33 @@ A modern digital signage system for displaying real-time transit departures and ## Screenshots ### Main Display (Landscape Layout) -![Landscape Layout](screenshots/landscape-layout.png) +![Main Display](screenshots/main-display.png) -> **Screenshot**: The system automatically optimizes for landscape orientation with a 4-column grid layout, maximizing screen real estate while maintaining readability. Add your screenshot to `screenshots/landscape-layout.png`. +The system automatically optimizes for landscape orientation with a 4-column grid layout, maximizing screen real estate while maintaining readability. The display includes: -### Key Features Highlighted -- **Clock Banner**: Compact ribbon-style header with time and date -- **Departure Cards**: Color-coded boxes with transport icons, line numbers, and direction arrows -- **Weather Widget**: Fixed at bottom with current conditions and hourly forecast +- **Clock Banner**: Compact ribbon-style header with time and date in Swedish locale +- **Departure Cards**: Color-coded boxes with transport icons, line numbers, and direction arrows showing real-time transit information +- **Weather Widget**: Compact widget with current conditions, hourly forecast, and sunrise/sunset times +- **Daylight Hours Bar**: 24-hour visual timeline at the bottom showing daylight hours with animated sun/moon indicator + +### Settings Panel +![Settings Modal](screenshots/settings-modal.png) + +Comprehensive configuration panel accessible via the gear icon (⚙️) with tabs for: +- **Display**: Screen orientation and dark mode settings +- **Appearance**: Background image and opacity controls +- **Content**: Transit stop management with map-based selection +- **Options**: Additional system preferences + +### Daylight Hours Bar +![Daylight Hours Bar](screenshots/daylight-hours-bar.png) + +The daylight hours bar provides a beautiful visual representation of the day: +- Modern gradient with smooth color transitions (midnight blue → orange/red → yellow) +- Animated sun icon (☀️) during daylight hours +- Animated moon icon (🌙) during nighttime hours +- Pulsing glow effect to draw attention +- Updates every minute to track the current time position ## Quick Start @@ -261,6 +283,15 @@ Settings are automatically saved to localStorage and persist across sessions. ## Recent Updates +### Version 1.2.0 - Daylight Hours Bar & UI Refinements +- ✅ **New Feature**: Daylight Hours Bar - Visual 24-hour timeline with modern gradient shading +- ✅ Animated sun/moon icon indicator with pulsing glow effect +- ✅ Enhanced gradient transitions (midnight blue → orange/red → yellow) +- ✅ Improved weather widget layout and alignment +- ✅ Better spacing and positioning for 1080p kiosk displays +- ✅ Moon icon automatically shown during nighttime hours +- ✅ Perfectly centered icon indicators with flexbox alignment + ### Version 1.1.0 - Landscape Optimization - ✅ Optimized 4-column grid layout for landscape screens - ✅ Replaced text labels with transport mode icons diff --git a/config/sites.json b/config/sites.json index 0921c11..53e6148 100644 --- a/config/sites.json +++ b/config/sites.json @@ -1,5 +1,5 @@ { - "orientation": "normal", + "orientation": "landscape", "darkMode": "auto", "backgroundImage": "https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000", "backgroundOpacity": 0.45, diff --git a/index.html b/index.html index f4ad34c..c868677 100644 --- a/index.html +++ b/index.html @@ -29,9 +29,10 @@
- - - + + +
+
@@ -114,8 +115,13 @@
5.5 °C
+ + - + + +
+
diff --git a/public/css/components.css b/public/css/components.css index 8044405..e67efe7 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -825,7 +825,20 @@ body.dark-mode .countdown-large.now { text-shadow: 0 0 8px #4ecdc4, 0 0 12px #4ecdc4, 0 0 16px rgba(78, 205, 196, 0.9); } -/* Time range */ +/* Soon tier (3-5 min) - orange */ +.countdown-large.soon { + color: var(--color-soon); + animation: pulse-glow 3s ease-in-out infinite; + text-shadow: 0 0 6px #e67e22, 0 0 10px rgba(230, 126, 34, 0.7); +} + +body.dark-mode .countdown-large.soon, +body.landscape .countdown-large.soon { + color: var(--color-soon-dark); + text-shadow: 0 0 8px #f39c12, 0 0 12px #f39c12, 0 0 16px rgba(243, 156, 18, 0.8); +} + +/* Time range (legacy) */ .time-range { font-size: 0.85em; color: var(--color-text-muted); @@ -837,6 +850,18 @@ body.dark-mode .time-range { color: var(--color-text-muted-dark); } +/* Next departures (replaces time-range) */ +.next-departures { + font-size: 0.8em; + color: var(--color-text-muted); + font-weight: 500; + white-space: nowrap; +} + +body.dark-mode .next-departures { + color: var(--color-text-muted-dark); +} + /* Weather widget styles */ .weather-container { margin: 20px 0; @@ -1091,6 +1116,86 @@ body.vertical-reverse #custom-weather { z-index: 1; } +/* Landscape: Prominent site headers */ +body.landscape .site-container { + display: contents; +} + +body.landscape .site-header { + grid-column: 1 / -1; + margin-bottom: 2px; +} + +body.landscape .site-name { + display: block; + background: linear-gradient(135deg, #0061a1 0%, #003d66 100%); + color: #fff; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + padding: 6px 16px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 97, 161, 0.4); + font-size: 0.85em; +} + +/* Compact weather bar - hidden by default, shown in landscape */ +#compact-weather-bar { + display: none; +} + +body.landscape #compact-weather-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + background: rgba(0, 0, 0, 0.5); + color: #ddd; + padding: 6px 20px; + border-radius: 4px; + font-size: 0.95em; + white-space: nowrap; +} + +#compact-weather-bar .weather-bar-icon { + width: 28px; + height: 28px; + vertical-align: middle; +} + +#compact-weather-bar .weather-bar-sep { + opacity: 0.4; +} + +/* News ticker - hidden by default, shown in landscape */ +#news-ticker { + display: none; +} + +body.landscape #news-ticker { + display: block; + overflow: hidden; + background: var(--ticker-bg); + height: var(--ticker-height); + line-height: var(--ticker-height); + border-radius: 4px; + position: relative; +} + +#news-ticker .ticker-content { + display: inline-block; + white-space: nowrap; + animation: ticker-scroll var(--ticker-speed) linear infinite; + color: #ddd; + font-size: 0.9em; + padding-left: 100%; +} + +@keyframes ticker-scroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + .error { color: red; text-align: center; @@ -1138,6 +1243,12 @@ body.vertical-reverse #custom-weather { text-shadow: 0 0 8px rgba(255, 215, 0, 0.6); } +/* Landscape: daylight bar sits in grid instead of fixed overlay */ +body.landscape #daylight-hours-bar { + position: relative; + z-index: auto; +} + /* Dark mode adjustments for daylight bar */ body.dark-mode #daylight-hours-bar .daylight-bar-background { background-color: #0a0a2e; @@ -1560,7 +1671,12 @@ body.dark-mode .time-range { /* Reduced motion preference */ @media (prefers-reduced-motion: reduce) { .countdown-large.urgent, - .countdown-large.now { + .countdown-large.now, + .countdown-large.soon { + animation: none; + } + + #news-ticker .ticker-content { animation: none; } diff --git a/public/css/main.css b/public/css/main.css index a22ed13..a6e8c93 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -14,6 +14,8 @@ --color-text-muted-dark: #aaa; --color-urgent: #c41e3a; --color-urgent-dark: #ff6b6b; + --color-soon: #e67e22; + --color-soon-dark: #f39c12; --color-now: #00a651; --color-now-dark: #4ecdc4; --color-border: #ddd; @@ -27,6 +29,11 @@ --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1); --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2); --gradient-blue: linear-gradient(135deg, #0061a1 0%, #004d80 100%); + --kiosk-gap: 8px; + --kiosk-countdown-size: 2em; + --ticker-height: 36px; + --ticker-speed: 30s; + --ticker-bg: rgba(0, 0, 0, 0.85); } /* ======================================== @@ -138,17 +145,24 @@ body.normal .departure-container { } /* ======================================== - Orientation: Landscape + Orientation: Landscape (Kiosk Mode) ======================================== */ body.landscape { max-width: 100%; - padding: 20px 40px; + padding: 10px 20px 0 20px; + background-color: #1a1a2e; + color: var(--color-text-light); +} + +/* Hide background overlay in landscape for maximum contrast */ +body.landscape #background-overlay { + display: none !important; } body.landscape #content-wrapper { display: grid; - grid-template-rows: auto 1fr; - gap: 20px; + grid-template-rows: auto auto 1fr auto auto; + gap: var(--kiosk-gap); height: 100vh; max-height: 100vh; overflow: hidden; @@ -159,23 +173,32 @@ body.landscape .clock-container { margin-bottom: 0; } -body.landscape .main-content-grid { +/* Compact weather bar sits in row 2 */ +body.landscape #compact-weather-bar { grid-row: 2; - display: grid; - grid-template-columns: 1fr 380px; - gap: 20px; +} + +body.landscape .main-content-grid { + grid-row: 3; + display: block; overflow: hidden; min-height: 0; } +/* Hide the full weather widget in landscape */ +body.landscape .weather-section { + display: none; +} + body.landscape .departure-container { display: grid; - grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); - gap: 15px; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: var(--kiosk-gap); overflow-y: auto; overflow-x: hidden; - padding-right: 10px; + padding-right: 4px; min-height: 0; + height: 100%; } body.landscape .weather-container { @@ -188,12 +211,14 @@ body.landscape .weather-container { } body.landscape .departure-card { - min-height: 120px; + min-height: 65px; + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.06); } body.landscape .line-number-box { - min-width: 120px; - width: 120px; + min-width: 90px; + width: 90px; } body.landscape .line-number-large { @@ -201,12 +226,65 @@ body.landscape .line-number-large { } body.landscape .site-container { - margin-bottom: 15px; + margin-bottom: 6px; } body.landscape .site-header { font-size: 1em; - padding: 8px 12px; + padding: 0; + margin-bottom: 6px; +} + +/* News ticker sits in row 4 */ +body.landscape #news-ticker { + grid-row: 4; +} + +/* Daylight bar sits in row 5 */ +body.landscape #daylight-hours-bar { + grid-row: 5; +} + +/* Dark card surfaces for landscape */ +body.landscape .direction-destination { + color: var(--color-text-light); +} + +body.landscape .countdown-large { + color: var(--color-text-light); +} + +body.landscape .time-range, +body.landscape .next-departures { + color: #bbb; +} + +/* Tighter card spacing in landscape */ +body.landscape .directions-wrapper { + padding: 4px 8px; + gap: 2px; +} + +body.landscape .direction-row { + min-height: 28px; + gap: 6px; +} + +/* Hero countdown in landscape */ +body.landscape .countdown-large { + font-size: var(--kiosk-countdown-size); +} + +body.landscape .times-container { + min-width: 130px; + max-width: 160px; +} + +body.landscape .next-departures { + font-size: 0.7em; + color: #bbb; + white-space: nowrap; + letter-spacing: 0.5px; } /* ======================================== diff --git a/public/js/components/DeparturesManager.js b/public/js/components/DeparturesManager.js index 471d01f..833b864 100644 --- a/public/js/components/DeparturesManager.js +++ b/public/js/components/DeparturesManager.js @@ -222,20 +222,16 @@ class DeparturesManager { const minutesUntil = this.calculateMinutesUntilArrival(departure); let countdownText = displayTime; let countdownClass = ''; - - const urgentThreshold = window.Constants?.TIME_THRESHOLDS?.URGENT || 5; - + if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') { countdownText = 'Nu'; countdownClass = 'now'; - } else if (minutesUntil < urgentThreshold) { - const minMatch = displayTime.match(/(\d+)\s*min/i); - if (minMatch) { - countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`; - } else { - countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`; - } + } else if (minutesUntil <= 2) { + countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`; countdownClass = 'urgent'; + } else if (minutesUntil <= 5) { + countdownText = `${minutesUntil} min`; + countdownClass = 'soon'; } else { const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime); if (isTimeOnly) { @@ -244,7 +240,7 @@ class DeparturesManager { countdownText = displayTime; } } - + return { countdownText, countdownClass }; } @@ -381,15 +377,13 @@ class DeparturesManager { timeDisplayElement.appendChild(countdownSpan); - const timeRangeSpan = document.createElement('span'); - timeRangeSpan.className = 'time-range'; - if (secondDeparture) { - const secondTime = DeparturesManager.formatDateTime(this.getDepartureTime(secondDeparture)); - timeRangeSpan.textContent = `${timeDisplay} - ${secondTime}`; - } else { - timeRangeSpan.textContent = timeDisplay; - } - timeDisplayElement.appendChild(timeRangeSpan); + // Show next 2-3 absolute times as small text + const nextTimesSpan = document.createElement('span'); + nextTimesSpan.className = 'next-departures'; + const upcomingTimes = direction.departures.slice(0, 3) + .map(d => DeparturesManager.formatDateTime(this.getDepartureTime(d))); + nextTimesSpan.textContent = upcomingTimes.join(' '); + timeDisplayElement.appendChild(nextTimesSpan); timesContainer.appendChild(timeDisplayElement); } @@ -481,16 +475,14 @@ class DeparturesManager { updateCardContent(card, departure) { const { countdownText, countdownClass } = this.getCountdownInfo(departure); const countdownElement = card.querySelector('.countdown'); - + if (countdownElement) { - countdownElement.classList.remove('now', 'urgent'); - - if (countdownClass === 'now') { - countdownElement.classList.add('now'); - } else if (countdownClass === 'urgent') { - countdownElement.classList.add('urgent'); + countdownElement.classList.remove('now', 'urgent', 'soon'); + + if (countdownClass) { + countdownElement.classList.add(countdownClass); } - + if (countdownElement.textContent !== `(${countdownText})`) { countdownElement.textContent = `(${countdownText})`; this.highlightElement(countdownElement); diff --git a/public/js/components/NewsTicker.js b/public/js/components/NewsTicker.js new file mode 100644 index 0000000..6d93a4d --- /dev/null +++ b/public/js/components/NewsTicker.js @@ -0,0 +1,84 @@ +/** + * NewsTicker - Scrolling news/announcement ticker for landscape kiosk mode + * Fetches from /api/ticker with fallback to hardcoded messages + */ + +class NewsTicker { + constructor(options = {}) { + this.options = { + containerId: options.containerId || 'news-ticker', + fetchUrl: options.fetchUrl || '/api/ticker', + refreshInterval: options.refreshInterval || 5 * 60 * 1000, // 5 minutes + fallbackMessages: options.fallbackMessages || [ + 'Välkommen till Ambassaderna', + 'Håll dörren stängd', + 'Tvättstugan stänger kl 22:00' + ], + ...options + }; + + this.container = null; + this.contentEl = null; + this.messages = []; + this.refreshTimer = null; + + this.init(); + } + + init() { + this.container = document.getElementById(this.options.containerId); + if (!this.container) return; + + this.contentEl = this.container.querySelector('.ticker-content'); + if (!this.contentEl) { + this.contentEl = document.createElement('div'); + this.contentEl.className = 'ticker-content'; + this.container.appendChild(this.contentEl); + } + + this.fetchMessages(); + this.refreshTimer = setInterval(() => this.fetchMessages(), this.options.refreshInterval); + } + + async fetchMessages() { + try { + const response = await fetch(this.options.fetchUrl); + if (response.ok) { + const data = await response.json(); + if (Array.isArray(data.messages) && data.messages.length > 0) { + this.messages = data.messages; + } else { + this.messages = this.options.fallbackMessages; + } + } else { + this.messages = this.options.fallbackMessages; + } + } catch { + this.messages = this.options.fallbackMessages; + } + + this.render(); + } + + render() { + if (!this.contentEl || this.messages.length === 0) return; + + const separator = ' \u2022 '; // bullet separator + const text = this.messages.join(separator); + // Duplicate text for seamless infinite scroll loop + this.contentEl.textContent = text + separator + text; + } + + stop() { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } +} + +// ES module export +export { NewsTicker }; + +// Keep window reference for backward compatibility +window.NewsTicker = NewsTicker; diff --git a/public/js/components/WeatherManager.js b/public/js/components/WeatherManager.js index e560390..7058da4 100644 --- a/public/js/components/WeatherManager.js +++ b/public/js/components/WeatherManager.js @@ -371,10 +371,62 @@ class WeatherManager { 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 diff --git a/public/js/main.js b/public/js/main.js index 2f47a0c..a7ad688 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -9,6 +9,7 @@ import { ConfigManager } from './components/ConfigManager.js'; import { Clock } from './components/Clock.js'; import { WeatherManager } from './components/WeatherManager.js'; import { DeparturesManager } from './components/DeparturesManager.js'; +import { NewsTicker } from './components/NewsTicker.js'; /** * Function to ensure content wrapper exists for rotated orientations @@ -77,6 +78,9 @@ document.addEventListener('DOMContentLoaded', async function() { lastUpdatedId: 'last-updated' }); + // Initialize NewsTicker (visible in landscape mode only via CSS) + window.newsTicker = new NewsTicker(); + // Set up event listeners document.addEventListener('darkModeChanged', event => { document.body.classList.toggle('dark-mode', event.detail.isDarkMode);