Files
SignageHTML/server/routes/sites.js
kyle 1fdb3e48c7 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>
2026-02-15 14:30:03 +01:00

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