diff --git a/README.md b/README.md index 99bea0e..ccb0797 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Try the live demo to explore GTFS data, view routes with colors, and see trip sc ## Features +### GTFS Static Data - ✅ Load GTFS data from ZIP files (URL or local path) - ✅ Skip importing specific files (e.g., shapes.txt) to reduce memory usage - ✅ Load existing SQLite databases @@ -29,6 +30,16 @@ Try the live demo to explore GTFS data, view routes with colors, and see trip sc - ✅ Proper handling of GTFS required/optional fields - ✅ Active service detection based on calendar/calendar_dates +### GTFS Realtime Support +- ✅ Load GTFS-RT data from protobuf feeds (URLs or local files) +- ✅ Support for Alerts, Trip Updates, and Vehicle Positions +- ✅ Automatic staleness filtering (configurable threshold) +- ✅ Active alert period checking +- ✅ Merge realtime data with static schedules +- ✅ Filter alerts and vehicle positions by route, stop, or trip +- ✅ Store RT data in SQLite for consistent querying +- ✅ Include RT data in database exports + ## Installation ```bash @@ -334,10 +345,170 @@ const stopTimes = gtfs.getStopTimes({ }); ``` +### GTFS Realtime Support + +This library supports GTFS Realtime data (alerts, trip updates, and vehicle positions) with automatic merging into static schedule data. + +#### Loading Realtime Data + +```typescript +// Configure RT feed URLs (optional - can also pass directly to fetchRealtimeData) +const gtfs = await GtfsSqlJs.fromZip('https://example.com/gtfs.zip', { + realtimeFeedUrls: [ + 'https://example.com/gtfs-rt/alerts', + 'https://example.com/gtfs-rt/trip-updates', + 'https://example.com/gtfs-rt/vehicle-positions' + ], + stalenessThreshold: 120 // seconds (default: 120) +}); + +// Fetch RT data (uses configured URLs or pass custom URLs) +await gtfs.fetchRealtimeData(); + +// Or fetch from specific URLs +await gtfs.fetchRealtimeData([ + 'https://example.com/gtfs-rt/combined-feed' +]); + +// Support local files in Node.js +await gtfs.fetchRealtimeData(['./path/to/feed.pb']); + +// Update configuration +gtfs.setRealtimeFeedUrls(['https://example.com/new-feed']); +gtfs.setStalenessThreshold(60); // 60 seconds +``` + +#### Querying Alerts + +```typescript +// Get all active alerts +const activeAlerts = gtfs.getAlerts({ activeOnly: true }); + +// Filter alerts by route +const routeAlerts = gtfs.getAlerts({ + routeId: 'ROUTE_1', + activeOnly: true +}); + +// Filter alerts by stop +const stopAlerts = gtfs.getAlerts({ + stopId: 'STOP_123', + activeOnly: true +}); + +// Filter alerts by trip +const tripAlerts = gtfs.getAlerts({ + tripId: 'TRIP_456' +}); + +// Get alert by ID +const alert = gtfs.getAlertById('alert:12345'); + +// Alert structure +console.log(alert.header_text); // TranslatedString +console.log(alert.description_text); // TranslatedString +console.log(alert.cause); // AlertCause enum +console.log(alert.effect); // AlertEffect enum +console.log(alert.active_period); // TimeRange[] +console.log(alert.informed_entity); // EntitySelector[] +``` + +#### Querying Vehicle Positions + +```typescript +// Get all vehicle positions +const vehicles = gtfs.getVehiclePositions(); + +// Filter by route +const routeVehicles = gtfs.getVehiclePositions({ + routeId: 'ROUTE_1' +}); + +// Filter by trip +const tripVehicle = gtfs.getVehiclePositions({ + tripId: 'TRIP_123' +}); + +// Get vehicle by trip ID +const vehicle = gtfs.getVehiclePositionByTripId('TRIP_123'); + +// Vehicle structure +console.log(vehicle.position); // { latitude, longitude, bearing, speed } +console.log(vehicle.current_stop_sequence); +console.log(vehicle.current_status); // VehicleStopStatus enum +console.log(vehicle.timestamp); +``` + +#### Merging Realtime with Static Data + +The library automatically merges realtime data with static schedules when requested: + +```typescript +// Get trips with realtime data +const tripsWithRT = gtfs.getTrips({ + routeId: 'ROUTE_1', + date: '20240115', + includeRealtime: true // Include RT data +}); + +for (const trip of tripsWithRT) { + if (trip.realtime?.vehicle_position) { + console.log('Vehicle location:', trip.realtime.vehicle_position.position); + } + if (trip.realtime?.trip_update) { + console.log('Trip delay:', trip.realtime.trip_update.delay, 'seconds'); + } +} + +// Get stop times with realtime delays +const stopTimesWithRT = gtfs.getStopTimes({ + tripId: 'TRIP_123', + includeRealtime: true // Include RT data +}); + +for (const st of stopTimesWithRT) { + console.log(`Stop: ${st.stop_id}`); + console.log(`Scheduled: ${st.arrival_time}`); + if (st.realtime?.arrival_delay) { + console.log(`Delay: ${st.realtime.arrival_delay} seconds`); + } +} +``` + +#### Clearing Realtime Data + +```typescript +// Clear all realtime data +gtfs.clearRealtimeData(); + +// Then fetch fresh data +await gtfs.fetchRealtimeData(); +``` + +#### GTFS-RT Enums + +The library exports all GTFS-RT enums for type checking: + +```typescript +import { + AlertCause, + AlertEffect, + ScheduleRelationship, + VehicleStopStatus, + CongestionLevel, + OccupancyStatus +} from 'gtfs-sqljs'; + +// Use enums for filtering or comparison +if (alert.cause === AlertCause.ACCIDENT) { + console.log('Alert is due to an accident'); +} +``` + ### Export Database ```typescript -// Export to ArrayBuffer for storage +// Export to ArrayBuffer for storage (includes RT data) const buffer = gtfs.export(); // Save to file (Node.js) @@ -465,31 +636,59 @@ example(); #### Special Methods - `getStopTimesByTrip(tripId)` - Get stop times for a trip (ordered by stop_sequence) +#### GTFS Realtime Methods +- `fetchRealtimeData(urls?)` - Fetch and load RT data from protobuf feeds +- `clearRealtimeData()` - Clear all realtime data from database +- `setRealtimeFeedUrls(urls)` - Configure RT feed URLs +- `getRealtimeFeedUrls()` - Get configured RT feed URLs +- `setStalenessThreshold(seconds)` - Set staleness threshold (default: 120) +- `getStalenessThreshold()` - Get staleness threshold +- `getAlerts(filters?)` - Get alerts with optional filters (routeId, stopId, tripId, activeOnly) +- `getAlertById(alertId)` - Get alert by ID +- `getVehiclePositions(filters?)` - Get vehicle positions with optional filters (routeId, tripId, vehicleId) +- `getVehiclePositionByTripId(tripId)` - Get vehicle position by trip ID + #### Database Methods -- `export()` - Export database to ArrayBuffer +- `export()` - Export database to ArrayBuffer (includes RT data) - `getDatabase()` - Get direct access to sql.js database - `close()` - Close database connection ## TypeScript Support -This library is written in TypeScript and provides full type definitions for all GTFS entities and filter options: +This library is written in TypeScript and provides full type definitions for all GTFS entities, filter options, and GTFS-RT types: ```typescript -import type { Stop, Route, Trip, StopTime, TripFilters, StopTimeFilters } from 'gtfs-sqljs'; +import type { + // Static GTFS types + Stop, Route, Trip, StopTime, + TripFilters, StopTimeFilters, + // GTFS-RT types + Alert, VehiclePosition, TripWithRealtime, StopTimeWithRealtime, + AlertFilters, VehiclePositionFilters, + // GTFS-RT enums + AlertCause, AlertEffect, ScheduleRelationship +} from 'gtfs-sqljs'; const stop: Stop = gtfs.getStopById('STOP_123')!; // Use filter types for better type safety const filters: TripFilters = { routeId: 'ROUTE_1', - directionId: 0 + directionId: 0, + includeRealtime: true }; const trips = gtfs.getTrips(filters); + +// RT types +const alerts: Alert[] = gtfs.getAlerts({ activeOnly: true }); +const vehicles: VehiclePosition[] = gtfs.getVehiclePositions(); ``` ## GTFS Specification -This library implements the [GTFS Schedule Reference](https://gtfs.org/schedule/reference/) with proper handling of required and optional fields. +This library implements: +- [GTFS Schedule Reference](https://gtfs.org/schedule/reference/) with proper handling of required and optional fields +- [GTFS Realtime Reference v2.0](https://gtfs.org/realtime/reference/) with support for Alerts, Trip Updates, and Vehicle Positions ## License diff --git a/src/loaders/gtfs-rt-loader.ts b/src/loaders/gtfs-rt-loader.ts index 8c1cc42..686c743 100644 --- a/src/loaders/gtfs-rt-loader.ts +++ b/src/loaders/gtfs-rt-loader.ts @@ -1,6 +1,98 @@ import type { Database } from 'sql.js'; import protobuf from 'protobufjs'; +// Types for protobuf decoded objects +interface ProtobufTranslation { + text: string; + language?: string; +} + +interface ProtobufTranslatedString { + translation: ProtobufTranslation[]; +} + +interface ProtobufAlert { + id: string; + activePeriod?: unknown[]; + active_period?: unknown[]; + informedEntity?: unknown[]; + informed_entity?: unknown[]; + cause?: number; + effect?: number; + url?: ProtobufTranslatedString; + headerText?: ProtobufTranslatedString; + header_text?: ProtobufTranslatedString; + descriptionText?: ProtobufTranslatedString; + description_text?: ProtobufTranslatedString; +} + +interface ProtobufTripDescriptor { + tripId?: string; + trip_id?: string; + routeId?: string; + route_id?: string; + scheduleRelationship?: number; + schedule_relationship?: number; +} + +interface ProtobufVehicleDescriptor { + id?: string; + label?: string; + licensePlate?: string; + license_plate?: string; +} + +interface ProtobufPosition { + latitude?: number; + longitude?: number; + bearing?: number; + odometer?: number; + speed?: number; +} + +interface ProtobufVehiclePosition { + trip?: ProtobufTripDescriptor; + vehicle?: ProtobufVehicleDescriptor; + position?: ProtobufPosition; + currentStopSequence?: number; + current_stop_sequence?: number; + stopId?: string; + stop_id?: string; + currentStatus?: number; + current_status?: number; + timestamp?: number; + congestionLevel?: number; + congestion_level?: number; + occupancyStatus?: number; + occupancy_status?: number; +} + +interface ProtobufStopTimeEvent { + delay?: number; + time?: number; + uncertainty?: number; +} + +interface ProtobufStopTimeUpdate { + stopSequence?: number; + stop_sequence?: number; + stopId?: string; + stop_id?: string; + scheduleRelationship?: number; + schedule_relationship?: number; + arrival?: ProtobufStopTimeEvent; + departure?: ProtobufStopTimeEvent; +} + +interface ProtobufTripUpdate { + trip?: ProtobufTripDescriptor; + vehicle?: ProtobufVehicleDescriptor; + timestamp?: number; + delay?: number; + stopTimeUpdate?: ProtobufStopTimeUpdate[]; + stop_time_update?: ProtobufStopTimeUpdate[]; +} + // GTFS Realtime protobuf definition (v2.0) // Source: https://github.com/google/transit/blob/master/gtfs-realtime/proto/gtfs-realtime.proto const GTFS_RT_PROTO = ` @@ -247,7 +339,7 @@ function camelToSnake(str: string): string { return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); } -function convertKeysToSnakeCase(obj: any): any { +function convertKeysToSnakeCase(obj: unknown): unknown { if (obj === null || obj === undefined) { return obj; } @@ -255,11 +347,11 @@ function convertKeysToSnakeCase(obj: any): any { return obj.map(convertKeysToSnakeCase); } if (typeof obj === 'object') { - const result: any = {}; - for (const key in obj) { - if (obj.hasOwnProperty(key)) { + const result: Record = {}; + for (const key in obj as Record) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { const snakeKey = camelToSnake(key); - result[snakeKey] = convertKeysToSnakeCase(obj[key]); + result[snakeKey] = convertKeysToSnakeCase((obj as Record)[key]); } } return result; @@ -268,12 +360,12 @@ function convertKeysToSnakeCase(obj: any): any { } // Parse TranslatedString to JSON -function parseTranslatedString(ts: any): string | null { +function parseTranslatedString(ts: ProtobufTranslatedString | undefined): string | null { if (!ts || !ts.translation || ts.translation.length === 0) { return null; } return JSON.stringify({ - translation: ts.translation.map((t: any) => ({ + translation: ts.translation.map((t) => ({ text: t.text, language: t.language || undefined })) @@ -281,7 +373,7 @@ function parseTranslatedString(ts: any): string | null { } // Insert alerts into database -function insertAlerts(db: Database, alerts: any[], timestamp: number): void { +function insertAlerts(db: Database, alerts: ProtobufAlert[], timestamp: number): void { const stmt = db.prepare(` INSERT OR REPLACE INTO rt_alerts ( id, active_period, informed_entity, cause, effect, @@ -317,7 +409,7 @@ function insertAlerts(db: Database, alerts: any[], timestamp: number): void { } // Insert vehicle positions into database -function insertVehiclePositions(db: Database, positions: any[], timestamp: number): void { +function insertVehiclePositions(db: Database, positions: ProtobufVehiclePosition[], timestamp: number): void { const stmt = db.prepare(` INSERT OR REPLACE INTO rt_vehicle_positions ( trip_id, route_id, vehicle_id, vehicle_label, vehicle_license_plate, @@ -368,7 +460,7 @@ function insertVehiclePositions(db: Database, positions: any[], timestamp: numbe } // Insert trip updates into database -function insertTripUpdates(db: Database, updates: any[], timestamp: number): void { +function insertTripUpdates(db: Database, updates: ProtobufTripUpdate[], timestamp: number): void { const tripStmt = db.prepare(` INSERT OR REPLACE INTO rt_trip_updates ( trip_id, route_id, vehicle_id, vehicle_label, vehicle_license_plate, @@ -473,9 +565,9 @@ export async function loadRealtimeData(db: Database, feedUrls: string[]): Promis const now = Math.floor(Date.now() / 1000); // Collect all entities by type - const allAlerts: any[] = []; - const allVehiclePositions: any[] = []; - const allTripUpdates: any[] = []; + const allAlerts: ProtobufAlert[] = []; + const allVehiclePositions: ProtobufVehiclePosition[] = []; + const allTripUpdates: ProtobufTripUpdate[] = []; for (const feed of feeds) { if (!feed.entity) continue; diff --git a/src/queries/rt-alerts.ts b/src/queries/rt-alerts.ts index fbe43de..218aeca 100644 --- a/src/queries/rt-alerts.ts +++ b/src/queries/rt-alerts.ts @@ -7,7 +7,7 @@ export type { AlertFilters }; /** * Parse JSON fields from database */ -function parseAlert(row: Record): Alert { +function parseAlert(row: Record): Alert { return { id: String(row.id), active_period: row.active_period ? JSON.parse(String(row.active_period)) as TimeRange[] : [], diff --git a/src/queries/rt-vehicle-positions.ts b/src/queries/rt-vehicle-positions.ts index 3099c48..6bef3de 100644 --- a/src/queries/rt-vehicle-positions.ts +++ b/src/queries/rt-vehicle-positions.ts @@ -1,5 +1,5 @@ import type { Database} from 'sql.js'; -import type { VehiclePosition, Position, VehicleDescriptor } from '../types/gtfs-rt'; +import type { VehiclePosition } from '../types/gtfs-rt'; import type { VehiclePositionFilters } from '../types/gtfs-rt'; export type { VehiclePositionFilters }; @@ -7,7 +7,7 @@ export type { VehiclePositionFilters }; /** * Parse vehicle position from database row */ -function parseVehiclePosition(row: Record): VehiclePosition { +function parseVehiclePosition(row: Record): VehiclePosition { const vp: VehiclePosition = { trip_id: String(row.trip_id), route_id: row.route_id ? String(row.route_id) : undefined, diff --git a/src/queries/trips.ts b/src/queries/trips.ts index 8dd2c93..ce36ec3 100644 --- a/src/queries/trips.ts +++ b/src/queries/trips.ts @@ -5,7 +5,6 @@ import type { Database } from 'sql.js'; import type { Trip } from '../types/gtfs'; import type { TripRealtime, VehiclePosition } from '../types/gtfs-rt'; -import { getVehiclePositionByTripId } from './rt-vehicle-positions'; export interface TripFilters { tripId?: string; @@ -45,7 +44,7 @@ function mergeRealtimeData( `); vpStmt.bind([...tripIds, staleThreshold]); - const vpMap = new Map(); + const vpMap = new Map>(); while (vpStmt.step()) { const row = vpStmt.getAsObject() as Record; vpMap.set(String(row.trip_id), row); @@ -60,7 +59,7 @@ function mergeRealtimeData( `); tuStmt.bind([...tripIds, staleThreshold]); - const tuMap = new Map(); + const tuMap = new Map>(); while (tuStmt.step()) { const row = tuStmt.getAsObject() as Record; tuMap.set(String(row.trip_id), row); diff --git a/website/demo.html b/website/demo.html index 5b6385f..02d946f 100644 --- a/website/demo.html +++ b/website/demo.html @@ -26,11 +26,22 @@

