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

Failed to load departures. Please try again later.

Error: ${error.message}

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

`; } } // Set up auto-refresh function setupAutoRefresh() { // Clear any existing timer if (refreshTimer) { clearInterval(refreshTimer); } // Set up new timer refreshTimer = setInterval(fetchDepartures, REFRESH_INTERVAL); } // Initialize departures functionality function initDepartures() { // API endpoint (using our local proxy server) window.API_URL = 'http://localhost:3002/api/departures'; // DOM elements window.departuresContainer = document.getElementById('departures'); window.statusElement = document.getElementById('status'); window.lastUpdatedElement = document.getElementById('last-updated'); // Auto-refresh interval (in milliseconds) - 5 seconds window.REFRESH_INTERVAL = 5000; window.refreshTimer = null; // Initial fetch and setup fetchDepartures(); setupAutoRefresh(); } // Initialize when the DOM is loaded document.addEventListener('DOMContentLoaded', function() { initDepartures(); });