From cdfd32dc69192aa940c7aaa2dd066d459d715b49 Mon Sep 17 00:00:00 2001 From: kyle Date: Sun, 15 Feb 2026 14:39:45 +0100 Subject: [PATCH] Items 16-22: Icon classification, accessibility, responsive design, API params - Consolidate 5 weather icon methods into classifyWeatherIcon/applyWeatherIconClasses - Add focus-visible styles, ARIA attributes, keyboard nav on config button/modal - Add responsive breakpoints for departure cards, weather widget, config modal - Simplify CSS selectors: replace :not(:is(...)) chains with body.normal - Fix upsidedown layout margin assumption with transform-based centering - Upgrade weather icons from @2x to @4x for high-DPI displays - Add forecast window and transport filter params to SL departures API Co-Authored-By: Claude Opus 4.6 --- index.html | 2 +- public/css/components.css | 266 ++++++++++ public/css/main.css | 703 +++++++++++++------------ public/js/components/ConfigManager.js | 25 +- public/js/components/WeatherManager.js | 95 ++-- server/routes/departures.js | 28 +- 6 files changed, 701 insertions(+), 418 deletions(-) diff --git a/index.html b/index.html index c1ae1b3..f4ad34c 100644 --- a/index.html +++ b/index.html @@ -133,7 +133,7 @@

Settings

