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>
This commit is contained in:
124
index.html
124
index.html
@@ -8,7 +8,7 @@
|
|||||||
<script>
|
<script>
|
||||||
// Global variables (fallback if Constants not loaded)
|
// Global variables (fallback if Constants not loaded)
|
||||||
window.API_URL = window.API_URL || 'http://localhost:3002/api/departures';
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Leaflet.js for map functionality -->
|
<!-- Leaflet.js for map functionality -->
|
||||||
@@ -19,18 +19,8 @@
|
|||||||
<link rel="stylesheet" href="public/css/main.css">
|
<link rel="stylesheet" href="public/css/main.css">
|
||||||
<link rel="stylesheet" href="public/css/components.css">
|
<link rel="stylesheet" href="public/css/components.css">
|
||||||
|
|
||||||
<!-- Utility scripts (must load first) -->
|
<!-- Main application script (ES module - imports all dependencies) -->
|
||||||
<script src="public/js/utils/constants.js"></script>
|
<script type="module" src="public/js/main.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>
|
|
||||||
|
|
||||||
<!-- Inline styles removed - now in external CSS files -->
|
<!-- Inline styles removed - now in external CSS files -->
|
||||||
</head>
|
</head>
|
||||||
@@ -69,6 +59,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="temperature">7.1 °C</div>
|
<div class="temperature">7.1 °C</div>
|
||||||
|
<div class="sun-times">
|
||||||
|
☀️ Sunrise: 06:45 AM | 🌙 Sunset: 05:32 PM
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="forecast">
|
<div class="forecast">
|
||||||
<div class="forecast-hour">
|
<div class="forecast-hour">
|
||||||
@@ -121,14 +114,111 @@
|
|||||||
<div class="temp">5.5 °C</div>
|
<div class="temp">5.5 °C</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sun-times">
|
|
||||||
â˜€ï¸ Sunrise: 06:45 AM | 🌙 Sunset: 05:32 PM
|
|
||||||
</div>
|
|
||||||
</div> <!-- End of weather-section -->
|
</div> <!-- End of weather-section -->
|
||||||
|
|
||||||
<div class="last-updated" id="last-updated"></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 -->
|
</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">×</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
@@ -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;
|
window.Clock = Clock;
|
||||||
|
|||||||
@@ -83,108 +83,38 @@ class ConfigManager {
|
|||||||
* Create the configuration modal
|
* Create the configuration modal
|
||||||
*/
|
*/
|
||||||
createConfigModal() {
|
createConfigModal() {
|
||||||
|
const template = document.getElementById('config-modal-template');
|
||||||
const modalContainer = document.createElement('div');
|
const modalContainer = document.createElement('div');
|
||||||
modalContainer.id = this.options.configModalId;
|
modalContainer.id = this.options.configModalId;
|
||||||
modalContainer.className = 'config-modal';
|
modalContainer.className = 'config-modal';
|
||||||
modalContainer.style.display = 'none';
|
modalContainer.style.display = 'none';
|
||||||
|
|
||||||
modalContainer.innerHTML = `
|
// Clone the template content into the modal
|
||||||
<div class="config-modal-content">
|
modalContainer.appendChild(template.content.cloneNode(true));
|
||||||
<div class="config-modal-header">
|
|
||||||
<h2>Settings</h2>
|
|
||||||
<span class="config-modal-close">×</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>
|
|
||||||
|
|
||||||
<!-- Appearance Tab -->
|
// Set dynamic values from current config
|
||||||
<div class="config-tab-content" id="tab-appearance">
|
modalContainer.querySelector('#orientation-select').value = this.config.orientation;
|
||||||
<div class="config-option">
|
modalContainer.querySelector('#dark-mode-select').value = this.config.darkMode;
|
||||||
<label for="background-image-url">Background Image:</label>
|
modalContainer.querySelector('#background-image-url').value = this.config.backgroundImage || '';
|
||||||
<input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}">
|
modalContainer.querySelector('#background-opacity').value = this.config.backgroundOpacity;
|
||||||
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
modalContainer.querySelector('#opacity-value').textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
|
||||||
<button id="test-image-button" style="padding: 5px 10px;">Use Test Image</button>
|
modalContainer.querySelector('#combine-directions').checked = this.config.combineSameDirection;
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Content Tab -->
|
// Populate sites
|
||||||
<div class="config-tab-content" id="tab-content">
|
const sitesContainer = modalContainer.querySelector('#sites-container');
|
||||||
<div class="config-option">
|
if (sitesContainer) {
|
||||||
<label>Transit Sites:</label>
|
sitesContainer.innerHTML = this.generateSitesHTML();
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Options Tab -->
|
// Update background preview
|
||||||
<div class="config-tab-content" id="tab-options">
|
const preview = modalContainer.querySelector('#background-preview');
|
||||||
<div class="config-option">
|
if (preview && this.config.backgroundImage) {
|
||||||
<label for="combine-directions">
|
const img = document.createElement('img');
|
||||||
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
|
img.src = this.config.backgroundImage;
|
||||||
Combine departures going in the same direction
|
img.alt = 'Background preview';
|
||||||
</label>
|
preview.innerHTML = '';
|
||||||
</div>
|
preview.appendChild(img);
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
<div class="config-modal-footer">
|
|
||||||
<button id="config-save-button">Save</button>
|
|
||||||
<button id="config-cancel-button">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modalContainer);
|
document.body.appendChild(modalContainer);
|
||||||
|
|
||||||
@@ -549,32 +479,9 @@ class ConfigManager {
|
|||||||
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
|
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.id = 'background-overlay';
|
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.backgroundImage = `url(${this.config.backgroundImage})`;
|
||||||
overlay.style.backgroundSize = 'cover';
|
|
||||||
overlay.style.backgroundPosition = 'center';
|
|
||||||
overlay.style.opacity = this.config.backgroundOpacity;
|
overlay.style.opacity = this.config.backgroundOpacity;
|
||||||
overlay.style.zIndex = '-1';
|
overlay.className = `orientation-${this.config.orientation}`;
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert as the first child of body
|
// Insert as the first child of body
|
||||||
document.body.insertBefore(overlay, document.body.firstChild);
|
document.body.insertBefore(overlay, document.body.firstChild);
|
||||||
@@ -614,14 +521,14 @@ class ConfigManager {
|
|||||||
|
|
||||||
return this.config.sites.map((site, index) => `
|
return this.config.sites.map((site, index) => `
|
||||||
<div class="site-item" data-index="${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="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;">
|
<input type="text" class="site-name config-site-name-input" value="${site.name}" placeholder="Site Name">
|
||||||
<button class="remove-site-button" style="padding: 2px 5px;">×</button>
|
<button class="remove-site-button config-btn-remove">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center;">
|
<div class="config-site-id-row">
|
||||||
<span style="margin-right: 5px;">ID:</span>
|
<span class="config-site-id-label">ID:</span>
|
||||||
<input type="text" class="site-id" value="${site.id}" placeholder="Site ID" style="width: 100px;">
|
<input type="text" class="site-id config-site-id-input" value="${site.id}" placeholder="Site ID">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -644,7 +551,7 @@ class ConfigManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
resultsContainer.style.display = 'block';
|
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)}`);
|
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
|
||||||
|
|
||||||
@@ -656,35 +563,32 @@ class ConfigManager {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data.sites || data.sites.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsContainer.innerHTML = data.sites.map(site => `
|
resultsContainer.innerHTML = '';
|
||||||
<div class="site-search-result" style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s;"
|
data.sites.forEach(site => {
|
||||||
data-site-id="${site.id}" data-site-name="${site.name}">
|
const resultDiv = document.createElement('div');
|
||||||
<div style="font-weight: bold; color: #0061a1;">${site.name}</div>
|
resultDiv.className = 'site-search-result';
|
||||||
<div style="font-size: 0.85em; color: #666;">ID: ${site.id}</div>
|
resultDiv.dataset.siteId = site.id;
|
||||||
</div>
|
resultDiv.dataset.siteName = site.name;
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Add click handlers to search results
|
const nameDiv = document.createElement('div');
|
||||||
resultsContainer.querySelectorAll('.site-search-result').forEach(result => {
|
nameDiv.textContent = site.name;
|
||||||
result.addEventListener('click', () => {
|
const idDiv = document.createElement('div');
|
||||||
const siteId = result.dataset.siteId;
|
idDiv.textContent = `ID: ${site.id}`;
|
||||||
const siteName = result.dataset.siteName;
|
|
||||||
this.addSiteFromSearch(siteId, siteName);
|
resultDiv.appendChild(nameDiv);
|
||||||
|
resultDiv.appendChild(idDiv);
|
||||||
|
|
||||||
|
resultDiv.addEventListener('click', () => {
|
||||||
|
this.addSiteFromSearch(site.id, site.name);
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
resultsContainer.style.display = 'none';
|
resultsContainer.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
result.addEventListener('mouseenter', () => {
|
resultsContainer.appendChild(resultDiv);
|
||||||
result.style.backgroundColor = '#f5f5f5';
|
|
||||||
});
|
|
||||||
|
|
||||||
result.addEventListener('mouseleave', () => {
|
|
||||||
result.style.backgroundColor = 'white';
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -698,7 +602,7 @@ class ConfigManager {
|
|||||||
errorMessage = `Server error: ${error.message}`;
|
errorMessage = `Server error: ${error.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsContainer.innerHTML = `<div style="padding: 10px; text-align: center; color: #d32f2f;">Error: ${errorMessage}</div>`;
|
resultsContainer.textContent = `Error: ${errorMessage}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,5 +1063,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;
|
window.ConfigManager = ConfigManager;
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ class DeparturesManager {
|
|||||||
this.updateExistingCards(departures);
|
this.updateExistingCards(departures);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentDepartures = JSON.parse(JSON.stringify(departures));
|
this.currentDepartures = structuredClone(departures);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,7 +444,7 @@ class DeparturesManager {
|
|||||||
this.updateCardContent(existingCard, departure);
|
this.updateCardContent(existingCard, departure);
|
||||||
} else {
|
} else {
|
||||||
const newCard = this.createDepartureCard(departure);
|
const newCard = this.createDepartureCard(departure);
|
||||||
newCard.style.opacity = '0';
|
newCard.classList.add('card-entering');
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
this.container.prepend(newCard);
|
this.container.prepend(newCard);
|
||||||
@@ -454,23 +454,21 @@ class DeparturesManager {
|
|||||||
this.container.insertBefore(newCard, this.container.children[index]);
|
this.container.insertBefore(newCard, this.container.children[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
newCard.style.transition = 'opacity 0.5s ease-in';
|
newCard.classList.add('card-visible');
|
||||||
newCard.style.opacity = '1';
|
});
|
||||||
}, 10);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
|
const newDepartureIds = newDepartures.map(d => d.journey.id.toString());
|
||||||
currentCards.forEach(card => {
|
currentCards.forEach(card => {
|
||||||
if (!newDepartureIds.includes(card.dataset.journeyId)) {
|
if (!newDepartureIds.includes(card.dataset.journeyId)) {
|
||||||
card.style.transition = 'opacity 0.5s ease-out';
|
card.classList.add('card-leaving');
|
||||||
card.style.opacity = '0';
|
card.addEventListener('transitionend', () => {
|
||||||
setTimeout(() => {
|
card.remove();
|
||||||
if (card.parentNode) {
|
}, { once: true });
|
||||||
card.parentNode.removeChild(card);
|
// Fallback removal if transitionend doesn't fire
|
||||||
}
|
setTimeout(() => card.remove(), 600);
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -505,13 +503,10 @@ class DeparturesManager {
|
|||||||
* @param {HTMLElement} element - Element to highlight
|
* @param {HTMLElement} element - Element to highlight
|
||||||
*/
|
*/
|
||||||
highlightElement(element) {
|
highlightElement(element) {
|
||||||
element.style.transition = 'none';
|
element.classList.remove('highlight-flash');
|
||||||
element.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
|
// Force reflow to restart animation
|
||||||
|
void element.offsetWidth;
|
||||||
setTimeout(() => {
|
element.classList.add('highlight-flash');
|
||||||
element.style.transition = 'background-color 1.5s ease-out';
|
|
||||||
element.style.backgroundColor = 'transparent';
|
|
||||||
}, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -632,5 +627,8 @@ class DeparturesManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the class
|
// ES module export
|
||||||
|
export { DeparturesManager };
|
||||||
|
|
||||||
|
// Keep window reference for backward compatibility
|
||||||
window.DeparturesManager = DeparturesManager;
|
window.DeparturesManager = DeparturesManager;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class WeatherManager {
|
|||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
// Default options
|
// Default options
|
||||||
// Get API key from options, window (injected by server from .env), or fallback
|
// 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 = {
|
this.options = {
|
||||||
latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude
|
latitude: options.latitude || (window.DEFAULT_LOCATION?.latitude) || 59.3293, // Stockholm latitude
|
||||||
@@ -24,6 +24,7 @@ class WeatherManager {
|
|||||||
this.sunTimes = null;
|
this.sunTimes = null;
|
||||||
this.isDarkMode = false;
|
this.isDarkMode = false;
|
||||||
this.lastUpdated = null;
|
this.lastUpdated = null;
|
||||||
|
this.daylightBarUpdateInterval = null;
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
this.init();
|
this.init();
|
||||||
@@ -34,6 +35,23 @@ class WeatherManager {
|
|||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
try {
|
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
|
// Fetch weather data
|
||||||
await this.fetchWeatherData();
|
await this.fetchWeatherData();
|
||||||
|
|
||||||
@@ -83,8 +101,8 @@ class WeatherManager {
|
|||||||
*/
|
*/
|
||||||
async fetchWeatherData() {
|
async fetchWeatherData() {
|
||||||
try {
|
try {
|
||||||
// Fetch current weather
|
// 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&appid=${this.options.apiKey}`;
|
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 currentWeatherResponse = await fetch(currentWeatherUrl);
|
||||||
const currentWeatherData = await currentWeatherResponse.json();
|
const currentWeatherData = await currentWeatherResponse.json();
|
||||||
|
|
||||||
@@ -92,8 +110,8 @@ class WeatherManager {
|
|||||||
throw new Error(`API Error: ${currentWeatherData.message}`);
|
throw new Error(`API Error: ${currentWeatherData.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch hourly forecast
|
// 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&appid=${this.options.apiKey}`;
|
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 forecastResponse = await fetch(forecastUrl);
|
||||||
const forecastData = await forecastResponse.json();
|
const forecastData = await forecastResponse.json();
|
||||||
|
|
||||||
@@ -107,7 +125,7 @@ class WeatherManager {
|
|||||||
this.lastUpdated = new Date();
|
this.lastUpdated = new Date();
|
||||||
|
|
||||||
// Extract sunrise and sunset times from the API response
|
// Extract sunrise and sunset times from the API response
|
||||||
this.updateSunTimesFromApi(currentWeatherData);
|
await this.updateSunTimesFromApi(currentWeatherData);
|
||||||
|
|
||||||
// Update the UI with the new data
|
// Update the UI with the new data
|
||||||
this.updateWeatherUI();
|
this.updateWeatherUI();
|
||||||
@@ -158,7 +176,7 @@ class WeatherManager {
|
|||||||
* Process forecast data from API response
|
* Process forecast data from API response
|
||||||
*/
|
*/
|
||||||
processForecast(data) {
|
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 => {
|
return data.list.slice(0, 7).map(item => {
|
||||||
const iconCode = item.weather[0].icon;
|
const iconCode = item.weather[0].icon;
|
||||||
return {
|
return {
|
||||||
@@ -375,6 +393,11 @@ class WeatherManager {
|
|||||||
const sunsetTime = this.formatTime(this.sunTimes.today.sunset);
|
const sunsetTime = this.formatTime(this.sunTimes.today.sunset);
|
||||||
sunTimesElement.textContent = `☀️ Sunrise: ${sunriseTime} | 🌙 Sunset: ${sunsetTime}`;
|
sunTimesElement.textContent = `☀️ Sunrise: ${sunriseTime} | 🌙 Sunset: ${sunsetTime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update daylight hours bar
|
||||||
|
if (this.sunTimes) {
|
||||||
|
this.updateDaylightHoursBar();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating weather UI:', error);
|
console.error('Error updating weather UI:', error);
|
||||||
}
|
}
|
||||||
@@ -383,24 +406,20 @@ class WeatherManager {
|
|||||||
/**
|
/**
|
||||||
* Update sunrise and sunset times from API data
|
* Update sunrise and sunset times from API data
|
||||||
*/
|
*/
|
||||||
updateSunTimesFromApi(data) {
|
async updateSunTimesFromApi(data) {
|
||||||
if (!data || !data.sys || !data.sys.sunrise || !data.sys.sunset) {
|
if (!data || !data.sys || !data.sys.sunrise || !data.sys.sunset) {
|
||||||
console.warn('No sunrise/sunset data in API response, using calculated times');
|
console.warn('No sunrise/sunset data in API response, using calculated times');
|
||||||
this.updateSunTimesFromCalculation();
|
await this.updateSunTimesFromCalculation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const today = new Date();
|
// Create Date objects from Unix timestamps for today
|
||||||
const tomorrow = new Date(today);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
|
|
||||||
// Create Date objects from Unix timestamps
|
|
||||||
const sunrise = new Date(data.sys.sunrise * 1000);
|
const sunrise = new Date(data.sys.sunrise * 1000);
|
||||||
const sunset = new Date(data.sys.sunset * 1000);
|
const sunset = new Date(data.sys.sunset * 1000);
|
||||||
|
|
||||||
// Use calculated times for tomorrow
|
// Fetch tomorrow's times from sunrise-sunset.org API
|
||||||
const tomorrowTimes = this.calculateSunTimes(tomorrow);
|
const tomorrowTimes = await this.fetchSunTimes('tomorrow');
|
||||||
|
|
||||||
this.sunTimes = {
|
this.sunTimes = {
|
||||||
today: { sunrise, sunset },
|
today: { sunrise, sunset },
|
||||||
@@ -411,92 +430,62 @@ class WeatherManager {
|
|||||||
return this.sunTimes;
|
return this.sunTimes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating sun times from API:', 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() {
|
async updateSunTimesFromCalculation() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Calculate sun times based on date and location
|
const [todayData, tomorrowData] = await Promise.all([
|
||||||
const today = new Date();
|
this.fetchSunTimes('today'),
|
||||||
const tomorrow = new Date(today);
|
this.fetchSunTimes('tomorrow')
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
]);
|
||||||
|
|
||||||
this.sunTimes = {
|
this.sunTimes = {
|
||||||
today: this.calculateSunTimes(today),
|
today: todayData,
|
||||||
tomorrow: this.calculateSunTimes(tomorrow)
|
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;
|
return this.sunTimes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating sun times from calculation:', error);
|
console.error('Error fetching sun times from API, using defaults:', error);
|
||||||
// Fallback to default times if calculation fails
|
|
||||||
const defaultSunrise = new Date();
|
const defaultSunrise = new Date();
|
||||||
defaultSunrise.setHours(6, 45, 0, 0);
|
defaultSunrise.setHours(7, 0, 0, 0);
|
||||||
|
|
||||||
const defaultSunset = new Date();
|
const defaultSunset = new Date();
|
||||||
defaultSunset.setHours(17, 32, 0, 0);
|
defaultSunset.setHours(16, 0, 0, 0);
|
||||||
|
|
||||||
this.sunTimes = {
|
this.sunTimes = {
|
||||||
today: {
|
today: { sunrise: defaultSunrise, sunset: defaultSunset },
|
||||||
sunrise: defaultSunrise,
|
tomorrow: { sunrise: defaultSunrise, sunset: defaultSunset }
|
||||||
sunset: defaultSunset
|
|
||||||
},
|
|
||||||
tomorrow: {
|
|
||||||
sunrise: defaultSunrise,
|
|
||||||
sunset: defaultSunset
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return this.sunTimes;
|
return this.sunTimes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate sunrise and sunset times for a given date
|
* Fetch sunrise/sunset times from sunrise-sunset.org API
|
||||||
* Uses a simplified algorithm
|
* @param {string} date - 'today', 'tomorrow', or YYYY-MM-DD
|
||||||
|
* @returns {Object} { sunrise: Date, sunset: Date }
|
||||||
*/
|
*/
|
||||||
calculateSunTimes(date) {
|
async fetchSunTimes(date) {
|
||||||
// This is a simplified calculation
|
const url = `https://api.sunrise-sunset.org/json?lat=${this.options.latitude}&lng=${this.options.longitude}&date=${date}&formatted=0`;
|
||||||
// For more accuracy, you would use a proper astronomical calculation
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
// Get day of year
|
if (data.status !== 'OK') {
|
||||||
const start = new Date(date.getFullYear(), 0, 0);
|
throw new Error(`Sunrise-sunset API returned status: ${data.status}`);
|
||||||
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
|
return {
|
||||||
// This is a very simplified model
|
sunrise: new Date(data.results.sunrise),
|
||||||
const latitude = this.options.latitude;
|
sunset: new Date(data.results.sunset)
|
||||||
|
};
|
||||||
// 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -588,7 +577,136 @@ class WeatherManager {
|
|||||||
if (!this.lastUpdated) return 'Never';
|
if (!this.lastUpdated) return 'Never';
|
||||||
return this.formatTime(this.lastUpdated);
|
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;
|
window.WeatherManager = WeatherManager;
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
* Initializes all components when the DOM is ready
|
* 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to ensure content wrapper exists for rotated orientations
|
* Function to ensure content wrapper exists for rotated orientations
|
||||||
*/
|
*/
|
||||||
function ensureContentWrapper() {
|
function ensureContentWrapper() {
|
||||||
if (!document.getElementById('content-wrapper')) {
|
if (!document.getElementById('content-wrapper')) {
|
||||||
if (window.logger) {
|
logger.info('Creating content wrapper');
|
||||||
window.logger.info('Creating content wrapper');
|
|
||||||
} else {
|
|
||||||
console.log('Creating content wrapper');
|
|
||||||
}
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.id = 'content-wrapper';
|
wrapper.id = 'content-wrapper';
|
||||||
|
|
||||||
@@ -40,28 +43,18 @@ function ensureContentWrapper() {
|
|||||||
|
|
||||||
// Initialize components when the DOM is loaded
|
// Initialize components when the DOM is loaded
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
if (window.logger) {
|
logger.info('DOM fully loaded');
|
||||||
window.logger.info('DOM fully loaded');
|
|
||||||
} else {
|
|
||||||
console.log('DOM fully loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize ConfigManager first
|
// Initialize ConfigManager first
|
||||||
if (window.logger) {
|
logger.info('Creating ConfigManager...');
|
||||||
window.logger.info('Creating ConfigManager...');
|
|
||||||
} else {
|
|
||||||
console.log('Creating ConfigManager...');
|
|
||||||
}
|
|
||||||
window.configManager = new ConfigManager({
|
window.configManager = new ConfigManager({
|
||||||
defaultOrientation: 'normal',
|
defaultOrientation: 'normal',
|
||||||
defaultDarkMode: 'auto'
|
defaultDarkMode: 'auto'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: ConfigManager already creates the config button and modal
|
|
||||||
|
|
||||||
// Initialize Clock
|
// Initialize Clock
|
||||||
const timezone = window.Constants?.TIMEZONE || 'Europe/Stockholm';
|
const timezone = Constants.TIMEZONE || 'Europe/Stockholm';
|
||||||
window.clock = new Clock({
|
window.clock = new Clock({
|
||||||
elementId: 'clock',
|
elementId: 'clock',
|
||||||
timezone: timezone
|
timezone: timezone
|
||||||
@@ -69,25 +62,20 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
|
|
||||||
// Initialize WeatherManager with location from window config or constants
|
// Initialize WeatherManager with location from window config or constants
|
||||||
const defaultLat = window.DEFAULT_LOCATION?.latitude ||
|
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 ||
|
const defaultLon = window.DEFAULT_LOCATION?.longitude ||
|
||||||
(window.Constants?.DEFAULT_LOCATION?.LONGITUDE) || 18.0686;
|
Constants.DEFAULT_LOCATION.LONGITUDE || 18.0686;
|
||||||
window.weatherManager = new WeatherManager({
|
window.weatherManager = new WeatherManager({
|
||||||
latitude: defaultLat,
|
latitude: defaultLat,
|
||||||
longitude: defaultLon
|
longitude: defaultLon
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize departures - use DeparturesManager
|
// Initialize DeparturesManager
|
||||||
if (typeof DeparturesManager !== 'undefined') {
|
|
||||||
window.departuresManager = new DeparturesManager({
|
window.departuresManager = new DeparturesManager({
|
||||||
containerId: 'departures',
|
containerId: 'departures',
|
||||||
statusId: 'status',
|
statusId: 'status',
|
||||||
lastUpdatedId: 'last-updated'
|
lastUpdatedId: 'last-updated'
|
||||||
});
|
});
|
||||||
} else if (typeof initDepartures === 'function') {
|
|
||||||
// Fallback to legacy function if DeparturesManager not available
|
|
||||||
initDepartures();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up event listeners
|
// Set up event listeners
|
||||||
document.addEventListener('darkModeChanged', event => {
|
document.addEventListener('darkModeChanged', event => {
|
||||||
@@ -103,17 +91,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
// Ensure content wrapper exists initially
|
// Ensure content wrapper exists initially
|
||||||
ensureContentWrapper();
|
ensureContentWrapper();
|
||||||
|
|
||||||
if (window.logger) {
|
logger.info('All components initialized successfully');
|
||||||
window.logger.info('All components initialized successfully');
|
|
||||||
} else {
|
|
||||||
console.log('All components initialized successfully');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (window.logger) {
|
logger.error('Error during initialization:', error);
|
||||||
window.logger.error('Error during initialization:', error);
|
|
||||||
} else {
|
|
||||||
console.error('Error during initialization:', error);
|
|
||||||
}
|
|
||||||
const errorDiv = document.createElement('div');
|
const errorDiv = document.createElement('div');
|
||||||
errorDiv.className = 'error';
|
errorDiv.className = 'error';
|
||||||
errorDiv.textContent = `Initialization error: ${error.message}`;
|
errorDiv.textContent = `Initialization error: ${error.message}`;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const Constants = {
|
|||||||
|
|
||||||
// Refresh intervals (in milliseconds)
|
// Refresh intervals (in milliseconds)
|
||||||
REFRESH: {
|
REFRESH: {
|
||||||
DEPARTURES: 5000, // 5 seconds
|
DEPARTURES: 30000, // 30 seconds
|
||||||
WEATHER: 30 * 60 * 1000, // 30 minutes
|
WEATHER: 30 * 60 * 1000, // 30 minutes
|
||||||
DARK_MODE_CHECK: 60000 // 1 minute
|
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;
|
window.Constants = Constants;
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class Logger {
|
|||||||
// Create a singleton instance
|
// Create a singleton instance
|
||||||
const logger = new Logger();
|
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;
|
||||||
window.logger = logger;
|
window.logger = logger;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const url = require('url');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ loadSitesConfig();
|
|||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = http.createServer(async (req, res) => {
|
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-Origin', '*');
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
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);
|
sitesRouter.handleSiteSearch(req, res, parsedUrl);
|
||||||
}
|
}
|
||||||
else if (parsedUrl.pathname === '/api/sites/nearby') {
|
else if (parsedUrl.pathname === '/api/sites/nearby') {
|
||||||
sitesRouter.handleNearbySites(req, res, parsedUrl);
|
await sitesRouter.handleNearbySites(req, res, parsedUrl);
|
||||||
}
|
}
|
||||||
else if (parsedUrl.pathname === '/api/config') {
|
else if (parsedUrl.pathname === '/api/config') {
|
||||||
configRouter.handleGetConfig(req, res, config);
|
configRouter.handleGetConfig(req, res, config);
|
||||||
|
|||||||
@@ -23,62 +23,14 @@ function fetchDeparturesForSite(siteId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
console.log('Raw API response:', data.substring(0, 200) + '...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(data);
|
const parsedData = JSON.parse(data);
|
||||||
console.log('Successfully parsed as regular JSON');
|
|
||||||
resolve(parsedData);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error processing API response:', error);
|
console.error('Error parsing departures API response:', error);
|
||||||
// Return empty departures array instead of rejecting to be more resilient
|
|
||||||
resolve({
|
resolve({
|
||||||
departures: [],
|
departures: [],
|
||||||
error: 'Error processing API response: ' + error.message
|
error: 'Failed to parse API response: ' + error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,131 +1,147 @@
|
|||||||
/**
|
/**
|
||||||
* Sites route handler
|
* Sites route handler
|
||||||
* Handles site search and nearby sites queries
|
* 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');
|
const https = require('https');
|
||||||
|
|
||||||
/**
|
// ── Site cache for nearby lookups ──────────────────────────────────────────
|
||||||
* Normalize site data from API response to consistent format
|
let cachedSites = null;
|
||||||
* @param {Object} site - Raw site data from API
|
let cacheTimestamp = null;
|
||||||
* @returns {Object} - Normalized site object
|
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
*/
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse sites from API response (handles multiple response formats)
|
* Fetch and cache all sites from the SL Transport API
|
||||||
* @param {Object|Array} parsedData - Parsed JSON data from API
|
* The /v1/sites endpoint returns ~6500 sites with coordinates.
|
||||||
* @returns {Array<Object>} - Array of normalized sites
|
* We fetch this once and reuse it for nearby-site lookups.
|
||||||
|
* @returns {Promise<Array>} Array of normalized site objects
|
||||||
*/
|
*/
|
||||||
function parseSitesFromResponse(parsedData) {
|
function getAllSites() {
|
||||||
let sites = [];
|
if (cachedSites && cacheTimestamp && (Date.now() - cacheTimestamp < CACHE_TTL)) {
|
||||||
|
return Promise.resolve(cachedSites);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
* Handle site search endpoint using SL Journey Planner v2 Stop Finder
|
||||||
* @param {http.IncomingMessage} req - HTTP request object
|
* This endpoint does real server-side search (unlike /v1/sites which returns everything)
|
||||||
* @param {http.ServerResponse} res - HTTP response object
|
|
||||||
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
|
|
||||||
*/
|
*/
|
||||||
function handleSiteSearch(req, res, parsedUrl) {
|
function handleSiteSearch(req, res, parsedUrl) {
|
||||||
const query = parsedUrl.query.q;
|
const query = parsedUrl.searchParams.get('q');
|
||||||
if (!query || query.length < 2) {
|
if (!query || query.length < 2) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
|
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(query)}`;
|
// any_obj_filter_sf=2 restricts results to stops only
|
||||||
console.log(`Searching sites: ${searchUrl}`);
|
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) => {
|
https.get(searchUrl, (apiRes) => {
|
||||||
let data = '';
|
let data = '';
|
||||||
|
|
||||||
if (apiRes.statusCode < 200 || apiRes.statusCode >= 300) {
|
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.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] }));
|
res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
apiRes.on('data', (chunk) => {
|
apiRes.on('data', chunk => { data += chunk; });
|
||||||
data += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
apiRes.on('end', () => {
|
apiRes.on('end', () => {
|
||||||
try {
|
try {
|
||||||
console.log('Raw API response:', data.substring(0, 500));
|
const parsed = JSON.parse(data);
|
||||||
const parsedData = JSON.parse(data);
|
const locations = parsed.locations || [];
|
||||||
console.log('Parsed data:', JSON.stringify(parsedData).substring(0, 500));
|
|
||||||
|
|
||||||
const sites = parseSitesFromResponse(parsedData);
|
const sites = locations
|
||||||
|
.filter(loc => loc.type === 'stop' && loc.properties && loc.properties.stopId)
|
||||||
if (sites.length > 0) {
|
.map(loc => ({
|
||||||
console.log('Sample site structure:', JSON.stringify(sites[0], null, 2));
|
id: stopIdToSiteId(loc.properties.stopId),
|
||||||
const sitesWithCoords = sites.filter(s => s.lat && s.lon);
|
name: loc.disassembledName || loc.name || 'Unknown',
|
||||||
console.log(`Found ${sites.length} sites, ${sitesWithCoords.length} with coordinates`);
|
lat: loc.coord ? loc.coord[0] : null,
|
||||||
} else {
|
lon: loc.coord ? loc.coord[1] : null
|
||||||
console.log('No sites found');
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
|
console.log(`Search "${query}" returned ${sites.length} stops`);
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ sites }));
|
res.end(JSON.stringify({ sites }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing site search response:', error);
|
console.error('Error parsing search response:', error);
|
||||||
console.error('Response data:', data.substring(0, 500));
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
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) => {
|
}).on('error', (error) => {
|
||||||
console.error('Error searching sites:', error);
|
console.error('Error searching sites:', error);
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
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)
|
* Calculate distance between two coordinates using equirectangular approximation
|
||||||
* @param {number} lat1 - Latitude of point 1
|
* Accurate enough for distances under ~100km at Stockholm's latitude
|
||||||
* @param {number} lon1 - Longitude of point 1
|
* @returns {number} Distance in meters
|
||||||
* @param {number} lat2 - Latitude of point 2
|
|
||||||
* @param {number} lon2 - Longitude of point 2
|
|
||||||
* @returns {number} - Distance in meters
|
|
||||||
*/
|
*/
|
||||||
function calculateDistance(lat1, lon1, lat2, lon2) {
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||||
return Math.sqrt(
|
const dLat = (lat2 - lat1) * 111000;
|
||||||
Math.pow((lat1 - lat2) * 111000, 2) +
|
const dLon = (lon2 - lon1) * 111000 * Math.cos(lat1 * Math.PI / 180);
|
||||||
Math.pow((lon1 - lon2) * 111000 * Math.cos(lat1 * Math.PI / 180), 2)
|
return Math.sqrt(dLat * dLat + dLon * dLon);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle nearby sites endpoint
|
* Handle nearby sites endpoint
|
||||||
* @param {http.IncomingMessage} req - HTTP request object
|
* Uses cached site list — no redundant API calls per request
|
||||||
* @param {http.ServerResponse} res - HTTP response object
|
|
||||||
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
|
|
||||||
*/
|
*/
|
||||||
function handleNearbySites(req, res, parsedUrl) {
|
async function handleNearbySites(req, res, parsedUrl) {
|
||||||
const lat = parseFloat(parsedUrl.query.lat);
|
const lat = parseFloat(parsedUrl.searchParams.get('lat'));
|
||||||
const lon = parseFloat(parsedUrl.query.lon);
|
const lon = parseFloat(parsedUrl.searchParams.get('lon'));
|
||||||
const radius = parseInt(parsedUrl.query.radius) || 5000; // Default 5km radius
|
const radius = parseInt(parsedUrl.searchParams.get('radius')) || 1000; // Default 1km
|
||||||
|
|
||||||
if (isNaN(lat) || isNaN(lon)) {
|
if (isNaN(lat) || isNaN(lon)) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
@@ -133,66 +149,30 @@ function handleNearbySites(req, res, parsedUrl) {
|
|||||||
return;
|
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 {
|
try {
|
||||||
const parsedData = JSON.parse(data);
|
const allSites = await getAllSites();
|
||||||
const sites = parseSitesFromResponse(parsedData);
|
|
||||||
|
|
||||||
sites.forEach(site => {
|
const nearby = allSites
|
||||||
if (site.lat && site.lon) {
|
.filter(site => site.lat && site.lon)
|
||||||
const distance = calculateDistance(lat, lon, 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) {
|
console.log(`Found ${nearby.length} sites within ${radius}m of [${lat}, ${lon}]`);
|
||||||
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.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ sites: uniqueSites }));
|
res.end(JSON.stringify({ sites: nearby }));
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
completedSearches++;
|
console.error('Error fetching nearby sites:', error);
|
||||||
if (completedSearches === searchTerms.length) {
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
|
res.end(JSON.stringify({ error: 'Error fetching nearby sites', sites: [] }));
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
handleSiteSearch,
|
handleSiteSearch,
|
||||||
handleNearbySites,
|
handleNearbySites,
|
||||||
normalizeSite,
|
getAllSites
|
||||||
parseSitesFromResponse
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user