Optimize landscape layout: 4-column grid, transport icons, improved sizing and spacing

This commit is contained in:
2025-12-31 16:27:55 +01:00
parent a0c997f7d4
commit 738a422dc9
14 changed files with 2173 additions and 1629 deletions

View File

@@ -0,0 +1,136 @@
# SL Transport API Response Structure
## Overview
The SL Transport API returns departure information for a specific stop/site. The response is a JSON object containing an array of departure objects.
## Response Structure
```json
{
"departures": [
{
"destination": "T-Centralen",
"direction_code": 2,
"direction": "T-Centralen",
"state": "EXPECTED",
"display": "Nu",
"scheduled": "2025-12-31T15:16:00",
"expected": "2025-12-31T15:24:52",
"journey": {
"id": "...",
"direction": "...",
// other journey fields
},
"line": {
"designation": "7",
"transportMode": "TRAM",
// other line fields
}
// potentially other fields
}
]
}
```
## Field Explanations
### Top-Level Fields
| Field | Type | Description | How We Use It |
|-------|------|-------------|---------------|
| `departures` | Array | List of departure objects | We iterate through this array to display all departures |
### Departure Object Fields
| Field | Type | Description | How We Use It |
|-------|------|-------------|---------------|
| `destination` | String | Final destination name (e.g., "T-Centralen", "Djurgårdsbrunn") | **Displayed as destination name** on the board |
| `direction_code` | Number | Direction indicator: `1` = going TO that direction, `2` = going FROM that direction | **Used to determine arrow direction** (1 = left ←, 2 = right →) |
| `direction` | String | Direction text (usually same as destination) | Available but we use `direction_code` instead |
| `state` | String | Departure state: `"EXPECTED"`, `"ATSTOP"`, etc. | Not currently used, but could indicate if bus is at stop |
| `display` | String | Human-readable time display: `"Nu"`, `"1 min"`, `"2 min"`, `"5 min"`, or time like `"15:23"` | **Used for countdown display** - we also calculate from `scheduled` |
| `scheduled` | ISO DateTime String | Original scheduled departure time (e.g., `"2025-12-31T15:16:00"`) | **Used to calculate minutes until arrival** and format time display |
| `expected` | ISO DateTime String | Expected/actual departure time (may differ from scheduled) | Available but we use `scheduled` for consistency |
| `journey` | Object | Journey information | We use `journey.id` to track unique departures |
| `journey.id` | String | Unique journey identifier | **Used as unique key** for tracking departures |
| `line` | Object | Line/route information | Contains line number and transport type |
| `line.designation` | String | Line number (e.g., `"7"`, `"69"`, `"76"`) | **Displayed as the bus/tram number** |
| `line.transportMode` | String | Transport type: `"BUS"`, `"TRAM"`, `"METRO"`, `"TRAIN"`, `"SHIP"` | **Used to determine icon and styling** |
## Current Usage in Code
### ✅ Correctly Used Fields
1. **`destination`** - Displayed as destination name
2. **`direction_code`** - Used to determine arrow direction (1 = left, 2 = right)
3. **`display`** - Used for countdown text (but we also calculate from `scheduled`)
4. **`scheduled`** - Used to calculate minutes until arrival and determine color (red < 5 min, white >= 5 min)
5. **`journey.id`** - Used as unique identifier for tracking departures
6. **`line.designation`** - Displayed as line number
7. **`line.transportMode`** - Used to determine transport icon and label
### ⚠️ Available But Not Used
1. **`expected`** - Could be used instead of `scheduled` for more accurate times
2. **`state`** - Could be used to show special status (e.g., "ATSTOP" = bus is at stop now)
3. **`direction`** - Text version of direction (we use `direction_code` instead)
## Recommendations
### Current Implementation is Correct ✅
Your current implementation is using the right fields:
- **`scheduled`** is the correct field to use for time calculations (more reliable than `expected`)
- **`direction_code`** is the correct field for arrow direction
- **`display`** is good for showing the API's formatted time, but calculating from `scheduled` ensures consistency
### Potential Enhancements
1. **Use `state` field**: Could highlight when `state === "ATSTOP"` to show bus is currently at the stop
2. **Fallback to `expected`**: If `expected` differs significantly from `scheduled`, could show both times
3. **Use `direction` text**: Could display alongside destination for clarity
## Example API Response
```json
{
"departures": [
{
"destination": "T-Centralen",
"direction_code": 2,
"direction": "T-Centralen",
"state": "EXPECTED",
"display": "Nu",
"scheduled": "2025-12-31T15:16:00",
"expected": "2025-12-31T15:24:52",
"journey": {
"id": "some-unique-id"
},
"line": {
"designation": "7",
"transportMode": "TRAM"
}
},
{
"destination": "Djurgårdsbrunn",
"direction_code": 1,
"direction": "Djurgårdsbrunn",
"state": "EXPECTED",
"display": "1 min",
"scheduled": "2025-12-31T15:28:38",
"expected": "2025-12-31T15:29:00",
"journey": {
"id": "another-unique-id"
},
"line": {
"designation": "69",
"transportMode": "BUS"
}
}
]
}
```
## Summary
**You are using the correct data!** The fields you're using (`destination`, `direction_code`, `display`, `scheduled`, `line.designation`, `line.transportMode`, `journey.id`) are the most important and reliable fields from the API response.

View File

@@ -1,6 +1,6 @@
# SL Transport Departures Display # SL Transport Departures Display
A digital signage system for displaying transit departures, weather information, and news tickers. Perfect for Raspberry Pi-based information displays. A digital signage system for displaying transit departures and weather information. Perfect for Raspberry Pi-based information displays.
![SL Transport Departures Display](https://example.com/screenshot.png) ![SL Transport Departures Display](https://example.com/screenshot.png)
@@ -8,11 +8,9 @@ A digital signage system for displaying transit departures, weather information,
- Real-time transit departure information - Real-time transit departure information
- Current weather and hourly forecast - Current weather and hourly forecast
- News ticker with RSS feed integration
- Multiple screen orientation support (0°, 90°, 180°, 270°) - Multiple screen orientation support (0°, 90°, 180°, 270°)
- Dark mode with automatic switching based on sunrise/sunset - Dark mode with automatic switching based on sunrise/sunset
- Custom background image support - Custom background image support
- Configurable ticker speed
- Responsive design for various screen sizes - Responsive design for various screen sizes
## Quick Start ## Quick Start
@@ -75,14 +73,6 @@ window.weatherManager = new WeatherManager({
}); });
``` ```
### 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 ## UI Settings
The gear icon in the top-right corner opens the settings panel where you can configure: The gear icon in the top-right corner opens the settings panel where you can configure:
@@ -90,7 +80,7 @@ The gear icon in the top-right corner opens the settings panel where you can con
- Screen orientation - Screen orientation
- Dark mode (auto/on/off) - Dark mode (auto/on/off)
- Background image and opacity - Background image and opacity
- Ticker speed - Transit sites
Settings are saved to localStorage and persist across sessions. Settings are saved to localStorage and persist across sessions.
@@ -102,7 +92,7 @@ The system consists of the following components:
2. **Configuration Manager** - Manages system settings and UI customization 2. **Configuration Manager** - Manages system settings and UI customization
3. **Weather Component** - Displays weather data and manages dark mode 3. **Weather Component** - Displays weather data and manages dark mode
4. **Clock Component** - Shows current time and date 4. **Clock Component** - Shows current time and date
5. **Ticker Component** - Displays scrolling news from RSS feeds 5. **Departures Component** - Displays transit departure information
6. **Main UI** - Responsive layout with multiple orientation support 6. **Main UI** - Responsive layout with multiple orientation support
## Documentation ## Documentation
@@ -127,10 +117,6 @@ For detailed documentation, see [documentation.md](documentation.md).
- Verify internet connection - Verify internet connection
- Look for errors in browser console - 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 ## License

View File

@@ -82,10 +82,10 @@ class Clock {
// Format and display the time // Format and display the time
this.timeElement.innerHTML = this.formatTime(now); this.timeElement.innerHTML = this.formatTime(now);
// Format and display the date // Format and display the date (with a separator)
this.dateElement.textContent = " " + this.formatDate(now); this.dateElement.textContent = " " + this.formatDate(now);
// Make sure the date element is visible // Make sure the date element is visible and inline
this.dateElement.style.display = 'inline-block'; this.dateElement.style.display = 'inline-block';
} }

623
config.js
View File

