Compare commits

4 Commits
main ... data

Author SHA1 Message Date
3cbaabed34 Updated prefix 2026-03-13 01:16:35 +01:00
3882e3b0a6 Cleanup and rename 2026-03-13 00:01:14 +01:00
c90be4a981 Saving GTFS-RT data to Parquet 2026-03-13 00:01:00 +01:00
afd195dab9 Basic data ingestion and analytics 2026-02-09 01:10:52 +01:00
14 changed files with 1422 additions and 3600 deletions

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# 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

146
README.md
View File

@@ -1,130 +1,68 @@
# 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.
- `index.ts`: continuous collector for multiple routes/stops.
- `lib/storage.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**
## Features
- **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
- 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
## Commands
Run single stop/route terminal tracker:
```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 start # Same as web
bun run bus-tracker-json.ts
```
### Finding Stop and Route IDs
Not sure which Stop ID or Route ID to use? Use the find command:
Run with custom stop and route IDs:
```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 "линија"
bun run bus-tracker-json.ts --stop 1571 --route 125
```
### 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:
Run background collection pipeline:
```bash
npm run tracker -- --stop <stopId> --route <routeId>
npm run tracker -- --help
bun run start
```
### API Endpoints
## Environment
Query parameters for custom tracking:
Copy `.env.example` to `.env` and adjust values as needed.
```
GET /api/config?stopId=1571&routeId=125
GET /api/arrivals?stopId=1571&routeId=125
GET /api/vehicles?routeId=125
```
Key variables:
## Configuration
- `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.
Edit [config.ts](config.ts) to set defaults:
## Scripts
```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,
},
};
```
- `bun run start`: collector entrypoint (`index.ts`).
- `bun run typecheck`: TypeScript no-emit check.
## Structure
## Notes
```
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)
└── gtfs/ # Static data (auto-downloaded)
```
## 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 collector 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.

253
bun.lock
View File

@@ -5,15 +5,104 @@
"": {
"name": "skopje-bus-api-client",
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"express": "^4.18.2",
"hyparquet": "^1.25.1",
"hyparquet-writer": "^0.13.0",
"protobufjs": "^7.5.4",
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"protobufjs-cli": "^2.0.0",
"typescript": "^5.9.3",
},
"optionalDependencies": {
"gtfs-realtime-bindings": "^1.1.1",
},
},
},
"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=="],
@@ -44,14 +133,136 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
"@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=="],
"@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=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.8", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/node": ["@types/node@20.19.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="],
"@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=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -70,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=="],
@@ -138,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=="],
@@ -168,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=="],
@@ -232,13 +453,15 @@
"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=="],
"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=="],
"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=="],
"protobufjs-cli": ["protobufjs-cli@2.0.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-/QGByFXSLY9Was5Oq8yKn5lu1pMywGMHgE0fDR9b4nZFTvaWJonHiJxT3HHu2ZuOQAxd7ufYyQtW8Q1ZgW+gPg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@@ -278,16 +501,22 @@
"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=="],
"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=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
@@ -308,8 +537,30 @@
"xmlcreate": ["xmlcreate@2.0.4", "", {}, "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg=="],
"@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=="],
"@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=="],
"escodegen/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
"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();

430
index.ts Normal file
View File

