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 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 14:39:45 +01:00
parent 1fdb3e48c7
commit cdfd32dc69
6 changed files with 701 additions and 418 deletions

View File

@@ -133,7 +133,7 @@
<div class="config-modal-content"> <div class="config-modal-content">
<div class="config-modal-header"> <div class="config-modal-header">
<h2>Settings</h2> <h2>Settings</h2>
<span class="config-modal-close">&times;</span> <span class="config-modal-close" role="button" tabindex="0" aria-label="Close settings">&times;</span>
</div> </div>
<div class="config-tabs"> <div class="config-tabs">
<button class="config-tab active" data-tab="display">Display</button> <button class="config-tab active" data-tab="display">Display</button>

View File

@@ -1291,6 +1291,272 @@ body.dark-mode .config-file-label {
margin-top: 10px; 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 */ /* Reduced motion preference */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.countdown-large.urgent, .countdown-large.urgent,

View File

@@ -1,37 +1,78 @@
/* Base styles */ /* ========================================
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 { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
margin: 0 auto; 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; padding: 20px;
background-color: #f5f5f5; }
color: #333;
transition: all 0.5s ease;
} }
/* Auto-apply landscape layout for wide screens */ /* Auto-apply wide layout for normal orientation on large screens */
@media (min-width: 1200px) { @media (min-width: 1200px) {
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) { body.normal {
max-width: 100%; max-width: 100%;
padding: 8px 12px 0 12px; /* Minimal padding to maximize space */ padding: 8px 12px 0 12px;
padding-bottom: 0;
} }
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) #content-wrapper { body.normal #content-wrapper {
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
gap: 8px; /* Reduced gap */ gap: 8px;
height: 100vh; height: 100vh;
max-height: 100vh; max-height: 100vh;
overflow: hidden; overflow: hidden;
} }
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .clock-container { body.normal .clock-container {
grid-row: 1; grid-row: 1;
margin-bottom: 0; margin-bottom: 0;
padding: 6px 16px; /* Reduced padding */ padding: 6px 16px;
} }
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .main-content-grid { body.normal .main-content-grid {
grid-row: 2; grid-row: 2;
display: block; display: block;
overflow-y: auto; overflow-y: auto;
@@ -40,108 +81,70 @@
width: 100%; width: 100%;
} }
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container { body.normal .departure-container {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); /* Fixed 4 columns to use all space */ grid-template-columns: repeat(4, 1fr);
gap: 6px; /* Minimal gap */ gap: 6px;
margin-bottom: 0; margin-bottom: 0;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0; /* Remove any padding */ padding: 0;
} }
/* Ensure each column uses equal space */ body.normal .departure-container > * {
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container > * { min-width: 0;
min-width: 0; /* Allow flex shrinking */ max-width: 100%;
max-width: 100%; /* Prevent overflow */
} }
/* Weather fixed at bottom */ body.normal .weather-section {
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-section {
grid-row: 3; grid-row: 3;
position: sticky; position: sticky;
bottom: 0; bottom: 35px;
background-color: inherit; background-color: inherit;
padding: 8px 0; /* Reduced padding */ padding: 8px 0 0 0;
margin-top: 0; margin-top: 0;
} }
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-container { body.normal .weather-container {
margin: 0; margin: 0;
max-width: 100%; max-width: 100%;
} }
} }
/* Dark mode styles */ /* ========================================
Dark Mode - Layout-level overrides only
======================================== */
body.dark-mode { body.dark-mode {
background-color: #222; background-color: var(--color-bg-dark);
color: #f5f5f5; color: var(--color-text-light);
} }
body.dark-mode .departure-card { body.dark-mode .departure-card {
background-color: #333; background-color: var(--color-surface-dark);
border-left-color: #0077cc; border-left-color: var(--color-primary-light);
} }
body.dark-mode .config-modal-content { /* ========================================
background-color: #333; Orientation: Normal
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 { body.normal {
max-width: 800px; max-width: 800px;
} }
body.normal .departure-container { body.normal .departure-container {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px; gap: 10px;
} }
/* Landscape orientation - Optimized for wide screens */ /* ========================================
Orientation: Landscape
======================================== */
body.landscape { body.landscape {
max-width: 100%; max-width: 100%;
padding: 20px 40px; padding: 20px 40px;
} }
/* Main content area: clock at top, then two-column layout below */
body.landscape #content-wrapper { body.landscape #content-wrapper {
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
@@ -156,7 +159,6 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Main content grid: departures on left, weather on right */
body.landscape .main-content-grid { body.landscape .main-content-grid {
grid-row: 2; grid-row: 2;
display: grid; display: grid;
@@ -166,7 +168,6 @@
min-height: 0; min-height: 0;
} }
/* Departures container: multi-column grid */
body.landscape .departure-container { body.landscape .departure-container {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(450px, 1fr));
@@ -177,7 +178,6 @@
min-height: 0; min-height: 0;
} }
/* Weather container: fixed width, scrollable */
body.landscape .weather-container { body.landscape .weather-container {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@@ -187,7 +187,6 @@
align-self: start; align-self: start;
} }
/* Better horizontal space usage in landscape */
body.landscape .departure-card { body.landscape .departure-card {
min-height: 120px; min-height: 120px;
} }
@@ -201,7 +200,6 @@
font-size: 3.5em; font-size: 3.5em;
} }
/* Site containers in landscape should be more compact */
body.landscape .site-container { body.landscape .site-container {
margin-bottom: 15px; margin-bottom: 15px;
} }
@@ -211,7 +209,9 @@
padding: 8px 12px; padding: 8px 12px;
} }
/* Vertical orientation (90 degrees rotated) */ /* ========================================
Orientation: Vertical (90deg)
======================================== */
body.vertical { body.vertical {
max-width: 100%; max-width: 100%;
height: 100vh; height: 100vh;
@@ -227,24 +227,24 @@
transform: rotate(90deg); transform: rotate(90deg);
transform-origin: center center; transform-origin: center center;
position: absolute; position: absolute;
width: 100vh; /* Use viewport height for width */ width: 100vh;
height: 100vw; /* Use viewport width for height */ height: 100vw;
max-width: 800px; /* Limit width for better readability */ max-width: 800px;
padding: 20px; padding: 20px;
box-sizing: border-box; box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
background-color: transparent; /* Remove background color */ background-color: transparent;
left: 50%; left: 50%;
top: 50%; top: 50%;
margin-left: -50vh; /* Half of width */ margin-left: -50vh;
margin-top: -50vw; /* Half of height */ margin-top: -50vw;
} }
body.vertical .config-button { body.vertical .config-button {
transform: rotate(-90deg); transform: rotate(-90deg);
position: fixed; position: fixed;
right: 10px; right: 10px;
bottom: 10px; /* Changed from top to bottom */ bottom: 10px;
z-index: 1000; z-index: 1000;
} }
@@ -254,7 +254,9 @@
gap: 10px; gap: 10px;
} }
/* Upside down orientation (180 degrees rotated) */ /* ========================================
Orientation: Upside Down (180deg)
======================================== */
body.upsidedown { body.upsidedown {
max-width: 100%; max-width: 100%;
height: 100vh; height: 100vh;
@@ -275,18 +277,17 @@
padding: 20px; padding: 20px;
box-sizing: border-box; box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
background-color: transparent; /* Remove background color */ background-color: transparent;
left: 50%; left: 50%;
top: 50%; top: 50%;
margin-left: -400px; /* Half of max-width */ transform: rotate(180deg) translate(50%, 50%);
margin-top: -50vh; /* Half of viewport height */
} }
body.upsidedown .config-button { body.upsidedown .config-button {
transform: rotate(-180deg); transform: rotate(-180deg);
position: fixed; position: fixed;
right: 10px; right: 10px;
bottom: 10px; /* Changed from top to bottom */ bottom: 10px;
z-index: 1000; z-index: 1000;
} }
@@ -296,7 +297,9 @@
gap: 10px; gap: 10px;
} }
/* Vertical reverse orientation (270 degrees rotated) */ /* ========================================
Orientation: Vertical Reverse (270deg)
======================================== */
body.vertical-reverse { body.vertical-reverse {
max-width: 100%; max-width: 100%;
height: 100vh; height: 100vh;
@@ -312,24 +315,24 @@
transform: rotate(270deg); transform: rotate(270deg);
transform-origin: center center; transform-origin: center center;
position: absolute; position: absolute;
width: 100vh; /* Use viewport height for width */ width: 100vh;
height: 100vw; /* Use viewport width for height */ height: 100vw;
max-width: none; /* Remove max-width limitation */ max-width: none;
padding: 20px; padding: 20px;
box-sizing: border-box; box-sizing: border-box;
overflow: visible; /* Show all content */ overflow: visible;
background-color: transparent; /* Remove background color to show background image */ background-color: transparent;
left: 50%; left: 50%;
top: 50%; top: 50%;
margin-left: -50vh; /* Half of width */ margin-left: -50vh;
margin-top: -50vw; /* Half of height */ margin-top: -50vw;
} }
body.vertical-reverse .config-button { body.vertical-reverse .config-button {
transform: rotate(-270deg); transform: rotate(-270deg);
position: fixed; position: fixed;
right: 10px; right: 10px;
bottom: 10px; /* Changed from top to bottom */ bottom: 10px;
z-index: 1000; z-index: 1000;
} }
@@ -337,13 +340,15 @@
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px; gap: 10px;
width: 100%; /* Ensure full width */ width: 100%;
} }
/* Mode indicators - using a class instead of pseudo-element */ /* ========================================
Mode Indicator
======================================== */
.mode-indicator { .mode-indicator {
font-size: 0.7em; font-size: 0.7em;
color: #666; color: var(--color-text-muted);
font-weight: normal; font-weight: normal;
display: inline; display: inline;
} }