@@ -35,7 +35,6 @@ class ConfigManager {
darkMode: this.options.defaultDarkMode, darkMode: this.options.defaultDarkMode,
backgroundImage: '', backgroundImage: '',
backgroundOpacity: 0.3, // Default opacity (30%) backgroundOpacity: 0.3, // Default opacity (30%)
tickerSpeed: 60, // Default ticker speed in seconds
sites: [ sites: [
{ {
id: '1411', id: '1411',
@@ -43,13 +42,6 @@ class ConfigManager {
enabled: true 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 combineSameDirection: true, // Combine departures going in the same direction
...this.loadConfig() // Load saved config if available ...this.loadConfig() // Load saved config if available
}; };
@@ -102,7 +94,15 @@ class ConfigManager {
<h2>Settings</h2> <h2>Settings</h2>
<span class="config-modal-close">&times;</span> <span class="config-modal-close">&times;</span>
</div> </div>
<div class="config-tabs">
<button class="config-tab active" data-tab="display">Display</button>
<button class="config-tab" data-tab="appearance">Appearance</button>
<button class="config-tab" data-tab="content">Content</button>
<button class="config-tab" data-tab="options">Options</button>
</div>
<div class="config-modal-body"> <div class="config-modal-body">
<!-- Display Tab -->
<div class="config-tab-content active" id="tab-display">
<div class="config-option"> <div class="config-option">
<label for="orientation-select">Screen Orientation:</label> <label for="orientation-select">Screen Orientation:</label>
<select id="orientation-select"> <select id="orientation-select">
@@ -124,6 +124,10 @@ class ConfigManager {
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small> <small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
</div> </div>
</div> </div>
</div>
<!-- Appearance Tab -->
<div class="config-tab-content" id="tab-appearance">
<div class="config-option"> <div class="config-option">
<label for="background-image-url">Background Image:</label> <label for="background-image-url">Background Image:</label>
<input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}"> <input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}">
@@ -142,28 +146,31 @@ class ConfigManager {
<label for="background-opacity">Background Opacity: <span id="opacity-value">${Math.round(this.config.backgroundOpacity * 100)}%</span></label> <label for="background-opacity">Background Opacity: <span id="opacity-value">${Math.round(this.config.backgroundOpacity * 100)}%</span></label>
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="${this.config.backgroundOpacity}"> <input type="range" id="background-opacity" min="0" max="1" step="0.05" value="${this.config.backgroundOpacity}">
</div> </div>
<div class="config-option">
<label for="ticker-speed">Ticker Speed: <span id="ticker-speed-value">${this.config.tickerSpeed}</span> seconds</label>
<input type="range" id="ticker-speed" min="10" max="120" step="5" value="${this.config.tickerSpeed}">
</div> </div>
<!-- Content Tab -->
<div class="config-tab-content" id="tab-content">
<div class="config-option"> <div class="config-option">
<label>Transit Sites:</label> <label>Transit Sites:</label>
<div style="margin-bottom: 15px;">
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" id="site-search-input" placeholder="Search for transit stop..." style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<button id="search-site-button" style="padding: 8px 15px; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer;">Search</button>
<button id="select-from-map-button" style="padding: 8px 15px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Select from Map</button>
</div>
<div id="site-search-results" style="display: none; max-height: 200px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: white; margin-top: 5px;"></div>
</div>
<div id="sites-container"> <div id="sites-container">
${this.generateSitesHTML()} ${this.generateSitesHTML()}
</div> </div>
<div style="margin-top: 10px;"> <div style="margin-top: 10px;">
<button id="add-site-button" style="padding: 5px 10px;">Add Site</button> <button id="add-site-button" style="padding: 5px 10px;">Add Site Manually</button>
</div> </div>
</div> </div>
<div class="config-option">
<label>RSS Feeds:</label>
<div id="rss-feeds-container">
${this.generateRssFeedsHTML()}
</div>
<div style="margin-top: 10px;">
<button id="add-feed-button" style="padding: 5px 10px;">Add Feed</button>
</div>
</div> </div>
<!-- Options Tab -->
<div class="config-tab-content" id="tab-options">
<div class="config-option"> <div class="config-option">
<label for="combine-directions"> <label for="combine-directions">
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}> <input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
@@ -171,6 +178,7 @@ class ConfigManager {
</label> </label>
</div> </div>
</div> </div>
</div>
<div class="config-modal-footer"> <div class="config-modal-footer">
<button id="config-save-button">Save</button> <button id="config-save-button">Save</button>
<button id="config-cancel-button">Cancel</button> <button id="config-cancel-button">Cancel</button>
@@ -180,6 +188,9 @@ class ConfigManager {
document.body.appendChild(modalContainer); document.body.appendChild(modalContainer);
// Add tab switching functionality
this.setupTabs(modalContainer);
// Add event listeners // Add event listeners
modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal()); modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal());
modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal()); modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal());
@@ -196,10 +207,22 @@ class ConfigManager {
addSiteButton.addEventListener('click', () => this.addNewSite()); addSiteButton.addEventListener('click', () => this.addNewSite());
} }
// Add event listener for add feed button // Add event listeners for site search
const addFeedButton = modalContainer.querySelector('#add-feed-button'); const searchSiteButton = modalContainer.querySelector('#search-site-button');
if (addFeedButton) { const siteSearchInput = modalContainer.querySelector('#site-search-input');
addFeedButton.addEventListener('click', () => this.addNewFeed()); if (searchSiteButton && siteSearchInput) {
searchSiteButton.addEventListener('click', () => this.searchSites());
siteSearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.searchSites();
}
});
}
// Add event listener for map selection
const selectFromMapButton = modalContainer.querySelector('#select-from-map-button');
if (selectFromMapButton) {
selectFromMapButton.addEventListener('click', () => this.showMapSelector());
} }
// Add event listener for local image selection // Add event listener for local image selection
@@ -226,9 +249,8 @@ class ConfigManager {
} }
}); });
// Add event listeners for site and feed management // Add event listeners for site management
this.setupSiteEventListeners(); this.setupSiteEventListeners();
this.setupFeedEventListeners();
} }
/** /**
@@ -250,25 +272,6 @@ class ConfigManager {
}); });
} }
/**
* 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 * Add a new site to the configuration
*/ */
@@ -286,23 +289,6 @@ class ConfigManager {
} }
} }
/**
* 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 * Remove a site from the configuration
*/ */
@@ -319,18 +305,28 @@ class ConfigManager {
} }
/** /**
* Remove an RSS feed from the configuration * Setup tab switching functionality
*/ */
removeFeed(index) { setupTabs(modalContainer) {
if (index >= 0 && index < this.config.rssFeeds.length) { const tabs = modalContainer.querySelectorAll('.config-tab');
this.config.rssFeeds.splice(index, 1); const tabContents = modalContainer.querySelectorAll('.config-tab-content');
const feedsContainer = document.getElementById('rss-feeds-container'); tabs.forEach(tab => {
if (feedsContainer) { tab.addEventListener('click', () => {
feedsContainer.innerHTML = this.generateRssFeedsHTML(); const targetTab = tab.dataset.tab;
this.setupFeedEventListeners();
} // Remove active class from all tabs and contents
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked tab and corresponding content
tab.classList.add('active');
const targetContent = modalContainer.querySelector(`#tab-${targetTab}`);
if (targetContent) {
targetContent.classList.add('active');
} }
});
});
} }
/** /**
@@ -360,6 +356,18 @@ class ConfigManager {
const modal = document.getElementById(this.options.configModalId); const modal = document.getElementById(this.options.configModalId);
modal.style.display = 'flex'; modal.style.display = 'flex';
// Reset to first tab
const tabs = modal.querySelectorAll('.config-tab');
const tabContents = modal.querySelectorAll('.config-tab-content');
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
if (tabs.length > 0) {
tabs[0].classList.add('active');
}
if (tabContents.length > 0) {
tabContents[0].classList.add('active');
}
// Update form values to match current config // Update form values to match current config
document.getElementById('orientation-select').value = this.config.orientation; document.getElementById('orientation-select').value = this.config.orientation;
document.getElementById('dark-mode-select').value = this.config.darkMode; document.getElementById('dark-mode-select').value = this.config.darkMode;
@@ -372,19 +380,10 @@ class ConfigManager {
this.setupSiteEventListeners(); this.setupSiteEventListeners();
} }
// Update RSS feeds container // Update background image and opacity values
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 backgroundImageUrl = document.getElementById('background-image-url');
const backgroundOpacity = document.getElementById('background-opacity'); const backgroundOpacity = document.getElementById('background-opacity');
const opacityValue = document.getElementById('opacity-value'); const opacityValue = document.getElementById('opacity-value');
const tickerSpeed = document.getElementById('ticker-speed');
const tickerSpeedValue = document.getElementById('ticker-speed-value');
if (backgroundImageUrl) { if (backgroundImageUrl) {
backgroundImageUrl.value = this.config.backgroundImage || ''; backgroundImageUrl.value = this.config.backgroundImage || '';
@@ -398,14 +397,6 @@ class ConfigManager {
opacityValue.textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`; 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 // Update background preview
this.updateBackgroundPreview(); this.updateBackgroundPreview();
@@ -438,16 +429,6 @@ class ConfigManager {
}); });
} }
// 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 // Local image selection
const localImageInput = document.getElementById('local-image-input'); const localImageInput = document.getElementById('local-image-input');
if (localImageInput) { if (localImageInput) {
@@ -477,12 +458,10 @@ class ConfigManager {
const darkModeSelect = document.getElementById('dark-mode-select'); const darkModeSelect = document.getElementById('dark-mode-select');
const backgroundImageUrl = document.getElementById('background-image-url'); const backgroundImageUrl = document.getElementById('background-image-url');
const backgroundOpacity = document.getElementById('background-opacity'); const backgroundOpacity = document.getElementById('background-opacity');
const tickerSpeed = document.getElementById('ticker-speed');
const combineDirections = document.getElementById('combine-directions'); const combineDirections = document.getElementById('combine-directions');
// Get sites and feeds configuration from form // Get sites configuration from form
const sitesConfig = this.getSitesFromForm(); const sitesConfig = this.getSitesFromForm();
const feedsConfig = this.getRssFeedsFromForm();
// Get the background image URL directly from the DOM // Get the background image URL directly from the DOM
const imageUrlValue = document.querySelector('#background-image-url').value; const imageUrlValue = document.querySelector('#background-image-url').value;
@@ -492,10 +471,8 @@ class ConfigManager {
this.config.darkMode = darkModeSelect.value; this.config.darkMode = darkModeSelect.value;
this.config.backgroundImage = imageUrlValue; this.config.backgroundImage = imageUrlValue;
this.config.backgroundOpacity = parseFloat(backgroundOpacity.value); this.config.backgroundOpacity = parseFloat(backgroundOpacity.value);
this.config.tickerSpeed = parseInt(tickerSpeed.value);
this.config.combineSameDirection = combineDirections.checked; this.config.combineSameDirection = combineDirections.checked;
this.config.sites = sitesConfig; this.config.sites = sitesConfig;
this.config.rssFeeds = feedsConfig;
// Save config // Save config
this.saveConfig(); this.saveConfig();
@@ -503,11 +480,6 @@ class ConfigManager {
// Sync with server // Sync with server
this.syncConfig(); this.syncConfig();
// Update ticker speed if changed
if (window.tickerManager && this.config.tickerSpeed) {
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
}
// Apply config // Apply config
this.applyConfig(); this.applyConfig();
@@ -532,9 +504,6 @@ class ConfigManager {
// Apply background image and opacity // Apply background image and opacity
this.applyBackgroundImage(); this.applyBackgroundImage();
// Apply ticker speed
this.applyTickerSpeed();
// Ensure content wrapper exists when changing to rotated modes // Ensure content wrapper exists when changing to rotated modes
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(this.config.orientation)) { if (['vertical', 'upsidedown', 'vertical-reverse'].includes(this.config.orientation)) {
this.ensureContentWrapper(); this.ensureContentWrapper();
@@ -612,15 +581,6 @@ class ConfigManager {
} }
} }
/**
* Apply ticker speed setting
*/
applyTickerSpeed() {
if (window.tickerManager) {
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
}
}
/** /**
* Update the sun times display in the config modal * Update the sun times display in the config modal
*/ */
@@ -668,26 +628,401 @@ class ConfigManager {
} }
/** /**
* Generate HTML for RSS feeds configuration * Search for transit sites
*/ */
generateRssFeedsHTML() { async searchSites() {
if (!this.config.rssFeeds || this.config.rssFeeds.length === 0) { const searchInput = document.getElementById('site-search-input');
return '<div class="no-feeds">No RSS feeds configured</div>'; const resultsContainer = document.getElementById('site-search-results');
if (!searchInput || !resultsContainer) return;
const query = searchInput.value.trim();
if (!query || query.length < 2) {
resultsContainer.style.display = 'none';
return;
} }
return this.config.rssFeeds.map((feed, index) => ` try {
<div class="feed-item" data-index="${index}"> resultsContainer.style.display = 'block';
<div style="display: flex; align-items: center; margin-bottom: 5px;"> resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">Searching...</div>';
<input type="checkbox" class="feed-enabled" ${feed.enabled ? 'checked' : ''}>
<input type="text" class="feed-name" value="${feed.name}" placeholder="Feed Name" style="flex: 1; margin: 0 5px;"> const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
<button class="remove-feed-button" style="padding: 2px 5px;">×</button>
</div> if (!response.ok) {
<div style="display: flex; align-items: center;"> const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
<span style="margin-right: 5px;">URL:</span> throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
<input type="text" class="feed-url" value="${feed.url}" placeholder="Feed URL" style="flex: 1;"> }
</div>
const data = await response.json();
if (!data.sites || data.sites.length === 0) {
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">No sites found. Try a different search term.</div>';
return;
}
resultsContainer.innerHTML = data.sites.map(site => `
<div class="site-search-result" style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s;"
data-site-id="${site.id}" data-site-name="${site.name}">
<div style="font-weight: bold; color: #0061a1;">${site.name}</div>
<div style="font-size: 0.85em; color: #666;">ID: ${site.id}</div>
</div> </div>
`).join(''); `).join('');
// Add click handlers to search results
resultsContainer.querySelectorAll('.site-search-result').forEach(result => {
result.addEventListener('click', () => {
const siteId = result.dataset.siteId;
const siteName = result.dataset.siteName;
this.addSiteFromSearch(siteId, siteName);
searchInput.value = '';
resultsContainer.style.display = 'none';
});
result.addEventListener('mouseenter', () => {
result.style.backgroundColor = '#f5f5f5';
});
result.addEventListener('mouseleave', () => {
result.style.backgroundColor = 'white';
});
});
} catch (error) {
console.error('Error searching sites:', error);
let errorMessage = error.message || 'Unknown error';
// Provide more helpful error messages
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
errorMessage = 'Unable to connect to server. Please make sure the server is running.';
} else if (error.message.includes('HTTP')) {
errorMessage = `Server error: ${error.message}`;
}
resultsContainer.innerHTML = `<div style="padding: 10px; text-align: center; color: #d32f2f;">Error: ${errorMessage}</div>`;
}
}
/**
* Add a site from search results
*/
addSiteFromSearch(siteId, siteName) {
// Check if site already exists
if (this.config.sites.some(site => site.id === siteId)) {
alert(`Site "${siteName}" (ID: ${siteId}) is already in your list.`);
return;
}
this.config.sites.push({
id: siteId,
name: siteName,
enabled: true
});
const sitesContainer = document.getElementById('sites-container');
if (sitesContainer) {
sitesContainer.innerHTML = this.generateSitesHTML();
this.setupSiteEventListeners();
}
}
/**
* Show map selector for choosing transit sites
*/
async showMapSelector() {
// Create map modal
const mapModal = document.createElement('div');
mapModal.id = 'map-selector-modal';
mapModal.className = 'config-modal';
mapModal.style.display = 'flex';
mapModal.innerHTML = `
<div class="config-modal-content" style="max-width: 90vw; max-height: 90vh; width: 1200px;">
<div class="config-modal-header">
<h2>Select Transit Stop from Map</h2>
<span class="map-modal-close">&times;</span>
</div>
<div style="padding: 20px;">
<div style="margin-bottom: 15px;">
<input type="text" id="map-search-input" placeholder="Search area or zoom to location..." style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div id="map-container" style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;"></div>
<div style="margin-top: 15px; padding: 10px; background-color: #f0f0f0; border-radius: 4px;">
<strong>Instructions:</strong> Click on a marker to select a transit stop. Zoom and pan to explore the map.
</div>
</div>
<div class="config-modal-footer">
<button id="map-close-button">Close</button>
</div>
</div>
`;
document.body.appendChild(mapModal);
// Wait for DOM to be ready
await new Promise(resolve => setTimeout(resolve, 100));
// Initialize map centered on Stockholm
const map = L.map('map-container').setView([59.3293, 18.0686], 13);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Close handlers
const closeMap = () => {
map.remove();
document.body.removeChild(mapModal);
};
mapModal.querySelector('.map-modal-close').addEventListener('click', closeMap);
mapModal.querySelector('#map-close-button').addEventListener('click', closeMap);
mapModal.addEventListener('click', (e) => {
if (e.target === mapModal) closeMap();
});
// Load transit stops - search for common Stockholm areas
const loadSitesOnMap = async () => {
try {
// Start with focused search to avoid too many markers
const searchTerms = ['Ambassaderna'];
const allSites = new Map();
for (const term of searchTerms) {
try {
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(term)}`);
const data = await response.json();
console.log(`Search "${term}" returned:`, data);
if (data.sites) {
data.sites.forEach(site => {
// Only add sites with valid coordinates from API
const lat = site.lat || site.latitude;
const lon = site.lon || site.longitude;
if (lat && lon && !isNaN(parseFloat(lat)) && !isNaN(parseFloat(lon))) {
if (!allSites.has(site.id)) {
allSites.set(site.id, {
id: site.id,
name: site.name,
lat: parseFloat(lat),
lon: parseFloat(lon)
});
}
} else {
console.log(`Site ${site.id} (${site.name}) missing coordinates, skipping`);
}
});
}
} catch (err) {
console.error(`Error searching for ${term}:`, err);
}
}
// Add known site with coordinates as fallback
const knownSites = [
{ id: '1411', name: 'Ambassaderna', lat: 59.3293, lon: 18.0686 }
];
knownSites.forEach(site => {
if (!allSites.has(site.id)) {
allSites.set(site.id, site);
}
});
const sitesArray = Array.from(allSites.values());
console.log(`Loading ${sitesArray.length} sites on map with coordinates:`, sitesArray);
if (sitesArray.length > 0) {
const markers = [];
sitesArray.forEach(site => {
const lat = site.lat;
const lon = site.lon;
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
console.warn(`Invalid coordinates for site ${site.id}, skipping`);
return;
}
// Create custom icon
const customIcon = L.divIcon({
className: 'custom-marker',
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);">🚌</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([lat, lon], { icon: customIcon }).addTo(map);
const popupContent = `
<div style="min-width: 200px;">
<strong style="font-size: 1.1em; color: #0061a1;">${site.name}</strong><br>
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span><br>
<button class="select-site-from-map" data-site-id="${site.id}" data-site-name="${site.name}"
style="margin-top: 8px; padding: 8px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
Select This Stop
</button>
</div>
`;
marker.bindPopup(popupContent);
markers.push(marker);
// Make marker clickable to open popup
marker.on('click', function() {
this.openPopup();
});
});
// Fit map to show all markers, or center on first marker
if (markers.length > 0) {
if (markers.length === 1) {
map.setView(markers[0].getLatLng(), 15);
} else {
const group = new L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.1));
}
} else {
// If no markers, show message
console.log('No sites with coordinates found');
}
}
} catch (error) {
console.error('Error loading sites on map:', error);
}
};
// Load sites after map is initialized
setTimeout(() => {
loadSitesOnMap();
}, 500);
// Handle site selection from map popup - use event delegation on the modal
mapModal.addEventListener('click', (e) => {
if (e.target.classList.contains('select-site-from-map')) {
e.preventDefault();
e.stopPropagation();
const siteId = e.target.dataset.siteId;
const siteName = e.target.dataset.siteName;
if (siteId && siteName) {
console.log(`Selecting site: ${siteName} (ID: ${siteId})`);
this.addSiteFromSearch(siteId, siteName);
closeMap();
}
}
});
// Also handle popupopen event to ensure buttons are clickable
map.on('popupopen', (e) => {
const popup = e.popup;
const content = popup.getElement();
if (content) {
const selectBtn = content.querySelector('.select-site-from-map');
if (selectBtn) {
selectBtn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const siteId = selectBtn.dataset.siteId;
const siteName = selectBtn.dataset.siteName;
if (siteId && siteName) {
console.log(`Selecting site from popup: ${siteName} (ID: ${siteId})`);
this.addSiteFromSearch(siteId, siteName);
closeMap();
}
});
}
}
});
// Map search functionality
const mapSearchInput = document.getElementById('map-search-input');
if (mapSearchInput) {
mapSearchInput.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') {
const query = mapSearchInput.value.trim();
if (query.length < 2) return;
try {
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.sites && data.sites.length > 0) {
// Clear existing markers
map.eachLayer((layer) => {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
// Clear existing markers
map.eachLayer((layer) => {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
// Add markers for search results
const markers = [];
data.sites.forEach(site => {
let lat = site.lat || site.latitude;
let lon = site.lon || site.longitude;
// If no coordinates, use approximate location based on map center
if (!lat || !lon) {
const center = map.getCenter();
lat = center.lat + (Math.random() - 0.5) * 0.05;
lon = center.lon + (Math.random() - 0.5) * 0.05;
}
// Create custom icon
const customIcon = L.divIcon({
className: 'custom-transit-marker',
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.4); font-size: 18px;">🚌</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16]
});
const marker = L.marker([lat, lon], {
icon: customIcon,
title: site.name
}).addTo(map);
const popupContent = document.createElement('div');
popupContent.style.minWidth = '220px';
popupContent.innerHTML = `
<div style="margin-bottom: 8px;">
<strong style="font-size: 1.1em; color: #0061a1; display: block; margin-bottom: 4px;">${site.name}</strong>
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span>
</div>
<button class="select-site-from-map-btn"
data-site-id="${site.id}"
data-site-name="${site.name}"
style="margin-top: 8px; padding: 10px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1em;">
Select This Stop
</button>
`;
marker.bindPopup(popupContent);
markers.push(marker);
marker.on('click', function() {
this.openPopup();
});
});
// Fit map to show results
if (markers.length > 0) {
const group = new L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.1));
}
}
} catch (error) {
console.error('Error searching on map:', error);
}
}
});
}
} }
/** /**
@@ -706,22 +1041,6 @@ class ConfigManager {
}); });
} }
/**
* 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 * Sync configuration with server
*/ */

View File

@@ -1,24 +0,0 @@
$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
}

View File

@@ -1,8 +1,14 @@
// Calculate minutes until arrival // Calculate minutes until arrival using expected time (or scheduled if expected not available)
function calculateMinutesUntilArrival(scheduledTime) { function calculateMinutesUntilArrival(departure) {
const now = new Date(); const now = new Date();
const scheduled = new Date(scheduledTime); // Use expected time if available (accounts for delays), otherwise use scheduled
return Math.round((scheduled - now) / (1000 * 60)); const arrivalTime = departure.expected ? new Date(departure.expected) : new Date(departure.scheduled);
return Math.round((arrivalTime - now) / (1000 * 60));
}
// Get the time to display (expected if available, otherwise scheduled)
function getDepartureTime(departure) {
return departure.expected || departure.scheduled;
} }
// Get transport icon based on transport mode // Get transport icon based on transport mode
@@ -33,31 +39,46 @@ function createDepartureCard(departure) {
departureCard.dataset.journeyId = departure.journey.id; departureCard.dataset.journeyId = departure.journey.id;
const displayTime = departure.display; const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled); const departureTime = getDepartureTime(departure);
const timeDisplay = formatDateTime(departureTime);
// Check if departure is within the next hour // Check if departure is within the next hour
const departureTime = new Date(departure.scheduled); const departureTimeDate = new Date(departureTime);
const now = new Date(); const now = new Date();
const diffMinutes = Math.round((departureTime - now) / (1000 * 60)); const diffMinutes = Math.round((departureTimeDate - now) / (1000 * 60));
const isWithinNextHour = diffMinutes <= 60; const isWithinNextHour = diffMinutes <= 60;
// Add condensed class if within next hour // Add condensed class if within next hour
departureCard.className = isWithinNextHour ? 'departure-card condensed' : 'departure-card'; departureCard.className = isWithinNextHour ? 'departure-card condensed' : 'departure-card';
// Check if the display time is just a time (HH:MM) or a countdown // Calculate minutes until arrival using expected time (accounts for delays)
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime); const minutesUntil = calculateMinutesUntilArrival(departure);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime; let countdownText = displayTime;
if (isTimeOnly) { let countdownClass = '';
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) { // Determine color class based on minutesUntil, regardless of displayTime format
countdownText = 'Now'; if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
} else if (minutesUntil === 1) { countdownText = 'Nu';
countdownText = '1 min'; countdownClass = 'now';
} else if (minutesUntil < 5) {
// Less than 5 minutes - red
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} else { } else {
countdownText = `${minutesUntil} min`; countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} }
countdownClass = 'urgent'; // Red: less than 5 minutes
} else {
// 5+ minutes - white
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
if (isTimeOnly) {
countdownText = `${minutesUntil} min`;
} else {
// Use displayTime as-is (e.g., "5 min", "8 min")
countdownText = displayTime;
}
// No class = white (default)
} }
// Get transport icon based on transport mode and line // Get transport icon based on transport mode and line
@@ -72,8 +93,8 @@ function createDepartureCard(departure) {
<span class="line-destination">${departure.destination}</span> <span class="line-destination">${departure.destination}</span>
</span> </span>
<span class="time"> <span class="time">
<span class="arrival-time">${scheduledTime}</span> <span class="arrival-time">${timeDisplay}</span>
<span class="countdown">(${countdownText})</span> <span class="countdown ${countdownClass}">(${countdownText})</span>
</span> </span>
</div> </div>
`; `;
@@ -81,92 +102,156 @@ function createDepartureCard(departure) {
return departureCard; return departureCard;
} }
// Display departures grouped by line number // Display departures grouped by line number - New card-based layout
function displayGroupedDeparturesByLine(groups, container) { function displayGroupedDeparturesByLine(groups, container) {
groups.forEach(group => { groups.forEach(group => {
// Create a card for this line number // Create a card for this line number
const groupCard = document.createElement('div'); const groupCard = document.createElement('div');
groupCard.className = 'departure-card line-card'; groupCard.className = 'departure-card line-card';
// Create card header // Get transport mode for styling - ensure we use the API value
const header = document.createElement('div'); const apiTransportMode = group.line?.transportMode || '';
header.className = 'departure-header'; const transportMode = apiTransportMode.toLowerCase();
// Get transport icon based on transport mode and line // Create large line number box on the left
const lineNumberBox = document.createElement('div');
lineNumberBox.className = `line-number-box ${transportMode}`;
// Get transport icon instead of text label
const transportIcon = getTransportIcon(group.line?.transportMode, group.line); const transportIcon = getTransportIcon(group.line?.transportMode, group.line);
// Add line number with transport icon lineNumberBox.innerHTML = `
const lineNumber = document.createElement('span'); <div class="transport-mode-icon">${transportIcon}</div>
lineNumber.className = 'line-number'; <div class="line-number-large">${group.lineNumber}</div>
`;
groupCard.appendChild(lineNumberBox);
// Use the first destination as the main one for the header // Create directions wrapper on the right
const mainDestination = group.directions[0]?.destination || ''; const directionsWrapper = document.createElement('div');
directionsWrapper.className = 'directions-wrapper';
lineNumber.innerHTML = `${transportIcon} ${group.lineNumber} <span class="line-destination">${mainDestination}</span>`; // Process each direction (up to 2 directions side-by-side)
header.appendChild(lineNumber); const maxDirections = 2;
groupCard.appendChild(header); group.directions.slice(0, maxDirections).forEach(direction => {
// Sort departures by expected time (or scheduled if expected not available)
// Create the directions container direction.departures.sort((a, b) => {
const directionsContainer = document.createElement('div'); const timeA = getDepartureTime(a);
directionsContainer.className = 'directions-container'; const timeB = getDepartureTime(b);
return new Date(timeA) - new Date(timeB);
// 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 // Create a row for this direction
const directionRow = document.createElement('div'); const directionRow = document.createElement('div');
directionRow.className = 'direction-row'; directionRow.className = 'direction-row';
// Add direction info // Add direction info (arrow + destination)
const directionInfo = document.createElement('div'); const directionInfo = document.createElement('div');
directionInfo.className = 'direction-info'; directionInfo.className = 'direction-info';
// Determine direction arrow // Determine direction arrow and styling from API data
const directionArrow = direction.direction === 1 ? '→' : '←'; // Get direction from the first departure in this direction group
const firstDep = direction.departures[0];
if (!firstDep) return; // Skip if no departures
directionInfo.innerHTML = `<span class="direction-arrow">${directionArrow}</span> <span class="direction-destination">${direction.destination}</span>`; // Use direction_code from API: 1 = going TO that direction, 2 = going FROM that direction
// For arrows: direction_code 1 = left arrow, direction_code 2 = right arrow
const directionCode = firstDep.direction_code !== undefined ? firstDep.direction_code :
firstDep.directionCode !== undefined ? firstDep.directionCode :
null;
// Map direction_code to arrow direction
// direction_code 1 = left arrow (←), direction_code 2 = right arrow (→)
const isRight = directionCode === 2;
if (directionCode === null || directionCode === undefined) {
console.warn('No direction_code found for:', direction.destination, firstDep);
}
const arrowBox = document.createElement('div');
arrowBox.className = `direction-arrow-box ${isRight ? 'right' : 'left'}`;
arrowBox.textContent = isRight ? '→' : '←';
const destinationSpan = document.createElement('span');
destinationSpan.className = 'direction-destination';
destinationSpan.textContent = direction.destination;
directionInfo.appendChild(arrowBox);
directionInfo.appendChild(destinationSpan);
directionRow.appendChild(directionInfo); directionRow.appendChild(directionInfo);
// Add times container // Add times container
const timesContainer = document.createElement('div'); const timesContainer = document.createElement('div');
timesContainer.className = 'times-container'; timesContainer.className = 'times-container';
// Add up to 2 departure times per direction // Get first two departures for time range
const maxTimes = 2; const firstDeparture = direction.departures[0];
direction.departures.slice(0, maxTimes).forEach(departure => { const secondDeparture = direction.departures[1];
const timeElement = document.createElement('span');
timeElement.className = 'time';
const displayTime = departure.display; if (firstDeparture) {
const scheduledTime = formatDateTime(departure.scheduled); const displayTime = firstDeparture.display;
const departureTime = getDepartureTime(firstDeparture);
const timeDisplay = formatDateTime(departureTime);
// Check if the display time is just a time (HH:MM) or a countdown // Calculate minutes until arrival using expected time (accounts for delays)
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime); const minutesUntil = calculateMinutesUntilArrival(firstDeparture);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime; let countdownText = displayTime;
if (isTimeOnly) { let countdownClass = '';
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) { // Determine color class based on minutesUntil, regardless of displayTime format
countdownText = 'Now'; if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
} else if (minutesUntil === 1) { countdownText = 'Nu';
countdownText = '1 min'; countdownClass = 'now';
} else if (minutesUntil < 5) {
// Use the number from displayTime if it's "X min", otherwise use calculated minutesUntil
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = `${minMatch[1]}`;
} else { } else {
countdownText = `${minutesUntil} min`; countdownText = `${minutesUntil}`;
} }
countdownClass = 'urgent'; // Red: less than 5 minutes
} else {
// 5+ minutes - use displayTime as-is or calculate
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = `${minMatch[1]}`;
} else if (/^\d{1,2}:\d{2}$/.test(displayTime)) {
countdownText = `${minutesUntil}`;
} else {
countdownText = displayTime;
}
// No class = white (default)
} }
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${countdownText})</span>`; // Create time display element
timesContainer.appendChild(timeElement); const timeDisplayElement = document.createElement('div');
}); timeDisplayElement.className = 'time-display';
const countdownSpan = document.createElement('span');
countdownSpan.className = `countdown-large ${countdownClass}`;
countdownSpan.textContent = countdownText;
timeDisplayElement.appendChild(countdownSpan);
// Add time range (show expected times)
const timeRangeSpan = document.createElement('span');
timeRangeSpan.className = 'time-range';
if (secondDeparture) {
const secondTime = formatDateTime(getDepartureTime(secondDeparture));
timeRangeSpan.textContent = `${timeDisplay} - ${secondTime}`;
} else {
timeRangeSpan.textContent = timeDisplay;
}
timeDisplayElement.appendChild(timeRangeSpan);
timesContainer.appendChild(timeDisplayElement);
}
directionRow.appendChild(timesContainer); directionRow.appendChild(timesContainer);
directionsContainer.appendChild(directionRow); directionsWrapper.appendChild(directionRow);
}); });
groupCard.appendChild(directionsContainer); groupCard.appendChild(directionsWrapper);
// Add to container // Add to container
container.appendChild(groupCard); container.appendChild(groupCard);
@@ -176,8 +261,12 @@ function displayGroupedDeparturesByLine(groups, container) {
// Display grouped departures (legacy function) // Display grouped departures (legacy function)
function displayGroupedDepartures(groups, container) { function displayGroupedDepartures(groups, container) {
groups.forEach(group => { groups.forEach(group => {
// Sort departures by time // Sort departures by expected time (or scheduled if expected not available)
group.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled)); group.departures.sort((a, b) => {
const timeA = getDepartureTime(a);
const timeB = getDepartureTime(b);
return new Date(timeA) - new Date(timeB);
});
// Create a card for this group // Create a card for this group
const groupCard = document.createElement('div'); const groupCard = document.createElement('div');
@@ -212,29 +301,40 @@ function displayGroupedDepartures(groups, container) {
timeElement.style.marginBottom = '2px'; timeElement.style.marginBottom = '2px';
const displayTime = departure.display; const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled); const departureTime = getDepartureTime(departure);
const timeDisplay = formatDateTime(departureTime);
// Check if the display time is just a time (HH:MM) or a countdown // Calculate minutes until arrival using expected time (accounts for delays)
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime); const minutesUntil = calculateMinutesUntilArrival(departure);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime; let countdownText = displayTime;
if (isTimeOnly) { let countdownClass = '';
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) { // Determine color class based on minutesUntil, regardless of displayTime format
countdownText = 'Now'; if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
} else if (minutesUntil === 1) { countdownText = 'Nu';
countdownText = '1 min'; countdownClass = 'now';
} else if (minutesUntil < 5) {
// Less than 5 minutes - red
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} else { } else {
countdownText = `${minutesUntil} min`; countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} }
countdownClass = 'urgent'; // Red: less than 5 minutes
} else {
// 5+ minutes - white
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
if (isTimeOnly) {
countdownText = `${minutesUntil} min`;
} else {
// Use displayTime as-is (e.g., "5 min", "8 min")
countdownText = displayTime;
}
// No class = white (default)
} }
if (isTimeOnly) { timeElement.innerHTML = `${scheduledTime} <span class="countdown ${countdownClass}">(${countdownText})</span>`;
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${countdownText})</span>`;
} else {
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${displayTime})</span>`;
}
timesContainer.appendChild(timeElement); timesContainer.appendChild(timeElement);
}); });
@@ -271,7 +371,7 @@ function formatRelativeTime(dateTimeString) {
const diffMinutes = Math.round((departureTime - now) / (1000 * 60)); const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
if (diffMinutes <= 0) { if (diffMinutes <= 0) {
return 'Now'; return 'Nu';
} else if (diffMinutes === 1) { } else if (diffMinutes === 1) {
return 'In 1 minute'; return 'In 1 minute';
} else if (diffMinutes < 60) { } else if (diffMinutes < 60) {
@@ -301,11 +401,17 @@ function groupDeparturesByLineNumber(departures) {
}; };
} }
const directionKey = `${departure.direction}-${departure.destination}`; // Get direction_code from API: 1 = going TO that direction, 2 = going FROM that direction
const departureDirection = departure.direction_code !== undefined ? departure.direction_code :
departure.directionCode !== undefined ? departure.directionCode :
departure.direction !== undefined ? departure.direction :
1; // Default to 1 (left arrow) if not found
const directionKey = `${departureDirection}-${departure.destination}`;
if (!groups[lineNumber].directions[directionKey]) { if (!groups[lineNumber].directions[directionKey]) {
groups[lineNumber].directions[directionKey] = { groups[lineNumber].directions[directionKey] = {
direction: departure.direction, direction: departureDirection,
destination: departure.destination, destination: departure.destination,
departures: [] departures: []
}; };
@@ -433,32 +539,59 @@ function updateExistingCards(newDepartures) {
// Update only the content that has changed in an existing card // Update only the content that has changed in an existing card
function updateCardContent(card, departure) { function updateCardContent(card, departure) {
const displayTime = departure.display; const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled); const departureTime = getDepartureTime(departure);
// Check if the display time is just a time (HH:MM) or a countdown // Calculate minutes until arrival using expected time (accounts for delays)
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime); const minutesUntil = calculateMinutesUntilArrival(departure);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime; let countdownText = displayTime;
if (isTimeOnly) { let countdownClass = '';
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) { // Determine color class based on minutesUntil, regardless of displayTime format
countdownText = 'Now'; if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
} else if (minutesUntil === 1) { countdownText = 'Nu';
countdownText = '1 min'; countdownClass = 'now';
} else if (minutesUntil < 5) {
// Less than 5 minutes - red
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} else { } else {
countdownText = `${minutesUntil} min`; countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} }
countdownClass = 'urgent'; // Red: less than 5 minutes
} else {
// 5+ minutes - white
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
if (isTimeOnly) {
countdownText = `${minutesUntil} min`;
} else {
// Use displayTime as-is (e.g., "5 min", "8 min")
countdownText = displayTime;
}
// No class = white (default)
} }
// Only update the countdown time which changes frequently // Only update the countdown time which changes frequently
const countdownElement = card.querySelector('.countdown'); const countdownElement = card.querySelector('.countdown');
// Update class for "now" and "urgent" states
if (countdownElement) {
// Remove all state classes first
countdownElement.classList.remove('now', 'urgent');
// Add the appropriate class
if (countdownClass === 'now') {
countdownElement.classList.add('now');
} else if (countdownClass === 'urgent') {
countdownElement.classList.add('urgent');
}
// Update with subtle highlight effect for changes // Update with subtle highlight effect for changes
if (countdownElement && countdownElement.textContent !== `(${countdownText})`) { if (countdownElement.textContent !== `(${countdownText})`) {
countdownElement.textContent = `(${countdownText})`; countdownElement.textContent = `(${countdownText})`;
highlightElement(countdownElement); highlightElement(countdownElement);
} }
}
} }
// Add a subtle highlight effect to show updated content // Add a subtle highlight effect to show updated content
@@ -499,202 +632,11 @@ function displayMultipleSites(sites) {
// Process departures for this site // Process departures for this site
if (site.data && site.data.departures) { if (site.data && site.data.departures) {
// Group departures by line number // Group departures by line number using the existing function
const lineGroups = {}; const lineGroups = groupDeparturesByLineNumber(site.data.departures);
site.data.departures.forEach(departure => { // Use the new card-based layout function
const lineNumber = departure.line.designation; displayGroupedDeparturesByLine(lineGroups, siteContainer);
if (!lineGroups[lineNumber]) {
lineGroups[lineNumber] = [];
}
lineGroups[lineNumber].push(departure);
});
// Process each line group
Object.entries(lineGroups).forEach(([lineNumber, lineDepartures]) => {
// Create a line container for side-by-side display
const lineContainer = document.createElement('div');
lineContainer.className = 'line-container';
// Group by direction
const directionGroups = {};
lineDepartures.forEach(departure => {
const directionKey = `${departure.direction}-${departure.destination}`;
if (!directionGroups[directionKey]) {
directionGroups[directionKey] = {
direction: departure.direction,
destination: departure.destination,
departures: []
};
}
directionGroups[directionKey].departures.push(departure);
});
// Get all direction groups
const directions = Object.values(directionGroups);
// Handle single direction case (like bus 4)
if (directions.length === 1) {
// Create a full-width card for this direction
const directionGroup = directions[0];
// Sort departures by time
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Create a card for this direction
const directionCard = document.createElement('div');
directionCard.className = 'departure-card';
// Don't set width to 100% as it causes the card to stick out
// Create a simplified layout with line number and times on the same row
const cardContent = document.createElement('div');
cardContent.className = 'departure-header';
cardContent.style.display = 'flex';
cardContent.style.justifyContent = 'space-between';
cardContent.style.alignItems = 'center';
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
// Add line number with transport icon and destination
const lineNumberElement = document.createElement('span');
lineNumberElement.className = 'line-number';
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} <span class="line-destination">${directionGroup.destination}</span>`;
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
timesContainer.style.display = 'flex';
timesContainer.style.flexDirection = 'column';
timesContainer.style.alignItems = 'flex-end';
// Add up to 2 departure times
const maxTimes = 2;
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
const timeElement = document.createElement('div');
timeElement.className = 'time';
timeElement.style.fontSize = '1.1em';
timeElement.style.marginBottom = '2px';
timeElement.style.whiteSpace = 'nowrap';
timeElement.style.textAlign = 'right';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
timeElement.textContent = `${scheduledTime} (${countdownText})`;
timeElement.style.width = '140px'; // Fixed width to prevent overflow
timeElement.style.width = '140px'; // Fixed width to prevent overflow
timesContainer.appendChild(timeElement);
});
cardContent.appendChild(lineNumberElement);
cardContent.appendChild(timesContainer);
directionCard.appendChild(cardContent);
siteContainer.appendChild(directionCard);
} else {
// Create cards for each direction, with max 2 per row
// Create a new line container for every 2 directions
for (let i = 0; i < directions.length; i += 2) {
// Create a new line container for this pair of directions
const rowContainer = document.createElement('div');
rowContainer.className = 'line-container';
// Process up to 2 directions for this row
for (let j = i; j < i + 2 && j < directions.length; j++) {
const directionGroup = directions[j];
// Sort departures by time
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Create a card for this direction
const directionCard = document.createElement('div');
directionCard.className = 'departure-card direction-card';
// Create a simplified layout with line number and times on the same row
const cardContent = document.createElement('div');
cardContent.className = 'departure-header';
cardContent.style.display = 'flex';
cardContent.style.justifyContent = 'space-between';
cardContent.style.alignItems = 'center';
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
// Add line number with transport icon and destination
const lineNumberElement = document.createElement('span');
lineNumberElement.className = 'line-number';
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} <span class="line-destination">${directionGroup.destination}</span>`;
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
timesContainer.style.display = 'flex';
timesContainer.style.flexDirection = 'column';
timesContainer.style.alignItems = 'flex-end';
// Add up to 2 departure times
const maxTimes = 2;
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
const timeElement = document.createElement('div');
timeElement.className = 'time';
timeElement.style.fontSize = '1.1em';
timeElement.style.marginBottom = '2px';
timeElement.style.whiteSpace = 'nowrap';
timeElement.style.textAlign = 'right';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
timeElement.textContent = `${scheduledTime} (${countdownText})`;
timesContainer.appendChild(timeElement);
});
cardContent.appendChild(lineNumberElement);
cardContent.appendChild(timesContainer);
directionCard.appendChild(cardContent);
rowContainer.appendChild(directionCard);
}
// Add this row to the site container
siteContainer.appendChild(rowContainer);
}
}
});
} else if (site.error) { } else if (site.error) {
// Display error for this site // Display error for this site
const errorElement = document.createElement('div'); const errorElement = document.createElement('div');

View File

@@ -2,7 +2,7 @@
## System Overview ## 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. This is a comprehensive digital signage system designed to display transit departures and weather information. 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 ## Architecture
@@ -12,7 +12,7 @@ The system consists of the following components:
2. **Configuration Manager** - Manages system settings and UI customization 2. **Configuration Manager** - Manages system settings and UI customization
3. **Weather Component** - Displays weather data and manages dark mode 3. **Weather Component** - Displays weather data and manages dark mode
4. **Clock Component** - Shows current time and date 4. **Clock Component** - Shows current time and date
5. **Ticker Component** - Displays scrolling news from RSS feeds 5. **Departures Component** - Displays transit departure information
6. **Main UI** - Responsive layout with multiple orientation support 6. **Main UI** - Responsive layout with multiple orientation support
## File Structure ## File Structure
@@ -22,7 +22,7 @@ The system consists of the following components:
- `config.js` - Configuration management module - `config.js` - Configuration management module
- `weather.js` - Weather display and dark mode management - `weather.js` - Weather display and dark mode management
- `clock.js` - Time and date display - `clock.js` - Time and date display
- `ticker.js` - News ticker component - `departures.js` - Transit departure display component
## Detailed Component Documentation ## Detailed Component Documentation
@@ -35,7 +35,6 @@ The server component acts as a proxy for external APIs and serves the static fil
- **Port**: Runs on port 3002 - **Port**: Runs on port 3002
- **API Proxying**: - **API Proxying**:
- `/api/departures` - Proxies requests to SL Transport API - `/api/departures` - Proxies requests to SL Transport API
- `/api/rss` - Proxies and parses RSS feeds
- **Error Handling**: Provides structured error responses - **Error Handling**: Provides structured error responses
- **Static File Serving**: Serves HTML, CSS, JavaScript, and image files - **Static File Serving**: Serves HTML, CSS, JavaScript, and image files
- **CORS Support**: Allows cross-origin requests - **CORS Support**: Allows cross-origin requests
@@ -43,7 +42,6 @@ The server component acts as a proxy for external APIs and serves the static fil
#### API Endpoints: #### API Endpoints:
- `GET /api/departures` - Returns transit departure information - `GET /api/departures` - Returns transit departure information
- `GET /api/rss` - Returns parsed RSS feed items
- `GET /` or `/index.html` - Serves the main application - `GET /` or `/index.html` - Serves the main application
- `GET /*.js|css|png|jpg|jpeg|gif|ico` - Serves static assets - `GET /*.js|css|png|jpg|jpeg|gif|ico` - Serves static assets
@@ -60,7 +58,6 @@ The Configuration Manager handles all system settings and provides a UI for chan
- **Screen Orientation**: Controls display rotation (0°, 90°, 180°, 270°, landscape) - **Screen Orientation**: Controls display rotation (0°, 90°, 180°, 270°, landscape)
- **Dark Mode**: Automatic (based on sunrise/sunset), always on, or always off - **Dark Mode**: Automatic (based on sunrise/sunset), always on, or always off
- **Background Image**: Custom background with opacity control - **Background Image**: Custom background with opacity control
- **Ticker Speed**: Controls the scrolling speed of the news ticker
- **Settings Persistence**: Saves settings to localStorage - **Settings Persistence**: Saves settings to localStorage
- **Configuration UI**: Modal-based interface with live previews - **Configuration UI**: Modal-based interface with live previews
@@ -70,7 +67,6 @@ The Configuration Manager handles all system settings and provides a UI for chan
- `darkMode`: Dark mode setting (`auto`, `on`, `off`) - `darkMode`: Dark mode setting (`auto`, `on`, `off`)
- `backgroundImage`: URL or data URL of background image - `backgroundImage`: URL or data URL of background image
- `backgroundOpacity`: Opacity value between 0 and 1 - `backgroundOpacity`: Opacity value between 0 and 1
- `tickerSpeed`: Scroll speed in seconds for one complete cycle
#### Implementation Details: #### Implementation Details:
@@ -109,21 +105,16 @@ The Clock component displays the current time and date.
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. 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) ### 5. Departures Component (departures.js)
The Ticker component displays scrolling news or announcements at the bottom of the screen. The Departures component displays transit departure information from the SL Transport API.
#### Key Features: #### Key Features:
- **RSS Integration**: Fetches and displays items from RSS feeds - **Real-time Updates**: Fetches departure data periodically
- **Smooth Animation**: CSS-based scrolling with configurable speed - **Multiple Sites**: Supports displaying departures from multiple transit stops
- **Fallback Content**: Provides default items when RSS feed is unavailable - **Grouped Display**: Groups departures by line and direction
- **Themed Display**: Red, white, and blue color scheme - **Countdown Timers**: Shows time until departure
- **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) ### 6. Main UI (index.html)
@@ -214,9 +205,6 @@ The HTML file contains the structure and styling for the application. It initial
- Verify internet connection - Verify internet connection
- Look for errors in browser console - 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 - Look for JavaScript errors in console
5. **Screen orientation issues** 5. **Screen orientation issues**
@@ -245,14 +233,6 @@ window.weatherManager = new WeatherManager({
}); });
``` ```
### 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: ### Adding Custom Styles:
Add custom CSS to the style section in index.html to modify the appearance. Add custom CSS to the style section in index.html to modify the appearance.
@@ -274,7 +254,7 @@ Add custom CSS to the style section in index.html to modify the appearance.
| UI Components | | External APIs | | UI Components | | External APIs |
| - Clock | | - SL Transport API | | - Clock | | - SL Transport API |
| - Weather | | - OpenWeatherMap | | - Weather | | - OpenWeatherMap |
| - Ticker | | - RSS Feeds | | - Departures | | |
| - Config Manager | | | | - Config Manager | | |
+---------------------+ +----------------------+ +---------------------+ +----------------------+
``` ```
@@ -283,7 +263,7 @@ Add custom CSS to the style section in index.html to modify the appearance.
1. User loads the application in a browser 1. User loads the application in a browser
2. Node.js server serves the HTML, CSS, and JavaScript files 2. Node.js server serves the HTML, CSS, and JavaScript files
3. Browser initializes all components (Clock, Weather, Config, Ticker) 3. Browser initializes all components (Clock, Weather, Config, Departures)
4. Components make API requests through the Node.js server 4. Components make API requests through the Node.js server
5. Server proxies requests to external APIs and returns responses 5. Server proxies requests to external APIs and returns responses
6. Components update their UI based on the data 6. Components update their UI based on the data