- × + ×
diff --git a/public/css/components.css b/public/css/components.css index 4c68dfb..8044405 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -1291,6 +1291,272 @@ body.dark-mode .config-file-label { margin-top: 10px; } +/* ======================================== + Accessibility - Focus styles + ======================================== */ +.config-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + opacity: 1; + background-color: var(--color-primary); +} + +.config-tab:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +body.dark-mode .config-tab:focus-visible { + outline-color: var(--color-accent); +} + +.config-modal-close:focus-visible { + outline: 2px solid var(--color-surface); + outline-offset: 2px; +} + +.config-modal-footer button:focus-visible, +.config-option button:focus-visible, +#search-site-button:focus-visible, +#select-from-map-button:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +body.dark-mode .config-modal-footer button:focus-visible, +body.dark-mode .config-option button:focus-visible { + outline-color: var(--color-accent); +} + +.config-option select:focus-visible, +.config-option input[type="text"]:focus-visible, +.config-search-input:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -1px; + border-color: var(--color-primary); +} + +body.dark-mode .config-option select:focus-visible, +body.dark-mode .config-option input[type="text"]:focus-visible, +body.dark-mode .config-search-input:focus-visible { + outline-color: var(--color-accent); + border-color: var(--color-accent); +} + +/* Accessibility - Color contrast improvements */ +#custom-weather .sun-times { + color: #ccc; +} + +.site-search-result div:last-child { + color: #777; +} + +body.dark-mode .site-search-result div:last-child { + color: #bbb; +} + +body.dark-mode .time-range { + color: #bbb; +} + +/* ======================================== + Responsive Design - Departure cards + ======================================== */ +@media (max-width: 768px) { + .departure-card { + min-height: 60px; + } + + .line-number-box { + min-width: 42px; + width: 42px; + } + + .line-number-large { + font-size: 1.3em; + } + + .direction-destination { + font-size: 0.9em; + } + + .countdown-large { + font-size: 1.1em; + } + + .times-container { + min-width: 90px; + max-width: 90px; + } + + .time-display { + font-size: 0.85em; + } + + .direction-arrow-box { + width: 26px; + height: 26px; + font-size: 1.1em; + } + + /* Weather responsive */ + #custom-weather .current-weather { + flex-direction: column; + gap: 4px; + } + + #custom-weather .location-info { + margin-right: 0; + } + + #custom-weather .temperature { + font-size: 1.3em; + } + + #custom-weather .forecast-hour { + min-width: 44px; + height: 80px; + padding: 4px 3px; + } + + #custom-weather .forecast-hour .icon img { + width: 32px; + height: 32px; + } + + /* Config modal responsive */ + .config-modal-content { + width: 95%; + max-height: 95vh; + } + + .config-modal-body { + max-height: calc(95vh - 180px); + } + + .config-tabs { + flex-wrap: wrap; + } + + .config-tab { + padding: 10px 12px; + font-size: 0.85em; + } +} + +@media (max-width: 480px) { + .departure-card { + min-height: 50px; + } + + .line-number-box { + min-width: 36px; + width: 36px; + padding: 2px; + } + + .line-number-large { + font-size: 1.1em; + } + + .transport-mode-icon .transport-icon { + width: 16px; + height: 16px; + } + + .directions-wrapper { + padding: 4px 6px; + gap: 3px; + } + + .direction-destination { + font-size: 0.8em; + } + + .countdown-large { + font-size: 1em; + } + + .times-container { + min-width: 75px; + max-width: 75px; + } + + .time-range { + font-size: 0.75em; + } + + .direction-arrow-box { + width: 22px; + height: 22px; + font-size: 0.9em; + } + + /* Weather responsive */ + #custom-weather { + padding: 6px; + } + + #custom-weather .weather-icon img { + width: 36px; + height: 36px; + } + + #custom-weather .temperature { + font-size: 1.1em; + } + + #custom-weather .forecast-hour { + min-width: 38px; + height: 70px; + margin-right: 4px; + } + + #custom-weather .forecast-hour .time { + font-size: 0.65em; + } + + #custom-weather .forecast-hour .icon img { + width: 28px; + height: 28px; + } + + #custom-weather .forecast-hour .temp { + font-size: 0.7em; + } + + /* Config modal responsive */ + .config-modal-header h2 { + font-size: 1.2em; + } + + .config-modal-header { + padding: 10px 15px; + } + + .config-modal-body { + padding: 15px; + } + + .config-modal-footer { + padding: 10px 15px; + } + + .config-tab { + padding: 8px 8px; + font-size: 0.8em; + } + + .config-flex-row { + flex-wrap: wrap; + } + + .config-flex-row-mb { + flex-wrap: wrap; + } +} + /* Reduced motion preference */ @media (prefers-reduced-motion: reduce) { .countdown-large.urgent, diff --git a/public/css/main.css b/public/css/main.css index dff081f..a22ed13 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1,349 +1,354 @@ -/* Base styles */ - body { - font-family: Arial, sans-serif; - margin: 0 auto; - padding: 20px; - background-color: #f5f5f5; - color: #333; - transition: all 0.5s ease; - } - - /* Auto-apply landscape layout for wide screens */ - @media (min-width: 1200px) { - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) { - max-width: 100%; - padding: 8px 12px 0 12px; /* Minimal padding to maximize space */ - padding-bottom: 0; - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) #content-wrapper { - display: grid; - grid-template-rows: auto 1fr auto; - gap: 8px; /* Reduced gap */ - height: 100vh; - max-height: 100vh; - overflow: hidden; - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .clock-container { - grid-row: 1; - margin-bottom: 0; - padding: 6px 16px; /* Reduced padding */ - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .main-content-grid { - grid-row: 2; - display: block; - overflow-y: auto; - overflow-x: hidden; - min-height: 0; - width: 100%; - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container { - display: grid; - grid-template-columns: repeat(4, 1fr); /* Fixed 4 columns to use all space */ - gap: 6px; /* Minimal gap */ - margin-bottom: 0; - width: 100%; - box-sizing: border-box; - padding: 0; /* Remove any padding */ - } - - /* Ensure each column uses equal space */ - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container > * { - min-width: 0; /* Allow flex shrinking */ - max-width: 100%; /* Prevent overflow */ - } - - /* Weather fixed at bottom */ - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-section { - grid-row: 3; - position: sticky; - bottom: 0; - background-color: inherit; - padding: 8px 0; /* Reduced padding */ - margin-top: 0; - } - - body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-container { - margin: 0; - max-width: 100%; - } - } - - /* Dark mode styles */ - body.dark-mode { - background-color: #222; - color: #f5f5f5; - } - - body.dark-mode .departure-card { - background-color: #333; - border-left-color: #0077cc; - } - - body.dark-mode .config-modal-content { - background-color: #333; - color: #f5f5f5; - } - - body.dark-mode .config-modal-body { - background-color: #333; - } - - body.dark-mode .config-modal-footer { - background-color: #444; - } - - body.dark-mode #config-cancel-button { - background-color: #555; - color: #f5f5f5; - } - - body.dark-mode .time, - body.dark-mode .destination { - color: #f5f5f5; - } - - body.dark-mode .direction, - body.dark-mode .details, - body.dark-mode .countdown, - body.dark-mode .last-updated { - color: #aaa; - } - - body.dark-mode h2 { - color: #0077cc; - } - - body.dark-mode .sun-times { - color: #aaa; - } - - body.dark-mode .line-number { - background-color: #0077cc; - } - - /* Normal orientation */ - body.normal { - max-width: 800px; - } - body.normal .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - } - - /* Landscape orientation - Optimized for wide screens */ - body.landscape { - max-width: 100%; - padding: 20px 40px; - } - - /* Main content area: clock at top, then two-column layout below */ - body.landscape #content-wrapper { - display: grid; - grid-template-rows: auto 1fr; - gap: 20px; - height: 100vh; - max-height: 100vh; - overflow: hidden; - } - - body.landscape .clock-container { - grid-row: 1; - margin-bottom: 0; - } - - /* Main content grid: departures on left, weather on right */ - body.landscape .main-content-grid { - grid-row: 2; - display: grid; - grid-template-columns: 1fr 380px; - gap: 20px; - overflow: hidden; - min-height: 0; - } - - /* Departures container: multi-column grid */ - body.landscape .departure-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); - gap: 15px; - overflow-y: auto; - overflow-x: hidden; - padding-right: 10px; - min-height: 0; - } - - /* Weather container: fixed width, scrollable */ - body.landscape .weather-container { - overflow-y: auto; - overflow-x: hidden; - max-height: 100%; - position: sticky; - top: 0; - align-self: start; - } - - /* Better horizontal space usage in landscape */ - body.landscape .departure-card { - min-height: 120px; - } - - body.landscape .line-number-box { - min-width: 120px; - width: 120px; - } - - body.landscape .line-number-large { - font-size: 3.5em; - } - - /* Site containers in landscape should be more compact */ - body.landscape .site-container { - margin-bottom: 15px; - } - - body.landscape .site-header { - font-size: 1em; - padding: 8px 12px; - } - - /* Vertical orientation (90 degrees rotated) */ - body.vertical { - max-width: 100%; - height: 100vh; - padding: 0; - margin: 0; - overflow: hidden; - display: flex; - justify-content: center; - align-items: center; - } - - body.vertical #content-wrapper { - transform: rotate(90deg); - transform-origin: center center; - position: absolute; - width: 100vh; /* Use viewport height for width */ - height: 100vw; /* Use viewport width for height */ - max-width: 800px; /* Limit width for better readability */ - padding: 20px; - box-sizing: border-box; - overflow-y: auto; - background-color: transparent; /* Remove background color */ - left: 50%; - top: 50%; - margin-left: -50vh; /* Half of width */ - margin-top: -50vw; /* Half of height */ - } - - body.vertical .config-button { - transform: rotate(-90deg); - position: fixed; - right: 10px; - bottom: 10px; /* Changed from top to bottom */ - z-index: 1000; - } - - body.vertical .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - } - - /* Upside down orientation (180 degrees rotated) */ - body.upsidedown { - max-width: 100%; - height: 100vh; - padding: 0; - margin: 0; - overflow: hidden; - display: flex; - justify-content: center; - align-items: center; - } - - body.upsidedown #content-wrapper { - transform: rotate(180deg); - transform-origin: center center; - position: absolute; - width: 100%; - max-width: 800px; - padding: 20px; - box-sizing: border-box; - overflow-y: auto; - background-color: transparent; /* Remove background color */ - left: 50%; - top: 50%; - margin-left: -400px; /* Half of max-width */ - margin-top: -50vh; /* Half of viewport height */ - } - - body.upsidedown .config-button { - transform: rotate(-180deg); - position: fixed; - right: 10px; - bottom: 10px; /* Changed from top to bottom */ - z-index: 1000; - } - - body.upsidedown .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - } - - /* Vertical reverse orientation (270 degrees rotated) */ - body.vertical-reverse { - max-width: 100%; - height: 100vh; - padding: 0; - margin: 0; - overflow: hidden; - display: flex; - justify-content: center; - align-items: center; - } - - body.vertical-reverse #content-wrapper { - transform: rotate(270deg); - transform-origin: center center; - position: absolute; - width: 100vh; /* Use viewport height for width */ - height: 100vw; /* Use viewport width for height */ - max-width: none; /* Remove max-width limitation */ - padding: 20px; - box-sizing: border-box; - overflow: visible; /* Show all content */ - background-color: transparent; /* Remove background color to show background image */ - left: 50%; - top: 50%; - margin-left: -50vh; /* Half of width */ - margin-top: -50vw; /* Half of height */ - } - - body.vertical-reverse .config-button { - transform: rotate(-270deg); - position: fixed; - right: 10px; - bottom: 10px; /* Changed from top to bottom */ - z-index: 1000; - } - - body.vertical-reverse .departure-container { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - width: 100%; /* Ensure full width */ - } - - /* Mode indicators - using a class instead of pseudo-element */ - .mode-indicator { - font-size: 0.7em; - color: #666; - font-weight: normal; - display: inline; - } +/* ======================================== + CSS Custom Properties + ======================================== */ +:root { + --color-primary: #0061a1; + --color-primary-dark: #004d80; + --color-primary-light: #0077cc; + --color-accent: #4fc3f7; + --color-bg: #f5f5f5; + --color-bg-dark: #222; + --color-text: #333; + --color-text-light: #f5f5f5; + --color-text-muted: #666; + --color-text-muted-dark: #aaa; + --color-urgent: #c41e3a; + --color-urgent-dark: #ff6b6b; + --color-now: #00a651; + --color-now-dark: #4ecdc4; + --color-border: #ddd; + --color-border-dark: #555; + --color-surface: white; + --color-surface-dark: #333; + --color-surface-darker: #444; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2); + --gradient-blue: linear-gradient(135deg, #0061a1 0%, #004d80 100%); +} + +/* ======================================== + Base Styles + ======================================== */ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: var(--color-bg); + color: var(--color-text); + transition: background-color 0.5s ease, color 0.5s ease; + height: 100vh; + overflow: hidden; +} + +/* For normal orientation on narrow screens, add padding */ +@media (max-width: 1199px) { + body.normal { + padding: 20px; + } +} + +/* Auto-apply wide layout for normal orientation on large screens */ +@media (min-width: 1200px) { + body.normal { + max-width: 100%; + padding: 8px 12px 0 12px; + } + + body.normal #content-wrapper { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 8px; + height: 100vh; + max-height: 100vh; + overflow: hidden; + } + + body.normal .clock-container { + grid-row: 1; + margin-bottom: 0; + padding: 6px 16px; + } + + body.normal .main-content-grid { + grid-row: 2; + display: block; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + width: 100%; + } + + body.normal .departure-container { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin-bottom: 0; + width: 100%; + box-sizing: border-box; + padding: 0; + } + + body.normal .departure-container > * { + min-width: 0; + max-width: 100%; + } + + body.normal .weather-section { + grid-row: 3; + position: sticky; + bottom: 35px; + background-color: inherit; + padding: 8px 0 0 0; + margin-top: 0; + } + + body.normal .weather-container { + margin: 0; + max-width: 100%; + } +} + +/* ======================================== + Dark Mode - Layout-level overrides only + ======================================== */ +body.dark-mode { + background-color: var(--color-bg-dark); + color: var(--color-text-light); +} + +body.dark-mode .departure-card { + background-color: var(--color-surface-dark); + border-left-color: var(--color-primary-light); +} + +/* ======================================== + Orientation: Normal + ======================================== */ +body.normal { + max-width: 800px; +} + +body.normal .departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +/* ======================================== + Orientation: Landscape + ======================================== */ +body.landscape { + max-width: 100%; + padding: 20px 40px; +} + +body.landscape #content-wrapper { + display: grid; + grid-template-rows: auto 1fr; + gap: 20px; + height: 100vh; + max-height: 100vh; + overflow: hidden; +} + +body.landscape .clock-container { + grid-row: 1; + margin-bottom: 0; +} + +body.landscape .main-content-grid { + grid-row: 2; + display: grid; + grid-template-columns: 1fr 380px; + gap: 20px; + overflow: hidden; + min-height: 0; +} + +body.landscape .departure-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); + gap: 15px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 10px; + min-height: 0; +} + +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: 120px; +} + +body.landscape .line-number-box { + min-width: 120px; + width: 120px; +} + +body.landscape .line-number-large { + font-size: 3.5em; +} + +body.landscape .site-container { + margin-bottom: 15px; +} + +body.landscape .site-header { + font-size: 1em; + padding: 8px 12px; +} + +/* ======================================== + Orientation: Vertical (90deg) + ======================================== */ +body.vertical { + max-width: 100%; + height: 100vh; + padding: 0; + margin: 0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +body.vertical #content-wrapper { + transform: rotate(90deg); + transform-origin: center center; + position: absolute; + width: 100vh; + height: 100vw; + max-width: 800px; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; + background-color: transparent; + left: 50%; + top: 50%; + margin-left: -50vh; + margin-top: -50vw; +} + +body.vertical .config-button { + transform: rotate(-90deg); + position: fixed; + right: 10px; + bottom: 10px; + z-index: 1000; +} + +body.vertical .departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +/* ======================================== + Orientation: Upside Down (180deg) + ======================================== */ +body.upsidedown { + max-width: 100%; + height: 100vh; + padding: 0; + margin: 0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +body.upsidedown #content-wrapper { + transform: rotate(180deg); + transform-origin: center center; + position: absolute; + width: 100%; + max-width: 800px; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; + background-color: transparent; + left: 50%; + top: 50%; + transform: rotate(180deg) translate(50%, 50%); +} + +body.upsidedown .config-button { + transform: rotate(-180deg); + position: fixed; + right: 10px; + bottom: 10px; + z-index: 1000; +} + +body.upsidedown .departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +/* ======================================== + Orientation: Vertical Reverse (270deg) + ======================================== */ +body.vertical-reverse { + max-width: 100%; + height: 100vh; + padding: 0; + margin: 0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +body.vertical-reverse #content-wrapper { + transform: rotate(270deg); + transform-origin: center center; + position: absolute; + width: 100vh; + height: 100vw; + max-width: none; + padding: 20px; + box-sizing: border-box; + overflow: visible; + background-color: transparent; + left: 50%; + top: 50%; + margin-left: -50vh; + margin-top: -50vw; +} + +body.vertical-reverse .config-button { + transform: rotate(-270deg); + position: fixed; + right: 10px; + bottom: 10px; + z-index: 1000; +} + +body.vertical-reverse .departure-container { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + width: 100%; +} + +/* ======================================== + Mode Indicator + ======================================== */ +.mode-indicator { + font-size: 0.7em; + color: var(--color-text-muted); + font-weight: normal; + display: inline; +} diff --git a/public/js/components/ConfigManager.js b/public/js/components/ConfigManager.js index 6c7f6b5..04d3718 100644 --- a/public/js/components/ConfigManager.js +++ b/public/js/components/ConfigManager.js @@ -68,6 +68,9 @@ class ConfigManager { buttonContainer.id = this.options.configButtonId; buttonContainer.className = 'config-button'; buttonContainer.title = 'Settings'; + buttonContainer.setAttribute('role', 'button'); + buttonContainer.setAttribute('aria-label', 'Open settings'); + buttonContainer.setAttribute('tabindex', '0'); buttonContainer.innerHTML = ` @@ -76,6 +79,12 @@ class ConfigManager { `; buttonContainer.addEventListener('click', () => this.toggleConfigModal()); + buttonContainer.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.toggleConfigModal(); + } + }); document.body.appendChild(buttonContainer); } @@ -88,6 +97,9 @@ class ConfigManager { modalContainer.id = this.options.configModalId; modalContainer.className = 'config-modal'; modalContainer.style.display = 'none'; + modalContainer.setAttribute('role', 'dialog'); + modalContainer.setAttribute('aria-label', 'Settings'); + modalContainer.setAttribute('aria-modal', 'true'); // Clone the template content into the modal modalContainer.appendChild(template.content.cloneNode(true)); @@ -122,7 +134,14 @@ class ConfigManager { this.setupTabs(modalContainer); // Add event listeners - modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal()); + const closeBtn = modalContainer.querySelector('.config-modal-close'); + closeBtn.addEventListener('click', () => this.hideConfigModal()); + closeBtn.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.hideConfigModal(); + } + }); modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal()); modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig()); modalContainer.querySelector('#test-image-button').addEventListener('click', () => { @@ -277,6 +296,8 @@ class ConfigManager { hideConfigModal() { const modal = document.getElementById(this.options.configModalId); modal.style.display = 'none'; + const configButton = document.getElementById(this.options.configButtonId); + if (configButton) configButton.focus(); } /** @@ -285,6 +306,8 @@ class ConfigManager { showConfigModal() { const modal = document.getElementById(this.options.configModalId); modal.style.display = 'flex'; + const closeBtn = modal.querySelector('.config-modal-close'); + if (closeBtn) closeBtn.focus(); // Reset to first tab const tabs = modal.querySelectorAll('.config-tab'); diff --git a/public/js/components/WeatherManager.js b/public/js/components/WeatherManager.js index f75e9e8..e560390 100644 --- a/public/js/components/WeatherManager.js +++ b/public/js/components/WeatherManager.js @@ -195,43 +195,42 @@ class WeatherManager { * Get weather icon URL from icon code */ getWeatherIconUrl(iconCode) { - return `https://openweathermap.org/img/wn/${iconCode}@2x.png`; + return `https://openweathermap.org/img/wn/${iconCode}@4x.png`; } /** - * Determine if icon represents sun (even behind clouds) + * Classify a weather icon and return the appropriate CSS classes + * @param {string} iconCode - OWM icon code (e.g. '01d', '13n') + * @param {string} condition - Weather condition text (e.g. 'Clear', 'Clouds') + * @returns {string[]} Array of CSS class names to apply */ - isSunIcon(iconCode, condition) { - // Icon codes: 01d, 01n = clear, 02d, 02n = few clouds, 03d, 03n = scattered, 04d, 04n = broken clouds - const sunIconCodes = ['01d', '01n', '02d', '02n', '03d', '03n', '04d', '04n']; - return sunIconCodes.includes(iconCode) || - condition.includes('Clear') || - condition.includes('Clouds'); + classifyWeatherIcon(iconCode, condition) { + const code = iconCode ? iconCode.replace(/[dn]$/, '') : ''; + + // Snow: icon 13x or condition contains 'Snow' + if (code === '13' || condition.includes('Snow')) { + return ['weather-snow']; + } + // Clear sun: icon 01x or condition is exactly 'Clear' + if (code === '01' || condition === 'Clear') { + return ['weather-sun', 'weather-clear-sun']; + } + // Sun behind clouds: icon 02-04x or cloudy condition + if (['02', '03', '04'].includes(code) || (condition.includes('Clouds') && !condition.includes('Clear'))) { + return ['weather-sun', 'weather-clouds-sun']; + } + return []; } - + /** - * Check if icon is clear sun (no clouds) + * Apply weather icon CSS classes to an element */ - isClearSun(iconCode, condition) { - const clearIconCodes = ['01d', '01n']; - return clearIconCodes.includes(iconCode) || condition === 'Clear'; - } - - /** - * Check if icon is sun behind clouds - */ - isSunBehindClouds(iconCode, condition) { - const cloudIconCodes = ['02d', '02n', '03d', '03n', '04d', '04n']; - return cloudIconCodes.includes(iconCode) || (condition.includes('Clouds') && !condition.includes('Clear')); - } - - /** - * Determine if icon represents snow - */ - isSnowIcon(iconCode, condition) { - // Icon code: 13d, 13n = snow - const snowIconCodes = ['13d', '13n']; - return snowIconCodes.includes(iconCode) || condition.includes('Snow'); + applyWeatherIconClasses(element, iconCode, condition) { + element.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun'); + const classes = this.classifyWeatherIcon(iconCode, condition); + if (classes.length > 0) { + element.classList.add(...classes); + } } /** @@ -242,7 +241,7 @@ class WeatherManager { temperature: 7.1, condition: 'Clear', description: 'clear sky', - icon: 'https://openweathermap.org/img/wn/01d@2x.png', + icon: 'https://openweathermap.org/img/wn/01d@4x.png', iconCode: '01d', wind: { speed: 14.8, @@ -273,7 +272,7 @@ class WeatherManager { temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour condition: i < 2 ? 'Clear' : 'Clouds', description: i < 2 ? 'clear sky' : 'few clouds', - icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@2x.png' : 'https://openweathermap.org/img/wn/02n@2x.png', + icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@4x.png' : 'https://openweathermap.org/img/wn/02n@4x.png', iconCode: i < 2 ? '01n' : '02n', timestamp: forecastTime, precipitation: 0 @@ -305,18 +304,8 @@ class WeatherManager { if (iconElement) { iconElement.src = this.weatherData.icon; iconElement.alt = this.weatherData.description; - // Add classes and data attributes for color filtering iconElement.setAttribute('data-condition', this.weatherData.condition); - iconElement.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun'); - if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) { - iconElement.classList.add('weather-snow'); - } else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) { - iconElement.classList.add('weather-sun', 'weather-clear-sun'); - } else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) { - iconElement.classList.add('weather-sun', 'weather-clouds-sun'); - } else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) { - iconElement.classList.add('weather-sun'); - } + this.applyWeatherIconClasses(iconElement, this.weatherData.iconCode, this.weatherData.condition); } const temperatureElement = document.querySelector('#custom-weather .temperature'); @@ -338,15 +327,7 @@ class WeatherManager { nowIcon.alt = this.weatherData.description; nowIcon.width = 56; nowIcon.setAttribute('data-condition', this.weatherData.condition); - if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) { - nowIcon.classList.add('weather-snow'); - } else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) { - nowIcon.classList.add('weather-sun', 'weather-clear-sun'); - } else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) { - nowIcon.classList.add('weather-sun', 'weather-clouds-sun'); - } else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) { - nowIcon.classList.add('weather-sun'); - } + this.applyWeatherIconClasses(nowIcon, this.weatherData.iconCode, this.weatherData.condition); nowElement.innerHTML = `
Nu
@@ -367,15 +348,7 @@ class WeatherManager { forecastIcon.alt = forecast.description; forecastIcon.width = 56; forecastIcon.setAttribute('data-condition', forecast.condition); - if (this.isSnowIcon(forecast.iconCode, forecast.condition)) { - forecastIcon.classList.add('weather-snow'); - } else if (this.isClearSun(forecast.iconCode, forecast.condition)) { - forecastIcon.classList.add('weather-sun', 'weather-clear-sun'); - } else if (this.isSunBehindClouds(forecast.iconCode, forecast.condition)) { - forecastIcon.classList.add('weather-sun', 'weather-clouds-sun'); - } else if (this.isSunIcon(forecast.iconCode, forecast.condition)) { - forecastIcon.classList.add('weather-sun'); - } + this.applyWeatherIconClasses(forecastIcon, forecast.iconCode, forecast.condition); forecastElement.innerHTML = `
${timeString}
diff --git a/server/routes/departures.js b/server/routes/departures.js index 5ed81d3..3ab872d 100644 --- a/server/routes/departures.js +++ b/server/routes/departures.js @@ -8,11 +8,19 @@ const https = require('https'); /** * Fetch departures for a specific site from SL Transport API * @param {string} siteId - The site ID to fetch departures for + * @param {Object} options - Query options + * @param {number} options.forecast - Time window in minutes (default: 60) + * @param {string} options.transport - Transport mode filter (e.g. 'BUS', 'METRO') * @returns {Promise} - Departure data */ -function fetchDeparturesForSite(siteId) { +function fetchDeparturesForSite(siteId, options = {}) { return new Promise((resolve, reject) => { - const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`; + const params = new URLSearchParams(); + params.set('forecast', options.forecast || 60); + if (options.transport) { + params.set('transport', options.transport); + } + const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures?${params}`; console.log(`Fetching data from: ${apiUrl}`); https.get(apiUrl, (res) => { @@ -46,15 +54,19 @@ function fetchDeparturesForSite(siteId) { * @param {Array} enabledSites - Array of enabled site configurations * @returns {Promise} - Object with sites array containing departure data */ -async function fetchAllDepartures(enabledSites) { +async function fetchAllDepartures(enabledSites, globalOptions = {}) { if (enabledSites.length === 0) { return { sites: [], error: 'No enabled sites configured' }; } - + try { const sitesPromises = enabledSites.map(async (site) => { try { - const departureData = await fetchDeparturesForSite(site.id); + const siteOptions = { + forecast: site.forecast || globalOptions.forecast || 60, + transport: site.transport || globalOptions.transport || '' + }; + const departureData = await fetchDeparturesForSite(site.id, siteOptions); return { siteId: site.id, siteName: site.name, @@ -87,7 +99,11 @@ async function fetchAllDepartures(enabledSites) { async function handleDepartures(req, res, config) { try { const enabledSites = config.sites.filter(site => site.enabled); - const data = await fetchAllDepartures(enabledSites); + const globalOptions = { + forecast: config.forecast || 60, + transport: config.transport || '' + }; + const data = await fetchAllDepartures(enabledSites, globalOptions); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } catch (error) {