@@ -0,0 +1,430 @@
import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
import { config } from './config';
import { GtfsRoute, GtfsStop, loadGtfsRoutes, loadGtfsStops } from './lib/gtfs';
import {
initStorage,
logVehiclePositions,
logVehicleFeedSnapshot,
logArrival,
closeStorage,
VehicleFeedSnapshot,
VehiclePosition
} from './lib/storage';
// Popular routes to track
const TRACKED_ROUTES = [
{ id: '245', name: 'Route 2' },
{ id: '121', name: 'Route 4' },
{ id: '123', name: 'Route 5' },
{ id: '125', name: 'Route 7' },
{ id: '129', name: 'Route 15' },
{ id: '134', name: 'Route 21' },
{ id: '136', name: 'Route 22' },
{ id: '138', name: 'Route 24' },
// Private routes (П)
{ id: '203', name: 'Route 22 П' },
{ id: '204', name: 'Route 12 П' },
];
const MONITORED_STOPS = [
// Central area and major transit hubs
'1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15',
'16', '17', '18', '19', '20', '21',
// Transit centers and bus stations
'47', '48', '49', '50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '60',
'61', '62', '63', '64', '65', '66', '67', '68',
// Kisela Voda area
'174', '175', '176', '177', '178', '179', '180', '181',
// Gjorche Petrov corridor
'246', '247', '248', '249', '250', '251', '252', '253', '254', '255', '256', '257',
'258', '259', '260', '261', '262', '263', '264', '265', '266', '267', '268', '269',
// Karposh areas
'270', '271', '272', '273', '274', '275', '276', '277', '278', '279', '280', '281',
'282', '283', '284', '285', '286', '287', '288', '289',
];
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';
const VERBOSE_TRACKER_LOGS = (process.env.VERBOSE_TRACKER_LOGS ?? 'false').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>();
for (const stop of stops.values()) {
if (stop.stop_code) {
stopsByCode.set(stop.stop_code, stop);
}
}
// Load GTFS routes data to map route short names back to IDs
const routes = loadGtfsRoutes();
const trackedRouteIds = new Set(TRACKED_ROUTES.map(r => r.id));
const routeIdByShortName = new Map<string, string>();
const routeIdByShortNameNormalized = new Map<string, string>();
function normalizeRouteKey(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
for (const routeId of trackedRouteIds) {
const route = routes.get(routeId);
if (route?.route_short_name) {
const shortName = route.route_short_name;
routeIdByShortName.set(shortName, routeId);
routeIdByShortNameNormalized.set(normalizeRouteKey(shortName), routeId);
}
}
// Fetch and process vehicle positions
async function trackVehicles() {
try {
console.log(`[${new Date().toISOString()}] Fetching vehicle data...`);
// 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[];
if (VERBOSE_TRACKER_LOGS) {
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);
if (!tripUpdatesResponse.ok) {
console.warn(' Could not fetch trip updates');
return;
}
const buffer = await tripUpdatesResponse.arrayBuffer();
if (buffer.byteLength === 0) {
console.warn(' Empty trip updates feed');
return;
}
let feed;
try {
feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
new Uint8Array(buffer)
);
} catch (decodeError) {
console.error(' Failed to decode GTFS-RT feed:', decodeError);
stats.errors++;
return;
}
// Build map of vehicle IDs to route IDs from GTFS trip updates.
const vehicleRouteMap = new Map<string, { routeId: string, tripId: string }>();
const trackedRouteSet = new Set(TRACKED_ROUTES.map(r => r.id));
for (const entity of feed.entity) {
if (!entity.tripUpdate) continue;
const tripUpdate = entity.tripUpdate;
const routeId = tripUpdate.trip?.routeId;
// 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;
const vehicleLabel = tripUpdate.vehicle?.label;
const tripInfo = {
routeId: routeId,
tripId: tripUpdate.trip.tripId || '',
};
if (vehicleId) {
vehicleRouteMap.set(vehicleId, tripInfo);
}
if (vehicleLabel && vehicleLabel !== vehicleId) {
vehicleRouteMap.set(vehicleLabel, tripInfo);
}
}
if (VERBOSE_TRACKER_LOGS) {
console.log(` Matched ${vehicleRouteMap.size} vehicles to GTFS routes`);
}
// Prepare vehicle positions.
const positions: VehiclePosition[] = [];
const now = Date.now();
for (const vehicle of allVehicles) {
const identificationNumber = vehicle.identificationNumber;
const inventoryNumber = vehicle.inventoryNumber?.toString();
if (!identificationNumber && !inventoryNumber) continue;
// 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',
});
}
// Persist current cycle positions
if (positions.length > 0) {
await logVehiclePositions(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`);
}
stats.cycles++;
stats.lastUpdate = new Date();
} catch (error) {
console.error(' [ERROR] Error tracking vehicles:', error);
stats.errors++;
}
}
// Track arrivals at monitored stops for traffic pattern analysis
async function trackArrivals() {
try {
const stopsToQuery = MONITORED_STOPS.slice(0, ARRIVAL_STOP_CAP);
console.log(`[${new Date().toISOString()}] Tracking arrivals at ${stopsToQuery.length} key stops...`);
const now = new Date();
let arrivalsLogged = 0;
let arrivalsFound = 0;
let duplicates = 0;
const unmatchedRoutes = new Map<string, number>();
const matchedRouteCounts = new Map<string, number>();
// Process each monitored stop
for (const stopKey of stopsToQuery) {
const stop = stops.get(stopKey) || stopsByCode.get(stopKey);
if (!stop) continue;
const stopId = stop.stop_id;
const stopCode = stop.stop_code;
try {
// Fetch arrivals near this stop
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);
if (!response.ok) continue;
const nearbyData = await response.json() as any[];
// Process arrivals for tracked routes
for (const stopData of nearbyData) {
const apiStopId = stopData.id?.toString();
if (apiStopId !== stopId && apiStopId !== stopCode) continue;
for (const pattern of stopData.patterns) {
const routeKey = pattern.routeId?.toString();
if (!routeKey) continue;
const normalizedRouteKey = normalizeRouteKey(routeKey);
// Only track our monitored routes (match by route_id or short name)
const canonicalRouteId = trackedRouteIds.has(routeKey)
? routeKey
: routeIdByShortName.get(routeKey) || routeIdByShortNameNormalized.get(normalizedRouteKey);
if (!canonicalRouteId) {
unmatchedRoutes.set(routeKey, (unmatchedRoutes.get(routeKey) || 0) + 1);
continue;
}
matchedRouteCounts.set(
canonicalRouteId,
(matchedRouteCounts.get(canonicalRouteId) || 0) + 1
);
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);
// Log arrivals in the next 60 minutes
if (minutesUntil >= -2 && minutesUntil <= 60) {
arrivalsFound++;
try {
await logArrival({
timestamp: scheduledTime.getTime(),
stopId,
routeId: canonicalRouteId,
scheduledTime: scheduledTime.getTime(),
predictedTime: arrivalTime.getTime(),
delaySeconds: stopTime.arrivalDelay,
isRealtime: stopTime.realtime,
headsign: stopTime.headsign,
});
arrivalsLogged++;
} catch (dbError) {
duplicates++;
}
}
}
}
}
// Small delay to avoid overwhelming the API
await new Promise(resolve => setTimeout(resolve, 100));
} catch (stopError) {
// Skip this stop if there's an error
continue;
}
}
if (arrivalsFound === 0) {
console.log(` [INFO] No arrivals found in time window`);
} else if (arrivalsLogged > 0) {
console.log(` [OK] Logged ${arrivalsLogged} new arrivals (${duplicates} duplicates skipped, ${arrivalsFound} total found)`);
stats.arrivalsLogged += arrivalsLogged;
} else {
console.log(` [INFO] Found ${arrivalsFound} arrivals but all were duplicates (already recorded)`);
}
if (VERBOSE_TRACKER_LOGS && matchedRouteCounts.size > 0) {
const matchedSummary = Array.from(matchedRouteCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([route, count]) => `${route}:${count}`)
.join(', ');
console.log(` [DEBUG] Matched route IDs: ${matchedSummary}`);
}
if (VERBOSE_TRACKER_LOGS && unmatchedRoutes.size > 0) {
const topUnmatched = Array.from(unmatchedRoutes.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([route, count]) => `${route}:${count}`)
.join(', ');
console.log(` [DEBUG] Unmatched route keys: ${topUnmatched}`);
}
} catch (error) {
console.error(' [ERROR] Error tracking arrivals:', error);
stats.errors++;
}
}
// Print statistics
function printStats() {
console.log('\n' + '='.repeat(60));
console.log('Background Tracker Statistics');
console.log('='.repeat(60));
console.log(`Tracking ${TRACKED_ROUTES.length} routes:`);
TRACKED_ROUTES.forEach(r => console.log(` - ${r.name} (ID: ${r.id})`));
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()}`);
console.log(`Refresh interval: ${REFRESH_INTERVAL / 1000}s`);
console.log('='.repeat(60) + '\n');
}
// Main loop
async function main() {
console.log('\nStarting Background Bus Tracker for Popular Routes & Stops\n');
// Initialize storage
try {
await initStorage();
} catch (error) {
console.error('Failed to initialize storage:', error);
console.log('Continuing without data logging...');
}
// Print tracked routes
printStats();
// Initial fetch
await trackVehicles();
await trackArrivals();
// Set up recurring fetch
setInterval(async () => {
await trackVehicles();
await trackArrivals();
}, REFRESH_INTERVAL);
// Print stats every 5 minutes
setInterval(() => {
printStats();
}, 5 * 60 * 1000);
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('\n\nShutting down tracker...');
printStats();
await closeStorage();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\n\nReceived SIGTERM, closing tracker...');
printStats();
await closeStorage();
process.exit(0);
});
}
main().catch(console.error);

