Initial commit: Digital signage system for transit departures, weather, and news ticker
This commit is contained in:
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Node.js dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Local development files
|
||||||
|
.nodemon.json
|
||||||
|
|
||||||
|
# Raspberry Pi specific
|
||||||
|
*.img
|
||||||
|
*.img.gz
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*~
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
config.local.js
|
||||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# SL Transport Departures Display
|
||||||
|
|
||||||
|
A digital signage system for displaying transit departures, weather information, and news tickers. Perfect for Raspberry Pi-based information displays.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Real-time transit departure information
|
||||||
|
- Current weather and hourly forecast
|
||||||
|
- News ticker with RSS feed integration
|
||||||
|
- Multiple screen orientation support (0°, 90°, 180°, 270°)
|
||||||
|
- Dark mode with automatic switching based on sunrise/sunset
|
||||||
|
- Custom background image support
|
||||||
|
- Configurable ticker speed
|
||||||
|
- Responsive design for various screen sizes
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Clone this repository
|
||||||
|
2. Run the server: `node server.js`
|
||||||
|
3. Open a browser and navigate to: `http://localhost:3002`
|
||||||
|
|
||||||
|
## System Requirements
|
||||||
|
|
||||||
|
- Node.js (v12 or higher)
|
||||||
|
- Modern web browser with CSS3 support
|
||||||
|
- Internet connection for API access
|
||||||
|
|
||||||
|
## Raspberry Pi Setup
|
||||||
|
|
||||||
|
For a dedicated display on Raspberry Pi, we've included a setup script that:
|
||||||
|
|
||||||
|
1. Installs Node.js if needed
|
||||||
|
2. Creates a systemd service to run the server on boot
|
||||||
|
3. Sets up Chromium to launch in kiosk mode on startup
|
||||||
|
4. Disables screen blanking and screensaver
|
||||||
|
5. Creates a desktop shortcut for manual launch
|
||||||
|
|
||||||
|
To set up on Raspberry Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/sl-departures-display.git
|
||||||
|
cd sl-departures-display
|
||||||
|
|
||||||
|
# Make the setup script executable
|
||||||
|
chmod +x raspberry-pi-setup.sh
|
||||||
|
|
||||||
|
# Run the setup script as root
|
||||||
|
sudo ./raspberry-pi-setup.sh
|
||||||
|
|
||||||
|
# Reboot to apply all changes
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Changing Transit Stop
|
||||||
|
|
||||||
|
To display departures for a different transit stop, modify the API_URL in server.js:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_URL = 'https://transport.integration.sl.se/v1/sites/YOUR_SITE_ID/departures';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changing Weather Location
|
||||||
|
|
||||||
|
To display weather for a different location, modify the latitude and longitude in index.html:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.weatherManager = new WeatherManager({
|
||||||
|
latitude: YOUR_LATITUDE,
|
||||||
|
longitude: YOUR_LONGITUDE
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changing RSS Feed
|
||||||
|
|
||||||
|
To display a different news source, modify the RSS_URL in server.js:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const RSS_URL = 'https://your-rss-feed-url.xml';
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Settings
|
||||||
|
|
||||||
|
The gear icon in the top-right corner opens the settings panel where you can configure:
|
||||||
|
|
||||||
|
- Screen orientation
|
||||||
|
- Dark mode (auto/on/off)
|
||||||
|
- Background image and opacity
|
||||||
|
- Ticker speed
|
||||||
|
|
||||||
|
Settings are saved to localStorage and persist across sessions.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system consists of the following components:
|
||||||
|
|
||||||
|
1. **Node.js Server** - Handles API proxying and serves static files
|
||||||
|
2. **Configuration Manager** - Manages system settings and UI customization
|
||||||
|
3. **Weather Component** - Displays weather data and manages dark mode
|
||||||
|
4. **Clock Component** - Shows current time and date
|
||||||
|
5. **Ticker Component** - Displays scrolling news from RSS feeds
|
||||||
|
6. **Main UI** - Responsive layout with multiple orientation support
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
For detailed documentation, see [documentation.md](documentation.md).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues:
|
||||||
|
|
||||||
|
1. **Server won't start**
|
||||||
|
- Check if port 3002 is already in use
|
||||||
|
- Ensure Node.js is installed correctly
|
||||||
|
|
||||||
|
2. **No departures displayed**
|
||||||
|
- Verify internet connection
|
||||||
|
- Check server console for API errors
|
||||||
|
- Ensure the site ID is correct (currently set to 9636)
|
||||||
|
|
||||||
|
3. **Weather data not loading**
|
||||||
|
- Check OpenWeatherMap API key
|
||||||
|
- Verify internet connection
|
||||||
|
- Look for errors in browser console
|
||||||
|
|
||||||
|
4. **Ticker not scrolling**
|
||||||
|
- Check if RSS feed is accessible
|
||||||
|
- Verify ticker speed setting is not set to 0
|
||||||
|
- Look for JavaScript errors in console
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
- OpenWeatherMap for weather data
|
||||||
|
- SL Transport API for departure information
|
||||||
165
clock.js
Normal file
165
clock.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* clock.js - A modular clock component for displaying time and date
|
||||||
|
* Specifically configured for Stockholm, Sweden timezone
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Clock {
|
||||||
|
constructor(options = {}) {
|
||||||
|
// Default options
|
||||||
|
this.options = {
|
||||||
|
elementId: 'clock',
|
||||||
|
timeFormat: 'HH:MM:SS',
|
||||||
|
dateFormat: 'WEEKDAY, MONTH DAY, YEAR',
|
||||||
|
timezone: 'Europe/Stockholm',
|
||||||
|
updateInterval: 1000, // Update every second
|
||||||
|
enableTimeSync: false, // Disable time sync by default to avoid CORS issues with local files
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.element = document.getElementById(this.options.elementId);
|
||||||
|
if (!this.element) {
|
||||||
|
console.error(`Clock element with ID "${this.options.elementId}" not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DOM structure
|
||||||
|
this.createClockElements();
|
||||||
|
|
||||||
|
// Start the clock
|
||||||
|
this.start();
|
||||||
|
|
||||||
|
// Sync with time server once a day (if enabled)
|
||||||
|
if (this.options.enableTimeSync) {
|
||||||
|
this.setupTimeSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the DOM elements for the clock
|
||||||
|
*/
|
||||||
|
createClockElements() {
|
||||||
|
// Create container with appropriate styling
|
||||||
|
this.element.classList.add('clock-container');
|
||||||
|
|
||||||
|
// Create time element
|
||||||
|
this.timeElement = document.createElement('div');
|
||||||
|
this.timeElement.classList.add('clock-time');
|
||||||
|
this.element.appendChild(this.timeElement);
|
||||||
|
|
||||||
|
// Create a separate date element
|
||||||
|
this.dateElement = document.createElement('div');
|
||||||
|
this.dateElement.classList.add('clock-date');
|
||||||
|
this.element.appendChild(this.dateElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the clock with the specified update interval
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
// Update immediately
|
||||||
|
this.update();
|
||||||
|
|
||||||
|
// Set interval for updates
|
||||||
|
this.intervalId = setInterval(() => this.update(), this.options.updateInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the clock
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the clock display
|
||||||
|
*/
|
||||||
|
update() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Format and display the time
|
||||||
|
this.timeElement.innerHTML = this.formatTime(now);
|
||||||
|
|
||||||
|
// Format and display the date
|
||||||
|
this.dateElement.textContent = " " + this.formatDate(now);
|
||||||
|
|
||||||
|
// Make sure the date element is visible
|
||||||
|
this.dateElement.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the time according to the specified format
|
||||||
|
* @param {Date} date - The date object to format
|
||||||
|
* @returns {string} - The formatted time string
|
||||||
|
*/
|
||||||
|
formatTime(date) {
|
||||||
|
const options = {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: this.options.timezone
|
||||||
|
};
|
||||||
|
|
||||||
|
return date.toLocaleTimeString('sv-SE', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the date according to the specified format
|
||||||
|
* @param {Date} date - The date object to format
|
||||||
|
* @returns {string} - The formatted date string
|
||||||
|
*/
|
||||||
|
formatDate(date) {
|
||||||
|
const options = {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: this.options.timezone
|
||||||
|
};
|
||||||
|
|
||||||
|
return date.toLocaleDateString('sv-SE', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up time synchronization with a time server
|
||||||
|
* This will sync the time once a day to ensure accuracy
|
||||||
|
*/
|
||||||
|
setupTimeSync() {
|
||||||
|
// Function to sync time
|
||||||
|
const syncTime = async () => {
|
||||||
|
try {
|
||||||
|
// Check if we're running from a local file (which would cause CORS issues)
|
||||||
|
if (window.location.protocol === 'file:') {
|
||||||
|
console.log('Running from local file, skipping time sync to avoid CORS issues');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the WorldTimeAPI to get the current time for Stockholm
|
||||||
|
const response = await fetch('https://worldtimeapi.org/api/timezone/Europe/Stockholm');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Time synced with WorldTimeAPI:', data);
|
||||||
|
|
||||||
|
// The API already returns the time in the correct timezone
|
||||||
|
// We just log it for verification purposes
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Time sync skipped:', error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync immediately on load
|
||||||
|
syncTime();
|
||||||
|
|
||||||
|
// Then sync once a day (86400000 ms = 24 hours)
|
||||||
|
setInterval(syncTime, 86400000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the Clock class for use in other modules
|
||||||
|
window.Clock = Clock;
|
||||||
844
config.js
Normal file
844
config.js
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
/**
|
||||||
|
* config.js - A modular configuration component for the SL Transport Departures app
|
||||||
|
* Allows changing settings on the fly, such as orientation (portrait/landscape)
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ConfigManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
// Default options
|
||||||
|
this.options = {
|
||||||
|
configButtonId: 'config-button',
|
||||||
|
configModalId: 'config-modal',
|
||||||
|
saveToLocalStorage: true,
|
||||||
|
defaultOrientation: 'normal',
|
||||||
|
defaultDarkMode: 'auto', // 'auto', 'on', or 'off'
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add updateBackgroundPreview function
|
||||||
|
this.updateBackgroundPreview = function() {
|
||||||
|
const preview = document.getElementById('background-preview');
|
||||||
|
const imageUrl = document.getElementById('background-image-url').value;
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
if (imageUrl && imageUrl.trim() !== '') {
|
||||||
|
preview.innerHTML = `<img src="${imageUrl}" alt="Background preview">`;
|
||||||
|
} else {
|
||||||
|
preview.innerHTML = '<div class="no-image">No image selected</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configuration state
|
||||||
|
this.config = {
|
||||||
|
orientation: this.options.defaultOrientation,
|
||||||
|
darkMode: this.options.defaultDarkMode,
|
||||||
|
backgroundImage: '',
|
||||||
|
backgroundOpacity: 0.3, // Default opacity (30%)
|
||||||
|
tickerSpeed: 60, // Default ticker speed in seconds
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
id: '1411',
|
||||||
|
name: 'Ambassaderna',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rssFeeds: [
|
||||||
|
{
|
||||||
|
name: "Travel Alerts",
|
||||||
|
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
combineSameDirection: true, // Combine departures going in the same direction
|
||||||
|
...this.loadConfig() // Load saved config if available
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync configuration with server
|
||||||
|
this.syncConfig();
|
||||||
|
|
||||||
|
// Create UI elements
|
||||||
|
this.createConfigButton();
|
||||||
|
this.createConfigModal();
|
||||||
|
|
||||||
|
// Add keyboard shortcuts for testing
|
||||||
|
this.setupKeyboardShortcuts();
|
||||||
|
|
||||||
|
// Apply initial configuration
|
||||||
|
this.applyConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the configuration button (gear icon)
|
||||||
|
*/
|
||||||
|
createConfigButton() {
|
||||||
|
const buttonContainer = document.createElement('div');
|
||||||
|
buttonContainer.id = this.options.configButtonId;
|
||||||
|
buttonContainer.className = 'config-button';
|
||||||
|
buttonContainer.title = 'Settings';
|
||||||
|
|
||||||
|
buttonContainer.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.65.07-.97 0-.32-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1 0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
buttonContainer.addEventListener('click', () => this.toggleConfigModal());
|
||||||
|
document.body.appendChild(buttonContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the configuration modal
|
||||||
|
*/
|
||||||
|
createConfigModal() {
|
||||||
|
const modalContainer = document.createElement('div');
|
||||||
|
modalContainer.id = this.options.configModalId;
|
||||||
|
modalContainer.className = 'config-modal';
|
||||||
|
modalContainer.style.display = 'none';
|
||||||
|
|
||||||
|
modalContainer.innerHTML = `
|
||||||
|
<div class="config-modal-content">
|
||||||
|
<div class="config-modal-header">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<span class="config-modal-close">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-modal-body">
|
||||||
|
<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 class="config-option">
|
||||||
|
<label for="background-image-url">Background Image:</label>
|
||||||
|
<input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}">
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
||||||
|
<button id="test-image-button" style="padding: 5px 10px;">Use Test Image</button>
|
||||||
|
<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 class="config-option">
|
||||||
|
<label for="ticker-speed">Ticker Speed: <span id="ticker-speed-value">${this.config.tickerSpeed}</span> seconds</label>
|
||||||
|
<input type="range" id="ticker-speed" min="10" max="120" step="5" value="${this.config.tickerSpeed}">
|
||||||
|
</div>
|
||||||
|
<div class="config-option">
|
||||||
|
<label>Transit Sites:</label>
|
||||||
|
<div id="sites-container">
|
||||||
|
${this.generateSitesHTML()}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<button id="add-site-button" style="padding: 5px 10px;">Add Site</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-option">
|
||||||
|
<label>RSS Feeds:</label>
|
||||||
|
<div id="rss-feeds-container">
|
||||||
|
${this.generateRssFeedsHTML()}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<button id="add-feed-button" style="padding: 5px 10px;">Add Feed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-option">
|
||||||
|
<label for="combine-directions">
|
||||||
|
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
|
||||||
|
Combine departures going in the same direction
|
||||||
|
</label>
|
||||||
|
</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);
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal());
|
||||||
|
modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal());
|
||||||
|
modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig());
|
||||||
|
modalContainer.querySelector('#test-image-button').addEventListener('click', () => {
|
||||||
|
const testImageUrl = 'https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000';
|
||||||
|
document.getElementById('background-image-url').value = testImageUrl;
|
||||||
|
this.updateBackgroundPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listener for add site button
|
||||||
|
const addSiteButton = modalContainer.querySelector('#add-site-button');
|
||||||
|
if (addSiteButton) {
|
||||||
|
addSiteButton.addEventListener('click', () => this.addNewSite());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for add feed button
|
||||||
|
const addFeedButton = modalContainer.querySelector('#add-feed-button');
|
||||||
|
if (addFeedButton) {
|
||||||
|
addFeedButton.addEventListener('click', () => this.addNewFeed());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for local image selection
|
||||||
|
const localImageInput = modalContainer.querySelector('#local-image-input');
|
||||||
|
if (localImageInput) {
|
||||||
|
localImageInput.addEventListener('change', (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const imageDataUrl = e.target.result;
|
||||||
|
document.getElementById('background-image-url').value = imageDataUrl;
|
||||||
|
this.updateBackgroundPreview();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside the content
|
||||||
|
modalContainer.addEventListener('click', (event) => {
|
||||||
|
if (event.target === modalContainer) {
|
||||||
|
this.hideConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for site and feed management
|
||||||
|
this.setupSiteEventListeners();
|
||||||
|
this.setupFeedEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listeners for site management
|
||||||
|
*/
|
||||||
|
setupSiteEventListeners() {
|
||||||
|
const sitesContainer = document.getElementById('sites-container');
|
||||||
|
if (!sitesContainer) return;
|
||||||
|
|
||||||
|
const removeButtons = sitesContainer.querySelectorAll('.remove-site-button');
|
||||||
|
removeButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
const siteItem = event.target.closest('.site-item');
|
||||||
|
if (siteItem) {
|
||||||
|
const index = parseInt(siteItem.dataset.index);
|
||||||
|
this.removeSite(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listeners for RSS feed management
|
||||||
|
*/
|
||||||
|
setupFeedEventListeners() {
|
||||||
|
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||||
|
if (!feedsContainer) return;
|
||||||
|
|
||||||
|
const removeButtons = feedsContainer.querySelectorAll('.remove-feed-button');
|
||||||
|
removeButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
const feedItem = event.target.closest('.feed-item');
|
||||||
|
if (feedItem) {
|
||||||
|
const index = parseInt(feedItem.dataset.index);
|
||||||
|
this.removeFeed(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new site to the configuration
|
||||||
|
*/
|
||||||
|
addNewSite() {
|
||||||
|
this.config.sites.push({
|
||||||
|
id: '',
|
||||||
|
name: 'New Site',
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const sitesContainer = document.getElementById('sites-container');
|
||||||
|
if (sitesContainer) {
|
||||||
|
sitesContainer.innerHTML = this.generateSitesHTML();
|
||||||
|
this.setupSiteEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new RSS feed to the configuration
|
||||||
|
*/
|
||||||
|
addNewFeed() {
|
||||||
|
this.config.rssFeeds.push({
|
||||||
|
name: 'New Feed',
|
||||||
|
url: '',
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||||
|
if (feedsContainer) {
|
||||||
|
feedsContainer.innerHTML = this.generateRssFeedsHTML();
|
||||||
|
this.setupFeedEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a site from the configuration
|
||||||
|
*/
|
||||||
|
removeSite(index) {
|
||||||
|
if (index >= 0 && index < this.config.sites.length) {
|
||||||
|
this.config.sites.splice(index, 1);
|
||||||
|
|
||||||
|
const sitesContainer = document.getElementById('sites-container');
|
||||||
|
if (sitesContainer) {
|
||||||
|
sitesContainer.innerHTML = this.generateSitesHTML();
|
||||||
|
this.setupSiteEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an RSS feed from the configuration
|
||||||
|
*/
|
||||||
|
removeFeed(index) {
|
||||||
|
if (index >= 0 && index < this.config.rssFeeds.length) {
|
||||||
|
this.config.rssFeeds.splice(index, 1);
|
||||||
|
|
||||||
|
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||||
|
if (feedsContainer) {
|
||||||
|
feedsContainer.innerHTML = this.generateRssFeedsHTML();
|
||||||
|
this.setupFeedEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the configuration modal visibility
|
||||||
|
*/
|
||||||
|
toggleConfigModal() {
|
||||||
|
const modal = document.getElementById(this.options.configModalId);
|
||||||
|
if (modal.style.display === 'none') {
|
||||||
|
this.showConfigModal();
|
||||||
|
} else {
|
||||||
|
this.hideConfigModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the configuration modal
|
||||||
|
*/
|
||||||
|
hideConfigModal() {
|
||||||
|
const modal = document.getElementById(this.options.configModalId);
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the configuration modal
|
||||||
|
*/
|
||||||
|
showConfigModal() {
|
||||||
|
const modal = document.getElementById(this.options.configModalId);
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
// Update form values to match current config
|
||||||
|
document.getElementById('orientation-select').value = this.config.orientation;
|
||||||
|
document.getElementById('dark-mode-select').value = this.config.darkMode;
|
||||||
|
document.getElementById('combine-directions').checked = this.config.combineSameDirection;
|
||||||
|
|
||||||
|
// Update sites container
|
||||||
|
const sitesContainer = document.getElementById('sites-container');
|
||||||
|
if (sitesContainer) {
|
||||||
|
sitesContainer.innerHTML = this.generateSitesHTML();
|
||||||
|
this.setupSiteEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update RSS feeds container
|
||||||
|
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||||
|
if (feedsContainer) {
|
||||||
|
feedsContainer.innerHTML = this.generateRssFeedsHTML();
|
||||||
|
this.setupFeedEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update background image, opacity, and ticker speed values
|
||||||
|
const backgroundImageUrl = document.getElementById('background-image-url');
|
||||||
|
const backgroundOpacity = document.getElementById('background-opacity');
|
||||||
|
const opacityValue = document.getElementById('opacity-value');
|
||||||
|
const tickerSpeed = document.getElementById('ticker-speed');
|
||||||
|
const tickerSpeedValue = document.getElementById('ticker-speed-value');
|
||||||
|
|
||||||
|
if (backgroundImageUrl) {
|
||||||
|
backgroundImageUrl.value = this.config.backgroundImage || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backgroundOpacity) {
|
||||||
|
backgroundOpacity.value = this.config.backgroundOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opacityValue) {
|
||||||
|
opacityValue.textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tickerSpeed) {
|
||||||
|
tickerSpeed.value = this.config.tickerSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tickerSpeedValue) {
|
||||||
|
tickerSpeedValue.textContent = this.config.tickerSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update background preview
|
||||||
|
this.updateBackgroundPreview();
|
||||||
|
|
||||||
|
// Update sun times display
|
||||||
|
this.updateSunTimesDisplay();
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
this.setupModalEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listeners for the modal
|
||||||
|
*/
|
||||||
|
setupModalEventListeners() {
|
||||||
|
// Background image URL input
|
||||||
|
const imageUrlInput = document.getElementById('background-image-url');
|
||||||
|
if (imageUrlInput) {
|
||||||
|
const newInput = imageUrlInput.cloneNode(true);
|
||||||
|
imageUrlInput.parentNode.replaceChild(newInput, imageUrlInput);
|
||||||
|
newInput.addEventListener('input', () => this.updateBackgroundPreview());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opacity slider
|
||||||
|
const opacitySlider = document.getElementById('background-opacity');
|
||||||
|
if (opacitySlider) {
|
||||||
|
const newSlider = opacitySlider.cloneNode(true);
|
||||||
|
opacitySlider.parentNode.replaceChild(newSlider, opacitySlider);
|
||||||
|
newSlider.addEventListener('input', (e) => {
|
||||||
|
document.getElementById('opacity-value').textContent = `${Math.round(e.target.value * 100)}%`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticker speed slider
|
||||||
|
const tickerSpeedSlider = document.getElementById('ticker-speed');
|
||||||
|
if (tickerSpeedSlider) {
|
||||||
|
const newSlider = tickerSpeedSlider.cloneNode(true);
|
||||||
|
tickerSpeedSlider.parentNode.replaceChild(newSlider, tickerSpeedSlider);
|
||||||
|
newSlider.addEventListener('input', (e) => {
|
||||||
|
document.getElementById('ticker-speed-value').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local image selection
|
||||||
|
const localImageInput = document.getElementById('local-image-input');
|
||||||
|
if (localImageInput) {
|
||||||
|
const newInput = localImageInput.cloneNode(true);
|
||||||
|
localImageInput.parentNode.replaceChild(newInput, localImageInput);
|
||||||
|
newInput.addEventListener('change', (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
document.getElementById('background-image-url').value = e.target.result;
|
||||||
|
this.updateBackgroundPreview();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save and apply the configuration from the form
|
||||||
|
*/
|
||||||
|
saveAndApplyConfig() {
|
||||||
|
try {
|
||||||
|
// Get values from form
|
||||||
|
const orientationSelect = document.getElementById('orientation-select');
|
||||||
|
const darkModeSelect = document.getElementById('dark-mode-select');
|
||||||
|
const backgroundImageUrl = document.getElementById('background-image-url');
|
||||||
|
const backgroundOpacity = document.getElementById('background-opacity');
|
||||||
|
const tickerSpeed = document.getElementById('ticker-speed');
|
||||||
|
const combineDirections = document.getElementById('combine-directions');
|
||||||
|
|
||||||
|
// Get sites and feeds configuration from form
|
||||||
|
const sitesConfig = this.getSitesFromForm();
|
||||||
|
const feedsConfig = this.getRssFeedsFromForm();
|
||||||
|
|
||||||
|
// Get the background image URL directly from the DOM
|
||||||
|
const imageUrlValue = document.querySelector('#background-image-url').value;
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
this.config.orientation = orientationSelect.value;
|
||||||
|
this.config.darkMode = darkModeSelect.value;
|
||||||
|
this.config.backgroundImage = imageUrlValue;
|
||||||
|
this.config.backgroundOpacity = parseFloat(backgroundOpacity.value);
|
||||||
|
this.config.tickerSpeed = parseInt(tickerSpeed.value);
|
||||||
|
this.config.combineSameDirection = combineDirections.checked;
|
||||||
|
this.config.sites = sitesConfig;
|
||||||
|
this.config.rssFeeds = feedsConfig;
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
this.saveConfig();
|
||||||
|
|
||||||
|
// Sync with server
|
||||||
|
this.syncConfig();
|
||||||
|
|
||||||
|
// Update ticker speed if changed
|
||||||
|
if (window.tickerManager && this.config.tickerSpeed) {
|
||||||
|
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply config
|
||||||
|
this.applyConfig();
|
||||||
|
|
||||||
|
// Hide modal
|
||||||
|
this.hideConfigModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving configuration:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the current configuration to the page
|
||||||
|
*/
|
||||||
|
applyConfig() {
|
||||||
|
// Apply orientation
|
||||||
|
document.body.classList.remove('normal', 'landscape', 'vertical', 'upsidedown', 'vertical-reverse');
|
||||||
|
document.body.classList.add(this.config.orientation);
|
||||||
|
|
||||||
|
// Apply dark mode setting
|
||||||
|
this.applyDarkModeSetting();
|
||||||
|
|
||||||
|
// Apply background image and opacity
|
||||||
|
this.applyBackgroundImage();
|
||||||
|
|
||||||
|
// Apply ticker speed
|
||||||
|
this.applyTickerSpeed();
|
||||||
|
|
||||||
|
// Ensure content wrapper exists when changing to rotated modes
|
||||||
|
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(this.config.orientation)) {
|
||||||
|
this.ensureContentWrapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for other components to react to config changes
|
||||||
|
const event = new CustomEvent('configChanged', { detail: { config: this.config } });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply dark mode setting based on configuration
|
||||||
|
*/
|
||||||
|
applyDarkModeSetting() {
|
||||||
|
// Always update sun times display for informational purposes
|
||||||
|
this.updateSunTimesDisplay();
|
||||||
|
|
||||||
|
// If we have a WeatherManager instance
|
||||||
|
if (window.weatherManager) {
|
||||||
|
if (this.config.darkMode === 'auto') {
|
||||||
|
window.weatherManager.updateDarkModeBasedOnTime();
|
||||||
|
} else {
|
||||||
|
const isDarkMode = this.config.darkMode === 'on';
|
||||||
|
window.weatherManager.setDarkMode(isDarkMode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isDarkMode = this.config.darkMode === 'on';
|
||||||
|
document.body.classList.toggle('dark-mode', isDarkMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply background image and opacity settings
|
||||||
|
*/
|
||||||
|
applyBackgroundImage() {
|
||||||
|
// Remove any existing background overlay
|
||||||
|
const existingOverlay = document.getElementById('background-overlay');
|
||||||
|
if (existingOverlay) {
|
||||||
|
existingOverlay.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's a background image URL, create and apply the overlay
|
||||||
|
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'background-overlay';
|
||||||
|
overlay.style.position = 'fixed';
|
||||||
|
overlay.style.top = '0';
|
||||||
|
overlay.style.left = '0';
|
||||||
|
overlay.style.width = '100vw';
|
||||||
|
overlay.style.height = '100vh';
|
||||||
|
overlay.style.backgroundImage = `url(${this.config.backgroundImage})`;
|
||||||
|
overlay.style.backgroundSize = 'cover';
|
||||||
|
overlay.style.backgroundPosition = 'center';
|
||||||
|
overlay.style.opacity = this.config.backgroundOpacity;
|
||||||
|
overlay.style.zIndex = '-1';
|
||||||
|
overlay.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Adjust background rotation based on orientation
|
||||||
|
if (this.config.orientation === 'vertical') {
|
||||||
|
overlay.style.transform = 'rotate(90deg) scale(2)';
|
||||||
|
overlay.style.transformOrigin = 'center center';
|
||||||
|
} else if (this.config.orientation === 'upsidedown') {
|
||||||
|
overlay.style.transform = 'rotate(180deg) scale(1.5)';
|
||||||
|
overlay.style.transformOrigin = 'center center';
|
||||||
|
} else if (this.config.orientation === 'vertical-reverse') {
|
||||||
|
overlay.style.transform = 'rotate(270deg) scale(2)';
|
||||||
|
overlay.style.transformOrigin = 'center center';
|
||||||
|
} else {
|
||||||
|
overlay.style.transform = 'scale(1.2)';
|
||||||
|
overlay.style.transformOrigin = 'center center';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert as the first child of body
|
||||||
|
document.body.insertBefore(overlay, document.body.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply ticker speed setting
|
||||||
|
*/
|
||||||
|
applyTickerSpeed() {
|
||||||
|
if (window.tickerManager) {
|
||||||
|
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the sun times display in the config modal
|
||||||
|
*/
|
||||||
|
updateSunTimesDisplay() {
|
||||||
|
if (window.weatherManager) {
|
||||||
|
const sunriseElement = document.getElementById('sunrise-time');
|
||||||
|
const sunsetElement = document.getElementById('sunset-time');
|
||||||
|
|
||||||
|
if (sunriseElement && sunsetElement) {
|
||||||
|
const sunriseTime = window.weatherManager.getSunriseTime();
|
||||||
|
const sunsetTime = window.weatherManager.getSunsetTime();
|
||||||
|
|
||||||
|
sunriseElement.textContent = sunriseTime;
|
||||||
|
sunsetElement.textContent = sunsetTime;
|
||||||
|
|
||||||
|
const weatherSunTimesElement = document.querySelector('#custom-weather .sun-times');
|
||||||
|
if (weatherSunTimesElement) {
|
||||||
|
weatherSunTimesElement.textContent = `Sunrise: ${sunriseTime} | Sunset: ${sunsetTime}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HTML for sites configuration
|
||||||
|
*/
|
||||||
|
generateSitesHTML() {
|
||||||
|
if (!this.config.sites || this.config.sites.length === 0) {
|
||||||
|
return '<div class="no-sites">No sites configured</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.config.sites.map((site, index) => `
|
||||||
|
<div class="site-item" data-index="${index}">
|
||||||
|
<div style="display: flex; align-items: center; margin-bottom: 5px;">
|
||||||
|
<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;">
|
||||||
|
<button class="remove-site-button" style="padding: 2px 5px;">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<span style="margin-right: 5px;">ID:</span>
|
||||||
|
<input type="text" class="site-id" value="${site.id}" placeholder="Site ID" style="width: 100px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HTML for RSS feeds configuration
|
||||||
|
*/
|
||||||
|
generateRssFeedsHTML() {
|
||||||
|
if (!this.config.rssFeeds || this.config.rssFeeds.length === 0) {
|
||||||
|
return '<div class="no-feeds">No RSS feeds configured</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.config.rssFeeds.map((feed, index) => `
|
||||||
|
<div class="feed-item" data-index="${index}">
|
||||||
|
<div style="display: flex; align-items: center; margin-bottom: 5px;">
|
||||||
|
<input type="checkbox" class="feed-enabled" ${feed.enabled ? 'checked' : ''}>
|
||||||
|
<input type="text" class="feed-name" value="${feed.name}" placeholder="Feed Name" style="flex: 1; margin: 0 5px;">
|
||||||
|
<button class="remove-feed-button" style="padding: 2px 5px;">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<span style="margin-right: 5px;">URL:</span>
|
||||||
|
<input type="text" class="feed-url" value="${feed.url}" placeholder="Feed URL" style="flex: 1;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sites configuration from form
|
||||||
|
*/
|
||||||
|
getSitesFromForm() {
|
||||||
|
const sitesContainer = document.getElementById('sites-container');
|
||||||
|
const siteItems = sitesContainer.querySelectorAll('.site-item');
|
||||||
|
|
||||||
|
return Array.from(siteItems).map(item => {
|
||||||
|
return {
|
||||||
|
id: item.querySelector('.site-id').value,
|
||||||
|
name: item.querySelector('.site-name').value,
|
||||||
|
enabled: item.querySelector('.site-enabled').checked
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get RSS feeds configuration from form
|
||||||
|
*/
|
||||||
|
getRssFeedsFromForm() {
|
||||||
|
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||||
|
const feedItems = feedsContainer.querySelectorAll('.feed-item');
|
||||||
|
|
||||||
|
return Array.from(feedItems).map(item => {
|
||||||
|
return {
|
||||||
|
name: item.querySelector('.feed-name').value,
|
||||||
|
url: item.querySelector('.feed-url').value,
|
||||||
|
enabled: item.querySelector('.feed-enabled').checked
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync configuration with server
|
||||||
|
*/
|
||||||
|
syncConfig() {
|
||||||
|
fetch('/api/config/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(this.config)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('Configuration synced with server:', data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error syncing configuration with server:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the configuration to localStorage
|
||||||
|
*/
|
||||||
|
saveConfig() {
|
||||||
|
if (this.options.saveToLocalStorage && window.localStorage) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('sl-departures-config', JSON.stringify(this.config));
|
||||||
|
console.log('Configuration saved to localStorage');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving configuration to localStorage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the configuration from localStorage
|
||||||
|
*/
|
||||||
|
loadConfig() {
|
||||||
|
if (this.options.saveToLocalStorage && window.localStorage) {
|
||||||
|
try {
|
||||||
|
const savedConfig = localStorage.getItem('sl-departures-config');
|
||||||
|
if (savedConfig) {
|
||||||
|
console.log('Configuration loaded from localStorage');
|
||||||
|
return JSON.parse(savedConfig);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading configuration from localStorage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup keyboard shortcuts for testing
|
||||||
|
*/
|
||||||
|
setupKeyboardShortcuts() {
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
// Only handle keyboard shortcuts if not in an input field
|
||||||
|
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + Shift + O: Toggle orientation
|
||||||
|
if (event.key === 'O' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
const orientations = ['normal', 'vertical', 'upsidedown', 'vertical-reverse', 'landscape'];
|
||||||
|
const currentIndex = orientations.indexOf(this.config.orientation);
|
||||||
|
const nextIndex = (currentIndex + 1) % orientations.length;
|
||||||
|
this.config.orientation = orientations[nextIndex];
|
||||||
|
this.applyConfig();
|
||||||
|
this.saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + Shift + D: Toggle dark mode
|
||||||
|
if (event.key === 'D' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
const modes = ['auto', 'on', 'off'];
|
||||||
|
const currentIndex = modes.indexOf(this.config.darkMode);
|
||||||
|
const nextIndex = (currentIndex + 1) % modes.length;
|
||||||
|
this.config.darkMode = modes[nextIndex];
|
||||||
|
this.applyConfig();
|
||||||
|
this.saveConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure content wrapper exists for rotated orientations
|
||||||
|
*/
|
||||||
|
ensureContentWrapper() {
|
||||||
|
if (!document.getElementById('content-wrapper')) {
|
||||||
|
console.log('Creating content wrapper');
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.id = 'content-wrapper';
|
||||||
|
|
||||||
|
// Move all body children to the wrapper except config elements
|
||||||
|
const configElements = ['config-button', 'config-modal', 'background-overlay'];
|
||||||
|
|
||||||
|
// Create an array of nodes to move (can't modify while iterating)
|
||||||
|
const nodesToMove = [];
|
||||||
|
for (let i = 0; i < document.body.children.length; i++) {
|
||||||
|
const child = document.body.children[i];
|
||||||
|
if (!configElements.includes(child.id) && child.id !== 'content-wrapper') {
|
||||||
|
nodesToMove.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the nodes to the wrapper
|
||||||
|
nodesToMove.forEach(node => {
|
||||||
|
wrapper.appendChild(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the wrapper back to the body
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the ConfigManager class for use in other modules
|
||||||
|
window.ConfigManager = ConfigManager;
|
||||||
52
create-deployment-package.sh
Normal file
52
create-deployment-package.sh
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to create a deployment package for Raspberry Pi
|
||||||
|
|
||||||
|
echo "Creating deployment package for SL Transport Departures Display..."
|
||||||
|
|
||||||
|
# Create a temporary directory
|
||||||
|
TEMP_DIR="deployment-package"
|
||||||
|
mkdir -p $TEMP_DIR
|
||||||
|
|
||||||
|
# Copy necessary files
|
||||||
|
echo "Copying files..."
|
||||||
|
cp index.html $TEMP_DIR/
|
||||||
|
cp server.js $TEMP_DIR/
|
||||||
|
cp clock.js $TEMP_DIR/
|
||||||
|
cp config.js $TEMP_DIR/
|
||||||
|
cp weather.js $TEMP_DIR/
|
||||||
|
cp ticker.js $TEMP_DIR/
|
||||||
|
cp package.json $TEMP_DIR/
|
||||||
|
cp README.md $TEMP_DIR/
|
||||||
|
cp documentation.md $TEMP_DIR/
|
||||||
|
cp raspberry-pi-setup.sh $TEMP_DIR/
|
||||||
|
cp .gitignore $TEMP_DIR/
|
||||||
|
|
||||||
|
# Copy any image files if they exist
|
||||||
|
if [ -d "images" ]; then
|
||||||
|
mkdir -p $TEMP_DIR/images
|
||||||
|
cp -r images/* $TEMP_DIR/images/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a version file with timestamp
|
||||||
|
echo "Creating version file..."
|
||||||
|
DATE=$(date +"%Y-%m-%d %H:%M:%S")
|
||||||
|
echo "SL Transport Departures Display" > $TEMP_DIR/version.txt
|
||||||
|
echo "Packaged on: $DATE" >> $TEMP_DIR/version.txt
|
||||||
|
echo "Version: 1.0.0" >> $TEMP_DIR/version.txt
|
||||||
|
|
||||||
|
# Create a ZIP archive
|
||||||
|
echo "Creating ZIP archive..."
|
||||||
|
ZIP_FILE="sl-departures-display-$(date +"%Y%m%d").zip"
|
||||||
|
zip -r $ZIP_FILE $TEMP_DIR
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
echo "Cleaning up..."
|
||||||
|
rm -rf $TEMP_DIR
|
||||||
|
|
||||||
|
echo "Deployment package created: $ZIP_FILE"
|
||||||
|
echo "To deploy to Raspberry Pi:"
|
||||||
|
echo "1. Transfer the ZIP file to your Raspberry Pi"
|
||||||
|
echo "2. Unzip the file: unzip $ZIP_FILE"
|
||||||
|
echo "3. Navigate to the directory: cd deployment-package"
|
||||||
|
echo "4. Make the setup script executable: chmod +x raspberry-pi-setup.sh"
|
||||||
|
echo "5. Run the setup script: sudo ./raspberry-pi-setup.sh"
|
||||||
24
create-gitea-repo.ps1
Normal file
24
create-gitea-repo.ps1
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
$headers = @{
|
||||||
|
'Authorization' = 'token 9ed750a7f1480481ff96f021c8bbf49836b902f8'
|
||||||
|
'Content-Type' = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = @{
|
||||||
|
name = 'SignageHTML'
|
||||||
|
description = 'Digital signage system for displaying transit departures, weather information, and news tickers'
|
||||||
|
private = $false
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Invoke-RestMethod -Uri 'http://192.168.68.53:3000/api/v1/user/repos' -Method Post -Headers $headers -Body $body
|
||||||
|
Write-Host "Repository created successfully!"
|
||||||
|
Write-Host "Repository URL: $($response.clone_url)"
|
||||||
|
Write-Host "SSH URL: $($response.ssh_url)"
|
||||||
|
$response | ConvertTo-Json -Depth 10
|
||||||
|
} catch {
|
||||||
|
Write-Host "Error: $($_.Exception.Message)"
|
||||||
|
if ($_.ErrorDetails.Message) {
|
||||||
|
Write-Host "Details: $($_.ErrorDetails.Message)"
|
||||||
|
}
|
||||||
|
Write-Host $_.Exception
|
||||||
|
}
|
||||||
815
departures.js
Normal file
815
departures.js
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
// Calculate minutes until arrival
|
||||||
|
function calculateMinutesUntilArrival(scheduledTime) {
|
||||||
|
const now = new Date();
|
||||||
|
const scheduled = new Date(scheduledTime);
|
||||||
|
return Math.round((scheduled - now) / (1000 * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get transport icon based on transport mode
|
||||||
|
function getTransportIcon(transportMode) {
|
||||||
|
// Default to bus if not specified
|
||||||
|
const mode = transportMode ? transportMode.toLowerCase() : 'bus';
|
||||||
|
|
||||||
|
// Special case for line 7 - it's a tram
|
||||||
|
if (arguments.length > 1 && arguments[1] && arguments[1].designation === '7') {
|
||||||
|
return '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 5l.75-1.5H17V2H7v1.5h4.75L11 5c-3.13.09-6 .73-6 3.5V17c0 1.5 1.11 2.73 2.55 2.95L6 21.5v.5h2l2-2h4l2 2h2v-.5l-1.55-1.55C17.89 19.73 19 18.5 19 17V8.5c0-2.77-2.87-3.41-6-3.5zm-1 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5-4.5H7V9h10v5z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG icons for different transport modes
|
||||||
|
const icons = {
|
||||||
|
bus: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z"/></svg>',
|
||||||
|
metro: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20v1h12v-1l-1.5-1c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-4-4-8-4zm0 2c3.51 0 4.96.48 5.57 1H6.43c.61-.52 2.06-1 5.57-1zM6 7h5v3H6V7zm12 0v3h-5V7h5zm-6 5v3H6v-3h6zm1 0h5v3h-5v-3z"/></svg>',
|
||||||
|
train: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20v1h12v-1l-1.5-1c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-4-4-8-4zm0 2c3.51 0 4.96.48 5.57 1H6.43c.61-.52 2.06-1 5.57-1zM6 7h5v3H6V7zm12 0v3h-5V7h5zm-6 5v3H6v-3h6zm1 0h5v3h-5v-3z"/></svg>',
|
||||||
|
tram: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 5l.75-1.5H17V2H7v1.5h4.75L11 5c-3.13.09-6 .73-6 3.5V17c0 1.5 1.11 2.73 2.55 2.95L6 21.5v.5h2l2-2h4l2 2h2v-.5l-1.55-1.55C17.89 19.73 19 18.5 19 17V8.5c0-2.77-2.87-3.41-6-3.5zm-1 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5-4.5H7V9h10v5z"/></svg>',
|
||||||
|
ship: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M20 21c-1.39 0-2.78-.47-4-1.32-2.44 1.71-5.56 1.71-8 0C6.78 20.53 5.39 21 4 21H2v2h2c1.38 0 2.74-.35 4-.99 2.52 1.29 5.48 1.29 8 0 1.26.65 2.62.99 4 .99h2v-2h-2zM3.95 19H4c1.6 0 3.02-.88 4-2 .98 1.12 2.4 2 4 2s3.02-.88 4-2c.98 1.12 2.4 2 4 2h.05l1.89-6.68c.08-.26.06-.54-.06-.78s-.34-.42-.6-.5L20 10.62V6c0-1.1-.9-2-2-2h-3V1H9v3H6c-1.1 0-2 .9-2 2v4.62l-1.29.42c-.26.08-.48.26-.6.5s-.15.52-.06.78L3.95 19zM6 6h12v3.97L12 8 6 9.97V6z"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
return icons[mode] || icons.bus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a departure card element
|
||||||
|
function createDepartureCard(departure) {
|
||||||
|
const departureCard = document.createElement('div');
|
||||||
|
departureCard.dataset.journeyId = departure.journey.id;
|
||||||
|
|
||||||
|
const displayTime = departure.display;
|
||||||
|
const scheduledTime = formatDateTime(departure.scheduled);
|
||||||
|
|
||||||
|
// Check if departure is within the next hour
|
||||||
|
const departureTime = new Date(departure.scheduled);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
|
||||||
|
const isWithinNextHour = diffMinutes <= 60;
|
||||||
|
|
||||||
|
// Add condensed class if within next hour
|
||||||
|
departureCard.className = isWithinNextHour ? 'departure-card condensed' : 'departure-card';
|
||||||
|
|
||||||
|
// Check if the display time is just a time (HH:MM) or a countdown
|
||||||
|
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||||
|
|
||||||
|
// If it's just a time, calculate minutes until arrival
|
||||||
|
let countdownText = displayTime;
|
||||||
|
if (isTimeOnly) {
|
||||||
|
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||||
|
if (minutesUntil <= 0) {
|
||||||
|
countdownText = 'Now';
|
||||||
|
} else if (minutesUntil === 1) {
|
||||||
|
countdownText = '1 min';
|
||||||
|
} else {
|
||||||
|
countdownText = `${minutesUntil} min`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get transport icon based on transport mode and line
|
||||||
|
const transportIcon = getTransportIcon(departure.line?.transportMode, departure.line);
|
||||||
|
|
||||||
|
// Create card based on time and display format
|
||||||
|
departureCard.innerHTML = `
|
||||||
|
<div class="departure-header">
|
||||||
|
<span class="line-number">
|
||||||
|
${transportIcon}
|
||||||
|
${departure.line.designation}
|
||||||
|
<span class="line-destination">${departure.destination}</span>
|
||||||
|
</span>
|
||||||
|
<span class="time">
|
||||||
|
<span class="arrival-time">${scheduledTime}</span>
|
||||||
|
<span class="countdown">(${countdownText})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return departureCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display departures grouped by line number
|
||||||
|
function displayGroupedDeparturesByLine(groups, container) {
|
||||||
|
groups.forEach(group => {
|
||||||
|
// Create a card for this line number
|
||||||
|
const groupCard = document.createElement('div');
|
||||||
|
groupCard.className = 'departure-card line-card';
|
||||||
|
|
||||||
|
// Create card header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'departure-header';
|
||||||
|
|
||||||
|
// Get transport icon based on transport mode and line
|
||||||
|
const transportIcon = getTransportIcon(group.line?.transportMode, group.line);
|
||||||
|
|
||||||
|
// Add line number with transport icon
|
||||||
|
const lineNumber = document.createElement('span');
|
||||||
|
lineNumber.className = 'line-number';
|
||||||
|
|
||||||
|
// Use the first destination as the main one for the header
|
||||||
|
const mainDestination = group.directions[0]?.destination || '';
|
||||||
|
|
||||||
|
lineNumber.innerHTML = `${transportIcon} ${group.lineNumber} <span class="line-destination">${mainDestination}</span>`;
|
||||||
|
header.appendChild(lineNumber);
|
||||||
|
groupCard.appendChild(header);
|
||||||
|
|
||||||
|
// Create the directions container
|
||||||
|
const directionsContainer = document.createElement('div');
|
||||||
|
directionsContainer.className = 'directions-container';
|
||||||
|
|
||||||
|
// Process each direction
|
||||||
|
group.directions.forEach(direction => {
|
||||||
|
// Sort departures by time
|
||||||
|
direction.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
|
||||||
|
|
||||||
|
// Create a row for this direction
|
||||||
|
const directionRow = document.createElement('div');
|
||||||
|
directionRow.className = 'direction-row';
|
||||||
|
|
||||||
|
// Add direction info
|
||||||
|
const directionInfo = document.createElement('div');
|
||||||
|
directionInfo.className = 'direction-info';
|
||||||
|
|
||||||
|
// Determine direction arrow
|
||||||
|
const directionArrow = direction.direction === 1 ? '→' : '←';
|
||||||
|
|
||||||
|
directionInfo.innerHTML = `<span class="direction-arrow">${directionArrow}</span> <span class="direction-destination">${direction.destination}</span>`;
|
||||||
|
directionRow.appendChild(directionInfo);
|
||||||
|
|
||||||
|
// Add times container
|
||||||
|
const timesContainer = document.createElement('div');
|
||||||
|
timesContainer.className = 'times-container';
|
||||||
|
|
||||||
|
// Add up to 2 departure times per direction
|
||||||
|
const maxTimes = 2;
|
||||||
|
direction.departures.slice(0, maxTimes).forEach(departure => {
|
||||||
|
const timeElement = document.createElement('span');
|
||||||
|
timeElement.className = 'time';
|
||||||
|
|
||||||
|
const displayTime = departure.display;
|
||||||
|
const scheduledTime = formatDateTime(departure.scheduled);
|
||||||
|
|
||||||
|
// Check if the display time is just a time (HH:MM) or a countdown
|
||||||
|
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||||
|
|
||||||
|
// If it's just a time, calculate minutes until arrival
|
||||||
|
let countdownText = displayTime;
|
||||||
|
if (isTimeOnly) {
|
||||||
|
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||||
|
if (minutesUntil <= 0) {
|
||||||
|
countdownText = 'Now';
|
||||||
|
} else if (minutesUntil === 1) {
|
||||||
|
countdownText = '1 min';
|
||||||
|
} else {
|
||||||
|
countdownText = `${minutesUntil} min`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${countdownText})</span>`;
|
||||||
|
timesContainer.appendChild(timeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
directionRow.appendChild(timesContainer);
|
||||||
|
directionsContainer.appendChild(directionRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
groupCard.appendChild(directionsContainer);
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
container.appendChild(groupCard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display grouped departures (legacy function)
|
||||||
|
function displayGroupedDepartures(groups, container) {
|
||||||
|
groups.forEach(group => {
|
||||||
|
// Sort departures by time
|
||||||
|
group.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
|
||||||
|
|
||||||
|
// Create a card for this group
|
||||||
|
const groupCard = document.createElement('div');
|
||||||
|
groupCard.className = 'departure-card';
|
||||||
|
|
||||||
|
// Create card header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'departure-header';
|
||||||
|
|
||||||
|
// Get transport icon based on transport mode and line
|
||||||
|
const transportIcon = getTransportIcon(group.line?.transportMode, group.line);
|
||||||
|
|
||||||
|
// Add line number with transport icon and destination
|
||||||
|
const lineNumber = document.createElement('span');
|
||||||
|
lineNumber.className = 'line-number';
|
||||||
|
lineNumber.innerHTML = `${transportIcon} ${group.line.designation} <span class="line-destination">${group.destination}</span>`;
|
||||||
|
header.appendChild(lineNumber);
|
||||||
|
|
||||||
|
// Add times container
|
||||||
|
const timesContainer = document.createElement('div');
|
||||||
|
timesContainer.className = 'times-container';
|
||||||
|
timesContainer.style.display = 'flex';
|
||||||
|
timesContainer.style.flexDirection = 'column';
|
||||||
|
timesContainer.style.alignItems = 'flex-end';
|
||||||
|
|
||||||
|
// Add up to 3 departure times
|
||||||
|
const maxTimes = 3;
|
||||||
|
group.departures.slice(0, maxTimes).forEach((departure, index) => {
|
||||||
|
const timeElement = document.createElement('span');
|
||||||
|
timeElement.className = 'time';
|
||||||
|
timeElement.style.fontSize = index === 0 ? '1.1em' : '0.9em';
|
||||||
|
timeElement.style.marginBottom = '2px';
|
||||||
|
|
||||||
|
const displayTime = departure.display;
|
||||||
|
const scheduledTime = formatDateTime(departure.scheduled);
|
||||||
|
|
||||||
|
// Check if the display time is just a time (HH:MM) or a countdown
|
||||||
|
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||||
|
|
||||||
|
// If it's just a time, calculate minutes until arrival
|
||||||
|
let countdownText = displayTime;
|
||||||
|
if (isTimeOnly) {
|
||||||
|
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||||
|
if (minutesUntil <= 0) {
|
||||||
|
countdownText = 'Now';
|
||||||
|
} else if (minutesUntil === 1) {
|
||||||
|
countdownText = '1 min';
|
||||||
|
} else {
|
||||||
|
countdownText = `${minutesUntil} min`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTimeOnly) {
|
||||||
|
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${countdownText})</span>`;
|
||||||
|
} else {
|
||||||
|
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${displayTime})</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
timesContainer.appendChild(timeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there are more departures, show a count
|
||||||
|
if (group.departures.length > maxTimes) {
|
||||||
|
const moreElement = document.createElement('span');
|
||||||
|
moreElement.style.fontSize = '0.8em';
|
||||||
|
moreElement.style.color = '#666';
|
||||||
|
moreElement.textContent = `+${group.departures.length - maxTimes} more`;
|
||||||
|
timesContainer.appendChild(moreElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
header.appendChild(timesContainer);
|
||||||
|
groupCard.appendChild(header);
|
||||||
|
|
||||||
|
// No need to add destination and direction separately as they're now in the header
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
container.appendChild(groupCard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date and time
|
||||||
|
function formatDateTime(dateTimeString) {
|
||||||
|
const date = new Date(dateTimeString);
|
||||||
|
return date.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format relative time (e.g., "in 5 minutes")
|
||||||
|
function formatRelativeTime(dateTimeString) {
|
||||||
|
const departureTime = new Date(dateTimeString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
|
||||||
|
|
||||||
|
if (diffMinutes <= 0) {
|
||||||
|
return 'Now';
|
||||||
|
} else if (diffMinutes === 1) {
|
||||||
|
return 'In 1 minute';
|
||||||
|
} else if (diffMinutes < 60) {
|
||||||
|
return `In ${diffMinutes} minutes`;
|
||||||
|
} else {
|
||||||
|
const hours = Math.floor(diffMinutes / 60);
|
||||||
|
const minutes = diffMinutes % 60;
|
||||||
|
if (minutes === 0) {
|
||||||
|
return `In ${hours} hour${hours > 1 ? 's' : ''}`;
|
||||||
|
} else {
|
||||||
|
return `In ${hours} hour${hours > 1 ? 's' : ''} and ${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group departures by line number
|
||||||
|
function groupDeparturesByLineNumber(departures) {
|
||||||
|
const groups = {};
|
||||||
|
|
||||||
|
departures.forEach(departure => {
|
||||||
|
const lineNumber = departure.line.designation;
|
||||||
|
|
||||||
|
if (!groups[lineNumber]) {
|
||||||
|
groups[lineNumber] = {
|
||||||
|
line: departure.line,
|
||||||
|
directions: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionKey = `${departure.direction}-${departure.destination}`;
|
||||||
|
|
||||||
|
if (!groups[lineNumber].directions[directionKey]) {
|
||||||
|
groups[lineNumber].directions[directionKey] = {
|
||||||
|
direction: departure.direction,
|
||||||
|
destination: departure.destination,
|
||||||
|
departures: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[lineNumber].directions[directionKey].departures.push(departure);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array format
|
||||||
|
return Object.entries(groups).map(([lineNumber, data]) => {
|
||||||
|
return {
|
||||||
|
lineNumber: lineNumber,
|
||||||
|
line: data.line,
|
||||||
|
directions: Object.values(data.directions)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group departures by direction (legacy function kept for compatibility)
|
||||||
|
function groupDeparturesByDirection(departures) {
|
||||||
|
const groups = {};
|
||||||
|
|
||||||
|
departures.forEach(departure => {
|
||||||
|
const key = `${departure.line.designation}-${departure.direction}-${departure.destination}`;
|
||||||
|
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = {
|
||||||
|
line: departure.line,
|
||||||
|
direction: departure.direction,
|
||||||
|
destination: departure.destination,
|
||||||
|
departures: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[key].departures.push(departure);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the current departures data for comparison
|
||||||
|
let currentDepartures = [];
|
||||||
|
|
||||||
|
// Display departures in the UI with smooth transitions
|
||||||
|
function displayDepartures(departures) {
|
||||||
|
if (!departures || departures.length === 0) {
|
||||||
|
departuresContainer.innerHTML = '<div class="error">No departures found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is the first load, just display everything
|
||||||
|
if (currentDepartures.length === 0) {
|
||||||
|
departuresContainer.innerHTML = '';
|
||||||
|
|
||||||
|
departures.forEach(departure => {
|
||||||
|
const departureCard = createDepartureCard(departure);
|
||||||
|
departuresContainer.appendChild(departureCard);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update only what has changed
|
||||||
|
updateExistingCards(departures);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the current departures for next comparison
|
||||||
|
currentDepartures = JSON.parse(JSON.stringify(departures));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing cards or add new ones
|
||||||
|
function updateExistingCards(newDepartures) {
|
||||||
|
// Get all current cards
|
||||||
|
const currentCards = departuresContainer.querySelectorAll('.departure-card');
|
||||||
|
const currentCardIds = Array.from(currentCards).map(card => card.dataset.journeyId);
|
||||||
|
|
||||||
|
// Process each new departure
|
||||||
|
newDepartures.forEach((departure, index) => {
|
||||||
|
const journeyId = departure.journey.id;
|
||||||
|
const existingCardIndex = currentCardIds.indexOf(journeyId.toString());
|
||||||
|
|
||||||
|
if (existingCardIndex !== -1) {
|
||||||
|
// Update existing card
|
||||||
|
const existingCard = currentCards[existingCardIndex];
|
||||||
|
updateCardContent(existingCard, departure);
|
||||||
|
} else {
|
||||||
|
// This is a new departure, add it
|
||||||
|
const newCard = createDepartureCard(departure);
|
||||||
|
|
||||||
|
// Add with fade-in effect
|
||||||
|
newCard.style.opacity = '0';
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
// Add to the beginning
|
||||||
|
departuresContainer.prepend(newCard);
|
||||||
|
} else if (index >= departuresContainer.children.length) {
|
||||||
|
// Add to the end
|
||||||
|
departuresContainer.appendChild(newCard);
|
||||||
|
} else {
|
||||||
|
// Insert at specific position
|
||||||
|
departuresContainer.insertBefore(newCard, departuresContainer.children[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger fade-in
|
||||||
|
setTimeout(() => {
|
||||||
|
newCard.style.transition = 'opacity 0.5s ease-in';
|
||||||
|
newCard.style.opacity = '1';
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove cards that are no longer in the new data
|
||||||
|
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
|
||||||
|
currentCards.forEach(card => {
|
||||||
|
if (!newDepartureIds.includes(card.dataset.journeyId)) {
|
||||||
|
// Fade out and remove
|
||||||
|
card.style.transition = 'opacity 0.5s ease-out';
|
||||||
|
card.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (card.parentNode) {
|
||||||
|
card.parentNode.removeChild(card);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update only the content that has changed in an existing card
|
||||||
|
function updateCardContent(card, departure) {
|
||||||
|
const displayTime = departure.display;
|
||||||
|
const scheduledTime = formatDateTime(departure.scheduled);
|
||||||
|
|
||||||
|
// Check if the display time is just a time (HH:MM) or a countdown
|
||||||
|
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||||
|
|
||||||
|
// If it's just a time, calculate minutes until arrival
|
||||||
|
let countdownText = displayTime;
|
||||||
|
if (isTimeOnly) {
|
||||||
|
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||||
|
if (minutesUntil <= 0) {
|
||||||
|
countdownText = 'Now';
|
||||||
|
} else if (minutesUntil === 1) {
|
||||||
|
countdownText = '1 min';
|
||||||
|
} else {
|
||||||
|
countdownText = `${minutesUntil} min`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update the countdown time which changes frequently
|
||||||
|
const countdownElement = card.querySelector('.countdown');
|
||||||
|
|
||||||
|
// Update with subtle highlight effect for changes
|
||||||
|
if (countdownElement && countdownElement.textContent !== `(${countdownText})`) {
|
||||||
|
countdownElement.textContent = `(${countdownText})`;
|
||||||
|
highlightElement(countdownElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a subtle highlight effect to show updated content
|
||||||
|
function highlightElement(element) {
|
||||||
|
element.style.transition = 'none';
|
||||||
|
element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
element.style.transition = 'background-color 1.5s ease-out';
|
||||||
|
element.style.backgroundColor = 'transparent';
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display multiple sites
|
||||||
|
function displayMultipleSites(sites) {
|
||||||
|
// Get configuration
|
||||||
|
const config = getConfig();
|
||||||
|
const enabledSites = config.sites.filter(site => site.enabled);
|
||||||
|
|
||||||
|
// Clear the container
|
||||||
|
departuresContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Process each site
|
||||||
|
sites.forEach(site => {
|
||||||
|
// Check if this site is enabled in the configuration
|
||||||
|
const siteConfig = enabledSites.find(s => s.id === site.siteId);
|
||||||
|
if (!siteConfig) return;
|
||||||
|
|
||||||
|
// Create a site container
|
||||||
|
const siteContainer = document.createElement('div');
|
||||||
|
siteContainer.className = 'site-container';
|
||||||
|
|
||||||
|
// Add site header with white tab
|
||||||
|
const siteHeader = document.createElement('div');
|
||||||
|
siteHeader.className = 'site-header';
|
||||||
|
siteHeader.innerHTML = `<span class="site-name">${site.siteName || siteConfig.name}</span>`;
|
||||||
|
siteContainer.appendChild(siteHeader);
|
||||||
|
|
||||||
|
// Process departures for this site
|
||||||
|
if (site.data && site.data.departures) {
|
||||||
|
// Group departures by line number
|
||||||
|
const lineGroups = {};
|
||||||
|
|
||||||
|
site.data.departures.forEach(departure => {
|
||||||
|
const lineNumber = departure.line.designation;
|
||||||
|
if (!lineGroups[lineNumber]) {
|
||||||
|
lineGroups[lineNumber] = [];
|
||||||
|
}
|
||||||
|
lineGroups[lineNumber].push(departure);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process each line group
|
||||||
|
Object.entries(lineGroups).forEach(([lineNumber, lineDepartures]) => {
|
||||||
|
// Create a line container for side-by-side display
|
||||||
|
const lineContainer = document.createElement('div');
|
||||||
|
lineContainer.className = 'line-container';
|
||||||
|
|
||||||
|
// Group by direction
|
||||||
|
const directionGroups = {};
|
||||||
|
|
||||||
|
lineDepartures.forEach(departure => {
|
||||||
|
const directionKey = `${departure.direction}-${departure.destination}`;
|
||||||
|
if (!directionGroups[directionKey]) {
|
||||||
|
directionGroups[directionKey] = {
|
||||||
|
direction: departure.direction,
|
||||||
|
destination: departure.destination,
|
||||||
|
departures: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
directionGroups[directionKey].departures.push(departure);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all direction groups
|
||||||
|
const directions = Object.values(directionGroups);
|
||||||
|
|
||||||
|
// Handle single direction case (like bus 4)
|
||||||
|
if (directions.length === 1) {
|
||||||
|
// Create a full-width card for this direction
|
||||||
|
const directionGroup = directions[0];
|
||||||
|
|
||||||
|
// Sort departures by time
|
||||||
|
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
|
||||||
|
|
||||||
|
// Create a card for this direction
|
||||||
|
const directionCard = document.createElement('div');
|
||||||
|
directionCard.className = 'departure-card';
|
||||||
|
// Don't set width to 100% as it causes the card to stick out
|
||||||
|
|
||||||
|
// Create a simplified layout with line number and times on the same row
|
||||||
|
const cardContent = document.createElement('div');
|
||||||
|
cardContent.className = 'departure-header';
|
||||||
|
cardContent.style.display = 'flex';
|
||||||
|
cardContent.style.justifyContent = 'space-between';
|
||||||
|
cardContent.style.alignItems = 'center';
|
||||||
|
|
||||||
|
// Get transport icon based on transport mode and line
|
||||||
|
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
|
||||||
|
|
||||||
|
// Add line number with transport icon and destination
|
||||||
|
const lineNumberElement = document.createElement('span');
|
||||||
|
lineNumberElement.className = 'line-number';
|
||||||
|
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} <span class="line-destination">${directionGroup.destination}</span>`;
|
||||||
|
|
||||||
|
// Add times container
|
||||||
|
const timesContainer = document.createElement('div');
|
||||||
|
timesContainer.className = 'times-container';
|
||||||
|
timesContainer.style.display = 'flex';
|
||||||
|
timesContainer.style.flexDirection = 'column';
|
||||||
|
timesContainer.style.alignItems = 'flex-end';
|
||||||
|
|
||||||
|
// Add up to 2 departure times
|
||||||
|
const maxTimes = 2;
|
||||||
|
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
|
||||||
|
const timeElement = document.createElement('div');
|
||||||
|
timeElement.className = 'time';
|
||||||
|
timeElement.style.fontSize = '1.1em';
|
||||||
|
timeElement.style.marginBottom = '2px';
|
||||||
|
timeElement.style.whiteSpace = 'nowrap';
|
||||||
|
timeElement.style.textAlign = 'right';
|
||||||
|
|
||||||
|
const displayTime = departure.display;
|
||||||
|
const scheduledTime = formatDateTime(departure.scheduled);
|
||||||
|
|
||||||
|
// Check if the display time is just a time (HH:MM) or a countdown
|
||||||
|
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||||
|
|
||||||
|
// If it's just a time, calculate minutes until arrival
|
||||||
|
let countdownText = displayTime;
|
||||||
|
if (isTimeOnly) {
|
||||||
|
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||||
|
if (minutesUntil <= 0) {
|
||||||
|
countdownText = 'Now';
|
||||||
|
} else if (minutesUntil === 1) {
|
||||||
|
countdownText = '1 min';
|
||||||
|
} else {
|
||||||
|
countdownText = `${minutesUntil} min`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeElement.textContent = `${scheduledTime} (${countdownText})`;
|
||||||
|
timeElement.style.width = '140px'; // Fixed width to prevent overflow
|
||||||
|
timeElement.style.width = '140px'; // Fixed width to prevent overflow
|
||||||
|
|
||||||
|
timesContainer.appendChild(timeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
cardContent.appendChild(lineNumberElement);
|
||||||
|
cardContent.appendChild(timesContainer);
|
||||||
|
directionCard.appendChild(cardContent);
|
||||||
|
siteContainer.appendChild(directionCard);
|
||||||
|
} else {
|
||||||
|
// Create cards for each direction, with max 2 per row
|
||||||
|
// Create a new line container for every 2 directions
|
||||||
|
for (let i = 0; i < directions.length; i += 2) {
|
||||||
|
// Create a new line container for this pair of directions
|
||||||
|
const rowContainer = document.createElement('div');
|
||||||
|
rowContainer.className = 'line-container';
|
||||||
|
|
||||||
|
// Process up to 2 directions for this row
|
||||||
|
for (let j = i; j < i + 2 && j < directions.length; j++) {
|
||||||
|
const directionGroup = directions[j];
|
||||||
|
|
||||||
|
// Sort departures by time
|
||||||
|
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
|
||||||
|
|
||||||
|
// Create a card for this direction
|
||||||
|
const directionCard = document.createElement('div');
|
||||||
|
directionCard.className = 'departure-card direction-card';
|
||||||
|
|
||||||
|
// Create a simplified layout with line number and times on the same row
|
||||||
|
const cardContent = document.createElement('div');
|
||||||
|
cardContent.className = 'departure-header';
|
||||||
|
cardContent.style.display = 'flex';
|
||||||
|
cardContent.style.justifyContent = 'space-between';
|
||||||
|
cardContent.style.alignItems = 'center';
|
||||||
|
|
||||||
|
// Get transport icon based on transport mode and line
|
||||||
|
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
|
||||||
|
|
||||||
|
// Add line number with transport icon and destination
|
||||||
|
const lineNumberElement = document.createElement('span');
|
||||||
|
lineNumberElement.className = 'line-number';
|
||||||
|
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} <span class="line-destination">${directionGroup.destination}</span>`;
|
||||||
|
|
||||||
|
// Add times container
|
||||||
|
const timesContainer = document.createElement('div');
|
||||||
|
timesContainer.className = 'times-container';
|
||||||
|
timesContainer.style.display = 'flex';
|
||||||
|
timesContainer.style.flexDirection = 'column';
|
||||||
|
timesContainer.style.alignItems = 'flex-end';
|
||||||
|
|
||||||
|
// Add up to 2 departure times
|
||||||
|
const maxTimes = 2;
|
||||||
|
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
|
||||||
|
const timeElement = document.createElement('div');
|
||||||
|
timeElement.className = 'time';
|
||||||
|
timeElement.style.fontSize = '1.1em';
|
||||||
|
timeElement.style.marginBottom = '2px';
|
||||||
|
timeElement.style.whiteSpace = 'nowrap';
|
||||||
|
timeElement.style.textAlign = 'right';
|
||||||
|
|
||||||
|
const displayTime = departure.display;
|
||||||
|
const scheduledTime = formatDateTime(departure.scheduled);
|
||||||
|
|
||||||
|
// Check if the display time is just a time (HH:MM) or a countdown
|
||||||
|
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||||
|
|
||||||
|
// If it's just a time, calculate minutes until arrival
|
||||||
|
let countdownText = displayTime;
|
||||||
|
if (isTimeOnly) {
|
||||||
|
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||||
|
if (minutesUntil <= 0) {
|
||||||
|
countdownText = 'Now';
|
||||||
|
} else if (minutesUntil === 1) {
|
||||||
|
countdownText = '1 min';
|
||||||
|
} else {
|
||||||
|
countdownText = `${minutesUntil} min`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeElement.textContent = `${scheduledTime} (${countdownText})`;
|
||||||
|
|
||||||
|
timesContainer.appendChild(timeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
cardContent.appendChild(lineNumberElement);
|
||||||
|
cardContent.appendChild(timesContainer);
|
||||||
|
directionCard.appendChild(cardContent);
|
||||||
|
rowContainer.appendChild(directionCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this row to the site container
|
||||||
|
siteContainer.appendChild(rowContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (site.error) {
|
||||||
|
// Display error for this site
|
||||||
|
const errorElement = document.createElement('div');
|
||||||
|
errorElement.className = 'error';
|
||||||
|
errorElement.textContent = `Error loading departures for ${site.siteName}: ${site.error}`;
|
||||||
|
siteContainer.appendChild(errorElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the site container to the main container
|
||||||
|
departuresContainer.appendChild(siteContainer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get configuration
|
||||||
|
function getConfig() {
|
||||||
|
// Default configuration
|
||||||
|
const defaultConfig = {
|
||||||
|
combineSameDirection: true,
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
id: '1411',
|
||||||
|
name: 'Ambassaderna',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we have a ConfigManager instance, use its config
|
||||||
|
if (window.configManager && window.configManager.config) {
|
||||||
|
return {
|
||||||
|
combineSameDirection: window.configManager.config.combineSameDirection !== undefined ?
|
||||||
|
window.configManager.config.combineSameDirection : defaultConfig.combineSameDirection,
|
||||||
|
sites: window.configManager.config.sites || defaultConfig.sites
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch departures from our proxy server
|
||||||
|
async function fetchDepartures() {
|
||||||
|
try {
|
||||||
|
// Don't show loading status to avoid layout disruptions
|
||||||
|
// statusElement.textContent = 'Loading departures...';
|
||||||
|
|
||||||
|
const response = await fetch(API_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.sites && Array.isArray(data.sites)) {
|
||||||
|
// Process multiple sites
|
||||||
|
displayMultipleSites(data.sites);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString('sv-SE')}`;
|
||||||
|
} else if (data.departures) {
|
||||||
|
// Legacy format - single site
|
||||||
|
displayDepartures(data.departures);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString('sv-SE')}`;
|
||||||
|
} else if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching departures:', error);
|
||||||
|
// Don't update status element to avoid layout disruptions
|
||||||
|
// statusElement.textContent = '';
|
||||||
|
departuresContainer.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<p>Failed to load departures. Please try again later.</p>
|
||||||
|
<p>Error: ${error.message}</p>
|
||||||
|
<p>Make sure the Node.js server is running: <code>node server.js</code></p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up auto-refresh
|
||||||
|
function setupAutoRefresh() {
|
||||||
|
// Clear any existing timer
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up new timer
|
||||||
|
refreshTimer = setInterval(fetchDepartures, REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize departures functionality
|
||||||
|
function initDepartures() {
|
||||||
|
// API endpoint (using our local proxy server)
|
||||||
|
window.API_URL = 'http://localhost:3002/api/departures';
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
window.departuresContainer = document.getElementById('departures');
|
||||||
|
window.statusElement = document.getElementById('status');
|
||||||
|
window.lastUpdatedElement = document.getElementById('last-updated');
|
||||||
|
|
||||||
|
// Auto-refresh interval (in milliseconds) - 5 seconds
|
||||||
|
window.REFRESH_INTERVAL = 5000;
|
||||||
|
window.refreshTimer = null;
|
||||||
|
|
||||||
|
// Initial fetch and setup
|
||||||
|
fetchDepartures();
|
||||||
|
setupAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when the DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initDepartures();
|
||||||
|
});
|
||||||
295
documentation.md
Normal file
295
documentation.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# SL Transport Departures Display System Documentation
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
This is a comprehensive digital signage system designed to display transit departures, weather information, and news tickers. The system is built with a modular architecture, making it easy to maintain and extend. It's specifically designed to work well on a Raspberry Pi for dedicated display purposes.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system consists of the following components:
|
||||||
|
|
||||||
|
1. **Node.js Server** - Handles API proxying and serves static files
|
||||||
|
2. **Configuration Manager** - Manages system settings and UI customization
|
||||||
|
3. **Weather Component** - Displays weather data and manages dark mode
|
||||||
|
4. **Clock Component** - Shows current time and date
|
||||||
|
5. **Ticker Component** - Displays scrolling news from RSS feeds
|
||||||
|
6. **Main UI** - Responsive layout with multiple orientation support
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `index.html` - Main HTML file containing the UI structure and inline JavaScript
|
||||||
|
- `server.js` - Node.js server for API proxying and static file serving
|
||||||
|
- `config.js` - Configuration management module
|
||||||
|
- `weather.js` - Weather display and dark mode management
|
||||||
|
- `clock.js` - Time and date display
|
||||||
|
- `ticker.js` - News ticker component
|
||||||
|
|
||||||
|
## Detailed Component Documentation
|
||||||
|
|
||||||
|
### 1. Node.js Server (server.js)
|
||||||
|
|
||||||
|
The server component acts as a proxy for external APIs and serves the static files for the application.
|
||||||
|
|
||||||
|
#### Key Features:
|
||||||
|
|
||||||
|
- **Port**: Runs on port 3002
|
||||||
|
- **API Proxying**:
|
||||||
|
- `/api/departures` - Proxies requests to SL Transport API
|
||||||
|
- `/api/rss` - Proxies and parses RSS feeds
|
||||||
|
- **Error Handling**: Provides structured error responses
|
||||||
|
- **Static File Serving**: Serves HTML, CSS, JavaScript, and image files
|
||||||
|
- **CORS Support**: Allows cross-origin requests
|
||||||
|
|
||||||
|
#### API Endpoints:
|
||||||
|
|
||||||
|
- `GET /api/departures` - Returns transit departure information
|
||||||
|
- `GET /api/rss` - Returns parsed RSS feed items
|
||||||
|
- `GET /` or `/index.html` - Serves the main application
|
||||||
|
- `GET /*.js|css|png|jpg|jpeg|gif|ico` - Serves static assets
|
||||||
|
|
||||||
|
#### Implementation Details:
|
||||||
|
|
||||||
|
The server handles malformed JSON responses from the SL Transport API by implementing custom JSON parsing and fixing. It also provides fallback data when API requests fail.
|
||||||
|
|
||||||
|
### 2. Configuration Manager (config.js)
|
||||||
|
|
||||||
|
The Configuration Manager handles all system settings and provides a UI for changing them.
|
||||||
|
|
||||||
|
#### Key Features:
|
||||||
|
|
||||||
|
- **Screen Orientation**: Controls display rotation (0°, 90°, 180°, 270°, landscape)
|
||||||
|
- **Dark Mode**: Automatic (based on sunrise/sunset), always on, or always off
|
||||||
|
- **Background Image**: Custom background with opacity control
|
||||||
|
- **Ticker Speed**: Controls the scrolling speed of the news ticker
|
||||||
|
- **Settings Persistence**: Saves settings to localStorage
|
||||||
|
- **Configuration UI**: Modal-based interface with live previews
|
||||||
|
|
||||||
|
#### Configuration Options:
|
||||||
|
|
||||||
|
- `orientation`: Screen orientation (`normal`, `vertical`, `upsidedown`, `vertical-reverse`, `landscape`)
|
||||||
|
- `darkMode`: Dark mode setting (`auto`, `on`, `off`)
|
||||||
|
- `backgroundImage`: URL or data URL of background image
|
||||||
|
- `backgroundOpacity`: Opacity value between 0 and 1
|
||||||
|
- `tickerSpeed`: Scroll speed in seconds for one complete cycle
|
||||||
|
|
||||||
|
#### Implementation Details:
|
||||||
|
|
||||||
|
The ConfigManager class creates a gear icon button that opens a modal dialog for changing settings. It applies settings immediately and dispatches events to notify other components of changes.
|
||||||
|
|
||||||
|
### 3. Weather Component (weather.js)
|
||||||
|
|
||||||
|
The Weather component displays current weather conditions, forecasts, and manages dark mode based on sunrise/sunset times.
|
||||||
|
|
||||||
|
#### Key Features:
|
||||||
|
|
||||||
|
- **Current Weather**: Temperature, condition, and icon
|
||||||
|
- **Hourly Forecast**: Weather predictions for upcoming hours
|
||||||
|
- **Sunrise/Sunset**: Calculates and displays sun times
|
||||||
|
- **Dark Mode Control**: Automatically switches between light/dark based on sun position
|
||||||
|
- **API Integration**: Uses OpenWeatherMap API
|
||||||
|
- **Fallback Data**: Provides default weather data when API is unavailable
|
||||||
|
|
||||||
|
#### Implementation Details:
|
||||||
|
|
||||||
|
The WeatherManager class fetches data from OpenWeatherMap API and updates the UI. It calculates sunrise/sunset times and uses them to determine if dark mode should be enabled. It dispatches events when dark mode changes.
|
||||||
|
|
||||||
|
### 4. Clock Component (clock.js)
|
||||||
|
|
||||||
|
The Clock component displays the current time and date.
|
||||||
|
|
||||||
|
#### Key Features:
|
||||||
|
|
||||||
|
- **Time Display**: Shows current time in HH:MM:SS format
|
||||||
|
- **Date Display**: Shows current date with weekday, month, day, and year
|
||||||
|
- **Timezone Support**: Configured for Stockholm timezone
|
||||||
|
- **Auto-Update**: Updates every second
|
||||||
|
- **Optional Time Sync**: Can synchronize with WorldTimeAPI
|
||||||
|
|
||||||
|
#### Implementation Details:
|
||||||
|
|
||||||
|
The Clock class creates and updates time and date elements. It uses the browser's Date object with the specified timezone and formats the output using toLocaleTimeString and toLocaleDateString.
|
||||||
|
|
||||||
|
### 5. Ticker Component (ticker.js)
|
||||||
|
|
||||||
|
The Ticker component displays scrolling news or announcements at the bottom of the screen.
|
||||||
|
|
||||||
|
#### Key Features:
|
||||||
|
|
||||||
|
- **RSS Integration**: Fetches and displays items from RSS feeds
|
||||||
|
- **Smooth Animation**: CSS-based scrolling with configurable speed
|
||||||
|
- **Fallback Content**: Provides default items when RSS feed is unavailable
|
||||||
|
- **Themed Display**: Red, white, and blue color scheme
|
||||||
|
- **Orientation Support**: Properly rotates with screen orientation changes
|
||||||
|
|
||||||
|
#### Implementation Details:
|
||||||
|
|
||||||
|
The TickerManager class creates a fixed container at the bottom of the screen and populates it with items from an RSS feed. It uses CSS animations for scrolling and adjusts the animation direction based on screen orientation.
|
||||||
|
|
||||||
|
### 6. Main UI (index.html)
|
||||||
|
|
||||||
|
The main UI integrates all components and provides responsive layouts for different screen orientations.
|
||||||
|
|
||||||
|
#### Key Features:
|
||||||
|
|
||||||
|
- **Responsive Design**: Adapts to different screen sizes and orientations
|
||||||
|
- **Dark Mode Support**: Changes colors based on dark mode setting
|
||||||
|
- **Departure Cards**: Displays transit departures with line numbers, destinations, and times
|
||||||
|
- **Weather Widget**: Shows current conditions and forecast
|
||||||
|
- **Auto-Refresh**: Periodically updates departure information
|
||||||
|
|
||||||
|
#### Implementation Details:
|
||||||
|
|
||||||
|
The HTML file contains the structure and styling for the application. It initializes all components and sets up event listeners for updates. The JavaScript in the file handles fetching and displaying departure information.
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### Prerequisites:
|
||||||
|
|
||||||
|
- Node.js installed
|
||||||
|
- Internet connection for API access
|
||||||
|
- Browser with CSS3 support
|
||||||
|
|
||||||
|
### Installation Steps:
|
||||||
|
|
||||||
|
1. Clone or download all files to a directory
|
||||||
|
2. Run `node server.js` to start the server
|
||||||
|
3. Access the application at `http://localhost:3002`
|
||||||
|
|
||||||
|
### Raspberry Pi Setup:
|
||||||
|
|
||||||
|
1. Install Node.js on Raspberry Pi
|
||||||
|
2. Copy all files to a directory on the Pi
|
||||||
|
3. Set up auto-start for the server:
|
||||||
|
```
|
||||||
|
# Create a systemd service file
|
||||||
|
sudo nano /etc/systemd/system/sl-departures.service
|
||||||
|
|
||||||
|
# Add the following content
|
||||||
|
[Unit]
|
||||||
|
Description=SL Departures Display
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/node /path/to/server.js
|
||||||
|
WorkingDirectory=/path/to/directory
|
||||||
|
Restart=always
|
||||||
|
User=pi
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
4. Enable and start the service:
|
||||||
|
```
|
||||||
|
sudo systemctl enable sl-departures
|
||||||
|
sudo systemctl start sl-departures
|
||||||
|
```
|
||||||
|
5. Configure Raspberry Pi to auto-start Chromium in kiosk mode:
|
||||||
|
```
|
||||||
|
# Edit autostart file
|
||||||
|
mkdir -p ~/.config/autostart
|
||||||
|
nano ~/.config/autostart/kiosk.desktop
|
||||||
|
|
||||||
|
# Add the following content
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Kiosk
|
||||||
|
Exec=chromium-browser --kiosk --disable-restore-session-state http://localhost:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues:
|
||||||
|
|
||||||
|
1. **Server won't start**
|
||||||
|
- Check if port 3002 is already in use
|
||||||
|
- Ensure Node.js is installed correctly
|
||||||
|
|
||||||
|
2. **No departures displayed**
|
||||||
|
- Verify internet connection
|
||||||
|
- Check server console for API errors
|
||||||
|
- Ensure the site ID is correct (currently set to 9636)
|
||||||
|
|
||||||
|
3. **Weather data not loading**
|
||||||
|
- Check OpenWeatherMap API key
|
||||||
|
- Verify internet connection
|
||||||
|
- Look for errors in browser console
|
||||||
|
|
||||||
|
4. **Ticker not scrolling**
|
||||||
|
- Check if RSS feed is accessible
|
||||||
|
- Verify ticker speed setting is not set to 0
|
||||||
|
- Look for JavaScript errors in console
|
||||||
|
|
||||||
|
5. **Screen orientation issues**
|
||||||
|
- Ensure the content wrapper is properly created
|
||||||
|
- Check for CSS conflicts
|
||||||
|
- Verify browser supports CSS transforms
|
||||||
|
|
||||||
|
## Customization Guide
|
||||||
|
|
||||||
|
### Changing Transit Stop:
|
||||||
|
|
||||||
|
To display departures for a different transit stop, modify the API_URL in server.js:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_URL = 'https://transport.integration.sl.se/v1/sites/YOUR_SITE_ID/departures';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changing Weather Location:
|
||||||
|
|
||||||
|
To display weather for a different location, modify the latitude and longitude in index.html:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.weatherManager = new WeatherManager({
|
||||||
|
latitude: YOUR_LATITUDE,
|
||||||
|
longitude: YOUR_LONGITUDE
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changing RSS Feed:
|
||||||
|
|
||||||
|
To display a different news source, modify the RSS_URL in server.js:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const RSS_URL = 'https://your-rss-feed-url.xml';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Custom Styles:
|
||||||
|
|
||||||
|
Add custom CSS to the style section in index.html to modify the appearance.
|
||||||
|
|
||||||
|
## System Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
+---------------------+ +----------------------+
|
||||||
|
| | | |
|
||||||
|
| Browser Interface |<----->| Node.js Server |
|
||||||
|
| (index.html) | | (server.js) |
|
||||||
|
| | | |
|
||||||
|
+---------------------+ +----------------------+
|
||||||
|
^ ^
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+---------------------+ +----------------------+
|
||||||
|
| | | |
|
||||||
|
| UI Components | | External APIs |
|
||||||
|
| - Clock | | - SL Transport API |
|
||||||
|
| - Weather | | - OpenWeatherMap |
|
||||||
|
| - Ticker | | - RSS Feeds |
|
||||||
|
| - Config Manager | | |
|
||||||
|
+---------------------+ +----------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Interaction Flow
|
||||||
|
|
||||||
|
1. User loads the application in a browser
|
||||||
|
2. Node.js server serves the HTML, CSS, and JavaScript files
|
||||||
|
3. Browser initializes all components (Clock, Weather, Config, Ticker)
|
||||||
|
4. Components make API requests through the Node.js server
|
||||||
|
5. Server proxies requests to external APIs and returns responses
|
||||||
|
6. Components update their UI based on the data
|
||||||
|
7. User can change settings through the Config Manager
|
||||||
|
8. Settings are applied immediately and saved to localStorage
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This documentation provides a comprehensive overview of the SL Transport Departures Display System. With this information, you should be able to understand, maintain, and recreate the system if needed.
|
||||||
1017
index.html
Normal file
1017
index.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
istockphoto-522585615-612x612.jpg
Normal file
BIN
istockphoto-522585615-612x612.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "sl-transport-departures-display",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A digital signage system for displaying transit departures, weather information, and news tickers",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"digital-signage",
|
||||||
|
"transit",
|
||||||
|
"raspberry-pi",
|
||||||
|
"weather",
|
||||||
|
"news-ticker"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^2.0.22"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/yourusername/sl-departures-display.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/yourusername/sl-departures-display/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/yourusername/sl-departures-display#readme"
|
||||||
|
}
|
||||||
144
raspberry-pi-setup.sh
Normal file
144
raspberry-pi-setup.sh
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Raspberry Pi Setup Script for SL Transport Departures Display
|
||||||
|
# This script sets up the necessary services to run the display system on boot
|
||||||
|
|
||||||
|
echo "Setting up SL Transport Departures Display on Raspberry Pi..."
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Please run as root (use sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the current directory
|
||||||
|
INSTALL_DIR=$(pwd)
|
||||||
|
echo "Installing from: $INSTALL_DIR"
|
||||||
|
|
||||||
|
# Check if Node.js is installed
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "Node.js not found. Installing Node.js..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
echo "Node.js installed successfully."
|
||||||
|
else
|
||||||
|
echo "Node.js is already installed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create systemd service for the server
|
||||||
|
echo "Creating systemd service for the server..."
|
||||||
|
cat > /etc/systemd/system/sl-departures.service << EOL
|
||||||
|
[Unit]
|
||||||
|
Description=SL Departures Display Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/node ${INSTALL_DIR}/server.js
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
Restart=always
|
||||||
|
# Run as the pi user - change if needed
|
||||||
|
User=pi
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOL
|
||||||
|
|
||||||
|
# Enable and start the service
|
||||||
|
echo "Enabling and starting the server service..."
|
||||||
|
systemctl enable sl-departures.service
|
||||||
|
systemctl start sl-departures.service
|
||||||
|
|
||||||
|
# Create autostart entry for Chromium in kiosk mode
|
||||||
|
echo "Setting up autostart for Chromium in kiosk mode..."
|
||||||
|
|
||||||
|
# Determine the user to set up autostart for
|
||||||
|
if [ -d "/home/pi" ]; then
|
||||||
|
USER_HOME="/home/pi"
|
||||||
|
USERNAME="pi"
|
||||||
|
else
|
||||||
|
# Try to find a non-root user
|
||||||
|
USERNAME=$(getent passwd 1000 | cut -d: -f1)
|
||||||
|
if [ -z "$USERNAME" ]; then
|
||||||
|
echo "Could not determine user for autostart. Please set up autostart manually."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
USER_HOME="/home/$USERNAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Setting up autostart for user: $USERNAME"
|
||||||
|
|
||||||
|
# Create autostart directory if it doesn't exist
|
||||||
|
mkdir -p $USER_HOME/.config/autostart
|
||||||
|
chown $USERNAME:$USERNAME $USER_HOME/.config/autostart
|
||||||
|
|
||||||
|
# Create autostart entry
|
||||||
|
cat > $USER_HOME/.config/autostart/sl-departures-kiosk.desktop << EOL
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SL Departures Kiosk
|
||||||
|
Exec=chromium-browser --kiosk --disable-restore-session-state --noerrdialogs --disable-infobars --no-first-run http://localhost:3002
|
||||||
|
Hidden=false
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
EOL
|
||||||
|
|
||||||
|
# Set correct ownership
|
||||||
|
chown $USERNAME:$USERNAME $USER_HOME/.config/autostart/sl-departures-kiosk.desktop
|
||||||
|
|
||||||
|
# Disable screen blanking and screensaver
|
||||||
|
echo "Disabling screen blanking and screensaver..."
|
||||||
|
|
||||||
|
# For X11
|
||||||
|
if [ -d "/etc/X11/xorg.conf.d" ]; then
|
||||||
|
cat > /etc/X11/xorg.conf.d/10-blanking.conf << EOL
|
||||||
|
Section "ServerFlags"
|
||||||
|
Option "BlankTime" "0"
|
||||||
|
Option "StandbyTime" "0"
|
||||||
|
Option "SuspendTime" "0"
|
||||||
|
Option "OffTime" "0"
|
||||||
|
EndSection
|
||||||
|
EOL
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For console
|
||||||
|
if [ -f "/etc/kbd/config" ]; then
|
||||||
|
sed -i 's/^BLANK_TIME=.*/BLANK_TIME=0/' /etc/kbd/config
|
||||||
|
sed -i 's/^POWERDOWN_TIME=.*/POWERDOWN_TIME=0/' /etc/kbd/config
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add to rc.local for good measure
|
||||||
|
if [ -f "/etc/rc.local" ]; then
|
||||||
|
# Check if the commands are already in rc.local
|
||||||
|
if ! grep -q "xset s off" /etc/rc.local; then
|
||||||
|
# Insert before the exit 0 line
|
||||||
|
sed -i '/exit 0/i \
|
||||||
|
# Disable screen blanking\
|
||||||
|
xset s off\
|
||||||
|
xset -dpms\
|
||||||
|
xset s noblank\
|
||||||
|
' /etc/rc.local
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating a desktop shortcut for manual launch..."
|
||||||
|
cat > $USER_HOME/Desktop/SL-Departures.desktop << EOL
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SL Departures Display
|
||||||
|
Comment=Launch SL Departures Display in Chromium
|
||||||
|
Exec=chromium-browser --kiosk --disable-restore-session-state http://localhost:3002
|
||||||
|
Icon=web-browser
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;WebBrowser;
|
||||||
|
EOL
|
||||||
|
|
||||||
|
chown $USERNAME:$USERNAME $USER_HOME/Desktop/SL-Departures.desktop
|
||||||
|
chmod +x $USER_HOME/Desktop/SL-Departures.desktop
|
||||||
|
|
||||||
|
echo "Setup complete!"
|
||||||
|
echo "The system will start automatically on next boot."
|
||||||
|
echo "To start manually:"
|
||||||
|
echo "1. Start the server: sudo systemctl start sl-departures.service"
|
||||||
|
echo "2. Launch the browser: Use the desktop shortcut or run 'chromium-browser --kiosk http://localhost:3002'"
|
||||||
|
echo ""
|
||||||
|
echo "To check server status: sudo systemctl status sl-departures.service"
|
||||||
|
echo "To view server logs: sudo journalctl -u sl-departures.service"
|
||||||
251
rss.js
Normal file
251
rss.js
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* rss.js - A modular RSS feed manager component
|
||||||
|
* Manages multiple RSS feeds and provides aggregated content to the ticker
|
||||||
|
*/
|
||||||
|
|
||||||
|
class RssManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
// Default options
|
||||||
|
this.options = {
|
||||||
|
proxyUrl: '/api/rss',
|
||||||
|
updateInterval: 300000, // Update every 5 minutes
|
||||||
|
maxItemsPerFeed: 10,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
this.feeds = [];
|
||||||
|
this.items = [];
|
||||||
|
this.updateCallbacks = [];
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the RSS manager
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
console.log('Initializing RssManager...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load feeds from config
|
||||||
|
await this.loadFeeds();
|
||||||
|
|
||||||
|
// Start periodic updates
|
||||||
|
this.startUpdates();
|
||||||
|
|
||||||
|
// Listen for config changes
|
||||||
|
document.addEventListener('configChanged', (event) => {
|
||||||
|
if (event.detail.config.rssFeeds) {
|
||||||
|
this.onConfigChanged(event.detail.config.rssFeeds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('RssManager initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing RssManager:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load feeds from config
|
||||||
|
*/
|
||||||
|
async loadFeeds() {
|
||||||
|
console.log('Loading RSS feeds...');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config');
|
||||||
|
const config = await response.json();
|
||||||
|
|
||||||
|
if (config.rssFeeds) {
|
||||||
|
this.feeds = config.rssFeeds;
|
||||||
|
console.log('Loaded RSS feeds from config:', this.feeds);
|
||||||
|
await this.refreshFeeds();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading RSS feeds from config:', error);
|
||||||
|
this.feeds = [{
|
||||||
|
name: "Travel Alerts",
|
||||||
|
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
||||||
|
enabled: true
|
||||||
|
}, {
|
||||||
|
name: "HD News",
|
||||||
|
url: "https://www.hd.se/feeds/feed.xml",
|
||||||
|
enabled: true
|
||||||
|
}];
|
||||||
|
console.log('Using default RSS feeds:', this.feeds);
|
||||||
|
await this.refreshFeeds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic updates
|
||||||
|
*/
|
||||||
|
startUpdates() {
|
||||||
|
setInterval(() => this.refreshFeeds(), this.options.updateInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh all enabled feeds
|
||||||
|
*/
|
||||||
|
async refreshFeeds() {
|
||||||
|
console.log('Refreshing RSS feeds...');
|
||||||
|
try {
|
||||||
|
const enabledFeeds = this.feeds.filter(feed => feed.enabled);
|
||||||
|
console.log('Enabled feeds:', enabledFeeds);
|
||||||
|
|
||||||
|
const feedPromises = enabledFeeds.map(feed => this.fetchFeed(feed));
|
||||||
|
const results = await Promise.all(feedPromises);
|
||||||
|
|
||||||
|
// Combine and sort all items by date
|
||||||
|
this.items = results
|
||||||
|
.flat()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.pubDate || 0);
|
||||||
|
const dateB = new Date(b.pubDate || 0);
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Fetched RSS items:', this.items.length);
|
||||||
|
|
||||||
|
// Notify subscribers
|
||||||
|
this.notifyUpdate();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing feeds:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single feed
|
||||||
|
*/
|
||||||
|
async fetchFeed(feed) {
|
||||||
|
try {
|
||||||
|
console.log(`Fetching feed ${feed.name} through proxy...`);
|
||||||
|
const proxyUrl = `/api/rss?url=${encodeURIComponent(feed.url)}`;
|
||||||
|
console.log('Proxy URL:', proxyUrl);
|
||||||
|
|
||||||
|
const response = await fetch(proxyUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`Received data for feed ${feed.name}:`, data);
|
||||||
|
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
// Add feed name to each item
|
||||||
|
return data.items
|
||||||
|
.slice(0, this.options.maxItemsPerFeed)
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
feedName: feed.name,
|
||||||
|
feedUrl: feed.url
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching feed ${feed.name}:`, error);
|
||||||
|
return [{
|
||||||
|
title: `Error fetching ${feed.name} feed`,
|
||||||
|
link: feed.url,
|
||||||
|
feedName: feed.name,
|
||||||
|
feedUrl: feed.url,
|
||||||
|
error: error.message
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle config changes
|
||||||
|
*/
|
||||||
|
async onConfigChanged(newFeeds) {
|
||||||
|
console.log('RSS feeds configuration changed');
|
||||||
|
this.feeds = newFeeds;
|
||||||
|
await this.refreshFeeds();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to feed updates
|
||||||
|
*/
|
||||||
|
onUpdate(callback) {
|
||||||
|
this.updateCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify subscribers of updates
|
||||||
|
*/
|
||||||
|
notifyUpdate() {
|
||||||
|
this.updateCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(this.items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in update callback:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current feed items
|
||||||
|
*/
|
||||||
|
getItems() {
|
||||||
|
return this.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feed configuration
|
||||||
|
*/
|
||||||
|
getFeeds() {
|
||||||
|
return this.feeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new feed
|
||||||
|
*/
|
||||||
|
async addFeed(feed) {
|
||||||
|
this.feeds.push({
|
||||||
|
name: feed.name,
|
||||||
|
url: feed.url,
|
||||||
|
enabled: feed.enabled ?? true
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.refreshFeeds();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a feed
|
||||||
|
*/
|
||||||
|
async removeFeed(index) {
|
||||||
|
if (index >= 0 && index < this.feeds.length) {
|
||||||
|
this.feeds.splice(index, 1);
|
||||||
|
await this.refreshFeeds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a feed
|
||||||
|
*/
|
||||||
|
async updateFeed(index, feed) {
|
||||||
|
if (index >= 0 && index < this.feeds.length) {
|
||||||
|
this.feeds[index] = {
|
||||||
|
...this.feeds[index],
|
||||||
|
...feed
|
||||||
|
};
|
||||||
|
await this.refreshFeeds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable a feed
|
||||||
|
*/
|
||||||
|
async toggleFeed(index, enabled) {
|
||||||
|
if (index >= 0 && index < this.feeds.length) {
|
||||||
|
this.feeds[index].enabled = enabled;
|
||||||
|
await this.refreshFeeds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the RssManager class for use in other modules
|
||||||
|
window.RssManager = RssManager;
|
||||||
519
server.js
Normal file
519
server.js
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const url = require('url');
|
||||||
|
|
||||||
|
const PORT = 3002;
|
||||||
|
|
||||||
|
// Default configuration
|
||||||
|
let config = {
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
id: '1411',
|
||||||
|
name: 'Ambassaderna',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rssFeeds: [
|
||||||
|
{
|
||||||
|
name: "Travel Alerts",
|
||||||
|
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to load configuration from file
|
||||||
|
function loadSitesConfig() {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
if (fs.existsSync('sites-config.json')) {
|
||||||
|
const configData = fs.readFileSync('sites-config.json', 'utf8');
|
||||||
|
const loadedConfig = JSON.parse(configData);
|
||||||
|
|
||||||
|
// Handle old format (array of sites)
|
||||||
|
if (Array.isArray(loadedConfig)) {
|
||||||
|
config.sites = loadedConfig;
|
||||||
|
} else {
|
||||||
|
config = loadedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Loaded configuration:', config);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading configuration:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration on startup
|
||||||
|
loadSitesConfig();
|
||||||
|
|
||||||
|
// Feed templates for different formats
|
||||||
|
const feedTemplates = {
|
||||||
|
rss2: {
|
||||||
|
detect: (data) => data.includes('<rss version="2.0"'),
|
||||||
|
itemPath: 'item',
|
||||||
|
titlePath: 'title',
|
||||||
|
descPath: 'description',
|
||||||
|
datePath: 'pubDate'
|
||||||
|
},
|
||||||
|
atom: {
|
||||||
|
detect: (data) => data.includes('<feed xmlns="http://www.w3.org/2005/Atom"'),
|
||||||
|
itemPath: 'entry',
|
||||||
|
titlePath: 'title',
|
||||||
|
descPath: 'content',
|
||||||
|
datePath: 'updated'
|
||||||
|
},
|
||||||
|
rss1: {
|
||||||
|
detect: (data) => data.includes('<rdf:RDF'),
|
||||||
|
itemPath: 'item',
|
||||||
|
titlePath: 'title',
|
||||||
|
descPath: 'description',
|
||||||
|
datePath: 'dc:date'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to detect feed type
|
||||||
|
function detectFeedType(data) {
|
||||||
|
for (const [type, template] of Object.entries(feedTemplates)) {
|
||||||
|
if (template.detect(data)) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'rss2'; // Default to RSS 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to clean HTML content
|
||||||
|
function cleanHtmlContent(content) {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
// Remove CDATA if present
|
||||||
|
content = content.replace(/<!\[CDATA\[(.*?)\]\]>/gs, '$1');
|
||||||
|
|
||||||
|
// Remove HTML tags
|
||||||
|
content = content.replace(/<[^>]+>/g, ' ');
|
||||||
|
|
||||||
|
// Convert common HTML entities
|
||||||
|
content = content
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
|
||||||
|
.replace(/&#x([A-Fa-f0-9]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||||
|
|
||||||
|
// Remove extra whitespace
|
||||||
|
content = content.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
// Get first sentence if content is too long
|
||||||
|
if (content.length > 200) {
|
||||||
|
const match = content.match(/^.*?[.!?](?:\s|$)/);
|
||||||
|
if (match) {
|
||||||
|
content = match[0].trim();
|
||||||
|
} else {
|
||||||
|
content = content.substring(0, 197) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to extract content based on template
|
||||||
|
function extractContent(content, template) {
|
||||||
|
const itemRegex = new RegExp(`<${template.itemPath}>([\\\s\\\S]*?)<\/${template.itemPath}>`, 'g');
|
||||||
|
const titleRegex = new RegExp(`<${template.titlePath}>([\\\s\\\S]*?)<\/${template.titlePath}>`);
|
||||||
|
const descRegex = new RegExp(`<${template.descPath}>([\\\s\\\S]*?)<\/${template.descPath}>`);
|
||||||
|
const dateRegex = new RegExp(`<${template.datePath}>([\\\s\\\S]*?)<\/${template.datePath}>`);
|
||||||
|
const linkRegex = /<link[^>]*?>([\s\S]*?)<\/link>/;
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = itemRegex.exec(content)) !== null) {
|
||||||
|
const itemContent = match[1];
|
||||||
|
|
||||||
|
const titleMatch = titleRegex.exec(itemContent);
|
||||||
|
const descMatch = descRegex.exec(itemContent);
|
||||||
|
const dateMatch = dateRegex.exec(itemContent);
|
||||||
|
const linkMatch = linkRegex.exec(itemContent);
|
||||||
|
|
||||||
|
const title = cleanHtmlContent(titleMatch ? titleMatch[1] : '');
|
||||||
|
const description = cleanHtmlContent(descMatch ? descMatch[1] : '');
|
||||||
|
|
||||||
|
// Create display text from title and optionally description
|
||||||
|
let displayText = title;
|
||||||
|
if (description && description !== title) {
|
||||||
|
displayText = `${title}: ${description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
title,
|
||||||
|
displayText,
|
||||||
|
link: linkMatch ? linkMatch[1].trim() : '',
|
||||||
|
description,
|
||||||
|
pubDate: dateMatch ? dateMatch[1] : '',
|
||||||
|
feedUrl: null // Will be set by caller
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to fetch RSS feed data from a single URL
|
||||||
|
async function fetchSingleRssFeed(feedUrl) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log(`Fetching RSS feed from: ${feedUrl}`);
|
||||||
|
|
||||||
|
const request = https.get(feedUrl, (res) => {
|
||||||
|
console.log(`RSS feed response status: ${res.statusCode}`);
|
||||||
|
console.log('RSS feed response headers:', res.headers);
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
// Log first part of response
|
||||||
|
console.log('RSS feed raw response (first 500 chars):', data.substring(0, 500));
|
||||||
|
|
||||||
|
// Detect feed type and get appropriate template
|
||||||
|
const feedType = detectFeedType(data);
|
||||||
|
const template = feedTemplates[feedType];
|
||||||
|
console.log(`Detected feed type: ${feedType}`);
|
||||||
|
|
||||||
|
// Extract and process items
|
||||||
|
const items = extractContent(data, template);
|
||||||
|
console.log(`Extracted ${items.length} items from feed`);
|
||||||
|
|
||||||
|
// Add feed URL to each item
|
||||||
|
items.forEach(item => {
|
||||||
|
item.feedUrl = feedUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing RSS feed:', error);
|
||||||
|
console.error('Error stack:', error.stack);
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
title: 'Error processing RSS feed',
|
||||||
|
link: feedUrl,
|
||||||
|
feedUrl: feedUrl,
|
||||||
|
error: error.message
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', (error) => {
|
||||||
|
console.error('Error fetching RSS feed:', error);
|
||||||
|
console.error('Error stack:', error.stack);
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
title: 'Error fetching RSS feed',
|
||||||
|
link: feedUrl,
|
||||||
|
feedUrl: feedUrl,
|
||||||
|
error: error.message
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a timeout of 10 seconds
|
||||||
|
request.setTimeout(10000, () => {
|
||||||
|
console.error('RSS feed request timed out:', feedUrl);
|
||||||
|
request.destroy();
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
title: 'RSS feed request timed out',
|
||||||
|
link: feedUrl,
|
||||||
|
feedUrl: feedUrl,
|
||||||
|
error: 'Request timed out after 10 seconds'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to fetch all enabled RSS feeds
|
||||||
|
async function fetchRssFeed() {
|
||||||
|
try {
|
||||||
|
const enabledFeeds = config.rssFeeds.filter(feed => feed.enabled);
|
||||||
|
const feedPromises = enabledFeeds.map(feed => fetchSingleRssFeed(feed.url));
|
||||||
|
|
||||||
|
const allItems = await Promise.all(feedPromises);
|
||||||
|
|
||||||
|
// Flatten array and sort by date (if available)
|
||||||
|
const items = allItems.flat().sort((a, b) => {
|
||||||
|
const dateA = new Date(a.pubDate || 0);
|
||||||
|
const dateB = new Date(b.pubDate || 0);
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching RSS feeds:', error);
|
||||||
|
return {
|
||||||
|
items: [{
|
||||||
|
title: 'Error fetching RSS feeds',
|
||||||
|
link: '#',
|
||||||
|
error: error.message
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to fetch data from the SL Transport API for a specific site
|
||||||
|
function fetchDeparturesForSite(siteId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`;
|
||||||
|
console.log(`Fetching data from: ${apiUrl}`);
|
||||||
|
|
||||||
|
https.get(apiUrl, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Raw API response:', data.substring(0, 200) + '...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(data);
|
||||||
|
console.log('Successfully parsed as regular JSON');
|
||||||
|
resolve(parsedData);
|
||||||
|
return;
|
||||||
|
} catch (jsonError) {
|
||||||
|
console.log('Not valid JSON, trying to fix format...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.startsWith('departures":')) {
|
||||||
|
data = '{' + data;
|
||||||
|
} else if (data.includes('departures":')) {
|
||||||
|
const startIndex = data.indexOf('departures":');
|
||||||
|
if (startIndex > 0) {
|
||||||
|
data = '{' + data.substring(startIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data = data.replace(/}{\s*"/g, '},{"');
|
||||||
|
data = data.replace(/"([^"]+)":\s*([^,{}\[\]]+)(?=")/g, '"$1": $2,');
|
||||||
|
data = data.replace(/,\s*}/g, '}').replace(/,\s*\]/g, ']');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(data);
|
||||||
|
console.log('Successfully parsed fixed JSON');
|
||||||
|
|
||||||
|
if (parsedData && parsedData.departures) {
|
||||||
|
const line7Departures = parsedData.departures.filter(d => d.line && d.line.designation === '7');
|
||||||
|
if (line7Departures.length > 0) {
|
||||||
|
console.log('Line 7 details:', JSON.stringify(line7Departures[0], null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(parsedData);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing fixed JSON:', parseError);
|
||||||
|
resolve({
|
||||||
|
departures: [],
|
||||||
|
error: 'Failed to parse API response: ' + parseError.message,
|
||||||
|
rawResponse: data.substring(0, 500) + '...'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing API response:', error);
|
||||||
|
resolve({
|
||||||
|
departures: [],
|
||||||
|
error: 'Error processing API response: ' + error.message,
|
||||||
|
rawResponse: data.substring(0, 500) + '...'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', (error) => {
|
||||||
|
console.error('Error fetching data from API:', error);
|
||||||
|
resolve({
|
||||||
|
departures: [],
|
||||||
|
error: 'Error fetching data from API: ' + error.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const parsedUrl = url.parse(req.url, true);
|
||||||
|
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to fetch data from all enabled sites
|
||||||
|
async function fetchAllDepartures() {
|
||||||
|
const enabledSites = config.sites.filter(site => site.enabled);
|
||||||
|
|
||||||
|
if (enabledSites.length === 0) {
|
||||||
|
return { sites: [], error: 'No enabled sites configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sitesPromises = enabledSites.map(async (site) => {
|
||||||
|
try {
|
||||||
|
const departureData = await fetchDeparturesForSite(site.id);
|
||||||
|
return {
|
||||||
|
siteId: site.id,
|
||||||
|
siteName: site.name,
|
||||||
|
data: departureData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching departures for site ${site.id}:`, error);
|
||||||
|
return {
|
||||||
|
siteId: site.id,
|
||||||
|
siteName: site.name,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(sitesPromises);
|
||||||
|
return { sites: results };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching all departures:', error);
|
||||||
|
return { sites: [], error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle API endpoints
|
||||||
|
if (parsedUrl.pathname === '/api/departures') {
|
||||||
|
try {
|
||||||
|
const data = await fetchAllDepartures();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling departures request:', error);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (parsedUrl.pathname === '/api/config') {
|
||||||
|
// Return the current configuration
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(config));
|
||||||
|
}
|
||||||
|
else if (parsedUrl.pathname === '/api/config/update' && req.method === 'POST') {
|
||||||
|
// Update configuration
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const newConfig = JSON.parse(body);
|
||||||
|
if (newConfig.sites && newConfig.rssFeeds) {
|
||||||
|
config = newConfig;
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.writeFileSync('sites-config.json', JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Configuration updated' }));
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid configuration format');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating configuration:', error);
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (parsedUrl.pathname === '/api/rss') {
|
||||||
|
try {
|
||||||
|
// If URL is provided, fetch single feed, otherwise fetch all enabled feeds
|
||||||
|
const feedUrl = parsedUrl.query.url;
|
||||||
|
console.log('RSS request received for URL:', feedUrl);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
if (feedUrl) {
|
||||||
|
console.log('Fetching single RSS feed:', feedUrl);
|
||||||
|
const items = await fetchSingleRssFeed(feedUrl);
|
||||||
|
data = { items };
|
||||||
|
console.log(`Fetched ${items.length} items from feed`);
|
||||||
|
} else {
|
||||||
|
console.log('Fetching all enabled RSS feeds');
|
||||||
|
data = await fetchRssFeed();
|
||||||
|
console.log(`Fetched ${data.items.length} total items from all feeds`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
console.log('RSS response sent successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling RSS request:', error);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Serve static files
|
||||||
|
else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') {
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.readFile('index.html', (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Error loading index.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (/\.(js|css|png|jpg|jpeg|gif|ico)$/.test(parsedUrl.pathname)) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const filePath = parsedUrl.pathname.substring(1);
|
||||||
|
fs.readFile(filePath, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('File not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = parsedUrl.pathname.split('.').pop();
|
||||||
|
const contentType = {
|
||||||
|
'js': 'text/javascript',
|
||||||
|
'css': 'text/css',
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'ico': 'image/x-icon'
|
||||||
|
}[ext] || 'text/plain';
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running at http://localhost:${PORT}/`);
|
||||||
|
});
|
||||||
39
setup-git-repo.ps1
Normal file
39
setup-git-repo.ps1
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Setup Git repository and push to Gitea
|
||||||
|
$gitPath = "C:\Program Files\Git\cmd\git.exe"
|
||||||
|
|
||||||
|
# Configure git user (if not already configured)
|
||||||
|
& $gitPath config --global user.name "kyle"
|
||||||
|
& $gitPath config --global user.email "t72chevy@hotmail.com"
|
||||||
|
|
||||||
|
# Initialize git repository
|
||||||
|
Write-Host "Initializing git repository..."
|
||||||
|
& $gitPath init
|
||||||
|
|
||||||
|
# Add remote
|
||||||
|
Write-Host "Adding Gitea remote..."
|
||||||
|
& $gitPath remote add origin "http://192.168.68.53:3000/kyle/SignageHTML.git"
|
||||||
|
|
||||||
|
# Configure remote URL with token for authentication
|
||||||
|
$remoteUrl = "http://kyle:9ed750a7f1480481ff96f021c8bbf49836b902f8@192.168.68.53:3000/kyle/SignageHTML.git"
|
||||||
|
& $gitPath remote set-url origin $remoteUrl
|
||||||
|
|
||||||
|
# Add all files
|
||||||
|
Write-Host "Adding files..."
|
||||||
|
& $gitPath add .
|
||||||
|
|
||||||
|
# Create initial commit
|
||||||
|
Write-Host "Creating initial commit..."
|
||||||
|
& $gitPath commit -m "Initial commit: Digital signage system for transit departures, weather, and news ticker"
|
||||||
|
|
||||||
|
# Push to Gitea
|
||||||
|
Write-Host "Pushing to Gitea..."
|
||||||
|
& $gitPath push -u origin main
|
||||||
|
|
||||||
|
# If main branch doesn't exist, try master
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Trying master branch..."
|
||||||
|
& $gitPath branch -M master
|
||||||
|
& $gitPath push -u origin master
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Done! Repository is now on Gitea at http://192.168.68.53:3000/kyle/SignageHTML"
|
||||||
22
sites-config.json
Normal file
22
sites-config.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"orientation": "normal",
|
||||||
|
"darkMode": "auto",
|
||||||
|
"backgroundImage": "",
|
||||||
|
"backgroundOpacity": 0.3,
|
||||||
|
"tickerSpeed": 60,
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"id": "1411",
|
||||||
|
"name": "Ambassaderna",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rssFeeds": [
|
||||||
|
{
|
||||||
|
"name": "Travel Alerts",
|
||||||
|
"url": "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combineSameDirection": true
|
||||||
|
}
|
||||||
297
ticker.js
Normal file
297
ticker.js
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* ticker.js - A modular ticker component for displaying RSS feed content
|
||||||
|
* Fetches and displays content from an RSS feed in a scrolling ticker at the bottom of the page
|
||||||
|
*/
|
||||||
|
|
||||||
|
class TickerManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
// Default options
|
||||||
|
this.options = {
|
||||||
|
elementId: 'ticker-container',
|
||||||
|
scrollSpeed: 60, // Animation duration in seconds
|
||||||
|
maxItems: 10, // Maximum number of items to display
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create ticker container immediately
|
||||||
|
this.createTickerContainer();
|
||||||
|
|
||||||
|
// State
|
||||||
|
this.items = [];
|
||||||
|
this.isScrolling = false;
|
||||||
|
this.animationFrameId = null;
|
||||||
|
|
||||||
|
// Initialize RSS manager
|
||||||
|
this.rssManager = new RssManager();
|
||||||
|
|
||||||
|
// Subscribe to RSS updates
|
||||||
|
this.rssManager.onUpdate(items => {
|
||||||
|
this.items = items.slice(0, this.options.maxItems);
|
||||||
|
this.updateTicker();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the ticker container
|
||||||
|
*/
|
||||||
|
createTickerContainer() {
|
||||||
|
// Create container if it doesn't exist
|
||||||
|
if (!document.getElementById(this.options.elementId)) {
|
||||||
|
console.log('Creating ticker container');
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = this.options.elementId;
|
||||||
|
container.className = 'ticker-container';
|
||||||
|
|
||||||
|
// Create ticker content
|
||||||
|
const tickerContent = document.createElement('div');
|
||||||
|
tickerContent.className = 'ticker-content';
|
||||||
|
container.appendChild(tickerContent);
|
||||||
|
|
||||||
|
// Add to document
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Add styles
|
||||||
|
this.addTickerStyles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the ticker
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
console.log('Initializing TickerManager...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set initial scroll speed
|
||||||
|
this.setScrollSpeed(this.options.scrollSpeed);
|
||||||
|
|
||||||
|
// Add initial loading message
|
||||||
|
const tickerContent = document.querySelector(`#${this.options.elementId} .ticker-content`);
|
||||||
|
if (tickerContent) {
|
||||||
|
tickerContent.innerHTML = '<div class="ticker-item">Loading news...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure ticker is visible
|
||||||
|
const container = document.getElementById(this.options.elementId);
|
||||||
|
if (container) {
|
||||||
|
container.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('TickerManager initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing TickerManager:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add ticker styles
|
||||||
|
*/
|
||||||
|
addTickerStyles() {
|
||||||
|
// Check if styles already exist
|
||||||
|
if (!document.getElementById('ticker-styles')) {
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.id = 'ticker-styles';
|
||||||
|
|
||||||
|
// Define styles
|
||||||
|
styleElement.textContent = `
|
||||||
|
.ticker-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(to right, #3c3b6e, #b22234, #ffffff);
|
||||||
|
color: white;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 40px;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vertical .ticker-container {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transform-origin: left bottom;
|
||||||
|
width: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vertical-reverse .ticker-container {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: right bottom;
|
||||||
|
width: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.upsidedown .ticker-container {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 30px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-item:nth-child(3n+1) {
|
||||||
|
background-color: rgba(178, 34, 52, 0.7); /* Red */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-item:nth-child(3n+2) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.7); /* White */
|
||||||
|
color: #3c3b6e; /* Dark blue text for readability */
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-item:nth-child(3n+3) {
|
||||||
|
background-color: rgba(60, 59, 110, 0.7); /* Blue */
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ticker-scroll {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation direction for different orientations */
|
||||||
|
body.vertical .ticker-content {
|
||||||
|
animation-direction: reverse; /* Reverse for vertical to maintain readability */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.upsidedown .ticker-content {
|
||||||
|
animation-direction: reverse; /* Reverse for upside down to maintain readability */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
body.dark-mode .ticker-container {
|
||||||
|
background: linear-gradient(to right, #1a1a4f, #8b1a29, #e6e6e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .ticker-item:nth-child(3n+1) {
|
||||||
|
background-color: rgba(139, 26, 41, 0.7); /* Darker Red */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .ticker-item:nth-child(3n+2) {
|
||||||
|
background-color: rgba(230, 230, 230, 0.7); /* Off-White */
|
||||||
|
color: #1a1a4f; /* Darker blue text */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .ticker-item:nth-child(3n+3) {
|
||||||
|
background-color: rgba(26, 26, 79, 0.7); /* Darker Blue */
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to document
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the ticker scroll speed
|
||||||
|
* @param {number} speed - Speed in seconds for one complete scroll cycle
|
||||||
|
*/
|
||||||
|
setScrollSpeed(speed) {
|
||||||
|
this.options.scrollSpeed = speed;
|
||||||
|
const container = document.getElementById(this.options.elementId);
|
||||||
|
if (container) {
|
||||||
|
// Reset animation by removing and re-adding content
|
||||||
|
const content = container.querySelector('.ticker-content');
|
||||||
|
if (content) {
|
||||||
|
const clone = content.cloneNode(true);
|
||||||
|
container.style.setProperty('--ticker-speed', `${speed}s`);
|
||||||
|
content.remove();
|
||||||
|
container.appendChild(clone);
|
||||||
|
}
|
||||||
|
console.log(`Ticker speed set to ${speed} seconds`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ticker content
|
||||||
|
*/
|
||||||
|
updateTicker() {
|
||||||
|
console.log('Updating ticker content...');
|
||||||
|
const tickerContent = document.querySelector(`#${this.options.elementId} .ticker-content`);
|
||||||
|
|
||||||
|
if (tickerContent) {
|
||||||
|
// Clear existing content
|
||||||
|
tickerContent.innerHTML = '';
|
||||||
|
|
||||||
|
if (this.items.length === 0) {
|
||||||
|
console.log('No items to display in ticker');
|
||||||
|
const tickerItem = document.createElement('div');
|
||||||
|
tickerItem.className = 'ticker-item';
|
||||||
|
tickerItem.textContent = 'Loading news...';
|
||||||
|
tickerContent.appendChild(tickerItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Adding ${this.items.length} items to ticker`);
|
||||||
|
|
||||||
|
// Add items
|
||||||
|
this.items.forEach((item, index) => {
|
||||||
|
const tickerItem = document.createElement('div');
|
||||||
|
tickerItem.className = 'ticker-item';
|
||||||
|
|
||||||
|
// Create link if available, using displayText or falling back to title
|
||||||
|
if (item.link) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = item.link;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.textContent = item.displayText || item.title;
|
||||||
|
link.style.color = 'inherit';
|
||||||
|
link.style.textDecoration = 'none';
|
||||||
|
tickerItem.appendChild(link);
|
||||||
|
} else {
|
||||||
|
tickerItem.textContent = item.displayText || item.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
tickerContent.appendChild(tickerItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Ticker content updated successfully');
|
||||||
|
|
||||||
|
// Calculate total width of content
|
||||||
|
const totalWidth = Array.from(tickerContent.children)
|
||||||
|
.reduce((width, item) => width + item.offsetWidth, 0);
|
||||||
|
|
||||||
|
// Calculate animation duration based on content width
|
||||||
|
const duration = Math.max(totalWidth / 100, this.options.scrollSpeed);
|
||||||
|
|
||||||
|
// Reset and start animation
|
||||||
|
tickerContent.style.animation = 'none';
|
||||||
|
tickerContent.offsetHeight; // Force reflow
|
||||||
|
tickerContent.style.animation = `ticker-scroll ${duration}s linear infinite`;
|
||||||
|
|
||||||
|
console.log(`Animation duration set to ${duration}s based on content width ${totalWidth}px`);
|
||||||
|
} else {
|
||||||
|
console.error('Ticker content element not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the TickerManager class for use in other modules
|
||||||
|
window.TickerManager = TickerManager;
|
||||||
47
update-site-id.sh
Normal file
47
update-site-id.sh
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to update the site ID for the SL Transport API
|
||||||
|
|
||||||
|
# Check if a site ID was provided
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Error: No site ID provided."
|
||||||
|
echo "Usage: ./update-site-id.sh SITE_ID"
|
||||||
|
echo "Example: ./update-site-id.sh 1234"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate that the site ID is numeric
|
||||||
|
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Error: Site ID must be a number."
|
||||||
|
echo "Usage: ./update-site-id.sh SITE_ID"
|
||||||
|
echo "Example: ./update-site-id.sh 1234"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SITE_ID=$1
|
||||||
|
SITE_NAME=""
|
||||||
|
|
||||||
|
# Ask for site name (optional)
|
||||||
|
read -p "Enter site name (optional, press Enter to skip): " SITE_NAME
|
||||||
|
|
||||||
|
# Update server.js
|
||||||
|
echo "Updating server.js with site ID: $SITE_ID"
|
||||||
|
sed -i "s|const API_URL = 'https://transport.integration.sl.se/v1/sites/[0-9]*/departures'|const API_URL = 'https://transport.integration.sl.se/v1/sites/$SITE_ID/departures'|g" server.js
|
||||||
|
|
||||||
|
# Update index.html if site name was provided
|
||||||
|
if [ ! -z "$SITE_NAME" ]; then
|
||||||
|
echo "Updating index.html with site name: $SITE_NAME"
|
||||||
|
sed -i "s|<h2 style=\"text-align: center;\">.*</h2>|<h2 style=\"text-align: center;\">$SITE_NAME (Site ID: $SITE_ID)</h2>|g" index.html
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update title in index.html
|
||||||
|
if [ ! -z "$SITE_NAME" ]; then
|
||||||
|
echo "Updating page title in index.html"
|
||||||
|
sed -i "s|<title>SL Transport Departures - .*</title>|<title>SL Transport Departures - $SITE_NAME ($SITE_ID)</title>|g" index.html
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Update complete!"
|
||||||
|
echo "Site ID: $SITE_ID"
|
||||||
|
if [ ! -z "$SITE_NAME" ]; then
|
||||||
|
echo "Site Name: $SITE_NAME"
|
||||||
|
fi
|
||||||
|
echo "Restart the server for changes to take effect: node server.js"
|
||||||
44
update-weather-location.sh
Normal file
44
update-weather-location.sh
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to update the weather location in the SL Transport Departures Display
|
||||||
|
|
||||||
|
# Check if latitude and longitude were provided
|
||||||
|
if [ -z "$1" ] || [ -z "$2" ]; then
|
||||||
|
echo "Error: Latitude and longitude must be provided."
|
||||||
|
echo "Usage: ./update-weather-location.sh LATITUDE LONGITUDE [LOCATION_NAME]"
|
||||||
|
echo "Example: ./update-weather-location.sh 59.3293 18.0686 Stockholm"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate that the latitude and longitude are numeric
|
||||||
|
if ! [[ "$1" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] || ! [[ "$2" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
|
||||||
|
echo "Error: Latitude and longitude must be numbers."
|
||||||
|
echo "Usage: ./update-weather-location.sh LATITUDE LONGITUDE [LOCATION_NAME]"
|
||||||
|
echo "Example: ./update-weather-location.sh 59.3293 18.0686 Stockholm"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LATITUDE=$1
|
||||||
|
LONGITUDE=$2
|
||||||
|
LOCATION_NAME=${3:-""}
|
||||||
|
|
||||||
|
# Update index.html with new latitude and longitude
|
||||||
|
echo "Updating weather location in index.html..."
|
||||||
|
sed -i "s/latitude: [0-9.-]\+/latitude: $LATITUDE/g" index.html
|
||||||
|
sed -i "s/longitude: [0-9.-]\+/longitude: $LONGITUDE/g" index.html
|
||||||
|
|
||||||
|
# Update location name in the weather widget if provided
|
||||||
|
if [ ! -z "$LOCATION_NAME" ]; then
|
||||||
|
echo "Updating location name to: $LOCATION_NAME"
|
||||||
|
# This is a more complex replacement that might need manual verification
|
||||||
|
sed -i "s/<h3>[^<]*<\/h3>/<h3>$LOCATION_NAME<\/h3>/g" index.html
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Weather location updated successfully!"
|
||||||
|
echo "Latitude: $LATITUDE"
|
||||||
|
echo "Longitude: $LONGITUDE"
|
||||||
|
if [ ! -z "$LOCATION_NAME" ]; then
|
||||||
|
echo "Location Name: $LOCATION_NAME"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Restart the application to see the changes."
|
||||||
|
echo "You can find latitude and longitude for any location at: https://www.latlong.net/"
|
||||||
511
weather.js
Normal file
511
weather.js
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
/**
|
||||||
|
* weather.js - A module for weather-related functionality
|
||||||
|
* Provides real-time weather data and sunset/sunrise information
|
||||||
|
* Uses OpenWeatherMap API for weather data
|
||||||
|
*/
|
||||||
|
|
||||||
|
class WeatherManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
// Default options
|
||||||
|
this.options = {
|
||||||
|
latitude: 59.3293, // Stockholm latitude
|
||||||
|
longitude: 18.0686, // Stockholm longitude
|
||||||
|
apiKey: options.apiKey || '4d8fb5b93d4af21d66a2948710284366', // OpenWeatherMap API key
|
||||||
|
refreshInterval: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
this.weatherData = null;
|
||||||
|
this.forecastData = null;
|
||||||
|
this.sunTimes = null;
|
||||||
|
this.isDarkMode = false;
|
||||||
|
this.lastUpdated = null;
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the weather manager
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
// Fetch weather data
|
||||||
|
await this.fetchWeatherData();
|
||||||
|
|
||||||
|
// Check if it's dark outside (only affects auto mode)
|
||||||
|
this.updateDarkModeBasedOnTime();
|
||||||
|
|
||||||
|
// Set up interval to check dark mode every minute (only affects auto mode)
|
||||||
|
this.darkModeCheckInterval = setInterval(() => {
|
||||||
|
// Only update dark mode based on time if ConfigManager has dark mode set to 'auto'
|
||||||
|
if (this.shouldUseAutoDarkMode()) {
|
||||||
|
this.updateDarkModeBasedOnTime();
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
// Set up interval to refresh weather data
|
||||||
|
setInterval(() => this.fetchWeatherData(), this.options.refreshInterval);
|
||||||
|
|
||||||
|
// Dispatch initial dark mode state
|
||||||
|
this.dispatchDarkModeEvent();
|
||||||
|
|
||||||
|
console.log('WeatherManager initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing WeatherManager:', error);
|
||||||
|
|
||||||
|
// Fallback to calculated sun times if API fails
|
||||||
|
await this.updateSunTimesFromCalculation();
|
||||||
|
this.updateDarkModeBasedOnTime();
|
||||||
|
this.dispatchDarkModeEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we should use automatic dark mode based on ConfigManager settings
|
||||||
|
*/
|
||||||
|
shouldUseAutoDarkMode() {
|
||||||
|
// If there's a ConfigManager instance with a config
|
||||||
|
if (window.configManager && window.configManager.config) {
|
||||||
|
// Only use auto dark mode if the setting is 'auto'
|
||||||
|
return window.configManager.config.darkMode === 'auto';
|
||||||
|
}
|
||||||
|
// Default to true if no ConfigManager is available
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch weather data from OpenWeatherMap API
|
||||||
|
*/
|
||||||
|
async fetchWeatherData() {
|
||||||
|
try {
|
||||||
|
// Fetch current weather
|
||||||
|
const currentWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&appid=${this.options.apiKey}`;
|
||||||
|
const currentWeatherResponse = await fetch(currentWeatherUrl);
|
||||||
|
const currentWeatherData = await currentWeatherResponse.json();
|
||||||
|
|
||||||
|
if (currentWeatherData.cod !== 200) {
|
||||||
|
throw new Error(`API Error: ${currentWeatherData.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch hourly forecast
|
||||||
|
const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&appid=${this.options.apiKey}`;
|
||||||
|
const forecastResponse = await fetch(forecastUrl);
|
||||||
|
const forecastData = await forecastResponse.json();
|
||||||
|
|
||||||
|
if (forecastData.cod !== "200") {
|
||||||
|
throw new Error(`API Error: ${forecastData.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process and store the data
|
||||||
|
this.weatherData = this.processCurrentWeather(currentWeatherData);
|
||||||
|
this.forecastData = this.processForecast(forecastData);
|
||||||
|
this.lastUpdated = new Date();
|
||||||
|
|
||||||
|
// Extract sunrise and sunset times from the API response
|
||||||
|
this.updateSunTimesFromApi(currentWeatherData);
|
||||||
|
|
||||||
|
// Update the UI with the new data
|
||||||
|
this.updateWeatherUI();
|
||||||
|
|
||||||
|
console.log('Weather data updated:', this.weatherData);
|
||||||
|
return this.weatherData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching weather data:', error);
|
||||||
|
|
||||||
|
// If we don't have any weather data yet, create some default data
|
||||||
|
if (!this.weatherData) {
|
||||||
|
this.weatherData = this.createDefaultWeatherData();
|
||||||
|
this.forecastData = this.createDefaultForecastData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to calculated sun times
|
||||||
|
await this.updateSunTimesFromCalculation();
|
||||||
|
|
||||||
|
return this.weatherData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process current weather data from API response
|
||||||
|
*/
|
||||||
|
processCurrentWeather(data) {
|
||||||
|
return {
|
||||||
|
temperature: Math.round(data.main.temp * 10) / 10, // Round to 1 decimal place
|
||||||
|
condition: data.weather[0].main,
|
||||||
|
description: data.weather[0].description,
|
||||||
|
icon: this.getWeatherIconUrl(data.weather[0].icon),
|
||||||
|
wind: {
|
||||||
|
speed: Math.round(data.wind.speed * 3.6), // Convert m/s to km/h
|
||||||
|
direction: data.wind.deg
|
||||||
|
},
|
||||||
|
humidity: data.main.humidity,
|
||||||
|
pressure: data.main.pressure,
|
||||||
|
precipitation: data.rain ? (data.rain['1h'] || 0) : 0,
|
||||||
|
location: data.name,
|
||||||
|
country: data.sys.country,
|
||||||
|
timestamp: new Date(data.dt * 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process forecast data from API response
|
||||||
|
*/
|
||||||
|
processForecast(data) {
|
||||||
|
// Get the next 7 forecasts (covering about 24 hours)
|
||||||
|
return data.list.slice(0, 7).map(item => {
|
||||||
|
return {
|
||||||
|
temperature: Math.round(item.main.temp * 10) / 10,
|
||||||
|
condition: item.weather[0].main,
|
||||||
|
description: item.weather[0].description,
|
||||||
|
icon: this.getWeatherIconUrl(item.weather[0].icon),
|
||||||
|
timestamp: new Date(item.dt * 1000),
|
||||||
|
precipitation: item.rain ? (item.rain['3h'] || 0) : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get weather icon URL from icon code
|
||||||
|
*/
|
||||||
|
getWeatherIconUrl(iconCode) {
|
||||||
|
return `https://openweathermap.org/img/wn/${iconCode}@2x.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default weather data for fallback
|
||||||
|
*/
|
||||||
|
createDefaultWeatherData() {
|
||||||
|
return {
|
||||||
|
temperature: 7.1,
|
||||||
|
condition: 'Clear',
|
||||||
|
description: 'clear sky',
|
||||||
|
icon: 'https://openweathermap.org/img/wn/01d@2x.png',
|
||||||
|
wind: {
|
||||||
|
speed: 14.8,
|
||||||
|
direction: 270
|
||||||
|
},
|
||||||
|
humidity: 65,
|
||||||
|
pressure: 1012.0,
|
||||||
|
precipitation: 0.00,
|
||||||
|
location: 'Stockholm',
|
||||||
|
country: 'SE',
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default forecast data for fallback
|
||||||
|
*/
|
||||||
|
createDefaultForecastData() {
|
||||||
|
const now = new Date();
|
||||||
|
const forecasts = [];
|
||||||
|
|
||||||
|
// Create 7 forecast entries
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const forecastTime = new Date(now);
|
||||||
|
forecastTime.setHours(now.getHours() + i);
|
||||||
|
|
||||||
|
forecasts.push({
|
||||||
|
temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour
|
||||||
|
condition: i < 2 ? 'Clear' : 'Partly Cloudy',
|
||||||
|
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',
|
||||||
|
timestamp: forecastTime,
|
||||||
|
precipitation: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return forecasts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the weather UI with current data
|
||||||
|
*/
|
||||||
|
updateWeatherUI() {
|
||||||
|
if (!this.weatherData || !this.forecastData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update current weather
|
||||||
|
const locationElement = document.querySelector('#custom-weather h3');
|
||||||
|
if (locationElement) {
|
||||||
|
locationElement.textContent = this.weatherData.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionElement = document.querySelector('#custom-weather .weather-icon div');
|
||||||
|
if (conditionElement) {
|
||||||
|
conditionElement.textContent = this.weatherData.condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconElement = document.querySelector('#custom-weather .weather-icon img');
|
||||||
|
if (iconElement) {
|
||||||
|
iconElement.src = this.weatherData.icon;
|
||||||
|
iconElement.alt = this.weatherData.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
const temperatureElement = document.querySelector('#custom-weather .temperature');
|
||||||
|
if (temperatureElement) {
|
||||||
|
temperatureElement.textContent = `${this.weatherData.temperature} °C`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update forecast
|
||||||
|
const forecastContainer = document.querySelector('#custom-weather .forecast');
|
||||||
|
if (forecastContainer) {
|
||||||
|
// Clear existing forecast
|
||||||
|
forecastContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Add current weather as "Now"
|
||||||
|
const nowElement = document.createElement('div');
|
||||||
|
nowElement.className = 'forecast-hour';
|
||||||
|
nowElement.innerHTML = `
|
||||||
|
<div class="time">Now</div>
|
||||||
|
<div class="icon">
|
||||||
|
<img src="${this.weatherData.icon}" alt="${this.weatherData.description}" width="40" />
|
||||||
|
</div>
|
||||||
|
<div class="temp">${this.weatherData.temperature} °C</div>
|
||||||
|
`;
|
||||||
|
forecastContainer.appendChild(nowElement);
|
||||||
|
|
||||||
|
// Add hourly forecasts
|
||||||
|
this.forecastData.forEach(forecast => {
|
||||||
|
const forecastTime = forecast.timestamp;
|
||||||
|
const timeString = forecastTime.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
const forecastElement = document.createElement('div');
|
||||||
|
forecastElement.className = 'forecast-hour';
|
||||||
|
forecastElement.innerHTML = `
|
||||||
|
<div class="time">${timeString}</div>
|
||||||
|
<div class="icon">
|
||||||
|
<img src="${forecast.icon}" alt="${forecast.description}" width="40" />
|
||||||
|
</div>
|
||||||
|
<div class="temp">${forecast.temperature} °C</div>
|
||||||
|
`;
|
||||||
|
forecastContainer.appendChild(forecastElement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sun times
|
||||||
|
const sunTimesElement = document.querySelector('#custom-weather .sun-times');
|
||||||
|
if (sunTimesElement && this.sunTimes) {
|
||||||
|
const sunriseTime = this.formatTime(this.sunTimes.today.sunrise);
|
||||||
|
const sunsetTime = this.formatTime(this.sunTimes.today.sunset);
|
||||||
|
sunTimesElement.textContent = `Sunrise: ${sunriseTime} | Sunset: ${sunsetTime}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating weather UI:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update sunrise and sunset times from API data
|
||||||
|
*/
|
||||||
|
updateSunTimesFromApi(data) {
|
||||||
|
if (!data || !data.sys || !data.sys.sunrise || !data.sys.sunset) {
|
||||||
|
console.warn('No sunrise/sunset data in API response, using calculated times');
|
||||||
|
this.updateSunTimesFromCalculation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
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 sunset = new Date(data.sys.sunset * 1000);
|
||||||
|
|
||||||
|
// Use calculated times for tomorrow
|
||||||
|
const tomorrowTimes = this.calculateSunTimes(tomorrow);
|
||||||
|
|
||||||
|
this.sunTimes = {
|
||||||
|
today: { sunrise, sunset },
|
||||||
|
tomorrow: tomorrowTimes
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Sun times updated from API:', this.sunTimes);
|
||||||
|
return this.sunTimes;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating sun times from API:', error);
|
||||||
|
this.updateSunTimesFromCalculation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update sunrise and sunset times using calculation
|
||||||
|
*/
|
||||||
|
async updateSunTimesFromCalculation() {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calculate sun times based on date and location
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
this.sunTimes = {
|
||||||
|
today: this.calculateSunTimes(today),
|
||||||
|
tomorrow: this.calculateSunTimes(tomorrow)
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Sun times updated from calculation:', this.sunTimes);
|
||||||
|
return this.sunTimes;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating sun times from calculation:', error);
|
||||||
|
// Fallback to default times if calculation fails
|
||||||
|
const defaultSunrise = new Date();
|
||||||
|
defaultSunrise.setHours(6, 45, 0, 0);
|
||||||
|
|
||||||
|
const defaultSunset = new Date();
|
||||||
|
defaultSunset.setHours(17, 32, 0, 0);
|
||||||
|
|
||||||
|
this.sunTimes = {
|
||||||
|
today: {
|
||||||
|
sunrise: defaultSunrise,
|
||||||
|
sunset: defaultSunset
|
||||||
|
},
|
||||||
|
tomorrow: {
|
||||||
|
sunrise: defaultSunrise,
|
||||||
|
sunset: defaultSunset
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return this.sunTimes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate sunrise and sunset times for a given date
|
||||||
|
* Uses a simplified algorithm
|
||||||
|
*/
|
||||||
|
calculateSunTimes(date) {
|
||||||
|
// This is a simplified calculation
|
||||||
|
// For more accuracy, you would use a proper astronomical calculation
|
||||||
|
|
||||||
|
// Get day of year
|
||||||
|
const start = new Date(date.getFullYear(), 0, 0);
|
||||||
|
const diff = date - start;
|
||||||
|
const oneDay = 1000 * 60 * 60 * 24;
|
||||||
|
const dayOfYear = Math.floor(diff / oneDay);
|
||||||
|
|
||||||
|
// Calculate sunrise and sunset times based on latitude and day of year
|
||||||
|
// This is a very simplified model
|
||||||
|
const latitude = this.options.latitude;
|
||||||
|
|
||||||
|
// Base sunrise and sunset times (in hours)
|
||||||
|
let baseSunrise = 6; // 6 AM
|
||||||
|
let baseSunset = 18; // 6 PM
|
||||||
|
|
||||||
|
// Adjust for latitude and season
|
||||||
|
// Northern hemisphere seasonal adjustment
|
||||||
|
const seasonalAdjustment = Math.sin((dayOfYear - 81) / 365 * 2 * Math.PI) * 3;
|
||||||
|
|
||||||
|
// Latitude adjustment (higher latitudes have more extreme day lengths)
|
||||||
|
const latitudeAdjustment = Math.abs(latitude) / 90 * 2;
|
||||||
|
|
||||||
|
// Apply adjustments
|
||||||
|
baseSunrise += seasonalAdjustment * latitudeAdjustment * -1;
|
||||||
|
baseSunset += seasonalAdjustment * latitudeAdjustment;
|
||||||
|
|
||||||
|
// Create Date objects
|
||||||
|
const sunrise = new Date(date);
|
||||||
|
sunrise.setHours(Math.floor(baseSunrise), Math.round((baseSunrise % 1) * 60), 0, 0);
|
||||||
|
|
||||||
|
const sunset = new Date(date);
|
||||||
|
sunset.setHours(Math.floor(baseSunset), Math.round((baseSunset % 1) * 60), 0, 0);
|
||||||
|
|
||||||
|
return { sunrise, sunset };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if it's currently dark outside based on sun times
|
||||||
|
*/
|
||||||
|
isDark() {
|
||||||
|
if (!this.sunTimes) return false;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = this.sunTimes.today;
|
||||||
|
|
||||||
|
// Check if current time is after today's sunset or before today's sunrise
|
||||||
|
return now > today.sunset || now < today.sunrise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update dark mode state based on current time
|
||||||
|
*/
|
||||||
|
updateDarkModeBasedOnTime() {
|
||||||
|
const wasDarkMode = this.isDarkMode;
|
||||||
|
this.isDarkMode = this.isDark();
|
||||||
|
|
||||||
|
// If dark mode state changed, dispatch event
|
||||||
|
if (wasDarkMode !== this.isDarkMode) {
|
||||||
|
this.dispatchDarkModeEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set dark mode state manually
|
||||||
|
*/
|
||||||
|
setDarkMode(isDarkMode) {
|
||||||
|
if (this.isDarkMode !== isDarkMode) {
|
||||||
|
this.isDarkMode = isDarkMode;
|
||||||
|
this.dispatchDarkModeEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle dark mode
|
||||||
|
*/
|
||||||
|
toggleDarkMode() {
|
||||||
|
this.isDarkMode = !this.isDarkMode;
|
||||||
|
this.dispatchDarkModeEvent();
|
||||||
|
return this.isDarkMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch dark mode change event
|
||||||
|
*/
|
||||||
|
dispatchDarkModeEvent() {
|
||||||
|
const event = new CustomEvent('darkModeChanged', {
|
||||||
|
detail: {
|
||||||
|
isDarkMode: this.isDarkMode,
|
||||||
|
automatic: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
console.log('Dark mode ' + (this.isDarkMode ? 'enabled' : 'disabled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted sunrise time
|
||||||
|
*/
|
||||||
|
getSunriseTime() {
|
||||||
|
if (!this.sunTimes) return '06:45';
|
||||||
|
return this.formatTime(this.sunTimes.today.sunrise);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted sunset time
|
||||||
|
*/
|
||||||
|
getSunsetTime() {
|
||||||
|
if (!this.sunTimes) return '17:32';
|
||||||
|
return this.formatTime(this.sunTimes.today.sunset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time as HH:MM
|
||||||
|
*/
|
||||||
|
formatTime(date) {
|
||||||
|
return date.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last updated time
|
||||||
|
*/
|
||||||
|
getLastUpdatedTime() {
|
||||||
|
if (!this.lastUpdated) return 'Never';
|
||||||
|
return this.formatTime(this.lastUpdated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the WeatherManager class for use in other modules
|
||||||
|
window.WeatherManager = WeatherManager;
|
||||||
Reference in New Issue
Block a user