Landscape kiosk overhaul: 3-column layout, resilient updates, visual polish

- Add 3-column balanced site distribution using greedy weight algorithm
- Build new DOM off-screen in DocumentFragment, swap atomically (no flash)
- Skip empty API responses and preserve display on transient errors
- Remove news ticker from UI and grid layout
- Add blue-to-red gradient on site header bars
- Bump font sizes: destinations 1.4em, countdowns 1.5em, line numbers 1.6em
- Add breathing pulse animation on daylight bar sun/moon icons
- Fix daylight bar indicator snapping to position on first render
- Make config button visible in landscape with semi-transparent background
- Add weather forecast strip as grid row 4 with compact styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:07:10 +01:00
parent 5f60ed88c8
commit 57cd9809e0
5 changed files with 222 additions and 98 deletions

View File

@@ -507,33 +507,81 @@ class DeparturesManager {
displayMultipleSites(sites) {
const config = this.getConfig();
const enabledSites = config.sites.filter(site => site.enabled);
this.container.innerHTML = '';
// Build new content off-DOM first, then swap in one operation
const fragment = document.createDocumentFragment();
// Build site containers
const siteElements = [];
sites.forEach(site => {
const siteConfig = enabledSites.find(s => s.id === site.siteId);
if (!siteConfig) return;
// Skip sites that returned empty departures (API hiccup)
// but keep sites with explicit errors so user sees feedback
if (site.data && site.data.departures && site.data.departures.length === 0 && !site.error) {
return;
}
const siteContainer = document.createElement('div');
siteContainer.className = 'site-container';
const siteHeader = document.createElement('div');
siteHeader.className = 'site-header';
siteHeader.innerHTML = `<span class="site-name">${site.siteName || siteConfig.name}</span>`;
const siteName = document.createElement('span');
siteName.className = 'site-name';
siteName.textContent = site.siteName || siteConfig.name;
siteHeader.appendChild(siteName);
siteContainer.appendChild(siteHeader);
if (site.data && site.data.departures) {
let cardCount = 0;
if (site.data && site.data.departures && site.data.departures.length > 0) {
const lineGroups = this.groupDeparturesByLineNumber(site.data.departures);
this.displayGroupedDeparturesByLine(lineGroups, siteContainer);
cardCount = Object.keys(lineGroups).length;
} else if (site.error) {
const errorElement = document.createElement('div');
errorElement.className = 'error';
errorElement.textContent = `Error loading departures for ${site.siteName}: ${site.error}`;
siteContainer.appendChild(errorElement);
cardCount = 1;
}
this.container.appendChild(siteContainer);
siteElements.push({ element: siteContainer, weight: cardCount + 1 });
});
// If no sites have data at all, keep existing display (don't flash empty)
if (siteElements.length === 0) return;
// In landscape mode, distribute sites into balanced columns
if (document.body.classList.contains('landscape') && siteElements.length > 1) {
const numCols = Math.min(3, siteElements.length);
const columns = [];
const weights = [];
for (let i = 0; i < numCols; i++) {
const col = document.createElement('div');
col.className = 'departure-column';
columns.push(col);
weights.push(0);
}
// Greedy: assign each site to the lightest column
siteElements.forEach(({ element, weight }) => {
const minIdx = weights.indexOf(Math.min(...weights));
columns[minIdx].appendChild(element);
weights[minIdx] += weight;
});
columns.forEach(col => fragment.appendChild(col));
} else {
siteElements.forEach(({ element }) => {
fragment.appendChild(element);
});
}
// Swap old content for new in one operation (no flash)
this.container.textContent = '';
this.container.appendChild(fragment);
}
/**
@@ -576,14 +624,15 @@ class DeparturesManager {
} else {
console.error('Error fetching departures:', error);
}
this.container.innerHTML = `
<div class="error">
<p>Failed to load departures. Please try again later.</p>
<p>Error: ${error.message}</p>
<p>Make sure the Node.js server is running: <code>node server.js</code></p>
</div>
`;
// On transient errors, keep existing data on screen.
// Only show error if we have no data at all yet.
if (!this.container.children.length) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = `Failed to load departures: ${error.message}`;
this.container.appendChild(errorDiv);
}
}
}