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

-> **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)
-
+
-> **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
+
+
+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
+
+
+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 @@
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);