668
lib/storage.ts Normal file
View File

@@ -0,0 +1,668 @@
import { promises as fs } from 'fs';
import * as fsSync from 'fs';
import * as path from 'path';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { ByteWriter, ParquetWriter } from 'hyparquet-writer';
import type { SchemaElement } from 'hyparquet';
import type { Writer } from 'hyparquet-writer';
const env = process.env;
const DATA_DIR = env.PARQUET_DIR ?? path.join(process.cwd(), 'data');
const ROLL_MINUTES = Number.parseInt(env.PARQUET_ROLL_MINUTES ?? '5', 10);
const ROLL_INTERVAL_MS = Math.max(1, Number.isNaN(ROLL_MINUTES) ? 5 : ROLL_MINUTES) * 60_000;
const OBJECT_STORAGE_BUCKET = env.S3_BUCKET;
const OBJECT_STORAGE_REGION = env.S3_REGION ?? 'us-east-1';
const OBJECT_STORAGE_ENDPOINT = env.S3_ENDPOINT;
const OBJECT_STORAGE_PREFIX = (env.S3_PREFIX ?? 'parquet').replace(/^\/+|\/+$/g, '');
const OBJECT_STORAGE_FORCE_PATH_STYLE = (env.S3_FORCE_PATH_STYLE ?? 'true').toLowerCase() === 'true';
const OBJECT_STORAGE_ACCESS_KEY = env.S3_ACCESS_KEY_ID;
const OBJECT_STORAGE_SECRET_KEY = env.S3_SECRET_ACCESS_KEY;
const OBJECT_STORAGE_ENABLED =
(env.S3_ENABLED ?? 'false').toLowerCase() === 'true' &&
!!OBJECT_STORAGE_BUCKET &&
!!OBJECT_STORAGE_ACCESS_KEY &&
!!OBJECT_STORAGE_SECRET_KEY;
const DELETE_LOCAL_AFTER_UPLOAD = (env.S3_DELETE_LOCAL_AFTER_UPLOAD ?? 'false').toLowerCase() === 'true';
const OBJECT_STORAGE_UPLOAD_RETRIES = Math.max(1, Number.parseInt(env.S3_UPLOAD_RETRIES ?? '3', 10) || 3);
const OBJECT_STORAGE_UPLOAD_RETRY_BASE_MS = Math.max(100, Number.parseInt(env.S3_UPLOAD_RETRY_BASE_MS ?? '1000', 10) || 1000);
type BasicType = 'BOOLEAN' | 'INT32' | 'INT64' | 'FLOAT' | 'DOUBLE' | 'STRING' | 'TIMESTAMP';
type ColumnSource = {
name: string;
data: Array<string | number | bigint | boolean | Date | null>;
type: BasicType;
nullable?: boolean;
};
type IncrementalWriter = {
write: (options: { columnData: ColumnSource[]; rowGroupSize?: number | number[]; pageSize?: number }) => void;
finish: () => void;
};
type SegmentMeta = {
segmentId: string;
vehicleFile: string;
arrivalFile: string;
snapshotFile: string;
vehicleRows: number;
arrivalRows: number;
snapshotRows: number;
};
const VEHICLE_SCHEMA: SchemaElement[] = [
{ name: 'root', num_children: 9 },
{ name: 'timestamp', type: 'INT64', repetition_type: 'REQUIRED' },
{ name: 'vehicleId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
{ name: 'routeId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
{ name: 'tripId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
{ name: 'latitude', type: 'DOUBLE', repetition_type: 'REQUIRED' },
{ name: 'longitude', type: 'DOUBLE', repetition_type: 'REQUIRED' },
{ name: 'speed', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'bearing', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'currentStatus', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
];
const ARRIVAL_SCHEMA: SchemaElement[] = [
{ name: 'root', num_children: 10 },
{ name: 'timestamp', type: 'INT64', repetition_type: 'REQUIRED' },
{ name: 'stopId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
{ name: 'routeId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
{ name: 'tripId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
{ name: 'scheduledTime', type: 'INT64', repetition_type: 'REQUIRED' },
{ name: 'predictedTime', type: 'INT64', repetition_type: 'OPTIONAL' },
{ name: 'actualTime', type: 'INT64', repetition_type: 'OPTIONAL' },
{ name: 'delaySeconds', type: 'INT32', repetition_type: 'OPTIONAL' },
{ name: 'isRealtime', type: 'BOOLEAN', repetition_type: 'REQUIRED' },
{ name: 'headsign', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
];
const SNAPSHOT_SCHEMA: SchemaElement[] = [
{ name: 'root', num_children: 10 },
{ name: 'timestamp', type: 'INT64', repetition_type: 'REQUIRED' },
{ name: 'sourceTimestamp', type: 'INT64', repetition_type: 'OPTIONAL' },
{ name: 'vehicleId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
{ name: 'inventoryNumber', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
{ name: 'latitude', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'longitude', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'speed', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'bearing', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'status', type: 'INT32', repetition_type: 'OPTIONAL' },
{ name: 'rawJson', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
];
let initialized = false;
let vehicleWriter: IncrementalWriter | undefined;
let arrivalWriter: IncrementalWriter | undefined;
let snapshotWriter: IncrementalWriter | undefined;
let vehicleWriteQueue: Promise<void> = Promise.resolve();
let arrivalWriteQueue: Promise<void> = Promise.resolve();
let snapshotWriteQueue: Promise<void> = Promise.resolve();
let rotationQueue: Promise<void> = Promise.resolve();
let rollTimer: ReturnType<typeof setInterval> | undefined;
let currentSegmentId = '';
let currentVehicleFile = '';
let currentArrivalFile = '';
let currentSnapshotFile = '';
let currentSegmentRows = { vehicle: 0, arrival: 0, snapshot: 0 };
const writes = {
vehiclePositions: 0,
arrivalRecords: 0,
vehicleSnapshots: 0,
};
const s3Client = OBJECT_STORAGE_ENABLED
? new S3Client({
region: OBJECT_STORAGE_REGION,
endpoint: OBJECT_STORAGE_ENDPOINT,
forcePathStyle: OBJECT_STORAGE_FORCE_PATH_STYLE,
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: OBJECT_STORAGE_ACCESS_KEY as string,
secretAccessKey: OBJECT_STORAGE_SECRET_KEY as string,
},
})
: undefined;
export interface VehiclePosition {
timestamp: number;
vehicleId: string;
routeId: string;
tripId?: string;
latitude: number;
longitude: number;
speed?: number;
bearing?: number;
currentStatus?: string;
}
export interface ArrivalRecord {
timestamp: number;
stopId: string;
routeId: string;
tripId?: string;
scheduledTime: number;
predictedTime?: number;
actualTime?: number;
delaySeconds?: number;
isRealtime: boolean;
headsign?: string;
}
export interface VehicleFeedSnapshot {
timestamp: number;
sourceTimestamp?: number;
vehicleId?: string;
inventoryNumber?: string;
latitude?: number;
longitude?: number;
speed?: number;
bearing?: number;
status?: number;
rawJson: string;
}
function segmentId(): string {
return new Date().toISOString().replace(/[.:]/g, '-');
}
function partitionPathFromSegmentId(id: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})T/.exec(id);
if (match) {
const [, year, month, day] = match;
return path.join(`year=${year}`, `month=${month}`, `day=${day}`);
}
const now = new Date();
const year = now.getUTCFullYear().toString();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const day = String(now.getUTCDate()).padStart(2, '0');
return path.join(`year=${year}`, `month=${month}`, `day=${day}`);
}
function buildPartitionedFilePath(prefix: string, id: string): string {
return path.join(DATA_DIR, prefix, partitionPathFromSegmentId(id), `${prefix}-${id}.parquet`);
}
function buildVehicleFile(id: string): string {
return buildPartitionedFilePath('vehicle_positions', id);
}
function buildArrivalFile(id: string): string {
return buildPartitionedFilePath('arrival_records', id);
}
function buildSnapshotFile(id: string): string {
return buildPartitionedFilePath('vehicle_snapshots', id);
}
function createFileWriter(filename: string): Writer {
const writer = new ByteWriter() as unknown as Writer & { index: number };
const chunkSize = 1_000_000;
fsSync.mkdirSync(path.dirname(filename), { recursive: true });
fsSync.writeFileSync(filename, '', { flag: 'w' });
const flush = () => {
const chunk = new Uint8Array(writer.buffer, 0, writer.index);
fsSync.writeFileSync(filename, chunk, { flag: 'a' });
writer.index = 0;
};
writer.ensure = (size: number) => {
if (writer.index > chunkSize) {
flush();
}
if (writer.index + size > writer.buffer.byteLength) {
const newSize = Math.max(writer.buffer.byteLength * 2, writer.index + size);
const newBuffer = new ArrayBuffer(newSize);
new Uint8Array(newBuffer).set(new Uint8Array(writer.buffer));
writer.buffer = newBuffer;
writer.view = new DataView(writer.buffer);
}
};
writer.getBuffer = () => {
throw new Error('getBuffer not supported for file writer');
};
writer.getBytes = () => {
throw new Error('getBytes not supported for file writer');
};
writer.finish = () => {
flush();
};
return writer;
}
function createParquetWriter(filename: string, schema: SchemaElement[]): IncrementalWriter {
return new ParquetWriter({ writer: createFileWriter(filename), schema }) as unknown as IncrementalWriter;
}
function queuedWrite(queue: Promise<void>, task: () => Promise<void>): Promise<void> {
return queue.then(task).catch((error) => {
console.error('Parquet write failed:', error);
});
}
function toInt64(value: number): bigint {
return BigInt(Math.trunc(value));
}
function toNullableInt64(value: number | undefined): bigint | null {
return value == null ? null : toInt64(value);
}
function toVehicleColumns(positions: VehiclePosition[]): ColumnSource[] {
return [
{ name: 'timestamp', data: positions.map((p) => toInt64(p.timestamp)), type: 'INT64', nullable: false },
{ name: 'vehicleId', data: positions.map((p) => p.vehicleId), type: 'STRING', nullable: false },
{ name: 'routeId', data: positions.map((p) => p.routeId), type: 'STRING', nullable: false },
{ name: 'tripId', data: positions.map((p) => p.tripId ?? null), type: 'STRING' },
{ name: 'latitude', data: positions.map((p) => p.latitude), type: 'DOUBLE', nullable: false },
{ name: 'longitude', data: positions.map((p) => p.longitude), type: 'DOUBLE', nullable: false },
{ name: 'speed', data: positions.map((p) => p.speed ?? null), type: 'DOUBLE' },
{ name: 'bearing', data: positions.map((p) => p.bearing ?? null), type: 'DOUBLE' },
{ name: 'currentStatus', data: positions.map((p) => p.currentStatus ?? null), type: 'STRING' },
];
}
function toArrivalColumns(arrivals: ArrivalRecord[]): ColumnSource[] {
return [
{ name: 'timestamp', data: arrivals.map((a) => toInt64(a.timestamp)), type: 'INT64', nullable: false },
{ name: 'stopId', data: arrivals.map((a) => a.stopId), type: 'STRING', nullable: false },
{ name: 'routeId', data: arrivals.map((a) => a.routeId), type: 'STRING', nullable: false },
{ name: 'tripId', data: arrivals.map((a) => a.tripId ?? null), type: 'STRING' },
{ name: 'scheduledTime', data: arrivals.map((a) => toInt64(a.scheduledTime)), type: 'INT64', nullable: false },
{ name: 'predictedTime', data: arrivals.map((a) => toNullableInt64(a.predictedTime)), type: 'INT64' },
{ name: 'actualTime', data: arrivals.map((a) => toNullableInt64(a.actualTime)), type: 'INT64' },
{ name: 'delaySeconds', data: arrivals.map((a) => (a.delaySeconds == null ? null : Math.trunc(a.delaySeconds))), type: 'INT32' },
{ name: 'isRealtime', data: arrivals.map((a) => a.isRealtime), type: 'BOOLEAN', nullable: false },
{ name: 'headsign', data: arrivals.map((a) => a.headsign ?? null), type: 'STRING' },
];
}
function toSnapshotColumns(snapshots: VehicleFeedSnapshot[]): ColumnSource[] {
return [
{ name: 'timestamp', data: snapshots.map((s) => toInt64(s.timestamp)), type: 'INT64', nullable: false },
{ name: 'sourceTimestamp', data: snapshots.map((s) => toNullableInt64(s.sourceTimestamp)), type: 'INT64' },
{ name: 'vehicleId', data: snapshots.map((s) => s.vehicleId ?? null), type: 'STRING' },
{ name: 'inventoryNumber', data: snapshots.map((s) => s.inventoryNumber ?? null), type: 'STRING' },
{ name: 'latitude', data: snapshots.map((s) => (s.latitude == null ? null : s.latitude)), type: 'DOUBLE' },
{ name: 'longitude', data: snapshots.map((s) => (s.longitude == null ? null : s.longitude)), type: 'DOUBLE' },
{ name: 'speed', data: snapshots.map((s) => (s.speed == null ? null : s.speed)), type: 'DOUBLE' },
{ name: 'bearing', data: snapshots.map((s) => (s.bearing == null ? null : s.bearing)), type: 'DOUBLE' },
{ name: 'status', data: snapshots.map((s) => (s.status == null ? null : Math.trunc(s.status))), type: 'INT32' },
{ name: 'rawJson', data: snapshots.map((s) => s.rawJson), type: 'STRING', nullable: false },
];
}
function startNewSegment(id: string): void {
currentSegmentId = id;
currentVehicleFile = buildVehicleFile(id);
currentArrivalFile = buildArrivalFile(id);
currentSnapshotFile = buildSnapshotFile(id);
currentSegmentRows = { vehicle: 0, arrival: 0, snapshot: 0 };
vehicleWriter = createParquetWriter(currentVehicleFile, VEHICLE_SCHEMA);
arrivalWriter = createParquetWriter(currentArrivalFile, ARRIVAL_SCHEMA);
snapshotWriter = createParquetWriter(currentSnapshotFile, SNAPSHOT_SCHEMA);
}
async function uploadFileToObjectStorage(filePath: string): Promise<boolean> {
if (!OBJECT_STORAGE_ENABLED || !s3Client || !OBJECT_STORAGE_BUCKET) {
return true;
}
const keyPrefix = OBJECT_STORAGE_PREFIX ? `${OBJECT_STORAGE_PREFIX}/` : '';
const relativePath = path.relative(DATA_DIR, filePath);
const normalizedRelativePath =
!relativePath.startsWith('..') && !path.isAbsolute(relativePath)
? relativePath.split(path.sep).join('/')
: path.basename(filePath);
const key = `${keyPrefix}${normalizedRelativePath}`;
const body = await fs.readFile(filePath);
for (let attempt = 1; attempt <= OBJECT_STORAGE_UPLOAD_RETRIES; attempt += 1) {
try {
await s3Client.send(
new PutObjectCommand({
Bucket: OBJECT_STORAGE_BUCKET,
Key: key,
Body: body,
ContentLength: body.byteLength,
ContentType: 'application/octet-stream',
})
);
console.log(`[OK] Uploaded parquet to object storage: s3://${OBJECT_STORAGE_BUCKET}/${key}`);
return true;
} catch (error) {
const status = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
const shouldRetry = !!status && status >= 500;
const canRetry = shouldRetry && attempt < OBJECT_STORAGE_UPLOAD_RETRIES;
console.error(
`[ERROR] Upload failed for ${filePath} (attempt ${attempt}/${OBJECT_STORAGE_UPLOAD_RETRIES}):`,
error
);
if (!canRetry) {
break;
}
const backoffMs = OBJECT_STORAGE_UPLOAD_RETRY_BASE_MS * attempt;
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
return false;
}
async function deleteLocalFile(filePath: string): Promise<void> {
await fs.unlink(filePath).catch((error: unknown) => {
const code = (error as { code?: string }).code;
if (code !== 'ENOENT') {
console.warn(`[WARN] Failed to delete local parquet file ${filePath}:`, error);
}
});
}
async function finalizeSegment(reason: string, reopen: boolean): Promise<void> {
if (!initialized) {
return;
}
await Promise.all([vehicleWriteQueue, arrivalWriteQueue, snapshotWriteQueue]);
const meta: SegmentMeta = {
segmentId: currentSegmentId,
vehicleFile: currentVehicleFile,
arrivalFile: currentArrivalFile,
snapshotFile: currentSnapshotFile,
vehicleRows: currentSegmentRows.vehicle,
arrivalRows: currentSegmentRows.arrival,
snapshotRows: currentSegmentRows.snapshot,
};
if (vehicleWriter) {
vehicleWriter.finish();
vehicleWriter = undefined;
}
if (arrivalWriter) {
arrivalWriter.finish();
arrivalWriter = undefined;
}
if (snapshotWriter) {
snapshotWriter.finish();
snapshotWriter = undefined;
}
const hasRows = meta.vehicleRows > 0 || meta.arrivalRows > 0 || meta.snapshotRows > 0;
try {
if (hasRows) {
const uploadResults = await Promise.all([
uploadFileToObjectStorage(meta.vehicleFile),
uploadFileToObjectStorage(meta.arrivalFile),
uploadFileToObjectStorage(meta.snapshotFile),
]);
const allUploaded = uploadResults.every(Boolean);
if (OBJECT_STORAGE_ENABLED && DELETE_LOCAL_AFTER_UPLOAD && allUploaded) {
await Promise.all([
deleteLocalFile(meta.vehicleFile),
deleteLocalFile(meta.arrivalFile),
deleteLocalFile(meta.snapshotFile),
]);
console.log(`[OK] Deleted local parquet segment after upload: ${meta.segmentId}`);
} else if (OBJECT_STORAGE_ENABLED && DELETE_LOCAL_AFTER_UPLOAD && !allUploaded) {
console.warn(`[WARN] Kept local parquet segment due to upload errors: ${meta.segmentId}`);
}
} else if (OBJECT_STORAGE_ENABLED && DELETE_LOCAL_AFTER_UPLOAD) {
await Promise.all([
deleteLocalFile(meta.vehicleFile),
deleteLocalFile(meta.arrivalFile),
deleteLocalFile(meta.snapshotFile),
]);
}
} finally {
if (reopen) {
startNewSegment(segmentId());
console.log(`[OK] Rotated parquet segment (${reason}) from ${meta.segmentId}`);
}
}
}
function enqueueRotation(reason: string, reopen: boolean): Promise<void> {
rotationQueue = rotationQueue.then(() => finalizeSegment(reason, reopen)).catch((error) => {
console.error('Parquet rotation failed:', error);
});
return rotationQueue;
}
function startRollTimer(): void {
if (rollTimer) {
clearInterval(rollTimer);
}
rollTimer = setInterval(() => {
void enqueueRotation('interval', true);
}, ROLL_INTERVAL_MS);
}
async function ensureInitialized(): Promise<void> {
if (initialized) {
return;
}
await fs.mkdir(DATA_DIR, { recursive: true });
startNewSegment(segmentId());
startRollTimer();
initialized = true;
const storageInfo = OBJECT_STORAGE_ENABLED
? `, object storage enabled (bucket=${OBJECT_STORAGE_BUCKET}, deleteLocal=${DELETE_LOCAL_AFTER_UPLOAD})`
: ', object storage disabled';
console.log(`[OK] Hyparquet storage ready at ${DATA_DIR}, roll=${ROLL_INTERVAL_MS / 60000}m${storageInfo}`);
}
export async function initStorage(): Promise<void> {
await ensureInitialized();
}
export async function initDatabase(): Promise<void> {
await initStorage();
}
export async function logVehiclePosition(position: VehiclePosition): Promise<void> {
await logVehiclePositions([position]);
}
export async function logVehiclePositions(positions: VehiclePosition[]): Promise<void> {
if (positions.length === 0) {
return;
}
await ensureInitialized();
await rotationQueue;
vehicleWriteQueue = queuedWrite(vehicleWriteQueue, async () => {
const writer = vehicleWriter;
if (!writer) {
return;
}
writer.write({
columnData: toVehicleColumns(positions),
rowGroupSize: positions.length,
});
currentSegmentRows.vehicle += positions.length;
writes.vehiclePositions += positions.length;
});
await vehicleWriteQueue;
}
export async function logArrival(arrival: ArrivalRecord): Promise<void> {
await ensureInitialized();
await rotationQueue;
arrivalWriteQueue = queuedWrite(arrivalWriteQueue, async () => {
const writer = arrivalWriter;
if (!writer) {
return;
}
writer.write({
columnData: toArrivalColumns([arrival]),
rowGroupSize: 1,
});
currentSegmentRows.arrival += 1;
writes.arrivalRecords += 1;
});
await arrivalWriteQueue;
}
export async function logVehicleFeedSnapshot(snapshots: VehicleFeedSnapshot[]): Promise<void> {
if (snapshots.length === 0) {
return;
}
await ensureInitialized();
await rotationQueue;
snapshotWriteQueue = queuedWrite(snapshotWriteQueue, async () => {
const writer = snapshotWriter;
if (!writer) {
return;
}
writer.write({
columnData: toSnapshotColumns(snapshots),
rowGroupSize: snapshots.length,
});
currentSegmentRows.snapshot += snapshots.length;
writes.vehicleSnapshots += snapshots.length;
});
await snapshotWriteQueue;
}
export async function getVehicleHistory(_vehicleId: string, _startTime: number, _endTime: number) {
return [];
}
export async function getRouteVehiclePositions(_routeId: string, _startTime: number, _endTime: number) {
return [];
}
export async function getStopArrivalHistory(_stopId: string, _routeId: string, _startTime: number, _endTime: number) {
return [];
}
export async function getRouteDelayStats(_routeId: string, _hours: number = 24) {
return {
total_arrivals: 0,
avg_delay: null,
min_delay: null,
max_delay: null,
on_time_count: 0,
late_count: 0,
early_count: 0,
};
}
export async function getStopDelayStats(_stopId: string, _hours: number = 24) {
return [];
}
export async function getRouteHourlyPattern(_routeId: string, _days: number = 7) {
return [];
}
export async function cleanupOldData(_daysToKeep: number = 90): Promise<void> {
console.log('cleanupOldData skipped: parquet segment mode');
}
export async function getStorageStats() {
await ensureInitialized();
const [vehicleInfo, arrivalInfo, snapshotInfo] = await Promise.all([
fs.stat(currentVehicleFile).catch(() => null),
fs.stat(currentArrivalFile).catch(() => null),
fs.stat(currentSnapshotFile).catch(() => null),
]);
return {
vehiclePositions: writes.vehiclePositions,
arrivalRecords: writes.arrivalRecords,
vehicleSnapshots: writes.vehicleSnapshots,
oldestRecord: null,
newestRecord: null,
storageType: 'hyparquet(rolling-write)',
host: OBJECT_STORAGE_ENABLED ? 'object-storage+local' : 'local-filesystem',
storagePath: DATA_DIR,
rolling: {
minutes: ROLL_INTERVAL_MS / 60000,
currentSegmentId,
},
objectStorage: {
enabled: OBJECT_STORAGE_ENABLED,
bucket: OBJECT_STORAGE_BUCKET ?? null,
region: OBJECT_STORAGE_REGION,
endpoint: OBJECT_STORAGE_ENDPOINT ?? null,
prefix: OBJECT_STORAGE_PREFIX,
deleteLocalAfterUpload: DELETE_LOCAL_AFTER_UPLOAD,
},
files: {
vehicle: {
path: currentVehicleFile,
bytes: vehicleInfo?.size ?? 0,
modifiedAt: vehicleInfo?.mtime ?? null,
},
arrivals: {
path: currentArrivalFile,
bytes: arrivalInfo?.size ?? 0,
modifiedAt: arrivalInfo?.mtime ?? null,
},
snapshots: {
path: currentSnapshotFile,
bytes: snapshotInfo?.size ?? 0,
modifiedAt: snapshotInfo?.mtime ?? null,
},
},
};
}
export async function getDatabaseStats() {
return getStorageStats();
}
export async function closeStorage(): Promise<void> {
if (!initialized) {
return;
}
if (rollTimer) {
clearInterval(rollTimer);
rollTimer = undefined;
}
await Promise.all([vehicleWriteQueue, arrivalWriteQueue, snapshotWriteQueue]);
await enqueueRotation('shutdown', false);
initialized = false;
}
export async function closeDatabase(): Promise<void> {
await closeStorage();
}

2058
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,11 @@
{
"name": "skopje-bus-tracker",
"name": "openjsp",
"version": "1.0.0",
"description": "Real-time bus tracking for Skopje public transport",
"main": "server.ts",
"main": "index.ts",
"scripts": {
"setup-gtfs": "npx ts-node setup-gtfs.ts",
"find": "npx ts-node find-stops-routes.ts",
"web": "npx ts-node server.ts",
"tracker": "npx ts-node bus-tracker-json.ts",
"build": "tsc",
"start": "npm run web"
"start": "bun run index.ts",
"typecheck": "bunx tsc --noEmit"
},
"keywords": [
"skopje",
@@ -23,7 +19,10 @@
"author": "",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"express": "^4.18.2",
"hyparquet": "^1.25.1",
"hyparquet-writer": "^0.13.0",
"protobufjs": "^7.5.4"
},
"optionalDependencies": {
@@ -33,7 +32,6 @@
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"protobufjs-cli": "^2.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3"
}
}

View File

@@ -1,623 +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;
}
</style>
</head>
<body>
<div class="header">
<h1>Skopje Bus Tracker</h1>
<div class="subtitle" id="routeInfo">Select stop and route to track</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>

260
server.ts
View File

@@ -1,260 +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';
const app = express();
// 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')));
// 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,
});
}
}
}
}
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`);
res.json(activeVehicles);
} catch (error) {
console.error('Error fetching vehicles:', error);
res.status(500).json({ error: 'Failed to fetch vehicles', details: String(error) });
}
});
// Start server
app.listen(config.server.port, () => {
console.log(`
===========================================================================
BUS TRACKER WEB APP
===========================================================================
Server running at: http://localhost:${config.server.port}
Open your browser and navigate to the URL above to view the map.
Features:
- Real-time bus arrivals
- Live vehicle locations on map
- Interactive map interface
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"]
}