View File

@@ -68,6 +68,9 @@ class ConfigManager {
buttonContainer.id = this.options.configButtonId; buttonContainer.id = this.options.configButtonId;
buttonContainer.className = 'config-button'; buttonContainer.className = 'config-button';
buttonContainer.title = 'Settings'; buttonContainer.title = 'Settings';
buttonContainer.setAttribute('role', 'button');
buttonContainer.setAttribute('aria-label', 'Open settings');
buttonContainer.setAttribute('tabindex', '0');
buttonContainer.innerHTML = ` buttonContainer.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
@@ -76,6 +79,12 @@ class ConfigManager {
`; `;
buttonContainer.addEventListener('click', () => this.toggleConfigModal()); buttonContainer.addEventListener('click', () => this.toggleConfigModal());
buttonContainer.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.toggleConfigModal();
}
});
document.body.appendChild(buttonContainer); document.body.appendChild(buttonContainer);
} }
@@ -88,6 +97,9 @@ class ConfigManager {
modalContainer.id = this.options.configModalId; modalContainer.id = this.options.configModalId;
modalContainer.className = 'config-modal'; modalContainer.className = 'config-modal';
modalContainer.style.display = 'none'; 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 // Clone the template content into the modal
modalContainer.appendChild(template.content.cloneNode(true)); modalContainer.appendChild(template.content.cloneNode(true));
@@ -122,7 +134,14 @@ class ConfigManager {
this.setupTabs(modalContainer); this.setupTabs(modalContainer);
// Add event listeners // 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-cancel-button').addEventListener('click', () => this.hideConfigModal());
modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig()); modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig());
modalContainer.querySelector('#test-image-button').addEventListener('click', () => { modalContainer.querySelector('#test-image-button').addEventListener('click', () => {
@@ -277,6 +296,8 @@ class ConfigManager {
hideConfigModal() { hideConfigModal() {
const modal = document.getElementById(this.options.configModalId); const modal = document.getElementById(this.options.configModalId);
modal.style.display = 'none'; modal.style.display = 'none';
const configButton = document.getElementById(this.options.configButtonId);
if (configButton) configButton.focus();
} }
/** /**
@@ -285,6 +306,8 @@ class ConfigManager {
showConfigModal() { showConfigModal() {
const modal = document.getElementById(this.options.configModalId); const modal = document.getElementById(this.options.configModalId);
modal.style.display = 'flex'; modal.style.display = 'flex';
const closeBtn = modal.querySelector('.config-modal-close');
if (closeBtn) closeBtn.focus();
// Reset to first tab // Reset to first tab
const tabs = modal.querySelectorAll('.config-tab'); const tabs = modal.querySelectorAll('.config-tab');

View File

@@ -195,43 +195,42 @@ class WeatherManager {
* Get weather icon URL from icon code * Get weather icon URL from icon code
*/ */
getWeatherIconUrl(iconCode) { 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) { classifyWeatherIcon(iconCode, condition) {
// Icon codes: 01d, 01n = clear, 02d, 02n = few clouds, 03d, 03n = scattered, 04d, 04n = broken clouds const code = iconCode ? iconCode.replace(/[dn]$/, '') : '';
const sunIconCodes = ['01d', '01n', '02d', '02n', '03d', '03n', '04d', '04n'];
return sunIconCodes.includes(iconCode) || // Snow: icon 13x or condition contains 'Snow'
condition.includes('Clear') || if (code === '13' || condition.includes('Snow')) {
condition.includes('Clouds'); 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) { applyWeatherIconClasses(element, iconCode, condition) {
const clearIconCodes = ['01d', '01n']; element.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun');
return clearIconCodes.includes(iconCode) || condition === 'Clear'; const classes = this.classifyWeatherIcon(iconCode, condition);
if (classes.length > 0) {
element.classList.add(...classes);
} }
/**
* 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');
} }
/** /**
@@ -242,7 +241,7 @@ class WeatherManager {
temperature: 7.1, temperature: 7.1,
condition: 'Clear', condition: 'Clear',
description: 'clear sky', description: 'clear sky',
icon: 'https://openweathermap.org/img/wn/01d@2x.png', icon: 'https://openweathermap.org/img/wn/01d@4x.png',
iconCode: '01d', iconCode: '01d',
wind: { wind: {
speed: 14.8, speed: 14.8,
@@ -273,7 +272,7 @@ class WeatherManager {
temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour
condition: i < 2 ? 'Clear' : 'Clouds', condition: i < 2 ? 'Clear' : 'Clouds',
description: i < 2 ? 'clear sky' : 'few 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', iconCode: i < 2 ? '01n' : '02n',
timestamp: forecastTime, timestamp: forecastTime,
precipitation: 0 precipitation: 0
@@ -305,18 +304,8 @@ class WeatherManager {
if (iconElement) { if (iconElement) {
iconElement.src = this.weatherData.icon; iconElement.src = this.weatherData.icon;
iconElement.alt = this.weatherData.description; iconElement.alt = this.weatherData.description;
// Add classes and data attributes for color filtering
iconElement.setAttribute('data-condition', this.weatherData.condition); iconElement.setAttribute('data-condition', this.weatherData.condition);
iconElement.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun'); this.applyWeatherIconClasses(iconElement, this.weatherData.iconCode, this.weatherData.condition);
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');
}
} }
const temperatureElement = document.querySelector('#custom-weather .temperature'); const temperatureElement = document.querySelector('#custom-weather .temperature');
@@ -338,15 +327,7 @@ class WeatherManager {
nowIcon.alt = this.weatherData.description; nowIcon.alt = this.weatherData.description;
nowIcon.width = 56; nowIcon.width = 56;
nowIcon.setAttribute('data-condition', this.weatherData.condition); nowIcon.setAttribute('data-condition', this.weatherData.condition);
if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) { this.applyWeatherIconClasses(nowIcon, 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');
}
nowElement.innerHTML = ` nowElement.innerHTML = `
<div class="time">Nu</div> <div class="time">Nu</div>
<div class="icon"></div> <div class="icon"></div>
@@ -367,15 +348,7 @@ class WeatherManager {
forecastIcon.alt = forecast.description; forecastIcon.alt = forecast.description;
forecastIcon.width = 56; forecastIcon.width = 56;
forecastIcon.setAttribute('data-condition', forecast.condition); forecastIcon.setAttribute('data-condition', forecast.condition);
if (this.isSnowIcon(forecast.iconCode, forecast.condition)) { this.applyWeatherIconClasses(forecastIcon, 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');
}
forecastElement.innerHTML = ` forecastElement.innerHTML = `
<div class="time">${timeString}</div> <div class="time">${timeString}</div>
<div class="icon"></div> <div class="icon"></div>

View File

@@ -8,11 +8,19 @@ const https = require('https');
/** /**
* Fetch departures for a specific site from SL Transport API * Fetch departures for a specific site from SL Transport API
* @param {string} siteId - The site ID to fetch departures for * @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<Object>} - Departure data * @returns {Promise<Object>} - Departure data
*/ */
function fetchDeparturesForSite(siteId) { function fetchDeparturesForSite(siteId, options = {}) {
return new Promise((resolve, reject) => { 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}`); console.log(`Fetching data from: ${apiUrl}`);
https.get(apiUrl, (res) => { https.get(apiUrl, (res) => {
@@ -46,7 +54,7 @@ function fetchDeparturesForSite(siteId) {
* @param {Array} enabledSites - Array of enabled site configurations * @param {Array} enabledSites - Array of enabled site configurations
* @returns {Promise<Object>} - Object with sites array containing departure data * @returns {Promise<Object>} - Object with sites array containing departure data
*/ */
async function fetchAllDepartures(enabledSites) { async function fetchAllDepartures(enabledSites, globalOptions = {}) {
if (enabledSites.length === 0) { if (enabledSites.length === 0) {
return { sites: [], error: 'No enabled sites configured' }; return { sites: [], error: 'No enabled sites configured' };
} }
@@ -54,7 +62,11 @@ async function fetchAllDepartures(enabledSites) {
try { try {
const sitesPromises = enabledSites.map(async (site) => { const sitesPromises = enabledSites.map(async (site) => {
try { 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 { return {
siteId: site.id, siteId: site.id,
siteName: site.name, siteName: site.name,
@@ -87,7 +99,11 @@ async function fetchAllDepartures(enabledSites) {
async function handleDepartures(req, res, config) { async function handleDepartures(req, res, config) {
try { try {
const enabledSites = config.sites.filter(site => site.enabled); 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.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} catch (error) { } catch (error) {