Interactive Demo

+
+ + + + +
@@ -89,6 +100,20 @@

Stop Times for

Loading stop times...

+ +
+

Active Alerts

+
+

No realtime data loaded. Use the "Fetch RT Data" button above.

+
+
+ +
+

Tracked Vehicles

+
+

No realtime data loaded. Use the "Fetch RT Data" button above.

+
+
diff --git a/website/demo.ts b/website/demo.ts index 41bb08c..fc332cb 100644 --- a/website/demo.ts +++ b/website/demo.ts @@ -1,10 +1,13 @@ import initSqlJs from 'sql.js'; +import type { SqlJsStatic } from 'sql.js'; import { GtfsSqlJs } from 'gtfs-sqljs'; -import type { Route, Trip, StopTime, Agency } from 'gtfs-sqljs'; +import type { Route, Trip, StopTime, Agency, Alert, VehiclePosition, TripWithRealtime, StopTimeWithRealtime } from 'gtfs-sqljs'; let gtfs: GtfsSqlJs; let selectedDate: string; -let SQL: any; +let SQL: SqlJsStatic | undefined; +let autoRefreshInterval: number | null = null; +let isAutoRefreshActive = false; // Convert https URL to proxy URL function getProxyUrl(httpsUrl: string): string { @@ -16,8 +19,8 @@ function getProxyUrl(httpsUrl: string): string { return `https://gtfs-proxy.sys-dev-run.re/proxy/${withoutProtocol}`; } -// Load GTFS from URL or default -async function loadGTFS(url?: string) { +// Load GTFS from URL +async function loadGTFS(url: string) { const loadingEl = document.getElementById('loading')!; const errorEl = document.getElementById('error')!; const errorMessageEl = document.getElementById('error-message')!; @@ -29,14 +32,8 @@ async function loadGTFS(url?: string) { errorEl.style.display = 'none'; contentEl.style.display = 'none'; - // Determine source - let source: string; - if (url) { - source = getProxyUrl(url); - } else { - // Load default car-jaune.zip from public folder - source = './car-jaune.zip'; - } + // Use proxy for the URL + const source = getProxyUrl(url); // Load GTFS data // Skip shapes.txt to reduce memory usage and improve load time @@ -49,16 +46,27 @@ async function loadGTFS(url?: string) { loadingEl.style.display = 'none'; contentEl.style.display = 'block'; + // Enable RT buttons + (document.getElementById('fetch-rt-btn') as HTMLButtonElement).disabled = false; + (document.getElementById('auto-refresh-btn') as HTMLButtonElement).disabled = false; + // Initialize date picker with today's date initDatePicker(); // Setup download button setupDownloadButton(); + // Setup RT buttons + setupRTButtons(); + // Render initial data renderAgencies(); renderActiveCalendars(); renderRoutes(); + + // Clear RT displays + renderAlerts(); + renderVehicleCount(); } catch (error) { console.error('Error loading GTFS data:', error); loadingEl.style.display = 'none'; @@ -86,8 +94,9 @@ async function init() { } }); - // Load default GTFS (car-jaune.zip) - await loadGTFS(); + // Load default GTFS (Car Jaune URL) + const defaultUrl = urlInput.value || 'https://pysae.com/api/v2/groups/car-jaune/gtfs/pub'; + await loadGTFS(defaultUrl); } catch (error) { console.error('Error initializing demo:', error); const loadingEl = document.getElementById('loading')!; @@ -218,11 +227,12 @@ function renderRoutes() { // Hide stop times section stopTimesSectionEl.style.display = 'none'; - // Get trips for this route on the selected date + // Get trips for this route on the selected date (with realtime data) const trips = gtfs.getTrips({ routeId: routeId, - date: selectedDate - }); + date: selectedDate, + includeRealtime: true + }) as TripWithRealtime[]; if (trips.length === 0) { tripsListEl.innerHTML = '

