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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user