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:
@@ -56,6 +56,12 @@ body.dark-mode .clock-container {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.landscape .config-button {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
opacity: 0.7;
|
||||
bottom: 50px;
|
||||
}
|
||||
|
||||
/* Configuration modal styles */
|
||||
.config-modal {
|
||||
display: none;
|
||||
@@ -1046,16 +1052,16 @@ body.landscape .site-header {
|
||||
|
||||
body.landscape .site-name {
|
||||
display: block;
|
||||
background: rgba(0, 97, 161, 0.3);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
background: linear-gradient(90deg, rgba(0, 97, 161, 0.45), rgba(200, 30, 50, 0.45));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
padding: 4px 16px;
|
||||
padding: 5px 16px;
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
font-size: 0.8em;
|
||||
border-left: 3px solid var(--color-primary-light);
|
||||
font-size: 0.95em;
|
||||
border-left: 4px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* Compact weather bar - hidden by default, shown in landscape */
|
||||
@@ -1092,13 +1098,7 @@ body.landscape #compact-weather-bar {
|
||||
}
|
||||
|
||||
body.landscape #news-ticker {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--ticker-bg);
|
||||
height: var(--ticker-height);
|
||||
line-height: var(--ticker-height);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#news-ticker .ticker-content {
|
||||
@@ -1155,13 +1155,28 @@ body.landscape #news-ticker {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#daylight-hours-bar .sun-icon {
|
||||
#daylight-hours-bar .sun-icon,
|
||||
#daylight-hours-bar .moon-icon {
|
||||
font-size: 18px;
|
||||
display: block;
|
||||
animation: celestial-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#daylight-hours-bar .sun-icon {
|
||||
filter: drop-shadow(0 0 4px rgba(255, 215, 0, 0.8));
|
||||
text-shadow: 0 0 8px rgba(255, 215, 0, 0.6);
|
||||
}
|
||||
|
||||
#daylight-hours-bar .moon-icon {
|
||||
filter: drop-shadow(0 0 4px rgba(180, 200, 255, 0.8));
|
||||
text-shadow: 0 0 8px rgba(180, 200, 255, 0.6);
|
||||
}
|
||||
|
||||
@keyframes celestial-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||
50% { transform: scale(1.3); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Landscape: daylight bar sits in grid instead of fixed overlay */
|
||||
body.landscape #daylight-hours-bar {
|
||||
position: relative;
|
||||
|
||||
@@ -101,7 +101,7 @@ body {
|
||||
|
||||
body.normal .departure-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
@@ -128,17 +128,6 @@ body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body.normal .direction-destination {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
body.normal .countdown-large {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
body.normal .next-departures {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@@ -185,7 +174,7 @@ body.landscape #background-overlay {
|
||||
body.landscape #content-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto auto;
|
||||
gap: var(--kiosk-gap);
|
||||
gap: 4px;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
@@ -196,9 +185,11 @@ body.landscape .clock-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Compact weather bar sits in row 2 */
|
||||
/* Compact weather bar in row 2 */
|
||||
body.landscape #compact-weather-bar {
|
||||
grid-row: 2;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 16px;
|
||||
}
|
||||
|
||||
body.landscape .main-content-grid {
|
||||
@@ -208,15 +199,62 @@ body.landscape .main-content-grid {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Hide the full weather widget in landscape */
|
||||
/* Weather forecast strip at bottom - row 4 */
|
||||
body.landscape .weather-section {
|
||||
grid-row: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.landscape .weather-container {
|
||||
overflow: hidden;
|
||||
max-height: none;
|
||||
position: static;
|
||||
}
|
||||
|
||||
body.landscape #custom-weather {
|
||||
background: var(--color-bar-bg);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
body.landscape #custom-weather .current-weather {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.landscape #custom-weather .forecast {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.landscape #custom-weather .forecast-hour {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
body.landscape #custom-weather .forecast-hour .time {
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
body.landscape #custom-weather .forecast-hour .icon img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
body.landscape #custom-weather .forecast-hour .temp {
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
body.landscape #custom-weather .attribution {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.landscape .departure-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--kiosk-gap);
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
@@ -224,49 +262,52 @@ body.landscape .departure-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body.landscape .weather-container {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 100%;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
body.landscape .departure-card {
|
||||
min-height: 80px;
|
||||
background-color: var(--color-surface-kiosk);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
body.landscape .line-number-box {
|
||||
min-width: 110px;
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
body.landscape .line-number-large {
|
||||
font-size: 3em;
|
||||
body.landscape .departure-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
body.landscape .site-container {
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
body.landscape .departure-card {
|
||||
min-height: 0;
|
||||
background-color: var(--color-surface-kiosk);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
body.landscape .line-number-box {
|
||||
min-width: 48px;
|
||||
width: 48px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
body.landscape .line-number-large {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
body.landscape .transport-mode-icon .transport-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
body.landscape .site-container {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
body.landscape .site-header {
|
||||
font-size: 1em;
|
||||
font-size: 0.8em;
|
||||
padding: 0;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* News ticker sits in row 4 */
|
||||
body.landscape #news-ticker {
|
||||
grid-row: 4;
|
||||
}
|
||||
|
||||
/* Daylight bar sits in row 5 */
|
||||
/* Daylight bar in row 5 */
|
||||
body.landscape #daylight-hours-bar {
|
||||
grid-row: 5;
|
||||
}
|
||||
@@ -274,7 +315,6 @@ body.landscape #daylight-hours-bar {
|
||||
/* Dark card surfaces for landscape */
|
||||
body.landscape .direction-destination {
|
||||
color: var(--color-text-light);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
body.landscape .countdown-large {
|
||||
@@ -285,32 +325,43 @@ body.landscape .next-departures {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Tighter card spacing in landscape */
|
||||
/* Compact card spacing in landscape */
|
||||
body.landscape .directions-wrapper {
|
||||
padding: 4px 8px;
|
||||
gap: 2px;
|
||||
padding: 3px 6px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
body.landscape .direction-row {
|
||||
min-height: 28px;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Hero countdown in landscape */
|
||||
body.landscape .direction-destination {
|
||||
font-size: 1.4em;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
body.landscape .direction-arrow-box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Countdown in landscape */
|
||||
body.landscape .countdown-large {
|
||||
font-size: 2.5em;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
body.landscape .times-container {
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
min-width: 90px;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
body.landscape .next-departures {
|
||||
font-size: 1em;
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 1px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -714,7 +714,17 @@ class WeatherManager {
|
||||
}
|
||||
|
||||
// Position current hour indicator
|
||||
indicatorElement.style.left = `${currentPos}%`;
|
||||
// On first render, skip transition so icon appears instantly at correct position
|
||||
if (!this._daylightBarInitialized) {
|
||||
indicatorElement.style.transition = 'none';
|
||||
indicatorElement.style.left = `${currentPos}%`;
|
||||
// Force reflow, then re-enable transition for smooth minute-to-minute movement
|
||||
indicatorElement.offsetLeft;
|
||||
indicatorElement.style.transition = '';
|
||||
this._daylightBarInitialized = true;
|
||||
} else {
|
||||
indicatorElement.style.left = `${currentPos}%`;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('Daylight bar positions:', {
|
||||
|
||||
@@ -78,8 +78,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
lastUpdatedId: 'last-updated'
|
||||
});
|
||||
|
||||
// Initialize NewsTicker (visible in landscape mode only via CSS)
|
||||
window.newsTicker = new NewsTicker();
|
||||
// NewsTicker disabled - ticker removed from UI
|
||||
|
||||
// Set up event listeners
|
||||
document.addEventListener('darkModeChanged', event => {
|
||||
|
||||
Reference in New Issue
Block a user