1123
index.html

File diff suppressed because it is too large Load Diff

251
rss.js
View File

@@ -1,251 +0,0 @@
/**
* 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;

454
server.js
View File

@@ -12,13 +12,6 @@ let config = {
name: 'Ambassaderna', name: 'Ambassaderna',
enabled: true enabled: true
} }
],
rssFeeds: [
{
name: "Travel Alerts",
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
enabled: true
}
] ]
}; };
@@ -47,225 +40,6 @@ function loadSitesConfig() {
// Load configuration on startup // Load configuration on startup
loadSitesConfig(); loadSitesConfig();
// Feed templates for different formats
const feedTemplates = {
rss2: {
detect: (data) => data.includes('<rss version="2.0"'),
itemPath: 'item',
titlePath: 'title',
descPath: 'description',
datePath: 'pubDate'
},
atom: {
detect: (data) => data.includes('<feed xmlns="http://www.w3.org/2005/Atom"'),
itemPath: 'entry',
titlePath: 'title',
descPath: 'content',
datePath: 'updated'
},
rss1: {
detect: (data) => data.includes('<rdf:RDF'),
itemPath: 'item',
titlePath: 'title',
descPath: 'description',
datePath: 'dc:date'
}
};
// Helper function to detect feed type
function detectFeedType(data) {
for (const [type, template] of Object.entries(feedTemplates)) {
if (template.detect(data)) {
return type;
}
}
return 'rss2'; // Default to RSS 2.0
}
// Helper function to clean HTML content
function cleanHtmlContent(content) {
if (!content) return '';
// Remove CDATA if present
content = content.replace(/<!\[CDATA\[(.*?)\]\]>/gs, '$1');
// Remove HTML tags
content = content.replace(/<[^>]+>/g, ' ');
// Convert common HTML entities
content = content
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
.replace(/&#x([A-Fa-f0-9]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)));
// Remove extra whitespace
content = content.replace(/\s+/g, ' ').trim();
// Get first sentence if content is too long
if (content.length > 200) {
const match = content.match(/^.*?[.!?](?:\s|$)/);
if (match) {
content = match[0].trim();
} else {
content = content.substring(0, 197) + '...';
}
}
return content;
}
// Function to extract content based on template
function extractContent(content, template) {
const itemRegex = new RegExp(`<${template.itemPath}>([\\\s\\\S]*?)<\/${template.itemPath}>`, 'g');
const titleRegex = new RegExp(`<${template.titlePath}>([\\\s\\\S]*?)<\/${template.titlePath}>`);
const descRegex = new RegExp(`<${template.descPath}>([\\\s\\\S]*?)<\/${template.descPath}>`);
const dateRegex = new RegExp(`<${template.datePath}>([\\\s\\\S]*?)<\/${template.datePath}>`);
const linkRegex = /<link[^>]*?>([\s\S]*?)<\/link>/;
const items = [];
let match;
while ((match = itemRegex.exec(content)) !== null) {
const itemContent = match[1];
const titleMatch = titleRegex.exec(itemContent);
const descMatch = descRegex.exec(itemContent);
const dateMatch = dateRegex.exec(itemContent);
const linkMatch = linkRegex.exec(itemContent);
const title = cleanHtmlContent(titleMatch ? titleMatch[1] : '');
const description = cleanHtmlContent(descMatch ? descMatch[1] : '');
// Create display text from title and optionally description
let displayText = title;
if (description && description !== title) {
displayText = `${title}: ${description}`;
}
items.push({
title,
displayText,
link: linkMatch ? linkMatch[1].trim() : '',
description,
pubDate: dateMatch ? dateMatch[1] : '',
feedUrl: null // Will be set by caller
});
}
return items;
}
// Function to fetch RSS feed data from a single URL
async function fetchSingleRssFeed(feedUrl) {
return new Promise((resolve, reject) => {
console.log(`Fetching RSS feed from: ${feedUrl}`);
const request = https.get(feedUrl, (res) => {
console.log(`RSS feed response status: ${res.statusCode}`);
console.log('RSS feed response headers:', res.headers);
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
// Log first part of response
console.log('RSS feed raw response (first 500 chars):', data.substring(0, 500));
// Detect feed type and get appropriate template
const feedType = detectFeedType(data);
const template = feedTemplates[feedType];
console.log(`Detected feed type: ${feedType}`);
// Extract and process items
const items = extractContent(data, template);
console.log(`Extracted ${items.length} items from feed`);
// Add feed URL to each item
items.forEach(item => {
item.feedUrl = feedUrl;
});
resolve(items);
} catch (error) {
console.error('Error processing RSS feed:', error);
console.error('Error stack:', error.stack);
resolve([
{
title: 'Error processing RSS feed',
link: feedUrl,
feedUrl: feedUrl,
error: error.message
}
]);
}
});
});
request.on('error', (error) => {
console.error('Error fetching RSS feed:', error);
console.error('Error stack:', error.stack);
resolve([
{
title: 'Error fetching RSS feed',
link: feedUrl,
feedUrl: feedUrl,
error: error.message
}
]);
});
// Set a timeout of 10 seconds
request.setTimeout(10000, () => {
console.error('RSS feed request timed out:', feedUrl);
request.destroy();
resolve([
{
title: 'RSS feed request timed out',
link: feedUrl,
feedUrl: feedUrl,
error: 'Request timed out after 10 seconds'
}
]);
});
});
}
// Function to fetch all enabled RSS feeds
async function fetchRssFeed() {
try {
const enabledFeeds = config.rssFeeds.filter(feed => feed.enabled);
const feedPromises = enabledFeeds.map(feed => fetchSingleRssFeed(feed.url));
const allItems = await Promise.all(feedPromises);
// Flatten array and sort by date (if available)
const items = allItems.flat().sort((a, b) => {
const dateA = new Date(a.pubDate || 0);
const dateB = new Date(b.pubDate || 0);
return dateB - dateA;
});
return { items };
} catch (error) {
console.error('Error fetching RSS feeds:', error);
return {
items: [{
title: 'Error fetching RSS feeds',
link: '#',
error: error.message
}]
};
}
}
// Function to fetch data from the SL Transport API for a specific site // Function to fetch data from the SL Transport API for a specific site
function fetchDeparturesForSite(siteId) { function fetchDeparturesForSite(siteId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -309,11 +83,19 @@ function fetchDeparturesForSite(siteId) {
const parsedData = JSON.parse(data); const parsedData = JSON.parse(data);
console.log('Successfully parsed fixed JSON'); console.log('Successfully parsed fixed JSON');
if (parsedData && parsedData.departures) { if (parsedData && parsedData.departures && parsedData.departures.length > 0) {
const line7Departures = parsedData.departures.filter(d => d.line && d.line.designation === '7'); // Log first departure to see structure
if (line7Departures.length > 0) { console.log('Sample departure structure:', JSON.stringify(parsedData.departures[0], null, 2));
console.log('Line 7 details:', JSON.stringify(line7Departures[0], null, 2));
} // Check for direction-related fields
const sample = parsedData.departures[0];
console.log('Direction fields:', {
direction: sample.direction,
directionText: sample.directionText,
directionCode: sample.directionCode,
journeyDirection: sample.journey?.direction,
stopPoint: sample.stopPoint
});
} }
resolve(parsedData); resolve(parsedData);
@@ -405,6 +187,184 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify({ error: error.message })); res.end(JSON.stringify({ error: error.message }));
} }
} }
else if (parsedUrl.pathname === '/api/sites/search') {
// Search for transit sites
const query = parsedUrl.query.q;
if (!query || query.length < 2) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
return;
}
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(query)}`;
console.log(`Searching sites: ${searchUrl}`);
https.get(searchUrl, (apiRes) => {
let data = '';
// Check for HTTP errors
if (apiRes.statusCode < 200 || apiRes.statusCode >= 300) {
console.error(`API returned status code: ${apiRes.statusCode}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] }));
return;
}
apiRes.on('data', (chunk) => {
data += chunk;
});
apiRes.on('end', () => {
try {
console.log('Raw API response:', data.substring(0, 500));
const parsedData = JSON.parse(data);
console.log('Parsed data:', JSON.stringify(parsedData).substring(0, 500));
// Handle different possible response formats
let sites = [];
if (Array.isArray(parsedData)) {
// Response is directly an array
sites = parsedData.map(site => ({
id: String(site.id || site.siteId || site.SiteId || ''),
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
lat: site.lat || site.latitude || site.Lat || site.Latitude || null,
lon: site.lon || site.longitude || site.Lon || site.Longitude || null
}));
} else if (parsedData.sites && Array.isArray(parsedData.sites)) {
// Response has a sites property
sites = parsedData.sites.map(site => ({
id: String(site.id || site.siteId || site.SiteId || ''),
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
lat: site.lat || site.latitude || site.Lat || site.Latitude || null,
lon: site.lon || site.longitude || site.Lon || site.Longitude || null
}));
} else if (parsedData.ResponseData && parsedData.ResponseData.Result) {
// SL API format with ResponseData
sites = parsedData.ResponseData.Result.map(site => ({
id: String(site.SiteId || site.id || ''),
name: site.Name || site.name || site.StopPointName || 'Unknown',
lat: site.Lat || site.lat || site.Latitude || site.latitude || null,
lon: site.Lon || site.lon || site.Longitude || site.longitude || null
}));
} else {
console.log('Unexpected response format:', parsedData);
sites = [];
}
// Log sample site to see structure
if (sites.length > 0) {
console.log('Sample site structure:', JSON.stringify(sites[0], null, 2));
const sitesWithCoords = sites.filter(s => s.lat && s.lon);
console.log(`Found ${sites.length} sites, ${sitesWithCoords.length} with coordinates`);
} else {
console.log('No sites found');
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites }));
} catch (error) {
console.error('Error parsing site search response:', error);
console.error('Response data:', data.substring(0, 500));
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Error parsing search results', details: error.message, sites: [] }));
}
});
}).on('error', (error) => {
console.error('Error searching sites:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Error searching sites', details: error.message, sites: [] }));
});
}
else if (parsedUrl.pathname === '/api/sites/nearby') {
// Get nearby transit sites based on coordinates
const lat = parseFloat(parsedUrl.query.lat);
const lon = parseFloat(parsedUrl.query.lon);
const radius = parseInt(parsedUrl.query.radius) || 5000; // Default 5km radius
if (isNaN(lat) || isNaN(lon)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid latitude or longitude', sites: [] }));
return;
}
// Use a broader search to get sites, then filter by distance
// For now, we'll search for common Stockholm area terms and filter
const searchTerms = ['Stockholm', 'T-Centralen', 'Gamla Stan', 'Södermalm'];
const allSites = [];
let completedSearches = 0;
searchTerms.forEach(term => {
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(term)}`;
https.get(searchUrl, (apiRes) => {
let data = '';
apiRes.on('data', (chunk) => {
data += chunk;
});
apiRes.on('end', () => {
try {
const parsedData = JSON.parse(data);
let sites = [];
if (Array.isArray(parsedData)) {
sites = parsedData;
} else if (parsedData.sites) {
sites = parsedData.sites;
} else if (parsedData.ResponseData && parsedData.ResponseData.Result) {
sites = parsedData.ResponseData.Result;
}
sites.forEach(site => {
const siteLat = site.lat || site.latitude || site.Lat || site.Latitude;
const siteLon = site.lon || site.longitude || site.Lon || site.Longitude;
if (siteLat && siteLon) {
// Calculate distance (simple haversine approximation)
const distance = Math.sqrt(
Math.pow((lat - siteLat) * 111000, 2) +
Math.pow((lon - siteLon) * 111000 * Math.cos(lat * Math.PI / 180), 2)
);
if (distance <= radius) {
allSites.push({
id: String(site.id || site.siteId || site.SiteId || ''),
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
lat: siteLat,
lon: siteLon
});
}
}
});
completedSearches++;
if (completedSearches === searchTerms.length) {
// Remove duplicates
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
}
} catch (error) {
completedSearches++;
if (completedSearches === searchTerms.length) {
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
}
}
});
}).on('error', () => {
completedSearches++;
if (completedSearches === searchTerms.length) {
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
}
});
});
}
else if (parsedUrl.pathname === '/api/config') { else if (parsedUrl.pathname === '/api/config') {
// Return the current configuration // Return the current configuration
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -420,7 +380,7 @@ const server = http.createServer(async (req, res) => {
req.on('end', () => { req.on('end', () => {
try { try {
const newConfig = JSON.parse(body); const newConfig = JSON.parse(body);
if (newConfig.sites && newConfig.rssFeeds) { if (newConfig.sites) {
config = newConfig; config = newConfig;
// Save to file // Save to file
@@ -439,36 +399,6 @@ const server = http.createServer(async (req, res) => {
} }
}); });
} }
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 // Serve static files
else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') { else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') {
const fs = require('fs'); const fs = require('fs');

View File

@@ -1,39 +0,0 @@
# 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"

View File

@@ -1,20 +1,27 @@
{ {
"orientation": "normal", "orientation": "normal",
"darkMode": "auto", "darkMode": "auto",
"backgroundImage": "", "backgroundImage": "https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000",
"backgroundOpacity": 0.3, "backgroundOpacity": 0.3,
"tickerSpeed": 60,
"sites": [ "sites": [
{ {
"id": "1411", "id": "1411",
"name": "Ambassaderna", "name": "Ambassaderna",
"enabled": true "enabled": true
} },
],
"rssFeeds": [
{ {
"name": "Travel Alerts", "id": "1410",
"url": "https://travel.state.gov/content/travel/en/rss/rss.xml", "name": "Berwaldhallen",
"enabled": true
},
{
"id": "1100",
"name": "Djurgårdsbron",
"enabled": true
},
{
"id": "1110",
"name": "Radiohuset",
"enabled": true "enabled": true
} }
], ],

297
ticker.js
View File

@@ -1,297 +0,0 @@
/**
* ticker.js - A modular ticker component for displaying RSS feed content
* Fetches and displays content from an RSS feed in a scrolling ticker at the bottom of the page
*/
class TickerManager {
constructor(options = {}) {
// Default options
this.options = {
elementId: 'ticker-container',
scrollSpeed: 60, // Animation duration in seconds
maxItems: 10, // Maximum number of items to display
...options
};
// Create ticker container immediately
this.createTickerContainer();
// State
this.items = [];
this.isScrolling = false;
this.animationFrameId = null;
// Initialize RSS manager
this.rssManager = new RssManager();
// Subscribe to RSS updates
this.rssManager.onUpdate(items => {
this.items = items.slice(0, this.options.maxItems);
this.updateTicker();
});
// Initialize
this.init();
}
/**
* Create the ticker container
*/
createTickerContainer() {
// Create container if it doesn't exist
if (!document.getElementById(this.options.elementId)) {
console.log('Creating ticker container');
const container = document.createElement('div');
container.id = this.options.elementId;
container.className = 'ticker-container';
// Create ticker content
const tickerContent = document.createElement('div');
tickerContent.className = 'ticker-content';
container.appendChild(tickerContent);
// Add to document
document.body.appendChild(container);
// Add styles
this.addTickerStyles();
}
}
/**
* Initialize the ticker
*/
init() {
console.log('Initializing TickerManager...');
try {
// Set initial scroll speed
this.setScrollSpeed(this.options.scrollSpeed);
// Add initial loading message
const tickerContent = document.querySelector(`#${this.options.elementId} .ticker-content`);
if (tickerContent) {
tickerContent.innerHTML = '<div class="ticker-item">Loading news...</div>';
}
// Ensure ticker is visible
const container = document.getElementById(this.options.elementId);
if (container) {
container.style.display = 'block';
}
console.log('TickerManager initialized successfully');
} catch (error) {
console.error('Error initializing TickerManager:', error);
}
}
/**
* Add ticker styles
*/
addTickerStyles() {
// Check if styles already exist
if (!document.getElementById('ticker-styles')) {
const styleElement = document.createElement('style');
styleElement.id = 'ticker-styles';
// Define styles
styleElement.textContent = `
.ticker-container {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: linear-gradient(to right, #3c3b6e, #b22234, #ffffff);
color: white;
overflow: hidden;
height: 40px;
z-index: 100;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.2);
}
body.vertical .ticker-container {
transform: rotate(90deg);
transform-origin: left bottom;
width: 100vh;
position: fixed;
bottom: 0;
left: 0;
height: 40px;
}
body.vertical-reverse .ticker-container {
transform: rotate(-90deg);
transform-origin: right bottom;
width: 100vh;
position: fixed;
bottom: 0;
right: 0;
height: 40px;
}
body.upsidedown .ticker-container {
transform: rotate(180deg);
}
.ticker-content {
display: flex;
align-items: center;
height: 100%;
white-space: nowrap;
position: absolute;
left: 0;
transform: translateX(100%);
}
.ticker-item {
display: inline-block;
padding: 0 30px;
color: white;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
.ticker-item:nth-child(3n+1) {
background-color: rgba(178, 34, 52, 0.7); /* Red */
}
.ticker-item:nth-child(3n+2) {
background-color: rgba(255, 255, 255, 0.7); /* White */
color: #3c3b6e; /* Dark blue text for readability */
text-shadow: none;
}
.ticker-item:nth-child(3n+3) {
background-color: rgba(60, 59, 110, 0.7); /* Blue */
}
@keyframes ticker-scroll {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
/* Animation direction for different orientations */
body.vertical .ticker-content {
animation-direction: reverse; /* Reverse for vertical to maintain readability */
}
body.upsidedown .ticker-content {
animation-direction: reverse; /* Reverse for upside down to maintain readability */
}
/* Dark mode styles */
body.dark-mode .ticker-container {
background: linear-gradient(to right, #1a1a4f, #8b1a29, #e6e6e6);
}
body.dark-mode .ticker-item:nth-child(3n+1) {
background-color: rgba(139, 26, 41, 0.7); /* Darker Red */
}
body.dark-mode .ticker-item:nth-child(3n+2) {
background-color: rgba(230, 230, 230, 0.7); /* Off-White */
color: #1a1a4f; /* Darker blue text */
}
body.dark-mode .ticker-item:nth-child(3n+3) {
background-color: rgba(26, 26, 79, 0.7); /* Darker Blue */
}
`;
// Add to document
document.head.appendChild(styleElement);
}
}
/**
* Set the ticker scroll speed
* @param {number} speed - Speed in seconds for one complete scroll cycle
*/
setScrollSpeed(speed) {
this.options.scrollSpeed = speed;
const container = document.getElementById(this.options.elementId);
if (container) {
// Reset animation by removing and re-adding content
const content = container.querySelector('.ticker-content');
if (content) {
const clone = content.cloneNode(true);
container.style.setProperty('--ticker-speed', `${speed}s`);
content.remove();
container.appendChild(clone);
}
console.log(`Ticker speed set to ${speed} seconds`);
}
}
/**
* Update ticker content
*/
updateTicker() {
console.log('Updating ticker content...');
const tickerContent = document.querySelector(`#${this.options.elementId} .ticker-content`);
if (tickerContent) {
// Clear existing content
tickerContent.innerHTML = '';
if (this.items.length === 0) {
console.log('No items to display in ticker');
const tickerItem = document.createElement('div');
tickerItem.className = 'ticker-item';
tickerItem.textContent = 'Loading news...';
tickerContent.appendChild(tickerItem);
return;
}
console.log(`Adding ${this.items.length} items to ticker`);
// Add items
this.items.forEach((item, index) => {
const tickerItem = document.createElement('div');
tickerItem.className = 'ticker-item';
// Create link if available, using displayText or falling back to title
if (item.link) {
const link = document.createElement('a');
link.href = item.link;
link.target = '_blank';
link.textContent = item.displayText || item.title;
link.style.color = 'inherit';
link.style.textDecoration = 'none';
tickerItem.appendChild(link);
} else {
tickerItem.textContent = item.displayText || item.title;
}
tickerContent.appendChild(tickerItem);
});
console.log('Ticker content updated successfully');
// Calculate total width of content
const totalWidth = Array.from(tickerContent.children)
.reduce((width, item) => width + item.offsetWidth, 0);
// Calculate animation duration based on content width
const duration = Math.max(totalWidth / 100, this.options.scrollSpeed);
// Reset and start animation
tickerContent.style.animation = 'none';
tickerContent.offsetHeight; // Force reflow
tickerContent.style.animation = `ticker-scroll ${duration}s linear infinite`;
console.log(`Animation duration set to ${duration}s based on content width ${totalWidth}px`);
} else {
console.error('Ticker content element not found');
}
}
}
// Export the TickerManager class for use in other modules
window.TickerManager = TickerManager;

