Optimize landscape layout: 4-column grid, transport icons, improved sizing and spacing

This commit is contained in:
2025-12-31 16:27:55 +01:00
parent a0c997f7d4
commit 738a422dc9
14 changed files with 2173 additions and 1629 deletions

View File

@@ -1,8 +1,14 @@
// Calculate minutes until arrival
function calculateMinutesUntilArrival(scheduledTime) {
// Calculate minutes until arrival using expected time (or scheduled if expected not available)
function calculateMinutesUntilArrival(departure) {
const now = new Date();
const scheduled = new Date(scheduledTime);
return Math.round((scheduled - now) / (1000 * 60));
// Use expected time if available (accounts for delays), otherwise use scheduled
const arrivalTime = departure.expected ? new Date(departure.expected) : new Date(departure.scheduled);
return Math.round((arrivalTime - now) / (1000 * 60));
}
// Get the time to display (expected if available, otherwise scheduled)
function getDepartureTime(departure) {
return departure.expected || departure.scheduled;
}
// Get transport icon based on transport mode
@@ -33,31 +39,46 @@ function createDepartureCard(departure) {
departureCard.dataset.journeyId = departure.journey.id;
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
const departureTime = getDepartureTime(departure);
const timeDisplay = formatDateTime(departureTime);
// Check if departure is within the next hour
const departureTime = new Date(departure.scheduled);
const departureTimeDate = new Date(departureTime);
const now = new Date();
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
const diffMinutes = Math.round((departureTimeDate - now) / (1000 * 60));
const isWithinNextHour = diffMinutes <= 60;
// Add condensed class if within next hour
departureCard.className = isWithinNextHour ? 'departure-card condensed' : 'departure-card';
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
// Calculate minutes until arrival using expected time (accounts for delays)
const minutesUntil = calculateMinutesUntilArrival(departure);
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
let countdownClass = '';
// Determine color class based on minutesUntil, regardless of displayTime format
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
countdownText = 'Nu';
countdownClass = 'now';
} else if (minutesUntil < 5) {
// Less than 5 minutes - red
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} else {
countdownText = `${minutesUntil} min`;
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
}
countdownClass = 'urgent'; // Red: less than 5 minutes
} else {
// 5+ minutes - white
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
if (isTimeOnly) {
countdownText = `${minutesUntil} min`;
} else {
// Use displayTime as-is (e.g., "5 min", "8 min")
countdownText = displayTime;
}
// No class = white (default)
}
// Get transport icon based on transport mode and line
@@ -72,8 +93,8 @@ function createDepartureCard(departure) {
<span class="line-destination">${departure.destination}</span>
</span>
<span class="time">
<span class="arrival-time">${scheduledTime}</span>
<span class="countdown">(${countdownText})</span>
<span class="arrival-time">${timeDisplay}</span>
<span class="countdown ${countdownClass}">(${countdownText})</span>
</span>
</div>
`;
@@ -81,92 +102,156 @@ function createDepartureCard(departure) {
return departureCard;
}
// Display departures grouped by line number
// Display departures grouped by line number - New card-based layout
function displayGroupedDeparturesByLine(groups, container) {
groups.forEach(group => {
// Create a card for this line number
const groupCard = document.createElement('div');
groupCard.className = 'departure-card line-card';
// Create card header
const header = document.createElement('div');
header.className = 'departure-header';
// Get transport mode for styling - ensure we use the API value
const apiTransportMode = group.line?.transportMode || '';
const transportMode = apiTransportMode.toLowerCase();
// Get transport icon based on transport mode and line
// Create large line number box on the left
const lineNumberBox = document.createElement('div');
lineNumberBox.className = `line-number-box ${transportMode}`;
// Get transport icon instead of text label
const transportIcon = getTransportIcon(group.line?.transportMode, group.line);
// Add line number with transport icon
const lineNumber = document.createElement('span');
lineNumber.className = 'line-number';
lineNumberBox.innerHTML = `
<div class="transport-mode-icon">${transportIcon}</div>
<div class="line-number-large">${group.lineNumber}</div>
`;
groupCard.appendChild(lineNumberBox);
// Use the first destination as the main one for the header
const mainDestination = group.directions[0]?.destination || '';
// Create directions wrapper on the right
const directionsWrapper = document.createElement('div');
directionsWrapper.className = 'directions-wrapper';
lineNumber.innerHTML = `${transportIcon} ${group.lineNumber} <span class="line-destination">${mainDestination}</span>`;
header.appendChild(lineNumber);
groupCard.appendChild(header);
// Create the directions container
const directionsContainer = document.createElement('div');
directionsContainer.className = 'directions-container';
// Process each direction
group.directions.forEach(direction => {
// Sort departures by time
direction.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Process each direction (up to 2 directions side-by-side)
const maxDirections = 2;
group.directions.slice(0, maxDirections).forEach(direction => {
// Sort departures by expected time (or scheduled if expected not available)
direction.departures.sort((a, b) => {
const timeA = getDepartureTime(a);
const timeB = getDepartureTime(b);
return new Date(timeA) - new Date(timeB);
});
// Create a row for this direction
const directionRow = document.createElement('div');
directionRow.className = 'direction-row';
// Add direction info
// Add direction info (arrow + destination)
const directionInfo = document.createElement('div');
directionInfo.className = 'direction-info';
// Determine direction arrow
const directionArrow = direction.direction === 1 ? '→' : '←';
// Determine direction arrow and styling from API data
// Get direction from the first departure in this direction group
const firstDep = direction.departures[0];
if (!firstDep) return; // Skip if no departures
directionInfo.innerHTML = `<span class="direction-arrow">${directionArrow}</span> <span class="direction-destination">${direction.destination}</span>`;
// Use direction_code from API: 1 = going TO that direction, 2 = going FROM that direction
// For arrows: direction_code 1 = left arrow, direction_code 2 = right arrow
const directionCode = firstDep.direction_code !== undefined ? firstDep.direction_code :
firstDep.directionCode !== undefined ? firstDep.directionCode :
null;
// Map direction_code to arrow direction
// direction_code 1 = left arrow (←), direction_code 2 = right arrow (→)
const isRight = directionCode === 2;
if (directionCode === null || directionCode === undefined) {
console.warn('No direction_code found for:', direction.destination, firstDep);
}
const arrowBox = document.createElement('div');
arrowBox.className = `direction-arrow-box ${isRight ? 'right' : 'left'}`;
arrowBox.textContent = isRight ? '→' : '←';
const destinationSpan = document.createElement('span');
destinationSpan.className = 'direction-destination';
destinationSpan.textContent = direction.destination;
directionInfo.appendChild(arrowBox);
directionInfo.appendChild(destinationSpan);
directionRow.appendChild(directionInfo);
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
// Add up to 2 departure times per direction
const maxTimes = 2;
direction.departures.slice(0, maxTimes).forEach(departure => {
const timeElement = document.createElement('span');
timeElement.className = 'time';
// Get first two departures for time range
const firstDeparture = direction.departures[0];
const secondDeparture = direction.departures[1];
if (firstDeparture) {
const displayTime = firstDeparture.display;
const departureTime = getDepartureTime(firstDeparture);
const timeDisplay = formatDateTime(departureTime);
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
// Calculate minutes until arrival using expected time (accounts for delays)
const minutesUntil = calculateMinutesUntilArrival(firstDeparture);
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
let countdownClass = '';
// Determine color class based on minutesUntil, regardless of displayTime format
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
countdownText = 'Nu';
countdownClass = 'now';
} else if (minutesUntil < 5) {
// Use the number from displayTime if it's "X min", otherwise use calculated minutesUntil
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = `${minMatch[1]}`;
} else {
countdownText = `${minutesUntil} min`;
countdownText = `${minutesUntil}`;
}
countdownClass = 'urgent'; // Red: less than 5 minutes
} else {
// 5+ minutes - use displayTime as-is or calculate
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = `${minMatch[1]}`;
} else if (/^\d{1,2}:\d{2}$/.test(displayTime)) {
countdownText = `${minutesUntil}`;
} else {
countdownText = displayTime;
}
// No class = white (default)
}
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${countdownText})</span>`;
timesContainer.appendChild(timeElement);
});
// Create time display element
const timeDisplayElement = document.createElement('div');
timeDisplayElement.className = 'time-display';
const countdownSpan = document.createElement('span');
countdownSpan.className = `countdown-large ${countdownClass}`;
countdownSpan.textContent = countdownText;
timeDisplayElement.appendChild(countdownSpan);
// Add time range (show expected times)
const timeRangeSpan = document.createElement('span');
timeRangeSpan.className = 'time-range';
if (secondDeparture) {
const secondTime = formatDateTime(getDepartureTime(secondDeparture));
timeRangeSpan.textContent = `${timeDisplay} - ${secondTime}`;
} else {
timeRangeSpan.textContent = timeDisplay;
}
timeDisplayElement.appendChild(timeRangeSpan);
timesContainer.appendChild(timeDisplayElement);
}
directionRow.appendChild(timesContainer);
directionsContainer.appendChild(directionRow);
directionsWrapper.appendChild(directionRow);
});
groupCard.appendChild(directionsContainer);
groupCard.appendChild(directionsWrapper);
// Add to container
container.appendChild(groupCard);
@@ -176,8 +261,12 @@ function displayGroupedDeparturesByLine(groups, container) {
// Display grouped departures (legacy function)
function displayGroupedDepartures(groups, container) {
groups.forEach(group => {
// Sort departures by time
group.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Sort departures by expected time (or scheduled if expected not available)
group.departures.sort((a, b) => {
const timeA = getDepartureTime(a);
const timeB = getDepartureTime(b);
return new Date(timeA) - new Date(timeB);
});
// Create a card for this group
const groupCard = document.createElement('div');
@@ -212,29 +301,40 @@ function displayGroupedDepartures(groups, container) {
timeElement.style.marginBottom = '2px';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
const departureTime = getDepartureTime(departure);
const timeDisplay = formatDateTime(departureTime);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
// Calculate minutes until arrival using expected time (accounts for delays)
const minutesUntil = calculateMinutesUntilArrival(departure);
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
let countdownClass = '';
// Determine color class based on minutesUntil, regardless of displayTime format
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
countdownText = 'Nu';
countdownClass = 'now';
} else if (minutesUntil < 5) {
// Less than 5 minutes - red
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} else {
countdownText = `${minutesUntil} min`;
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
}
countdownClass = 'urgent'; // Red: less than 5 minutes
} else {
// 5+ minutes - white
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
if (isTimeOnly) {
countdownText = `${minutesUntil} min`;
} else {
// Use displayTime as-is (e.g., "5 min", "8 min")
countdownText = displayTime;
}
// No class = white (default)
}
if (isTimeOnly) {
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${countdownText})</span>`;
} else {
timeElement.innerHTML = `${scheduledTime} <span class="countdown">(${displayTime})</span>`;
}
timeElement.innerHTML = `${scheduledTime} <span class="countdown ${countdownClass}">(${countdownText})</span>`;
timesContainer.appendChild(timeElement);
});
@@ -271,7 +371,7 @@ function formatRelativeTime(dateTimeString) {
const diffMinutes = Math.round((departureTime - now) / (1000 * 60));
if (diffMinutes <= 0) {
return 'Now';
return 'Nu';
} else if (diffMinutes === 1) {
return 'In 1 minute';
} else if (diffMinutes < 60) {
@@ -301,11 +401,17 @@ function groupDeparturesByLineNumber(departures) {
};
}
const directionKey = `${departure.direction}-${departure.destination}`;
// Get direction_code from API: 1 = going TO that direction, 2 = going FROM that direction
const departureDirection = departure.direction_code !== undefined ? departure.direction_code :
departure.directionCode !== undefined ? departure.directionCode :
departure.direction !== undefined ? departure.direction :
1; // Default to 1 (left arrow) if not found
const directionKey = `${departureDirection}-${departure.destination}`;
if (!groups[lineNumber].directions[directionKey]) {
groups[lineNumber].directions[directionKey] = {
direction: departure.direction,
direction: departureDirection,
destination: departure.destination,
departures: []
};
@@ -433,31 +539,58 @@ function updateExistingCards(newDepartures) {
// Update only the content that has changed in an existing card
function updateCardContent(card, departure) {
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
const departureTime = getDepartureTime(departure);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
// Calculate minutes until arrival using expected time (accounts for delays)
const minutesUntil = calculateMinutesUntilArrival(departure);
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
let countdownClass = '';
// Determine color class based on minutesUntil, regardless of displayTime format
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
countdownText = 'Nu';
countdownClass = 'now';
} else if (minutesUntil < 5) {
// Less than 5 minutes - red
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} else {
countdownText = `${minutesUntil} min`;
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
}
countdownClass = 'urgent'; // Red: less than 5 minutes
} else {
// 5+ minutes - white
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
if (isTimeOnly) {
countdownText = `${minutesUntil} min`;
} else {
// Use displayTime as-is (e.g., "5 min", "8 min")
countdownText = displayTime;
}
// No class = white (default)
}
// Only update the countdown time which changes frequently
const countdownElement = card.querySelector('.countdown');
// Update with subtle highlight effect for changes
if (countdownElement && countdownElement.textContent !== `(${countdownText})`) {
countdownElement.textContent = `(${countdownText})`;
highlightElement(countdownElement);
// 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);
}
}
}
@@ -499,202 +632,11 @@ function displayMultipleSites(sites) {
// Process departures for this site
if (site.data && site.data.departures) {
// Group departures by line number
const lineGroups = {};
// Group departures by line number using the existing function
const lineGroups = groupDeparturesByLineNumber(site.data.departures);
site.data.departures.forEach(departure => {
const lineNumber = departure.line.designation;
if (!lineGroups[lineNumber]) {
lineGroups[lineNumber] = [];
}
lineGroups[lineNumber].push(departure);
});
// Process each line group
Object.entries(lineGroups).forEach(([lineNumber, lineDepartures]) => {
// Create a line container for side-by-side display
const lineContainer = document.createElement('div');
lineContainer.className = 'line-container';
// Group by direction
const directionGroups = {};
lineDepartures.forEach(departure => {
const directionKey = `${departure.direction}-${departure.destination}`;
if (!directionGroups[directionKey]) {
directionGroups[directionKey] = {
direction: departure.direction,
destination: departure.destination,
departures: []
};
}
directionGroups[directionKey].departures.push(departure);
});
// Get all direction groups
const directions = Object.values(directionGroups);
// Handle single direction case (like bus 4)
if (directions.length === 1) {
// Create a full-width card for this direction
const directionGroup = directions[0];
// Sort departures by time
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Create a card for this direction
const directionCard = document.createElement('div');
directionCard.className = 'departure-card';
// Don't set width to 100% as it causes the card to stick out
// Create a simplified layout with line number and times on the same row
const cardContent = document.createElement('div');
cardContent.className = 'departure-header';
cardContent.style.display = 'flex';
cardContent.style.justifyContent = 'space-between';
cardContent.style.alignItems = 'center';
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
// Add line number with transport icon and destination
const lineNumberElement = document.createElement('span');
lineNumberElement.className = 'line-number';
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} <span class="line-destination">${directionGroup.destination}</span>`;
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
timesContainer.style.display = 'flex';
timesContainer.style.flexDirection = 'column';
timesContainer.style.alignItems = 'flex-end';
// Add up to 2 departure times
const maxTimes = 2;
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
const timeElement = document.createElement('div');
timeElement.className = 'time';
timeElement.style.fontSize = '1.1em';
timeElement.style.marginBottom = '2px';
timeElement.style.whiteSpace = 'nowrap';
timeElement.style.textAlign = 'right';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
timeElement.textContent = `${scheduledTime} (${countdownText})`;
timeElement.style.width = '140px'; // Fixed width to prevent overflow
timeElement.style.width = '140px'; // Fixed width to prevent overflow
timesContainer.appendChild(timeElement);
});
cardContent.appendChild(lineNumberElement);
cardContent.appendChild(timesContainer);
directionCard.appendChild(cardContent);
siteContainer.appendChild(directionCard);
} else {
// Create cards for each direction, with max 2 per row
// Create a new line container for every 2 directions
for (let i = 0; i < directions.length; i += 2) {
// Create a new line container for this pair of directions
const rowContainer = document.createElement('div');
rowContainer.className = 'line-container';
// Process up to 2 directions for this row
for (let j = i; j < i + 2 && j < directions.length; j++) {
const directionGroup = directions[j];
// Sort departures by time
directionGroup.departures.sort((a, b) => new Date(a.scheduled) - new Date(b.scheduled));
// Create a card for this direction
const directionCard = document.createElement('div');
directionCard.className = 'departure-card direction-card';
// Create a simplified layout with line number and times on the same row
const cardContent = document.createElement('div');
cardContent.className = 'departure-header';
cardContent.style.display = 'flex';
cardContent.style.justifyContent = 'space-between';
cardContent.style.alignItems = 'center';
// Get transport icon based on transport mode and line
const transportIcon = getTransportIcon(directionGroup.departures[0].line?.transportMode, directionGroup.departures[0].line);
// Add line number with transport icon and destination
const lineNumberElement = document.createElement('span');
lineNumberElement.className = 'line-number';
lineNumberElement.innerHTML = `${transportIcon} ${lineNumber} <span class="line-destination">${directionGroup.destination}</span>`;
// Add times container
const timesContainer = document.createElement('div');
timesContainer.className = 'times-container';
timesContainer.style.display = 'flex';
timesContainer.style.flexDirection = 'column';
timesContainer.style.alignItems = 'flex-end';
// Add up to 2 departure times
const maxTimes = 2;
directionGroup.departures.slice(0, maxTimes).forEach(departure => {
const timeElement = document.createElement('div');
timeElement.className = 'time';
timeElement.style.fontSize = '1.1em';
timeElement.style.marginBottom = '2px';
timeElement.style.whiteSpace = 'nowrap';
timeElement.style.textAlign = 'right';
const displayTime = departure.display;
const scheduledTime = formatDateTime(departure.scheduled);
// Check if the display time is just a time (HH:MM) or a countdown
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
// If it's just a time, calculate minutes until arrival
let countdownText = displayTime;
if (isTimeOnly) {
const minutesUntil = calculateMinutesUntilArrival(departure.scheduled);
if (minutesUntil <= 0) {
countdownText = 'Now';
} else if (minutesUntil === 1) {
countdownText = '1 min';
} else {
countdownText = `${minutesUntil} min`;
}
}
timeElement.textContent = `${scheduledTime} (${countdownText})`;
timesContainer.appendChild(timeElement);
});
cardContent.appendChild(lineNumberElement);
cardContent.appendChild(timesContainer);
directionCard.appendChild(cardContent);
rowContainer.appendChild(directionCard);
}
// Add this row to the site container
siteContainer.appendChild(rowContainer);
}
}
});
// Use the new card-based layout function
displayGroupedDeparturesByLine(lineGroups, siteContainer);
} else if (site.error) {
// Display error for this site
const errorElement = document.createElement('div');