No trips found for this route on the selected date

'; @@ -235,7 +245,7 @@ function renderRoutes() { interface TripGroup { headsign: string; directionId: number; - trips: Trip[]; + trips: TripWithRealtime[]; } const groupMap = new Map(); @@ -287,12 +297,29 @@ function renderRoutes() { const tripName = trip.trip_short_name || trip.trip_id; const showTripId = trip.trip_short_name ? trip.trip_id : null; + // Check for realtime data + const hasVehicle = trip.realtime?.vehicle_position !== null; + const hasTripUpdate = trip.realtime?.trip_update !== null; + const delay = trip.realtime?.trip_update?.delay; + + let rtIndicator = ''; + if (hasVehicle || hasTripUpdate) { + rtIndicator = `RT`; + } + + let delayIndicator = ''; + if (delay !== undefined && delay !== null) { + const delayMin = Math.round(delay / 60); + const delayClass = delayMin > 5 ? 'delay-high' : delayMin > 2 ? 'delay-medium' : 'delay-low'; + delayIndicator = `${delayMin > 0 ? '+' : ''}${delayMin} min`; + } + return ` -
+
-
${escapeHtml(tripName)}
+
${escapeHtml(tripName)} ${rtIndicator}
${showTripId ? `
ID: ${escapeHtml(showTripId)}
` : ''} -
Service: ${escapeHtml(trip.service_id)}
+
Service: ${escapeHtml(trip.service_id)} ${delayIndicator}
→
@@ -317,8 +344,11 @@ function renderRoutes() { const stopTimesListEl = document.getElementById('stop-times-list')!; const selectedTripNameEl = document.getElementById('selected-trip-name')!; - // Get stop times for this trip - const stopTimes = gtfs.getStopTimesByTrip(tripId); + // Get stop times for this trip (with realtime data) + const stopTimes = gtfs.getStopTimes({ + tripId: tripId, + includeRealtime: true + }) as StopTimeWithRealtime[]; if (stopTimes.length === 0) { stopTimesListEl.innerHTML = '

No stop times found for this trip

'; @@ -327,15 +357,38 @@ function renderRoutes() { return; } - // Render stop times with stop names + // Render stop times with stop names and realtime data const html = stopTimes.map(st => { const stop = gtfs.getStopById(st.stop_id); const stopName = stop ? stop.stop_name : st.stop_id; + const hasRealtime = st.realtime !== undefined; + const arrivalDelay = st.realtime?.arrival_delay; + const departureDelay = st.realtime?.departure_delay; + + // Calculate realtime arrival if delay exists + let rtArrivalTime = ''; + if (arrivalDelay !== undefined && arrivalDelay !== null) { + const scheduledSeconds = timeToSeconds(st.arrival_time); + const rtSeconds = scheduledSeconds + arrivalDelay; + rtArrivalTime = secondsToTime(rtSeconds); + } + + const delayClass = arrivalDelay && arrivalDelay > 300 ? 'delay-high' : + arrivalDelay && arrivalDelay > 120 ? 'delay-medium' : + arrivalDelay && arrivalDelay > 0 ? 'delay-low' : ''; + return ` -
+
#${st.stop_sequence}
-
${st.arrival_time}
+
+ ${hasRealtime && rtArrivalTime ? ` +
${rtArrivalTime}
+
${st.arrival_time}
+ ` : ` +
${st.arrival_time}
+ `} +
${escapeHtml(stopName)}
`; @@ -349,6 +402,165 @@ function renderRoutes() { stopTimesSectionEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; +// Fetch realtime data +async function fetchRealtimeData() { + const rtUrlInput = document.getElementById('gtfs-rt-url-input') as HTMLInputElement; + const rtUrl = rtUrlInput.value.trim(); + + if (!rtUrl) { + return; + } + + try { + const proxyUrl = getProxyUrl(rtUrl); + await gtfs.fetchRealtimeData([proxyUrl]); + + // Update displays + renderAlerts(); + renderVehicleCount(); + } catch (error) { + console.error('Error fetching realtime data:', error); + alert(`Failed to fetch realtime data: ${error instanceof Error ? error.message : String(error)}`); + } +} + +// Setup RT buttons +function setupRTButtons() { + const fetchBtn = document.getElementById('fetch-rt-btn') as HTMLButtonElement; + const autoRefreshBtn = document.getElementById('auto-refresh-btn') as HTMLButtonElement; + + fetchBtn.addEventListener('click', async () => { + fetchBtn.disabled = true; + fetchBtn.textContent = 'Fetching...'; + await fetchRealtimeData(); + fetchBtn.disabled = false; + fetchBtn.textContent = 'Fetch RT Data'; + }); + + autoRefreshBtn.addEventListener('click', () => { + if (isAutoRefreshActive) { + // Stop auto-refresh + if (autoRefreshInterval !== null) { + clearInterval(autoRefreshInterval); + autoRefreshInterval = null; + } + isAutoRefreshActive = false; + autoRefreshBtn.textContent = 'Fetch RT Data Every 10s'; + autoRefreshBtn.classList.remove('button-active'); + } else { + // Start auto-refresh + isAutoRefreshActive = true; + autoRefreshBtn.textContent = 'Stop Fetching RT Data'; + autoRefreshBtn.classList.add('button-active'); + + // Fetch immediately + fetchRealtimeData(); + + // Then fetch every 10 seconds + autoRefreshInterval = window.setInterval(() => { + fetchRealtimeData(); + }, 10000); + } + }); +} + +// Render alerts +function renderAlerts() { + const alertsListEl = document.getElementById('alerts-list')!; + + try { + const alerts = gtfs.getAlerts({ activeOnly: true }); + + if (alerts.length === 0) { + alertsListEl.innerHTML = '

No active alerts

'; + return; + } + + const html = alerts.map(alert => { + let headerText = 'Alert'; + if (alert.header_text) { + try { + const parsed = typeof alert.header_text === 'string' + ? JSON.parse(alert.header_text) + : alert.header_text; + headerText = parsed.translation?.[0]?.text || 'Alert'; + } catch (e) { + // Use default + } + } + + let descriptionText = ''; + if (alert.description_text) { + try { + const parsed = typeof alert.description_text === 'string' + ? JSON.parse(alert.description_text) + : alert.description_text; + descriptionText = parsed.translation?.[0]?.text || ''; + } catch (e) { + // No description + } + } + + // Get affected routes + const affectedRoutes = alert.informed_entity + .filter(e => e.route_id) + .map(e => e.route_id) + .slice(0, 10); // Show max 10 routes + + const routesBadges = affectedRoutes.map(routeId => { + const route = gtfs.getRouteById(routeId!); + if (!route) return ''; + + const bgColor = route.route_color ? `#${route.route_color}` : '#64748b'; + const textColor = route.route_text_color ? `#${route.route_text_color}` : getContrastColor(bgColor); + + return `${escapeHtml(route.route_short_name)}`; + }).join(''); + + const moreRoutes = alert.informed_entity.filter(e => e.route_id).length > 10 + ? `+${alert.informed_entity.filter(e => e.route_id).length - 10} more` + : ''; + + return ` +
+

${escapeHtml(headerText)}

+ ${descriptionText ? `

${escapeHtml(descriptionText.substring(0, 200))}${descriptionText.length > 200 ? '...' : ''}

` : ''} + ${routesBadges || moreRoutes ? `
Affected routes: ${routesBadges}${moreRoutes}
` : ''} +
+ `; + }).join(''); + + alertsListEl.innerHTML = html; + } catch (error) { + console.error('Error rendering alerts:', error); + alertsListEl.innerHTML = '

Error loading alerts

'; + } +} + +// Render vehicle count +function renderVehicleCount() { + const vehiclesCountEl = document.getElementById('vehicles-count')!; + + try { + const vehicles = gtfs.getVehiclePositions(); + + if (vehicles.length === 0) { + vehiclesCountEl.innerHTML = '

No tracked vehicles

'; + return; + } + + vehiclesCountEl.innerHTML = ` +
+
${vehicles.length}
+
Active Vehicle${vehicles.length > 1 ? 's' : ''}
+
+ `; + } catch (error) { + console.error('Error rendering vehicle count:', error); + vehiclesCountEl.innerHTML = '

Error loading vehicle data

'; + } +} + // Setup download button function setupDownloadButton() { const downloadBtn = document.getElementById('download-db-btn') as HTMLButtonElement; @@ -400,5 +612,18 @@ function getContrastColor(hexColor: string): string { return luminance > 0.5 ? '#000000' : '#ffffff'; } +// Time conversion helpers +function timeToSeconds(time: string): number { + const [hours, minutes, seconds] = time.split(':').map(Number); + return hours * 3600 + minutes * 60 + seconds; +} + +function secondsToTime(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; +} + // Start the demo init(); diff --git a/website/style.css b/website/style.css index cb9893f..71245eb 100644 --- a/website/style.css +++ b/website/style.css @@ -601,3 +601,159 @@ footer { grid-column: 1 / -1; } } + +/* Realtime Data Styles */ +.button-active { + background: #ef4444; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} + +.alert-card { + background: #fef2f2; + border-left: 4px solid #ef4444; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0.5rem; +} + +.alert-card h4 { + color: #991b1b; + margin-top: 0; + margin-bottom: 0.5rem; +} + +.alert-card p { + color: #7f1d1d; + margin: 0.5rem 0; +} + +.alert-routes { + margin-top: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.route-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 600; +} + +.route-badge-more { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.vehicle-count-display { + text-align: center; + padding: 2rem; +} + +.vehicle-count-number { + font-size: 3rem; + font-weight: 700; + color: var(--primary-color); +} + +.vehicle-count-label { + font-size: 1.125rem; + color: var(--text-secondary); + margin-top: 0.5rem; +} + +.no-data { + color: var(--text-secondary); + font-style: italic; + text-align: center; + padding: 1rem; +} + +.rt-indicator { + display: inline-block; + background: #10b981; + color: white; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + margin-left: 0.5rem; +} + +.delay-indicator { + display: inline-block; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + margin-left: 0.5rem; +} + +.delay-low { + background: #d1fae5; + color: #065f46; +} + +.delay-medium { + background: #fed7aa; + color: #92400e; +} + +.delay-high { + background: #fecaca; + color: #991b1b; +} + +.trip-has-rt { + border-left: 3px solid #10b981; +} + +.has-realtime { + background: #f0fdf4; +} + +.rt-time { + font-weight: 700; + font-size: 1.125rem; +} + +.scheduled-time { + font-size: 0.875rem; + color: var(--text-secondary); + text-decoration: line-through; +} + +.url-input-section { + margin-bottom: 1.5rem; +} + +.url-input-section label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +.url-input-section input[type="url"] { + width: 100%; + padding: 0.75rem; + border: 2px solid var(--border); + border-radius: 0.5rem; + font-size: 1rem; + margin-bottom: 0.75rem; +} + +.url-input-section button { + margin-right: 0.5rem; +}