Merge pull request 'UI overhaul: kiosk-optimized landscape mode' (#23) from ui-overhaul into main
This commit was merged in pull request #23.
This commit is contained in:
45
README.md
45
README.md
@@ -4,7 +4,7 @@ A modern digital signage system for displaying real-time transit departures and
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **Note**: Screenshots should be added to the `screenshots/` directory. The application displays real-time transit departures in a modern, optimized layout.
|
A comprehensive digital signage solution displaying real-time transit departures, weather information, and a visual daylight hours timeline.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ A modern digital signage system for displaying real-time transit departures and
|
|||||||
- Current weather conditions with icons
|
- Current weather conditions with icons
|
||||||
- Hourly forecast (8-hour outlook)
|
- Hourly forecast (8-hour outlook)
|
||||||
- Sunrise/sunset times
|
- Sunrise/sunset times
|
||||||
|
- **Daylight Hours Bar**: Visual 24-hour timeline at the bottom showing sunrise to sunset with animated sun/moon indicator
|
||||||
- Automatic dark mode based on time of day
|
- Automatic dark mode based on time of day
|
||||||
|
|
||||||
### ⚙️ Flexible Configuration
|
### ⚙️ Flexible Configuration
|
||||||
@@ -33,6 +34,8 @@ A modern digital signage system for displaying real-time transit departures and
|
|||||||
### 🎨 Modern Design
|
### 🎨 Modern Design
|
||||||
- Swedish color scheme (blue/yellow gradient clock banner)
|
- Swedish color scheme (blue/yellow gradient clock banner)
|
||||||
- Compact ribbon-style clock with time and date on one line
|
- Compact ribbon-style clock with time and date on one line
|
||||||
|
- **Daylight Hours Bar**: Modern gradient bar with smooth color transitions from midnight blue (night) through orange/red (dawn/dusk) to yellow (day)
|
||||||
|
- Animated sun/moon icon with pulsing glow effect
|
||||||
- Optimized spacing and typography for readability
|
- Optimized spacing and typography for readability
|
||||||
- Responsive design that adapts to screen size
|
- Responsive design that adapts to screen size
|
||||||
- Smooth animations and visual effects
|
- Smooth animations and visual effects
|
||||||
@@ -40,14 +43,33 @@ A modern digital signage system for displaying real-time transit departures and
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
### Main Display (Landscape Layout)
|
### Main Display (Landscape Layout)
|
||||||

|

|
||||||
|
|
||||||
> **Screenshot**: The system automatically optimizes for landscape orientation with a 4-column grid layout, maximizing screen real estate while maintaining readability. Add your screenshot to `screenshots/landscape-layout.png`.
|
The system automatically optimizes for landscape orientation with a 4-column grid layout, maximizing screen real estate while maintaining readability. The display includes:
|
||||||
|
|
||||||
### Key Features Highlighted
|
- **Clock Banner**: Compact ribbon-style header with time and date in Swedish locale
|
||||||
- **Clock Banner**: Compact ribbon-style header with time and date
|
- **Departure Cards**: Color-coded boxes with transport icons, line numbers, and direction arrows showing real-time transit information
|
||||||
- **Departure Cards**: Color-coded boxes with transport icons, line numbers, and direction arrows
|
- **Weather Widget**: Compact widget with current conditions, hourly forecast, and sunrise/sunset times
|
||||||
- **Weather Widget**: Fixed at bottom with current conditions and hourly forecast
|
- **Daylight Hours Bar**: 24-hour visual timeline at the bottom showing daylight hours with animated sun/moon indicator
|
||||||
|
|
||||||
|
### Settings Panel
|
||||||
|

|
||||||
|
|
||||||
|
Comprehensive configuration panel accessible via the gear icon (⚙️) with tabs for:
|
||||||
|
- **Display**: Screen orientation and dark mode settings
|
||||||
|
- **Appearance**: Background image and opacity controls
|
||||||
|
- **Content**: Transit stop management with map-based selection
|
||||||
|
- **Options**: Additional system preferences
|
||||||
|
|
||||||
|
### Daylight Hours Bar
|
||||||
|

|
||||||
|
|
||||||
|
The daylight hours bar provides a beautiful visual representation of the day:
|
||||||
|
- Modern gradient with smooth color transitions (midnight blue → orange/red → yellow)
|
||||||
|
- Animated sun icon (☀️) during daylight hours
|
||||||
|
- Animated moon icon (🌙) during nighttime hours
|
||||||
|
- Pulsing glow effect to draw attention
|
||||||
|
- Updates every minute to track the current time position
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -261,6 +283,15 @@ Settings are automatically saved to localStorage and persist across sessions.
|
|||||||
|
|
||||||
## Recent Updates
|
## Recent Updates
|
||||||
|
|
||||||
|
### Version 1.2.0 - Daylight Hours Bar & UI Refinements
|
||||||
|
- ✅ **New Feature**: Daylight Hours Bar - Visual 24-hour timeline with modern gradient shading
|
||||||
|
- ✅ Animated sun/moon icon indicator with pulsing glow effect
|
||||||
|
- ✅ Enhanced gradient transitions (midnight blue → orange/red → yellow)
|
||||||
|
- ✅ Improved weather widget layout and alignment
|
||||||
|
- ✅ Better spacing and positioning for 1080p kiosk displays
|
||||||
|
- ✅ Moon icon automatically shown during nighttime hours
|
||||||
|
- ✅ Perfectly centered icon indicators with flexbox alignment
|
||||||
|
|
||||||
### Version 1.1.0 - Landscape Optimization
|
### Version 1.1.0 - Landscape Optimization
|
||||||
- ✅ Optimized 4-column grid layout for landscape screens
|
- ✅ Optimized 4-column grid layout for landscape screens
|
||||||
- ✅ Replaced text labels with transport mode icons
|
- ✅ Replaced text labels with transport mode icons
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"orientation": "normal",
|
"orientation": "landscape",
|
||||||
"darkMode": "auto",
|
"darkMode": "auto",
|
||||||
"backgroundImage": "https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000",
|
"backgroundImage": "https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000",
|
||||||
"backgroundOpacity": 0.45,
|
"backgroundOpacity": 0.45,
|
||||||
|
|||||||
132
index.html
132
index.html
@@ -8,7 +8,7 @@
|
|||||||
<script>
|
<script>
|
||||||
// Global variables (fallback if Constants not loaded)
|
// Global variables (fallback if Constants not loaded)
|
||||||
window.API_URL = window.API_URL || 'http://localhost:3002/api/departures';
|
window.API_URL = window.API_URL || 'http://localhost:3002/api/departures';
|
||||||
window.REFRESH_INTERVAL = window.REFRESH_INTERVAL || 5000;
|
window.REFRESH_INTERVAL = window.REFRESH_INTERVAL || 30000;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Leaflet.js for map functionality -->
|
<!-- Leaflet.js for map functionality -->
|
||||||
@@ -19,18 +19,8 @@
|
|||||||
<link rel="stylesheet" href="public/css/main.css">
|
<link rel="stylesheet" href="public/css/main.css">
|
||||||
<link rel="stylesheet" href="public/css/components.css">
|
<link rel="stylesheet" href="public/css/components.css">
|
||||||
|
|
||||||
<!-- Utility scripts (must load first) -->
|
<!-- Main application script (ES module - imports all dependencies) -->
|
||||||
<script src="public/js/utils/constants.js"></script>
|
<script type="module" src="public/js/main.js"></script>
|
||||||
<script src="public/js/utils/logger.js"></script>
|
|
||||||
|
|
||||||
<!-- Component scripts (load in dependency order) -->
|
|
||||||
<script src="public/js/components/ConfigManager.js"></script>
|
|
||||||
<script src="public/js/components/Clock.js"></script>
|
|
||||||
<script src="public/js/components/WeatherManager.js"></script>
|
|
||||||
<script src="public/js/components/DeparturesManager.js"></script>
|
|
||||||
|
|
||||||
<!-- Main application script -->
|
|
||||||
<script src="public/js/main.js"></script>
|
|
||||||
|
|
||||||
<!-- Inline styles removed - now in external CSS files -->
|
<!-- Inline styles removed - now in external CSS files -->
|
||||||
</head>
|
</head>
|
||||||
@@ -40,7 +30,8 @@
|
|||||||
<!-- Clock element -->
|
<!-- Clock element -->
|
||||||
<div id="clock"></div>
|
<div id="clock"></div>
|
||||||
|
|
||||||
<!-- Site ID header removed as requested -->
|
<!-- Compact weather bar (landscape only) -->
|
||||||
|
<div id="compact-weather-bar"></div>
|
||||||
|
|
||||||
<!-- Main content grid for landscape layout -->
|
<!-- Main content grid for landscape layout -->
|
||||||
<div class="main-content-grid">
|
<div class="main-content-grid">
|
||||||
@@ -69,6 +60,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="temperature">7.1 °C</div>
|
<div class="temperature">7.1 °C</div>
|
||||||
|
<div class="sun-times">
|
||||||
|
☀️ Sunrise: 06:45 AM | 🌙 Sunset: 05:32 PM
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="forecast">
|
<div class="forecast">
|
||||||
<div class="forecast-hour">
|
<div class="forecast-hour">
|
||||||
@@ -121,14 +115,116 @@
|
|||||||
<div class="temp">5.5 °C</div>
|
<div class="temp">5.5 °C</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sun-times">
|
</div> <!-- End of custom-weather -->
|
||||||
â˜€ï¸ Sunrise: 06:45 AM | 🌙 Sunset: 05:32 PM
|
</div> <!-- End of weather-container -->
|
||||||
</div>
|
|
||||||
</div> <!-- End of weather-section -->
|
</div> <!-- End of weather-section -->
|
||||||
|
|
||||||
<div class="last-updated" id="last-updated"></div>
|
<!-- News ticker (landscape only) -->
|
||||||
|
<div id="news-ticker"><div class="ticker-content"></div></div>
|
||||||
|
|
||||||
|
<!-- Daylight Hours Bar -->
|
||||||
|
<div id="daylight-hours-bar">
|
||||||
|
<div class="daylight-bar-background"></div>
|
||||||
|
<div class="daylight-bar-indicator">
|
||||||
|
<span class="sun-icon">☀️</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div> <!-- End of content-wrapper -->
|
</div> <!-- End of content-wrapper -->
|
||||||
|
|
||||||
|
<!-- Last updated - positioned next to gear icon -->
|
||||||
|
<div class="last-updated" id="last-updated"></div>
|
||||||
|
|
||||||
|
<!-- Config Modal Template -->
|
||||||
|
<template id="config-modal-template">
|
||||||
|
<div class="config-modal-content">
|
||||||
|
<div class="config-modal-header">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<span class="config-modal-close" role="button" tabindex="0" aria-label="Close settings">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-tabs">
|
||||||
|
<button class="config-tab active" data-tab="display">Display</button>
|
||||||
|
<button class="config-tab" data-tab="appearance">Appearance</button>
|
||||||
|
<button class="config-tab" data-tab="content">Content</button>
|
||||||
|
<button class="config-tab" data-tab="options">Options</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-modal-body">
|
||||||
|
<!-- Display Tab -->
|
||||||
|
<div class="config-tab-content active" id="tab-display">
|
||||||
|
<div class="config-option">
|
||||||
|
<label for="orientation-select">Screen Orientation:</label>
|
||||||
|
<select id="orientation-select">
|
||||||
|
<option value="normal">Normal (0°)</option>
|
||||||
|
<option value="vertical">Vertical (90°)</option>
|
||||||
|
<option value="upsidedown">Upside Down (180°)</option>
|
||||||
|
<option value="vertical-reverse">Vertical Reverse (270°)</option>
|
||||||
|
<option value="landscape">Landscape (2-column)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="config-option">
|
||||||
|
<label for="dark-mode-select">Dark Mode:</label>
|
||||||
|
<select id="dark-mode-select">
|
||||||
|
<option value="auto">Automatic (Sunset/Sunrise)</option>
|
||||||
|
<option value="on">Always On</option>
|
||||||
|
<option value="off">Always Off</option>
|
||||||
|
</select>
|
||||||
|
<div class="sun-times" id="sun-times">
|
||||||
|
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Appearance Tab -->
|
||||||
|
<div class="config-tab-content" id="tab-appearance">
|
||||||
|
<div class="config-option">
|
||||||
|
<label for="background-image-url">Background Image:</label>
|
||||||
|
<input type="text" id="background-image-url" placeholder="Enter image URL">
|
||||||
|
<div class="config-flex-row">
|
||||||
|
<button id="test-image-button" class="config-btn-sm">Use Test Image</button>
|
||||||
|
<label for="local-image-input" class="config-file-label">Select Local Image</label>
|
||||||
|
<input type="file" id="local-image-input" accept="image/*" class="config-file-input-hidden">
|
||||||
|
</div>
|
||||||
|
<div class="background-preview" id="background-preview">
|
||||||
|
<div class="no-image">No image selected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-option">
|
||||||
|
<label for="background-opacity">Background Opacity: <span id="opacity-value">30%</span></label>
|
||||||
|
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="0.3">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content Tab -->
|
||||||
|
<div class="config-tab-content" id="tab-content">
|
||||||
|
<div class="config-option">
|
||||||
|
<label>Transit Sites:</label>
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<div class="config-flex-row-mb">
|
||||||
|
<input type="text" id="site-search-input" placeholder="Search for transit stop..." class="config-search-input">
|
||||||
|
<button id="search-site-button">Search</button>
|
||||||
|
<button id="select-from-map-button">Select from Map</button>
|
||||||
|
</div>
|
||||||
|
<div id="site-search-results" class="config-search-results"></div>
|
||||||
|
</div>
|
||||||
|
<div id="sites-container"></div>
|
||||||
|
<div class="config-sites-add">
|
||||||
|
<button id="add-site-button" class="config-btn-sm">Add Site Manually</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Options Tab -->
|
||||||
|
<div class="config-tab-content" id="tab-options">
|
||||||
|
<div class="config-option">
|
||||||
|
<label for="combine-directions">
|
||||||
|
<input type="checkbox" id="combine-directions">
|
||||||
|
Combine departures going in the same direction
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-modal-footer">
|
||||||
|
<button id="config-save-button">Save</button>
|
||||||
|
<button id="config-cancel-button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,85 @@
|
|||||||
/* Base styles */
|
/* ========================================
|
||||||
body {
|
CSS Custom Properties
|
||||||
|
======================================== */
|
||||||
|
:root {
|
||||||
|
--color-primary: #0061a1;
|
||||||
|
--color-primary-dark: #004d80;
|
||||||
|
--color-primary-light: #0077cc;
|
||||||
|
--color-accent: #4fc3f7;
|
||||||
|
--color-bg: #f5f5f5;
|
||||||
|
--color-bg-dark: #222;
|
||||||
|
--color-text: #333;
|
||||||
|
--color-text-light: #f5f5f5;
|
||||||
|
--color-text-muted: #666;
|
||||||
|
--color-text-muted-dark: #aaa;
|
||||||
|
--color-urgent: #c41e3a;
|
||||||
|
--color-urgent-dark: #ff6b6b;
|
||||||
|
--color-soon: #e67e22;
|
||||||
|
--color-soon-dark: #f39c12;
|
||||||
|
--color-now: #00a651;
|
||||||
|
--color-now-dark: #4ecdc4;
|
||||||
|
--color-border: #ddd;
|
||||||
|
--color-border-dark: #555;
|
||||||
|
--color-surface: white;
|
||||||
|
--color-surface-dark: #333;
|
||||||
|
--color-surface-darker: #444;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
--gradient-blue: linear-gradient(135deg, #0061a1 0%, #004d80 100%);
|
||||||
|
--kiosk-gap: 8px;
|
||||||
|
--kiosk-countdown-size: 2em;
|
||||||
|
--ticker-height: 36px;
|
||||||
|
--ticker-speed: 30s;
|
||||||
|
--ticker-bg: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Base Styles
|
||||||
|
======================================== */
|
||||||
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: background-color 0.5s ease, color 0.5s ease;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For normal orientation on narrow screens, add padding */
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
body.normal {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #f5f5f5;
|
|
||||||
color: #333;
|
|
||||||
transition: all 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Auto-apply landscape layout for wide screens */
|
/* Auto-apply wide layout for normal orientation on large screens */
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) {
|
body.normal {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 8px 12px 0 12px; /* Minimal padding to maximize space */
|
padding: 8px 12px 0 12px;
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) #content-wrapper {
|
body.normal #content-wrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
gap: 8px; /* Reduced gap */
|
gap: 8px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .clock-container {
|
body.normal .clock-container {
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding: 6px 16px; /* Reduced padding */
|
padding: 6px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .main-content-grid {
|
body.normal .main-content-grid {
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
display: block;
|
display: block;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -40,179 +88,209 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container {
|
body.normal .departure-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr); /* Fixed 4 columns to use all space */
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 6px; /* Minimal gap */
|
gap: 6px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0; /* Remove any padding */
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure each column uses equal space */
|
body.normal .departure-container > * {
|
||||||
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container > * {
|
min-width: 0;
|
||||||
min-width: 0; /* Allow flex shrinking */
|
max-width: 100%;
|
||||||
max-width: 100%; /* Prevent overflow */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Weather fixed at bottom */
|
body.normal .weather-section {
|
||||||
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-section {
|
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 35px;
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
padding: 8px 0; /* Reduced padding */
|
padding: 8px 0 0 0;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-container {
|
body.normal .weather-container {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode styles */
|
/* ========================================
|
||||||
body.dark-mode {
|
Dark Mode - Layout-level overrides only
|
||||||
background-color: #222;
|
======================================== */
|
||||||
color: #f5f5f5;
|
body.dark-mode {
|
||||||
}
|
background-color: var(--color-bg-dark);
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode .departure-card {
|
body.dark-mode .departure-card {
|
||||||
background-color: #333;
|
background-color: var(--color-surface-dark);
|
||||||
border-left-color: #0077cc;
|
border-left-color: var(--color-primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .config-modal-content {
|
/* ========================================
|
||||||
background-color: #333;
|
Orientation: Normal
|
||||||
color: #f5f5f5;
|
======================================== */
|
||||||
}
|
body.normal {
|
||||||
|
|
||||||
body.dark-mode .config-modal-body {
|
|
||||||
background-color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .config-modal-footer {
|
|
||||||
background-color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode #config-cancel-button {
|
|
||||||
background-color: #555;
|
|
||||||
color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .time,
|
|
||||||
body.dark-mode .destination {
|
|
||||||
color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .direction,
|
|
||||||
body.dark-mode .details,
|
|
||||||
body.dark-mode .countdown,
|
|
||||||
body.dark-mode .last-updated {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode h2 {
|
|
||||||
color: #0077cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .sun-times {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .line-number {
|
|
||||||
background-color: #0077cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Normal orientation */
|
|
||||||
body.normal {
|
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
body.normal .departure-container {
|
|
||||||
|
body.normal .departure-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Landscape orientation - Optimized for wide screens */
|
/* ========================================
|
||||||
body.landscape {
|
Orientation: Landscape (Kiosk Mode)
|
||||||
|
======================================== */
|
||||||
|
body.landscape {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 20px 40px;
|
padding: 10px 20px 0 20px;
|
||||||
}
|
background-color: #1a1a2e;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
/* Main content area: clock at top, then two-column layout below */
|
/* Hide background overlay in landscape for maximum contrast */
|
||||||
body.landscape #content-wrapper {
|
body.landscape #background-overlay {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape #content-wrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto auto 1fr auto auto;
|
||||||
gap: 20px;
|
gap: var(--kiosk-gap);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.landscape .clock-container {
|
body.landscape .clock-container {
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main content grid: departures on left, weather on right */
|
/* Compact weather bar sits in row 2 */
|
||||||
body.landscape .main-content-grid {
|
body.landscape #compact-weather-bar {
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
display: grid;
|
}
|
||||||
grid-template-columns: 1fr 380px;
|
|
||||||
gap: 20px;
|
body.landscape .main-content-grid {
|
||||||
|
grid-row: 3;
|
||||||
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Departures container: multi-column grid */
|
/* Hide the full weather widget in landscape */
|
||||||
body.landscape .departure-container {
|
body.landscape .weather-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .departure-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(450px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
gap: 15px;
|
gap: var(--kiosk-gap);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding-right: 10px;
|
padding-right: 4px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* Weather container: fixed width, scrollable */
|
body.landscape .weather-container {
|
||||||
body.landscape .weather-container {
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Better horizontal space usage in landscape */
|
body.landscape .departure-card {
|
||||||
body.landscape .departure-card {
|
min-height: 65px;
|
||||||
min-height: 120px;
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
body.landscape .line-number-box {
|
body.landscape .line-number-box {
|
||||||
min-width: 120px;
|
min-width: 90px;
|
||||||
width: 120px;
|
width: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.landscape .line-number-large {
|
body.landscape .line-number-large {
|
||||||
font-size: 3.5em;
|
font-size: 3.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Site containers in landscape should be more compact */
|
body.landscape .site-container {
|
||||||
body.landscape .site-container {
|
margin-bottom: 6px;
|
||||||
margin-bottom: 15px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
body.landscape .site-header {
|
body.landscape .site-header {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
padding: 8px 12px;
|
padding: 0;
|
||||||
}
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Vertical orientation (90 degrees rotated) */
|
/* News ticker sits in row 4 */
|
||||||
body.vertical {
|
body.landscape #news-ticker {
|
||||||
|
grid-row: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Daylight bar sits in row 5 */
|
||||||
|
body.landscape #daylight-hours-bar {
|
||||||
|
grid-row: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark card surfaces for landscape */
|
||||||
|
body.landscape .direction-destination {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .countdown-large {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .time-range,
|
||||||
|
body.landscape .next-departures {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tighter card spacing in landscape */
|
||||||
|
body.landscape .directions-wrapper {
|
||||||
|
padding: 4px 8px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .direction-row {
|
||||||
|
min-height: 28px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero countdown in landscape */
|
||||||
|
body.landscape .countdown-large {
|
||||||
|
font-size: var(--kiosk-countdown-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .times-container {
|
||||||
|
min-width: 130px;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .next-departures {
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: #bbb;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Orientation: Vertical (90deg)
|
||||||
|
======================================== */
|
||||||
|
body.vertical {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -221,41 +299,43 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.vertical #content-wrapper {
|
body.vertical #content-wrapper {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100vh; /* Use viewport height for width */
|
width: 100vh;
|
||||||
height: 100vw; /* Use viewport width for height */
|
height: 100vw;
|
||||||
max-width: 800px; /* Limit width for better readability */
|
max-width: 800px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background-color: transparent; /* Remove background color */
|
background-color: transparent;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
margin-left: -50vh; /* Half of width */
|
margin-left: -50vh;
|
||||||
margin-top: -50vw; /* Half of height */
|
margin-top: -50vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.vertical .config-button {
|
body.vertical .config-button {
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 10px; /* Changed from top to bottom */
|
bottom: 10px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.vertical .departure-container {
|
body.vertical .departure-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upside down orientation (180 degrees rotated) */
|
/* ========================================
|
||||||
body.upsidedown {
|
Orientation: Upside Down (180deg)
|
||||||
|
======================================== */
|
||||||
|
body.upsidedown {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -264,9 +344,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.upsidedown #content-wrapper {
|
body.upsidedown #content-wrapper {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -275,29 +355,30 @@
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background-color: transparent; /* Remove background color */
|
background-color: transparent;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
margin-left: -400px; /* Half of max-width */
|
transform: rotate(180deg) translate(50%, 50%);
|
||||||
margin-top: -50vh; /* Half of viewport height */
|
}
|
||||||
}
|
|
||||||
|
|
||||||
body.upsidedown .config-button {
|
body.upsidedown .config-button {
|
||||||
transform: rotate(-180deg);
|
transform: rotate(-180deg);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 10px; /* Changed from top to bottom */
|
bottom: 10px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.upsidedown .departure-container {
|
body.upsidedown .departure-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vertical reverse orientation (270 degrees rotated) */
|
/* ========================================
|
||||||
body.vertical-reverse {
|
Orientation: Vertical Reverse (270deg)
|
||||||
|
======================================== */
|
||||||
|
body.vertical-reverse {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -306,44 +387,46 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.vertical-reverse #content-wrapper {
|
body.vertical-reverse #content-wrapper {
|
||||||
transform: rotate(270deg);
|
transform: rotate(270deg);
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100vh; /* Use viewport height for width */
|
width: 100vh;
|
||||||
height: 100vw; /* Use viewport width for height */
|
height: 100vw;
|
||||||
max-width: none; /* Remove max-width limitation */
|
max-width: none;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: visible; /* Show all content */
|
overflow: visible;
|
||||||
background-color: transparent; /* Remove background color to show background image */
|
background-color: transparent;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
margin-left: -50vh; /* Half of width */
|
margin-left: -50vh;
|
||||||
margin-top: -50vw; /* Half of height */
|
margin-top: -50vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.vertical-reverse .config-button {
|
body.vertical-reverse .config-button {
|
||||||
transform: rotate(-270deg);
|
transform: rotate(-270deg);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 10px; /* Changed from top to bottom */
|
bottom: 10px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.vertical-reverse .departure-container {
|
body.vertical-reverse .departure-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%; /* Ensure full width */
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mode indicators - using a class instead of pseudo-element */
|
/* ========================================
|
||||||
.mode-indicator {
|
Mode Indicator
|
||||||
|
======================================== */
|
||||||
|
.mode-indicator {
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
color: #666;
|
color: var(--color-text-muted);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
window.Clock = Clock;
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ class ConfigManager {
|
|||||||
buttonContainer.id = this.options.configButtonId;
|
buttonContainer.id = this.options.configButtonId;
|
||||||
buttonContainer.className = 'config-button';
|
buttonContainer.className = 'config-button';
|
||||||
buttonContainer.title = 'Settings';
|
buttonContainer.title = 'Settings';
|
||||||
|
buttonContainer.setAttribute('role', 'button');
|
||||||
|
buttonContainer.setAttribute('aria-label', 'Open settings');
|
||||||
|
buttonContainer.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
buttonContainer.innerHTML = `
|
buttonContainer.innerHTML = `
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||||
@@ -76,6 +79,12 @@ class ConfigManager {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
buttonContainer.addEventListener('click', () => this.toggleConfigModal());
|
buttonContainer.addEventListener('click', () => this.toggleConfigModal());
|
||||||
|
buttonContainer.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggleConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
document.body.appendChild(buttonContainer);
|
document.body.appendChild(buttonContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,108 +92,41 @@ class ConfigManager {
|
|||||||
* Create the configuration modal
|
* Create the configuration modal
|
||||||
*/
|
*/
|
||||||
createConfigModal() {
|
createConfigModal() {
|
||||||
|
const template = document.getElementById('config-modal-template');
|
||||||
const modalContainer = document.createElement('div');
|
const modalContainer = document.createElement('div');
|
||||||
modalContainer.id = this.options.configModalId;
|
modalContainer.id = this.options.configModalId;
|
||||||
modalContainer.className = 'config-modal';
|
modalContainer.className = 'config-modal';
|
||||||
modalContainer.style.display = 'none';
|
modalContainer.style.display = 'none';
|
||||||
|
modalContainer.setAttribute('role', 'dialog');
|
||||||
|
modalContainer.setAttribute('aria-label', 'Settings');
|
||||||
|
modalContainer.setAttribute('aria-modal', 'true');
|
||||||
|
|
||||||
modalContainer.innerHTML = `
|
// Clone the template content into the modal
|
||||||
<div class="config-modal-content">
|
modalContainer.appendChild(template.content.cloneNode(true));
|
||||||
<div class="config-modal-header">
|
|
||||||
<h2>Settings</h2>
|
|
||||||
<span class="config-modal-close">×</span>
|
|
||||||
</div>
|
|
||||||
<div class="config-tabs">
|
|
||||||
<button class="config-tab active" data-tab="display">Display</button>
|
|
||||||
<button class="config-tab" data-tab="appearance">Appearance</button>
|
|
||||||
<button class="config-tab" data-tab="content">Content</button>
|
|
||||||
<button class="config-tab" data-tab="options">Options</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-modal-body">
|
|
||||||
<!-- Display Tab -->
|
|
||||||
<div class="config-tab-content active" id="tab-display">
|
|
||||||
<div class="config-option">
|
|
||||||
<label for="orientation-select">Screen Orientation:</label>
|
|
||||||
<select id="orientation-select">
|
|
||||||
<option value="normal" ${this.config.orientation === 'normal' ? 'selected' : ''}>Normal (0°)</option>
|
|
||||||
<option value="vertical" ${this.config.orientation === 'vertical' ? 'selected' : ''}>Vertical (90°)</option>
|
|
||||||
<option value="upsidedown" ${this.config.orientation === 'upsidedown' ? 'selected' : ''}>Upside Down (180°)</option>
|
|
||||||
<option value="vertical-reverse" ${this.config.orientation === 'vertical-reverse' ? 'selected' : ''}>Vertical Reverse (270°)</option>
|
|
||||||
<option value="landscape" ${this.config.orientation === 'landscape' ? 'selected' : ''}>Landscape (2-column)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="config-option">
|
|
||||||
<label for="dark-mode-select">Dark Mode:</label>
|
|
||||||
<select id="dark-mode-select">
|
|
||||||
<option value="auto" ${this.config.darkMode === 'auto' ? 'selected' : ''}>Automatic (Sunset/Sunrise)</option>
|
|
||||||
<option value="on" ${this.config.darkMode === 'on' ? 'selected' : ''}>Always On</option>
|
|
||||||
<option value="off" ${this.config.darkMode === 'off' ? 'selected' : ''}>Always Off</option>
|
|
||||||
</select>
|
|
||||||
<div class="sun-times" id="sun-times">
|
|
||||||
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Appearance Tab -->
|
// Set dynamic values from current config
|
||||||
<div class="config-tab-content" id="tab-appearance">
|
modalContainer.querySelector('#orientation-select').value = this.config.orientation;
|
||||||
<div class="config-option">
|
modalContainer.querySelector('#dark-mode-select').value = this.config.darkMode;
|
||||||
<label for="background-image-url">Background Image:</label>
|
modalContainer.querySelector('#background-image-url').value = this.config.backgroundImage || '';
|
||||||
<input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}">
|
modalContainer.querySelector('#background-opacity').value = this.config.backgroundOpacity;
|
||||||
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
modalContainer.querySelector('#opacity-value').textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
|
||||||
<button id="test-image-button" style="padding: 5px 10px;">Use Test Image</button>
|
modalContainer.querySelector('#combine-directions').checked = this.config.combineSameDirection;
|
||||||
<label for="local-image-input" style="padding: 5px 10px; background-color: #ddd; border-radius: 4px; cursor: pointer;">
|
|
||||||
Select Local Image
|
|
||||||
</label>
|
|
||||||
<input type="file" id="local-image-input" accept="image/*" style="display: none;">
|
|
||||||
</div>
|
|
||||||
<div class="background-preview" id="background-preview">
|
|
||||||
${this.config.backgroundImage ? `<img src="${this.config.backgroundImage}" alt="Background preview">` : '<div class="no-image">No image selected</div>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="config-option">
|
|
||||||
<label for="background-opacity">Background Opacity: <span id="opacity-value">${Math.round(this.config.backgroundOpacity * 100)}%</span></label>
|
|
||||||
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="${this.config.backgroundOpacity}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Tab -->
|
// Populate sites
|
||||||
<div class="config-tab-content" id="tab-content">
|
const sitesContainer = modalContainer.querySelector('#sites-container');
|
||||||
<div class="config-option">
|
if (sitesContainer) {
|
||||||
<label>Transit Sites:</label>
|
sitesContainer.innerHTML = this.generateSitesHTML();
|
||||||
<div style="margin-bottom: 15px;">
|
}
|
||||||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
|
||||||
<input type="text" id="site-search-input" placeholder="Search for transit stop..." style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
|
||||||
<button id="search-site-button" style="padding: 8px 15px; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer;">Search</button>
|
|
||||||
<button id="select-from-map-button" style="padding: 8px 15px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Select from Map</button>
|
|
||||||
</div>
|
|
||||||
<div id="site-search-results" style="display: none; max-height: 200px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: white; margin-top: 5px;"></div>
|
|
||||||
</div>
|
|
||||||
<div id="sites-container">
|
|
||||||
${this.generateSitesHTML()}
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 10px;">
|
|
||||||
<button id="add-site-button" style="padding: 5px 10px;">Add Site Manually</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Options Tab -->
|
// Update background preview
|
||||||
<div class="config-tab-content" id="tab-options">
|
const preview = modalContainer.querySelector('#background-preview');
|
||||||
<div class="config-option">
|
if (preview && this.config.backgroundImage) {
|
||||||
<label for="combine-directions">
|
const img = document.createElement('img');
|
||||||
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
|
img.src = this.config.backgroundImage;
|
||||||
Combine departures going in the same direction
|
img.alt = 'Background preview';
|
||||||
</label>
|
preview.innerHTML = '';
|
||||||
</div>
|
preview.appendChild(img);
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
<div class="config-modal-footer">
|
|
||||||
<button id="config-save-button">Save</button>
|
|
||||||
<button id="config-cancel-button">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modalContainer);
|
document.body.appendChild(modalContainer);
|
||||||
|
|
||||||
@@ -192,7 +134,14 @@ class ConfigManager {
|
|||||||
this.setupTabs(modalContainer);
|
this.setupTabs(modalContainer);
|
||||||
|
|
||||||
// Add event listeners
|
// Add event listeners
|
||||||
modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal());
|
const closeBtn = modalContainer.querySelector('.config-modal-close');
|
||||||
|
closeBtn.addEventListener('click', () => this.hideConfigModal());
|
||||||
|
closeBtn.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.hideConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal());
|
modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal());
|
||||||
modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig());
|
modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig());
|
||||||
modalContainer.querySelector('#test-image-button').addEventListener('click', () => {
|
modalContainer.querySelector('#test-image-button').addEventListener('click', () => {
|
||||||
@@ -347,6 +296,8 @@ class ConfigManager {
|
|||||||
hideConfigModal() {
|
hideConfigModal() {
|
||||||
const modal = document.getElementById(this.options.configModalId);
|
const modal = document.getElementById(this.options.configModalId);
|
||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
|
const configButton = document.getElementById(this.options.configButtonId);
|
||||||
|
if (configButton) configButton.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -355,6 +306,8 @@ class ConfigManager {
|
|||||||
showConfigModal() {
|
showConfigModal() {
|
||||||
const modal = document.getElementById(this.options.configModalId);
|
const modal = document.getElementById(this.options.configModalId);
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
const closeBtn = modal.querySelector('.config-modal-close');
|
||||||
|
if (closeBtn) closeBtn.focus();
|
||||||
|
|
||||||
// Reset to first tab
|
// Reset to first tab
|
||||||
const tabs = modal.querySelectorAll('.config-tab');
|
const tabs = modal.querySelectorAll('.config-tab');
|
||||||
@@ -549,32 +502,9 @@ class ConfigManager {
|
|||||||
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
|
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.id = 'background-overlay';
|
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.backgroundImage = `url(${this.config.backgroundImage})`;
|
||||||
overlay.style.backgroundSize = 'cover';
|
|
||||||
overlay.style.backgroundPosition = 'center';
|
|
||||||
overlay.style.opacity = this.config.backgroundOpacity;
|
overlay.style.opacity = this.config.backgroundOpacity;
|
||||||
overlay.style.zIndex = '-1';
|
overlay.className = `orientation-${this.config.orientation}`;
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert as the first child of body
|
// Insert as the first child of body
|
||||||
document.body.insertBefore(overlay, document.body.firstChild);
|
document.body.insertBefore(overlay, document.body.firstChild);
|
||||||
@@ -614,14 +544,14 @@ class ConfigManager {
|
|||||||
|
|
||||||
return this.config.sites.map((site, index) => `
|
return this.config.sites.map((site, index) => `
|
||||||
<div class="site-item" data-index="${index}">
|
<div class="site-item" data-index="${index}">
|
||||||
<div style="display: flex; align-items: center; margin-bottom: 5px;">
|
<div class="config-site-flex">
|
||||||
<input type="checkbox" class="site-enabled" ${site.enabled ? 'checked' : ''}>
|
<input type="checkbox" class="site-enabled" ${site.enabled ? 'checked' : ''}>
|
||||||
<input type="text" class="site-name" value="${site.name}" placeholder="Site Name" style="flex: 1; margin: 0 5px;">
|
<input type="text" class="site-name config-site-name-input" value="${site.name}" placeholder="Site Name">
|
||||||
<button class="remove-site-button" style="padding: 2px 5px;">×</button>
|
<button class="remove-site-button config-btn-remove">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center;">
|
<div class="config-site-id-row">
|
||||||
<span style="margin-right: 5px;">ID:</span>
|
<span class="config-site-id-label">ID:</span>
|
||||||
<input type="text" class="site-id" value="${site.id}" placeholder="Site ID" style="width: 100px;">
|
<input type="text" class="site-id config-site-id-input" value="${site.id}" placeholder="Site ID">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -644,7 +574,7 @@ class ConfigManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
resultsContainer.style.display = 'block';
|
resultsContainer.style.display = 'block';
|
||||||
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">Searching...</div>';
|
resultsContainer.textContent = 'Searching...';
|
||||||
|
|
||||||
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
|
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
|
||||||
|
|
||||||
@@ -656,35 +586,32 @@ class ConfigManager {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data.sites || data.sites.length === 0) {
|
if (!data.sites || data.sites.length === 0) {
|
||||||
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">No sites found. Try a different search term.</div>';
|
resultsContainer.textContent = 'No sites found. Try a different search term.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsContainer.innerHTML = data.sites.map(site => `
|
resultsContainer.innerHTML = '';
|
||||||
<div class="site-search-result" style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s;"
|
data.sites.forEach(site => {
|
||||||
data-site-id="${site.id}" data-site-name="${site.name}">
|
const resultDiv = document.createElement('div');
|
||||||
<div style="font-weight: bold; color: #0061a1;">${site.name}</div>
|
resultDiv.className = 'site-search-result';
|
||||||
<div style="font-size: 0.85em; color: #666;">ID: ${site.id}</div>
|
resultDiv.dataset.siteId = site.id;
|
||||||
</div>
|
resultDiv.dataset.siteName = site.name;
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Add click handlers to search results
|
const nameDiv = document.createElement('div');
|
||||||
resultsContainer.querySelectorAll('.site-search-result').forEach(result => {
|
nameDiv.textContent = site.name;
|
||||||
result.addEventListener('click', () => {
|
const idDiv = document.createElement('div');
|
||||||
const siteId = result.dataset.siteId;
|
idDiv.textContent = `ID: ${site.id}`;
|
||||||
const siteName = result.dataset.siteName;
|
|
||||||
this.addSiteFromSearch(siteId, siteName);
|
resultDiv.appendChild(nameDiv);
|
||||||
|
resultDiv.appendChild(idDiv);
|
||||||
|
|
||||||
|
resultDiv.addEventListener('click', () => {
|
||||||
|
this.addSiteFromSearch(site.id, site.name);
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
resultsContainer.style.display = 'none';
|
resultsContainer.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
result.addEventListener('mouseenter', () => {
|
resultsContainer.appendChild(resultDiv);
|
||||||
result.style.backgroundColor = '#f5f5f5';
|
|
||||||
});
|
|
||||||
|
|
||||||
result.addEventListener('mouseleave', () => {
|
|
||||||
result.style.backgroundColor = 'white';
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -698,7 +625,7 @@ class ConfigManager {
|
|||||||
errorMessage = `Server error: ${error.message}`;
|
errorMessage = `Server error: ${error.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsContainer.innerHTML = `<div style="padding: 10px; text-align: center; color: #d32f2f;">Error: ${errorMessage}</div>`;
|
resultsContainer.textContent = `Error: ${errorMessage}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,77 +709,37 @@ class ConfigManager {
|
|||||||
if (e.target === mapModal) closeMap();
|
if (e.target === mapModal) closeMap();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load transit stops - search for common Stockholm areas
|
// Load nearby transit stops based on map center
|
||||||
const loadSitesOnMap = async () => {
|
const markersLayer = L.layerGroup().addTo(map);
|
||||||
try {
|
const loadedSiteIds = new Set();
|
||||||
// Start with focused search to avoid too many markers
|
|
||||||
const searchTerms = ['Ambassaderna'];
|
const loadNearbySites = async () => {
|
||||||
const allSites = new Map();
|
const center = map.getCenter();
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
// Scale radius based on zoom: wider view = larger radius
|
||||||
|
const radius = zoom >= 15 ? 500 : zoom >= 13 ? 1500 : zoom >= 11 ? 4000 : 8000;
|
||||||
|
|
||||||
for (const term of searchTerms) {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(term)}`);
|
const response = await fetch(`/api/sites/nearby?lat=${center.lat}&lon=${center.lng}&radius=${radius}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(`Search "${term}" returned:`, data);
|
|
||||||
if (data.sites) {
|
if (data.sites) {
|
||||||
data.sites.forEach(site => {
|
data.sites.forEach(site => {
|
||||||
// Only add sites with valid coordinates from API
|
if (loadedSiteIds.has(site.id)) return;
|
||||||
const lat = site.lat || site.latitude;
|
const lat = parseFloat(site.lat);
|
||||||
const lon = site.lon || site.longitude;
|
const lon = parseFloat(site.lon);
|
||||||
|
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
|
||||||
|
|
||||||
if (lat && lon && !isNaN(parseFloat(lat)) && !isNaN(parseFloat(lon))) {
|
loadedSiteIds.add(site.id);
|
||||||
if (!allSites.has(site.id)) {
|
|
||||||
allSites.set(site.id, {
|
|
||||||
id: site.id,
|
|
||||||
name: site.name,
|
|
||||||
lat: parseFloat(lat),
|
|
||||||
lon: parseFloat(lon)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`Site ${site.id} (${site.name}) missing coordinates, skipping`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error searching for ${term}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add known site with coordinates as fallback
|
|
||||||
const knownSites = [
|
|
||||||
{ id: '1411', name: 'Ambassaderna', lat: 59.3293, lon: 18.0686 }
|
|
||||||
];
|
|
||||||
|
|
||||||
knownSites.forEach(site => {
|
|
||||||
if (!allSites.has(site.id)) {
|
|
||||||
allSites.set(site.id, site);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const sitesArray = Array.from(allSites.values());
|
|
||||||
console.log(`Loading ${sitesArray.length} sites on map with coordinates:`, sitesArray);
|
|
||||||
|
|
||||||
if (sitesArray.length > 0) {
|
|
||||||
const markers = [];
|
|
||||||
sitesArray.forEach(site => {
|
|
||||||
const lat = site.lat;
|
|
||||||
const lon = site.lon;
|
|
||||||
|
|
||||||
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
|
|
||||||
console.warn(`Invalid coordinates for site ${site.id}, skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create custom icon
|
|
||||||
const customIcon = L.divIcon({
|
const customIcon = L.divIcon({
|
||||||
className: 'custom-marker',
|
className: 'custom-marker',
|
||||||
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);">🚌</div>`,
|
html: '<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);">🚌</div>',
|
||||||
iconSize: [30, 30],
|
iconSize: [30, 30],
|
||||||
iconAnchor: [15, 15]
|
iconAnchor: [15, 15]
|
||||||
});
|
});
|
||||||
|
|
||||||
const marker = L.marker([lat, lon], { icon: customIcon }).addTo(map);
|
const marker = L.marker([lat, lon], { icon: customIcon });
|
||||||
|
|
||||||
const popupContent = `
|
const popupContent = `
|
||||||
<div style="min-width: 200px;">
|
<div style="min-width: 200px;">
|
||||||
@@ -866,36 +753,18 @@ class ConfigManager {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
marker.bindPopup(popupContent);
|
marker.bindPopup(popupContent);
|
||||||
markers.push(marker);
|
markersLayer.addLayer(marker);
|
||||||
|
|
||||||
// Make marker clickable to open popup
|
|
||||||
marker.on('click', function() {
|
|
||||||
this.openPopup();
|
|
||||||
});
|
});
|
||||||
});
|
console.log(`Loaded ${data.sites.length} nearby sites (${loadedSiteIds.size} total on map)`);
|
||||||
|
|
||||||
// Fit map to show all markers, or center on first marker
|
|
||||||
if (markers.length > 0) {
|
|
||||||
if (markers.length === 1) {
|
|
||||||
map.setView(markers[0].getLatLng(), 15);
|
|
||||||
} else {
|
|
||||||
const group = new L.featureGroup(markers);
|
|
||||||
map.fitBounds(group.getBounds().pad(0.1));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no markers, show message
|
|
||||||
console.log('No sites with coordinates found');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading sites on map:', error);
|
console.error('Error loading nearby sites:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load sites after map is initialized
|
// Load sites on init and when map is panned/zoomed
|
||||||
setTimeout(() => {
|
setTimeout(() => loadNearbySites(), 300);
|
||||||
loadSitesOnMap();
|
map.on('moveend', loadNearbySites);
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Handle site selection from map popup - use event delegation on the modal
|
// Handle site selection from map popup - use event delegation on the modal
|
||||||
mapModal.addEventListener('click', (e) => {
|
mapModal.addEventListener('click', (e) => {
|
||||||
@@ -947,73 +816,42 @@ class ConfigManager {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.sites && data.sites.length > 0) {
|
if (data.sites && data.sites.length > 0) {
|
||||||
// Clear existing markers
|
// Clear existing markers and reset tracking
|
||||||
map.eachLayer((layer) => {
|
markersLayer.clearLayers();
|
||||||
if (layer instanceof L.Marker) {
|
loadedSiteIds.clear();
|
||||||
map.removeLayer(layer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear existing markers
|
const searchMarkers = [];
|
||||||
map.eachLayer((layer) => {
|
|
||||||
if (layer instanceof L.Marker) {
|
|
||||||
map.removeLayer(layer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add markers for search results
|
|
||||||
const markers = [];
|
|
||||||
data.sites.forEach(site => {
|
data.sites.forEach(site => {
|
||||||
let lat = site.lat || site.latitude;
|
const lat = parseFloat(site.lat);
|
||||||
let lon = site.lon || site.longitude;
|
const lon = parseFloat(site.lon);
|
||||||
|
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
|
||||||
|
|
||||||
// If no coordinates, use approximate location based on map center
|
loadedSiteIds.add(site.id);
|
||||||
if (!lat || !lon) {
|
|
||||||
const center = map.getCenter();
|
|
||||||
lat = center.lat + (Math.random() - 0.5) * 0.05;
|
|
||||||
lon = center.lon + (Math.random() - 0.5) * 0.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create custom icon
|
|
||||||
const customIcon = L.divIcon({
|
const customIcon = L.divIcon({
|
||||||
className: 'custom-transit-marker',
|
className: 'custom-marker',
|
||||||
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.4); font-size: 18px;">🚌</div>`,
|
html: '<div style="background-color: #28a745; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.4); font-size: 18px;">🚌</div>',
|
||||||
iconSize: [32, 32],
|
iconSize: [32, 32],
|
||||||
iconAnchor: [16, 16],
|
iconAnchor: [16, 16]
|
||||||
popupAnchor: [0, -16]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const marker = L.marker([lat, lon], {
|
const marker = L.marker([lat, lon], { icon: customIcon });
|
||||||
icon: customIcon,
|
marker.bindPopup(`
|
||||||
title: site.name
|
<div style="min-width: 200px;">
|
||||||
}).addTo(map);
|
<strong style="font-size: 1.1em; color: #0061a1;">${site.name}</strong><br>
|
||||||
|
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span><br>
|
||||||
const popupContent = document.createElement('div');
|
<button class="select-site-from-map" data-site-id="${site.id}" data-site-name="${site.name}"
|
||||||
popupContent.style.minWidth = '220px';
|
style="margin-top: 8px; padding: 8px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||||
popupContent.innerHTML = `
|
|
||||||
<div style="margin-bottom: 8px;">
|
|
||||||
<strong style="font-size: 1.1em; color: #0061a1; display: block; margin-bottom: 4px;">${site.name}</strong>
|
|
||||||
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span>
|
|
||||||
</div>
|
|
||||||
<button class="select-site-from-map-btn"
|
|
||||||
data-site-id="${site.id}"
|
|
||||||
data-site-name="${site.name}"
|
|
||||||
style="margin-top: 8px; padding: 10px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1em;">
|
|
||||||
Select This Stop
|
Select This Stop
|
||||||
</button>
|
</button>
|
||||||
`;
|
</div>
|
||||||
|
`);
|
||||||
marker.bindPopup(popupContent);
|
markersLayer.addLayer(marker);
|
||||||
markers.push(marker);
|
searchMarkers.push(marker);
|
||||||
|
|
||||||
marker.on('click', function() {
|
|
||||||
this.openPopup();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fit map to show results
|
if (searchMarkers.length > 0) {
|
||||||
if (markers.length > 0) {
|
const group = new L.featureGroup(searchMarkers);
|
||||||
const group = new L.featureGroup(markers);
|
|
||||||
map.fitBounds(group.getBounds().pad(0.1));
|
map.fitBounds(group.getBounds().pad(0.1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1159,5 +997,8 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the ConfigManager class for use in other modules
|
// ES module export
|
||||||
|
export { ConfigManager };
|
||||||
|
|
||||||
|
// Keep window reference for backward compatibility
|
||||||
window.ConfigManager = ConfigManager;
|
window.ConfigManager = ConfigManager;
|
||||||
|
|||||||
@@ -223,19 +223,15 @@ class DeparturesManager {
|
|||||||
let countdownText = displayTime;
|
let countdownText = displayTime;
|
||||||
let countdownClass = '';
|
let countdownClass = '';
|
||||||
|
|
||||||
const urgentThreshold = window.Constants?.TIME_THRESHOLDS?.URGENT || 5;
|
|
||||||
|
|
||||||
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
|
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
|
||||||
countdownText = 'Nu';
|
countdownText = 'Nu';
|
||||||
countdownClass = 'now';
|
countdownClass = 'now';
|
||||||
} else if (minutesUntil < urgentThreshold) {
|
} else if (minutesUntil <= 2) {
|
||||||
const minMatch = displayTime.match(/(\d+)\s*min/i);
|
|
||||||
if (minMatch) {
|
|
||||||
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
|
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
|
||||||
} else {
|
|
||||||
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
|
|
||||||
}
|
|
||||||
countdownClass = 'urgent';
|
countdownClass = 'urgent';
|
||||||
|
} else if (minutesUntil <= 5) {
|
||||||
|
countdownText = `${minutesUntil} min`;
|
||||||
|
countdownClass = 'soon';
|
||||||
} else {
|
} else {
|
||||||
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||||
if (isTimeOnly) {
|
if (isTimeOnly) {
|
||||||
@@ -381,15 +377,13 @@ class DeparturesManager {
|
|||||||
|
|
||||||
timeDisplayElement.appendChild(countdownSpan);
|
timeDisplayElement.appendChild(countdownSpan);
|
||||||
|
|
||||||
const timeRangeSpan = document.createElement('span');
|
// Show next 2-3 absolute times as small text
|
||||||
timeRangeSpan.className = 'time-range';
|
const nextTimesSpan = document.createElement('span');
|
||||||
if (secondDeparture) {
|
nextTimesSpan.className = 'next-departures';
|
||||||
const secondTime = DeparturesManager.formatDateTime(this.getDepartureTime(secondDeparture));
|
const upcomingTimes = direction.departures.slice(0, 3)
|
||||||
timeRangeSpan.textContent = `${timeDisplay} - ${secondTime}`;
|
.map(d => DeparturesManager.formatDateTime(this.getDepartureTime(d)));
|
||||||
} else {
|
nextTimesSpan.textContent = upcomingTimes.join(' ');
|
||||||
timeRangeSpan.textContent = timeDisplay;
|
timeDisplayElement.appendChild(nextTimesSpan);
|
||||||
}
|
|
||||||
timeDisplayElement.appendChild(timeRangeSpan);
|
|
||||||
|
|
||||||
timesContainer.appendChild(timeDisplayElement);
|
timesContainer.appendChild(timeDisplayElement);
|
||||||
}
|
}
|
||||||
@@ -424,7 +418,7 @@ class DeparturesManager {
|
|||||||
this.updateExistingCards(departures);
|
this.updateExistingCards(departures);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentDepartures = JSON.parse(JSON.stringify(departures));
|
this.currentDepartures = structuredClone(departures);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,7 +438,7 @@ class DeparturesManager {
|
|||||||
this.updateCardContent(existingCard, departure);
|
this.updateCardContent(existingCard, departure);
|
||||||
} else {
|
} else {
|
||||||
const newCard = this.createDepartureCard(departure);
|
const newCard = this.createDepartureCard(departure);
|
||||||
newCard.style.opacity = '0';
|
newCard.classList.add('card-entering');
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
this.container.prepend(newCard);
|
this.container.prepend(newCard);
|
||||||
@@ -454,23 +448,21 @@ class DeparturesManager {
|
|||||||
this.container.insertBefore(newCard, this.container.children[index]);
|
this.container.insertBefore(newCard, this.container.children[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
newCard.style.transition = 'opacity 0.5s ease-in';
|
newCard.classList.add('card-visible');
|
||||||
newCard.style.opacity = '1';
|
});
|
||||||
}, 10);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
|
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
|
||||||
currentCards.forEach(card => {
|
currentCards.forEach(card => {
|
||||||
if (!newDepartureIds.includes(card.dataset.journeyId)) {
|
if (!newDepartureIds.includes(card.dataset.journeyId)) {
|
||||||
card.style.transition = 'opacity 0.5s ease-out';
|
card.classList.add('card-leaving');
|
||||||
card.style.opacity = '0';
|
card.addEventListener('transitionend', () => {
|
||||||
setTimeout(() => {
|
card.remove();
|
||||||
if (card.parentNode) {
|
}, { once: true });
|
||||||
card.parentNode.removeChild(card);
|
// Fallback removal if transitionend doesn't fire
|
||||||
}
|
setTimeout(() => card.remove(), 600);
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -485,12 +477,10 @@ class DeparturesManager {
|
|||||||
const countdownElement = card.querySelector('.countdown');
|
const countdownElement = card.querySelector('.countdown');
|
||||||
|
|
||||||
if (countdownElement) {
|
if (countdownElement) {
|
||||||
countdownElement.classList.remove('now', 'urgent');
|
countdownElement.classList.remove('now', 'urgent', 'soon');
|
||||||
|
|
||||||
if (countdownClass === 'now') {
|
if (countdownClass) {
|
||||||
countdownElement.classList.add('now');
|
countdownElement.classList.add(countdownClass);
|
||||||
} else if (countdownClass === 'urgent') {
|
|
||||||
countdownElement.classList.add('urgent');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countdownElement.textContent !== `(${countdownText})`) {
|
if (countdownElement.textContent !== `(${countdownText})`) {
|
||||||
@@ -505,13 +495,10 @@ class DeparturesManager {
|
|||||||
* @param {HTMLElement} element - Element to highlight
|
* @param {HTMLElement} element - Element to highlight
|
||||||
*/
|
*/
|
||||||
highlightElement(element) {
|
highlightElement(element) {
|
||||||
element.style.transition = 'none';
|
element.classList.remove('highlight-flash');
|
||||||
element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
|
// Force reflow to restart animation
|
||||||
|
void element.offsetWidth;
|
||||||
setTimeout(() => {
|
element.classList.add('highlight-flash');
|
||||||
element.style.transition = 'background-color 1.5s ease-out';
|
|
||||||
element.style.backgroundColor = 'transparent';
|
|
||||||
}, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -632,5 +619,8 @@ class DeparturesManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the class
|
// ES module export
|
||||||
|
export { DeparturesManager };
|
||||||
|
|
||||||
|
// Keep window reference for backward compatibility
|
||||||
window.DeparturesManager = DeparturesManager;
|
window.DeparturesManager = DeparturesManager;
|
||||||
|
|||||||
84
public/js/components/NewsTicker.js
Normal file
84
public/js/components/NewsTicker.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* NewsTicker - Scrolling news/announcement ticker for landscape kiosk mode
|
||||||
|
* Fetches from /api/ticker with fallback to hardcoded messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
class NewsTicker {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
containerId: options.containerId || 'news-ticker',
|
||||||
|
fetchUrl: options.fetchUrl || '/api/ticker',
|
||||||
|
refreshInterval: options.refreshInterval || 5 * 60 * 1000, // 5 minutes
|
||||||
|
fallbackMessages: options.fallbackMessages || [
|
||||||
|
'Välkommen till Ambassaderna',
|
||||||
|
'Håll dörren stängd',
|
||||||
|
'Tvättstugan stänger kl 22:00'
|
||||||
|
],
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.container = null;
|
||||||
|
this.contentEl = null;
|
||||||
|
this.messages = [];
|
||||||
|
this.refreshTimer = null;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.container = document.getElementById(this.options.containerId);
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
this.contentEl = this.container.querySelector('.ticker-content');
|
||||||
|
if (!this.contentEl) {
|
||||||
|
this.contentEl = document.createElement('div');
|
||||||
|
this.contentEl.className = 'ticker-content';
|
||||||
|
this.container.appendChild(this.contentEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fetchMessages();
|
||||||
|
this.refreshTimer = setInterval(() => this.fetchMessages(), this.options.refreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMessages() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.options.fetchUrl);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (Array.isArray(data.messages) && data.messages.length > 0) {
|
||||||
|
this.messages = data.messages;
|
||||||
|
} else {
|
||||||
|
this.messages = this.options.fallbackMessages;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.messages = this.options.fallbackMessages;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.messages = this.options.fallbackMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.contentEl || this.messages.length === 0) return;
|
||||||
|
|
||||||
|
const separator = ' \u2022 '; // bullet separator
|
||||||
|
const text = this.messages.join(separator);
|
||||||
|
// Duplicate text for seamless infinite scroll loop
|
||||||
|
this.contentEl.textContent = text + separator + text;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ES module export
|
||||||
|
export { NewsTicker };
|
||||||
|
|
||||||
|
// Keep window reference for backward compatibility
|
||||||
|
window.NewsTicker = NewsTicker;
|
||||||
@@ -8,7 +8,7 @@ class WeatherManager {
|
|||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
// Default options
|
// Default options
|
||||||
// Get API key from options, window (injected by server from .env), or fallback
|
// 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 = {
|
this.options = {
|
||||||
latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude
|
latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude
|
||||||
@@ -24,6 +24,7 @@ class WeatherManager {
|
|||||||
this.sunTimes = null;
|
this.sunTimes = null;
|
||||||
this.isDarkMode = false;
|
this.isDarkMode = false;
|
||||||
this.lastUpdated = null;
|
this.lastUpdated = null;
|
||||||
|
this.daylightBarUpdateInterval = null;
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
this.init();
|
this.init();
|
||||||
@@ -34,6 +35,23 @@ class WeatherManager {
|
|||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
try {
|
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
|
// Fetch weather data
|
||||||
await this.fetchWeatherData();
|
await this.fetchWeatherData();
|
||||||
|
|
||||||
@@ -83,8 +101,8 @@ class WeatherManager {
|
|||||||
*/
|
*/
|
||||||
async fetchWeatherData() {
|
async fetchWeatherData() {
|
||||||
try {
|
try {
|
||||||
// Fetch current weather
|
// 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&appid=${this.options.apiKey}`;
|
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 currentWeatherResponse = await fetch(currentWeatherUrl);
|
||||||
const currentWeatherData = await currentWeatherResponse.json();
|
const currentWeatherData = await currentWeatherResponse.json();
|
||||||
|
|
||||||
@@ -92,8 +110,8 @@ class WeatherManager {
|
|||||||
throw new Error(`API Error: ${currentWeatherData.message}`);
|
throw new Error(`API Error: ${currentWeatherData.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch hourly forecast
|
// 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&appid=${this.options.apiKey}`;
|
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 forecastResponse = await fetch(forecastUrl);
|
||||||
const forecastData = await forecastResponse.json();
|
const forecastData = await forecastResponse.json();
|
||||||
|
|
||||||
@@ -107,7 +125,7 @@ class WeatherManager {
|
|||||||
this.lastUpdated = new Date();
|
this.lastUpdated = new Date();
|
||||||
|
|
||||||
// Extract sunrise and sunset times from the API response
|
// Extract sunrise and sunset times from the API response
|
||||||
this.updateSunTimesFromApi(currentWeatherData);
|
await this.updateSunTimesFromApi(currentWeatherData);
|
||||||
|
|
||||||
// Update the UI with the new data
|
// Update the UI with the new data
|
||||||
this.updateWeatherUI();
|
this.updateWeatherUI();
|
||||||
@@ -158,7 +176,7 @@ class WeatherManager {
|
|||||||
* Process forecast data from API response
|
* Process forecast data from API response
|
||||||
*/
|
*/
|
||||||
processForecast(data) {
|
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 => {
|
return data.list.slice(0, 7).map(item => {
|
||||||
const iconCode = item.weather[0].icon;
|
const iconCode = item.weather[0].icon;
|
||||||
return {
|
return {
|
||||||
@@ -177,43 +195,42 @@ class WeatherManager {
|
|||||||
* Get weather icon URL from icon code
|
* Get weather icon URL from icon code
|
||||||
*/
|
*/
|
||||||
getWeatherIconUrl(iconCode) {
|
getWeatherIconUrl(iconCode) {
|
||||||
return `https://openweathermap.org/img/wn/${iconCode}@2x.png`;
|
return `https://openweathermap.org/img/wn/${iconCode}@4x.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if icon represents sun (even behind clouds)
|
* Classify a weather icon and return the appropriate CSS classes
|
||||||
|
* @param {string} iconCode - OWM icon code (e.g. '01d', '13n')
|
||||||
|
* @param {string} condition - Weather condition text (e.g. 'Clear', 'Clouds')
|
||||||
|
* @returns {string[]} Array of CSS class names to apply
|
||||||
*/
|
*/
|
||||||
isSunIcon(iconCode, condition) {
|
classifyWeatherIcon(iconCode, condition) {
|
||||||
// Icon codes: 01d, 01n = clear, 02d, 02n = few clouds, 03d, 03n = scattered, 04d, 04n = broken clouds
|
const code = iconCode ? iconCode.replace(/[dn]$/, '') : '';
|
||||||
const sunIconCodes = ['01d', '01n', '02d', '02n', '03d', '03n', '04d', '04n'];
|
|
||||||
return sunIconCodes.includes(iconCode) ||
|
// Snow: icon 13x or condition contains 'Snow'
|
||||||
condition.includes('Clear') ||
|
if (code === '13' || condition.includes('Snow')) {
|
||||||
condition.includes('Clouds');
|
return ['weather-snow'];
|
||||||
|
}
|
||||||
|
// Clear sun: icon 01x or condition is exactly 'Clear'
|
||||||
|
if (code === '01' || condition === 'Clear') {
|
||||||
|
return ['weather-sun', 'weather-clear-sun'];
|
||||||
|
}
|
||||||
|
// Sun behind clouds: icon 02-04x or cloudy condition
|
||||||
|
if (['02', '03', '04'].includes(code) || (condition.includes('Clouds') && !condition.includes('Clear'))) {
|
||||||
|
return ['weather-sun', 'weather-clouds-sun'];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if icon is clear sun (no clouds)
|
* Apply weather icon CSS classes to an element
|
||||||
*/
|
*/
|
||||||
isClearSun(iconCode, condition) {
|
applyWeatherIconClasses(element, iconCode, condition) {
|
||||||
const clearIconCodes = ['01d', '01n'];
|
element.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun');
|
||||||
return clearIconCodes.includes(iconCode) || condition === 'Clear';
|
const classes = this.classifyWeatherIcon(iconCode, condition);
|
||||||
|
if (classes.length > 0) {
|
||||||
|
element.classList.add(...classes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if icon is sun behind clouds
|
|
||||||
*/
|
|
||||||
isSunBehindClouds(iconCode, condition) {
|
|
||||||
const cloudIconCodes = ['02d', '02n', '03d', '03n', '04d', '04n'];
|
|
||||||
return cloudIconCodes.includes(iconCode) || (condition.includes('Clouds') && !condition.includes('Clear'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if icon represents snow
|
|
||||||
*/
|
|
||||||
isSnowIcon(iconCode, condition) {
|
|
||||||
// Icon code: 13d, 13n = snow
|
|
||||||
const snowIconCodes = ['13d', '13n'];
|
|
||||||
return snowIconCodes.includes(iconCode) || condition.includes('Snow');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,7 +241,7 @@ class WeatherManager {
|
|||||||
temperature: 7.1,
|
temperature: 7.1,
|
||||||
condition: 'Clear',
|
condition: 'Clear',
|
||||||
description: 'clear sky',
|
description: 'clear sky',
|
||||||
icon: 'https://openweathermap.org/img/wn/01d@2x.png',
|
icon: 'https://openweathermap.org/img/wn/01d@4x.png',
|
||||||
iconCode: '01d',
|
iconCode: '01d',
|
||||||
wind: {
|
wind: {
|
||||||
speed: 14.8,
|
speed: 14.8,
|
||||||
@@ -255,7 +272,7 @@ class WeatherManager {
|
|||||||
temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour
|
temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour
|
||||||
condition: i < 2 ? 'Clear' : 'Clouds',
|
condition: i < 2 ? 'Clear' : 'Clouds',
|
||||||
description: i < 2 ? 'clear sky' : 'few clouds',
|
description: i < 2 ? 'clear sky' : 'few clouds',
|
||||||
icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@2x.png' : 'https://openweathermap.org/img/wn/02n@2x.png',
|
icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@4x.png' : 'https://openweathermap.org/img/wn/02n@4x.png',
|
||||||
iconCode: i < 2 ? '01n' : '02n',
|
iconCode: i < 2 ? '01n' : '02n',
|
||||||
timestamp: forecastTime,
|
timestamp: forecastTime,
|
||||||
precipitation: 0
|
precipitation: 0
|
||||||
@@ -287,18 +304,8 @@ class WeatherManager {
|
|||||||
if (iconElement) {
|
if (iconElement) {
|
||||||
iconElement.src = this.weatherData.icon;
|
iconElement.src = this.weatherData.icon;
|
||||||
iconElement.alt = this.weatherData.description;
|
iconElement.alt = this.weatherData.description;
|
||||||
// Add classes and data attributes for color filtering
|
|
||||||
iconElement.setAttribute('data-condition', this.weatherData.condition);
|
iconElement.setAttribute('data-condition', this.weatherData.condition);
|
||||||
iconElement.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun');
|
this.applyWeatherIconClasses(iconElement, this.weatherData.iconCode, this.weatherData.condition);
|
||||||
if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) {
|
|
||||||
iconElement.classList.add('weather-snow');
|
|
||||||
} else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) {
|
|
||||||
iconElement.classList.add('weather-sun', 'weather-clear-sun');
|
|
||||||
} else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) {
|
|
||||||
iconElement.classList.add('weather-sun', 'weather-clouds-sun');
|
|
||||||
} else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) {
|
|
||||||
iconElement.classList.add('weather-sun');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const temperatureElement = document.querySelector('#custom-weather .temperature');
|
const temperatureElement = document.querySelector('#custom-weather .temperature');
|
||||||
@@ -320,15 +327,7 @@ class WeatherManager {
|
|||||||
nowIcon.alt = this.weatherData.description;
|
nowIcon.alt = this.weatherData.description;
|
||||||
nowIcon.width = 56;
|
nowIcon.width = 56;
|
||||||
nowIcon.setAttribute('data-condition', this.weatherData.condition);
|
nowIcon.setAttribute('data-condition', this.weatherData.condition);
|
||||||
if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) {
|
this.applyWeatherIconClasses(nowIcon, this.weatherData.iconCode, this.weatherData.condition);
|
||||||
nowIcon.classList.add('weather-snow');
|
|
||||||
} else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) {
|
|
||||||
nowIcon.classList.add('weather-sun', 'weather-clear-sun');
|
|
||||||
} else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) {
|
|
||||||
nowIcon.classList.add('weather-sun', 'weather-clouds-sun');
|
|
||||||
} else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) {
|
|
||||||
nowIcon.classList.add('weather-sun');
|
|
||||||
}
|
|
||||||
nowElement.innerHTML = `
|
nowElement.innerHTML = `
|
||||||
<div class="time">Nu</div>
|
<div class="time">Nu</div>
|
||||||
<div class="icon"></div>
|
<div class="icon"></div>
|
||||||
@@ -349,15 +348,7 @@ class WeatherManager {
|
|||||||
forecastIcon.alt = forecast.description;
|
forecastIcon.alt = forecast.description;
|
||||||
forecastIcon.width = 56;
|
forecastIcon.width = 56;
|
||||||
forecastIcon.setAttribute('data-condition', forecast.condition);
|
forecastIcon.setAttribute('data-condition', forecast.condition);
|
||||||
if (this.isSnowIcon(forecast.iconCode, forecast.condition)) {
|
this.applyWeatherIconClasses(forecastIcon, forecast.iconCode, forecast.condition);
|
||||||
forecastIcon.classList.add('weather-snow');
|
|
||||||
} else if (this.isClearSun(forecast.iconCode, forecast.condition)) {
|
|
||||||
forecastIcon.classList.add('weather-sun', 'weather-clear-sun');
|
|
||||||
} else if (this.isSunBehindClouds(forecast.iconCode, forecast.condition)) {
|
|
||||||
forecastIcon.classList.add('weather-sun', 'weather-clouds-sun');
|
|
||||||
} else if (this.isSunIcon(forecast.iconCode, forecast.condition)) {
|
|
||||||
forecastIcon.classList.add('weather-sun');
|
|
||||||
}
|
|
||||||
forecastElement.innerHTML = `
|
forecastElement.innerHTML = `
|
||||||
<div class="time">${timeString}</div>
|
<div class="time">${timeString}</div>
|
||||||
<div class="icon"></div>
|
<div class="icon"></div>
|
||||||
@@ -375,32 +366,85 @@ class WeatherManager {
|
|||||||
const sunsetTime = this.formatTime(this.sunTimes.today.sunset);
|
const sunsetTime = this.formatTime(this.sunTimes.today.sunset);
|
||||||
sunTimesElement.textContent = `☀️ Sunrise: ${sunriseTime} | 🌙 Sunset: ${sunsetTime}`;
|
sunTimesElement.textContent = `☀️ Sunrise: ${sunriseTime} | 🌙 Sunset: ${sunsetTime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update daylight hours bar
|
||||||
|
if (this.sunTimes) {
|
||||||
|
this.updateDaylightHoursBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update compact weather bar (landscape mode)
|
||||||
|
this.renderCompactWeatherBar();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating weather UI:', error);
|
console.error('Error updating weather UI:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render compact weather bar for landscape mode
|
||||||
|
* Shows: [icon] temp condition | Sunrise HH:MM | Sunset HH:MM
|
||||||
|
*/
|
||||||
|
renderCompactWeatherBar() {
|
||||||
|
const bar = document.getElementById('compact-weather-bar');
|
||||||
|
if (!bar || !this.weatherData) return;
|
||||||
|
|
||||||
|
bar.textContent = '';
|
||||||
|
|
||||||
|
const icon = document.createElement('img');
|
||||||
|
icon.className = 'weather-bar-icon';
|
||||||
|
icon.src = this.weatherData.icon || '';
|
||||||
|
icon.alt = this.weatherData.condition || '';
|
||||||
|
bar.appendChild(icon);
|
||||||
|
|
||||||
|
const tempSpan = document.createElement('span');
|
||||||
|
const strong = document.createElement('strong');
|
||||||
|
strong.textContent = `${this.weatherData.temperature}\u00B0C`;
|
||||||
|
tempSpan.appendChild(strong);
|
||||||
|
tempSpan.appendChild(document.createTextNode(` ${this.weatherData.condition || ''}`));
|
||||||
|
bar.appendChild(tempSpan);
|
||||||
|
|
||||||
|
const sep1 = document.createElement('span');
|
||||||
|
sep1.className = 'weather-bar-sep';
|
||||||
|
sep1.textContent = '|';
|
||||||
|
bar.appendChild(sep1);
|
||||||
|
|
||||||
|
let sunriseStr = '--:--';
|
||||||
|
let sunsetStr = '--:--';
|
||||||
|
if (this.sunTimes) {
|
||||||
|
sunriseStr = this.formatTime(this.sunTimes.today.sunrise);
|
||||||
|
sunsetStr = this.formatTime(this.sunTimes.today.sunset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sunriseSpan = document.createElement('span');
|
||||||
|
sunriseSpan.textContent = `\u2600\uFE0F Sunrise ${sunriseStr}`;
|
||||||
|
bar.appendChild(sunriseSpan);
|
||||||
|
|
||||||
|
const sep2 = document.createElement('span');
|
||||||
|
sep2.className = 'weather-bar-sep';
|
||||||
|
sep2.textContent = '|';
|
||||||
|
bar.appendChild(sep2);
|
||||||
|
|
||||||
|
const sunsetSpan = document.createElement('span');
|
||||||
|
sunsetSpan.textContent = `\uD83C\uDF19 Sunset ${sunsetStr}`;
|
||||||
|
bar.appendChild(sunsetSpan);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update sunrise and sunset times from API data
|
* Update sunrise and sunset times from API data
|
||||||
*/
|
*/
|
||||||
updateSunTimesFromApi(data) {
|
async updateSunTimesFromApi(data) {
|
||||||
if (!data || !data.sys || !data.sys.sunrise || !data.sys.sunset) {
|
if (!data || !data.sys || !data.sys.sunrise || !data.sys.sunset) {
|
||||||
console.warn('No sunrise/sunset data in API response, using calculated times');
|
console.warn('No sunrise/sunset data in API response, using calculated times');
|
||||||
this.updateSunTimesFromCalculation();
|
await this.updateSunTimesFromCalculation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const today = new Date();
|
// Create Date objects from Unix timestamps for today
|
||||||
const tomorrow = new Date(today);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
|
|
||||||
// Create Date objects from Unix timestamps
|
|
||||||
const sunrise = new Date(data.sys.sunrise * 1000);
|
const sunrise = new Date(data.sys.sunrise * 1000);
|
||||||
const sunset = new Date(data.sys.sunset * 1000);
|
const sunset = new Date(data.sys.sunset * 1000);
|
||||||
|
|
||||||
// Use calculated times for tomorrow
|
// Fetch tomorrow's times from sunrise-sunset.org API
|
||||||
const tomorrowTimes = this.calculateSunTimes(tomorrow);
|
const tomorrowTimes = await this.fetchSunTimes('tomorrow');
|
||||||
|
|
||||||
this.sunTimes = {
|
this.sunTimes = {
|
||||||
today: { sunrise, sunset },
|
today: { sunrise, sunset },
|
||||||
@@ -411,92 +455,62 @@ class WeatherManager {
|
|||||||
return this.sunTimes;
|
return this.sunTimes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating sun times from API:', 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() {
|
async updateSunTimesFromCalculation() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Calculate sun times based on date and location
|
const [todayData, tomorrowData] = await Promise.all([
|
||||||
const today = new Date();
|
this.fetchSunTimes('today'),
|
||||||
const tomorrow = new Date(today);
|
this.fetchSunTimes('tomorrow')
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
]);
|
||||||
|
|
||||||
this.sunTimes = {
|
this.sunTimes = {
|
||||||
today: this.calculateSunTimes(today),
|
today: todayData,
|
||||||
tomorrow: this.calculateSunTimes(tomorrow)
|
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;
|
return this.sunTimes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating sun times from calculation:', error);
|
console.error('Error fetching sun times from API, using defaults:', error);
|
||||||
// Fallback to default times if calculation fails
|
|
||||||
const defaultSunrise = new Date();
|
const defaultSunrise = new Date();
|
||||||
defaultSunrise.setHours(6, 45, 0, 0);
|
defaultSunrise.setHours(7, 0, 0, 0);
|
||||||
|
|
||||||
const defaultSunset = new Date();
|
const defaultSunset = new Date();
|
||||||
defaultSunset.setHours(17, 32, 0, 0);
|
defaultSunset.setHours(16, 0, 0, 0);
|
||||||
|
|
||||||
this.sunTimes = {
|
this.sunTimes = {
|
||||||
today: {
|
today: { sunrise: defaultSunrise, sunset: defaultSunset },
|
||||||
sunrise: defaultSunrise,
|
tomorrow: { sunrise: defaultSunrise, sunset: defaultSunset }
|
||||||
sunset: defaultSunset
|
|
||||||
},
|
|
||||||
tomorrow: {
|
|
||||||
sunrise: defaultSunrise,
|
|
||||||
sunset: defaultSunset
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return this.sunTimes;
|
return this.sunTimes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate sunrise and sunset times for a given date
|
* Fetch sunrise/sunset times from sunrise-sunset.org API
|
||||||
* Uses a simplified algorithm
|
* @param {string} date - 'today', 'tomorrow', or YYYY-MM-DD
|
||||||
|
* @returns {Object} { sunrise: Date, sunset: Date }
|
||||||
*/
|
*/
|
||||||
calculateSunTimes(date) {
|
async fetchSunTimes(date) {
|
||||||
// This is a simplified calculation
|
const url = `https://api.sunrise-sunset.org/json?lat=${this.options.latitude}&lng=${this.options.longitude}&date=${date}&formatted=0`;
|
||||||
// For more accuracy, you would use a proper astronomical calculation
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
// Get day of year
|
if (data.status !== 'OK') {
|
||||||
const start = new Date(date.getFullYear(), 0, 0);
|
throw new Error(`Sunrise-sunset API returned status: ${data.status}`);
|
||||||
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
|
return {
|
||||||
// This is a very simplified model
|
sunrise: new Date(data.results.sunrise),
|
||||||
const latitude = this.options.latitude;
|
sunset: new Date(data.results.sunset)
|
||||||
|
};
|
||||||
// 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -588,7 +602,136 @@ class WeatherManager {
|
|||||||
if (!this.lastUpdated) return 'Never';
|
if (!this.lastUpdated) return 'Never';
|
||||||
return this.formatTime(this.lastUpdated);
|
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;
|
window.WeatherManager = WeatherManager;
|
||||||
|
|||||||
@@ -3,16 +3,20 @@
|
|||||||
* Initializes all components when the DOM is ready
|
* Initializes all components when the DOM is ready
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Constants } from './utils/constants.js';
|
||||||
|
import { logger } from './utils/logger.js';
|
||||||
|
import { ConfigManager } from './components/ConfigManager.js';
|
||||||
|
import { Clock } from './components/Clock.js';
|
||||||
|
import { WeatherManager } from './components/WeatherManager.js';
|
||||||
|
import { DeparturesManager } from './components/DeparturesManager.js';
|
||||||
|
import { NewsTicker } from './components/NewsTicker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to ensure content wrapper exists for rotated orientations
|
* Function to ensure content wrapper exists for rotated orientations
|
||||||
*/
|
*/
|
||||||
function ensureContentWrapper() {
|
function ensureContentWrapper() {
|
||||||
if (!document.getElementById('content-wrapper')) {
|
if (!document.getElementById('content-wrapper')) {
|
||||||
if (window.logger) {
|
logger.info('Creating content wrapper');
|
||||||
window.logger.info('Creating content wrapper');
|
|
||||||
} else {
|
|
||||||
console.log('Creating content wrapper');
|
|
||||||
}
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.id = 'content-wrapper';
|
wrapper.id = 'content-wrapper';
|
||||||
|
|
||||||
@@ -40,28 +44,18 @@ function ensureContentWrapper() {
|
|||||||
|
|
||||||
// Initialize components when the DOM is loaded
|
// Initialize components when the DOM is loaded
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
if (window.logger) {
|
logger.info('DOM fully loaded');
|
||||||
window.logger.info('DOM fully loaded');
|
|
||||||
} else {
|
|
||||||
console.log('DOM fully loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize ConfigManager first
|
// Initialize ConfigManager first
|
||||||
if (window.logger) {
|
logger.info('Creating ConfigManager...');
|
||||||
window.logger.info('Creating ConfigManager...');
|
|
||||||
} else {
|
|
||||||
console.log('Creating ConfigManager...');
|
|
||||||
}
|
|
||||||
window.configManager = new ConfigManager({
|
window.configManager = new ConfigManager({
|
||||||
defaultOrientation: 'normal',
|
defaultOrientation: 'normal',
|
||||||
defaultDarkMode: 'auto'
|
defaultDarkMode: 'auto'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: ConfigManager already creates the config button and modal
|
|
||||||
|
|
||||||
// Initialize Clock
|
// Initialize Clock
|
||||||
const timezone = window.Constants?.TIMEZONE || 'Europe/Stockholm';
|
const timezone = Constants.TIMEZONE || 'Europe/Stockholm';
|
||||||
window.clock = new Clock({
|
window.clock = new Clock({
|
||||||
elementId: 'clock',
|
elementId: 'clock',
|
||||||
timezone: timezone
|
timezone: timezone
|
||||||
@@ -69,25 +63,23 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
|
|
||||||
// Initialize WeatherManager with location from window config or constants
|
// Initialize WeatherManager with location from window config or constants
|
||||||
const defaultLat = window.DEFAULT_LOCATION?.latitude ||
|
const defaultLat = window.DEFAULT_LOCATION?.latitude ||
|
||||||
(window.Constants?.DEFAULT_LOCATION?.LATITUDE) || 59.3293;
|
Constants.DEFAULT_LOCATION.LATITUDE || 59.3293;
|
||||||
const defaultLon = window.DEFAULT_LOCATION?.longitude ||
|
const defaultLon = window.DEFAULT_LOCATION?.longitude ||
|
||||||
(window.Constants?.DEFAULT_LOCATION?.LONGITUDE) || 18.0686;
|
Constants.DEFAULT_LOCATION.LONGITUDE || 18.0686;
|
||||||
window.weatherManager = new WeatherManager({
|
window.weatherManager = new WeatherManager({
|
||||||
latitude: defaultLat,
|
latitude: defaultLat,
|
||||||
longitude: defaultLon
|
longitude: defaultLon
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize departures - use DeparturesManager
|
// Initialize DeparturesManager
|
||||||
if (typeof DeparturesManager !== 'undefined') {
|
|
||||||
window.departuresManager = new DeparturesManager({
|
window.departuresManager = new DeparturesManager({
|
||||||
containerId: 'departures',
|
containerId: 'departures',
|
||||||
statusId: 'status',
|
statusId: 'status',
|
||||||
lastUpdatedId: 'last-updated'
|
lastUpdatedId: 'last-updated'
|
||||||
});
|
});
|
||||||
} else if (typeof initDepartures === 'function') {
|
|
||||||
// Fallback to legacy function if DeparturesManager not available
|
// Initialize NewsTicker (visible in landscape mode only via CSS)
|
||||||
initDepartures();
|
window.newsTicker = new NewsTicker();
|
||||||
}
|
|
||||||
|
|
||||||
// Set up event listeners
|
// Set up event listeners
|
||||||
document.addEventListener('darkModeChanged', event => {
|
document.addEventListener('darkModeChanged', event => {
|
||||||
@@ -103,17 +95,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
// Ensure content wrapper exists initially
|
// Ensure content wrapper exists initially
|
||||||
ensureContentWrapper();
|
ensureContentWrapper();
|
||||||
|
|
||||||
if (window.logger) {
|
logger.info('All components initialized successfully');
|
||||||
window.logger.info('All components initialized successfully');
|
|
||||||
} else {
|
|
||||||
console.log('All components initialized successfully');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (window.logger) {
|
logger.error('Error during initialization:', error);
|
||||||
window.logger.error('Error during initialization:', error);
|
|
||||||
} else {
|
|
||||||
console.error('Error during initialization:', error);
|
|
||||||
}
|
|
||||||
const errorDiv = document.createElement('div');
|
const errorDiv = document.createElement('div');
|
||||||
errorDiv.className = 'error';
|
errorDiv.className = 'error';
|
||||||
errorDiv.textContent = `Initialization error: ${error.message}`;
|
errorDiv.textContent = `Initialization error: ${error.message}`;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const Constants = {
|
|||||||
|
|
||||||
// Refresh intervals (in milliseconds)
|
// Refresh intervals (in milliseconds)
|
||||||
REFRESH: {
|
REFRESH: {
|
||||||
DEPARTURES: 5000, // 5 seconds
|
DEPARTURES: 30000, // 30 seconds
|
||||||
WEATHER: 30 * 60 * 1000, // 30 minutes
|
WEATHER: 30 * 60 * 1000, // 30 minutes
|
||||||
DARK_MODE_CHECK: 60000 // 1 minute
|
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;
|
window.Constants = Constants;
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class Logger {
|
|||||||
// Create a singleton instance
|
// Create a singleton instance
|
||||||
const logger = new Logger();
|
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;
|
||||||
window.logger = logger;
|
window.logger = logger;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const url = require('url');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ loadSitesConfig();
|
|||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = http.createServer(async (req, res) => {
|
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-Origin', '*');
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
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);
|
sitesRouter.handleSiteSearch(req, res, parsedUrl);
|
||||||
}
|
}
|
||||||
else if (parsedUrl.pathname === '/api/sites/nearby') {
|
else if (parsedUrl.pathname === '/api/sites/nearby') {
|
||||||
sitesRouter.handleNearbySites(req, res, parsedUrl);
|
await sitesRouter.handleNearbySites(req, res, parsedUrl);
|
||||||
}
|
}
|
||||||
else if (parsedUrl.pathname === '/api/config') {
|
else if (parsedUrl.pathname === '/api/config') {
|
||||||
configRouter.handleGetConfig(req, res, config);
|
configRouter.handleGetConfig(req, res, config);
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ const https = require('https');
|
|||||||
/**
|
/**
|
||||||
* Fetch departures for a specific site from SL Transport API
|
* Fetch departures for a specific site from SL Transport API
|
||||||
* @param {string} siteId - The site ID to fetch departures for
|
* @param {string} siteId - The site ID to fetch departures for
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {number} options.forecast - Time window in minutes (default: 60)
|
||||||
|
* @param {string} options.transport - Transport mode filter (e.g. 'BUS', 'METRO')
|
||||||
* @returns {Promise<Object>} - Departure data
|
* @returns {Promise<Object>} - Departure data
|
||||||
*/
|
*/
|
||||||
function fetchDeparturesForSite(siteId) {
|
function fetchDeparturesForSite(siteId, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`;
|
const params = new URLSearchParams();
|
||||||
|
params.set('forecast', options.forecast || 60);
|
||||||
|
if (options.transport) {
|
||||||
|
params.set('transport', options.transport);
|
||||||
|
}
|
||||||
|
const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures?${params}`;
|
||||||
console.log(`Fetching data from: ${apiUrl}`);
|
console.log(`Fetching data from: ${apiUrl}`);
|
||||||
|
|
||||||
https.get(apiUrl, (res) => {
|
https.get(apiUrl, (res) => {
|
||||||
@@ -23,62 +31,14 @@ function fetchDeparturesForSite(siteId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
console.log('Raw API response:', data.substring(0, 200) + '...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(data);
|
const parsedData = JSON.parse(data);
|
||||||
console.log('Successfully parsed as regular JSON');
|
|
||||||
resolve(parsedData);
|
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing API response:', error);
|
console.error('Error parsing departures API response:', error);
|
||||||
// Return empty departures array instead of rejecting to be more resilient
|
|
||||||
resolve({
|
resolve({
|
||||||
departures: [],
|
departures: [],
|
||||||
error: 'Error processing API response: ' + error.message
|
error: 'Failed to parse API response: ' + error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -94,7 +54,7 @@ function fetchDeparturesForSite(siteId) {
|
|||||||
* @param {Array} enabledSites - Array of enabled site configurations
|
* @param {Array} enabledSites - Array of enabled site configurations
|
||||||
* @returns {Promise<Object>} - Object with sites array containing departure data
|
* @returns {Promise<Object>} - Object with sites array containing departure data
|
||||||
*/
|
*/
|
||||||
async function fetchAllDepartures(enabledSites) {
|
async function fetchAllDepartures(enabledSites, globalOptions = {}) {
|
||||||
if (enabledSites.length === 0) {
|
if (enabledSites.length === 0) {
|
||||||
return { sites: [], error: 'No enabled sites configured' };
|
return { sites: [], error: 'No enabled sites configured' };
|
||||||
}
|
}
|
||||||
@@ -102,7 +62,11 @@ async function fetchAllDepartures(enabledSites) {
|
|||||||
try {
|
try {
|
||||||
const sitesPromises = enabledSites.map(async (site) => {
|
const sitesPromises = enabledSites.map(async (site) => {
|
||||||
try {
|
try {
|
||||||
const departureData = await fetchDeparturesForSite(site.id);
|
const siteOptions = {
|
||||||
|
forecast: site.forecast || globalOptions.forecast || 60,
|
||||||
|
transport: site.transport || globalOptions.transport || ''
|
||||||
|
};
|
||||||
|
const departureData = await fetchDeparturesForSite(site.id, siteOptions);
|
||||||
return {
|
return {
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
siteName: site.name,
|
siteName: site.name,
|
||||||
@@ -135,7 +99,11 @@ async function fetchAllDepartures(enabledSites) {
|
|||||||
async function handleDepartures(req, res, config) {
|
async function handleDepartures(req, res, config) {
|
||||||
try {
|
try {
|
||||||
const enabledSites = config.sites.filter(site => site.enabled);
|
const enabledSites = config.sites.filter(site => site.enabled);
|
||||||
const data = await fetchAllDepartures(enabledSites);
|
const globalOptions = {
|
||||||
|
forecast: config.forecast || 60,
|
||||||
|
transport: config.transport || ''
|
||||||
|
};
|
||||||
|
const data = await fetchAllDepartures(enabledSites, globalOptions);
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,131 +1,147 @@
|
|||||||
/**
|
/**
|
||||||
* Sites route handler
|
* Sites route handler
|
||||||
* Handles site search and nearby sites queries
|
* 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');
|
const https = require('https');
|
||||||
|
|
||||||
/**
|
// ── Site cache for nearby lookups ──────────────────────────────────────────
|
||||||
* Normalize site data from API response to consistent format
|
let cachedSites = null;
|
||||||
* @param {Object} site - Raw site data from API
|
let cacheTimestamp = null;
|
||||||
* @returns {Object} - Normalized site object
|
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
*/
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse sites from API response (handles multiple response formats)
|
* Fetch and cache all sites from the SL Transport API
|
||||||
* @param {Object|Array} parsedData - Parsed JSON data from API
|
* The /v1/sites endpoint returns ~6500 sites with coordinates.
|
||||||
* @returns {Array<Object>} - Array of normalized sites
|
* We fetch this once and reuse it for nearby-site lookups.
|
||||||
|
* @returns {Promise<Array>} Array of normalized site objects
|
||||||
*/
|
*/
|
||||||
function parseSitesFromResponse(parsedData) {
|
function getAllSites() {
|
||||||
let sites = [];
|
if (cachedSites && cacheTimestamp && (Date.now() - cacheTimestamp < CACHE_TTL)) {
|
||||||
|
return Promise.resolve(cachedSites);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
* Handle site search endpoint using SL Journey Planner v2 Stop Finder
|
||||||
* @param {http.IncomingMessage} req - HTTP request object
|
* This endpoint does real server-side search (unlike /v1/sites which returns everything)
|
||||||
* @param {http.ServerResponse} res - HTTP response object
|
|
||||||
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
|
|
||||||
*/
|
*/
|
||||||
function handleSiteSearch(req, res, parsedUrl) {
|
function handleSiteSearch(req, res, parsedUrl) {
|
||||||
const query = parsedUrl.query.q;
|
const query = parsedUrl.searchParams.get('q');
|
||||||
if (!query || query.length < 2) {
|
if (!query || query.length < 2) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
|
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(query)}`;
|
// any_obj_filter_sf=2 restricts results to stops only
|
||||||
console.log(`Searching sites: ${searchUrl}`);
|
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) => {
|
https.get(searchUrl, (apiRes) => {
|
||||||
let data = '';
|
let data = '';
|
||||||
|
|
||||||
if (apiRes.statusCode < 200 || apiRes.statusCode >= 300) {
|
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.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] }));
|
res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
apiRes.on('data', (chunk) => {
|
apiRes.on('data', chunk => { data += chunk; });
|
||||||
data += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
apiRes.on('end', () => {
|
apiRes.on('end', () => {
|
||||||
try {
|
try {
|
||||||
console.log('Raw API response:', data.substring(0, 500));
|
const parsed = JSON.parse(data);
|
||||||
const parsedData = JSON.parse(data);
|
const locations = parsed.locations || [];
|
||||||
console.log('Parsed data:', JSON.stringify(parsedData).substring(0, 500));
|
|
||||||
|
|
||||||
const sites = parseSitesFromResponse(parsedData);
|
const sites = locations
|
||||||
|
.filter(loc => loc.type === 'stop' && loc.properties && loc.properties.stopId)
|
||||||
if (sites.length > 0) {
|
.map(loc => ({
|
||||||
console.log('Sample site structure:', JSON.stringify(sites[0], null, 2));
|
id: stopIdToSiteId(loc.properties.stopId),
|
||||||
const sitesWithCoords = sites.filter(s => s.lat && s.lon);
|
name: loc.disassembledName || loc.name || 'Unknown',
|
||||||
console.log(`Found ${sites.length} sites, ${sitesWithCoords.length} with coordinates`);
|
lat: loc.coord ? loc.coord[0] : null,
|
||||||
} else {
|
lon: loc.coord ? loc.coord[1] : null
|
||||||
console.log('No sites found');
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
|
console.log(`Search "${query}" returned ${sites.length} stops`);
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ sites }));
|
res.end(JSON.stringify({ sites }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing site search response:', error);
|
console.error('Error parsing search response:', error);
|
||||||
console.error('Response data:', data.substring(0, 500));
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
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) => {
|
}).on('error', (error) => {
|
||||||
console.error('Error searching sites:', error);
|
console.error('Error searching sites:', error);
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
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)
|
* Calculate distance between two coordinates using equirectangular approximation
|
||||||
* @param {number} lat1 - Latitude of point 1
|
* Accurate enough for distances under ~100km at Stockholm's latitude
|
||||||
* @param {number} lon1 - Longitude of point 1
|
* @returns {number} Distance in meters
|
||||||
* @param {number} lat2 - Latitude of point 2
|
|
||||||
* @param {number} lon2 - Longitude of point 2
|
|
||||||
* @returns {number} - Distance in meters
|
|
||||||
*/
|
*/
|
||||||
function calculateDistance(lat1, lon1, lat2, lon2) {
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||||
return Math.sqrt(
|
const dLat = (lat2 - lat1) * 111000;
|
||||||
Math.pow((lat1 - lat2) * 111000, 2) +
|
const dLon = (lon2 - lon1) * 111000 * Math.cos(lat1 * Math.PI / 180);
|
||||||
Math.pow((lon1 - lon2) * 111000 * Math.cos(lat1 * Math.PI / 180), 2)
|
return Math.sqrt(dLat * dLat + dLon * dLon);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle nearby sites endpoint
|
* Handle nearby sites endpoint
|
||||||
* @param {http.IncomingMessage} req - HTTP request object
|
* Uses cached site list — no redundant API calls per request
|
||||||
* @param {http.ServerResponse} res - HTTP response object
|
|
||||||
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
|
|
||||||
*/
|
*/
|
||||||
function handleNearbySites(req, res, parsedUrl) {
|
async function handleNearbySites(req, res, parsedUrl) {
|
||||||
const lat = parseFloat(parsedUrl.query.lat);
|
const lat = parseFloat(parsedUrl.searchParams.get('lat'));
|
||||||
const lon = parseFloat(parsedUrl.query.lon);
|
const lon = parseFloat(parsedUrl.searchParams.get('lon'));
|
||||||
const radius = parseInt(parsedUrl.query.radius) || 5000; // Default 5km radius
|
const radius = parseInt(parsedUrl.searchParams.get('radius')) || 1000; // Default 1km
|
||||||
|
|
||||||
if (isNaN(lat) || isNaN(lon)) {
|
if (isNaN(lat) || isNaN(lon)) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
@@ -133,66 +149,30 @@ function handleNearbySites(req, res, parsedUrl) {
|
|||||||
return;
|
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 {
|
try {
|
||||||
const parsedData = JSON.parse(data);
|
const allSites = await getAllSites();
|
||||||
const sites = parseSitesFromResponse(parsedData);
|
|
||||||
|
|
||||||
sites.forEach(site => {
|
const nearby = allSites
|
||||||
if (site.lat && site.lon) {
|
.filter(site => site.lat && site.lon)
|
||||||
const distance = calculateDistance(lat, lon, 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);
|
||||||
|
|
||||||
if (distance <= radius) {
|
console.log(`Found ${nearby.length} sites within ${radius}m of [${lat}, ${lon}]`);
|
||||||
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.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ sites: uniqueSites }));
|
res.end(JSON.stringify({ sites: nearby }));
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
completedSearches++;
|
console.error('Error fetching nearby sites:', error);
|
||||||
if (completedSearches === searchTerms.length) {
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
|
res.end(JSON.stringify({ error: 'Error fetching nearby sites', sites: [] }));
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleSiteSearch,
|
handleSiteSearch,
|
||||||
handleNearbySites,
|
handleNearbySites,
|
||||||
normalizeSite,
|
getAllSites
|
||||||
parseSitesFromResponse
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user