diff --git a/index.html b/index.html index dc3eb78..c1ae1b3 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ @@ -19,18 +19,8 @@ - - - - - - - - - - - - + + @@ -69,6 +59,9 @@
7.1 °C
+
+ β˜€οΈ Sunrise: 06:45 AM | πŸŒ™ Sunset: 05:32 PM +
@@ -121,16 +114,113 @@
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..4c68dfb 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -1,1242 +1,1312 @@ /* 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); +} + +/* Time range */ +.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); +} + +/* 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; +} + +.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); +} + +/* 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; +} + +/* Reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + .countdown-large.urgent, + .countdown-large.now { + animation: none; + } + + .departure-card.card-entering.card-visible { + transition: none; + } + + .departure-card.card-leaving { + transition: none; + } + + .highlight-flash { + animation: none; + } +} 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..6c7f6b5 100644 --- a/public/js/components/ConfigManager.js +++ b/public/js/components/ConfigManager.js @@ -83,109 +83,39 @@ 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()} -
-
- -
-
-
- - -
-
- -
-
-
- -
- `; - + + // 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 @@ -549,33 +479,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 +521,14 @@ class ConfigManager { return this.config.sites.map((site, index) => `
-
+
- - + +
-
- ID: - +
+ ID: +
`).join(''); @@ -644,7 +551,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 +563,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 +602,7 @@ class ConfigManager { errorMessage = `Server error: ${error.message}`; } - resultsContainer.innerHTML = `
Error: ${errorMessage}
`; + resultsContainer.textContent = `Error: ${errorMessage}`; } } @@ -1159,5 +1063,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..471d01f 100644 --- a/public/js/components/DeparturesManager.js +++ b/public/js/components/DeparturesManager.js @@ -424,7 +424,7 @@ class DeparturesManager { this.updateExistingCards(departures); } - this.currentDepartures = JSON.parse(JSON.stringify(departures)); + this.currentDepartures = structuredClone(departures); } /** @@ -444,8 +444,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 +453,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); } }); } @@ -505,13 +503,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 +627,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/WeatherManager.js b/public/js/components/WeatherManager.js index 9f2b81a..f75e9e8 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 { @@ -375,6 +393,11 @@ 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(); + } } catch (error) { console.error('Error updating weather UI:', error); } @@ -383,120 +406,86 @@ class WeatherManager { /** * 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 +577,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..2f47a0c 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -3,22 +3,25 @@ * 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'; + /** * 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 +30,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 +43,57 @@ 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' + }); + // 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..5ed81d3 100644 --- a/server/routes/departures.js +++ b/server/routes/departures.js @@ -23,62 +23,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 }); } }); 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 };