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 dc3eb78..c868677 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ @@ -19,18 +19,8 @@ - - - - - - - - - - - - + + @@ -39,9 +29,10 @@
- - - + + +
+
@@ -69,6 +60,9 @@
7.1 °C
+
+ β˜€οΈ Sunrise: 06:45 AM | πŸŒ™ Sunset: 05:32 PM +
@@ -121,16 +115,118 @@
5.5 °C
-
- Γ’Λœβ‚¬Γ―ΒΈΒ Sunrise: 06:45 AM | Γ°ΕΈΕ’β„’ Sunset: 05:32 PM -
+ + - -
+ + +
+ + +
+
+
+ β˜€οΈ +
+
- - - + +
+ + + + + + diff --git a/public/css/components.css b/public/css/components.css index 1f7e259..e67efe7 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -1,1242 +1,1694 @@ /* Clock styles - Modern entryway kiosk design with blue glow - Ribbon banner */ - .clock-container { - background: linear-gradient(135deg, #003366 0%, #004080 50%, #0059b3 100%); - color: white; - padding: 8px 20px; /* Reduced padding for ribbon style */ - border-radius: 8px; /* Smaller radius for ribbon */ - margin-bottom: 8px; /* Reduced margin */ - text-align: center; - box-shadow: 0 0 20px rgba(0, 89, 179, 0.6), - 0 0 40px rgba(0, 89, 179, 0.4), - 0 4px 12px rgba(0, 0, 0, 0.15), - inset 0 1px 0 rgba(255, 255, 255, 0.3); - position: relative; - overflow: hidden; - backdrop-filter: blur(10px); - transition: box-shadow 0.3s ease; - border: 2px solid rgba(255, 255, 255, 0.2); - display: flex; - align-items: center; - justify-content: center; - gap: 15px; /* Space between time and date */ - } - - .clock-container::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 50%, rgba(255, 255, 255, 0.1) 100%); - pointer-events: none; - } - - .clock-container:hover { - box-shadow: 0 0 30px rgba(0, 89, 179, 0.8), - 0 0 60px rgba(0, 89, 179, 0.5), - 0 4px 12px rgba(0, 0, 0, 0.15), - inset 0 1px 0 rgba(255, 255, 255, 0.3); - } - - body.dark-mode .clock-container { - background: linear-gradient(135deg, #001a33 0%, #002d5c 50%, #004080 100%); - box-shadow: 0 0 25px rgba(0, 89, 179, 0.7), - 0 0 50px rgba(0, 89, 179, 0.4), - 0 4px 12px rgba(0, 0, 0, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.2); - } - - body.dark-mode .clock-container:hover { - box-shadow: 0 0 35px rgba(0, 89, 179, 0.9), - 0 0 70px rgba(0, 89, 179, 0.6), - 0 4px 12px rgba(0, 0, 0, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.2); - } - - /* Configuration button styles */ - .config-button { - position: fixed; - bottom: 20px; /* Changed from top to bottom */ - right: 20px; - width: 40px; - height: 40px; - background-color: rgba(0, 97, 161, 0.3); /* Translucent by default */ - border-radius: 50%; - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - z-index: 100; - transition: transform 0.3s ease, background-color 0.3s ease, opacity 0.3s ease; - opacity: 0.5; /* Translucent */ - } - - .config-button:hover { - transform: rotate(30deg); - background-color: #0061a1; /* Full color on hover */ - opacity: 1; /* Fully opaque on hover */ - } - - .config-button svg { - transition: opacity 0.3s ease; - opacity: 0.7; /* Icon also translucent */ - } - - .config-button:hover svg { - opacity: 1; /* Full opacity on hover */ - } - - /* Configuration modal styles */ - .config-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - z-index: 200; - justify-content: center; - align-items: center; - } - - .config-modal-content { - background-color: white; - border-radius: 8px; - width: 90%; - max-width: 600px; - max-height: 90vh; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - overflow: hidden; - display: flex; - flex-direction: column; - } - - body.dark-mode .config-modal-content { - background-color: #333; - } - - .config-modal-header { - background-color: #0061a1; - color: white; - padding: 15px 20px; - display: flex; - justify-content: space-between; - align-items: center; - flex-shrink: 0; - } - - .config-modal-header h2 { - margin: 0; - font-size: 1.5em; - } - - .config-modal-close { - font-size: 1.8em; - cursor: pointer; - line-height: 1; - } - - .config-modal-close:hover { - opacity: 0.8; - } - - /* Tab navigation */ - .config-tabs { - display: flex; - background-color: #f0f0f0; - border-bottom: 2px solid #ddd; - flex-shrink: 0; - } - - body.dark-mode .config-tabs { - background-color: #444; - border-bottom-color: #555; - } - - .config-tab { - flex: 1; - padding: 12px 20px; - background-color: transparent; - border: none; - border-bottom: 3px solid transparent; - cursor: pointer; - font-size: 0.95em; - font-weight: 500; - color: #666; - transition: all 0.2s ease; - } - - body.dark-mode .config-tab { - color: #aaa; - } - - .config-tab:hover { - background-color: #e0e0e0; - color: #333; - } - - body.dark-mode .config-tab:hover { - background-color: #555; - color: #f5f5f5; - } - - .config-tab.active { - background-color: white; - color: #0061a1; - border-bottom-color: #0061a1; - font-weight: 600; - } - - body.dark-mode .config-tab.active { - background-color: #333; - color: #4fc3f7; - border-bottom-color: #4fc3f7; - } - - .config-modal-body { - padding: 20px; - overflow-y: auto; - flex: 1; - min-height: 0; - max-height: calc(90vh - 200px); - } - - /* Custom scrollbar for modal body */ - .config-modal-body::-webkit-scrollbar { - width: 8px; - } - - .config-modal-body::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 4px; - } - - .config-modal-body::-webkit-scrollbar-thumb { - background: #888; - border-radius: 4px; - } - - .config-modal-body::-webkit-scrollbar-thumb:hover { - background: #555; - } - - body.dark-mode .config-modal-body::-webkit-scrollbar-track { - background: #444; - } - - body.dark-mode .config-modal-body::-webkit-scrollbar-thumb { - background: #666; - } - - body.dark-mode .config-modal-body::-webkit-scrollbar-thumb:hover { - background: #888; - } - - .config-tab-content { - display: none; - } - - .config-tab-content.active { - display: block; - } - - .config-option { - margin-bottom: 15px; - } - - .config-option label { - display: block; - margin-bottom: 5px; - font-weight: bold; - } - - .config-option select, - .config-option input[type="text"] { - width: 100%; - padding: 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 1em; - box-sizing: border-box; - } - - body.dark-mode .config-option select, - body.dark-mode .config-option input[type="text"] { - background-color: #444; - color: #f5f5f5; - border-color: #555; - } - - .config-option input[type="range"] { - width: 100%; - } - - .config-option button { - padding: 5px 10px; - border: 1px solid #ddd; - border-radius: 4px; - background-color: #f5f5f5; - cursor: pointer; - font-size: 0.9em; - } - - body.dark-mode .config-option button { - background-color: #555; - color: #f5f5f5; - border-color: #666; - } - - .config-option button:hover { - background-color: #e0e0e0; - } - - body.dark-mode .config-option button:hover { - background-color: #666; - } - - /* Site search styles */ - #site-search-results { - border: 1px solid #ddd; - border-radius: 4px; - background: white; - margin-top: 5px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - } - - body.dark-mode #site-search-results { - background-color: #444; - border-color: #555; - } - - .site-search-result { - padding: 10px; - border-bottom: 1px solid #eee; - cursor: pointer; - transition: background-color 0.2s; - } - - .site-search-result:last-child { - border-bottom: none; - } - - .site-search-result:hover { - background-color: #f5f5f5; - } - - body.dark-mode .site-search-result { - border-bottom-color: #555; - } - - body.dark-mode .site-search-result:hover { - background-color: #555; - } - - .site-search-result div:first-child { - font-weight: bold; - color: #0061a1; - margin-bottom: 4px; - } - - body.dark-mode .site-search-result div:first-child { - color: #4fc3f7; - } - - .site-search-result div:last-child { - font-size: 0.85em; - color: #666; - } - - body.dark-mode .site-search-result div:last-child { - color: #aaa; - } - - #search-site-button { - background-color: #0061a1; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-weight: 500; - transition: background-color 0.2s; - } - - #search-site-button:hover { - background-color: #004d80; - } - - body.dark-mode #search-site-button { - background-color: #4fc3f7; - color: #333; - } - - body.dark-mode #search-site-button:hover { - background-color: #29b6f6; - } - - /* Map selector button */ - #select-from-map-button { - background-color: #28a745; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-weight: 500; - transition: background-color 0.2s; - } - - #select-from-map-button:hover { - background-color: #218838; - } - - body.dark-mode #select-from-map-button { - background-color: #34ce57; - color: #333; - } - - body.dark-mode #select-from-map-button:hover { - background-color: #28a745; - } - - /* Map selector modal styles */ - #map-selector-modal .config-modal-content { - width: 90vw; - max-width: 1200px; - } - - #map-container { - z-index: 1; - } - - .map-modal-close { - font-size: 1.8em; - cursor: pointer; - line-height: 1; - color: white; - } - - .map-modal-close:hover { - opacity: 0.8; - } - - .config-modal-footer { - padding: 15px 20px; - background-color: #f5f5f5; - text-align: right; - border-top: 1px solid #ddd; - flex-shrink: 0; - } - - body.dark-mode .config-modal-footer { - background-color: #444; - border-top-color: #555; - } - - .config-modal-footer button { - padding: 8px 15px; - margin-left: 10px; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 1em; - } - - #config-save-button { - background-color: #0061a1; - color: white; - } - - #config-save-button:hover { - background-color: #004d80; - } - - #config-cancel-button { - background-color: #ddd; - } - - body.dark-mode #config-cancel-button { - background-color: #555; - color: #f5f5f5; - } - - #config-cancel-button:hover { - background-color: #ccc; - } - - body.dark-mode #config-cancel-button:hover { - background-color: #666; - } - .clock-time { - font-size: 2.2em; /* Further reduced for ribbon */ - font-weight: 700; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; - white-space: nowrap; - display: inline-block; /* Changed to inline-block for same line */ - letter-spacing: 1px; - text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), - 0 1px 4px rgba(255, 255, 255, 0.3); - margin: 0; /* No margin for inline layout */ - position: relative; - z-index: 1; - line-height: 1; - color: #FFFFFF; - } - - .clock-date { - font-size: 2.2em; /* Match time size */ - font-weight: 400; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; - display: inline-block; /* Changed to inline-block for same line */ - opacity: 0.98; - letter-spacing: 0.5px; - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.15), - 0 1px 2px rgba(255, 255, 255, 0.2); - text-transform: capitalize; - position: relative; - z-index: 1; - line-height: 1; - margin: 0; /* No margin for inline layout */ - color: #FFFFFF; - } - - body.dark-mode .clock-time, - body.dark-mode .clock-date { - color: #E6F4FF; - text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), - 0 1px 4px rgba(0, 0, 0, 0.2); - } - - /* Responsive adjustments for smaller screens */ - @media (max-width: 768px) { - .clock-time { - font-size: 1.8em; - letter-spacing: 1px; - } - - .clock-date { - font-size: 1.8em; /* Match time size */ - } - - .clock-container { - padding: 6px 16px; - gap: 10px; - } - } - - @media (max-width: 480px) { - .clock-time { - font-size: 1.5em; - } - - .clock-date { - font-size: 1.5em; /* Match time size */ - } - - .clock-container { - padding: 6px 12px; - gap: 8px; - } - } - h2 { - color: #0061a1; - text-align: center; - } - .status { - text-align: center; - margin-bottom: 20px; - font-style: italic; - display: none; /* Hide the status message to avoid layout disruptions */ - } - .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 12px; - margin-bottom: 20px; - } - - /* Main content grid wrapper */ - .main-content-grid { - display: block; /* Default: single column for normal/vertical modes */ - } - - .departures-section { - width: 100%; - } - - .weather-section { - width: 100%; - } - - /* New card-based layout similar to card.JPG */ - .departure-card { - background-color: white; - border-radius: 6px; /* Further reduced radius */ - padding: 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); - margin-bottom: 6px; /* Further reduced margin */ - min-height: 75px; /* Further reduced min height */ - display: flex; - overflow: hidden; - width: 100%; /* Ensure card uses full column width */ - box-sizing: border-box; /* Include padding/border in width */ - max-width: 100%; /* Prevent overflow */ - } - - body.dark-mode .departure-card { - background-color: #333; - } - - /* Large line number box on the left */ - .line-number-box { - background: linear-gradient(135deg, #0061a1 0%, #004d80 100%); /* Changed from green to blue */ - color: white; - min-width: 50px; /* Further reduced */ - width: 50px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 4px; /* Further reduced padding */ - border-radius: 4px 0 0 4px; /* Further reduced radius */ - position: relative; - } - - /* Transport mode icon */ - .transport-mode-icon { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 2px; - opacity: 0.95; - } - - .transport-mode-icon .transport-icon { - width: 20px; /* Increased icon size */ - height: 20px; - fill: currentColor; - } - - /* Large line number */ - .line-number-large { - font-size: 1.6em; /* Increased - more space available */ - font-weight: bold; - line-height: 1; - color: #fff; /* Changed to white */ - } - - /* Transport-specific colors */ - .line-number-box.bus { - background: linear-gradient(135deg, #0061a1 0%, #004d80 100%); - } - - .line-number-box.bus .line-number-large { - color: white; - } - - .line-number-box.metro { - background: linear-gradient(135deg, #c41e3a 0%, #9a1629 100%); - } - - .line-number-box.metro .line-number-large { - color: white; - } - - .line-number-box.train { - background: linear-gradient(135deg, #0061a1 0%, #004d80 100%); - } - - .line-number-box.train .line-number-large { - color: white; - } - - .line-number-box.tram { - background: linear-gradient(135deg, #0061a1 0%, #004d80 100%); /* Blue like bus/train */ - } - - .line-number-box.tram .line-number-large { - color: white; - } - - .line-number-box.ship { - background: linear-gradient(135deg, #0061a1 0%, #004d80 100%); - } - - .line-number-box.ship .line-number-large { - color: white; - } - - /* Directions container on the right */ - .directions-wrapper { - flex: 1; - display: flex; - flex-direction: column; - padding: 6px 10px; /* Increased padding - more space available */ - gap: 5px; /* Increased gap */ - min-width: 0; /* Allow flex shrinking */ - overflow: hidden; /* Prevent overflow */ - } - - /* Single direction row */ - .direction-row { - display: flex; - align-items: center; - justify-content: space-between; - flex: 1; - min-height: 36px; /* Increased */ - gap: 8px; /* Increased gap between direction-info and times-container */ - min-width: 0; /* Allow flex shrinking */ - } - - /* Direction info (arrow + destination) */ - .direction-info { - display: flex; - align-items: center; - gap: 6px; /* Increased gap */ - flex: 1; - min-width: 0; /* Allow flex shrinking */ - overflow: hidden; /* Prevent overflow */ - } - - /* Direction arrow indicator - Increased size */ - .direction-arrow-box { - width: 32px; /* Increased */ - height: 32px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.4em; /* Increased */ - font-weight: bold; - flex-shrink: 0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - border: 1.5px solid rgba(255, 255, 255, 0.3); - } - - .direction-arrow-box.left { - background: repeating-linear-gradient( - 45deg, - #fff5e6, - #fff5e6 4px, - #ffe6cc 4px, - #ffe6cc 8px - ); - color: #ff6600; - border-color: #ff6600; - } - - .direction-arrow-box.right { - background: repeating-linear-gradient( - 45deg, - #e6f2ff, - #e6f2ff 4px, - #cce6ff 4px, - #cce6ff 8px - ); - color: #0066cc; - border-color: #0066cc; - } - - body.dark-mode .direction-arrow-box.left { - background: repeating-linear-gradient( - 45deg, - #664422, - #664422 4px, - #553311 4px, - #553311 8px - ); - color: #ff8800; - border-color: #ff8800; - } - - body.dark-mode .direction-arrow-box.right { - background: repeating-linear-gradient( - 45deg, - #223366, - #223366 4px, - #112255 4px, - #112255 8px - ); - color: #4fc3f7; - border-color: #4fc3f7; - } - - /* Destination text */ - .direction-destination { - font-size: 1.0em; /* Reduced for better spacing with countdown */ - font-weight: 600; - color: #333; - white-space: nowrap; /* Prevent wrapping */ - overflow: hidden; - text-overflow: ellipsis; /* Show ellipsis if too long */ - flex: 1; - min-width: 0; /* Allow flex shrinking */ - } - - body.dark-mode .direction-destination { - color: #f5f5f5; - } - - /* Times container */ - .times-container { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 3px; /* Increased gap */ - min-width: 110px; /* Increased slightly */ - flex-shrink: 0; /* Prevent shrinking */ - max-width: 110px; /* Prevent overflow */ - } - - /* Time display */ - .time-display { - display: flex; - align-items: baseline; - gap: 4px; /* Reduced gap for better spacing */ - white-space: nowrap; /* Prevent wrapping */ - font-size: 0.95em; /* Slightly reduced */ - } - - /* Pulse animation for urgent and now states - sharp text with exterior glow only */ - @keyframes pulse-glow { - 0%, 100% { - text-shadow: 0 0 8px currentColor, 0 0 12px currentColor, 0 0 16px currentColor; - } - 50% { - text-shadow: 0 0 12px currentColor, 0 0 18px currentColor, 0 0 24px currentColor; - } - } - - @keyframes pulse-scale { - 0%, 100% { - transform: scale(1); - } - 50% { - transform: scale(1.05); - } - } - - .countdown-large { - font-size: 1.2em; /* Restored to previous size */ - font-weight: bold; - color: #333; /* Dark color for light mode */ - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - white-space: nowrap; /* Prevent wrapping */ - } - - .countdown-large.urgent { - color: #c41e3a; /* Red: less than 5 minutes */ - animation: pulse-glow 2s ease-in-out infinite, pulse-scale 2s ease-in-out infinite; - text-shadow: 0 0 8px #c41e3a, 0 0 12px #c41e3a, 0 0 16px rgba(196, 30, 58, 0.9); - } - - .countdown-large.now { - color: #00a651; /* Green: "Nu" (now) */ - animation: pulse-glow 2s ease-in-out infinite, pulse-scale 2s ease-in-out infinite; - text-shadow: 0 0 8px #00a651, 0 0 12px #00a651, 0 0 16px rgba(0, 166, 81, 0.9); - } - - body.dark-mode .countdown-large { - color: #f5f5f5; /* White for 5+ minutes in dark mode */ - } - - body.dark-mode .countdown-large.urgent { - color: #ff6b6b; /* Red: less than 5 minutes in dark mode */ - text-shadow: 0 0 8px #ff6b6b, 0 0 12px #ff6b6b, 0 0 16px rgba(255, 107, 107, 0.9); - } - - body.dark-mode .countdown-large.now { - color: #4ecdc4; /* Green: "Nu" in dark mode */ - text-shadow: 0 0 8px #4ecdc4, 0 0 12px #4ecdc4, 0 0 16px rgba(78, 205, 196, 0.9); - } - - /* Time range */ - .time-range { - font-size: 0.85em; /* Increased - more space available */ - color: #666; - font-weight: 500; - white-space: nowrap; /* Prevent wrapping */ - } - - body.dark-mode .time-range { - color: #aaa; - } - - /* Legacy styles for compatibility */ - .departure-header { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - } - - .line-number { - background-color: #0061a1; - color: white; - padding: 6px 12px; - border-radius: 6px; - font-weight: bold; - font-size: 1.8em; - display: flex; - align-items: center; - gap: 8px; - margin-right: 10px; - } - - .transport-icon { - width: 24px; - height: 24px; - fill: currentColor; - } - - .line-destination { - font-size: 0.65em; - font-weight: normal; - margin-left: 6px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 120px; - display: inline-block; - vertical-align: middle; - } - - .time { - font-size: 1.5em; - font-weight: bold; - color: #333; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-end; - gap: 8px; - } - - .arrival-time { - font-size: 1.3em; - } - - .countdown { - font-size: 1.1em; - color: white; /* Default: white for 5+ minutes */ - } - - .countdown.urgent { - color: #c41e3a; /* Red: less than 5 minutes */ - animation: pulse-glow 2s ease-in-out infinite, pulse-scale 2s ease-in-out infinite; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-shadow: 0 0 6px #c41e3a, 0 0 10px #c41e3a, 0 0 14px rgba(196, 30, 58, 0.9); - } - - .countdown.now { - color: #00a651; /* Green: "Nu" (now) */ - font-weight: bold; - animation: pulse-glow 2s ease-in-out infinite, pulse-scale 2s ease-in-out infinite; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-shadow: 0 0 6px #00a651, 0 0 10px #00a651, 0 0 14px rgba(0, 166, 81, 0.9); - } - - body.dark-mode .countdown { - color: #f5f5f5; /* White for 5+ minutes in dark mode */ - } - - body.dark-mode .countdown.urgent { - color: #ff6b6b; /* Red: less than 5 minutes in dark mode */ - text-shadow: 0 0 6px #ff6b6b, 0 0 10px #ff6b6b, 0 0 14px rgba(255, 107, 107, 0.9); - } - - body.dark-mode .countdown.now { - color: #4ecdc4; /* Green: "Nu" in dark mode */ - text-shadow: 0 0 6px #4ecdc4, 0 0 10px #4ecdc4, 0 0 14px rgba(78, 205, 196, 0.9); - } - - .destination { - font-weight: bold; - font-size: 1.4em; - margin: 10px 0; - } - - .direction { - color: #666; - } - - .details { - margin-top: 10px; - font-size: 0.9em; - color: #666; - } - - /* Weather widget styles */ - .weather-container { - margin: 20px 0; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - } - - body.dark-mode .weather-container { - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - } - - /* Custom weather display */ - #custom-weather { - padding: 8px; /* Reduced from 12px */ - background-color: rgba(30, 36, 50, 0.8); /* Semi-transparent background */ - color: white; - border-radius: 8px; - text-align: center; - } - - /* Ensure weather widget is visible in vertical-reverse mode */ - body.vertical-reverse #custom-weather { - width: auto; - max-width: none; - } - - #custom-weather .current-weather { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - margin-bottom: 12px; - flex-wrap: wrap; - } - - #custom-weather .location-info { - display: flex; - align-items: center; - margin-right: 15px; - } - - #custom-weather h3 { - margin: 0; - margin-right: 8px; - font-size: 1em; /* Reduced from 1.1em */ - } - - #custom-weather .weather-icon { - display: flex; - align-items: center; - margin-right: 8px; /* Reduced from 10px */ - } - - #custom-weather .weather-icon img { - width: 48px; /* Reduced from 64px */ - height: 48px; - margin-right: 5px; - } - - /* Make sun icons RGB(255, 224, 0) - bright yellow */ - /* For clear sun (no clouds): apply full yellow filter */ - #custom-weather .weather-icon img.weather-clear-sun, - #custom-weather .weather-icon img[data-condition="Clear"], - #custom-weather .forecast-hour .icon img.weather-clear-sun, - #custom-weather .forecast-hour .icon img[data-condition="Clear"] { - filter: brightness(0) saturate(100%) invert(100%) sepia(100%) saturate(10000%) hue-rotate(0deg) brightness(1.12); - } - - /* For clouds with sun: preserve white clouds (#FFFFFF), color only sun parts yellow */ - /* Use brightness to preserve white areas, then selective saturation/hue for yellow sun */ - #custom-weather .weather-icon img.weather-clouds-sun, - #custom-weather .weather-icon img[data-condition*="Clouds"]:not([data-condition="Clear"]), - #custom-weather .forecast-hour .icon img.weather-clouds-sun, - #custom-weather .forecast-hour .icon img[data-condition*="Clouds"]:not([data-condition="Clear"]) { - /* High brightness preserves white clouds, saturation and hue-rotate color the sun yellow */ - filter: brightness(1.25) saturate(3) hue-rotate(-30deg) contrast(1.05); - } - - /* Make snow icons #F5F5F5 - light grey/white (RGB: 245, 245, 245) */ - /* Convert to light grey: brightness(0) -> black, invert(96%) -> #F5F5F5 equivalent */ - #custom-weather .weather-icon img[data-condition*="Snow"], - #custom-weather .weather-icon img.weather-snow, - #custom-weather .forecast-hour .icon img.weather-snow { - filter: brightness(0) saturate(0%) invert(96%); - } - - #custom-weather .temperature { - font-size: 1.5em; /* Reduced from 1.8em */ - font-weight: bold; - white-space: nowrap; - } - - #custom-weather .sun-times { - text-align: center; - margin-top: 6px; /* Reduced from 10px */ - font-size: 0.75em; /* Reduced from 0.85em */ - color: #aaa; - } - - /* Hourly forecast styles */ - #custom-weather .forecast { - display: flex; - justify-content: center; - overflow-x: auto; - padding-bottom: 6px; /* Reduced from 10px */ - margin-bottom: 6px; /* Reduced from 10px */ - scrollbar-width: thin; - scrollbar-color: #4fc3f7 #1e2432; - } - - #custom-weather .forecast::-webkit-scrollbar { - height: 6px; - } - - #custom-weather .forecast::-webkit-scrollbar-track { - background: #1e2432; - } - - #custom-weather .forecast::-webkit-scrollbar-thumb { - background-color: #4fc3f7; - border-radius: 6px; - } - - #custom-weather .forecast-hour { - text-align: center; - min-width: 50px; /* Reduced from 60px */ - margin-right: 6px; /* Reduced from 10px */ - flex-shrink: 0; - background-color: rgba(30, 40, 60, 0.5); - border: 1px solid #4fc3f7; - border-radius: 4px; /* Reduced from 6px */ - padding: 6px 4px; /* Reduced from 8px 5px */ - display: flex; - flex-direction: column; - align-items: center; - justify-content: space-between; - height: 90px; /* Reduced from 120px */ - } - - #custom-weather .forecast-hour:last-child { - margin-right: 0; - } - - #custom-weather .forecast-hour .time { - margin-bottom: 3px; /* Reduced from 5px */ - font-size: 0.75em; /* Reduced from 0.9em */ - font-weight: bold; - color: white; - display: block; - } - - #custom-weather .forecast-hour .icon { - margin: 3px 0; /* Reduced from 5px */ - } - - #custom-weather .forecast-hour .icon img { - width: 40px; /* Reduced from 56px */ - height: 40px; - } - - /* Sun icons already handled above - removing duplicate */ - - #custom-weather .forecast-hour .temp { - font-weight: bold; - font-size: 0.8em; /* Added size reduction */ - } - - #custom-weather .attribution { - margin-top: 10px; - font-size: 0.7em; - text-align: right; - } - - #custom-weather .attribution a { - color: #4fc3f7; - text-decoration: none; - } - - /* Sun times display in config */ - .sun-times { - margin-top: 5px; - color: #666; - } - - /* Background image settings */ - #background-image-url { - width: 100%; - padding: 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 1em; - margin-bottom: 10px; - } - - .background-preview { - width: 100%; - height: 120px; - border: 1px solid #ddd; - border-radius: 4px; - margin-top: 5px; - margin-bottom: 15px; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - background-color: #f5f5f5; - } - - .background-preview img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - } - - .background-preview .no-image { - color: #999; - font-style: italic; - } - - #background-opacity { - width: 100%; - margin-top: 5px; - } - - #opacity-value { - font-weight: normal; - color: #0077cc; - } - - .last-updated { - text-align: center; - font-size: 0.8em; - color: #666; - margin-top: 20px; - } - /* Site header styles */ - .site-header { - margin-bottom: 10px; /* Reduced to create more consistent spacing */ - position: relative; - } - - /* Site container styles */ - .site-container { - margin-bottom: 10px; /* Reduced to match other spacing */ - } - - .site-name { - display: inline-block; - background-color: white; - color: #0061a1; - font-weight: bold; - padding: 5px 15px; - border-radius: 4px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - position: relative; - z-index: 1; - } - - .error { - color: red; - text-align: center; - padding: 20px; - background-color: #ffeeee; - border-radius: 8px; - margin: 20px 0; - } +.clock-container { + background: linear-gradient(135deg, #003366 0%, #004080 50%, #0059b3 100%); + color: var(--color-surface); + padding: 8px 20px; + border-radius: 8px; + margin-bottom: 8px; + text-align: center; + box-shadow: 0 0 20px rgba(0, 89, 179, 0.6), + 0 0 40px rgba(0, 89, 179, 0.4), + 0 4px 12px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + position: relative; + overflow: hidden; + backdrop-filter: blur(10px); + transition: box-shadow 0.3s ease; + border: 2px solid rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + gap: 15px; +} + +.clock-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 50%, rgba(255, 255, 255, 0.1) 100%); + pointer-events: none; +} + +.clock-container:hover { + box-shadow: 0 0 30px rgba(0, 89, 179, 0.8), + 0 0 60px rgba(0, 89, 179, 0.5), + 0 4px 12px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.3); +} + +body.dark-mode .clock-container { + background: linear-gradient(135deg, #001a33 0%, #002d5c 50%, #004080 100%); + box-shadow: 0 0 25px rgba(0, 89, 179, 0.7), + 0 0 50px rgba(0, 89, 179, 0.4), + 0 4px 12px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.2); +} + +body.dark-mode .clock-container:hover { + box-shadow: 0 0 35px rgba(0, 89, 179, 0.9), + 0 0 70px rgba(0, 89, 179, 0.6), + 0 4px 12px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.2); +} + +/* Configuration button styles */ +.config-button { + position: fixed; + bottom: 20px; + right: 20px; + width: 40px; + height: 40px; + background-color: rgba(0, 97, 161, 0.3); + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + z-index: 100; + transition: transform 0.3s ease, background-color 0.3s ease, opacity 0.3s ease; + opacity: 0.5; +} + +.config-button:hover { + transform: rotate(30deg); + background-color: var(--color-primary); + opacity: 1; +} + +.config-button svg { + transition: opacity 0.3s ease; + opacity: 0.7; +} + +.config-button:hover svg { + opacity: 1; +} + +/* Configuration modal styles */ +.config-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 200; + justify-content: center; + align-items: center; +} + +.config-modal-content { + background-color: var(--color-surface); + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 90vh; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + overflow: hidden; + display: flex; + flex-direction: column; +} + +body.dark-mode .config-modal-content { + background-color: var(--color-surface-dark); +} + +.config-modal-header { + background-color: var(--color-primary); + color: var(--color-surface); + padding: 15px 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.config-modal-header h2 { + margin: 0; + font-size: 1.5em; +} + +.config-modal-close { + font-size: 1.8em; + cursor: pointer; + line-height: 1; +} + +.config-modal-close:hover { + opacity: 0.8; +} + +/* Tab navigation */ +.config-tabs { + display: flex; + background-color: #f0f0f0; + border-bottom: 2px solid var(--color-border); + flex-shrink: 0; +} + +body.dark-mode .config-tabs { + background-color: #444; + border-bottom-color: var(--color-border-dark); +} + +.config-tab { + flex: 1; + padding: 12px 20px; + background-color: transparent; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 0.95em; + font-weight: 500; + color: var(--color-text-muted); + transition: background-color 0.2s ease, color 0.2s ease, border-bottom-color 0.2s ease; +} + +body.dark-mode .config-tab { + color: var(--color-text-muted-dark); +} + +.config-tab:hover { + background-color: #e0e0e0; + color: var(--color-text); +} + +body.dark-mode .config-tab:hover { + background-color: var(--color-border-dark); + color: var(--color-text-light); +} + +.config-tab.active { + background-color: var(--color-surface); + color: var(--color-primary); + border-bottom-color: var(--color-primary); + font-weight: 600; +} + +body.dark-mode .config-tab.active { + background-color: var(--color-surface-dark); + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +.config-modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; + min-height: 0; + max-height: calc(90vh - 200px); +} + +/* Custom scrollbar for modal body */ +.config-modal-body::-webkit-scrollbar { + width: 8px; +} + +.config-modal-body::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +.config-modal-body::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +.config-modal-body::-webkit-scrollbar-thumb:hover { + background: var(--color-border-dark); +} + +body.dark-mode .config-modal-body::-webkit-scrollbar-track { + background: #444; +} + +body.dark-mode .config-modal-body::-webkit-scrollbar-thumb { + background: var(--color-text-muted); +} + +body.dark-mode .config-modal-body::-webkit-scrollbar-thumb:hover { + background: #888; +} + +.config-tab-content { + display: none; +} + +.config-tab-content.active { + display: block; +} + +.config-option { + margin-bottom: 15px; +} + +.config-option label { + display: block; + margin-bottom: 5px; + font-weight: bold; +} + +.config-option select, +.config-option input[type="text"] { + width: 100%; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 1em; + box-sizing: border-box; +} + +body.dark-mode .config-option select, +body.dark-mode .config-option input[type="text"] { + background-color: #444; + color: var(--color-text-light); + border-color: var(--color-border-dark); +} + +.config-option input[type="range"] { + width: 100%; +} + +.config-option button { + padding: 5px 10px; + border: 1px solid var(--color-border); + border-radius: 4px; + background-color: var(--color-bg); + cursor: pointer; + font-size: 0.9em; +} + +body.dark-mode .config-option button { + background-color: var(--color-border-dark); + color: var(--color-text-light); + border-color: var(--color-text-muted); +} + +.config-option button:hover { + background-color: #e0e0e0; +} + +body.dark-mode .config-option button:hover { + background-color: var(--color-text-muted); +} + +/* Site search styles */ +#site-search-results { + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-surface); + margin-top: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +body.dark-mode #site-search-results { + background-color: #444; + border-color: var(--color-border-dark); +} + +.site-search-result { + padding: 10px; + border-bottom: 1px solid #eee; + cursor: pointer; + transition: background-color 0.2s; +} + +.site-search-result:last-child { + border-bottom: none; +} + +.site-search-result:hover { + background-color: var(--color-bg); +} + +body.dark-mode .site-search-result { + border-bottom-color: var(--color-border-dark); +} + +body.dark-mode .site-search-result:hover { + background-color: var(--color-border-dark); +} + +.site-search-result div:first-child { + font-weight: bold; + color: var(--color-primary); + margin-bottom: 4px; +} + +body.dark-mode .site-search-result div:first-child { + color: var(--color-accent); +} + +.site-search-result div:last-child { + font-size: 0.85em; + color: var(--color-text-muted); +} + +body.dark-mode .site-search-result div:last-child { + color: var(--color-text-muted-dark); +} + +#search-site-button { + background-color: var(--color-primary); + color: var(--color-surface); + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; +} + +#search-site-button:hover { + background-color: var(--color-primary-dark); +} + +body.dark-mode #search-site-button { + background-color: var(--color-accent); + color: var(--color-surface-dark); +} + +body.dark-mode #search-site-button:hover { + background-color: #29b6f6; +} + +/* Map selector button */ +#select-from-map-button { + background-color: #28a745; + color: var(--color-surface); + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; +} + +#select-from-map-button:hover { + background-color: #218838; +} + +body.dark-mode #select-from-map-button { + background-color: #34ce57; + color: var(--color-surface-dark); +} + +body.dark-mode #select-from-map-button:hover { + background-color: #28a745; +} + +/* Map selector modal styles */ +#map-selector-modal .config-modal-content { + width: 90vw; + max-width: 1200px; +} + +#map-container { + z-index: 1; +} + +.map-modal-close { + font-size: 1.8em; + cursor: pointer; + line-height: 1; + color: var(--color-surface); +} + +.map-modal-close:hover { + opacity: 0.8; +} + +.config-modal-footer { + padding: 15px 20px; + background-color: var(--color-bg); + text-align: right; + border-top: 1px solid var(--color-border); + flex-shrink: 0; +} + +body.dark-mode .config-modal-footer { + background-color: #444; + border-top-color: var(--color-border-dark); +} + +.config-modal-footer button { + padding: 8px 15px; + margin-left: 10px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; +} + +#config-save-button { + background-color: var(--color-primary); + color: var(--color-surface); +} + +#config-save-button:hover { + background-color: var(--color-primary-dark); +} + +#config-cancel-button { + background-color: var(--color-border); +} + +body.dark-mode #config-cancel-button { + background-color: var(--color-border-dark); + color: var(--color-text-light); +} + +#config-cancel-button:hover { + background-color: #ccc; +} + +body.dark-mode #config-cancel-button:hover { + background-color: var(--color-text-muted); +} + +.clock-time { + font-size: 2.2em; + font-weight: 700; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; + white-space: nowrap; + display: inline-block; + letter-spacing: 1px; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), + 0 1px 4px rgba(255, 255, 255, 0.3); + margin: 0; + position: relative; + z-index: 1; + line-height: 1; + color: #FFFFFF; +} + +.clock-date { + font-size: 2.2em; + font-weight: 400; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; + display: inline-block; + opacity: 0.98; + letter-spacing: 0.5px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.15), + 0 1px 2px rgba(255, 255, 255, 0.2); + text-transform: capitalize; + position: relative; + z-index: 1; + line-height: 1; + margin: 0; + color: #FFFFFF; +} + +body.dark-mode .clock-time, +body.dark-mode .clock-date { + color: #E6F4FF; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), + 0 1px 4px rgba(0, 0, 0, 0.2); +} + +/* Responsive adjustments for smaller screens */ +@media (max-width: 768px) { + .clock-time { + font-size: 1.8em; + letter-spacing: 1px; + } + + .clock-date { + font-size: 1.8em; + } + + .clock-container { + padding: 6px 16px; + gap: 10px; + } +} + +@media (max-width: 480px) { + .clock-time { + font-size: 1.5em; + } + + .clock-date { + font-size: 1.5em; + } + + .clock-container { + padding: 6px 12px; + gap: 8px; + } +} + +h2 { + color: var(--color-primary); + text-align: center; +} + +.status { + text-align: center; + margin-bottom: 20px; + font-style: italic; + display: none; +} + +.departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + margin-bottom: 20px; +} + +/* Main content grid wrapper */ +.main-content-grid { + display: block; +} + +.departures-section { + width: 100%; +} + +.weather-section { + width: 100%; +} + +/* New card-based layout */ +.departure-card { + background-color: var(--color-surface); + border-radius: 6px; + padding: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + margin-bottom: 6px; + min-height: 75px; + display: flex; + overflow: hidden; + width: 100%; + box-sizing: border-box; + max-width: 100%; +} + +body.dark-mode .departure-card { + background-color: var(--color-surface-dark); +} + +/* Large line number box on the left */ +.line-number-box { + background: var(--gradient-blue); + color: var(--color-surface); + min-width: 50px; + width: 50px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 4px; + border-radius: 4px 0 0 4px; + position: relative; +} + +/* Transport mode icon */ +.transport-mode-icon { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 2px; + opacity: 0.95; +} + +.transport-mode-icon .transport-icon { + width: 20px; + height: 20px; + fill: currentColor; +} + +/* Large line number */ +.line-number-large { + font-size: 1.6em; + font-weight: bold; + line-height: 1; + color: #fff; +} + +/* Transport-specific colors - only metro has a different gradient */ +.line-number-box.metro { + background: linear-gradient(135deg, #c41e3a 0%, #9a1629 100%); +} + +.line-number-box.metro .line-number-large { + color: var(--color-surface); +} + +/* Directions container on the right */ +.directions-wrapper { + flex: 1; + display: flex; + flex-direction: column; + padding: 6px 10px; + gap: 5px; + min-width: 0; + overflow: hidden; +} + +/* Single direction row */ +.direction-row { + display: flex; + align-items: center; + justify-content: space-between; + flex: 1; + min-height: 36px; + gap: 8px; + min-width: 0; +} + +/* Direction info (arrow + destination) */ +.direction-info { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + overflow: hidden; +} + +/* Direction arrow indicator */ +.direction-arrow-box { + width: 32px; + height: 32px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4em; + font-weight: bold; + flex-shrink: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + border: 1.5px solid rgba(255, 255, 255, 0.3); +} + +.direction-arrow-box.left { + background: repeating-linear-gradient( + 45deg, + #fff5e6, + #fff5e6 4px, + #ffe6cc 4px, + #ffe6cc 8px + ); + color: #ff6600; + border-color: #ff6600; +} + +.direction-arrow-box.right { + background: repeating-linear-gradient( + 45deg, + #e6f2ff, + #e6f2ff 4px, + #cce6ff 4px, + #cce6ff 8px + ); + color: #0066cc; + border-color: #0066cc; +} + +body.dark-mode .direction-arrow-box.left { + background: repeating-linear-gradient( + 45deg, + #664422, + #664422 4px, + #553311 4px, + #553311 8px + ); + color: #ff8800; + border-color: #ff8800; +} + +body.dark-mode .direction-arrow-box.right { + background: repeating-linear-gradient( + 45deg, + #223366, + #223366 4px, + #112255 4px, + #112255 8px + ); + color: var(--color-accent); + border-color: var(--color-accent); +} + +/* Destination text */ +.direction-destination { + font-size: 1.0em; + font-weight: 600; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +body.dark-mode .direction-destination { + color: var(--color-text-light); +} + +/* Times container */ +.times-container { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + min-width: 110px; + flex-shrink: 0; + max-width: 110px; +} + +/* Time display */ +.time-display { + display: flex; + align-items: baseline; + gap: 4px; + white-space: nowrap; + font-size: 0.95em; +} + +/* Pulse animation for urgent and now states */ +@keyframes pulse-glow { + 0%, 100% { + text-shadow: 0 0 8px currentColor, 0 0 12px currentColor, 0 0 16px currentColor; + } + 50% { + text-shadow: 0 0 12px currentColor, 0 0 18px currentColor, 0 0 24px currentColor; + } +} + +@keyframes pulse-scale { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +.countdown-large { + font-size: 1.2em; + font-weight: bold; + color: var(--color-text); + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + white-space: nowrap; +} + +.countdown-large.urgent { + color: var(--color-urgent); + animation: pulse-glow 2s ease-in-out infinite, pulse-scale 2s ease-in-out infinite; + text-shadow: 0 0 8px #c41e3a, 0 0 12px #c41e3a, 0 0 16px rgba(196, 30, 58, 0.9); +} + +.countdown-large.now { + color: var(--color-now); + animation: pulse-glow 2s ease-in-out infinite, pulse-scale 2s ease-in-out infinite; + text-shadow: 0 0 8px #00a651, 0 0 12px #00a651, 0 0 16px rgba(0, 166, 81, 0.9); +} + +body.dark-mode .countdown-large { + color: var(--color-text-light); +} + +body.dark-mode .countdown-large.urgent { + color: var(--color-urgent-dark); + text-shadow: 0 0 8px #ff6b6b, 0 0 12px #ff6b6b, 0 0 16px rgba(255, 107, 107, 0.9); +} + +body.dark-mode .countdown-large.now { + color: var(--color-now-dark); + text-shadow: 0 0 8px #4ecdc4, 0 0 12px #4ecdc4, 0 0 16px rgba(78, 205, 196, 0.9); +} + +/* 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); + font-weight: 500; + white-space: nowrap; +} + +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; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +body.dark-mode .weather-container { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +/* Custom weather display */ +#custom-weather { + padding: 8px; + background-color: rgba(30, 36, 50, 0.8); + color: var(--color-surface); + border-radius: 8px; + text-align: center; +} + +/* Ensure weather widget is visible in vertical-reverse mode */ +body.vertical-reverse #custom-weather { + width: auto; + max-width: none; +} + +#custom-weather .current-weather { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-bottom: 12px; + flex-wrap: wrap; +} + +#custom-weather .location-info { + display: flex; + align-items: center; + margin-right: 15px; +} + +#custom-weather h3 { + margin: 0; + margin-right: 8px; + font-size: 1em; +} + +#custom-weather .weather-icon { + display: flex; + align-items: center; + margin-right: 8px; +} + +#custom-weather .weather-icon img { + width: 48px; + height: 48px; + margin-right: 5px; +} + +/* Sun icon filters - bright yellow for clear sun */ +#custom-weather .weather-icon img.weather-clear-sun, +#custom-weather .weather-icon img[data-condition="Clear"], +#custom-weather .forecast-hour .icon img.weather-clear-sun, +#custom-weather .forecast-hour .icon img[data-condition="Clear"] { + filter: brightness(0) saturate(100%) invert(100%) sepia(100%) saturate(10000%) hue-rotate(0deg) brightness(1.12); +} + +/* Cloud with sun - preserve white clouds, color sun parts yellow */ +#custom-weather .weather-icon img.weather-clouds-sun, +#custom-weather .weather-icon img[data-condition*="Clouds"]:not([data-condition="Clear"]), +#custom-weather .forecast-hour .icon img.weather-clouds-sun, +#custom-weather .forecast-hour .icon img[data-condition*="Clouds"]:not([data-condition="Clear"]) { + filter: brightness(1.25) saturate(3) hue-rotate(-30deg) contrast(1.05); +} + +/* Snow icons - light grey/white */ +#custom-weather .weather-icon img[data-condition*="Snow"], +#custom-weather .weather-icon img.weather-snow, +#custom-weather .forecast-hour .icon img.weather-snow { + filter: brightness(0) saturate(0%) invert(96%); +} + +#custom-weather .temperature { + font-size: 1.5em; + font-weight: bold; + white-space: nowrap; +} + +#custom-weather .sun-times { + text-align: center; + margin-top: 6px; + font-size: 0.75em; + color: var(--color-text-muted-dark); +} + +/* Hourly forecast styles */ +#custom-weather .forecast { + display: flex; + justify-content: center; + overflow-x: auto; + padding-bottom: 6px; + margin-bottom: 6px; + scrollbar-width: thin; + scrollbar-color: var(--color-accent) #1e2432; +} + +#custom-weather .forecast::-webkit-scrollbar { + height: 6px; +} + +#custom-weather .forecast::-webkit-scrollbar-track { + background: #1e2432; +} + +#custom-weather .forecast::-webkit-scrollbar-thumb { + background-color: var(--color-accent); + border-radius: 6px; +} + +#custom-weather .forecast-hour { + text-align: center; + min-width: 50px; + margin-right: 6px; + flex-shrink: 0; + background-color: rgba(30, 40, 60, 0.5); + border: 1px solid var(--color-accent); + border-radius: 4px; + padding: 6px 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + height: 90px; +} + +#custom-weather .forecast-hour:last-child { + margin-right: 0; +} + +#custom-weather .forecast-hour .time { + margin-bottom: 3px; + font-size: 0.75em; + font-weight: bold; + color: var(--color-surface); + display: block; +} + +#custom-weather .forecast-hour .icon { + margin: 3px 0; +} + +#custom-weather .forecast-hour .icon img { + width: 40px; + height: 40px; +} + +#custom-weather .forecast-hour .temp { + font-weight: bold; + font-size: 0.8em; +} + +#custom-weather .attribution { + margin-top: 10px; + font-size: 0.7em; + text-align: right; +} + +#custom-weather .attribution a { + color: var(--color-accent); + text-decoration: none; +} + +/* Sun times display in config */ +.sun-times { + margin-top: 5px; + color: var(--color-text-muted); +} + +/* Background image settings */ +#background-image-url { + width: 100%; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 1em; + margin-bottom: 10px; +} + +.background-preview { + width: 100%; + height: 120px; + border: 1px solid var(--color-border); + border-radius: 4px; + margin-top: 5px; + margin-bottom: 15px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-bg); +} + +.background-preview img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.background-preview .no-image { + color: #999; + font-style: italic; +} + +#background-opacity { + width: 100%; + margin-top: 5px; +} + +#opacity-value { + font-weight: normal; + color: var(--color-primary-light); +} + +.last-updated { + text-align: center; + font-size: 0.8em; + color: var(--color-text-muted); + margin-top: 20px; +} + +/* Site header styles */ +.site-header { + margin-bottom: 10px; + position: relative; +} + +/* Site container styles */ +.site-container { + margin-bottom: 10px; +} + +.site-name { + display: inline-block; + background-color: var(--color-surface); + color: var(--color-primary); + font-weight: bold; + padding: 5px 15px; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + position: relative; + 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; + padding: 20px; + background-color: #ffeeee; + border-radius: 8px; + margin: 20px 0; +} + +/* Daylight Hours Bar */ +#daylight-hours-bar { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + height: 15px; + z-index: 1000; + overflow: visible; +} + +#daylight-hours-bar .daylight-bar-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #191970; + background-image: var(--daylight-gradient, none); +} + +#daylight-hours-bar .daylight-bar-indicator { + position: absolute; + top: -20px; + left: var(--current-hour-position, 0%); + transform: translateX(-50%); + transition: left 60s linear; + z-index: 1001; + pointer-events: none; +} + +#daylight-hours-bar .sun-icon { + font-size: 18px; + display: block; + filter: drop-shadow(0 0 4px rgba(255, 215, 0, 0.8)); + 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; +} + +/* Background overlay for custom background images */ +#background-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-size: cover; + background-position: center; + z-index: -1; + pointer-events: none; + transform-origin: center center; +} + +#background-overlay.orientation-normal { + transform: scale(1.2); +} + +#background-overlay.orientation-vertical { + transform: rotate(90deg) scale(2); +} + +#background-overlay.orientation-upsidedown { + transform: rotate(180deg) scale(1.5); +} + +#background-overlay.orientation-vertical-reverse { + transform: rotate(270deg) scale(2); +} + +/* Departure card enter/leave animations */ +.departure-card.card-entering { + opacity: 0; +} + +.departure-card.card-entering.card-visible { + transition: opacity 0.5s ease-in; + opacity: 1; +} + +.departure-card.card-leaving { + transition: opacity 0.5s ease-out; + opacity: 0; +} + +/* Countdown highlight flash effect */ +@keyframes highlight-flash { + 0% { background-color: rgba(255, 255, 0, 0.3); } + 100% { background-color: transparent; } +} + +.highlight-flash { + animation: highlight-flash 1.5s ease-out; +} + +/* Config modal inline style replacements */ +.config-flex-row { + display: flex; + gap: 10px; + margin-top: 5px; +} + +.config-flex-row-mb { + display: flex; + gap: 10px; + margin-bottom: 10px; +} + +.config-site-flex { + display: flex; + align-items: center; + margin-bottom: 5px; +} + +.config-site-id-row { + display: flex; + align-items: center; +} + +.config-site-id-label { + margin-right: 5px; +} + +.config-site-name-input { + flex: 1; + margin: 0 5px; +} + +.config-site-id-input { + width: 100px; +} + +.config-btn-sm { + padding: 5px 10px; +} + +.config-btn-remove { + padding: 2px 5px; +} + +.config-search-input { + flex: 1; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; +} + +body.dark-mode .config-search-input { + background-color: var(--color-surface-dark); + border-color: var(--color-border-dark); + color: var(--color-text-light); +} + +.config-search-results { + display: none; + max-height: 200px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + margin-top: 5px; +} + +body.dark-mode .config-search-results { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); +} + +.config-file-label { + padding: 5px 10px; + background-color: #ddd; + border-radius: 4px; + cursor: pointer; +} + +body.dark-mode .config-file-label { + background-color: var(--color-surface-dark); + color: var(--color-text-light); +} + +.config-file-input-hidden { + display: none; +} + +.config-sites-add { + margin-top: 10px; +} + +/* ======================================== + Accessibility - Focus styles + ======================================== */ +.config-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + opacity: 1; + background-color: var(--color-primary); +} + +.config-tab:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +body.dark-mode .config-tab:focus-visible { + outline-color: var(--color-accent); +} + +.config-modal-close:focus-visible { + outline: 2px solid var(--color-surface); + outline-offset: 2px; +} + +.config-modal-footer button:focus-visible, +.config-option button:focus-visible, +#search-site-button:focus-visible, +#select-from-map-button:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +body.dark-mode .config-modal-footer button:focus-visible, +body.dark-mode .config-option button:focus-visible { + outline-color: var(--color-accent); +} + +.config-option select:focus-visible, +.config-option input[type="text"]:focus-visible, +.config-search-input:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -1px; + border-color: var(--color-primary); +} + +body.dark-mode .config-option select:focus-visible, +body.dark-mode .config-option input[type="text"]:focus-visible, +body.dark-mode .config-search-input:focus-visible { + outline-color: var(--color-accent); + border-color: var(--color-accent); +} + +/* Accessibility - Color contrast improvements */ +#custom-weather .sun-times { + color: #ccc; +} + +.site-search-result div:last-child { + color: #777; +} + +body.dark-mode .site-search-result div:last-child { + color: #bbb; +} + +body.dark-mode .time-range { + color: #bbb; +} + +/* ======================================== + Responsive Design - Departure cards + ======================================== */ +@media (max-width: 768px) { + .departure-card { + min-height: 60px; + } + + .line-number-box { + min-width: 42px; + width: 42px; + } + + .line-number-large { + font-size: 1.3em; + } + + .direction-destination { + font-size: 0.9em; + } + + .countdown-large { + font-size: 1.1em; + } + + .times-container { + min-width: 90px; + max-width: 90px; + } + + .time-display { + font-size: 0.85em; + } + + .direction-arrow-box { + width: 26px; + height: 26px; + font-size: 1.1em; + } + + /* Weather responsive */ + #custom-weather .current-weather { + flex-direction: column; + gap: 4px; + } + + #custom-weather .location-info { + margin-right: 0; + } + + #custom-weather .temperature { + font-size: 1.3em; + } + + #custom-weather .forecast-hour { + min-width: 44px; + height: 80px; + padding: 4px 3px; + } + + #custom-weather .forecast-hour .icon img { + width: 32px; + height: 32px; + } + + /* Config modal responsive */ + .config-modal-content { + width: 95%; + max-height: 95vh; + } + + .config-modal-body { + max-height: calc(95vh - 180px); + } + + .config-tabs { + flex-wrap: wrap; + } + + .config-tab { + padding: 10px 12px; + font-size: 0.85em; + } +} + +@media (max-width: 480px) { + .departure-card { + min-height: 50px; + } + + .line-number-box { + min-width: 36px; + width: 36px; + padding: 2px; + } + + .line-number-large { + font-size: 1.1em; + } + + .transport-mode-icon .transport-icon { + width: 16px; + height: 16px; + } + + .directions-wrapper { + padding: 4px 6px; + gap: 3px; + } + + .direction-destination { + font-size: 0.8em; + } + + .countdown-large { + font-size: 1em; + } + + .times-container { + min-width: 75px; + max-width: 75px; + } + + .time-range { + font-size: 0.75em; + } + + .direction-arrow-box { + width: 22px; + height: 22px; + font-size: 0.9em; + } + + /* Weather responsive */ + #custom-weather { + padding: 6px; + } + + #custom-weather .weather-icon img { + width: 36px; + height: 36px; + } + + #custom-weather .temperature { + font-size: 1.1em; + } + + #custom-weather .forecast-hour { + min-width: 38px; + height: 70px; + margin-right: 4px; + } + + #custom-weather .forecast-hour .time { + font-size: 0.65em; + } + + #custom-weather .forecast-hour .icon img { + width: 28px; + height: 28px; + } + + #custom-weather .forecast-hour .temp { + font-size: 0.7em; + } + + /* Config modal responsive */ + .config-modal-header h2 { + font-size: 1.2em; + } + + .config-modal-header { + padding: 10px 15px; + } + + .config-modal-body { + padding: 15px; + } + + .config-modal-footer { + padding: 10px 15px; + } + + .config-tab { + padding: 8px 8px; + font-size: 0.8em; + } + + .config-flex-row { + flex-wrap: wrap; + } + + .config-flex-row-mb { + flex-wrap: wrap; + } +} + +/* Reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + .countdown-large.urgent, + .countdown-large.now, + .countdown-large.soon { + animation: none; + } + + #news-ticker .ticker-content { + animation: none; + } + + .departure-card.card-entering.card-visible { + transition: none; + } + + .departure-card.card-leaving { + transition: none; + } + + .highlight-flash { + animation: none; + } +} diff --git a/public/css/main.css b/public/css/main.css index dff081f..a6e8c93 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1,349 +1,432 @@ -/* Base styles */ - body { - font-family: Arial, sans-serif; - margin: 0 auto; - padding: 20px; - background-color: #f5f5f5; - color: #333; - transition: all 0.5s ease; - } - - /* Auto-apply landscape layout for wide screens */ - @media (min-width: 1200px) { - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) { - max-width: 100%; - padding: 8px 12px 0 12px; /* Minimal padding to maximize space */ - padding-bottom: 0; - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) #content-wrapper { - display: grid; - grid-template-rows: auto 1fr auto; - gap: 8px; /* Reduced gap */ - height: 100vh; - max-height: 100vh; - overflow: hidden; - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .clock-container { - grid-row: 1; - margin-bottom: 0; - padding: 6px 16px; /* Reduced padding */ - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .main-content-grid { - grid-row: 2; - display: block; - overflow-y: auto; - overflow-x: hidden; - min-height: 0; - width: 100%; - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container { - display: grid; - grid-template-columns: repeat(4, 1fr); /* Fixed 4 columns to use all space */ - gap: 6px; /* Minimal gap */ - margin-bottom: 0; - width: 100%; - box-sizing: border-box; - padding: 0; /* Remove any padding */ - } - - /* Ensure each column uses equal space */ - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container > * { - min-width: 0; /* Allow flex shrinking */ - max-width: 100%; /* Prevent overflow */ - } - - /* Weather fixed at bottom */ - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-section { - grid-row: 3; - position: sticky; - bottom: 0; - background-color: inherit; - padding: 8px 0; /* Reduced padding */ - margin-top: 0; - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-container { - margin: 0; - max-width: 100%; - } - } - - /* Dark mode styles */ - body.dark-mode { - background-color: #222; - color: #f5f5f5; - } - - body.dark-mode .departure-card { - background-color: #333; - border-left-color: #0077cc; - } - - body.dark-mode .config-modal-content { - background-color: #333; - color: #f5f5f5; - } - - body.dark-mode .config-modal-body { - background-color: #333; - } - - body.dark-mode .config-modal-footer { - background-color: #444; - } - - body.dark-mode #config-cancel-button { - background-color: #555; - color: #f5f5f5; - } - - body.dark-mode .time, - body.dark-mode .destination { - color: #f5f5f5; - } - - body.dark-mode .direction, - body.dark-mode .details, - body.dark-mode .countdown, - body.dark-mode .last-updated { - color: #aaa; - } - - body.dark-mode h2 { - color: #0077cc; - } - - body.dark-mode .sun-times { - color: #aaa; - } - - body.dark-mode .line-number { - background-color: #0077cc; - } - - /* Normal orientation */ - body.normal { - max-width: 800px; - } - body.normal .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - } - - /* Landscape orientation - Optimized for wide screens */ - body.landscape { - max-width: 100%; - padding: 20px 40px; - } - - /* Main content area: clock at top, then two-column layout below */ - body.landscape #content-wrapper { - display: grid; - grid-template-rows: auto 1fr; - gap: 20px; - height: 100vh; - max-height: 100vh; - overflow: hidden; - } - - body.landscape .clock-container { - grid-row: 1; - margin-bottom: 0; - } - - /* Main content grid: departures on left, weather on right */ - body.landscape .main-content-grid { - grid-row: 2; - display: grid; - grid-template-columns: 1fr 380px; - gap: 20px; - overflow: hidden; - min-height: 0; - } - - /* Departures container: multi-column grid */ - body.landscape .departure-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); - gap: 15px; - overflow-y: auto; - overflow-x: hidden; - padding-right: 10px; - min-height: 0; - } - - /* Weather container: fixed width, scrollable */ - body.landscape .weather-container { - overflow-y: auto; - overflow-x: hidden; - max-height: 100%; - position: sticky; - top: 0; - align-self: start; - } - - /* Better horizontal space usage in landscape */ - body.landscape .departure-card { - min-height: 120px; - } - - body.landscape .line-number-box { - min-width: 120px; - width: 120px; - } - - body.landscape .line-number-large { - font-size: 3.5em; - } - - /* Site containers in landscape should be more compact */ - body.landscape .site-container { - margin-bottom: 15px; - } - - body.landscape .site-header { - font-size: 1em; - padding: 8px 12px; - } - - /* Vertical orientation (90 degrees rotated) */ - body.vertical { - max-width: 100%; - height: 100vh; - padding: 0; - margin: 0; - overflow: hidden; - display: flex; - justify-content: center; - align-items: center; - } - - body.vertical #content-wrapper { - transform: rotate(90deg); - transform-origin: center center; - position: absolute; - width: 100vh; /* Use viewport height for width */ - height: 100vw; /* Use viewport width for height */ - max-width: 800px; /* Limit width for better readability */ - padding: 20px; - box-sizing: border-box; - overflow-y: auto; - background-color: transparent; /* Remove background color */ - left: 50%; - top: 50%; - margin-left: -50vh; /* Half of width */ - margin-top: -50vw; /* Half of height */ - } - - body.vertical .config-button { - transform: rotate(-90deg); - position: fixed; - right: 10px; - bottom: 10px; /* Changed from top to bottom */ - z-index: 1000; - } - - body.vertical .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - } - - /* Upside down orientation (180 degrees rotated) */ - body.upsidedown { - max-width: 100%; - height: 100vh; - padding: 0; - margin: 0; - overflow: hidden; - display: flex; - justify-content: center; - align-items: center; - } - - body.upsidedown #content-wrapper { - transform: rotate(180deg); - transform-origin: center center; - position: absolute; - width: 100%; - max-width: 800px; - padding: 20px; - box-sizing: border-box; - overflow-y: auto; - background-color: transparent; /* Remove background color */ - left: 50%; - top: 50%; - margin-left: -400px; /* Half of max-width */ - margin-top: -50vh; /* Half of viewport height */ - } - - body.upsidedown .config-button { - transform: rotate(-180deg); - position: fixed; - right: 10px; - bottom: 10px; /* Changed from top to bottom */ - z-index: 1000; - } - - body.upsidedown .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - } - - /* Vertical reverse orientation (270 degrees rotated) */ - body.vertical-reverse { - max-width: 100%; - height: 100vh; - padding: 0; - margin: 0; - overflow: hidden; - display: flex; - justify-content: center; - align-items: center; - } - - body.vertical-reverse #content-wrapper { - transform: rotate(270deg); - transform-origin: center center; - position: absolute; - width: 100vh; /* Use viewport height for width */ - height: 100vw; /* Use viewport width for height */ - max-width: none; /* Remove max-width limitation */ - padding: 20px; - box-sizing: border-box; - overflow: visible; /* Show all content */ - background-color: transparent; /* Remove background color to show background image */ - left: 50%; - top: 50%; - margin-left: -50vh; /* Half of width */ - margin-top: -50vw; /* Half of height */ - } - - body.vertical-reverse .config-button { - transform: rotate(-270deg); - position: fixed; - right: 10px; - bottom: 10px; /* Changed from top to bottom */ - z-index: 1000; - } - - body.vertical-reverse .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - width: 100%; /* Ensure full width */ - } - - /* Mode indicators - using a class instead of pseudo-element */ - .mode-indicator { - font-size: 0.7em; - color: #666; - font-weight: normal; - display: inline; - } +/* ======================================== + CSS Custom Properties + ======================================== */ +:root { + --color-primary: #0061a1; + --color-primary-dark: #004d80; + --color-primary-light: #0077cc; + --color-accent: #4fc3f7; + --color-bg: #f5f5f5; + --color-bg-dark: #222; + --color-text: #333; + --color-text-light: #f5f5f5; + --color-text-muted: #666; + --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; + --color-border-dark: #555; + --color-surface: white; + --color-surface-dark: #333; + --color-surface-darker: #444; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --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); +} + +/* ======================================== + Base Styles + ======================================== */ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: var(--color-bg); + color: var(--color-text); + transition: background-color 0.5s ease, color 0.5s ease; + height: 100vh; + overflow: hidden; +} + +/* For normal orientation on narrow screens, add padding */ +@media (max-width: 1199px) { + body.normal { + padding: 20px; + } +} + +/* Auto-apply wide layout for normal orientation on large screens */ +@media (min-width: 1200px) { + body.normal { + max-width: 100%; + padding: 8px 12px 0 12px; + } + + body.normal #content-wrapper { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 8px; + height: 100vh; + max-height: 100vh; + overflow: hidden; + } + + body.normal .clock-container { + grid-row: 1; + margin-bottom: 0; + padding: 6px 16px; + } + + body.normal .main-content-grid { + grid-row: 2; + display: block; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + width: 100%; + } + + body.normal .departure-container { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin-bottom: 0; + width: 100%; + box-sizing: border-box; + padding: 0; + } + + body.normal .departure-container > * { + min-width: 0; + max-width: 100%; + } + + body.normal .weather-section { + grid-row: 3; + position: sticky; + bottom: 35px; + background-color: inherit; + padding: 8px 0 0 0; + margin-top: 0; + } + + body.normal .weather-container { + margin: 0; + max-width: 100%; + } +} + +/* ======================================== + Dark Mode - Layout-level overrides only + ======================================== */ +body.dark-mode { + background-color: var(--color-bg-dark); + color: var(--color-text-light); +} + +body.dark-mode .departure-card { + background-color: var(--color-surface-dark); + border-left-color: var(--color-primary-light); +} + +/* ======================================== + Orientation: Normal + ======================================== */ +body.normal { + max-width: 800px; +} + +body.normal .departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +/* ======================================== + Orientation: Landscape (Kiosk Mode) + ======================================== */ +body.landscape { + max-width: 100%; + 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 auto 1fr auto auto; + gap: var(--kiosk-gap); + height: 100vh; + max-height: 100vh; + overflow: hidden; +} + +body.landscape .clock-container { + grid-row: 1; + margin-bottom: 0; +} + +/* Compact weather bar sits in row 2 */ +body.landscape #compact-weather-bar { + grid-row: 2; +} + +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(380px, 1fr)); + gap: var(--kiosk-gap); + overflow-y: auto; + overflow-x: hidden; + padding-right: 4px; + min-height: 0; + height: 100%; +} + +body.landscape .weather-container { + overflow-y: auto; + overflow-x: hidden; + max-height: 100%; + position: sticky; + top: 0; + align-self: start; +} + +body.landscape .departure-card { + 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: 90px; + width: 90px; +} + +body.landscape .line-number-large { + font-size: 3.5em; +} + +body.landscape .site-container { + margin-bottom: 6px; +} + +body.landscape .site-header { + font-size: 1em; + 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; +} + +/* ======================================== + Orientation: Vertical (90deg) + ======================================== */ +body.vertical { + max-width: 100%; + height: 100vh; + padding: 0; + margin: 0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +body.vertical #content-wrapper { + transform: rotate(90deg); + transform-origin: center center; + position: absolute; + width: 100vh; + height: 100vw; + max-width: 800px; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; + background-color: transparent; + left: 50%; + top: 50%; + margin-left: -50vh; + margin-top: -50vw; +} + +body.vertical .config-button { + transform: rotate(-90deg); + position: fixed; + right: 10px; + bottom: 10px; + z-index: 1000; +} + +body.vertical .departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +/* ======================================== + Orientation: Upside Down (180deg) + ======================================== */ +body.upsidedown { + max-width: 100%; + height: 100vh; + padding: 0; + margin: 0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +body.upsidedown #content-wrapper { + transform: rotate(180deg); + transform-origin: center center; + position: absolute; + width: 100%; + max-width: 800px; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; + background-color: transparent; + left: 50%; + top: 50%; + transform: rotate(180deg) translate(50%, 50%); +} + +body.upsidedown .config-button { + transform: rotate(-180deg); + position: fixed; + right: 10px; + bottom: 10px; + z-index: 1000; +} + +body.upsidedown .departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +/* ======================================== + Orientation: Vertical Reverse (270deg) + ======================================== */ +body.vertical-reverse { + max-width: 100%; + height: 100vh; + padding: 0; + margin: 0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +body.vertical-reverse #content-wrapper { + transform: rotate(270deg); + transform-origin: center center; + position: absolute; + width: 100vh; + height: 100vw; + max-width: none; + padding: 20px; + box-sizing: border-box; + overflow: visible; + background-color: transparent; + left: 50%; + top: 50%; + margin-left: -50vh; + margin-top: -50vw; +} + +body.vertical-reverse .config-button { + transform: rotate(-270deg); + position: fixed; + right: 10px; + bottom: 10px; + z-index: 1000; +} + +body.vertical-reverse .departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + width: 100%; +} + +/* ======================================== + Mode Indicator + ======================================== */ +.mode-indicator { + font-size: 0.7em; + color: var(--color-text-muted); + font-weight: normal; + display: inline; +} diff --git a/public/js/components/Clock.js b/public/js/components/Clock.js index 3032431..fee7c6f 100644 --- a/public/js/components/Clock.js +++ b/public/js/components/Clock.js @@ -161,5 +161,8 @@ class Clock { } } -// Export the Clock class for use in other modules +// ES module export +export { Clock }; + +// Keep window reference for backward compatibility window.Clock = Clock; diff --git a/public/js/components/ConfigManager.js b/public/js/components/ConfigManager.js index 98b52a5..d823d62 100644 --- a/public/js/components/ConfigManager.js +++ b/public/js/components/ConfigManager.js @@ -68,6 +68,9 @@ class ConfigManager { buttonContainer.id = this.options.configButtonId; buttonContainer.className = 'config-button'; buttonContainer.title = 'Settings'; + buttonContainer.setAttribute('role', 'button'); + buttonContainer.setAttribute('aria-label', 'Open settings'); + buttonContainer.setAttribute('tabindex', '0'); buttonContainer.innerHTML = ` @@ -76,6 +79,12 @@ class ConfigManager { `; buttonContainer.addEventListener('click', () => this.toggleConfigModal()); + buttonContainer.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.toggleConfigModal(); + } + }); document.body.appendChild(buttonContainer); } @@ -83,116 +92,56 @@ class ConfigManager { * Create the configuration modal */ createConfigModal() { + const template = document.getElementById('config-modal-template'); const modalContainer = document.createElement('div'); modalContainer.id = this.options.configModalId; modalContainer.className = 'config-modal'; modalContainer.style.display = 'none'; - - modalContainer.innerHTML = ` -
-
-

Settings

- × -
-
- - - - -
-
- -
-
- - -
-
- - -
- Sunrise: --:-- | Sunset: --:-- -
-
-
- - -
-
- - -
- - - -
-
- ${this.config.backgroundImage ? `Background preview` : '
No image selected
'} -
-
-
- - -
-
- - -
-
- -
-
- - - -
- -
-
- ${this.generateSitesHTML()} -
-
- -
-
-
- - -
-
- -
-
-
- -
- `; - + modalContainer.setAttribute('role', 'dialog'); + modalContainer.setAttribute('aria-label', 'Settings'); + modalContainer.setAttribute('aria-modal', 'true'); + + // Clone the template content into the modal + modalContainer.appendChild(template.content.cloneNode(true)); + + // Set dynamic values from current config + modalContainer.querySelector('#orientation-select').value = this.config.orientation; + modalContainer.querySelector('#dark-mode-select').value = this.config.darkMode; + modalContainer.querySelector('#background-image-url').value = this.config.backgroundImage || ''; + modalContainer.querySelector('#background-opacity').value = this.config.backgroundOpacity; + modalContainer.querySelector('#opacity-value').textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`; + modalContainer.querySelector('#combine-directions').checked = this.config.combineSameDirection; + + // Populate sites + const sitesContainer = modalContainer.querySelector('#sites-container'); + if (sitesContainer) { + sitesContainer.innerHTML = this.generateSitesHTML(); + } + + // Update background preview + const preview = modalContainer.querySelector('#background-preview'); + if (preview && this.config.backgroundImage) { + const img = document.createElement('img'); + img.src = this.config.backgroundImage; + img.alt = 'Background preview'; + preview.innerHTML = ''; + preview.appendChild(img); + } + document.body.appendChild(modalContainer); // Add tab switching functionality this.setupTabs(modalContainer); // Add event listeners - modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal()); + const closeBtn = modalContainer.querySelector('.config-modal-close'); + closeBtn.addEventListener('click', () => this.hideConfigModal()); + closeBtn.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.hideConfigModal(); + } + }); modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal()); modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig()); modalContainer.querySelector('#test-image-button').addEventListener('click', () => { @@ -347,6 +296,8 @@ class ConfigManager { hideConfigModal() { const modal = document.getElementById(this.options.configModalId); modal.style.display = 'none'; + const configButton = document.getElementById(this.options.configButtonId); + if (configButton) configButton.focus(); } /** @@ -355,6 +306,8 @@ class ConfigManager { showConfigModal() { const modal = document.getElementById(this.options.configModalId); modal.style.display = 'flex'; + const closeBtn = modal.querySelector('.config-modal-close'); + if (closeBtn) closeBtn.focus(); // Reset to first tab const tabs = modal.querySelectorAll('.config-tab'); @@ -549,33 +502,10 @@ class ConfigManager { if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') { const overlay = document.createElement('div'); overlay.id = 'background-overlay'; - overlay.style.position = 'fixed'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.width = '100vw'; - overlay.style.height = '100vh'; overlay.style.backgroundImage = `url(${this.config.backgroundImage})`; - overlay.style.backgroundSize = 'cover'; - overlay.style.backgroundPosition = 'center'; overlay.style.opacity = this.config.backgroundOpacity; - overlay.style.zIndex = '-1'; - overlay.style.pointerEvents = 'none'; - - // Adjust background rotation based on orientation - if (this.config.orientation === 'vertical') { - overlay.style.transform = 'rotate(90deg) scale(2)'; - overlay.style.transformOrigin = 'center center'; - } else if (this.config.orientation === 'upsidedown') { - overlay.style.transform = 'rotate(180deg) scale(1.5)'; - overlay.style.transformOrigin = 'center center'; - } else if (this.config.orientation === 'vertical-reverse') { - overlay.style.transform = 'rotate(270deg) scale(2)'; - overlay.style.transformOrigin = 'center center'; - } else { - overlay.style.transform = 'scale(1.2)'; - overlay.style.transformOrigin = 'center center'; - } - + overlay.className = `orientation-${this.config.orientation}`; + // Insert as the first child of body document.body.insertBefore(overlay, document.body.firstChild); } @@ -614,14 +544,14 @@ class ConfigManager { return this.config.sites.map((site, index) => `
-
+
- - + +
-
- ID: - +
+ ID: +
`).join(''); @@ -644,7 +574,7 @@ class ConfigManager { try { resultsContainer.style.display = 'block'; - resultsContainer.innerHTML = '
Searching...
'; + resultsContainer.textContent = 'Searching...'; const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`); @@ -656,35 +586,32 @@ class ConfigManager { const data = await response.json(); if (!data.sites || data.sites.length === 0) { - resultsContainer.innerHTML = '
No sites found. Try a different search term.
'; + resultsContainer.textContent = 'No sites found. Try a different search term.'; return; } - resultsContainer.innerHTML = data.sites.map(site => ` -
-
${site.name}
-
ID: ${site.id}
-
- `).join(''); - - // Add click handlers to search results - resultsContainer.querySelectorAll('.site-search-result').forEach(result => { - result.addEventListener('click', () => { - const siteId = result.dataset.siteId; - const siteName = result.dataset.siteName; - this.addSiteFromSearch(siteId, siteName); + resultsContainer.innerHTML = ''; + data.sites.forEach(site => { + const resultDiv = document.createElement('div'); + resultDiv.className = 'site-search-result'; + resultDiv.dataset.siteId = site.id; + resultDiv.dataset.siteName = site.name; + + const nameDiv = document.createElement('div'); + nameDiv.textContent = site.name; + const idDiv = document.createElement('div'); + idDiv.textContent = `ID: ${site.id}`; + + resultDiv.appendChild(nameDiv); + resultDiv.appendChild(idDiv); + + resultDiv.addEventListener('click', () => { + this.addSiteFromSearch(site.id, site.name); searchInput.value = ''; resultsContainer.style.display = 'none'; }); - - result.addEventListener('mouseenter', () => { - result.style.backgroundColor = '#f5f5f5'; - }); - - result.addEventListener('mouseleave', () => { - result.style.backgroundColor = 'white'; - }); + + resultsContainer.appendChild(resultDiv); }); } catch (error) { @@ -698,7 +625,7 @@ class ConfigManager { errorMessage = `Server error: ${error.message}`; } - resultsContainer.innerHTML = `
Error: ${errorMessage}
`; + resultsContainer.textContent = `Error: ${errorMessage}`; } } @@ -782,120 +709,62 @@ class ConfigManager { if (e.target === mapModal) closeMap(); }); - // Load transit stops - search for common Stockholm areas - const loadSitesOnMap = async () => { + // Load nearby transit stops based on map center + const markersLayer = L.layerGroup().addTo(map); + const loadedSiteIds = new Set(); + + const loadNearbySites = async () => { + const center = map.getCenter(); + const zoom = map.getZoom(); + // Scale radius based on zoom: wider view = larger radius + const radius = zoom >= 15 ? 500 : zoom >= 13 ? 1500 : zoom >= 11 ? 4000 : 8000; + try { - // Start with focused search to avoid too many markers - const searchTerms = ['Ambassaderna']; - const allSites = new Map(); - - for (const term of searchTerms) { - try { - const response = await fetch(`/api/sites/search?q=${encodeURIComponent(term)}`); - const data = await response.json(); - console.log(`Search "${term}" returned:`, data); - if (data.sites) { - data.sites.forEach(site => { - // Only add sites with valid coordinates from API - const lat = site.lat || site.latitude; - const lon = site.lon || site.longitude; - - if (lat && lon && !isNaN(parseFloat(lat)) && !isNaN(parseFloat(lon))) { - if (!allSites.has(site.id)) { - allSites.set(site.id, { - id: site.id, - name: site.name, - lat: parseFloat(lat), - lon: parseFloat(lon) - }); - } - } else { - console.log(`Site ${site.id} (${site.name}) missing coordinates, skipping`); - } - }); - } - } catch (err) { - console.error(`Error searching for ${term}:`, err); - } - } - - // Add known site with coordinates as fallback - const knownSites = [ - { id: '1411', name: 'Ambassaderna', lat: 59.3293, lon: 18.0686 } - ]; - - knownSites.forEach(site => { - if (!allSites.has(site.id)) { - allSites.set(site.id, site); - } - }); - - const sitesArray = Array.from(allSites.values()); - console.log(`Loading ${sitesArray.length} sites on map with coordinates:`, sitesArray); - - if (sitesArray.length > 0) { - const markers = []; - sitesArray.forEach(site => { - const lat = site.lat; - const lon = site.lon; - - if (!lat || !lon || isNaN(lat) || isNaN(lon)) { - console.warn(`Invalid coordinates for site ${site.id}, skipping`); - return; - } - - // Create custom icon + const response = await fetch(`/api/sites/nearby?lat=${center.lat}&lon=${center.lng}&radius=${radius}`); + const data = await response.json(); + + if (data.sites) { + data.sites.forEach(site => { + if (loadedSiteIds.has(site.id)) return; + const lat = parseFloat(site.lat); + const lon = parseFloat(site.lon); + if (!lat || !lon || isNaN(lat) || isNaN(lon)) return; + + loadedSiteIds.add(site.id); + const customIcon = L.divIcon({ className: 'custom-marker', - html: `
🚌
`, + html: '
🚌
', iconSize: [30, 30], iconAnchor: [15, 15] }); - - const marker = L.marker([lat, lon], { icon: customIcon }).addTo(map); - + + const marker = L.marker([lat, lon], { icon: customIcon }); + const popupContent = `
${site.name}
ID: ${site.id}
-
`; - + marker.bindPopup(popupContent); - markers.push(marker); - - // Make marker clickable to open popup - marker.on('click', function() { - this.openPopup(); - }); + markersLayer.addLayer(marker); }); - - // Fit map to show all markers, or center on first marker - if (markers.length > 0) { - if (markers.length === 1) { - map.setView(markers[0].getLatLng(), 15); - } else { - const group = new L.featureGroup(markers); - map.fitBounds(group.getBounds().pad(0.1)); - } - } else { - // If no markers, show message - console.log('No sites with coordinates found'); - } + console.log(`Loaded ${data.sites.length} nearby sites (${loadedSiteIds.size} total on map)`); } } catch (error) { - console.error('Error loading sites on map:', error); + console.error('Error loading nearby sites:', error); } }; - - // Load sites after map is initialized - setTimeout(() => { - loadSitesOnMap(); - }, 500); + + // Load sites on init and when map is panned/zoomed + setTimeout(() => loadNearbySites(), 300); + map.on('moveend', loadNearbySites); // Handle site selection from map popup - use event delegation on the modal mapModal.addEventListener('click', (e) => { @@ -947,73 +816,42 @@ class ConfigManager { const data = await response.json(); if (data.sites && data.sites.length > 0) { - // Clear existing markers - map.eachLayer((layer) => { - if (layer instanceof L.Marker) { - map.removeLayer(layer); - } - }); - - // Clear existing markers - map.eachLayer((layer) => { - if (layer instanceof L.Marker) { - map.removeLayer(layer); - } - }); - - // Add markers for search results - const markers = []; + // Clear existing markers and reset tracking + markersLayer.clearLayers(); + loadedSiteIds.clear(); + + const searchMarkers = []; data.sites.forEach(site => { - let lat = site.lat || site.latitude; - let lon = site.lon || site.longitude; - - // If no coordinates, use approximate location based on map center - if (!lat || !lon) { - const center = map.getCenter(); - lat = center.lat + (Math.random() - 0.5) * 0.05; - lon = center.lon + (Math.random() - 0.5) * 0.05; - } - - // Create custom icon + const lat = parseFloat(site.lat); + const lon = parseFloat(site.lon); + if (!lat || !lon || isNaN(lat) || isNaN(lon)) return; + + loadedSiteIds.add(site.id); + const customIcon = L.divIcon({ - className: 'custom-transit-marker', - html: `
🚌
`, + className: 'custom-marker', + html: '
🚌
', iconSize: [32, 32], - iconAnchor: [16, 16], - popupAnchor: [0, -16] + iconAnchor: [16, 16] }); - - const marker = L.marker([lat, lon], { - icon: customIcon, - title: site.name - }).addTo(map); - - const popupContent = document.createElement('div'); - popupContent.style.minWidth = '220px'; - popupContent.innerHTML = ` -
- ${site.name} - ID: ${site.id} + + const marker = L.marker([lat, lon], { icon: customIcon }); + marker.bindPopup(` +
+ ${site.name}
+ ID: ${site.id}
+
- - `; - - marker.bindPopup(popupContent); - markers.push(marker); - - marker.on('click', function() { - this.openPopup(); - }); + `); + markersLayer.addLayer(marker); + searchMarkers.push(marker); }); - - // Fit map to show results - if (markers.length > 0) { - const group = new L.featureGroup(markers); + + if (searchMarkers.length > 0) { + const group = new L.featureGroup(searchMarkers); map.fitBounds(group.getBounds().pad(0.1)); } } @@ -1159,5 +997,8 @@ class ConfigManager { } } -// Export the ConfigManager class for use in other modules +// ES module export +export { ConfigManager }; + +// Keep window reference for backward compatibility window.ConfigManager = ConfigManager; diff --git a/public/js/components/DeparturesManager.js b/public/js/components/DeparturesManager.js index f52147c..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); } @@ -424,7 +418,7 @@ class DeparturesManager { this.updateExistingCards(departures); } - this.currentDepartures = JSON.parse(JSON.stringify(departures)); + this.currentDepartures = structuredClone(departures); } /** @@ -444,8 +438,8 @@ class DeparturesManager { this.updateCardContent(existingCard, departure); } else { const newCard = this.createDepartureCard(departure); - newCard.style.opacity = '0'; - + newCard.classList.add('card-entering'); + if (index === 0) { this.container.prepend(newCard); } else if (index >= this.container.children.length) { @@ -453,24 +447,22 @@ class DeparturesManager { } else { this.container.insertBefore(newCard, this.container.children[index]); } - - setTimeout(() => { - newCard.style.transition = 'opacity 0.5s ease-in'; - newCard.style.opacity = '1'; - }, 10); + + requestAnimationFrame(() => { + newCard.classList.add('card-visible'); + }); } }); const newDepartureIds = newDepartures.map(d => d.journey.id.toString()); currentCards.forEach(card => { if (!newDepartureIds.includes(card.dataset.journeyId)) { - card.style.transition = 'opacity 0.5s ease-out'; - card.style.opacity = '0'; - setTimeout(() => { - if (card.parentNode) { - card.parentNode.removeChild(card); - } - }, 500); + card.classList.add('card-leaving'); + card.addEventListener('transitionend', () => { + card.remove(); + }, { once: true }); + // Fallback removal if transitionend doesn't fire + setTimeout(() => card.remove(), 600); } }); } @@ -483,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); @@ -505,13 +495,10 @@ class DeparturesManager { * @param {HTMLElement} element - Element to highlight */ highlightElement(element) { - element.style.transition = 'none'; - element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)'; - - setTimeout(() => { - element.style.transition = 'background-color 1.5s ease-out'; - element.style.backgroundColor = 'transparent'; - }, 10); + element.classList.remove('highlight-flash'); + // Force reflow to restart animation + void element.offsetWidth; + element.classList.add('highlight-flash'); } /** @@ -632,5 +619,8 @@ class DeparturesManager { } } -// Export the class +// ES module export +export { DeparturesManager }; + +// Keep window reference for backward compatibility window.DeparturesManager = DeparturesManager; 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 9f2b81a..7058da4 100644 --- a/public/js/components/WeatherManager.js +++ b/public/js/components/WeatherManager.js @@ -8,7 +8,7 @@ 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 || '4d8fb5b93d4af21d66a2948710284366'; + const apiKey = options.apiKey || window.OPENWEATHERMAP_API_KEY || ''; this.options = { latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude @@ -24,6 +24,7 @@ class WeatherManager { this.sunTimes = null; this.isDarkMode = false; this.lastUpdated = null; + this.daylightBarUpdateInterval = null; // Initialize this.init(); @@ -34,6 +35,23 @@ class WeatherManager { */ 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(); @@ -83,17 +101,17 @@ class WeatherManager { */ async fetchWeatherData() { try { - // Fetch current weather - const currentWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&appid=${this.options.apiKey}`; + // 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 hourly forecast - const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&appid=${this.options.apiKey}`; + + // 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(); @@ -107,7 +125,7 @@ class WeatherManager { this.lastUpdated = new Date(); // Extract sunrise and sunset times from the API response - this.updateSunTimesFromApi(currentWeatherData); + await this.updateSunTimesFromApi(currentWeatherData); // Update the UI with the new data this.updateWeatherUI(); @@ -158,7 +176,7 @@ class WeatherManager { * Process forecast data from API response */ processForecast(data) { - // Get the next 7 forecasts (covering about 24 hours) + // 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 { @@ -177,43 +195,42 @@ class WeatherManager { * Get weather icon URL from icon code */ getWeatherIconUrl(iconCode) { - return `https://openweathermap.org/img/wn/${iconCode}@2x.png`; + return `https://openweathermap.org/img/wn/${iconCode}@4x.png`; } /** - * Determine if icon represents sun (even behind clouds) + * 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 */ - isSunIcon(iconCode, condition) { - // Icon codes: 01d, 01n = clear, 02d, 02n = few clouds, 03d, 03n = scattered, 04d, 04n = broken clouds - const sunIconCodes = ['01d', '01n', '02d', '02n', '03d', '03n', '04d', '04n']; - return sunIconCodes.includes(iconCode) || - condition.includes('Clear') || - condition.includes('Clouds'); + 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 []; } - + /** - * Check if icon is clear sun (no clouds) + * Apply weather icon CSS classes to an element */ - isClearSun(iconCode, condition) { - const clearIconCodes = ['01d', '01n']; - return clearIconCodes.includes(iconCode) || condition === 'Clear'; - } - - /** - * Check if icon is sun behind clouds - */ - isSunBehindClouds(iconCode, condition) { - const cloudIconCodes = ['02d', '02n', '03d', '03n', '04d', '04n']; - return cloudIconCodes.includes(iconCode) || (condition.includes('Clouds') && !condition.includes('Clear')); - } - - /** - * Determine if icon represents snow - */ - isSnowIcon(iconCode, condition) { - // Icon code: 13d, 13n = snow - const snowIconCodes = ['13d', '13n']; - return snowIconCodes.includes(iconCode) || condition.includes('Snow'); + 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); + } } /** @@ -224,7 +241,7 @@ class WeatherManager { temperature: 7.1, condition: 'Clear', description: 'clear sky', - icon: 'https://openweathermap.org/img/wn/01d@2x.png', + icon: 'https://openweathermap.org/img/wn/01d@4x.png', iconCode: '01d', wind: { speed: 14.8, @@ -255,7 +272,7 @@ class WeatherManager { 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@2x.png' : 'https://openweathermap.org/img/wn/02n@2x.png', + 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 @@ -287,18 +304,8 @@ class WeatherManager { if (iconElement) { iconElement.src = this.weatherData.icon; iconElement.alt = this.weatherData.description; - // Add classes and data attributes for color filtering iconElement.setAttribute('data-condition', this.weatherData.condition); - iconElement.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun'); - if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) { - iconElement.classList.add('weather-snow'); - } else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) { - iconElement.classList.add('weather-sun', 'weather-clear-sun'); - } else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) { - iconElement.classList.add('weather-sun', 'weather-clouds-sun'); - } else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) { - iconElement.classList.add('weather-sun'); - } + this.applyWeatherIconClasses(iconElement, this.weatherData.iconCode, this.weatherData.condition); } const temperatureElement = document.querySelector('#custom-weather .temperature'); @@ -320,15 +327,7 @@ class WeatherManager { nowIcon.alt = this.weatherData.description; nowIcon.width = 56; nowIcon.setAttribute('data-condition', this.weatherData.condition); - if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) { - nowIcon.classList.add('weather-snow'); - } else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) { - nowIcon.classList.add('weather-sun', 'weather-clear-sun'); - } else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) { - nowIcon.classList.add('weather-sun', 'weather-clouds-sun'); - } else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) { - nowIcon.classList.add('weather-sun'); - } + this.applyWeatherIconClasses(nowIcon, this.weatherData.iconCode, this.weatherData.condition); nowElement.innerHTML = `
Nu
@@ -349,15 +348,7 @@ class WeatherManager { forecastIcon.alt = forecast.description; forecastIcon.width = 56; forecastIcon.setAttribute('data-condition', forecast.condition); - if (this.isSnowIcon(forecast.iconCode, forecast.condition)) { - forecastIcon.classList.add('weather-snow'); - } else if (this.isClearSun(forecast.iconCode, forecast.condition)) { - forecastIcon.classList.add('weather-sun', 'weather-clear-sun'); - } else if (this.isSunBehindClouds(forecast.iconCode, forecast.condition)) { - forecastIcon.classList.add('weather-sun', 'weather-clouds-sun'); - } else if (this.isSunIcon(forecast.iconCode, forecast.condition)) { - forecastIcon.classList.add('weather-sun'); - } + this.applyWeatherIconClasses(forecastIcon, forecast.iconCode, forecast.condition); forecastElement.innerHTML = `
${timeString}
@@ -375,128 +366,151 @@ class WeatherManager { 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 */ - updateSunTimesFromApi(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'); - this.updateSunTimesFromCalculation(); + await this.updateSunTimesFromCalculation(); return; } - + try { - const today = new Date(); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - // Create Date objects from Unix timestamps + // Create Date objects from Unix timestamps for today const sunrise = new Date(data.sys.sunrise * 1000); const sunset = new Date(data.sys.sunset * 1000); - - // Use calculated times for tomorrow - const tomorrowTimes = this.calculateSunTimes(tomorrow); - + + // 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); - this.updateSunTimesFromCalculation(); + await this.updateSunTimesFromCalculation(); } } /** - * Update sunrise and sunset times using calculation + * Update sunrise and sunset times using sunrise-sunset.org API + * Falls back to hardcoded defaults if the API is unreachable */ async updateSunTimesFromCalculation() { - try { - // Calculate sun times based on date and location - const today = new Date(); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - + const [todayData, tomorrowData] = await Promise.all([ + this.fetchSunTimes('today'), + this.fetchSunTimes('tomorrow') + ]); + this.sunTimes = { - today: this.calculateSunTimes(today), - tomorrow: this.calculateSunTimes(tomorrow) + today: todayData, + tomorrow: tomorrowData }; - - console.log('Sun times updated from calculation:', this.sunTimes); + + console.log('Sun times updated from sunrise-sunset.org:', this.sunTimes); return this.sunTimes; } catch (error) { - console.error('Error updating sun times from calculation:', error); - // Fallback to default times if calculation fails + console.error('Error fetching sun times from API, using defaults:', error); const defaultSunrise = new Date(); - defaultSunrise.setHours(6, 45, 0, 0); - + defaultSunrise.setHours(7, 0, 0, 0); + const defaultSunset = new Date(); - defaultSunset.setHours(17, 32, 0, 0); - + defaultSunset.setHours(16, 0, 0, 0); + this.sunTimes = { - today: { - sunrise: defaultSunrise, - sunset: defaultSunset - }, - tomorrow: { - sunrise: defaultSunrise, - sunset: defaultSunset - } + today: { sunrise: defaultSunrise, sunset: defaultSunset }, + tomorrow: { sunrise: defaultSunrise, sunset: defaultSunset } }; return this.sunTimes; } } - + /** - * Calculate sunrise and sunset times for a given date - * Uses a simplified algorithm + * Fetch sunrise/sunset times from sunrise-sunset.org API + * @param {string} date - 'today', 'tomorrow', or YYYY-MM-DD + * @returns {Object} { sunrise: Date, sunset: Date } */ - calculateSunTimes(date) { - // This is a simplified calculation - // For more accuracy, you would use a proper astronomical calculation - - // Get day of year - const start = new Date(date.getFullYear(), 0, 0); - const diff = date - start; - const oneDay = 1000 * 60 * 60 * 24; - const dayOfYear = Math.floor(diff / oneDay); - - // Calculate sunrise and sunset times based on latitude and day of year - // This is a very simplified model - const latitude = this.options.latitude; - - // Base sunrise and sunset times (in hours) - let baseSunrise = 6; // 6 AM - let baseSunset = 18; // 6 PM - - // Adjust for latitude and season - // Northern hemisphere seasonal adjustment - const seasonalAdjustment = Math.sin((dayOfYear - 81) / 365 * 2 * Math.PI) * 3; - - // Latitude adjustment (higher latitudes have more extreme day lengths) - const latitudeAdjustment = Math.abs(latitude) / 90 * 2; - - // Apply adjustments - baseSunrise += seasonalAdjustment * latitudeAdjustment * -1; - baseSunset += seasonalAdjustment * latitudeAdjustment; - - // Create Date objects - const sunrise = new Date(date); - sunrise.setHours(Math.floor(baseSunrise), Math.round((baseSunrise % 1) * 60), 0, 0); - - const sunset = new Date(date); - sunset.setHours(Math.floor(baseSunset), Math.round((baseSunset % 1) * 60), 0, 0); - - return { sunrise, sunset }; + 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) + }; } /** @@ -588,7 +602,136 @@ class WeatherManager { 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 + } } -// Export the WeatherManager class for use in other modules +// ES module export +export { WeatherManager }; + +// Keep window reference for backward compatibility window.WeatherManager = WeatherManager; diff --git a/public/js/main.js b/public/js/main.js index 3c06ac9..a7ad688 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -3,22 +3,26 @@ * Initializes all components when the DOM is ready */ +import { Constants } from './utils/constants.js'; +import { logger } from './utils/logger.js'; +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 */ function ensureContentWrapper() { if (!document.getElementById('content-wrapper')) { - if (window.logger) { - window.logger.info('Creating content wrapper'); - } else { - console.log('Creating content wrapper'); - } + logger.info('Creating content wrapper'); const wrapper = document.createElement('div'); wrapper.id = 'content-wrapper'; - + // Move all body children to the wrapper except excluded elements const excludedElements = ['config-button', 'config-modal', 'background-overlay']; - + // Create an array of nodes to move (can't modify while iterating) const nodesToMove = []; for (let i = 0; i < document.body.children.length; i++) { @@ -27,12 +31,12 @@ function ensureContentWrapper() { nodesToMove.push(child); } } - + // Move the nodes to the wrapper nodesToMove.forEach(node => { wrapper.appendChild(node); }); - + // Add the wrapper back to the body document.body.appendChild(wrapper); } @@ -40,80 +44,60 @@ function ensureContentWrapper() { // Initialize components when the DOM is loaded document.addEventListener('DOMContentLoaded', async function() { - if (window.logger) { - window.logger.info('DOM fully loaded'); - } else { - console.log('DOM fully loaded'); - } - + logger.info('DOM fully loaded'); + try { // Initialize ConfigManager first - if (window.logger) { - window.logger.info('Creating ConfigManager...'); - } else { - console.log('Creating ConfigManager...'); - } + logger.info('Creating ConfigManager...'); window.configManager = new ConfigManager({ defaultOrientation: 'normal', defaultDarkMode: 'auto' }); - - // Note: ConfigManager already creates the config button and modal - + // Initialize Clock - const timezone = window.Constants?.TIMEZONE || 'Europe/Stockholm'; + const timezone = Constants.TIMEZONE || 'Europe/Stockholm'; window.clock = new Clock({ elementId: 'clock', timezone: timezone }); - + // Initialize WeatherManager with location from window config or constants - const defaultLat = window.DEFAULT_LOCATION?.latitude || - (window.Constants?.DEFAULT_LOCATION?.LATITUDE) || 59.3293; - const defaultLon = window.DEFAULT_LOCATION?.longitude || - (window.Constants?.DEFAULT_LOCATION?.LONGITUDE) || 18.0686; + const defaultLat = window.DEFAULT_LOCATION?.latitude || + Constants.DEFAULT_LOCATION.LATITUDE || 59.3293; + const defaultLon = window.DEFAULT_LOCATION?.longitude || + Constants.DEFAULT_LOCATION.LONGITUDE || 18.0686; window.weatherManager = new WeatherManager({ latitude: defaultLat, longitude: defaultLon }); - - // Initialize departures - use DeparturesManager - if (typeof DeparturesManager !== 'undefined') { - window.departuresManager = new DeparturesManager({ - containerId: 'departures', - statusId: 'status', - lastUpdatedId: 'last-updated' - }); - } else if (typeof initDepartures === 'function') { - // Fallback to legacy function if DeparturesManager not available - initDepartures(); - } - + + // Initialize DeparturesManager + window.departuresManager = new DeparturesManager({ + containerId: 'departures', + statusId: 'status', + 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); }); - + document.addEventListener('configChanged', event => { if (['vertical', 'upsidedown', 'vertical-reverse'].includes(event.detail.config.orientation)) { ensureContentWrapper(); } }); - + // Ensure content wrapper exists initially ensureContentWrapper(); - - if (window.logger) { - window.logger.info('All components initialized successfully'); - } else { - console.log('All components initialized successfully'); - } + + logger.info('All components initialized successfully'); } catch (error) { - if (window.logger) { - window.logger.error('Error during initialization:', error); - } else { - console.error('Error during initialization:', error); - } + logger.error('Error during initialization:', error); const errorDiv = document.createElement('div'); errorDiv.className = 'error'; errorDiv.textContent = `Initialization error: ${error.message}`; diff --git a/public/js/utils/constants.js b/public/js/utils/constants.js index b16daf7..077668b 100644 --- a/public/js/utils/constants.js +++ b/public/js/utils/constants.js @@ -24,7 +24,7 @@ const Constants = { // Refresh intervals (in milliseconds) REFRESH: { - DEPARTURES: 5000, // 5 seconds + DEPARTURES: 30000, // 30 seconds WEATHER: 30 * 60 * 1000, // 30 minutes DARK_MODE_CHECK: 60000 // 1 minute }, @@ -85,5 +85,8 @@ const Constants = { } }; -// Export constants +// ES module export +export { Constants }; + +// Keep window reference for backward compatibility with inline scripts window.Constants = Constants; diff --git a/public/js/utils/logger.js b/public/js/utils/logger.js index a31f533..34ca4f0 100644 --- a/public/js/utils/logger.js +++ b/public/js/utils/logger.js @@ -95,6 +95,9 @@ class Logger { // Create a singleton instance const logger = new Logger(); -// Export both the class and the singleton instance +// ES module export +export { Logger, logger }; + +// Keep window reference for backward compatibility window.Logger = Logger; window.logger = logger; diff --git a/server.js b/server.js index 9234fbe..dfc68d2 100644 --- a/server.js +++ b/server.js @@ -2,7 +2,6 @@ require('dotenv').config(); const http = require('http'); -const url = require('url'); const fs = require('fs'); const path = require('path'); @@ -52,7 +51,7 @@ loadSitesConfig(); // Create HTTP server const server = http.createServer(async (req, res) => { - const parsedUrl = url.parse(req.url, true); + const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); @@ -72,7 +71,7 @@ const server = http.createServer(async (req, res) => { sitesRouter.handleSiteSearch(req, res, parsedUrl); } else if (parsedUrl.pathname === '/api/sites/nearby') { - sitesRouter.handleNearbySites(req, res, parsedUrl); + await sitesRouter.handleNearbySites(req, res, parsedUrl); } else if (parsedUrl.pathname === '/api/config') { configRouter.handleGetConfig(req, res, config); diff --git a/server/routes/departures.js b/server/routes/departures.js index f27b322..3ab872d 100644 --- a/server/routes/departures.js +++ b/server/routes/departures.js @@ -8,11 +8,19 @@ const https = require('https'); /** * Fetch departures for a specific site from SL Transport API * @param {string} siteId - The site ID to fetch departures for + * @param {Object} options - Query options + * @param {number} options.forecast - Time window in minutes (default: 60) + * @param {string} options.transport - Transport mode filter (e.g. 'BUS', 'METRO') * @returns {Promise} - Departure data */ -function fetchDeparturesForSite(siteId) { +function fetchDeparturesForSite(siteId, options = {}) { return new Promise((resolve, reject) => { - const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`; + const params = new URLSearchParams(); + params.set('forecast', options.forecast || 60); + if (options.transport) { + params.set('transport', options.transport); + } + const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures?${params}`; console.log(`Fetching data from: ${apiUrl}`); https.get(apiUrl, (res) => { @@ -23,62 +31,14 @@ function fetchDeparturesForSite(siteId) { }); res.on('end', () => { - console.log('Raw API response:', data.substring(0, 200) + '...'); - try { - try { - const parsedData = JSON.parse(data); - console.log('Successfully parsed as regular JSON'); - resolve(parsedData); - return; - } catch (jsonError) { - console.log('Not valid JSON, trying to fix format...'); - } - - if (data.startsWith('departures":')) { - data = '{' + data; - } else if (data.includes('departures":')) { - const startIndex = data.indexOf('departures":'); - if (startIndex > 0) { - data = '{' + data.substring(startIndex); - } - } - - data = data.replace(/}{\s*"/g, '},{"'); - data = data.replace(/"([^"]+)":\s*([^,{}\[\]]+)(?=")/g, '"$1": $2,'); - data = data.replace(/,\s*}/g, '}').replace(/,\s*\]/g, ']'); - - try { - const parsedData = JSON.parse(data); - console.log('Successfully parsed fixed JSON'); - - if (parsedData && parsedData.departures && parsedData.departures.length > 0) { - console.log('Sample departure structure:', JSON.stringify(parsedData.departures[0], null, 2)); - - const sample = parsedData.departures[0]; - console.log('Direction fields:', { - direction: sample.direction, - directionText: sample.directionText, - directionCode: sample.directionCode, - destination: sample.destination - }); - } - - resolve(parsedData); - } catch (parseError) { - console.error('Failed to parse even after fixing:', parseError); - // Return empty departures array instead of rejecting to be more resilient - resolve({ - departures: [], - error: 'Failed to parse API response: ' + parseError.message - }); - } + const parsedData = JSON.parse(data); + resolve(parsedData); } catch (error) { - console.error('Error processing API response:', error); - // Return empty departures array instead of rejecting to be more resilient + console.error('Error parsing departures API response:', error); resolve({ departures: [], - error: 'Error processing API response: ' + error.message + error: 'Failed to parse API response: ' + error.message }); } }); @@ -94,15 +54,19 @@ function fetchDeparturesForSite(siteId) { * @param {Array} enabledSites - Array of enabled site configurations * @returns {Promise} - Object with sites array containing departure data */ -async function fetchAllDepartures(enabledSites) { +async function fetchAllDepartures(enabledSites, globalOptions = {}) { if (enabledSites.length === 0) { return { sites: [], error: 'No enabled sites configured' }; } - + try { const sitesPromises = enabledSites.map(async (site) => { try { - const departureData = await fetchDeparturesForSite(site.id); + const siteOptions = { + forecast: site.forecast || globalOptions.forecast || 60, + transport: site.transport || globalOptions.transport || '' + }; + const departureData = await fetchDeparturesForSite(site.id, siteOptions); return { siteId: site.id, siteName: site.name, @@ -135,7 +99,11 @@ async function fetchAllDepartures(enabledSites) { async function handleDepartures(req, res, config) { try { const enabledSites = config.sites.filter(site => site.enabled); - const data = await fetchAllDepartures(enabledSites); + const globalOptions = { + forecast: config.forecast || 60, + transport: config.transport || '' + }; + const data = await fetchAllDepartures(enabledSites, globalOptions); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } catch (error) { diff --git a/server/routes/sites.js b/server/routes/sites.js index f7b2947..e8fe370 100644 --- a/server/routes/sites.js +++ b/server/routes/sites.js @@ -1,198 +1,178 @@ /** * Sites route handler * Handles site search and nearby sites queries + * + * Search uses SL Journey Planner v2 Stop Finder (real server-side search) + * Nearby uses cached site list from SL Transport API (fetched once, filtered in-memory) */ const https = require('https'); -/** - * Normalize site data from API response to consistent format - * @param {Object} site - Raw site data from API - * @returns {Object} - Normalized site object - */ -function normalizeSite(site) { - return { - id: String(site.id || site.siteId || site.SiteId || ''), - name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown', - lat: site.lat || site.latitude || site.Lat || site.Latitude || null, - lon: site.lon || site.longitude || site.Lon || site.Longitude || null - }; -} +// ── Site cache for nearby lookups ────────────────────────────────────────── +let cachedSites = null; +let cacheTimestamp = null; +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours /** - * Parse sites from API response (handles multiple response formats) - * @param {Object|Array} parsedData - Parsed JSON data from API - * @returns {Array} - Array of normalized sites + * Fetch and cache all sites from the SL Transport API + * The /v1/sites endpoint returns ~6500 sites with coordinates. + * We fetch this once and reuse it for nearby-site lookups. + * @returns {Promise} Array of normalized site objects */ -function parseSitesFromResponse(parsedData) { - let sites = []; - - if (Array.isArray(parsedData)) { - sites = parsedData.map(normalizeSite); - } else if (parsedData.sites && Array.isArray(parsedData.sites)) { - sites = parsedData.sites.map(normalizeSite); - } else if (parsedData.ResponseData && parsedData.ResponseData.Result) { - sites = parsedData.ResponseData.Result.map(normalizeSite); +function getAllSites() { + if (cachedSites && cacheTimestamp && (Date.now() - cacheTimestamp < CACHE_TTL)) { + return Promise.resolve(cachedSites); } - - return sites; + + return new Promise((resolve, reject) => { + console.log('Fetching full site list from SL Transport API (will cache for 24h)...'); + https.get('https://transport.integration.sl.se/v1/sites', (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + try { + const sites = JSON.parse(data); + cachedSites = sites.map(site => ({ + id: String(site.id), + name: site.name || 'Unknown', + lat: site.lat || null, + lon: site.lon || null + })); + cacheTimestamp = Date.now(); + console.log(`Cached ${cachedSites.length} sites`); + resolve(cachedSites); + } catch (error) { + console.error('Error parsing site list:', error); + reject(error); + } + }); + }).on('error', reject); + }); +} + +// ── Search via Journey Planner v2 ────────────────────────────────────────── + +/** + * Convert a Journey Planner stopId to an SL Transport siteId + * stopId format is "180XXXXX" β€” strip the "180" prefix to get the siteId + * @param {string} stopId - e.g. "18001411" + * @returns {string} siteId - e.g. "1411" + */ +function stopIdToSiteId(stopId) { + if (!stopId) return ''; + // Strip the "180" prefix (or "1800" for shorter IDs) + return stopId.replace(/^180+/, '') || stopId; } /** - * Handle site search endpoint - * @param {http.IncomingMessage} req - HTTP request object - * @param {http.ServerResponse} res - HTTP response object - * @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object + * Handle site search endpoint using SL Journey Planner v2 Stop Finder + * This endpoint does real server-side search (unlike /v1/sites which returns everything) */ function handleSiteSearch(req, res, parsedUrl) { - const query = parsedUrl.query.q; + const query = parsedUrl.searchParams.get('q'); if (!query || query.length < 2) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Query must be at least 2 characters' })); return; } - - const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(query)}`; - console.log(`Searching sites: ${searchUrl}`); - + + // any_obj_filter_sf=2 restricts results to stops only + const searchUrl = `https://journeyplanner.integration.sl.se/v2/stop-finder?name_sf=${encodeURIComponent(query)}&type_sf=any&any_obj_filter_sf=2`; + console.log(`Searching sites via Journey Planner: ${searchUrl}`); + https.get(searchUrl, (apiRes) => { let data = ''; - + if (apiRes.statusCode < 200 || apiRes.statusCode >= 300) { - console.error(`API returned status code: ${apiRes.statusCode}`); + console.error(`Journey Planner API returned status: ${apiRes.statusCode}`); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] })); return; } - - apiRes.on('data', (chunk) => { - data += chunk; - }); - + + apiRes.on('data', chunk => { data += chunk; }); apiRes.on('end', () => { try { - console.log('Raw API response:', data.substring(0, 500)); - const parsedData = JSON.parse(data); - console.log('Parsed data:', JSON.stringify(parsedData).substring(0, 500)); - - const sites = parseSitesFromResponse(parsedData); - - if (sites.length > 0) { - console.log('Sample site structure:', JSON.stringify(sites[0], null, 2)); - const sitesWithCoords = sites.filter(s => s.lat && s.lon); - console.log(`Found ${sites.length} sites, ${sitesWithCoords.length} with coordinates`); - } else { - console.log('No sites found'); - } - + const parsed = JSON.parse(data); + const locations = parsed.locations || []; + + const sites = locations + .filter(loc => loc.type === 'stop' && loc.properties && loc.properties.stopId) + .map(loc => ({ + id: stopIdToSiteId(loc.properties.stopId), + name: loc.disassembledName || loc.name || 'Unknown', + lat: loc.coord ? loc.coord[0] : null, + lon: loc.coord ? loc.coord[1] : null + })); + + console.log(`Search "${query}" returned ${sites.length} stops`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ sites })); } catch (error) { - console.error('Error parsing site search response:', error); - console.error('Response data:', data.substring(0, 500)); + console.error('Error parsing search response:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Error parsing search results', details: error.message, sites: [] })); + res.end(JSON.stringify({ error: 'Error parsing search results', sites: [] })); } }); }).on('error', (error) => { console.error('Error searching sites:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Error searching sites', details: error.message, sites: [] })); + res.end(JSON.stringify({ error: 'Error searching sites', sites: [] })); }); } +// ── Nearby sites from cache ──────────────────────────────────────────────── + /** - * Calculate distance between two coordinates (simple approximation) - * @param {number} lat1 - Latitude of point 1 - * @param {number} lon1 - Longitude of point 1 - * @param {number} lat2 - Latitude of point 2 - * @param {number} lon2 - Longitude of point 2 - * @returns {number} - Distance in meters + * Calculate distance between two coordinates using equirectangular approximation + * Accurate enough for distances under ~100km at Stockholm's latitude + * @returns {number} Distance in meters */ function calculateDistance(lat1, lon1, lat2, lon2) { - return Math.sqrt( - Math.pow((lat1 - lat2) * 111000, 2) + - Math.pow((lon1 - lon2) * 111000 * Math.cos(lat1 * Math.PI / 180), 2) - ); + const dLat = (lat2 - lat1) * 111000; + const dLon = (lon2 - lon1) * 111000 * Math.cos(lat1 * Math.PI / 180); + return Math.sqrt(dLat * dLat + dLon * dLon); } /** * Handle nearby sites endpoint - * @param {http.IncomingMessage} req - HTTP request object - * @param {http.ServerResponse} res - HTTP response object - * @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object + * Uses cached site list β€” no redundant API calls per request */ -function handleNearbySites(req, res, parsedUrl) { - const lat = parseFloat(parsedUrl.query.lat); - const lon = parseFloat(parsedUrl.query.lon); - const radius = parseInt(parsedUrl.query.radius) || 5000; // Default 5km radius - +async function handleNearbySites(req, res, parsedUrl) { + const lat = parseFloat(parsedUrl.searchParams.get('lat')); + const lon = parseFloat(parsedUrl.searchParams.get('lon')); + const radius = parseInt(parsedUrl.searchParams.get('radius')) || 1000; // Default 1km + if (isNaN(lat) || isNaN(lon)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid latitude or longitude', sites: [] })); return; } - - // Use a broader search to get sites, then filter by distance - const searchTerms = ['Stockholm', 'T-Centralen', 'Gamla Stan', 'SΓΆdermalm']; - const allSites = []; - let completedSearches = 0; - - searchTerms.forEach(term => { - const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(term)}`; - - https.get(searchUrl, (apiRes) => { - let data = ''; - - apiRes.on('data', (chunk) => { - data += chunk; - }); - - apiRes.on('end', () => { - try { - const parsedData = JSON.parse(data); - const sites = parseSitesFromResponse(parsedData); - - sites.forEach(site => { - if (site.lat && site.lon) { - const distance = calculateDistance(lat, lon, site.lat, site.lon); - - if (distance <= radius) { - allSites.push(site); - } - } - }); - - completedSearches++; - if (completedSearches === searchTerms.length) { - // Remove duplicates - const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values()); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ sites: uniqueSites })); - } - } catch (error) { - completedSearches++; - if (completedSearches === searchTerms.length) { - const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values()); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ sites: uniqueSites })); - } - } - }); - }).on('error', () => { - completedSearches++; - if (completedSearches === searchTerms.length) { - const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values()); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ sites: uniqueSites })); - } - }); - }); + + try { + const allSites = await getAllSites(); + + const nearby = allSites + .filter(site => site.lat && site.lon) + .map(site => ({ + ...site, + distance: calculateDistance(lat, lon, site.lat, site.lon) + })) + .filter(site => site.distance <= radius) + .sort((a, b) => a.distance - b.distance); + + console.log(`Found ${nearby.length} sites within ${radius}m of [${lat}, ${lon}]`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ sites: nearby })); + } catch (error) { + console.error('Error fetching nearby sites:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Error fetching nearby sites', sites: [] })); + } } module.exports = { handleSiteSearch, handleNearbySites, - normalizeSite, - parseSitesFromResponse + getAllSites };