Fix nearby stop grouping and location fallback
This commit is contained in:
17
server.mjs
17
server.mjs
@@ -44,9 +44,9 @@ createServer(async (request, response) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function handleDepartures(url, response) {
|
async function handleDepartures(url, response) {
|
||||||
const stopId = url.searchParams.get('stopId') ?? ''
|
const stopIds = uniqueQueryValues(url.searchParams, 'stopId')
|
||||||
const stopCode = url.searchParams.get('stopCode') ?? ''
|
const stopCodes = uniqueQueryValues(url.searchParams, 'stopCode')
|
||||||
if (!stopId && !stopCode) {
|
if (!stopIds.length && !stopCodes.length) {
|
||||||
sendJson(response, 400, { departures: [], error: 'stopId or stopCode is required' })
|
sendJson(response, 400, { departures: [], error: 'stopId or stopCode is required' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ async function handleDepartures(url, response) {
|
|||||||
|
|
||||||
const feed = await getTripUpdateFeed(apiKey)
|
const feed = await getTripUpdateFeed(apiKey)
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
departures: parseTripUpdates(feed, stopId, stopCode),
|
departures: parseTripUpdates(feed, stopIds, stopCodes),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -85,9 +85,10 @@ async function getTripUpdateFeed(apiKey) {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTripUpdates(payload, stopId, stopCode) {
|
function parseTripUpdates(payload, stopIds, stopCodes) {
|
||||||
const entities = Array.isArray(payload?.entity) ? payload.entity : []
|
const entities = Array.isArray(payload?.entity) ? payload.entity : []
|
||||||
const departures = []
|
const departures = []
|
||||||
|
const stopIdSet = new Set([...stopIds, ...stopCodes])
|
||||||
|
|
||||||
for (const entity of entities) {
|
for (const entity of entities) {
|
||||||
const tripUpdate = entity.tripUpdate || entity.trip_update || {}
|
const tripUpdate = entity.tripUpdate || entity.trip_update || {}
|
||||||
@@ -98,7 +99,7 @@ function parseTripUpdates(payload, stopId, stopCode) {
|
|||||||
|
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
const updateStopId = update.stopId || update.stop_id
|
const updateStopId = update.stopId || update.stop_id
|
||||||
if (updateStopId !== stopId && updateStopId !== stopCode) {
|
if (!stopIdSet.has(updateStopId)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +132,10 @@ function parseTripUpdates(payload, stopId, stopCode) {
|
|||||||
return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40)
|
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) {
|
async function serveStatic(pathname, response) {
|
||||||
const cleanPath = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '')
|
const cleanPath = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '')
|
||||||
let filePath = join(root, cleanPath === '/' ? 'index.html' : cleanPath)
|
let filePath = join(root, cleanPath === '/' ? 'index.html' : cleanPath)
|
||||||
|
|||||||
204
src/main.ts
204
src/main.ts
@@ -38,6 +38,8 @@ type StopSummary = {
|
|||||||
modes: Mode[]
|
modes: Mode[]
|
||||||
routes: string[]
|
routes: string[]
|
||||||
distanceMeters?: number
|
distanceMeters?: number
|
||||||
|
childStopIds?: string[]
|
||||||
|
childStopCodes?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Departure = {
|
type Departure = {
|
||||||
@@ -134,6 +136,7 @@ const MAP_TILE_SIZE = 256
|
|||||||
const MAP_ZOOM = 14
|
const MAP_ZOOM = 14
|
||||||
const MAP_VIEW_STOP_LIMIT = 80
|
const MAP_VIEW_STOP_LIMIT = 80
|
||||||
const MAP_RADIUS_OPTIONS = [500, 1000, 2000, 5000]
|
const MAP_RADIUS_OPTIONS = [500, 1000, 2000, 5000]
|
||||||
|
const STOP_GROUP_RADIUS_METERS = 130
|
||||||
const MAIN_MENU: Array<{
|
const MAIN_MENU: Array<{
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
@@ -246,6 +249,7 @@ async function boot(): Promise<void> {
|
|||||||
await loadStops()
|
await loadStops()
|
||||||
restoreLastStop()
|
restoreLastStop()
|
||||||
await renderAll(true)
|
await renderAll(true)
|
||||||
|
void refreshUserLocation(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStops(): Promise<void> {
|
async function loadStops(): Promise<void> {
|
||||||
@@ -285,7 +289,7 @@ function indexStops(): void {
|
|||||||
stopSearchIndex = new Map(
|
stopSearchIndex = new Map(
|
||||||
state.stops.map((stop) => [
|
state.stops.map((stop) => [
|
||||||
stop.id,
|
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<void> {
|
|||||||
await renderAll(true)
|
await renderAll(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNearbyStops(): Promise<void> {
|
async function refreshUserLocation(silent = false): Promise<boolean> {
|
||||||
state.message = 'Checking location...'
|
if (!silent) {
|
||||||
renderPhone()
|
state.message = 'Checking location...'
|
||||||
|
renderPhone()
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const position = await getCurrentPosition()
|
const position = await getCurrentPosition()
|
||||||
state.userLocation = { lat: position.coords.latitude, lon: position.coords.longitude }
|
state.userLocation = { lat: position.coords.latitude, lon: position.coords.longitude }
|
||||||
state.view = 'nearby'
|
if (!silent) {
|
||||||
state.returnView = 'nearby'
|
state.message = 'Location updated.'
|
||||||
state.selectedStopIndex = 0
|
}
|
||||||
state.filteredStops = state.stops
|
renderPhone()
|
||||||
.map((stop) => ({
|
return true
|
||||||
...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.`
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.view = 'nearby'
|
if (!silent) {
|
||||||
state.returnView = 'nearby'
|
state.message = `GPS unavailable: ${errorMessage(error)}`
|
||||||
state.filteredStops = state.stops.length ? state.stops.slice(0, 12) : demoStops.slice(0, 6)
|
renderPhone()
|
||||||
state.message = `Location unavailable: ${errorMessage(error)}`
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNearbyStops(): Promise<void> {
|
||||||
|
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)
|
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<void> {
|
async function selectStop(stop: StopSummary): Promise<void> {
|
||||||
if (isStopListView(state.view)) {
|
if (isStopListView(state.view)) {
|
||||||
state.returnView = state.view
|
state.returnView = state.view
|
||||||
@@ -536,9 +560,11 @@ async function loadDepartures(stop: StopSummary): Promise<void> {
|
|||||||
async function fetchBackendDepartures(stop: StopSummary): Promise<Departure[]> {
|
async function fetchBackendDepartures(stop: StopSummary): Promise<Departure[]> {
|
||||||
const base = TFI_API_BASE?.replace(/\/$/, '') ?? ''
|
const base = TFI_API_BASE?.replace(/\/$/, '') ?? ''
|
||||||
|
|
||||||
const params = new URLSearchParams({ stopId: stop.id })
|
const params = new URLSearchParams()
|
||||||
if (stop.code) {
|
stopQueryIds(stop).forEach((id) => params.append('stopId', id))
|
||||||
params.set('stopCode', stop.code)
|
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()}`, {
|
const response = await fetch(`${base}/departures?${params.toString()}`, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
@@ -1365,20 +1391,20 @@ function truncate(value: string, maxLength: number): string {
|
|||||||
function getStopsForView(view: View): StopSummary[] {
|
function getStopsForView(view: View): StopSummary[] {
|
||||||
if (view === 'favourites') {
|
if (view === 'favourites') {
|
||||||
const favourites = state.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))
|
.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') {
|
if (view === 'recent') {
|
||||||
const recent = state.recentStopIds
|
const recent = state.recentStopIds
|
||||||
.map((id) => state.stops.find((stop) => stop.id === id))
|
.map((id) => findStopByAnyId(id))
|
||||||
.filter((stop): stop is StopSummary => Boolean(stop))
|
.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') {
|
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') {
|
if (view === 'search') {
|
||||||
@@ -1468,7 +1494,7 @@ function wrapLongitude(lon: number): number {
|
|||||||
function searchStops(query: string): StopSummary[] {
|
function searchStops(query: string): StopSummary[] {
|
||||||
const cleanQuery = query.trim().toLowerCase()
|
const cleanQuery = query.trim().toLowerCase()
|
||||||
if (!cleanQuery) {
|
if (!cleanQuery) {
|
||||||
return state.stops.slice(0, 20)
|
return nearestStops(state.userLocation ?? DEFAULT_MAP_CENTER, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.stops
|
return state.stops
|
||||||
@@ -1497,7 +1523,7 @@ function parseTripUpdates(payload: unknown, stop: StopSummary): Departure[] {
|
|||||||
|
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
const stopId = stringValue(update.stopId ?? update.stop_id)
|
const stopId = stringValue(update.stopId ?? update.stop_id)
|
||||||
if (stopId !== stop.id && stopId !== stop.code) {
|
if (!matchesStopIdentifier(stop, stopId)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1528,6 +1554,25 @@ function parseTripUpdates(payload: unknown, stop: StopSummary): Departure[] {
|
|||||||
return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40)
|
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[] {
|
function normalizeDepartures(payload: unknown, stop: StopSummary): Departure[] {
|
||||||
const rows = Array.isArray(payload)
|
const rows = Array.isArray(payload)
|
||||||
? payload
|
? payload
|
||||||
@@ -1560,7 +1605,7 @@ function normalizeStops(payload: unknown): StopSummary[] {
|
|||||||
? payload
|
? payload
|
||||||
: arrayOfRecords((payload as Record<string, unknown>)?.stops)
|
: arrayOfRecords((payload as Record<string, unknown>)?.stops)
|
||||||
|
|
||||||
return rows
|
const platformStops = rows
|
||||||
.map((row) => asRecord(row))
|
.map((row) => asRecord(row))
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: stringValue(row.id ?? row.stop_id ?? row.stopId) || '',
|
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),
|
routes: arrayOfStrings(row.routes ?? row.routeIds ?? row.route_ids),
|
||||||
}))
|
}))
|
||||||
.filter((stop) => stop.id && stop.name && stop.lat && stop.lon)
|
.filter((stop) => stop.id && stop.name && stop.lat && stop.lon)
|
||||||
|
|
||||||
|
return groupPlatformStops(platformStops)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupPlatformStops(platformStops: StopSummary[]): StopSummary[] {
|
||||||
|
const groups = new Map<string, StopSummary[]>()
|
||||||
|
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 {
|
function restoreLastStop(): void {
|
||||||
@@ -1579,12 +1699,21 @@ function restoreLastStop(): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = state.stops.find((item) => item.id === state.lastStopId)
|
const stop = findStopByAnyId(state.lastStopId)
|
||||||
if (stop) {
|
if (stop) {
|
||||||
state.selectedStop = 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 {
|
function clampSelection(): void {
|
||||||
state.selectedStopIndex = clamp(state.selectedStopIndex, 0, Math.max(0, state.filteredStops.length - 1))
|
state.selectedStopIndex = clamp(state.selectedStopIndex, 0, Math.max(0, state.filteredStops.length - 1))
|
||||||
state.selectedDepartureIndex = clamp(
|
state.selectedDepartureIndex = clamp(
|
||||||
@@ -1618,7 +1747,8 @@ function viewTitle(): string {
|
|||||||
function stopSubtitle(stop: StopSummary): string {
|
function stopSubtitle(stop: StopSummary): string {
|
||||||
const distance = stop.distanceMeters == null ? '' : `${Math.round(stop.distanceMeters)}m • `
|
const distance = stop.distanceMeters == null ? '' : `${Math.round(stop.distanceMeters)}m • `
|
||||||
const routes = stop.routes.length ? stop.routes.slice(0, 8).join(', ') : 'routes unknown'
|
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 {
|
function modeLabel(mode: Mode): string {
|
||||||
@@ -1739,6 +1869,14 @@ const demoStops: StopSummary[] = [
|
|||||||
|
|
||||||
function getCurrentPosition(): Promise<GeolocationPosition> {
|
function getCurrentPosition(): Promise<GeolocationPosition> {
|
||||||
return new Promise((resolve, reject) => {
|
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, {
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||||
enableHighAccuracy: false,
|
enableHighAccuracy: false,
|
||||||
maximumAge: 60_000,
|
maximumAge: 60_000,
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ async function fetchStops(env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDepartures(url, env, ctx) {
|
async function fetchDepartures(url, env, ctx) {
|
||||||
const stopId = url.searchParams.get('stopId') ?? ''
|
const stopIds = uniqueQueryValues(url.searchParams, 'stopId')
|
||||||
const stopCode = url.searchParams.get('stopCode') ?? ''
|
const stopCodes = uniqueQueryValues(url.searchParams, 'stopCode')
|
||||||
if (!stopId && !stopCode) {
|
if (!stopIds.length && !stopCodes.length) {
|
||||||
return json({ departures: [], error: 'stopId or stopCode is required' }, 400)
|
return json({ departures: [], error: 'stopId or stopCode is required' }, 400)
|
||||||
}
|
}
|
||||||
if (!env.NTA_API_KEY) {
|
if (!env.NTA_API_KEY) {
|
||||||
@@ -53,7 +53,7 @@ async function fetchDepartures(url, env, ctx) {
|
|||||||
|
|
||||||
const feed = await getTripUpdateFeed(env, ctx)
|
const feed = await getTripUpdateFeed(env, ctx)
|
||||||
return json({
|
return json({
|
||||||
departures: parseTripUpdates(feed, stopId, stopCode),
|
departures: parseTripUpdates(feed, stopIds, stopCodes),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -86,9 +86,10 @@ async function getTripUpdateFeed(env, ctx) {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTripUpdates(payload, stopId, stopCode) {
|
function parseTripUpdates(payload, stopIds, stopCodes) {
|
||||||
const entities = Array.isArray(payload?.entity) ? payload.entity : []
|
const entities = Array.isArray(payload?.entity) ? payload.entity : []
|
||||||
const departures = []
|
const departures = []
|
||||||
|
const stopIdSet = new Set([...stopIds, ...stopCodes])
|
||||||
|
|
||||||
for (const entity of entities) {
|
for (const entity of entities) {
|
||||||
const tripUpdate = entity.tripUpdate || entity.trip_update || {}
|
const tripUpdate = entity.tripUpdate || entity.trip_update || {}
|
||||||
@@ -99,7 +100,7 @@ function parseTripUpdates(payload, stopId, stopCode) {
|
|||||||
|
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
const updateStopId = update.stopId || update.stop_id
|
const updateStopId = update.stopId || update.stop_id
|
||||||
if (updateStopId !== stopId && updateStopId !== stopCode) {
|
if (!stopIdSet.has(updateStopId)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +132,10 @@ function parseTripUpdates(payload, stopId, stopCode) {
|
|||||||
return departures.sort((a, b) => a.dueMinutes - b.dueMinutes).slice(0, 40)
|
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 = {}) {
|
function json(body, status = 200, headers = {}) {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
status,
|
status,
|
||||||
|
|||||||
Reference in New Issue
Block a user