// Calculate minutes until arrival
function calculateMinutesUntilArrival(scheduledTime) {
const now = new Date();
const scheduled = new Date(scheduledTime);
return Math.round((scheduled - now) / (1000 * 60));
}
// Get transport icon based on transport mode
function getTransportIcon(transportMode) {
// Default to bus if not specified
const mode = transportMode ? transportMode.toLowerCase() : 'bus';
// Special case for line 7 - it's a tram
if (arguments.length > 1 && arguments[1] && arguments[1].designation === '7') {
return '';
}
// SVG icons for different transport modes
const icons = {
bus: '',
metro: '',
train: '',
tram: '',
ship: ''
};
return icons[mode] || icons.bus;
}
// Create a departure card element
function createDepartureCard(departure) {
const departureCard = document.createElement('div');
departureCard.dataset.journeyId = departure.journey.id;
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if departure is within the next hour
const departureTime = new Date(departure.scheduled);
const now = new Date();
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
const isWithinNextHour = diffMinutes <= 60;
// Add condensed class if within next hour
departureCard.className = isWithinNextHour ? 'departure-card condensed' : 'departure-card';
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(departure.line?.transportMode, departure.line);
// Create card based on time and display format
departureCard.innerHTML = `
`;
return departureCard;
}
// Display departures grouped by line number
function displayGroupedDeparturesByLine(groups, container) {
groups.forEach(group => {
// Create a card for this line number
const groupCard = document.createElement('div');
groupCard.className = 'departure-card line-card';
// Create card header
const header = document.createElement('div');
header.className = 'departure-header';
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(group.line?.transportMode, group.line);
// Add line number with transport icon
const lineNumber = document.createElement('span');
lineNumber.className = 'line-number';
// Use the first destination as the main one for the header
const mainDestination = group.directions[0]?.destination || '';
lineNumber.innerHTML = `${transportIcon} ${group.lineNumber} ${mainDestination}`;
header.appendChild(lineNumber);
groupCard.appendChild(header);
// Create the directions container
const directionsContainer = document.createElement('div');
directionsContainer.className = 'directions-container';
// Process each direction
group.directions.forEach(direction => {
// Sort departures by time
direction.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Create a row for this direction
const directionRow = document.createElement('div');
directionRow.className = 'direction-row';
// Add direction info
const directionInfo = document.createElement('div');
directionInfo.className = 'direction-info';
// Determine direction arrow
const directionArrow = direction.direction === 1 ? '→' : '←';
directionInfo.innerHTML = `${directionArrow}${direction.destination}`;
directionRow.appendChild(directionInfo);
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
// Add up to 2 departure times per direction
const maxTimes = 2;
direction.departures.slice(0, maxTimes).forEach(departure => {
const timeElement = document.createElement('span');
timeElement.className = 'time';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
timeElement.innerHTML = `${scheduledTime} (${countdownText})`;
timesContainer.appendChild(timeElement);
});
directionRow.appendChild(timesContainer);
directionsContainer.appendChild(directionRow);
});
groupCard.appendChild(directionsContainer);
// Add to container
container.appendChild(groupCard);
});
}
// Display grouped departures (legacy function)
function displayGroupedDepartures(groups, container) {
groups.forEach(group => {
// Sort departures by time
group.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Create a card for this group
const groupCard = document.createElement('div');
groupCard.className = 'departure-card';
// Create card header
const header = document.createElement('div');
header.className = 'departure-header';
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(group.line?.transportMode, group.line);
// Add line number with transport icon and destination
const lineNumber = document.createElement('span');
lineNumber.className = 'line-number';
lineNumber.innerHTML = `${transportIcon} ${group.line.designation} ${group.destination}`;
header.appendChild(lineNumber);
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
timesContainer.style.display = 'flex';
timesContainer.style.flexDirection = 'column';
timesContainer.style.alignItems = 'flex-end';
// Add up to 3 departure times
const maxTimes = 3;
group.departures.slice(0, maxTimes).forEach((departure, index) => {
const timeElement = document.createElement('span');
timeElement.className = 'time';
timeElement.style.fontSize = index === 0 ? '1.1em' : '0.9em';
timeElement.style.marginBottom = '2px';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
if (isTimeOnly) {
timeElement.innerHTML = `${scheduledTime} (${countdownText})`;
} else {
timeElement.innerHTML = `${scheduledTime} (${displayTime})`;
}
timesContainer.appendChild(timeElement);
});
// If there are more departures, show a count
if (group.departures.length > maxTimes) {
const moreElement = document.createElement('span');
moreElement.style.fontSize = '0.8em';
moreElement.style.color = '#666';
moreElement.textContent = `+${group.departures.length - maxTimes} more`;
timesContainer.appendChild(moreElement);
}
header.appendChild(timesContainer);
groupCard.appendChild(header);
// No need to add destination and direction separately as they're now in the header
// Add to container
container.appendChild(groupCard);
});
}
// Format date and time
function formatDateTime(dateTimeString) {
const date = new Date(dateTimeString);
return date.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
}
// Format relative time (e.g., "in 5 minutes")
function formatRelativeTime(dateTimeString) {
const departureTime = new Date(dateTimeString);
const now = new Date();
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
if (diffMinutes <= 0) {
return 'Now';
} else if (diffMinutes === 1) {
return 'In 1 minute';
} else if (diffMinutes < 60) {
return `In ${diffMinutes} minutes`;
} else {
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
if (minutes === 0) {
return `In ${hours} hour${hours > 1 ? 's' : ''}`;
} else {
return `In ${hours} hour${hours > 1 ? 's' : ''} and ${minutes} minute${minutes > 1 ? 's' : ''}`;
}
}
}
// Group departures by line number
function groupDeparturesByLineNumber(departures) {
const groups = {};
departures.forEach(departure => {
const lineNumber = departure.line.designation;
if (!groups[lineNumber]) {
groups[lineNumber] = {
line: departure.line,
directions: {}
};
}
const directionKey = `${departure.direction}-${departure.destination}`;
if (!groups[lineNumber].directions[directionKey]) {
groups[lineNumber].directions[directionKey] = {
direction: departure.direction,
destination: departure.destination,
departures: []
};
}
groups[lineNumber].directions[directionKey].departures.push(departure);
});
// Convert to array format
return Object.entries(groups).map(([lineNumber, data]) => {
return {
lineNumber: lineNumber,
line: data.line,
directions: Object.values(data.directions)
};
});
}
// Group departures by direction (legacy function kept for compatibility)
function groupDeparturesByDirection(departures) {
const groups = {};
departures.forEach(departure => {
const key = `${departure.line.designation}-${departure.direction}-${departure.destination}`;
if (!groups[key]) {
groups[key] = {
line: departure.line,
direction: departure.direction,
destination: departure.destination,
departures: []
};
}
groups[key].departures.push(departure);
});
return Object.values(groups);
}
// Store the current departures data for comparison
let currentDepartures = [];
// Display departures in the UI with smooth transitions
function displayDepartures(departures) {
if (!departures || departures.length === 0) {
departuresContainer.innerHTML = '
No departures found
';
return;
}
// If this is the first load, just display everything
if (currentDepartures.length === 0) {
departuresContainer.innerHTML = '';
departures.forEach(departure => {
const departureCard = createDepartureCard(departure);
departuresContainer.appendChild(departureCard);
});
} else {
// Update only what has changed
updateExistingCards(departures);
}
// Update the current departures for next comparison
currentDepartures = JSON.parse(JSON.stringify(departures));
}
// Update existing cards or add new ones
function updateExistingCards(newDepartures) {
// Get all current cards
const currentCards = departuresContainer.querySelectorAll('.departure-card');
const currentCardIds = Array.from(currentCards).map(card => card.dataset.journeyId);
// Process each new departure
newDepartures.forEach((departure, index) => {
const journeyId = departure.journey.id;
const existingCardIndex = currentCardIds.indexOf(journeyId.toString());
if (existingCardIndex !== -1) {
// Update existing card
const existingCard = currentCards[existingCardIndex];
updateCardContent(existingCard, departure);
} else {
// This is a new departure, add it
const newCard = createDepartureCard(departure);
// Add with fade-in effect
newCard.style.opacity = '0';
if (index === 0) {
// Add to the beginning
departuresContainer.prepend(newCard);
} else if (index >= departuresContainer.children.length) {
// Add to the end
departuresContainer.appendChild(newCard);
} else {
// Insert at specific position
departuresContainer.insertBefore(newCard, departuresContainer.children[index]);
}
// Trigger fade-in
setTimeout(() => {
newCard.style.transition = 'opacity 0.5s ease-in';
newCard.style.opacity = '1';
}, 10);
}
});
// Remove cards that are no longer in the new data
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
currentCards.forEach(card => {
if (!newDepartureIds.includes(card.dataset.journeyId)) {
// Fade out and remove
card.style.transition = 'opacity 0.5s ease-out';
card.style.opacity = '0';
setTimeout(() => {
if (card.parentNode) {
card.parentNode.removeChild(card);
}
}, 500);
}
});
}
// Update only the content that has changed in an existing card
function updateCardContent(card, departure) {
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
// Only update the countdown time which changes frequently
const countdownElement = card.querySelector('.countdown');
// Update with subtle highlight effect for changes
if (countdownElement && countdownElement.textContent !== `(${countdownText})`) {
countdownElement.textContent = `(${countdownText})`;
highlightElement(countdownElement);
}
}
// Add a subtle highlight effect to show updated content
function highlightElement(element) {
element.style.transition = 'none';
element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
setTimeout(() => {
element.style.transition = 'background-color 1.5s ease-out';
element.style.backgroundColor = 'transparent';
}, 10);
}
// Display multiple sites
function displayMultipleSites(sites) {
// Get configuration
const config = getConfig();
const enabledSites = config.sites.filter(site => site.enabled);
// Clear the container
departuresContainer.innerHTML = '';
// Process each site
sites.forEach(site => {
// Check if this site is enabled in the configuration
const siteConfig = enabledSites.find(s => s.id === site.siteId);
if (!siteConfig) return;
// Create a site container
const siteContainer = document.createElement('div');
siteContainer.className = 'site-container';
// Add site header with white tab
const siteHeader = document.createElement('div');
siteHeader.className = 'site-header';
siteHeader.innerHTML = `${site.siteName || siteConfig.name}`;
siteContainer.appendChild(siteHeader);
// Process departures for this site
if (site.data && site.data.departures) {
// Group departures by line number
const lineGroups = {};
site.data.departures.forEach(departure => {
const lineNumber = departure.line.designation;
if (!lineGroups[lineNumber]) {
lineGroups[lineNumber] = [];
}
lineGroups[lineNumber].push(departure);
});
// Process each line group
Object.entries(lineGroups).forEach(([lineNumber, lineDepartures]) => {
// Create a line container for side-by-side display
const lineContainer = document.createElement('div');
lineContainer.className = 'line-container';
// Group by direction
const directionGroups = {};
lineDepartures.forEach(departure => {
const directionKey = `${departure.direction}-${departure.destination}`;
if (!directionGroups[directionKey]) {
directionGroups[directionKey] = {
direction: departure.direction,
destination: departure.destination,
departures: []
};
}
directionGroups[directionKey].departures.push(departure);
});
// Get all direction groups
const directions = Object.values(directionGroups);
// Handle single direction case (like bus 4)
if (directions.length === 1) {
// Create a full-width card for this direction
const directionGroup = directions[0];
// Sort departures by time
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Create a card for this direction
const directionCard = document.createElement('div');
directionCard.className = 'departure-card';
// Don't set width to 100% as it causes the card to stick out
// Create a simplified layout with line number and times on the same row
const cardContent = document.createElement('div');
cardContent.className = 'departure-header';
cardContent.style.display = 'flex';
cardContent.style.justifyContent = 'space-between';
cardContent.style.alignItems = 'center';
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
// Add line number with transport icon and destination
const lineNumberElement = document.createElement('span');
lineNumberElement.className = 'line-number';
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} ${directionGroup.destination}`;
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
timesContainer.style.display = 'flex';
timesContainer.style.flexDirection = 'column';
timesContainer.style.alignItems = 'flex-end';
// Add up to 2 departure times
const maxTimes = 2;
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
const timeElement = document.createElement('div');
timeElement.className = 'time';
timeElement.style.fontSize = '1.1em';
timeElement.style.marginBottom = '2px';
timeElement.style.whiteSpace = 'nowrap';
timeElement.style.textAlign = 'right';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
timeElement.textContent = `${scheduledTime} (${countdownText})`;
timeElement.style.width = '140px'; // Fixed width to prevent overflow
timeElement.style.width = '140px'; // Fixed width to prevent overflow
timesContainer.appendChild(timeElement);
});
cardContent.appendChild(lineNumberElement);
cardContent.appendChild(timesContainer);
directionCard.appendChild(cardContent);
siteContainer.appendChild(directionCard);
} else {
// Create cards for each direction, with max 2 per row
// Create a new line container for every 2 directions
for (let i = 0; i < directions.length; i += 2) {
// Create a new line container for this pair of directions
const rowContainer = document.createElement('div');
rowContainer.className = 'line-container';
// Process up to 2 directions for this row
for (let j = i; j < i + 2 && j < directions.length; j++) {
const directionGroup = directions[j];
// Sort departures by time
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Create a card for this direction
const directionCard = document.createElement('div');
directionCard.className = 'departure-card direction-card';
// Create a simplified layout with line number and times on the same row
const cardContent = document.createElement('div');
cardContent.className = 'departure-header';
cardContent.style.display = 'flex';
cardContent.style.justifyContent = 'space-between';
cardContent.style.alignItems = 'center';
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
// Add line number with transport icon and destination
const lineNumberElement = document.createElement('span');
lineNumberElement.className = 'line-number';
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} ${directionGroup.destination}`;
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
timesContainer.style.display = 'flex';
timesContainer.style.flexDirection = 'column';
timesContainer.style.alignItems = 'flex-end';
// Add up to 2 departure times
const maxTimes = 2;
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
const timeElement = document.createElement('div');
timeElement.className = 'time';
timeElement.style.fontSize = '1.1em';
timeElement.style.marginBottom = '2px';
timeElement.style.whiteSpace = 'nowrap';
timeElement.style.textAlign = 'right';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
timeElement.textContent = `${scheduledTime} (${countdownText})`;
timesContainer.appendChild(timeElement);
});
cardContent.appendChild(lineNumberElement);
cardContent.appendChild(timesContainer);
directionCard.appendChild(cardContent);
rowContainer.appendChild(directionCard);
}
// Add this row to the site container
siteContainer.appendChild(rowContainer);
}
}
});
} else if (site.error) {
// Display error for this site
const errorElement = document.createElement('div');
errorElement.className = 'error';
errorElement.textContent = `Error loading departures for ${site.siteName}: ${site.error}`;
siteContainer.appendChild(errorElement);
}
// Add the site container to the main container
departuresContainer.appendChild(siteContainer);
});
}
// Get configuration
function getConfig() {
// Default configuration
const defaultConfig = {
combineSameDirection: true,
sites: [
{
id: '1411',
name: 'Ambassaderna',
enabled: true
}
]
};
// If we have a ConfigManager instance, use its config
if (window.configManager && window.configManager.config) {
return {
combineSameDirection: window.configManager.config.combineSameDirection !== undefined ?
window.configManager.config.combineSameDirection : defaultConfig.combineSameDirection,
sites: window.configManager.config.sites || defaultConfig.sites
};
}
return defaultConfig;
}
// Fetch departures from our proxy server
async function fetchDepartures() {
try {
// Don't show loading status to avoid layout disruptions
// statusElement.textContent = 'Loading departures...';
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.sites && Array.isArray(data.sites)) {
// Process multiple sites
displayMultipleSites(data.sites);
const now = new Date();
lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString('sv-SE')}`;
} else if (data.departures) {
// Legacy format - single site
displayDepartures(data.departures);
const now = new Date();
lastUpdatedElement.textContent = `Last updated: ${now.toLocaleTimeString('sv-SE')}`;
} else if (data.error) {
throw new Error(data.error);
} else {
throw new Error('Invalid response format from server');
}
} catch (error) {
console.error('Error fetching departures:', error);
// Don't update status element to avoid layout disruptions
// statusElement.textContent = '';
departuresContainer.innerHTML = `
Failed to load departures. Please try again later.
Error: ${error.message}
Make sure the Node.js server is running: node server.js
`;
}
}
// Set up auto-refresh
function setupAutoRefresh() {
// Clear any existing timer
if (refreshTimer) {
clearInterval(refreshTimer);
}
// Set up new timer
refreshTimer = setInterval(fetchDepartures, REFRESH_INTERVAL);
}
// Initialize departures functionality
function initDepartures() {
// API endpoint (using our local proxy server)
window.API_URL = 'http://localhost:3002/api/departures';
// DOM elements
window.departuresContainer = document.getElementById('departures');
window.statusElement = document.getElementById('status');
window.lastUpdatedElement = document.getElementById('last-updated');
// Auto-refresh interval (in milliseconds) - 5 seconds
window.REFRESH_INTERVAL = 5000;
window.refreshTimer = null;
// Initial fetch and setup
fetchDepartures();
setupAutoRefresh();
}
// Initialize when the DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
initDepartures();
});