Refactor: Complete codebase reorganization and modernization
- Split server.js routes into modular files (server/routes/) - departures.js: Departure data endpoints - sites.js: Site search and nearby sites - config.js: Configuration endpoints - Reorganized file structure following Node.js best practices: - Moved sites-config.json to config/sites.json - Moved API_RESPONSE_DOCUMENTATION.md to docs/ - Moved raspberry-pi-setup.sh to scripts/ - Archived legacy files to archive/ directory - Updated all code references to new file locations - Added archive/ to .gitignore to exclude legacy files from repo - Updated README.md with new structure and organization - All functionality tested and working correctly Version: 1.2.0
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=3002
|
||||||
|
|
||||||
|
# OpenWeatherMap API Key
|
||||||
|
# Get your API key from: https://openweathermap.org/api
|
||||||
|
OPENWEATHERMAP_API_KEY=your_openweathermap_api_key_here
|
||||||
|
|
||||||
|
# Default Location (Stockholm, Sweden)
|
||||||
|
DEFAULT_LATITUDE=59.3293
|
||||||
|
DEFAULT_LONGITUDE=18.0686
|
||||||
|
DEFAULT_LOCATION_NAME=Stockholm
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -56,3 +56,6 @@ pids
|
|||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
config.local.js
|
config.local.js
|
||||||
|
|
||||||
|
# Archive directory (legacy/development files)
|
||||||
|
archive/
|
||||||
124
README.md
124
README.md
@@ -64,15 +64,35 @@ A modern digital signage system for displaying real-time transit departures and
|
|||||||
cd SignageHTML
|
cd SignageHTML
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Start the server**
|
2. **Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
node server.js
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Open in browser**
|
3. **Configure environment variables**
|
||||||
|
|
||||||
|
Create a `.env` file in the root directory (or copy from `.env.example` if it exists):
|
||||||
|
```bash
|
||||||
|
PORT=3002
|
||||||
|
OPENWEATHERMAP_API_KEY=your_openweathermap_api_key_here
|
||||||
|
DEFAULT_LATITUDE=59.3293
|
||||||
|
DEFAULT_LONGITUDE=18.0686
|
||||||
|
DEFAULT_LOCATION_NAME=Stockholm
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your OpenWeatherMap API key from [openweathermap.org](https://openweathermap.org/api).
|
||||||
|
|
||||||
|
4. **Start the server**
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
# or for development with auto-reload:
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Open in browser**
|
||||||
Navigate to: `http://localhost:3002`
|
Navigate to: `http://localhost:3002`
|
||||||
|
|
||||||
The server will start on port 3002 by default. The application will automatically load and begin fetching transit and weather data.
|
The server will start on port 3002 by default (or the port specified in `.env`). The application will automatically load and begin fetching transit and weather data.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -95,19 +115,24 @@ The server will start on port 3002 by default. The application will automaticall
|
|||||||
2. Enter the Site Name and Site ID
|
2. Enter the Site Name and Site ID
|
||||||
3. Enable/disable the site as needed
|
3. Enable/disable the site as needed
|
||||||
|
|
||||||
### Changing Weather Location
|
### Environment Variables
|
||||||
|
|
||||||
Weather location is configured in `weather.js`. Update the default coordinates:
|
The application uses environment variables for configuration. Create a `.env` file in the root directory:
|
||||||
|
|
||||||
```javascript
|
```bash
|
||||||
window.weatherManager = new WeatherManager({
|
# Server port (default: 3002)
|
||||||
latitude: 59.3293, // Stockholm default
|
PORT=3002
|
||||||
longitude: 18.0686,
|
|
||||||
apiKey: 'your-api-key'
|
# OpenWeatherMap API key (required for weather features)
|
||||||
});
|
OPENWEATHERMAP_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# Default location coordinates (Stockholm, Sweden)
|
||||||
|
DEFAULT_LATITUDE=59.3293
|
||||||
|
DEFAULT_LONGITUDE=18.0686
|
||||||
|
DEFAULT_LOCATION_NAME=Stockholm
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the settings panel to configure it through the UI.
|
**Note**: The OpenWeatherMap API key is injected into the HTML at runtime by the server. You can also configure the weather location through the settings panel in the UI.
|
||||||
|
|
||||||
### Screen Orientation
|
### Screen Orientation
|
||||||
|
|
||||||
@@ -138,10 +163,10 @@ git clone http://192.168.68.53:3000/kyle/SignageHTML.git
|
|||||||
cd SignageHTML
|
cd SignageHTML
|
||||||
|
|
||||||
# Make the setup script executable
|
# Make the setup script executable
|
||||||
chmod +x raspberry-pi-setup.sh
|
chmod +x scripts/raspberry-pi-setup.sh
|
||||||
|
|
||||||
# Run the setup script as root
|
# Run the setup script as root
|
||||||
sudo ./raspberry-pi-setup.sh
|
sudo ./scripts/raspberry-pi-setup.sh
|
||||||
|
|
||||||
# Reboot to apply all changes
|
# Reboot to apply all changes
|
||||||
sudo reboot
|
sudo reboot
|
||||||
@@ -155,33 +180,44 @@ The system consists of the following components:
|
|||||||
|
|
||||||
1. **Node.js Server** (`server.js`)
|
1. **Node.js Server** (`server.js`)
|
||||||
- Handles API proxying (SL Transport API, OpenWeatherMap)
|
- Handles API proxying (SL Transport API, OpenWeatherMap)
|
||||||
- Serves static files
|
- Serves static files from `public/` directory
|
||||||
- Manages site configuration
|
- Manages site configuration
|
||||||
|
- Route handlers organized in `server/routes/`:
|
||||||
|
- `departures.js` - Transit departure data endpoints
|
||||||
|
- `sites.js` - Site search and nearby sites
|
||||||
|
- `config.js` - Configuration management endpoints
|
||||||
|
|
||||||
2. **Configuration Manager** (`config.js`)
|
2. **Configuration Manager** (`public/js/components/ConfigManager.js`)
|
||||||
- Manages system settings and UI customization
|
- Manages system settings and UI customization
|
||||||
- Handles site selection and map integration
|
- Handles site selection and map integration
|
||||||
- Persists settings to localStorage
|
- Persists settings to localStorage
|
||||||
|
|
||||||
3. **Weather Component** (`weather.js`)
|
3. **Weather Component** (`public/js/components/WeatherManager.js`)
|
||||||
- Displays weather data from OpenWeatherMap
|
- Displays weather data from OpenWeatherMap
|
||||||
- Manages dark mode based on sunrise/sunset
|
- Manages dark mode based on sunrise/sunset
|
||||||
- Shows hourly forecasts
|
- Shows hourly forecasts
|
||||||
|
|
||||||
4. **Clock Component** (`clock.js`)
|
4. **Clock Component** (`public/js/components/Clock.js`)
|
||||||
- Shows current time and date
|
- Shows current time and date
|
||||||
- Swedish locale formatting
|
- Swedish locale formatting
|
||||||
- Updates every second
|
- Updates every second
|
||||||
|
|
||||||
5. **Departures Component** (`departures.js`)
|
5. **Departures Component** (`public/js/components/DeparturesManager.js`)
|
||||||
- Fetches and displays transit departure information
|
- Fetches and displays transit departure information
|
||||||
- Groups departures by line number
|
- Groups departures by line number
|
||||||
- Handles direction indicators and timing
|
- Handles direction indicators and timing
|
||||||
|
|
||||||
6. **Main UI** (`index.html`)
|
6. **Utilities** (`public/js/utils/`)
|
||||||
|
- `constants.js` - Application constants and configuration
|
||||||
|
- `logger.js` - Centralized logging utility
|
||||||
|
|
||||||
|
7. **Main UI** (`index.html`)
|
||||||
- Responsive layout with multiple orientation support
|
- Responsive layout with multiple orientation support
|
||||||
- CSS Grid-based landscape optimization
|
- CSS Grid-based landscape optimization
|
||||||
- Modern styling with animations
|
- Modern styling with animations
|
||||||
|
- External CSS in `public/css/`:
|
||||||
|
- `main.css` - Base styles, dark mode, orientation styles
|
||||||
|
- `components.css` - Component-specific styles
|
||||||
|
|
||||||
## API Integration
|
## API Integration
|
||||||
|
|
||||||
@@ -196,7 +232,7 @@ Weather data is fetched from OpenWeatherMap:
|
|||||||
- Provides current conditions and hourly forecasts
|
- Provides current conditions and hourly forecasts
|
||||||
- Used for dark mode timing calculations
|
- Used for dark mode timing calculations
|
||||||
|
|
||||||
For detailed API response documentation, see [API_RESPONSE_DOCUMENTATION.md](API_RESPONSE_DOCUMENTATION.md).
|
For detailed API response documentation, see [docs/API_RESPONSE_DOCUMENTATION.md](docs/API_RESPONSE_DOCUMENTATION.md).
|
||||||
|
|
||||||
## UI Settings
|
## UI Settings
|
||||||
|
|
||||||
@@ -251,10 +287,13 @@ Settings are automatically saved to localStorage and persist across sessions.
|
|||||||
- Verify sites are configured in settings
|
- Verify sites are configured in settings
|
||||||
|
|
||||||
3. **Weather data not loading**
|
3. **Weather data not loading**
|
||||||
- Check OpenWeatherMap API key in `weather.js`
|
- Check OpenWeatherMap API key in `.env` file
|
||||||
|
- Ensure `.env` file exists in the root directory
|
||||||
|
- Verify the API key is correctly set: `OPENWEATHERMAP_API_KEY=your_key_here`
|
||||||
|
- Restart the server after changing `.env` file
|
||||||
- Verify internet connection
|
- Verify internet connection
|
||||||
- Look for errors in browser console (F12)
|
- Look for errors in browser console (F12)
|
||||||
- Check API key quota/limits
|
- Check API key quota/limits on openweathermap.org
|
||||||
|
|
||||||
4. **Map not loading**
|
4. **Map not loading**
|
||||||
- Ensure internet connection is active
|
- Ensure internet connection is active
|
||||||
@@ -271,14 +310,37 @@ Settings are automatically saved to localStorage and persist across sessions.
|
|||||||
### Project Structure
|
### Project Structure
|
||||||
```
|
```
|
||||||
SignageHTML/
|
SignageHTML/
|
||||||
├── index.html # Main HTML file with UI and styles
|
├── index.html # Main HTML file
|
||||||
├── server.js # Node.js server for API proxying
|
├── server.js # Node.js server entry point
|
||||||
├── config.js # Configuration management
|
├── server/
|
||||||
├── clock.js # Clock component
|
│ └── routes/ # API route handlers
|
||||||
├── weather.js # Weather component
|
│ ├── departures.js # Departure data endpoints
|
||||||
├── departures.js # Departures component
|
│ ├── sites.js # Site search endpoints
|
||||||
├── sites-config.json # Persistent site configuration
|
│ └── config.js # Configuration endpoints
|
||||||
|
├── public/ # Static assets
|
||||||
|
│ ├── css/
|
||||||
|
│ │ ├── main.css # Base styles, dark mode, orientations
|
||||||
|
│ │ └── components.css # Component-specific styles
|
||||||
|
│ └── js/
|
||||||
|
│ ├── main.js # Application initialization
|
||||||
|
│ ├── components/ # Component classes
|
||||||
|
│ │ ├── Clock.js
|
||||||
|
│ │ ├── ConfigManager.js
|
||||||
|
│ │ ├── DeparturesManager.js
|
||||||
|
│ │ └── WeatherManager.js
|
||||||
|
│ └── utils/ # Utility modules
|
||||||
|
│ ├── constants.js
|
||||||
|
│ └── logger.js
|
||||||
|
├── config/
|
||||||
|
│ └── sites.json # Persistent site configuration
|
||||||
|
├── docs/
|
||||||
|
│ └── API_RESPONSE_DOCUMENTATION.md # API response documentation
|
||||||
|
├── scripts/
|
||||||
|
│ └── raspberry-pi-setup.sh # Raspberry Pi deployment script
|
||||||
├── package.json # Node.js dependencies
|
├── package.json # Node.js dependencies
|
||||||
|
├── .env # Environment variables (create from .env.example)
|
||||||
|
├── .env.example # Environment variable template
|
||||||
|
├── archive/ # Archived legacy files (see archive/README.md)
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"orientation": "normal",
|
"orientation": "normal",
|
||||||
"darkMode": "auto",
|
"darkMode": "auto",
|
||||||
"backgroundImage": "https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000",
|
"backgroundImage": "https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000",
|
||||||
"backgroundOpacity": 0.3,
|
"backgroundOpacity": 0.45,
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"id": "1411",
|
"id": "1411",
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script to create a deployment package for Raspberry Pi
|
|
||||||
|
|
||||||
echo "Creating deployment package for SL Transport Departures Display..."
|
|
||||||
|
|
||||||
# Create a temporary directory
|
|
||||||
TEMP_DIR="deployment-package"
|
|
||||||
mkdir -p $TEMP_DIR
|
|
||||||
|
|
||||||
# Copy necessary files
|
|
||||||
echo "Copying files..."
|
|
||||||
cp index.html $TEMP_DIR/
|
|
||||||
cp server.js $TEMP_DIR/
|
|
||||||
cp clock.js $TEMP_DIR/
|
|
||||||
cp config.js $TEMP_DIR/
|
|
||||||
cp weather.js $TEMP_DIR/
|
|
||||||
cp ticker.js $TEMP_DIR/
|
|
||||||
cp package.json $TEMP_DIR/
|
|
||||||
cp README.md $TEMP_DIR/
|
|
||||||
cp documentation.md $TEMP_DIR/
|
|
||||||
cp raspberry-pi-setup.sh $TEMP_DIR/
|
|
||||||
cp .gitignore $TEMP_DIR/
|
|
||||||
|
|
||||||
# Copy any image files if they exist
|
|
||||||
if [ -d "images" ]; then
|
|
||||||
mkdir -p $TEMP_DIR/images
|
|
||||||
cp -r images/* $TEMP_DIR/images/
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a version file with timestamp
|
|
||||||
echo "Creating version file..."
|
|
||||||
DATE=$(date +"%Y-%m-%d %H:%M:%S")
|
|
||||||
echo "SL Transport Departures Display" > $TEMP_DIR/version.txt
|
|
||||||
echo "Packaged on: $DATE" >> $TEMP_DIR/version.txt
|
|
||||||
echo "Version: 1.0.0" >> $TEMP_DIR/version.txt
|
|
||||||
|
|
||||||
# Create a ZIP archive
|
|
||||||
echo "Creating ZIP archive..."
|
|
||||||
ZIP_FILE="sl-departures-display-$(date +"%Y%m%d").zip"
|
|
||||||
zip -r $ZIP_FILE $TEMP_DIR
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
echo "Cleaning up..."
|
|
||||||
rm -rf $TEMP_DIR
|
|
||||||
|
|
||||||
echo "Deployment package created: $ZIP_FILE"
|
|
||||||
echo "To deploy to Raspberry Pi:"
|
|
||||||
echo "1. Transfer the ZIP file to your Raspberry Pi"
|
|
||||||
echo "2. Unzip the file: unzip $ZIP_FILE"
|
|
||||||
echo "3. Navigate to the directory: cd deployment-package"
|
|
||||||
echo "4. Make the setup script executable: chmod +x raspberry-pi-setup.sh"
|
|
||||||
echo "5. Run the setup script: sudo ./raspberry-pi-setup.sh"
|
|
||||||
766
departures.js
766
departures.js
@@ -1,766 +0,0 @@
|
|||||||
// Calculate minutes until arrival using expected time (or scheduled if expected not available)
|
|
||||||
function calculateMinutesUntilArrival(departure) {
|
|
||||||
const now = new Date();
|
|
||||||
// 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
|
|
||||||
function getTransportIcon(transportMode) {
|
|
||||||
// Default to bus if not specified
|
|
||||||
const mode = transportMode ? transportMode.toLowerCase() : 'bus';
|
|
||||||
|
|
||||||
// Special case for line 7 - it's a tram
|
|
||||||
if (arguments.length > 1 && arguments[1] && arguments[1].designation === '7') {
|
|
||||||
return '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 5l.75-1.5H17V2H7v1.5h4.75L11 5c-3.13.09-6 .73-6 3.5V17c0 1.5 1.11 2.73 2.55 2.95L6 21.5v.5h2l2-2h4l2 2h2v-.5l-1.55-1.55C17.89 19.73 19 18.5 19 17V8.5c0-2.77-2.87-3.41-6-3.5zm-1 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5-4.5H7V9h10v5z"/></svg>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// SVG icons for different transport modes
|
|
||||||
const icons = {
|
|
||||||
bus: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z"/></svg>',
|
|
||||||
metro: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20v1h12v-1l-1.5-1c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-4-4-8-4zm0 2c3.51 0 4.96.48 5.57 1H6.43c.61-.52 2.06-1 5.57-1zM6 7h5v3H6V7zm12 0v3h-5V7h5zm-6 5v3H6v-3h6zm1 0h5v3h-5v-3z"/></svg>',
|
|
||||||
train: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20v1h12v-1l-1.5-1c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-4-4-8-4zm0 2c3.51 0 4.96.48 5.57 1H6.43c.61-.52 2.06-1 5.57-1zM6 7h5v3H6V7zm12 0v3h-5V7h5zm-6 5v3H6v-3h6zm1 0h5v3h-5v-3z"/></svg>',
|
|
||||||
tram: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 5l.75-1.5H17V2H7v1.5h4.75L11 5c-3.13.09-6 .73-6 3.5V17c0 1.5 1.11 2.73 2.55 2.95L6 21.5v.5h2l2-2h4l2 2h2v-.5l-1.55-1.55C17.89 19.73 19 18.5 19 17V8.5c0-2.77-2.87-3.41-6-3.5zm-1 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5-4.5H7V9h10v5z"/></svg>',
|
|
||||||
ship: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M20 21c-1.39 0-2.78-.47-4-1.32-2.44 1.71-5.56 1.71-8 0C6.78 20.53 5.39 21 4 21H2v2h2c1.38 0 2.74-.35 4-.99 2.52 1.29 5.48 1.29 8 0 1.26.65 2.62.99 4 .99h2v-2h-2zM3.95 19H4c1.6 0 3.02-.88 4-2 .98 1.12 2.4 2 4 2s3.02-.88 4-2c.98 1.12 2.4 2 4 2h.05l1.89-6.68c.08-.26.06-.54-.06-.78s-.34-.42-.6-.5L20 10.62V6c0-1.1-.9-2-2-2h-3V1H9v3H6c-1.1 0-2 .9-2 2v4.62l-1.29.42c-.26.08-.48.26-.6.5s-.15.52-.06.78L3.95 19zM6 6h12v3.97L12 8 6 9.97V6z"/></svg>'
|
|
||||||
};
|
|
||||||
|
|
||||||
return icons[mode] || icons.bus;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a departure card element
|
|
||||||
function createDepartureCard(departure) {
|
|
||||||
const departureCard = document.createElement('div');
|
|
||||||
departureCard.dataset.journeyId = departure.journey.id;
|
|
||||||
|
|
||||||
const displayTime = departure.display;
|
|
||||||
const departureTime = getDepartureTime(departure);
|
|
||||||
const timeDisplay = formatDateTime(departureTime);
|
|
||||||
|
|
||||||
// Check if departure is within the next hour
|
|
||||||
const departureTimeDate = new Date(departureTime);
|
|
||||||
const now = new Date();
|
|
||||||
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';
|
|
||||||
|
|
||||||
// Calculate minutes until arrival using expected time (accounts for delays)
|
|
||||||
const minutesUntil = calculateMinutesUntilArrival(departure);
|
|
||||||
let countdownText = displayTime;
|
|
||||||
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 === 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
|
|
||||||
const transportIcon = getTransportIcon(departure.line?.transportMode, departure.line);
|
|
||||||
|
|
||||||
// Create card based on time and display format
|
|
||||||
departureCard.innerHTML = `
|
|
||||||
<div class="departure-header">
|
|
||||||
<span class="line-number">
|
|
||||||
${transportIcon}
|
|
||||||
${departure.line.designation}
|
|
||||||
<span class="line-destination">${departure.destination}</span>
|
|
||||||
</span>
|
|
||||||
<span class="time">
|
|
||||||
<span class="arrival-time">${timeDisplay}</span>
|
|
||||||
<span class="countdown ${countdownClass}">(${countdownText})</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return departureCard;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
|
|
||||||
// Get transport mode for styling - ensure we use the API value
|
|
||||||
const apiTransportMode = group.line?.transportMode || '';
|
|
||||||
const transportMode = apiTransportMode.toLowerCase();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
lineNumberBox.innerHTML = `
|
|
||||||
<div class="transport-mode-icon">${transportIcon}</div>
|
|
||||||
<div class="line-number-large">${group.lineNumber}</div>
|
|
||||||
`;
|
|
||||||
groupCard.appendChild(lineNumberBox);
|
|
||||||
|
|
||||||
// Create directions wrapper on the right
|
|
||||||
const directionsWrapper = document.createElement('div');
|
|
||||||
directionsWrapper.className = 'directions-wrapper';
|
|
||||||
|
|
||||||
// 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 (arrow + destination)
|
|
||||||
const directionInfo = document.createElement('div');
|
|
||||||
directionInfo.className = 'direction-info';
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
|
|
||||||
// Get first two departures for time range
|
|
||||||
const firstDeparture = direction.departures[0];
|
|
||||||
const secondDeparture = direction.departures[1];
|
|
||||||
|
|
||||||
if (firstDeparture) {
|
|
||||||
const displayTime = firstDeparture.display;
|
|
||||||
const departureTime = getDepartureTime(firstDeparture);
|
|
||||||
const timeDisplay = formatDateTime(departureTime);
|
|
||||||
|
|
||||||
// Calculate minutes until arrival using expected time (accounts for delays)
|
|
||||||
const minutesUntil = calculateMinutesUntilArrival(firstDeparture);
|
|
||||||
let countdownText = displayTime;
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
directionsWrapper.appendChild(directionRow);
|
|
||||||
});
|
|
||||||
|
|
||||||
groupCard.appendChild(directionsWrapper);
|
|
||||||
|
|
||||||
// Add to container
|
|
||||||
container.appendChild(groupCard);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display grouped departures (legacy function)
|
|
||||||
function displayGroupedDepartures(groups, container) {
|
|
||||||
groups.forEach(group => {
|
|
||||||
// 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');
|
|
||||||
groupCard.className = 'departure-card';
|
|
||||||
|
|
||||||
// Create card header
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = 'departure-header';
|
|
||||||
|
|
||||||
// Get transport icon based on transport mode and line
|
|
||||||
const transportIcon = getTransportIcon(group.line?.transportMode, group.line);
|
|
||||||
|
|
||||||
// Add line number with transport icon and destination
|
|
||||||
const lineNumber = document.createElement('span');
|
|
||||||
lineNumber.className = 'line-number';
|
|
||||||
lineNumber.innerHTML = `${transportIcon} ${group.line.designation} <span class="line-destination">${group.destination}</span>`;
|
|
||||||
header.appendChild(lineNumber);
|
|
||||||
|
|
||||||
// Add times container
|
|
||||||
const timesContainer = document.createElement('div');
|
|
||||||
timesContainer.className = 'times-container';
|
|
||||||
timesContainer.style.display = 'flex';
|
|
||||||
timesContainer.style.flexDirection = 'column';
|
|
||||||
timesContainer.style.alignItems = 'flex-end';
|
|
||||||
|
|
||||||
// Add up to 3 departure times
|
|
||||||
const maxTimes = 3;
|
|
||||||
group.departures.slice(0, maxTimes).forEach((departure, index) => {
|
|
||||||
const timeElement = document.createElement('span');
|
|
||||||
timeElement.className = 'time';
|
|
||||||
timeElement.style.fontSize = index === 0 ? '1.1em' : '0.9em';
|
|
||||||
timeElement.style.marginBottom = '2px';
|
|
||||||
|
|
||||||
const displayTime = departure.display;
|
|
||||||
const departureTime = getDepartureTime(departure);
|
|
||||||
const timeDisplay = formatDateTime(departureTime);
|
|
||||||
|
|
||||||
// Calculate minutes until arrival using expected time (accounts for delays)
|
|
||||||
const minutesUntil = calculateMinutesUntilArrival(departure);
|
|
||||||
let countdownText = displayTime;
|
|
||||||
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 === 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeElement.innerHTML = `${scheduledTime} <span class="countdown ${countdownClass}">(${countdownText})</span>`;
|
|
||||||
|
|
||||||
timesContainer.appendChild(timeElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// If there are more departures, show a count
|
|
||||||
if (group.departures.length > maxTimes) {
|
|
||||||
const moreElement = document.createElement('span');
|
|
||||||
moreElement.style.fontSize = '0.8em';
|
|
||||||
moreElement.style.color = '#666';
|
|
||||||
moreElement.textContent = `+${group.departures.length - maxTimes} more`;
|
|
||||||
timesContainer.appendChild(moreElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
header.appendChild(timesContainer);
|
|
||||||
groupCard.appendChild(header);
|
|
||||||
|
|
||||||
// No need to add destination and direction separately as they're now in the header
|
|
||||||
|
|
||||||
// Add to container
|
|
||||||
container.appendChild(groupCard);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format date and time
|
|
||||||
function formatDateTime(dateTimeString) {
|
|
||||||
const date = new Date(dateTimeString);
|
|
||||||
return date.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format relative time (e.g., "in 5 minutes")
|
|
||||||
function formatRelativeTime(dateTimeString) {
|
|
||||||
const departureTime = new Date(dateTimeString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
|
|
||||||
|
|
||||||
if (diffMinutes <= 0) {
|
|
||||||
return 'Nu';
|
|
||||||
} else if (diffMinutes === 1) {
|
|
||||||
return 'In 1 minute';
|
|
||||||
} else if (diffMinutes < 60) {
|
|
||||||
return `In ${diffMinutes} minutes`;
|
|
||||||
} else {
|
|
||||||
const hours = Math.floor(diffMinutes / 60);
|
|
||||||
const minutes = diffMinutes % 60;
|
|
||||||
if (minutes === 0) {
|
|
||||||
return `In ${hours} hour${hours > 1 ? 's' : ''}`;
|
|
||||||
} else {
|
|
||||||
return `In ${hours} hour${hours > 1 ? 's' : ''} and ${minutes} minute${minutes > 1 ? 's' : ''}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group departures by line number
|
|
||||||
function groupDeparturesByLineNumber(departures) {
|
|
||||||
const groups = {};
|
|
||||||
|
|
||||||
departures.forEach(departure => {
|
|
||||||
const lineNumber = departure.line.designation;
|
|
||||||
|
|
||||||
if (!groups[lineNumber]) {
|
|
||||||
groups[lineNumber] = {
|
|
||||||
line: departure.line,
|
|
||||||
directions: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: departureDirection,
|
|
||||||
destination: departure.destination,
|
|
||||||
departures: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
groups[lineNumber].directions[directionKey].departures.push(departure);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert to array format and sort directions by direction_code (1 first, then 2)
|
|
||||||
return Object.entries(groups).map(([lineNumber, data]) => {
|
|
||||||
// Sort directions: direction 1 first, then direction 2
|
|
||||||
const directionsArray = Object.values(data.directions);
|
|
||||||
directionsArray.sort((a, b) => {
|
|
||||||
// Sort by direction_code: 1 comes before 2
|
|
||||||
const dirA = a.direction || 1;
|
|
||||||
const dirB = b.direction || 1;
|
|
||||||
return dirA - dirB;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
lineNumber: lineNumber,
|
|
||||||
line: data.line,
|
|
||||||
directions: directionsArray
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group departures by direction (legacy function kept for compatibility)
|
|
||||||
function groupDeparturesByDirection(departures) {
|
|
||||||
const groups = {};
|
|
||||||
|
|
||||||
departures.forEach(departure => {
|
|
||||||
const key = `${departure.line.designation}-${departure.direction}-${departure.destination}`;
|
|
||||||
|
|
||||||
if (!groups[key]) {
|
|
||||||
groups[key] = {
|
|
||||||
line: departure.line,
|
|
||||||
direction: departure.direction,
|
|
||||||
destination: departure.destination,
|
|
||||||
departures: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
groups[key].departures.push(departure);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(groups);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the current departures data for comparison
|
|
||||||
let currentDepartures = [];
|
|
||||||
|
|
||||||
// Display departures in the UI with smooth transitions
|
|
||||||
function displayDepartures(departures) {
|
|
||||||
if (!departures || departures.length === 0) {
|
|
||||||
departuresContainer.innerHTML = '<div class="error">No departures found</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is the first load, just display everything
|
|
||||||
if (currentDepartures.length === 0) {
|
|
||||||
departuresContainer.innerHTML = '';
|
|
||||||
|
|
||||||
departures.forEach(departure => {
|
|
||||||
const departureCard = createDepartureCard(departure);
|
|
||||||
departuresContainer.appendChild(departureCard);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Update only what has changed
|
|
||||||
updateExistingCards(departures);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the current departures for next comparison
|
|
||||||
currentDepartures = JSON.parse(JSON.stringify(departures));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing cards or add new ones
|
|
||||||
function updateExistingCards(newDepartures) {
|
|
||||||
// Get all current cards
|
|
||||||
const currentCards = departuresContainer.querySelectorAll('.departure-card');
|
|
||||||
const currentCardIds = Array.from(currentCards).map(card => card.dataset.journeyId);
|
|
||||||
|
|
||||||
// Process each new departure
|
|
||||||
newDepartures.forEach((departure, index) => {
|
|
||||||
const journeyId = departure.journey.id;
|
|
||||||
const existingCardIndex = currentCardIds.indexOf(journeyId.toString());
|
|
||||||
|
|
||||||
if (existingCardIndex !== -1) {
|
|
||||||
// Update existing card
|
|
||||||
const existingCard = currentCards[existingCardIndex];
|
|
||||||
updateCardContent(existingCard, departure);
|
|
||||||
} else {
|
|
||||||
// This is a new departure, add it
|
|
||||||
const newCard = createDepartureCard(departure);
|
|
||||||
|
|
||||||
// Add with fade-in effect
|
|
||||||
newCard.style.opacity = '0';
|
|
||||||
|
|
||||||
if (index === 0) {
|
|
||||||
// Add to the beginning
|
|
||||||
departuresContainer.prepend(newCard);
|
|
||||||
} else if (index >= departuresContainer.children.length) {
|
|
||||||
// Add to the end
|
|
||||||
departuresContainer.appendChild(newCard);
|
|
||||||
} else {
|
|
||||||
// Insert at specific position
|
|
||||||
departuresContainer.insertBefore(newCard, departuresContainer.children[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger fade-in
|
|
||||||
setTimeout(() => {
|
|
||||||
newCard.style.transition = 'opacity 0.5s ease-in';
|
|
||||||
newCard.style.opacity = '1';
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove cards that are no longer in the new data
|
|
||||||
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
|
|
||||||
currentCards.forEach(card => {
|
|
||||||
if (!newDepartureIds.includes(card.dataset.journeyId)) {
|
|
||||||
// Fade out and remove
|
|
||||||
card.style.transition = 'opacity 0.5s ease-out';
|
|
||||||
card.style.opacity = '0';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (card.parentNode) {
|
|
||||||
card.parentNode.removeChild(card);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update only the content that has changed in an existing card
|
|
||||||
function updateCardContent(card, departure) {
|
|
||||||
const displayTime = departure.display;
|
|
||||||
const departureTime = getDepartureTime(departure);
|
|
||||||
|
|
||||||
// Calculate minutes until arrival using expected time (accounts for delays)
|
|
||||||
const minutesUntil = calculateMinutesUntilArrival(departure);
|
|
||||||
let countdownText = displayTime;
|
|
||||||
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 === 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.textContent !== `(${countdownText})`) {
|
|
||||||
countdownElement.textContent = `(${countdownText})`;
|
|
||||||
highlightElement(countdownElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a subtle highlight effect to show updated content
|
|
||||||
function highlightElement(element) {
|
|
||||||
element.style.transition = 'none';
|
|
||||||
element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
element.style.transition = 'background-color 1.5s ease-out';
|
|
||||||
element.style.backgroundColor = 'transparent';
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display multiple sites
|
|
||||||
function displayMultipleSites(sites) {
|
|
||||||
// Get configuration
|
|
||||||
const config = getConfig();
|
|
||||||
const enabledSites = config.sites.filter(site => site.enabled);
|
|
||||||
|
|
||||||
// Clear the container
|
|
||||||
departuresContainer.innerHTML = '';
|
|
||||||
|
|
||||||
// Process each site
|
|
||||||
sites.forEach(site => {
|
|
||||||
// Check if this site is enabled in the configuration
|
|
||||||
const siteConfig = enabledSites.find(s => s.id === site.siteId);
|
|
||||||
if (!siteConfig) return;
|
|
||||||
|
|
||||||
// Create a site container
|
|
||||||
const siteContainer = document.createElement('div');
|
|
||||||
siteContainer.className = 'site-container';
|
|
||||||
|
|
||||||
// Add site header with white tab
|
|
||||||
const siteHeader = document.createElement('div');
|
|
||||||
siteHeader.className = 'site-header';
|
|
||||||
siteHeader.innerHTML = `<span class="site-name">${site.siteName || siteConfig.name}</span>`;
|
|
||||||
siteContainer.appendChild(siteHeader);
|
|
||||||
|
|
||||||
// Process departures for this site
|
|
||||||
if (site.data && site.data.departures) {
|
|
||||||
// Group departures by line number using the existing function
|
|
||||||
const lineGroups = groupDeparturesByLineNumber(site.data.departures);
|
|
||||||
|
|
||||||
// Use the new card-based layout function
|
|
||||||
displayGroupedDeparturesByLine(lineGroups, siteContainer);
|
|
||||||
} else if (site.error) {
|
|
||||||
// Display error for this site
|
|
||||||
const errorElement = document.createElement('div');
|
|
||||||
errorElement.className = 'error';
|
|
||||||
errorElement.textContent = `Error loading departures for ${site.siteName}: ${site.error}`;
|
|
||||||
siteContainer.appendChild(errorElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the site container to the main container
|
|
||||||
departuresContainer.appendChild(siteContainer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get configuration
|
|
||||||
function getConfig() {
|
|
||||||
// Default configuration
|
|
||||||
const defaultConfig = {
|
|
||||||
combineSameDirection: true,
|
|
||||||
sites: [
|
|
||||||
{
|
|
||||||
id: '1411',
|
|
||||||
name: 'Ambassaderna',
|
|
||||||
enabled: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// If we have a ConfigManager instance, use its config
|
|
||||||
if (window.configManager && window.configManager.config) {
|
|
||||||
return {
|
|
||||||
combineSameDirection: window.configManager.config.combineSameDirection !== undefined ?
|
|
||||||
window.configManager.config.combineSameDirection : defaultConfig.combineSameDirection,
|
|
||||||
sites: window.configManager.config.sites || defaultConfig.sites
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch departures from our proxy server
|
|
||||||
async function fetchDepartures() {
|
|
||||||
try {
|
|
||||||
// Don't show loading status to avoid layout disruptions
|
|
||||||
// statusElement.textContent = 'Loading departures...';
|
|
||||||
|
|
||||||
const response = await fetch(API_URL);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.sites && Array.isArray(data.sites)) {
|
|
||||||
// Process multiple sites
|
|
||||||
displayMultipleSites(data.sites);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString('sv-SE')}`;
|
|
||||||
} else if (data.departures) {
|
|
||||||
// Legacy format - single site
|
|
||||||
displayDepartures(data.departures);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString('sv-SE')}`;
|
|
||||||
} else if (data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
} else {
|
|
||||||
throw new Error('Invalid response format from server');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching departures:', error);
|
|
||||||
// Don't update status element to avoid layout disruptions
|
|
||||||
// statusElement.textContent = '';
|
|
||||||
departuresContainer.innerHTML = `
|
|
||||||
<div class="error">
|
|
||||||
<p>Failed to load departures. Please try again later.</p>
|
|
||||||
<p>Error: ${error.message}</p>
|
|
||||||
<p>Make sure the Node.js server is running: <code>node server.js</code></p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up auto-refresh
|
|
||||||
function setupAutoRefresh() {
|
|
||||||
// Clear any existing timer
|
|
||||||
if (refreshTimer) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up new timer
|
|
||||||
refreshTimer = setInterval(fetchDepartures, REFRESH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize departures functionality
|
|
||||||
function initDepartures() {
|
|
||||||
// API endpoint (using our local proxy server)
|
|
||||||
window.API_URL = 'http://localhost:3002/api/departures';
|
|
||||||
|
|
||||||
// DOM elements
|
|
||||||
window.departuresContainer = document.getElementById('departures');
|
|
||||||
window.statusElement = document.getElementById('status');
|
|
||||||
window.lastUpdatedElement = document.getElementById('last-updated');
|
|
||||||
|
|
||||||
// Auto-refresh interval (in milliseconds) - 5 seconds
|
|
||||||
window.REFRESH_INTERVAL = 5000;
|
|
||||||
window.refreshTimer = null;
|
|
||||||
|
|
||||||
// Initial fetch and setup
|
|
||||||
fetchDepartures();
|
|
||||||
setupAutoRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize when the DOM is loaded
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
initDepartures();
|
|
||||||
});
|
|
||||||
275
documentation.md
275
documentation.md
@@ -1,275 +0,0 @@
|
|||||||
# SL Transport Departures Display System Documentation
|
|
||||||
|
|
||||||
## System Overview
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
The system consists of the following components:
|
|
||||||
|
|
||||||
1. **Node.js Server** - Handles API proxying and serves static files
|
|
||||||
2. **Configuration Manager** - Manages system settings and UI customization
|
|
||||||
3. **Weather Component** - Displays weather data and manages dark mode
|
|
||||||
4. **Clock Component** - Shows current time and date
|
|
||||||
5. **Departures Component** - Displays transit departure information
|
|
||||||
6. **Main UI** - Responsive layout with multiple orientation support
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
- `index.html` - Main HTML file containing the UI structure and inline JavaScript
|
|
||||||
- `server.js` - Node.js server for API proxying and static file serving
|
|
||||||
- `config.js` - Configuration management module
|
|
||||||
- `weather.js` - Weather display and dark mode management
|
|
||||||
- `clock.js` - Time and date display
|
|
||||||
- `departures.js` - Transit departure display component
|
|
||||||
|
|
||||||
## Detailed Component Documentation
|
|
||||||
|
|
||||||
### 1. Node.js Server (server.js)
|
|
||||||
|
|
||||||
The server component acts as a proxy for external APIs and serves the static files for the application.
|
|
||||||
|
|
||||||
#### Key Features:
|
|
||||||
|
|
||||||
- **Port**: Runs on port 3002
|
|
||||||
- **API Proxying**:
|
|
||||||
- `/api/departures` - Proxies requests to SL Transport API
|
|
||||||
- **Error Handling**: Provides structured error responses
|
|
||||||
- **Static File Serving**: Serves HTML, CSS, JavaScript, and image files
|
|
||||||
- **CORS Support**: Allows cross-origin requests
|
|
||||||
|
|
||||||
#### API Endpoints:
|
|
||||||
|
|
||||||
- `GET /api/departures` - Returns transit departure information
|
|
||||||
- `GET /` or `/index.html` - Serves the main application
|
|
||||||
- `GET /*.js|css|png|jpg|jpeg|gif|ico` - Serves static assets
|
|
||||||
|
|
||||||
#### Implementation Details:
|
|
||||||
|
|
||||||
The server handles malformed JSON responses from the SL Transport API by implementing custom JSON parsing and fixing. It also provides fallback data when API requests fail.
|
|
||||||
|
|
||||||
### 2. Configuration Manager (config.js)
|
|
||||||
|
|
||||||
The Configuration Manager handles all system settings and provides a UI for changing them.
|
|
||||||
|
|
||||||
#### Key Features:
|
|
||||||
|
|
||||||
- **Screen Orientation**: Controls display rotation (0°, 90°, 180°, 270°, landscape)
|
|
||||||
- **Dark Mode**: Automatic (based on sunrise/sunset), always on, or always off
|
|
||||||
- **Background Image**: Custom background with opacity control
|
|
||||||
- **Settings Persistence**: Saves settings to localStorage
|
|
||||||
- **Configuration UI**: Modal-based interface with live previews
|
|
||||||
|
|
||||||
#### Configuration Options:
|
|
||||||
|
|
||||||
- `orientation`: Screen orientation (`normal`, `vertical`, `upsidedown`, `vertical-reverse`, `landscape`)
|
|
||||||
- `darkMode`: Dark mode setting (`auto`, `on`, `off`)
|
|
||||||
- `backgroundImage`: URL or data URL of background image
|
|
||||||
- `backgroundOpacity`: Opacity value between 0 and 1
|
|
||||||
|
|
||||||
#### Implementation Details:
|
|
||||||
|
|
||||||
The ConfigManager class creates a gear icon button that opens a modal dialog for changing settings. It applies settings immediately and dispatches events to notify other components of changes.
|
|
||||||
|
|
||||||
### 3. Weather Component (weather.js)
|
|
||||||
|
|
||||||
The Weather component displays current weather conditions, forecasts, and manages dark mode based on sunrise/sunset times.
|
|
||||||
|
|
||||||
#### Key Features:
|
|
||||||
|
|
||||||
- **Current Weather**: Temperature, condition, and icon
|
|
||||||
- **Hourly Forecast**: Weather predictions for upcoming hours
|
|
||||||
- **Sunrise/Sunset**: Calculates and displays sun times
|
|
||||||
- **Dark Mode Control**: Automatically switches between light/dark based on sun position
|
|
||||||
- **API Integration**: Uses OpenWeatherMap API
|
|
||||||
- **Fallback Data**: Provides default weather data when API is unavailable
|
|
||||||
|
|
||||||
#### Implementation Details:
|
|
||||||
|
|
||||||
The WeatherManager class fetches data from OpenWeatherMap API and updates the UI. It calculates sunrise/sunset times and uses them to determine if dark mode should be enabled. It dispatches events when dark mode changes.
|
|
||||||
|
|
||||||
### 4. Clock Component (clock.js)
|
|
||||||
|
|
||||||
The Clock component displays the current time and date.
|
|
||||||
|
|
||||||
#### Key Features:
|
|
||||||
|
|
||||||
- **Time Display**: Shows current time in HH:MM:SS format
|
|
||||||
- **Date Display**: Shows current date with weekday, month, day, and year
|
|
||||||
- **Timezone Support**: Configured for Stockholm timezone
|
|
||||||
- **Auto-Update**: Updates every second
|
|
||||||
- **Optional Time Sync**: Can synchronize with WorldTimeAPI
|
|
||||||
|
|
||||||
#### Implementation Details:
|
|
||||||
|
|
||||||
The Clock class creates and updates time and date elements. It uses the browser's Date object with the specified timezone and formats the output using toLocaleTimeString and toLocaleDateString.
|
|
||||||
|
|
||||||
### 5. Departures Component (departures.js)
|
|
||||||
|
|
||||||
The Departures component displays transit departure information from the SL Transport API.
|
|
||||||
|
|
||||||
#### Key Features:
|
|
||||||
|
|
||||||
- **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)
|
|
||||||
|
|
||||||
The main UI integrates all components and provides responsive layouts for different screen orientations.
|
|
||||||
|
|
||||||
#### Key Features:
|
|
||||||
|
|
||||||
- **Responsive Design**: Adapts to different screen sizes and orientations
|
|
||||||
- **Dark Mode Support**: Changes colors based on dark mode setting
|
|
||||||
- **Departure Cards**: Displays transit departures with line numbers, destinations, and times
|
|
||||||
- **Weather Widget**: Shows current conditions and forecast
|
|
||||||
- **Auto-Refresh**: Periodically updates departure information
|
|
||||||
|
|
||||||
#### Implementation Details:
|
|
||||||
|
|
||||||
The HTML file contains the structure and styling for the application. It initializes all components and sets up event listeners for updates. The JavaScript in the file handles fetching and displaying departure information.
|
|
||||||
|
|
||||||
## Setup Instructions
|
|
||||||
|
|
||||||
### Prerequisites:
|
|
||||||
|
|
||||||
- Node.js installed
|
|
||||||
- Internet connection for API access
|
|
||||||
- Browser with CSS3 support
|
|
||||||
|
|
||||||
### Installation Steps:
|
|
||||||
|
|
||||||
1. Clone or download all files to a directory
|
|
||||||
2. Run `node server.js` to start the server
|
|
||||||
3. Access the application at `http://localhost:3002`
|
|
||||||
|
|
||||||
### Raspberry Pi Setup:
|
|
||||||
|
|
||||||
1. Install Node.js on Raspberry Pi
|
|
||||||
2. Copy all files to a directory on the Pi
|
|
||||||
3. Set up auto-start for the server:
|
|
||||||
```
|
|
||||||
# Create a systemd service file
|
|
||||||
sudo nano /etc/systemd/system/sl-departures.service
|
|
||||||
|
|
||||||
# Add the following content
|
|
||||||
[Unit]
|
|
||||||
Description=SL Departures Display
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/bin/node /path/to/server.js
|
|
||||||
WorkingDirectory=/path/to/directory
|
|
||||||
Restart=always
|
|
||||||
User=pi
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
4. Enable and start the service:
|
|
||||||
```
|
|
||||||
sudo systemctl enable sl-departures
|
|
||||||
sudo systemctl start sl-departures
|
|
||||||
```
|
|
||||||
5. Configure Raspberry Pi to auto-start Chromium in kiosk mode:
|
|
||||||
```
|
|
||||||
# Edit autostart file
|
|
||||||
mkdir -p ~/.config/autostart
|
|
||||||
nano ~/.config/autostart/kiosk.desktop
|
|
||||||
|
|
||||||
# Add the following content
|
|
||||||
[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=Kiosk
|
|
||||||
Exec=chromium-browser --kiosk --disable-restore-session-state http://localhost:3002
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues:
|
|
||||||
|
|
||||||
1. **Server won't start**
|
|
||||||
- Check if port 3002 is already in use
|
|
||||||
- Ensure Node.js is installed correctly
|
|
||||||
|
|
||||||
2. **No departures displayed**
|
|
||||||
- Verify internet connection
|
|
||||||
- Check server console for API errors
|
|
||||||
- Ensure the site ID is correct (currently set to 9636)
|
|
||||||
|
|
||||||
3. **Weather data not loading**
|
|
||||||
- Check OpenWeatherMap API key
|
|
||||||
- Verify internet connection
|
|
||||||
- Look for errors in browser console
|
|
||||||
|
|
||||||
- Look for JavaScript errors in console
|
|
||||||
|
|
||||||
5. **Screen orientation issues**
|
|
||||||
- Ensure the content wrapper is properly created
|
|
||||||
- Check for CSS conflicts
|
|
||||||
- Verify browser supports CSS transforms
|
|
||||||
|
|
||||||
## Customization Guide
|
|
||||||
|
|
||||||
### Changing Transit Stop:
|
|
||||||
|
|
||||||
To display departures for a different transit stop, modify the API_URL in server.js:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const API_URL = 'https://transport.integration.sl.se/v1/sites/YOUR_SITE_ID/departures';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changing Weather Location:
|
|
||||||
|
|
||||||
To display weather for a different location, modify the latitude and longitude in index.html:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.weatherManager = new WeatherManager({
|
|
||||||
latitude: YOUR_LATITUDE,
|
|
||||||
longitude: YOUR_LONGITUDE
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Custom Styles:
|
|
||||||
|
|
||||||
Add custom CSS to the style section in index.html to modify the appearance.
|
|
||||||
|
|
||||||
## System Architecture Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
+---------------------+ +----------------------+
|
|
||||||
| | | |
|
|
||||||
| Browser Interface |<----->| Node.js Server |
|
|
||||||
| (index.html) | | (server.js) |
|
|
||||||
| | | |
|
|
||||||
+---------------------+ +----------------------+
|
|
||||||
^ ^
|
|
||||||
| |
|
|
||||||
v v
|
|
||||||
+---------------------+ +----------------------+
|
|
||||||
| | | |
|
|
||||||
| UI Components | | External APIs |
|
|
||||||
| - Clock | | - SL Transport API |
|
|
||||||
| - Weather | | - OpenWeatherMap |
|
|
||||||
| - Departures | | |
|
|
||||||
| - Config Manager | | |
|
|
||||||
+---------------------+ +----------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Interaction Flow
|
|
||||||
|
|
||||||
1. User loads the application in a browser
|
|
||||||
2. Node.js server serves the HTML, CSS, and JavaScript files
|
|
||||||
3. Browser initializes all components (Clock, Weather, Config, 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
|
|
||||||
7. User can change settings through the Config Manager
|
|
||||||
8. Settings are applied immediately and saved to localStorage
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
This documentation provides a comprehensive overview of the SL Transport Departures Display System. With this information, you should be able to understand, maintain, and recreate the system if needed.
|
|
||||||
1727
index.html
1727
index.html
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB |
@@ -16,7 +16,9 @@
|
|||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^2.0.22"
|
"nodemon": "^2.0.22"
|
||||||
},
|
},
|
||||||
|
|||||||
1242
public/css/components.css
Normal file
1242
public/css/components.css
Normal file
File diff suppressed because it is too large
Load Diff
349
public/css/main.css
Normal file
349
public/css/main.css
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/* Base styles */
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-apply landscape layout for wide screens */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 8px 12px 0 12px; /* Minimal padding to maximize space */
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) #content-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
gap: 8px; /* Reduced gap */
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .clock-container {
|
||||||
|
grid-row: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 6px 16px; /* Reduced padding */
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .main-content-grid {
|
||||||
|
grid-row: 2;
|
||||||
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr); /* Fixed 4 columns to use all space */
|
||||||
|
gap: 6px; /* Minimal gap */
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0; /* Remove any padding */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure each column uses equal space */
|
||||||
|
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container > * {
|
||||||
|
min-width: 0; /* Allow flex shrinking */
|
||||||
|
max-width: 100%; /* Prevent overflow */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weather fixed at bottom */
|
||||||
|
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-section {
|
||||||
|
grid-row: 3;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: inherit;
|
||||||
|
padding: 8px 0; /* Reduced padding */
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-container {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
body.dark-mode {
|
||||||
|
background-color: #222;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .departure-card {
|
||||||
|
background-color: #333;
|
||||||
|
border-left-color: #0077cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .config-modal-content {
|
||||||
|
background-color: #333;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .config-modal-body {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .config-modal-footer {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode #config-cancel-button {
|
||||||
|
background-color: #555;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .time,
|
||||||
|
body.dark-mode .destination {
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .direction,
|
||||||
|
body.dark-mode .details,
|
||||||
|
body.dark-mode .countdown,
|
||||||
|
body.dark-mode .last-updated {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode h2 {
|
||||||
|
color: #0077cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .sun-times {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .line-number {
|
||||||
|
background-color: #0077cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normal orientation */
|
||||||
|
body.normal {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
body.normal .departure-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape orientation - Optimized for wide screens */
|
||||||
|
body.landscape {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area: clock at top, then two-column layout below */
|
||||||
|
body.landscape #content-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .clock-container {
|
||||||
|
grid-row: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content grid: departures on left, weather on right */
|
||||||
|
body.landscape .main-content-grid {
|
||||||
|
grid-row: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 380px;
|
||||||
|
gap: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Departures container: multi-column grid */
|
||||||
|
body.landscape .departure-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(450px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weather container: fixed width, scrollable */
|
||||||
|
body.landscape .weather-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
max-height: 100%;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better horizontal space usage in landscape */
|
||||||
|
body.landscape .departure-card {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .line-number-box {
|
||||||
|
min-width: 120px;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .line-number-large {
|
||||||
|
font-size: 3.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Site containers in landscape should be more compact */
|
||||||
|
body.landscape .site-container {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.landscape .site-header {
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical orientation (90 degrees rotated) */
|
||||||
|
body.vertical {
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vertical #content-wrapper {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transform-origin: center center;
|
||||||
|
position: absolute;
|
||||||
|
width: 100vh; /* Use viewport height for width */
|
||||||
|
height: 100vw; /* Use viewport width for height */
|
||||||
|
max-width: 800px; /* Limit width for better readability */
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: transparent; /* Remove background color */
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
margin-left: -50vh; /* Half of width */
|
||||||
|
margin-top: -50vw; /* Half of height */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vertical .config-button {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
position: fixed;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px; /* Changed from top to bottom */
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vertical .departure-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upside down orientation (180 degrees rotated) */
|
||||||
|
body.upsidedown {
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.upsidedown #content-wrapper {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
transform-origin: center center;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: transparent; /* Remove background color */
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
margin-left: -400px; /* Half of max-width */
|
||||||
|
margin-top: -50vh; /* Half of viewport height */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.upsidedown .config-button {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
position: fixed;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px; /* Changed from top to bottom */
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.upsidedown .departure-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical reverse orientation (270 degrees rotated) */
|
||||||
|
body.vertical-reverse {
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vertical-reverse #content-wrapper {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
transform-origin: center center;
|
||||||
|
position: absolute;
|
||||||
|
width: 100vh; /* Use viewport height for width */
|
||||||
|
height: 100vw; /* Use viewport width for height */
|
||||||
|
max-width: none; /* Remove max-width limitation */
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: visible; /* Show all content */
|
||||||
|
background-color: transparent; /* Remove background color to show background image */
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
margin-left: -50vh; /* Half of width */
|
||||||
|
margin-top: -50vw; /* Half of height */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vertical-reverse .config-button {
|
||||||
|
transform: rotate(-270deg);
|
||||||
|
position: fixed;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px; /* Changed from top to bottom */
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.vertical-reverse .departure-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%; /* Ensure full width */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode indicators - using a class instead of pseudo-element */
|
||||||
|
.mode-indicator {
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
636
public/js/components/DeparturesManager.js
Normal file
636
public/js/components/DeparturesManager.js
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
/**
|
||||||
|
* DeparturesManager - Manages fetching and displaying transit departures
|
||||||
|
* Refactored from global functions to a class-based architecture
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DeparturesManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
apiUrl: options.apiUrl || (window.Constants ? `${window.Constants.API_BASE_URL}${window.Constants.ENDPOINTS.DEPARTURES}` : 'http://localhost:3002/api/departures'),
|
||||||
|
refreshInterval: options.refreshInterval || (window.Constants ? window.Constants.REFRESH.DEPARTURES : 5000),
|
||||||
|
containerId: options.containerId || 'departures',
|
||||||
|
statusId: options.statusId || 'status',
|
||||||
|
lastUpdatedId: options.lastUpdatedId || 'last-updated',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM element references
|
||||||
|
this.container = null;
|
||||||
|
this.statusElement = null;
|
||||||
|
this.lastUpdatedElement = null;
|
||||||
|
|
||||||
|
// State
|
||||||
|
this.currentDepartures = [];
|
||||||
|
this.refreshTimer = null;
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the departures manager
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Get DOM elements
|
||||||
|
this.container = document.getElementById(this.options.containerId);
|
||||||
|
this.statusElement = document.getElementById(this.options.statusId);
|
||||||
|
this.lastUpdatedElement = document.getElementById(this.options.lastUpdatedId);
|
||||||
|
|
||||||
|
if (!this.container) {
|
||||||
|
if (window.logger) {
|
||||||
|
window.logger.error(`Departures container with ID "${this.options.containerId}" not found`);
|
||||||
|
} else {
|
||||||
|
console.error(`Departures container with ID "${this.options.containerId}" not found`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch and setup
|
||||||
|
this.fetchDepartures();
|
||||||
|
this.setupAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate minutes until arrival using expected time (or scheduled if expected not available)
|
||||||
|
* @param {Object} departure - Departure object
|
||||||
|
* @returns {number} Minutes until arrival
|
||||||
|
*/
|
||||||
|
calculateMinutesUntilArrival(departure) {
|
||||||
|
const now = new Date();
|
||||||
|
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)
|
||||||
|
* @param {Object} departure - Departure object
|
||||||
|
* @returns {string} ISO time string
|
||||||
|
*/
|
||||||
|
getDepartureTime(departure) {
|
||||||
|
return departure.expected || departure.scheduled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transport icon SVG based on transport mode
|
||||||
|
* @param {string} transportMode - Transport mode (bus, metro, train, tram, ship)
|
||||||
|
* @param {Object} line - Optional line object for special cases
|
||||||
|
* @returns {string} SVG icon HTML
|
||||||
|
*/
|
||||||
|
static getTransportIcon(transportMode, line = null) {
|
||||||
|
const mode = transportMode ? transportMode.toLowerCase() : 'bus';
|
||||||
|
|
||||||
|
// Special case for line 7 - it's a tram
|
||||||
|
if (line && line.designation === '7') {
|
||||||
|
return '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 5l.75-1.5H17V2H7v1.5h4.75L11 5c-3.13.09-6 .73-6 3.5V17c0 1.5 1.11 2.73 2.55 2.95L6 21.5v.5h2l2-2h4l2 2h2v-.5l-1.55-1.55C17.89 19.73 19 18.5 19 17V8.5c0-2.77-2.87-3.41-6-3.5zm-1 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5-4.5H7V9h10v5z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
bus: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z"/></svg>',
|
||||||
|
metro: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20v1h12v-1l-1.5-1c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-4-4-8-4zm0 2c3.51 0 4.96.48 5.57 1H6.43c.61-.52 2.06-1 5.57-1zM6 7h5v3H6V7zm12 0v3h-5V7h5zm-6 5v3H6v-3h6zm1 0h5v3h-5v-3z"/></svg>',
|
||||||
|
train: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20v1h12v-1l-1.5-1c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-4-4-8-4zm0 2c3.51 0 4.96.48 5.57 1H6.43c.61-.52 2.06-1 5.57-1zM6 7h5v3H6V7zm12 0v3h-5V7h5zm-6 5v3H6v-3h6zm1 0h5v3h-5v-3z"/></svg>',
|
||||||
|
tram: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M13 5l.75-1.5H17V2H7v1.5h4.75L11 5c-3.13.09-6 .73-6 3.5V17c0 1.5 1.11 2.73 2.55 2.95L6 21.5v.5h2l2-2h4l2 2h2v-.5l-1.55-1.55C17.89 19.73 19 18.5 19 17V8.5c0-2.77-2.87-3.41-6-3.5zm-1 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5-4.5H7V9h10v5z"/></svg>',
|
||||||
|
ship: '<svg class="transport-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M20 21c-1.39 0-2.78-.47-4-1.32-2.44 1.71-5.56 1.71-8 0C6.78 20.53 5.39 21 4 21H2v2h2c1.38 0 2.74-.35 4-.99 2.52 1.29 5.48 1.29 8 0 1.26.65 2.62.99 4 .99h2v-2h-2zM3.95 19H4c1.6 0 3.02-.88 4-2 .98 1.12 2.4 2 4 2s3.02-.88 4-2c.98 1.12 2.4 2 4 2h.05l1.89-6.68c.08-.26.06-.54-.06-.78s-.34-.42-.6-.5L20 10.62V6c0-1.1-.9-2-2-2h-3V1H9v3H6c-1.1 0-2 .9-2 2v4.62l-1.29.42c-.26.08-.48.26-.6.5s-.15.52-.06.78L3.95 19zM6 6h12v3.97L12 8 6 9.97V6z"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
return icons[mode] || icons.bus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date and time
|
||||||
|
* @param {string} dateTimeString - ISO date string
|
||||||
|
* @returns {string} Formatted time string
|
||||||
|
*/
|
||||||
|
static formatDateTime(dateTimeString) {
|
||||||
|
const date = new Date(dateTimeString);
|
||||||
|
const locale = window.Constants?.DATE_FORMAT?.LOCALE || 'sv-SE';
|
||||||
|
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format relative time (e.g., "in 5 minutes")
|
||||||
|
* @param {string} dateTimeString - ISO date string
|
||||||
|
* @returns {string} Formatted relative time string
|
||||||
|
*/
|
||||||
|
static formatRelativeTime(dateTimeString) {
|
||||||
|
const departureTime = new Date(dateTimeString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
|
||||||
|
|
||||||
|
if (diffMinutes <= 0) {
|
||||||
|
return 'Nu';
|
||||||
|
} else if (diffMinutes === 1) {
|
||||||
|
return 'In 1 minute';
|
||||||
|
} else if (diffMinutes < 60) {
|
||||||
|
return `In ${diffMinutes} minutes`;
|
||||||
|
} else {
|
||||||
|
const hours = Math.floor(diffMinutes / 60);
|
||||||
|
const minutes = diffMinutes % 60;
|
||||||
|
if (minutes === 0) {
|
||||||
|
return `In ${hours} hour${hours > 1 ? 's' : ''}`;
|
||||||
|
} else {
|
||||||
|
return `In ${hours} hour${hours > 1 ? 's' : ''} and ${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configuration from ConfigManager or return defaults
|
||||||
|
* @returns {Object} Configuration object
|
||||||
|
*/
|
||||||
|
getConfig() {
|
||||||
|
const defaultConfig = {
|
||||||
|
combineSameDirection: true,
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
id: window.Constants?.DEFAULT_SITE?.ID || '1411',
|
||||||
|
name: window.Constants?.DEFAULT_SITE?.NAME || 'Ambassaderna',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.configManager && window.configManager.config) {
|
||||||
|
return {
|
||||||
|
combineSameDirection: window.configManager.config.combineSameDirection !== undefined ?
|
||||||
|
window.configManager.config.combineSameDirection : defaultConfig.combineSameDirection,
|
||||||
|
sites: window.configManager.config.sites || defaultConfig.sites
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group departures by line number
|
||||||
|
* @param {Array} departures - Array of departure objects
|
||||||
|
* @returns {Array} Array of grouped departure objects
|
||||||
|
*/
|
||||||
|
groupDeparturesByLineNumber(departures) {
|
||||||
|
const groups = {};
|
||||||
|
|
||||||
|
departures.forEach(departure => {
|
||||||
|
const lineNumber = departure.line.designation;
|
||||||
|
|
||||||
|
if (!groups[lineNumber]) {
|
||||||
|
groups[lineNumber] = {
|
||||||
|
line: departure.line,
|
||||||
|
directions: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const departureDirection = departure.direction_code !== undefined ? departure.direction_code :
|
||||||
|
departure.directionCode !== undefined ? departure.directionCode :
|
||||||
|
departure.direction !== undefined ? departure.direction :
|
||||||
|
1;
|
||||||
|
|
||||||
|
const directionKey = `${departureDirection}-${departure.destination}`;
|
||||||
|
|
||||||
|
if (!groups[lineNumber].directions[directionKey]) {
|
||||||
|
groups[lineNumber].directions[directionKey] = {
|
||||||
|
direction: departureDirection,
|
||||||
|
destination: departure.destination,
|
||||||
|
departures: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[lineNumber].directions[directionKey].departures.push(departure);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(groups).map(([lineNumber, data]) => {
|
||||||
|
const directionsArray = Object.values(data.directions);
|
||||||
|
directionsArray.sort((a, b) => {
|
||||||
|
const dirA = a.direction || 1;
|
||||||
|
const dirB = b.direction || 1;
|
||||||
|
return dirA - dirB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineNumber: lineNumber,
|
||||||
|
line: data.line,
|
||||||
|
directions: directionsArray
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine countdown text and class based on minutes until arrival
|
||||||
|
* @param {Object} departure - Departure object
|
||||||
|
* @returns {Object} Object with countdownText and countdownClass
|
||||||
|
*/
|
||||||
|
getCountdownInfo(departure) {
|
||||||
|
const displayTime = departure.display;
|
||||||
|
const minutesUntil = this.calculateMinutesUntilArrival(departure);
|
||||||
|
let countdownText = displayTime;
|
||||||
|
let countdownClass = '';
|
||||||
|
|
||||||
|
const urgentThreshold = window.Constants?.TIME_THRESHOLDS?.URGENT || 5;
|
||||||
|
|
||||||
|
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
|
||||||
|
countdownText = 'Nu';
|
||||||
|
countdownClass = 'now';
|
||||||
|
} else if (minutesUntil < urgentThreshold) {
|
||||||
|
const minMatch = displayTime.match(/(\d+)\s*min/i);
|
||||||
|
if (minMatch) {
|
||||||
|
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
|
||||||
|
} else {
|
||||||
|
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
|
||||||
|
}
|
||||||
|
countdownClass = 'urgent';
|
||||||
|
} else {
|
||||||
|
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
|
||||||
|
if (isTimeOnly) {
|
||||||
|
countdownText = `${minutesUntil} min`;
|
||||||
|
} else {
|
||||||
|
countdownText = displayTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { countdownText, countdownClass };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a departure card element (legacy format)
|
||||||
|
* @param {Object} departure - Departure object
|
||||||
|
* @returns {HTMLElement} Departure card element
|
||||||
|
*/
|
||||||
|
createDepartureCard(departure) {
|
||||||
|
const departureCard = document.createElement('div');
|
||||||
|
departureCard.dataset.journeyId = departure.journey.id;
|
||||||
|
|
||||||
|
const displayTime = departure.display;
|
||||||
|
const departureTime = this.getDepartureTime(departure);
|
||||||
|
const timeDisplay = DeparturesManager.formatDateTime(departureTime);
|
||||||
|
|
||||||
|
const departureTimeDate = new Date(departureTime);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMinutes = Math.round((departureTimeDate - now) / (1000 * 60));
|
||||||
|
const withinHourThreshold = window.Constants?.TIME_THRESHOLDS?.WITHIN_HOUR || 60;
|
||||||
|
const isWithinNextHour = diffMinutes <= withinHourThreshold;
|
||||||
|
|
||||||
|
departureCard.className = isWithinNextHour ? 'departure-card condensed' : 'departure-card';
|
||||||
|
|
||||||
|
const { countdownText, countdownClass } = this.getCountdownInfo(departure);
|
||||||
|
const transportIcon = DeparturesManager.getTransportIcon(departure.line?.transportMode, departure.line);
|
||||||
|
|
||||||
|
departureCard.innerHTML = `
|
||||||
|
<div class="departure-header">
|
||||||
|
<span class="line-number">
|
||||||
|
${transportIcon}
|
||||||
|
${departure.line.designation}
|
||||||
|
<span class="line-destination">${departure.destination}</span>
|
||||||
|
</span>
|
||||||
|
<span class="time">
|
||||||
|
<span class="arrival-time">${timeDisplay}</span>
|
||||||
|
<span class="countdown ${countdownClass}">(${countdownText})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return departureCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display grouped departures by line (new card-based layout)
|
||||||
|
* @param {Array} groups - Array of grouped departure objects
|
||||||
|
* @param {HTMLElement} container - Container element
|
||||||
|
*/
|
||||||
|
displayGroupedDeparturesByLine(groups, container) {
|
||||||
|
groups.forEach(group => {
|
||||||
|
const groupCard = document.createElement('div');
|
||||||
|
groupCard.className = 'departure-card line-card';
|
||||||
|
|
||||||
|
const apiTransportMode = group.line?.transportMode || '';
|
||||||
|
const transportMode = apiTransportMode.toLowerCase();
|
||||||
|
|
||||||
|
const lineNumberBox = document.createElement('div');
|
||||||
|
lineNumberBox.className = `line-number-box ${transportMode}`;
|
||||||
|
|
||||||
|
const transportIcon = DeparturesManager.getTransportIcon(group.line?.transportMode, group.line);
|
||||||
|
|
||||||
|
lineNumberBox.innerHTML = `
|
||||||
|
<div class="transport-mode-icon">${transportIcon}</div>
|
||||||
|
<div class="line-number-large">${group.lineNumber}</div>
|
||||||
|
`;
|
||||||
|
groupCard.appendChild(lineNumberBox);
|
||||||
|
|
||||||
|
const directionsWrapper = document.createElement('div');
|
||||||
|
directionsWrapper.className = 'directions-wrapper';
|
||||||
|
|
||||||
|
const maxDirections = 2;
|
||||||
|
group.directions.slice(0, maxDirections).forEach(direction => {
|
||||||
|
direction.departures.sort((a, b) => {
|
||||||
|
const timeA = this.getDepartureTime(a);
|
||||||
|
const timeB = this.getDepartureTime(b);
|
||||||
|
return new Date(timeA) - new Date(timeB);
|
||||||
|
});
|
||||||
|
|
||||||
|
const directionRow = document.createElement('div');
|
||||||
|
directionRow.className = 'direction-row';
|
||||||
|
|
||||||
|
const directionInfo = document.createElement('div');
|
||||||
|
directionInfo.className = 'direction-info';
|
||||||
|
|
||||||
|
const firstDep = direction.departures[0];
|
||||||
|
if (!firstDep) return;
|
||||||
|
|
||||||
|
const directionCode = firstDep.direction_code !== undefined ? firstDep.direction_code :
|
||||||
|
firstDep.directionCode !== undefined ? firstDep.directionCode :
|
||||||
|
null;
|
||||||
|
|
||||||
|
const isRight = directionCode === 2;
|
||||||
|
|
||||||
|
if (directionCode === null || directionCode === undefined) {
|
||||||
|
if (window.logger) {
|
||||||
|
window.logger.warn('No direction_code found for:', direction.destination, firstDep);
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
|
||||||
|
const timesContainer = document.createElement('div');
|
||||||
|
timesContainer.className = 'times-container';
|
||||||
|
|
||||||
|
const firstDeparture = direction.departures[0];
|
||||||
|
const secondDeparture = direction.departures[1];
|
||||||
|
|
||||||
|
if (firstDeparture) {
|
||||||
|
const displayTime = firstDeparture.display;
|
||||||
|
const departureTime = this.getDepartureTime(firstDeparture);
|
||||||
|
const timeDisplay = DeparturesManager.formatDateTime(departureTime);
|
||||||
|
|
||||||
|
const { countdownText, countdownClass } = this.getCountdownInfo(firstDeparture);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const timeRangeSpan = document.createElement('span');
|
||||||
|
timeRangeSpan.className = 'time-range';
|
||||||
|
if (secondDeparture) {
|
||||||
|
const secondTime = DeparturesManager.formatDateTime(this.getDepartureTime(secondDeparture));
|
||||||
|
timeRangeSpan.textContent = `${timeDisplay} - ${secondTime}`;
|
||||||
|
} else {
|
||||||
|
timeRangeSpan.textContent = timeDisplay;
|
||||||
|
}
|
||||||
|
timeDisplayElement.appendChild(timeRangeSpan);
|
||||||
|
|
||||||
|
timesContainer.appendChild(timeDisplayElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
directionRow.appendChild(timesContainer);
|
||||||
|
directionsWrapper.appendChild(directionRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
groupCard.appendChild(directionsWrapper);
|
||||||
|
container.appendChild(groupCard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display departures in the UI
|
||||||
|
* @param {Array} departures - Array of departure objects
|
||||||
|
*/
|
||||||
|
displayDepartures(departures) {
|
||||||
|
if (!departures || departures.length === 0) {
|
||||||
|
this.container.innerHTML = '<div class="error">No departures found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentDepartures.length === 0) {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
|
||||||
|
departures.forEach(departure => {
|
||||||
|
const departureCard = this.createDepartureCard(departure);
|
||||||
|
this.container.appendChild(departureCard);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.updateExistingCards(departures);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentDepartures = JSON.parse(JSON.stringify(departures));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing cards or add new ones
|
||||||
|
* @param {Array} newDepartures - Array of new departure objects
|
||||||
|
*/
|
||||||
|
updateExistingCards(newDepartures) {
|
||||||
|
const currentCards = this.container.querySelectorAll('.departure-card');
|
||||||
|
const currentCardIds = Array.from(currentCards).map(card => card.dataset.journeyId);
|
||||||
|
|
||||||
|
newDepartures.forEach((departure, index) => {
|
||||||
|
const journeyId = departure.journey.id;
|
||||||
|
const existingCardIndex = currentCardIds.indexOf(journeyId.toString());
|
||||||
|
|
||||||
|
if (existingCardIndex !== -1) {
|
||||||
|
const existingCard = currentCards[existingCardIndex];
|
||||||
|
this.updateCardContent(existingCard, departure);
|
||||||
|
} else {
|
||||||
|
const newCard = this.createDepartureCard(departure);
|
||||||
|
newCard.style.opacity = '0';
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
this.container.prepend(newCard);
|
||||||
|
} else if (index >= this.container.children.length) {
|
||||||
|
this.container.appendChild(newCard);
|
||||||
|
} else {
|
||||||
|
this.container.insertBefore(newCard, this.container.children[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
newCard.style.transition = 'opacity 0.5s ease-in';
|
||||||
|
newCard.style.opacity = '1';
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
|
||||||
|
currentCards.forEach(card => {
|
||||||
|
if (!newDepartureIds.includes(card.dataset.journeyId)) {
|
||||||
|
card.style.transition = 'opacity 0.5s ease-out';
|
||||||
|
card.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (card.parentNode) {
|
||||||
|
card.parentNode.removeChild(card);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update card content
|
||||||
|
* @param {HTMLElement} card - Card element
|
||||||
|
* @param {Object} departure - Departure object
|
||||||
|
*/
|
||||||
|
updateCardContent(card, departure) {
|
||||||
|
const { countdownText, countdownClass } = this.getCountdownInfo(departure);
|
||||||
|
const countdownElement = card.querySelector('.countdown');
|
||||||
|
|
||||||
|
if (countdownElement) {
|
||||||
|
countdownElement.classList.remove('now', 'urgent');
|
||||||
|
|
||||||
|
if (countdownClass === 'now') {
|
||||||
|
countdownElement.classList.add('now');
|
||||||
|
} else if (countdownClass === 'urgent') {
|
||||||
|
countdownElement.classList.add('urgent');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countdownElement.textContent !== `(${countdownText})`) {
|
||||||
|
countdownElement.textContent = `(${countdownText})`;
|
||||||
|
this.highlightElement(countdownElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add highlight effect to element
|
||||||
|
* @param {HTMLElement} element - Element to highlight
|
||||||
|
*/
|
||||||
|
highlightElement(element) {
|
||||||
|
element.style.transition = 'none';
|
||||||
|
element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
element.style.transition = 'background-color 1.5s ease-out';
|
||||||
|
element.style.backgroundColor = 'transparent';
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display multiple sites
|
||||||
|
* @param {Array} sites - Array of site objects
|
||||||
|
*/
|
||||||
|
displayMultipleSites(sites) {
|
||||||
|
const config = this.getConfig();
|
||||||
|
const enabledSites = config.sites.filter(site => site.enabled);
|
||||||
|
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
|
||||||
|
sites.forEach(site => {
|
||||||
|
const siteConfig = enabledSites.find(s => s.id === site.siteId);
|
||||||
|
if (!siteConfig) return;
|
||||||
|
|
||||||
|
const siteContainer = document.createElement('div');
|
||||||
|
siteContainer.className = 'site-container';
|
||||||
|
|
||||||
|
const siteHeader = document.createElement('div');
|
||||||
|
siteHeader.className = 'site-header';
|
||||||
|
siteHeader.innerHTML = `<span class="site-name">${site.siteName || siteConfig.name}</span>`;
|
||||||
|
siteContainer.appendChild(siteHeader);
|
||||||
|
|
||||||
|
if (site.data && site.data.departures) {
|
||||||
|
const lineGroups = this.groupDeparturesByLineNumber(site.data.departures);
|
||||||
|
this.displayGroupedDeparturesByLine(lineGroups, siteContainer);
|
||||||
|
} else if (site.error) {
|
||||||
|
const errorElement = document.createElement('div');
|
||||||
|
errorElement.className = 'error';
|
||||||
|
errorElement.textContent = `Error loading departures for ${site.siteName}: ${site.error}`;
|
||||||
|
siteContainer.appendChild(errorElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.appendChild(siteContainer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch departures from API
|
||||||
|
*/
|
||||||
|
async fetchDepartures() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.options.apiUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.sites && Array.isArray(data.sites)) {
|
||||||
|
this.displayMultipleSites(data.sites);
|
||||||
|
|
||||||
|
if (this.lastUpdatedElement) {
|
||||||
|
const now = new Date();
|
||||||
|
const locale = window.Constants?.DATE_FORMAT?.LOCALE || 'sv-SE';
|
||||||
|
this.lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString(locale)}`;
|
||||||
|
}
|
||||||
|
} else if (data.departures) {
|
||||||
|
this.displayDepartures(data.departures);
|
||||||
|
|
||||||
|
if (this.lastUpdatedElement) {
|
||||||
|
const now = new Date();
|
||||||
|
const locale = window.Constants?.DATE_FORMAT?.LOCALE || 'sv-SE';
|
||||||
|
this.lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString(locale)}`;
|
||||||
|
}
|
||||||
|
} else if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (window.logger) {
|
||||||
|
window.logger.error('Error fetching departures:', error);
|
||||||
|
} else {
|
||||||
|
console.error('Error fetching departures:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<p>Failed to load departures. Please try again later.</p>
|
||||||
|
<p>Error: ${error.message}</p>
|
||||||
|
<p>Make sure the Node.js server is running: <code>node server.js</code></p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up auto-refresh timer
|
||||||
|
*/
|
||||||
|
setupAutoRefresh() {
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearInterval(this.refreshTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshTimer = setInterval(() => this.fetchDepartures(), this.options.refreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop auto-refresh
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart auto-refresh with new interval
|
||||||
|
* @param {number} interval - New interval in milliseconds
|
||||||
|
*/
|
||||||
|
setRefreshInterval(interval) {
|
||||||
|
this.options.refreshInterval = interval;
|
||||||
|
this.setupAutoRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the class
|
||||||
|
window.DeparturesManager = DeparturesManager;
|
||||||
@@ -7,11 +7,14 @@
|
|||||||
class WeatherManager {
|
class WeatherManager {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
// Default options
|
// Default options
|
||||||
|
// Get API key from options, window (injected by server from .env), or fallback
|
||||||
|
const apiKey = options.apiKey || window.OPENWEATHERMAP_API_KEY || '4d8fb5b93d4af21d66a2948710284366';
|
||||||
|
|
||||||
this.options = {
|
this.options = {
|
||||||
latitude: 59.3293, // Stockholm latitude
|
latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude
|
||||||
longitude: 18.0686, // Stockholm longitude
|
longitude: options.longitude || (window.DEFAULT_LOCATION?.longitude) || 18.0686, // Stockholm longitude
|
||||||
apiKey: options.apiKey || '4d8fb5b93d4af21d66a2948710284366', // OpenWeatherMap API key
|
apiKey: apiKey, // OpenWeatherMap API key (from .env via server injection, or fallback)
|
||||||
refreshInterval: 30 * 60 * 1000, // 30 minutes in milliseconds
|
refreshInterval: options.refreshInterval || 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
122
public/js/main.js
Normal file
122
public/js/main.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Main application entry point
|
||||||
|
* Initializes all components when the DOM is ready
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to ensure content wrapper exists for rotated orientations
|
||||||
|
*/
|
||||||
|
function ensureContentWrapper() {
|
||||||
|
if (!document.getElementById('content-wrapper')) {
|
||||||
|
if (window.logger) {
|
||||||
|
window.logger.info('Creating content wrapper');
|
||||||
|
} else {
|
||||||
|
console.log('Creating content wrapper');
|
||||||
|
}
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.id = 'content-wrapper';
|
||||||
|
|
||||||
|
// Move all body children to the wrapper except excluded elements
|
||||||
|
const excludedElements = ['config-button', 'config-modal', 'background-overlay'];
|
||||||
|
|
||||||
|
// Create an array of nodes to move (can't modify while iterating)
|
||||||
|
const nodesToMove = [];
|
||||||
|
for (let i = 0; i < document.body.children.length; i++) {
|
||||||
|
const child = document.body.children[i];
|
||||||
|
if (!excludedElements.includes(child.id) && child.id !== 'content-wrapper') {
|
||||||
|
nodesToMove.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the nodes to the wrapper
|
||||||
|
nodesToMove.forEach(node => {
|
||||||
|
wrapper.appendChild(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the wrapper back to the body
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize components when the DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
if (window.logger) {
|
||||||
|
window.logger.info('DOM fully loaded');
|
||||||
|
} else {
|
||||||
|
console.log('DOM fully loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize ConfigManager first
|
||||||
|
if (window.logger) {
|
||||||
|
window.logger.info('Creating ConfigManager...');
|
||||||
|
} else {
|
||||||
|
console.log('Creating ConfigManager...');
|
||||||
|
}
|
||||||
|
window.configManager = new ConfigManager({
|
||||||
|
defaultOrientation: 'normal',
|
||||||
|
defaultDarkMode: 'auto'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: ConfigManager already creates the config button and modal
|
||||||
|
|
||||||
|
// Initialize Clock
|
||||||
|
const timezone = window.Constants?.TIMEZONE || 'Europe/Stockholm';
|
||||||
|
window.clock = new Clock({
|
||||||
|
elementId: 'clock',
|
||||||
|
timezone: timezone
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WeatherManager with location from window config or constants
|
||||||
|
const defaultLat = window.DEFAULT_LOCATION?.latitude ||
|
||||||
|
(window.Constants?.DEFAULT_LOCATION?.LATITUDE) || 59.3293;
|
||||||
|
const defaultLon = window.DEFAULT_LOCATION?.longitude ||
|
||||||
|
(window.Constants?.DEFAULT_LOCATION?.LONGITUDE) || 18.0686;
|
||||||
|
window.weatherManager = new WeatherManager({
|
||||||
|
latitude: defaultLat,
|
||||||
|
longitude: defaultLon
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize departures - use DeparturesManager
|
||||||
|
if (typeof DeparturesManager !== 'undefined') {
|
||||||
|
window.departuresManager = new DeparturesManager({
|
||||||
|
containerId: 'departures',
|
||||||
|
statusId: 'status',
|
||||||
|
lastUpdatedId: 'last-updated'
|
||||||
|
});
|
||||||
|
} else if (typeof initDepartures === 'function') {
|
||||||
|
// Fallback to legacy function if DeparturesManager not available
|
||||||
|
initDepartures();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
document.addEventListener('darkModeChanged', event => {
|
||||||
|
document.body.classList.toggle('dark-mode', event.detail.isDarkMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('configChanged', event => {
|
||||||
|
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(event.detail.config.orientation)) {
|
||||||
|
ensureContentWrapper();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure content wrapper exists initially
|
||||||
|
ensureContentWrapper();
|
||||||
|
|
||||||
|
if (window.logger) {
|
||||||
|
window.logger.info('All components initialized successfully');
|
||||||
|
} else {
|
||||||
|
console.log('All components initialized successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (window.logger) {
|
||||||
|
window.logger.error('Error during initialization:', error);
|
||||||
|
} else {
|
||||||
|
console.error('Error during initialization:', error);
|
||||||
|
}
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'error';
|
||||||
|
errorDiv.textContent = `Initialization error: ${error.message}`;
|
||||||
|
document.body.appendChild(errorDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
89
public/js/utils/constants.js
Normal file
89
public/js/utils/constants.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Application constants and configuration values
|
||||||
|
* Centralizes magic numbers, API endpoints, and default settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Constants = {
|
||||||
|
// Server configuration
|
||||||
|
PORT: 3002,
|
||||||
|
API_BASE_URL: 'http://localhost:3002',
|
||||||
|
|
||||||
|
// API Endpoints
|
||||||
|
ENDPOINTS: {
|
||||||
|
DEPARTURES: '/api/departures',
|
||||||
|
SITES_SEARCH: '/api/sites/search',
|
||||||
|
SITES_NEARBY: '/api/sites/nearby',
|
||||||
|
CONFIG_UPDATE: '/api/config/update'
|
||||||
|
},
|
||||||
|
|
||||||
|
// External API URLs
|
||||||
|
EXTERNAL_APIS: {
|
||||||
|
SL_TRANSPORT_BASE: 'https://transport.integration.sl.se/v1',
|
||||||
|
OPENWEATHERMAP_BASE: 'https://api.openweathermap.org/data/2.5'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh intervals (in milliseconds)
|
||||||
|
REFRESH: {
|
||||||
|
DEPARTURES: 5000, // 5 seconds
|
||||||
|
WEATHER: 30 * 60 * 1000, // 30 minutes
|
||||||
|
DARK_MODE_CHECK: 60000 // 1 minute
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default location (Stockholm, Sweden)
|
||||||
|
DEFAULT_LOCATION: {
|
||||||
|
LATITUDE: 59.3293,
|
||||||
|
LONGITUDE: 18.0686,
|
||||||
|
NAME: 'Stockholm'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default site configuration
|
||||||
|
DEFAULT_SITE: {
|
||||||
|
ID: '1411',
|
||||||
|
NAME: 'Ambassaderna'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default search radius (in meters)
|
||||||
|
DEFAULT_SEARCH_RADIUS: 5000, // 5km
|
||||||
|
|
||||||
|
// Transport mode identifiers
|
||||||
|
TRANSPORT_MODES: {
|
||||||
|
BUS: 'bus',
|
||||||
|
METRO: 'metro',
|
||||||
|
TRAIN: 'train',
|
||||||
|
TRAM: 'tram',
|
||||||
|
SHIP: 'ship'
|
||||||
|
},
|
||||||
|
|
||||||
|
// CSS class names (for consistency)
|
||||||
|
CSS_CLASSES: {
|
||||||
|
DARK_MODE: 'dark-mode',
|
||||||
|
ORIENTATION_NORMAL: 'normal',
|
||||||
|
ORIENTATION_LANDSCAPE: 'landscape',
|
||||||
|
ORIENTATION_VERTICAL: 'vertical',
|
||||||
|
ORIENTATION_UPSIDE_DOWN: 'upsidedown',
|
||||||
|
ORIENTATION_VERTICAL_REVERSE: 'vertical-reverse'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Time thresholds (in minutes)
|
||||||
|
TIME_THRESHOLDS: {
|
||||||
|
URGENT: 5, // Less than 5 minutes is urgent (red)
|
||||||
|
WITHIN_HOUR: 60 // Within next hour
|
||||||
|
},
|
||||||
|
|
||||||
|
// LocalStorage keys
|
||||||
|
STORAGE_KEYS: {
|
||||||
|
CONFIG: 'sl-departures-config'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timezone
|
||||||
|
TIMEZONE: 'Europe/Stockholm',
|
||||||
|
|
||||||
|
// Date/time formats
|
||||||
|
DATE_FORMAT: {
|
||||||
|
TIMEZONE: 'Europe/Stockholm',
|
||||||
|
LOCALE: 'sv-SE'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export constants
|
||||||
|
window.Constants = Constants;
|
||||||
100
public/js/utils/logger.js
Normal file
100
public/js/utils/logger.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Logger utility for consistent logging throughout the application
|
||||||
|
* Supports different log levels and can be configured for production vs development
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
constructor() {
|
||||||
|
this.level = this.getLogLevel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current log level from environment or default to 'info'
|
||||||
|
* @returns {string} Log level ('debug', 'info', 'warn', 'error')
|
||||||
|
*/
|
||||||
|
getLogLevel() {
|
||||||
|
// In production, you might want to check window.location.hostname
|
||||||
|
// or a global config. For now, default to 'info'
|
||||||
|
if (typeof window !== 'undefined' && window.LOG_LEVEL) {
|
||||||
|
return window.LOG_LEVEL;
|
||||||
|
}
|
||||||
|
// Default to 'info' level
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a log level should be output
|
||||||
|
* @param {string} level - Log level to check
|
||||||
|
* @returns {boolean} Whether to output this level
|
||||||
|
*/
|
||||||
|
shouldLog(level) {
|
||||||
|
const levels = ['debug', 'info', 'warn', 'error'];
|
||||||
|
const currentLevelIndex = levels.indexOf(this.level);
|
||||||
|
const messageLevelIndex = levels.indexOf(level);
|
||||||
|
return messageLevelIndex >= currentLevelIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a log message with timestamp
|
||||||
|
* @param {string} level - Log level
|
||||||
|
* @param {string} message - Message to log
|
||||||
|
* @param {any[]} args - Additional arguments
|
||||||
|
* @returns {string} Formatted message
|
||||||
|
*/
|
||||||
|
formatMessage(level, message, args = []) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||||
|
return args.length > 0 ? [prefix, message, ...args] : [prefix, message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a debug message
|
||||||
|
* @param {string} message - Message to log
|
||||||
|
* @param {...any} args - Additional arguments
|
||||||
|
*/
|
||||||
|
debug(message, ...args) {
|
||||||
|
if (this.shouldLog('debug')) {
|
||||||
|
console.debug(...this.formatMessage('debug', message, args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an info message
|
||||||
|
* @param {string} message - Message to log
|
||||||
|
* @param {...any} args - Additional arguments
|
||||||
|
*/
|
||||||
|
info(message, ...args) {
|
||||||
|
if (this.shouldLog('info')) {
|
||||||
|
console.info(...this.formatMessage('info', message, args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a warning message
|
||||||
|
* @param {string} message - Message to log
|
||||||
|
* @param {...any} args - Additional arguments
|
||||||
|
*/
|
||||||
|
warn(message, ...args) {
|
||||||
|
if (this.shouldLog('warn')) {
|
||||||
|
console.warn(...this.formatMessage('warn', message, args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an error message
|
||||||
|
* @param {string} message - Message to log
|
||||||
|
* @param {...any} args - Additional arguments
|
||||||
|
*/
|
||||||
|
error(message, ...args) {
|
||||||
|
if (this.shouldLog('error')) {
|
||||||
|
console.error(...this.formatMessage('error', message, args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a singleton instance
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
|
// Export both the class and the singleton instance
|
||||||
|
window.Logger = Logger;
|
||||||
|
window.logger = logger;
|
||||||
404
server.js
404
server.js
@@ -1,8 +1,17 @@
|
|||||||
const http = require('http');
|
// Load environment variables
|
||||||
const https = require('https');
|
require('dotenv').config();
|
||||||
const url = require('url');
|
|
||||||
|
|
||||||
const PORT = 3002;
|
const http = require('http');
|
||||||
|
const url = require('url');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Route handlers
|
||||||
|
const departuresRouter = require('./server/routes/departures');
|
||||||
|
const sitesRouter = require('./server/routes/sites');
|
||||||
|
const configRouter = require('./server/routes/config');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3002;
|
||||||
|
|
||||||
// Default configuration
|
// Default configuration
|
||||||
let config = {
|
let config = {
|
||||||
@@ -19,8 +28,9 @@ let config = {
|
|||||||
function loadSitesConfig() {
|
function loadSitesConfig() {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
if (fs.existsSync('sites-config.json')) {
|
const configPath = path.join('config', 'sites.json');
|
||||||
const configData = fs.readFileSync('sites-config.json', 'utf8');
|
if (fs.existsSync(configPath)) {
|
||||||
|
const configData = fs.readFileSync(configPath, 'utf8');
|
||||||
const loadedConfig = JSON.parse(configData);
|
const loadedConfig = JSON.parse(configData);
|
||||||
|
|
||||||
// Handle old format (array of sites)
|
// Handle old format (array of sites)
|
||||||
@@ -40,92 +50,6 @@ function loadSitesConfig() {
|
|||||||
// Load configuration on startup
|
// Load configuration on startup
|
||||||
loadSitesConfig();
|
loadSitesConfig();
|
||||||
|
|
||||||
// Function to fetch data from the SL Transport API for a specific site
|
|
||||||
function fetchDeparturesForSite(siteId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`;
|
|
||||||
console.log(`Fetching data from: ${apiUrl}`);
|
|
||||||
|
|
||||||
https.get(apiUrl, (res) => {
|
|
||||||
let data = '';
|
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
|
||||||
data += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on('end', () => {
|
|
||||||
console.log('Raw API response:', data.substring(0, 200) + '...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
const parsedData = JSON.parse(data);
|
|
||||||
console.log('Successfully parsed as regular JSON');
|
|
||||||
resolve(parsedData);
|
|
||||||
return;
|
|
||||||
} catch (jsonError) {
|
|
||||||
console.log('Not valid JSON, trying to fix format...');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.startsWith('departures":')) {
|
|
||||||
data = '{' + data;
|
|
||||||
} else if (data.includes('departures":')) {
|
|
||||||
const startIndex = data.indexOf('departures":');
|
|
||||||
if (startIndex > 0) {
|
|
||||||
data = '{' + data.substring(startIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data = data.replace(/}{\s*"/g, '},{"');
|
|
||||||
data = data.replace(/"([^"]+)":\s*([^,{}\[\]]+)(?=")/g, '"$1": $2,');
|
|
||||||
data = data.replace(/,\s*}/g, '}').replace(/,\s*\]/g, ']');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedData = JSON.parse(data);
|
|
||||||
console.log('Successfully parsed fixed JSON');
|
|
||||||
|
|
||||||
if (parsedData && parsedData.departures && 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);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Error parsing fixed JSON:', parseError);
|
|
||||||
resolve({
|
|
||||||
departures: [],
|
|
||||||
error: 'Failed to parse API response: ' + parseError.message,
|
|
||||||
rawResponse: data.substring(0, 500) + '...'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing API response:', error);
|
|
||||||
resolve({
|
|
||||||
departures: [],
|
|
||||||
error: 'Error processing API response: ' + error.message,
|
|
||||||
rawResponse: data.substring(0, 500) + '...'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).on('error', (error) => {
|
|
||||||
console.error('Error fetching data from API:', error);
|
|
||||||
resolve({
|
|
||||||
departures: [],
|
|
||||||
error: 'Error fetching data from API: ' + error.message
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
const parsedUrl = url.parse(req.url, true);
|
const parsedUrl = url.parse(req.url, true);
|
||||||
@@ -140,282 +64,65 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to fetch data from all enabled sites
|
// Handle API endpoints - use route handlers
|
||||||
async function fetchAllDepartures() {
|
|
||||||
const enabledSites = config.sites.filter(site => site.enabled);
|
|
||||||
|
|
||||||
if (enabledSites.length === 0) {
|
|
||||||
return { sites: [], error: 'No enabled sites configured' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sitesPromises = enabledSites.map(async (site) => {
|
|
||||||
try {
|
|
||||||
const departureData = await fetchDeparturesForSite(site.id);
|
|
||||||
return {
|
|
||||||
siteId: site.id,
|
|
||||||
siteName: site.name,
|
|
||||||
data: departureData
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching departures for site ${site.id}:`, error);
|
|
||||||
return {
|
|
||||||
siteId: site.id,
|
|
||||||
siteName: site.name,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(sitesPromises);
|
|
||||||
return { sites: results };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching all departures:', error);
|
|
||||||
return { sites: [], error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle API endpoints
|
|
||||||
if (parsedUrl.pathname === '/api/departures') {
|
if (parsedUrl.pathname === '/api/departures') {
|
||||||
try {
|
await departuresRouter.handleDepartures(req, res, config);
|
||||||
const data = await fetchAllDepartures();
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling departures request:', error);
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: error.message }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (parsedUrl.pathname === '/api/sites/search') {
|
else if (parsedUrl.pathname === '/api/sites/search') {
|
||||||
// Search for transit sites
|
sitesRouter.handleSiteSearch(req, res, parsedUrl);
|
||||||
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') {
|
else if (parsedUrl.pathname === '/api/sites/nearby') {
|
||||||
// Get nearby transit sites based on coordinates
|
sitesRouter.handleNearbySites(req, res, parsedUrl);
|
||||||
const lat = parseFloat(parsedUrl.query.lat);
|
|
||||||
const lon = parseFloat(parsedUrl.query.lon);
|
|
||||||
const radius = parseInt(parsedUrl.query.radius) || 5000; // Default 5km radius
|
|
||||||
|
|
||||||
if (isNaN(lat) || isNaN(lon)) {
|
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'Invalid latitude or longitude', sites: [] }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a broader search to get sites, then filter by distance
|
|
||||||
// For now, we'll search for common Stockholm area terms and filter
|
|
||||||
const searchTerms = ['Stockholm', 'T-Centralen', 'Gamla Stan', 'Södermalm'];
|
|
||||||
const allSites = [];
|
|
||||||
let completedSearches = 0;
|
|
||||||
|
|
||||||
searchTerms.forEach(term => {
|
|
||||||
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(term)}`;
|
|
||||||
|
|
||||||
https.get(searchUrl, (apiRes) => {
|
|
||||||
let data = '';
|
|
||||||
|
|
||||||
apiRes.on('data', (chunk) => {
|
|
||||||
data += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
apiRes.on('end', () => {
|
|
||||||
try {
|
|
||||||
const parsedData = JSON.parse(data);
|
|
||||||
let sites = [];
|
|
||||||
|
|
||||||
if (Array.isArray(parsedData)) {
|
|
||||||
sites = parsedData;
|
|
||||||
} else if (parsedData.sites) {
|
|
||||||
sites = parsedData.sites;
|
|
||||||
} else if (parsedData.ResponseData && parsedData.ResponseData.Result) {
|
|
||||||
sites = parsedData.ResponseData.Result;
|
|
||||||
}
|
|
||||||
|
|
||||||
sites.forEach(site => {
|
|
||||||
const siteLat = site.lat || site.latitude || site.Lat || site.Latitude;
|
|
||||||
const siteLon = site.lon || site.longitude || site.Lon || site.Longitude;
|
|
||||||
|
|
||||||
if (siteLat && siteLon) {
|
|
||||||
// Calculate distance (simple haversine approximation)
|
|
||||||
const distance = Math.sqrt(
|
|
||||||
Math.pow((lat - siteLat) * 111000, 2) +
|
|
||||||
Math.pow((lon - siteLon) * 111000 * Math.cos(lat * Math.PI / 180), 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (distance <= radius) {
|
|
||||||
allSites.push({
|
|
||||||
id: String(site.id || site.siteId || site.SiteId || ''),
|
|
||||||
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
|
|
||||||
lat: siteLat,
|
|
||||||
lon: siteLon
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
completedSearches++;
|
|
||||||
if (completedSearches === searchTerms.length) {
|
|
||||||
// Remove duplicates
|
|
||||||
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ sites: uniqueSites }));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
completedSearches++;
|
|
||||||
if (completedSearches === searchTerms.length) {
|
|
||||||
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ sites: uniqueSites }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).on('error', () => {
|
|
||||||
completedSearches++;
|
|
||||||
if (completedSearches === searchTerms.length) {
|
|
||||||
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ sites: uniqueSites }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
else if (parsedUrl.pathname === '/api/config') {
|
else if (parsedUrl.pathname === '/api/config') {
|
||||||
// Return the current configuration
|
configRouter.handleGetConfig(req, res, config);
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(config));
|
|
||||||
}
|
}
|
||||||
else if (parsedUrl.pathname === '/api/config/update' && req.method === 'POST') {
|
else if (parsedUrl.pathname === '/api/config/update' && req.method === 'POST') {
|
||||||
// Update configuration
|
configRouter.handleUpdateConfig(req, res, config);
|
||||||
let body = '';
|
|
||||||
req.on('data', chunk => {
|
|
||||||
body += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('end', () => {
|
|
||||||
try {
|
|
||||||
const newConfig = JSON.parse(body);
|
|
||||||
if (newConfig.sites) {
|
|
||||||
config = newConfig;
|
|
||||||
|
|
||||||
// Save to file
|
|
||||||
const fs = require('fs');
|
|
||||||
fs.writeFileSync('sites-config.json', JSON.stringify(config, null, 2));
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ success: true, message: 'Configuration updated' }));
|
|
||||||
} else {
|
|
||||||
throw new Error('Invalid configuration format');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
else if (parsedUrl.pathname === '/api/config/client') {
|
||||||
console.error('Error updating configuration:', error);
|
configRouter.handleClientConfig(req, res);
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: error.message }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Serve static files
|
// Serve static files
|
||||||
else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') {
|
else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') {
|
||||||
const fs = require('fs');
|
fs.readFile('index.html', 'utf8', (err, data) => {
|
||||||
fs.readFile('index.html', (err, data) => {
|
|
||||||
if (err) {
|
if (err) {
|
||||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
res.end('Error loading index.html');
|
res.end('Error loading index.html');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Always inject API key and config into HTML (even if empty, so window vars exist)
|
||||||
|
const apiKey = process.env.OPENWEATHERMAP_API_KEY || '';
|
||||||
|
if (!data.includes('window.OPENWEATHERMAP_API_KEY')) {
|
||||||
|
// Inject before closing </head> tag
|
||||||
|
const configScript = `
|
||||||
|
<script>
|
||||||
|
window.OPENWEATHERMAP_API_KEY = '${apiKey.replace(/'/g, "\\'")}';
|
||||||
|
window.DEFAULT_LOCATION = {
|
||||||
|
latitude: ${parseFloat(process.env.DEFAULT_LATITUDE) || 59.3293},
|
||||||
|
longitude: ${parseFloat(process.env.DEFAULT_LONGITUDE) || 18.0686},
|
||||||
|
name: '${(process.env.DEFAULT_LOCATION_NAME || 'Stockholm').replace(/'/g, "\\'")}'
|
||||||
|
};
|
||||||
|
</script>`;
|
||||||
|
data = data.replace('</head>', configScript + '\n</head>');
|
||||||
|
}
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
res.end(data);
|
res.end(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (/\.(js|css|png|jpg|jpeg|gif|ico)$/.test(parsedUrl.pathname)) {
|
// Serve static files from public directory or root
|
||||||
const fs = require('fs');
|
else if (/\.(js|css|png|jpg|jpeg|gif|ico|svg|json)$/.test(parsedUrl.pathname)) {
|
||||||
const filePath = parsedUrl.pathname.substring(1);
|
let filePath = parsedUrl.pathname.substring(1); // Remove leading /
|
||||||
fs.readFile(filePath, (err, data) => {
|
|
||||||
|
// Try public directory first, then root directory for backward compatibility
|
||||||
|
const publicPath = path.join('public', filePath);
|
||||||
|
const rootPath = filePath;
|
||||||
|
|
||||||
|
// Check if file exists in public directory first
|
||||||
|
fs.access(publicPath, fs.constants.F_OK, (publicErr) => {
|
||||||
|
const targetPath = publicErr ? rootPath : publicPath;
|
||||||
|
|
||||||
|
fs.readFile(targetPath, (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
res.end('File not found');
|
res.end('File not found');
|
||||||
@@ -430,12 +137,15 @@ const server = http.createServer(async (req, res) => {
|
|||||||
'jpg': 'image/jpeg',
|
'jpg': 'image/jpeg',
|
||||||
'jpeg': 'image/jpeg',
|
'jpeg': 'image/jpeg',
|
||||||
'gif': 'image/gif',
|
'gif': 'image/gif',
|
||||||
'ico': 'image/x-icon'
|
'ico': 'image/x-icon',
|
||||||
|
'svg': 'image/svg+xml',
|
||||||
|
'json': 'application/json'
|
||||||
}[ext] || 'text/plain';
|
}[ext] || 'text/plain';
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': contentType });
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
res.end(data);
|
res.end(data);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
|||||||
78
server/routes/config.js
Normal file
78
server/routes/config.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Config route handler
|
||||||
|
* Handles configuration get/update and client-side config
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/config endpoint
|
||||||
|
* @param {http.IncomingMessage} req - HTTP request object
|
||||||
|
* @param {http.ServerResponse} res - HTTP response object
|
||||||
|
* @param {Object} config - Application configuration
|
||||||
|
*/
|
||||||
|
function handleGetConfig(req, res, config) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle POST /api/config/update endpoint
|
||||||
|
* @param {http.IncomingMessage} req - HTTP request object
|
||||||
|
* @param {http.ServerResponse} res - HTTP response object
|
||||||
|
* @param {Object} config - Application configuration (will be modified)
|
||||||
|
*/
|
||||||
|
function handleUpdateConfig(req, res, config) {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const newConfig = JSON.parse(body);
|
||||||
|
if (newConfig.sites) {
|
||||||
|
// Update config object (passed by reference)
|
||||||
|
Object.assign(config, newConfig);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const configPath = path.join('config', 'sites.json');
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Configuration updated' }));
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid configuration format');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating configuration:', error);
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/config/client endpoint
|
||||||
|
* Returns client-side configuration (API keys, default location)
|
||||||
|
* @param {http.IncomingMessage} req - HTTP request object
|
||||||
|
* @param {http.ServerResponse} res - HTTP response object
|
||||||
|
*/
|
||||||
|
function handleClientConfig(req, res) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
openweathermapApiKey: process.env.OPENWEATHERMAP_API_KEY || '',
|
||||||
|
defaultLocation: {
|
||||||
|
latitude: parseFloat(process.env.DEFAULT_LATITUDE) || 59.3293,
|
||||||
|
longitude: parseFloat(process.env.DEFAULT_LONGITUDE) || 18.0686,
|
||||||
|
name: process.env.DEFAULT_LOCATION_NAME || 'Stockholm'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleGetConfig,
|
||||||
|
handleUpdateConfig,
|
||||||
|
handleClientConfig
|
||||||
|
};
|
||||||
152
server/routes/departures.js
Normal file
152
server/routes/departures.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Departures route handler
|
||||||
|
* Handles fetching and returning transit departure data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch departures for a specific site from SL Transport API
|
||||||
|
* @param {string} siteId - The site ID to fetch departures for
|
||||||
|
* @returns {Promise<Object>} - Departure data
|
||||||
|
*/
|
||||||
|
function fetchDeparturesForSite(siteId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`;
|
||||||
|
console.log(`Fetching data from: ${apiUrl}`);
|
||||||
|
|
||||||
|
https.get(apiUrl, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Raw API response:', data.substring(0, 200) + '...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(data);
|
||||||
|
console.log('Successfully parsed as regular JSON');
|
||||||
|
resolve(parsedData);
|
||||||
|
return;
|
||||||
|
} catch (jsonError) {
|
||||||
|
console.log('Not valid JSON, trying to fix format...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.startsWith('departures":')) {
|
||||||
|
data = '{' + data;
|
||||||
|
} else if (data.includes('departures":')) {
|
||||||
|
const startIndex = data.indexOf('departures":');
|
||||||
|
if (startIndex > 0) {
|
||||||
|
data = '{' + data.substring(startIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data = data.replace(/}{\s*"/g, '},{"');
|
||||||
|
data = data.replace(/"([^"]+)":\s*([^,{}\[\]]+)(?=")/g, '"$1": $2,');
|
||||||
|
data = data.replace(/,\s*}/g, '}').replace(/,\s*\]/g, ']');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(data);
|
||||||
|
console.log('Successfully parsed fixed JSON');
|
||||||
|
|
||||||
|
if (parsedData && parsedData.departures && parsedData.departures.length > 0) {
|
||||||
|
console.log('Sample departure structure:', JSON.stringify(parsedData.departures[0], null, 2));
|
||||||
|
|
||||||
|
const sample = parsedData.departures[0];
|
||||||
|
console.log('Direction fields:', {
|
||||||
|
direction: sample.direction,
|
||||||
|
directionText: sample.directionText,
|
||||||
|
directionCode: sample.directionCode,
|
||||||
|
destination: sample.destination
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(parsedData);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Failed to parse even after fixing:', parseError);
|
||||||
|
// Return empty departures array instead of rejecting to be more resilient
|
||||||
|
resolve({
|
||||||
|
departures: [],
|
||||||
|
error: 'Failed to parse API response: ' + parseError.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing API response:', error);
|
||||||
|
// Return empty departures array instead of rejecting to be more resilient
|
||||||
|
resolve({
|
||||||
|
departures: [],
|
||||||
|
error: 'Error processing API response: ' + error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', (error) => {
|
||||||
|
console.error('Error fetching departures:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch departures for all enabled sites
|
||||||
|
* @param {Array} enabledSites - Array of enabled site configurations
|
||||||
|
* @returns {Promise<Object>} - Object with sites array containing departure data
|
||||||
|
*/
|
||||||
|
async function fetchAllDepartures(enabledSites) {
|
||||||
|
if (enabledSites.length === 0) {
|
||||||
|
return { sites: [], error: 'No enabled sites configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sitesPromises = enabledSites.map(async (site) => {
|
||||||
|
try {
|
||||||
|
const departureData = await fetchDeparturesForSite(site.id);
|
||||||
|
return {
|
||||||
|
siteId: site.id,
|
||||||
|
siteName: site.name,
|
||||||
|
data: departureData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching departures for site ${site.id}:`, error);
|
||||||
|
return {
|
||||||
|
siteId: site.id,
|
||||||
|
siteName: site.name,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(sitesPromises);
|
||||||
|
return { sites: results };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching all departures:', error);
|
||||||
|
return { sites: [], error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle departures API endpoint
|
||||||
|
* @param {http.IncomingMessage} req - HTTP request object
|
||||||
|
* @param {http.ServerResponse} res - HTTP response object
|
||||||
|
* @param {Object} config - Application configuration
|
||||||
|
*/
|
||||||
|
async function handleDepartures(req, res, config) {
|
||||||
|
try {
|
||||||
|
const enabledSites = config.sites.filter(site => site.enabled);
|
||||||
|
const data = await fetchAllDepartures(enabledSites);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling departures request:', error);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleDepartures,
|
||||||
|
fetchDeparturesForSite,
|
||||||
|
fetchAllDepartures
|
||||||
|
};
|
||||||
198
server/routes/sites.js
Normal file
198
server/routes/sites.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Sites route handler
|
||||||
|
* Handles site search and nearby sites queries
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize site data from API response to consistent format
|
||||||
|
* @param {Object} site - Raw site data from API
|
||||||
|
* @returns {Object} - Normalized site object
|
||||||
|
*/
|
||||||
|
function normalizeSite(site) {
|
||||||
|
return {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse sites from API response (handles multiple response formats)
|
||||||
|
* @param {Object|Array} parsedData - Parsed JSON data from API
|
||||||
|
* @returns {Array<Object>} - Array of normalized sites
|
||||||
|
*/
|
||||||
|
function parseSitesFromResponse(parsedData) {
|
||||||
|
let sites = [];
|
||||||
|
|
||||||
|
if (Array.isArray(parsedData)) {
|
||||||
|
sites = parsedData.map(normalizeSite);
|
||||||
|
} else if (parsedData.sites && Array.isArray(parsedData.sites)) {
|
||||||
|
sites = parsedData.sites.map(normalizeSite);
|
||||||
|
} else if (parsedData.ResponseData && parsedData.ResponseData.Result) {
|
||||||
|
sites = parsedData.ResponseData.Result.map(normalizeSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sites;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle site search endpoint
|
||||||
|
* @param {http.IncomingMessage} req - HTTP request object
|
||||||
|
* @param {http.ServerResponse} res - HTTP response object
|
||||||
|
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
|
||||||
|
*/
|
||||||
|
function handleSiteSearch(req, res, parsedUrl) {
|
||||||
|
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 = '';
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
const sites = parseSitesFromResponse(parsedData);
|
||||||
|
|
||||||
|
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: [] }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two coordinates (simple approximation)
|
||||||
|
* @param {number} lat1 - Latitude of point 1
|
||||||
|
* @param {number} lon1 - Longitude of point 1
|
||||||
|
* @param {number} lat2 - Latitude of point 2
|
||||||
|
* @param {number} lon2 - Longitude of point 2
|
||||||
|
* @returns {number} - Distance in meters
|
||||||
|
*/
|
||||||
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.pow((lat1 - lat2) * 111000, 2) +
|
||||||
|
Math.pow((lon1 - lon2) * 111000 * Math.cos(lat1 * Math.PI / 180), 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle nearby sites endpoint
|
||||||
|
* @param {http.IncomingMessage} req - HTTP request object
|
||||||
|
* @param {http.ServerResponse} res - HTTP response object
|
||||||
|
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
|
||||||
|
*/
|
||||||
|
function handleNearbySites(req, res, parsedUrl) {
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
const sites = parseSitesFromResponse(parsedData);
|
||||||
|
|
||||||
|
sites.forEach(site => {
|
||||||
|
if (site.lat && site.lon) {
|
||||||
|
const distance = calculateDistance(lat, lon, site.lat, site.lon);
|
||||||
|
|
||||||
|
if (distance <= radius) {
|
||||||
|
allSites.push(site);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleSiteSearch,
|
||||||
|
handleNearbySites,
|
||||||
|
normalizeSite,
|
||||||
|
parseSitesFromResponse
|
||||||
|
};
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script to update the site ID for the SL Transport API
|
|
||||||
|
|
||||||
# Check if a site ID was provided
|
|
||||||
if [ -z "$1" ]; then
|
|
||||||
echo "Error: No site ID provided."
|
|
||||||
echo "Usage: ./update-site-id.sh SITE_ID"
|
|
||||||
echo "Example: ./update-site-id.sh 1234"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate that the site ID is numeric
|
|
||||||
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo "Error: Site ID must be a number."
|
|
||||||
echo "Usage: ./update-site-id.sh SITE_ID"
|
|
||||||
echo "Example: ./update-site-id.sh 1234"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
SITE_ID=$1
|
|
||||||
SITE_NAME=""
|
|
||||||
|
|
||||||
# Ask for site name (optional)
|
|
||||||
read -p "Enter site name (optional, press Enter to skip): " SITE_NAME
|
|
||||||
|
|
||||||
# Update server.js
|
|
||||||
echo "Updating server.js with site ID: $SITE_ID"
|
|
||||||
sed -i "s|const API_URL = 'https://transport.integration.sl.se/v1/sites/[0-9]*/departures'|const API_URL = 'https://transport.integration.sl.se/v1/sites/$SITE_ID/departures'|g" server.js
|
|
||||||
|
|
||||||
# Update index.html if site name was provided
|
|
||||||
if [ ! -z "$SITE_NAME" ]; then
|
|
||||||
echo "Updating index.html with site name: $SITE_NAME"
|
|
||||||
sed -i "s|<h2 style=\"text-align: center;\">.*</h2>|<h2 style=\"text-align: center;\">$SITE_NAME (Site ID: $SITE_ID)</h2>|g" index.html
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update title in index.html
|
|
||||||
if [ ! -z "$SITE_NAME" ]; then
|
|
||||||
echo "Updating page title in index.html"
|
|
||||||
sed -i "s|<title>SL Transport Departures - .*</title>|<title>SL Transport Departures - $SITE_NAME ($SITE_ID)</title>|g" index.html
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Update complete!"
|
|
||||||
echo "Site ID: $SITE_ID"
|
|
||||||
if [ ! -z "$SITE_NAME" ]; then
|
|
||||||
echo "Site Name: $SITE_NAME"
|
|
||||||
fi
|
|
||||||
echo "Restart the server for changes to take effect: node server.js"
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script to update the weather location in the SL Transport Departures Display
|
|
||||||
|
|
||||||
# Check if latitude and longitude were provided
|
|
||||||
if [ -z "$1" ] || [ -z "$2" ]; then
|
|
||||||
echo "Error: Latitude and longitude must be provided."
|
|
||||||
echo "Usage: ./update-weather-location.sh LATITUDE LONGITUDE [LOCATION_NAME]"
|
|
||||||
echo "Example: ./update-weather-location.sh 59.3293 18.0686 Stockholm"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate that the latitude and longitude are numeric
|
|
||||||
if ! [[ "$1" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] || ! [[ "$2" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
|
|
||||||
echo "Error: Latitude and longitude must be numbers."
|
|
||||||
echo "Usage: ./update-weather-location.sh LATITUDE LONGITUDE [LOCATION_NAME]"
|
|
||||||
echo "Example: ./update-weather-location.sh 59.3293 18.0686 Stockholm"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
LATITUDE=$1
|
|
||||||
LONGITUDE=$2
|
|
||||||
LOCATION_NAME=${3:-""}
|
|
||||||
|
|
||||||
# Update index.html with new latitude and longitude
|
|
||||||
echo "Updating weather location in index.html..."
|
|
||||||
sed -i "s/latitude: [0-9.-]\+/latitude: $LATITUDE/g" index.html
|
|
||||||
sed -i "s/longitude: [0-9.-]\+/longitude: $LONGITUDE/g" index.html
|
|
||||||
|
|
||||||
# Update location name in the weather widget if provided
|
|
||||||
if [ ! -z "$LOCATION_NAME" ]; then
|
|
||||||
echo "Updating location name to: $LOCATION_NAME"
|
|
||||||
# This is a more complex replacement that might need manual verification
|
|
||||||
sed -i "s/<h3>[^<]*<\/h3>/<h3>$LOCATION_NAME<\/h3>/g" index.html
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Weather location updated successfully!"
|
|
||||||
echo "Latitude: $LATITUDE"
|
|
||||||
echo "Longitude: $LONGITUDE"
|
|
||||||
if [ ! -z "$LOCATION_NAME" ]; then
|
|
||||||
echo "Location Name: $LOCATION_NAME"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo "Restart the application to see the changes."
|
|
||||||
echo "You can find latitude and longitude for any location at: https://www.latlong.net/"
|
|
||||||
Reference in New Issue
Block a user