From a0c997f7d430e96c0c948869a681d47c4b9cf476 Mon Sep 17 00:00:00 2001 From: kyle Date: Wed, 31 Dec 2025 13:53:19 +0100 Subject: [PATCH] Initial commit: Digital signage system for transit departures, weather, and news ticker --- .gitignore | 58 ++ README.md | 142 ++++ card.JPG | Bin 0 -> 15752 bytes clock.js | 165 +++++ config.js | 844 ++++++++++++++++++++++++ create-deployment-package.sh | 52 ++ create-gitea-repo.ps1 | 24 + departures.js | 815 +++++++++++++++++++++++ documentation.md | 295 +++++++++ index.html | 1017 +++++++++++++++++++++++++++++ istockphoto-522585615-612x612.jpg | Bin 0 -> 95270 bytes package.json | 34 + raspberry-pi-setup.sh | 144 ++++ rss.js | 251 +++++++ server.js | 519 +++++++++++++++ setup-git-repo.ps1 | 39 ++ sites-config.json | 22 + ticker.js | 297 +++++++++ update-site-id.sh | 47 ++ update-weather-location.sh | 44 ++ weather.js | 511 +++++++++++++++ 21 files changed, 5320 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 card.JPG create mode 100644 clock.js create mode 100644 config.js create mode 100644 create-deployment-package.sh create mode 100644 create-gitea-repo.ps1 create mode 100644 departures.js create mode 100644 documentation.md create mode 100644 index.html create mode 100644 istockphoto-522585615-612x612.jpg create mode 100644 package.json create mode 100644 raspberry-pi-setup.sh create mode 100644 rss.js create mode 100644 server.js create mode 100644 setup-git-repo.ps1 create mode 100644 sites-config.json create mode 100644 ticker.js create mode 100644 update-site-id.sh create mode 100644 update-weather-location.sh create mode 100644 weather.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d3dd35 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aedd4ca --- /dev/null +++ b/README.md @@ -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 diff --git a/card.JPG b/card.JPG new file mode 100644 index 0000000000000000000000000000000000000000..32fb7eb22d36f87d2e797cc84234a629b3fa3f6c GIT binary patch literal 15752 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 literal 0 HcmV?d00001 diff --git a/clock.js b/clock.js new file mode 100644 index 0000000..7383891 --- /dev/null +++ b/clock.js @@ -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; diff --git a/config.js b/config.js new file mode 100644 index 0000000..85b09f1 --- /dev/null +++ b/config.js @@ -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 = `Background preview`; + } else { + preview.innerHTML = '
No image selected
'; + } + } + }; + + // 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 = ` + + + + `; + + 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 = ` +
+
+

Settings

+ × +
+
+
+ + +
+
+ + +
+ Sunrise: --:-- | Sunset: --:-- +
+
+
+ + +
+ + + +
+
+ ${this.config.backgroundImage ? `Background preview` : '
No image selected
'} +
+
+
+ + +
+
+ + +
+
+ +
+ ${this.generateSitesHTML()} +
+
+ +
+
+
+ +
+ ${this.generateRssFeedsHTML()} +
+
+ +
+
+
+ +
+
+ +
+ `; + + 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 '
No sites configured
'; + } + + return this.config.sites.map((site, index) => ` +
+
+ + + +
+
+ ID: + +
+
+ `).join(''); + } + + /** + * Generate HTML for RSS feeds configuration + */ + generateRssFeedsHTML() { + if (!this.config.rssFeeds || this.config.rssFeeds.length === 0) { + return '
No RSS feeds configured
'; + } + + return this.config.rssFeeds.map((feed, index) => ` +
+
+ + + +
+
+ URL: + +
+
+ `).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; diff --git a/create-deployment-package.sh b/create-deployment-package.sh new file mode 100644 index 0000000..55a3af5 --- /dev/null +++ b/create-deployment-package.sh @@ -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" diff --git a/create-gitea-repo.ps1 b/create-gitea-repo.ps1 new file mode 100644 index 0000000..7afe1d3 --- /dev/null +++ b/create-gitea-repo.ps1 @@ -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 +} diff --git a/departures.js b/departures.js new file mode 100644 index 0000000..1ffb4b4 --- /dev/null +++ b/departures.js @@ -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 icons for different transport modes + const icons = { + bus: '', + metro: '', + train: '', + tram: '', + ship: '' + }; + + 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 = ` +
+ + ${transportIcon} + ${departure.line.designation} + ${departure.destination} + + + ${scheduledTime} + (${countdownText}) + +
+ `; + + 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} ${mainDestination}`; + 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 = `${directionArrow} ${direction.destination}`; + 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} (${countdownText})`; + 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} ${group.destination}`; + 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} (${countdownText})`; + } else { + timeElement.innerHTML = `${scheduledTime} (${displayTime})`; + } + + 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 = '
No departures found
'; + 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 = `${site.siteName || siteConfig.name}`; + 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} ${directionGroup.destination}`; + + // 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} ${directionGroup.destination}`; + + // 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 = ` +
+

Failed to load departures. Please try again later.

+

Error: ${error.message}

+

