`).join('');
@@ -644,7 +574,7 @@ class ConfigManager {
try {
resultsContainer.style.display = 'block';
- resultsContainer.innerHTML = '
Searching...
';
+ 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 = '
No sites found. Try a different search term.
';
+ resultsContainer.textContent = 'No sites found. Try a different search term.';
return;
}
- resultsContainer.innerHTML = data.sites.map(site => `
-
- `).join('');
-
- // 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);
+ 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;
+
+ 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 = `
`;
+ resultsContainer.textContent = `Error: ${errorMessage}`;
}
}
@@ -782,120 +709,62 @@ class ConfigManager {
if (e.target === mapModal) closeMap();
});
- // Load transit stops - search for common Stockholm areas
- const loadSitesOnMap = async () => {
+ // 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;
+
try {
- // Start with focused search to avoid too many markers
- const searchTerms = ['Ambassaderna'];
- const allSites = new Map();
-
- for (const term of searchTerms) {
- try {
- const response = await fetch(`/api/sites/search?q=${encodeURIComponent(term)}`);
- 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 (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);
- }
- }
-
- // 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 response = await fetch(`/api/sites/nearby?lat=${center.lat}&lon=${center.lng}&radius=${radius}`);
+ const data = await response.json();
+
+ if (data.sites) {
+ data.sites.forEach(site => {
+ if (loadedSiteIds.has(site.id)) return;
+ const lat = parseFloat(site.lat);
+ const lon = parseFloat(site.lon);
+ if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
+
+ loadedSiteIds.add(site.id);
+
const customIcon = L.divIcon({
className: 'custom-marker',
- html: `
',
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 = `
`;
-
+
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
- map.eachLayer((layer) => {
- if (layer instanceof L.Marker) {
- map.removeLayer(layer);
- }
- });
-
- // Add markers for search results
- const markers = [];
+ // Clear existing markers and reset tracking
+ markersLayer.clearLayers();
+ loadedSiteIds.clear();
+
+ const searchMarkers = [];
data.sites.forEach(site => {
- let lat = site.lat || site.latitude;
- let lon = site.lon || site.longitude;
-
- // 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;
- }
-
- // Create custom icon
+ const lat = parseFloat(site.lat);
+ const lon = parseFloat(site.lon);
+ if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
+
+ loadedSiteIds.add(site.id);
+
const customIcon = L.divIcon({
- className: 'custom-transit-marker',
- html: `
`,
+ className: 'custom-marker',
+ html: '
',
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 = `
-
-
${site.name}
-
ID: ${site.id}
+
+ const marker = L.marker([lat, lon], { icon: customIcon });
+ marker.bindPopup(`
+
+ ${site.name}
+ ID: ${site.id}
+
+ Select This Stop
+
-
- Select This Stop
-
- `;
-
- marker.bindPopup(popupContent);
- markers.push(marker);
-
- marker.on('click', function() {
- this.openPopup();
- });
+ `);
+ 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;
diff --git a/public/js/components/DeparturesManager.js b/public/js/components/DeparturesManager.js
index f52147c..833b864 100644
--- a/public/js/components/DeparturesManager.js
+++ b/public/js/components/DeparturesManager.js
@@ -222,20 +222,16 @@ class DeparturesManager {
const minutesUntil = this.calculateMinutesUntilArrival(departure);
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) {
- countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
- } else {
- countdownText = minutesUntil === 1 ? '1 min' : `${minutesUntil} min`;
- }
+ } else if (minutesUntil <= 2) {
+ 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) {
@@ -244,7 +240,7 @@ class DeparturesManager {
countdownText = displayTime;
}
}
-
+
return { countdownText, countdownClass };
}
@@ -381,15 +377,13 @@ class DeparturesManager {
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;
- }
- timeDisplayElement.appendChild(timeRangeSpan);
+ // Show next 2-3 absolute times as small text
+ const nextTimesSpan = document.createElement('span');
+ nextTimesSpan.className = 'next-departures';
+ const upcomingTimes = direction.departures.slice(0, 3)
+ .map(d => DeparturesManager.formatDateTime(this.getDepartureTime(d)));
+ nextTimesSpan.textContent = upcomingTimes.join(' ');
+ timeDisplayElement.appendChild(nextTimesSpan);
timesContainer.appendChild(timeDisplayElement);
}
@@ -424,7 +418,7 @@ class DeparturesManager {
this.updateExistingCards(departures);
}
- this.currentDepartures = JSON.parse(JSON.stringify(departures));
+ this.currentDepartures = structuredClone(departures);
}
/**
@@ -444,8 +438,8 @@ 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);
} else if (index >= this.container.children.length) {
@@ -453,24 +447,22 @@ class DeparturesManager {
} else {
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);
}
});
}
@@ -483,16 +475,14 @@ class DeparturesManager {
updateCardContent(card, departure) {
const { countdownText, countdownClass } = this.getCountdownInfo(departure);
const countdownElement = card.querySelector('.countdown');
-
+
if (countdownElement) {
- countdownElement.classList.remove('now', 'urgent');
-
- if (countdownClass === 'now') {
- countdownElement.classList.add('now');
- } else if (countdownClass === 'urgent') {
- countdownElement.classList.add('urgent');
+ countdownElement.classList.remove('now', 'urgent', 'soon');
+
+ if (countdownClass) {
+ countdownElement.classList.add(countdownClass);
}
-
+
if (countdownElement.textContent !== `(${countdownText})`) {
countdownElement.textContent = `(${countdownText})`;
this.highlightElement(countdownElement);
@@ -505,13 +495,10 @@ class DeparturesManager {
* @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');
}
/**
@@ -632,5 +619,8 @@ class DeparturesManager {
}
}
-// Export the class
+// ES module export
+export { DeparturesManager };
+
+// Keep window reference for backward compatibility
window.DeparturesManager = DeparturesManager;
diff --git a/public/js/components/NewsTicker.js b/public/js/components/NewsTicker.js
new file mode 100644
index 0000000..6d93a4d
--- /dev/null
+++ b/public/js/components/NewsTicker.js
@@ -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;
diff --git a/public/js/components/WeatherManager.js b/public/js/components/WeatherManager.js
index 9f2b81a..7058da4 100644
--- a/public/js/components/WeatherManager.js
+++ b/public/js/components/WeatherManager.js
@@ -8,7 +8,7 @@ class WeatherManager {
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 +24,7 @@ class WeatherManager {
this.sunTimes = null;
this.isDarkMode = false;
this.lastUpdated = null;
+ this.daylightBarUpdateInterval = null;
// Initialize
this.init();
@@ -34,6 +35,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,17 +101,17 @@ 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();
-
+
if (currentWeatherData.cod !== 200) {
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 +125,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 +176,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 +195,42 @@ class WeatherManager {
* Get weather icon URL from icon code
*/
getWeatherIconUrl(iconCode) {
- return `https://openweathermap.org/img/wn/${iconCode}@2x.png`;
+ return `https://openweathermap.org/img/wn/${iconCode}@4x.png`;
}
/**
- * Determine if icon represents sun (even behind clouds)
+ * Classify a weather icon and return the appropriate CSS classes
+ * @param {string} iconCode - OWM icon code (e.g. '01d', '13n')
+ * @param {string} condition - Weather condition text (e.g. 'Clear', 'Clouds')
+ * @returns {string[]} Array of CSS class names to apply
*/
- isSunIcon(iconCode, condition) {
- // Icon codes: 01d, 01n = clear, 02d, 02n = few clouds, 03d, 03n = scattered, 04d, 04n = broken clouds
- const sunIconCodes = ['01d', '01n', '02d', '02n', '03d', '03n', '04d', '04n'];
- return sunIconCodes.includes(iconCode) ||
- condition.includes('Clear') ||
- condition.includes('Clouds');
+ classifyWeatherIcon(iconCode, condition) {
+ const code = iconCode ? iconCode.replace(/[dn]$/, '') : '';
+
+ // Snow: icon 13x or condition contains 'Snow'
+ if (code === '13' || condition.includes('Snow')) {
+ return ['weather-snow'];
+ }
+ // Clear sun: icon 01x or condition is exactly 'Clear'
+ if (code === '01' || condition === 'Clear') {
+ return ['weather-sun', 'weather-clear-sun'];
+ }
+ // Sun behind clouds: icon 02-04x or cloudy condition
+ if (['02', '03', '04'].includes(code) || (condition.includes('Clouds') && !condition.includes('Clear'))) {
+ return ['weather-sun', 'weather-clouds-sun'];
+ }
+ return [];
}
-
+
/**
- * Check if icon is clear sun (no clouds)
+ * Apply weather icon CSS classes to an element
*/
- isClearSun(iconCode, condition) {
- const clearIconCodes = ['01d', '01n'];
- return clearIconCodes.includes(iconCode) || condition === 'Clear';
- }
-
- /**
- * Check if icon is sun behind clouds
- */
- isSunBehindClouds(iconCode, condition) {
- const cloudIconCodes = ['02d', '02n', '03d', '03n', '04d', '04n'];
- return cloudIconCodes.includes(iconCode) || (condition.includes('Clouds') && !condition.includes('Clear'));
- }
-
- /**
- * Determine if icon represents snow
- */
- isSnowIcon(iconCode, condition) {
- // Icon code: 13d, 13n = snow
- const snowIconCodes = ['13d', '13n'];
- return snowIconCodes.includes(iconCode) || condition.includes('Snow');
+ applyWeatherIconClasses(element, iconCode, condition) {
+ element.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun');
+ const classes = this.classifyWeatherIcon(iconCode, condition);
+ if (classes.length > 0) {
+ element.classList.add(...classes);
+ }
}
/**
@@ -224,7 +241,7 @@ class WeatherManager {
temperature: 7.1,
condition: 'Clear',
description: 'clear sky',
- icon: 'https://openweathermap.org/img/wn/01d@2x.png',
+ icon: 'https://openweathermap.org/img/wn/01d@4x.png',
iconCode: '01d',
wind: {
speed: 14.8,
@@ -255,7 +272,7 @@ class WeatherManager {
temperature: 7.1 - (i * 0.3), // Decrease temperature slightly each hour
condition: i < 2 ? 'Clear' : 'Clouds',
description: i < 2 ? 'clear sky' : 'few clouds',
- icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@2x.png' : 'https://openweathermap.org/img/wn/02n@2x.png',
+ icon: i < 2 ? 'https://openweathermap.org/img/wn/01n@4x.png' : 'https://openweathermap.org/img/wn/02n@4x.png',
iconCode: i < 2 ? '01n' : '02n',
timestamp: forecastTime,
precipitation: 0
@@ -287,18 +304,8 @@ class WeatherManager {
if (iconElement) {
iconElement.src = this.weatherData.icon;
iconElement.alt = this.weatherData.description;
- // Add classes and data attributes for color filtering
iconElement.setAttribute('data-condition', this.weatherData.condition);
- iconElement.classList.remove('weather-sun', 'weather-snow', 'weather-clear-sun', 'weather-clouds-sun');
- if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) {
- iconElement.classList.add('weather-snow');
- } else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) {
- iconElement.classList.add('weather-sun', 'weather-clear-sun');
- } else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) {
- iconElement.classList.add('weather-sun', 'weather-clouds-sun');
- } else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) {
- iconElement.classList.add('weather-sun');
- }
+ this.applyWeatherIconClasses(iconElement, this.weatherData.iconCode, this.weatherData.condition);
}
const temperatureElement = document.querySelector('#custom-weather .temperature');
@@ -320,15 +327,7 @@ class WeatherManager {
nowIcon.alt = this.weatherData.description;
nowIcon.width = 56;
nowIcon.setAttribute('data-condition', this.weatherData.condition);
- if (this.isSnowIcon(this.weatherData.iconCode, this.weatherData.condition)) {
- nowIcon.classList.add('weather-snow');
- } else if (this.isClearSun(this.weatherData.iconCode, this.weatherData.condition)) {
- nowIcon.classList.add('weather-sun', 'weather-clear-sun');
- } else if (this.isSunBehindClouds(this.weatherData.iconCode, this.weatherData.condition)) {
- nowIcon.classList.add('weather-sun', 'weather-clouds-sun');
- } else if (this.isSunIcon(this.weatherData.iconCode, this.weatherData.condition)) {
- nowIcon.classList.add('weather-sun');
- }
+ this.applyWeatherIconClasses(nowIcon, this.weatherData.iconCode, this.weatherData.condition);
nowElement.innerHTML = `
Nu
@@ -349,15 +348,7 @@ class WeatherManager {
forecastIcon.alt = forecast.description;
forecastIcon.width = 56;
forecastIcon.setAttribute('data-condition', forecast.condition);
- if (this.isSnowIcon(forecast.iconCode, forecast.condition)) {
- forecastIcon.classList.add('weather-snow');
- } else if (this.isClearSun(forecast.iconCode, forecast.condition)) {
- forecastIcon.classList.add('weather-sun', 'weather-clear-sun');
- } else if (this.isSunBehindClouds(forecast.iconCode, forecast.condition)) {
- forecastIcon.classList.add('weather-sun', 'weather-clouds-sun');
- } else if (this.isSunIcon(forecast.iconCode, forecast.condition)) {
- forecastIcon.classList.add('weather-sun');
- }
+ this.applyWeatherIconClasses(forecastIcon, forecast.iconCode, forecast.condition);
forecastElement.innerHTML = `
${timeString}
@@ -375,128 +366,151 @@ class WeatherManager {
const sunsetTime = this.formatTime(this.sunTimes.today.sunset);
sunTimesElement.textContent = `βοΈ Sunrise: ${sunriseTime} | π Sunset: ${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 | Sunrise HH:MM | Sunset 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);
+ tempSpan.appendChild(document.createTextNode(` ${this.weatherData.condition || ''}`));
+ bar.appendChild(tempSpan);
+
+ const sep1 = document.createElement('span');
+ sep1.className = 'weather-bar-sep';
+ sep1.textContent = '|';
+ bar.appendChild(sep1);
+
+ let sunriseStr = '--:--';
+ let sunsetStr = '--:--';
+ if (this.sunTimes) {
+ sunriseStr = this.formatTime(this.sunTimes.today.sunrise);
+ sunsetStr = this.formatTime(this.sunTimes.today.sunset);
+ }
+
+ const sunriseSpan = document.createElement('span');
+ sunriseSpan.textContent = `\u2600\uFE0F Sunrise ${sunriseStr}`;
+ bar.appendChild(sunriseSpan);
+
+ const sep2 = document.createElement('span');
+ sep2.className = 'weather-bar-sep';
+ sep2.textContent = '|';
+ bar.appendChild(sep2);
+
+ const sunsetSpan = document.createElement('span');
+ sunsetSpan.textContent = `\uD83C\uDF19 Sunset ${sunsetStr}`;
+ bar.appendChild(sunsetSpan);
+ }
/**
* 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 },
tomorrow: tomorrowTimes
};
-
+
console.log('Sun times updated from API:', this.sunTimes);
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
-
- // 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);
-
- // 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 };
+ 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();
+
+ if (data.status !== 'OK') {
+ throw new Error(`Sunrise-sunset API returned status: ${data.status}`);
+ }
+
+ return {
+ sunrise: new Date(data.results.sunrise),
+ sunset: new Date(data.results.sunset)
+ };
}
/**
@@ -588,7 +602,136 @@ 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
+ 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;
diff --git a/public/js/main.js b/public/js/main.js
index 3c06ac9..a7ad688 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -3,22 +3,26 @@
* 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';
-
+
// Move all body children to the wrapper except excluded elements
const excludedElements = ['config-button', 'config-modal', 'background-overlay'];
-
+
// Create an array of nodes to move (can't modify while iterating)
const nodesToMove = [];
for (let i = 0; i < document.body.children.length; i++) {
@@ -27,12 +31,12 @@ function ensureContentWrapper() {
nodesToMove.push(child);
}
}
-
+
// Move the nodes to the wrapper
nodesToMove.forEach(node => {
wrapper.appendChild(node);
});
-
+
// Add the wrapper back to the body
document.body.appendChild(wrapper);
}
@@ -40,80 +44,60 @@ 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
});
-
+
// Initialize WeatherManager with location from window config or constants
- const defaultLat = window.DEFAULT_LOCATION?.latitude ||
- (window.Constants?.DEFAULT_LOCATION?.LATITUDE) || 59.3293;
- const defaultLon = window.DEFAULT_LOCATION?.longitude ||
- (window.Constants?.DEFAULT_LOCATION?.LONGITUDE) || 18.0686;
+ const defaultLat = window.DEFAULT_LOCATION?.latitude ||
+ Constants.DEFAULT_LOCATION.LATITUDE || 59.3293;
+ const defaultLon = window.DEFAULT_LOCATION?.longitude ||
+ Constants.DEFAULT_LOCATION.LONGITUDE || 18.0686;
window.weatherManager = new WeatherManager({
latitude: defaultLat,
longitude: defaultLon
});
-
- // Initialize departures - use DeparturesManager
- if (typeof DeparturesManager !== 'undefined') {
- 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();
- }
-
+
+ // Initialize DeparturesManager
+ window.departuresManager = new DeparturesManager({
+ containerId: 'departures',
+ statusId: 'status',
+ lastUpdatedId: 'last-updated'
+ });
+
+ // Initialize NewsTicker (visible in landscape mode only via CSS)
+ window.newsTicker = new NewsTicker();
+
// Set up event listeners
document.addEventListener('darkModeChanged', event => {
document.body.classList.toggle('dark-mode', event.detail.isDarkMode);
});
-
+
document.addEventListener('configChanged', event => {
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(event.detail.config.orientation)) {
ensureContentWrapper();
}
});
-
+
// 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}`;
diff --git a/public/js/utils/constants.js b/public/js/utils/constants.js
index b16daf7..077668b 100644
--- a/public/js/utils/constants.js
+++ b/public/js/utils/constants.js
@@ -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;
diff --git a/public/js/utils/logger.js b/public/js/utils/logger.js
index a31f533..34ca4f0 100644
--- a/public/js/utils/logger.js
+++ b/public/js/utils/logger.js
@@ -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;
diff --git a/server.js b/server.js
index 9234fbe..dfc68d2 100644
--- a/server.js
+++ b/server.js
@@ -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);
diff --git a/server/routes/departures.js b/server/routes/departures.js
index f27b322..3ab872d 100644
--- a/server/routes/departures.js
+++ b/server/routes/departures.js
@@ -8,11 +8,19 @@ const https = require('https');
/**
* Fetch departures for a specific site from SL Transport API
* @param {string} siteId - The site ID to fetch departures for
+ * @param {Object} options - Query options
+ * @param {number} options.forecast - Time window in minutes (default: 60)
+ * @param {string} options.transport - Transport mode filter (e.g. 'BUS', 'METRO')
* @returns {Promise
} - Departure data
*/
-function fetchDeparturesForSite(siteId) {
+function fetchDeparturesForSite(siteId, options = {}) {
return new Promise((resolve, reject) => {
- const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`;
+ const params = new URLSearchParams();
+ params.set('forecast', options.forecast || 60);
+ if (options.transport) {
+ params.set('transport', options.transport);
+ }
+ const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures?${params}`;
console.log(`Fetching data from: ${apiUrl}`);
https.get(apiUrl, (res) => {
@@ -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
- });
- }
+ const parsedData = JSON.parse(data);
+ resolve(parsedData);
} 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,15 +54,19 @@ function fetchDeparturesForSite(siteId) {
* @param {Array} enabledSites - Array of enabled site configurations
* @returns {Promise} - Object with sites array containing departure data
*/
-async function fetchAllDepartures(enabledSites) {
+async function fetchAllDepartures(enabledSites, globalOptions = {}) {
if (enabledSites.length === 0) {
return { sites: [], error: 'No enabled sites configured' };
}
-
+
try {
const sitesPromises = enabledSites.map(async (site) => {
try {
- const departureData = await fetchDeparturesForSite(site.id);
+ const siteOptions = {
+ forecast: site.forecast || globalOptions.forecast || 60,
+ transport: site.transport || globalOptions.transport || ''
+ };
+ const departureData = await fetchDeparturesForSite(site.id, siteOptions);
return {
siteId: site.id,
siteName: site.name,
@@ -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) {
diff --git a/server/routes/sites.js b/server/routes/sites.js
index f7b2947..e8fe370 100644
--- a/server/routes/sites.js
+++ b/server/routes/sites.js
@@ -1,198 +1,178 @@
/**
* 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} - 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 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 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 parsed = JSON.parse(data);
+ const locations = parsed.locations || [];
+
+ 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' });
res.end(JSON.stringify({ error: 'Invalid latitude or longitude', sites: [] }));
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);
-
- sites.forEach(site => {
- if (site.lat && site.lon) {
- const distance = calculateDistance(lat, lon, site.lat, site.lon);
-
- 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());
- res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ sites: uniqueSites }));
- }
- } 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 }));
- }
- }
- });
- }).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 }));
- }
- });
- });
+
+ try {
+ const allSites = await getAllSites();
+
+ 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);
+
+ console.log(`Found ${nearby.length} sites within ${radius}m of [${lat}, ${lon}]`);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ sites: nearby }));
+ } catch (error) {
+ console.error('Error fetching nearby sites:', error);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Error fetching nearby sites', sites: [] }));
+ }
}
module.exports = {
handleSiteSearch,
handleNearbySites,
- normalizeSite,
- parseSitesFromResponse
+ getAllSites
};