Saving GTFS-RT data to Parquet

This commit is contained in:
2026-03-13 00:01:00 +01:00
parent afd195dab9
commit c90be4a981
18 changed files with 930 additions and 6310 deletions

View File

@@ -1,6 +1,18 @@
# Database Configuration (TimescaleDB)
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=iot_data
POSTGRES_USER=postgres
POSTGRES_PASSWORD=example
# Parquet storage
PARQUET_DIR=./data
PARQUET_ROLL_MINUTES=5
SAVE_ALL_VEHICLE_SNAPSHOTS=true
SAVE_ALL_VEHICLE_POSITIONS=true
# Object storage (S3-compatible) upload
S3_ENABLED=true
S3_BUCKET=test-bucket
S3_REGION=us-east-1
S3_ENDPOINT=http://localhost:2600
S3_PREFIX=parquet
S3_FORCE_PATH_STYLE=true
S3_DELETE_LOCAL_AFTER_UPLOAD=true
S3_UPLOAD_RETRIES=3
S3_UPLOAD_RETRY_BASE_MS=1000
S3_ACCESS_KEY_ID=test
S3_SECRET_ACCESS_KEY=test123!

4
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules/
.env
dist/
dist/
data/*.parquet
*.log

View File

@@ -1,871 +0,0 @@
# GTFS API Documentation
Comprehensive API documentation for the Skopje Bus Tracker GTFS-based API.
## Table of Contents
- [Overview](#overview)
- [OpenAPI Specification](#openapi-specification)
- [Base URL](#base-url)
- [GTFS Static Data Endpoints](#gtfs-static-data-endpoints)
- [Real-Time Data Endpoints](#real-time-data-endpoints)
- [Historical Data Endpoints](#historical-data-endpoints)
- [Statistics & Analytics Endpoints](#statistics--analytics-endpoints)
- [Data Models](#data-models)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
---
## Overview
This API provides access to Skopje public transport data based on the **GTFS (General Transit Feed Specification)** standard. It combines static GTFS data with real-time information and historical analytics stored in TimescaleDB.
### Key Features
- **GTFS-compliant** static data (stops, routes)
- **Real-time** vehicle positions and arrival predictions
- **Historical** time-series data for analytics
- **Statistical** analysis of delays and patterns
### Upstream API
This application consumes data from the **ModeShift GTFS API**. For documentation on the upstream API endpoints:
- See **[UPSTREAM-API-DOCUMENTATION.md](UPSTREAM-API-DOCUMENTATION.md)**
---
## OpenAPI Specification
A complete **OpenAPI 3.0** specification is available at [openapi.yaml](openapi.yaml). This machine-readable specification includes:
- All API endpoints with parameters and responses
- Request/response schemas and data models
- Examples for all operations
- Error response formats
### Using the OpenAPI Spec
**Interactive Documentation (Local):**
Once the server is running, visit:
```
http://localhost:3000/api-docs.html
```
This provides an interactive Swagger UI where you can:
- Browse all API endpoints
- View request/response schemas
- Try out API calls directly from the browser
- Download the OpenAPI specification
**Interactive Documentation with Swagger UI (Alternative):**
```bash
# Using Docker
docker run -p 8080:8080 -e SWAGGER_JSON=/openapi.yaml -v $(pwd)/openapi.yaml:/openapi.yaml swaggerapi/swagger-ui
# Or use online editor
# Visit: https://editor.swagger.io/
# Then paste the contents of openapi.yaml
```
**Generate Client Libraries:**
```bash
# Install OpenAPI Generator
npm install -g @openapitools/openapi-generator-cli
# Generate TypeScript client
openapi-generator-cli generate -i openapi.yaml -g typescript-fetch -o ./client/typescript
# Generate Python client
openapi-generator-cli generate -i openapi.yaml -g python -o ./client/python
# Generate Go client
openapi-generator-cli generate -i openapi.yaml -g go -o ./client/go
```
**VS Code Integration:**
Install the "OpenAPI (Swagger) Editor" extension to view and edit the spec with validation and IntelliSense.
---
## Base URL
```
http://localhost:3000
```
For production, replace with your deployed server URL.
---
## GTFS Static Data Endpoints
### Get Configuration
Retrieve configuration including default stop and route information.
**Endpoint:** `GET /api/config`
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `stopId` | string | No | Specific stop ID to query (defaults to configured default) |
| `routeId` | string | No | Specific route ID to query (defaults to configured default) |
**Response:**
```json
{
"stop": {
"id": "1563",
"code": "363",
"name": "KARPOS II - STIV NAUMOV",
"lat": 41.97964,
"lon": 21.41283
},
"route": {
"id": "125",
"shortName": "7",
"longName": "Centar - GTC - Karpos 4 - Karposh 2"
},
"defaults": {
"stopId": "1563",
"routeId": "125"
}
}
```
**Example:**
```bash
curl "http://localhost:3000/api/config?stopId=1571&routeId=125"
```
---
### Get All Stops
Retrieve all GTFS stops in the system.
**Endpoint:** `GET /api/stops`
**Query Parameters:** None
**Response:**
```json
[
{
"id": "1563",
"code": "363",
"name": "KARPOS II - STIV NAUMOV",
"lat": 41.97964,
"lon": 21.41283
},
{
"id": "1571",
"code": "371",
"name": "AMERICAN COLLEGE - VASIL GLAVINOV",
"lat": 42.00437,
"lon": 21.39687
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/stops"
```
**Use Cases:**
- Populate stop selection dropdowns
- Build stop search functionality
- Generate maps with all stops
---
### Get All Routes
Retrieve all GTFS routes in the system.
**Endpoint:** `GET /api/routes`
**Query Parameters:** None
**Response:**
```json
[
{
"id": "125",
"shortName": "7",
"longName": "Centar - GTC - Karpos 4 - Karposh 2"
},
{
"id": "128",
"shortName": "2",
"longName": "Kisela Voda - Centar - 11 Oktomvri - Avtokomanda"
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/routes"
```
**Use Cases:**
- Display available routes
- Filter by route number
- Route selection interfaces
---
## Real-Time Data Endpoints
### Get Real-Time Arrivals
Get upcoming bus arrivals at a specific stop with real-time predictions.
**Endpoint:** `GET /api/arrivals`
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `stopId` | string | No | Stop ID to query (defaults to configured default) |
| `routeId` | string | No | Filter by specific route ID (defaults to configured default) |
**Response:**
```json
[
{
"arrivalTime": "2026-02-07T14:35:00.000Z",
"scheduledTime": "2026-02-07T14:33:00.000Z",
"minutesUntil": 8,
"delaySeconds": 120,
"headsign": "Karpos 2",
"isRealtime": true,
"realtimeState": "UPDATED"
},
{
"arrivalTime": "2026-02-07T14:48:00.000Z",
"scheduledTime": "2026-02-07T14:48:00.000Z",
"minutesUntil": 21,
"delaySeconds": 0,
"headsign": "Centar",
"isRealtime": true,
"realtimeState": "SCHEDULED"
}
]
```
**Fields:**
- `arrivalTime`: Predicted arrival time (ISO 8601)
- `scheduledTime`: Originally scheduled time (ISO 8601)
- `minutesUntil`: Minutes until arrival (can be negative if bus passed)
- `delaySeconds`: Delay in seconds (positive = late, negative = early)
- `headsign`: Destination shown on bus
- `isRealtime`: Whether prediction is based on real-time data
- `realtimeState`: `SCHEDULED`, `UPDATED`, or `CANCELED`
**Example:**
```bash
curl "http://localhost:3000/api/arrivals?stopId=1563&routeId=125"
```
**Notes:**
- Returns arrivals from 2 minutes ago up to configured minutes ahead
- Results are sorted by arrival time
- Data is also logged to database for historical analysis
---
### Get Real-Time Vehicle Positions
Get current positions of all vehicles on a specific route.
**Endpoint:** `GET /api/vehicles`
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `routeId` | string | No | Route ID to filter vehicles (defaults to configured default) |
**Response:**
```json
[
{
"id": 12345,
"vehicleId": "MK-SK-1234",
"label": "1234",
"lat": 41.9981,
"lon": 21.4254,
"bearing": 87.5,
"speed": 15.3,
"timestamp": "2026-02-07T14:27:15Z",
"tripId": "trip_789",
"currentStopSequence": 0,
"currentStatus": "IN_TRANSIT_TO"
}
]
```
**Fields:**
- `id`: Internal vehicle database ID
- `vehicleId`: Vehicle identification number (license plate)
- `label`: Bus inventory number (shown on bus)
- `lat`/`lon`: Current GPS coordinates
- `bearing`: Direction of travel (0-360 degrees, 0=North)
- `speed`: Current speed in km/h
- `timestamp`: Last position update time
- `tripId`: GTFS trip ID vehicle is currently serving
- `currentStatus`: `IN_TRANSIT_TO`, `STOPPED_AT`, or `UNKNOWN`
**Example:**
```bash
curl "http://localhost:3000/api/vehicles?routeId=125"
```
**Notes:**
- Combines GTFS-RT trip updates with JSON vehicle API
- Vehicle positions are logged to database
- Updates typically every 10-30 seconds from source
---
## Historical Data Endpoints
### Get Database Statistics
Get overview statistics about stored historical data.
**Endpoint:** `GET /api/stats/db`
**Query Parameters:** None
**Response:**
```json
{
"vehiclePositions": {
"totalRecords": 1523847,
"oldestRecord": "2026-01-15T08:00:00.000Z",
"newestRecord": "2026-02-07T14:27:30.000Z",
"dataRetentionDays": 90
},
"arrivalRecords": {
"totalRecords": 456231,
"oldestRecord": "2026-01-15T08:00:00.000Z",
"newestRecord": "2026-02-07T14:27:30.000Z",
"dataRetentionDays": 90
},
"databaseSize": "2.3 GB",
"status": "healthy"
}
```
**Example:**
```bash
curl "http://localhost:3000/api/stats/db"
```
---
### Get Vehicle Position History
Retrieve historical GPS positions for a specific vehicle.
**Endpoint:** `GET /api/history/vehicle/:vehicleId`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `vehicleId` | string | Yes | Vehicle identification number or label |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `hours` | integer | No | 24 | Number of hours to look back |
**Response:**
```json
[
{
"time": "2026-02-07T10:00:00.000Z",
"vehicleId": "MK-SK-1234",
"routeId": "125",
"tripId": "trip_789",
"latitude": 41.9981,
"longitude": 21.4254,
"speed": 15.3,
"bearing": 87.5,
"currentStatus": "IN_TRANSIT_TO"
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/history/vehicle/1234?hours=12"
```
**Use Cases:**
- Visualize vehicle path on map
- Analyze driving patterns
- Calculate actual travel times
---
### Get Route Vehicle History
Get historical positions of all vehicles that served a specific route.
**Endpoint:** `GET /api/history/route/:routeId/vehicles`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `routeId` | string | Yes | GTFS route ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `hours` | integer | No | 24 | Number of hours to look back |
**Response:**
```json
[
{
"time": "2026-02-07T10:00:00.000Z",
"vehicleId": "1234",
"routeId": "125",
"tripId": "trip_789",
"latitude": 41.9981,
"longitude": 21.4254,
"speed": 15.3,
"bearing": 87.5
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/history/route/125/vehicles?hours=6"
```
**Use Cases:**
- Service frequency analysis
- Route coverage visualization
- Fleet utilization tracking
---
### Get Stop Arrival History
Retrieve historical arrival records for a specific stop.
**Endpoint:** `GET /api/history/stop/:stopId/arrivals`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `stopId` | string | Yes | GTFS stop ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `routeId` | string | No | (all) | Filter by specific route |
| `hours` | integer | No | 24 | Number of hours to look back |
**Response:**
```json
[
{
"time": "2026-02-07T10:15:00.000Z",
"stopId": "1563",
"routeId": "125",
"scheduledTime": "2026-02-07T10:15:00.000Z",
"predictedTime": "2026-02-07T10:17:30.000Z",
"delaySeconds": 150,
"isRealtime": true,
"headsign": "Karpos 2"
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/history/stop/1563/arrivals?routeId=125&hours=48"
```
---
## Statistics & Analytics Endpoints
### Get Route Delay Statistics
Get delay statistics and performance metrics for a route.
**Endpoint:** `GET /api/stats/route/:routeId/delays`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `routeId` | string | Yes | GTFS route ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `hours` | integer | No | 24 | Time window for analysis |
**Response:**
```json
{
"routeId": "125",
"periodHours": 24,
"statistics": {
"totalArrivals": 287,
"avgDelaySeconds": 62,
"medianDelaySeconds": 45,
"minDelaySeconds": -30,
"maxDelaySeconds": 420,
"stdDeviation": 78.5,
"onTimePercentage": 68.3,
"earlyPercentage": 8.7,
"latePercentage": 23.0,
"veryLatePercentage": 4.2
},
"thresholds": {
"onTime": "±5 minutes",
"veryLate": ">10 minutes"
}
}
```
**Example:**
```bash
curl "http://localhost:3000/api/stats/route/125/delays?hours=72"
```
**Definitions:**
- **On-time**: Within ±5 minutes of schedule
- **Early**: More than 5 minutes early
- **Late**: 5-10 minutes late
- **Very Late**: More than 10 minutes late
---
### Get Stop Delay Statistics
Get delay statistics for a specific stop.
**Endpoint:** `GET /api/stats/stop/:stopId/delays`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `stopId` | string | Yes | GTFS stop ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `hours` | integer | No | 24 | Time window for analysis |
**Response:**
```json
{
"stopId": "1563",
"periodHours": 24,
"statistics": {
"totalArrivals": 145,
"avgDelaySeconds": 72,
"medianDelaySeconds": 60,
"onTimePercentage": 62.1,
"routeBreakdown": [
{
"routeId": "125",
"routeName": "7",
"arrivals": 89,
"avgDelaySeconds": 65
},
{
"routeId": "128",
"routeName": "2",
"arrivals": 56,
"avgDelaySeconds": 82
}
]
}
}
```
**Example:**
```bash
curl "http://localhost:3000/api/stats/stop/1563/delays?hours=48"
```
**Use Cases:**
- Compare reliability across stops
- Identify problematic locations
- Plan transfer reliability
---
### Get Route Hourly Pattern
Analyze delay patterns by hour of day for a route.
**Endpoint:** `GET /api/stats/route/:routeId/hourly`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `routeId` | string | Yes | GTFS route ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `days` | integer | No | 7 | Number of days to analyze |
**Response:**
```json
{
"routeId": "125",
"periodDays": 7,
"hourlyPattern": [
{
"hour": 6,
"arrivals": 42,
"avgDelaySeconds": 15,
"onTimePercentage": 85.7
},
{
"hour": 7,
"arrivals": 89,
"avgDelaySeconds": 45,
"onTimePercentage": 71.9
},
{
"hour": 8,
"arrivals": 124,
"avgDelaySeconds": 125,
"onTimePercentage": 52.4
}
]
}
```
**Example:**
```bash
curl "http://localhost:3000/api/stats/route/125/hourly?days=14"
```
**Use Cases:**
- Identify rush hour patterns
- Compare morning vs evening reliability
- Optimize schedules based on actual performance
---
## Data Models
### GTFS Stop
```typescript
interface GtfsStop {
stop_id: string; // Unique stop identifier
stop_code: string; // Public-facing stop code
stop_name: string; // Stop name
stop_lat: number; // Latitude (WGS84)
stop_lon: number; // Longitude (WGS84)
}
```
### GTFS Route
```typescript
interface GtfsRoute {
route_id: string; // Unique route identifier
route_short_name: string; // Public route number (e.g., "7")
route_long_name: string; // Full route name
route_type?: string; // GTFS route type (0=Tram, 3=Bus, etc.)
}
```
### Vehicle Position
```typescript
interface VehiclePosition {
time: Date; // Timestamp (ISO 8601)
vehicle_id: string; // Vehicle identifier
route_id: string; // Route being served
trip_id?: string; // Current trip ID
latitude: number; // GPS latitude
longitude: number; // GPS longitude
speed?: number; // Speed in km/h
bearing?: number; // Direction (0-360°)
current_status: string; // IN_TRANSIT_TO, STOPPED_AT, UNKNOWN
}
```
### Arrival Record
```typescript
interface ArrivalRecord {
time: Date; // Record timestamp
stop_id: string; // Stop ID
route_id: string; // Route ID
scheduled_time: Date; // Scheduled arrival time
predicted_time?: Date; // Real-time predicted time
actual_time?: Date; // Actual arrival time (if observed)
delay_seconds: number; // Delay in seconds
is_realtime: boolean; // Based on real-time data
headsign?: string; // Trip headsign
}
```
---
## Error Handling
All endpoints return standard HTTP status codes:
| Status Code | Description |
|-------------|-------------|
| `200` | Success |
| `404` | Resource not found (e.g., invalid stop/route ID) |
| `500` | Server error |
**Error Response Format:**
```json
{
"error": "Description of the error",
"details": "Additional error details (when available)"
}
```
**Example Error:**
```bash
curl "http://localhost:3000/api/config?stopId=invalid"
```
Response (404):
```json
{
"error": "Stop invalid not found"
}
```
---
## Rate Limiting
Currently, there is no rate limiting implemented. For production deployments, consider:
- Implementing per-IP rate limits
- Using caching for frequently accessed endpoints
- Load balancing for high-traffic scenarios
**Recommended Polling Intervals:**
- Real-time arrivals: 10-30 seconds
- Vehicle positions: 10-30 seconds
- Historical data: As needed (not time-sensitive)
- Statistics: 5-60 minutes (depending on use case)
---
## GTFS Data Update
The GTFS static data (stops, routes, schedules) is loaded from the `gtfs/` directory on server startup.
**To update GTFS data:**
```bash
bun run setup-gtfs.ts
```
This downloads the latest GTFS feed and extracts it. Restart the server to load the new data.
---
## TimescaleDB Setup
Historical data and analytics require TimescaleDB. See the [README.md](README.md) for setup instructions.
**Quick start:**
```bash
cd infrastructure
docker compose up -d
```
The database automatically:
- Creates hypertables for time-series data
- Sets up 90-day data retention
- Builds continuous aggregates for hourly metrics
- Maintains indexes for fast queries
---
## Example Use Cases
### Building a Real-Time Tracker
```javascript
// 1. Get all stops
const stops = await fetch('/api/stops').then(r => r.json());
// 2. Get all routes
const routes = await fetch('/api/routes').then(r => r.json());
// 3. Poll for arrivals every 15 seconds
setInterval(async () => {
const arrivals = await fetch('/api/arrivals?stopId=1563&routeId=125')
.then(r => r.json());
updateUI(arrivals);
}, 15000);
// 4. Poll for vehicle positions
setInterval(async () => {
const vehicles = await fetch('/api/vehicles?routeId=125')
.then(r => r.json());
updateMap(vehicles);
}, 15000);
```
### Building Analytics Dashboard
```javascript
// Get delay statistics for route
const stats = await fetch('/api/stats/route/125/delays?hours=168')
.then(r => r.json());
// Get hourly patterns
const pattern = await fetch('/api/stats/route/125/hourly?days=30')
.then(r => r.json());
// Get stop performance comparison
const stopStats = await fetch('/api/stats/stop/1563/delays?hours=168')
.then(r => r.json());
// Display charts with the data
renderDelayChart(stats);
renderHourlyPattern(pattern);
renderStopComparison(stopStats);
```
---
## Additional Resources
- **OpenAPI Specification**: [openapi.yaml](openapi.yaml) - Machine-readable API spec
- **Upstream API Documentation**: [UPSTREAM-API-DOCUMENTATION.md](UPSTREAM-API-DOCUMENTATION.md) - ModeShift GTFS API docs
- **Swagger UI Demo**: https://editor.swagger.io/ (paste openapi.yaml content)
- **GTFS Specification**: https://gtfs.org/
- **GTFS-RT Reference**: https://gtfs.org/realtime/
- **TimescaleDB Documentation**: https://docs.timescale.com/
- **Project README**: [README.md](README.md)
---
## Support
For issues or questions:
1. Check the [README.md](README.md) for setup instructions
2. Review this documentation
3. Check the source code in [server.ts](server.ts) and [lib/gtfs.ts](lib/gtfs.ts)
---
**Last Updated:** February 7, 2026
**API Version:** 1.0.0

235
README.md
View File

@@ -1,217 +1,70 @@
# Skopje Bus Tracker
# OpenJSP Bus Tracker
Real-time bus tracking for Skopje public transport. Modular system supporting any stop and route.
Real-time Skopje public transport tracking with Bun, GTFS/GTFS-RT ingestion, parquet persistence, and optional S3-compatible segment upload.
## What Is In This Repo
- `bus-tracker-json.ts`: terminal tracker for one stop + one route.
- `background-tracker.ts`: continuous collector for multiple routes/stops.
- `lib/database.ts`: parquet write layer with rolling segments and optional S3 upload.
- `lib/gtfs.ts`: GTFS CSV loading helpers.
- `config.ts`: API base URL, defaults, and tracker timing.
## Requirements
- Bun 1.x+
- Network access to the configured GTFS/JSON upstream APIs
## Quick Start
```bash
npm install
npm run setup-gtfs # Download latest GTFS data
npm run web
bun install
bun run typecheck
```
Open **http://localhost:3000**
Visit **http://localhost:3000/analytics.html** for historical data and performance analytics.
## TimescaleDB Setup
The application uses TimescaleDB for storing time-series data (vehicle positions, arrivals, delays).
### Start the database:
Run single stop/route terminal tracker:
```bash
cd infrastructure
docker compose up -d
bun run tracker
```
### Configure environment:
Create a `.env` file (or use the defaults):
Run with custom stop and route IDs:
```bash
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=iot_data
POSTGRES_USER=postgres
POSTGRES_PASSWORD=example
bun run tracker -- --stop 1571 --route 125
```
The database will automatically:
- Create hypertables for efficient time-series queries
- Set up compression and retention policies (90 days)
- Build continuous aggregates for hourly metrics
- Index data for fast queries
### Analytics Features:
- **Vehicle Position History**: Track individual buses over time
- **Delay Analysis**: On-time performance, average delays, patterns
- **Hourly Patterns**: See when buses are typically late/early
- **Route Statistics**: Reliability scores, service quality metrics
- **Stop Performance**: Compare delays across different stops
### Background Tracker:
For continuous data collection without keeping the web interface open:
Run background collection pipeline:
```bash
npm run track
bun run track
```
This automatically tracks these popular routes every 30 seconds:
- Routes: 2, 4, 5, 7, 15, 21, 22, 24
- Private routes: 12П, 19П, 22П, 45П, 52П, 54П, 61П, 9П
## Environment
Data is stored in TimescaleDB for historical analysis. The tracker runs indefinitely until stopped with Ctrl+C.
Copy `.env.example` to `.env` and adjust values as needed.
## Features
Key variables:
- **Fully Modular Web Interface**: Select any stop and route via UI controls or URL parameters
- **Dynamic Tracking**: Change stops/routes without restarting the server
- Interactive map with live vehicle positions
- Real-time arrivals with delays
- **Time-Series Data Storage**: Historical tracking with TimescaleDB
- **Analytics Dashboard**: Delay statistics, hourly patterns, performance metrics
- 5-second auto-refresh (web), 10-second (terminal)
- CLI arguments for terminal tracker
- Configurable defaults via [config.ts](config.ts)
- Shareable URLs with stop/route parameters
- `PARQUET_DIR`: local output directory for parquet files.
- `PARQUET_ROLL_MINUTES`: segment rotation interval.
- `SAVE_ALL_VEHICLE_SNAPSHOTS`: save full raw vehicle feed snapshots.
- `SAVE_ALL_VEHICLE_POSITIONS`: persist all vehicle positions (not only route-matched).
- `S3_ENABLED`: enable object storage upload.
- `S3_BUCKET`, `S3_REGION`, `S3_ENDPOINT`, `S3_PREFIX`: object storage target.
- `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`: object storage credentials.
- `S3_DELETE_LOCAL_AFTER_UPLOAD`: delete local parquet after successful upload.
- `S3_UPLOAD_RETRIES`, `S3_UPLOAD_RETRY_BASE_MS`: upload retry behavior.
## Commands
## Scripts
```bash
npm run setup-gtfs # Download GTFS data
npm run find -- --stop "american" # Find stop IDs by name
npm run find -- --route "7" # Find route IDs by number/name
npm run web # Web interface at http://localhost:3000
npm run tracker # Terminal interface (default)
npm run tracker -- --stop 1571 --route 125 # Custom stop/route
npm run track # Background tracker for popular routes (30s intervals)
npm start # Same as web
```
- `bun run start`: alias for the terminal tracker.
- `bun run tracker`: terminal tracker.
- `bun run track`: background collector.
- `bun run typecheck`: TypeScript no-emit check.
### Finding Stop and Route IDs
## Notes
Not sure which Stop ID or Route ID to use? Use the find command:
```bash
# Find stops by name (case-insensitive)
npm run find -- --stop "american"
npm run find -- --stop "центар"
# Find routes by number or name
npm run find -- --route "7"
npm run find -- --route "линија"
```
### Web Interface Usage
1. **Default tracking**: Open `http://localhost:3000` (loads default stop/route, can be changed in UI)
2. **Direct URL**: `http://localhost:3000?stopId=1571&routeId=125` (bookmarkable)
3. **Change tracking**: Use the controls at the top to enter different Stop ID and Route ID
4. **Share**: Copy URL after selecting a stop/route to share with others
### CLI Arguments
Terminal tracker supports custom stop and route:
```bash
npm run tracker -- --stop <stopId> --route <routeId>
npm run tracker -- --help
```
### API Endpoints
**This Application's API:**
- Complete docs: **[API-DOCUMENTATION.md](API-DOCUMENTATION.md)**
- Interactive docs: http://localhost:3000/api-docs.html (when server is running)
- OpenAPI spec: **[openapi.yaml](openapi.yaml)**
**Upstream ModeShift GTFS API:**
- Documentation: **[UPSTREAM-API-DOCUMENTATION.md](UPSTREAM-API-DOCUMENTATION.md)**
- Provider: ModeShift (Skopje public transport data)
#### Quick Reference
Query parameters for custom tracking:
```
GET /api/config?stopId=1571&routeId=125
GET /api/arrivals?stopId=1571&routeId=125
GET /api/vehicles?routeId=125
GET /api/stops # All stops
GET /api/routes # All routes
# Historical Data APIs
GET /api/stats/db # Database statistics
GET /api/history/vehicle/:vehicleId?hours=24
GET /api/history/route/:routeId/vehicles?hours=24
GET /api/history/stop/:stopId/arrivals?routeId=125&hours=24
GET /api/stats/route/:routeId/delays?hours=24
GET /api/stats/stop/:stopId/delays?hours=24
GET /api/stats/route/:routeId/hourly?days=7
```
## Configuration
Edit [config.ts](config.ts) to set defaults:
```typescript
export const config: AppConfig = {
defaultStop: {
stopId: '1571',
name: 'АМЕРИКАН КОЛЕЏ-КОН ЦЕНТАР',
lat: 41.98057556152344,
lon: 21.457794189453125,
},
defaultRoute: {
routeId: '125',
shortName: '7',
name: 'ЛИНИЈА 7',
},
server: {
port: 3000,
},
tracking: {
refreshInterval: {
web: 5000, // 5 seconds
terminal: 10000, // 10 seconds
},
minutesAhead: 90,
}, + analytics)
bus-tracker-json.ts # Terminal tracker (CLI args)
lib/
gtfs.ts # GTFS loader
database.ts # TimescaleDB time-series storage
public/
index.html # Live tracker UI
analytics.html # Analytics dashboard
infrastructure/
compose.yml # TimescaleDB Docker setup
gtfs/ ure
```
bus/
├── config.ts # Configuration (stops, routes, timing)
├── setup-gtfs.ts # GTFS data downloader
├── find-stops-routes.ts # Helper to find Stop/Route IDs
├── server.ts # Web server (modular API)
├── bus-tracker-json.ts # Terminal tracker (CLI args)
├── lib/gtfs.ts # GTFS loader
├── public/index.html # Frontend (modular UI)
└─**TimescaleDB (PostgreSQL)** for time-series data
- Leaflet.js + OpenStreetMap
- Chart.js for analytics visualizations
- GTFS + GTFS-RT Protocol Buffers
- Docker Compose for database
## Stack
- Node.js + Express + TypeScript
- Leaflet.js + OpenStreetMap
- GTFS + GTFS-RT Protocol Buffers
## License
MIT
- Generated parquet files are intentionally ignored by git (`data/*.parquet`).
- The background tracker rotates segments and uploads each closed segment when S3 is enabled.
- On process shutdown (`SIGINT`/`SIGTERM`), writers are flushed so the current segment is finalized.

View File

@@ -1,556 +0,0 @@
# ModeShift GTFS API Documentation
Documentation for the upstream ModeShift GTFS API used by this Skopje Bus Tracker application.
## Table of Contents
- [Overview](#overview)
- [OpenAPI Specification](#openapi-specification)
- [Base URL](#base-url)
- [Authentication](#authentication)
- [Endpoints](#endpoints)
- [GTFS-RT Trip Updates](#gtfs-rt-trip-updates)
- [Public Vehicles (JSON)](#public-vehicles-json)
- [Nearby Stop Times](#nearby-stop-times)
- [Data Models](#data-models)
- [Usage Examples](#usage-examples)
---
## Overview
The ModeShift API provides real-time transit data for Skopje public transport based on the **GTFS (General Transit Feed Specification)** and **GTFS-Realtime** standards.
**Provider:** ModeShift
**API Type:** GTFS + GTFS-Realtime
**Data Format:** JSON and Protocol Buffers
**Coverage:** Skopje, North Macedonia
---
## OpenAPI Specification
A complete **OpenAPI 3.0** specification for the ModeShift API is available at [upstream-openapi.yaml](upstream-openapi.yaml).
This machine-readable specification includes:
- All upstream API endpoints
- Request/response schemas
- Parameter validation
- Examples for each operation
You can view it interactively using:
- [Swagger Editor](https://editor.swagger.io/) - Paste the YAML content
- [Redoc](https://redocly.github.io/redoc/) - For a clean documentation view
- VS Code with OpenAPI extensions
---
## Base URL
```
https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e
```
The base URL includes a tenant/agency identifier (`9814b106-2afe-47c8-919b-bdec6a5e521e`) specific to Skopje's transit system.
---
## Authentication
Currently, the API appears to be publicly accessible without authentication tokens. Requests can be made directly to the endpoints.
---
## Endpoints
### GTFS-RT Trip Updates
Get real-time trip updates in GTFS-Realtime format (Protocol Buffers).
**Endpoint:** `GET /transport/gtfsrt/tripupdates.pb`
**Full URL:**
```
https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfsrt/tripupdates.pb
```
**Response Format:** Protocol Buffers (binary)
**Content-Type:** `application/octet-stream`
**Description:**
Returns a GTFS-Realtime FeedMessage containing TripUpdate entities. This follows the official [GTFS-Realtime specification](https://gtfs.org/realtime/).
**Response Structure (Protobuf):**
```protobuf
message FeedMessage {
FeedHeader header = 1;
repeated FeedEntity entity = 2;
}
message FeedEntity {
required string id = 1;
optional TripUpdate trip_update = 3;
}
message TripUpdate {
optional TripDescriptor trip = 1;
optional VehicleDescriptor vehicle = 3;
repeated StopTimeUpdate stop_time_update = 2;
}
```
**Example Usage (JavaScript):**
```javascript
import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
const response = await fetch(
'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfsrt/tripupdates.pb'
);
const buffer = await response.arrayBuffer();
const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
new Uint8Array(buffer)
);
for (const entity of feed.entity) {
if (entity.tripUpdate) {
console.log('Trip:', entity.tripUpdate.trip.tripId);
console.log('Route:', entity.tripUpdate.trip.routeId);
console.log('Vehicle:', entity.tripUpdate.vehicle?.id);
}
}
```
**Key Fields:**
- `trip.tripId`: GTFS trip identifier
- `trip.routeId`: GTFS route identifier
- `vehicle.id`: Vehicle identification number
- `vehicle.label`: Public-facing vehicle number
- `stopTimeUpdate[]`: Array of stop time predictions
---
### Public Vehicles (JSON)
Get current positions and status of all active vehicles in JSON format.
**Endpoint:** `GET /transport/public/vehicles`
**Full URL:**
```
https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/public/vehicles
```
**Response Format:** JSON
**Description:**
Returns an array of all currently active vehicles with their real-time GPS positions, speed, bearing, and status information.
**Response Structure:**
```json
[
{
"id": 12345,
"identificationNumber": "MK-SK-1234",
"inventoryNumber": 1234,
"positionLatitude": 41.9981,
"positionLongitude": 21.4254,
"positionBearing": 87.5,
"positionSpeed": 15.3,
"positionModifiedAt": "2026-02-07T14:27:15Z",
"status": 2
},
{
"id": 12346,
"identificationNumber": "MK-SK-5678",
"inventoryNumber": 5678,
"positionLatitude": 42.0012,
"positionLongitude": 21.4301,
"positionBearing": 180.0,
"positionSpeed": 0.0,
"positionModifiedAt": "2026-02-07T14:27:10Z",
"status": 1
}
]
```
**Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Internal database identifier for the vehicle |
| `identificationNumber` | string | Vehicle license plate or identification (e.g., "MK-SK-1234") |
| `inventoryNumber` | integer | Public-facing bus number shown on the vehicle |
| `positionLatitude` | number | Current GPS latitude (WGS84) |
| `positionLongitude` | number | Current GPS longitude (WGS84) |
| `positionBearing` | number | Direction of travel in degrees (0-360, 0=North, 90=East) |
| `positionSpeed` | number | Current speed in km/h |
| `positionModifiedAt` | string | ISO 8601 timestamp of last position update |
| `status` | integer | Vehicle status code (1=stopped, 2=in transit) |
**Status Codes:**
- `1` - Vehicle is stopped
- `2` - Vehicle is in transit
**Example Usage:**
```bash
curl "https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/public/vehicles"
```
**Use Cases:**
- Display all vehicles on a map
- Match vehicles to routes using trip update data
- Track real-time vehicle movements
---
### Nearby Stop Times
Get arrival predictions for stops near a specific location.
**Endpoint:** `GET /transport/planner/stops/nearbyTimes`
**Full URL:**
```
https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/planner/stops/nearbyTimes
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `latitude` | number | Yes | Search center latitude (WGS84) |
| `longitude` | number | Yes | Search center longitude (WGS84) |
| `radius` | integer | Yes | Search radius in meters |
**Response Format:** JSON
**Description:**
Returns stops within the specified radius along with their upcoming arrivals, including real-time predictions.
**Response Structure:**
```json
[
{
"id": 1563,
"code": "363",
"name": "KARPOS II - STIV NAUMOV",
"latitude": 41.97964,
"longitude": 21.41283,
"patterns": [
{
"routeId": 125,
"routeShortName": "7",
"routeLongName": "Centar - GTC - Karpos 4 - Karposh 2",
"headsign": "Karpos 2",
"stopTimes": [
{
"serviceDay": 1738972800,
"scheduledArrival": 52380,
"realtimeArrival": 52500,
"arrivalDelay": 120,
"realtime": true,
"realtimeState": "UPDATED",
"headsign": "Karpos 2"
},
{
"serviceDay": 1738972800,
"scheduledArrival": 53280,
"realtimeArrival": 53280,
"arrivalDelay": 0,
"realtime": true,
"realtimeState": "SCHEDULED",
"headsign": "Karpos 2"
}
]
}
]
}
]
```
**Fields:**
**Stop Level:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | GTFS stop ID |
| `code` | string | Public stop code |
| `name` | string | Stop name |
| `latitude` | number | Stop latitude |
| `longitude` | number | Stop longitude |
| `patterns[]` | array | Array of route patterns serving this stop |
**Pattern Level:**
| Field | Type | Description |
|-------|------|-------------|
| `routeId` | integer | GTFS route ID |
| `routeShortName` | string | Public route number (e.g., "7") |
| `routeLongName` | string | Full route name |
| `headsign` | string | Direction/destination |
| `stopTimes[]` | array | Array of upcoming arrivals |
**Stop Time Level:**
| Field | Type | Description |
|-------|------|-------------|
| `serviceDay` | integer | Unix timestamp (seconds) of service day start (midnight) |
| `scheduledArrival` | integer | Seconds since midnight for scheduled arrival time |
| `realtimeArrival` | integer | Seconds since midnight for predicted arrival time |
| `arrivalDelay` | integer | Delay in seconds (positive=late, negative=early) |
| `realtime` | boolean | Whether prediction is based on real-time data |
| `realtimeState` | string | State: `SCHEDULED`, `UPDATED`, `CANCELED` |
| `headsign` | string | Trip headsign (destination) |
**Calculating Arrival Time:**
```javascript
const arrival = new Date(
(stopTime.serviceDay + stopTime.realtimeArrival) * 1000
);
```
**Example Usage:**
```bash
# Get arrivals near American College
curl "https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/planner/stops/nearbyTimes?latitude=42.00437&longitude=21.39687&radius=50"
```
**Use Cases:**
- Display upcoming arrivals at a stop
- Show real-time delay information
- Calculate minutes until arrival
- Filter by specific routes
---
## Data Models
### GTFS Stop
```typescript
interface Stop {
id: number; // GTFS stop_id (as integer)
code: string; // Public stop code
name: string; // Stop name
latitude: number; // WGS84 latitude
longitude: number; // WGS84 longitude
patterns: Pattern[]; // Routes serving this stop
}
```
### Route Pattern
```typescript
interface Pattern {
routeId: number; // GTFS route_id (as integer)
routeShortName: string; // Public route number
routeLongName: string; // Full route name
headsign: string; // Direction/destination
stopTimes: StopTime[]; // Upcoming arrivals
}
```
### Stop Time
```typescript
interface StopTime {
serviceDay: number; // Unix timestamp of service day start
scheduledArrival: number; // Seconds since midnight (scheduled)
realtimeArrival: number; // Seconds since midnight (predicted)
arrivalDelay: number; // Delay in seconds
realtime: boolean; // Based on real-time data
realtimeState: string; // SCHEDULED, UPDATED, CANCELED
headsign: string; // Trip headsign
}
```
### Vehicle
```typescript
interface Vehicle {
id: number; // Internal ID
identificationNumber: string; // License plate
inventoryNumber: number; // Bus number
positionLatitude: number; // GPS latitude
positionLongitude: number; // GPS longitude
positionBearing: number; // Direction (0-360°)
positionSpeed: number; // Speed in km/h
positionModifiedAt: string; // ISO 8601 timestamp
status: number; // 1=stopped, 2=in_transit
}
```
---
## Usage Examples
### Complete Real-Time Tracker Flow
```javascript
// 1. Get a specific stop's arrivals
const stopLat = 41.97964;
const stopLon = 21.41283;
const radius = 50;
const nearbyResponse = await fetch(
`https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/planner/stops/nearbyTimes?latitude=${stopLat}&longitude=${stopLon}&radius=${radius}`
);
const stops = await nearbyResponse.json();
// 2. Process arrivals for a specific route
const targetRouteId = 125;
const now = new Date();
for (const stop of stops) {
for (const pattern of stop.patterns) {
if (pattern.routeId === targetRouteId) {
for (const stopTime of pattern.stopTimes) {
const arrivalTime = new Date(
(stopTime.serviceDay + stopTime.realtimeArrival) * 1000
);
const minutesUntil = Math.floor(
(arrivalTime.getTime() - now.getTime()) / 60000
);
console.log(`Route ${pattern.routeShortName} - ${minutesUntil} min`);
console.log(`Delay: ${stopTime.arrivalDelay} seconds`);
}
}
}
}
// 3. Get all vehicle positions
const vehiclesResponse = await fetch(
'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/public/vehicles'
);
const vehicles = await vehiclesResponse.json();
// 4. Get trip updates to match vehicles to routes
const tripUpdatesResponse = await fetch(
'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfsrt/tripupdates.pb'
);
const buffer = await tripUpdatesResponse.arrayBuffer();
const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
new Uint8Array(buffer)
);
// 5. Build vehicle-to-route mapping
const vehicleRouteMap = new Map();
for (const entity of feed.entity) {
if (entity.tripUpdate?.vehicle?.id && entity.tripUpdate?.trip?.routeId) {
vehicleRouteMap.set(
entity.tripUpdate.vehicle.id,
entity.tripUpdate.trip.routeId
);
}
}
// 6. Filter vehicles by route
const routeVehicles = vehicles.filter(v => {
const vehicleKey = v.identificationNumber || v.inventoryNumber.toString();
return vehicleRouteMap.get(vehicleKey) == targetRouteId;
});
console.log(`Found ${routeVehicles.length} vehicles on route ${targetRouteId}`);
```
### Polling for Updates
```javascript
// Recommended polling intervals
const VEHICLE_UPDATE_INTERVAL = 10000; // 10 seconds
const ARRIVAL_UPDATE_INTERVAL = 15000; // 15 seconds
const TRIP_UPDATE_INTERVAL = 10000; // 10 seconds
// Poll for vehicle positions
setInterval(async () => {
const vehicles = await fetch(vehiclesEndpoint).then(r => r.json());
updateMap(vehicles);
}, VEHICLE_UPDATE_INTERVAL);
// Poll for arrival predictions
setInterval(async () => {
const arrivals = await fetch(nearbyTimesEndpoint).then(r => r.json());
updateArrivalBoard(arrivals);
}, ARRIVAL_UPDATE_INTERVAL);
```
---
## GTFS Static Data
The API uses GTFS static data which should be downloaded separately. Static data includes:
- **stops.txt**: All stop locations and names
- **routes.txt**: All route definitions
- **trips.txt**: Trip schedules
- **stop_times.txt**: Stop time schedules
- **calendar.txt**: Service calendar
- **shapes.txt**: Route shapes (geographic paths)
**Note:** The static GTFS feed download URL should be obtained from ModeShift or the transit agency.
---
## Rate Limiting & Best Practices
1. **Polling Frequency**:
- Don't poll more frequently than every 10 seconds
- Cache responses when appropriate
- Use conditional requests when possible
2. **Radius Parameter**:
- Use smallest radius that meets your needs (typically 50-100m)
- Larger radius = more data returned = slower response
3. **Error Handling**:
- Handle network errors gracefully
- Implement exponential backoff for failures
- Parse protobuf data carefully (can be empty)
4. **Data Volume**:
- Vehicle positions endpoint returns ALL vehicles (100+)
- Filter client-side rather than making multiple requests
- Consider caching vehicle positions
---
## Common Issues & Solutions
### Empty GTFS-RT Feed
**Problem:** `tripupdates.pb` returns empty or zero-length buffer
**Solution:** This can happen during off-peak hours or system maintenance. Handle gracefully and retry later.
### Vehicle Matching
**Problem:** Vehicles in JSON don't match trip updates
**Solution:** Try matching by both `identificationNumber` and `inventoryNumber`. Some vehicles may not have active trips.
### Time Zone Issues
**Problem:** Arrival times are incorrect
**Solution:** The API uses Europe/Skopje timezone. Service day timestamps are at midnight local time.
### Delay Calculation
**Problem:** Delays seem wrong
**Solution:** Use `realtimeArrival - scheduledArrival` (already provided as `arrivalDelay` field).
---
## Related Documentation
- **GTFS Specification**: https://gtfs.org/
- **GTFS-Realtime**: https://gtfs.org/realtime/
- **Protocol Buffers**: https://protobuf.dev/
- **gtfs-realtime-bindings**: https://www.npmjs.com/package/gtfs-realtime-bindings
---
## Support
For issues with the ModeShift API:
- Contact ModeShift support
- Check ModeShift documentation at https://www.modeshift.app/
For issues with this tracker application:
- See [README.md](README.md)
- Check [API-DOCUMENTATION.md](API-DOCUMENTATION.md) for the local API
---
**Last Updated:** February 7, 2026
**API Provider:** ModeShift
**Coverage:** Skopje, North Macedonia

270
bun.lock
View File

@@ -5,16 +5,16 @@
"": {
"name": "skopje-bus-api-client",
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"express": "^4.18.2",
"pg": "^8.18.0",
"hyparquet": "^1.25.1",
"hyparquet-writer": "^0.13.0",
"protobufjs": "^7.5.4",
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@types/pg": "^8.11.0",
"protobufjs-cli": "^2.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3",
},
"optionalDependencies": {
@@ -23,6 +23,86 @@
},
},
"packages": {
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1008.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-node": "^3.972.20", "@aws-sdk/middleware-bucket-endpoint": "^3.972.7", "@aws-sdk/middleware-expect-continue": "^3.972.7", "@aws-sdk/middleware-flexible-checksums": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-location-constraint": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-sdk-s3": "^3.972.19", "@aws-sdk/middleware-ssec": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/signature-v4-multi-region": "^3.996.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.6", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/eventstream-serde-browser": "^4.2.11", "@smithy/eventstream-serde-config-resolver": "^4.3.11", "@smithy/eventstream-serde-node": "^4.2.11", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-blob-browser": "^4.2.12", "@smithy/hash-node": "^4.2.11", "@smithy/hash-stream-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/md5-js": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-w/SIRD25v2zVMbkn8CYIxUsac8yf5Jghkhw5j7EsNWdJhl56m/nWpUX7t1etFUW1cnzpFjZV0lXt0dNFSnbXwA=="],
"@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-login": "^3.972.19", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.19", "@aws-sdk/credential-provider-web-identity": "^3.972.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.20", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-ini": "^3.972.19", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.19", "@aws-sdk/credential-provider-web-identity": "^3.972.19", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/token-providers": "3.1008.0", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.973.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/crc64-nvme": "^3.972.4", "@aws-sdk/types": "^3.973.5", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.9", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.6", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.7", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.19", "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1008.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.6", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -31,14 +111,6 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@jsdoc/salty": ["@jsdoc/salty@0.2.9", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
@@ -61,13 +133,107 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
"@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
"@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.11", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw=="],
"@smithy/core": ["@smithy/core@3.23.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.19", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
"@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
"@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.25", "", { "dependencies": { "@smithy/core": "^3.23.11", "@smithy/middleware-serde": "^4.2.14", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.42", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.5", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.14", "", { "dependencies": { "@smithy/core": "^3.23.11", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.16", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.12.5", "", { "dependencies": { "@smithy/core": "^3.23.11", "@smithy/middleware-endpoint": "^4.4.25", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.19", "tslib": "^2.6.2" } }, "sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w=="],
"@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.41", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.5", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.44", "", { "dependencies": { "@smithy/config-resolver": "^4.4.11", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.5", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
"@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
"@smithy/util-stream": ["@smithy/util-stream@4.5.19", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.4.16", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
@@ -89,8 +255,6 @@
"@types/node": ["@types/node@20.19.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="],
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
@@ -105,12 +269,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
@@ -121,6 +281,8 @@
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -145,8 +307,6 @@
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -155,8 +315,6 @@
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -193,6 +351,10 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-xml-builder": ["fast-xml-builder@1.1.2", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA=="],
"fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
@@ -223,6 +385,10 @@
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"hyparquet": ["hyparquet@1.25.1", "", {}, "sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w=="],
"hyparquet-writer": ["hyparquet-writer@0.13.0", "", { "dependencies": { "hyparquet": "1.25.1" } }, "sha512-4Vl2RxwQPjMhCOZ2mCNK/WY3lJiiy+NlUugdsqBPTTy9sw3Uu7sJPU3UMiy7+aRAbY+y2K5dRJuNGTmQ8PdXFQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
@@ -245,8 +411,6 @@
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="],
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": "bin/markdown-it.mjs" }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
"markdown-it-anchor": ["markdown-it-anchor@8.6.7", "", { "peerDependencies": { "@types/markdown-it": "*", "markdown-it": "*" } }, "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA=="],
@@ -289,32 +453,10 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-expression-matcher": ["path-expression-matcher@1.1.3", "", {}, "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ=="],
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="],
"pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"prelude-ls": ["prelude-ls@1.1.2", "", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="],
"protobufjs": ["protobufjs@7.5.4", "", { "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" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
@@ -355,19 +497,19 @@
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.3.2", "", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="],
@@ -387,8 +529,6 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -397,9 +537,11 @@
"xmlcreate": ["xmlcreate@2.0.4", "", {}, "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
@@ -408,5 +550,17 @@
"gtfs-realtime-bindings/protobufjs-cli": ["protobufjs-cli@1.2.0", "", { "dependencies": { "chalk": "^4.0.0", "escodegen": "^1.13.0", "espree": "^9.0.0", "estraverse": "^5.1.0", "glob": "^8.0.0", "jsdoc": "^4.0.0", "minimist": "^1.2.0", "semver": "^7.1.2", "tmp": "^0.2.1", "uglify-js": "^3.7.7" }, "peerDependencies": { "protobufjs": "^7.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-+YvqJEmsmZHGzE5j0tvEzFeHm0sX7pzRFpyj7+GazhkS4Y0r+jgbioVvFxxSWIlPzUel/lxeOnLChBmV8NmyHA=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
}
}

View File

@@ -1,347 +0,0 @@
import { loadGtfsStops, loadGtfsRoutes, GtfsStop, GtfsRoute } from './lib/gtfs';
import { config } from './config';
// ============================================================================
// CLI Arguments
// ============================================================================
function parseArgs() {
const args = process.argv.slice(2);
let stopId = config.defaultStop.stopId;
let routeId = config.defaultRoute.routeId;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--stop' && args[i + 1]) {
stopId = args[i + 1];
i++;
} else if (args[i] === '--route' && args[i + 1]) {
routeId = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: npm run tracker [options]
Options:
--stop <stopId> Stop ID to track (default: ${config.defaultStop.stopId})
--route <routeId> Route ID to track (default: ${config.defaultRoute.routeId})
--help, -h Show this help message
Examples:
npm run tracker
npm run tracker -- --stop 1571 --route 125
`);
process.exit(0);
}
}
return { stopId, routeId };
}
const { stopId: TARGET_STOP_ID, routeId: TARGET_ROUTE_ID } = parseArgs();
// ============================================================================
// Configuration
// ============================================================================
// ============================================================================
// Additional Types
// ============================================================================
interface StopTime {
scheduledArrival: number;
scheduledDeparture: number;
realtimeArrival: number;
realtimeDeparture: number;
arrivalDelay: number;
departureDelay: number;
timepoint: boolean;
realtime: boolean;
realtimeState: string;
serviceDay: number;
headsign: string;
}
interface Pattern {
routeId: number;
index: number;
stopTimes: StopTime[];
}
interface StopArrivalData {
id: number;
patterns: Pattern[];
distance: number;
}
interface BusArrival {
tripId: string;
routeName: string;
stopName: string;
headsign: string;
arrivalTime: Date;
scheduledTime: Date;
delaySeconds: number;
minutesUntilArrival: number;
isApproaching: boolean;
realtimeState: string;
isRealtime: boolean;
}
// ============================================================================
// Real-time Data Fetching
// ============================================================================
async function getNextBuses(
stops: Map<string, GtfsStop>,
routes: Map<string, GtfsRoute>
): Promise<BusArrival[]> {
const targetStop = stops.get(TARGET_STOP_ID);
if (!targetStop) {
throw new Error(`Stop ${TARGET_STOP_ID} not found in GTFS data`);
}
// Fetch nearby arrivals using JSON API
const radius = 50; // 50 meters
const nearbyUrl = `${config.baseUrl}/transport/planner/stops/nearbyTimes?latitude=${targetStop.stop_lat}&longitude=${targetStop.stop_lon}&radius=${radius}`;
const response = await fetch(nearbyUrl);
const nearbyData = await response.json() as StopArrivalData[];
const now = new Date();
const maxTime = new Date(now.getTime() + config.tracking.minutesAhead * 60000);
const arrivals: BusArrival[] = [];
// Get route info
const targetRoute = routes.get(TARGET_ROUTE_ID);
// Process the nearby data
for (const stopData of nearbyData) {
if (stopData.id.toString() !== TARGET_STOP_ID) continue;
for (const pattern of stopData.patterns) {
// Filter by target route
if (pattern.routeId.toString() !== TARGET_ROUTE_ID) continue;
const routeInfo = routes.get(pattern.routeId.toString());
if (!routeInfo) continue;
for (const stopTime of pattern.stopTimes) {
// Convert service day + seconds to actual timestamp
const serviceDay = new Date(stopTime.serviceDay * 1000);
const arrivalTime = new Date(serviceDay.getTime() + stopTime.realtimeArrival * 1000);
const scheduledTime = new Date(serviceDay.getTime() + stopTime.scheduledArrival * 1000);
// Only include buses arriving within our time window
if (arrivalTime > maxTime || arrivalTime < now) continue;
const minutesUntil = Math.floor((arrivalTime.getTime() - now.getTime()) / 60000);
arrivals.push({
tripId: `${pattern.routeId}.${pattern.index}`,
routeName: `${routeInfo.route_short_name} - ${routeInfo.route_long_name}`,
stopName: targetStop.stop_name,
headsign: stopTime.headsign,
arrivalTime: arrivalTime,
scheduledTime: scheduledTime,
delaySeconds: stopTime.arrivalDelay,
minutesUntilArrival: minutesUntil,
isApproaching: minutesUntil <= 5 && minutesUntil >= 0,
realtimeState: stopTime.realtimeState,
isRealtime: stopTime.realtime,
});
}
}
}
// Sort by arrival time
arrivals.sort((a, b) => a.arrivalTime.getTime() - b.arrivalTime.getTime());
return arrivals;
}
// ============================================================================
// Display Functions
// ============================================================================
function formatArrival(arrival: BusArrival, index: number): string {
const delayMin = Math.floor(arrival.delaySeconds / 60);
let delayText = "";
if (delayMin > 0) {
delayText = ` [${delayMin}min LATE]`;
} else if (delayMin < -3) {
delayText = ` [${Math.abs(delayMin)}min EARLY]`;
} else {
delayText = " [ON TIME]";
}
const timeStr = arrival.arrivalTime.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
let status = "";
if (arrival.minutesUntilArrival <= 0) {
status = " >> ARRIVING NOW!";
} else if (arrival.isApproaching) {
status = " >> APPROACHING";
}
const minutesText = arrival.minutesUntilArrival === 0
? "NOW"
: `${arrival.minutesUntilArrival} min`;
const realtimeIndicator = arrival.isRealtime ? "[LIVE]" : "[SCHED]";
return ` ${index + 1}. ${timeStr} (in ${minutesText}) ${delayText} ${realtimeIndicator}${status}`;
}
async function displayBusSchedule(
stops: Map<string, GtfsStop>,
routes: Map<string, GtfsRoute>
) {
console.clear();
const targetStop = stops.get(TARGET_STOP_ID);
const targetRoute = routes.get(TARGET_ROUTE_ID);
console.log("=".repeat(75));
console.log(` BUS TRACKER - SKOPJE PUBLIC TRANSPORT`);
console.log("=".repeat(75));
console.log(` Route: ${targetRoute?.route_short_name} - ${targetRoute?.route_long_name}`);
console.log(` Stop: ${targetStop?.stop_name} (Code: ${targetStop?.stop_code})`);
console.log(` Location: ${targetStop?.stop_lat.toFixed(5)}, ${targetStop?.stop_lon.toFixed(5)}`);
console.log("=".repeat(75));
console.log(` Updated: ${new Date().toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})}\n`);
try {
const arrivals = await getNextBuses(stops, routes);
if (arrivals.length === 0) {
console.log(` No buses scheduled in the next ${config.tracking.minutesAhead} minutes.\n`);
console.log(" This could mean:");
console.log(" - No buses are currently running on this route");
console.log(" - The next bus is more than 90 minutes away");
console.log(" - Service has ended for the day\n");
return;
}
console.log(` Found ${arrivals.length} upcoming bus${arrivals.length > 1 ? 'es' : ''}:\n`);
// Show all buses (or first 10 if too many)
const showCount = Math.min(arrivals.length, 10);
arrivals.slice(0, showCount).forEach((arrival, index) => {
console.log(formatArrival(arrival, index));
if (arrival.headsign && index < 5) {
console.log(` Direction: ${arrival.headsign}`);
}
});
if (arrivals.length > showCount) {
console.log(`\n ... and ${arrivals.length - showCount} more later`);
}
// Highlight the next bus
const nextBus = arrivals[0];
if (nextBus) {
console.log("\n" + "-".repeat(75));
console.log(" NEXT BUS:");
console.log("-".repeat(75));
if (nextBus.minutesUntilArrival <= 0) {
console.log(" >> BUS IS ARRIVING NOW! HEAD TO THE STOP! <<");
} else if (nextBus.minutesUntilArrival <= 2) {
console.log(` >> Bus arriving in ${nextBus.minutesUntilArrival} minute${nextBus.minutesUntilArrival > 1 ? 's' : ''}! Run! <<`);
} else if (nextBus.minutesUntilArrival <= 5) {
console.log(` >> Bus arriving in ${nextBus.minutesUntilArrival} minutes - Time to head to the stop <<`);
} else if (nextBus.minutesUntilArrival <= 15) {
console.log(` >> ${nextBus.minutesUntilArrival} minutes - Start getting ready <<`);
} else {
console.log(` >> ${nextBus.minutesUntilArrival} minutes - You have time to relax <<`);
}
console.log(` Direction: ${nextBus.headsign}`);
console.log(` Data source: ${nextBus.isRealtime ? 'Real-time tracking' : 'Scheduled times'}`);
const delayMin = Math.floor(nextBus.delaySeconds / 60);
if (delayMin > 5) {
console.log(` WARNING: Bus is running ${delayMin} minutes LATE`);
} else if (delayMin < -3) {
console.log(` NOTE: Bus is ${Math.abs(delayMin)} minutes EARLY`);
}
}
} catch (error) {
console.log(`\n ERROR: ${error}\n`);
console.log(" Please check:");
console.log(" - Your internet connection");
console.log(" - The API is accessible");
console.log(" - The GTFS files are present in ./gtfs/ directory\n");
}
console.log("\n" + "=".repeat(75));
console.log(` Auto-refresh: Every ${config.tracking.refreshInterval.terminal / 1000} seconds`);
console.log(" Press Ctrl+C to stop");
console.log("=".repeat(75));
}
// ============================================================================
// Main Program
// ============================================================================
async function startMonitoring() {
console.log("Loading GTFS data...\n");
try {
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
console.log(`Loaded ${stops.size} stops`);
console.log(`Loaded ${routes.size} routes\n`);
// Verify target stop and route exist
const targetStop = stops.get(TARGET_STOP_ID);
const targetRoute = routes.get(TARGET_ROUTE_ID);
if (!targetStop) {
console.error(`ERROR: Stop ${TARGET_STOP_ID} not found in GTFS data`);
process.exit(1);
}
if (!targetRoute) {
console.error(`ERROR: Route ${TARGET_ROUTE_ID} not found in GTFS data`);
process.exit(1);
}
console.log("Configuration validated");
console.log("Starting bus monitor...\n");
// Initial display
await displayBusSchedule(stops, routes);
// Set up periodic refresh
setInterval(async () => {
await displayBusSchedule(stops, routes);
}, config.tracking.refreshInterval.terminal);
} catch (error) {
console.error(`Failed to start: ${error}`);
console.error("\nPlease ensure:");
console.error(" 1. GTFS files exist in ./gtfs/ directory");
console.error(" 2. Files include: stops.txt and routes.txt");
console.error(" 3. Node.js has permission to read the files");
process.exit(1);
}
}
// Start the application
startMonitoring();

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env ts-node
/**
* Helper script to find Stop IDs and Route IDs
* Usage:
* npm run find -- --stop "american"
* npm run find -- --route "7"
*/
import { loadGtfsStops, loadGtfsRoutes } from './lib/gtfs';
function parseArgs() {
const args = process.argv.slice(2);
let searchStop = '';
let searchRoute = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--stop' && args[i + 1]) {
searchStop = args[i + 1].toLowerCase();
i++;
} else if (args[i] === '--route' && args[i + 1]) {
searchRoute = args[i + 1].toLowerCase();
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: npm run find -- [options]
Options:
--stop <keyword> Search for stops by name (case-insensitive)
--route <keyword> Search for routes by name or number
--help, -h Show this help message
Examples:
npm run find -- --stop "american"
npm run find -- --route "7"
npm run find -- --stop "center"
npm run find -- --route "linija"
`);
process.exit(0);
}
}
return { searchStop, searchRoute };
}
async function main() {
const { searchStop, searchRoute } = parseArgs();
if (!searchStop && !searchRoute) {
console.log('Please specify --stop or --route. Use --help for usage information.');
process.exit(1);
}
console.log('Loading GTFS data...\n');
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
if (searchStop) {
console.log(`=== Searching for stops matching "${searchStop}" ===\n`);
const matches = Array.from(stops.values())
.filter(stop => stop.stop_name.toLowerCase().includes(searchStop))
.slice(0, 20); // Limit to 20 results
if (matches.length === 0) {
console.log('No stops found.');
} else {
console.log(`Found ${matches.length} stop(s):\n`);
matches.forEach(stop => {
console.log(`Stop ID: ${stop.stop_id}`);
console.log(` Name: ${stop.stop_name}`);
console.log(` Code: ${stop.stop_code}`);
console.log(` Location: ${stop.stop_lat}, ${stop.stop_lon}`);
console.log('');
});
if (matches.length === 20) {
console.log('(Showing first 20 results, refine your search for more specific results)\n');
}
}
}
if (searchRoute) {
console.log(`=== Searching for routes matching "${searchRoute}" ===\n`);
const matches = Array.from(routes.values())
.filter(route =>
route.route_short_name?.toLowerCase().includes(searchRoute) ||
route.route_long_name?.toLowerCase().includes(searchRoute)
)
.slice(0, 20); // Limit to 20 results
if (matches.length === 0) {
console.log('No routes found.');
} else {
console.log(`Found ${matches.length} route(s):\n`);
matches.forEach(route => {
console.log(`Route ID: ${route.route_id}`);
console.log(` Number: ${route.route_short_name}`);
console.log(` Name: ${route.route_long_name}`);
console.log('');
});
if (matches.length === 20) {
console.log('(Showing first 20 results, refine your search for more specific results)\n');
}
}
}
console.log('Use these IDs in the web interface or terminal tracker.');
}
main();

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bun
/**
* Background tracker for popular bus routes in Skopje
* Continuously monitors GTFS-RT feeds and stores data to TimescaleDB
* Continuously monitors GTFS-RT feeds and stores data as parquet segments
*/
import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
@@ -10,7 +10,10 @@ import { GtfsRoute, GtfsStop, loadGtfsRoutes, loadGtfsStops } from './lib/gtfs';
import {
initDatabase,
logVehiclePositions,
logVehicleFeedSnapshot,
logArrival,
closeDatabase,
VehicleFeedSnapshot,
VehiclePosition
} from './lib/database';
@@ -29,8 +32,6 @@ const TRACKED_ROUTES = [
{ id: '204', name: 'Route 12 П' },
];
// Key stops to monitor for arrival predictions and delay tracking
// These are the busiest transit points in Skopje
const MONITORED_STOPS = [
// Central area and major transit hubs
'1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15',
@@ -50,15 +51,22 @@ const MONITORED_STOPS = [
const REFRESH_INTERVAL = 30000; // 30 seconds
const ARRIVAL_STOP_CAP = 150; // Max stops to query per cycle
const SAVE_ALL_VEHICLE_SNAPSHOTS = (process.env.SAVE_ALL_VEHICLE_SNAPSHOTS ?? 'true').toLowerCase() === 'true';
const SAVE_ALL_VEHICLE_POSITIONS = (process.env.SAVE_ALL_VEHICLE_POSITIONS ?? 'true').toLowerCase() === 'true';
let stats = {
cycles: 0,
lastUpdate: new Date(),
vehiclesTracked: 0,
vehicleSnapshots: 0,
arrivalsLogged: 0,
errors: 0,
};
function toOptionalNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
// Load GTFS stops data
const stops = loadGtfsStops();
const stopsByCode = new Map<string, GtfsStop>();
@@ -102,6 +110,26 @@ async function trackVehicles() {
const allVehicles = await vehiclesResponse.json() as any[];
console.log(` Found ${allVehicles.length} total vehicles`);
if (SAVE_ALL_VEHICLE_SNAPSHOTS && allVehicles.length > 0) {
const captureTime = Date.now();
const snapshots: VehicleFeedSnapshot[] = allVehicles.map((vehicle) => ({
timestamp: captureTime,
sourceTimestamp: toOptionalNumber(vehicle.positionModifiedAt),
vehicleId: vehicle.identificationNumber?.toString() || undefined,
inventoryNumber: vehicle.inventoryNumber?.toString() || undefined,
latitude: toOptionalNumber(vehicle.positionLatitude),
longitude: toOptionalNumber(vehicle.positionLongitude),
speed: toOptionalNumber(vehicle.positionSpeed),
bearing: toOptionalNumber(vehicle.positionBearing),
status: toOptionalNumber(vehicle.status),
rawJson: JSON.stringify(vehicle),
}));
await logVehicleFeedSnapshot(snapshots);
stats.vehicleSnapshots += snapshots.length;
console.log(` [OK] Logged ${snapshots.length} full-vehicle snapshots`);
}
// Fetch trip updates to match vehicles to routes
const tripUpdatesResponse = await fetch(config.apiEndpoints.gtfsRtTripUpdates);
@@ -129,9 +157,9 @@ async function trackVehicles() {
return;
}
// Build map of vehicle IDs to route IDs for tracked routes
// Build map of vehicle IDs to route IDs from GTFS trip updates.
const vehicleRouteMap = new Map<string, { routeId: string, tripId: string }>();
const trackedRouteIds = new Set(TRACKED_ROUTES.map(r => r.id));
const trackedRouteSet = new Set(TRACKED_ROUTES.map(r => r.id));
for (const entity of feed.entity) {
if (!entity.tripUpdate) continue;
@@ -139,8 +167,9 @@ async function trackVehicles() {
const tripUpdate = entity.tripUpdate;
const routeId = tripUpdate.trip?.routeId;
// Only process tracked routes
if (!routeId || !trackedRouteIds.has(routeId)) continue;
// In "all positions" mode keep every route; otherwise keep tracked routes only.
if (!routeId) continue;
if (!SAVE_ALL_VEHICLE_POSITIONS && !trackedRouteSet.has(routeId)) continue;
// Get vehicle ID and label - store both as keys
const vehicleId = tripUpdate.vehicle?.id;
@@ -158,7 +187,7 @@ async function trackVehicles() {
}
}
console.log(` Matched ${vehicleRouteMap.size} vehicles to tracked routes`);
console.log(` Matched ${vehicleRouteMap.size} vehicles to GTFS routes`);
// Debug: Show sample vehicle IDs from both sources
if (vehicleRouteMap.size > 0) {
@@ -173,7 +202,7 @@ async function trackVehicles() {
console.log(` Sample JSON API vehicle IDs: ${sampleJsonIds.join(', ')}`);
}
// Filter and prepare vehicle positions for tracked routes
// Prepare vehicle positions.
const positions: VehiclePosition[] = [];
const now = Date.now();
@@ -183,29 +212,29 @@ async function trackVehicles() {
if (!identificationNumber && !inventoryNumber) continue;
// Try multiple matching strategies
let routeInfo = vehicleRouteMap.get(identificationNumber) ||
vehicleRouteMap.get(inventoryNumber);
if (routeInfo) {
positions.push({
timestamp: now,
vehicleId: identificationNumber || inventoryNumber,
routeId: routeInfo.routeId,
tripId: routeInfo.tripId,
latitude: vehicle.positionLatitude,
longitude: vehicle.positionLongitude,
speed: vehicle.positionSpeed,
bearing: vehicle.positionBearing,
currentStatus: vehicle.status === 2 ? 'IN_TRANSIT_TO' : 'UNKNOWN',
});
}
// Try multiple matching strategies.
const routeInfo = vehicleRouteMap.get(identificationNumber) || vehicleRouteMap.get(inventoryNumber);
const includeVehicle = SAVE_ALL_VEHICLE_POSITIONS || !!routeInfo;
if (!includeVehicle) continue;
positions.push({
timestamp: now,
vehicleId: identificationNumber || inventoryNumber,
routeId: routeInfo?.routeId || 'UNKNOWN',
tripId: routeInfo?.tripId,
latitude: vehicle.positionLatitude,
longitude: vehicle.positionLongitude,
speed: vehicle.positionSpeed,
bearing: vehicle.positionBearing,
currentStatus: vehicle.status === 2 ? 'IN_TRANSIT_TO' : 'UNKNOWN',
});
}
// Log to database
if (positions.length > 0) {
await logVehiclePositions(positions);
console.log(` [OK] Logged ${positions.length} vehicle positions`);
console.log(` [OK] Logged ${positions.length} vehicle positions${SAVE_ALL_VEHICLE_POSITIONS ? ' (all vehicles mode)' : ''}`);
stats.vehiclesTracked += positions.length;
} else {
console.log(` [WARN] No vehicles found for tracked routes`);
@@ -358,6 +387,7 @@ function printStats() {
console.log(`Monitoring up to ${ARRIVAL_STOP_CAP} key stops for arrival data`);
console.log(`\nCycles completed: ${stats.cycles}`);
console.log(`Vehicle positions tracked: ${stats.vehiclesTracked}`);
console.log(`Full vehicle snapshots logged: ${stats.vehicleSnapshots}`);
console.log(`Arrival predictions logged: ${stats.arrivalsLogged}`);
console.log(`Errors: ${stats.errors}`);
console.log(`Last update: ${stats.lastUpdate.toLocaleString()}`);
@@ -396,9 +426,17 @@ async function main() {
}, 5 * 60 * 1000);
// Handle graceful shutdown
process.on('SIGINT', () => {
process.on('SIGINT', async () => {
console.log('\n\nShutting down tracker...');
printStats();
await closeDatabase();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\n\nReceived SIGTERM, closing tracker...');
printStats();
await closeDatabase();
process.exit(0);
});
}

View File

@@ -1,24 +0,0 @@
services:
timescaledb:
image: timescale/timescaledb:latest-pg17
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-example}
POSTGRES_DB: ${POSTGRES_DB:-iot_data}
ports:
- "5432:5432"
volumes:
- timescaledb-data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:latest
ports:
- "6379:6379"
restart: unless-stopped
volumes:
- redis-data:/data
volumes:
timescaledb-data:
redis-data:

File diff suppressed because it is too large Load Diff

2058
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,10 @@
"description": "Real-time bus tracking for Skopje public transport",
"main": "server.ts",
"scripts": {
"start": "bun run server.ts",
"track": "bun run background-tracker.ts"
"start": "bun run bus-tracker-json.ts",
"tracker": "bun run bus-tracker-json.ts",
"track": "bun run background-tracker.ts",
"typecheck": "bunx tsc --noEmit"
},
"keywords": [
"skopje",
@@ -19,8 +21,10 @@
"author": "",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"express": "^4.18.2",
"pg": "^8.18.0",
"hyparquet": "^1.25.1",
"hyparquet-writer": "^0.13.0",
"protobufjs": "^7.5.4"
},
"optionalDependencies": {
@@ -29,9 +33,7 @@
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@types/pg": "^8.11.0",
"protobufjs-cli": "^2.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3"
}
}

View File

@@ -1,500 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bus Analytics - Skopje</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 28px;
margin-bottom: 5px;
}
.header .subtitle {
opacity: 0.9;
font-size: 14px;
}
.nav {
background: white;
padding: 10px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.nav a {
color: #667eea;
text-decoration: none;
margin-right: 20px;
font-weight: 600;
}
.nav a:hover {
text-decoration: underline;
}
.controls {
background: white;
padding: 15px 20px;
margin: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 12px;
color: #666;
font-weight: 600;
}
.control-group select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 250px;
}
.btn {
padding: 8px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
margin-top: 20px;
}
.btn:hover {
background: #5568d3;
}
.container {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stats-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.stats-card h3 {
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.stats-card .value {
font-size: 32px;
font-weight: bold;
color: #667eea;
}
.stats-card .unit {
font-size: 14px;
color: #999;
}
.charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 20px;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.chart-container h2 {
font-size: 18px;
color: #333;
margin-bottom: 15px;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.db-stats {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin: 20px;
border-radius: 4px;
}
.db-stats h3 {
color: #333;
margin-bottom: 10px;
font-size: 16px;
}
.db-stats p {
color: #666;
font-size: 14px;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="header">
<h1>Bus Analytics Dashboard</h1>
<div class="subtitle">Historical performance and delay analysis</div>
</div>
<div class="nav">
<a href="/">← Back to Live Tracker</a>
<a href="#" onclick="loadData(); return false;">Refresh Data</a>
</div>
<div class="controls">
<div class="control-group">
<label for="routeId">Bus Route</label>
<select id="routeId">
<option value="">Loading routes...</option>
</select>
</div>
<button class="btn" onclick="loadAnalytics()">Load Analytics</button>
</div>
<div class="container">
<div id="dbStats" class="db-stats" style="display: none;">
<h3>Database Status</h3>
<p id="dbInfo">Loading...</p>
</div>
<div id="statsCards" class="stats-cards">
<!-- Stats will be inserted here -->
</div>
<div class="charts">
<div class="chart-container">
<h2>Delay Distribution</h2>
<canvas id="delayChart"></canvas>
</div>
<div class="chart-container">
<h2>Hourly Service Pattern (Last 7 Days)</h2>
<canvas id="hourlyChart"></canvas>
</div>
<div class="chart-container">
<h2>On-Time Performance</h2>
<canvas id="performanceChart"></canvas>
</div>
</div>
</div>
<script>
let currentRouteId = null;
let delayChart = null;
let hourlyChart = null;
let performanceChart = null;
// Load stops and routes
async function loadRoutes() {
try {
const response = await fetch('/api/routes');
const routes = await response.json();
const routeSelect = document.getElementById('routeId');
routes.sort((a, b) => {
const aNum = parseInt(a.shortName) || 9999;
const bNum = parseInt(b.shortName) || 9999;
return aNum - bNum;
});
routeSelect.innerHTML = '<option value="">Select a route...</option>' +
routes.map(route =>
`<option value="${route.id}">${route.shortName} - ${route.longName}</option>`
).join('');
} catch (error) {
console.error('Failed to load routes:', error);
}
}
// Load database statistics
async function loadDbStats() {
try {
const response = await fetch('/api/stats/db');
const stats = await response.json();
document.getElementById('dbStats').style.display = 'block';
document.getElementById('dbInfo').innerHTML = `
<strong>Records:</strong> ${stats.vehiclePositions.toLocaleString()} vehicle positions,
${stats.arrivalRecords.toLocaleString()} arrival records<br>
<strong>Time Range:</strong> ${stats.oldestRecord ? new Date(stats.oldestRecord).toLocaleString() : 'N/A'}
to ${stats.newestRecord ? new Date(stats.newestRecord).toLocaleString() : 'N/A'}
`;
} catch (error) {
console.error('Failed to load DB stats:', error);
}
}
// Load analytics for selected route
async function loadAnalytics() {
const routeId = document.getElementById('routeId').value;
if (!routeId) {
alert('Please select a route');
return;
}
currentRouteId = routeId;
await Promise.all([
loadDelayStats(),
loadHourlyPattern(),
loadDbStats()
]);
}
// Load delay statistics
async function loadDelayStats() {
try {
const response = await fetch(`/api/stats/route/${currentRouteId}/delays?hours=24`);
const stats = await response.json();
// Check if we have data
if (!stats.total_arrivals || stats.total_arrivals === 0) {
document.getElementById('statsCards').innerHTML = `
<div class="stats-card" style="grid-column: 1 / -1; text-align: center; padding: 40px;">
<h3 style="color: #999; font-size: 18px; margin-bottom: 10px;">No Arrival Data Yet</h3>
<p style="color: #666; font-size: 14px;">Start tracking a stop to collect arrival data.<br>Go to the <a href="/" style="color: #667eea;">Live Tracker</a> and select a stop and route.</p>
</div>
`;
// Clear charts
if (performanceChart) performanceChart.destroy();
if (delayChart) delayChart.destroy();
if (hourlyChart) hourlyChart.destroy();
return;
}
// Update stats cards
const cardsHTML = `
<div class="stats-card">
<h3>Total Arrivals (24h)</h3>
<div class="value">${stats.total_arrivals || 0}</div>
</div>
<div class="stats-card">
<h3>Average Delay</h3>
<div class="value">${stats.avg_delay ? Math.round(stats.avg_delay / 60) : 0}</div>
<div class="unit">minutes</div>
</div>
<div class="stats-card">
<h3>On-Time Rate</h3>
<div class="value">${stats.total_arrivals > 0 ? Math.round((stats.on_time_count / stats.total_arrivals) * 100) : 0}%</div>
</div>
<div class="stats-card">
<h3>Maximum Delay</h3>
<div class="value">${stats.max_delay ? Math.round(stats.max_delay / 60) : 0}</div>
<div class="unit">minutes</div>
</div>
`;
document.getElementById('statsCards').innerHTML = cardsHTML;
// Update performance chart
if (performanceChart) {
performanceChart.destroy();
}
const ctx = document.getElementById('performanceChart');
performanceChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['On Time (±2 min)', 'Late (>2 min)', 'Early (<-2 min)'],
datasets: [{
data: [
stats.on_time_count || 0,
stats.late_count || 0,
stats.early_count || 0
],
backgroundColor: [
'#4caf50',
'#ff6b6b',
'#2196f3'
]
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
// Update delay distribution chart
if (delayChart) {
delayChart.destroy();
}
const delayCtx = document.getElementById('delayChart');
delayChart = new Chart(delayCtx, {
type: 'bar',
data: {
labels: ['Early', 'On Time', 'Late'],
datasets: [{
label: 'Number of Arrivals',
data: [
stats.early_count || 0,
stats.on_time_count || 0,
stats.late_count || 0
],
backgroundColor: [
'#2196f3',
'#4caf50',
'#ff6b6b'
]
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
} catch (error) {
console.error('Failed to load delay stats:', error);
}
}
// Load hourly pattern
async function loadHourlyPattern() {
try {
const response = await fetch(`/api/stats/route/${currentRouteId}/hourly?days=7`);
const pattern = await response.json();
if (hourlyChart) {
hourlyChart.destroy();
}
const ctx = document.getElementById('hourlyChart');
hourlyChart = new Chart(ctx, {
type: 'line',
data: {
labels: pattern.map(p => `${p.hour}:00`),
datasets: [
{
label: 'Average Delay (seconds)',
data: pattern.map(p => p.avg_delay || 0),
borderColor: '#ff6b6b',
backgroundColor: 'rgba(255, 107, 107, 0.1)',
yAxisID: 'y',
},
{
label: 'Number of Arrivals',
data: pattern.map(p => p.arrival_count || 0),
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
yAxisID: 'y1',
}
]
},
options: {
responsive: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Delay (seconds)'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Arrivals'
},
grid: {
drawOnChartArea: false,
},
},
}
}
});
} catch (error) {
console.error('Failed to load hourly pattern:', error);
}
}
// Initialize
async function init() {
await loadRoutes();
await loadDbStats();
// Load default route analytics if available
const response = await fetch('/api/config');
const config = await response.json();
if (config.defaults.routeId) {
document.getElementById('routeId').value = config.defaults.routeId;
await loadAnalytics();
}
}
init();
</script>
</body>
</html>

View File

@@ -1,642 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skopje Bus Tracker</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 28px;
margin-bottom: 5px;
}
.header .subtitle {
opacity: 0.9;
font-size: 14px;
}
.controls {
background: white;
padding: 15px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 12px;
color: #666;
font-weight: 600;
}
.control-group input,
.control-group select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 250px;
}
.control-group input:focus,
.control-group select:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 8px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: background 0.2s;
margin-top: 20px;
}
.btn:hover {
background: #5568d3;
}
.container {
display: flex;
height: calc(100vh - 180px);
}
.sidebar {
width: 350px;
background: white;
padding: 20px;
overflow-y: auto;
box-shadow: 2px 0 10px rgba(0,0,0,0.1);
}
.map-container {
flex: 1;
position: relative;
}
#map {
width: 100%;
height: 100%;
}
.info-card {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.info-card h3 {
color: #333;
margin-bottom: 10px;
font-size: 16px;
}
.info-card p {
color: #666;
font-size: 14px;
margin: 5px 0;
}
.arrivals-section {
margin-top: 20px;
}
.arrivals-section h2 {
color: #333;
font-size: 18px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.arrival-item {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
transition: all 0.3s;
}
.arrival-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.arrival-item.approaching {
border-left: 4px solid #ff6b6b;
background: #fff5f5;
}
.arrival-item.soon {
border-left: 4px solid #ffa500;
background: #fff8e1;
}
.arrival-time {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.arrival-minutes {
color: #667eea;
font-weight: 600;
font-size: 16px;
}
.arrival-details {
font-size: 13px;
color: #666;
margin-top: 8px;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
margin-right: 5px;
}
.badge-live {
background: #4caf50;
color: white;
}
.badge-scheduled {
background: #9e9e9e;
color: white;
}
.badge-late {
background: #ff6b6b;
color: white;
}
.badge-early {
background: #2196f3;
color: white;
}
.badge-ontime {
background: #4caf50;
color: white;
}
.no-buses {
text-align: center;
padding: 30px;
color: #999;
}
.loading {
text-align: center;
padding: 20px;
color: #667eea;
}
.last-update {
text-align: center;
color: #999;
font-size: 12px;
padding: 10px;
border-top: 1px solid #eee;
margin-top: 10px;
}
.stats {
display: flex;
justify-content: space-around;
margin-bottom: 20px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 5px;
}
.nav-link {
padding: 8px 20px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
text-decoration: none;
color: white;
font-size: 14px;
white-space: nowrap;
}
.nav-link:hover {
background: rgba(255,255,255,0.3);
}
</style>
</head>
<body>
<div class="header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>Skopje Bus Tracker</h1>
<div class="subtitle" id="routeInfo">Select stop and route to track</div>
</div>
<a href="/analytics.html" class="nav-link">View Analytics</a>
</div>
</div>
<div class="controls">
<div class="control-group">
<label for="stopId">Bus Stop</label>
<select id="stopId">
<option value="">Loading stops...</option>
</select>
</div>
<div class="control-group">
<label for="routeId">Bus Route</label>
<select id="routeId">
<option value="">Loading routes...</option>
</select>
</div>
<button class="btn" onclick="loadTracker()">Track Bus</button>
</div>
<div class="container">
<div class="sidebar">
<div class="info-card">
<h3>Target Stop</h3>
<p id="stopName">Loading...</p>
<p id="stopCode"></p>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="busCount">-</div>
<div class="stat-label">Active Buses</div>
</div>
<div class="stat-item">
<div class="stat-value" id="arrivalCount">-</div>
<div class="stat-label">Upcoming</div>
</div>
</div>
<div class="arrivals-section">
<h2>Upcoming Arrivals</h2>
<div id="arrivalsList" class="loading">
Loading arrivals...
</div>
</div>
<div class="last-update" id="lastUpdate">
Last updated: -
</div>
</div>
<div class="map-container">
<div id="map"></div>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
let stopMarker;
let vehicleMarkers = [];
let config = null;
let currentStopId = null;
let currentRouteId = null;
let refreshInterval = null;
// Get URL parameters
function getUrlParams() {
const params = new URLSearchParams(window.location.search);
return {
stopId: params.get('stopId'),
routeId: params.get('routeId')
};
}
// Update URL without reload
function updateUrl(stopId, routeId) {
const url = new URL(window.location);
url.searchParams.set('stopId', stopId);
url.searchParams.set('routeId', routeId);
window.history.pushState({}, '', url);
}
// Initialize map
async function initMap() {
if (map) {
map.remove();
}
// Get configuration with selected stop and route
const queryParams = currentStopId && currentRouteId
? `?stopId=${currentStopId}&routeId=${currentRouteId}`
: '';
const response = await fetch('/api/config' + queryParams);
config = await response.json();
// Update header
document.getElementById('routeInfo').textContent =
`${config.route.shortName} - ${config.route.longName} at ${config.stop.name}`;
// Update sidebar info
document.getElementById('stopName').textContent = config.stop.name;
document.getElementById('stopCode').textContent = `Stop Code: ${config.stop.code}`;
// Initialize map centered on stop
map = L.map('map').setView([config.stop.lat, config.stop.lon], 14);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
// Add stop marker
const stopIcon = L.divIcon({
html: '<div style="background: #667eea; width: 30px; height: 30px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.3);"></div>',
className: '',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
stopMarker = L.marker([config.stop.lat, config.stop.lon], { icon: stopIcon })
.addTo(map)
.bindPopup(`<b>${config.stop.name}</b><br>Stop Code: ${config.stop.code}`);
}
// Update vehicle positions
async function updateVehicles() {
if (!currentRouteId) return;
try {
const response = await fetch(`/api/vehicles?routeId=${currentRouteId}`);
const vehicles = await response.json();
document.getElementById('busCount').textContent = vehicles.length;
// Remove old markers
vehicleMarkers.forEach(marker => map.removeLayer(marker));
vehicleMarkers = [];
// Add new markers
vehicles.forEach(vehicle => {
const label = vehicle.label || vehicle.vehicleId || 'Bus';
const speedKmh = vehicle.speed ? (vehicle.speed * 3.6).toFixed(1) : '0.0'; // Convert m/s to km/h
// Create arrow icon rotated by bearing
const busIcon = L.divIcon({
html: `<div style="transform: rotate(${vehicle.bearing}deg); width: 30px; height: 30px;">
<svg width="30" height="30" viewBox="0 0 30 30">
<path d="M15 5 L25 25 L15 20 L5 25 Z" fill="#ff6b6b" stroke="white" stroke-width="2"/>
</svg>
</div>`,
className: '',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([vehicle.lat, vehicle.lon], { icon: busIcon })
.addTo(map)
.bindPopup(`
<b>Bus ${label}</b><br>
Vehicle ID: ${vehicle.vehicleId}<br>
Speed: ${speedKmh} km/h<br>
Bearing: ${vehicle.bearing}°<br>
Status: ${vehicle.currentStatus}<br>
Updated: ${new Date(vehicle.timestamp).toLocaleTimeString()}
`);
vehicleMarkers.push(marker);
});
} catch (error) {
console.error('Failed to update vehicles:', error);
}
}
// Update arrivals
async function updateArrivals() {
if (!currentStopId || !currentRouteId) return;
try {
const response = await fetch(`/api/arrivals?stopId=${currentStopId}&routeId=${currentRouteId}`);
const arrivals = await response.json();
const arrivalsList = document.getElementById('arrivalsList');
document.getElementById('arrivalCount').textContent = arrivals.length;
if (arrivals.length === 0) {
arrivalsList.innerHTML = '<div class="no-buses">No buses scheduled in the next 90 minutes</div>';
return;
}
arrivalsList.innerHTML = arrivals.slice(0, 10).map((arrival, index) => {
const arrivalTime = new Date(arrival.arrivalTime);
const delayMin = Math.floor(arrival.delaySeconds / 60);
let delayBadge = '';
if (delayMin > 2) {
delayBadge = `<span class="badge badge-late">${delayMin}min late</span>`;
} else if (delayMin < -2) {
delayBadge = `<span class="badge badge-early">${Math.abs(delayMin)}min early</span>`;
} else {
delayBadge = `<span class="badge badge-ontime">On time</span>`;
}
const realtimeBadge = arrival.isRealtime
? '<span class="badge badge-live">LIVE</span>'
: '<span class="badge badge-scheduled">SCHEDULED</span>';
let cssClass = 'arrival-item';
if (arrival.minutesUntil <= 2) {
cssClass += ' approaching';
} else if (arrival.minutesUntil <= 10) {
cssClass += ' soon';
}
const minutesText = arrival.minutesUntil <= 0
? 'ARRIVING NOW'
: `in ${arrival.minutesUntil} min`;
return `
<div class="${cssClass}">
<div class="arrival-time">${arrivalTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })}</div>
<div class="arrival-minutes">${minutesText}</div>
<div class="arrival-details">
${realtimeBadge}
${delayBadge}
<br>
Direction: ${arrival.headsign}
</div>
</div>
`;
}).join('');
document.getElementById('lastUpdate').textContent =
`Last updated: ${new Date().toLocaleTimeString()}`;
} catch (error) {
console.error('Failed to update arrivals:', error);
document.getElementById('arrivalsList').innerHTML =
'<div class="no-buses">Failed to load arrivals</div>';
}
}
// Load tracker for selected stop and route
async function loadTracker() {
const stopId = document.getElementById('stopId').value.trim();
const routeId = document.getElementById('routeId').value.trim();
if (!stopId || !routeId) {
alert('Please select both a stop and a route');
return;
}
// Clear existing interval
if (refreshInterval) {
clearInterval(refreshInterval);
}
// Update current selection
currentStopId = stopId;
currentRouteId = routeId;
// Update URL
updateUrl(stopId, routeId);
// Initialize tracker
document.getElementById('arrivalsList').innerHTML = '<div class="loading">Loading arrivals...</div>';
try {
await initMap();
await updateVehicles();
await updateArrivals();
// Refresh data every 5 seconds
refreshInterval = setInterval(async () => {
await updateVehicles();
await updateArrivals();
}, 5000);
} catch (error) {
console.error('Failed to load tracker:', error);
alert('Failed to load tracker. Please try another stop or route.');
}
}
// Load stops and routes
async function loadStopsAndRoutes() {
try {
// Load stops
const stopsResponse = await fetch('/api/stops');
const stops = await stopsResponse.json();
const stopSelect = document.getElementById('stopId');
stops.sort((a, b) => a.name.localeCompare(b.name));
stopSelect.innerHTML = '<option value="">Select a stop...</option>' +
stops.map(stop =>
`<option value="${stop.id}">${stop.name} (${stop.code})</option>`
).join('');
// Load routes
const routesResponse = await fetch('/api/routes');
const routes = await routesResponse.json();
const routeSelect = document.getElementById('routeId');
routes.sort((a, b) => {
const aNum = parseInt(a.shortName) || 9999;
const bNum = parseInt(b.shortName) || 9999;
return aNum - bNum;
});
routeSelect.innerHTML = '<option value="">Select a route...</option>' +
routes.map(route =>
`<option value="${route.id}">${route.shortName} - ${route.longName}</option>`
).join('');
} catch (error) {
console.error('Failed to load stops and routes:', error);
}
}
// Start application
async function start() {
// Load stops and routes first
await loadStopsAndRoutes();
// Check URL parameters
const params = getUrlParams();
if (params.stopId && params.routeId) {
// Load from URL
currentStopId = params.stopId;
currentRouteId = params.routeId;
document.getElementById('stopId').value = params.stopId;
document.getElementById('routeId').value = params.routeId;
await loadTracker();
} else {
// Load defaults
const response = await fetch('/api/config');
const defaultConfig = await response.json();
document.getElementById('stopId').value = defaultConfig.defaults.stopId;
document.getElementById('routeId').value = defaultConfig.defaults.routeId;
// Auto-load defaults
await loadTracker();
}
}
start();
</script>
</body>
</html>

416
server.ts
View File

@@ -1,416 +0,0 @@
import express, { Request, Response } from 'express';
import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
import { loadGtfsStops, loadGtfsRoutes } from './lib/gtfs';
import { config, StopConfig, RouteConfig } from './config';
import {
initDatabase,
logVehiclePosition,
logVehiclePositions,
logArrival,
getDatabaseStats,
cleanupOldData,
getVehicleHistory,
getRouteVehiclePositions,
getStopArrivalHistory,
getRouteDelayStats,
getStopDelayStats,
getRouteHourlyPattern
} from './lib/database';
const app = express();
// Initialize database
try {
initDatabase();
console.log('Database initialized successfully');
} catch (error) {
console.error('Failed to initialize database:', error);
console.log('Continuing without data logging...');
}
// Load GTFS data
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
// Serve static files
import * as path from 'path';
app.use(express.static(path.join(__dirname, 'public')));
// Serve OpenAPI specification
app.get('/openapi.yaml', (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, 'openapi.yaml'));
});
// API Endpoints
app.get('/api/config', (req: Request, res: Response) => {
const stopId = (req.query.stopId as string) || config.defaultStop.stopId;
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
const stop = stops.get(stopId);
const route = routes.get(routeId);
res.json({
stop: {
id: stopId,
code: stop?.stop_code,
name: stop?.stop_name,
lat: stop?.stop_lat,
lon: stop?.stop_lon,
},
route: {
id: routeId,
shortName: route?.route_short_name,
longName: route?.route_long_name,
},
defaults: {
stopId: config.defaultStop.stopId,
routeId: config.defaultRoute.routeId,
}
});
});
app.get('/api/stops', (req: Request, res: Response) => {
const stopsList = Array.from(stops.values()).map(stop => ({
id: stop.stop_id,
code: stop.stop_code,
name: stop.stop_name,
lat: stop.stop_lat,
lon: stop.stop_lon,
}));
res.json(stopsList);
});
app.get('/api/routes', (req: Request, res: Response) => {
const routesList = Array.from(routes.values()).map(route => ({
id: route.route_id,
shortName: route.route_short_name,
longName: route.route_long_name,
}));
res.json(routesList);
});
app.get('/api/arrivals', async (req: Request, res: Response) => {
try {
const stopId = (req.query.stopId as string) || config.defaultStop.stopId;
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
const stop = stops.get(stopId);
if (!stop) {
return res.status(404).json({ error: `Stop ${stopId} not found` });
}
const radius = 50;
const nearbyUrl = `${config.baseUrl}/transport/planner/stops/nearbyTimes?latitude=${stop.stop_lat}&longitude=${stop.stop_lon}&radius=${radius}`;
const response = await fetch(nearbyUrl);
const nearbyData = await response.json() as any[];
const now = new Date();
const arrivals: any[] = [];
for (const stopData of nearbyData) {
if (stopData.id.toString() !== stopId) continue;
for (const pattern of stopData.patterns) {
if (pattern.routeId.toString() !== routeId) continue;
const routeInfo = routes.get(pattern.routeId.toString());
for (const stopTime of pattern.stopTimes) {
const serviceDay = new Date(stopTime.serviceDay * 1000);
const arrivalTime = new Date(serviceDay.getTime() + stopTime.realtimeArrival * 1000);
const scheduledTime = new Date(serviceDay.getTime() + stopTime.scheduledArrival * 1000);
const minutesUntil = Math.floor((arrivalTime.getTime() - now.getTime()) / 60000);
if (minutesUntil >= -2 && minutesUntil <= config.tracking.minutesAhead) {
arrivals.push({
arrivalTime: arrivalTime.toISOString(),
scheduledTime: scheduledTime.toISOString(),
minutesUntil: minutesUntil,
delaySeconds: stopTime.arrivalDelay,
headsign: stopTime.headsign,
isRealtime: stopTime.realtime,
realtimeState: stopTime.realtimeState,
});
// Log arrival to database (use scheduled time as primary timestamp to avoid duplicates)
try {
logArrival({
timestamp: scheduledTime.getTime(),
stopId,
routeId,
scheduledTime: scheduledTime.getTime(),
predictedTime: arrivalTime.getTime(),
delaySeconds: stopTime.arrivalDelay,
isRealtime: stopTime.realtime,
headsign: stopTime.headsign,
});
} catch (dbError) {
// Fail silently if database is not available
}
}
}
}
}
arrivals.sort((a, b) => new Date(a.arrivalTime).getTime() - new Date(b.arrivalTime).getTime());
res.json(arrivals);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch arrivals' });
}
});
app.get('/api/vehicles', async (req: Request, res: Response) => {
try {
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
// Fetch all vehicles from JSON API
const vehiclesResponse = await fetch(config.apiEndpoints.vehiclesJson);
if (!vehiclesResponse.ok) {
throw new Error(`HTTP error! status: ${vehiclesResponse.status}`);
}
const allVehicles = await vehiclesResponse.json() as any[];
// Fetch trip updates to find which vehicles are on our route
const tripUpdatesResponse = await fetch(config.apiEndpoints.gtfsRtTripUpdates);
if (!tripUpdatesResponse.ok) {
console.warn('Could not fetch trip updates, returning all vehicles');
// Return all vehicles with basic info
res.json(allVehicles.slice(0, 20).map((v: any) => ({
id: v.id,
vehicleId: v.identificationNumber,
label: v.inventoryNumber,
lat: v.positionLatitude,
lon: v.positionLongitude,
bearing: v.positionBearing,
speed: v.positionSpeed,
timestamp: v.positionModifiedAt,
tripId: '',
currentStopSequence: 0,
currentStatus: 'UNKNOWN',
})));
return;
}
const buffer = await tripUpdatesResponse.arrayBuffer();
if (buffer.byteLength === 0) {
console.warn('Empty trip updates feed');
res.json([]);
return;
}
let feed;
try {
feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
new Uint8Array(buffer)
);
} catch (decodeError) {
console.error('Failed to decode GTFS-RT feed:', decodeError);
res.json([]);
return;
}
// Find trip IDs and vehicle IDs for our route
const routeVehicleIds = new Set<string>();
const vehicleTripMap = new Map<string, any>();
for (const entity of feed.entity) {
if (!entity.tripUpdate) continue;
const tripUpdate = entity.tripUpdate;
// Filter by target route
if (tripUpdate.trip?.routeId !== routeId) continue;
// Get vehicle ID if available
if (tripUpdate.vehicle?.id) {
routeVehicleIds.add(tripUpdate.vehicle.id);
vehicleTripMap.set(tripUpdate.vehicle.id, {
tripId: tripUpdate.trip.tripId,
routeId: tripUpdate.trip.routeId,
});
}
// Also try vehicle label
if (tripUpdate.vehicle?.label) {
routeVehicleIds.add(tripUpdate.vehicle.label);
vehicleTripMap.set(tripUpdate.vehicle.label, {
tripId: tripUpdate.trip.tripId,
routeId: tripUpdate.trip.routeId,
});
}
}
// Filter vehicles by route
const activeVehicles: any[] = [];
for (const vehicle of allVehicles) {
// Try to match by identification number or inventory number
const vehicleId = vehicle.identificationNumber || vehicle.inventoryNumber?.toString();
if (vehicleId && (routeVehicleIds.has(vehicleId) || routeVehicleIds.has(vehicle.inventoryNumber?.toString()))) {
const tripInfo = vehicleTripMap.get(vehicleId) || vehicleTripMap.get(vehicle.inventoryNumber?.toString());
activeVehicles.push({
id: vehicle.id,
vehicleId: vehicle.identificationNumber,
label: vehicle.inventoryNumber,
lat: vehicle.positionLatitude,
lon: vehicle.positionLongitude,
bearing: vehicle.positionBearing,
speed: vehicle.positionSpeed,
timestamp: vehicle.positionModifiedAt,
tripId: tripInfo?.tripId || '',
currentStopSequence: 0,
currentStatus: vehicle.status === 2 ? 'IN_TRANSIT_TO' : 'UNKNOWN',
});
}
}
console.log(`Found ${routeVehicleIds.size} vehicle IDs for route ${routeId}`);
console.log(`Matched ${activeVehicles.length} vehicles from JSON API`);
// Log vehicle positions to database
try {
const positions = activeVehicles.map(v => ({
timestamp: Date.now(),
vehicleId: v.vehicleId || v.label,
routeId,
tripId: v.tripId,
latitude: v.lat,
longitude: v.lon,
speed: v.speed,
bearing: v.bearing,
currentStatus: v.currentStatus,
}));
logVehiclePositions(positions);
} catch (dbError) {
// Fail silently if database is not available
}
res.json(activeVehicles);
} catch (error) {
console.error('Error fetching vehicles:', error);
res.status(500).json({ error: 'Failed to fetch vehicles', details: String(error) });
}
});
// Historical Data API Endpoints
app.get('/api/stats/db', async (req: Request, res: Response) => {
try {
const stats = await getDatabaseStats();
res.json(stats);
} catch (error) {
res.status(500).json({ error: 'Database not available' });
}
});
app.get('/api/history/vehicle/:vehicleId', async (req: Request, res: Response) => {
try {
const vehicleId = req.params.vehicleId;
const hours = parseInt(req.query.hours as string) || 24;
const endTime = Date.now();
const startTime = endTime - (hours * 60 * 60 * 1000);
const history = await getVehicleHistory(vehicleId, startTime, endTime);
res.json(history);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch vehicle history' });
}
});
app.get('/api/history/route/:routeId/vehicles', async (req: Request, res: Response) => {
try {
const routeId = req.params.routeId;
const hours = parseInt(req.query.hours as string) || 24;
const endTime = Date.now();
const startTime = endTime - (hours * 60 * 60 * 1000);
const positions = await getRouteVehiclePositions(routeId, startTime, endTime);
res.json(positions);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch route vehicle history' });
}
});
app.get('/api/history/stop/:stopId/arrivals', async (req: Request, res: Response) => {
try {
const stopId = req.params.stopId;
const routeId = (req.query.routeId as string) || '';
const hours = parseInt(req.query.hours as string) || 24;
const endTime = Date.now();
const startTime = endTime - (hours * 60 * 60 * 1000);
const arrivals = await getStopArrivalHistory(stopId, routeId, startTime, endTime);
res.json(arrivals);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch arrival history' });
}
});
app.get('/api/stats/route/:routeId/delays', async (req: Request, res: Response) => {
try {
const routeId = req.params.routeId;
const hours = parseInt(req.query.hours as string) || 24;
const stats = await getRouteDelayStats(routeId, hours);
res.json(stats);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch delay stats' });
}
});
app.get('/api/stats/stop/:stopId/delays', async (req: Request, res: Response) => {
try {
const stopId = req.params.stopId;
const hours = parseInt(req.query.hours as string) || 24;
const stats = await getStopDelayStats(stopId, hours);
res.json(stats);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch delay stats' });
}
});
app.get('/api/stats/route/:routeId/hourly', async (req: Request, res: Response) => {
try {
const routeId = req.params.routeId;
const days = parseInt(req.query.days as string) || 7;
const pattern = await getRouteHourlyPattern(routeId, days);
res.json(pattern);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch hourly pattern' });
}
});
// Start server
app.listen(config.server.port, () => {
console.log(`
===========================================================================
BUS TRACKER WEB APP
===========================================================================
Server running at: http://localhost:${config.server.port}
Pages:
- Main Tracker: http://localhost:${config.server.port}
- Analytics: http://localhost:${config.server.port}/analytics.html
- API Docs: http://localhost:${config.server.port}/api-docs.html
Features:
- Real-time bus arrivals and vehicle tracking
- Interactive map interface
- Historical analytics and delay statistics
- Complete REST API with OpenAPI specification
Press Ctrl+C to stop the server
===========================================================================
`);
});

View File

@@ -1,86 +0,0 @@
#!/usr/bin/env ts-node
/**
* Downloads and extracts GTFS static data
* Run: npm run setup-gtfs
*/
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import { execSync } from 'child_process';
const GTFS_ZIP_URL = 'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfs/gtfs.zip';
const GTFS_DIR = path.join(__dirname, 'gtfs');
const ZIP_FILE = path.join(__dirname, 'gtfs.zip');
async function downloadFile(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`Downloading GTFS data from ${url}...`);
const file = fs.createWriteStream(dest);
https.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
if (response.headers.location) {
https.get(response.headers.location, (redirectResponse) => {
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
}
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}
}).on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
});
}
async function extractZip(zipPath: string, destDir: string): Promise<void> {
console.log(`Extracting GTFS data to ${destDir}...`);
// Create directory if it doesn't exist
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// Use unzip command (available on most Linux/Mac systems)
try {
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
} catch (error) {
throw new Error('Failed to extract zip. Make sure unzip is installed.');
}
}
async function main() {
try {
// Download GTFS zip
await downloadFile(GTFS_ZIP_URL, ZIP_FILE);
console.log('✓ Download complete');
// Extract zip
await extractZip(ZIP_FILE, GTFS_DIR);
console.log('✓ Extraction complete');
// Clean up zip file
fs.unlinkSync(ZIP_FILE);
console.log('✓ Cleanup complete');
// List extracted files
const files = fs.readdirSync(GTFS_DIR);
console.log(`\n✓ GTFS data ready! Files extracted:\n${files.map(f => ` - ${f}`).join('\n')}`);
} catch (error) {
console.error('Error setting up GTFS data:', error);
process.exit(1);
}
}
main();

View File

@@ -3,6 +3,7 @@
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"types": ["node"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
@@ -16,6 +17,6 @@
"noImplicitAny": true,
"strictNullChecks": true
},
"include": ["*.ts"],
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}