Make sure the Node.js server is running: node server.js

+
+ `; + } +} + +// 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(); +}); diff --git a/documentation.md b/documentation.md new file mode 100644 index 0000000..b52f1b5 --- /dev/null +++ b/documentation.md @@ -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. diff --git a/index.html b/index.html new file mode 100644 index 0000000..20c86bf --- /dev/null +++ b/index.html @@ -0,0 +1,1017 @@ + + + + + + SL Transport Departures - Ambassaderna (1411) + + + + + + + + + + + + + + +
+ +
+ + + +
Loading departures...
+ +
+ +
+ + +
+ +
+
+
+

Stockholm

+
+ Clear +
Clear
+
+
+
7.1 °C
+
+
+
+
Now
+
+ Clear +
+
7.1 °C
+
+
+
19:00
+
+ Clear +
+
6.8 °C
+
+
+
20:00
+
+ Clear +
+
6.5 °C
+
+
+
21:00
+
+ Partly cloudy +
+
6.2 °C
+
+
+
22:00
+
+ Partly cloudy +
+
6.0 °C
+
+
+
23:00
+
+ Partly cloudy +
+
5.8 °C
+
+
+
00:00
+
+ Partly cloudy +
+
5.5 °C
+
+
+
+ Sunrise: 06:45 AM | Sunset: 05:32 PM +
+
+
+ +
+
+ + + + + diff --git a/istockphoto-522585615-612x612.jpg b/istockphoto-522585615-612x612.jpg new file mode 100644 index 0000000000000000000000000000000000000000..04a119f9c2decc7bf97a3a96c54c167ff5c7ca21 GIT binary patch literal 95270 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

R;nBS6=}fI@uZ>Qm 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..8de3a11 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/raspberry-pi-setup.sh b/raspberry-pi-setup.sh new file mode 100644 index 0000000..6a7472f --- /dev/null +++ b/raspberry-pi-setup.sh @@ -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" diff --git a/rss.js b/rss.js new file mode 100644 index 0000000..ec0aa7c --- /dev/null +++ b/rss.js @@ -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; diff --git a/server.js b/server.js new file mode 100644 index 0000000..b407ba1 --- /dev/null +++ b/server.js @@ -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(' data.includes(' data.includes('/gs, '$1'); + + // Remove HTML tags + content = content.replace(/<[^>]+>/g, ' '); + + // Convert common HTML entities + content = content + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec)) + .replace(/&#x([A-Fa-f0-9]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16))); + + // Remove extra whitespace + content = content.replace(/\s+/g, ' ').trim(); + + // Get first sentence if content is too long + if (content.length > 200) { + const match = content.match(/^.*?[.!?](?:\s|$)/); + if (match) { + content = match[0].trim(); + } else { + content = content.substring(0, 197) + '...'; + } + } + + return content; +} + +// Function to extract content based on template +function extractContent(content, template) { + const itemRegex = new RegExp(`<${template.itemPath}>([\\\s\\\S]*?)<\/${template.itemPath}>`, 'g'); + const titleRegex = new RegExp(`<${template.titlePath}>([\\\s\\\S]*?)<\/${template.titlePath}>`); + const descRegex = new RegExp(`<${template.descPath}>([\\\s\\\S]*?)<\/${template.descPath}>`); + const dateRegex = new RegExp(`<${template.datePath}>([\\\s\\\S]*?)<\/${template.datePath}>`); + const linkRegex = /]*?>([\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}/`); +}); diff --git a/setup-git-repo.ps1 b/setup-git-repo.ps1 new file mode 100644 index 0000000..41217e1 --- /dev/null +++ b/setup-git-repo.ps1 @@ -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" diff --git a/sites-config.json b/sites-config.json new file mode 100644 index 0000000..3f159ce --- /dev/null +++ b/sites-config.json @@ -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 +} \ No newline at end of file diff --git a/ticker.js b/ticker.js new file mode 100644 index 0000000..ebe83bc --- /dev/null +++ b/ticker.js @@ -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 = '

Loading news...
'; + } + + // 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; diff --git a/update-site-id.sh b/update-site-id.sh new file mode 100644 index 0000000..fdc4a9a --- /dev/null +++ b/update-site-id.sh @@ -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|

.*

|

$SITE_NAME (Site ID: $SITE_ID)

|g" index.html +fi + +# Update title in index.html +if [ ! -z "$SITE_NAME" ]; then + echo "Updating page title in index.html" + sed -i "s|SL Transport Departures - .*|SL Transport Departures - $SITE_NAME ($SITE_ID)|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" diff --git a/update-weather-location.sh b/update-weather-location.sh new file mode 100644 index 0000000..ff48f4e --- /dev/null +++ b/update-weather-location.sh @@ -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>/

$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/" diff --git a/weather.js b/weather.js new file mode 100644 index 0000000..afe8233 --- /dev/null +++ b/weather.js @@ -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 = ` +
Now
+
+ ${this.weatherData.description} +
+
${this.weatherData.temperature} °C
+ `; + 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 = ` +
${timeString}
+
+ ${forecast.description} +
+
${forecast.temperature} °C
+ `; + 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;