// 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 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 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 = `
${transportIcon} ${departure.line.designation} ${departure.destination} ${timeDisplay} (${countdownText})
`; 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 = `
${transportIcon}
${group.lineNumber}
`; 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} ${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 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} (${countdownText})`; 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 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 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 = `${site.siteName || siteConfig.name}`; 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 = `

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(); });