Compare commits

..

17 Commits

Author SHA1 Message Date
ba0cdbab64 Add all 5 transit stops to sites config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:07:51 +01:00
57cd9809e0 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>
2026-02-15 23:07:10 +01:00
5f60ed88c8 chore: remove unused legacy departure card styles and dead code
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 20:04:09 +01:00
4a6012b097 feat: Swedish weather bar with wind speed display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 20:01:39 +01:00
f0b04a7a0d fix: use 2-column grid for normal mode on large screens
Change from 4-column to 2-column grid in the @media (min-width: 1200px)
block for body.normal, giving each departure card ~580px width on 1080p
monitors. Also add font-size increases for destination text (1.1em),
countdown numbers (1.4em), and next departures (0.9em) to improve
readability at the wider card size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 20:00:26 +01:00
84ce6efb2d fix: reduce clock and site header visual weight for better information hierarchy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:59:26 +01:00
92166cea6e feat: improve departure information hierarchy with clearer time display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:57:53 +01:00
98441bc906 feat: full-width departure rows for landscape kiosk mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:55:33 +01:00
3c9ae03cb6 fix: replace noisy striped direction arrows with clean circular indicators
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:53:56 +01:00
1b1460fd45 feat: translate all user-facing text to Swedish for consistency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:51:49 +01:00
2b7fc6b016 refactor: consolidate hardcoded colors into CSS custom properties
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:49:39 +01:00
1e776c1c9a feat: unify typography with tabular-nums for stable number alignment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:45:52 +01:00
f2e5f23b18 Merge pull request 'UI overhaul: kiosk-optimized landscape mode' (#23) from ui-overhaul into main 2026-02-15 10:15:16 -08:00
60e41c2cc4 Kiosk UI/UX overhaul: dark landscape mode with hero countdowns and full-width layout
Redesign the landscape orientation for kiosk readability at 3-10m distance:

- Add dark kiosk background (#1a1a2e) with high-contrast light text
- Replace 2-column grid with 5-row full-width stacking layout
- Add compact weather bar (temp + sunrise/sunset) replacing full widget
- Enlarge countdown to 2em hero size in landscape
- Replace time ranges with next 2-3 absolute departure times
- Add 3-tier urgency colors: Nu (green), 1-2min (red), 3-5min (orange)
- Make site headers full-width blue gradient bars in landscape
- Tighten card spacing (65px min-height, 8px gap) for 4-stop visibility
- Add scrolling news ticker with /api/ticker fallback messages
- Fix daylight bar from position:fixed to relative in landscape grid
- Hide background overlay in landscape for maximum contrast
- Fix weather-section HTML missing closing div tags

All changes scoped behind body.landscape CSS selectors; other orientations unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:12:08 +01:00
6565661740 Fix map selector to load nearby stops from SL API
Replace hardcoded single-stop search with /api/sites/nearby endpoint
that loads all transit stops within radius of map center. Stops load
dynamically as user pans/zooms the map. Also unified marker layer
management and consistent CSS class for select buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:17:33 +01:00
cdfd32dc69 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>
2026-02-15 14:39:45 +01:00
1fdb3e48c7 Items 10-15: ES modules, inline style cleanup, template modal, code modernization
- Item 10: Convert to ES modules with import/export, single module entry point
- Item 11: Replace inline styles with CSS classes (background overlay, card
  animations, highlight effect, config modal form elements)
- Item 12: Move ConfigManager modal HTML from JS template literal to
  <template> element in index.html
- Item 13: Replace deprecated url.parse() with new URL() in server.js
  and update route handlers to use searchParams
- Item 14: Replace JSON.parse/stringify deep clone with structuredClone()
- Item 15: Remove dead JSON-fixing regex code from departures.js route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:30:03 +01:00
16 changed files with 3170 additions and 2435 deletions

View File

@@ -4,7 +4,7 @@ A modern digital signage system for displaying real-time transit departures and
![SL Transport Departures Display](screenshots/main-display.png)
> **Note**: Screenshots should be added to the `screenshots/` directory. The application displays real-time transit departures in a modern, optimized layout.
A comprehensive digital signage solution displaying real-time transit departures, weather information, and a visual daylight hours timeline.
## Features
@@ -21,6 +21,7 @@ A modern digital signage system for displaying real-time transit departures and
- Current weather conditions with icons
- Hourly forecast (8-hour outlook)
- Sunrise/sunset times
- **Daylight Hours Bar**: Visual 24-hour timeline at the bottom showing sunrise to sunset with animated sun/moon indicator
- Automatic dark mode based on time of day
### ⚙️ Flexible Configuration
@@ -33,6 +34,8 @@ A modern digital signage system for displaying real-time transit departures and
### 🎨 Modern Design
- Swedish color scheme (blue/yellow gradient clock banner)
- Compact ribbon-style clock with time and date on one line
- **Daylight Hours Bar**: Modern gradient bar with smooth color transitions from midnight blue (night) through orange/red (dawn/dusk) to yellow (day)
- Animated sun/moon icon with pulsing glow effect
- Optimized spacing and typography for readability
- Responsive design that adapts to screen size
- Smooth animations and visual effects
@@ -40,14 +43,33 @@ A modern digital signage system for displaying real-time transit departures and
## Screenshots
### Main Display (Landscape Layout)
![Landscape Layout](screenshots/landscape-layout.png)
![Main Display](screenshots/main-display.png)
> **Screenshot**: The system automatically optimizes for landscape orientation with a 4-column grid layout, maximizing screen real estate while maintaining readability. Add your screenshot to `screenshots/landscape-layout.png`.
The system automatically optimizes for landscape orientation with a 4-column grid layout, maximizing screen real estate while maintaining readability. The display includes:
### Key Features Highlighted
- **Clock Banner**: Compact ribbon-style header with time and date
- **Departure Cards**: Color-coded boxes with transport icons, line numbers, and direction arrows
- **Weather Widget**: Fixed at bottom with current conditions and hourly forecast
- **Clock Banner**: Compact ribbon-style header with time and date in Swedish locale
- **Departure Cards**: Color-coded boxes with transport icons, line numbers, and direction arrows showing real-time transit information
- **Weather Widget**: Compact widget with current conditions, hourly forecast, and sunrise/sunset times
- **Daylight Hours Bar**: 24-hour visual timeline at the bottom showing daylight hours with animated sun/moon indicator
### Settings Panel
![Settings Modal](screenshots/settings-modal.png)
Comprehensive configuration panel accessible via the gear icon (⚙️) with tabs for:
- **Display**: Screen orientation and dark mode settings
- **Appearance**: Background image and opacity controls
- **Content**: Transit stop management with map-based selection
- **Options**: Additional system preferences
### Daylight Hours Bar
![Daylight Hours Bar](screenshots/daylight-hours-bar.png)
The daylight hours bar provides a beautiful visual representation of the day:
- Modern gradient with smooth color transitions (midnight blue → orange/red → yellow)
- Animated sun icon (☀️) during daylight hours
- Animated moon icon (🌙) during nighttime hours
- Pulsing glow effect to draw attention
- Updates every minute to track the current time position
## Quick Start
@@ -261,6 +283,15 @@ Settings are automatically saved to localStorage and persist across sessions.
## Recent Updates
### Version 1.2.0 - Daylight Hours Bar & UI Refinements
- ✅ **New Feature**: Daylight Hours Bar - Visual 24-hour timeline with modern gradient shading
- ✅ Animated sun/moon icon indicator with pulsing glow effect
- ✅ Enhanced gradient transitions (midnight blue → orange/red → yellow)
- ✅ Improved weather widget layout and alignment
- ✅ Better spacing and positioning for 1080p kiosk displays
- ✅ Moon icon automatically shown during nighttime hours
- ✅ Perfectly centered icon indicators with flexbox alignment
### Version 1.1.0 - Landscape Optimization
- ✅ Optimized 4-column grid layout for landscape screens
- ✅ Replaced text labels with transport mode icons

View File

@@ -1,8 +1,8 @@
{
"orientation": "normal",
"darkMode": "auto",
"orientation": "landscape",
"darkMode": "off",
"backgroundImage": "https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000",
"backgroundOpacity": 0.45,
"backgroundOpacity": 0.25,
"sites": [
{
"id": "1411",
@@ -23,6 +23,16 @@
"id": "1110",
"name": "Radiohuset",
"enabled": true
},
{
"id": "1007",
"name": "Cityterminalen (på Kungsbron)",
"enabled": true
},
{
"id": "9636",
"name": "Bråvallavägen",
"enabled": true
}
],
"combineSameDirection": true

View File

@@ -8,7 +8,7 @@
<script>
// Global variables (fallback if Constants not loaded)
window.API_URL = window.API_URL || 'http://localhost:3002/api/departures';
window.REFRESH_INTERVAL = window.REFRESH_INTERVAL || 5000;
window.REFRESH_INTERVAL = window.REFRESH_INTERVAL || 30000;
</script>
<!-- Leaflet.js for map functionality -->
@@ -19,18 +19,8 @@
<link rel="stylesheet" href="public/css/main.css">
<link rel="stylesheet" href="public/css/components.css">
<!-- Utility scripts (must load first) -->
<script src="public/js/utils/constants.js"></script>
<script src="public/js/utils/logger.js"></script>
<!-- Component scripts (load in dependency order) -->
<script src="public/js/components/ConfigManager.js"></script>
<script src="public/js/components/Clock.js"></script>
<script src="public/js/components/WeatherManager.js"></script>
<script src="public/js/components/DeparturesManager.js"></script>
<!-- Main application script -->
<script src="public/js/main.js"></script>
<!-- Main application script (ES module - imports all dependencies) -->
<script type="module" src="public/js/main.js"></script>
<!-- Inline styles removed - now in external CSS files -->
</head>
@@ -40,7 +30,8 @@
<!-- Clock element -->
<div id="clock"></div>
<!-- Site ID header removed as requested -->
<!-- Compact weather bar (landscape only) -->
<div id="compact-weather-bar"></div>
<!-- Main content grid for landscape layout -->
<div class="main-content-grid">
@@ -69,6 +60,9 @@
</div>
</div>
<div class="temperature">7.1 °C</div>
<div class="sun-times">
☀️ Sunrise: 06:45 AM | 🌙 Sunset: 05:32 PM
</div>
</div>
<div class="forecast">
<div class="forecast-hour">
@@ -121,14 +115,116 @@
<div class="temp">5.5 °C</div>
</div>
</div>
<div class="sun-times">
☀️ Sunrise: 06:45 AM | 🌙 Sunset: 05:32 PM
</div>
</div> <!-- End of custom-weather -->
</div> <!-- End of weather-container -->
</div> <!-- End of weather-section -->
<div class="last-updated" id="last-updated"></div>
<!-- News ticker (landscape only) -->
<div id="news-ticker"><div class="ticker-content"></div></div>
<!-- Daylight Hours Bar -->
<div id="daylight-hours-bar">
<div class="daylight-bar-background"></div>
<div class="daylight-bar-indicator">
<span class="sun-icon">☀️</span>
</div>
</div>
</div> <!-- End of content-wrapper -->
<!-- Last updated - positioned next to gear icon -->
<div class="last-updated" id="last-updated"></div>
<!-- Config Modal Template -->
<template id="config-modal-template">
<div class="config-modal-content">
<div class="config-modal-header">
<h2>Settings</h2>
<span class="config-modal-close" role="button" tabindex="0" aria-label="Close settings">&times;</span>
</div>
<div class="config-tabs">
<button class="config-tab active" data-tab="display">Display</button>
<button class="config-tab" data-tab="appearance">Appearance</button>
<button class="config-tab" data-tab="content">Content</button>
<button class="config-tab" data-tab="options">Options</button>
</div>
<div class="config-modal-body">
<!-- Display Tab -->
<div class="config-tab-content active" id="tab-display">
<div class="config-option">
<label for="orientation-select">Screen Orientation:</label>
<select id="orientation-select">
<option value="normal">Normal (0°)</option>
<option value="vertical">Vertical (90°)</option>
<option value="upsidedown">Upside Down (180°)</option>
<option value="vertical-reverse">Vertical Reverse (270°)</option>
<option value="landscape">Landscape (2-column)</option>
</select>
</div>
<div class="config-option">
<label for="dark-mode-select">Dark Mode:</label>
<select id="dark-mode-select">
<option value="auto">Automatic (Sunset/Sunrise)</option>
<option value="on">Always On</option>
<option value="off">Always Off</option>
</select>
<div class="sun-times" id="sun-times">
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
</div>
</div>
</div>
<!-- Appearance Tab -->
<div class="config-tab-content" id="tab-appearance">
<div class="config-option">
<label for="background-image-url">Background Image:</label>
<input type="text" id="background-image-url" placeholder="Enter image URL">
<div class="config-flex-row">
<button id="test-image-button" class="config-btn-sm">Use Test Image</button>
<label for="local-image-input" class="config-file-label">Select Local Image</label>
<input type="file" id="local-image-input" accept="image/*" class="config-file-input-hidden">
</div>
<div class="background-preview" id="background-preview">
<div class="no-image">No image selected</div>
</div>
</div>
<div class="config-option">
<label for="background-opacity">Background Opacity: <span id="opacity-value">30%</span></label>
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="0.3">
</div>
</div>
<!-- Content Tab -->
<div class="config-tab-content" id="tab-content">
<div class="config-option">
<label>Transit Sites:</label>
<div style="margin-bottom: 15px;">
<div class="config-flex-row-mb">
<input type="text" id="site-search-input" placeholder="Search for transit stop..." class="config-search-input">
<button id="search-site-button">Search</button>
<button id="select-from-map-button">Select from Map</button>
</div>
<div id="site-search-results" class="config-search-results"></div>
</div>
<div id="sites-container"></div>
<div class="config-sites-add">
<button id="add-site-button" class="config-btn-sm">Add Site Manually</button>
</div>
</div>
</div>
<!-- Options Tab -->
<div class="config-tab-content" id="tab-options">
<div class="config-option">
<label for="combine-directions">
<input type="checkbox" id="combine-directions">
Combine departures going in the same direction
</label>
</div>
</div>
</div>
<div class="config-modal-footer">
<button id="config-save-button">Save</button>
<button id="config-cancel-button">Cancel</button>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,96 @@
/* Base styles */
body {
font-family: Arial, sans-serif;
margin: 0 auto;
/* ========================================
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-soon: #e67e22;
--color-soon-dark: #f39c12;
--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%);
--kiosk-gap: 8px;
--kiosk-countdown-size: 2em;
--ticker-height: 36px;
--ticker-speed: 30s;
--ticker-bg: rgba(0, 0, 0, 0.85);
--font-primary: 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
--font-numbers: 'Segoe UI', 'Roboto Mono', 'SF Mono', 'Consolas', monospace;
--color-bg-kiosk: #1a1a2e;
--color-surface-kiosk: rgba(255, 255, 255, 0.08);
--color-surface-kiosk-hover: rgba(255, 255, 255, 0.12);
--color-text-secondary: #bbb;
--color-text-tertiary: #888;
--color-bar-bg: rgba(0, 0, 0, 0.5);
--color-daylight-night: #191970;
--color-daylight-dawn: #FF6B35;
--color-daylight-day: #FFEB3B;
}
/* ========================================
Base Styles
======================================== */
body {
font-family: var(--font-primary);
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;
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) {
/* Auto-apply wide layout for normal orientation on large screens */
@media (min-width: 1200px) {
body.normal {
max-width: 100%;
padding: 8px 12px 0 12px; /* Minimal padding to maximize space */
padding-bottom: 0;
padding: 8px 12px 0 12px;
}
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) #content-wrapper {
body.normal #content-wrapper {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 8px; /* Reduced gap */
gap: 8px;
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .clock-container {
body.normal .clock-container {
grid-row: 1;
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;
display: block;
overflow-y: auto;
@@ -40,179 +99,275 @@
width: 100%;
}
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .departure-container {
body.normal .departure-container {
display: grid;
grid-template-columns: repeat(4, 1fr); /* Fixed 4 columns to use all space */
gap: 6px; /* Minimal gap */
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 0;
width: 100%;
box-sizing: border-box;
padding: 0; /* Remove any padding */
padding: 0;
}
/* 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 */
body.normal .departure-container > * {
min-width: 0;
max-width: 100%;
}
/* Weather fixed at bottom */
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-section {
body.normal .weather-section {
grid-row: 3;
position: sticky;
bottom: 0;
bottom: 35px;
background-color: inherit;
padding: 8px 0; /* Reduced padding */
padding: 8px 0 0 0;
margin-top: 0;
}
body:not(.vertical):not(.upsidedown):not(.vertical-reverse) .weather-container {
body.normal .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;
}
/* ========================================
Dark Mode - Layout-level overrides only
======================================== */
body.dark-mode {
background-color: var(--color-bg-dark);
color: var(--color-text-light);
}
body.dark-mode .config-modal-content {
background-color: #333;
color: #f5f5f5;
}
body.dark-mode .departure-card {
background-color: var(--color-surface-dark);
border-left-color: var(--color-primary-light);
}
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 {
/* ========================================
Orientation: Normal
======================================== */
body.normal {
max-width: 800px;
}
body.normal .departure-container {
}
body.normal .departure-container {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
}
/* Landscape orientation - Optimized for wide screens */
body.landscape {
/* ========================================
Orientation: Landscape (Kiosk Mode)
======================================== */
body.landscape {
max-width: 100%;
padding: 20px 40px;
}
padding: 10px 20px 0 20px;
background-color: var(--color-bg-kiosk);
color: var(--color-text-light);
}
/* Main content area: clock at top, then two-column layout below */
body.landscape #content-wrapper {
/* Hide background overlay in landscape for maximum contrast */
body.landscape #background-overlay {
display: none !important;
}
body.landscape #content-wrapper {
display: grid;
grid-template-rows: auto 1fr;
gap: 20px;
grid-template-rows: auto auto 1fr auto auto;
gap: 4px;
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
}
body.landscape .clock-container {
body.landscape .clock-container {
grid-row: 1;
margin-bottom: 0;
}
}
/* Main content grid: departures on left, weather on right */
body.landscape .main-content-grid {
/* Compact weather bar in row 2 */
body.landscape #compact-weather-bar {
grid-row: 2;
display: grid;
grid-template-columns: 1fr 380px;
gap: 20px;
font-size: 0.85em;
padding: 2px 16px;
}
body.landscape .main-content-grid {
grid-row: 3;
display: block;
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;
/* 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;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 10px;
padding-right: 4px;
min-height: 0;
}
height: 100%;
}
/* 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;
}
body.landscape .departure-column {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Better horizontal space usage in landscape */
body.landscape .departure-card {
min-height: 120px;
}
body.landscape .site-container {
margin-bottom: 0;
}
body.landscape .line-number-box {
min-width: 120px;
width: 120px;
}
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-large {
font-size: 3.5em;
}
body.landscape .line-number-box {
min-width: 48px;
width: 48px;
padding: 2px;
}
/* Site containers in landscape should be more compact */
body.landscape .site-container {
margin-bottom: 15px;
}
body.landscape .line-number-large {
font-size: 1.6em;
}
body.landscape .site-header {
font-size: 1em;
padding: 8px 12px;
}
body.landscape .transport-mode-icon .transport-icon {
width: 14px;
height: 14px;
}
/* Vertical orientation (90 degrees rotated) */
body.vertical {
body.landscape .site-container {
margin-bottom: 2px;
}
body.landscape .site-header {
font-size: 0.8em;
padding: 0;
margin-bottom: 2px;
}
/* Daylight bar in row 5 */
body.landscape #daylight-hours-bar {
grid-row: 5;
}
/* Dark card surfaces for landscape */
body.landscape .direction-destination {
color: var(--color-text-light);
}
body.landscape .countdown-large {
color: var(--color-text-light);
}
body.landscape .next-departures {
color: var(--color-text-secondary);
}
/* Compact card spacing in landscape */
body.landscape .directions-wrapper {
padding: 3px 6px;
gap: 1px;
}
body.landscape .direction-row {
min-height: 30px;
gap: 4px;
}
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: 1.5em;
}
body.landscape .times-container {
min-width: 90px;
max-width: 140px;
}
body.landscape .next-departures {
font-size: 0.75em;
color: var(--color-text-secondary);
white-space: nowrap;
letter-spacing: 0.3px;
}
/* ========================================
Orientation: Vertical (90deg)
======================================== */
body.vertical {
max-width: 100%;
height: 100vh;
padding: 0;
@@ -221,41 +376,43 @@
display: flex;
justify-content: center;
align-items: center;
}
}
body.vertical #content-wrapper {
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 */
width: 100vh;
height: 100vw;
max-width: 800px;
padding: 20px;
box-sizing: border-box;
overflow-y: auto;
background-color: transparent; /* Remove background color */
background-color: transparent;
left: 50%;
top: 50%;
margin-left: -50vh; /* Half of width */
margin-top: -50vw; /* Half of height */
}
margin-left: -50vh;
margin-top: -50vw;
}
body.vertical .config-button {
body.vertical .config-button {
transform: rotate(-90deg);
position: fixed;
right: 10px;
bottom: 10px; /* Changed from top to bottom */
bottom: 10px;
z-index: 1000;
}
}
body.vertical .departure-container {
body.vertical .departure-container {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
}
/* Upside down orientation (180 degrees rotated) */
body.upsidedown {
/* ========================================
Orientation: Upside Down (180deg)
======================================== */
body.upsidedown {
max-width: 100%;
height: 100vh;
padding: 0;
@@ -264,9 +421,9 @@
display: flex;
justify-content: center;
align-items: center;
}
}
body.upsidedown #content-wrapper {
body.upsidedown #content-wrapper {
transform: rotate(180deg);
transform-origin: center center;
position: absolute;
@@ -275,29 +432,30 @@
padding: 20px;
box-sizing: border-box;
overflow-y: auto;
background-color: transparent; /* Remove background color */
background-color: transparent;
left: 50%;
top: 50%;
margin-left: -400px; /* Half of max-width */
margin-top: -50vh; /* Half of viewport height */
}
transform: rotate(180deg) translate(50%, 50%);
}
body.upsidedown .config-button {
body.upsidedown .config-button {
transform: rotate(-180deg);
position: fixed;
right: 10px;
bottom: 10px; /* Changed from top to bottom */
bottom: 10px;
z-index: 1000;
}
}
body.upsidedown .departure-container {
body.upsidedown .departure-container {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
}
/* Vertical reverse orientation (270 degrees rotated) */
body.vertical-reverse {
/* ========================================
Orientation: Vertical Reverse (270deg)
======================================== */
body.vertical-reverse {
max-width: 100%;
height: 100vh;
padding: 0;
@@ -306,44 +464,46 @@
display: flex;
justify-content: center;
align-items: center;
}
}
body.vertical-reverse #content-wrapper {
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 */
width: 100vh;
height: 100vw;
max-width: none;
padding: 20px;
box-sizing: border-box;
overflow: visible; /* Show all content */
background-color: transparent; /* Remove background color to show background image */
overflow: visible;
background-color: transparent;
left: 50%;
top: 50%;
margin-left: -50vh; /* Half of width */
margin-top: -50vw; /* Half of height */
}
margin-left: -50vh;
margin-top: -50vw;
}
body.vertical-reverse .config-button {
body.vertical-reverse .config-button {
transform: rotate(-270deg);
position: fixed;
right: 10px;
bottom: 10px; /* Changed from top to bottom */
bottom: 10px;
z-index: 1000;
}
}
body.vertical-reverse .departure-container {
body.vertical-reverse .departure-container {
display: grid;
grid-template-columns: 1fr;
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;
color: #666;
color: var(--color-text-muted);
font-weight: normal;
display: inline;
}
}

View File

@@ -161,5 +161,8 @@ class Clock {
}
}
// Export the Clock class for use in other modules
// ES module export
export { Clock };
// Keep window reference for backward compatibility
window.Clock = Clock;

View File

@@ -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 = `
<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('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.toggleConfigModal();
}
});
document.body.appendChild(buttonContainer);
}
@@ -83,108 +92,41 @@ class ConfigManager {
* Create the configuration modal
*/
createConfigModal() {
const template = document.getElementById('config-modal-template');
const modalContainer = document.createElement('div');
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');
modalContainer.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<h2>Settings</h2>
<span class="config-modal-close">&times;</span>
</div>
<div class="config-tabs">
<button class="config-tab active" data-tab="display">Display</button>
<button class="config-tab" data-tab="appearance">Appearance</button>
<button class="config-tab" data-tab="content">Content</button>
<button class="config-tab" data-tab="options">Options</button>
</div>
<div class="config-modal-body">
<!-- Display Tab -->
<div class="config-tab-content active" id="tab-display">
<div class="config-option">
<label for="orientation-select">Screen Orientation:</label>
<select id="orientation-select">
<option value="normal" ${this.config.orientation === 'normal' ? 'selected' : ''}>Normal (0°)</option>
<option value="vertical" ${this.config.orientation === 'vertical' ? 'selected' : ''}>Vertical (90°)</option>
<option value="upsidedown" ${this.config.orientation === 'upsidedown' ? 'selected' : ''}>Upside Down (180°)</option>
<option value="vertical-reverse" ${this.config.orientation === 'vertical-reverse' ? 'selected' : ''}>Vertical Reverse (270°)</option>
<option value="landscape" ${this.config.orientation === 'landscape' ? 'selected' : ''}>Landscape (2-column)</option>
</select>
</div>
<div class="config-option">
<label for="dark-mode-select">Dark Mode:</label>
<select id="dark-mode-select">
<option value="auto" ${this.config.darkMode === 'auto' ? 'selected' : ''}>Automatic (Sunset/Sunrise)</option>
<option value="on" ${this.config.darkMode === 'on' ? 'selected' : ''}>Always On</option>
<option value="off" ${this.config.darkMode === 'off' ? 'selected' : ''}>Always Off</option>
</select>
<div class="sun-times" id="sun-times">
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
</div>
</div>
</div>
// Clone the template content into the modal
modalContainer.appendChild(template.content.cloneNode(true));
<!-- Appearance Tab -->
<div class="config-tab-content" id="tab-appearance">
<div class="config-option">
<label for="background-image-url">Background Image:</label>
<input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}">
<div style="display: flex; gap: 10px; margin-top: 5px;">
<button id="test-image-button" style="padding: 5px 10px;">Use Test Image</button>
<label for="local-image-input" style="padding: 5px 10px; background-color: #ddd; border-radius: 4px; cursor: pointer;">
Select Local Image
</label>
<input type="file" id="local-image-input" accept="image/*" style="display: none;">
</div>
<div class="background-preview" id="background-preview">
${this.config.backgroundImage ? `<img src="${this.config.backgroundImage}" alt="Background preview">` : '<div class="no-image">No image selected</div>'}
</div>
</div>
<div class="config-option">
<label for="background-opacity">Background Opacity: <span id="opacity-value">${Math.round(this.config.backgroundOpacity * 100)}%</span></label>
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="${this.config.backgroundOpacity}">
</div>
</div>
// Set dynamic values from current config
modalContainer.querySelector('#orientation-select').value = this.config.orientation;
modalContainer.querySelector('#dark-mode-select').value = this.config.darkMode;
modalContainer.querySelector('#background-image-url').value = this.config.backgroundImage || '';
modalContainer.querySelector('#background-opacity').value = this.config.backgroundOpacity;
modalContainer.querySelector('#opacity-value').textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
modalContainer.querySelector('#combine-directions').checked = this.config.combineSameDirection;
<!-- Content Tab -->
<div class="config-tab-content" id="tab-content">
<div class="config-option">
<label>Transit Sites:</label>
<div style="margin-bottom: 15px;">
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" id="site-search-input" placeholder="Search for transit stop..." style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<button id="search-site-button" style="padding: 8px 15px; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer;">Search</button>
<button id="select-from-map-button" style="padding: 8px 15px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Select from Map</button>
</div>
<div id="site-search-results" style="display: none; max-height: 200px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: white; margin-top: 5px;"></div>
</div>
<div id="sites-container">
${this.generateSitesHTML()}
</div>
<div style="margin-top: 10px;">
<button id="add-site-button" style="padding: 5px 10px;">Add Site Manually</button>
</div>
</div>
</div>
// Populate sites
const sitesContainer = modalContainer.querySelector('#sites-container');
if (sitesContainer) {
sitesContainer.innerHTML = this.generateSitesHTML();
}
<!-- Options Tab -->
<div class="config-tab-content" id="tab-options">
<div class="config-option">
<label for="combine-directions">
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
Combine departures going in the same direction
</label>
</div>
</div>
</div>
<div class="config-modal-footer">
<button id="config-save-button">Save</button>
<button id="config-cancel-button">Cancel</button>
</div>
</div>
`;
// Update background preview
const preview = modalContainer.querySelector('#background-preview');
if (preview && this.config.backgroundImage) {
const img = document.createElement('img');
img.src = this.config.backgroundImage;
img.alt = 'Background preview';
preview.innerHTML = '';
preview.appendChild(img);
}
document.body.appendChild(modalContainer);
@@ -192,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', () => {
@@ -347,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();
}
/**
@@ -355,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');
@@ -549,32 +502,9 @@ class ConfigManager {
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
const overlay = document.createElement('div');
overlay.id = 'background-overlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundImage = `url(${this.config.backgroundImage})`;
overlay.style.backgroundSize = 'cover';
overlay.style.backgroundPosition = 'center';
overlay.style.opacity = this.config.backgroundOpacity;
overlay.style.zIndex = '-1';
overlay.style.pointerEvents = 'none';
// Adjust background rotation based on orientation
if (this.config.orientation === 'vertical') {
overlay.style.transform = 'rotate(90deg) scale(2)';
overlay.style.transformOrigin = 'center center';
} else if (this.config.orientation === 'upsidedown') {
overlay.style.transform = 'rotate(180deg) scale(1.5)';
overlay.style.transformOrigin = 'center center';
} else if (this.config.orientation === 'vertical-reverse') {
overlay.style.transform = 'rotate(270deg) scale(2)';
overlay.style.transformOrigin = 'center center';
} else {
overlay.style.transform = 'scale(1.2)';
overlay.style.transformOrigin = 'center center';
}
overlay.className = `orientation-${this.config.orientation}`;
// Insert as the first child of body
document.body.insertBefore(overlay, document.body.firstChild);
@@ -614,14 +544,14 @@ class ConfigManager {
return this.config.sites.map((site, index) => `
<div class="site-item" data-index="${index}">
<div style="display: flex; align-items: center; margin-bottom: 5px;">
<div class="config-site-flex">
<input type="checkbox" class="site-enabled" ${site.enabled ? 'checked' : ''}>
<input type="text" class="site-name" value="${site.name}" placeholder="Site Name" style="flex: 1; margin: 0 5px;">
<button class="remove-site-button" style="padding: 2px 5px;">×</button>
<input type="text" class="site-name config-site-name-input" value="${site.name}" placeholder="Site Name">
<button class="remove-site-button config-btn-remove">×</button>
</div>
<div style="display: flex; align-items: center;">
<span style="margin-right: 5px;">ID:</span>
<input type="text" class="site-id" value="${site.id}" placeholder="Site ID" style="width: 100px;">
<div class="config-site-id-row">
<span class="config-site-id-label">ID:</span>
<input type="text" class="site-id config-site-id-input" value="${site.id}" placeholder="Site ID">
</div>
</div>
`).join('');
@@ -644,7 +574,7 @@ class ConfigManager {
try {
resultsContainer.style.display = 'block';
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">Searching...</div>';
resultsContainer.textContent = 'Searching...';
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
@@ -656,35 +586,32 @@ class ConfigManager {
const data = await response.json();
if (!data.sites || data.sites.length === 0) {
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">No sites found. Try a different search term.</div>';
resultsContainer.textContent = 'No sites found. Try a different search term.';
return;
}
resultsContainer.innerHTML = data.sites.map(site => `
<div class="site-search-result" style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s;"
data-site-id="${site.id}" data-site-name="${site.name}">
<div style="font-weight: bold; color: #0061a1;">${site.name}</div>
<div style="font-size: 0.85em; color: #666;">ID: ${site.id}</div>
</div>
`).join('');
resultsContainer.innerHTML = '';
data.sites.forEach(site => {
const resultDiv = document.createElement('div');
resultDiv.className = 'site-search-result';
resultDiv.dataset.siteId = site.id;
resultDiv.dataset.siteName = site.name;
// Add click handlers to search results
resultsContainer.querySelectorAll('.site-search-result').forEach(result => {
result.addEventListener('click', () => {
const siteId = result.dataset.siteId;
const siteName = result.dataset.siteName;
this.addSiteFromSearch(siteId, siteName);
const nameDiv = document.createElement('div');
nameDiv.textContent = site.name;
const idDiv = document.createElement('div');
idDiv.textContent = `ID: ${site.id}`;
resultDiv.appendChild(nameDiv);
resultDiv.appendChild(idDiv);
resultDiv.addEventListener('click', () => {
this.addSiteFromSearch(site.id, site.name);
searchInput.value = '';
resultsContainer.style.display = 'none';
});
result.addEventListener('mouseenter', () => {
result.style.backgroundColor = '#f5f5f5';
});
result.addEventListener('mouseleave', () => {
result.style.backgroundColor = 'white';
});
resultsContainer.appendChild(resultDiv);
});
} catch (error) {
@@ -698,7 +625,7 @@ class ConfigManager {
errorMessage = `Server error: ${error.message}`;
}
resultsContainer.innerHTML = `<div style="padding: 10px; text-align: center; color: #d32f2f;">Error: ${errorMessage}</div>`;
resultsContainer.textContent = `Error: ${errorMessage}`;
}
}
@@ -782,77 +709,37 @@ class ConfigManager {
if (e.target === mapModal) closeMap();
});
// Load transit stops - search for common Stockholm areas
const loadSitesOnMap = async () => {
try {
// Start with focused search to avoid too many markers
const searchTerms = ['Ambassaderna'];
const allSites = new Map();
// Load nearby transit stops based on map center
const markersLayer = L.layerGroup().addTo(map);
const loadedSiteIds = new Set();
const loadNearbySites = async () => {
const center = map.getCenter();
const zoom = map.getZoom();
// Scale radius based on zoom: wider view = larger radius
const radius = zoom >= 15 ? 500 : zoom >= 13 ? 1500 : zoom >= 11 ? 4000 : 8000;
for (const term of searchTerms) {
try {
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(term)}`);
const response = await fetch(`/api/sites/nearby?lat=${center.lat}&lon=${center.lng}&radius=${radius}`);
const data = await response.json();
console.log(`Search "${term}" returned:`, data);
if (data.sites) {
data.sites.forEach(site => {
// Only add sites with valid coordinates from API
const lat = site.lat || site.latitude;
const lon = site.lon || site.longitude;
if (loadedSiteIds.has(site.id)) return;
const lat = parseFloat(site.lat);
const lon = parseFloat(site.lon);
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
if (lat && lon && !isNaN(parseFloat(lat)) && !isNaN(parseFloat(lon))) {
if (!allSites.has(site.id)) {
allSites.set(site.id, {
id: site.id,
name: site.name,
lat: parseFloat(lat),
lon: parseFloat(lon)
});
}
} else {
console.log(`Site ${site.id} (${site.name}) missing coordinates, skipping`);
}
});
}
} catch (err) {
console.error(`Error searching for ${term}:`, err);
}
}
loadedSiteIds.add(site.id);
// Add known site with coordinates as fallback
const knownSites = [
{ id: '1411', name: 'Ambassaderna', lat: 59.3293, lon: 18.0686 }
];
knownSites.forEach(site => {
if (!allSites.has(site.id)) {
allSites.set(site.id, site);
}
});
const sitesArray = Array.from(allSites.values());
console.log(`Loading ${sitesArray.length} sites on map with coordinates:`, sitesArray);
if (sitesArray.length > 0) {
const markers = [];
sitesArray.forEach(site => {
const lat = site.lat;
const lon = site.lon;
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
console.warn(`Invalid coordinates for site ${site.id}, skipping`);
return;
}
// Create custom icon
const customIcon = L.divIcon({
className: 'custom-marker',
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);">🚌</div>`,
html: '<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);">&#x1F68C;</div>',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([lat, lon], { icon: customIcon }).addTo(map);
const marker = L.marker([lat, lon], { icon: customIcon });
const popupContent = `
<div style="min-width: 200px;">
@@ -866,36 +753,18 @@ class ConfigManager {
`;
marker.bindPopup(popupContent);
markers.push(marker);
// Make marker clickable to open popup
marker.on('click', function() {
this.openPopup();
markersLayer.addLayer(marker);
});
});
// Fit map to show all markers, or center on first marker
if (markers.length > 0) {
if (markers.length === 1) {
map.setView(markers[0].getLatLng(), 15);
} else {
const group = new L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.1));
}
} else {
// If no markers, show message
console.log('No sites with coordinates found');
}
console.log(`Loaded ${data.sites.length} nearby sites (${loadedSiteIds.size} total on map)`);
}
} catch (error) {
console.error('Error loading sites on map:', error);
console.error('Error loading nearby sites:', error);
}
};
// Load sites after map is initialized
setTimeout(() => {
loadSitesOnMap();
}, 500);
// Load sites on init and when map is panned/zoomed
setTimeout(() => loadNearbySites(), 300);
map.on('moveend', loadNearbySites);
// Handle site selection from map popup - use event delegation on the modal
mapModal.addEventListener('click', (e) => {
@@ -947,73 +816,42 @@ class ConfigManager {
const data = await response.json();
if (data.sites && data.sites.length > 0) {
// Clear existing markers
map.eachLayer((layer) => {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
// Clear existing markers and reset tracking
markersLayer.clearLayers();
loadedSiteIds.clear();
// Clear existing markers
map.eachLayer((layer) => {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
// Add markers for search results
const markers = [];
const searchMarkers = [];
data.sites.forEach(site => {
let lat = site.lat || site.latitude;
let lon = site.lon || site.longitude;
const lat = parseFloat(site.lat);
const lon = parseFloat(site.lon);
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
// If no coordinates, use approximate location based on map center
if (!lat || !lon) {
const center = map.getCenter();
lat = center.lat + (Math.random() - 0.5) * 0.05;
lon = center.lon + (Math.random() - 0.5) * 0.05;
}
loadedSiteIds.add(site.id);
// Create custom icon
const customIcon = L.divIcon({
className: 'custom-transit-marker',
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.4); font-size: 18px;">🚌</div>`,
className: 'custom-marker',
html: '<div style="background-color: #28a745; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.4); font-size: 18px;">&#x1F68C;</div>',
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16]
iconAnchor: [16, 16]
});
const marker = L.marker([lat, lon], {
icon: customIcon,
title: site.name
}).addTo(map);
const popupContent = document.createElement('div');
popupContent.style.minWidth = '220px';
popupContent.innerHTML = `
<div style="margin-bottom: 8px;">
<strong style="font-size: 1.1em; color: #0061a1; display: block; margin-bottom: 4px;">${site.name}</strong>
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span>
</div>
<button class="select-site-from-map-btn"
data-site-id="${site.id}"
data-site-name="${site.name}"
style="margin-top: 8px; padding: 10px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1em;">
const marker = L.marker([lat, lon], { icon: customIcon });
marker.bindPopup(`
<div style="min-width: 200px;">
<strong style="font-size: 1.1em; color: #0061a1;">${site.name}</strong><br>
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span><br>
<button class="select-site-from-map" data-site-id="${site.id}" data-site-name="${site.name}"
style="margin-top: 8px; padding: 8px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
Select This Stop
</button>
`;
marker.bindPopup(popupContent);
markers.push(marker);
marker.on('click', function() {
this.openPopup();
});
</div>
`);
markersLayer.addLayer(marker);
searchMarkers.push(marker);
});
// Fit map to show results
if (markers.length > 0) {
const group = new L.featureGroup(markers);
if (searchMarkers.length > 0) {
const group = new L.featureGroup(searchMarkers);
map.fitBounds(group.getBounds().pad(0.1));
}
}
@@ -1159,5 +997,8 @@ class ConfigManager {
}
}
// Export the ConfigManager class for use in other modules
// ES module export
export { ConfigManager };
// Keep window reference for backward compatibility
window.ConfigManager = ConfigManager;

View File

@@ -119,16 +119,16 @@ class DeparturesManager {
if (diffMinutes <= 0) {
return 'Nu';
} else if (diffMinutes === 1) {
return 'In 1 minute';
return 'Om 1 minut';
} else if (diffMinutes < 60) {
return `In ${diffMinutes} minutes`;
return `Om ${diffMinutes} minuter`;
} else {
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
if (minutes === 0) {
return `In ${hours} hour${hours > 1 ? 's' : ''}`;
return `Om ${hours} timm${hours > 1 ? 'ar' : 'e'}`;
} else {
return `In ${hours} hour${hours > 1 ? 's' : ''} and ${minutes} minute${minutes > 1 ? 's' : ''}`;
return `Om ${hours} timm${hours > 1 ? 'ar' : 'e'} och ${minutes} minut${minutes > 1 ? 'er' : ''}`;
}
}
}
@@ -223,19 +223,15 @@ class DeparturesManager {
let countdownText = displayTime;
let countdownClass = '';
const urgentThreshold = window.Constants?.TIME_THRESHOLDS?.URGENT || 5;
if (minutesUntil <= 0 || displayTime === 'Nu' || displayTime.toLowerCase() === 'nu') {
countdownText = 'Nu';
countdownClass = 'now';
} else if (minutesUntil < urgentThreshold) {
const minMatch = displayTime.match(/(\d+)\s*min/i);
if (minMatch) {
} else if (minutesUntil <= 2) {
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
} else {
countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
}
countdownClass = 'urgent';
} else if (minutesUntil <= 5) {
countdownText = `${minutesUntil} min`;
countdownClass = 'soon';
} else {
const isTimeOnly = /^\d{1,2}:\d{2}$/.test(displayTime);
if (isTimeOnly) {
@@ -248,6 +244,8 @@ class DeparturesManager {
return { countdownText, countdownClass };
}
// --- Legacy single-site display methods (fallback if API returns data.departures instead of data.sites) ---
/**
* Create a departure card element (legacy format)
* @param {Object} departure - Departure object
@@ -363,35 +361,25 @@ class DeparturesManager {
timesContainer.className = 'times-container';
const firstDeparture = direction.departures[0];
const secondDeparture = direction.departures[1];
if (firstDeparture) {
const displayTime = firstDeparture.display;
const departureTime = this.getDepartureTime(firstDeparture);
const timeDisplay = DeparturesManager.formatDateTime(departureTime);
const { countdownText, countdownClass } = this.getCountdownInfo(firstDeparture);
const timeDisplayElement = document.createElement('div');
timeDisplayElement.className = 'time-display';
// Primary countdown - big and prominent
const countdownSpan = document.createElement('span');
countdownSpan.className = `countdown-large ${countdownClass}`;
countdownSpan.textContent = countdownText;
timesContainer.appendChild(countdownSpan);
timeDisplayElement.appendChild(countdownSpan);
const timeRangeSpan = document.createElement('span');
timeRangeSpan.className = 'time-range';
if (secondDeparture) {
const secondTime = DeparturesManager.formatDateTime(this.getDepartureTime(secondDeparture));
timeRangeSpan.textContent = `${timeDisplay} - ${secondTime}`;
} else {
timeRangeSpan.textContent = timeDisplay;
// Next departures - separate line, excludes current departure
const upcomingTimes = direction.departures.slice(1, 4)
.map(d => DeparturesManager.formatDateTime(this.getDepartureTime(d)));
if (upcomingTimes.length > 0) {
const nextTimesDiv = document.createElement('div');
nextTimesDiv.className = 'next-departures';
nextTimesDiv.textContent = 'Sedan: ' + upcomingTimes.join(' ');
timesContainer.appendChild(nextTimesDiv);
}
timeDisplayElement.appendChild(timeRangeSpan);
timesContainer.appendChild(timeDisplayElement);
}
directionRow.appendChild(timesContainer);
@@ -403,8 +391,11 @@ class DeparturesManager {
});
}
// --- Legacy single-site display methods (continued) ---
/**
* Display departures in the UI
* Display departures in the UI (legacy single-site path)
* Called when API returns data.departures instead of data.sites.
* @param {Array} departures - Array of departure objects
*/
displayDepartures(departures) {
@@ -424,11 +415,11 @@ class DeparturesManager {
this.updateExistingCards(departures);
}
this.currentDepartures = JSON.parse(JSON.stringify(departures));
this.currentDepartures = structuredClone(departures);
}
/**
* Update existing cards or add new ones
* Update existing cards or add new ones (legacy single-site path)
* @param {Array} newDepartures - Array of new departure objects
*/
updateExistingCards(newDepartures) {
@@ -444,7 +435,7 @@ class DeparturesManager {
this.updateCardContent(existingCard, departure);
} else {
const newCard = this.createDepartureCard(departure);
newCard.style.opacity = '0';
newCard.classList.add('card-entering');
if (index === 0) {
this.container.prepend(newCard);
@@ -454,29 +445,27 @@ class DeparturesManager {
this.container.insertBefore(newCard, this.container.children[index]);
}
setTimeout(() => {
newCard.style.transition = 'opacity 0.5s ease-in';
newCard.style.opacity = '1';
}, 10);
requestAnimationFrame(() => {
newCard.classList.add('card-visible');
});
}
});
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
currentCards.forEach(card => {
if (!newDepartureIds.includes(card.dataset.journeyId)) {
card.style.transition = 'opacity 0.5s ease-out';
card.style.opacity = '0';
setTimeout(() => {
if (card.parentNode) {
card.parentNode.removeChild(card);
}
}, 500);
card.classList.add('card-leaving');
card.addEventListener('transitionend', () => {
card.remove();
}, { once: true });
// Fallback removal if transitionend doesn't fire
setTimeout(() => card.remove(), 600);
}
});
}
/**
* Update card content
* Update card content (legacy single-site path)
* @param {HTMLElement} card - Card element
* @param {Object} departure - Departure object
*/
@@ -485,12 +474,10 @@ class DeparturesManager {
const countdownElement = card.querySelector('.countdown');
if (countdownElement) {
countdownElement.classList.remove('now', 'urgent');
countdownElement.classList.remove('now', 'urgent', 'soon');
if (countdownClass === 'now') {
countdownElement.classList.add('now');
} else if (countdownClass === 'urgent') {
countdownElement.classList.add('urgent');
if (countdownClass) {
countdownElement.classList.add(countdownClass);
}
if (countdownElement.textContent !== `(${countdownText})`) {
@@ -500,18 +487,17 @@ class DeparturesManager {
}
}
// --- End of legacy single-site display methods ---
/**
* Add highlight effect to element
* @param {HTMLElement} element - Element to highlight
*/
highlightElement(element) {
element.style.transition = 'none';
element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
setTimeout(() => {
element.style.transition = 'background-color 1.5s ease-out';
element.style.backgroundColor = 'transparent';
}, 10);
element.classList.remove('highlight-flash');
// Force reflow to restart animation
void element.offsetWidth;
element.classList.add('highlight-flash');
}
/**
@@ -522,32 +508,80 @@ class DeparturesManager {
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);
}
/**
@@ -591,13 +625,14 @@ class DeparturesManager {
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);
}
}
}
@@ -632,5 +667,8 @@ class DeparturesManager {
}
}
// Export the class
// ES module export
export { DeparturesManager };
// Keep window reference for backward compatibility
window.DeparturesManager = DeparturesManager;

View File

@@ -0,0 +1,84 @@
/**
* NewsTicker - Scrolling news/announcement ticker for landscape kiosk mode
* Fetches from /api/ticker with fallback to hardcoded messages
*/
class NewsTicker {
constructor(options = {}) {
this.options = {
containerId: options.containerId || 'news-ticker',
fetchUrl: options.fetchUrl || '/api/ticker',
refreshInterval: options.refreshInterval || 5 * 60 * 1000, // 5 minutes
fallbackMessages: options.fallbackMessages || [
'Välkommen till Ambassaderna',
'Håll dörren stängd',
'Tvättstugan stänger kl 22:00'
],
...options
};
this.container = null;
this.contentEl = null;
this.messages = [];
this.refreshTimer = null;
this.init();
}
init() {
this.container = document.getElementById(this.options.containerId);
if (!this.container) return;
this.contentEl = this.container.querySelector('.ticker-content');
if (!this.contentEl) {
this.contentEl = document.createElement('div');
this.contentEl.className = 'ticker-content';
this.container.appendChild(this.contentEl);
}
this.fetchMessages();
this.refreshTimer = setInterval(() => this.fetchMessages(), this.options.refreshInterval);
}
async fetchMessages() {
try {
const response = await fetch(this.options.fetchUrl);
if (response.ok) {
const data = await response.json();
if (Array.isArray(data.messages) && data.messages.length > 0) {
this.messages = data.messages;
} else {
this.messages = this.options.fallbackMessages;
}
} else {
this.messages = this.options.fallbackMessages;
}
} catch {
this.messages = this.options.fallbackMessages;
}
this.render();
}
render() {
if (!this.contentEl || this.messages.length === 0) return;
const separator = ' \u2022 '; // bullet separator
const text = this.messages.join(separator);
// Duplicate text for seamless infinite scroll loop
this.contentEl.textContent = text + separator + text;
}
stop() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
}
// ES module export
export { NewsTicker };
// Keep window reference for backward compatibility
window.NewsTicker = NewsTicker;

View File

@@ -5,10 +5,28 @@
*/
class WeatherManager {
static WEATHER_CONDITIONS_SV = {
'Clear': 'Klart',
'Clouds': 'Molnigt',
'Rain': 'Regn',
'Drizzle': 'Duggregn',
'Thunderstorm': 'Åska',
'Snow': 'Snö',
'Mist': 'Dimma',
'Fog': 'Dimma',
'Haze': 'Dis',
'Smoke': 'Rök',
'Dust': 'Damm',
'Sand': 'Sand',
'Ash': 'Aska',
'Squall': 'Byar',
'Tornado': 'Tornado'
};
constructor(options = {}) {
// Default options
// Get API key from options, window (injected by server from .env), or fallback
const apiKey = options.apiKey || window.OPENWEATHERMAP_API_KEY || '4d8fb5b93d4af21d66a2948710284366';
const apiKey = options.apiKey || window.OPENWEATHERMAP_API_KEY || '';
this.options = {
latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude
@@ -24,6 +42,7 @@ class WeatherManager {
this.sunTimes = null;
this.isDarkMode = false;
this.lastUpdated = null;
this.daylightBarUpdateInterval = null;
// Initialize
this.init();
@@ -34,6 +53,23 @@ class WeatherManager {
*/
async init() {
try {
// Check for API key
if (!this.options.apiKey) {
console.warn('WeatherManager: No OpenWeatherMap API key configured. Set OPENWEATHERMAP_API_KEY in your .env file.');
const weatherContainer = document.getElementById('custom-weather');
if (weatherContainer) {
const warningEl = document.createElement('div');
warningEl.style.cssText = 'padding: 10px; color: #c41e3a; font-size: 0.9em; text-align: center;';
warningEl.textContent = 'Weather unavailable: No API key configured. Set OPENWEATHERMAP_API_KEY in .env';
weatherContainer.prepend(warningEl);
}
// Still set up sun times from calculation so dark mode works
await this.updateSunTimesFromCalculation();
this.updateDarkModeBasedOnTime();
this.dispatchDarkModeEvent();
return;
}
// Fetch weather data
await this.fetchWeatherData();
@@ -83,8 +119,8 @@ class WeatherManager {
*/
async fetchWeatherData() {
try {
// Fetch current weather
const currentWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&appid=${this.options.apiKey}`;
// Fetch current weather (lang=se for Swedish descriptions)
const currentWeatherUrl = `https://api.openweathermap.org/data/2.5/weather?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&lang=se&appid=${this.options.apiKey}`;
const currentWeatherResponse = await fetch(currentWeatherUrl);
const currentWeatherData = await currentWeatherResponse.json();
@@ -92,8 +128,8 @@ class WeatherManager {
throw new Error(`API Error: ${currentWeatherData.message}`);
}
// Fetch hourly forecast
const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&appid=${this.options.apiKey}`;
// Fetch 3-hour interval forecast (cnt=8 limits to ~24h of data)
const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?lat=${this.options.latitude}&lon=${this.options.longitude}&units=metric&lang=se&cnt=8&appid=${this.options.apiKey}`;
const forecastResponse = await fetch(forecastUrl);
const forecastData = await forecastResponse.json();
@@ -107,7 +143,7 @@ class WeatherManager {
this.lastUpdated = new Date();
// Extract sunrise and sunset times from the API response
this.updateSunTimesFromApi(currentWeatherData);
await this.updateSunTimesFromApi(currentWeatherData);
// Update the UI with the new data
this.updateWeatherUI();
@@ -158,7 +194,7 @@ class WeatherManager {
* Process forecast data from API response
*/
processForecast(data) {
// Get the next 7 forecasts (covering about 24 hours)
// Get the next 7 forecast periods (3-hour intervals, covering ~21 hours)
return data.list.slice(0, 7).map(item => {
const iconCode = item.weather[0].icon;
return {
@@ -177,43 +213,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';
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);
}
/**
* 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');
}
/**
@@ -224,7 +259,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,
@@ -255,7 +290,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
@@ -280,25 +315,15 @@ class WeatherManager {
const conditionElement = document.querySelector('#custom-weather .weather-icon div');
if (conditionElement) {
conditionElement.textContent = this.weatherData.condition;
conditionElement.textContent = WeatherManager.WEATHER_CONDITIONS_SV[this.weatherData.condition] || this.weatherData.condition;
}
const iconElement = document.querySelector('#custom-weather .weather-icon img');
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');
@@ -320,15 +345,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 = `
<div class="time">Nu</div>
<div class="icon"></div>
@@ -349,15 +366,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 = `
<div class="time">${timeString}</div>
<div class="icon"></div>
@@ -373,34 +382,85 @@ class WeatherManager {
if (sunTimesElement && this.sunTimes) {
const sunriseTime = this.formatTime(this.sunTimes.today.sunrise);
const sunsetTime = this.formatTime(this.sunTimes.today.sunset);
sunTimesElement.textContent = `☀️ Sunrise: ${sunriseTime} | 🌙 Sunset: ${sunsetTime}`;
sunTimesElement.textContent = `☀️ Soluppgång: ${sunriseTime} | 🌙 Solnedgång: ${sunsetTime}`;
}
// Update daylight hours bar
if (this.sunTimes) {
this.updateDaylightHoursBar();
}
// Update compact weather bar (landscape mode)
this.renderCompactWeatherBar();
} catch (error) {
console.error('Error updating weather UI:', error);
}
}
/**
* Render compact weather bar for landscape mode
* Shows: [icon] temp condition 💨 wind | ☀️ Sol ↑ HH:MM 🌙 Sol ↓ HH:MM
*/
renderCompactWeatherBar() {
const bar = document.getElementById('compact-weather-bar');
if (!bar || !this.weatherData) return;
bar.textContent = '';
const icon = document.createElement('img');
icon.className = 'weather-bar-icon';
icon.src = this.weatherData.icon || '';
icon.alt = this.weatherData.condition || '';
bar.appendChild(icon);
const tempSpan = document.createElement('span');
const strong = document.createElement('strong');
strong.textContent = `${this.weatherData.temperature}\u00B0C`;
tempSpan.appendChild(strong);
const conditionSv = WeatherManager.WEATHER_CONDITIONS_SV[this.weatherData.condition] || this.weatherData.condition;
tempSpan.appendChild(document.createTextNode(` ${conditionSv || ''}`));
bar.appendChild(tempSpan);
// Wind speed
if (this.weatherData.wind) {
const windSpan = document.createElement('span');
windSpan.textContent = `💨 ${this.weatherData.wind.speed} km/h`;
bar.appendChild(windSpan);
}
const sep = document.createElement('span');
sep.className = 'weather-bar-sep';
sep.textContent = '|';
bar.appendChild(sep);
let sunriseStr = '--:--';
let sunsetStr = '--:--';
if (this.sunTimes) {
sunriseStr = this.formatTime(this.sunTimes.today.sunrise);
sunsetStr = this.formatTime(this.sunTimes.today.sunset);
}
const sunSpan = document.createElement('span');
sunSpan.textContent = `☀️ Sol ↑ ${sunriseStr} 🌙 Sol ↓ ${sunsetStr}`;
bar.appendChild(sunSpan);
}
/**
* Update sunrise and sunset times from API data
*/
updateSunTimesFromApi(data) {
async updateSunTimesFromApi(data) {
if (!data || !data.sys || !data.sys.sunrise || !data.sys.sunset) {
console.warn('No sunrise/sunset data in API response, using calculated times');
this.updateSunTimesFromCalculation();
await this.updateSunTimesFromCalculation();
return;
}
try {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Create Date objects from Unix timestamps
// Create Date objects from Unix timestamps for today
const sunrise = new Date(data.sys.sunrise * 1000);
const sunset = new Date(data.sys.sunset * 1000);
// Use calculated times for tomorrow
const tomorrowTimes = this.calculateSunTimes(tomorrow);
// Fetch tomorrow's times from sunrise-sunset.org API
const tomorrowTimes = await this.fetchSunTimes('tomorrow');
this.sunTimes = {
today: { sunrise, sunset },
@@ -411,92 +471,62 @@ class WeatherManager {
return this.sunTimes;
} catch (error) {
console.error('Error updating sun times from API:', error);
this.updateSunTimesFromCalculation();
await this.updateSunTimesFromCalculation();
}
}
/**
* Update sunrise and sunset times using calculation
* Update sunrise and sunset times using sunrise-sunset.org API
* Falls back to hardcoded defaults if the API is unreachable
*/
async updateSunTimesFromCalculation() {
try {
// Calculate sun times based on date and location
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const [todayData, tomorrowData] = await Promise.all([
this.fetchSunTimes('today'),
this.fetchSunTimes('tomorrow')
]);
this.sunTimes = {
today: this.calculateSunTimes(today),
tomorrow: this.calculateSunTimes(tomorrow)
today: todayData,
tomorrow: tomorrowData
};
console.log('Sun times updated from calculation:', this.sunTimes);
console.log('Sun times updated from sunrise-sunset.org:', this.sunTimes);
return this.sunTimes;
} catch (error) {
console.error('Error updating sun times from calculation:', error);
// Fallback to default times if calculation fails
console.error('Error fetching sun times from API, using defaults:', error);
const defaultSunrise = new Date();
defaultSunrise.setHours(6, 45, 0, 0);
defaultSunrise.setHours(7, 0, 0, 0);
const defaultSunset = new Date();
defaultSunset.setHours(17, 32, 0, 0);
defaultSunset.setHours(16, 0, 0, 0);
this.sunTimes = {
today: {
sunrise: defaultSunrise,
sunset: defaultSunset
},
tomorrow: {
sunrise: defaultSunrise,
sunset: defaultSunset
}
today: { sunrise: defaultSunrise, sunset: defaultSunset },
tomorrow: { sunrise: defaultSunrise, sunset: defaultSunset }
};
return this.sunTimes;
}
}
/**
* Calculate sunrise and sunset times for a given date
* Uses a simplified algorithm
* Fetch sunrise/sunset times from sunrise-sunset.org API
* @param {string} date - 'today', 'tomorrow', or YYYY-MM-DD
* @returns {Object} { sunrise: Date, sunset: Date }
*/
calculateSunTimes(date) {
// This is a simplified calculation
// For more accuracy, you would use a proper astronomical calculation
async fetchSunTimes(date) {
const url = `https://api.sunrise-sunset.org/json?lat=${this.options.latitude}&lng=${this.options.longitude}&date=${date}&formatted=0`;
const response = await fetch(url);
const data = await response.json();
// Get day of year
const start = new Date(date.getFullYear(), 0, 0);
const diff = date - start;
const oneDay = 1000 * 60 * 60 * 24;
const dayOfYear = Math.floor(diff / oneDay);
if (data.status !== 'OK') {
throw new Error(`Sunrise-sunset API returned status: ${data.status}`);
}
// Calculate sunrise and sunset times based on latitude and day of year
// This is a very simplified model
const latitude = this.options.latitude;
// Base sunrise and sunset times (in hours)
let baseSunrise = 6; // 6 AM
let baseSunset = 18; // 6 PM
// Adjust for latitude and season
// Northern hemisphere seasonal adjustment
const seasonalAdjustment = Math.sin((dayOfYear - 81) / 365 * 2 * Math.PI) * 3;
// Latitude adjustment (higher latitudes have more extreme day lengths)
const latitudeAdjustment = Math.abs(latitude) / 90 * 2;
// Apply adjustments
baseSunrise += seasonalAdjustment * latitudeAdjustment * -1;
baseSunset += seasonalAdjustment * latitudeAdjustment;
// Create Date objects
const sunrise = new Date(date);
sunrise.setHours(Math.floor(baseSunrise), Math.round((baseSunrise % 1) * 60), 0, 0);
const sunset = new Date(date);
sunset.setHours(Math.floor(baseSunset), Math.round((baseSunset % 1) * 60), 0, 0);
return { sunrise, sunset };
return {
sunrise: new Date(data.results.sunrise),
sunset: new Date(data.results.sunset)
};
}
/**
@@ -588,7 +618,146 @@ class WeatherManager {
if (!this.lastUpdated) return 'Never';
return this.formatTime(this.lastUpdated);
}
/**
* Render the daylight hours bar with gradient and current hour indicator
*/
renderDaylightHoursBar() {
if (!this.sunTimes) return;
const barElement = document.getElementById('daylight-hours-bar');
const backgroundElement = barElement?.querySelector('.daylight-bar-background');
const indicatorElement = barElement?.querySelector('.daylight-bar-indicator');
if (!barElement || !backgroundElement || !indicatorElement) return;
const today = this.sunTimes.today;
// Normalize sunrise and sunset to today's date for consistent calculation
const now = new Date();
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const sunrise = new Date(todayDate);
sunrise.setHours(today.sunrise.getHours(), today.sunrise.getMinutes(), 0, 0);
const sunset = new Date(todayDate);
sunset.setHours(today.sunset.getHours(), today.sunset.getMinutes(), 0, 0);
// Calculate positions as percentage of 24 hours (1440 minutes)
// Extract hours and minutes from the date objects
const getTimePosition = (date) => {
const hours = date.getHours();
const minutes = date.getMinutes();
const totalMinutes = hours * 60 + minutes;
return (totalMinutes / 1440) * 100;
};
const sunrisePosition = getTimePosition(sunrise);
const sunsetPosition = getTimePosition(sunset);
const currentPosition = getTimePosition(now);
// Ensure positions are valid (0-100)
const clampPosition = (pos) => Math.max(0, Math.min(100, pos));
const sunrisePos = clampPosition(sunrisePosition);
const sunsetPos = clampPosition(sunsetPosition);
const currentPos = clampPosition(currentPosition);
// Create modern gradient for daylight hours with smooth transitions
// Multiple color stops for a more sophisticated gradient effect
let gradient = '';
// Handle case where sunrise is before sunset (normal day)
if (sunrisePos < sunsetPos) {
// Create gradient with smooth transitions:
// - Midnight blue (night) -> dark blue -> orange/red (dawn) -> yellow (day) -> orange/red (dusk) -> dark blue -> midnight blue (night)
const dawnStart = Math.max(0, sunrisePos - 2);
const dawnEnd = Math.min(100, sunrisePos + 1);
const duskStart = Math.max(0, sunsetPos - 1);
const duskEnd = Math.min(100, sunsetPos + 2);
gradient = `linear-gradient(to right,
#191970 0%,
#191970 ${dawnStart}%,
#2E3A87 ${dawnStart}%,
#FF6B35 ${dawnEnd}%,
#FFD93D ${Math.min(100, dawnEnd + 1)}%,
#FFEB3B ${Math.min(100, dawnEnd + 1)}%,
#FFEB3B ${duskStart}%,
#FFD93D ${duskStart}%,
#FF6B35 ${Math.max(0, duskEnd - 1)}%,
#2E3A87 ${duskEnd}%,
#191970 ${duskEnd}%,
#191970 100%)`;
} else {
// Handle edge cases (polar day/night or sunrise after sunset near midnight)
// For simplicity, show all as night (midnight blue)
gradient = 'linear-gradient(to right, #191970 0%, #191970 100%)';
}
// Apply gradient to background
backgroundElement.style.backgroundImage = gradient;
// Determine if it's day or night for icon
const isDaytime = currentPos >= sunrisePos && currentPos <= sunsetPos;
const iconElement = indicatorElement.querySelector('.sun-icon, .moon-icon');
if (iconElement) {
iconElement.textContent = isDaytime ? '☀️' : '🌙';
// Update classes to match the icon for proper styling
if (isDaytime) {
iconElement.classList.remove('moon-icon');
iconElement.classList.add('sun-icon');
} else {
iconElement.classList.remove('sun-icon');
iconElement.classList.add('moon-icon');
}
}
// Position current hour indicator
// 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:', {
sunrise: `${today.sunrise.getHours()}:${today.sunrise.getMinutes().toString().padStart(2, '0')}`,
sunset: `${today.sunset.getHours()}:${today.sunset.getMinutes().toString().padStart(2, '0')}`,
current: `${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`,
sunrisePos: `${sunrisePos.toFixed(1)}%`,
sunsetPos: `${sunsetPos.toFixed(1)}%`,
currentPos: `${currentPos.toFixed(1)}%`
});
}
/**
* Update daylight hours bar and set up interval for current hour updates
*/
updateDaylightHoursBar() {
// Render the bar immediately
this.renderDaylightHoursBar();
// Clear existing interval if any
if (this.daylightBarUpdateInterval) {
clearInterval(this.daylightBarUpdateInterval);
}
// Update current hour position every minute
this.daylightBarUpdateInterval = setInterval(() => {
this.renderDaylightHoursBar();
}, 60000); // Update every minute
}
}
// Export the WeatherManager class for use in other modules
// ES module export
export { WeatherManager };
// Keep window reference for backward compatibility
window.WeatherManager = WeatherManager;

View File

@@ -3,16 +3,20 @@
* Initializes all components when the DOM is ready
*/
import { Constants } from './utils/constants.js';
import { logger } from './utils/logger.js';
import { ConfigManager } from './components/ConfigManager.js';
import { Clock } from './components/Clock.js';
import { WeatherManager } from './components/WeatherManager.js';
import { DeparturesManager } from './components/DeparturesManager.js';
import { NewsTicker } from './components/NewsTicker.js';
/**
* Function to ensure content wrapper exists for rotated orientations
*/
function ensureContentWrapper() {
if (!document.getElementById('content-wrapper')) {
if (window.logger) {
window.logger.info('Creating content wrapper');
} else {
console.log('Creating content wrapper');
}
logger.info('Creating content wrapper');
const wrapper = document.createElement('div');
wrapper.id = 'content-wrapper';
@@ -40,28 +44,18 @@ function ensureContentWrapper() {
// Initialize components when the DOM is loaded
document.addEventListener('DOMContentLoaded', async function() {
if (window.logger) {
window.logger.info('DOM fully loaded');
} else {
console.log('DOM fully loaded');
}
logger.info('DOM fully loaded');
try {
// Initialize ConfigManager first
if (window.logger) {
window.logger.info('Creating ConfigManager...');
} else {
console.log('Creating ConfigManager...');
}
logger.info('Creating ConfigManager...');
window.configManager = new ConfigManager({
defaultOrientation: 'normal',
defaultDarkMode: 'auto'
});
// Note: ConfigManager already creates the config button and modal
// Initialize Clock
const timezone = window.Constants?.TIMEZONE || 'Europe/Stockholm';
const timezone = Constants.TIMEZONE || 'Europe/Stockholm';
window.clock = new Clock({
elementId: 'clock',
timezone: timezone
@@ -69,25 +63,22 @@ document.addEventListener('DOMContentLoaded', async function() {
// Initialize WeatherManager with location from window config or constants
const defaultLat = window.DEFAULT_LOCATION?.latitude ||
(window.Constants?.DEFAULT_LOCATION?.LATITUDE) || 59.3293;
Constants.DEFAULT_LOCATION.LATITUDE || 59.3293;
const defaultLon = window.DEFAULT_LOCATION?.longitude ||
(window.Constants?.DEFAULT_LOCATION?.LONGITUDE) || 18.0686;
Constants.DEFAULT_LOCATION.LONGITUDE || 18.0686;
window.weatherManager = new WeatherManager({
latitude: defaultLat,
longitude: defaultLon
});
// Initialize departures - use DeparturesManager
if (typeof DeparturesManager !== 'undefined') {
// Initialize DeparturesManager
window.departuresManager = new DeparturesManager({
containerId: 'departures',
statusId: 'status',
lastUpdatedId: 'last-updated'
});
} else if (typeof initDepartures === 'function') {
// Fallback to legacy function if DeparturesManager not available
initDepartures();
}
// NewsTicker disabled - ticker removed from UI
// Set up event listeners
document.addEventListener('darkModeChanged', event => {
@@ -103,17 +94,9 @@ document.addEventListener('DOMContentLoaded', async function() {
// Ensure content wrapper exists initially
ensureContentWrapper();
if (window.logger) {
window.logger.info('All components initialized successfully');
} else {
console.log('All components initialized successfully');
}
logger.info('All components initialized successfully');
} catch (error) {
if (window.logger) {
window.logger.error('Error during initialization:', error);
} else {
console.error('Error during initialization:', error);
}
logger.error('Error during initialization:', error);
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = `Initialization error: ${error.message}`;

View File

@@ -24,7 +24,7 @@ const Constants = {
// Refresh intervals (in milliseconds)
REFRESH: {
DEPARTURES: 5000, // 5 seconds
DEPARTURES: 30000, // 30 seconds
WEATHER: 30 * 60 * 1000, // 30 minutes
DARK_MODE_CHECK: 60000 // 1 minute
},
@@ -85,5 +85,8 @@ const Constants = {
}
};
// Export constants
// ES module export
export { Constants };
// Keep window reference for backward compatibility with inline scripts
window.Constants = Constants;

View File

@@ -95,6 +95,9 @@ class Logger {
// Create a singleton instance
const logger = new Logger();
// Export both the class and the singleton instance
// ES module export
export { Logger, logger };
// Keep window reference for backward compatibility
window.Logger = Logger;
window.logger = logger;

View File

@@ -2,7 +2,6 @@
require('dotenv').config();
const http = require('http');
const url = require('url');
const fs = require('fs');
const path = require('path');
@@ -52,7 +51,7 @@ loadSitesConfig();
// Create HTTP server
const server = http.createServer(async (req, res) => {
const parsedUrl = url.parse(req.url, true);
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
@@ -72,7 +71,7 @@ const server = http.createServer(async (req, res) => {
sitesRouter.handleSiteSearch(req, res, parsedUrl);
}
else if (parsedUrl.pathname === '/api/sites/nearby') {
sitesRouter.handleNearbySites(req, res, parsedUrl);
await sitesRouter.handleNearbySites(req, res, parsedUrl);
}
else if (parsedUrl.pathname === '/api/config') {
configRouter.handleGetConfig(req, res, config);

View File

@@ -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<Object>} - 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) => {
@@ -23,62 +31,14 @@ function fetchDeparturesForSite(siteId) {
});
res.on('end', () => {
console.log('Raw API response:', data.substring(0, 200) + '...');
try {
try {
const parsedData = JSON.parse(data);
console.log('Successfully parsed as regular JSON');
resolve(parsedData);
return;
} catch (jsonError) {
console.log('Not valid JSON, trying to fix format...');
}
if (data.startsWith('departures":')) {
data = '{' + data;
} else if (data.includes('departures":')) {
const startIndex = data.indexOf('departures":');
if (startIndex > 0) {
data = '{' + data.substring(startIndex);
}
}
data = data.replace(/}{\s*"/g, '},{"');
data = data.replace(/"([^"]+)":\s*([^,{}\[\]]+)(?=")/g, '"$1": $2,');
data = data.replace(/,\s*}/g, '}').replace(/,\s*\]/g, ']');
try {
const parsedData = JSON.parse(data);
console.log('Successfully parsed fixed JSON');
if (parsedData && parsedData.departures && parsedData.departures.length > 0) {
console.log('Sample departure structure:', JSON.stringify(parsedData.departures[0], null, 2));
const sample = parsedData.departures[0];
console.log('Direction fields:', {
direction: sample.direction,
directionText: sample.directionText,
directionCode: sample.directionCode,
destination: sample.destination
});
}
resolve(parsedData);
} catch (parseError) {
console.error('Failed to parse even after fixing:', parseError);
// Return empty departures array instead of rejecting to be more resilient
resolve({
departures: [],
error: 'Failed to parse API response: ' + parseError.message
});
}
} catch (error) {
console.error('Error processing API response:', error);
// Return empty departures array instead of rejecting to be more resilient
console.error('Error parsing departures API response:', error);
resolve({
departures: [],
error: 'Error processing API response: ' + error.message
error: 'Failed to parse API response: ' + error.message
});
}
});
@@ -94,7 +54,7 @@ function fetchDeparturesForSite(siteId) {
* @param {Array} enabledSites - Array of enabled site configurations
* @returns {Promise<Object>} - 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' };
}
@@ -102,7 +62,11 @@ async function fetchAllDepartures(enabledSites) {
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,
@@ -135,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) {

View File

@@ -1,131 +1,147 @@
/**
* Sites route handler
* Handles site search and nearby sites queries
*
* Search uses SL Journey Planner v2 Stop Finder (real server-side search)
* Nearby uses cached site list from SL Transport API (fetched once, filtered in-memory)
*/
const https = require('https');
/**
* Normalize site data from API response to consistent format
* @param {Object} site - Raw site data from API
* @returns {Object} - Normalized site object
*/
function normalizeSite(site) {
return {
id: String(site.id || site.siteId || site.SiteId || ''),
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
lat: site.lat || site.latitude || site.Lat || site.Latitude || null,
lon: site.lon || site.longitude || site.Lon || site.Longitude || null
};
}
// ── Site cache for nearby lookups ──────────────────────────────────────────
let cachedSites = null;
let cacheTimestamp = null;
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
/**
* Parse sites from API response (handles multiple response formats)
* @param {Object|Array} parsedData - Parsed JSON data from API
* @returns {Array<Object>} - Array of normalized sites
* Fetch and cache all sites from the SL Transport API
* The /v1/sites endpoint returns ~6500 sites with coordinates.
* We fetch this once and reuse it for nearby-site lookups.
* @returns {Promise<Array>} Array of normalized site objects
*/
function parseSitesFromResponse(parsedData) {
let sites = [];
if (Array.isArray(parsedData)) {
sites = parsedData.map(normalizeSite);
} else if (parsedData.sites && Array.isArray(parsedData.sites)) {
sites = parsedData.sites.map(normalizeSite);
} else if (parsedData.ResponseData && parsedData.ResponseData.Result) {
sites = parsedData.ResponseData.Result.map(normalizeSite);
function getAllSites() {
if (cachedSites && cacheTimestamp && (Date.now() - cacheTimestamp < CACHE_TTL)) {
return Promise.resolve(cachedSites);
}
return sites;
return new Promise((resolve, reject) => {
console.log('Fetching full site list from SL Transport API (will cache for 24h)...');
https.get('https://transport.integration.sl.se/v1/sites', (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
const sites = JSON.parse(data);
cachedSites = sites.map(site => ({
id: String(site.id),
name: site.name || 'Unknown',
lat: site.lat || null,
lon: site.lon || null
}));
cacheTimestamp = Date.now();
console.log(`Cached ${cachedSites.length} sites`);
resolve(cachedSites);
} catch (error) {
console.error('Error parsing site list:', error);
reject(error);
}
});
}).on('error', reject);
});
}
// ── Search via Journey Planner v2 ──────────────────────────────────────────
/**
* Convert a Journey Planner stopId to an SL Transport siteId
* stopId format is "180XXXXX" — strip the "180" prefix to get the siteId
* @param {string} stopId - e.g. "18001411"
* @returns {string} siteId - e.g. "1411"
*/
function stopIdToSiteId(stopId) {
if (!stopId) return '';
// Strip the "180" prefix (or "1800" for shorter IDs)
return stopId.replace(/^180+/, '') || stopId;
}
/**
* Handle site search endpoint
* @param {http.IncomingMessage} req - HTTP request object
* @param {http.ServerResponse} res - HTTP response object
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
* Handle site search endpoint using SL Journey Planner v2 Stop Finder
* This endpoint does real server-side search (unlike /v1/sites which returns everything)
*/
function handleSiteSearch(req, res, parsedUrl) {
const query = parsedUrl.query.q;
const query = parsedUrl.searchParams.get('q');
if (!query || query.length < 2) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
return;
}
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(query)}`;
console.log(`Searching sites: ${searchUrl}`);
// any_obj_filter_sf=2 restricts results to stops only
const searchUrl = `https://journeyplanner.integration.sl.se/v2/stop-finder?name_sf=${encodeURIComponent(query)}&type_sf=any&any_obj_filter_sf=2`;
console.log(`Searching sites via Journey Planner: ${searchUrl}`);
https.get(searchUrl, (apiRes) => {
let data = '';
if (apiRes.statusCode < 200 || apiRes.statusCode >= 300) {
console.error(`API returned status code: ${apiRes.statusCode}`);
console.error(`Journey Planner API returned status: ${apiRes.statusCode}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] }));
return;
}
apiRes.on('data', (chunk) => {
data += chunk;
});
apiRes.on('data', chunk => { data += chunk; });
apiRes.on('end', () => {
try {
console.log('Raw API response:', data.substring(0, 500));
const parsedData = JSON.parse(data);
console.log('Parsed data:', JSON.stringify(parsedData).substring(0, 500));
const parsed = JSON.parse(data);
const locations = parsed.locations || [];
const sites = parseSitesFromResponse(parsedData);
if (sites.length > 0) {
console.log('Sample site structure:', JSON.stringify(sites[0], null, 2));
const sitesWithCoords = sites.filter(s => s.lat && s.lon);
console.log(`Found ${sites.length} sites, ${sitesWithCoords.length} with coordinates`);
} else {
console.log('No sites found');
}
const sites = locations
.filter(loc => loc.type === 'stop' && loc.properties && loc.properties.stopId)
.map(loc => ({
id: stopIdToSiteId(loc.properties.stopId),
name: loc.disassembledName || loc.name || 'Unknown',
lat: loc.coord ? loc.coord[0] : null,
lon: loc.coord ? loc.coord[1] : null
}));
console.log(`Search "${query}" returned ${sites.length} stops`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites }));
} catch (error) {
console.error('Error parsing site search response:', error);
console.error('Response data:', data.substring(0, 500));
console.error('Error parsing search response:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Error parsing search results', details: error.message, sites: [] }));
res.end(JSON.stringify({ error: 'Error parsing search results', sites: [] }));
}
});
}).on('error', (error) => {
console.error('Error searching sites:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Error searching sites', details: error.message, sites: [] }));
res.end(JSON.stringify({ error: 'Error searching sites', sites: [] }));
});
}
// ── Nearby sites from cache ────────────────────────────────────────────────
/**
* Calculate distance between two coordinates (simple approximation)
* @param {number} lat1 - Latitude of point 1
* @param {number} lon1 - Longitude of point 1
* @param {number} lat2 - Latitude of point 2
* @param {number} lon2 - Longitude of point 2
* @returns {number} - Distance in meters
* Calculate distance between two coordinates using equirectangular approximation
* Accurate enough for distances under ~100km at Stockholm's latitude
* @returns {number} Distance in meters
*/
function calculateDistance(lat1, lon1, lat2, lon2) {
return Math.sqrt(
Math.pow((lat1 - lat2) * 111000, 2) +
Math.pow((lon1 - lon2) * 111000 * Math.cos(lat1 * Math.PI / 180), 2)
);
const dLat = (lat2 - lat1) * 111000;
const dLon = (lon2 - lon1) * 111000 * Math.cos(lat1 * Math.PI / 180);
return Math.sqrt(dLat * dLat + dLon * dLon);
}
/**
* Handle nearby sites endpoint
* @param {http.IncomingMessage} req - HTTP request object
* @param {http.ServerResponse} res - HTTP response object
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
* Uses cached site list — no redundant API calls per request
*/
function handleNearbySites(req, res, parsedUrl) {
const lat = parseFloat(parsedUrl.query.lat);
const lon = parseFloat(parsedUrl.query.lon);
const radius = parseInt(parsedUrl.query.radius) || 5000; // Default 5km radius
async function handleNearbySites(req, res, parsedUrl) {
const lat = parseFloat(parsedUrl.searchParams.get('lat'));
const lon = parseFloat(parsedUrl.searchParams.get('lon'));
const radius = parseInt(parsedUrl.searchParams.get('radius')) || 1000; // Default 1km
if (isNaN(lat) || isNaN(lon)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -133,66 +149,30 @@ function handleNearbySites(req, res, parsedUrl) {
return;
}
// Use a broader search to get sites, then filter by distance
const searchTerms = ['Stockholm', 'T-Centralen', 'Gamla Stan', 'Södermalm'];
const allSites = [];
let completedSearches = 0;
searchTerms.forEach(term => {
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(term)}`;
https.get(searchUrl, (apiRes) => {
let data = '';
apiRes.on('data', (chunk) => {
data += chunk;
});
apiRes.on('end', () => {
try {
const parsedData = JSON.parse(data);
const sites = parseSitesFromResponse(parsedData);
const allSites = await getAllSites();
sites.forEach(site => {
if (site.lat && site.lon) {
const distance = calculateDistance(lat, lon, site.lat, site.lon);
const nearby = allSites
.filter(site => site.lat && site.lon)
.map(site => ({
...site,
distance: calculateDistance(lat, lon, site.lat, site.lon)
}))
.filter(site => site.distance <= radius)
.sort((a, b) => a.distance - b.distance);
if (distance <= radius) {
allSites.push(site);
}
}
});
completedSearches++;
if (completedSearches === searchTerms.length) {
// Remove duplicates
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
console.log(`Found ${nearby.length} sites within ${radius}m of [${lat}, ${lon}]`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
}
res.end(JSON.stringify({ sites: nearby }));
} catch (error) {
completedSearches++;
if (completedSearches === searchTerms.length) {
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
console.error('Error fetching nearby sites:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Error fetching nearby sites', sites: [] }));
}
}
});
}).on('error', () => {
completedSearches++;
if (completedSearches === searchTerms.length) {
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
}
});
});
}
module.exports = {
handleSiteSearch,
handleNearbySites,
normalizeSite,
parseSitesFromResponse
getAllSites
};