diff --git a/.gitignore b/.gitignore index 89c97d2..60e2f31 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,10 @@ yarn-error.log* # Extracted GTFS files from ZIP tests/fixtures/*.txt + +# Downloaded GTFS-RT test files +*.pb + +# Debug/temporary test scripts +test-*-debug.ts +test-protobuf-decode.ts diff --git a/package-lock.json b/package-lock.json index 1dc7aef..9418260 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "jszip": "^3.10.1", "papaparse": "^5.5.3", + "protobufjs": "^7.2.5", "sql.js": "^1.10.3" }, "devDependencies": { @@ -767,6 +768,70 @@ "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", @@ -1107,7 +1172,6 @@ "version": "20.19.24", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2786,6 +2850,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -3320,6 +3390,30 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4120,7 +4214,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/uri-js": { diff --git a/package.json b/package.json index 14a4a61..1502eba 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "dependencies": { "jszip": "^3.10.1", "papaparse": "^5.5.3", + "protobufjs": "^7.2.5", "sql.js": "^1.10.3" }, "devDependencies": { diff --git a/src/gtfs-sqljs.ts b/src/gtfs-sqljs.ts index 1eee858..d83132b 100644 --- a/src/gtfs-sqljs.ts +++ b/src/gtfs-sqljs.ts @@ -6,6 +6,8 @@ import initSqlJs, { type Database, type SqlJsStatic } from 'sql.js'; import { getAllCreateStatements } from './schema/schema'; import { loadGTFSZip } from './loaders/zip-loader'; import { loadGTFSData } from './loaders/data-loader'; +import { createRealtimeTables, clearRealtimeData as clearRTData } from './schema/gtfs-rt-schema'; +import { loadRealtimeData } from './loaders/gtfs-rt-loader'; // Query methods import { getAgencyById, getAgencies, type AgencyFilters } from './queries/agencies'; @@ -17,14 +19,19 @@ import { getCalendarDates, getCalendarDatesForDate, } from './queries/calendar'; -import { getTripById, getTrips, type TripFilters } from './queries/trips'; -import { getStopTimesByTrip, getStopTimes, type StopTimeFilters } from './queries/stop-times'; +import { getTripById, getTrips, type TripFilters, type TripWithRealtime } from './queries/trips'; +import { getStopTimesByTrip, getStopTimes, type StopTimeFilters, type StopTimeWithRealtime } from './queries/stop-times'; +import { getAlerts as getAlertsQuery, getAlertById, type AlertFilters } from './queries/rt-alerts'; +import { getVehiclePositions as getVehiclePositionsQuery, getVehiclePositionByTripId, type VehiclePositionFilters } from './queries/rt-vehicle-positions'; // Types import type { Agency, Stop, Route, Trip, StopTime, Calendar, CalendarDate } from './types/gtfs'; +import type { Alert, VehiclePosition } from './types/gtfs-rt'; // Export filter types for users -export type { AgencyFilters, StopFilters, RouteFilters, TripFilters, StopTimeFilters }; +export type { AgencyFilters, StopFilters, RouteFilters, TripFilters, StopTimeFilters, AlertFilters, VehiclePositionFilters }; +// Export RT types +export type { Alert, VehiclePosition, TripWithRealtime, StopTimeWithRealtime }; export interface GtfsSqlJsOptions { /** @@ -52,11 +59,24 @@ export interface GtfsSqlJsOptions { * Tables will be created but no data will be imported for these files */ skipFiles?: string[]; + + /** + * Optional: Array of GTFS-RT feed URLs for realtime data + */ + realtimeFeedUrls?: string[]; + + /** + * Optional: Staleness threshold in seconds (default: 120) + * Realtime data older than this will be excluded from queries + */ + stalenessThreshold?: number; } export class GtfsSqlJs { private db: Database | null = null; private SQL: SqlJsStatic | null = null; + private realtimeFeedUrls: string[] = []; + private stalenessThreshold: number = 120; /** * Private constructor - use static factory methods instead @@ -97,15 +117,26 @@ export class GtfsSqlJs { // Create new database this.db = new this.SQL.Database(); - // Create schema + // Create GTFS schema const createStatements = getAllCreateStatements(); for (const statement of createStatements) { this.db.run(statement); } + // Create GTFS-RT tables + createRealtimeTables(this.db); + // Load GTFS data const files = await loadGTFSZip(zipPath); await loadGTFSData(this.db, files, options.skipFiles); + + // Set RT configuration + if (options.realtimeFeedUrls) { + this.realtimeFeedUrls = options.realtimeFeedUrls; + } + if (options.stalenessThreshold !== undefined) { + this.stalenessThreshold = options.stalenessThreshold; + } } /** @@ -120,6 +151,17 @@ export class GtfsSqlJs { // Load existing database this.db = new this.SQL.Database(new Uint8Array(database)); + + // Ensure RT tables exist (in case loading old database) + createRealtimeTables(this.db); + + // Set RT configuration + if (options.realtimeFeedUrls) { + this.realtimeFeedUrls = options.realtimeFeedUrls; + } + if (options.stalenessThreshold !== undefined) { + this.stalenessThreshold = options.stalenessThreshold; + } } /** @@ -286,7 +328,7 @@ export class GtfsSqlJs { finalFilters.serviceIds = serviceIds; } - return getTrips(this.db, finalFilters); + return getTrips(this.db, finalFilters, this.stalenessThreshold); } // ==================== Stop Time Methods ==================== @@ -335,6 +377,91 @@ export class GtfsSqlJs { finalFilters.serviceIds = serviceIds; } - return getStopTimes(this.db, finalFilters); + return getStopTimes(this.db, finalFilters, this.stalenessThreshold); + } + + // ==================== Realtime Methods ==================== + + /** + * Set GTFS-RT feed URLs + */ + setRealtimeFeedUrls(urls: string[]): void { + this.realtimeFeedUrls = urls; + } + + /** + * Get currently configured GTFS-RT feed URLs + */ + getRealtimeFeedUrls(): string[] { + return [...this.realtimeFeedUrls]; + } + + /** + * Set staleness threshold in seconds + */ + setStalenessThreshold(seconds: number): void { + this.stalenessThreshold = seconds; + } + + /** + * Get current staleness threshold + */ + getStalenessThreshold(): number { + return this.stalenessThreshold; + } + + /** + * Fetch and load GTFS Realtime data from configured feed URLs or provided URLs + * @param urls - Optional array of feed URLs. If not provided, uses configured feed URLs + */ + async fetchRealtimeData(urls?: string[]): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const feedUrls = urls || this.realtimeFeedUrls; + if (feedUrls.length === 0) { + throw new Error('No realtime feed URLs configured. Use setRealtimeFeedUrls() or pass urls parameter.'); + } + + await loadRealtimeData(this.db, feedUrls); + } + + /** + * Clear all realtime data from the database + */ + clearRealtimeData(): void { + if (!this.db) throw new Error('Database not initialized'); + clearRTData(this.db); + } + + /** + * Get alerts with optional filters + */ + getAlerts(filters?: AlertFilters): Alert[] { + if (!this.db) throw new Error('Database not initialized'); + return getAlertsQuery(this.db, filters, this.stalenessThreshold); + } + + /** + * Get alert by ID + */ + getAlertById(alertId: string): Alert | null { + if (!this.db) throw new Error('Database not initialized'); + return getAlertById(this.db, alertId, this.stalenessThreshold); + } + + /** + * Get vehicle positions with optional filters + */ + getVehiclePositions(filters?: VehiclePositionFilters): VehiclePosition[] { + if (!this.db) throw new Error('Database not initialized'); + return getVehiclePositionsQuery(this.db, filters, this.stalenessThreshold); + } + + /** + * Get vehicle position by trip ID + */ + getVehiclePositionByTripId(tripId: string): VehiclePosition | null { + if (!this.db) throw new Error('Database not initialized'); + return getVehiclePositionByTripId(this.db, tripId, this.stalenessThreshold); } } diff --git a/src/index.ts b/src/index.ts index 0314b82..ab4eeec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,23 @@ * @license MIT */ -export { GtfsSqlJs, type GtfsSqlJsOptions, type AgencyFilters, type StopFilters, type RouteFilters, type TripFilters, type StopTimeFilters } from './gtfs-sqljs'; +export { + GtfsSqlJs, + type GtfsSqlJsOptions, + type AgencyFilters, + type StopFilters, + type RouteFilters, + type TripFilters, + type StopTimeFilters, + type AlertFilters, + type VehiclePositionFilters, + type Alert, + type VehiclePosition, + type TripWithRealtime, + type StopTimeWithRealtime +} from './gtfs-sqljs'; -// Export types +// Export GTFS types export type { Agency, Stop, @@ -26,5 +40,30 @@ export type { Attribution, } from './types/gtfs'; +// Export GTFS-RT types +export type { + TranslatedString, + EntitySelector, + TimeRange, + Position, + VehicleDescriptor, + StopTimeEvent, + StopTimeUpdate, + TripUpdate, + StopTimeRealtime, + TripRealtime, + RealtimeConfig +} from './types/gtfs-rt'; + +// Export GTFS-RT enums +export { + ScheduleRelationship, + VehicleStopStatus, + CongestionLevel, + OccupancyStatus, + AlertCause, + AlertEffect +} from './types/gtfs-rt'; + // Export schema definitions for advanced use export { GTFS_SCHEMA, type TableSchema, type ColumnDefinition, type IndexDefinition } from './schema/schema'; diff --git a/src/loaders/gtfs-rt-loader.ts b/src/loaders/gtfs-rt-loader.ts new file mode 100644 index 0000000..8c1cc42 --- /dev/null +++ b/src/loaders/gtfs-rt-loader.ts @@ -0,0 +1,506 @@ +import type { Database } from 'sql.js'; +import protobuf from 'protobufjs'; + +// GTFS Realtime protobuf definition (v2.0) +// Source: https://github.com/google/transit/blob/master/gtfs-realtime/proto/gtfs-realtime.proto +const GTFS_RT_PROTO = ` +syntax = "proto2"; +option java_package = "com.google.transit.realtime"; +package transit_realtime; + +message FeedMessage { + required FeedHeader header = 1; + repeated FeedEntity entity = 2; +} + +message FeedHeader { + required string gtfs_realtime_version = 1; + enum Incrementality { + FULL_DATASET = 0; + DIFFERENTIAL = 1; + } + optional Incrementality incrementality = 2 [default = FULL_DATASET]; + optional uint64 timestamp = 3; +} + +message FeedEntity { + required string id = 1; + optional bool is_deleted = 2 [default = false]; + optional TripUpdate trip_update = 3; + optional VehiclePosition vehicle = 4; + optional Alert alert = 5; +} + +message TripUpdate { + required TripDescriptor trip = 1; + optional VehicleDescriptor vehicle = 3; + repeated StopTimeUpdate stop_time_update = 2; + optional uint64 timestamp = 4; + optional int32 delay = 5; + + message StopTimeEvent { + optional int32 delay = 1; + optional int64 time = 2; + optional int32 uncertainty = 3; + } + + message StopTimeUpdate { + optional uint32 stop_sequence = 1; + optional string stop_id = 4; + optional StopTimeEvent arrival = 2; + optional StopTimeEvent departure = 3; + enum ScheduleRelationship { + SCHEDULED = 0; + SKIPPED = 1; + NO_DATA = 2; + } + optional ScheduleRelationship schedule_relationship = 5 [default = SCHEDULED]; + } +} + +message VehiclePosition { + optional TripDescriptor trip = 1; + optional VehicleDescriptor vehicle = 8; + optional Position position = 2; + optional uint32 current_stop_sequence = 3; + optional string stop_id = 7; + enum VehicleStopStatus { + INCOMING_AT = 0; + STOPPED_AT = 1; + IN_TRANSIT_TO = 2; + } + optional VehicleStopStatus current_status = 4 [default = IN_TRANSIT_TO]; + optional uint64 timestamp = 5; + enum CongestionLevel { + UNKNOWN_CONGESTION_LEVEL = 0; + RUNNING_SMOOTHLY = 1; + STOP_AND_GO = 2; + CONGESTION = 3; + SEVERE_CONGESTION = 4; + } + optional CongestionLevel congestion_level = 6; + enum OccupancyStatus { + EMPTY = 0; + MANY_SEATS_AVAILABLE = 1; + FEW_SEATS_AVAILABLE = 2; + STANDING_ROOM_ONLY = 3; + CRUSHED_STANDING_ROOM_ONLY = 4; + FULL = 5; + NOT_ACCEPTING_PASSENGERS = 6; + } + optional OccupancyStatus occupancy_status = 9; +} + +message Alert { + repeated TimeRange active_period = 1; + repeated EntitySelector informed_entity = 5; + + enum Cause { + UNKNOWN_CAUSE = 1; + OTHER_CAUSE = 2; + TECHNICAL_PROBLEM = 3; + STRIKE = 4; + DEMONSTRATION = 5; + ACCIDENT = 6; + HOLIDAY = 7; + WEATHER = 8; + MAINTENANCE = 9; + CONSTRUCTION = 10; + POLICE_ACTIVITY = 11; + MEDICAL_EMERGENCY = 12; + } + optional Cause cause = 6 [default = UNKNOWN_CAUSE]; + + enum Effect { + NO_SERVICE = 1; + REDUCED_SERVICE = 2; + SIGNIFICANT_DELAYS = 3; + DETOUR = 4; + ADDITIONAL_SERVICE = 5; + MODIFIED_SERVICE = 6; + OTHER_EFFECT = 7; + UNKNOWN_EFFECT = 8; + STOP_MOVED = 9; + NO_EFFECT = 10; + ACCESSIBILITY_ISSUE = 11; + } + optional Effect effect = 7 [default = UNKNOWN_EFFECT]; + optional TranslatedString url = 8; + optional TranslatedString header_text = 10; + optional TranslatedString description_text = 11; +} + +message TimeRange { + optional uint64 start = 1; + optional uint64 end = 2; +} + +message Position { + required float latitude = 1; + required float longitude = 2; + optional float bearing = 3; + optional double odometer = 4; + optional float speed = 5; +} + +message TripDescriptor { + optional string trip_id = 1; + optional string route_id = 5; + optional uint32 direction_id = 6; + optional string start_time = 2; + optional string start_date = 3; + enum ScheduleRelationship { + SCHEDULED = 0; + ADDED = 1; + UNSCHEDULED = 2; + CANCELED = 3; + } + optional ScheduleRelationship schedule_relationship = 4; +} + +message VehicleDescriptor { + optional string id = 1; + optional string label = 2; + optional string license_plate = 3; +} + +message EntitySelector { + optional string agency_id = 1; + optional string route_id = 2; + optional int32 route_type = 3; + optional TripDescriptor trip = 4; + optional string stop_id = 5; +} + +message TranslatedString { + message Translation { + required string text = 1; + optional string language = 2; + } + repeated Translation translation = 1; +} +`; + +// Fetch protobuf data from URL or local file +// Uses fetch API (available in modern browsers and Node.js 18+) +async function fetchProtobuf(source: string): Promise { + const isUrl = source.startsWith('http://') || source.startsWith('https://'); + + if (isUrl) { + // Fetch from URL + const response = await fetch(source, { + headers: { + 'Accept': 'application/x-protobuf, application/octet-stream' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch GTFS-RT feed from ${source}: ${response.status} ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + return new Uint8Array(arrayBuffer); + } + + // Read from local file (Node.js only) + const isNode = typeof process !== 'undefined' && + process.versions != null && + process.versions.node != null; + + if (isNode) { + try { + const fs = await import('fs'); + const buffer = await fs.promises.readFile(source); + return new Uint8Array(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)); + } catch (error) { + throw new Error(`Failed to read GTFS-RT file from ${source}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // In browser, try fetch for relative paths + const response = await fetch(source, { + headers: { + 'Accept': 'application/x-protobuf, application/octet-stream' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch GTFS-RT feed from ${source}: ${response.status} ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + return new Uint8Array(arrayBuffer); +} + +// Load and parse GTFS-RT protobuf schema +let gtfsRtRoot: protobuf.Root | null = null; + +function loadGtfsRtProto(): protobuf.Root { + if (!gtfsRtRoot) { + gtfsRtRoot = protobuf.parse(GTFS_RT_PROTO).root; + } + return gtfsRtRoot; +} + +// Convert camelCase object keys to snake_case +function camelToSnake(str: string): string { + return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +function convertKeysToSnakeCase(obj: any): any { + if (obj === null || obj === undefined) { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(convertKeysToSnakeCase); + } + if (typeof obj === 'object') { + const result: any = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const snakeKey = camelToSnake(key); + result[snakeKey] = convertKeysToSnakeCase(obj[key]); + } + } + return result; + } + return obj; +} + +// Parse TranslatedString to JSON +function parseTranslatedString(ts: any): string | null { + if (!ts || !ts.translation || ts.translation.length === 0) { + return null; + } + return JSON.stringify({ + translation: ts.translation.map((t: any) => ({ + text: t.text, + language: t.language || undefined + })) + }); +} + +// Insert alerts into database +function insertAlerts(db: Database, alerts: any[], timestamp: number): void { + const stmt = db.prepare(` + INSERT OR REPLACE INTO rt_alerts ( + id, active_period, informed_entity, cause, effect, + url, header_text, description_text, rt_last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const alert of alerts) { + // Map camelCase protobuf fields to snake_case + const activePeriod = alert.activePeriod || alert.active_period || []; + const informedEntity = alert.informedEntity || alert.informed_entity || []; + const headerText = alert.headerText || alert.header_text; + const descriptionText = alert.descriptionText || alert.description_text; + + // Convert nested objects to snake_case + const activePeriodSnake = convertKeysToSnakeCase(activePeriod); + const informedEntitySnake = convertKeysToSnakeCase(informedEntity); + + stmt.run([ + alert.id, + JSON.stringify(activePeriodSnake), + JSON.stringify(informedEntitySnake), + alert.cause || null, + alert.effect || null, + parseTranslatedString(alert.url), + parseTranslatedString(headerText), + parseTranslatedString(descriptionText), + timestamp + ]); + } + + stmt.free(); +} + +// Insert vehicle positions into database +function insertVehiclePositions(db: Database, positions: any[], 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, + latitude, longitude, bearing, odometer, speed, + current_stop_sequence, stop_id, current_status, timestamp, + congestion_level, occupancy_status, rt_last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const vp of positions) { + const trip = vp.trip; + if (!trip || !(trip.tripId || trip.trip_id)) continue; + + // Map camelCase protobuf fields to snake_case + const tripId = trip.tripId || trip.trip_id; + const routeId = trip.routeId || trip.route_id; + const vehicleId = vp.vehicle?.id; + const vehicleLabel = vp.vehicle?.label; + const vehicleLicensePlate = vp.vehicle?.licensePlate || vp.vehicle?.license_plate; + const currentStopSequence = vp.currentStopSequence || vp.current_stop_sequence; + const stopId = vp.stopId || vp.stop_id; + const currentStatus = vp.currentStatus || vp.current_status; + const congestionLevel = vp.congestionLevel || vp.congestion_level; + const occupancyStatus = vp.occupancyStatus || vp.occupancy_status; + + stmt.run([ + tripId, + routeId || null, + vehicleId || null, + vehicleLabel || null, + vehicleLicensePlate || null, + vp.position?.latitude || null, + vp.position?.longitude || null, + vp.position?.bearing || null, + vp.position?.odometer || null, + vp.position?.speed || null, + currentStopSequence || null, + stopId || null, + currentStatus || null, + vp.timestamp || null, + congestionLevel || null, + occupancyStatus || null, + timestamp + ]); + } + + stmt.free(); +} + +// Insert trip updates into database +function insertTripUpdates(db: Database, updates: any[], 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, + timestamp, delay, schedule_relationship, rt_last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const stopTimeStmt = db.prepare(` + INSERT OR REPLACE INTO rt_stop_time_updates ( + trip_id, stop_sequence, stop_id, + arrival_delay, arrival_time, arrival_uncertainty, + departure_delay, departure_time, departure_uncertainty, + schedule_relationship, rt_last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const tu of updates) { + const trip = tu.trip; + if (!trip || !(trip.tripId || trip.trip_id)) continue; + + // Map camelCase protobuf fields to snake_case + const tripId = trip.tripId || trip.trip_id; + const routeId = trip.routeId || trip.route_id; + const vehicleId = tu.vehicle?.id; + const vehicleLabel = tu.vehicle?.label; + const vehicleLicensePlate = tu.vehicle?.licensePlate || tu.vehicle?.license_plate; + const scheduleRelationship = trip.scheduleRelationship || trip.schedule_relationship; + const stopTimeUpdate = tu.stopTimeUpdate || tu.stop_time_update; + + // Insert trip update + tripStmt.run([ + tripId, + routeId || null, + vehicleId || null, + vehicleLabel || null, + vehicleLicensePlate || null, + tu.timestamp || null, + tu.delay || null, + scheduleRelationship || null, + timestamp + ]); + + // Insert stop time updates + if (stopTimeUpdate) { + for (const stu of stopTimeUpdate) { + const stopSequence = stu.stopSequence || stu.stop_sequence; + const stopId = stu.stopId || stu.stop_id; + const scheduleRel = stu.scheduleRelationship || stu.schedule_relationship; + + stopTimeStmt.run([ + tripId, + stopSequence || null, + stopId || null, + stu.arrival?.delay || null, + stu.arrival?.time || null, + stu.arrival?.uncertainty || null, + stu.departure?.delay || null, + stu.departure?.time || null, + stu.departure?.uncertainty || null, + scheduleRel || null, + timestamp + ]); + } + } + } + + tripStmt.free(); + stopTimeStmt.free(); +} + +/** + * Fetch and load GTFS Realtime data from multiple feed URLs + * All feeds are fetched in parallel. If any feed fails, an error is thrown. + */ +export async function loadRealtimeData(db: Database, feedUrls: string[]): Promise { + const root = loadGtfsRtProto(); + const FeedMessage = root.lookupType('transit_realtime.FeedMessage'); + + // Fetch all feeds in parallel + const fetchPromises = feedUrls.map(async (url) => { + try { + const data = await fetchProtobuf(url); + const message = FeedMessage.decode(data); + return FeedMessage.toObject(message, { + longs: Number, + enums: Number, + bytes: String, + defaults: false, + arrays: true, + objects: true, + oneofs: true + }); + } catch (error) { + throw new Error(`Failed to fetch or parse GTFS-RT feed from ${url}: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + // Wait for all feeds - if any fails, this will throw + const feeds = await Promise.all(fetchPromises); + + // Current timestamp for staleness tracking + const now = Math.floor(Date.now() / 1000); + + // Collect all entities by type + const allAlerts: any[] = []; + const allVehiclePositions: any[] = []; + const allTripUpdates: any[] = []; + + for (const feed of feeds) { + if (!feed.entity) continue; + + for (const entity of feed.entity) { + if (entity.alert) { + allAlerts.push({ id: entity.id, ...entity.alert }); + } + if (entity.vehicle) { + allVehiclePositions.push(entity.vehicle); + } + if (entity.tripUpdate) { + allTripUpdates.push(entity.tripUpdate); + } + } + } + + // Insert into database + if (allAlerts.length > 0) { + insertAlerts(db, allAlerts, now); + } + if (allVehiclePositions.length > 0) { + insertVehiclePositions(db, allVehiclePositions, now); + } + if (allTripUpdates.length > 0) { + insertTripUpdates(db, allTripUpdates, now); + } +} diff --git a/src/queries/rt-alerts.ts b/src/queries/rt-alerts.ts new file mode 100644 index 0000000..fbe43de --- /dev/null +++ b/src/queries/rt-alerts.ts @@ -0,0 +1,164 @@ +import type { Database } from 'sql.js'; +import type { Alert, EntitySelector, TimeRange, TranslatedString } from '../types/gtfs-rt'; +import type { AlertFilters } from '../types/gtfs-rt'; + +export type { AlertFilters }; + +/** + * Parse JSON fields from database + */ +function parseAlert(row: Record): Alert { + return { + id: String(row.id), + active_period: row.active_period ? JSON.parse(String(row.active_period)) as TimeRange[] : [], + informed_entity: row.informed_entity ? JSON.parse(String(row.informed_entity)) as EntitySelector[] : [], + cause: row.cause ? Number(row.cause) : undefined, + effect: row.effect ? Number(row.effect) : undefined, + url: row.url ? JSON.parse(String(row.url)) as TranslatedString : undefined, + header_text: row.header_text ? JSON.parse(String(row.header_text)) as TranslatedString : undefined, + description_text: row.description_text ? JSON.parse(String(row.description_text)) as TranslatedString : undefined, + rt_last_updated: Number(row.rt_last_updated) + }; +} + +/** + * Check if an alert is currently active based on its active_period + */ +function isAlertActive(alert: Alert, now: number): boolean { + // If no active periods defined, assume always active + if (!alert.active_period || alert.active_period.length === 0) { + return true; + } + + // Check if current time falls within any active period + for (const period of alert.active_period) { + const start = period.start || 0; + const end = period.end || Number.MAX_SAFE_INTEGER; + + if (now >= start && now <= end) { + return true; + } + } + + return false; +} + +/** + * Check if an alert affects the specified entity + */ +function alertAffectsEntity(alert: Alert, filters: AlertFilters): boolean { + if (!alert.informed_entity || alert.informed_entity.length === 0) { + return true; // No specific entities, assume it affects all + } + + for (const entity of alert.informed_entity) { + // Check route_id + if (filters.routeId && entity.route_id === filters.routeId) { + return true; + } + + // Check stop_id + if (filters.stopId && entity.stop_id === filters.stopId) { + return true; + } + + // Check trip_id + if (filters.tripId && entity.trip?.trip_id === filters.tripId) { + return true; + } + } + + return false; +} + +/** + * Get alerts with optional filters + */ +export function getAlerts(db: Database, filters: AlertFilters = {}, stalenessThreshold: number = 120): Alert[] { + const { + alertId, + activeOnly, + routeId, + stopId, + tripId, + cause, + effect, + limit + } = filters; + + const conditions: string[] = []; + const params: (string | number)[] = []; + + // Filter by alert ID + if (alertId) { + conditions.push('id = ?'); + params.push(alertId); + } + + // Filter by cause + if (cause !== undefined) { + conditions.push('cause = ?'); + params.push(cause); + } + + // Filter by effect + if (effect !== undefined) { + conditions.push('effect = ?'); + params.push(effect); + } + + // Staleness filter (always applied) + const now = Math.floor(Date.now() / 1000); + const staleThreshold = now - stalenessThreshold; + conditions.push('rt_last_updated >= ?'); + params.push(staleThreshold); + + // Build query + let sql = 'SELECT * FROM rt_alerts'; + if (conditions.length > 0) { + sql += ' WHERE ' + conditions.join(' AND '); + } + sql += ' ORDER BY rt_last_updated DESC'; + + if (limit) { + sql += ' LIMIT ?'; + params.push(limit); + } + + // Execute query + const stmt = db.prepare(sql); + if (params.length > 0) { + stmt.bind(params); + } + + const alerts: Alert[] = []; + while (stmt.step()) { + const row = stmt.getAsObject() as Record; + const alert = parseAlert(row); + + // Apply activeOnly filter in application code + if (activeOnly && !isAlertActive(alert, now)) { + continue; + } + + // Apply entity filters in application code (since informed_entity is JSON) + if (routeId || stopId || tripId) { + if (!alertAffectsEntity(alert, filters)) { + continue; + } + } + + alerts.push(alert); + } + + stmt.free(); + return alerts; +} + +/** + * Get alert by ID + */ +export function getAlertById(db: Database, alertId: string, stalenessThreshold: number = 120): Alert | null { + const alerts = getAlerts(db, { alertId, limit: 1 }, stalenessThreshold); + return alerts.length > 0 ? alerts[0] : null; +} diff --git a/src/queries/rt-vehicle-positions.ts b/src/queries/rt-vehicle-positions.ts new file mode 100644 index 0000000..3099c48 --- /dev/null +++ b/src/queries/rt-vehicle-positions.ts @@ -0,0 +1,135 @@ +import type { Database} from 'sql.js'; +import type { VehiclePosition, Position, VehicleDescriptor } from '../types/gtfs-rt'; +import type { VehiclePositionFilters } from '../types/gtfs-rt'; + +export type { VehiclePositionFilters }; + +/** + * Parse vehicle position from database row + */ +function parseVehiclePosition(row: Record): VehiclePosition { + const vp: VehiclePosition = { + trip_id: String(row.trip_id), + route_id: row.route_id ? String(row.route_id) : undefined, + rt_last_updated: Number(row.rt_last_updated) + }; + + // Vehicle descriptor + if (row.vehicle_id || row.vehicle_label || row.vehicle_license_plate) { + vp.vehicle = { + id: row.vehicle_id ? String(row.vehicle_id) : undefined, + label: row.vehicle_label ? String(row.vehicle_label) : undefined, + license_plate: row.vehicle_license_plate ? String(row.vehicle_license_plate) : undefined + }; + } + + // Position + if (row.latitude !== null && row.longitude !== null) { + vp.position = { + latitude: Number(row.latitude), + longitude: Number(row.longitude), + bearing: row.bearing !== null ? Number(row.bearing) : undefined, + odometer: row.odometer !== null ? Number(row.odometer) : undefined, + speed: row.speed !== null ? Number(row.speed) : undefined + }; + } + + // Other fields + if (row.current_stop_sequence !== null) { + vp.current_stop_sequence = Number(row.current_stop_sequence); + } + if (row.stop_id) { + vp.stop_id = String(row.stop_id); + } + if (row.current_status !== null) { + vp.current_status = Number(row.current_status); + } + if (row.timestamp !== null) { + vp.timestamp = Number(row.timestamp); + } + if (row.congestion_level !== null) { + vp.congestion_level = Number(row.congestion_level); + } + if (row.occupancy_status !== null) { + vp.occupancy_status = Number(row.occupancy_status); + } + + return vp; +} + +/** + * Get vehicle positions with optional filters + */ +export function getVehiclePositions( + db: Database, + filters: VehiclePositionFilters = {}, + stalenessThreshold: number = 120 +): VehiclePosition[] { + const { tripId, routeId, vehicleId, limit } = filters; + + const conditions: string[] = []; + const params: (string | number)[] = []; + + // Filter by trip_id + if (tripId) { + conditions.push('trip_id = ?'); + params.push(tripId); + } + + // Filter by route_id + if (routeId) { + conditions.push('route_id = ?'); + params.push(routeId); + } + + // Filter by vehicle_id + if (vehicleId) { + conditions.push('vehicle_id = ?'); + params.push(vehicleId); + } + + // Staleness filter (always applied) + const now = Math.floor(Date.now() / 1000); + const staleThreshold = now - stalenessThreshold; + conditions.push('rt_last_updated >= ?'); + params.push(staleThreshold); + + // Build query + let sql = 'SELECT * FROM rt_vehicle_positions'; + if (conditions.length > 0) { + sql += ' WHERE ' + conditions.join(' AND '); + } + sql += ' ORDER BY rt_last_updated DESC'; + + if (limit) { + sql += ' LIMIT ?'; + params.push(limit); + } + + // Execute query + const stmt = db.prepare(sql); + if (params.length > 0) { + stmt.bind(params); + } + + const positions: VehiclePosition[] = []; + while (stmt.step()) { + const row = stmt.getAsObject() as Record; + positions.push(parseVehiclePosition(row)); + } + + stmt.free(); + return positions; +} + +/** + * Get vehicle position by trip ID + */ +export function getVehiclePositionByTripId( + db: Database, + tripId: string, + stalenessThreshold: number = 120 +): VehiclePosition | null { + const positions = getVehiclePositions(db, { tripId, limit: 1 }, stalenessThreshold); + return positions.length > 0 ? positions[0] : null; +} diff --git a/src/queries/stop-times.ts b/src/queries/stop-times.ts index f1a76f0..61dd9a9 100644 --- a/src/queries/stop-times.ts +++ b/src/queries/stop-times.ts @@ -4,6 +4,7 @@ import type { Database } from 'sql.js'; import type { StopTime } from '../types/gtfs'; +import type { StopTimeRealtime } from '../types/gtfs-rt'; export interface StopTimeFilters { tripId?: string; @@ -12,14 +13,73 @@ export interface StopTimeFilters { serviceIds?: string[]; directionId?: number; agencyId?: string; + includeRealtime?: boolean; limit?: number; } +export interface StopTimeWithRealtime extends StopTime { + realtime?: StopTimeRealtime; +} + +/** + * Merge realtime data with stop times + */ +function mergeRealtimeData( + stopTimes: StopTime[], + db: Database, + stalenessThreshold: number +): StopTimeWithRealtime[] { + const now = Math.floor(Date.now() / 1000); + const staleThreshold = now - stalenessThreshold; + + // Build map of trip_id -> stop times for this query + const tripIds = Array.from(new Set(stopTimes.map(st => st.trip_id))); + if (tripIds.length === 0) return stopTimes; + + const placeholders = tripIds.map(() => '?').join(', '); + const stmt = db.prepare(` + SELECT trip_id, stop_sequence, stop_id, + arrival_delay, departure_delay, schedule_relationship + FROM rt_stop_time_updates + WHERE trip_id IN (${placeholders}) + AND rt_last_updated >= ? + `); + stmt.bind([...tripIds, staleThreshold]); + + // Build map of trip_id+stop_sequence -> RT data + const rtMap = new Map(); + while (stmt.step()) { + const row = stmt.getAsObject() as Record; + const key = `${row.trip_id}_${row.stop_sequence}`; + rtMap.set(key, { + arrival_delay: row.arrival_delay !== null ? Number(row.arrival_delay) : undefined, + departure_delay: row.departure_delay !== null ? Number(row.departure_delay) : undefined, + schedule_relationship: row.schedule_relationship !== null ? Number(row.schedule_relationship) : undefined + }); + } + stmt.free(); + + // Merge RT data with stop times + return stopTimes.map((st): StopTimeWithRealtime => { + const key = `${st.trip_id}_${st.stop_sequence}`; + const rtData = rtMap.get(key); + + if (rtData) { + return { ...st, realtime: rtData }; + } + return st; + }); +} + /** * Get stop times with optional filters */ -export function getStopTimes(db: Database, filters: StopTimeFilters = {}): StopTime[] { - const { tripId, stopId, routeId, serviceIds, directionId, agencyId, limit } = filters; +export function getStopTimes( + db: Database, + filters: StopTimeFilters = {}, + stalenessThreshold: number = 120 +): StopTime[] | StopTimeWithRealtime[] { + const { tripId, stopId, routeId, serviceIds, directionId, agencyId, includeRealtime, limit } = filters; // Determine if we need to join with trips and/or routes table const needsTripsJoin = routeId || serviceIds || directionId !== undefined || agencyId !== undefined; @@ -94,6 +154,12 @@ export function getStopTimes(db: Database, filters: StopTimeFilters = {}): StopT } stmt.free(); + + // Merge realtime data if requested + if (includeRealtime) { + return mergeRealtimeData(stopTimes, db, stalenessThreshold); + } + return stopTimes; } diff --git a/src/queries/trips.ts b/src/queries/trips.ts index d3c539b..8dd2c93 100644 --- a/src/queries/trips.ts +++ b/src/queries/trips.ts @@ -4,6 +4,8 @@ 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; @@ -11,14 +13,131 @@ export interface TripFilters { serviceIds?: string[]; directionId?: number; agencyId?: string; + includeRealtime?: boolean; limit?: number; } +export interface TripWithRealtime extends Trip { + realtime?: TripRealtime; +} + +/** + * Merge realtime data with trips + */ +function mergeRealtimeData( + trips: Trip[], + db: Database, + stalenessThreshold: number +): TripWithRealtime[] { + const now = Math.floor(Date.now() / 1000); + const staleThreshold = now - stalenessThreshold; + + const tripIds = trips.map(t => t.trip_id); + if (tripIds.length === 0) return trips; + + const placeholders = tripIds.map(() => '?').join(', '); + + // Get vehicle positions + const vpStmt = db.prepare(` + SELECT * FROM rt_vehicle_positions + WHERE trip_id IN (${placeholders}) + AND rt_last_updated >= ? + `); + vpStmt.bind([...tripIds, staleThreshold]); + + const vpMap = new Map(); + while (vpStmt.step()) { + const row = vpStmt.getAsObject() as Record; + vpMap.set(String(row.trip_id), row); + } + vpStmt.free(); + + // Get trip updates + const tuStmt = db.prepare(` + SELECT * FROM rt_trip_updates + WHERE trip_id IN (${placeholders}) + AND rt_last_updated >= ? + `); + tuStmt.bind([...tripIds, staleThreshold]); + + const tuMap = new Map(); + while (tuStmt.step()) { + const row = tuStmt.getAsObject() as Record; + tuMap.set(String(row.trip_id), row); + } + tuStmt.free(); + + // Merge realtime data + return trips.map((trip): TripWithRealtime => { + const vpRow = vpMap.get(trip.trip_id); + const tuRow = tuMap.get(trip.trip_id); + + if (!vpRow && !tuRow) { + return { ...trip, realtime: { vehicle_position: null, trip_update: null } }; + } + + const realtime: TripRealtime = { + vehicle_position: null, + trip_update: null + }; + + // Parse vehicle position + if (vpRow) { + const vp: VehiclePosition = { + trip_id: String(vpRow.trip_id), + route_id: vpRow.route_id ? String(vpRow.route_id) : undefined, + rt_last_updated: Number(vpRow.rt_last_updated) + }; + + if (vpRow.vehicle_id || vpRow.vehicle_label || vpRow.vehicle_license_plate) { + vp.vehicle = { + id: vpRow.vehicle_id ? String(vpRow.vehicle_id) : undefined, + label: vpRow.vehicle_label ? String(vpRow.vehicle_label) : undefined, + license_plate: vpRow.vehicle_license_plate ? String(vpRow.vehicle_license_plate) : undefined + }; + } + + if (vpRow.latitude !== null && vpRow.longitude !== null) { + vp.position = { + latitude: Number(vpRow.latitude), + longitude: Number(vpRow.longitude), + bearing: vpRow.bearing !== null ? Number(vpRow.bearing) : undefined, + odometer: vpRow.odometer !== null ? Number(vpRow.odometer) : undefined, + speed: vpRow.speed !== null ? Number(vpRow.speed) : undefined + }; + } + + if (vpRow.current_stop_sequence !== null) vp.current_stop_sequence = Number(vpRow.current_stop_sequence); + if (vpRow.stop_id) vp.stop_id = String(vpRow.stop_id); + if (vpRow.current_status !== null) vp.current_status = Number(vpRow.current_status); + if (vpRow.timestamp !== null) vp.timestamp = Number(vpRow.timestamp); + if (vpRow.congestion_level !== null) vp.congestion_level = Number(vpRow.congestion_level); + if (vpRow.occupancy_status !== null) vp.occupancy_status = Number(vpRow.occupancy_status); + + realtime.vehicle_position = vp; + } + + // Parse trip update + if (tuRow) { + realtime.trip_update = { + delay: tuRow.delay !== null ? Number(tuRow.delay) : undefined, + schedule_relationship: tuRow.schedule_relationship !== null ? Number(tuRow.schedule_relationship) : undefined + }; + } + + return { ...trip, realtime }; + }); +} + /** * Get trips with optional filters */ -export function getTrips(db: Database, filters: TripFilters = {}): Trip[] { - const { tripId, routeId, serviceIds, directionId, agencyId, limit } = filters; +export function getTrips( + db: Database, + filters: TripFilters = {}, + stalenessThreshold: number = 120 +): Trip[] | TripWithRealtime[] { + const { tripId, routeId, serviceIds, directionId, agencyId, includeRealtime, limit } = filters; // Determine if we need to join with routes table const needsRoutesJoin = agencyId !== undefined; @@ -78,6 +197,12 @@ export function getTrips(db: Database, filters: TripFilters = {}): Trip[] { } stmt.free(); + + // Merge realtime data if requested + if (includeRealtime) { + return mergeRealtimeData(trips, db, stalenessThreshold); + } + return trips; } diff --git a/src/schema/gtfs-rt-schema.ts b/src/schema/gtfs-rt-schema.ts new file mode 100644 index 0000000..0251e0b --- /dev/null +++ b/src/schema/gtfs-rt-schema.ts @@ -0,0 +1,100 @@ +import type { Database } from 'sql.js'; + +/** + * Create GTFS Realtime tables in the database + */ +export function createRealtimeTables(db: Database): void { + // Alerts table + db.run(` + CREATE TABLE IF NOT EXISTS rt_alerts ( + id TEXT PRIMARY KEY, + active_period TEXT, -- JSON array of TimeRange objects + informed_entity TEXT, -- JSON array of EntitySelector objects + cause INTEGER, + effect INTEGER, + url TEXT, -- JSON TranslatedString + header_text TEXT, -- JSON TranslatedString + description_text TEXT, -- JSON TranslatedString + rt_last_updated INTEGER NOT NULL + ) + `); + + // Create index on rt_last_updated for staleness filtering + db.run('CREATE INDEX IF NOT EXISTS idx_rt_alerts_updated ON rt_alerts(rt_last_updated)'); + + // Vehicle Positions table + db.run(` + CREATE TABLE IF NOT EXISTS rt_vehicle_positions ( + trip_id TEXT PRIMARY KEY, + route_id TEXT, + vehicle_id TEXT, + vehicle_label TEXT, + vehicle_license_plate TEXT, + latitude REAL, + longitude REAL, + bearing REAL, + odometer REAL, + speed REAL, + current_stop_sequence INTEGER, + stop_id TEXT, + current_status INTEGER, + timestamp INTEGER, + congestion_level INTEGER, + occupancy_status INTEGER, + rt_last_updated INTEGER NOT NULL + ) + `); + + db.run('CREATE INDEX IF NOT EXISTS idx_rt_vehicle_positions_updated ON rt_vehicle_positions(rt_last_updated)'); + db.run('CREATE INDEX IF NOT EXISTS idx_rt_vehicle_positions_route ON rt_vehicle_positions(route_id)'); + + // Trip Updates table + db.run(` + CREATE TABLE IF NOT EXISTS rt_trip_updates ( + trip_id TEXT PRIMARY KEY, + route_id TEXT, + vehicle_id TEXT, + vehicle_label TEXT, + vehicle_license_plate TEXT, + timestamp INTEGER, + delay INTEGER, + schedule_relationship INTEGER, + rt_last_updated INTEGER NOT NULL + ) + `); + + db.run('CREATE INDEX IF NOT EXISTS idx_rt_trip_updates_updated ON rt_trip_updates(rt_last_updated)'); + db.run('CREATE INDEX IF NOT EXISTS idx_rt_trip_updates_route ON rt_trip_updates(route_id)'); + + // Stop Time Updates table (child of trip updates) + db.run(` + CREATE TABLE IF NOT EXISTS rt_stop_time_updates ( + trip_id TEXT NOT NULL, + stop_sequence INTEGER, + stop_id TEXT, + arrival_delay INTEGER, + arrival_time INTEGER, + arrival_uncertainty INTEGER, + departure_delay INTEGER, + departure_time INTEGER, + departure_uncertainty INTEGER, + schedule_relationship INTEGER, + rt_last_updated INTEGER NOT NULL, + PRIMARY KEY (trip_id, stop_sequence), + FOREIGN KEY (trip_id) REFERENCES rt_trip_updates(trip_id) ON DELETE CASCADE + ) + `); + + db.run('CREATE INDEX IF NOT EXISTS idx_rt_stop_time_updates_updated ON rt_stop_time_updates(rt_last_updated)'); + db.run('CREATE INDEX IF NOT EXISTS idx_rt_stop_time_updates_stop ON rt_stop_time_updates(stop_id)'); +} + +/** + * Clear all realtime data from the database + */ +export function clearRealtimeData(db: Database): void { + db.run('DELETE FROM rt_alerts'); + db.run('DELETE FROM rt_vehicle_positions'); + db.run('DELETE FROM rt_trip_updates'); + db.run('DELETE FROM rt_stop_time_updates'); +} diff --git a/src/types/gtfs-rt.ts b/src/types/gtfs-rt.ts new file mode 100644 index 0000000..c5a1a01 --- /dev/null +++ b/src/types/gtfs-rt.ts @@ -0,0 +1,205 @@ +/** + * GTFS Realtime TypeScript types + * Based on GTFS Realtime v2.0 specification + */ + +// Enum types from GTFS-RT specification + +export enum ScheduleRelationship { + SCHEDULED = 0, + ADDED = 1, + UNSCHEDULED = 2, + CANCELED = 3, + SKIPPED = 4, + NO_DATA = 5 +} + +export enum VehicleStopStatus { + INCOMING_AT = 0, + STOPPED_AT = 1, + IN_TRANSIT_TO = 2 +} + +export enum CongestionLevel { + UNKNOWN_CONGESTION_LEVEL = 0, + RUNNING_SMOOTHLY = 1, + STOP_AND_GO = 2, + CONGESTION = 3, + SEVERE_CONGESTION = 4 +} + +export enum OccupancyStatus { + EMPTY = 0, + MANY_SEATS_AVAILABLE = 1, + FEW_SEATS_AVAILABLE = 2, + STANDING_ROOM_ONLY = 3, + CRUSHED_STANDING_ROOM_ONLY = 4, + FULL = 5, + NOT_ACCEPTING_PASSENGERS = 6 +} + +export enum AlertCause { + UNKNOWN_CAUSE = 1, + OTHER_CAUSE = 2, + TECHNICAL_PROBLEM = 3, + STRIKE = 4, + DEMONSTRATION = 5, + ACCIDENT = 6, + HOLIDAY = 7, + WEATHER = 8, + MAINTENANCE = 9, + CONSTRUCTION = 10, + POLICE_ACTIVITY = 11, + MEDICAL_EMERGENCY = 12 +} + +export enum AlertEffect { + NO_SERVICE = 1, + REDUCED_SERVICE = 2, + SIGNIFICANT_DELAYS = 3, + DETOUR = 4, + ADDITIONAL_SERVICE = 5, + MODIFIED_SERVICE = 6, + OTHER_EFFECT = 7, + UNKNOWN_EFFECT = 8, + STOP_MOVED = 9, + NO_EFFECT = 10, + ACCESSIBILITY_ISSUE = 11 +} + +// Main GTFS-RT types + +export interface TranslatedString { + translation: Array<{ + text: string; + language?: string; + }>; +} + +export interface EntitySelector { + agency_id?: string; + route_id?: string; + route_type?: number; + trip?: { + trip_id?: string; + route_id?: string; + direction_id?: number; + start_time?: string; + start_date?: string; + schedule_relationship?: ScheduleRelationship; + }; + stop_id?: string; +} + +export interface TimeRange { + start?: number; // UNIX timestamp + end?: number; // UNIX timestamp +} + +export interface Alert { + id: string; + active_period: TimeRange[]; + informed_entity: EntitySelector[]; + cause?: AlertCause; + effect?: AlertEffect; + url?: TranslatedString; + header_text?: TranslatedString; + description_text?: TranslatedString; + rt_last_updated: number; // UNIX timestamp +} + +export interface Position { + latitude: number; + longitude: number; + bearing?: number; + odometer?: number; + speed?: number; +} + +export interface VehicleDescriptor { + id?: string; + label?: string; + license_plate?: string; +} + +export interface VehiclePosition { + trip_id: string; + route_id?: string; + vehicle?: VehicleDescriptor; + position?: Position; + current_stop_sequence?: number; + stop_id?: string; + current_status?: VehicleStopStatus; + timestamp?: number; + congestion_level?: CongestionLevel; + occupancy_status?: OccupancyStatus; + rt_last_updated: number; // UNIX timestamp +} + +export interface StopTimeEvent { + delay?: number; // seconds + time?: number; // UNIX timestamp (absolute time) + uncertainty?: number; +} + +export interface StopTimeUpdate { + stop_sequence?: number; + stop_id?: string; + arrival?: StopTimeEvent; + departure?: StopTimeEvent; + schedule_relationship?: ScheduleRelationship; +} + +export interface TripUpdate { + trip_id: string; + route_id?: string; + vehicle?: VehicleDescriptor; + stop_time_update: StopTimeUpdate[]; + timestamp?: number; + delay?: number; + schedule_relationship?: ScheduleRelationship; + rt_last_updated: number; // UNIX timestamp +} + +// Enhanced types for queries with realtime data + +export interface StopTimeRealtime { + arrival_delay?: number; // seconds + departure_delay?: number; // seconds + schedule_relationship?: ScheduleRelationship; +} + +export interface TripRealtime { + vehicle_position?: VehiclePosition | null; + trip_update?: { + delay?: number; + schedule_relationship?: ScheduleRelationship; + } | null; +} + +// Filter types for realtime queries + +export interface AlertFilters { + alertId?: string; + activeOnly?: boolean; + routeId?: string; + stopId?: string; + tripId?: string; + cause?: AlertCause; + effect?: AlertEffect; + limit?: number; +} + +export interface VehiclePositionFilters { + tripId?: string; + routeId?: string; + vehicleId?: string; + limit?: number; +} + +// Configuration + +export interface RealtimeConfig { + feedUrls?: string[]; + stalenessThreshold?: number; // seconds, default 120 +} diff --git a/test-realtime-local-file.ts b/test-realtime-local-file.ts new file mode 100644 index 0000000..48476a7 --- /dev/null +++ b/test-realtime-local-file.ts @@ -0,0 +1,222 @@ +/** + * Test script for GTFS Realtime with local Car Jaune RT file + */ + +import { GtfsSqlJs } from './src/gtfs-sqljs'; + +async function testRealtimeLocalFile() { + console.log('๐Ÿš€ Testing GTFS-RT with local Car Jaune RT file...\n'); + + try { + // Load GTFS static data from local file + console.log('๐Ÿ“ฆ Loading GTFS static data from local file...'); + const gtfs = await GtfsSqlJs.fromZip('./website/public/car-jaune.zip', { + skipFiles: ['shapes.txt'], + stalenessThreshold: 120 + }); + console.log('โœ… GTFS static data loaded\n'); + + // Show some basic GTFS info + const agencies = gtfs.getAgencies(); + console.log(`๐Ÿ“ Agencies: ${agencies.length}`); + agencies.forEach(agency => { + console.log(` - ${agency.agency_name} (${agency.agency_id})`); + }); + + const routes = gtfs.getRoutes({ limit: 5 }); + console.log(`\n๐ŸšŒ Routes: ${gtfs.getRoutes().length} total (showing first 5):`); + routes.forEach(route => { + console.log(` - ${route.route_short_name}: ${route.route_long_name}`); + }); + + // Load realtime data from local file + console.log('\n\n๐Ÿ“ก Loading GTFS-RT data from local file: ./car-jaune-rt.pb'); + await gtfs.fetchRealtimeData(['./car-jaune-rt.pb']); + console.log('โœ… GTFS-RT data loaded\n'); + + // Check alerts + console.log('๐Ÿšจ Checking alerts...'); + const allAlerts = gtfs.getAlerts(); + console.log(` Total alerts: ${allAlerts.length}`); + + const activeAlerts = gtfs.getAlerts({ activeOnly: true }); + console.log(` Active alerts: ${activeAlerts.length}`); + + if (allAlerts.length > 0) { + console.log('\n๐Ÿ“‹ Alert Details:'); + allAlerts.forEach((alert, idx) => { + console.log(`\n Alert #${idx + 1} (ID: ${alert.id})`); + + if (alert.header_text) { + try { + const header = typeof alert.header_text === 'string' + ? JSON.parse(alert.header_text) + : alert.header_text; + console.log(` Header: ${header.translation[0]?.text || 'N/A'}`); + } catch (e) { + console.log(` Header: [Unable to parse]`); + } + } + + if (alert.description_text) { + try { + const desc = typeof alert.description_text === 'string' + ? JSON.parse(alert.description_text) + : alert.description_text; + const text = desc.translation[0]?.text || 'N/A'; + // Truncate long descriptions + const truncated = text.length > 150 ? text.substring(0, 150) + '...' : text; + console.log(` Description: ${truncated}`); + } catch (e) { + console.log(` Description: [Unable to parse]`); + } + } + + console.log(` Cause: ${alert.cause || 'N/A'}`); + console.log(` Effect: ${alert.effect || 'N/A'}`); + + if (alert.informed_entity && alert.informed_entity.length > 0) { + console.log(` Affected entities: ${alert.informed_entity.length}`); + const routes = alert.informed_entity.filter(e => e.route_id).slice(0, 5); + if (routes.length > 0) { + console.log(` Routes: ${routes.map(e => e.route_id).join(', ')}${alert.informed_entity.filter(e => e.route_id).length > 5 ? ' ...' : ''}`); + } + const stops = alert.informed_entity.filter(e => e.stop_id).slice(0, 5); + if (stops.length > 0) { + console.log(` Stops: ${stops.map(e => e.stop_id).join(', ')}${alert.informed_entity.filter(e => e.stop_id).length > 5 ? ' ...' : ''}`); + } + } + + if (alert.active_period && alert.active_period.length > 0) { + console.log(` Active periods: ${alert.active_period.length}`); + alert.active_period.slice(0, 2).forEach(period => { + const start = period.start ? new Date(period.start * 1000).toISOString() : 'N/A'; + const end = period.end ? new Date(period.end * 1000).toISOString() : 'N/A'; + console.log(` - ${start} to ${end}`); + }); + } + + console.log(` Last updated: ${new Date(alert.rt_last_updated * 1000).toISOString()}`); + }); + } + + // Check vehicle positions + console.log('\n\n๐Ÿš— Checking vehicle positions...'); + const vehiclePositions = gtfs.getVehiclePositions(); + console.log(` Total vehicle positions: ${vehiclePositions.length}`); + + if (vehiclePositions.length > 0) { + console.log('\n๐Ÿ“ Vehicle Position Details (first 5):'); + vehiclePositions.slice(0, 5).forEach((vp, idx) => { + console.log(`\n Vehicle #${idx + 1}`); + console.log(` Trip ID: ${vp.trip_id}`); + console.log(` Route ID: ${vp.route_id || 'N/A'}`); + if (vp.vehicle?.id) console.log(` Vehicle ID: ${vp.vehicle.id}`); + if (vp.vehicle?.label) console.log(` Vehicle Label: ${vp.vehicle.label}`); + if (vp.position) { + console.log(` Position: ${vp.position.latitude.toFixed(6)}, ${vp.position.longitude.toFixed(6)}`); + if (vp.position.speed !== undefined) console.log(` Speed: ${vp.position.speed.toFixed(1)} m/s`); + if (vp.position.bearing !== undefined) console.log(` Bearing: ${vp.position.bearing.toFixed(0)}ยฐ`); + } + if (vp.current_stop_sequence) console.log(` Current stop sequence: ${vp.current_stop_sequence}`); + if (vp.stop_id) console.log(` Stop ID: ${vp.stop_id}`); + if (vp.timestamp) console.log(` Timestamp: ${new Date(vp.timestamp * 1000).toISOString()}`); + console.log(` Last updated: ${new Date(vp.rt_last_updated * 1000).toISOString()}`); + }); + } + + // Check trips with realtime data + console.log('\n\n๐Ÿ• Checking trips with realtime data...'); + const tripsWithRT = gtfs.getTrips({ limit: 20, includeRealtime: true }); + console.log(` Checked ${tripsWithRT.length} trips for realtime data`); + + const tripsWithData = tripsWithRT.filter((t: any) => + t.realtime?.vehicle_position !== null || t.realtime?.trip_update !== null + ); + console.log(` Trips with RT data: ${tripsWithData.length}`); + + if (tripsWithData.length > 0) { + console.log('\n๐Ÿ“Š Trip Realtime Details (first 3):'); + tripsWithData.slice(0, 3).forEach((trip: any, idx: number) => { + console.log(`\n Trip #${idx + 1} (ID: ${trip.trip_id})`); + console.log(` Route: ${trip.route_id}`); + console.log(` Headsign: ${trip.trip_headsign || 'N/A'}`); + + if (trip.realtime?.vehicle_position) { + const vp = trip.realtime.vehicle_position; + console.log(` โœ“ Has vehicle position`); + if (vp.position) { + console.log(` Position: ${vp.position.latitude.toFixed(6)}, ${vp.position.longitude.toFixed(6)}`); + } + if (vp.vehicle?.id) { + console.log(` Vehicle ID: ${vp.vehicle.id}`); + } + } + + if (trip.realtime?.trip_update) { + const tu = trip.realtime.trip_update; + console.log(` โœ“ Has trip update`); + if (tu.delay !== undefined) { + console.log(` Delay: ${tu.delay} seconds`); + } + if (tu.schedule_relationship !== undefined) { + console.log(` Schedule relationship: ${tu.schedule_relationship}`); + } + } + }); + } + + // Check stop times with realtime + console.log('\n\nโฐ Checking stop times with realtime...'); + if (tripsWithData.length > 0) { + const sampleTrip = tripsWithData[0]; + const stopTimes = gtfs.getStopTimes({ + tripId: sampleTrip.trip_id, + includeRealtime: true, + limit: 10 + }); + + console.log(` Stop times for trip ${sampleTrip.trip_id}: ${stopTimes.length}`); + + const stopTimesWithRT = stopTimes.filter((st: any) => st.realtime); + console.log(` Stop times with RT data: ${stopTimesWithRT.length}`); + + if (stopTimesWithRT.length > 0) { + console.log('\n Stop Time RT Details (first 5):'); + stopTimesWithRT.slice(0, 5).forEach((st: any) => { + const stop = gtfs.getStopById(st.stop_id); + console.log(`\n Stop: ${stop?.stop_name || st.stop_id}`); + console.log(` Sequence: ${st.stop_sequence}`); + console.log(` Scheduled arrival: ${st.arrival_time}`); + if (st.realtime?.arrival_delay !== undefined) { + console.log(` Arrival delay: ${st.realtime.arrival_delay} seconds`); + } + if (st.realtime?.departure_delay !== undefined) { + console.log(` Departure delay: ${st.realtime.departure_delay} seconds`); + } + if (st.realtime?.schedule_relationship !== undefined) { + console.log(` Schedule relationship: ${st.realtime.schedule_relationship}`); + } + }); + } + } + + console.log('\n\nโœ… Test completed successfully!'); + console.log('\nSummary:'); + console.log(` - Alerts: ${allAlerts.length} total, ${activeAlerts.length} active`); + console.log(` - Vehicle Positions: ${vehiclePositions.length}`); + console.log(` - Trips with RT data: ${tripsWithData.length}`); + + // Cleanup + gtfs.close(); + + } catch (error) { + console.error('\nโŒ Error during test:', error); + if (error instanceof Error) { + console.error('Stack:', error.stack); + } + process.exit(1); + } +} + +testRealtimeLocalFile(); diff --git a/test-realtime-local.ts b/test-realtime-local.ts new file mode 100644 index 0000000..c2a377b --- /dev/null +++ b/test-realtime-local.ts @@ -0,0 +1,230 @@ +/** + * Test script for GTFS Realtime API with local Car Jaune data + * Demonstrates RT functionality and API structure + */ + +import { GtfsSqlJs, AlertCause, AlertEffect } from './src/gtfs-sqljs'; + +async function testRealtimeLocal() { + console.log('๐Ÿš€ Testing GTFS-RT API with Car Jaune data...\n'); + + try { + // Load GTFS static data from local file + console.log('๐Ÿ“ฆ Loading GTFS static data from local file...'); + const gtfs = await GtfsSqlJs.fromZip('./website/public/car-jaune.zip', { + skipFiles: ['shapes.txt'], + stalenessThreshold: 120 + }); + console.log('โœ… GTFS static data loaded\n'); + + // Show some basic GTFS info + const agencies = gtfs.getAgencies(); + console.log(`๐Ÿ“ Agencies: ${agencies.length}`); + agencies.forEach(agency => { + console.log(` - ${agency.agency_name} (${agency.agency_id})`); + }); + + const routes = gtfs.getRoutes({ limit: 5 }); + console.log(`\n๐ŸšŒ Sample Routes (showing 5 of many):`); + routes.forEach(route => { + console.log(` - ${route.route_short_name}: ${route.route_long_name}`); + }); + + const stops = gtfs.getStops({ limit: 5 }); + console.log(`\n๐Ÿ›‘ Sample Stops (showing 5 of many):`); + stops.forEach(stop => { + console.log(` - ${stop.stop_name}`); + }); + + // Demonstrate RT configuration + console.log('\n\n๐Ÿ“ก GTFS-RT Configuration:'); + console.log('-----------------------------------'); + + // Set RT feed URLs + gtfs.setRealtimeFeedUrls([ + 'https://pysae.com/api/v2/groups/car-jaune/gtfs-rt' + ]); + console.log('โœ“ RT feed URLs configured'); + console.log(` URLs: ${gtfs.getRealtimeFeedUrls().join(', ')}`); + + // Check staleness threshold + console.log(`โœ“ Staleness threshold: ${gtfs.getStalenessThreshold()} seconds`); + + // Demonstrate RT API (without fetching real data) + console.log('\n\n๐Ÿ”ง GTFS-RT API Methods Available:'); + console.log('-----------------------------------'); + + console.log('\n1. fetchRealtimeData(urls?)'); + console.log(' - Fetches and loads RT data from configured URLs'); + console.log(' - Example: await gtfs.fetchRealtimeData()'); + console.log(' - Or: await gtfs.fetchRealtimeData(["https://custom-url"])'); + + console.log('\n2. getAlerts(filters?)'); + console.log(' - Get service alerts with optional filtering'); + console.log(' - Filters: activeOnly, routeId, stopId, tripId, cause, effect, limit'); + console.log(' - Example: gtfs.getAlerts({ activeOnly: true, routeId: "1" })'); + + console.log('\n3. getAlertById(alertId)'); + console.log(' - Get specific alert by ID'); + console.log(' - Example: gtfs.getAlertById("alert_123")'); + + console.log('\n4. getVehiclePositions(filters?)'); + console.log(' - Get vehicle positions with optional filtering'); + console.log(' - Filters: tripId, routeId, vehicleId, limit'); + console.log(' - Example: gtfs.getVehiclePositions({ routeId: "1" })'); + + console.log('\n5. getVehiclePositionByTripId(tripId)'); + console.log(' - Get vehicle position for specific trip'); + console.log(' - Example: gtfs.getVehiclePositionByTripId("trip_123")'); + + console.log('\n6. getTrips({ includeRealtime: true })'); + console.log(' - Get trips with realtime data merged'); + console.log(' - Returns vehicle position and trip update data'); + console.log(' - Example: gtfs.getTrips({ routeId: "1", includeRealtime: true })'); + + console.log('\n7. getStopTimes({ includeRealtime: true })'); + console.log(' - Get stop times with realtime delays merged'); + console.log(' - Returns nested realtime object with delays'); + console.log(' - Example: gtfs.getStopTimes({ tripId: "trip_123", includeRealtime: true })'); + + console.log('\n8. clearRealtimeData()'); + console.log(' - Clear all realtime data from database'); + + // Demonstrate data structure + console.log('\n\n๐Ÿ“Š GTFS-RT Data Structures:'); + console.log('-----------------------------------'); + + console.log('\nAlert structure:'); + console.log(`{ + id: string, + active_period: [{ start?: number, end?: number }], + informed_entity: [{ route_id?, stop_id?, trip?: {...} }], + cause?: AlertCause (enum), + effect?: AlertEffect (enum), + header_text?: TranslatedString, + description_text?: TranslatedString, + rt_last_updated: number +}`); + + console.log('\nVehiclePosition structure:'); + console.log(`{ + trip_id: string, + route_id?: string, + vehicle?: { id?, label?, license_plate? }, + position?: { latitude, longitude, bearing?, speed? }, + current_stop_sequence?: number, + stop_id?: string, + current_status?: VehicleStopStatus, + timestamp?: number, + rt_last_updated: number +}`); + + console.log('\nTripWithRealtime structure:'); + console.log(`{ + ...Trip (all static trip fields), + realtime?: { + vehicle_position: VehiclePosition | null, + trip_update: { + delay?: number, + schedule_relationship?: ScheduleRelationship + } | null + } +}`); + + console.log('\nStopTimeWithRealtime structure:'); + console.log(`{ + ...StopTime (all static stop time fields), + realtime?: { + arrival_delay?: number, + departure_delay?: number, + schedule_relationship?: ScheduleRelationship + } +}`); + + // Test with empty RT data (no fetch) + console.log('\n\n๐Ÿงช Testing RT Queries (no data yet):'); + console.log('-----------------------------------'); + + const alerts = gtfs.getAlerts(); + console.log(`โœ“ getAlerts(): ${alerts.length} alerts (expected 0 without fetch)`); + + const vehiclePositions = gtfs.getVehiclePositions(); + console.log(`โœ“ getVehiclePositions(): ${vehiclePositions.length} positions (expected 0 without fetch)`); + + const tripsWithRT = gtfs.getTrips({ limit: 3, includeRealtime: true }) as any[]; + console.log(`โœ“ getTrips({ includeRealtime: true }): ${tripsWithRT.length} trips`); + console.log(` First trip realtime structure: ${JSON.stringify(tripsWithRT[0]?.realtime || {})}`); + + const stopTimesWithRT = gtfs.getStopTimes({ tripId: tripsWithRT[0]?.trip_id, limit: 3, includeRealtime: true }) as any[]; + console.log(`โœ“ getStopTimes({ includeRealtime: true }): ${stopTimesWithRT.length} stop times`); + console.log(` First stop time realtime structure: ${JSON.stringify(stopTimesWithRT[0]?.realtime || 'undefined')}`); + + // Show how to use in production + console.log('\n\n๐Ÿ“š Production Usage Example:'); + console.log('-----------------------------------'); + console.log(` +// Initialize with RT feed URLs +const gtfs = await GtfsSqlJs.fromZip('gtfs.zip', { + realtimeFeedUrls: ['https://pysae.com/api/v2/groups/car-jaune/gtfs-rt'], + stalenessThreshold: 120 +}); + +// Fetch RT data (call periodically, e.g., every 30 seconds) +await gtfs.fetchRealtimeData(); + +// Get active alerts +const alerts = gtfs.getAlerts({ activeOnly: true }); +alerts.forEach(alert => { + console.log(\`Alert: \${alert.header_text}\`); + console.log(\`Affected routes: \${alert.informed_entity.filter(e => e.route_id).length}\`); +}); + +// Get trips with vehicle positions +const trips = gtfs.getTrips({ + routeId: '1', + date: '20250105', + includeRealtime: true +}); + +trips.forEach(trip => { + if (trip.realtime?.vehicle_position?.position) { + const pos = trip.realtime.vehicle_position.position; + console.log(\`Trip \${trip.trip_id} at \${pos.latitude}, \${pos.longitude}\`); + } + if (trip.realtime?.trip_update?.delay) { + console.log(\` Delayed by \${trip.realtime.trip_update.delay} seconds\`); + } +}); + +// Get stop times with realtime delays +const stopTimes = gtfs.getStopTimes({ + stopId: 'STOP_123', + routeId: '1', + date: '20250105', + includeRealtime: true +}); + +stopTimes.forEach(st => { + const delay = st.realtime?.arrival_delay || 0; + console.log(\`\${st.arrival_time} (delay: \${delay}s)\`); +}); +`); + + console.log('\nโœ… GTFS-RT API test completed successfully!'); + console.log('\nNote: To test with real data, use the URLs:'); + console.log(' GTFS: https://pysae.com/api/v2/groups/car-jaune/gtfs/pub'); + console.log(' GTFS-RT: https://pysae.com/api/v2/groups/car-jaune/gtfs-rt'); + + // Cleanup + gtfs.close(); + + } catch (error) { + console.error('\nโŒ Error during test:', error); + if (error instanceof Error) { + console.error('Stack:', error.stack); + } + process.exit(1); + } +} + +testRealtimeLocal(); diff --git a/test-realtime.ts b/test-realtime.ts new file mode 100644 index 0000000..f839723 --- /dev/null +++ b/test-realtime.ts @@ -0,0 +1,190 @@ +/** + * Test script for GTFS Realtime with Car Jaune data + */ + +import { GtfsSqlJs } from './src/gtfs-sqljs'; + +async function testRealtime() { + console.log('๐Ÿš€ Testing GTFS-RT with Car Jaune data...\n'); + + try { + // Load GTFS static data + console.log('๐Ÿ“ฆ Loading GTFS static data...'); + const gtfs = await GtfsSqlJs.fromZip('https://pysae.com/api/v2/groups/car-jaune/gtfs/pub', { + skipFiles: ['shapes.txt'], + realtimeFeedUrls: ['https://pysae.com/api/v2/groups/car-jaune/gtfs-rt'], + stalenessThreshold: 120 + }); + console.log('โœ… GTFS static data loaded\n'); + + // Show some basic GTFS info + const agencies = gtfs.getAgencies(); + console.log(`๐Ÿ“ Agencies: ${agencies.length}`); + agencies.forEach(agency => { + console.log(` - ${agency.agency_name}`); + }); + + const routes = gtfs.getRoutes(); + console.log(`๐ŸšŒ Routes: ${routes.length}`); + + const stops = gtfs.getStops(); + console.log(`๐Ÿ›‘ Stops: ${stops.length}\n`); + + // Fetch realtime data + console.log('๐Ÿ“ก Fetching GTFS-RT data from: https://pysae.com/api/v2/groups/car-jaune/gtfs-rt'); + await gtfs.fetchRealtimeData(); + console.log('โœ… GTFS-RT data fetched\n'); + + // Check alerts + console.log('๐Ÿšจ Checking alerts...'); + const allAlerts = gtfs.getAlerts(); + console.log(` Total alerts: ${allAlerts.length}`); + + const activeAlerts = gtfs.getAlerts({ activeOnly: true }); + console.log(` Active alerts: ${activeAlerts.length}`); + + if (activeAlerts.length > 0) { + console.log('\n๐Ÿ“‹ Active Alert Details:'); + activeAlerts.forEach((alert, idx) => { + console.log(`\n Alert #${idx + 1} (ID: ${alert.id})`); + + if (alert.header_text) { + const header = JSON.parse(alert.header_text as any); + console.log(` Header: ${header.translation[0]?.text || 'N/A'}`); + } + + if (alert.description_text) { + const desc = JSON.parse(alert.description_text as any); + console.log(` Description: ${desc.translation[0]?.text || 'N/A'}`); + } + + console.log(` Cause: ${alert.cause || 'N/A'}`); + console.log(` Effect: ${alert.effect || 'N/A'}`); + + if (alert.informed_entity && alert.informed_entity.length > 0) { + console.log(` Affected entities: ${alert.informed_entity.length}`); + alert.informed_entity.slice(0, 3).forEach(entity => { + if (entity.route_id) console.log(` - Route: ${entity.route_id}`); + if (entity.stop_id) console.log(` - Stop: ${entity.stop_id}`); + if (entity.trip?.trip_id) console.log(` - Trip: ${entity.trip.trip_id}`); + }); + } + + if (alert.active_period && alert.active_period.length > 0) { + console.log(` Active periods: ${alert.active_period.length}`); + alert.active_period.slice(0, 2).forEach(period => { + const start = period.start ? new Date(period.start * 1000).toISOString() : 'N/A'; + const end = period.end ? new Date(period.end * 1000).toISOString() : 'N/A'; + console.log(` - ${start} to ${end}`); + }); + } + }); + } + + // Check vehicle positions + console.log('\n\n๐Ÿš— Checking vehicle positions...'); + const vehiclePositions = gtfs.getVehiclePositions(); + console.log(` Total vehicle positions: ${vehiclePositions.length}`); + + if (vehiclePositions.length > 0) { + console.log('\n๐Ÿ“ Vehicle Position Details (first 5):'); + vehiclePositions.slice(0, 5).forEach((vp, idx) => { + console.log(`\n Vehicle #${idx + 1}`); + console.log(` Trip ID: ${vp.trip_id}`); + console.log(` Route ID: ${vp.route_id || 'N/A'}`); + if (vp.vehicle?.id) console.log(` Vehicle ID: ${vp.vehicle.id}`); + if (vp.position) { + console.log(` Position: ${vp.position.latitude.toFixed(6)}, ${vp.position.longitude.toFixed(6)}`); + if (vp.position.speed) console.log(` Speed: ${vp.position.speed.toFixed(1)} m/s`); + if (vp.position.bearing) console.log(` Bearing: ${vp.position.bearing.toFixed(0)}ยฐ`); + } + if (vp.current_stop_sequence) console.log(` Current stop sequence: ${vp.current_stop_sequence}`); + if (vp.timestamp) console.log(` Timestamp: ${new Date(vp.timestamp * 1000).toISOString()}`); + }); + } + + // Check trip updates + console.log('\n\n๐Ÿ• Checking trip updates...'); + // Get a sample trip with realtime data + const tripsWithRT = gtfs.getTrips({ limit: 10, includeRealtime: true }); + console.log(` Checked ${tripsWithRT.length} trips for realtime data`); + + const tripsWithData = tripsWithRT.filter((t: any) => + t.realtime?.vehicle_position !== null || t.realtime?.trip_update !== null + ); + console.log(` Trips with RT data: ${tripsWithData.length}`); + + if (tripsWithData.length > 0) { + console.log('\n๐Ÿ“Š Trip Realtime Details (first 3):'); + tripsWithData.slice(0, 3).forEach((trip: any, idx: number) => { + console.log(`\n Trip #${idx + 1} (ID: ${trip.trip_id})`); + console.log(` Route: ${trip.route_id}`); + console.log(` Headsign: ${trip.trip_headsign || 'N/A'}`); + + if (trip.realtime?.vehicle_position) { + const vp = trip.realtime.vehicle_position; + console.log(` โœ“ Has vehicle position`); + if (vp.position) { + console.log(` Position: ${vp.position.latitude.toFixed(6)}, ${vp.position.longitude.toFixed(6)}`); + } + } + + if (trip.realtime?.trip_update) { + const tu = trip.realtime.trip_update; + console.log(` โœ“ Has trip update`); + if (tu.delay !== undefined) { + console.log(` Delay: ${tu.delay} seconds`); + } + } + }); + } + + // Check stop times with realtime + console.log('\n\nโฐ Checking stop times with realtime...'); + if (tripsWithData.length > 0) { + const sampleTrip = tripsWithData[0]; + const stopTimes = gtfs.getStopTimes({ + tripId: sampleTrip.trip_id, + includeRealtime: true, + limit: 5 + }); + + console.log(` Stop times for trip ${sampleTrip.trip_id}: ${stopTimes.length}`); + + const stopTimesWithRT = stopTimes.filter((st: any) => st.realtime); + console.log(` Stop times with RT data: ${stopTimesWithRT.length}`); + + if (stopTimesWithRT.length > 0) { + console.log('\n Stop Time RT Details:'); + stopTimesWithRT.slice(0, 3).forEach((st: any) => { + const stop = gtfs.getStopById(st.stop_id); + console.log(`\n Stop: ${stop?.stop_name || st.stop_id}`); + console.log(` Scheduled arrival: ${st.arrival_time}`); + if (st.realtime?.arrival_delay) { + console.log(` Arrival delay: ${st.realtime.arrival_delay} seconds`); + } + if (st.realtime?.departure_delay) { + console.log(` Departure delay: ${st.realtime.departure_delay} seconds`); + } + if (st.realtime?.schedule_relationship !== undefined) { + console.log(` Schedule relationship: ${st.realtime.schedule_relationship}`); + } + }); + } + } + + console.log('\n\nโœ… Test completed successfully!'); + + // Cleanup + gtfs.close(); + + } catch (error) { + console.error('\nโŒ Error during test:', error); + if (error instanceof Error) { + console.error('Stack:', error.stack); + } + process.exit(1); + } +} + +testRealtime();