Initial commit: Digital signage system for transit departures, weather, and news ticker

This commit is contained in:
2025-12-31 13:53:19 +01:00
commit a0c997f7d4
21 changed files with 5320 additions and 0 deletions

58
.gitignore vendored Normal file
View 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
View 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.
![SL Transport Departures Display](https://example.com/screenshot.png)
## 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

BIN
card.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

165
clock.js Normal file
View 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
View 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">&times;</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;

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

34
package.json Normal file
View 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
View 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
View 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
View 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(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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
View 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
View 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
View 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
View 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"

View 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
View 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;