Optimize landscape layout: 4-column grid, transport icons, improved sizing and spacing

This commit is contained in:
2025-12-31 16:27:55 +01:00
parent a0c997f7d4
commit 738a422dc9
14 changed files with 2173 additions and 1629 deletions

725
config.js
View File

@@ -35,7 +35,6 @@ class ConfigManager {
darkMode: this.options.defaultDarkMode,
backgroundImage: '',
backgroundOpacity: 0.3, // Default opacity (30%)
tickerSpeed: 60, // Default ticker speed in seconds
sites: [
{
id: '1411',
@@ -43,13 +42,6 @@ class ConfigManager {
enabled: true
}
],
rssFeeds: [
{
name: "Travel Alerts",
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
enabled: true
}
],
combineSameDirection: true, // Combine departures going in the same direction
...this.loadConfig() // Load saved config if available
};
@@ -102,73 +94,89 @@ class ConfigManager {
<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">
<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>
<!-- 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>
<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
<!-- 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>
<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 class="config-option">
<label for="ticker-speed">Ticker Speed: <span id="ticker-speed-value">${this.config.tickerSpeed}</span> seconds</label>
<input type="range" id="ticker-speed" min="10" max="120" step="5" value="${this.config.tickerSpeed}">
</div>
<div class="config-option">
<label>Transit Sites:</label>
<div id="sites-container">
${this.generateSitesHTML()}
</div>
<div style="margin-top: 10px;">
<button id="add-site-button" style="padding: 5px 10px;">Add Site</button>
</div>
</div>
<div class="config-option">
<label>RSS Feeds:</label>
<div id="rss-feeds-container">
${this.generateRssFeedsHTML()}
</div>
<div style="margin-top: 10px;">
<button id="add-feed-button" style="padding: 5px 10px;">Add Feed</button>
</div>
</div>
<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 class="config-modal-footer">
@@ -180,6 +188,9 @@ class ConfigManager {
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());
@@ -196,10 +207,22 @@ class ConfigManager {
addSiteButton.addEventListener('click', () => this.addNewSite());
}
// Add event listener for add feed button
const addFeedButton = modalContainer.querySelector('#add-feed-button');
if (addFeedButton) {
addFeedButton.addEventListener('click', () => this.addNewFeed());
// 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
@@ -226,9 +249,8 @@ class ConfigManager {
}
});
// Add event listeners for site and feed management
// Add event listeners for site management
this.setupSiteEventListeners();
this.setupFeedEventListeners();
}
/**
@@ -250,25 +272,6 @@ class ConfigManager {
});
}
/**
* Setup event listeners for RSS feed management
*/
setupFeedEventListeners() {
const feedsContainer = document.getElementById('rss-feeds-container');
if (!feedsContainer) return;
const removeButtons = feedsContainer.querySelectorAll('.remove-feed-button');
removeButtons.forEach(button => {
button.addEventListener('click', (event) => {
const feedItem = event.target.closest('.feed-item');
if (feedItem) {
const index = parseInt(feedItem.dataset.index);
this.removeFeed(index);
}
});
});
}
/**
* Add a new site to the configuration
*/
@@ -286,23 +289,6 @@ class ConfigManager {
}
}
/**
* Add a new RSS feed to the configuration
*/
addNewFeed() {
this.config.rssFeeds.push({
name: 'New Feed',
url: '',
enabled: true
});
const feedsContainer = document.getElementById('rss-feeds-container');
if (feedsContainer) {
feedsContainer.innerHTML = this.generateRssFeedsHTML();
this.setupFeedEventListeners();
}
}
/**
* Remove a site from the configuration
*/
@@ -319,18 +305,28 @@ class ConfigManager {
}
/**
* Remove an RSS feed from the configuration
* Setup tab switching functionality
*/
removeFeed(index) {
if (index >= 0 && index < this.config.rssFeeds.length) {
this.config.rssFeeds.splice(index, 1);
const feedsContainer = document.getElementById('rss-feeds-container');
if (feedsContainer) {
feedsContainer.innerHTML = this.generateRssFeedsHTML();
this.setupFeedEventListeners();
}
}
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');
}
});
});
}
/**
@@ -360,6 +356,18 @@ class ConfigManager {
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;
@@ -372,19 +380,10 @@ class ConfigManager {
this.setupSiteEventListeners();
}
// Update RSS feeds container
const feedsContainer = document.getElementById('rss-feeds-container');
if (feedsContainer) {
feedsContainer.innerHTML = this.generateRssFeedsHTML();
this.setupFeedEventListeners();
}
// Update background image, opacity, and ticker speed values
// 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');
const tickerSpeed = document.getElementById('ticker-speed');
const tickerSpeedValue = document.getElementById('ticker-speed-value');
if (backgroundImageUrl) {
backgroundImageUrl.value = this.config.backgroundImage || '';
@@ -398,14 +397,6 @@ class ConfigManager {
opacityValue.textContent = `${Math.round(this.config.backgroundOpacity * 100)}%`;
}
if (tickerSpeed) {
tickerSpeed.value = this.config.tickerSpeed;
}
if (tickerSpeedValue) {
tickerSpeedValue.textContent = this.config.tickerSpeed;
}
// Update background preview
this.updateBackgroundPreview();
@@ -438,16 +429,6 @@ class ConfigManager {
});
}
// Ticker speed slider
const tickerSpeedSlider = document.getElementById('ticker-speed');
if (tickerSpeedSlider) {
const newSlider = tickerSpeedSlider.cloneNode(true);
tickerSpeedSlider.parentNode.replaceChild(newSlider, tickerSpeedSlider);
newSlider.addEventListener('input', (e) => {
document.getElementById('ticker-speed-value').textContent = e.target.value;
});
}
// Local image selection
const localImageInput = document.getElementById('local-image-input');
if (localImageInput) {
@@ -477,12 +458,10 @@ class ConfigManager {
const darkModeSelect = document.getElementById('dark-mode-select');
const backgroundImageUrl = document.getElementById('background-image-url');
const backgroundOpacity = document.getElementById('background-opacity');
const tickerSpeed = document.getElementById('ticker-speed');
const combineDirections = document.getElementById('combine-directions');
// Get sites and feeds configuration from form
// Get sites configuration from form
const sitesConfig = this.getSitesFromForm();
const feedsConfig = this.getRssFeedsFromForm();
// Get the background image URL directly from the DOM
const imageUrlValue = document.querySelector('#background-image-url').value;
@@ -492,10 +471,8 @@ class ConfigManager {
this.config.darkMode = darkModeSelect.value;
this.config.backgroundImage = imageUrlValue;
this.config.backgroundOpacity = parseFloat(backgroundOpacity.value);
this.config.tickerSpeed = parseInt(tickerSpeed.value);
this.config.combineSameDirection = combineDirections.checked;
this.config.sites = sitesConfig;
this.config.rssFeeds = feedsConfig;
// Save config
this.saveConfig();
@@ -503,11 +480,6 @@ class ConfigManager {
// Sync with server
this.syncConfig();
// Update ticker speed if changed
if (window.tickerManager && this.config.tickerSpeed) {
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
}
// Apply config
this.applyConfig();
@@ -532,9 +504,6 @@ class ConfigManager {
// Apply background image and opacity
this.applyBackgroundImage();
// Apply ticker speed
this.applyTickerSpeed();
// Ensure content wrapper exists when changing to rotated modes
if (['vertical', 'upsidedown', 'vertical-reverse'].includes(this.config.orientation)) {
this.ensureContentWrapper();
@@ -612,15 +581,6 @@ class ConfigManager {
}
}
/**
* Apply ticker speed setting
*/
applyTickerSpeed() {
if (window.tickerManager) {
window.tickerManager.setScrollSpeed(this.config.tickerSpeed);
}
}
/**
* Update the sun times display in the config modal
*/
@@ -668,26 +628,401 @@ class ConfigManager {
}
/**
* Generate HTML for RSS feeds configuration
* Search for transit sites
*/
generateRssFeedsHTML() {
if (!this.config.rssFeeds || this.config.rssFeeds.length === 0) {
return '<div class="no-feeds">No RSS feeds configured</div>';
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;
}
return this.config.rssFeeds.map((feed, index) => `
<div class="feed-item" data-index="${index}">
<div style="display: flex; align-items: center; margin-bottom: 5px;">
<input type="checkbox" class="feed-enabled" ${feed.enabled ? 'checked' : ''}>
<input type="text" class="feed-name" value="${feed.name}" placeholder="Feed Name" style="flex: 1; margin: 0 5px;">
<button class="remove-feed-button" style="padding: 2px 5px;">×</button>
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>
<div style="display: flex; align-items: center;">
<span style="margin-right: 5px;">URL:</span>
<input type="text" class="feed-url" value="${feed.url}" placeholder="Feed URL" style="flex: 1;">
`).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>
`).join('');
`;
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);
}
}
});
}
}
/**
@@ -706,22 +1041,6 @@ class ConfigManager {
});
}
/**
* Get RSS feeds configuration from form
*/
getRssFeedsFromForm() {
const feedsContainer = document.getElementById('rss-feeds-container');
const feedItems = feedsContainer.querySelectorAll('.feed-item');
return Array.from(feedItems).map(item => {
return {
name: item.querySelector('.feed-name').value,
url: item.querySelector('.feed-url').value,
enabled: item.querySelector('.feed-enabled').checked
};
});
}
/**
* Sync configuration with server
*/