From 54721bb74481b53a7f2dd2e312083555b95711f8 Mon Sep 17 00:00:00 2001 From: Matiss Date: Mon, 1 Jun 2026 17:56:44 +0100 Subject: [PATCH] Fix nearby stop grouping and location fallback --- server.mjs | 17 ++-- src/main.ts | 204 +++++++++++++++++++++++++++++++++++++------- worker/nta-proxy.js | 17 ++-- 3 files changed, 193 insertions(+), 45 deletions(-) diff --git a/server.mjs b/server.mjs index 6b041b0..ab1d776 100644 --- a/server.mjs +++ b/server.mjs @@ -44,9 +44,9 @@ createServer(async (request, response) => { }) async function handleDepartures(url, response) { - const stopId = url.searchParams.get('stopId') ?? '' - const stopCode = url.searchParams.get('stopCode') ?? '' - if (!stopId && !stopCode) { + const stopIds = uniqueQueryValues(url.searchParams, 'stopId') + const stopCodes = uniqueQueryValues(url.searchParams, 'stopCode') + if (!stopIds.length && !stopCodes.length) { sendJson(response, 400, { departures: [], error: 'stopId or stopCode is required' }) return } @@ -59,7 +59,7 @@ async function handleDepartures(url, response) { const feed = await getTripUpdateFeed(apiKey) sendJson(response, 200, { - departures: parseTripUpdates(feed, stopId, stopCode), + departures: parseTripUpdates(feed, stopIds, stopCodes), updatedAt: Date.now(), }) } @@ -85,9 +85,10 @@ async function getTripUpdateFeed(apiKey) { return payload } -function parseTripUpdates(payload, stopId, stopCode) { +function parseTripUpdates(payload, stopIds, stopCodes) { const entities = Array.isArray(payload?.entity) ? payload.entity : [] const departures = [] + const stopIdSet = new Set([...stopIds, ...stopCodes]) for (const entity of entities) { const tripUpdate = entity.tripUpdate || entity.trip_update || {} @@ -98,7 +99,7 @@ function parseTripUpdates(payload, stopId, stopCode) { for (const update of updates) { const updateStopId = update.stopId || update.stop_id - if (updateStopId !== stopId && updateStopId !== stopCode) { + if (!stopIdSet.has(updateStopId)) { continue } @@ -131,6 +132,10 @@ function parseTripUpdates(payload, stopId, stopCode) { return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40) } +function uniqueQueryValues(params, key) { + return [...new Set(params.getAll(key).flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean))] +} + async function serveStatic(pathname, response) { const cleanPath = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '') let filePath = join(root, cleanPath === '/' ? 'index.html' : cleanPath) diff --git a/src/main.ts b/src/main.ts index 3a12a71..7889e2e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,6 +38,8 @@ type StopSummary = { modes: Mode[] routes: string[] distanceMeters?: number + childStopIds?: string[] + childStopCodes?: string[] } type Departure = { @@ -134,6 +136,7 @@ const MAP_TILE_SIZE = 256 const MAP_ZOOM = 14 const MAP_VIEW_STOP_LIMIT = 80 const MAP_RADIUS_OPTIONS = [500, 1000, 2000, 5000] +const STOP_GROUP_RADIUS_METERS = 130 const MAIN_MENU: Array<{ label: string description: string @@ -246,6 +249,7 @@ async function boot(): Promise { await loadStops() restoreLastStop() await renderAll(true) + void refreshUserLocation(true) } async function loadStops(): Promise { @@ -285,7 +289,7 @@ function indexStops(): void { stopSearchIndex = new Map( state.stops.map((stop) => [ stop.id, - `${stop.name} ${stop.code ?? ''} ${stop.routes.join(' ')} ${stop.modes.join(' ')}`.toLowerCase(), + `${stop.name} ${stop.code ?? ''} ${(stop.childStopCodes ?? []).join(' ')} ${(stop.childStopIds ?? []).join(' ')} ${stop.routes.join(' ')} ${stop.modes.join(' ')}`.toLowerCase(), ]), ) } @@ -438,34 +442,54 @@ async function runTextSearch(query: string): Promise { await renderAll(true) } -async function loadNearbyStops(): Promise { - state.message = 'Checking location...' - renderPhone() - +async function refreshUserLocation(silent = false): Promise { + if (!silent) { + state.message = 'Checking location...' + renderPhone() + } try { const position = await getCurrentPosition() state.userLocation = { lat: position.coords.latitude, lon: position.coords.longitude } - state.view = 'nearby' - state.returnView = 'nearby' - state.selectedStopIndex = 0 - state.filteredStops = state.stops - .map((stop) => ({ - ...stop, - distanceMeters: distanceMeters(position.coords.latitude, position.coords.longitude, stop.lat, stop.lon), - })) - .sort((a, b) => (a.distanceMeters ?? 0) - (b.distanceMeters ?? 0)) - .slice(0, 24) - state.message = `Found ${state.filteredStops.length} nearby stops.` + if (!silent) { + state.message = 'Location updated.' + } + renderPhone() + return true } catch (error) { - state.view = 'nearby' - state.returnView = 'nearby' - state.filteredStops = state.stops.length ? state.stops.slice(0, 12) : demoStops.slice(0, 6) - state.message = `Location unavailable: ${errorMessage(error)}` + if (!silent) { + state.message = `GPS unavailable: ${errorMessage(error)}` + renderPhone() + } + return false } +} + +async function loadNearbyStops(): Promise { + const hasLocation = await refreshUserLocation(false) + const center = hasLocation && state.userLocation ? state.userLocation : DEFAULT_MAP_CENTER + + state.view = 'nearby' + state.returnView = 'nearby' + state.selectedStopIndex = 0 + state.filteredStops = nearestStops(center, 24) + state.message = hasLocation + ? `Found ${state.filteredStops.length} nearby stops.` + : `GPS unavailable; showing stops near Dublin city centre.` await renderAll(true) } +function nearestStops(center: GeoPoint, limit: number): StopSummary[] { + return state.stops + .map((stop) => ({ + ...stop, + distanceMeters: distanceMeters(center.lat, center.lon, stop.lat, stop.lon), + })) + .filter((stop) => Number.isFinite(stop.distanceMeters)) + .sort((a, b) => (a.distanceMeters ?? 0) - (b.distanceMeters ?? 0)) + .slice(0, limit) +} + async function selectStop(stop: StopSummary): Promise { if (isStopListView(state.view)) { state.returnView = state.view @@ -536,9 +560,11 @@ async function loadDepartures(stop: StopSummary): Promise { async function fetchBackendDepartures(stop: StopSummary): Promise { const base = TFI_API_BASE?.replace(/\/$/, '') ?? '' - const params = new URLSearchParams({ stopId: stop.id }) - if (stop.code) { - params.set('stopCode', stop.code) + const params = new URLSearchParams() + stopQueryIds(stop).forEach((id) => params.append('stopId', id)) + stopQueryCodes(stop).forEach((code) => params.append('stopCode', code)) + if (!params.has('stopId') && stop.id) { + params.set('stopId', stop.id) } const response = await fetch(`${base}/departures?${params.toString()}`, { headers: { Accept: 'application/json' }, @@ -1365,20 +1391,20 @@ function truncate(value: string, maxLength: number): string { function getStopsForView(view: View): StopSummary[] { if (view === 'favourites') { const favourites = state.favourites - .map((id) => state.stops.find((stop) => stop.id === id)) + .map((id) => findStopByAnyId(id)) .filter((stop): stop is StopSummary => Boolean(stop)) - return favourites.length ? favourites : state.stops.slice(0, 8) + return favourites.length ? favourites : nearestStops(state.userLocation ?? DEFAULT_MAP_CENTER, 8) } if (view === 'recent') { const recent = state.recentStopIds - .map((id) => state.stops.find((stop) => stop.id === id)) + .map((id) => findStopByAnyId(id)) .filter((stop): stop is StopSummary => Boolean(stop)) - return recent.length ? recent : state.stops.slice(0, 8) + return recent.length ? recent : nearestStops(state.userLocation ?? DEFAULT_MAP_CENTER, 8) } if (view === 'nearby') { - return state.filteredStops.length ? state.filteredStops : state.stops.slice(0, 12) + return state.filteredStops.length ? state.filteredStops : nearestStops(state.userLocation ?? DEFAULT_MAP_CENTER, 12) } if (view === 'search') { @@ -1468,7 +1494,7 @@ function wrapLongitude(lon: number): number { function searchStops(query: string): StopSummary[] { const cleanQuery = query.trim().toLowerCase() if (!cleanQuery) { - return state.stops.slice(0, 20) + return nearestStops(state.userLocation ?? DEFAULT_MAP_CENTER, 20) } return state.stops @@ -1497,7 +1523,7 @@ function parseTripUpdates(payload: unknown, stop: StopSummary): Departure[] { for (const update of updates) { const stopId = stringValue(update.stopId ?? update.stop_id) - if (stopId !== stop.id && stopId !== stop.code) { + if (!matchesStopIdentifier(stop, stopId)) { continue } @@ -1528,6 +1554,25 @@ function parseTripUpdates(payload: unknown, stop: StopSummary): Departure[] { return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40) } +function matchesStopIdentifier(stop: StopSummary, value: string | undefined): boolean { + if (!value) { + return false + } + return value === stop.id || + value === stop.code || + stop.childStopIds?.includes(value) || + stop.childStopCodes?.includes(value) || + false +} + +function stopQueryIds(stop: StopSummary): string[] { + return uniqueStrings(stop.childStopIds?.length ? stop.childStopIds : [stop.id]) +} + +function stopQueryCodes(stop: StopSummary): string[] { + return uniqueStrings(stop.childStopCodes?.length ? stop.childStopCodes : stop.code ? [stop.code] : []) +} + function normalizeDepartures(payload: unknown, stop: StopSummary): Departure[] { const rows = Array.isArray(payload) ? payload @@ -1560,7 +1605,7 @@ function normalizeStops(payload: unknown): StopSummary[] { ? payload : arrayOfRecords((payload as Record)?.stops) - return rows + const platformStops = rows .map((row) => asRecord(row)) .map((row) => ({ id: stringValue(row.id ?? row.stop_id ?? row.stopId) || '', @@ -1572,6 +1617,81 @@ function normalizeStops(payload: unknown): StopSummary[] { routes: arrayOfStrings(row.routes ?? row.routeIds ?? row.route_ids), })) .filter((stop) => stop.id && stop.name && stop.lat && stop.lon) + + return groupPlatformStops(platformStops) +} + +function groupPlatformStops(platformStops: StopSummary[]): StopSummary[] { + const groups = new Map() + for (const stop of platformStops) { + const key = `${canonicalStopName(stop.name)}|${stop.modes.slice().sort().join(',')}` + const buckets = groups.get(key) ?? [] + const bucket = buckets.find((candidate) => + distanceMeters(candidate.lat, candidate.lon, stop.lat, stop.lon) <= STOP_GROUP_RADIUS_METERS, + ) + + if (bucket) { + bucket.childStopIds?.push(stop.id) + if (stop.code) { + bucket.childStopCodes?.push(stop.code) + } + bucket.routes = uniqueStrings([...bucket.routes, ...stop.routes]).sort(routeSort) + bucket.modes = uniqueModes([...bucket.modes, ...stop.modes]) + const count = bucket.childStopIds?.length ?? 1 + bucket.lat = bucket.lat + (stop.lat - bucket.lat) / count + bucket.lon = bucket.lon + (stop.lon - bucket.lon) / count + } else { + buckets.push({ + ...stop, + id: stop.id, + childStopIds: [stop.id], + childStopCodes: stop.code ? [stop.code] : [], + routes: uniqueStrings(stop.routes).sort(routeSort), + modes: uniqueModes(stop.modes), + }) + groups.set(key, buckets) + } + } + + return [...groups.values()] + .flat() + .map((stop) => ({ + ...stop, + code: preferredStopCode(stop.childStopCodes, stop.code), + childStopIds: uniqueStrings(stop.childStopIds ?? [stop.id]), + childStopCodes: uniqueStrings(stop.childStopCodes ?? (stop.code ? [stop.code] : [])), + })) +} + +function canonicalStopName(name: string): string { + return name + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\bst(?:reet)?\b/g, 'street') + .replace(/\s+/g, ' ') + .trim() +} + +function preferredStopCode(codes: string[] | undefined, fallback: string | undefined): string | undefined { + const uniqueCodes = uniqueStrings(codes ?? []) + if (!uniqueCodes.length) { + return fallback + } + return uniqueCodes.length === 1 ? uniqueCodes[0] : `${uniqueCodes[0]}+${uniqueCodes.length - 1}` +} + +function uniqueModes(modes: Mode[]): Mode[] { + const unique = [...new Set(modes.filter((mode) => mode !== 'unknown'))] as Mode[] + return unique.length ? unique : ['unknown'] +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))] +} + +function routeSort(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) } function restoreLastStop(): void { @@ -1579,12 +1699,21 @@ function restoreLastStop(): void { return } - const stop = state.stops.find((item) => item.id === state.lastStopId) + const stop = findStopByAnyId(state.lastStopId) if (stop) { state.selectedStop = stop } } +function findStopByAnyId(id: string): StopSummary | undefined { + return state.stops.find((stop) => + stop.id === id || + stop.code === id || + stop.childStopIds?.includes(id) || + stop.childStopCodes?.includes(id), + ) +} + function clampSelection(): void { state.selectedStopIndex = clamp(state.selectedStopIndex, 0, Math.max(0, state.filteredStops.length - 1)) state.selectedDepartureIndex = clamp( @@ -1618,7 +1747,8 @@ function viewTitle(): string { function stopSubtitle(stop: StopSummary): string { const distance = stop.distanceMeters == null ? '' : `${Math.round(stop.distanceMeters)}m • ` const routes = stop.routes.length ? stop.routes.slice(0, 8).join(', ') : 'routes unknown' - return `${distance}${stop.code ?? stop.id} • ${routes}` + const platformCount = stop.childStopIds && stop.childStopIds.length > 1 ? ` • ${stop.childStopIds.length} platforms` : '' + return `${distance}${stop.code ?? stop.id}${platformCount} • ${routes}` } function modeLabel(mode: Mode): string { @@ -1739,6 +1869,14 @@ const demoStops: StopSummary[] = [ function getCurrentPosition(): Promise { return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('location is not available in this browser')) + return + } + if (!window.isSecureContext && !['localhost', '127.0.0.1'].includes(window.location.hostname)) { + reject(new Error('location requires HTTPS')) + return + } navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, maximumAge: 60_000, diff --git a/worker/nta-proxy.js b/worker/nta-proxy.js index 681e27b..e962475 100644 --- a/worker/nta-proxy.js +++ b/worker/nta-proxy.js @@ -42,9 +42,9 @@ async function fetchStops(env) { } async function fetchDepartures(url, env, ctx) { - const stopId = url.searchParams.get('stopId') ?? '' - const stopCode = url.searchParams.get('stopCode') ?? '' - if (!stopId && !stopCode) { + const stopIds = uniqueQueryValues(url.searchParams, 'stopId') + const stopCodes = uniqueQueryValues(url.searchParams, 'stopCode') + if (!stopIds.length && !stopCodes.length) { return json({ departures: [], error: 'stopId or stopCode is required' }, 400) } if (!env.NTA_API_KEY) { @@ -53,7 +53,7 @@ async function fetchDepartures(url, env, ctx) { const feed = await getTripUpdateFeed(env, ctx) return json({ - departures: parseTripUpdates(feed, stopId, stopCode), + departures: parseTripUpdates(feed, stopIds, stopCodes), updatedAt: Date.now(), }) } @@ -86,9 +86,10 @@ async function getTripUpdateFeed(env, ctx) { return payload } -function parseTripUpdates(payload, stopId, stopCode) { +function parseTripUpdates(payload, stopIds, stopCodes) { const entities = Array.isArray(payload?.entity) ? payload.entity : [] const departures = [] + const stopIdSet = new Set([...stopIds, ...stopCodes]) for (const entity of entities) { const tripUpdate = entity.tripUpdate || entity.trip_update || {} @@ -99,7 +100,7 @@ function parseTripUpdates(payload, stopId, stopCode) { for (const update of updates) { const updateStopId = update.stopId || update.stop_id - if (updateStopId !== stopId && updateStopId !== stopCode) { + if (!stopIdSet.has(updateStopId)) { continue } @@ -131,6 +132,10 @@ function parseTripUpdates(payload, stopId, stopCode) { return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40) } +function uniqueQueryValues(params, key) { + return [...new Set(params.getAll(key).flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean))] +} + function json(body, status = 200, headers = {}) { return new Response(JSON.stringify(body), { status,