/** * 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 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 };