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:
2026-02-15 14:30:03 +01:00
parent 392a50b535
commit 1fdb3e48c7
12 changed files with 1883 additions and 1780 deletions

View File

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