- 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>
179 lines
6.6 KiB
JavaScript
179 lines
6.6 KiB
JavaScript
/**
|
|
* 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');
|
|
|
|
// ── Site cache for nearby lookups ──────────────────────────────────────────
|
|
let cachedSites = null;
|
|
let cacheTimestamp = null;
|
|
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
/**
|
|
* 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 getAllSites() {
|
|
if (cachedSites && cacheTimestamp && (Date.now() - cacheTimestamp < CACHE_TTL)) {
|
|
return Promise.resolve(cachedSites);
|
|
}
|
|
|
|
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 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.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;
|
|
}
|
|
|
|
// 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(`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('end', () => {
|
|
try {
|
|
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 search response:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
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', sites: [] }));
|
|
});
|
|
}
|
|
|
|
// ── Nearby sites from cache ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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) {
|
|
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
|
|
* Uses cached site list — no redundant API calls per request
|
|
*/
|
|
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;
|
|
}
|
|
|
|
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,
|
|
getAllSites
|
|
};
|