Optimize landscape layout: 4-column grid, transport icons, improved sizing and spacing
This commit is contained in:
136
API_RESPONSE_DOCUMENTATION.md
Normal file
136
API_RESPONSE_DOCUMENTATION.md
Normal 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.
|
||||
20
README.md
20
README.md
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
@@ -8,11 +8,9 @@ A digital signage system for displaying transit departures, weather information,
|
||||
|
||||
- Real-time transit departure information
|
||||
- Current weather and hourly forecast
|
||||
- News ticker with RSS feed integration
|
||||
- Multiple screen orientation support (0°, 90°, 180°, 270°)
|
||||
- Dark mode with automatic switching based on sunrise/sunset
|
||||
- Custom background image support
|
||||
- Configurable ticker speed
|
||||
- Responsive design for various screen sizes
|
||||
|
||||
## Quick Start
|
||||
@@ -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
|
||||
|
||||
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
|
||||
- Dark mode (auto/on/off)
|
||||
- Background image and opacity
|
||||
- Ticker speed
|
||||
- Transit sites
|
||||
|
||||
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
|
||||
3. **Weather Component** - Displays weather data and manages dark mode
|
||||
4. **Clock Component** - Shows current time and date
|
||||
5. **Ticker Component** - Displays scrolling news from RSS feeds
|
||||
5. **Departures Component** - Displays transit departure information
|
||||
6. **Main UI** - Responsive layout with multiple orientation support
|
||||
|
||||
## Documentation
|
||||
@@ -127,10 +117,6 @@ For detailed documentation, see [documentation.md](documentation.md).
|
||||
- Verify internet connection
|
||||
- Look for errors in browser console
|
||||
|
||||
4. **Ticker not scrolling**
|
||||
- Check if RSS feed is accessible
|
||||
- Verify ticker speed setting is not set to 0
|
||||
- Look for JavaScript errors in console
|
||||
|
||||
## License
|
||||
|
||||
|
||||
6
clock.js
6
clock.js
@@ -82,10 +82,10 @@ class Clock {
|
||||
// Format and display the time
|
||||
this.timeElement.innerHTML = this.formatTime(now);
|
||||
|
||||
// Format and display the date
|
||||
this.dateElement.textContent = " " + this.formatDate(now);
|
||||
// Format and display the date (with a separator)
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
623
config.js
623
config.js
@@ -35,7 +35,6 @@ class ConfigManager {
|
||||
darkMode: this.options.defaultDarkMode,
|
||||
backgroundImage: '',
|
||||
backgroundOpacity: 0.3, // Default opacity (30%)
|
||||
tickerSpeed: 60, // Default ticker speed in seconds
|
||||
sites: [
|
||||
{
|
||||
id: '1411',
|
||||
@@ -43,13 +42,6 @@ class ConfigManager {
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
rssFeeds: [
|
||||
{
|
||||
name: "Travel Alerts",
|
||||
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
combineSameDirection: true, // Combine departures going in the same direction
|
||||
...this.loadConfig() // Load saved config if available
|
||||
};
|
||||
@@ -102,7 +94,15 @@ class ConfigManager {
|
||||
<h2>Settings</h2>
|
||||
<span class="config-modal-close">×</span>
|
||||
</div>
|
||||
<div class="config-tabs">
|
||||
<button class="config-tab active" data-tab="display">Display</button>
|
||||
<button class="config-tab" data-tab="appearance">Appearance</button>
|
||||
<button class="config-tab" data-tab="content">Content</button>
|
||||
<button class="config-tab" data-tab="options">Options</button>
|
||||
</div>
|
||||
<div class="config-modal-body">
|
||||
<!-- Display Tab -->
|
||||
<div class="config-tab-content active" id="tab-display">
|
||||
<div class="config-option">
|
||||
<label for="orientation-select">Screen Orientation:</label>
|
||||
<select id="orientation-select">
|
||||
@@ -124,6 +124,10 @@ class ConfigManager {
|
||||
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Tab -->
|
||||
<div class="config-tab-content" id="tab-appearance">
|
||||
<div class="config-option">
|
||||
<label for="background-image-url">Background Image:</label>
|
||||
<input type="text" id="background-image-url" placeholder="Enter image URL" 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>
|
||||
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="${this.config.backgroundOpacity}">
|
||||
</div>
|
||||
<div class="config-option">
|
||||
<label for="ticker-speed">Ticker Speed: <span id="ticker-speed-value">${this.config.tickerSpeed}</span> seconds</label>
|
||||
<input type="range" id="ticker-speed" min="10" max="120" step="5" value="${this.config.tickerSpeed}">
|
||||
</div>
|
||||
|
||||
<!-- Content Tab -->
|
||||
<div class="config-tab-content" id="tab-content">
|
||||
<div class="config-option">
|
||||
<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">
|
||||
${this.generateSitesHTML()}
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
<!-- Options Tab -->
|
||||
<div class="config-tab-content" id="tab-options">
|
||||
<div class="config-option">
|
||||
<label for="combine-directions">
|
||||
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
|
||||
@@ -171,6 +178,7 @@ class ConfigManager {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-modal-footer">
|
||||
<button id="config-save-button">Save</button>
|
||||
<button id="config-cancel-button">Cancel</button>
|
||||
@@ -180,6 +188,9 @@ class ConfigManager {
|
||||
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
// Add tab switching functionality
|
||||
this.setupTabs(modalContainer);
|
||||
|
||||
// Add event listeners
|
||||
modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal());
|
||||
modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal());
|
||||
@@ -196,10 +207,22 @@ class ConfigManager {
|
||||
addSiteButton.addEventListener('click', () => this.addNewSite());
|
||||
}
|
||||
|
||||
// Add event listener for add feed button
|
||||
const addFeedButton = modalContainer.querySelector('#add-feed-button');
|
||||
if (addFeedButton) {
|
||||
addFeedButton.addEventListener('click', () => this.addNewFeed());
|
||||
// Add event listeners for site search
|
||||
const searchSiteButton = modalContainer.querySelector('#search-site-button');
|
||||
const siteSearchInput = modalContainer.querySelector('#site-search-input');
|
||||
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
|
||||
@@ -226,9 +249,8 @@ class ConfigManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners for site and feed management
|
||||
// Add event listeners for site management
|
||||
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
|
||||
*/
|
||||
@@ -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
|
||||
*/
|
||||
@@ -319,18 +305,28 @@ class ConfigManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an RSS feed from the configuration
|
||||
* Setup tab switching functionality
|
||||
*/
|
||||
removeFeed(index) {
|
||||
if (index >= 0 && index < this.config.rssFeeds.length) {
|
||||
this.config.rssFeeds.splice(index, 1);
|
||||
setupTabs(modalContainer) {
|
||||
const tabs = modalContainer.querySelectorAll('.config-tab');
|
||||
const tabContents = modalContainer.querySelectorAll('.config-tab-content');
|
||||
|
||||
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||
if (feedsContainer) {
|
||||
feedsContainer.innerHTML = this.generateRssFeedsHTML();
|
||||
this.setupFeedEventListeners();
|
||||
}
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const targetTab = tab.dataset.tab;
|
||||
|
||||
// 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);
|
||||
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
|
||||
document.getElementById('orientation-select').value = this.config.orientation;
|
||||
document.getElementById('dark-mode-select').value = this.config.darkMode;
|
||||
@@ -372,19 +380,10 @@ class ConfigManager {
|
||||
this.setupSiteEventListeners();
|
||||
}
|
||||
|
||||
// Update RSS feeds container
|
||||
const feedsContainer = document.getElementById('rss-feeds-container');
|
||||
if (feedsContainer) {
|
||||
feedsContainer.innerHTML = this.generateRssFeedsHTML();
|
||||
this.setupFeedEventListeners();
|
||||
}
|
||||
|
||||
// Update background image, opacity, and ticker speed values
|
||||
// Update background image and opacity values
|
||||
const backgroundImageUrl = document.getElementById('background-image-url');
|
||||
const backgroundOpacity = document.getElementById('background-opacity');
|
||||
const opacityValue = document.getElementById('opacity-value');
|
||||
const tickerSpeed = document.getElementById('ticker-speed');
|
||||
const tickerSpeedValue = document.getElementById('ticker-speed-value');
|
||||
|
||||
if (backgroundImageUrl) {
|
||||
backgroundImageUrl.value = this.config.backgroundImage || '';
|
||||
@@ -398,14 +397,6 @@ class ConfigManager {
|
||||
opacityValue.textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
|
||||
}
|
||||
|
||||
if (tickerSpeed) {
|
||||
tickerSpeed.value = this.config.tickerSpeed;
|
||||
}
|
||||
|
||||
if (tickerSpeedValue) {
|
||||
tickerSpeedValue.textContent = this.config.tickerSpeed;
|
||||
}
|
||||
|
||||
// Update background preview
|
||||
this.updateBackgroundPreview();
|
||||
|
||||
@@ -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
|
||||
const localImageInput = document.getElementById('local-image-input');
|
||||
if (localImageInput) {
|
||||
@@ -477,12 +458,10 @@ class ConfigManager {
|
||||
const darkModeSelect = document.getElementById('dark-mode-select');
|
||||
const backgroundImageUrl = document.getElementById('background-image-url');
|
||||
const backgroundOpacity = document.getElementById('background-opacity');
|
||||
const tickerSpeed = document.getElementById('ticker-speed');
|
||||
const combineDirections = document.getElementById('combine-directions');
|
||||
|
||||
// Get sites and feeds configuration from form
|
||||
// Get sites configuration from form
|
||||
const sitesConfig = this.getSitesFromForm();
|
||||
const feedsConfig = this.getRssFeedsFromForm();
|
||||
|
||||
// Get the background image URL directly from the DOM
|
||||
const imageUrlValue = document.querySelector('#background-image-url').value;
|
||||
@@ -492,10 +471,8 @@ class ConfigManager {
|
||||
this.config.darkMode = darkModeSelect.value;
|
||||
this.config.backgroundImage = imageUrlValue;
|
||||
this.config.backgroundOpacity = parseFloat(backgroundOpacity.value);
|
||||
this.config.tickerSpeed = parseInt(tickerSpeed.value);
|
||||
this.config.combineSameDirection = combineDirections.checked;
|
||||
this.config.sites = sitesConfig;
|
||||
this.config.rssFeeds = feedsConfig;
|
||||
|
||||
// Save config
|
||||
this.saveConfig();
|
||||
@@ -503,11 +480,6 @@ class ConfigManager {
|
||||
// Sync with server
|
||||
this.syncConfig();
|
||||
|
||||
// Update ticker speed if changed
|
||||
if (window.tickerManager && this.config.tickerSpeed) {
|
||||
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
|
||||
}
|
||||
|
||||
// Apply config
|
||||
this.applyConfig();
|
||||
|
||||
@@ -532,9 +504,6 @@ class ConfigManager {
|
||||
// Apply background image and opacity
|
||||
this.applyBackgroundImage();
|
||||
|
||||
// Apply ticker speed
|
||||
this.applyTickerSpeed();
|
||||
|
||||
// Ensure content wrapper exists when changing to rotated modes
|
||||
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(this.config.orientation)) {
|
||||
this.ensureContentWrapper();
|
||||
@@ -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
|
||||
*/
|
||||
@@ -668,26 +628,401 @@ class ConfigManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for RSS feeds configuration
|
||||
* Search for transit sites
|
||||
*/
|
||||
generateRssFeedsHTML() {
|
||||
if (!this.config.rssFeeds || this.config.rssFeeds.length === 0) {
|
||||
return '<div class="no-feeds">No RSS feeds configured</div>';
|
||||
async searchSites() {
|
||||
const searchInput = document.getElementById('site-search-input');
|
||||
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) => `
|
||||
<div class="feed-item" data-index="${index}">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 5px;">
|
||||
<input type="checkbox" class="feed-enabled" ${feed.enabled ? 'checked' : ''}>
|
||||
<input type="text" class="feed-name" value="${feed.name}" placeholder="Feed Name" style="flex: 1; margin: 0 5px;">
|
||||
<button class="remove-feed-button" style="padding: 2px 5px;">×</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<span style="margin-right: 5px;">URL:</span>
|
||||
<input type="text" class="feed-url" value="${feed.url}" placeholder="Feed URL" style="flex: 1;">
|
||||
</div>
|
||||
try {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">Searching...</div>';
|
||||
|
||||
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
|
||||
throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
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>
|
||||
`).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">×</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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
540
departures.js
540
departures.js
@@ -1,8 +1,14 @@
|
||||
// Calculate minutes until arrival
|
||||
function calculateMinutesUntilArrival(scheduledTime) {
|
||||
// Calculate minutes until arrival using expected time (or scheduled if expected not available)
|
||||
function calculateMinutesUntilArrival(departure) {
|
||||
const now = new Date();
|
||||
const scheduled = new Date(scheduledTime);
|
||||
return Math.round((scheduled - now) / (1000 * 60));
|
||||
// Use expected time if available (accounts for delays), otherwise use scheduled
|
||||
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
|
||||
@@ -33,31 +39,46 @@ function createDepartureCard(departure) {
|
||||
departureCard.dataset.journeyId = departure.journey.id;
|
||||
|
||||
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
|
||||
const departureTime = new Date(departure.scheduled);
|
||||
const departureTimeDate = new Date(departureTime);
|
||||
const now = new Date();
|
||||
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
|
||||
const diffMinutes = Math.round((departureTimeDate - now) / (1000 * 60));
|
||||
const isWithinNextHour = diffMinutes <= 60;
|
||||
|
||||
// Add condensed class if within next hour
|
||||
departureCard.className = isWithinNextHour ? 'departure-card condensed' : 'departure-card';
|
||||
|
||||
// Check if the display time is just a time (HH:MM) or a countdown
|
||||
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||
|
||||
// If it's just a time, calculate minutes until arrival
|
||||
// Calculate minutes until arrival using expected time (accounts for delays)
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure);
|
||||
let countdownText = displayTime;
|
||||
if (isTimeOnly) {
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||
if (minutesUntil <= 0) {
|
||||
countdownText = 'Now';
|
||||
} else if (minutesUntil === 1) {
|
||||
countdownText = '1 min';
|
||||
let countdownClass = '';
|
||||
|
||||
// Determine color class based on minutesUntil, regardless of displayTime format
|
||||
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
|
||||
countdownText = 'Nu';
|
||||
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 {
|
||||
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
|
||||
@@ -72,8 +93,8 @@ function createDepartureCard(departure) {
|
||||
<span class="line-destination">${departure.destination}</span>
|
||||
</span>
|
||||
<span class="time">
|
||||
<span class="arrival-time">${scheduledTime}</span>
|
||||
<span class="countdown">(${countdownText})</span>
|
||||
<span class="arrival-time">${timeDisplay}</span>
|
||||
<span class="countdown ${countdownClass}">(${countdownText})</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -81,92 +102,156 @@ function createDepartureCard(departure) {
|
||||
return departureCard;
|
||||
}
|
||||
|
||||
// Display departures grouped by line number
|
||||
// Display departures grouped by line number - New card-based layout
|
||||
function displayGroupedDeparturesByLine(groups, container) {
|
||||
groups.forEach(group => {
|
||||
// Create a card for this line number
|
||||
const groupCard = document.createElement('div');
|
||||
groupCard.className = 'departure-card line-card';
|
||||
|
||||
// Create card header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'departure-header';
|
||||
// Get transport mode for styling - ensure we use the API value
|
||||
const apiTransportMode = group.line?.transportMode || '';
|
||||
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);
|
||||
|
||||
// Add line number with transport icon
|
||||
const lineNumber = document.createElement('span');
|
||||
lineNumber.className = 'line-number';
|
||||
lineNumberBox.innerHTML = `
|
||||
<div class="transport-mode-icon">${transportIcon}</div>
|
||||
<div class="line-number-large">${group.lineNumber}</div>
|
||||
`;
|
||||
groupCard.appendChild(lineNumberBox);
|
||||
|
||||
// Use the first destination as the main one for the header
|
||||
const mainDestination = group.directions[0]?.destination || '';
|
||||
// Create directions wrapper on the right
|
||||
const directionsWrapper = document.createElement('div');
|
||||
directionsWrapper.className = 'directions-wrapper';
|
||||
|
||||
lineNumber.innerHTML = `${transportIcon} ${group.lineNumber} <span class="line-destination">${mainDestination}</span>`;
|
||||
header.appendChild(lineNumber);
|
||||
groupCard.appendChild(header);
|
||||
|
||||
// Create the directions container
|
||||
const directionsContainer = document.createElement('div');
|
||||
directionsContainer.className = 'directions-container';
|
||||
|
||||
// Process each direction
|
||||
group.directions.forEach(direction => {
|
||||
// Sort departures by time
|
||||
direction.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
|
||||
// Process each direction (up to 2 directions side-by-side)
|
||||
const maxDirections = 2;
|
||||
group.directions.slice(0, maxDirections).forEach(direction => {
|
||||
// Sort departures by expected time (or scheduled if expected not available)
|
||||
direction.departures.sort((a, b) => {
|
||||
const timeA = getDepartureTime(a);
|
||||
const timeB = getDepartureTime(b);
|
||||
return new Date(timeA) - new Date(timeB);
|
||||
});
|
||||
|
||||
// Create a row for this direction
|
||||
const directionRow = document.createElement('div');
|
||||
directionRow.className = 'direction-row';
|
||||
|
||||
// Add direction info
|
||||
// Add direction info (arrow + destination)
|
||||
const directionInfo = document.createElement('div');
|
||||
directionInfo.className = 'direction-info';
|
||||
|
||||
// Determine direction arrow
|
||||
const directionArrow = direction.direction === 1 ? '→' : '←';
|
||||
// Determine direction arrow and styling from API data
|
||||
// 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);
|
||||
|
||||
// Add times container
|
||||
const timesContainer = document.createElement('div');
|
||||
timesContainer.className = 'times-container';
|
||||
|
||||
// Add up to 2 departure times per direction
|
||||
const maxTimes = 2;
|
||||
direction.departures.slice(0, maxTimes).forEach(departure => {
|
||||
const timeElement = document.createElement('span');
|
||||
timeElement.className = 'time';
|
||||
// Get first two departures for time range
|
||||
const firstDeparture = direction.departures[0];
|
||||
const secondDeparture = direction.departures[1];
|
||||
|
||||
const displayTime = departure.display;
|
||||
const scheduledTime = formatDateTime(departure.scheduled);
|
||||
if (firstDeparture) {
|
||||
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
|
||||
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||
|
||||
// If it's just a time, calculate minutes until arrival
|
||||
// Calculate minutes until arrival using expected time (accounts for delays)
|
||||
const minutesUntil = calculateMinutesUntilArrival(firstDeparture);
|
||||
let countdownText = displayTime;
|
||||
if (isTimeOnly) {
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||
if (minutesUntil <= 0) {
|
||||
countdownText = 'Now';
|
||||
} else if (minutesUntil === 1) {
|
||||
countdownText = '1 min';
|
||||
let countdownClass = '';
|
||||
|
||||
// Determine color class based on minutesUntil, regardless of displayTime format
|
||||
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
|
||||
countdownText = 'Nu';
|
||||
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 {
|
||||
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>`;
|
||||
timesContainer.appendChild(timeElement);
|
||||
});
|
||||
// Create time display element
|
||||
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);
|
||||
directionsContainer.appendChild(directionRow);
|
||||
directionsWrapper.appendChild(directionRow);
|
||||
});
|
||||
|
||||
groupCard.appendChild(directionsContainer);
|
||||
groupCard.appendChild(directionsWrapper);
|
||||
|
||||
// Add to container
|
||||
container.appendChild(groupCard);
|
||||
@@ -176,8 +261,12 @@ function displayGroupedDeparturesByLine(groups, container) {
|
||||
// Display grouped departures (legacy function)
|
||||
function displayGroupedDepartures(groups, container) {
|
||||
groups.forEach(group => {
|
||||
// Sort departures by time
|
||||
group.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
|
||||
// Sort departures by expected time (or scheduled if expected not available)
|
||||
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
|
||||
const groupCard = document.createElement('div');
|
||||
@@ -212,29 +301,40 @@ function displayGroupedDepartures(groups, container) {
|
||||
timeElement.style.marginBottom = '2px';
|
||||
|
||||
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
|
||||
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||
|
||||
// If it's just a time, calculate minutes until arrival
|
||||
// Calculate minutes until arrival using expected time (accounts for delays)
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure);
|
||||
let countdownText = displayTime;
|
||||
if (isTimeOnly) {
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||
if (minutesUntil <= 0) {
|
||||
countdownText = 'Now';
|
||||
} else if (minutesUntil === 1) {
|
||||
countdownText = '1 min';
|
||||
let countdownClass = '';
|
||||
|
||||
// Determine color class based on minutesUntil, regardless of displayTime format
|
||||
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
|
||||
countdownText = 'Nu';
|
||||
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 {
|
||||
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">(${countdownText})</span>`;
|
||||
} else {
|
||||
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${displayTime})</span>`;
|
||||
}
|
||||
timeElement.innerHTML = `${scheduledTime} <span class="countdown ${countdownClass}">(${countdownText})</span>`;
|
||||
|
||||
timesContainer.appendChild(timeElement);
|
||||
});
|
||||
@@ -271,7 +371,7 @@ function formatRelativeTime(dateTimeString) {
|
||||
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
|
||||
|
||||
if (diffMinutes <= 0) {
|
||||
return 'Now';
|
||||
return 'Nu';
|
||||
} else if (diffMinutes === 1) {
|
||||
return 'In 1 minute';
|
||||
} 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]) {
|
||||
groups[lineNumber].directions[directionKey] = {
|
||||
direction: departure.direction,
|
||||
direction: departureDirection,
|
||||
destination: departure.destination,
|
||||
departures: []
|
||||
};
|
||||
@@ -433,32 +539,59 @@ function updateExistingCards(newDepartures) {
|
||||
// Update only the content that has changed in an existing card
|
||||
function updateCardContent(card, departure) {
|
||||
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
|
||||
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||
|
||||
// If it's just a time, calculate minutes until arrival
|
||||
// Calculate minutes until arrival using expected time (accounts for delays)
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure);
|
||||
let countdownText = displayTime;
|
||||
if (isTimeOnly) {
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||
if (minutesUntil <= 0) {
|
||||
countdownText = 'Now';
|
||||
} else if (minutesUntil === 1) {
|
||||
countdownText = '1 min';
|
||||
let countdownClass = '';
|
||||
|
||||
// Determine color class based on minutesUntil, regardless of displayTime format
|
||||
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
|
||||
countdownText = 'Nu';
|
||||
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 {
|
||||
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
|
||||
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
|
||||
if (countdownElement && countdownElement.textContent !== `(${countdownText})`) {
|
||||
if (countdownElement.textContent !== `(${countdownText})`) {
|
||||
countdownElement.textContent = `(${countdownText})`;
|
||||
highlightElement(countdownElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a subtle highlight effect to show updated content
|
||||
@@ -499,202 +632,11 @@ function displayMultipleSites(sites) {
|
||||
|
||||
// Process departures for this site
|
||||
if (site.data && site.data.departures) {
|
||||
// Group departures by line number
|
||||
const lineGroups = {};
|
||||
// Group departures by line number using the existing function
|
||||
const lineGroups = groupDeparturesByLineNumber(site.data.departures);
|
||||
|
||||
site.data.departures.forEach(departure => {
|
||||
const lineNumber = departure.line.designation;
|
||||
if (!lineGroups[lineNumber]) {
|
||||
lineGroups[lineNumber] = [];
|
||||
}
|
||||
lineGroups[lineNumber].push(departure);
|
||||
});
|
||||
|
||||
// Process each line group
|
||||
Object.entries(lineGroups).forEach(([lineNumber, lineDepartures]) => {
|
||||
// Create a line container for side-by-side display
|
||||
const lineContainer = document.createElement('div');
|
||||
lineContainer.className = 'line-container';
|
||||
|
||||
// Group by direction
|
||||
const directionGroups = {};
|
||||
|
||||
lineDepartures.forEach(departure => {
|
||||
const directionKey = `${departure.direction}-${departure.destination}`;
|
||||
if (!directionGroups[directionKey]) {
|
||||
directionGroups[directionKey] = {
|
||||
direction: departure.direction,
|
||||
destination: departure.destination,
|
||||
departures: []
|
||||
};
|
||||
}
|
||||
directionGroups[directionKey].departures.push(departure);
|
||||
});
|
||||
|
||||
// Get all direction groups
|
||||
const directions = Object.values(directionGroups);
|
||||
|
||||
// Handle single direction case (like bus 4)
|
||||
if (directions.length === 1) {
|
||||
// Create a full-width card for this direction
|
||||
const directionGroup = directions[0];
|
||||
|
||||
// Sort departures by time
|
||||
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
|
||||
|
||||
// Create a card for this direction
|
||||
const directionCard = document.createElement('div');
|
||||
directionCard.className = 'departure-card';
|
||||
// Don't set width to 100% as it causes the card to stick out
|
||||
|
||||
// Create a simplified layout with line number and times on the same row
|
||||
const cardContent = document.createElement('div');
|
||||
cardContent.className = 'departure-header';
|
||||
cardContent.style.display = 'flex';
|
||||
cardContent.style.justifyContent = 'space-between';
|
||||
cardContent.style.alignItems = 'center';
|
||||
|
||||
// Get transport icon based on transport mode and line
|
||||
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
|
||||
|
||||
// Add line number with transport icon and destination
|
||||
const lineNumberElement = document.createElement('span');
|
||||
lineNumberElement.className = 'line-number';
|
||||
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} <span class="line-destination">${directionGroup.destination}</span>`;
|
||||
|
||||
// Add times container
|
||||
const timesContainer = document.createElement('div');
|
||||
timesContainer.className = 'times-container';
|
||||
timesContainer.style.display = 'flex';
|
||||
timesContainer.style.flexDirection = 'column';
|
||||
timesContainer.style.alignItems = 'flex-end';
|
||||
|
||||
// Add up to 2 departure times
|
||||
const maxTimes = 2;
|
||||
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
|
||||
const timeElement = document.createElement('div');
|
||||
timeElement.className = 'time';
|
||||
timeElement.style.fontSize = '1.1em';
|
||||
timeElement.style.marginBottom = '2px';
|
||||
timeElement.style.whiteSpace = 'nowrap';
|
||||
timeElement.style.textAlign = 'right';
|
||||
|
||||
const displayTime = departure.display;
|
||||
const scheduledTime = formatDateTime(departure.scheduled);
|
||||
|
||||
// Check if the display time is just a time (HH:MM) or a countdown
|
||||
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||
|
||||
// If it's just a time, calculate minutes until arrival
|
||||
let countdownText = displayTime;
|
||||
if (isTimeOnly) {
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||
if (minutesUntil <= 0) {
|
||||
countdownText = 'Now';
|
||||
} else if (minutesUntil === 1) {
|
||||
countdownText = '1 min';
|
||||
} else {
|
||||
countdownText = `${minutesUntil} min`;
|
||||
}
|
||||
}
|
||||
|
||||
timeElement.textContent = `${scheduledTime} (${countdownText})`;
|
||||
timeElement.style.width = '140px'; // Fixed width to prevent overflow
|
||||
timeElement.style.width = '140px'; // Fixed width to prevent overflow
|
||||
|
||||
timesContainer.appendChild(timeElement);
|
||||
});
|
||||
|
||||
cardContent.appendChild(lineNumberElement);
|
||||
cardContent.appendChild(timesContainer);
|
||||
directionCard.appendChild(cardContent);
|
||||
siteContainer.appendChild(directionCard);
|
||||
} else {
|
||||
// Create cards for each direction, with max 2 per row
|
||||
// Create a new line container for every 2 directions
|
||||
for (let i = 0; i < directions.length; i += 2) {
|
||||
// Create a new line container for this pair of directions
|
||||
const rowContainer = document.createElement('div');
|
||||
rowContainer.className = 'line-container';
|
||||
|
||||
// Process up to 2 directions for this row
|
||||
for (let j = i; j < i + 2 && j < directions.length; j++) {
|
||||
const directionGroup = directions[j];
|
||||
|
||||
// Sort departures by time
|
||||
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
|
||||
|
||||
// Create a card for this direction
|
||||
const directionCard = document.createElement('div');
|
||||
directionCard.className = 'departure-card direction-card';
|
||||
|
||||
// Create a simplified layout with line number and times on the same row
|
||||
const cardContent = document.createElement('div');
|
||||
cardContent.className = 'departure-header';
|
||||
cardContent.style.display = 'flex';
|
||||
cardContent.style.justifyContent = 'space-between';
|
||||
cardContent.style.alignItems = 'center';
|
||||
|
||||
// Get transport icon based on transport mode and line
|
||||
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
|
||||
|
||||
// Add line number with transport icon and destination
|
||||
const lineNumberElement = document.createElement('span');
|
||||
lineNumberElement.className = 'line-number';
|
||||
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} <span class="line-destination">${directionGroup.destination}</span>`;
|
||||
|
||||
// Add times container
|
||||
const timesContainer = document.createElement('div');
|
||||
timesContainer.className = 'times-container';
|
||||
timesContainer.style.display = 'flex';
|
||||
timesContainer.style.flexDirection = 'column';
|
||||
timesContainer.style.alignItems = 'flex-end';
|
||||
|
||||
// Add up to 2 departure times
|
||||
const maxTimes = 2;
|
||||
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
|
||||
const timeElement = document.createElement('div');
|
||||
timeElement.className = 'time';
|
||||
timeElement.style.fontSize = '1.1em';
|
||||
timeElement.style.marginBottom = '2px';
|
||||
timeElement.style.whiteSpace = 'nowrap';
|
||||
timeElement.style.textAlign = 'right';
|
||||
|
||||
const displayTime = departure.display;
|
||||
const scheduledTime = formatDateTime(departure.scheduled);
|
||||
|
||||
// Check if the display time is just a time (HH:MM) or a countdown
|
||||
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||
|
||||
// If it's just a time, calculate minutes until arrival
|
||||
let countdownText = displayTime;
|
||||
if (isTimeOnly) {
|
||||
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
|
||||
if (minutesUntil <= 0) {
|
||||
countdownText = 'Now';
|
||||
} else if (minutesUntil === 1) {
|
||||
countdownText = '1 min';
|
||||
} else {
|
||||
countdownText = `${minutesUntil} min`;
|
||||
}
|
||||
}
|
||||
|
||||
timeElement.textContent = `${scheduledTime} (${countdownText})`;
|
||||
|
||||
timesContainer.appendChild(timeElement);
|
||||
});
|
||||
|
||||
cardContent.appendChild(lineNumberElement);
|
||||
cardContent.appendChild(timesContainer);
|
||||
directionCard.appendChild(cardContent);
|
||||
rowContainer.appendChild(directionCard);
|
||||
}
|
||||
|
||||
// Add this row to the site container
|
||||
siteContainer.appendChild(rowContainer);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Use the new card-based layout function
|
||||
displayGroupedDeparturesByLine(lineGroups, siteContainer);
|
||||
} else if (site.error) {
|
||||
// Display error for this site
|
||||
const errorElement = document.createElement('div');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -12,7 +12,7 @@ The system consists of the following components:
|
||||
2. **Configuration Manager** - Manages system settings and UI customization
|
||||
3. **Weather Component** - Displays weather data and manages dark mode
|
||||
4. **Clock Component** - Shows current time and date
|
||||
5. **Ticker Component** - Displays scrolling news from RSS feeds
|
||||
5. **Departures Component** - Displays transit departure information
|
||||
6. **Main UI** - Responsive layout with multiple orientation support
|
||||
|
||||
## File Structure
|
||||
@@ -22,7 +22,7 @@ The system consists of the following components:
|
||||
- `config.js` - Configuration management module
|
||||
- `weather.js` - Weather display and dark mode management
|
||||
- `clock.js` - Time and date display
|
||||
- `ticker.js` - News ticker component
|
||||
- `departures.js` - Transit departure display component
|
||||
|
||||
## 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
|
||||
- **API Proxying**:
|
||||
- `/api/departures` - Proxies requests to SL Transport API
|
||||
- `/api/rss` - Proxies and parses RSS feeds
|
||||
- **Error Handling**: Provides structured error responses
|
||||
- **Static File Serving**: Serves HTML, CSS, JavaScript, and image files
|
||||
- **CORS Support**: Allows cross-origin requests
|
||||
@@ -43,7 +42,6 @@ The server component acts as a proxy for external APIs and serves the static fil
|
||||
#### API Endpoints:
|
||||
|
||||
- `GET /api/departures` - Returns transit departure information
|
||||
- `GET /api/rss` - Returns parsed RSS feed items
|
||||
- `GET /` or `/index.html` - Serves the main application
|
||||
- `GET /*.js|css|png|jpg|jpeg|gif|ico` - Serves static assets
|
||||
|
||||
@@ -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)
|
||||
- **Dark Mode**: Automatic (based on sunrise/sunset), always on, or always off
|
||||
- **Background Image**: Custom background with opacity control
|
||||
- **Ticker Speed**: Controls the scrolling speed of the news ticker
|
||||
- **Settings Persistence**: Saves settings to localStorage
|
||||
- **Configuration UI**: Modal-based interface with live previews
|
||||
|
||||
@@ -70,7 +67,6 @@ The Configuration Manager handles all system settings and provides a UI for chan
|
||||
- `darkMode`: Dark mode setting (`auto`, `on`, `off`)
|
||||
- `backgroundImage`: URL or data URL of background image
|
||||
- `backgroundOpacity`: Opacity value between 0 and 1
|
||||
- `tickerSpeed`: Scroll speed in seconds for one complete cycle
|
||||
|
||||
#### Implementation Details:
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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:
|
||||
|
||||
- **RSS Integration**: Fetches and displays items from RSS feeds
|
||||
- **Smooth Animation**: CSS-based scrolling with configurable speed
|
||||
- **Fallback Content**: Provides default items when RSS feed is unavailable
|
||||
- **Themed Display**: Red, white, and blue color scheme
|
||||
- **Orientation Support**: Properly rotates with screen orientation changes
|
||||
|
||||
#### Implementation Details:
|
||||
|
||||
The TickerManager class creates a fixed container at the bottom of the screen and populates it with items from an RSS feed. It uses CSS animations for scrolling and adjusts the animation direction based on screen orientation.
|
||||
- **Real-time Updates**: Fetches departure data periodically
|
||||
- **Multiple Sites**: Supports displaying departures from multiple transit stops
|
||||
- **Grouped Display**: Groups departures by line and direction
|
||||
- **Countdown Timers**: Shows time until departure
|
||||
|
||||
### 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
|
||||
- Look for errors in browser console
|
||||
|
||||
4. **Ticker not scrolling**
|
||||
- Check if RSS feed is accessible
|
||||
- Verify ticker speed setting is not set to 0
|
||||
- Look for JavaScript errors in console
|
||||
|
||||
5. **Screen orientation issues**
|
||||
@@ -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:
|
||||
|
||||
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 |
|
||||
| - Clock | | - SL Transport API |
|
||||
| - Weather | | - OpenWeatherMap |
|
||||
| - Ticker | | - RSS Feeds |
|
||||
| - Departures | | |
|
||||
| - 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
|
||||
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
|
||||
5. Server proxies requests to external APIs and returns responses
|
||||
6. Components update their UI based on the data
|
||||
|
||||
1123
index.html
1123
index.html
File diff suppressed because it is too large
Load Diff
251
rss.js
251
rss.js
@@ -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
454
server.js
@@ -12,13 +12,6 @@ let config = {
|
||||
name: 'Ambassaderna',
|
||||
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
|
||||
loadSitesConfig();
|
||||
|
||||
// Feed templates for different formats
|
||||
const feedTemplates = {
|
||||
rss2: {
|
||||
detect: (data) => data.includes('<rss version="2.0"'),
|
||||
itemPath: 'item',
|
||||
titlePath: 'title',
|
||||
descPath: 'description',
|
||||
datePath: 'pubDate'
|
||||
},
|
||||
atom: {
|
||||
detect: (data) => data.includes('<feed xmlns="http://www.w3.org/2005/Atom"'),
|
||||
itemPath: 'entry',
|
||||
titlePath: 'title',
|
||||
descPath: 'content',
|
||||
datePath: 'updated'
|
||||
},
|
||||
rss1: {
|
||||
detect: (data) => data.includes('<rdf:RDF'),
|
||||
itemPath: 'item',
|
||||
titlePath: 'title',
|
||||
descPath: 'description',
|
||||
datePath: 'dc:date'
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to detect feed type
|
||||
function detectFeedType(data) {
|
||||
for (const [type, template] of Object.entries(feedTemplates)) {
|
||||
if (template.detect(data)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return 'rss2'; // Default to RSS 2.0
|
||||
}
|
||||
|
||||
// Helper function to clean HTML content
|
||||
function cleanHtmlContent(content) {
|
||||
if (!content) return '';
|
||||
|
||||
// Remove CDATA if present
|
||||
content = content.replace(/<!\[CDATA\[(.*?)\]\]>/gs, '$1');
|
||||
|
||||
// Remove HTML tags
|
||||
content = content.replace(/<[^>]+>/g, ' ');
|
||||
|
||||
// Convert common HTML entities
|
||||
content = content
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
|
||||
.replace(/&#x([A-Fa-f0-9]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||
|
||||
// Remove extra whitespace
|
||||
content = content.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Get first sentence if content is too long
|
||||
if (content.length > 200) {
|
||||
const match = content.match(/^.*?[.!?](?:\s|$)/);
|
||||
if (match) {
|
||||
content = match[0].trim();
|
||||
} else {
|
||||
content = content.substring(0, 197) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// Function to extract content based on template
|
||||
function extractContent(content, template) {
|
||||
const itemRegex = new RegExp(`<${template.itemPath}>([\\\s\\\S]*?)<\/${template.itemPath}>`, 'g');
|
||||
const titleRegex = new RegExp(`<${template.titlePath}>([\\\s\\\S]*?)<\/${template.titlePath}>`);
|
||||
const descRegex = new RegExp(`<${template.descPath}>([\\\s\\\S]*?)<\/${template.descPath}>`);
|
||||
const dateRegex = new RegExp(`<${template.datePath}>([\\\s\\\S]*?)<\/${template.datePath}>`);
|
||||
const linkRegex = /<link[^>]*?>([\s\S]*?)<\/link>/;
|
||||
|
||||
const items = [];
|
||||
let match;
|
||||
|
||||
while ((match = itemRegex.exec(content)) !== null) {
|
||||
const itemContent = match[1];
|
||||
|
||||
const titleMatch = titleRegex.exec(itemContent);
|
||||
const descMatch = descRegex.exec(itemContent);
|
||||
const dateMatch = dateRegex.exec(itemContent);
|
||||
const linkMatch = linkRegex.exec(itemContent);
|
||||
|
||||
const title = cleanHtmlContent(titleMatch ? titleMatch[1] : '');
|
||||
const description = cleanHtmlContent(descMatch ? descMatch[1] : '');
|
||||
|
||||
// Create display text from title and optionally description
|
||||
let displayText = title;
|
||||
if (description && description !== title) {
|
||||
displayText = `${title}: ${description}`;
|
||||
}
|
||||
|
||||
items.push({
|
||||
title,
|
||||
displayText,
|
||||
link: linkMatch ? linkMatch[1].trim() : '',
|
||||
description,
|
||||
pubDate: dateMatch ? dateMatch[1] : '',
|
||||
feedUrl: null // Will be set by caller
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Function to fetch RSS feed data from a single URL
|
||||
async function fetchSingleRssFeed(feedUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`Fetching RSS feed from: ${feedUrl}`);
|
||||
|
||||
const request = https.get(feedUrl, (res) => {
|
||||
console.log(`RSS feed response status: ${res.statusCode}`);
|
||||
console.log('RSS feed response headers:', res.headers);
|
||||
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
// Log first part of response
|
||||
console.log('RSS feed raw response (first 500 chars):', data.substring(0, 500));
|
||||
|
||||
// Detect feed type and get appropriate template
|
||||
const feedType = detectFeedType(data);
|
||||
const template = feedTemplates[feedType];
|
||||
console.log(`Detected feed type: ${feedType}`);
|
||||
|
||||
// Extract and process items
|
||||
const items = extractContent(data, template);
|
||||
console.log(`Extracted ${items.length} items from feed`);
|
||||
|
||||
// Add feed URL to each item
|
||||
items.forEach(item => {
|
||||
item.feedUrl = feedUrl;
|
||||
});
|
||||
|
||||
resolve(items);
|
||||
} catch (error) {
|
||||
console.error('Error processing RSS feed:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
resolve([
|
||||
{
|
||||
title: 'Error processing RSS feed',
|
||||
link: feedUrl,
|
||||
feedUrl: feedUrl,
|
||||
error: error.message
|
||||
}
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
console.error('Error fetching RSS feed:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
resolve([
|
||||
{
|
||||
title: 'Error fetching RSS feed',
|
||||
link: feedUrl,
|
||||
feedUrl: feedUrl,
|
||||
error: error.message
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
// Set a timeout of 10 seconds
|
||||
request.setTimeout(10000, () => {
|
||||
console.error('RSS feed request timed out:', feedUrl);
|
||||
request.destroy();
|
||||
resolve([
|
||||
{
|
||||
title: 'RSS feed request timed out',
|
||||
link: feedUrl,
|
||||
feedUrl: feedUrl,
|
||||
error: 'Request timed out after 10 seconds'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Function to fetch all enabled RSS feeds
|
||||
async function fetchRssFeed() {
|
||||
try {
|
||||
const enabledFeeds = config.rssFeeds.filter(feed => feed.enabled);
|
||||
const feedPromises = enabledFeeds.map(feed => fetchSingleRssFeed(feed.url));
|
||||
|
||||
const allItems = await Promise.all(feedPromises);
|
||||
|
||||
// Flatten array and sort by date (if available)
|
||||
const items = allItems.flat().sort((a, b) => {
|
||||
const dateA = new Date(a.pubDate || 0);
|
||||
const dateB = new Date(b.pubDate || 0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return { items };
|
||||
} catch (error) {
|
||||
console.error('Error fetching RSS feeds:', error);
|
||||
return {
|
||||
items: [{
|
||||
title: 'Error fetching RSS feeds',
|
||||
link: '#',
|
||||
error: error.message
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Function to fetch data from the SL Transport API for a specific site
|
||||
function fetchDeparturesForSite(siteId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -309,11 +83,19 @@ function fetchDeparturesForSite(siteId) {
|
||||
const parsedData = JSON.parse(data);
|
||||
console.log('Successfully parsed fixed JSON');
|
||||
|
||||
if (parsedData && parsedData.departures) {
|
||||
const line7Departures = parsedData.departures.filter(d => d.line && d.line.designation === '7');
|
||||
if (line7Departures.length > 0) {
|
||||
console.log('Line 7 details:', JSON.stringify(line7Departures[0], null, 2));
|
||||
}
|
||||
if (parsedData && parsedData.departures && parsedData.departures.length > 0) {
|
||||
// Log first departure to see structure
|
||||
console.log('Sample departure structure:', JSON.stringify(parsedData.departures[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);
|
||||
@@ -405,6 +187,184 @@ const server = http.createServer(async (req, res) => {
|
||||
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') {
|
||||
// Return the current configuration
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -420,7 +380,7 @@ const server = http.createServer(async (req, res) => {
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const newConfig = JSON.parse(body);
|
||||
if (newConfig.sites && newConfig.rssFeeds) {
|
||||
if (newConfig.sites) {
|
||||
config = newConfig;
|
||||
|
||||
// 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
|
||||
else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') {
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -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"
|
||||
@@ -1,20 +1,27 @@
|
||||
{
|
||||
"orientation": "normal",
|
||||
"darkMode": "auto",
|
||||
"backgroundImage": "",
|
||||
"backgroundImage": "https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000",
|
||||
"backgroundOpacity": 0.3,
|
||||
"tickerSpeed": 60,
|
||||
"sites": [
|
||||
{
|
||||
"id": "1411",
|
||||
"name": "Ambassaderna",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"rssFeeds": [
|
||||
},
|
||||
{
|
||||
"name": "Travel Alerts",
|
||||
"url": "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
||||
"id": "1410",
|
||||
"name": "Berwaldhallen",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "1100",
|
||||
"name": "Djurgårdsbron",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "1110",
|
||||
"name": "Radiohuset",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
|
||||
297
ticker.js
297
ticker.js
@@ -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;
|
||||
102
weather.js
102
weather.js
@@ -131,11 +131,13 @@ class WeatherManager {
|
||||
* Process current weather data from API response
|
||||
*/
|
||||
processCurrentWeather(data) {
|
||||
const iconCode = data.weather[0].icon;
|
||||
return {
|
||||
temperature: Math.round(data.main.temp * 10) / 10, // Round to 1 decimal place
|
||||
condition: data.weather[0].main,
|
||||
description: data.weather[0].description,
|
||||
icon: this.getWeatherIconUrl(data.weather[0].icon),
|
||||
icon: this.getWeatherIconUrl(iconCode),
|
||||
iconCode: iconCode, // Store icon code for classification
|
||||
wind: {
|
||||
speed: Math.round(data.wind.speed * 3.6), // Convert m/s to km/h
|
||||
direction: data.wind.deg
|
||||
@@ -155,11 +157,13 @@ class WeatherManager {
|
||||
processForecast(data) {
|
||||
// Get the next 7 forecasts (covering about 24 hours)
|
||||
return data.list.slice(0, 7).map(item => {
|
||||
const iconCode = item.weather[0].icon;
|
||||
return {
|
||||
temperature: Math.round(item.main.temp * 10) / 10,
|
||||
condition: item.weather[0].main,
|
||||
description: item.weather[0].description,
|
||||
icon: this.getWeatherIconUrl(item.weather[0].icon),
|
||||
icon: this.getWeatherIconUrl(iconCode),
|
||||
iconCode: iconCode, // Store icon code for classification
|
||||
timestamp: new Date(item.dt * 1000),
|
||||
precipitation: item.rain ? (item.rain['3h'] || 0) : 0
|
||||
};
|
||||
@@ -173,6 +177,42 @@ class WeatherManager {
|
||||
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
|
||||
*/
|
||||
@@ -182,6 +222,7 @@ class WeatherManager {
|
||||
condition: 'Clear',
|
||||
description: 'clear sky',
|
||||
icon: 'https://openweathermap.org/img/wn/01d@2x.png',
|
||||
iconCode: '01d',
|
||||
wind: {
|
||||
speed: 14.8,
|
||||
direction: 270
|
||||
@@ -209,9 +250,10 @@ class WeatherManager {
|
||||
|
||||
forecasts.push({
|
||||
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',
|
||||
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,
|
||||
precipitation: 0
|
||||
});
|
||||
@@ -242,6 +284,18 @@ class WeatherManager {
|
||||
if (iconElement) {
|
||||
iconElement.src = this.weatherData.icon;
|
||||
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');
|
||||
@@ -255,16 +309,29 @@ class WeatherManager {
|
||||
// Clear existing forecast
|
||||
forecastContainer.innerHTML = '';
|
||||
|
||||
// Add current weather as "Now"
|
||||
// Add current weather as "Nu" (Swedish for "Now")
|
||||
const nowElement = document.createElement('div');
|
||||
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 = `
|
||||
<div class="time">Now</div>
|
||||
<div class="icon">
|
||||
<img src="${this.weatherData.icon}" alt="${this.weatherData.description}" width="40" />
|
||||
</div>
|
||||
<div class="time">Nu</div>
|
||||
<div class="icon"></div>
|
||||
<div class="temp">${this.weatherData.temperature} °C</div>
|
||||
`;
|
||||
nowElement.querySelector('.icon').appendChild(nowIcon);
|
||||
forecastContainer.appendChild(nowElement);
|
||||
|
||||
// Add hourly forecasts
|
||||
@@ -274,13 +341,26 @@ class WeatherManager {
|
||||
|
||||
const forecastElement = document.createElement('div');
|
||||
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 = `
|
||||
<div class="time">${timeString}</div>
|
||||
<div class="icon">
|
||||
<img src="${forecast.icon}" alt="${forecast.description}" width="40" />
|
||||
</div>
|
||||
<div class="icon"></div>
|
||||
<div class="temp">${forecast.temperature} °C</div>
|
||||
`;
|
||||
forecastElement.querySelector('.icon').appendChild(forecastIcon);
|
||||
forecastContainer.appendChild(forecastElement);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user