Files
SignageHTML/public/js/components/ConfigManager.js
kyle 392a50b535 Refactor: Complete codebase reorganization and modernization
- Split server.js routes into modular files (server/routes/)
  - departures.js: Departure data endpoints
  - sites.js: Site search and nearby sites
  - config.js: Configuration endpoints

- Reorganized file structure following Node.js best practices:
  - Moved sites-config.json to config/sites.json
  - Moved API_RESPONSE_DOCUMENTATION.md to docs/
  - Moved raspberry-pi-setup.sh to scripts/
  - Archived legacy files to archive/ directory

- Updated all code references to new file locations
- Added archive/ to .gitignore to exclude legacy files from repo
- Updated README.md with new structure and organization
- All functionality tested and working correctly

Version: 1.2.0
2026-01-01 10:52:21 +01:00

1164 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* config.js - A modular configuration component for the SL Transport Departures app
* Allows changing settings on the fly, such as orientation (portrait/landscape)
*/
class ConfigManager {
constructor(options = {}) {
// Default options
this.options = {
configButtonId: 'config-button',
configModalId: 'config-modal',
saveToLocalStorage: true,
defaultOrientation: 'normal',
defaultDarkMode: 'auto', // 'auto', 'on', or 'off'
...options
};
// Add updateBackgroundPreview function
this.updateBackgroundPreview = function() {
const preview = document.getElementById('background-preview');
const imageUrl = document.getElementById('background-image-url').value;
if (preview) {
if (imageUrl && imageUrl.trim() !== '') {
preview.innerHTML = `<img src="${imageUrl}" alt="Background preview">`;
} else {
preview.innerHTML = '<div class="no-image">No image selected</div>';
}
}
};
// Configuration state
this.config = {
orientation: this.options.defaultOrientation,
darkMode: this.options.defaultDarkMode,
backgroundImage: '',
backgroundOpacity: 0.3, // Default opacity (30%)
sites: [
{
id: '1411',
name: 'Ambassaderna',
enabled: true
}
],
combineSameDirection: true, // Combine departures going in the same direction
...this.loadConfig() // Load saved config if available
};
// Sync configuration with server
this.syncConfig();
// Create UI elements
this.createConfigButton();
this.createConfigModal();
// Add keyboard shortcuts for testing
this.setupKeyboardShortcuts();
// Apply initial configuration
this.applyConfig();
}
/**
* Create the configuration button (gear icon)
*/
createConfigButton() {
const buttonContainer = document.createElement('div');
buttonContainer.id = this.options.configButtonId;
buttonContainer.className = 'config-button';
buttonContainer.title = 'Settings';
buttonContainer.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.65.07-.97 0-.32-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1 0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66z" fill="#fff"/>
</svg>
`;
buttonContainer.addEventListener('click', () => this.toggleConfigModal());
document.body.appendChild(buttonContainer);
}
/**
* Create the configuration modal
*/
createConfigModal() {
const modalContainer = document.createElement('div');
modalContainer.id = this.options.configModalId;
modalContainer.className = 'config-modal';
modalContainer.style.display = 'none';
modalContainer.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<h2>Settings</h2>
<span class="config-modal-close">&times;</span>
</div>
<div class="config-tabs">
<button class="config-tab active" data-tab="display">Display</button>
<button class="config-tab" data-tab="appearance">Appearance</button>
<button class="config-tab" data-tab="content">Content</button>
<button class="config-tab" data-tab="options">Options</button>
</div>
<div class="config-modal-body">
<!-- Display Tab -->
<div class="config-tab-content active" id="tab-display">
<div class="config-option">
<label for="orientation-select">Screen Orientation:</label>
<select id="orientation-select">
<option value="normal" ${this.config.orientation === 'normal' ? 'selected' : ''}>Normal (0°)</option>
<option value="vertical" ${this.config.orientation === 'vertical' ? 'selected' : ''}>Vertical (90°)</option>
<option value="upsidedown" ${this.config.orientation === 'upsidedown' ? 'selected' : ''}>Upside Down (180°)</option>
<option value="vertical-reverse" ${this.config.orientation === 'vertical-reverse' ? 'selected' : ''}>Vertical Reverse (270°)</option>
<option value="landscape" ${this.config.orientation === 'landscape' ? 'selected' : ''}>Landscape (2-column)</option>
</select>
</div>
<div class="config-option">
<label for="dark-mode-select">Dark Mode:</label>
<select id="dark-mode-select">
<option value="auto" ${this.config.darkMode === 'auto' ? 'selected' : ''}>Automatic (Sunset/Sunrise)</option>
<option value="on" ${this.config.darkMode === 'on' ? 'selected' : ''}>Always On</option>
<option value="off" ${this.config.darkMode === 'off' ? 'selected' : ''}>Always Off</option>
</select>
<div class="sun-times" id="sun-times">
<small>Sunrise: <span id="sunrise-time">--:--</span> | Sunset: <span id="sunset-time">--:--</span></small>
</div>
</div>
</div>
<!-- Appearance Tab -->
<div class="config-tab-content" id="tab-appearance">
<div class="config-option">
<label for="background-image-url">Background Image:</label>
<input type="text" id="background-image-url" placeholder="Enter image URL" value="${this.config.backgroundImage}">
<div style="display: flex; gap: 10px; margin-top: 5px;">
<button id="test-image-button" style="padding: 5px 10px;">Use Test Image</button>
<label for="local-image-input" style="padding: 5px 10px; background-color: #ddd; border-radius: 4px; cursor: pointer;">
Select Local Image
</label>
<input type="file" id="local-image-input" accept="image/*" style="display: none;">
</div>
<div class="background-preview" id="background-preview">
${this.config.backgroundImage ? `<img src="${this.config.backgroundImage}" alt="Background preview">` : '<div class="no-image">No image selected</div>'}
</div>
</div>
<div class="config-option">
<label for="background-opacity">Background Opacity: <span id="opacity-value">${Math.round(this.config.backgroundOpacity * 100)}%</span></label>
<input type="range" id="background-opacity" min="0" max="1" step="0.05" value="${this.config.backgroundOpacity}">
</div>
</div>
<!-- Content Tab -->
<div class="config-tab-content" id="tab-content">
<div class="config-option">
<label>Transit Sites:</label>
<div style="margin-bottom: 15px;">
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" id="site-search-input" placeholder="Search for transit stop..." style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<button id="search-site-button" style="padding: 8px 15px; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer;">Search</button>
<button id="select-from-map-button" style="padding: 8px 15px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Select from Map</button>
</div>
<div id="site-search-results" style="display: none; max-height: 200px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; background: white; margin-top: 5px;"></div>
</div>
<div id="sites-container">
${this.generateSitesHTML()}
</div>
<div style="margin-top: 10px;">
<button id="add-site-button" style="padding: 5px 10px;">Add Site Manually</button>
</div>
</div>
</div>
<!-- Options Tab -->
<div class="config-tab-content" id="tab-options">
<div class="config-option">
<label for="combine-directions">
<input type="checkbox" id="combine-directions" ${this.config.combineSameDirection ? 'checked' : ''}>
Combine departures going in the same direction
</label>
</div>
</div>
</div>
<div class="config-modal-footer">
<button id="config-save-button">Save</button>
<button id="config-cancel-button">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modalContainer);
// Add tab switching functionality
this.setupTabs(modalContainer);
// Add event listeners
modalContainer.querySelector('.config-modal-close').addEventListener('click', () => this.hideConfigModal());
modalContainer.querySelector('#config-cancel-button').addEventListener('click', () => this.hideConfigModal());
modalContainer.querySelector('#config-save-button').addEventListener('click', () => this.saveAndApplyConfig());
modalContainer.querySelector('#test-image-button').addEventListener('click', () => {
const testImageUrl = 'https://images.unsplash.com/photo-1509356843151-3e7d96241e11?q=80&w=1000';
document.getElementById('background-image-url').value = testImageUrl;
this.updateBackgroundPreview();
});
// Add event listener for add site button
const addSiteButton = modalContainer.querySelector('#add-site-button');
if (addSiteButton) {
addSiteButton.addEventListener('click', () => this.addNewSite());
}
// Add event listeners for site search
const searchSiteButton = modalContainer.querySelector('#search-site-button');
const siteSearchInput = modalContainer.querySelector('#site-search-input');
if (searchSiteButton && siteSearchInput) {
searchSiteButton.addEventListener('click', () => this.searchSites());
siteSearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.searchSites();
}
});
}
// Add event listener for map selection
const selectFromMapButton = modalContainer.querySelector('#select-from-map-button');
if (selectFromMapButton) {
selectFromMapButton.addEventListener('click', () => this.showMapSelector());
}
// Add event listener for local image selection
const localImageInput = modalContainer.querySelector('#local-image-input');
if (localImageInput) {
localImageInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const imageDataUrl = e.target.result;
document.getElementById('background-image-url').value = imageDataUrl;
this.updateBackgroundPreview();
};
reader.readAsDataURL(file);
}
});
}
// Close modal when clicking outside the content
modalContainer.addEventListener('click', (event) => {
if (event.target === modalContainer) {
this.hideConfigModal();
}
});
// Add event listeners for site management
this.setupSiteEventListeners();
}
/**
* Setup event listeners for site management
*/
setupSiteEventListeners() {
const sitesContainer = document.getElementById('sites-container');
if (!sitesContainer) return;
const removeButtons = sitesContainer.querySelectorAll('.remove-site-button');
removeButtons.forEach(button => {
button.addEventListener('click', (event) => {
const siteItem = event.target.closest('.site-item');
if (siteItem) {
const index = parseInt(siteItem.dataset.index);
this.removeSite(index);
}
});
});
}
/**
* Add a new site to the configuration
*/
addNewSite() {
this.config.sites.push({
id: '',
name: 'New Site',
enabled: true
});
const sitesContainer = document.getElementById('sites-container');
if (sitesContainer) {
sitesContainer.innerHTML = this.generateSitesHTML();
this.setupSiteEventListeners();
}
}
/**
* Remove a site from the configuration
*/
removeSite(index) {
if (index >= 0 && index < this.config.sites.length) {
this.config.sites.splice(index, 1);
const sitesContainer = document.getElementById('sites-container');
if (sitesContainer) {
sitesContainer.innerHTML = this.generateSitesHTML();
this.setupSiteEventListeners();
}
}
}
/**
* Setup tab switching functionality
*/
setupTabs(modalContainer) {
const tabs = modalContainer.querySelectorAll('.config-tab');
const tabContents = modalContainer.querySelectorAll('.config-tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.dataset.tab;
// Remove active class from all tabs and contents
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked tab and corresponding content
tab.classList.add('active');
const targetContent = modalContainer.querySelector(`#tab-${targetTab}`);
if (targetContent) {
targetContent.classList.add('active');
}
});
});
}
/**
* Toggle the configuration modal visibility
*/
toggleConfigModal() {
const modal = document.getElementById(this.options.configModalId);
if (modal.style.display === 'none') {
this.showConfigModal();
} else {
this.hideConfigModal();
}
}
/**
* Hide the configuration modal
*/
hideConfigModal() {
const modal = document.getElementById(this.options.configModalId);
modal.style.display = 'none';
}
/**
* Show the configuration modal
*/
showConfigModal() {
const modal = document.getElementById(this.options.configModalId);
modal.style.display = 'flex';
// Reset to first tab
const tabs = modal.querySelectorAll('.config-tab');
const tabContents = modal.querySelectorAll('.config-tab-content');
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
if (tabs.length > 0) {
tabs[0].classList.add('active');
}
if (tabContents.length > 0) {
tabContents[0].classList.add('active');
}
// Update form values to match current config
document.getElementById('orientation-select').value = this.config.orientation;
document.getElementById('dark-mode-select').value = this.config.darkMode;
document.getElementById('combine-directions').checked = this.config.combineSameDirection;
// Update sites container
const sitesContainer = document.getElementById('sites-container');
if (sitesContainer) {
sitesContainer.innerHTML = this.generateSitesHTML();
this.setupSiteEventListeners();
}
// Update background image and opacity values
const backgroundImageUrl = document.getElementById('background-image-url');
const backgroundOpacity = document.getElementById('background-opacity');
const opacityValue = document.getElementById('opacity-value');
if (backgroundImageUrl) {
backgroundImageUrl.value = this.config.backgroundImage || '';
}
if (backgroundOpacity) {
backgroundOpacity.value = this.config.backgroundOpacity;
}
if (opacityValue) {
opacityValue.textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
}
// Update background preview
this.updateBackgroundPreview();
// Update sun times display
this.updateSunTimesDisplay();
// Add event listeners
this.setupModalEventListeners();
}
/**
* Setup event listeners for the modal
*/
setupModalEventListeners() {
// Background image URL input
const imageUrlInput = document.getElementById('background-image-url');
if (imageUrlInput) {
const newInput = imageUrlInput.cloneNode(true);
imageUrlInput.parentNode.replaceChild(newInput, imageUrlInput);
newInput.addEventListener('input', () => this.updateBackgroundPreview());
}
// Opacity slider
const opacitySlider = document.getElementById('background-opacity');
if (opacitySlider) {
const newSlider = opacitySlider.cloneNode(true);
opacitySlider.parentNode.replaceChild(newSlider, opacitySlider);
newSlider.addEventListener('input', (e) => {
document.getElementById('opacity-value').textContent = `${Math.round(e.target.value * 100)}%`;
});
}
// Local image selection
const localImageInput = document.getElementById('local-image-input');
if (localImageInput) {
const newInput = localImageInput.cloneNode(true);
localImageInput.parentNode.replaceChild(newInput, localImageInput);
newInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
document.getElementById('background-image-url').value = e.target.result;
this.updateBackgroundPreview();
};
reader.readAsDataURL(file);
}
});
}
}
/**
* Save and apply the configuration from the form
*/
saveAndApplyConfig() {
try {
// Get values from form
const orientationSelect = document.getElementById('orientation-select');
const darkModeSelect = document.getElementById('dark-mode-select');
const backgroundImageUrl = document.getElementById('background-image-url');
const backgroundOpacity = document.getElementById('background-opacity');
const combineDirections = document.getElementById('combine-directions');
// Get sites configuration from form
const sitesConfig = this.getSitesFromForm();
// Get the background image URL directly from the DOM
const imageUrlValue = document.querySelector('#background-image-url').value;
// Update config
this.config.orientation = orientationSelect.value;
this.config.darkMode = darkModeSelect.value;
this.config.backgroundImage = imageUrlValue;
this.config.backgroundOpacity = parseFloat(backgroundOpacity.value);
this.config.combineSameDirection = combineDirections.checked;
this.config.sites = sitesConfig;
// Save config
this.saveConfig();
// Sync with server
this.syncConfig();
// Apply config
this.applyConfig();
// Hide modal
this.hideConfigModal();
} catch (error) {
console.error('Error saving configuration:', error);
}
}
/**
* Apply the current configuration to the page
*/
applyConfig() {
// Apply orientation
document.body.classList.remove('normal', 'landscape', 'vertical', 'upsidedown', 'vertical-reverse');
document.body.classList.add(this.config.orientation);
// Apply dark mode setting
this.applyDarkModeSetting();
// Apply background image and opacity
this.applyBackgroundImage();
// Ensure content wrapper exists when changing to rotated modes
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(this.config.orientation)) {
this.ensureContentWrapper();
}
// Dispatch event for other components to react to config changes
const event = new CustomEvent('configChanged', { detail: { config: this.config } });
document.dispatchEvent(event);
}
/**
* Apply dark mode setting based on configuration
*/
applyDarkModeSetting() {
// Always update sun times display for informational purposes
this.updateSunTimesDisplay();
// If we have a WeatherManager instance
if (window.weatherManager) {
if (this.config.darkMode === 'auto') {
window.weatherManager.updateDarkModeBasedOnTime();
} else {
const isDarkMode = this.config.darkMode === 'on';
window.weatherManager.setDarkMode(isDarkMode);
}
} else {
const isDarkMode = this.config.darkMode === 'on';
document.body.classList.toggle('dark-mode', isDarkMode);
}
}
/**
* Apply background image and opacity settings
*/
applyBackgroundImage() {
// Remove any existing background overlay
const existingOverlay = document.getElementById('background-overlay');
if (existingOverlay) {
existingOverlay.remove();
}
// If there's a background image URL, create and apply the overlay
if (this.config.backgroundImage && this.config.backgroundImage.trim() !== '') {
const overlay = document.createElement('div');
overlay.id = 'background-overlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundImage = `url(${this.config.backgroundImage})`;
overlay.style.backgroundSize = 'cover';
overlay.style.backgroundPosition = 'center';
overlay.style.opacity = this.config.backgroundOpacity;
overlay.style.zIndex = '-1';
overlay.style.pointerEvents = 'none';
// Adjust background rotation based on orientation
if (this.config.orientation === 'vertical') {
overlay.style.transform = 'rotate(90deg) scale(2)';
overlay.style.transformOrigin = 'center center';
} else if (this.config.orientation === 'upsidedown') {
overlay.style.transform = 'rotate(180deg) scale(1.5)';
overlay.style.transformOrigin = 'center center';
} else if (this.config.orientation === 'vertical-reverse') {
overlay.style.transform = 'rotate(270deg) scale(2)';
overlay.style.transformOrigin = 'center center';
} else {
overlay.style.transform = 'scale(1.2)';
overlay.style.transformOrigin = 'center center';
}
// Insert as the first child of body
document.body.insertBefore(overlay, document.body.firstChild);
}
}
/**
* Update the sun times display in the config modal
*/
updateSunTimesDisplay() {
if (window.weatherManager) {
const sunriseElement = document.getElementById('sunrise-time');
const sunsetElement = document.getElementById('sunset-time');
if (sunriseElement && sunsetElement) {
const sunriseTime = window.weatherManager.getSunriseTime();
const sunsetTime = window.weatherManager.getSunsetTime();
sunriseElement.textContent = sunriseTime;
sunsetElement.textContent = sunsetTime;
const weatherSunTimesElement = document.querySelector('#custom-weather .sun-times');
if (weatherSunTimesElement) {
weatherSunTimesElement.textContent = `☀️ Sunrise: ${sunriseTime} | 🌙 Sunset: ${sunsetTime}`;
}
}
}
}
/**
* Generate HTML for sites configuration
*/
generateSitesHTML() {
if (!this.config.sites || this.config.sites.length === 0) {
return '<div class="no-sites">No sites configured</div>';
}
return this.config.sites.map((site, index) => `
<div class="site-item" data-index="${index}">
<div style="display: flex; align-items: center; margin-bottom: 5px;">
<input type="checkbox" class="site-enabled" ${site.enabled ? 'checked' : ''}>
<input type="text" class="site-name" value="${site.name}" placeholder="Site Name" style="flex: 1; margin: 0 5px;">
<button class="remove-site-button" style="padding: 2px 5px;">×</button>
</div>
<div style="display: flex; align-items: center;">
<span style="margin-right: 5px;">ID:</span>
<input type="text" class="site-id" value="${site.id}" placeholder="Site ID" style="width: 100px;">
</div>
</div>
`).join('');
}
/**
* Search for transit sites
*/
async searchSites() {
const searchInput = document.getElementById('site-search-input');
const resultsContainer = document.getElementById('site-search-results');
if (!searchInput || !resultsContainer) return;
const query = searchInput.value.trim();
if (!query || query.length < 2) {
resultsContainer.style.display = 'none';
return;
}
try {
resultsContainer.style.display = 'block';
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">Searching...</div>';
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
throw new Error(errorData.error || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (!data.sites || data.sites.length === 0) {
resultsContainer.innerHTML = '<div style="padding: 10px; text-align: center; color: #666;">No sites found. Try a different search term.</div>';
return;
}
resultsContainer.innerHTML = data.sites.map(site => `
<div class="site-search-result" style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background-color 0.2s;"
data-site-id="${site.id}" data-site-name="${site.name}">
<div style="font-weight: bold; color: #0061a1;">${site.name}</div>
<div style="font-size: 0.85em; color: #666;">ID: ${site.id}</div>
</div>
`).join('');
// 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);
searchInput.value = '';
resultsContainer.style.display = 'none';
});
result.addEventListener('mouseenter', () => {
result.style.backgroundColor = '#f5f5f5';
});
result.addEventListener('mouseleave', () => {
result.style.backgroundColor = 'white';
});
});
} catch (error) {
console.error('Error searching sites:', error);
let errorMessage = error.message || 'Unknown error';
// Provide more helpful error messages
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
errorMessage = 'Unable to connect to server. Please make sure the server is running.';
} else if (error.message.includes('HTTP')) {
errorMessage = `Server error: ${error.message}`;
}
resultsContainer.innerHTML = `<div style="padding: 10px; text-align: center; color: #d32f2f;">Error: ${errorMessage}</div>`;
}
}
/**
* Add a site from search results
*/
addSiteFromSearch(siteId, siteName) {
// Check if site already exists
if (this.config.sites.some(site => site.id === siteId)) {
alert(`Site "${siteName}" (ID: ${siteId}) is already in your list.`);
return;
}
this.config.sites.push({
id: siteId,
name: siteName,
enabled: true
});
const sitesContainer = document.getElementById('sites-container');
if (sitesContainer) {
sitesContainer.innerHTML = this.generateSitesHTML();
this.setupSiteEventListeners();
}
}
/**
* Show map selector for choosing transit sites
*/
async showMapSelector() {
// Create map modal
const mapModal = document.createElement('div');
mapModal.id = 'map-selector-modal';
mapModal.className = 'config-modal';
mapModal.style.display = 'flex';
mapModal.innerHTML = `
<div class="config-modal-content" style="max-width: 90vw; max-height: 90vh; width: 1200px;">
<div class="config-modal-header">
<h2>Select Transit Stop from Map</h2>
<span class="map-modal-close">&times;</span>
</div>
<div style="padding: 20px;">
<div style="margin-bottom: 15px;">
<input type="text" id="map-search-input" placeholder="Search area or zoom to location..." style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
</div>
<div id="map-container" style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;"></div>
<div style="margin-top: 15px; padding: 10px; background-color: #f0f0f0; border-radius: 4px;">
<strong>Instructions:</strong> Click on a marker to select a transit stop. Zoom and pan to explore the map.
</div>
</div>
<div class="config-modal-footer">
<button id="map-close-button">Close</button>
</div>
</div>
`;
document.body.appendChild(mapModal);
// Wait for DOM to be ready
await new Promise(resolve => setTimeout(resolve, 100));
// Initialize map centered on Stockholm
const map = L.map('map-container').setView([59.3293, 18.0686], 13);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Close handlers
const closeMap = () => {
map.remove();
document.body.removeChild(mapModal);
};
mapModal.querySelector('.map-modal-close').addEventListener('click', closeMap);
mapModal.querySelector('#map-close-button').addEventListener('click', closeMap);
mapModal.addEventListener('click', (e) => {
if (e.target === mapModal) closeMap();
});
// Load transit stops - search for common Stockholm areas
const loadSitesOnMap = async () => {
try {
// Start with focused search to avoid too many markers
const searchTerms = ['Ambassaderna'];
const allSites = new Map();
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 customIcon = L.divIcon({
className: 'custom-marker',
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);">🚌</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([lat, lon], { icon: customIcon }).addTo(map);
const popupContent = `
<div style="min-width: 200px;">
<strong style="font-size: 1.1em; color: #0061a1;">${site.name}</strong><br>
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span><br>
<button class="select-site-from-map" data-site-id="${site.id}" data-site-name="${site.name}"
style="margin-top: 8px; padding: 8px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
Select This Stop
</button>
</div>
`;
marker.bindPopup(popupContent);
markers.push(marker);
// Make marker clickable to open popup
marker.on('click', function() {
this.openPopup();
});
});
// 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');
}
}
} catch (error) {
console.error('Error loading sites on map:', error);
}
};
// Load sites after map is initialized
setTimeout(() => {
loadSitesOnMap();
}, 500);
// Handle site selection from map popup - use event delegation on the modal
mapModal.addEventListener('click', (e) => {
if (e.target.classList.contains('select-site-from-map')) {
e.preventDefault();
e.stopPropagation();
const siteId = e.target.dataset.siteId;
const siteName = e.target.dataset.siteName;
if (siteId && siteName) {
console.log(`Selecting site: ${siteName} (ID: ${siteId})`);
this.addSiteFromSearch(siteId, siteName);
closeMap();
}
}
});
// Also handle popupopen event to ensure buttons are clickable
map.on('popupopen', (e) => {
const popup = e.popup;
const content = popup.getElement();
if (content) {
const selectBtn = content.querySelector('.select-site-from-map');
if (selectBtn) {
selectBtn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const siteId = selectBtn.dataset.siteId;
const siteName = selectBtn.dataset.siteName;
if (siteId && siteName) {
console.log(`Selecting site from popup: ${siteName} (ID: ${siteId})`);
this.addSiteFromSearch(siteId, siteName);
closeMap();
}
});
}
}
});
// Map search functionality
const mapSearchInput = document.getElementById('map-search-input');
if (mapSearchInput) {
mapSearchInput.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') {
const query = mapSearchInput.value.trim();
if (query.length < 2) return;
try {
const response = await fetch(`/api/sites/search?q=${encodeURIComponent(query)}`);
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 = [];
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 customIcon = L.divIcon({
className: 'custom-transit-marker',
html: `<div style="background-color: #0061a1; color: white; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.4); font-size: 18px;">🚌</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16]
});
const marker = L.marker([lat, lon], {
icon: customIcon,
title: site.name
}).addTo(map);
const popupContent = document.createElement('div');
popupContent.style.minWidth = '220px';
popupContent.innerHTML = `
<div style="margin-bottom: 8px;">
<strong style="font-size: 1.1em; color: #0061a1; display: block; margin-bottom: 4px;">${site.name}</strong>
<span style="color: #666; font-size: 0.9em;">ID: ${site.id}</span>
</div>
<button class="select-site-from-map-btn"
data-site-id="${site.id}"
data-site-name="${site.name}"
style="margin-top: 8px; padding: 10px 15px; width: 100%; background-color: #0061a1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 1em;">
Select This Stop
</button>
`;
marker.bindPopup(popupContent);
markers.push(marker);
marker.on('click', function() {
this.openPopup();
});
});
// Fit map to show results
if (markers.length > 0) {
const group = new L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.1));
}
}
} catch (error) {
console.error('Error searching on map:', error);
}
}
});
}
}
/**
* Get sites configuration from form
*/
getSitesFromForm() {
const sitesContainer = document.getElementById('sites-container');
const siteItems = sitesContainer.querySelectorAll('.site-item');
return Array.from(siteItems).map(item => {
return {
id: item.querySelector('.site-id').value,
name: item.querySelector('.site-name').value,
enabled: item.querySelector('.site-enabled').checked
};
});
}
/**
* Sync configuration with server
*/
syncConfig() {
fetch('/api/config/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.config)
})
.then(response => response.json())
.then(data => {
console.log('Configuration synced with server:', data);
})
.catch(error => {
console.error('Error syncing configuration with server:', error);
});
}
/**
* Save the configuration to localStorage
*/
saveConfig() {
if (this.options.saveToLocalStorage && window.localStorage) {
try {
localStorage.setItem('sl-departures-config', JSON.stringify(this.config));
console.log('Configuration saved to localStorage');
} catch (error) {
console.error('Error saving configuration to localStorage:', error);
}
}
}
/**
* Load the configuration from localStorage
*/
loadConfig() {
if (this.options.saveToLocalStorage && window.localStorage) {
try {
const savedConfig = localStorage.getItem('sl-departures-config');
if (savedConfig) {
console.log('Configuration loaded from localStorage');
return JSON.parse(savedConfig);
}
} catch (error) {
console.error('Error loading configuration from localStorage:', error);
}
}
return {};
}
/**
* Setup keyboard shortcuts for testing
*/
setupKeyboardShortcuts() {
document.addEventListener('keydown', (event) => {
// Only handle keyboard shortcuts if not in an input field
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
return;
}
// Ctrl/Cmd + Shift + O: Toggle orientation
if (event.key === 'O' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
event.preventDefault();
const orientations = ['normal', 'vertical', 'upsidedown', 'vertical-reverse', 'landscape'];
const currentIndex = orientations.indexOf(this.config.orientation);
const nextIndex = (currentIndex + 1) % orientations.length;
this.config.orientation = orientations[nextIndex];
this.applyConfig();
this.saveConfig();
}
// Ctrl/Cmd + Shift + D: Toggle dark mode
if (event.key === 'D' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
event.preventDefault();
const modes = ['auto', 'on', 'off'];
const currentIndex = modes.indexOf(this.config.darkMode);
const nextIndex = (currentIndex + 1) % modes.length;
this.config.darkMode = modes[nextIndex];
this.applyConfig();
this.saveConfig();
}
});
}
/**
* Ensure content wrapper exists for rotated orientations
*/
ensureContentWrapper() {
if (!document.getElementById('content-wrapper')) {
console.log('Creating content wrapper');
const wrapper = document.createElement('div');
wrapper.id = 'content-wrapper';
// Move all body children to the wrapper except config elements
const configElements = ['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++) {
const child = document.body.children[i];
if (!configElements.includes(child.id) && child.id !== 'content-wrapper') {
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);
}
}
}
// Export the ConfigManager class for use in other modules
window.ConfigManager = ConfigManager;