View File

@@ -131,11 +131,13 @@ class WeatherManager {
* Process current weather data from API response * Process current weather data from API response
*/ */
processCurrentWeather(data) { processCurrentWeather(data) {
const iconCode = data.weather[0].icon;
return { return {
temperature: Math.round(data.main.temp * 10) / 10, // Round to 1 decimal place temperature: Math.round(data.main.temp * 10) / 10, // Round to 1 decimal place
condition: data.weather[0].main, condition: data.weather[0].main,
description: data.weather[0].description, description: data.weather[0].description,
icon: this.getWeatherIconUrl(data.weather[0].icon), icon: this.getWeatherIconUrl(iconCode),
iconCode: iconCode, // Store icon code for classification
wind: { wind: {
speed: Math.round(data.wind.speed * 3.6), // Convert m/s to km/h speed: Math.round(data.wind.speed * 3.6), // Convert m/s to km/h
direction: data.wind.deg direction: data.wind.deg
@@ -155,11 +157,13 @@ class WeatherManager {
processForecast(data) { processForecast(data) {
// Get the next 7 forecasts (covering about 24 hours) // Get the next 7 forecasts (covering about 24 hours)
return data.list.slice(0, 7).map(item => { return data.list.slice(0, 7).map(item => {
const iconCode = item.weather[0].icon;
return { return {
temperature: Math.round(item.main.temp * 10) / 10, temperature: Math.round(item.main.temp * 10) / 10,
condition: item.weather[0].main, condition: item.weather[0].main,
description: item.weather[0].description, description: item.weather[0].description,
icon: this.getWeatherIconUrl(item.weather[0].icon), icon: this.getWeatherIconUrl(iconCode),
iconCode: iconCode, // Store icon code for classification
timestamp: new Date(item.dt * 1000), timestamp: new Date(item.dt * 1000),
precipitation: item.rain ? (item.rain['3h'] || 0) : 0 precipitation: item.rain ? (item.rain['3h'] || 0) : 0
}; };
@@ -173,6 +177,42 @@ class WeatherManager {
return `https://openweathermap.org/img/wn/${iconCode}@2x.png`; return `https://openweathermap.org/img/wn/${iconCode}@2x.png`;
} }
/**
* Determine if icon represents sun (even behind clouds)
*/
isSunIcon(iconCode, condition) {
// Icon codes: 01d, 01n = clear, 02d, 02n = few clouds, 03d, 03n = scattered, 04d, 04n = broken clouds
const sunIconCodes = ['01d', '01n', '02d', '02n', '03d', '03n', '04d', '04n'];
return sunIconCodes.includes(iconCode) ||
condition.includes('Clear') ||
condition.includes('Clouds');
}
/**
* Check if icon is clear sun (no clouds)
*/
isClearSun(iconCode, condition) {
const clearIconCodes = ['01d', '01n'];
return clearIconCodes.includes(iconCode) || condition === 'Clear';
}
/**
* Check if icon is sun behind clouds
*/
isSunBehindClouds(iconCode, condition) {
const cloudIconCodes = ['02d', '02n', '03d', '03n', '04d', '04n'];
return cloudIconCodes.includes(iconCode) || (condition.includes('Clouds') && !condition.includes('Clear'));
}
/**
* Determine if icon represents snow
*/
isSnowIcon(iconCode, condition) {
// Icon code: 13d, 13n = snow
const snowIconCodes = ['13d', '13n'];
return snowIconCodes.includes(iconCode) || condition.includes('Snow');
}
/** /**
* Create default weather data for fallback * Create default weather data for fallback
*/ */
@@ -182,6 +222,7 @@ class WeatherManager {
condition: 'Clear', condition: 'Clear',
description: 'clear sky', description: 'clear sky',
icon: 'https://openweathermap.org/img/wn/01d@2x.png', icon: 'https://openweathermap.org/img/wn/01d@2x.png',
iconCode: '01d',
wind: { wind: {
speed: 14.8, speed: 14.8,
direction: 270 direction: 270
@@ -209,9 +250,10 @@ class WeatherManager {
forecasts.push({ forecasts.push({
temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour
condition: i < 2 ? 'Clear' : 'Partly Cloudy', condition: i < 2 ? 'Clear' : 'Clouds',
description: i < 2 ? 'clear sky' : 'few clouds', description: i < 2 ? 'clear sky' : 'few clouds',
icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@2x.png' : 'https://openweathermap.org/img/wn/02n@2x.png', icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@2x.png' : 'https://openweathermap.org/img/wn/02n@2x.png',
iconCode: i < 2 ? '01n' : '02n',
timestamp: forecastTime, timestamp: forecastTime,
precipitation: 0 precipitation: 0
}); });
@@ -242,6 +284,18 @@ class WeatherManager {
if (iconElement) { if (iconElement) {
iconElement.src = this.weatherData.icon; iconElement.src = this.weatherData.icon;
iconElement.alt = this.weatherData.description; iconElement.alt = this.weatherData.description;
// Add classes and data attributes for color filtering
iconElement.setAttribute('data-condition', this.weatherData.condition);
iconElement.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun');
if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) {
iconElement.classList.add('weather-snow');
} else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) {
iconElement.classList.add('weather-sun', 'weather-clear-sun');
} else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) {
iconElement.classList.add('weather-sun', 'weather-clouds-sun');
} else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) {
iconElement.classList.add('weather-sun');
}
} }
const temperatureElement = document.querySelector('#custom-weather .temperature'); const temperatureElement = document.querySelector('#custom-weather .temperature');
@@ -255,16 +309,29 @@ class WeatherManager {
// Clear existing forecast // Clear existing forecast
forecastContainer.innerHTML = ''; forecastContainer.innerHTML = '';
// Add current weather as "Now" // Add current weather as "Nu" (Swedish for "Now")
const nowElement = document.createElement('div'); const nowElement = document.createElement('div');
nowElement.className = 'forecast-hour'; nowElement.className = 'forecast-hour';
const nowIcon = document.createElement('img');
nowIcon.src = this.weatherData.icon;
nowIcon.alt = this.weatherData.description;
nowIcon.width = 56;
nowIcon.setAttribute('data-condition', this.weatherData.condition);
if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) {
nowIcon.classList.add('weather-snow');
} else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) {
nowIcon.classList.add('weather-sun', 'weather-clear-sun');
} else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) {
nowIcon.classList.add('weather-sun', 'weather-clouds-sun');
} else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) {
nowIcon.classList.add('weather-sun');
}
nowElement.innerHTML = ` nowElement.innerHTML = `
<div class="time">Now</div> <div class="time">Nu</div>
<div class="icon"> <div class="icon"></div>
<img src="${this.weatherData.icon}" alt="${this.weatherData.description}" width="40" />
</div>
<div class="temp">${this.weatherData.temperature} °C</div> <div class="temp">${this.weatherData.temperature} °C</div>
`; `;
nowElement.querySelector('.icon').appendChild(nowIcon);
forecastContainer.appendChild(nowElement); forecastContainer.appendChild(nowElement);
// Add hourly forecasts // Add hourly forecasts
@@ -274,13 +341,26 @@ class WeatherManager {
const forecastElement = document.createElement('div'); const forecastElement = document.createElement('div');
forecastElement.className = 'forecast-hour'; forecastElement.className = 'forecast-hour';
const forecastIcon = document.createElement('img');
forecastIcon.src = forecast.icon;
forecastIcon.alt = forecast.description;
forecastIcon.width = 56;
forecastIcon.setAttribute('data-condition', forecast.condition);
if (this.isSnowIcon(forecast.iconCode, forecast.condition)) {
forecastIcon.classList.add('weather-snow');
} else if (this.isClearSun(forecast.iconCode, forecast.condition)) {
forecastIcon.classList.add('weather-sun', 'weather-clear-sun');
} else if (this.isSunBehindClouds(forecast.iconCode, forecast.condition)) {
forecastIcon.classList.add('weather-sun', 'weather-clouds-sun');
} else if (this.isSunIcon(forecast.iconCode, forecast.condition)) {
forecastIcon.classList.add('weather-sun');
}
forecastElement.innerHTML = ` forecastElement.innerHTML = `
<div class="time">${timeString}</div> <div class="time">${timeString}</div>
<div class="icon"> <div class="icon"></div>
<img src="${forecast.icon}" alt="${forecast.description}" width="40" />
</div>
<div class="temp">${forecast.temperature} °C</div> <div class="temp">${forecast.temperature} °C</div>
`; `;
forecastElement.querySelector('.icon').appendChild(forecastIcon);
forecastContainer.appendChild(forecastElement); forecastContainer.appendChild(forecastElement);
}); });
} }